diff --git a/.dockerignore b/.dockerignore index 18f578ca4879c..6b8710a711f3b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1 @@ -frontend/app_flowy/ -frontend/scripts/ -frontend/rust-lib/target -shared-lib/target/ \ No newline at end of file +.git diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 192fc9dd12200..af7010a11f86b 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -44,7 +44,7 @@ test "" = "$(grep '^Signed-off-by: ' "$1" | if [ $? -ne 0 ] then printError "Please fix your commit message to match AppFlowy coding standards" - printError "https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/software-contributions/submitting-code/code-submission-guidelines#commit-message-guidelines" + printError "https://docs.appflowy.io/docs/documentation/software-contributions/conventions/git-conventions" exit 1 fi diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000000..f71d51f2636bb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ + + + + +### Feature Preview + + + +--- + + + +#### PR Checklist + +- [ ] My code adheres to [AppFlowy's Conventions](https://docs.appflowy.io/docs/documentation/software-contributions/conventions) +- [ ] I've listed at least one issue that this PR fixes in the description above. +- [ ] I've added a test(s) to validate changes in this PR, or this PR only contains semantic changes. +- [ ] All existing tests are passing. diff --git a/.github/actions/flutter_build/action.yml b/.github/actions/flutter_build/action.yml new file mode 100644 index 0000000000000..bfcb501327a87 --- /dev/null +++ b/.github/actions/flutter_build/action.yml @@ -0,0 +1,102 @@ +name: Flutter Integration Test +description: Run integration tests for AppFlowy + +inputs: + os: + description: "The operating system to run the tests on" + required: true + flutter_version: + description: "The version of Flutter to use" + required: true + rust_toolchain: + description: "The version of Rust to use" + required: true + cargo_make_version: + description: "The version of cargo-make to use" + required: true + rust_target: + description: "The target to build for" + required: true + flutter_profile: + description: "The profile to build with" + required: true + +runs: + using: "composite" + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + id: rust_toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ inputs.rust_toolchain }} + target: ${{ inputs.rust_target }} + override: true + profile: minimal + + - name: Install flutter + id: flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ inputs.flutter_version }} + cache: true + + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: ${{ inputs.os }} + workspaces: | + frontend/rust-lib + cache-all-crates: true + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-make@${{ inputs.cargo_make_version }}, duckscript_cli + + - name: Install prerequisites + working-directory: frontend + shell: bash + run: | + case $RUNNER_OS in + Linux) + sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub + sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list + sudo apt-get update + sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev + ;; + Windows) + vcpkg integrate install + vcpkg update + ;; + macOS) + # No additional prerequisites needed for macOS + ;; + esac + cargo make appflowy-flutter-deps-tools + + - name: Build AppFlowy + working-directory: frontend + run: cargo make --profile ${{ inputs.flutter_profile }} appflowy-core-dev + shell: bash + + - name: Run code generation + working-directory: frontend + run: cargo make code_generation + shell: bash + + - name: Flutter Analyzer + working-directory: frontend/appflowy_flutter + run: flutter analyze . + shell: bash + + - name: Compress appflowy_flutter + run: tar -czf appflowy_flutter.tar.gz frontend/appflowy_flutter + shell: bash + + - uses: actions/upload-artifact@v4 + with: + name: ${{ github.run_id }}-${{ matrix.os }} + path: appflowy_flutter.tar.gz diff --git a/.github/actions/flutter_integration_test/action.yml b/.github/actions/flutter_integration_test/action.yml new file mode 100644 index 0000000000000..e0fa508adece2 --- /dev/null +++ b/.github/actions/flutter_integration_test/action.yml @@ -0,0 +1,78 @@ +name: Flutter Integration Test +description: Run integration tests for AppFlowy + +inputs: + test_path: + description: "The path to the integration test file" + required: true + flutter_version: + description: "The version of Flutter to use" + required: true + rust_toolchain: + description: "The version of Rust to use" + required: true + cargo_make_version: + description: "The version of cargo-make to use" + required: true + rust_target: + description: "The target to build for" + required: true + +runs: + using: "composite" + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + id: rust_toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ inputs.RUST_TOOLCHAIN }} + target: ${{ inputs.rust_target }} + override: true + profile: minimal + + - name: Install flutter + id: flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ inputs.flutter_version }} + cache: true + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-make@${{ inputs.cargo_make_version }} + + - name: Install prerequisites + working-directory: frontend + run: | + sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub + sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list + sudo apt-get update + sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev network-manager + shell: bash + + - name: Enable Flutter Desktop + run: | + flutter config --enable-linux-desktop + shell: bash + + - uses: actions/download-artifact@v4 + with: + name: ${{ github.run_id }}-ubuntu-latest + + - name: Uncompressed appflowy_flutter + run: tar -xf appflowy_flutter.tar.gz + shell: bash + + - name: Run Flutter integration tests + working-directory: frontend/appflowy_flutter + run: | + export DISPLAY=:99 + sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & + sudo apt-get install network-manager + flutter test ${{ inputs.test_path }} -d Linux --coverage + shell: bash diff --git a/.github/workflows/android_ci.yaml.bak b/.github/workflows/android_ci.yaml.bak new file mode 100644 index 0000000000000..002255d1594ff --- /dev/null +++ b/.github/workflows/android_ci.yaml.bak @@ -0,0 +1,197 @@ +name: Android CI + +on: + push: + branches: + - "main" + paths: + - ".github/workflows/mobile_ci.yaml" + - "frontend/**" + - "!frontend/appflowy_tauri/**" + + pull_request: + branches: + - "main" + paths: + - ".github/workflows/mobile_ci.yaml" + - "frontend/**" + - "!frontend/appflowy_tauri/**" + +env: + CARGO_TERM_COLOR: always + FLUTTER_VERSION: "3.22.3" + RUST_TOOLCHAIN: "1.80.1" + CARGO_MAKE_VERSION: "0.37.18" + CLOUD_VERSION: 0.6.54-amd64 + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + if: github.event.pull_request.draft != true + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Check storage space + run: + df -h + + # the following step is required to avoid running out of space + - name: Maximize build space + if: matrix.os == 'ubuntu-latest' + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo docker image prune --all --force + sudo rm -rf /opt/hostedtoolcache/codeQL + sudo rm -rf ${GITHUB_WORKSPACE}/.git + + - name: Check storage space + run: df -h + + - name: Checkout appflowy cloud code + uses: actions/checkout@v4 + with: + repository: AppFlowy-IO/AppFlowy-Cloud + path: AppFlowy-Cloud + + - name: Prepare appflowy cloud env + working-directory: AppFlowy-Cloud + run: | + # log level + cp deploy.env .env + sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env + sed -i 's/GOTRUE_EXTERNAL_GOOGLE_ENABLED=.*/GOTRUE_EXTERNAL_GOOGLE_ENABLED=true/' .env + sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env + sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env + + - name: Run Docker-Compose + working-directory: AppFlowy-Cloud + env: + APPFLOWY_CLOUD_VERSION: ${{ env.CLOUD_VERSION }} + APPFLOWY_HISTORY_VERSION: ${{ env.CLOUD_VERSION }} + APPFLOWY_WORKER_VERSION: ${{ env.CLOUD_VERSION }} + run: | + container_id=$(docker ps --filter name=appflowy-cloud-appflowy_cloud-1 -q) + if [ -z "$container_id" ]; then + echo "AppFlowy-Cloud container is not running. Pulling and starting the container..." + docker compose pull + docker compose up -d + echo "Waiting for the container to be ready..." + sleep 10 + else + running_image=$(docker inspect --format='{{index .Config.Image}}' "$container_id") + if [ "$running_image" != "appflowy-cloud:$APPFLOWY_CLOUD_VERSION" ]; then + echo "AppFlowy-Cloud is running with an incorrect version. Restarting with the correct version..." + # Remove all containers if any exist + if [ "$(docker ps -aq)" ]; then + docker rm -f $(docker ps -aq) + else + echo "No containers to remove." + fi + + # Remove all volumes if any exist + if [ "$(docker volume ls -q)" ]; then + docker volume rm $(docker volume ls -q) + else + echo "No volumes to remove." + fi + docker compose pull + docker compose up -d + echo "Waiting for the container to be ready..." + sleep 10 + docker ps -a + docker compose logs + else + echo "AppFlowy-Cloud is running with the correct version." + fi + fi + + - name: Checkout source code + uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 11 + + - name: Install Rust toolchain + id: rust_toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + override: true + profile: minimal + + - name: Install flutter + id: flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + + - uses: gradle/gradle-build-action@v3 + with: + gradle-version: 8.10 + + - uses: davidB/rust-cargo-make@v1 + with: + version: ${{ env.CARGO_MAKE_VERSION }} + + - name: Install prerequisites + working-directory: frontend + run: | + rustup target install aarch64-linux-android + rustup target install x86_64-linux-android + rustup target add armv7-linux-androideabi + cargo install --force --locked duckscript_cli + cargo install cargo-ndk + if [ "$RUNNER_OS" == "Linux" ]; then + sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub + sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list + sudo apt-get update + sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev + sudo apt-get install keybinder-3.0 libnotify-dev + sudo apt-get install gcc-multilib + elif [ "$RUNNER_OS" == "Windows" ]; then + vcpkg integrate install + elif [ "$RUNNER_OS" == "macOS" ]; then + echo 'do nothing' + fi + cargo make appflowy-flutter-deps-tools + shell: bash + + - name: Build AppFlowy + working-directory: frontend + run: | + cargo make --profile development-android appflowy-core-dev-android + cargo make --profile development-android code_generation + cd rust-lib + cargo clean + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run integration tests + # https://github.com/ReactiveCircus/android-emulator-runner + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 33 + arch: x86_64 + disk-size: 2048M + working-directory: frontend/appflowy_flutter + disable-animations: true + force-avd-creation: false + target: google_apis + script: flutter test integration_test/mobile/cloud/cloud_runner.dart diff --git a/.github/workflows/appflowy_editor_test.yml b/.github/workflows/appflowy_editor_test.yml deleted file mode 100644 index 229ef85efd628..0000000000000 --- a/.github/workflows/appflowy_editor_test.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: AppFlowyEditor test - -on: - push: - branches: - - "main" - - "release/*" - - pull_request: - branches: - - "main" - - "release/*" - paths: - - "frontend/app_flowy/packages/appflowy_editor/**" - -env: - CARGO_TERM_COLOR: always - -jobs: - tests: - strategy: - matrix: - os: [macos-latest, ubuntu-latest, windows-latest] - - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@v2 - - - uses: subosito/flutter-action@v2 - with: - channel: "stable" - flutter-version: "3.0.5" - cache: true - - - name: Run FlowyEditor tests - working-directory: frontend/app_flowy/packages/appflowy_editor - run: | - flutter pub get - flutter format --set-exit-if-changed . - flutter analyze . - flutter test --coverage - - - uses: codecov/codecov-action@v3 - with: - name: appflowy_editor - env_vars: ${{ matrix.os }} - fail_ci_if_error: true - verbose: true - diff --git a/.github/workflows/build_command.yml b/.github/workflows/build_command.yml new file mode 100644 index 0000000000000..1648953bae575 --- /dev/null +++ b/.github/workflows/build_command.yml @@ -0,0 +1,42 @@ +name: build + +on: + repository_dispatch: + types: [build-command] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: notify appflowy_builder + run: | + platform=${{ github.event.client_payload.slash_command.args.unnamed.arg1 }} + build_name=${{ github.event.client_payload.slash_command.args.named.build_name }} + branch=${{ github.event.client_payload.slash_command.args.named.ref }} + build_type="" + arch="" + + if [ "$platform" = "android" ]; then + build_type="apk" + elif [ "$platform" = "macos" ]; then + arch="universal" + fi + + params=$(jq -n \ + --arg ref "main" \ + --arg repo "LucasXu0/AppFlowy" \ + --arg branch "$branch" \ + --arg build_name "$build_name" \ + --arg build_type "$build_type" \ + --arg arch "$arch" \ + '{ref: $ref, inputs: {repo: $repo, branch: $branch, build_name: $build_name, build_type: $build_type, arch: $arch}} | del(.inputs | .. | select(. == ""))') + + echo "params: $params" + + curl -L \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.TOKEN }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/AppFlowy-IO/AppFlowy-Builder/actions/workflows/$platform.yaml/dispatches \ + -d "$params" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index 5881741061f24..0000000000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,98 +0,0 @@ -name: CI - -on: - push: - branches: - - "main" - - "release/*" - - pull_request: - branches: - - "main" - - "release/*" - -jobs: - build: - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - include: - - os: ubuntu-latest - flutter_profile: development-linux-x86_64 - - os: macos-latest - flutter_profile: development-mac-x86_64 - - os: windows-latest - flutter_profile: development-windows-x86 - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@v2 - - - id: rust_toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: "stable-2022-04-07" - - - id: flutter - uses: subosito/flutter-action@v2 - with: - channel: "stable" - cache: true - flutter-version: "3.0.5" - - - name: Cache Cargo - id: cache-cargo - uses: actions/cache@v2 - with: - path: | - ~/.cargo - key: ${{ runner.os }}-cargo-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} - - - name: Cache Rust - uses: actions/cache@v2 - with: - path: | - frontend/rust-lib/target - shared-lib/target - key: ${{ runner.os }}-rust-rust-lib-share-lib-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} - - - name: Setup Environment - run: | - if [ "$RUNNER_OS" == "Linux" ]; then - sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub - sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list - sudo apt-get update - sudo apt-get install -y dart curl build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev - sudo apt-get install keybinder-3.0 - elif [ "$RUNNER_OS" == "macOS" ]; then - echo 'do nothing' - fi - shell: bash - - - if: steps.cache-cargo.outputs.cache-hit != 'true' - name: Deps - working-directory: frontend - run: | - cargo install cargo-make - cargo install duckscript_cli - - - name: Cargo make flowy_dev - working-directory: frontend - run: | - cargo make flowy_dev - - - name: Config Flutter - run: | - if [ "$RUNNER_OS" == "Linux" ]; then - flutter config --enable-linux-desktop - elif [ "$RUNNER_OS" == "macOS" ]; then - flutter config --enable-macos-desktop - elif [ "$RUNNER_OS" == "windows" ]; then - flutter config --enable-windows-desktop - fi - shell: bash - - - name: Build - working-directory: frontend - run: | - cargo make --profile ${{ matrix.flutter_profile }} appflowy-dev diff --git a/.github/workflows/commit_lint.yml b/.github/workflows/commit_lint.yml index 4c9a5a54732a1..eb55922af23d8 100644 --- a/.github/workflows/commit_lint.yml +++ b/.github/workflows/commit_lint.yml @@ -5,8 +5,7 @@ jobs: commitlint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: wagoid/commitlint-github-action@v4 - diff --git a/.github/workflows/dart_lint.yml b/.github/workflows/dart_lint.yml deleted file mode 100644 index 678677e3d4a5e..0000000000000 --- a/.github/workflows/dart_lint.yml +++ /dev/null @@ -1,86 +0,0 @@ -# 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. - -name: Flutter lint - -on: - push: - branches: - - "main" - - "release/*" - paths: - - "frontend/app_flowy/**" - - pull_request: - branches: - - "main" - - "release/*" - paths: - - "frontend/app_flowy/**" - -env: - CARGO_TERM_COLOR: always - -jobs: - flutter-analyze: - name: flutter analyze - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - uses: subosito/flutter-action@v1 - with: - flutter-version: "3.0.5" - channel: "stable" - - uses: actions-rs/toolchain@v1 - with: - toolchain: "stable-2022-04-07" - - - name: Cache Cargo - id: cache-cargo - uses: actions/cache@v2 - with: - path: | - ~/.cargo - key: ${{ runner.os }}-cargo-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} - - - name: Cache Rust - id: cache-rust-target - uses: actions/cache@v2 - with: - path: | - frontend/rust-lib/target - shared-lib/target - key: ${{ runner.os }}-rust-rust-lib-share-lib-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} - - - if: steps.cache-cargo.outputs.cache-hit != 'true' - name: Rust Deps - working-directory: frontend - run: | - cargo install cargo-make - - - name: Cargo make flowy dev - working-directory: frontend - run: | - cargo make flowy_dev - - - name: Flutter Deps - run: flutter packages pub get - working-directory: frontend/app_flowy - - - name: Build FlowySDK - working-directory: frontend - run: | - cargo make --profile development-linux-x86_64 flowy-sdk-dev - - - name: Flutter Code Generation - working-directory: frontend/app_flowy - run: | - flutter packages pub run easy_localization:generate -f keys -o locale_keys.g.dart -S assets/translations -s en.json - flutter packages pub run build_runner build --delete-conflicting-outputs - - - name: Run Flutter Analyzer - working-directory: frontend/app_flowy - run: flutter analyze diff --git a/.github/workflows/dart_test.yml b/.github/workflows/dart_test.yml deleted file mode 100644 index 39a938628e769..0000000000000 --- a/.github/workflows/dart_test.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Frontend test - -on: - push: - branches: - - "main" - - "release/*" - paths: - - "frontend/app_flowy/**" - - pull_request: - branches: - - "main" - - "release/*" - paths: - - "frontend/app_flowy/**" - -env: - CARGO_TERM_COLOR: always - -jobs: - tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - toolchain: "stable-2022-04-07" - - - uses: subosito/flutter-action@v2 - with: - channel: "stable" - flutter-version: "3.0.5" - cache: true - - - name: Cache Cargo - uses: actions/cache@v2 - with: - path: | - ~/.cargo - key: ${{ runner.os }}-cargo-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} - - - name: Cache Rust - id: cache-rust-target - uses: actions/cache@v2 - with: - path: | - frontend/rust-lib/target - shared-lib/target - key: ${{ runner.os }}-rust-rust-lib-share-lib-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} - - - if: steps.cache-cargo.outputs.cache-hit != 'true' - name: Rust Deps - working-directory: frontend - run: | - cargo install cargo-make - cargo make flowy_dev - - - name: Flutter Deps - working-directory: frontend/app_flowy - run: | - flutter config --enable-linux-desktop - - - name: Build Test lib - working-directory: frontend - run: | - cargo make --profile test-linux build-test-lib - - - name: Flutter Code Generation - working-directory: frontend/app_flowy - run: | - flutter packages pub get - flutter packages pub run easy_localization:generate -f keys -o locale_keys.g.dart -S assets/translations -s en.json - flutter packages pub run build_runner build --delete-conflicting-outputs - - - name: Run bloc tests - working-directory: frontend/app_flowy - run: | - flutter pub get - flutter test diff --git a/.github/workflows/docker_ci.yml b/.github/workflows/docker_ci.yml new file mode 100644 index 0000000000000..51e8a2ac28dd9 --- /dev/null +++ b/.github/workflows/docker_ci.yml @@ -0,0 +1,47 @@ +name: Docker-CI + +on: + push: + branches: [ "main", "release/*" ] + pull_request: + branches: [ "main", "release/*" ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build-app: + if: github.event.pull_request.draft != true + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # cache the docker layers + # don't cache anything temporarly, because it always triggers "no space left on device" error + # - name: Cache Docker layers + # uses: actions/cache@v3 + # with: + # path: /tmp/.buildx-cache + # key: ${{ runner.os }}-buildx-${{ github.sha }} + # restore-keys: | + # ${{ runner.os }}-buildx- + + - name: Build the app + uses: docker/build-push-action@v5 + with: + context: . + file: ./frontend/scripts/docker-buildfiles/Dockerfile + push: false + # cache-from: type=local,src=/tmp/.buildx-cache + # cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max + + # - name: Move cache + # run: | + # rm -rf /tmp/.buildx-cache + # mv /tmp/.buildx-cache-new /tmp/.buildx-cache diff --git a/.github/workflows/flutter_ci.yaml b/.github/workflows/flutter_ci.yaml new file mode 100644 index 0000000000000..86d5b7ef30579 --- /dev/null +++ b/.github/workflows/flutter_ci.yaml @@ -0,0 +1,365 @@ +name: Flutter-CI + +on: + push: + branches: + - "main" + - "release/*" + paths: + - ".github/workflows/flutter_ci.yaml" + - ".github/actions/flutter_build/**" + - "frontend/rust-lib/**" + - "frontend/appflowy_flutter/**" + - "frontend/resources/**" + + pull_request: + branches: + - "main" + - "release/*" + paths: + - ".github/workflows/flutter_ci.yaml" + - ".github/actions/flutter_build/**" + - "frontend/rust-lib/**" + - "frontend/appflowy_flutter/**" + - "frontend/resources/**" + +env: + CARGO_TERM_COLOR: always + FLUTTER_VERSION: "3.22.2" + RUST_TOOLCHAIN: "1.80.1" + CARGO_MAKE_VERSION: "0.37.18" + CLOUD_VERSION: 0.6.54-amd64 + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + prepare-linux: + if: github.event.pull_request.draft != true + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest] + include: + - os: ubuntu-latest + flutter_profile: development-linux-x86_64 + target: x86_64-unknown-linux-gnu + runs-on: ${{ matrix.os }} + + steps: + # the following step is required to avoid running out of space + - name: Maximize build space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Flutter build + uses: ./.github/actions/flutter_build + with: + os: ${{ matrix.os }} + flutter_version: ${{ env.FLUTTER_VERSION }} + rust_toolchain: ${{ env.RUST_TOOLCHAIN }} + cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} + rust_target: ${{ matrix.target }} + flutter_profile: ${{ matrix.flutter_profile }} + + prepare-windows: + if: github.event.pull_request.draft != true + strategy: + fail-fast: true + matrix: + os: [windows-latest] + include: + - os: windows-latest + flutter_profile: development-windows-x86 + target: x86_64-pc-windows-msvc + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Flutter build + uses: ./.github/actions/flutter_build + with: + os: ${{ matrix.os }} + flutter_version: ${{ env.FLUTTER_VERSION }} + DISABLE_CI_TEST_LOG: "true" + rust_toolchain: ${{ env.RUST_TOOLCHAIN }} + cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} + rust_target: ${{ matrix.target }} + flutter_profile: ${{ matrix.flutter_profile }} + + prepare-macos: + if: github.event.pull_request.draft != true + strategy: + fail-fast: true + matrix: + os: [macos-latest] + include: + - os: macos-latest + flutter_profile: development-mac-x86_64 + target: x86_64-apple-darwin + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Flutter build + uses: ./.github/actions/flutter_build + with: + os: ${{ matrix.os }} + flutter_version: ${{ env.FLUTTER_VERSION }} + rust_toolchain: ${{ env.RUST_TOOLCHAIN }} + cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} + rust_target: ${{ matrix.target }} + flutter_profile: ${{ matrix.flutter_profile }} + + unit_test: + needs: [prepare-linux] + if: github.event.pull_request.draft != true + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + include: + - os: ubuntu-latest + flutter_profile: development-linux-x86_64 + target: x86_64-unknown-linux-gnu + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + id: rust_toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + target: ${{ matrix.target }} + override: true + profile: minimal + + - name: Install flutter + id: flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: ${{ matrix.os }} + workspaces: | + frontend/rust-lib + cache-all-crates: true + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-make@${{ env.CARGO_MAKE_VERSION }}, duckscript_cli + + - name: Install prerequisites + working-directory: frontend + run: | + if [ "$RUNNER_OS" == "Linux" ]; then + sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub + sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list + sudo apt-get update + sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev + fi + shell: bash + + - name: Enable Flutter Desktop + run: | + if [ "$RUNNER_OS" == "Linux" ]; then + flutter config --enable-linux-desktop + elif [ "$RUNNER_OS" == "macOS" ]; then + flutter config --enable-macos-desktop + elif [ "$RUNNER_OS" == "Windows" ]; then + git config --system core.longpaths true + flutter config --enable-windows-desktop + fi + shell: bash + + - uses: actions/download-artifact@v4 + with: + name: ${{ github.run_id }}-${{ matrix.os }} + + - name: Uncompress appflowy_flutter + run: tar -xf appflowy_flutter.tar.gz + + - name: Run flutter pub get + working-directory: frontend + run: cargo make pub_get + + - name: Run Flutter unit tests + env: + DISABLE_EVENT_LOG: true + DISABLE_CI_TEST_LOG: "true" + working-directory: frontend + run: | + if [ "$RUNNER_OS" == "macOS" ]; then + cargo make dart_unit_test + elif [ "$RUNNER_OS" == "Linux" ]; then + cargo make dart_unit_test_no_build + elif [ "$RUNNER_OS" == "Windows" ]; then + cargo make dart_unit_test_no_build + fi + shell: bash + + cloud_integration_test: + needs: [prepare-linux] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + include: + - os: ubuntu-latest + flutter_profile: development-linux-x86_64 + target: x86_64-unknown-linux-gnu + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout appflowy cloud code + uses: actions/checkout@v4 + with: + repository: AppFlowy-IO/AppFlowy-Cloud + path: AppFlowy-Cloud + + - name: Prepare appflowy cloud env + working-directory: AppFlowy-Cloud + run: | + # log level + cp deploy.env .env + sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env + sed -i 's/GOTRUE_EXTERNAL_GOOGLE_ENABLED=.*/GOTRUE_EXTERNAL_GOOGLE_ENABLED=true/' .env + sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env + sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env + + - name: Run Docker-Compose + working-directory: AppFlowy-Cloud + env: + APPFLOWY_CLOUD_VERSION: ${{ env.CLOUD_VERSION }} + APPFLOWY_HISTORY_VERSION: ${{ env.CLOUD_VERSION }} + APPFLOWY_WORKER_VERSION: ${{ env.CLOUD_VERSION }} + run: | + container_id=$(docker ps --filter name=appflowy-cloud-appflowy_cloud-1 -q) + if [ -z "$container_id" ]; then + echo "AppFlowy-Cloud container is not running. Pulling and starting the container..." + docker compose pull + docker compose up -d + echo "Waiting for the container to be ready..." + sleep 10 + else + running_image=$(docker inspect --format='{{index .Config.Image}}' "$container_id") + if [ "$running_image" != "appflowy-cloud:$APPFLOWY_CLOUD_VERSION" ]; then + echo "AppFlowy-Cloud is running with an incorrect version. Restarting with the correct version..." + # Remove all containers if any exist + if [ "$(docker ps -aq)" ]; then + docker rm -f $(docker ps -aq) + else + echo "No containers to remove." + fi + + # Remove all volumes if any exist + if [ "$(docker volume ls -q)" ]; then + docker volume rm $(docker volume ls -q) + else + echo "No volumes to remove." + fi + docker compose pull + docker compose up -d + echo "Waiting for the container to be ready..." + sleep 10 + docker ps -a + docker compose logs + else + echo "AppFlowy-Cloud is running with the correct version." + fi + fi + + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Install flutter + id: flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-make@${{ env.CARGO_MAKE_VERSION }} + + - name: Install prerequisites + working-directory: frontend + run: | + sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub + sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list + sudo apt-get update + sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev + shell: bash + + - name: Enable Flutter Desktop + run: | + flutter config --enable-linux-desktop + shell: bash + + - uses: actions/download-artifact@v4 + with: + name: ${{ github.run_id }}-${{ matrix.os }} + + - name: Uncompressed appflowy_flutter + run: | + tar -xf appflowy_flutter.tar.gz + ls -al + + - name: Run flutter pub get + working-directory: frontend + run: cargo make pub_get + + - name: Run Flutter integration tests + working-directory: frontend/appflowy_flutter + run: | + export DISPLAY=:99 + sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & + sudo apt-get install network-manager + docker ps -a + flutter test integration_test/desktop/cloud/cloud_runner.dart -d Linux --coverage + shell: bash + + integration_test: + needs: [prepare-linux] + if: github.event.pull_request.draft != true + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + test_number: [1, 2, 3, 4, 5, 6, 7, 8, 9] + include: + - os: ubuntu-latest + target: "x86_64-unknown-linux-gnu" + runs-on: ${{ matrix.os }} + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Flutter Integration Test ${{ matrix.test_number }} + uses: ./.github/actions/flutter_integration_test + with: + test_path: integration_test/desktop_runner_${{ matrix.test_number }}.dart + flutter_version: ${{ env.FLUTTER_VERSION }} + rust_toolchain: ${{ env.RUST_TOOLCHAIN }} + cargo_make_version: ${{ env.CARGO_MAKE_VERSION }} + rust_target: ${{ matrix.target }} diff --git a/.github/workflows/ios_ci.yaml b/.github/workflows/ios_ci.yaml new file mode 100644 index 0000000000000..1cdc9971808f4 --- /dev/null +++ b/.github/workflows/ios_ci.yaml @@ -0,0 +1,121 @@ +name: iOS CI + +on: + push: + branches: + - "main" + paths: + - ".github/workflows/mobile_ci.yaml" + - "frontend/**" + - "!frontend/appflowy_tauri/**" + - "!frontend/appflowy_web_app/**" + + pull_request: + branches: + - "main" + paths: + - ".github/workflows/mobile_ci.yaml" + - "frontend/**" + - "!frontend/appflowy_tauri/**" + - "!frontend/appflowy_web_app/**" + +env: + FLUTTER_VERSION: "3.22.3" + RUST_TOOLCHAIN: "1.80.1" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build-self-hosted: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: self-hosted + + steps: + - name: Checkout source code + uses: actions/checkout@v2 + + - name: Build AppFlowy + working-directory: frontend + run: | + cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios + cargo make --profile development-ios-arm64-sim code_generation + + - uses: futureware-tech/simulator-action@v3 + id: simulator-action + with: + model: "iPhone 15" + shutdown_after_job: false + + integration-tests: + if: github.event.pull_request.head.repo.full_name != github.repository + runs-on: macos-latest + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + target: aarch64-apple-ios-sim + override: true + profile: minimal + + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: macos-latest + workspaces: | + frontend/rust-lib + + - uses: davidB/rust-cargo-make@v1 + with: + version: "0.37.15" + + - name: Install prerequisites + working-directory: frontend + run: | + rustup target install aarch64-apple-ios-sim + cargo install --force --locked duckscript_cli + cargo install cargo-lipo + cargo make appflowy-flutter-deps-tools + shell: bash + + - name: Build AppFlowy + working-directory: frontend + run: | + cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios + cargo make --profile development-ios-arm64-sim code_generation + + - uses: futureware-tech/simulator-action@v3 + id: simulator-action + with: + model: "iPhone 15" + shutdown_after_job: false + + - name: Run AppFlowy on simulator + working-directory: frontend/appflowy_flutter + run: | + flutter run -d ${{ steps.simulator-action.outputs.udid }} & + pid=$! + sleep 500 + kill $pid + continue-on-error: true + + # Integration tests + - name: Run integration tests + working-directory: frontend/appflowy_flutter + # The integration tests are flaky and sometimes fail with "Connection timed out": + # Don't block the CI. If the tests fail, the CI will still pass. + # Instead, we're using Code Magic to re-run the tests to check if they pass. + continue-on-error: true + run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }} diff --git a/.github/workflows/mobile_ci.yml b/.github/workflows/mobile_ci.yml new file mode 100644 index 0000000000000..4606a6779924f --- /dev/null +++ b/.github/workflows/mobile_ci.yml @@ -0,0 +1,83 @@ +name: Mobile-CI + +on: + workflow_dispatch: + inputs: + branch: + description: "Branch to build" + required: true + default: "main" + workflow_id: + description: "Codemagic workflow ID" + required: true + default: "ios-workflow" + type: choice + options: + - ios-workflow + - android-workflow + +env: + CODEMAGIC_API_TOKEN: ${{ secrets.CODEMAGIC_API_TOKEN }} + APP_ID: "6731d2f427e7c816080c3674" + +jobs: + trigger-mobile-build: + runs-on: ubuntu-latest + steps: + - name: Trigger Codemagic Build + id: trigger_build + run: | + RESPONSE=$(curl -X POST \ + --header "Content-Type: application/json" \ + --header "x-auth-token: $CODEMAGIC_API_TOKEN" \ + --data '{ + "appId": "${{ env.APP_ID }}", + "workflowId": "${{ github.event.inputs.workflow_id }}", + "branch": "${{ github.event.inputs.branch }}" + }' \ + https://api.codemagic.io/builds) + + BUILD_ID=$(echo $RESPONSE | jq -r '.buildId') + echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT + echo "build_id=$BUILD_ID" + + - name: Wait for build and check status + id: check_status + run: | + while true; do + curl -X GET \ + --header "Content-Type: application/json" \ + --header "x-auth-token: $CODEMAGIC_API_TOKEN" \ + https://api.codemagic.io/builds/${{ steps.trigger_build.outputs.build_id }} > /tmp/response.json + + RESPONSE_WITHOUT_COMMAND=$(cat /tmp/response.json | jq 'walk(if type == "object" and has("subactions") then .subactions |= map(del(.command)) else . end)') + STATUS=$(echo $RESPONSE_WITHOUT_COMMAND | jq -r '.build.status') + + if [ "$STATUS" = "finished" ]; then + SUCCESS=$(echo $RESPONSE_WITHOUT_COMMAND | jq -r '.success') + BUILD_URL=$(echo $RESPONSE_WITHOUT_COMMAND | jq -r '.buildUrl') + echo "status=$STATUS" >> $GITHUB_OUTPUT + echo "success=$SUCCESS" >> $GITHUB_OUTPUT + echo "build_url=$BUILD_URL" >> $GITHUB_OUTPUT + break + elif [ "$STATUS" = "failed" ]; then + echo "status=failed" >> $GITHUB_OUTPUT + break + fi + + sleep 60 + done + + - name: Slack Notification + uses: 8398a7/action-slack@v3 + if: always() + with: + status: ${{ steps.check_status.outputs.success == 'true' && 'success' || 'failure' }} + fields: repo,message,commit,author,action,eventName,ref,workflow,job,took + text: | + Mobile CI Build Result + Branch: ${{ github.event.inputs.branch }} + Workflow: ${{ github.event.inputs.workflow_id }} + Build URL: ${{ steps.check_status.outputs.build_url }} + env: + SLACK_WEBHOOK_URL: ${{ secrets.RELEASE_SLACK_WEBHOOK }} diff --git a/.github/workflows/ninja_i18n.yml b/.github/workflows/ninja_i18n.yml new file mode 100644 index 0000000000000..8473f8f06997f --- /dev/null +++ b/.github/workflows/ninja_i18n.yml @@ -0,0 +1,25 @@ +name: Ninja i18n action + +on: + pull_request_target: + +# explicitly configure permissions, in case your GITHUB_TOKEN workflow permissions are set to read-only in repository settings +permissions: + pull-requests: write + +jobs: + ninja-i18n: + name: Ninja i18n - GitHub Lint Action + runs-on: ubuntu-latest + + steps: + - name: Checkout + id: checkout + uses: actions/checkout@v4 + + - name: Run Ninja i18n + id: ninja-i18n + uses: opral/ninja-i18n-action@main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e5a38cb98926..86ad99c6dcbf5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,11 @@ name: release on: push: tags: - - '*' + - "*" + +env: + FLUTTER_VERSION: "3.22.0" + RUST_TOOLCHAIN: "1.80.1" jobs: create-release: @@ -14,7 +18,7 @@ jobs: upload_url: ${{ steps.create_release.outputs.upload_url }} steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Build release notes run: | @@ -31,96 +35,171 @@ jobs: release_name: v${{ github.ref }} body_path: ${{ env.RELEASE_NOTES_PATH }} - build-linux-x86: - runs-on: ubuntu-latest + # the package name should be with the format: AppFlowy--- + + build-for-windows: + name: ${{ matrix.job.target }} (${{ matrix.job.os }}) needs: create-release env: - LINUX_APP_RELEASE_PATH: frontend/app_flowy/product/${{ github.ref_name }}/linux/Release - LINUX_ZIP_NAME: AppFlowy-linux-x86.tar.gz + WINDOWS_APP_RELEASE_PATH: frontend\appflowy_flutter\product\${{ github.ref_name }}\windows + WINDOWS_ZIP_NAME: AppFlowy-${{ github.ref_name }}-windows-x86_64.zip + WINDOWS_INSTALLER_NAME: AppFlowy-${{ github.ref_name }}-windows-x86_64 + runs-on: ${{ matrix.job.os }} + strategy: + fail-fast: false + matrix: + job: + - { target: x86_64-pc-windows-msvc, os: windows-2019 } steps: - - name: Checkout - uses: actions/checkout@v2 + - name: Checkout source code + uses: actions/checkout@v4 - - name: Setup environment - Rust and Cargo - uses: actions-rs/toolchain@v1 + - name: Install flutter + uses: subosito/flutter-action@v2 with: - toolchain: 'stable-2022-04-07' + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} - - name: Setup environment - Flutter - uses: subosito/flutter-action@v2 + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 with: - channel: 'stable' - flutter-version: '3.0.5' + toolchain: ${{ env.RUST_TOOLCHAIN }} + target: ${{ matrix.job.target }} + override: true + components: rustfmt + profile: minimal - - name: Pre build + - name: Install prerequisites working-directory: frontend run: | - sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub - sudo apt-get update - sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev - sudo apt-get install keybinder-3.0 - source $HOME/.cargo/env - cargo install --force cargo-make - cargo install --force duckscript_cli - cargo make flowy_dev + vcpkg integrate install + cargo install --force --locked cargo-make + cargo install --force --locked duckscript_cli - - name: Build Linux app + - name: Build Windows app working-directory: frontend + # the cargo make script has to be run separately because of file locking issues run: | - flutter config --enable-linux-desktop - cargo make --env APP_VERSION=${{ github.ref_name }} --profile production-linux-x86_64 appflowy + flutter config --enable-windows-desktop + dart ./scripts/flutter_release_build/build_flowy.dart exclude-directives . ${{ github.ref_name }} + cargo make --env APP_VERSION=${{ github.ref_name }} --profile production-windows-x86 appflowy + dart ./scripts/flutter_release_build/build_flowy.dart include-directives . ${{ github.ref_name }} - - name: Upload Release Asset + - name: Archive Asset + uses: vimtor/action-zip@v1 + with: + files: ${{ env.WINDOWS_APP_RELEASE_PATH }}\ + dest: ${{ env.WINDOWS_APP_RELEASE_PATH }}\${{ env.WINDOWS_ZIP_NAME }} + + - name: Copy installer config & icon file + working-directory: frontend + run: | + cp scripts/windows_installer/* ../${{ env.WINDOWS_APP_RELEASE_PATH }} + + - name: Build installer executable + working-directory: ${{ env.WINDOWS_APP_RELEASE_PATH }} + run: | + iscc /F${{ env.WINDOWS_INSTALLER_NAME }} inno_setup_config.iss /DAppVersion=${{ github.ref_name }} + + - name: Upload Asset id: upload-release-asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_ZIP_NAME }} - asset_name: ${{ env.LINUX_ZIP_NAME }} + asset_path: ${{ env.WINDOWS_APP_RELEASE_PATH }}\${{ env.WINDOWS_ZIP_NAME }} + asset_name: ${{ env.WINDOWS_ZIP_NAME }} + asset_content_type: application/octet-stream + + - name: Upload Installer Asset + id: upload-installer-asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ${{ env.WINDOWS_APP_RELEASE_PATH }}\Output\${{ env.WINDOWS_INSTALLER_NAME }}.exe + asset_name: ${{ env.WINDOWS_INSTALLER_NAME }}.exe asset_content_type: application/octet-stream - build-macos-x86_64: - runs-on: macos-latest + build-for-macOS-x86_64: + name: ${{ matrix.job.target }} (${{ matrix.job.os }}) [${{ matrix.job.extra-build-args }}] + runs-on: ${{ matrix.job.os }} needs: create-release env: - MACOS_APP_RELEASE_PATH: frontend/app_flowy/product/${{ github.ref_name }}/macos/Release - MACOS_X86_ZIP_NAME: Appflowy-macos-x86_64.zip + MACOS_APP_RELEASE_PATH: frontend/appflowy_flutter/product/${{ github.ref_name }}/macos/Release + MACOS_X86_ZIP_NAME: AppFlowy-${{ github.ref_name }}-macos-x86_64.zip + MACOS_DMG_NAME: AppFlowy-${{ github.ref_name }}-macos-x86_64 + strategy: + fail-fast: false + matrix: + job: + - { target: x86_64-apple-darwin, os: macos-13, extra-build-args: "" } steps: - - name: Checkout - uses: actions/checkout@v2 + - name: Checkout source code + uses: actions/checkout@v4 - - name: Setup environment - Rust and Cargo - uses: actions-rs/toolchain@v1 + - name: Install flutter + uses: subosito/flutter-action@v2 with: - toolchain: 'stable-2022-04-07' + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} - - name: Setup environment - Flutter - uses: subosito/flutter-action@v2 + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 with: - channel: 'stable' - flutter-version: '3.0.5' + toolchain: ${{ env.RUST_TOOLCHAIN }} + target: ${{ matrix.job.target }} + override: true + components: rustfmt + profile: minimal - - name: Pre build + - name: Install prerequisites working-directory: frontend run: | - source $HOME/.cargo/env - cargo install --force cargo-make - cargo install --force duckscript_cli - cargo make flowy_dev + cargo install --force --locked cargo-make + cargo install --force --locked duckscript_cli - - name: Build macOS app for x86_64 + - name: Build AppFlowy working-directory: frontend run: | flutter config --enable-macos-desktop - cargo make --env APP_VERSION=${{ github.ref_name }} --profile production-mac-x86_64 appflowy + dart ./scripts/flutter_release_build/build_flowy.dart run . ${{ github.ref_name }} + + - name: Codesign AppFlowy + run: | + echo ${{ secrets.MACOS_CERTIFICATE }} | base64 --decode > certificate.p12 + security create-keychain -p action build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p action build.keychain + security import certificate.p12 -k build.keychain -P ${{ secrets.MACOS_CERTIFICATE_PWD }} -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k action build.keychain + /usr/bin/codesign --force --options runtime --deep --sign "${{ secrets.MACOS_CODESIGN_ID }}" "${{ env.MACOS_APP_RELEASE_PATH }}/AppFlowy.app" -v + + - name: Create macOS dmg + run: | + brew install create-dmg + create-dmg \ + --volname ${{ env.MACOS_DMG_NAME }} \ + --hide-extension "AppFlowy.app" \ + --background frontend/scripts/dmg_assets/AppFlowyInstallerBackground.jpg \ + --window-size 600 450 \ + --icon-size 94 \ + --icon "AppFlowy.app" 141 249 \ + --app-drop-link 458 249 \ + "${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg" \ + "${{ env.MACOS_APP_RELEASE_PATH }}/AppFlowy.app" - - name: Archive macOS app + - name: Notarize AppFlowy + run: | + xcrun notarytool submit ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg --apple-id ${{ secrets.MACOS_NOTARY_USER }} --team-id ${{ secrets.MACOS_TEAM_ID }} --password ${{ secrets.MACOS_NOTARY_PWD }} -v -f "json" --wait + + - name: Archive Asset working-directory: ${{ env.MACOS_APP_RELEASE_PATH }} run: zip --symlinks -qr ${{ env.MACOS_X86_ZIP_NAME }} AppFlowy.app - - name: Upload Release Asset + - name: Upload Asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -130,53 +209,305 @@ jobs: asset_name: ${{ env.MACOS_X86_ZIP_NAME }} asset_content_type: application/octet-stream - build-windows-x86_64: - runs-on: windows-latest + - name: Upload DMG Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg + asset_name: ${{ env.MACOS_DMG_NAME }}.dmg + asset_content_type: application/octet-stream + + build-for-macOS-universal: + name: ${{ matrix.job.target }} (${{ matrix.job.os }}) [${{ matrix.job.extra-build-args }}] + runs-on: ${{ matrix.job.os }} needs: create-release env: - WINDOWS_APP_RELEASE_PATH: frontend\app_flowy\product\${{ github.ref_name }}\windows - WINDOWS_ZIP_NAME: AppFlowy-windows-x86_64.zip + MACOS_APP_RELEASE_PATH: frontend/appflowy_flutter/product/${{ github.ref_name }}/macos/Release + MACOS_AARCH64_ZIP_NAME: AppFlowy-${{ github.ref_name }}-macos-universal.zip + MACOS_DMG_NAME: AppFlowy-${{ github.ref_name }}-macos-universal + strategy: + fail-fast: false + matrix: + job: + - { + targets: "aarch64-apple-darwin,x86_64-apple-darwin", + os: macos-latest, + extra-build-args: "", + } steps: - - name: Checkout - uses: actions/checkout@v3 + - name: Checkout source code + uses: actions/checkout@v4 - - name: Setup environment - Rust and Cargo - uses: actions-rs/toolchain@v1 + - name: Install flutter + uses: subosito/flutter-action@v2 with: - toolchain: 'stable-2022-04-07' + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} - - name: Setup environment - Flutter - uses: subosito/flutter-action@v2 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable with: - channel: 'stable' - flutter-version: '3.0.5' + toolchain: ${{ env.RUST_TOOLCHAIN }} + targets: ${{ matrix.job.targets }} + components: rustfmt - - name: Pre build + - name: Install prerequisites working-directory: frontend run: | - vcpkg integrate install - cargo install --force cargo-make - cargo install --force duckscript_cli - cargo make flowy_dev + cargo install --force --locked cargo-make + cargo install --force --locked duckscript_cli - - name: Build Windows app + - name: Build AppFlowy working-directory: frontend run: | - flutter config --enable-windows-desktop - cargo make --env APP_VERSION=${{ github.ref_name }} --profile production-windows-x86 appflowy + flutter config --enable-macos-desktop + sh scripts/flutter_release_build/build_universal_package_for_macos.sh ${{ github.ref_name }} + + - name: Codesign AppFlowy + run: | + echo ${{ secrets.MACOS_CERTIFICATE }} | base64 --decode > certificate.p12 + security create-keychain -p action build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p action build.keychain + security import certificate.p12 -k build.keychain -P ${{ secrets.MACOS_CERTIFICATE_PWD }} -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k action build.keychain + /usr/bin/codesign --force --options runtime --deep --sign "${{ secrets.MACOS_CODESIGN_ID }}" "${{ env.MACOS_APP_RELEASE_PATH }}/AppFlowy.app" -v + + - name: Create macOS dmg + run: | + brew install create-dmg + create-dmg \ + --volname ${{ env.MACOS_DMG_NAME }} \ + --hide-extension "AppFlowy.app" \ + --background frontend/scripts/dmg_assets/AppFlowyInstallerBackground.jpg \ + --window-size 600 450 \ + --icon-size 94 \ + --icon "AppFlowy.app" 141 249 \ + --app-drop-link 458 249 \ + "${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg" \ + "${{ env.MACOS_APP_RELEASE_PATH }}/AppFlowy.app" + + - name: Notarize AppFlowy + run: | + xcrun notarytool submit ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg --apple-id ${{ secrets.MACOS_NOTARY_USER }} --team-id ${{ secrets.MACOS_TEAM_ID }} --password ${{ secrets.MACOS_NOTARY_PWD }} -v -f "json" --wait + + - name: Archive Asset + working-directory: ${{ env.MACOS_APP_RELEASE_PATH }} + run: zip --symlinks -qr ${{ env.MACOS_AARCH64_ZIP_NAME }} AppFlowy.app - - uses: vimtor/action-zip@v1 + - name: Upload Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - files: ${{ env.WINDOWS_APP_RELEASE_PATH }}\ - dest: ${{ env.WINDOWS_APP_RELEASE_PATH }}\${{ env.WINDOWS_ZIP_NAME }} + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_AARCH64_ZIP_NAME }} + asset_name: ${{ env.MACOS_AARCH64_ZIP_NAME }} + asset_content_type: application/octet-stream + + - name: Upload DMG Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg + asset_name: ${{ env.MACOS_DMG_NAME }}.dmg + asset_content_type: application/octet-stream - - name: Upload Release Asset + build-for-linux: + name: ${{ matrix.job.target }} (${{ matrix.job.os }}) [${{ matrix.job.extra-build-args }}] + runs-on: ${{ matrix.job.os }} + needs: create-release + env: + LINUX_APP_RELEASE_PATH: frontend/appflowy_flutter/product/${{ github.ref_name }}/linux/Release + LINUX_ZIP_NAME: AppFlowy-${{ matrix.job.target }}-x86_64.tar.gz + LINUX_PACKAGE_DEB_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.deb + LINUX_PACKAGE_RPM_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.rpm + LINUX_PACKAGE_TMP_RPM_NAME: AppFlowy-${{ github.ref_name }}-2.x86_64.rpm + LINUX_PACKAGE_TMP_APPIMAGE_NAME: AppFlowy-${{ github.ref_name }}-x86_64.AppImage + LINUX_PACKAGE_APPIMAGE_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.AppImage + LINUX_PACKAGE_ZIP_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.tar.gz + + strategy: + fail-fast: false + matrix: + job: + - { + arch: x86_64, + target: x86_64-unknown-linux-gnu, + os: ubuntu-20.04, + extra-build-args: "", + flutter_profile: production-linux-x86_64, + } + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Install flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + target: ${{ matrix.job.target }} + override: true + components: rustfmt + profile: minimal + + - name: Install prerequisites + working-directory: frontend + run: | + sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub + sudo apt-get update + sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev + sudo apt-get install keybinder-3.0 + sudo apt-get install -y alien libnotify-dev + source $HOME/.cargo/env + cargo install --force --locked cargo-make + cargo install --force --locked duckscript_cli + rustup target add ${{ matrix.job.target }} + + - name: Install gcc-aarch64-linux-gnu + if: ${{ matrix.job.target == 'aarch64-unknown-linux-gnu' }} + working-directory: frontend + run: | + sudo apt-get install -qy binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libgtk-3-0 + + - name: Build AppFlowy + working-directory: frontend + run: | + flutter config --enable-linux-desktop + dart ./scripts/flutter_release_build/build_flowy.dart run . ${{ github.ref_name }} + + - name: Archive Asset + working-directory: ${{ env.LINUX_APP_RELEASE_PATH }} + run: tar -czf ${{ env.LINUX_ZIP_NAME }} * + + - name: Build Linux package (.deb) + working-directory: frontend + run: | + sh scripts/linux_distribution/deb/build_deb.sh appflowy_flutter/product/${{ github.ref_name }}/linux/Release ${{ github.ref_name }} ${{ env.LINUX_PACKAGE_DEB_NAME }} + + - name: Build Linux package (.rpm) + working-directory: ${{ env.LINUX_APP_RELEASE_PATH }} + run: | + sudo alien -r ${{ env.LINUX_PACKAGE_DEB_NAME }} + cp -r ${{ env.LINUX_PACKAGE_TMP_RPM_NAME }} ${{ env.LINUX_PACKAGE_RPM_NAME }} + + - name: Build Linux package (.AppImage) + working-directory: frontend + continue-on-error: true + run: | + sh scripts/linux_distribution/appimage/build_appimage.sh ${{ github.ref_name }} + cd .. + cp -r frontend/${{ env.LINUX_PACKAGE_TMP_APPIMAGE_NAME }} ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_PACKAGE_APPIMAGE_NAME }} + + - name: Upload Asset id: upload-release-asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: ${{ env.WINDOWS_APP_RELEASE_PATH }}\${{ env.WINDOWS_ZIP_NAME }} - asset_name: ${{ env.WINDOWS_ZIP_NAME }} + asset_path: ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_ZIP_NAME }} + asset_name: ${{ env.LINUX_PACKAGE_ZIP_NAME }} asset_content_type: application/octet-stream + + - name: Upload Debian package + id: upload-release-asset-install-package-deb + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_PACKAGE_DEB_NAME }} + asset_name: ${{ env.LINUX_PACKAGE_DEB_NAME }} + asset_content_type: application/octet-stream + + - name: Upload RPM package + id: upload-release-asset-install-package-rpm + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_PACKAGE_RPM_NAME }} + asset_name: ${{ env.LINUX_PACKAGE_RPM_NAME }} + asset_content_type: application/octet-stream + + - name: Upload AppImage package + id: upload-release-asset-install-package-appimage + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_PACKAGE_APPIMAGE_NAME }} + asset_name: ${{ env.LINUX_PACKAGE_APPIMAGE_NAME }} + asset_content_type: application/octet-stream + + build-for-docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./frontend/scripts/docker-buildfiles/Dockerfile + builder: ${{ steps.buildx.outputs.name }} + push: true + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_client:${{ github.ref_name }} + cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/af_build_cache:buildcache + cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/af_build_cache:buildcache,mode=max + + notify-failure: + runs-on: ubuntu-latest + needs: + - build-for-macOS-x86_64 + - build-for-windows + - build-for-linux + if: failure() + steps: + - uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + text: | + 🔴🔴🔴Workflow ${{ github.workflow }} in repository ${{ github.repository }} was failed 🔴🔴🔴. + fields: repo,message,author,eventName,ref,workflow + env: + SLACK_WEBHOOK_URL: ${{ secrets.RELEASE_SLACK_WEBHOOK }} + if: always() + + notify-discord: + runs-on: ubuntu-latest + needs: + [ + build-for-linux, + build-for-windows, + build-for-macOS-x86_64, + build-for-macOS-universal, + ] + steps: + - name: Notify Discord + run: | + curl -H "Content-Type: application/json" -d '{"username": "release@appflowy", "content": "🎉 AppFlowy ${{ github.ref_name }} is available. https://github.com/AppFlowy-IO/AppFlowy/releases/tag/'${{ github.ref_name }}'"}' "https://discord.com/api/webhooks/${{ secrets.DISCORD }}" + shell: bash diff --git a/.github/workflows/release_docker.yml b/.github/workflows/release_docker.yml deleted file mode 100644 index 5c64195ef3803..0000000000000 --- a/.github/workflows/release_docker.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: release_docker - -on: - push: - branches: - - 'main' - tags: - - '*' - -jobs: - build-docker-image: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Login to Docker Hub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Build and push - uses: docker/build-push-action@v2 - with: - context: . - file: ./frontend/scripts/docker-buildfiles/Dockerfile - builder: ${{ steps.buildx.outputs.name }} - push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_client:${{ github.ref_name }} - cache-from: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/af_build_cache:buildcache - cache-to: type=registry,ref=${{ secrets.DOCKER_HUB_USERNAME }}/af_build_cache:buildcache,mode=max diff --git a/.github/workflows/rust_ci.yaml b/.github/workflows/rust_ci.yaml new file mode 100644 index 0000000000000..a67fd9d6ef145 --- /dev/null +++ b/.github/workflows/rust_ci.yaml @@ -0,0 +1,128 @@ +name: Rust-CI + +on: + push: + branches: + - "main" + - "develop" + - "release/*" + paths: + - "frontend/rust-lib/**" + - ".github/workflows/rust_ci.yaml" + + pull_request: + branches: + - "main" + - "develop" + - "release/*" + +env: + CARGO_TERM_COLOR: always + CLOUD_VERSION: 0.8.3-amd64 + RUST_TOOLCHAIN: "1.80.1" + +jobs: + ubuntu-job: + runs-on: ubuntu-latest + steps: + - name: Set timezone for action + uses: szenius/set-timezone@v2.0 + with: + timezoneLinux: "US/Pacific" + + - name: Maximize build space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo docker image prune --all --force + + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + override: true + components: rustfmt, clippy + profile: minimal + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: ${{ runner.os }} + cache-on-failure: true + workspaces: | + frontend/rust-lib + + - name: Checkout appflowy cloud code + uses: actions/checkout@v4 + with: + repository: AppFlowy-IO/AppFlowy-Cloud + path: AppFlowy-Cloud + + - name: Prepare appflowy cloud env + working-directory: AppFlowy-Cloud + run: | + cp deploy.env .env + sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env + sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env + sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env + + - name: Ensure AppFlowy-Cloud is Running with Correct Version + working-directory: AppFlowy-Cloud + env: + APPFLOWY_CLOUD_VERSION: ${{ env.CLOUD_VERSION }} + APPFLOWY_HISTORY_VERSION: ${{ env.CLOUD_VERSION }} + APPFLOWY_WORKER_VERSION: ${{ env.CLOUD_VERSION }} + run: | + # Remove all containers if any exist + if [ "$(docker ps -aq)" ]; then + docker rm -f $(docker ps -aq) + else + echo "No containers to remove." + fi + + # Remove all volumes if any exist + if [ "$(docker volume ls -q)" ]; then + docker volume rm $(docker volume ls -q) + else + echo "No volumes to remove." + fi + + docker compose pull + docker compose up -d + echo "Waiting for the container to be ready..." + sleep 10 + docker ps -a + docker compose logs + + - name: Run rust-lib tests + working-directory: frontend/rust-lib + env: + RUST_LOG: info + RUST_BACKTRACE: 1 + af_cloud_test_base_url: http://localhost + af_cloud_test_ws_url: ws://localhost/ws/v1 + af_cloud_test_gotrue_url: http://localhost/gotrue + run: | + DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart" + + - name: rustfmt rust-lib + run: cargo fmt --all -- --check + working-directory: frontend/rust-lib/ + + - name: clippy rust-lib + run: cargo clippy --all-targets -- -D warnings + working-directory: frontend/rust-lib + + - name: "Debug: show Appflowy-Cloud container logs" + if: failure() + working-directory: AppFlowy-Cloud + run: | + docker compose logs appflowy_cloud + + - name: Clean up Docker images + run: | + docker image prune -af + docker volume prune -f diff --git a/.github/workflows/rust_coverage.yml b/.github/workflows/rust_coverage.yml index 512796cb932aa..94a80aa95a4bb 100644 --- a/.github/workflows/rust_coverage.yml +++ b/.github/workflows/rust_coverage.yml @@ -1,4 +1,4 @@ -name: Rust coverage tests +name: Rust code coverage on: push: @@ -7,67 +7,55 @@ on: - "release/*" paths: - "frontend/rust-lib/**" - - "shared-lib/**" - - pull_request: - branches: - - "main" - - "release/*" - paths: - - "frontend/rust-lib/**" - - "shared-lib/**" env: CARGO_TERM_COLOR: always - + FLUTTER_VERSION: "3.22.0" + RUST_TOOLCHAIN: "1.80.1" jobs: - test-coverage: + tests: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v2 - - - id: rust_toolchain + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + id: rust_toolchain uses: actions-rs/toolchain@v1 with: - toolchain: 'stable-2022-04-07' - - - name: Cache Cargo - uses: actions/cache@v2 - with: - path: | - ~/.cargo - key: ${{ runner.os }}-cargo-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} - - - name: Cache Rust - uses: actions/cache@v2 - with: - path: | - frontend/rust-lib/target - shared-lib/target - key: ${{ runner.os }}-rust-rust-lib-share-lib-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} + toolchain: ${{ env.RUST_TOOLCHAIN }} + target: ${{ matrix.job.target }} + override: true + profile: minimal + + - name: Install flutter + id: flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true - - name: Setup Environment + - name: Install prerequisites + working-directory: frontend run: | - if [ "$RUNNER_OS" == "Linux" ]; then - sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub - sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list - sudo apt-get update - sudo apt-get install -y dart curl build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev - sudo apt-get install keybinder-3.0 - elif [ "$RUNNER_OS" == "macOS" ]; then - echo 'do nothing' - fi - shell: bash + cargo install --force --locked cargo-make + cargo install --force --locked duckscript_cli + + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: ${{ matrix.job.os }} - - name: Install cargo-make, grcov and llvm-tools-preview + - name: Install code-coverage tools working-directory: frontend run: | - cargo install cargo-make + sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub + sudo apt-get update + sudo apt-get install keybinder-3.0 cargo install grcov rustup component add llvm-tools-preview - - name: Run Coverage tests and generate LCOV report + - name: Run tests working-directory: frontend - run: cargo make get_ci_test_coverage + run: cargo make rust_unit_test_with_coverage diff --git a/.github/workflows/rust_lint.yml b/.github/workflows/rust_lint.yml deleted file mode 100644 index 4ea8ba40a2ddf..0000000000000 --- a/.github/workflows/rust_lint.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Rust lint - -on: - push: - branches: - - "main" - - "release/*" - paths: - - "frontend/rust-lib/**" - - "shared-lib/**" - - pull_request: - branches: - - "main" - - "release/*" - paths: - - "frontend/rust-lib/**" - - "shared-lib/**" - -env: - CARGO_TERM_COLOR: always - -jobs: - rust-fmt: - name: Rustfmt - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - toolchain: 'stable-2022-04-07' - override: true - - uses: subosito/flutter-action@v1 - with: - flutter-version: '3.0.5' - channel: "stable" - - - name: Rust Deps - working-directory: frontend - run: | - cargo install cargo-make - cargo make flowy_dev - - - name: Build FlowySDK - working-directory: frontend - run: | - cargo make --profile development-linux-x86_64 flowy-sdk-dev - - - run: rustup component add rustfmt - working-directory: frontend/rust-lib - - name: rustfmt - run: cargo fmt --all -- --check - working-directory: frontend/rust-lib/ - - - - run: rustup component add clippy - working-directory: frontend/rust-lib - - name: clippy - run: cargo clippy --no-default-features - working-directory: frontend/rust-lib - diff --git a/.github/workflows/rust_test.yml b/.github/workflows/rust_test.yml deleted file mode 100644 index 3f49d5085b6f5..0000000000000 --- a/.github/workflows/rust_test.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Backend test - -on: - push: - branches: - - "main" - - "release/*" - paths: - - "frontend/rust-lib/**" - - "shared-lib/**" - - pull_request: - branches: - - "main" - - "release/*" - paths: - - "frontend/rust-lib/**" - - "shared-lib/**" - -env: - CARGO_TERM_COLOR: always - -jobs: - tests: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - - id: rust_toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: 'stable-2022-04-07' - - - name: Cache Cargo - uses: actions/cache@v2 - with: - path: | - ~/.cargo - key: ${{ runner.os }}-cargo-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} - - - name: Cache Rust - uses: actions/cache@v2 - with: - path: | - frontend/rust-lib/target - shared-lib/target - key: ${{ runner.os }}-rust-rust-lib-share-lib-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} - - - name: Install cargo-make - working-directory: frontend - run: cargo install cargo-make - - - name: Run rust-lib tests - working-directory: frontend/rust-lib - run: RUST_LOG=info cargo test --no-default-features --features="sync" - - - name: Run shared-lib tests - working-directory: shared-lib - run: RUST_LOG=info cargo test --no-default-features diff --git a/.github/workflows/tauri2_ci.yaml b/.github/workflows/tauri2_ci.yaml new file mode 100644 index 0000000000000..b4b9183d0151f --- /dev/null +++ b/.github/workflows/tauri2_ci.yaml @@ -0,0 +1,98 @@ +name: Tauri-CI + +on: + pull_request: + paths: + - ".github/workflows/tauri2_ci.yaml" + - "frontend/rust-lib/**" + paths-ignore: + - "frontend/appflowy_web_app/**" + - "frontend/resources/**" + +env: + NODE_VERSION: "18.16.0" + PNPM_VERSION: "8.5.0" + RUST_TOOLCHAIN: "1.80.1" + CARGO_MAKE_VERSION: "0.36.6" + CI: true + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + tauri-build-ubuntu: + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v4 + - name: Maximize build space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo docker image prune --all --force + sudo rm -rf /opt/hostedtoolcache/codeQL + sudo rm -rf ${GITHUB_WORKSPACE}/.git + sudo rm -rf $ANDROID_HOME/ndk + + - name: setup node + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: setup pnpm + uses: pnpm/action-setup@v2 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Install Rust toolchain + id: rust_toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + override: true + profile: minimal + + - name: Node_modules cache + uses: actions/cache@v2 + with: + path: frontend/appflowy_web_app/node_modules + key: node-modules-${{ runner.os }} + + - name: install dependencies + working-directory: frontend + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-make@${{ env.CARGO_MAKE_VERSION }} + + - name: install tauri deps tools + working-directory: frontend + run: | + cargo make appflowy-tauri-deps-tools + shell: bash + + - name: install frontend dependencies + working-directory: frontend/appflowy_web_app + run: | + mkdir dist + pnpm install + cd src-tauri && cargo build + + - name: test and lint + working-directory: frontend/appflowy_web_app + run: | + pnpm run lint:tauri + + - uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tauriScript: pnpm tauri + projectPath: frontend/appflowy_web_app + args: "--debug" diff --git a/.github/workflows/tauri_ci.yaml b/.github/workflows/tauri_ci.yaml new file mode 100644 index 0000000000000..7d9c67e25a9a1 --- /dev/null +++ b/.github/workflows/tauri_ci.yaml @@ -0,0 +1,111 @@ +name: Tauri-CI +on: + push: + branches: + - build/tauri + +env: + NODE_VERSION: "18.16.0" + PNPM_VERSION: "8.5.0" + RUST_TOOLCHAIN: "1.80.1" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + tauri-build: + if: github.event.pull_request.draft != true + strategy: + fail-fast: false + matrix: + platform: [ubuntu-20.04] + + runs-on: ${{ matrix.platform }} + + env: + CI: true + steps: + - uses: actions/checkout@v4 + + - name: Maximize build space (ubuntu only) + if: matrix.platform == 'ubuntu-20.04' + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo docker image prune --all --force + sudo rm -rf /opt/hostedtoolcache/codeQL + sudo rm -rf ${GITHUB_WORKSPACE}/.git + sudo rm -rf $ANDROID_HOME/ndk + + - name: setup node + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: setup pnpm + uses: pnpm/action-setup@v2 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Install Rust toolchain + id: rust_toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + override: true + profile: minimal + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./frontend/appflowy_tauri/src-tauri -> target" + + - name: Node_modules cache + uses: actions/cache@v2 + with: + path: frontend/appflowy_tauri/node_modules + key: node-modules-${{ runner.os }} + + - name: install dependencies (windows only) + if: matrix.platform == 'windows-latest' + working-directory: frontend + run: | + cargo install --force --locked duckscript_cli + vcpkg integrate install + + - name: install dependencies (ubuntu only) + if: matrix.platform == 'ubuntu-20.04' + working-directory: frontend + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf + + - name: install cargo-make + working-directory: frontend + run: | + cargo install --force --locked cargo-make + cargo make appflowy-tauri-deps-tools + + - name: install frontend dependencies + working-directory: frontend/appflowy_tauri + run: | + mkdir dist + pnpm install + cargo make --cwd .. tauri_build + + - name: frontend tests and linting + working-directory: frontend/appflowy_tauri + run: | + pnpm test + pnpm test:errors + + - uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tauriScript: pnpm tauri + projectPath: frontend/appflowy_tauri + args: "--debug" diff --git a/.github/workflows/tauri_release.yml b/.github/workflows/tauri_release.yml new file mode 100644 index 0000000000000..4612d5c3aacc4 --- /dev/null +++ b/.github/workflows/tauri_release.yml @@ -0,0 +1,152 @@ +name: Publish Tauri Release + +on: + workflow_dispatch: + inputs: + branch: + description: "The branch to release" + required: true + default: "main" + version: + description: "The version to release" + required: true + default: "0.0.0" +env: + NODE_VERSION: "18.16.0" + PNPM_VERSION: "8.5.0" + RUST_TOOLCHAIN: "1.80.1" + +jobs: + publish-tauri: + permissions: + contents: write + strategy: + fail-fast: false + matrix: + settings: + - platform: windows-latest + args: "--verbose" + target: "windows-x86_64" + - platform: macos-latest + args: "--target x86_64-apple-darwin" + target: "macos-x86_64" + - platform: ubuntu-20.04 + args: "--target x86_64-unknown-linux-gnu" + target: "linux-x86_64" + + runs-on: ${{ matrix.settings.platform }} + + env: + CI: true + PACKAGE_PREFIX: AppFlowy_Tauri-${{ github.event.inputs.version }}-${{ matrix.settings.target }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.branch }} + + - name: Maximize build space (ubuntu only) + if: matrix.settings.platform == 'ubuntu-20.04' + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo docker image prune --all --force + sudo rm -rf /opt/hostedtoolcache/codeQL + sudo rm -rf ${GITHUB_WORKSPACE}/.git + sudo rm -rf $ANDROID_HOME/ndk + + - name: setup node + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: setup pnpm + uses: pnpm/action-setup@v2 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Install Rust toolchain + id: rust_toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + override: true + profile: minimal + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./frontend/appflowy_tauri/src-tauri -> target" + + - name: install dependencies (windows only) + if: matrix.settings.platform == 'windows-latest' + working-directory: frontend + run: | + cargo install --force --locked duckscript_cli + vcpkg integrate install + + - name: install dependencies (ubuntu only) + if: matrix.settings.platform == 'ubuntu-20.04' + working-directory: frontend + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf + + - name: install cargo-make + working-directory: frontend + run: | + cargo install --force --locked cargo-make + cargo make appflowy-tauri-deps-tools + + - name: install frontend dependencies + working-directory: frontend/appflowy_tauri + run: | + mkdir dist + pnpm install + pnpm exec node scripts/update_version.cjs ${{ github.event.inputs.version }} + cargo make --cwd .. tauri_build + + - uses: tauri-apps/tauri-action@dev + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APPLE_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.MACOS_TEAM_ID }} + APPLE_ID: ${{ secrets.MACOS_NOTARY_USER }} + APPLE_TEAM_ID: ${{ secrets.MACOS_TEAM_ID }} + APPLE_PASSWORD: ${{ secrets.MACOS_NOTARY_PWD }} + CI: true + with: + args: ${{ matrix.settings.args }} + appVersion: ${{ github.event.inputs.version }} + tauriScript: pnpm tauri + projectPath: frontend/appflowy_tauri + + - name: Upload EXE package(windows only) + uses: actions/upload-artifact@v4 + if: matrix.settings.platform == 'windows-latest' + with: + name: ${{ env.PACKAGE_PREFIX }}.exe + path: frontend/appflowy_tauri/src-tauri/target/release/bundle/nsis/AppFlowy_${{ github.event.inputs.version }}_x64-setup.exe + + - name: Upload DMG package(macos only) + uses: actions/upload-artifact@v4 + if: matrix.settings.platform == 'macos-latest' + with: + name: ${{ env.PACKAGE_PREFIX }}.dmg + path: frontend/appflowy_tauri/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/AppFlowy_${{ github.event.inputs.version }}_x64.dmg + + - name: Upload Deb package(ubuntu only) + uses: actions/upload-artifact@v4 + if: matrix.settings.platform == 'ubuntu-20.04' + with: + name: ${{ env.PACKAGE_PREFIX }}.deb + path: frontend/appflowy_tauri/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/app-flowy_${{ github.event.inputs.version }}_amd64.deb + + - name: Upload AppImage package(ubuntu only) + uses: actions/upload-artifact@v4 + if: matrix.settings.platform == 'ubuntu-20.04' + with: + name: ${{ env.PACKAGE_PREFIX }}.AppImage + path: frontend/appflowy_tauri/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/app-flowy_${{ github.event.inputs.version }}_amd64.AppImage diff --git a/.github/workflows/translation_notify.yml b/.github/workflows/translation_notify.yml index 00f1576b6f007..84142424c737d 100644 --- a/.github/workflows/translation_notify.yml +++ b/.github/workflows/translation_notify.yml @@ -3,7 +3,7 @@ on: push: branches: [ main ] paths: - - "frontend/app_flowy/assets/translations/en.json" + - "frontend/appflowy_flutter/assets/translations/en.json" jobs: Discord-Notify: @@ -13,4 +13,6 @@ jobs: env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} with: - args: '@appflowytranslators English UI strings has been updated.' + args: | + @appflowytranslators English UI strings has been updated. + Link to changes: ${{github.event.compare}} diff --git a/.github/workflows/web2_ci.yaml b/.github/workflows/web2_ci.yaml new file mode 100644 index 0000000000000..c52f71dd84943 --- /dev/null +++ b/.github/workflows/web2_ci.yaml @@ -0,0 +1,75 @@ +name: Web-CI +on: + pull_request: + paths: + - ".github/workflows/web2_ci.yaml" + - "frontend/appflowy_web_app/**" + - "frontend/resources/**" +env: + NODE_VERSION: "18.16.0" + PNPM_VERSION: "8.5.0" +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + web-build: + if: github.event.pull_request.draft != true + strategy: + fail-fast: false + matrix: + platform: [ ubuntu-20.04 ] + + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v4 + - name: Maximize build space (ubuntu only) + if: matrix.platform == 'ubuntu-20.04' + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo docker image prune --all --force + sudo rm -rf /opt/hostedtoolcache/codeQL + sudo rm -rf ${GITHUB_WORKSPACE}/.git + sudo rm -rf $ANDROID_HOME/ndk + - name: setup node + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + - name: setup pnpm + uses: pnpm/action-setup@v2 + with: + version: ${{ env.PNPM_VERSION }} + - name: Node_modules cache + uses: actions/cache@v2 + with: + path: frontend/appflowy_web_app/node_modules + key: node-modules-${{ runner.os }} + + - name: install frontend dependencies + working-directory: frontend/appflowy_web_app + run: | + pnpm install + - name: Run lint check + working-directory: frontend/appflowy_web_app + run: | + pnpm run lint + + - name: build and analyze + working-directory: frontend/appflowy_web_app + run: | + pnpm run analyze >> analyze-size.txt + - name: Upload analyze-size.txt + uses: actions/upload-artifact@v4 + with: + name: analyze-size.txt + path: frontend/appflowy_web_app/analyze-size.txt + retention-days: 30 + - name: Upload stats.html + uses: actions/upload-artifact@v4 + with: + name: stats.html + path: frontend/appflowy_web_app/dist/stats.html + retention-days: 30 diff --git a/.github/workflows/web_coverage.yaml b/.github/workflows/web_coverage.yaml new file mode 100644 index 0000000000000..7803f719c9cb2 --- /dev/null +++ b/.github/workflows/web_coverage.yaml @@ -0,0 +1,65 @@ +name: Web Code Coverage + +on: + pull_request: + paths: + - ".github/workflows/web2_ci.yaml" + - "frontend/appflowy_web_app/**" + - "frontend/resources/**" + +env: + NODE_VERSION: "18.16.0" + PNPM_VERSION: "8.5.0" +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + test: + if: github.event.pull_request.draft != true + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Maximize build space (ubuntu only) + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo docker image prune --all --force + sudo rm -rf /opt/hostedtoolcache/codeQL + sudo rm -rf ${GITHUB_WORKSPACE}/.git + sudo rm -rf $ANDROID_HOME/ndk + - name: setup node + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + - name: setup pnpm + uses: pnpm/action-setup@v2 + with: + version: ${{ env.PNPM_VERSION }} + # Install pnpm dependencies, cache them correctly + # and run all Cypress tests + - name: Cypress run + uses: cypress-io/github-action@v6 + with: + working-directory: frontend/appflowy_web_app + component: true + build: pnpm run build + start: pnpm run start + browser: chrome + + - name: Jest run + working-directory: frontend/appflowy_web_app + run: | + pnpm run test:unit + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 + with: + token: cf9245e0-e136-4e21-b0ee-35755fa0c493 + files: frontend/appflowy_web_app/coverage/jest/lcov.info,frontend/appflowy_web_app/coverage/cypress/lcov.info + flags: appflowy_web_app + name: frontend/appflowy_web_app + fail_ci_if_error: true + verbose: true + diff --git a/.gitignore b/.gitignore index 3d544c712eebe..33de28002aae3 100644 --- a/.gitignore +++ b/.gitignore @@ -19,20 +19,26 @@ node_modules **/.cache **/.DS_Store -**/src/protobuf **/resources/proto -frontend/.vscode/* -!frontend/.vscode/settings.json -!frontend/.vscode/tasks.json -!frontend/.vscode/launch.json -!frontend/.vscode/extensions.json -!frontend/.vscode/*.code-snippets +# ignore settings.json +frontend/.vscode/settings.json # Commit the highest level pubspec.lock, but ignore the others pubspec.lock -!frontend/app_flowy/pubspec.lock +!frontend/appflowy_flutter/pubspec.lock # ignore tool used for commit linting .githooks/gitlint .githooks/gitlint.exe +.fvm/ + +**/AppFlowy-Collab/ + +# ignore generated assets +frontend/package +frontend/*.deb + +**/Cargo.toml.bak + +**/.cargo/** \ No newline at end of file diff --git a/.run/ProtoBuf_Gen.run.xml b/.run/ProtoBuf_Gen.run.xml deleted file mode 100644 index ab3331fd3e9eb..0000000000000 --- a/.run/ProtoBuf_Gen.run.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - \ No newline at end of file diff --git a/.run/Run backend.run.xml b/.run/Run backend.run.xml deleted file mode 100644 index 840a367809c67..0000000000000 --- a/.run/Run backend.run.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - \ No newline at end of file diff --git a/.run/dart-event.run.xml b/.run/dart-event.run.xml deleted file mode 100644 index 911316db88f95..0000000000000 --- a/.run/dart-event.run.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fcb348030c57..0e4f1e5931f77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,55 +1,884 @@ # Release Notes +## Version 0.7.9 - 25/12/2024 +### New Features + +### Bug Fixes + +## Version 0.7.8 - 18/12/2024 +### New Features +image + +- Meet Simple Table 2.0: + - Insert a list into a table cell + - Insert images, quotes, callouts, and code blocks into a table cell + - Drag to move rows or columns + - Toggle header rows or columns on/off + - Distribute columns evenly + - Adjust to page width +- Enjoy a new UI/UX for a seamless experience +- Revamped mention page interactions in AI Chat +- Improved AppFlowy AI service + +### Bug Fixes +- Fixed an error when opening files in the database in local mode +- Fixed arrow up/down navigation not working for selecting a language in Code Block +- Fixed an issue where deleting multiple blocks using the drag button on the document page didn’t work + +## Version 0.7.7 - 09/12/2024 +### Bug Fixes +- Fixed sidebar menu resize regression +- Fixed AI chat loading issues +- Fixed inability to open local files in database +- Fixed mentions remaining in notifications after removal from document +- Fixed event card closing when clicking on empty space +- Fixed keyboard shortcut issues + +## Version 0.7.6 - 03/12/2024 +### New Features +- Revamped the simple table UI +- Added support for capturing images from camera on mobile +### Bug Fixes +- Improved markdown rendering capabilities in AI writer +- Fixed an issue where pressing Enter on a collapsed toggle list would add an unnecessary new line +- Fixed an issue where creating a document from slash menu could insert content at incorrect position + +## Version 0.7.5 - 25/11/2024 +### Bug Fixes +- Improved chat response parsing +- Fixed toggle list icon direction for RTL mode +- Fixed cross blocks formatting not reflecting in float toolbar +- Fixed unable to click inside the toggle list to create a new paragraph +- Fixed open file error 50 on macOS +- Fixed upload file exceed limit error + +## Version 0.7.4 - 19/11/2024 +### New Features +- Support uploading WebP and BMP images +- Support managing workspaces on mobile +- Support adding toggle headings on mobile +- Improve the AI chat page UI +### Bug Fixes +- Optimized the workspace menu loading performance +- Optimized tab switching performance +- Fixed searching issues in Document page + +## Version 0.7.3 - 07/11/2024 +### New Features +- Enable custom URLs for published pages +- Support toggling headings +- Create a subpage by typing in the document +- Turn selected blocks into a subpage +- Add a manual date picker for the Date property + +### Bug Fixes +- Fixed an issue where the workspace owner was unable to delete spaces created by others +- Fixed cursor height inconsistencies with text height +- Fixed editing issues in Kanban cards +- Fixed an issue preventing images or files from being dropped into empty paragraphs + +## Version 0.7.2 - 22/10/2024 +### New Features +- Copy link to block +- Support turn into in document +- Enable sharing links and publishing pages on mobile +- Enable drag and drop in row documents +- Right-click on page in sidebar to open more actions +- Create new subpage in document using `+` character +- Allow reordering checklist item + +### Bug Fixes +- Fixed issue with inability to cancel inline code format in French IME +- Fixed delete with Shift or Ctrl shortcuts not working in documents +- Fixed the issues with incorrect time zone being used in filters. + +## Version 0.7.1 - 07/10/2024 +### New Features +- Copy link to share and open it in a browser +- Enable the ability to edit the page title within the body of the document +- Filter by last modified, created at, or a date range +- Allow customization of database property icons +- Support CTRL/CMD+X to delete the current line when the selection is collapsed in the document +- Support window tiling on macOS +- Add filters to grid views on mobile +- Create and manage workspaces on mobile +- Automatically convert property types for imported CSV files + +### Bug Fixes +- Fixed calculations with filters applied +- Fixed issues with importing data folders into a cloud account +- Fixed French IME backtick issues +- Fixed selection gesture bugs on mobile + +## Version 0.7.0 - 19/09/2024 +### New Features +- Support reordering blocks in document with drag and drop +- Support for adding a cover to a row/card in databases +- Added support for accessing settings on the sign-in page +- Added "Move to" option to the document menu in top right corner +- Support for adjusting the document width from settings +- Show full name of a group on hover +- Colored group names in kanban boards +- Support "Ask AI" on multiple lines of text +- Support for keyboard gestures to move cursor on Mobile +- Added markdown support for quickly inserting a code block using three backticks + +### Bug Fixes +- Fixed a critical bug where the backtick character would crash the application +- Fixed an issue with signing-in from the settings dialog where the dialog would persist +- Fixed a visual bug with icon alignment in primary cell of database rows +- Fixed a bug with filters applied where new rows were inserted in wrong position +- Fixed a bug where "Untitled" would override the name of the row +- Fixed page title not updating after renaming from "More"-menu +- Fixed File block breaking row detail document +- Fixed issues with reordering rows with sorting rules applied +- Improvements to the File & Media type in Database +- Performance improvement in Grid view +- Fixed filters sometimes not applying properly in databases + +## Version 0.6.9 - 09/09/2024 +### New Features +- Added a new property type, 'Files & media' +- Supported Apple Sign-in +- Displayed the page icon next to the row name when the row page contains nested notes +- Enabled Delete Account in Settings +- Included a collapsible navigation menu in your published site + +### Bug Fixes +- Fixed the space name color issue in the community themes +- Fixed database filters and sorting issues +- Fixed the issue of not being able to fully display the title on Kanban cards +- Fixed the inability to see the entire text of a checklist item when it's more than one line long +- Fixed hide/unhide buttons in the No Status group +- Fixed the inability to edit group names on Kanban boards +- Made error codes more user-friendly +- Added leading zeros to day and month in date format + +## Version 0.6.8 - 22/08/2024 +### New Features +- Enabled viewing data inside a database record on mobile. +- Added the ability to invite members to a workspace on mobile. +- Introduced Ask AI in the Home tab on mobile. +- Import CSV files with up to 1,000 rows. +- Convert properties from one type to another while preserving the data. +- Optimized the speed of opening documents and databases. +- Improved syncing performance across devices. +- Added support for a monochrome app icon on Android. + +### Bug Fixes +- Removed the Wayland header from the AppImage build. +- Fixed the issue where pasting a web image on mobile failed. +- Corrected the Local AI state when switching between different workspaces. +- Fixed high CPU usage when opening large databases. + +## Version 0.6.7 - 13/08/2024 +### New Features +- Redesigned the icon picker design on Desktop. +- Redesigned the notification page on Mobile. + +### Bug Fixes +- Enhance the toolbar tooltip functionality on Desktop. +- Enhance the slash menu user experience on Desktop. +- Fixed the issue where list style overrides occurred during text pasting. +- Fixed the issue where linking multiple databases in the same document could cause random loss of focus. + +## Version 0.6.6 - 30/07/2024 +### New Features +- Upgrade your workspace to a premium plan to unlock more features and storage. +- Image galleries and drag-and-drop image support in documents. + +### Bug Fixes +- Fix minor UI issues on Desktop and Mobile. + +## Version 0.6.5 - 24/07/2024 +### New Features +- Publish a Database to the Web + +## Version 0.6.4 - 16/07/2024 +### New Features +- Enhanced the message style on the AI chat page. +- Added the ability to choose cursor color and selection color from a palette in settings page. +### Bug Fixes +- Optimized the performance for loading recent pages. +- Fixed an issue where the cursor would jump randomly when typing in the document title on mobile. + +## Version 0.6.3 - 08/07/2024 +### New Features +- Publish a Document to the Web + +## Version 0.6.2 - 01/07/2024 +### New Features +- Added support for duplicating spaces. +- Added support for moving pages across spaces. +- Undo markdown formatting with `Ctrl + Z` or `Cmd + Z`. +- Improved shortcuts settings UI. +### Bug Fixes +- Fixed unable to zoom in with `Ctrl` and `+` or `Cmd` and `+` on some keyboards. +- Fixed unable to paste nested lists in existing lists. + +## Version 0.6.1 - 22/06/2024 +### New Features +- Introduced the "Space" feature to help you organize your pages more efficiently. +### Bug Fixes +- Resolved shortcut conflicts on the board page. +- Resolved an issue where underscores could cause the editor to freeze. + +## Version 0.6.0 - 19/06/2024 +### New Features +- Introduced the "Space" feature to help you organize your pages more efficiently. +### Bug Fixes +- Resolved shortcut conflicts on the board page. +- Resolved an issue where underscores could cause the editor to freeze. + +## Version 0.5.9 - 06/06/2024 +### New Features +- Revamped the sidebar for both Desktop and Mobile. +- Added support for embedding videos in documents. +- Introduced a hotkey (Cmd/Ctrl + 0) to reset the app scale. +- Supported searching the workspace by page title. +### Bug Fixes +- Fixed the issue preventing the use of Backspace to delete words in Kanban boards. + +## Version 0.5.8 - 05/20/2024 +### New Features +- Improvement to the Callout block to insert new lines +- New settings page "Manage data" replaced the "Files" page +- New settings page "Workspace" replaced the "Appearance" and "Language" pages +- A custom implementation of a title bar for Windows users +- Added support for selecting Cards in kanban and performing grouped keyboard shortcuts +- Added support for default system font family +- Support for scaling the application up/down using a keyboard shortcut (CMD/CTRL + PLUS/MINUS) + +### Bug Fixes +- Resolved and refined the UI on Mobile +- Resolved issue with text editing in database +- Improved appearance of empty text cells in kanban/calendar +- Resolved an issue where a page's more actions (delete, duplicate) did not work properly +- Resolved and inconsistency in padding on get started screen on Desktop + +## Version 0.5.7 - 05/10/2024 +### Bug Fixes +- Resolved page opening issue on Android. +- Fixed text input inconsistency on Kanban board cards. + +## Version 0.5.6 - 05/07/2024 +### New Features +- Team collaboration is live! Add members to your workspace to edit and collaborate on pages together. +- Collaborate in real time on the same page with other members. Edits made by others will appear instantly. +- Create multiple workspaces for different kinds of content. +- Customize your entire page on mobile through the Page Style menu with options for layout, font, font size, emoji, and cover image. +- Open a row record as a full page. +### Bug Fixes +- Resolved issue with setting background color for the Simple Table block. +- Adjusted toolbar for various screen sizes. +- Added a request for photo permission before uploading images on mobile. +- Exported creation and last modification timestamps to CSV. + +## Version 0.5.5 - 04/24/2024 +### New Features +- Improved the display of code blocks with line numbers +- Added support for signing in using Magic Link +### Bug Fixes +- Fixed the database synchronization indicator issue +- Resolved the issue with opening the mentioned page on mobile +- Cleared the collaboration status when the user exits AppFlowy + +## Version 0.5.4 - 04/08/2024 +### New Features +- Introduced support for displaying a synchronization indicator within documents and databases to enhance user awareness of data sync status +- Revamped the select option cell editor in database +- Improved translations for Spanish, German, Kurdish, and Vietnamese +- Supported Android 6 and newer versions +### Bug Fixes +- Resolved an issue where twelve-hour time formats were not being parsed correctly in databases +- Fixed a bug affecting the user interface of the single select option filter +- Fixed various minor UI issues + +## Version 0.5.3 - 03/21/2024 +### New Features +- Added build support for 32-bit Android devices +- Introduced filters for KanBan boards for enhanced organization +- Introduced the new "Relations" column type in Grids +- Expanded language support with the addition of Greek +- Enhanced toolbar design for Mobile devices +- Introduced a command palette feature with initial support for page search +### Bug Fixes +- Rectified the issue of incomplete row data in Grids when adding new rows with active filters +- Enhanced the logic governing the filtering of number and select/multi-select fields for improved accuracy +- Implemented UI refinements on both Desktop and Mobile platforms, enriching the overall user experience of AppFlowy + +## Version 0.5.2 - 03/13/2024 +### Bug Fixes +- Import csv file. + +## Version 0.5.1 - 03/11/2024 +### New Features +- Introduced support for performing generic calculations on databases. +- Implemented functionality for easily duplicating calendar events. +- Added the ability to duplicate fields with cell data, facilitating smoother data management. +- Now supports customizing font styles and colors prior to typing. +- Enhanced the checklist user experience with the integration of keyboard shortcuts. +- Improved the dark mode experience on mobile devices. +### Bug Fixes +- Fixed an issue with some pages failing to sync properly. +- Fixed an issue where links without the http(s) scheme could not be opened, ensuring consistent link functionality. +- Fixed an issue that prevented numbers from being inserted before heading blocks. +- Fixed the inline page reference update mechanism to accurately reflect workspace changes. +- Fixed an issue that made it difficult to resize images in certain cases. +- Enhanced image loading reliability by clearing the image cache when images fail to load. +- Resolved a problem preventing the launching of URLs on some Linux distributions. + +## Version 0.5.0 - 02/26/2024 +### New Features +- Added support for scaling text on mobile platforms for better readability. +- Introduced a toggle for favorites directly from the documents' top bar. +- Optimized the image upload process and added error messaging for failed uploads. +- Implemented depth control for outline block components. +- New checklist task creation is now more intuitive, with prompts appearing on hover over list items in the row detail page. +- Enhanced sorting capabilities, allowing reordering and addition of multiple sorts. +- Expanded sorting and filtering options to include more field types like checklist, creation time, and modification time. +- Added support for field calculations within databases. +### Bug Fixes +- Fixed an issue where inserting an image from Unsplash in local mode was not possible. +- Fixed undo/redo functionality in lists. +- Fixed data loss issues when converting between block types. +- Fixed a bug where newly created rows were not being automatically sorted. +- Fixed issues related to deleting a sorting field or sort not removing existing sorts properly. +### Notes +- Windows 7, Windows 8, and iOS 11 are not yet supported due to the upgrade to Flutter 3.22.0. + +## Version 0.4.9 - 02/17/2024 +### Bug Fixes +- Resolved the issue that caused users to be redirected to the Sign In page + +## Version 0.4.8 - 02/13/2024 +### Bug Fixes +- Fixed a possible error when loading workspaces + +## Version 0.4.6 - 02/03/2024 +### Bug Fixes +- Fixed refresh token bug + +## Version 0.4.5 - 02/01/2024 +### Bug Fixes +- Fixed WebSocket connection issue + +## Version 0.4.4 - 01/31/2024 +### New Features +- Added functionality for uploading images to cloud storage. +- Enabled anonymous sign-in option for mobile platform users. +- Introduced the ability to customize cloud settings directly from the startup page. +- Added support for inserting reminders on the mobile platform. +- Overhauled the user interface on mobile devices, including improvements to the action bottom sheet, editor toolbar, database details page, and app bar. +- Implemented a shortcut (F2 key) to rename the current view. + +### Bug Fixes +- Fixed an issue where the font family was not displaying correctly on the mobile platform. +- Resolved a problem with the mobile row detail title not updating correctly. +- Fixed issues related to deleting images and refactored the image actions menu for better usability. +- Fixed other known issues. + +# Release Notes +## Version 0.4.3 - 01/16/2024 +### Bug Fixes +- Fixed file name too long issue + +## Version 0.4.2 - 01/15/2024 +AppFlowy for Android is available to download on GitHub. +If you’ve been using our desktop app, it’s important to read [this guide](https://docs.appflowy.io/docs/guides/sync-desktop-and-mobile) before logging into the mobile app. +### New Features +- Enhanced RTL (Right-to-Left) support for mobile platforms. +- Optimized selection gesture system on mobile. +- Optimized the mobile toolbar menu. +- Improved reference menu (‘@’ menu). +- Updated privacy policy. +- Improved the data import process for AppFlowy by implementing a progress indicator and compressing the data to enhance efficiency. +- Enhanced the utilization of local disk space to optimize storage consumption. +### Bug Fixes +- Fixed sign-in cancellation issue on mobile. +- Resolved keyboard close bug on Android. + + +## Version 0.4.1 - 01/03/2024 +### Bug fixes +- Fix import AppFlowy data folder + +## Version 0.4.0 - 12/30/2023 +1. Added capability to import data from an AppFlowy data folder. For detailed information, please see [AppFlowy Data Storage Documentation](https://docs.appflowy.io/docs/appflowy/product/data-storage). +2. Enhanced user interface and fixed various bugs. +3. Improved the efficiency of data synchronization in AppFlowy Cloud + +## Version 0.3.9.1 - 12/07/2023 + +### Bug fixes +- Fix potential blank pages that may occur in an empty document + +## Version 0.3.9 - 12/07/2023 + +### New Features +- Support inserting a new field to the left or right of an existing one + +### Bug fixes +- Fix some emojis are shown in black/white +- Fix unable to rename a subpage of subpage + +## Version 0.3.8 - 11/13/2023 + +### New Features +- Support hiding any stack in a board +- Support customizing page icons in menu +- Display visual hint when card contains notes +- Quick action for adding new stack to a board +- Support more ways of inserting page references in documents +- Shift + click on a checkbox to power toggle its children + +### Bug fixes +- Improved color of the "Share"-button text +- Text overflow issue in Calendar properties +- Default font (Roboto) added to application +- Placeholder added for the editor inside a Card +- Toggle notifications in settings have been fixed +- Dialog for linking board/grid/calendar opens in correct position +- Quick add Card in Board at top, correctly adds a new Card at the top + +## Version 0.3.7 - 10/30/2023 + +### New Features +- Support showing checklist items inline in row page. +- Support inserting date from slash menu. +- Support renaming a stack directly by clicking on the stack name. +- Show the detailed reminder content in the notification center. +- Save card order in Board view. +- Allow to hide the ungrouped stack. +- Segmented the checklist progress bar. + +### Bug fixes +- Optimize side panel animation. +- Fix calendar with hidden date or title doesn't show options correctly. +- Fix the horizontal scroll bar disappears in Grid view. +- Improve setting tab UI in Grid view. +- Improve theme of the code block. +- Fix some UI issues. + +## Version 0.3.6 - 10/16/2023 + +### New Features +- Support setting Markdown styles through keyboard shortcuts. +- Added Ukrainian language. +- Support auto-hiding sidebar feature, ensuring a streamlined view even when resizing to a smaller window. +- Support toggling the notifitcation on/off. +- Added Lemonade theme. + +### Bug fixes +- Improve Vietnamese translations. +- Improve reminder feature. +- Fix some UI issues. + +## Version 0.3.5 - 10/09/2023 + +### New Features +- Added support for browsing and inserting images from Unsplash. +- Revamp and unify the emoji picker throughout AppFlowy. + +### Bug fixes +- Improve layout of the settings page. +- Improve design of the restore page banner. +- Improve UX of the reminders. +- Other UI fixes. + +## Version 0.3.4 - 10/02/2023 + +### New Features +- Added support for creating a reminder. +- Added support for finding and replacing in the document page. +- Added support for showing the hidden fields in row detail page. +- Adjust the toolbar style in RTL mode. + +### Bug fixes +- Improve snackbar UI design. +- Improve dandelion theme. +- Improve id-ID and pl-PL language translations. + +## Version 0.3.3 - 09/24/2023 + +### New Features +- Added an end date field to the time cell in the database. +- Added Support for customizing the font family from GoogleFonts in the editor. +- Set the uploaded image to cover by default. +- Added Support for resetting the user icon on settings page +- Add Urdu language translations. + +### Bug fixes +- Default colors for the blocks except for the callout were not transparent. +- Option/Alt + click to add a block above didn't work on the first line. +- Unable to paste HTML content containing `` tag. +- Unable to select the text from anywhere in the line. +- The selection in the editor didn't clear when editing the inline database. +- Added a bottom border to new property column in the database. +- Set minimum width of 50px for grid fields. + +## Version 0.3.2 - 09/18/2023 + +### New Features + +- Improve the performance of the editor, now it is much faster when editing a large document. +- Support for reordering the rows of the database on Windows. +- Revamp the row detail page of the database. +- Revamp the checklist cell editor of the database. + +### Bug fixes + +- Some UI issues + +## Version 0.3.1 - 09/04/2023 + +### New Features + +- Improve CJK (Chinese, Japanese, Korean) input method support. +- Share a database in CSV format. +- Support for aligning the block component with the toolbar. +- Support for editing name when creating a new page. +- Support for inserting a table in the document page. +- Database views allow for independent field visibility toggling. + +### Bug fixes + +- Paste multiple lines in code block. +- Some UI issues + +## Version 0.3.0 - 08/22/2023 + +### New Features + +- Improve paste features: + - Paste HTML content from website. + - Paste image from clipboard. + +- Support Group by Date in Kanban Board. +- Notarize the macOS package, which is now verified by Apple. +- Add Persian language translations. + +### Bug fixes + +- Some UI issues + +## Version 0.2.9 - 08/08/2023 + +### New Features + +- Improve tab and shortcut, click with alt/option to open a page in new tab. +- Improve database tab bar UI. + +### Bug fixes + +- Add button and more action button of the favorite section doesn't work. +- Fix euro currency number format. +- Some UI issues + +## Version 0.2.8 - 08/03/2023 + +### New Features + +- Nestable personal folder that supports drag and drop +- Support for favorite folders. +- Support for sorting by date in Grid view. +- Add a duplicate button in the Board context menu. + +### Bug fixes + +- Improve readability in Callout +- Some UI issues + +## Version 0.2.7 - 07/18/2023 + +### New Features + + + +- Open page in new tab +- Create toggle lists to keep things tidy in your pages +- Alt/Option + click to add a text block above + +### Bug fixes + +- Pasting into a Grid property crashed on Windows +- Double-click a link to open + +## Version 0.2.6 - 07/11/2023 + +### New Features + +- Dynamic load themes +- Inline math equation + + +## Version 0.2.5 - 07/02/2023 + +### New Features + +- Insert local images +- Mention a page +- Outlines (Table of contents) +- Added support for aligning the image by image menu + +### Bug fixes + +- Some UI issues + +## Version 0.2.4 - 06/23/2023 + +### Bug fixes: + +- Unable to copy and paste a word +- Some UI issues + +## Version 0.2.3 - 06/21/2023 + +### New Features + +- Added support for creating multiple database views for existing database + +## Version 0.2.2 - 06/15/2023 + +### New Features + +- Added support for embedding a document in the database's row detail page +- Added support for inserting an emoji in the database's row detail page + +### Other Updates + +- Added language selector on the welcome page +- Added support for importing multiple markdown files all at once + +## Version 0.2.1 - 06/11/2023 + +### New Features + +- Added support for creating or referencing a calendar in the document +- Added `+` icon in grid's add field + +### Other Updates + +- Added vertical padding for progress bar +- Hide url cell accessory when the content is empty + +### Bug fixes: + +- Fixed unable to export markdown +- Fixed adding vertical padding for progress bar +- Fixed database view didn't update after the database layout changed. + +## Version 0.2.0 - 06/08/2023 + +### New Features + +- Improved checklists to support each cell having its own list +- Drag and drop calendar events +- Switch layouts (calendar, grid, kanban) of a database +- New database properties: 'Updated At' and 'Created At' +- Enabled hiding properties on the row detail page +- Added support for reordering and saving row order in different database views. +- Enabled each database view to have its own settings, including filter and sort options +- Added support to convert `“` (double quote) into a block quote +- Added support to convert `***` (three stars) into a divider +- Added support for an 'Add' button to insert a paragraph in a document and display the slash menu +- Added support for an 'Option' button to delete, duplicate, and customize block actions + +### Other Updates + +- Added support for importing v0.1.x documents and databases +- Added support for database import and export to CSV +- Optimized scroll behavior in documents. +- Redesigned the launch page + +### Bug fixes + +- Fixed bugs related to numbers +- Fixed issues with referenced databases in documents +- Fixed menu overflow issues in documents + +### Data migration + +The data format of this version is not compatible with previous versions. Therefore, to migrate your data to the new version, you need to use the export and import functions. Please follow the guide to learn how to export and import your data. + +#### Export files in v0.1.6 + +https://github.com/AppFlowy-IO/AppFlowy/assets/11863087/0c89bf2b-cd97-4a7b-b627-59df8d2967d9 + +#### Import files in v0.2.0 + +https://github.com/AppFlowy-IO/AppFlowy/assets/11863087/7b392f35-4972-497a-8a7f-f38efced32e2 + +## Version 0.1.5 - 11/05/2023 + +### Bug Fixes + +- Fix: calendar dates don't match with weekdays. +- Fix: sort numbers in Grid. + +## Version 0.1.4 - 04/05/2023 + +### New features + +- Use AppFlowy’s calendar views to plan and manage tasks and deadlines. +- Writing can be improved with the help of OpenAI. + +## Version 0.1.3 - 24/04/2023 + +### New features + +- Launch the official Dark Mode. +- Customize the font color and highlight color by setting a hex color value and an opacity level. + +### Bug Fixes + +- Fix: the slash menu can be triggered by all other keyboards than English. +- Fix: convert the single asterisk to italic text and the double asterisks to bold text. + +## Version 0.1.2 - 03/28/2023 + +### Bug Fixes + +- Fix: update calendar selected range. +- Fix: duplicate view. + +## Version 0.1.1 - 03/21/2023 + +### New features + +- AppFlowy brings the power of OpenAI into your AppFlowy pages. Ask AI to write anything for you in AppFlowy. +- Support adding a cover image to your page, making your pages beautiful. +- More shortcuts become available. Click on '?' at the bottom right to access our shortcut guide. + +### Bug Fixes + +- Fix some bugs + +## Version 0.1.0 - 02/09/2023 + +### New features + +- Support linking a Board or Grid into the Document page +- Integrate a callout plugin implemented by community members +- Optimize user interface + +### Bug Fixes + +- Fix some bugs + +## Version 0.0.9.1 - 01/03/2023 + +### New features + +- New theme +- Support changing text color of editor +- Optimize user interface + +### Bug Fixes + +- Fix some grid bugs + +## Version 0.0.9 - 12/21/2022 + +### New features + +- Enable the user to define where to store their data +- Support inserting Emojis through the slash command + +### Bug Fixes + +- Fix some bugs + +## Version 0.0.8.1 - 12/09/2022 + +### New features + +- Support following your default system theme +- Improve the filter in Grid + +### Bug Fixes + +- Copy/Paste + +## Version 0.0.8 - 11/30/2022 + +### New features + +- Table-view database + - support column type: Checklist +- Board-view database + - support column type: Checklist +- Customize font size: small, medium, large + +## Version 0.0.7.1 - 11/30/2022 + +### Bug Fixes + +- Fix some bugs + +## Version 0.0.7 - 11/27/2022 + +### New features + +- Support adding filters by the text/checkbox/single-select property in Grid ## Version 0.0.6.2 - 10/30/2022 + - Fix some bugs ## Version 0.0.6.1 - 10/26/2022 + ### New features -- Optimzie appflowy_editor dark mode style + +- Optimize appflowy_editor dark mode style ### Bug Fixes + - Unable to copy the text with checkbox or link style ## Version 0.0.6 - 10/23/2022 ### New features -- Integrate **appflowy_editor** +- Integrate **appflowy_editor** ## Version 0.0.5.3 - 09/26/2022 ### New features + - Open the next page automatically after deleting the current page - Refresh the Kanban board after altering a property type ### Bug Fixes + - Fix switch board bug - Fix delete the Kanban board's row error - Remove duplicate time format - Fix can't delete field in property edit panel - Adjust some display UI issues - ## Version 0.0.5.2 - 09/16/2022 ### New features + - Enable adding a new card to the "No Status" group - Fix some bugs ### Bug Fixes + - Fix cannot open AppFlowy error - Fix delete the Kanban board's row error - ## Version 0.0.5.1 - 09/14/2022 ### New features -- Enable deleting a field in board -- Fix some bugs +- Enable deleting a field in board +- Fix some bugs ## Version 0.0.5 - 09/08/2022 + ### New features - Kanban Board like Notion and Trello beta + Boards are the best way to manage projects & tasks. Use them to group your databases by select, multiselect, and checkbox.

@@ -59,6 +888,7 @@ Boards are the best way to manage projects & tasks. Use them to group your datab - Update database properties in the Board view by clicking on a property and making edits on the card ### Other Features & Improvements + - Settings allow users to change avatars - Click and drag the right edge to resize your sidebar - And many user interface improvements (link) @@ -66,27 +896,30 @@ Boards are the best way to manage projects & tasks. Use them to group your datab ## Version 0.0.5 - beta.2 - beta.1 - 09/01/2022 ### New features + - Board-view database - Support start editing after creating a new card - Support editing the card directly by clicking the edit button - Add the `No Status` column to display the cards while their status is empty ### Bug Fixes + - Optimize insert card animation - Fix some UI bugs ## Version 0.0.5 - beta.1 - 08/25/2022 ### New features -- Board-view database + +- Board-view database - Group by single select - drag and drop cards - insert / delete cards ![Aug-25-2022 16-22-38](https://user-images.githubusercontent.com/86001920/186614248-23186dfe-410e-427a-8cc6-865b1f79e074.gif) - ## Version 0.0.4 - 06/06/2022 + - Drag to adjust the width of a column - Upgrade to Flutter 3.0 - Native support for M1 chip @@ -95,10 +928,11 @@ Boards are the best way to manage projects & tasks. Use them to group your datab - Keyboard shortcuts support for Grid: press Enter to leave the edit mode; control c/v to copy-paste cell values ### Bug Fixes -- Fixed some bugs +- Fixed some bugs ## Version 0.0.4 - beta.3 - 05/02/2022 + - Drag to reorder app/ view/ field - Row record opens as a page - Auto resize the height of the row in the grid @@ -107,42 +941,45 @@ Boards are the best way to manage projects & tasks. Use them to group your datab ![May-03-2022 10-03-00](https://user-images.githubusercontent.com/86001920/166394640-a8f1f3bc-5f20-4033-93e9-16bc308d7005.gif) - ### Bug Fixes & Improvements + - Improved row/cell data cache - Fixed some bugs - ## Version 0.0.4 - beta.2 - 04/11/2022 - - Support properties: Text, Number, Date, Checkbox, Select, Multi-select - - Insert / delete rows - - Add / delete / hide columns - - Edit property - ![](https://user-images.githubusercontent.com/12026239/162753644-bf2f4e7a-2367-4d48-87e6-35e244e83a5b.png) +- Support properties: Text, Number, Date, Checkbox, Select, Multi-select +- Insert / delete rows +- Add / delete / hide columns +- Edit property + ![](https://user-images.githubusercontent.com/12026239/162753644-bf2f4e7a-2367-4d48-87e6-35e244e83a5b.png) ## Version 0.0.4 - beta.1 - 04/08/2022 + v0.0.4 - beta.1 is pre-release ### New features + - Table-view database - - supported column types: Text, Checkbox, Single-select, Multi-select, Numbers - - hide / delete columns - - insert rows + - support column types: Text, Checkbox, Single-select, Multi-select, Numbers + - hide / delete columns + - insert rows ## Version 0.0.3 - 02/23/2022 + v0.0.3 is production ready, available on Linux, macOS, and Windows ### New features -- Dark Mode -- Support new languages: French, Italian, Russian, Simplified Chinese, Spanish + +- Dark Mode +- Support new languages: French, Italian, Russian, Simplified Chinese, Spanish - Add Settings: Toggle on Dark Mode; Select a language -- Show device info +- Show device info - Add tooltip on the toolbar icons Bug fixes and improvements + - Increased height of action -- CPU performance issue +- CPU performance issue - Fix potential data parser error -- More foundation work for online collaboration - +- More foundation work for online collaboration \ No newline at end of file diff --git a/README.md b/README.md index 30069468ba91f..b9606b8844f50 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

-You are in charge of your data and customizations. +AppFlowy is the AI workspace where you achieve more without losing control of your data

@@ -18,42 +18,63 @@ You are in charge of your data and customizations.

- Website • + Website • + ForumDiscord • + RedditTwitter

-

The Open Source Alternative To Notion.

-

The Open Source Alternative To Notion.

-

The Open Source Alternative To Notion.

+

AppFlowy Kanban Board for To-dos

+

AppFlowy Databases for Tasks and Projects

+

AppFlowy Sites for Beautiful documentation

+

AppFlowy AI

+

AppFlowy Templates

+ +

+

+ Work across devices

+

+ Work across devices

+

+ Work across devices

## User Installation -* [Windows/Mac/Linux](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/mac-windows-linux-packages) -* [Docker](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/installing-with-docker) -* [Source](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/from-source) +- [Download AppFlowy Desktop (macOS, Windows, and Linux)](https://github.com/AppFlowy-IO/AppFlowy/releases) +- Other + channels: [FlatHub](https://flathub.org/apps/io.appflowy.AppFlowy), [Snapcraft](https://snapcraft.io/appflowy), [Sourceforge](https://sourceforge.net/projects/appflowy/) +- Available on + - [App Store](https://apps.apple.com/app/appflowy/id6457261352): iPhone + - [Play Store](https://play.google.com/store/apps/details?id=io.appflowy.appflowy): Android 10 or above; ARMv7 is + not supported +- [Self-hosting AppFlowy](https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy) +- [Source](https://docs.appflowy.io/docs/documentation/appflowy/from-source) ## Built With -* [Flutter](https://flutter.dev/) +- [Flutter](https://flutter.dev/) -* [Rust](https://www.rust-lang.org/) +- [Rust](https://www.rust-lang.org/) ## Stay Up-to-Date

AppFlowy Github - how to star the repo

## Getting Started with development -Please view the [documentation](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy) for OS specific development instructions + +Please view the [documentation](https://docs.appflowy.io/docs/documentation/appflowy/from-source) for OS specific +development instructions ## Roadmap -- [AppFlowy Roadmap ReadMe](https://appflowy.gitbook.io/docs/essential-documentation/roadmap) +- [AppFlowy Roadmap ReadMe](https://docs.appflowy.io/docs/appflowy/roadmap) - [AppFlowy Public Roadmap](https://github.com/orgs/AppFlowy-IO/projects/5/views/12) - -If you'd like to propose a feature, submit a feature request [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=feature_request.yaml&title=%5BFR%5D+)
-If you'd like to report a bug, submit bug report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=bug_report.yaml&title=%5BBug%5D+) +If you'd like to propose a feature, submit a feature +request [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=feature_request.yaml&title=%5BFR%5D+)
+If you'd like to report a bug, submit a bug +report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=bug_report.yaml&title=%5BBug%5D+) ## **Releases** @@ -61,29 +82,57 @@ Please see the [changelog](https://www.appflowy.io/whatsnew) for more details ab ## Contributing -Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. Please look at [Contributing to AppFlowy](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/contributing-to-appflowy) for details. +Contributions make the open-source community a fantastic place to learn, inspire, and create. Any contributions you make +are **greatly appreciated**. Please look +at [Contributing to AppFlowy](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy) +for details. -If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly easier to use or understand, congratulations! If your administrative and managerial work behind the scenes that sustains the community as a whole, congratulations! You are now an official contributor to AppFlowy. Get in touch with us ([link](https://tally.so/r/mKP5z3)) to receive the very special Contributor T-shirt! +If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly +easier to use or understand, **Congratulations!** If your administrative and managerial work behind the scenes sustains +the community, **Congratulations!** You are now an official contributor to AppFlowy. Get in touch with +us ([link](https://tally.so/r/mKP5z3)) to receive the very special Contributor T-shirt! Proudly wear your T-shirt and show it to us by tagging [@appflowy](https://twitter.com/appflowy) on Twitter. +## Translations 🌎🗺 + +[![translation badge](https://inlang.com/badge?url=github.com/AppFlowy-IO/AppFlowy)](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy?ref=badge) + +To add translations, you can manually edit the JSON translation files in `/frontend/resources/translations`, use +the [inlang online editor](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy), or +run `npx inlang machine translate` to add missing translations. + +## Join the community to build AppFlowy together -## Join the community to build AppFlowy together! ## Why Are We Building This? -Notion has been our favorite project and knowledge management tool in recent years because of its aesthetic appeal and functionality. Our team uses it daily, and we are on its paid plan. However, as we all know Notion has its limitations. These include weak data security and poor compatibility with mobile devices. Likewise, alternative collaborative workplace management tools also have their constraints. +Notion has been our favourite project and knowledge management tool in recent years because of its aesthetic appeal and +functionality. Our team uses it daily, and we are on its paid plan. However, as we all know, Notion has its limitations. +These include weak data security and poor compatibility with mobile devices. Likewise, alternative collaborative +workplace management tools also have their constraints. -The limitations we encountered using these tools rooted in our past work experience with collaborative productivity tools lead to our firm belief that there is, and will be a glass ceiling on what's possible in the future for tools like Notion. This emanates from these tools probable struggles to scale horizontally at some point. It implies that they will likely be forced to prioritize for a proportion of customers whose needs can be quite different from the rest. While decision-makers want a workplace OS, the truth is that it is not very possible to come up with a one-size fits all solution in such a fragmented market. +The limitations we encountered using these tools and our past work experience with collaborative productivity tools have +led to our firm belief that there is a glass ceiling on what's possible for these tools in the future. This emanates +from the fact that these tools will probably struggle to scale horizontally at some point and be forced to prioritize a +proportion of customers whose needs differ from the rest. While decision-makers want a workplace OS, it is impossible to +come up with a one-size fits all solution in such a fragmented market. -When a customer's evolving core needs are not satisfied, they either switch to another or build one from the ground up, in-house. Consequently, they either go under another ceiling or buy an expensive ticket to learn a hard lesson. This is a requirement for many resources and expertise, building a reliable and easy-to-use collaborative tool, not to mention the speed and native experience. The same may apply to individual users as well. +When a customer's evolving core needs are not satisfied, they either switch to another or build one from the ground up, +in-house. Consequently, they either go under another ceiling or buy an expensive ticket to learn a hard lesson. This is +a requirement for many resources and expertise, building a reliable and easy-to-use collaborative tool, not to mention +the speed and native experience. The same may apply to individual users as well. -All these restrictions necessitate our mission - to make it possible for anyone to create apps that suit their needs well. +All these restrictions necessitate our mission - to make it possible for anyone to create apps that suit their needs +well. -- To individuals, we would like to offer Notion's functionality along with data security and cross-platform native experience. -- To enterprises and hackers, AppFlowy is dedicated to offering building blocks, that is, collaboration infra services to enable you to make apps on your own. Moreover, you have 100% control of your data. You can design and modify AppFlowy your way, with a single codebase written in Flutter and Rust supporting multiple platforms armed with long-term maintainability. +- To individuals, we would like to offer Notion's functionality, data security, and cross-platform native experience. +- To enterprises and hackers, AppFlowy is dedicated to offering building blocks and collaboration infra services to + enable you to make apps on your own. Moreover, you have 100% control of your data. You can design and modify AppFlowy + your way, with a single codebase written in Flutter and Rust supporting multiple platforms armed with long-term + maintainability. We decided to achieve this mission by upholding the three most fundamental values: @@ -91,16 +140,20 @@ We decided to achieve this mission by upholding the three most fundamental value - Reliable native experience - Community-driven extensibility -To be honest, we do not claim to outperform Notion in terms of functionality and design, at least for now. Besides, our priority doesn't lie in more functionality at the moment. Instead, we would like to cultivate a community to democratize the knowledge and wheels of making complex workplace management tools, while enabling people and businesses to create beautiful things on their own by equipping them with a versatile toolbox of building blocks. +We do not claim to outperform Notion in terms of functionality and design, at least for now. Besides, our priority +doesn't lie in more functionality at the moment. Instead, we would like to cultivate a community to democratize the +knowledge and wheels of making complex workplace management tools while enabling people and businesses to create +beautiful things on their own by equipping them with a versatile toolbox of building blocks. ## License -Distributed under the AGPLv3 License. See [`LICENSE.md`](https://github.com/AppFlowy-IO/AppFlowy/blob/main/LICENSE) for more information. +Distributed under the AGPLv3 License. See [`LICENSE.md`](https://github.com/AppFlowy-IO/AppFlowy/blob/main/LICENSE) for +more information. -## Acknowledgements +## Acknowledgments Special thanks to these amazing projects which help power AppFlowy.IO: -- [flutter-quill](https://github.com/singerdmx/flutter-quill) - [cargo-make](https://github.com/sagiegurari/cargo-make) - [contrib.rocks](https://contrib.rocks) +- [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui) \ No newline at end of file diff --git a/codemagic.yaml b/codemagic.yaml new file mode 100644 index 0000000000000..b8934d8be8863 --- /dev/null +++ b/codemagic.yaml @@ -0,0 +1,47 @@ +workflows: + ios-workflow: + name: iOS Workflow + instance_type: mac_mini_m2 + max_build_duration: 30 + environment: + flutter: 3.22.3 + xcode: latest + cocoapods: default + + scripts: + - name: Build Flutter + script: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + source "$HOME/.cargo/env" + rustc --version + cargo --version + + cd frontend + + rustup target install aarch64-apple-ios-sim + cargo install --force cargo-make + cargo install --force --locked duckscript_cli + cargo install --force cargo-lipo + + cargo make appflowy-flutter-deps-tools + cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios + cargo make --profile development-ios-arm64-sim code_generation + + - name: iOS integration tests + script: | + cd frontend/appflowy_flutter + flutter emulators --launch apple_ios_simulator + flutter -d iPhone test integration_test/runner.dart + + artifacts: + - build/ios/ipa/*.ipa + - /tmp/xcodebuild_logs/*.log + - flutter_drive.log + + publishing: + email: + recipients: + - lucas.xu@appflowy.io + notify: + success: true + failure: true diff --git a/doc/readme/desktop_guide_1.jpg b/doc/readme/desktop_guide_1.jpg new file mode 100644 index 0000000000000..d264c816957b9 Binary files /dev/null and b/doc/readme/desktop_guide_1.jpg differ diff --git a/doc/readme/desktop_guide_2.jpg b/doc/readme/desktop_guide_2.jpg new file mode 100644 index 0000000000000..d9cdbe5fc1aa7 Binary files /dev/null and b/doc/readme/desktop_guide_2.jpg differ diff --git a/doc/readme/getting_started_1.png b/doc/readme/getting_started_1.png new file mode 100644 index 0000000000000..8c3c7658ffc40 Binary files /dev/null and b/doc/readme/getting_started_1.png differ diff --git a/doc/readme/mobile_guide_1.png b/doc/readme/mobile_guide_1.png new file mode 100644 index 0000000000000..744fdf29dc38f Binary files /dev/null and b/doc/readme/mobile_guide_1.png differ diff --git a/doc/readme/mobile_guide_2.png b/doc/readme/mobile_guide_2.png new file mode 100644 index 0000000000000..d92c0295c6999 Binary files /dev/null and b/doc/readme/mobile_guide_2.png differ diff --git a/doc/readme/mobile_guide_3.png b/doc/readme/mobile_guide_3.png new file mode 100644 index 0000000000000..9e3cc52d92a73 Binary files /dev/null and b/doc/readme/mobile_guide_3.png differ diff --git a/doc/readme/mobile_guide_4.png b/doc/readme/mobile_guide_4.png new file mode 100644 index 0000000000000..b39e03c251d4f Binary files /dev/null and b/doc/readme/mobile_guide_4.png differ diff --git a/doc/readme/mobile_guide_5.png b/doc/readme/mobile_guide_5.png new file mode 100644 index 0000000000000..9083b80bed63a Binary files /dev/null and b/doc/readme/mobile_guide_5.png differ diff --git a/frontend/.vscode/launch.json b/frontend/.vscode/launch.json index f40b4c713e648..09965baee185b 100644 --- a/frontend/.vscode/launch.json +++ b/frontend/.vscode/launch.json @@ -5,105 +5,136 @@ "version": "0.2.0", "configurations": [ { - // This task builds the Rust and Dart code of AppFlowy. - "name": "AF: Build All", + // This task only builds the Dart code of AppFlowy. + // It supports both the desktop and mobile version. + "name": "AF: Build Dart Only", "request": "launch", "program": "./lib/main.dart", "type": "dart", - "preLaunchTask": "AF: build_flowy_sdk", "env": { - "RUST_LOG": "info" + "RUST_LOG": "debug", }, - "cwd": "${workspaceRoot}/app_flowy" + // uncomment the following line to testing performance. + // "flutterMode": "profile", + "cwd": "${workspaceRoot}/appflowy_flutter" }, { - // This task builds the Rust and Dart code of AppFlowy for android. - "name": "AF: Run Android", + // This task builds the Rust and Dart code of AppFlowy. + "name": "AF-desktop: Build All", "request": "launch", "program": "./lib/main.dart", "type": "dart", - "preLaunchTask": "AF: build_mobile_sdk", + "preLaunchTask": "AF: Build Appflowy Core", "env": { - "RUST_LOG": "info" + "RUST_LOG": "trace", + "RUST_BACKTRACE": "1" }, - "cwd": "${workspaceRoot}/app_flowy" + "cwd": "${workspaceRoot}/appflowy_flutter" }, { - "name": "AF: Debug Rust", - "request": "attach", - "type": "lldb", - "pid": "${command:pickMyProcess}" + // This task builds will: + // - call the clean task, + // - rebuild all the generated Files (including freeze and language files) + // - rebuild the the Rust and Dart code of AppFlowy. + "name": "AF-desktop: Clean + Rebuild All", + "request": "launch", + "program": "./lib/main.dart", + "type": "dart", + "preLaunchTask": "AF: Clean + Rebuild All", + "env": { + "RUST_LOG": "trace" + }, + "cwd": "${workspaceRoot}/appflowy_flutter" }, { - // This task only builds the Dart code of AppFlowy. - "name": "AF: Build Dart Only", + "name": "AF-iOS: Build All", "request": "launch", "program": "./lib/main.dart", "type": "dart", + "preLaunchTask": "AF: Build Appflowy Core For iOS", "env": { - "RUST_LOG": "debug" + "RUST_LOG": "trace" }, - "cwd": "${workspaceRoot}/app_flowy" + "cwd": "${workspaceRoot}/appflowy_flutter" }, { - // This task builds will: - // - call the clean task, - // - rebuild all the generated Files (including freeze and language files) - // - rebuild the the Rust and Dart code of AppFlowy. - "name": "AF: Clean + Rebuild All", + "name": "AF-iOS: Clean + Rebuild All", "request": "launch", "program": "./lib/main.dart", "type": "dart", - "preLaunchTask": "AF: Clean + Rebuild All", + "preLaunchTask": "AF: Clean + Rebuild All (iOS)", "env": { "RUST_LOG": "trace" }, - "cwd": "${workspaceRoot}/app_flowy" + "cwd": "${workspaceRoot}/appflowy_flutter" }, { - // This task builds will: - // - call the clean task, - // - rebuild all the generated Files (including freeze and language files) - // - rebuild the the Rust and Dart code of AppFlowy. - "name": "AF: Clean + Rebuild All (Android)", + "name": "AF-iOS-Simulator: Build All", "request": "launch", "program": "./lib/main.dart", "type": "dart", - "preLaunchTask": "AF: Clean + Rebuild All (Android)", + "preLaunchTask": "AF: Build Appflowy Core For iOS Simulator", "env": { - "RUST_LOG": "info" + "RUST_LOG": "trace" }, - "cwd": "${workspaceRoot}/app_flowy" + "cwd": "${workspaceRoot}/appflowy_flutter" }, { - "name": "AF: Build All (rustlog: trace)", + "name": "AF-iOS-Simulator: Clean + Rebuild All", "request": "launch", "program": "./lib/main.dart", "type": "dart", - "preLaunchTask": "AF: build_flowy_sdk", + "preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)", "env": { "RUST_LOG": "trace" }, - "cwd": "${workspaceRoot}/app_flowy" + "cwd": "${workspaceRoot}/appflowy_flutter" }, { - "name": "AF: Build All Android (rustlog: trace)", + "name": "AF-Android: Build All", "request": "launch", "program": "./lib/main.dart", "type": "dart", - "preLaunchTask": "AF: build_mobile_sdk", + "preLaunchTask": "AF: Build Appflowy Core For Android", "env": { "RUST_LOG": "trace" }, - "cwd": "${workspaceRoot}/app_flowy" + "cwd": "${workspaceRoot}/appflowy_flutter" }, { - "name": "AF: app_flowy (profile mode)", + "name": "AF-Android: Clean + Rebuild All", "request": "launch", "program": "./lib/main.dart", "type": "dart", - "flutterMode": "profile", - "cwd": "${workspaceRoot}/app_flowy" + "preLaunchTask": "AF: Clean + Rebuild All (Android)", + "env": { + "RUST_LOG": "trace" + }, + "cwd": "${workspaceRoot}/appflowy_flutter" + }, + { + "name": "AF-desktop: Debug Rust", + "type": "lldb", + "request": "attach", + "pid": "${command:pickMyProcess}" + // To launch the application directly, use the following configuration: + // "request": "launch", + // "program": "[YOUR_APPLICATION_PATH]", + }, + { + // https://tauri.app/v1/guides/debugging/vs-code + "type": "lldb", + "request": "launch", + "name": "AF-tauri: Debug backend", + "cargo": { + "args": [ + "build", + "--manifest-path=./appflowy_tauri/src-tauri/Cargo.toml", + "--no-default-features" + ] + }, + "preLaunchTask": "AF: Tauri UI Dev", + "cwd": "${workspaceRoot}/appflowy_tauri/" }, ] -} \ No newline at end of file +} diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json deleted file mode 100644 index d1732c5231f09..0000000000000 --- a/frontend/.vscode/settings.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "[dart]": { - "editor.formatOnSave": true, - "editor.formatOnType": true, - "editor.rulers": [80], - "editor.selectionHighlight": false, - "editor.suggest.snippetsPreventQuickSuggestions": false, - "editor.suggestSelection": "first", - "editor.tabCompletion": "onlySnippets", - "editor.wordBasedSuggestions": false, - }, - "svgviewer.enableautopreview": true, - "svgviewer.previewcolumn": "Active", - "svgviewer.showzoominout": true, - "editor.wordWrapColumn": 80, - "editor.minimap.maxColumn": 140, - "prettier.printWidth": 140, - "editor.wordWrap": "wordWrapColumn", - "dart.lineLength": 80, - "files.associations": { - "*.log.*": "log" - }, - "editor.formatOnSave": true, - "files.eol": "\n", -} \ No newline at end of file diff --git a/frontend/.vscode/tasks.json b/frontend/.vscode/tasks.json index 30fc1e134ddee..d940eef0a822d 100644 --- a/frontend/.vscode/tasks.json +++ b/frontend/.vscode/tasks.json @@ -1,190 +1,302 @@ { - "version": "2.0.0", - // https://code.visualstudio.com/docs/editor/tasks - // https://gist.github.com/deadalusai/9e13e36d61ec7fb72148 - // ${workspaceRoot}: the root folder of the team - // ${file}: the current opened file - // ${fileBasename}: the current opened file's basename - // ${fileDirname}: the current opened file's dirname - // ${fileExtname}: the current opened file's extension - // ${cwd}: the current working directory of the spawned process - "tasks": [ - { - "label": "AF: Clean + Rebuild All", - "type": "shell", - "dependsOrder": "sequence", - "dependsOn": [ - "AF: Rust Clean", - "AF: Flutter Clean", - "AF: build_flowy_sdk", - "AF: Flutter Pub Get", - "AF: Flutter Package Get", - "AF: Generate Language Files", - "AF: Generate Freezed Files", - ], - "presentation": { - "reveal": "always", - "panel": "new" - } - }, - { - "label": "AF: Clean + Rebuild All (Android)", - "type": "shell", - "dependsOrder": "sequence", - "dependsOn": [ - "AF: Rust Clean", - "AF: Flutter Clean", - "AF: build_flowy_sdk_for_android", - "AF: Flutter Pub Get", - "AF: Flutter Package Get", - "AF: Generate Language Files", - "AF: Generate Freezed Files", - ], - "presentation": { - "reveal": "always", - "panel": "new", - }, - }, - { - "label": "AF: build_flowy_sdk_for_android", - "type": "shell", - "command": "cargo make --profile development-android flowy-sdk-dev-android", - "group": "build", - "options": { - "cwd": "${workspaceFolder}" - } - }, - { - "label": "AF: build_flowy_sdk", - "type": "shell", - "command": "sh ./scripts/build_sdk.sh", - "windows": { - "options": { - "env": { - "FLOWY_DEV_ENV": "Windows" - }, - "shell": { - "executable": "cmd.exe", - "args": [ - "/d", - "/c", - ".\\scripts\\build_sdk.cmd" - ] - } - } - }, - "linux": { - "options": { - "env": { - "FLOWY_DEV_ENV": "Linux" - } - } - }, - "osx": { - "options": { - "env": { - "FLOWY_DEV_ENV": "macOS" - } - } - }, - "group": "build", - "options": { - "cwd": "${workspaceFolder}" - } - }, - { - "label": "AF: Code Gen", - "type": "shell", - "dependsOrder": "sequence", - "dependsOn": [ - "AF: Flutter Clean", - "AF: Flutter Pub Get", - "AF: Flutter Package Get", - "AF: Generate Language Files", - "AF: Generate Freezed Files" - ], - "group": { - "kind": "build", - "isDefault": true - }, - "presentation": { - "reveal": "always", - "panel": "new" - } - }, - { - "label": "AF: Flutter Clean", - "type": "shell", - "command": "flutter clean", - "options": { - "cwd": "${workspaceFolder}/app_flowy" - } - }, - { - "label": "AF: Flutter Pub Get", - "type": "shell", - "command": "flutter pub get", - "options": { - "cwd": "${workspaceFolder}/app_flowy" - } - }, - { - "label": "AF: Flutter Package Get", - "type": "shell", - "command": "flutter packages pub get", - "options": { - "cwd": "${workspaceFolder}/app_flowy" - } - }, - { - "label": "AF: Generate Freezed Files", - "type": "shell", - "command": "flutter pub run build_runner build --delete-conflicting-outputs", - "options": { - "cwd": "${workspaceFolder}/app_flowy" - } - }, - { - "label": "AF: Generate Language Files", - "type": "shell", - "command": "sh ./scripts/generate_language_files.sh", - "windows": { - "options": { - "shell": { - "executable": "cmd.exe", - "args": [ - "/d", - "/c", - ".\\scripts\\generate_language_files.cmd" - ] - } - } - }, - "group": "build", - "options": { - "cwd": "${workspaceFolder}" - } - }, - { - "label": "AF: Rust Clean", - "type": "shell", - "command": "cargo make flowy_clean", - "group": "build", - "options": { - "cwd": "${workspaceFolder}" - } - }, - { - "label": "AF: flutter build aar", - "type": "flutter", - "command": "flutter", - "args": [ - "build", - "aar" - ], - "group": "build", - "problemMatcher": [], - "detail": "app_flowy" - } - ] + "version": "2.0.0", + // https://code.visualstudio.com/docs/editor/tasks + // https://gist.github.com/deadalusai/9e13e36d61ec7fb72148 + // ${workspaceRoot}: the root folder of the team + // ${file}: the current opened file + // ${fileBasename}: the current opened file's basename + // ${fileDirname}: the current opened file's dirname + // ${fileExtname}: the current opened file's extension + // ${cwd}: the current working directory of the spawned process + "tasks": [ + { + "label": "AF: Clean + Rebuild All", + "type": "shell", + "dependsOrder": "sequence", + "dependsOn": [ + "AF: Dart Clean", + "AF: Flutter Clean", + "AF: Build Appflowy Core", + "AF: Flutter Pub Get", + "AF: Generate Language Files", + "AF: Generate Freezed Files", + "AF: Generate Svg Files" + ], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "AF: Clean + Rebuild All (iOS)", + "type": "shell", + "dependsOrder": "sequence", + "dependsOn": [ + "AF: Dart Clean", + "AF: Flutter Clean", + "AF: Build Appflowy Core For iOS", + "AF: Flutter Pub Get", + "AF: Generate Language Files", + "AF: Generate Freezed Files", + "AF: Generate Svg Files" + ], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "AF: Clean + Rebuild All (iOS Simulator)", + "type": "shell", + "dependsOrder": "sequence", + "dependsOn": [ + "AF: Dart Clean", + "AF: Flutter Clean", + "AF: Build Appflowy Core For iOS Simulator", + "AF: Flutter Pub Get", + "AF: Generate Language Files", + "AF: Generate Freezed Files", + "AF: Generate Svg Files" + ], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "AF: Clean + Rebuild All (Android)", + "type": "shell", + "dependsOrder": "sequence", + "dependsOn": [ + "AF: Dart Clean", + "AF: Flutter Clean", + "AF: Build Appflowy Core For Android", + "AF: Flutter Pub Get", + "AF: Generate Language Files", + "AF: Generate Freezed Files", + "AF: Generate Svg Files" + ], + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "AF: Build Appflowy Core", + "type": "shell", + "windows": { + "command": "cargo make --profile development-windows-x86 appflowy-core-dev" + }, + "linux": { + "command": "cargo make --profile \"development-linux-$(uname -m)\" appflowy-core-dev" + }, + "osx": { + "command": "cargo make --profile \"development-mac-$(uname -m)\" appflowy-core-dev" + }, + "group": "build", + "options": { + "cwd": "${workspaceFolder}" + } + }, + { + "label": "AF: Build Appflowy Core For iOS", + "type": "shell", + "command": "cargo make --profile development-ios-arm64 appflowy-core-dev-ios", + "group": "build", + "options": { + "cwd": "${workspaceFolder}" + } + }, + { + "label": "AF: Build Appflowy Core For iOS Simulator", + "type": "shell", + "command": "cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios", + "group": "build", + "options": { + "cwd": "${workspaceFolder}" + } + }, + { + "label": "AF: Build Appflowy Core For Android", + "type": "shell", + "command": "cargo make --profile development-android appflowy-core-dev-android", + "group": "build", + "options": { + "cwd": "${workspaceFolder}" + } + }, + { + "label": "AF: Code Gen", + "type": "shell", + "dependsOrder": "sequence", + "dependsOn": [ + "AF: Flutter Clean", + "AF: Flutter Pub Get", + "AF: Generate Language Files", + "AF: Generate Freezed Files", + "AF: Generate Svg Files" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "AF: Dart Clean", + "type": "shell", + "command": "cargo make flutter_clean", + "group": "build", + "options": { + "cwd": "${workspaceFolder}" + } + }, + { + "label": "AF: Flutter Clean", + "type": "shell", + "command": "flutter clean", + "options": { + "cwd": "${workspaceFolder}/appflowy_flutter" + } + }, + { + "label": "AF: Flutter Pub Get", + "type": "shell", + "command": "flutter pub get", + "options": { + "cwd": "${workspaceFolder}/appflowy_flutter" + } + }, + { + "label": "AF: Generate Freezed Files", + "type": "shell", + "command": "sh ./scripts/code_generation/freezed/generate_freezed.sh", + "options": { + "cwd": "${workspaceFolder}" + }, + "group": "build", + "windows": { + "options": { + "shell": { + "executable": "cmd.exe", + "args": [ + "/d", + "/c", + ".\\scripts\\code_generation\\freezed\\generate_freezed.cmd" + ] + } + } + } + }, + { + "label": "AF: Generate Language Files", + "type": "shell", + "command": "sh ./scripts/code_generation/language_files/generate_language_files.sh", + "windows": { + "options": { + "shell": { + "executable": "cmd.exe", + "args": [ + "/d", + "/c", + ".\\scripts\\code_generation\\language_files\\generate_language_files.cmd" + ] + } + } + }, + "group": "build", + "options": { + "cwd": "${workspaceFolder}" + } + }, + { + "label": "AF: Generate Svg Files", + "type": "shell", + "command": "sh ./scripts/code_generation/flowy_icons/generate_flowy_icons.sh", + "windows": { + "options": { + "shell": { + "executable": "cmd.exe", + "args": [ + "/d", + "/c", + ".\\scripts\\code_generation\\flowy_icons\\generate_flowy_icons.cmd" + ] + } + } + }, + "group": "build", + "options": { + "cwd": "${workspaceFolder}" + } + }, + { + "label": "AF: flutter build aar", + "type": "flutter", + "command": "flutter", + "args": [ + "build", + "aar" + ], + "group": "build", + "problemMatcher": [], + "detail": "appflowy_flutter" + }, + { + "label": "AF: Tauri UI Build", + "type": "shell", + "command": "pnpm run build", + "options": { + "cwd": "${workspaceFolder}/appflowy_tauri" + } + }, + { + "label": "AF: Tauri UI Dev", + "type": "shell", + "isBackground": true, + "command": "pnpm sync:i18n && pnpm run dev", + "options": { + "cwd": "${workspaceFolder}/appflowy_tauri" + } + }, + { + "label": "AF: Tauri Clean", + "type": "shell", + "command": "cargo make tauri_clean", + "options": { + "cwd": "${workspaceFolder}" + } + }, + { + "label": "AF: Tauri Clean + Dev", + "type": "shell", + "dependsOrder": "sequence", + "dependsOn": [ + "AF: Tauri Clean", + "AF: Tauri UI Dev" + ], + "options": { + "cwd": "${workspaceFolder}" + } + }, + { + "label": "AF: Tauri ESLint", + "type": "shell", + "command": "npx eslint --fix src", + "options": { + "cwd": "${workspaceFolder}/appflowy_tauri" + } + }, + { + "label": "AF: Generate Env File", + "type": "shell", + "command": "dart run build_runner clean && dart run build_runner build --delete-conflicting-outputs", + "options": { + "cwd": "${workspaceFolder}/appflowy_flutter" + } + } + ] } \ No newline at end of file diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index 92a2ce040e3bc..bcf4baaeea742 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -2,12 +2,15 @@ extend = [ { path = "scripts/makefile/desktop.toml" }, + { path = "scripts/makefile/mobile.toml" }, { path = "scripts/makefile/protobuf.toml" }, { path = "scripts/makefile/tests.toml" }, { path = "scripts/makefile/docker.toml" }, { path = "scripts/makefile/env.toml" }, { path = "scripts/makefile/flutter.toml" }, { path = "scripts/makefile/tool.toml" }, + { path = "scripts/makefile/tauri.toml" }, + { path = "scripts/makefile/web.toml" }, ] [config] @@ -18,16 +21,18 @@ run_task = { name = ["restore-crate-type"] } [env] RUST_LOG = "info" +CARGO_PROFILE = "dev" CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" -CURRENT_APP_VERSION = "0.0.6.2" -FEATURES = "flutter" +APPFLOWY_VERSION = "0.7.9" +FLUTTER_DESKTOP_FEATURES = "dart" PRODUCT_NAME = "AppFlowy" +MACOSX_DEPLOYMENT_TARGET = "11.0" # CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html # If you update the macOS's CRATE_TYPE, don't forget to update the -# flowy_sdk.podspec +# appflowy_backend.podspec # for staticlib: # s.static_framework = true # s.vendored_libraries = "libdart_ffi.a" @@ -42,12 +47,13 @@ PRODUCT_NAME = "AppFlowy" CRATE_TYPE = "staticlib" LIB_EXT = "a" APP_ENVIRONMENT = "local" -FLUTTER_FLOWY_SDK_PATH = "app_flowy/packages/flowy_sdk" -PROTOBUF_DERIVE_CACHE = "../shared-lib/flowy-derive/src/derive_cache/derive_cache.rs" +FLUTTER_FLOWY_SDK_PATH = "appflowy_flutter/packages/appflowy_backend" +TAURI_BACKEND_SERVICE_PATH = "appflowy_tauri/src/services/backend" +WEB_BACKEND_SERVICE_PATH = "appflowy_web/src/services/backend" +TAURI_APP_BACKEND_SERVICE_PATH = "appflowy_web_app/src/application/services/tauri-services/backend" # Test default config TEST_CRATE_TYPE = "cdylib" TEST_LIB_EXT = "dylib" -TEST_RUST_LOG = "info" TEST_BUILD_FLAG = "debug" TEST_COMPILE_TARGET = "x86_64-apple-darwin" @@ -59,6 +65,8 @@ BUILD_FLAG = "debug" FLUTTER_OUTPUT_DIR = "Debug" PRODUCT_EXT = "app" BUILD_ARCHS = "arm64" +BUILD_ACTIVE_ARCHS_ONLY = true +CRATE_TYPE = "staticlib" [env.development-mac-x86_64] RUST_LOG = "info" @@ -68,8 +76,11 @@ BUILD_FLAG = "debug" FLUTTER_OUTPUT_DIR = "Debug" PRODUCT_EXT = "app" BUILD_ARCHS = "x86_64" +BUILD_ACTIVE_ARCHS_ONLY = true +CRATE_TYPE = "staticlib" [env.production-mac-arm64] +CARGO_PROFILE = "release" BUILD_FLAG = "release" TARGET_OS = "macos" RUST_COMPILE_TARGET = "aarch64-apple-darwin" @@ -77,8 +88,11 @@ FLUTTER_OUTPUT_DIR = "Release" PRODUCT_EXT = "app" APP_ENVIRONMENT = "production" BUILD_ARCHS = "arm64" +BUILD_ACTIVE_ARCHS_ONLY = false +CRATE_TYPE = "staticlib" [env.production-mac-x86_64] +CARGO_PROFILE = "release" BUILD_FLAG = "release" TARGET_OS = "macos" RUST_COMPILE_TARGET = "x86_64-apple-darwin" @@ -86,6 +100,17 @@ FLUTTER_OUTPUT_DIR = "Release" PRODUCT_EXT = "app" APP_ENVIRONMENT = "production" BUILD_ARCHS = "x86_64" +BUILD_ACTIVE_ARCHS_ONLY = false +CRATE_TYPE = "staticlib" + +[env.production-mac-universal] +CARGO_PROFILE = "release" +BUILD_FLAG = "release" +TARGET_OS = "macos" +FLUTTER_OUTPUT_DIR = "Release" +PRODUCT_EXT = "app" +BUILD_ACTIVE_ARCHS_ONLY = false +APP_ENVIRONMENT = "production" [env.development-windows-x86] TARGET_OS = "windows" @@ -97,6 +122,7 @@ CRATE_TYPE = "cdylib" LIB_EXT = "dll" [env.production-windows-x86] +CARGO_PROFILE = "release" BUILD_FLAG = "release" TARGET_OS = "windows" RUST_COMPILE_TARGET = "x86_64-pc-windows-msvc" @@ -104,6 +130,7 @@ FLUTTER_OUTPUT_DIR = "Release" PRODUCT_EXT = "exe" CRATE_TYPE = "cdylib" LIB_EXT = "dll" +BUILD_ARCHS = "x64" APP_ENVIRONMENT = "production" [env.development-linux-x86_64] @@ -116,6 +143,7 @@ LIB_EXT = "so" LINUX_ARCH = "x64" [env.production-linux-x86_64] +CARGO_PROFILE = "release" BUILD_FLAG = "release" TARGET_OS = "linux" RUST_COMPILE_TARGET = "x86_64-unknown-linux-gnu" @@ -133,8 +161,10 @@ CRATE_TYPE = "cdylib" FLUTTER_OUTPUT_DIR = "Debug" LIB_EXT = "so" LINUX_ARCH = "arm64" +FLUTTER_DESKTOP_FEATURES = "dart,openssl_vendored" [env.production-linux-aarch64] +CARGO_PROFILE = "release" BUILD_FLAG = "release" TARGET_OS = "linux" RUST_COMPILE_TARGET = "aarch64-unknown-linux-gnu" @@ -143,51 +173,73 @@ FLUTTER_OUTPUT_DIR = "Release" LIB_EXT = "so" LINUX_ARCH = "arm64" APP_ENVIRONMENT = "production" +FLUTTER_DESKTOP_FEATURES = "dart,openssl_vendored" -[tasks.echo_env] -script = [''' - echo "-------- Env Parameters --------" - echo CRATE_TYPE: ${CRATE_TYPE} - echo BUILD_FLAG: ${BUILD_FLAG} - echo TARGET_OS: ${TARGET_OS} - echo RUST_COMPILE_TARGET: ${RUST_COMPILE_TARGET} - echo FEATURES: ${FEATURES} - echo PRODUCT_EXT: ${PRODUCT_EXT} - echo APP_ENVIRONMENT: ${APP_ENVIRONMENT} - echo ${platforms} - echo ${BUILD_ARCHS} - '''] -script_runner = "@shell" +[env.development-ios-arm64-sim] +BUILD_FLAG = "debug" +TARGET_OS = "ios" +FLUTTER_OUTPUT_DIR = "Debug" +RUST_COMPILE_TARGET = "aarch64-apple-ios-sim" +BUILD_ARCHS = "arm64" +CRATE_TYPE = "staticlib" -[env.production-ios] +[env.development-ios-arm64] +BUILD_FLAG = "debug" +TARGET_OS = "ios" +FLUTTER_OUTPUT_DIR = "Debug" +RUST_COMPILE_TARGET = "aarch64-apple-ios" +BUILD_ARCHS = "arm64" +CRATE_TYPE = "staticlib" + +[env.production-ios-arm64] BUILD_FLAG = "release" TARGET_OS = "ios" FLUTTER_OUTPUT_DIR = "Release" -PRODUCT_EXT = "ipa" +RUST_COMPILE_TARGET = "aarch64-apple-ios" +BUILD_ARCHS = "arm64" +CRATE_TYPE = "staticlib" [env.development-android] BUILD_FLAG = "debug" TARGET_OS = "android" CRATE_TYPE = "cdylib" FLUTTER_OUTPUT_DIR = "Debug" -FEATURES = "flutter,openssl_vendored" +LIB_EXT = "so" +PRODUCT_EXT = "apk" +FLUTTER_DESKTOP_FEATURES = "dart,openssl_vendored" [env.production-android] BUILD_FLAG = "release" TARGET_OS = "android" CRATE_TYPE = "cdylib" FLUTTER_OUTPUT_DIR = "Release" -FEATURES = "flutter,openssl_vendored" +PRODUCT_EXT = "apk" +LIB_EXT = "so" + +[tasks.echo_env] +script = [''' + echo "-------- Env Parameters --------" + echo CRATE_TYPE: ${CRATE_TYPE} + echo BUILD_FLAG: ${BUILD_FLAG} + echo TARGET_OS: ${TARGET_OS} + echo RUST_COMPILE_TARGET: ${RUST_COMPILE_TARGET} + echo FEATURES: ${FLUTTER_DESKTOP_FEATURES} + echo PRODUCT_EXT: ${PRODUCT_EXT} + echo APP_ENVIRONMENT: ${APP_ENVIRONMENT} + echo BUILD_ARCHS: ${BUILD_ARCHS} + echo BUILD_VERSION: ${BUILD_VERSION} + '''] +script_runner = "@shell" [tasks.setup-crate-type] private = true script = [ """ - toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml - val = replace ${toml} "staticlib" ${CRATE_TYPE} - result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val} - assert ${result} - """, + toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml + val = replace ${toml} "staticlib" ${CRATE_TYPE} + result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val} + assert ${result} + """, ] script_runner = "@duckscript" @@ -195,20 +247,25 @@ script_runner = "@duckscript" private = true script = [ """ - toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml - val = replace ${toml} ${CRATE_TYPE} "staticlib" - result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val} - assert ${result} - """, + toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml + val = replace ${toml} ${CRATE_TYPE} "staticlib" + result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val} + assert ${result} + """, ] script_runner = "@duckscript" -[env.test-macos] +[env.test-macos-x86_64] TEST_CRATE_TYPE = "cdylib" TEST_LIB_EXT = "dylib" # For the moment, the DynamicLibrary only supports open x86_64 architectures binary. TEST_COMPILE_TARGET = "x86_64-apple-darwin" +[env.test-macos-arm64] +TEST_CRATE_TYPE = "cdylib" +TEST_LIB_EXT = "dylib" +TEST_COMPILE_TARGET = "aarch64-apple-darwin" + [env.test-linux] TEST_CRATE_TYPE = "cdylib" TEST_LIB_EXT = "so" @@ -223,11 +280,11 @@ TEST_COMPILE_TARGET = "x86_64-pc-windows-msvc" private = true script = [ """ - toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml - val = replace ${toml} "staticlib" ${TEST_CRATE_TYPE} - result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val} - assert ${result} - """, + toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml + val = replace ${toml} "staticlib" ${TEST_CRATE_TYPE} + result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val} + assert ${result} + """, ] script_runner = "@duckscript" @@ -235,11 +292,11 @@ script_runner = "@duckscript" private = true script = [ """ - toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml - val = replace ${toml} ${TEST_CRATE_TYPE} "staticlib" - result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val} - assert ${result} - """, + toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml + val = replace ${toml} ${TEST_CRATE_TYPE} "staticlib" + result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val} + assert ${result} + """, ] script_runner = "@duckscript" @@ -250,5 +307,3 @@ script = [""" cargo build -vv --features=dart """] script_runner = "@shell" - - diff --git a/frontend/app_flowy/.gitignore b/frontend/app_flowy/.gitignore deleted file mode 100644 index e43862d70ecd8..0000000000000 --- a/frontend/app_flowy/.gitignore +++ /dev/null @@ -1,68 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ - -# 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 -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -.pub-cache/ -.pub/ -/build/ - -# Web related -lib/generated_plugin_registrant.dart - -# Language related generated files -lib/generated/ - -# Freezed generated files -*.g.dart -*.freezed.dart - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release - -/packages/flowy_protobuf -/packages/flutter-quill - -product/** -windows/flutter/dart_ffi/ - -**/**/*.dylib -**/**/*.a -**/**/*.lib -**/**/*.dll -**/**/*.so -**/**/Brewfile.lock.json -**/.sandbox -**/.vscode/ \ No newline at end of file diff --git a/frontend/app_flowy/Makefile b/frontend/app_flowy/Makefile deleted file mode 100644 index 1f35bf6aafed6..0000000000000 --- a/frontend/app_flowy/Makefile +++ /dev/null @@ -1,7 +0,0 @@ -.PHONY: freeze_build, free_watch - -freeze_build: - flutter pub run build_runner build --delete-conflicting-outputs - -watch: - flutter pub run build_runner watch \ No newline at end of file diff --git a/frontend/app_flowy/README.md b/frontend/app_flowy/README.md deleted file mode 100644 index 1b037500e12ac..0000000000000 --- a/frontend/app_flowy/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# app_flowy - -A new Flutter project. - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) - -For help getting started with Flutter, view our -[online documentation](https://flutter.dev/docs), which offers tutorials, -samples, guidance on mobile development, and a full API reference. - - -## release check -1. [entitlements](https://flutter.dev/desktop#setting-up-entitlements) -2. [symbols stripped](https://flutter.dev/docs/development/platform-integration/c-interop) \ No newline at end of file diff --git a/frontend/app_flowy/analysis_options.yaml b/frontend/app_flowy/analysis_options.yaml deleted file mode 100644 index e345c3da7685b..0000000000000 --- a/frontend/app_flowy/analysis_options.yaml +++ /dev/null @@ -1,40 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. - -include: package:flutter_lints/flutter.yaml - -analyzer: - exclude: - - "**/*.g.dart" - - "**/*.freezed.dart" - - "packages/appflowy_editor/**" - - "packages/editor/**" - # - "packages/flowy_infra_ui/**" -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options - -errors: - invalid_annotation_target: ignore diff --git a/frontend/app_flowy/android/app/build.gradle b/frontend/app_flowy/android/app/build.gradle deleted file mode 100644 index 2fc26c2fb507d..0000000000000 --- a/frontend/app_flowy/android/app/build.gradle +++ /dev/null @@ -1,75 +0,0 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -android { - compileSdkVersion 31 - ndkVersion "24.0.8215888" - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - main.jniLibs.srcDirs += 'jniLibs/' - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.example.app_flowy" - minSdkVersion 19 - targetSdkVersion 31 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - multiDexEnabled true - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - minifyEnabled true - shrinkResources true - - signingConfig signingConfigs.debug - } - } -} - -flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation "com.android.support:multidex:2.0.1" -} diff --git a/frontend/app_flowy/android/app/src/debug/AndroidManifest.xml b/frontend/app_flowy/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index bbcce3827b15d..0000000000000 --- a/frontend/app_flowy/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/frontend/app_flowy/android/app/src/main/AndroidManifest.xml b/frontend/app_flowy/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index a34dc985874d7..0000000000000 --- a/frontend/app_flowy/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/android/app/src/main/kotlin/com/example/app_flowy/MainActivity.kt b/frontend/app_flowy/android/app/src/main/kotlin/com/example/app_flowy/MainActivity.kt deleted file mode 100644 index cd02c5ed92d95..0000000000000 --- a/frontend/app_flowy/android/app/src/main/kotlin/com/example/app_flowy/MainActivity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.app_flowy - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity: FlutterActivity() { -} diff --git a/frontend/app_flowy/android/app/src/profile/AndroidManifest.xml b/frontend/app_flowy/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index bbcce3827b15d..0000000000000 --- a/frontend/app_flowy/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/frontend/app_flowy/android/build.gradle b/frontend/app_flowy/android/build.gradle deleted file mode 100644 index 09fbd6404c82c..0000000000000 --- a/frontend/app_flowy/android/build.gradle +++ /dev/null @@ -1,29 +0,0 @@ -buildscript { - ext.kotlin_version = '1.6.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" - project.evaluationDependsOn(':app') -} - -task clean(type: Delete) { - delete rootProject.buildDir -} diff --git a/frontend/app_flowy/android/gradle.properties b/frontend/app_flowy/android/gradle.properties deleted file mode 100644 index 792b531d57290..0000000000000 --- a/frontend/app_flowy/android/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M -android.useAndroidX=true -android.enableJetifier=true -org.gradle.caching=true diff --git a/frontend/app_flowy/android/gradle/wrapper/gradle-wrapper.properties b/frontend/app_flowy/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 1acc777d741ca..0000000000000 --- a/frontend/app_flowy/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-all.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/frontend/app_flowy/assets/images/app_flowy_logo.jpg b/frontend/app_flowy/assets/images/app_flowy_logo.jpg deleted file mode 100644 index bb27e0ddb1ecf..0000000000000 Binary files a/frontend/app_flowy/assets/images/app_flowy_logo.jpg and /dev/null differ diff --git a/frontend/app_flowy/assets/images/editor/Favorite/active.svg b/frontend/app_flowy/assets/images/editor/Favorite/active.svg deleted file mode 100644 index 8ad54bbbb5733..0000000000000 --- a/frontend/app_flowy/assets/images/editor/Favorite/active.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/assets/images/editor/Favorite/default.svg b/frontend/app_flowy/assets/images/editor/Favorite/default.svg deleted file mode 100644 index 0ccfc1edff86e..0000000000000 --- a/frontend/app_flowy/assets/images/editor/Favorite/default.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/assets/images/editor/board.svg b/frontend/app_flowy/assets/images/editor/board.svg deleted file mode 100644 index 550d045178da5..0000000000000 --- a/frontend/app_flowy/assets/images/editor/board.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/app_flowy/assets/images/editor/copy.svg b/frontend/app_flowy/assets/images/editor/copy.svg deleted file mode 100644 index f11048fd2ff7d..0000000000000 --- a/frontend/app_flowy/assets/images/editor/copy.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/editor/documents.svg b/frontend/app_flowy/assets/images/editor/documents.svg deleted file mode 100644 index e232eba85dc0d..0000000000000 --- a/frontend/app_flowy/assets/images/editor/documents.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/editor/editor_check.svg b/frontend/app_flowy/assets/images/editor/editor_check.svg deleted file mode 100644 index 8446cced9f680..0000000000000 --- a/frontend/app_flowy/assets/images/editor/editor_check.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/editor/grid.svg b/frontend/app_flowy/assets/images/editor/grid.svg deleted file mode 100644 index 8164b24e6adc6..0000000000000 --- a/frontend/app_flowy/assets/images/editor/grid.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/app_flowy/assets/images/editor/group.svg b/frontend/app_flowy/assets/images/editor/group.svg deleted file mode 100644 index f0a6dff4f9b6f..0000000000000 --- a/frontend/app_flowy/assets/images/editor/group.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/app_flowy/assets/images/editor/hide.svg b/frontend/app_flowy/assets/images/editor/hide.svg deleted file mode 100644 index 45e81d8748176..0000000000000 --- a/frontend/app_flowy/assets/images/editor/hide.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/editor/insert_emoticon.svg b/frontend/app_flowy/assets/images/editor/insert_emoticon.svg deleted file mode 100644 index 8bb960e52d430..0000000000000 --- a/frontend/app_flowy/assets/images/editor/insert_emoticon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/app_flowy/assets/images/editor/insert_emoticon_2.svg b/frontend/app_flowy/assets/images/editor/insert_emoticon_2.svg deleted file mode 100644 index 66bbf7a626a6b..0000000000000 --- a/frontend/app_flowy/assets/images/editor/insert_emoticon_2.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/app_flowy/assets/images/editor/page.svg b/frontend/app_flowy/assets/images/editor/page.svg deleted file mode 100644 index f0fb8ce9cefb8..0000000000000 --- a/frontend/app_flowy/assets/images/editor/page.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/editor/search.svg b/frontend/app_flowy/assets/images/editor/search.svg deleted file mode 100644 index 1efb2d475c980..0000000000000 --- a/frontend/app_flowy/assets/images/editor/search.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/editor/send.svg b/frontend/app_flowy/assets/images/editor/send.svg deleted file mode 100644 index a5f933a8ca3d0..0000000000000 --- a/frontend/app_flowy/assets/images/editor/send.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/assets/images/editor/settings.svg b/frontend/app_flowy/assets/images/editor/settings.svg deleted file mode 100644 index f9896aad5250c..0000000000000 --- a/frontend/app_flowy/assets/images/editor/settings.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/file_icon.jpg b/frontend/app_flowy/assets/images/file_icon.jpg deleted file mode 100644 index 88865fa0042ec..0000000000000 Binary files a/frontend/app_flowy/assets/images/file_icon.jpg and /dev/null differ diff --git a/frontend/app_flowy/assets/images/file_icon.svg b/frontend/app_flowy/assets/images/file_icon.svg deleted file mode 100644 index f0fb8ce9cefb8..0000000000000 --- a/frontend/app_flowy/assets/images/file_icon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/flowy_logo_dark_mode.svg b/frontend/app_flowy/assets/images/flowy_logo_dark_mode.svg deleted file mode 100644 index 911f37152b5e4..0000000000000 --- a/frontend/app_flowy/assets/images/flowy_logo_dark_mode.svg +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/assets/images/grid/checkmark.svg b/frontend/app_flowy/assets/images/grid/checkmark.svg deleted file mode 100644 index f9c848f71359b..0000000000000 --- a/frontend/app_flowy/assets/images/grid/checkmark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/assets/images/grid/delete.svg b/frontend/app_flowy/assets/images/grid/delete.svg deleted file mode 100644 index fcfbf2f6dd5ab..0000000000000 --- a/frontend/app_flowy/assets/images/grid/delete.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/app_flowy/assets/images/grid/details.svg b/frontend/app_flowy/assets/images/grid/details.svg deleted file mode 100644 index e4c9f58f27a52..0000000000000 --- a/frontend/app_flowy/assets/images/grid/details.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/grid/duplicate.svg b/frontend/app_flowy/assets/images/grid/duplicate.svg deleted file mode 100644 index f11048fd2ff7d..0000000000000 --- a/frontend/app_flowy/assets/images/grid/duplicate.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/grid/expander.svg b/frontend/app_flowy/assets/images/grid/expander.svg deleted file mode 100644 index 179bdb1a9ed3d..0000000000000 --- a/frontend/app_flowy/assets/images/grid/expander.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/app_flowy/assets/images/grid/field/checklist.svg b/frontend/app_flowy/assets/images/grid/field/checklist.svg deleted file mode 100644 index 3a88d236a1b66..0000000000000 --- a/frontend/app_flowy/assets/images/grid/field/checklist.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/grid/field/euro.svg b/frontend/app_flowy/assets/images/grid/field/euro.svg deleted file mode 100644 index 95f511f687fa1..0000000000000 --- a/frontend/app_flowy/assets/images/grid/field/euro.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/assets/images/grid/field/number.svg b/frontend/app_flowy/assets/images/grid/field/number.svg deleted file mode 100644 index 9d8b98d10d555..0000000000000 --- a/frontend/app_flowy/assets/images/grid/field/number.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/assets/images/grid/field/single_select.svg b/frontend/app_flowy/assets/images/grid/field/single_select.svg deleted file mode 100644 index 8ccbc9a2e3f4f..0000000000000 --- a/frontend/app_flowy/assets/images/grid/field/single_select.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/grid/field/us_dollar.svg b/frontend/app_flowy/assets/images/grid/field/us_dollar.svg deleted file mode 100644 index a8485cd6a1c58..0000000000000 --- a/frontend/app_flowy/assets/images/grid/field/us_dollar.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/assets/images/grid/field/yen.svg b/frontend/app_flowy/assets/images/grid/field/yen.svg deleted file mode 100644 index 8e9bf47c99a92..0000000000000 --- a/frontend/app_flowy/assets/images/grid/field/yen.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/assets/images/grid/hide.svg b/frontend/app_flowy/assets/images/grid/hide.svg deleted file mode 100644 index dfb6dbb90ce97..0000000000000 --- a/frontend/app_flowy/assets/images/grid/hide.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/grid/more.svg b/frontend/app_flowy/assets/images/grid/more.svg deleted file mode 100644 index b191e64a10343..0000000000000 --- a/frontend/app_flowy/assets/images/grid/more.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/assets/images/grid/setting/group.svg b/frontend/app_flowy/assets/images/grid/setting/group.svg deleted file mode 100644 index f0a6dff4f9b6f..0000000000000 --- a/frontend/app_flowy/assets/images/grid/setting/group.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/app_flowy/assets/images/grid/setting/setting.svg b/frontend/app_flowy/assets/images/grid/setting/setting.svg deleted file mode 100644 index 3d632703abc84..0000000000000 --- a/frontend/app_flowy/assets/images/grid/setting/setting.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/grid/setting/sort.svg b/frontend/app_flowy/assets/images/grid/setting/sort.svg deleted file mode 100644 index 06e17d62a9613..0000000000000 --- a/frontend/app_flowy/assets/images/grid/setting/sort.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/images/home/new_app.svg b/frontend/app_flowy/assets/images/home/new_app.svg deleted file mode 100644 index ac6c002d3d1ff..0000000000000 --- a/frontend/app_flowy/assets/images/home/new_app.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/app_flowy/assets/images/home/show.svg b/frontend/app_flowy/assets/images/home/show.svg deleted file mode 100644 index 3550115093886..0000000000000 --- a/frontend/app_flowy/assets/images/home/show.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/assets/translations/ca-ES.json b/frontend/app_flowy/assets/translations/ca-ES.json deleted file mode 100644 index 23ae51bd699de..0000000000000 --- a/frontend/app_flowy/assets/translations/ca-ES.json +++ /dev/null @@ -1,149 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Jo", - "welcomeText": "Benvingut a @:appName", - "githubStarText": "Preferit a Github", - "subscribeNewsletterText": "Subscriu-me al butlletí", - "letsGoButtonText": "Endavant", - "title": "Títol", - "signUp": { - "buttonText": "Registra't", - "title": "Registra't a @:appName", - "getStartedText": "Comencem", - "emptyPasswordError": "La contrasenya no pot ser buida", - "repeatPasswordEmptyError": "La contrasenya repetida no pot ser buida", - "unmatchedPasswordError": "Les contrasenyes no concorden", - "alreadyHaveAnAccount": "Ja tens un compte?", - "emailHint": "Correu electrònic", - "passwordHint": "Contrasenya", - "repeatPasswordHint": "Repeteix la contrasenya" - }, - "signIn": { - "loginTitle": "Inicia sessió a @:appName", - "loginButtonText": "Inicia sessió", - "buttonText": "Inicia sessió", - "forgotPassword": "Has oblidat la contrasenya?", - "emailHint": "Correu electrònic", - "passwordHint": "Contrasenya", - "dontHaveAnAccount": "No tens un compte?", - "repeatPasswordEmptyError": "La contrasenya repetida no pot ser buida", - "unmatchedPasswordError": "Les contrasenyes no concorden" - }, - "workspace": { - "create": "Crear un espai de treball", - "hint": "espai de treball", - "notFoundError": "No s'ha trobat l'espai de treball" - }, - "shareAction": { - "buttonText": "Compartir", - "workInProgress": "Pròximament", - "markdown": "Markdown", - "copyLink": "Copiar l'enllaç" - }, - "disclosureAction": { - "rename": "Canviar el nom", - "delete": "Eliminar", - "duplicate": "Duplicar" - }, - "blankPageTitle": "Pàgina en blanc", - "newPageText": "Nova pàgina", - "trash": { - "text": "Paperera", - "restoreAll": "Recuperar-ho tot", - "deleteAll": "Eliminar-ho tot", - "pageHeader": { - "fileName": "Nom del fitxer", - "lastModified": "Última modificació", - "created": "Creat" - } - }, - "deletePagePrompt": { - "text": "Aquest pàgina es troba a la paperera", - "restore": "Recuperar-la", - "deletePermanent": "Elimina-la" - }, - "dialogCreatePageNameHint": "Nom de la pàgina", - "questionBubble": { - "whatsNew": "Què hi ha de nou?", - "help": "Ajuda i Suport", - "debug": { - "name": "Informació de depuració", - "success": "S'ha copiat la informació de depuració!", - "fail": "No es pot copiar la informació de depuració" - } - }, - "menuAppHeader": { - "addPageTooltip": "Afegeix ràpidament una pàgina dins", - "defaultNewPageName": "Sense títol", - "renameDialog": "Canviar el nom" - }, - "toolbar": { - "undo": "Desfer", - "redo": "Refer", - "bold": "Negreta", - "italic": "Cursiva", - "underline": "Text subratllar", - "strike": "Ratllat", - "numList": "Llista numerada", - "bulletList": "Llista de punts", - "checkList": "Llista de comprovació", - "inlineCode": "Inserir codi", - "quote": "Bloc citat", - "header": "Capçalera", - "highlight": "Subratllar" - }, - "tooltip": { - "lightMode": "Canviar a mode clar", - "darkMode": "Canviar a mode fosc" - }, - "contactsPage": { - "title": "Contactes", - "whatsHappening": "Que passa aquesta setmana?", - "addContact": "Afegir un contacte", - "editContact": "Editar un contacte" - }, - "button": { - "OK": "OK", - "Cancel": "Cancel·lar", - "signIn": "Iniciar sessió", - "signOut": "Tancar sessió", - "complete": "Completar", - "save": "Guardar" - }, - "label": { - "welcome": "Benvingut!", - "firstName": "Nom", - "middleName": "Segon Nom", - "lastName": "Cognom", - "stepX": "Pas {X}" - }, - "oAuth": { - "err": { - "failedTitle": "No s'ha pogut connectar al teu compte.", - "failedMsg": "Assegureu-vos que heu completat el procés d'inici de sessió al vostre navegador." - }, - "google": { - "title": "Iniciar sessió amb Google", - "instruction1": "Per importar els vostres contactes de Google, haureu d'autoritzar aquesta aplicació mitjançant el vostre navegador web.", - "instruction2": "Copia aquest codi clicant la icona o seleccionant el text:", - "instruction3": "Navega al següent enllaç amb el teu navegador i insereix el codi anterior:", - "instruction4": "Pressiona el botó d'avall una vegada hagis completat el registre:" - } - }, - "settings": { - "title": "Configuració", - "menu": { - "appearance": "Aparença", - "language": "Idioma", - "open": "Obrir la configuració" - }, - "appearance": { - "lightLabel": "Mode Clar", - "darkLabel": "Mode Fosc" - } - }, - "sideBar": { - "openSidebar": "Open sidebar", - "closeSidebar": "Close sidebar" - } -} diff --git a/frontend/app_flowy/assets/translations/de-DE.json b/frontend/app_flowy/assets/translations/de-DE.json deleted file mode 100644 index 6ce661e7b9ffa..0000000000000 --- a/frontend/app_flowy/assets/translations/de-DE.json +++ /dev/null @@ -1,150 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Ich", - "welcomeText": "Willkommen bei @:appName", - "githubStarText": "GitHub Star vergeben", - "subscribeNewsletterText": "Abonniere den Newsletter", - "letsGoButtonText": "Los geht's", - "title": "Titel", - "signUp": { - "buttonText": "Registrieren", - "title": "Registriere dich bei @:appName", - "getStartedText": "Erste Schritte", - "emptyPasswordError": "Passwort darf nicht leer sein", - "repeatPasswordEmptyError": "Passwortwiederholung darf nicht leer sein", - "unmatchedPasswordError": "Passwörter stimmen nicht überein", - "alreadyHaveAnAccount": "Bereits registriert?", - "emailHint": "E-Mail", - "passwordHint": "Passwort", - "repeatPasswordHint": "Wiederhole Passwort" - }, - "signIn": { - "loginTitle": "Bei @:appName einloggen", - "loginButtonText": "Anmelden", - "buttonText": "Anmelden", - "forgotPassword": "Passwort vergessen?", - "emailHint": "E-Mail", - "passwordHint": "Passwort", - "dontHaveAnAccount": "Du besitzt noch kein Konto?", - "repeatPasswordEmptyError": "Passwortwiederholung darf nicht leer sein", - "unmatchedPasswordError": "Passwörter stimmen nicht überein" - }, - "workspace": { - "create": "Arbeitsbereich erstellen", - "hint": "Arbeitsbereich", - "notFoundError": "Arbeitsbereich nicht gefunden" - }, - "shareAction": { - "buttonText": "Teilen", - "workInProgress": "Demnächst verfügbar", - "markdown": "Markdown", - "copyLink": "Link kopieren" - }, - "disclosureAction": { - "rename": "Umbenennen", - "delete": "Löschen", - "duplicate": "Duplizieren" - }, - "blankPageTitle": "Leere Seite", - "newPageText": "Neue Seite", - "trash": { - "text": "Papierkorb", - "restoreAll": "Alles wiederherstellen", - "deleteAll": "Alles löschen", - "pageHeader": { - "fileName": "Dateiname", - "lastModified": "Letzte Änderung", - "created": "Erstellt" - } - }, - "deletePagePrompt": { - "text": "Diese Seite ist im Papierkorb", - "restore": "Seite wiederherstellen", - "deletePermanent": "Dauerhaft löschen" - }, - "dialogCreatePageNameHint": "Seitenname", - "questionBubble": { - "whatsNew": "Was gibt es Neues?", - "help": "Hilfe & Support", - "debug": { - "name": "Debug-Informationen", - "success": "Debug-Informationen in die Zwischenablage kopiert!", - "fail": "Debug-Informationen können nicht in die Zwischenablage kopiert werden" - } - }, - "menuAppHeader": { - "addPageTooltip": "Schnell eine Seite innerhalb hinzufügen", - "defaultNewPageName": "Unbenannt", - "renameDialog": "Umbenennen" - }, - "toolbar": { - "undo": "Rückgängig", - "redo": "Wiederherstellen", - "bold": "Fett", - "italic": "Kursiv", - "underline": "Unterstreichen", - "strike": "Durchstreichen", - "numList": "Nummerierte Liste", - "bulletList": "Aufzählung", - "checkList": "Checkliste", - "inlineCode": "Inline-Code", - "quote": "Zitat", - "header": "Überschrift", - "highlight": "Hervorhebung" - }, - "tooltip": { - "lightMode": "In den hellen Modus wechseln", - "darkMode": "In den dunklen Modus wechseln" - }, - "contactsPage": { - "title": "Kontakte", - "whatsHappening": "Was geschieht diese Woche?", - "addContact": "Kontakt hinzufügen", - "editContact": "Kontakt bearbeiten" - }, - "button": { - "OK": "OK", - "Cancel": "Abbrechen", - "signIn": "Anmelden", - "signOut": "Abmelden", - "complete": "Fertig", - "save": "Speichern" - }, - "label": { - "welcome": "Willkommen!", - "firstName": "Vorname", - "middleName": "Zweiter Vorname", - "lastName": "Nachname", - "stepX": "Schritt {X}" - }, - "oAuth": { - "err": { - "failedTitle": "Keine Verbindung zu Ihrem Konto möglich.", - "failedMsg": "Bitte vergewissern Sie sich, dass Sie den Anmeldevorgang in Ihrem Browser abgeschlossen haben." - }, - "google": { - "title": "GOOGLE ANMELDUNG", - "instruction1": "Um Ihre Google-Kontakte zu importieren, müssen Sie diese Anwendung über Ihren Webbrowser autorisieren.", - "instruction2": "Kopieren Sie diesen Code in Ihre Zwischenablage, indem Sie auf das Symbol klicken oder den Text auswählen:", - "instruction3": "Rufen Sie den folgenden Link in Ihrem Webbrowser auf, und geben Sie den obigen Code ein:", - "instruction4": "Klicken Sie unten auf die Schaltfläche, wenn Sie die Anmeldung abgeschlossen haben:" - } - }, - "settings": { - "title": "Einstellungen", - "menu": { - "appearance": "Aussehen", - "language": "Sprache", - "open": "Einstellungen öffnen" - }, - "appearance": { - "lightLabel": "Heller Modus", - "darkLabel": "Dunkler Modus" - } - }, - "sideBar": { - "openSidebar": "Open sidebar", - "closeSidebar": "Close sidebar" - } - } - \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/en.json b/frontend/app_flowy/assets/translations/en.json deleted file mode 100644 index 2381d5b28861b..0000000000000 --- a/frontend/app_flowy/assets/translations/en.json +++ /dev/null @@ -1,235 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Me", - "welcomeText": "Welcome to @:appName", - "githubStarText": "Star on GitHub", - "subscribeNewsletterText": "Subscribe to Newsletter", - "letsGoButtonText": "Let's Go", - "title": "Title", - "signUp": { - "buttonText": "Sign Up", - "title": "Sign Up to @:appName", - "getStartedText": "Get Started", - "emptyPasswordError": "Password can't be empty", - "repeatPasswordEmptyError": "Repeat password can't be empty", - "unmatchedPasswordError": "Repeat password is not the same as password", - "alreadyHaveAnAccount": "Already have an account?", - "emailHint": "Email", - "passwordHint": "Password", - "repeatPasswordHint": "Repeat password" - }, - "signIn": { - "loginTitle": "Login to @:appName", - "loginButtonText": "Login", - "buttonText": "Sign In", - "forgotPassword": "Forgot Password?", - "emailHint": "Email", - "passwordHint": "Password", - "dontHaveAnAccount": "Don't have an account?", - "repeatPasswordEmptyError": "Repeat password can't be empty", - "unmatchedPasswordError": "Repeat password is not the same as password" - }, - "workspace": { - "create": "Create workspace", - "hint": "workspace", - "notFoundError": "Workspace not found" - }, - "shareAction": { - "buttonText": "Share", - "workInProgress": "Coming soon", - "markdown": "Markdown", - "copyLink": "Copy Link" - }, - "disclosureAction": { - "rename": "Rename", - "delete": "Delete", - "duplicate": "Duplicate" - }, - "blankPageTitle": "Blank page", - "newPageText": "New page", - "trash": { - "text": "Trash", - "restoreAll": "Restore All", - "deleteAll": "Delete All", - "pageHeader": { - "fileName": "File name", - "lastModified": "Last Modified", - "created": "Created" - } - }, - "deletePagePrompt": { - "text": "This page is in Trash", - "restore": "Restore page", - "deletePermanent": "Delete permanently" - }, - "dialogCreatePageNameHint": "Page name", - "questionBubble": { - "whatsNew": "What's new?", - "help": "Help & Support", - "debug": { - "name": "Debug Info", - "success": "Copied debug info to clipboard!", - "fail": "Unable to copy debug info to clipboard" - } - }, - "menuAppHeader": { - "addPageTooltip": "Quickly add a page inside", - "defaultNewPageName": "Untitled", - "renameDialog": "Rename" - }, - "toolbar": { - "undo": "Undo", - "redo": "Redo", - "bold": "Bold", - "italic": "Italic", - "underline": "Underline", - "strike": "Strikethrough", - "numList": "Numbered List", - "bulletList": "Bulleted List", - "checkList": "Check List", - "inlineCode": "Inline Code", - "quote": "Quote Block", - "header": "Header", - "highlight": "Highlight" - }, - "tooltip": { - "lightMode": "Switch to Light mode", - "darkMode": "Switch to Dark mode", - "openAsPage": "Open as a Page", - "addNewRow": "Add a new row", - "openMenu": "Click to open menu" - }, - "sideBar": { - "closeSidebar": "Close side bar", - "openSidebar": "Open side bar" - }, - "notifications": { - "export": { - "markdown": "Exported Note To Markdown", - "path": "Documents/flowy" - } - }, - "contactsPage": { - "title": "Contacts", - "whatsHappening": "What's happening this week?", - "addContact": "Add Contact", - "editContact": "Edit Contact" - }, - "button": { - "OK": "OK", - "Cancel": "Cancel", - "signIn": "Sign In", - "signOut": "Sign Out", - "complete": "Complete", - "save": "Save" - }, - "label": { - "welcome": "Welcome!", - "firstName": "First Name", - "middleName": "Middle Name", - "lastName": "Last Name", - "stepX": "Step {X}" - }, - "oAuth": { - "err": { - "failedTitle": "Unable to connect to your account.", - "failedMsg": "Please make sure you've completed the sign-in process in your browser." - }, - "google": { - "title": "GOOGLE SIGN-IN", - "instruction1": "In order to import your Google Contacts, you'll need to authorize this application using your web browser.", - "instruction2": "Copy this code to your clipboard by clicking the icon or selecting the text:", - "instruction3": "Navigate to the following link in your web browser, and enter the above code:", - "instruction4": "Press the button below when you've completed signup:" - } - }, - "settings": { - "title": "Settings", - "menu": { - "appearance": "Appearance", - "language": "Language", - "user": "User", - "open": "Open Settings" - }, - "appearance": { - "lightLabel": "Light Mode", - "darkLabel": "Dark Mode" - } - }, - "grid": { - "settings": { - "filter": "Filter", - "sortBy": "Sort by", - "Properties": "Properties", - "group": "Group" - }, - "field": { - "hide": "Hide", - "insertLeft": "Insert Left", - "insertRight": "Insert Right", - "duplicate": "Duplicate", - "delete": "Delete", - "textFieldName": "Text", - "checkboxFieldName": "Checkbox", - "dateFieldName": "Date", - "numberFieldName": "Numbers", - "singleSelectFieldName": "Select", - "multiSelectFieldName": "Multiselect", - "urlFieldName": "URL", - "numberFormat": " Number format", - "dateFormat": " Date format", - "includeTime": " Include time", - "dateFormatFriendly": "Month Day,Year", - "dateFormatISO": "Year-Month-Day", - "dateFormatLocal": "Month/Day/Year", - "dateFormatUS": "Year/Month/Day", - "timeFormat": " Time format", - "invalidTimeFormat": "Invalid format", - "timeFormatTwelveHour": "12 hour", - "timeFormatTwentyFourHour": "24 hour", - "addSelectOption": "Add an option", - "optionTitle": "Options", - "addOption": "Add option", - "editProperty": "Edit property", - "newColumn": "New column", - "deleteFieldPromptMessage": "Are you sure? This property will be deleted" - }, - "row": { - "duplicate": "Duplicate", - "delete": "Delete", - "textPlaceholder": "Empty", - "copyProperty": "Copied property to clipboard", - "count": "Count", - "newRow": "New row" - }, - "selectOption": { - "create": "Create", - "purpleColor": "Purple", - "pinkColor": "Pink", - "lightPinkColor": "Light Pink", - "orangeColor": "Orange", - "yellowColor": "Yellow", - "limeColor": "Lime", - "greenColor": "Green", - "aquaColor": "Aqua", - "blueColor": "Blue", - "deleteTag": "Delete tag", - "colorPanelTitle": "Colors", - "panelTitle": "Select an option or create one", - "searchOption": "Search for an option" - }, - "menuName": "Grid" - }, - "document": { - "menuName": "Doc", - "date": { - "timeHintTextInTwelveHour": "01:00 PM", - "timeHintTextInTwentyFourHour": "13:00" - } - }, - "board": { - "column": { - "create_new_card": "New" - } - } -} diff --git a/frontend/app_flowy/assets/translations/es-VE.json b/frontend/app_flowy/assets/translations/es-VE.json deleted file mode 100644 index f928c18cdaa20..0000000000000 --- a/frontend/app_flowy/assets/translations/es-VE.json +++ /dev/null @@ -1,226 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Mi", - "welcomeText": "Bienvenido a @:appName", - "githubStarText": "Favorito en GitHub", - "subscribeNewsletterText": "Suscribir al boletín", - "letsGoButtonText": "Vamos", - "title": "Título", - "signUp": { - "buttonText": "Registrar", - "title": "Registrar en @:appName", - "getStartedText": "Empezar", - "emptyPasswordError": "La contraseña no puede estar en blanco", - "repeatPasswordEmptyError": "La contraseña no puede estar en blanco", - "unmatchedPasswordError": "Las contraseñas no coinciden", - "alreadyHaveAnAccount": "¿Posee credenciales?", - "emailHint": "Correo", - "passwordHint": "Contraseña", - "repeatPasswordHint": "Repetir contraseña" - }, - "signIn": { - "loginTitle": "Ingresa a @:appName", - "loginButtonText": "Ingresar", - "buttonText": "Ingresar", - "forgotPassword": "¿Olvidó su contraseña?", - "emailHint": "Correo", - "passwordHint": "Contraseña", - "dontHaveAnAccount": "¿No posee credenciales?", - "repeatPasswordEmptyError": "La contraseña no puede estar en blanco", - "unmatchedPasswordError": "Las contraseñas no coinciden" - }, - "workspace": { - "create": "Crear espacio de trabajo", - "hint": "Espacio de trabajo", - "notFoundError": "Espacio de trabajo no encontrado" - }, - "shareAction": { - "buttonText": "Compartir", - "workInProgress": "Próximamente", - "markdown": "Marcador", - "copyLink": "Copiar enlace" - }, - "disclosureAction": { - "rename": "Renombrar", - "delete": "Eliminar", - "duplicate": "Duplicar" - }, - "blankPageTitle": "Página en blanco", - "newPageText": "Nueva página", - "trash": { - "text": "Papelera", - "restoreAll": "Recuperar todo", - "deleteAll": "Eliminar todo", - "pageHeader": { - "fileName": "Nombre de archivo", - "lastModified": "Última modificación", - "created": "Creado" - } - }, - "deletePagePrompt": { - "text": "Esta página está en la Papelera", - "restore": "Recuperar página", - "deletePermanent": "Eliminar permanentemente" - }, - "dialogCreatePageNameHint": "Nombre de página", - "questionBubble": { - "whatsNew": "¿Qué hay de nuevo?", - "help": "Ayuda y Soporte", - "debug": { - "name": "Información de depuración", - "success": "¡Información copiada!", - "fail": "No fue posible copiar la información" - } - }, - "menuAppHeader": { - "addPageTooltip": "Inserta una página", - "defaultNewPageName": "Sin Título", - "renameDialog": "Renombrar" - }, - "toolbar": { - "undo": "Deshacer", - "redo": "Rehacer", - "bold": "Negrita", - "italic": "Cursiva", - "underline": "Subrayado", - "strike": "Tachado", - "numList": "Lista numerada", - "bulletList": "Lista con viñetas", - "checkList": "Lista de verificación", - "inlineCode": "Código embebido", - "quote": "Cita", - "header": "Título", - "highlight": "Resaltado" - }, - "tooltip": { - "lightMode": "Cambiar a modo Claro", - "darkMode": "Cambiar a modo Oscuro" - }, - "notifications": { - "export": { - "markdown": "Nota exportada a Markdown", - "path": "Documentos/flowy" - } - }, - "contactsPage": { - "title": "Contactos", - "whatsHappening": "¿Qué está pasando esta semana?", - "addContact": "Agregar Contacto", - "editContact": "Editar Contacto" - }, - "button": { - "OK": "OK", - "Cancel": "Cancelar", - "signIn": "Ingresar", - "signOut": "Salir", - "complete": "Completar", - "save": "Guardar" - }, - "label": { - "welcome": "¡Bienvenido!", - "firstName": "Primer nombre", - "middleName": "Segundo nombre", - "lastName": "Apellido", - "stepX": "Paso {X}" - }, - "oAuth": { - "err": { - "failedTitle": "Imposible conectarse con sus credenciales.", - "failedMsg": "Por favor asegurese haber completado el proceso de ingreso en su navegador." - }, - "google": { - "title": "Ingresar con Google", - "instruction1": "Para importar sus contactos de Google, debe autorizar esta aplicación usando su navegador web.", - "instruction2": "Copie este código al presionar el icono o al seleccionar el texto:", - "instruction3": "Navege al siguiente enlace en su navegador web, e ingrese el código anterior:", - "instruction4": "Presione el botón de abajo cuando haya completado su registro:" - } - }, - "settings": { - "title": "Ajustes", - "menu": { - "appearance": "Apariencia", - "language": "Lenguaje", - "open": "Abrir ajustes" - }, - "appearance": { - "lightLabel": "Modo Claro", - "darkLabel": "Modo Oscuro" - } - }, - "grid": { - "settings": { - "filter": "Filtrar", - "sortBy": "Ordenar por", - "Properties": "Propiedades" - }, - "field": { - "hide": "Ocultar", - "insertLeft": "Insertar a la Izquierda", - "insertRight": "Insertar a la Derecha", - "duplicate": "Duplicar", - "delete": "Eliminar", - "textFieldName": "Texto", - "checkboxFieldName": "Casilla de verificación", - "dateFieldName": "Fecha", - "numberFieldName": "Números", - "singleSelectFieldName": "Seleccionar", - "multiSelectFieldName": "Selección múltiple", - "urlFieldName": "URL", - "numberFormat": "Formato numérico", - "dateFormat": "Formato de fecha", - "includeTime": "Incluir tiempo", - "dateFormatFriendly": "Mes Día, Año", - "dateFormatISO": "Año-Mes-Día", - "dateFormatLocal": "Mes/Día/Año", - "dateFormatUS": "Año/Mes/Día", - "timeFormat": "Formato de tiempo", - "invalidTimeFormat": "Formato de tiempo inválido", - "timeFormatTwelveHour": "12 horas", - "timeFormatTwentyFourHour": "24 horas", - "addSelectOption": "Añadir una opción", - "optionTitle": "Opciones", - "addOption": "Añadir opción", - "editProperty": "Editar propiedad" - }, - "row": { - "duplicate": "Duplicar", - "delete": "Eliminar", - "textPlaceholder": "Vacío", - "copyProperty": "Propiedad copiada al portapapeles" - }, - "selectOption": { - "create": "Crear", - "purpleColor": "Morado", - "pinkColor": "Rosa", - "lightPinkColor": "Rosa Claro", - "orangeColor": "Naranja", - "yellowColor": "Amarillo", - "limeColor": "Lima", - "greenColor": "Verde", - "aquaColor": "Agua", - "blueColor": "Azul", - "deleteTag": "Borrar etiqueta", - "colorPanelTitle": "Colores", - "panelTitle": "Selecciona una opción o crea una", - "searchOption": "Buscar una opción" - }, - "menuName": "Cuadrícula" - }, - "document": { - "menuName": "Documento", - "date": { - "timeHintTextInTwelveHour": "01:00 PM", - "timeHintTextInTwentyFourHour": "13:00" - } - }, - "sideBar": { - "openSidebar": "Abrir panel lateral", - "closeSidebar": "Cerrar panel lateral" - }, - "board": { - "column": { - "create_new_card": "Nuevo" - } - } -} diff --git a/frontend/app_flowy/assets/translations/fr-CA.json b/frontend/app_flowy/assets/translations/fr-CA.json deleted file mode 100644 index ff7f5ca251e6f..0000000000000 --- a/frontend/app_flowy/assets/translations/fr-CA.json +++ /dev/null @@ -1,149 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Moi", - "welcomeText": "Bienvenue sur @:appName", - "githubStarText": "Favoriser sur GitHub", - "subscribeNewsletterText": "Abonnez-vous à notre courriel", - "letsGoButtonText": "Allons-y", - "title": "Titre", - "signUp": { - "buttonText": "S'inscrire", - "title": "S'inscrire à @:appName", - "getStartedText": "Commencer", - "emptyPasswordError": "Vous n'avez pas saisi votre mot de passe", - "repeatPasswordEmptyError": "Mot de passe ne doit pas être vide", - "unmatchedPasswordError": "Les deux mots de passe ne sont pas identiques", - "alreadyHaveAnAccount": "Avez-vous déjà un compte?", - "emailHint": "courriel", - "passwordHint": "Mot de passe", - "repeatPasswordHint": "Ressaisir votre mot de passe" - }, - "signIn": { - "loginTitle": "Connexion à @:appName", - "loginButtonText": "Connexion", - "buttonText": "Se connecter", - "forgotPassword": "Mot de passe oublié?", - "emailHint": "courriel", - "passwordHint": "Mot de passe", - "dontHaveAnAccount": "Vous n'avez pas de compte?", - "repeatPasswordEmptyError": "Mot de passe ne doit pas être vide", - "unmatchedPasswordError": "Les deux mots de passe ne sont pas identiques" - }, - "workspace": { - "create": "Créer un espace de travail", - "hint": "Espace de travail", - "notFoundError": "Espace de travail introuvable" - }, - "shareAction": { - "buttonText": "Partager", - "workInProgress": "Bientôt disponible", - "markdown": "Markdown", - "copyLink": "Copier le lien" - }, - "disclosureAction": { - "rename": "Renommer", - "delete": "Supprimer", - "duplicate": "Dupliquer" - }, - "blankPageTitle": "Page vierge", - "newPageText": "Nouvelle page", - "trash": { - "text": "Corbeille", - "restoreAll": "Tout récupérer", - "deleteAll": "Tout supprimer", - "pageHeader": { - "fileName": "Nom de fichier", - "lastModified": "Dernière modification", - "created": "Créé" - } - }, - "deletePagePrompt": { - "text": "Cette page est dans la corbeille", - "restore": "Récupérer la page", - "deletePermanent": "Supprimer définitivement" - }, - "dialogCreatePageNameHint": "Nom de la page", - "questionBubble": { - "whatsNew": "Nouveautés", - "help": "Aide et Support Technique", - "debug": { - "name": "Infos du système", - "success": "Info copié!", - "fail": "Impossible de copier l'info" - } - }, - "menuAppHeader": { - "addPageTooltip": "Ajouter une page", - "defaultNewPageName": "Sans titre", - "renameDialog": "Renommer" - }, - "toolbar": { - "undo": "Annuler", - "redo": "Rétablir", - "bold": "Gras", - "italic": "Italique", - "underline": "Souligner", - "strike": "Barré", - "numList": "Liste numérotée", - "bulletList": "Liste à puces", - "checkList": "Liste de contrôle", - "inlineCode": "Code en ligne", - "quote": "Citation", - "header": "En-tête", - "highlight": "Surligner" - }, - "tooltip": { - "lightMode": "Passer en mode clair", - "darkMode": "Passer en mode sombre" - }, - "contactsPage": { - "title": "Contacts", - "whatsHappening": "Quoi de neuf?", - "addContact": "Ajouter un contact", - "editContact": "Modifier le contact" - }, - "button": { - "OK": "OK", - "Cancel": "Annuler", - "signIn": "Se connecter", - "signOut": "Se déconnecter", - "complete": "Achevé", - "save": "Sauvegarder" - }, - "label": { - "welcome": "Bienvenue!", - "firstName": "Prénom", - "middleName": "Deuxième nom", - "lastName": "Nom de famille", - "stepX": "Étape {X}" - }, - "oAuth": { - "err": { - "failedTitle": "Incapable de se connecter à votre compte.", - "failedMsg": "SVP vous assurrez d'avoir complèté le processus d'enregistrement dans votre fureteur." - }, - "google": { - "title": "S'identifier avec Google", - "instruction1": "Pour importer vos contacts Google, vous devez autoriser cette application à l'aide de votre navigateur Web.", - "instruction2": "Copiez ce code dans votre presse-papiers en cliquant sur l'icône ou en sélectionnant le texte:", - "instruction3": "Accédez au lien suivant dans votre navigateur Web et saisissez le code ci-dessus:", - "instruction4": "Appuyez sur le bouton ci-dessous lorsque vous avez terminé votre inscription:" - } - }, - "settings": { - "title": "Paramètres", - "menu": { - "appearance": "Apparence", - "language": "Langue", - "open": "Ouvrir les paramètres" - }, - "appearance": { - "lightLabel": "Mode clair", - "darkLabel": "Mode sombre" - } - }, - "sideBar": { - "openSidebar": "Open sidebar", - "closeSidebar": "Close sidebar" - } -} diff --git a/frontend/app_flowy/assets/translations/fr-FR.json b/frontend/app_flowy/assets/translations/fr-FR.json deleted file mode 100644 index 2de9e637045c1..0000000000000 --- a/frontend/app_flowy/assets/translations/fr-FR.json +++ /dev/null @@ -1,235 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Moi", - "welcomeText": "Bienvenue sur @:appName", - "githubStarText": "Favoriser sur GitHub", - "subscribeNewsletterText": "S'inscrire à la Newsletter", - "letsGoButtonText": "Allons-y", - "title": "Titre", - "signUp": { - "buttonText": "S'inscrire", - "title": "Inscrivez-vous sur @:appName", - "getStartedText": "Commencer", - "emptyPasswordError": "Vous n'avez pas saisi votre mot de passe", - "repeatPasswordEmptyError": "Vous n'avez pas ressaisi votre mot de passe", - "unmatchedPasswordError": "Les deux mots de passe ne sont pas identiques", - "alreadyHaveAnAccount": "Avez-vous déjà un compte ?", - "emailHint": "Email", - "passwordHint": "Mot de passe", - "repeatPasswordHint": "Ressaisir votre mot de passe" - }, - "signIn": { - "loginTitle": "Connexion à @:appName", - "loginButtonText": "Connexion", - "buttonText": "Se connecter", - "forgotPassword": "Mot de passe oublié ?", - "emailHint": "Email", - "passwordHint": "Mot de passe", - "dontHaveAnAccount": "Vous n'avez pas encore créé votre compte ?", - "repeatPasswordEmptyError": "Vous n'avez pas ressaisi votre mot de passe", - "unmatchedPasswordError": "Les deux mots de passe ne sont pas identiques" - }, - "workspace": { - "create": "Créer un espace de travail", - "hint": "Espace de travail", - "notFoundError": "Espace de travail introuvable" - }, - "shareAction": { - "buttonText": "Partager", - "workInProgress": "Bientôt disponible", - "markdown": "Markdown", - "copyLink": "Copier le lien" - }, - "disclosureAction": { - "rename": "Renommer", - "delete": "Supprimer", - "duplicate": "Dupliquer" - }, - "blankPageTitle": "Page vierge", - "newPageText": "Nouvelle page", - "trash": { - "text": "Corbeille", - "restoreAll": "Restaurer tout", - "deleteAll": "Supprimer tout", - "pageHeader": { - "fileName": "Nom de fichier", - "lastModified": "Dernière modification", - "created": "Créé" - } - }, - "deletePagePrompt": { - "text": "Cette page a été supprimée, vous pouvez la retrouver dans la corbeille", - "restore": "Restaurer la page", - "deletePermanent": "Supprimer définitivement" - }, - "dialogCreatePageNameHint": "Nom de la page", - "questionBubble": { - "whatsNew": "Nouveautés", - "help": "Aide et Support", - "debug": { - "name": "Informations de Débogage", - "success": "Informations de Débogage copiées dans le presse-papiers !", - "fail": "Impossible de copier les informations de Débogage dans le presse-papiers" - } - }, - "menuAppHeader": { - "addPageTooltip": "Ajoutez rapidement une page à l'intérieur", - "defaultNewPageName": "Sans-titre", - "renameDialog": "Renommer" - }, - "toolbar": { - "undo": "Annuler", - "redo": "Rétablir", - "bold": "Gras", - "italic": "Italique", - "underline": "Souligner", - "strike": "Barré", - "numList": "Liste numérotée", - "bulletList": "Liste à puces", - "checkList": "To-Do List", - "inlineCode": "Code", - "quote": "Bloc de citation", - "header": "En-tête", - "highlight": "Surligner" - }, - "tooltip": { - "lightMode": "Passer en mode clair", - "darkMode": "Passer en mode sombre", - "openAsPage": "Ouvrir en tant que page", - "addNewRow": "Ajouter une ligne", - "openMenu": "Cliquer pour ouvrir le menu" - }, - "sideBar": { - "closeSidebar": "Fermer le menu latéral", - "openSidebar": "Ouvrir le menu latéral" - }, - "notifications": { - "export": { - "markdown": "Note exportée en Markdown", - "path": "Documents/flowy" - } - }, - "contactsPage": { - "title": "Contacts", - "whatsHappening": "Que se passe-t-il cette semaine ?", - "addContact": "Ajouter un contact", - "editContact": "Modifier le contact" - }, - "button": { - "OK": "OK", - "Cancel": "Annuler", - "signIn": "Se connecter", - "signOut": "Se déconnecter", - "complete": "Achevé", - "save": "Enregistrer" - }, - "label": { - "welcome": "Bienvenue !", - "firstName": "Prénom", - "middleName": "Deuxième prénom", - "lastName": "Nom", - "stepX": "Étape {X}" - }, - "oAuth": { - "err": { - "failedTitle": "Impossible de se connecter à votre compte.", - "failedMsg": "Assurez-vous d'avoir terminé le processus de connexion dans votre navigateur." - }, - "google": { - "title": "CONNEXION VIA GOOGLE", - "instruction1": "Pour importer vos contacts Google, vous devez autoriser cette application à l'aide de votre navigateur web.", - "instruction2": "Copiez ce code dans votre presse-papiers en cliquant sur l'icône ou en sélectionnant le texte:", - "instruction3": "Accédez au lien suivant dans votre navigateur web et saisissez le code ci-dessus:", - "instruction4": "Appuyez sur le bouton ci-dessous lorsque vous avez terminé votre inscription:" - } - }, - "settings": { - "title": "Paramètres", - "menu": { - "appearance": "Apparence", - "language": "Langue", - "user": "Utilisateur", - "open": "Ouvrir les paramètres" - }, - "appearance": { - "lightLabel": "Mode clair", - "darkLabel": "Mode sombre" - } - }, - "grid": { - "settings": { - "filter": "Filtrer", - "sortBy": "Filtrer par", - "Properties": "Propriétés", - "group": "Groupe" - }, - "field": { - "hide": "Cacher", - "insertLeft": "Insérer à gauche", - "insertRight": "Insérer à droite", - "duplicate": "Dupliquer", - "delete": "Supprimer", - "textFieldName": "Texte", - "checkboxFieldName": "Case à cocher", - "dateFieldName": "Date", - "numberFieldName": "Nombre", - "singleSelectFieldName": "Sélectionner", - "multiSelectFieldName": "Multisélection", - "urlFieldName": "URL", - "numberFormat": " Format du nombre", - "dateFormat": " Format de la date", - "includeTime": " Inclure l'heure", - "dateFormatFriendly": "Mois Jour, Année", - "dateFormatISO": "Année-Mois-Jour", - "dateFormatLocal": "Mois/Jour/Année", - "dateFormatUS": "Année/Mois/Jour", - "timeFormat": " Format du temps", - "invalidTimeFormat": "Format invalide", - "timeFormatTwelveHour": "12 heures", - "timeFormatTwentyFourHour": "24 heures", - "addSelectOption": "Ajouter une option", - "optionTitle": "Options", - "addOption": "Ajouter une option", - "editProperty": "Modifier la propriété", - "newColumn": "Nouvelle colonne", - "deleteFieldPromptMessage": "Vous voulez supprimer cette propriété ?" - }, - "row": { - "duplicate": "Dupliquer", - "delete": "Supprimer", - "textPlaceholder": "Vide", - "copyProperty": "Copie de la propriété dans le presse-papiers", - "count": "Nombre", - "newRow": "Nouvelle ligne" - }, - "selectOption": { - "create": "Créer", - "purpleColor": "Violet", - "pinkColor": "Rose", - "lightPinkColor": "Rose clair", - "orangeColor": "Orange", - "yellowColor": "Jaune", - "limeColor": "Citron vert", - "greenColor": "Vert", - "aquaColor": "Aqua", - "blueColor": "Bleu", - "deleteTag": "Supprimer l'étiquette", - "colorPanelTitle": "Couleurs", - "panelTitle": "Sélectionnez une option ou créez-en une", - "searchOption": "Rechercher une option" - }, - "menuName": "Grille" - }, - "document": { - "menuName": "Doc", - "date": { - "timeHintTextInTwelveHour": "01:00 PM", - "timeHintTextInTwentyFourHour": "13:00" - } - }, - "board": { - "column": { - "create_new_card": "Nouveau" - } - } -} diff --git a/frontend/app_flowy/assets/translations/hu-HU.json b/frontend/app_flowy/assets/translations/hu-HU.json deleted file mode 100644 index 7428f48520188..0000000000000 --- a/frontend/app_flowy/assets/translations/hu-HU.json +++ /dev/null @@ -1,149 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Én", - "welcomeText": "Üdvözöl az @:appName", - "githubStarText": "GitHub csillagozás", - "subscribeNewsletterText": "Iratkozz fel a hírlevelünkre", - "letsGoButtonText": "Vágjunk bele", - "title": "Cím", - "signUp": { - "buttonText": "Regisztráció", - "title": "Regisztrálj az @:appName -ra", - "getStartedText": "Kezdés", - "emptyPasswordError": "A jelszó nem lehet üres", - "repeatPasswordEmptyError": "A jelszó megerősítése nem lehet üres", - "unmatchedPasswordError": "A jelszavak nem egyeznek", - "alreadyHaveAnAccount": "Rendelkezel már fiókkal?", - "emailHint": "Email", - "passwordHint": "Jelszó", - "repeatPasswordHint": "Jelszó megerősítése" - }, - "signIn": { - "loginTitle": "Bejelentkezés az @:appName -ba", - "loginButtonText": "Belépés", - "buttonText": "Bejelentkezés", - "forgotPassword": "Elfelejtett jelszó?", - "emailHint": "Email", - "passwordHint": "Jelszó", - "dontHaveAnAccount": "Még nincs fiókod?", - "repeatPasswordEmptyError": "A jelszó megerősítése nem lehet üres", - "unmatchedPasswordError": "A jelszavak nem egyeznek" - }, - "workspace": { - "create": "Új munkaterület létrehozása", - "hint": "munkaterület", - "notFoundError": "munkaterület nem található" - }, - "shareAction": { - "buttonText": "Megosztás", - "workInProgress": "Hamarosan érkezik...", - "markdown": "Markdown", - "copyLink": "Link másolása" - }, - "disclosureAction": { - "rename": "Átnevezés", - "delete": "Törlés", - "duplicate": "Duplikálás" - }, - "blankPageTitle": "Üres oldal", - "newPageText": "Új oldal", - "trash": { - "text": "Kuka", - "restoreAll": "Összes visszaállítása", - "deleteAll": "Összes törlése", - "pageHeader": { - "fileName": "Fájlnév", - "lastModified": "Utoljára módosítva", - "created": "Létrehozva" - } - }, - "deletePagePrompt": { - "text": "Ez az oldal a kukában van", - "restore": "Oldal visszaállítása", - "deletePermanent": "Végleges törlés" - }, - "dialogCreatePageNameHint": "Oldalnév", - "questionBubble": { - "whatsNew": "Újdonságok", - "help": "Segítség & Támogatás", - "debug": { - "name": "Debug Információ", - "success": "Debug információ a vágólapra másolva", - "fail": "A Debug információ nem másolható a vágólapra" - } - }, - "menuAppHeader": { - "addPageTooltip": "Belső oldal hozzáadása", - "defaultNewPageName": "Névtelen", - "renameDialog": "Átnevezés" - }, - "toolbar": { - "undo": "Vissza", - "redo": "Előre", - "bold": "Félkövér", - "italic": "Dőlt", - "underline": "Aláhúzott", - "strike": "Áthúzott", - "numList": "Számozott lista", - "bulletList": "Felsorolás", - "checkList": "Ellenőrző lista", - "inlineCode": "Inline kód", - "quote": "Idézet", - "header": "Címsor", - "highlight": "Kiemelés" - }, - "tooltip": { - "lightMode": "Világos mód", - "darkMode": "Éjjeli mód" - }, - "contactsPage": { - "title": "Kontaktok", - "whatsHappening": "Heti újdonságok", - "addContact": "Új Kontakt", - "editContact": "Kontakt Szerkesztése" - }, - "button": { - "OK": "OK", - "Cancel": "Mégse", - "signIn": "Bejelentkezés", - "signOut": "Kijelentkezés", - "complete": "Kész", - "save": "Mentés" - }, - "label": { - "welcome": "Üdvözlünk!", - "firstName": "Keresztnév", - "middleName": "Középső név", - "lastName": "Vezetéknév", - "stepX": "{X}. lépés" - }, - "oAuth": { - "err": { - "failedTitle": "Sikertelen bejelentkezés.", - "failedMsg": "Kérjük győződj meg róla, hogy elvégezted a bejelentkezési folyamatot a böngésződben" - }, - "google": { - "title": "Bejelentkezés Google-al", - "instruction1": "Ahhoz, hogy hozzáférj a Google Kontaktjaidhoz, kérjük hatalmazd fel ezt az alkalmazást a böngésződben.", - "instruction2": "Másold ezt a kódot a vágólapra az ikonra kattintással vagy a szöveg kijelölésével:", - "instruction3": "Nyisd meg ezt a linket a böngésződben, és írjd be a fenti kódot:", - "instruction4": "Nyomd meg az alábbi gombot, ha elvégezted a registrációt:" - } - }, - "settings": { - "title": "Beállítások", - "menu": { - "appearance": "Megjelenés", - "language": "Nyelv", - "open": "Beállítások megnyitása" - }, - "appearance": { - "lightLabel": "Világos mód", - "darkLabel": "Éjjeli mód" - } - }, - "sideBar": { - "openSidebar": "Open sidebar", - "closeSidebar": "Close sidebar" - } -} diff --git a/frontend/app_flowy/assets/translations/id-ID.json b/frontend/app_flowy/assets/translations/id-ID.json deleted file mode 100644 index 1239194266638..0000000000000 --- a/frontend/app_flowy/assets/translations/id-ID.json +++ /dev/null @@ -1,222 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Saya", - "welcomeText": "Selamat datang di @:appName", - "githubStarText": "Bintangi GitHub", - "subscribeNewsletterText": "Berlangganan buletin", - "letsGoButtonText": "Ayo", - "title": "Judul", - "signUp": { - "buttonText": "Daftar", - "title": "Daftar ke @:appName", - "getStartedText": "Mulai", - "emptyPasswordError": "Sandi tidak boleh kosong", - "repeatPasswordEmptyError": "Sandi ulang tidak boleh kosong", - "unmatchedPasswordError": "Sandi ulang tidak sama dengan sandi", - "alreadyHaveAnAccount": "Sudah punya akun?", - "emailHint": "Email", - "passwordHint": "Sandi", - "repeatPasswordHint": "Sandi ulang" - }, - "signIn": { - "loginTitle": "Masuk ke @:appName", - "loginButtonText": "Masuk", - "buttonText": "Masuk", - "forgotPassword": "Lupa Sandi?", - "emailHint": "Email", - "passwordHint": "Sandi", - "dontHaveAnAccount": "Belum punya akun?", - "repeatPasswordEmptyError": "Sandi ulang tidak boleh kosong", - "unmatchedPasswordError": "Sandi ulang tidak sama dengan sandi" - }, - "workspace": { - "create": "Buat workspace", - "hint": "workspace", - "notFoundError": "Workspace tidak ditemukan" - }, - "shareAction": { - "buttonText": "Bagikan", - "workInProgress": "Segera", - "markdown": "Markdown", - "copyLink": "Salin tautan" - }, - "disclosureAction": { - "rename": "Ganti nama", - "delete": "Hapus", - "duplicate": "Duplikat" - }, - "blankPageTitle": "Halaman kosong", - "newPageText": "Halaman baru", - "trash": { - "text": "Sampah", - "restoreAll": "Pulihkan Semua", - "deleteAll": "Hapus semua", - "pageHeader": { - "fileName": "Nama file", - "lastModified": "Terakhir diubah", - "created": "Dibuat" - } - }, - "deletePagePrompt": { - "text": "Halaman ini di tempat sampah", - "restore": "Pulihkan halaman", - "deletePermanent": "Hapus secara permanen" - }, - "dialogCreatePageNameHint": "Nama halaman", - "questionBubble": { - "whatsNew": "Apa yang baru?", - "help": "Bantuan & Dukungan", - "debug": { - "name": "Info debug", - "success": "Info debug disalin ke papan klip!", - "fail": "Tidak dapat menyalin info debug ke papan klip" - } - }, - "menuAppHeader": { - "addPageTooltip": "Menambahkan halaman di dalam dengan cepat", - "defaultNewPageName": "Tanpa Judul", - "renameDialog": "Ganti nama" - }, - "toolbar": { - "undo": "Undo", - "redo": "Redo", - "bold": "Tebal", - "italic": "Miring", - "underline": "Garis bawah", - "strike": "Dicoret", - "numList": "Daftar bernomor", - "bulletList": "Daftar berpoin", - "checkList": "Daftar periksa", - "inlineCode": "Kode sebaris", - "quote": "Blok kutipan", - "header": "Tajuk", - "highlight": "Sorotan" - }, - "tooltip": { - "lightMode": "Ganti mode terang", - "darkMode": "Ganti mode gelap" - }, - "notifications": { - "export": { - "markdown": "Mengekspor Catatan ke Markdown", - "path": "Documents/flowy" - } - }, - "contactsPage": { - "title": "Kontak", - "whatsHappening": "Apa yang terjadi minggu ini?", - "addContact": "Tambahkan Kontak", - "editContact": "Ubah Kontak" - }, - "button": { - "OK": "Ya", - "Cancel": "Batal", - "signIn": "Masuk", - "signOut": "Keluar", - "complete": "Selesai", - "save": "Simpan" - }, - "label": { - "welcome": "Selamat datang!", - "firstName": "Nama Depan", - "middleName": "Nama Tengah", - "lastName": "Nama Akhir", - "stepX": "Langkah {X}" - }, - "oAuth": { - "err": { - "failedTitle": "Tidak dapat terhubung ke akun anda", - "failedMsg": "Mohon pastikan anda menyelesaikan proses pendaftaran pada browser anda." - }, - "google": { - "title": "MASUK GOOGLE", - "instruction1": "Untuk mengimpor kontak Google Contacts anda, anda harus mengizinkan aplikasi ini menggunakan browser web anda.", - "instruction2": "Salin kode ini ke papan klip anda dengan cara mengklik ikon atau memilih teks:", - "instruction3": "Arahkan ke tautan berikut di browser web Anda, dan masukkan kode di atas:", - "instruction4": "Tekan tombol di bawah ini setelah Anda menyelesaikan pendaftaran:" - } - }, - "settings": { - "title": "Pengaturan", - "menu": { - "appearance": "Tampilan", - "language": "Bahasa", - "user": "Pengguna", - "open": "Buka Pengaturan" - }, - "appearance": { - "lightLabel": "Mode Terang", - "darkLabel": "Mode Gelap" - } - }, - "grid": { - "settings": { - "filter": "Filter", - "sortBy": "Sortir dengan", - "Properties": "Properti" - }, - "field": { - "hide": "Sembunyikan", - "insertLeft": "Sisipkan Kiri", - "insertRight": "Sisipkan Kanan", - "duplicate": "Duplikasi", - "delete": "Hapus", - "textFieldName": "Teks", - "checkboxFieldName": "Kotak Centang", - "dateFieldName": "Tanggal", - "numberFieldName": "Angka", - "singleSelectFieldName": "seleksi", - "multiSelectFieldName": "Multi seleksi", - "urlFieldName": "URL", - "numberFormat": " Format angka", - "dateFormat": " Format tanggal", - "includeTime": " Sertakan waktu", - "dateFormatFriendly": "Bulan Hari,Tahun", - "dateFormatISO": "Tahun-Bulan-Hari", - "dateFormatLocal": "Bulan/Hari/Tahun", - "dateFormatUS": "Tahun/Bulan/Hari", - "timeFormat": " Format waktu", - "invalidTimeFormat": "Format yang tidak valid", - "timeFormatTwelveHour": "12 jam", - "timeFormatTwentyFourHour": "24 jam", - "addSelectOption": "Tambahkan opsi", - "optionTitle": "Opsi", - "addOption": "Tambahkan opsi", - "editProperty": "Ubah properti" - }, - "row": { - "duplicate": "Duplikasi", - "delete": "Hapus", - "textPlaceholder": "Kosong", - "copyProperty": "Salin properti ke papan klip" - }, - "selectOption": { - "create": "Buat", - "purpleColor": "Ungu", - "pinkColor": "Merah Jambu", - "lightPinkColor": "Merah Jambu Muda", - "orangeColor": "Oranye", - "yellowColor": "Kuning", - "limeColor": "Limau", - "greenColor": "Hijau", - "aquaColor": "Air", - "blueColor": "Biru", - "deleteTag": "Hapus tag", - "colorPanelTitle": "Warna", - "panelTitle": "Pilih opsi atau buat baru", - "searchOption": "Cari opsi" - }, - "menuName": "Grid" - }, - "document": { - "menuName": "Doc", - "date": { - "timeHintTextInTwelveHour": "01:00 PM", - "timeHintTextInTwentyFourHour": "13:00" - } - }, - "sideBar": { - "openSidebar": "Open sidebar", - "closeSidebar": "Close sidebar" - } -} \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/it-IT.json b/frontend/app_flowy/assets/translations/it-IT.json deleted file mode 100644 index 3433eb4d8e836..0000000000000 --- a/frontend/app_flowy/assets/translations/it-IT.json +++ /dev/null @@ -1,155 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Me", - "welcomeText": "Benvenuto in @:appName", - "githubStarText": "Vota su GitHub", - "subscribeNewsletterText": "Sottoscrivi la Newsletter", - "letsGoButtonText": "Andiamo", - "title": "Titolo", - "signUp": { - "buttonText": "Registrati", - "title": "Registrati per @:appName", - "getStartedText": "Iniziamo", - "emptyPasswordError": "La password non può essere vuota", - "repeatPasswordEmptyError": "La password ripetuta non può essere vuota", - "unmatchedPasswordError": "La password ripetuta non è uguale alla password", - "alreadyHaveAnAccount": "Hai già un account?", - "emailHint": "Email", - "passwordHint": "Password", - "repeatPasswordHint": "Ripeti password" - }, - "signIn": { - "loginTitle": "Accedi a @:appName", - "loginButtonText": "Login", - "buttonText": "Accedi", - "forgotPassword": "Password Dimentica?", - "emailHint": "Email", - "passwordHint": "Password", - "dontHaveAnAccount": "Non hai un account?", - "repeatPasswordEmptyError": "La password ripetuta non può essere vuota", - "unmatchedPasswordError": "La password ripetuta non è uguale alla password" - }, - "workspace": { - "create": "Crea spazio di lavoro", - "hint": "spazio di lavoro", - "notFoundError": "Spazio di lavoro non trovato" - }, - "shareAction": { - "buttonText": "Condividi", - "workInProgress": "Prossimamente", - "markdown": "Markdown", - "copyLink": "Copia Link" - }, - "disclosureAction": { - "rename": "Rinomina", - "delete": "Cancella", - "duplicate": "Duplica" - }, - "blankPageTitle": "Pagina vuota", - "newPageText": "Nuova pagina", - "trash": { - "text": "Cestino", - "restoreAll": "Ripristina Tutto", - "deleteAll": "Elimina Tutto", - "pageHeader": { - "fileName": "Nome file", - "lastModified": "Ultima Modifica", - "created": "Creato" - } - }, - "deletePagePrompt": { - "text": "Questa pagina è nel Cestino", - "restore": "Ripristina pagina", - "deletePermanent": "Elimina definitivamente" - }, - "dialogCreatePageNameHint": "Nome pagina", - "questionBubble": { - "whatsNew": "Cosa c'è di nuovo?", - "help": "Aiuto & Supporto", - "debug": { - "name": "Informazioni di debug", - "success": "Informazioni di debug copiate negli appunti!", - "fail": "Impossibile copiare le informazioni di debug negli appunti" - } - }, - "menuAppHeader": { - "addPageTooltip": "Aggiungi velocemente una pagina all'interno", - "defaultNewPageName": "Senza titolo", - "renameDialog": "Rinomina" - }, - "toolbar": { - "undo": "Undo", - "redo": "Redo", - "bold": "Grassetto", - "italic": "Italico", - "underline": "Sottolineato", - "strike": "Barrato", - "numList": "Lista numerata", - "bulletList": "Lista a punti", - "checkList": "Lista Controllo", - "inlineCode": "Codice in linea", - "quote": "Cita Blocco", - "header": "intestazione", - "highlight": "Evidenziare" - }, - "tooltip": { - "lightMode": "Passa alla modalità Chiara", - "darkMode": "Passa alla modalità Scura" - }, - "contactsPage": { - "title": "Contatti", - "whatsHappening": "Cosa accadrà la prossima settimana?", - "addContact": "Aggiungi Contatti", - "editContact": "Modifica Contatti" - }, - "button": { - "OK": "OK", - "Cancel": "Annulla", - "signIn": "Accedi", - "signOut": "Esci", - "complete": "Completa", - "save": "Salva" - }, - "label": { - "welcome": "Benvenuto!", - "firstName": "Name", - "middleName": "Secondo Name", - "lastName": "Cognome", - "stepX": "Passo {X}" - }, - "oAuth": { - "err": { - "failedTitle": "Impossibile collegarsi al tuo account.", - "failedMsg": "Si prega di verificare di aver completato il processo di iscrizione nel tuo browser." - }, - "google": { - "title": "GOOGLE SIGN-IN", - "instruction1": "Al fine di importare i tuoi Contatti Google è necessario autorizzare questa applicazione ad utilizzare il tuo beowser web.", - "instruction2": "Copia questo codice negli appunti premendo l'icona o selezionando il testo:", - "instruction3": "Naviga sul seguente link con il tuo browser web e inserisci il codice seguente:", - "instruction4": "Premi il bottone qui sotto quando hai completato l'iscrizione:" - } - }, - "settings": { - "title": "Impostazioni", - "menu": { - "appearance": "Aspetto", - "language": "Lingua", - "open": "aprire le impostazioni" - }, - "appearance": { - "lightLabel": "Modalità Chiara", - "darkLabel": "Modalità Scura" - } - }, - "grid": { - "menuName":"Griglia" - }, - "document":{ - "menuName":"Documento" - }, - "sideBar": { - "openSidebar": "Open sidebar", - "closeSidebar": "Close sidebar" - } -} diff --git a/frontend/app_flowy/assets/translations/ja-JP.json b/frontend/app_flowy/assets/translations/ja-JP.json deleted file mode 100644 index 793604212cde0..0000000000000 --- a/frontend/app_flowy/assets/translations/ja-JP.json +++ /dev/null @@ -1,203 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "ユーザー", - "welcomeText": "Welcome to @:appName", - "githubStarText": "Star on GitHub", - "subscribeNewsletterText": "新着情報を受け取る", - "letsGoButtonText": "Let's Go", - "title": "タイトル", - "signUp": { - "buttonText": "新規登録", - "title": "@:appNameに新規登録", - "getStartedText": "はじめる", - "emptyPasswordError": "パスワードを空にはできません", - "repeatPasswordEmptyError": "パスワード(確認用)を空にはできません", - "unmatchedPasswordError": "パスワード(確認用)が一致しません", - "alreadyHaveAnAccount": "すでにアカウントを登録済ですか?", - "emailHint": "メールアドレス", - "passwordHint": "パスワード", - "repeatPasswordHint": "パスワード(確認用)" - }, - "signIn": { - "loginTitle": "@:appName にログイン", - "loginButtonText": "ログイン", - "buttonText": "サインイン", - "forgotPassword": "パスワードをお忘れですか?", - "emailHint": "メールアドレス", - "passwordHint": "パスワード", - "dontHaveAnAccount": "まだアカウントをお持ちではないですか?", - "repeatPasswordEmptyError": "パスワード(確認用)を空にはできません", - "unmatchedPasswordError": "パスワード(確認用)が一致しません" - }, - "workspace": { - "create": "ワークスペースを作成する", - "hint": "ワークスペース", - "notFoundError": "ワークスペースがみつかりません" - }, - "shareAction": { - "buttonText": "共有する", - "workInProgress": "Coming soon", - "markdown": "Markdown", - "copyLink": "Copy Link" - }, - "disclosureAction": { - "rename": "名前を変更", - "delete": "削除", - "duplicate": "コピーを作成" - }, - "blankPageTitle": "空のページ", - "newPageText": "新しいページ", - "trash": { - "text": "ごみ箱", - "restoreAll": "全て復元", - "deleteAll": "全て削除", - "pageHeader": { - "fileName": "ファイル名", - "lastModified": "最終更新日時", - "created": "作成日時" - } - }, - "deletePagePrompt": { - "text": "このページはごみ箱にあります", - "restore": "ページを元に戻す", - "deletePermanent": "削除する" - }, - "dialogCreatePageNameHint": "ページ名", - "questionBubble": { - "whatsNew": "What's new?", - "help": "ヘルプとサポート", - "debug": { - "name": "デバッグ情報", - "success": "デバッグ情報をクリップボードにコピーしました!", - "fail": "デバッグ情報をクリップボードにコピーできませんでした" - } - }, - "menuAppHeader": { - "addPageTooltip": "内部ページを追加", - "defaultNewPageName": "Untitled", - "renameDialog": "名前を変更" - }, - "toolbar": { - "undo": "元に戻す", - "redo": "やり直し", - "bold": "太字", - "italic": "斜体", - "underline": "下線", - "strike": "取り消し線", - "numList": "番号付きリスト", - "bulletList": "箇条書き", - "checkList": "チェックボックス", - "inlineCode": "インラインコード", - "quote": "引用文", - "header": "見出し", - "highlight": "文字の背景色" - }, - "tooltip": { - "lightMode": "ライトモードに切り替える", - "darkMode": "ダークモードに切り替える" - }, - "contactsPage": { - "title": "連絡先", - "whatsHappening": "今週はどんなことがありましたか?", - "addContact": "連絡先を追加する", - "editContact": "連絡先を編集する" - }, - "button": { - "OK": "OK", - "Cancel": "キャンセル", - "signIn": "サインイン", - "signOut": "サインアウト", - "complete": "完了", - "save": "保存" - }, - "label": { - "welcome": "ようこそ!", - "firstName": "名", - "middleName": "ミドルネーム", - "lastName": "姓", - "stepX": "Step {X}" - }, - "oAuth": { - "err": { - "failedTitle": "アカウントに接続できません", - "failedMsg": "サインインが完了したことをブラウザーで確認してください" - }, - "google": { - "title": "GOOGLEでサインイン", - "instruction1": "GOOGLEでのサインインを有効にするためには、Webブラウザーを使ってこのアプリケーションを認証する必要があります。", - "instruction2": "アイコンをクリックするか、以下のテキストを選択して、このコードをクリップボードにコピーします。", - "instruction3": "以下のリンク先をブラウザーで開いて、次のコードを入力します。", - "instruction4": "登録が完了したら以下のボタンを押してください。" - } - }, - "settings": { - "title": "設定", - "menu": { - "appearance": "外観", - "language": "言語", - "open": "設定" - }, - "appearance": { - "lightLabel": "ライトモード", - "darkLabel": "ダークモード" - } - }, - "grid": { - "settings": { - "filter": "絞り込み", - "sortBy": "並び替え", - "Properties": "プロパティ" - }, - "field": { - "hide": "隠す", - "insertLeft": "左に挿入", - "insertRight": "右に挿入", - "duplicate": "コピーを作成", - "delete": "削除", - "textFieldName": "テキスト", - "checkboxFieldName": "チェックボックス", - "dateFieldName": "日付", - "numberFieldName": "数値", - "singleSelectFieldName": "単一選択", - "multiSelectFieldName": "複数選択", - "numberFormat": " 数値書式", - "dateFormat": " 日付書式", - "includeTime": " 時刻を含める", - "dateFormatFriendly": "月 日,年", - "dateFormatISO": "年-月-日", - "dateFormatLocal": "月/日/年", - "dateFormatUS": "年/月/日", - "timeFormat": " 時刻書式", - "timeFormatTwelveHour": "12 時間表記", - "timeFormatTwentyFourHour": "24 時間表記", - "addSelectOption": "選択候補追加", - "optionTitle": "選択候補", - "addOption": "選択候補追加", - "editProperty": "プロパティの編集" - }, - "row": { - "duplicate": "コピーを作成", - "delete": "削除", - "textPlaceholder": "空白" - }, - "selectOption": { - "purpleColor": "紫", - "pinkColor": "ピンク", - "lightPinkColor": "ライトピンク", - "orangeColor": "オレンジ", - "yellowColor": "黄色", - "limeColor": "ライム", - "greenColor": "緑", - "aquaColor": "水色", - "blueColor": "青", - "deleteTag": "選択候補を削除", - "colorPanelTitle": "色", - "panelTitle": "選択候補を検索 または 作成する", - "searchOption": "選択候補を検索" - } - }, - "sideBar": { - "openSidebar": "Open sidebar", - "closeSidebar": "Close sidebar" - } -} \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/ko-KR.json b/frontend/app_flowy/assets/translations/ko-KR.json deleted file mode 100644 index 7c5cc92c4a2ee..0000000000000 --- a/frontend/app_flowy/assets/translations/ko-KR.json +++ /dev/null @@ -1,236 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Me", - "welcomeText": "@:appName 에 오신것을 환영합니다", - "githubStarText": "Star on GitHub", - "subscribeNewsletterText": "뉴스레터 구독", - "letsGoButtonText": "Let's Go", - "title": "제목", - "signUp": { - "buttonText": "회원가입", - "title": "@:appName 에 회원가입", - "getStartedText": "시작하기", - "emptyPasswordError": "비밀번호는 공백일 수 없습니다", - "repeatPasswordEmptyError": "비밀번호 재입력란은 공백일 수 없습니다", - "unmatchedPasswordError": "재입력 하신 비밀번호가 같지 않습니다", - "alreadyHaveAnAccount": "이미 계정이 있으신가요?", - "emailHint": "이메일", - "passwordHint": "비밀번호", - "repeatPasswordHint": "비밀번호 재입력" - }, - "signIn": { - "loginTitle": "@:appName 에 로그인", - "loginButtonText": "로그인", - "buttonText": "로그인", - "forgotPassword": "비밀번호를 잊으셨나요?", - "emailHint": "이메일", - "passwordHint": "비밀번호", - "dontHaveAnAccount": "계정이 없으신가요?", - "repeatPasswordEmptyError": "비밀번호 재입력란은 공백일 수 없습니다", - "unmatchedPasswordError": "재입력 하신 비밀번호가 같지 않습니다" - }, - "workspace": { - "create": "워크스페이스 생성", - "hint": "워크스페이스", - "notFoundError": "워크스페이스를 찾을 수 없습니다" - }, - "shareAction": { - "buttonText": "공유", - "workInProgress": "Coming soon", - "markdown": "마크다운", - "copyLink": "링크 복사" - }, - "disclosureAction": { - "rename": "이름변경", - "delete": "삭제", - "duplicate": "복제" - }, - "blankPageTitle": "빈 페이지", - "newPageText": "새로운 페이지", - "trash": { - "text": "휴지통", - "restoreAll": "모두 복구", - "deleteAll": "모두 삭제", - "pageHeader": { - "fileName": "파일 이름", - "lastModified": "수정날짜", - "created": "생성날짜" - } - }, - "deletePagePrompt": { - "text": "현재 페이지는 휴지통에 있습니다", - "restore": "페이지 복구", - "deletePermanent": "영구 삭제" - }, - "dialogCreatePageNameHint": "페이지 이름", - "questionBubble": { - "whatsNew": "새로운 소식", - "help": "도움 및 지원", - "debug": { - "name": "디버그 정보", - "success": "디버그 정보를 클립보드로 복사했습니다.", - "fail": "디버그 정보를 클립보드로 복사할 수 없습니다." - } - }, - "menuAppHeader": { - "addPageTooltip": "하위에 페이지 추가", - "defaultNewPageName": "제목없음", - "renameDialog": "이름변경" - }, - "toolbar": { - "undo": "실행취소", - "redo": "재실행", - "bold": "굵게", - "italic": "기울임꼴", - "underline": "밑줄", - "strike": "취소선", - "numList": "번호 매기기 목록", - "bulletList": "글머리 기호 목록", - "checkList": "작업 목록", - "inlineCode": "인라인 코드", - "quote": "인용구 블록", - "header": "헤더", - "highlight": "하이라이트" - }, - "tooltip": { - "lightMode": "라이트 모드로 변경", - "darkMode": "다크 모드로 변경", - "openAsPage": "페이지로 열기", - "addNewRow": "열 추가", - "openMenu": "메뉴를 여시려면 클릭하세요" - }, - "sideBar": { - "closeSidebar": "사이드바 닫기", - "openSidebar": "사이드바 열기" - }, - "notifications": { - "export": { - "markdown": "마크다운으로 노트를 내보냄", - "path": "Documents/flowy" - } - }, - "contactsPage": { - "title": "연락처", - "whatsHappening": "이번주에는 무슨 일이 있나요?", - "addContact": "연락처 추가", - "editContact": "연락처 편집" - }, - "button": { - "OK": "확인", - "Cancel": "취소", - "signIn": "로그인", - "signOut": "로그아웃", - "complete": "완료", - "save": "저장" - }, - "label": { - "welcome": "환영합니다!", - "firstName": "이름", - "middleName": "중간 이름", - "lastName": "성", - "stepX": "{X} 단계" - }, - "oAuth": { - "err": { - "failedTitle": "계정에 연결을 할 수 없습니다.", - "failedMsg": "브라우저에서 회원가입이 완료되었는지 확인해주세요." - }, - "google": { - "title": "GOOGLE SIGN-IN", - "instruction1": "구글 연락처를 가져오기 위해서 웹브라우저로 앱을 승인 해야 합니다.", - "instruction2": "아이콘을 클릭 또는 텍스트를 선택해서 이 코드를 클립보드로 복사하세요:", - "instruction3": "웹브라우저로 다음 링크로 가셔서 위 코드를 입력해주세요:", - "instruction4": "가입 완료 후 아래 버튼을 눌러주세요:" - } - }, - "settings": { - "title": "설정", - "menu": { - "appearance": "화면", - "language": "언어", - "user": "사용자", - "open": "설정 열기" - }, - "appearance": { - "lightLabel": "라이트 모드", - "darkLabel": "다크 모드" - } - }, - "grid": { - "settings": { - "filter": "필터", - "sortBy": "정렬 기준", - "Properties": "속성", - "group": "그룹" - }, - "field": { - "hide": "숨기기", - "insertLeft": "왼쪽 삽입", - "insertRight": "오른쪽 삽입", - "duplicate": "복제", - "delete": "삭제", - "textFieldName": "텍스트", - "checkboxFieldName": "체크박스", - "dateFieldName": "날짜", - "numberFieldName": "숫자", - "singleSelectFieldName": "선택", - "multiSelectFieldName": "다중선택", - "urlFieldName": "링크", - "numberFormat": " 숫자 형식", - "dateFormat": " 날짜 형식", - "includeTime": " 시간 표시", - "dateFormatFriendly": "월 일,년", - "dateFormatISO": "년-월-일", - "dateFormatLocal": "월/일/년", - "dateFormatUS": "년/월/일", - "timeFormat": " 시간 형식", - "invalidTimeFormat": "잘못된 형식", - "timeFormatTwelveHour": "12 시간", - "timeFormatTwentyFourHour": "24 시간", - "addSelectOption": "옵션 추가", - "optionTitle": "옵션", - "addOption": "옵션 추가", - "editProperty": "속성 편집", - "newColumn": "열 추가", - "deleteFieldPromptMessage": "해당 속성을 삭제 하시겠습니까?" - }, - "row": { - "duplicate": "복제", - "delete": "삭제", - "textPlaceholder": "비어있음", - "copyProperty": "속성이 클립보드로 복사됨", - "count": "개수", - "newRow": "행 추가" - }, - "selectOption": { - "create": "생성", - "purpleColor": "보라색", - "pinkColor": "핑크색", - "lightPinkColor": "연한 핑크색", - "orangeColor": "오렌지색", - "yellowColor": "노랑색", - "limeColor": "라임색", - "greenColor": "초록색", - "aquaColor": "아쿠아색", - "blueColor": "파랑색", - "deleteTag": "태그 삭제", - "colorPanelTitle": "색상", - "panelTitle": "옵션 선택 또는 생성", - "searchOption": "옵션 검색" - }, - "menuName": "그리드" - }, - "document": { - "menuName": "도큐먼트", - "date": { - "timeHintTextInTwelveHour": "01:00 PM", - "timeHintTextInTwentyFourHour": "13:00" - } - }, - "board": { - "column": { - "create_new_card": "추가" - } - } - } - \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/pl-PL.json b/frontend/app_flowy/assets/translations/pl-PL.json deleted file mode 100644 index ba6c4e18619a3..0000000000000 --- a/frontend/app_flowy/assets/translations/pl-PL.json +++ /dev/null @@ -1,149 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Ja", - "welcomeText": "Witaj w @:appName", - "githubStarText": "Gwiazdka na GitHub-ie", - "subscribeNewsletterText": "Zapisz się do naszego Newslettera", - "letsGoButtonText": "Start!", - "title": "Tytuł", - "signUp": { - "buttonText": "Zarejestruj", - "title": "Zarejestruj się w @:appName", - "getStartedText": "Zaczynamy", - "emptyPasswordError": "Hasło nie moze być puste", - "repeatPasswordEmptyError": "Powtórzone hasło nie moze być puste", - "unmatchedPasswordError": "Hasła nie są takie same", - "alreadyHaveAnAccount": "Masz juz konto?", - "emailHint": "Email", - "passwordHint": "Hasło", - "repeatPasswordHint": "Powtórz hasło" - }, - "signIn": { - "loginTitle": "Zaloguj do @:appName", - "loginButtonText": "Logowanie", - "buttonText": "Zaloguj", - "forgotPassword": "Zapomniałem hasła?", - "emailHint": "Email", - "passwordHint": "Password", - "dontHaveAnAccount": "Nie masz konta?", - "repeatPasswordEmptyError": "Powtórzone hasło nie moze być puste", - "unmatchedPasswordError": "Hasła nie są takie same" - }, - "workspace": { - "create": "Utwórz przestrzeń", - "hint": "przestrzeń robocza", - "notFoundError": "Przestrzeni nie znaleziono" - }, - "shareAction": { - "buttonText": "Udostępnij", - "workInProgress": "Wkrótce", - "markdown": "Markdown", - "copyLink": "Skopiuj link" - }, - "disclosureAction": { - "rename": "Zmień nazwę", - "delete": "Usuń", - "duplicate": "Duplikuj" - }, - "blankPageTitle": "Pusta strona", - "newPageText": "Nowa strona", - "trash": { - "text": "Kosz", - "restoreAll": "Przywróć Wszystko", - "deleteAll": "Usuń Wszystko", - "pageHeader": { - "fileName": "Nazwa Pliku", - "lastModified": "Ostatnio Zmodyfikowano", - "created": "Utworzono" - } - }, - "deletePagePrompt": { - "text": "Ta strona jest w Koszu", - "restore": "Przywróć strone", - "deletePermanent": "Usuń bezpowrotnie" - }, - "dialogCreatePageNameHint": "Nazwa Strony", - "questionBubble": { - "whatsNew": "What's new?", - "help": "Pomoc & Wsparcie", - "debug": { - "name": "Informacje Debugowania", - "success": "Skopiowano informacje debugowania do schowka!", - "fail": "Nie mozna skopiować informacji debugowania do schowka" - } - }, - "menuAppHeader": { - "addPageTooltip": "Szybko dodaj stronę do środka", - "defaultNewPageName": "Brak tytułu", - "renameDialog": "Zmień nazwę" - }, - "toolbar": { - "undo": "Cofnij", - "redo": "Powtórz", - "bold": "Pogrubiony", - "italic": "Kursywa", - "underline": "Podkreśl", - "strike": "Przekreśl", - "numList": "Lista Numerowana", - "bulletList": "Lista Punktowana", - "checkList": "Lista Kontrolna", - "inlineCode": "Kod Wbudowany", - "quote": "Blok cytat", - "header": "Nagłówek", - "highlight": "Podświetl" - }, - "tooltip": { - "lightMode": "Przełącz w Tryb Jasny", - "darkMode": "Przełącz w Tryb Ciemny" - }, - "contactsPage": { - "title": "Kontakty", - "whatsHappening": "Co się dzieje w tym tygodniu?", - "addContact": "Dodaj Kontakt", - "editContact": "Edytuj Kontakt" - }, - "button": { - "OK": "OK", - "Cancel": "Anuluj", - "signIn": "Zaloguj", - "signOut": "Wyloguj", - "complete": "Zakończono", - "save": "Zapisz" - }, - "label": { - "welcome": "Witaj!", - "firstName": "Imię Pierwsze", - "middleName": "Imię Drugie", - "lastName": "Nazwisko", - "stepX": "Krok {X}" - }, - "oAuth": { - "err": { - "failedTitle": "Nie można połączyć się z Twoim kontem.", - "failedMsg": "Upewnij się, że zakończyłeś proces logowania w przeglądarce." - }, - "google": { - "title": "LOGOWANIE GOOGLE", - "instruction1": "Aby zaimportować Kontakty Google, musisz autoryzować tę aplikację za pomocą przeglądarki internetowej.", - "instruction2": "Skopiuj ten kod do schowka, klikając ikonę lub zaznaczając tekst:", - "instruction3": "Przejdź do następującego linku w przeglądarce internetowej i wprowadź powyższy kod:", - "instruction4": "Naciśnij poniższy przycisk po zakończeniu rejestracji:" - } - }, - "settings": { - "title": "Ustawienia", - "menu": { - "appearance": "Wygląd", - "language": "Język", - "open": "Otwórz Ustawienia" - }, - "appearance": { - "lightLabel": "Tryb Jasny", - "darkLabel": "Tryb Ciemny" - } - }, - "sideBar": { - "openSidebar": "Open sidebar", - "closeSidebar": "Close sidebar" - } -} diff --git a/frontend/app_flowy/assets/translations/pt-BR.json b/frontend/app_flowy/assets/translations/pt-BR.json deleted file mode 100644 index 4bea9c6a3df49..0000000000000 --- a/frontend/app_flowy/assets/translations/pt-BR.json +++ /dev/null @@ -1,234 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Eu", - "welcomeText": "Bem-vindo @:appName", - "githubStarText": "Dar uma estrela no Github", - "subscribeNewsletterText": "Inscreva-se para receber novidades", - "letsGoButtonText": "Vamos lá", - "title": "Título", - "signUp": { - "buttonText": "Inscreva-se", - "title": "Inscreva-se no @:appName", - "getStartedText": "Começar", - "emptyPasswordError": "Senha não pode estar em branco.", - "repeatPasswordEmptyError": "Senha não estar em branco.", - "unmatchedPasswordError": "As senhas não conferem.", - "alreadyHaveAnAccount": "Já possui uma conta?", - "emailHint": "E-mail", - "passwordHint": "Dica de senha", - "repeatPasswordHint": "Confirme a senha" - }, - "signIn": { - "loginTitle": "Conectar-se ao @:appName", - "loginButtonText": "Conectar-se", - "buttonText": "Entre", - "forgotPassword": "Esqueceu sua senha?", - "emailHint": "E-mail", - "passwordHint": "Senha", - "dontHaveAnAccount": "Não possui uma conta?", - "repeatPasswordEmptyError": "Senha não pode estar em branco.", - "unmatchedPasswordError": "As senhas não conferem." - }, - "workspace": { - "create": "Crie uma área de trabalho", - "hint": "Espaço de trabalho", - "notFoundError": "Espaço de trabalho não encontrada" - }, - "shareAction": { - "buttonText": "Compartilhar", - "workInProgress": "Em breve", - "markdown": "Marcador", - "copyLink": "Copiar o link" - }, - "disclosureAction": { - "rename": "Renomear", - "delete": "Apagar", - "duplicate": "Duplicar" - }, - "blankPageTitle": "Página em branco", - "newPageText": "Nova página", - "trash": { - "text": "Lixeira", - "restoreAll": "Restaurar tudo", - "deleteAll": "Apagar tudo", - "pageHeader": { - "fileName": "Nome do arquivo", - "lastModified": "Última modificação", - "created": "Criado" - } - }, - "deletePagePrompt": { - "text": "Está página está na lixeira", - "restore": "Restaurar a página", - "deletePermanent": "Apagar permanentemente" - }, - "dialogCreatePageNameHint": "Nome da página", - "questionBubble": { - "whatsNew": "O que há de novo?", - "help": "Ajuda e Suporte", - "debug": { - "name": "Informação de depuração", - "success": "Informação de depuração copiada para a área de transferência!", - "fail": "Falha ao copiar a informação de depuração para a área de transferência" - } - }, - "menuAppHeader": { - "addPageTooltip": "Adicionar uma nova página.", - "defaultNewPageName": "Sem título", - "renameDialog": "Renomear" - }, - "toolbar": { - "undo": "Desfazer", - "redo": "Refazer", - "bold": "Negrito", - "italic": "Itálico", - "underline": "Sublinhado", - "strike": "Tachado", - "numList": "Lista numerada", - "bulletList": "Lista com marcadores", - "checkList": "Check list", - "inlineCode": "Embutir código", - "quote": "Citação em bloco", - "header": "Cabeçalho", - "highlight": "Realçar" - }, - "tooltip": { - "lightMode": "Mudar para o modo claro", - "darkMode": "Mudar para o modo escuro", - "openAsPage": "Abrir como uma página", - "addNewRow": "Adicionar uma nova linha", - "openMenu": "Clique para abrir o menu" - }, - "sideBar": { - "openSidebar": "Abrir barra lateral", - "closeSidebar": "Fechar barra lateral" - }, - "notifications": { - "export": { - "markdown": "Nota exportada como um marcador", - "path": "Documentos/flowy" - } - }, - "contactsPage": { - "title": "Contatos", - "whatsHappening": "O que está acontecendo essa semana?", - "addContact": "Adicionar um contato", - "editContact": "Editar um contato" - }, - "button": { - "OK": "Ok", - "Cancel": "Cancelar", - "signIn": "Conectar", - "signOut": "Desconectar", - "complete": "Completar", - "save": "Salvar" - }, - "label": { - "welcome": "Welcome!", - "firstName": "Nome", - "middleName": "Sobrenome", - "lastName": "Último nome", - "stepX": "Passo {X}" - }, - "oAuth": { - "err": { - "failedTitle": "Erro ao conectar a sua conta.", - "failedMsg": "Verifique se você concluiu o processo de conexão em seu navegador." - }, - "google": { - "title": "Conexão com o GOOGLE", - "instruction1": "Para importar seus Contatos do Google, você precisará autorizar este aplicativo usando seu navegador.", - "instruction2": "Copie este código para sua área de transferência clicando no ícone ou selecionando o texto:", - "instruction3": "Navegue até o link a seguir em seu navegador e digite o código acima:", - "instruction4": "Pressione o botão abaixo ao concluir a inscrição:" - } - }, - "settings": { - "title": "Configurações", - "menu": { - "appearance": "Aparência", - "language": "Idioma", - "user": "Usuário", - "open": "Abrir as Configurações" - }, - "appearance": { - "lightLabel": "Modo Claro", - "darkLabel": "Modo Escuro" - } - }, - "grid": { - "settings": { - "filter": "Filtro", - "sortBy": "Ordenar por", - "Properties": "Propriedades", - "group": "Grupo" - }, - "field": { - "hide": "Esconder", - "insertLeft": "Inserir à esquerda", - "insertRight": "Inserir à direita", - "duplicate": "Duplicar", - "delete": "Apagar", - "textFieldName": "Texto", - "checkboxFieldName": "Caixa de seleção", - "dateFieldName": "Data", - "numberFieldName": "Números", - "singleSelectFieldName": "Selecionar", - "multiSelectFieldName": "Seleção múltipla", - "urlFieldName": "URL", - "numberFormat": "Formato numérico", - "dateFormat": "Formato de data", - "includeTime": "Incluir horário", - "dateFormatFriendly": "Mês/Dia/Ano", - "dateFormatISO": "Ano/Mês/Dia", - "dateFormatLocal": "Mês/Dia/Ano", - "dateFormatUS": "Ano/Mês/Dia", - "timeFormat": "Formato de hora", - "invalidTimeFormat": "Formato Inválido", - "timeFormatTwelveHour": "12 horas", - "timeFormatTwentyFourHour": "24 horas", - "addSelectOption": "Adicionar uma opção", - "optionTitle": "Opções", - "addOption": "Adicionar opção", - "editProperty": "Editar propriedade", - "newColumn": "Nova coluna", - "deleteFieldPromptMessage": "Tem certeza? Esta propriedade será excluída" - }, - "row": { - "duplicate": "Duplicar", - "delete": "Apagar", - "textPlaceholder": "Vazio", - "copyProperty": "Propriedade copiada para a área de transferência", - "count": "Contagem" - }, - "selectOption": { - "create": "Criar", - "purpleColor": "Roxo", - "pinkColor": "Rosa", - "lightPinkColor": "Rosa claro", - "orangeColor": "Laranja", - "yellowColor": "Amarelo", - "limeColor": "Verde limão", - "greenColor": "Verde", - "aquaColor": "Áqua", - "blueColor": "Azul", - "deleteTag": "Apagar etiqueta", - "colorPannelTitle": "Cores", - "pannelTitle": "Escolha uma opção ou crie uma", - "searchOption": "Procure uma opção" - }, - "menuName": "Grade" - }, - "document": { - "menuName": "Doc", - "date": { - "timeHintTextInTwelveHour": "12:00 AM", - "timeHintTextInTwentyFourHour": "12:00" - } - }, - "board": { - "column": { - "create_new_card": "Novo" - } - } -} \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/pt-PT.json b/frontend/app_flowy/assets/translations/pt-PT.json deleted file mode 100644 index a9291949bd349..0000000000000 --- a/frontend/app_flowy/assets/translations/pt-PT.json +++ /dev/null @@ -1,150 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Me", - "welcomeText": "Bem vindo ao @:appName", - "githubStarText": "Star on GitHub", - "subscribeNewsletterText": "Inscreve-te ao Newsletter", - "letsGoButtonText": "Bora", - "title": "Título", - "signUp": { - "buttonText": "Inscreve-te", - "title": "Inscreve-te ao @:appName", - "getStartedText": "Começar", - "emptyPasswordError": "A palavra-passe não pode estar em branco.", - "repeatPasswordEmptyError": "Confirmar a palavra-passe não pode estar em branco.", - "unmatchedPasswordError": "As palavras-passes não coincidem.", - "alreadyHaveAnAccount": "Já possuis uma conta?", - "emailHint": "Email", - "passwordHint": "Password", - "repeatPasswordHint": "Confirma a tua password" - }, - "signIn": { - "loginTitle": "Entre no @:appName", - "loginButtonText": "Login", - "buttonText": "Entre", - "forgotPassword": "Esqueceste-te da tua palavra-passe?", - "emailHint": "Email", - "passwordHint": "Palavra-passe", - "dontHaveAnAccount": "Não possuis uma conta?", - "repeatPasswordEmptyError": "Confirmar a palavra-passe não pode estar em branco.", - "unmatchedPasswordError": "As palavras-passes não conferem." - }, - "workspace": { - "create": "Cria um ambiente de trabalho", - "hint": "ambiente de trabalho", - "notFoundError": "Ambiente de trabalho não encontrada" - }, - "shareAction": { - "buttonText": "Partilhar", - "workInProgress": "Em breve", - "markdown": "Markdown", - "copyLink": "Copiar o link" - }, - "disclosureAction": { - "rename": "Renomear", - "delete": "Apagar", - "duplicate": "Duplicar" - }, - "blankPageTitle": "Página em branco", - "newPageText": "Nova página", - "trash": { - "text": "Lixo", - "restoreAll": "Restaurar todos", - "deleteAll": "Apagar todos", - "pageHeader": { - "fileName": "Nome do ficheiro", - "lastModified": "Última modificação", - "created": "Criado" - } - }, - "deletePagePrompt": { - "text": "Esta página está no lixo", - "restore": "Restaurar a página", - "deletePermanent": "Apagar permanentemente" - }, - "dialogCreatePageNameHint": "Nome da página", - "questionBubble": { - "whatsNew": "O que há de novo?", - "help": "Ajuda & Suporte", - "debug": { - "name": "Informação de depuração", - "success": "Copiar informação de depuração para o clipboard!", - "fail": "Falha em copiar a informação de depuração para o clipboard" - } - }, - "menuAppHeader": { - "addPageTooltip": "Adiciona uma nova página.", - "defaultNewPageName": "Sem título", - "renameDialog": "Renomear" - }, - "toolbar": { - "undo": "Desfazer", - "redo": "Refazer", - "bold": "Negrito", - "italic": "Itálico", - "underline": "Sublinhado", - "strike": "Riscado", - "numList": "Lista numerada", - "bulletList": "Lista com marcadores", - "checkList": "Lista de verificação", - "inlineCode": "Embutir código", - "quote": "Citação em bloco", - "header": "Cabeçalho", - "highlight": "Realçar" - }, - "tooltip": { - "lightMode": "Mudar para o modo Claro.", - "darkMode": "Mudar para o modo Escuro." - }, - "contactsPage": { - "title": "Conctatos", - "whatsHappening": "O que está a acontecer nesta semana?", - "addContact": "Adicionar um conctato", - "editContact": "Editar um conctato" - }, - "button": { - "OK": "OK", - "Cancel": "Cancelar", - "signIn": "Entrar", - "signOut": "Sair", - "complete": "Completar", - "save": "Guardar" - }, - "label": { - "welcome": "Bem vindo!", - "firstName": "Nome", - "middleName": "Nome do Meio", - "lastName": "Apelido", - "stepX": "Passo {X}" - }, - "oAuth": { - "err": { - "failedTitle": "Erro ao conectar à sua conta.", - "failedMsg": "Verifica se concluiste o processo de login no teu navegador." - }, - "google": { - "title": "GOOGLE SIGN-IN", - "instruction1": "Para importar os teus Conctatos do Google, tens de autorizar esta aplicação usando o teu navegador web.", - "instruction2": "Copia este código para a tua área de transferências clicando no ícone ou selecionando o texto:", - "instruction3": "Navega até o link a seguir no seu navegador e digite o código acima:", - "instruction4": "Clica no botão abaixo ao concluir a inscrição:" - } - }, - "settings": { - "title": "Definições", - "menu": { - "appearance": "Aparência", - "language": "Idioma", - "open": "Abrir as Definições" - }, - "appearance": { - "lightLabel": "Modo Claro", - "darkLabel": "Modo Escuro" - } - }, - "sideBar": { - "openSidebar": "Open sidebar", - "closeSidebar": "Close sidebar" - } - } - diff --git a/frontend/app_flowy/assets/translations/ru-RU.json b/frontend/app_flowy/assets/translations/ru-RU.json deleted file mode 100644 index 6f53886f8473e..0000000000000 --- a/frontend/app_flowy/assets/translations/ru-RU.json +++ /dev/null @@ -1,233 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Я", - "welcomeText": "Добро пожаловать в @:appName", - "githubStarText": "Поставить звезду на GitHub", - "subscribeNewsletterText": "Подписаться на рассылку", - "letsGoButtonText": "Начнём", - "title": "Заголовок", - "signUp": { - "buttonText": "Зарегистрироваться", - "title": "Регистрация в @:appName", - "getStartedText": "Начать", - "emptyPasswordError": "Пароль не может быть пустым", - "repeatPasswordEmptyError": "Повтор пароля не может быть пустым", - "unmatchedPasswordError": "Пароли не совпадают", - "alreadyHaveAnAccount": "Уже есть аккаунт?", - "emailHint": "Электронная почта", - "passwordHint": "Пароль", - "repeatPasswordHint": "Повторите пароль" - }, - "signIn": { - "loginTitle": "Войти в @:appName", - "loginButtonText": "Войти", - "buttonText": "Авторизация", - "forgotPassword": "Забыли пароль?", - "emailHint": "Электронная почта", - "passwordHint": "Пароль", - "dontHaveAnAccount": "Нет аккаунта?", - "repeatPasswordEmptyError": "Повтор пароля не может быть пустым", - "unmatchedPasswordError": "Пароли не совпадают" - }, - "workspace": { - "create": "Создать рабочее пространство", - "hint": "рабочее пространство", - "notFoundError": "Нет такого рабочего пространства" - }, - "shareAction": { - "buttonText": "Поделиться", - "workInProgress": "В разработке", - "markdown": "Markdown", - "copyLink": "Скопировать ссылку" - }, - "disclosureAction": { - "rename": "Переименовать", - "delete": "Удалить", - "duplicate": "Дублировать" - }, - "blankPageTitle": "Пустая страница", - "newPageText": "Новая страница", - "trash": { - "text": "Корзина", - "restoreAll": "Восстановить всё", - "deleteAll": "Очистить", - "pageHeader": { - "fileName": "Имя", - "lastModified": "Последнее изменение", - "created": "Создан" - } - }, - "deletePagePrompt": { - "text": "Эта страница в Корзине", - "restore": "Восстановить страницу", - "deletePermanent": "Удалить навсегда" - }, - "dialogCreatePageNameHint": "Имя", - "questionBubble": { - "whatsNew": "Что нового?", - "help": "Помощь", - "debug": { - "name": "Отладочная информация", - "success": "Скопировано в буфер обмена!", - "fail": "Не получилось скопировать" - } - }, - "menuAppHeader": { - "addPageTooltip": "Быстро добавить новую страницу", - "defaultNewPageName": "Без заголовка", - "renameDialog": "Переименовать" - }, - "toolbar": { - "undo": "Отменить", - "redo": "Повторить", - "bold": "Жирный", - "italic": "Курсив", - "underline": "Подчёркнутый", - "strike": "Зачёркнутый", - "numList": "Нумерованный список", - "bulletList": "Маркированный список", - "checkList": "Список To-Do", - "inlineCode": "Код", - "quote": "Цитата", - "header": "Заголовок", - "highlight": "Выделение" - }, - "tooltip": { - "darkMode": "Переключиться в тёмную тему", - "openAsPage": "Открыть как страницу", - "addNewRow": "Добавить новую строку", - "openMenu": "Открыть меню" - }, - "sideBar": { - "closeSidebar": "Закрыть боковое меню", - "openSidebar": "Открыть боковое меню" - }, - "notifications": { - "export": { - "markdown": "Заметка экспортирована в Markdown", - "path": "Документы/flowy" - } - }, - "contactsPage": { - "title": "Контакты", - "whatsHappening": "Что происходит на этой неделе?", - "addContact": "Новый контакт", - "editContact": "Редактировать" - }, - "button": { - "OK": "OK", - "Cancel": "Отмена", - "signIn": "Войти", - "signOut": "Выйти", - "complete": "Завершить", - "save": "Сохранить" - }, - "label": { - "welcome": "Добро пожаловать!", - "firstName": "Имя", - "middleName": "Отчество", - "lastName": "Фамилия", - "stepX": "Этап {X}" - }, - "oAuth": { - "err": { - "failedTitle": "Ошибка подключения к аккаунту.", - "failedMsg": "Убедитесь, что вы завершили вход в своём браузере." - }, - "google": { - "title": "Вход через Google", - "instruction1": "Чтобы импортировать ваши Google Контакты, вам нужно будет авторизовать приложение через браузер.", - "instruction2": "Скопируйте этот код в буфер обмена (нажав кнопку или выделив текст):", - "instruction3": "Пройдите по ссылке и введите этот код:", - "instruction4": "Нажмите на кнопку ниже, когда завершите вход:" - } - }, - "settings": { - "title": "Настройки", - "menu": { - "appearance": "Внешний вид", - "language": "Язык", - "user": "Пользователь", - "open": "Открыть настройки" - }, - "appearance": { - "lightLabel": "Светлая", - "darkLabel": "Тёмная" - } - }, - "grid": { - "settings": { - "filter": "Фильтр", - "sortBy": "Сортировать", - "Properties": "Свойства", - "group": "Группировать" - }, - "field": { - "hide": "Скрыть", - "insertLeft": "Вставить слева", - "insertRight": "Вставить справа", - "duplicate": "Дублировать", - "delete": "Удалить", - "textFieldName": "Текст", - "checkboxFieldName": "Чекбокс", - "dateFieldName": "Дата", - "numberFieldName": "Число", - "singleSelectFieldName": "Выбор", - "multiSelectFieldName": "Выбор нескольких", - "urlFieldName": "URL", - "numberFormat": " Формат числа", - "dateFormat": " Формат даты", - "includeTime": " Время", - "dateFormatFriendly": "День Месяц, Год", - "dateFormatISO": "Год-Месяц-День", - "dateFormatLocal": "Месяц/День/Год", - "dateFormatUS": "Год/Месяц/День", - "timeFormat": " Форматировать время", - "invalidTimeFormat": "Неверный формат", - "timeFormatTwelveHour": "12 часов", - "timeFormatTwentyFourHour": "24 часа", - "addSelectOption": "Добавить вариант", - "optionTitle": "Варианты", - "addOption": "Добавить", - "editProperty": "Редактировать свойство", - "newColumn": "Добавить колонку", - "deleteFieldPromptMessage": "Вы уверены? Свойство будет удалено" - }, - "row": { - "duplicate": "Дублировать", - "delete": "Удалить", - "textPlaceholder": "Пусто", - "copyProperty": "Свойство скопировано", - "count": "Количество" - }, - "selectOption": { - "create": "Создать", - "purpleColor": "Фиолетовый", - "pinkColor": "Розовый", - "lightPinkColor": "Светло-розовый", - "orangeColor": "Оранжевый", - "yellowColor": "Желтый", - "limeColor": "Ярко-зелёный", - "greenColor": "Зелёный", - "aquaColor": "Бирюзовый", - "blueColor": "Синий", - "deleteTag": "Удалить вариант", - "colorPanelTitle": "Цвета", - "panelTitle": "Выберите или создайте вариант", - "searchOption": "Поиск" - }, - "menuName": "Сетка" - }, - "document": { - "menuName": "Документ", - "date": { - "timeHintTextInTwelveHour": "01:00 PM", - "timeHintTextInTwentyFourHour": "13:00" - } - }, - "board": { - "column": { - "create_new_card": "Создать" - } - } -} \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/sv.json b/frontend/app_flowy/assets/translations/sv.json deleted file mode 100644 index e6cf4c6ff29b8..0000000000000 --- a/frontend/app_flowy/assets/translations/sv.json +++ /dev/null @@ -1,235 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Jag", - "welcomeText": "Välkommen till @:appName", - "githubStarText": "Stärnmärk på GitHub", - "subscribeNewsletterText": "Prenumerera på nyhetsbrev", - "letsGoButtonText": "Kör igång", - "title": "Namn", - "signUp": { - "buttonText": "Registrera dig", - "title": "Registrera dig på @:appName", - "getStartedText": "Sätt igång", - "emptyPasswordError": "Lösenordet kan inte vara tomt", - "repeatPasswordEmptyError": "Upprepat lösenord kan inte vara tomt", - "unmatchedPasswordError": "Upprepat lösenord är inte samma som det första", - "alreadyHaveAnAccount": "Har du redan ett konto?", - "emailHint": "E-post", - "passwordHint": "Lösenord", - "repeatPasswordHint": "Uprepa lösenordet" - }, - "signIn": { - "loginTitle": "Logga in till @:appName", - "loginButtonText": "Logga in", - "buttonText": "Registrering", - "forgotPassword": "Glömt lösenordet?", - "emailHint": "E-post", - "passwordHint": "Lösenord", - "dontHaveAnAccount": "Har du inget konto?", - "repeatPasswordEmptyError": "Upprepat lösenord kan inte vara tomt", - "unmatchedPasswordError": "Upprepat lösenord är inte samma som det första" - }, - "workspace": { - "create": "Skapa arbetsyta", - "hint": "Arbetsyta", - "notFoundError": "Hittade ingen arbetsyta" - }, - "shareAction": { - "buttonText": "Dela", - "workInProgress": "Kommer snart", - "markdown": "Markdown", - "copyLink": "Kopiera länk" - }, - "disclosureAction": { - "rename": "Byt namn", - "delete": "Ta bort", - "duplicate": "Klona" - }, - "blankPageTitle": "Tom sida", - "newPageText": "Ny sida", - "trash": { - "text": "Skräp", - "restoreAll": "Återställ alla", - "deleteAll": "Ta bort alla", - "pageHeader": { - "fileName": "Filnamn", - "lastModified": "Ändrad", - "created": "Skapad" - } - }, - "deletePagePrompt": { - "text": "Denna sida är i skräpmappen", - "restore": "Återställ sida", - "deletePermanent": "Radera permanent" - }, - "dialogCreatePageNameHint": "Sidnamn", - "questionBubble": { - "whatsNew": "Vad nytt?", - "help": "Hjälp & Support", - "debug": { - "name": "Felsökningsinfo", - "success": "Kopierade felsökningsinfo till urklipp!", - "fail": "Kunde inte kopiera felsökningsinfo till urklipp" - } - }, - "menuAppHeader": { - "addPageTooltip": "Lägg snabbt till en sida inuti", - "defaultNewPageName": "Namnlös", - "renameDialog": "Byt namn" - }, - "toolbar": { - "undo": "Ångra", - "redo": "Upprepa", - "bold": "Fet", - "italic": "Kursiv", - "underline": "Understruken", - "strike": "Genomstruken", - "numList": "Numrerad lista", - "bulletList": "Punktlista", - "checkList": "Checklista", - "inlineCode": "Infogad kod", - "quote": "Citatblock", - "header": "Rubrik", - "highlight": "Färgmarkera" - }, - "tooltip": { - "lightMode": "Växla till ljust läge", - "darkMode": "Växla till mörkt läge", - "openAsPage": "Öppna som sida", - "addNewRow": "Lägg till ny rad", - "openMenu": "Klicka för att öppna meny" - }, - "sideBar": { - "closeSidebar": "Stäng sidofältet", - "openSidebar": "Öppna sidofältet" - }, - "notifications": { - "export": { - "markdown": "Exporterade anteckning till Markdown", - "path": "Dokument/flowy" - } - }, - "contactsPage": { - "title": "Kontakter", - "whatsHappening": "Vad händer denna vecka?", - "addContact": "Lägg till kontakt", - "editContact": "Redigera kontakt" - }, - "button": { - "OK": "OK", - "Cancel": "Avbryt", - "signIn": "Logga in", - "signOut": "Logga ut", - "complete": "Slutfört", - "save": "Spara" - }, - "label": { - "welcome": "Välkommen!", - "firstName": "Förnamn", - "middleName": "Mellannamn", - "lastName": "Efternamn", - "stepX": "Steg {X}" - }, - "oAuth": { - "err": { - "failedTitle": "Kan inte ansluta till ditt konto.", - "failedMsg": "Tillse att du har slutfört registreringsprocessen i din webbläsare." - }, - "google": { - "title": "GOOGLE-inloggning", - "instruction1": "För att kunna importera dina Google-kontakter, måste du auktorisera detta program med hjälp av din webbläsare.", - "instruction2": "Kopiera den här koden till urklipp genom att klicka på ikonen eller genom att markera texten:", - "instruction3": "Gå till följande länk i din webbläsare, och ange ovanstående kod:", - "instruction4": "Tryck på nedanstående knapp när du slutfört registreringen:" - } - }, - "settings": { - "title": "Inställningar", - "menu": { - "appearance": "Utseende", - "language": "Språk", - "user": "Användare", - "open": "Öppna inställningarna" - }, - "appearance": { - "lightLabel": "Ljust läge", - "darkLabel": "Mörkt läge" - } - }, - "grid": { - "settings": { - "filter": "Filter", - "sortBy": "Sortera efter", - "Properties": "Egenskaper", - "group": "Grupp" - }, - "field": { - "hide": "Dölj", - "insertLeft": "Infoga till vänster", - "insertRight": "Infoga till höger", - "duplicate": "Klona", - "delete": "Ta bort", - "textFieldName": "Text", - "checkboxFieldName": "Checkruta", - "dateFieldName": "Datum", - "numberFieldName": "Siffror", - "singleSelectFieldName": "Välj", - "multiSelectFieldName": "Välj flera", - "urlFieldName": "URL", - "numberFormat": " Sifferformat", - "dateFormat": " Datumformat", - "includeTime": " Inkludera tid", - "dateFormatFriendly": "Månad Dag,År", - "dateFormatISO": "År-Månad-Dag", - "dateFormatLocal": "Månad/Dag/År", - "dateFormatUS": "År/Månad/Dag", - "timeFormat": " Tidsformat", - "invalidTimeFormat": "Ogiltigt format", - "timeFormatTwelveHour": "12-timmars", - "timeFormatTwentyFourHour": "24-timmars", - "addSelectOption": "Lägg till ett alternativ", - "optionTitle": "Alternativ", - "addOption": "Lägg till alternativ", - "editProperty": "Redigera egenskap", - "newColumn": "Ny kolumn", - "deleteFieldPromptMessage": "Är du säker? Denna egenskap kommer att raderas." - }, - "row": { - "duplicate": "Klona", - "delete": "Ta bort", - "textPlaceholder": "Tom", - "copyProperty": "Kopierade egenskap till urklipp", - "count": "Antal", - "newRow": "Ny rad" - }, - "selectOption": { - "create": "Skapa", - "purpleColor": "Purpur", - "pinkColor": "Rosa", - "lightPinkColor": "Ljusrosa", - "orangeColor": "Orange", - "yellowColor": "Gul", - "limeColor": "Lime", - "greenColor": "Grön", - "aquaColor": "Vatten", - "blueColor": "Blå", - "deleteTag": "Ta bort tagg", - "colorPanelTitle": "Färger", - "panelTitle": "Välj ett alternativ eller skapa ett", - "searchOption": "Sök efter ett alternativ" - }, - "menuName": "Tabell" - }, - "document": { - "menuName": "Dokument", - "date": { - "timeHintTextInTwelveHour": "01:00 PM", - "timeHintTextInTwentyFourHour": "13:00" - } - }, - "board": { - "column": { - "create_new_card": "Nytt" - } - } -} diff --git a/frontend/app_flowy/assets/translations/tr-TR.json b/frontend/app_flowy/assets/translations/tr-TR.json deleted file mode 100644 index aa2b1a3a39972..0000000000000 --- a/frontend/app_flowy/assets/translations/tr-TR.json +++ /dev/null @@ -1,149 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "Ben", - "welcomeText": "@:appName'e Hoş Geldiniz!", - "githubStarText": "GitHub Yıldızı!", - "subscribeNewsletterText": "Bültene Abone Ol", - "letsGoButtonText": "Hadi başlayalım.", - "title": "Başlık", - "signUp": { - "buttonText": "Kayıt Ol", - "title": "@:appName'e kaydolun.", - "getStartedText": "başlayalım", - "emptyPasswordError": "Parola boş olamaz", - "repeatPasswordEmptyError": "Parola (tekrar) boş olamaz", - "unmatchedPasswordError": "Parolalar eşleşmiyor", - "alreadyHaveAnAccount": "Zaten hesabınız var mı?", - "emailHint": "E-Posta", - "passwordHint": "Parola", - "repeatPasswordHint": "Tekrar parola" - }, - "signIn": { - "loginTitle": "@:appName oturum aç", - "loginButtonText": "Giriş", - "buttonText": "Oturum Aç", - "forgotPassword": "Parolanızı mı Unuttunuz?", - "emailHint": "E-Posta", - "passwordHint": "Parola", - "dontHaveAnAccount": "Hesabınız yok mu?", - "repeatPasswordEmptyError": "Parola (tekrar) boş olamaz", - "unmatchedPasswordError": "Parolalar eşleşmiyor" - }, - "workspace": { - "create": "Çalışma alanı oluştur", - "hint": "Çalışma alanı", - "notFoundError": "Çalışma alanı bulunamadı" - }, - "shareAction": { - "buttonText": "Paylaş", - "workInProgress": "Yakında", - "markdown": "Markdown", - "copyLink": "Link'i Kopyala" - }, - "disclosureAction": { - "rename": "Yeniden adlandır", - "delete": "Sil", - "duplicate": "Çoğalt" - }, - "blankPageTitle": "Boş sayfa", - "newPageText": "Yeni sayfa", - "trash": { - "text": "Çöp", - "restoreAll": "Geri Yükle", - "deleteAll": "Sil", - "pageHeader": { - "fileName": "Dosya adı", - "lastModified": "Son Değiştirme", - "created": "Oluşturuldu" - } - }, - "deletePagePrompt": { - "text": "Bu sayfa Çöp Kutusu'nda", - "restore": "Sayfayı geri yükle", - "deletePermanent": "Kalıcı olarak sil" - }, - "dialogCreatePageNameHint": "Sayfa adı", - "questionBubble": { - "whatsNew": "Yeni ne var?", - "help": "Yardım & Destek", - "debug": { - "name": "Hata Ayıklama", - "success": "Hata ayıklama bilgileri panoya kopyalandı!", - "fail": "Hata ayıklama bilgileri panoya kopyalanamıyor" - } - }, - "menuAppHeader": { - "addPageTooltip": "Yeni bir sayfa ekleyin", - "defaultNewPageName": "Başlıksız", - "renameDialog": "Yeniden adlandır" - }, - "toolbar": { - "undo": "Geri", - "redo": "İleri", - "bold": "Kalın", - "italic": "İtalik", - "underline": "Altı Çizili", - "strike": "Üstü Çizili", - "numList": "Numaralı Liste", - "bulletList": "Madde İşaretli Liste", - "checkList": "Yapılacaklar Listesi", - "inlineCode": "Kod", - "quote": "Alıntı", - "header": "Başlık", - "highlight": "Vurgu" - }, - "tooltip": { - "lightMode": "Aydınlık Mod'a Geç", - "darkMode": "Karanlık Mod'a Geç" - }, - "contactsPage": { - "title": "İletişim", - "whatsHappening": "Bu hafta neler var?", - "addContact": "Kişi Ekle", - "editContact": "Kişiyi Düzenle" - }, - "button": { - "OK": "TAMAM", - "Cancel": "İptal", - "signIn": "Oturum Aç", - "signOut": "Oturum Kapat", - "complete": "Tamamlandı", - "save": "Kaydet" - }, - "label": { - "welcome": "Merhaba!", - "firstName": "Ad", - "middleName": "İkinci Ad", - "lastName": "Soyad", - "stepX": "Aşama {X}" - }, - "oAuth": { - "err": { - "failedTitle": "Hesabınıza bağlanılamıyor.", - "failedMsg": "Lütfen, tarayıcınızda oturum açma işlemini tamamladığınızdan emin olun." - }, - "google": { - "title": "GOOGLE OTURUM AÇMA", - "instruction1": "Google Kişilerinizi içe aktarmak için web tarayıcınızı kullanarak bu uygulamaya izin vermeniz gerekir.", - "instruction2": "Simgeyi tıklayarak veya metni seçerek bu kodu panonuza kopyalayın:", - "instruction3": "Web tarayıcınızda aşağıdaki bağlantıyı açın ve yukarıdaki kodu girin:", - "instruction4": "Kayıt işlemini tamamladığınızda aşağıdaki düğmeye basın:" - } - }, - "settings": { - "title": "Ayarlar", - "menu": { - "appearance": "Görünüm", - "language": "Dil", - "open": "Ayarları Aç" - }, - "appearance": { - "lightLabel": "Aydınlık Mod", - "darkLabel": "Karanlık Mod" - } - }, - "sideBar": { - "openSidebar": "Open sidebar", - "closeSidebar": "Close sidebar" - } -} diff --git a/frontend/app_flowy/assets/translations/zh-CN.json b/frontend/app_flowy/assets/translations/zh-CN.json deleted file mode 100644 index 6271d138cd620..0000000000000 --- a/frontend/app_flowy/assets/translations/zh-CN.json +++ /dev/null @@ -1,236 +0,0 @@ -{ - "appName": "Appflowy", - "defaultUsername": "我", - "welcomeText": "欢迎使用 @:appName", - "githubStarText": "Star on GitHub", - "subscribeNewsletterText": "消息订阅", - "letsGoButtonText": "开始", - "title": "标题", - "signUp": { - "buttonText": "注册", - "title": "注册 @:appName 账户", - "getStartedText": "开始", - "emptyPasswordError": "密码不能为空", - "repeatPasswordEmptyError": "确认密码不能为空", - "unmatchedPasswordError": "两次密码输入不一致", - "alreadyHaveAnAccount": "已有账户?", - "emailHint": "邮箱", - "passwordHint": "密码", - "repeatPasswordHint": "确认密码" - }, - "signIn": { - "loginTitle": "登录 @:appName", - "loginButtonText": "登录", - "buttonText": "登录", - "forgotPassword": "忘记密码?", - "emailHint": "邮箱", - "passwordHint": "密码", - "dontHaveAnAccount": "没有已注册的账户?", - "repeatPasswordEmptyError": "确认密码不能为空", - "unmatchedPasswordError": "两次密码输入不一致" - }, - "workspace": { - "create": "新建空间", - "hint": "空间", - "notFoundError": "未知的空间" - }, - "shareAction": { - "buttonText": "分享", - "workInProgress": "敬请期待", - "markdown": "Markdown", - "copyLink": "复制链接" - }, - "disclosureAction": { - "rename": "重命名", - "delete": "删除", - "duplicate": "复制" - }, - "blankPageTitle": "空白页", - "newPageText": "新页面", - "trash": { - "text": "回收站", - "restoreAll": "全部恢复", - "deleteAll": "全部删除", - "pageHeader": { - "fileName": "文件名", - "lastModified": "最近修改", - "created": "创建" - } - }, - "deletePagePrompt": { - "text": "此页面已被移动至回收站", - "restore": "恢复页面", - "deletePermanent": "彻底删除" - }, - "dialogCreatePageNameHint": "页面名称", - "questionBubble": { - "whatsNew": "新功能?", - "help": "帮助 & 支持", - "debug": { - "name": "调试信息", - "success": "将调试信息复制到剪贴板!", - "fail": "无法将调试信息复制到剪贴板" - } - }, - "menuAppHeader": { - "addPageTooltip": "在其中快速添加页面", - "defaultNewPageName": "未命名页面", - "renameDialog": "重命名" - }, - "toolbar": { - "undo": "撤销", - "redo": "恢复", - "bold": "加粗", - "italic": "斜体", - "underline": "下划线", - "strike": "删除线", - "numList": "有序列表", - "bulletList": "无序列表", - "checkList": "任务列表", - "inlineCode": "内联代码", - "quote": "块引用", - "header": "标题", - "highlight": "高亮" - }, - "tooltip": { - "lightMode": "切换到亮色模式", - "darkMode": "切换到暗色模式", - "openAsPage": "作为页面打开", - "addNewRow": "增加一行", - "openMenu": "点击打开菜单" - }, - "sideBar": { - "openSidebar": "打开侧边栏", - "closeSidebar": "关闭侧边栏" - }, - "notifications": { - "export": { - "markdown": "导出笔记为Markdown文档", - "path": "Documents/flowy" - } - }, - "contactsPage": { - "title": "联系人", - "whatsHappening": "这周发生了哪些事?", - "addContact": "添加联系人", - "editContact": "编辑联系人" - }, - "button": { - "OK": "确认", - "Cancel": "取消", - "signIn": "登录", - "signOut": "登出", - "complete": "完成", - "save": "保存" - }, - "label": { - "welcome": "欢迎!", - "firstName": "名", - "middleName": "中间名", - "lastName": "姓", - "stepX": "第{X}步" - }, - "oAuth": { - "err": { - "failedTitle": "无法连接到您的账户。", - "failedMsg": "请确认您已在浏览器中完成登录。" - }, - "google": { - "title": "Google 账号登录", - "instruction1": "为了导入您的 Google 联系人,您需要在浏览器中给予本程序授权。", - "instruction2": "单击图标或选择文本复制到剪贴板:", - "instruction3": "进入下面的链接,然后输入上面的代码:", - "instruction4": "完成注册后,点击下面的按钮:" - } - }, - "settings": { - "title": "设置", - "menu": { - "appearance": "外观", - "language": "语言", - "user": "用户", - "open": "打开设置" - }, - "appearance": { - "lightLabel": "日间模式", - "darkLabel": "夜间模式" - } - }, - "grid": { - "settings": { - "filter": "过滤器", - "sortBy": "排序", - "Properties": "属性", - "group": "组" - }, - "field": { - "hide": "隐藏", - "insertLeft": "左侧插入", - "insertRight": "右侧插入", - "duplicate": "拷贝", - "delete": "删除", - "textFieldName": "文本", - "checkboxFieldName": "勾选框", - "dateFieldName": "日期", - "numberFieldName": "数字", - "singleSelectFieldName": "单项选择器", - "multiSelectFieldName": "多项选择器", - "urlFieldName": "链接", - "numberFormat": " 数字格式", - "dateFormat": " 日期格式", - "includeTime": " 包含时间", - "dateFormatFriendly": "月 日,年", - "dateFormatISO": "年-月-日", - "dateFormatLocal": "月/日/年", - "dateFormatUS": "年/月/日", - "timeFormat": " 时间格式", - "invalidTimeFormat": "时间格式错误", - "timeFormatTwelveHour": "12小时制", - "timeFormatTwentyFourHour": "24小时制", - "addSelectOption": "添加一个标签", - "optionTitle": "标签", - "addOption": "添加标签", - "editProperty": "编辑列属性", - "newColumn": "增加一列", - "deleteFieldPromptMessage": "确定要删除这个属性吗? " - }, - "row": { - "duplicate": "复制", - "delete": "删除", - "textPlaceholder": "空", - "copyProperty": "复制列", - "count": "数量", - "newRow": "添加一行" - }, - "selectOption": { - "create": "新建", - "purpleColor": "紫色", - "pinkColor": "粉色", - "lightPinkColor": "浅粉色", - "orangeColor": "橙色", - "yellowColor": "黄色", - "limeColor": "鲜绿色", - "greenColor": "绿色", - "aquaColor": "水蓝色", - "blueColor": "蓝色", - "deleteTag": "删除标签", - "colorPanelTitle": "颜色", - "panelTitle": "选择或新建一个标签", - "searchOption": "搜索标签" - }, - "menuName": "网格" - }, - "document": { - "menuName": "文档", - "date": { - "timeHintTextInTwelveHour": "01:00 PM", - "timeHintTextInTwentyFourHour": "13:00" - } - }, - "board": { - "column": { - "create_new_card": "新建" - }, - "menuName": "看板" - } -} \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/zh-TW.json b/frontend/app_flowy/assets/translations/zh-TW.json deleted file mode 100644 index faa2dea03ec6d..0000000000000 --- a/frontend/app_flowy/assets/translations/zh-TW.json +++ /dev/null @@ -1,222 +0,0 @@ -{ - "appName": "AppFlowy", - "defaultUsername": "我", - "welcomeText": "歡迎使用 @:appName", - "githubStarText": "在 GitHub 點星", - "subscribeNewsletterText": "訂閱電子報", - "letsGoButtonText": "出發吧", - "title": "標題", - "signUp": { - "buttonText": "註冊", - "title": "註冊 @:appName", - "getStartedText": "開始使用", - "emptyPasswordError": "密碼不能為空", - "repeatPasswordEmptyError": "確認密碼不能為空", - "unmatchedPasswordError": "確認密碼與密碼不符", - "alreadyHaveAnAccount": "已經有帳號了嗎?", - "emailHint": "電子郵件地址", - "passwordHint": "密碼", - "repeatPasswordHint": "確認密碼" - }, - "signIn": { - "loginTitle": "登入 @:appName", - "loginButtonText": "登入", - "buttonText": "登入", - "forgotPassword": "忘記密碼?", - "emailHint": "電子郵件地址", - "passwordHint": "密碼", - "dontHaveAnAccount": "沒有帳號?", - "repeatPasswordEmptyError": "確認密碼不能為空", - "unmatchedPasswordError": "確認密碼與密碼不符" - }, - "workspace": { - "create": "建立工作區", - "hint": "工作區", - "notFoundError": "找不到工作區" - }, - "shareAction": { - "buttonText": "分享", - "workInProgress": "即將推出", - "markdown": "Markdown", - "copyLink": "複製連結" - }, - "disclosureAction": { - "rename": "重新命名", - "delete": "刪除", - "duplicate": "複製" - }, - "blankPageTitle": "空白頁面", - "newPageText": "新頁面", - "trash": { - "text": "垃圾筒", - "restoreAll": "全部復原", - "deleteAll": "全部刪除", - "pageHeader": { - "fileName": "檔案名稱", - "lastModified": "最後修改時間", - "created": "建立時間" - } - }, - "deletePagePrompt": { - "text": "此頁面在垃圾筒中", - "restore": "復原頁面", - "deletePermanent": "永久刪除" - }, - "dialogCreatePageNameHint": "頁面名稱", - "questionBubble": { - "whatsNew": "新功能", - "help": "幫助 & 支援", - "debug": { - "name": "除錯資訊", - "success": "已將除錯資訊複製至剪貼簿!", - "fail": "無法將除錯資訊複製至剪貼簿" - } - }, - "menuAppHeader": { - "addPageTooltip": "快速新增頁面", - "defaultNewPageName": "未命名", - "renameDialog": "重新命名" - }, - "toolbar": { - "undo": "復原", - "redo": "取消復原", - "bold": "粗體", - "italic": "斜體", - "underline": "底線", - "strike": "刪除線", - "numList": "有序清單", - "bulletList": "無序清單", - "checkList": "核取清單", - "inlineCode": "程式碼", - "quote": "區塊引言", - "header": "標題", - "highlight": "反白" - }, - "tooltip": { - "lightMode": "切換至亮色模式", - "darkMode": "切換至暗色模式" - }, - "notifications": { - "export": { - "markdown": "已將筆記匯出成 Markdown", - "path": "Documents/flowy" - } - }, - "contactsPage": { - "title": "聯絡人", - "whatsHappening": "這周有甚麼新鮮事?", - "addContact": "新增聯絡人", - "editContact": "編輯聯絡人" - }, - "button": { - "OK": "OK", - "Cancel": "取消", - "signIn": "登入", - "signOut": "登出", - "complete": "完成", - "save": "儲存" - }, - "label": { - "welcome": "歡迎!", - "firstName": "名", - "middleName": "中間名", - "lastName": "姓", - "stepX": "步驟 {X}" - }, - "oAuth": { - "err": { - "failedTitle": "無法連接至您的帳號。", - "failedMsg": "請確認您已在瀏覽器中完成登入程序:" - }, - "google": { - "title": "GOOGLE 登入", - "instruction1": "若要匯入您的 Google 聯絡人,您必須透過瀏覽器授權此應用程式:", - "instruction2": "點擊圖示或選取文字以複製代碼:", - "instruction3": "前往下列網址,並輸入上述代碼:", - "instruction4": "完成註冊後,請點擊下方按鈕:" - } - }, - "settings": { - "title": "設定", - "menu": { - "appearance": "外觀", - "language": "語言", - "user": "使用者", - "open": "開啟設定" - }, - "appearance": { - "lightLabel": "亮色模式", - "darkLabel": "暗色模式" - } - }, - "grid": { - "settings": { - "filter": "篩選", - "sortBy": "排序方式", - "Properties": "內容" - }, - "field": { - "hide": "隱藏", - "insertLeft": "插入左方欄", - "insertRight": "插入右方欄", - "duplicate": "複製", - "delete": "刪除", - "textFieldName": "文字", - "checkboxFieldName": "核取方塊", - "dateFieldName": "日期", - "numberFieldName": "數字", - "singleSelectFieldName": "單選", - "multiSelectFieldName": "多選", - "urlFieldName": "網址", - "numberFormat": " 數字格式", - "dateFormat": " 日期格式", - "includeTime": " 包含時間", - "dateFormatFriendly": "月 日,年", - "dateFormatISO": "年-月-日", - "dateFormatLocal": "月/日/年", - "dateFormatUS": "年/月/日", - "timeFormat": " 時間格式", - "invalidTimeFormat": "格式無效", - "timeFormatTwelveHour": "12 小時", - "timeFormatTwentyFourHour": "24 小時", - "addSelectOption": "新增選項", - "optionTitle": "選項", - "addOption": "新增選項", - "editProperty": "編輯內容" - }, - "row": { - "duplicate": "複製", - "delete": "刪除", - "textPlaceholder": "空", - "copyProperty": "已將內容複製至剪貼簿" - }, - "selectOption": { - "create": "建立", - "purpleColor": "紫色", - "pinkColor": "粉色", - "lightPinkColor": "淡粉色", - "orangeColor": "橘色", - "yellowColor": "黃色", - "limeColor": "萊姆色", - "greenColor": "綠色", - "aquaColor": "水藍色", - "blueColor": "藍色", - "deleteTag": "刪除標籤", - "colorPanelTitle": "顏色", - "panelTitle": "搜尋或建立選項", - "searchOption": "搜尋選項" - }, - "menuName": "網格" - }, - "document": { - "menuName": "檔案", - "date": { - "timeHintTextInTwelveHour": "01:00 PM", - "timeHintTextInTwentyFourHour": "13:00" - } - }, - "sideBar": { - "openSidebar": "Open sidebar", - "closeSidebar": "Close sidebar" - } -} \ No newline at end of file diff --git a/frontend/app_flowy/ios/Flutter/AppFrameworkInfo.plist b/frontend/app_flowy/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 8d4492f977adc..0000000000000 --- a/frontend/app_flowy/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - MinimumOSVersion - 9.0 - - diff --git a/frontend/app_flowy/ios/Podfile.lock b/frontend/app_flowy/ios/Podfile.lock deleted file mode 100644 index 779d9d60158af..0000000000000 --- a/frontend/app_flowy/ios/Podfile.lock +++ /dev/null @@ -1,81 +0,0 @@ -PODS: - - flowy_editor (0.0.1): - - Flutter - - flowy_infra_ui (0.0.1): - - Flutter - - flowy_sdk (0.0.1): - - Flutter - - Flutter (1.0.0) - - flutter_inappwebview (0.0.1): - - Flutter - - flutter_inappwebview/Core (= 0.0.1) - - OrderedSet (~> 5.0) - - flutter_inappwebview/Core (0.0.1): - - Flutter - - OrderedSet (~> 5.0) - - flutter_keyboard_visibility (0.0.1): - - Flutter - - image_picker (0.0.1): - - Flutter - - OrderedSet (5.0.0) - - path_provider (0.0.1): - - Flutter - - url_launcher (0.0.1): - - Flutter - - video_player (0.0.1): - - Flutter - -DEPENDENCIES: - - flowy_editor (from `.symlinks/plugins/flowy_editor/ios`) - - flowy_infra_ui (from `.symlinks/plugins/flowy_infra_ui/ios`) - - flowy_sdk (from `.symlinks/plugins/flowy_sdk/ios`) - - Flutter (from `Flutter`) - - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`) - - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - - image_picker (from `.symlinks/plugins/image_picker/ios`) - - path_provider (from `.symlinks/plugins/path_provider/ios`) - - url_launcher (from `.symlinks/plugins/url_launcher/ios`) - - video_player (from `.symlinks/plugins/video_player/ios`) - -SPEC REPOS: - trunk: - - OrderedSet - -EXTERNAL SOURCES: - flowy_editor: - :path: ".symlinks/plugins/flowy_editor/ios" - flowy_infra_ui: - :path: ".symlinks/plugins/flowy_infra_ui/ios" - flowy_sdk: - :path: ".symlinks/plugins/flowy_sdk/ios" - Flutter: - :path: Flutter - flutter_inappwebview: - :path: ".symlinks/plugins/flutter_inappwebview/ios" - flutter_keyboard_visibility: - :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" - image_picker: - :path: ".symlinks/plugins/image_picker/ios" - path_provider: - :path: ".symlinks/plugins/path_provider/ios" - url_launcher: - :path: ".symlinks/plugins/url_launcher/ios" - video_player: - :path: ".symlinks/plugins/video_player/ios" - -SPEC CHECKSUMS: - flowy_editor: bf8d58894ddb03453bd4d8521c57267ad638b837 - flowy_infra_ui: 146c88346fd55d2ee6a41ae35059a5bf095cfbb3 - flowy_sdk: c416222c639e678828776789bf0c1a1d0d59df3c - Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a - flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721 - flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 - image_picker: 9aa50e1d8cdacdbed739e925b7eea16d014367e6 - OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c - path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c - url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef - video_player: ecd305f42e9044793efd34846e1ce64c31ea6fcb - -PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c - -COCOAPODS: 1.10.1 diff --git a/frontend/app_flowy/ios/Runner.xcodeproj/project.pbxproj b/frontend/app_flowy/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index c5e6758eb68e2..0000000000000 --- a/frontend/app_flowy/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,539 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 50; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - 9D1D47ADD7F5DE8237063BCA /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 197F72694BED43249F1523E8 /* Pods_Runner.framework */; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 197F72694BED43249F1523E8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 35DA03217F6DD4F7AC9356F9 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 4C2CB38DA64605A62D45B098 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 580A1ED8E012CA1552E5EFD3 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 9D1D47ADD7F5DE8237063BCA /* Pods_Runner.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 78844014EF958DCBB6F9B4EA /* Frameworks */ = { - isa = PBXGroup; - children = ( - 197F72694BED43249F1523E8 /* Pods_Runner.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 9EC83BEE9154F1BD11D24F8F /* Pods */, - 78844014EF958DCBB6F9B4EA /* Frameworks */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; - 9EC83BEE9154F1BD11D24F8F /* Pods */ = { - isa = PBXGroup; - children = ( - 35DA03217F6DD4F7AC9356F9 /* Pods-Runner.debug.xcconfig */, - 580A1ED8E012CA1552E5EFD3 /* Pods-Runner.release.xcconfig */, - 4C2CB38DA64605A62D45B098 /* Pods-Runner.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - E790B8FE5609053209ED85CB /* [CP] Check Pods Manifest.lock */, - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 08FAA63113168DEC7FB74204 /* [CP] Embed Pods Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 1300; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 08FAA63113168DEC7FB74204 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; - E790B8FE5609053209ED85CB /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.example.appFlowy; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.example.appFlowy; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.example.appFlowy; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/frontend/app_flowy/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/app_flowy/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index 3db53b6e1fb7f..0000000000000 --- a/frontend/app_flowy/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/ios/Runner/Info.plist b/frontend/app_flowy/ios/Runner/Info.plist deleted file mode 100644 index 122f6a8cd7ca7..0000000000000 --- a/frontend/app_flowy/ios/Runner/Info.plist +++ /dev/null @@ -1,51 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - app_flowy - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - CFBundleLocalizations - - en - - CADisableMinimumFrameDurationOnPhone - - - diff --git a/frontend/app_flowy/lib/core/folder_notification.dart b/frontend/app_flowy/lib/core/folder_notification.dart deleted file mode 100644 index 1f9d7751ceabc..0000000000000 --- a/frontend/app_flowy/lib/core/folder_notification.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; -import 'package:flowy_sdk/protobuf/dart-notify/protobuf.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/dart_notification.pb.dart'; -import 'package:flowy_sdk/rust_stream.dart'; - -import 'notification_helper.dart'; - -// Folder -typedef FolderNotificationCallback = void Function(FolderNotification, Either); - -class FolderNotificationParser extends NotificationParser { - FolderNotificationParser({String? id, required FolderNotificationCallback callback}) - : super( - id: id, - callback: callback, - tyParser: (ty) => FolderNotification.valueOf(ty), - errorParser: (bytes) => FlowyError.fromBuffer(bytes), - ); -} - -typedef FolderNotificationHandler = Function(FolderNotification ty, Either result); - -class FolderNotificationListener { - StreamSubscription? _subscription; - FolderNotificationParser? _parser; - - FolderNotificationListener({required String objectId, required FolderNotificationHandler handler}) - : _parser = FolderNotificationParser(id: objectId, callback: handler) { - _subscription = RustStreamReceiver.listen((observable) => _parser?.parse(observable)); - } - - Future stop() async { - _parser = null; - await _subscription?.cancel(); - } -} diff --git a/frontend/app_flowy/lib/core/frameless_window.dart b/frontend/app_flowy/lib/core/frameless_window.dart deleted file mode 100644 index 3641aeef4d16b..0000000000000 --- a/frontend/app_flowy/lib/core/frameless_window.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter/material.dart'; -import 'dart:io' show Platform; - -class CocoaWindowChannel { - CocoaWindowChannel._(); - - final MethodChannel _channel = const MethodChannel("flutter/cocoaWindow"); - - static final CocoaWindowChannel instance = CocoaWindowChannel._(); - - Future setWindowPosition(Offset offset) async { - await _channel.invokeMethod("setWindowPosition", [offset.dx, offset.dy]); - } - - Future> getWindowPosition() async { - final raw = await _channel.invokeMethod("getWindowPosition"); - final arr = raw as List; - final List result = arr.map((s) => s as double).toList(); - return result; - } - - Future zoom() async { - await _channel.invokeMethod("zoom"); - } -} - -class MoveWindowDetector extends StatefulWidget { - const MoveWindowDetector({Key? key, this.child}) : super(key: key); - - final Widget? child; - - @override - MoveWindowDetectorState createState() => MoveWindowDetectorState(); -} - -class MoveWindowDetectorState extends State { - double winX = 0; - double winY = 0; - - @override - Widget build(BuildContext context) { - if (!Platform.isMacOS) { - return widget.child ?? Container(); - } - return GestureDetector( - // https://stackoverflow.com/questions/52965799/flutter-gesturedetector-not-working-with-containers-in-stack - behavior: HitTestBehavior.translucent, - onDoubleTap: () async { - await CocoaWindowChannel.instance.zoom(); - }, - onPanStart: (DragStartDetails details) { - winX = details.globalPosition.dx; - winY = details.globalPosition.dy; - }, - onPanUpdate: (DragUpdateDetails details) async { - final windowPos = await CocoaWindowChannel.instance.getWindowPosition(); - final double dx = windowPos[0]; - final double dy = windowPos[1]; - final deltaX = details.globalPosition.dx - winX; - final deltaY = details.globalPosition.dy - winY; - await CocoaWindowChannel.instance - .setWindowPosition(Offset(dx + deltaX, dy - deltaY)); - }, - child: widget.child, - ); - } -} diff --git a/frontend/app_flowy/lib/core/grid_notification.dart b/frontend/app_flowy/lib/core/grid_notification.dart deleted file mode 100644 index 9a2429a417f49..0000000000000 --- a/frontend/app_flowy/lib/core/grid_notification.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; -import 'package:flowy_sdk/protobuf/dart-notify/protobuf.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/dart_notification.pb.dart'; -import 'package:flowy_sdk/rust_stream.dart'; - -import 'notification_helper.dart'; - -// GridPB -typedef GridNotificationCallback = void Function(GridNotification, Either); - -class GridNotificationParser extends NotificationParser { - GridNotificationParser({String? id, required GridNotificationCallback callback}) - : super( - id: id, - callback: callback, - tyParser: (ty) => GridNotification.valueOf(ty), - errorParser: (bytes) => FlowyError.fromBuffer(bytes), - ); -} - -typedef GridNotificationHandler = Function(GridNotification ty, Either result); - -class GridNotificationListener { - StreamSubscription? _subscription; - GridNotificationParser? _parser; - - GridNotificationListener({required String objectId, required GridNotificationHandler handler}) - : _parser = GridNotificationParser(id: objectId, callback: handler) { - _subscription = RustStreamReceiver.listen((observable) => _parser?.parse(observable)); - } - - Future stop() async { - _parser = null; - await _subscription?.cancel(); - } -} diff --git a/frontend/app_flowy/lib/core/network_monitor.dart b/frontend/app_flowy/lib/core/network_monitor.dart deleted file mode 100644 index ba7806873d481..0000000000000 --- a/frontend/app_flowy/lib/core/network_monitor.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:async'; - -import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-net/network_state.pb.dart'; -import 'package:flutter/services.dart'; - -class NetworkListener { - final Connectivity _connectivity = Connectivity(); - late StreamSubscription _connectivitySubscription; - - NetworkListener() { - _connectivitySubscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus); - } - - Future start() async { - late ConnectivityResult result; - // Platform messages may fail, so we use a try/catch PlatformException. - try { - result = await _connectivity.checkConnectivity(); - } on PlatformException catch (e) { - Log.error('Couldn\'t check connectivity status. $e'); - return; - } - return _updateConnectionStatus(result); - } - - void stop() { - _connectivitySubscription.cancel(); - } - - Future _updateConnectionStatus(ConnectivityResult result) async { - final networkType = () { - switch (result) { - case ConnectivityResult.wifi: - return NetworkType.Wifi; - case ConnectivityResult.ethernet: - return NetworkType.Ethernet; - case ConnectivityResult.mobile: - return NetworkType.Cell; - case ConnectivityResult.none: - return NetworkType.UnknownNetworkType; - case ConnectivityResult.bluetooth: - return NetworkType.UnknownNetworkType; - } - }(); - Log.info("Network type: $networkType"); - final state = NetworkState.create()..ty = networkType; - NetworkEventUpdateNetworkType(state).send().then((result) { - result.fold( - (l) {}, - (e) => Log.error(e), - ); - }); - } -} diff --git a/frontend/app_flowy/lib/core/notification_helper.dart b/frontend/app_flowy/lib/core/notification_helper.dart deleted file mode 100644 index ba99c8bab0340..0000000000000 --- a/frontend/app_flowy/lib/core/notification_helper.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'dart:typed_data'; -import 'package:flowy_sdk/protobuf/dart-notify/protobuf.dart'; -import 'package:dartz/dartz.dart'; - -class NotificationParser { - String? id; - void Function(T, Either) callback; - - T? Function(int) tyParser; - E Function(Uint8List) errorParser; - - NotificationParser({this.id, required this.callback, required this.errorParser, required this.tyParser}); - void parse(SubscribeObject subject) { - if (id != null) { - if (subject.id != id) { - return; - } - } - - final ty = tyParser(subject.ty); - if (ty == null) { - return; - } - - if (subject.hasError()) { - final bytes = Uint8List.fromList(subject.error); - final error = errorParser(bytes); - callback(ty, right(error)); - } else { - final bytes = Uint8List.fromList(subject.payload); - callback(ty, left(bytes)); - } - } -} diff --git a/frontend/app_flowy/lib/core/user_notification.dart b/frontend/app_flowy/lib/core/user_notification.dart deleted file mode 100644 index 8e43d4b8241de..0000000000000 --- a/frontend/app_flowy/lib/core/user_notification.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; -import 'package:flowy_sdk/protobuf/dart-notify/protobuf.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/rust_stream.dart'; - -import 'notification_helper.dart'; - -// User -typedef UserNotificationCallback = void Function(UserNotification, Either); - -class UserNotificationParser extends NotificationParser { - UserNotificationParser({required String id, required UserNotificationCallback callback}) - : super( - id: id, - callback: callback, - tyParser: (ty) => UserNotification.valueOf(ty), - errorParser: (bytes) => FlowyError.fromBuffer(bytes), - ); -} - -typedef UserNotificationHandler = Function(UserNotification ty, Either result); - -class UserNotificationListener { - StreamSubscription? _subscription; - UserNotificationParser? _parser; - - UserNotificationListener({required String objectId, required UserNotificationHandler handler}) - : _parser = UserNotificationParser(id: objectId, callback: handler) { - _subscription = RustStreamReceiver.listen((observable) => _parser?.parse(observable)); - } - - Future stop() async { - _parser = null; - await _subscription?.cancel(); - } -} diff --git a/frontend/app_flowy/lib/main.dart b/frontend/app_flowy/lib/main.dart deleted file mode 100644 index 58b784da26fc1..0000000000000 --- a/frontend/app_flowy/lib/main.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/user/presentation/splash_screen.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:hotkey_manager/hotkey_manager.dart'; -import 'package:flutter/material.dart'; - -class FlowyApp implements EntryPoint { - @override - Widget create() { - return const SplashScreen(); - } -} - -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - await EasyLocalization.ensureInitialized(); - - WidgetsFlutterBinding.ensureInitialized(); - await hotKeyManager.unregisterAll(); - - await FlowyRunner.run(FlowyApp()); -} diff --git a/frontend/app_flowy/lib/plugins/blank/blank.dart b/frontend/app_flowy/lib/plugins/blank/blank.dart deleted file mode 100644 index 7595adefd584d..0000000000000 --- a/frontend/app_flowy/lib/plugins/blank/blank.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; - -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/startup/plugin/plugin.dart'; - -class BlankPluginBuilder extends PluginBuilder { - @override - Plugin build(dynamic data) { - return BlankPagePlugin(); - } - - @override - String get menuName => "Blank"; - - @override - PluginType get pluginType => PluginType.blank; -} - -class BlankPluginConfig implements PluginConfig { - @override - bool get creatable => false; -} - -class BlankPagePlugin extends Plugin { - @override - PluginDisplay get display => BlankPagePluginDisplay(); - - @override - PluginId get id => "BlankStack"; - - @override - PluginType get ty => PluginType.blank; -} - -class BlankPagePluginDisplay extends PluginDisplay with NavigationItem { - @override - Widget get leftBarItem => - FlowyText.medium(LocaleKeys.blankPageTitle.tr(), fontSize: 12); - - @override - Widget buildWidget(PluginContext context) => const BlankPage(); - - @override - List get navigationItems => [this]; -} - -class BlankPage extends StatefulWidget { - const BlankPage({Key? key}) : super(key: key); - - @override - State createState() => _BlankPageState(); -} - -class _BlankPageState extends State { - @override - Widget build(BuildContext context) { - return SizedBox.expand( - child: Container( - color: Theme.of(context).colorScheme.surface, - child: Padding( - padding: const EdgeInsets.all(10), - child: Container(), - ), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/board/application/board_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/board_bloc.dart deleted file mode 100644 index 027fa943160d6..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/board_bloc.dart +++ /dev/null @@ -1,504 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; - -import 'package:app_flowy/plugins/grid/application/block/block_cache.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_service.dart'; -import 'package:appflowy_board/appflowy_board.dart'; -import 'package:dartz/dartz.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import 'board_data_controller.dart'; -import 'group_controller.dart'; - -part 'board_bloc.freezed.dart'; - -class BoardBloc extends Bloc { - final BoardDataController _gridDataController; - late final AppFlowyBoardController boardController; - final MoveRowFFIService _rowService; - final LinkedHashMap groupControllers = - LinkedHashMap(); - - GridFieldController get fieldController => - _gridDataController.fieldController; - String get gridId => _gridDataController.gridId; - - BoardBloc({required ViewPB view}) - : _rowService = MoveRowFFIService(gridId: view.id), - _gridDataController = BoardDataController(view: view), - super(BoardState.initial(view.id)) { - boardController = AppFlowyBoardController( - onMoveGroup: ( - fromGroupId, - fromIndex, - toGroupId, - toIndex, - ) { - _moveGroup(fromGroupId, toGroupId); - }, - onMoveGroupItem: ( - groupId, - fromIndex, - toIndex, - ) { - final fromRow = groupControllers[groupId]?.rowAtIndex(fromIndex); - final toRow = groupControllers[groupId]?.rowAtIndex(toIndex); - _moveRow(fromRow, groupId, toRow); - }, - onMoveGroupItemToGroup: ( - fromGroupId, - fromIndex, - toGroupId, - toIndex, - ) { - final fromRow = groupControllers[fromGroupId]?.rowAtIndex(fromIndex); - final toRow = groupControllers[toGroupId]?.rowAtIndex(toIndex); - _moveRow(fromRow, toGroupId, toRow); - }, - ); - - on( - (event, emit) async { - await event.when( - initial: () async { - _startListening(); - await _openGrid(emit); - }, - createBottomRow: (groupId) async { - final startRowId = groupControllers[groupId]?.lastRow()?.id; - final result = await _gridDataController.createBoardCard( - groupId, - startRowId: startRowId, - ); - result.fold( - (_) {}, - (err) => Log.error(err), - ); - }, - createHeaderRow: (String groupId) async { - final result = await _gridDataController.createBoardCard(groupId); - result.fold( - (_) {}, - (err) => Log.error(err), - ); - }, - didCreateRow: (group, row, int? index) { - emit(state.copyWith( - editingRow: Some(BoardEditingRow( - group: group, - row: row, - index: index, - )), - )); - _groupItemStartEditing(group, row, true); - }, - startEditingRow: (group, row) { - emit(state.copyWith( - editingRow: Some(BoardEditingRow( - group: group, - row: row, - index: null, - )), - )); - _groupItemStartEditing(group, row, true); - }, - endEditingRow: (rowId) { - state.editingRow.fold(() => null, (editingRow) { - assert(editingRow.row.id == rowId); - _groupItemStartEditing(editingRow.group, editingRow.row, false); - emit(state.copyWith(editingRow: none())); - }); - }, - didReceiveGridUpdate: (GridPB grid) { - emit(state.copyWith(grid: Some(grid))); - }, - didReceiveError: (FlowyError error) { - emit(state.copyWith(noneOrError: some(error))); - }, - didReceiveGroups: (List groups) { - emit( - state.copyWith( - groupIds: groups.map((group) => group.groupId).toList(), - ), - ); - }, - ); - }, - ); - } - - void _groupItemStartEditing(GroupPB group, RowPB row, bool isEdit) { - final fieldContext = fieldController.getField(group.fieldId); - if (fieldContext == null) { - Log.warn("FieldContext should not be null"); - return; - } - - boardController.enableGroupDragging(!isEdit); - // boardController.updateGroupItem( - // group.groupId, - // GroupItem( - // row: row, - // fieldContext: fieldContext, - // isDraggable: !isEdit, - // ), - // ); - } - - void _moveRow(RowPB? fromRow, String columnId, RowPB? toRow) { - if (fromRow != null) { - _rowService - .moveGroupRow( - fromRowId: fromRow.id, - toGroupId: columnId, - toRowId: toRow?.id, - ) - .then((result) { - result.fold((l) => null, (r) => add(BoardEvent.didReceiveError(r))); - }); - } - } - - void _moveGroup(String fromGroupId, String toGroupId) { - _rowService - .moveGroup( - fromGroupId: fromGroupId, - toGroupId: toGroupId, - ) - .then((result) { - result.fold((l) => null, (r) => add(BoardEvent.didReceiveError(r))); - }); - } - - @override - Future close() async { - await _gridDataController.dispose(); - for (final controller in groupControllers.values) { - controller.dispose(); - } - return super.close(); - } - - void initializeGroups(List groupsData) { - for (var controller in groupControllers.values) { - controller.dispose(); - } - groupControllers.clear(); - boardController.clear(); - - // - List groups = groupsData - .where((group) => fieldController.getField(group.fieldId) != null) - .map((group) { - return AppFlowyGroupData( - id: group.groupId, - name: group.desc, - items: _buildGroupItems(group), - customData: GroupData( - group: group, - fieldContext: fieldController.getField(group.fieldId)!, - ), - ); - }).toList(); - boardController.addGroups(groups); - - for (final group in groupsData) { - final delegate = GroupControllerDelegateImpl( - controller: boardController, - fieldController: fieldController, - onNewColumnItem: (groupId, row, index) { - add(BoardEvent.didCreateRow(group, row, index)); - }, - ); - final controller = GroupController( - gridId: state.gridId, - group: group, - delegate: delegate, - ); - controller.startListening(); - groupControllers[controller.group.groupId] = (controller); - } - } - - GridRowCache? getRowCache(String blockId) { - final GridBlockCache? blockCache = _gridDataController.blocks[blockId]; - return blockCache?.rowCache; - } - - void _startListening() { - _gridDataController.addListener( - onGridChanged: (grid) { - if (!isClosed) { - add(BoardEvent.didReceiveGridUpdate(grid)); - } - }, - didLoadGroups: (groups) { - if (isClosed) return; - initializeGroups(groups); - add(BoardEvent.didReceiveGroups(groups)); - }, - onDeletedGroup: (groupIds) { - if (isClosed) return; - // - }, - onInsertedGroup: (insertedGroups) { - if (isClosed) return; - // - }, - onUpdatedGroup: (updatedGroups) { - if (isClosed) return; - for (final group in updatedGroups) { - final columnController = - boardController.getGroupController(group.groupId); - columnController?.updateGroupName(group.desc); - } - }, - onError: (err) { - Log.error(err); - }, - onResetGroups: (groups) { - if (isClosed) return; - - initializeGroups(groups); - add(BoardEvent.didReceiveGroups(groups)); - }, - ); - } - - List _buildGroupItems(GroupPB group) { - final items = group.rows.map((row) { - final fieldContext = fieldController.getField(group.fieldId); - return GroupItem( - row: row, - fieldContext: fieldContext!, - ); - }).toList(); - - return [...items]; - } - - Future _openGrid(Emitter emit) async { - final result = await _gridDataController.openGrid(); - result.fold( - (grid) => emit( - state.copyWith(loadingState: GridLoadingState.finish(left(unit))), - ), - (err) => emit( - state.copyWith(loadingState: GridLoadingState.finish(right(err))), - ), - ); - } -} - -@freezed -class BoardEvent with _$BoardEvent { - const factory BoardEvent.initial() = _InitialBoard; - const factory BoardEvent.createBottomRow(String groupId) = _CreateBottomRow; - const factory BoardEvent.createHeaderRow(String groupId) = _CreateHeaderRow; - const factory BoardEvent.didCreateRow( - GroupPB group, - RowPB row, - int? index, - ) = _DidCreateRow; - const factory BoardEvent.startEditingRow( - GroupPB group, - RowPB row, - ) = _StartEditRow; - const factory BoardEvent.endEditingRow(String rowId) = _EndEditRow; - const factory BoardEvent.didReceiveError(FlowyError error) = _DidReceiveError; - const factory BoardEvent.didReceiveGridUpdate( - GridPB grid, - ) = _DidReceiveGridUpdate; - const factory BoardEvent.didReceiveGroups(List groups) = - _DidReceiveGroups; -} - -@freezed -class BoardState with _$BoardState { - const factory BoardState({ - required String gridId, - required Option grid, - required List groupIds, - required Option editingRow, - required GridLoadingState loadingState, - required Option noneOrError, - }) = _BoardState; - - factory BoardState.initial(String gridId) => BoardState( - grid: none(), - gridId: gridId, - groupIds: [], - editingRow: none(), - noneOrError: none(), - loadingState: const _Loading(), - ); -} - -@freezed -class GridLoadingState with _$GridLoadingState { - const factory GridLoadingState.loading() = _Loading; - const factory GridLoadingState.finish( - Either successOrFail) = _Finish; -} - -class GridFieldEquatable extends Equatable { - final UnmodifiableListView _fields; - const GridFieldEquatable( - UnmodifiableListView fields, - ) : _fields = fields; - - @override - List get props { - if (_fields.isEmpty) { - return []; - } - - return [ - _fields.length, - _fields - .map((field) => field.width) - .reduce((value, element) => value + element), - ]; - } - - UnmodifiableListView get value => UnmodifiableListView(_fields); -} - -class GroupItem extends AppFlowyGroupItem { - final RowPB row; - final GridFieldContext fieldContext; - - GroupItem({ - required this.row, - required this.fieldContext, - bool draggable = true, - }) { - super.draggable = draggable; - } - - @override - String get id => row.id; -} - -class GroupControllerDelegateImpl extends GroupControllerDelegate { - final GridFieldController fieldController; - final AppFlowyBoardController controller; - final void Function(String, RowPB, int?) onNewColumnItem; - - GroupControllerDelegateImpl({ - required this.controller, - required this.fieldController, - required this.onNewColumnItem, - }); - - @override - void insertRow(GroupPB group, RowPB row, int? index) { - final fieldContext = fieldController.getField(group.fieldId); - if (fieldContext == null) { - Log.warn("FieldContext should not be null"); - return; - } - - if (index != null) { - final item = GroupItem( - row: row, - fieldContext: fieldContext, - ); - controller.insertGroupItem(group.groupId, index, item); - } else { - final item = GroupItem( - row: row, - fieldContext: fieldContext, - ); - controller.addGroupItem(group.groupId, item); - } - } - - @override - void removeRow(GroupPB group, String rowId) { - controller.removeGroupItem(group.groupId, rowId); - } - - @override - void updateRow(GroupPB group, RowPB row) { - final fieldContext = fieldController.getField(group.fieldId); - if (fieldContext == null) { - Log.warn("FieldContext should not be null"); - return; - } - controller.updateGroupItem( - group.groupId, - GroupItem( - row: row, - fieldContext: fieldContext, - ), - ); - } - - @override - void addNewRow(GroupPB group, RowPB row, int? index) { - final fieldContext = fieldController.getField(group.fieldId); - if (fieldContext == null) { - Log.warn("FieldContext should not be null"); - return; - } - final item = GroupItem( - row: row, - fieldContext: fieldContext, - draggable: false, - ); - - if (index != null) { - controller.insertGroupItem(group.groupId, index, item); - } else { - controller.addGroupItem(group.groupId, item); - } - onNewColumnItem(group.groupId, row, index); - } -} - -class BoardEditingRow { - GroupPB group; - RowPB row; - int? index; - - BoardEditingRow({ - required this.group, - required this.row, - required this.index, - }); -} - -class GroupData { - final GroupPB group; - final GridFieldContext fieldContext; - GroupData({ - required this.group, - required this.fieldContext, - }); - - CheckboxGroup? asCheckboxGroup() { - if (fieldType != FieldType.Checkbox) return null; - return CheckboxGroup(group); - } - - FieldType get fieldType => fieldContext.fieldType; -} - -class CheckboxGroup { - final GroupPB group; - - CheckboxGroup(this.group); - -// Hardcode value: "Yes" that equal to the value defined in Rust -// pub const CHECK: &str = "Yes"; - bool get isCheck => group.groupId == "Yes"; -} diff --git a/frontend/app_flowy/lib/plugins/board/application/board_data_controller.dart b/frontend/app_flowy/lib/plugins/board/application/board_data_controller.dart deleted file mode 100644 index 7ca035d3757e4..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/board_data_controller.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'dart:collection'; - -import 'package:app_flowy/plugins/grid/application/block/block_cache.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:app_flowy/plugins/grid/application/grid_service.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'dart:async'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart'; - -import 'board_listener.dart'; - -typedef OnFieldsChanged = void Function(UnmodifiableListView); -typedef OnGridChanged = void Function(GridPB); -typedef DidLoadGroups = void Function(List); -typedef OnUpdatedGroup = void Function(List); -typedef OnDeletedGroup = void Function(List); -typedef OnInsertedGroup = void Function(List); -typedef OnResetGroups = void Function(List); - -typedef OnRowsChanged = void Function( - List, - RowsChangedReason, -); -typedef OnError = void Function(FlowyError); - -class BoardDataController { - final String gridId; - final GridFFIService _gridFFIService; - final GridFieldController fieldController; - final BoardListener _listener; - - // key: the block id - final LinkedHashMap _blocks; - UnmodifiableMapView get blocks => - UnmodifiableMapView(_blocks); - - OnFieldsChanged? _onFieldsChanged; - OnGridChanged? _onGridChanged; - DidLoadGroups? _didLoadGroup; - OnRowsChanged? _onRowsChanged; - OnError? _onError; - - List get rowInfos { - final List rows = []; - for (var block in _blocks.values) { - rows.addAll(block.rows); - } - return rows; - } - - BoardDataController({required ViewPB view}) - : gridId = view.id, - _listener = BoardListener(view.id), - // ignore: prefer_collection_literals - _blocks = LinkedHashMap(), - _gridFFIService = GridFFIService(gridId: view.id), - fieldController = GridFieldController(gridId: view.id); - - void addListener({ - required OnGridChanged onGridChanged, - OnFieldsChanged? onFieldsChanged, - required DidLoadGroups didLoadGroups, - OnRowsChanged? onRowsChanged, - required OnUpdatedGroup onUpdatedGroup, - required OnDeletedGroup onDeletedGroup, - required OnInsertedGroup onInsertedGroup, - required OnResetGroups onResetGroups, - required OnError? onError, - }) { - _onGridChanged = onGridChanged; - _onFieldsChanged = onFieldsChanged; - _didLoadGroup = didLoadGroups; - _onRowsChanged = onRowsChanged; - _onError = onError; - - fieldController.addListener(onFields: (fields) { - _onFieldsChanged?.call(UnmodifiableListView(fields)); - }); - - _listener.start( - onBoardChanged: (result) { - result.fold( - (changeset) { - if (changeset.updateGroups.isNotEmpty) { - onUpdatedGroup.call(changeset.updateGroups); - } - - if (changeset.deletedGroups.isNotEmpty) { - onDeletedGroup.call(changeset.deletedGroups); - } - - if (changeset.insertedGroups.isNotEmpty) { - onInsertedGroup.call(changeset.insertedGroups); - } - }, - (e) => _onError?.call(e), - ); - }, - onGroupByNewField: (result) { - result.fold( - (groups) => onResetGroups(groups), - (e) => _onError?.call(e), - ); - }, - ); - } - - Future> openGrid() async { - final result = await _gridFFIService.openGrid(); - return Future( - () => result.fold( - (grid) async { - _onGridChanged?.call(grid); - final result = await fieldController.loadFields( - fieldIds: grid.fields, - ); - return result.fold( - (l) { - _loadGroups(grid.blocks); - return left(l); - }, - (err) => right(err), - ); - }, - (err) => right(err), - ), - ); - } - - Future> createBoardCard(String groupId, - {String? startRowId}) { - return _gridFFIService.createBoardCard(groupId, startRowId); - } - - Future dispose() async { - await _gridFFIService.closeGrid(); - await fieldController.dispose(); - - for (final blockCache in _blocks.values) { - blockCache.dispose(); - } - } - - Future _loadGroups(List blocks) async { - for (final block in blocks) { - final cache = GridBlockCache( - gridId: gridId, - block: block, - fieldController: fieldController, - ); - - cache.addListener(onRowsChanged: (reason) { - _onRowsChanged?.call(rowInfos, reason); - }); - _blocks[block.id] = cache; - } - - final result = await _gridFFIService.loadGroups(); - return Future( - () => result.fold( - (groups) { - _didLoadGroup?.call(groups.items); - }, - (err) => _onError?.call(err), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/board/application/board_listener.dart b/frontend/app_flowy/lib/plugins/board/application/board_listener.dart deleted file mode 100644 index 9f8662fc5b15c..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/board_listener.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:typed_data'; - -import 'package:app_flowy/core/grid_notification.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/dart_notification.pb.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/group.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/group_changeset.pb.dart'; - -typedef GroupUpdateValue = Either; -typedef GroupByNewFieldValue = Either, FlowyError>; - -class BoardListener { - final String viewId; - PublishNotifier? _groupUpdateNotifier = PublishNotifier(); - PublishNotifier? _groupByNewFieldNotifier = - PublishNotifier(); - GridNotificationListener? _listener; - BoardListener(this.viewId); - - void start({ - required void Function(GroupUpdateValue) onBoardChanged, - required void Function(GroupByNewFieldValue) onGroupByNewField, - }) { - _groupUpdateNotifier?.addPublishListener(onBoardChanged); - _groupByNewFieldNotifier?.addPublishListener(onGroupByNewField); - _listener = GridNotificationListener( - objectId: viewId, - handler: _handler, - ); - } - - void _handler( - GridNotification ty, - Either result, - ) { - switch (ty) { - case GridNotification.DidUpdateGroupView: - result.fold( - (payload) => _groupUpdateNotifier?.value = - left(GroupViewChangesetPB.fromBuffer(payload)), - (error) => _groupUpdateNotifier?.value = right(error), - ); - break; - case GridNotification.DidGroupByNewField: - result.fold( - (payload) => _groupByNewFieldNotifier?.value = - left(GroupViewChangesetPB.fromBuffer(payload).newGroups), - (error) => _groupByNewFieldNotifier?.value = right(error), - ); - break; - default: - break; - } - } - - Future stop() async { - await _listener?.stop(); - _groupUpdateNotifier?.dispose(); - _groupUpdateNotifier = null; - - _groupByNewFieldNotifier?.dispose(); - _groupByNewFieldNotifier = null; - } -} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/board_checkbox_cell_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/board_checkbox_cell_bloc.dart deleted file mode 100644 index c8a0d87f9c371..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/card/board_checkbox_cell_bloc.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; - -part 'board_checkbox_cell_bloc.freezed.dart'; - -class BoardCheckboxCellBloc - extends Bloc { - final GridCheckboxCellController cellController; - void Function()? _onCellChangedFn; - BoardCheckboxCellBloc({ - required this.cellController, - }) : super(BoardCheckboxCellState.initial(cellController)) { - on( - (event, emit) async { - await event.when( - initial: () async { - _startListening(); - }, - didReceiveCellUpdate: (cellData) { - emit(state.copyWith(isSelected: _isSelected(cellData))); - }, - select: () async { - cellController.saveCellData(!state.isSelected ? "Yes" : "No"); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = cellController.startListening( - onCellChanged: ((cellContent) { - if (!isClosed) { - add(BoardCheckboxCellEvent.didReceiveCellUpdate(cellContent ?? "")); - } - }), - ); - } -} - -@freezed -class BoardCheckboxCellEvent with _$BoardCheckboxCellEvent { - const factory BoardCheckboxCellEvent.initial() = _InitialCell; - const factory BoardCheckboxCellEvent.select() = _Selected; - const factory BoardCheckboxCellEvent.didReceiveCellUpdate( - String cellContent) = _DidReceiveCellUpdate; -} - -@freezed -class BoardCheckboxCellState with _$BoardCheckboxCellState { - const factory BoardCheckboxCellState({ - required bool isSelected, - }) = _CheckboxCellState; - - factory BoardCheckboxCellState.initial(GridCellController context) { - return BoardCheckboxCellState( - isSelected: _isSelected(context.getCellData())); - } -} - -bool _isSelected(String? cellData) { - return cellData == "Yes"; -} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/board_date_cell_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/board_date_cell_bloc.dart deleted file mode 100644 index a19d7b64a88fd..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/card/board_date_cell_bloc.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -part 'board_date_cell_bloc.freezed.dart'; - -class BoardDateCellBloc extends Bloc { - final GridDateCellController cellController; - void Function()? _onCellChangedFn; - - BoardDateCellBloc({required this.cellController}) - : super(BoardDateCellState.initial(cellController)) { - on( - (event, emit) async { - event.when( - initial: () => _startListening(), - didReceiveCellUpdate: (DateCellDataPB? cellData) { - emit(state.copyWith( - data: cellData, dateStr: _dateStrFromCellData(cellData))); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = cellController.startListening( - onCellChanged: ((data) { - if (!isClosed) { - add(BoardDateCellEvent.didReceiveCellUpdate(data)); - } - }), - ); - } -} - -@freezed -class BoardDateCellEvent with _$BoardDateCellEvent { - const factory BoardDateCellEvent.initial() = _InitialCell; - const factory BoardDateCellEvent.didReceiveCellUpdate(DateCellDataPB? data) = - _DidReceiveCellUpdate; -} - -@freezed -class BoardDateCellState with _$BoardDateCellState { - const factory BoardDateCellState({ - required DateCellDataPB? data, - required String dateStr, - required GridFieldContext fieldContext, - }) = _BoardDateCellState; - - factory BoardDateCellState.initial(GridDateCellController context) { - final cellData = context.getCellData(); - - return BoardDateCellState( - fieldContext: context.fieldContext, - data: cellData, - dateStr: _dateStrFromCellData(cellData), - ); - } -} - -String _dateStrFromCellData(DateCellDataPB? cellData) { - String dateStr = ""; - if (cellData != null) { - dateStr = "${cellData.date} ${cellData.time}"; - } - return dateStr; -} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/board_number_cell_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/board_number_cell_bloc.dart deleted file mode 100644 index 2cc4882357016..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/card/board_number_cell_bloc.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; - -part 'board_number_cell_bloc.freezed.dart'; - -class BoardNumberCellBloc - extends Bloc { - final GridNumberCellController cellController; - void Function()? _onCellChangedFn; - BoardNumberCellBloc({ - required this.cellController, - }) : super(BoardNumberCellState.initial(cellController)) { - on( - (event, emit) async { - await event.when( - initial: () async { - _startListening(); - }, - didReceiveCellUpdate: (content) { - emit(state.copyWith(content: content)); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = cellController.startListening( - onCellChanged: ((cellContent) { - if (!isClosed) { - add(BoardNumberCellEvent.didReceiveCellUpdate(cellContent ?? "")); - } - }), - ); - } -} - -@freezed -class BoardNumberCellEvent with _$BoardNumberCellEvent { - const factory BoardNumberCellEvent.initial() = _InitialCell; - const factory BoardNumberCellEvent.didReceiveCellUpdate(String cellContent) = - _DidReceiveCellUpdate; -} - -@freezed -class BoardNumberCellState with _$BoardNumberCellState { - const factory BoardNumberCellState({ - required String content, - }) = _BoardNumberCellState; - - factory BoardNumberCellState.initial(GridCellController context) => - BoardNumberCellState( - content: context.getCellData() ?? "", - ); -} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/board_select_option_cell_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/board_select_option_cell_bloc.dart deleted file mode 100644 index daa4dcc3831ea..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/card/board_select_option_cell_bloc.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'dart:async'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; - -part 'board_select_option_cell_bloc.freezed.dart'; - -class BoardSelectOptionCellBloc - extends Bloc { - final GridSelectOptionCellController cellController; - void Function()? _onCellChangedFn; - - BoardSelectOptionCellBloc({ - required this.cellController, - }) : super(BoardSelectOptionCellState.initial(cellController)) { - on( - (event, emit) async { - await event.when( - initial: () async { - _startListening(); - }, - didReceiveOptions: (List selectedOptions) { - emit(state.copyWith(selectedOptions: selectedOptions)); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = cellController.startListening( - onCellChanged: ((selectOptionContext) { - if (!isClosed) { - add(BoardSelectOptionCellEvent.didReceiveOptions( - selectOptionContext?.selectOptions ?? [], - )); - } - }), - ); - } -} - -@freezed -class BoardSelectOptionCellEvent with _$BoardSelectOptionCellEvent { - const factory BoardSelectOptionCellEvent.initial() = _InitialCell; - const factory BoardSelectOptionCellEvent.didReceiveOptions( - List selectedOptions, - ) = _DidReceiveOptions; -} - -@freezed -class BoardSelectOptionCellState with _$BoardSelectOptionCellState { - const factory BoardSelectOptionCellState({ - required List selectedOptions, - }) = _BoardSelectOptionCellState; - - factory BoardSelectOptionCellState.initial( - GridSelectOptionCellController context) { - final data = context.getCellData(); - return BoardSelectOptionCellState( - selectedOptions: data?.selectOptions ?? [], - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/board_text_cell_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/board_text_cell_bloc.dart deleted file mode 100644 index 9d1b14c605b40..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/card/board_text_cell_bloc.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; - -part 'board_text_cell_bloc.freezed.dart'; - -class BoardTextCellBloc extends Bloc { - final GridCellController cellController; - void Function()? _onCellChangedFn; - BoardTextCellBloc({ - required this.cellController, - }) : super(BoardTextCellState.initial(cellController)) { - on( - (event, emit) async { - await event.when( - initial: () async { - _startListening(); - }, - didReceiveCellUpdate: (content) { - emit(state.copyWith(content: content)); - }, - updateText: (text) { - if (text != state.content) { - cellController.saveCellData(text); - emit(state.copyWith(content: text)); - } - }, - enableEdit: (bool enabled) { - emit(state.copyWith(enableEdit: enabled)); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = cellController.startListening( - onCellChanged: ((cellContent) { - if (!isClosed) { - add(BoardTextCellEvent.didReceiveCellUpdate(cellContent ?? "")); - } - }), - ); - } -} - -@freezed -class BoardTextCellEvent with _$BoardTextCellEvent { - const factory BoardTextCellEvent.initial() = _InitialCell; - const factory BoardTextCellEvent.updateText(String text) = _UpdateContent; - const factory BoardTextCellEvent.enableEdit(bool enabled) = _EnableEdit; - const factory BoardTextCellEvent.didReceiveCellUpdate(String cellContent) = - _DidReceiveCellUpdate; -} - -@freezed -class BoardTextCellState with _$BoardTextCellState { - const factory BoardTextCellState({ - required String content, - required bool enableEdit, - }) = _BoardTextCellState; - - factory BoardTextCellState.initial(GridCellController context) => - BoardTextCellState( - content: context.getCellData() ?? "", - enableEdit: false, - ); -} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/board_url_cell_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/board_url_cell_bloc.dart deleted file mode 100644 index 045a1633fa0cc..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/card/board_url_cell_bloc.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/url_type_option_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; - -part 'board_url_cell_bloc.freezed.dart'; - -class BoardURLCellBloc extends Bloc { - final GridURLCellController cellController; - void Function()? _onCellChangedFn; - BoardURLCellBloc({ - required this.cellController, - }) : super(BoardURLCellState.initial(cellController)) { - on( - (event, emit) async { - event.when( - initial: () { - _startListening(); - }, - didReceiveCellUpdate: (cellData) { - emit(state.copyWith( - content: cellData?.content ?? "", - url: cellData?.url ?? "", - )); - }, - updateURL: (String url) { - cellController.saveCellData(url, deduplicate: true); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = cellController.startListening( - onCellChanged: ((cellData) { - if (!isClosed) { - add(BoardURLCellEvent.didReceiveCellUpdate(cellData)); - } - }), - ); - } -} - -@freezed -class BoardURLCellEvent with _$BoardURLCellEvent { - const factory BoardURLCellEvent.initial() = _InitialCell; - const factory BoardURLCellEvent.updateURL(String url) = _UpdateURL; - const factory BoardURLCellEvent.didReceiveCellUpdate(URLCellDataPB? cell) = - _DidReceiveCellUpdate; -} - -@freezed -class BoardURLCellState with _$BoardURLCellState { - const factory BoardURLCellState({ - required String content, - required String url, - }) = _BoardURLCellState; - - factory BoardURLCellState.initial(GridURLCellController context) { - final cellData = context.getCellData(); - return BoardURLCellState( - content: cellData?.content ?? "", - url: cellData?.url ?? "", - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/card_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/card_bloc.dart deleted file mode 100644 index 82372896d1062..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/card/card_bloc.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'dart:collection'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_service.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'card_data_controller.dart'; - -part 'card_bloc.freezed.dart'; - -class BoardCardBloc extends Bloc { - final String groupFieldId; - final RowFFIService _rowService; - final CardDataController _dataController; - - BoardCardBloc({ - required this.groupFieldId, - required String gridId, - required CardDataController dataController, - required bool isEditing, - }) : _rowService = RowFFIService( - gridId: gridId, - blockId: dataController.rowPB.blockId, - ), - _dataController = dataController, - super( - BoardCardState.initial( - dataController.rowPB, - _makeCells(groupFieldId, dataController.loadData()), - isEditing, - ), - ) { - on( - (event, emit) async { - await event.when( - initial: () async { - await _startListening(); - }, - didReceiveCells: (cells, reason) async { - emit(state.copyWith( - cells: cells, - changeReason: reason, - )); - }, - setIsEditing: (bool isEditing) { - emit(state.copyWith(isEditing: isEditing)); - }, - ); - }, - ); - } - - @override - Future close() async { - _dataController.dispose(); - return super.close(); - } - - RowInfo rowInfo() { - return RowInfo( - gridId: _rowService.gridId, - fields: UnmodifiableListView( - state.cells.map((cell) => cell.identifier.fieldContext).toList(), - ), - rowPB: state.rowPB, - ); - } - - Future _startListening() async { - _dataController.addListener( - onRowChanged: (cellMap, reason) { - if (!isClosed) { - final cells = _makeCells(groupFieldId, cellMap); - add(BoardCardEvent.didReceiveCells(cells, reason)); - } - }, - ); - } -} - -List _makeCells( - String groupFieldId, GridCellMap originalCellMap) { - List cells = []; - for (final entry in originalCellMap.entries) { - // Filter out the cell if it's fieldId equal to the groupFieldId - if (entry.value.fieldId != groupFieldId) { - cells.add(BoardCellEquatable(entry.value)); - } - } - return cells; -} - -@freezed -class BoardCardEvent with _$BoardCardEvent { - const factory BoardCardEvent.initial() = _InitialRow; - const factory BoardCardEvent.setIsEditing(bool isEditing) = _IsEditing; - const factory BoardCardEvent.didReceiveCells( - List cells, - RowsChangedReason reason, - ) = _DidReceiveCells; -} - -@freezed -class BoardCardState with _$BoardCardState { - const factory BoardCardState({ - required RowPB rowPB, - required List cells, - required bool isEditing, - RowsChangedReason? changeReason, - }) = _BoardCardState; - - factory BoardCardState.initial( - RowPB rowPB, - List cells, - bool isEditing, - ) => - BoardCardState( - rowPB: rowPB, - cells: cells, - isEditing: isEditing, - ); -} - -class BoardCellEquatable extends Equatable { - final GridCellIdentifier identifier; - - const BoardCellEquatable(this.identifier); - - @override - List get props { - return [ - identifier.fieldContext.id, - identifier.fieldContext.fieldType, - identifier.fieldContext.visibility, - identifier.fieldContext.width, - ]; - } -} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/card_data_controller.dart b/frontend/app_flowy/lib/plugins/board/application/card/card_data_controller.dart deleted file mode 100644 index 0e7cf6c153c7e..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/card/card_data_controller.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:app_flowy/plugins/board/presentation/card/card_cell_builder.dart'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_field_notifier.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; -import 'package:flutter/foundation.dart'; - -typedef OnCardChanged = void Function(GridCellMap, RowsChangedReason); - -class CardDataController extends BoardCellBuilderDelegate { - final RowPB rowPB; - final GridFieldController _fieldController; - final GridRowCache _rowCache; - final List _onCardChangedListeners = []; - - CardDataController({ - required this.rowPB, - required GridFieldController fieldController, - required GridRowCache rowCache, - }) : _fieldController = fieldController, - _rowCache = rowCache; - - GridCellMap loadData() { - return _rowCache.loadGridCells(rowPB.id); - } - - void addListener({OnCardChanged? onRowChanged}) { - _onCardChangedListeners.add(_rowCache.addListener( - rowId: rowPB.id, - onCellUpdated: onRowChanged, - )); - } - - void dispose() { - for (final fn in _onCardChangedListeners) { - _rowCache.removeRowListener(fn); - } - } - - @override - GridCellFieldNotifier buildFieldNotifier() { - return GridCellFieldNotifier( - notifier: GridCellFieldNotifierImpl(_fieldController)); - } - - @override - GridCellCache get cellCache => _rowCache.cellCache; -} diff --git a/frontend/app_flowy/lib/plugins/board/application/group.dart b/frontend/app_flowy/lib/plugins/board/application/group.dart deleted file mode 100644 index 1e59350826aee..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/group.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; - -class BoardGroupService { - final String gridId; - FieldPB? groupField; - - BoardGroupService(this.gridId); - - void setGroupField(FieldPB field) { - groupField = field; - } -} - -abstract class CanBeGroupField { - String get groupContent; -} - -// class SingleSelectGroup extends CanBeGroupField { -// final SingleSelectTypeOptionContext typeOptionContext; -// } diff --git a/frontend/app_flowy/lib/plugins/board/application/group_controller.dart b/frontend/app_flowy/lib/plugins/board/application/group_controller.dart deleted file mode 100644 index 6a148c0312694..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/group_controller.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart'; -import 'group_listener.dart'; - -typedef OnGroupError = void Function(FlowyError); - -abstract class GroupControllerDelegate { - void removeRow(GroupPB group, String rowId); - void insertRow(GroupPB group, RowPB row, int? index); - void updateRow(GroupPB group, RowPB row); - void addNewRow(GroupPB group, RowPB row, int? index); -} - -class GroupController { - final GroupPB group; - final GroupListener _listener; - final GroupControllerDelegate delegate; - - GroupController({ - required String gridId, - required this.group, - required this.delegate, - }) : _listener = GroupListener(group); - - RowPB? rowAtIndex(int index) { - if (index < group.rows.length) { - return group.rows[index]; - } else { - return null; - } - } - - RowPB? lastRow() { - if (group.rows.isEmpty) return null; - return group.rows.last; - } - - void startListening() { - _listener.start(onGroupChanged: (result) { - result.fold( - (GroupChangesetPB changeset) { - for (final deletedRow in changeset.deletedRows) { - group.rows.removeWhere((rowPB) => rowPB.id == deletedRow); - delegate.removeRow(group, deletedRow); - } - - for (final insertedRow in changeset.insertedRows) { - final index = insertedRow.hasIndex() ? insertedRow.index : null; - if (insertedRow.hasIndex() && - group.rows.length > insertedRow.index) { - group.rows.insert(insertedRow.index, insertedRow.row); - } else { - group.rows.add(insertedRow.row); - } - - if (insertedRow.isNew) { - delegate.addNewRow(group, insertedRow.row, index); - } else { - delegate.insertRow(group, insertedRow.row, index); - } - } - - for (final updatedRow in changeset.updatedRows) { - final index = group.rows.indexWhere( - (rowPB) => rowPB.id == updatedRow.id, - ); - - if (index != -1) { - group.rows[index] = updatedRow; - } - - delegate.updateRow(group, updatedRow); - } - }, - (err) => Log.error(err), - ); - }); - } - - // GroupChangesetPB _transformChangeset(GroupChangesetPB changeset) { - // final insertedRows = changeset.insertedRows - // .where( - // (delete) => !changeset.deletedRows.contains(delete.row.id), - // ) - // .toList(); - - // final deletedRows = changeset.deletedRows - // .where((deletedRowId) => - // changeset.insertedRows - // .indexWhere((insert) => insert.row.id == deletedRowId) == - // -1) - // .toList(); - - // return changeset.rebuild((rebuildChangeset) { - // rebuildChangeset.insertedRows.clear(); - // rebuildChangeset.insertedRows.addAll(insertedRows); - - // rebuildChangeset.deletedRows.clear(); - // rebuildChangeset.deletedRows.addAll(deletedRows); - // }); - // } - - Future dispose() async { - _listener.stop(); - } -} diff --git a/frontend/app_flowy/lib/plugins/board/application/group_listener.dart b/frontend/app_flowy/lib/plugins/board/application/group_listener.dart deleted file mode 100644 index e3b626af07ca1..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/group_listener.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'dart:typed_data'; - -import 'package:app_flowy/core/grid_notification.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/dart_notification.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/group.pb.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/group_changeset.pb.dart'; - -typedef UpdateGroupNotifiedValue = Either; - -class GroupListener { - final GroupPB group; - PublishNotifier? _groupNotifier = PublishNotifier(); - GridNotificationListener? _listener; - GroupListener(this.group); - - void start({ - required void Function(UpdateGroupNotifiedValue) onGroupChanged, - }) { - _groupNotifier?.addPublishListener(onGroupChanged); - _listener = GridNotificationListener( - objectId: group.groupId, - handler: _handler, - ); - } - - void _handler( - GridNotification ty, - Either result, - ) { - switch (ty) { - case GridNotification.DidUpdateGroup: - result.fold( - (payload) => _groupNotifier?.value = - left(GroupChangesetPB.fromBuffer(payload)), - (error) => _groupNotifier?.value = right(error), - ); - break; - default: - break; - } - } - - Future stop() async { - await _listener?.stop(); - _groupNotifier?.dispose(); - _groupNotifier = null; - } -} diff --git a/frontend/app_flowy/lib/plugins/board/application/toolbar/board_setting_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/toolbar/board_setting_bloc.dart deleted file mode 100644 index 97185c5efe957..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/application/toolbar/board_setting_bloc.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'package:dartz/dartz.dart'; - -part 'board_setting_bloc.freezed.dart'; - -class BoardSettingBloc extends Bloc { - final String gridId; - BoardSettingBloc({required this.gridId}) - : super(BoardSettingState.initial()) { - on( - (event, emit) async { - event.when(performAction: (action) { - emit(state.copyWith(selectedAction: Some(action))); - }); - }, - ); - } - - @override - Future close() async { - return super.close(); - } -} - -@freezed -class BoardSettingEvent with _$BoardSettingEvent { - const factory BoardSettingEvent.performAction(BoardSettingAction action) = - _PerformAction; -} - -@freezed -class BoardSettingState with _$BoardSettingState { - const factory BoardSettingState({ - required Option selectedAction, - }) = _BoardSettingState; - - factory BoardSettingState.initial() => BoardSettingState( - selectedAction: none(), - ); -} - -enum BoardSettingAction { - properties, - groups, -} diff --git a/frontend/app_flowy/lib/plugins/board/board.dart b/frontend/app_flowy/lib/plugins/board/board.dart deleted file mode 100644 index 564dffd7c79d6..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/board.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:app_flowy/plugins/util.dart'; -import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; -import 'package:app_flowy/workspace/presentation/widgets/left_bar_item.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:flutter/material.dart'; - -import 'presentation/board_page.dart'; - -class BoardPluginBuilder implements PluginBuilder { - @override - Plugin build(dynamic data) { - if (data is ViewPB) { - return BoardPlugin(pluginType: pluginType, view: data); - } else { - throw FlowyPluginException.invalidData; - } - } - - @override - String get menuName => "Board"; - - @override - PluginType get pluginType => PluginType.board; - - @override - ViewDataFormatPB get dataFormatType => ViewDataFormatPB.DatabaseFormat; - - @override - ViewLayoutTypePB? get layoutType => ViewLayoutTypePB.Board; -} - -class BoardPluginConfig implements PluginConfig { - @override - bool get creatable => true; -} - -class BoardPlugin extends Plugin { - @override - final ViewPluginNotifier notifier; - final PluginType _pluginType; - - BoardPlugin({ - required ViewPB view, - required PluginType pluginType, - }) : _pluginType = pluginType, - notifier = ViewPluginNotifier(view: view); - - @override - PluginDisplay get display => GridPluginDisplay(notifier: notifier); - - @override - PluginId get id => notifier.view.id; - - @override - PluginType get ty => _pluginType; -} - -class GridPluginDisplay extends PluginDisplay { - final ViewPluginNotifier notifier; - GridPluginDisplay({required this.notifier, Key? key}); - - ViewPB get view => notifier.view; - - @override - Widget get leftBarItem => ViewLeftBarItem(view: view); - - @override - Widget buildWidget(PluginContext context) { - notifier.isDeleted.addListener(() { - notifier.isDeleted.value.fold(() => null, (deletedView) { - if (deletedView.hasIndex()) { - context.onDeleted(view, deletedView.index); - } - }); - }); - - return BoardPage(key: ValueKey(view.id), view: view); - } - - @override - List get navigationItems => [this]; -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/board_page.dart b/frontend/app_flowy/lib/plugins/board/presentation/board_page.dart deleted file mode 100644 index a7f0d9055722b..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/board_page.dart +++ /dev/null @@ -1,388 +0,0 @@ -// ignore_for_file: unused_field - -import 'dart:collection'; - -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/plugins/board/application/card/card_data_controller.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_data_controller.dart'; -import 'package:app_flowy/plugins/grid/presentation/widgets/cell/cell_builder.dart'; -import 'package:app_flowy/plugins/grid/presentation/widgets/row/row_detail.dart'; -import 'package:app_flowy/workspace/application/appearance.dart'; -import 'package:appflowy_board/appflowy_board.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:provider/provider.dart'; -import '../../grid/application/row/row_cache.dart'; -import '../application/board_bloc.dart'; -import 'card/card.dart'; -import 'card/card_cell_builder.dart'; -import 'toolbar/board_toolbar.dart'; - -class BoardPage extends StatelessWidget { - final ViewPB view; - BoardPage({ - required this.view, - Key? key, - }) : super(key: ValueKey(view.id)); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - BoardBloc(view: view)..add(const BoardEvent.initial()), - child: BlocBuilder( - buildWhen: (p, c) => p.loadingState != c.loadingState, - builder: (context, state) { - return state.loadingState.map( - loading: (_) => - const Center(child: CircularProgressIndicator.adaptive()), - finish: (result) { - return result.successOrFail.fold( - (_) => const BoardContent(), - (err) => FlowyErrorPage(err.toString()), - ); - }, - ); - }, - ), - ); - } -} - -class BoardContent extends StatefulWidget { - const BoardContent({Key? key}) : super(key: key); - - @override - State createState() => _BoardContentState(); -} - -class _BoardContentState extends State { - late AppFlowyBoardScrollController scrollManager; - - final config = AppFlowyBoardConfig( - groupBackgroundColor: HexColor.fromHex('#F7F8FC'), - ); - - @override - void initState() { - scrollManager = AppFlowyBoardScrollController(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) => _handleEditStateChanged(state, context), - child: BlocBuilder( - buildWhen: (previous, current) => previous.groupIds != current.groupIds, - builder: (context, state) { - final column = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [const _ToolbarBlocAdaptor(), _buildBoard(context)], - ); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: column, - ); - }, - ), - ); - } - - Widget _buildBoard(BuildContext context) { - return ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), - child: Selector( - selector: (ctx, notifier) => notifier.theme, - builder: (ctx, theme, child) => Expanded( - child: AppFlowyBoard( - boardScrollController: scrollManager, - scrollController: ScrollController(), - controller: context.read().boardController, - headerBuilder: _buildHeader, - footerBuilder: _buildFooter, - cardBuilder: (_, column, columnItem) => _buildCard( - context, - column, - columnItem, - ), - groupConstraints: const BoxConstraints.tightFor(width: 300), - config: AppFlowyBoardConfig( - groupBackgroundColor: theme.bg1, - ), - ), - ), - ), - ); - } - - void _handleEditStateChanged(BoardState state, BuildContext context) { - state.editingRow.fold( - () => null, - (editingRow) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (editingRow.index != null) { - } else { - scrollManager.scrollToBottom(editingRow.group.groupId); - } - }); - }, - ); - } - - @override - void dispose() { - super.dispose(); - } - - Widget _buildHeader( - BuildContext context, - AppFlowyGroupData groupData, - ) { - final boardCustomData = groupData.customData as GroupData; - return AppFlowyGroupHeader( - title: Flexible( - fit: FlexFit.tight, - child: FlowyText.medium( - groupData.headerData.groupName, - fontSize: 14, - overflow: TextOverflow.clip, - color: context.read().textColor, - ), - ), - icon: _buildHeaderIcon(boardCustomData), - addIcon: SizedBox( - height: 20, - width: 20, - child: svgWidget( - "home/add", - color: context.read().iconColor, - ), - ), - onAddButtonClick: () { - context.read().add( - BoardEvent.createHeaderRow(groupData.id), - ); - }, - height: 50, - margin: config.headerPadding, - ); - } - - Widget _buildFooter(BuildContext context, AppFlowyGroupData columnData) { - // final boardCustomData = columnData.customData as BoardCustomData; - // final group = boardCustomData.group; - - return AppFlowyGroupFooter( - icon: SizedBox( - height: 20, - width: 20, - child: svgWidget( - "home/add", - color: context.read().iconColor, - ), - ), - title: FlowyText.medium( - LocaleKeys.board_column_create_new_card.tr(), - fontSize: 14, - color: context.read().textColor, - ), - height: 50, - margin: config.footerPadding, - onAddButtonClick: () { - context.read().add( - BoardEvent.createBottomRow(columnData.id), - ); - }, - ); - } - - Widget _buildCard( - BuildContext context, - AppFlowyGroupData afGroupData, - AppFlowyGroupItem afGroupItem, - ) { - final groupItem = afGroupItem as GroupItem; - final groupData = afGroupData.customData as GroupData; - final rowPB = groupItem.row; - final rowCache = context.read().getRowCache(rowPB.blockId); - - /// Return placeholder widget if the rowCache is null. - if (rowCache == null) return SizedBox(key: ObjectKey(groupItem)); - - final fieldController = context.read().fieldController; - final gridId = context.read().gridId; - final cardController = CardDataController( - fieldController: fieldController, - rowCache: rowCache, - rowPB: rowPB, - ); - - final cellBuilder = BoardCellBuilder(cardController); - bool isEditing = false; - context.read().state.editingRow.fold( - () => null, - (editingRow) { - isEditing = editingRow.row.id == groupItem.row.id; - }, - ); - - final groupItemId = groupItem.row.id + groupData.group.groupId; - return AppFlowyGroupCard( - key: ValueKey(groupItemId), - margin: config.cardPadding, - decoration: _makeBoxDecoration(context), - child: BoardCard( - gridId: gridId, - groupId: groupData.group.groupId, - fieldId: groupItem.fieldContext.id, - isEditing: isEditing, - cellBuilder: cellBuilder, - dataController: cardController, - openCard: (context) => _openCard( - gridId, - fieldController, - rowPB, - rowCache, - context, - ), - onStartEditing: () { - context.read().add( - BoardEvent.startEditingRow( - groupData.group, - groupItem.row, - ), - ); - }, - onEndEditing: () { - context - .read() - .add(BoardEvent.endEditingRow(groupItem.row.id)); - }, - ), - ); - } - - BoxDecoration _makeBoxDecoration(BuildContext context) { - final theme = context.read(); - final borderSide = BorderSide(color: theme.shader6, width: 1.0); - return BoxDecoration( - color: theme.surface, - border: Border.fromBorderSide(borderSide), - borderRadius: const BorderRadius.all(Radius.circular(6)), - ); - } - - void _openCard( - String gridId, - GridFieldController fieldController, - RowPB rowPB, - GridRowCache rowCache, - BuildContext context, - ) { - final rowInfo = RowInfo( - gridId: gridId, - fields: UnmodifiableListView(fieldController.fieldContexts), - rowPB: rowPB, - ); - - final dataController = GridRowDataController( - rowInfo: rowInfo, - fieldController: fieldController, - rowCache: rowCache, - ); - - FlowyOverlay.show( - context: context, - builder: (BuildContext context) { - return RowDetailPage( - cellBuilder: GridCellBuilder(delegate: dataController), - dataController: dataController, - ); - }, - ); - } -} - -class _ToolbarBlocAdaptor extends StatelessWidget { - const _ToolbarBlocAdaptor({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final bloc = context.read(); - final toolbarContext = BoardToolbarContext( - viewId: bloc.gridId, - fieldController: bloc.fieldController, - ); - - return ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), - child: Selector( - selector: (ctx, notifier) => notifier.theme, - builder: (ctx, theme, child) { - return BoardToolbar(toolbarContext: toolbarContext); - }, - ), - ); - }, - ); - } -} - -extension HexColor on Color { - static Color fromHex(String hexString) { - final buffer = StringBuffer(); - if (hexString.length == 6 || hexString.length == 7) buffer.write('ff'); - buffer.write(hexString.replaceFirst('#', '')); - return Color(int.parse(buffer.toString(), radix: 16)); - } -} - -Widget? _buildHeaderIcon(GroupData customData) { - Widget? widget; - switch (customData.fieldType) { - case FieldType.Checkbox: - final group = customData.asCheckboxGroup()!; - if (group.isCheck) { - widget = svgWidget('editor/editor_check'); - } else { - widget = svgWidget('editor/editor_uncheck'); - } - break; - case FieldType.DateTime: - break; - case FieldType.MultiSelect: - break; - case FieldType.Number: - break; - case FieldType.RichText: - break; - case FieldType.SingleSelect: - break; - case FieldType.URL: - break; - } - - if (widget != null) { - widget = SizedBox( - width: 20, - height: 20, - child: widget, - ); - } - return widget; -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_cell.dart deleted file mode 100644 index 549f7ba64c9d3..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/board_cell.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import 'package:flutter/material.dart'; - -abstract class FocusableBoardCell { - set becomeFocus(bool isFocus); -} - -class EditableCellNotifier { - final ValueNotifier isCellEditing; - - EditableCellNotifier({bool isEditing = false}) - : isCellEditing = ValueNotifier(isEditing); - - void dispose() { - isCellEditing.dispose(); - } -} - -class EditableRowNotifier { - final Map _cells = {}; - final ValueNotifier isEditing; - - EditableRowNotifier({required bool isEditing}) - : isEditing = ValueNotifier(isEditing); - - void bindCell( - GridCellIdentifier cellIdentifier, - EditableCellNotifier notifier, - ) { - assert( - _cells.values.isEmpty, - 'Only one cell can receive the notification', - ); - final id = EditableCellId.from(cellIdentifier); - _cells[id]?.dispose(); - - notifier.isCellEditing.addListener(() { - isEditing.value = notifier.isCellEditing.value; - }); - - _cells[EditableCellId.from(cellIdentifier)] = notifier; - } - - void becomeFirstResponder() { - if (_cells.values.isEmpty) return; - assert( - _cells.values.length == 1, - 'Only one cell can receive the notification', - ); - _cells.values.first.isCellEditing.value = true; - } - - void resignFirstResponder() { - if (_cells.values.isEmpty) return; - assert( - _cells.values.length == 1, - 'Only one cell can receive the notification', - ); - _cells.values.first.isCellEditing.value = false; - } - - void unbind() { - for (final notifier in _cells.values) { - notifier.dispose(); - } - _cells.clear(); - } - - void dispose() { - for (final notifier in _cells.values) { - notifier.dispose(); - } - - _cells.clear(); - } -} - -abstract class EditableCell { - // Each cell notifier will be bind to the [EditableRowNotifier], which enable - // the row notifier receive its cells event. For example: begin editing the - // cell or end editing the cell. - // - EditableCellNotifier? get editableNotifier; -} - -class EditableCellId { - String fieldId; - String rowId; - - EditableCellId(this.rowId, this.fieldId); - - factory EditableCellId.from(GridCellIdentifier cellIdentifier) => - EditableCellId( - cellIdentifier.rowId, - cellIdentifier.fieldId, - ); -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_checkbox_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_checkbox_cell.dart deleted file mode 100644 index 6b94d3399b3cf..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/board_checkbox_cell.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:app_flowy/plugins/board/application/card/board_checkbox_cell_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class BoardCheckboxCell extends StatefulWidget { - final String groupId; - final GridCellControllerBuilder cellControllerBuilder; - - const BoardCheckboxCell({ - required this.groupId, - required this.cellControllerBuilder, - Key? key, - }) : super(key: key); - - @override - State createState() => _BoardCheckboxCellState(); -} - -class _BoardCheckboxCellState extends State { - late BoardCheckboxCellBloc _cellBloc; - - @override - void initState() { - final cellController = - widget.cellControllerBuilder.build() as GridCheckboxCellController; - _cellBloc = BoardCheckboxCellBloc(cellController: cellController); - _cellBloc.add(const BoardCheckboxCellEvent.initial()); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cellBloc, - child: BlocBuilder( - buildWhen: (previous, current) => - previous.isSelected != current.isSelected, - builder: (context, state) { - final icon = state.isSelected - ? svgWidget('editor/editor_check') - : svgWidget('editor/editor_uncheck'); - return Align( - alignment: Alignment.centerLeft, - child: FlowyIconButton( - iconPadding: EdgeInsets.zero, - icon: icon, - width: 20, - onPressed: () => context - .read() - .add(const BoardCheckboxCellEvent.select()), - ), - ); - }, - ), - ); - } - - @override - Future dispose() async { - _cellBloc.close(); - super.dispose(); - } -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_date_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_date_cell.dart deleted file mode 100644 index 18c0a84f47ae2..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/board_date_cell.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:app_flowy/plugins/board/application/card/board_date_cell_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'define.dart'; - -class BoardDateCell extends StatefulWidget { - final String groupId; - final GridCellControllerBuilder cellControllerBuilder; - - const BoardDateCell({ - required this.groupId, - required this.cellControllerBuilder, - Key? key, - }) : super(key: key); - - @override - State createState() => _BoardDateCellState(); -} - -class _BoardDateCellState extends State { - late BoardDateCellBloc _cellBloc; - - @override - void initState() { - final cellController = - widget.cellControllerBuilder.build() as GridDateCellController; - - _cellBloc = BoardDateCellBloc(cellController: cellController) - ..add(const BoardDateCellEvent.initial()); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cellBloc, - child: BlocBuilder( - buildWhen: (previous, current) => previous.dateStr != current.dateStr, - builder: (context, state) { - if (state.dateStr.isEmpty) { - return const SizedBox(); - } else { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: EdgeInsets.symmetric( - vertical: BoardSizes.cardCellVPadding, - ), - child: FlowyText.regular( - state.dateStr, - fontSize: 13, - color: context.read().shader3, - ), - ), - ); - } - }, - ), - ); - } - - @override - Future dispose() async { - _cellBloc.close(); - super.dispose(); - } -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_number_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_number_cell.dart deleted file mode 100644 index 907950fcc6dac..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/board_number_cell.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:app_flowy/plugins/board/application/card/board_number_cell_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'define.dart'; - -class BoardNumberCell extends StatefulWidget { - final String groupId; - final GridCellControllerBuilder cellControllerBuilder; - - const BoardNumberCell({ - required this.groupId, - required this.cellControllerBuilder, - Key? key, - }) : super(key: key); - - @override - State createState() => _BoardNumberCellState(); -} - -class _BoardNumberCellState extends State { - late BoardNumberCellBloc _cellBloc; - - @override - void initState() { - final cellController = - widget.cellControllerBuilder.build() as GridNumberCellController; - - _cellBloc = BoardNumberCellBloc(cellController: cellController) - ..add(const BoardNumberCellEvent.initial()); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cellBloc, - child: BlocBuilder( - buildWhen: (previous, current) => previous.content != current.content, - builder: (context, state) { - if (state.content.isEmpty) { - return const SizedBox(); - } else { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: EdgeInsets.symmetric( - vertical: BoardSizes.cardCellVPadding, - ), - child: FlowyText.medium( - state.content, - fontSize: 14, - ), - ), - ); - } - }, - ), - ); - } - - @override - Future dispose() async { - _cellBloc.close(); - super.dispose(); - } -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_select_option_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_select_option_cell.dart deleted file mode 100644 index 65a16a17324dd..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/board_select_option_cell.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:app_flowy/plugins/board/application/card/board_select_option_cell_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart'; -import 'package:app_flowy/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'board_cell.dart'; - -class BoardSelectOptionCell extends StatefulWidget with EditableCell { - final String groupId; - final GridCellControllerBuilder cellControllerBuilder; - @override - final EditableCellNotifier? editableNotifier; - - const BoardSelectOptionCell({ - required this.groupId, - required this.cellControllerBuilder, - this.editableNotifier, - Key? key, - }) : super(key: key); - - @override - State createState() => _BoardSelectOptionCellState(); -} - -class _BoardSelectOptionCellState extends State { - late BoardSelectOptionCellBloc _cellBloc; - late PopoverController _popover; - - @override - void initState() { - _popover = PopoverController(); - final cellController = - widget.cellControllerBuilder.build() as GridSelectOptionCellController; - _cellBloc = BoardSelectOptionCellBloc(cellController: cellController) - ..add(const BoardSelectOptionCellEvent.initial()); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cellBloc, - child: BlocBuilder( - buildWhen: (previous, current) { - return previous.selectedOptions != current.selectedOptions; - }, builder: (context, state) { - // Returns SizedBox if the content of the cell is empty - if (_isEmpty(state)) return const SizedBox(); - - final children = state.selectedOptions.map( - (option) { - final tag = SelectOptionTag.fromOption( - context: context, - option: option, - onSelected: () => _popover.show(), - ); - return _wrapPopover(tag); - }, - ).toList(); - - return IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: SizedBox.expand( - child: Wrap(spacing: 4, runSpacing: 2, children: children), - ), - ), - ); - }), - ); - } - - bool _isEmpty(BoardSelectOptionCellState state) { - // The cell should hide if the option id is equal to the groupId. - final isInGroup = state.selectedOptions - .where((element) => element.id == widget.groupId) - .isNotEmpty; - return isInGroup || state.selectedOptions.isEmpty; - } - - Widget _wrapPopover(Widget child) { - final constraints = BoxConstraints.loose(Size( - SelectOptionCellEditor.editorPanelWidth, - 300, - )); - return AppFlowyPopover( - controller: _popover, - constraints: constraints, - direction: PopoverDirection.bottomWithLeftAligned, - popupBuilder: (BuildContext context) { - return SelectOptionCellEditor( - cellController: widget.cellControllerBuilder.build() - as GridSelectOptionCellController, - ); - }, - onClose: () {}, - child: child, - ); - } - - @override - Future dispose() async { - _cellBloc.close(); - super.dispose(); - } -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_text_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_text_cell.dart deleted file mode 100644 index 822749e3f22ac..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/board_text_cell.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:app_flowy/plugins/board/application/card/board_text_cell_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/presentation/widgets/cell/cell_builder.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; -import 'board_cell.dart'; -import 'define.dart'; - -class BoardTextCell extends StatefulWidget with EditableCell { - final String groupId; - @override - final EditableCellNotifier? editableNotifier; - final GridCellControllerBuilder cellControllerBuilder; - - const BoardTextCell({ - required this.groupId, - required this.cellControllerBuilder, - this.editableNotifier, - Key? key, - }) : super(key: key); - - @override - State createState() => _BoardTextCellState(); -} - -class _BoardTextCellState extends State { - late BoardTextCellBloc _cellBloc; - late TextEditingController _controller; - bool focusWhenInit = false; - SingleListenerFocusNode focusNode = SingleListenerFocusNode(); - - @override - void initState() { - final cellController = - widget.cellControllerBuilder.build() as GridCellController; - _cellBloc = BoardTextCellBloc(cellController: cellController) - ..add(const BoardTextCellEvent.initial()); - _controller = TextEditingController(text: _cellBloc.state.content); - focusWhenInit = widget.editableNotifier?.isCellEditing.value ?? false; - if (focusWhenInit) { - focusNode.requestFocus(); - } - - // If the focusNode lost its focus, the widget's editableNotifier will - // set to false, which will cause the [EditableRowNotifier] to receive - // end edit event. - focusNode.addListener(() { - if (!focusNode.hasFocus) { - focusWhenInit = false; - widget.editableNotifier?.isCellEditing.value = false; - _cellBloc.add(const BoardTextCellEvent.enableEdit(false)); - } - }); - _bindEditableNotifier(); - super.initState(); - } - - void _bindEditableNotifier() { - widget.editableNotifier?.isCellEditing.addListener(() { - if (!mounted) return; - - final isEditing = widget.editableNotifier?.isCellEditing.value ?? false; - if (isEditing) { - WidgetsBinding.instance.addPostFrameCallback((_) { - focusNode.requestFocus(); - }); - } - _cellBloc.add(BoardTextCellEvent.enableEdit(isEditing)); - }); - } - - @override - void didUpdateWidget(covariant BoardTextCell oldWidget) { - _bindEditableNotifier(); - super.didUpdateWidget(oldWidget); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cellBloc, - child: BlocListener( - listener: (context, state) { - if (_controller.text != state.content) { - _controller.text = state.content; - } - }, - child: BlocBuilder( - buildWhen: (previous, current) { - if (previous.content != current.content && - _controller.text == current.content && - current.enableEdit) { - return false; - } - - return previous != current; - }, - builder: (context, state) { - if (state.content.isEmpty && - state.enableEdit == false && - focusWhenInit == false) { - return const SizedBox(); - } - - // - Widget child; - if (state.enableEdit || focusWhenInit) { - child = _buildTextField(); - } else { - child = _buildText(state); - } - return Align(alignment: Alignment.centerLeft, child: child); - }, - ), - ), - ); - } - - Future focusChanged() async { - _cellBloc.add(BoardTextCellEvent.updateText(_controller.text)); - } - - @override - Future dispose() async { - _cellBloc.close(); - _controller.dispose(); - focusNode.dispose(); - super.dispose(); - } - - Widget _buildText(BoardTextCellState state) { - return Padding( - padding: EdgeInsets.symmetric( - vertical: BoardSizes.cardCellVPadding, - ), - child: FlowyText.medium( - state.content, - fontSize: 14, - maxLines: null, // Enable multiple lines - ), - ); - } - - Widget _buildTextField() { - return IntrinsicHeight( - child: TextField( - controller: _controller, - focusNode: focusNode, - onChanged: (value) => focusChanged(), - onEditingComplete: () => focusNode.unfocus(), - maxLines: null, - style: TextStyles.body1.size(FontSizes.s14), - decoration: InputDecoration( - // Magic number 4 makes the textField take up the same space as FlowyText - contentPadding: EdgeInsets.symmetric( - vertical: BoardSizes.cardCellVPadding + 4, - ), - border: InputBorder.none, - isDense: true, - ), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_url_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_url_cell.dart deleted file mode 100644 index c38e0ea54a819..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/board_url_cell.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:app_flowy/plugins/board/application/card/board_url_cell_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -import 'define.dart'; - -class BoardUrlCell extends StatefulWidget { - final String groupId; - final GridCellControllerBuilder cellControllerBuilder; - - const BoardUrlCell({ - required this.groupId, - required this.cellControllerBuilder, - Key? key, - }) : super(key: key); - - @override - State createState() => _BoardUrlCellState(); -} - -class _BoardUrlCellState extends State { - late BoardURLCellBloc _cellBloc; - - @override - void initState() { - final cellController = - widget.cellControllerBuilder.build() as GridURLCellController; - _cellBloc = BoardURLCellBloc(cellController: cellController); - _cellBloc.add(const BoardURLCellEvent.initial()); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocProvider.value( - value: _cellBloc, - child: BlocBuilder( - buildWhen: (previous, current) => previous.content != current.content, - builder: (context, state) { - if (state.content.isEmpty) { - return const SizedBox(); - } else { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: EdgeInsets.symmetric( - vertical: BoardSizes.cardCellVPadding, - ), - child: RichText( - textAlign: TextAlign.left, - text: TextSpan( - text: state.content, - style: TextStyles.general( - fontSize: FontSizes.s14, - color: theme.main2, - ).underline, - ), - ), - ), - ); - } - }, - ), - ); - } - - @override - Future dispose() async { - _cellBloc.close(); - super.dispose(); - } -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/card.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/card.dart deleted file mode 100644 index bf1143523a592..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/card.dart +++ /dev/null @@ -1,256 +0,0 @@ -import 'package:app_flowy/plugins/board/application/card/card_bloc.dart'; -import 'package:app_flowy/plugins/board/application/card/card_data_controller.dart'; -import 'package:app_flowy/plugins/grid/presentation/widgets/row/row_action_sheet.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'board_cell.dart'; -import 'card_cell_builder.dart'; -import 'container/accessory.dart'; -import 'container/card_container.dart'; - -class BoardCard extends StatefulWidget { - final String gridId; - final String groupId; - final String fieldId; - final bool isEditing; - final CardDataController dataController; - final BoardCellBuilder cellBuilder; - final void Function(BuildContext) openCard; - final VoidCallback onStartEditing; - final VoidCallback onEndEditing; - - const BoardCard({ - required this.gridId, - required this.groupId, - required this.fieldId, - required this.isEditing, - required this.dataController, - required this.cellBuilder, - required this.openCard, - required this.onStartEditing, - required this.onEndEditing, - Key? key, - }) : super(key: key); - - @override - State createState() => _BoardCardState(); -} - -class _BoardCardState extends State { - late BoardCardBloc _cardBloc; - late EditableRowNotifier rowNotifier; - late PopoverController popoverController; - AccessoryType? accessoryType; - - @override - void initState() { - rowNotifier = EditableRowNotifier(isEditing: widget.isEditing); - _cardBloc = BoardCardBloc( - gridId: widget.gridId, - groupFieldId: widget.fieldId, - dataController: widget.dataController, - isEditing: widget.isEditing, - )..add(const BoardCardEvent.initial()); - - rowNotifier.isEditing.addListener(() { - if (!mounted) return; - _cardBloc.add(BoardCardEvent.setIsEditing(rowNotifier.isEditing.value)); - - if (rowNotifier.isEditing.value) { - widget.onStartEditing(); - } else { - widget.onEndEditing(); - } - }); - - popoverController = PopoverController(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cardBloc, - child: BlocBuilder( - buildWhen: (previous, current) { - // Rebuild when: - // 1.If the length of the cells is not the same - // 2.isEditing changed - if (previous.cells.length != current.cells.length || - previous.isEditing != current.isEditing) { - return true; - } - - // 3.Compare the content of the cells. The cells consists of - // list of [BoardCellEquatable] that extends the [Equatable]. - return !listEquals(previous.cells, current.cells); - }, - builder: (context, state) { - return AppFlowyPopover( - controller: popoverController, - triggerActions: PopoverTriggerFlags.none, - constraints: BoxConstraints.loose(const Size(140, 200)), - margin: const EdgeInsets.all(6), - direction: PopoverDirection.rightWithCenterAligned, - popupBuilder: (popoverContext) => _handlePopoverBuilder( - context, - popoverContext, - ), - child: BoardCardContainer( - buildAccessoryWhen: () => state.isEditing == false, - accessoryBuilder: (context) { - return [ - _CardEditOption(rowNotifier: rowNotifier), - _CardMoreOption(), - ]; - }, - openAccessory: _handleOpenAccessory, - openCard: (context) => widget.openCard(context), - child: _CellColumn( - groupId: widget.groupId, - rowNotifier: rowNotifier, - cellBuilder: widget.cellBuilder, - cells: state.cells, - ), - ), - ); - }, - ), - ); - } - - void _handleOpenAccessory(AccessoryType newAccessoryType) { - accessoryType = newAccessoryType; - switch (newAccessoryType) { - case AccessoryType.edit: - break; - case AccessoryType.more: - popoverController.show(); - break; - } - } - - Widget _handlePopoverBuilder( - BuildContext context, - BuildContext popoverContext, - ) { - switch (accessoryType!) { - case AccessoryType.edit: - throw UnimplementedError(); - case AccessoryType.more: - return GridRowActionSheet( - rowData: context.read().rowInfo(), - ); - } - } - - @override - Future dispose() async { - rowNotifier.dispose(); - _cardBloc.close(); - super.dispose(); - } -} - -class _CellColumn extends StatelessWidget { - final String groupId; - final BoardCellBuilder cellBuilder; - final EditableRowNotifier rowNotifier; - final List cells; - const _CellColumn({ - required this.groupId, - required this.rowNotifier, - required this.cellBuilder, - required this.cells, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: _makeCells(context, cells), - ); - } - - List _makeCells( - BuildContext context, - List cells, - ) { - final List children = []; - // Remove all the cell listeners. - rowNotifier.unbind(); - - cells.asMap().forEach( - (int index, BoardCellEquatable cell) { - final isEditing = index == 0 ? rowNotifier.isEditing.value : false; - final cellNotifier = EditableCellNotifier(isEditing: isEditing); - - if (index == 0) { - // Only use the first cell to receive user's input when click the edit - // button - rowNotifier.bindCell(cell.identifier, cellNotifier); - } - - final child = Padding( - key: cell.identifier.key(), - padding: const EdgeInsets.only(left: 4, right: 4), - child: cellBuilder.buildCell( - groupId, - cell.identifier, - cellNotifier, - ), - ); - - children.add(child); - }, - ); - return children; - } -} - -class _CardMoreOption extends StatelessWidget with CardAccessory { - _CardMoreOption({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(3.0), - child: - svgWidget('grid/details', color: context.read().iconColor), - ); - } - - @override - AccessoryType get type => AccessoryType.more; -} - -class _CardEditOption extends StatelessWidget with CardAccessory { - final EditableRowNotifier rowNotifier; - const _CardEditOption({ - required this.rowNotifier, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(3.0), - child: svgWidget( - 'editor/edit', - color: context.read().iconColor, - ), - ); - } - - @override - void onTap(BuildContext context) => rowNotifier.becomeFirstResponder(); - - @override - AccessoryType get type => AccessoryType.edit; -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/card_cell_builder.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/card_cell_builder.dart deleted file mode 100644 index 1485bb7bd0e13..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/card_cell_builder.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter/material.dart'; - -import 'board_cell.dart'; -import 'board_checkbox_cell.dart'; -import 'board_date_cell.dart'; -import 'board_number_cell.dart'; -import 'board_select_option_cell.dart'; -import 'board_text_cell.dart'; -import 'board_url_cell.dart'; - -abstract class BoardCellBuilderDelegate - extends GridCellControllerBuilderDelegate { - GridCellCache get cellCache; -} - -class BoardCellBuilder { - final BoardCellBuilderDelegate delegate; - - BoardCellBuilder(this.delegate); - - Widget buildCell( - String groupId, - GridCellIdentifier cellId, - EditableCellNotifier cellNotifier, - ) { - final cellControllerBuilder = GridCellControllerBuilder( - delegate: delegate, - cellId: cellId, - cellCache: delegate.cellCache, - ); - - final key = cellId.key(); - switch (cellId.fieldType) { - case FieldType.Checkbox: - return BoardCheckboxCell( - groupId: groupId, - cellControllerBuilder: cellControllerBuilder, - key: key, - ); - case FieldType.DateTime: - return BoardDateCell( - groupId: groupId, - cellControllerBuilder: cellControllerBuilder, - key: key, - ); - case FieldType.SingleSelect: - return BoardSelectOptionCell( - groupId: groupId, - cellControllerBuilder: cellControllerBuilder, - key: key, - ); - case FieldType.MultiSelect: - return BoardSelectOptionCell( - groupId: groupId, - cellControllerBuilder: cellControllerBuilder, - editableNotifier: cellNotifier, - key: key, - ); - case FieldType.Number: - return BoardNumberCell( - groupId: groupId, - cellControllerBuilder: cellControllerBuilder, - key: key, - ); - case FieldType.RichText: - return BoardTextCell( - groupId: groupId, - cellControllerBuilder: cellControllerBuilder, - editableNotifier: cellNotifier, - key: key, - ); - case FieldType.URL: - return BoardUrlCell( - groupId: groupId, - cellControllerBuilder: cellControllerBuilder, - key: key, - ); - } - throw UnimplementedError; - } -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/container/accessory.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/container/accessory.dart deleted file mode 100644 index 0052e0e29ce2d..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/container/accessory.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -enum AccessoryType { - edit, - more, -} - -abstract class CardAccessory implements Widget { - AccessoryType get type; - void onTap(BuildContext context) {} -} - -typedef CardAccessoryBuilder = List Function( - BuildContext buildContext, -); - -class CardAccessoryContainer extends StatelessWidget { - final void Function(AccessoryType) onTapAccessory; - final List accessories; - const CardAccessoryContainer({ - required this.accessories, - required this.onTapAccessory, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.read(); - final children = accessories.map((accessory) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - accessory.onTap(context); - onTapAccessory(accessory.type); - }, - child: _wrapHover(theme, accessory), - ); - }).toList(); - return _wrapDecoration(context, Row(children: children)); - } - - FlowyHover _wrapHover(AppTheme theme, CardAccessory accessory) { - return FlowyHover( - style: HoverStyle( - hoverColor: theme.hover, - backgroundColor: theme.surface, - borderRadius: BorderRadius.zero, - ), - builder: (_, onHover) => SizedBox( - width: 24, - height: 24, - child: accessory, - ), - ); - } - - Widget _wrapDecoration(BuildContext context, Widget child) { - final theme = context.read(); - final borderSide = BorderSide(color: theme.shader6, width: 1.0); - final decoration = BoxDecoration( - color: Colors.transparent, - border: Border.fromBorderSide(borderSide), - borderRadius: const BorderRadius.all(Radius.circular(4)), - ); - return Container( - clipBehavior: Clip.hardEdge, - decoration: decoration, - child: child, - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/container/card_container.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/container/card_container.dart deleted file mode 100644 index ccdfc3fc3e6c5..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/container/card_container.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:styled_widget/styled_widget.dart'; - -import 'accessory.dart'; - -class BoardCardContainer extends StatelessWidget { - final Widget child; - final CardAccessoryBuilder? accessoryBuilder; - final bool Function()? buildAccessoryWhen; - final void Function(BuildContext) openCard; - final void Function(AccessoryType) openAccessory; - const BoardCardContainer({ - required this.child, - required this.openCard, - required this.openAccessory, - this.accessoryBuilder, - this.buildAccessoryWhen, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (_) => _CardContainerNotifier(), - child: Consumer<_CardContainerNotifier>( - builder: (context, notifier, _) { - Widget container = Center(child: child); - bool shouldBuildAccessory = true; - if (buildAccessoryWhen != null) { - shouldBuildAccessory = buildAccessoryWhen!.call(); - } - - if (accessoryBuilder != null && shouldBuildAccessory) { - final accessories = accessoryBuilder!(context); - if (accessories.isNotEmpty) { - container = _CardEnterRegion( - accessories: accessories, - onTapAccessory: openAccessory, - child: container, - ); - } - } - - return GestureDetector( - onTap: () => openCard(context), - child: Padding( - padding: const EdgeInsets.all(8), - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 30), - child: container, - ), - ), - ); - }, - ), - ); - } -} - -class _CardEnterRegion extends StatelessWidget { - final Widget child; - final List accessories; - final void Function(AccessoryType) onTapAccessory; - const _CardEnterRegion({ - required this.child, - required this.accessories, - required this.onTapAccessory, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Selector<_CardContainerNotifier, bool>( - selector: (context, notifier) => notifier.onEnter, - builder: (context, onEnter, _) { - List children = [child]; - if (onEnter) { - children.add( - CardAccessoryContainer( - accessories: accessories, - onTapAccessory: onTapAccessory, - ).positioned(right: 0), - ); - } - - return MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (p) => - Provider.of<_CardContainerNotifier>(context, listen: false) - .onEnter = true, - onExit: (p) => - Provider.of<_CardContainerNotifier>(context, listen: false) - .onEnter = false, - child: IntrinsicHeight( - child: Stack( - alignment: AlignmentDirectional.topEnd, - fit: StackFit.expand, - children: children, - )), - ); - }, - ); - } -} - -class _CardContainerNotifier extends ChangeNotifier { - bool _onEnter = false; - - _CardContainerNotifier(); - - set onEnter(bool value) { - if (_onEnter != value) { - _onEnter = value; - notifyListeners(); - } - } - - bool get onEnter => _onEnter; -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/define.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/define.dart deleted file mode 100644 index 5fc55743db15a..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/define.dart +++ /dev/null @@ -1,3 +0,0 @@ -class BoardSizes { - static double get cardCellVPadding => 6; -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_setting.dart b/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_setting.dart deleted file mode 100644 index a1bd42d06d332..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_setting.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/plugins/board/application/toolbar/board_setting_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:app_flowy/plugins/grid/presentation/layout/sizes.dart'; -import 'package:app_flowy/plugins/grid/presentation/widgets/toolbar/grid_group.dart'; -import 'package:app_flowy/plugins/grid/presentation/widgets/toolbar/grid_property.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'board_toolbar.dart'; - -class BoardSettingContext { - final String viewId; - final GridFieldController fieldController; - BoardSettingContext({ - required this.viewId, - required this.fieldController, - }); - - factory BoardSettingContext.from(BoardToolbarContext toolbarContext) => - BoardSettingContext( - viewId: toolbarContext.viewId, - fieldController: toolbarContext.fieldController, - ); -} - -class BoardSettingList extends StatelessWidget { - final BoardSettingContext settingContext; - final Function(BoardSettingAction, BoardSettingContext) onAction; - const BoardSettingList({ - required this.settingContext, - required this.onAction, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => BoardSettingBloc(gridId: settingContext.viewId), - child: BlocListener( - listenWhen: (previous, current) => - previous.selectedAction != current.selectedAction, - listener: (context, state) { - state.selectedAction.foldLeft(null, (_, action) { - onAction(action, settingContext); - }); - }, - child: BlocBuilder( - builder: (context, state) { - return _renderList(); - }, - ), - ), - ); - } - - Widget _renderList() { - final cells = BoardSettingAction.values.map((action) { - return _SettingItem(action: action); - }).toList(); - - return SizedBox( - width: 140, - child: ListView.separated( - shrinkWrap: true, - controller: ScrollController(), - itemCount: cells.length, - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - physics: StyledScrollPhysics(), - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - ), - ); - } -} - -class _SettingItem extends StatelessWidget { - final BoardSettingAction action; - - const _SettingItem({ - required this.action, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.read(); - final isSelected = context - .read() - .state - .selectedAction - .foldLeft(false, (_, selectedAction) => selectedAction == action); - - return SizedBox( - height: 30, - child: FlowyButton( - isSelected: isSelected, - text: FlowyText.medium( - action.title(), - fontSize: FontSizes.s12, - ), - hoverColor: theme.hover, - onTap: () { - context - .read() - .add(BoardSettingEvent.performAction(action)); - }, - leftIcon: svgWidget(action.iconName(), color: theme.iconColor), - ), - ); - } -} - -extension _GridSettingExtension on BoardSettingAction { - String iconName() { - switch (this) { - case BoardSettingAction.properties: - return 'grid/setting/properties'; - case BoardSettingAction.groups: - return 'grid/setting/group'; - } - } - - String title() { - switch (this) { - case BoardSettingAction.properties: - return LocaleKeys.grid_settings_Properties.tr(); - case BoardSettingAction.groups: - return LocaleKeys.grid_settings_group.tr(); - } - } -} - -class BoardSettingListPopover extends StatefulWidget { - final PopoverController popoverController; - final BoardSettingContext settingContext; - - const BoardSettingListPopover({ - Key? key, - required this.popoverController, - required this.settingContext, - }) : super(key: key); - - @override - State createState() => _BoardSettingListPopoverState(); -} - -class _BoardSettingListPopoverState extends State { - BoardSettingAction? _action; - - @override - Widget build(BuildContext context) { - if (_action != null) { - switch (_action!) { - case BoardSettingAction.groups: - return GridGroupList( - viewId: widget.settingContext.viewId, - fieldController: widget.settingContext.fieldController, - onDismissed: () { - widget.popoverController.close(); - }, - ); - case BoardSettingAction.properties: - return GridPropertyList( - gridId: widget.settingContext.viewId, - fieldController: widget.settingContext.fieldController, - ); - } - } - - return BoardSettingList( - settingContext: widget.settingContext, - onAction: (action, settingContext) { - setState(() => _action = action); - }, - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_toolbar.dart b/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_toolbar.dart deleted file mode 100644 index f20dcd6aa0f27..0000000000000 --- a/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_toolbar.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flutter/widgets.dart'; -import 'package:provider/provider.dart'; - -import 'board_setting.dart'; - -class BoardToolbarContext { - final String viewId; - final GridFieldController fieldController; - - BoardToolbarContext({ - required this.viewId, - required this.fieldController, - }); -} - -class BoardToolbar extends StatelessWidget { - final BoardToolbarContext toolbarContext; - const BoardToolbar({ - required this.toolbarContext, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 40, - child: Row( - children: [ - _SettingButton( - settingContext: BoardSettingContext.from(toolbarContext), - ), - ], - ), - ); - } -} - -class _SettingButton extends StatefulWidget { - final BoardSettingContext settingContext; - const _SettingButton({required this.settingContext, Key? key}) - : super(key: key); - - @override - State<_SettingButton> createState() => _SettingButtonState(); -} - -class _SettingButtonState extends State<_SettingButton> { - late PopoverController popoverController; - - @override - void initState() { - popoverController = PopoverController(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.read(); - return AppFlowyPopover( - controller: popoverController, - constraints: BoxConstraints.loose(const Size(260, 400)), - child: FlowyIconButton( - hoverColor: theme.hover, - width: 22, - icon: Padding( - padding: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 3.0), - child: svgWidget("grid/setting/setting", color: theme.iconColor), - ), - ), - popupBuilder: (BuildContext popoverContext) { - return BoardSettingListPopover( - settingContext: widget.settingContext, - popoverController: popoverController, - ); - }, - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/application/doc_bloc.dart b/frontend/app_flowy/lib/plugins/doc/application/doc_bloc.dart deleted file mode 100644 index 585d4207b8396..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/application/doc_bloc.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'dart:convert'; -import 'package:app_flowy/plugins/trash/application/trash_service.dart'; -import 'package:app_flowy/workspace/application/view/view_listener.dart'; -import 'package:app_flowy/plugins/doc/application/doc_service.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' - show EditorState, Document, Transaction; -import 'package:flowy_sdk/protobuf/flowy-folder/trash.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:dartz/dartz.dart'; -import 'dart:async'; - -part 'doc_bloc.freezed.dart'; - -class DocumentBloc extends Bloc { - final ViewPB view; - final DocumentService _documentService; - - final ViewListener _listener; - final TrashService _trashService; - late EditorState editorState; - StreamSubscription? _subscription; - - DocumentBloc({ - required this.view, - }) : _documentService = DocumentService(), - _listener = ViewListener(view: view), - _trashService = TrashService(), - super(DocumentState.initial()) { - on((event, emit) async { - await event.map( - initial: (Initial value) async { - await _initial(value, emit); - _listenOnViewChange(); - }, - deleted: (Deleted value) async { - emit(state.copyWith(isDeleted: true)); - }, - restore: (Restore value) async { - emit(state.copyWith(isDeleted: false)); - }, - deletePermanently: (DeletePermanently value) async { - final result = await _trashService - .deleteViews([Tuple2(view.id, TrashType.TrashView)]); - - final newState = result.fold( - (l) => state.copyWith(forceClose: true), (r) => state); - emit(newState); - }, - restorePage: (RestorePage value) async { - final result = await _trashService.putback(view.id); - final newState = result.fold( - (l) => state.copyWith(isDeleted: false), (r) => state); - emit(newState); - }, - ); - }); - } - - @override - Future close() async { - await _listener.stop(); - - if (_subscription != null) { - await _subscription?.cancel(); - } - - await _documentService.closeDocument(docId: view.id); - return super.close(); - } - - Future _initial(Initial value, Emitter emit) async { - final result = await _documentService.openDocument(view: view); - result.fold( - (block) { - final document = Document.fromJson(jsonDecode(block.snapshot)); - editorState = EditorState(document: document); - _listenOnDocumentChange(); - emit( - state.copyWith( - loadingState: DocumentLoadingState.finish(left(unit)), - ), - ); - }, - (err) { - emit( - state.copyWith( - loadingState: DocumentLoadingState.finish(right(err)), - ), - ); - }, - ); - } - - void _listenOnViewChange() { - _listener.start( - onViewDeleted: (result) { - result.fold( - (view) => add(const DocumentEvent.deleted()), - (error) {}, - ); - }, - onViewRestored: (result) { - result.fold( - (view) => add(const DocumentEvent.restore()), - (error) {}, - ); - }, - ); - } - - void _listenOnDocumentChange() { - _subscription = editorState.transactionStream.listen((transaction) { - final json = jsonEncode(TransactionAdaptor(transaction).toJson()); - _documentService - .applyEdit(docId: view.id, operations: json) - .then((result) { - result.fold( - (l) => null, - (err) => Log.error(err), - ); - }); - }); - } -} - -@freezed -class DocumentEvent with _$DocumentEvent { - const factory DocumentEvent.initial() = Initial; - const factory DocumentEvent.deleted() = Deleted; - const factory DocumentEvent.restore() = Restore; - const factory DocumentEvent.restorePage() = RestorePage; - const factory DocumentEvent.deletePermanently() = DeletePermanently; -} - -@freezed -class DocumentState with _$DocumentState { - const factory DocumentState({ - required DocumentLoadingState loadingState, - required bool isDeleted, - required bool forceClose, - }) = _DocumentState; - - factory DocumentState.initial() => const DocumentState( - loadingState: _Loading(), - isDeleted: false, - forceClose: false, - ); -} - -@freezed -class DocumentLoadingState with _$DocumentLoadingState { - const factory DocumentLoadingState.loading() = _Loading; - const factory DocumentLoadingState.finish( - Either successOrFail) = _Finish; -} - -/// Uses to erase the different between appflowy editor and the backend -class TransactionAdaptor { - final Transaction transaction; - TransactionAdaptor(this.transaction); - - Map toJson() { - final json = {}; - if (transaction.operations.isNotEmpty) { - // The backend uses [0,0] as the beginning path, but the editor uses [0]. - // So it needs to extend the path by inserting `0` at the head for all - // operations before passing to the backend. - json['operations'] = transaction.operations - .map((e) => e.copyWith(path: [0, ...e.path]).toJson()) - .toList(); - } - if (transaction.afterSelection != null) { - final selection = transaction.afterSelection!; - final start = selection.start; - final end = selection.end; - json['after_selection'] = selection - .copyWith( - start: start.copyWith(path: [0, ...start.path]), - end: end.copyWith(path: [0, ...end.path]), - ) - .toJson(); - } - if (transaction.beforeSelection != null) { - final selection = transaction.beforeSelection!; - final start = selection.start; - final end = selection.end; - json['before_selection'] = selection - .copyWith( - start: start.copyWith(path: [0, ...start.path]), - end: end.copyWith(path: [0, ...end.path]), - ) - .toJson(); - } - return json; - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/application/doc_service.dart b/frontend/app_flowy/lib/plugins/doc/application/doc_service.dart deleted file mode 100644 index c967221c9cda4..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/application/doc_service.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; - -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-document/entities.pb.dart'; - -class DocumentService { - Future> openDocument({ - required ViewPB view, - }) async { - await FolderEventSetLatestView(ViewIdPB(value: view.id)).send(); - - final payload = OpenDocumentContextPB() - ..documentId = view.id - ..documentVersion = DocumentVersionPB.V1; - // switch (view.dataFormat) { - // case ViewDataFormatPB.DeltaFormat: - // payload.documentVersion = DocumentVersionPB.V0; - // break; - // default: - // break; - // } - - return DocumentEventGetDocument(payload).send(); - } - - Future> applyEdit({ - required String docId, - required String operations, - }) { - final payload = EditPayloadPB.create() - ..docId = docId - ..operations = operations; - return DocumentEventApplyEdit(payload).send(); - } - - Future> closeDocument({required String docId}) { - final request = ViewIdPB(value: docId); - return FolderEventCloseView(request).send(); - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/application/prelude.dart b/frontend/app_flowy/lib/plugins/doc/application/prelude.dart deleted file mode 100644 index f2befe4cf093c..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/application/prelude.dart +++ /dev/null @@ -1,4 +0,0 @@ -export 'doc_bloc.dart'; -export 'doc_service.dart'; -export 'share_bloc.dart'; -export 'share_service.dart'; diff --git a/frontend/app_flowy/lib/plugins/doc/application/share_bloc.dart b/frontend/app_flowy/lib/plugins/doc/application/share_bloc.dart deleted file mode 100644 index 9487350522ed8..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/application/share_bloc.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'package:app_flowy/plugins/doc/application/share_service.dart'; -import 'package:app_flowy/workspace/application/markdown/document_markdown.dart'; -import 'package:flowy_sdk/protobuf/flowy-document/entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:dartz/dartz.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' show Document; -part 'share_bloc.freezed.dart'; - -class DocShareBloc extends Bloc { - ShareService service; - ViewPB view; - DocShareBloc({required this.view, required this.service}) - : super(const DocShareState.initial()) { - on((event, emit) async { - await event.map( - shareMarkdown: (ShareMarkdown shareMarkdown) async { - await service.exportMarkdown(view).then((result) { - result.fold( - (value) => emit( - DocShareState.finish( - left(_saveMarkdown(value, shareMarkdown.path)), - ), - ), - (error) => emit(DocShareState.finish(right(error))), - ); - }); - - emit(const DocShareState.loading()); - }, - shareLink: (ShareLink value) {}, - shareText: (ShareText value) {}, - ); - }); - } - - ExportDataPB _saveMarkdown(ExportDataPB value, String path) { - final markdown = _convertDocumentToMarkdown(value); - value.data = markdown; - File(path).writeAsStringSync(markdown); - return value; - } - - String _convertDocumentToMarkdown(ExportDataPB value) { - final json = jsonDecode(value.data); - final document = Document.fromJson(json); - return documentToMarkdown(document); - } -} - -@freezed -class DocShareEvent with _$DocShareEvent { - const factory DocShareEvent.shareMarkdown(String path) = ShareMarkdown; - const factory DocShareEvent.shareText() = ShareText; - const factory DocShareEvent.shareLink() = ShareLink; -} - -@freezed -class DocShareState with _$DocShareState { - const factory DocShareState.initial() = _Initial; - const factory DocShareState.loading() = _Loading; - const factory DocShareState.finish( - Either successOrFail) = _Finish; -} diff --git a/frontend/app_flowy/lib/plugins/doc/application/share_service.dart b/frontend/app_flowy/lib/plugins/doc/application/share_service.dart deleted file mode 100644 index 5bb0ef0d4d0cc..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/application/share_service.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'dart:async'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-document/protobuf.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; - -class ShareService { - Future> export( - ViewPB view, ExportType type) { - var payload = ExportPayloadPB.create() - ..viewId = view.id - ..exportType = type - ..documentVersion = DocumentVersionPB.V1; - - return DocumentEventExportDocument(payload).send(); - } - - Future> exportText(ViewPB view) { - return export(view, ExportType.Text); - } - - Future> exportMarkdown(ViewPB view) { - return export(view, ExportType.Markdown); - } - - Future> exportURL(ViewPB view) { - return export(view, ExportType.Link); - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/document.dart b/frontend/app_flowy/lib/plugins/doc/document.dart deleted file mode 100644 index ecc18ff518ce3..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/document.dart +++ /dev/null @@ -1,246 +0,0 @@ -library document_plugin; - -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/plugins/util.dart'; -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/workspace/application/appearance.dart'; -import 'package:app_flowy/plugins/doc/application/share_bloc.dart'; -import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; -import 'package:app_flowy/workspace/presentation/home/toast.dart'; -import 'package:app_flowy/workspace/presentation/widgets/left_bar_item.dart'; -import 'package:app_flowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:app_flowy/workspace/presentation/widgets/pop_up_action.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:clipboard/clipboard.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-document/entities.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:provider/provider.dart'; - -import 'document_page.dart'; - -class DocumentPluginBuilder extends PluginBuilder { - @override - Plugin build(dynamic data) { - if (data is ViewPB) { - return DocumentPlugin(pluginType: pluginType, view: data); - } else { - throw FlowyPluginException.invalidData; - } - } - - @override - String get menuName => LocaleKeys.document_menuName.tr(); - - @override - PluginType get pluginType => PluginType.editor; - - @override - ViewDataFormatPB get dataFormatType => ViewDataFormatPB.TreeFormat; -} - -class DocumentPlugin extends Plugin { - late PluginType _pluginType; - - @override - final ViewPluginNotifier notifier; - - DocumentPlugin({ - required PluginType pluginType, - required ViewPB view, - Key? key, - }) : notifier = ViewPluginNotifier(view: view) { - _pluginType = pluginType; - } - - @override - PluginDisplay get display => DocumentPluginDisplay(notifier: notifier); - - @override - PluginType get ty => _pluginType; - - @override - PluginId get id => notifier.view.id; -} - -class DocumentPluginDisplay extends PluginDisplay with NavigationItem { - final ViewPluginNotifier notifier; - ViewPB get view => notifier.view; - int? deletedViewIndex; - - DocumentPluginDisplay({required this.notifier, Key? key}); - - @override - Widget buildWidget(PluginContext context) { - notifier.isDeleted.addListener(() { - notifier.isDeleted.value.fold(() => null, (deletedView) { - if (deletedView.hasIndex()) { - deletedViewIndex = deletedView.index; - } - }); - }); - - return DocumentPage( - view: view, - onDeleted: () => context.onDeleted(view, deletedViewIndex), - key: ValueKey(view.id), - ); - } - - @override - Widget get leftBarItem => ViewLeftBarItem(view: view); - - @override - Widget? get rightBarItem => DocumentShareButton(view: view); - - @override - List get navigationItems => [this]; -} - -class DocumentShareButton extends StatelessWidget { - final ViewPB view; - DocumentShareButton({Key? key, required this.view}) - : super(key: ValueKey(view.hashCode)); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => getIt(param1: view), - child: BlocListener( - listener: (context, state) { - state.map( - initial: (_) {}, - loading: (_) {}, - finish: (state) { - state.successOrFail.fold( - _handleExportData, - _handleExportError, - ); - }, - ); - }, - child: BlocBuilder( - builder: (context, state) { - return ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), - child: Selector( - selector: (ctx, notifier) => notifier.locale, - builder: (ctx, _, child) => ConstrainedBox( - constraints: const BoxConstraints.expand( - height: 30, - width: 100, - ), - child: ShareActionList( - view: view, - ), - ), - ), - ); - }, - ), - ), - ); - } - - void _handleExportData(ExportDataPB exportData) { - switch (exportData.exportType) { - case ExportType.Link: - break; - case ExportType.Markdown: - FlutterClipboard.copy(exportData.data) - .then((value) => Log.info('copied to clipboard')); - break; - case ExportType.Text: - break; - } - } - - void _handleExportError(FlowyError error) {} -} - -class ShareActionList extends StatelessWidget { - const ShareActionList({ - Key? key, - required this.view, - }) : super(key: key); - - final ViewPB view; - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - final docShareBloc = context.read(); - return PopoverActionList( - direction: PopoverDirection.bottomWithCenterAligned, - actions: ShareAction.values - .map((action) => ShareActionWrapper(action)) - .toList(), - buildChild: (controller) { - return RoundedTextButton( - title: LocaleKeys.shareAction_buttonText.tr(), - fontSize: FontSizes.s12, - borderRadius: Corners.s6Border, - color: theme.main1, - onPressed: () => controller.show(), - ); - }, - onSelected: (action, controller) async { - switch (action.inner) { - case ShareAction.markdown: - final exportPath = await FilePicker.platform.saveFile( - dialogTitle: '', - fileName: '${view.name}.md', - ); - if (exportPath != null) { - docShareBloc.add(DocShareEvent.shareMarkdown(exportPath)); - showMessageToast('Exported to: $exportPath'); - } - break; - case ShareAction.copyLink: - NavigatorAlertDialog( - title: LocaleKeys.shareAction_workInProgress.tr()) - .show(context); - break; - } - controller.close(); - }, - ); - } -} - -enum ShareAction { - markdown, - copyLink, -} - -class ShareActionWrapper extends ActionCell { - final ShareAction inner; - - ShareActionWrapper(this.inner); - - @override - Widget? icon(Color iconColor) => null; - - @override - String get name => inner.name; -} - -extension QuestionBubbleExtension on ShareAction { - String get name { - switch (this) { - case ShareAction.markdown: - return LocaleKeys.shareAction_markdown.tr(); - case ShareAction.copyLink: - return LocaleKeys.shareAction_copyLink.tr(); - } - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/document_page.dart b/frontend/app_flowy/lib/plugins/doc/document_page.dart deleted file mode 100644 index 76ac3f2523763..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/document_page.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'package:app_flowy/plugins/doc/editor_styles.dart'; -import 'package:app_flowy/plugins/doc/presentation/plugins/horizontal_rule_node_widget.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/plugins/doc/presentation/banner.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:intl/intl.dart'; -import 'application/doc_bloc.dart'; - -class DocumentPage extends StatefulWidget { - final VoidCallback onDeleted; - final ViewPB view; - - DocumentPage({ - required this.view, - required this.onDeleted, - Key? key, - }) : super(key: ValueKey(view.id)); - - @override - State createState() => _DocumentPageState(); -} - -class _DocumentPageState extends State { - late DocumentBloc documentBloc; - final FocusNode _focusNode = FocusNode(); - - @override - void initState() { - // The appflowy editor use Intl as localization, set the default language as fallback. - Intl.defaultLocale = 'en_US'; - documentBloc = getIt(param1: super.widget.view) - ..add(const DocumentEvent.initial()); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: documentBloc), - ], - child: - BlocBuilder(builder: (context, state) { - return state.loadingState.map( - loading: (_) => SizedBox.expand( - child: Container(color: Colors.transparent), - ), - finish: (result) => result.successOrFail.fold( - (_) { - if (state.forceClose) { - widget.onDeleted(); - return const SizedBox(); - } else { - return _renderDocument(context, state); - } - }, - (err) => FlowyErrorPage(err.toString()), - ), - ); - }), - ); - } - - @override - Future dispose() async { - documentBloc.close(); - _focusNode.dispose(); - super.dispose(); - } - - Widget _renderDocument(BuildContext context, DocumentState state) { - return Column( - children: [ - if (state.isDeleted) _renderBanner(context), - // AppFlowy Editor - _renderAppFlowyEditor(context.read().editorState), - ], - ); - } - - Widget _renderBanner(BuildContext context) { - return DocumentBanner( - onRestore: () => - context.read().add(const DocumentEvent.restorePage()), - onDelete: () => context - .read() - .add(const DocumentEvent.deletePermanently()), - ); - } - - Widget _renderAppFlowyEditor(EditorState editorState) { - final theme = Theme.of(context); - final editor = AppFlowyEditor( - editorState: editorState, - autoFocus: editorState.document.isEmpty, - customBuilders: { - 'horizontal_rule': HorizontalRuleWidgetBuilder(), - }, - shortcutEvents: [ - insertHorizontalRule, - ], - themeData: theme.copyWith(extensions: [ - ...theme.extensions.values, - customEditorTheme(context), - ...customPluginTheme(context), - ]), - ); - return Expanded( - child: SizedBox.expand( - child: editor, - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/editor_styles.dart b/frontend/app_flowy/lib/plugins/doc/editor_styles.dart deleted file mode 100644 index 0c503e19cb3f8..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/editor_styles.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -const _baseFontSize = 14.0; - -EditorStyle customEditorTheme(BuildContext context) { - final theme = context.watch(); - - var editorStyle = theme.isDark ? EditorStyle.dark : EditorStyle.light; - editorStyle = editorStyle.copyWith( - textStyle: editorStyle.textStyle?.copyWith( - fontFamily: 'poppins', - fontSize: _baseFontSize, - ), - placeholderTextStyle: editorStyle.placeholderTextStyle?.copyWith( - fontFamily: 'poppins', - fontSize: _baseFontSize, - ), - bold: editorStyle.bold?.copyWith( - fontWeight: FontWeight.w500, - ), - ); - return editorStyle; -} - -Iterable> customPluginTheme(BuildContext context) { - final theme = context.watch(); - const basePadding = 12.0; - var headingPluginStyle = - theme.isDark ? HeadingPluginStyle.dark : HeadingPluginStyle.light; - headingPluginStyle = headingPluginStyle.copyWith( - textStyle: (EditorState editorState, Node node) { - final headingToFontSize = { - 'h1': _baseFontSize + 12, - 'h2': _baseFontSize + 8, - 'h3': _baseFontSize + 4, - 'h4': _baseFontSize, - 'h5': _baseFontSize, - 'h6': _baseFontSize, - }; - final fontSize = - headingToFontSize[node.attributes.heading] ?? _baseFontSize; - return TextStyle(fontSize: fontSize, fontWeight: FontWeight.w600); - }, - padding: (EditorState editorState, Node node) { - final headingToPadding = { - 'h1': basePadding + 6, - 'h2': basePadding + 4, - 'h3': basePadding + 2, - 'h4': basePadding, - 'h5': basePadding, - 'h6': basePadding, - }; - final padding = headingToPadding[node.attributes.heading] ?? basePadding; - return EdgeInsets.only(bottom: padding); - }, - ); - final pluginTheme = - theme.isDark ? darkPlguinStyleExtension : lightPlguinStyleExtension; - return pluginTheme.toList() - ..removeWhere((element) => element is HeadingPluginStyle) - ..add(headingPluginStyle); -} diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/banner.dart b/frontend/app_flowy/lib/plugins/doc/presentation/banner.dart deleted file mode 100644 index 66c1f6dfba087..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/presentation/banner.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/buttons/base_styled_button.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; - -class DocumentBanner extends StatelessWidget { - final void Function() onRestore; - final void Function() onDelete; - const DocumentBanner( - {required this.onRestore, required this.onDelete, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return ConstrainedBox( - constraints: const BoxConstraints(minHeight: 60), - child: Container( - width: double.infinity, - color: theme.main1, - child: FittedBox( - alignment: Alignment.center, - fit: BoxFit.scaleDown, - child: Row( - children: [ - FlowyText.medium(LocaleKeys.deletePagePrompt_text.tr(), - color: Colors.white), - const HSpace(20), - BaseStyledButton( - minWidth: 160, - minHeight: 40, - contentPadding: EdgeInsets.zero, - bgColor: Colors.transparent, - hoverColor: theme.main2, - downColor: theme.main1, - outlineColor: Colors.white, - borderRadius: Corners.s8Border, - onPressed: onRestore, - child: FlowyText.medium( - LocaleKeys.deletePagePrompt_restore.tr(), - color: Colors.white, - fontSize: 14)), - const HSpace(20), - BaseStyledButton( - minWidth: 220, - minHeight: 40, - contentPadding: EdgeInsets.zero, - bgColor: Colors.transparent, - hoverColor: theme.main2, - downColor: theme.main1, - outlineColor: Colors.white, - borderRadius: Corners.s8Border, - onPressed: onDelete, - child: FlowyText.medium( - LocaleKeys.deletePagePrompt_deletePermanent.tr(), - color: Colors.white, - fontSize: 14)), - ], - ), - ), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/plugins/horizontal_rule_node_widget.dart b/frontend/app_flowy/lib/plugins/doc/presentation/plugins/horizontal_rule_node_widget.dart deleted file mode 100644 index c3d4cbeb35d9d..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/presentation/plugins/horizontal_rule_node_widget.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; - -ShortcutEvent insertHorizontalRule = ShortcutEvent( - key: 'Horizontal rule', - command: 'Minus', - handler: _insertHorzaontalRule, -); - -ShortcutEventHandler _insertHorzaontalRule = (editorState, event) { - final selection = editorState.service.selectionService.currentSelection.value; - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); - if (textNodes.length != 1 || selection == null) { - return KeyEventResult.ignored; - } - final textNode = textNodes.first; - if (textNode.toPlainText() == '--') { - final transaction = editorState.transaction - ..deleteText(textNode, 0, 2) - ..insertNode( - textNode.path, - Node( - type: 'horizontal_rule', - children: LinkedList(), - attributes: {}, - ), - ) - ..afterSelection = - Selection.single(path: textNode.path.next, startOffset: 0); - editorState.apply(transaction); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; -}; - -SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem( - name: () => 'Horizontal rule', - icon: (editorState, onSelected) => Icon( - Icons.horizontal_rule, - color: onSelected - ? editorState.editorStyle.selectionMenuItemSelectedIconColor - : editorState.editorStyle.selectionMenuItemIconColor, - size: 18.0, - ), - keywords: ['horizontal rule'], - handler: (editorState, _, __) { - final selection = - editorState.service.selectionService.currentSelection.value; - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); - if (selection == null || textNodes.isEmpty) { - return; - } - final textNode = textNodes.first; - if (textNode.toPlainText().isEmpty) { - final transaction = editorState.transaction - ..insertNode( - textNode.path, - Node( - type: 'horizontal_rule', - children: LinkedList(), - attributes: {}, - ), - ) - ..afterSelection = - Selection.single(path: textNode.path.next, startOffset: 0); - editorState.apply(transaction); - } else { - final transaction = editorState.transaction - ..insertNode( - selection.end.path.next, - TextNode( - children: LinkedList(), - attributes: { - 'subtype': 'horizontal_rule', - }, - delta: Delta()..insert('---'), - ), - ) - ..afterSelection = selection; - editorState.apply(transaction); - } - }, -); - -class HorizontalRuleWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return _HorizontalRuleWidget( - key: context.node.key, - node: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => (node) { - return true; - }; -} - -class _HorizontalRuleWidget extends StatefulWidget { - const _HorizontalRuleWidget({ - Key? key, - required this.node, - required this.editorState, - }) : super(key: key); - - final Node node; - final EditorState editorState; - - @override - State<_HorizontalRuleWidget> createState() => __HorizontalRuleWidgetState(); -} - -class __HorizontalRuleWidgetState extends State<_HorizontalRuleWidget> - with SelectableMixin { - RenderBox get _renderBox => context.findRenderObject() as RenderBox; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Container( - height: 1, - color: Colors.grey, - ), - ); - } - - @override - Position start() => Position(path: widget.node.path, offset: 0); - - @override - Position end() => Position(path: widget.node.path, offset: 1); - - @override - Position getPositionInOffset(Offset start) => end(); - - @override - bool get shouldCursorBlink => false; - - @override - CursorStyle get cursorStyle => CursorStyle.borderLine; - - @override - Rect? getCursorRectInPosition(Position position) { - final size = _renderBox.size; - return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height); - } - - @override - List getRectsInSelection(Selection selection) => - [Offset.zero & _renderBox.size]; - - @override - Selection getSelectionInRange(Offset start, Offset end) => Selection.single( - path: widget.node.path, - startOffset: 0, - endOffset: 1, - ); - - @override - Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset); -} diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/style_widgets.dart b/frontend/app_flowy/lib/plugins/doc/presentation/style_widgets.dart deleted file mode 100644 index 8bbd5bc0d001b..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/presentation/style_widgets.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_quill/flutter_quill.dart'; - -class StyleWidgetBuilder { - static QuillCheckboxBuilder checkbox(AppTheme theme) { - return EditorCheckboxBuilder(theme); - } -} - -class EditorCheckboxBuilder extends QuillCheckboxBuilder { - final AppTheme theme; - - EditorCheckboxBuilder(this.theme); - - @override - Widget build( - {required BuildContext context, - required bool isChecked, - required ValueChanged onChanged}) { - return FlowyEditorCheckbox( - theme: theme, - isChecked: isChecked, - onChanged: onChanged, - ); - } -} - -class FlowyEditorCheckbox extends StatefulWidget { - final bool isChecked; - final ValueChanged onChanged; - final AppTheme theme; - const FlowyEditorCheckbox({ - required this.theme, - required this.isChecked, - required this.onChanged, - Key? key, - }) : super(key: key); - - @override - FlowyEditorCheckboxState createState() => FlowyEditorCheckboxState(); -} - -class FlowyEditorCheckboxState extends State { - late bool isChecked; - - @override - void initState() { - isChecked = widget.isChecked; - super.initState(); - } - - @override - Widget build(BuildContext context) { - final icon = isChecked - ? svgWidget('editor/editor_check') - : svgWidget('editor/editor_uncheck'); - return Align( - alignment: Alignment.centerLeft, - child: FlowyIconButton( - onPressed: () { - isChecked = !isChecked; - widget.onChanged(isChecked); - setState(() {}); - }, - iconPadding: EdgeInsets.zero, - icon: icon, - width: 23, - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/check_button.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/check_button.dart deleted file mode 100644 index 280209c64a6ba..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/check_button.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter_quill/flutter_quill.dart'; -import 'package:flutter_quill/models/documents/style.dart'; -import 'package:flutter/material.dart'; - -import 'toolbar_icon_button.dart'; - -class FlowyCheckListButton extends StatefulWidget { - const FlowyCheckListButton({ - required this.controller, - required this.attribute, - required this.tooltipText, - this.iconSize = defaultIconSize, - this.fillColor, - this.childBuilder = defaultToggleStyleButtonBuilder, - Key? key, - }) : super(key: key); - - final double iconSize; - - final Color? fillColor; - - final QuillController controller; - - final ToggleStyleButtonBuilder childBuilder; - - final Attribute attribute; - - final String tooltipText; - - @override - FlowyCheckListButtonState createState() => FlowyCheckListButtonState(); -} - -class FlowyCheckListButtonState extends State { - bool? _isToggled; - - Style get _selectionStyle => widget.controller.getSelectionStyle(); - - void _didChangeEditingValue() { - setState(() { - _isToggled = - _getIsToggled(widget.controller.getSelectionStyle().attributes); - }); - } - - @override - void initState() { - super.initState(); - _isToggled = _getIsToggled(_selectionStyle.attributes); - widget.controller.addListener(_didChangeEditingValue); - } - - bool _getIsToggled(Map attrs) { - if (widget.attribute.key == Attribute.list.key) { - final attribute = attrs[widget.attribute.key]; - if (attribute == null) { - return false; - } - return attribute.value == widget.attribute.value || - attribute.value == Attribute.checked.value; - } - return attrs.containsKey(widget.attribute.key); - } - - @override - void didUpdateWidget(covariant FlowyCheckListButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeEditingValue); - widget.controller.addListener(_didChangeEditingValue); - _isToggled = _getIsToggled(_selectionStyle.attributes); - } - } - - @override - void dispose() { - widget.controller.removeListener(_didChangeEditingValue); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return ToolbarIconButton( - onPressed: _toggleAttribute, - width: widget.iconSize * kIconButtonFactor, - iconName: 'editor/checkbox', - isToggled: _isToggled ?? false, - tooltipText: widget.tooltipText, - ); - } - - void _toggleAttribute() { - widget.controller.formatSelection(_isToggled! - ? Attribute.clone(Attribute.unchecked, null) - : Attribute.unchecked); - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/color_picker.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/color_picker.dart deleted file mode 100644 index 1f262483a8c4b..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/color_picker.dart +++ /dev/null @@ -1,280 +0,0 @@ -import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_quill/flutter_quill.dart'; -import 'package:flutter_quill/models/documents/style.dart'; -import 'package:flutter_quill/utils/color.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'toolbar_icon_button.dart'; - -class FlowyColorButton extends StatefulWidget { - const FlowyColorButton({ - required this.icon, - required this.controller, - required this.background, - this.iconSize = defaultIconSize, - this.iconTheme, - Key? key, - }) : super(key: key); - - final IconData icon; - final double iconSize; - final bool background; - final QuillController controller; - final QuillIconTheme? iconTheme; - - @override - FlowyColorButtonState createState() => FlowyColorButtonState(); -} - -class FlowyColorButtonState extends State { - late bool _isToggledColor; - late bool _isToggledBackground; - late bool _isWhite; - late bool _isWhitebackground; - - Style get _selectionStyle => widget.controller.getSelectionStyle(); - - void _didChangeEditingValue() { - setState(() { - _isToggledColor = - _getIsToggledColor(widget.controller.getSelectionStyle().attributes); - _isToggledBackground = _getIsToggledBackground( - widget.controller.getSelectionStyle().attributes); - _isWhite = _isToggledColor && - _selectionStyle.attributes['color']!.value == '#ffffff'; - _isWhitebackground = _isToggledBackground && - _selectionStyle.attributes['background']!.value == '#ffffff'; - }); - } - - @override - void initState() { - super.initState(); - _isToggledColor = _getIsToggledColor(_selectionStyle.attributes); - _isToggledBackground = _getIsToggledBackground(_selectionStyle.attributes); - _isWhite = _isToggledColor && - _selectionStyle.attributes['color']!.value == '#ffffff'; - _isWhitebackground = _isToggledBackground && - _selectionStyle.attributes['background']!.value == '#ffffff'; - widget.controller.addListener(_didChangeEditingValue); - } - - bool _getIsToggledColor(Map attrs) { - return attrs.containsKey(Attribute.color.key); - } - - bool _getIsToggledBackground(Map attrs) { - return attrs.containsKey(Attribute.background.key); - } - - @override - void didUpdateWidget(covariant FlowyColorButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeEditingValue); - widget.controller.addListener(_didChangeEditingValue); - _isToggledColor = _getIsToggledColor(_selectionStyle.attributes); - _isToggledBackground = - _getIsToggledBackground(_selectionStyle.attributes); - _isWhite = _isToggledColor && - _selectionStyle.attributes['color']!.value == '#ffffff'; - _isWhitebackground = _isToggledBackground && - _selectionStyle.attributes['background']!.value == '#ffffff'; - } - } - - @override - void dispose() { - widget.controller.removeListener(_didChangeEditingValue); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - final fillColor = _isToggledColor && !widget.background && _isWhite - ? stringToColor('#ffffff') - : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor); - final fillColorBackground = - _isToggledBackground && widget.background && _isWhitebackground - ? stringToColor('#ffffff') - : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor); - - return Tooltip( - message: LocaleKeys.toolbar_highlight.tr(), - showDuration: Duration.zero, - child: QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: widget.iconSize * kIconButtonFactor, - icon: Icon(widget.icon, - size: widget.iconSize, color: theme.iconTheme.color), - fillColor: widget.background ? fillColorBackground : fillColor, - onPressed: _showColorPicker, - ), - ); - } - - void _changeColor(BuildContext context, Color color) { - var hex = color.value.toRadixString(16); - if (hex.startsWith('ff')) { - hex = hex.substring(2); - } - hex = '#$hex'; - widget.controller.formatSelection( - widget.background ? BackgroundAttribute(hex) : ColorAttribute(hex)); - Navigator.of(context).pop(); - } - - void _showColorPicker() { - final style = widget.controller.getSelectionStyle(); - final values = style.values - .where((v) => v.key == Attribute.background.key) - .map((v) => v.value); - int initialColor = 0; - if (values.isNotEmpty) { - assert(values.length == 1); - initialColor = stringToHex(values.first); - } - - StyledDialog( - child: SingleChildScrollView( - child: FlowyColorPicker( - onColorChanged: (color) { - if (color == null) { - widget.controller.formatSelection(BackgroundAttribute(null)); - Navigator.of(context).pop(); - } else { - _changeColor(context, color); - } - }, - initialColor: initialColor, - ), - ), - ).show(context); - } -} - -int stringToHex(String code) { - return int.parse(code.substring(1, 7), radix: 16) + 0xFF000000; -} - -class FlowyColorPicker extends StatefulWidget { - final List colors = [ - 0xffe8e0ff, - 0xffffe7fd, - 0xffffe7ee, - 0xffffefe3, - 0xfffff2cd, - 0xfff5ffdc, - 0xffddffd6, - 0xffdefff1, - ]; - final Function(Color?) onColorChanged; - final int initialColor; - FlowyColorPicker( - {Key? key, required this.onColorChanged, this.initialColor = 0}) - : super(key: key); - - @override - State createState() => _FlowyColorPickerState(); -} - -// if (shrinkWrap) { -// innerContent = IntrinsicWidth(child: IntrinsicHeight(child: innerContent)); -// } -class _FlowyColorPickerState extends State { - @override - Widget build(BuildContext context) { - const double width = 480; - const int crossAxisCount = 6; - const double mainAxisSpacing = 10; - const double crossAxisSpacing = 10; - final numberOfRows = (widget.colors.length / crossAxisCount).ceil(); - - const perRowHeight = - ((width - ((crossAxisCount - 1) * mainAxisSpacing)) / crossAxisCount); - final totalHeight = - numberOfRows * perRowHeight + numberOfRows * crossAxisSpacing; - - return Container( - constraints: BoxConstraints.tightFor(width: width, height: totalHeight), - child: CustomScrollView( - scrollDirection: Axis.vertical, - controller: ScrollController(), - physics: const ClampingScrollPhysics(), - slivers: [ - SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisSpacing: mainAxisSpacing, - crossAxisSpacing: crossAxisSpacing, - childAspectRatio: 1.0, - ), - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - if (widget.colors.length > index) { - final isSelected = - widget.colors[index] == widget.initialColor; - return ColorItem( - color: Color(widget.colors[index]), - onPressed: widget.onColorChanged, - isSelected: isSelected, - ); - } else { - return null; - } - }, - childCount: widget.colors.length, - ), - ), - ], - ), - ); - } -} - -class ColorItem extends StatelessWidget { - final Function(Color?) onPressed; - final bool isSelected; - final Color color; - const ColorItem({ - Key? key, - required this.color, - required this.onPressed, - this.isSelected = false, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - if (!isSelected) { - return RawMaterialButton( - onPressed: () { - onPressed(color); - }, - elevation: 0, - hoverElevation: 0.6, - fillColor: color, - shape: const CircleBorder(), - ); - } else { - return RawMaterialButton( - shape: const CircleBorder( - side: BorderSide(color: Colors.white, width: 8)) + - CircleBorder(side: BorderSide(color: color, width: 4)), - onPressed: () { - if (isSelected) { - onPressed(null); - } else { - onPressed(color); - } - }, - elevation: 1.0, - hoverElevation: 0.6, - fillColor: color, - ); - } - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/header_button.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/header_button.dart deleted file mode 100644 index 2db98b5af0c48..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/header_button.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter_quill/flutter_quill.dart'; -import 'package:flutter_quill/models/documents/style.dart'; -import 'package:flutter/material.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'toolbar_icon_button.dart'; - -class FlowyHeaderStyleButton extends StatefulWidget { - const FlowyHeaderStyleButton({ - required this.controller, - this.iconSize = defaultIconSize, - Key? key, - }) : super(key: key); - - final QuillController controller; - final double iconSize; - - @override - FlowyHeaderStyleButtonState createState() => FlowyHeaderStyleButtonState(); -} - -class FlowyHeaderStyleButtonState extends State { - Attribute? _value; - - Style get _selectionStyle => widget.controller.getSelectionStyle(); - - @override - void initState() { - super.initState(); - setState(() { - _value = - _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; - }); - widget.controller.addListener(_didChangeEditingValue); - } - - @override - Widget build(BuildContext context) { - final valueToText = { - Attribute.h1: 'H1', - Attribute.h2: 'H2', - Attribute.h3: 'H3', - }; - - final valueAttribute = [ - Attribute.h1, - Attribute.h2, - Attribute.h3 - ]; - final valueString = ['H1', 'H2', 'H3']; - final attributeImageName = ['editor/H1', 'editor/H2', 'editor/H3']; - - return Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(3, (index) { - // final child = - // _valueToText[_value] == _valueString[index] ? svg('editor/H1', color: Colors.white) : svg('editor/H1'); - - final headerTitle = "${LocaleKeys.toolbar_header.tr()} ${index + 1}"; - final isToggled = valueToText[_value] == valueString[index]; - return ToolbarIconButton( - onPressed: () { - if (isToggled) { - widget.controller.formatSelection(Attribute.header); - } else { - widget.controller.formatSelection(valueAttribute[index]); - } - }, - width: widget.iconSize * kIconButtonFactor, - iconName: attributeImageName[index], - isToggled: isToggled, - tooltipText: headerTitle, - ); - }), - ); - } - - void _didChangeEditingValue() { - setState(() { - _value = - _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; - }); - } - - @override - void didUpdateWidget(covariant FlowyHeaderStyleButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeEditingValue); - widget.controller.addListener(_didChangeEditingValue); - _value = - _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; - } - } - - @override - void dispose() { - widget.controller.removeListener(_didChangeEditingValue); - super.dispose(); - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/history_button.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/history_button.dart deleted file mode 100644 index fbe85f40dd445..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/history_button.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_quill/flutter_quill.dart'; - -class FlowyHistoryButton extends StatelessWidget { - final IconData icon; - final double iconSize; - final bool undo; - final QuillController controller; - final String tooltipText; - - const FlowyHistoryButton({ - required this.icon, - required this.controller, - required this.undo, - required this.tooltipText, - required this.iconSize, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: tooltipText, - showDuration: Duration.zero, - child: HistoryButton( - icon: icon, - iconSize: iconSize, - controller: controller, - undo: undo, - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/image_button.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/image_button.dart deleted file mode 100644 index 1a18ef10f1c6e..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/image_button.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter_quill/flutter_quill.dart'; -import 'package:flutter/material.dart'; -import 'toolbar_icon_button.dart'; - -class FlowyImageButton extends StatelessWidget { - const FlowyImageButton({ - required this.controller, - required this.tooltipText, - this.iconSize = defaultIconSize, - this.onImagePickCallback, - this.fillColor, - this.filePickImpl, - this.webImagePickImpl, - this.mediaPickSettingSelector, - Key? key, - }) : super(key: key); - - final double iconSize; - - final Color? fillColor; - - final QuillController controller; - - final OnImagePickCallback? onImagePickCallback; - - final WebImagePickImpl? webImagePickImpl; - - final FilePickImpl? filePickImpl; - - final MediaPickSettingSelector? mediaPickSettingSelector; - - final String tooltipText; - - @override - Widget build(BuildContext context) { - return ToolbarIconButton( - iconName: 'editor/image', - width: iconSize * 1.77, - onPressed: () => _onPressedHandler(context), - isToggled: false, - tooltipText: tooltipText, - ); - } - - Future _onPressedHandler(BuildContext context) async { - // if (onImagePickCallback != null) { - // final selector = mediaPickSettingSelector ?? ImageVideoUtils.selectMediaPickSetting; - // final source = await selector(context); - // if (source != null) { - // if (source == MediaPickSetting.Gallery) { - // _pickImage(context); - // } else { - // _typeLink(context); - // } - // } - // } else { - // _typeLink(context); - // } - } - - // void _pickImage(BuildContext context) => ImageVideoUtils.handleImageButtonTap( - // context, - // controller, - // ImageSource.gallery, - // onImagePickCallback!, - // filePickImpl: filePickImpl, - // webImagePickImpl: webImagePickImpl, - // ); - - // void _typeLink(BuildContext context) { - // TextFieldDialog( - // title: 'URL', - // value: "", - // confirm: (newValue) { - // if (newValue.isEmpty) { - // return; - // } - // final index = controller.selection.baseOffset; - // final length = controller.selection.extentOffset - index; - - // controller.replaceText(index, length, BlockEmbed.image(newValue), null); - // }, - // ).show(context); - // } -} diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/link_button.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/link_button.dart deleted file mode 100644 index 22c9f108ec822..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/link_button.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:app_flowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:flutter_quill/flutter_quill.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'toolbar_icon_button.dart'; - -class FlowyLinkStyleButton extends StatefulWidget { - const FlowyLinkStyleButton({ - required this.controller, - this.iconSize = defaultIconSize, - Key? key, - }) : super(key: key); - - final QuillController controller; - final double iconSize; - - @override - FlowyLinkStyleButtonState createState() => FlowyLinkStyleButtonState(); -} - -class FlowyLinkStyleButtonState extends State { - void _didChangeSelection() { - setState(() {}); - } - - @override - void initState() { - super.initState(); - widget.controller.addListener(_didChangeSelection); - } - - @override - void didUpdateWidget(covariant FlowyLinkStyleButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeSelection); - widget.controller.addListener(_didChangeSelection); - } - } - - @override - void dispose() { - super.dispose(); - widget.controller.removeListener(_didChangeSelection); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - final isEnabled = !widget.controller.selection.isCollapsed; - final pressedHandler = isEnabled ? () => _openLinkDialog(context) : null; - final icon = isEnabled - ? svgWidget( - 'editor/share', - color: theme.iconColor, - ) - : svgWidget( - 'editor/share', - color: theme.disableIconColor, - ); - - return FlowyIconButton( - onPressed: pressedHandler, - iconPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), - icon: icon, - fillColor: theme.shader6, - hoverColor: theme.shader5, - width: widget.iconSize * kIconButtonFactor, - ); - } - - void _openLinkDialog(BuildContext context) { - final style = widget.controller.getSelectionStyle(); - final values = style.values - .where((v) => v.key == Attribute.link.key) - .map((v) => v.value); - String value = ""; - if (values.isNotEmpty) { - assert(values.length == 1); - value = values.first; - } - - NavigatorTextFieldDialog( - title: 'URL', - value: value, - confirm: (newValue) { - if (newValue.isEmpty) { - return; - } - widget.controller.formatSelection(LinkAttribute(newValue)); - }, - ).show(context); - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/toggle_button.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/toggle_button.dart deleted file mode 100644 index 2ecb98c3ea1fb..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/toggle_button.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter_quill/flutter_quill.dart'; -import 'package:flutter_quill/models/documents/style.dart'; -import 'package:flutter/material.dart'; - -import 'toolbar_icon_button.dart'; - -class FlowyToggleStyleButton extends StatefulWidget { - final Attribute attribute; - final String normalIcon; - final double iconSize; - final QuillController controller; - final String tooltipText; - - const FlowyToggleStyleButton({ - required this.attribute, - required this.normalIcon, - required this.controller, - required this.tooltipText, - this.iconSize = defaultIconSize, - Key? key, - }) : super(key: key); - - @override - ToggleStyleButtonState createState() => ToggleStyleButtonState(); -} - -class ToggleStyleButtonState extends State { - bool? _isToggled; - Style get _selectionStyle => widget.controller.getSelectionStyle(); - @override - void initState() { - super.initState(); - _isToggled = _getIsToggled(_selectionStyle.attributes); - widget.controller.addListener(_didChangeEditingValue); - } - - @override - Widget build(BuildContext context) { - return ToolbarIconButton( - onPressed: _toggleAttribute, - width: widget.iconSize * kIconButtonFactor, - isToggled: _isToggled ?? false, - iconName: widget.normalIcon, - tooltipText: widget.tooltipText, - ); - } - - @override - void didUpdateWidget(covariant FlowyToggleStyleButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_didChangeEditingValue); - widget.controller.addListener(_didChangeEditingValue); - _isToggled = _getIsToggled(_selectionStyle.attributes); - } - } - - @override - void dispose() { - widget.controller.removeListener(_didChangeEditingValue); - super.dispose(); - } - - void _didChangeEditingValue() { - setState(() => _isToggled = _getIsToggled(_selectionStyle.attributes)); - } - - bool _getIsToggled(Map attrs) { - if (widget.attribute.key == Attribute.list.key) { - final attribute = attrs[widget.attribute.key]; - if (attribute == null) { - return false; - } - return attribute.value == widget.attribute.value; - } - return attrs.containsKey(widget.attribute.key); - } - - void _toggleAttribute() { - widget.controller.formatSelection(_isToggled! - ? Attribute.clone(widget.attribute, null) - : widget.attribute); - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/tool_bar.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/tool_bar.dart deleted file mode 100644 index d649340066fa4..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/tool_bar.dart +++ /dev/null @@ -1,308 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:app_flowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter_quill/flutter_quill.dart'; -import 'package:flutter/material.dart'; -import 'package:styled_widget/styled_widget.dart'; -import 'check_button.dart'; -import 'color_picker.dart'; -import 'header_button.dart'; -import 'history_button.dart'; -import 'link_button.dart'; -import 'toggle_button.dart'; -import 'toolbar_icon_button.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; - -class EditorToolbar extends StatelessWidget implements PreferredSizeWidget { - final List children; - final double toolBarHeight; - final Color? color; - - const EditorToolbar({ - required this.children, - this.toolBarHeight = 46, - this.color, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - color: Theme.of(context).canvasColor, - constraints: BoxConstraints.tightFor(height: preferredSize.height), - child: ToolbarButtonList(buttons: children) - .padding(horizontal: 4, vertical: 4), - ); - } - - @override - Size get preferredSize => Size.fromHeight(toolBarHeight); - - factory EditorToolbar.basic({ - required QuillController controller, - double toolbarIconSize = defaultIconSize, - OnImagePickCallback? onImagePickCallback, - OnVideoPickCallback? onVideoPickCallback, - MediaPickSettingSelector? mediaPickSettingSelector, - FilePickImpl? filePickImpl, - WebImagePickImpl? webImagePickImpl, - WebVideoPickImpl? webVideoPickImpl, - Key? key, - }) { - return EditorToolbar( - key: key, - toolBarHeight: toolbarIconSize * 2, - children: [ - FlowyHistoryButton( - icon: Icons.undo_outlined, - iconSize: toolbarIconSize, - controller: controller, - undo: true, - tooltipText: LocaleKeys.toolbar_undo.tr(), - ), - FlowyHistoryButton( - icon: Icons.redo_outlined, - iconSize: toolbarIconSize, - controller: controller, - undo: false, - tooltipText: LocaleKeys.toolbar_redo.tr(), - ), - FlowyToggleStyleButton( - attribute: Attribute.bold, - normalIcon: 'editor/bold', - iconSize: toolbarIconSize, - controller: controller, - tooltipText: LocaleKeys.toolbar_bold.tr(), - ), - FlowyToggleStyleButton( - attribute: Attribute.italic, - normalIcon: 'editor/italic', - iconSize: toolbarIconSize, - controller: controller, - tooltipText: LocaleKeys.toolbar_italic.tr(), - ), - FlowyToggleStyleButton( - attribute: Attribute.underline, - normalIcon: 'editor/underline', - iconSize: toolbarIconSize, - controller: controller, - tooltipText: LocaleKeys.toolbar_underline.tr(), - ), - FlowyToggleStyleButton( - attribute: Attribute.strikeThrough, - normalIcon: 'editor/strikethrough', - iconSize: toolbarIconSize, - controller: controller, - tooltipText: LocaleKeys.toolbar_strike.tr(), - ), - FlowyColorButton( - icon: Icons.format_color_fill, - iconSize: toolbarIconSize, - controller: controller, - background: true, - ), - // FlowyImageButton( - // iconSize: toolbarIconSize, - // controller: controller, - // onImagePickCallback: onImagePickCallback, - // filePickImpl: filePickImpl, - // webImagePickImpl: webImagePickImpl, - // mediaPickSettingSelector: mediaPickSettingSelector, - // ), - FlowyHeaderStyleButton( - controller: controller, - iconSize: toolbarIconSize, - ), - FlowyToggleStyleButton( - attribute: Attribute.ol, - controller: controller, - normalIcon: 'editor/numbers', - iconSize: toolbarIconSize, - tooltipText: LocaleKeys.toolbar_numList.tr(), - ), - FlowyToggleStyleButton( - attribute: Attribute.ul, - controller: controller, - normalIcon: 'editor/bullet_list', - iconSize: toolbarIconSize, - tooltipText: LocaleKeys.toolbar_bulletList.tr(), - ), - FlowyCheckListButton( - attribute: Attribute.unchecked, - controller: controller, - iconSize: toolbarIconSize, - tooltipText: LocaleKeys.toolbar_checkList.tr(), - ), - FlowyToggleStyleButton( - attribute: Attribute.inlineCode, - controller: controller, - normalIcon: 'editor/inline_block', - iconSize: toolbarIconSize, - tooltipText: LocaleKeys.toolbar_inlineCode.tr(), - ), - FlowyToggleStyleButton( - attribute: Attribute.blockQuote, - controller: controller, - normalIcon: 'editor/quote', - iconSize: toolbarIconSize, - tooltipText: LocaleKeys.toolbar_quote.tr(), - ), - FlowyLinkStyleButton( - controller: controller, - iconSize: toolbarIconSize, - ), - FlowyEmojiStyleButton( - normalIcon: 'editor/insert_emoticon', - controller: controller, - tooltipText: "Emoji Picker", - ), - ], - ); - } -} - -class ToolbarButtonList extends StatefulWidget { - const ToolbarButtonList({required this.buttons, Key? key}) : super(key: key); - - final List buttons; - - @override - ToolbarButtonListState createState() => ToolbarButtonListState(); -} - -class ToolbarButtonListState extends State - with WidgetsBindingObserver { - final ScrollController _controller = ScrollController(); - bool _showLeftArrow = false; - bool _showRightArrow = false; - - @override - void initState() { - super.initState(); - _controller.addListener(_handleScroll); - - // Listening to the WidgetsBinding instance is necessary so that we can - // hide the arrows when the window gets a new size and thus the toolbar - // becomes scrollable/unscrollable. - WidgetsBinding.instance.addObserver(this); - - // Workaround to allow the scroll controller attach to our ListView so that - // we can detect if overflow arrows need to be shown on init. - Timer.run(_handleScroll); - } - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - List children = []; - double width = - (widget.buttons.length + 2) * defaultIconSize * kIconButtonFactor; - final isFit = constraints.maxWidth > width; - if (!isFit) { - children.add(_buildLeftArrow()); - width = width + 18; - } - - children.add(_buildScrollableList(constraints, isFit)); - - if (!isFit) { - children.add(_buildRightArrow()); - width = width + 18; - } - - return SizedBox( - width: min(constraints.maxWidth, width), - child: Row( - children: children, - ), - ); - }, - ); - } - - @override - void didChangeMetrics() => _handleScroll(); - - @override - void dispose() { - _controller.dispose(); - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - void _handleScroll() { - if (!mounted) return; - setState(() { - _showLeftArrow = - _controller.position.minScrollExtent != _controller.position.pixels; - _showRightArrow = - _controller.position.maxScrollExtent != _controller.position.pixels; - }); - } - - Widget _buildLeftArrow() { - return SizedBox( - width: 8, - child: Transform.translate( - // Move the icon a few pixels to center it - offset: const Offset(-5, 0), - child: _showLeftArrow ? const Icon(Icons.arrow_left, size: 18) : null, - ), - ); - } - - // [[sliver: https://medium.com/flutter/slivers-demystified-6ff68ab0296f]] - Widget _buildScrollableList(BoxConstraints constraints, bool isFit) { - Widget child = Expanded( - child: CustomScrollView( - scrollDirection: Axis.horizontal, - controller: _controller, - physics: const ClampingScrollPhysics(), - slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - return widget.buttons[index]; - }, - childCount: widget.buttons.length, - addAutomaticKeepAlives: false, - ), - ) - ], - ), - ); - - if (!isFit) { - child = ScrollConfiguration( - // Remove the glowing effect, as we already have the arrow indicators - behavior: _NoGlowBehavior(), - // The CustomScrollView is necessary so that the children are not - // stretched to the height of the toolbar, https://bit.ly/3uC3bjI - child: child, - ); - } - - return child; - } - - Widget _buildRightArrow() { - return SizedBox( - width: 8, - child: Transform.translate( - // Move the icon a few pixels to center it - offset: const Offset(-5, 0), - child: _showRightArrow ? const Icon(Icons.arrow_right, size: 18) : null, - ), - ); - } -} - -class _NoGlowBehavior extends ScrollBehavior { - @override - Widget buildViewportChrome(BuildContext _, Widget child, AxisDirection __) { - return child; - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/toolbar_icon_button.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/toolbar_icon_button.dart deleted file mode 100644 index aac5b5a4b1e7c..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/toolbar_icon_button.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flutter/material.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:provider/provider.dart'; - -const double defaultIconSize = 18; - -class ToolbarIconButton extends StatelessWidget { - final double width; - final VoidCallback? onPressed; - final bool isToggled; - final String iconName; - final String tooltipText; - - const ToolbarIconButton({ - Key? key, - required this.onPressed, - required this.isToggled, - required this.width, - required this.iconName, - required this.tooltipText, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return FlowyIconButton( - iconPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), - onPressed: onPressed, - width: width, - icon: isToggled == true ? svgWidget(iconName, color: Colors.white) : svgWidget(iconName, color: theme.iconColor), - fillColor: isToggled == true ? theme.main1 : theme.shader6, - hoverColor: isToggled == true ? theme.main1 : theme.hover, - tooltipText: tooltipText, - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/doc/styles.dart b/frontend/app_flowy/lib/plugins/doc/styles.dart deleted file mode 100644 index d3dee3f97a031..0000000000000 --- a/frontend/app_flowy/lib/plugins/doc/styles.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:app_flowy/plugins/doc/presentation/style_widgets.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_quill/flutter_quill.dart'; -import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; -import 'package:flowy_infra/theme.dart'; - -DefaultStyles customStyles(BuildContext context) { - const baseSpacing = Tuple2(6, 0); - - final theme = context.watch(); - final themeData = theme.themeData; - final fontFamily = makeFontFamily(themeData); - - final defaultTextStyle = DefaultTextStyle.of(context); - final baseStyle = defaultTextStyle.style.copyWith( - fontSize: 18, - height: 1.3, - fontWeight: FontWeight.w300, - letterSpacing: 0.6, - fontFamily: fontFamily, - ); - - return DefaultStyles( - h1: DefaultTextBlockStyle( - defaultTextStyle.style.copyWith( - fontSize: 34, - color: defaultTextStyle.style.color!.withOpacity(0.70), - height: 1.15, - fontWeight: FontWeight.w300, - ), - const Tuple2(16, 0), - const Tuple2(0, 0), - null), - h2: DefaultTextBlockStyle( - defaultTextStyle.style.copyWith( - fontSize: 24, - color: defaultTextStyle.style.color!.withOpacity(0.70), - height: 1.15, - fontWeight: FontWeight.normal, - ), - const Tuple2(8, 0), - const Tuple2(0, 0), - null), - h3: DefaultTextBlockStyle( - defaultTextStyle.style.copyWith( - fontSize: 20, - color: defaultTextStyle.style.color!.withOpacity(0.70), - height: 1.25, - fontWeight: FontWeight.w500, - ), - const Tuple2(8, 0), - const Tuple2(0, 0), - null), - paragraph: DefaultTextBlockStyle( - baseStyle, const Tuple2(10, 0), const Tuple2(0, 0), null), - bold: const TextStyle(fontWeight: FontWeight.bold), - italic: const TextStyle(fontStyle: FontStyle.italic), - small: const TextStyle(fontSize: 12, color: Colors.black45), - underline: const TextStyle(decoration: TextDecoration.underline), - strikeThrough: const TextStyle(decoration: TextDecoration.lineThrough), - inlineCode: TextStyle( - color: Colors.blue.shade900.withOpacity(0.9), - fontFamily: fontFamily, - fontSize: 13, - ), - link: TextStyle( - color: themeData.colorScheme.secondary, - decoration: TextDecoration.underline, - ), - color: theme.textColor, - placeHolder: DefaultTextBlockStyle( - defaultTextStyle.style.copyWith( - fontSize: 20, - height: 1.5, - color: Colors.grey.withOpacity(0.6), - ), - const Tuple2(0, 0), - const Tuple2(0, 0), - null), - lists: DefaultListBlockStyle(baseStyle, baseSpacing, const Tuple2(0, 6), - null, StyleWidgetBuilder.checkbox(theme)), - quote: DefaultTextBlockStyle( - TextStyle(color: baseStyle.color!.withOpacity(0.6)), - baseSpacing, - const Tuple2(6, 2), - BoxDecoration( - border: Border( - left: BorderSide(width: 4, color: theme.shader5), - ), - )), - code: DefaultTextBlockStyle( - TextStyle( - color: Colors.blue.shade900.withOpacity(0.9), - fontFamily: fontFamily, - fontSize: 13, - height: 1.15, - ), - baseSpacing, - const Tuple2(0, 0), - BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(2), - )), - indent: DefaultTextBlockStyle( - baseStyle, baseSpacing, const Tuple2(0, 6), null), - align: DefaultTextBlockStyle( - baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), - leading: DefaultTextBlockStyle( - baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), - sizeSmall: const TextStyle(fontSize: 10), - sizeLarge: const TextStyle(fontSize: 18), - sizeHuge: const TextStyle(fontSize: 22)); -} - -String makeFontFamily(ThemeData themeData) { - String fontFamily; - switch (themeData.platform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - fontFamily = 'Mulish'; - break; - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.windows: - case TargetPlatform.linux: - fontFamily = 'Roboto Mono'; - break; - default: - throw UnimplementedError(); - } - return fontFamily; -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/block/block_cache.dart b/frontend/app_flowy/lib/plugins/grid/application/block/block_cache.dart deleted file mode 100644 index b39226f0beda4..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/block/block_cache.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:async'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; - -import '../field/field_controller.dart'; -import '../row/row_cache.dart'; -import 'block_listener.dart'; - -/// Read https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/frontend/grid for more information -class GridBlockCache { - final String gridId; - final BlockPB block; - late GridRowCache _rowCache; - late GridBlockListener _listener; - - List get rows => _rowCache.rows; - GridRowCache get rowCache => _rowCache; - - GridBlockCache({ - required this.gridId, - required this.block, - required GridFieldController fieldController, - }) { - _rowCache = GridRowCache( - gridId: gridId, - block: block, - notifier: GridRowFieldNotifierImpl(fieldController), - ); - - _listener = GridBlockListener(blockId: block.id); - _listener.start((result) { - result.fold( - (changesets) => _rowCache.applyChangesets(changesets), - (err) => Log.error(err), - ); - }); - } - - Future dispose() async { - await _listener.stop(); - await _rowCache.dispose(); - } - - void addListener({ - required void Function(RowsChangedReason) onRowsChanged, - bool Function()? listenWhen, - }) { - _rowCache.onRowsChanged((reason) { - if (listenWhen != null && listenWhen() == false) { - return; - } - - onRowsChanged(reason); - }); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/block/block_listener.dart b/frontend/app_flowy/lib/plugins/grid/application/block/block_listener.dart deleted file mode 100644 index 91f93c61fe129..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/block/block_listener.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; -import 'package:app_flowy/core/grid_notification.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/dart_notification.pb.dart'; - -typedef GridBlockUpdateNotifierValue = Either, FlowyError>; - -class GridBlockListener { - final String blockId; - PublishNotifier? _rowsUpdateNotifier = PublishNotifier(); - GridNotificationListener? _listener; - - GridBlockListener({required this.blockId}); - - void start(void Function(GridBlockUpdateNotifierValue) onBlockChanged) { - if (_listener != null) { - _listener?.stop(); - } - - _listener = GridNotificationListener( - objectId: blockId, - handler: _handler, - ); - - _rowsUpdateNotifier?.addPublishListener(onBlockChanged); - } - - void _handler(GridNotification ty, Either result) { - switch (ty) { - case GridNotification.DidUpdateGridBlock: - result.fold( - (payload) => _rowsUpdateNotifier?.value = left([GridBlockChangesetPB.fromBuffer(payload)]), - (error) => _rowsUpdateNotifier?.value = right(error), - ); - break; - - default: - break; - } - } - - Future stop() async { - await _listener?.stop(); - _rowsUpdateNotifier?.dispose(); - _rowsUpdateNotifier = null; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_listener.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_listener.dart deleted file mode 100644 index 4805ad8b7a9c4..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_listener.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:app_flowy/core/grid_notification.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/dart_notification.pb.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'dart:async'; -import 'dart:typed_data'; - -typedef UpdateFieldNotifiedValue = Either; - -class CellListener { - final String rowId; - final String fieldId; - PublishNotifier? _updateCellNotifier = - PublishNotifier(); - GridNotificationListener? _listener; - CellListener({required this.rowId, required this.fieldId}); - - void start({required void Function(UpdateFieldNotifiedValue) onCellChanged}) { - _updateCellNotifier?.addPublishListener(onCellChanged); - _listener = GridNotificationListener( - objectId: "$rowId:$fieldId", handler: _handler); - } - - void _handler(GridNotification ty, Either result) { - switch (ty) { - case GridNotification.DidUpdateCell: - result.fold( - (payload) => _updateCellNotifier?.value = left(unit), - (error) => _updateCellNotifier?.value = right(error), - ); - break; - default: - break; - } - } - - Future stop() async { - await _listener?.stop(); - _updateCellNotifier?.dispose(); - _updateCellNotifier = null; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_cache.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_cache.dart deleted file mode 100644 index 3d816b21d12d8..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_cache.dart +++ /dev/null @@ -1,77 +0,0 @@ -part of 'cell_service.dart'; - -typedef GridCellMap = LinkedHashMap; - -class GridCell { - dynamic object; - GridCell({ - required this.object, - }); -} - -/// Use to index the cell in the grid. -/// We use [fieldId + rowId] to identify the cell. -class GridCellCacheKey { - final String fieldId; - final String rowId; - GridCellCacheKey({ - required this.fieldId, - required this.rowId, - }); -} - -/// GridCellCache is used to cache cell data of each block. -/// We use GridCellCacheKey to index the cell in the cache. -/// Read https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/frontend/grid -/// for more information -class GridCellCache { - final String gridId; - - /// fieldId: {cacheKey: GridCell} - final Map> _cellDataByFieldId = {}; - GridCellCache({ - required this.gridId, - }); - - void removeCellWithFieldId(String fieldId) { - _cellDataByFieldId.remove(fieldId); - } - - void remove(GridCellCacheKey key) { - var map = _cellDataByFieldId[key.fieldId]; - if (map != null) { - map.remove(key.rowId); - } - } - - void insert(GridCellCacheKey key, T value) { - var map = _cellDataByFieldId[key.fieldId]; - if (map == null) { - _cellDataByFieldId[key.fieldId] = {}; - map = _cellDataByFieldId[key.fieldId]; - } - - map![key.rowId] = value.object; - } - - T? get(GridCellCacheKey key) { - final map = _cellDataByFieldId[key.fieldId]; - if (map == null) { - return null; - } else { - final value = map[key.rowId]; - if (value is T) { - return value; - } else { - if (value != null) { - Log.error("Expected value type: $T, but receive $value"); - } - return null; - } - } - } - - Future dispose() async { - _cellDataByFieldId.clear(); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_controller.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_controller.dart deleted file mode 100644 index 7a43378eb887b..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_controller.dart +++ /dev/null @@ -1,339 +0,0 @@ -part of 'cell_service.dart'; - -typedef GridCellController = IGridCellController; -typedef GridCheckboxCellController = IGridCellController; -typedef GridNumberCellController = IGridCellController; -typedef GridSelectOptionCellController - = IGridCellController; -typedef GridDateCellController - = IGridCellController; -typedef GridURLCellController = IGridCellController; - -abstract class GridCellControllerBuilderDelegate { - GridCellFieldNotifier buildFieldNotifier(); -} - -class GridCellControllerBuilder { - final GridCellIdentifier _cellId; - final GridCellCache _cellCache; - final GridCellControllerBuilderDelegate delegate; - - GridCellControllerBuilder({ - required this.delegate, - required GridCellIdentifier cellId, - required GridCellCache cellCache, - }) : _cellCache = cellCache, - _cellId = cellId; - - IGridCellController build() { - final cellFieldNotifier = delegate.buildFieldNotifier(); - switch (_cellId.fieldType) { - case FieldType.Checkbox: - final cellDataLoader = GridCellDataLoader( - cellId: _cellId, - parser: StringCellDataParser(), - ); - return GridCellController( - cellId: _cellId, - cellCache: _cellCache, - cellDataLoader: cellDataLoader, - fieldNotifier: cellFieldNotifier, - cellDataPersistence: CellDataPersistence(cellId: _cellId), - ); - case FieldType.DateTime: - final cellDataLoader = GridCellDataLoader( - cellId: _cellId, - parser: DateCellDataParser(), - reloadOnFieldChanged: true, - ); - - return GridDateCellController( - cellId: _cellId, - cellCache: _cellCache, - cellDataLoader: cellDataLoader, - fieldNotifier: cellFieldNotifier, - cellDataPersistence: DateCellDataPersistence(cellId: _cellId), - ); - case FieldType.Number: - final cellDataLoader = GridCellDataLoader( - cellId: _cellId, - parser: StringCellDataParser(), - reloadOnFieldChanged: true, - ); - return GridNumberCellController( - cellId: _cellId, - cellCache: _cellCache, - cellDataLoader: cellDataLoader, - fieldNotifier: cellFieldNotifier, - cellDataPersistence: CellDataPersistence(cellId: _cellId), - ); - case FieldType.RichText: - final cellDataLoader = GridCellDataLoader( - cellId: _cellId, - parser: StringCellDataParser(), - ); - return GridCellController( - cellId: _cellId, - cellCache: _cellCache, - cellDataLoader: cellDataLoader, - fieldNotifier: cellFieldNotifier, - cellDataPersistence: CellDataPersistence(cellId: _cellId), - ); - case FieldType.MultiSelect: - case FieldType.SingleSelect: - final cellDataLoader = GridCellDataLoader( - cellId: _cellId, - parser: SelectOptionCellDataParser(), - reloadOnFieldChanged: true, - ); - - return GridSelectOptionCellController( - cellId: _cellId, - cellCache: _cellCache, - cellDataLoader: cellDataLoader, - fieldNotifier: cellFieldNotifier, - cellDataPersistence: CellDataPersistence(cellId: _cellId), - ); - - case FieldType.URL: - final cellDataLoader = GridCellDataLoader( - cellId: _cellId, - parser: URLCellDataParser(), - ); - return GridURLCellController( - cellId: _cellId, - cellCache: _cellCache, - cellDataLoader: cellDataLoader, - fieldNotifier: cellFieldNotifier, - cellDataPersistence: CellDataPersistence(cellId: _cellId), - ); - } - throw UnimplementedError; - } -} - -/// IGridCellController is used to manipulate the cell and receive notifications. -/// * Read/Write cell data -/// * Listen on field/cell notifications. -/// -/// Generic T represents the type of the cell data. -/// Generic D represents the type of data that will be saved to the disk -/// -// ignore: must_be_immutable -class IGridCellController extends Equatable { - final GridCellIdentifier cellId; - final GridCellCache _cellsCache; - final GridCellCacheKey _cacheKey; - final FieldService _fieldService; - final GridCellFieldNotifier _fieldNotifier; - final GridCellDataLoader _cellDataLoader; - final IGridCellDataPersistence _cellDataPersistence; - - CellListener? _cellListener; - ValueNotifier? _cellDataNotifier; - - bool isListening = false; - VoidCallback? _onFieldChangedFn; - Timer? _loadDataOperation; - Timer? _saveDataOperation; - bool _isDispose = false; - - IGridCellController({ - required this.cellId, - required GridCellCache cellCache, - required GridCellFieldNotifier fieldNotifier, - required GridCellDataLoader cellDataLoader, - required IGridCellDataPersistence cellDataPersistence, - }) : _cellsCache = cellCache, - _cellDataLoader = cellDataLoader, - _cellDataPersistence = cellDataPersistence, - _fieldNotifier = fieldNotifier, - _fieldService = FieldService( - gridId: cellId.gridId, - fieldId: cellId.fieldContext.id, - ), - _cacheKey = GridCellCacheKey( - rowId: cellId.rowId, - fieldId: cellId.fieldContext.id, - ); - - String get gridId => cellId.gridId; - - String get rowId => cellId.rowId; - - String get fieldId => cellId.fieldContext.id; - - GridFieldContext get fieldContext => cellId.fieldContext; - - FieldType get fieldType => cellId.fieldContext.fieldType; - - VoidCallback? startListening({ - required void Function(T?) onCellChanged, - VoidCallback? onCellFieldChanged, - }) { - if (isListening) { - Log.error("Already started. It seems like you should call clone first"); - return null; - } - isListening = true; - - _cellDataNotifier = ValueNotifier(_cellsCache.get(_cacheKey)); - _cellListener = - CellListener(rowId: cellId.rowId, fieldId: cellId.fieldContext.id); - - /// 1.Listen on user edit event and load the new cell data if needed. - /// For example: - /// user input: 12 - /// cell display: $12 - _cellListener?.start(onCellChanged: (result) { - result.fold( - (_) { - _cellsCache.remove(_cacheKey); - _loadData(); - }, - (err) => Log.error(err), - ); - }); - - /// 2.Listen on the field event and load the cell data if needed. - _onFieldChangedFn = () { - if (onCellFieldChanged != null) { - onCellFieldChanged(); - } - - /// reloadOnFieldChanged should be true if you need to load the data when the corresponding field is changed - /// For example: - /// ¥12 -> $12 - if (_cellDataLoader.reloadOnFieldChanged) { - _loadData(); - } - }; - - _fieldNotifier.register(_cacheKey, _onFieldChangedFn!); - - /// Notify the listener, the cell data was changed. - onCellChangedFn() => onCellChanged(_cellDataNotifier?.value); - _cellDataNotifier?.addListener(onCellChangedFn); - - // Return the function pointer that can be used when calling removeListener. - return onCellChangedFn; - } - - void removeListener(VoidCallback fn) { - _cellDataNotifier?.removeListener(fn); - } - - /// Return the cell data. - /// The cell data will be read from the Cache first, and load from disk if it does not exist. - /// You can set [loadIfNotExist] to false (default is true) to disable loading the cell data. - T? getCellData({bool loadIfNotExist = true}) { - final data = _cellsCache.get(_cacheKey); - if (data == null && loadIfNotExist) { - _loadData(); - } - return data; - } - - /// Return the FieldTypeOptionDataPB that can be parsed into corresponding class using the [parser]. - /// [PD] is the type that the parser return. - Future> - getFieldTypeOption(P parser) { - return _fieldService - .getFieldTypeOptionData(fieldType: fieldType) - .then((result) { - return result.fold( - (data) => left(parser.fromBuffer(data.typeOptionData)), - (err) => right(err), - ); - }); - } - - /// Save the cell data to disk - /// You can set [deduplicate] to true (default is false) to reduce the save operation. - /// It's useful when you call this method when user editing the [TextField]. - /// The default debounce interval is 300 milliseconds. - void saveCellData(D data, - {bool deduplicate = false, - void Function(Option)? resultCallback}) async { - if (deduplicate) { - _loadDataOperation?.cancel(); - - _saveDataOperation?.cancel(); - _saveDataOperation = Timer(const Duration(milliseconds: 300), () async { - final result = await _cellDataPersistence.save(data); - if (resultCallback != null) { - resultCallback(result); - } - }); - } else { - final result = await _cellDataPersistence.save(data); - if (resultCallback != null) { - resultCallback(result); - } - } - } - - void _loadData() { - _saveDataOperation?.cancel(); - - _loadDataOperation?.cancel(); - _loadDataOperation = Timer(const Duration(milliseconds: 10), () { - _cellDataLoader.loadData().then((data) { - if (data != null) { - _cellsCache.insert(_cacheKey, GridCell(object: data)); - } else { - _cellsCache.remove(_cacheKey); - } - - _cellDataNotifier?.value = data; - }); - }); - } - - void dispose() { - if (_isDispose) { - Log.error("$this should only dispose once"); - return; - } - _isDispose = true; - _cellListener?.stop(); - _loadDataOperation?.cancel(); - _saveDataOperation?.cancel(); - _cellDataNotifier = null; - - if (_onFieldChangedFn != null) { - _fieldNotifier.unregister(_cacheKey, _onFieldChangedFn!); - _fieldNotifier.dispose(); - _onFieldChangedFn = null; - } - } - - @override - List get props => - [_cellsCache.get(_cacheKey) ?? "", cellId.rowId + cellId.fieldContext.id]; -} - -class GridCellFieldNotifierImpl extends IGridCellFieldNotifier { - final GridFieldController _cache; - OnChangeset? _onChangesetFn; - - GridCellFieldNotifierImpl(GridFieldController cache) : _cache = cache; - - @override - void onCellDispose() { - if (_onChangesetFn != null) { - _cache.removeListener(onChangesetListener: _onChangesetFn!); - _onChangesetFn = null; - } - } - - @override - void onCellFieldChanged(void Function(FieldPB p1) callback) { - _onChangesetFn = (FieldChangesetPB changeset) { - for (final updatedField in changeset.updatedFields) { - callback(updatedField); - } - }; - _cache.addListener(onChangeset: _onChangesetFn); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_data_loader.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_data_loader.dart deleted file mode 100644 index a6a1ba43a92e4..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_data_loader.dart +++ /dev/null @@ -1,83 +0,0 @@ -part of 'cell_service.dart'; - -abstract class IGridCellDataConfig { - // The cell data will reload if it receives the field's change notification. - bool get reloadOnFieldChanged; -} - -abstract class IGridCellDataParser { - T? parserData(List data); -} - -class GridCellDataLoader { - final CellService service = CellService(); - final GridCellIdentifier cellId; - final IGridCellDataParser parser; - final bool reloadOnFieldChanged; - - GridCellDataLoader({ - required this.cellId, - required this.parser, - this.reloadOnFieldChanged = false, - }); - - Future loadData() { - final fut = service.getCell(cellId: cellId); - return fut.then( - (result) => result.fold( - (GridCellPB cell) { - try { - return parser.parserData(cell.data); - } catch (e, s) { - Log.error('$parser parser cellData failed, $e'); - Log.error('Stack trace \n $s'); - return null; - } - }, - (err) { - Log.error(err); - return null; - }, - ), - ); - } -} - -class StringCellDataParser implements IGridCellDataParser { - @override - String? parserData(List data) { - final s = utf8.decode(data); - return s; - } -} - -class DateCellDataParser implements IGridCellDataParser { - @override - DateCellDataPB? parserData(List data) { - if (data.isEmpty) { - return null; - } - return DateCellDataPB.fromBuffer(data); - } -} - -class SelectOptionCellDataParser - implements IGridCellDataParser { - @override - SelectOptionCellDataPB? parserData(List data) { - if (data.isEmpty) { - return null; - } - return SelectOptionCellDataPB.fromBuffer(data); - } -} - -class URLCellDataParser implements IGridCellDataParser { - @override - URLCellDataPB? parserData(List data) { - if (data.isEmpty) { - return null; - } - return URLCellDataPB.fromBuffer(data); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_data_persistence.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_data_persistence.dart deleted file mode 100644 index 71927bae140d9..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_data_persistence.dart +++ /dev/null @@ -1,66 +0,0 @@ -part of 'cell_service.dart'; - -/// Save the cell data to disk -/// You can extend this class to do custom operations. For example, the DateCellDataPersistence. -abstract class IGridCellDataPersistence { - Future> save(D data); -} - -class CellDataPersistence implements IGridCellDataPersistence { - final GridCellIdentifier cellId; - - CellDataPersistence({ - required this.cellId, - }); - final CellService _cellService = CellService(); - - @override - Future> save(String data) async { - final fut = _cellService.updateCell(cellId: cellId, data: data); - - return fut.then((result) { - return result.fold( - (l) => none(), - (err) => Some(err), - ); - }); - } -} - -@freezed -class CalendarData with _$CalendarData { - const factory CalendarData({required DateTime date, String? time}) = _CalendarData; -} - -class DateCellDataPersistence implements IGridCellDataPersistence { - final GridCellIdentifier cellId; - DateCellDataPersistence({ - required this.cellId, - }); - - @override - Future> save(CalendarData data) { - var payload = DateChangesetPayloadPB.create()..cellIdentifier = _makeCellIdPayload(cellId); - - final date = (data.date.millisecondsSinceEpoch ~/ 1000).toString(); - payload.date = date; - - if (data.time != null) { - payload.time = data.time!; - } - - return GridEventUpdateDateCell(payload).send().then((result) { - return result.fold( - (l) => none(), - (err) => Some(err), - ); - }); - } -} - -GridCellIdPB _makeCellIdPayload(GridCellIdentifier cellId) { - return GridCellIdPB.create() - ..gridId = cellId.gridId - ..fieldId = cellId.fieldId - ..rowId = cellId.rowId; -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_field_notifier.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_field_notifier.dart deleted file mode 100644 index d4cca373bc376..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_field_notifier.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter/foundation.dart'; - -import 'cell_service.dart'; - -abstract class IGridCellFieldNotifier { - void onCellFieldChanged(void Function(FieldPB) callback); - void onCellDispose(); -} - -/// GridPB's cell helper wrapper that enables each cell will get notified when the corresponding field was changed. -/// You Register an onFieldChanged callback to listen to the cell changes, and unregister if you don't want to listen. -class GridCellFieldNotifier { - final IGridCellFieldNotifier notifier; - - /// fieldId: {objectId: callback} - final Map>> _fieldListenerByFieldId = - {}; - - GridCellFieldNotifier({required this.notifier}) { - notifier.onCellFieldChanged( - (field) { - final map = _fieldListenerByFieldId[field.id]; - if (map != null) { - for (final callbacks in map.values) { - for (final callback in callbacks) { - callback(); - } - } - } - }, - ); - } - - /// - void register(GridCellCacheKey cacheKey, VoidCallback onFieldChanged) { - var map = _fieldListenerByFieldId[cacheKey.fieldId]; - if (map == null) { - _fieldListenerByFieldId[cacheKey.fieldId] = {}; - map = _fieldListenerByFieldId[cacheKey.fieldId]; - map![cacheKey.rowId] = [onFieldChanged]; - } else { - var objects = map[cacheKey.rowId]; - if (objects == null) { - map[cacheKey.rowId] = [onFieldChanged]; - } else { - objects.add(onFieldChanged); - } - } - } - - void unregister(GridCellCacheKey cacheKey, VoidCallback fn) { - var callbacks = _fieldListenerByFieldId[cacheKey.fieldId]?[cacheKey.rowId]; - final index = callbacks?.indexWhere((callback) => callback == fn); - if (index != null && index != -1) { - callbacks?.removeAt(index); - } - } - - Future dispose() async { - notifier.onCellDispose(); - _fieldListenerByFieldId.clear(); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_service.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_service.dart deleted file mode 100644 index 759b7a1ed7c1a..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_service.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; -import 'package:dartz/dartz.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/cell_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/url_type_option_entities.pb.dart'; -import 'package:flutter/foundation.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:app_flowy/plugins/grid/application/cell/cell_listener.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; -import 'dart:convert' show utf8; - -import '../../field/field_controller.dart'; -import '../../field/type_option/type_option_context.dart'; -import 'cell_field_notifier.dart'; -part 'cell_service.freezed.dart'; -part 'cell_data_loader.dart'; -part 'cell_controller.dart'; -part 'cell_cache.dart'; -part 'cell_data_persistence.dart'; - -// key: rowId - -class CellService { - CellService(); - - Future> updateCell({ - required GridCellIdentifier cellId, - required String data, - }) { - final payload = CellChangesetPB.create() - ..gridId = cellId.gridId - ..fieldId = cellId.fieldId - ..rowId = cellId.rowId - ..content = data; - return GridEventUpdateCell(payload).send(); - } - - Future> getCell({ - required GridCellIdentifier cellId, - }) { - final payload = GridCellIdPB.create() - ..gridId = cellId.gridId - ..fieldId = cellId.fieldId - ..rowId = cellId.rowId; - return GridEventGetCell(payload).send(); - } -} - -/// Id of the cell -/// We can locate the cell by using gridId + rowId + field.id. -@freezed -class GridCellIdentifier with _$GridCellIdentifier { - const factory GridCellIdentifier({ - required String gridId, - required String rowId, - required GridFieldContext fieldContext, - }) = _GridCellIdentifier; - - // ignore: unused_element - const GridCellIdentifier._(); - - String get fieldId => fieldContext.id; - - FieldType get fieldType => fieldContext.fieldType; - - ValueKey key() { - return ValueKey("$rowId$fieldId${fieldContext.fieldType}"); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/checkbox_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/checkbox_cell_bloc.dart deleted file mode 100644 index 5f7aee108e665..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/checkbox_cell_bloc.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'cell_service/cell_service.dart'; - -part 'checkbox_cell_bloc.freezed.dart'; - -class CheckboxCellBloc extends Bloc { - final GridCheckboxCellController cellController; - void Function()? _onCellChangedFn; - - CheckboxCellBloc({ - required CellService service, - required this.cellController, - }) : super(CheckboxCellState.initial(cellController)) { - on( - (event, emit) async { - await event.when( - initial: () { - _startListening(); - }, - select: () async { - cellController.saveCellData(!state.isSelected ? "Yes" : "No"); - }, - didReceiveCellUpdate: (cellData) { - emit(state.copyWith(isSelected: _isSelected(cellData))); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = - cellController.startListening(onCellChanged: ((cellData) { - if (!isClosed) { - add(CheckboxCellEvent.didReceiveCellUpdate(cellData)); - } - })); - } -} - -@freezed -class CheckboxCellEvent with _$CheckboxCellEvent { - const factory CheckboxCellEvent.initial() = _Initial; - const factory CheckboxCellEvent.select() = _Selected; - const factory CheckboxCellEvent.didReceiveCellUpdate(String? cellData) = - _DidReceiveCellUpdate; -} - -@freezed -class CheckboxCellState with _$CheckboxCellState { - const factory CheckboxCellState({ - required bool isSelected, - }) = _CheckboxCellState; - - factory CheckboxCellState.initial(GridCellController context) { - return CheckboxCellState(isSelected: _isSelected(context.getCellData())); - } -} - -bool _isSelected(String? cellData) { - return cellData == "Yes"; -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/date_cal_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/date_cal_bloc.dart deleted file mode 100644 index 0deee8098ccab..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/date_cal_bloc.dart +++ /dev/null @@ -1,272 +0,0 @@ -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; -import 'package:easy_localization/easy_localization.dart' - show StringTranslateExtension; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-error-code/code.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:table_calendar/table_calendar.dart'; -import 'dart:async'; -import 'cell_service/cell_service.dart'; -import 'package:dartz/dartz.dart'; -import 'package:protobuf/protobuf.dart'; -import 'package:fixnum/fixnum.dart' as $fixnum; -part 'date_cal_bloc.freezed.dart'; - -class DateCalBloc extends Bloc { - final GridDateCellController cellController; - void Function()? _onCellChangedFn; - - DateCalBloc({ - required DateTypeOptionPB dateTypeOptionPB, - required DateCellDataPB? cellData, - required this.cellController, - }) : super(DateCalState.initial(dateTypeOptionPB, cellData)) { - on( - (event, emit) async { - await event.when( - initial: () async => _startListening(), - selectDay: (date) async { - await _updateDateData(emit, date: date, time: state.time); - }, - setCalFormat: (format) { - emit(state.copyWith(format: format)); - }, - setFocusedDay: (focusedDay) { - emit(state.copyWith(focusedDay: focusedDay)); - }, - didReceiveCellUpdate: (DateCellDataPB? cellData) { - final calData = calDataFromCellData(cellData); - final time = - calData.foldRight("", (dateData, previous) => dateData.time); - emit(state.copyWith(calData: calData, time: time)); - }, - setIncludeTime: (includeTime) async { - await _updateTypeOption(emit, includeTime: includeTime); - }, - setDateFormat: (dateFormat) async { - await _updateTypeOption(emit, dateFormat: dateFormat); - }, - setTimeFormat: (timeFormat) async { - await _updateTypeOption(emit, timeFormat: timeFormat); - }, - setTime: (time) async { - if (state.calData.isSome()) { - await _updateDateData(emit, time: time); - } - }, - didUpdateCalData: - (Option data, Option timeFormatError) { - emit(state.copyWith( - calData: data, timeFormatError: timeFormatError)); - }, - ); - }, - ); - } - - Future _updateDateData(Emitter emit, - {DateTime? date, String? time}) { - final CalendarData newDateData = state.calData.fold( - () => CalendarData(date: date ?? DateTime.now(), time: time), - (dateData) { - var newDateData = dateData; - if (date != null && !isSameDay(newDateData.date, date)) { - newDateData = newDateData.copyWith(date: date); - } - - if (newDateData.time != time) { - newDateData = newDateData.copyWith(time: time); - } - return newDateData; - }, - ); - - return _saveDateData(emit, newDateData); - } - - Future _saveDateData( - Emitter emit, CalendarData newCalData) async { - if (state.calData == Some(newCalData)) { - return; - } - - updateCalData( - Option calData, Option timeFormatError) { - if (!isClosed) { - add(DateCalEvent.didUpdateCalData(calData, timeFormatError)); - } - } - - cellController.saveCellData(newCalData, resultCallback: (result) { - result.fold( - () => updateCalData(Some(newCalData), none()), - (err) { - switch (ErrorCode.valueOf(err.code)!) { - case ErrorCode.InvalidDateTimeFormat: - updateCalData(state.calData, Some(timeFormatPrompt(err))); - break; - default: - Log.error(err); - } - }, - ); - }); - } - - String timeFormatPrompt(FlowyError error) { - String msg = "${LocaleKeys.grid_field_invalidTimeFormat.tr()}."; - switch (state.dateTypeOptionPB.timeFormat) { - case TimeFormat.TwelveHour: - msg = "$msg e.g. 01:00 PM"; - break; - case TimeFormat.TwentyFourHour: - msg = "$msg e.g. 13:00"; - break; - default: - break; - } - return msg; - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = cellController.startListening( - onCellChanged: ((cell) { - if (!isClosed) { - add(DateCalEvent.didReceiveCellUpdate(cell)); - } - }), - ); - } - - Future? _updateTypeOption( - Emitter emit, { - DateFormat? dateFormat, - TimeFormat? timeFormat, - bool? includeTime, - }) async { - state.dateTypeOptionPB.freeze(); - final newDateTypeOption = state.dateTypeOptionPB.rebuild((typeOption) { - if (dateFormat != null) { - typeOption.dateFormat = dateFormat; - } - - if (timeFormat != null) { - typeOption.timeFormat = timeFormat; - } - - if (includeTime != null) { - typeOption.includeTime = includeTime; - } - }); - - final result = await FieldService.updateFieldTypeOption( - gridId: cellController.gridId, - fieldId: cellController.fieldContext.id, - typeOptionData: newDateTypeOption.writeToBuffer(), - ); - - result.fold( - (l) => emit(state.copyWith( - dateTypeOptionPB: newDateTypeOption, - timeHintText: _timeHintText(newDateTypeOption))), - (err) => Log.error(err), - ); - } -} - -@freezed -class DateCalEvent with _$DateCalEvent { - const factory DateCalEvent.initial() = _Initial; - const factory DateCalEvent.selectDay(DateTime day) = _SelectDay; - const factory DateCalEvent.setCalFormat(CalendarFormat format) = - _CalendarFormat; - const factory DateCalEvent.setFocusedDay(DateTime day) = _FocusedDay; - const factory DateCalEvent.setTimeFormat(TimeFormat timeFormat) = _TimeFormat; - const factory DateCalEvent.setDateFormat(DateFormat dateFormat) = _DateFormat; - const factory DateCalEvent.setIncludeTime(bool includeTime) = _IncludeTime; - const factory DateCalEvent.setTime(String time) = _Time; - const factory DateCalEvent.didReceiveCellUpdate(DateCellDataPB? data) = - _DidReceiveCellUpdate; - const factory DateCalEvent.didUpdateCalData( - Option data, Option timeFormatError) = - _DidUpdateCalData; -} - -@freezed -class DateCalState with _$DateCalState { - const factory DateCalState({ - required DateTypeOptionPB dateTypeOptionPB, - required CalendarFormat format, - required DateTime focusedDay, - required Option timeFormatError, - required Option calData, - required String? time, - required String timeHintText, - }) = _DateCalState; - - factory DateCalState.initial( - DateTypeOptionPB dateTypeOptionPB, - DateCellDataPB? cellData, - ) { - Option calData = calDataFromCellData(cellData); - final time = calData.foldRight("", (dateData, previous) => dateData.time); - return DateCalState( - dateTypeOptionPB: dateTypeOptionPB, - format: CalendarFormat.month, - focusedDay: DateTime.now(), - time: time, - calData: calData, - timeFormatError: none(), - timeHintText: _timeHintText(dateTypeOptionPB), - ); - } -} - -String _timeHintText(DateTypeOptionPB typeOption) { - switch (typeOption.timeFormat) { - case TimeFormat.TwelveHour: - return LocaleKeys.document_date_timeHintTextInTwelveHour.tr(); - case TimeFormat.TwentyFourHour: - return LocaleKeys.document_date_timeHintTextInTwentyFourHour.tr(); - } - return ""; -} - -Option calDataFromCellData(DateCellDataPB? cellData) { - String? time = timeFromCellData(cellData); - Option calData = none(); - if (cellData != null) { - final timestamp = cellData.timestamp * 1000; - final date = DateTime.fromMillisecondsSinceEpoch(timestamp.toInt()); - calData = Some(CalendarData(date: date, time: time)); - } - return calData; -} - -$fixnum.Int64 timestampFromDateTime(DateTime dateTime) { - final timestamp = (dateTime.millisecondsSinceEpoch ~/ 1000); - return $fixnum.Int64(timestamp); -} - -String? timeFromCellData(DateCellDataPB? cellData) { - String? time; - if (cellData?.hasTime() ?? false) { - time = cellData?.time; - } - return time; -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/date_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/date_cell_bloc.dart deleted file mode 100644 index e44e15a2fafd7..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/date_cell_bloc.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'cell_service/cell_service.dart'; -part 'date_cell_bloc.freezed.dart'; - -class DateCellBloc extends Bloc { - final GridDateCellController cellController; - void Function()? _onCellChangedFn; - - DateCellBloc({required this.cellController}) - : super(DateCellState.initial(cellController)) { - on( - (event, emit) async { - event.when( - initial: () => _startListening(), - didReceiveCellUpdate: (DateCellDataPB? cellData) { - emit(state.copyWith( - data: cellData, dateStr: _dateStrFromCellData(cellData))); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = cellController.startListening( - onCellChanged: ((data) { - if (!isClosed) { - add(DateCellEvent.didReceiveCellUpdate(data)); - } - }), - ); - } -} - -@freezed -class DateCellEvent with _$DateCellEvent { - const factory DateCellEvent.initial() = _InitialCell; - const factory DateCellEvent.didReceiveCellUpdate(DateCellDataPB? data) = - _DidReceiveCellUpdate; -} - -@freezed -class DateCellState with _$DateCellState { - const factory DateCellState({ - required DateCellDataPB? data, - required String dateStr, - required GridFieldContext fieldContext, - }) = _DateCellState; - - factory DateCellState.initial(GridDateCellController context) { - final cellData = context.getCellData(); - - return DateCellState( - fieldContext: context.fieldContext, - data: cellData, - dateStr: _dateStrFromCellData(cellData), - ); - } -} - -String _dateStrFromCellData(DateCellDataPB? cellData) { - String dateStr = ""; - if (cellData != null) { - dateStr = "${cellData.date} ${cellData.time}"; - } - return dateStr; -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/number_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/number_cell_bloc.dart deleted file mode 100644 index 2ca989289f943..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/number_cell_bloc.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'package:dartz/dartz.dart'; -import 'cell_service/cell_service.dart'; - -part 'number_cell_bloc.freezed.dart'; - -class NumberCellBloc extends Bloc { - final GridNumberCellController cellController; - void Function()? _onCellChangedFn; - - NumberCellBloc({ - required this.cellController, - }) : super(NumberCellState.initial(cellController)) { - on( - (event, emit) async { - event.when( - initial: () { - _startListening(); - }, - didReceiveCellUpdate: (content) { - emit(state.copyWith(content: content)); - }, - updateCell: (text) { - cellController.saveCellData(text, resultCallback: (result) { - result.fold( - () => null, - (err) { - if (!isClosed) { - add(NumberCellEvent.didReceiveCellUpdate(right(err))); - } - }, - ); - }); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = cellController.startListening( - onCellChanged: ((cellContent) { - if (!isClosed) { - add(NumberCellEvent.didReceiveCellUpdate(left(cellContent ?? ""))); - } - }), - ); - } -} - -@freezed -class NumberCellEvent with _$NumberCellEvent { - const factory NumberCellEvent.initial() = _Initial; - const factory NumberCellEvent.updateCell(String text) = _UpdateCell; - const factory NumberCellEvent.didReceiveCellUpdate( - Either cellContent) = _DidReceiveCellUpdate; -} - -@freezed -class NumberCellState with _$NumberCellState { - const factory NumberCellState({ - required Either content, - }) = _NumberCellState; - - factory NumberCellState.initial(GridCellController context) { - final cellContent = context.getCellData() ?? ""; - return NumberCellState( - content: left(cellContent), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_cell_bloc.dart deleted file mode 100644 index 8ca25a60508ec..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_cell_bloc.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:async'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; - -part 'select_option_cell_bloc.freezed.dart'; - -class SelectOptionCellBloc - extends Bloc { - final GridSelectOptionCellController cellController; - void Function()? _onCellChangedFn; - - SelectOptionCellBloc({ - required this.cellController, - }) : super(SelectOptionCellState.initial(cellController)) { - on( - (event, emit) async { - await event.map( - initial: (_InitialCell value) async { - _startListening(); - }, - didReceiveOptions: (_DidReceiveOptions value) { - emit(state.copyWith( - selectedOptions: value.selectedOptions, - )); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = cellController.startListening( - onCellChanged: ((selectOptionContext) { - if (!isClosed) { - add(SelectOptionCellEvent.didReceiveOptions( - selectOptionContext?.selectOptions ?? [], - )); - } - }), - ); - } -} - -@freezed -class SelectOptionCellEvent with _$SelectOptionCellEvent { - const factory SelectOptionCellEvent.initial() = _InitialCell; - const factory SelectOptionCellEvent.didReceiveOptions( - List selectedOptions, - ) = _DidReceiveOptions; -} - -@freezed -class SelectOptionCellState with _$SelectOptionCellState { - const factory SelectOptionCellState({ - required List selectedOptions, - }) = _SelectOptionCellState; - - factory SelectOptionCellState.initial( - GridSelectOptionCellController context) { - final data = context.getCellData(); - - return SelectOptionCellState( - selectedOptions: data?.selectOptions ?? [], - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_editor_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_editor_bloc.dart deleted file mode 100644 index 8b36560027c1b..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_editor_bloc.dart +++ /dev/null @@ -1,282 +0,0 @@ -import 'dart:async'; - -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import 'select_option_service.dart'; - -part 'select_option_editor_bloc.freezed.dart'; - -class SelectOptionCellEditorBloc - extends Bloc { - final SelectOptionService _selectOptionService; - final GridSelectOptionCellController cellController; - Timer? _delayOperation; - - SelectOptionCellEditorBloc({ - required this.cellController, - }) : _selectOptionService = - SelectOptionService(cellId: cellController.cellId), - super(SelectOptionEditorState.initial(cellController)) { - on( - (event, emit) async { - await event.map( - initial: (_Initial value) async { - _startListening(); - _loadOptions(); - }, - didReceiveOptions: (_DidReceiveOptions value) { - final result = _makeOptions(state.filter, value.options); - emit(state.copyWith( - allOptions: value.options, - options: result.options, - createOption: result.createOption, - selectedOptions: value.selectedOptions, - )); - }, - newOption: (_NewOption value) { - _createOption(value.optionName); - emit(state.copyWith( - filter: none(), - )); - }, - deleteOption: (_DeleteOption value) { - _deleteOption([value.option]); - }, - deleteAllOptions: (_DeleteAllOptions value) { - if (state.allOptions.isNotEmpty) { - _deleteOption(state.allOptions); - } - }, - updateOption: (_UpdateOption value) { - _updateOption(value.option); - }, - selectOption: (_SelectOption value) { - _selectOptionService.select(optionIds: [value.optionId]); - }, - unSelectOption: (_UnSelectOption value) { - _selectOptionService.unSelect(optionIds: [value.optionId]); - }, - trySelectOption: (_TrySelectOption value) { - _trySelectOption(value.optionName, emit); - }, - selectMultipleOptions: (_SelectMultipleOptions value) { - if (value.optionNames.isNotEmpty) { - _selectMultipleOptions(value.optionNames); - } - _filterOption(value.remainder, emit); - }, - filterOption: (_SelectOptionFilter value) { - _filterOption(value.optionName, emit); - }, - ); - }, - ); - } - - @override - Future close() async { - _delayOperation?.cancel(); - cellController.dispose(); - return super.close(); - } - - void _createOption(String name) async { - final result = await _selectOptionService.create(name: name); - result.fold((l) => {}, (err) => Log.error(err)); - } - - void _deleteOption(List options) async { - final result = await _selectOptionService.delete(options: options); - result.fold((l) => null, (err) => Log.error(err)); - } - - void _updateOption(SelectOptionPB option) async { - final result = await _selectOptionService.update( - option: option, - ); - - result.fold((l) => null, (err) => Log.error(err)); - } - - void _trySelectOption( - String optionName, Emitter emit) { - SelectOptionPB? matchingOption; - bool optionExistsButSelected = false; - - for (final option in state.options) { - if (option.name.toLowerCase() == optionName.toLowerCase()) { - if (!state.selectedOptions.contains(option)) { - matchingOption = option; - break; - } else { - optionExistsButSelected = true; - } - } - } - - // if there isn't a matching option at all, then create it - if (matchingOption == null && !optionExistsButSelected) { - _createOption(optionName); - } - - // if there is an unselected matching option, select it - if (matchingOption != null) { - _selectOptionService.select(optionIds: [matchingOption.id]); - } - - // clear the filter - emit(state.copyWith(filter: none())); - } - - void _selectMultipleOptions(List optionNames) { - // The options are unordered. So in order to keep the inserted [optionNames] - // order, it needs to get the option id in the [optionNames] order. - final lowerCaseNames = optionNames.map((e) => e.toLowerCase()); - final Map optionIdsMap = {}; - for (final option in state.options) { - optionIdsMap[option.name.toLowerCase()] = option.id; - } - - final optionIds = lowerCaseNames - .where((name) => optionIdsMap[name] != null) - .map((name) => optionIdsMap[name]!) - .toList(); - - _selectOptionService.select(optionIds: optionIds); - } - - void _filterOption(String optionName, Emitter emit) { - final _MakeOptionResult result = - _makeOptions(Some(optionName), state.allOptions); - emit(state.copyWith( - filter: Some(optionName), - options: result.options, - createOption: result.createOption, - )); - } - - void _loadOptions() { - _delayOperation?.cancel(); - _delayOperation = Timer(const Duration(milliseconds: 10), () { - _selectOptionService.getOptionContext().then((result) { - if (isClosed) { - return; - } - return result.fold( - (data) => add( - SelectOptionEditorEvent.didReceiveOptions( - data.options, data.selectOptions), - ), - (err) { - Log.error(err); - return null; - }, - ); - }); - }); - } - - _MakeOptionResult _makeOptions( - Option filter, List allOptions) { - final List options = List.from(allOptions); - Option createOption = filter; - - filter.foldRight(null, (filter, previous) { - if (filter.isNotEmpty) { - options.retainWhere((option) { - final name = option.name.toLowerCase(); - final lFilter = filter.toLowerCase(); - - if (name == lFilter) { - createOption = none(); - } - - return name.contains(lFilter); - }); - } else { - createOption = none(); - } - }); - - return _MakeOptionResult( - options: options, - createOption: createOption, - ); - } - - void _startListening() { - cellController.startListening( - onCellChanged: ((selectOptionContext) { - if (!isClosed) { - _loadOptions(); - } - }), - onCellFieldChanged: () { - _loadOptions(); - }, - ); - } -} - -@freezed -class SelectOptionEditorEvent with _$SelectOptionEditorEvent { - const factory SelectOptionEditorEvent.initial() = _Initial; - const factory SelectOptionEditorEvent.didReceiveOptions( - List options, List selectedOptions) = - _DidReceiveOptions; - const factory SelectOptionEditorEvent.newOption(String optionName) = - _NewOption; - const factory SelectOptionEditorEvent.selectOption(String optionId) = - _SelectOption; - const factory SelectOptionEditorEvent.unSelectOption(String optionId) = - _UnSelectOption; - const factory SelectOptionEditorEvent.updateOption(SelectOptionPB option) = - _UpdateOption; - const factory SelectOptionEditorEvent.deleteOption(SelectOptionPB option) = - _DeleteOption; - const factory SelectOptionEditorEvent.deleteAllOptions() = _DeleteAllOptions; - const factory SelectOptionEditorEvent.filterOption(String optionName) = - _SelectOptionFilter; - const factory SelectOptionEditorEvent.trySelectOption(String optionName) = - _TrySelectOption; - const factory SelectOptionEditorEvent.selectMultipleOptions( - List optionNames, String remainder) = _SelectMultipleOptions; -} - -@freezed -class SelectOptionEditorState with _$SelectOptionEditorState { - const factory SelectOptionEditorState({ - required List options, - required List allOptions, - required List selectedOptions, - required Option createOption, - required Option filter, - }) = _SelectOptionEditorState; - - factory SelectOptionEditorState.initial( - GridSelectOptionCellController context) { - final data = context.getCellData(loadIfNotExist: false); - return SelectOptionEditorState( - options: data?.options ?? [], - allOptions: data?.options ?? [], - selectedOptions: data?.selectOptions ?? [], - createOption: none(), - filter: none(), - ); - } -} - -class _MakeOptionResult { - List options; - Option createOption; - - _MakeOptionResult({ - required this.options, - required this.createOption, - }); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_service.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_service.dart deleted file mode 100644 index 0a182fbe1576b..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_service.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/cell_entities.pb.dart'; -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_service.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'cell_service/cell_service.dart'; - -class SelectOptionService { - final GridCellIdentifier cellId; - SelectOptionService({required this.cellId}); - - String get gridId => cellId.gridId; - String get fieldId => cellId.fieldContext.id; - String get rowId => cellId.rowId; - - Future> create({required String name}) { - return TypeOptionFFIService(gridId: gridId, fieldId: fieldId) - .newOption(name: name) - .then( - (result) { - return result.fold( - (option) { - final cellIdentifier = GridCellIdPB.create() - ..gridId = gridId - ..fieldId = fieldId - ..rowId = rowId; - final payload = SelectOptionChangesetPayloadPB.create() - ..insertOptions.add(option) - ..cellIdentifier = cellIdentifier; - return GridEventUpdateSelectOption(payload).send(); - }, - (r) => right(r), - ); - }, - ); - } - - Future> update({ - required SelectOptionPB option, - }) { - final payload = SelectOptionChangesetPayloadPB.create() - ..updateOptions.add(option) - ..cellIdentifier = _cellIdentifier(); - return GridEventUpdateSelectOption(payload).send(); - } - - Future> delete( - {required Iterable options}) { - final payload = SelectOptionChangesetPayloadPB.create() - ..deleteOptions.addAll(options) - ..cellIdentifier = _cellIdentifier(); - - return GridEventUpdateSelectOption(payload).send(); - } - - Future> getOptionContext() { - final payload = GridCellIdPB.create() - ..gridId = gridId - ..fieldId = fieldId - ..rowId = rowId; - - return GridEventGetSelectOptionCellData(payload).send(); - } - - Future> select( - {required Iterable optionIds}) { - final payload = SelectOptionCellChangesetPayloadPB.create() - ..cellIdentifier = _cellIdentifier() - ..insertOptionIds.addAll(optionIds); - return GridEventUpdateSelectOptionCell(payload).send(); - } - - Future> unSelect( - {required Iterable optionIds}) { - final payload = SelectOptionCellChangesetPayloadPB.create() - ..cellIdentifier = _cellIdentifier() - ..deleteOptionIds.addAll(optionIds); - return GridEventUpdateSelectOptionCell(payload).send(); - } - - GridCellIdPB _cellIdentifier() { - return GridCellIdPB.create() - ..gridId = gridId - ..fieldId = fieldId - ..rowId = rowId; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/text_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/text_cell_bloc.dart deleted file mode 100644 index 3fa55b744ffc4..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/text_cell_bloc.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'cell_service/cell_service.dart'; - -part 'text_cell_bloc.freezed.dart'; - -class TextCellBloc extends Bloc { - final GridCellController cellController; - void Function()? _onCellChangedFn; - TextCellBloc({ - required this.cellController, - }) : super(TextCellState.initial(cellController)) { - on( - (event, emit) async { - await event.when( - initial: () async { - _startListening(); - }, - updateText: (text) { - cellController.saveCellData(text); - emit(state.copyWith(content: text)); - }, - didReceiveCellUpdate: (content) { - emit(state.copyWith(content: content)); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = cellController.startListening( - onCellChanged: ((cellContent) { - if (!isClosed) { - add(TextCellEvent.didReceiveCellUpdate(cellContent ?? "")); - } - }), - ); - } -} - -@freezed -class TextCellEvent with _$TextCellEvent { - const factory TextCellEvent.initial() = _InitialCell; - const factory TextCellEvent.didReceiveCellUpdate(String cellContent) = - _DidReceiveCellUpdate; - const factory TextCellEvent.updateText(String text) = _UpdateText; -} - -@freezed -class TextCellState with _$TextCellState { - const factory TextCellState({ - required String content, - }) = _TextCellState; - - factory TextCellState.initial(GridCellController context) => TextCellState( - content: context.getCellData() ?? "", - ); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_bloc.dart deleted file mode 100644 index 824900f1730ed..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_bloc.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flowy_sdk/protobuf/flowy-grid/url_type_option_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'cell_service/cell_service.dart'; - -part 'url_cell_bloc.freezed.dart'; - -class URLCellBloc extends Bloc { - final GridURLCellController cellController; - void Function()? _onCellChangedFn; - URLCellBloc({ - required this.cellController, - }) : super(URLCellState.initial(cellController)) { - on( - (event, emit) async { - event.when( - initial: () { - _startListening(); - }, - didReceiveCellUpdate: (cellData) { - emit(state.copyWith( - content: cellData?.content ?? "", - url: cellData?.url ?? "", - )); - }, - updateURL: (String url) { - cellController.saveCellData(url, deduplicate: true); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = cellController.startListening( - onCellChanged: ((cellData) { - if (!isClosed) { - add(URLCellEvent.didReceiveCellUpdate(cellData)); - } - }), - ); - } -} - -@freezed -class URLCellEvent with _$URLCellEvent { - const factory URLCellEvent.initial() = _InitialCell; - const factory URLCellEvent.updateURL(String url) = _UpdateURL; - const factory URLCellEvent.didReceiveCellUpdate(URLCellDataPB? cell) = - _DidReceiveCellUpdate; -} - -@freezed -class URLCellState with _$URLCellState { - const factory URLCellState({ - required String content, - required String url, - }) = _URLCellState; - - factory URLCellState.initial(GridURLCellController context) { - final cellData = context.getCellData(); - return URLCellState( - content: cellData?.content ?? "", - url: cellData?.url ?? "", - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_editor_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_editor_bloc.dart deleted file mode 100644 index 8e82c27f423c7..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_editor_bloc.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flowy_sdk/protobuf/flowy-grid/url_type_option_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'cell_service/cell_service.dart'; - -part 'url_cell_editor_bloc.freezed.dart'; - -class URLCellEditorBloc extends Bloc { - final GridURLCellController cellController; - void Function()? _onCellChangedFn; - URLCellEditorBloc({ - required this.cellController, - }) : super(URLCellEditorState.initial(cellController)) { - on( - (event, emit) async { - event.when( - initial: () { - _startListening(); - }, - updateText: (text) { - cellController.saveCellData(text, deduplicate: true); - emit(state.copyWith(content: text)); - }, - didReceiveCellUpdate: (cellData) { - emit(state.copyWith(content: cellData?.content ?? "")); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onCellChangedFn != null) { - cellController.removeListener(_onCellChangedFn!); - _onCellChangedFn = null; - } - cellController.dispose(); - return super.close(); - } - - void _startListening() { - _onCellChangedFn = cellController.startListening( - onCellChanged: ((cellData) { - if (!isClosed) { - add(URLCellEditorEvent.didReceiveCellUpdate(cellData)); - } - }), - ); - } -} - -@freezed -class URLCellEditorEvent with _$URLCellEditorEvent { - const factory URLCellEditorEvent.initial() = _InitialCell; - const factory URLCellEditorEvent.didReceiveCellUpdate(URLCellDataPB? cell) = - _DidReceiveCellUpdate; - const factory URLCellEditorEvent.updateText(String text) = _UpdateText; -} - -@freezed -class URLCellEditorState with _$URLCellEditorState { - const factory URLCellEditorState({ - required String content, - }) = _URLCellEditorState; - - factory URLCellEditorState.initial(GridURLCellController context) { - final cellData = context.getCellData(); - return URLCellEditorState( - content: cellData?.content ?? "", - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_action_sheet_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_action_sheet_bloc.dart deleted file mode 100644 index fc2cfb10983f7..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_action_sheet_bloc.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'field_service.dart'; - -part 'field_action_sheet_bloc.freezed.dart'; - -class FieldActionSheetBloc - extends Bloc { - final FieldService fieldService; - - FieldActionSheetBloc({required GridFieldCellContext fieldCellContext}) - : fieldService = FieldService( - gridId: fieldCellContext.gridId, - fieldId: fieldCellContext.field.id, - ), - super( - FieldActionSheetState.initial( - FieldTypeOptionDataPB.create()..field_2 = fieldCellContext.field, - ), - ) { - on( - (event, emit) async { - await event.map( - updateFieldName: (_UpdateFieldName value) async { - final result = await fieldService.updateField(name: value.name); - result.fold( - (l) => null, - (err) => Log.error(err), - ); - }, - hideField: (_HideField value) async { - final result = await fieldService.updateField(visibility: false); - result.fold( - (l) => null, - (err) => Log.error(err), - ); - }, - showField: (_ShowField value) async { - final result = await fieldService.updateField(visibility: true); - result.fold( - (l) => null, - (err) => Log.error(err), - ); - }, - deleteField: (_DeleteField value) async { - final result = await fieldService.deleteField(); - result.fold( - (l) => null, - (err) => Log.error(err), - ); - }, - duplicateField: (_DuplicateField value) async { - final result = await fieldService.duplicateField(); - result.fold( - (l) => null, - (err) => Log.error(err), - ); - }, - saveField: (_SaveField value) {}, - ); - }, - ); - } - - @override - Future close() async { - return super.close(); - } -} - -@freezed -class FieldActionSheetEvent with _$FieldActionSheetEvent { - const factory FieldActionSheetEvent.updateFieldName(String name) = - _UpdateFieldName; - const factory FieldActionSheetEvent.hideField() = _HideField; - const factory FieldActionSheetEvent.showField() = _ShowField; - const factory FieldActionSheetEvent.duplicateField() = _DuplicateField; - const factory FieldActionSheetEvent.deleteField() = _DeleteField; - const factory FieldActionSheetEvent.saveField() = _SaveField; -} - -@freezed -class FieldActionSheetState with _$FieldActionSheetState { - const factory FieldActionSheetState({ - required FieldTypeOptionDataPB fieldTypeOptionData, - required String errorText, - required String fieldName, - }) = _FieldActionSheetState; - - factory FieldActionSheetState.initial(FieldTypeOptionDataPB data) => - FieldActionSheetState( - fieldTypeOptionData: data, - errorText: '', - fieldName: data.field_2.name, - ); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_cell_bloc.dart deleted file mode 100644 index 43114036c1a61..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_cell_bloc.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/field_listener.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; - -part 'field_cell_bloc.freezed.dart'; - -class FieldCellBloc extends Bloc { - final SingleFieldListener _fieldListener; - final FieldService _fieldService; - - FieldCellBloc({ - required GridFieldCellContext cellContext, - }) : _fieldListener = SingleFieldListener(fieldId: cellContext.field.id), - _fieldService = FieldService( - gridId: cellContext.gridId, fieldId: cellContext.field.id), - super(FieldCellState.initial(cellContext)) { - on( - (event, emit) async { - event.when( - initial: () { - _startListening(); - }, - didReceiveFieldUpdate: (field) { - emit(state.copyWith(field: cellContext.field)); - }, - startUpdateWidth: (offset) { - final width = state.width + offset; - emit(state.copyWith(width: width)); - }, - endUpdateWidth: () { - if (state.width != state.field.width.toDouble()) { - _fieldService.updateField(width: state.width); - } - }, - ); - }, - ); - } - - @override - Future close() async { - await _fieldListener.stop(); - return super.close(); - } - - void _startListening() { - _fieldListener.start(onFieldChanged: (result) { - if (isClosed) { - return; - } - result.fold( - (field) => add(FieldCellEvent.didReceiveFieldUpdate(field)), - (err) => Log.error(err), - ); - }); - } -} - -@freezed -class FieldCellEvent with _$FieldCellEvent { - const factory FieldCellEvent.initial() = _InitialCell; - const factory FieldCellEvent.didReceiveFieldUpdate(FieldPB field) = - _DidReceiveFieldUpdate; - const factory FieldCellEvent.startUpdateWidth(double offset) = - _StartUpdateWidth; - const factory FieldCellEvent.endUpdateWidth() = _EndUpdateWidth; -} - -@freezed -class FieldCellState with _$FieldCellState { - const factory FieldCellState({ - required String gridId, - required FieldPB field, - required double width, - }) = _FieldCellState; - - factory FieldCellState.initial(GridFieldCellContext cellContext) => - FieldCellState( - gridId: cellContext.gridId, - field: cellContext.field, - width: cellContext.field.width.toDouble(), - ); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_controller.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_controller.dart deleted file mode 100644 index 73127ed6df2cf..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_controller.dart +++ /dev/null @@ -1,315 +0,0 @@ -import 'dart:collection'; -import 'package:app_flowy/plugins/grid/application/field/grid_listener.dart'; -import 'package:app_flowy/plugins/grid/application/grid_service.dart'; -import 'package:app_flowy/plugins/grid/application/setting/setting_listener.dart'; -import 'package:app_flowy/plugins/grid/application/setting/setting_service.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/group.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/setting_entities.pb.dart'; -import 'package:flutter/foundation.dart'; -import '../row/row_cache.dart'; - -class _GridFieldNotifier extends ChangeNotifier { - List _fieldContexts = []; - - set fieldContexts(List fieldContexts) { - _fieldContexts = fieldContexts; - notifyListeners(); - } - - void notify() { - notifyListeners(); - } - - List get fieldContexts => _fieldContexts; -} - -typedef OnChangeset = void Function(FieldChangesetPB); -typedef OnReceiveFields = void Function(List); - -class GridFieldController { - final String gridId; - final GridFieldsListener _fieldListener; - final SettingListener _settingListener; - final Map _fieldCallbackMap = {}; - final Map _changesetCallbackMap = {}; - final GridFFIService _gridFFIService; - final SettingFFIService _settingFFIService; - - _GridFieldNotifier? _fieldNotifier = _GridFieldNotifier(); - final Map _configurationByFieldId = {}; - - List get fieldContexts => - [..._fieldNotifier?.fieldContexts ?? []]; - - GridFieldController({required this.gridId}) - : _fieldListener = GridFieldsListener(gridId: gridId), - _gridFFIService = GridFFIService(gridId: gridId), - _settingFFIService = SettingFFIService(viewId: gridId), - _settingListener = SettingListener(gridId: gridId) { - //Listen on field's changes - _fieldListener.start(onFieldsChanged: (result) { - result.fold( - (changeset) { - _deleteFields(changeset.deletedFields); - _insertFields(changeset.insertedFields); - _updateFields(changeset.updatedFields); - for (final listener in _changesetCallbackMap.values) { - listener(changeset); - } - }, - (err) => Log.error(err), - ); - }); - - //Listen on setting changes - _settingListener.start(onSettingUpdated: (result) { - result.fold( - (setting) => _updateGroupConfiguration(setting), - (r) => Log.error(r), - ); - }); - - _settingFFIService.getSetting().then((result) { - result.fold( - (setting) => _updateGroupConfiguration(setting), - (err) => Log.error(err), - ); - }); - } - - GridFieldContext? getField(String fieldId) { - final fields = _fieldNotifier?.fieldContexts - .where( - (element) => element.id == fieldId, - ) - .toList(); - if (fields?.isEmpty ?? true) { - return null; - } - return fields!.first; - } - - void _updateGroupConfiguration(GridSettingPB setting) { - _configurationByFieldId.clear(); - for (final configuration in setting.groupConfigurations.items) { - _configurationByFieldId[configuration.fieldId] = configuration; - } - _updateFieldContexts(); - } - - void _updateFieldContexts() { - if (_fieldNotifier != null) { - for (var field in _fieldNotifier!.fieldContexts) { - if (_configurationByFieldId[field.id] != null) { - field._isGroupField = true; - } else { - field._isGroupField = false; - } - } - _fieldNotifier?.notify(); - } - } - - Future dispose() async { - await _fieldListener.stop(); - _fieldNotifier?.dispose(); - _fieldNotifier = null; - } - - Future> loadFields( - {required List fieldIds}) async { - final result = await _gridFFIService.getFields(fieldIds: fieldIds); - return Future( - () => result.fold( - (newFields) { - _fieldNotifier?.fieldContexts = newFields.items - .map((field) => GridFieldContext(field: field)) - .toList(); - _updateFieldContexts(); - return left(unit); - }, - (err) => right(err), - ), - ); - } - - void addListener({ - OnReceiveFields? onFields, - OnChangeset? onChangeset, - bool Function()? listenWhen, - }) { - if (onChangeset != null) { - callback(c) { - if (listenWhen != null && listenWhen() == false) { - return; - } - onChangeset(c); - } - - _changesetCallbackMap[onChangeset] = callback; - } - - if (onFields != null) { - callback() { - if (listenWhen != null && listenWhen() == false) { - return; - } - onFields(fieldContexts); - } - - _fieldCallbackMap[onFields] = callback; - _fieldNotifier?.addListener(callback); - } - } - - void removeListener({ - OnReceiveFields? onFieldsListener, - OnChangeset? onChangesetListener, - }) { - if (onFieldsListener != null) { - final callback = _fieldCallbackMap.remove(onFieldsListener); - if (callback != null) { - _fieldNotifier?.removeListener(callback); - } - } - - if (onChangesetListener != null) { - _changesetCallbackMap.remove(onChangesetListener); - } - } - - void _deleteFields(List deletedFields) { - if (deletedFields.isEmpty) { - return; - } - final List newFields = fieldContexts; - final Map deletedFieldMap = { - for (var fieldOrder in deletedFields) fieldOrder.fieldId: fieldOrder - }; - - newFields.retainWhere((field) => (deletedFieldMap[field.id] == null)); - _fieldNotifier?.fieldContexts = newFields; - } - - void _insertFields(List insertedFields) { - if (insertedFields.isEmpty) { - return; - } - final List newFields = fieldContexts; - for (final indexField in insertedFields) { - final gridField = GridFieldContext(field: indexField.field_1); - if (newFields.length > indexField.index) { - newFields.insert(indexField.index, gridField); - } else { - newFields.add(gridField); - } - } - _fieldNotifier?.fieldContexts = newFields; - } - - void _updateFields(List updatedFields) { - if (updatedFields.isEmpty) { - return; - } - final List newFields = fieldContexts; - for (final updatedField in updatedFields) { - final index = - newFields.indexWhere((field) => field.id == updatedField.id); - if (index != -1) { - newFields.removeAt(index); - final gridField = GridFieldContext(field: updatedField); - newFields.insert(index, gridField); - } - } - _fieldNotifier?.fieldContexts = newFields; - } -} - -class GridRowFieldNotifierImpl extends IGridRowFieldNotifier { - final GridFieldController _cache; - OnChangeset? _onChangesetFn; - OnReceiveFields? _onFieldFn; - GridRowFieldNotifierImpl(GridFieldController cache) : _cache = cache; - - @override - UnmodifiableListView get fields => - UnmodifiableListView(_cache.fieldContexts); - - @override - void onRowFieldsChanged(VoidCallback callback) { - _onFieldFn = (_) => callback(); - _cache.addListener(onFields: _onFieldFn); - } - - @override - void onRowFieldChanged(void Function(FieldPB) callback) { - _onChangesetFn = (FieldChangesetPB changeset) { - for (final updatedField in changeset.updatedFields) { - callback(updatedField); - } - }; - - _cache.addListener(onChangeset: _onChangesetFn); - } - - @override - void onRowDispose() { - if (_onFieldFn != null) { - _cache.removeListener(onFieldsListener: _onFieldFn!); - _onFieldFn = null; - } - - if (_onChangesetFn != null) { - _cache.removeListener(onChangesetListener: _onChangesetFn!); - _onChangesetFn = null; - } - } -} - -class GridFieldContext { - final FieldPB _field; - bool _isGroupField = false; - - String get id => _field.id; - - FieldType get fieldType => _field.fieldType; - - bool get visibility => _field.visibility; - - double get width => _field.width.toDouble(); - - bool get isPrimary => _field.isPrimary; - - String get name => _field.name; - - FieldPB get field => _field; - - bool get isGroupField => _isGroupField; - - bool get canGroup { - switch (_field.fieldType) { - case FieldType.Checkbox: - return true; - case FieldType.DateTime: - return false; - case FieldType.MultiSelect: - return true; - case FieldType.Number: - return false; - case FieldType.RichText: - return false; - case FieldType.SingleSelect: - return true; - case FieldType.URL: - return false; - } - - return false; - } - - GridFieldContext({required FieldPB field}) : _field = field; -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_editor_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_editor_bloc.dart deleted file mode 100644 index 0e8965a5a886e..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_editor_bloc.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'dart:async'; -import 'package:dartz/dartz.dart'; -import 'field_service.dart'; -import 'type_option/type_option_context.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import 'type_option/type_option_data_controller.dart'; - -part 'field_editor_bloc.freezed.dart'; - -class FieldEditorBloc extends Bloc { - final TypeOptionDataController dataController; - - FieldEditorBloc({ - required String gridId, - required String fieldName, - required bool isGroupField, - required IFieldTypeOptionLoader loader, - }) : dataController = - TypeOptionDataController(gridId: gridId, loader: loader), - super(FieldEditorState.initial(gridId, fieldName, isGroupField)) { - on( - (event, emit) async { - await event.when( - initial: () async { - dataController.addFieldListener((field) { - if (!isClosed) { - add(FieldEditorEvent.didReceiveFieldChanged(field)); - } - }); - await dataController.loadTypeOptionData(); - }, - updateName: (name) { - if (state.name != name) { - dataController.fieldName = name; - emit(state.copyWith(name: name)); - } - }, - didReceiveFieldChanged: (FieldPB field) { - emit(state.copyWith( - field: Some(field), - name: field.name, - canDelete: field.isPrimary, - )); - }, - deleteField: () { - state.field.fold( - () => null, - (field) { - final fieldService = FieldService( - gridId: gridId, - fieldId: field.id, - ); - fieldService.deleteField(); - }, - ); - }, - switchToField: (FieldType fieldType) async { - await dataController.switchToField(fieldType); - }, - ); - }, - ); - } - - @override - Future close() async { - return super.close(); - } -} - -@freezed -class FieldEditorEvent with _$FieldEditorEvent { - const factory FieldEditorEvent.initial() = _InitialField; - const factory FieldEditorEvent.updateName(String name) = _UpdateName; - const factory FieldEditorEvent.deleteField() = _DeleteField; - const factory FieldEditorEvent.switchToField(FieldType fieldType) = - _SwitchToField; - const factory FieldEditorEvent.didReceiveFieldChanged(FieldPB field) = - _DidReceiveFieldChanged; -} - -@freezed -class FieldEditorState with _$FieldEditorState { - const factory FieldEditorState({ - required String gridId, - required String errorText, - required String name, - required Option field, - required bool canDelete, - required bool isGroupField, - }) = _FieldEditorState; - - factory FieldEditorState.initial( - String gridId, - String fieldName, - bool isGroupField, - ) => - FieldEditorState( - gridId: gridId, - errorText: '', - field: none(), - canDelete: false, - name: fieldName, - isGroupField: isGroupField, - ); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_listener.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_listener.dart deleted file mode 100644 index b1bb885ae2091..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_listener.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:app_flowy/core/grid_notification.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/dart_notification.pb.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'dart:async'; -import 'dart:typed_data'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; - -typedef UpdateFieldNotifiedValue = Either; - -class SingleFieldListener { - final String fieldId; - PublishNotifier? _updateFieldNotifier = - PublishNotifier(); - GridNotificationListener? _listener; - - SingleFieldListener({required this.fieldId}); - - void start( - {required void Function(UpdateFieldNotifiedValue) onFieldChanged}) { - _updateFieldNotifier?.addPublishListener(onFieldChanged); - _listener = GridNotificationListener( - objectId: fieldId, - handler: _handler, - ); - } - - void _handler( - GridNotification ty, - Either result, - ) { - switch (ty) { - case GridNotification.DidUpdateField: - result.fold( - (payload) => - _updateFieldNotifier?.value = left(FieldPB.fromBuffer(payload)), - (error) => _updateFieldNotifier?.value = right(error), - ); - break; - default: - break; - } - } - - Future stop() async { - await _listener?.stop(); - _updateFieldNotifier?.dispose(); - _updateFieldNotifier = null; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_service.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_service.dart deleted file mode 100644 index e7487b6f0215a..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_service.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart'; -import 'package:flutter/foundation.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'field_service.freezed.dart'; - -/// FieldService consists of lots of event functions. We define the events in the backend(Rust), -/// you can find the corresponding event implementation in event_map.rs of the corresponding crate. -/// -/// You could check out the rust-lib/flowy-grid/event_map.rs for more information. -class FieldService { - final String gridId; - final String fieldId; - - FieldService({required this.gridId, required this.fieldId}); - - Future> moveField(int fromIndex, int toIndex) { - final payload = MoveFieldPayloadPB.create() - ..gridId = gridId - ..fieldId = fieldId - ..fromIndex = fromIndex - ..toIndex = toIndex; - - return GridEventMoveField(payload).send(); - } - - Future> updateField({ - String? name, - FieldType? fieldType, - bool? frozen, - bool? visibility, - double? width, - List? typeOptionData, - }) { - var payload = FieldChangesetPayloadPB.create() - ..gridId = gridId - ..fieldId = fieldId; - - if (name != null) { - payload.name = name; - } - - if (fieldType != null) { - payload.fieldType = fieldType; - } - - if (frozen != null) { - payload.frozen = frozen; - } - - if (visibility != null) { - payload.visibility = visibility; - } - - if (width != null) { - payload.width = width.toInt(); - } - - if (typeOptionData != null) { - payload.typeOptionData = typeOptionData; - } - - return GridEventUpdateField(payload).send(); - } - - static Future> updateFieldTypeOption({ - required String gridId, - required String fieldId, - required List typeOptionData, - }) { - var payload = UpdateFieldTypeOptionPayloadPB.create() - ..gridId = gridId - ..fieldId = fieldId - ..typeOptionData = typeOptionData; - - return GridEventUpdateFieldTypeOption(payload).send(); - } - - Future> deleteField() { - final payload = DeleteFieldPayloadPB.create() - ..gridId = gridId - ..fieldId = fieldId; - - return GridEventDeleteField(payload).send(); - } - - Future> duplicateField() { - final payload = DuplicateFieldPayloadPB.create() - ..gridId = gridId - ..fieldId = fieldId; - - return GridEventDuplicateField(payload).send(); - } - - Future> getFieldTypeOptionData({ - required FieldType fieldType, - }) { - final payload = FieldTypeOptionIdPB.create() - ..gridId = gridId - ..fieldId = fieldId - ..fieldType = fieldType; - return GridEventGetFieldTypeOption(payload).send().then((result) { - return result.fold( - (data) => left(data), - (err) => right(err), - ); - }); - } -} - -@freezed -class GridFieldCellContext with _$GridFieldCellContext { - const factory GridFieldCellContext({ - required String gridId, - required FieldPB field, - }) = _GridFieldCellContext; -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_type_option_edit_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_type_option_edit_bloc.dart deleted file mode 100644 index 1835ba6262f83..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_type_option_edit_bloc.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; - -import 'type_option/type_option_data_controller.dart'; -part 'field_type_option_edit_bloc.freezed.dart'; - -class FieldTypeOptionEditBloc - extends Bloc { - final TypeOptionDataController _dataController; - void Function()? _fieldListenFn; - - FieldTypeOptionEditBloc(TypeOptionDataController dataController) - : _dataController = dataController, - super(FieldTypeOptionEditState.initial(dataController)) { - on( - (event, emit) async { - event.when( - initial: () { - _fieldListenFn = dataController.addFieldListener((field) { - add(FieldTypeOptionEditEvent.didReceiveFieldUpdated(field)); - }); - }, - didReceiveFieldUpdated: (field) { - emit(state.copyWith(field: field)); - }, - switchToField: (FieldType fieldType) async { - await _dataController.switchToField(fieldType); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_fieldListenFn != null) { - _dataController.removeFieldListener(_fieldListenFn!); - } - return super.close(); - } -} - -@freezed -class FieldTypeOptionEditEvent with _$FieldTypeOptionEditEvent { - const factory FieldTypeOptionEditEvent.initial() = _Initial; - const factory FieldTypeOptionEditEvent.switchToField(FieldType fieldType) = - _SwitchToField; - const factory FieldTypeOptionEditEvent.didReceiveFieldUpdated(FieldPB field) = - _DidReceiveFieldUpdated; -} - -@freezed -class FieldTypeOptionEditState with _$FieldTypeOptionEditState { - const factory FieldTypeOptionEditState({ - required FieldPB field, - }) = _FieldTypeOptionEditState; - - factory FieldTypeOptionEditState.initial( - TypeOptionDataController typeOptionDataController, - ) => - FieldTypeOptionEditState( - field: typeOptionDataController.field, - ); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/grid_listener.dart b/frontend/app_flowy/lib/plugins/grid/application/field/grid_listener.dart deleted file mode 100644 index 61d931e43e29c..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/grid_listener.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:app_flowy/core/grid_notification.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/dart_notification.pb.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'dart:async'; -import 'dart:typed_data'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; - -typedef UpdateFieldNotifiedValue = Either; - -class GridFieldsListener { - final String gridId; - PublishNotifier? updateFieldsNotifier = - PublishNotifier(); - GridNotificationListener? _listener; - GridFieldsListener({required this.gridId}); - - void start( - {required void Function(UpdateFieldNotifiedValue) onFieldsChanged}) { - updateFieldsNotifier?.addPublishListener(onFieldsChanged); - _listener = GridNotificationListener( - objectId: gridId, - handler: _handler, - ); - } - - void _handler(GridNotification ty, Either result) { - switch (ty) { - case GridNotification.DidUpdateGridField: - result.fold( - (payload) => updateFieldsNotifier?.value = - left(FieldChangesetPB.fromBuffer(payload)), - (error) => updateFieldsNotifier?.value = right(error), - ); - break; - default: - break; - } - } - - Future stop() async { - await _listener?.stop(); - updateFieldsNotifier?.dispose(); - updateFieldsNotifier = null; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/date_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/date_bloc.dart deleted file mode 100644 index e45ed58a2c62a..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/date_bloc.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'package:protobuf/protobuf.dart'; - -import 'type_option_context.dart'; -part 'date_bloc.freezed.dart'; - -class DateTypeOptionBloc - extends Bloc { - DateTypeOptionBloc({required DateTypeOptionContext typeOptionContext}) - : super(DateTypeOptionState.initial(typeOptionContext.typeOption)) { - on( - (event, emit) async { - event.map( - didSelectDateFormat: (_DidSelectDateFormat value) { - emit(state.copyWith( - typeOption: _updateTypeOption(dateFormat: value.format))); - }, - didSelectTimeFormat: (_DidSelectTimeFormat value) { - emit(state.copyWith( - typeOption: _updateTypeOption(timeFormat: value.format))); - }, - includeTime: (_IncludeTime value) { - emit(state.copyWith( - typeOption: _updateTypeOption(includeTime: value.includeTime))); - }, - ); - }, - ); - } - - DateTypeOptionPB _updateTypeOption({ - DateFormat? dateFormat, - TimeFormat? timeFormat, - bool? includeTime, - }) { - state.typeOption.freeze(); - return state.typeOption.rebuild((typeOption) { - if (dateFormat != null) { - typeOption.dateFormat = dateFormat; - } - - if (timeFormat != null) { - typeOption.timeFormat = timeFormat; - } - - if (includeTime != null) { - typeOption.includeTime = includeTime; - } - }); - } - - @override - Future close() async { - return super.close(); - } -} - -@freezed -class DateTypeOptionEvent with _$DateTypeOptionEvent { - const factory DateTypeOptionEvent.didSelectDateFormat(DateFormat format) = - _DidSelectDateFormat; - const factory DateTypeOptionEvent.didSelectTimeFormat(TimeFormat format) = - _DidSelectTimeFormat; - const factory DateTypeOptionEvent.includeTime(bool includeTime) = - _IncludeTime; -} - -@freezed -class DateTypeOptionState with _$DateTypeOptionState { - const factory DateTypeOptionState({ - required DateTypeOptionPB typeOption, - }) = _DateTypeOptionState; - - factory DateTypeOptionState.initial(DateTypeOptionPB typeOption) => - DateTypeOptionState(typeOption: typeOption); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/edit_select_option_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/edit_select_option_bloc.dart deleted file mode 100644 index f3ad6c80bc61f..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/edit_select_option_bloc.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'package:protobuf/protobuf.dart'; -import 'package:dartz/dartz.dart'; -part 'edit_select_option_bloc.freezed.dart'; - -class EditSelectOptionBloc - extends Bloc { - EditSelectOptionBloc({required SelectOptionPB option}) - : super(EditSelectOptionState.initial(option)) { - on( - (event, emit) async { - event.map( - updateName: (_UpdateName value) { - emit(state.copyWith(option: _updateName(value.name))); - }, - updateColor: (_UpdateColor value) { - emit(state.copyWith(option: _updateColor(value.color))); - }, - delete: (_Delete value) { - emit(state.copyWith(deleted: const Some(true))); - }, - ); - }, - ); - } - - @override - Future close() async { - return super.close(); - } - - SelectOptionPB _updateColor(SelectOptionColorPB color) { - state.option.freeze(); - return state.option.rebuild((option) { - option.color = color; - }); - } - - SelectOptionPB _updateName(String name) { - state.option.freeze(); - return state.option.rebuild((option) { - option.name = name; - }); - } -} - -@freezed -class EditSelectOptionEvent with _$EditSelectOptionEvent { - const factory EditSelectOptionEvent.updateName(String name) = _UpdateName; - const factory EditSelectOptionEvent.updateColor(SelectOptionColorPB color) = - _UpdateColor; - const factory EditSelectOptionEvent.delete() = _Delete; -} - -@freezed -class EditSelectOptionState with _$EditSelectOptionState { - const factory EditSelectOptionState({ - required SelectOptionPB option, - required Option deleted, - }) = _EditSelectOptionState; - - factory EditSelectOptionState.initial(SelectOptionPB option) => - EditSelectOptionState( - option: option, - deleted: none(), - ); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/multi_select_type_option.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/multi_select_type_option.dart deleted file mode 100644 index f1bb7bfecf7bf..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/multi_select_type_option.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/multi_select_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'dart:async'; -import 'select_option_type_option_bloc.dart'; -import 'type_option_context.dart'; -import 'type_option_service.dart'; -import 'package:protobuf/protobuf.dart'; - -class MultiSelectAction with ISelectOptionAction { - final String gridId; - final String fieldId; - final TypeOptionFFIService service; - final MultiSelectTypeOptionContext typeOptionContext; - - MultiSelectAction({ - required this.gridId, - required this.fieldId, - required this.typeOptionContext, - }) : service = TypeOptionFFIService( - gridId: gridId, - fieldId: fieldId, - ); - - MultiSelectTypeOptionPB get typeOption => typeOptionContext.typeOption; - - set typeOption(MultiSelectTypeOptionPB newTypeOption) { - typeOptionContext.typeOption = newTypeOption; - } - - @override - List Function(SelectOptionPB) get deleteOption { - return (SelectOptionPB option) { - typeOption.freeze(); - typeOption = typeOption.rebuild((typeOption) { - final index = - typeOption.options.indexWhere((element) => element.id == option.id); - if (index != -1) { - typeOption.options.removeAt(index); - } - }); - return typeOption.options; - }; - } - - @override - Future> Function(String) get insertOption { - return (String optionName) { - return service.newOption(name: optionName).then((result) { - return result.fold( - (option) { - typeOption.freeze(); - typeOption = typeOption.rebuild((typeOption) { - final exists = typeOption.options - .any((element) => element.name == option.name); - if (!exists) { - typeOption.options.insert(0, option); - } - }); - - return typeOption.options; - }, - (err) { - Log.error(err); - return typeOption.options; - }, - ); - }); - }; - } - - @override - List Function(SelectOptionPB) get updateOption { - return (SelectOptionPB option) { - typeOption.freeze(); - typeOption = typeOption.rebuild((typeOption) { - final index = - typeOption.options.indexWhere((element) => element.id == option.id); - if (index != -1) { - typeOption.options[index] = option; - } - }); - return typeOption.options; - }; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_bloc.dart deleted file mode 100644 index a475652095fda..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_bloc.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flowy_sdk/protobuf/flowy-grid/format.pbenum.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/number_type_option.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'package:protobuf/protobuf.dart'; -import 'type_option_context.dart'; - -part 'number_bloc.freezed.dart'; - -class NumberTypeOptionBloc - extends Bloc { - NumberTypeOptionBloc({required NumberTypeOptionContext typeOptionContext}) - : super(NumberTypeOptionState.initial(typeOptionContext.typeOption)) { - on( - (event, emit) async { - event.map( - didSelectFormat: (_DidSelectFormat value) { - emit(state.copyWith(typeOption: _updateNumberFormat(value.format))); - }, - ); - }, - ); - } - - NumberTypeOptionPB _updateNumberFormat(NumberFormat format) { - state.typeOption.freeze(); - return state.typeOption.rebuild((typeOption) { - typeOption.format = format; - }); - } - - @override - Future close() async { - return super.close(); - } -} - -@freezed -class NumberTypeOptionEvent with _$NumberTypeOptionEvent { - const factory NumberTypeOptionEvent.didSelectFormat(NumberFormat format) = - _DidSelectFormat; -} - -@freezed -class NumberTypeOptionState with _$NumberTypeOptionState { - const factory NumberTypeOptionState({ - required NumberTypeOptionPB typeOption, - }) = _NumberTypeOptionState; - - factory NumberTypeOptionState.initial(NumberTypeOptionPB typeOption) => - NumberTypeOptionState( - typeOption: typeOption, - ); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_format_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_format_bloc.dart deleted file mode 100644 index 870eb2cdef6ba..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_format_bloc.dart +++ /dev/null @@ -1,145 +0,0 @@ -import 'package:flowy_sdk/protobuf/flowy-grid/format.pbenum.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -part 'number_format_bloc.freezed.dart'; - -class NumberFormatBloc extends Bloc { - NumberFormatBloc() : super(NumberFormatState.initial()) { - on( - (event, emit) async { - event.map(setFilter: (_SetFilter value) { - final List formats = List.from(NumberFormat.values); - if (value.filter.isNotEmpty) { - formats.retainWhere((element) => element - .title() - .toLowerCase() - .contains(value.filter.toLowerCase())); - } - emit(state.copyWith(formats: formats, filter: value.filter)); - }); - }, - ); - } - - @override - Future close() async { - return super.close(); - } -} - -@freezed -class NumberFormatEvent with _$NumberFormatEvent { - const factory NumberFormatEvent.setFilter(String filter) = _SetFilter; -} - -@freezed -class NumberFormatState with _$NumberFormatState { - const factory NumberFormatState({ - required List formats, - required String filter, - }) = _NumberFormatState; - - factory NumberFormatState.initial() { - return const NumberFormatState( - formats: NumberFormat.values, - filter: "", - ); - } -} - -extension NumberFormatExtension on NumberFormat { - String title() { - switch (this) { - case NumberFormat.ArgentinePeso: - return "Argentine peso"; - case NumberFormat.Baht: - return "Baht"; - case NumberFormat.CanadianDollar: - return "Canadian dollar"; - case NumberFormat.ChileanPeso: - return "Chilean peso"; - case NumberFormat.ColombianPeso: - return "Colombian peso"; - case NumberFormat.DanishKrone: - return "Danish krone"; - case NumberFormat.Dirham: - return "Dirham"; - case NumberFormat.EUR: - return "Euro"; - case NumberFormat.Forint: - return "Forint"; - case NumberFormat.Franc: - return "Franc"; - case NumberFormat.HongKongDollar: - return "Hone Kong dollar"; - case NumberFormat.Koruna: - return "Koruna"; - case NumberFormat.Krona: - return "Krona"; - case NumberFormat.Leu: - return "Leu"; - case NumberFormat.Lira: - return "Lira"; - case NumberFormat.MexicanPeso: - return "Mexican Peso"; - case NumberFormat.NewTaiwanDollar: - return "New Taiwan dollar"; - case NumberFormat.NewZealandDollar: - return "New Zealand dollar"; - case NumberFormat.NorwegianKrone: - return "Norwegian krone"; - case NumberFormat.Num: - return "Number"; - case NumberFormat.Percent: - return "Percent"; - case NumberFormat.PhilippinePeso: - return "PhilippinePeso"; - case NumberFormat.Pound: - return "Pound"; - case NumberFormat.Rand: - return "Rand"; - case NumberFormat.Real: - return "Real"; - case NumberFormat.Ringgit: - return "Ringgit"; - case NumberFormat.Riyal: - return "Riyal"; - case NumberFormat.Ruble: - return "Ruble"; - case NumberFormat.Rupee: - return "Rupee"; - case NumberFormat.Rupiah: - return "Rupiah"; - case NumberFormat.Shekel: - return "Skekel"; - case NumberFormat.USD: - return "US Dollar"; - case NumberFormat.UruguayanPeso: - return "Uruguayan peso"; - case NumberFormat.Won: - return "Uruguayan peso"; - case NumberFormat.Yen: - return "Yen"; - case NumberFormat.Yuan: - return "Yuan"; - default: - throw UnimplementedError; - } - } - - // String iconName() { - // switch (this) { - // case NumberFormat.CNY: - // return "grid/field/yen"; - // case NumberFormat.EUR: - // return "grid/field/euro"; - // case NumberFormat.Number: - // return "grid/field/numbers"; - // case NumberFormat.USD: - // return "grid/field/us_dollar"; - // default: - // throw UnimplementedError; - // } - // } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/select_option_type_option_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/select_option_type_option_bloc.dart deleted file mode 100644 index 39f1f31f06db3..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/select_option_type_option_bloc.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'package:dartz/dartz.dart'; -part 'select_option_type_option_bloc.freezed.dart'; - -abstract class ISelectOptionAction { - Future> Function(String) get insertOption; - - List Function(SelectOptionPB) get deleteOption; - - List Function(SelectOptionPB) get updateOption; -} - -class SelectOptionTypeOptionBloc - extends Bloc { - final ISelectOptionAction typeOptionAction; - - SelectOptionTypeOptionBloc({ - required List options, - required this.typeOptionAction, - }) : super(SelectOptionTypeOptionState.initial(options)) { - on( - (event, emit) async { - await event.when( - createOption: (optionName) async { - final List options = - await typeOptionAction.insertOption(optionName); - emit(state.copyWith(options: options)); - }, - addingOption: () { - emit(state.copyWith(isEditingOption: true, newOptionName: none())); - }, - endAddingOption: () { - emit(state.copyWith(isEditingOption: false, newOptionName: none())); - }, - updateOption: (option) { - final List options = - typeOptionAction.updateOption(option); - emit(state.copyWith(options: options)); - }, - deleteOption: (option) { - final List options = - typeOptionAction.deleteOption(option); - emit(state.copyWith(options: options)); - }, - ); - }, - ); - } - - @override - Future close() async { - return super.close(); - } -} - -@freezed -class SelectOptionTypeOptionEvent with _$SelectOptionTypeOptionEvent { - const factory SelectOptionTypeOptionEvent.createOption(String optionName) = - _CreateOption; - const factory SelectOptionTypeOptionEvent.addingOption() = _AddingOption; - const factory SelectOptionTypeOptionEvent.endAddingOption() = - _EndAddingOption; - const factory SelectOptionTypeOptionEvent.updateOption( - SelectOptionPB option) = _UpdateOption; - const factory SelectOptionTypeOptionEvent.deleteOption( - SelectOptionPB option) = _DeleteOption; -} - -@freezed -class SelectOptionTypeOptionState with _$SelectOptionTypeOptionState { - const factory SelectOptionTypeOptionState({ - required List options, - required bool isEditingOption, - required Option newOptionName, - }) = _SelectOptionTypeOptionState; - - factory SelectOptionTypeOptionState.initial(List options) => - SelectOptionTypeOptionState( - options: options, - isEditingOption: false, - newOptionName: none(), - ); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/single_select_type_option.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/single_select_type_option.dart deleted file mode 100644 index 186f2d760225b..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/single_select_type_option.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/single_select_type_option.pb.dart'; -import 'dart:async'; -import 'package:protobuf/protobuf.dart'; -import 'select_option_type_option_bloc.dart'; -import 'type_option_context.dart'; -import 'type_option_service.dart'; - -class SingleSelectAction with ISelectOptionAction { - final String gridId; - final String fieldId; - final SingleSelectTypeOptionContext typeOptionContext; - final TypeOptionFFIService service; - - SingleSelectAction({ - required this.gridId, - required this.fieldId, - required this.typeOptionContext, - }) : service = TypeOptionFFIService(gridId: gridId, fieldId: fieldId); - - SingleSelectTypeOptionPB get typeOption => typeOptionContext.typeOption; - - set typeOption(SingleSelectTypeOptionPB newTypeOption) { - typeOptionContext.typeOption = newTypeOption; - } - - @override - List Function(SelectOptionPB) get deleteOption { - return (SelectOptionPB option) { - typeOption.freeze(); - typeOption = typeOption.rebuild((typeOption) { - final index = - typeOption.options.indexWhere((element) => element.id == option.id); - if (index != -1) { - typeOption.options.removeAt(index); - } - }); - return typeOption.options; - }; - } - - @override - Future> Function(String) get insertOption { - return (String optionName) { - return service.newOption(name: optionName).then((result) { - return result.fold( - (option) { - typeOption.freeze(); - typeOption = typeOption.rebuild((typeOption) { - final exists = typeOption.options - .any((element) => element.name == option.name); - if (!exists) { - typeOption.options.insert(0, option); - } - }); - - return typeOption.options; - }, - (err) { - Log.error(err); - return typeOption.options; - }, - ); - }); - }; - } - - @override - List Function(SelectOptionPB) get updateOption { - return (SelectOptionPB option) { - typeOption.freeze(); - typeOption = typeOption.rebuild((typeOption) { - final index = - typeOption.options.indexWhere((element) => element.id == option.id); - if (index != -1) { - typeOption.options[index] = option; - } - }); - return typeOption.options; - }; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_context.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_context.dart deleted file mode 100644 index f644f4d2a00cb..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_context.dart +++ /dev/null @@ -1,218 +0,0 @@ -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/checkbox_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/multi_select_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/number_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/single_select_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/text_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/url_type_option.pb.dart'; -import 'package:protobuf/protobuf.dart'; - -import 'type_option_data_controller.dart'; - -abstract class TypeOptionDataParser { - T fromBuffer(List buffer); -} - -// Number -typedef NumberTypeOptionContext = TypeOptionContext; - -class NumberTypeOptionWidgetDataParser - extends TypeOptionDataParser { - @override - NumberTypeOptionPB fromBuffer(List buffer) { - return NumberTypeOptionPB.fromBuffer(buffer); - } -} - -// RichText -typedef RichTextTypeOptionContext = TypeOptionContext; - -class RichTextTypeOptionWidgetDataParser - extends TypeOptionDataParser { - @override - RichTextTypeOptionPB fromBuffer(List buffer) { - return RichTextTypeOptionPB.fromBuffer(buffer); - } -} - -// Checkbox -typedef CheckboxTypeOptionContext = TypeOptionContext; - -class CheckboxTypeOptionWidgetDataParser - extends TypeOptionDataParser { - @override - CheckboxTypeOptionPB fromBuffer(List buffer) { - return CheckboxTypeOptionPB.fromBuffer(buffer); - } -} - -// URL -typedef URLTypeOptionContext = TypeOptionContext; - -class URLTypeOptionWidgetDataParser - extends TypeOptionDataParser { - @override - URLTypeOptionPB fromBuffer(List buffer) { - return URLTypeOptionPB.fromBuffer(buffer); - } -} - -// Date -typedef DateTypeOptionContext = TypeOptionContext; - -class DateTypeOptionDataParser extends TypeOptionDataParser { - @override - DateTypeOptionPB fromBuffer(List buffer) { - return DateTypeOptionPB.fromBuffer(buffer); - } -} - -// SingleSelect -typedef SingleSelectTypeOptionContext - = TypeOptionContext; - -class SingleSelectTypeOptionWidgetDataParser - extends TypeOptionDataParser { - @override - SingleSelectTypeOptionPB fromBuffer(List buffer) { - return SingleSelectTypeOptionPB.fromBuffer(buffer); - } -} - -// Multi-select -typedef MultiSelectTypeOptionContext - = TypeOptionContext; - -class MultiSelectTypeOptionWidgetDataParser - extends TypeOptionDataParser { - @override - MultiSelectTypeOptionPB fromBuffer(List buffer) { - return MultiSelectTypeOptionPB.fromBuffer(buffer); - } -} - -class TypeOptionContext { - T? _typeOptionObject; - final TypeOptionDataParser dataParser; - final TypeOptionDataController _dataController; - - TypeOptionContext({ - required this.dataParser, - required TypeOptionDataController dataController, - }) : _dataController = dataController; - - String get gridId => _dataController.gridId; - - String get fieldId => _dataController.field.id; - - Future loadTypeOptionData({ - required void Function(T) onCompleted, - required void Function(FlowyError) onError, - }) async { - await _dataController.loadTypeOptionData().then((result) { - result.fold((l) => null, (err) => onError(err)); - }); - - onCompleted(typeOption); - } - - T get typeOption { - if (_typeOptionObject != null) { - return _typeOptionObject!; - } - - final T object = _dataController.getTypeOption(dataParser); - _typeOptionObject = object; - return object; - } - - set typeOption(T typeOption) { - _dataController.typeOptionData = typeOption.writeToBuffer(); - _typeOptionObject = typeOption; - } -} - -abstract class TypeOptionFieldDelegate { - void onFieldChanged(void Function(String) callback); - void dispose(); -} - -abstract class IFieldTypeOptionLoader { - String get gridId; - Future> load(); - - Future> switchToField( - String fieldId, FieldType fieldType) { - final payload = EditFieldPayloadPB.create() - ..gridId = gridId - ..fieldId = fieldId - ..fieldType = fieldType; - - return GridEventSwitchToField(payload).send(); - } -} - -/// Uses when creating a new field -class NewFieldTypeOptionLoader extends IFieldTypeOptionLoader { - FieldTypeOptionDataPB? fieldTypeOption; - - @override - final String gridId; - NewFieldTypeOptionLoader({ - required this.gridId, - }); - - /// Creates the field type option if the fieldTypeOption is null. - /// Otherwise, it loads the type option data from the backend. - @override - Future> load() { - if (fieldTypeOption != null) { - final payload = FieldTypeOptionIdPB.create() - ..gridId = gridId - ..fieldId = fieldTypeOption!.field_2.id - ..fieldType = fieldTypeOption!.field_2.fieldType; - - return GridEventGetFieldTypeOption(payload).send(); - } else { - final payload = CreateFieldPayloadPB.create() - ..gridId = gridId - ..fieldType = FieldType.RichText; - - return GridEventCreateFieldTypeOption(payload).send().then((result) { - return result.fold( - (newFieldTypeOption) { - fieldTypeOption = newFieldTypeOption; - return left(newFieldTypeOption); - }, - (err) => right(err), - ); - }); - } - } -} - -/// Uses when editing a existing field -class FieldTypeOptionLoader extends IFieldTypeOptionLoader { - @override - final String gridId; - final FieldPB field; - - FieldTypeOptionLoader({ - required this.gridId, - required this.field, - }); - - @override - Future> load() { - final payload = FieldTypeOptionIdPB.create() - ..gridId = gridId - ..fieldId = field.id - ..fieldType = field.fieldType; - - return GridEventGetFieldTypeOption(payload).send(); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_data_controller.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_data_controller.dart deleted file mode 100644 index e52b9f33cd2c6..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_data_controller.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; -import 'package:dartz/dartz.dart'; -import 'package:protobuf/protobuf.dart'; -import 'package:flowy_sdk/log.dart'; - -import 'type_option_context.dart'; - -class TypeOptionDataController { - final String gridId; - final IFieldTypeOptionLoader loader; - late FieldTypeOptionDataPB _data; - final PublishNotifier _fieldNotifier = PublishNotifier(); - - /// Returns a [TypeOptionDataController] used to modify the specified - /// [FieldPB]'s data - /// - /// Should call [loadTypeOptionData] if the passed-in [GridFieldContext] - /// is null - /// - TypeOptionDataController({ - required this.gridId, - required this.loader, - GridFieldContext? fieldContext, - }) { - if (fieldContext != null) { - _data = FieldTypeOptionDataPB.create() - ..gridId = gridId - ..field_2 = fieldContext.field; - } - } - - Future> loadTypeOptionData() async { - final result = await loader.load(); - return result.fold( - (data) { - data.freeze(); - _data = data; - _fieldNotifier.value = data.field_2; - return left(unit); - }, - (err) { - Log.error(err); - return right(err); - }, - ); - } - - FieldPB get field { - return _data.field_2; - } - - T getTypeOption(TypeOptionDataParser parser) { - return parser.fromBuffer(_data.typeOptionData); - } - - set fieldName(String name) { - _data = _data.rebuild((rebuildData) { - rebuildData.field_2 = rebuildData.field_2.rebuild((rebuildField) { - rebuildField.name = name; - }); - }); - - _fieldNotifier.value = _data.field_2; - - FieldService(gridId: gridId, fieldId: field.id).updateField(name: name); - } - - set typeOptionData(List typeOptionData) { - _data = _data.rebuild((rebuildData) { - if (typeOptionData.isNotEmpty) { - rebuildData.typeOptionData = typeOptionData; - } - }); - - FieldService.updateFieldTypeOption( - gridId: gridId, - fieldId: field.id, - typeOptionData: typeOptionData, - ); - } - - Future switchToField(FieldType newFieldType) async { - final result = await loader.switchToField(field.id, newFieldType); - await result.fold( - (_) { - // Should load the type-option data after switching to a new field. - // After loading the type-option data, the editor widget that uses - // the type-option data will be rebuild. - loadTypeOptionData(); - }, - (err) => Future(() => Log.error(err)), - ); - } - - void Function() addFieldListener(void Function(FieldPB) callback) { - listener() { - callback(field); - } - - _fieldNotifier.addListener(listener); - return listener; - } - - void removeFieldListener(void Function() listener) { - _fieldNotifier.removeListener(listener); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_service.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_service.dart deleted file mode 100644 index de6a000d7588a..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_service.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/cell_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; - -class TypeOptionFFIService { - final String gridId; - final String fieldId; - - TypeOptionFFIService({ - required this.gridId, - required this.fieldId, - }); - - Future> newOption({ - required String name, - }) { - final payload = CreateSelectOptionPayloadPB.create() - ..optionName = name - ..gridId = gridId - ..fieldId = fieldId; - - return GridEventNewSelectOption(payload).send(); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/grid_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/grid_bloc.dart deleted file mode 100644 index fb3a9cc5181c3..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/grid_bloc.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'dart:async'; -import 'package:dartz/dartz.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'block/block_cache.dart'; -import 'field/field_controller.dart'; -import 'grid_data_controller.dart'; -import 'row/row_cache.dart'; -import 'dart:collection'; - -import 'row/row_service.dart'; -part 'grid_bloc.freezed.dart'; - -class GridBloc extends Bloc { - final GridDataController dataController; - void Function()? _createRowOperation; - - GridBloc({required ViewPB view}) - : dataController = GridDataController(view: view), - super(GridState.initial(view.id)) { - on( - (event, emit) async { - await event.when( - initial: () async { - _startListening(); - await _openGrid(emit); - }, - createRow: () { - state.loadingState.when( - loading: () { - _createRowOperation = () => dataController.createRow(); - }, - finish: (_) => dataController.createRow(), - ); - }, - deleteRow: (rowInfo) async { - final rowService = RowFFIService( - blockId: rowInfo.rowPB.blockId, - gridId: rowInfo.gridId, - ); - await rowService.deleteRow(rowInfo.rowPB.id); - }, - didReceiveGridUpdate: (grid) { - emit(state.copyWith(grid: Some(grid))); - }, - didReceiveFieldUpdate: (fields) { - emit(state.copyWith( - fields: GridFieldEquatable(fields), - )); - }, - didReceiveRowUpdate: (newRowInfos, reason) { - emit(state.copyWith( - rowInfos: newRowInfos, - rowCount: newRowInfos.length, - reason: reason, - )); - }, - ); - }, - ); - } - - @override - Future close() async { - await dataController.dispose(); - return super.close(); - } - - GridRowCache? getRowCache(String blockId, String rowId) { - final GridBlockCache? blockCache = dataController.blocks[blockId]; - return blockCache?.rowCache; - } - - void _startListening() { - dataController.addListener( - onGridChanged: (grid) { - if (!isClosed) { - add(GridEvent.didReceiveGridUpdate(grid)); - } - }, - onRowsChanged: (rowInfos, reason) { - if (!isClosed) { - add(GridEvent.didReceiveRowUpdate(rowInfos, reason)); - } - }, - onFieldsChanged: (fields) { - if (!isClosed) { - add(GridEvent.didReceiveFieldUpdate(fields)); - } - }, - ); - } - - Future _openGrid(Emitter emit) async { - final result = await dataController.openGrid(); - result.fold( - (grid) { - if (_createRowOperation != null) { - _createRowOperation?.call(); - _createRowOperation = null; - } - emit( - state.copyWith(loadingState: GridLoadingState.finish(left(unit))), - ); - }, - (err) => emit( - state.copyWith(loadingState: GridLoadingState.finish(right(err))), - ), - ); - } -} - -@freezed -class GridEvent with _$GridEvent { - const factory GridEvent.initial() = InitialGrid; - const factory GridEvent.createRow() = _CreateRow; - const factory GridEvent.deleteRow(RowInfo rowInfo) = _DeleteRow; - const factory GridEvent.didReceiveRowUpdate( - List rows, - RowsChangedReason listState, - ) = _DidReceiveRowUpdate; - const factory GridEvent.didReceiveFieldUpdate( - UnmodifiableListView fields, - ) = _DidReceiveFieldUpdate; - - const factory GridEvent.didReceiveGridUpdate( - GridPB grid, - ) = _DidReceiveGridUpdate; -} - -@freezed -class GridState with _$GridState { - const factory GridState({ - required String gridId, - required Option grid, - required GridFieldEquatable fields, - required List rowInfos, - required int rowCount, - required GridLoadingState loadingState, - required RowsChangedReason reason, - }) = _GridState; - - factory GridState.initial(String gridId) => GridState( - fields: GridFieldEquatable(UnmodifiableListView([])), - rowInfos: [], - rowCount: 0, - grid: none(), - gridId: gridId, - loadingState: const _Loading(), - reason: const InitialListState(), - ); -} - -@freezed -class GridLoadingState with _$GridLoadingState { - const factory GridLoadingState.loading() = _Loading; - const factory GridLoadingState.finish( - Either successOrFail) = _Finish; -} - -class GridFieldEquatable extends Equatable { - final UnmodifiableListView _fields; - const GridFieldEquatable( - UnmodifiableListView fields, - ) : _fields = fields; - - @override - List get props { - if (_fields.isEmpty) { - return []; - } - - return [ - _fields.length, - _fields - .map((field) => field.width) - .reduce((value, element) => value + element), - ]; - } - - UnmodifiableListView get value => - UnmodifiableListView(_fields); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/grid_data_controller.dart b/frontend/app_flowy/lib/plugins/grid/application/grid_data_controller.dart deleted file mode 100644 index a353f60d898a6..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/grid_data_controller.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:collection'; - -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart'; -import 'dart:async'; -import 'package:dartz/dartz.dart'; -import 'block/block_cache.dart'; -import 'field/field_controller.dart'; -import 'prelude.dart'; -import 'row/row_cache.dart'; - -typedef OnFieldsChanged = void Function(UnmodifiableListView); -typedef OnGridChanged = void Function(GridPB); - -typedef OnRowsChanged = void Function( - List rowInfos, - RowsChangedReason, -); -typedef ListenOnRowChangedCondition = bool Function(); - -class GridDataController { - final String gridId; - final GridFFIService _gridFFIService; - final GridFieldController fieldController; - - // key: the block id - final LinkedHashMap _blocks; - UnmodifiableMapView get blocks => - UnmodifiableMapView(_blocks); - - OnRowsChanged? _onRowChanged; - OnFieldsChanged? _onFieldsChanged; - OnGridChanged? _onGridChanged; - - List get rowInfos { - final List rows = []; - for (var block in _blocks.values) { - rows.addAll(block.rows); - } - return rows; - } - - GridDataController({required ViewPB view}) - : gridId = view.id, - // ignore: prefer_collection_literals - _blocks = LinkedHashMap(), - _gridFFIService = GridFFIService(gridId: view.id), - fieldController = GridFieldController(gridId: view.id); - - void addListener({ - required OnGridChanged onGridChanged, - required OnRowsChanged onRowsChanged, - required OnFieldsChanged onFieldsChanged, - }) { - _onGridChanged = onGridChanged; - _onRowChanged = onRowsChanged; - _onFieldsChanged = onFieldsChanged; - - fieldController.addListener(onFields: (fields) { - _onFieldsChanged?.call(UnmodifiableListView(fields)); - }); - } - - // Loads the rows from each block - Future> openGrid() async { - final result = await _gridFFIService.openGrid(); - return Future( - () => result.fold( - (grid) async { - _initialBlocks(grid.blocks); - _onGridChanged?.call(grid); - return await fieldController.loadFields(fieldIds: grid.fields); - }, - (err) => right(err), - ), - ); - } - - Future createRow() async { - await _gridFFIService.createRow(); - } - - Future dispose() async { - await _gridFFIService.closeGrid(); - await fieldController.dispose(); - - for (final blockCache in _blocks.values) { - blockCache.dispose(); - } - } - - void _initialBlocks(List blocks) { - for (final block in blocks) { - if (_blocks[block.id] != null) { - Log.warn("Initial duplicate block's cache: ${block.id}"); - return; - } - - final cache = GridBlockCache( - gridId: gridId, - block: block, - fieldController: fieldController, - ); - - cache.addListener( - onRowsChanged: (reason) { - _onRowChanged?.call(rowInfos, reason); - }, - ); - - _blocks[block.id] = cache; - } - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/grid_header_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/grid_header_bloc.dart deleted file mode 100644 index 5f777eedf75ee..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/grid_header_bloc.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'field/field_controller.dart'; - -part 'grid_header_bloc.freezed.dart'; - -class GridHeaderBloc extends Bloc { - final GridFieldController fieldController; - final String gridId; - - GridHeaderBloc({ - required this.gridId, - required this.fieldController, - }) : super(GridHeaderState.initial(fieldController.fieldContexts)) { - on( - (event, emit) async { - await event.map( - initial: (_InitialHeader value) async { - _startListening(); - }, - didReceiveFieldUpdate: (_DidReceiveFieldUpdate value) { - emit( - state.copyWith( - fields: value.fields - .where((element) => element.visibility) - .toList(), - ), - ); - }, - moveField: (_MoveField value) async { - await _moveField(value, emit); - }, - ); - }, - ); - } - - Future _moveField( - _MoveField value, Emitter emit) async { - final fields = List.from(state.fields); - fields.insert(value.toIndex, fields.removeAt(value.fromIndex)); - emit(state.copyWith(fields: fields)); - - final fieldService = FieldService(gridId: gridId, fieldId: value.field.id); - final result = await fieldService.moveField( - value.fromIndex, - value.toIndex, - ); - result.fold((l) {}, (err) => Log.error(err)); - } - - Future _startListening() async { - fieldController.addListener( - onFields: (fields) => add(GridHeaderEvent.didReceiveFieldUpdate(fields)), - listenWhen: () => !isClosed, - ); - } - - @override - Future close() async { - return super.close(); - } -} - -@freezed -class GridHeaderEvent with _$GridHeaderEvent { - const factory GridHeaderEvent.initial() = _InitialHeader; - const factory GridHeaderEvent.didReceiveFieldUpdate( - List fields) = _DidReceiveFieldUpdate; - const factory GridHeaderEvent.moveField( - FieldPB field, int fromIndex, int toIndex) = _MoveField; -} - -@freezed -class GridHeaderState with _$GridHeaderState { - const factory GridHeaderState({required List fields}) = - _GridHeaderState; - - factory GridHeaderState.initial(List fields) { - // final List newFields = List.from(fields); - // newFields.retainWhere((field) => field.visibility); - return GridHeaderState(fields: fields); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/grid_service.dart b/frontend/app_flowy/lib/plugins/grid/application/grid_service.dart deleted file mode 100644 index eac63c86ffaa5..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/grid_service.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/group.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/row_entities.pb.dart'; - -class GridFFIService { - final String gridId; - GridFFIService({ - required this.gridId, - }); - - Future> openGrid() async { - await FolderEventSetLatestView(ViewIdPB(value: gridId)).send(); - - final payload = GridIdPB(value: gridId); - return GridEventGetGrid(payload).send(); - } - - Future> createRow({Option? startRowId}) { - var payload = CreateTableRowPayloadPB.create()..gridId = gridId; - startRowId?.fold(() => null, (id) => payload.startRowId = id); - return GridEventCreateTableRow(payload).send(); - } - - Future> createBoardCard( - String groupId, - String? startRowId, - ) { - CreateBoardCardPayloadPB payload = CreateBoardCardPayloadPB.create() - ..gridId = gridId - ..groupId = groupId; - - if (startRowId != null) { - payload.startRowId = startRowId; - } - - return GridEventCreateBoardCard(payload).send(); - } - - Future> getFields( - {required List fieldIds}) { - final payload = QueryFieldPayloadPB.create() - ..gridId = gridId - ..fieldIds = RepeatedFieldIdPB(items: fieldIds); - return GridEventGetFields(payload).send(); - } - - Future> closeGrid() { - final request = ViewIdPB(value: gridId); - return FolderEventCloseView(request).send(); - } - - Future> loadGroups() { - final payload = GridIdPB(value: gridId); - return GridEventGetGroup(payload).send(); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/prelude.dart b/frontend/app_flowy/lib/plugins/grid/application/prelude.dart deleted file mode 100644 index 7585c55e497ee..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/prelude.dart +++ /dev/null @@ -1,28 +0,0 @@ -export 'grid_bloc.dart'; -export 'row/row_bloc.dart'; -export 'row/row_service.dart'; -export 'grid_service.dart'; -export 'grid_header_bloc.dart'; - -// FieldPB -export 'field/field_service.dart'; -export 'field/field_action_sheet_bloc.dart'; -export 'field/field_editor_bloc.dart'; -export 'field/field_type_option_edit_bloc.dart'; - -// FieldPB Type Option -export 'field/type_option/date_bloc.dart'; -export 'field/type_option/number_bloc.dart'; -export 'field/type_option/single_select_type_option.dart'; - -// GridCellPB -export 'cell/text_cell_bloc.dart'; -export 'cell/number_cell_bloc.dart'; -export 'cell/select_option_cell_bloc.dart'; -export 'cell/date_cell_bloc.dart'; -export 'cell/checkbox_cell_bloc.dart'; -export 'cell/cell_service/cell_service.dart'; - -// Setting -export 'setting/setting_bloc.dart'; -export 'setting/property_bloc.dart'; diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_action_sheet_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_action_sheet_bloc.dart deleted file mode 100644 index fa81e6cb23c18..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/row/row_action_sheet_bloc.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/row/row_service.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'package:dartz/dartz.dart'; - -import 'row_cache.dart'; - -part 'row_action_sheet_bloc.freezed.dart'; - -class RowActionSheetBloc - extends Bloc { - final RowFFIService _rowService; - - RowActionSheetBloc({required RowInfo rowInfo}) - : _rowService = RowFFIService( - gridId: rowInfo.gridId, - blockId: rowInfo.rowPB.blockId, - ), - super(RowActionSheetState.initial(rowInfo)) { - on( - (event, emit) async { - await event.map( - deleteRow: (_DeleteRow value) async { - final result = await _rowService.deleteRow(state.rowData.rowPB.id); - logResult(result); - }, - duplicateRow: (_DuplicateRow value) async { - final result = - await _rowService.duplicateRow(state.rowData.rowPB.id); - logResult(result); - }, - ); - }, - ); - } - - @override - Future close() async { - return super.close(); - } - - void logResult(Either result) { - result.fold((l) => null, (err) => Log.error(err)); - } -} - -@freezed -class RowActionSheetEvent with _$RowActionSheetEvent { - const factory RowActionSheetEvent.duplicateRow() = _DuplicateRow; - const factory RowActionSheetEvent.deleteRow() = _DeleteRow; -} - -@freezed -class RowActionSheetState with _$RowActionSheetState { - const factory RowActionSheetState({ - required RowInfo rowData, - }) = _RowActionSheetState; - - factory RowActionSheetState.initial(RowInfo rowData) => RowActionSheetState( - rowData: rowData, - ); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_bloc.dart deleted file mode 100644 index 5e14c2e445d17..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/row/row_bloc.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'dart:collection'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'row_cache.dart'; -import 'row_data_controller.dart'; -import 'row_service.dart'; - -part 'row_bloc.freezed.dart'; - -class RowBloc extends Bloc { - final RowFFIService _rowService; - final GridRowDataController _dataController; - - RowBloc({ - required RowInfo rowInfo, - required GridRowDataController dataController, - }) : _rowService = RowFFIService( - gridId: rowInfo.gridId, - blockId: rowInfo.rowPB.blockId, - ), - _dataController = dataController, - super(RowState.initial(rowInfo, dataController.loadData())) { - on( - (event, emit) async { - await event.map( - initial: (_InitialRow value) async { - await _startListening(); - }, - createRow: (_CreateRow value) { - _rowService.createRow(rowInfo.rowPB.id); - }, - didReceiveCells: (_DidReceiveCells value) async { - final cells = value.gridCellMap.values - .map((e) => GridCellEquatable(e.fieldContext)) - .toList(); - emit(state.copyWith( - gridCellMap: value.gridCellMap, - cells: UnmodifiableListView(cells), - changeReason: value.reason, - )); - }, - ); - }, - ); - } - - @override - Future close() async { - _dataController.dispose(); - return super.close(); - } - - Future _startListening() async { - _dataController.addListener( - onRowChanged: (cells, reason) { - if (!isClosed) { - add(RowEvent.didReceiveCells(cells, reason)); - } - }, - ); - } -} - -@freezed -class RowEvent with _$RowEvent { - const factory RowEvent.initial() = _InitialRow; - const factory RowEvent.createRow() = _CreateRow; - const factory RowEvent.didReceiveCells( - GridCellMap gridCellMap, RowsChangedReason reason) = _DidReceiveCells; -} - -@freezed -class RowState with _$RowState { - const factory RowState({ - required RowInfo rowInfo, - required GridCellMap gridCellMap, - required UnmodifiableListView cells, - RowsChangedReason? changeReason, - }) = _RowState; - - factory RowState.initial(RowInfo rowInfo, GridCellMap cellDataMap) => - RowState( - rowInfo: rowInfo, - gridCellMap: cellDataMap, - cells: UnmodifiableListView( - cellDataMap.values - .map((e) => GridCellEquatable(e.fieldContext)) - .toList(), - ), - ); -} - -class GridCellEquatable extends Equatable { - final GridFieldContext _fieldContext; - - const GridCellEquatable(GridFieldContext field) : _fieldContext = field; - - @override - List get props => [ - _fieldContext.id, - _fieldContext.fieldType, - _fieldContext.visibility, - _fieldContext.width, - ]; -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_cache.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_cache.dart deleted file mode 100644 index 6e257429749cb..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/row/row_cache.dart +++ /dev/null @@ -1,331 +0,0 @@ -import 'dart:collection'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/row_entities.pb.dart'; -import 'package:flutter/foundation.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -part 'row_cache.freezed.dart'; - -typedef RowUpdateCallback = void Function(); - -abstract class IGridRowFieldNotifier { - UnmodifiableListView get fields; - void onRowFieldsChanged(VoidCallback callback); - void onRowFieldChanged(void Function(FieldPB) callback); - void onRowDispose(); -} - -/// Cache the rows in memory -/// Insert / delete / update row -/// -/// Read https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/frontend/grid for more information. - -class GridRowCache { - final String gridId; - final BlockPB block; - - /// _rows containers the current block's rows - /// Use List to reverse the order of the GridRow. - List _rowInfos = []; - - /// Use Map for faster access the raw row data. - final HashMap _rowByRowId; - - final GridCellCache _cellCache; - final IGridRowFieldNotifier _fieldNotifier; - final _RowChangesetNotifier _rowChangeReasonNotifier; - - UnmodifiableListView get rows => UnmodifiableListView(_rowInfos); - GridCellCache get cellCache => _cellCache; - - GridRowCache({ - required this.gridId, - required this.block, - required IGridRowFieldNotifier notifier, - }) : _cellCache = GridCellCache(gridId: gridId), - _rowByRowId = HashMap(), - _rowChangeReasonNotifier = _RowChangesetNotifier(), - _fieldNotifier = notifier { - // - notifier.onRowFieldsChanged(() => _rowChangeReasonNotifier - .receive(const RowsChangedReason.fieldDidChange())); - notifier.onRowFieldChanged( - (field) => _cellCache.removeCellWithFieldId(field.id)); - _rowInfos = block.rows.map((rowPB) => buildGridRow(rowPB)).toList(); - } - - Future dispose() async { - _fieldNotifier.onRowDispose(); - _rowChangeReasonNotifier.dispose(); - await _cellCache.dispose(); - } - - void applyChangesets(List changesets) { - for (final changeset in changesets) { - _deleteRows(changeset.deletedRows); - _insertRows(changeset.insertedRows); - _updateRows(changeset.updatedRows); - _hideRows(changeset.hideRows); - _showRows(changeset.visibleRows); - } - } - - void _deleteRows(List deletedRows) { - if (deletedRows.isEmpty) { - return; - } - - final List newRows = []; - final DeletedIndexs deletedIndex = []; - final Map deletedRowByRowId = { - for (var rowId in deletedRows) rowId: rowId - }; - - _rowInfos.asMap().forEach((index, RowInfo rowInfo) { - if (deletedRowByRowId[rowInfo.rowPB.id] == null) { - newRows.add(rowInfo); - } else { - _rowByRowId.remove(rowInfo.rowPB.id); - deletedIndex.add(DeletedIndex(index: index, row: rowInfo)); - } - }); - _rowInfos = newRows; - _rowChangeReasonNotifier.receive(RowsChangedReason.delete(deletedIndex)); - } - - void _insertRows(List insertRows) { - if (insertRows.isEmpty) { - return; - } - - InsertedIndexs insertIndexs = []; - for (final InsertedRowPB insertRow in insertRows) { - final insertIndex = InsertedIndex( - index: insertRow.index, - rowId: insertRow.row.id, - ); - insertIndexs.add(insertIndex); - _rowInfos.insert( - insertRow.index, - (buildGridRow(insertRow.row)), - ); - } - - _rowChangeReasonNotifier.receive(RowsChangedReason.insert(insertIndexs)); - } - - void _updateRows(List updatedRows) { - if (updatedRows.isEmpty) { - return; - } - - final UpdatedIndexs updatedIndexs = UpdatedIndexs(); - for (final RowPB updatedRow in updatedRows) { - final rowId = updatedRow.id; - final index = _rowInfos.indexWhere( - (rowInfo) => rowInfo.rowPB.id == rowId, - ); - if (index != -1) { - _rowByRowId[rowId] = updatedRow; - - _rowInfos.removeAt(index); - _rowInfos.insert(index, buildGridRow(updatedRow)); - updatedIndexs[rowId] = UpdatedIndex(index: index, rowId: rowId); - } - } - - _rowChangeReasonNotifier.receive(RowsChangedReason.update(updatedIndexs)); - } - - void _hideRows(List hideRows) {} - - void _showRows(List visibleRows) {} - - void onRowsChanged( - void Function(RowsChangedReason) onRowChanged, - ) { - _rowChangeReasonNotifier.addListener(() { - onRowChanged(_rowChangeReasonNotifier.reason); - }); - } - - RowUpdateCallback addListener({ - required String rowId, - void Function(GridCellMap, RowsChangedReason)? onCellUpdated, - bool Function()? listenWhen, - }) { - listenerHandler() async { - if (listenWhen != null && listenWhen() == false) { - return; - } - - notifyUpdate() { - if (onCellUpdated != null) { - final row = _rowByRowId[rowId]; - if (row != null) { - final GridCellMap cellDataMap = _makeGridCells(rowId, row); - onCellUpdated(cellDataMap, _rowChangeReasonNotifier.reason); - } - } - } - - _rowChangeReasonNotifier.reason.whenOrNull( - update: (indexs) { - if (indexs[rowId] != null) notifyUpdate(); - }, - fieldDidChange: () => notifyUpdate(), - ); - } - - _rowChangeReasonNotifier.addListener(listenerHandler); - return listenerHandler; - } - - void removeRowListener(VoidCallback callback) { - _rowChangeReasonNotifier.removeListener(callback); - } - - GridCellMap loadGridCells(String rowId) { - final RowPB? data = _rowByRowId[rowId]; - if (data == null) { - _loadRow(rowId); - } - return _makeGridCells(rowId, data); - } - - Future _loadRow(String rowId) async { - final payload = RowIdPB.create() - ..gridId = gridId - ..blockId = block.id - ..rowId = rowId; - - final result = await GridEventGetRow(payload).send(); - result.fold( - (optionRow) => _refreshRow(optionRow), - (err) => Log.error(err), - ); - } - - GridCellMap _makeGridCells(String rowId, RowPB? row) { - // ignore: prefer_collection_literals - var cellDataMap = GridCellMap(); - for (final field in _fieldNotifier.fields) { - if (field.visibility) { - cellDataMap[field.id] = GridCellIdentifier( - rowId: rowId, - gridId: gridId, - fieldContext: field, - ); - } - } - return cellDataMap; - } - - void _refreshRow(OptionalRowPB optionRow) { - if (!optionRow.hasRow()) { - return; - } - final updatedRow = optionRow.row; - updatedRow.freeze(); - - _rowByRowId[updatedRow.id] = updatedRow; - final index = - _rowInfos.indexWhere((rowInfo) => rowInfo.rowPB.id == updatedRow.id); - if (index != -1) { - // update the corresponding row in _rows if they are not the same - if (_rowInfos[index].rowPB != updatedRow) { - final rowInfo = _rowInfos.removeAt(index).copyWith(rowPB: updatedRow); - _rowInfos.insert(index, rowInfo); - - // Calculate the update index - final UpdatedIndexs updatedIndexs = UpdatedIndexs(); - updatedIndexs[rowInfo.rowPB.id] = UpdatedIndex( - index: index, - rowId: rowInfo.rowPB.id, - ); - - // - _rowChangeReasonNotifier - .receive(RowsChangedReason.update(updatedIndexs)); - } - } - } - - RowInfo buildGridRow(RowPB rowPB) { - return RowInfo( - gridId: gridId, - fields: _fieldNotifier.fields, - rowPB: rowPB, - ); - } -} - -class _RowChangesetNotifier extends ChangeNotifier { - RowsChangedReason reason = const InitialListState(); - - _RowChangesetNotifier(); - - void receive(RowsChangedReason newReason) { - reason = newReason; - reason.map( - insert: (_) => notifyListeners(), - delete: (_) => notifyListeners(), - update: (_) => notifyListeners(), - fieldDidChange: (_) => notifyListeners(), - initial: (_) {}, - ); - } -} - -@freezed -class RowInfo with _$RowInfo { - const factory RowInfo({ - required String gridId, - required UnmodifiableListView fields, - required RowPB rowPB, - }) = _RowInfo; -} - -typedef InsertedIndexs = List; -typedef DeletedIndexs = List; -typedef UpdatedIndexs = LinkedHashMap; - -@freezed -class RowsChangedReason with _$RowsChangedReason { - const factory RowsChangedReason.insert(InsertedIndexs items) = _Insert; - const factory RowsChangedReason.delete(DeletedIndexs items) = _Delete; - const factory RowsChangedReason.update(UpdatedIndexs indexs) = _Update; - const factory RowsChangedReason.fieldDidChange() = _FieldDidChange; - const factory RowsChangedReason.initial() = InitialListState; -} - -class InsertedIndex { - final int index; - final String rowId; - InsertedIndex({ - required this.index, - required this.rowId, - }); -} - -class DeletedIndex { - final int index; - final RowInfo row; - DeletedIndex({ - required this.index, - required this.row, - }); -} - -class UpdatedIndex { - final int index; - final String rowId; - UpdatedIndex({ - required this.index, - required this.rowId, - }); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_data_controller.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_data_controller.dart deleted file mode 100644 index e5fc6161bb174..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/row/row_data_controller.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_field_notifier.dart'; -import 'package:flutter/material.dart'; -import '../../presentation/widgets/cell/cell_builder.dart'; -import '../cell/cell_service/cell_service.dart'; -import '../field/field_controller.dart'; -import 'row_cache.dart'; - -typedef OnRowChanged = void Function(GridCellMap, RowsChangedReason); - -class GridRowDataController extends GridCellBuilderDelegate { - final RowInfo rowInfo; - final List _onRowChangedListeners = []; - final GridFieldController _fieldController; - final GridRowCache _rowCache; - - GridRowDataController({ - required this.rowInfo, - required GridFieldController fieldController, - required GridRowCache rowCache, - }) : _fieldController = fieldController, - _rowCache = rowCache; - - GridCellMap loadData() { - return _rowCache.loadGridCells(rowInfo.rowPB.id); - } - - void addListener({OnRowChanged? onRowChanged}) { - _onRowChangedListeners.add(_rowCache.addListener( - rowId: rowInfo.rowPB.id, - onCellUpdated: onRowChanged, - )); - } - - void dispose() { - for (final fn in _onRowChangedListeners) { - _rowCache.removeRowListener(fn); - } - } - - // GridCellBuilderDelegate implementation - @override - GridCellFieldNotifier buildFieldNotifier() { - return GridCellFieldNotifier( - notifier: GridCellFieldNotifierImpl(_fieldController)); - } - - @override - GridCellCache get cellCache => _rowCache.cellCache; -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_detail_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_detail_bloc.dart deleted file mode 100644 index 10b4fe3cf8d98..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/row/row_detail_bloc.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'row_data_controller.dart'; -part 'row_detail_bloc.freezed.dart'; - -class RowDetailBloc extends Bloc { - final GridRowDataController dataController; - - RowDetailBloc({ - required this.dataController, - }) : super(RowDetailState.initial()) { - on( - (event, emit) async { - await event.map( - initial: (_Initial value) async { - await _startListening(); - final cells = dataController.loadData(); - if (!isClosed) { - add(RowDetailEvent.didReceiveCellDatas(cells.values.toList())); - } - }, - didReceiveCellDatas: (_DidReceiveCellDatas value) { - emit(state.copyWith(gridCells: value.gridCells)); - }, - deleteField: (_DeleteField value) { - final fieldService = FieldService( - gridId: dataController.rowInfo.gridId, - fieldId: value.fieldId, - ); - fieldService.deleteField(); - }, - ); - }, - ); - } - - @override - Future close() async { - dataController.dispose(); - return super.close(); - } - - Future _startListening() async { - dataController.addListener( - onRowChanged: (cells, reason) { - if (!isClosed) { - add(RowDetailEvent.didReceiveCellDatas(cells.values.toList())); - } - }, - ); - } -} - -@freezed -class RowDetailEvent with _$RowDetailEvent { - const factory RowDetailEvent.initial() = _Initial; - const factory RowDetailEvent.deleteField(String fieldId) = _DeleteField; - const factory RowDetailEvent.didReceiveCellDatas( - List gridCells) = _DidReceiveCellDatas; -} - -@freezed -class RowDetailState with _$RowDetailState { - const factory RowDetailState({ - required List gridCells, - }) = _RowDetailState; - - factory RowDetailState.initial() => RowDetailState( - gridCells: List.empty(), - ); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_listener.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_listener.dart deleted file mode 100644 index 1df24c50c2348..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/row/row_listener.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:app_flowy/core/grid_notification.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/dart_notification.pb.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'dart:async'; -import 'dart:typed_data'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; - -typedef UpdateRowNotifiedValue = Either; -typedef UpdateFieldNotifiedValue = Either, FlowyError>; - -class RowListener { - final String rowId; - PublishNotifier? updateRowNotifier = - PublishNotifier(); - GridNotificationListener? _listener; - - RowListener({required this.rowId}); - - void start() { - _listener = GridNotificationListener(objectId: rowId, handler: _handler); - } - - void _handler(GridNotification ty, Either result) { - switch (ty) { - case GridNotification.DidUpdateRow: - result.fold( - (payload) => - updateRowNotifier?.value = left(RowPB.fromBuffer(payload)), - (error) => updateRowNotifier?.value = right(error), - ); - break; - default: - break; - } - } - - Future stop() async { - await _listener?.stop(); - updateRowNotifier?.dispose(); - updateRowNotifier = null; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_service.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_service.dart deleted file mode 100644 index 2612f5975c6b1..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/row/row_service.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/group_changeset.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/row_entities.pb.dart'; - -class RowFFIService { - final String gridId; - final String blockId; - - RowFFIService({ - required this.gridId, - required this.blockId, - }); - - Future> createRow(String rowId) { - final payload = CreateTableRowPayloadPB.create() - ..gridId = gridId - ..startRowId = rowId; - - return GridEventCreateTableRow(payload).send(); - } - - Future> getRow(String rowId) { - final payload = RowIdPB.create() - ..gridId = gridId - ..blockId = blockId - ..rowId = rowId; - - return GridEventGetRow(payload).send(); - } - - Future> deleteRow(String rowId) { - final payload = RowIdPB.create() - ..gridId = gridId - ..blockId = blockId - ..rowId = rowId; - - return GridEventDeleteRow(payload).send(); - } - - Future> duplicateRow(String rowId) { - final payload = RowIdPB.create() - ..gridId = gridId - ..blockId = blockId - ..rowId = rowId; - - return GridEventDuplicateRow(payload).send(); - } -} - -class MoveRowFFIService { - final String gridId; - - MoveRowFFIService({ - required this.gridId, - }); - - Future> moveRow({ - required String fromRowId, - required String toRowId, - }) { - var payload = MoveRowPayloadPB.create() - ..viewId = gridId - ..fromRowId = fromRowId - ..toRowId = toRowId; - - return GridEventMoveRow(payload).send(); - } - - Future> moveGroupRow({ - required String fromRowId, - required String toGroupId, - required String? toRowId, - }) { - var payload = MoveGroupRowPayloadPB.create() - ..viewId = gridId - ..fromRowId = fromRowId - ..toGroupId = toGroupId; - - if (toRowId != null) { - payload.toRowId = toRowId; - } - - return GridEventMoveGroupRow(payload).send(); - } - - Future> moveGroup({ - required String fromGroupId, - required String toGroupId, - }) { - final payload = MoveGroupPayloadPB.create() - ..viewId = gridId - ..fromGroupId = fromGroupId - ..toGroupId = toGroupId; - - return GridEventMoveGroup(payload).send(); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/setting/group_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/setting/group_bloc.dart deleted file mode 100644 index a4341517acc98..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/setting/group_bloc.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; - -import '../field/field_controller.dart'; -import 'setting_service.dart'; - -part 'group_bloc.freezed.dart'; - -class GridGroupBloc extends Bloc { - final GridFieldController _fieldController; - final SettingFFIService _settingFFIService; - Function(List)? _onFieldsFn; - - GridGroupBloc({ - required String viewId, - required GridFieldController fieldController, - }) : _fieldController = fieldController, - _settingFFIService = SettingFFIService(viewId: viewId), - super(GridGroupState.initial(viewId, fieldController.fieldContexts)) { - on( - (event, emit) async { - event.when( - initial: () { - _startListening(); - }, - didReceiveFieldUpdate: (fieldContexts) { - emit(state.copyWith(fieldContexts: fieldContexts)); - }, - setGroupByField: (String fieldId, FieldType fieldType) async { - final result = await _settingFFIService.groupByField( - fieldId: fieldId, - fieldType: fieldType, - ); - result.fold((l) => null, (err) => Log.error(err)); - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onFieldsFn != null) { - _fieldController.removeListener(onFieldsListener: _onFieldsFn!); - _onFieldsFn = null; - } - return super.close(); - } - - void _startListening() { - _onFieldsFn = (fieldContexts) => - add(GridGroupEvent.didReceiveFieldUpdate(fieldContexts)); - _fieldController.addListener( - onFields: _onFieldsFn, - listenWhen: () => !isClosed, - ); - } -} - -@freezed -class GridGroupEvent with _$GridGroupEvent { - const factory GridGroupEvent.initial() = _Initial; - const factory GridGroupEvent.setGroupByField( - String fieldId, - FieldType fieldType, - ) = _GroupByField; - const factory GridGroupEvent.didReceiveFieldUpdate( - List fields) = _DidReceiveFieldUpdate; -} - -@freezed -class GridGroupState with _$GridGroupState { - const factory GridGroupState({ - required String gridId, - required List fieldContexts, - }) = _GridGroupState; - - factory GridGroupState.initial( - String gridId, List fieldContexts) => - GridGroupState( - gridId: gridId, - fieldContexts: fieldContexts, - ); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/setting/property_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/setting/property_bloc.dart deleted file mode 100644 index c54f16ae12fd1..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/setting/property_bloc.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; - -import '../field/field_controller.dart'; - -part 'property_bloc.freezed.dart'; - -class GridPropertyBloc extends Bloc { - final GridFieldController _fieldController; - Function(List)? _onFieldsFn; - - GridPropertyBloc( - {required String gridId, required GridFieldController fieldController}) - : _fieldController = fieldController, - super( - GridPropertyState.initial(gridId, fieldController.fieldContexts)) { - on( - (event, emit) async { - await event.map( - initial: (_Initial value) { - _startListening(); - }, - setFieldVisibility: (_SetFieldVisibility value) async { - final fieldService = - FieldService(gridId: gridId, fieldId: value.fieldId); - final result = - await fieldService.updateField(visibility: value.visibility); - result.fold( - (l) => null, - (err) => Log.error(err), - ); - }, - didReceiveFieldUpdate: (_DidReceiveFieldUpdate value) { - emit(state.copyWith(fieldContexts: value.fields)); - }, - moveField: (_MoveField value) { - // - }, - ); - }, - ); - } - - @override - Future close() async { - if (_onFieldsFn != null) { - _fieldController.removeListener(onFieldsListener: _onFieldsFn!); - _onFieldsFn = null; - } - return super.close(); - } - - void _startListening() { - _onFieldsFn = - (fields) => add(GridPropertyEvent.didReceiveFieldUpdate(fields)); - _fieldController.addListener( - onFields: _onFieldsFn, - listenWhen: () => !isClosed, - ); - } -} - -@freezed -class GridPropertyEvent with _$GridPropertyEvent { - const factory GridPropertyEvent.initial() = _Initial; - const factory GridPropertyEvent.setFieldVisibility( - String fieldId, bool visibility) = _SetFieldVisibility; - const factory GridPropertyEvent.didReceiveFieldUpdate( - List fields) = _DidReceiveFieldUpdate; - const factory GridPropertyEvent.moveField(int fromIndex, int toIndex) = - _MoveField; -} - -@freezed -class GridPropertyState with _$GridPropertyState { - const factory GridPropertyState({ - required String gridId, - required List fieldContexts, - }) = _GridPropertyState; - - factory GridPropertyState.initial( - String gridId, - List fieldContexts, - ) => - GridPropertyState( - gridId: gridId, - fieldContexts: fieldContexts, - ); -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/setting/setting_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/setting/setting_bloc.dart deleted file mode 100644 index bea2b87da60ba..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/setting/setting_bloc.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'dart:async'; -import 'package:dartz/dartz.dart'; - -part 'setting_bloc.freezed.dart'; - -class GridSettingBloc extends Bloc { - final String gridId; - GridSettingBloc({required this.gridId}) : super(GridSettingState.initial()) { - on( - (event, emit) async { - event.map(performAction: (_PerformAction value) { - emit(state.copyWith(selectedAction: Some(value.action))); - }); - }, - ); - } - - @override - Future close() async { - return super.close(); - } -} - -@freezed -class GridSettingEvent with _$GridSettingEvent { - const factory GridSettingEvent.performAction(GridSettingAction action) = _PerformAction; -} - -@freezed -class GridSettingState with _$GridSettingState { - const factory GridSettingState({ - required Option selectedAction, - }) = _GridSettingState; - - factory GridSettingState.initial() => GridSettingState( - selectedAction: none(), - ); -} - -enum GridSettingAction { - filter, - sortBy, - properties, -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/setting/setting_controller.dart b/frontend/app_flowy/lib/plugins/grid/application/setting/setting_controller.dart deleted file mode 100644 index 795adc4622b03..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/setting/setting_controller.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/setting/setting_service.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/setting_entities.pb.dart'; -import 'setting_listener.dart'; - -typedef OnError = void Function(FlowyError); -typedef OnSettingUpdated = void Function(GridSettingPB); - -class SettingController { - final String viewId; - final SettingFFIService _ffiService; - final SettingListener _listener; - OnSettingUpdated? _onSettingUpdated; - OnError? _onError; - GridSettingPB? _setting; - GridSettingPB? get setting => _setting; - - SettingController({ - required this.viewId, - }) : _ffiService = SettingFFIService(viewId: viewId), - _listener = SettingListener(gridId: viewId) { - // Load setting - _ffiService.getSetting().then((result) { - result.fold( - (newSetting) => updateSetting(newSetting), - (err) => _onError?.call(err), - ); - }); - - // Listen on the seting changes - _listener.start(onSettingUpdated: (result) { - result.fold( - (newSetting) => updateSetting(newSetting), - (err) => _onError?.call(err), - ); - }); - } - - void startListeing({ - required OnSettingUpdated onSettingUpdated, - required OnError onError, - }) { - assert(_onSettingUpdated == null, 'Should call once'); - assert(_onError == null, 'Should call once'); - _onSettingUpdated = onSettingUpdated; - _onError = onError; - } - - void updateSetting(GridSettingPB newSetting) { - _setting = newSetting; - _onSettingUpdated?.call(newSetting); - } - - void dispose() { - _onSettingUpdated = null; - _onError = null; - _listener.stop(); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/setting/setting_listener.dart b/frontend/app_flowy/lib/plugins/grid/application/setting/setting_listener.dart deleted file mode 100644 index 00de48ca78f44..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/setting/setting_listener.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'dart:typed_data'; - -import 'package:app_flowy/core/grid_notification.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/dart_notification.pbserver.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/setting_entities.pb.dart'; - -typedef UpdateSettingNotifiedValue = Either; - -class SettingListener { - final String gridId; - GridNotificationListener? _listener; - PublishNotifier? _updateSettingNotifier = - PublishNotifier(); - - SettingListener({required this.gridId}); - - void start({ - required void Function(UpdateSettingNotifiedValue) onSettingUpdated, - }) { - _updateSettingNotifier?.addPublishListener(onSettingUpdated); - _listener = GridNotificationListener(objectId: gridId, handler: _handler); - } - - void _handler(GridNotification ty, Either result) { - switch (ty) { - case GridNotification.DidUpdateGridSetting: - result.fold( - (payload) => _updateSettingNotifier?.value = left( - GridSettingPB.fromBuffer(payload), - ), - (error) => _updateSettingNotifier?.value = right(error), - ); - break; - default: - break; - } - } - - Future stop() async { - await _listener?.stop(); - _updateSettingNotifier?.dispose(); - _updateSettingNotifier = null; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/setting/setting_service.dart b/frontend/app_flowy/lib/plugins/grid/application/setting/setting_service.dart deleted file mode 100644 index b050809bb52ec..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/application/setting/setting_service.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/group.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/setting_entities.pb.dart'; - -class SettingFFIService { - final String viewId; - - const SettingFFIService({required this.viewId}); - - Future> getSetting() { - final payload = GridIdPB.create()..value = viewId; - return GridEventGetGridSetting(payload).send(); - } - - Future> groupByField({ - required String fieldId, - required FieldType fieldType, - }) { - final insertGroupPayload = InsertGroupPayloadPB.create() - ..fieldId = fieldId - ..fieldType = fieldType; - final payload = GridSettingChangesetPayloadPB.create() - ..gridId = viewId - ..insertGroup = insertGroupPayload; - - return GridEventUpdateGridSetting(payload).send(); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/grid.dart b/frontend/app_flowy/lib/plugins/grid/grid.dart deleted file mode 100644 index 2532b5bc57943..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/grid.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/plugins/util.dart'; -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; -import 'package:app_flowy/workspace/presentation/widgets/left_bar_item.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter/material.dart'; - -import 'presentation/grid_page.dart'; - -class GridPluginBuilder implements PluginBuilder { - @override - Plugin build(dynamic data) { - if (data is ViewPB) { - return GridPlugin(pluginType: pluginType, view: data); - } else { - throw FlowyPluginException.invalidData; - } - } - - @override - String get menuName => LocaleKeys.grid_menuName.tr(); - - @override - PluginType get pluginType => PluginType.grid; - - @override - ViewDataFormatPB get dataFormatType => ViewDataFormatPB.DatabaseFormat; - - @override - ViewLayoutTypePB? get layoutType => ViewLayoutTypePB.Grid; -} - -class GridPluginConfig implements PluginConfig { - @override - bool get creatable => true; -} - -class GridPlugin extends Plugin { - @override - final ViewPluginNotifier notifier; - final PluginType _pluginType; - - GridPlugin({ - required ViewPB view, - required PluginType pluginType, - }) : _pluginType = pluginType, - notifier = ViewPluginNotifier(view: view); - - @override - PluginDisplay get display => GridPluginDisplay(notifier: notifier); - - @override - PluginId get id => notifier.view.id; - - @override - PluginType get ty => _pluginType; -} - -class GridPluginDisplay extends PluginDisplay { - final ViewPluginNotifier notifier; - ViewPB get view => notifier.view; - - GridPluginDisplay({required this.notifier, Key? key}); - - @override - Widget get leftBarItem => ViewLeftBarItem(view: view); - - @override - Widget buildWidget(PluginContext context) { - notifier.isDeleted.addListener(() { - notifier.isDeleted.value.fold(() => null, (deletedView) { - if (deletedView.hasIndex()) { - context.onDeleted(view, deletedView.index); - } - }); - }); - - return GridPage(key: ValueKey(view.id), view: view); - } - - @override - List get navigationItems => [this]; -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/controller/flowy_table_selection.dart b/frontend/app_flowy/lib/plugins/grid/presentation/controller/flowy_table_selection.dart deleted file mode 100755 index 7f01b7dcf0473..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/controller/flowy_table_selection.dart +++ /dev/null @@ -1,73 +0,0 @@ -/// The data structor representing each selection of flowy table. -enum FlowyTableSelectionType { - item, - row, - col, -} - -class FlowyTableSelectionItem { - final FlowyTableSelectionType type; - final int? row; - final int? column; - - const FlowyTableSelectionItem({ - required this.type, - this.row, - this.column, - }); - - @override - String toString() { - return '$type($row, $column)'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - return other is FlowyTableSelectionItem && - type == other.type && - row == other.row && - column == other.column; - } - - @override - int get hashCode => type.hashCode ^ row.hashCode ^ column.hashCode; -} - -class FlowyTableSelection { - Set _items = {}; - - Set get items => _items; - - FlowyTableSelection( - this._items, - ); - - FlowyTableSelection.combine( - FlowyTableSelection lhs, FlowyTableSelection rhs) { - this..combine(lhs)..combine(rhs); - } - - FlowyTableSelection operator +(FlowyTableSelection other) { - return this..combine(other); - } - - void combine(FlowyTableSelection other) { - var totalItems = items..union(other.items); - final rows = totalItems - .where((ele) => ele.type == FlowyTableSelectionType.row) - .map((e) => e.row) - .toSet(); - final cols = totalItems - .where((ele) => ele.type == FlowyTableSelectionType.col) - .map((e) => e.column) - .toSet(); - totalItems.removeWhere((ele) { - return ele.type == FlowyTableSelectionType.item && - (rows.contains(ele.row) || cols.contains(ele.column)); - }); - _items = totalItems; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/grid_page.dart b/frontend/app_flowy/lib/plugins/grid/presentation/grid_page.dart deleted file mode 100755 index 38a437dee3c77..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/grid_page.dart +++ /dev/null @@ -1,360 +0,0 @@ -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_data_controller.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/plugins/grid/application/grid_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter/material.dart'; -import 'package:linked_scroll_controller/linked_scroll_controller.dart'; -import '../application/row/row_cache.dart'; -import 'controller/grid_scroll.dart'; -import 'layout/layout.dart'; -import 'layout/sizes.dart'; -import 'widgets/cell/cell_builder.dart'; -import 'widgets/row/grid_row.dart'; -import 'widgets/footer/grid_footer.dart'; -import 'widgets/header/grid_header.dart'; -import 'widgets/row/row_detail.dart'; -import 'widgets/shortcuts.dart'; -import 'widgets/toolbar/grid_toolbar.dart'; - -class GridPage extends StatefulWidget { - final ViewPB view; - final VoidCallback? onDeleted; - - GridPage({ - required this.view, - this.onDeleted, - Key? key, - }) : super(key: ValueKey(view.id)); - - @override - State createState() => _GridPageState(); -} - -class _GridPageState extends State { - @override - Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => getIt(param1: widget.view) - ..add(const GridEvent.initial()), - ), - ], - child: BlocBuilder( - builder: (context, state) { - return state.loadingState.map( - loading: (_) => - const Center(child: CircularProgressIndicator.adaptive()), - finish: (result) => result.successOrFail.fold( - (_) => const GridShortcuts(child: FlowyGrid()), - (err) => FlowyErrorPage(err.toString()), - ), - ); - }, - ), - ); - } - - @override - void dispose() { - super.dispose(); - } - - @override - void deactivate() { - super.deactivate(); - } - - @override - void didUpdateWidget(covariant GridPage oldWidget) { - super.didUpdateWidget(oldWidget); - } -} - -class FlowyGrid extends StatefulWidget { - const FlowyGrid({Key? key}) : super(key: key); - - @override - State createState() => _FlowyGridState(); -} - -class _FlowyGridState extends State { - final _scrollController = GridScrollController( - scrollGroupController: LinkedScrollControllerGroup()); - late ScrollController headerScrollController; - - @override - void initState() { - headerScrollController = _scrollController.linkHorizontalController(); - super.initState(); - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - buildWhen: (previous, current) => previous.fields != current.fields, - builder: (context, state) { - final contentWidth = GridLayout.headerWidth(state.fields.value); - final child = _wrapScrollView( - contentWidth, - [ - const _GridRows(), - const _GridFooter(), - ], - ); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _GridToolbarAdaptor(), - _gridHeader(context, state.gridId), - Flexible(child: child), - const RowCountBadge(), - ], - ); - }, - ); - } - - Widget _wrapScrollView( - double contentWidth, - List slivers, - ) { - final verticalScrollView = ScrollConfiguration( - behavior: const ScrollBehavior().copyWith(scrollbars: false), - child: CustomScrollView( - physics: StyledScrollPhysics(), - controller: _scrollController.verticalController, - slivers: slivers, - ), - ); - - final sizedVerticalScrollView = SizedBox( - width: contentWidth, - child: verticalScrollView, - ); - - final horizontalScrollView = StyledSingleChildScrollView( - controller: _scrollController.horizontalController, - axis: Axis.horizontal, - child: sizedVerticalScrollView, - ); - - return ScrollbarListStack( - axis: Axis.vertical, - controller: _scrollController.verticalController, - barSize: GridSize.scrollBarSize, - child: horizontalScrollView, - ); - } - - Widget _gridHeader(BuildContext context, String gridId) { - final fieldController = - context.read().dataController.fieldController; - return GridHeaderSliverAdaptor( - gridId: gridId, - fieldController: fieldController, - anchorScrollController: headerScrollController, - ); - } -} - -class _GridToolbarAdaptor extends StatelessWidget { - const _GridToolbarAdaptor({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocSelector( - selector: (state) { - final fieldController = - context.read().dataController.fieldController; - return GridToolbarContext( - gridId: state.gridId, - fieldController: fieldController, - ); - }, - builder: (context, toolbarContext) { - return GridToolbar(toolbarContext: toolbarContext); - }, - ); - } -} - -class _GridRows extends StatefulWidget { - const _GridRows({Key? key}) : super(key: key); - - @override - State<_GridRows> createState() => _GridRowsState(); -} - -class _GridRowsState extends State<_GridRows> { - final _key = GlobalKey(); - - @override - Widget build(BuildContext context) { - return BlocConsumer( - listenWhen: (previous, current) => previous.reason != current.reason, - listener: (context, state) { - state.reason.mapOrNull( - insert: (value) { - for (final item in value.items) { - _key.currentState?.insertItem(item.index); - } - }, - delete: (value) { - for (final item in value.items) { - _key.currentState?.removeItem( - item.index, - (context, animation) => - _renderRow(context, item.row, animation), - ); - } - }, - ); - }, - buildWhen: (previous, current) => false, - builder: (context, state) { - return SliverAnimatedList( - key: _key, - initialItemCount: context.read().state.rowInfos.length, - itemBuilder: - (BuildContext context, int index, Animation animation) { - final RowInfo rowInfo = - context.read().state.rowInfos[index]; - return _renderRow(context, rowInfo, animation); - }, - ); - }, - ); - } - - Widget _renderRow( - BuildContext context, - RowInfo rowInfo, - Animation animation, - ) { - final rowCache = context.read().getRowCache( - rowInfo.rowPB.blockId, - rowInfo.rowPB.id, - ); - - /// Return placeholder widget if the rowCache is null. - if (rowCache == null) return const SizedBox(); - - final fieldController = - context.read().dataController.fieldController; - final dataController = GridRowDataController( - rowInfo: rowInfo, - fieldController: fieldController, - rowCache: rowCache, - ); - - return SizeTransition( - sizeFactor: animation, - child: GridRowWidget( - rowInfo: rowInfo, - dataController: dataController, - cellBuilder: GridCellBuilder(delegate: dataController), - openDetailPage: (context, cellBuilder) { - _openRowDetailPage( - context, - rowInfo, - fieldController, - rowCache, - cellBuilder, - ); - }, - key: ValueKey(rowInfo.rowPB.id), - ), - ); - } - - void _openRowDetailPage( - BuildContext context, - RowInfo rowInfo, - GridFieldController fieldController, - GridRowCache rowCache, - GridCellBuilder cellBuilder, - ) { - final dataController = GridRowDataController( - rowInfo: rowInfo, - fieldController: fieldController, - rowCache: rowCache, - ); - - FlowyOverlay.show( - context: context, - builder: (BuildContext context) { - return RowDetailPage( - cellBuilder: cellBuilder, - dataController: dataController, - ); - }); - } -} - -class _GridFooter extends StatelessWidget { - const _GridFooter({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return SliverPadding( - padding: const EdgeInsets.only(bottom: 200), - sliver: SliverToBoxAdapter( - child: SizedBox( - height: GridSize.footerHeight, - child: Padding( - padding: GridSize.footerContentInsets, - child: const SizedBox(height: 40, child: GridAddRowButton()), - ), - ), - ), - ); - } -} - -class RowCountBadge extends StatelessWidget { - const RowCountBadge({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - return BlocSelector( - selector: (state) => state.rowCount, - builder: (context, rowCount) { - return Padding( - padding: GridSize.footerContentInsets, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - FlowyText.regular( - '${LocaleKeys.grid_row_count.tr()} : ', - fontSize: 13, - color: theme.shader3, - ), - FlowyText.regular(rowCount.toString(), fontSize: 13), - ], - ), - ); - }, - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/layout/layout.dart b/frontend/app_flowy/lib/plugins/grid/presentation/layout/layout.dart deleted file mode 100755 index 72b1eb643d136..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/layout/layout.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'sizes.dart'; - -class GridLayout { - static double headerWidth(List fields) { - if (fields.isEmpty) return 0; - - final fieldsWidth = fields - .map((field) => field.width.toDouble()) - .reduce((value, element) => value + element); - - return fieldsWidth + - GridSize.leadingHeaderPadding + - GridSize.trailHeaderPadding; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/layout/sizes.dart b/frontend/app_flowy/lib/plugins/grid/presentation/layout/sizes.dart deleted file mode 100755 index 18dbb68adf90d..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/layout/sizes.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/widgets.dart'; - -class GridSize { - static double scale = 1; - - static double get scrollBarSize => 12 * scale; - static double get headerHeight => 40 * scale; - static double get footerHeight => 40 * scale; - static double get leadingHeaderPadding => 50 * scale; - static double get trailHeaderPadding => 140 * scale; - static double get headerContainerPadding => 0 * scale; - static double get cellHPadding => 10 * scale; - static double get cellVPadding => 10 * scale; - static double get typeOptionItemHeight => 32 * scale; - static double get typeOptionSeparatorHeight => 4 * scale; - - // - static EdgeInsets get headerContentInsets => EdgeInsets.symmetric( - horizontal: GridSize.headerContainerPadding, - vertical: GridSize.headerContainerPadding, - ); - static EdgeInsets get cellContentInsets => EdgeInsets.symmetric( - horizontal: GridSize.cellHPadding, - vertical: GridSize.cellVPadding, - ); - - static EdgeInsets get fieldContentInsets => EdgeInsets.symmetric( - horizontal: GridSize.cellHPadding, - vertical: GridSize.cellVPadding, - ); - - static EdgeInsets get typeOptionContentInsets => const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ); - - static EdgeInsets get footerContentInsets => EdgeInsets.fromLTRB( - GridSize.leadingHeaderPadding, - GridSize.headerContainerPadding, - GridSize.headerContainerPadding, - GridSize.headerContainerPadding, - ); -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_accessory.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_accessory.dart deleted file mode 100644 index 3d80227c891ff..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_accessory.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:styled_widget/styled_widget.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -import 'cell_builder.dart'; - -class GridCellAccessoryBuildContext { - final BuildContext anchorContext; - final bool isCellEditing; - - GridCellAccessoryBuildContext({ - required this.anchorContext, - required this.isCellEditing, - }); -} - -class GridCellAccessoryBuilder { - final GlobalKey _key = GlobalKey(); - - final Widget Function(Key key) _builder; - - GridCellAccessoryBuilder({required Widget Function(Key key) builder}) - : _builder = builder; - - Widget build() => _builder(_key); - - void onTap() { - (_key.currentState as GridCellAccessoryState).onTap(); - } - - bool enable() { - if (_key.currentState == null) { - return true; - } - return (_key.currentState as GridCellAccessoryState).enable(); - } -} - -abstract class GridCellAccessoryState { - void onTap(); - - // The accessory will be hidden if enable() return false; - bool enable() => true; -} - -class PrimaryCellAccessory extends StatefulWidget { - final VoidCallback onTapCallback; - final bool isCellEditing; - const PrimaryCellAccessory({ - required this.onTapCallback, - required this.isCellEditing, - Key? key, - }) : super(key: key); - - @override - State createState() => _PrimaryCellAccessoryState(); -} - -class _PrimaryCellAccessoryState extends State - with GridCellAccessoryState { - @override - Widget build(BuildContext context) { - if (widget.isCellEditing) { - return const SizedBox(); - } else { - final theme = context.watch(); - return Tooltip( - message: LocaleKeys.tooltip_openAsPage.tr(), - textStyle: TextStyles.caption.textColor(Colors.white), - child: svgWidget( - "grid/expander", - color: theme.main1, - ), - ); - } - } - - @override - void onTap() => widget.onTapCallback(); - - @override - bool enable() => !widget.isCellEditing; -} - -class AccessoryHover extends StatefulWidget { - final CellAccessory child; - final EdgeInsets contentPadding; - const AccessoryHover({ - required this.child, - this.contentPadding = EdgeInsets.zero, - Key? key, - }) : super(key: key); - - @override - State createState() => _AccessoryHoverState(); -} - -class _AccessoryHoverState extends State { - late AccessoryHoverState _hoverState; - VoidCallback? _listenerFn; - - @override - void initState() { - _hoverState = AccessoryHoverState(); - _listenerFn = () => - _hoverState.onHover = widget.child.onAccessoryHover?.value ?? false; - widget.child.onAccessoryHover?.addListener(_listenerFn!); - - super.initState(); - } - - @override - void dispose() { - _hoverState.dispose(); - - if (_listenerFn != null) { - widget.child.onAccessoryHover?.removeListener(_listenerFn!); - _listenerFn = null; - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - List children = [ - const _Background(), - Padding(padding: widget.contentPadding, child: widget.child), - ]; - - final accessoryBuilder = widget.child.accessoryBuilder; - if (accessoryBuilder != null) { - final accessories = accessoryBuilder((GridCellAccessoryBuildContext( - anchorContext: context, - isCellEditing: false, - ))); - children.add( - Padding( - padding: const EdgeInsets.only(right: 6), - child: CellAccessoryContainer(accessories: accessories), - ).positioned(right: 0), - ); - } - - return ChangeNotifierProvider.value( - value: _hoverState, - child: MouseRegion( - cursor: SystemMouseCursors.click, - opaque: false, - onEnter: (p) => setState(() => _hoverState.onHover = true), - onExit: (p) => setState(() => _hoverState.onHover = false), - child: Stack( - fit: StackFit.loose, - alignment: AlignmentDirectional.center, - children: children, - ), - ), - ); - } -} - -class AccessoryHoverState extends ChangeNotifier { - bool _onHover = false; - - set onHover(bool value) { - if (_onHover != value) { - _onHover = value; - notifyListeners(); - } - } - - bool get onHover => _onHover; -} - -class _Background extends StatelessWidget { - const _Background({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return Consumer( - builder: (context, state, child) { - if (state.onHover) { - return FlowyHoverContainer( - style: HoverStyle( - borderRadius: Corners.s6Border, hoverColor: theme.shader6), - ); - } else { - return const SizedBox(); - } - }, - ); - } -} - -class CellAccessoryContainer extends StatelessWidget { - final List accessories; - const CellAccessoryContainer({required this.accessories, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - final children = - accessories.where((accessory) => accessory.enable()).map((accessory) { - final hover = FlowyHover( - style: - HoverStyle(hoverColor: theme.bg3, backgroundColor: theme.surface), - builder: (_, onHover) => Container( - width: 26, - height: 26, - padding: const EdgeInsets.all(3), - child: accessory.build(), - ), - ); - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => accessory.onTap(), - child: hover, - ); - }).toList(); - - return Wrap(spacing: 6, children: children); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_builder.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_builder.dart deleted file mode 100755 index 1f885c3821482..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_builder.dart +++ /dev/null @@ -1,263 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter/material.dart'; -import 'cell_accessory.dart'; -import 'cell_shortcuts.dart'; -import 'checkbox_cell.dart'; -import 'date_cell/date_cell.dart'; -import 'number_cell.dart'; -import 'select_option_cell/select_option_cell.dart'; -import 'text_cell.dart'; -import 'url_cell/url_cell.dart'; - -abstract class GridCellBuilderDelegate - extends GridCellControllerBuilderDelegate { - GridCellCache get cellCache; -} - -class GridCellBuilder { - final GridCellBuilderDelegate delegate; - GridCellBuilder({ - required this.delegate, - }); - - GridCellWidget build(GridCellIdentifier cellId, {GridCellStyle? style}) { - final cellControllerBuilder = GridCellControllerBuilder( - cellId: cellId, - cellCache: delegate.cellCache, - delegate: delegate, - ); - - final key = cellId.key(); - switch (cellId.fieldType) { - case FieldType.Checkbox: - return GridCheckboxCell( - cellControllerBuilder: cellControllerBuilder, - key: key, - ); - case FieldType.DateTime: - return GridDateCell( - cellControllerBuilder: cellControllerBuilder, - key: key, - style: style, - ); - case FieldType.SingleSelect: - return GridSingleSelectCell( - cellControllerBuilder: cellControllerBuilder, - style: style, - key: key, - ); - case FieldType.MultiSelect: - return GridMultiSelectCell( - cellControllerBuilder: cellControllerBuilder, - style: style, - key: key, - ); - case FieldType.Number: - return GridNumberCell( - cellControllerBuilder: cellControllerBuilder, - key: key, - ); - case FieldType.RichText: - return GridTextCell( - cellControllerBuilder: cellControllerBuilder, - style: style, - key: key, - ); - case FieldType.URL: - return GridURLCell( - cellControllerBuilder: cellControllerBuilder, - style: style, - key: key, - ); - } - throw UnimplementedError; - } -} - -class BlankCell extends StatelessWidget { - const BlankCell({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Container(); - } -} - -abstract class CellEditable { - GridCellFocusListener get beginFocus; - - ValueNotifier get onCellFocus; - - ValueNotifier get onCellEditing; -} - -typedef AccessoryBuilder = List Function( - GridCellAccessoryBuildContext buildContext); - -abstract class CellAccessory extends Widget { - const CellAccessory({Key? key}) : super(key: key); - - // The hover will show if the isHover's value is true - ValueNotifier? get onAccessoryHover; - - AccessoryBuilder? get accessoryBuilder; -} - -abstract class GridCellWidget extends StatefulWidget - implements CellAccessory, CellEditable, CellShortcuts { - GridCellWidget({Key? key}) : super(key: key) { - onCellEditing.addListener(() { - onCellFocus.value = onCellEditing.value; - }); - } - - @override - final ValueNotifier onCellFocus = ValueNotifier(false); - - // When the cell is focused, we assume that the accessory also be hovered. - @override - ValueNotifier get onAccessoryHover => onCellFocus; - - @override - final ValueNotifier onCellEditing = ValueNotifier(false); - - @override - List Function( - GridCellAccessoryBuildContext buildContext)? get accessoryBuilder => null; - - @override - final GridCellFocusListener beginFocus = GridCellFocusListener(); - - @override - final Map shortcutHandlers = {}; -} - -abstract class GridCellState extends State { - @override - void initState() { - widget.beginFocus.setListener(() => requestBeginFocus()); - widget.shortcutHandlers[CellKeyboardKey.onCopy] = () => onCopy(); - widget.shortcutHandlers[CellKeyboardKey.onInsert] = () { - Clipboard.getData("text/plain").then((data) { - final s = data?.text; - if (s is String) { - onInsert(s); - } - }); - }; - super.initState(); - } - - @override - void didUpdateWidget(covariant T oldWidget) { - if (oldWidget != this) { - widget.beginFocus.setListener(() => requestBeginFocus()); - } - super.didUpdateWidget(oldWidget); - } - - @override - void dispose() { - widget.beginFocus.removeAllListener(); - super.dispose(); - } - - void requestBeginFocus(); - - String? onCopy() => null; - - void onInsert(String value) {} -} - -abstract class GridFocusNodeCellState - extends GridCellState { - SingleListenerFocusNode focusNode = SingleListenerFocusNode(); - - @override - void initState() { - widget.shortcutHandlers[CellKeyboardKey.onEnter] = - () => focusNode.unfocus(); - _listenOnFocusNodeChanged(); - super.initState(); - } - - @override - void didUpdateWidget(covariant T oldWidget) { - if (oldWidget != this) { - _listenOnFocusNodeChanged(); - } - super.didUpdateWidget(oldWidget); - } - - @override - void dispose() { - widget.shortcutHandlers.clear(); - focusNode.removeAllListener(); - focusNode.dispose(); - super.dispose(); - } - - @override - void requestBeginFocus() { - if (focusNode.hasFocus == false && focusNode.canRequestFocus) { - FocusScope.of(context).requestFocus(focusNode); - } - } - - void _listenOnFocusNodeChanged() { - widget.onCellEditing.value = focusNode.hasFocus; - focusNode.setListener(() { - widget.onCellEditing.value = focusNode.hasFocus; - focusChanged(); - }); - } - - Future focusChanged() async {} -} - -class GridCellFocusListener extends ChangeNotifier { - VoidCallback? _listener; - - void setListener(VoidCallback listener) { - if (_listener != null) { - removeListener(_listener!); - } - - _listener = listener; - addListener(listener); - } - - void removeAllListener() { - if (_listener != null) { - removeListener(_listener!); - } - } - - void notify() { - notifyListeners(); - } -} - -abstract class GridCellStyle {} - -class SingleListenerFocusNode extends FocusNode { - VoidCallback? _listener; - - void setListener(VoidCallback listener) { - if (_listener != null) { - removeListener(_listener!); - } - - _listener = listener; - super.addListener(listener); - } - - void removeAllListener() { - if (_listener != null) { - removeListener(_listener!); - } - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_container.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_container.dart deleted file mode 100644 index 43a150d0823ef..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_container.dart +++ /dev/null @@ -1,157 +0,0 @@ -import 'package:flowy_infra/theme.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:provider/provider.dart'; -import 'package:styled_widget/styled_widget.dart'; - -import '../../layout/sizes.dart'; -import '../row/grid_row.dart'; -import 'cell_accessory.dart'; -import 'cell_builder.dart'; -import 'cell_shortcuts.dart'; - -class CellContainer extends StatelessWidget { - final GridCellWidget child; - final AccessoryBuilder? accessoryBuilder; - final double width; - final RegionStateNotifier rowStateNotifier; - const CellContainer({ - Key? key, - required this.child, - required this.width, - required this.rowStateNotifier, - this.accessoryBuilder, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return ChangeNotifierProxyProvider( - create: (_) => _CellContainerNotifier(child), - update: (_, rowStateNotifier, cellStateNotifier) => - cellStateNotifier!..onEnter = rowStateNotifier.onEnter, - child: Selector<_CellContainerNotifier, bool>( - selector: (context, notifier) => notifier.isFocus, - builder: (context, isFocus, _) { - Widget container = Center(child: GridCellShortcuts(child: child)); - - if (accessoryBuilder != null) { - final accessories = accessoryBuilder!( - GridCellAccessoryBuildContext( - anchorContext: context, - isCellEditing: isFocus, - ), - ); - - if (accessories.isNotEmpty) { - container = _GridCellEnterRegion( - accessories: accessories, - child: container, - ); - } - } - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => child.beginFocus.notify(), - child: Container( - constraints: BoxConstraints(maxWidth: width, minHeight: 46), - decoration: _makeBoxDecoration(context, isFocus), - child: container, - ), - ); - }, - ), - ); - } - - BoxDecoration _makeBoxDecoration(BuildContext context, bool isFocus) { - final theme = context.watch(); - if (isFocus) { - final borderSide = BorderSide(color: theme.main1, width: 1.0); - return BoxDecoration(border: Border.fromBorderSide(borderSide)); - } else { - final borderSide = BorderSide(color: theme.shader5, width: 1.0); - return BoxDecoration( - border: Border(right: borderSide, bottom: borderSide)); - } - } -} - -class _GridCellEnterRegion extends StatelessWidget { - final Widget child; - final List accessories; - const _GridCellEnterRegion( - {required this.child, required this.accessories, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return Selector<_CellContainerNotifier, bool>( - selector: (context, notifier) => notifier.onEnter, - builder: (context, onEnter, _) { - List children = [child]; - if (onEnter) { - children.add( - CellAccessoryContainer(accessories: accessories).positioned( - right: GridSize.cellContentInsets.right, - ), - ); - } - - return MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (p) => - Provider.of<_CellContainerNotifier>(context, listen: false) - .onEnter = true, - onExit: (p) => - Provider.of<_CellContainerNotifier>(context, listen: false) - .onEnter = false, - child: Stack( - alignment: AlignmentDirectional.center, - fit: StackFit.expand, - children: children, - ), - ); - }, - ); - } -} - -class _CellContainerNotifier extends ChangeNotifier { - final CellEditable cellEditable; - VoidCallback? _onCellFocusListener; - bool _isFocus = false; - bool _onEnter = false; - - _CellContainerNotifier(this.cellEditable) { - _onCellFocusListener = () => isFocus = cellEditable.onCellFocus.value; - cellEditable.onCellFocus.addListener(_onCellFocusListener!); - } - - @override - void dispose() { - if (_onCellFocusListener != null) { - cellEditable.onCellFocus.removeListener(_onCellFocusListener!); - } - super.dispose(); - } - - set isFocus(bool value) { - if (_isFocus != value) { - _isFocus = value; - notifyListeners(); - } - } - - set onEnter(bool value) { - if (_onEnter != value) { - _onEnter = value; - notifyListeners(); - } - } - - bool get isFocus => _isFocus; - - bool get onEnter => _onEnter; -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_decoration.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_decoration.dart deleted file mode 100755 index 0e74073e143f7..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_decoration.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -class CellDecoration { - static BoxDecoration box({required Color color}) { - return BoxDecoration( - border: Border.all(color: Colors.black26, width: 0.2), - color: color, - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_shortcuts.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_shortcuts.dart deleted file mode 100644 index f44a4b56631fa..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_shortcuts.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -typedef CellKeyboardAction = dynamic Function(); - -enum CellKeyboardKey { - onEnter, - onCopy, - onInsert, -} - -abstract class CellShortcuts extends Widget { - const CellShortcuts({Key? key}) : super(key: key); - - Map get shortcutHandlers; -} - -class GridCellShortcuts extends StatelessWidget { - final CellShortcuts child; - const GridCellShortcuts({required this.child, Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Shortcuts( - shortcuts: { - LogicalKeySet(LogicalKeyboardKey.enter): const GridCellEnterIdent(), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyC): - const GridCellCopyIntent(), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyV): - const GridCellPasteIntent(), - }, - child: Actions( - actions: { - GridCellEnterIdent: GridCellEnterAction(child: child), - GridCellCopyIntent: GridCellCopyAction(child: child), - GridCellPasteIntent: GridCellPasteAction(child: child), - }, - child: child, - ), - ); - } -} - -class GridCellEnterIdent extends Intent { - const GridCellEnterIdent(); -} - -class GridCellEnterAction extends Action { - final CellShortcuts child; - GridCellEnterAction({required this.child}); - - @override - void invoke(covariant GridCellEnterIdent intent) { - final callback = child.shortcutHandlers[CellKeyboardKey.onEnter]; - if (callback != null) { - callback(); - } - } -} - -class GridCellCopyIntent extends Intent { - const GridCellCopyIntent(); -} - -class GridCellCopyAction extends Action { - final CellShortcuts child; - GridCellCopyAction({required this.child}); - - @override - void invoke(covariant GridCellCopyIntent intent) { - final callback = child.shortcutHandlers[CellKeyboardKey.onCopy]; - if (callback == null) { - return; - } - - final s = callback(); - if (s is String) { - Clipboard.setData(ClipboardData(text: s)); - } - } -} - -class GridCellPasteIntent extends Intent { - const GridCellPasteIntent(); -} - -class GridCellPasteAction extends Action { - final CellShortcuts child; - GridCellPasteAction({required this.child}); - - @override - void invoke(covariant GridCellPasteIntent intent) { - final callback = child.shortcutHandlers[CellKeyboardKey.onInsert]; - if (callback != null) { - callback(); - } - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/checkbox_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/checkbox_cell.dart deleted file mode 100644 index e9bd8c79a4abc..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/checkbox_cell.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../layout/sizes.dart'; -import 'cell_builder.dart'; - -class GridCheckboxCell extends GridCellWidget { - final GridCellControllerBuilder cellControllerBuilder; - GridCheckboxCell({ - required this.cellControllerBuilder, - Key? key, - }) : super(key: key); - - @override - GridCellState createState() => _CheckboxCellState(); -} - -class _CheckboxCellState extends GridCellState { - late CheckboxCellBloc _cellBloc; - - @override - void initState() { - final cellController = - widget.cellControllerBuilder.build() as GridCheckboxCellController; - _cellBloc = getIt(param1: cellController) - ..add(const CheckboxCellEvent.initial()); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cellBloc, - child: BlocBuilder( - builder: (context, state) { - final icon = state.isSelected - ? svgWidget('editor/editor_check') - : svgWidget('editor/editor_uncheck'); - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: GridSize.cellContentInsets, - child: FlowyIconButton( - onPressed: () => context - .read() - .add(const CheckboxCellEvent.select()), - iconPadding: EdgeInsets.zero, - icon: icon, - width: 20, - ), - ), - ); - }, - ), - ); - } - - @override - Future dispose() async { - _cellBloc.close(); - super.dispose(); - } - - @override - void requestBeginFocus() { - _cellBloc.add(const CheckboxCellEvent.select()); - } - - @override - String? onCopy() { - if (_cellBloc.state.isSelected) { - return "Yes"; - } else { - return "No"; - } - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_cell.dart deleted file mode 100644 index fd35d4d198097..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_cell.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; - -import '../../../layout/sizes.dart'; -import '../cell_builder.dart'; -import 'date_editor.dart'; - -class DateCellStyle extends GridCellStyle { - Alignment alignment; - - DateCellStyle({this.alignment = Alignment.center}); -} - -abstract class GridCellDelegate { - void onFocus(bool isFocus); - GridCellDelegate get delegate; -} - -class GridDateCell extends GridCellWidget { - final GridCellControllerBuilder cellControllerBuilder; - late final DateCellStyle? cellStyle; - - GridDateCell({ - GridCellStyle? style, - required this.cellControllerBuilder, - Key? key, - }) : super(key: key) { - if (style != null) { - cellStyle = (style as DateCellStyle); - } else { - cellStyle = null; - } - } - - @override - GridCellState createState() => _DateCellState(); -} - -class _DateCellState extends GridCellState { - late PopoverController _popover; - late DateCellBloc _cellBloc; - - @override - void initState() { - _popover = PopoverController(); - final cellController = - widget.cellControllerBuilder.build() as GridDateCellController; - _cellBloc = getIt(param1: cellController) - ..add(const DateCellEvent.initial()); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final alignment = widget.cellStyle != null - ? widget.cellStyle!.alignment - : Alignment.centerLeft; - return BlocProvider.value( - value: _cellBloc, - child: BlocBuilder( - builder: (context, state) { - return AppFlowyPopover( - controller: _popover, - triggerActions: PopoverTriggerFlags.none, - direction: PopoverDirection.bottomWithLeftAligned, - constraints: BoxConstraints.loose(const Size(320, 520)), - margin: EdgeInsets.zero, - child: SizedBox.expand( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _popover.show(), - child: Align( - alignment: alignment, - child: Padding( - padding: GridSize.cellContentInsets, - child: FlowyText.medium( - state.dateStr, - fontSize: FontSizes.s14, - ), - ), - ), - ), - ), - popupBuilder: (BuildContext popoverContent) { - return DateCellEditor( - cellController: widget.cellControllerBuilder.build() - as GridDateCellController, - onDismissed: () => widget.onCellEditing.value = false, - ); - }, - onClose: () { - widget.onCellEditing.value = false; - }, - ); - }, - ), - ); - } - - @override - Future dispose() async { - _cellBloc.close(); - super.dispose(); - } - - @override - void requestBeginFocus() {} - - @override - String? onCopy() => _cellBloc.state.dateStr; -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_editor.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_editor.dart deleted file mode 100644 index ee85c4f76d44c..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/date_cell/date_editor.dart +++ /dev/null @@ -1,489 +0,0 @@ -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/plugins/grid/application/cell/date_cal_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:app_flowy/workspace/presentation/widgets/toggle/toggle.dart'; -import 'package:app_flowy/workspace/presentation/widgets/toggle/toggle_style.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:dartz/dartz.dart' show Either; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/rounded_input_field.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pbserver.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:table_calendar/table_calendar.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; -import '../../../layout/sizes.dart'; -import '../../header/type_option/date.dart'; - -final kToday = DateTime.now(); -final kFirstDay = DateTime(kToday.year, kToday.month - 3, kToday.day); -final kLastDay = DateTime(kToday.year, kToday.month + 3, kToday.day); -const kMargin = EdgeInsets.symmetric(horizontal: 6, vertical: 10); - -class DateCellEditor extends StatefulWidget { - final VoidCallback onDismissed; - final GridDateCellController cellController; - - const DateCellEditor({ - Key? key, - required this.onDismissed, - required this.cellController, - }) : super(key: key); - - @override - State createState() => _DateCellEditor(); -} - -class _DateCellEditor extends State { - @override - Widget build(BuildContext context) { - return FutureBuilder>( - future: widget.cellController.getFieldTypeOption( - DateTypeOptionDataParser(), - ), - builder: (BuildContext context, snapshot) { - if (snapshot.hasData) { - return _buildWidget(snapshot); - } else { - return const SizedBox(); - } - }, - ); - } - - Widget _buildWidget(AsyncSnapshot> snapshot) { - return snapshot.data!.fold( - (dateTypeOptionPB) { - return Padding( - padding: const EdgeInsets.all(12), - child: _CellCalendarWidget( - cellContext: widget.cellController, - dateTypeOptionPB: dateTypeOptionPB, - ), - ); - }, - (err) { - Log.error(err); - return const SizedBox(); - }, - ); - } -} - -class _CellCalendarWidget extends StatefulWidget { - final GridDateCellController cellContext; - final DateTypeOptionPB dateTypeOptionPB; - - const _CellCalendarWidget({ - required this.cellContext, - required this.dateTypeOptionPB, - Key? key, - }) : super(key: key); - - @override - State<_CellCalendarWidget> createState() => _CellCalendarWidgetState(); -} - -class _CellCalendarWidgetState extends State<_CellCalendarWidget> { - late PopoverMutex popoverMutex; - late DateCalBloc bloc; - - @override - void initState() { - popoverMutex = PopoverMutex(); - - bloc = DateCalBloc( - dateTypeOptionPB: widget.dateTypeOptionPB, - cellData: widget.cellContext.getCellData(), - cellController: widget.cellContext, - )..add(const DateCalEvent.initial()); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocProvider.value( - value: bloc, - child: BlocBuilder( - buildWhen: (p, c) => false, - builder: (context, state) { - List children = [ - _buildCalendar(theme, context), - _TimeTextField( - bloc: context.read(), - popoverMutex: popoverMutex, - ), - Divider(height: 1, color: theme.shader5), - const _IncludeTimeButton(), - _DateTypeOptionButton(popoverMutex: popoverMutex) - ]; - - return ListView.separated( - shrinkWrap: true, - controller: ScrollController(), - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - itemCount: children.length, - itemBuilder: (BuildContext context, int index) { - return children[index]; - }, - ); - }, - ), - ); - } - - @override - void dispose() { - bloc.close(); - popoverMutex.dispose(); - super.dispose(); - } - - Widget _buildCalendar(AppTheme theme, BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return TableCalendar( - firstDay: kFirstDay, - lastDay: kLastDay, - focusedDay: state.focusedDay, - rowHeight: 40, - calendarFormat: state.format, - daysOfWeekHeight: 40, - headerStyle: HeaderStyle( - formatButtonVisible: false, - titleCentered: true, - titleTextStyle: TextStyles.body1.size(FontSizes.s14), - leftChevronMargin: EdgeInsets.zero, - leftChevronPadding: EdgeInsets.zero, - leftChevronIcon: svgWidget("home/arrow_left"), - rightChevronPadding: EdgeInsets.zero, - rightChevronMargin: EdgeInsets.zero, - rightChevronIcon: svgWidget("home/arrow_right"), - headerMargin: const EdgeInsets.only(bottom: 8.0), - ), - daysOfWeekStyle: DaysOfWeekStyle( - dowTextFormatter: (date, locale) => - DateFormat.E(locale).format(date).toUpperCase(), - weekdayStyle: TextStyles.general( - fontSize: 13, - fontWeight: FontWeight.w400, - color: theme.shader3, - ), - weekendStyle: TextStyles.general( - fontSize: 13, - fontWeight: FontWeight.w400, - color: theme.shader3, - ), - ), - calendarStyle: CalendarStyle( - cellMargin: const EdgeInsets.all(3), - defaultDecoration: BoxDecoration( - color: theme.surface, - shape: BoxShape.rectangle, - borderRadius: const BorderRadius.all(Radius.circular(6)), - ), - selectedDecoration: BoxDecoration( - color: theme.main1, - shape: BoxShape.rectangle, - borderRadius: const BorderRadius.all(Radius.circular(6)), - ), - todayDecoration: BoxDecoration( - color: theme.shader4, - shape: BoxShape.rectangle, - borderRadius: const BorderRadius.all(Radius.circular(6)), - ), - weekendDecoration: BoxDecoration( - color: theme.surface, - shape: BoxShape.rectangle, - borderRadius: const BorderRadius.all(Radius.circular(6)), - ), - outsideDecoration: BoxDecoration( - color: theme.surface, - shape: BoxShape.rectangle, - borderRadius: const BorderRadius.all(Radius.circular(6)), - ), - defaultTextStyle: TextStyles.body1.size(FontSizes.s14), - weekendTextStyle: TextStyles.body1.size(FontSizes.s14), - selectedTextStyle: TextStyles.general( - fontSize: FontSizes.s14, - color: theme.surface, - ), - todayTextStyle: TextStyles.general( - fontSize: FontSizes.s14, - color: theme.surface, - ), - outsideTextStyle: TextStyles.general( - fontSize: FontSizes.s14, - color: theme.shader4, - ), - ), - selectedDayPredicate: (day) { - return state.calData.fold( - () => false, - (dateData) => isSameDay(dateData.date, day), - ); - }, - onDaySelected: (selectedDay, focusedDay) { - context - .read() - .add(DateCalEvent.selectDay(selectedDay)); - }, - onFormatChanged: (format) { - context.read().add(DateCalEvent.setCalFormat(format)); - }, - onPageChanged: (focusedDay) { - context - .read() - .add(DateCalEvent.setFocusedDay(focusedDay)); - }, - ); - }, - ); - } -} - -class _IncludeTimeButton extends StatelessWidget { - const _IncludeTimeButton({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocSelector( - selector: (state) => state.dateTypeOptionPB.includeTime, - builder: (context, includeTime) { - return SizedBox( - height: 50, - child: Padding( - padding: kMargin, - child: Row( - children: [ - svgWidget("grid/clock", color: theme.iconColor), - const HSpace(4), - FlowyText.medium( - LocaleKeys.grid_field_includeTime.tr(), - fontSize: FontSizes.s14, - ), - const Spacer(), - Toggle( - value: includeTime, - onChanged: (value) => context - .read() - .add(DateCalEvent.setIncludeTime(!value)), - style: ToggleStyle.big(theme), - padding: EdgeInsets.zero, - ), - ], - ), - ), - ); - }, - ); - } -} - -class _TimeTextField extends StatefulWidget { - final DateCalBloc bloc; - final PopoverMutex popoverMutex; - const _TimeTextField({ - required this.bloc, - required this.popoverMutex, - Key? key, - }) : super(key: key); - - @override - State<_TimeTextField> createState() => _TimeTextFieldState(); -} - -class _TimeTextFieldState extends State<_TimeTextField> { - late final FocusNode _focusNode; - late final TextEditingController _controller; - - @override - void initState() { - _focusNode = FocusNode(); - _controller = TextEditingController(text: widget.bloc.state.time); - if (widget.bloc.state.dateTypeOptionPB.includeTime) { - _focusNode.addListener(() { - if (mounted) { - widget.bloc.add(DateCalEvent.setTime(_controller.text)); - } - - if (_focusNode.hasFocus) { - widget.popoverMutex.close(); - } - }); - - widget.popoverMutex.listenOnPopoverChanged(() { - if (_focusNode.hasFocus) { - _focusNode.unfocus(); - } - }); - } - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocConsumer( - listener: (context, state) { - _controller.text = state.time ?? ""; - }, - listenWhen: (p, c) => p.time != c.time, - builder: (context, state) { - if (state.dateTypeOptionPB.includeTime) { - return Padding( - padding: kMargin, - child: RoundedInputField( - height: 40, - focusNode: _focusNode, - autoFocus: true, - hintText: state.timeHintText, - controller: _controller, - style: TextStyles.body1.size(FontSizes.s14), - normalBorderColor: theme.shader4, - errorBorderColor: theme.red, - focusBorderColor: theme.main1, - cursorColor: theme.main1, - errorText: state.timeFormatError.fold(() => "", (error) => error), - onEditingComplete: (value) { - widget.bloc.add(DateCalEvent.setTime(value)); - }, - ), - ); - } else { - return const SizedBox(); - } - }, - ); - } - - @override - void dispose() { - _focusNode.dispose(); - super.dispose(); - } -} - -class _DateTypeOptionButton extends StatelessWidget { - final PopoverMutex popoverMutex; - const _DateTypeOptionButton({ - required this.popoverMutex, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - final title = - "${LocaleKeys.grid_field_dateFormat.tr()} &${LocaleKeys.grid_field_timeFormat.tr()}"; - return BlocSelector( - selector: (state) => state.dateTypeOptionPB, - builder: (context, dateTypeOptionPB) { - return AppFlowyPopover( - mutex: popoverMutex, - triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, - offset: const Offset(20, 0), - constraints: BoxConstraints.loose(const Size(140, 100)), - child: FlowyButton( - text: FlowyText.medium(title, fontSize: 14), - hoverColor: theme.hover, - margin: kMargin, - rightIcon: svgWidget("grid/more", color: theme.iconColor), - ), - popupBuilder: (BuildContext popContext) { - return _CalDateTimeSetting( - dateTypeOptionPB: dateTypeOptionPB, - onEvent: (event) { - context.read().add(event); - popoverMutex.close(); - }, - ); - }, - ); - }, - ); - } -} - -class _CalDateTimeSetting extends StatefulWidget { - final DateTypeOptionPB dateTypeOptionPB; - final Function(DateCalEvent) onEvent; - const _CalDateTimeSetting({ - required this.dateTypeOptionPB, - required this.onEvent, - Key? key, - }) : super(key: key); - - @override - State<_CalDateTimeSetting> createState() => _CalDateTimeSettingState(); -} - -class _CalDateTimeSettingState extends State<_CalDateTimeSetting> { - final timeSettingPopoverMutex = PopoverMutex(); - String? overlayIdentifier; - - @override - Widget build(BuildContext context) { - List children = [ - AppFlowyPopover( - mutex: timeSettingPopoverMutex, - triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, - offset: const Offset(20, 0), - popupBuilder: (BuildContext context) { - return DateFormatList( - selectedFormat: widget.dateTypeOptionPB.dateFormat, - onSelected: (format) { - widget.onEvent(DateCalEvent.setDateFormat(format)); - timeSettingPopoverMutex.close(); - }, - ); - }, - child: const DateFormatButton(), - ), - AppFlowyPopover( - mutex: timeSettingPopoverMutex, - triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, - offset: const Offset(20, 0), - popupBuilder: (BuildContext context) { - return TimeFormatList( - selectedFormat: widget.dateTypeOptionPB.timeFormat, - onSelected: (format) { - widget.onEvent(DateCalEvent.setTimeFormat(format)); - timeSettingPopoverMutex.close(); - }); - }, - child: TimeFormatButton(timeFormat: widget.dateTypeOptionPB.timeFormat), - ), - ]; - - return SizedBox( - width: 180, - child: ListView.separated( - shrinkWrap: true, - controller: ScrollController(), - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - itemCount: children.length, - itemBuilder: (BuildContext context, int index) { - return children[index]; - }, - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/number_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/number_cell.dart deleted file mode 100644 index 667d7b6c936bc..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/number_cell.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'dart:async'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -import '../../layout/sizes.dart'; -import 'cell_builder.dart'; - -class GridNumberCell extends GridCellWidget { - final GridCellControllerBuilder cellControllerBuilder; - - GridNumberCell({ - required this.cellControllerBuilder, - Key? key, - }) : super(key: key); - - @override - GridFocusNodeCellState createState() => _NumberCellState(); -} - -class _NumberCellState extends GridFocusNodeCellState { - late NumberCellBloc _cellBloc; - late TextEditingController _controller; - Timer? _delayOperation; - - @override - void initState() { - final cellController = widget.cellControllerBuilder.build(); - _cellBloc = getIt(param1: cellController) - ..add(const NumberCellEvent.initial()); - _controller = - TextEditingController(text: contentFromState(_cellBloc.state)); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cellBloc, - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (p, c) => p.content != c.content, - listener: (context, state) => - _controller.text = contentFromState(state), - ), - ], - child: Padding( - padding: GridSize.cellContentInsets, - child: TextField( - controller: _controller, - focusNode: focusNode, - onEditingComplete: () => focusNode.unfocus(), - onSubmitted: (_) => focusNode.unfocus(), - maxLines: 1, - style: TextStyles.body1.size(FontSizes.s14), - textInputAction: TextInputAction.done, - decoration: const InputDecoration( - contentPadding: EdgeInsets.zero, - border: InputBorder.none, - isDense: true, - ), - ), - ), - ), - ); - } - - @override - Future dispose() async { - _delayOperation = null; - _cellBloc.close(); - super.dispose(); - } - - @override - Future focusChanged() async { - if (mounted) { - _delayOperation?.cancel(); - _delayOperation = Timer(const Duration(milliseconds: 30), () { - if (_cellBloc.isClosed == false && - _controller.text != contentFromState(_cellBloc.state)) { - _cellBloc.add(NumberCellEvent.updateCell(_controller.text)); - } - }); - } - } - - String contentFromState(NumberCellState state) { - return state.content.fold((l) => l, (r) => ""); - } - - @override - String? onCopy() { - return _cellBloc.state.content.fold((content) => content, (r) => null); - } - - @override - void onInsert(String value) { - _cellBloc.add(NumberCellEvent.updateCell(value)); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/prelude.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/prelude.dart deleted file mode 100644 index 7b34d8fcdfd13..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/prelude.dart +++ /dev/null @@ -1,7 +0,0 @@ -export 'cell_builder.dart'; -export 'text_cell.dart'; -export 'number_cell.dart'; -export 'date_cell/date_cell.dart'; -export 'checkbox_cell.dart'; -export 'select_option_cell/select_option_cell.dart'; -export 'url_cell/url_cell.dart'; diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart deleted file mode 100644 index 90aa59d23aeed..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -extension SelectOptionColorExtension on SelectOptionColorPB { - Color make(BuildContext context) { - final theme = context.watch(); - switch (this) { - case SelectOptionColorPB.Purple: - return theme.tint1; - case SelectOptionColorPB.Pink: - return theme.tint2; - case SelectOptionColorPB.LightPink: - return theme.tint3; - case SelectOptionColorPB.Orange: - return theme.tint4; - case SelectOptionColorPB.Yellow: - return theme.tint5; - case SelectOptionColorPB.Lime: - return theme.tint6; - case SelectOptionColorPB.Green: - return theme.tint7; - case SelectOptionColorPB.Aqua: - return theme.tint8; - case SelectOptionColorPB.Blue: - return theme.tint9; - default: - throw ArgumentError; - } - } - - String optionName() { - switch (this) { - case SelectOptionColorPB.Purple: - return LocaleKeys.grid_selectOption_purpleColor.tr(); - case SelectOptionColorPB.Pink: - return LocaleKeys.grid_selectOption_pinkColor.tr(); - case SelectOptionColorPB.LightPink: - return LocaleKeys.grid_selectOption_lightPinkColor.tr(); - case SelectOptionColorPB.Orange: - return LocaleKeys.grid_selectOption_orangeColor.tr(); - case SelectOptionColorPB.Yellow: - return LocaleKeys.grid_selectOption_yellowColor.tr(); - case SelectOptionColorPB.Lime: - return LocaleKeys.grid_selectOption_limeColor.tr(); - case SelectOptionColorPB.Green: - return LocaleKeys.grid_selectOption_greenColor.tr(); - case SelectOptionColorPB.Aqua: - return LocaleKeys.grid_selectOption_aquaColor.tr(); - case SelectOptionColorPB.Blue: - return LocaleKeys.grid_selectOption_blueColor.tr(); - default: - throw ArgumentError; - } - } -} - -class SelectOptionTag extends StatelessWidget { - final String name; - final Color color; - final bool isSelected; - final VoidCallback? onSelected; - const SelectOptionTag({ - required this.name, - required this.color, - this.onSelected, - this.isSelected = false, - Key? key, - }) : super(key: key); - - factory SelectOptionTag.fromOption({ - required BuildContext context, - required SelectOptionPB option, - VoidCallback? onSelected, - bool isSelected = false, - }) { - return SelectOptionTag( - name: option.name, - color: option.color.make(context), - isSelected: isSelected, - onSelected: onSelected, - ); - } - - @override - Widget build(BuildContext context) { - return ChoiceChip( - pressElevation: 1, - label: FlowyText.medium( - name, - fontSize: 12, - overflow: TextOverflow.clip, - ), - selectedColor: color, - backgroundColor: color, - labelPadding: const EdgeInsets.symmetric(horizontal: 6), - selected: true, - onSelected: (_) => onSelected?.call(), - ); - } -} - -class SelectOptionTagCell extends StatelessWidget { - final List children; - final void Function(SelectOptionPB) onSelected; - final SelectOptionPB option; - const SelectOptionTagCell({ - required this.option, - required this.onSelected, - this.children = const [], - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return Stack( - fit: StackFit.expand, - children: [ - FlowyHover( - style: HoverStyle(hoverColor: theme.hover), - child: InkWell( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 3), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - SelectOptionTag.fromOption( - context: context, - option: option, - onSelected: () => onSelected(option), - ), - const Spacer(), - ...children, - ], - ), - ), - onTap: () => onSelected(option), - ), - ), - ], - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_cell.dart deleted file mode 100644 index 4d4b3ebb29688..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_cell.dart +++ /dev/null @@ -1,234 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; - -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -// ignore: unused_import -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../../layout/sizes.dart'; -import '../cell_builder.dart'; -import 'extension.dart'; -import 'select_option_editor.dart'; - -class SelectOptionCellStyle extends GridCellStyle { - String placeholder; - - SelectOptionCellStyle({ - required this.placeholder, - }); -} - -class GridSingleSelectCell extends GridCellWidget { - final GridCellControllerBuilder cellControllerBuilder; - late final SelectOptionCellStyle? cellStyle; - - GridSingleSelectCell({ - required this.cellControllerBuilder, - GridCellStyle? style, - Key? key, - }) : super(key: key) { - if (style != null) { - cellStyle = (style as SelectOptionCellStyle); - } else { - cellStyle = null; - } - } - - @override - State createState() => _SingleSelectCellState(); -} - -class _SingleSelectCellState extends State { - late SelectOptionCellBloc _cellBloc; - - @override - void initState() { - final cellController = - widget.cellControllerBuilder.build() as GridSelectOptionCellController; - _cellBloc = getIt(param1: cellController) - ..add(const SelectOptionCellEvent.initial()); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cellBloc, - child: BlocBuilder( - builder: (context, state) { - return SelectOptionWrap( - selectOptions: state.selectedOptions, - cellStyle: widget.cellStyle, - onFocus: (value) => widget.onCellEditing.value = value, - cellControllerBuilder: widget.cellControllerBuilder); - }, - ), - ); - } - - @override - Future dispose() async { - _cellBloc.close(); - super.dispose(); - } -} - -//---------------------------------------------------------------- -class GridMultiSelectCell extends GridCellWidget { - final GridCellControllerBuilder cellControllerBuilder; - late final SelectOptionCellStyle? cellStyle; - - GridMultiSelectCell({ - required this.cellControllerBuilder, - GridCellStyle? style, - Key? key, - }) : super(key: key) { - if (style != null) { - cellStyle = (style as SelectOptionCellStyle); - } else { - cellStyle = null; - } - } - - @override - State createState() => _MultiSelectCellState(); -} - -class _MultiSelectCellState extends State { - late SelectOptionCellBloc _cellBloc; - - @override - void initState() { - final cellController = - widget.cellControllerBuilder.build() as GridSelectOptionCellController; - _cellBloc = getIt(param1: cellController) - ..add(const SelectOptionCellEvent.initial()); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cellBloc, - child: BlocBuilder( - builder: (context, state) { - return SelectOptionWrap( - selectOptions: state.selectedOptions, - cellStyle: widget.cellStyle, - onFocus: (value) => widget.onCellEditing.value = value, - cellControllerBuilder: widget.cellControllerBuilder, - ); - }, - ), - ); - } - - @override - Future dispose() async { - _cellBloc.close(); - super.dispose(); - } -} - -class SelectOptionWrap extends StatefulWidget { - final List selectOptions; - final void Function(bool)? onFocus; - final SelectOptionCellStyle? cellStyle; - final GridCellControllerBuilder cellControllerBuilder; - const SelectOptionWrap({ - required this.selectOptions, - required this.cellControllerBuilder, - this.onFocus, - this.cellStyle, - Key? key, - }) : super(key: key); - - @override - State createState() => _SelectOptionWrapState(); -} - -class _SelectOptionWrapState extends State { - late PopoverController _popover; - - @override - void initState() { - _popover = PopoverController(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - Widget child = _buildOptions(theme, context); - - return Stack( - alignment: AlignmentDirectional.center, - fit: StackFit.expand, - children: [ - Padding( - padding: GridSize.cellContentInsets, - child: _wrapPopover(child), - ), - InkWell(onTap: () => _popover.show()), - ], - ); - } - - Widget _wrapPopover(Widget child) { - final constraints = BoxConstraints.loose(Size( - SelectOptionCellEditor.editorPanelWidth, - 300, - )); - return AppFlowyPopover( - controller: _popover, - constraints: constraints, - direction: PopoverDirection.bottomWithLeftAligned, - popupBuilder: (BuildContext context) { - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onFocus?.call(true); - }); - return SelectOptionCellEditor( - cellController: widget.cellControllerBuilder.build() - as GridSelectOptionCellController, - ); - }, - onClose: () => widget.onFocus?.call(false), - child: child, - ); - } - - Widget _buildOptions(AppTheme theme, BuildContext context) { - final Widget child; - if (widget.selectOptions.isEmpty && widget.cellStyle != null) { - child = FlowyText.medium( - widget.cellStyle!.placeholder, - fontSize: 14, - color: theme.shader3, - ); - } else { - final children = widget.selectOptions.map( - (option) { - return Padding( - padding: const EdgeInsets.only(right: 4), - child: SelectOptionTag.fromOption( - context: context, - option: option, - ), - ); - }, - ).toList(); - - child = Wrap( - runSpacing: 2, - children: children, - ); - } - return Align(alignment: Alignment.centerLeft, child: child); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart deleted file mode 100644 index 43030f1b68afb..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart +++ /dev/null @@ -1,312 +0,0 @@ -import 'dart:collection'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/application/cell/select_option_editor_bloc.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; - -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:textfield_tags/textfield_tags.dart'; - -import '../../../layout/sizes.dart'; -import '../../common/text_field.dart'; -import '../../header/type_option/select_option_editor.dart'; -import 'extension.dart'; -import 'text_field.dart'; - -const double _editorPanelWidth = 300; - -class SelectOptionCellEditor extends StatefulWidget { - final GridSelectOptionCellController cellController; - static double editorPanelWidth = 300; - - const SelectOptionCellEditor({required this.cellController, Key? key}) - : super(key: key); - - @override - State createState() => _SelectOptionCellEditorState(); -} - -class _SelectOptionCellEditorState extends State { - late PopoverMutex popoverMutex; - - @override - void initState() { - popoverMutex = PopoverMutex(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => SelectOptionCellEditorBloc( - cellController: widget.cellController, - )..add(const SelectOptionEditorEvent.initial()), - child: BlocBuilder( - builder: (context, state) { - return Padding( - padding: const EdgeInsets.all(6.0), - child: CustomScrollView( - shrinkWrap: true, - slivers: [ - SliverToBoxAdapter( - child: _TextField(popoverMutex: popoverMutex), - ), - const SliverToBoxAdapter(child: VSpace(6)), - const SliverToBoxAdapter(child: TypeOptionSeparator()), - const SliverToBoxAdapter(child: VSpace(6)), - const SliverToBoxAdapter(child: _Title()), - SliverToBoxAdapter( - child: _OptionList(popoverMutex: popoverMutex), - ), - ], - ), - ); - }, - ), - ); - } -} - -class _OptionList extends StatelessWidget { - final PopoverMutex popoverMutex; - const _OptionList({ - required this.popoverMutex, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - List cells = []; - cells.addAll(state.options.map((option) { - return _SelectOptionCell( - option: option, - isSelected: state.selectedOptions.contains(option), - popoverMutex: popoverMutex, - ); - }).toList()); - - state.createOption.fold( - () => null, - (createOption) { - cells.add(_CreateOptionCell(name: createOption)); - }, - ); - - final list = ListView.separated( - shrinkWrap: true, - controller: ScrollController(), - itemCount: cells.length, - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - physics: StyledScrollPhysics(), - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - ); - - return Padding( - padding: const EdgeInsets.all(3.0), - child: list, - ); - }, - ); - } -} - -class _TextField extends StatelessWidget { - final PopoverMutex popoverMutex; - final TextfieldTagsController _tagController = TextfieldTagsController(); - - _TextField({ - required this.popoverMutex, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final optionMap = LinkedHashMap.fromIterable( - state.selectedOptions, - key: (option) => option.name, - value: (option) => option); - - return SizedBox( - height: 62, - child: SelectOptionTextField( - options: state.options, - selectedOptionMap: optionMap, - distanceToText: _editorPanelWidth * 0.7, - maxLength: 30, - tagController: _tagController, - textSeparators: const [','], - onClick: () => popoverMutex.close(), - newText: (text) { - context - .read() - .add(SelectOptionEditorEvent.filterOption(text)); - }, - onSubmitted: (tagName) { - context - .read() - .add(SelectOptionEditorEvent.trySelectOption(tagName)); - }, - onPaste: (tagNames, remainder) { - context - .read() - .add(SelectOptionEditorEvent.selectMultipleOptions( - tagNames, - remainder, - )); - }, - ), - ); - }, - ); - } -} - -class _Title extends StatelessWidget { - const _Title({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: FlowyText.medium( - LocaleKeys.grid_selectOption_panelTitle.tr(), - fontSize: 12, - color: theme.shader3, - ), - ), - ); - } -} - -class _CreateOptionCell extends StatelessWidget { - final String name; - const _CreateOptionCell({required this.name, Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return Row( - children: [ - FlowyText.medium( - LocaleKeys.grid_selectOption_create.tr(), - fontSize: 12, - color: theme.shader3, - ), - const HSpace(10), - SelectOptionTag( - name: name, - color: theme.shader6, - onSelected: () => context - .read() - .add(SelectOptionEditorEvent.newOption(name)), - ), - ], - ); - } -} - -class _SelectOptionCell extends StatefulWidget { - final SelectOptionPB option; - final PopoverMutex popoverMutex; - final bool isSelected; - const _SelectOptionCell({ - required this.option, - required this.isSelected, - required this.popoverMutex, - Key? key, - }) : super(key: key); - - @override - State<_SelectOptionCell> createState() => _SelectOptionCellState(); -} - -class _SelectOptionCellState extends State<_SelectOptionCell> { - late PopoverController _popoverController; - - @override - void initState() { - _popoverController = PopoverController(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return AppFlowyPopover( - controller: _popoverController, - offset: const Offset(20, 0), - asBarrier: true, - constraints: BoxConstraints.loose(const Size(200, 300)), - mutex: widget.popoverMutex, - child: SizedBox( - height: GridSize.typeOptionItemHeight, - child: SelectOptionTagCell( - option: widget.option, - onSelected: (option) { - if (widget.isSelected) { - context - .read() - .add(SelectOptionEditorEvent.unSelectOption(option.id)); - } else { - context - .read() - .add(SelectOptionEditorEvent.selectOption(option.id)); - } - }, - children: [ - if (widget.isSelected) - Padding( - padding: const EdgeInsets.only(right: 6), - child: svgWidget("grid/checkmark"), - ), - FlowyIconButton( - width: 30, - onPressed: () => _popoverController.show(), - iconPadding: const EdgeInsets.fromLTRB(4, 4, 4, 4), - icon: svgWidget("editor/details", color: theme.iconColor), - ), - ], - ), - ), - popupBuilder: (BuildContext popoverContext) { - return SelectOptionTypeOptionEditor( - option: widget.option, - onDeleted: () { - context - .read() - .add(SelectOptionEditorEvent.deleteOption(widget.option)); - }, - onUpdated: (updatedOption) { - context - .read() - .add(SelectOptionEditorEvent.updateOption(updatedOption)); - }, - key: ValueKey( - widget.option.id, - ), // Use ValueKey to refresh the UI, otherwise, it will remain the old value. - ); - }, - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/text_field.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/text_field.dart deleted file mode 100644 index b1abbb9deaf71..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/text_field.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'dart:collection'; - -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:textfield_tags/textfield_tags.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -import 'extension.dart'; - -class SelectOptionTextField extends StatefulWidget { - final TextfieldTagsController tagController; - final List options; - final LinkedHashMap selectedOptionMap; - final double distanceToText; - final List textSeparators; - - final Function(String) onSubmitted; - final Function(String) newText; - final Function(List, String) onPaste; - final VoidCallback? onClick; - final int? maxLength; - - const SelectOptionTextField({ - required this.options, - required this.selectedOptionMap, - required this.distanceToText, - required this.tagController, - required this.onSubmitted, - required this.onPaste, - required this.newText, - required this.textSeparators, - this.onClick, - this.maxLength, - TextEditingController? textController, - FocusNode? focusNode, - Key? key, - }) : super(key: key); - - @override - State createState() => _SelectOptionTextFieldState(); -} - -class _SelectOptionTextFieldState extends State { - late FocusNode focusNode; - late TextEditingController controller; - - @override - void initState() { - focusNode = FocusNode(); - controller = TextEditingController(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - focusNode.requestFocus(); - }); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - return TextFieldTags( - textEditingController: controller, - textfieldTagsController: widget.tagController, - initialTags: widget.selectedOptionMap.keys.toList(), - focusNode: focusNode, - textSeparators: widget.textSeparators, - inputfieldBuilder: ( - BuildContext context, - editController, - focusNode, - error, - onChanged, - onSubmitted, - ) { - return ((context, sc, tags, onTagDelegate) { - return TextField( - controller: editController, - focusNode: focusNode, - onTap: widget.onClick, - onChanged: (text) { - if (onChanged != null) { - onChanged(text); - } - _newText(text, editController); - }, - onSubmitted: (text) { - if (onSubmitted != null) { - onSubmitted(text); - } - - if (text.isNotEmpty) { - widget.onSubmitted(text.trim()); - focusNode.requestFocus(); - } - }, - maxLines: 1, - maxLength: widget.maxLength, - maxLengthEnforcement: - MaxLengthEnforcement.truncateAfterCompositionEnds, - style: TextStyles.body1.size(FontSizes.s14), - decoration: InputDecoration( - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: theme.main1, width: 1.0), - borderRadius: Corners.s10Border, - ), - isDense: true, - prefixIcon: _renderTags(context, sc), - hintText: LocaleKeys.grid_selectOption_searchOption.tr(), - prefixIconConstraints: - BoxConstraints(maxWidth: widget.distanceToText), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: theme.main1, width: 1.0), - borderRadius: Corners.s10Border, - ), - ), - ); - }); - }, - ); - } - - void _newText(String text, TextEditingController editingController) { - if (text.isEmpty) { - widget.newText(''); - return; - } - - final trimmedText = text.trimLeft(); - List splits = []; - String currentString = ''; - - // split the string into tokens - for (final char in trimmedText.split('')) { - if (widget.textSeparators.contains(char)) { - if (currentString.isNotEmpty) { - splits.add(currentString.trim()); - } - currentString = ''; - continue; - } - currentString += char; - } - // add the remainder (might be '') - splits.add(currentString); - - final submittedOptions = splits.sublist(0, splits.length - 1).toList(); - - final remainder = splits.elementAt(splits.length - 1).trimLeft(); - editingController.text = remainder; - editingController.selection = - TextSelection.collapsed(offset: controller.text.length); - widget.onPaste(submittedOptions, remainder); - } - - Widget? _renderTags(BuildContext context, ScrollController sc) { - if (widget.selectedOptionMap.isEmpty) { - return null; - } - - final children = widget.selectedOptionMap.values - .map((option) => - SelectOptionTag.fromOption(context: context, option: option)) - .toList(); - return Padding( - padding: const EdgeInsets.all(8.0), - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.mouse, - PointerDeviceKind.touch, - PointerDeviceKind.trackpad, - PointerDeviceKind.stylus, - PointerDeviceKind.invertedStylus, - }, - ), - child: SingleChildScrollView( - controller: sc, - scrollDirection: Axis.horizontal, - child: Wrap(spacing: 4, children: children), - ), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/text_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/text_cell.dart deleted file mode 100644 index 329afb3d86637..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/text_cell.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'dart:async'; -import 'package:app_flowy/plugins/grid/presentation/widgets/cell/prelude.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import '../../layout/sizes.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; -import 'cell_builder.dart'; - -class GridTextCellStyle extends GridCellStyle { - String? placeholder; - - GridTextCellStyle({ - this.placeholder, - }); -} - -class GridTextCell extends GridCellWidget { - final GridCellControllerBuilder cellControllerBuilder; - late final GridTextCellStyle? cellStyle; - GridTextCell({ - required this.cellControllerBuilder, - GridCellStyle? style, - Key? key, - }) : super(key: key) { - if (style != null) { - cellStyle = (style as GridTextCellStyle); - } else { - cellStyle = null; - } - } - - @override - GridFocusNodeCellState createState() => _GridTextCellState(); -} - -class _GridTextCellState extends GridFocusNodeCellState { - late TextCellBloc _cellBloc; - late TextEditingController _controller; - Timer? _delayOperation; - - @override - void initState() { - final cellController = widget.cellControllerBuilder.build(); - _cellBloc = getIt(param1: cellController); - _cellBloc.add(const TextCellEvent.initial()); - _controller = TextEditingController(text: _cellBloc.state.content); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cellBloc, - child: BlocListener( - listener: (context, state) { - if (_controller.text != state.content) { - _controller.text = state.content; - } - }, - child: Padding( - padding: EdgeInsets.only( - left: GridSize.cellContentInsets.left, - right: GridSize.cellContentInsets.right, - ), - child: TextField( - controller: _controller, - focusNode: focusNode, - onChanged: (value) => focusChanged(), - onEditingComplete: () => focusNode.unfocus(), - maxLines: null, - style: TextStyles.body1.size(FontSizes.s14), - decoration: InputDecoration( - contentPadding: EdgeInsets.only( - top: GridSize.cellContentInsets.top, - bottom: GridSize.cellContentInsets.bottom, - ), - border: InputBorder.none, - hintText: widget.cellStyle?.placeholder, - isDense: true, - ), - ), - ), - ), - ); - } - - @override - Future dispose() async { - _delayOperation = null; - _cellBloc.close(); - super.dispose(); - } - - @override - Future focusChanged() async { - if (mounted) { - _delayOperation?.cancel(); - _delayOperation = Timer(const Duration(milliseconds: 30), () { - if (_cellBloc.isClosed == false && - _controller.text != _cellBloc.state.content) { - _cellBloc.add(TextCellEvent.updateText(_controller.text)); - } - }); - } - } - - @override - String? onCopy() => _cellBloc.state.content; - - @override - void onInsert(String value) { - _cellBloc.add(TextCellEvent.updateText(value)); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/cell_editor.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/cell_editor.dart deleted file mode 100644 index 58c63a309f5ae..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/cell_editor.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/application/cell/url_cell_editor_bloc.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flutter/material.dart'; -import 'dart:async'; - -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -class URLCellEditor extends StatefulWidget { - final GridURLCellController cellController; - const URLCellEditor({required this.cellController, Key? key}) - : super(key: key); - - @override - State createState() => _URLCellEditorState(); -} - -class _URLCellEditorState extends State { - late URLCellEditorBloc _cellBloc; - late TextEditingController _controller; - - @override - void initState() { - _cellBloc = URLCellEditorBloc(cellController: widget.cellController); - _cellBloc.add(const URLCellEditorEvent.initial()); - _controller = TextEditingController(text: _cellBloc.state.content); - - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cellBloc, - child: BlocListener( - listener: (context, state) { - if (_controller.text != state.content) { - _controller.text = state.content; - } - }, - child: TextField( - autofocus: true, - controller: _controller, - onChanged: (value) => focusChanged(), - maxLines: null, - style: TextStyles.body1.size(FontSizes.s14), - decoration: const InputDecoration( - contentPadding: EdgeInsets.zero, - border: InputBorder.none, - hintText: "", - isDense: true, - ), - ), - ), - ); - } - - @override - Future dispose() async { - _cellBloc.close(); - - super.dispose(); - } - - Future focusChanged() async { - if (mounted) { - if (_cellBloc.isClosed == false && - _controller.text != _cellBloc.state.content) { - _cellBloc.add(URLCellEditorEvent.updateText(_controller.text)); - } - } - } -} - -class URLEditorPopover extends StatelessWidget { - final GridURLCellController cellController; - const URLEditorPopover({required this.cellController, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 200, - child: Padding( - padding: const EdgeInsets.all(6), - child: URLCellEditor( - cellController: cellController, - ), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/url_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/url_cell.dart deleted file mode 100644 index 0abf5528b7d42..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/url_cell/url_cell.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'dart:async'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/plugins/grid/application/cell/url_cell_bloc.dart'; -import 'package:app_flowy/workspace/presentation/home/toast.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; -import 'package:url_launcher/url_launcher.dart'; -import '../../../layout/sizes.dart'; -import '../cell_accessory.dart'; -import '../cell_builder.dart'; -import 'cell_editor.dart'; - -class GridURLCellStyle extends GridCellStyle { - String? placeholder; - - List accessoryTypes; - - GridURLCellStyle({ - this.placeholder, - this.accessoryTypes = const [], - }); -} - -enum GridURLCellAccessoryType { - edit, - copyURL, -} - -class GridURLCell extends GridCellWidget { - final GridCellControllerBuilder cellControllerBuilder; - late final GridURLCellStyle? cellStyle; - GridURLCell({ - required this.cellControllerBuilder, - GridCellStyle? style, - Key? key, - }) : super(key: key) { - if (style != null) { - cellStyle = (style as GridURLCellStyle); - } else { - cellStyle = null; - } - } - - @override - GridCellState createState() => _GridURLCellState(); - - GridCellAccessoryBuilder accessoryFromType( - GridURLCellAccessoryType ty, GridCellAccessoryBuildContext buildContext) { - switch (ty) { - case GridURLCellAccessoryType.edit: - return GridCellAccessoryBuilder( - builder: (Key key) => _EditURLAccessory( - key: key, - anchorContext: buildContext.anchorContext, - cellControllerBuilder: cellControllerBuilder, - ), - ); - - case GridURLCellAccessoryType.copyURL: - final cellContext = - cellControllerBuilder.build() as GridURLCellController; - return GridCellAccessoryBuilder( - builder: (Key key) => _CopyURLAccessory( - key: key, - cellContext: cellContext, - ), - ); - } - } - - @override - List Function( - GridCellAccessoryBuildContext buildContext) - get accessoryBuilder => (buildContext) { - final List accessories = []; - if (cellStyle != null) { - accessories.addAll(cellStyle!.accessoryTypes.map((ty) { - return accessoryFromType(ty, buildContext); - })); - } - - // If the accessories is empty then the default accessory will be GridURLCellAccessoryType.edit - if (accessories.isEmpty) { - accessories.add(accessoryFromType( - GridURLCellAccessoryType.edit, buildContext)); - } - - return accessories; - }; -} - -class _GridURLCellState extends GridCellState { - final _popoverController = PopoverController(); - late URLCellBloc _cellBloc; - - @override - void initState() { - final cellController = - widget.cellControllerBuilder.build() as GridURLCellController; - _cellBloc = URLCellBloc(cellController: cellController); - _cellBloc.add(const URLCellEvent.initial()); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocProvider.value( - value: _cellBloc, - child: BlocBuilder( - builder: (context, state) { - final richText = Padding( - padding: GridSize.cellContentInsets, - child: RichText( - textAlign: TextAlign.left, - text: TextSpan( - text: state.content, - style: TextStyles.general( - fontSize: FontSizes.s14, - color: theme.main2, - ).underline, - ), - ), - ); - - return AppFlowyPopover( - controller: _popoverController, - constraints: BoxConstraints.loose(const Size(300, 160)), - direction: PopoverDirection.bottomWithLeftAligned, - triggerActions: PopoverTriggerFlags.none, - offset: const Offset(0, 20), - child: SizedBox.expand( - child: GestureDetector( - child: Align(alignment: Alignment.centerLeft, child: richText), - onTap: () async { - final url = context.read().state.url; - await _openUrlOrEdit(url); - }, - ), - ), - popupBuilder: (BuildContext popoverContext) { - return URLEditorPopover( - cellController: widget.cellControllerBuilder.build() - as GridURLCellController, - ); - }, - onClose: () { - widget.onCellEditing.value = false; - }, - ); - }, - ), - ); - } - - @override - Future dispose() async { - _cellBloc.close(); - super.dispose(); - } - - Future _openUrlOrEdit(String url) async { - final uri = Uri.parse(url); - if (url.isNotEmpty && await canLaunchUrl(uri)) { - await launchUrl(uri); - } - } - - @override - void requestBeginFocus() { - widget.onCellEditing.value = true; - _popoverController.show(); - } - - @override - String? onCopy() => _cellBloc.state.content; - - @override - void onInsert(String value) { - _cellBloc.add(URLCellEvent.updateURL(value)); - } -} - -class _EditURLAccessory extends StatefulWidget { - final GridCellControllerBuilder cellControllerBuilder; - final BuildContext anchorContext; - const _EditURLAccessory({ - required this.cellControllerBuilder, - required this.anchorContext, - Key? key, - }) : super(key: key); - - @override - State createState() => _EditURLAccessoryState(); -} - -class _EditURLAccessoryState extends State<_EditURLAccessory> - with GridCellAccessoryState { - late PopoverController _popoverController; - - @override - void initState() { - _popoverController = PopoverController(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(300, 160)), - controller: _popoverController, - direction: PopoverDirection.bottomWithLeftAligned, - offset: const Offset(0, 20), - child: svgWidget("editor/edit", color: theme.iconColor), - popupBuilder: (BuildContext popoverContext) { - return URLEditorPopover( - cellController: - widget.cellControllerBuilder.build() as GridURLCellController, - ); - }, - ); - } - - @override - void onTap() { - _popoverController.show(); - } -} - -class _CopyURLAccessory extends StatefulWidget { - final GridURLCellController cellContext; - const _CopyURLAccessory({required this.cellContext, Key? key}) - : super(key: key); - - @override - State createState() => _CopyURLAccessoryState(); -} - -class _CopyURLAccessoryState extends State<_CopyURLAccessory> - with GridCellAccessoryState { - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return svgWidget("editor/copy", color: theme.iconColor); - } - - @override - void onTap() { - final content = - widget.cellContext.getCellData(loadIfNotExist: false)?.content ?? ""; - Clipboard.setData(ClipboardData(text: content)); - showMessageToast(LocaleKeys.grid_row_copyProperty.tr()); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/common/text_field.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/common/text_field.dart deleted file mode 100644 index 50d264512d963..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/common/text_field.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/widget/rounded_input_field.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -class InputTextField extends StatefulWidget { - final void Function(String)? onDone; - final void Function(String)? onChanged; - final void Function() onCanceled; - final bool autoClearWhenDone; - final String text; - final int? maxLength; - - const InputTextField({ - required this.text, - this.onDone, - required this.onCanceled, - this.onChanged, - this.autoClearWhenDone = false, - this.maxLength, - Key? key, - }) : super(key: key); - - @override - State createState() => _InputTextFieldState(); -} - -class _InputTextFieldState extends State { - late FocusNode _focusNode; - var isEdited = false; - late TextEditingController _controller; - - @override - void initState() { - _focusNode = FocusNode(); - _controller = TextEditingController(text: widget.text); - SchedulerBinding.instance.addPostFrameCallback((Duration _) { - _focusNode.requestFocus(); - }); - - _focusNode.addListener(notifyDidEndEditing); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - final height = widget.maxLength == null ? 36.0 : 56.0; - - return RoundedInputField( - controller: _controller, - focusNode: _focusNode, - autoFocus: true, - height: height, - maxLength: widget.maxLength, - style: TextStyles.body1.size(13), - normalBorderColor: theme.shader4, - focusBorderColor: theme.main1, - cursorColor: theme.main1, - onChanged: (text) { - if (widget.onChanged != null) { - widget.onChanged!(text); - } - }, - onEditingComplete: (_) { - if (widget.onDone != null) { - widget.onDone!(_controller.text); - } - - if (widget.autoClearWhenDone) { - _controller.text = ""; - } - }, - ); - } - - @override - void dispose() { - _focusNode.removeListener(notifyDidEndEditing); - _focusNode.dispose(); - super.dispose(); - } - - void notifyDidEndEditing() { - if (!_focusNode.hasFocus) { - if (_controller.text.isEmpty) { - widget.onCanceled(); - } else { - if (widget.onDone != null) { - widget.onDone!(_controller.text); - } - } - } - } -} - -class TypeOptionSeparator extends StatelessWidget { - const TypeOptionSeparator({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Container( - color: theme.shader4, - height: 0.25, - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart deleted file mode 100755 index 338ada57fe5f8..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/plugins/grid/application/grid_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class GridAddRowButton extends StatelessWidget { - const GridAddRowButton({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return FlowyButton( - text: FlowyText.medium(LocaleKeys.grid_row_newRow.tr(), fontSize: 12), - hoverColor: theme.shader6, - onTap: () => context.read().add(const GridEvent.createRow()), - leftIcon: svgWidget("home/add", color: theme.iconColor), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/constants.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/constants.dart deleted file mode 100755 index 0a02de50764c3..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/constants.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:flutter/material.dart'; - -class GridHeaderConstants { - static Color get backgroundColor => Colors.grey; -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell.dart deleted file mode 100755 index 19f3b3f6f119c..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell.dart +++ /dev/null @@ -1,183 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/field_cell_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../layout/sizes.dart'; -import 'field_type_extension.dart'; - -import 'field_cell_action_sheet.dart'; - -class GridFieldCell extends StatefulWidget { - final GridFieldCellContext cellContext; - const GridFieldCell({ - Key? key, - required this.cellContext, - }) : super(key: key); - - @override - State createState() => _GridFieldCellState(); -} - -class _GridFieldCellState extends State { - late PopoverController popoverController; - - @override - void initState() { - popoverController = PopoverController(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) { - return FieldCellBloc(cellContext: widget.cellContext); - }, - child: BlocBuilder( - builder: (context, state) { - final button = AppFlowyPopover( - triggerActions: PopoverTriggerFlags.none, - constraints: BoxConstraints.loose(const Size(240, 840)), - direction: PopoverDirection.bottomWithLeftAligned, - controller: popoverController, - popupBuilder: (BuildContext context) { - return GridFieldCellActionSheet( - cellContext: widget.cellContext, - ); - }, - child: FieldCellButton( - field: widget.cellContext.field, - onTap: () => popoverController.show(), - ), - ); - - const line = Positioned( - top: 0, - bottom: 0, - right: 0, - child: _DragToExpandLine(), - ); - - return _GridHeaderCellContainer( - width: state.width, - child: Stack( - alignment: Alignment.centerRight, - fit: StackFit.expand, - children: [button, line], - ), - ); - }, - ), - ); - } -} - -class _GridHeaderCellContainer extends StatelessWidget { - final Widget child; - final double width; - const _GridHeaderCellContainer({ - required this.child, - required this.width, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - final borderSide = BorderSide(color: theme.shader5, width: 1.0); - final decoration = BoxDecoration( - border: Border( - top: borderSide, - right: borderSide, - bottom: borderSide, - )); - - return Container( - width: width, - decoration: decoration, - child: ConstrainedBox( - constraints: const BoxConstraints.expand(), child: child), - ); - } -} - -class _DragToExpandLine extends StatelessWidget { - const _DragToExpandLine({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - return InkWell( - onTap: () {}, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragUpdate: (value) { - debugPrint("update new width: ${value.delta.dx}"); - context - .read() - .add(FieldCellEvent.startUpdateWidth(value.delta.dx)); - }, - onHorizontalDragEnd: (end) { - context - .read() - .add(const FieldCellEvent.endUpdateWidth()); - }, - child: FlowyHover( - cursor: SystemMouseCursors.resizeLeftRight, - style: HoverStyle( - hoverColor: theme.main1, - borderRadius: BorderRadius.zero, - contentMargin: const EdgeInsets.only(left: 6), - ), - child: const SizedBox(width: 4), - ), - ), - ); - } -} - -class FieldCellButton extends StatelessWidget { - final VoidCallback onTap; - final FieldPB field; - final int? maxLines; - const FieldCellButton({ - required this.field, - required this.onTap, - this.maxLines = 1, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - // Using this technique to have proper text ellipsis - // https://github.com/flutter/flutter/issues/18761#issuecomment-812390920 - final text = Characters(field.name) - .replaceAll(Characters(''), Characters('\u{200B}')) - .toString(); - return FlowyButton( - hoverColor: theme.shader6, - onTap: onTap, - leftIcon: svgWidget(field.fieldType.iconName(), color: theme.iconColor), - text: FlowyText.medium( - text, - fontSize: 12, - maxLines: maxLines, - overflow: TextOverflow.ellipsis, - ), - margin: GridSize.cellContentInsets, - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell_action_sheet.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell_action_sheet.dart deleted file mode 100644 index 5dca885414961..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell_action_sheet.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:app_flowy/plugins/grid/presentation/widgets/header/field_editor.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import 'package:app_flowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; - -import '../../layout/sizes.dart'; - -class GridFieldCellActionSheet extends StatefulWidget { - final GridFieldCellContext cellContext; - const GridFieldCellActionSheet({required this.cellContext, Key? key}) - : super(key: key); - - @override - State createState() => _GridFieldCellActionSheetState(); -} - -class _GridFieldCellActionSheetState extends State { - bool _showFieldEditor = false; - - @override - Widget build(BuildContext context) { - if (_showFieldEditor) { - final field = widget.cellContext.field; - return FieldEditor( - gridId: widget.cellContext.gridId, - fieldName: field.name, - typeOptionLoader: FieldTypeOptionLoader( - gridId: widget.cellContext.gridId, - field: field, - ), - ); - } - return BlocProvider( - create: (context) => - getIt(param1: widget.cellContext), - child: SingleChildScrollView( - child: Column( - children: [ - _EditFieldButton( - cellContext: widget.cellContext, - onTap: () { - setState(() { - _showFieldEditor = true; - }); - }, - ), - const VSpace(6), - _FieldOperationList(widget.cellContext, () {}), - ], - ), - ), - ); - } -} - -class _EditFieldButton extends StatelessWidget { - final GridFieldCellContext cellContext; - final void Function()? onTap; - const _EditFieldButton({required this.cellContext, Key? key, this.onTap}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - return BlocBuilder( - builder: (context, state) { - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyButton( - text: FlowyText.medium( - LocaleKeys.grid_field_editProperty.tr(), - fontSize: 12, - ), - hoverColor: theme.hover, - onTap: onTap, - ), - ); - }, - ); - } -} - -class _FieldOperationList extends StatelessWidget { - final GridFieldCellContext fieldContext; - final VoidCallback onDismissed; - const _FieldOperationList(this.fieldContext, this.onDismissed, {Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return GridView( - // https://api.flutter.dev/flutter/widgets/AnimatedList/shrinkWrap.html - shrinkWrap: true, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 4.0, - mainAxisSpacing: 8, - ), - children: buildCells(), - ); - } - - List buildCells() { - return FieldAction.values.map( - (action) { - bool enable = true; - switch (action) { - case FieldAction.delete: - enable = !fieldContext.field.isPrimary; - break; - default: - break; - } - - return FieldActionCell( - fieldContext: fieldContext, - action: action, - onTap: onDismissed, - enable: enable, - ); - }, - ).toList(); - } -} - -class FieldActionCell extends StatelessWidget { - final GridFieldCellContext fieldContext; - final VoidCallback onTap; - final FieldAction action; - final bool enable; - - const FieldActionCell({ - required this.fieldContext, - required this.action, - required this.onTap, - required this.enable, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return FlowyButton( - text: FlowyText.medium( - action.title(), - fontSize: 12, - color: enable ? null : theme.shader4, - ), - hoverColor: theme.hover, - onTap: () { - if (enable) { - action.run(context, fieldContext); - onTap(); - } - }, - leftIcon: svgWidget( - action.iconName(), - color: enable ? theme.iconColor : theme.disableIconColor, - ), - ); - } -} - -enum FieldAction { - hide, - duplicate, - delete, -} - -extension _FieldActionExtension on FieldAction { - String iconName() { - switch (this) { - case FieldAction.hide: - return 'grid/hide'; - case FieldAction.duplicate: - return 'grid/duplicate'; - case FieldAction.delete: - return 'grid/delete'; - } - } - - String title() { - switch (this) { - case FieldAction.hide: - return LocaleKeys.grid_field_hide.tr(); - case FieldAction.duplicate: - return LocaleKeys.grid_field_duplicate.tr(); - case FieldAction.delete: - return LocaleKeys.grid_field_delete.tr(); - } - } - - void run(BuildContext context, GridFieldCellContext fieldContext) { - switch (this) { - case FieldAction.hide: - context - .read() - .add(const FieldActionSheetEvent.hideField()); - break; - case FieldAction.duplicate: - PopoverContainer.of(context).close(); - - FieldService( - gridId: fieldContext.gridId, - fieldId: fieldContext.field.id, - ).duplicateField(); - - break; - case FieldAction.delete: - PopoverContainer.of(context).close(); - - NavigatorAlertDialog( - title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), - confirm: () { - FieldService( - gridId: fieldContext.gridId, - fieldId: fieldContext.field.id, - ).deleteField(); - }, - ).show(context); - - break; - } - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_editor.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_editor.dart deleted file mode 100644 index d69930d95f460..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_editor.dart +++ /dev/null @@ -1,244 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/field_editor_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:app_flowy/plugins/grid/presentation/layout/sizes.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:dartz/dartz.dart' show none; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/rounded_input_field.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'field_type_option_editor.dart'; - -class FieldEditor extends StatefulWidget { - final String gridId; - final String fieldName; - final bool isGroupField; - final Function(String)? onDeleted; - final IFieldTypeOptionLoader typeOptionLoader; - - const FieldEditor({ - required this.gridId, - this.fieldName = "", - required this.typeOptionLoader, - this.isGroupField = false, - this.onDeleted, - Key? key, - }) : super(key: key); - - @override - State createState() => _FieldEditorState(); -} - -class _FieldEditorState extends State { - late PopoverMutex popoverMutex; - - @override - void initState() { - popoverMutex = PopoverMutex(); - super.initState(); - } - - @override - void dispose() { - popoverMutex.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => FieldEditorBloc( - gridId: widget.gridId, - fieldName: widget.fieldName, - isGroupField: widget.isGroupField, - loader: widget.typeOptionLoader, - )..add(const FieldEditorEvent.initial()), - child: Padding( - padding: GridSize.typeOptionContentInsets, - child: ListView( - shrinkWrap: true, - children: [ - FlowyText.medium( - LocaleKeys.grid_field_editProperty.tr(), - fontSize: 12, - ), - const VSpace(10), - _FieldNameTextField(popoverMutex: popoverMutex), - const VSpace(10), - ..._addDeleteFieldButton(), - _FieldTypeOptionCell(popoverMutex: popoverMutex), - ], - ), - ), - ); - } - - List _addDeleteFieldButton() { - if (widget.onDeleted == null) { - return []; - } - return [ - BlocBuilder( - builder: (context, state) { - return _DeleteFieldButton( - popoverMutex: popoverMutex, - onDeleted: () { - state.field.fold( - () => Log.error('Can not delete the field'), - (field) => widget.onDeleted?.call(field.id), - ); - }, - ); - }, - ), - ]; - } -} - -class _FieldTypeOptionCell extends StatelessWidget { - final PopoverMutex popoverMutex; - - const _FieldTypeOptionCell({ - Key? key, - required this.popoverMutex, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - buildWhen: (p, c) => p.field != c.field, - builder: (context, state) { - return state.field.fold( - () => const SizedBox(), - (fieldContext) { - final dataController = - context.read().dataController; - return FieldTypeOptionEditor( - dataController: dataController, - popoverMutex: popoverMutex, - ); - }, - ); - }, - ); - } -} - -class _FieldNameTextField extends StatefulWidget { - final PopoverMutex popoverMutex; - const _FieldNameTextField({ - required this.popoverMutex, - Key? key, - }) : super(key: key); - - @override - State<_FieldNameTextField> createState() => _FieldNameTextFieldState(); -} - -class _FieldNameTextFieldState extends State<_FieldNameTextField> { - FocusNode focusNode = FocusNode(); - late TextEditingController controller; - - @override - void initState() { - controller = TextEditingController(); - focusNode.addListener(() { - if (focusNode.hasFocus) { - widget.popoverMutex.close(); - } - }); - - widget.popoverMutex.listenOnPopoverChanged(() { - if (focusNode.hasFocus) { - focusNode.unfocus(); - } - }); - - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (p, c) => p.field == none(), - listener: (context, state) { - focusNode.requestFocus(); - }, - ), - BlocListener( - listenWhen: (p, c) => controller.text != c.name, - listener: (context, state) { - controller.text = state.name; - }, - ), - ], - child: BlocBuilder( - buildWhen: (previous, current) => - previous.errorText != current.errorText, - builder: (context, state) { - return RoundedInputField( - height: 36, - focusNode: focusNode, - style: TextStyles.general( - fontSize: 13, - ), - controller: controller, - normalBorderColor: theme.shader4, - errorBorderColor: theme.red, - focusBorderColor: theme.main1, - cursorColor: theme.main1, - errorText: context.read().state.errorText, - onChanged: (newName) { - context - .read() - .add(FieldEditorEvent.updateName(newName)); - }, - ); - }, - ), - ); - } -} - -class _DeleteFieldButton extends StatelessWidget { - final PopoverMutex popoverMutex; - final VoidCallback? onDeleted; - - const _DeleteFieldButton({ - required this.popoverMutex, - required this.onDeleted, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocBuilder( - buildWhen: (previous, current) => previous != current, - builder: (context, state) { - final enable = !state.canDelete && !state.isGroupField; - Widget button = FlowyButton( - text: FlowyText.medium( - LocaleKeys.grid_field_delete.tr(), - fontSize: 12, - color: enable ? null : theme.shader4, - ), - onTap: () => onDeleted?.call(), - hoverColor: theme.hover, - onHover: (_) => popoverMutex.close(), - ); - return SizedBox(height: 36, child: button); - }, - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_extension.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_extension.dart deleted file mode 100644 index 0f6da9951ed80..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_extension.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; - -extension FieldTypeListExtension on FieldType { - String iconName() { - switch (this) { - case FieldType.Checkbox: - return "grid/field/checkbox"; - case FieldType.DateTime: - return "grid/field/date"; - case FieldType.MultiSelect: - return "grid/field/multi_select"; - case FieldType.Number: - return "grid/field/number"; - case FieldType.RichText: - return "grid/field/text"; - case FieldType.SingleSelect: - return "grid/field/single_select"; - case FieldType.URL: - return "grid/field/url"; - } - throw UnimplementedError; - } - - String title() { - switch (this) { - case FieldType.Checkbox: - return LocaleKeys.grid_field_checkboxFieldName.tr(); - case FieldType.DateTime: - return LocaleKeys.grid_field_dateFieldName.tr(); - case FieldType.MultiSelect: - return LocaleKeys.grid_field_multiSelectFieldName.tr(); - case FieldType.Number: - return LocaleKeys.grid_field_numberFieldName.tr(); - case FieldType.RichText: - return LocaleKeys.grid_field_textFieldName.tr(); - case FieldType.SingleSelect: - return LocaleKeys.grid_field_singleSelectFieldName.tr(); - case FieldType.URL: - return LocaleKeys.grid_field_urlFieldName.tr(); - } - throw UnimplementedError; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_list.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_list.dart deleted file mode 100644 index e504c0ab4fba5..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_list.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter/material.dart'; -import '../../layout/sizes.dart'; -import 'field_type_extension.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -typedef SelectFieldCallback = void Function(FieldType); - -class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate { - final SelectFieldCallback onSelectField; - const FieldTypeList({required this.onSelectField, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final cells = FieldType.values.map((fieldType) { - return FieldTypeCell( - fieldType: fieldType, - onSelectField: (fieldType) { - onSelectField(fieldType); - PopoverContainer.of(context).closeAll(); - }, - ); - }).toList(); - - return SizedBox( - width: 140, - child: ListView.separated( - shrinkWrap: true, - controller: ScrollController(), - itemCount: cells.length, - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - physics: StyledScrollPhysics(), - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - ), - ); - } -} - -class FieldTypeCell extends StatelessWidget { - final FieldType fieldType; - final SelectFieldCallback onSelectField; - const FieldTypeCell({ - required this.fieldType, - required this.onSelectField, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyButton( - text: FlowyText.medium(fieldType.title(), fontSize: 12), - hoverColor: theme.hover, - onTap: () => onSelectField(fieldType), - leftIcon: svgWidget(fieldType.iconName(), color: theme.iconColor), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_option_editor.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_option_editor.dart deleted file mode 100644 index a3a41ef35bc21..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_type_option_editor.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'dart:typed_data'; -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_data_controller.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:dartz/dartz.dart' show Either; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import '../../layout/sizes.dart'; -import 'field_type_extension.dart'; -import 'field_type_list.dart'; -import 'type_option/builder.dart'; - -typedef UpdateFieldCallback = void Function(FieldPB, Uint8List); -typedef SwitchToFieldCallback - = Future> Function( - String fieldId, - FieldType fieldType, -); - -class FieldTypeOptionEditor extends StatelessWidget { - final TypeOptionDataController _dataController; - final PopoverMutex popoverMutex; - - const FieldTypeOptionEditor({ - required TypeOptionDataController dataController, - required this.popoverMutex, - Key? key, - }) : _dataController = dataController, - super(key: key); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) { - final bloc = FieldTypeOptionEditBloc(_dataController); - bloc.add(const FieldTypeOptionEditEvent.initial()); - return bloc; - }, - child: BlocBuilder( - builder: (context, state) { - final typeOptionWidget = _typeOptionWidget( - context: context, - state: state, - ); - - List children = [ - _SwitchFieldButton(popoverMutex: popoverMutex), - if (typeOptionWidget != null) typeOptionWidget - ]; - - return ListView( - shrinkWrap: true, - children: children, - ); - }, - ), - ); - } - - Widget? _typeOptionWidget({ - required BuildContext context, - required FieldTypeOptionEditState state, - }) { - return makeTypeOptionWidget( - context: context, - dataController: _dataController, - popoverMutex: popoverMutex, - ); - } -} - -class _SwitchFieldButton extends StatelessWidget { - final PopoverMutex popoverMutex; - const _SwitchFieldButton({ - required this.popoverMutex, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final widget = AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(460, 540)), - asBarrier: true, - triggerActions: PopoverTriggerFlags.click | PopoverTriggerFlags.hover, - mutex: popoverMutex, - offset: const Offset(20, 0), - popupBuilder: (popOverContext) { - return FieldTypeList(onSelectField: (newFieldType) { - context - .read() - .add(FieldTypeOptionEditEvent.switchToField(newFieldType)); - }); - }, - child: _buildMoreButton(context), - ); - - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: widget, - ); - } - - Widget _buildMoreButton(BuildContext context) { - final theme = context.read(); - final bloc = context.read(); - return FlowyButton( - text: FlowyText.medium( - bloc.state.field.fieldType.title(), - fontSize: 12, - ), - margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - hoverColor: theme.hover, - leftIcon: svgWidget( - bloc.state.field.fieldType.iconName(), - color: theme.iconColor, - ), - rightIcon: svgWidget("grid/more", color: theme.iconColor), - ); - } -} - -abstract class TypeOptionWidget extends StatelessWidget { - const TypeOptionWidget({Key? key}) : super(key: key); -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/grid_header.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/grid_header.dart deleted file mode 100644 index f155fdd88c9ba..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/grid_header.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:reorderables/reorderables.dart'; -import '../../layout/sizes.dart'; -import 'field_editor.dart'; -import 'field_cell.dart'; - -class GridHeaderSliverAdaptor extends StatefulWidget { - final String gridId; - final GridFieldController fieldController; - final ScrollController anchorScrollController; - const GridHeaderSliverAdaptor({ - required this.gridId, - required this.fieldController, - required this.anchorScrollController, - Key? key, - }) : super(key: key); - - @override - State createState() => - _GridHeaderSliverAdaptorState(); -} - -class _GridHeaderSliverAdaptorState extends State { - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) { - final bloc = getIt( - param1: widget.gridId, param2: widget.fieldController); - bloc.add(const GridHeaderEvent.initial()); - return bloc; - }, - child: BlocBuilder( - buildWhen: (previous, current) => - previous.fields.length != current.fields.length, - builder: (context, state) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: widget.anchorScrollController, - child: SizedBox( - height: GridSize.headerHeight, - child: _GridHeader(gridId: widget.gridId), - ), - ); - - // return SliverPersistentHeader( - // delegate: SliverHeaderDelegateImplementation(gridId: gridId, fields: state.fields), - // floating: true, - // pinned: true, - // ); - }, - ), - ); - } -} - -class _GridHeader extends StatefulWidget { - final String gridId; - const _GridHeader({Key? key, required this.gridId}) : super(key: key); - - @override - State<_GridHeader> createState() => _GridHeaderState(); -} - -class _GridHeaderState extends State<_GridHeader> { - final Map> _gridMap = {}; - - /// This is a workaround for [ReorderableRow]. - /// [ReorderableRow] warps the child's key with a [GlobalKey]. - /// It will trigger the child's widget's to recreate. - /// The state will lose. - _getKeyById(String id) { - if (_gridMap.containsKey(id)) { - return _gridMap[id]; - } - final newKey = ValueKey(id); - _gridMap[id] = newKey; - return newKey; - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocBuilder( - buildWhen: (previous, current) => previous.fields != current.fields, - builder: (context, state) { - final cells = state.fields - .where((field) => field.visibility) - .map((field) => - GridFieldCellContext(gridId: widget.gridId, field: field.field)) - .map((ctx) => - GridFieldCell(key: _getKeyById(ctx.field.id), cellContext: ctx)) - .toList(); - - return Container( - color: theme.surface, - child: RepaintBoundary( - child: ReorderableRow( - crossAxisAlignment: CrossAxisAlignment.stretch, - scrollController: ScrollController(), - header: const _CellLeading(), - needsLongPressDraggable: false, - footer: _CellTrailing(gridId: widget.gridId), - onReorder: (int oldIndex, int newIndex) { - _onReorder(cells, oldIndex, context, newIndex); - }, - children: cells, - ), - ), - ); - }, - ); - } - - void _onReorder(List cells, int oldIndex, BuildContext context, - int newIndex) { - if (cells.length > oldIndex) { - final field = cells[oldIndex].cellContext.field; - context - .read() - .add(GridHeaderEvent.moveField(field, oldIndex, newIndex)); - } - } -} - -class _CellLeading extends StatelessWidget { - const _CellLeading({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: GridSize.leadingHeaderPadding, - ); - } -} - -class _CellTrailing extends StatelessWidget { - final String gridId; - const _CellTrailing({required this.gridId, Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - final borderSide = BorderSide(color: theme.shader4, width: 0.4); - return Container( - width: GridSize.trailHeaderPadding, - decoration: BoxDecoration( - border: Border(top: borderSide, bottom: borderSide), - ), - padding: GridSize.headerContentInsets, - child: CreateFieldButton(gridId: gridId), - ); - } -} - -class CreateFieldButton extends StatelessWidget { - final String gridId; - const CreateFieldButton({required this.gridId, Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - return AppFlowyPopover( - direction: PopoverDirection.bottomWithRightAligned, - asBarrier: true, - constraints: BoxConstraints.loose(const Size(240, 600)), - child: FlowyButton( - radius: BorderRadius.zero, - text: FlowyText.medium( - LocaleKeys.grid_field_newColumn.tr(), - fontSize: 12, - ), - hoverColor: theme.shader6, - onTap: () {}, - leftIcon: svgWidget( - "home/add", - color: theme.iconColor, - ), - ), - popupBuilder: (BuildContext popover) { - return FieldEditor( - gridId: gridId, - typeOptionLoader: NewFieldTypeOptionLoader(gridId: gridId), - ); - }, - ); - } -} - -class SliverHeaderDelegateImplementation - extends SliverPersistentHeaderDelegate { - final String gridId; - final List fields; - - SliverHeaderDelegateImplementation( - {required this.gridId, required this.fields}); - - @override - Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { - return _GridHeader(gridId: gridId); - } - - @override - double get maxExtent => GridSize.headerHeight; - - @override - double get minExtent => GridSize.headerHeight; - - @override - bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { - if (oldDelegate is SliverHeaderDelegateImplementation) { - return fields.length != oldDelegate.fields.length; - } - return true; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/builder.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/builder.dart deleted file mode 100644 index e6d47a06c0a58..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/builder.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'dart:typed_data'; - -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_data_controller.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/checkbox_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/multi_select_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/number_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/single_select_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/text_type_option.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/url_type_option.pb.dart'; -import 'package:protobuf/protobuf.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter/material.dart'; -import 'checkbox.dart'; -import 'date.dart'; -import 'multi_select.dart'; -import 'number.dart'; -import 'rich_text.dart'; -import 'single_select.dart'; -import 'url.dart'; - -typedef TypeOptionData = Uint8List; -typedef TypeOptionDataCallback = void Function(TypeOptionData typeOptionData); -typedef ShowOverlayCallback = void Function( - BuildContext anchorContext, - Widget child, { - VoidCallback? onRemoved, -}); -typedef HideOverlayCallback = void Function(BuildContext anchorContext); - -class TypeOptionOverlayDelegate { - ShowOverlayCallback showOverlay; - HideOverlayCallback hideOverlay; - TypeOptionOverlayDelegate({ - required this.showOverlay, - required this.hideOverlay, - }); -} - -abstract class TypeOptionWidgetBuilder { - Widget? build(BuildContext context); -} - -Widget? makeTypeOptionWidget({ - required BuildContext context, - required TypeOptionDataController dataController, - required PopoverMutex popoverMutex, -}) { - final builder = makeTypeOptionWidgetBuilder( - dataController: dataController, - popoverMutex: popoverMutex, - ); - return builder.build(context); -} - -TypeOptionWidgetBuilder makeTypeOptionWidgetBuilder({ - required TypeOptionDataController dataController, - required PopoverMutex popoverMutex, -}) { - final gridId = dataController.gridId; - final fieldType = dataController.field.fieldType; - - switch (dataController.field.fieldType) { - case FieldType.Checkbox: - return CheckboxTypeOptionWidgetBuilder( - makeTypeOptionContextWithDataController( - gridId: gridId, - fieldType: fieldType, - dataController: dataController, - ), - ); - case FieldType.DateTime: - return DateTypeOptionWidgetBuilder( - makeTypeOptionContextWithDataController( - gridId: gridId, - fieldType: fieldType, - dataController: dataController, - ), - popoverMutex); - case FieldType.SingleSelect: - return SingleSelectTypeOptionWidgetBuilder( - makeTypeOptionContextWithDataController( - gridId: gridId, - fieldType: fieldType, - dataController: dataController, - ), - popoverMutex, - ); - case FieldType.MultiSelect: - return MultiSelectTypeOptionWidgetBuilder( - makeTypeOptionContextWithDataController( - gridId: gridId, - fieldType: fieldType, - dataController: dataController, - ), - popoverMutex, - ); - case FieldType.Number: - return NumberTypeOptionWidgetBuilder( - makeTypeOptionContextWithDataController( - gridId: gridId, - fieldType: fieldType, - dataController: dataController, - ), - popoverMutex, - ); - case FieldType.RichText: - return RichTextTypeOptionWidgetBuilder( - makeTypeOptionContextWithDataController( - gridId: gridId, - fieldType: fieldType, - dataController: dataController, - ), - ); - - case FieldType.URL: - return URLTypeOptionWidgetBuilder( - makeTypeOptionContextWithDataController( - gridId: gridId, - fieldType: fieldType, - dataController: dataController, - ), - ); - } - throw UnimplementedError; -} - -TypeOptionContext makeTypeOptionContext({ - required String gridId, - required GridFieldContext fieldContext, -}) { - final loader = - FieldTypeOptionLoader(gridId: gridId, field: fieldContext.field); - final dataController = TypeOptionDataController( - gridId: gridId, - loader: loader, - fieldContext: fieldContext, - ); - return makeTypeOptionContextWithDataController( - gridId: gridId, - fieldType: fieldContext.fieldType, - dataController: dataController, - ); -} - -TypeOptionContext - makeTypeOptionContextWithDataController({ - required String gridId, - required FieldType fieldType, - required TypeOptionDataController dataController, -}) { - switch (fieldType) { - case FieldType.Checkbox: - return CheckboxTypeOptionContext( - dataController: dataController, - dataParser: CheckboxTypeOptionWidgetDataParser(), - ) as TypeOptionContext; - case FieldType.DateTime: - return DateTypeOptionContext( - dataController: dataController, - dataParser: DateTypeOptionDataParser(), - ) as TypeOptionContext; - case FieldType.SingleSelect: - return SingleSelectTypeOptionContext( - dataController: dataController, - dataParser: SingleSelectTypeOptionWidgetDataParser(), - ) as TypeOptionContext; - case FieldType.MultiSelect: - return MultiSelectTypeOptionContext( - dataController: dataController, - dataParser: MultiSelectTypeOptionWidgetDataParser(), - ) as TypeOptionContext; - case FieldType.Number: - return NumberTypeOptionContext( - dataController: dataController, - dataParser: NumberTypeOptionWidgetDataParser(), - ) as TypeOptionContext; - case FieldType.RichText: - return RichTextTypeOptionContext( - dataController: dataController, - dataParser: RichTextTypeOptionWidgetDataParser(), - ) as TypeOptionContext; - - case FieldType.URL: - return URLTypeOptionContext( - dataController: dataController, - dataParser: URLTypeOptionWidgetDataParser(), - ) as TypeOptionContext; - } - - throw UnimplementedError; -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/checkbox.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/checkbox.dart deleted file mode 100644 index 92511a888a1cd..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/checkbox.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:flutter/material.dart'; -import 'builder.dart'; - -class CheckboxTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { - CheckboxTypeOptionWidgetBuilder(CheckboxTypeOptionContext typeOptionContext); - - @override - Widget? build(BuildContext context) => null; -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/date.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/date.dart deleted file mode 100644 index 7ca3d4ad28b88..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/date.dart +++ /dev/null @@ -1,362 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/date_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:app_flowy/workspace/presentation/widgets/toggle/toggle.dart'; -import 'package:app_flowy/workspace/presentation/widgets/toggle/toggle_style.dart'; -import 'package:easy_localization/easy_localization.dart' hide DateFormat; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option_entities.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import '../../../layout/sizes.dart'; -import '../../common/text_field.dart'; -import '../field_type_option_editor.dart'; -import 'builder.dart'; - -class DateTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { - final DateTypeOptionWidget _widget; - - DateTypeOptionWidgetBuilder( - DateTypeOptionContext typeOptionContext, - PopoverMutex popoverMutex, - ) : _widget = DateTypeOptionWidget( - typeOptionContext: typeOptionContext, - popoverMutex: popoverMutex, - ); - - @override - Widget? build(BuildContext context) { - return _widget; - } -} - -class DateTypeOptionWidget extends TypeOptionWidget { - final DateTypeOptionContext typeOptionContext; - final PopoverMutex popoverMutex; - const DateTypeOptionWidget({ - required this.typeOptionContext, - required this.popoverMutex, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - DateTypeOptionBloc(typeOptionContext: typeOptionContext), - child: BlocConsumer( - listener: (context, state) => - typeOptionContext.typeOption = state.typeOption, - builder: (context, state) { - return Column( - children: [ - const TypeOptionSeparator(), - _renderDateFormatButton(context, state.typeOption.dateFormat), - _renderTimeFormatButton(context, state.typeOption.timeFormat), - const _IncludeTimeButton(), - ], - ); - }, - ), - ); - } - - Widget _renderDateFormatButton(BuildContext context, DateFormat dataFormat) { - return AppFlowyPopover( - mutex: popoverMutex, - asBarrier: true, - triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, - offset: const Offset(20, 0), - constraints: BoxConstraints.loose(const Size(460, 440)), - popupBuilder: (popoverContext) { - return DateFormatList( - selectedFormat: dataFormat, - onSelected: (format) { - context - .read() - .add(DateTypeOptionEvent.didSelectDateFormat(format)); - PopoverContainer.of(popoverContext).close(); - }, - ); - }, - child: const DateFormatButton(), - ); - } - - Widget _renderTimeFormatButton(BuildContext context, TimeFormat timeFormat) { - return AppFlowyPopover( - mutex: popoverMutex, - asBarrier: true, - triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, - offset: const Offset(20, 0), - constraints: BoxConstraints.loose(const Size(460, 440)), - popupBuilder: (BuildContext popoverContext) { - return TimeFormatList( - selectedFormat: timeFormat, - onSelected: (format) { - context - .read() - .add(DateTypeOptionEvent.didSelectTimeFormat(format)); - PopoverContainer.of(popoverContext).close(); - }, - ); - }, - child: TimeFormatButton(timeFormat: timeFormat), - ); - } -} - -class DateFormatButton extends StatelessWidget { - final VoidCallback? onTap; - final void Function(bool)? onHover; - const DateFormatButton({this.onTap, this.onHover, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyButton( - text: FlowyText.medium(LocaleKeys.grid_field_dateFormat.tr(), - fontSize: 12), - margin: GridSize.typeOptionContentInsets, - hoverColor: theme.hover, - onTap: onTap, - onHover: onHover, - rightIcon: svgWidget("grid/more", color: theme.iconColor), - ), - ); - } -} - -class TimeFormatButton extends StatelessWidget { - final TimeFormat timeFormat; - final VoidCallback? onTap; - final void Function(bool)? onHover; - const TimeFormatButton( - {required this.timeFormat, this.onTap, this.onHover, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyButton( - text: FlowyText.medium(LocaleKeys.grid_field_timeFormat.tr(), - fontSize: 12), - margin: GridSize.typeOptionContentInsets, - hoverColor: theme.hover, - onTap: onTap, - onHover: onHover, - rightIcon: svgWidget("grid/more", color: theme.iconColor), - ), - ); - } -} - -class _IncludeTimeButton extends StatelessWidget { - const _IncludeTimeButton({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocSelector( - selector: (state) => state.typeOption.includeTime, - builder: (context, includeTime) { - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: Padding( - padding: GridSize.typeOptionContentInsets, - child: Row( - children: [ - FlowyText.medium(LocaleKeys.grid_field_includeTime.tr(), - fontSize: 12), - const Spacer(), - Toggle( - value: includeTime, - onChanged: (value) { - context - .read() - .add(DateTypeOptionEvent.includeTime(!value)); - }, - style: ToggleStyle.big(theme), - padding: EdgeInsets.zero, - ), - ], - ), - ), - ); - }, - ); - } -} - -class DateFormatList extends StatelessWidget { - final DateFormat selectedFormat; - final Function(DateFormat format) onSelected; - const DateFormatList( - {required this.selectedFormat, required this.onSelected, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final cells = DateFormat.values.map((format) { - return DateFormatCell( - dateFormat: format, - onSelected: onSelected, - isSelected: selectedFormat == format, - ); - }).toList(); - - return SizedBox( - width: 180, - child: ListView.separated( - shrinkWrap: true, - controller: ScrollController(), - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - itemCount: cells.length, - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - ), - ); - } -} - -class DateFormatCell extends StatelessWidget { - final bool isSelected; - final DateFormat dateFormat; - final Function(DateFormat format) onSelected; - const DateFormatCell({ - required this.dateFormat, - required this.onSelected, - required this.isSelected, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - Widget? checkmark; - if (isSelected) { - checkmark = svgWidget("grid/checkmark"); - } - - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyButton( - text: FlowyText.medium(dateFormat.title(), fontSize: 12), - hoverColor: theme.hover, - rightIcon: checkmark, - onTap: () => onSelected(dateFormat), - ), - ); - } -} - -extension DateFormatExtension on DateFormat { - String title() { - switch (this) { - case DateFormat.Friendly: - return LocaleKeys.grid_field_dateFormatFriendly.tr(); - case DateFormat.ISO: - return LocaleKeys.grid_field_dateFormatISO.tr(); - case DateFormat.Local: - return LocaleKeys.grid_field_dateFormatLocal.tr(); - case DateFormat.US: - return LocaleKeys.grid_field_dateFormatUS.tr(); - default: - throw UnimplementedError; - } - } -} - -class TimeFormatList extends StatelessWidget { - final TimeFormat selectedFormat; - final Function(TimeFormat format) onSelected; - const TimeFormatList({ - required this.selectedFormat, - required this.onSelected, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final cells = TimeFormat.values.map((format) { - return TimeFormatCell( - isSelected: format == selectedFormat, - timeFormat: format, - onSelected: onSelected, - ); - }).toList(); - - return SizedBox( - width: 120, - child: ListView.separated( - shrinkWrap: true, - controller: ScrollController(), - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - itemCount: cells.length, - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - ), - ); - } -} - -class TimeFormatCell extends StatelessWidget { - final TimeFormat timeFormat; - final bool isSelected; - final Function(TimeFormat format) onSelected; - const TimeFormatCell({ - required this.timeFormat, - required this.onSelected, - required this.isSelected, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - Widget? checkmark; - if (isSelected) { - checkmark = svgWidget("grid/checkmark"); - } - - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyButton( - text: FlowyText.medium(timeFormat.title(), fontSize: 12), - hoverColor: theme.hover, - rightIcon: checkmark, - onTap: () => onSelected(timeFormat), - ), - ); - } -} - -extension TimeFormatExtension on TimeFormat { - String title() { - switch (this) { - case TimeFormat.TwelveHour: - return LocaleKeys.grid_field_timeFormatTwelveHour.tr(); - case TimeFormat.TwentyFourHour: - return LocaleKeys.grid_field_timeFormatTwentyFourHour.tr(); - default: - throw UnimplementedError; - } - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/multi_select.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/multi_select.dart deleted file mode 100644 index a3982e6331aae..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/multi_select.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/multi_select_type_option.dart'; -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:flutter/material.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; - -import '../field_type_option_editor.dart'; -import 'builder.dart'; -import 'select_option.dart'; - -class MultiSelectTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { - final MultiSelectTypeOptionWidget _widget; - - MultiSelectTypeOptionWidgetBuilder( - MultiSelectTypeOptionContext typeOptionContext, - PopoverMutex popoverMutex, - ) : _widget = MultiSelectTypeOptionWidget( - selectOptionAction: MultiSelectAction( - fieldId: typeOptionContext.fieldId, - gridId: typeOptionContext.gridId, - typeOptionContext: typeOptionContext, - ), - popoverMutex: popoverMutex, - ); - - @override - Widget? build(BuildContext context) => _widget; -} - -class MultiSelectTypeOptionWidget extends TypeOptionWidget { - final MultiSelectAction selectOptionAction; - final PopoverMutex? popoverMutex; - - const MultiSelectTypeOptionWidget({ - Key? key, - required this.selectOptionAction, - this.popoverMutex, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return SelectOptionTypeOptionWidget( - options: selectOptionAction.typeOption.options, - beginEdit: () { - PopoverContainer.of(context).closeAll(); - }, - popoverMutex: popoverMutex, - typeOptionAction: selectOptionAction, - // key: ValueKey(state.typeOption.hashCode), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/number.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/number.dart deleted file mode 100644 index 800813fa54fa8..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/number.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/number_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/field/type_option/number_format_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/format.pbenum.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:easy_localization/easy_localization.dart' hide NumberFormat; -import 'package:app_flowy/generated/locale_keys.g.dart'; - -import '../../../layout/sizes.dart'; -import '../../common/text_field.dart'; -import '../field_type_option_editor.dart'; -import 'builder.dart'; - -class NumberTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { - final NumberTypeOptionWidget _widget; - - NumberTypeOptionWidgetBuilder( - NumberTypeOptionContext typeOptionContext, - PopoverMutex popoverMutex, - ) : _widget = NumberTypeOptionWidget( - typeOptionContext: typeOptionContext, - popoverMutex: popoverMutex, - ); - - @override - Widget? build(BuildContext context) { - return Column(children: [ - const TypeOptionSeparator(), - _widget, - ]); - } -} - -class NumberTypeOptionWidget extends TypeOptionWidget { - final NumberTypeOptionContext typeOptionContext; - final PopoverMutex popoverMutex; - const NumberTypeOptionWidget({ - required this.typeOptionContext, - required this.popoverMutex, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocProvider( - create: (context) => - NumberTypeOptionBloc(typeOptionContext: typeOptionContext), - child: SizedBox( - height: GridSize.typeOptionItemHeight, - child: BlocConsumer( - listener: (context, state) => - typeOptionContext.typeOption = state.typeOption, - builder: (context, state) { - return AppFlowyPopover( - mutex: popoverMutex, - triggerActions: - PopoverTriggerFlags.hover | PopoverTriggerFlags.click, - offset: const Offset(20, 0), - constraints: BoxConstraints.loose(const Size(460, 440)), - child: FlowyButton( - margin: GridSize.typeOptionContentInsets, - hoverColor: theme.hover, - rightIcon: svgWidget("grid/more", color: theme.iconColor), - text: Row( - children: [ - FlowyText.medium(LocaleKeys.grid_field_numberFormat.tr(), - fontSize: 12), - // const HSpace(6), - const Spacer(), - FlowyText.regular(state.typeOption.format.title(), - fontSize: 12), - ], - ), - ), - popupBuilder: (BuildContext popoverContext) { - return NumberFormatList( - onSelected: (format) { - context - .read() - .add(NumberTypeOptionEvent.didSelectFormat(format)); - PopoverContainer.of(popoverContext).close(); - }, - selectedFormat: state.typeOption.format, - ); - }, - ); - }, - ), - ), - ); - } -} - -typedef SelectNumberFormatCallback = Function(NumberFormat format); - -class NumberFormatList extends StatelessWidget { - final SelectNumberFormatCallback onSelected; - final NumberFormat selectedFormat; - const NumberFormatList( - {required this.selectedFormat, required this.onSelected, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => NumberFormatBloc(), - child: SizedBox( - width: 180, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _FilterTextField(), - const VSpace(10), - BlocBuilder( - builder: (context, state) { - final cells = state.formats.map((format) { - return NumberFormatCell( - isSelected: format == selectedFormat, - format: format, - onSelected: (format) { - onSelected(format); - }); - }).toList(); - - final list = ListView.separated( - shrinkWrap: true, - controller: ScrollController(), - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - itemCount: cells.length, - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - ); - return Expanded(child: list); - }, - ), - ], - ), - ), - ); - } -} - -class NumberFormatCell extends StatelessWidget { - final NumberFormat format; - final bool isSelected; - final Function(NumberFormat format) onSelected; - const NumberFormatCell({ - required this.isSelected, - required this.format, - required this.onSelected, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - Widget? checkmark; - if (isSelected) { - checkmark = svgWidget("grid/checkmark"); - } - - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyButton( - text: FlowyText.medium(format.title(), fontSize: 12), - hoverColor: theme.hover, - onTap: () => onSelected(format), - rightIcon: checkmark, - ), - ); - } -} - -class _FilterTextField extends StatelessWidget { - const _FilterTextField({Key? key}) : super(key: key); - @override - Widget build(BuildContext context) { - return InputTextField( - text: "", - onCanceled: () {}, - onChanged: (text) { - context.read().add(NumberFormatEvent.setFilter(text)); - }, - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/rich_text.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/rich_text.dart deleted file mode 100644 index 1ca0386c31df2..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/rich_text.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:flutter/material.dart'; -import 'builder.dart'; - -class RichTextTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { - RichTextTypeOptionWidgetBuilder(RichTextTypeOptionContext typeOptionContext); - - @override - Widget? build(BuildContext context) => null; -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/select_option.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/select_option.dart deleted file mode 100644 index ee35d208f9529..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/select_option.dart +++ /dev/null @@ -1,280 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/select_option_type_option_bloc.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; - -import '../../../layout/sizes.dart'; -import '../../cell/select_option_cell/extension.dart'; -import '../../common/text_field.dart'; -import 'select_option_editor.dart'; - -class SelectOptionTypeOptionWidget extends StatelessWidget { - final List options; - final VoidCallback beginEdit; - final ISelectOptionAction typeOptionAction; - final PopoverMutex? popoverMutex; - - const SelectOptionTypeOptionWidget({ - required this.options, - required this.beginEdit, - required this.typeOptionAction, - this.popoverMutex, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => SelectOptionTypeOptionBloc( - options: options, - typeOptionAction: typeOptionAction, - ), - child: - BlocBuilder( - builder: (context, state) { - List children = [ - const TypeOptionSeparator(), - const OptionTitle(), - if (state.isEditingOption) - const Padding( - padding: EdgeInsets.only(bottom: 10), - child: _CreateOptionTextField(), - ), - if (state.options.isEmpty && !state.isEditingOption) - const _AddOptionButton(), - _OptionList(popoverMutex: popoverMutex) - ]; - - return Column(children: children); - }, - ), - ); - } -} - -class OptionTitle extends StatelessWidget { - const OptionTitle({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.read(); - return BlocBuilder( - builder: (context, state) { - List children = [ - FlowyText.medium( - LocaleKeys.grid_field_optionTitle.tr(), - fontSize: 12, - color: theme.shader3, - ) - ]; - if (state.options.isNotEmpty && !state.isEditingOption) { - children.add(const Spacer()); - children.add(const _OptionTitleButton()); - } - - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: Row(children: children), - ); - }, - ); - } -} - -class _OptionTitleButton extends StatelessWidget { - const _OptionTitleButton({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return SizedBox( - width: 100, - height: 26, - child: FlowyButton( - text: FlowyText.medium( - LocaleKeys.grid_field_addOption.tr(), - fontSize: 12, - textAlign: TextAlign.center, - ), - hoverColor: theme.hover, - onTap: () { - context - .read() - .add(const SelectOptionTypeOptionEvent.addingOption()); - }, - ), - ); - } -} - -class _OptionList extends StatelessWidget { - final PopoverMutex? popoverMutex; - const _OptionList({Key? key, this.popoverMutex}) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - buildWhen: (previous, current) { - return previous.options != current.options; - }, - builder: (context, state) { - final cells = state.options.map((option) { - return _makeOptionCell( - context: context, - option: option, - popoverMutex: popoverMutex, - ); - }).toList(); - - return ListView.separated( - shrinkWrap: true, - controller: ScrollController(), - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - itemCount: cells.length, - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - ); - }, - ); - } - - _OptionCell _makeOptionCell({ - required BuildContext context, - required SelectOptionPB option, - PopoverMutex? popoverMutex, - }) { - return _OptionCell( - option: option, - popoverMutex: popoverMutex, - ); - } -} - -class _OptionCell extends StatefulWidget { - final SelectOptionPB option; - final PopoverMutex? popoverMutex; - const _OptionCell({required this.option, Key? key, this.popoverMutex}) - : super(key: key); - - @override - State<_OptionCell> createState() => _OptionCellState(); -} - -class _OptionCellState extends State<_OptionCell> { - late PopoverController _popoverController; - - @override - void initState() { - _popoverController = PopoverController(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - return AppFlowyPopover( - controller: _popoverController, - mutex: widget.popoverMutex, - offset: const Offset(20, 0), - asBarrier: true, - constraints: BoxConstraints.loose(const Size(460, 440)), - child: SizedBox( - height: GridSize.typeOptionItemHeight, - child: SelectOptionTagCell( - option: widget.option, - onSelected: (SelectOptionPB pb) { - _popoverController.show(); - }, - children: [ - svgWidget( - "grid/details", - color: theme.iconColor, - ), - ], - ), - ), - popupBuilder: (BuildContext popoverContext) { - return SelectOptionTypeOptionEditor( - option: widget.option, - onDeleted: () { - context - .read() - .add(SelectOptionTypeOptionEvent.deleteOption(widget.option)); - PopoverContainer.of(popoverContext).close(); - }, - onUpdated: (updatedOption) { - context - .read() - .add(SelectOptionTypeOptionEvent.updateOption(updatedOption)); - PopoverContainer.of(popoverContext).close(); - }, - key: ValueKey(widget.option.id), - ); - }, - ); - } -} - -class _AddOptionButton extends StatelessWidget { - const _AddOptionButton({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyButton( - text: FlowyText.medium(LocaleKeys.grid_field_addSelectOption.tr(), - fontSize: 12), - hoverColor: theme.hover, - onTap: () { - context - .read() - .add(const SelectOptionTypeOptionEvent.addingOption()); - }, - leftIcon: svgWidget("home/add", color: theme.iconColor), - ), - ); - } -} - -class _CreateOptionTextField extends StatelessWidget { - const _CreateOptionTextField({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final text = state.newOptionName.foldRight("", (a, previous) => a); - return InputTextField( - autoClearWhenDone: true, - maxLength: 30, - text: text, - onCanceled: () { - context - .read() - .add(const SelectOptionTypeOptionEvent.endAddingOption()); - }, - onDone: (optionName) { - context - .read() - .add(SelectOptionTypeOptionEvent.createOption(optionName)); - }, - ); - }, - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/select_option_editor.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/select_option_editor.dart deleted file mode 100644 index 9f259dcdc69a7..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/select_option_editor.dart +++ /dev/null @@ -1,212 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/edit_select_option_bloc.dart'; -import 'package:app_flowy/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; - -import '../../../layout/sizes.dart'; -import '../../common/text_field.dart'; - -class SelectOptionTypeOptionEditor extends StatelessWidget { - final SelectOptionPB option; - final VoidCallback onDeleted; - final Function(SelectOptionPB) onUpdated; - const SelectOptionTypeOptionEditor({ - required this.option, - required this.onDeleted, - required this.onUpdated, - Key? key, - }) : super(key: key); - - static String get identifier => (SelectOptionTypeOptionEditor).toString(); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => EditSelectOptionBloc(option: option), - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (p, c) => p.deleted != c.deleted, - listener: (context, state) { - state.deleted.fold(() => null, (_) => onDeleted()); - }, - ), - BlocListener( - listenWhen: (p, c) => p.option != c.option, - listener: (context, state) { - onUpdated(state.option); - }, - ), - ], - child: BlocBuilder( - builder: (context, state) { - List slivers = [ - SliverToBoxAdapter( - child: _OptionNameTextField(state.option.name)), - const SliverToBoxAdapter(child: VSpace(10)), - const SliverToBoxAdapter(child: _DeleteTag()), - const SliverToBoxAdapter(child: TypeOptionSeparator()), - SliverToBoxAdapter( - child: - SelectOptionColorList(selectedColor: state.option.color)), - ]; - - return SizedBox( - width: 160, - child: Padding( - padding: const EdgeInsets.all(6.0), - child: CustomScrollView( - slivers: slivers, - controller: ScrollController(), - physics: StyledScrollPhysics(), - ), - ), - ); - }, - ), - ), - ); - } -} - -class _DeleteTag extends StatelessWidget { - const _DeleteTag({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyButton( - text: FlowyText.medium(LocaleKeys.grid_selectOption_deleteTag.tr(), - fontSize: 12), - hoverColor: theme.hover, - leftIcon: svgWidget("grid/delete", color: theme.iconColor), - onTap: () { - context - .read() - .add(const EditSelectOptionEvent.delete()); - }, - ), - ); - } -} - -class _OptionNameTextField extends StatelessWidget { - final String name; - const _OptionNameTextField(this.name, {Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return InputTextField( - text: name, - maxLength: 30, - onCanceled: () {}, - onDone: (optionName) { - if (name != optionName) { - context - .read() - .add(EditSelectOptionEvent.updateName(optionName)); - } - }, - ); - } -} - -class SelectOptionColorList extends StatelessWidget { - final SelectOptionColorPB selectedColor; - const SelectOptionColorList({required this.selectedColor, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - final cells = SelectOptionColorPB.values.map((color) { - return _SelectOptionColorCell( - color: color, isSelected: selectedColor == color); - }).toList(); - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: GridSize.typeOptionContentInsets, - child: SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyText.medium( - LocaleKeys.grid_selectOption_colorPanelTitle.tr(), - fontSize: FontSizes.s12, - textAlign: TextAlign.left, - color: theme.shader3, - ), - ), - ), - ListView.separated( - shrinkWrap: true, - controller: ScrollController(), - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - itemCount: cells.length, - physics: StyledScrollPhysics(), - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - ), - ], - ); - } -} - -class _SelectOptionColorCell extends StatelessWidget { - final SelectOptionColorPB color; - final bool isSelected; - const _SelectOptionColorCell( - {required this.color, required this.isSelected, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - Widget? checkmark; - if (isSelected) { - checkmark = svgWidget("grid/checkmark"); - } - - final colorIcon = SizedBox.square( - dimension: 16, - child: Container( - decoration: BoxDecoration( - color: color.make(context), - shape: BoxShape.circle, - ), - ), - ); - - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyButton( - text: FlowyText.medium(color.optionName(), fontSize: 12), - hoverColor: theme.hover, - leftIcon: colorIcon, - rightIcon: checkmark, - onTap: () { - context - .read() - .add(EditSelectOptionEvent.updateColor(color)); - }, - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/single_select.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/single_select.dart deleted file mode 100644 index 37431128eacf8..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/single_select.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/single_select_type_option.dart'; -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:flutter/material.dart'; -import '../field_type_option_editor.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'builder.dart'; -import 'select_option.dart'; - -class SingleSelectTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { - final SingleSelectTypeOptionWidget _widget; - - SingleSelectTypeOptionWidgetBuilder( - SingleSelectTypeOptionContext singleSelectTypeOption, - PopoverMutex popoverMutex, - ) : _widget = SingleSelectTypeOptionWidget( - selectOptionAction: SingleSelectAction( - fieldId: singleSelectTypeOption.fieldId, - gridId: singleSelectTypeOption.gridId, - typeOptionContext: singleSelectTypeOption, - ), - popoverMutex: popoverMutex, - ); - - @override - Widget? build(BuildContext context) => _widget; -} - -class SingleSelectTypeOptionWidget extends TypeOptionWidget { - final SingleSelectAction selectOptionAction; - final PopoverMutex? popoverMutex; - - const SingleSelectTypeOptionWidget({ - Key? key, - required this.selectOptionAction, - this.popoverMutex, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return SelectOptionTypeOptionWidget( - options: selectOptionAction.typeOption.options, - beginEdit: () { - PopoverContainer.of(context).closeAll(); - }, - popoverMutex: popoverMutex, - typeOptionAction: selectOptionAction, - // key: ValueKey(state.typeOption.hashCode), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/url.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/url.dart deleted file mode 100644 index 9997837d63c18..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/url.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:flutter/material.dart'; -import 'builder.dart'; - -class URLTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { - URLTypeOptionWidgetBuilder(URLTypeOptionContext typeOptionContext); - - @override - Widget? build(BuildContext context) => null; -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/grid_row.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/grid_row.dart deleted file mode 100755 index 79643f48fbe1a..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/grid_row.dart +++ /dev/null @@ -1,310 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_data_controller.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:provider/provider.dart'; - -import '../../layout/sizes.dart'; -import '../cell/cell_accessory.dart'; -import '../cell/cell_container.dart'; -import '../cell/prelude.dart'; -import 'row_action_sheet.dart'; -import "package:app_flowy/generated/locale_keys.g.dart"; -import 'package:easy_localization/easy_localization.dart'; - -class GridRowWidget extends StatefulWidget { - final RowInfo rowInfo; - final GridRowDataController dataController; - final GridCellBuilder cellBuilder; - final void Function(BuildContext, GridCellBuilder) openDetailPage; - - const GridRowWidget({ - required this.rowInfo, - required this.dataController, - required this.cellBuilder, - required this.openDetailPage, - Key? key, - }) : super(key: key); - - @override - State createState() => _GridRowWidgetState(); -} - -class _GridRowWidgetState extends State { - late RowBloc _rowBloc; - - @override - void initState() { - _rowBloc = RowBloc( - rowInfo: widget.rowInfo, - dataController: widget.dataController, - ); - _rowBloc.add(const RowEvent.initial()); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _rowBloc, - child: _RowEnterRegion( - child: BlocBuilder( - buildWhen: (p, c) => p.rowInfo.rowPB.height != c.rowInfo.rowPB.height, - builder: (context, state) { - final content = Expanded( - child: RowContent( - builder: widget.cellBuilder, - onExpand: () => widget.openDetailPage( - context, - widget.cellBuilder, - ), - ), - ); - - return Row(children: [ - const _RowLeading(), - content, - const _RowTrailing(), - ]); - }, - ), - ), - ); - } - - @override - Future dispose() async { - _rowBloc.close(); - super.dispose(); - } -} - -class _RowLeading extends StatefulWidget { - const _RowLeading({Key? key}) : super(key: key); - - @override - State<_RowLeading> createState() => _RowLeadingState(); -} - -class _RowLeadingState extends State<_RowLeading> { - late PopoverController popoverController; - - @override - void initState() { - popoverController = PopoverController(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return AppFlowyPopover( - controller: popoverController, - triggerActions: PopoverTriggerFlags.none, - constraints: BoxConstraints.loose(const Size(140, 200)), - direction: PopoverDirection.rightWithCenterAligned, - margin: const EdgeInsets.all(6), - popupBuilder: (BuildContext popoverContext) { - return GridRowActionSheet( - rowData: context.read().state.rowInfo); - }, - child: Consumer( - builder: (context, state, _) { - return SizedBox( - width: GridSize.leadingHeaderPadding, - child: state.onEnter ? _activeWidget() : null, - ); - }, - ), - ); - } - - Widget _activeWidget() { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const _InsertButton(), - _MenuButton(openMenu: () { - popoverController.show(); - }), - ], - ); - } -} - -class _RowTrailing extends StatelessWidget { - const _RowTrailing({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return const SizedBox(); - } -} - -class _InsertButton extends StatelessWidget { - const _InsertButton({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return FlowyIconButton( - tooltipText: LocaleKeys.tooltip_addNewRow.tr(), - hoverColor: theme.hover, - width: 20, - height: 30, - onPressed: () => context.read().add(const RowEvent.createRow()), - iconPadding: const EdgeInsets.all(3), - icon: svgWidget("home/add"), - ); - } -} - -class _MenuButton extends StatefulWidget { - final VoidCallback openMenu; - const _MenuButton({ - required this.openMenu, - Key? key, - }) : super(key: key); - - @override - State<_MenuButton> createState() => _MenuButtonState(); -} - -class _MenuButtonState extends State<_MenuButton> { - @override - void initState() { - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return FlowyIconButton( - tooltipText: LocaleKeys.tooltip_openMenu.tr(), - hoverColor: theme.hover, - width: 20, - height: 30, - onPressed: () => widget.openMenu(), - iconPadding: const EdgeInsets.all(3), - icon: svgWidget("editor/details"), - ); - } -} - -class RowContent extends StatelessWidget { - final VoidCallback onExpand; - final GridCellBuilder builder; - const RowContent({ - required this.builder, - required this.onExpand, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - buildWhen: (previous, current) => - !listEquals(previous.cells, current.cells), - builder: (context, state) { - return IntrinsicHeight( - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: _makeCells(context, state.gridCellMap), - )); - }, - ); - } - - List _makeCells(BuildContext context, GridCellMap gridCellMap) { - return gridCellMap.values.map( - (cellId) { - final GridCellWidget child = builder.build(cellId); - - return CellContainer( - width: cellId.fieldContext.width.toDouble(), - rowStateNotifier: - Provider.of(context, listen: false), - accessoryBuilder: (buildContext) { - final builder = child.accessoryBuilder; - List accessories = []; - if (cellId.fieldContext.isPrimary) { - accessories.add( - GridCellAccessoryBuilder( - builder: (key) => PrimaryCellAccessory( - key: key, - onTapCallback: onExpand, - isCellEditing: buildContext.isCellEditing, - ), - ), - ); - } - - if (builder != null) { - accessories.addAll(builder(buildContext)); - } - return accessories; - }, - child: child, - ); - }, - ).toList(); - } -} - -class RegionStateNotifier extends ChangeNotifier { - bool _onEnter = false; - - set onEnter(bool value) { - if (_onEnter != value) { - _onEnter = value; - notifyListeners(); - } - } - - bool get onEnter => _onEnter; -} - -class _RowEnterRegion extends StatefulWidget { - final Widget child; - const _RowEnterRegion({required this.child, Key? key}) : super(key: key); - - @override - State<_RowEnterRegion> createState() => _RowEnterRegionState(); -} - -class _RowEnterRegionState extends State<_RowEnterRegion> { - late RegionStateNotifier _rowStateNotifier; - - @override - void initState() { - _rowStateNotifier = RegionStateNotifier(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: _rowStateNotifier, - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (p) => _rowStateNotifier.onEnter = true, - onExit: (p) => _rowStateNotifier.onEnter = false, - child: widget.child, - ), - ); - } - - @override - Future dispose() async { - _rowStateNotifier.dispose(); - super.dispose(); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_action_sheet.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_action_sheet.dart deleted file mode 100644 index b944bc24adaa6..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_action_sheet.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/row/row_action_sheet_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../../application/row/row_cache.dart'; -import '../../layout/sizes.dart'; - -class GridRowActionSheet extends StatelessWidget { - final RowInfo rowData; - const GridRowActionSheet({required this.rowData, Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => RowActionSheetBloc(rowInfo: rowData), - child: BlocBuilder( - builder: (context, state) { - final cells = _RowAction.values - .where((value) => value.enable()) - .map( - (action) => _RowActionCell(action: action), - ) - .toList(); - - // - final list = ListView.separated( - shrinkWrap: true, - controller: ScrollController(), - itemCount: cells.length, - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - physics: StyledScrollPhysics(), - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - ); - return list; - }, - ), - ); - } -} - -class _RowActionCell extends StatelessWidget { - final _RowAction action; - const _RowActionCell({required this.action, Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyButton( - text: FlowyText.medium( - action.title(), - fontSize: 12, - color: action.enable() ? theme.textColor : theme.shader3, - ), - hoverColor: theme.hover, - onTap: () { - if (action.enable()) { - action.performAction(context); - } - }, - leftIcon: svgWidget(action.iconName(), color: theme.iconColor), - ), - ); - } -} - -enum _RowAction { - delete, - duplicate, -} - -extension _RowActionExtension on _RowAction { - String iconName() { - switch (this) { - case _RowAction.duplicate: - return 'grid/duplicate'; - case _RowAction.delete: - return 'grid/delete'; - } - } - - String title() { - switch (this) { - case _RowAction.duplicate: - return LocaleKeys.grid_row_duplicate.tr(); - case _RowAction.delete: - return LocaleKeys.grid_row_delete.tr(); - } - } - - bool enable() { - switch (this) { - case _RowAction.duplicate: - return false; - case _RowAction.delete: - return true; - } - } - - void performAction(BuildContext context) { - switch (this) { - case _RowAction.duplicate: - context - .read() - .add(const RowActionSheetEvent.duplicateRow()); - break; - case _RowAction.delete: - context - .read() - .add(const RowActionSheetEvent.deleteRow()); - - break; - } - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_detail.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_detail.dart deleted file mode 100644 index 3a6876076d2ff..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/row_detail.dart +++ /dev/null @@ -1,359 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_data_controller.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_detail_bloc.dart'; -import 'package:app_flowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; - -import '../../layout/sizes.dart'; -import '../cell/cell_accessory.dart'; -import '../cell/prelude.dart'; -import '../header/field_cell.dart'; -import '../header/field_editor.dart'; - -class RowDetailPage extends StatefulWidget with FlowyOverlayDelegate { - final GridRowDataController dataController; - final GridCellBuilder cellBuilder; - - const RowDetailPage({ - required this.dataController, - required this.cellBuilder, - Key? key, - }) : super(key: key); - - @override - State createState() => _RowDetailPageState(); - - static String identifier() { - return (RowDetailPage).toString(); - } -} - -class _RowDetailPageState extends State { - @override - Widget build(BuildContext context) { - return FlowyDialog( - child: BlocProvider( - create: (context) { - final bloc = RowDetailBloc( - dataController: widget.dataController, - ); - bloc.add(const RowDetailEvent.initial()); - return bloc; - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 20), - child: Column( - children: [ - SizedBox( - height: 30, - child: Row( - children: const [Spacer(), _CloseButton()], - ), - ), - Expanded( - child: _PropertyList( - cellBuilder: widget.cellBuilder, - viewId: widget.dataController.rowInfo.gridId, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _CloseButton extends StatelessWidget { - const _CloseButton({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return FlowyIconButton( - width: 24, - onPressed: () { - FlowyOverlay.pop(context); - }, - iconPadding: const EdgeInsets.fromLTRB(2, 2, 2, 2), - icon: svgWidget("home/close", color: theme.iconColor), - ); - } -} - -class _PropertyList extends StatelessWidget { - final String viewId; - final GridCellBuilder cellBuilder; - final ScrollController _scrollController; - _PropertyList({ - required this.viewId, - required this.cellBuilder, - Key? key, - }) : _scrollController = ScrollController(), - super(key: key); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - buildWhen: (previous, current) => previous.gridCells != current.gridCells, - builder: (context, state) { - return Column( - children: [ - Expanded(child: _wrapScrollbar(buildList(state))), - const VSpace(10), - _CreateFieldButton( - viewId: viewId, - onClosed: _handleDidCreateField, - ), - ], - ); - }, - ); - } - - Widget buildList(RowDetailState state) { - return ListView.separated( - controller: _scrollController, - itemCount: state.gridCells.length, - itemBuilder: (BuildContext context, int index) { - return _RowDetailCell( - cellId: state.gridCells[index], - cellBuilder: cellBuilder, - ); - }, - separatorBuilder: (BuildContext context, int index) { - return const VSpace(2); - }, - ); - } - - Widget _wrapScrollbar(Widget child) { - return ScrollbarListStack( - axis: Axis.vertical, - controller: _scrollController, - barSize: GridSize.scrollBarSize, - autoHideScrollbar: false, - child: child, - ); - } - - void _handleDidCreateField() { - WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 250), - curve: Curves.ease, - ); - }); - } -} - -class _CreateFieldButton extends StatefulWidget { - final String viewId; - final VoidCallback onClosed; - - const _CreateFieldButton({ - required this.viewId, - required this.onClosed, - Key? key, - }) : super(key: key); - - @override - State<_CreateFieldButton> createState() => _CreateFieldButtonState(); -} - -class _CreateFieldButtonState extends State<_CreateFieldButton> { - late PopoverController popoverController; - - @override - void initState() { - popoverController = PopoverController(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.read(); - - return AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(240, 200)), - controller: popoverController, - direction: PopoverDirection.topWithLeftAligned, - onClose: widget.onClosed, - child: Container( - height: 40, - decoration: _makeBoxDecoration(context), - child: FlowyButton( - text: FlowyText.medium( - LocaleKeys.grid_field_newColumn.tr(), - fontSize: 12, - ), - hoverColor: theme.shader6, - onTap: () {}, - leftIcon: svgWidget("home/add"), - ), - ), - popupBuilder: (BuildContext popOverContext) { - return FieldEditor( - gridId: widget.viewId, - typeOptionLoader: NewFieldTypeOptionLoader(gridId: widget.viewId), - onDeleted: (fieldId) { - popoverController.close(); - - NavigatorAlertDialog( - title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), - confirm: () { - context - .read() - .add(RowDetailEvent.deleteField(fieldId)); - }, - ).show(context); - }, - ); - }, - ); - } - - BoxDecoration _makeBoxDecoration(BuildContext context) { - final theme = context.read(); - final borderSide = BorderSide(color: theme.shader6, width: 1.0); - return BoxDecoration( - color: theme.surface, - border: Border(top: borderSide), - ); - } -} - -class _RowDetailCell extends StatefulWidget { - final GridCellIdentifier cellId; - final GridCellBuilder cellBuilder; - const _RowDetailCell({ - required this.cellId, - required this.cellBuilder, - Key? key, - }) : super(key: key); - - @override - State createState() => _RowDetailCellState(); -} - -class _RowDetailCellState extends State<_RowDetailCell> { - final PopoverController popover = PopoverController(); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - final style = _customCellStyle(theme, widget.cellId.fieldType); - final cell = widget.cellBuilder.build(widget.cellId, style: style); - - final gesture = GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => cell.beginFocus.notify(), - child: AccessoryHover( - contentPadding: const EdgeInsets.symmetric(horizontal: 3, vertical: 3), - child: cell, - ), - ); - - return IntrinsicHeight( - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 30), - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppFlowyPopover( - controller: popover, - constraints: BoxConstraints.loose(const Size(240, 600)), - triggerActions: PopoverTriggerFlags.none, - popupBuilder: (popoverContext) => buildFieldEditor(), - child: SizedBox( - width: 150, - child: FieldCellButton( - maxLines: null, - field: widget.cellId.fieldContext.field, - onTap: () => popover.show(), - ), - ), - ), - const HSpace(10), - Expanded(child: gesture), - ], - ), - ), - ); - } - - Widget buildFieldEditor() { - return FieldEditor( - gridId: widget.cellId.gridId, - fieldName: widget.cellId.fieldContext.field.name, - isGroupField: widget.cellId.fieldContext.isGroupField, - typeOptionLoader: FieldTypeOptionLoader( - gridId: widget.cellId.gridId, - field: widget.cellId.fieldContext.field, - ), - onDeleted: (fieldId) { - popover.close(); - - NavigatorAlertDialog( - title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), - confirm: () { - context - .read() - .add(RowDetailEvent.deleteField(fieldId)); - }, - ).show(context); - }, - ); - } -} - -GridCellStyle? _customCellStyle(AppTheme theme, FieldType fieldType) { - switch (fieldType) { - case FieldType.Checkbox: - return null; - case FieldType.DateTime: - return DateCellStyle( - alignment: Alignment.centerLeft, - ); - case FieldType.MultiSelect: - return SelectOptionCellStyle( - placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), - ); - case FieldType.Number: - return null; - case FieldType.RichText: - return GridTextCellStyle( - placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), - ); - case FieldType.SingleSelect: - return SelectOptionCellStyle( - placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), - ); - - case FieldType.URL: - return GridURLCellStyle( - placeholder: LocaleKeys.grid_row_textPlaceholder.tr(), - accessoryTypes: [ - GridURLCellAccessoryType.edit, - GridURLCellAccessoryType.copyURL, - ], - ); - } - throw UnimplementedError; -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/shortcuts.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/shortcuts.dart deleted file mode 100644 index 1e38c5647d4a3..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/shortcuts.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -class GridShortcuts extends StatelessWidget { - final Widget child; - const GridShortcuts({required this.child, Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Shortcuts( - shortcuts: bindKeys([]), - child: Actions( - dispatcher: LoggingActionDispatcher(), - actions: const {}, - child: child, - ), - ); - } -} - -Map bindKeys(List keys) { - return {for (var key in keys) LogicalKeySet(key): KeyboardKeyIdent(key)}; -} - -Map> bindActions() { - return { - KeyboardKeyIdent: KeyboardBindingAction(), - }; -} - -class KeyboardKeyIdent extends Intent { - final KeyboardKey key; - - const KeyboardKeyIdent(this.key); -} - -class KeyboardBindingAction extends Action { - KeyboardBindingAction(); - - @override - void invoke(covariant KeyboardKeyIdent intent) { - // print(intent); - } -} - -class LoggingActionDispatcher extends ActionDispatcher { - @override - Object? invokeAction( - covariant Action action, - covariant Intent intent, [ - BuildContext? context, - ]) { - // print('Action invoked: $action($intent) from $context'); - super.invokeAction(action, intent, context); - - return null; - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_group.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_group.dart deleted file mode 100644 index dc3461b2e7767..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_group.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:app_flowy/plugins/grid/presentation/layout/sizes.dart'; -import 'package:app_flowy/plugins/grid/presentation/widgets/header/field_type_extension.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:app_flowy/plugins/grid/application/setting/group_bloc.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; - -class GridGroupList extends StatelessWidget { - final String viewId; - final GridFieldController fieldController; - final VoidCallback onDismissed; - const GridGroupList({ - required this.viewId, - required this.fieldController, - required this.onDismissed, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => GridGroupBloc( - viewId: viewId, - fieldController: fieldController, - )..add(const GridGroupEvent.initial()), - child: BlocBuilder( - builder: (context, state) { - final cells = state.fieldContexts.map((fieldContext) { - Widget cell = _GridGroupCell( - fieldContext: fieldContext, - onSelected: () => onDismissed(), - key: ValueKey(fieldContext.id), - ); - - if (!fieldContext.canGroup) { - cell = IgnorePointer(child: Opacity(opacity: 0.3, child: cell)); - } - return cell; - }).toList(); - - return ListView.separated( - shrinkWrap: true, - itemCount: cells.length, - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - separatorBuilder: (BuildContext context, int index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - ); - }, - ), - ); - } -} - -class _GridGroupCell extends StatelessWidget { - final VoidCallback onSelected; - final GridFieldContext fieldContext; - const _GridGroupCell({ - required this.fieldContext, - required this.onSelected, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.read(); - - Widget? rightIcon; - if (fieldContext.isGroupField) { - rightIcon = Padding( - padding: const EdgeInsets.all(2.0), - child: svgWidget("grid/checkmark"), - ); - } - - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyButton( - text: FlowyText.medium(fieldContext.name, fontSize: 12), - hoverColor: theme.hover, - leftIcon: svgWidget( - fieldContext.fieldType.iconName(), - color: theme.iconColor, - ), - rightIcon: rightIcon, - onTap: () { - context.read().add( - GridGroupEvent.setGroupByField( - fieldContext.id, - fieldContext.fieldType, - ), - ); - onSelected(); - }, - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_property.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_property.dart deleted file mode 100644 index 8cc55c8265e23..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_property.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:app_flowy/plugins/grid/presentation/widgets/header/field_editor.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/plugins/grid/application/setting/property_bloc.dart'; -import 'package:app_flowy/plugins/grid/presentation/widgets/header/field_type_extension.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:styled_widget/styled_widget.dart'; - -import '../../../application/field/field_controller.dart'; -import '../../layout/sizes.dart'; - -class GridPropertyList extends StatefulWidget { - final String gridId; - final GridFieldController fieldController; - const GridPropertyList({ - required this.gridId, - required this.fieldController, - Key? key, - }) : super(key: key); - - @override - State createState() => _GridPropertyListState(); -} - -class _GridPropertyListState extends State { - late PopoverMutex _popoverMutex; - - @override - void initState() { - _popoverMutex = PopoverMutex(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => getIt( - param1: widget.gridId, param2: widget.fieldController) - ..add(const GridPropertyEvent.initial()), - child: BlocBuilder( - builder: (context, state) { - final cells = state.fieldContexts.map((field) { - return _GridPropertyCell( - popoverMutex: _popoverMutex, - gridId: widget.gridId, - fieldContext: field, - key: ValueKey(field.id), - ); - }).toList(); - - return ListView.separated( - controller: ScrollController(), - shrinkWrap: true, - itemCount: cells.length, - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - separatorBuilder: (BuildContext context, int index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - ); - }, - ), - ); - } -} - -class _GridPropertyCell extends StatelessWidget { - final GridFieldContext fieldContext; - final String gridId; - final PopoverMutex popoverMutex; - const _GridPropertyCell({ - required this.gridId, - required this.fieldContext, - required this.popoverMutex, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - final checkmark = fieldContext.visibility - ? svgWidget('home/show', color: theme.iconColor) - : svgWidget('home/hide', color: theme.iconColor); - - return Row( - children: [ - Expanded( - child: SizedBox( - height: GridSize.typeOptionItemHeight, - child: _editFieldButton(theme, context), - ), - ), - FlowyIconButton( - hoverColor: theme.hover, - width: GridSize.typeOptionItemHeight, - onPressed: () { - context.read().add( - GridPropertyEvent.setFieldVisibility( - fieldContext.id, !fieldContext.visibility)); - }, - icon: checkmark.padding(all: 6), - ) - ], - ); - } - - Widget _editFieldButton(AppTheme theme, BuildContext context) { - return AppFlowyPopover( - mutex: popoverMutex, - offset: const Offset(20, 0), - constraints: BoxConstraints.loose(const Size(240, 400)), - child: FlowyButton( - text: FlowyText.medium(fieldContext.name, fontSize: 12), - hoverColor: theme.hover, - leftIcon: svgWidget(fieldContext.fieldType.iconName(), - color: theme.iconColor), - ), - popupBuilder: (BuildContext context) { - return FieldEditor( - gridId: gridId, - fieldName: fieldContext.name, - typeOptionLoader: FieldTypeOptionLoader( - gridId: gridId, - field: fieldContext.field, - ), - ); - }, - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_setting.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_setting.dart deleted file mode 100644 index 961d20354fde2..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_setting.dart +++ /dev/null @@ -1,148 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/setting/setting_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'package:app_flowy/generated/locale_keys.g.dart'; -import '../../../application/field/field_controller.dart'; -import '../../layout/sizes.dart'; - -class GridSettingContext { - final String gridId; - final GridFieldController fieldController; - - GridSettingContext({ - required this.gridId, - required this.fieldController, - }); -} - -class GridSettingList extends StatelessWidget { - final GridSettingContext settingContext; - final Function(GridSettingAction, GridSettingContext) onAction; - const GridSettingList( - {required this.settingContext, required this.onAction, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => GridSettingBloc(gridId: settingContext.gridId), - child: BlocListener( - listenWhen: (previous, current) => - previous.selectedAction != current.selectedAction, - listener: (context, state) { - state.selectedAction.foldLeft(null, (_, action) { - onAction(action, settingContext); - }); - }, - child: BlocBuilder( - builder: (context, state) { - return _renderList(); - }, - ), - ), - ); - } - - String identifier() { - return toString(); - } - - Widget _renderList() { - final cells = - GridSettingAction.values.where((value) => value.enable()).map((action) { - return _SettingItem(action: action); - }).toList(); - - return SizedBox( - width: 140, - child: ListView.separated( - shrinkWrap: true, - controller: ScrollController(), - itemCount: cells.length, - separatorBuilder: (context, index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, - physics: StyledScrollPhysics(), - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - ), - ); - } -} - -class _SettingItem extends StatelessWidget { - final GridSettingAction action; - - const _SettingItem({ - required this.action, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - final isSelected = context - .read() - .state - .selectedAction - .foldLeft(false, (_, selectedAction) => selectedAction == action); - - return SizedBox( - height: GridSize.typeOptionItemHeight, - child: FlowyButton( - isSelected: isSelected, - text: FlowyText.medium(action.title(), - fontSize: 12, color: action.enable() ? null : theme.shader4), - hoverColor: theme.hover, - onTap: () { - context - .read() - .add(GridSettingEvent.performAction(action)); - }, - leftIcon: svgWidget(action.iconName(), color: theme.iconColor), - ), - ); - } -} - -extension _GridSettingExtension on GridSettingAction { - String iconName() { - switch (this) { - case GridSettingAction.filter: - return 'grid/setting/filter'; - case GridSettingAction.sortBy: - return 'grid/setting/sort'; - case GridSettingAction.properties: - return 'grid/setting/properties'; - } - } - - String title() { - switch (this) { - case GridSettingAction.filter: - return LocaleKeys.grid_settings_filter.tr(); - case GridSettingAction.sortBy: - return LocaleKeys.grid_settings_sortBy.tr(); - case GridSettingAction.properties: - return LocaleKeys.grid_settings_Properties.tr(); - } - } - - bool enable() { - switch (this) { - case GridSettingAction.properties: - return true; - default: - return false; - } - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_toolbar.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_toolbar.dart deleted file mode 100644 index 7b5ca129cecd5..0000000000000 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_toolbar.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/setting/setting_bloc.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/extension.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../../application/field/field_controller.dart'; -import '../../layout/sizes.dart'; -import 'grid_property.dart'; -import 'grid_setting.dart'; - -class GridToolbarContext { - final String gridId; - final GridFieldController fieldController; - GridToolbarContext({ - required this.gridId, - required this.fieldController, - }); -} - -class GridToolbar extends StatelessWidget { - final GridToolbarContext toolbarContext; - const GridToolbar({required this.toolbarContext, Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final settingContext = GridSettingContext( - gridId: toolbarContext.gridId, - fieldController: toolbarContext.fieldController, - ); - return SizedBox( - height: 40, - child: Row( - children: [ - SizedBox(width: GridSize.leadingHeaderPadding), - _SettingButton(settingContext: settingContext), - const Spacer(), - ], - ), - ); - } -} - -class _SettingButton extends StatelessWidget { - final GridSettingContext settingContext; - const _SettingButton({required this.settingContext, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return AppFlowyPopover( - constraints: BoxConstraints.loose(const Size(260, 400)), - offset: const Offset(0, 10), - margin: const EdgeInsets.all(6), - child: FlowyIconButton( - width: 22, - hoverColor: theme.hover, - icon: svgWidget( - "grid/setting/setting", - color: theme.iconColor, - ).padding(horizontal: 3, vertical: 3), - ), - popupBuilder: (BuildContext context) { - return _GridSettingListPopover(settingContext: settingContext); - }, - ); - } -} - -class _GridSettingListPopover extends StatefulWidget { - final GridSettingContext settingContext; - - const _GridSettingListPopover({Key? key, required this.settingContext}) - : super(key: key); - - @override - State createState() => _GridSettingListPopoverState(); -} - -class _GridSettingListPopoverState extends State<_GridSettingListPopover> { - GridSettingAction? _action; - - @override - Widget build(BuildContext context) { - if (_action == GridSettingAction.properties) { - return GridPropertyList( - gridId: widget.settingContext.gridId, - fieldController: widget.settingContext.fieldController, - ); - } - - return GridSettingList( - settingContext: widget.settingContext, - onAction: (action, settingContext) { - setState(() { - _action = action; - }); - }, - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/trash/application/trash_bloc.dart b/frontend/app_flowy/lib/plugins/trash/application/trash_bloc.dart deleted file mode 100644 index 7e96fdc601a42..0000000000000 --- a/frontend/app_flowy/lib/plugins/trash/application/trash_bloc.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/trash.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:app_flowy/plugins/trash/application/trash_service.dart'; -import 'package:app_flowy/plugins/trash/application/trash_listener.dart'; - -part 'trash_bloc.freezed.dart'; - -class TrashBloc extends Bloc { - final TrashService _service; - final TrashListener _listener; - TrashBloc() - : _service = TrashService(), - _listener = TrashListener(), - super(TrashState.init()) { - on((event, emit) async { - await event.map(initial: (e) async { - _listener.start(trashUpdated: _listenTrashUpdated); - final result = await _service.readTrash(); - emit(result.fold( - (object) => state.copyWith( - objects: object.items, successOrFailure: left(unit)), - (error) => state.copyWith(successOrFailure: right(error)), - )); - }, didReceiveTrash: (e) async { - emit(state.copyWith(objects: e.trash)); - }, putback: (e) async { - final result = await _service.putback(e.trashId); - await _handleResult(result, emit); - }, delete: (e) async { - final result = - await _service.deleteViews([Tuple2(e.trash.id, e.trash.ty)]); - await _handleResult(result, emit); - }, deleteAll: (e) async { - final result = await _service.deleteAll(); - await _handleResult(result, emit); - }, restoreAll: (e) async { - final result = await _service.restoreAll(); - await _handleResult(result, emit); - }); - }); - } - - Future _handleResult( - Either result, Emitter emit) async { - emit(result.fold( - (l) => state.copyWith(successOrFailure: left(unit)), - (error) => state.copyWith(successOrFailure: right(error)), - )); - } - - void _listenTrashUpdated(Either, FlowyError> trashOrFailed) { - trashOrFailed.fold( - (trash) { - add(TrashEvent.didReceiveTrash(trash)); - }, - (error) { - Log.error(error); - }, - ); - } - - @override - Future close() async { - await _listener.close(); - return super.close(); - } -} - -@freezed -class TrashEvent with _$TrashEvent { - const factory TrashEvent.initial() = Initial; - const factory TrashEvent.didReceiveTrash(List trash) = ReceiveTrash; - const factory TrashEvent.putback(String trashId) = Putback; - const factory TrashEvent.delete(TrashPB trash) = Delete; - const factory TrashEvent.restoreAll() = RestoreAll; - const factory TrashEvent.deleteAll() = DeleteAll; -} - -@freezed -class TrashState with _$TrashState { - const factory TrashState({ - required List objects, - required Either successOrFailure, - }) = _TrashState; - - factory TrashState.init() => TrashState( - objects: [], - successOrFailure: left(unit), - ); -} diff --git a/frontend/app_flowy/lib/plugins/trash/application/trash_listener.dart b/frontend/app_flowy/lib/plugins/trash/application/trash_listener.dart deleted file mode 100644 index 0b4e142058ef8..0000000000000 --- a/frontend/app_flowy/lib/plugins/trash/application/trash_listener.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; -import 'package:app_flowy/core/folder_notification.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/dart-notify/subject.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/dart_notification.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/trash.pb.dart'; -import 'package:flowy_sdk/rust_stream.dart'; - -typedef TrashUpdatedCallback = void Function(Either, FlowyError> trashOrFailed); - -class TrashListener { - StreamSubscription? _subscription; - TrashUpdatedCallback? _trashUpdated; - FolderNotificationParser? _parser; - - void start({TrashUpdatedCallback? trashUpdated}) { - _trashUpdated = trashUpdated; - _parser = FolderNotificationParser(callback: _bservableCallback); - _subscription = RustStreamReceiver.listen((observable) => _parser?.parse(observable)); - } - - void _bservableCallback(FolderNotification ty, Either result) { - switch (ty) { - case FolderNotification.TrashUpdated: - if (_trashUpdated != null) { - result.fold( - (payload) { - final repeatedTrash = RepeatedTrashPB.fromBuffer(payload); - _trashUpdated!(left(repeatedTrash.items)); - }, - (error) => _trashUpdated!(right(error)), - ); - } - break; - default: - break; - } - } - - Future close() async { - _parser = null; - await _subscription?.cancel(); - _trashUpdated = null; - } -} diff --git a/frontend/app_flowy/lib/plugins/trash/application/trash_service.dart b/frontend/app_flowy/lib/plugins/trash/application/trash_service.dart deleted file mode 100644 index e782120a7487d..0000000000000 --- a/frontend/app_flowy/lib/plugins/trash/application/trash_service.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'dart:async'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/trash.pb.dart'; - -class TrashService { - Future> readTrash() { - return FolderEventReadTrash().send(); - } - - Future> putback(String trashId) { - final id = TrashIdPB.create()..id = trashId; - - return FolderEventPutbackTrash(id).send(); - } - - Future> deleteViews(List> trashList) { - final items = trashList.map((trash) { - return TrashIdPB.create() - ..id = trash.value1 - ..ty = trash.value2; - }); - - final ids = RepeatedTrashIdPB(items: items); - return FolderEventDeleteTrash(ids).send(); - } - - Future> restoreAll() { - return FolderEventRestoreAllTrash().send(); - } - - Future> deleteAll() { - return FolderEventDeleteAllTrash().send(); - } -} diff --git a/frontend/app_flowy/lib/plugins/trash/menu.dart b/frontend/app_flowy/lib/plugins/trash/menu.dart deleted file mode 100644 index 73fff65ba3ce4..0000000000000 --- a/frontend/app_flowy/lib/plugins/trash/menu.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/workspace/application/appearance.dart'; -import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; -import 'package:app_flowy/workspace/presentation/home/menu/menu.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:flowy_infra/theme.dart'; - -class MenuTrash extends StatelessWidget { - const MenuTrash({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 26, - child: InkWell( - onTap: () { - getIt().latestOpenView = null; - getIt() - .setPlugin(makePlugin(pluginType: PluginType.trash)); - }, - child: _render(context), - ), - ); - } - - Widget _render(BuildContext context) { - return Row(children: [ - ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), - child: Selector( - selector: (ctx, notifier) => notifier.theme, - builder: (ctx, theme, child) => SizedBox( - width: 16, - height: 16, - child: svgWidget("home/trash", color: theme.iconColor)), - ), - ), - const HSpace(6), - ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), - child: Selector( - selector: (ctx, notifier) => notifier.locale, - builder: (ctx, _, child) => - FlowyText.medium(LocaleKeys.trash_text.tr(), fontSize: 12), - ), - ), - ]); - } -} diff --git a/frontend/app_flowy/lib/plugins/trash/src/sizes.dart b/frontend/app_flowy/lib/plugins/trash/src/sizes.dart deleted file mode 100644 index d3c7d90bdc1d0..0000000000000 --- a/frontend/app_flowy/lib/plugins/trash/src/sizes.dart +++ /dev/null @@ -1,10 +0,0 @@ -class TrashSizes { - static double scale = 0.8; - static double get headerHeight => 60 * scale; - static double get fileNameWidth => 320 * scale; - static double get lashModifyWidth => 230 * scale; - static double get createTimeWidth => 230 * scale; - static double get padding => 100 * scale; - static double get totalWidth => - TrashSizes.fileNameWidth + TrashSizes.lashModifyWidth + TrashSizes.createTimeWidth + TrashSizes.padding; -} diff --git a/frontend/app_flowy/lib/plugins/trash/src/trash_cell.dart b/frontend/app_flowy/lib/plugins/trash/src/trash_cell.dart deleted file mode 100644 index 97f56ba89ab63..0000000000000 --- a/frontend/app_flowy/lib/plugins/trash/src/trash_cell.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/trash.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:fixnum/fixnum.dart' as $fixnum; -import 'package:provider/provider.dart'; - -import 'sizes.dart'; - -class TrashCell extends StatelessWidget { - final VoidCallback onRestore; - final VoidCallback onDelete; - final TrashPB object; - const TrashCell( - {required this.object, - required this.onRestore, - required this.onDelete, - Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return Row( - children: [ - SizedBox( - width: TrashSizes.fileNameWidth, - child: FlowyText(object.name, fontSize: 12)), - SizedBox( - width: TrashSizes.lashModifyWidth, - child: FlowyText(dateFormatter(object.modifiedTime), fontSize: 12)), - SizedBox( - width: TrashSizes.createTimeWidth, - child: FlowyText(dateFormatter(object.createTime), fontSize: 12)), - const Spacer(), - FlowyIconButton( - width: 26, - onPressed: onRestore, - hoverColor: theme.hover, - iconPadding: const EdgeInsets.all(5), - icon: svgWidget("editor/restore", color: theme.iconColor), - ), - const HSpace(20), - FlowyIconButton( - width: 26, - onPressed: onDelete, - hoverColor: theme.hover, - iconPadding: const EdgeInsets.all(5), - icon: svgWidget("editor/delete", color: theme.iconColor), - ), - ], - ); - } - - String dateFormatter($fixnum.Int64 inputTimestamps) { - var outputFormat = DateFormat('MM/dd/yyyy hh:mm a'); - var date = - DateTime.fromMillisecondsSinceEpoch(inputTimestamps.toInt() * 1000); - var outputDate = outputFormat.format(date); - return outputDate; - } -} diff --git a/frontend/app_flowy/lib/plugins/trash/src/trash_header.dart b/frontend/app_flowy/lib/plugins/trash/src/trash_header.dart deleted file mode 100644 index dcabd05dda611..0000000000000 --- a/frontend/app_flowy/lib/plugins/trash/src/trash_header.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; - -import 'sizes.dart'; - -class TrashHeaderDelegate extends SliverPersistentHeaderDelegate { - TrashHeaderDelegate(); - - @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { - return TrashHeader(); - } - - @override - double get maxExtent => TrashSizes.headerHeight; - - @override - double get minExtent => TrashSizes.headerHeight; - - @override - bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { - return false; - } -} - -class TrashHeaderItem { - double width; - String title; - - TrashHeaderItem({required this.width, required this.title}); -} - -class TrashHeader extends StatelessWidget { - final List items = [ - TrashHeaderItem(title: LocaleKeys.trash_pageHeader_fileName.tr(), width: TrashSizes.fileNameWidth), - TrashHeaderItem(title: LocaleKeys.trash_pageHeader_lastModified.tr(), width: TrashSizes.lashModifyWidth), - TrashHeaderItem(title: LocaleKeys.trash_pageHeader_created.tr(), width: TrashSizes.createTimeWidth), - ]; - - TrashHeader({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - final headerItems = List.empty(growable: true); - items.asMap().forEach((index, item) { - headerItems.add( - SizedBox( - width: item.width, - child: FlowyText( - item.title, - fontSize: 12, - color: theme.shader3, - ), - ), - ); - }); - - return Container( - color: theme.surface, - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ...headerItems, - ], - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/trash/trash.dart b/frontend/app_flowy/lib/plugins/trash/trash.dart deleted file mode 100644 index 1661578267b87..0000000000000 --- a/frontend/app_flowy/lib/plugins/trash/trash.dart +++ /dev/null @@ -1,62 +0,0 @@ -export "./src/sizes.dart"; -export "./src/trash_cell.dart"; -export "./src/trash_header.dart"; - -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; - -import 'trash_page.dart'; - -class TrashPluginBuilder extends PluginBuilder { - @override - Plugin build(dynamic data) { - return TrashPlugin(pluginType: pluginType); - } - - @override - String get menuName => "TrashPB"; - - @override - PluginType get pluginType => PluginType.trash; -} - -class TrashPluginConfig implements PluginConfig { - @override - bool get creatable => false; -} - -class TrashPlugin extends Plugin { - final PluginType _pluginType; - - TrashPlugin({required PluginType pluginType}) : _pluginType = pluginType; - - @override - PluginDisplay get display => TrashPluginDisplay(); - - @override - PluginId get id => "TrashStack"; - - @override - PluginType get ty => _pluginType; -} - -class TrashPluginDisplay extends PluginDisplay { - @override - Widget get leftBarItem => - FlowyText.medium(LocaleKeys.trash_text.tr(), fontSize: 12); - - @override - Widget? get rightBarItem => null; - - @override - Widget buildWidget(PluginContext context) => const TrashPage( - key: ValueKey('TrashPage'), - ); - - @override - List get navigationItems => [this]; -} diff --git a/frontend/app_flowy/lib/plugins/trash/trash_page.dart b/frontend/app_flowy/lib/plugins/trash/trash_page.dart deleted file mode 100644 index 2c0ed36633b2e..0000000000000 --- a/frontend/app_flowy/lib/plugins/trash/trash_page.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/plugins/trash/src/sizes.dart'; -import 'package:app_flowy/plugins/trash/src/trash_header.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:styled_widget/styled_widget.dart'; - -import 'application/trash_bloc.dart'; -import 'src/trash_cell.dart'; - -class TrashPage extends StatefulWidget { - const TrashPage({Key? key}) : super(key: key); - - @override - State createState() => _TrashPageState(); -} - -class _TrashPageState extends State { - final ScrollController _scrollController = ScrollController(); - @override - Widget build(BuildContext context) { - final theme = context.watch(); - const horizontalPadding = 80.0; - return BlocProvider( - create: (context) => getIt()..add(const TrashEvent.initial()), - child: BlocBuilder( - builder: (context, state) { - return SizedBox.expand( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - _renderTopBar(context, theme, state), - const VSpace(32), - _renderTrashList(context, state), - ], - ).padding(horizontal: horizontalPadding, vertical: 48), - ); - }, - ), - ); - } - - Widget _renderTrashList(BuildContext context, TrashState state) { - const barSize = 6.0; - return Expanded( - child: ScrollbarListStack( - axis: Axis.vertical, - controller: _scrollController, - scrollbarPadding: EdgeInsets.only(top: TrashSizes.headerHeight), - barSize: barSize, - child: StyledSingleChildScrollView( - controller: ScrollController(), - barSize: barSize, - axis: Axis.horizontal, - child: SizedBox( - width: TrashSizes.totalWidth, - child: ScrollConfiguration( - behavior: const ScrollBehavior().copyWith(scrollbars: false), - child: CustomScrollView( - shrinkWrap: true, - physics: StyledScrollPhysics(), - controller: _scrollController, - slivers: [ - _renderListHeader(context, state), - _renderListBody(context, state), - ], - ), - ), - ), - ), - ), - ); - } - - Widget _renderTopBar(BuildContext context, AppTheme theme, TrashState state) { - return SizedBox( - height: 36, - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - FlowyText.semibold(LocaleKeys.trash_text.tr()), - const Spacer(), - IntrinsicWidth( - child: FlowyButton( - text: FlowyText.medium(LocaleKeys.trash_restoreAll.tr(), - fontSize: 12), - leftIcon: svgWidget('editor/restore', color: theme.iconColor), - hoverColor: theme.hover, - onTap: () => context.read().add( - const TrashEvent.restoreAll(), - ), - ), - ), - const HSpace(6), - IntrinsicWidth( - child: FlowyButton( - text: FlowyText.medium(LocaleKeys.trash_deleteAll.tr(), - fontSize: 12), - leftIcon: svgWidget('editor/delete', color: theme.iconColor), - hoverColor: theme.hover, - onTap: () => - context.read().add(const TrashEvent.deleteAll()), - ), - ) - ], - ), - ); - } - - Widget _renderListHeader(BuildContext context, TrashState state) { - return SliverPersistentHeader( - delegate: TrashHeaderDelegate(), - floating: true, - pinned: true, - ); - } - - Widget _renderListBody(BuildContext context, TrashState state) { - return SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - final object = state.objects[index]; - return SizedBox( - height: 42, - child: TrashCell( - object: object, - onRestore: () { - context.read().add(TrashEvent.putback(object.id)); - }, - onDelete: () => - context.read().add(TrashEvent.delete(object)), - ), - ); - }, - childCount: state.objects.length, - addAutomaticKeepAlives: false, - ), - ); - } -} diff --git a/frontend/app_flowy/lib/plugins/util.dart b/frontend/app_flowy/lib/plugins/util.dart deleted file mode 100644 index db57d7f526ae0..0000000000000 --- a/frontend/app_flowy/lib/plugins/util.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:app_flowy/workspace/application/view/view_listener.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter/material.dart'; - -class ViewPluginNotifier extends PluginNotifier> { - final ViewListener? _viewListener; - ViewPB view; - - @override - final ValueNotifier> isDeleted = ValueNotifier(none()); - - @override - final ValueNotifier isDisplayChanged = ValueNotifier(0); - - ViewPluginNotifier({ - required this.view, - }) : _viewListener = ViewListener(view: view) { - _viewListener?.start(onViewUpdated: (result) { - result.fold( - (updatedView) { - view = updatedView; - isDisplayChanged.value = updatedView.hashCode; - }, - (err) => Log.error(err), - ); - }, onViewMoveToTrash: (result) { - result.fold( - (deletedView) => isDeleted.value = some(deletedView), - (err) => Log.error(err), - ); - }); - } - - @override - void dispose() { - isDeleted.dispose(); - isDisplayChanged.dispose(); - _viewListener?.stop(); - } -} diff --git a/frontend/app_flowy/lib/startup/deps_resolver.dart b/frontend/app_flowy/lib/startup/deps_resolver.dart deleted file mode 100644 index 1deaf508fdc98..0000000000000 --- a/frontend/app_flowy/lib/startup/deps_resolver.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'package:app_flowy/core/network_monitor.dart'; -import 'package:app_flowy/user/application/user_listener.dart'; -import 'package:app_flowy/user/application/user_service.dart'; -import 'package:app_flowy/workspace/application/app/prelude.dart'; -import 'package:app_flowy/plugins/doc/application/prelude.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import 'package:app_flowy/workspace/application/user/prelude.dart'; -import 'package:app_flowy/workspace/application/workspace/prelude.dart'; -import 'package:app_flowy/workspace/application/edit_panel/edit_panel_bloc.dart'; -import 'package:app_flowy/workspace/application/view/prelude.dart'; -import 'package:app_flowy/workspace/application/menu/prelude.dart'; -import 'package:app_flowy/workspace/application/settings/prelude.dart'; -import 'package:app_flowy/user/application/prelude.dart'; -import 'package:app_flowy/user/presentation/router.dart'; -import 'package:app_flowy/plugins/trash/application/prelude.dart'; -import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; -import 'package:app_flowy/workspace/presentation/home/menu/menu.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:get_it/get_it.dart'; - -import '../plugins/grid/application/field/field_controller.dart'; - -class DependencyResolver { - static Future resolve(GetIt getIt) async { - _resolveUserDeps(getIt); - - _resolveHomeDeps(getIt); - - _resolveFolderDeps(getIt); - - _resolveDocDeps(getIt); - - _resolveGridDeps(getIt); - } -} - -void _resolveUserDeps(GetIt getIt) { - getIt.registerFactory(() => AuthService()); - getIt.registerFactory(() => AuthRouter()); - - getIt.registerFactory(() => SignInBloc(getIt())); - getIt.registerFactory(() => SignUpBloc(getIt())); - - getIt.registerFactory(() => SplashRoute()); - getIt.registerFactory(() => EditPanelBloc()); - getIt.registerFactory(() => SplashBloc()); - getIt.registerLazySingleton(() => NetworkListener()); -} - -void _resolveHomeDeps(GetIt getIt) { - getIt.registerSingleton(FToast()); - - getIt.registerSingleton(MenuSharedState()); - - getIt.registerFactoryParam( - (user, _) => UserListener(userProfile: user), - ); - - // - getIt.registerLazySingleton(() => HomeStackManager()); - - getIt.registerFactoryParam( - (user, _) => WelcomeBloc( - userService: UserService(userId: user.id), - userWorkspaceListener: UserWorkspaceListener(userProfile: user), - ), - ); - - // share - getIt.registerLazySingleton(() => ShareService()); - getIt.registerFactoryParam( - (view, _) => DocShareBloc(view: view, service: getIt())); -} - -void _resolveFolderDeps(GetIt getIt) { - //workspace - getIt.registerFactoryParam( - (user, workspaceId) => - WorkspaceListener(user: user, workspaceId: workspaceId)); - - // ViewPB - getIt.registerFactoryParam( - (view, _) => ViewListener(view: view), - ); - - getIt.registerFactoryParam( - (view, _) => ViewBloc( - view: view, - ), - ); - - getIt.registerFactoryParam( - (user, _) => MenuUserBloc(user), - ); - - //Settings - getIt.registerFactoryParam( - (user, _) => SettingsDialogBloc(user), - ); - - //User - getIt.registerFactoryParam( - (user, _) => SettingsUserViewBloc(user), - ); - - // AppPB - getIt.registerFactoryParam( - (app, _) => AppBloc(app: app), - ); - - // trash - getIt.registerLazySingleton(() => TrashService()); - getIt.registerLazySingleton(() => TrashListener()); - getIt.registerFactory( - () => TrashBloc(), - ); -} - -void _resolveDocDeps(GetIt getIt) { -// Doc - getIt.registerFactoryParam( - (view, _) => DocumentBloc(view: view), - ); -} - -void _resolveGridDeps(GetIt getIt) { - // GridPB - getIt.registerFactoryParam( - (view, _) => GridBloc(view: view), - ); - - getIt.registerFactoryParam( - (gridId, fieldController) => GridHeaderBloc( - gridId: gridId, - fieldController: fieldController, - ), - ); - - getIt.registerFactoryParam( - (data, _) => FieldActionSheetBloc(fieldCellContext: data), - ); - - getIt.registerFactoryParam( - (context, _) => TextCellBloc( - cellController: context, - ), - ); - - getIt.registerFactoryParam( - (context, _) => SelectOptionCellBloc( - cellController: context, - ), - ); - - getIt.registerFactoryParam( - (context, _) => NumberCellBloc( - cellController: context, - ), - ); - - getIt.registerFactoryParam( - (context, _) => DateCellBloc( - cellController: context, - ), - ); - - getIt.registerFactoryParam( - (cellData, _) => CheckboxCellBloc( - service: CellService(), - cellController: cellData, - ), - ); - - getIt.registerFactoryParam( - (gridId, cache) => GridPropertyBloc(gridId: gridId, fieldController: cache), - ); -} diff --git a/frontend/app_flowy/lib/startup/plugin/plugin.dart b/frontend/app_flowy/lib/startup/plugin/plugin.dart deleted file mode 100644 index 6f33c85374767..0000000000000 --- a/frontend/app_flowy/lib/startup/plugin/plugin.dart +++ /dev/null @@ -1,98 +0,0 @@ -library flowy_plugin; - -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter/widgets.dart'; - -export "./src/sandbox.dart"; - -enum PluginType { - editor, - blank, - trash, - grid, - board, -} - -typedef PluginId = String; - -abstract class Plugin { - PluginId get id; - - PluginDisplay get display; - - PluginNotifier? get notifier => null; - - PluginType get ty; - - void dispose() { - notifier?.dispose(); - } -} - -abstract class PluginNotifier { - /// Notify if the plugin get deleted - ValueNotifier get isDeleted; - - /// Notify if the [PluginDisplay]'s content was changed - ValueNotifier get isDisplayChanged; - - void dispose() {} -} - -abstract class PluginBuilder { - Plugin build(dynamic data); - - String get menuName; - - PluginType get pluginType; - - ViewDataFormatPB get dataFormatType => ViewDataFormatPB.TreeFormat; - - ViewLayoutTypePB? get layoutType => ViewLayoutTypePB.Document; -} - -abstract class PluginConfig { - // Return false will disable the user to create it. For example, a trash plugin shouldn't be created by the user, - bool get creatable => true; -} - -abstract class PluginDisplay with NavigationItem { - List get navigationItems; - - Widget buildWidget(PluginContext context); -} - -class PluginContext { - // calls when widget of the plugin get deleted - final Function(ViewPB, int?) onDeleted; - - PluginContext({required this.onDeleted}); -} - -void registerPlugin({required PluginBuilder builder, PluginConfig? config}) { - getIt() - .registerPlugin(builder.pluginType, builder, config: config); -} - -Plugin makePlugin({required PluginType pluginType, dynamic data}) { - final plugin = getIt().buildPlugin(pluginType, data); - return plugin; -} - -List pluginBuilders() { - final pluginBuilders = getIt().builders; - final pluginConfigs = getIt().pluginConfigs; - return pluginBuilders.where( - (builder) { - final config = pluginConfigs[builder.pluginType]?.creatable; - return config ?? true; - }, - ).toList(); -} - -enum FlowyPluginException { - invalidData, -} diff --git a/frontend/app_flowy/lib/startup/plugin/src/sandbox.dart b/frontend/app_flowy/lib/startup/plugin/src/sandbox.dart deleted file mode 100644 index ca32ec4217900..0000000000000 --- a/frontend/app_flowy/lib/startup/plugin/src/sandbox.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:collection'; - -import 'package:flutter/services.dart'; - -import '../plugin.dart'; -import 'runner.dart'; - -class PluginSandbox { - final LinkedHashMap _pluginBuilders = - LinkedHashMap(); - final Map _pluginConfigs = - {}; - late PluginRunner pluginRunner; - - PluginSandbox() { - pluginRunner = PluginRunner(); - } - - int indexOf(PluginType pluginType) { - final index = - _pluginBuilders.keys.toList().indexWhere((ty) => ty == pluginType); - if (index == -1) { - throw PlatformException( - code: '-1', message: "Can't find the flowy plugin type: $pluginType"); - } - return index; - } - - Plugin buildPlugin(PluginType pluginType, dynamic data) { - final plugin = _pluginBuilders[pluginType]!.build(data); - return plugin; - } - - void registerPlugin(PluginType pluginType, PluginBuilder builder, - {PluginConfig? config}) { - if (_pluginBuilders.containsKey(pluginType)) { - throw PlatformException( - code: '-1', message: "$pluginType was registered before"); - } - _pluginBuilders[pluginType] = builder; - - if (config != null) { - _pluginConfigs[pluginType] = config; - } - } - - List get supportPluginTypes => _pluginBuilders.keys.toList(); - - List get builders => _pluginBuilders.values.toList(); - - Map get pluginConfigs => _pluginConfigs; -} diff --git a/frontend/app_flowy/lib/startup/startup.dart b/frontend/app_flowy/lib/startup/startup.dart deleted file mode 100644 index 77d0cc3d33920..0000000000000 --- a/frontend/app_flowy/lib/startup/startup.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'dart:io'; - -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:app_flowy/startup/tasks/prelude.dart'; -import 'package:app_flowy/startup/deps_resolver.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:flowy_sdk/flowy_sdk.dart'; - -// [[diagram: flowy startup flow]] -// ┌──────────┐ -// │ FlowyApp │ -// └──────────┘ -// │ impl -// ▼ -// ┌────────┐ 1.run ┌──────────┐ -// │ System │───┬───▶│EntryPoint│ -// └────────┘ │ └──────────┘ ┌─────────────────┐ -// │ ┌──▶ │ RustSDKInitTask │ -// │ ┌───────────┐ │ └─────────────────┘ -// └──▶ │AppLauncher│───┤ -// 2.launch └───────────┘ │ ┌─────────────┐ ┌──────────────────┐ ┌───────────────┐ -// └───▶│AppWidgetTask│────────▶│ApplicationWidget │─────▶│ SplashScreen │ -// └─────────────┘ └──────────────────┘ └───────────────┘ -// -// 3.build MeterialApp -final getIt = GetIt.instance; - -abstract class EntryPoint { - Widget create(); -} - -class FlowyRunner { - static Future run(EntryPoint f) async { - // Specify the env - final env = integrationEnv(); - initGetIt(getIt, env, f); - - // add task - getIt().addTask(InitRustSDKTask()); - getIt().addTask(PluginLoadTask()); - - if (!env.isTest()) { - getIt().addTask(InitAppWidgetTask()); - getIt().addTask(InitPlatformServiceTask()); - } - - // execute the tasks - getIt().launch(); - } -} - -Future initGetIt( - GetIt getIt, - IntegrationMode env, - EntryPoint f, -) async { - getIt.registerFactory(() => f); - getIt.registerLazySingleton(() => const FlowySDK()); - getIt.registerLazySingleton(() => AppLauncher(env, getIt)); - getIt.registerSingleton(PluginSandbox()); - - await DependencyResolver.resolve(getIt); -} - -class LaunchContext { - GetIt getIt; - IntegrationMode env; - LaunchContext(this.getIt, this.env); -} - -enum LaunchTaskType { - dataProcessing, - appLauncher, -} - -/// The interface of an app launch task, which will trigger -/// some nonresident indispensable task in app launching task. -abstract class LaunchTask { - LaunchTaskType get type => LaunchTaskType.dataProcessing; - Future initialize(LaunchContext context); -} - -class AppLauncher { - List tasks; - IntegrationMode env; - GetIt getIt; - - AppLauncher(this.env, this.getIt) : tasks = List.from([]); - - void addTask(LaunchTask task) { - tasks.add(task); - } - - Future launch() async { - final context = LaunchContext(getIt, env); - for (var task in tasks) { - await task.initialize(context); - } - } -} - -enum IntegrationMode { - develop, - release, - test, -} - -extension IntegrationEnvExt on IntegrationMode { - bool isTest() { - return this == IntegrationMode.test; - } -} - -IntegrationMode integrationEnv() { - if (Platform.environment.containsKey('FLUTTER_TEST')) { - return IntegrationMode.test; - } - - if (kReleaseMode) { - return IntegrationMode.release; - } - - return IntegrationMode.develop; -} diff --git a/frontend/app_flowy/lib/startup/tasks/app_widget.dart b/frontend/app_flowy/lib/startup/tasks/app_widget.dart deleted file mode 100644 index 513fd69f091fb..0000000000000 --- a/frontend/app_flowy/lib/startup/tasks/app_widget.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/user/application/user_settings_service.dart'; -import 'package:app_flowy/workspace/application/appearance.dart'; -import 'package:appflowy_editor/appflowy_editor.dart' hide Log; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:window_size/window_size.dart'; -import 'package:bloc/bloc.dart'; - -class InitAppWidgetTask extends LaunchTask { - @override - LaunchTaskType get type => LaunchTaskType.appLauncher; - - @override - Future initialize(LaunchContext context) async { - final widget = context.getIt().create(); - final setting = await SettingsFFIService().getAppearanceSetting(); - final appearanceSetting = AppearanceSetting(setting); - final app = ApplicationWidget( - appearanceSetting: appearanceSetting, - child: widget, - ); - Bloc.observer = ApplicationBlocObserver(); - runApp( - EasyLocalization( - supportedLocales: const [ - // In alphabetical order - Locale('ca', 'ES'), - Locale('de', 'DE'), - Locale('en'), - Locale('es', 'VE'), - Locale('fr', 'FR'), - Locale('fr', 'CA'), - Locale('hu', 'HU'), - Locale('id', 'ID'), - Locale('it', 'IT'), - Locale('ja', 'JP'), - Locale('ko', 'KR'), - Locale('pl', 'PL'), - Locale('pt', 'BR'), - Locale('ru', 'RU'), - Locale('sv'), - Locale('tr', 'TR'), - Locale('zh', 'CN'), - ], - path: 'assets/translations', - fallbackLocale: const Locale('en'), - useFallbackTranslations: true, - saveLocale: false, - child: app, - ), - ); - - return Future(() => {}); - } -} - -class ApplicationWidget extends StatelessWidget { - final Widget child; - final AppearanceSetting appearanceSetting; - - const ApplicationWidget({ - Key? key, - required this.child, - required this.appearanceSetting, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: appearanceSetting, - builder: (context, _) { - const ratio = 1.73; - const minWidth = 600.0; - setWindowMinSize(const Size(minWidth, minWidth / ratio)); - appearanceSetting.readLocaleWhenAppLaunch(context); - AppTheme theme = context.select( - (value) => value.theme, - ); - Locale locale = context.select( - (value) => value.locale, - ); - - return MultiProvider( - providers: [ - Provider.value(value: theme), - Provider.value(value: locale), - ], - builder: (context, _) { - return MaterialApp( - builder: overlayManagerBuilder(), - debugShowCheckedModeBanner: false, - theme: theme.themeData, - localizationsDelegates: context.localizationDelegates + - [AppFlowyEditorLocalizations.delegate], - supportedLocales: context.supportedLocales, - locale: locale, - navigatorKey: AppGlobals.rootNavKey, - home: child, - ); - }, - ); - }, - ); - } -} - -class AppGlobals { - static GlobalKey rootNavKey = GlobalKey(); - static NavigatorState get nav => rootNavKey.currentState!; -} - -class ApplicationBlocObserver extends BlocObserver { - @override - // ignore: unnecessary_overrides - void onTransition(Bloc bloc, Transition transition) { - // Log.debug("[current]: ${transition.currentState} \n\n[next]: ${transition.nextState}"); - // Log.debug("${transition.nextState}"); - super.onTransition(bloc, transition); - } - - @override - void onError(BlocBase bloc, Object error, StackTrace stackTrace) { - Log.debug(error); - super.onError(bloc, error, stackTrace); - } - - // @override - // void onEvent(Bloc bloc, Object? event) { - // Log.debug("$event"); - // super.onEvent(bloc, event); - // } -} diff --git a/frontend/app_flowy/lib/startup/tasks/load_plugin.dart b/frontend/app_flowy/lib/startup/tasks/load_plugin.dart deleted file mode 100644 index 5eabc18064177..0000000000000 --- a/frontend/app_flowy/lib/startup/tasks/load_plugin.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/plugins/blank/blank.dart'; -import 'package:app_flowy/plugins/board/board.dart'; -import 'package:app_flowy/plugins/doc/document.dart'; -import 'package:app_flowy/plugins/grid/grid.dart'; -import 'package:app_flowy/plugins/trash/trash.dart'; - -class PluginLoadTask extends LaunchTask { - @override - LaunchTaskType get type => LaunchTaskType.dataProcessing; - - @override - Future initialize(LaunchContext context) async { - registerPlugin(builder: BlankPluginBuilder(), config: BlankPluginConfig()); - registerPlugin(builder: TrashPluginBuilder(), config: TrashPluginConfig()); - registerPlugin(builder: DocumentPluginBuilder()); - registerPlugin(builder: GridPluginBuilder(), config: GridPluginConfig()); - registerPlugin(builder: BoardPluginBuilder(), config: BoardPluginConfig()); - } -} diff --git a/frontend/app_flowy/lib/startup/tasks/platform_service.dart b/frontend/app_flowy/lib/startup/tasks/platform_service.dart deleted file mode 100644 index 55a59c186aad4..0000000000000 --- a/frontend/app_flowy/lib/startup/tasks/platform_service.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:app_flowy/core/network_monitor.dart'; -import '../startup.dart'; - -class InitPlatformServiceTask extends LaunchTask { - @override - LaunchTaskType get type => LaunchTaskType.dataProcessing; - - @override - Future initialize(LaunchContext context) async { - getIt().start(); - } -} diff --git a/frontend/app_flowy/lib/startup/tasks/prelude.dart b/frontend/app_flowy/lib/startup/tasks/prelude.dart deleted file mode 100644 index c381c17d3170d..0000000000000 --- a/frontend/app_flowy/lib/startup/tasks/prelude.dart +++ /dev/null @@ -1,4 +0,0 @@ -export 'app_widget.dart'; -export 'rust_sdk.dart'; -export 'platform_service.dart'; -export 'load_plugin.dart'; diff --git a/frontend/app_flowy/lib/startup/tasks/rust_sdk.dart b/frontend/app_flowy/lib/startup/tasks/rust_sdk.dart deleted file mode 100644 index 7e12c19f44a9c..0000000000000 --- a/frontend/app_flowy/lib/startup/tasks/rust_sdk.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:io'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:flowy_sdk/flowy_sdk.dart'; - -class InitRustSDKTask extends LaunchTask { - @override - LaunchTaskType get type => LaunchTaskType.dataProcessing; - - @override - Future initialize(LaunchContext context) async { - await appFlowyDocumentDirectory().then((directory) async { - await context.getIt().init(directory); - }); - } -} - -Future appFlowyDocumentDirectory() async { - switch (integrationEnv()) { - case IntegrationMode.develop: - Directory documentsDir = await getApplicationDocumentsDirectory(); - return Directory('${documentsDir.path}/flowy_dev').create(); - case IntegrationMode.release: - Directory documentsDir = await getApplicationDocumentsDirectory(); - return Directory('${documentsDir.path}/flowy').create(); - case IntegrationMode.test: - return Directory("${Directory.current.path}/.sandbox"); - } -} diff --git a/frontend/app_flowy/lib/user/application/auth_service.dart b/frontend/app_flowy/lib/user/application/auth_service.dart deleted file mode 100644 index c2ce625ccfd27..0000000000000 --- a/frontend/app_flowy/lib/user/application/auth_service.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart' show SignInPayloadPB, SignUpPayloadPB, UserProfilePB; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; - -class AuthService { - Future> signIn({required String? email, required String? password}) { - // - final request = SignInPayloadPB.create() - ..email = email ?? '' - ..password = password ?? ''; - - return UserEventSignIn(request).send(); - } - - Future> signUp( - {required String? name, required String? password, required String? email}) { - final request = SignUpPayloadPB.create() - ..email = email ?? '' - ..name = name ?? '' - ..password = password ?? ''; - - return UserEventSignUp(request).send(); - - // return UserEventSignUp(request).send().then((result) { - // return result.fold((userProfile) async { - // return await FolderEventCreateDefaultWorkspace().send().then((result) { - // return result.fold((workspaceIdentifier) { - // return left(Tuple2(userProfile, workspaceIdentifier.workspaceId)); - // }, (error) { - // throw UnimplementedError; - // }); - // }); - // }, (error) => right(error)); - // }); - } - - Future> signOut() { - return UserEventSignOut().send(); - } -} diff --git a/frontend/app_flowy/lib/user/application/prelude.dart b/frontend/app_flowy/lib/user/application/prelude.dart deleted file mode 100644 index 74b644808e3b0..0000000000000 --- a/frontend/app_flowy/lib/user/application/prelude.dart +++ /dev/null @@ -1,4 +0,0 @@ -export './auth_service.dart'; -export './sign_in_bloc.dart'; -export './sign_up_bloc.dart'; -export './splash_bloc.dart'; diff --git a/frontend/app_flowy/lib/user/application/sign_in_bloc.dart b/frontend/app_flowy/lib/user/application/sign_in_bloc.dart deleted file mode 100644 index 1b5838d9c4088..0000000000000 --- a/frontend/app_flowy/lib/user/application/sign_in_bloc.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:app_flowy/user/application/auth_service.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-error-code/code.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -part 'sign_in_bloc.freezed.dart'; - -class SignInBloc extends Bloc { - final AuthService authService; - SignInBloc(this.authService) : super(SignInState.initial()) { - on((event, emit) async { - await event.map( - signedInWithUserEmailAndPassword: (e) async { - await _performActionOnSignIn( - state, - emit, - ); - }, - emailChanged: (EmailChanged value) async { - emit(state.copyWith(email: value.email, emailError: none(), successOrFail: none())); - }, - passwordChanged: (PasswordChanged value) async { - emit(state.copyWith(password: value.password, passwordError: none(), successOrFail: none())); - }, - ); - }); - } - - Future _performActionOnSignIn(SignInState state, Emitter emit) async { - emit(state.copyWith(isSubmitting: true, emailError: none(), passwordError: none(), successOrFail: none())); - - final result = await authService.signIn( - email: state.email, - password: state.password, - ); - emit(result.fold( - (userProfile) => state.copyWith(isSubmitting: false, successOrFail: some(left(userProfile))), - (error) => stateFromCode(error), - )); - } - - SignInState stateFromCode(FlowyError error) { - switch (ErrorCode.valueOf(error.code)!) { - case ErrorCode.EmailFormatInvalid: - return state.copyWith(isSubmitting: false, emailError: some(error.msg), passwordError: none()); - case ErrorCode.PasswordFormatInvalid: - return state.copyWith(isSubmitting: false, passwordError: some(error.msg), emailError: none()); - default: - return state.copyWith(isSubmitting: false, successOrFail: some(right(error))); - } - } -} - -@freezed -class SignInEvent with _$SignInEvent { - const factory SignInEvent.signedInWithUserEmailAndPassword() = SignedInWithUserEmailAndPassword; - const factory SignInEvent.emailChanged(String email) = EmailChanged; - const factory SignInEvent.passwordChanged(String password) = PasswordChanged; -} - -@freezed -class SignInState with _$SignInState { - const factory SignInState({ - String? email, - String? password, - required bool isSubmitting, - required Option passwordError, - required Option emailError, - required Option> successOrFail, - }) = _SignInState; - - factory SignInState.initial() => SignInState( - isSubmitting: false, - passwordError: none(), - emailError: none(), - successOrFail: none(), - ); -} diff --git a/frontend/app_flowy/lib/user/application/sign_up_bloc.dart b/frontend/app_flowy/lib/user/application/sign_up_bloc.dart deleted file mode 100644 index 0c103fd4d758e..0000000000000 --- a/frontend/app_flowy/lib/user/application/sign_up_bloc.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:app_flowy/user/application/auth_service.dart'; -import 'package:dartz/dartz.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_sdk/protobuf/flowy-error-code/code.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; - -part 'sign_up_bloc.freezed.dart'; - -class SignUpBloc extends Bloc { - final AuthService authService; - SignUpBloc(this.authService) : super(SignUpState.initial()) { - on((event, emit) async { - await event.map(signUpWithUserEmailAndPassword: (e) async { - await _performActionOnSignUp(emit); - }, emailChanged: (_EmailChanged value) async { - emit(state.copyWith(email: value.email, emailError: none(), successOrFail: none())); - }, passwordChanged: (_PasswordChanged value) async { - emit(state.copyWith(password: value.password, passwordError: none(), successOrFail: none())); - }, repeatPasswordChanged: (_RepeatPasswordChanged value) async { - emit(state.copyWith(repeatedPassword: value.password, repeatPasswordError: none(), successOrFail: none())); - }); - }); - } - - Future _performActionOnSignUp(Emitter emit) async { - emit(state.copyWith( - isSubmitting: true, - successOrFail: none(), - )); - - final password = state.password; - final repeatedPassword = state.repeatedPassword; - if (password == null) { - emit(state.copyWith( - isSubmitting: false, - passwordError: some(LocaleKeys.signUp_emptyPasswordError.tr()), - )); - return; - } - - if (repeatedPassword == null) { - emit(state.copyWith( - isSubmitting: false, - repeatPasswordError: some(LocaleKeys.signUp_repeatPasswordEmptyError.tr()), - )); - return; - } - - if (password != repeatedPassword) { - emit(state.copyWith( - isSubmitting: false, - repeatPasswordError: some(LocaleKeys.signUp_unmatchedPasswordError.tr()), - )); - return; - } - - emit(state.copyWith( - passwordError: none(), - repeatPasswordError: none(), - )); - - final result = await authService.signUp( - name: state.email, - password: state.password, - email: state.email, - ); - emit(result.fold( - (profile) => state.copyWith( - isSubmitting: false, - successOrFail: some(left(profile)), - emailError: none(), - passwordError: none(), - repeatPasswordError: none(), - ), - (error) => stateFromCode(error), - )); - } - - SignUpState stateFromCode(FlowyError error) { - switch (ErrorCode.valueOf(error.code)!) { - case ErrorCode.EmailFormatInvalid: - return state.copyWith( - isSubmitting: false, - emailError: some(error.msg), - passwordError: none(), - successOrFail: none(), - ); - case ErrorCode.PasswordFormatInvalid: - return state.copyWith( - isSubmitting: false, - passwordError: some(error.msg), - emailError: none(), - successOrFail: none(), - ); - default: - return state.copyWith(isSubmitting: false, successOrFail: some(right(error))); - } - } -} - -@freezed -class SignUpEvent with _$SignUpEvent { - const factory SignUpEvent.signUpWithUserEmailAndPassword() = SignUpWithUserEmailAndPassword; - const factory SignUpEvent.emailChanged(String email) = _EmailChanged; - const factory SignUpEvent.passwordChanged(String password) = _PasswordChanged; - const factory SignUpEvent.repeatPasswordChanged(String password) = _RepeatPasswordChanged; -} - -@freezed -class SignUpState with _$SignUpState { - const factory SignUpState({ - String? email, - String? password, - String? repeatedPassword, - required bool isSubmitting, - required Option passwordError, - required Option repeatPasswordError, - required Option emailError, - required Option> successOrFail, - }) = _SignUpState; - - factory SignUpState.initial() => SignUpState( - isSubmitting: false, - passwordError: none(), - repeatPasswordError: none(), - emailError: none(), - successOrFail: none(), - ); -} diff --git a/frontend/app_flowy/lib/user/application/splash_bloc.dart b/frontend/app_flowy/lib/user/application/splash_bloc.dart deleted file mode 100644 index de6d56e998a74..0000000000000 --- a/frontend/app_flowy/lib/user/application/splash_bloc.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:app_flowy/user/domain/auth_state.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'splash_bloc.freezed.dart'; - -class SplashBloc extends Bloc { - SplashBloc() : super(SplashState.initial()) { - on((event, emit) async { - await event.map( - getUser: (val) async { - final result = await UserEventCheckUser().send(); - final authState = result.fold( - (userProfile) { - return AuthState.authenticated(userProfile); - }, - (error) { - return AuthState.unauthenticated(error); - }, - ); - - emit(state.copyWith(auth: authState)); - }, - ); - }); - } -} - -@freezed -class SplashEvent with _$SplashEvent { - const factory SplashEvent.getUser() = _GetUser; -} - -@freezed -class SplashState with _$SplashState { - const factory SplashState({ - required AuthState auth, - }) = _SplashState; - - factory SplashState.initial() => const SplashState( - auth: AuthState.initial(), - ); -} diff --git a/frontend/app_flowy/lib/user/application/user_listener.dart b/frontend/app_flowy/lib/user/application/user_listener.dart deleted file mode 100644 index 1fe0566400b52..0000000000000 --- a/frontend/app_flowy/lib/user/application/user_listener.dart +++ /dev/null @@ -1,164 +0,0 @@ -import 'dart:async'; -import 'package:app_flowy/core/folder_notification.dart'; -import 'package:app_flowy/core/user_notification.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-error-code/code.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'dart:typed_data'; -import 'package:flowy_infra/notifier.dart'; -import 'package:flowy_sdk/protobuf/dart-notify/protobuf.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/dart_notification.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/dart_notification.pb.dart' - as user; -import 'package:flowy_sdk/rust_stream.dart'; - -typedef UserProfileNotifyValue = Either; -typedef AuthNotifyValue = Either; - -class UserListener { - StreamSubscription? _subscription; - PublishNotifier? _authNotifier = PublishNotifier(); - PublishNotifier? _profileNotifier = PublishNotifier(); - - UserNotificationParser? _userParser; - final UserProfilePB _userProfile; - UserListener({ - required UserProfilePB userProfile, - }) : _userProfile = userProfile; - - void start({ - void Function(AuthNotifyValue)? onAuthChanged, - void Function(UserProfileNotifyValue)? onProfileUpdated, - }) { - if (onProfileUpdated != null) { - _profileNotifier?.addPublishListener(onProfileUpdated); - } - - if (onAuthChanged != null) { - _authNotifier?.addPublishListener(onAuthChanged); - } - - _userParser = UserNotificationParser( - id: _userProfile.token, callback: _userNotificationCallback); - _subscription = RustStreamReceiver.listen((observable) { - _userParser?.parse(observable); - }); - } - - Future stop() async { - _userParser = null; - await _subscription?.cancel(); - _profileNotifier?.dispose(); - _profileNotifier = null; - - _authNotifier?.dispose(); - _authNotifier = null; - } - - void _userNotificationCallback( - user.UserNotification ty, Either result) { - switch (ty) { - case user.UserNotification.UserUnauthorized: - result.fold( - (_) {}, - (error) => _authNotifier?.value = right(error), - ); - break; - case user.UserNotification.UserProfileUpdated: - result.fold( - (payload) => - _profileNotifier?.value = left(UserProfilePB.fromBuffer(payload)), - (error) => _profileNotifier?.value = right(error), - ); - break; - default: - break; - } - } -} - -typedef WorkspaceListNotifyValue = Either, FlowyError>; -typedef WorkspaceSettingNotifyValue = Either; - -class UserWorkspaceListener { - PublishNotifier? _authNotifier = PublishNotifier(); - PublishNotifier? _workspacesChangedNotifier = - PublishNotifier(); - PublishNotifier? _settingChangedNotifier = - PublishNotifier(); - - FolderNotificationListener? _listener; - final UserProfilePB _userProfile; - - UserWorkspaceListener({ - required UserProfilePB userProfile, - }) : _userProfile = userProfile; - - void start({ - void Function(AuthNotifyValue)? onAuthChanged, - void Function(WorkspaceListNotifyValue)? onWorkspacesUpdated, - void Function(WorkspaceSettingNotifyValue)? onSettingUpdated, - }) { - if (onAuthChanged != null) { - _authNotifier?.addPublishListener(onAuthChanged); - } - - if (onWorkspacesUpdated != null) { - _workspacesChangedNotifier?.addPublishListener(onWorkspacesUpdated); - } - - if (onSettingUpdated != null) { - _settingChangedNotifier?.addPublishListener(onSettingUpdated); - } - - _listener = FolderNotificationListener( - objectId: _userProfile.token, - handler: _handleObservableType, - ); - } - - void _handleObservableType( - FolderNotification ty, Either result) { - switch (ty) { - case FolderNotification.UserCreateWorkspace: - case FolderNotification.UserDeleteWorkspace: - case FolderNotification.WorkspaceListUpdated: - result.fold( - (payload) => _workspacesChangedNotifier?.value = - left(RepeatedWorkspacePB.fromBuffer(payload).items), - (error) => _workspacesChangedNotifier?.value = right(error), - ); - break; - case FolderNotification.WorkspaceSetting: - result.fold( - (payload) => _settingChangedNotifier?.value = - left(WorkspaceSettingPB.fromBuffer(payload)), - (error) => _settingChangedNotifier?.value = right(error), - ); - break; - case FolderNotification.UserUnauthorized: - result.fold( - (_) {}, - (error) => _authNotifier?.value = right( - FlowyError.create()..code = ErrorCode.UserUnauthorized.value), - ); - break; - default: - break; - } - } - - Future stop() async { - await _listener?.stop(); - _workspacesChangedNotifier?.dispose(); - _workspacesChangedNotifier = null; - - _settingChangedNotifier?.dispose(); - _settingChangedNotifier = null; - - _authNotifier?.dispose(); - _authNotifier = null; - } -} diff --git a/frontend/app_flowy/lib/user/application/user_service.dart b/frontend/app_flowy/lib/user/application/user_service.dart deleted file mode 100644 index 35e32f2eb18ff..0000000000000 --- a/frontend/app_flowy/lib/user/application/user_service.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'dart:async'; - -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; - -class UserService { - final String userId; - UserService({ - required this.userId, - }); - Future> getUserProfile( - {required String userId}) { - return UserEventGetUserProfile().send(); - } - - Future> updateUserProfile({ - String? name, - String? password, - String? email, - String? iconUrl, - }) { - var payload = UpdateUserProfilePayloadPB.create()..id = userId; - - if (name != null) { - payload.name = name; - } - - if (password != null) { - payload.password = password; - } - - if (email != null) { - payload.email = email; - } - - if (iconUrl != null) { - payload.iconUrl = iconUrl; - } - - return UserEventUpdateUserProfile(payload).send(); - } - - Future> deleteWorkspace( - {required String workspaceId}) { - throw UnimplementedError(); - } - - Future> signOut() { - return UserEventSignOut().send(); - } - - Future> initUser() async { - return UserEventInitUser().send(); - } - - Future, FlowyError>> getWorkspaces() { - final request = WorkspaceIdPB.create(); - - return FolderEventReadWorkspaces(request).send().then((result) { - return result.fold( - (workspaces) => left(workspaces.items), - (error) => right(error), - ); - }); - } - - Future> openWorkspace(String workspaceId) { - final request = WorkspaceIdPB.create()..value = workspaceId; - return FolderEventOpenWorkspace(request).send().then((result) { - return result.fold( - (workspace) => left(workspace), - (error) => right(error), - ); - }); - } - - Future> createWorkspace( - String name, String desc) { - final request = CreateWorkspacePayloadPB.create() - ..name = name - ..desc = desc; - return FolderEventCreateWorkspace(request).send().then((result) { - return result.fold( - (workspace) => left(workspace), - (error) => right(error), - ); - }); - } -} diff --git a/frontend/app_flowy/lib/user/application/user_settings_service.dart b/frontend/app_flowy/lib/user/application/user_settings_service.dart deleted file mode 100644 index b420af505cc2d..0000000000000 --- a/frontend/app_flowy/lib/user/application/user_settings_service.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/flowy_sdk.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/user_setting.pb.dart'; - -class SettingsFFIService { - Future getAppearanceSetting() async { - final result = await UserEventGetAppearanceSetting().send(); - - return result.fold( - (AppearanceSettingsPB setting) { - return setting; - }, - (error) { - throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty); - }, - ); - } - - Future> setAppearanceSetting( - AppearanceSettingsPB setting) { - return UserEventSetAppearanceSetting(setting).send(); - } -} diff --git a/frontend/app_flowy/lib/user/domain/auth_state.dart b/frontend/app_flowy/lib/user/domain/auth_state.dart deleted file mode 100644 index ae0c259573ea5..0000000000000 --- a/frontend/app_flowy/lib/user/domain/auth_state.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -part 'auth_state.freezed.dart'; - -@freezed -class AuthState with _$AuthState { - const factory AuthState.authenticated(UserProfilePB userProfile) = Authenticated; - const factory AuthState.unauthenticated(FlowyError error) = Unauthenticated; - const factory AuthState.initial() = _Initial; -} diff --git a/frontend/app_flowy/lib/user/presentation/router.dart b/frontend/app_flowy/lib/user/presentation/router.dart deleted file mode 100644 index 6ec497164473e..0000000000000 --- a/frontend/app_flowy/lib/user/presentation/router.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/user/application/auth_service.dart'; -import 'package:app_flowy/user/presentation/sign_in_screen.dart'; -import 'package:app_flowy/user/presentation/sign_up_screen.dart'; -import 'package:app_flowy/user/presentation/skip_log_in_screen.dart'; -import 'package:app_flowy/user/presentation/welcome_screen.dart'; -import 'package:app_flowy/workspace/presentation/home/home_screen.dart'; -import 'package:flowy_infra/time/duration.dart'; -import 'package:flowy_infra_ui/widget/route/animation.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:flowy_sdk/protobuf/flowy-folder/protobuf.dart'; -import 'package:flutter/material.dart'; - -class AuthRouter { - void pushForgetPasswordScreen(BuildContext context) { - // TODO: implement showForgetPasswordScreen - } - - void pushWelcomeScreen(BuildContext context, UserProfilePB userProfile) { - getIt().pushWelcomeScreen(context, userProfile); - } - - void pushSignUpScreen(BuildContext context) { - Navigator.of(context).push( - PageRoutes.fade( - () => SignUpScreen(router: getIt()), - ), - ); - } - - void pushHomeScreen(BuildContext context, UserProfilePB profile, - WorkspaceSettingPB workspaceSetting) { - Navigator.push( - context, - PageRoutes.fade(() => HomeScreen(profile, workspaceSetting), - RouteDurations.slow.inMilliseconds * .001), - ); - } -} - -class SplashRoute { - Future pushWelcomeScreen( - BuildContext context, UserProfilePB userProfile) async { - final screen = WelcomeScreen(userProfile: userProfile); - final workspaceId = await Navigator.of(context).push( - PageRoutes.fade( - () => screen, - RouteDurations.slow.inMilliseconds * .001, - ), - ); - - // ignore: use_build_context_synchronously - pushHomeScreen(context, userProfile, workspaceId); - } - - void pushHomeScreen(BuildContext context, UserProfilePB userProfile, - WorkspaceSettingPB workspaceSetting) { - Navigator.push( - context, - PageRoutes.fade(() => HomeScreen(userProfile, workspaceSetting), - RouteDurations.slow.inMilliseconds * .001), - ); - } - - void pushSignInScreen(BuildContext context) { - Navigator.push( - context, - PageRoutes.fade(() => SignInScreen(router: getIt()), - RouteDurations.slow.inMilliseconds * .001), - ); - } - - void pushSkipLoginScreen(BuildContext context) { - Navigator.push( - context, - PageRoutes.fade( - () => SkipLogInScreen( - router: getIt(), - authService: getIt(), - ), - RouteDurations.slow.inMilliseconds * .001), - ); - } -} diff --git a/frontend/app_flowy/lib/user/presentation/sign_in_screen.dart b/frontend/app_flowy/lib/user/presentation/sign_in_screen.dart deleted file mode 100644 index ceb7d1abf85f8..0000000000000 --- a/frontend/app_flowy/lib/user/presentation/sign_in_screen.dart +++ /dev/null @@ -1,231 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/user/application/sign_in_bloc.dart'; -import 'package:app_flowy/user/presentation/router.dart'; -import 'package:app_flowy/user/presentation/widgets/background.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flowy_infra_ui/widget/rounded_input_field.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -class SignInScreen extends StatelessWidget { - final AuthRouter router; - const SignInScreen({Key? key, required this.router}) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => getIt(), - child: BlocListener( - listener: (context, state) { - state.successOrFail.fold( - () => null, - (result) => _handleSuccessOrFail(result, context), - ); - }, - child: Scaffold( - body: SignInForm(router: router), - ), - ), - ); - } - - void _handleSuccessOrFail( - Either result, BuildContext context) { - result.fold( - (user) => router.pushWelcomeScreen(context, user), - (error) => showSnapBar(context, error.msg), - ); - } -} - -class SignInForm extends StatelessWidget { - final AuthRouter router; - const SignInForm({ - Key? key, - required this.router, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment.center, - child: AuthFormContainer( - children: [ - FlowyLogoTitle( - title: LocaleKeys.signIn_loginTitle.tr(), - logoSize: const Size(60, 60), - ), - const VSpace(30), - const EmailTextField(), - const PasswordTextField(), - ForgetPasswordButton(router: router), - const VSpace(30), - const LoginButton(), - const VSpace(10), - SignUpPrompt(router: router), - if (context.read().state.isSubmitting) ...[ - const SizedBox(height: 8), - const LinearProgressIndicator(value: null), - ] - ], - ), - ); - } -} - -class SignUpPrompt extends StatelessWidget { - const SignUpPrompt({ - Key? key, - required this.router, - }) : super(key: key); - - final AuthRouter router; - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FlowyText.medium( - LocaleKeys.signIn_dontHaveAnAccount.tr(), - fontSize: FontSizes.s12, - color: theme.shader3, - ), - TextButton( - style: TextButton.styleFrom(textStyle: TextStyles.body1), - onPressed: () => router.pushSignUpScreen(context), - child: Text( - LocaleKeys.signUp_buttonText.tr(), - style: TextStyle(color: theme.main1), - ), - ), - ], - ); - } -} - -class LoginButton extends StatelessWidget { - const LoginButton({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return RoundedTextButton( - title: LocaleKeys.signIn_loginButtonText.tr(), - height: 48, - borderRadius: Corners.s10Border, - color: theme.main1, - onPressed: () { - context - .read() - .add(const SignInEvent.signedInWithUserEmailAndPassword()); - }, - ); - } -} - -class ForgetPasswordButton extends StatelessWidget { - const ForgetPasswordButton({ - Key? key, - required this.router, - }) : super(key: key); - - final AuthRouter router; - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return TextButton( - style: TextButton.styleFrom( - textStyle: TextStyles.body1, - ), - onPressed: () => router.pushForgetPasswordScreen(context), - child: Text( - LocaleKeys.signIn_forgotPassword.tr(), - style: TextStyle(color: theme.main1), - ), - ); - } -} - -class PasswordTextField extends StatelessWidget { - const PasswordTextField({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocBuilder( - buildWhen: (previous, current) => - previous.passwordError != current.passwordError, - builder: (context, state) { - return RoundedInputField( - obscureText: true, - style: TextStyles.body1.size(FontSizes.s14), - obscureIcon: svgWidget("home/hide"), - obscureHideIcon: svgWidget("home/show"), - hintText: LocaleKeys.signIn_passwordHint.tr(), - normalBorderColor: theme.shader4, - errorBorderColor: theme.red, - cursorColor: theme.main1, - errorText: context - .read() - .state - .passwordError - .fold(() => "", (error) => error), - onChanged: (value) => context - .read() - .add(SignInEvent.passwordChanged(value)), - ); - }, - ); - } -} - -class EmailTextField extends StatelessWidget { - const EmailTextField({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocBuilder( - buildWhen: (previous, current) => - previous.emailError != current.emailError, - builder: (context, state) { - return RoundedInputField( - hintText: LocaleKeys.signIn_emailHint.tr(), - style: TextStyles.body1.size(FontSizes.s14), - normalBorderColor: theme.shader4, - errorBorderColor: theme.red, - cursorColor: theme.main1, - errorText: context - .read() - .state - .emailError - .fold(() => "", (error) => error), - onChanged: (value) => - context.read().add(SignInEvent.emailChanged(value)), - ); - }, - ); - } -} diff --git a/frontend/app_flowy/lib/user/presentation/sign_up_screen.dart b/frontend/app_flowy/lib/user/presentation/sign_up_screen.dart deleted file mode 100644 index b05435703db69..0000000000000 --- a/frontend/app_flowy/lib/user/presentation/sign_up_screen.dart +++ /dev/null @@ -1,230 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/user/application/sign_up_bloc.dart'; -import 'package:app_flowy/user/presentation/router.dart'; -import 'package:app_flowy/user/presentation/widgets/background.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flowy_infra_ui/widget/rounded_input_field.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -class SignUpScreen extends StatelessWidget { - final AuthRouter router; - const SignUpScreen({Key? key, required this.router}) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => getIt(), - child: BlocListener( - listener: (context, state) { - state.successOrFail.fold( - () => {}, - (result) => _handleSuccessOrFail(context, result), - ); - }, - child: const Scaffold(body: SignUpForm()), - ), - ); - } - - void _handleSuccessOrFail( - BuildContext context, Either result) { - result.fold( - (user) => router.pushWelcomeScreen(context, user), - (error) => showSnapBar(context, error.msg), - ); - } -} - -class SignUpForm extends StatelessWidget { - const SignUpForm({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment.center, - child: AuthFormContainer( - children: [ - FlowyLogoTitle( - title: LocaleKeys.signUp_title.tr(), - logoSize: const Size(60, 60), - ), - const VSpace(30), - const EmailTextField(), - const PasswordTextField(), - const RepeatPasswordTextField(), - const VSpace(30), - const SignUpButton(), - const VSpace(10), - const SignUpPrompt(), - if (context.read().state.isSubmitting) ...[ - const SizedBox(height: 8), - const LinearProgressIndicator(value: null), - ] - ], - ), - ); - } -} - -class SignUpPrompt extends StatelessWidget { - const SignUpPrompt({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - LocaleKeys.signUp_alreadyHaveAnAccount.tr(), - style: TextStyle(color: theme.shader3, fontSize: 12), - ), - TextButton( - style: TextButton.styleFrom(textStyle: TextStyles.body1), - onPressed: () => Navigator.pop(context), - child: Text(LocaleKeys.signIn_buttonText.tr(), - style: TextStyle(color: theme.main1)), - ), - ], - ); - } -} - -class SignUpButton extends StatelessWidget { - const SignUpButton({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return RoundedTextButton( - title: LocaleKeys.signUp_getStartedText.tr(), - height: 48, - color: theme.main1, - onPressed: () { - context - .read() - .add(const SignUpEvent.signUpWithUserEmailAndPassword()); - }, - ); - } -} - -class PasswordTextField extends StatelessWidget { - const PasswordTextField({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocBuilder( - buildWhen: (previous, current) => - previous.passwordError != current.passwordError, - builder: (context, state) { - return RoundedInputField( - obscureText: true, - obscureIcon: svgWidget("home/hide"), - obscureHideIcon: svgWidget("home/show"), - style: TextStyles.body1.size(FontSizes.s14), - hintText: LocaleKeys.signUp_passwordHint.tr(), - normalBorderColor: theme.shader4, - errorBorderColor: theme.red, - cursorColor: theme.main1, - errorText: context - .read() - .state - .passwordError - .fold(() => "", (error) => error), - onChanged: (value) => context - .read() - .add(SignUpEvent.passwordChanged(value)), - ); - }, - ); - } -} - -class RepeatPasswordTextField extends StatelessWidget { - const RepeatPasswordTextField({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocBuilder( - buildWhen: (previous, current) => - previous.repeatPasswordError != current.repeatPasswordError, - builder: (context, state) { - return RoundedInputField( - obscureText: true, - obscureIcon: svgWidget("home/hide"), - obscureHideIcon: svgWidget("home/show"), - style: TextStyles.body1.size(FontSizes.s14), - hintText: LocaleKeys.signUp_repeatPasswordHint.tr(), - normalBorderColor: theme.shader4, - errorBorderColor: theme.red, - cursorColor: theme.main1, - errorText: context - .read() - .state - .repeatPasswordError - .fold(() => "", (error) => error), - onChanged: (value) => context - .read() - .add(SignUpEvent.repeatPasswordChanged(value)), - ); - }, - ); - } -} - -class EmailTextField extends StatelessWidget { - const EmailTextField({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocBuilder( - buildWhen: (previous, current) => - previous.emailError != current.emailError, - builder: (context, state) { - return RoundedInputField( - hintText: LocaleKeys.signUp_emailHint.tr(), - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), - normalBorderColor: theme.shader4, - errorBorderColor: theme.red, - cursorColor: theme.main1, - errorText: context - .read() - .state - .emailError - .fold(() => "", (error) => error), - onChanged: (value) => - context.read().add(SignUpEvent.emailChanged(value)), - ); - }, - ); - } -} diff --git a/frontend/app_flowy/lib/user/presentation/skip_log_in_screen.dart b/frontend/app_flowy/lib/user/presentation/skip_log_in_screen.dart deleted file mode 100644 index 03aba5267b72b..0000000000000 --- a/frontend/app_flowy/lib/user/presentation/skip_log_in_screen.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:app_flowy/user/application/auth_service.dart'; -import 'package:app_flowy/user/presentation/router.dart'; -import 'package:app_flowy/user/presentation/widgets/background.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra/uuid.dart'; -import 'package:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/protobuf.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:dartz/dartz.dart' as dartz; -import 'package:app_flowy/generated/locale_keys.g.dart'; - -class SkipLogInScreen extends StatefulWidget { - final AuthRouter router; - final AuthService authService; - - const SkipLogInScreen({ - Key? key, - required this.router, - required this.authService, - }) : super(key: key); - - @override - State createState() => _SkipLogInScreenState(); -} - -class _SkipLogInScreenState extends State { - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: SizedBox( - width: 400, - height: 600, - child: _renderBody(context), - ), - ), - ); - } - - Widget _renderBody(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FlowyLogoTitle( - title: LocaleKeys.welcomeText.tr(), - logoSize: const Size.square(60), - ), - const VSpace(80), - GoButton(onPressed: () => _autoRegister(context)), - const VSpace(30), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - InkWell( - child: Text( - LocaleKeys.githubStarText.tr(), - style: TextStyles.general(color: Colors.blue).underline, - ), - onTap: () { - _launchURL('https://github.com/AppFlowy-IO/appflowy'); - }, - ), - InkWell( - child: Text( - LocaleKeys.subscribeNewsletterText.tr(), - style: TextStyles.general(color: Colors.blue).underline, - ), - onTap: () { - _launchURL('https://www.appflowy.io/blog'); - }, - ), - ], - ) - ], - ); - } - - _launchURL(String url) async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } else { - throw 'Could not launch $url'; - } - } - - void _autoRegister(BuildContext context) async { - const password = "AppFlowy123@"; - final uid = uuid(); - final userEmail = "$uid@appflowy.io"; - final result = await widget.authService.signUp( - name: LocaleKeys.defaultUsername.tr(), - password: password, - email: userEmail, - ); - result.fold( - (user) { - FolderEventReadCurrentWorkspace().send().then((result) { - _openCurrentWorkspace(context, user, result); - }); - }, - (error) { - Log.error(error); - }, - ); - } - - void _openCurrentWorkspace( - BuildContext context, - UserProfilePB user, - dartz.Either workspacesOrError, - ) { - workspacesOrError.fold( - (workspaceSetting) { - widget.router.pushHomeScreen(context, user, workspaceSetting); - }, - (error) { - Log.error(error); - }, - ); - } -} - -class GoButton extends StatelessWidget { - final VoidCallback onPressed; - const GoButton({ - Key? key, - required this.onPressed, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return RoundedTextButton( - title: LocaleKeys.letsGoButtonText.tr(), - height: 50, - borderRadius: Corners.s10Border, - color: theme.main1, - onPressed: onPressed, - ); - } -} diff --git a/frontend/app_flowy/lib/user/presentation/splash_screen.dart b/frontend/app_flowy/lib/user/presentation/splash_screen.dart deleted file mode 100644 index 9f89e00ed1a22..0000000000000 --- a/frontend/app_flowy/lib/user/presentation/splash_screen.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/user/application/splash_bloc.dart'; -import 'package:app_flowy/user/domain/auth_state.dart'; -import 'package:app_flowy/user/presentation/router.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error-code/code.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -// [[diagram: splash screen]] -// ┌────────────────┐1.get user ┌──────────┐ ┌────────────┐ 2.send UserEventCheckUser -// │ SplashScreen │──────────▶│SplashBloc│────▶│ISplashUser │─────┐ -// └────────────────┘ └──────────┘ └────────────┘ │ -// │ -// ▼ -// ┌───────────┐ ┌─────────────┐ ┌────────┐ -// │HomeScreen │◀───────────│BlocListener │◀────────────────│RustSDK │ -// └───────────┘ └─────────────┘ └────────┘ -// 4. Show HomeScreen or SignIn 3.return AuthState -class SplashScreen extends StatelessWidget { - const SplashScreen({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) { - return getIt()..add(const SplashEvent.getUser()); - }, - child: Scaffold( - body: BlocListener( - listener: (context, state) { - state.auth.map( - authenticated: (r) => _handleAuthenticated(context, r), - unauthenticated: (r) => _handleUnauthenticated(context, r), - initial: (r) => {}, - ); - }, - child: const Body(), - ), - ), - ); - } - - void _handleAuthenticated(BuildContext context, Authenticated result) { - final userProfile = result.userProfile; - FolderEventReadCurrentWorkspace().send().then( - (result) { - return result.fold( - (workspaceSetting) => getIt() - .pushHomeScreen(context, userProfile, workspaceSetting), - (error) async { - Log.error(error); - assert(error.code == ErrorCode.RecordNotFound.value); - getIt().pushWelcomeScreen(context, userProfile); - }, - ); - }, - ); - } - - void _handleUnauthenticated(BuildContext context, Unauthenticated result) { - // getIt().pushSignInScreen(context); - getIt().pushSkipLoginScreen(context); - } -} - -class Body extends StatelessWidget { - const Body({Key? key}) : super(key: key); - @override - Widget build(BuildContext context) { - var size = MediaQuery.of(context).size; - - return Container( - alignment: Alignment.center, - child: SingleChildScrollView( - child: Stack( - alignment: Alignment.center, - children: [ - Image( - fit: BoxFit.cover, - width: size.width, - height: size.height, - image: const AssetImage( - 'assets/images/appflowy_launch_splash.jpg')), - const CircularProgressIndicator.adaptive(), - ], - ), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/user/presentation/welcome_screen.dart b/frontend/app_flowy/lib/user/presentation/welcome_screen.dart deleted file mode 100644 index 31b06d8bd1875..0000000000000 --- a/frontend/app_flowy/lib/user/presentation/welcome_screen.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/workspace/application/workspace/welcome_bloc.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; - -class WelcomeScreen extends StatelessWidget { - final UserProfilePB userProfile; - const WelcomeScreen({ - Key? key, - required this.userProfile, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => getIt(param1: userProfile)..add(const WelcomeEvent.initial()), - child: BlocBuilder( - builder: (context, state) { - return Scaffold( - body: Padding( - padding: const EdgeInsets.all(60.0), - child: Column( - children: [ - _renderBody(state), - _renderCreateButton(context), - ], - ), - ), - ); - }, - ), - ); - } - - Widget _renderBody(WelcomeState state) { - final body = state.successOrFailure.fold( - (_) => _renderList(state.workspaces), - (error) => FlowyErrorPage(error.toString()), - ); - return body; - } - - Widget _renderCreateButton(BuildContext context) { - final theme = context.watch(); - - return SizedBox( - width: 200, - height: 40, - child: FlowyTextButton( - LocaleKeys.workspace_create.tr(), - fontSize: 14, - hoverColor: theme.bg3, - onPressed: () { - context.read().add(WelcomeEvent.createWorkspace(LocaleKeys.workspace_hint.tr(), "")); - }, - ), - ); - } - - Widget _renderList(List workspaces) { - return Expanded( - child: StyledListView( - itemBuilder: (BuildContext context, int index) { - final workspace = workspaces[index]; - return WorkspaceItem( - workspace: workspace, - onPressed: (workspace) => _handleOnPress(context, workspace), - ); - }, - itemCount: workspaces.length, - ), - ); - } - - void _handleOnPress(BuildContext context, WorkspacePB workspace) { - context.read().add(WelcomeEvent.openWorkspace(workspace)); - - Navigator.of(context).pop(workspace.id); - } -} - -class WorkspaceItem extends StatelessWidget { - final WorkspacePB workspace; - final void Function(WorkspacePB workspace) onPressed; - const WorkspaceItem({Key? key, required this.workspace, required this.onPressed}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - return SizedBox( - height: 46, - child: FlowyTextButton( - workspace.name, - hoverColor: theme.bg3, - fontSize: 14, - onPressed: () => onPressed(workspace), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/user/presentation/widgets/background.dart b/frontend/app_flowy/lib/user/presentation/widgets/background.dart deleted file mode 100644 index c7d7b25e3c2c3..0000000000000 --- a/frontend/app_flowy/lib/user/presentation/widgets/background.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:math'; - -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; - -class AuthFormContainer extends StatelessWidget { - final List children; - const AuthFormContainer({ - Key? key, - required this.children, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final size = MediaQuery.of(context).size; - return SizedBox( - width: min(size.width, 340), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: children, - ), - ); - } -} - -class FlowyLogoTitle extends StatelessWidget { - final String title; - final Size logoSize; - const FlowyLogoTitle({ - Key? key, - required this.title, - this.logoSize = const Size.square(40), - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return SizedBox( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox.fromSize( - size: logoSize, - child: svgWidget("flowy_logo"), - ), - const VSpace(30), - FlowyText.semibold( - title, - fontSize: 24, - ), - ], - ), - ); - } -} diff --git a/frontend/app_flowy/lib/workspace/application/app/app_bloc.dart b/frontend/app_flowy/lib/workspace/application/app/app_bloc.dart deleted file mode 100644 index 187a7155ad391..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/app/app_bloc.dart +++ /dev/null @@ -1,258 +0,0 @@ -import 'dart:collection'; - -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/workspace/application/app/app_listener.dart'; -import 'package:app_flowy/workspace/application/app/app_service.dart'; -import 'package:app_flowy/workspace/presentation/home/menu/menu.dart'; -import 'package:expandable/expandable.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flutter/foundation.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:dartz/dartz.dart'; - -part 'app_bloc.freezed.dart'; - -class AppBloc extends Bloc { - final AppService appService; - final AppListener appListener; - - AppBloc({required AppPB app}) - : appService = AppService(), - appListener = AppListener(appId: app.id), - super(AppState.initial(app)) { - on((event, emit) async { - await event.map(initial: (e) async { - _startListening(); - await _loadViews(emit); - }, createView: (CreateView value) async { - await _createView(value, emit); - }, loadViews: (_) async { - await _loadViews(emit); - }, delete: (e) async { - await _deleteApp(emit); - }, deleteView: (deletedView) async { - await _deleteView(emit, deletedView.viewId); - }, rename: (e) async { - await _renameView(e, emit); - }, appDidUpdate: (e) async { - final latestCreatedView = state.latestCreatedView; - final views = e.app.belongings.items; - AppState newState = state.copyWith( - views: views, - app: e.app, - ); - if (latestCreatedView != null) { - final index = - views.indexWhere((element) => element.id == latestCreatedView.id); - if (index == -1) { - newState = newState.copyWith(latestCreatedView: null); - } - } - emit(newState); - }); - }); - } - - void _startListening() { - appListener.start( - onAppUpdated: (app) { - if (!isClosed) { - add(AppEvent.appDidUpdate(app)); - } - }, - ); - } - - Future _renameView(Rename e, Emitter emit) async { - final result = - await appService.updateApp(appId: state.app.id, name: e.newName); - result.fold( - (l) => emit(state.copyWith(successOrFailure: left(unit))), - (error) => emit(state.copyWith(successOrFailure: right(error))), - ); - } - -// Delete the current app - Future _deleteApp(Emitter emit) async { - final result = await appService.delete(appId: state.app.id); - result.fold( - (unit) => emit(state.copyWith(successOrFailure: left(unit))), - (error) => emit(state.copyWith(successOrFailure: right(error))), - ); - } - - Future _deleteView(Emitter emit, String viewId) async { - final result = await appService.deleteView(viewId: viewId); - result.fold( - (unit) => emit(state.copyWith(successOrFailure: left(unit))), - (error) => emit(state.copyWith(successOrFailure: right(error))), - ); - } - - Future _createView(CreateView value, Emitter emit) async { - final result = await appService.createView( - appId: state.app.id, - name: value.name, - desc: value.desc ?? "", - dataFormatType: value.pluginBuilder.dataFormatType, - pluginType: value.pluginBuilder.pluginType, - layoutType: value.pluginBuilder.layoutType!, - ); - result.fold( - (view) => emit(state.copyWith( - latestCreatedView: view, - successOrFailure: left(unit), - )), - (error) { - Log.error(error); - emit(state.copyWith(successOrFailure: right(error))); - }, - ); - } - - @override - Future close() async { - await appListener.stop(); - return super.close(); - } - - Future _loadViews(Emitter emit) async { - final viewsOrFailed = await appService.getViews(appId: state.app.id); - viewsOrFailed.fold( - (views) => emit(state.copyWith(views: views)), - (error) { - Log.error(error); - emit(state.copyWith(successOrFailure: right(error))); - }, - ); - } -} - -@freezed -class AppEvent with _$AppEvent { - const factory AppEvent.initial() = Initial; - const factory AppEvent.createView( - String name, - PluginBuilder pluginBuilder, { - String? desc, - }) = CreateView; - const factory AppEvent.loadViews() = LoadApp; - const factory AppEvent.delete() = DeleteApp; - const factory AppEvent.deleteView(String viewId) = DeleteView; - const factory AppEvent.rename(String newName) = Rename; - const factory AppEvent.appDidUpdate(AppPB app) = AppDidUpdate; -} - -@freezed -class AppState with _$AppState { - const factory AppState({ - required AppPB app, - required List views, - ViewPB? latestCreatedView, - required Either successOrFailure, - }) = _AppState; - - factory AppState.initial(AppPB app) => AppState( - app: app, - views: app.belongings.items, - successOrFailure: left(unit), - ); -} - -class AppViewDataContext extends ChangeNotifier { - final String appId; - final ValueNotifier> _viewsNotifier = ValueNotifier([]); - final ValueNotifier _selectedViewNotifier = ValueNotifier(null); - VoidCallback? _menuSharedStateListener; - ExpandableController expandController = - ExpandableController(initialExpanded: false); - - AppViewDataContext({required this.appId}) { - _setLatestView(getIt().latestOpenView); - _menuSharedStateListener = - getIt().addLatestViewListener((view) { - _setLatestView(view); - }); - } - - VoidCallback addSelectedViewChangeListener(void Function(ViewPB?) callback) { - listener() { - callback(_selectedViewNotifier.value); - } - - _selectedViewNotifier.addListener(listener); - return listener; - } - - void removeSelectedViewListener(VoidCallback listener) { - _selectedViewNotifier.removeListener(listener); - } - - void _setLatestView(ViewPB? view) { - view?.freeze(); - - if (_selectedViewNotifier.value != view) { - _selectedViewNotifier.value = view; - _expandIfNeed(); - notifyListeners(); - } - } - - ViewPB? get selectedView => _selectedViewNotifier.value; - - set views(List views) { - if (_viewsNotifier.value != views) { - _viewsNotifier.value = views; - _expandIfNeed(); - notifyListeners(); - } - } - - UnmodifiableListView get views => - UnmodifiableListView(_viewsNotifier.value); - - VoidCallback addViewsChangeListener( - void Function(UnmodifiableListView) callback) { - listener() { - callback(views); - } - - _viewsNotifier.addListener(listener); - return listener; - } - - void removeViewsListener(VoidCallback listener) { - _viewsNotifier.removeListener(listener); - } - - void _expandIfNeed() { - if (_selectedViewNotifier.value == null) { - return; - } - - if (!_viewsNotifier.value.contains(_selectedViewNotifier.value)) { - return; - } - - if (expandController.expanded == false) { - // Workaround: Delay 150 milliseconds to make the smooth animation while expanding - Future.delayed(const Duration(milliseconds: 150), () { - expandController.expanded = true; - }); - } - } - - @override - void dispose() { - if (_menuSharedStateListener != null) { - getIt() - .removeLatestViewListener(_menuSharedStateListener!); - } - super.dispose(); - } -} diff --git a/frontend/app_flowy/lib/workspace/application/app/app_listener.dart b/frontend/app_flowy/lib/workspace/application/app/app_listener.dart deleted file mode 100644 index 7019200261888..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/app/app_listener.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; -import 'package:app_flowy/core/folder_notification.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/dart-notify/subject.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/dart_notification.pb.dart'; -import 'package:flowy_sdk/rust_stream.dart'; - -typedef AppDidUpdateCallback = void Function(AppPB app); -typedef ViewsDidChangeCallback = void Function( - Either, FlowyError> viewsOrFailed); - -class AppListener { - StreamSubscription? _subscription; - AppDidUpdateCallback? _updated; - FolderNotificationParser? _parser; - String appId; - - AppListener({ - required this.appId, - }); - - void start({AppDidUpdateCallback? onAppUpdated}) { - _updated = onAppUpdated; - _parser = FolderNotificationParser(id: appId, callback: _handleCallback); - _subscription = - RustStreamReceiver.listen((observable) => _parser?.parse(observable)); - } - - void _handleCallback( - FolderNotification ty, Either result) { - switch (ty) { - case FolderNotification.AppUpdated: - if (_updated != null) { - result.fold( - (payload) { - final app = AppPB.fromBuffer(payload); - _updated!(app); - }, - (error) => Log.error(error), - ); - } - break; - default: - break; - } - } - - Future stop() async { - _parser = null; - await _subscription?.cancel(); - _updated = null; - } -} diff --git a/frontend/app_flowy/lib/workspace/application/app/app_service.dart b/frontend/app_flowy/lib/workspace/application/app/app_service.dart deleted file mode 100644 index fd096a4e0ca52..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/app/app_service.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:async'; - -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; - -import 'package:app_flowy/startup/plugin/plugin.dart'; - -class AppService { - Future> readApp({required String appId}) { - final payload = AppIdPB.create()..value = appId; - - return FolderEventReadApp(payload).send(); - } - - Future> createView({ - required String appId, - required String name, - String? desc, - required ViewDataFormatPB dataFormatType, - required PluginType pluginType, - required ViewLayoutTypePB layoutType, - }) { - var payload = CreateViewPayloadPB.create() - ..belongToId = appId - ..name = name - ..desc = desc ?? "" - ..dataFormat = dataFormatType - ..layout = layoutType; - - return FolderEventCreateView(payload).send(); - } - - Future, FlowyError>> getViews({required String appId}) { - final payload = AppIdPB.create()..value = appId; - - return FolderEventReadApp(payload).send().then((result) { - return result.fold( - (app) => left(app.belongings.items), - (error) => right(error), - ); - }); - } - - Future> delete({required String appId}) { - final request = AppIdPB.create()..value = appId; - return FolderEventDeleteApp(request).send(); - } - - Future> deleteView({required String viewId}) { - final request = RepeatedViewIdPB.create()..items.add(viewId); - return FolderEventDeleteView(request).send(); - } - - Future> updateApp( - {required String appId, String? name}) { - UpdateAppPayloadPB payload = UpdateAppPayloadPB.create()..appId = appId; - - if (name != null) { - payload.name = name; - } - return FolderEventUpdateApp(payload).send(); - } - - Future> moveView({ - required String viewId, - required int fromIndex, - required int toIndex, - }) { - final payload = MoveFolderItemPayloadPB.create() - ..itemId = viewId - ..from = fromIndex - ..to = toIndex - ..ty = MoveFolderItemType.MoveView; - - return FolderEventMoveFolderItem(payload).send(); - } -} diff --git a/frontend/app_flowy/lib/workspace/application/app/prelude.dart b/frontend/app_flowy/lib/workspace/application/app/prelude.dart deleted file mode 100644 index f8477049d326f..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/app/prelude.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'app_bloc.dart'; -export 'app_listener.dart'; -export 'app_service.dart'; diff --git a/frontend/app_flowy/lib/workspace/application/appearance.dart b/frontend/app_flowy/lib/workspace/application/appearance.dart deleted file mode 100644 index f0d77dae49b91..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/appearance.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'dart:async'; - -import 'package:app_flowy/user/application/user_settings_service.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/user_setting.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:easy_localization/easy_localization.dart'; - -/// [AppearanceSetting] is used to modify the appear setting of AppFlowy application. Including the [Locale], [AppTheme], etc. -class AppearanceSetting extends ChangeNotifier with EquatableMixin { - final AppearanceSettingsPB _setting; - AppTheme _theme; - Locale _locale; - - AppearanceSetting(AppearanceSettingsPB setting) - : _setting = setting, - _theme = AppTheme.fromName(name: setting.theme), - _locale = Locale( - setting.locale.languageCode, - setting.locale.countryCode, - ); - - /// Returns the current [AppTheme] - AppTheme get theme => _theme; - - /// Returns the current [Locale] - Locale get locale => _locale; - - /// Updates the current theme and notify the listeners the theme was changed. - /// Do nothing if the passed in themeType equal to the current theme type. - /// - void setTheme(ThemeType themeType) { - if (_theme.ty == themeType) { - return; - } - - _theme = AppTheme.fromType(themeType); - _setting.theme = themeTypeToString(themeType); - _saveAppearSetting(); - - notifyListeners(); - } - - /// Updates the current locale and notify the listeners the locale was changed - /// Fallback to [en] locale If the newLocale is not supported. - /// - void setLocale(BuildContext context, Locale newLocale) { - if (!context.supportedLocales.contains(newLocale)) { - Log.warn("Unsupported locale: $newLocale, Fallback to locale: en"); - newLocale = const Locale('en'); - } - - context.setLocale(newLocale); - - if (_locale != newLocale) { - _locale = newLocale; - _setting.locale.languageCode = _locale.languageCode; - _setting.locale.countryCode = _locale.countryCode ?? ""; - _saveAppearSetting(); - - notifyListeners(); - } - } - - /// Saves key/value setting to disk. - /// Removes the key if the passed in value is null - void setKeyValue(String key, String? value) { - if (key.isEmpty) { - Log.warn("The key should not be empty"); - return; - } - - if (value == null) { - _setting.settingKeyValue.remove(key); - } - - if (_setting.settingKeyValue[key] != value) { - if (value == null) { - _setting.settingKeyValue.remove(key); - } else { - _setting.settingKeyValue[key] = value; - } - } - _saveAppearSetting(); - notifyListeners(); - } - - String? getValue(String key) { - if (key.isEmpty) { - Log.warn("The key should not be empty"); - return null; - } - return _setting.settingKeyValue[key]; - } - - /// Called when the application launch. - /// Uses the device locale when open the application for the first time - void readLocaleWhenAppLaunch(BuildContext context) { - if (_setting.resetToDefault) { - _setting.resetToDefault = false; - _saveAppearSetting(); - setLocale(context, context.deviceLocale); - return; - } - - setLocale(context, _locale); - } - - Future _saveAppearSetting() async { - SettingsFFIService().setAppearanceSetting(_setting).then((result) { - result.fold( - (l) => null, - (error) => Log.error(error), - ); - }); - } - - @override - List get props { - return [_setting.hashCode]; - } -} diff --git a/frontend/app_flowy/lib/workspace/application/edit_panel/edit_context.dart b/frontend/app_flowy/lib/workspace/application/edit_panel/edit_context.dart deleted file mode 100644 index 6234858f60b63..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/edit_panel/edit_context.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:flutter/material.dart'; - -abstract class EditPanelContext extends Equatable { - final String identifier; - final String title; - final Widget child; - const EditPanelContext( - {required this.child, required this.identifier, required this.title}); - - @override - List get props => [identifier]; -} diff --git a/frontend/app_flowy/lib/workspace/application/edit_panel/edit_panel_bloc.dart b/frontend/app_flowy/lib/workspace/application/edit_panel/edit_panel_bloc.dart deleted file mode 100644 index d739c25559a87..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/edit_panel/edit_panel_bloc.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:app_flowy/workspace/application/edit_panel/edit_context.dart'; -import 'package:dartz/dartz.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -part 'edit_panel_bloc.freezed.dart'; - -class EditPanelBloc extends Bloc { - EditPanelBloc() : super(EditPanelState.initial()) { - on((event, emit) async { - await event.map( - startEdit: (e) async { - emit(state.copyWith(isEditing: true, editContext: some(e.context))); - }, - endEdit: (value) async { - emit(state.copyWith(isEditing: false, editContext: none())); - }, - ); - }); - } -} - -@freezed -class EditPanelEvent with _$EditPanelEvent { - const factory EditPanelEvent.startEdit(EditPanelContext context) = _StartEdit; - - const factory EditPanelEvent.endEdit(EditPanelContext context) = _EndEdit; -} - -@freezed -class EditPanelState with _$EditPanelState { - const factory EditPanelState({ - required bool isEditing, - required Option editContext, - }) = _EditPanelState; - - factory EditPanelState.initial() => EditPanelState( - isEditing: false, - editContext: none(), - ); -} diff --git a/frontend/app_flowy/lib/workspace/application/home/home_bloc.dart b/frontend/app_flowy/lib/workspace/application/home/home_bloc.dart deleted file mode 100644 index 5c3455454a66a..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/home/home_bloc.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:app_flowy/user/application/user_listener.dart'; -import 'package:app_flowy/workspace/application/edit_panel/edit_context.dart'; -import 'package:flowy_infra/time/duration.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-error-code/code.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart' - show WorkspaceSettingPB; -import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:dartz/dartz.dart'; -part 'home_bloc.freezed.dart'; - -class HomeBloc extends Bloc { - final UserWorkspaceListener _listener; - - HomeBloc( - UserProfilePB user, - WorkspaceSettingPB workspaceSetting, - ) : _listener = UserWorkspaceListener(userProfile: user), - super(HomeState.initial(workspaceSetting)) { - on( - (event, emit) async { - await event.map( - initial: (_Initial value) { - _listener.start( - onAuthChanged: (result) => _authDidChanged(result), - onSettingUpdated: (result) { - result.fold( - (setting) => - add(HomeEvent.didReceiveWorkspaceSetting(setting)), - (r) => Log.error(r), - ); - }, - ); - }, - showLoading: (e) async { - emit(state.copyWith(isLoading: e.isLoading)); - }, - setEditPanel: (e) async { - emit(state.copyWith(panelContext: some(e.editContext))); - }, - dismissEditPanel: (value) async { - emit(state.copyWith(panelContext: none())); - }, - forceCollapse: (e) async { - emit(state.copyWith(forceCollapse: e.forceCollapse)); - }, - didReceiveWorkspaceSetting: (_DidReceiveWorkspaceSetting value) { - emit(state.copyWith(workspaceSetting: value.setting)); - }, - unauthorized: (_Unauthorized value) { - emit(state.copyWith(unauthorized: true)); - }, - collapseMenu: (_CollapseMenu e) { - emit(state.copyWith(isMenuCollapsed: !state.isMenuCollapsed)); - }, - editPanelResizeStart: (_EditPanelResizeStart e) { - emit(state.copyWith( - resizeType: MenuResizeType.drag, - resizeStart: state.resizeOffset, - )); - }, - editPanelResized: (_EditPanelResized e) { - final newPosition = - (e.offset + state.resizeStart).clamp(-50, 200).toDouble(); - if (state.resizeOffset != newPosition) { - emit(state.copyWith(resizeOffset: newPosition)); - } - }, - editPanelResizeEnd: (_EditPanelResizeEnd e) { - emit(state.copyWith(resizeType: MenuResizeType.slide)); - }, - ); - }, - ); - } - - @override - Future close() async { - await _listener.stop(); - return super.close(); - } - - void _authDidChanged(Either errorOrNothing) { - errorOrNothing.fold((_) {}, (error) { - if (error.code == ErrorCode.UserUnauthorized.value) { - add(HomeEvent.unauthorized(error.msg)); - } - }); - } -} - -enum MenuResizeType { - slide, - drag, -} - -extension MenuResizeTypeExtension on MenuResizeType { - Duration duration() { - switch (this) { - case MenuResizeType.drag: - return 30.milliseconds; - case MenuResizeType.slide: - return 350.milliseconds; - } - } -} - -@freezed -class HomeEvent with _$HomeEvent { - const factory HomeEvent.initial() = _Initial; - const factory HomeEvent.showLoading(bool isLoading) = _ShowLoading; - const factory HomeEvent.forceCollapse(bool forceCollapse) = _ForceCollapse; - const factory HomeEvent.setEditPanel(EditPanelContext editContext) = - _ShowEditPanel; - const factory HomeEvent.dismissEditPanel() = _DismissEditPanel; - const factory HomeEvent.didReceiveWorkspaceSetting( - WorkspaceSettingPB setting) = _DidReceiveWorkspaceSetting; - const factory HomeEvent.unauthorized(String msg) = _Unauthorized; - const factory HomeEvent.collapseMenu() = _CollapseMenu; - const factory HomeEvent.editPanelResized(double offset) = _EditPanelResized; - const factory HomeEvent.editPanelResizeStart() = _EditPanelResizeStart; - const factory HomeEvent.editPanelResizeEnd() = _EditPanelResizeEnd; -} - -@freezed -class HomeState with _$HomeState { - const factory HomeState({ - required bool isLoading, - required bool forceCollapse, - required Option panelContext, - required WorkspaceSettingPB workspaceSetting, - required bool unauthorized, - required bool isMenuCollapsed, - required double resizeOffset, - required double resizeStart, - required MenuResizeType resizeType, - }) = _HomeState; - - factory HomeState.initial(WorkspaceSettingPB workspaceSetting) => HomeState( - isLoading: false, - forceCollapse: false, - panelContext: none(), - workspaceSetting: workspaceSetting, - unauthorized: false, - isMenuCollapsed: false, - resizeOffset: 0, - resizeStart: 0, - resizeType: MenuResizeType.slide, - ); -} diff --git a/frontend/app_flowy/lib/workspace/application/home/home_service.dart b/frontend/app_flowy/lib/workspace/application/home/home_service.dart deleted file mode 100644 index bf74f931c14d0..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/home/home_service.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'dart:async'; - -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; - -class HomeService { - Future> readApp({required String appId}) { - final payload = AppIdPB.create()..value = appId; - - return FolderEventReadApp(payload).send(); - } -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/delta_markdown.dart b/frontend/app_flowy/lib/workspace/application/markdown/delta_markdown.dart deleted file mode 100644 index ef723f2697756..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/delta_markdown.dart +++ /dev/null @@ -1,30 +0,0 @@ -library delta_markdown; - -import 'dart:convert'; - -import 'src/delta_markdown_decoder.dart'; -import 'src/delta_markdown_encoder.dart'; -import 'src/version.dart'; - -const version = packageVersion; - -/// Codec used to convert between Markdown and Quill deltas. -const DeltaMarkdownCodec _kCodec = DeltaMarkdownCodec(); - -String markdownToDelta(String markdown) { - return _kCodec.decode(markdown); -} - -String deltaToMarkdown(String delta) { - return _kCodec.encode(delta); -} - -class DeltaMarkdownCodec extends Codec { - const DeltaMarkdownCodec(); - - @override - Converter get decoder => DeltaMarkdownDecoder(); - - @override - Converter get encoder => DeltaMarkdownEncoder(); -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/document_markdown.dart b/frontend/app_flowy/lib/workspace/application/markdown/document_markdown.dart deleted file mode 100644 index 71d013728033a..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/document_markdown.dart +++ /dev/null @@ -1,29 +0,0 @@ -library delta_markdown; - -import 'dart:convert'; - -import 'package:appflowy_editor/appflowy_editor.dart' show Document; -import 'package:app_flowy/workspace/application/markdown/src/parser/markdown_encoder.dart'; - -/// Codec used to convert between Markdown and AppFlowy Editor Document. -const AppFlowyEditorMarkdownCodec _kCodec = AppFlowyEditorMarkdownCodec(); - -Document markdownToDocument(String markdown) { - return _kCodec.decode(markdown); -} - -String documentToMarkdown(Document document) { - return _kCodec.encode(document); -} - -class AppFlowyEditorMarkdownCodec extends Codec { - const AppFlowyEditorMarkdownCodec(); - - @override - Converter get decoder => throw UnimplementedError(); - - @override - Converter get encoder { - return AppFlowyEditorMarkdownEncoder(); - } -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/ast.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/ast.dart deleted file mode 100644 index 5356f1d05f7a2..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/ast.dart +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file -// for details. All rights reserved. Use of this source code is governed by a -// BSD-style license that can be found in the LICENSE file. - -typedef Resolver = Node? Function(String name, [String? title]); - -/// Base class for any AST item. -/// -/// Roughly corresponds to Node in the DOM. Will be either an Element or Text. -class Node { - void accept(NodeVisitor visitor) {} - - bool isToplevel = false; - - String? get textContent { - return null; - } -} - -/// A named tag that can contain other nodes. -class Element extends Node { - /// Instantiates a [tag] Element with [children]. - Element(this.tag, this.children) : attributes = {}; - - /// Instantiates an empty, self-closing [tag] Element. - Element.empty(this.tag) - : children = null, - attributes = {}; - - /// Instantiates a [tag] Element with no [children]. - Element.withTag(this.tag) - : children = [], - attributes = {}; - - /// Instantiates a [tag] Element with a single Text child. - Element.text(this.tag, String text) - : children = [Text(text)], - attributes = {}; - - final String tag; - final List? children; - final Map attributes; - String? generatedId; - - /// Whether this element is self-closing. - bool get isEmpty => children == null; - - @override - void accept(NodeVisitor visitor) { - if (visitor.visitElementBefore(this)) { - if (children != null) { - for (final child in children!) { - child.accept(visitor); - } - } - visitor.visitElementAfter(this); - } - } - - @override - String get textContent => children == null - ? '' - : children!.map((child) => child.textContent).join(); -} - -/// A plain text element. -class Text extends Node { - Text(this.text); - - final String text; - - @override - void accept(NodeVisitor visitor) => visitor.visitText(this); - - @override - String get textContent => text; -} - -/// Inline content that has not been parsed into inline nodes (strong, links, -/// etc). -/// -/// These placeholder nodes should only remain in place while the block nodes -/// of a document are still being parsed, in order to gather all reference link -/// definitions. -class UnparsedContent extends Node { - UnparsedContent(this.textContent); - - @override - final String textContent; - - @override - void accept(NodeVisitor visitor); -} - -/// Visitor pattern for the AST. -/// -/// Renderers or other AST transformers should implement this. -abstract class NodeVisitor { - /// Called when a Text node has been reached. - void visitText(Text text); - - /// Called when an Element has been reached, before its children have been - /// visited. - /// - /// Returns `false` to skip its children. - bool visitElementBefore(Element element); - - /// Called when an Element has been reached, after its children have been - /// visited. - /// - /// Will not be called if [visitElementBefore] returns `false`. - void visitElementAfter(Element element); -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/block_parser.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/block_parser.dart deleted file mode 100644 index faac444b98c37..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/block_parser.dart +++ /dev/null @@ -1,1096 +0,0 @@ -// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file -// for details. All rights reserved. Use of this source code is governed by a -// BSD-style license that can be found in the LICENSE file. - -import 'ast.dart'; -import 'document.dart'; -import 'util.dart'; - -/// The line contains only whitespace or is empty. -final _emptyPattern = RegExp(r'^(?:[ \t]*)$'); - -/// A series of `=` or `-` (on the next line) define setext-style headers. -final _setextPattern = RegExp(r'^[ ]{0,3}(=+|-+)\s*$'); - -/// Leading (and trailing) `#` define atx-style headers. -/// -/// Starts with 1-6 unescaped `#` characters which must not be followed by a -/// non-space character. Line may end with any number of `#` characters,. -final _headerPattern = RegExp(r'^ {0,3}(#{1,6})[ \x09\x0b\x0c](.*?)#*$'); - -/// The line starts with `>` with one optional space after. -final _blockquotePattern = RegExp(r'^[ ]{0,3}>[ ]?(.*)$'); - -/// A line indented four spaces. Used for code blocks and lists. -final _indentPattern = RegExp(r'^(?: | {0,3}\t)(.*)$'); - -/// Fenced code block. -final _codePattern = RegExp(r'^[ ]{0,3}(`{3,}|~{3,})(.*)$'); - -/// Three or more hyphens, asterisks or underscores by themselves. Note that -/// a line like `----` is valid as both HR and SETEXT. In case of a tie, -/// SETEXT should win. -final _hrPattern = RegExp(r'^ {0,3}([-*_])[ \t]*\1[ \t]*\1(?:\1|[ \t])*$'); - -/// One or more whitespace, for compressing. -final _oneOrMoreWhitespacePattern = RegExp('[ \n\r\t]+'); - -/// A line starting with one of these markers: `-`, `*`, `+`. May have up to -/// three leading spaces before the marker and any number of spaces or tabs -/// after. -/// -/// Contains a dummy group at [2], so that the groups in [_ulPattern] and -/// [_olPattern] match up; in both, [2] is the length of the number that begins -/// the list marker. -final _ulPattern = RegExp(r'^([ ]{0,3})()([*+-])(([ \t])([ \t]*)(.*))?$'); - -/// A line starting with a number like `123.`. May have up to three leading -/// spaces before the marker and any number of spaces or tabs after. -final _olPattern = - RegExp(r'^([ ]{0,3})(\d{1,9})([\.)])(([ \t])([ \t]*)(.*))?$'); - -/// A line of hyphens separated by at least one pipe. -final _tablePattern = RegExp(r'^[ ]{0,3}\|?( *:?\-+:? *\|)+( *:?\-+:? *)?$'); - -/// Maintains the internal state needed to parse a series of lines into blocks -/// of Markdown suitable for further inline parsing. -class BlockParser { - BlockParser(this.lines, this.document) { - blockSyntaxes - ..addAll(document.blockSyntaxes) - ..addAll(standardBlockSyntaxes); - } - - final List lines; - - /// The Markdown document this parser is parsing. - final Document document; - - /// The enabled block syntaxes. - /// - /// To turn a series of lines into blocks, each of these will be tried in - /// turn. Order matters here. - final List blockSyntaxes = []; - - /// Index of the current line. - int _pos = 0; - - /// Whether the parser has encountered a blank line between two block-level - /// elements. - bool encounteredBlankLine = false; - - /// The collection of built-in block parsers. - final List standardBlockSyntaxes = [ - const EmptyBlockSyntax(), - const BlockTagBlockHtmlSyntax(), - LongBlockHtmlSyntax(r'^ {0,3}|$)', ''), - LongBlockHtmlSyntax(r'^ {0,3}|$)', ''), - LongBlockHtmlSyntax(r'^ {0,3}|$)', ''), - LongBlockHtmlSyntax('^ {0,3}'), - LongBlockHtmlSyntax('^ {0,3}<\\?', '\\?>'), - LongBlockHtmlSyntax('^ {0,3}'), - LongBlockHtmlSyntax('^ {0,3}'), - const OtherTagBlockHtmlSyntax(), - const SetextHeaderSyntax(), - const HeaderSyntax(), - const CodeBlockSyntax(), - const BlockquoteSyntax(), - const HorizontalRuleSyntax(), - const UnorderedListSyntax(), - const OrderedListSyntax(), - const ParagraphSyntax() - ]; - - /// Gets the current line. - String get current => lines[_pos]; - - /// Gets the line after the current one or `null` if there is none. - String? get next { - // Don't read past the end. - if (_pos >= lines.length - 1) { - return null; - } - return lines[_pos + 1]; - } - - /// Gets the line that is [linesAhead] lines ahead of the current one, or - /// `null` if there is none. - /// - /// `peek(0)` is equivalent to [current]. - /// - /// `peek(1)` is equivalent to [next]. - String? peek(int linesAhead) { - if (linesAhead < 0) { - throw ArgumentError('Invalid linesAhead: $linesAhead; must be >= 0.'); - } - // Don't read past the end. - if (_pos >= lines.length - linesAhead) { - return null; - } - return lines[_pos + linesAhead]; - } - - void advance() { - _pos++; - } - - bool get isDone => _pos >= lines.length; - - /// Gets whether or not the current line matches the given pattern. - bool matches(RegExp regex) { - if (isDone) { - return false; - } - return regex.firstMatch(current) != null; - } - - /// Gets whether or not the next line matches the given pattern. - bool matchesNext(RegExp regex) { - if (next == null) { - return false; - } - return regex.firstMatch(next!) != null; - } - - List parseLines() { - final blocks = []; - while (!isDone) { - for (final syntax in blockSyntaxes) { - if (syntax.canParse(this)) { - final block = syntax.parse(this); - if (block != null) { - blocks.add(block); - } - break; - } - } - } - - return blocks; - } -} - -abstract class BlockSyntax { - const BlockSyntax(); - - /// Gets the regex used to identify the beginning of this block, if any. - RegExp? get pattern => null; - - bool get canEndBlock => true; - - bool canParse(BlockParser parser) { - return pattern!.firstMatch(parser.current) != null; - } - - Node? parse(BlockParser parser); - - List parseChildLines(BlockParser parser) { - // Grab all of the lines that form the block element. - final childLines = []; - - while (!parser.isDone) { - final match = pattern!.firstMatch(parser.current); - if (match == null) { - break; - } - childLines.add(match[1]); - parser.advance(); - } - - return childLines; - } - - /// Gets whether or not [parser]'s current line should end the previous block. - static bool isAtBlockEnd(BlockParser parser) { - if (parser.isDone) { - return true; - } - return parser.blockSyntaxes.any((s) => s.canParse(parser) && s.canEndBlock); - } - - /// Generates a valid HTML anchor from the inner text of [element]. - static String generateAnchorHash(Element element) => - element.children!.first.textContent! - .toLowerCase() - .trim() - .replaceAll(RegExp(r'[^a-z0-9 _-]'), '') - .replaceAll(RegExp(r'\s'), '-'); -} - -class EmptyBlockSyntax extends BlockSyntax { - const EmptyBlockSyntax(); - - @override - RegExp get pattern => _emptyPattern; - - @override - Node? parse(BlockParser parser) { - parser - ..encounteredBlankLine = true - ..advance(); - - // Don't actually emit anything. - return null; - } -} - -/// Parses setext-style headers. -class SetextHeaderSyntax extends BlockSyntax { - const SetextHeaderSyntax(); - - @override - bool canParse(BlockParser parser) { - if (!_interperableAsParagraph(parser.current)) { - return false; - } - - var i = 1; - while (true) { - final nextLine = parser.peek(i); - if (nextLine == null) { - // We never reached an underline. - return false; - } - if (_setextPattern.hasMatch(nextLine)) { - return true; - } - // Ensure that we're still in something like paragraph text. - if (!_interperableAsParagraph(nextLine)) { - return false; - } - i++; - } - } - - @override - Node parse(BlockParser parser) { - final lines = []; - late String tag; - while (!parser.isDone) { - final match = _setextPattern.firstMatch(parser.current); - if (match == null) { - // More text. - lines.add(parser.current); - parser.advance(); - continue; - } else { - // The underline. - tag = (match[1]![0] == '=') ? 'h1' : 'h2'; - parser.advance(); - break; - } - } - - final contents = UnparsedContent(lines.join('\n')); - - return Element(tag, [contents]); - } - - bool _interperableAsParagraph(String line) => - !(_indentPattern.hasMatch(line) || - _codePattern.hasMatch(line) || - _headerPattern.hasMatch(line) || - _blockquotePattern.hasMatch(line) || - _hrPattern.hasMatch(line) || - _ulPattern.hasMatch(line) || - _olPattern.hasMatch(line) || - _emptyPattern.hasMatch(line)); -} - -/// Parses setext-style headers, and adds generated IDs to the generated -/// elements. -class SetextHeaderWithIdSyntax extends SetextHeaderSyntax { - const SetextHeaderWithIdSyntax(); - - @override - Node parse(BlockParser parser) { - final element = super.parse(parser) as Element; - element.generatedId = BlockSyntax.generateAnchorHash(element); - return element; - } -} - -/// Parses atx-style headers: `## Header ##`. -class HeaderSyntax extends BlockSyntax { - const HeaderSyntax(); - - @override - RegExp get pattern => _headerPattern; - - @override - Node parse(BlockParser parser) { - final match = pattern.firstMatch(parser.current)!; - parser.advance(); - final level = match[1]!.length; - final contents = UnparsedContent(match[2]!.trim()); - return Element('h$level', [contents]); - } -} - -/// Parses atx-style headers, and adds generated IDs to the generated elements. -class HeaderWithIdSyntax extends HeaderSyntax { - const HeaderWithIdSyntax(); - - @override - Node parse(BlockParser parser) { - final element = super.parse(parser) as Element; - element.generatedId = BlockSyntax.generateAnchorHash(element); - return element; - } -} - -/// Parses email-style blockquotes: `> quote`. -class BlockquoteSyntax extends BlockSyntax { - const BlockquoteSyntax(); - - @override - RegExp get pattern => _blockquotePattern; - - @override - List parseChildLines(BlockParser parser) { - // Grab all of the lines that form the blockquote, stripping off the ">". - final childLines = []; - - while (!parser.isDone) { - final match = pattern.firstMatch(parser.current); - if (match != null) { - childLines.add(match[1]!); - parser.advance(); - continue; - } - - // A paragraph continuation is OK. This is content that cannot be parsed - // as any other syntax except Paragraph, and it doesn't match the bar in - // a Setext header. - if (parser.blockSyntaxes.firstWhere((s) => s.canParse(parser)) - is ParagraphSyntax) { - childLines.add(parser.current); - parser.advance(); - } else { - break; - } - } - - return childLines; - } - - @override - Node parse(BlockParser parser) { - final childLines = parseChildLines(parser); - - // Recursively parse the contents of the blockquote. - final children = BlockParser(childLines, parser.document).parseLines(); - return Element('blockquote', children); - } -} - -/// Parses preformatted code blocks that are indented four spaces. -class CodeBlockSyntax extends BlockSyntax { - const CodeBlockSyntax(); - - @override - RegExp get pattern => _indentPattern; - - @override - bool get canEndBlock => false; - - @override - List parseChildLines(BlockParser parser) { - final childLines = []; - - while (!parser.isDone) { - final match = pattern.firstMatch(parser.current); - if (match != null) { - childLines.add(match[1]); - parser.advance(); - } else { - // If there's a codeblock, then a newline, then a codeblock, keep the - // code blocks together. - final nextMatch = - parser.next != null ? pattern.firstMatch(parser.next!) : null; - if (parser.current.trim() == '' && nextMatch != null) { - childLines..add('')..add(nextMatch[1]); - parser..advance()..advance(); - } else { - break; - } - } - } - return childLines; - } - - @override - Node parse(BlockParser parser) { - final childLines = parseChildLines(parser) - // The Markdown tests expect a trailing newline. - ..add(''); - - // Escape the code. - final escaped = escapeHtml(childLines.join('\n')); - - return Element('pre', [Element.text('code', escaped)]); - } -} - -/// Parses preformatted code blocks between two ~~~ or ``` sequences. -/// -/// See [Pandoc's documentation](http://pandoc.org/README.html#fenced-code-blocks). -class FencedCodeBlockSyntax extends BlockSyntax { - const FencedCodeBlockSyntax(); - - @override - RegExp get pattern => _codePattern; - - @override - List parseChildLines(BlockParser parser, [String? endBlock]) { - endBlock ??= ''; - - final childLines = []; - parser.advance(); - - while (!parser.isDone) { - final match = pattern.firstMatch(parser.current); - if (match == null || !match[1]!.startsWith(endBlock)) { - childLines.add(parser.current); - parser.advance(); - } else { - parser.advance(); - break; - } - } - - return childLines; - } - - @override - Node parse(BlockParser parser) { - // Get the syntax identifier, if there is one. - final match = pattern.firstMatch(parser.current)!; - final endBlock = match.group(1); - var infoString = match.group(2)!; - - final childLines = parseChildLines(parser, endBlock) - // The Markdown tests expect a trailing newline. - ..add(''); - - final code = Element.text('code', childLines.join('\n')); - - // the info-string should be trimmed - // http://spec.commonmark.org/0.22/#example-100 - infoString = infoString.trim(); - if (infoString.isNotEmpty) { - // only use the first word in the syntax - // http://spec.commonmark.org/0.22/#example-100 - infoString = infoString.split(' ').first; - code.attributes['class'] = 'language-$infoString'; - } - - final element = Element('pre', [code]); - return element; - } -} - -/// Parses horizontal rules like `---`, `_ _ _`, `* * *`, etc. -class HorizontalRuleSyntax extends BlockSyntax { - const HorizontalRuleSyntax(); - - @override - RegExp get pattern => _hrPattern; - - @override - Node parse(BlockParser parser) { - parser.advance(); - return Element.empty('hr'); - } -} - -/// Parses inline HTML at the block level. This differs from other Markdown -/// implementations in several ways: -/// -/// 1. This one is way way WAY simpler. -/// 2. Essentially no HTML parsing or validation is done. We're a Markdown -/// parser, not an HTML parser! -abstract class BlockHtmlSyntax extends BlockSyntax { - const BlockHtmlSyntax(); - - @override - bool get canEndBlock => true; -} - -class BlockTagBlockHtmlSyntax extends BlockHtmlSyntax { - const BlockTagBlockHtmlSyntax(); - - static final _pattern = RegExp( - r'^ {0,3}|/>|$)'); - - @override - RegExp get pattern => _pattern; - - @override - Node parse(BlockParser parser) { - final childLines = []; - - // Eat until we hit a blank line. - while (!parser.isDone && !parser.matches(_emptyPattern)) { - childLines.add(parser.current); - parser.advance(); - } - - return Text(childLines.join('\n')); - } -} - -class OtherTagBlockHtmlSyntax extends BlockTagBlockHtmlSyntax { - const OtherTagBlockHtmlSyntax(); - - @override - bool get canEndBlock => false; - - // Really hacky way to detect "other" HTML. This matches: - // - // * any opening spaces - // * open bracket and maybe a slash ("<" or " RegExp(r'^ {0,3}|\s+[^>]*>)\s*$'); -} - -/// A BlockHtmlSyntax that has a specific `endPattern`. -/// -/// In practice this means that the syntax dominates; it is allowed to eat -/// many lines, including blank lines, before matching its `endPattern`. -class LongBlockHtmlSyntax extends BlockHtmlSyntax { - LongBlockHtmlSyntax(String pattern, String endPattern) - : pattern = RegExp(pattern), - _endPattern = RegExp(endPattern); - - @override - final RegExp pattern; - final RegExp _endPattern; - - @override - Node parse(BlockParser parser) { - final childLines = []; - // Eat until we hit [endPattern]. - while (!parser.isDone) { - childLines.add(parser.current); - if (parser.matches(_endPattern)) { - break; - } - parser.advance(); - } - - parser.advance(); - return Text(childLines.join('\n')); - } -} - -class ListItem { - ListItem(this.lines); - - bool forceBlock = false; - final List lines; -} - -/// Base class for both ordered and unordered lists. -abstract class ListSyntax extends BlockSyntax { - const ListSyntax(); - - @override - bool get canEndBlock => true; - - String get listTag; - - /// A list of patterns that can start a valid block within a list item. - static final blocksInList = [ - _blockquotePattern, - _headerPattern, - _hrPattern, - _indentPattern, - _ulPattern, - _olPattern - ]; - - static final _whitespaceRe = RegExp('[ \t]*'); - - @override - Node parse(BlockParser parser) { - final items = []; - var childLines = []; - - void endItem() { - if (childLines.isNotEmpty) { - items.add(ListItem(childLines)); - childLines = []; - } - } - - Match? match; - bool tryMatch(RegExp pattern) { - match = pattern.firstMatch(parser.current); - return match != null; - } - - String? listMarker; - String? indent; - // In case the first number in an ordered list is not 1, use it as the - // "start". - int? startNumber; - - while (!parser.isDone) { - final leadingSpace = - _whitespaceRe.matchAsPrefix(parser.current)!.group(0)!; - final leadingExpandedTabLength = _expandedTabLength(leadingSpace); - if (tryMatch(_emptyPattern)) { - if (_emptyPattern.firstMatch(parser.next ?? '') != null) { - // Two blank lines ends a list. - break; - } - // Add a blank line to the current list item. - childLines.add(''); - } else if (indent != null && indent.length <= leadingExpandedTabLength) { - // Strip off indent and add to current item. - final line = parser.current - .replaceFirst(leadingSpace, ' ' * leadingExpandedTabLength) - .replaceFirst(indent, ''); - childLines.add(line); - } else if (tryMatch(_hrPattern)) { - // Horizontal rule takes precedence to a list item. - break; - } else if (tryMatch(_ulPattern) || tryMatch(_olPattern)) { - final precedingWhitespace = match![1]; - final digits = match![2] ?? ''; - if (startNumber == null && digits.isNotEmpty) { - startNumber = int.parse(digits); - } - final marker = match![3]; - final firstWhitespace = match![5] ?? ''; - final restWhitespace = match![6] ?? ''; - final content = match![7] ?? ''; - final isBlank = content.isEmpty; - if (listMarker != null && listMarker != marker) { - // Changing the bullet or ordered list delimiter starts a list. - break; - } - listMarker = marker; - final markerAsSpaces = ' ' * (digits.length + marker!.length); - if (isBlank) { - // See http://spec.commonmark.org/0.28/#list-items under "3. Item - // starting with a blank line." - // - // If the list item starts with a blank line, the final piece of the - // indentation is just a single space. - indent = '$precedingWhitespace$markerAsSpaces '; - } else if (restWhitespace.length >= 4) { - // See http://spec.commonmark.org/0.28/#list-items under "2. Item - // starting with indented code." - // - // If the list item starts with indented code, we need to _not_ count - // any indentation past the required whitespace character. - indent = precedingWhitespace! + markerAsSpaces + firstWhitespace; - } else { - indent = precedingWhitespace! + - markerAsSpaces + - firstWhitespace + - restWhitespace; - } - // End the current list item and start a one. - endItem(); - childLines.add(restWhitespace + content); - } else if (BlockSyntax.isAtBlockEnd(parser)) { - // Done with the list. - break; - } else { - // If the previous item is a blank line, this means we're done with the - // list and are starting a top-level paragraph. - if ((childLines.isNotEmpty) && (childLines.last == '')) { - parser.encounteredBlankLine = true; - break; - } - - // Anything else is paragraph continuation text. - childLines.add(parser.current); - } - parser.advance(); - } - - endItem(); - final itemNodes = []; - - items.forEach(removeLeadingEmptyLine); - final anyEmptyLines = removeTrailingEmptyLines(items); - var anyEmptyLinesBetweenBlocks = false; - - for (final item in items) { - final itemParser = BlockParser(item.lines, parser.document); - final children = itemParser.parseLines(); - itemNodes.add(Element('li', children)); - anyEmptyLinesBetweenBlocks = - anyEmptyLinesBetweenBlocks || itemParser.encounteredBlankLine; - } - - // Must strip paragraph tags if the list is "tight". - // http://spec.commonmark.org/0.28/#lists - final listIsTight = !anyEmptyLines && !anyEmptyLinesBetweenBlocks; - - if (listIsTight) { - // We must post-process the list items, converting any top-level paragraph - // elements to just text elements. - for (final item in itemNodes) { - for (var i = 0; i < item.children!.length; i++) { - final child = item.children![i]; - if (child is Element && child.tag == 'p') { - item.children!.removeAt(i); - item.children!.insertAll(i, child.children!); - } - } - } - } - - if (listTag == 'ol' && startNumber != 1) { - return Element(listTag, itemNodes)..attributes['start'] = '$startNumber'; - } else { - return Element(listTag, itemNodes); - } - } - - void removeLeadingEmptyLine(ListItem item) { - if (item.lines.isNotEmpty && _emptyPattern.hasMatch(item.lines.first)) { - item.lines.removeAt(0); - } - } - - /// Removes any trailing empty lines and notes whether any items are separated - /// by such lines. - bool removeTrailingEmptyLines(List items) { - var anyEmpty = false; - for (var i = 0; i < items.length; i++) { - if (items[i].lines.length == 1) { - continue; - } - while (items[i].lines.isNotEmpty && - _emptyPattern.hasMatch(items[i].lines.last)) { - if (i < items.length - 1) { - anyEmpty = true; - } - items[i].lines.removeLast(); - } - } - return anyEmpty; - } - - static int _expandedTabLength(String input) { - var length = 0; - for (final char in input.codeUnits) { - length += char == 0x9 ? 4 - (length % 4) : 1; - } - return length; - } -} - -/// Parses unordered lists. -class UnorderedListSyntax extends ListSyntax { - const UnorderedListSyntax(); - - @override - RegExp get pattern => _ulPattern; - - @override - String get listTag => 'ul'; -} - -/// Parses ordered lists. -class OrderedListSyntax extends ListSyntax { - const OrderedListSyntax(); - - @override - RegExp get pattern => _olPattern; - - @override - String get listTag => 'ol'; -} - -/// Parses tables. -class TableSyntax extends BlockSyntax { - const TableSyntax(); - - static final _pipePattern = RegExp(r'\s*\|\s*'); - static final _openingPipe = RegExp(r'^\|\s*'); - static final _closingPipe = RegExp(r'\s*\|$'); - - @override - bool get canEndBlock => false; - - @override - bool canParse(BlockParser parser) { - // Note: matches *next* line, not the current one. We're looking for the - // bar separating the head row from the body rows. - return parser.matchesNext(_tablePattern); - } - - /// Parses a table into its three parts: - /// - /// * a head row of head cells (`` cells) - /// * a divider of hyphens and pipes (not rendered) - /// * many body rows of body cells (`` cells) - @override - Node? parse(BlockParser parser) { - final alignments = parseAlignments(parser.next!); - final columnCount = alignments.length; - final headRow = parseRow(parser, alignments, 'th'); - if (headRow.children!.length != columnCount) { - return null; - } - final head = Element('thead', [headRow]); - - // Advance past the divider of hyphens. - parser.advance(); - - final rows = []; - while (!parser.isDone && !BlockSyntax.isAtBlockEnd(parser)) { - final row = parseRow(parser, alignments, 'td'); - while (row.children!.length < columnCount) { - // Insert synthetic empty cells. - row.children!.add(Element.empty('td')); - } - while (row.children!.length > columnCount) { - row.children!.removeLast(); - } - rows.add(row); - } - if (rows.isEmpty) { - return Element('table', [head]); - } else { - final body = Element('tbody', rows); - - return Element('table', [head, body]); - } - } - - List parseAlignments(String line) { - line = line.replaceFirst(_openingPipe, '').replaceFirst(_closingPipe, ''); - return line.split('|').map((column) { - column = column.trim(); - if (column.startsWith(':') && column.endsWith(':')) { - return 'center'; - } - if (column.startsWith(':')) { - return 'left'; - } - if (column.endsWith(':')) { - return 'right'; - } - return null; - }).toList(); - } - - Element parseRow( - BlockParser parser, List alignments, String cellType) { - final line = parser.current - .replaceFirst(_openingPipe, '') - .replaceFirst(_closingPipe, ''); - final cells = line.split(_pipePattern); - parser.advance(); - final row = []; - String? preCell; - - for (var cell in cells) { - if (preCell != null) { - cell = preCell + cell; - preCell = null; - } - if (cell.endsWith('\\')) { - preCell = '${cell.substring(0, cell.length - 1)}|'; - continue; - } - - final contents = UnparsedContent(cell); - row.add(Element(cellType, [contents])); - } - - for (var i = 0; i < row.length && i < alignments.length; i++) { - if (alignments[i] == null) { - continue; - } - row[i].attributes['style'] = 'text-align: ${alignments[i]};'; - } - - return Element('tr', row); - } -} - -/// Parses paragraphs of regular text. -class ParagraphSyntax extends BlockSyntax { - const ParagraphSyntax(); - - static final _reflinkDefinitionStart = RegExp(r'[ ]{0,3}\['); - - static final _whitespacePattern = RegExp(r'^\s*$'); - - @override - bool get canEndBlock => false; - - @override - bool canParse(BlockParser parser) => true; - - @override - Node parse(BlockParser parser) { - final childLines = []; - - // Eat until we hit something that ends a paragraph. - while (!BlockSyntax.isAtBlockEnd(parser)) { - childLines.add(parser.current); - parser.advance(); - } - - final paragraphLines = _extractReflinkDefinitions(parser, childLines); - if (paragraphLines == null) { - // Paragraph consisted solely of reference link definitions. - return Text(''); - } else { - final contents = UnparsedContent(paragraphLines.join('\n')); - return Element('p', [contents]); - } - } - - /// Extract reference link definitions from the front of the paragraph, and - /// return the remaining paragraph lines. - List? _extractReflinkDefinitions( - BlockParser parser, List lines) { - bool lineStartsReflinkDefinition(int i) => - lines[i].startsWith(_reflinkDefinitionStart); - - var i = 0; - loopOverDefinitions: - while (true) { - // Check for reflink definitions. - if (!lineStartsReflinkDefinition(i)) { - // It's paragraph content from here on out. - break; - } - var contents = lines[i]; - var j = i + 1; - while (j < lines.length) { - // Check to see if the _next_ line might start a reflink definition. - // Even if it turns out not to be, but it started with a '[', then it - // is not a part of _this_ possible reflink definition. - if (lineStartsReflinkDefinition(j)) { - // Try to parse [contents] as a reflink definition. - if (_parseReflinkDefinition(parser, contents)) { - // Loop again, starting at the next possible reflink definition. - i = j; - continue loopOverDefinitions; - } else { - // Could not parse [contents] as a reflink definition. - break; - } - } else { - contents = '$contents\n${lines[j]}'; - j++; - } - } - // End of the block. - if (_parseReflinkDefinition(parser, contents)) { - i = j; - break; - } - - // It may be that there is a reflink definition starting at [i], but it - // does not extend all the way to [j], such as: - // - // [link]: url // line i - // "title" - // garbage - // [link2]: url // line j - // - // In this case, [i, i+1] is a reflink definition, and the rest is - // paragraph content. - while (j >= i) { - // This isn't the most efficient loop, what with this big ole' - // Iterable allocation (`getRange`) followed by a big 'ole String - // allocation, but we - // must walk backwards, checking each range. - contents = lines.getRange(i, j).join('\n'); - if (_parseReflinkDefinition(parser, contents)) { - // That is the last reflink definition. The rest is paragraph - // content. - i = j; - break; - } - j--; - } - // The ending was not a reflink definition at all. Just paragraph - // content. - - break; - } - - if (i == lines.length) { - // No paragraph content. - return null; - } else { - // Ends with paragraph content. - return lines.sublist(i); - } - } - - // Parse [contents] as a reference link definition. - // - // Also adds the reference link definition to the document. - // - // Returns whether [contents] could be parsed as a reference link definition. - bool _parseReflinkDefinition(BlockParser parser, String contents) { - final pattern = RegExp( - // Leading indentation. - r'''^[ ]{0,3}''' - // Reference id in brackets, and URL. - r'''\[((?:\\\]|[^\]])+)\]:\s*(?:<(\S+)>|(\S+))\s*''' - // Title in double or single quotes, or parens. - r'''("[^"]+"|'[^']+'|\([^)]+\)|)\s*$''', - multiLine: true); - final match = pattern.firstMatch(contents); - if (match == null) { - // Not a reference link definition. - return false; - } - if (match[0]!.length < contents.length) { - // Trailing text. No good. - return false; - } - - var label = match[1]!; - final destination = match[2] ?? match[3]; - var title = match[4]; - - // The label must contain at least one non-whitespace character. - if (_whitespacePattern.hasMatch(label)) { - return false; - } - - if (title == '') { - // No title. - title = null; - } else { - // Remove "", '', or (). - title = title!.substring(1, title.length - 1); - } - - // References are case-insensitive, and internal whitespace is compressed. - label = - label.toLowerCase().trim().replaceAll(_oneOrMoreWhitespacePattern, ' '); - - parser.document.linkReferences - .putIfAbsent(label, () => LinkReference(label, destination!, title!)); - return true; - } -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/delta_markdown_decoder.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/delta_markdown_decoder.dart deleted file mode 100644 index 74982c752ee4f..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/delta_markdown_decoder.dart +++ /dev/null @@ -1,255 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; - -import 'package:flutter_quill/models/documents/attribute.dart'; -import 'package:flutter_quill/models/quill_delta.dart'; - -import 'ast.dart' as ast; -import 'document.dart'; - -class DeltaMarkdownDecoder extends Converter { - @override - String convert(String input) { - final lines = input.replaceAll('\r\n', '\n').split('\n'); - - final markdownDocument = Document().parseLines(lines); - - return jsonEncode(_DeltaVisitor().convert(markdownDocument).toJson()); - } -} - -class _DeltaVisitor implements ast.NodeVisitor { - static final _blockTags = - RegExp('h1|h2|h3|h4|h5|h6|hr|pre|ul|ol|blockquote|p|pre'); - - static final _embedTags = RegExp('hr|img'); - - late Delta delta; - - late Queue activeInlineAttributes; - Attribute? activeBlockAttribute; - late Set uniqueIds; - - ast.Element? previousElement; - late ast.Element previousToplevelElement; - - Delta convert(List nodes) { - delta = Delta(); - activeInlineAttributes = Queue(); - uniqueIds = {}; - - for (final node in nodes) { - node.accept(this); - } - - // Ensure the delta ends with a newline. - if (delta.length > 0 && delta.last.value != '\n') { - delta.insert('\n', activeBlockAttribute?.toJson()); - } - - return delta; - } - - @override - void visitText(ast.Text text) { - // Remove trailing newline - //final lines = text.text.trim().split('\n'); - - /* - final attributes = Map(); - for (final attr in activeInlineAttributes) { - attributes.addAll(attr.toJson()); - } - - for (final l in lines) { - delta.insert(l, attributes); - delta.insert('\n', activeBlockAttribute.toJson()); - }*/ - - final str = text.text; - //if (str.endsWith('\n')) str = str.substring(0, str.length - 1); - - final attributes = {}; - for (final attr in activeInlineAttributes) { - attributes.addAll(attr.toJson()); - } - - var newlineIndex = str.indexOf('\n'); - var startIndex = 0; - while (newlineIndex != -1) { - final previousText = str.substring(startIndex, newlineIndex); - if (previousText.isNotEmpty) { - delta.insert(previousText, attributes.isNotEmpty ? attributes : null); - } - delta.insert('\n', activeBlockAttribute?.toJson()); - - startIndex = newlineIndex + 1; - newlineIndex = str.indexOf('\n', newlineIndex + 1); - } - - if (startIndex < str.length) { - final lastStr = str.substring(startIndex); - delta.insert(lastStr, attributes.isNotEmpty ? attributes : null); - } - } - - @override - bool visitElementBefore(ast.Element element) { - // Hackish. Separate block-level elements with newlines. - final attr = _tagToAttribute(element); - - if (delta.isNotEmpty && _blockTags.firstMatch(element.tag) != null) { - if (element.isToplevel) { - // If the last active block attribute is not a list, we need to finish - // it off. - if (previousToplevelElement.tag != 'ul' && - previousToplevelElement.tag != 'ol' && - previousToplevelElement.tag != 'pre' && - previousToplevelElement.tag != 'hr') { - delta.insert('\n', activeBlockAttribute?.toJson()); - } - - // Only separate the blocks if both are paragraphs. - // - // TODO(kolja): Determine which behavior we really want here. - // We can either insert an additional newline or just have the - // paragraphs as single lines. Zefyr will by default render two lines - // are different paragraphs so for now we will not add an additional - // newline here. - // - // if (previousToplevelElement != null && - // previousToplevelElement.tag == 'p' && - // element.tag == 'p') { - // delta.insert('\n'); - // } - } else if (element.tag == 'p' && - previousElement != null && - !previousElement!.isToplevel && - !previousElement!.children!.contains(element)) { - // Here we have two children of the same toplevel element. These need - // to be separated by additional newlines. - - delta - // Finish off the last lower-level block. - ..insert('\n', activeBlockAttribute?.toJson()) - // Add an empty line between the lower-level blocks. - ..insert('\n', activeBlockAttribute?.toJson()); - } - } - - // Keep track of the top-level block attribute. - if (element.isToplevel && element.tag != 'hr') { - // Hacky solution for horizontal rule so that the attribute is not added - // to the line feed at the end of the line. - activeBlockAttribute = attr; - } - - if (_embedTags.firstMatch(element.tag) != null) { - // We write out the element here since the embed has no children or - // content. - delta.insert(attr!.toJson()); - } else if (_blockTags.firstMatch(element.tag) == null && attr != null) { - activeInlineAttributes.addLast(attr); - } - - previousElement = element; - if (element.isToplevel) { - previousToplevelElement = element; - } - - if (element.isEmpty) { - // Empty element like
. - //buffer.write(' />'); - - if (element.tag == 'br') { - delta.insert('\n'); - } - - return false; - } else { - //buffer.write('>'); - return true; - } - } - - @override - void visitElementAfter(ast.Element element) { - if (element.tag == 'li' && - (previousToplevelElement.tag == 'ol' || - previousToplevelElement.tag == 'ul')) { - delta.insert('\n', activeBlockAttribute?.toJson()); - } - - final attr = _tagToAttribute(element); - if (attr == null || !attr.isInline || activeInlineAttributes.last != attr) { - return; - } - activeInlineAttributes.removeLast(); - - // Always keep track of the last element. - // This becomes relevant if we have something like - // - //
    - //
  • ...
  • - //
  • ...
  • - //
- previousElement = element; - } - - /// Uniquifies an id generated from text. - String uniquifyId(String id) { - if (!uniqueIds.contains(id)) { - uniqueIds.add(id); - return id; - } - - var suffix = 2; - var suffixedId = '$id-$suffix'; - while (uniqueIds.contains(suffixedId)) { - suffixedId = '$id-${suffix++}'; - } - uniqueIds.add(suffixedId); - return suffixedId; - } - - Attribute? _tagToAttribute(ast.Element el) { - switch (el.tag) { - case 'em': - return Attribute.italic; - case 'strong': - return Attribute.bold; - case 'ul': - return Attribute.ul; - case 'ol': - return Attribute.ol; - case 'pre': - return Attribute.codeBlock; - case 'blockquote': - return Attribute.blockQuote; - case 'h1': - return Attribute.h1; - case 'h2': - return Attribute.h2; - case 'h3': - return Attribute.h3; - case 'a': - final href = el.attributes['href']; - return LinkAttribute(href); - case 'img': - final href = el.attributes['src']; - return ImageAttribute(href); - case 'hr': - return DividerAttribute(); - } - - return null; - } -} - -class ImageAttribute extends Attribute { - ImageAttribute(String? val) : super('image', AttributeScope.EMBEDS, val); -} - -class DividerAttribute extends Attribute { - DividerAttribute() : super('divider', AttributeScope.EMBEDS, 'hr'); -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/delta_markdown_encoder.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/delta_markdown_encoder.dart deleted file mode 100644 index fd7880d63f202..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/delta_markdown_encoder.dart +++ /dev/null @@ -1,284 +0,0 @@ -import 'dart:convert'; - -import 'package:collection/collection.dart' show IterableExtension; -import 'package:flutter_quill/models/documents/attribute.dart'; -import 'package:flutter_quill/models/documents/nodes/embed.dart'; -import 'package:flutter_quill/models/documents/style.dart'; -import 'package:flutter_quill/models/quill_delta.dart'; - -class DeltaMarkdownEncoder extends Converter { - static const _lineFeedAsciiCode = 0x0A; - - late StringBuffer markdownBuffer; - late StringBuffer lineBuffer; - - Attribute? currentBlockStyle; - late Style currentInlineStyle; - - late List currentBlockLines; - - /// Converts the [input] delta to Markdown. - @override - String convert(String input) { - markdownBuffer = StringBuffer(); - lineBuffer = StringBuffer(); - currentInlineStyle = Style(); - currentBlockLines = []; - - final inputJson = jsonDecode(input) as List?; - if (inputJson is! List) { - throw ArgumentError('Unexpected formatting of the input delta string.'); - } - final delta = Delta.fromJson(inputJson); - final iterator = DeltaIterator(delta); - - while (iterator.hasNext) { - final operation = iterator.next(); - - if (operation.data is String) { - final operationData = operation.data as String; - - if (!operationData.contains('\n')) { - _handleInline(lineBuffer, operationData, operation.attributes); - } else { - _handleLine(operationData, operation.attributes); - } - } else if (operation.data is Map) { - _handleEmbed(operation.data as Map); - } else { - throw ArgumentError('Unexpected formatting of the input delta string.'); - } - } - - _handleBlock(currentBlockStyle); // Close the last block - - return markdownBuffer.toString(); - } - - void _handleInline( - StringBuffer buffer, - String text, - Map? attributes, - ) { - final style = Style.fromJson(attributes); - - // First close any current styles if needed - final markedForRemoval = []; - // Close the styles in reverse order, e.g. **_ for _**Test**_. - for (final value - in currentInlineStyle.attributes.values.toList().reversed) { - // TODO(tillf): Is block correct? - if (value.scope == AttributeScope.BLOCK) { - continue; - } - if (style.containsKey(value.key)) { - continue; - } - - final padding = _trimRight(buffer); - _writeAttribute(buffer, value, close: true); - if (padding.isNotEmpty) { - buffer.write(padding); - } - markedForRemoval.add(value); - } - - // Make sure to remove all attributes that are marked for removal. - for (final value in markedForRemoval) { - currentInlineStyle.attributes.removeWhere((_, v) => v == value); - } - - // Now open any new styles. - for (final attribute in style.attributes.values) { - // TODO(tillf): Is block correct? - if (attribute.scope == AttributeScope.BLOCK) { - continue; - } - if (currentInlineStyle.containsKey(attribute.key)) { - continue; - } - final originalText = text; - text = text.trimLeft(); - final padding = ' ' * (originalText.length - text.length); - if (padding.isNotEmpty) { - buffer.write(padding); - } - _writeAttribute(buffer, attribute); - } - - // Write the text itself - buffer.write(text); - currentInlineStyle = style; - } - - void _handleLine(String data, Map? attributes) { - final span = StringBuffer(); - - for (var i = 0; i < data.length; i++) { - if (data.codeUnitAt(i) == _lineFeedAsciiCode) { - if (span.isNotEmpty) { - // Write the span if it's not empty. - _handleInline(lineBuffer, span.toString(), attributes); - } - // Close any open inline styles. - _handleInline(lineBuffer, '', null); - - final lineBlock = Style.fromJson(attributes) - .attributes - .values - .singleWhereOrNull((a) => a.scope == AttributeScope.BLOCK); - - if (lineBlock == currentBlockStyle) { - currentBlockLines.add(lineBuffer.toString()); - } else { - _handleBlock(currentBlockStyle); - currentBlockLines - ..clear() - ..add(lineBuffer.toString()); - - currentBlockStyle = lineBlock; - } - lineBuffer.clear(); - - span.clear(); - } else { - span.writeCharCode(data.codeUnitAt(i)); - } - } - - // Remaining span - if (span.isNotEmpty) { - _handleInline(lineBuffer, span.toString(), attributes); - } - } - - void _handleEmbed(Map data) { - final embed = BlockEmbed(data.keys.first, data.values.first as String); - - if (embed.type == 'image') { - _writeEmbedTag(lineBuffer, embed); - _writeEmbedTag(lineBuffer, embed, close: true); - } else if (embed.type == 'divider') { - _writeEmbedTag(lineBuffer, embed); - _writeEmbedTag(lineBuffer, embed, close: true); - } - } - - void _handleBlock(Attribute? blockStyle) { - if (currentBlockLines.isEmpty) { - return; // Empty block - } - - // If there was a block before this one, add empty line between the blocks - if (markdownBuffer.isNotEmpty) { - markdownBuffer.writeln(); - } - - if (blockStyle == null) { - markdownBuffer - ..write(currentBlockLines.join('\n')) - ..writeln(); - } else if (blockStyle == Attribute.codeBlock) { - _writeAttribute(markdownBuffer, blockStyle); - markdownBuffer.write(currentBlockLines.join('\n')); - _writeAttribute(markdownBuffer, blockStyle, close: true); - markdownBuffer.writeln(); - } else { - // Dealing with lists or a quote. - for (final line in currentBlockLines) { - _writeBlockTag(markdownBuffer, blockStyle); - markdownBuffer - ..write(line) - ..writeln(); - } - } - } - - String _trimRight(StringBuffer buffer) { - final text = buffer.toString(); - if (!text.endsWith(' ')) { - return ''; - } - - final result = text.trimRight(); - buffer - ..clear() - ..write(result); - return ' ' * (text.length - result.length); - } - - void _writeAttribute( - StringBuffer buffer, - Attribute attribute, { - bool close = false, - }) { - if (attribute.key == Attribute.bold.key) { - buffer.write('**'); - } else if (attribute.key == Attribute.italic.key) { - buffer.write('_'); - } else if (attribute.key == Attribute.link.key) { - buffer.write(!close ? '[' : '](${attribute.value})'); - } else if (attribute == Attribute.codeBlock) { - buffer.write(!close ? '```\n' : '\n```'); - } else if (attribute.key == Attribute.background.key) { - buffer.write(!close ? '' : ''); - } else if (attribute.key == Attribute.underline.key) { - buffer.write(!close ? '' : ''); - } else if (attribute.key == Attribute.codeBlock.key) { - buffer.write(!close ? '```\n' : '\n```'); - } else if (attribute.key == Attribute.inlineCode.key) { - buffer.write(!close ? '`' : '`'); - } else if (attribute.key == Attribute.strikeThrough.key) { - buffer.write(!close ? '~~' : '~~'); - } else { - // do nothing, just skip the unknown attribute. - } - } - - void _writeBlockTag( - StringBuffer buffer, - Attribute block, { - bool close = false, - }) { - if (close) { - return; // no close tag needed for simple blocks. - } - - if (block == Attribute.blockQuote) { - buffer.write('> '); - } else if (block == Attribute.ul) { - buffer.write('* '); - } else if (block == Attribute.ol) { - buffer.write('1. '); - } else if (block.key == Attribute.h1.key && block.value == 1) { - buffer.write('# '); - } else if (block.key == Attribute.h2.key && block.value == 2) { - buffer.write('## '); - } else if (block.key == Attribute.h3.key && block.value == 3) { - buffer.write('### '); - } else if (block.key == Attribute.list.key) { - buffer.write('* '); - } else { - // do nothing, just skip the unknown attribute. - } - } - - void _writeEmbedTag( - StringBuffer buffer, - BlockEmbed embed, { - bool close = false, - }) { - const kImageType = 'image'; - const kDividerType = 'divider'; - - if (embed.type == kImageType) { - if (close) { - buffer.write('](${embed.data})'); - } else { - buffer.write('!['); - } - } else if (embed.type == kDividerType && close) { - buffer.write('\n---\n\n'); - } - } -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/document.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/document.dart deleted file mode 100644 index 890b858cd24da..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/document.dart +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file -// for details. All rights reserved. Use of this source code is governed by a -// BSD-style license that can be found in the LICENSE file. - -import 'ast.dart'; -import 'block_parser.dart'; -import 'extension_set.dart'; -import 'inline_parser.dart'; - -/// Maintains the context needed to parse a Markdown document. -class Document { - Document({ - Iterable? blockSyntaxes, - Iterable? inlineSyntaxes, - ExtensionSet? extensionSet, - this.linkResolver, - this.imageLinkResolver, - }) : extensionSet = extensionSet ?? ExtensionSet.commonMark { - _blockSyntaxes - ..addAll(blockSyntaxes ?? []) - ..addAll(this.extensionSet.blockSyntaxes); - _inlineSyntaxes - ..addAll(inlineSyntaxes ?? []) - ..addAll(this.extensionSet.inlineSyntaxes); - } - - final Map linkReferences = {}; - final ExtensionSet extensionSet; - final Resolver? linkResolver; - final Resolver? imageLinkResolver; - final _blockSyntaxes = {}; - final _inlineSyntaxes = {}; - - Iterable get blockSyntaxes => _blockSyntaxes; - Iterable get inlineSyntaxes => _inlineSyntaxes; - - /// Parses the given [lines] of Markdown to a series of AST nodes. - List parseLines(List lines) { - final nodes = BlockParser(lines, this).parseLines(); - // Make sure to mark the top level nodes as such. - for (final n in nodes) { - n.isToplevel = true; - } - _parseInlineContent(nodes); - return nodes; - } - - /// Parses the given inline Markdown [text] to a series of AST nodes. - List? parseInline(String text) => InlineParser(text, this).parse(); - - void _parseInlineContent(List nodes) { - for (var i = 0; i < nodes.length; i++) { - final node = nodes[i]; - if (node is UnparsedContent) { - final inlineNodes = parseInline(node.textContent)!; - nodes - ..removeAt(i) - ..insertAll(i, inlineNodes); - i += inlineNodes.length - 1; - } else if (node is Element && node.children != null) { - _parseInlineContent(node.children!); - } - } - } -} - -/// A [link reference -/// definition](http://spec.commonmark.org/0.28/#link-reference-definitions). -class LinkReference { - /// Construct a [LinkReference], with all necessary fields. - /// - /// If the parsed link reference definition does not include a title, use - /// `null` for the [title] parameter. - LinkReference(this.label, this.destination, this.title); - - /// The [link label](http://spec.commonmark.org/0.28/#link-label). - /// - /// Temporarily, this class is also being used to represent the link data for - /// an inline link (the destination and title), but this should change before - /// the package is released. - final String label; - - /// The [link destination](http://spec.commonmark.org/0.28/#link-destination). - final String destination; - - /// The [link title](http://spec.commonmark.org/0.28/#link-title). - final String title; -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/emojis.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/emojis.dart deleted file mode 100644 index 3c6e89311222a..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/emojis.dart +++ /dev/null @@ -1,1818 +0,0 @@ -// This file was generated by 0xN0x using emojilib's emoji data file: -// https://github.com/muan/unicode-emoji-json/raw/main/data-by-emoji.json -// at 2021-12-01 10:10:53 -// Emoji version 13.1 - -const emojis = { - 'grinning_face': '😀', - 'grinning_face_with_big_eyes': '😃', - 'grinning_face_with_smiling_eyes': '😄', - 'beaming_face_with_smiling_eyes': '😁', - 'grinning_squinting_face': '😆', - 'grinning_face_with_sweat': '😅', - 'rolling_on_the_floor_laughing': '🤣', - 'face_with_tears_of_joy': '😂', - 'slightly_smiling_face': '🙂', - 'upside_down_face': '🙃', - 'winking_face': '😉', - 'smiling_face_with_smiling_eyes': '😊', - 'smiling_face_with_halo': '😇', - 'smiling_face_with_hearts': '🥰', - 'smiling_face_with_heart_eyes': '😍', - 'star_struck': '🤩', - 'face_blowing_a_kiss': '😘', - 'kissing_face': '😗', - 'smiling_face': '☺️', - 'kissing_face_with_closed_eyes': '😚', - 'kissing_face_with_smiling_eyes': '😙', - 'smiling_face_with_tear': '🥲', - 'face_savoring_food': '😋', - 'face_with_tongue': '😛', - 'winking_face_with_tongue': '😜', - 'zany_face': '🤪', - 'squinting_face_with_tongue': '😝', - 'money_mouth_face': '🤑', - 'hugging_face': '🤗', - 'face_with_hand_over_mouth': '🤭', - 'shushing_face': '🤫', - 'thinking_face': '🤔', - 'zipper_mouth_face': '🤐', - 'face_with_raised_eyebrow': '🤨', - 'neutral_face': '😐', - 'expressionless_face': '😑', - 'face_without_mouth': '😶', - 'face_in_clouds': '😶‍🌫️', - 'smirking_face': '😏', - 'unamused_face': '😒', - 'face_with_rolling_eyes': '🙄', - 'grimacing_face': '😬', - 'face_exhaling': '😮‍💨', - 'lying_face': '🤥', - 'relieved_face': '😌', - 'pensive_face': '😔', - 'sleepy_face': '😪', - 'drooling_face': '🤤', - 'sleeping_face': '😴', - 'face_with_medical_mask': '😷', - 'face_with_thermometer': '🤒', - 'face_with_head_bandage': '🤕', - 'nauseated_face': '🤢', - 'face_vomiting': '🤮', - 'sneezing_face': '🤧', - 'hot_face': '🥵', - 'cold_face': '🥶', - 'woozy_face': '🥴', - 'knocked_out_face': '😵', - 'face_with_spiral_eyes': '😵‍💫', - 'exploding_head': '🤯', - 'cowboy_hat_face': '🤠', - 'partying_face': '🥳', - 'disguised_face': '🥸', - 'smiling_face_with_sunglasses': '😎', - 'nerd_face': '🤓', - 'face_with_monocle': '🧐', - 'confused_face': '😕', - 'worried_face': '😟', - 'slightly_frowning_face': '🙁', - 'frowning_face': '☹️', - 'face_with_open_mouth': '😮', - 'hushed_face': '😯', - 'astonished_face': '😲', - 'flushed_face': '😳', - 'pleading_face': '🥺', - 'frowning_face_with_open_mouth': '😦', - 'anguished_face': '😧', - 'fearful_face': '😨', - 'anxious_face_with_sweat': '😰', - 'sad_but_relieved_face': '😥', - 'crying_face': '😢', - 'loudly_crying_face': '😭', - 'face_screaming_in_fear': '😱', - 'confounded_face': '😖', - 'persevering_face': '😣', - 'disappointed_face': '😞', - 'downcast_face_with_sweat': '😓', - 'weary_face': '😩', - 'tired_face': '😫', - 'yawning_face': '🥱', - 'face_with_steam_from_nose': '😤', - 'pouting_face': '😡', - 'angry_face': '😠', - 'face_with_symbols_on_mouth': '🤬', - 'smiling_face_with_horns': '😈', - 'angry_face_with_horns': '👿', - 'skull': '💀', - 'skull_and_crossbones': '☠️', - 'pile_of_poo': '💩', - 'clown_face': '🤡', - 'ogre': '👹', - 'goblin': '👺', - 'ghost': '👻', - 'alien': '👽', - 'alien_monster': '👾', - 'robot': '🤖', - 'grinning_cat': '😺', - 'grinning_cat_with_smiling_eyes': '😸', - 'cat_with_tears_of_joy': '😹', - 'smiling_cat_with_heart_eyes': '😻', - 'cat_with_wry_smile': '😼', - 'kissing_cat': '😽', - 'weary_cat': '🙀', - 'crying_cat': '😿', - 'pouting_cat': '😾', - 'see_no_evil_monkey': '🙈', - 'hear_no_evil_monkey': '🙉', - 'speak_no_evil_monkey': '🙊', - 'kiss_mark': '💋', - 'love_letter': '💌', - 'heart_with_arrow': '💘', - 'heart_with_ribbon': '💝', - 'sparkling_heart': '💖', - 'growing_heart': '💗', - 'beating_heart': '💓', - 'revolving_hearts': '💞', - 'two_hearts': '💕', - 'heart_decoration': '💟', - 'heart_exclamation': '❣️', - 'broken_heart': '💔', - 'heart_on_fire': '❤️‍🔥', - 'mending_heart': '❤️‍🩹', - 'red_heart': '❤️', - 'orange_heart': '🧡', - 'yellow_heart': '💛', - 'green_heart': '💚', - 'blue_heart': '💙', - 'purple_heart': '💜', - 'brown_heart': '🤎', - 'black_heart': '🖤', - 'white_heart': '🤍', - 'hundred_points': '💯', - 'anger_symbol': '💢', - 'collision': '💥', - 'dizzy': '💫', - 'sweat_droplets': '💦', - 'dashing_away': '💨', - 'hole': '🕳️', - 'bomb': '💣', - 'speech_balloon': '💬', - 'eye_in_speech_bubble': '👁️‍🗨️', - 'left_speech_bubble': '🗨️', - 'right_anger_bubble': '🗯️', - 'thought_balloon': '💭', - 'zzz': '💤', - 'waving_hand': '👋', - 'raised_back_of_hand': '🤚', - 'hand_with_fingers_splayed': '🖐️', - 'raised_hand': '✋', - 'vulcan_salute': '🖖', - 'ok_hand': '👌', - 'pinched_fingers': '🤌', - 'pinching_hand': '🤏', - 'victory_hand': '✌️', - 'crossed_fingers': '🤞', - 'love_you_gesture': '🤟', - 'sign_of_the_horns': '🤘', - 'call_me_hand': '🤙', - 'backhand_index_pointing_left': '👈', - 'backhand_index_pointing_right': '👉', - 'backhand_index_pointing_up': '👆', - 'middle_finger': '🖕', - 'backhand_index_pointing_down': '👇', - 'index_pointing_up': '☝️', - 'thumbs_up': '👍', - 'thumbs_down': '👎', - 'raised_fist': '✊', - 'oncoming_fist': '👊', - 'left_facing_fist': '🤛', - 'right_facing_fist': '🤜', - 'clapping_hands': '👏', - 'raising_hands': '🙌', - 'open_hands': '👐', - 'palms_up_together': '🤲', - 'handshake': '🤝', - 'folded_hands': '🙏', - 'writing_hand': '✍️', - 'nail_polish': '💅', - 'selfie': '🤳', - 'flexed_biceps': '💪', - 'mechanical_arm': '🦾', - 'mechanical_leg': '🦿', - 'leg': '🦵', - 'foot': '🦶', - 'ear': '👂', - 'ear_with_hearing_aid': '🦻', - 'nose': '👃', - 'brain': '🧠', - 'anatomical_heart': '🫀', - 'lungs': '🫁', - 'tooth': '🦷', - 'bone': '🦴', - 'eyes': '👀', - 'eye': '👁️', - 'tongue': '👅', - 'mouth': '👄', - 'baby': '👶', - 'child': '🧒', - 'boy': '👦', - 'girl': '👧', - 'person': '🧑', - 'person_blond_hair': '👱', - 'man': '👨', - 'person_beard': '🧔', - 'man_beard': '🧔‍♂️', - 'woman_beard': '🧔‍♀️', - 'man_red_hair': '👨‍🦰', - 'man_curly_hair': '👨‍🦱', - 'man_white_hair': '👨‍🦳', - 'man_bald': '👨‍🦲', - 'woman': '👩', - 'woman_red_hair': '👩‍🦰', - 'person_red_hair': '🧑‍🦰', - 'woman_curly_hair': '👩‍🦱', - 'person_curly_hair': '🧑‍🦱', - 'woman_white_hair': '👩‍🦳', - 'person_white_hair': '🧑‍🦳', - 'woman_bald': '👩‍🦲', - 'person_bald': '🧑‍🦲', - 'woman_blond_hair': '👱‍♀️', - 'man_blond_hair': '👱‍♂️', - 'older_person': '🧓', - 'old_man': '👴', - 'old_woman': '👵', - 'person_frowning': '🙍', - 'man_frowning': '🙍‍♂️', - 'woman_frowning': '🙍‍♀️', - 'person_pouting': '🙎', - 'man_pouting': '🙎‍♂️', - 'woman_pouting': '🙎‍♀️', - 'person_gesturing_no': '🙅', - 'man_gesturing_no': '🙅‍♂️', - 'woman_gesturing_no': '🙅‍♀️', - 'person_gesturing_ok': '🙆', - 'man_gesturing_ok': '🙆‍♂️', - 'woman_gesturing_ok': '🙆‍♀️', - 'person_tipping_hand': '💁', - 'man_tipping_hand': '💁‍♂️', - 'woman_tipping_hand': '💁‍♀️', - 'person_raising_hand': '🙋', - 'man_raising_hand': '🙋‍♂️', - 'woman_raising_hand': '🙋‍♀️', - 'deaf_person': '🧏', - 'deaf_man': '🧏‍♂️', - 'deaf_woman': '🧏‍♀️', - 'person_bowing': '🙇', - 'man_bowing': '🙇‍♂️', - 'woman_bowing': '🙇‍♀️', - 'person_facepalming': '🤦', - 'man_facepalming': '🤦‍♂️', - 'woman_facepalming': '🤦‍♀️', - 'person_shrugging': '🤷', - 'man_shrugging': '🤷‍♂️', - 'woman_shrugging': '🤷‍♀️', - 'health_worker': '🧑‍⚕️', - 'man_health_worker': '👨‍⚕️', - 'woman_health_worker': '👩‍⚕️', - 'student': '🧑‍🎓', - 'man_student': '👨‍🎓', - 'woman_student': '👩‍🎓', - 'teacher': '🧑‍🏫', - 'man_teacher': '👨‍🏫', - 'woman_teacher': '👩‍🏫', - 'judge': '🧑‍⚖️', - 'man_judge': '👨‍⚖️', - 'woman_judge': '👩‍⚖️', - 'farmer': '🧑‍🌾', - 'man_farmer': '👨‍🌾', - 'woman_farmer': '👩‍🌾', - 'cook': '🧑‍🍳', - 'man_cook': '👨‍🍳', - 'woman_cook': '👩‍🍳', - 'mechanic': '🧑‍🔧', - 'man_mechanic': '👨‍🔧', - 'woman_mechanic': '👩‍🔧', - 'factory_worker': '🧑‍🏭', - 'man_factory_worker': '👨‍🏭', - 'woman_factory_worker': '👩‍🏭', - 'office_worker': '🧑‍💼', - 'man_office_worker': '👨‍💼', - 'woman_office_worker': '👩‍💼', - 'scientist': '🧑‍🔬', - 'man_scientist': '👨‍🔬', - 'woman_scientist': '👩‍🔬', - 'technologist': '🧑‍💻', - 'man_technologist': '👨‍💻', - 'woman_technologist': '👩‍💻', - 'singer': '🧑‍🎤', - 'man_singer': '👨‍🎤', - 'woman_singer': '👩‍🎤', - 'artist': '🧑‍🎨', - 'man_artist': '👨‍🎨', - 'woman_artist': '👩‍🎨', - 'pilot': '🧑‍✈️', - 'man_pilot': '👨‍✈️', - 'woman_pilot': '👩‍✈️', - 'astronaut': '🧑‍🚀', - 'man_astronaut': '👨‍🚀', - 'woman_astronaut': '👩‍🚀', - 'firefighter': '🧑‍🚒', - 'man_firefighter': '👨‍🚒', - 'woman_firefighter': '👩‍🚒', - 'police_officer': '👮', - 'man_police_officer': '👮‍♂️', - 'woman_police_officer': '👮‍♀️', - 'detective': '🕵️', - 'man_detective': '🕵️‍♂️', - 'woman_detective': '🕵️‍♀️', - 'guard': '💂', - 'man_guard': '💂‍♂️', - 'woman_guard': '💂‍♀️', - 'ninja': '🥷', - 'construction_worker': '👷', - 'man_construction_worker': '👷‍♂️', - 'woman_construction_worker': '👷‍♀️', - 'prince': '🤴', - 'princess': '👸', - 'person_wearing_turban': '👳', - 'man_wearing_turban': '👳‍♂️', - 'woman_wearing_turban': '👳‍♀️', - 'person_with_skullcap': '👲', - 'woman_with_headscarf': '🧕', - 'person_in_tuxedo': '🤵', - 'man_in_tuxedo': '🤵‍♂️', - 'woman_in_tuxedo': '🤵‍♀️', - 'person_with_veil': '👰', - 'man_with_veil': '👰‍♂️', - 'woman_with_veil': '👰‍♀️', - 'pregnant_woman': '🤰', - 'breast_feeding': '🤱', - 'woman_feeding_baby': '👩‍🍼', - 'man_feeding_baby': '👨‍🍼', - 'person_feeding_baby': '🧑‍🍼', - 'baby_angel': '👼', - 'santa_claus': '🎅', - 'mrs_claus': '🤶', - 'mx_claus': '🧑‍🎄', - 'superhero': '🦸', - 'man_superhero': '🦸‍♂️', - 'woman_superhero': '🦸‍♀️', - 'supervillain': '🦹', - 'man_supervillain': '🦹‍♂️', - 'woman_supervillain': '🦹‍♀️', - 'mage': '🧙', - 'man_mage': '🧙‍♂️', - 'woman_mage': '🧙‍♀️', - 'fairy': '🧚', - 'man_fairy': '🧚‍♂️', - 'woman_fairy': '🧚‍♀️', - 'vampire': '🧛', - 'man_vampire': '🧛‍♂️', - 'woman_vampire': '🧛‍♀️', - 'merperson': '🧜', - 'merman': '🧜‍♂️', - 'mermaid': '🧜‍♀️', - 'elf': '🧝', - 'man_elf': '🧝‍♂️', - 'woman_elf': '🧝‍♀️', - 'genie': '🧞', - 'man_genie': '🧞‍♂️', - 'woman_genie': '🧞‍♀️', - 'zombie': '🧟', - 'man_zombie': '🧟‍♂️', - 'woman_zombie': '🧟‍♀️', - 'person_getting_massage': '💆', - 'man_getting_massage': '💆‍♂️', - 'woman_getting_massage': '💆‍♀️', - 'person_getting_haircut': '💇', - 'man_getting_haircut': '💇‍♂️', - 'woman_getting_haircut': '💇‍♀️', - 'person_walking': '🚶', - 'man_walking': '🚶‍♂️', - 'woman_walking': '🚶‍♀️', - 'person_standing': '🧍', - 'man_standing': '🧍‍♂️', - 'woman_standing': '🧍‍♀️', - 'person_kneeling': '🧎', - 'man_kneeling': '🧎‍♂️', - 'woman_kneeling': '🧎‍♀️', - 'person_with_white_cane': '🧑‍🦯', - 'man_with_white_cane': '👨‍🦯', - 'woman_with_white_cane': '👩‍🦯', - 'person_in_motorized_wheelchair': '🧑‍🦼', - 'man_in_motorized_wheelchair': '👨‍🦼', - 'woman_in_motorized_wheelchair': '👩‍🦼', - 'person_in_manual_wheelchair': '🧑‍🦽', - 'man_in_manual_wheelchair': '👨‍🦽', - 'woman_in_manual_wheelchair': '👩‍🦽', - 'person_running': '🏃', - 'man_running': '🏃‍♂️', - 'woman_running': '🏃‍♀️', - 'woman_dancing': '💃', - 'man_dancing': '🕺', - 'person_in_suit_levitating': '🕴️', - 'people_with_bunny_ears': '👯', - 'men_with_bunny_ears': '👯‍♂️', - 'women_with_bunny_ears': '👯‍♀️', - 'person_in_steamy_room': '🧖', - 'man_in_steamy_room': '🧖‍♂️', - 'woman_in_steamy_room': '🧖‍♀️', - 'person_climbing': '🧗', - 'man_climbing': '🧗‍♂️', - 'woman_climbing': '🧗‍♀️', - 'person_fencing': '🤺', - 'horse_racing': '🏇', - 'skier': '⛷️', - 'snowboarder': '🏂', - 'person_golfing': '🏌️', - 'man_golfing': '🏌️‍♂️', - 'woman_golfing': '🏌️‍♀️', - 'person_surfing': '🏄', - 'man_surfing': '🏄‍♂️', - 'woman_surfing': '🏄‍♀️', - 'person_rowing_boat': '🚣', - 'man_rowing_boat': '🚣‍♂️', - 'woman_rowing_boat': '🚣‍♀️', - 'person_swimming': '🏊', - 'man_swimming': '🏊‍♂️', - 'woman_swimming': '🏊‍♀️', - 'person_bouncing_ball': '⛹️', - 'man_bouncing_ball': '⛹️‍♂️', - 'woman_bouncing_ball': '⛹️‍♀️', - 'person_lifting_weights': '🏋️', - 'man_lifting_weights': '🏋️‍♂️', - 'woman_lifting_weights': '🏋️‍♀️', - 'person_biking': '🚴', - 'man_biking': '🚴‍♂️', - 'woman_biking': '🚴‍♀️', - 'person_mountain_biking': '🚵', - 'man_mountain_biking': '🚵‍♂️', - 'woman_mountain_biking': '🚵‍♀️', - 'person_cartwheeling': '🤸', - 'man_cartwheeling': '🤸‍♂️', - 'woman_cartwheeling': '🤸‍♀️', - 'people_wrestling': '🤼', - 'men_wrestling': '🤼‍♂️', - 'women_wrestling': '🤼‍♀️', - 'person_playing_water_polo': '🤽', - 'man_playing_water_polo': '🤽‍♂️', - 'woman_playing_water_polo': '🤽‍♀️', - 'person_playing_handball': '🤾', - 'man_playing_handball': '🤾‍♂️', - 'woman_playing_handball': '🤾‍♀️', - 'person_juggling': '🤹', - 'man_juggling': '🤹‍♂️', - 'woman_juggling': '🤹‍♀️', - 'person_in_lotus_position': '🧘', - 'man_in_lotus_position': '🧘‍♂️', - 'woman_in_lotus_position': '🧘‍♀️', - 'person_taking_bath': '🛀', - 'person_in_bed': '🛌', - 'people_holding_hands': '🧑‍🤝‍🧑', - 'women_holding_hands': '👭', - 'woman_and_man_holding_hands': '👫', - 'men_holding_hands': '👬', - 'kiss': '💏', - 'kiss_woman_man': '👩‍❤️‍💋‍👨', - 'kiss_man_man': '👨‍❤️‍💋‍👨', - 'kiss_woman_woman': '👩‍❤️‍💋‍👩', - 'couple_with_heart': '💑', - 'couple_with_heart_woman_man': '👩‍❤️‍👨', - 'couple_with_heart_man_man': '👨‍❤️‍👨', - 'couple_with_heart_woman_woman': '👩‍❤️‍👩', - 'family': '👪', - 'family_man_woman_boy': '👨‍👩‍👦', - 'family_man_woman_girl': '👨‍👩‍👧', - 'family_man_woman_girl_boy': '👨‍👩‍👧‍👦', - 'family_man_woman_boy_boy': '👨‍👩‍👦‍👦', - 'family_man_woman_girl_girl': '👨‍👩‍👧‍👧', - 'family_man_man_boy': '👨‍👨‍👦', - 'family_man_man_girl': '👨‍👨‍👧', - 'family_man_man_girl_boy': '👨‍👨‍👧‍👦', - 'family_man_man_boy_boy': '👨‍👨‍👦‍👦', - 'family_man_man_girl_girl': '👨‍👨‍👧‍👧', - 'family_woman_woman_boy': '👩‍👩‍👦', - 'family_woman_woman_girl': '👩‍👩‍👧', - 'family_woman_woman_girl_boy': '👩‍👩‍👧‍👦', - 'family_woman_woman_boy_boy': '👩‍👩‍👦‍👦', - 'family_woman_woman_girl_girl': '👩‍👩‍👧‍👧', - 'family_man_boy': '👨‍👦', - 'family_man_boy_boy': '👨‍👦‍👦', - 'family_man_girl': '👨‍👧', - 'family_man_girl_boy': '👨‍👧‍👦', - 'family_man_girl_girl': '👨‍👧‍👧', - 'family_woman_boy': '👩‍👦', - 'family_woman_boy_boy': '👩‍👦‍👦', - 'family_woman_girl': '👩‍👧', - 'family_woman_girl_boy': '👩‍👧‍👦', - 'family_woman_girl_girl': '👩‍👧‍👧', - 'speaking_head': '🗣️', - 'bust_in_silhouette': '👤', - 'busts_in_silhouette': '👥', - 'people_hugging': '🫂', - 'footprints': '👣', - 'monkey_face': '🐵', - 'monkey': '🐒', - 'gorilla': '🦍', - 'orangutan': '🦧', - 'dog_face': '🐶', - 'dog': '🐕', - 'guide_dog': '🦮', - 'service_dog': '🐕‍🦺', - 'poodle': '🐩', - 'wolf': '🐺', - 'fox': '🦊', - 'raccoon': '🦝', - 'cat_face': '🐱', - 'cat': '🐈', - 'black_cat': '🐈‍⬛', - 'lion': '🦁', - 'tiger_face': '🐯', - 'tiger': '🐅', - 'leopard': '🐆', - 'horse_face': '🐴', - 'horse': '🐎', - 'unicorn': '🦄', - 'zebra': '🦓', - 'deer': '🦌', - 'bison': '🦬', - 'cow_face': '🐮', - 'ox': '🐂', - 'water_buffalo': '🐃', - 'cow': '🐄', - 'pig_face': '🐷', - 'pig': '🐖', - 'boar': '🐗', - 'pig_nose': '🐽', - 'ram': '🐏', - 'ewe': '🐑', - 'goat': '🐐', - 'camel': '🐪', - 'two_hump_camel': '🐫', - 'llama': '🦙', - 'giraffe': '🦒', - 'elephant': '🐘', - 'mammoth': '🦣', - 'rhinoceros': '🦏', - 'hippopotamus': '🦛', - 'mouse_face': '🐭', - 'mouse': '🐁', - 'rat': '🐀', - 'hamster': '🐹', - 'rabbit_face': '🐰', - 'rabbit': '🐇', - 'chipmunk': '🐿️', - 'beaver': '🦫', - 'hedgehog': '🦔', - 'bat': '🦇', - 'bear': '🐻', - 'polar_bear': '🐻‍❄️', - 'koala': '🐨', - 'panda': '🐼', - 'sloth': '🦥', - 'otter': '🦦', - 'skunk': '🦨', - 'kangaroo': '🦘', - 'badger': '🦡', - 'paw_prints': '🐾', - 'turkey': '🦃', - 'chicken': '🐔', - 'rooster': '🐓', - 'hatching_chick': '🐣', - 'baby_chick': '🐤', - 'front_facing_baby_chick': '🐥', - 'bird': '🐦', - 'penguin': '🐧', - 'dove': '🕊️', - 'eagle': '🦅', - 'duck': '🦆', - 'swan': '🦢', - 'owl': '🦉', - 'dodo': '🦤', - 'feather': '🪶', - 'flamingo': '🦩', - 'peacock': '🦚', - 'parrot': '🦜', - 'frog': '🐸', - 'crocodile': '🐊', - 'turtle': '🐢', - 'lizard': '🦎', - 'snake': '🐍', - 'dragon_face': '🐲', - 'dragon': '🐉', - 'sauropod': '🦕', - 't_rex': '🦖', - 'spouting_whale': '🐳', - 'whale': '🐋', - 'dolphin': '🐬', - 'seal': '🦭', - 'fish': '🐟', - 'tropical_fish': '🐠', - 'blowfish': '🐡', - 'shark': '🦈', - 'octopus': '🐙', - 'spiral_shell': '🐚', - 'snail': '🐌', - 'butterfly': '🦋', - 'bug': '🐛', - 'ant': '🐜', - 'honeybee': '🐝', - 'beetle': '🪲', - 'lady_beetle': '🐞', - 'cricket': '🦗', - 'cockroach': '🪳', - 'spider': '🕷️', - 'spider_web': '🕸️', - 'scorpion': '🦂', - 'mosquito': '🦟', - 'fly': '🪰', - 'worm': '🪱', - 'microbe': '🦠', - 'bouquet': '💐', - 'cherry_blossom': '🌸', - 'white_flower': '💮', - 'rosette': '🏵️', - 'rose': '🌹', - 'wilted_flower': '🥀', - 'hibiscus': '🌺', - 'sunflower': '🌻', - 'blossom': '🌼', - 'tulip': '🌷', - 'seedling': '🌱', - 'potted_plant': '🪴', - 'evergreen_tree': '🌲', - 'deciduous_tree': '🌳', - 'palm_tree': '🌴', - 'cactus': '🌵', - 'sheaf_of_rice': '🌾', - 'herb': '🌿', - 'shamrock': '☘️', - 'four_leaf_clover': '🍀', - 'maple_leaf': '🍁', - 'fallen_leaf': '🍂', - 'leaf_fluttering_in_wind': '🍃', - 'grapes': '🍇', - 'melon': '🍈', - 'watermelon': '🍉', - 'tangerine': '🍊', - 'lemon': '🍋', - 'banana': '🍌', - 'pineapple': '🍍', - 'mango': '🥭', - 'red_apple': '🍎', - 'green_apple': '🍏', - 'pear': '🍐', - 'peach': '🍑', - 'cherries': '🍒', - 'strawberry': '🍓', - 'blueberries': '🫐', - 'kiwi_fruit': '🥝', - 'tomato': '🍅', - 'olive': '🫒', - 'coconut': '🥥', - 'avocado': '🥑', - 'eggplant': '🍆', - 'potato': '🥔', - 'carrot': '🥕', - 'ear_of_corn': '🌽', - 'hot_pepper': '🌶️', - 'bell_pepper': '🫑', - 'cucumber': '🥒', - 'leafy_green': '🥬', - 'broccoli': '🥦', - 'garlic': '🧄', - 'onion': '🧅', - 'mushroom': '🍄', - 'peanuts': '🥜', - 'chestnut': '🌰', - 'bread': '🍞', - 'croissant': '🥐', - 'baguette_bread': '🥖', - 'flatbread': '🫓', - 'pretzel': '🥨', - 'bagel': '🥯', - 'pancakes': '🥞', - 'waffle': '🧇', - 'cheese_wedge': '🧀', - 'meat_on_bone': '🍖', - 'poultry_leg': '🍗', - 'cut_of_meat': '🥩', - 'bacon': '🥓', - 'hamburger': '🍔', - 'french_fries': '🍟', - 'pizza': '🍕', - 'hot_dog': '🌭', - 'sandwich': '🥪', - 'taco': '🌮', - 'burrito': '🌯', - 'tamale': '🫔', - 'stuffed_flatbread': '🥙', - 'falafel': '🧆', - 'egg': '🥚', - 'cooking': '🍳', - 'shallow_pan_of_food': '🥘', - 'pot_of_food': '🍲', - 'fondue': '🫕', - 'bowl_with_spoon': '🥣', - 'green_salad': '🥗', - 'popcorn': '🍿', - 'butter': '🧈', - 'salt': '🧂', - 'canned_food': '🥫', - 'bento_box': '🍱', - 'rice_cracker': '🍘', - 'rice_ball': '🍙', - 'cooked_rice': '🍚', - 'curry_rice': '🍛', - 'steaming_bowl': '🍜', - 'spaghetti': '🍝', - 'roasted_sweet_potato': '🍠', - 'oden': '🍢', - 'sushi': '🍣', - 'fried_shrimp': '🍤', - 'fish_cake_with_swirl': '🍥', - 'moon_cake': '🥮', - 'dango': '🍡', - 'dumpling': '🥟', - 'fortune_cookie': '🥠', - 'takeout_box': '🥡', - 'crab': '🦀', - 'lobster': '🦞', - 'shrimp': '🦐', - 'squid': '🦑', - 'oyster': '🦪', - 'soft_ice_cream': '🍦', - 'shaved_ice': '🍧', - 'ice_cream': '🍨', - 'doughnut': '🍩', - 'cookie': '🍪', - 'birthday_cake': '🎂', - 'shortcake': '🍰', - 'cupcake': '🧁', - 'pie': '🥧', - 'chocolate_bar': '🍫', - 'candy': '🍬', - 'lollipop': '🍭', - 'custard': '🍮', - 'honey_pot': '🍯', - 'baby_bottle': '🍼', - 'glass_of_milk': '🥛', - 'hot_beverage': '☕', - 'teapot': '🫖', - 'teacup_without_handle': '🍵', - 'sake': '🍶', - 'bottle_with_popping_cork': '🍾', - 'wine_glass': '🍷', - 'cocktail_glass': '🍸', - 'tropical_drink': '🍹', - 'beer_mug': '🍺', - 'clinking_beer_mugs': '🍻', - 'clinking_glasses': '🥂', - 'tumbler_glass': '🥃', - 'cup_with_straw': '🥤', - 'bubble_tea': '🧋', - 'beverage_box': '🧃', - 'mate': '🧉', - 'ice': '🧊', - 'chopsticks': '🥢', - 'fork_and_knife_with_plate': '🍽️', - 'fork_and_knife': '🍴', - 'spoon': '🥄', - 'kitchen_knife': '🔪', - 'amphora': '🏺', - 'globe_showing_europe_africa': '🌍', - 'globe_showing_americas': '🌎', - 'globe_showing_asia_australia': '🌏', - 'globe_with_meridians': '🌐', - 'world_map': '🗺️', - 'map_of_japan': '🗾', - 'compass': '🧭', - 'snow_capped_mountain': '🏔️', - 'mountain': '⛰️', - 'volcano': '🌋', - 'mount_fuji': '🗻', - 'camping': '🏕️', - 'beach_with_umbrella': '🏖️', - 'desert': '🏜️', - 'desert_island': '🏝️', - 'national_park': '🏞️', - 'stadium': '🏟️', - 'classical_building': '🏛️', - 'building_construction': '🏗️', - 'brick': '🧱', - 'rock': '🪨', - 'wood': '🪵', - 'hut': '🛖', - 'houses': '🏘️', - 'derelict_house': '🏚️', - 'house': '🏠', - 'house_with_garden': '🏡', - 'office_building': '🏢', - 'japanese_post_office': '🏣', - 'post_office': '🏤', - 'hospital': '🏥', - 'bank': '🏦', - 'hotel': '🏨', - 'love_hotel': '🏩', - 'convenience_store': '🏪', - 'school': '🏫', - 'department_store': '🏬', - 'factory': '🏭', - 'japanese_castle': '🏯', - 'castle': '🏰', - 'wedding': '💒', - 'tokyo_tower': '🗼', - 'statue_of_liberty': '🗽', - 'church': '⛪', - 'mosque': '🕌', - 'hindu_temple': '🛕', - 'synagogue': '🕍', - 'shinto_shrine': '⛩️', - 'kaaba': '🕋', - 'fountain': '⛲', - 'tent': '⛺', - 'foggy': '🌁', - 'night_with_stars': '🌃', - 'cityscape': '🏙️', - 'sunrise_over_mountains': '🌄', - 'sunrise': '🌅', - 'cityscape_at_dusk': '🌆', - 'sunset': '🌇', - 'bridge_at_night': '🌉', - 'hot_springs': '♨️', - 'carousel_horse': '🎠', - 'ferris_wheel': '🎡', - 'roller_coaster': '🎢', - 'barber_pole': '💈', - 'circus_tent': '🎪', - 'locomotive': '🚂', - 'railway_car': '🚃', - 'high_speed_train': '🚄', - 'bullet_train': '🚅', - 'train': '🚆', - 'metro': '🚇', - 'light_rail': '🚈', - 'station': '🚉', - 'tram': '🚊', - 'monorail': '🚝', - 'mountain_railway': '🚞', - 'tram_car': '🚋', - 'bus': '🚌', - 'oncoming_bus': '🚍', - 'trolleybus': '🚎', - 'minibus': '🚐', - 'ambulance': '🚑', - 'fire_engine': '🚒', - 'police_car': '🚓', - 'oncoming_police_car': '🚔', - 'taxi': '🚕', - 'oncoming_taxi': '🚖', - 'automobile': '🚗', - 'oncoming_automobile': '🚘', - 'sport_utility_vehicle': '🚙', - 'pickup_truck': '🛻', - 'delivery_truck': '🚚', - 'articulated_lorry': '🚛', - 'tractor': '🚜', - 'racing_car': '🏎️', - 'motorcycle': '🏍️', - 'motor_scooter': '🛵', - 'manual_wheelchair': '🦽', - 'motorized_wheelchair': '🦼', - 'auto_rickshaw': '🛺', - 'bicycle': '🚲', - 'kick_scooter': '🛴', - 'skateboard': '🛹', - 'roller_skate': '🛼', - 'bus_stop': '🚏', - 'motorway': '🛣️', - 'railway_track': '🛤️', - 'oil_drum': '🛢️', - 'fuel_pump': '⛽', - 'police_car_light': '🚨', - 'horizontal_traffic_light': '🚥', - 'vertical_traffic_light': '🚦', - 'stop_sign': '🛑', - 'construction': '🚧', - 'anchor': '⚓', - 'sailboat': '⛵', - 'canoe': '🛶', - 'speedboat': '🚤', - 'passenger_ship': '🛳️', - 'ferry': '⛴️', - 'motor_boat': '🛥️', - 'ship': '🚢', - 'airplane': '✈️', - 'small_airplane': '🛩️', - 'airplane_departure': '🛫', - 'airplane_arrival': '🛬', - 'parachute': '🪂', - 'seat': '💺', - 'helicopter': '🚁', - 'suspension_railway': '🚟', - 'mountain_cableway': '🚠', - 'aerial_tramway': '🚡', - 'satellite': '🛰️', - 'rocket': '🚀', - 'flying_saucer': '🛸', - 'bellhop_bell': '🛎️', - 'luggage': '🧳', - 'hourglass_done': '⌛', - 'hourglass_not_done': '⏳', - 'watch': '⌚', - 'alarm_clock': '⏰', - 'stopwatch': '⏱️', - 'timer_clock': '⏲️', - 'mantelpiece_clock': '🕰️', - 'twelve_o_clock': '🕛', - 'twelve_thirty': '🕧', - 'one_o_clock': '🕐', - 'one_thirty': '🕜', - 'two_o_clock': '🕑', - 'two_thirty': '🕝', - 'three_o_clock': '🕒', - 'three_thirty': '🕞', - 'four_o_clock': '🕓', - 'four_thirty': '🕟', - 'five_o_clock': '🕔', - 'five_thirty': '🕠', - 'six_o_clock': '🕕', - 'six_thirty': '🕡', - 'seven_o_clock': '🕖', - 'seven_thirty': '🕢', - 'eight_o_clock': '🕗', - 'eight_thirty': '🕣', - 'nine_o_clock': '🕘', - 'nine_thirty': '🕤', - 'ten_o_clock': '🕙', - 'ten_thirty': '🕥', - 'eleven_o_clock': '🕚', - 'eleven_thirty': '🕦', - 'new_moon': '🌑', - 'waxing_crescent_moon': '🌒', - 'first_quarter_moon': '🌓', - 'waxing_gibbous_moon': '🌔', - 'full_moon': '🌕', - 'waning_gibbous_moon': '🌖', - 'last_quarter_moon': '🌗', - 'waning_crescent_moon': '🌘', - 'crescent_moon': '🌙', - 'new_moon_face': '🌚', - 'first_quarter_moon_face': '🌛', - 'last_quarter_moon_face': '🌜', - 'thermometer': '🌡️', - 'sun': '☀️', - 'full_moon_face': '🌝', - 'sun_with_face': '🌞', - 'ringed_planet': '🪐', - 'star': '⭐', - 'glowing_star': '🌟', - 'shooting_star': '🌠', - 'milky_way': '🌌', - 'cloud': '☁️', - 'sun_behind_cloud': '⛅', - 'cloud_with_lightning_and_rain': '⛈️', - 'sun_behind_small_cloud': '🌤️', - 'sun_behind_large_cloud': '🌥️', - 'sun_behind_rain_cloud': '🌦️', - 'cloud_with_rain': '🌧️', - 'cloud_with_snow': '🌨️', - 'cloud_with_lightning': '🌩️', - 'tornado': '🌪️', - 'fog': '🌫️', - 'wind_face': '🌬️', - 'cyclone': '🌀', - 'rainbow': '🌈', - 'closed_umbrella': '🌂', - 'umbrella': '☂️', - 'umbrella_with_rain_drops': '☔', - 'umbrella_on_ground': '⛱️', - 'high_voltage': '⚡', - 'snowflake': '❄️', - 'snowman': '☃️', - 'snowman_without_snow': '⛄', - 'comet': '☄️', - 'fire': '🔥', - 'droplet': '💧', - 'water_wave': '🌊', - 'jack_o_lantern': '🎃', - 'christmas_tree': '🎄', - 'fireworks': '🎆', - 'sparkler': '🎇', - 'firecracker': '🧨', - 'sparkles': '✨', - 'balloon': '🎈', - 'party_popper': '🎉', - 'confetti_ball': '🎊', - 'tanabata_tree': '🎋', - 'pine_decoration': '🎍', - 'japanese_dolls': '🎎', - 'carp_streamer': '🎏', - 'wind_chime': '🎐', - 'moon_viewing_ceremony': '🎑', - 'red_envelope': '🧧', - 'ribbon': '🎀', - 'wrapped_gift': '🎁', - 'reminder_ribbon': '🎗️', - 'admission_tickets': '🎟️', - 'ticket': '🎫', - 'military_medal': '🎖️', - 'trophy': '🏆', - 'sports_medal': '🏅', - '1st_place_medal': '🥇', - '2nd_place_medal': '🥈', - '3rd_place_medal': '🥉', - 'soccer_ball': '⚽', - 'baseball': '⚾', - 'softball': '🥎', - 'basketball': '🏀', - 'volleyball': '🏐', - 'american_football': '🏈', - 'rugby_football': '🏉', - 'tennis': '🎾', - 'flying_disc': '🥏', - 'bowling': '🎳', - 'cricket_game': '🏏', - 'field_hockey': '🏑', - 'ice_hockey': '🏒', - 'lacrosse': '🥍', - 'ping_pong': '🏓', - 'badminton': '🏸', - 'boxing_glove': '🥊', - 'martial_arts_uniform': '🥋', - 'goal_net': '🥅', - 'flag_in_hole': '⛳', - 'ice_skate': '⛸️', - 'fishing_pole': '🎣', - 'diving_mask': '🤿', - 'running_shirt': '🎽', - 'skis': '🎿', - 'sled': '🛷', - 'curling_stone': '🥌', - 'bullseye': '🎯', - 'yo_yo': '🪀', - 'kite': '🪁', - 'pool_8_ball': '🎱', - 'crystal_ball': '🔮', - 'magic_wand': '🪄', - 'nazar_amulet': '🧿', - 'video_game': '🎮', - 'joystick': '🕹️', - 'slot_machine': '🎰', - 'game_die': '🎲', - 'puzzle_piece': '🧩', - 'teddy_bear': '🧸', - 'pinata': '🪅', - 'nesting_dolls': '🪆', - 'spade_suit': '♠️', - 'heart_suit': '♥️', - 'diamond_suit': '♦️', - 'club_suit': '♣️', - 'chess_pawn': '♟️', - 'joker': '🃏', - 'mahjong_red_dragon': '🀄', - 'flower_playing_cards': '🎴', - 'performing_arts': '🎭', - 'framed_picture': '🖼️', - 'artist_palette': '🎨', - 'thread': '🧵', - 'sewing_needle': '🪡', - 'yarn': '🧶', - 'knot': '🪢', - 'glasses': '👓', - 'sunglasses': '🕶️', - 'goggles': '🥽', - 'lab_coat': '🥼', - 'safety_vest': '🦺', - 'necktie': '👔', - 't_shirt': '👕', - 'jeans': '👖', - 'scarf': '🧣', - 'gloves': '🧤', - 'coat': '🧥', - 'socks': '🧦', - 'dress': '👗', - 'kimono': '👘', - 'sari': '🥻', - 'one_piece_swimsuit': '🩱', - 'briefs': '🩲', - 'shorts': '🩳', - 'bikini': '👙', - 'woman_s_clothes': '👚', - 'purse': '👛', - 'handbag': '👜', - 'clutch_bag': '👝', - 'shopping_bags': '🛍️', - 'backpack': '🎒', - 'thong_sandal': '🩴', - 'man_s_shoe': '👞', - 'running_shoe': '👟', - 'hiking_boot': '🥾', - 'flat_shoe': '🥿', - 'high_heeled_shoe': '👠', - 'woman_s_sandal': '👡', - 'ballet_shoes': '🩰', - 'woman_s_boot': '👢', - 'crown': '👑', - 'woman_s_hat': '👒', - 'top_hat': '🎩', - 'graduation_cap': '🎓', - 'billed_cap': '🧢', - 'military_helmet': '🪖', - 'rescue_worker_s_helmet': '⛑️', - 'prayer_beads': '📿', - 'lipstick': '💄', - 'ring': '💍', - 'gem_stone': '💎', - 'muted_speaker': '🔇', - 'speaker_low_volume': '🔈', - 'speaker_medium_volume': '🔉', - 'speaker_high_volume': '🔊', - 'loudspeaker': '📢', - 'megaphone': '📣', - 'postal_horn': '📯', - 'bell': '🔔', - 'bell_with_slash': '🔕', - 'musical_score': '🎼', - 'musical_note': '🎵', - 'musical_notes': '🎶', - 'studio_microphone': '🎙️', - 'level_slider': '🎚️', - 'control_knobs': '🎛️', - 'microphone': '🎤', - 'headphone': '🎧', - 'radio': '📻', - 'saxophone': '🎷', - 'accordion': '🪗', - 'guitar': '🎸', - 'musical_keyboard': '🎹', - 'trumpet': '🎺', - 'violin': '🎻', - 'banjo': '🪕', - 'drum': '🥁', - 'long_drum': '🪘', - 'mobile_phone': '📱', - 'mobile_phone_with_arrow': '📲', - 'telephone': '☎️', - 'telephone_receiver': '📞', - 'pager': '📟', - 'fax_machine': '📠', - 'battery': '🔋', - 'electric_plug': '🔌', - 'laptop': '💻', - 'desktop_computer': '🖥️', - 'printer': '🖨️', - 'keyboard': '⌨️', - 'computer_mouse': '🖱️', - 'trackball': '🖲️', - 'computer_disk': '💽', - 'floppy_disk': '💾', - 'optical_disk': '💿', - 'dvd': '📀', - 'abacus': '🧮', - 'movie_camera': '🎥', - 'film_frames': '🎞️', - 'film_projector': '📽️', - 'clapper_board': '🎬', - 'television': '📺', - 'camera': '📷', - 'camera_with_flash': '📸', - 'video_camera': '📹', - 'videocassette': '📼', - 'magnifying_glass_tilted_left': '🔍', - 'magnifying_glass_tilted_right': '🔎', - 'candle': '🕯️', - 'light_bulb': '💡', - 'flashlight': '🔦', - 'red_paper_lantern': '🏮', - 'diya_lamp': '🪔', - 'notebook_with_decorative_cover': '📔', - 'closed_book': '📕', - 'open_book': '📖', - 'green_book': '📗', - 'blue_book': '📘', - 'orange_book': '📙', - 'books': '📚', - 'notebook': '📓', - 'ledger': '📒', - 'page_with_curl': '📃', - 'scroll': '📜', - 'page_facing_up': '📄', - 'newspaper': '📰', - 'rolled_up_newspaper': '🗞️', - 'bookmark_tabs': '📑', - 'bookmark': '🔖', - 'label': '🏷️', - 'money_bag': '💰', - 'coin': '🪙', - 'yen_banknote': '💴', - 'dollar_banknote': '💵', - 'euro_banknote': '💶', - 'pound_banknote': '💷', - 'money_with_wings': '💸', - 'credit_card': '💳', - 'receipt': '🧾', - 'chart_increasing_with_yen': '💹', - 'envelope': '✉️', - 'e_mail': '📧', - 'incoming_envelope': '📨', - 'envelope_with_arrow': '📩', - 'outbox_tray': '📤', - 'inbox_tray': '📥', - 'package': '📦', - 'closed_mailbox_with_raised_flag': '📫', - 'closed_mailbox_with_lowered_flag': '📪', - 'open_mailbox_with_raised_flag': '📬', - 'open_mailbox_with_lowered_flag': '📭', - 'postbox': '📮', - 'ballot_box_with_ballot': '🗳️', - 'pencil': '✏️', - 'black_nib': '✒️', - 'fountain_pen': '🖋️', - 'pen': '🖊️', - 'paintbrush': '🖌️', - 'crayon': '🖍️', - 'memo': '📝', - 'briefcase': '💼', - 'file_folder': '📁', - 'open_file_folder': '📂', - 'card_index_dividers': '🗂️', - 'calendar': '📅', - 'tear_off_calendar': '📆', - 'spiral_notepad': '🗒️', - 'spiral_calendar': '🗓️', - 'card_index': '📇', - 'chart_increasing': '📈', - 'chart_decreasing': '📉', - 'bar_chart': '📊', - 'clipboard': '📋', - 'pushpin': '📌', - 'round_pushpin': '📍', - 'paperclip': '📎', - 'linked_paperclips': '🖇️', - 'straight_ruler': '📏', - 'triangular_ruler': '📐', - 'scissors': '✂️', - 'card_file_box': '🗃️', - 'file_cabinet': '🗄️', - 'wastebasket': '🗑️', - 'locked': '🔒', - 'unlocked': '🔓', - 'locked_with_pen': '🔏', - 'locked_with_key': '🔐', - 'key': '🔑', - 'old_key': '🗝️', - 'hammer': '🔨', - 'axe': '🪓', - 'pick': '⛏️', - 'hammer_and_pick': '⚒️', - 'hammer_and_wrench': '🛠️', - 'dagger': '🗡️', - 'crossed_swords': '⚔️', - 'water_pistol': '🔫', - 'boomerang': '🪃', - 'bow_and_arrow': '🏹', - 'shield': '🛡️', - 'carpentry_saw': '🪚', - 'wrench': '🔧', - 'screwdriver': '🪛', - 'nut_and_bolt': '🔩', - 'gear': '⚙️', - 'clamp': '🗜️', - 'balance_scale': '⚖️', - 'white_cane': '🦯', - 'link': '🔗', - 'chains': '⛓️', - 'hook': '🪝', - 'toolbox': '🧰', - 'magnet': '🧲', - 'ladder': '🪜', - 'alembic': '⚗️', - 'test_tube': '🧪', - 'petri_dish': '🧫', - 'dna': '🧬', - 'microscope': '🔬', - 'telescope': '🔭', - 'satellite_antenna': '📡', - 'syringe': '💉', - 'drop_of_blood': '🩸', - 'pill': '💊', - 'adhesive_bandage': '🩹', - 'stethoscope': '🩺', - 'door': '🚪', - 'elevator': '🛗', - 'mirror': '🪞', - 'window': '🪟', - 'bed': '🛏️', - 'couch_and_lamp': '🛋️', - 'chair': '🪑', - 'toilet': '🚽', - 'plunger': '🪠', - 'shower': '🚿', - 'bathtub': '🛁', - 'mouse_trap': '🪤', - 'razor': '🪒', - 'lotion_bottle': '🧴', - 'safety_pin': '🧷', - 'broom': '🧹', - 'basket': '🧺', - 'roll_of_paper': '🧻', - 'bucket': '🪣', - 'soap': '🧼', - 'toothbrush': '🪥', - 'sponge': '🧽', - 'fire_extinguisher': '🧯', - 'shopping_cart': '🛒', - 'cigarette': '🚬', - 'coffin': '⚰️', - 'headstone': '🪦', - 'funeral_urn': '⚱️', - 'moai': '🗿', - 'placard': '🪧', - 'atm_sign': '🏧', - 'litter_in_bin_sign': '🚮', - 'potable_water': '🚰', - 'wheelchair_symbol': '♿', - 'men_s_room': '🚹', - 'women_s_room': '🚺', - 'restroom': '🚻', - 'baby_symbol': '🚼', - 'water_closet': '🚾', - 'passport_control': '🛂', - 'customs': '🛃', - 'baggage_claim': '🛄', - 'left_luggage': '🛅', - 'warning': '⚠️', - 'children_crossing': '🚸', - 'no_entry': '⛔', - 'prohibited': '🚫', - 'no_bicycles': '🚳', - 'no_smoking': '🚭', - 'no_littering': '🚯', - 'non_potable_water': '🚱', - 'no_pedestrians': '🚷', - 'no_mobile_phones': '📵', - 'no_one_under_eighteen': '🔞', - 'radioactive': '☢️', - 'biohazard': '☣️', - 'up_arrow': '⬆️', - 'up_right_arrow': '↗️', - 'right_arrow': '➡️', - 'down_right_arrow': '↘️', - 'down_arrow': '⬇️', - 'down_left_arrow': '↙️', - 'left_arrow': '⬅️', - 'up_left_arrow': '↖️', - 'up_down_arrow': '↕️', - 'left_right_arrow': '↔️', - 'right_arrow_curving_left': '↩️', - 'left_arrow_curving_right': '↪️', - 'right_arrow_curving_up': '⤴️', - 'right_arrow_curving_down': '⤵️', - 'clockwise_vertical_arrows': '🔃', - 'counterclockwise_arrows_button': '🔄', - 'back_arrow': '🔙', - 'end_arrow': '🔚', - 'on_arrow': '🔛', - 'soon_arrow': '🔜', - 'top_arrow': '🔝', - 'place_of_worship': '🛐', - 'atom_symbol': '⚛️', - 'om': '🕉️', - 'star_of_david': '✡️', - 'wheel_of_dharma': '☸️', - 'yin_yang': '☯️', - 'latin_cross': '✝️', - 'orthodox_cross': '☦️', - 'star_and_crescent': '☪️', - 'peace_symbol': '☮️', - 'menorah': '🕎', - 'dotted_six_pointed_star': '🔯', - 'aries': '♈', - 'taurus': '♉', - 'gemini': '♊', - 'cancer': '♋', - 'leo': '♌', - 'virgo': '♍', - 'libra': '♎', - 'scorpio': '♏', - 'sagittarius': '♐', - 'capricorn': '♑', - 'aquarius': '♒', - 'pisces': '♓', - 'ophiuchus': '⛎', - 'shuffle_tracks_button': '🔀', - 'repeat_button': '🔁', - 'repeat_single_button': '🔂', - 'play_button': '▶️', - 'fast_forward_button': '⏩', - 'next_track_button': '⏭️', - 'play_or_pause_button': '⏯️', - 'reverse_button': '◀️', - 'fast_reverse_button': '⏪', - 'last_track_button': '⏮️', - 'upwards_button': '🔼', - 'fast_up_button': '⏫', - 'downwards_button': '🔽', - 'fast_down_button': '⏬', - 'pause_button': '⏸️', - 'stop_button': '⏹️', - 'record_button': '⏺️', - 'eject_button': '⏏️', - 'cinema': '🎦', - 'dim_button': '🔅', - 'bright_button': '🔆', - 'antenna_bars': '📶', - 'vibration_mode': '📳', - 'mobile_phone_off': '📴', - 'female_sign': '♀️', - 'male_sign': '♂️', - 'transgender_symbol': '⚧️', - 'multiply': '✖️', - 'plus': '➕', - 'minus': '➖', - 'divide': '➗', - 'infinity': '♾️', - 'double_exclamation_mark': '‼️', - 'exclamation_question_mark': '⁉️', - 'red_question_mark': '❓', - 'white_question_mark': '❔', - 'white_exclamation_mark': '❕', - 'red_exclamation_mark': '❗', - 'wavy_dash': '〰️', - 'currency_exchange': '💱', - 'heavy_dollar_sign': '💲', - 'medical_symbol': '⚕️', - 'recycling_symbol': '♻️', - 'fleur_de_lis': '⚜️', - 'trident_emblem': '🔱', - 'name_badge': '📛', - 'japanese_symbol_for_beginner': '🔰', - 'hollow_red_circle': '⭕', - 'check_mark_button': '✅', - 'check_box_with_check': '☑️', - 'check_mark': '✔️', - 'cross_mark': '❌', - 'cross_mark_button': '❎', - 'curly_loop': '➰', - 'double_curly_loop': '➿', - 'part_alternation_mark': '〽️', - 'eight_spoked_asterisk': '✳️', - 'eight_pointed_star': '✴️', - 'sparkle': '❇️', - 'copyright': '©️', - 'registered': '®️', - 'trade_mark': '™️', - 'keycap_': '*️⃣', - 'keycap_0': '0️⃣', - 'keycap_1': '1️⃣', - 'keycap_2': '2️⃣', - 'keycap_3': '3️⃣', - 'keycap_4': '4️⃣', - 'keycap_5': '5️⃣', - 'keycap_6': '6️⃣', - 'keycap_7': '7️⃣', - 'keycap_8': '8️⃣', - 'keycap_9': '9️⃣', - 'keycap_10': '🔟', - 'input_latin_uppercase': '🔠', - 'input_latin_lowercase': '🔡', - 'input_numbers': '🔢', - 'input_symbols': '🔣', - 'input_latin_letters': '🔤', - 'a_button': '🅰️', - 'ab_button': '🆎', - 'b_button': '🅱️', - 'cl_button': '🆑', - 'cool_button': '🆒', - 'free_button': '🆓', - 'information': 'ℹ️', - 'id_button': '🆔', - 'circled_m': 'Ⓜ️', - 'new_button': '🆕', - 'ng_button': '🆖', - 'o_button': '🅾️', - 'ok_button': '🆗', - 'p_button': '🅿️', - 'sos_button': '🆘', - 'up_button': '🆙', - 'vs_button': '🆚', - 'japanese_here_button': '🈁', - 'japanese_service_charge_button': '🈂️', - 'japanese_monthly_amount_button': '🈷️', - 'japanese_not_free_of_charge_button': '🈶', - 'japanese_reserved_button': '🈯', - 'japanese_bargain_button': '🉐', - 'japanese_discount_button': '🈹', - 'japanese_free_of_charge_button': '🈚', - 'japanese_prohibited_button': '🈲', - 'japanese_acceptable_button': '🉑', - 'japanese_application_button': '🈸', - 'japanese_passing_grade_button': '🈴', - 'japanese_vacancy_button': '🈳', - 'japanese_congratulations_button': '㊗️', - 'japanese_secret_button': '㊙️', - 'japanese_open_for_business_button': '🈺', - 'japanese_no_vacancy_button': '🈵', - 'red_circle': '🔴', - 'orange_circle': '🟠', - 'yellow_circle': '🟡', - 'green_circle': '🟢', - 'blue_circle': '🔵', - 'purple_circle': '🟣', - 'brown_circle': '🟤', - 'black_circle': '⚫', - 'white_circle': '⚪', - 'red_square': '🟥', - 'orange_square': '🟧', - 'yellow_square': '🟨', - 'green_square': '🟩', - 'blue_square': '🟦', - 'purple_square': '🟪', - 'brown_square': '🟫', - 'black_large_square': '⬛', - 'white_large_square': '⬜', - 'black_medium_square': '◼️', - 'white_medium_square': '◻️', - 'black_medium_small_square': '◾', - 'white_medium_small_square': '◽', - 'black_small_square': '▪️', - 'white_small_square': '▫️', - 'large_orange_diamond': '🔶', - 'large_blue_diamond': '🔷', - 'small_orange_diamond': '🔸', - 'small_blue_diamond': '🔹', - 'red_triangle_pointed_up': '🔺', - 'red_triangle_pointed_down': '🔻', - 'diamond_with_a_dot': '💠', - 'radio_button': '🔘', - 'white_square_button': '🔳', - 'black_square_button': '🔲', - 'chequered_flag': '🏁', - 'triangular_flag': '🚩', - 'crossed_flags': '🎌', - 'black_flag': '🏴', - 'white_flag': '🏳️', - 'rainbow_flag': '🏳️‍🌈', - 'transgender_flag': '🏳️‍⚧️', - 'pirate_flag': '🏴‍☠️', - 'flag_ascension_island': '🇦🇨', - 'flag_andorra': '🇦🇩', - 'flag_united_arab_emirates': '🇦🇪', - 'flag_afghanistan': '🇦🇫', - 'flag_antigua_barbuda': '🇦🇬', - 'flag_anguilla': '🇦🇮', - 'flag_albania': '🇦🇱', - 'flag_armenia': '🇦🇲', - 'flag_angola': '🇦🇴', - 'flag_antarctica': '🇦🇶', - 'flag_argentina': '🇦🇷', - 'flag_american_samoa': '🇦🇸', - 'flag_austria': '🇦🇹', - 'flag_australia': '🇦🇺', - 'flag_aruba': '🇦🇼', - 'flag_aland_islands': '🇦🇽', - 'flag_azerbaijan': '🇦🇿', - 'flag_bosnia_herzegovina': '🇧🇦', - 'flag_barbados': '🇧🇧', - 'flag_bangladesh': '🇧🇩', - 'flag_belgium': '🇧🇪', - 'flag_burkina_faso': '🇧🇫', - 'flag_bulgaria': '🇧🇬', - 'flag_bahrain': '🇧🇭', - 'flag_burundi': '🇧🇮', - 'flag_benin': '🇧🇯', - 'flag_st_barthelemy': '🇧🇱', - 'flag_bermuda': '🇧🇲', - 'flag_brunei': '🇧🇳', - 'flag_bolivia': '🇧🇴', - 'flag_caribbean_netherlands': '🇧🇶', - 'flag_brazil': '🇧🇷', - 'flag_bahamas': '🇧🇸', - 'flag_bhutan': '🇧🇹', - 'flag_bouvet_island': '🇧🇻', - 'flag_botswana': '🇧🇼', - 'flag_belarus': '🇧🇾', - 'flag_belize': '🇧🇿', - 'flag_canada': '🇨🇦', - 'flag_cocos_islands': '🇨🇨', - 'flag_congo_kinshasa': '🇨🇩', - 'flag_central_african_republic': '🇨🇫', - 'flag_congo_brazzaville': '🇨🇬', - 'flag_switzerland': '🇨🇭', - 'flag_cote_d_ivoire': '🇨🇮', - 'flag_cook_islands': '🇨🇰', - 'flag_chile': '🇨🇱', - 'flag_cameroon': '🇨🇲', - 'flag_china': '🇨🇳', - 'flag_colombia': '🇨🇴', - 'flag_clipperton_island': '🇨🇵', - 'flag_costa_rica': '🇨🇷', - 'flag_cuba': '🇨🇺', - 'flag_cape_verde': '🇨🇻', - 'flag_curacao': '🇨🇼', - 'flag_christmas_island': '🇨🇽', - 'flag_cyprus': '🇨🇾', - 'flag_czechia': '🇨🇿', - 'flag_germany': '🇩🇪', - 'flag_diego_garcia': '🇩🇬', - 'flag_djibouti': '🇩🇯', - 'flag_denmark': '🇩🇰', - 'flag_dominica': '🇩🇲', - 'flag_dominican_republic': '🇩🇴', - 'flag_algeria': '🇩🇿', - 'flag_ceuta_melilla': '🇪🇦', - 'flag_ecuador': '🇪🇨', - 'flag_estonia': '🇪🇪', - 'flag_egypt': '🇪🇬', - 'flag_western_sahara': '🇪🇭', - 'flag_eritrea': '🇪🇷', - 'flag_spain': '🇪🇸', - 'flag_ethiopia': '🇪🇹', - 'flag_european_union': '🇪🇺', - 'flag_finland': '🇫🇮', - 'flag_fiji': '🇫🇯', - 'flag_falkland_islands': '🇫🇰', - 'flag_micronesia': '🇫🇲', - 'flag_faroe_islands': '🇫🇴', - 'flag_france': '🇫🇷', - 'flag_gabon': '🇬🇦', - 'flag_united_kingdom': '🇬🇧', - 'flag_grenada': '🇬🇩', - 'flag_georgia': '🇬🇪', - 'flag_french_guiana': '🇬🇫', - 'flag_guernsey': '🇬🇬', - 'flag_ghana': '🇬🇭', - 'flag_gibraltar': '🇬🇮', - 'flag_greenland': '🇬🇱', - 'flag_gambia': '🇬🇲', - 'flag_guinea': '🇬🇳', - 'flag_guadeloupe': '🇬🇵', - 'flag_equatorial_guinea': '🇬🇶', - 'flag_greece': '🇬🇷', - 'flag_south_georgia_south_sandwich_islands': '🇬🇸', - 'flag_guatemala': '🇬🇹', - 'flag_guam': '🇬🇺', - 'flag_guinea_bissau': '🇬🇼', - 'flag_guyana': '🇬🇾', - 'flag_hong_kong_sar_china': '🇭🇰', - 'flag_heard_mcdonald_islands': '🇭🇲', - 'flag_honduras': '🇭🇳', - 'flag_croatia': '🇭🇷', - 'flag_haiti': '🇭🇹', - 'flag_hungary': '🇭🇺', - 'flag_canary_islands': '🇮🇨', - 'flag_indonesia': '🇮🇩', - 'flag_ireland': '🇮🇪', - 'flag_israel': '🇮🇱', - 'flag_isle_of_man': '🇮🇲', - 'flag_india': '🇮🇳', - 'flag_british_indian_ocean_territory': '🇮🇴', - 'flag_iraq': '🇮🇶', - 'flag_iran': '🇮🇷', - 'flag_iceland': '🇮🇸', - 'flag_italy': '🇮🇹', - 'flag_jersey': '🇯🇪', - 'flag_jamaica': '🇯🇲', - 'flag_jordan': '🇯🇴', - 'flag_japan': '🇯🇵', - 'flag_kenya': '🇰🇪', - 'flag_kyrgyzstan': '🇰🇬', - 'flag_cambodia': '🇰🇭', - 'flag_kiribati': '🇰🇮', - 'flag_comoros': '🇰🇲', - 'flag_st_kitts_nevis': '🇰🇳', - 'flag_north_korea': '🇰🇵', - 'flag_south_korea': '🇰🇷', - 'flag_kuwait': '🇰🇼', - 'flag_cayman_islands': '🇰🇾', - 'flag_kazakhstan': '🇰🇿', - 'flag_laos': '🇱🇦', - 'flag_lebanon': '🇱🇧', - 'flag_st_lucia': '🇱🇨', - 'flag_liechtenstein': '🇱🇮', - 'flag_sri_lanka': '🇱🇰', - 'flag_liberia': '🇱🇷', - 'flag_lesotho': '🇱🇸', - 'flag_lithuania': '🇱🇹', - 'flag_luxembourg': '🇱🇺', - 'flag_latvia': '🇱🇻', - 'flag_libya': '🇱🇾', - 'flag_morocco': '🇲🇦', - 'flag_monaco': '🇲🇨', - 'flag_moldova': '🇲🇩', - 'flag_montenegro': '🇲🇪', - 'flag_st_martin': '🇲🇫', - 'flag_madagascar': '🇲🇬', - 'flag_marshall_islands': '🇲🇭', - 'flag_north_macedonia': '🇲🇰', - 'flag_mali': '🇲🇱', - 'flag_myanmar': '🇲🇲', - 'flag_mongolia': '🇲🇳', - 'flag_macao_sar_china': '🇲🇴', - 'flag_northern_mariana_islands': '🇲🇵', - 'flag_martinique': '🇲🇶', - 'flag_mauritania': '🇲🇷', - 'flag_montserrat': '🇲🇸', - 'flag_malta': '🇲🇹', - 'flag_mauritius': '🇲🇺', - 'flag_maldives': '🇲🇻', - 'flag_malawi': '🇲🇼', - 'flag_mexico': '🇲🇽', - 'flag_malaysia': '🇲🇾', - 'flag_mozambique': '🇲🇿', - 'flag_namibia': '🇳🇦', - 'flag_new_caledonia': '🇳🇨', - 'flag_niger': '🇳🇪', - 'flag_norfolk_island': '🇳🇫', - 'flag_nigeria': '🇳🇬', - 'flag_nicaragua': '🇳🇮', - 'flag_netherlands': '🇳🇱', - 'flag_norway': '🇳🇴', - 'flag_nepal': '🇳🇵', - 'flag_nauru': '🇳🇷', - 'flag_niue': '🇳🇺', - 'flag_new_zealand': '🇳🇿', - 'flag_oman': '🇴🇲', - 'flag_panama': '🇵🇦', - 'flag_peru': '🇵🇪', - 'flag_french_polynesia': '🇵🇫', - 'flag_papua_new_guinea': '🇵🇬', - 'flag_philippines': '🇵🇭', - 'flag_pakistan': '🇵🇰', - 'flag_poland': '🇵🇱', - 'flag_st_pierre_miquelon': '🇵🇲', - 'flag_pitcairn_islands': '🇵🇳', - 'flag_puerto_rico': '🇵🇷', - 'flag_palestinian_territories': '🇵🇸', - 'flag_portugal': '🇵🇹', - 'flag_palau': '🇵🇼', - 'flag_paraguay': '🇵🇾', - 'flag_qatar': '🇶🇦', - 'flag_reunion': '🇷🇪', - 'flag_romania': '🇷🇴', - 'flag_serbia': '🇷🇸', - 'flag_russia': '🇷🇺', - 'flag_rwanda': '🇷🇼', - 'flag_saudi_arabia': '🇸🇦', - 'flag_solomon_islands': '🇸🇧', - 'flag_seychelles': '🇸🇨', - 'flag_sudan': '🇸🇩', - 'flag_sweden': '🇸🇪', - 'flag_singapore': '🇸🇬', - 'flag_st_helena': '🇸🇭', - 'flag_slovenia': '🇸🇮', - 'flag_svalbard_jan_mayen': '🇸🇯', - 'flag_slovakia': '🇸🇰', - 'flag_sierra_leone': '🇸🇱', - 'flag_san_marino': '🇸🇲', - 'flag_senegal': '🇸🇳', - 'flag_somalia': '🇸🇴', - 'flag_suriname': '🇸🇷', - 'flag_south_sudan': '🇸🇸', - 'flag_sao_tome_principe': '🇸🇹', - 'flag_el_salvador': '🇸🇻', - 'flag_sint_maarten': '🇸🇽', - 'flag_syria': '🇸🇾', - 'flag_eswatini': '🇸🇿', - 'flag_tristan_da_cunha': '🇹🇦', - 'flag_turks_caicos_islands': '🇹🇨', - 'flag_chad': '🇹🇩', - 'flag_french_southern_territories': '🇹🇫', - 'flag_togo': '🇹🇬', - 'flag_thailand': '🇹🇭', - 'flag_tajikistan': '🇹🇯', - 'flag_tokelau': '🇹🇰', - 'flag_timor_leste': '🇹🇱', - 'flag_turkmenistan': '🇹🇲', - 'flag_tunisia': '🇹🇳', - 'flag_tonga': '🇹🇴', - 'flag_turkey': '🇹🇷', - 'flag_trinidad_tobago': '🇹🇹', - 'flag_tuvalu': '🇹🇻', - 'flag_taiwan': '🇹🇼', - 'flag_tanzania': '🇹🇿', - 'flag_ukraine': '🇺🇦', - 'flag_uganda': '🇺🇬', - 'flag_u_s_outlying_islands': '🇺🇲', - 'flag_united_nations': '🇺🇳', - 'flag_united_states': '🇺🇸', - 'flag_uruguay': '🇺🇾', - 'flag_uzbekistan': '🇺🇿', - 'flag_vatican_city': '🇻🇦', - 'flag_st_vincent_grenadines': '🇻🇨', - 'flag_venezuela': '🇻🇪', - 'flag_british_virgin_islands': '🇻🇬', - 'flag_u_s_virgin_islands': '🇻🇮', - 'flag_vietnam': '🇻🇳', - 'flag_vanuatu': '🇻🇺', - 'flag_wallis_futuna': '🇼🇫', - 'flag_samoa': '🇼🇸', - 'flag_kosovo': '🇽🇰', - 'flag_yemen': '🇾🇪', - 'flag_mayotte': '🇾🇹', - 'flag_south_africa': '🇿🇦', - 'flag_zambia': '🇿🇲', - 'flag_zimbabwe': '🇿🇼', - 'flag_england': '🏴󠁧󠁢󠁥󠁮󠁧󠁿', - 'flag_scotland': '🏴󠁧󠁢󠁳󠁣󠁴󠁿', - 'flag_wales': '🏴󠁧󠁢󠁷󠁬󠁳󠁿', -}; diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/extension_set.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/extension_set.dart deleted file mode 100644 index eadda7bc05abe..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/extension_set.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'block_parser.dart'; -import 'inline_parser.dart'; - -/// ExtensionSets provide a simple grouping mechanism for common Markdown -/// flavors. -/// -/// For example, the [gitHubFlavored] set of syntax extensions allows users to -/// output HTML from their Markdown in a similar fashion to GitHub's parsing. -class ExtensionSet { - ExtensionSet(this.blockSyntaxes, this.inlineSyntaxes); - - /// The [ExtensionSet.none] extension set renders Markdown similar to - /// [Markdown.pl]. - /// - /// However, this set does not render _exactly_ the same as Markdown.pl; - /// rather it is more-or-less the CommonMark standard of Markdown, without - /// fenced code blocks, or inline HTML. - /// - /// [Markdown.pl]: http://daringfireball.net/projects/markdown/syntax - static final ExtensionSet none = ExtensionSet([], []); - - /// The [commonMark] extension set is close to compliance with [CommonMark]. - /// - /// [CommonMark]: http://commonmark.org/ - static final ExtensionSet commonMark = - ExtensionSet([const FencedCodeBlockSyntax()], [InlineHtmlSyntax()]); - - /// The [gitHubWeb] extension set renders Markdown similarly to GitHub. - /// - /// This is different from the [gitHubFlavored] extension set in that GitHub - /// actually renders HTML different from straight [GitHub flavored Markdown]. - /// - /// (The only difference currently is that [gitHubWeb] renders headers with - /// linkable IDs.) - /// - /// [GitHub flavored Markdown]: https://github.github.com/gfm/ - static final ExtensionSet gitHubWeb = ExtensionSet([ - const FencedCodeBlockSyntax(), - const HeaderWithIdSyntax(), - const SetextHeaderWithIdSyntax(), - const TableSyntax() - ], [ - InlineHtmlSyntax(), - StrikethroughSyntax(), - EmojiSyntax(), - AutolinkExtensionSyntax(), - ]); - - /// The [gitHubFlavored] extension set is close to compliance with the [GitHub - /// flavored Markdown spec]. - /// - /// [GitHub flavored Markdown]: https://github.github.com/gfm/ - static final ExtensionSet gitHubFlavored = ExtensionSet([ - const FencedCodeBlockSyntax(), - const TableSyntax() - ], [ - InlineHtmlSyntax(), - StrikethroughSyntax(), - AutolinkExtensionSyntax(), - ]); - - final List blockSyntaxes; - final List inlineSyntaxes; -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/html_renderer.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/html_renderer.dart deleted file mode 100644 index d6630e297c609..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/html_renderer.dart +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file -// for details. All rights reserved. Use of this source code is governed by a -// BSD-style license that can be found in the LICENSE file. - -import 'ast.dart'; -import 'block_parser.dart'; -import 'document.dart'; -import 'extension_set.dart'; -import 'inline_parser.dart'; - -/// Converts the given string of Markdown to HTML. -String markdownToHtml(String markdown, - {Iterable? blockSyntaxes, - Iterable? inlineSyntaxes, - ExtensionSet? extensionSet, - Resolver? linkResolver, - Resolver? imageLinkResolver, - bool inlineOnly = false}) { - final document = Document( - blockSyntaxes: blockSyntaxes, - inlineSyntaxes: inlineSyntaxes, - extensionSet: extensionSet, - linkResolver: linkResolver, - imageLinkResolver: imageLinkResolver); - - if (inlineOnly) { - return renderToHtml(document.parseInline(markdown)!); - } - - // Replace windows line endings with unix line endings, and split. - final lines = markdown.replaceAll('\r\n', '\n').split('\n'); - - return '${renderToHtml(document.parseLines(lines))}\n'; -} - -/// Renders [nodes] to HTML. -String renderToHtml(List nodes) => HtmlRenderer().render(nodes); - -/// Translates a parsed AST to HTML. -class HtmlRenderer implements NodeVisitor { - HtmlRenderer(); - - static final _blockTags = RegExp('blockquote|h1|h2|h3|h4|h5|h6|hr|p|pre'); - - late StringBuffer buffer; - late Set uniqueIds; - - String render(List nodes) { - buffer = StringBuffer(); - uniqueIds = {}; - - for (final node in nodes) { - node.accept(this); - } - - return buffer.toString(); - } - - @override - void visitText(Text text) { - buffer.write(text.text); - } - - @override - bool visitElementBefore(Element element) { - // Hackish. Separate block-level elements with newlines. - if (buffer.isNotEmpty && _blockTags.firstMatch(element.tag) != null) { - buffer.write('\n'); - } - - buffer.write('<${element.tag}'); - - // Sort the keys so that we generate stable output. - final attributeNames = element.attributes.keys.toList() - ..sort((a, b) => a.compareTo(b)); - - for (final name in attributeNames) { - buffer.write(' $name="${element.attributes[name]}"'); - } - - // attach header anchor ids generated from text - if (element.generatedId != null) { - buffer.write(' id="${uniquifyId(element.generatedId!)}"'); - } - - if (element.isEmpty) { - // Empty element like
. - buffer.write(' />'); - - if (element.tag == 'br') { - buffer.write('\n'); - } - - return false; - } else { - buffer.write('>'); - return true; - } - } - - @override - void visitElementAfter(Element element) { - buffer.write(''); - } - - /// Uniquifies an id generated from text. - String uniquifyId(String id) { - if (!uniqueIds.contains(id)) { - uniqueIds.add(id); - return id; - } - - var suffix = 2; - var suffixedId = '$id-$suffix'; - while (uniqueIds.contains(suffixedId)) { - suffixedId = '$id-${suffix++}'; - } - uniqueIds.add(suffixedId); - return suffixedId; - } -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/inline_parser.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/inline_parser.dart deleted file mode 100644 index ce0f11302ec2e..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/inline_parser.dart +++ /dev/null @@ -1,1270 +0,0 @@ -// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file -// for details. All rights reserved. Use of this source code is governed by a -// BSD-style license that can be found in the LICENSE file. - -import 'package:charcode/charcode.dart'; - -import 'ast.dart'; -import 'document.dart'; -import 'emojis.dart'; -import 'util.dart'; - -/// Maintains the internal state needed to parse inline span elements in -/// Markdown. -class InlineParser { - InlineParser(this.source, this.document) : _stack = [] { - // User specified syntaxes are the first syntaxes to be evaluated. - syntaxes.addAll(document.inlineSyntaxes); - - final documentHasCustomInlineSyntaxes = document.inlineSyntaxes - .any((s) => !document.extensionSet.inlineSyntaxes.contains(s)); - - // This first RegExp matches plain text to accelerate parsing. It's written - // so that it does not match any prefix of any following syntaxes. Most - // Markdown is plain text, so it's faster to match one RegExp per 'word' - // rather than fail to match all the following RegExps at each non-syntax - // character position. - if (documentHasCustomInlineSyntaxes) { - // We should be less aggressive in blowing past "words". - syntaxes.add(TextSyntax(r'[A-Za-z0-9]+(?=\s)')); - } else { - syntaxes.add(TextSyntax(r'[ \tA-Za-z0-9]*[A-Za-z0-9](?=\s)')); - } - - syntaxes - ..addAll(_defaultSyntaxes) - // Custom link resolvers go after the generic text syntax. - ..insertAll(1, [ - LinkSyntax(linkResolver: document.linkResolver), - ImageSyntax(linkResolver: document.imageLinkResolver) - ]); - } - - static final List _defaultSyntaxes = - List.unmodifiable([ - EmailAutolinkSyntax(), - AutolinkSyntax(), - LineBreakSyntax(), - LinkSyntax(), - ImageSyntax(), - // Allow any punctuation to be escaped. - EscapeSyntax(), - // "*" surrounded by spaces is left alone. - TextSyntax(r' \* '), - // "_" surrounded by spaces is left alone. - TextSyntax(r' _ '), - // Parse "**strong**" and "*emphasis*" tags. - TagSyntax(r'\*+', requiresDelimiterRun: true), - // Parse "__strong__" and "_emphasis_" tags. - TagSyntax(r'_+', requiresDelimiterRun: true), - CodeSyntax(), - // We will add the LinkSyntax once we know about the specific link resolver. - ]); - - /// The string of Markdown being parsed. - final String source; - - /// The Markdown document this parser is parsing. - final Document document; - - final List syntaxes = []; - - /// The current read position. - int pos = 0; - - /// Starting position of the last unconsumed text. - int start = 0; - - final List _stack; - - List? parse() { - // Make a fake top tag to hold the results. - _stack.add(TagState(0, 0, null, null)); - - while (!isDone) { - // See if any of the current tags on the stack match. This takes - // priority over other possible matches. - if (_stack.reversed - .any((state) => state.syntax != null && state.tryMatch(this))) { - continue; - } - - // See if the current text matches any defined markdown syntax. - if (syntaxes.any((syntax) => syntax.tryMatch(this))) { - continue; - } - - // If we got here, it's just text. - advanceBy(1); - } - - // Unwind any unmatched tags and get the results. - return _stack[0].close(this, null); - } - - int charAt(int index) => source.codeUnitAt(index); - - void writeText() { - writeTextRange(start, pos); - start = pos; - } - - void writeTextRange(int start, int end) { - if (end <= start) { - return; - } - - final text = source.substring(start, end); - final nodes = _stack.last.children; - - // If the previous node is text too, just append. - if (nodes.isNotEmpty && nodes.last is Text) { - final textNode = nodes.last as Text; - nodes[nodes.length - 1] = Text('${textNode.text}$text'); - } else { - nodes.add(Text(text)); - } - } - - /// Add [node] to the last [TagState] on the stack. - void addNode(Node node) { - _stack.last.children.add(node); - } - - /// Push [state] onto the stack of [TagState]s. - void openTag(TagState state) => _stack.add(state); - - bool get isDone => pos == source.length; - - void advanceBy(int length) { - pos += length; - } - - void consume(int length) { - pos += length; - start = pos; - } -} - -/// Represents one kind of Markdown tag that can be parsed. -abstract class InlineSyntax { - InlineSyntax(String pattern) : pattern = RegExp(pattern, multiLine: true); - - final RegExp pattern; - - /// Tries to match at the parser's current position. - /// - /// The parser's position can be overridden with [startMatchPos]. - /// Returns whether or not the pattern successfully matched. - bool tryMatch(InlineParser parser, [int? startMatchPos]) { - startMatchPos ??= parser.pos; - - final startMatch = pattern.matchAsPrefix(parser.source, startMatchPos); - if (startMatch == null) { - return false; - } - - // Write any existing plain text up to this point. - parser.writeText(); - - if (onMatch(parser, startMatch)) { - parser.consume(startMatch[0]!.length); - } - return true; - } - - /// Processes [match], adding nodes to [parser] and possibly advancing - /// [parser]. - /// - /// Returns whether the caller should advance [parser] by `match[0].length`. - bool onMatch(InlineParser parser, Match match); -} - -/// Represents a hard line break. -class LineBreakSyntax extends InlineSyntax { - LineBreakSyntax() : super(r'(?:\\| +)\n'); - - /// Create a void
element. - @override - bool onMatch(InlineParser parser, Match match) { - parser.addNode(Element.empty('br')); - return true; - } -} - -/// Matches stuff that should just be passed through as straight text. -class TextSyntax extends InlineSyntax { - TextSyntax(String pattern, {String? sub}) - : substitute = sub, - super(pattern); - - final String? substitute; - - @override - bool onMatch(InlineParser parser, Match match) { - if (substitute == null) { - // Just use the original matched text. - parser.advanceBy(match[0]!.length); - return false; - } - - // Insert the substitution. - parser.addNode(Text(substitute!)); - return true; - } -} - -/// Escape punctuation preceded by a backslash. -class EscapeSyntax extends InlineSyntax { - EscapeSyntax() : super(r'''\\[!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~]'''); - - @override - bool onMatch(InlineParser parser, Match match) { - // Insert the substitution. - parser.addNode(Text(match[0]![1])); - return true; - } -} - -/// Leave inline HTML tags alone, from -/// [CommonMark 0.28](http://spec.commonmark.org/0.28/#raw-html). -/// -/// This is not actually a good definition (nor CommonMark's) of an HTML tag, -/// but it is fast. It will leave text like `]*)?>'); -} - -/// Matches autolinks like ``. -/// -/// See . -class EmailAutolinkSyntax extends InlineSyntax { - EmailAutolinkSyntax() : super('<($_email)>'); - - static const _email = - r'''[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}''' - r'''[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*'''; - - @override - bool onMatch(InlineParser parser, Match match) { - final url = match[1]!; - final anchor = Element.text('a', escapeHtml(url)); - anchor.attributes['href'] = Uri.encodeFull('mailto:$url'); - parser.addNode(anchor); - - return true; - } -} - -/// Matches autolinks like ``. -class AutolinkSyntax extends InlineSyntax { - AutolinkSyntax() : super(r'<(([a-zA-Z][a-zA-Z\-\+\.]+):(?://)?[^\s>]*)>'); - - @override - bool onMatch(InlineParser parser, Match match) { - final url = match[1]!; - final anchor = Element.text('a', escapeHtml(url)); - anchor.attributes['href'] = Uri.encodeFull(url); - parser.addNode(anchor); - - return true; - } -} - -/// Matches autolinks like `http://foo.com`. -class AutolinkExtensionSyntax extends InlineSyntax { - AutolinkExtensionSyntax() : super('$start(($scheme)($domain)($path))'); - - /// Broken up parts of the autolink regex for reusability and readability - - // Autolinks can only come at the beginning of a line, after whitespace, or - // any of the delimiting characters *, _, ~, and (. - static const start = r'(?:^|[\s*_~(>])'; - // An extended url autolink will be recognized when one of the schemes - // http://, https://, or ftp://, followed by a valid domain - static const scheme = r'(?:(?:https?|ftp):\/\/|www\.)'; - // A valid domain consists of alphanumeric characters, underscores (_), - // hyphens (-) and periods (.). There must be at least one period, and no - // underscores may be present in the last two segments of the domain. - static const domainPart = r'\w\-'; - static const domain = '[$domainPart][$domainPart.]+'; - // A valid domain consists of alphanumeric characters, underscores (_), - // hyphens (-) and periods (.). - static const path = r'[^\s<]*'; - // Trailing punctuation (specifically, ?, !, ., ,, :, *, _, and ~) will not - // be considered part of the autolink - static const truncatingPunctuationPositive = r'[?!.,:*_~]'; - - static final regExpTrailingPunc = - RegExp('$truncatingPunctuationPositive*' r'$'); - static final regExpEndsWithColon = RegExp(r'\&[a-zA-Z0-9]+;$'); - static final regExpWhiteSpace = RegExp(r'\s'); - - @override - bool tryMatch(InlineParser parser, [int? startMatchPos]) { - return super.tryMatch(parser, parser.pos > 0 ? parser.pos - 1 : 0); - } - - @override - bool onMatch(InlineParser parser, Match match) { - var url = match[1]!; - var href = url; - var matchLength = url.length; - - if (url[0] == '>' || url.startsWith(regExpWhiteSpace)) { - url = url.substring(1, url.length - 1); - href = href.substring(1, href.length - 1); - parser.pos++; - matchLength--; - } - - // Prevent accidental standard autolink matches - if (url.endsWith('>') && parser.source[parser.pos - 1] == '<') { - return false; - } - - // When an autolink ends in ), we scan the entire autolink for the total - // number of parentheses. If there is a greater number of closing - // parentheses than opening ones, we don’t consider the last character - // part of the autolink, in order to facilitate including an autolink - // inside a parenthesis: - // https://github.github.com/gfm/#example-600 - if (url.endsWith(')')) { - final opening = _countChars(url, '('); - final closing = _countChars(url, ')'); - - if (closing > opening) { - url = url.substring(0, url.length - 1); - href = href.substring(0, href.length - 1); - matchLength--; - } - } - - // Trailing punctuation (specifically, ?, !, ., ,, :, *, _, and ~) will - // not be considered part of the autolink, though they may be included - // in the interior of the link: - // https://github.github.com/gfm/#example-599 - final trailingPunc = regExpTrailingPunc.firstMatch(url); - if (trailingPunc != null) { - url = url.substring(0, url.length - trailingPunc[0]!.length); - href = href.substring(0, href.length - trailingPunc[0]!.length); - matchLength -= trailingPunc[0]!.length; - } - - // If an autolink ends in a semicolon (;), we check to see if it appears - // to resemble an - // [entity reference](https://github.github.com/gfm/#entity-references); - // if the preceding text is & followed by one or more alphanumeric - // characters. If so, it is excluded from the autolink: - // https://github.github.com/gfm/#example-602 - if (url.endsWith(';')) { - final entityRef = regExpEndsWithColon.firstMatch(url); - if (entityRef != null) { - // Strip out HTML entity reference - url = url.substring(0, url.length - entityRef[0]!.length); - href = href.substring(0, href.length - entityRef[0]!.length); - matchLength -= entityRef[0]!.length; - } - } - - // The scheme http will be inserted automatically - if (!href.startsWith('http://') && - !href.startsWith('https://') && - !href.startsWith('ftp://')) { - href = 'http://$href'; - } - - final anchor = Element.text('a', escapeHtml(url)); - anchor.attributes['href'] = Uri.encodeFull(href); - parser - ..addNode(anchor) - ..consume(matchLength); - return false; - } - - int _countChars(String input, String char) { - var count = 0; - - for (var i = 0; i < input.length; i++) { - if (input[i] == char) { - count++; - } - } - - return count; - } -} - -class DelimiterRun { - DelimiterRun._( - {this.char, - this.length, - this.isLeftFlanking, - this.isRightFlanking, - this.isPrecededByPunctuation, - this.isFollowedByPunctuation}); - - static const String punctuation = r'''!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~'''; - // TODO(srawlins): Unicode whitespace - static const String whitespace = ' \t\r\n'; - - final int? char; - final int? length; - final bool? isLeftFlanking; - final bool? isRightFlanking; - final bool? isPrecededByPunctuation; - final bool? isFollowedByPunctuation; - - // ignore: prefer_constructors_over_static_methods - static DelimiterRun? tryParse(InlineParser parser, int runStart, int runEnd) { - bool leftFlanking, - rightFlanking, - precededByPunctuation, - followedByPunctuation; - String preceding, following; - if (runStart == 0) { - rightFlanking = false; - preceding = '\n'; - } else { - preceding = parser.source.substring(runStart - 1, runStart); - } - precededByPunctuation = punctuation.contains(preceding); - - if (runEnd == parser.source.length - 1) { - leftFlanking = false; - following = '\n'; - } else { - following = parser.source.substring(runEnd + 1, runEnd + 2); - } - followedByPunctuation = punctuation.contains(following); - - // http://spec.commonmark.org/0.28/#left-flanking-delimiter-run - if (whitespace.contains(following)) { - leftFlanking = false; - } else { - leftFlanking = !followedByPunctuation || - whitespace.contains(preceding) || - precededByPunctuation; - } - - // http://spec.commonmark.org/0.28/#right-flanking-delimiter-run - if (whitespace.contains(preceding)) { - rightFlanking = false; - } else { - rightFlanking = !precededByPunctuation || - whitespace.contains(following) || - followedByPunctuation; - } - - if (!leftFlanking && !rightFlanking) { - // Could not parse a delimiter run. - return null; - } - - return DelimiterRun._( - char: parser.charAt(runStart), - length: runEnd - runStart + 1, - isLeftFlanking: leftFlanking, - isRightFlanking: rightFlanking, - isPrecededByPunctuation: precededByPunctuation, - isFollowedByPunctuation: followedByPunctuation); - } - - @override - String toString() => - ''; - - // Whether a delimiter in this run can open emphasis or strong emphasis. - bool get canOpen => - isLeftFlanking! && - (char == $asterisk || !isRightFlanking! || isPrecededByPunctuation!); - - // Whether a delimiter in this run can close emphasis or strong emphasis. - bool get canClose => - isRightFlanking! && - (char == $asterisk || !isLeftFlanking! || isFollowedByPunctuation!); -} - -/// Matches syntax that has a pair of tags and becomes an element, like `*` for -/// ``. Allows nested tags. -class TagSyntax extends InlineSyntax { - TagSyntax(String pattern, {String? end, this.requiresDelimiterRun = false}) - : endPattern = RegExp((end != null) ? end : pattern, multiLine: true), - super(pattern); - - final RegExp endPattern; - - /// Whether this is parsed according to the same nesting rules as [emphasis - /// delimiters][]. - /// - /// [emphasis delimiters]: http://spec.commonmark.org/0.28/#can-open-emphasis - final bool requiresDelimiterRun; - - @override - bool onMatch(InlineParser parser, Match match) { - final runLength = match.group(0)!.length; - final matchStart = parser.pos; - final matchEnd = parser.pos + runLength - 1; - if (!requiresDelimiterRun) { - parser.openTag(TagState(parser.pos, matchEnd + 1, this, null)); - return true; - } - - final delimiterRun = DelimiterRun.tryParse(parser, matchStart, matchEnd); - if (delimiterRun != null && delimiterRun.canOpen) { - parser.openTag(TagState(parser.pos, matchEnd + 1, this, delimiterRun)); - return true; - } else { - parser.advanceBy(runLength); - return false; - } - } - - bool onMatchEnd(InlineParser parser, Match match, TagState state) { - final runLength = match.group(0)!.length; - final matchStart = parser.pos; - final matchEnd = parser.pos + runLength - 1; - final openingRunLength = state.endPos - state.startPos; - final delimiterRun = DelimiterRun.tryParse(parser, matchStart, matchEnd); - - if (openingRunLength == 1 && runLength == 1) { - parser.addNode(Element('em', state.children)); - } else if (openingRunLength == 1 && runLength > 1) { - parser - ..addNode(Element('em', state.children)) - ..pos = parser.pos - (runLength - 1) - ..start = parser.pos; - } else if (openingRunLength > 1 && runLength == 1) { - parser - ..openTag( - TagState(state.startPos, state.endPos - 1, this, delimiterRun)) - ..addNode(Element('em', state.children)); - } else if (openingRunLength == 2 && runLength == 2) { - parser.addNode(Element('strong', state.children)); - } else if (openingRunLength == 2 && runLength > 2) { - parser - ..addNode(Element('strong', state.children)) - ..pos = parser.pos - (runLength - 2) - ..start = parser.pos; - } else if (openingRunLength > 2 && runLength == 2) { - parser - ..openTag( - TagState(state.startPos, state.endPos - 2, this, delimiterRun)) - ..addNode(Element('strong', state.children)); - } else if (openingRunLength > 2 && runLength > 2) { - parser - ..openTag( - TagState(state.startPos, state.endPos - 2, this, delimiterRun)) - ..addNode(Element('strong', state.children)) - ..pos = parser.pos - (runLength - 2) - ..start = parser.pos; - } - - return true; - } -} - -/// Matches strikethrough syntax according to the GFM spec. -class StrikethroughSyntax extends TagSyntax { - StrikethroughSyntax() : super('~+', requiresDelimiterRun: true); - - @override - bool onMatchEnd(InlineParser parser, Match match, TagState state) { - final runLength = match.group(0)!.length; - final matchStart = parser.pos; - final matchEnd = parser.pos + runLength - 1; - final delimiterRun = DelimiterRun.tryParse(parser, matchStart, matchEnd)!; - if (!delimiterRun.isRightFlanking!) { - return false; - } - - parser.addNode(Element('del', state.children)); - return true; - } -} - -/// Matches links like `[blah][label]` and `[blah](url)`. -class LinkSyntax extends TagSyntax { - LinkSyntax({Resolver? linkResolver, String pattern = r'\['}) - : linkResolver = (linkResolver ?? (_, [__]) => null), - super(pattern, end: r'\]'); - - static final _entirelyWhitespacePattern = RegExp(r'^\s*$'); - - final Resolver linkResolver; - - // The pending [TagState]s, all together, are "active" or "inactive" based on - // whether a link element has just been parsed. - // - // Links cannot be nested, so we must "deactivate" any pending ones. For - // example, take the following text: - // - // Text [link and [more](links)](links). - // - // Once we have parsed `Text [`, there is one (pending) link in the state - // stack. It is, by default, active. Once we parse the next possible link, - // `[more](links)`, as a real link, we must deactivate the pending links (just - // the one, in this case). - var _pendingStatesAreActive = true; - - @override - bool onMatch(InlineParser parser, Match match) { - final matched = super.onMatch(parser, match); - if (!matched) { - return false; - } - - _pendingStatesAreActive = true; - - return true; - } - - @override - bool onMatchEnd(InlineParser parser, Match match, TagState state) { - if (!_pendingStatesAreActive) { - return false; - } - - final text = parser.source.substring(state.endPos, parser.pos); - // The current character is the `]` that closed the link text. Examine the - // next character, to determine what type of link we might have (a '(' - // means a possible inline link; otherwise a possible reference link). - if (parser.pos + 1 >= parser.source.length) { - // In this case, the Markdown document may have ended with a shortcut - // reference link. - - return _tryAddReferenceLink(parser, state, text); - } - // Peek at the next character; don't advance, so as to avoid later stepping - // backward. - final char = parser.charAt(parser.pos + 1); - - if (char == $lparen) { - // Maybe an inline link, like `[text](destination)`. - parser.advanceBy(1); - final leftParenIndex = parser.pos; - final inlineLink = _parseInlineLink(parser); - if (inlineLink != null) { - return _tryAddInlineLink(parser, state, inlineLink); - } - - // Reset the parser position. - parser - ..pos = leftParenIndex - - // At this point, we've matched `[...](`, but that `(` did not pan out - // to be an inline link. We must now check if `[...]` is simply a - // shortcut reference link. - ..advanceBy(-1); - return _tryAddReferenceLink(parser, state, text); - } - - if (char == $lbracket) { - parser.advanceBy(1); - // At this point, we've matched `[...][`. Maybe a *full* reference link, - // like `[foo][bar]` or a *collapsed* reference link, like `[foo][]`. - if (parser.pos + 1 < parser.source.length && - parser.charAt(parser.pos + 1) == $rbracket) { - // That opening `[` is not actually part of the link. Maybe a - // *shortcut* reference link (followed by a `[`). - parser.advanceBy(1); - return _tryAddReferenceLink(parser, state, text); - } - final label = _parseReferenceLinkLabel(parser); - if (label != null) { - return _tryAddReferenceLink(parser, state, label); - } - return false; - } - - // The link text (inside `[...]`) was not followed with a opening `(` nor - // an opening `[`. Perhaps just a simple shortcut reference link (`[...]`). - - return _tryAddReferenceLink(parser, state, text); - } - - /// Resolve a possible reference link. - /// - /// Uses [linkReferences], [linkResolver], and [_createNode] to try to - /// resolve [label] and [state] into a [Node]. If [label] is defined in - /// [linkReferences] or can be resolved by [linkResolver], returns a [Node] - /// that links to the resolved URL. - /// - /// Otherwise, returns `null`. - /// - /// [label] does not need to be normalized. - Node? _resolveReferenceLink( - String label, TagState state, Map linkReferences) { - final normalizedLabel = label.toLowerCase(); - final linkReference = linkReferences[normalizedLabel]; - if (linkReference != null) { - return _createNode(state, linkReference.destination, linkReference.title); - } else { - // This link has no reference definition. But we allow users of the - // library to specify a custom resolver function ([linkResolver]) that - // may choose to handle this. Otherwise, it's just treated as plain - // text. - - // Normally, label text does not get parsed as inline Markdown. However, - // for the benefit of the link resolver, we need to at least escape - // brackets, so that, e.g. a link resolver can receive `[\[\]]` as `[]`. - return linkResolver(label - .replaceAll(r'\\', r'\') - .replaceAll(r'\[', '[') - .replaceAll(r'\]', ']')); - } - } - - /// Create the node represented by a Markdown link. - Node _createNode(TagState state, String destination, String? title) { - final element = Element('a', state.children); - element.attributes['href'] = escapeAttribute(destination); - if (title != null && title.isNotEmpty) { - element.attributes['title'] = escapeAttribute(title); - } - return element; - } - - // Add a reference link node to [parser]'s AST. - // - // Returns whether the link was added successfully. - bool _tryAddReferenceLink(InlineParser parser, TagState state, String label) { - final element = - _resolveReferenceLink(label, state, parser.document.linkReferences); - if (element == null) { - return false; - } - parser - ..addNode(element) - ..start = parser.pos; - _pendingStatesAreActive = false; - return true; - } - - // Add an inline link node to [parser]'s AST. - // - // Returns whether the link was added successfully. - bool _tryAddInlineLink(InlineParser parser, TagState state, InlineLink link) { - final element = _createNode(state, link.destination, link.title); - parser - ..addNode(element) - ..start = parser.pos; - _pendingStatesAreActive = false; - return true; - } - - /// Parse a reference link label at the current position. - /// - /// Specifically, [parser.pos] is expected to be pointing at the `[` which - /// opens the link label. - /// - /// Returns the label if it could be parsed, or `null` if not. - String? _parseReferenceLinkLabel(InlineParser parser) { - // Walk past the opening `[`. - parser.advanceBy(1); - if (parser.isDone) { - return null; - } - - final buffer = StringBuffer(); - while (true) { - final char = parser.charAt(parser.pos); - if (char == $backslash) { - parser.advanceBy(1); - final next = parser.charAt(parser.pos); - if (next != $backslash && next != $rbracket) { - buffer.writeCharCode(char); - } - buffer.writeCharCode(next); - } else if (char == $rbracket) { - break; - } else { - buffer.writeCharCode(char); - } - parser.advanceBy(1); - if (parser.isDone) { - return null; - } - // TODO(srawlins): only check 999 characters, for performance reasons? - } - - final label = buffer.toString(); - - // A link label must contain at least one non-whitespace character. - if (_entirelyWhitespacePattern.hasMatch(label)) { - return null; - } - - return label; - } - - /// Parse an inline [InlineLink] at the current position. - /// - /// At this point, we have parsed a link's (or image's) opening `[`, and then - /// a matching closing `]`, and [parser.pos] is pointing at an opening `(`. - /// This method will then attempt to parse a link destination wrapped in `<>`, - /// such as `()`, or a bare link destination, such as - /// `(http://url)`, or a link destination with a title, such as - /// `(http://url "title")`. - /// - /// Returns the [InlineLink] if one was parsed, or `null` if not. - InlineLink? _parseInlineLink(InlineParser parser) { - // Start walking to the character just after the opening `(`. - parser.advanceBy(1); - - _moveThroughWhitespace(parser); - if (parser.isDone) { - return null; // EOF. Not a link. - } - - if (parser.charAt(parser.pos) == $lt) { - // Maybe a `<...>`-enclosed link destination. - return _parseInlineBracketedLink(parser); - } else { - return _parseInlineBareDestinationLink(parser); - } - } - - /// Parse an inline link with a bracketed destination (a destination wrapped - /// in `<...>`). The current position of the parser must be the first - /// character of the destination. - InlineLink? _parseInlineBracketedLink(InlineParser parser) { - parser.advanceBy(1); - - final buffer = StringBuffer(); - while (true) { - final char = parser.charAt(parser.pos); - if (char == $backslash) { - parser.advanceBy(1); - final next = parser.charAt(parser.pos); - if (char == $space || char == $lf || char == $cr || char == $ff) { - // Not a link (no whitespace allowed within `<...>`). - return null; - } - // TODO: Follow the backslash spec better here. - // http://spec.commonmark.org/0.28/#backslash-escapes - if (next != $backslash && next != $gt) { - buffer.writeCharCode(char); - } - buffer.writeCharCode(next); - } else if (char == $space || char == $lf || char == $cr || char == $ff) { - // Not a link (no whitespace allowed within `<...>`). - return null; - } else if (char == $gt) { - break; - } else { - buffer.writeCharCode(char); - } - parser.advanceBy(1); - if (parser.isDone) { - return null; - } - } - final destination = buffer.toString(); - - parser.advanceBy(1); - final char = parser.charAt(parser.pos); - if (char == $space || char == $lf || char == $cr || char == $ff) { - final title = _parseTitle(parser); - if (title == null && parser.charAt(parser.pos) != $rparen) { - // This looked like an inline link, until we found this $space - // followed by mystery characters; no longer a link. - return null; - } - return InlineLink(destination, title: title); - } else if (char == $rparen) { - return InlineLink(destination); - } else { - // We parsed something like `[foo](X`. Not a link. - return null; - } - } - - /// Parse an inline link with a "bare" destination (a destination _not_ - /// wrapped in `<...>`). The current position of the parser must be the first - /// character of the destination. - InlineLink? _parseInlineBareDestinationLink(InlineParser parser) { - // According to - // [CommonMark](http://spec.commonmark.org/0.28/#link-destination): - // - // > A link destination consists of [...] a nonempty sequence of - // > characters [...], and includes parentheses only if (a) they are - // > backslash-escaped or (b) they are part of a balanced pair of - // > unescaped parentheses. - // - // We need to count the open parens. We start with 1 for the paren that - // opened the destination. - var parenCount = 1; - final buffer = StringBuffer(); - - while (true) { - final char = parser.charAt(parser.pos); - switch (char) { - case $backslash: - parser.advanceBy(1); - if (parser.isDone) { - return null; // EOF. Not a link. - } - - final next = parser.charAt(parser.pos); - // Parentheses may be escaped. - // - // http://spec.commonmark.org/0.28/#example-467 - if (next != $backslash && next != $lparen && next != $rparen) { - buffer.writeCharCode(char); - } - buffer.writeCharCode(next); - break; - - case $space: - case $lf: - case $cr: - case $ff: - final destination = buffer.toString(); - final title = _parseTitle(parser); - if (title == null && parser.charAt(parser.pos) != $rparen) { - // This looked like an inline link, until we found this $space - // followed by mystery characters; no longer a link. - return null; - } - // [_parseTitle] made sure the title was followed by a closing `)` - // (but it's up to the code here to examine the balance of - // parentheses). - parenCount--; - if (parenCount == 0) { - return InlineLink(destination, title: title); - } - break; - - case $lparen: - parenCount++; - buffer.writeCharCode(char); - break; - - case $rparen: - parenCount--; - // ignore: invariant_booleans - if (parenCount == 0) { - final destination = buffer.toString(); - return InlineLink(destination); - } - buffer.writeCharCode(char); - break; - - default: - buffer.writeCharCode(char); - } - parser.advanceBy(1); - if (parser.isDone) { - return null; // EOF. Not a link. - } - } - } - - // Walk the parser forward through any whitespace. - void _moveThroughWhitespace(InlineParser parser) { - while (true) { - final char = parser.charAt(parser.pos); - if (char != $space && - char != $tab && - char != $lf && - char != $vt && - char != $cr && - char != $ff) { - return; - } - parser.advanceBy(1); - if (parser.isDone) { - return; - } - } - } - - // Parse a link title in [parser] at it's current position. The parser's - // current position should be a whitespace character that followed a link - // destination. - String? _parseTitle(InlineParser parser) { - _moveThroughWhitespace(parser); - if (parser.isDone) { - return null; - } - - // The whitespace should be followed by a title delimiter. - final delimiter = parser.charAt(parser.pos); - if (delimiter != $apostrophe && - delimiter != $quote && - delimiter != $lparen) { - return null; - } - - final closeDelimiter = delimiter == $lparen ? $rparen : delimiter; - parser.advanceBy(1); - - // Now we look for an un-escaped closing delimiter. - final buffer = StringBuffer(); - while (true) { - final char = parser.charAt(parser.pos); - if (char == $backslash) { - parser.advanceBy(1); - final next = parser.charAt(parser.pos); - if (next != $backslash && next != closeDelimiter) { - buffer.writeCharCode(char); - } - buffer.writeCharCode(next); - } else if (char == closeDelimiter) { - break; - } else { - buffer.writeCharCode(char); - } - parser.advanceBy(1); - if (parser.isDone) { - return null; - } - } - final title = buffer.toString(); - - // Advance past the closing delimiter. - parser.advanceBy(1); - if (parser.isDone) { - return null; - } - _moveThroughWhitespace(parser); - if (parser.isDone) { - return null; - } - if (parser.charAt(parser.pos) != $rparen) { - return null; - } - return title; - } -} - -/// Matches images like `![alternate text](url "optional title")` and -/// `![alternate text][label]`. -class ImageSyntax extends LinkSyntax { - ImageSyntax({Resolver? linkResolver}) - : super(linkResolver: linkResolver, pattern: r'!\['); - - @override - Node _createNode(TagState state, String destination, String? title) { - final element = Element.empty('img'); - element.attributes['src'] = escapeHtml(destination); - element.attributes['alt'] = state.textContent; - if (title != null && title.isNotEmpty) { - element.attributes['title'] = escapeAttribute(title); - } - return element; - } - - // Add an image node to [parser]'s AST. - // - // If [label] is present, the potential image is treated as a reference image. - // Otherwise, it is treated as an inline image. - // - // Returns whether the image was added successfully. - @override - bool _tryAddReferenceLink(InlineParser parser, TagState state, String label) { - final element = - _resolveReferenceLink(label, state, parser.document.linkReferences); - if (element == null) { - return false; - } - parser - ..addNode(element) - ..start = parser.pos; - return true; - } -} - -/// Matches backtick-enclosed inline code blocks. -class CodeSyntax extends InlineSyntax { - CodeSyntax() : super(_pattern); - - // This pattern matches: - // - // * a string of backticks (not followed by any more), followed by - // * a non-greedy string of anything, including newlines, ending with anything - // except a backtick, followed by - // * a string of backticks the same length as the first, not followed by any - // more. - // - // This conforms to the delimiters of inline code, both in Markdown.pl, and - // CommonMark. - static const String _pattern = r'(`+(?!`))((?:.|\n)*?[^`])\1(?!`)'; - - @override - bool tryMatch(InlineParser parser, [int? startMatchPos]) { - if (parser.pos > 0 && parser.charAt(parser.pos - 1) == $backquote) { - // Not really a match! We can't just sneak past one backtick to try the - // next character. An example of this situation would be: - // - // before ``` and `` after. - // ^--parser.pos - return false; - } - - final match = pattern.matchAsPrefix(parser.source, parser.pos); - if (match == null) { - return false; - } - parser.writeText(); - if (onMatch(parser, match)) { - parser.consume(match[0]!.length); - } - return true; - } - - @override - bool onMatch(InlineParser parser, Match match) { - parser.addNode(Element.text('code', escapeHtml(match[2]!.trim()))); - return true; - } -} - -/// Matches GitHub Markdown emoji syntax like `:smile:`. -/// -/// There is no formal specification of GitHub's support for this colon-based -/// emoji support, so this syntax is based on the results of Markdown-enabled -/// text fields at github.com. -class EmojiSyntax extends InlineSyntax { - // Emoji "aliases" are mostly limited to lower-case letters, numbers, and - // underscores, but GitHub also supports `:+1:` and `:-1:`. - EmojiSyntax() : super(':([a-z0-9_+-]+):'); - - @override - bool onMatch(InlineParser parser, Match match) { - final alias = match[1]; - final emoji = emojis[alias!]; - if (emoji == null) { - parser.advanceBy(1); - return false; - } - parser.addNode(Text(emoji)); - - return true; - } -} - -/// Keeps track of a currently open tag while it is being parsed. -/// -/// The parser maintains a stack of these so it can handle nested tags. -class TagState { - TagState(this.startPos, this.endPos, this.syntax, this.openingDelimiterRun) - : children = []; - - /// The point in the original source where this tag started. - final int startPos; - - /// The point in the original source where open tag ended. - final int endPos; - - /// The syntax that created this node. - final TagSyntax? syntax; - - /// The children of this node. Will be `null` for text nodes. - final List children; - - final DelimiterRun? openingDelimiterRun; - - /// Attempts to close this tag by matching the current text against its end - /// pattern. - bool tryMatch(InlineParser parser) { - final endMatch = - syntax!.endPattern.matchAsPrefix(parser.source, parser.pos); - if (endMatch == null) { - return false; - } - - if (!syntax!.requiresDelimiterRun) { - // Close the tag. - close(parser, endMatch); - return true; - } - - // TODO: Move this logic into TagSyntax. - final runLength = endMatch.group(0)!.length; - final openingRunLength = endPos - startPos; - final closingMatchStart = parser.pos; - final closingMatchEnd = parser.pos + runLength - 1; - final closingDelimiterRun = - DelimiterRun.tryParse(parser, closingMatchStart, closingMatchEnd); - if (closingDelimiterRun != null && closingDelimiterRun.canClose) { - // Emphasis rules #9 and #10: - final oneRunOpensAndCloses = - (openingDelimiterRun!.canOpen && openingDelimiterRun!.canClose) || - (closingDelimiterRun.canOpen && closingDelimiterRun.canClose); - if (oneRunOpensAndCloses && - (openingRunLength + closingDelimiterRun.length!) % 3 == 0) { - return false; - } - // Close the tag. - close(parser, endMatch); - return true; - } else { - return false; - } - } - - /// Pops this tag off the stack, completes it, and adds it to the output. - /// - /// Will discard any unmatched tags that happen to be above it on the stack. - /// If this is the last node in the stack, returns its children. - List? close(InlineParser parser, Match? endMatch) { - // If there are unclosed tags on top of this one when it's closed, that - // means they are mismatched. Mismatched tags are treated as plain text in - // markdown. So for each tag above this one, we write its start tag as text - // and then adds its children to this one's children. - final index = parser._stack.indexOf(this); - - // Remove the unmatched children. - final unmatchedTags = parser._stack.sublist(index + 1); - parser._stack.removeRange(index + 1, parser._stack.length); - - // Flatten them out onto this tag. - for (final unmatched in unmatchedTags) { - // Write the start tag as text. - parser.writeTextRange(unmatched.startPos, unmatched.endPos); - - // Bequeath its children unto this tag. - children.addAll(unmatched.children); - } - - // Pop this off the stack. - parser.writeText(); - parser._stack.removeLast(); - - // If the stack is empty now, this is the special "results" node. - if (parser._stack.isEmpty) { - return children; - } - final endMatchIndex = parser.pos; - - // We are still parsing, so add this to its parent's children. - if (syntax!.onMatchEnd(parser, endMatch!, this)) { - parser.consume(endMatch[0]!.length); - } else { - // Didn't close correctly so revert to text. - parser - ..writeTextRange(startPos, endPos) - .._stack.last.children.addAll(children) - ..pos = endMatchIndex - ..advanceBy(endMatch[0]!.length); - } - - return null; - } - - String get textContent => children.map((child) => child.textContent).join(); -} - -class InlineLink { - InlineLink(this.destination, {this.title}); - - final String destination; - final String? title; -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/image_node_parser.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/parser/image_node_parser.dart deleted file mode 100644 index 575cf132169a1..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/image_node_parser.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:app_flowy/workspace/application/markdown/src/parser/node_parser.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; - -class ImageNodeParser extends NodeParser { - const ImageNodeParser(); - - @override - String get id => 'image'; - - @override - String transform(Node node) { - return '![](${node.attributes['image_src']})'; - } -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/markdown_encoder.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/parser/markdown_encoder.dart deleted file mode 100644 index f6c1e6ea02a7f..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/markdown_encoder.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:convert'; - -import 'package:app_flowy/workspace/application/markdown/src/parser/image_node_parser.dart'; -import 'package:app_flowy/workspace/application/markdown/src/parser/node_parser.dart'; -import 'package:app_flowy/workspace/application/markdown/src/parser/text_node_parser.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; - -class AppFlowyEditorMarkdownEncoder extends Converter { - AppFlowyEditorMarkdownEncoder({ - this.parsers = const [ - TextNodeParser(), - ImageNodeParser(), - ], - }); - - final List parsers; - - @override - String convert(Document input) { - final buffer = StringBuffer(); - for (final node in input.root.children) { - NodeParser? parser = - parsers.firstWhereOrNull((element) => element.id == node.type); - if (parser != null) { - buffer.write(parser.transform(node)); - } - } - return buffer.toString(); - } -} - -extension IterableExtension on Iterable { - T? firstWhereOrNull(bool Function(T element) test) { - for (var element in this) { - if (test(element)) return element; - } - return null; - } -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/node_parser.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/parser/node_parser.dart deleted file mode 100644 index 649ca7eae746d..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/node_parser.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; - -abstract class NodeParser { - const NodeParser(); - - String get id; - String transform(Node node); -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/text_node_parser.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/parser/text_node_parser.dart deleted file mode 100644 index 0dbf6418aac98..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/text_node_parser.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'dart:convert'; - -import 'package:app_flowy/workspace/application/markdown/delta_markdown.dart'; -import 'package:app_flowy/workspace/application/markdown/src/parser/node_parser.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; - -class TextNodeParser extends NodeParser { - const TextNodeParser(); - - @override - String get id => 'text'; - - @override - String transform(Node node) { - assert(node is TextNode); - final textNode = node as TextNode; - final delta = jsonEncode( - textNode.delta - ..add(TextInsert('\n')) - ..toJson(), - ); - final markdown = deltaToMarkdown(delta); - final attributes = textNode.attributes; - var result = markdown; - var suffix = ''; - if (attributes.isNotEmpty && - attributes.containsKey(BuiltInAttributeKey.subtype)) { - final subtype = attributes[BuiltInAttributeKey.subtype]; - if (node.next?.subtype != subtype) { - suffix = '\n'; - } - if (subtype == 'heading') { - final heading = attributes[BuiltInAttributeKey.heading]; - if (heading == 'h1') { - result = '# $markdown'; - } else if (heading == 'h2') { - result = '## $markdown'; - } else if (heading == 'h3') { - result = '### $markdown'; - } else if (heading == 'h4') { - result = '#### $markdown'; - } else if (heading == 'h5') { - result = '##### $markdown'; - } else if (heading == 'h6') { - result = '###### $markdown'; - } - } else if (subtype == 'quote') { - result = '> $markdown'; - } else if (subtype == 'code') { - result = '`$markdown`'; - } else if (subtype == 'code-block') { - result = '```\n$markdown\n```'; - } else if (subtype == 'bulleted-list') { - result = '- $markdown'; - } else if (subtype == 'number-list') { - final number = attributes['number']; - result = '$number. $markdown'; - } else if (subtype == 'checkbox') { - if (attributes[BuiltInAttributeKey.checkbox] == true) { - result = '- [x] $markdown'; - } else { - result = '- [ ] $markdown'; - } - } - } - return '$result$suffix'; - } -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/util.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/util.dart deleted file mode 100644 index aed4c3c3fccfa..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/util.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'dart:convert'; - -import 'package:charcode/charcode.dart'; - -String escapeHtml(String html) => - const HtmlEscape(HtmlEscapeMode.element).convert(html); - -// Escape the contents of [value], so that it may be used as an HTML attribute. - -// Based on http://spec.commonmark.org/0.28/#backslash-escapes. -String escapeAttribute(String value) { - final result = StringBuffer(); - int ch; - for (var i = 0; i < value.codeUnits.length; i++) { - ch = value.codeUnitAt(i); - if (ch == $backslash) { - i++; - if (i == value.codeUnits.length) { - result.writeCharCode(ch); - break; - } - ch = value.codeUnitAt(i); - switch (ch) { - case $quote: - result.write('"'); - break; - case $exclamation: - case $hash: - case $dollar: - case $percent: - case $ampersand: - case $apostrophe: - case $lparen: - case $rparen: - case $asterisk: - case $plus: - case $comma: - case $dash: - case $dot: - case $slash: - case $colon: - case $semicolon: - case $lt: - case $equal: - case $gt: - case $question: - case $at: - case $lbracket: - case $backslash: - case $rbracket: - case $caret: - case $underscore: - case $backquote: - case $lbrace: - case $bar: - case $rbrace: - case $tilde: - result.writeCharCode(ch); - break; - default: - result.write('%5C'); - result.writeCharCode(ch); - } - } else if (ch == $quote) { - result.write('%22'); - } else { - result.writeCharCode(ch); - } - } - return result.toString(); -} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/version.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/version.dart deleted file mode 100644 index 19433ffa4ad96..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/version.dart +++ /dev/null @@ -1,2 +0,0 @@ -// Generated code. Do not modify. -const packageVersion = '0.0.2'; diff --git a/frontend/app_flowy/lib/workspace/application/menu/menu_bloc.dart b/frontend/app_flowy/lib/workspace/application/menu/menu_bloc.dart deleted file mode 100644 index 5786998ef803d..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/menu/menu_bloc.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'dart:async'; -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:app_flowy/workspace/application/workspace/workspace_listener.dart'; -import 'package:app_flowy/workspace/application/workspace/workspace_service.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -part 'menu_bloc.freezed.dart'; - -class MenuBloc extends Bloc { - final WorkspaceService _workspaceService; - final WorkspaceListener _listener; - final UserProfilePB user; - final WorkspacePB workspace; - - MenuBloc({ - required this.user, - required this.workspace, - }) : _workspaceService = WorkspaceService(workspaceId: workspace.id), - _listener = WorkspaceListener( - user: user, - workspaceId: workspace.id, - ), - super(MenuState.initial(workspace)) { - on((event, emit) async { - await event.map( - initial: (e) async { - _listener.start(appsChanged: _handleAppsOrFail); - await _fetchApps(emit); - }, - openPage: (e) async { - emit(state.copyWith(plugin: e.plugin)); - }, - createApp: (_CreateApp event) async { - await _performActionOnCreateApp(event, emit); - }, - didReceiveApps: (e) async { - emit(e.appsOrFail.fold( - (apps) => state.copyWith(apps: apps, successOrFailure: left(unit)), - (err) => state.copyWith(successOrFailure: right(err)), - )); - }, - moveApp: (_MoveApp value) { - if (state.apps.length > value.fromIndex) { - final app = state.apps[value.fromIndex]; - _workspaceService.moveApp( - appId: app.id, - fromIndex: value.fromIndex, - toIndex: value.toIndex); - final apps = List.from(state.apps); - apps.insert(value.toIndex, apps.removeAt(value.fromIndex)); - emit(state.copyWith(apps: apps)); - } - }, - ); - }); - } - - @override - Future close() async { - await _listener.stop(); - return super.close(); - } - - Future _performActionOnCreateApp( - _CreateApp event, Emitter emit) async { - final result = await _workspaceService.createApp( - name: event.name, desc: event.desc ?? ""); - result.fold( - (app) => {}, - (error) { - Log.error(error); - emit(state.copyWith(successOrFailure: right(error))); - }, - ); - } - - // ignore: unused_element - Future _fetchApps(Emitter emit) async { - final appsOrFail = await _workspaceService.getApps(); - emit(appsOrFail.fold( - (apps) => state.copyWith(apps: apps), - (error) { - Log.error(error); - return state.copyWith(successOrFailure: right(error)); - }, - )); - } - - void _handleAppsOrFail(Either, FlowyError> appsOrFail) { - appsOrFail.fold( - (apps) => add(MenuEvent.didReceiveApps(left(apps))), - (error) => add(MenuEvent.didReceiveApps(right(error))), - ); - } -} - -@freezed -class MenuEvent with _$MenuEvent { - const factory MenuEvent.initial() = _Initial; - const factory MenuEvent.openPage(Plugin plugin) = _OpenPage; - const factory MenuEvent.createApp(String name, {String? desc}) = _CreateApp; - const factory MenuEvent.moveApp(int fromIndex, int toIndex) = _MoveApp; - const factory MenuEvent.didReceiveApps( - Either, FlowyError> appsOrFail) = _ReceiveApps; -} - -@freezed -class MenuState with _$MenuState { - const factory MenuState({ - required List apps, - required Either successOrFailure, - required Plugin plugin, - }) = _MenuState; - - factory MenuState.initial(WorkspacePB workspace) => MenuState( - apps: workspace.apps.items, - successOrFailure: left(unit), - plugin: makePlugin(pluginType: PluginType.blank), - ); -} diff --git a/frontend/app_flowy/lib/workspace/application/menu/menu_user_bloc.dart b/frontend/app_flowy/lib/workspace/application/menu/menu_user_bloc.dart deleted file mode 100644 index 0f30bb9d45bbf..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/menu/menu_user_bloc.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:app_flowy/user/application/user_listener.dart'; -import 'package:app_flowy/user/application/user_service.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:dartz/dartz.dart'; - -part 'menu_user_bloc.freezed.dart'; - -class MenuUserBloc extends Bloc { - final UserService _userService; - final UserListener _userListener; - final UserWorkspaceListener _userWorkspaceListener; - final UserProfilePB userProfile; - - MenuUserBloc(this.userProfile) - : _userListener = UserListener(userProfile: userProfile), - _userWorkspaceListener = UserWorkspaceListener(userProfile: userProfile), - _userService = UserService(userId: userProfile.id), - super(MenuUserState.initial(userProfile)) { - on((event, emit) async { - await event.when( - initial: () async { - _userListener.start(onProfileUpdated: _profileUpdated); - _userWorkspaceListener.start(onWorkspacesUpdated: _workspaceListUpdated); - await _initUser(); - }, - fetchWorkspaces: () async { - // - }, - didReceiveUserProfile: (UserProfilePB newUserProfile) { - emit(state.copyWith(userProfile: newUserProfile)); - }, - updateUserName: (String name) { - _userService.updateUserProfile(name: name).then((result) { - result.fold( - (l) => null, - (err) => Log.error(err), - ); - }); - }, - ); - }); - } - - @override - Future close() async { - await _userListener.stop(); - await _userWorkspaceListener.stop(); - super.close(); - } - - Future _initUser() async { - final result = await _userService.initUser(); - result.fold((l) => null, (error) => Log.error(error)); - } - - void _profileUpdated(Either userProfileOrFailed) { - userProfileOrFailed.fold( - (newUserProfile) => add(MenuUserEvent.didReceiveUserProfile(newUserProfile)), - (err) => Log.error(err), - ); - } - - void _workspaceListUpdated(Either, FlowyError> workspacesOrFailed) { - // Do nothing by now - } -} - -@freezed -class MenuUserEvent with _$MenuUserEvent { - const factory MenuUserEvent.initial() = _Initial; - const factory MenuUserEvent.fetchWorkspaces() = _FetchWorkspaces; - const factory MenuUserEvent.updateUserName(String name) = _UpdateUserName; - const factory MenuUserEvent.didReceiveUserProfile(UserProfilePB newUserProfile) = _DidReceiveUserProfile; -} - -@freezed -class MenuUserState with _$MenuUserState { - const factory MenuUserState({ - required UserProfilePB userProfile, - required Option> workspaces, - required Either successOrFailure, - }) = _MenuUserState; - - factory MenuUserState.initial(UserProfilePB userProfile) => MenuUserState( - userProfile: userProfile, - workspaces: none(), - successOrFailure: left(unit), - ); -} diff --git a/frontend/app_flowy/lib/workspace/application/menu/menu_view_section_bloc.dart b/frontend/app_flowy/lib/workspace/application/menu/menu_view_section_bloc.dart deleted file mode 100644 index 1b499636c27d9..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/menu/menu_view_section_bloc.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'dart:async'; - -import 'package:app_flowy/workspace/application/app/app_bloc.dart'; -import 'package:app_flowy/workspace/application/app/app_service.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -part 'menu_view_section_bloc.freezed.dart'; - -class ViewSectionBloc extends Bloc { - void Function()? _viewsListener; - void Function()? _selectedViewlistener; - final AppViewDataContext _appViewData; - late final AppService _appService; - - ViewSectionBloc({ - required AppViewDataContext appViewData, - }) : _appService = AppService(), - _appViewData = appViewData, - super(ViewSectionState.initial(appViewData)) { - on((event, emit) async { - await event.map( - initial: (e) async { - _startListening(); - }, - setSelectedView: (_SetSelectedView value) { - _setSelectView(value, emit); - }, - didReceiveViewUpdated: (_DidReceiveViewUpdated value) { - emit(state.copyWith(views: value.views)); - }, - moveView: (_MoveView value) async { - _moveView(value, emit); - }, - ); - }); - } - - void _startListening() { - _viewsListener = _appViewData.addViewsChangeListener((views) { - if (!isClosed) { - add(ViewSectionEvent.didReceiveViewUpdated(views)); - } - }); - _selectedViewlistener = _appViewData.addSelectedViewChangeListener((view) { - if (!isClosed) { - add(ViewSectionEvent.setSelectedView(view)); - } - }); - } - - void _setSelectView(_SetSelectedView value, Emitter emit) { - if (state.views.contains(value.view)) { - emit(state.copyWith(selectedView: value.view)); - } else { - emit(state.copyWith(selectedView: null)); - } - } - - Future _moveView( - _MoveView value, Emitter emit) async { - if (value.fromIndex < state.views.length) { - final viewId = state.views[value.fromIndex].id; - final views = List.from(state.views); - views.insert(value.toIndex, views.removeAt(value.fromIndex)); - emit(state.copyWith(views: views)); - - final result = await _appService.moveView( - viewId: viewId, - fromIndex: value.fromIndex, - toIndex: value.toIndex, - ); - result.fold((l) => null, (err) => Log.error(err)); - } - } - - @override - Future close() async { - if (_selectedViewlistener != null) { - _appViewData.removeSelectedViewListener(_selectedViewlistener!); - } - - if (_viewsListener != null) { - _appViewData.removeViewsListener(_viewsListener!); - } - - return super.close(); - } -} - -@freezed -class ViewSectionEvent with _$ViewSectionEvent { - const factory ViewSectionEvent.initial() = _Initial; - const factory ViewSectionEvent.setSelectedView(ViewPB? view) = - _SetSelectedView; - const factory ViewSectionEvent.moveView(int fromIndex, int toIndex) = - _MoveView; - const factory ViewSectionEvent.didReceiveViewUpdated(List views) = - _DidReceiveViewUpdated; -} - -@freezed -class ViewSectionState with _$ViewSectionState { - const factory ViewSectionState({ - required List views, - ViewPB? selectedView, - }) = _ViewSectionState; - - factory ViewSectionState.initial(AppViewDataContext appViewData) => - ViewSectionState( - views: appViewData.views, - selectedView: appViewData.selectedView, - ); -} diff --git a/frontend/app_flowy/lib/workspace/application/menu/prelude.dart b/frontend/app_flowy/lib/workspace/application/menu/prelude.dart deleted file mode 100644 index 0bf94ea60b1b1..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/menu/prelude.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'menu_bloc.dart'; -export 'menu_user_bloc.dart'; diff --git a/frontend/app_flowy/lib/workspace/application/settings/prelude.dart b/frontend/app_flowy/lib/workspace/application/settings/prelude.dart deleted file mode 100644 index 3917b54aaff1e..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/settings/prelude.dart +++ /dev/null @@ -1 +0,0 @@ -export 'settings_dialog_bloc.dart'; diff --git a/frontend/app_flowy/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/app_flowy/lib/workspace/application/settings/settings_dialog_bloc.dart deleted file mode 100644 index 3c40f767b1ae7..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/settings/settings_dialog_bloc.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:app_flowy/user/application/user_listener.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:dartz/dartz.dart'; - -part 'settings_dialog_bloc.freezed.dart'; - -class SettingsDialogBloc extends Bloc { - final UserListener _userListener; - final UserProfilePB userProfile; - - SettingsDialogBloc(this.userProfile) - : _userListener = UserListener(userProfile: userProfile), - super(SettingsDialogState.initial(userProfile)) { - on((event, emit) async { - await event.when( - initial: () async { - _userListener.start(onProfileUpdated: _profileUpdated); - }, - didReceiveUserProfile: (UserProfilePB newUserProfile) { - emit(state.copyWith(userProfile: newUserProfile)); - }, - setViewIndex: (int viewIndex) { - emit(state.copyWith(viewIndex: viewIndex)); - }, - ); - }); - } - - @override - Future close() async { - await _userListener.stop(); - super.close(); - } - - void _profileUpdated(Either userProfileOrFailed) { - userProfileOrFailed.fold( - (newUserProfile) => add(SettingsDialogEvent.didReceiveUserProfile(newUserProfile)), - (err) => Log.error(err), - ); - } -} - -@freezed -class SettingsDialogEvent with _$SettingsDialogEvent { - const factory SettingsDialogEvent.initial() = _Initial; - const factory SettingsDialogEvent.didReceiveUserProfile(UserProfilePB newUserProfile) = _DidReceiveUserProfile; - const factory SettingsDialogEvent.setViewIndex(int index) = _SetViewIndex; -} - -@freezed -class SettingsDialogState with _$SettingsDialogState { - const factory SettingsDialogState({ - required UserProfilePB userProfile, - required Either successOrFailure, - required int viewIndex, - }) = _SettingsDialogState; - - factory SettingsDialogState.initial(UserProfilePB userProfile) => SettingsDialogState( - userProfile: userProfile, - successOrFailure: left(unit), - viewIndex: 0, - ); -} diff --git a/frontend/app_flowy/lib/workspace/application/user/prelude.dart b/frontend/app_flowy/lib/workspace/application/user/prelude.dart deleted file mode 100644 index f698497db9285..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/user/prelude.dart +++ /dev/null @@ -1 +0,0 @@ -export 'settings_user_bloc.dart'; diff --git a/frontend/app_flowy/lib/workspace/application/user/settings_user_bloc.dart b/frontend/app_flowy/lib/workspace/application/user/settings_user_bloc.dart deleted file mode 100644 index de6377781286a..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/user/settings_user_bloc.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:app_flowy/user/application/user_listener.dart'; -import 'package:app_flowy/user/application/user_service.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:dartz/dartz.dart'; - -part 'settings_user_bloc.freezed.dart'; - -class SettingsUserViewBloc extends Bloc { - final UserService _userService; - final UserListener _userListener; - final UserProfilePB userProfile; - - SettingsUserViewBloc(this.userProfile) - : _userListener = UserListener(userProfile: userProfile), - _userService = UserService(userId: userProfile.id), - super(SettingsUserState.initial(userProfile)) { - on((event, emit) async { - await event.when( - initial: () async { - _userListener.start(onProfileUpdated: _profileUpdated); - await _initUser(); - }, - didReceiveUserProfile: (UserProfilePB newUserProfile) { - emit(state.copyWith(userProfile: newUserProfile)); - }, - updateUserName: (String name) { - _userService.updateUserProfile(name: name).then((result) { - result.fold( - (l) => null, - (err) => Log.error(err), - ); - }); - }, - updateUserIcon: (String iconUrl) { - _userService.updateUserProfile(iconUrl: iconUrl).then((result) { - result.fold( - (l) => null, - (err) => Log.error(err), - ); - }); - }, - ); - }); - } - - @override - Future close() async { - await _userListener.stop(); - super.close(); - } - - Future _initUser() async { - final result = await _userService.initUser(); - result.fold((l) => null, (error) => Log.error(error)); - } - - void _profileUpdated(Either userProfileOrFailed) { - userProfileOrFailed.fold( - (newUserProfile) => - add(SettingsUserEvent.didReceiveUserProfile(newUserProfile)), - (err) => Log.error(err), - ); - } -} - -@freezed -class SettingsUserEvent with _$SettingsUserEvent { - const factory SettingsUserEvent.initial() = _Initial; - const factory SettingsUserEvent.updateUserName(String name) = _UpdateUserName; - const factory SettingsUserEvent.updateUserIcon(String iconUrl) = - _UpdateUserIcon; - const factory SettingsUserEvent.didReceiveUserProfile( - UserProfilePB newUserProfile) = _DidReceiveUserProfile; -} - -@freezed -class SettingsUserState with _$SettingsUserState { - const factory SettingsUserState({ - required UserProfilePB userProfile, - required Either successOrFailure, - }) = _SettingsUserState; - - factory SettingsUserState.initial(UserProfilePB userProfile) => - SettingsUserState( - userProfile: userProfile, - successOrFailure: left(unit), - ); -} diff --git a/frontend/app_flowy/lib/workspace/application/view/view_bloc.dart b/frontend/app_flowy/lib/workspace/application/view/view_bloc.dart deleted file mode 100644 index 507c727aa8af2..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/view/view_bloc.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:app_flowy/workspace/application/view/view_listener.dart'; -import 'package:app_flowy/workspace/application/view/view_service.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'view_bloc.freezed.dart'; - -class ViewBloc extends Bloc { - final ViewService service; - final ViewListener listener; - final ViewPB view; - - ViewBloc({ - required this.view, - }) : service = ViewService(), - listener = ViewListener(view: view), - super(ViewState.init(view)) { - on((event, emit) async { - await event.map( - initial: (e) { - listener.start(onViewUpdated: (result) { - add(ViewEvent.viewDidUpdate(result)); - }); - emit(state); - }, - setIsEditing: (e) { - emit(state.copyWith(isEditing: e.isEditing)); - }, - viewDidUpdate: (e) { - e.result.fold( - (view) => emit( - state.copyWith(view: view, successOrFailure: left(unit)), - ), - (error) => emit( - state.copyWith(successOrFailure: right(error)), - ), - ); - }, - rename: (e) async { - final result = await service.updateView( - viewId: view.id, - name: e.newName, - ); - emit( - result.fold( - (l) => state.copyWith(successOrFailure: left(unit)), - (error) => state.copyWith(successOrFailure: right(error)), - ), - ); - }, - delete: (e) async { - final result = await service.delete(viewId: view.id); - emit( - result.fold( - (l) => state.copyWith(successOrFailure: left(unit)), - (error) => state.copyWith(successOrFailure: right(error)), - ), - ); - }, - duplicate: (e) async { - final result = await service.duplicate(view: view); - emit( - result.fold( - (l) => state.copyWith(successOrFailure: left(unit)), - (error) => state.copyWith(successOrFailure: right(error)), - ), - ); - }, - ); - }); - } - - @override - Future close() async { - await listener.stop(); - return super.close(); - } -} - -@freezed -class ViewEvent with _$ViewEvent { - const factory ViewEvent.initial() = Initial; - const factory ViewEvent.setIsEditing(bool isEditing) = SetEditing; - const factory ViewEvent.rename(String newName) = Rename; - const factory ViewEvent.delete() = Delete; - const factory ViewEvent.duplicate() = Duplicate; - const factory ViewEvent.viewDidUpdate(Either result) = - ViewDidUpdate; -} - -@freezed -class ViewState with _$ViewState { - const factory ViewState({ - required ViewPB view, - required bool isEditing, - required Either successOrFailure, - }) = _ViewState; - - factory ViewState.init(ViewPB view) => ViewState( - view: view, - isEditing: false, - successOrFailure: left(unit), - ); -} diff --git a/frontend/app_flowy/lib/workspace/application/view/view_ext.dart b/frontend/app_flowy/lib/workspace/application/view/view_ext.dart deleted file mode 100644 index 3684d9670c79b..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/view/view_ext.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter/material.dart'; - -enum FlowyPlugin { - editor, - kanban, -} - -extension FlowyPluginExtension on FlowyPlugin { - String displayName() { - switch (this) { - case FlowyPlugin.editor: - return "Doc"; - case FlowyPlugin.kanban: - return "Kanban"; - default: - return ""; - } - } - - bool enable() { - switch (this) { - case FlowyPlugin.editor: - return true; - case FlowyPlugin.kanban: - return false; - default: - return false; - } - } -} - -extension ViewExtension on ViewPB { - Widget renderThumbnail({Color? iconColor}) { - String thumbnail = "file_icon"; - - final Widget widget = svgWidget(thumbnail, color: iconColor); - return widget; - } - - PluginType get pluginType { - switch (layout) { - case ViewLayoutTypePB.Board: - return PluginType.board; - case ViewLayoutTypePB.Document: - return PluginType.editor; - case ViewLayoutTypePB.Grid: - return PluginType.grid; - } - - throw UnimplementedError; - } - - Plugin plugin() { - final plugin = makePlugin(pluginType: pluginType, data: this); - return plugin; - } -} diff --git a/frontend/app_flowy/lib/workspace/application/view/view_listener.dart b/frontend/app_flowy/lib/workspace/application/view/view_listener.dart deleted file mode 100644 index 6c575375f3663..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/view/view_listener.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; -import 'package:app_flowy/core/folder_notification.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/dart-notify/subject.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/dart_notification.pb.dart'; -import 'package:flowy_sdk/rust_stream.dart'; -import 'package:flowy_infra/notifier.dart'; - -// Delete the view from trash, which means the view was deleted permanently -typedef DeleteViewNotifyValue = Either; -// The view get updated -typedef UpdateViewNotifiedValue = Either; -// Restore the view from trash -typedef RestoreViewNotifiedValue = Either; -// Move the view to trash -typedef MoveToTrashNotifiedValue = Either; - -class ViewListener { - StreamSubscription? _subscription; - final _updatedViewNotifier = PublishNotifier(); - final _deletedNotifier = PublishNotifier(); - final _restoredNotifier = PublishNotifier(); - final _moveToTrashNotifier = PublishNotifier(); - FolderNotificationParser? _parser; - ViewPB view; - - ViewListener({ - required this.view, - }); - - void start({ - void Function(UpdateViewNotifiedValue)? onViewUpdated, - void Function(DeleteViewNotifyValue)? onViewDeleted, - void Function(RestoreViewNotifiedValue)? onViewRestored, - void Function(MoveToTrashNotifiedValue)? onViewMoveToTrash, - }) { - if (onViewUpdated != null) { - _updatedViewNotifier.addListener(() { - onViewUpdated(_updatedViewNotifier.currentValue!); - }); - } - - if (onViewDeleted != null) { - _deletedNotifier.addListener(() { - onViewDeleted(_deletedNotifier.currentValue!); - }); - } - - if (onViewRestored != null) { - _restoredNotifier.addListener(() { - onViewRestored(_restoredNotifier.currentValue!); - }); - } - - if (onViewMoveToTrash != null) { - _moveToTrashNotifier.addListener(() { - onViewMoveToTrash(_moveToTrashNotifier.currentValue!); - }); - } - - _parser = FolderNotificationParser( - id: view.id, - callback: (ty, result) { - _handleObservableType(ty, result); - }, - ); - - _subscription = - RustStreamReceiver.listen((observable) => _parser?.parse(observable)); - } - - void _handleObservableType( - FolderNotification ty, Either result) { - switch (ty) { - case FolderNotification.ViewUpdated: - result.fold( - (payload) => - _updatedViewNotifier.value = left(ViewPB.fromBuffer(payload)), - (error) => _updatedViewNotifier.value = right(error), - ); - break; - case FolderNotification.ViewDeleted: - result.fold( - (payload) => - _deletedNotifier.value = left(ViewPB.fromBuffer(payload)), - (error) => _deletedNotifier.value = right(error), - ); - break; - case FolderNotification.ViewRestored: - result.fold( - (payload) => - _restoredNotifier.value = left(ViewPB.fromBuffer(payload)), - (error) => _restoredNotifier.value = right(error), - ); - break; - case FolderNotification.ViewMoveToTrash: - result.fold( - (payload) => _moveToTrashNotifier.value = - left(DeletedViewPB.fromBuffer(payload)), - (error) => _moveToTrashNotifier.value = right(error), - ); - break; - default: - break; - } - } - - Future stop() async { - _parser = null; - await _subscription?.cancel(); - _updatedViewNotifier.dispose(); - _deletedNotifier.dispose(); - _restoredNotifier.dispose(); - } -} diff --git a/frontend/app_flowy/lib/workspace/application/view/view_service.dart b/frontend/app_flowy/lib/workspace/application/view/view_service.dart deleted file mode 100644 index 7cfbbfeeb77dd..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/view/view_service.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'dart:async'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; - -class ViewService { - Future> readView({required String viewId}) { - final request = ViewIdPB(value: viewId); - return FolderEventReadView(request).send(); - } - - Future> updateView( - {required String viewId, String? name, String? desc}) { - final request = UpdateViewPayloadPB.create()..viewId = viewId; - - if (name != null) { - request.name = name; - } - - if (desc != null) { - request.desc = desc; - } - - return FolderEventUpdateView(request).send(); - } - - Future> delete({required String viewId}) { - final request = RepeatedViewIdPB.create()..items.add(viewId); - return FolderEventDeleteView(request).send(); - } - - Future> duplicate({required ViewPB view}) { - return FolderEventDuplicateView(view).send(); - } -} diff --git a/frontend/app_flowy/lib/workspace/application/workspace/prelude.dart b/frontend/app_flowy/lib/workspace/application/workspace/prelude.dart deleted file mode 100644 index 129462beb0504..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/workspace/prelude.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'welcome_bloc.dart'; -export 'workspace_listener.dart'; -export 'workspace_service.dart'; diff --git a/frontend/app_flowy/lib/workspace/application/workspace/welcome_bloc.dart b/frontend/app_flowy/lib/workspace/application/workspace/welcome_bloc.dart deleted file mode 100644 index 49c3ba19bee78..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/workspace/welcome_bloc.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:app_flowy/user/application/user_listener.dart'; -import 'package:app_flowy/user/application/user_service.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:dartz/dartz.dart'; - -part 'welcome_bloc.freezed.dart'; - -class WelcomeBloc extends Bloc { - final UserService userService; - final UserWorkspaceListener userWorkspaceListener; - WelcomeBloc({required this.userService, required this.userWorkspaceListener}) : super(WelcomeState.initial()) { - on( - (event, emit) async { - await event.map(initial: (e) async { - userWorkspaceListener.start( - onWorkspacesUpdated: (result) => add(WelcomeEvent.workspacesReveived(result)), - ); - // - await _fetchWorkspaces(emit); - }, openWorkspace: (e) async { - await _openWorkspace(e.workspace, emit); - }, createWorkspace: (e) async { - await _createWorkspace(e.name, e.desc, emit); - }, workspacesReveived: (e) async { - emit(e.workspacesOrFail.fold( - (workspaces) => state.copyWith(workspaces: workspaces, successOrFailure: left(unit)), - (error) => state.copyWith(successOrFailure: right(error)), - )); - }); - }, - ); - } - - @override - Future close() async { - await userWorkspaceListener.stop(); - super.close(); - } - - Future _fetchWorkspaces(Emitter emit) async { - final workspacesOrFailed = await userService.getWorkspaces(); - emit(workspacesOrFailed.fold( - (workspaces) => state.copyWith(workspaces: workspaces, successOrFailure: left(unit)), - (error) { - Log.error(error); - return state.copyWith(successOrFailure: right(error)); - }, - )); - } - - Future _openWorkspace(WorkspacePB workspace, Emitter emit) async { - final result = await userService.openWorkspace(workspace.id); - emit(result.fold( - (workspaces) => state.copyWith(successOrFailure: left(unit)), - (error) { - Log.error(error); - return state.copyWith(successOrFailure: right(error)); - }, - )); - } - - Future _createWorkspace(String name, String desc, Emitter emit) async { - final result = await userService.createWorkspace(name, desc); - emit(result.fold( - (workspace) { - return state.copyWith(successOrFailure: left(unit)); - }, - (error) { - Log.error(error); - return state.copyWith(successOrFailure: right(error)); - }, - )); - } -} - -@freezed -class WelcomeEvent with _$WelcomeEvent { - const factory WelcomeEvent.initial() = Initial; - // const factory WelcomeEvent.fetchWorkspaces() = FetchWorkspace; - const factory WelcomeEvent.createWorkspace(String name, String desc) = CreateWorkspace; - const factory WelcomeEvent.openWorkspace(WorkspacePB workspace) = OpenWorkspace; - const factory WelcomeEvent.workspacesReveived(Either, FlowyError> workspacesOrFail) = - WorkspacesReceived; -} - -@freezed -class WelcomeState with _$WelcomeState { - const factory WelcomeState({ - required bool isLoading, - required List workspaces, - required Either successOrFailure, - }) = _WelcomeState; - - factory WelcomeState.initial() => WelcomeState( - isLoading: false, - workspaces: List.empty(), - successOrFailure: left(unit), - ); -} diff --git a/frontend/app_flowy/lib/workspace/application/workspace/workspace_listener.dart b/frontend/app_flowy/lib/workspace/application/workspace/workspace_listener.dart deleted file mode 100644 index 2d7a100e8bf22..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/workspace/workspace_listener.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; -import 'package:app_flowy/core/folder_notification.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/dart_notification.pb.dart'; - -typedef AppListNotifyValue = Either, FlowyError>; -typedef WorkspaceNotifyValue = Either; - -class WorkspaceListener { - PublishNotifier? _appsChangedNotifier = PublishNotifier(); - PublishNotifier? _workspaceUpdatedNotifier = PublishNotifier(); - - FolderNotificationListener? _listener; - final UserProfilePB user; - final String workspaceId; - - WorkspaceListener({ - required this.user, - required this.workspaceId, - }); - - void start({ - void Function(AppListNotifyValue)? appsChanged, - void Function(WorkspaceNotifyValue)? onWorkspaceUpdated, - }) { - if (appsChanged != null) { - _appsChangedNotifier?.addPublishListener(appsChanged); - } - - if (onWorkspaceUpdated != null) { - _workspaceUpdatedNotifier?.addPublishListener(onWorkspaceUpdated); - } - - _listener = FolderNotificationListener( - objectId: workspaceId, - handler: _handleObservableType, - ); - } - - void _handleObservableType(FolderNotification ty, Either result) { - switch (ty) { - case FolderNotification.WorkspaceUpdated: - result.fold( - (payload) => _workspaceUpdatedNotifier?.value = left(WorkspacePB.fromBuffer(payload)), - (error) => _workspaceUpdatedNotifier?.value = right(error), - ); - break; - case FolderNotification.WorkspaceAppsChanged: - result.fold( - (payload) => _appsChangedNotifier?.value = left(RepeatedAppPB.fromBuffer(payload).items), - (error) => _appsChangedNotifier?.value = right(error), - ); - break; - default: - break; - } - } - - Future stop() async { - await _listener?.stop(); - _appsChangedNotifier?.dispose(); - _appsChangedNotifier = null; - - _workspaceUpdatedNotifier?.dispose(); - _workspaceUpdatedNotifier = null; - } -} diff --git a/frontend/app_flowy/lib/workspace/application/workspace/workspace_service.dart b/frontend/app_flowy/lib/workspace/application/workspace/workspace_service.dart deleted file mode 100644 index c6b7d4cba3184..0000000000000 --- a/frontend/app_flowy/lib/workspace/application/workspace/workspace_service.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'dart:async'; - -import 'package:dartz/dartz.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart' - show MoveFolderItemPayloadPB, MoveFolderItemType; -import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart'; - -import 'package:app_flowy/generated/locale_keys.g.dart'; - -class WorkspaceService { - final String workspaceId; - WorkspaceService({ - required this.workspaceId, - }); - Future> createApp( - {required String name, String? desc}) { - final payload = CreateAppPayloadPB.create() - ..name = name - ..workspaceId = workspaceId - ..desc = desc ?? ""; - return FolderEventCreateApp(payload).send(); - } - - Future> getWorkspace() { - final payload = WorkspaceIdPB.create()..value = workspaceId; - return FolderEventReadWorkspaces(payload).send().then((result) { - return result.fold( - (workspaces) { - assert(workspaces.items.length == 1); - - if (workspaces.items.isEmpty) { - return right(FlowyError.create() - ..msg = LocaleKeys.workspace_notFoundError.tr()); - } else { - return left(workspaces.items[0]); - } - }, - (error) => right(error), - ); - }); - } - - Future, FlowyError>> getApps() { - final payload = WorkspaceIdPB.create()..value = workspaceId; - return FolderEventReadWorkspaceApps(payload).send().then((result) { - return result.fold( - (apps) => left(apps.items), - (error) => right(error), - ); - }); - } - - Future> moveApp({ - required String appId, - required int fromIndex, - required int toIndex, - }) { - final payload = MoveFolderItemPayloadPB.create() - ..itemId = appId - ..from = fromIndex - ..to = toIndex - ..ty = MoveFolderItemType.MoveApp; - - return FolderEventMoveFolderItem(payload).send(); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/home_layout.dart b/frontend/app_flowy/lib/workspace/presentation/home/home_layout.dart deleted file mode 100644 index e515eb9a1b489..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/home_layout.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'dart:io' show Platform; - -import 'package:app_flowy/workspace/application/home/home_bloc.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flutter/material.dart'; -// ignore: import_of_legacy_library_into_null_safe -import 'package:sized_context/sized_context.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'home_sizes.dart'; - -class HomeLayout { - late double menuWidth; - late bool showMenu; - late bool menuIsDrawer; - late bool showEditPanel; - late double editPanelWidth; - late double homePageLOffset; - late double homePageROffset; - late double menuSpacing; - late Duration animDuration; - - HomeLayout(BuildContext context, BoxConstraints homeScreenConstraint, - bool forceCollapse) { - final homeBlocState = context.read().state; - - showEditPanel = homeBlocState.panelContext.isSome(); - - menuWidth = Sizes.sideBarMed; - if (context.widthPx >= PageBreaks.desktop) { - menuWidth = Sizes.sideBarLg; - } - - menuWidth += homeBlocState.resizeOffset; - - if (forceCollapse) { - showMenu = false; - } else { - showMenu = true; - menuIsDrawer = context.widthPx <= PageBreaks.tabletPortrait; - } - - homePageLOffset = (showMenu && !menuIsDrawer) ? menuWidth : 0.0; - - menuSpacing = !showMenu && Platform.isMacOS ? 80.0 : 0.0; - animDuration = homeBlocState.resizeType.duration(); - - editPanelWidth = HomeSizes.editPanelWidth; - homePageROffset = showEditPanel ? editPanelWidth : 0; - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart b/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart deleted file mode 100644 index 4995289bc0964..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart +++ /dev/null @@ -1,290 +0,0 @@ -import 'package:app_flowy/plugins/blank/blank.dart'; -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:app_flowy/workspace/application/home/home_bloc.dart'; -import 'package:app_flowy/workspace/application/home/home_service.dart'; - -import 'package:app_flowy/workspace/presentation/home/hotkeys.dart'; -import 'package:app_flowy/workspace/application/view/view_ext.dart'; -import 'package:app_flowy/workspace/presentation/widgets/edit_panel/panel_animation.dart'; -import 'package:app_flowy/workspace/presentation/widgets/float_bubble/question_bubble.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_infra_ui/style_widget/container.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:flowy_sdk/protobuf/flowy-folder/protobuf.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:styled_widget/styled_widget.dart'; - -import '../widgets/edit_panel/edit_panel.dart'; - -import 'home_layout.dart'; -import 'home_stack.dart'; -import 'menu/menu.dart'; - -class HomeScreen extends StatefulWidget { - final UserProfilePB user; - final WorkspaceSettingPB workspaceSetting; - const HomeScreen(this.user, this.workspaceSetting, {Key? key}) - : super(key: key); - - @override - State createState() => _HomeScreenState(); -} - -class _HomeScreenState extends State { - @override - Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) { - return HomeBloc(widget.user, widget.workspaceSetting) - ..add(const HomeEvent.initial()); - }, - ), - ], - child: HomeHotKeys( - child: Scaffold( - body: BlocListener( - listenWhen: (p, c) => p.unauthorized != c.unauthorized, - listener: (context, state) { - if (state.unauthorized) { - Log.error("Push to login screen when user token was invalid"); - } - }, - child: BlocBuilder( - buildWhen: (previous, current) => previous != current, - builder: (context, state) { - final collapsedNotifier = - getIt().collapsedNotifier; - collapsedNotifier.addPublishListener((isCollapsed) { - context - .read() - .add(HomeEvent.forceCollapse(isCollapsed)); - }); - return FlowyContainer( - Theme.of(context).colorScheme.surface, - // Colors.white, - child: _buildBody(context, state), - ); - }, - ), - ), - )), - ); - } - - Widget _buildBody(BuildContext context, HomeState state) { - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final layout = HomeLayout(context, constraints, state.forceCollapse); - final homeStack = HomeStack( - layout: layout, - delegate: HomeScreenStackAdaptor( - buildContext: context, - homeState: state, - ), - ); - final menu = _buildHomeMenu( - layout: layout, - context: context, - state: state, - ); - final homeMenuResizer = _buildHomeMenuResizer(context: context); - final editPanel = _buildEditPanel( - homeState: state, - layout: layout, - context: context, - ); - const bubble = QuestionBubble(); - return _layoutWidgets( - layout: layout, - homeStack: homeStack, - homeMenu: menu, - editPanel: editPanel, - bubble: bubble, - homeMenuResizer: homeMenuResizer, - ); - }, - ); - } - - Widget _buildHomeMenu( - {required HomeLayout layout, - required BuildContext context, - required HomeState state}) { - final workspaceSetting = state.workspaceSetting; - final homeMenu = HomeMenu( - user: widget.user, - workspaceSetting: workspaceSetting, - collapsedNotifier: getIt().collapsedNotifier, - ); - - // Only open the last opened view if the [HomeStackManager] current opened - // plugin is blank and the last opened view is not null. - // - // All opened widgets that display on the home screen are in the form - // of plugins. There is a list of built-in plugins defined in the - // [PluginType] enum, including board, grid and trash. - if (getIt().plugin.ty == PluginType.blank) { - // Open the last opened view. - if (workspaceSetting.hasLatestView()) { - final view = workspaceSetting.latestView; - final plugin = makePlugin( - pluginType: view.pluginType, - data: view, - ); - getIt().setPlugin(plugin); - getIt().latestOpenView = view; - } - } - - return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu)); - } - - Widget _buildEditPanel( - {required HomeState homeState, - required BuildContext context, - required HomeLayout layout}) { - final homeBloc = context.read(); - return BlocBuilder( - buildWhen: (previous, current) => - previous.panelContext != current.panelContext, - builder: (context, state) { - return state.panelContext.fold( - () => const SizedBox(), - (panelContext) => FocusTraversalGroup( - child: RepaintBoundary( - child: EditPanel( - panelContext: panelContext, - onEndEdit: () => - homeBloc.add(const HomeEvent.dismissEditPanel()), - ), - ), - ), - ); - }, - ); - } - - Widget _buildHomeMenuResizer({ - required BuildContext context, - }) { - return MouseRegion( - cursor: SystemMouseCursors.resizeLeftRight, - child: GestureDetector( - dragStartBehavior: DragStartBehavior.down, - onHorizontalDragStart: (details) => context - .read() - .add(const HomeEvent.editPanelResizeStart()), - onHorizontalDragUpdate: (details) => context - .read() - .add(HomeEvent.editPanelResized(details.localPosition.dx)), - onHorizontalDragEnd: (details) => context - .read() - .add(const HomeEvent.editPanelResizeEnd()), - onHorizontalDragCancel: () => context - .read() - .add(const HomeEvent.editPanelResizeEnd()), - behavior: HitTestBehavior.translucent, - child: SizedBox( - width: 10, - height: MediaQuery.of(context).size.height, - )), - ); - } - - Widget _layoutWidgets({ - required HomeLayout layout, - required Widget homeMenu, - required Widget homeStack, - required Widget editPanel, - required Widget bubble, - required Widget homeMenuResizer, - }) { - return Stack( - children: [ - homeStack - .constrained(minWidth: 500) - .positioned( - left: layout.homePageLOffset, - right: layout.homePageROffset, - bottom: 0, - top: 0, - animate: true) - .animate(layout.animDuration, Curves.easeOut), - bubble - .positioned( - right: 20, - bottom: 16, - animate: true, - ) - .animate(layout.animDuration, Curves.easeOut), - editPanel - .animatedPanelX( - duration: layout.animDuration.inMilliseconds * 0.001, - closeX: layout.editPanelWidth, - isClosed: !layout.showEditPanel, - ) - .positioned( - right: 0, top: 0, bottom: 0, width: layout.editPanelWidth), - homeMenu - .animatedPanelX( - closeX: -layout.menuWidth, - isClosed: !layout.showMenu, - ) - .positioned( - left: 0, - top: 0, - width: layout.menuWidth, - bottom: 0, - animate: true) - .animate(layout.animDuration, Curves.easeOut), - homeMenuResizer - .positioned(left: layout.homePageLOffset - 5) - .animate(layout.animDuration, Curves.easeOut), - ], - ); - } -} - -class HomeScreenStackAdaptor extends HomeStackDelegate { - final BuildContext buildContext; - final HomeState homeState; - - HomeScreenStackAdaptor({ - required this.buildContext, - required this.homeState, - }); - - @override - void didDeleteStackWidget(ViewPB view, int? index) { - final homeService = HomeService(); - homeService.readApp(appId: view.appId).then((result) { - result.fold( - (appPB) { - final List views = appPB.belongings.items; - if (views.isNotEmpty) { - var lastView = views.last; - if (index != null && index != 0 && views.length > index - 1) { - lastView = views[index - 1]; - } - - final plugin = makePlugin( - pluginType: lastView.pluginType, - data: lastView, - ); - getIt().latestOpenView = lastView; - getIt().setPlugin(plugin); - } else { - getIt().latestOpenView = null; - getIt().setPlugin(BlankPagePlugin()); - } - }, - (err) => Log.error(err), - ); - }); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/home_sizes.dart b/frontend/app_flowy/lib/workspace/presentation/home/home_sizes.dart deleted file mode 100644 index 299d3fe03c019..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/home_sizes.dart +++ /dev/null @@ -1,10 +0,0 @@ -class HomeSizes { - static double get menuAddButtonHeight => 60; - static double get topBarHeight => 60; - static double get editPanelTopBarHeight => 60; - static double get editPanelWidth => 400; -} - -class HomeInsets { - static double get topBarTitlePadding => 12; -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart b/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart deleted file mode 100644 index 4ae0420b36feb..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart +++ /dev/null @@ -1,228 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/plugins/blank/blank.dart'; -import 'package:app_flowy/workspace/presentation/home/toast.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:time/time.dart'; -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:app_flowy/workspace/presentation/home/home_sizes.dart'; -import 'package:app_flowy/workspace/presentation/home/navigation.dart'; -import 'package:app_flowy/core/frameless_window.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_infra_ui/style_widget/extension.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'home_layout.dart'; - -typedef NavigationCallback = void Function(String id); - -abstract class HomeStackDelegate { - void didDeleteStackWidget(ViewPB view, int? index); -} - -class HomeStack extends StatelessWidget { - final HomeStackDelegate delegate; - final HomeLayout layout; - const HomeStack({ - required this.delegate, - required this.layout, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - getIt().stackTopBar(layout: layout), - Expanded( - child: Container( - color: theme.surface, - child: FocusTraversalGroup( - child: getIt().stackWidget( - onDeleted: (view, index) { - delegate.didDeleteStackWidget(view, index); - }, - ), - ), - ), - ), - ], - ); - } -} - -class FadingIndexedStack extends StatefulWidget { - final int index; - final List children; - final Duration duration; - - const FadingIndexedStack({ - Key? key, - required this.index, - required this.children, - this.duration = const Duration( - milliseconds: 250, - ), - }) : super(key: key); - - @override - FadingIndexedStackState createState() => FadingIndexedStackState(); -} - -class FadingIndexedStackState extends State { - double _targetOpacity = 1; - - @override - void initState() { - super.initState(); - initToastWithContext(context); - } - - @override - void didUpdateWidget(FadingIndexedStack oldWidget) { - if (oldWidget.index == widget.index) return; - setState(() => _targetOpacity = 0); - Future.delayed(1.milliseconds, () => setState(() => _targetOpacity = 1)); - super.didUpdateWidget(oldWidget); - } - - @override - Widget build(BuildContext context) { - return TweenAnimationBuilder( - duration: _targetOpacity > 0 ? widget.duration : 0.milliseconds, - tween: Tween(begin: 0, end: _targetOpacity), - builder: (_, value, child) { - return Opacity(opacity: value, child: child); - }, - child: IndexedStack(index: widget.index, children: widget.children), - ); - } -} - -abstract class NavigationItem { - Widget get leftBarItem; - Widget? get rightBarItem => null; - - NavigationCallback get action => (id) { - getIt().setStackWithId(id); - }; -} - -class HomeStackNotifier extends ChangeNotifier { - Plugin _plugin; - PublishNotifier collapsedNotifier = PublishNotifier(); - - Widget get titleWidget => _plugin.display.leftBarItem; - - HomeStackNotifier({Plugin? plugin}) - : _plugin = plugin ?? makePlugin(pluginType: PluginType.blank); - - set plugin(Plugin newPlugin) { - if (newPlugin.id == _plugin.id) { - return; - } - - _plugin.notifier?.isDisplayChanged.addListener(notifyListeners); - _plugin.dispose(); - - _plugin = newPlugin; - _plugin.notifier?.isDisplayChanged.removeListener(notifyListeners); - notifyListeners(); - } - - Plugin get plugin => _plugin; -} - -// HomeStack is initialized as singleton to control the page stack. -class HomeStackManager { - final HomeStackNotifier _notifier = HomeStackNotifier(); - HomeStackManager(); - - Widget title() { - return _notifier.plugin.display.leftBarItem; - } - - PublishNotifier get collapsedNotifier => _notifier.collapsedNotifier; - Plugin get plugin => _notifier.plugin; - - void setPlugin(Plugin newPlugin) { - _notifier.plugin = newPlugin; - } - - void setStackWithId(String id) { - // Navigate to the page with id - } - - Widget stackTopBar({required HomeLayout layout}) { - return MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: _notifier), - ], - child: Selector( - selector: (context, notifier) => notifier.titleWidget, - builder: (context, widget, child) { - return MoveWindowDetector(child: HomeTopBar(layout: layout)); - }, - ), - ); - } - - Widget stackWidget({required Function(ViewPB, int?) onDeleted}) { - return MultiProvider( - providers: [ChangeNotifierProvider.value(value: _notifier)], - child: Consumer(builder: (ctx, HomeStackNotifier notifier, child) { - return FadingIndexedStack( - index: getIt().indexOf(notifier.plugin.ty), - children: getIt().supportPluginTypes.map((pluginType) { - if (pluginType == notifier.plugin.ty) { - return notifier.plugin.display - .buildWidget(PluginContext(onDeleted: onDeleted)) - .padding(horizontal: 40, vertical: 28); - } else { - return const BlankPage(); - } - }).toList(), - ); - }), - ); - } -} - -class HomeTopBar extends StatelessWidget { - const HomeTopBar({Key? key, required this.layout}) : super(key: key); - - final HomeLayout layout; - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return Container( - color: theme.surface, - height: HomeSizes.topBarHeight, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - HSpace(layout.menuSpacing), - const FlowyNavigation(), - const HSpace(16), - ChangeNotifierProvider.value( - value: Provider.of(context, listen: false), - child: Consumer( - builder: (BuildContext context, HomeStackNotifier notifier, - Widget? child) { - return notifier.plugin.display.rightBarItem ?? const SizedBox(); - }, - ), - ) // _renderMoreButton(), - ], - ) - .padding( - horizontal: HomeInsets.topBarTitlePadding, - ) - .bottomBorder(color: Colors.grey.shade300), - ); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/hotkeys.dart b/frontend/app_flowy/lib/workspace/presentation/home/hotkeys.dart deleted file mode 100644 index 0ac9cbc704455..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/hotkeys.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:io'; - -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/workspace/application/home/home_bloc.dart'; -import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; -import 'package:flutter/material.dart'; -import 'package:hotkey_manager/hotkey_manager.dart'; -import 'package:provider/provider.dart'; - -class HomeHotKeys extends StatelessWidget { - final Widget child; - const HomeHotKeys({required this.child, Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - HotKey hotKey = HotKey( - KeyCode.backslash, - modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], - // Set hotkey scope (default is HotKeyScope.system) - scope: HotKeyScope.inapp, // Set as inapp-wide hotkey. - ); - hotKeyManager.register( - hotKey, - keyDownHandler: (hotKey) { - context.read().add(const HomeEvent.collapseMenu()); - getIt().collapsedNotifier.value = - !getIt().collapsedNotifier.currentValue!; - }, - ); - return child; - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/create_button.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/create_button.dart deleted file mode 100644 index 2db3f66bcfe55..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/create_button.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:app_flowy/workspace/presentation/home/home_sizes.dart'; -import 'package:app_flowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flutter/material.dart'; -import 'package:flowy_infra_ui/style_widget/extension.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -// ignore: implementation_imports - -class NewAppButton extends StatelessWidget { - final Function(String)? press; - - const NewAppButton({this.press, Key? key}) : super(key: key); - @override - Widget build(BuildContext context) { - final child = FlowyTextButton( - LocaleKeys.newPageText.tr(), - fontSize: FontSizes.s12, - fontWeight: FontWeight.w500, - onPressed: () async => await _showCreateAppDialog(context), - heading: svgWithSize("home/new_app", const Size(16, 16)), - padding: EdgeInsets.symmetric(horizontal: Insets.l, vertical: 20), - ); - - return SizedBox( - height: HomeSizes.menuAddButtonHeight, - child: child, - ).topBorder(color: Colors.grey.shade300); - } - - Future _showCreateAppDialog(BuildContext context) async { - return NavigatorTextFieldDialog( - title: LocaleKeys.newPageText.tr(), - value: "", - confirm: (newValue) { - if (newValue.isNotEmpty && press != null) { - press!(newValue); - } - }, - ).show(context); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/header/add_button.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/header/add_button.dart deleted file mode 100644 index 80dd484dcb299..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/header/add_button.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:app_flowy/startup/plugin/plugin.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:styled_widget/styled_widget.dart'; - -class AddButton extends StatelessWidget { - final Function(PluginBuilder) onSelected; - const AddButton({ - Key? key, - required this.onSelected, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return FlowyIconButton( - hoverColor: theme.hover, - width: 22, - onPressed: () { - ActionList( - anchorContext: context, - onSelected: onSelected, - ).show(context); - }, - icon: svgWidget("home/add", color: theme.iconColor) - .padding(horizontal: 3, vertical: 3), - ); - } -} - -class ActionList { - final Function(PluginBuilder) onSelected; - final BuildContext anchorContext; - final String _identifier = 'DisclosureButtonActionList'; - - const ActionList({required this.anchorContext, required this.onSelected}); - - void show(BuildContext buildContext) { - final items = pluginBuilders().map( - (pluginBuilder) { - return CreateItem( - pluginBuilder: pluginBuilder, - onSelected: (builder) { - onSelected(builder); - FlowyOverlay.of(buildContext).remove(_identifier); - }, - ); - }, - ).toList(); - - ListOverlay.showWithAnchor( - buildContext, - identifier: _identifier, - itemCount: items.length, - itemBuilder: (context, index) => items[index], - anchorContext: anchorContext, - anchorDirection: AnchorDirection.bottomRight, - constraints: BoxConstraints( - minWidth: 120, - maxWidth: 280, - minHeight: items.length * (CreateItem.height), - maxHeight: items.length * (CreateItem.height), - ), - ); - } -} - -class CreateItem extends StatelessWidget { - static const double height = 30; - static const double verticalPadding = 6; - - final PluginBuilder pluginBuilder; - final Function(PluginBuilder) onSelected; - const CreateItem({ - Key? key, - required this.pluginBuilder, - required this.onSelected, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - final config = HoverStyle(hoverColor: theme.hover); - - return FlowyHover( - style: config, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onSelected(pluginBuilder), - child: ConstrainedBox( - constraints: const BoxConstraints( - minWidth: 120, - minHeight: CreateItem.height, - ), - child: Align( - alignment: Alignment.centerLeft, - child: FlowyText.medium( - pluginBuilder.menuName, - color: theme.textColor, - fontSize: 12, - ).padding(horizontal: 10), - ), - ), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/header/header.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/header/header.dart deleted file mode 100644 index c7680e8b308ff..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/header/header.dart +++ /dev/null @@ -1,199 +0,0 @@ -import 'package:app_flowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:app_flowy/workspace/presentation/widgets/pop_up_action.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:expandable/expandable.dart'; -import 'package:flowy_infra/icon_data.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:app_flowy/workspace/application/app/app_bloc.dart'; -import 'package:styled_widget/styled_widget.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -import '../menu_app.dart'; -import 'add_button.dart'; - -class MenuAppHeader extends StatelessWidget { - final AppPB app; - const MenuAppHeader( - this.app, { - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.read(); - return SizedBox( - height: MenuAppSizes.headerHeight, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _renderExpandedIcon(context, theme), - // HSpace(MenuAppSizes.iconPadding), - _renderTitle(context, theme), - _renderCreateViewButton(context), - ], - ), - ); - } - - Widget _renderExpandedIcon(BuildContext context, AppTheme theme) { - return SizedBox( - width: MenuAppSizes.headerHeight, - height: MenuAppSizes.headerHeight, - child: InkWell( - onTap: () { - ExpandableController.of(context, - rebuildOnChange: false, required: true) - ?.toggle(); - }, - child: ExpandableIcon( - theme: ExpandableThemeData( - expandIcon: FlowyIconData.drop_down_show, - collapseIcon: FlowyIconData.drop_down_hide, - iconColor: theme.shader1, - iconSize: MenuAppSizes.iconSize, - iconPadding: const EdgeInsets.fromLTRB(0, 0, 10, 0), - hasIcon: false, - ), - ), - ), - ); - } - - Widget _renderTitle(BuildContext context, AppTheme theme) { - return Expanded( - child: BlocListener( - listenWhen: (p, c) => - (p.latestCreatedView == null && c.latestCreatedView != null), - listener: (context, state) { - final expandableController = ExpandableController.of(context, - rebuildOnChange: false, required: true)!; - if (!expandableController.expanded) { - expandableController.toggle(); - } - }, - child: AppActionList(onSelected: (action) { - switch (action) { - case AppDisclosureAction.rename: - NavigatorTextFieldDialog( - title: LocaleKeys.menuAppHeader_renameDialog.tr(), - value: context.read().state.app.name, - confirm: (newValue) { - context.read().add(AppEvent.rename(newValue)); - }, - ).show(context); - - break; - case AppDisclosureAction.delete: - context.read().add(const AppEvent.delete()); - break; - } - }), - ), - ); - } - - Widget _renderCreateViewButton(BuildContext context) { - return Tooltip( - message: LocaleKeys.menuAppHeader_addPageTooltip.tr(), - textStyle: TextStyles.caption.textColor(Colors.white), - child: AddButton( - onSelected: (pluginBuilder) { - context.read().add( - AppEvent.createView( - LocaleKeys.menuAppHeader_defaultNewPageName.tr(), - pluginBuilder, - ), - ); - }, - ).padding(right: MenuAppSizes.headerPadding), - ); - } -} - -enum AppDisclosureAction { - rename, - delete, -} - -extension AppDisclosureExtension on AppDisclosureAction { - String get name { - switch (this) { - case AppDisclosureAction.rename: - return LocaleKeys.disclosureAction_rename.tr(); - case AppDisclosureAction.delete: - return LocaleKeys.disclosureAction_delete.tr(); - } - } - - Widget icon(Color iconColor) { - switch (this) { - case AppDisclosureAction.rename: - return svgWidget('editor/edit', color: iconColor); - case AppDisclosureAction.delete: - return svgWidget('editor/delete', color: iconColor); - } - } -} - -class AppActionList extends StatelessWidget { - final Function(AppDisclosureAction) onSelected; - const AppActionList({ - required this.onSelected, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.read(); - return PopoverActionList( - direction: PopoverDirection.bottomWithCenterAligned, - actions: AppDisclosureAction.values - .map((action) => DisclosureActionWrapper(action)) - .toList(), - buildChild: (controller) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => ExpandableController.of(context, - rebuildOnChange: false, required: true) - ?.toggle(), - onSecondaryTap: () { - controller.show(); - }, - child: BlocSelector( - selector: (state) => state.app, - builder: (context, app) => FlowyText.medium( - app.name, - fontSize: 12, - color: theme.textColor, - overflow: TextOverflow.ellipsis, - ), - ), - ); - }, - onSelected: (action, controller) { - onSelected(action.inner); - controller.close(); - }, - ); - } -} - -class DisclosureActionWrapper extends ActionCell { - final AppDisclosureAction inner; - - DisclosureActionWrapper(this.inner); - @override - Widget? icon(Color iconColor) => inner.icon(iconColor); - - @override - String get name => inner.name; -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart deleted file mode 100644 index b4a2e3532c44d..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/menu_app.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:app_flowy/workspace/application/appearance.dart'; -import 'package:app_flowy/workspace/presentation/home/menu/menu.dart'; -import 'package:expandable/expandable.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/workspace/application/app/app_bloc.dart'; -import 'package:provider/provider.dart'; -import 'section/section.dart'; - -class MenuApp extends StatefulWidget { - final AppPB app; - const MenuApp(this.app, {Key? key}) : super(key: key); - - @override - State createState() => _MenuAppState(); -} - -class _MenuAppState extends State { - late AppViewDataContext viewDataContext; - - @override - void initState() { - viewDataContext = AppViewDataContext(appId: widget.app.id); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) { - final appBloc = getIt(param1: widget.app); - appBloc.add(const AppEvent.initial()); - return appBloc; - }, - ), - ], - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (p, c) => p.latestCreatedView != c.latestCreatedView, - listener: (context, state) { - if (state.latestCreatedView != null) { - getIt().latestOpenView = - state.latestCreatedView; - } - }, - ), - BlocListener( - listener: (context, state) => viewDataContext.views = state.views, - ), - ], - child: BlocBuilder( - builder: (context, state) { - return ChangeNotifierProvider.value( - value: viewDataContext, - child: Consumer( - builder: (context, viewDataContext, _) { - return expandableWrapper(context, viewDataContext); - }, - ), - ); - }, - ), - ), - ); - } - - ExpandableNotifier expandableWrapper( - BuildContext context, AppViewDataContext viewDataContext) { - return ExpandableNotifier( - controller: viewDataContext.expandController, - child: ScrollOnExpand( - scrollOnExpand: false, - scrollOnCollapse: false, - child: Column( - children: [ - ExpandablePanel( - theme: const ExpandableThemeData( - headerAlignment: ExpandablePanelHeaderAlignment.center, - tapBodyToExpand: false, - tapBodyToCollapse: false, - tapHeaderToExpand: false, - iconPadding: EdgeInsets.zero, - hasIcon: false, - ), - header: ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), - child: MenuAppHeader(widget.app), - ), - expanded: ViewSection(appViewData: viewDataContext), - collapsed: const SizedBox(), - ), - ], - ), - ), - ); - } - - @override - void didUpdateWidget(covariant MenuApp oldWidget) { - super.didUpdateWidget(oldWidget); - } - - @override - void dispose() { - viewDataContext.dispose(); - super.dispose(); - } -} - -class MenuAppSizes { - static double iconSize = 16; - static double headerHeight = 26; - static double headerPadding = 6; - static double iconPadding = 6; - static double appVPadding = 14; - static double scale = 1; - static double get expandedPadding => iconSize * scale + headerPadding; -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/item.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/item.dart deleted file mode 100644 index 624f823eed88a..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/item.dart +++ /dev/null @@ -1,215 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/workspace/application/view/view_bloc.dart'; -import 'package:app_flowy/workspace/application/view/view_ext.dart'; -import 'package:app_flowy/workspace/presentation/home/menu/menu.dart'; -import 'package:app_flowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:styled_widget/styled_widget.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:flowy_infra/image.dart'; - -import 'package:app_flowy/workspace/presentation/widgets/pop_up_action.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; - -// ignore: must_be_immutable -class ViewSectionItem extends StatelessWidget { - final bool isSelected; - final ViewPB view; - final void Function(ViewPB) onSelected; - - ViewSectionItem({ - Key? key, - required this.view, - required this.isSelected, - required this.onSelected, - }) : super(key: ValueKey('$view.hashCode/$isSelected')); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (ctx) => getIt(param1: view) - ..add( - const ViewEvent.initial(), - ), - ), - ], - child: BlocBuilder( - builder: (blocContext, state) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: InkWell( - onTap: () => onSelected(blocContext.read().state.view), - child: FlowyHover( - style: HoverStyle(hoverColor: theme.bg3), - // If current state.isEditing is true, the hover should not - // rebuild when onEnter/onExit events happened. - buildWhenOnHover: () => !state.isEditing, - builder: (_, onHover) => _render( - blocContext, - onHover, - state, - theme.iconColor, - ), - isSelected: () => state.isEditing || isSelected, - ), - ), - ); - }, - ), - ); - } - - Widget _render( - BuildContext blocContext, - bool onHover, - ViewState state, - Color iconColor, - ) { - List children = [ - SizedBox( - width: 16, - height: 16, - child: state.view.renderThumbnail(iconColor: iconColor), - ), - const HSpace(2), - Expanded( - child: FlowyText.regular( - state.view.name, - fontSize: 12, - overflow: TextOverflow.ellipsis, - ), - ), - ]; - - if (onHover || state.isEditing) { - children.add( - ViewDisclosureButton( - onEdit: (isEdit) => - blocContext.read().add(ViewEvent.setIsEditing(isEdit)), - onAction: (action) { - switch (action) { - case ViewDisclosureAction.rename: - NavigatorTextFieldDialog( - title: LocaleKeys.disclosureAction_rename.tr(), - value: blocContext.read().state.view.name, - confirm: (newValue) { - blocContext - .read() - .add(ViewEvent.rename(newValue)); - }, - ).show(blocContext); - - break; - case ViewDisclosureAction.delete: - blocContext.read().add(const ViewEvent.delete()); - break; - case ViewDisclosureAction.duplicate: - blocContext.read().add(const ViewEvent.duplicate()); - break; - } - }, - ), - ); - } - - return SizedBox( - height: 26, - child: Row(children: children).padding( - left: MenuAppSizes.expandedPadding, - right: MenuAppSizes.headerPadding, - ), - ); - } -} - -enum ViewDisclosureAction { - rename, - delete, - duplicate, -} - -extension ViewDisclosureExtension on ViewDisclosureAction { - String get name { - switch (this) { - case ViewDisclosureAction.rename: - return LocaleKeys.disclosureAction_rename.tr(); - case ViewDisclosureAction.delete: - return LocaleKeys.disclosureAction_delete.tr(); - case ViewDisclosureAction.duplicate: - return LocaleKeys.disclosureAction_duplicate.tr(); - } - } - - Widget icon(Color iconColor) { - switch (this) { - case ViewDisclosureAction.rename: - return svgWidget('editor/edit', color: iconColor); - case ViewDisclosureAction.delete: - return svgWidget('editor/delete', color: iconColor); - case ViewDisclosureAction.duplicate: - return svgWidget('editor/copy', color: iconColor); - } - } -} - -class ViewDisclosureButton extends StatelessWidget { - final Function(bool) onEdit; - final Function(ViewDisclosureAction) onAction; - const ViewDisclosureButton({ - required this.onEdit, - required this.onAction, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return PopoverActionList( - direction: PopoverDirection.bottomWithCenterAligned, - actions: ViewDisclosureAction.values - .map((action) => ViewDisclosureActionWrapper(action)) - .toList(), - buildChild: (controller) { - return FlowyIconButton( - iconPadding: const EdgeInsets.all(5), - width: 26, - icon: svgWidget("editor/details", color: theme.iconColor), - onPressed: () { - onEdit(true); - controller.show(); - }, - ); - }, - onSelected: (action, controller) { - onEdit(false); - onAction(action.inner); - controller.close(); - }, - onClosed: () { - onEdit(false); - }, - ); - } -} - -class ViewDisclosureActionWrapper extends ActionCell { - final ViewDisclosureAction inner; - - ViewDisclosureActionWrapper(this.inner); - @override - Widget? icon(Color iconColor) => inner.icon(iconColor); - - @override - String get name => inner.name; -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/section.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/section.dart deleted file mode 100644 index d352db66202da..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/section.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/workspace/application/app/app_bloc.dart'; -import 'package:app_flowy/workspace/application/menu/menu_view_section_bloc.dart'; -import 'package:app_flowy/workspace/application/view/view_ext.dart'; -import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; -import 'package:app_flowy/workspace/presentation/home/menu/menu.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:reorderables/reorderables.dart'; - -import 'item.dart'; - -class ViewSection extends StatelessWidget { - final AppViewDataContext appViewData; - const ViewSection({Key? key, required this.appViewData}) : super(key: key); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) { - final bloc = ViewSectionBloc(appViewData: appViewData); - bloc.add(const ViewSectionEvent.initial()); - return bloc; - }, - child: BlocListener( - listenWhen: (p, c) => p.selectedView != c.selectedView, - listener: (context, state) { - if (state.selectedView != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - getIt().setPlugin(state.selectedView!.plugin()); - }); - } - }, - child: BlocBuilder( - builder: (context, state) { - return _reorderableColum(context, state); - }, - ), - ), - ); - } - - ReorderableColumn _reorderableColum(BuildContext context, ViewSectionState state) { - final children = state.views.map((view) { - return ViewSectionItem( - key: ValueKey(view.id), - view: view, - isSelected: _isViewSelected(state, view.id), - onSelected: (view) => getIt().latestOpenView = view, - ); - }).toList(); - - return ReorderableColumn( - needsLongPressDraggable: false, - onReorder: (oldIndex, index) { - context.read().add(ViewSectionEvent.moveView(oldIndex, index)); - }, - children: children, - ); - } - - bool _isViewSelected(ViewSectionState state, String viewId) { - final view = state.selectedView; - if (view == null) { - return false; - } - return view.id == viewId; - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/favorite/header.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/favorite/header.dart deleted file mode 100644 index 5907027044447..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/favorite/header.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -class FavoriteHeader extends StatelessWidget { - const FavoriteHeader({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - throw UnimplementedError(); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/favorite/section.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/favorite/section.dart deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart deleted file mode 100644 index ff2ca68d16648..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart +++ /dev/null @@ -1,239 +0,0 @@ -export './app/header/header.dart'; -export './app/menu_app.dart'; - -import 'dart:io' show Platform; -import 'package:app_flowy/plugins/trash/menu.dart'; -import 'package:app_flowy/workspace/presentation/home/home_sizes.dart'; -import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:styled_widget/styled_widget.dart'; -import 'package:expandable/expandable.dart'; -import 'package:flowy_infra/time/duration.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/workspace/application/menu/menu_bloc.dart'; -import 'package:app_flowy/workspace/application/home/home_bloc.dart'; -import 'package:app_flowy/core/frameless_window.dart'; -// import 'package:app_flowy/workspace/presentation/home/home_sizes.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; - -import '../navigation.dart'; -import 'app/menu_app.dart'; -import 'app/create_button.dart'; -import 'menu_user.dart'; - -class HomeMenu extends StatelessWidget { - final PublishNotifier _collapsedNotifier; - final UserProfilePB user; - final WorkspaceSettingPB workspaceSetting; - - const HomeMenu({ - Key? key, - required this.user, - required this.workspaceSetting, - required PublishNotifier collapsedNotifier, - }) : _collapsedNotifier = collapsedNotifier, - super(key: key); - - @override - Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) { - final menuBloc = MenuBloc( - user: user, - workspace: workspaceSetting.workspace, - ); - menuBloc.add(const MenuEvent.initial()); - return menuBloc; - }, - ), - ], - child: MultiBlocListener( - listeners: [ - BlocListener( - listenWhen: (p, c) => p.plugin.id != c.plugin.id, - listener: (context, state) { - getIt().setPlugin(state.plugin); - }, - ), - BlocListener( - listenWhen: (p, c) => p.isMenuCollapsed != c.isMenuCollapsed, - listener: (context, state) { - _collapsedNotifier.value = state.isMenuCollapsed; - }, - ) - ], - child: BlocBuilder( - builder: (context, state) => _renderBody(context), - ), - ), - ); - } - - Widget _renderBody(BuildContext context) { - // nested column: https://siddharthmolleti.com/flutter-box-constraints-nested-column-s-row-s-3dfacada7361 - final theme = context.watch(); - return Container( - color: theme.bg1, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - const MenuTopBar(), - const VSpace(10), - _renderApps(context), - ], - ).padding(horizontal: Insets.l), - ), - const VSpace(20), - const MenuTrash().padding(horizontal: Insets.l), - const VSpace(20), - _renderNewAppButton(context), - ], - ), - ); - } - - Widget _renderApps(BuildContext context) { - return ExpandableTheme( - data: ExpandableThemeData( - useInkWell: true, animationDuration: Durations.medium), - child: Expanded( - child: ScrollConfiguration( - behavior: const ScrollBehavior().copyWith(scrollbars: false), - child: BlocSelector>( - selector: (state) => state.apps - .map((app) => MenuApp(app, key: ValueKey(app.id))) - .toList(), - builder: (context, menuItems) { - return ReorderableListView.builder( - itemCount: menuItems.length, - buildDefaultDragHandles: false, - header: Padding( - padding: - EdgeInsets.only(bottom: 20.0 - MenuAppSizes.appVPadding), - child: MenuUser(user), - ), - onReorder: (oldIndex, newIndex) { - // Moving item1 from index 0 to index 1 - // expect: oldIndex: 0, newIndex: 1 - // receive: oldIndex: 0, newIndex: 2 - // Workaround: if newIndex > oldIndex, we just minus one - int index = newIndex > oldIndex ? newIndex - 1 : newIndex; - context - .read() - .add(MenuEvent.moveApp(oldIndex, index)); - }, - physics: StyledScrollPhysics(), - itemBuilder: (BuildContext context, int index) { - return ReorderableDragStartListener( - key: ValueKey(menuItems[index].key), - index: index, - child: Padding( - padding: EdgeInsets.symmetric( - vertical: MenuAppSizes.appVPadding / 2), - child: menuItems[index], - ), - ); - }, - ); - }, - ), - ), - ), - ); - } - - Widget _renderNewAppButton(BuildContext context) { - return NewAppButton( - press: (appName) => - context.read().add(MenuEvent.createApp(appName, desc: "")), - ); - } -} - -class MenuSharedState { - final ValueNotifier _latestOpenView = ValueNotifier(null); - - MenuSharedState({ViewPB? view}) { - _latestOpenView.value = view; - } - - ViewPB? get latestOpenView => _latestOpenView.value; - - set latestOpenView(ViewPB? view) { - if (_latestOpenView.value != view) { - _latestOpenView.value = view; - } - } - - VoidCallback addLatestViewListener(void Function(ViewPB?) callback) { - listener() { - callback(_latestOpenView.value); - } - - _latestOpenView.addListener(listener); - return listener; - } - - void removeLatestViewListener(VoidCallback listener) { - _latestOpenView.removeListener(listener); - } -} - -class MenuTopBar extends StatelessWidget { - const MenuTopBar({Key? key}) : super(key: key); - - Widget renderIcon(BuildContext context) { - if (Platform.isMacOS) { - return Container(); - } - final theme = context.watch(); - return (theme.isDark - ? svgWithSize("flowy_logo_dark_mode", const Size(92, 17)) - : svgWithSize("flowy_logo_with_text", const Size(92, 17))); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BlocBuilder( - builder: (context, state) { - return SizedBox( - height: HomeSizes.topBarHeight, - child: MoveWindowDetector( - child: Row( - children: [ - renderIcon(context), - const Spacer(), - Tooltip( - richMessage: sidebarTooltipTextSpan(), - child: FlowyIconButton( - width: 28, - onPressed: () => context - .read() - .add(const HomeEvent.collapseMenu()), - iconPadding: const EdgeInsets.fromLTRB(4, 4, 4, 4), - icon: svgWidget("home/hide_menu", color: theme.iconColor), - )) - ], - )), - ); - }, - ); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/menu_user.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/menu_user.dart deleted file mode 100644 index 38b096d7f7b7c..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/menu_user.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/workspace/application/menu/menu_user_bloc.dart'; -import 'package:app_flowy/workspace/presentation/settings/settings_dialog.dart'; -import 'package:app_flowy/workspace/presentation/settings/widgets/settings_user_view.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart' show UserProfilePB; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -class MenuUser extends StatelessWidget { - final UserProfilePB user; - MenuUser(this.user, {Key? key}) : super(key: ValueKey(user.id)); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - getIt(param1: user)..add(const MenuUserEvent.initial()), - child: BlocBuilder( - builder: (context, state) => Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _renderAvatar(context), - const HSpace(10), - Expanded( - child: _renderUserName(context), - ), - _renderSettingsButton(context), - //ToDo: when the user is allowed to create another workspace, - //we get the below block back - //_renderDropButton(context), - ], - ), - ), - ); - } - - Widget _renderAvatar(BuildContext context) { - String iconUrl = context.read().state.userProfile.iconUrl; - if (iconUrl.isEmpty) { - iconUrl = defaultUserAvatar; - } - - return SizedBox( - width: 25, - height: 25, - child: ClipRRect( - borderRadius: Corners.s5Border, - child: CircleAvatar( - backgroundColor: Colors.transparent, - child: svgWidget('emoji/$iconUrl'), - )), - ); - } - - Widget _renderUserName(BuildContext context) { - String name = context.read().state.userProfile.name; - if (name.isEmpty) { - name = context.read().state.userProfile.email; - } - return FlowyText.medium( - name, - fontSize: FontSizes.s12, - overflow: TextOverflow.ellipsis, - ); - } - - Widget _renderSettingsButton(BuildContext context) { - final theme = context.watch(); - final userProfile = context.read().state.userProfile; - return Tooltip( - message: LocaleKeys.settings_menu_open.tr(), - textStyle: TextStyles.caption.textColor(Colors.white), - child: IconButton( - onPressed: () { - showDialog( - context: context, - builder: (context) { - return SettingsDialog(userProfile); - }, - ); - }, - icon: SizedBox.square( - dimension: 20, - child: svgWidget("home/settings", color: theme.iconColor), - ), - ), - ); - } - //ToDo: when the user is allowed to create another workspace, - //we get the below block back - // Widget _renderDropButton(BuildContext context) { - // return FlowyDropdownButton( - // onPressed: () { - // debugPrint('show user profile'); - // }, - // ); - // } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/navigation.dart b/frontend/app_flowy/lib/workspace/presentation/home/navigation.dart deleted file mode 100644 index d502c85f9819b..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/navigation.dart +++ /dev/null @@ -1,212 +0,0 @@ -import 'dart:io'; - -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/workspace/application/home/home_bloc.dart'; -import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; -import 'package:flowy_infra/image.dart'; -import 'package:flowy_infra/notifier.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/icon_button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:styled_widget/styled_widget.dart'; -import 'package:easy_localization/easy_localization.dart'; - -typedef NaviAction = void Function(); - -class NavigationNotifier with ChangeNotifier { - List navigationItems; - PublishNotifier collapasedNotifier; - NavigationNotifier( - {required this.navigationItems, required this.collapasedNotifier}); - - void update(HomeStackNotifier notifier) { - bool shouldNotify = false; - if (navigationItems != notifier.plugin.display.navigationItems) { - navigationItems = notifier.plugin.display.navigationItems; - shouldNotify = true; - } - - if (shouldNotify) { - notifyListeners(); - } - } -} - -// [[diagram: HomeStack navigation flow]] -// ┌───────────────────────┐ -// 2.notify listeners ┌──────│DefaultHomeStackContext│ -// ┌────────────────┐ ┌───────────┐ ┌────────────────┐ │ └───────────────────────┘ -// │HomeStackNotifie│◀──────────│ HomeStack │◀──│HomeStackContext│◀─ impl -// └────────────────┘ └───────────┘ └────────────────┘ │ ┌───────────────────┐ -// │ ▲ └───────│ DocStackContext │ -// │ │ └───────────────────┘ -// 3.notify change 1.set context -// │ │ -// ▼ │ -// ┌───────────────────┐ ┌──────────────────┐ -// │NavigationNotifier │ │ ViewSectionItem │ -// └───────────────────┘ └──────────────────┘ -// │ -// │ -// ▼ -// ┌─────────────────┐ -// │ FlowyNavigation │ 4.render navigation items -// └─────────────────┘ -class FlowyNavigation extends StatelessWidget { - const FlowyNavigation({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - return ChangeNotifierProxyProvider( - create: (_) { - final notifier = Provider.of(context, listen: false); - return NavigationNotifier( - navigationItems: notifier.plugin.display.navigationItems, - collapasedNotifier: notifier.collapsedNotifier, - ); - }, - update: (_, notifier, controller) => controller!..update(notifier), - child: Expanded( - child: Row(children: [ - Selector>( - selector: (context, notifier) => notifier.collapasedNotifier, - builder: (ctx, collapsedNotifier, child) => - _renderCollapse(ctx, collapsedNotifier, theme)), - Selector>( - selector: (context, notifier) => notifier.navigationItems, - builder: (ctx, items, child) => Expanded( - child: Row( - children: _renderNavigationItems(items), - // crossAxisAlignment: WrapCrossAlignment.start, - ), - ), - ), - ]), - ), - ); - } - - Widget _renderCollapse(BuildContext context, - PublishNotifier collapsedNotifier, AppTheme theme) { - return ChangeNotifierProvider.value( - value: collapsedNotifier, - child: Consumer( - builder: (ctx, PublishNotifier notifier, child) { - if (notifier.currentValue ?? false) { - return RotationTransition( - turns: const AlwaysStoppedAnimation(180 / 360), - child: Tooltip( - richMessage: sidebarTooltipTextSpan(), - child: FlowyIconButton( - width: 24, - onPressed: () { - notifier.value = false; - ctx.read().add(const HomeEvent.collapseMenu()); - }, - iconPadding: const EdgeInsets.fromLTRB(2, 2, 2, 2), - icon: svgWidget("home/hide_menu", color: theme.iconColor), - )), - ); - } else { - return Container(); - } - }, - ), - ); - } - - List _renderNavigationItems(List items) { - if (items.isEmpty) { - return []; - } - - List newItems = _filter(items); - Widget last = NaviItemWidget(newItems.removeLast()); - - List widgets = List.empty(growable: true); - // widgets.addAll(newItems.map((item) => NaviItemDivider(child: NaviItemWidget(item))).toList()); - - for (final item in newItems) { - widgets.add(NaviItemWidget(item)); - widgets.add(const Text('/')); - } - - widgets.add(last); - - return widgets; - } - - List _filter(List items) { - final length = items.length; - if (length > 4) { - final first = items[0]; - final ellipsisItems = items.getRange(1, length - 2).toList(); - final last = items.getRange(length - 2, length).toList(); - return [ - first, - EllipsisNaviItem(items: ellipsisItems), - ...last, - ]; - } else { - return items; - } - } -} - -class NaviItemWidget extends StatelessWidget { - final NavigationItem item; - const NaviItemWidget(this.item, {Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Expanded( - child: item.leftBarItem.padding(horizontal: 2, vertical: 2)); - } -} - -class NaviItemDivider extends StatelessWidget { - final Widget child; - const NaviItemDivider({Key? key, required this.child}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Row( - children: [child, const Text('/')], - ); - } -} - -class EllipsisNaviItem extends NavigationItem { - final List items; - EllipsisNaviItem({ - required this.items, - }); - - @override - Widget get leftBarItem => const FlowyText.medium('...'); - - @override - NavigationCallback get action => (id) {}; -} - -TextSpan sidebarTooltipTextSpan() => TextSpan( - children: [ - TextSpan( - text: "${LocaleKeys.sideBar_openSidebar.tr()}\n", - style: TextStyles.caption, - ), - TextSpan( - text: Platform.isMacOS ? "⌘+\\" : "Ctrl+\\", - style: TextStyles.general( - fontSize: FontSizes.s11, - color: Colors.white60, - ), - ), - ], - ); diff --git a/frontend/app_flowy/lib/workspace/presentation/home/toast.dart b/frontend/app_flowy/lib/workspace/presentation/home/toast.dart deleted file mode 100644 index d3473adaa9b8c..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/home/toast.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:fluttertoast/fluttertoast.dart'; - -class FlowyMessageToast extends StatelessWidget { - final String message; - const FlowyMessageToast({required this.message, Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(4)), - color: Colors.black, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: FlowyText.medium(message, color: Colors.white), - ), - ); - } -} - -void initToastWithContext(BuildContext context) { - getIt().init(context); -} - -void showMessageToast(String message) { - final child = FlowyMessageToast(message: message); - - getIt().showToast( - child: child, - gravity: ToastGravity.BOTTOM, - toastDuration: const Duration(seconds: 3), - ); -} diff --git a/frontend/app_flowy/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/app_flowy/lib/workspace/presentation/settings/settings_dialog.dart deleted file mode 100644 index d64aea7f74a8c..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/settings/settings_dialog.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/workspace/application/appearance.dart'; -import 'package:app_flowy/workspace/presentation/settings/widgets/settings_appearance_view.dart'; -import 'package:app_flowy/workspace/presentation/settings/widgets/settings_language_view.dart'; -import 'package:app_flowy/workspace/presentation/settings/widgets/settings_user_view.dart'; -import 'package:app_flowy/workspace/presentation/settings/widgets/settings_menu.dart'; -import 'package:app_flowy/workspace/application/settings/settings_dialog_bloc.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:provider/provider.dart'; - -class SettingsDialog extends StatelessWidget { - final UserProfilePB user; - SettingsDialog(this.user, {Key? key}) : super(key: ValueKey(user.id)); - - Widget getSettingsView(int index, UserProfilePB user) { - final List settingsViews = [ - const SettingsAppearanceView(), - const SettingsLanguageView(), - SettingsUserView(user), - ]; - return settingsViews[index]; - } - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => getIt(param1: user) - ..add(const SettingsDialogEvent.initial()), - child: BlocBuilder( - builder: (context, state) => ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), - child: FlowyDialog( - title: FlowyText( - LocaleKeys.settings_title.tr(), - fontSize: 20, - fontWeight: FontWeight.w700, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 200, - child: SettingsMenu( - changeSelectedIndex: (index) { - context - .read() - .add(SettingsDialogEvent.setViewIndex(index)); - }, - currentIndex: context - .read() - .state - .viewIndex, - ), - ), - const VerticalDivider(), - const SizedBox(width: 10), - Expanded( - child: getSettingsView( - context.read().state.viewIndex, - context - .read() - .state - .userProfile, - ), - ) - ], - ), - ), - ))); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart deleted file mode 100644 index f51b053f2dc5b..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/workspace/application/appearance.dart'; -import 'package:app_flowy/workspace/presentation/widgets/toggle/toggle_style.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -import '../../widgets/toggle/toggle.dart'; - -class SettingsAppearanceView extends StatelessWidget { - const SettingsAppearanceView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - LocaleKeys.settings_appearance_lightLabel.tr(), - style: TextStyles.body1.size(FontSizes.s14), - ), - Toggle( - value: theme.isDark, - onChanged: (_) => setTheme(context), - style: ToggleStyle.big(theme), - ), - Text( - LocaleKeys.settings_appearance_darkLabel.tr(), - style: TextStyles.body1.size(FontSizes.s14), - ), - ], - ), - ], - ), - ); - } - - void setTheme(BuildContext context) { - final theme = context.read(); - if (theme.isDark) { - context.read().setTheme(ThemeType.light); - } else { - context.read().setTheme(ThemeType.dark); - } - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart deleted file mode 100644 index 416e361792fa0..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/workspace/application/appearance.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flutter/material.dart'; -import 'package:flowy_infra/language.dart'; -import 'package:provider/provider.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -class SettingsLanguageView extends StatelessWidget { - const SettingsLanguageView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - context.watch(); - return ChangeNotifierProvider.value( - value: Provider.of(context, listen: true), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - LocaleKeys.settings_menu_language.tr(), - style: TextStyles.body1.size(FontSizes.s14), - ), - const LanguageSelectorDropdown(), - ], - ), - ], - ), - ), - ); - } -} - -class LanguageSelectorDropdown extends StatefulWidget { - const LanguageSelectorDropdown({ - Key? key, - }) : super(key: key); - - @override - State createState() => - _LanguageSelectorDropdownState(); -} - -class _LanguageSelectorDropdownState extends State { - Color currHoverColor = Colors.white.withOpacity(0.0); - late Color themedHoverColor; - void hoverExitLanguage() { - setState(() { - currHoverColor = Colors.white.withOpacity(0.0); - }); - } - - void hoverEnterLanguage() { - setState(() { - currHoverColor = themedHoverColor; - }); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - themedHoverColor = theme.main2; - - return MouseRegion( - onEnter: (event) => {hoverEnterLanguage()}, - onExit: (event) => {hoverExitLanguage()}, - child: Container( - margin: const EdgeInsets.only(left: 8, right: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: currHoverColor, - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: context.read().locale, - onChanged: (val) { - setState(() { - context.read().setLocale(context, val!); - }); - }, - icon: const Visibility( - visible: false, - child: (Icon(Icons.arrow_downward)), - ), - borderRadius: BorderRadius.circular(8), - items: EasyLocalization.of(context)!.supportedLocales.map((locale) { - return DropdownMenuItem( - value: locale, - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - languageFromLocale(locale), - style: TextStyles.body1.size(FontSizes.s14), - ), - ), - ); - }).toList(), - ), - ), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_menu.dart deleted file mode 100644 index a27d9861c4520..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:app_flowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -class SettingsMenu extends StatelessWidget { - const SettingsMenu({ - Key? key, - required this.changeSelectedIndex, - required this.currentIndex, - }) : super(key: key); - - final Function changeSelectedIndex; - final int currentIndex; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - SettingsMenuElement( - index: 0, - currentIndex: currentIndex, - label: LocaleKeys.settings_menu_appearance.tr(), - icon: Icons.brightness_4, - changeSelectedIndex: changeSelectedIndex, - ), - const SizedBox( - height: 10, - ), - SettingsMenuElement( - index: 1, - currentIndex: currentIndex, - label: LocaleKeys.settings_menu_language.tr(), - icon: Icons.translate, - changeSelectedIndex: changeSelectedIndex, - ), - const SizedBox( - height: 10, - ), - SettingsMenuElement( - index: 2, - currentIndex: currentIndex, - label: LocaleKeys.settings_menu_user.tr(), - icon: Icons.account_box_outlined, - changeSelectedIndex: changeSelectedIndex, - ), - ], - ); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_menu_element.dart b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_menu_element.dart deleted file mode 100644 index 016d19840e878..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_menu_element.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class SettingsMenuElement extends StatelessWidget { - const SettingsMenuElement({ - Key? key, - required this.index, - required this.label, - required this.icon, - required this.changeSelectedIndex, - required this.currentIndex, - }) : super(key: key); - - final int index; - final int currentIndex; - final String label; - final IconData icon; - final Function changeSelectedIndex; - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return ListTile( - leading: Icon( - icon, - size: 16, - color: index == currentIndex ? Colors.black : theme.textColor, - ), - onTap: () { - changeSelectedIndex(index); - }, - selected: index == currentIndex, - selectedColor: Colors.black, - selectedTileColor: theme.main2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - minLeadingWidth: 0, - title: FlowyText.semibold( - label, - fontSize: FontSizes.s14, - overflow: TextOverflow.ellipsis, - ), - ); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_user_view.dart b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_user_view.dart deleted file mode 100644 index a3fba334cd512..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_user_view.dart +++ /dev/null @@ -1,185 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:app_flowy/workspace/application/user/settings_user_bloc.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; -import 'package:flowy_infra/image.dart'; - -import 'dart:convert'; - -const defaultUserAvatar = '1F600'; - -class SettingsUserView extends StatelessWidget { - final UserProfilePB user; - SettingsUserView(this.user, {Key? key}) : super(key: ValueKey(user.id)); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => getIt(param1: user) - ..add(const SettingsUserEvent.initial()), - child: BlocBuilder( - builder: (context, state) => SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _renderUserNameInput(context), - const VSpace(20), - _renderCurrentIcon(context) - ], - ), - ), - ), - ); - } - - Widget _renderUserNameInput(BuildContext context) { - String name = context.read().state.userProfile.name; - return _UserNameInput(name); - } - - Widget _renderCurrentIcon(BuildContext context) { - String iconUrl = - context.read().state.userProfile.iconUrl; - if (iconUrl.isEmpty) { - iconUrl = defaultUserAvatar; - } - return _CurrentIcon(iconUrl); - } -} - -class _UserNameInput extends StatelessWidget { - final String name; - const _UserNameInput( - this.name, { - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return TextField( - controller: TextEditingController()..text = name, - decoration: const InputDecoration( - labelText: 'Name', - ), - onSubmitted: (val) { - context - .read() - .add(SettingsUserEvent.updateUserName(val)); - }); - } -} - -class _CurrentIcon extends StatelessWidget { - final String iconUrl; - const _CurrentIcon(this.iconUrl, {Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - _setIcon(String iconUrl) { - context - .read() - .add(SettingsUserEvent.updateUserIcon(iconUrl)); - Navigator.of(context).pop(); - } - - return Material( - color: Colors.transparent, - child: GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (BuildContext context) { - return SimpleDialog( - title: const FlowyText.medium('Select an Icon'), - children: [ - SizedBox( - height: 300, width: 300, child: IconGallery(_setIcon)) - ]); - }, - ); - }, - child: Column(children: [ - const Align( - alignment: Alignment.topLeft, - child: Text( - "Icon", - style: TextStyle(color: Colors.grey), - )), - Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.all(5.0), - decoration: - BoxDecoration(border: Border.all(color: Colors.grey)), - child: svgWithSize('emoji/$iconUrl', const Size(60, 60)), - )), - ])), - ); - } -} - -class IconGallery extends StatelessWidget { - final Function setIcon; - const IconGallery(this.setIcon, {Key? key}) : super(key: key); - - Future> _getIcons(BuildContext context) async { - final manifestContent = - await DefaultAssetBundle.of(context).loadString('AssetManifest.json'); - - final Map manifestMap = json.decode(manifestContent); - - final iconUrls = manifestMap.keys - .where((String key) => - key.startsWith('assets/images/emoji/') && key.endsWith('.svg')) - .map((String key) => key.split('/').last.split('.').first) - .toList(); - - return iconUrls; - } - - @override - Widget build(BuildContext context) { - return FutureBuilder>( - future: _getIcons(context), - builder: (BuildContext context, AsyncSnapshot> snapshot) { - if (snapshot.hasData) { - return GridView.count( - padding: const EdgeInsets.all(20), - crossAxisCount: 5, - children: (snapshot.data ?? []).map((String iconUrl) { - return IconOption(iconUrl, setIcon); - }).toList(), - ); - } else { - return const Center( - child: CircularProgressIndicator(), - ); - } - }, - ); - } -} - -class IconOption extends StatelessWidget { - final String iconUrl; - final Function setIcon; - - IconOption(this.iconUrl, this.setIcon, {Key? key}) - : super(key: ValueKey(iconUrl)); - - @override - Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - child: GestureDetector( - onTap: () { - setIcon(iconUrl); - }, - child: svgWidget('emoji/$iconUrl'), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/dialogs.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/dialogs.dart deleted file mode 100644 index 793cc68c27417..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/dialogs.dart +++ /dev/null @@ -1,233 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; -import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:app_flowy/startup/tasks/app_widget.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra_ui/style_widget/text_input.dart'; -import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; -export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; - -class NavigatorTextFieldDialog extends StatefulWidget { - final String value; - final String title; - final void Function()? cancel; - final void Function(String) confirm; - - const NavigatorTextFieldDialog({ - required this.title, - required this.value, - required this.confirm, - this.cancel, - Key? key, - }) : super(key: key); - - @override - State createState() => _CreateTextFieldDialog(); -} - -class _CreateTextFieldDialog extends State { - String newValue = ""; - - @override - void initState() { - newValue = widget.value; - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return StyledDialog( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...[ - FlowyText.medium(widget.title, color: theme.shader4), - VSpace(Insets.sm * 1.5), - ], - FlowyFormTextInput( - hintText: LocaleKeys.dialogCreatePageNameHint.tr(), - initialValue: widget.value, - textStyle: TextStyles.general( - fontSize: 24, - fontWeight: FontWeight.w400, - ), - autoFocus: true, - onChanged: (text) { - newValue = text; - }, - onEditingComplete: () { - widget.confirm(newValue); - AppGlobals.nav.pop(); - }, - ), - const VSpace(10), - OkCancelButton( - onOkPressed: () { - widget.confirm(newValue); - Navigator.of(context).pop(); - }, - onCancelPressed: () { - if (widget.cancel != null) { - widget.cancel!(); - } - Navigator.of(context).pop(); - }, - ) - ], - ), - ); - } -} - -class NavigatorAlertDialog extends StatefulWidget { - final String title; - final void Function()? cancel; - final void Function()? confirm; - - const NavigatorAlertDialog({ - required this.title, - this.confirm, - this.cancel, - Key? key, - }) : super(key: key); - - @override - State createState() => _CreateFlowyAlertDialog(); -} - -class _CreateFlowyAlertDialog extends State { - @override - void initState() { - super.initState(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return StyledDialog( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ...[ - FlowyText.medium(widget.title, color: theme.shader4), - ], - if (widget.confirm != null) ...[ - const VSpace(20), - OkCancelButton(onOkPressed: () { - widget.confirm?.call(); - Navigator.of(context).pop(); - }, onCancelPressed: () { - widget.cancel?.call(); - Navigator.of(context).pop(); - }) - ] - ], - ), - ); - } -} - -class NavigatorOkCancelDialog extends StatelessWidget { - final VoidCallback? onOkPressed; - final VoidCallback? onCancelPressed; - final String? okTitle; - final String? cancelTitle; - final String? title; - final String message; - final double? maxWidth; - - const NavigatorOkCancelDialog( - {Key? key, - this.onOkPressed, - this.onCancelPressed, - this.okTitle, - this.cancelTitle, - this.title, - required this.message, - this.maxWidth}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return StyledDialog( - maxWidth: maxWidth ?? 500, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (title != null) ...[ - FlowyText.medium(title!.toUpperCase(), color: theme.shader1), - VSpace(Insets.sm * 1.5), - Container(color: theme.bg1, height: 1), - VSpace(Insets.m * 1.5), - ], - FlowyText.medium(message, fontSize: FontSizes.s12), - SizedBox(height: Insets.l), - OkCancelButton( - onOkPressed: () { - onOkPressed?.call(); - Navigator.of(context).pop(); - }, - onCancelPressed: () { - onCancelPressed?.call(); - Navigator.of(context).pop(); - }, - okTitle: okTitle?.toUpperCase(), - cancelTitle: cancelTitle?.toUpperCase(), - ) - ], - ), - ); - } -} - -class OkCancelButton extends StatelessWidget { - final VoidCallback? onOkPressed; - final VoidCallback? onCancelPressed; - final String? okTitle; - final String? cancelTitle; - final double? minHeight; - - const OkCancelButton( - {Key? key, - this.onOkPressed, - this.onCancelPressed, - this.okTitle, - this.cancelTitle, - this.minHeight}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 48, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (onCancelPressed != null) - SecondaryTextButton( - cancelTitle ?? LocaleKeys.button_Cancel.tr(), - onPressed: onCancelPressed, - bigMode: true, - ), - HSpace(Insets.m), - if (onOkPressed != null) - PrimaryTextButton( - okTitle ?? LocaleKeys.button_OK.tr(), - onPressed: onOkPressed, - bigMode: true, - ), - ], - ), - ); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/edit_panel/panel_animation.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/edit_panel/panel_animation.dart deleted file mode 100644 index 614ff356cb3cd..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/edit_panel/panel_animation.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:flutter/material.dart'; - -class AnimatedPanel extends StatefulWidget { - final bool isClosed; - final double closedX; - final double closedY; - final double duration; - final Curve? curve; - final Widget? child; - - const AnimatedPanel( - {Key? key, - this.isClosed = false, - this.closedX = 0.0, - this.closedY = 0.0, - this.duration = 0.0, - this.curve, - this.child}) - : super(key: key); - - @override - AnimatedPanelState createState() => AnimatedPanelState(); -} - -class AnimatedPanelState extends State { - bool _isHidden = true; - - @override - Widget build(BuildContext context) { - Offset closePos = Offset(widget.closedX, widget.closedY); - double duration = _isHidden && widget.isClosed ? 0 : widget.duration; - return TweenAnimationBuilder( - curve: widget.curve ?? Curves.easeOut, - tween: Tween( - begin: !widget.isClosed ? Offset.zero : closePos, - end: !widget.isClosed ? Offset.zero : closePos, - ), - duration: Duration(milliseconds: (duration * 1000).round()), - builder: (_, Offset value, Widget? c) { - _isHidden = - widget.isClosed && value == Offset(widget.closedX, widget.closedY); - return _isHidden - ? Container() - : Transform.translate(offset: value, child: c); - }, - child: widget.child, - ); - } -} - -extension AnimatedPanelExtensions on Widget { - Widget animatedPanelX( - {double closeX = 0.0, - bool? isClosed, - double? duration, - Curve? curve}) => - animatedPanel( - closePos: Offset(closeX, 0), - isClosed: isClosed, - curve: curve, - duration: duration); - - Widget animatedPanelY( - {double closeY = 0.0, - bool? isClosed, - double? duration, - Curve? curve}) => - animatedPanel( - closePos: Offset(0, closeY), - isClosed: isClosed, - curve: curve, - duration: duration); - - Widget animatedPanel( - {required Offset closePos, - bool? isClosed, - double? duration, - Curve? curve}) { - return AnimatedPanel( - closedX: closePos.dx, - closedY: closePos.dy, - isClosed: isClosed ?? false, - duration: duration ?? .35, - curve: curve, - child: this); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/emoji_picker.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/emoji_picker.dart deleted file mode 100644 index f2cc8bb4409be..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/emoji_picker.dart +++ /dev/null @@ -1,5 +0,0 @@ -export 'src/config.dart'; -export 'src/models/emoji_model.dart'; -export 'src/emoji_picker.dart'; -export 'src/emoji_picker_builder.dart'; -export 'src/emoji_button.dart'; diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/config.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/config.dart deleted file mode 100644 index 26046af5bb34f..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/config.dart +++ /dev/null @@ -1,164 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; - -import 'models/category_models.dart'; -import 'emoji_picker.dart'; - -/// Config for customizations -class Config { - /// Constructor - const Config( - {this.columns = 7, - this.emojiSizeMax = 32.0, - this.verticalSpacing = 0, - this.horizontalSpacing = 0, - this.initCategory = Category.RECENT, - this.bgColor = const Color(0xFFEBEFF2), - this.indicatorColor = Colors.blue, - this.iconColor = Colors.grey, - this.iconColorSelected = Colors.blue, - this.progressIndicatorColor = Colors.blue, - this.backspaceColor = Colors.blue, - this.showRecentsTab = true, - this.recentsLimit = 28, - this.noRecentsText = 'No Recents', - this.noRecentsStyle = const TextStyle(fontSize: 20, color: Colors.black26), - this.tabIndicatorAnimDuration = kTabScrollDuration, - this.categoryIcons = const CategoryIcons(), - this.buttonMode = ButtonMode.MATERIAL}); - - /// Number of emojis per row - final int columns; - - /// Width and height the emoji will be maximal displayed - /// Can be smaller due to screen size and amount of columns - final double emojiSizeMax; - - /// Vertical spacing between emojis - final double verticalSpacing; - - /// Horizontal spacing between emojis - final double horizontalSpacing; - - /// The initial [Category] that will be selected - /// This [Category] will have its button in the bottombar darkened - final Category initCategory; - - /// The background color of the Widget - final Color bgColor; - - /// The color of the category indicator - final Color indicatorColor; - - /// The color of the category icons - final Color iconColor; - - /// The color of the category icon when selected - final Color iconColorSelected; - - /// The color of the loading indicator during initialization - final Color progressIndicatorColor; - - /// The color of the backspace icon button - final Color backspaceColor; - - /// Show extra tab with recently used emoji - final bool showRecentsTab; - - /// Limit of recently used emoji that will be saved - final int recentsLimit; - - /// The text to be displayed if no recent emojis to display - final String noRecentsText; - - /// The text style for [noRecentsText] - final TextStyle noRecentsStyle; - - /// Duration of tab indicator to animate to next category - final Duration tabIndicatorAnimDuration; - - /// Determines the icon to display for each [Category] - final CategoryIcons categoryIcons; - - /// Change between Material and Cupertino button style - final ButtonMode buttonMode; - - /// Get Emoji size based on properties and screen width - double getEmojiSize(double width) { - final maxSize = width / columns; - return min(maxSize, emojiSizeMax); - } - - /// Returns the icon for the category - IconData getIconForCategory(Category category) { - switch (category) { - case Category.RECENT: - return categoryIcons.recentIcon; - case Category.SMILEYS: - return categoryIcons.smileyIcon; - case Category.ANIMALS: - return categoryIcons.animalIcon; - case Category.FOODS: - return categoryIcons.foodIcon; - case Category.TRAVEL: - return categoryIcons.travelIcon; - case Category.ACTIVITIES: - return categoryIcons.activityIcon; - case Category.OBJECTS: - return categoryIcons.objectIcon; - case Category.SYMBOLS: - return categoryIcons.symbolIcon; - case Category.FLAGS: - return categoryIcons.flagIcon; - case Category.SEARCH: - return categoryIcons.searchIcon; - default: - throw Exception('Unsupported Category'); - } - } - - @override - bool operator ==(other) { - return (other is Config) && - other.columns == columns && - other.emojiSizeMax == emojiSizeMax && - other.verticalSpacing == verticalSpacing && - other.horizontalSpacing == horizontalSpacing && - other.initCategory == initCategory && - other.bgColor == bgColor && - other.indicatorColor == indicatorColor && - other.iconColor == iconColor && - other.iconColorSelected == iconColorSelected && - other.progressIndicatorColor == progressIndicatorColor && - other.backspaceColor == backspaceColor && - other.showRecentsTab == showRecentsTab && - other.recentsLimit == recentsLimit && - other.noRecentsText == noRecentsText && - other.noRecentsStyle == noRecentsStyle && - other.tabIndicatorAnimDuration == tabIndicatorAnimDuration && - other.categoryIcons == categoryIcons && - other.buttonMode == buttonMode; - } - - @override - int get hashCode => - columns.hashCode ^ - emojiSizeMax.hashCode ^ - verticalSpacing.hashCode ^ - horizontalSpacing.hashCode ^ - initCategory.hashCode ^ - bgColor.hashCode ^ - indicatorColor.hashCode ^ - iconColor.hashCode ^ - iconColorSelected.hashCode ^ - progressIndicatorColor.hashCode ^ - backspaceColor.hashCode ^ - showRecentsTab.hashCode ^ - recentsLimit.hashCode ^ - noRecentsText.hashCode ^ - noRecentsStyle.hashCode ^ - tabIndicatorAnimDuration.hashCode ^ - categoryIcons.hashCode ^ - buttonMode.hashCode; -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/default_emoji_picker_view.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/default_emoji_picker_view.dart deleted file mode 100644 index 271a3b6dd296a..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/default_emoji_picker_view.dart +++ /dev/null @@ -1,299 +0,0 @@ -import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; - -import 'models/category_models.dart'; -import 'config.dart'; -import 'models/emoji_model.dart'; -import 'emoji_picker.dart'; -import 'emoji_picker_builder.dart'; -import 'emoji_view_state.dart'; - -class DefaultEmojiPickerView extends EmojiPickerBuilder { - const DefaultEmojiPickerView(Config config, EmojiViewState state, {Key? key}) - : super(config, state, key: key); - - @override - DefaultEmojiPickerViewState createState() => DefaultEmojiPickerViewState(); -} - -class DefaultEmojiPickerViewState extends State - with TickerProviderStateMixin { - PageController? _pageController; - TabController? _tabController; - final TextEditingController _emojiController = TextEditingController(); - final FocusNode _emojiFocusNode = FocusNode(); - final CategoryEmoji _categoryEmoji = - CategoryEmoji(Category.SEARCH, List.empty(growable: true)); - CategoryEmoji searchEmojiList = CategoryEmoji(Category.SEARCH, []); - - @override - void initState() { - var initCategory = widget.state.categoryEmoji.indexWhere( - (element) => element.category == widget.config.initCategory); - if (initCategory == -1) { - initCategory = 0; - } - _tabController = TabController( - initialIndex: initCategory, - length: widget.state.categoryEmoji.length, - vsync: this); - _pageController = PageController(initialPage: initCategory); - _emojiFocusNode.requestFocus(); - - _emojiController.addListener(() { - String query = _emojiController.text.toLowerCase(); - if (query.isEmpty) { - searchEmojiList.emoji.clear(); - _pageController!.jumpToPage( - _tabController!.index, - ); - } else { - searchEmojiList.emoji.clear(); - for (var element in widget.state.categoryEmoji) { - searchEmojiList.emoji.addAll( - element.emoji.where((item) { - return item.name.toLowerCase().contains(query); - }).toList(), - ); - } - } - setState(() {}); - }); - super.initState(); - } - - @override - void dispose() { - _emojiController.dispose(); - _emojiFocusNode.dispose(); - super.dispose(); - } - - Widget _buildBackspaceButton() { - if (widget.state.onBackspacePressed != null) { - return Material( - type: MaterialType.transparency, - child: IconButton( - padding: const EdgeInsets.only(bottom: 2), - icon: Icon( - Icons.backspace, - color: widget.config.backspaceColor, - ), - onPressed: () { - widget.state.onBackspacePressed!(); - }), - ); - } - return Container(); - } - - bool isEmojiSearching() { - bool result = - searchEmojiList.emoji.isNotEmpty || _emojiController.text.isNotEmpty; - - return result; - } - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final emojiSize = widget.config.getEmojiSize(constraints.maxWidth); - - return Container( - color: widget.config.bgColor, - padding: const EdgeInsets.all(5.0), - child: Column( - children: [ - SizedBox( - height: 25.0, - child: TextField( - controller: _emojiController, - focusNode: _emojiFocusNode, - autofocus: true, - style: const TextStyle(fontSize: 14.0), - cursorWidth: 1.0, - cursorColor: Colors.black, - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric(horizontal: 5.0), - hintText: "Search emoji", - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(4.0), - borderSide: const BorderSide(), - gapPadding: 0.0, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(4.0), - borderSide: const BorderSide(), - gapPadding: 0.0, - ), - filled: true, - fillColor: Colors.white, - hoverColor: Colors.white, - ), - ), - ), - Row( - children: [ - Expanded( - child: TabBar( - labelColor: widget.config.iconColorSelected, - unselectedLabelColor: widget.config.iconColor, - controller: isEmojiSearching() - ? TabController(length: 1, vsync: this) - : _tabController, - labelPadding: EdgeInsets.zero, - indicatorColor: widget.config.indicatorColor, - padding: const EdgeInsets.symmetric(vertical: 5.0), - indicator: BoxDecoration( - border: Border.all(color: Colors.transparent), - borderRadius: BorderRadius.circular(4.0), - color: Colors.grey.withOpacity(0.5), - ), - onTap: (index) { - _pageController!.animateToPage( - index, - duration: widget.config.tabIndicatorAnimDuration, - curve: Curves.ease, - ); - }, - tabs: isEmojiSearching() - ? [_buildCategory(Category.SEARCH, emojiSize)] - : widget.state.categoryEmoji - .asMap() - .entries - .map((item) => _buildCategory( - item.value.category, emojiSize)) - .toList(), - ), - ), - _buildBackspaceButton(), - ], - ), - Flexible( - child: PageView.builder( - itemCount: searchEmojiList.emoji.isNotEmpty - ? 1 - : widget.state.categoryEmoji.length, - controller: _pageController, - physics: const NeverScrollableScrollPhysics(), - // onPageChanged: (index) { - // _tabController!.animateTo( - // index, - // duration: widget.config.tabIndicatorAnimDuration, - // ); - // }, - itemBuilder: (context, index) { - CategoryEmoji catEmoji = isEmojiSearching() - ? searchEmojiList - : widget.state.categoryEmoji[index]; - return _buildPage(emojiSize, catEmoji); - }, - ), - ), - ], - ), - ); - }, - ); - } - - Widget _buildCategory(Category category, double categorySize) { - return Tab( - height: categorySize, - child: Icon( - widget.config.getIconForCategory(category), - size: categorySize / 1.3, - ), - ); - } - - Widget _buildButtonWidget( - {required VoidCallback onPressed, required Widget child}) { - if (widget.config.buttonMode == ButtonMode.MATERIAL) { - return TextButton( - onPressed: onPressed, - style: ButtonStyle(padding: MaterialStateProperty.all(EdgeInsets.zero)), - child: child, - ); - } - return CupertinoButton( - padding: EdgeInsets.zero, onPressed: onPressed, child: child); - } - - Widget _buildPage(double emojiSize, CategoryEmoji categoryEmoji) { - // Display notice if recent has no entries yet - final scrollController = ScrollController(); - - if (categoryEmoji.category == Category.RECENT && - categoryEmoji.emoji.isEmpty) { - return _buildNoRecent(); - } else if (categoryEmoji.category == Category.SEARCH && - categoryEmoji.emoji.isEmpty) { - return const Center(child: Text("No Emoji Found")); - } - // Build page normally - return ScrollbarListStack( - axis: Axis.vertical, - controller: scrollController, - barSize: 4.0, - scrollbarPadding: const EdgeInsets.symmetric(horizontal: 5.0), - handleColor: const Color(0xffDFE0E0), - trackColor: const Color(0xffDFE0E0), - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), - child: GridView.count( - scrollDirection: Axis.vertical, - physics: const ScrollPhysics(), - controller: scrollController, - shrinkWrap: true, - // primary: true, - padding: const EdgeInsets.all(0), - crossAxisCount: widget.config.columns, - mainAxisSpacing: widget.config.verticalSpacing, - crossAxisSpacing: widget.config.horizontalSpacing, - children: _categoryEmoji.emoji.isNotEmpty - ? _categoryEmoji.emoji - .map((e) => _buildEmoji(emojiSize, categoryEmoji, e)) - .toList() - : categoryEmoji.emoji - .map( - (item) => _buildEmoji(emojiSize, categoryEmoji, item)) - .toList(), - ), - ), - ); - } - - Widget _buildEmoji( - double emojiSize, - CategoryEmoji categoryEmoji, - Emoji emoji, - ) { - return _buildButtonWidget( - onPressed: () { - widget.state.onEmojiSelected(categoryEmoji.category, emoji); - }, - child: FittedBox( - fit: BoxFit.fill, - child: Text( - emoji.emoji, - textScaleFactor: 1.0, - style: TextStyle( - fontSize: emojiSize, - backgroundColor: Colors.transparent, - ), - ), - )); - } - - Widget _buildNoRecent() { - return Center( - child: Text( - widget.config.noRecentsText, - style: widget.config.noRecentsStyle, - textAlign: TextAlign.center, - )); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_button.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_button.dart deleted file mode 100644 index 28cf268a465a9..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_button.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'package:app_flowy/plugins/doc/presentation/toolbar/toolbar_icon_button.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_quill/flutter_quill.dart'; -import 'package:app_flowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart'; - -class FlowyEmojiStyleButton extends StatefulWidget { - // final Attribute attribute; - final String normalIcon; - final double iconSize; - final QuillController controller; - final String tooltipText; - - const FlowyEmojiStyleButton({ - // required this.attribute, - required this.normalIcon, - required this.controller, - required this.tooltipText, - this.iconSize = defaultIconSize, - Key? key, - }) : super(key: key); - - @override - EmojiStyleButtonState createState() => EmojiStyleButtonState(); -} - -class EmojiStyleButtonState extends State { - bool _isToggled = false; - // Style get _selectionStyle => widget.controller.getSelectionStyle(); - final GlobalKey emojiButtonKey = GlobalKey(); - OverlayEntry? _entry; - // final FocusNode _keyFocusNode = FocusNode(); - - @override - void initState() { - super.initState(); - // _isToggled = _getIsToggled(_selectionStyle.attributes); - // widget.controller.addListener(_didChangeEditingValue); - } - - @override - Widget build(BuildContext context) { - // debugPrint(MediaQuery.of(context).size.width.toString()); - // debugPrint(MediaQuery.of(context).size.height.toString()); - - return ToolbarIconButton( - key: emojiButtonKey, - onPressed: _toggleAttribute, - width: widget.iconSize * kIconButtonFactor, - isToggled: _isToggled, - iconName: widget.normalIcon, - tooltipText: widget.tooltipText, - ); - } - - @override - void dispose() { - _entry?.remove(); - super.dispose(); - } - - // @override - // void didUpdateWidget(covariant FlowyEmojiStyleButton oldWidget) { - // super.didUpdateWidget(oldWidget); - // if (oldWidget.controller != widget.controller) { - // oldWidget.controller.removeListener(_didChangeEditingValue); - // widget.controller.addListener(_didChangeEditingValue); - // _isToggled = _getIsToggled(_selectionStyle.attributes); - // } - // } - - // @override - // void dispose() { - // widget.controller.removeListener(_didChangeEditingValue); - // super.dispose(); - // } - - // void _didChangeEditingValue() { - // setState(() => _isToggled = _getIsToggled(_selectionStyle.attributes)); - // } - - // bool _getIsToggled(Map attrs) { - // return _entry.mounted; - // } - - void _toggleAttribute() { - if (_entry?.mounted ?? false) { - _entry?.remove(); - _entry = null; - setState(() => _isToggled = false); - } else { - RenderBox box = - emojiButtonKey.currentContext?.findRenderObject() as RenderBox; - Offset position = box.localToGlobal(Offset.zero); - - // final window = await getWindowInfo(); - - _entry = OverlayEntry( - builder: (BuildContext context) => BuildEmojiPickerView( - controller: widget.controller, - offset: position, - ), - ); - - Overlay.of(context)!.insert(_entry!); - setState(() => _isToggled = true); - } - - //TODO @gaganyadav80: INFO: throws error when using TextField with FlowyOverlay. - - // FlowyOverlay.of(context).insertWithRect( - // widget: BuildEmojiPickerView(controller: widget.controller), - // identifier: 'overlay_emoji_picker', - // anchorPosition: Offset(position.dx + 40, position.dy - 10), - // anchorSize: window.frame.size, - // anchorDirection: AnchorDirection.topLeft, - // style: FlowyOverlayStyle(blur: true), - // ); - } -} - -class BuildEmojiPickerView extends StatefulWidget { - const BuildEmojiPickerView({Key? key, required this.controller, this.offset}) - : super(key: key); - - final QuillController controller; - final Offset? offset; - - @override - State createState() => _BuildEmojiPickerViewState(); -} - -class _BuildEmojiPickerViewState extends State { - @override - Widget build(BuildContext context) { - return Stack( - children: [ - Positioned( - //TODO @gaganyadav80: Not sure about the calculated position. - top: widget.offset!.dy - - MediaQuery.of(context).size.height / 2.83 - - 30, - left: - widget.offset!.dx - MediaQuery.of(context).size.width / 3.92 + 40, - child: Material( - borderRadius: BorderRadius.circular(8.0), - child: SizedBox( - //TODO @gaganyadav80: FIXIT: Gets too large when fullscreen. - height: MediaQuery.of(context).size.height / 2.83 + 20, - width: MediaQuery.of(context).size.width / 3.92, - child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), - child: EmojiPicker( - onEmojiSelected: (category, emoji) => insertEmoji(emoji), - config: const Config( - columns: 8, - emojiSizeMax: 28, - bgColor: Color(0xffF2F2F2), - iconColor: Colors.grey, - iconColorSelected: Color(0xff333333), - indicatorColor: Color(0xff333333), - progressIndicatorColor: Color(0xff333333), - buttonMode: ButtonMode.CUPERTINO, - initCategory: Category.RECENT, - ), - ), - ), - ), - ), - ), - ], - ); - } - - void insertEmoji(Emoji emoji) { - final baseOffset = widget.controller.selection.baseOffset; - final extentOffset = widget.controller.selection.extentOffset; - final replaceLen = extentOffset - baseOffset; - final selection = widget.controller.selection.copyWith( - baseOffset: baseOffset + emoji.emoji.length, - extentOffset: baseOffset + emoji.emoji.length, - ); - - widget.controller - .replaceText(baseOffset, replaceLen, emoji.emoji, selection); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_picker.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_picker.dart deleted file mode 100644 index 8852b12799672..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_picker.dart +++ /dev/null @@ -1,312 +0,0 @@ -// ignore_for_file: constant_identifier_names - -import 'dart:convert'; -import 'dart:io'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'models/category_models.dart'; -import 'config.dart'; -import 'default_emoji_picker_view.dart'; -import 'models/emoji_model.dart'; -import 'emoji_lists.dart' as emoji_list; -import 'emoji_view_state.dart'; -import 'models/recent_emoji_model.dart'; - -/// All the possible categories that [Emoji] can be put into -/// -/// All [Category] are shown in the category bar -enum Category { - /// Searched emojis - SEARCH, - - /// Recent emojis - RECENT, - - /// Smiley emojis - SMILEYS, - - /// Animal emojis - ANIMALS, - - /// Food emojis - FOODS, - - /// Activity emojis - ACTIVITIES, - - /// Travel emojis - TRAVEL, - - /// Objects emojis - OBJECTS, - - /// Sumbol emojis - SYMBOLS, - - /// Flag emojis - FLAGS, -} - -/// Enum to alter the keyboard button style -enum ButtonMode { - /// Android button style - gives the button a splash color with ripple effect - MATERIAL, - - /// iOS button style - gives the button a fade out effect when pressed - CUPERTINO -} - -/// Callback function for when emoji is selected -/// -/// The function returns the selected [Emoji] as well -/// as the [Category] from which it originated -typedef OnEmojiSelected = void Function(Category category, Emoji emoji); - -/// Callback function for backspace button -typedef OnBackspacePressed = void Function(); - -/// Callback function for custom view -typedef EmojiViewBuilder = Widget Function(Config config, EmojiViewState state); - -/// The Emoji Keyboard widget -/// -/// This widget displays a grid of [Emoji] sorted by [Category] -/// which the user can horizontally scroll through. -/// -/// There is also a bottombar which displays all the possible [Category] -/// and allow the user to quickly switch to that [Category] -class EmojiPicker extends StatefulWidget { - /// EmojiPicker for flutter - const EmojiPicker({ - Key? key, - required this.onEmojiSelected, - this.onBackspacePressed, - this.config = const Config(), - this.customWidget, - }) : super(key: key); - - /// Custom widget - final EmojiViewBuilder? customWidget; - - /// The function called when the emoji is selected - final OnEmojiSelected onEmojiSelected; - - /// The function called when backspace button is pressed - final OnBackspacePressed? onBackspacePressed; - - /// Config for customizations - final Config config; - - @override - EmojiPickerState createState() => EmojiPickerState(); -} - -class EmojiPickerState extends State { - static const platform = MethodChannel('emoji_picker_flutter'); - - List categoryEmoji = List.empty(growable: true); - List recentEmoji = List.empty(growable: true); - late Future updateEmojiFuture; - - // Prevent emojis to be reloaded with every build - bool loaded = false; - - @override - void initState() { - super.initState(); - updateEmojiFuture = _updateEmojis(); - } - - @override - void didUpdateWidget(covariant EmojiPicker oldWidget) { - if (oldWidget.config != widget.config) { - // Config changed - rebuild EmojiPickerView completely - loaded = false; - updateEmojiFuture = _updateEmojis(); - } - super.didUpdateWidget(oldWidget); - } - - @override - Widget build(BuildContext context) { - if (!loaded) { - // Load emojis - updateEmojiFuture.then( - (value) => WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - setState(() { - loaded = true; - }); - }), - ); - - // Show loading indicator - return const Center(child: CircularProgressIndicator()); - } - if (widget.config.showRecentsTab) { - categoryEmoji[0].emoji = - recentEmoji.map((e) => e.emoji).toList().cast(); - } - - var state = EmojiViewState( - categoryEmoji, - _getOnEmojiListener(), - widget.onBackspacePressed, - ); - - // Build - return widget.customWidget == null - ? DefaultEmojiPickerView(widget.config, state) - : widget.customWidget!(widget.config, state); - } - - // Add recent emoji handling to tap listener - OnEmojiSelected _getOnEmojiListener() { - return (category, emoji) { - if (widget.config.showRecentsTab) { - _addEmojiToRecentlyUsed(emoji).then((value) { - if (category != Category.RECENT && mounted) { - setState(() { - // rebuild to update recent emoji tab - // when it is not current tab - }); - } - }); - } - widget.onEmojiSelected(category, emoji); - }; - } - - // Initialize emoji data - Future _updateEmojis() async { - categoryEmoji.clear(); - if (widget.config.showRecentsTab) { - recentEmoji = await _getRecentEmojis(); - final List recentEmojiMap = - recentEmoji.map((e) => e.emoji).toList().cast(); - categoryEmoji.add(CategoryEmoji(Category.RECENT, recentEmojiMap)); - } - categoryEmoji.addAll([ - CategoryEmoji(Category.SMILEYS, - await _getAvailableEmojis(emoji_list.smileys, title: 'smileys')), - CategoryEmoji(Category.ANIMALS, - await _getAvailableEmojis(emoji_list.animals, title: 'animals')), - CategoryEmoji(Category.FOODS, - await _getAvailableEmojis(emoji_list.foods, title: 'foods')), - CategoryEmoji( - Category.ACTIVITIES, - await _getAvailableEmojis(emoji_list.activities, - title: 'activities')), - CategoryEmoji(Category.TRAVEL, - await _getAvailableEmojis(emoji_list.travel, title: 'travel')), - CategoryEmoji(Category.OBJECTS, - await _getAvailableEmojis(emoji_list.objects, title: 'objects')), - CategoryEmoji(Category.SYMBOLS, - await _getAvailableEmojis(emoji_list.symbols, title: 'symbols')), - CategoryEmoji(Category.FLAGS, - await _getAvailableEmojis(emoji_list.flags, title: 'flags')) - ]); - } - - // Get available emoji for given category title - Future> _getAvailableEmojis(Map map, - {required String title}) async { - Map? newMap; - - // Get Emojis cached locally if available - newMap = await _restoreFilteredEmojis(title); - - if (newMap == null) { - // Check if emoji is available on this platform - newMap = await _getPlatformAvailableEmoji(map); - // Save available Emojis to local storage for faster loading next time - if (newMap != null) { - await _cacheFilteredEmojis(title, newMap); - } - } - - // Map to Emoji Object - return newMap!.entries - .map((entry) => Emoji(entry.key, entry.value)) - .toList(); - } - - // Check if emoji is available on current platform - Future?> _getPlatformAvailableEmoji( - Map emoji) async { - if (Platform.isAndroid) { - Map? filtered = {}; - var delimiter = '|'; - try { - var entries = emoji.values.join(delimiter); - var keys = emoji.keys.join(delimiter); - var result = (await platform.invokeMethod('checkAvailability', - {'emojiKeys': keys, 'emojiEntries': entries})) as String; - var resultKeys = result.split(delimiter); - for (var i = 0; i < resultKeys.length; i++) { - filtered[resultKeys[i]] = emoji[resultKeys[i]]!; - } - } on PlatformException catch (_) { - filtered = null; - } - return filtered; - } else { - return emoji; - } - } - - // Restore locally cached emoji - Future?> _restoreFilteredEmojis(String title) async { - final prefs = await SharedPreferences.getInstance(); - var emojiJson = prefs.getString(title); - if (emojiJson == null) { - return null; - } - var emojis = - Map.from(jsonDecode(emojiJson) as Map); - return emojis; - } - - // Stores filtered emoji locally for faster access next time - Future _cacheFilteredEmojis( - String title, Map emojis) async { - final prefs = await SharedPreferences.getInstance(); - var emojiJson = jsonEncode(emojis); - prefs.setString(title, emojiJson); - } - - // Returns list of recently used emoji from cache - Future> _getRecentEmojis() async { - final prefs = await SharedPreferences.getInstance(); - var emojiJson = prefs.getString('recent'); - if (emojiJson == null) { - return []; - } - var json = jsonDecode(emojiJson) as List; - return json.map(RecentEmoji.fromJson).toList(); - } - - // Add an emoji to recently used list or increase its counter - Future _addEmojiToRecentlyUsed(Emoji emoji) async { - final prefs = await SharedPreferences.getInstance(); - var recentEmojiIndex = - recentEmoji.indexWhere((element) => element.emoji.emoji == emoji.emoji); - if (recentEmojiIndex != -1) { - // Already exist in recent list - // Just update counter - recentEmoji[recentEmojiIndex].counter++; - } else { - recentEmoji.add(RecentEmoji(emoji, 1)); - } - // Sort by counter desc - recentEmoji.sort((a, b) => b.counter - a.counter); - // Limit entries to recentsLimit - recentEmoji = recentEmoji.sublist( - 0, min(widget.config.recentsLimit, recentEmoji.length)); - // save locally - prefs.setString('recent', jsonEncode(recentEmoji)); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_picker_builder.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_picker_builder.dart deleted file mode 100644 index ee00569852935..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_picker_builder.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'config.dart'; -import 'emoji_view_state.dart'; - -/// Template class for custom implementation -/// Inherit this class to create your own EmojiPicker -abstract class EmojiPickerBuilder extends StatefulWidget { - /// Constructor - const EmojiPickerBuilder(this.config, this.state, {Key? key}) : super(key: key); - - /// Config for customizations - final Config config; - - /// State that holds current emoji data - final EmojiViewState state; -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_view_state.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_view_state.dart deleted file mode 100644 index 202f913715f8c..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_view_state.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'models/category_models.dart'; -import 'emoji_picker.dart'; - -/// State that holds current emoji data -class EmojiViewState { - /// Constructor - EmojiViewState( - this.categoryEmoji, - this.onEmojiSelected, - this.onBackspacePressed, - ); - - /// List of all category including their emoji - final List categoryEmoji; - - /// Callback when pressed on emoji - final OnEmojiSelected onEmojiSelected; - - /// Callback when pressed on backspace - final OnBackspacePressed? onBackspacePressed; -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/models/category_models.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/models/category_models.dart deleted file mode 100644 index 8d59fa87e7d25..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/models/category_models.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'emoji_model.dart'; -import '../emoji_picker.dart'; - -/// Container for Category and their emoji -class CategoryEmoji { - /// Constructor - CategoryEmoji(this.category, this.emoji); - - /// Category instance - final Category category; - - /// List of emoji of this category - List emoji; - - @override - String toString() { - return 'Name: $category, Emoji: $emoji'; - } -} - -/// Class that defines the icon representing a [Category] -class CategoryIcon { - /// Icon of Category - const CategoryIcon({ - required this.icon, - this.color = const Color.fromRGBO(211, 211, 211, 1), - this.selectedColor = const Color.fromRGBO(178, 178, 178, 1), - }); - - /// The icon to represent the category - final IconData icon; - - /// The default color of the icon - final Color color; - - /// The color of the icon once the category is selected - final Color selectedColor; -} - -/// Class used to define all the [CategoryIcon] shown for each [Category] -/// -/// This allows the keyboard to be personalized by changing icons shown. -/// If a [CategoryIcon] is set as null or not defined during initialization, -/// the default icons will be used instead -class CategoryIcons { - /// Constructor - const CategoryIcons({ - this.recentIcon = Icons.access_time, - this.smileyIcon = Icons.tag_faces, - this.animalIcon = Icons.pets, - this.foodIcon = Icons.fastfood, - this.activityIcon = Icons.directions_run, - this.travelIcon = Icons.location_city, - this.objectIcon = Icons.lightbulb_outline, - this.symbolIcon = Icons.emoji_symbols, - this.flagIcon = Icons.flag, - this.searchIcon = Icons.search, - }); - - /// Icon for [Category.RECENT] - final IconData recentIcon; - - /// Icon for [Category.SMILEYS] - final IconData smileyIcon; - - /// Icon for [Category.ANIMALS] - final IconData animalIcon; - - /// Icon for [Category.FOODS] - final IconData foodIcon; - - /// Icon for [Category.ACTIVITIES] - final IconData activityIcon; - - /// Icon for [Category.TRAVEL] - final IconData travelIcon; - - /// Icon for [Category.OBJECTS] - final IconData objectIcon; - - /// Icon for [Category.SYMBOLS] - final IconData symbolIcon; - - /// Icon for [Category.FLAGS] - final IconData flagIcon; - - /// Icon for [Category.SEARCH] - final IconData searchIcon; -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart deleted file mode 100644 index adb1f67f97315..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart +++ /dev/null @@ -1,205 +0,0 @@ -import 'package:app_flowy/startup/tasks/rust_sdk.dart'; -import 'package:app_flowy/workspace/presentation/home/toast.dart'; -import 'package:app_flowy/workspace/presentation/widgets/pop_up_action.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/button.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:styled_widget/styled_widget.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; -import 'package:device_info_plus/device_info_plus.dart'; - -class QuestionBubble extends StatelessWidget { - const QuestionBubble({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return const SizedBox( - width: 30, - height: 30, - child: BubbleActionList(), - ); - } -} - -class BubbleActionList extends StatelessWidget { - const BubbleActionList({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - - final List actions = []; - actions.addAll( - BubbleAction.values.map((action) => BubbleActionWrapper(action)), - ); - actions.add(FlowyVersionDescription()); - - return PopoverActionList( - direction: PopoverDirection.topWithRightAligned, - actions: actions, - buildChild: (controller) { - return FlowyTextButton( - '?', - tooltip: LocaleKeys.questionBubble_help.tr(), - fontSize: 12, - fontWeight: FontWeight.w600, - fillColor: theme.selector, - mainAxisAlignment: MainAxisAlignment.center, - radius: BorderRadius.circular(10), - onPressed: () => controller.show(), - ); - }, - onSelected: (action, controller) { - if (action is BubbleActionWrapper) { - switch (action.inner) { - case BubbleAction.whatsNews: - _launchURL("https://www.appflowy.io/whatsnew"); - break; - case BubbleAction.help: - _launchURL("https://discord.gg/9Q2xaN37tV"); - break; - case BubbleAction.debug: - _DebugToast().show(); - break; - } - } - - controller.close(); - }, - ); - } - - _launchURL(String url) async { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } else { - throw 'Could not launch $url'; - } - } -} - -class _DebugToast { - void show() async { - var debugInfo = ""; - debugInfo += await _getDeviceInfo(); - debugInfo += await _getDocumentPath(); - Clipboard.setData(ClipboardData(text: debugInfo)); - - showMessageToast(LocaleKeys.questionBubble_debug_success.tr()); - } - - Future _getDeviceInfo() async { - final deviceInfoPlugin = DeviceInfoPlugin(); - final deviceInfo = deviceInfoPlugin.deviceInfo; - - return deviceInfo.then((info) { - var debugText = ""; - info.toMap().forEach((key, value) { - debugText = "$debugText$key: $value\n"; - }); - return debugText; - }); - } - - Future _getDocumentPath() async { - return appFlowyDocumentDirectory().then((directory) { - final path = directory.path.toString(); - return "Document: $path\n"; - }); - } -} - -class FlowyVersionDescription extends CustomActionCell { - @override - Widget buildWithContext(BuildContext context) { - final theme = context.watch(); - - return FutureBuilder( - future: PackageInfo.fromPlatform(), - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - if (snapshot.hasError) { - return FlowyText( - "Error: ${snapshot.error}", - fontSize: FontSizes.s12, - color: theme.shader4, - ); - } - - PackageInfo packageInfo = snapshot.data; - String appName = packageInfo.appName; - String version = packageInfo.version; - String buildNumber = packageInfo.buildNumber; - - return SizedBox( - height: 30, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Divider(height: 1, color: theme.shader6, thickness: 1.0), - const VSpace(6), - FlowyText( - "$appName $version.$buildNumber", - fontSize: FontSizes.s12, - color: theme.shader4, - ), - ], - ).padding( - horizontal: ActionListSizes.itemHPadding, - ), - ); - } else { - return const SizedBox(height: 30); - } - }, - ); - } -} - -enum BubbleAction { whatsNews, help, debug } - -class BubbleActionWrapper extends ActionCell { - final BubbleAction inner; - - BubbleActionWrapper(this.inner); - @override - Widget? icon(Color iconColor) => FlowyText.regular(inner.emoji, fontSize: 12); - - @override - String get name => inner.name; -} - -extension QuestionBubbleExtension on BubbleAction { - String get name { - switch (this) { - case BubbleAction.whatsNews: - return LocaleKeys.questionBubble_whatsNew.tr(); - case BubbleAction.help: - return LocaleKeys.questionBubble_help.tr(); - case BubbleAction.debug: - return LocaleKeys.questionBubble_debug_name.tr(); - } - } - - String get emoji { - switch (this) { - case BubbleAction.whatsNews: - return '⭐️'; - case BubbleAction.help: - return '👥'; - case BubbleAction.debug: - return '🐛'; - } - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/left_bar_item.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/left_bar_item.dart deleted file mode 100644 index d609ae6566d78..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/left_bar_item.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:app_flowy/workspace/application/view/view_listener.dart'; -import 'package:app_flowy/workspace/application/view/view_service.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_sdk/log.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter/material.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -class ViewLeftBarItem extends StatefulWidget { - final ViewPB view; - - ViewLeftBarItem({required this.view, Key? key}) - : super(key: ValueKey(view.hashCode)); - - @override - State createState() => _ViewLeftBarItemState(); -} - -class _ViewLeftBarItemState extends State { - final _controller = TextEditingController(); - final _focusNode = FocusNode(); - late ViewService _viewService; - late ViewListener _viewListener; - late ViewPB view; - - @override - void initState() { - view = widget.view; - _viewService = ViewService(); - _focusNode.addListener(_handleFocusChanged); - _viewListener = ViewListener(view: widget.view); - _viewListener.start(onViewUpdated: (result) { - result.fold( - (updatedView) { - if (mounted) { - setState(() => view = updatedView); - } - }, - (err) => Log.error(err), - ); - }); - super.initState(); - } - - @override - void dispose() { - _controller.dispose(); - _focusNode.removeListener(_handleFocusChanged); - _focusNode.dispose(); - _viewListener.stop(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - _controller.text = view.name; - - return IntrinsicWidth( - key: ValueKey(_controller.text), - child: TextField( - controller: _controller, - focusNode: _focusNode, - scrollPadding: EdgeInsets.zero, - decoration: const InputDecoration( - contentPadding: EdgeInsets.zero, - border: InputBorder.none, - isDense: true, - ), - style: TextStyles.body1.size(FontSizes.s14), - // cursorColor: widget.cursorColor, - // obscureText: widget.enableObscure, - ), - ); - } - - void _handleFocusChanged() { - if (_controller.text.isEmpty) { - _controller.text = view.name; - return; - } - - if (_controller.text != view.name) { - _viewService.updateView(viewId: view.id, name: _controller.text); - } - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/pop_up_action.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/pop_up_action.dart deleted file mode 100644 index 9aa4df4455cb7..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/pop_up_action.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:styled_widget/styled_widget.dart'; - -class PopoverActionList extends StatefulWidget { - final List actions; - final Function(T, PopoverController) onSelected; - final BoxConstraints constraints; - final PopoverDirection direction; - final Widget Function(PopoverController) buildChild; - final VoidCallback? onClosed; - - const PopoverActionList({ - required this.actions, - required this.buildChild, - required this.onSelected, - this.onClosed, - this.direction = PopoverDirection.rightWithTopAligned, - this.constraints = const BoxConstraints( - minWidth: 120, - maxWidth: 360, - maxHeight: 300, - ), - Key? key, - }) : super(key: key); - - @override - State> createState() => _PopoverActionListState(); -} - -class _PopoverActionListState - extends State> { - late PopoverController popoverController; - - @override - void initState() { - popoverController = PopoverController(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - final child = widget.buildChild(popoverController); - - return AppFlowyPopover( - controller: popoverController, - constraints: widget.constraints, - direction: widget.direction, - triggerActions: PopoverTriggerFlags.none, - onClose: widget.onClosed, - popupBuilder: (BuildContext popoverContext) { - final List children = widget.actions.map((action) { - if (action is ActionCell) { - return ActionCellWidget( - action: action, - itemHeight: ActionListSizes.itemHeight, - onSelected: (action) { - widget.onSelected(action, popoverController); - }, - ); - } else { - final custom = action as CustomActionCell; - return custom.buildWithContext(context); - } - }).toList(); - - return IntrinsicHeight( - child: IntrinsicWidth( - child: Column( - children: children, - ), - ), - ); - }, - child: child, - ); - } -} - -abstract class ActionCell extends PopoverAction { - Widget? icon(Color iconColor); - String get name; -} - -abstract class CustomActionCell extends PopoverAction { - Widget buildWithContext(BuildContext context); -} - -abstract class PopoverAction {} - -class ActionListSizes { - static double itemHPadding = 10; - static double itemHeight = 20; - static double vPadding = 6; - static double hPadding = 10; -} - -class ActionCellWidget extends StatelessWidget { - final T action; - final Function(T) onSelected; - final double itemHeight; - const ActionCellWidget({ - Key? key, - required this.action, - required this.onSelected, - required this.itemHeight, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final actionCell = action as ActionCell; - final theme = context.watch(); - final icon = actionCell.icon(theme.iconColor); - - return FlowyHover( - style: HoverStyle(hoverColor: theme.hover), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onSelected(action), - child: SizedBox( - height: itemHeight, - child: Row( - children: [ - if (icon != null) ...[icon, HSpace(ActionListSizes.itemHPadding)], - Expanded( - child: FlowyText.medium( - actionCell.name, - fontSize: 12, - overflow: TextOverflow.visible, - ), - ), - ], - ), - ).padding( - horizontal: ActionListSizes.hPadding, - vertical: ActionListSizes.vPadding, - ), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/toggle/toggle.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/toggle/toggle.dart deleted file mode 100644 index 4de1c7d376625..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/toggle/toggle.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:app_flowy/workspace/presentation/widgets/toggle/toggle_style.dart'; -import 'package:flutter/widgets.dart'; - -class Toggle extends StatelessWidget { - final ToggleStyle style; - final bool value; - final void Function(bool) onChanged; - final EdgeInsets padding; - - const Toggle({ - Key? key, - required this.value, - required this.onChanged, - required this.style, - this.padding = const EdgeInsets.all(8.0), - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: (() => onChanged(value)), - child: Padding( - padding: padding, - child: Stack( - children: [ - Container( - height: style.height, - width: style.width, - decoration: BoxDecoration( - color: value ? style.activeBackgroundColor : style.inactiveBackgroundColor, - borderRadius: BorderRadius.circular(style.height / 2), - ), - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 150), - top: (style.height - style.thumbRadius) / 2, - left: value ? style.width - style.thumbRadius - 1 : 1, - child: Container( - height: style.thumbRadius, - width: style.thumbRadius, - decoration: BoxDecoration( - color: style.thumbColor, - borderRadius: BorderRadius.circular(style.thumbRadius / 2), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/toggle/toggle_style.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/toggle/toggle_style.dart deleted file mode 100644 index 683abfab5f875..0000000000000 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/toggle/toggle_style.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flowy_infra/theme.dart'; -import 'package:flutter/painting.dart'; -import 'package:flutter/widgets.dart'; - -class ToggleStyle { - final double height; - final double width; - - final double thumbRadius; - final Color thumbColor; - final Color activeBackgroundColor; - final Color inactiveBackgroundColor; - - ToggleStyle({ - required this.height, - required this.width, - required this.thumbRadius, - required this.thumbColor, - required this.activeBackgroundColor, - required this.inactiveBackgroundColor, - }); - - ToggleStyle.big(AppTheme theme) - : height = 16, - width = 27, - thumbRadius = 14, - activeBackgroundColor = theme.main1, - inactiveBackgroundColor = theme.shader5, - thumbColor = theme.surface; - - ToggleStyle.small(AppTheme theme) - : height = 10, - width = 16, - thumbRadius = 8, - activeBackgroundColor = theme.main1, - inactiveBackgroundColor = theme.shader5, - thumbColor = theme.surface; -} diff --git a/frontend/app_flowy/linux/CMakeLists.txt b/frontend/app_flowy/linux/CMakeLists.txt deleted file mode 100644 index ff7a4c7afafa0..0000000000000 --- a/frontend/app_flowy/linux/CMakeLists.txt +++ /dev/null @@ -1,120 +0,0 @@ -cmake_minimum_required(VERSION 3.10) -project(runner LANGUAGES CXX) - -set(BINARY_NAME "app_flowy") -set(APPLICATION_ID "com.example.app_flowy") - -cmake_policy(SET CMP0063 NEW) - -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Root filesystem for cross-building. -if(FLUTTER_TARGET_PLATFORM_SYSROOT) - set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) - set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) -endif() - -# Configure build options. -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") -endif() - -# Compilation settings that should be applied to most targets. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) - target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") -endfunction() - -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") - -# Flutter library and tool build rules. -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) - -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -# Application build -add_executable(${BINARY_NAME} - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) -apply_standard_settings(${BINARY_NAME}) -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) -add_dependencies(${BINARY_NAME} flutter_assemble) -# Only the install-generated bundle's copy of the executable will launch -# correctly, since the resources must in the right relative locations. To avoid -# people trying to run the unbundled copy, put it in a subdirectory instead of -# the default top-level location. -set_target_properties(${BINARY_NAME} - PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" -) - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# By default, "installing" just makes a relocatable bundle in the build -# directory. -set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -# Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) - -set(DART_FFI_DIR "${CMAKE_INSTALL_PREFIX}/lib") -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${DART_FFI_DLL}" DESTINATION "${DART_FFI_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -if(PLUGIN_BUNDLED_LIBRARIES) - install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") - install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() diff --git a/frontend/app_flowy/linux/appflowy.desktop.temp b/frontend/app_flowy/linux/appflowy.desktop.temp deleted file mode 100644 index 77bd120524cd8..0000000000000 --- a/frontend/app_flowy/linux/appflowy.desktop.temp +++ /dev/null @@ -1,8 +0,0 @@ -[Desktop Entry] -Name=AppFlowy -Comment=An Open Source Alternative to Notion -Icon=[CHANGE_THIS]/AppFlowy/flowy_logo.svg -Exec=[CHANGE_THIS]/AppFlowy/app_flowy -Categories=Office -Type=Application -Terminal=false \ No newline at end of file diff --git a/frontend/app_flowy/linux/flutter/dart_ffi/binding.h b/frontend/app_flowy/linux/flutter/dart_ffi/binding.h deleted file mode 100644 index a45d19dcefb40..0000000000000 --- a/frontend/app_flowy/linux/flutter/dart_ffi/binding.h +++ /dev/null @@ -1,14 +0,0 @@ -#include -#include -#include -#include - -int64_t init_sdk(char *path); - -void async_event(int64_t port, const uint8_t *input, uintptr_t len); - -const uint8_t *sync_event(const uint8_t *input, uintptr_t len); - -int32_t set_stream_port(int64_t port); - -void link_me_please(void); \ No newline at end of file diff --git a/frontend/app_flowy/linux/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index f05fb593f4bf2..0000000000000 --- a/frontend/app_flowy/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,31 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include -#include -#include -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) flowy_infra_ui_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FlowyInfraUIPlugin"); - flowy_infra_u_i_plugin_register_with_registrar(flowy_infra_ui_registrar); - g_autoptr(FlPluginRegistrar) hotkey_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "HotkeyManagerPlugin"); - hotkey_manager_plugin_register_with_registrar(hotkey_manager_registrar); - g_autoptr(FlPluginRegistrar) rich_clipboard_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "RichClipboardPlugin"); - rich_clipboard_plugin_register_with_registrar(rich_clipboard_linux_registrar); - g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); - url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); - g_autoptr(FlPluginRegistrar) window_size_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WindowSizePlugin"); - window_size_plugin_register_with_registrar(window_size_registrar); -} diff --git a/frontend/app_flowy/linux/flutter/generated_plugin_registrant.h b/frontend/app_flowy/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47bc08f3..0000000000000 --- a/frontend/app_flowy/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/app_flowy/linux/flutter/generated_plugins.cmake b/frontend/app_flowy/linux/flutter/generated_plugins.cmake deleted file mode 100644 index ce38abcac040e..0000000000000 --- a/frontend/app_flowy/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,28 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - flowy_infra_ui - hotkey_manager - rich_clipboard_linux - url_launcher_linux - window_size -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/frontend/app_flowy/linux/my_application.cc b/frontend/app_flowy/linux/my_application.cc deleted file mode 100644 index c3dd73fb7515a..0000000000000 --- a/frontend/app_flowy/linux/my_application.cc +++ /dev/null @@ -1,104 +0,0 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -// Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "app_flowy"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "app_flowy"); - } - - gtk_window_set_default_size(window, 1280, 720); - gtk_widget_show(GTK_WIDGET(window)); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - gtk_widget_grab_focus(GTK_WIDGET(view)); -} - -// Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -// Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) {} - -MyApplication* my_application_new() { - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, - "flags", G_APPLICATION_NON_UNIQUE, - nullptr)); -} diff --git a/frontend/app_flowy/macos/.gitignore b/frontend/app_flowy/macos/.gitignore deleted file mode 100644 index 9aad20e46dce0..0000000000000 --- a/frontend/app_flowy/macos/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Flutter-related -**/Flutter/ephemeral/ -**/Pods/ - -# Xcode-related -**/xcuserdata/ -Podfile.lock \ No newline at end of file diff --git a/frontend/app_flowy/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/app_flowy/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 2f24aad58b53a..0000000000000 --- a/frontend/app_flowy/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import connectivity_plus_macos -import device_info_plus_macos -import flowy_infra_ui -import flowy_sdk -import hotkey_manager -import package_info_plus_macos -import path_provider_macos -import rich_clipboard_macos -import shared_preferences_macos -import url_launcher_macos -import window_size - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) - DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) - FlowyInfraUIPlugin.register(with: registry.registrar(forPlugin: "FlowyInfraUIPlugin")) - FlowySdkPlugin.register(with: registry.registrar(forPlugin: "FlowySdkPlugin")) - HotkeyManagerPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerPlugin")) - FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) - RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) - WindowSizePlugin.register(with: registry.registrar(forPlugin: "WindowSizePlugin")) -} diff --git a/frontend/app_flowy/macos/Podfile b/frontend/app_flowy/macos/Podfile deleted file mode 100644 index e806f574bd372..0000000000000 --- a/frontend/app_flowy/macos/Podfile +++ /dev/null @@ -1,57 +0,0 @@ -platform :osx, '10.11' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_macos_podfile_setup - -def build_specify_archs_only - if ENV.has_key?('BUILD_ARCHS') - xcodeproj_path = File.dirname(__FILE__) + '/Runner.xcodeproj' - project = Xcodeproj::Project.open(xcodeproj_path) - project.targets.each do |target| - if target.name == 'Runner' - target.build_configurations.each do |config| - config.build_settings['ARCHS'] = ENV['BUILD_ARCHS'] - end - end - end - project.save() - end -end - -build_specify_archs_only() - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_macos_build_settings(target) - end -end diff --git a/frontend/app_flowy/macos/Runner.xcodeproj/project.pbxproj b/frontend/app_flowy/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 219b81efe0f3d..0000000000000 --- a/frontend/app_flowy/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,647 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 51; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - D7360C6D6177708F7B2D3C9D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD81A6C7244B2318E0BA2E8 /* Pods_Runner.framework */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1823EB6E74189944EAA69652 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 1CD81A6C7244B2318E0BA2E8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* AppFlowy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppFlowy.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 6DEEC7DEFA746DDF1338FF4D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 7D41C30A3910C3A40B6085E3 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - D7360C6D6177708F7B2D3C9D /* Pods_Runner.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - 6B44C542FA0845A8F2FA3624 /* Pods */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* AppFlowy.app */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - 6B44C542FA0845A8F2FA3624 /* Pods */ = { - isa = PBXGroup; - children = ( - 6DEEC7DEFA746DDF1338FF4D /* Pods-Runner.debug.xcconfig */, - 7D41C30A3910C3A40B6085E3 /* Pods-Runner.release.xcconfig */, - 1823EB6E74189944EAA69652 /* Pods-Runner.profile.xcconfig */, - ); - path = Pods; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 1CD81A6C7244B2318E0BA2E8 /* Pods_Runner.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 33CC10EC2044A3C60003C045 /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 611CF908D5E75C6DF581F81A /* [CP] Check Pods Manifest.lock */, - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - 3399D490228B24CF009A79C7 /* ShellScript */, - 4372B34726148A3BC297850A /* [CP] Embed Pods Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* AppFlowy.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - isa = PBXProject; - attributes = { - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 0930; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; - 4372B34726148A3BC297850A /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 611CF908D5E75C6DF581F81A /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - STRIP_STYLE = "non-global"; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - EXCLUDED_ARCHS = ""; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.macos; - PRODUCT_NAME = AppFlowy; - PROVISIONING_PROFILE_SPECIFIER = ""; - STRIP_STYLE = "non-global"; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - STRIP_STYLE = "non-global"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - STRIP_STYLE = "non-global"; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - EXCLUDED_ARCHS = ""; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.macos; - PRODUCT_NAME = AppFlowy; - PROVISIONING_PROFILE_SPECIFIER = ""; - STRIP_STYLE = "non-global"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - EXCLUDED_ARCHS = ""; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.macos; - PRODUCT_NAME = AppFlowy; - PROVISIONING_PROFILE_SPECIFIER = ""; - STRIP_STYLE = "non-global"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/frontend/app_flowy/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/app_flowy/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index 60b6e605fe763..0000000000000 --- a/frontend/app_flowy/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/macos/Runner/Configs/AppInfo.xcconfig b/frontend/app_flowy/macos/Runner/Configs/AppInfo.xcconfig deleted file mode 100644 index b42bbd8f62ed9..0000000000000 --- a/frontend/app_flowy/macos/Runner/Configs/AppInfo.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. - -// The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = app_flowy - -// The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.example.appFlowy - -// The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2021 com.example. All rights reserved. diff --git a/frontend/app_flowy/macos/Runner/DebugProfile.entitlements b/frontend/app_flowy/macos/Runner/DebugProfile.entitlements deleted file mode 100644 index e585d0e0f8fd0..0000000000000 --- a/frontend/app_flowy/macos/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - com.apple.security.network.client - - - diff --git a/frontend/app_flowy/macos/Runner/Info.plist b/frontend/app_flowy/macos/Runner/Info.plist deleted file mode 100644 index c5650a431360d..0000000000000 --- a/frontend/app_flowy/macos/Runner/Info.plist +++ /dev/null @@ -1,44 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - CFBundleLocalizations - - en - fr - it - zh - - - diff --git a/frontend/app_flowy/macos/Runner/MainFlutterWindow.swift b/frontend/app_flowy/macos/Runner/MainFlutterWindow.swift deleted file mode 100644 index 8e357d7ca1b7d..0000000000000 --- a/frontend/app_flowy/macos/Runner/MainFlutterWindow.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Cocoa -import FlutterMacOS - -private let kTrafficLightOffetTop = 22 - -class MainFlutterWindow: NSWindow { - func registerMethodChannel(flutterViewController: FlutterViewController) { - let cocoaWindowChannel = FlutterMethodChannel(name: "flutter/cocoaWindow", binaryMessenger: flutterViewController.engine.binaryMessenger) - cocoaWindowChannel.setMethodCallHandler({ - (call: FlutterMethodCall, result: FlutterResult) -> Void in - if call.method == "setWindowPosition" { - guard let position = call.arguments as? NSArray else { - result(nil) - return - } - let nX = position[0] as! NSNumber - let nY = position[1] as! NSNumber - let x = nX.doubleValue - let y = nY.doubleValue - - self.setFrameOrigin(NSPoint(x: x, y: y)) - result(nil) - return - } else if call.method == "getWindowPosition" { - let frame = self.frame - result([frame.origin.x, frame.origin.y]) - return - } else if call.method == "zoom" { - self.zoom(self) - result(nil) - return - } - - result(FlutterMethodNotImplemented) - }) - } - - func layoutTrafficLightButton(titlebarView: NSView, button: NSButton, offsetTop: CGFloat, offsetLeft: CGFloat) { - button.translatesAutoresizingMaskIntoConstraints = false; - titlebarView.addConstraint(NSLayoutConstraint.init( - item: button, - attribute: NSLayoutConstraint.Attribute.top, relatedBy: NSLayoutConstraint.Relation.equal, toItem: titlebarView, attribute: NSLayoutConstraint.Attribute.top, multiplier: 1, constant: offsetTop)) - titlebarView.addConstraint(NSLayoutConstraint.init( - item: button, - attribute: NSLayoutConstraint.Attribute.left, relatedBy: NSLayoutConstraint.Relation.equal, toItem: titlebarView, attribute: NSLayoutConstraint.Attribute.left, multiplier: 1, constant: offsetLeft)) - } - - func layoutTrafficLights() { - let closeButton = self.standardWindowButton(ButtonType.closeButton)! - let minButton = self.standardWindowButton(ButtonType.miniaturizeButton)! - let zoomButton = self.standardWindowButton(ButtonType.zoomButton)! - let titlebarView = closeButton.superview! - - self.layoutTrafficLightButton(titlebarView: titlebarView, button: closeButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 20) - self.layoutTrafficLightButton(titlebarView: titlebarView, button: minButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 38) - self.layoutTrafficLightButton(titlebarView: titlebarView, button: zoomButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 56) - - let customToolbar = NSTitlebarAccessoryViewController() - let newView = NSView() - newView.frame = NSRect(origin: CGPoint(), size: CGSize(width: 0, height: 40)) // only the height is cared - customToolbar.view = newView - self.addTitlebarAccessoryViewController(customToolbar) - } - - override func awakeFromNib() { - let flutterViewController = FlutterViewController.init() - let windowFrame = self.frame - self.contentViewController = flutterViewController - - self.registerMethodChannel(flutterViewController: flutterViewController) - - self.setFrame(windowFrame, display: true) - self.titlebarAppearsTransparent = true - self.titleVisibility = .hidden - self.styleMask.insert(StyleMask.fullSizeContentView) - self.isMovableByWindowBackground = true - self.isMovable = false - - self.layoutTrafficLights() - - RegisterGeneratedPlugins(registry: flutterViewController) - - super.awakeFromNib() - } -} diff --git a/frontend/app_flowy/macos/Runner/Release.entitlements b/frontend/app_flowy/macos/Runner/Release.entitlements deleted file mode 100644 index 779a1789c4f11..0000000000000 --- a/frontend/app_flowy/macos/Runner/Release.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.network.client - - - diff --git a/frontend/app_flowy/packages/appflowy_board/CHANGELOG.md b/frontend/app_flowy/packages/appflowy_board/CHANGELOG.md deleted file mode 100644 index a4d3ca722b52a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/CHANGELOG.md +++ /dev/null @@ -1,36 +0,0 @@ -# 0.0.9 -* Enable slide to select text in card -* Fix some bugs - -# 0.0.8 -* Enable drag and drop group - -# 0.0.7 -* Rename some classes -* Add documentation - -# 0.0.6 -* Support scroll to bottom -* Fix some bugs - -# 0.0.5 -* Optimize insert card animation -* Enable insert card at the end of the group -* Fix some bugs - -# 0.0.4 -* Fix some bugs - -# 0.0.3 -* Support customize UI -* Update example -* Add AppFlowy style widget - -# 0.0.2 - -* Update documentation - -# 0.0.1 - -* Support drag and drop group items from one to another - diff --git a/frontend/app_flowy/packages/appflowy_board/LICENSE b/frontend/app_flowy/packages/appflowy_board/LICENSE deleted file mode 100644 index 0ad25db4bd1d8..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/frontend/app_flowy/packages/appflowy_board/README.md b/frontend/app_flowy/packages/appflowy_board/README.md deleted file mode 100644 index 7d879a491b9c7..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/README.md +++ /dev/null @@ -1,140 +0,0 @@ -# appflowy_board - -

AppFlowy Board

- -

A customizable and draggable Kanban Board widget for Flutter

- -
- - -

- -

- -## Intro - -appflowy_board is a customizable and draggable Kanban Board widget for Flutter. -You can use it to create a Kanban Board tool like those in Trello. - -Check out [AppFlowy](https://github.com/AppFlowy-IO/AppFlowy) to see how appflowy_board is used to build a BoardView database. -

- -

- - -## Getting Started -Add the AppFlowy Board [Flutter package](https://docs.flutter.dev/development/packages-and-plugins/using-packages) to your environment. - -With Flutter: -```dart -flutter pub add appflowy_board -flutter pub get -``` - -This will add a line like this to your package's pubspec.yaml: -```dart -dependencies: - appflowy_board: ^0.0.6 -``` - -## Create your first board - -Initialize an `AppFlowyBoardController` for the board. It contains the data used by the board. You can -register callbacks to receive the changes of the board. - -```dart - -final AppFlowyBoardController controller = AppFlowyBoardController( - onMoveGroup: (fromGroupId, fromIndex, toGroupId, toIndex) { - debugPrint('Move item from $fromIndex to $toIndex'); - }, - onMoveGroupItem: (groupId, fromIndex, toIndex) { - debugPrint('Move $groupId:$fromIndex to $groupId:$toIndex'); - }, - onMoveGroupItemToGroup: (fromGroupId, fromIndex, toGroupId, toIndex) { - debugPrint('Move $fromGroupId:$fromIndex to $toGroupId:$toIndex'); - }, -); -``` - -Provide an initial value of the board by initializing the `AppFlowyGroupData`. It represents a group data and contains list of items. Each item displayed in the group requires to implement the `AppFlowyGroupItem` class. - -```dart - -void initState() { - final group1 = AppFlowyGroupData(id: "To Do", items: [ - TextItem("Card 1"), - TextItem("Card 2"), - ]); - final group2 = AppFlowyGroupData(id: "In Progress", items: [ - TextItem("Card 3"), - TextItem("Card 4"), - ]); - - final group3 = AppFlowyGroupData(id: "Done", items: []); - - controller.addGroup(group1); - controller.addGroup(group2); - controller.addGroup(group3); - super.initState(); -} - -class TextItem extends AppFlowyGroupItem { - final String s; - TextItem(this.s); - - @override - String get id => s; -} - -``` - -Finally, return a `AppFlowyBoard` widget in the build method. - -```dart - -@override -Widget build(BuildContext context) { - return AppFlowyBoard( - controller: controller, - cardBuilder: (context, group, groupItem) { - final textItem = groupItem as TextItem; - return AppFlowyGroupCard( - key: ObjectKey(textItem), - child: Text(textItem.s), - ); - }, - groupConstraints: const BoxConstraints.tightFor(width: 240), - ); -} - -``` - -## Usage Example -To quickly grasp how it can be used, look at the /example/lib folder. -First, run main.dart to play with the demo. - - -Second, let's delve into multi_board_list_example.dart to understand a few key components: -* A Board widget is created via instantiating an `AppFlowyBoard` object. -* In the `AppFlowyBoard` object, you can find the `AppFlowyBoardController`, which is defined in board_data.dart, is fed with pre-populated mock data. It also contains callback functions to materialize future user data. -* Three builders: AppFlowyBoardHeaderBuilder, AppFlowyBoardFooterBuilder, AppFlowyBoardCardBuilder. See below image for what they are used for. - - -

- -

- -## Glossary -Please refer to the API documentation. - -## Contributing -Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated. - -Please look at [CONTRIBUTING.md](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/contributing-to-appflowy) for details. - -## License -Distributed under the AGPLv3 License. See [LICENSE](https://github.com/AppFlowy-IO/AppFlowy-Docs/blob/main/LICENSE) for more information. diff --git a/frontend/app_flowy/packages/appflowy_board/example/.gitignore b/frontend/app_flowy/packages/appflowy_board/example/.gitignore deleted file mode 100644 index 34802023f37ba..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/.gitignore +++ /dev/null @@ -1,49 +0,0 @@ -# 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 -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -.pub-cache/ -.pub/ -/build/ - -# Web related -lib/generated_plugin_registrant.dart - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release - -.metadata \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/build.gradle b/frontend/app_flowy/packages/appflowy_board/example/android/app/build.gradle deleted file mode 100644 index 4466eba6525db..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/android/app/build.gradle +++ /dev/null @@ -1,71 +0,0 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -android { - compileSdkVersion flutter.compileSdkVersion - ndkVersion flutter.ndkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.appflowy.board.example" - // You can update the following values to match your application needs. - // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. - minSdkVersion flutter.minSdkVersion - targetSdkVersion flutter.targetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig signingConfigs.debug - } - } -} - -flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/debug/AndroidManifest.xml b/frontend/app_flowy/packages/appflowy_board/example/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 95bcce0ae3a4f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/AndroidManifest.xml b/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 07ba21884a6ee..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/kotlin/com/appflowy/board/example/MainActivity.kt b/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/kotlin/com/appflowy/board/example/MainActivity.kt deleted file mode 100644 index 4cd6f28560b0a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/kotlin/com/appflowy/board/example/MainActivity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.appflowy.board.example - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity: FlutterActivity() { -} diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/profile/AndroidManifest.xml b/frontend/app_flowy/packages/appflowy_board/example/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 95bcce0ae3a4f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_builders.jpg b/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_builders.jpg deleted file mode 100644 index 76a9fb67f83d4..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_builders.jpg and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_video_1.gif b/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_video_1.gif deleted file mode 100644 index bf1345608e2e8..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_video_1.gif and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_video_2.gif b/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_video_2.gif deleted file mode 100644 index d2a29020ed6d1..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_video_2.gif and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Flutter/AppFrameworkInfo.plist b/frontend/app_flowy/packages/appflowy_board/example/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 8d4492f977adc..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - MinimumOSVersion - 9.0 - - diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcodeproj/project.pbxproj b/frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 74374c85300fb..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,481 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 50; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 1300; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.board.example; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.board.example; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.board.example; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index c87d15a335208..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Info.plist b/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Info.plist deleted file mode 100644 index 907f329fe0923..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Info.plist +++ /dev/null @@ -1,49 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Example - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - example - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - CADisableMinimumFrameDurationOnPhone - - - diff --git a/frontend/app_flowy/packages/appflowy_board/example/lib/main.dart b/frontend/app_flowy/packages/appflowy_board/example/lib/main.dart deleted file mode 100644 index 9e42611e7e7b7..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/lib/main.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; -import 'single_board_list_example.dart'; -import 'multi_board_list_example.dart'; - -void main() { - runApp(const MyApp()); -} - -class MyApp extends StatefulWidget { - const MyApp({Key? key}) : super(key: key); - - @override - State createState() => _MyAppState(); -} - -class _MyAppState extends State { - int _currentIndex = 0; - final _bottomNavigationColor = Colors.blue; - - final List _examples = [ - const MultiBoardListExample(), - const SingleBoardListExample(), - ]; - - @override - void initState() { - super.initState(); - } - - @override - Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - appBar: AppBar( - title: const Text('AppFlowy Board'), - ), - body: Container(color: Colors.white, child: _examples[_currentIndex]), - bottomNavigationBar: BottomNavigationBar( - fixedColor: _bottomNavigationColor, - showSelectedLabels: true, - showUnselectedLabels: false, - currentIndex: _currentIndex, - items: [ - BottomNavigationBarItem( - icon: Icon(Icons.grid_on, color: _bottomNavigationColor), - label: "MultiColumn"), - BottomNavigationBarItem( - icon: Icon(Icons.grid_on, color: _bottomNavigationColor), - label: "SingleColumn"), - ], - onTap: (int index) { - setState(() { - _currentIndex = index; - }); - }, - )), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart b/frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart deleted file mode 100644 index ddf764d5eae01..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'package:appflowy_board/appflowy_board.dart'; -import 'package:flutter/material.dart'; - -class MultiBoardListExample extends StatefulWidget { - const MultiBoardListExample({Key? key}) : super(key: key); - - @override - State createState() => _MultiBoardListExampleState(); -} - -class _MultiBoardListExampleState extends State { - final AppFlowyBoardController controller = AppFlowyBoardController( - onMoveGroup: (fromGroupId, fromIndex, toGroupId, toIndex) { - debugPrint('Move item from $fromIndex to $toIndex'); - }, - onMoveGroupItem: (groupId, fromIndex, toIndex) { - debugPrint('Move $groupId:$fromIndex to $groupId:$toIndex'); - }, - onMoveGroupItemToGroup: (fromGroupId, fromIndex, toGroupId, toIndex) { - debugPrint('Move $fromGroupId:$fromIndex to $toGroupId:$toIndex'); - }, - ); - - late AppFlowyBoardScrollController boardController; - - @override - void initState() { - boardController = AppFlowyBoardScrollController(); - final group1 = AppFlowyGroupData(id: "To Do", name: "To Do", items: [ - TextItem("Card 1"), - TextItem("Card 2"), - RichTextItem(title: "Card 3", subtitle: 'Aug 1, 2020 4:05 PM'), - TextItem("Card 4"), - TextItem("Card 5"), - TextItem("Card 6"), - RichTextItem(title: "Card 7", subtitle: 'Aug 1, 2020 4:05 PM'), - RichTextItem(title: "Card 8", subtitle: 'Aug 1, 2020 4:05 PM'), - TextItem("Card 9"), - ]); - - final group2 = AppFlowyGroupData( - id: "In Progress", - name: "In Progress", - items: [ - RichTextItem(title: "Card 10", subtitle: 'Aug 1, 2020 4:05 PM'), - TextItem("Card 11"), - ], - ); - - final group3 = AppFlowyGroupData( - id: "Done", name: "Done", items: []); - - controller.addGroup(group1); - controller.addGroup(group2); - controller.addGroup(group3); - - super.initState(); - } - - @override - Widget build(BuildContext context) { - final config = AppFlowyBoardConfig( - groupBackgroundColor: HexColor.fromHex('#F7F8FC'), - ); - return AppFlowyBoard( - controller: controller, - cardBuilder: (context, group, groupItem) { - return AppFlowyGroupCard( - key: ValueKey(groupItem.id), - child: _buildCard(groupItem), - ); - }, - boardScrollController: boardController, - footerBuilder: (context, columnData) { - return AppFlowyGroupFooter( - icon: const Icon(Icons.add, size: 20), - title: const Text('New'), - height: 50, - margin: config.groupItemPadding, - onAddButtonClick: () { - boardController.scrollToBottom(columnData.id); - }, - ); - }, - headerBuilder: (context, columnData) { - return AppFlowyGroupHeader( - icon: const Icon(Icons.lightbulb_circle), - title: SizedBox( - width: 60, - child: TextField( - controller: TextEditingController() - ..text = columnData.headerData.groupName, - onSubmitted: (val) { - controller - .getGroupController(columnData.headerData.groupId)! - .updateGroupName(val); - }, - ), - ), - addIcon: const Icon(Icons.add, size: 20), - moreIcon: const Icon(Icons.more_horiz, size: 20), - height: 50, - margin: config.groupItemPadding, - ); - }, - groupConstraints: const BoxConstraints.tightFor(width: 240), - config: config); - } - - Widget _buildCard(AppFlowyGroupItem item) { - if (item is TextItem) { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 30), - child: Text(item.s), - ), - ); - } - - if (item is RichTextItem) { - return RichTextCard(item: item); - } - - throw UnimplementedError(); - } -} - -class RichTextCard extends StatefulWidget { - final RichTextItem item; - const RichTextCard({ - required this.item, - Key? key, - }) : super(key: key); - - @override - State createState() => _RichTextCardState(); -} - -class _RichTextCardState extends State { - @override - void initState() { - super.initState(); - } - - @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.item.title, - style: const TextStyle(fontSize: 14), - textAlign: TextAlign.left, - ), - const SizedBox(height: 10), - Text( - widget.item.subtitle, - style: const TextStyle(fontSize: 12, color: Colors.grey), - ) - ], - ), - ), - ); - } -} - -class TextItem extends AppFlowyGroupItem { - final String s; - - TextItem(this.s); - - @override - String get id => s; -} - -class RichTextItem extends AppFlowyGroupItem { - final String title; - final String subtitle; - - RichTextItem({required this.title, required this.subtitle}); - - @override - String get id => title; -} - -extension HexColor on Color { - static Color fromHex(String hexString) { - final buffer = StringBuffer(); - if (hexString.length == 6 || hexString.length == 7) buffer.write('ff'); - buffer.write(hexString.replaceFirst('#', '')); - return Color(int.parse(buffer.toString(), radix: 16)); - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/example/lib/single_board_list_example.dart b/frontend/app_flowy/packages/appflowy_board/example/lib/single_board_list_example.dart deleted file mode 100644 index c222856efa840..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/lib/single_board_list_example.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:appflowy_board/appflowy_board.dart'; - -class SingleBoardListExample extends StatefulWidget { - const SingleBoardListExample({Key? key}) : super(key: key); - - @override - State createState() => _SingleBoardListExampleState(); -} - -class _SingleBoardListExampleState extends State { - final AppFlowyBoardController boardData = AppFlowyBoardController(); - - @override - void initState() { - final column = AppFlowyGroupData( - id: "1", - name: "1", - items: [ - TextItem("a"), - TextItem("b"), - TextItem("c"), - TextItem("d"), - ], - ); - - boardData.addGroup(column); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return AppFlowyBoard( - controller: boardData, - cardBuilder: (context, column, columnItem) { - return _RowWidget( - item: columnItem as TextItem, key: ObjectKey(columnItem)); - }, - ); - } -} - -class _RowWidget extends StatelessWidget { - final TextItem item; - const _RowWidget({Key? key, required this.item}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - key: ObjectKey(item), - height: 60, - color: Colors.green, - child: Center(child: Text(item.s)), - ); - } -} - -class TextItem extends AppFlowyGroupItem { - final String s; - - TextItem(this.s); - - @override - String get id => s; -} diff --git a/frontend/app_flowy/packages/appflowy_board/example/linux/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_board/example/linux/CMakeLists.txt deleted file mode 100644 index 697a9f905ae6b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/linux/CMakeLists.txt +++ /dev/null @@ -1,138 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.10) -project(runner LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "example") -# The unique GTK application identifier for this application. See: -# https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.appflowy.board.example") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(SET CMP0063 NEW) - -# Load bundled libraries from the lib/ directory relative to the binary. -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Root filesystem for cross-building. -if(FLUTTER_TARGET_PLATFORM_SYSROOT) - set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) - set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) -endif() - -# Define build configuration options. -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") -endif() - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) - target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) - -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -# Define the application target. To change its name, change BINARY_NAME above, -# not the value here, or `flutter run` will no longer work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add dependency libraries. Add any application-specific dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) - -# Only the install-generated bundle's copy of the executable will launch -# correctly, since the resources must in the right relative locations. To avoid -# people trying to run the unbundled copy, put it in a subdirectory instead of -# the default top-level location. -set_target_properties(${BINARY_NAME} - PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" -) - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# By default, "installing" just makes a relocatable bundle in the build -# directory. -set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -# Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) - install(FILES "${bundled_library}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endforeach(bundled_library) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") - install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() diff --git a/frontend/app_flowy/packages/appflowy_board/example/linux/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/packages/appflowy_board/example/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index e71a16d23d058..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,11 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - - -void fl_register_plugins(FlPluginRegistry* registry) { -} diff --git a/frontend/app_flowy/packages/appflowy_board/example/linux/flutter/generated_plugin_registrant.h b/frontend/app_flowy/packages/appflowy_board/example/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47bc08f3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/app_flowy/packages/appflowy_board/example/linux/flutter/generated_plugins.cmake b/frontend/app_flowy/packages/appflowy_board/example/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 2e1de87a7eb61..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,23 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/app_flowy/packages/appflowy_board/example/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index cccf817a52206..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { -} diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/app_flowy/packages/appflowy_board/example/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index c84862c675761..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,572 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 51; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* example.app */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 33CC10EC2044A3C60003C045 /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - 3399D490228B24CF009A79C7 /* ShellScript */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* example.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - isa = PBXProject; - attributes = { - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/app_flowy/packages/appflowy_board/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index fb7259e17785a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Configs/AppInfo.xcconfig b/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Configs/AppInfo.xcconfig deleted file mode 100644 index e379d4a333bd8..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Configs/AppInfo.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. - -// The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = example - -// The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.board.example - -// The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2022 com.appflowy.board. All rights reserved. diff --git a/frontend/app_flowy/packages/appflowy_board/example/pubspec.yaml b/frontend/app_flowy/packages/appflowy_board/example/pubspec.yaml deleted file mode 100644 index e6ef2c7013a13..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/pubspec.yaml +++ /dev/null @@ -1,91 +0,0 @@ -name: example -description: A new Flutter project. - -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.0+1 - -environment: - sdk: ">=2.17.0 <3.0.0" - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - appflowy_board: - path: ../ - - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 - -dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^2.0.1 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/packages/appflowy_board/example/test/widget_test.dart b/frontend/app_flowy/packages/appflowy_board/example/test/widget_test.dart deleted file mode 100644 index 29cad86982fb7..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/test/widget_test.dart +++ /dev/null @@ -1,7 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. -void main() {} diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/packages/appflowy_board/example/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 8b6d4680af388..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,11 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - - -void RegisterPlugins(flutter::PluginRegistry* registry) { -} diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/flutter/generated_plugin_registrant.h b/frontend/app_flowy/packages/appflowy_board/example/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d85a9310..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/flutter/generated_plugins.cmake b/frontend/app_flowy/packages/appflowy_board/example/windows/flutter/generated_plugins.cmake deleted file mode 100644 index b93c4c30c1670..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,23 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/runner/Runner.rc b/frontend/app_flowy/packages/appflowy_board/example/windows/runner/Runner.rc deleted file mode 100644 index 89492605148de..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/example/windows/runner/Runner.rc +++ /dev/null @@ -1,121 +0,0 @@ -// Microsoft Visual C++ generated resource script. -// -#pragma code_page(65001) -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_APP_ICON ICON "resources\\app_icon.ico" - - -///////////////////////////////////////////////////////////////////////////// -// -// Version -// - -#ifdef FLUTTER_BUILD_NUMBER -#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER -#else -#define VERSION_AS_NUMBER 1,0,0 -#endif - -#ifdef FLUTTER_BUILD_NAME -#define VERSION_AS_STRING #FLUTTER_BUILD_NAME -#else -#define VERSION_AS_STRING "1.0.0" -#endif - -VS_VERSION_INFO VERSIONINFO - FILEVERSION VERSION_AS_NUMBER - PRODUCTVERSION VERSION_AS_NUMBER - FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG - FILEFLAGS VS_FF_DEBUG -#else - FILEFLAGS 0x0L -#endif - FILEOS VOS__WINDOWS32 - FILETYPE VFT_APP - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904e4" - BEGIN - VALUE "CompanyName", "com.appflowy.board" "\0" - VALUE "FileDescription", "example" "\0" - VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "example" "\0" - VALUE "LegalCopyright", "Copyright (C) 2022 com.appflowy.board. All rights reserved." "\0" - VALUE "OriginalFilename", "example.exe" "\0" - VALUE "ProductName", "example" "\0" - VALUE "ProductVersion", VERSION_AS_STRING "\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED diff --git a/frontend/app_flowy/packages/appflowy_board/lib/appflowy_board.dart b/frontend/app_flowy/packages/appflowy_board/lib/appflowy_board.dart deleted file mode 100644 index 6125cf29e61af..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/appflowy_board.dart +++ /dev/null @@ -1,7 +0,0 @@ -/// AppFlowyBoard library -library appflowy_board; - -export 'src/widgets/board_group/group_data.dart'; -export 'src/widgets/board_data.dart'; -export 'src/widgets/styled_widgets/widgets.dart'; -export 'src/widgets/board.dart'; diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/rendering/board_overlay.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/rendering/board_overlay.dart deleted file mode 100644 index f2c99e057b154..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/rendering/board_overlay.dart +++ /dev/null @@ -1,409 +0,0 @@ -import 'dart:collection'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/scheduler.dart'; - -class BoardOverlayEntry { - /// This entry will include the widget built by this builder in the overlay at - /// the entry's position. - /// The builder will be called again after calling [markNeedsBuild] on this entry. - final WidgetBuilder builder; - - /// Whether this entry occludes the entire overlay. - /// - /// If an entry claims to be opaque, then, for efficiency, the overlay will - /// skip building entries below that entry. - bool get opaque => _opaque; - bool _opaque; - - BoardOverlayState? _overlay; - final GlobalKey<_OverlayEntryWidgetState> _key = - GlobalKey<_OverlayEntryWidgetState>(); - - set opaque(bool value) { - if (_opaque == value) return; - - _opaque = value; - assert(_overlay != null); - _overlay!._didChangeEntryOpacity(); - } - - BoardOverlayEntry({ - required this.builder, - bool opaque = false, - }) : _opaque = opaque; - - /// If this method is called while the [SchedulerBinding.schedulerPhase] is - /// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or - /// paint phases (see [WidgetsBinding.drawFrame]), then the removal is - /// delayed until the post-frame callbacks phase. Otherwise the removal is done synchronously. - void remove() { - assert(_overlay != null, 'Should only call once'); - final BoardOverlayState overlay = _overlay!; - _overlay = null; - if (SchedulerBinding.instance.schedulerPhase == - SchedulerPhase.persistentCallbacks) { - SchedulerBinding.instance.addPostFrameCallback((Duration duration) { - overlay._remove(this); - }); - } else { - overlay._remove(this); - } - } - - /// Cause this entry to rebuild during the next pipeline flush. - /// You need to call this function if the output of [builder] has changed. - void markNeedsBuild() { - _key.currentState?._markNeedsBuild(); - } -} - -/// A [Stack] of entries that can be managed independently. -/// -/// Overlays let independent child widgets "float" visual elements on top of -/// other widgets by inserting them into the overlay's [Stack]. The overlay lets -/// each of these widgets manage their participation in the overlay using -/// [OverlayEntry] objects. -class BoardOverlay extends StatefulWidget { - final List initialEntries; - - const BoardOverlay({ - this.initialEntries = const [], - Key? key, - }) : super(key: key); - - static BoardOverlayState of(BuildContext context, - {Widget? debugRequiredFor}) { - final BoardOverlayState? result = - context.findAncestorStateOfType(); - assert(() { - if (debugRequiredFor != null && result == null) { - final String additional = context.widget != debugRequiredFor - ? '\nThe context from which that widget was searching for an overlay was:\n $context' - : ''; - throw FlutterError('No Overlay widget found.\n' - '${debugRequiredFor.runtimeType} widgets require an Overlay widget ancestor for correct operation.\n' - 'The most common way to add an Overlay to an application is to include a MaterialApp or Navigator widget in the runApp() call.\n' - 'The specific widget that failed to find an overlay was:\n' - ' $debugRequiredFor' - '$additional'); - } - return true; - }()); - return result!; - } - - @override - BoardOverlayState createState() => BoardOverlayState(); -} - -class BoardOverlayState extends State - with TickerProviderStateMixin { - final List _entries = []; - - @override - void initState() { - super.initState(); - insertAll(widget.initialEntries); - } - - /// Insert the given entry into the overlay. - /// - /// If [above] is non-null, the entry is inserted just above [above]. - /// Otherwise, the entry is inserted on top. - void insert(BoardOverlayEntry entry, {BoardOverlayEntry? above}) { - assert(entry._overlay == null); - assert( - above == null || (above._overlay == this && _entries.contains(above))); - entry._overlay = this; - setState(() { - final int index = - above == null ? _entries.length : _entries.indexOf(above) + 1; - _entries.insert(index, entry); - }); - } - - /// Insert all the entries in the given iterable. - /// - /// If [above] is non-null, the entries are inserted just above [above]. - /// Otherwise, the entries are inserted on top. - void insertAll(Iterable entries, - {BoardOverlayEntry? above}) { - assert( - above == null || (above._overlay == this && _entries.contains(above))); - if (entries.isEmpty) return; - for (BoardOverlayEntry entry in entries) { - assert(entry._overlay == null); - entry._overlay = this; - } - setState(() { - final int index = - above == null ? _entries.length : _entries.indexOf(above) + 1; - _entries.insertAll(index, entries); - }); - } - - void _remove(BoardOverlayEntry entry) { - if (mounted) { - _entries.remove(entry); - setState(() { - /* entry was removed */ - }); - } - } - - void _didChangeEntryOpacity() { - setState(() { - // We use the opacity of the entry in our build function, which means we - // our state has changed. - }); - } - - @override - Widget build(BuildContext context) { - // These lists are filled backwards. For the offstage children that - // does not matter since they aren't rendered, but for the onstage - // children we reverse the list below before adding it to the tree. - final List onstageChildren = []; - final List offstageChildren = []; - bool onstage = true; - for (int i = _entries.length - 1; i >= 0; i -= 1) { - final BoardOverlayEntry entry = _entries[i]; - if (onstage) { - onstageChildren.add(_OverlayEntryWidget(entry)); - if (entry.opaque) onstage = false; - } - } - return _BoardStack( - onstage: Stack( - fit: StackFit.passthrough, - //HanSheng changed it to passthrough so that this widget doesn't change layout constraints - children: onstageChildren.reversed.toList(growable: false), - ), - offstage: offstageChildren, - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - .add(DiagnosticsProperty>('entries', _entries)); - } -} - -class _OverlayEntryWidget extends StatefulWidget { - _OverlayEntryWidget(this.entry) : super(key: entry._key); - - final BoardOverlayEntry entry; - - @override - _OverlayEntryWidgetState createState() => _OverlayEntryWidgetState(); -} - -class _OverlayEntryWidgetState extends State<_OverlayEntryWidget> { - @override - Widget build(BuildContext context) { - return widget.entry.builder(context); - } - - void _markNeedsBuild() { - setState(() {}); - } -} - -/// A widget that has one [onstage] child which is visible, and one or more -/// [offstage] widgets which are kept alive, and are built, but are not laid out -/// or painted. -/// -/// The onstage widget must be a Stack. -/// -/// For convenience, it is legal to use [Positioned] widgets around the offstage -/// widgets. -class _BoardStack extends RenderObjectWidget { - final Stack? onstage; - final List offstage; - - const _BoardStack({ - required this.offstage, - this.onstage, - }); - - @override - _BoardStackElement createElement() => _BoardStackElement(this); - - @override - _RenderBoardObject createRenderObject(BuildContext context) => - _RenderBoardObject(); -} - -class _BoardStackElement extends RenderObjectElement { - Element? _onstage; - static final Object _onstageSlot = Object(); - late List _offstage; - final Set _forgottenOffstageChildren = HashSet(); - - _BoardStackElement(_BoardStack widget) - : assert(!debugChildrenHaveDuplicateKeys(widget, widget.offstage)), - super(widget); - - @override - _BoardStack get widget => super.widget as _BoardStack; - - @override - _RenderBoardObject get renderObject => - super.renderObject as _RenderBoardObject; - - @override - void insertRenderObjectChild(RenderBox child, dynamic slot) { - assert(renderObject.debugValidateChild(child)); - if (slot == _onstageSlot) { - assert(child is RenderStack); - renderObject.child = child as RenderStack?; - } else { - assert(slot == null || slot is Element); - renderObject.insert(child, after: slot?.renderObject); - } - } - - @override - void moveRenderObjectChild(RenderBox child, dynamic oldSlot, dynamic slot) { - if (slot == _onstageSlot) { - renderObject.remove(child); - assert(child is RenderStack); - renderObject.child = child as RenderStack?; - } else { - assert(slot == null || slot is Element); - if (renderObject.child == child) { - renderObject.child = null; - renderObject.insert(child, after: slot?.renderObject); - } else { - renderObject.move(child, after: slot?.renderObject); - } - } - } - - @override - void removeRenderObjectChild(RenderBox child, dynamic slot) { - if (renderObject.child == child) { - renderObject.child = null; - } else { - renderObject.remove(child); - } - } - - @override - void visitChildren(ElementVisitor visitor) { - if (_onstage != null) visitor(_onstage!); - for (Element child in _offstage) { - if (!_forgottenOffstageChildren.contains(child)) visitor(child); - } - } - - @override - void debugVisitOnstageChildren(ElementVisitor visitor) { - if (_onstage != null) visitor(_onstage!); - } - - @override - void forgetChild(Element child) { - if (child == _onstage) { - _onstage = null; - } else { - assert(_offstage.contains(child)); - assert(!_forgottenOffstageChildren.contains(child)); - _forgottenOffstageChildren.add(child); - } - super.forgetChild(child); - } - - @override - void mount(Element? parent, dynamic newSlot) { - super.mount(parent, newSlot); - _onstage = updateChild(_onstage, widget.onstage, _onstageSlot); - _offstage = []; - } - - @override - void update(_BoardStack newWidget) { - super.update(newWidget); - assert(widget == newWidget); - _onstage = updateChild(_onstage, widget.onstage, _onstageSlot); - _offstage = updateChildren(_offstage, widget.offstage, - forgottenChildren: _forgottenOffstageChildren); - _forgottenOffstageChildren.clear(); - } -} - -// A render object which lays out and paints one subtree while keeping a list -// of other subtrees alive but not laid out or painted. -// -// The subtree that is laid out and painted must be a [RenderStack]. -// -// This class uses [StackParentData] objects for its parent data so that the -// children of its primary subtree's stack can be moved to this object's list -// of zombie children without changing their parent data objects. -class _RenderBoardObject extends RenderBox - with - RenderObjectWithChildMixin, - RenderProxyBoxMixin, - ContainerRenderObjectMixin { - @override - void setupParentData(RenderObject child) { - if (child.parentData is! StackParentData) { - child.parentData = StackParentData(); - } - } - - @override - void redepthChildren() { - if (child != null) redepthChild(child!); - super.redepthChildren(); - } - - @override - void visitChildren(RenderObjectVisitor visitor) { - if (child != null) visitor(child!); - super.visitChildren(visitor); - } - - @override - List debugDescribeChildren() { - final List children = []; - - if (child != null) children.add(child!.toDiagnosticsNode(name: 'onstage')); - - if (firstChild != null) { - RenderBox child = firstChild!; - - int count = 1; - while (true) { - children.add( - child.toDiagnosticsNode( - name: 'offstage $count', - style: DiagnosticsTreeStyle.offstage, - ), - ); - if (child == lastChild) break; - final StackParentData childParentData = - child.parentData! as StackParentData; - child = childParentData.nextSibling!; - count += 1; - } - } else { - children.add( - DiagnosticsNode.message( - 'no offstage children', - style: DiagnosticsTreeStyle.offstage, - ), - ); - } - return children; - } - - @override - void visitChildrenForSemantics(RenderObjectVisitor visitor) { - if (child != null) visitor(child!); - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart deleted file mode 100644 index b73d28eed22ce..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; - -// ignore: constant_identifier_names -const DART_LOG = "Dart_LOG"; - -class Log { - static const enableLog = false; - - static void info(String? message) { - if (enableLog) { - debugPrint('AppFlowyBoard: ℹ️[Info]=> $message'); - } - } - - static void debug(String? message) { - if (enableLog) { - debugPrint( - 'AppFlowyBoard: 🐛[Debug] - ${DateTime.now().second}=> $message'); - } - } - - static void warn(String? message) { - if (enableLog) { - debugPrint( - 'AppFlowyBoard: 🐛[Warn] - ${DateTime.now().second} => $message'); - } - } - - static void trace(String? message) { - if (enableLog) { - debugPrint( - 'AppFlowyBoard: ❗️[Trace] - ${DateTime.now().second}=> $message'); - } - } - - static void error(String? message) { - debugPrint('AppFlowyBoard: ❌[Error] - ${DateTime.now().second}=> $message'); - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart deleted file mode 100644 index a295b575cd1f2..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart +++ /dev/null @@ -1,406 +0,0 @@ -import 'package:appflowy_board/src/utils/log.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'board_data.dart'; -import 'board_group/group.dart'; -import 'board_group/group_data.dart'; -import 'reorder_flex/drag_state.dart'; -import 'reorder_flex/drag_target_interceptor.dart'; -import 'reorder_flex/reorder_flex.dart'; -import 'reorder_phantom/phantom_controller.dart'; -import '../rendering/board_overlay.dart'; - -class AppFlowyBoardScrollController { - AppFlowyBoardState? _boardState; - - void scrollToBottom(String groupId, - {void Function(BuildContext)? completed}) { - _boardState?.reorderFlexActionMap[groupId]?.scrollToBottom(completed); - } -} - -class AppFlowyBoardConfig { - final double cornerRadius; - final EdgeInsets groupPadding; - final EdgeInsets groupItemPadding; - final EdgeInsets footerPadding; - final EdgeInsets headerPadding; - final EdgeInsets cardPadding; - final Color groupBackgroundColor; - - const AppFlowyBoardConfig({ - this.cornerRadius = 6.0, - this.groupPadding = const EdgeInsets.symmetric(horizontal: 8), - this.groupItemPadding = const EdgeInsets.symmetric(horizontal: 12), - this.footerPadding = const EdgeInsets.symmetric(horizontal: 12), - this.headerPadding = const EdgeInsets.symmetric(horizontal: 16), - this.cardPadding = const EdgeInsets.symmetric(horizontal: 3, vertical: 4), - this.groupBackgroundColor = Colors.transparent, - }); -} - -class AppFlowyBoard extends StatelessWidget { - /// The widget that will be rendered as the background of the board. - final Widget? background; - - /// The [cardBuilder] function which will be invoked on each card build. - /// The [cardBuilder] takes the [BuildContext],[AppFlowyGroupData] and - /// the corresponding [AppFlowyGroupItem]. - /// - /// must return a widget. - final AppFlowyBoardCardBuilder cardBuilder; - - /// The [headerBuilder] function which will be invoked on each group build. - /// The [headerBuilder] takes the [BuildContext] and [AppFlowyGroupData]. - /// - /// must return a widget. - final AppFlowyBoardHeaderBuilder? headerBuilder; - - /// The [footerBuilder] function which will be invoked on each group build. - /// The [footerBuilder] takes the [BuildContext] and [AppFlowyGroupData]. - /// - /// must return a widget. - final AppFlowyBoardFooterBuilder? footerBuilder; - - /// A controller for [AppFlowyBoard] widget. - /// - /// A [AppFlowyBoardController] can be used to provide an initial value of - /// the board by calling `addGroup` method with the passed in parameter - /// [AppFlowyGroupData]. A [AppFlowyGroupData] represents one - /// group data. Whenever the user modifies the board, this controller will - /// update the corresponding group data. - /// - /// Also, you can register the callbacks that receive the changes. Check out - /// the [AppFlowyBoardController] for more information. - /// - final AppFlowyBoardController controller; - - /// A constraints applied to [AppFlowyBoardGroup] widget. - final BoxConstraints groupConstraints; - - /// A controller is used by the [ReorderFlex]. - /// - /// The [ReorderFlex] will used the primary scrollController of the current - /// [BuildContext] by using PrimaryScrollController.of(context). - /// If the primary scrollController is null, we will assign a new [ScrollController]. - final ScrollController? scrollController; - - /// - final AppFlowyBoardConfig config; - - /// A controller is used to control each group scroll actions. - /// - final AppFlowyBoardScrollController? boardScrollController; - - const AppFlowyBoard({ - required this.controller, - required this.cardBuilder, - this.background, - this.footerBuilder, - this.headerBuilder, - this.scrollController, - this.boardScrollController, - this.groupConstraints = const BoxConstraints(maxWidth: 200), - this.config = const AppFlowyBoardConfig(), - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: controller, - child: Consumer( - builder: (context, notifier, child) { - final boardState = AppFlowyBoardState(); - BoardPhantomController phantomController = BoardPhantomController( - delegate: controller, - groupsState: boardState, - ); - - if (boardScrollController != null) { - boardScrollController!._boardState = boardState; - } - - return _AppFlowyBoardContent( - config: config, - dataController: controller, - scrollController: scrollController, - scrollManager: boardScrollController, - boardState: boardState, - background: background, - delegate: phantomController, - groupConstraints: groupConstraints, - cardBuilder: cardBuilder, - footerBuilder: footerBuilder, - headerBuilder: headerBuilder, - phantomController: phantomController, - onReorder: controller.moveGroup, - ); - }, - ), - ); - } -} - -class _AppFlowyBoardContent extends StatefulWidget { - final ScrollController? scrollController; - final OnReorder onReorder; - final AppFlowyBoardController dataController; - final Widget? background; - final AppFlowyBoardConfig config; - final ReorderFlexConfig reorderFlexConfig; - final BoxConstraints groupConstraints; - final AppFlowyBoardScrollController? scrollManager; - final AppFlowyBoardState boardState; - final AppFlowyBoardCardBuilder cardBuilder; - final AppFlowyBoardHeaderBuilder? headerBuilder; - final AppFlowyBoardFooterBuilder? footerBuilder; - final OverlapDragTargetDelegate delegate; - final BoardPhantomController phantomController; - - const _AppFlowyBoardContent({ - required this.config, - required this.onReorder, - required this.delegate, - required this.dataController, - required this.scrollManager, - required this.boardState, - this.scrollController, - this.background, - required this.groupConstraints, - required this.cardBuilder, - this.footerBuilder, - this.headerBuilder, - required this.phantomController, - Key? key, - }) : reorderFlexConfig = const ReorderFlexConfig( - direction: Axis.horizontal, - dragDirection: Axis.horizontal, - ), - super(key: key); - - @override - State<_AppFlowyBoardContent> createState() => _AppFlowyBoardContentState(); -} - -class _AppFlowyBoardContentState extends State<_AppFlowyBoardContent> { - final GlobalKey _boardContentKey = - GlobalKey(debugLabel: '$_AppFlowyBoardContent overlay key'); - late BoardOverlayEntry _overlayEntry; - - @override - void initState() { - _overlayEntry = BoardOverlayEntry( - builder: (BuildContext context) { - final interceptor = OverlappingDragTargetInterceptor( - reorderFlexId: widget.dataController.identifier, - acceptedReorderFlexId: widget.dataController.groupIds, - delegate: widget.delegate, - columnsState: widget.boardState, - ); - - final reorderFlex = ReorderFlex( - config: widget.reorderFlexConfig, - scrollController: widget.scrollController, - onReorder: widget.onReorder, - dataSource: widget.dataController, - interceptor: interceptor, - children: _buildColumns(), - ); - - return Stack( - alignment: AlignmentDirectional.topStart, - children: [ - if (widget.background != null) - Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(widget.config.cornerRadius), - ), - child: widget.background, - ), - reorderFlex, - ], - ); - }, - opaque: false, - ); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BoardOverlay( - key: _boardContentKey, - initialEntries: [_overlayEntry], - ); - } - - List _buildColumns() { - final List children = - widget.dataController.groupDatas.asMap().entries.map( - (item) { - final columnData = item.value; - final columnIndex = item.key; - - final dataSource = _BoardGroupDataSourceImpl( - groupId: columnData.id, - dataController: widget.dataController, - ); - - final reorderFlexAction = ReorderFlexActionImpl(); - widget.boardState.reorderFlexActionMap[columnData.id] = - reorderFlexAction; - - return ChangeNotifierProvider.value( - key: ValueKey(columnData.id), - value: widget.dataController.getGroupController(columnData.id), - child: Consumer( - builder: (context, value, child) { - final boardColumn = AppFlowyBoardGroup( - // key: PageStorageKey(columnData.id), - margin: _marginFromIndex(columnIndex), - itemMargin: widget.config.groupItemPadding, - headerBuilder: _buildHeader, - footerBuilder: widget.footerBuilder, - cardBuilder: widget.cardBuilder, - dataSource: dataSource, - scrollController: ScrollController(), - phantomController: widget.phantomController, - onReorder: widget.dataController.moveGroupItem, - cornerRadius: widget.config.cornerRadius, - backgroundColor: widget.config.groupBackgroundColor, - dragStateStorage: widget.boardState, - dragTargetKeys: widget.boardState, - reorderFlexAction: reorderFlexAction, - ); - - return ConstrainedBox( - constraints: widget.groupConstraints, - child: boardColumn, - ); - }, - ), - ); - }, - ).toList(); - - return children; - } - - Widget? _buildHeader( - BuildContext context, - AppFlowyGroupData groupData, - ) { - if (widget.headerBuilder == null) { - return null; - } - return Selector( - selector: (context, controller) => controller.groupData.headerData, - builder: (context, headerData, _) { - return widget.headerBuilder!(context, groupData)!; - }, - ); - } - - EdgeInsets _marginFromIndex(int index) { - if (widget.dataController.groupDatas.isEmpty) { - return widget.config.groupPadding; - } - - if (index == 0) { - return EdgeInsets.only(right: widget.config.groupPadding.right); - } - - if (index == widget.dataController.groupDatas.length - 1) { - return EdgeInsets.only(left: widget.config.groupPadding.left); - } - - return widget.config.groupPadding; - } -} - -class _BoardGroupDataSourceImpl extends AppFlowyGroupDataDataSource { - String groupId; - final AppFlowyBoardController dataController; - - _BoardGroupDataSourceImpl({ - required this.groupId, - required this.dataController, - }); - - @override - AppFlowyGroupData get groupData => - dataController.getGroupController(groupId)!.groupData; - - @override - List get acceptedGroupIds => dataController.groupIds; -} - -/// A context contains the group states including the draggingState. -/// -/// [draggingState] represents the dragging state of the group. -class AppFlowyGroupContext { - DraggingState? draggingState; -} - -class AppFlowyBoardState extends DraggingStateStorage - with ReorderDragTargeKeys { - final Map groupDragStates = {}; - final Map> groupDragTargetKeys = {}; - - /// Quick access to the [AppFlowyBoardGroup], the [GlobalKey] is bind to the - /// AppFlowyBoardGroup's [ReorderFlex] widget. - final Map reorderFlexActionMap = {}; - - @override - DraggingState? readState(String reorderFlexId) { - return groupDragStates[reorderFlexId]; - } - - @override - void insertState(String reorderFlexId, DraggingState state) { - Log.trace('$reorderFlexId Write dragging state: $state'); - groupDragStates[reorderFlexId] = state; - } - - @override - void removeState(String reorderFlexId) { - groupDragStates.remove(reorderFlexId); - } - - @override - void insertDragTarget( - String reorderFlexId, - String key, - GlobalObjectKey> value, - ) { - Map? group = groupDragTargetKeys[reorderFlexId]; - if (group == null) { - group = {}; - groupDragTargetKeys[reorderFlexId] = group; - } - group[key] = value; - } - - @override - GlobalObjectKey>? getDragTarget( - String reorderFlexId, - String key, - ) { - Map? group = groupDragTargetKeys[reorderFlexId]; - if (group != null) { - return group[key]; - } else { - return null; - } - } - - @override - void removeDragTarget(String reorderFlexId) { - groupDragTargetKeys.remove(reorderFlexId); - } -} - -class ReorderFlexActionImpl extends ReorderFlexAction {} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart deleted file mode 100644 index 43dd9728fc63f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart +++ /dev/null @@ -1,304 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_board/src/widgets/board_group/group_data.dart'; -import 'package:equatable/equatable.dart'; - -import '../utils/log.dart'; -import 'reorder_flex/reorder_flex.dart'; -import 'package:flutter/material.dart'; -import 'reorder_phantom/phantom_controller.dart'; - -typedef OnMoveGroup = void Function( - String fromGroupId, - int fromIndex, - String toGroupId, - int toIndex, -); - -typedef OnMoveGroupItem = void Function( - String groupId, - int fromIndex, - int toIndex, -); - -typedef OnMoveGroupItemToGroup = void Function( - String fromGroupId, - int fromIndex, - String toGroupId, - int toIndex, -); - -/// A controller for [AppFlowyBoard] widget. -/// -/// A [AppFlowyBoardController] can be used to provide an initial value of -/// the board by calling `addGroup` method with the passed in parameter -/// [AppFlowyGroupData]. A [AppFlowyGroupData] represents one -/// group data. Whenever the user modifies the board, this controller will -/// update the corresponding group data. -/// -/// Also, you can register the callbacks that receive the changes. -/// [onMoveGroup] will get called when moving the group from one position to -/// another. -/// -/// [onMoveGroupItem] will get called when moving the group's items. -/// -/// [onMoveGroupItemToGroup] will get called when moving the group's item from -/// one group to another group. -class AppFlowyBoardController extends ChangeNotifier - with EquatableMixin, BoardPhantomControllerDelegate, ReoderFlexDataSource { - final List _groupDatas = []; - - /// [onMoveGroup] will get called when moving the group from one position to - /// another. - final OnMoveGroup? onMoveGroup; - - /// [onMoveGroupItem] will get called when moving the group's items. - final OnMoveGroupItem? onMoveGroupItem; - - /// [onMoveGroupItemToGroup] will get called when moving the group's item from - /// one group to another group. - final OnMoveGroupItemToGroup? onMoveGroupItemToGroup; - - /// Returns the unmodifiable list of [AppFlowyGroupData] - UnmodifiableListView get groupDatas => - UnmodifiableListView(_groupDatas); - - /// Returns list of group id - List get groupIds => - _groupDatas.map((groupData) => groupData.id).toList(); - - final LinkedHashMap _groupControllers = - LinkedHashMap(); - - AppFlowyBoardController({ - this.onMoveGroup, - this.onMoveGroupItem, - this.onMoveGroupItemToGroup, - }); - - /// Adds a new group to the end of the current group list. - /// - /// If you don't want to notify the listener after adding a new group, the - /// [notify] should set to false. Default value is true. - void addGroup(AppFlowyGroupData groupData, {bool notify = true}) { - if (_groupControllers[groupData.id] != null) return; - - final controller = AppFlowyGroupController(groupData: groupData); - _groupDatas.add(groupData); - _groupControllers[groupData.id] = controller; - if (notify) notifyListeners(); - } - - /// Adds a list of groups to the end of the current group list. - /// - /// If you don't want to notify the listener after adding the groups, the - /// [notify] should set to false. Default value is true. - void addGroups(List groups, {bool notify = true}) { - for (final column in groups) { - addGroup(column, notify: false); - } - - if (groups.isNotEmpty && notify) notifyListeners(); - } - - /// Removes the group with id [groupId] - /// - /// If you don't want to notify the listener after removing the group, the - /// [notify] should set to false. Default value is true. - void removeGroup(String groupId, {bool notify = true}) { - final index = _groupDatas.indexWhere((group) => group.id == groupId); - if (index == -1) { - Log.warn( - 'Try to remove Group:[$groupId] failed. Group:[$groupId] not exist'); - } - - if (index != -1) { - _groupDatas.removeAt(index); - _groupControllers.remove(groupId); - - if (notify) notifyListeners(); - } - } - - /// Removes a list of groups - /// - /// If you don't want to notify the listener after removing the groups, the - /// [notify] should set to false. Default value is true. - void removeGroups(List groupIds, {bool notify = true}) { - for (final groupId in groupIds) { - removeGroup(groupId, notify: false); - } - - if (groupIds.isNotEmpty && notify) notifyListeners(); - } - - /// Remove all the groups controller. - /// - /// This method should get called when you want to remove all the current - /// groups or get ready to reinitialize the [AppFlowyBoard]. - void clear() { - _groupDatas.clear(); - for (final group in _groupControllers.values) { - group.dispose(); - } - _groupControllers.clear(); - - notifyListeners(); - } - - /// Returns the [AppFlowyGroupController] with id [groupId]. - AppFlowyGroupController? getGroupController(String groupId) { - final groupController = _groupControllers[groupId]; - if (groupController == null) { - Log.warn('Group:[$groupId] \'s controller is not exist'); - } - - return groupController; - } - - /// Moves the group controller from [fromIndex] to [toIndex] and notify the - /// listeners. - /// - /// If you don't want to notify the listener after moving the group, the - /// [notify] should set to false. Default value is true. - void moveGroup(int fromIndex, int toIndex, {bool notify = true}) { - final toGroupData = _groupDatas[toIndex]; - final fromGroupData = _groupDatas.removeAt(fromIndex); - - _groupDatas.insert(toIndex, fromGroupData); - onMoveGroup?.call(fromGroupData.id, fromIndex, toGroupData.id, toIndex); - if (notify) notifyListeners(); - } - - /// Moves the group's item from [fromIndex] to [toIndex] - /// If the group with id [groupId] is not exist, this method will do nothing. - void moveGroupItem(String groupId, int fromIndex, int toIndex) { - if (getGroupController(groupId)?.move(fromIndex, toIndex) ?? false) { - onMoveGroupItem?.call(groupId, fromIndex, toIndex); - } - } - - /// Adds the [AppFlowyGroupItem] to the end of the group - /// - /// If the group with id [groupId] is not exist, this method will do nothing. - void addGroupItem(String groupId, AppFlowyGroupItem item) { - getGroupController(groupId)?.add(item); - } - - /// Inserts the [AppFlowyGroupItem] at [index] in the group - /// - /// It will do nothing if the group with id [groupId] is not exist - void insertGroupItem(String groupId, int index, AppFlowyGroupItem item) { - getGroupController(groupId)?.insert(index, item); - } - - /// Removes the item with id [itemId] from the group - /// - /// It will do nothing if the group with id [groupId] is not exist - void removeGroupItem(String groupId, String itemId) { - getGroupController(groupId)?.removeWhere((item) => item.id == itemId); - } - - /// Replaces or inserts the [AppFlowyGroupItem] to the end of the group. - /// - /// If the group with id [groupId] is not exist, this method will do nothing. - void updateGroupItem(String groupId, AppFlowyGroupItem item) { - getGroupController(groupId)?.replaceOrInsertItem(item); - } - - void enableGroupDragging(bool isEnable) { - for (var groupController in _groupControllers.values) { - groupController.enableDragging(isEnable); - } - - notifyListeners(); - } - - /// Moves the item at [fromGroupIndex] in group with id [fromGroupId] to - /// group with id [toGroupId] at [toGroupIndex] - @override - @protected - void moveGroupItemToAnotherGroup( - String fromGroupId, - int fromGroupIndex, - String toGroupId, - int toGroupIndex, - ) { - final fromGroupController = getGroupController(fromGroupId)!; - final toGroupController = getGroupController(toGroupId)!; - final fromGroupItem = fromGroupController.removeAt(fromGroupIndex); - if (fromGroupItem == null) return; - - if (toGroupController.items.length > toGroupIndex) { - assert(toGroupController.items[toGroupIndex] is PhantomGroupItem); - - toGroupController.replace(toGroupIndex, fromGroupItem); - onMoveGroupItemToGroup?.call( - fromGroupId, - fromGroupIndex, - toGroupId, - toGroupIndex, - ); - } - } - - @override - List get props { - return [_groupDatas]; - } - - @override - AppFlowyGroupController? controller(String groupId) { - return _groupControllers[groupId]; - } - - @override - String get identifier => '$AppFlowyBoardController'; - - @override - UnmodifiableListView get items => - UnmodifiableListView(_groupDatas); - - @override - @protected - bool removePhantom(String groupId) { - final groupController = getGroupController(groupId); - if (groupController == null) { - Log.warn('Can not find the group controller with groupId: $groupId'); - return false; - } - final index = groupController.items.indexWhere((item) => item.isPhantom); - final isExist = index != -1; - if (isExist) { - groupController.removeAt(index); - - Log.debug( - '[$AppFlowyBoardController] Group:[$groupId] remove phantom, current count: ${groupController.items.length}'); - } - return isExist; - } - - @override - @protected - void updatePhantom(String groupId, int newIndex) { - final groupController = getGroupController(groupId)!; - final index = groupController.items.indexWhere((item) => item.isPhantom); - - if (index != -1) { - if (index != newIndex) { - Log.trace( - '[$BoardPhantomController] update $groupId:$index to $groupId:$newIndex'); - final item = groupController.removeAt(index, notify: false); - if (item != null) { - groupController.insert(newIndex, item, notify: false); - } - } - } - } - - @override - @protected - void insertPhantom(String groupId, int index, PhantomGroupItem item) { - getGroupController(groupId)!.insert(index, item); - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group.dart deleted file mode 100644 index b0c69b1070b4e..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_board/src/widgets/reorder_flex/drag_state.dart'; -import 'package:flutter/material.dart'; -import '../../rendering/board_overlay.dart'; -import '../../utils/log.dart'; -import '../reorder_phantom/phantom_controller.dart'; -import '../reorder_flex/reorder_flex.dart'; -import '../reorder_flex/drag_target_interceptor.dart'; -import 'group_data.dart'; - -typedef OnGroupDragStarted = void Function(int index); - -typedef OnGroupDragEnded = void Function(String groupId); - -typedef OnGroupReorder = void Function( - String groupId, - int fromIndex, - int toIndex, -); - -typedef OnGroupDeleted = void Function(String groupId, int deletedIndex); - -typedef OnGroupInserted = void Function(String groupId, int insertedIndex); - -typedef AppFlowyBoardCardBuilder = Widget Function( - BuildContext context, - AppFlowyGroupData groupData, - AppFlowyGroupItem item, -); - -typedef AppFlowyBoardHeaderBuilder = Widget? Function( - BuildContext context, - AppFlowyGroupData groupData, -); - -typedef AppFlowyBoardFooterBuilder = Widget Function( - BuildContext context, - AppFlowyGroupData groupData, -); - -abstract class AppFlowyGroupDataDataSource extends ReoderFlexDataSource { - AppFlowyGroupData get groupData; - - List get acceptedGroupIds; - - @override - String get identifier => groupData.id; - - @override - UnmodifiableListView get items => groupData.items; - - void debugPrint() { - String msg = '[$AppFlowyGroupDataDataSource] $groupData data: '; - for (var element in items) { - msg = '$msg$element,'; - } - - Log.debug(msg); - } -} - -/// A [AppFlowyBoardGroup] represents the group UI of the Board. -/// -class AppFlowyBoardGroup extends StatefulWidget { - final AppFlowyGroupDataDataSource dataSource; - final ScrollController? scrollController; - final ReorderFlexConfig config; - final OnGroupDragStarted? onDragStarted; - final OnGroupReorder onReorder; - final OnGroupDragEnded? onDragEnded; - - final BoardPhantomController phantomController; - - String get groupId => dataSource.groupData.id; - - final AppFlowyBoardCardBuilder cardBuilder; - - final AppFlowyBoardHeaderBuilder? headerBuilder; - - final AppFlowyBoardFooterBuilder? footerBuilder; - - final EdgeInsets margin; - - final EdgeInsets itemMargin; - - final double cornerRadius; - - final Color backgroundColor; - - final DraggingStateStorage? dragStateStorage; - - final ReorderDragTargeKeys? dragTargetKeys; - - final ReorderFlexAction? reorderFlexAction; - - const AppFlowyBoardGroup({ - Key? key, - this.headerBuilder, - this.footerBuilder, - required this.cardBuilder, - required this.onReorder, - required this.dataSource, - required this.phantomController, - this.reorderFlexAction, - this.dragStateStorage, - this.dragTargetKeys, - this.scrollController, - this.onDragStarted, - this.onDragEnded, - this.margin = EdgeInsets.zero, - this.itemMargin = EdgeInsets.zero, - this.cornerRadius = 0.0, - this.backgroundColor = Colors.transparent, - }) : config = const ReorderFlexConfig(), - super(key: key); - - @override - State createState() => _AppFlowyBoardGroupState(); -} - -class _AppFlowyBoardGroupState extends State { - final GlobalKey _columnOverlayKey = - GlobalKey(debugLabel: '$AppFlowyBoardGroup overlay key'); - late BoardOverlayEntry _overlayEntry; - - @override - void initState() { - _overlayEntry = BoardOverlayEntry( - builder: (BuildContext context) { - final children = widget.dataSource.groupData.items - .map((item) => _buildWidget(context, item)) - .toList(); - - final header = - widget.headerBuilder?.call(context, widget.dataSource.groupData); - - final footer = - widget.footerBuilder?.call(context, widget.dataSource.groupData); - - final interceptor = CrossReorderFlexDragTargetInterceptor( - reorderFlexId: widget.groupId, - delegate: widget.phantomController, - acceptedReorderFlexIds: widget.dataSource.acceptedGroupIds, - draggableTargetBuilder: PhantomDraggableBuilder(), - ); - - Widget reorderFlex = ReorderFlex( - key: ValueKey(widget.groupId), - dragStateStorage: widget.dragStateStorage, - dragTargetKeys: widget.dragTargetKeys, - scrollController: widget.scrollController, - config: widget.config, - onDragStarted: (index) { - widget.phantomController.groupStartDragging(widget.groupId); - widget.onDragStarted?.call(index); - }, - onReorder: ((fromIndex, toIndex) { - if (widget.phantomController.shouldReorder(widget.groupId)) { - widget.onReorder(widget.groupId, fromIndex, toIndex); - widget.phantomController.updateIndex(fromIndex, toIndex); - } - }), - onDragEnded: () { - widget.phantomController.groupEndDragging(widget.groupId); - widget.onDragEnded?.call(widget.groupId); - widget.dataSource.debugPrint(); - }, - dataSource: widget.dataSource, - interceptor: interceptor, - reorderFlexAction: widget.reorderFlexAction, - children: children, - ); - - reorderFlex = Expanded( - child: Padding(padding: widget.itemMargin, child: reorderFlex), - ); - - return Container( - margin: widget.margin, - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - color: widget.backgroundColor, - borderRadius: BorderRadius.circular(widget.cornerRadius), - ), - child: Column( - children: [ - if (header != null) header, - reorderFlex, - if (footer != null) footer, - ], - ), - ); - }, - opaque: false, - ); - super.initState(); - } - - @override - Widget build(BuildContext context) { - return BoardOverlay( - key: _columnOverlayKey, - initialEntries: [_overlayEntry], - ); - } - - Widget _buildWidget(BuildContext context, AppFlowyGroupItem item) { - if (item is PhantomGroupItem) { - return PassthroughPhantomWidget( - key: UniqueKey(), - opacity: widget.config.draggingWidgetOpacity, - passthroughPhantomContext: item.phantomContext, - ); - } else { - return widget.cardBuilder(context, widget.dataSource.groupData, item); - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group_data.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group_data.dart deleted file mode 100644 index 659e87fadde8f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_group/group_data.dart +++ /dev/null @@ -1,225 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_board/src/utils/log.dart'; -import 'package:appflowy_board/src/widgets/reorder_flex/reorder_flex.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter/material.dart'; - -typedef IsDraggable = bool; - -/// A item represents the generic data model of each group card. -/// -/// Each item displayed in the group required to implement this class. -abstract class AppFlowyGroupItem extends ReoderFlexItem { - bool get isPhantom => false; - - @override - String toString() => id; -} - -/// [AppFlowyGroupController] is used to handle the [AppFlowyGroupData]. -/// -/// * Remove an item by calling [removeAt] method. -/// * Move item to another position by calling [move] method. -/// * Insert item to index by calling [insert] method -/// * Replace item at index by calling [replace] method. -/// -/// All there operations will notify listeners by default. -/// -class AppFlowyGroupController extends ChangeNotifier with EquatableMixin { - final AppFlowyGroupData groupData; - - AppFlowyGroupController({ - required this.groupData, - }); - - @override - List get props => groupData.props; - - /// Returns the readonly List - UnmodifiableListView get items => - UnmodifiableListView(groupData.items); - - void updateGroupName(String newName) { - if (groupData.headerData.groupName != newName) { - groupData.headerData.groupName = newName; - _notify(); - } - } - - /// Remove the item at [index]. - /// * [index] the index of the item you want to remove - /// * [notify] the default value of [notify] is true, it will notify the - /// listener. Set to false if you do not want to notify the listeners. - /// - AppFlowyGroupItem? removeAt(int index, {bool notify = true}) { - if (groupData._items.length <= index) { - Log.error( - 'Fatal error, index is out of bounds. Index: $index, len: ${groupData._items.length}'); - return null; - } - - if (index < 0) { - Log.error('Invalid index:$index'); - return null; - } - - Log.debug('[$AppFlowyGroupController] $groupData remove item at $index'); - final item = groupData._items.removeAt(index); - if (notify) { - _notify(); - } - return item; - } - - void removeWhere(bool Function(AppFlowyGroupItem) condition) { - final index = items.indexWhere(condition); - if (index != -1) { - removeAt(index); - } - } - - /// Move the item from [fromIndex] to [toIndex]. It will do nothing if the - /// [fromIndex] equal to the [toIndex]. - bool move(int fromIndex, int toIndex) { - assert(toIndex >= 0); - if (groupData._items.length < fromIndex) { - Log.error( - 'Out of bounds error. index: $fromIndex should not greater than ${groupData._items.length}'); - return false; - } - - if (fromIndex == toIndex) { - return false; - } - - Log.debug( - '[$AppFlowyGroupController] $groupData move item from $fromIndex to $toIndex'); - final item = groupData._items.removeAt(fromIndex); - groupData._items.insert(toIndex, item); - _notify(); - return true; - } - - /// Insert an item to [index] and notify the listen if the value of [notify] - /// is true. - /// - /// The default value of [notify] is true. - bool insert(int index, AppFlowyGroupItem item, {bool notify = true}) { - assert(index >= 0); - Log.debug('[$AppFlowyGroupController] $groupData insert $item at $index'); - - if (_containsItem(item)) { - return false; - } else { - if (groupData._items.length > index) { - groupData._items.insert(index, item); - } else { - groupData._items.add(item); - } - - if (notify) _notify(); - return true; - } - } - - bool add(AppFlowyGroupItem item, {bool notify = true}) { - if (_containsItem(item)) { - return false; - } else { - groupData._items.add(item); - if (notify) _notify(); - return true; - } - } - - /// Replace the item at index with the [newItem]. - void replace(int index, AppFlowyGroupItem newItem) { - if (groupData._items.isEmpty) { - groupData._items.add(newItem); - Log.debug('[$AppFlowyGroupController] $groupData add $newItem'); - } else { - if (index >= groupData._items.length) { - Log.error( - '[$AppFlowyGroupController] unexpected items length, index should less than the count of the items. Index: $index, items count: ${items.length}'); - return; - } - - final removedItem = groupData._items.removeAt(index); - groupData._items.insert(index, newItem); - Log.debug( - '[$AppFlowyGroupController] $groupData replace $removedItem with $newItem at $index'); - } - - _notify(); - } - - void replaceOrInsertItem(AppFlowyGroupItem newItem) { - final index = groupData._items.indexWhere((item) => item.id == newItem.id); - if (index != -1) { - groupData._items.removeAt(index); - groupData._items.insert(index, newItem); - _notify(); - } else { - groupData._items.add(newItem); - _notify(); - } - } - - bool _containsItem(AppFlowyGroupItem item) { - return groupData._items.indexWhere((element) => element.id == item.id) != - -1; - } - - void enableDragging(bool isEnable) { - groupData.draggable = isEnable; - - for (var item in groupData._items) { - item.draggable = isEnable; - } - _notify(); - } - - void _notify() { - notifyListeners(); - } -} - -/// [AppFlowyGroupData] represents the data of each group of the Board. -class AppFlowyGroupData extends ReoderFlexItem with EquatableMixin { - @override - final String id; - AppFlowyGroupHeaderData headerData; - final List _items; - final CustomData? customData; - - AppFlowyGroupData({ - this.customData, - required this.id, - required String name, - List items = const [], - }) : _items = items, - headerData = AppFlowyGroupHeaderData( - groupId: id, - groupName: name, - ); - - /// Returns the readonly List - UnmodifiableListView get items => - UnmodifiableListView([..._items]); - - @override - List get props => [id, ..._items]; - - @override - String toString() { - return 'Group:[$id]'; - } -} - -class AppFlowyGroupHeaderData { - String groupId; - String groupName; - - AppFlowyGroupHeaderData({required this.groupId, required this.groupName}); -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart deleted file mode 100644 index d24c99bfbdbe7..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart +++ /dev/null @@ -1,214 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../utils/log.dart'; -import 'drag_target.dart'; -import 'reorder_flex.dart'; - -/// [FlexDragTargetData] is used to store the custom dragging data. -/// -/// * [draggingIndex] the index of the dragTarget that is being dragged. -/// * [draggingWidget] the widget of the dragTarget that is being dragged. -/// * [reorderFlexId] the id of the [ReorderFlex] -/// * [reorderFlexItem] the item of the [ReorderFlex] -/// -class FlexDragTargetData extends DragTargetData { - /// The index of the dragging target in the boardList. - @override - final int draggingIndex; - - final DraggingState _draggingState; - - Widget? get draggingWidget => _draggingState.draggingWidget; - - Size? get feedbackSize => _draggingState.feedbackSize; - - bool get isDragging => _draggingState.isDragging(); - - final String dragTargetId; - - Offset dragTargetOffset = Offset.zero; - - final GlobalObjectKey dragTargetIndexKey; - - final String reorderFlexId; - - final ReoderFlexItem reorderFlexItem; - - FlexDragTargetData({ - required this.dragTargetId, - required this.draggingIndex, - required this.reorderFlexId, - required this.reorderFlexItem, - required this.dragTargetIndexKey, - required DraggingState draggingState, - }) : _draggingState = draggingState; - - @override - String toString() { - return 'ReorderFlexId: $reorderFlexId, dragTargetId: $dragTargetId'; - } - - bool isOverlapWithWidgets(List widgetKeys) { - final renderBox = dragTargetIndexKey.currentContext?.findRenderObject(); - if (renderBox == null) return false; - if (renderBox is! RenderBox) return false; - final size = feedbackSize ?? Size.zero; - - final Rect dragTargetRect = renderBox.localToGlobal(Offset.zero) & size; - for (final widgetKey in widgetKeys) { - final renderObject = widgetKey.currentContext?.findRenderObject(); - if (renderObject != null && renderObject is RenderBox) { - Rect widgetRect = - renderObject.localToGlobal(Offset.zero) & renderObject.size; - return dragTargetRect.overlaps(widgetRect); - } - } - - return false; - } -} - -abstract class DraggingStateStorage { - void insertState(String reorderFlexId, DraggingState state); - void removeState(String reorderFlexId); - DraggingState? readState(String reorderFlexId); -} - -class DraggingState { - final String reorderFlexId; - - /// The member of widget.children currently being dragged. - Widget? _draggingWidget; - - Widget? get draggingWidget => _draggingWidget; - - /// The last computed size of the feedback widget being dragged. - Size? feedbackSize = Size.zero; - - /// The location that the dragging widget occupied before it started to drag. - int dragStartIndex = -1; - - /// The index that the dragging widget most recently left. - /// This is used to show an animation of the widget's position. - int phantomIndex = -1; - - /// The index that the dragging widget currently occupies. - int currentIndex = -1; - - /// The widget to move the dragging widget too after the current index. - int nextIndex = -1; - - /// Whether or not we are currently scrolling this view to show a widget. - bool scrolling = false; - - /// The additional margin to place around a computed drop area. - static const double _dropAreaMargin = 0.0; - - DraggingState(this.reorderFlexId); - - Size get dropAreaSize { - if (feedbackSize == null) { - return Size.zero; - } - return feedbackSize! + const Offset(_dropAreaMargin, _dropAreaMargin); - } - - void startDragging( - Widget draggingWidget, - int draggingWidgetIndex, - Size? draggingWidgetSize, - ) { - /// - assert(draggingWidgetIndex >= 0); - - _draggingWidget = draggingWidget; - phantomIndex = draggingWidgetIndex; - dragStartIndex = draggingWidgetIndex; - currentIndex = draggingWidgetIndex; - feedbackSize = draggingWidgetSize; - } - - void endDragging() { - dragStartIndex = -1; - phantomIndex = -1; - currentIndex = -1; - nextIndex = -1; - _draggingWidget = null; - } - - /// When the phantomIndex and currentIndex are the same, it means the dragging - /// widget did move to the destination location. - void removePhantom() { - phantomIndex = currentIndex; - } - - /// The dragging widget overlaps with the phantom widget. - bool isOverlapWithPhantom() { - return currentIndex != phantomIndex; - } - - bool isPhantomAboveDragTarget() { - return currentIndex > phantomIndex; - } - - bool isPhantomBelowDragTarget() { - return currentIndex < phantomIndex; - } - - bool didDragTargetMoveToNext() { - return currentIndex == nextIndex; - } - - /// Set the currentIndex to nextIndex - void moveDragTargetToNext() { - Log.debug('$reorderFlexId updateCurrentIndex: $nextIndex'); - currentIndex = nextIndex; - } - - void updateNextIndex(int index) { - Log.debug('$reorderFlexId updateNextIndex: $index'); - nextIndex = index; - } - - void setStartDraggingIndex(int index) { - Log.debug('$reorderFlexId setDragIndex: $index'); - dragStartIndex = index; - phantomIndex = index; - currentIndex = index; - nextIndex = index; - } - - bool isNotDragging() { - return dragStartIndex == -1; - } - - bool isDragging() { - return !isNotDragging(); - } - - /// When the _dragStartIndex less than the _currentIndex, it means the - /// dragTarget is going down to the end of the list. - bool isDragTargetMovingDown() { - return dragStartIndex < currentIndex; - } - - /// The index represents the widget original index of the list. - int calculateShiftedIndex(int index) { - int shiftedIndex = index; - if (index == dragStartIndex) { - shiftedIndex = phantomIndex; - } else if (index > dragStartIndex && index <= phantomIndex) { - /// phantom move up - shiftedIndex--; - } else if (index < dragStartIndex && index >= phantomIndex) { - /// phantom move down - shiftedIndex++; - } - return shiftedIndex; - } - - @override - String toString() { - return 'DragStartIndex: $dragStartIndex, PhantomIndex: $phantomIndex, CurrentIndex: $currentIndex, NextIndex: $nextIndex'; - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart deleted file mode 100644 index 7482eb36e0504..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart +++ /dev/null @@ -1,550 +0,0 @@ -import 'package:appflowy_board/appflowy_board.dart'; -import 'package:appflowy_board/src/utils/log.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:provider/provider.dart'; -import '../transitions.dart'; - -abstract class DragTargetData { - int get draggingIndex; -} - -abstract class ReorderFlexDraggableTargetBuilder { - Widget? build( - BuildContext context, - Widget child, - DragTargetOnStarted onDragStarted, - DragTargetOnEnded onDragEnded, - DragTargetWillAccepted onWillAccept, - AnimationController insertAnimationController, - AnimationController deleteAnimationController, - ); -} - -/// -typedef DragTargetWillAccepted = bool Function( - T dragTargetData); - -/// -typedef DragTargetOnStarted = void Function(Widget, int, Size?); - -typedef DragTargetOnMove = void Function( - T dragTargetData, - Offset offset, -); - -/// -typedef DragTargetOnEnded = void Function( - T dragTargetData); - -/// [ReorderDragTarget] is a [DragTarget] that carries the index information of -/// the child. You could check out this link for more information. -/// -/// The size of the [ReorderDragTarget] will become zero when it start dragging. -/// -class ReorderDragTarget extends StatefulWidget { - final Widget child; - final T dragTargetData; - - final GlobalObjectKey indexGlobalKey; - - /// Called when dragTarget is being dragging. - final DragTargetOnStarted onDragStarted; - - final DragTargetOnEnded onDragEnded; - - final DragTargetOnMove onDragMoved; - - /// Called to determine whether this widget is interested in receiving a given - /// piece of data being dragged over this drag target. - /// - /// [toAccept] represents the dragTarget index, which is the value passed in - /// when creating the [ReorderDragTarget]. - final DragTargetWillAccepted onWillAccept; - - /// Called when an acceptable piece of data was dropped over this drag target. - /// - /// Equivalent to [onAcceptWithDetails], but only includes the data. - final void Function(T dragTargetData)? onAccept; - - /// Called when a given piece of data being dragged over this target leaves - /// the target. - final void Function(T dragTargetData)? onLeave; - - final ReorderFlexDraggableTargetBuilder? draggableTargetBuilder; - - final AnimationController insertAnimationController; - - final AnimationController deleteAnimationController; - - final bool useMoveAnimation; - - final IsDraggable draggable; - - final double draggingOpacity; - - final Axis? dragDirection; - - const ReorderDragTarget({ - Key? key, - required this.child, - required this.indexGlobalKey, - required this.dragTargetData, - required this.onDragStarted, - required this.onDragMoved, - required this.onDragEnded, - required this.onWillAccept, - required this.insertAnimationController, - required this.deleteAnimationController, - required this.useMoveAnimation, - required this.draggable, - this.onAccept, - this.onLeave, - this.draggableTargetBuilder, - this.draggingOpacity = 0.3, - this.dragDirection, - }) : super(key: key); - - @override - State> createState() => _ReorderDragTargetState(); -} - -class _ReorderDragTargetState - extends State> { - /// Returns the dragTarget's size - Size? _draggingFeedbackSize = Size.zero; - - @override - Widget build(BuildContext context) { - Widget dragTarget = DragTarget( - builder: _buildDraggableWidget, - onWillAccept: (dragTargetData) { - if (dragTargetData == null) { - return false; - } - - return widget.onWillAccept(dragTargetData); - }, - onAccept: widget.onAccept, - onMove: (detail) { - widget.onDragMoved(detail.data, detail.offset); - }, - onLeave: (dragTargetData) { - assert(dragTargetData != null); - if (dragTargetData != null) { - widget.onLeave?.call(dragTargetData); - } - }, - ); - - dragTarget = KeyedSubtree(key: widget.indexGlobalKey, child: dragTarget); - return dragTarget; - } - - Widget _buildDraggableWidget( - BuildContext context, - List acceptedCandidates, - List rejectedCandidates, - ) { - Widget feedbackBuilder = Builder(builder: (BuildContext context) { - BoxConstraints contentSizeConstraints = - BoxConstraints.loose(_draggingFeedbackSize!); - return _buildDraggableFeedback( - context, - contentSizeConstraints, - widget.child, - ); - }); - - final draggableWidget = widget.draggableTargetBuilder?.build( - context, - widget.child, - widget.onDragStarted, - widget.onDragEnded, - widget.onWillAccept, - widget.insertAnimationController, - widget.deleteAnimationController, - ) ?? - Draggable( - axis: widget.dragDirection, - maxSimultaneousDrags: widget.draggable ? 1 : 0, - data: widget.dragTargetData, - ignoringFeedbackSemantics: false, - feedback: feedbackBuilder, - childWhenDragging: IgnorePointerWidget( - useIntrinsicSize: !widget.useMoveAnimation, - opacity: widget.draggingOpacity, - child: widget.child, - ), - onDragStarted: () { - _draggingFeedbackSize = widget.indexGlobalKey.currentContext?.size; - widget.onDragStarted( - widget.child, - widget.dragTargetData.draggingIndex, - _draggingFeedbackSize, - ); - }, - dragAnchorStrategy: childDragAnchorStrategy, - - /// When the drag ends inside a DragTarget widget, the drag - /// succeeds, and we reorder the widget into position appropriately. - onDragCompleted: () { - widget.onDragEnded(widget.dragTargetData); - }, - - /// When the drag does not end inside a DragTarget widget, the - /// drag fails, but we still reorder the widget to the last position it - /// had been dragged to. - onDraggableCanceled: (Velocity velocity, Offset offset) { - widget.onDragEnded(widget.dragTargetData); - }, - child: widget.child, - ); - - return draggableWidget; - } - - Widget _buildDraggableFeedback( - BuildContext context, - BoxConstraints constraints, - Widget child, - ) { - return Transform( - transform: Matrix4.rotationZ(0), - alignment: FractionalOffset.topLeft, - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.zero, - clipBehavior: Clip.hardEdge, - child: ConstrainedBox( - constraints: constraints, - child: Opacity(opacity: widget.draggingOpacity, child: child), - ), - ), - ); - } -} - -class DragTargetAnimation { - // How long an animation to reorder an element in the list takes. - final Duration reorderAnimationDuration; - - // This controls the entrance of the dragging widget into a new place. - late AnimationController entranceController; - - // This controls the 'phantom' of the dragging widget, which is left behind - // where the widget used to be. - late AnimationController phantomController; - - // Uses to simulate the insert animation when card was moved from on group to - // another group. Check out the [FakeDragTarget]. - late AnimationController insertController; - - // Used to remove the phantom - late AnimationController deleteController; - - DragTargetAnimation({ - required this.reorderAnimationDuration, - required TickerProvider vsync, - required void Function(AnimationStatus) entranceAnimateStatusChanged, - }) { - entranceController = AnimationController( - value: 1.0, vsync: vsync, duration: reorderAnimationDuration); - entranceController.addStatusListener(entranceAnimateStatusChanged); - - phantomController = AnimationController( - value: 0, vsync: vsync, duration: reorderAnimationDuration); - - insertController = AnimationController( - value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 100)); - - deleteController = AnimationController( - value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 1)); - } - - void startDragging() { - entranceController.value = 1.0; - } - - void animateToNext() { - phantomController.reverse(from: 1.0); - entranceController.forward(from: 0.0); - } - - void reverseAnimation() { - phantomController.reverse(from: 0.1); - entranceController.reverse(from: 0.0); - } - - void dispose() { - entranceController.dispose(); - phantomController.dispose(); - insertController.dispose(); - deleteController.dispose(); - } -} - -class IgnorePointerWidget extends StatelessWidget { - final Widget? child; - final bool useIntrinsicSize; - final double opacity; - - const IgnorePointerWidget({ - required this.child, - required this.opacity, - this.useIntrinsicSize = false, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final sizedChild = useIntrinsicSize - ? child - : SizedBox(width: 0.0, height: 0.0, child: child); - - return IgnorePointer( - ignoring: true, - child: Opacity( - opacity: useIntrinsicSize ? opacity : 0.0, - child: sizedChild, - ), - ); - } -} - -class AbsorbPointerWidget extends StatelessWidget { - final Widget? child; - final bool useIntrinsicSize; - final double opacity; - const AbsorbPointerWidget({ - required this.child, - required this.opacity, - this.useIntrinsicSize = false, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final sizedChild = useIntrinsicSize - ? child - : SizedBox(width: 0.0, height: 0.0, child: child); - - return AbsorbPointer( - child: Opacity( - opacity: useIntrinsicSize ? opacity : 0.0, - child: sizedChild, - ), - ); - } -} - -class PhantomWidget extends StatelessWidget { - final Widget? child; - final double opacity; - const PhantomWidget({ - this.child, - this.opacity = 1.0, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Opacity( - opacity: opacity, - child: child, - ); - } -} - -abstract class DragTargetMovePlaceholderDelegate { - void registerPlaceholder( - int dragTargetIndex, - void Function(int currentDragTargetIndex) callback, - ); - - void unregisterPlaceholder(int dragTargetIndex); -} - -class DragTargeMovePlaceholder extends StatefulWidget { - final double height; - final Color color; - final Color highlightColor; - final int dragTargetIndex; - final DragTargetMovePlaceholderDelegate delegate; - - const DragTargeMovePlaceholder({ - required this.delegate, - required this.dragTargetIndex, - this.height = 4, - this.color = Colors.transparent, - this.highlightColor = Colors.lightBlue, - Key? key, - }) : super(key: key); - - @override - State createState() => - _DragTargeMovePlaceholderState(); -} - -class _DragTargeMovePlaceholderState extends State { - ValueNotifier isHighlight = ValueNotifier(false); - - @override - void initState() { - widget.delegate.registerPlaceholder( - widget.dragTargetIndex, - (currentDragTargetIndex) { - if (!mounted) return; - - SchedulerBinding.instance.addPostFrameCallback((Duration duration) { - if (currentDragTargetIndex == -1) { - isHighlight.value = false; - } else { - isHighlight.value = - widget.dragTargetIndex == currentDragTargetIndex; - } - }); - }, - ); - super.initState(); - } - - @override - void dispose() { - isHighlight.dispose(); - widget.delegate.unregisterPlaceholder(widget.dragTargetIndex); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: isHighlight, - child: Consumer>( - builder: (context, notifier, child) { - return Container( - height: widget.height, - color: notifier.value ? widget.highlightColor : widget.color, - ); - }, - ), - ); - } -} - -abstract class FakeDragTargetEventTrigger { - void fakeOnDragStart(void Function(int?) callback); - void fakeOnDragEnded(VoidCallback callback); -} - -abstract class FakeDragTargetEventData { - Size? get feedbackSize; - int get index; - DragTargetData get dragTargetData; -} - -class FakeDragTarget extends StatefulWidget { - final FakeDragTargetEventTrigger eventTrigger; - final FakeDragTargetEventData eventData; - final DragTargetOnStarted onDragStarted; - final DragTargetOnEnded onDragEnded; - final DragTargetWillAccepted onWillAccept; - final Widget child; - final AnimationController insertAnimationController; - final AnimationController deleteAnimationController; - const FakeDragTarget({ - Key? key, - required this.eventTrigger, - required this.eventData, - required this.onDragStarted, - required this.onDragEnded, - required this.onWillAccept, - required this.insertAnimationController, - required this.deleteAnimationController, - required this.child, - }) : super(key: key); - - @override - State> createState() => _FakeDragTargetState(); -} - -class _FakeDragTargetState - extends State> - with TickerProviderStateMixin> { - bool simulateDragging = false; - - @override - void initState() { - widget.insertAnimationController.addStatusListener( - _onInsertedAnimationStatusChanged, - ); - - /// Start insert animation - widget.insertAnimationController.forward(from: 0.0); - - // widget.eventTrigger.fakeOnDragStart((insertIndex) { - // Log.trace("[$FakeDragTarget] on drag $insertIndex"); - // }); - - widget.eventTrigger.fakeOnDragEnded(() { - Log.trace("[$FakeDragTarget] on drag end"); - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onDragEnded(widget.eventData.dragTargetData as T); - }); - }); - - super.initState(); - } - - @override - void dispose() { - widget.insertAnimationController - .removeStatusListener(_onInsertedAnimationStatusChanged); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (simulateDragging) { - return SizeTransitionWithIntrinsicSize( - sizeFactor: widget.deleteAnimationController, - axis: Axis.vertical, - child: AbsorbPointerWidget( - opacity: 0.3, - child: widget.child, - ), - ); - } else { - return SizeTransitionWithIntrinsicSize( - sizeFactor: widget.insertAnimationController, - axis: Axis.vertical, - child: AbsorbPointerWidget( - useIntrinsicSize: true, - opacity: 0.3, - child: widget.child, - ), - ); - } - } - - void _onInsertedAnimationStatusChanged(AnimationStatus status) { - if (status != AnimationStatus.completed) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - setState(() { - if (widget.onWillAccept(widget.eventData.dragTargetData as T)) { - Log.trace("[$FakeDragTarget] on drag start"); - simulateDragging = true; - widget.deleteAnimationController.reverse(from: 1.0); - widget.onDragStarted( - widget.child, - widget.eventData.index, - widget.eventData.feedbackSize, - ); - } else { - Log.trace("[$FakeDragTarget] cancel start drag"); - } - }); - }); - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart deleted file mode 100644 index 21a67a3c7d870..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy_board/src/widgets/board.dart'; -import 'package:flutter/material.dart'; -import '../../utils/log.dart'; -import 'drag_state.dart'; -import 'drag_target.dart'; -import 'reorder_flex.dart'; - -/// [DragTargetInterceptor] is used to intercept the [DragTarget]'s -/// [onWillAccept], [OnAccept], and [onLeave] event. -abstract class DragTargetInterceptor { - String get reorderFlexId; - - /// Returns [yes] to receive the [DragTarget]'s event. - bool canHandler(FlexDragTargetData dragTargetData); - - /// Handle the [DragTarget]'s [onWillAccept] event. - bool onWillAccept({ - required BuildContext context, - required ReorderFlexState reorderFlexState, - required FlexDragTargetData dragTargetData, - required String dragTargetId, - required int dragTargetIndex, - }); - - /// Handle the [DragTarget]'s [onAccept] event. - void onAccept(FlexDragTargetData dragTargetData) {} - - /// Handle the [DragTarget]'s [onLeave] event. - void onLeave(FlexDragTargetData dragTargetData) {} - - ReorderFlexDraggableTargetBuilder? get draggableTargetBuilder => null; -} - -abstract class OverlapDragTargetDelegate { - void cancel(); - void dragTargetDidMoveToReorderFlex( - String reorderFlexId, - FlexDragTargetData dragTargetData, - int dragTargetIndex, - ); - - int getInsertedIndex(String dragTargetId); -} - -/// [OverlappingDragTargetInterceptor] is used to receive the overlapping -/// [DragTarget] event. If a [DragTarget] child is [DragTarget], it will -/// receive the [DragTarget] event when being dragged. -/// -/// Receive the [DragTarget] event if the [acceptedReorderFlexId] contains -/// the passed in dragTarget' reorderFlexId. -class OverlappingDragTargetInterceptor extends DragTargetInterceptor { - @override - final String reorderFlexId; - final List acceptedReorderFlexId; - final OverlapDragTargetDelegate delegate; - final AppFlowyBoardState columnsState; - Timer? _delayOperation; - - OverlappingDragTargetInterceptor({ - required this.delegate, - required this.reorderFlexId, - required this.acceptedReorderFlexId, - required this.columnsState, - }); - - @override - bool canHandler(FlexDragTargetData dragTargetData) { - return acceptedReorderFlexId.contains(dragTargetData.reorderFlexId); - } - - @override - bool onWillAccept( - {required BuildContext context, - required ReorderFlexState reorderFlexState, - required FlexDragTargetData dragTargetData, - required String dragTargetId, - required int dragTargetIndex}) { - if (dragTargetId == dragTargetData.reorderFlexId) { - delegate.cancel(); - } else { - // Ignore the event if the dragTarget overlaps with the other column's dragTargets. - final columnKeys = columnsState.groupDragTargetKeys[dragTargetId]; - if (columnKeys != null) { - final keys = columnKeys.values.toList(); - if (dragTargetData.isOverlapWithWidgets(keys)) { - _delayOperation?.cancel(); - return true; - } - } - - /// The priority of the column interactions is high than the cross column. - /// Workaround: delay 100 milliseconds to lower the cross column event priority. - /// - _delayOperation?.cancel(); - _delayOperation = Timer(const Duration(milliseconds: 100), () { - final index = delegate.getInsertedIndex(dragTargetId); - if (index != -1) { - Log.trace( - '[$OverlappingDragTargetInterceptor] move to $dragTargetId at $index'); - delegate.dragTargetDidMoveToReorderFlex( - dragTargetId, dragTargetData, index); - - columnsState.reorderFlexActionMap[dragTargetId] - ?.resetDragTargetIndex(index); - } - }); - } - - return true; - } -} - -abstract class CrossReorderFlexDragTargetDelegate { - /// * [reorderFlexId] is the id that the [ReorderFlex] passed in. - bool acceptNewDragTargetData( - String reorderFlexId, - FlexDragTargetData dragTargetData, - int dragTargetIndex, - ); - - void updateDragTargetData( - String reorderFlexId, - int dragTargetIndex, - ); -} - -class CrossReorderFlexDragTargetInterceptor extends DragTargetInterceptor { - @override - final String reorderFlexId; - final List acceptedReorderFlexIds; - final CrossReorderFlexDragTargetDelegate delegate; - - @override - final ReorderFlexDraggableTargetBuilder? draggableTargetBuilder; - - CrossReorderFlexDragTargetInterceptor({ - required this.reorderFlexId, - required this.delegate, - required this.acceptedReorderFlexIds, - this.draggableTargetBuilder, - }); - - @override - bool canHandler(FlexDragTargetData dragTargetData) { - if (acceptedReorderFlexIds.isEmpty) { - return false; - } - - if (acceptedReorderFlexIds.contains(dragTargetData.reorderFlexId)) { - /// If the columnId equal to the dragTargetData's columnId, - /// it means the dragTarget is dragging on the top of its own list. - /// Otherwise, it means the dargTarget was moved to another list. - Log.trace( - "[$CrossReorderFlexDragTargetInterceptor] $reorderFlexId should accept ${dragTargetData.reorderFlexId} : ${reorderFlexId != dragTargetData.reorderFlexId}"); - return reorderFlexId != dragTargetData.reorderFlexId; - } else { - Log.trace( - "[$CrossReorderFlexDragTargetInterceptor] not accept ${dragTargetData.reorderFlexId}"); - return false; - } - } - - @override - void onAccept(FlexDragTargetData dragTargetData) { - Log.trace( - '[$CrossReorderFlexDragTargetInterceptor] Group:[$reorderFlexId] on onAccept'); - } - - @override - void onLeave(FlexDragTargetData dragTargetData) { - Log.trace( - '[$CrossReorderFlexDragTargetInterceptor] Group:[$reorderFlexId] on leave'); - } - - @override - bool onWillAccept({ - required BuildContext context, - required ReorderFlexState reorderFlexState, - required FlexDragTargetData dragTargetData, - required String dragTargetId, - required int dragTargetIndex, - }) { - final isNewDragTarget = delegate.acceptNewDragTargetData( - reorderFlexId, - dragTargetData, - dragTargetIndex, - ); - - Log.debug( - '[$CrossReorderFlexDragTargetInterceptor] isNewDragTarget: $isNewDragTarget, dargTargetIndex: $dragTargetIndex, reorderFlexId: $reorderFlexId'); - - if (isNewDragTarget == false) { - delegate.updateDragTargetData(reorderFlexId, dragTargetIndex); - reorderFlexState.handleOnWillAccept(context, dragTargetIndex); - } - - return true; - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart deleted file mode 100644 index 2fe79c839f087..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart +++ /dev/null @@ -1,730 +0,0 @@ -import 'dart:collection'; -import 'dart:math'; - -import 'package:appflowy_board/appflowy_board.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import '../../utils/log.dart'; -import 'reorder_mixin.dart'; -import 'drag_target.dart'; -import 'drag_state.dart'; -import 'drag_target_interceptor.dart'; - -typedef OnDragStarted = void Function(int index); -typedef OnDragEnded = void Function(); -typedef OnReorder = void Function(int fromIndex, int toIndex); -typedef OnDeleted = void Function(int deletedIndex); -typedef OnInserted = void Function(int insertedIndex); -typedef OnReceivePassedInPhantom = void Function( - FlexDragTargetData dragTargetData, int phantomIndex); - -abstract class ReoderFlexDataSource { - /// [identifier] represents the id the [ReorderFlex]. It must be unique. - String get identifier; - - /// The number of [ReoderFlexItem]s will be displayed in the [ReorderFlex]. - UnmodifiableListView get items; -} - -/// Each item displayed in the [ReorderFlex] required to implement the [ReoderFlexItem]. -abstract class ReoderFlexItem { - /// [id] is used to identify the item. It must be unique. - String get id; - - IsDraggable draggable = true; -} - -/// Cache each dragTarget's key. -/// For the moment, the key is used to locate the render object that will -/// be passed in the [ScrollPosition]'s [ensureVisible] function. -/// -abstract class ReorderDragTargeKeys { - void insertDragTarget( - String reorderFlexId, - String key, - GlobalObjectKey value, - ); - - GlobalObjectKey? getDragTarget( - String reorderFlexId, - String key, - ); - - void removeDragTarget(String reorderFlexId); -} - -abstract class ReorderFlexAction { - void Function(void Function(BuildContext)?)? _scrollToBottom; - void Function(void Function(BuildContext)?) get scrollToBottom => - _scrollToBottom!; - - void Function(int)? _resetDragTargetIndex; - void Function(int) get resetDragTargetIndex => _resetDragTargetIndex!; -} - -class ReorderFlexConfig { - /// The opacity of the dragging widget - final double draggingWidgetOpacity = 0.4; - - // How long an animation to reorder an element - final Duration reorderAnimationDuration = const Duration(milliseconds: 200); - - // How long an animation to scroll to an off-screen element - final Duration scrollAnimationDuration = const Duration(milliseconds: 200); - - final bool useMoveAnimation; - - final bool useMovePlaceholder; - - /// [direction] How to place the children, default is Axis.vertical - final Axis direction; - - final Axis? dragDirection; - - const ReorderFlexConfig({ - this.useMoveAnimation = true, - this.direction = Axis.vertical, - this.dragDirection, - }) : useMovePlaceholder = !useMoveAnimation; -} - -class ReorderFlex extends StatefulWidget { - final ReorderFlexConfig config; - final List children; - - final MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start; - - final ScrollController? scrollController; - - /// [onDragStarted] is called when start dragging - final OnDragStarted? onDragStarted; - - /// [onReorder] is called when dragTarget did end dragging - final OnReorder onReorder; - - /// [onDragEnded] is called when dragTarget did end dragging - final OnDragEnded? onDragEnded; - - final ReoderFlexDataSource dataSource; - - final DragTargetInterceptor? interceptor; - - /// Save the [DraggingState] if the current [ReorderFlex] get reinitialize. - final DraggingStateStorage? dragStateStorage; - - final ReorderDragTargeKeys? dragTargetKeys; - - final ReorderFlexAction? reorderFlexAction; - - ReorderFlex({ - Key? key, - this.scrollController, - required this.dataSource, - required this.children, - required this.config, - required this.onReorder, - this.dragStateStorage, - this.dragTargetKeys, - this.onDragStarted, - this.onDragEnded, - this.interceptor, - this.reorderFlexAction, - }) : assert(children.every((Widget w) => w.key != null), - 'All child must have a key.'), - super(key: key); - - @override - State createState() => ReorderFlexState(); - - String get reorderFlexId => dataSource.identifier; -} - -class ReorderFlexState extends State - with ReorderFlexMixin, TickerProviderStateMixin { - /// Controls scrolls and measures scroll progress. - late ScrollController _scrollController; - - /// Records the position of the [Scrollable] - ScrollPosition? _attachedScrollPosition; - - /// Whether or not we are currently scrolling this view to show a widget. - bool _scrolling = false; - - /// [draggingState] records the dragging state including dragStartIndex, and phantomIndex, etc. - late DraggingState draggingState; - - /// [_animation] controls the dragging animations - late DragTargetAnimation _animation; - - late ReorderFlexNotifier _notifier; - - @override - void initState() { - _notifier = ReorderFlexNotifier(); - final flexId = widget.reorderFlexId; - draggingState = widget.dragStateStorage?.readState(flexId) ?? - DraggingState(widget.reorderFlexId); - Log.trace('[DragTarget] init dragState: $draggingState'); - - widget.dragStateStorage?.removeState(flexId); - - _animation = DragTargetAnimation( - reorderAnimationDuration: widget.config.reorderAnimationDuration, - entranceAnimateStatusChanged: (status) { - if (status == AnimationStatus.completed) { - if (draggingState.nextIndex == -1) return; - setState(() => _requestAnimationToNextIndex()); - } - }, - vsync: this, - ); - - widget.reorderFlexAction?._scrollToBottom = (fn) { - scrollToBottom(fn); - }; - - widget.reorderFlexAction?._resetDragTargetIndex = (index) { - resetDragTargetIndex(index); - }; - - super.initState(); - } - - @override - void didChangeDependencies() { - if (_attachedScrollPosition != null) { - _scrollController.detach(_attachedScrollPosition!); - _attachedScrollPosition = null; - } - - _scrollController = widget.scrollController ?? - PrimaryScrollController.of(context) ?? - ScrollController(); - - if (_scrollController.hasClients) { - _attachedScrollPosition = Scrollable.of(context)?.position; - } else { - _attachedScrollPosition = null; - } - - if (_attachedScrollPosition != null) { - _scrollController.attach(_attachedScrollPosition!); - } - super.didChangeDependencies(); - } - - @override - Widget build(BuildContext context) { - final List children = []; - - for (int i = 0; i < widget.children.length; i += 1) { - Widget child = widget.children[i]; - final ReoderFlexItem item = widget.dataSource.items[i]; - - final indexKey = GlobalObjectKey(child.key!); - // Save the index key for quick access - widget.dragTargetKeys?.insertDragTarget( - widget.reorderFlexId, - item.id, - indexKey, - ); - - children.add(_wrap(child, i, indexKey, item.draggable)); - - // if (widget.config.useMovePlaceholder) { - // children.add(DragTargeMovePlaceholder( - // dragTargetIndex: i, - // delegate: _notifier, - // )); - // } - } - - final child = _wrapContainer(children); - return _wrapScrollView(child: child); - } - - @override - void dispose() { - if (_attachedScrollPosition != null) { - _scrollController.detach(_attachedScrollPosition!); - _attachedScrollPosition = null; - } - - _animation.dispose(); - super.dispose(); - } - - void _requestAnimationToNextIndex({bool isAcceptingNewTarget = false}) { - /// Update the dragState and animate to the next index if the current - /// dragging animation is completed. Otherwise, it will get called again - /// when the animation finish. - - if (_animation.entranceController.isCompleted) { - draggingState.removePhantom(); - - if (!isAcceptingNewTarget && draggingState.didDragTargetMoveToNext()) { - return; - } - - draggingState.moveDragTargetToNext(); - _animation.animateToNext(); - } - } - - /// [child]: the child will be wrapped with dartTarget - /// [childIndex]: the index of the child in a list - Widget _wrap( - Widget child, - int childIndex, - GlobalObjectKey indexKey, - IsDraggable draggable, - ) { - return Builder(builder: (context) { - final ReorderDragTarget dragTarget = _buildDragTarget( - context, - child, - childIndex, - indexKey, - draggable, - ); - int shiftedIndex = childIndex; - - if (draggingState.isOverlapWithPhantom()) { - shiftedIndex = draggingState.calculateShiftedIndex(childIndex); - } - - Log.trace( - 'Rebuild: Group:[${draggingState.reorderFlexId}] ${draggingState.toString()}, childIndex: $childIndex shiftedIndex: $shiftedIndex'); - final currentIndex = draggingState.currentIndex; - final dragPhantomIndex = draggingState.phantomIndex; - - if (shiftedIndex == currentIndex || childIndex == dragPhantomIndex) { - Widget dragSpace; - if (draggingState.draggingWidget != null) { - if (draggingState.draggingWidget is PhantomWidget) { - dragSpace = draggingState.draggingWidget!; - } else { - dragSpace = PhantomWidget( - opacity: widget.config.draggingWidgetOpacity, - child: draggingState.draggingWidget, - ); - } - } else { - dragSpace = SizedBox.fromSize(size: draggingState.dropAreaSize); - } - - /// Returns the dragTarget it is not start dragging. The size of the - /// dragTarget is the same as the the passed in child. - /// - if (draggingState.isNotDragging()) { - return _buildDraggingContainer(children: [dragTarget]); - } - - /// Determine the size of the drop area to show under the dragging widget. - Size? feedbackSize = Size.zero; - if (widget.config.useMoveAnimation) { - feedbackSize = draggingState.feedbackSize; - } - - Widget appearSpace = _makeAppearSpace(dragSpace, feedbackSize); - Widget disappearSpace = _makeDisappearSpace(dragSpace, feedbackSize); - - /// When start dragging, the dragTarget, [ReorderDragTarget], will - /// return a [IgnorePointerWidget] which size is zero. - if (draggingState.isPhantomAboveDragTarget()) { - _notifier.updateDragTargetIndex(currentIndex); - if (shiftedIndex == currentIndex && childIndex == dragPhantomIndex) { - return _buildDraggingContainer(children: [ - disappearSpace, - dragTarget, - appearSpace, - ]); - } else if (shiftedIndex == currentIndex) { - return _buildDraggingContainer(children: [ - dragTarget, - appearSpace, - ]); - } else if (childIndex == dragPhantomIndex) { - return _buildDraggingContainer( - children: shiftedIndex <= childIndex - ? [dragTarget, disappearSpace] - : [disappearSpace, dragTarget]); - } - } - - /// - if (draggingState.isPhantomBelowDragTarget()) { - _notifier.updateDragTargetIndex(currentIndex); - if (shiftedIndex == currentIndex && childIndex == dragPhantomIndex) { - return _buildDraggingContainer(children: [ - appearSpace, - dragTarget, - disappearSpace, - ]); - } else if (shiftedIndex == currentIndex) { - return _buildDraggingContainer(children: [ - appearSpace, - dragTarget, - ]); - } else if (childIndex == dragPhantomIndex) { - return _buildDraggingContainer( - children: shiftedIndex >= childIndex - ? [disappearSpace, dragTarget] - : [dragTarget, disappearSpace]); - } - } - - assert(!draggingState.isOverlapWithPhantom()); - - List children = []; - if (draggingState.isDragTargetMovingDown()) { - children.addAll([dragTarget, appearSpace]); - } else { - children.addAll([appearSpace, dragTarget]); - } - return _buildDraggingContainer(children: children); - } - - /// We still wrap dragTarget with a container so that widget's depths are - /// the same and it prevent's layout alignment issue - return _buildDraggingContainer(children: [dragTarget]); - }); - } - - static ReorderFlexState of(BuildContext context) { - if (context is StatefulElement && context.state is ReorderFlexState) { - return context.state as ReorderFlexState; - } - final ReorderFlexState? result = - context.findAncestorStateOfType(); - return result!; - } - - ReorderDragTarget _buildDragTarget( - BuildContext builderContext, - Widget child, - int dragTargetIndex, - GlobalObjectKey indexKey, - IsDraggable draggable, - ) { - final reorderFlexItem = widget.dataSource.items[dragTargetIndex]; - return ReorderDragTarget( - indexGlobalKey: indexKey, - draggable: draggable, - dragTargetData: FlexDragTargetData( - draggingIndex: dragTargetIndex, - reorderFlexId: widget.reorderFlexId, - reorderFlexItem: reorderFlexItem, - draggingState: draggingState, - dragTargetId: reorderFlexItem.id, - dragTargetIndexKey: indexKey, - ), - onDragStarted: (draggingWidget, draggingIndex, size) { - Log.debug( - "[DragTarget] Group:[${widget.dataSource.identifier}] start dragging item at $draggingIndex"); - _startDragging(draggingWidget, draggingIndex, size); - widget.onDragStarted?.call(draggingIndex); - widget.dragStateStorage?.removeState(widget.reorderFlexId); - }, - onDragMoved: (dragTargetData, offset) { - dragTargetData.dragTargetOffset = offset; - }, - onDragEnded: (dragTargetData) { - if (!mounted) { - Log.warn( - "[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging but current widget was unmounted"); - return; - } - Log.debug( - "[DragTarget]: Group:[${widget.dataSource.identifier}] end dragging"); - - _notifier.updateDragTargetIndex(-1); - _animation.insertController.stop(); - - setState(() { - if (dragTargetData.reorderFlexId == widget.reorderFlexId) { - _onReordered( - draggingState.dragStartIndex, - draggingState.currentIndex, - ); - } - draggingState.endDragging(); - widget.onDragEnded?.call(); - }); - }, - onWillAccept: (FlexDragTargetData dragTargetData) { - // Do not receive any events if the Insert item is animating. - if (_animation.insertController.isAnimating) { - return false; - } - - if (dragTargetData.isDragging) { - assert(widget.dataSource.items.length > dragTargetIndex); - if (_interceptDragTarget(dragTargetData, (interceptor) { - interceptor.onWillAccept( - context: builderContext, - reorderFlexState: this, - dragTargetData: dragTargetData, - dragTargetId: reorderFlexItem.id, - dragTargetIndex: dragTargetIndex, - ); - })) { - return true; - } else { - return handleOnWillAccept(builderContext, dragTargetIndex); - } - } else { - return false; - } - }, - onAccept: (dragTargetData) { - _interceptDragTarget( - dragTargetData, - (interceptor) => interceptor.onAccept(dragTargetData), - ); - }, - onLeave: (dragTargetData) { - _notifier.updateDragTargetIndex(-1); - _interceptDragTarget( - dragTargetData, - (interceptor) => interceptor.onLeave(dragTargetData), - ); - }, - insertAnimationController: _animation.insertController, - deleteAnimationController: _animation.deleteController, - draggableTargetBuilder: widget.interceptor?.draggableTargetBuilder, - useMoveAnimation: widget.config.useMoveAnimation, - draggingOpacity: widget.config.draggingWidgetOpacity, - dragDirection: widget.config.dragDirection, - child: child, - ); - } - - bool _interceptDragTarget( - FlexDragTargetData dragTargetData, - void Function(DragTargetInterceptor) callback, - ) { - final interceptor = widget.interceptor; - if (interceptor != null && interceptor.canHandler(dragTargetData)) { - callback(interceptor); - return true; - } else { - return false; - } - } - - Widget _makeAppearSpace(Widget child, Size? feedbackSize) { - return makeAppearingWidget( - child, - _animation.entranceController, - feedbackSize, - widget.config.direction, - ); - } - - Widget _makeDisappearSpace(Widget child, Size? feedbackSize) { - return makeDisappearingWidget( - child, - _animation.phantomController, - feedbackSize, - widget.config.direction, - ); - } - - void _startDragging( - Widget draggingWidget, - int dragIndex, - Size? feedbackSize, - ) { - setState(() { - draggingState.startDragging(draggingWidget, dragIndex, feedbackSize); - _animation.startDragging(); - }); - } - - void resetDragTargetIndex(int dragTargetIndex) { - if (dragTargetIndex > widget.dataSource.items.length) { - return; - } - - draggingState.setStartDraggingIndex(dragTargetIndex); - widget.dragStateStorage?.insertState( - widget.reorderFlexId, - draggingState, - ); - } - - bool handleOnWillAccept(BuildContext context, int dragTargetIndex) { - final dragIndex = draggingState.dragStartIndex; - - /// The [willAccept] will be true if the dargTarget is the widget that gets - /// dragged and it is dragged on top of the other dragTargets. - /// - - bool willAccept = draggingState.dragStartIndex == dragIndex && - dragIndex != dragTargetIndex; - setState(() { - if (willAccept) { - int shiftedIndex = draggingState.calculateShiftedIndex(dragTargetIndex); - draggingState.updateNextIndex(shiftedIndex); - } else { - draggingState.updateNextIndex(dragTargetIndex); - } - _requestAnimationToNextIndex(isAcceptingNewTarget: true); - }); - - Log.trace( - '[$ReorderDragTarget] ${widget.reorderFlexId} dragging state: $draggingState}'); - - _scrollTo(context); - - /// If the target is not the original starting point, then we will accept the drop. - return willAccept; - } - - void _onReordered(int fromIndex, int toIndex) { - if (toIndex == -1) return; - if (fromIndex == -1) return; - - if (fromIndex != toIndex) { - widget.onReorder.call(fromIndex, toIndex); - } - - _animation.reverseAnimation(); - } - - Widget _wrapScrollView({required Widget child}) { - if (widget.scrollController != null && - PrimaryScrollController.of(context) == null) { - return child; - } else { - return SingleChildScrollView( - scrollDirection: widget.config.direction, - controller: _scrollController, - child: child, - ); - } - } - - Widget _wrapContainer(List children) { - switch (widget.config.direction) { - case Axis.horizontal: - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: widget.mainAxisAlignment, - children: children, - ); - case Axis.vertical: - default: - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: widget.mainAxisAlignment, - children: children, - ); - } - } - - Widget _buildDraggingContainer({required List children}) { - switch (widget.config.direction) { - case Axis.horizontal: - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: widget.mainAxisAlignment, - children: children, - ); - case Axis.vertical: - default: - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: widget.mainAxisAlignment, - children: children, - ); - } - } - - void scrollToBottom(void Function(BuildContext)? completed) { - if (_scrolling) { - completed?.call(context); - return; - } - - if (widget.dataSource.items.isNotEmpty) { - final item = widget.dataSource.items.last; - final dragTargetKey = widget.dragTargetKeys?.getDragTarget( - widget.reorderFlexId, - item.id, - ); - if (dragTargetKey == null) { - completed?.call(context); - return; - } - - final dragTargetContext = dragTargetKey.currentContext; - if (dragTargetContext == null || _scrollController.hasClients == false) { - completed?.call(context); - return; - } - - final dragTargetRenderObject = dragTargetContext.findRenderObject(); - if (dragTargetRenderObject != null) { - _scrolling = true; - _scrollController.position - .ensureVisible( - dragTargetRenderObject, - alignment: 0.5, - alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, - duration: const Duration(milliseconds: 120), - ) - .then((value) { - setState(() { - _scrolling = false; - completed?.call(context); - }); - }); - } else { - completed?.call(context); - } - } - } - -// Scrolls to a target context if that context is not on the screen. - void _scrollTo(BuildContext context) { - if (_scrolling) return; - final RenderObject contextObject = context.findRenderObject()!; - final RenderAbstractViewport viewport = - RenderAbstractViewport.of(contextObject)!; - // If and only if the current scroll offset falls in-between the offsets - // necessary to reveal the selected context at the top or bottom of the - // screen, then it is already on-screen. - final double margin = widget.config.direction == Axis.horizontal - ? draggingState.dropAreaSize.width - : draggingState.dropAreaSize.height / 2.0; - if (_scrollController.hasClients) { - final double scrollOffset = _scrollController.offset; - final double topOffset = max( - _scrollController.position.minScrollExtent, - viewport.getOffsetToReveal(contextObject, 0.0).offset - margin, - ); - final double bottomOffset = min( - _scrollController.position.maxScrollExtent, - viewport.getOffsetToReveal(contextObject, 1.0).offset + margin, - ); - final bool onScreen = - scrollOffset <= topOffset && scrollOffset >= bottomOffset; - - // If the context is off screen, then we request a scroll to make it visible. - if (!onScreen) { - _scrolling = true; - _scrollController.position - .animateTo( - scrollOffset < bottomOffset ? bottomOffset : topOffset, - duration: widget.config.scrollAnimationDuration, - curve: Curves.easeInOut, - ) - .then((void value) { - setState(() => _scrolling = false); - }); - } - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_mixin.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_mixin.dart deleted file mode 100644 index 742d5dd7a6073..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_mixin.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import '../transitions.dart'; -import 'drag_target.dart'; - -mixin ReorderFlexMixin { - @protected - Widget makeAppearingWidget( - Widget child, - AnimationController animationController, - Size? draggingFeedbackSize, - Axis direction, - ) { - final sizeFactor = animationController.withLinearCurve(); - if (null == draggingFeedbackSize) { - return SizeTransitionWithIntrinsicSize( - sizeFactor: sizeFactor, - axis: direction, - child: FadeTransition( - opacity: sizeFactor, - child: child, - ), - ); - } else { - var transition = SizeTransition( - sizeFactor: sizeFactor, - axis: direction, - child: FadeTransition(opacity: animationController, child: child), - ); - - BoxConstraints contentSizeConstraints = - BoxConstraints.loose(draggingFeedbackSize); - return ConstrainedBox( - constraints: contentSizeConstraints, child: transition); - } - } - - @protected - Widget makeDisappearingWidget( - Widget child, - AnimationController animationController, - Size? draggingFeedbackSize, - Axis direction, - ) { - final sizeFactor = animationController.withLinearCurve(); - if (null == draggingFeedbackSize) { - return SizeTransitionWithIntrinsicSize( - sizeFactor: sizeFactor, - axis: direction, - child: FadeTransition( - opacity: sizeFactor, - child: child, - ), - ); - } else { - var transition = SizeTransition( - sizeFactor: sizeFactor, - axis: direction, - child: FadeTransition(opacity: animationController, child: child), - ); - - BoxConstraints contentSizeConstraints = - BoxConstraints.loose(draggingFeedbackSize); - return ConstrainedBox( - constraints: contentSizeConstraints, child: transition); - } - } -} - -Animation withCurve( - AnimationController animationController, Cubic curve) { - return CurvedAnimation( - parent: animationController, - curve: curve, - ); -} - -extension CurveAnimationController on AnimationController { - Animation withLinearCurve() { - return withCurve(Curves.linear); - } - - Animation withCurve(Curve curve) { - return CurvedAnimation( - parent: this, - curve: curve, - ); - } -} - -class ReorderFlexNotifier extends DragTargetMovePlaceholderDelegate { - Map dragTargeEventNotifier = {}; - - void updateDragTargetIndex(int index) { - for (var notifier in dragTargeEventNotifier.values) { - notifier.setDragTargetIndex(index); - } - } - - DragTargetEventNotifier _notifierFromIndex(int dragTargetIndex) { - DragTargetEventNotifier? notifier = dragTargeEventNotifier[dragTargetIndex]; - if (notifier == null) { - final newNotifier = DragTargetEventNotifier(); - dragTargeEventNotifier[dragTargetIndex] = newNotifier; - notifier = newNotifier; - } - - return notifier; - } - - void dispose() { - for (var notifier in dragTargeEventNotifier.values) { - notifier.dispose(); - } - } - - @override - void registerPlaceholder( - int dragTargetIndex, - void Function(int dragTargetIndex) callback, - ) { - _notifierFromIndex(dragTargetIndex).addListener(() { - callback.call(_notifierFromIndex(dragTargetIndex).currentDragTargetIndex); - }); - } - - @override - void unregisterPlaceholder(int dragTargetIndex) { - dragTargeEventNotifier.remove(dragTargetIndex); - } -} - -class DragTargetEventNotifier extends ChangeNotifier { - int currentDragTargetIndex = -1; - - void setDragTargetIndex(int index) { - if (currentDragTargetIndex != index) { - currentDragTargetIndex = index; - notifyListeners(); - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart deleted file mode 100644 index 7e991b34cda53..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart +++ /dev/null @@ -1,378 +0,0 @@ -import 'package:appflowy_board/appflowy_board.dart'; -import 'package:flutter/widgets.dart'; - -import '../../utils/log.dart'; -import '../reorder_flex/drag_state.dart'; -import '../reorder_flex/drag_target.dart'; -import '../reorder_flex/drag_target_interceptor.dart'; -import 'phantom_state.dart'; - -abstract class BoardPhantomControllerDelegate { - AppFlowyGroupController? controller(String groupId); - - bool removePhantom(String groupId); - - /// Insert the phantom into the group - /// - /// * [groupId] id of the group - /// * [index] the phantom occupies index - void insertPhantom( - String groupId, - int index, - PhantomGroupItem item, - ); - - /// Update the group's phantom index if it exists. - /// [toGroupId] the id of the group - /// [newIndex] the index of the dragTarget - void updatePhantom(String groupId, int newIndex); - - void moveGroupItemToAnotherGroup( - String fromGroupId, - int fromGroupIndex, - String toGroupId, - int toGroupIndex, - ); -} - -class BoardPhantomController extends OverlapDragTargetDelegate - with CrossReorderFlexDragTargetDelegate { - PhantomRecord? phantomRecord; - final BoardPhantomControllerDelegate delegate; - final AppFlowyBoardState groupsState; - final phantomState = GroupPhantomState(); - BoardPhantomController({ - required this.delegate, - required this.groupsState, - }); - - /// Determines whether the group should perform reorder - /// - /// Returns `true` if the fromGroupId and toGroupId of the phantomRecord - /// equal to the passed in groupId. - /// - /// Returns `true` if the phantomRecord is null - /// - bool shouldReorder(String groupId) { - if (phantomRecord != null) { - return phantomRecord!.toGroupId == groupId && - phantomRecord!.fromGroupId == groupId; - } else { - return true; - } - } - - void updateIndex(int fromIndex, int toIndex) { - if (phantomRecord == null) { - return; - } - assert(phantomRecord!.fromGroupIndex == fromIndex); - phantomRecord?.updateFromGroupIndex(toIndex); - } - - void groupStartDragging(String groupId) { - phantomState.setGroupIsDragging(groupId, true); - } - - /// Remove the phantom in the group when the group is end dragging. - void groupEndDragging(String groupId) { - phantomState.setGroupIsDragging(groupId, false); - if (phantomRecord == null) return; - - final fromGroupId = phantomRecord!.fromGroupId; - final toGroupId = phantomRecord!.toGroupId; - if (fromGroupId == groupId) { - phantomState.notifyDidRemovePhantom(toGroupId); - } - - if (phantomRecord!.toGroupId == groupId) { - delegate.moveGroupItemToAnotherGroup( - fromGroupId, - phantomRecord!.fromGroupIndex, - toGroupId, - phantomRecord!.toGroupIndex, - ); - - // Log.debug( - // "[$BoardPhantomController] did move ${phantomRecord.toString()}"); - phantomRecord = null; - } - } - - /// Remove the phantom in the group if it contains phantom - void _removePhantom(String groupId) { - final didRemove = delegate.removePhantom(groupId); - if (didRemove) { - phantomState.notifyDidRemovePhantom(groupId); - phantomState.removeGroupListener(groupId); - } - } - - void _insertPhantom( - String toGroupId, - FlexDragTargetData dragTargetData, - int phantomIndex, - ) { - final phantomContext = PassthroughPhantomContext( - index: phantomIndex, - dragTargetData: dragTargetData, - ); - phantomState.addGroupListener(toGroupId, phantomContext); - - delegate.insertPhantom( - toGroupId, - phantomIndex, - PhantomGroupItem(phantomContext), - ); - - phantomState.notifyDidInsertPhantom(toGroupId, phantomIndex); - } - - /// Reset or initial the [PhantomRecord] - /// - /// - /// * [groupId] the id of the group - /// * [dragTargetData] - /// * [dragTargetIndex] - /// - void _resetPhantomRecord( - String groupId, - FlexDragTargetData dragTargetData, - int dragTargetIndex, - ) { - // Log.debug( - // '[$BoardPhantomController] move Group:[${dragTargetData.reorderFlexId}]:${dragTargetData.draggingIndex} ' - // 'to Group:[$groupId]:$dragTargetIndex'); - - phantomRecord = PhantomRecord( - toGroupId: groupId, - toGroupIndex: dragTargetIndex, - fromGroupId: dragTargetData.reorderFlexId, - fromGroupIndex: dragTargetData.draggingIndex, - ); - Log.debug('[$BoardPhantomController] will move: $phantomRecord'); - } - - @override - bool acceptNewDragTargetData( - String reorderFlexId, - FlexDragTargetData dragTargetData, - int dragTargetIndex, - ) { - if (phantomRecord == null) { - _resetPhantomRecord(reorderFlexId, dragTargetData, dragTargetIndex); - _insertPhantom(reorderFlexId, dragTargetData, dragTargetIndex); - - return true; - } - - final isNewDragTarget = phantomRecord!.toGroupId != reorderFlexId; - if (isNewDragTarget) { - /// Remove the phantom when the dragTarget is moved from one group to another group. - _removePhantom(phantomRecord!.toGroupId); - _resetPhantomRecord(reorderFlexId, dragTargetData, dragTargetIndex); - _insertPhantom(reorderFlexId, dragTargetData, dragTargetIndex); - } - - return isNewDragTarget; - } - - @override - void updateDragTargetData( - String reorderFlexId, - int dragTargetIndex, - ) { - phantomRecord?.updateInsertedIndex(dragTargetIndex); - - assert(phantomRecord != null); - if (phantomRecord!.toGroupId == reorderFlexId) { - /// Update the existing phantom index - delegate.updatePhantom(phantomRecord!.toGroupId, dragTargetIndex); - } - } - - @override - void cancel() { - if (phantomRecord == null) { - return; - } - - /// Remove the phantom when the dragTarge is go back to the original group. - _removePhantom(phantomRecord!.toGroupId); - phantomRecord = null; - } - - @override - void dragTargetDidMoveToReorderFlex( - String reorderFlexId, - FlexDragTargetData dragTargetData, - int dragTargetIndex, - ) { - acceptNewDragTargetData( - reorderFlexId, - dragTargetData, - dragTargetIndex, - ); - } - - @override - int getInsertedIndex(String dragTargetId) { - if (phantomState.isDragging(dragTargetId)) { - return -1; - } - - final controller = delegate.controller(dragTargetId); - if (controller != null) { - return controller.groupData.items.length; - } else { - return 0; - } - } -} - -/// Use [PhantomRecord] to record where to remove the group item and where to -/// insert the group item. -/// -/// [fromGroupId] the group that phantom comes from -/// [fromGroupIndex] the index of the phantom from the original group -/// [toGroupId] the group that the phantom moves into -/// [toGroupIndex] the index of the phantom moves into the group -/// -class PhantomRecord { - final String fromGroupId; - int fromGroupIndex; - - final String toGroupId; - int toGroupIndex; - - PhantomRecord({ - required this.toGroupId, - required this.toGroupIndex, - required this.fromGroupId, - required this.fromGroupIndex, - }); - - void updateFromGroupIndex(int index) { - fromGroupIndex = index; - } - - void updateInsertedIndex(int index) { - if (toGroupIndex == index) { - return; - } - - Log.debug( - '[$PhantomRecord] Group:[$toGroupId] update position $toGroupIndex -> $index'); - toGroupIndex = index; - } - - @override - String toString() { - return 'Group:[$fromGroupId]:$fromGroupIndex to Group:[$toGroupId]:$toGroupIndex'; - } -} - -class PhantomGroupItem extends AppFlowyGroupItem { - final PassthroughPhantomContext phantomContext; - - PhantomGroupItem(PassthroughPhantomContext insertedPhantom) - : phantomContext = insertedPhantom; - - @override - bool get isPhantom => true; - - @override - String get id => phantomContext.itemData.id; - - Size? get feedbackSize => phantomContext.feedbackSize; - - Widget get draggingWidget => phantomContext.draggingWidget == null - ? const SizedBox() - : phantomContext.draggingWidget!; - - @override - String toString() { - return 'phantom:$id'; - } -} - -class PassthroughPhantomContext extends FakeDragTargetEventTrigger - with FakeDragTargetEventData, PassthroughPhantomListener { - @override - int index; - - @override - final FlexDragTargetData dragTargetData; - - @override - Size? get feedbackSize => dragTargetData.feedbackSize; - - Widget? get draggingWidget => dragTargetData.draggingWidget; - - AppFlowyGroupItem get itemData => - dragTargetData.reorderFlexItem as AppFlowyGroupItem; - - @override - void Function(int?)? onInserted; - - @override - VoidCallback? onDragEnded; - - PassthroughPhantomContext({ - required this.index, - required this.dragTargetData, - }); - - @override - void fakeOnDragEnded(VoidCallback callback) { - onDragEnded = callback; - } - - @override - void fakeOnDragStart(void Function(int? index) callback) { - onInserted = callback; - } -} - -class PassthroughPhantomWidget extends PhantomWidget { - final PassthroughPhantomContext passthroughPhantomContext; - - PassthroughPhantomWidget({ - required double opacity, - required this.passthroughPhantomContext, - Key? key, - }) : super( - child: passthroughPhantomContext.draggingWidget, - opacity: opacity, - key: key, - ); -} - -class PhantomDraggableBuilder extends ReorderFlexDraggableTargetBuilder { - PhantomDraggableBuilder(); - @override - Widget? build( - BuildContext context, - Widget child, - DragTargetOnStarted onDragStarted, - DragTargetOnEnded onDragEnded, - DragTargetWillAccepted onWillAccept, - AnimationController insertAnimationController, - AnimationController deleteAnimationController, - ) { - if (child is PassthroughPhantomWidget) { - return FakeDragTarget( - eventTrigger: child.passthroughPhantomContext, - eventData: child.passthroughPhantomContext, - onDragStarted: onDragStarted, - onDragEnded: onDragEnded, - onWillAccept: onWillAccept, - insertAnimationController: insertAnimationController, - deleteAnimationController: deleteAnimationController, - child: child, - ); - } else { - return null; - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart deleted file mode 100644 index a00b1a2aa0605..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:appflowy_board/src/utils/log.dart'; - -import 'phantom_controller.dart'; -import 'package:flutter/material.dart'; - -class GroupPhantomState { - final _groupStates = {}; - final _groupIsDragging = {}; - - void setGroupIsDragging(String groupId, bool isDragging) { - _groupIsDragging[groupId] = isDragging; - } - - bool isDragging(String groupId) { - return _groupIsDragging[groupId] ?? false; - } - - void addGroupListener(String groupId, PassthroughPhantomListener listener) { - if (_groupStates[groupId] == null) { - Log.debug("[$GroupPhantomState] add group listener: $groupId"); - _groupStates[groupId] = GroupState(); - _groupStates[groupId]?.notifier.addListener( - onInserted: (index) => listener.onInserted?.call(index), - onDeleted: () => listener.onDragEnded?.call(), - ); - } - } - - void removeGroupListener(String groupId) { - Log.debug("[$GroupPhantomState] remove group listener: $groupId"); - final groupState = _groupStates.remove(groupId); - groupState?.dispose(); - } - - void notifyDidInsertPhantom(String groupId, int index) { - _groupStates[groupId]?.notifier.insert(index); - } - - void notifyDidRemovePhantom(String groupId) { - Log.debug("[$GroupPhantomState] $groupId remove phantom"); - _groupStates[groupId]?.notifier.remove(); - } -} - -class GroupState { - bool isDragging = false; - final notifier = PassthroughPhantomNotifier(); - - void dispose() { - notifier.dispose(); - } -} - -abstract class PassthroughPhantomListener { - void Function(int?)? get onInserted; - VoidCallback? get onDragEnded; -} - -class PassthroughPhantomNotifier { - final insertNotifier = PhantomInsertNotifier(); - - final removeNotifier = PhantomDeleteNotifier(); - - void insert(int index) { - insertNotifier.insert(index); - } - - void remove() { - removeNotifier.remove(); - } - - void addListener({ - void Function(int? insertedIndex)? onInserted, - void Function()? onDeleted, - }) { - if (onInserted != null) { - insertNotifier.addListener(() { - onInserted(insertNotifier.insertedIndex); - }); - } - - if (onDeleted != null) { - removeNotifier.addListener(() { - onDeleted(); - }); - } - } - - void dispose() { - insertNotifier.dispose(); - removeNotifier.dispose(); - } -} - -class PhantomInsertNotifier extends ChangeNotifier { - int insertedIndex = -1; - PassthroughPhantomContext? insertedPhantom; - - void insert(int index) { - insertedIndex = index; - notifyListeners(); - } -} - -class PhantomDeleteNotifier extends ChangeNotifier { - void remove() { - notifyListeners(); - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/card.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/card.dart deleted file mode 100644 index b3ae96b28e534..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/card.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/material.dart'; - -class AppFlowyGroupCard extends StatefulWidget { - final Widget? child; - final EdgeInsets margin; - final BoxConstraints boxConstraints; - final BoxDecoration decoration; - - const AppFlowyGroupCard({ - this.child, - this.margin = const EdgeInsets.all(4), - this.decoration = const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.zero, - ), - this.boxConstraints = const BoxConstraints(minHeight: 40), - Key? key, - }) : super(key: key); - - @override - State createState() => _AppFlowyGroupCardState(); -} - -class _AppFlowyGroupCardState extends State { - @override - Widget build(BuildContext context) { - return Padding( - padding: widget.margin, - child: Container( - clipBehavior: Clip.hardEdge, - constraints: widget.boxConstraints, - decoration: widget.decoration, - child: widget.child, - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/footer.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/footer.dart deleted file mode 100644 index b130f1e099a32..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/footer.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter/material.dart'; - -typedef OnFooterAddButtonClick = void Function(); - -class AppFlowyGroupFooter extends StatefulWidget { - final double height; - final Widget? icon; - final Widget? title; - final EdgeInsets margin; - final OnFooterAddButtonClick? onAddButtonClick; - - const AppFlowyGroupFooter({ - this.icon, - this.title, - this.margin = const EdgeInsets.symmetric(horizontal: 12), - required this.height, - this.onAddButtonClick, - Key? key, - }) : super(key: key); - - @override - State createState() => _AppFlowyGroupFooterState(); -} - -class _AppFlowyGroupFooterState extends State { - @override - Widget build(BuildContext context) { - return InkWell( - onTap: widget.onAddButtonClick, - child: SizedBox( - height: widget.height, - child: Padding( - padding: widget.margin, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (widget.icon != null) widget.icon!, - const SizedBox(width: 8), - if (widget.title != null) widget.title!, - ], - ), - ), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/header.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/header.dart deleted file mode 100644 index 9e17a340ee366..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/header.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/material.dart'; - -typedef OnHeaderAddButtonClick = void Function(); -typedef OnHeaderMoreButtonClick = void Function(); - -class AppFlowyGroupHeader extends StatefulWidget { - final double height; - final Widget? icon; - final Widget? title; - final Widget? addIcon; - final Widget? moreIcon; - final EdgeInsets margin; - final OnHeaderAddButtonClick? onAddButtonClick; - final OnHeaderMoreButtonClick? onMoreButtonClick; - - const AppFlowyGroupHeader({ - required this.height, - this.icon, - this.title, - this.addIcon, - this.moreIcon, - this.margin = EdgeInsets.zero, - this.onAddButtonClick, - this.onMoreButtonClick, - Key? key, - }) : super(key: key); - - @override - State createState() => _AppFlowyGroupHeaderState(); -} - -class _AppFlowyGroupHeaderState extends State { - @override - Widget build(BuildContext context) { - List children = []; - - if (widget.icon != null) { - children.add(widget.icon!); - children.add(_hSpace()); - } - - if (widget.title != null) { - children.add(widget.title!); - children.add(_hSpace()); - } - - if (widget.moreIcon != null) { - // children.add(const Spacer()); - children.add( - IconButton( - onPressed: widget.onMoreButtonClick, - icon: widget.moreIcon!, - padding: const EdgeInsets.all(4), - constraints: const BoxConstraints(), - ), - ); - } - - if (widget.addIcon != null) { - children.add( - IconButton( - onPressed: widget.onAddButtonClick, - icon: widget.addIcon!, - padding: const EdgeInsets.all(4), - constraints: const BoxConstraints(), - ), - ); - } - - return SizedBox( - height: widget.height, - child: Padding( - padding: widget.margin, - child: Row(children: children), - ), - ); - } - - Widget _hSpace() { - return const SizedBox(width: 6); - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/widgets.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/widgets.dart deleted file mode 100644 index b802d15daef4a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/widgets.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'card.dart'; -export 'footer.dart'; -export 'header.dart'; diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/transitions.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/transitions.dart deleted file mode 100644 index d5cb1de26a4c0..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/transitions.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter/rendering.dart'; - -class SizeTransitionWithIntrinsicSize extends SingleChildRenderObjectWidget { - /// Creates a size transition with its intrinsic width/height taking [sizeFactor] - /// into account. - /// - /// The [axis] argument defaults to [Axis.vertical]. - /// The [axisAlignment] defaults to 0.0, which centers the child along the - /// main axis during the transition. - SizeTransitionWithIntrinsicSize({ - this.axis = Axis.vertical, - required this.sizeFactor, - double axisAlignment = 0.0, - Widget? child, - Key? key, - }) : super( - key: key, - child: SizeTransition( - axis: axis, - sizeFactor: sizeFactor, - axisAlignment: axisAlignment, - child: child, - )); - - final Axis axis; - final Animation sizeFactor; - - @override - RenderSizeTransitionWithIntrinsicSize createRenderObject( - BuildContext context) { - return RenderSizeTransitionWithIntrinsicSize( - axis: axis, - sizeFactor: sizeFactor, - ); - } - - @override - void updateRenderObject(BuildContext context, - RenderSizeTransitionWithIntrinsicSize renderObject) { - renderObject - ..axis = axis - ..sizeFactor = sizeFactor; - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('axis', axis)); - properties - .add(DiagnosticsProperty>('sizeFactor', sizeFactor)); - } -} - -class RenderSizeTransitionWithIntrinsicSize extends RenderProxyBox { - Axis axis; - Animation sizeFactor; - - RenderSizeTransitionWithIntrinsicSize({ - this.axis = Axis.vertical, - required this.sizeFactor, - RenderBox? child, - }) : super(child); - - @override - double computeMinIntrinsicWidth(double height) { - final child = this.child; - if (child != null) { - double childWidth = child.getMinIntrinsicWidth(height); - return axis == Axis.horizontal - ? childWidth * sizeFactor.value - : childWidth; - } - return 0.0; - } - - @override - double computeMaxIntrinsicWidth(double height) { - final child = this.child; - if (child != null) { - double childWidth = child.getMaxIntrinsicWidth(height); - return axis == Axis.horizontal - ? childWidth * sizeFactor.value - : childWidth; - } - return 0.0; - } - - @override - double computeMinIntrinsicHeight(double width) { - final child = this.child; - if (child != null) { - double childHeight = child.getMinIntrinsicHeight(width); - return axis == Axis.vertical - ? childHeight * sizeFactor.value - : childHeight; - } - return 0.0; - } - - @override - double computeMaxIntrinsicHeight(double width) { - final child = this.child; - if (child != null) { - double childHeight = child.getMaxIntrinsicHeight(width); - return axis == Axis.vertical - ? childHeight * sizeFactor.value - : childHeight; - } - return 0.0; - } -} diff --git a/frontend/app_flowy/packages/appflowy_board/pubspec.yaml b/frontend/app_flowy/packages/appflowy_board/pubspec.yaml deleted file mode 100644 index 23c3b8be82db1..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/pubspec.yaml +++ /dev/null @@ -1,57 +0,0 @@ -name: appflowy_board -description: AppFlowyBoard is a board-style widget that consists of multi-groups. It supports drag and drop between different groups. -version: 0.0.9 -homepage: https://github.com/AppFlowy-IO/AppFlowy -repository: https://github.com/AppFlowy-IO/AppFlowy/tree/main/frontend/app_flowy/packages/appflowy_board - -environment: - sdk: ">=2.17.0 <3.0.0" - flutter: ">=1.17.0" - -dependencies: - flutter: - sdk: flutter - equatable: ^2.0.3 - provider: ^6.0.1 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.1 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/packages/appflowy_board/test/appflowy_board_test.dart b/frontend/app_flowy/packages/appflowy_board/test/appflowy_board_test.dart deleted file mode 100644 index ab73b3a234abf..0000000000000 --- a/frontend/app_flowy/packages/appflowy_board/test/appflowy_board_test.dart +++ /dev/null @@ -1 +0,0 @@ -void main() {} diff --git a/frontend/app_flowy/packages/appflowy_editor/.gitignore b/frontend/app_flowy/packages/appflowy_editor/.gitignore deleted file mode 100644 index 7501b909b4479..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -# 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/ -.packages -build/ -coverage/ diff --git a/frontend/app_flowy/packages/appflowy_editor/.metadata b/frontend/app_flowy/packages/appflowy_editor/.metadata deleted file mode 100644 index d3da16e67e698..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# 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: cd41fdd495f6944ecd3506c21e94c6567b073278 - channel: stable - -project_type: package diff --git a/frontend/app_flowy/packages/appflowy_editor/CHANGELOG.md b/frontend/app_flowy/packages/appflowy_editor/CHANGELOG.md deleted file mode 100644 index e791f4c9fe407..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/CHANGELOG.md +++ /dev/null @@ -1,36 +0,0 @@ -## 0.0.7 -* Refactor theme customizer, and support dark mode. -* Fix some bugs. - -## 0.0.6 -* Add three plugins: Code Block, LateX, and Horizontal rule. -* Support web platform. -* Support more markdown syntax conversions. - * `~ ~` to format text as strikethrough - * `_ _` to format text as italic - * \` \` to format text as code - * `[]()` to format text as link -* Fix some bugs. - -## 0.0.5 -* Support customize the hotkeys for a shortcut on different platforms. -* Support customize a theme. -* Support localizations. -* Support insert numbered lists. -* Fix some bugs. - -## 0.0.4 -* Support more shortcut events. -* Fix some bugs. -* Update the documentation. - -## 0.0.3 -* Support insert image. -* Support insert link. -* Fix some bugs. - -## 0.0.2 -Minor Updates to Documentation. - -## 0.0.1 -Initial Version of the library. diff --git a/frontend/app_flowy/packages/appflowy_editor/LICENSE b/frontend/app_flowy/packages/appflowy_editor/LICENSE deleted file mode 100644 index 29ebfa545f558..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/README.md b/frontend/app_flowy/packages/appflowy_editor/README.md deleted file mode 100644 index 2d1e2ae0474c7..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/README.md +++ /dev/null @@ -1,125 +0,0 @@ - - -

AppFlowy Editor

- -

A highly customizable rich-text editor for Flutter

- -

- Discord • - Twitter -

- -

- - - -

- -
- -
- -## Key Features - -* Build rich, intuitive editors -* Design and modify an ever expanding list of customizable features including - * components (such as form input controls, numbered lists, and rich text widgets) - * shortcut events - * themes - * menu options (**coming soon!**) -* [Test-coverage](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/documentation/testing.md) and ongoing maintenance by AppFlowy's core team and community of more than 1,000 builders - -## Getting Started - -Add the AppFlowy editor [Flutter package](https://docs.flutter.dev/development/packages-and-plugins/using-packages) to your environment. - -```shell -flutter pub add appflowy_editor -flutter pub get -``` - -## Creating Your First Editor - -Start by creating a new empty AppFlowyEditor object. - -```dart -final editorStyle = EditorStyle.defaultStyle(); -final editorState = EditorState.empty(); // an empty state -final editor = AppFlowyEditor( - editorState: editorState, - editorStyle: editorStyle, -); -``` - -You can also create an editor from a JSON object in order to configure your initial state. - -```dart -final json = ...; -final editorStyle = EditorStyle.defaultStyle(); -final editorState = EditorState(Document.fromJson(data)); -final editor = AppFlowyEditor( - editorState: editorState, - editorStyle: editorStyle, -); -``` - -> Note: The parameters `localizationsDelegates` need to be assigned in MaterialApp widget -```dart -MaterialApp( - localizationsDelegates: const [ - AppFlowyEditorLocalizations.delegate, - ], -); -``` - -To get a sense for how the AppFlowy Editor works, run our example: - -```shell -git clone https://github.com/AppFlowy-IO/AppFlowy.git -cd frontend/app_flowy/packages/appflowy_editor/example -flutter run -``` - -## Customizing Your Editor - -### Customizing Components - -Please refer to our documentation on customizing AppFlowy for a detailed discussion about [customizing components](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/documentation/customizing.md#customize-a-component). - -Below are some examples of component customizations: - - * [Checkbox Text](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart) demonstrates how to extend new styles based on existing rich text components - * [Image](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/network_image_node_widget.dart) demonstrates how to extend a new node and render it - * See further examples of [rich-text plugins](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text) - -### Customizing Shortcut Events - -Please refer to our documentation on customizing AppFlowy for a detailed discussion about [customizing shortcut events](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/documentation/customizing.md#customize-a-shortcut-event). - -Below are some examples of shortcut event customizations: - - * [BIUS](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/format_style_handler.dart) demonstrates how to make text bold/italic/underline/strikethrough through shortcut keys - * [Paste HTML](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart) gives you an idea on how to handle pasted styles through shortcut keys - * Need more examples? Check out [Internal key event handlers](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers) - -## Glossary -Please refer to the API documentation. - -## Contributing -Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated. - -Please look at [CONTRIBUTING.md](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/contributing-to-appflowy) for details. - -## License -Distributed under the AGPLv3 License. See [LICENSE](https://github.com/AppFlowy-IO/AppFlowy-Docs/blob/main/LICENSE) for more information. diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/check.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/check.svg deleted file mode 100644 index 8446cced9f680..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/check.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/clear.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/clear.svg deleted file mode 100644 index 7f303d737f0a6..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/clear.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg deleted file mode 100644 index 101cf34205b41..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/delete.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/delete.svg deleted file mode 100644 index b7f242542d942..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/delete.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/copy.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/copy.svg deleted file mode 100644 index 101cf34205b41..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/copy.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/delete.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/delete.svg deleted file mode 100644 index 5a3d972872da9..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/delete.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/divider.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/divider.svg deleted file mode 100644 index 3e57a6b000020..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/divider.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/point.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/point.svg deleted file mode 100644 index be88518d0da72..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/point.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/quote.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/quote.svg deleted file mode 100644 index 1393e7155662b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/quote.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/bulleted_list.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/bulleted_list.svg deleted file mode 100644 index 97a2e9c434ac9..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/bulleted_list.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/checkbox.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/checkbox.svg deleted file mode 100644 index 37f52c47ed16f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/checkbox.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h1.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h1.svg deleted file mode 100644 index 6e97796956d1f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h1.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h2.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h2.svg deleted file mode 100644 index 2c1d1d9d1ceea..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h2.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h3.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h3.svg deleted file mode 100644 index 8c6276263d7e3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h3.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/number.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/number.svg deleted file mode 100644 index 9d8b98d10d555..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/number.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/quote.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/quote.svg deleted file mode 100644 index 5c1cbb4a509fd..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/quote.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/text.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/text.svg deleted file mode 100644 index 7befa5080f62a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/text.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/bold.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/bold.svg deleted file mode 100644 index 85640695afd8b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/bold.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/bulleted_list.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/bulleted_list.svg deleted file mode 100644 index c2c962fa0be86..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/bulleted_list.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/center.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/center.svg deleted file mode 100644 index ea834a35bd763..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/center.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/code.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/code.svg deleted file mode 100644 index 9b96b3c2dcffd..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/code.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/divider.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/divider.svg deleted file mode 100644 index 3e57a6b000020..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/divider.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h1.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h1.svg deleted file mode 100644 index 8a87ece4f06fb..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h1.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h2.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h2.svg deleted file mode 100644 index 9ce394b0b18cf..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h2.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h3.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h3.svg deleted file mode 100644 index 43af128937242..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h3.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/highlight.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/highlight.svg deleted file mode 100644 index 697603a054e6f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/highlight.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/italic.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/italic.svg deleted file mode 100644 index 6b739a761f1b3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/italic.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/left.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/left.svg deleted file mode 100644 index b4f2d0101ed8c..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/left.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/link.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/link.svg deleted file mode 100644 index 279e7ac471f48..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/link.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/number_list.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/number_list.svg deleted file mode 100644 index 2db0ab3b64b66..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/number_list.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/quote.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/quote.svg deleted file mode 100644 index 8e55d9e2e35ab..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/quote.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/right.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/right.svg deleted file mode 100644 index 86a1facaac782..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/right.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/strikethrough.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/strikethrough.svg deleted file mode 100644 index b37bb9acc0f4c..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/strikethrough.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/underline.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/underline.svg deleted file mode 100644 index 933471e6a70d7..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/underline.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/uncheck.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/uncheck.svg deleted file mode 100644 index 6c487795c6ebc..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/uncheck.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/customizing.md b/frontend/app_flowy/packages/appflowy_editor/documentation/customizing.md deleted file mode 100644 index 98acc58f9809a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/documentation/customizing.md +++ /dev/null @@ -1,394 +0,0 @@ -# Customizing Editor Features - -## Customizing a Shortcut Event - -We will use a simple example to illustrate how to quickly add a shortcut event. - -In this example, text that starts and ends with an underscore ( \_ ) character will be rendered in italics for emphasis. So typing `_xxx_` will automatically be converted into _xxx_. - -Let's start with a blank document: - -```dart -@override -Widget build(BuildContext context) { - return Scaffold( - body: Container( - alignment: Alignment.topCenter, - child: AppFlowyEditor( - editorState: EditorState.empty(), - shortcutEvents: const [], - customBuilders: const {}, - ), - ), - ); -} -``` - -At this point, nothing magic will happen after typing `_xxx_`. - -![Before](./images/customize_a_shortcut_event_before.gif) - -To implement our shortcut event we will create a `ShortcutEvent` instance to handle an underscore input. - -We need to define `key` and `command` in a ShortCutEvent object to customize hotkeys. We recommend using the description of your event as a key. For example, if the underscore `_` is defined to make text italic, the key can be 'Underscore to italic'. - -> The command, made up of a single keyword such as `underscore` or a combination of keywords using the `+` sign in between to concatenate, is a condition that triggers a user-defined function. To see which keywords are available to define a command, please refer to [key_mapping.dart](../lib/src/service/shortcut_event/key_mapping.dart). -> If more than one commands trigger the same handler, then we use ',' to split them. For example, using CTRL and A or CMD and A to 'select all', we describe it as `cmd+a,ctrl+a`(case-insensitive). - -```dart -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; - -ShortcutEvent underscoreToItalicEvent = ShortcutEvent( - key: 'Underscore to italic', - command: 'shift+underscore', - handler: _underscoreToItalicHandler, -); - -ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) { - -}; -``` - -Then, we need to determine if the currently selected node is a `TextNode` and if the selection is collapsed. - -If so, we will continue. - -```dart -// ... -ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) { - // Obtain the selection and selected nodes of the current document through the 'selectionService' - // to determine whether the selection is collapsed and whether the selected node is a text node. - final selectionService = editorState.service.selectionService; - final selection = selectionService.currentSelection.value; - final textNodes = selectionService.currentSelectedNodes.whereType(); - if (selection == null || !selection.isSingle || textNodes.length != 1) { - return KeyEventResult.ignored; - } -``` - -Now, we deal with handling the underscore. - -Look for the position of the previous underscore and -1. if one is _not_ found, return without doing anything. -2. if one is found, the text enclosed within the two underscores will be formatted to display in italics. - -```dart -// ... -ShortcutEventHandler _underscoreToItalicHandler = (editorState, event) { - // ... - - final textNode = textNodes.first; - final text = textNode.toRawString(); - // Determine if an 'underscore' already exists in the text node and only once. - final firstUnderscore = text.indexOf('_'); - final lastUnderscore = text.lastIndexOf('_'); - if (firstUnderscore == -1 || - firstUnderscore != lastUnderscore || - firstUnderscore == selection.start.offset - 1) { - return KeyEventResult.ignored; - } - - // Delete the previous 'underscore', - // update the style of the text surrounded by the two underscores to 'italic', - // and update the cursor position. - final transaction = editorState.transaction - ..deleteText(textNode, firstUnderscore, 1) - ..formatText( - textNode, - firstUnderscore, - selection.end.offset - firstUnderscore - 1, - { - BuiltInAttributeKey.italic: true, - }, - ) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: selection.end.offset - 1, - ), - ); - editorState.apply(transaction); - - return KeyEventResult.handled; -}; -``` - -Now our 'underscore handler' function is done and the only task left is to inject it into the AppFlowyEditor. - -```dart -@override -Widget build(BuildContext context) { - return Scaffold( - body: Container( - alignment: Alignment.topCenter, - child: AppFlowyEditor( - editorState: EditorState.empty(), - customBuilders: const {}, - shortcutEvents: [ - underscoreToItalic, - ], - ), - ), - ); -} -``` - -![After](./images/customize_a_shortcut_event_after.gif) - -Check out the [complete code](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart) file of this example. - - -## Customizing a Component -We will use a simple example to show how to quickly add a custom component. - -In this example we will render an image from the network. - -Let's start with a blank document: - -```dart -@override -Widget build(BuildContext context) { - return Scaffold( - body: Container( - alignment: Alignment.topCenter, - child: AppFlowyEditor( - editorState: EditorState.empty(), - shortcutEvents: const [], - customBuilders: const {}, - ), - ), - ); -} -``` - -Next, we will choose a unique string for your custom node's type. - -We'll use `network_image` in this case. And we add `network_image_src` to the `attributes` to describe the link of the image. - -```JSON -{ - "type": "network_image", - "attributes": { - "network_image_src": "https://docs.flutter.dev/assets/images/dash/dash-fainting.gif" - } -} -``` - -Then, we create a class that inherits [NodeWidgetBuilder](../lib/src/service/render_plugin_service.dart). As shown in the autoprompt, we need to implement two functions: -1. one returns a widget -2. the other verifies the correctness of the [Node](../lib/src/core/document/node.dart). - - -```dart -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; - -class NetworkImageNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - throw UnimplementedError(); - } - - @override - NodeValidator get nodeValidator => throw UnimplementedError(); -} -``` - -Now, let's implement a simple image widget based on `Image`. - -Note that the `State` object that is returned by the `Widget` must implement [Selectable](../lib/src/render/selection/selectable.dart) using the `with` keyword. - -```dart -class _NetworkImageNodeWidget extends StatefulWidget { - const _NetworkImageNodeWidget({ - Key? key, - required this.node, - }) : super(key: key); - - final Node node; - - @override - State<_NetworkImageNodeWidget> createState() => - __NetworkImageNodeWidgetState(); -} - -class __NetworkImageNodeWidgetState extends State<_NetworkImageNodeWidget> - with Selectable { - RenderBox get _renderBox => context.findRenderObject() as RenderBox; - - @override - Widget build(BuildContext context) { - return Image.network( - widget.node.attributes['network_image_src'], - height: 200, - loadingBuilder: (context, child, loadingProgress) => - loadingProgress == null ? child : const CircularProgressIndicator(), - ); - } - - @override - Position start() => Position(path: widget.node.path, offset: 0); - - @override - Position end() => Position(path: widget.node.path, offset: 1); - - @override - Position getPositionInOffset(Offset start) => end(); - - @override - List getRectsInSelection(Selection selection) => - [Offset.zero & _renderBox.size]; - - @override - Selection getSelectionInRange(Offset start, Offset end) => Selection.single( - path: widget.node.path, - startOffset: 0, - endOffset: 1, - ); - - @override - Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset); -} -``` - -Finally, we return `_NetworkImageNodeWidget` in the `build` function of `NetworkImageNodeWidgetBuilder`... - -```dart -class NetworkImageNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return _NetworkImageNodeWidget( - key: context.node.key, - node: context.node, - ); - } - - @override - NodeValidator get nodeValidator => (node) { - return node.type == 'network_image' && - node.attributes['network_image_src'] is String; - }; -} -``` - -... and register `NetworkImageNodeWidgetBuilder` in the `AppFlowyEditor`. - -```dart -final editorState = EditorState( - document: StateTree.empty() - ..insert( - [0], - [ - TextNode.empty(), - Node.fromJson({ - 'type': 'network_image', - 'attributes': { - 'network_image_src': - 'https://docs.flutter.dev/assets/images/dash/dash-fainting.gif' - } - }) - ], - ), -); -return AppFlowyEditor( - editorState: editorState, - editorStyle: EditorStyle.defaultStyle(), - shortcutEvents: const [], - customBuilders: { - 'network_image': NetworkImageNodeWidgetBuilder(), - }, -); -``` - -![Whew!](./images/customize_a_component.gif) - -Check out the [complete code](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/network_image_node_widget.dart) file of this example. - -## Customizing a Theme (New Feature in 0.0.7) - -We will use a simple example to illustrate how to quickly customize a theme. - -Let's start with a blank document: - -```dart -@override -Widget build(BuildContext context) { - return Scaffold( - body: Container( - alignment: Alignment.topCenter, - child: AppFlowyEditor( - editorState: EditorState.empty(), - shortcutEvents: const [], - customBuilders: const {}, - ), - ), - ); -} -``` - -At this point, the editor looks like ... -![Before](./images/customizing_a_theme_before.png) - - -Next, we will customize the `EditorStyle`. - -```dart -ThemeData customizeEditorTheme(BuildContext context) { - final dark = EditorStyle.dark; - final editorStyle = dark.copyWith( - padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 150), - cursorColor: Colors.red.shade600, - selectionColor: Colors.yellow.shade600.withOpacity(0.5), - textStyle: GoogleFonts.poppins().copyWith( - fontSize: 14, - color: Colors.white, - ), - placeholderTextStyle: GoogleFonts.poppins().copyWith( - fontSize: 14, - color: Colors.grey.shade400, - ), - code: dark.code?.copyWith( - backgroundColor: Colors.lightBlue.shade200, - fontStyle: FontStyle.italic, - ), - highlightColorHex: '0x60FF0000', // red - ); - - final quote = QuotedTextPluginStyle.dark.copyWith( - textStyle: (_, __) => GoogleFonts.poppins().copyWith( - fontSize: 14, - color: Colors.blue.shade400, - fontStyle: FontStyle.italic, - fontWeight: FontWeight.w700, - ), - ); - - return Theme.of(context).copyWith(extensions: [ - editorStyle, - ...darkPlguinStyleExtension, - quote, - ]); -} -``` - -Now our 'customize style' function is done and the only task left is to inject it into the AppFlowyEditor. - -```dart -@override -Widget build(BuildContext context) { - return Scaffold( - body: Container( - alignment: Alignment.topCenter, - child: AppFlowyEditor( - editorState: EditorState.empty(), - themeData: customizeEditorTheme(context), - shortcutEvents: const [], - customBuilders: const {}, - ), - ), - ); -} -``` - -![After](./images/customizing_a_theme_after.png) \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/images/appflowy_editor_example.mp4 b/frontend/app_flowy/packages/appflowy_editor/documentation/images/appflowy_editor_example.mp4 deleted file mode 100644 index 3b1b97f847c86..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_editor/documentation/images/appflowy_editor_example.mp4 and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/images/customize_a_component.gif b/frontend/app_flowy/packages/appflowy_editor/documentation/images/customize_a_component.gif deleted file mode 100644 index e78f0b2f048f2..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_editor/documentation/images/customize_a_component.gif and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/images/customize_a_shortcut_event_after.gif b/frontend/app_flowy/packages/appflowy_editor/documentation/images/customize_a_shortcut_event_after.gif deleted file mode 100644 index e93035c70557c..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_editor/documentation/images/customize_a_shortcut_event_after.gif and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/images/customize_a_shortcut_event_before.gif b/frontend/app_flowy/packages/appflowy_editor/documentation/images/customize_a_shortcut_event_before.gif deleted file mode 100644 index 89d31b8d13241..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_editor/documentation/images/customize_a_shortcut_event_before.gif and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_theme_after.png b/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_theme_after.png deleted file mode 100644 index 3890829222a2c..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_theme_after.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_theme_before.png b/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_theme_before.png deleted file mode 100644 index 8eaac244a5aa8..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_theme_before.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/images/example.png b/frontend/app_flowy/packages/appflowy_editor/documentation/images/example.png deleted file mode 100644 index 9bd16610dccdc..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_editor/documentation/images/example.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/images/reporting_bugs.png b/frontend/app_flowy/packages/appflowy_editor/documentation/images/reporting_bugs.png deleted file mode 100644 index 5d4d7f61503b1..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_editor/documentation/images/reporting_bugs.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/testing.md b/frontend/app_flowy/packages/appflowy_editor/documentation/testing.md deleted file mode 100644 index c721510844572..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/documentation/testing.md +++ /dev/null @@ -1,145 +0,0 @@ -# Testing - -The directory structure of test files mirrors that of the code files, making it easy for us to map a file with the corresponding test and check if the test is updated. - -For an overview of testing best practices in Flutter applications, please refer to Flutter's [introduction to widget testing](https://docs.flutter.dev/cookbook/testing/widget/introduction) as well as their [introduction to unit testing](https://docs.flutter.dev/cookbook/testing/unit/introduction). -There you will learn how to do such things as such as simulate a click as well as leverage the `test` and `expect` functions. - -## Testing Basic Editor Functions - -The example code below shows how to construct a document that will be used in our testing. - -```dart -const text = 'Welcome to Appflowy 😁'; -// Get the instance of the editor. -final editor = tester.editor; - -// Insert an empty text node. -editor.insertEmptyTextNode(); - -// Insert a text node with the text string we defined earlier. -editor.insertTextNode(text); - -// Insert the same text, but with the heading style. -editor.insertTextNode(text, attributes: { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading, - BuiltInAttributeKey.heading: BuiltInAttributeKey.h1, -}); - -// Insert our text with the bulleted list style and the bold style. -// If you want to modify the style of the inserted text, you need to use the Delta parameter. -editor.insertTextNode( - '', - attributes: { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList, - }, - delta: Delta([ - TextInsert(text, {BuiltInAttributeKey.bold: true}), - ]), -); -``` - -The `startTesting` function of the editor must be called before you begin your test. - -```dart -await editor.startTesting(); -``` - -Get the number of nodes in the document. - -```dart -final length = editor.documentLength; -print(length); -``` - -Get the node of a defined path. In this case we are getting the first node of the document which is the text "Welcome to Appflowy 😁". - -```dart -final firstTextNode = editor.nodeAtPath([0]) as TextNode; -``` - -Update the [Selection](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart) so that our text "Welcome to Appflowy 😁" is selected. We will start our selection from the beginning of the string. - -```dart -await editor.updateSelection( - Selection.single(path: firstTextNode.path, startOffset: 0), -); -``` - -Get the current selection. - -```dart -final selection = editor.documentSelection; -print(selection); -``` - -Next we will simulate the input of a shortcut key being pressed that will select all the text. - -```dart -// Meta + A. -await editor.pressLogicKey(LogicalKeyboardKey.keyA, isMetaPressed: true); -// Meta + shift + S. -await editor.pressLogicKey( - LogicalKeyboardKey.keyS, - isMetaPressed: true, - isShiftPressed: true, -); -``` - -We will then simulate text input. - -```dart -// Insert 'Hello World' at the beginning of the first node. -editor.insertText(firstTextNode, 'Hello World', 0); -``` - -Once the text has been added, we can get information about the text node. - -```dart -// Get the text of the first text node as plain text -final textAfterInserted = firstTextNode.toRawString(); -print(textAfterInserted); -// Get the attributes of the text node -final attributes = firstTextNode.attributes; -print(attributes); -``` - -## A Complete Code Example - -In the example code below we are going to test `select_all_handler.dart` by inserting 100 lines of text that read "Welcome to Appflowy 😁" and then simulating the "selectAll" shortcut key being pressed. - -Afterwards, we will `expect` that the current selection of the editor is equal to the selection of all the lines that were generated. - -```dart -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('select_all_handler_test.dart', () { - testWidgets('Presses Command + A in the document', (tester) async { - const lines = 100; - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor; - for (var i = 0; i < lines; i++) { - editor.insertTextNode(text); - } - await editor.startTesting(); - await editor.pressLogicKey(LogicalKeyboardKey.keyA, isMetaPressed: true); - - expect( - editor.documentSelection, - Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [lines - 1], offset: text.length), - ), - ); - }); - }); -} -``` diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/translation.md b/frontend/app_flowy/packages/appflowy_editor/documentation/translation.md deleted file mode 100644 index 54c5900bc3d0b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/documentation/translation.md +++ /dev/null @@ -1,16 +0,0 @@ -# Translate AppFlowy Editor - -You can help Appflowy Editor in supporting various languages by contributing. Follow the steps below sequentially to contribute translations. - -## Steps to modify an existing translation -Translation files are located in: `frontend/app_flowy/packages/appflowy_editor/lib/l10n/` -1. Install the Visual Studio Code plugin: [Flutter intl](https://marketplace.visualstudio.com/items?itemName=localizely.flutter-intl) -2. Modify the specific translation file. -3. Save the file and the translation will be generated automatically. - -## Steps to add new language -Translation files are located in: `frontend/app_flowy/packages/appflowy_editor/lib/l10n/` -1. Install the Visual Studio Code plugin: [Flutter intl](https://marketplace.visualstudio.com/items?itemName=localizely.flutter-intl) -2. Copy the `intl_en.arb` as a base translation and rename the new file to `intl_.arb` -3. Modify the new translation file. -4. Save the file and the translation will be generated automatically. \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/example/.firebaserc b/frontend/app_flowy/packages/appflowy_editor/example/.firebaserc deleted file mode 100644 index 06fcc074c4b07..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/.firebaserc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "projects": { - "default": "appflowy-editor" - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/.metadata b/frontend/app_flowy/packages/appflowy_editor/example/.metadata deleted file mode 100644 index ed0b5185fb867..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/.metadata +++ /dev/null @@ -1,45 +0,0 @@ -# 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. - -version: - revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - channel: stable - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - - platform: android - create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - - platform: ios - create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - - platform: linux - create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - - platform: macos - create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - - platform: web - create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - - platform: windows - create_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - base_revision: cd41fdd495f6944ecd3506c21e94c6567b073278 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/frontend/app_flowy/packages/appflowy_editor/example/README.md b/frontend/app_flowy/packages/appflowy_editor/example/README.md deleted file mode 100644 index 2b3fce4c86a59..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# example - -A new Flutter project. - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. diff --git a/frontend/app_flowy/packages/appflowy_editor/example/analysis_options.yaml b/frontend/app_flowy/packages/appflowy_editor/example/analysis_options.yaml deleted file mode 100644 index 61b6c4de17c96..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/analysis_options.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/.gitignore b/frontend/app_flowy/packages/appflowy_editor/example/android/.gitignore deleted file mode 100644 index 6f568019d3c69..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/android/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java - -# Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app -key.properties -**/*.keystore -**/*.jks diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/values-night/styles.xml b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be745f9f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/values/styles.xml b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef88056edd..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/build.gradle b/frontend/app_flowy/packages/appflowy_editor/example/android/build.gradle deleted file mode 100644 index 83ae220041c7e..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/android/build.gradle +++ /dev/null @@ -1,31 +0,0 @@ -buildscript { - ext.kotlin_version = '1.6.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.1.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -task clean(type: Delete) { - delete rootProject.buildDir -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/gradle/wrapper/gradle-wrapper.properties b/frontend/app_flowy/packages/appflowy_editor/example/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index cc5527d781a7b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,6 +0,0 @@ -#Fri Jun 23 08:50:38 CEST 2017 -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip diff --git a/frontend/app_flowy/packages/appflowy_editor/example/assets/big_document.json b/frontend/app_flowy/packages/appflowy_editor/example/assets/big_document.json deleted file mode 100644 index 80647bf97e57f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/assets/big_document.json +++ /dev/null @@ -1,41960 +0,0 @@ -{ - "document": { - "type": "editor", - "attributes": {}, - "children": [ - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - } - ] - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/assets/example.json b/frontend/app_flowy/packages/appflowy_editor/example/assets/example.json deleted file mode 100644 index 39994cdfb2ced..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/assets/example.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "document": { - "type": "editor", - "children": [ - { - "type": "text", - "attributes": { - "subtype": "heading", - "heading": "h2" - }, - "delta": [ - { "insert": "👋 " }, - { "insert": "Welcome to ", "attributes": { "bold": true } }, - { - "insert": "AppFlowy Editor", - "attributes": { - "href": "appflowy.io", - "italic": true, - "bold": true - } - } - ] - }, - { "type": "text", "delta": [] }, - { - "type": "text", - "delta": [ - { "insert": "AppFlowy Editor is a " }, - { "insert": "highly customizable", "attributes": { "bold": true } }, - { "insert": " " }, - { "insert": "rich-text editor", "attributes": { "italic": true } }, - { "insert": " for " }, - { "insert": "Flutter", "attributes": { "underline": true } } - ] - }, - { - "type": "text", - "attributes": { "checkbox": true, "subtype": "checkbox" }, - "delta": [{ "insert": "Customizable" }] - }, - { - "type": "text", - "attributes": { "checkbox": true, "subtype": "checkbox" }, - "delta": [{ "insert": "Test-covered" }] - }, - { - "type": "text", - "attributes": { "checkbox": false, "subtype": "checkbox" }, - "delta": [{ "insert": "more to come!" }] - }, - { "type": "text", "delta": [] }, - { - "type": "text", - "attributes": { "subtype": "quote" }, - "delta": [{ "insert": "Here is an example you can give a try" }] - }, - { "type": "text", "delta": [] }, - { - "type": "text", - "delta": [ - { "insert": "You can also use " }, - { - "insert": "AppFlowy Editor", - "attributes": { - "italic": true, - "bold": true, - "backgroundColor": "0x6000BCF0" - } - }, - { "insert": " as a component to build your own app." } - ] - }, - { "type": "text", "delta": [] }, - { - "type": "text", - "attributes": { "subtype": "bulleted-list" }, - "delta": [{ "insert": "Use / to insert blocks" }] - }, - { - "type": "text", - "attributes": { "subtype": "bulleted-list" }, - "delta": [ - { - "insert": "Select text to trigger to the toolbar to format your notes." - } - ] - }, - { "type": "text", "delta": [] }, - { - "type": "text", - "delta": [ - { - "insert": "If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!" - } - ] - } - ] - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/firebase.json b/frontend/app_flowy/packages/appflowy_editor/example/firebase.json deleted file mode 100644 index bba899ee87ff7..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/firebase.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "hosting": { - "public": "build/web", - "ignore": [ - "firebase.json", - "**/.*", - "**/node_modules/**" - ], - "rewrites": [ - { - "source": "**", - "destination": "/index.html" - } - ], - "headers": [ { - "source": "**/*.@(png|jpg|jpeg|gif)", - "headers": [ { - "key": "Access-Control-Allow-Origin", - "value": "*" - } ] - } ] - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/.gitignore b/frontend/app_flowy/packages/appflowy_editor/example/ios/.gitignore deleted file mode 100644 index 7a7f9873ad7dc..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/ios/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Flutter/AppFrameworkInfo.plist b/frontend/app_flowy/packages/appflowy_editor/example/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 8d4492f977adc..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - MinimumOSVersion - 9.0 - - diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.pbxproj b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 813642b9a4bea..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,484 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 50; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 1300; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 446D3AAR7E; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.example; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 446D3AAR7E; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.example; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 446D3AAR7E; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.example; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index c87d15a335208..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Info.plist b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Info.plist deleted file mode 100644 index 907f329fe0923..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Info.plist +++ /dev/null @@ -1,49 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Example - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - example - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - CADisableMinimumFrameDurationOnPhone - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/expandable_floating_action_button.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/expandable_floating_action_button.dart deleted file mode 100644 index 01da3ab5936b4..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/expandable_floating_action_button.dart +++ /dev/null @@ -1,234 +0,0 @@ -import 'dart:math' as math; - -import 'package:flutter/material.dart'; - -// copy from https://docs.flutter.dev/cookbook/effects/expandable-fab -@immutable -class ExpandableFab extends StatefulWidget { - const ExpandableFab({ - super.key, - this.initialOpen, - required this.distance, - required this.children, - }); - - final bool? initialOpen; - final double distance; - final List children; - - @override - State createState() => _ExpandableFabState(); -} - -class _ExpandableFabState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - late final Animation _expandAnimation; - bool _open = false; - - @override - void initState() { - super.initState(); - _open = widget.initialOpen ?? false; - _controller = AnimationController( - value: _open ? 1.0 : 0.0, - duration: const Duration(milliseconds: 250), - vsync: this, - ); - _expandAnimation = CurvedAnimation( - curve: Curves.fastOutSlowIn, - reverseCurve: Curves.easeOutQuad, - parent: _controller, - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _toggle() { - setState(() { - _open = !_open; - if (_open) { - _controller.forward(); - } else { - _controller.reverse(); - } - }); - } - - @override - Widget build(BuildContext context) { - return SizedBox.expand( - child: Stack( - alignment: Alignment.bottomRight, - clipBehavior: Clip.none, - children: [ - _buildTapToCloseFab(), - ..._buildExpandingActionButtons(), - _buildTapToOpenFab(), - ], - ), - ); - } - - Widget _buildTapToCloseFab() { - return SizedBox( - width: 56.0, - height: 56.0, - child: Center( - child: Material( - shape: const CircleBorder(), - clipBehavior: Clip.antiAlias, - elevation: 4.0, - child: InkWell( - onTap: _toggle, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Icon( - Icons.close, - color: Theme.of(context).primaryColor, - ), - ), - ), - ), - ), - ); - } - - List _buildExpandingActionButtons() { - final children = []; - final count = widget.children.length; - final step = 90.0 / (count - 1); - for (var i = 0, angleInDegrees = 0.0; - i < count; - i++, angleInDegrees += step) { - children.add( - _ExpandingActionButton( - directionInDegrees: angleInDegrees, - maxDistance: widget.distance, - progress: _expandAnimation, - child: widget.children[i], - ), - ); - } - return children; - } - - Widget _buildTapToOpenFab() { - return IgnorePointer( - ignoring: _open, - child: AnimatedContainer( - transformAlignment: Alignment.center, - transform: Matrix4.diagonal3Values( - _open ? 0.7 : 1.0, - _open ? 0.7 : 1.0, - 1.0, - ), - duration: const Duration(milliseconds: 250), - curve: const Interval(0.0, 0.5, curve: Curves.easeOut), - child: AnimatedOpacity( - opacity: _open ? 0.0 : 1.0, - curve: const Interval(0.25, 1.0, curve: Curves.easeInOut), - duration: const Duration(milliseconds: 250), - child: FloatingActionButton( - onPressed: _toggle, - child: const Icon(Icons.create), - ), - ), - ), - ); - } -} - -@immutable -class _ExpandingActionButton extends StatelessWidget { - const _ExpandingActionButton({ - required this.directionInDegrees, - required this.maxDistance, - required this.progress, - required this.child, - }); - - final double directionInDegrees; - final double maxDistance; - final Animation progress; - final Widget child; - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: progress, - builder: (context, child) { - final offset = Offset.fromDirection( - directionInDegrees * (math.pi / 180.0), - progress.value * maxDistance, - ); - return Positioned( - right: 4.0 + offset.dx, - bottom: 4.0 + offset.dy, - child: Transform.rotate( - angle: (1.0 - progress.value) * math.pi / 2, - child: child!, - ), - ); - }, - child: FadeTransition( - opacity: progress, - child: child, - ), - ); - } -} - -@immutable -class ActionButton extends StatelessWidget { - const ActionButton({ - super.key, - this.onPressed, - required this.icon, - }); - - final VoidCallback? onPressed; - final Widget icon; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Material( - shape: const CircleBorder(), - clipBehavior: Clip.antiAlias, - color: theme.colorScheme.secondary, - elevation: 4.0, - child: IconButton( - onPressed: onPressed, - icon: icon, - color: theme.colorScheme.onSecondary, - ), - ); - } -} - -@immutable -class FakeItem extends StatelessWidget { - const FakeItem({ - super.key, - required this.isBig, - }); - - final bool isBig; - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 24.0), - height: isBig ? 128.0 : 36.0, - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8.0)), - color: Colors.grey.shade300, - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart deleted file mode 100644 index a81b823f37769..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart +++ /dev/null @@ -1,250 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:example/plugin/editor_theme.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:example/plugin/code_block_node_widget.dart'; -import 'package:example/plugin/horizontal_rule_node_widget.dart'; -import 'package:example/plugin/tex_block_node_widget.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:universal_html/html.dart' as html; - -import 'package:appflowy_editor/appflowy_editor.dart'; - -import 'expandable_floating_action_button.dart'; - -void main() { - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return const MaterialApp( - localizationsDelegates: [ - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - AppFlowyEditorLocalizations.delegate, - ], - supportedLocales: [Locale('en', 'US')], - debugShowCheckedModeBanner: false, - home: MyHomePage(title: 'AppFlowyEditor Example'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({Key? key, required this.title}) : super(key: key); - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _pageIndex = 0; - EditorState? _editorState; - bool darkMode = false; - Future? _jsonString; - - ThemeData? _editorThemeData; - - @override - Widget build(BuildContext context) { - return Scaffold( - extendBodyBehindAppBar: true, - body: _buildEditor(context), - floatingActionButton: _buildExpandableFab(), - ); - } - - Widget _buildEditor(BuildContext context) { - if (_jsonString != null) { - return _buildEditorWithJsonString(_jsonString!); - } - if (_pageIndex == 0) { - return _buildEditorWithJsonString( - rootBundle.loadString('assets/example.json'), - ); - } else if (_pageIndex == 1) { - return _buildEditorWithJsonString( - Future.value( - jsonEncode(EditorState.empty().document.toJson()), - ), - ); - } - throw UnimplementedError(); - } - - Widget _buildEditorWithJsonString(Future jsonString) { - return FutureBuilder( - future: jsonString, - builder: (_, snapshot) { - if (snapshot.hasData && - snapshot.connectionState == ConnectionState.done) { - _editorState ??= EditorState( - document: Document.fromJson( - Map.from( - json.decode(snapshot.data!), - ), - ), - ); - _editorState!.logConfiguration - ..level = LogLevel.all - ..handler = (message) { - debugPrint(message); - }; - _editorState!.transactionStream.listen((event) { - debugPrint('Transaction: ${event.toJson()}'); - }); - _editorThemeData ??= Theme.of(context).copyWith(extensions: [ - if (darkMode) ...darkEditorStyleExtension, - if (darkMode) ...darkPlguinStyleExtension, - if (!darkMode) ...lightEditorStyleExtension, - if (!darkMode) ...lightPlguinStyleExtension, - ]); - return Container( - color: darkMode ? Colors.black : Colors.white, - width: MediaQuery.of(context).size.width, - child: AppFlowyEditor( - editorState: _editorState!, - editable: true, - autoFocus: _editorState!.document.isEmpty, - themeData: _editorThemeData, - customBuilders: { - 'text/code_block': CodeBlockNodeWidgetBuilder(), - 'tex': TeXBlockNodeWidgetBuidler(), - 'horizontal_rule': HorizontalRuleWidgetBuilder(), - }, - shortcutEvents: [ - enterInCodeBlock, - ignoreKeysInCodeBlock, - insertHorizontalRule, - ], - selectionMenuItems: [ - codeBlockMenuItem, - teXBlockMenuItem, - horizontalRuleMenuItem, - ], - ), - ); - } else { - return const Center( - child: CircularProgressIndicator(), - ); - } - }, - ); - } - - Widget _buildExpandableFab() { - return ExpandableFab( - distance: 112.0, - children: [ - ActionButton( - icon: const Icon(Icons.abc), - onPressed: () => _switchToPage(0), - ), - ActionButton( - icon: const Icon(Icons.abc), - onPressed: () => _switchToPage(1), - ), - ActionButton( - icon: const Icon(Icons.print), - onPressed: () => _exportDocument(_editorState!), - ), - ActionButton( - icon: const Icon(Icons.import_export), - onPressed: () async => await _importDocument(), - ), - ActionButton( - icon: const Icon(Icons.dark_mode), - onPressed: () { - setState(() { - darkMode = !darkMode; - }); - }, - ), - ActionButton( - icon: const Icon(Icons.color_lens), - onPressed: () { - setState(() { - _editorThemeData = customizeEditorTheme(context); - darkMode = true; - }); - }, - ), - ], - ); - } - - void _exportDocument(EditorState editorState) async { - final document = editorState.document.toJson(); - final json = jsonEncode(document); - if (kIsWeb) { - final blob = html.Blob([json], 'text/plain', 'native'); - html.AnchorElement( - href: html.Url.createObjectUrlFromBlob(blob).toString(), - ) - ..setAttribute('download', 'editor.json') - ..click(); - } else { - final directory = await getTemporaryDirectory(); - final path = directory.path; - final file = File('$path/editor.json'); - await file.writeAsString(json); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('The document is saved to the ${file.path}'), - ), - ); - } - } - } - - Future _importDocument() async { - if (kIsWeb) { - final result = await FilePicker.platform.pickFiles( - allowMultiple: false, - allowedExtensions: ['json'], - type: FileType.custom, - ); - final bytes = result?.files.first.bytes; - if (bytes != null) { - final jsonString = const Utf8Decoder().convert(bytes); - setState(() { - _editorState = null; - _jsonString = Future.value(jsonString); - }); - } - } else { - final directory = await getTemporaryDirectory(); - final path = '${directory.path}/editor.json'; - final file = File(path); - setState(() { - _editorState = null; - _jsonString = file.readAsString(); - }); - } - } - - void _switchToPage(int pageIndex) { - if (pageIndex != _pageIndex) { - setState(() { - _editorThemeData = null; - _editorState = null; - _pageIndex = pageIndex; - }); - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart deleted file mode 100644 index 5ecf4d4ed814b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart +++ /dev/null @@ -1,280 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; -import 'package:highlight/highlight.dart' as highlight; -import 'package:highlight/languages/all.dart'; - -ShortcutEvent enterInCodeBlock = ShortcutEvent( - key: 'Enter in code block', - command: 'enter', - handler: _enterInCodeBlockHandler, -); - -ShortcutEvent ignoreKeysInCodeBlock = ShortcutEvent( - key: 'White space in code block', - command: 'space,slash,shift+underscore', - handler: _ignorekHandler, -); - -ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) { - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - final codeBlockNode = - nodes.whereType().where((node) => node.id == 'text/code_block'); - if (codeBlockNode.length != 1 || selection == null) { - return KeyEventResult.ignored; - } - if (selection.isCollapsed) { - final transaction = editorState.transaction - ..insertText(codeBlockNode.first, selection.end.offset, '\n'); - editorState.apply(transaction); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; -}; - -ShortcutEventHandler _ignorekHandler = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final codeBlockNodes = - nodes.whereType().where((node) => node.id == 'text/code_block'); - if (codeBlockNodes.length == 1) { - return KeyEventResult.skipRemainingHandlers; - } - return KeyEventResult.ignored; -}; - -SelectionMenuItem codeBlockMenuItem = SelectionMenuItem( - name: () => 'Code Block', - icon: (_, __) => const Icon( - Icons.abc, - color: Colors.black, - size: 18.0, - ), - keywords: ['code block'], - handler: (editorState, _, __) { - final selection = - editorState.service.selectionService.currentSelection.value; - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); - if (selection == null || textNodes.isEmpty) { - return; - } - if (textNodes.first.toPlainText().isEmpty) { - final transaction = editorState.transaction - ..updateNode(textNodes.first, { - 'subtype': 'code_block', - 'theme': 'vs', - 'language': null, - }) - ..afterSelection = selection; - editorState.apply(transaction); - } else { - final transaction = editorState.transaction - ..insertNode( - selection.end.path.next, - TextNode( - children: LinkedList(), - attributes: { - 'subtype': 'code_block', - 'theme': 'vs', - 'language': null, - }, - delta: Delta()..insert('\n'), - ), - ) - ..afterSelection = selection; - editorState.apply(transaction); - } - }, -); - -class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return _CodeBlockNodeWidge( - key: context.node.key, - textNode: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => (node) { - return node is TextNode && node.attributes['theme'] is String; - }; -} - -class _CodeBlockNodeWidge extends StatefulWidget { - const _CodeBlockNodeWidge({ - Key? key, - required this.textNode, - required this.editorState, - }) : super(key: key); - - final TextNode textNode; - final EditorState editorState; - - @override - State<_CodeBlockNodeWidge> createState() => __CodeBlockNodeWidgeState(); -} - -class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge> - with SelectableMixin, DefaultSelectable { - final _richTextKey = GlobalKey(debugLabel: 'code_block_text'); - final _padding = const EdgeInsets.only(left: 20, top: 20, bottom: 20); - String? get _language => widget.textNode.attributes['language'] as String?; - String? _detectLanguage; - - @override - SelectableMixin get forward => - _richTextKey.currentState as SelectableMixin; - - @override - GlobalKey>? get iconKey => null; - - @override - Offset get baseOffset => super.baseOffset + _padding.topLeft; - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - _buildCodeBlock(context), - _buildSwitchCodeButton(context), - ], - ); - } - - Widget _buildCodeBlock(BuildContext context) { - final result = highlight.highlight.parse( - widget.textNode.toPlainText(), - language: _language, - autoDetection: _language == null, - ); - _detectLanguage = _language ?? result.language; - final code = result.nodes; - final codeTextSpan = _convert(code!); - return Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8.0)), - color: Colors.grey.withOpacity(0.1), - ), - padding: _padding, - width: MediaQuery.of(context).size.width, - child: FlowyRichText( - key: _richTextKey, - textNode: widget.textNode, - editorState: widget.editorState, - textSpanDecorator: (textSpan) => TextSpan( - style: widget.editorState.editorStyle.textStyle, - children: codeTextSpan, - ), - ), - ); - } - - Widget _buildSwitchCodeButton(BuildContext context) { - return Positioned( - top: -5, - right: 0, - child: DropdownButton( - value: _detectLanguage, - onChanged: (value) { - final transaction = widget.editorState.transaction - ..updateNode(widget.textNode, { - 'language': value, - }); - widget.editorState.apply(transaction); - }, - items: allLanguages.keys.map>((String value) { - return DropdownMenuItem( - value: value, - child: Text( - value, - style: const TextStyle(fontSize: 12.0), - ), - ); - }).toList(growable: false), - ), - ); - } - - // Copy from flutter.highlight package. - // https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart - List _convert(List nodes) { - List spans = []; - var currentSpans = spans; - List> stack = []; - - _traverse(highlight.Node node) { - if (node.value != null) { - currentSpans.add(node.className == null - ? TextSpan(text: node.value) - : TextSpan( - text: node.value, - style: _builtInCodeBlockTheme[node.className!])); - } else if (node.children != null) { - List tmp = []; - currentSpans.add(TextSpan( - children: tmp, style: _builtInCodeBlockTheme[node.className!])); - stack.add(currentSpans); - currentSpans = tmp; - - for (var n in node.children!) { - _traverse(n); - if (n == node.children!.last) { - currentSpans = stack.isEmpty ? spans : stack.removeLast(); - } - } - } - } - - for (var node in nodes) { - _traverse(node); - } - - return spans; - } -} - -const _builtInCodeBlockTheme = { - 'root': - TextStyle(backgroundColor: Color(0xffffffff), color: Color(0xff000000)), - 'comment': TextStyle(color: Color(0xff007400)), - 'quote': TextStyle(color: Color(0xff007400)), - 'tag': TextStyle(color: Color(0xffaa0d91)), - 'attribute': TextStyle(color: Color(0xffaa0d91)), - 'keyword': TextStyle(color: Color(0xffaa0d91)), - 'selector-tag': TextStyle(color: Color(0xffaa0d91)), - 'literal': TextStyle(color: Color(0xffaa0d91)), - 'name': TextStyle(color: Color(0xffaa0d91)), - 'variable': TextStyle(color: Color(0xff3F6E74)), - 'template-variable': TextStyle(color: Color(0xff3F6E74)), - 'code': TextStyle(color: Color(0xffc41a16)), - 'string': TextStyle(color: Color(0xffc41a16)), - 'meta-string': TextStyle(color: Color(0xffc41a16)), - 'regexp': TextStyle(color: Color(0xff0E0EFF)), - 'link': TextStyle(color: Color(0xff0E0EFF)), - 'title': TextStyle(color: Color(0xff1c00cf)), - 'symbol': TextStyle(color: Color(0xff1c00cf)), - 'bullet': TextStyle(color: Color(0xff1c00cf)), - 'number': TextStyle(color: Color(0xff1c00cf)), - 'section': TextStyle(color: Color(0xff643820)), - 'meta': TextStyle(color: Color(0xff643820)), - 'type': TextStyle(color: Color(0xff5c2699)), - 'built_in': TextStyle(color: Color(0xff5c2699)), - 'builtin-name': TextStyle(color: Color(0xff5c2699)), - 'params': TextStyle(color: Color(0xff5c2699)), - 'attr': TextStyle(color: Color(0xff836C28)), - 'subst': TextStyle(color: Color(0xff000000)), - 'formula': TextStyle( - backgroundColor: Color(0xffeeeeee), fontStyle: FontStyle.italic), - 'addition': TextStyle(backgroundColor: Color(0xffbaeeba)), - 'deletion': TextStyle(backgroundColor: Color(0xffffc8bd)), - 'selector-id': TextStyle(color: Color(0xff9b703f)), - 'selector-class': TextStyle(color: Color(0xff9b703f)), - 'doctag': TextStyle(fontWeight: FontWeight.bold), - 'strong': TextStyle(fontWeight: FontWeight.bold), - 'emphasis': TextStyle(fontStyle: FontStyle.italic), -}; diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/editor_theme.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/editor_theme.dart deleted file mode 100644 index 65a8f868faf61..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/editor_theme.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; - -ThemeData customizeEditorTheme(BuildContext context) { - final dark = EditorStyle.dark; - final editorStyle = dark.copyWith( - padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 150), - cursorColor: Colors.red.shade600, - selectionColor: Colors.yellow.shade600.withOpacity(0.5), - textStyle: GoogleFonts.poppins().copyWith( - fontSize: 14, - color: Colors.white, - ), - placeholderTextStyle: GoogleFonts.poppins().copyWith( - fontSize: 14, - color: Colors.grey.shade400, - ), - code: dark.code?.copyWith( - backgroundColor: Colors.lightBlue.shade200, - fontStyle: FontStyle.italic, - ), - highlightColorHex: '0x60FF0000', // red - ); - - final quote = QuotedTextPluginStyle.dark.copyWith( - textStyle: (_, __) => GoogleFonts.poppins().copyWith( - fontSize: 14, - color: Colors.blue.shade400, - fontStyle: FontStyle.italic, - fontWeight: FontWeight.w700, - ), - ); - - return Theme.of(context).copyWith(extensions: [ - editorStyle, - ...darkPlguinStyleExtension, - quote, - ]); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/horizontal_rule_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/horizontal_rule_node_widget.dart deleted file mode 100644 index 0ca302de18f14..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/horizontal_rule_node_widget.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; - -ShortcutEvent insertHorizontalRule = ShortcutEvent( - key: 'Horizontal rule', - command: 'Minus', - handler: _insertHorzaontalRule, -); - -ShortcutEventHandler _insertHorzaontalRule = (editorState, event) { - final selection = editorState.service.selectionService.currentSelection.value; - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); - if (textNodes.length != 1 || selection == null) { - return KeyEventResult.ignored; - } - final textNode = textNodes.first; - if (textNode.toPlainText() == '--') { - final transaction = editorState.transaction - ..deleteText(textNode, 0, 2) - ..insertNode( - textNode.path, - Node( - type: 'horizontal_rule', - children: LinkedList(), - attributes: {}, - ), - ) - ..afterSelection = - Selection.single(path: textNode.path.next, startOffset: 0); - editorState.apply(transaction); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; -}; - -SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem( - name: () => 'Horizontal rule', - icon: (_, __) => const Icon( - Icons.horizontal_rule, - color: Colors.black, - size: 18.0, - ), - keywords: ['horizontal rule'], - handler: (editorState, _, __) { - final selection = - editorState.service.selectionService.currentSelection.value; - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); - if (selection == null || textNodes.isEmpty) { - return; - } - final textNode = textNodes.first; - if (textNode.toPlainText().isEmpty) { - final transaction = editorState.transaction - ..insertNode( - textNode.path, - Node( - type: 'horizontal_rule', - children: LinkedList(), - attributes: {}, - ), - ) - ..afterSelection = - Selection.single(path: textNode.path.next, startOffset: 0); - editorState.apply(transaction); - } else { - final transaction = editorState.transaction - ..insertNode( - selection.end.path.next, - TextNode( - children: LinkedList(), - attributes: { - 'subtype': 'horizontal_rule', - }, - delta: Delta()..insert('---'), - ), - ) - ..afterSelection = selection; - editorState.apply(transaction); - } - }, -); - -class HorizontalRuleWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return _HorizontalRuleWidget( - key: context.node.key, - node: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => (node) { - return true; - }; -} - -class _HorizontalRuleWidget extends StatefulWidget { - const _HorizontalRuleWidget({ - Key? key, - required this.node, - required this.editorState, - }) : super(key: key); - - final Node node; - final EditorState editorState; - - @override - State<_HorizontalRuleWidget> createState() => __HorizontalRuleWidgetState(); -} - -class __HorizontalRuleWidgetState extends State<_HorizontalRuleWidget> - with SelectableMixin { - RenderBox get _renderBox => context.findRenderObject() as RenderBox; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Container( - height: 1, - color: Colors.grey, - ), - ); - } - - @override - Position start() => Position(path: widget.node.path, offset: 0); - - @override - Position end() => Position(path: widget.node.path, offset: 1); - - @override - Position getPositionInOffset(Offset start) => end(); - - @override - bool get shouldCursorBlink => false; - - @override - CursorStyle get cursorStyle => CursorStyle.borderLine; - - @override - Rect? getCursorRectInPosition(Position position) { - final size = _renderBox.size; - return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height); - } - - @override - List getRectsInSelection(Selection selection) => - [Offset.zero & _renderBox.size]; - - @override - Selection getSelectionInRange(Offset start, Offset end) => Selection.single( - path: widget.node.path, - startOffset: 0, - endOffset: 1, - ); - - @override - Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/network_image_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/network_image_node_widget.dart deleted file mode 100644 index 395fc175f43d7..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/network_image_node_widget.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; - -class NetworkImageNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return _NetworkImageNodeWidget( - key: context.node.key, - node: context.node, - ); - } - - @override - NodeValidator get nodeValidator => (node) { - return node.type == 'network_image' && - node.attributes['network_image_src'] is String; - }; -} - -class _NetworkImageNodeWidget extends StatefulWidget { - const _NetworkImageNodeWidget({ - Key? key, - required this.node, - }) : super(key: key); - - final Node node; - - @override - State<_NetworkImageNodeWidget> createState() => - __NetworkImageNodeWidgetState(); -} - -class __NetworkImageNodeWidgetState extends State<_NetworkImageNodeWidget> - with SelectableMixin { - RenderBox get _renderBox => context.findRenderObject() as RenderBox; - - @override - Widget build(BuildContext context) { - return Image.network( - widget.node.attributes['network_image_src'], - height: 200, - loadingBuilder: (context, child, loadingProgress) => - loadingProgress == null ? child : const CircularProgressIndicator(), - ); - } - - @override - Position start() => Position(path: widget.node.path, offset: 0); - - @override - Position end() => Position(path: widget.node.path, offset: 1); - - @override - Position getPositionInOffset(Offset start) => end(); - - @override - List getRectsInSelection(Selection selection) => - [Offset.zero & _renderBox.size]; - - @override - Selection getSelectionInRange(Offset start, Offset end) => Selection.single( - path: widget.node.path, - startOffset: 0, - endOffset: 1, - ); - - @override - Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/tex_block_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/tex_block_node_widget.dart deleted file mode 100644 index c9b0e8d478848..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/tex_block_node_widget.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_math_fork/flutter_math.dart'; - -SelectionMenuItem teXBlockMenuItem = SelectionMenuItem( - name: () => 'Tex', - icon: (_, __) => const Icon( - Icons.text_fields_rounded, - color: Colors.black, - size: 18.0, - ), - keywords: ['tex, latex, katex'], - handler: (editorState, _, __) { - final selection = - editorState.service.selectionService.currentSelection.value; - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); - if (selection == null || !selection.isCollapsed || textNodes.isEmpty) { - return; - } - final Path texNodePath; - if (textNodes.first.toPlainText().isEmpty) { - texNodePath = selection.end.path; - final transaction = editorState.transaction - ..insertNode( - selection.end.path, - Node( - type: 'tex', - children: LinkedList(), - attributes: {'tex': ''}, - ), - ) - ..deleteNode(textNodes.first) - ..afterSelection = selection; - editorState.apply(transaction); - } else { - texNodePath = selection.end.path.next; - final transaction = editorState.transaction - ..insertNode( - selection.end.path.next, - Node( - type: 'tex', - children: LinkedList(), - attributes: {'tex': ''}, - ), - ) - ..afterSelection = selection; - editorState.apply(transaction); - } - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - final texState = - editorState.document.nodeAtPath(texNodePath)?.key?.currentState; - if (texState != null && texState is __TeXBlockNodeWidgetState) { - texState.showEditingDialog(); - } - }); - }, -); - -class TeXBlockNodeWidgetBuidler extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return _TeXBlockNodeWidget( - key: context.node.key, - node: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => (node) { - return node.attributes['tex'] is String; - }; -} - -class _TeXBlockNodeWidget extends StatefulWidget { - const _TeXBlockNodeWidget({ - Key? key, - required this.node, - required this.editorState, - }) : super(key: key); - - final Node node; - final EditorState editorState; - - @override - State<_TeXBlockNodeWidget> createState() => __TeXBlockNodeWidgetState(); -} - -class __TeXBlockNodeWidgetState extends State<_TeXBlockNodeWidget> { - String get _tex => widget.node.attributes['tex'] as String; - bool _isHover = false; - - @override - Widget build(BuildContext context) { - return InkWell( - onHover: (value) { - setState(() { - _isHover = value; - }); - }, - onTap: () { - showEditingDialog(); - }, - child: Stack( - children: [ - _buildTex(context), - if (_isHover) _buildDeleteButton(context), - ], - ), - ); - } - - Widget _buildTex(BuildContext context) { - return Container( - width: MediaQuery.of(context).size.width, - padding: const EdgeInsets.symmetric(vertical: 20), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8.0)), - color: _isHover ? Colors.grey[200] : Colors.transparent, - ), - child: Center( - child: Math.tex( - _tex, - textStyle: const TextStyle(fontSize: 20), - mathStyle: MathStyle.display, - ), - ), - ); - } - - Widget _buildDeleteButton(BuildContext context) { - return Positioned( - top: -5, - right: -5, - child: IconButton( - icon: Icon( - Icons.delete_outline, - color: Colors.blue[400], - size: 16, - ), - onPressed: () { - final transaction = widget.editorState.transaction - ..deleteNode(widget.node); - widget.editorState.apply(transaction); - }, - ), - ); - } - - void showEditingDialog() { - showDialog( - context: context, - builder: (context) { - final controller = TextEditingController(text: _tex); - return AlertDialog( - title: const Text('Edit Katex'), - content: TextField( - controller: controller, - maxLines: null, - decoration: const InputDecoration( - border: OutlineInputBorder(), - ), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - if (controller.text != _tex) { - final transaction = widget.editorState.transaction - ..updateNode( - widget.node, - {'tex': controller.text}, - ); - widget.editorState.apply(transaction); - } - }, - child: const Text('OK'), - ), - ], - ); - }, - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/CMakeLists.txt deleted file mode 100644 index d5bd01648a96d..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/CMakeLists.txt +++ /dev/null @@ -1,88 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.10) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. - -# Serves the same purpose as list(TRANSFORM ... PREPEND ...), -# which isn't available in 3.10. -function(list_prepend LIST_NAME PREFIX) - set(NEW_LIST "") - foreach(element ${${LIST_NAME}}) - list(APPEND NEW_LIST "${PREFIX}${element}") - endforeach(element) - set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) -endfunction() - -# === Flutter Library === -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) -pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) -pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) - -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "fl_basic_message_channel.h" - "fl_binary_codec.h" - "fl_binary_messenger.h" - "fl_dart_project.h" - "fl_engine.h" - "fl_json_message_codec.h" - "fl_json_method_codec.h" - "fl_message_codec.h" - "fl_method_call.h" - "fl_method_channel.h" - "fl_method_codec.h" - "fl_method_response.h" - "fl_plugin_registrar.h" - "fl_plugin_registry.h" - "fl_standard_message_codec.h" - "fl_standard_method_codec.h" - "fl_string_codec.h" - "fl_value.h" - "fl_view.h" - "flutter_linux.h" -) -list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") -target_link_libraries(flutter INTERFACE - PkgConfig::GTK - PkgConfig::GLIB - PkgConfig::GIO -) -add_dependencies(flutter flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CMAKE_CURRENT_BINARY_DIR}/_phony_ - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" - ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} -) diff --git a/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 00fd3bc03fca1..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,19 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) rich_clipboard_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "RichClipboardPlugin"); - rich_clipboard_plugin_register_with_registrar(rich_clipboard_linux_registrar); - g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); - url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugin_registrant.h b/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47bc08f3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugins.cmake b/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 0342e3868a985..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,25 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - rich_clipboard_linux - url_launcher_linux -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/frontend/app_flowy/packages/appflowy_editor/example/linux/my_application.cc b/frontend/app_flowy/packages/appflowy_editor/example/linux/my_application.cc deleted file mode 100644 index 0ba8f43096dba..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/linux/my_application.cc +++ /dev/null @@ -1,104 +0,0 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -// Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "example"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "example"); - } - - gtk_window_set_default_size(window, 1280, 720); - gtk_widget_show(GTK_WIDGET(window)); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - gtk_widget_grab_focus(GTK_WIDGET(view)); -} - -// Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -// Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) {} - -MyApplication* my_application_new() { - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, - "flags", G_APPLICATION_NON_UNIQUE, - nullptr)); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/.gitignore b/frontend/app_flowy/packages/appflowy_editor/example/macos/.gitignore deleted file mode 100644 index 746adbb6b9e14..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Flutter-related -**/Flutter/ephemeral/ -**/Pods/ - -# Xcode-related -**/dgph -**/xcuserdata/ diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index f0f250ebd6532..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import path_provider_macos -import rich_clipboard_macos -import url_launcher_macos - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) - RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin")) - UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile b/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile deleted file mode 100644 index dade8dfad0dcf..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile +++ /dev/null @@ -1,40 +0,0 @@ -platform :osx, '10.11' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_macos_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_macos_build_settings(target) - end -end diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock b/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock deleted file mode 100644 index 49a5879fa6df0..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock +++ /dev/null @@ -1,34 +0,0 @@ -PODS: - - FlutterMacOS (1.0.0) - - path_provider_macos (0.0.1): - - FlutterMacOS - - rich_clipboard_macos (0.0.1): - - FlutterMacOS - - url_launcher_macos (0.0.1): - - FlutterMacOS - -DEPENDENCIES: - - FlutterMacOS (from `Flutter/ephemeral`) - - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) - - rich_clipboard_macos (from `Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos`) - - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - -EXTERNAL SOURCES: - FlutterMacOS: - :path: Flutter/ephemeral - path_provider_macos: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos - rich_clipboard_macos: - :path: Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos - url_launcher_macos: - :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos - -SPEC CHECKSUMS: - FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 - path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19 - rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c - url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3 - -PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c - -COCOAPODS: 1.11.3 diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 057a1a8224915..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,632 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 51; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 8FD791997F0D60CE136153FB /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F21284F13DB2F7E10C6EB1F7 /* Pods_Runner.framework */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 4C1351C0AA74138239028404 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - BBAF6135AB8D71FE6D8B315C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - BE3A038D8FDF07F3AD1C02FB /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - F21284F13DB2F7E10C6EB1F7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 8FD791997F0D60CE136153FB /* Pods_Runner.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - 7B5E3B15415D0C17244EF9E7 /* Pods */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* example.app */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - 7B5E3B15415D0C17244EF9E7 /* Pods */ = { - isa = PBXGroup; - children = ( - BBAF6135AB8D71FE6D8B315C /* Pods-Runner.debug.xcconfig */, - 4C1351C0AA74138239028404 /* Pods-Runner.release.xcconfig */, - BE3A038D8FDF07F3AD1C02FB /* Pods-Runner.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - F21284F13DB2F7E10C6EB1F7 /* Pods_Runner.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 33CC10EC2044A3C60003C045 /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 87BB3D0057F20B3618A17B82 /* [CP] Check Pods Manifest.lock */, - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - 3399D490228B24CF009A79C7 /* ShellScript */, - 09CDF3F9864A27F94DEE8EC6 /* [CP] Embed Pods Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* example.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - isa = PBXProject; - attributes = { - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 09CDF3F9864A27F94DEE8EC6 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; - 87BB3D0057F20B3618A17B82 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index fb7259e17785a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Base.lproj/MainMenu.xib b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Base.lproj/MainMenu.xib deleted file mode 100644 index a6d4312ff0177..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,344 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/DebugProfile.entitlements b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/DebugProfile.entitlements deleted file mode 100644 index c946719a1a36f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - com.apple.security.network.client - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Release.entitlements b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Release.entitlements deleted file mode 100644 index 48271acc95b32..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Release.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.network.client - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml b/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml deleted file mode 100644 index fc67a9d93377a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml +++ /dev/null @@ -1,100 +0,0 @@ -name: example -description: A new Flutter project. - -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: "none" # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.0+1 - -environment: - sdk: ">=2.17.0 <3.0.0" - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 - appflowy_editor: - path: ../ - provider: ^6.0.3 - url_launcher: ^6.1.5 - path_provider: ^2.0.11 - google_fonts: ^3.0.1 - flutter_localizations: - sdk: flutter - file_picker: ^5.0.1 - universal_html: ^2.0.8 - highlight: ^0.7.0 - flutter_math_fork: ^0.6.3+1 - -dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^2.0.1 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - assets: - - example.json - - big_document.json - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/packages/appflowy_editor/example/test/widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/example/test/widget_test.dart deleted file mode 100644 index 2a2b81928549d..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/test/widget_test.dart +++ /dev/null @@ -1,8 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -void main() {} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/web/index.html b/frontend/app_flowy/packages/appflowy_editor/example/web/index.html deleted file mode 100644 index 41b3bc336f404..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/web/index.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - example - - - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_editor/example/web/manifest.json b/frontend/app_flowy/packages/appflowy_editor/example/web/manifest.json deleted file mode 100644 index 096edf8fe4cd7..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "example", - "short_name": "example", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_editor/example/windows/CMakeLists.txt deleted file mode 100644 index c0270746b1b9b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/windows/CMakeLists.txt +++ /dev/null @@ -1,101 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.14) -project(example LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "example") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(SET CMP0063 NEW) - -# Define build configuration option. -get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) -if(IS_MULTICONFIG) - set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" - CACHE STRING "" FORCE) -else() - if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") - endif() -endif() -# Define settings for the Profile build mode. -set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") -set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") -set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") -set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") - -# Use Unicode for all projects. -add_definitions(-DUNICODE -D_UNICODE) - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") - target_compile_options(${TARGET} PRIVATE /EHsc) - target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") - target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# Support files are copied into place next to the executable, so that it can -# run in place. This is done instead of making a separate bundle (as on Linux) -# so that building and running from within Visual Studio will work. -set(BUILD_BUNDLE_DIR "$") -# Make the "install" step default, as it's required to run. -set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -if(PLUGIN_BUNDLED_LIBRARIES) - install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - CONFIGURATIONS Profile;Release - COMPONENT Runtime) diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/CMakeLists.txt deleted file mode 100644 index 930d2071a324e..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/CMakeLists.txt +++ /dev/null @@ -1,104 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.14) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. -set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") - -# === Flutter Library === -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "flutter_export.h" - "flutter_windows.h" - "flutter_messenger.h" - "flutter_plugin_registrar.h" - "flutter_texture_registrar.h" -) -list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") -add_dependencies(flutter flutter_assemble) - -# === Wrapper === -list(APPEND CPP_WRAPPER_SOURCES_CORE - "core_implementations.cc" - "standard_codec.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_PLUGIN - "plugin_registrar.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_APP - "flutter_engine.cc" - "flutter_view_controller.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") - -# Wrapper sources needed for a plugin. -add_library(flutter_wrapper_plugin STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} -) -apply_standard_settings(flutter_wrapper_plugin) -set_target_properties(flutter_wrapper_plugin PROPERTIES - POSITION_INDEPENDENT_CODE ON) -set_target_properties(flutter_wrapper_plugin PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) -target_include_directories(flutter_wrapper_plugin PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_plugin flutter_assemble) - -# Wrapper sources needed for the runner. -add_library(flutter_wrapper_app STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_APP} -) -apply_standard_settings(flutter_wrapper_app) -target_link_libraries(flutter_wrapper_app PUBLIC flutter) -target_include_directories(flutter_wrapper_app PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_app flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") -set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} - ${PHONY_OUTPUT} - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} -) diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 4f7884874da74..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,14 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - UrlLauncherWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("UrlLauncherWindows")); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugin_registrant.h b/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d85a9310..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugins.cmake b/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugins.cmake deleted file mode 100644 index 88b22e5c775e5..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,24 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - url_launcher_windows -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/CMakeLists.txt deleted file mode 100644 index b9e550fba8e17..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/CMakeLists.txt +++ /dev/null @@ -1,32 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} WIN32 - "flutter_window.cpp" - "main.cpp" - "utils.cpp" - "win32_window.cpp" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" - "Runner.rc" - "runner.exe.manifest" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Disable Windows macros that collide with C++ standard library functions. -target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") - -# Add dependency libraries and include directories. Add any application-specific -# dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/main.cpp b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/main.cpp deleted file mode 100644 index bcb57b0e2aacb..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/main.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include -#include -#include - -#include "flutter_window.h" -#include "utils.h" - -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { - // Attach to console when present (e.g., 'flutter run') or create a - // new console when running with a debugger. - if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { - CreateAndAttachConsole(); - } - - // Initialize COM, so that it is available for use in the library and/or - // plugins. - ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - - flutter::DartProject project(L"data"); - - std::vector command_line_arguments = - GetCommandLineArguments(); - - project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - - FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); - if (!window.CreateAndShow(L"example", origin, size)) { - return EXIT_FAILURE; - } - window.SetQuitOnClose(true); - - ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { - ::TranslateMessage(&msg); - ::DispatchMessage(&msg); - } - - ::CoUninitialize(); - return EXIT_SUCCESS; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/utils.cpp b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/utils.cpp deleted file mode 100644 index f5bf9fa0f536c..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/utils.cpp +++ /dev/null @@ -1,64 +0,0 @@ -#include "utils.h" - -#include -#include -#include -#include - -#include - -void CreateAndAttachConsole() { - if (::AllocConsole()) { - FILE *unused; - if (freopen_s(&unused, "CONOUT$", "w", stdout)) { - _dup2(_fileno(stdout), 1); - } - if (freopen_s(&unused, "CONOUT$", "w", stderr)) { - _dup2(_fileno(stdout), 2); - } - std::ios::sync_with_stdio(); - FlutterDesktopResyncOutputStreams(); - } -} - -std::vector GetCommandLineArguments() { - // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. - int argc; - wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); - if (argv == nullptr) { - return std::vector(); - } - - std::vector command_line_arguments; - - // Skip the first argument as it's the binary name. - for (int i = 1; i < argc; i++) { - command_line_arguments.push_back(Utf8FromUtf16(argv[i])); - } - - ::LocalFree(argv); - - return command_line_arguments; -} - -std::string Utf8FromUtf16(const wchar_t* utf16_string) { - if (utf16_string == nullptr) { - return std::string(); - } - int target_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, nullptr, 0, nullptr, nullptr); - std::string utf8_string; - if (target_length == 0 || target_length > utf8_string.max_size()) { - return utf8_string; - } - utf8_string.resize(target_length); - int converted_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, utf8_string.data(), - target_length, nullptr, nullptr); - if (converted_length == 0) { - return std::string(); - } - return utf8_string; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart deleted file mode 100644 index 29cb9f87f6ca4..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart +++ /dev/null @@ -1,35 +0,0 @@ -/// AppFlowyEditor library -library appflowy_editor; - -export 'src/infra/log.dart'; -export 'src/render/style/editor_style.dart'; -export 'src/core/document/node.dart'; -export 'src/core/document/path.dart'; -export 'src/core/location/position.dart'; -export 'src/core/location/selection.dart'; -export 'src/core/document/document.dart'; -export 'src/core/document/text_delta.dart'; -export 'src/core/document/attributes.dart'; -export 'src/core/legacy/built_in_attribute_keys.dart'; -export 'src/editor_state.dart'; -export 'src/core/transform/operation.dart'; -export 'src/core/transform/transaction.dart'; -export 'src/render/selection/selectable.dart'; -export 'src/service/editor_service.dart'; -export 'src/service/render_plugin_service.dart'; -export 'src/service/service.dart'; -export 'src/service/selection_service.dart'; -export 'src/service/scroll_service.dart'; -export 'src/service/toolbar_service.dart'; -export 'src/service/keyboard_service.dart'; -export 'src/service/input_service.dart'; -export 'src/service/shortcut_event/keybinding.dart'; -export 'src/service/shortcut_event/shortcut_event.dart'; -export 'src/service/shortcut_event/shortcut_event_handler.dart'; -export 'src/extensions/attributes_extension.dart'; -export 'src/render/rich_text/default_selectable.dart'; -export 'src/render/rich_text/flowy_rich_text.dart'; -export 'src/render/selection_menu/selection_menu_widget.dart'; -export 'src/l10n/l10n.dart'; -export 'src/render/style/plugin_styles.dart'; -export 'src/render/style/editor_style.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_bn_BN.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_bn_BN.arb deleted file mode 100644 index fa585614982cb..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_bn_BN.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "bn_BN", - "bold": "বল্ড ফন্ট", - "@bold": {}, - "bulletedList": "বুলেট তালিকা", - "@bulletedList": {}, - "checkbox": "চেকবক্স", - "@checkbox": {}, - "embedCode": "এম্বেড কোড", - "@embedCode": {}, - "heading1": "শিরোনাম 1", - "@heading1": {}, - "heading2": "শিরোনাম 2", - "@heading2": {}, - "heading3": "শিরোনাম 3", - "@heading3": {}, - "highlight": "হাইলাইট", - "@highlight": {}, - "image": "ইমেজ", - "@image": {}, - "italic": "ইটালিক ফন্ট", - "@italic": {}, - "link": "লিঙ্ক", - "@link": {}, - "numberedList": "সংখ্যাযুক্ত তালিকা", - "@numberedList": {}, - "quote": "উদ্ধৃতি", - "@quote": {}, - "strikethrough": "স্ট্রাইকথ্রু", - "@strikethrough": {}, - "text": "পাঠ্য", - "@text": {}, - "underline": "আন্ডারলাইন", - "@underline": {} -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_ca.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_ca.arb deleted file mode 100644 index 45190fb1062f2..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_ca.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "ca", - "bold": "", - "@bold": {}, - "bulletedList": "", - "@bulletedList": {}, - "checkbox": "", - "@checkbox": {}, - "embedCode": "", - "@embedCode": {}, - "heading1": "", - "@heading1": {}, - "heading2": "", - "@heading2": {}, - "heading3": "", - "@heading3": {}, - "highlight": "", - "@highlight": {}, - "image": "", - "@image": {}, - "italic": "", - "@italic": {}, - "link": "", - "@link": {}, - "numberedList": "", - "@numberedList": {}, - "quote": "", - "@quote": {}, - "strikethrough": "", - "@strikethrough": {}, - "text": "", - "@text": {}, - "underline": "", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_cs_CZ.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_cs_CZ.arb deleted file mode 100644 index 2b1ea7b0bfba8..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_cs_CZ.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "cs-CZ", - "bold": "Tučně", - "@bold": {}, - "bulletedList": "Odrážkový seznam", - "@bulletedList": {}, - "checkbox": "Zaškrtávací políčko", - "@checkbox": {}, - "embedCode": "Vložit kód", - "@embedCode": {}, - "heading1": "Nadpis 1", - "@heading1": {}, - "heading2": "Nadpis 2", - "@heading2": {}, - "heading3": "Nadpis 3", - "@heading3": {}, - "highlight": "Zvýraznění", - "@highlight": {}, - "image": "Obrázek", - "@image": {}, - "italic": "Kurzíva", - "@italic": {}, - "link": "Odkaz", - "@link": {}, - "numberedList": "Číslovaný seznam", - "@numberedList": {}, - "quote": "Citace", - "@quote": {}, - "strikethrough": "Přeškrtnutí", - "@strikethrough": {}, - "text": "Text", - "@text": {}, - "underline": "Podtržení", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_de_DE.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_de_DE.arb deleted file mode 100644 index bd39879f0d5d8..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_de_DE.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "de-DE", - "bold": "Fett gedruckt", - "@bold": {}, - "bulletedList": "Aufzählungsliste", - "@bulletedList": {}, - "checkbox": "Kontrollkästchen", - "@checkbox": {}, - "embedCode": "Code einbetten", - "@embedCode": {}, - "heading1": "Überschrift 1", - "@heading1": {}, - "heading2": "Überschrift 2", - "@heading2": {}, - "heading3": "Überschrift 3", - "@heading3": {}, - "highlight": "Markieren", - "@highlight": {}, - "image": "Bild", - "@image": {}, - "italic": "kursiv", - "@italic": {}, - "link": "Verknüpfung", - "@link": {}, - "numberedList": "NummerierteListe", - "@numberedList": {}, - "quote": "zitieren", - "@quote": {}, - "strikethrough": "durchgestrichen", - "@strikethrough": {}, - "text": "Text", - "@text": {}, - "underline": "unterstreichen", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_en.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_en.arb deleted file mode 100644 index f9968ab07aace..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_en.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "en", - "bold": "Bold", - "@bold": {}, - "bulletedList": "Bulleted List", - "@bulletedList": {}, - "checkbox": "Checkbox", - "@checkbox": {}, - "embedCode": "Embed Code", - "@embedCode": {}, - "heading1": "H1", - "@heading1": {}, - "heading2": "H2", - "@heading2": {}, - "heading3": "H3", - "@heading3": {}, - "highlight": "Highlight", - "@highlight": {}, - "image": "Image", - "@image": {}, - "italic": "Italic", - "@italic": {}, - "link": "Link", - "@link": {}, - "numberedList": "Numbered List", - "@numberedList": {}, - "quote": "Quote", - "@quote": {}, - "strikethrough": "Strikethrough", - "@strikethrough": {}, - "text": "Text", - "@text": {}, - "underline": "Underline", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_es_VE.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_es_VE.arb deleted file mode 100644 index bace9331aeb5b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_es_VE.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "es-VE", - "bold": "", - "@bold": {}, - "bulletedList": "", - "@bulletedList": {}, - "checkbox": "", - "@checkbox": {}, - "embedCode": "", - "@embedCode": {}, - "heading1": "", - "@heading1": {}, - "heading2": "", - "@heading2": {}, - "heading3": "", - "@heading3": {}, - "highlight": "", - "@highlight": {}, - "image": "", - "@image": {}, - "italic": "", - "@italic": {}, - "link": "", - "@link": {}, - "numberedList": "", - "@numberedList": {}, - "quote": "", - "@quote": {}, - "strikethrough": "", - "@strikethrough": {}, - "text": "", - "@text": {}, - "underline": "", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_fr_CA.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_fr_CA.arb deleted file mode 100644 index 1ff4ab2f7582e..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_fr_CA.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "fr-CA", - "bold": "gras", - "@bold": {}, - "bulletedList": "liste à puces", - "@bulletedList": {}, - "checkbox": "case à cocher", - "@checkbox": {}, - "embedCode": "incorporer Code", - "@embedCode": {}, - "heading1": "en-tête1", - "@heading1": {}, - "heading2": "en-tête2", - "@heading2": {}, - "heading3": "en-tête3", - "@heading3": {}, - "highlight": "mettre en évidence", - "@highlight": {}, - "image": "l’image", - "@image": {}, - "italic": "italique", - "@italic": {}, - "link": "lien", - "@link": {}, - "numberedList": "liste numérotée", - "@numberedList": {}, - "quote": "citation", - "@quote": {}, - "strikethrough": "barré", - "@strikethrough": {}, - "text": "texte", - "@text": {}, - "underline": "souligner", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_fr_FR.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_fr_FR.arb deleted file mode 100644 index 0e631895060b8..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_fr_FR.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "fr-FR", - "bold": "Gras", - "@bold": {}, - "bulletedList": "List à puces", - "@bulletedList": {}, - "checkbox": "Case à cocher", - "@checkbox": {}, - "embedCode": "Incorporer code", - "@embedCode": {}, - "heading1": "Titre 1", - "@heading1": {}, - "heading2": "Titre 2", - "@heading2": {}, - "heading3": "Titre 3", - "@heading3": {}, - "highlight": "Surligné", - "@highlight": {}, - "image": "Image", - "@image": {}, - "italic": "Italique", - "@italic": {}, - "link": "Lien", - "@link": {}, - "numberedList": "Liste numérotée", - "@numberedList": {}, - "quote": "Citation", - "@quote": {}, - "strikethrough": "Barré", - "@strikethrough": {}, - "text": "Texte", - "@text": {}, - "underline": "Souligné", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_hi_IN.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_hi_IN.arb deleted file mode 100644 index 96102143c121c..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_hi_IN.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "hi-IN", - "bold": "बोल्ड", - "@bold": {}, - "bulletedList": "बुलेटेड सूची", - "@bulletedList": {}, - "checkbox": "चेक बॉक्स", - "@checkbox": {}, - "embedCode": "लागु किया गया संहिता", - "@embedCode": {}, - "heading1": "शीर्षक 1", - "@heading1": {}, - "heading2": "शीर्षक 2", - "@heading2": {}, - "heading3": "शीर्षक 3", - "@heading3": {}, - "highlight": "प्रमुखता से दिखाना", - "@highlight": {}, - "image": "छवि", - "@image": {}, - "italic": "तिरछा", - "@italic": {}, - "link": "संपर्क", - "@link": {}, - "numberedList": "क्रमांकित सूची", - "@numberedList": {}, - "quote": "उद्धरण", - "@quote": {}, - "strikethrough": "स्ट्राइकथ्रू", - "@strikethrough": {}, - "text": "मूलपाठ", - "@text": {}, - "underline": "रेखांकन", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_hu_HU.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_hu_HU.arb deleted file mode 100644 index f96b3b0ec33c9..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_hu_HU.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "hu-HU", - "bold": "bátor", - "@bold": {}, - "bulletedList": "pontozott lista", - "@bulletedList": {}, - "checkbox": "jelölőnégyzetet", - "@checkbox": {}, - "embedCode": "Beágyazás", - "@embedCode": {}, - "heading1": "címsor1", - "@heading1": {}, - "heading2": "címsor2", - "@heading2": {}, - "heading3": "címsor3", - "@heading3": {}, - "highlight": "Kiemel", - "@highlight": {}, - "image": "kép", - "@image": {}, - "italic": "dőlt", - "@italic": {}, - "link": "link", - "@link": {}, - "numberedList": "számozottLista", - "@numberedList": {}, - "quote": "idézet", - "@quote": {}, - "strikethrough": "áthúzott", - "@strikethrough": {}, - "text": "szöveg", - "@text": {}, - "underline": "aláhúzás", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_id_ID.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_id_ID.arb deleted file mode 100644 index 6b3f6a0a6d729..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_id_ID.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "id-ID", - "bold": "berani", - "@bold": {}, - "bulletedList": "daftar berpoin", - "@bulletedList": {}, - "checkbox": "kotak centang", - "@checkbox": {}, - "embedCode": "menyematkan Kode", - "@embedCode": {}, - "heading1": "pos1", - "@heading1": {}, - "heading2": "pos2", - "@heading2": {}, - "heading3": "pos3", - "@heading3": {}, - "highlight": "menyorot", - "@highlight": {}, - "image": "gambar", - "@image": {}, - "italic": "miring", - "@italic": {}, - "link": "tautan", - "@link": {}, - "numberedList": "daftar bernomor", - "@numberedList": {}, - "quote": "mengutip", - "@quote": {}, - "strikethrough": "coret", - "@strikethrough": {}, - "text": "teks", - "@text": {}, - "underline": "menggarisbawahi", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_it_IT.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_it_IT.arb deleted file mode 100644 index e645959f7be3b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_it_IT.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "it-IT", - "bold": "Grassetto", - "@bold": {}, - "bulletedList": "Elenco puntato", - "@bulletedList": {}, - "checkbox": "Casella di spunta", - "@checkbox": {}, - "embedCode": "Incorpora codice", - "@embedCode": {}, - "heading1": "H1", - "@heading1": {}, - "heading2": "H2", - "@heading2": {}, - "heading3": "H3", - "@heading3": {}, - "highlight": "Evidenzia", - "@highlight": {}, - "image": "Immagine", - "@image": {}, - "italic": "Corsivo", - "@italic": {}, - "link": "Collegamento", - "@link": {}, - "numberedList": "Elenco numerato", - "@numberedList": {}, - "quote": "Cita", - "@quote": {}, - "strikethrough": "Barrato", - "@strikethrough": {}, - "text": "Testo", - "@text": {}, - "underline": "Sottolineato", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_ja_JP.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_ja_JP.arb deleted file mode 100644 index 5d49578cc3a3a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_ja_JP.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "ja-JP", - "bold": "", - "@bold": {}, - "bulletedList": "", - "@bulletedList": {}, - "checkbox": "", - "@checkbox": {}, - "embedCode": "", - "@embedCode": {}, - "heading1": "", - "@heading1": {}, - "heading2": "", - "@heading2": {}, - "heading3": "", - "@heading3": {}, - "highlight": "", - "@highlight": {}, - "image": "", - "@image": {}, - "italic": "", - "@italic": {}, - "link": "", - "@link": {}, - "numberedList": "", - "@numberedList": {}, - "quote": "", - "@quote": {}, - "strikethrough": "", - "@strikethrough": {}, - "text": "", - "@text": {}, - "underline": "", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_ml_IN.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_ml_IN.arb deleted file mode 100644 index d48179e149faf..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_ml_IN.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "ml_IN", - "bold": "ബോൾഡ്", - "@bold": {}, - "bulletedList": "ബുള്ളറ്റഡ് പട്ടിക", - "@bulletedList": {}, - "checkbox": "ചെക്ക്ബോക്സ്", - "@checkbox": {}, - "embedCode": "എംബെഡഡ് കോഡ്", - "@embedCode": {}, - "heading1": "തലക്കെട്ട് 1", - "@heading1": {}, - "heading2": "തലക്കെട്ട് 2", - "@heading2": {}, - "heading3": "തലക്കെട്ട് 3", - "@heading3": {}, - "highlight": "പ്രമുഖമാക്കിക്കാട്ടുക", - "@highlight": {}, - "image": "ചിത്രം", - "@image": {}, - "italic": "ഇറ്റാലിക്", - "@italic": {}, - "link": "ലിങ്ക്", - "@link": {}, - "numberedList": "അക്കമിട്ട പട്ടിക", - "@numberedList": {}, - "quote": "ഉദ്ധരണി", - "@quote": {}, - "strikethrough": "സ്ട്രൈക്ക്ത്രൂ", - "@strikethrough": {}, - "text": "വചനം", - "@text": {}, - "underline": "അടിവരയിടുക", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_nl_NL.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_nl_NL.arb deleted file mode 100644 index 814e22655d287..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_nl_NL.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "nl-NL", - "bold": "Vet", - "@bold": {}, - "bulletedList": "Opsommingstekens", - "@bulletedList": {}, - "checkbox": "Selectievakje", - "@checkbox": {}, - "embedCode": "Invoegcode", - "@embedCode": {}, - "heading1": "H1", - "@heading1": {}, - "heading2": "H2", - "@heading2": {}, - "heading3": "H3", - "@heading3": {}, - "highlight": "Highlight", - "@highlight": {}, - "image": "Afbeelding", - "@image": {}, - "italic": "Cursief", - "@italic": {}, - "link": "", - "@link": {}, - "numberedList": "Nummering", - "@numberedList": {}, - "quote": "Quote", - "@quote": {}, - "strikethrough": "Doorhalen", - "@strikethrough": {}, - "text": "Tekst", - "@text": {}, - "underline": "Onderstrepen", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_pl_PL.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_pl_PL.arb deleted file mode 100644 index ba97af8f26116..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_pl_PL.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "pl-PL", - "bold": "", - "@bold": {}, - "bulletedList": "", - "@bulletedList": {}, - "checkbox": "", - "@checkbox": {}, - "embedCode": "", - "@embedCode": {}, - "heading1": "", - "@heading1": {}, - "heading2": "", - "@heading2": {}, - "heading3": "", - "@heading3": {}, - "highlight": "", - "@highlight": {}, - "image": "", - "@image": {}, - "italic": "", - "@italic": {}, - "link": "", - "@link": {}, - "numberedList": "", - "@numberedList": {}, - "quote": "", - "@quote": {}, - "strikethrough": "", - "@strikethrough": {}, - "text": "", - "@text": {}, - "underline": "", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_pt_BR.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_pt_BR.arb deleted file mode 100644 index 8f9186b7d8cae..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_pt_BR.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "pt-BR", - "bold": "Negrito", - "@bold": {}, - "bulletedList": "Lista de marcadores", - "@bulletedList": {}, - "checkbox": "Caixa de seleção", - "@checkbox": {}, - "embedCode": "Código incorporado", - "@embedCode": {}, - "heading1": "H1", - "@heading1": {}, - "heading2": "H2", - "@heading2": {}, - "heading3": "H3", - "@heading3": {}, - "highlight": "Destacar", - "@highlight": {}, - "image": "Imagem", - "@image": {}, - "italic": "Itálico", - "@italic": {}, - "link": "Link", - "@link": {}, - "numberedList": "Lista numerada", - "@numberedList": {}, - "quote": "Citar", - "@quote": {}, - "strikethrough": "Rasurar", - "@strikethrough": {}, - "text": "Texto", - "@text": {}, - "underline": "Sublinhar", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_pt_PT.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_pt_PT.arb deleted file mode 100644 index 9b7386fd46a71..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_pt_PT.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "pt-PT", - "bold": "negrito", - "@bold": {}, - "bulletedList": "lista com marcadores", - "@bulletedList": {}, - "checkbox": "caixa de seleção", - "@checkbox": {}, - "embedCode": "Código embutido", - "@embedCode": {}, - "heading1": "Cabeçallho 1", - "@heading1": {}, - "heading2": "Cabeçallho 2", - "@heading2": {}, - "heading3": "Cabeçallho 3", - "@heading3": {}, - "highlight": "realçar", - "@highlight": {}, - "image": "imagem", - "@image": {}, - "italic": "itálico", - "@italic": {}, - "link": "link", - "@link": {}, - "numberedList": "lista numerada", - "@numberedList": {}, - "quote": "citar", - "@quote": {}, - "strikethrough": "tachado", - "@strikethrough": {}, - "text": "texto", - "@text": {}, - "underline": "sublinhado", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_ru_RU.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_ru_RU.arb deleted file mode 100644 index 33408dcaa0aba..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_ru_RU.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "ru-RU", - "bold": "смелый", - "@bold": {}, - "bulletedList": "маркированный список", - "@bulletedList": {}, - "checkbox": "флажок", - "@checkbox": {}, - "embedCode": "код для вставки", - "@embedCode": {}, - "heading1": "заголовок1", - "@heading1": {}, - "heading2": "заголовок2", - "@heading2": {}, - "heading3": "заголовок3", - "@heading3": {}, - "highlight": "выделять", - "@highlight": {}, - "image": "изображение", - "@image": {}, - "italic": "курсив", - "@italic": {}, - "link": "ссылка на сайт", - "@link": {}, - "numberedList": "нумерованный список", - "@numberedList": {}, - "quote": "цитировать", - "@quote": {}, - "strikethrough": "зачеркнутый", - "@strikethrough": {}, - "text": "текст", - "@text": {}, - "underline": "подчеркнуть", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_tr_TR.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_tr_TR.arb deleted file mode 100644 index 16887cd1cf6cd..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_tr_TR.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "tr-TR", - "bold": "", - "@bold": {}, - "bulletedList": "", - "@bulletedList": {}, - "checkbox": "", - "@checkbox": {}, - "embedCode": "", - "@embedCode": {}, - "heading1": "", - "@heading1": {}, - "heading2": "", - "@heading2": {}, - "heading3": "", - "@heading3": {}, - "highlight": "", - "@highlight": {}, - "image": "", - "@image": {}, - "italic": "", - "@italic": {}, - "link": "", - "@link": {}, - "numberedList": "", - "@numberedList": {}, - "quote": "", - "@quote": {}, - "strikethrough": "", - "@strikethrough": {}, - "text": "", - "@text": {}, - "underline": "", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_zh_CN.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_zh_CN.arb deleted file mode 100644 index cc2c79985ce2b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_zh_CN.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "zh-CN", - "bold": "", - "@bold": {}, - "bulletedList": "", - "@bulletedList": {}, - "checkbox": "", - "@checkbox": {}, - "embedCode": "", - "@embedCode": {}, - "heading1": "", - "@heading1": {}, - "heading2": "", - "@heading2": {}, - "heading3": "", - "@heading3": {}, - "highlight": "", - "@highlight": {}, - "image": "", - "@image": {}, - "italic": "", - "@italic": {}, - "link": "", - "@link": {}, - "numberedList": "", - "@numberedList": {}, - "quote": "", - "@quote": {}, - "strikethrough": "", - "@strikethrough": {}, - "text": "", - "@text": {}, - "underline": "", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_zh_TW.arb b/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_zh_TW.arb deleted file mode 100644 index 4d2778a608bd1..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/l10n/intl_zh_TW.arb +++ /dev/null @@ -1,35 +0,0 @@ -{ - "@@locale": "zh-TW", - "bold": "", - "@bold": {}, - "bulletedList": "", - "@bulletedList": {}, - "checkbox": "", - "@checkbox": {}, - "embedCode": "", - "@embedCode": {}, - "heading1": "", - "@heading1": {}, - "heading2": "", - "@heading2": {}, - "heading3": "", - "@heading3": {}, - "highlight": "", - "@highlight": {}, - "image": "", - "@image": {}, - "italic": "", - "@italic": {}, - "link": "", - "@link": {}, - "numberedList": "", - "@numberedList": {}, - "quote": "", - "@quote": {}, - "strikethrough": "", - "@strikethrough": {}, - "text": "", - "@text": {}, - "underline": "", - "@underline": {} -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/attributes_commands.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/attributes_commands.dart deleted file mode 100644 index b506bd100ca45..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/attributes_commands.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:appflowy_editor/src/commands/command_extension.dart'; -import 'package:appflowy_editor/src/core/document/attributes.dart'; -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/document/path.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; - -extension TextCommands on EditorState { - Future updateNodeAttributes( - Attributes attributes, { - Path? path, - Node? node, - }) { - return futureCommand(() { - final n = getNode(path: path, node: node); - apply( - transaction..updateNode(n, attributes), - ); - }); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/command_extension.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/command_extension.dart deleted file mode 100644 index 71a6aa01de38d..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/command_extension.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/document/path.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:flutter/widgets.dart'; - -extension CommandExtension on EditorState { - Future futureCommand(void Function() fn) async { - final completer = Completer(); - fn(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - completer.complete(); - }); - return completer.future; - } - - Node getNode({ - Path? path, - Node? node, - }) { - if (node != null) { - return node; - } else if (path != null) { - return document.nodeAtPath(path)!; - } - throw Exception('path and node cannot be null at the same time'); - } - - TextNode getTextNode({ - Path? path, - TextNode? textNode, - }) { - if (textNode != null) { - return textNode; - } else if (path != null) { - return document.nodeAtPath(path)! as TextNode; - } - throw Exception('path and node cannot be null at the same time'); - } - - Selection getSelection( - Selection? selection, - ) { - final currentSelection = service.selectionService.currentSelection.value; - if (selection != null) { - return selection; - } else if (currentSelection != null) { - return currentSelection; - } - throw Exception('path and textNode cannot be null at the same time'); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text/text_commands.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text/text_commands.dart deleted file mode 100644 index aa19c50d77e29..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text/text_commands.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/commands/command_extension.dart'; - -extension TextCommands on EditorState { - /// Insert text at the given index of the given [TextNode] or the [Path]. - /// - /// [Path] and [TextNode] are mutually exclusive. - /// One of these two parameters must have a value. - Future insertText( - int index, - String text, { - Path? path, - TextNode? textNode, - }) async { - return futureCommand(() { - final n = getTextNode(path: path, textNode: textNode); - apply( - transaction..insertText(n, index, text), - ); - }); - } - - Future formatText( - EditorState editorState, - Selection? selection, - Attributes attributes, { - Path? path, - TextNode? textNode, - }) async { - return futureCommand(() { - final n = getTextNode(path: path, textNode: textNode); - final s = getSelection(selection); - apply( - transaction..formatText(n, s.startIndex, s.length, attributes), - ); - }); - } - - Future formatTextWithBuiltInAttribute( - EditorState editorState, - String key, - Attributes attributes, { - Selection? selection, - Path? path, - TextNode? textNode, - }) async { - return futureCommand(() { - final n = getTextNode(path: path, textNode: textNode); - if (BuiltInAttributeKey.globalStyleKeys.contains(key)) { - final attr = n.attributes - ..removeWhere( - (key, _) => BuiltInAttributeKey.globalStyleKeys.contains(key)) - ..addAll(attributes) - ..addAll({ - BuiltInAttributeKey.subtype: key, - }); - apply( - transaction..updateNode(n, attr), - ); - } else if (BuiltInAttributeKey.partialStyleKeys.contains(key)) { - final s = getSelection(selection); - apply( - transaction..formatText(n, s.startIndex, s.length, attributes), - ); - } - }); - } - - Future formatTextToCheckbox( - EditorState editorState, - bool check, { - Path? path, - TextNode? textNode, - }) async { - return formatTextWithBuiltInAttribute( - editorState, - BuiltInAttributeKey.checkbox, - {BuiltInAttributeKey.checkbox: check}, - path: path, - textNode: textNode, - ); - } - - Future formatLinkInText( - EditorState editorState, - String? link, { - Path? path, - TextNode? textNode, - }) async { - return formatTextWithBuiltInAttribute( - editorState, - BuiltInAttributeKey.href, - {BuiltInAttributeKey.href: link}, - path: path, - textNode: textNode, - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/attributes.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/attributes.dart deleted file mode 100644 index 163e8e9ab6fad..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/attributes.dart +++ /dev/null @@ -1,51 +0,0 @@ -/// Attributes is used to describe the Node's information. -/// -/// Please note: The keywords in [BuiltInAttributeKey] are reserved. -typedef Attributes = Map; - -Attributes? composeAttributes( - Attributes? base, - Attributes? other, { - keepNull = false, -}) { - base ??= {}; - other ??= {}; - Attributes attributes = { - ...base, - ...other, - }; - - if (!keepNull) { - attributes = Attributes.from(attributes) - ..removeWhere((_, value) => value == null); - } - - return attributes.isNotEmpty ? attributes : null; -} - -Attributes invertAttributes(Attributes? from, Attributes? to) { - from ??= {}; - to ??= {}; - final attributes = Attributes.from({}); - - // key in from but not in to, or value is different - for (final entry in from.entries) { - if ((!to.containsKey(entry.key) && entry.value != null) || - to[entry.key] != entry.value) { - attributes[entry.key] = entry.value; - } - } - - // key in to but not in from, or value is different - for (final entry in to.entries) { - if (!from.containsKey(entry.key) && entry.value != null) { - attributes[entry.key] = null; - } - } - - return attributes; -} - -int hashAttributes(Attributes base) => Object.hashAllUnordered( - base.entries.map((e) => Object.hash(e.key, e.value)), - ); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/document.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/document.dart deleted file mode 100644 index 3cf5b837b971b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/document.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_editor/src/core/document/attributes.dart'; -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/document/path.dart'; -import 'package:appflowy_editor/src/core/document/text_delta.dart'; - -/// [Document] reprensents a AppFlowy Editor document structure. -/// -/// It stores the root of the document. -/// -/// DO NOT directly mutate the properties of a [Document] object. -class Document { - Document({ - required this.root, - }); - - factory Document.fromJson(Map json) { - assert(json['document'] is Map); - - final document = Map.from(json['document'] as Map); - final root = Node.fromJson(document); - return Document(root: root); - } - - /// Creates a empty document with a single text node. - factory Document.empty() { - final root = Node( - type: 'editor', - children: LinkedList()..add(TextNode.empty()), - ); - return Document( - root: root, - ); - } - - final Node root; - - /// Returns the node at the given [path]. - Node? nodeAtPath(Path path) { - return root.childAtPath(path); - } - - /// Inserts a [Node]s at the given [Path]. - bool insert(Path path, Iterable nodes) { - if (path.isEmpty || nodes.isEmpty) { - return false; - } - - final target = nodeAtPath(path); - if (target != null) { - for (final node in nodes) { - target.insertBefore(node); - } - return true; - } - - final parent = nodeAtPath(path.parent); - if (parent != null) { - for (var i = 0; i < nodes.length; i++) { - parent.insert(nodes.elementAt(i), index: path.last + i); - } - return true; - } - - return false; - } - - /// Deletes the [Node]s at the given [Path]. - bool delete(Path path, [int length = 1]) { - if (path.isEmpty || length <= 0) { - return false; - } - var target = nodeAtPath(path); - if (target == null) { - return false; - } - while (target != null && length > 0) { - final next = target.next; - target.unlink(); - target = next; - length--; - } - return true; - } - - /// Updates the [Node] at the given [Path] - bool update(Path path, Attributes attributes) { - if (path.isEmpty) { - return false; - } - final target = nodeAtPath(path); - if (target == null) { - return false; - } - target.updateAttributes(attributes); - return true; - } - - /// Updates the [TextNode] at the given [Path] - bool updateText(Path path, Delta delta) { - if (path.isEmpty) { - return false; - } - final target = nodeAtPath(path); - if (target == null || target is! TextNode) { - return false; - } - target.delta = target.delta.compose(delta); - return true; - } - - bool get isEmpty { - if (root.children.isEmpty) { - return true; - } - - final node = root.children.first; - if (node is TextNode && - (node.delta.isEmpty || node.delta.toPlainText().isEmpty)) { - return true; - } - - return false; - } - - Map toJson() { - return { - 'document': root.toJson(), - }; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node.dart deleted file mode 100644 index 975ef00df3810..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node.dart +++ /dev/null @@ -1,301 +0,0 @@ -import 'dart:collection'; - -import 'package:flutter/material.dart'; - -import 'package:appflowy_editor/src/core/document/attributes.dart'; -import 'package:appflowy_editor/src/core/document/path.dart'; -import 'package:appflowy_editor/src/core/document/text_delta.dart'; -import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart'; - -class Node extends ChangeNotifier with LinkedListEntry { - Node({ - required this.type, - Attributes? attributes, - this.parent, - LinkedList? children, - }) : children = children ?? LinkedList(), - _attributes = attributes ?? {} { - for (final child in this.children) { - child.parent = this; - } - } - - factory Node.fromJson(Map json) { - assert(json['type'] is String); - - final jType = json['type'] as String; - final jChildren = json['children'] as List?; - final jAttributes = json['attributes'] != null - ? Attributes.from(json['attributes'] as Map) - : Attributes.from({}); - - final children = LinkedList(); - if (jChildren != null) { - children.addAll( - jChildren.map( - (jChild) => Node.fromJson( - Map.from(jChild), - ), - ), - ); - } - - Node node; - - if (jType == 'text') { - final jDelta = json['delta'] as List?; - final delta = jDelta == null ? Delta() : Delta.fromJson(jDelta); - node = TextNode( - children: children, - attributes: jAttributes, - delta: delta, - ); - } else { - node = Node( - type: jType, - children: children, - attributes: jAttributes, - ); - } - - for (final child in children) { - child.parent = node; - } - - return node; - } - - final String type; - final LinkedList children; - Node? parent; - Attributes _attributes; - - // Renderable - GlobalKey? key; - final layerLink = LayerLink(); - - Attributes get attributes => {..._attributes}; - - String get id { - if (subtype != null) { - return '$type/$subtype'; - } - return type; - } - - String? get subtype { - if (attributes[BuiltInAttributeKey.subtype] is String) { - return attributes[BuiltInAttributeKey.subtype] as String; - } - return null; - } - - Path get path => _computePath(); - - void updateAttributes(Attributes attributes) { - final oldAttributes = this.attributes; - - _attributes = composeAttributes(this.attributes, attributes) ?? {}; - - // Notifies the new attributes - // if attributes contains 'subtype', should notify parent to rebuild node - // else, just notify current node. - bool shouldNotifyParent = - this.attributes['subtype'] != oldAttributes['subtype']; - shouldNotifyParent ? parent?.notifyListeners() : notifyListeners(); - } - - Node? childAtIndex(int index) { - if (children.length <= index || index < 0) { - return null; - } - - return children.elementAt(index); - } - - Node? childAtPath(Path path) { - if (path.isEmpty) { - return this; - } - - return childAtIndex(path.first)?.childAtPath(path.sublist(1)); - } - - void insert(Node entry, {int? index}) { - final length = children.length; - index ??= length; - - if (children.isEmpty) { - entry.parent = this; - children.add(entry); - notifyListeners(); - return; - } - - // If index is out of range, insert at the end. - // If index is negative, insert at the beginning. - // If index is positive, insert at the index. - if (index >= length) { - children.last.insertAfter(entry); - } else if (index <= 0) { - children.first.insertBefore(entry); - } else { - childAtIndex(index)?.insertBefore(entry); - } - } - - @override - void insertAfter(Node entry) { - entry.parent = parent; - super.insertAfter(entry); - - // Notifies the new node. - parent?.notifyListeners(); - } - - @override - void insertBefore(Node entry) { - entry.parent = parent; - super.insertBefore(entry); - - // Notifies the new node. - parent?.notifyListeners(); - } - - @override - void unlink() { - super.unlink(); - - parent?.notifyListeners(); - parent = null; - } - - Map toJson() { - var map = { - 'type': type, - }; - if (children.isNotEmpty) { - map['children'] = - children.map((node) => node.toJson()).toList(growable: false); - } - if (attributes.isNotEmpty) { - map['attributes'] = attributes; - } - return map; - } - - Node copyWith({ - String? type, - LinkedList? children, - Attributes? attributes, - }) { - final node = Node( - type: type ?? this.type, - attributes: attributes ?? {...this.attributes}, - children: children, - ); - if (children == null && this.children.isNotEmpty) { - for (final child in this.children) { - node.children.add( - child.copyWith()..parent = node, - ); - } - } - return node; - } - - Path _computePath([Path previous = const []]) { - if (parent == null) { - return previous; - } - var index = 0; - for (final child in parent!.children) { - if (child == this) { - break; - } - index += 1; - } - return parent!._computePath([index, ...previous]); - } -} - -class TextNode extends Node { - TextNode({ - required Delta delta, - LinkedList? children, - Attributes? attributes, - }) : _delta = delta, - super( - type: 'text', - children: children, - attributes: attributes ?? {}, - ); - - TextNode.empty({Attributes? attributes}) - : _delta = Delta(operations: [TextInsert('')]), - super( - type: 'text', - attributes: attributes ?? {}, - ); - - Delta _delta; - Delta get delta => _delta; - set delta(Delta v) { - _delta = v; - notifyListeners(); - } - - @override - Map toJson() { - final map = super.toJson(); - map['delta'] = delta.toJson(); - return map; - } - - @override - TextNode copyWith({ - String? type = 'text', - LinkedList? children, - Attributes? attributes, - Delta? delta, - }) { - final textNode = TextNode( - children: children, - attributes: attributes ?? this.attributes, - delta: delta ?? this.delta, - ); - if (children == null && this.children.isNotEmpty) { - for (final child in this.children) { - textNode.children.add( - child.copyWith()..parent = textNode, - ); - } - } - return textNode; - } - - String toPlainText() => _delta.toPlainText(); -} - -extension NodeEquality on Iterable { - bool equals(Iterable other) { - if (length != other.length) { - return false; - } - for (var i = 0; i < length; i++) { - if (!_nodeEquals(elementAt(i), other.elementAt(i))) { - return false; - } - } - return true; - } - - bool _nodeEquals(T base, U other) { - if (identical(this, other)) return true; - - return base is Node && - other is Node && - other.type == base.type && - other.children.equals(base.children); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node_iterator.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node_iterator.dart deleted file mode 100644 index d275023ba04fc..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node_iterator.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/document/document.dart'; - -/// [NodeIterator] is used to traverse the nodes in visual order. -class NodeIterator implements Iterator { - NodeIterator({ - required this.document, - required this.startNode, - this.endNode, - }); - - final Document document; - final Node startNode; - final Node? endNode; - - Node? _currentNode; - bool _began = false; - - @override - Node get current => _currentNode!; - - @override - bool moveNext() { - if (!_began) { - _currentNode = startNode; - _began = true; - return true; - } - - final node = _currentNode; - if (node == null) { - return false; - } - - if (endNode != null && endNode == node) { - _currentNode = null; - return false; - } - - if (node.children.isNotEmpty) { - _currentNode = _findLeadingChild(node); - } else if (node.next != null) { - _currentNode = node.next!; - } else { - final parent = node.parent!; - final nextOfParent = parent.next; - if (nextOfParent == null) { - _currentNode = null; - } else { - _currentNode = nextOfParent; - } - } - - return _currentNode != null; - } - - List toList() { - final result = []; - while (moveNext()) { - result.add(current); - } - return result; - } - - Node _findLeadingChild(Node node) { - while (node.children.isNotEmpty) { - node = node.children.first; - } - return node; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/path.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/path.dart deleted file mode 100644 index 5e411407d5104..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/path.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/foundation.dart'; - -typedef Path = List; - -extension PathExtensions on Path { - bool equals(Path other) { - return listEquals(this, other); - } - - bool operator >=(Path other) { - if (equals(other)) { - return true; - } - return this > other; - } - - bool operator >(Path other) { - if (equals(other)) { - return false; - } - final length = min(this.length, other.length); - for (var i = 0; i < length; i++) { - if (this[i] < other[i]) { - return false; - } else if (this[i] > other[i]) { - return true; - } - } - if (this.length < other.length) { - return false; - } - return true; - } - - bool operator <=(Path other) { - if (equals(other)) { - return true; - } - return this < other; - } - - bool operator <(Path other) { - if (equals(other)) { - return false; - } - final length = min(this.length, other.length); - for (var i = 0; i < length; i++) { - if (this[i] > other[i]) { - return false; - } else if (this[i] < other[i]) { - return true; - } - } - if (this.length > other.length) { - return false; - } - return true; - } - - Path get next { - Path nextPath = Path.from(this, growable: true); - if (isEmpty) { - return nextPath; - } - final last = nextPath.last; - return nextPath - ..removeLast() - ..add(last + 1); - } - - Path get previous { - Path previousPath = Path.from(this, growable: true); - if (isEmpty) { - return previousPath; - } - final last = previousPath.last; - return previousPath - ..removeLast() - ..add(max(0, last - 1)); - } - - Path get parent { - if (isEmpty) { - return this; - } - return Path.from(this, growable: true)..removeLast(); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/text_delta.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/text_delta.dart deleted file mode 100644 index 5bf1832f73a8b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/text_delta.dart +++ /dev/null @@ -1,541 +0,0 @@ -import 'dart:collection'; -import 'dart:math'; - -import 'package:flutter/foundation.dart'; - -import 'package:appflowy_editor/src/core/document/attributes.dart'; - -// constant number: 2^53 - 1 -const int _maxInt = 9007199254740991; - -List stringIndexes(String text) { - final indexes = List.filled(text.length, 0); - final iterator = text.runes.iterator; - - while (iterator.moveNext()) { - for (var i = 0; i < iterator.currentSize; i++) { - indexes[iterator.rawIndex + i] = iterator.rawIndex; - } - } - - return indexes; -} - -abstract class TextOperation { - Attributes? get attributes; - int get length; - - bool get isEmpty => length == 0; - - Map toJson(); -} - -class TextInsert extends TextOperation { - TextInsert( - this.text, { - Attributes? attributes, - }) : _attributes = attributes; - - String text; - final Attributes? _attributes; - - @override - int get length => text.length; - - @override - Attributes? get attributes => _attributes != null ? {..._attributes!} : null; - - @override - Map toJson() { - final result = { - 'insert': text, - }; - if (_attributes != null) { - result['attributes'] = attributes; - } - return result; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is TextInsert && - other.text == text && - mapEquals(_attributes, other._attributes); - } - - @override - int get hashCode => text.hashCode ^ _attributes.hashCode; -} - -class TextRetain extends TextOperation { - TextRetain( - this.length, { - Attributes? attributes, - }) : _attributes = attributes; - - @override - int length; - final Attributes? _attributes; - - @override - Attributes? get attributes => _attributes != null ? {..._attributes!} : null; - - @override - Map toJson() { - final result = { - 'retain': length, - }; - if (_attributes != null) { - result['attributes'] = attributes; - } - return result; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is TextRetain && - other.length == length && - mapEquals(_attributes, other._attributes); - } - - @override - int get hashCode => length.hashCode ^ _attributes.hashCode; -} - -class TextDelete extends TextOperation { - TextDelete({ - required this.length, - }); - - @override - int length; - - @override - Attributes? get attributes => null; - - @override - Map toJson() { - return { - 'delete': length, - }; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is TextDelete && other.length == length; - } - - @override - int get hashCode => length.hashCode; -} - -/// Deltas are a simple, yet expressive format that can be used to describe contents and changes. -/// The format is JSON based, and is human readable, yet easily parsible by machines. -/// Deltas can describe any rich text document, includes all text and formatting information, without the ambiguity and complexity of HTML. -/// - -/// Basically borrowed from: https://github.com/quilljs/delta -class Delta extends Iterable { - Delta({ - List? operations, - }) : _operations = operations ?? []; - - factory Delta.fromJson(List list) { - final operations = []; - - for (final value in list) { - if (value is Map) { - final op = _textOperationFromJson(value); - if (op != null) { - operations.add(op); - } - } - } - - return Delta(operations: operations); - } - - final List _operations; - String? _plainText; - List? _runeIndexes; - - void addAll(Iterable textOperations) { - textOperations.forEach(add); - } - - void add(TextOperation textOperation) { - if (textOperation.isEmpty) { - return; - } - _plainText = null; - - if (_operations.isNotEmpty) { - final lastOp = _operations.last; - if (lastOp is TextDelete && textOperation is TextDelete) { - lastOp.length += textOperation.length; - return; - } - if (mapEquals(lastOp.attributes, textOperation.attributes)) { - if (lastOp is TextInsert && textOperation is TextInsert) { - lastOp.text += textOperation.text; - return; - } - // if there is an delete before the insert - // swap the order - if (lastOp is TextDelete && textOperation is TextInsert) { - _operations.removeLast(); - _operations.add(textOperation); - _operations.add(lastOp); - return; - } - if (lastOp is TextRetain && textOperation is TextRetain) { - lastOp.length += textOperation.length; - return; - } - } - } - - _operations.add(textOperation); - } - - /// The slice() method does not change the original string. - /// The start and end parameters specifies the part of the string to extract. - /// The end position is optional. - Delta slice(int start, [int? end]) { - final result = Delta(); - final iterator = _OpIterator(_operations); - int index = 0; - - while ((end == null || index < end) && iterator.hasNext) { - TextOperation? nextOp; - if (index < start) { - nextOp = iterator._next(start - index); - } else { - nextOp = iterator._next(end == null ? null : end - index); - result.add(nextOp); - } - - index += nextOp.length; - } - - return result; - } - - /// Insert operations have an `insert` key defined. - /// A String value represents inserting text. - void insert(String text, {Attributes? attributes}) => - add(TextInsert(text, attributes: attributes)); - - /// Retain operations have a Number `retain` key defined representing the number of characters to keep (other libraries might use the name keep or skip). - /// An optional `attributes` key can be defined with an Object to describe formatting changes to the character range. - /// A value of `null` in the `attributes` Object represents removal of that key. - /// - /// *Note: It is not necessary to retain the last characters of a document as this is implied.* - void retain(int length, {Attributes? attributes}) => - add(TextRetain(length, attributes: attributes)); - - /// Delete operations have a Number `delete` key defined representing the number of characters to delete. - void delete(int length) => add(TextDelete(length: length)); - - /// The length of the string fo the [Delta]. - @override - int get length { - return _operations.fold( - 0, (previousValue, element) => previousValue + element.length); - } - - /// Returns a Delta that is equivalent to applying the operations of own Delta, followed by another Delta. - Delta compose(Delta other) { - final thisIter = _OpIterator(_operations); - final otherIter = _OpIterator(other._operations); - final operations = []; - - final firstOther = otherIter.peek(); - if (firstOther != null && - firstOther is TextRetain && - firstOther.attributes == null) { - int firstLeft = firstOther.length; - while ( - thisIter.peek() is TextInsert && thisIter.peekLength() <= firstLeft) { - firstLeft -= thisIter.peekLength(); - final next = thisIter._next(); - operations.add(next); - } - if (firstOther.length - firstLeft > 0) { - otherIter._next(firstOther.length - firstLeft); - } - } - - final delta = Delta(operations: operations); - while (thisIter.hasNext || otherIter.hasNext) { - if (otherIter.peek() is TextInsert) { - final next = otherIter._next(); - delta.add(next); - } else if (thisIter.peek() is TextDelete) { - final next = thisIter._next(); - delta.add(next); - } else { - // otherIs - final length = min(thisIter.peekLength(), otherIter.peekLength()); - final thisOp = thisIter._next(length); - final otherOp = otherIter._next(length); - final attributes = composeAttributes( - thisOp.attributes, - otherOp.attributes, - keepNull: thisOp is TextRetain, - ); - - if (otherOp is TextRetain && otherOp.length > 0) { - TextOperation? newOp; - if (thisOp is TextRetain) { - newOp = TextRetain(length, attributes: attributes); - } else if (thisOp is TextInsert) { - newOp = TextInsert(thisOp.text, attributes: attributes); - } - - if (newOp != null) { - delta.add(newOp); - } - - // Optimization if rest of other is just retain - if (!otherIter.hasNext && - delta._operations.isNotEmpty && - delta._operations.last == newOp) { - final rest = Delta(operations: thisIter.rest()); - return (delta + rest)..chop(); - } - } else if (otherOp is TextDelete && (thisOp is TextRetain)) { - delta.add(otherOp); - } - } - } - - return delta..chop(); - } - - /// This method joins two Delta together. - Delta operator +(Delta other) { - var operations = [..._operations]; - if (other._operations.isNotEmpty) { - operations.add(other._operations[0]); - operations.addAll(other._operations.sublist(1)); - } - return Delta(operations: operations); - } - - void chop() { - if (_operations.isEmpty) { - return; - } - _plainText = null; - final lastOp = _operations.last; - if (lastOp is TextRetain && (lastOp.attributes?.length ?? 0) == 0) { - _operations.removeLast(); - } - } - - @override - bool operator ==(Object other) { - if (other is! Delta) { - return false; - } - return listEquals(_operations, other._operations); - } - - @override - int get hashCode { - return Object.hashAll(_operations); - } - - /// Returned an inverted delta that has the opposite effect of against a base document delta. - Delta invert(Delta base) { - final inverted = Delta(); - _operations.fold(0, (int previousValue, op) { - if (op is TextInsert) { - inverted.delete(op.length); - } else if (op is TextRetain && op.attributes == null) { - inverted.retain(op.length); - return previousValue + op.length; - } else if (op is TextDelete || op is TextRetain) { - final length = op.length; - final slice = base.slice(previousValue, previousValue + length); - for (final baseOp in slice._operations) { - if (op is TextDelete) { - inverted.add(baseOp); - } else if (op is TextRetain && op.attributes != null) { - inverted.retain( - baseOp.length, - attributes: invertAttributes(baseOp.attributes, op.attributes), - ); - } - } - return previousValue + length; - } - return previousValue; - }); - return inverted..chop(); - } - - List toJson() { - return _operations.map((e) => e.toJson()).toList(); - } - - /// This method will return the position of the previous rune. - /// - /// Since the encoding of the [String] in Dart is UTF-16. - /// If you want to find the previous character of a position, - /// you can' just use the `position - 1` simply. - /// - /// This method can help you to compute the position of the previous character. - int prevRunePosition(int pos) { - if (pos == 0) { - return pos - 1; - } - _plainText ??= - _operations.whereType().map((op) => op.text).join(); - _runeIndexes ??= stringIndexes(_plainText!); - return _runeIndexes![pos - 1]; - } - - /// This method will return the position of the next rune. - /// - /// Since the encoding of the [String] in Dart is UTF-16. - /// If you want to find the previous character of a position, - /// you can' just use the `position + 1` simply. - /// - /// This method can help you to compute the position of the next character. - int nextRunePosition(int pos) { - final stringContent = toPlainText(); - if (pos >= stringContent.length - 1) { - return stringContent.length; - } - _runeIndexes ??= stringIndexes(_plainText!); - - for (var i = pos + 1; i < _runeIndexes!.length; i++) { - if (_runeIndexes![i] != pos) { - return _runeIndexes![i]; - } - } - - return stringContent.length; - } - - String toPlainText() { - _plainText ??= - _operations.whereType().map((op) => op.text).join(); - return _plainText!; - } - - @override - Iterator get iterator => _operations.iterator; - - static TextOperation? _textOperationFromJson(Map json) { - TextOperation? operation; - - if (json['insert'] is String) { - final attributes = json['attributes'] as Map?; - operation = TextInsert( - json['insert'] as String, - attributes: attributes != null ? {...attributes} : null, - ); - } else if (json['retain'] is int) { - final attrs = json['attributes'] as Map?; - operation = TextRetain( - json['retain'] as int, - attributes: attrs != null ? {...attrs} : null, - ); - } else if (json['delete'] is int) { - operation = TextDelete(length: json['delete'] as int); - } - - return operation; - } -} - -class _OpIterator { - _OpIterator( - Iterable operations, - ) : _operations = UnmodifiableListView(operations); - - final UnmodifiableListView _operations; - int _index = 0; - int _offset = 0; - - bool get hasNext { - return peekLength() < _maxInt; - } - - TextOperation? peek() { - if (_index >= _operations.length) { - return null; - } - - return _operations[_index]; - } - - int peekLength() { - if (_index < _operations.length) { - final op = _operations[_index]; - return op.length - _offset; - } - return _maxInt; - } - - TextOperation _next([int? length]) { - length ??= _maxInt; - - if (_index >= _operations.length) { - return TextRetain(_maxInt); - } - - final nextOp = _operations[_index]; - - final offset = _offset; - final opLength = nextOp.length; - if (length >= opLength - offset) { - length = opLength - offset; - _index += 1; - _offset = 0; - } else { - _offset += length; - } - if (nextOp is TextDelete) { - return TextDelete(length: length); - } - - if (nextOp is TextRetain) { - return TextRetain(length, attributes: nextOp.attributes); - } - - if (nextOp is TextInsert) { - return TextInsert( - nextOp.text.substring(offset, offset + length), - attributes: nextOp.attributes, - ); - } - - return TextRetain(_maxInt); - } - - List rest() { - if (!hasNext) { - return []; - } else if (_offset == 0) { - return _operations.sublist(_index); - } else { - final offset = _offset; - final index = _index; - final next = _next(); - final rest = _operations.sublist(_index); - _offset = offset; - _index = index; - return [next] + rest; - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/legacy/built_in_attribute_keys.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/legacy/built_in_attribute_keys.dart deleted file mode 100644 index 8cfe822f46928..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/legacy/built_in_attribute_keys.dart +++ /dev/null @@ -1,60 +0,0 @@ -/// -/// Supported partial rendering types: -/// bold, italic, -/// underline, strikethrough, -/// color, font, -/// href -/// -/// Supported global rendering types: -/// heading: h1, h2, h3, h4, h5, h6, ... -/// block quote, -/// list: ordered list, bulleted list, -/// code block -/// -class BuiltInAttributeKey { - static String bold = 'bold'; - static String italic = 'italic'; - static String underline = 'underline'; - static String strikethrough = 'strikethrough'; - static String color = 'color'; - static String backgroundColor = 'backgroundColor'; - static String font = 'font'; - static String href = 'href'; - - static String subtype = 'subtype'; - static String heading = 'heading'; - static String h1 = 'h1'; - static String h2 = 'h2'; - static String h3 = 'h3'; - static String h4 = 'h4'; - static String h5 = 'h5'; - static String h6 = 'h6'; - - static String bulletedList = 'bulleted-list'; - static String numberList = 'number-list'; - - static String quote = 'quote'; - static String checkbox = 'checkbox'; - static String code = 'code'; - static String number = 'number'; - static String defaultFormating = 'defaultFormating'; - - static List partialStyleKeys = [ - BuiltInAttributeKey.bold, - BuiltInAttributeKey.italic, - BuiltInAttributeKey.underline, - BuiltInAttributeKey.strikethrough, - BuiltInAttributeKey.backgroundColor, - BuiltInAttributeKey.href, - BuiltInAttributeKey.code, - ]; - - static List globalStyleKeys = [ - BuiltInAttributeKey.subtype, - BuiltInAttributeKey.heading, - BuiltInAttributeKey.checkbox, - BuiltInAttributeKey.bulletedList, - BuiltInAttributeKey.numberList, - BuiltInAttributeKey.quote, - ]; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/position.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/position.dart deleted file mode 100644 index e793faa62524c..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/position.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:appflowy_editor/src/core/document/path.dart'; - -class Position { - final Path path; - final int offset; - - Position({ - required this.path, - this.offset = 0, - }); - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is Position && - other.path.equals(path) && - other.offset == offset; - } - - @override - int get hashCode => Object.hash(offset, Object.hashAll(path)); - - @override - String toString() => 'path = $path, offset = $offset'; - - Position copyWith({Path? path, int? offset}) { - return Position( - path: path ?? this.path, - offset: offset ?? this.offset, - ); - } - - Map toJson() { - return { - 'path': path, - 'offset': offset, - }; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/selection.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/selection.dart deleted file mode 100644 index b22d743c7d3d3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/location/selection.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:appflowy_editor/src/core/document/path.dart'; -import 'package:appflowy_editor/src/core/location/position.dart'; - -/// Selection represents the selected area or the cursor area in the editor. -/// -/// [Selection] is directional. -/// -/// 1. forward,the end position is before the start position. -/// 2. backward, the end position is after the start position. -/// 3. collapsed, the end position is equal to the start position. -class Selection { - /// Create a selection with [start], [end]. - Selection({ - required this.start, - required this.end, - }); - - /// Create a selection with [Path], [startOffset] and [endOffset]. - /// - /// The [endOffset] is optional. - /// - /// This constructor will return a collapsed [Selection] if [endOffset] is null. - /// - Selection.single({ - required Path path, - required int startOffset, - int? endOffset, - }) : start = Position(path: path, offset: startOffset), - end = Position(path: path, offset: endOffset ?? startOffset); - - /// Create a collapsed selection with [position]. - Selection.collapsed(Position position) - : start = position, - end = position; - - final Position start; - final Position end; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is Selection && other.start == start && other.end == end; - } - - @override - int get hashCode => start.hashCode ^ end.hashCode; - - @override - String toString() => 'start = $start, end = $end'; - - /// Returns a Boolean indicating whether the selection's start and end points - /// are at the same position. - bool get isCollapsed => start == end; - - /// Returns a Boolean indicating whether the selection's start and end points - /// are at the same path. - bool get isSingle => start.path.equals(end.path); - - /// Returns a Boolean indicating whether the selection is forward. - bool get isForward => - (start.path > end.path) || (isSingle && start.offset > end.offset); - - /// Returns a Boolean indicating whether the selection is backward. - bool get isBackward => - (start.path < end.path) || (isSingle && start.offset < end.offset); - - /// Returns a normalized selection that direction is forward. - Selection get normalized => isBackward ? copyWith() : reversed.copyWith(); - - /// Returns a reversed selection. - Selection get reversed => copyWith(start: end, end: start); - - /// Returns the offset in the starting position under the normalized selection. - int get startIndex => normalized.start.offset; - - /// Returns the offset in the ending position under the normalized selection. - int get endIndex => normalized.end.offset; - - int get length => endIndex - startIndex; - - /// Collapses the current selection to a single point. - /// - /// If [atStart] is true, the selection will be collapsed to the start point. - /// If [atStart] is false, the selection will be collapsed to the end point. - Selection collapse({bool atStart = false}) { - if (atStart) { - return copyWith(end: start); - } else { - return copyWith(start: end); - } - } - - Selection copyWith({Position? start, Position? end}) { - return Selection( - start: start ?? this.start, - end: end ?? this.end, - ); - } - - Map toJson() { - return { - 'start': start.toJson(), - 'end': end.toJson(), - }; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/operation.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/operation.dart deleted file mode 100644 index 31662a61ca1fd..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/operation.dart +++ /dev/null @@ -1,273 +0,0 @@ -import 'package:flutter/foundation.dart'; - -import 'package:appflowy_editor/src/core/document/attributes.dart'; -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/document/path.dart'; -import 'package:appflowy_editor/src/core/document/text_delta.dart'; - -/// [Operation] represents a change to a [Document]. -abstract class Operation { - Operation( - this.path, - ); - - factory Operation.fromJson() => throw UnimplementedError(); - - final Path path; - - /// Inverts the operation. - /// - /// Returns the inverted operation. - Operation invert(); - - /// Returns the JSON representation of the operation. - Map toJson(); - - Operation copyWith({Path? path}); -} - -/// [InsertOperation] represents an insert operation. -class InsertOperation extends Operation { - InsertOperation( - super.path, - this.nodes, - ); - - factory InsertOperation.fromJson(Map json) { - final path = json['path'] as Path; - final nodes = (json['nodes'] as List) - .map((n) => Node.fromJson(n)) - .toList(growable: false); - return InsertOperation(path, nodes); - } - - final Iterable nodes; - - @override - Operation invert() => DeleteOperation(path, nodes); - - @override - Map toJson() { - return { - 'op': 'insert', - 'path': path, - 'nodes': nodes.map((n) => n.toJson()).toList(growable: false), - }; - } - - @override - Operation copyWith({Path? path}) { - return InsertOperation(path ?? this.path, nodes); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is InsertOperation && - other.path.equals(path) && - other.nodes.equals(nodes); - } - - @override - int get hashCode => path.hashCode ^ Object.hashAll(nodes); -} - -/// [DeleteOperation] represents a delete operation. -class DeleteOperation extends Operation { - DeleteOperation( - super.path, - this.nodes, - ); - - factory DeleteOperation.fromJson(Map json) { - final path = json['path'] as Path; - final nodes = (json['nodes'] as List) - .map((n) => Node.fromJson(n)) - .toList(growable: false); - return DeleteOperation(path, nodes); - } - - final Iterable nodes; - - @override - Operation invert() => InsertOperation(path, nodes); - - @override - Map toJson() { - return { - 'op': 'delete', - 'path': path, - 'nodes': nodes.map((n) => n.toJson()).toList(growable: false), - }; - } - - @override - Operation copyWith({Path? path}) { - return DeleteOperation(path ?? this.path, nodes); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is DeleteOperation && - other.path.equals(path) && - other.nodes.equals(nodes); - } - - @override - int get hashCode => path.hashCode ^ Object.hashAll(nodes); -} - -/// [UpdateOperation] represents an attributes update operation. -class UpdateOperation extends Operation { - UpdateOperation( - super.path, - this.attributes, - this.oldAttributes, - ); - - factory UpdateOperation.fromJson(Map json) { - final path = json['path'] as Path; - final oldAttributes = json['oldAttributes'] as Attributes; - final attributes = json['attributes'] as Attributes; - return UpdateOperation( - path, - attributes, - oldAttributes, - ); - } - - final Attributes attributes; - final Attributes oldAttributes; - - @override - Operation invert() => UpdateOperation( - path, - oldAttributes, - attributes, - ); - - @override - Map toJson() { - return { - 'op': 'update', - 'path': path, - 'attributes': {...attributes}, - 'oldAttributes': {...oldAttributes}, - }; - } - - @override - Operation copyWith({Path? path}) { - return UpdateOperation( - path ?? this.path, - {...attributes}, - {...oldAttributes}, - ); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is UpdateOperation && - other.path.equals(path) && - mapEquals(other.attributes, attributes) && - mapEquals(other.oldAttributes, oldAttributes); - } - - @override - int get hashCode => - path.hashCode ^ attributes.hashCode ^ oldAttributes.hashCode; -} - -/// [UpdateTextOperation] represents a text update operation. -class UpdateTextOperation extends Operation { - UpdateTextOperation( - super.path, - this.delta, - this.inverted, - ); - - factory UpdateTextOperation.fromJson(Map json) { - final path = json['path'] as Path; - final delta = Delta.fromJson(json['delta']); - final inverted = Delta.fromJson(json['inverted']); - return UpdateTextOperation(path, delta, inverted); - } - - final Delta delta; - final Delta inverted; - - @override - Operation invert() => UpdateTextOperation(path, inverted, delta); - - @override - Map toJson() { - return { - 'op': 'update_text', - 'path': path, - 'delta': delta.toJson(), - 'inverted': inverted.toJson(), - }; - } - - @override - Operation copyWith({Path? path}) { - return UpdateTextOperation(path ?? this.path, delta, inverted); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is UpdateTextOperation && - other.path.equals(path) && - other.delta == delta && - other.inverted == inverted; - } - - @override - int get hashCode => delta.hashCode ^ inverted.hashCode; -} - -// TODO(Lucas.Xu): refactor this part -Path transformPath(Path preInsertPath, Path b, [int delta = 1]) { - if (preInsertPath.length > b.length) { - return b; - } - if (preInsertPath.isEmpty || b.isEmpty) { - return b; - } - // check the prefix - for (var i = 0; i < preInsertPath.length - 1; i++) { - if (preInsertPath[i] != b[i]) { - return b; - } - } - final prefix = preInsertPath.sublist(0, preInsertPath.length - 1); - final suffix = b.sublist(preInsertPath.length); - final preInsertLast = preInsertPath.last; - final bAtIndex = b[preInsertPath.length - 1]; - if (preInsertLast <= bAtIndex) { - prefix.add(bAtIndex + delta); - } else { - prefix.add(bAtIndex); - } - prefix.addAll(suffix); - return prefix; -} - -Operation transformOperation(Operation a, Operation b) { - if (a is InsertOperation) { - final newPath = transformPath(a.path, b.path, a.nodes.length); - return b.copyWith(path: newPath); - } else if (a is DeleteOperation) { - final newPath = transformPath(a.path, b.path, -1 * a.nodes.length); - return b.copyWith(path: newPath); - } - // TODO: transform update and textedit - return b; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/transaction.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/transaction.dart deleted file mode 100644 index 4df4adb228e4b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/transaction.dart +++ /dev/null @@ -1,267 +0,0 @@ -import 'dart:math'; - -import 'package:appflowy_editor/src/core/document/attributes.dart'; -import 'package:appflowy_editor/src/core/document/document.dart'; -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/document/path.dart'; -import 'package:appflowy_editor/src/core/document/text_delta.dart'; -import 'package:appflowy_editor/src/core/location/position.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/core/transform/operation.dart'; - -/// A [Transaction] has a list of [Operation] objects that will be applied -/// to the editor. -/// -/// There will be several ways to consume the transaction: -/// 1. Apply to the state to update the UI. -/// 2. Send to the backend to store and do operation transforming. -class Transaction { - Transaction({ - required this.document, - }); - - final Document document; - - /// The operations to be applied. - final List operations = []; - - /// The selection to be applied. - Selection? afterSelection; - - /// The before selection is to be recovered if needed. - Selection? beforeSelection; - - /// Inserts the [Node] at the given [Path]. - void insertNode( - Path path, - Node node, { - bool deepCopy = true, - }) { - insertNodes(path, [node], deepCopy: deepCopy); - } - - /// Inserts a sequence of [Node]s at the given [Path]. - void insertNodes( - Path path, - Iterable nodes, { - bool deepCopy = true, - }) { - if (deepCopy) { - add(InsertOperation(path, nodes.map((e) => e.copyWith()))); - } else { - add(InsertOperation(path, nodes)); - } - } - - /// Updates the attributes of the [Node]. - /// - /// The [attributes] will be merged into the existing attributes. - void updateNode(Node node, Attributes attributes) { - final inverted = invertAttributes(node.attributes, attributes); - add(UpdateOperation( - node.path, - {...attributes}, - inverted, - )); - } - - /// Deletes the [Node] in the document. - void deleteNode(Node node) { - deleteNodesAtPath(node.path); - } - - /// Deletes the [Node]s in the document. - void deleteNodes(Iterable nodes) { - nodes.forEach(deleteNode); - } - - /// Deletes the [Node]s at the given [Path]. - /// - /// The [length] indicates the number of consecutive deletions, - /// including the node of the current path. - void deleteNodesAtPath(Path path, [int length = 1]) { - if (path.isEmpty) return; - final nodes = []; - final parent = path.parent; - for (var i = 0; i < length; i++) { - final node = document.nodeAtPath(parent + [path.last + i]); - if (node == null) { - break; - } - nodes.add(node); - } - add(DeleteOperation(path, nodes)); - } - - /// Update the [TextNode]s with the given [Delta]. - void updateText(TextNode textNode, Delta delta) { - final inverted = delta.invert(textNode.delta); - add(UpdateTextOperation(textNode.path, delta, inverted)); - } - - /// Returns the JSON representation of the transaction. - Map toJson() { - final json = {}; - if (operations.isNotEmpty) { - json['operations'] = operations.map((o) => o.toJson()).toList(); - } - if (afterSelection != null) { - json['after_selection'] = afterSelection!.toJson(); - } - if (beforeSelection != null) { - json['before_selection'] = beforeSelection!.toJson(); - } - return json; - } - - /// Adds an operation to the transaction. - /// This method will merge operations if they are both TextEdits. - /// - /// Also, this method will transform the path of the operations - /// to avoid conflicts. - void add(Operation op, {bool transform = true}) { - final Operation? last = operations.isEmpty ? null : operations.last; - if (last != null) { - if (op is UpdateTextOperation && - last is UpdateTextOperation && - op.path.equals(last.path)) { - final newOp = UpdateTextOperation( - op.path, - last.delta.compose(op.delta), - op.inverted.compose(last.inverted), - ); - operations[operations.length - 1] = newOp; - return; - } - } - if (transform) { - for (var i = 0; i < operations.length; i++) { - op = transformOperation(operations[i], op); - } - } - if (op is UpdateTextOperation && op.delta.isEmpty) { - return; - } - operations.add(op); - } -} - -extension TextTransaction on Transaction { - void mergeText( - TextNode first, - TextNode second, { - int? firstOffset, - int secondOffset = 0, - }) { - final firstLength = first.delta.length; - final secondLength = second.delta.length; - firstOffset ??= firstLength; - updateText( - first, - Delta() - ..retain(firstOffset) - ..delete(firstLength - firstOffset) - ..addAll(second.delta.slice(secondOffset, secondLength)), - ); - afterSelection = Selection.collapsed(Position( - path: first.path, - offset: firstOffset, - )); - } - - /// Inserts the text content at a specified index. - /// - /// Optionally, you may specify formatting attributes that are applied to the inserted string. - /// By default, the formatting attributes before the insert position will be reused. - void insertText( - TextNode textNode, - int index, - String text, { - Attributes? attributes, - }) { - var newAttributes = attributes; - if (index != 0 && attributes == null) { - newAttributes = - textNode.delta.slice(max(index - 1, 0), index).first.attributes; - if (newAttributes != null) { - newAttributes = {...newAttributes}; // make a copy - } - } - updateText( - textNode, - Delta() - ..retain(index) - ..insert(text, attributes: newAttributes), - ); - afterSelection = Selection.collapsed( - Position(path: textNode.path, offset: index + text.length), - ); - } - - /// Assigns a formatting attributes to a range of text. - void formatText( - TextNode textNode, - int index, - int length, - Attributes attributes, - ) { - afterSelection = beforeSelection; - updateText( - textNode, - Delta() - ..retain(index) - ..retain(length, attributes: attributes), - ); - } - - /// Deletes the text of specified length starting at index. - void deleteText( - TextNode textNode, - int index, - int length, - ) { - updateText( - textNode, - Delta() - ..retain(index) - ..delete(length), - ); - afterSelection = Selection.collapsed( - Position(path: textNode.path, offset: index), - ); - } - - /// Replaces the text of specified length starting at index. - /// - /// Optionally, you may specify formatting attributes that are applied to the inserted string. - /// By default, the formatting attributes before the insert position will be reused. - void replaceText( - TextNode textNode, - int index, - int length, - String text, { - Attributes? attributes, - }) { - var newAttributes = attributes; - if (index != 0 && attributes == null) { - newAttributes = - textNode.delta.slice(max(index - 1, 0), index).first.attributes; - if (newAttributes != null) { - newAttributes = {...newAttributes}; // make a copy - } - } - updateText( - textNode, - Delta() - ..retain(index) - ..delete(length) - ..insert(text, attributes: newAttributes), - ); - afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: index + text.length, - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart deleted file mode 100644 index 95bab3c231147..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'dart:async'; -import 'package:appflowy_editor/src/infra/log.dart'; -import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; -import 'package:appflowy_editor/src/render/style/editor_style.dart'; -import 'package:appflowy_editor/src/service/service.dart'; -import 'package:flutter/material.dart'; - -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/core/document/document.dart'; -import 'package:appflowy_editor/src/core/transform/operation.dart'; -import 'package:appflowy_editor/src/core/transform/transaction.dart'; -import 'package:appflowy_editor/src/history/undo_manager.dart'; - -class ApplyOptions { - /// This flag indicates that - /// whether the transaction should be recorded into - /// the undo stack - final bool recordUndo; - final bool recordRedo; - const ApplyOptions({ - this.recordUndo = true, - this.recordRedo = false, - }); -} - -enum CursorUpdateReason { - uiEvent, - others, -} - -/// The state of the editor. -/// -/// The state includes: -/// - The document to render -/// - The state of the selection -/// -/// [EditorState] also includes the services of the editor: -/// - Selection service -/// - Scroll service -/// - Keyboard service -/// - Input service -/// - Toolbar service -/// -/// In consideration of collaborative editing, -/// all the mutations should be applied through [Transaction]. -/// -/// Mutating the document with document's API is not recommended. -class EditorState { - final Document document; - - // Service reference. - final service = FlowyService(); - - /// Configures log output parameters, - /// such as log level and log output callbacks, - /// with this variable. - LogConfiguration get logConfiguration => LogConfiguration(); - - /// Stores the selection menu items. - List selectionMenuItems = []; - - /// Operation stream. - Stream get transactionStream => _observer.stream; - final StreamController _observer = StreamController.broadcast(); - - late ThemeData themeData; - EditorStyle get editorStyle => - themeData.extension() ?? EditorStyle.light; - - final UndoManager undoManager = UndoManager(); - Selection? _cursorSelection; - - // TODO: only for testing. - bool disableSealTimer = false; - - bool editable = true; - - Transaction get transaction { - final transaction = Transaction(document: document); - transaction.beforeSelection = _cursorSelection; - return transaction; - } - - Selection? get cursorSelection { - return _cursorSelection; - } - - RenderBox? get renderBox { - final renderObject = - service.scrollServiceKey.currentContext?.findRenderObject(); - if (renderObject != null && renderObject is RenderBox) { - return renderObject; - } - return null; - } - - updateCursorSelection(Selection? cursorSelection, - [CursorUpdateReason reason = CursorUpdateReason.others]) { - // broadcast to other users here - if (reason != CursorUpdateReason.uiEvent) { - service.selectionService.updateSelection(cursorSelection); - } - _cursorSelection = cursorSelection; - } - - Timer? _debouncedSealHistoryItemTimer; - - EditorState({ - required this.document, - }) { - undoManager.state = this; - } - - factory EditorState.empty() { - return EditorState(document: Document.empty()); - } - - /// Apply the transaction to the state. - /// - /// The options can be used to determine whether the editor - /// should record the transaction in undo/redo stack. - apply(Transaction transaction, - [ApplyOptions options = const ApplyOptions(recordUndo: true)]) { - if (!editable) { - return; - } - // TODO: validate the transation. - for (final op in transaction.operations) { - _applyOperation(op); - } - - _observer.add(transaction); - - WidgetsBinding.instance.addPostFrameCallback((_) { - updateCursorSelection(transaction.afterSelection); - }); - - if (options.recordUndo) { - final undoItem = undoManager.getUndoHistoryItem(); - undoItem.addAll(transaction.operations); - if (undoItem.beforeSelection == null && - transaction.beforeSelection != null) { - undoItem.beforeSelection = transaction.beforeSelection; - } - undoItem.afterSelection = transaction.afterSelection; - _debouncedSealHistoryItem(); - } else if (options.recordRedo) { - final redoItem = HistoryItem(); - redoItem.addAll(transaction.operations); - redoItem.beforeSelection = transaction.beforeSelection; - redoItem.afterSelection = transaction.afterSelection; - undoManager.redoStack.push(redoItem); - } - } - - _debouncedSealHistoryItem() { - if (disableSealTimer) { - return; - } - _debouncedSealHistoryItemTimer?.cancel(); - _debouncedSealHistoryItemTimer = - Timer(const Duration(milliseconds: 1000), () { - if (undoManager.undoStack.isNonEmpty) { - Log.editor.debug('Seal history item'); - final last = undoManager.undoStack.last; - last.seal(); - } - }); - } - - _applyOperation(Operation op) { - if (op is InsertOperation) { - document.insert(op.path, op.nodes); - } else if (op is UpdateOperation) { - document.update(op.path, op.attributes); - } else if (op is DeleteOperation) { - document.delete(op.path, op.nodes.length); - } else if (op is UpdateTextOperation) { - document.updateText(op.path, op.delta); - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/attributes_extension.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/attributes_extension.dart deleted file mode 100644 index 816747fdad6aa..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/attributes_extension.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:appflowy_editor/src/core/document/attributes.dart'; -import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart'; -import 'package:flutter/material.dart'; - -extension NodeAttributesExtensions on Attributes { - String? get heading { - if (containsKey(BuiltInAttributeKey.subtype) && - containsKey(BuiltInAttributeKey.heading) && - this[BuiltInAttributeKey.subtype] == BuiltInAttributeKey.heading && - this[BuiltInAttributeKey.heading] is String) { - return this[BuiltInAttributeKey.heading]; - } - return null; - } - - bool get quote { - return containsKey(BuiltInAttributeKey.quote); - } - - num? get number { - if (containsKey(BuiltInAttributeKey.number) && - this[BuiltInAttributeKey.number] is num) { - return this[BuiltInAttributeKey.number]; - } - return null; - } - - bool get code { - if (containsKey(BuiltInAttributeKey.code) && - this[BuiltInAttributeKey.code] is bool) { - return this[BuiltInAttributeKey.code]; - } - return false; - } - - bool get check { - if (containsKey(BuiltInAttributeKey.checkbox) && - this[BuiltInAttributeKey.checkbox] is bool) { - return this[BuiltInAttributeKey.checkbox]; - } - return false; - } -} - -extension DeltaAttributesExtensions on Attributes { - bool get bold { - return (containsKey(BuiltInAttributeKey.bold) && - this[BuiltInAttributeKey.bold] == true); - } - - bool get italic { - return (containsKey(BuiltInAttributeKey.italic) && - this[BuiltInAttributeKey.italic] == true); - } - - bool get underline { - return (containsKey(BuiltInAttributeKey.underline) && - this[BuiltInAttributeKey.underline] == true); - } - - bool get strikethrough { - return (containsKey(BuiltInAttributeKey.strikethrough) && - this[BuiltInAttributeKey.strikethrough] == true); - } - - static const whiteInt = 0XFFFFFFFF; - - Color? get color { - if (containsKey(BuiltInAttributeKey.color) && - this[BuiltInAttributeKey.color] is String) { - return Color( - // If the parse fails returns white by default - int.tryParse(this[BuiltInAttributeKey.color]) ?? whiteInt, - ); - } - return null; - } - - Color? get backgroundColor { - if (containsKey(BuiltInAttributeKey.backgroundColor) && - this[BuiltInAttributeKey.backgroundColor] is String) { - return Color( - int.tryParse(this[BuiltInAttributeKey.backgroundColor]) ?? whiteInt); - } - return null; - } - - String? get href { - if (containsKey(BuiltInAttributeKey.href) && - this[BuiltInAttributeKey.href] is String) { - return this[BuiltInAttributeKey.href]; - } - return null; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/color_extension.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/color_extension.dart deleted file mode 100644 index 7228127104bc9..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/color_extension.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/painting.dart'; - -extension ColorExtension on Color { - /// Try to parse the `rgba(red, greed, blue, alpha)` - /// from the string. - static Color? tryFromRgbaString(String colorString) { - final reg = RegExp(r'rgba\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)'); - final match = reg.firstMatch(colorString); - if (match == null) { - return null; - } - - if (match.groupCount < 4) { - return null; - } - final redStr = match.group(1); - final greenStr = match.group(2); - final blueStr = match.group(3); - final alphaStr = match.group(4); - - final red = redStr != null ? int.tryParse(redStr) : null; - final green = greenStr != null ? int.tryParse(greenStr) : null; - final blue = blueStr != null ? int.tryParse(blueStr) : null; - final alpha = alphaStr != null ? int.tryParse(alphaStr) : null; - - if (red == null || green == null || blue == null || alpha == null) { - return null; - } - - return Color.fromARGB(alpha, red, green, blue); - } - - String toRgbaString() { - return 'rgba($red, $green, $blue, $alpha)'; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/editor_state_extensions.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/editor_state_extensions.dart deleted file mode 100644 index 56b0c7726fdaf..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/editor_state_extensions.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; - -extension EditorStateExtensions on EditorState { - List get selectedTextNodes => - service.selectionService.currentSelectedNodes - .whereType() - .toList(growable: false); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/node_extensions.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/node_extensions.dart deleted file mode 100644 index 82d28bd4690c1..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/node_extensions.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/document/path.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/extensions/object_extensions.dart'; -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:flutter/material.dart'; - -extension NodeExtensions on Node { - RenderBox? get renderBox => - key?.currentContext?.findRenderObject()?.unwrapOrNull(); - - BuildContext? get context => key?.currentContext; - SelectableMixin? get selectable => - key?.currentState?.unwrapOrNull(); - - bool inSelection(Selection selection) { - if (selection.start.path <= selection.end.path) { - return selection.start.path <= path && path <= selection.end.path; - } else { - return selection.end.path <= path && path <= selection.start.path; - } - } - - Rect get rect { - if (renderBox != null) { - final boxOffset = renderBox!.localToGlobal(Offset.zero); - return boxOffset & renderBox!.size; - } - return Rect.zero; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/object_extensions.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/object_extensions.dart deleted file mode 100644 index b1b6e53512559..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/object_extensions.dart +++ /dev/null @@ -1,8 +0,0 @@ -extension FlowyObjectExtensions on Object { - T? unwrapOrNull() { - if (this is T) { - return this as T; - } - return null; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart deleted file mode 100644 index 48538f8bfbd69..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart +++ /dev/null @@ -1,205 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/document/path.dart'; -import 'package:appflowy_editor/src/core/location/position.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/core/document/text_delta.dart'; -import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart'; - -extension TextNodeExtension on TextNode { - T? getAttributeInSelection(Selection selection, String styleKey) { - final ops = delta.whereType(); - final startOffset = - selection.isBackward ? selection.start.offset : selection.end.offset; - final endOffset = - selection.isBackward ? selection.end.offset : selection.start.offset; - var start = 0; - for (final op in ops) { - if (start >= endOffset) { - break; - } - final length = op.length; - if (start < endOffset && start + length > startOffset) { - final attributes = op.attributes; - if (attributes != null && attributes[styleKey] is T?) { - return attributes[styleKey]; - } - } - start += length; - } - return null; - } - - bool allSatisfyLinkInSelection(Selection selection) => - allSatisfyInSelection(selection, BuiltInAttributeKey.href, (value) { - return value != null; - }); - - bool allSatisfyBoldInSelection(Selection selection) => - allSatisfyInSelection(selection, BuiltInAttributeKey.bold, (value) { - return value == true; - }); - - bool allSatisfyItalicInSelection(Selection selection) => - allSatisfyInSelection(selection, BuiltInAttributeKey.italic, (value) { - return value == true; - }); - - bool allSatisfyUnderlineInSelection(Selection selection) => - allSatisfyInSelection(selection, BuiltInAttributeKey.underline, (value) { - return value == true; - }); - - bool allSatisfyStrikethroughInSelection(Selection selection) => - allSatisfyInSelection(selection, BuiltInAttributeKey.strikethrough, - (value) { - return value == true; - }); - - bool allSatisfyCodeInSelection(Selection selection) => - allSatisfyInSelection(selection, BuiltInAttributeKey.code, (value) { - return value == true; - }); - - bool allSatisfyInSelection( - Selection selection, - String styleKey, - bool Function(dynamic value) test, - ) { - if (BuiltInAttributeKey.globalStyleKeys.contains(styleKey)) { - if (attributes.containsKey(styleKey)) { - return test(attributes[styleKey]); - } - } else if (BuiltInAttributeKey.partialStyleKeys.contains(styleKey)) { - final ops = delta.whereType(); - final startOffset = - selection.isBackward ? selection.start.offset : selection.end.offset; - final endOffset = - selection.isBackward ? selection.end.offset : selection.start.offset; - var start = 0; - for (final op in ops) { - if (start >= endOffset) { - break; - } - final length = op.length; - if (start < endOffset && start + length > startOffset) { - if (op.attributes == null || - !op.attributes!.containsKey(styleKey) || - !test(op.attributes![styleKey])) { - return false; - } - } - start += length; - } - return true; - } - return false; - } - - bool allNotSatisfyInSelection( - String styleKey, - dynamic value, - Selection selection, - ) { - final ops = delta.whereType(); - final startOffset = - selection.isBackward ? selection.start.offset : selection.end.offset; - final endOffset = - selection.isBackward ? selection.end.offset : selection.start.offset; - var start = 0; - for (final op in ops) { - if (start >= endOffset) { - break; - } - final length = op.length; - if (start < endOffset && start + length > startOffset) { - if (op.attributes != null && - op.attributes!.containsKey(styleKey) && - op.attributes![styleKey] == value) { - return false; - } - } - start += length; - } - return true; - } -} - -extension TextNodesExtension on List { - bool allSatisfyBoldInSelection(Selection selection) => allSatisfyInSelection( - selection, - BuiltInAttributeKey.bold, - (value) => value == true, - ); - - bool allSatisfyItalicInSelection(Selection selection) => - allSatisfyInSelection( - selection, - BuiltInAttributeKey.italic, - (value) => value == true, - ); - - bool allSatisfyUnderlineInSelection(Selection selection) => - allSatisfyInSelection( - selection, - BuiltInAttributeKey.underline, - (value) => value == true, - ); - - bool allSatisfyStrikethroughInSelection(Selection selection) => - allSatisfyInSelection( - selection, - BuiltInAttributeKey.strikethrough, - (value) => value == true, - ); - - bool allSatisfyInSelection( - Selection selection, - String styleKey, - bool Function(dynamic value) test, - ) { - if (isEmpty) { - return false; - } - if (length == 1) { - return first.allSatisfyInSelection(selection, styleKey, (value) { - return test(value); - }); - } else { - for (var i = 0; i < length; i++) { - final node = this[i]; - final Selection newSelection; - if (i == 0 && node.path.equals(selection.start.path)) { - if (selection.isBackward) { - newSelection = selection.copyWith( - end: Position(path: node.path, offset: node.toPlainText().length), - ); - } else { - newSelection = selection.copyWith( - end: Position(path: node.path, offset: 0), - ); - } - } else if (i == length - 1 && node.path.equals(selection.end.path)) { - if (selection.isBackward) { - newSelection = selection.copyWith( - start: Position(path: node.path, offset: 0), - ); - } else { - newSelection = selection.copyWith( - start: - Position(path: node.path, offset: node.toPlainText().length), - ); - } - } else { - newSelection = Selection( - start: Position(path: node.path, offset: 0), - end: Position(path: node.path, offset: node.toPlainText().length), - ); - } - if (!node.allSatisfyInSelection(newSelection, styleKey, test)) { - return false; - } - } - return true; - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_style_extension.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_style_extension.dart deleted file mode 100644 index 5e2828bf07560..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_style_extension.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; - -extension TextSpanExtensions on TextSpan { - TextSpan copyWith({ - String? text, - TextStyle? style, - List? children, - GestureRecognizer? recognizer, - String? semanticsLabel, - }) { - return TextSpan( - text: text ?? this.text, - style: style ?? this.style, - children: children ?? this.children, - recognizer: recognizer ?? this.recognizer, - semanticsLabel: semanticsLabel ?? this.semanticsLabel, - ); - } - - TextSpan updateTextStyle(TextStyle? other) { - if (other == null) { - return this; - } - return copyWith( - style: style?.combine(other), - children: children?.map((child) { - if (child is TextSpan) { - return child.updateTextStyle(other); - } - return child; - }).toList(growable: false), - ); - } -} - -extension TextStyleExtensions on TextStyle { - TextStyle combine(TextStyle? other) { - if (other == null) { - return this; - } - if (!other.inherit) { - return other; - } - - return copyWith( - color: other.color, - backgroundColor: other.backgroundColor, - fontSize: other.fontSize, - fontWeight: other.fontWeight, - fontStyle: other.fontStyle, - letterSpacing: other.letterSpacing, - wordSpacing: other.wordSpacing, - textBaseline: other.textBaseline, - height: other.height, - leadingDistribution: other.leadingDistribution, - locale: other.locale, - foreground: other.foreground, - background: other.background, - shadows: other.shadows, - fontFeatures: other.fontFeatures, - decoration: TextDecoration.combine([ - if (decoration != null) decoration!, - if (other.decoration != null) other.decoration!, - ]), - decorationColor: other.decorationColor, - decorationStyle: other.decorationStyle, - decorationThickness: other.decorationThickness, - fontFamilyFallback: other.fontFamilyFallback, - overflow: other.overflow, - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/theme_extension.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/theme_extension.dart deleted file mode 100644 index 9b8f01aafabdc..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/theme_extension.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -extension ThemeExtension on ThemeData { - T? extensionOrNull() { - if (extensions.containsKey(T)) { - return extensions[T] as T; - } - return null; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart deleted file mode 100644 index 1c0ea30c82b36..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:url_launcher/url_launcher_string.dart'; - -Future safeLaunchUrl(String? href) async { - if (href == null) { - return Future.value(false); - } - final uri = Uri.parse(href); - // url_launcher cannot open a link without scheme. - final newHref = (uri.scheme.isNotEmpty ? href : 'http://$href').trim(); - if (await canLaunchUrlString(newHref)) { - await launchUrlString(newHref); - } - return Future.value(true); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/flutter/overlay.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/flutter/overlay.dart deleted file mode 100644 index 0a91229e0a830..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/flutter/overlay.dart +++ /dev/null @@ -1,904 +0,0 @@ -// TODO: Remove this file until we update the flutter version to 3.5.x -// -// This file is copied from flutter(3.5.x) repo. -// -// We Need to commit(https://github.com/flutter/flutter/pull/113770) to fix the -// overflow issue. - -// Copyright 2014 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:collection'; -import 'dart:math' as math; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart'; - -/// A place in an [Overlay] that can contain a widget. -/// -/// Overlay entries are inserted into an [Overlay] using the -/// [OverlayState.insert] or [OverlayState.insertAll] functions. To find the -/// closest enclosing overlay for a given [BuildContext], use the [Overlay.of] -/// function. -/// -/// An overlay entry can be in at most one overlay at a time. To remove an entry -/// from its overlay, call the [remove] function on the overlay entry. -/// -/// Because an [Overlay] uses a [Stack] layout, overlay entries can use -/// [Positioned] and [AnimatedPositioned] to position themselves within the -/// overlay. -/// -/// For example, [Draggable] uses an [OverlayEntry] to show the drag avatar that -/// follows the user's finger across the screen after the drag begins. Using the -/// overlay to display the drag avatar lets the avatar float over the other -/// widgets in the app. As the user's finger moves, draggable calls -/// [markNeedsBuild] on the overlay entry to cause it to rebuild. In its build, -/// the entry includes a [Positioned] with its top and left property set to -/// position the drag avatar near the user's finger. When the drag is over, -/// [Draggable] removes the entry from the overlay to remove the drag avatar -/// from view. -/// -/// By default, if there is an entirely [opaque] entry over this one, then this -/// one will not be included in the widget tree (in particular, stateful widgets -/// within the overlay entry will not be instantiated). To ensure that your -/// overlay entry is still built even if it is not visible, set [maintainState] -/// to true. This is more expensive, so should be done with care. In particular, -/// if widgets in an overlay entry with [maintainState] set to true repeatedly -/// call [State.setState], the user's battery will be drained unnecessarily. -/// -/// [OverlayEntry] is a [ChangeNotifier] that notifies when the widget built by -/// [builder] is mounted or unmounted, whose exact state can be queried by -/// [mounted]. -/// -/// See also: -/// -/// * [Overlay] -/// * [OverlayState] -/// * [WidgetsApp] -/// * [MaterialApp] -class OverlayEntry extends ChangeNotifier { - /// Creates an overlay entry. - /// - /// To insert the entry into an [Overlay], first find the overlay using - /// [Overlay.of] and then call [OverlayState.insert]. To remove the entry, - /// call [remove] on the overlay entry itself. - OverlayEntry({ - required this.builder, - bool opaque = false, - bool maintainState = false, - }) : _opaque = opaque, - _maintainState = maintainState; - - /// This entry will include the widget built by this builder in the overlay at - /// the entry's position. - /// - /// To cause this builder to be called again, call [markNeedsBuild] on this - /// overlay entry. - final WidgetBuilder builder; - - /// Whether this entry occludes the entire overlay. - /// - /// If an entry claims to be opaque, then, for efficiency, the overlay will - /// skip building entries below that entry unless they have [maintainState] - /// set. - bool get opaque => _opaque; - bool _opaque; - set opaque(bool value) { - if (_opaque == value) return; - _opaque = value; - _overlay?._didChangeEntryOpacity(); - } - - /// Whether this entry must be included in the tree even if there is a fully - /// [opaque] entry above it. - /// - /// By default, if there is an entirely [opaque] entry over this one, then this - /// one will not be included in the widget tree (in particular, stateful widgets - /// within the overlay entry will not be instantiated). To ensure that your - /// overlay entry is still built even if it is not visible, set [maintainState] - /// to true. This is more expensive, so should be done with care. In particular, - /// if widgets in an overlay entry with [maintainState] set to true repeatedly - /// call [State.setState], the user's battery will be drained unnecessarily. - /// - /// This is used by the [Navigator] and [Route] objects to ensure that routes - /// are kept around even when in the background, so that [Future]s promised - /// from subsequent routes will be handled properly when they complete. - bool get maintainState => _maintainState; - bool _maintainState; - set maintainState(bool value) { - if (_maintainState == value) return; - _maintainState = value; - assert(_overlay != null); - _overlay!._didChangeEntryOpacity(); - } - - /// Whether the [OverlayEntry] is currently mounted in the widget tree. - /// - /// The [OverlayEntry] notifies its listeners when this value changes. - bool get mounted => _mounted; - bool _mounted = false; - void _updateMounted(bool value) { - if (value == _mounted) { - return; - } - _mounted = value; - notifyListeners(); - } - - OverlayState? _overlay; - final GlobalKey<_OverlayEntryWidgetState> _key = - GlobalKey<_OverlayEntryWidgetState>(); - - /// Remove this entry from the overlay. - /// - /// This should only be called once. - /// - /// This method removes this overlay entry from the overlay immediately. The - /// UI will be updated in the same frame if this method is called before the - /// overlay rebuild in this frame; otherwise, the UI will be updated in the - /// next frame. This means that it is safe to call during builds, but also - /// that if you do call this after the overlay rebuild, the UI will not update - /// until the next frame (i.e. many milliseconds later). - void remove() { - assert(_overlay != null); - final OverlayState overlay = _overlay!; - _overlay = null; - if (!overlay.mounted) return; - - overlay._entries.remove(this); - if (SchedulerBinding.instance.schedulerPhase == - SchedulerPhase.persistentCallbacks) { - SchedulerBinding.instance.addPostFrameCallback((Duration duration) { - overlay._markDirty(); - }); - } else { - overlay._markDirty(); - } - } - - /// Cause this entry to rebuild during the next pipeline flush. - /// - /// You need to call this function if the output of [builder] has changed. - void markNeedsBuild() { - _key.currentState?._markNeedsBuild(); - } - - @override - String toString() => - '${describeIdentity(this)}(opaque: $opaque; maintainState: $maintainState)'; -} - -class _OverlayEntryWidget extends StatefulWidget { - const _OverlayEntryWidget({ - required Key key, - required this.entry, - this.tickerEnabled = true, - }) : super(key: key); - - final OverlayEntry entry; - final bool tickerEnabled; - - @override - _OverlayEntryWidgetState createState() => _OverlayEntryWidgetState(); -} - -class _OverlayEntryWidgetState extends State<_OverlayEntryWidget> { - @override - void initState() { - super.initState(); - widget.entry._updateMounted(true); - } - - @override - void dispose() { - widget.entry._updateMounted(false); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return TickerMode( - enabled: widget.tickerEnabled, - child: widget.entry.builder(context), - ); - } - - void _markNeedsBuild() { - setState(() {/* the state that changed is in the builder */}); - } -} - -/// A stack of entries that can be managed independently. -/// -/// Overlays let independent child widgets "float" visual elements on top of -/// other widgets by inserting them into the overlay's stack. The overlay lets -/// each of these widgets manage their participation in the overlay using -/// [OverlayEntry] objects. -/// -/// Although you can create an [Overlay] directly, it's most common to use the -/// overlay created by the [Navigator] in a [WidgetsApp] or a [MaterialApp]. The -/// navigator uses its overlay to manage the visual appearance of its routes. -/// -/// The [Overlay] widget uses a custom stack implementation, which is very -/// similar to the [Stack] widget. The main use case of [Overlay] is related to -/// navigation and being able to insert widgets on top of the pages in an app. -/// To simply display a stack of widgets, consider using [Stack] instead. -/// -/// See also: -/// -/// * [OverlayEntry], the class that is used for describing the overlay entries. -/// * [OverlayState], which is used to insert the entries into the overlay. -/// * [WidgetsApp], which inserts an [Overlay] widget indirectly via its [Navigator]. -/// * [MaterialApp], which inserts an [Overlay] widget indirectly via its [Navigator]. -/// * [Stack], which allows directly displaying a stack of widgets. -class Overlay extends StatefulWidget { - /// Creates an overlay. - /// - /// The initial entries will be inserted into the overlay when its associated - /// [OverlayState] is initialized. - /// - /// Rather than creating an overlay, consider using the overlay that is - /// created by the [Navigator] in a [WidgetsApp] or a [MaterialApp] for the application. - const Overlay({ - Key? key, - this.initialEntries = const [], - this.clipBehavior = Clip.hardEdge, - }) : super(key: key); - - /// The entries to include in the overlay initially. - /// - /// These entries are only used when the [OverlayState] is initialized. If you - /// are providing a new [Overlay] description for an overlay that's already in - /// the tree, then the new entries are ignored. - /// - /// To add entries to an [Overlay] that is already in the tree, use - /// [Overlay.of] to obtain the [OverlayState] (or assign a [GlobalKey] to the - /// [Overlay] widget and obtain the [OverlayState] via - /// [GlobalKey.currentState]), and then use [OverlayState.insert] or - /// [OverlayState.insertAll]. - /// - /// To remove an entry from an [Overlay], use [OverlayEntry.remove]. - final List initialEntries; - - /// {@macro flutter.material.Material.clipBehavior} - /// - /// Defaults to [Clip.hardEdge], and must not be null. - final Clip clipBehavior; - - /// The state from the closest instance of this class that encloses the given context. - /// - /// In debug mode, if the `debugRequiredFor` argument is provided then this - /// function will assert that an overlay was found and will throw an exception - /// if not. The exception attempts to explain that the calling [Widget] (the - /// one given by the `debugRequiredFor` argument) needs an [Overlay] to be - /// present to function. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// OverlayState overlay = Overlay.of(context); - /// ``` - /// - /// If `rootOverlay` is set to true, the state from the furthest instance of - /// this class is given instead. Useful for installing overlay entries - /// above all subsequent instances of [Overlay]. - /// - /// This method can be expensive (it walks the element tree). - static OverlayState? of( - BuildContext context, { - bool rootOverlay = false, - Widget? debugRequiredFor, - }) { - final OverlayState? result = rootOverlay - ? context.findRootAncestorStateOfType() - : context.findAncestorStateOfType(); - assert(() { - if (debugRequiredFor != null && result == null) { - final List information = [ - ErrorSummary('No Overlay widget found.'), - ErrorDescription( - '${debugRequiredFor.runtimeType} widgets require an Overlay widget ancestor for correct operation.'), - ErrorHint( - 'The most common way to add an Overlay to an application is to include a MaterialApp or Navigator widget in the runApp() call.'), - DiagnosticsProperty( - 'The specific widget that failed to find an overlay was', - debugRequiredFor, - style: DiagnosticsTreeStyle.errorProperty), - if (context.widget != debugRequiredFor) - context.describeElement( - 'The context from which that widget was searching for an overlay was'), - ]; - - throw FlutterError.fromParts(information); - } - return true; - }()); - return result; - } - - @override - OverlayState createState() => OverlayState(); -} - -/// The current state of an [Overlay]. -/// -/// Used to insert [OverlayEntry]s into the overlay using the [insert] and -/// [insertAll] functions. -class OverlayState extends State with TickerProviderStateMixin { - final List _entries = []; - - @override - void initState() { - super.initState(); - insertAll(widget.initialEntries); - } - - int _insertionIndex(OverlayEntry? below, OverlayEntry? above) { - assert(above == null || below == null); - if (below != null) return _entries.indexOf(below); - if (above != null) return _entries.indexOf(above) + 1; - return _entries.length; - } - - /// Insert the given entry into the overlay. - /// - /// If `below` is non-null, the entry is inserted just below `below`. - /// If `above` is non-null, the entry is inserted just above `above`. - /// Otherwise, the entry is inserted on top. - /// - /// It is an error to specify both `above` and `below`. - void insert(OverlayEntry entry, {OverlayEntry? below, OverlayEntry? above}) { - assert(_debugVerifyInsertPosition(above, below)); - assert(!_entries.contains(entry), - 'The specified entry is already present in the Overlay.'); - assert(entry._overlay == null, - 'The specified entry is already present in another Overlay.'); - entry._overlay = this; - setState(() { - _entries.insert(_insertionIndex(below, above), entry); - }); - } - - /// Insert all the entries in the given iterable. - /// - /// If `below` is non-null, the entries are inserted just below `below`. - /// If `above` is non-null, the entries are inserted just above `above`. - /// Otherwise, the entries are inserted on top. - /// - /// It is an error to specify both `above` and `below`. - void insertAll(Iterable entries, - {OverlayEntry? below, OverlayEntry? above}) { - assert(_debugVerifyInsertPosition(above, below)); - assert( - entries.every((OverlayEntry entry) => !_entries.contains(entry)), - 'One or more of the specified entries are already present in the Overlay.', - ); - assert( - entries.every((OverlayEntry entry) => entry._overlay == null), - 'One or more of the specified entries are already present in another Overlay.', - ); - if (entries.isEmpty) return; - for (final OverlayEntry entry in entries) { - assert(entry._overlay == null); - entry._overlay = this; - } - setState(() { - _entries.insertAll(_insertionIndex(below, above), entries); - }); - } - - bool _debugVerifyInsertPosition(OverlayEntry? above, OverlayEntry? below, - {Iterable? newEntries}) { - assert( - above == null || below == null, - 'Only one of `above` and `below` may be specified.', - ); - assert( - above == null || - (above._overlay == this && - _entries.contains(above) && - (newEntries?.contains(above) ?? true)), - 'The provided entry used for `above` must be present in the Overlay${newEntries != null ? ' and in the `newEntriesList`' : ''}.', - ); - assert( - below == null || - (below._overlay == this && - _entries.contains(below) && - (newEntries?.contains(below) ?? true)), - 'The provided entry used for `below` must be present in the Overlay${newEntries != null ? ' and in the `newEntriesList`' : ''}.', - ); - return true; - } - - /// Remove all the entries listed in the given iterable, then reinsert them - /// into the overlay in the given order. - /// - /// Entries mention in `newEntries` but absent from the overlay are inserted - /// as if with [insertAll]. - /// - /// Entries not mentioned in `newEntries` but present in the overlay are - /// positioned as a group in the resulting list relative to the entries that - /// were moved, as specified by one of `below` or `above`, which, if - /// specified, must be one of the entries in `newEntries`: - /// - /// If `below` is non-null, the group is positioned just below `below`. - /// If `above` is non-null, the group is positioned just above `above`. - /// Otherwise, the group is left on top, with all the rearranged entries - /// below. - /// - /// It is an error to specify both `above` and `below`. - void rearrange(Iterable newEntries, - {OverlayEntry? below, OverlayEntry? above}) { - final List newEntriesList = newEntries is List - ? newEntries - : newEntries.toList(growable: false); - assert( - _debugVerifyInsertPosition(above, below, newEntries: newEntriesList)); - assert( - newEntriesList.every((OverlayEntry entry) => - entry._overlay == null || entry._overlay == this), - 'One or more of the specified entries are already present in another Overlay.', - ); - assert( - newEntriesList.every((OverlayEntry entry) => - _entries.indexOf(entry) == _entries.lastIndexOf(entry)), - 'One or more of the specified entries are specified multiple times.', - ); - if (newEntriesList.isEmpty) return; - if (listEquals(_entries, newEntriesList)) return; - final LinkedHashSet old = - LinkedHashSet.of(_entries); - for (final OverlayEntry entry in newEntriesList) { - entry._overlay ??= this; - } - setState(() { - _entries.clear(); - _entries.addAll(newEntriesList); - old.removeAll(newEntriesList); - _entries.insertAll(_insertionIndex(below, above), old); - }); - } - - void _markDirty() { - if (mounted) { - setState(() {}); - } - } - - /// (DEBUG ONLY) Check whether a given entry is visible (i.e., not behind an - /// opaque entry). - /// - /// This is an O(N) algorithm, and should not be necessary except for debug - /// asserts. To avoid people depending on it, this function is implemented - /// only in debug mode, and always returns false in release mode. - bool debugIsVisible(OverlayEntry entry) { - bool result = false; - assert(_entries.contains(entry)); - assert(() { - for (int i = _entries.length - 1; i > 0; i -= 1) { - final OverlayEntry candidate = _entries[i]; - if (candidate == entry) { - result = true; - break; - } - if (candidate.opaque) break; - } - return true; - }()); - return result; - } - - void _didChangeEntryOpacity() { - setState(() { - // We use the opacity of the entry in our build function, which means we - // our state has changed. - }); - } - - @override - Widget build(BuildContext context) { - // This list is filled backwards and then reversed below before - // it is added to the tree. - final List children = []; - bool onstage = true; - int onstageCount = 0; - for (int i = _entries.length - 1; i >= 0; i -= 1) { - final OverlayEntry entry = _entries[i]; - if (onstage) { - onstageCount += 1; - children.add(_OverlayEntryWidget( - key: entry._key, - entry: entry, - )); - if (entry.opaque) onstage = false; - } else if (entry.maintainState) { - children.add(_OverlayEntryWidget( - key: entry._key, - entry: entry, - tickerEnabled: false, - )); - } - } - return _Theatre( - skipCount: children.length - onstageCount, - clipBehavior: widget.clipBehavior, - children: children.reversed.toList(growable: false), - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - // TODO(jacobr): use IterableProperty instead as that would - // provide a slightly more consistent string summary of the List. - properties - .add(DiagnosticsProperty>('entries', _entries)); - } -} - -/// Special version of a [Stack], that doesn't layout and render the first -/// [skipCount] children. -/// -/// The first [skipCount] children are considered "offstage". -class _Theatre extends MultiChildRenderObjectWidget { - _Theatre({ - Key? key, - this.skipCount = 0, - this.clipBehavior = Clip.hardEdge, - List children = const [], - }) : assert(skipCount >= 0), - assert(children.length >= skipCount), - super(key: key, children: children); - - final int skipCount; - - final Clip clipBehavior; - - @override - _TheatreElement createElement() => _TheatreElement(this); - - @override - _RenderTheatre createRenderObject(BuildContext context) { - return _RenderTheatre( - skipCount: skipCount, - textDirection: Directionality.of(context), - clipBehavior: clipBehavior, - ); - } - - @override - void updateRenderObject(BuildContext context, _RenderTheatre renderObject) { - renderObject - ..skipCount = skipCount - ..textDirection = Directionality.of(context) - ..clipBehavior = clipBehavior; - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(IntProperty('skipCount', skipCount)); - } -} - -class _TheatreElement extends MultiChildRenderObjectElement { - _TheatreElement(_Theatre widget) : super(widget); - - @override - _RenderTheatre get renderObject => super.renderObject as _RenderTheatre; - - @override - void debugVisitOnstageChildren(ElementVisitor visitor) { - final _Theatre theatre = widget as _Theatre; - assert(children.length >= theatre.skipCount); - children.skip(theatre.skipCount).forEach(visitor); - } -} - -class _RenderTheatre extends RenderBox - with ContainerRenderObjectMixin { - _RenderTheatre({ - List? children, - required TextDirection textDirection, - int skipCount = 0, - Clip clipBehavior = Clip.hardEdge, - }) : assert(skipCount >= 0), - _textDirection = textDirection, - _skipCount = skipCount, - _clipBehavior = clipBehavior { - addAll(children); - } - - bool _hasVisualOverflow = false; - - @override - void setupParentData(RenderBox child) { - if (child.parentData is! StackParentData) { - child.parentData = StackParentData(); - } - } - - Alignment? _resolvedAlignment; - - void _resolve() { - if (_resolvedAlignment != null) return; - _resolvedAlignment = AlignmentDirectional.topStart.resolve(textDirection); - } - - void _markNeedResolution() { - _resolvedAlignment = null; - markNeedsLayout(); - } - - TextDirection get textDirection => _textDirection; - TextDirection _textDirection; - set textDirection(TextDirection value) { - if (_textDirection == value) return; - _textDirection = value; - _markNeedResolution(); - } - - int get skipCount => _skipCount; - int _skipCount; - set skipCount(int value) { - if (_skipCount != value) { - _skipCount = value; - markNeedsLayout(); - } - } - - /// {@macro flutter.material.Material.clipBehavior} - /// - /// Defaults to [Clip.hardEdge], and must not be null. - Clip get clipBehavior => _clipBehavior; - Clip _clipBehavior = Clip.hardEdge; - set clipBehavior(Clip value) { - if (value != _clipBehavior) { - _clipBehavior = value; - markNeedsPaint(); - markNeedsSemanticsUpdate(); - } - } - - RenderBox? get _firstOnstageChild { - if (skipCount == super.childCount) { - return null; - } - RenderBox? child = super.firstChild; - for (int toSkip = skipCount; toSkip > 0; toSkip--) { - final StackParentData childParentData = - child!.parentData! as StackParentData; - child = childParentData.nextSibling; - assert(child != null); - } - return child; - } - - RenderBox? get _lastOnstageChild => - skipCount == super.childCount ? null : lastChild; - - int get _onstageChildCount => childCount - skipCount; - - @override - double computeMinIntrinsicWidth(double height) { - return RenderStack.getIntrinsicDimension(_firstOnstageChild, - (RenderBox child) => child.getMinIntrinsicWidth(height)); - } - - @override - double computeMaxIntrinsicWidth(double height) { - return RenderStack.getIntrinsicDimension(_firstOnstageChild, - (RenderBox child) => child.getMaxIntrinsicWidth(height)); - } - - @override - double computeMinIntrinsicHeight(double width) { - return RenderStack.getIntrinsicDimension(_firstOnstageChild, - (RenderBox child) => child.getMinIntrinsicHeight(width)); - } - - @override - double computeMaxIntrinsicHeight(double width) { - return RenderStack.getIntrinsicDimension(_firstOnstageChild, - (RenderBox child) => child.getMaxIntrinsicHeight(width)); - } - - @override - double? computeDistanceToActualBaseline(TextBaseline baseline) { - assert(!debugNeedsLayout); - double? result; - RenderBox? child = _firstOnstageChild; - while (child != null) { - assert(!child.debugNeedsLayout); - final StackParentData childParentData = - child.parentData! as StackParentData; - double? candidate = child.getDistanceToActualBaseline(baseline); - if (candidate != null) { - candidate += childParentData.offset.dy; - if (result != null) { - result = math.min(result, candidate); - } else { - result = candidate; - } - } - child = childParentData.nextSibling; - } - return result; - } - - @override - bool get sizedByParent => true; - - @override - Size computeDryLayout(BoxConstraints constraints) { - assert(constraints.biggest.isFinite); - return constraints.biggest; - } - - @override - void performLayout() { - _hasVisualOverflow = false; - - if (_onstageChildCount == 0) { - return; - } - - _resolve(); - assert(_resolvedAlignment != null); - - // Same BoxConstraints as used by RenderStack for StackFit.expand. - final BoxConstraints nonPositionedConstraints = - BoxConstraints.tight(constraints.biggest); - - RenderBox? child = _firstOnstageChild; - while (child != null) { - final StackParentData childParentData = - child.parentData! as StackParentData; - - if (!childParentData.isPositioned) { - child.layout(nonPositionedConstraints, parentUsesSize: true); - childParentData.offset = - _resolvedAlignment!.alongOffset(size - child.size as Offset); - } else { - _hasVisualOverflow = RenderStack.layoutPositionedChild( - child, childParentData, size, _resolvedAlignment!) || - _hasVisualOverflow; - } - - assert(child.parentData == childParentData); - child = childParentData.nextSibling; - } - } - - @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - RenderBox? child = _lastOnstageChild; - for (int i = 0; i < _onstageChildCount; i++) { - assert(child != null); - final StackParentData childParentData = - child!.parentData! as StackParentData; - final bool isHit = result.addWithPaintOffset( - offset: childParentData.offset, - position: position, - hitTest: (BoxHitTestResult result, Offset transformed) { - assert(transformed == position - childParentData.offset); - return child!.hitTest(result, position: transformed); - }, - ); - if (isHit) return true; - child = childParentData.previousSibling; - } - return false; - } - - @protected - void paintStack(PaintingContext context, Offset offset) { - RenderBox? child = _firstOnstageChild; - while (child != null) { - final StackParentData childParentData = - child.parentData! as StackParentData; - context.paintChild(child, childParentData.offset + offset); - child = childParentData.nextSibling; - } - } - - @override - void paint(PaintingContext context, Offset offset) { - _hasVisualOverflow = true; - if (_hasVisualOverflow && clipBehavior != Clip.none) { - _clipRectLayer.layer = context.pushClipRect( - needsCompositing, - offset, - Offset.zero & size, - paintStack, - clipBehavior: clipBehavior, - oldLayer: _clipRectLayer.layer, - ); - } else { - _clipRectLayer.layer = null; - paintStack(context, offset); - } - } - - final LayerHandle _clipRectLayer = - LayerHandle(); - - @override - void dispose() { - _clipRectLayer.layer = null; - super.dispose(); - } - - @override - void visitChildrenForSemantics(RenderObjectVisitor visitor) { - RenderBox? child = _firstOnstageChild; - while (child != null) { - visitor(child); - final StackParentData childParentData = - child.parentData! as StackParentData; - child = childParentData.nextSibling; - } - } - - @override - Rect? describeApproximatePaintClip(RenderObject child) => - _hasVisualOverflow ? Offset.zero & size : null; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(IntProperty('skipCount', skipCount)); - properties.add(EnumProperty('textDirection', textDirection)); - } - - @override - List debugDescribeChildren() { - final List offstageChildren = []; - final List onstageChildren = []; - - int count = 1; - bool onstage = false; - RenderBox? child = firstChild; - final RenderBox? firstOnstageChild = _firstOnstageChild; - while (child != null) { - if (child == firstOnstageChild) { - onstage = true; - count = 1; - } - - if (onstage) { - onstageChildren.add( - child.toDiagnosticsNode( - name: 'onstage $count', - ), - ); - } else { - offstageChildren.add( - child.toDiagnosticsNode( - name: 'offstage $count', - style: DiagnosticsTreeStyle.offstage, - ), - ); - } - - final StackParentData childParentData = - child.parentData! as StackParentData; - child = childParentData.nextSibling; - count += 1; - } - - return [ - ...onstageChildren, - if (offstageChildren.isNotEmpty) - ...offstageChildren - else - DiagnosticsNode.message( - 'no offstage children', - style: DiagnosticsTreeStyle.offstage, - ), - ]; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/history/undo_manager.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/history/undo_manager.dart deleted file mode 100644 index 1ef8bf5c10d94..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/history/undo_manager.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/infra/log.dart'; -import 'package:appflowy_editor/src/core/transform/operation.dart'; -import 'package:appflowy_editor/src/core/transform/transaction.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; - -/// A [HistoryItem] contains list of operations committed by users. -/// If a [HistoryItem] is not sealed, operations can be added sequentially. -/// Otherwise, the operations should be added to a new [HistoryItem]. -class HistoryItem extends LinkedListEntry { - final List operations = []; - Selection? beforeSelection; - Selection? afterSelection; - bool _sealed = false; - - HistoryItem(); - - /// Seal the history item. - /// When an item is sealed, no more operations can be added - /// to the item. - /// - /// The caller should create a new [HistoryItem]. - seal() { - _sealed = true; - } - - bool get sealed => _sealed; - - add(Operation op) { - operations.add(op); - } - - addAll(Iterable iterable) { - operations.addAll(iterable); - } - - /// Create a new [Transaction] by inverting the operations. - Transaction toTransaction(EditorState state) { - final builder = Transaction(document: state.document); - for (var i = operations.length - 1; i >= 0; i--) { - final operation = operations[i]; - final inverted = operation.invert(); - builder.add(inverted, transform: false); - } - builder.afterSelection = beforeSelection; - builder.beforeSelection = afterSelection; - return builder; - } -} - -class FixedSizeStack { - final _list = LinkedList(); - final int maxSize; - - FixedSizeStack(this.maxSize); - - push(HistoryItem stackItem) { - if (_list.length >= maxSize) { - _list.remove(_list.first); - } - _list.add(stackItem); - } - - HistoryItem? pop() { - if (_list.isEmpty) { - return null; - } - final last = _list.last; - - _list.remove(last); - - return last; - } - - clear() { - _list.clear(); - } - - HistoryItem get last => _list.last; - - bool get isEmpty => _list.isEmpty; - - bool get isNonEmpty => _list.isNotEmpty; -} - -class UndoManager { - final FixedSizeStack undoStack; - final FixedSizeStack redoStack; - EditorState? state; - - UndoManager([int stackSize = 20]) - : undoStack = FixedSizeStack(stackSize), - redoStack = FixedSizeStack(stackSize); - - HistoryItem getUndoHistoryItem() { - if (undoStack.isEmpty) { - final item = HistoryItem(); - undoStack.push(item); - return item; - } - final last = undoStack.last; - if (last.sealed) { - redoStack.clear(); - final item = HistoryItem(); - undoStack.push(item); - return item; - } - return last; - } - - undo() { - Log.editor.debug('undo'); - final s = state; - if (s == null) { - return; - } - final historyItem = undoStack.pop(); - if (historyItem == null) { - return; - } - final transaction = historyItem.toTransaction(s); - s.apply( - transaction, - const ApplyOptions( - recordUndo: false, - recordRedo: true, - ), - ); - } - - redo() { - Log.editor.debug('redo'); - final s = state; - if (s == null) { - return; - } - final historyItem = redoStack.pop(); - if (historyItem == null) { - return; - } - final transaction = historyItem.toTransaction(s); - s.apply( - transaction, - const ApplyOptions( - recordUndo: true, - recordRedo: false, - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/flowy_svg.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/flowy_svg.dart deleted file mode 100644 index 96ae89a4d59f6..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/flowy_svg.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; - -class FlowySvg extends StatelessWidget { - const FlowySvg({ - Key? key, - this.name, - this.width, - this.height, - this.color, - this.number, - this.padding, - }) : super(key: key); - - final String? name; - final double? width; - final double? height; - final Color? color; - final int? number; - final EdgeInsets? padding; - - final _defaultWidth = 20.0; - final _defaultHeight = 20.0; - - @override - Widget build(BuildContext context) { - return Padding( - padding: padding ?? const EdgeInsets.all(0), - child: _buildSvg(), - ); - } - - Widget _buildSvg() { - if (name != null) { - return SvgPicture.asset( - 'assets/images/$name.svg', - color: color, - fit: BoxFit.fill, - height: height, - width: width, - package: 'appflowy_editor', - ); - } else if (number != null) { - final numberText = - '$number.'; - return SvgPicture.string( - numberText, - width: width ?? _defaultWidth, - height: height ?? _defaultHeight, - ); - } - return Container(); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/html_converter.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/html_converter.dart deleted file mode 100644 index f20d3d8a9ad1a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/html_converter.dart +++ /dev/null @@ -1,626 +0,0 @@ -import 'dart:collection'; -import 'dart:ui'; - -import 'package:appflowy_editor/src/core/document/attributes.dart'; -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/document/text_delta.dart'; -import 'package:appflowy_editor/src/extensions/color_extension.dart'; -import 'package:flutter/material.dart'; -import 'package:html/parser.dart' show parse; -import 'package:html/dom.dart' as html; -import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart'; - -class HTMLTag { - static const h1 = "h1"; - static const h2 = "h2"; - static const h3 = "h3"; - static const orderedList = "ol"; - static const unorderedList = "ul"; - static const list = "li"; - static const paragraph = "p"; - static const image = "img"; - static const anchor = "a"; - static const italic = "i"; - static const bold = "b"; - static const underline = "u"; - static const del = "del"; - static const strong = "strong"; - static const span = "span"; - static const code = "code"; - static const blockQuote = "blockquote"; - static const div = "div"; - - static bool isTopLevel(String tag) { - return tag == h1 || - tag == h2 || - tag == h3 || - tag == paragraph || - tag == div || - tag == blockQuote; - } -} - -/// Converting the HTML to nodes -class HTMLToNodesConverter { - final html.Document _document; - - /// This flag is used for parsing HTML pasting from Google Docs - /// Google docs wraps the the content inside the `` tag. It's strange. - /// - /// If a `` element is parsing in the

, we regard it as as text spans. - /// Otherwise, it's parsed as a container. - bool _inParagraph = false; - - HTMLToNodesConverter(String htmlString) : _document = parse(htmlString); - - List toNodes() { - final childNodes = _document.body?.nodes.toList() ?? []; - return _handleContainer(childNodes); - } - - List _handleContainer(List childNodes) { - final delta = Delta(); - final result = []; - for (final child in childNodes) { - if (child is html.Element) { - if (child.localName == HTMLTag.anchor || - child.localName == HTMLTag.span || - child.localName == HTMLTag.code || - child.localName == HTMLTag.strong || - child.localName == HTMLTag.underline || - child.localName == HTMLTag.italic || - child.localName == HTMLTag.del) { - _handleRichTextElement(delta, child); - } else if (child.localName == HTMLTag.bold) { - // Google docs wraps the the content inside the `` tag. - // It's strange - if (!_inParagraph) { - result.addAll(_handleBTag(child)); - } else { - result.add(_handleRichText(child)); - } - } else if (child.localName == HTMLTag.blockQuote) { - result.addAll(_handleBlockQuote(child)); - } else { - result.addAll(_handleElement(child)); - } - } else { - delta.insert(child.text ?? ""); - } - } - if (delta.isNotEmpty) { - result.add(TextNode(delta: delta)); - } - return result; - } - - List _handleBlockQuote(html.Element element) { - final result = []; - - for (final child in element.nodes.toList()) { - if (child is html.Element) { - result.addAll( - _handleElement(child, {"subtype": BuiltInAttributeKey.quote})); - } - } - - return result; - } - - List _handleBTag(html.Element element) { - final childNodes = element.nodes; - return _handleContainer(childNodes); - } - - List _handleElement(html.Element element, - [Map? attributes]) { - if (element.localName == HTMLTag.h1) { - return [_handleHeadingElement(element, HTMLTag.h1)]; - } else if (element.localName == HTMLTag.h2) { - return [_handleHeadingElement(element, HTMLTag.h2)]; - } else if (element.localName == HTMLTag.h3) { - return [_handleHeadingElement(element, HTMLTag.h3)]; - } else if (element.localName == HTMLTag.unorderedList) { - return _handleUnorderedList(element); - } else if (element.localName == HTMLTag.orderedList) { - return _handleOrderedList(element); - } else if (element.localName == HTMLTag.list) { - return _handleListElement(element); - } else if (element.localName == HTMLTag.paragraph) { - return [_handleParagraph(element, attributes)]; - } else if (element.localName == HTMLTag.image) { - return [_handleImage(element)]; - } else { - final delta = Delta(); - delta.insert(element.text); - if (delta.isNotEmpty) { - return [TextNode(delta: delta)]; - } - } - return []; - } - - Node _handleParagraph(html.Element element, - [Map? attributes]) { - _inParagraph = true; - final node = _handleRichText(element, attributes); - _inParagraph = false; - return node; - } - - Map _cssStringToMap(String? cssString) { - final result = {}; - if (cssString == null) { - return result; - } - - final entries = cssString.split(";"); - for (final entry in entries) { - final tuples = entry.split(":"); - if (tuples.length < 2) { - continue; - } - result[tuples[0].trim()] = tuples[1].trim(); - } - - return result; - } - - Attributes? _getDeltaAttributesFromHtmlAttributes( - LinkedHashMap htmlAttributes) { - final attrs = {}; - final styleString = htmlAttributes["style"]; - final cssMap = _cssStringToMap(styleString); - - final fontWeightStr = cssMap["font-weight"]; - if (fontWeightStr != null) { - if (fontWeightStr == "bold") { - attrs[BuiltInAttributeKey.bold] = true; - } else { - int? weight = int.tryParse(fontWeightStr); - if (weight != null && weight > 500) { - attrs[BuiltInAttributeKey.bold] = true; - } - } - } - - final textDecorationStr = cssMap["text-decoration"]; - if (textDecorationStr != null) { - _assignTextDecorations(attrs, textDecorationStr); - } - - final backgroundColorStr = cssMap["background-color"]; - final backgroundColor = backgroundColorStr == null - ? null - : ColorExtension.tryFromRgbaString(backgroundColorStr); - if (backgroundColor != null) { - attrs[BuiltInAttributeKey.backgroundColor] = - '0x${backgroundColor.value.toRadixString(16)}'; - } - - if (cssMap["font-style"] == "italic") { - attrs[BuiltInAttributeKey.italic] = true; - } - - return attrs.isEmpty ? null : attrs; - } - - _assignTextDecorations(Attributes attrs, String decorationStr) { - final decorations = decorationStr.split(" "); - for (final d in decorations) { - if (d == "line-through") { - attrs[BuiltInAttributeKey.strikethrough] = true; - } else if (d == "underline") { - attrs[BuiltInAttributeKey.underline] = true; - } - } - } - - _handleRichTextElement(Delta delta, html.Element element) { - if (element.localName == HTMLTag.span) { - delta.insert( - element.text, - attributes: _getDeltaAttributesFromHtmlAttributes(element.attributes), - ); - } else if (element.localName == HTMLTag.anchor) { - final hyperLink = element.attributes["href"]; - Map? attributes; - if (hyperLink != null) { - attributes = {"href": hyperLink}; - } - delta.insert(element.text, attributes: attributes); - } else if (element.localName == HTMLTag.strong || - element.localName == HTMLTag.bold) { - delta.insert(element.text, attributes: {BuiltInAttributeKey.bold: true}); - } else if (element.localName == HTMLTag.underline) { - delta.insert(element.text, - attributes: {BuiltInAttributeKey.underline: true}); - } else if (element.localName == HTMLTag.italic) { - delta - .insert(element.text, attributes: {BuiltInAttributeKey.italic: true}); - } else if (element.localName == HTMLTag.del) { - delta.insert(element.text, - attributes: {BuiltInAttributeKey.strikethrough: true}); - } else if (element.localName == HTMLTag.code) { - delta.insert(element.text, attributes: {BuiltInAttributeKey.code: true}); - } else { - delta.insert(element.text); - } - } - - /// A container contains a will - /// be regarded as a checkbox block. - /// - /// A container contains a will be regarded as a image block - Node _handleRichText(html.Element element, - [Map? attributes]) { - final image = element.querySelector(HTMLTag.image); - if (image != null) { - final imageNode = _handleImage(image); - return imageNode; - } - final testInput = element.querySelector("input"); - bool checked = false; - final isCheckbox = - testInput != null && testInput.attributes["type"] == "checkbox"; - if (isCheckbox) { - checked = testInput.attributes.containsKey("checked") && - testInput.attributes["checked"] != "false"; - } - - final delta = Delta(); - - for (final child in element.nodes.toList()) { - if (child is html.Element) { - _handleRichTextElement(delta, child); - } else { - delta.insert(child.text ?? ""); - } - } - - final textNode = TextNode(delta: delta, attributes: { - if (attributes != null) ...attributes, - if (isCheckbox) ...{ - BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, - BuiltInAttributeKey.checkbox: checked, - } - }); - return textNode; - } - - Node _handleImage(html.Element element) { - final src = element.attributes["src"]; - final attributes = {}; - if (src != null) { - attributes["image_src"] = src; - } - return Node(type: "image", attributes: attributes, children: LinkedList()); - } - - List _handleUnorderedList(html.Element element) { - final result = []; - for (var child in element.children) { - result.addAll(_handleListElement( - child, {"subtype": BuiltInAttributeKey.bulletedList})); - } - return result; - } - - List _handleOrderedList(html.Element element) { - final result = []; - for (var i = 0; i < element.children.length; i++) { - final child = element.children[i]; - result.addAll(_handleListElement( - child, {"subtype": BuiltInAttributeKey.numberList, "number": i + 1})); - } - return result; - } - - Node _handleHeadingElement( - html.Element element, - String headingStyle, - ) { - final delta = Delta(); - delta.insert(element.text); - return TextNode( - attributes: {"subtype": "heading", "heading": headingStyle}, - delta: delta); - } - - List _handleListElement(html.Element element, - [Map? attributes]) { - final result = []; - final childNodes = element.nodes.toList(); - for (final child in childNodes) { - if (child is html.Element) { - result.addAll(_handleElement(child, attributes)); - } - } - return result; - } -} - -/// [NodesToHTMLConverter] is used to convert the nodes to HTML. -/// Can be used to copy & paste, exporting the document. -class NodesToHTMLConverter { - final List nodes; - final int? startOffset; - final int? endOffset; - final List _result = []; - - /// According to the W3C specs. The bullet list should be wrapped as - /// - ///

    - ///
  • xxx
  • - ///
  • xxx
  • - ///
  • xxx
  • - ///
- /// - /// This container is used to save the list elements temporarily. - html.Element? _stashListContainer; - - NodesToHTMLConverter( - {required this.nodes, this.startOffset, this.endOffset}) { - if (nodes.isEmpty) { - return; - } else if (nodes.length == 1) { - final first = nodes.first; - if (first is TextNode) { - nodes[0] = first.copyWith( - delta: first.delta.slice(startOffset ?? 0, endOffset)); - } - } else { - final first = nodes.first; - final last = nodes.last; - if (first is TextNode) { - nodes[0] = first.copyWith(delta: first.delta.slice(startOffset ?? 0)); - } - if (last is TextNode) { - nodes[nodes.length - 1] = - last.copyWith(delta: last.delta.slice(0, endOffset)); - } - } - } - - List toHTMLNodes() { - for (final node in nodes) { - if (node.type == "text") { - final textNode = node as TextNode; - if (node == nodes.first) { - _addTextNode(textNode); - } else if (node == nodes.last) { - _addTextNode(textNode, end: endOffset); - } else { - _addTextNode(textNode); - } - } - // TODO: handle image and other blocks - } - if (_stashListContainer != null) { - _result.add(_stashListContainer!); - _stashListContainer = null; - } - return _result; - } - - _addTextNode(TextNode textNode, {int? end}) { - _addElement(textNode, _textNodeToHtml(textNode, end: end)); - } - - _addElement(TextNode textNode, html.Element element) { - if (element.localName == HTMLTag.list) { - final isNumbered = - textNode.attributes["subtype"] == BuiltInAttributeKey.numberList; - _stashListContainer ??= html.Element.tag( - isNumbered ? HTMLTag.orderedList : HTMLTag.unorderedList); - _stashListContainer?.append(element); - } else { - if (_stashListContainer != null) { - _result.add(_stashListContainer!); - _stashListContainer = null; - } - _result.add(element); - } - } - - String toHTMLString() { - final elements = toHTMLNodes(); - final copyString = elements.fold( - "", ((previousValue, element) => previousValue + stringify(element))); - return copyString; - } - - html.Element _textNodeToHtml(TextNode textNode, {int? end}) { - String? subType = textNode.attributes["subtype"]; - String? heading = textNode.attributes["heading"]; - return _deltaToHtml(textNode.delta, - subType: subType, - heading: heading, - end: end, - checked: textNode.attributes["checkbox"] == true); - } - - String _textDecorationsFromAttributes(Attributes attributes) { - var textDecoration = []; - if (attributes[BuiltInAttributeKey.strikethrough] == true) { - textDecoration.add("line-through"); - } - if (attributes[BuiltInAttributeKey.underline] == true) { - textDecoration.add("underline"); - } - - return textDecoration.join(" "); - } - - String _attributesToCssStyle(Map attributes) { - final cssMap = {}; - if (attributes[BuiltInAttributeKey.backgroundColor] != null) { - final color = Color( - int.parse(attributes[BuiltInAttributeKey.backgroundColor]), - ); - cssMap["background-color"] = color.toRgbaString(); - } - if (attributes[BuiltInAttributeKey.color] != null) { - final color = Color( - int.parse(attributes[BuiltInAttributeKey.color]), - ); - cssMap["color"] = color.toRgbaString(); - } - if (attributes[BuiltInAttributeKey.bold] == true) { - cssMap["font-weight"] = "bold"; - } - - final textDecoration = _textDecorationsFromAttributes(attributes); - if (textDecoration.isNotEmpty) { - cssMap["text-decoration"] = textDecoration; - } - - if (attributes[BuiltInAttributeKey.italic] == true) { - cssMap["font-style"] = "italic"; - } - return _cssMapToCssStyle(cssMap); - } - - String _cssMapToCssStyle(Map cssMap) { - return cssMap.entries.fold("", (previousValue, element) { - final kv = '${element.key}: ${element.value}'; - if (previousValue.isEmpty) { - return kv; - } - return '$previousValue; $kv'; - }); - } - - /// Convert the rich text to HTML - /// - /// Use `` for bold only. - /// Use `` for italic only. - /// Use `` for strikethrough only. - /// Use `` for underline only. - /// - /// If the text has multiple styles, use a `` - /// to mix the styles. - /// - /// A CSS style string is used to describe the styles. - /// The HTML will be: - /// - /// ```html - /// Text - /// ``` - html.Element _deltaToHtml(Delta delta, - {String? subType, String? heading, int? end, bool? checked}) { - if (end != null) { - delta = delta.slice(0, end); - } - - final childNodes = []; - String tagName = HTMLTag.paragraph; - - if (subType == BuiltInAttributeKey.bulletedList || - subType == BuiltInAttributeKey.numberList) { - tagName = HTMLTag.list; - } else if (subType == BuiltInAttributeKey.checkbox) { - final node = html.Element.html(''); - if (checked != null && checked) { - node.attributes["checked"] = "true"; - } - childNodes.add(node); - } else if (subType == BuiltInAttributeKey.heading) { - if (heading == BuiltInAttributeKey.h1) { - tagName = HTMLTag.h1; - } else if (heading == BuiltInAttributeKey.h2) { - tagName = HTMLTag.h2; - } else if (heading == BuiltInAttributeKey.h3) { - tagName = HTMLTag.h3; - } - } else if (subType == BuiltInAttributeKey.quote) { - tagName = HTMLTag.blockQuote; - } - - for (final op in delta) { - if (op is TextInsert) { - final attributes = op.attributes; - if (attributes != null) { - if (attributes.length == 1 && - attributes[BuiltInAttributeKey.bold] == true) { - final strong = html.Element.tag(HTMLTag.strong); - strong.append(html.Text(op.text)); - childNodes.add(strong); - } else if (attributes.length == 1 && - attributes[BuiltInAttributeKey.underline] == true) { - final strong = html.Element.tag(HTMLTag.underline); - strong.append(html.Text(op.text)); - childNodes.add(strong); - } else if (attributes.length == 1 && - attributes[BuiltInAttributeKey.italic] == true) { - final strong = html.Element.tag(HTMLTag.italic); - strong.append(html.Text(op.text)); - childNodes.add(strong); - } else if (attributes.length == 1 && - attributes[BuiltInAttributeKey.strikethrough] == true) { - final strong = html.Element.tag(HTMLTag.del); - strong.append(html.Text(op.text)); - childNodes.add(strong); - } else if (attributes.length == 1 && - attributes[BuiltInAttributeKey.code] == true) { - final code = html.Element.tag(HTMLTag.code); - code.append(html.Text(op.text)); - childNodes.add(code); - } else if (attributes.length == 1 && - attributes[BuiltInAttributeKey.href] != null) { - final anchor = html.Element.tag(HTMLTag.anchor); - anchor.attributes["href"] = attributes[BuiltInAttributeKey.href]; - anchor.append(html.Text(op.text)); - childNodes.add(anchor); - } else { - final span = html.Element.tag(HTMLTag.span); - final cssString = _attributesToCssStyle(attributes); - if (cssString.isNotEmpty) { - span.attributes["style"] = cssString; - } - span.append(html.Text(op.text)); - childNodes.add(span); - } - } else { - childNodes.add(html.Text(op.text)); - } - } - } - - if (tagName == HTMLTag.blockQuote) { - final p = html.Element.tag(HTMLTag.paragraph); - for (final node in childNodes) { - p.append(node); - } - final blockQuote = html.Element.tag(tagName); - blockQuote.append(p); - return blockQuote; - } else if (!HTMLTag.isTopLevel(tagName)) { - final p = html.Element.tag(HTMLTag.paragraph); - for (final node in childNodes) { - p.append(node); - } - final result = html.Element.tag(HTMLTag.list); - result.append(p); - return result; - } else { - final p = html.Element.tag(tagName); - for (final node in childNodes) { - p.append(node); - } - return p; - } - } -} - -String stringify(html.Node node) { - if (node is html.Element) { - return node.outerHtml; - } - - if (node is html.Text) { - return node.text; - } - - return ""; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/infra.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/infra.dart deleted file mode 100644 index 1463f6a97bbb5..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/infra.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; - -class Infra { -// find the forward nearest text node - static TextNode? forwardNearestTextNode(Node node) { - var previous = node.previous; - while (previous != null) { - final lastTextNode = findLastTextNode(previous); - if (lastTextNode != null) { - return lastTextNode; - } - if (previous is TextNode) { - return previous; - } - previous = previous.previous; - } - final parent = node.parent; - if (parent != null) { - if (parent is TextNode) { - return parent; - } - return forwardNearestTextNode(parent); - } - return null; - } - - // find the last text node - static TextNode? findLastTextNode(Node node) { - final children = node.children.toList(growable: false).reversed; - for (final child in children) { - if (child.children.isNotEmpty) { - final result = findLastTextNode(child); - if (result != null) { - return result; - } - } - if (child is TextNode) { - return child; - } - } - if (node is TextNode) { - return node; - } - return null; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/log.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/log.dart deleted file mode 100644 index 8175ecb7056b4..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/log.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'package:logging/logging.dart'; - -enum LogLevel { - off, - error, - warn, - info, - debug, - all, -} - -typedef LogHandler = void Function(String message); - -/// Manages log service for [AppFlowyEditor] -/// -/// Set the log level and config the handler depending on your need. -class LogConfiguration { - LogConfiguration._() { - Logger.root.onRecord.listen((record) { - if (handler != null) { - handler!( - '[${record.level.toLogLevel().name}][${record.loggerName}]: ${record.time}: ${record.message}', - ); - } - }); - } - - factory LogConfiguration() => _logConfiguration; - - static final LogConfiguration _logConfiguration = LogConfiguration._(); - - LogHandler? handler; - - LogLevel _level = LogLevel.off; - - LogLevel get level => _level; - set level(LogLevel level) { - _level = level; - Logger.root.level = level.toLevel(); - } -} - -/// For logging message in AppFlowyEditor -class Log { - Log._({ - required this.name, - }) : _logger = Logger(name); - - final String name; - late final Logger _logger; - - /// For logging message related to [AppFlowyEditor]. - /// - /// For example, uses the logger when registering plugins - /// or handling something related to [EditorState]. - static Log editor = Log._(name: 'editor'); - - /// For logging message related to [AppFlowySelectionService]. - /// - /// For example, uses the logger when updating or clearing selection. - static Log selection = Log._(name: 'selection'); - - /// For logging message related to [AppFlowyKeyboardService]. - /// - /// For example, uses the logger when processing shortcut events. - static Log keyboard = Log._(name: 'keyboard'); - - /// For logging message related to [AppFlowyInputService]. - /// - /// For example, uses the logger when processing text inputs. - static Log input = Log._(name: 'input'); - - /// For logging message related to [AppFlowyScrollService]. - /// - /// For example, uses the logger when processing scroll events. - static Log scroll = Log._(name: 'scroll'); - - /// For logging message related to [AppFlowyToolbarService]. - /// - /// For example, uses the logger when processing toolbar events. - static Log toolbar = Log._(name: 'toolbar'); - - /// For logging message related to UI. - /// - /// For example, uses the logger when building the widget. - static Log ui = Log._(name: 'ui'); - - void error(String message) => _logger.severe(message); - void warn(String message) => _logger.warning(message); - void info(String message) => _logger.info(message); - void debug(String message) => _logger.fine(message); -} - -extension on LogLevel { - Level toLevel() { - switch (this) { - case LogLevel.off: - return Level.OFF; - case LogLevel.error: - return Level.SEVERE; - case LogLevel.warn: - return Level.WARNING; - case LogLevel.info: - return Level.INFO; - case LogLevel.debug: - return Level.FINE; - case LogLevel.all: - return Level.ALL; - } - } - - String get name { - switch (this) { - case LogLevel.off: - return 'OFF'; - case LogLevel.error: - return 'ERROR'; - case LogLevel.warn: - return 'WARN'; - case LogLevel.info: - return 'INFO'; - case LogLevel.debug: - return 'DEBUG'; - case LogLevel.all: - return 'ALL'; - } - } -} - -extension on Level { - LogLevel toLogLevel() { - if (this == Level.SEVERE) { - return LogLevel.error; - } else if (this == Level.WARNING) { - return LogLevel.warn; - } else if (this == Level.INFO) { - return LogLevel.info; - } else if (this == Level.FINE) { - return LogLevel.debug; - } - return LogLevel.off; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_all.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_all.dart deleted file mode 100644 index 16fd7b9b6b993..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_all.dart +++ /dev/null @@ -1,146 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that looks up messages for specific locales by -// delegating to the appropriate library. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:implementation_imports, file_names, unnecessary_new -// ignore_for_file:unnecessary_brace_in_string_interps, directives_ordering -// ignore_for_file:argument_type_not_assignable, invalid_assignment -// ignore_for_file:prefer_single_quotes, prefer_generic_function_type_aliases -// ignore_for_file:comment_references - -import 'dart:async'; - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; -import 'package:intl/src/intl_helpers.dart'; - -import 'messages_bn_BN.dart' as messages_bn_bn; -import 'messages_ca.dart' as messages_ca; -import 'messages_cs-CZ.dart' as messages_cs_cz; -import 'messages_de-DE.dart' as messages_de_de; -import 'messages_en.dart' as messages_en; -import 'messages_es-VE.dart' as messages_es_ve; -import 'messages_fr-CA.dart' as messages_fr_ca; -import 'messages_fr-FR.dart' as messages_fr_fr; -import 'messages_hi-IN.dart' as messages_hi_in; -import 'messages_hu-HU.dart' as messages_hu_hu; -import 'messages_id-ID.dart' as messages_id_id; -import 'messages_it-IT.dart' as messages_it_it; -import 'messages_ja-JP.dart' as messages_ja_jp; -import 'messages_ml_IN.dart' as messages_ml_in; -import 'messages_nl-NL.dart' as messages_nl_nl; -import 'messages_pl-PL.dart' as messages_pl_pl; -import 'messages_pt-BR.dart' as messages_pt_br; -import 'messages_pt-PT.dart' as messages_pt_pt; -import 'messages_ru-RU.dart' as messages_ru_ru; -import 'messages_tr-TR.dart' as messages_tr_tr; -import 'messages_zh-CN.dart' as messages_zh_cn; -import 'messages_zh-TW.dart' as messages_zh_tw; - -typedef Future LibraryLoader(); -Map _deferredLibraries = { - 'bn_BN': () => new Future.value(null), - 'ca': () => new Future.value(null), - 'cs_CZ': () => new Future.value(null), - 'de_DE': () => new Future.value(null), - 'en': () => new Future.value(null), - 'es_VE': () => new Future.value(null), - 'fr_CA': () => new Future.value(null), - 'fr_FR': () => new Future.value(null), - 'hi_IN': () => new Future.value(null), - 'hu_HU': () => new Future.value(null), - 'id_ID': () => new Future.value(null), - 'it_IT': () => new Future.value(null), - 'ja_JP': () => new Future.value(null), - 'ml_IN': () => new Future.value(null), - 'nl_NL': () => new Future.value(null), - 'pl_PL': () => new Future.value(null), - 'pt_BR': () => new Future.value(null), - 'pt_PT': () => new Future.value(null), - 'ru_RU': () => new Future.value(null), - 'tr_TR': () => new Future.value(null), - 'zh_CN': () => new Future.value(null), - 'zh_TW': () => new Future.value(null), -}; - -MessageLookupByLibrary? _findExact(String localeName) { - switch (localeName) { - case 'bn_BN': - return messages_bn_bn.messages; - case 'ca': - return messages_ca.messages; - case 'cs_CZ': - return messages_cs_cz.messages; - case 'de_DE': - return messages_de_de.messages; - case 'en': - return messages_en.messages; - case 'es_VE': - return messages_es_ve.messages; - case 'fr_CA': - return messages_fr_ca.messages; - case 'fr_FR': - return messages_fr_fr.messages; - case 'hi_IN': - return messages_hi_in.messages; - case 'hu_HU': - return messages_hu_hu.messages; - case 'id_ID': - return messages_id_id.messages; - case 'it_IT': - return messages_it_it.messages; - case 'ja_JP': - return messages_ja_jp.messages; - case 'ml_IN': - return messages_ml_in.messages; - case 'nl_NL': - return messages_nl_nl.messages; - case 'pl_PL': - return messages_pl_pl.messages; - case 'pt_BR': - return messages_pt_br.messages; - case 'pt_PT': - return messages_pt_pt.messages; - case 'ru_RU': - return messages_ru_ru.messages; - case 'tr_TR': - return messages_tr_tr.messages; - case 'zh_CN': - return messages_zh_cn.messages; - case 'zh_TW': - return messages_zh_tw.messages; - default: - return null; - } -} - -/// User programs should call this before using [localeName] for messages. -Future initializeMessages(String localeName) async { - var availableLocale = Intl.verifiedLocale( - localeName, (locale) => _deferredLibraries[locale] != null, - onFailure: (_) => null); - if (availableLocale == null) { - return new Future.value(false); - } - var lib = _deferredLibraries[availableLocale]; - await (lib == null ? new Future.value(false) : lib()); - initializeInternalMessageLookup(() => new CompositeMessageLookup()); - messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor); - return new Future.value(true); -} - -bool _messagesExistFor(String locale) { - try { - return _findExact(locale) != null; - } catch (e) { - return false; - } -} - -MessageLookupByLibrary? _findGeneratedMessagesFor(String locale) { - var actualLocale = - Intl.verifiedLocale(locale, _messagesExistFor, onFailure: (_) => null); - if (actualLocale == null) return null; - return _findExact(actualLocale); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_bn_BN.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_bn_BN.dart deleted file mode 100644 index 38e1cae590f35..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_bn_BN.dart +++ /dev/null @@ -1,43 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a bn_BN locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'bn_BN'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("বল্ড ফন্ট"), - "bulletedList": MessageLookupByLibrary.simpleMessage("বুলেট তালিকা"), - "checkbox": MessageLookupByLibrary.simpleMessage("চেকবক্স"), - "embedCode": MessageLookupByLibrary.simpleMessage("এম্বেড কোড"), - "heading1": MessageLookupByLibrary.simpleMessage("শিরোনাম 1"), - "heading2": MessageLookupByLibrary.simpleMessage("শিরোনাম 2"), - "heading3": MessageLookupByLibrary.simpleMessage("শিরোনাম 3"), - "highlight": MessageLookupByLibrary.simpleMessage("হাইলাইট"), - "image": MessageLookupByLibrary.simpleMessage("ইমেজ"), - "italic": MessageLookupByLibrary.simpleMessage("ইটালিক ফন্ট"), - "link": MessageLookupByLibrary.simpleMessage("লিঙ্ক"), - "numberedList": - MessageLookupByLibrary.simpleMessage("সংখ্যাযুক্ত তালিকা"), - "quote": MessageLookupByLibrary.simpleMessage("উদ্ধৃতি"), - "strikethrough": MessageLookupByLibrary.simpleMessage("স্ট্রাইকথ্রু"), - "text": MessageLookupByLibrary.simpleMessage("পাঠ্য"), - "underline": MessageLookupByLibrary.simpleMessage("আন্ডারলাইন") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_ca.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_ca.dart deleted file mode 100644 index 10c178027b438..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_ca.dart +++ /dev/null @@ -1,42 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a ca locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'ca'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage(""), - "bulletedList": MessageLookupByLibrary.simpleMessage(""), - "checkbox": MessageLookupByLibrary.simpleMessage(""), - "embedCode": MessageLookupByLibrary.simpleMessage(""), - "heading1": MessageLookupByLibrary.simpleMessage(""), - "heading2": MessageLookupByLibrary.simpleMessage(""), - "heading3": MessageLookupByLibrary.simpleMessage(""), - "highlight": MessageLookupByLibrary.simpleMessage(""), - "image": MessageLookupByLibrary.simpleMessage(""), - "italic": MessageLookupByLibrary.simpleMessage(""), - "link": MessageLookupByLibrary.simpleMessage(""), - "numberedList": MessageLookupByLibrary.simpleMessage(""), - "quote": MessageLookupByLibrary.simpleMessage(""), - "strikethrough": MessageLookupByLibrary.simpleMessage(""), - "text": MessageLookupByLibrary.simpleMessage(""), - "underline": MessageLookupByLibrary.simpleMessage("") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_cs-CZ.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_cs-CZ.dart deleted file mode 100644 index 810fe3888f96d..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_cs-CZ.dart +++ /dev/null @@ -1,44 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a cs_CZ locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'cs_CZ'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("Tučně"), - "bulletedList": - MessageLookupByLibrary.simpleMessage("Odrážkový seznam"), - "checkbox": MessageLookupByLibrary.simpleMessage("Zaškrtávací políčko"), - "embedCode": MessageLookupByLibrary.simpleMessage("Vložit kód"), - "heading1": MessageLookupByLibrary.simpleMessage("Nadpis 1"), - "heading2": MessageLookupByLibrary.simpleMessage("Nadpis 2"), - "heading3": MessageLookupByLibrary.simpleMessage("Nadpis 3"), - "highlight": MessageLookupByLibrary.simpleMessage("Zvýraznění"), - "image": MessageLookupByLibrary.simpleMessage("Obrázek"), - "italic": MessageLookupByLibrary.simpleMessage("Kurzíva"), - "link": MessageLookupByLibrary.simpleMessage("Odkaz"), - "numberedList": - MessageLookupByLibrary.simpleMessage("Číslovaný seznam"), - "quote": MessageLookupByLibrary.simpleMessage("Citace"), - "strikethrough": MessageLookupByLibrary.simpleMessage("Přeškrtnutí"), - "text": MessageLookupByLibrary.simpleMessage("Text"), - "underline": MessageLookupByLibrary.simpleMessage("Podtržení") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_de-DE.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_de-DE.dart deleted file mode 100644 index 7771d433705c6..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_de-DE.dart +++ /dev/null @@ -1,45 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a de_DE locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'de_DE'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("Fett gedruckt"), - "bulletedList": - MessageLookupByLibrary.simpleMessage("Aufzählungsliste"), - "checkbox": MessageLookupByLibrary.simpleMessage("Kontrollkästchen"), - "embedCode": MessageLookupByLibrary.simpleMessage("Code einbetten"), - "heading1": MessageLookupByLibrary.simpleMessage("Überschrift 1"), - "heading2": MessageLookupByLibrary.simpleMessage("Überschrift 2"), - "heading3": MessageLookupByLibrary.simpleMessage("Überschrift 3"), - "highlight": MessageLookupByLibrary.simpleMessage("Markieren"), - "image": MessageLookupByLibrary.simpleMessage("Bild"), - "italic": MessageLookupByLibrary.simpleMessage("kursiv"), - "link": MessageLookupByLibrary.simpleMessage("Verknüpfung"), - "numberedList": - MessageLookupByLibrary.simpleMessage("NummerierteListe"), - "quote": MessageLookupByLibrary.simpleMessage("zitieren"), - "strikethrough": - MessageLookupByLibrary.simpleMessage("durchgestrichen"), - "text": MessageLookupByLibrary.simpleMessage("Text"), - "underline": MessageLookupByLibrary.simpleMessage("unterstreichen") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_en.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_en.dart deleted file mode 100644 index 0a834ae7eb100..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_en.dart +++ /dev/null @@ -1,42 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a en locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'en'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("Bold"), - "bulletedList": MessageLookupByLibrary.simpleMessage("Bulleted List"), - "checkbox": MessageLookupByLibrary.simpleMessage("Checkbox"), - "embedCode": MessageLookupByLibrary.simpleMessage("Embed Code"), - "heading1": MessageLookupByLibrary.simpleMessage("H1"), - "heading2": MessageLookupByLibrary.simpleMessage("H2"), - "heading3": MessageLookupByLibrary.simpleMessage("H3"), - "highlight": MessageLookupByLibrary.simpleMessage("Highlight"), - "image": MessageLookupByLibrary.simpleMessage("Image"), - "italic": MessageLookupByLibrary.simpleMessage("Italic"), - "link": MessageLookupByLibrary.simpleMessage("Link"), - "numberedList": MessageLookupByLibrary.simpleMessage("Numbered List"), - "quote": MessageLookupByLibrary.simpleMessage("Quote"), - "strikethrough": MessageLookupByLibrary.simpleMessage("Strikethrough"), - "text": MessageLookupByLibrary.simpleMessage("Text"), - "underline": MessageLookupByLibrary.simpleMessage("Underline") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_es-VE.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_es-VE.dart deleted file mode 100644 index d5b4cb5b06092..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_es-VE.dart +++ /dev/null @@ -1,42 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a es_VE locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'es_VE'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage(""), - "bulletedList": MessageLookupByLibrary.simpleMessage(""), - "checkbox": MessageLookupByLibrary.simpleMessage(""), - "embedCode": MessageLookupByLibrary.simpleMessage(""), - "heading1": MessageLookupByLibrary.simpleMessage(""), - "heading2": MessageLookupByLibrary.simpleMessage(""), - "heading3": MessageLookupByLibrary.simpleMessage(""), - "highlight": MessageLookupByLibrary.simpleMessage(""), - "image": MessageLookupByLibrary.simpleMessage(""), - "italic": MessageLookupByLibrary.simpleMessage(""), - "link": MessageLookupByLibrary.simpleMessage(""), - "numberedList": MessageLookupByLibrary.simpleMessage(""), - "quote": MessageLookupByLibrary.simpleMessage(""), - "strikethrough": MessageLookupByLibrary.simpleMessage(""), - "text": MessageLookupByLibrary.simpleMessage(""), - "underline": MessageLookupByLibrary.simpleMessage("") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_fr-CA.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_fr-CA.dart deleted file mode 100644 index a7239232ed890..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_fr-CA.dart +++ /dev/null @@ -1,42 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a fr_CA locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'fr_CA'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("gras"), - "bulletedList": MessageLookupByLibrary.simpleMessage("liste à puces"), - "checkbox": MessageLookupByLibrary.simpleMessage("case à cocher"), - "embedCode": MessageLookupByLibrary.simpleMessage("incorporer Code"), - "heading1": MessageLookupByLibrary.simpleMessage("en-tête1"), - "heading2": MessageLookupByLibrary.simpleMessage("en-tête2"), - "heading3": MessageLookupByLibrary.simpleMessage("en-tête3"), - "highlight": MessageLookupByLibrary.simpleMessage("mettre en évidence"), - "image": MessageLookupByLibrary.simpleMessage("l’image"), - "italic": MessageLookupByLibrary.simpleMessage("italique"), - "link": MessageLookupByLibrary.simpleMessage("lien"), - "numberedList": MessageLookupByLibrary.simpleMessage("liste numérotée"), - "quote": MessageLookupByLibrary.simpleMessage("citation"), - "strikethrough": MessageLookupByLibrary.simpleMessage("barré"), - "text": MessageLookupByLibrary.simpleMessage("texte"), - "underline": MessageLookupByLibrary.simpleMessage("souligner") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_fr-FR.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_fr-FR.dart deleted file mode 100644 index 07e73020338a2..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_fr-FR.dart +++ /dev/null @@ -1,42 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a fr_FR locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'fr_FR'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("Gras"), - "bulletedList": MessageLookupByLibrary.simpleMessage("List à puces"), - "checkbox": MessageLookupByLibrary.simpleMessage("Case à cocher"), - "embedCode": MessageLookupByLibrary.simpleMessage("Incorporer code"), - "heading1": MessageLookupByLibrary.simpleMessage("Titre 1"), - "heading2": MessageLookupByLibrary.simpleMessage("Titre 2"), - "heading3": MessageLookupByLibrary.simpleMessage("Titre 3"), - "highlight": MessageLookupByLibrary.simpleMessage("Surligné"), - "image": MessageLookupByLibrary.simpleMessage("Image"), - "italic": MessageLookupByLibrary.simpleMessage("Italique"), - "link": MessageLookupByLibrary.simpleMessage("Lien"), - "numberedList": MessageLookupByLibrary.simpleMessage("Liste numérotée"), - "quote": MessageLookupByLibrary.simpleMessage("Citation"), - "strikethrough": MessageLookupByLibrary.simpleMessage("Barré"), - "text": MessageLookupByLibrary.simpleMessage("Texte"), - "underline": MessageLookupByLibrary.simpleMessage("Souligné") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_hi-IN.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_hi-IN.dart deleted file mode 100644 index 7f3e29e0af6b8..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_hi-IN.dart +++ /dev/null @@ -1,43 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a hi_IN locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'hi_IN'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("बोल्ड"), - "bulletedList": MessageLookupByLibrary.simpleMessage("बुलेटेड सूची"), - "checkbox": MessageLookupByLibrary.simpleMessage("चेक बॉक्स"), - "embedCode": - MessageLookupByLibrary.simpleMessage("लागु किया गया संहिता"), - "heading1": MessageLookupByLibrary.simpleMessage("शीर्षक 1"), - "heading2": MessageLookupByLibrary.simpleMessage("शीर्षक 2"), - "heading3": MessageLookupByLibrary.simpleMessage("शीर्षक 3"), - "highlight": MessageLookupByLibrary.simpleMessage("प्रमुखता से दिखाना"), - "image": MessageLookupByLibrary.simpleMessage("छवि"), - "italic": MessageLookupByLibrary.simpleMessage("तिरछा"), - "link": MessageLookupByLibrary.simpleMessage("संपर्क"), - "numberedList": MessageLookupByLibrary.simpleMessage("क्रमांकित सूची"), - "quote": MessageLookupByLibrary.simpleMessage("उद्धरण"), - "strikethrough": MessageLookupByLibrary.simpleMessage("स्ट्राइकथ्रू"), - "text": MessageLookupByLibrary.simpleMessage("मूलपाठ"), - "underline": MessageLookupByLibrary.simpleMessage("रेखांकन") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_hu-HU.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_hu-HU.dart deleted file mode 100644 index 44a54b5478436..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_hu-HU.dart +++ /dev/null @@ -1,42 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a hu_HU locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'hu_HU'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("bátor"), - "bulletedList": MessageLookupByLibrary.simpleMessage("pontozott lista"), - "checkbox": MessageLookupByLibrary.simpleMessage("jelölőnégyzetet"), - "embedCode": MessageLookupByLibrary.simpleMessage("Beágyazás"), - "heading1": MessageLookupByLibrary.simpleMessage("címsor1"), - "heading2": MessageLookupByLibrary.simpleMessage("címsor2"), - "heading3": MessageLookupByLibrary.simpleMessage("címsor3"), - "highlight": MessageLookupByLibrary.simpleMessage("Kiemel"), - "image": MessageLookupByLibrary.simpleMessage("kép"), - "italic": MessageLookupByLibrary.simpleMessage("dőlt"), - "link": MessageLookupByLibrary.simpleMessage("link"), - "numberedList": MessageLookupByLibrary.simpleMessage("számozottLista"), - "quote": MessageLookupByLibrary.simpleMessage("idézet"), - "strikethrough": MessageLookupByLibrary.simpleMessage("áthúzott"), - "text": MessageLookupByLibrary.simpleMessage("szöveg"), - "underline": MessageLookupByLibrary.simpleMessage("aláhúzás") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_id-ID.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_id-ID.dart deleted file mode 100644 index cda97336d448e..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_id-ID.dart +++ /dev/null @@ -1,42 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a id_ID locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'id_ID'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("berani"), - "bulletedList": MessageLookupByLibrary.simpleMessage("daftar berpoin"), - "checkbox": MessageLookupByLibrary.simpleMessage("kotak centang"), - "embedCode": MessageLookupByLibrary.simpleMessage("menyematkan Kode"), - "heading1": MessageLookupByLibrary.simpleMessage("pos1"), - "heading2": MessageLookupByLibrary.simpleMessage("pos2"), - "heading3": MessageLookupByLibrary.simpleMessage("pos3"), - "highlight": MessageLookupByLibrary.simpleMessage("menyorot"), - "image": MessageLookupByLibrary.simpleMessage("gambar"), - "italic": MessageLookupByLibrary.simpleMessage("miring"), - "link": MessageLookupByLibrary.simpleMessage("tautan"), - "numberedList": MessageLookupByLibrary.simpleMessage("daftar bernomor"), - "quote": MessageLookupByLibrary.simpleMessage("mengutip"), - "strikethrough": MessageLookupByLibrary.simpleMessage("coret"), - "text": MessageLookupByLibrary.simpleMessage("teks"), - "underline": MessageLookupByLibrary.simpleMessage("menggarisbawahi") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_it-IT.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_it-IT.dart deleted file mode 100644 index 05ee3e1353858..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_it-IT.dart +++ /dev/null @@ -1,42 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a it_IT locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'it_IT'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("Grassetto"), - "bulletedList": MessageLookupByLibrary.simpleMessage("Elenco puntato"), - "checkbox": MessageLookupByLibrary.simpleMessage("Casella di spunta"), - "embedCode": MessageLookupByLibrary.simpleMessage("Incorpora codice"), - "heading1": MessageLookupByLibrary.simpleMessage("H1"), - "heading2": MessageLookupByLibrary.simpleMessage("H2"), - "heading3": MessageLookupByLibrary.simpleMessage("H3"), - "highlight": MessageLookupByLibrary.simpleMessage("Evidenzia"), - "image": MessageLookupByLibrary.simpleMessage("Immagine"), - "italic": MessageLookupByLibrary.simpleMessage("Corsivo"), - "link": MessageLookupByLibrary.simpleMessage("Collegamento"), - "numberedList": MessageLookupByLibrary.simpleMessage("Elenco numerato"), - "quote": MessageLookupByLibrary.simpleMessage("Cita"), - "strikethrough": MessageLookupByLibrary.simpleMessage("Barrato"), - "text": MessageLookupByLibrary.simpleMessage("Testo"), - "underline": MessageLookupByLibrary.simpleMessage("Sottolineato") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_ja-JP.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_ja-JP.dart deleted file mode 100644 index 925acc9668a34..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_ja-JP.dart +++ /dev/null @@ -1,42 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a ja_JP locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'ja_JP'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage(""), - "bulletedList": MessageLookupByLibrary.simpleMessage(""), - "checkbox": MessageLookupByLibrary.simpleMessage(""), - "embedCode": MessageLookupByLibrary.simpleMessage(""), - "heading1": MessageLookupByLibrary.simpleMessage(""), - "heading2": MessageLookupByLibrary.simpleMessage(""), - "heading3": MessageLookupByLibrary.simpleMessage(""), - "highlight": MessageLookupByLibrary.simpleMessage(""), - "image": MessageLookupByLibrary.simpleMessage(""), - "italic": MessageLookupByLibrary.simpleMessage(""), - "link": MessageLookupByLibrary.simpleMessage(""), - "numberedList": MessageLookupByLibrary.simpleMessage(""), - "quote": MessageLookupByLibrary.simpleMessage(""), - "strikethrough": MessageLookupByLibrary.simpleMessage(""), - "text": MessageLookupByLibrary.simpleMessage(""), - "underline": MessageLookupByLibrary.simpleMessage("") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_ml_IN.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_ml_IN.dart deleted file mode 100644 index e7378a907e1c0..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_ml_IN.dart +++ /dev/null @@ -1,45 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a ml_IN locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'ml_IN'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("ബോൾഡ്"), - "bulletedList": - MessageLookupByLibrary.simpleMessage("ബുള്ളറ്റഡ് പട്ടിക"), - "checkbox": MessageLookupByLibrary.simpleMessage("ചെക്ക്ബോക്സ്"), - "embedCode": MessageLookupByLibrary.simpleMessage("എംബെഡഡ് കോഡ്"), - "heading1": MessageLookupByLibrary.simpleMessage("തലക്കെട്ട് 1"), - "heading2": MessageLookupByLibrary.simpleMessage("തലക്കെട്ട് 2"), - "heading3": MessageLookupByLibrary.simpleMessage("തലക്കെട്ട് 3"), - "highlight": - MessageLookupByLibrary.simpleMessage("പ്രമുഖമാക്കിക്കാട്ടുക"), - "image": MessageLookupByLibrary.simpleMessage("ചിത്രം"), - "italic": MessageLookupByLibrary.simpleMessage("ഇറ്റാലിക്"), - "link": MessageLookupByLibrary.simpleMessage("ലിങ്ക്"), - "numberedList": - MessageLookupByLibrary.simpleMessage("അക്കമിട്ട പട്ടിക"), - "quote": MessageLookupByLibrary.simpleMessage("ഉദ്ധരണി"), - "strikethrough": MessageLookupByLibrary.simpleMessage("സ്ട്രൈക്ക്ത്രൂ"), - "text": MessageLookupByLibrary.simpleMessage("വചനം"), - "underline": MessageLookupByLibrary.simpleMessage("അടിവരയിടുക") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_nl-NL.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_nl-NL.dart deleted file mode 100644 index eb096b9a7afbc..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_nl-NL.dart +++ /dev/null @@ -1,43 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a nl_NL locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'nl_NL'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("Vet"), - "bulletedList": - MessageLookupByLibrary.simpleMessage("Opsommingstekens"), - "checkbox": MessageLookupByLibrary.simpleMessage("Selectievakje"), - "embedCode": MessageLookupByLibrary.simpleMessage("Invoegcode"), - "heading1": MessageLookupByLibrary.simpleMessage("H1"), - "heading2": MessageLookupByLibrary.simpleMessage("H2"), - "heading3": MessageLookupByLibrary.simpleMessage("H3"), - "highlight": MessageLookupByLibrary.simpleMessage("Highlight"), - "image": MessageLookupByLibrary.simpleMessage("Afbeelding"), - "italic": MessageLookupByLibrary.simpleMessage("Cursief"), - "link": MessageLookupByLibrary.simpleMessage(""), - "numberedList": MessageLookupByLibrary.simpleMessage("Nummering"), - "quote": MessageLookupByLibrary.simpleMessage("Quote"), - "strikethrough": MessageLookupByLibrary.simpleMessage("Doorhalen"), - "text": MessageLookupByLibrary.simpleMessage("Tekst"), - "underline": MessageLookupByLibrary.simpleMessage("Onderstrepen") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_pl-PL.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_pl-PL.dart deleted file mode 100644 index 30a9cc38b226a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_pl-PL.dart +++ /dev/null @@ -1,42 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a pl_PL locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'pl_PL'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage(""), - "bulletedList": MessageLookupByLibrary.simpleMessage(""), - "checkbox": MessageLookupByLibrary.simpleMessage(""), - "embedCode": MessageLookupByLibrary.simpleMessage(""), - "heading1": MessageLookupByLibrary.simpleMessage(""), - "heading2": MessageLookupByLibrary.simpleMessage(""), - "heading3": MessageLookupByLibrary.simpleMessage(""), - "highlight": MessageLookupByLibrary.simpleMessage(""), - "image": MessageLookupByLibrary.simpleMessage(""), - "italic": MessageLookupByLibrary.simpleMessage(""), - "link": MessageLookupByLibrary.simpleMessage(""), - "numberedList": MessageLookupByLibrary.simpleMessage(""), - "quote": MessageLookupByLibrary.simpleMessage(""), - "strikethrough": MessageLookupByLibrary.simpleMessage(""), - "text": MessageLookupByLibrary.simpleMessage(""), - "underline": MessageLookupByLibrary.simpleMessage("") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_pt-BR.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_pt-BR.dart deleted file mode 100644 index 22c53407ac3ae..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_pt-BR.dart +++ /dev/null @@ -1,43 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a pt_BR locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'pt_BR'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("Negrito"), - "bulletedList": - MessageLookupByLibrary.simpleMessage("Lista de marcadores"), - "checkbox": MessageLookupByLibrary.simpleMessage("Caixa de seleção"), - "embedCode": MessageLookupByLibrary.simpleMessage("Código incorporado"), - "heading1": MessageLookupByLibrary.simpleMessage("H1"), - "heading2": MessageLookupByLibrary.simpleMessage("H2"), - "heading3": MessageLookupByLibrary.simpleMessage("H3"), - "highlight": MessageLookupByLibrary.simpleMessage("Destacar"), - "image": MessageLookupByLibrary.simpleMessage("Imagem"), - "italic": MessageLookupByLibrary.simpleMessage("Itálico"), - "link": MessageLookupByLibrary.simpleMessage("Link"), - "numberedList": MessageLookupByLibrary.simpleMessage("Lista numerada"), - "quote": MessageLookupByLibrary.simpleMessage("Citar"), - "strikethrough": MessageLookupByLibrary.simpleMessage("Rasurar"), - "text": MessageLookupByLibrary.simpleMessage("Texto"), - "underline": MessageLookupByLibrary.simpleMessage("Sublinhar") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_pt-PT.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_pt-PT.dart deleted file mode 100644 index d2d2781580de4..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_pt-PT.dart +++ /dev/null @@ -1,43 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a pt_PT locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'pt_PT'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("negrito"), - "bulletedList": - MessageLookupByLibrary.simpleMessage("lista com marcadores"), - "checkbox": MessageLookupByLibrary.simpleMessage("caixa de seleção"), - "embedCode": MessageLookupByLibrary.simpleMessage("Código embutido"), - "heading1": MessageLookupByLibrary.simpleMessage("Cabeçallho 1"), - "heading2": MessageLookupByLibrary.simpleMessage("Cabeçallho 2"), - "heading3": MessageLookupByLibrary.simpleMessage("Cabeçallho 3"), - "highlight": MessageLookupByLibrary.simpleMessage("realçar"), - "image": MessageLookupByLibrary.simpleMessage("imagem"), - "italic": MessageLookupByLibrary.simpleMessage("itálico"), - "link": MessageLookupByLibrary.simpleMessage("link"), - "numberedList": MessageLookupByLibrary.simpleMessage("lista numerada"), - "quote": MessageLookupByLibrary.simpleMessage("citar"), - "strikethrough": MessageLookupByLibrary.simpleMessage("tachado"), - "text": MessageLookupByLibrary.simpleMessage("texto"), - "underline": MessageLookupByLibrary.simpleMessage("sublinhado") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_ru-RU.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_ru-RU.dart deleted file mode 100644 index 21ae9f6a0ed7a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_ru-RU.dart +++ /dev/null @@ -1,44 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a ru_RU locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'ru_RU'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage("смелый"), - "bulletedList": - MessageLookupByLibrary.simpleMessage("маркированный список"), - "checkbox": MessageLookupByLibrary.simpleMessage("флажок"), - "embedCode": MessageLookupByLibrary.simpleMessage("код для вставки"), - "heading1": MessageLookupByLibrary.simpleMessage("заголовок1"), - "heading2": MessageLookupByLibrary.simpleMessage("заголовок2"), - "heading3": MessageLookupByLibrary.simpleMessage("заголовок3"), - "highlight": MessageLookupByLibrary.simpleMessage("выделять"), - "image": MessageLookupByLibrary.simpleMessage("изображение"), - "italic": MessageLookupByLibrary.simpleMessage("курсив"), - "link": MessageLookupByLibrary.simpleMessage("ссылка на сайт"), - "numberedList": - MessageLookupByLibrary.simpleMessage("нумерованный список"), - "quote": MessageLookupByLibrary.simpleMessage("цитировать"), - "strikethrough": MessageLookupByLibrary.simpleMessage("зачеркнутый"), - "text": MessageLookupByLibrary.simpleMessage("текст"), - "underline": MessageLookupByLibrary.simpleMessage("подчеркнуть") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_tr-TR.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_tr-TR.dart deleted file mode 100644 index 50ba27e79009a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_tr-TR.dart +++ /dev/null @@ -1,42 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a tr_TR locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'tr_TR'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage(""), - "bulletedList": MessageLookupByLibrary.simpleMessage(""), - "checkbox": MessageLookupByLibrary.simpleMessage(""), - "embedCode": MessageLookupByLibrary.simpleMessage(""), - "heading1": MessageLookupByLibrary.simpleMessage(""), - "heading2": MessageLookupByLibrary.simpleMessage(""), - "heading3": MessageLookupByLibrary.simpleMessage(""), - "highlight": MessageLookupByLibrary.simpleMessage(""), - "image": MessageLookupByLibrary.simpleMessage(""), - "italic": MessageLookupByLibrary.simpleMessage(""), - "link": MessageLookupByLibrary.simpleMessage(""), - "numberedList": MessageLookupByLibrary.simpleMessage(""), - "quote": MessageLookupByLibrary.simpleMessage(""), - "strikethrough": MessageLookupByLibrary.simpleMessage(""), - "text": MessageLookupByLibrary.simpleMessage(""), - "underline": MessageLookupByLibrary.simpleMessage("") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_zh-CN.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_zh-CN.dart deleted file mode 100644 index 21a3b9d7a29e3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_zh-CN.dart +++ /dev/null @@ -1,42 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a zh_CN locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'zh_CN'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage(""), - "bulletedList": MessageLookupByLibrary.simpleMessage(""), - "checkbox": MessageLookupByLibrary.simpleMessage(""), - "embedCode": MessageLookupByLibrary.simpleMessage(""), - "heading1": MessageLookupByLibrary.simpleMessage(""), - "heading2": MessageLookupByLibrary.simpleMessage(""), - "heading3": MessageLookupByLibrary.simpleMessage(""), - "highlight": MessageLookupByLibrary.simpleMessage(""), - "image": MessageLookupByLibrary.simpleMessage(""), - "italic": MessageLookupByLibrary.simpleMessage(""), - "link": MessageLookupByLibrary.simpleMessage(""), - "numberedList": MessageLookupByLibrary.simpleMessage(""), - "quote": MessageLookupByLibrary.simpleMessage(""), - "strikethrough": MessageLookupByLibrary.simpleMessage(""), - "text": MessageLookupByLibrary.simpleMessage(""), - "underline": MessageLookupByLibrary.simpleMessage("") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_zh-TW.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_zh-TW.dart deleted file mode 100644 index 8fbe4a836b1a9..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/intl/messages_zh-TW.dart +++ /dev/null @@ -1,42 +0,0 @@ -// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart -// This is a library that provides messages for a zh_TW locale. All the -// messages from the main program should be duplicated here with the same -// function name. - -// Ignore issues from commonly used lints in this file. -// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new -// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering -// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases -// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes -// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes - -import 'package:intl/intl.dart'; -import 'package:intl/message_lookup_by_library.dart'; - -final messages = new MessageLookup(); - -typedef String MessageIfAbsent(String messageStr, List args); - -class MessageLookup extends MessageLookupByLibrary { - String get localeName => 'zh_TW'; - - final messages = _notInlinedMessages(_notInlinedMessages); - static Map _notInlinedMessages(_) => { - "bold": MessageLookupByLibrary.simpleMessage(""), - "bulletedList": MessageLookupByLibrary.simpleMessage(""), - "checkbox": MessageLookupByLibrary.simpleMessage(""), - "embedCode": MessageLookupByLibrary.simpleMessage(""), - "heading1": MessageLookupByLibrary.simpleMessage(""), - "heading2": MessageLookupByLibrary.simpleMessage(""), - "heading3": MessageLookupByLibrary.simpleMessage(""), - "highlight": MessageLookupByLibrary.simpleMessage(""), - "image": MessageLookupByLibrary.simpleMessage(""), - "italic": MessageLookupByLibrary.simpleMessage(""), - "link": MessageLookupByLibrary.simpleMessage(""), - "numberedList": MessageLookupByLibrary.simpleMessage(""), - "quote": MessageLookupByLibrary.simpleMessage(""), - "strikethrough": MessageLookupByLibrary.simpleMessage(""), - "text": MessageLookupByLibrary.simpleMessage(""), - "underline": MessageLookupByLibrary.simpleMessage("") - }; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/l10n.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/l10n.dart deleted file mode 100644 index 0d464022d2e75..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/l10n/l10n.dart +++ /dev/null @@ -1,262 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'intl/messages_all.dart'; - -// ************************************************************************** -// Generator: Flutter Intl IDE plugin -// Made by Localizely -// ************************************************************************** - -// ignore_for_file: non_constant_identifier_names, lines_longer_than_80_chars -// ignore_for_file: join_return_with_assignment, prefer_final_in_for_each -// ignore_for_file: avoid_redundant_argument_values, avoid_escaping_inner_quotes - -class AppFlowyEditorLocalizations { - AppFlowyEditorLocalizations(); - - static AppFlowyEditorLocalizations? _current; - - static AppFlowyEditorLocalizations get current { - assert(_current != null, - 'No instance of AppFlowyEditorLocalizations was loaded. Try to initialize the AppFlowyEditorLocalizations delegate before accessing AppFlowyEditorLocalizations.current.'); - return _current!; - } - - static const AppLocalizationDelegate delegate = AppLocalizationDelegate(); - - static Future load(Locale locale) { - final name = (locale.countryCode?.isEmpty ?? false) - ? locale.languageCode - : locale.toString(); - final localeName = Intl.canonicalizedLocale(name); - return initializeMessages(localeName).then((_) { - Intl.defaultLocale = localeName; - final instance = AppFlowyEditorLocalizations(); - AppFlowyEditorLocalizations._current = instance; - - return instance; - }); - } - - static AppFlowyEditorLocalizations of(BuildContext context) { - final instance = AppFlowyEditorLocalizations.maybeOf(context); - assert(instance != null, - 'No instance of AppFlowyEditorLocalizations present in the widget tree. Did you add AppFlowyEditorLocalizations.delegate in localizationsDelegates?'); - return instance!; - } - - static AppFlowyEditorLocalizations? maybeOf(BuildContext context) { - return Localizations.of( - context, AppFlowyEditorLocalizations); - } - - /// `Bold` - String get bold { - return Intl.message( - 'Bold', - name: 'bold', - desc: '', - args: [], - ); - } - - /// `Bulleted List` - String get bulletedList { - return Intl.message( - 'Bulleted List', - name: 'bulletedList', - desc: '', - args: [], - ); - } - - /// `Checkbox` - String get checkbox { - return Intl.message( - 'Checkbox', - name: 'checkbox', - desc: '', - args: [], - ); - } - - /// `Embed Code` - String get embedCode { - return Intl.message( - 'Embed Code', - name: 'embedCode', - desc: '', - args: [], - ); - } - - /// `H1` - String get heading1 { - return Intl.message( - 'H1', - name: 'heading1', - desc: '', - args: [], - ); - } - - /// `H2` - String get heading2 { - return Intl.message( - 'H2', - name: 'heading2', - desc: '', - args: [], - ); - } - - /// `H3` - String get heading3 { - return Intl.message( - 'H3', - name: 'heading3', - desc: '', - args: [], - ); - } - - /// `Highlight` - String get highlight { - return Intl.message( - 'Highlight', - name: 'highlight', - desc: '', - args: [], - ); - } - - /// `Image` - String get image { - return Intl.message( - 'Image', - name: 'image', - desc: '', - args: [], - ); - } - - /// `Italic` - String get italic { - return Intl.message( - 'Italic', - name: 'italic', - desc: '', - args: [], - ); - } - - /// `Link` - String get link { - return Intl.message( - 'Link', - name: 'link', - desc: '', - args: [], - ); - } - - /// `Numbered List` - String get numberedList { - return Intl.message( - 'Numbered List', - name: 'numberedList', - desc: '', - args: [], - ); - } - - /// `Quote` - String get quote { - return Intl.message( - 'Quote', - name: 'quote', - desc: '', - args: [], - ); - } - - /// `Strikethrough` - String get strikethrough { - return Intl.message( - 'Strikethrough', - name: 'strikethrough', - desc: '', - args: [], - ); - } - - /// `Text` - String get text { - return Intl.message( - 'Text', - name: 'text', - desc: '', - args: [], - ); - } - - /// `Underline` - String get underline { - return Intl.message( - 'Underline', - name: 'underline', - desc: '', - args: [], - ); - } -} - -class AppLocalizationDelegate - extends LocalizationsDelegate { - const AppLocalizationDelegate(); - - List get supportedLocales { - return const [ - Locale.fromSubtags(languageCode: 'en'), - Locale.fromSubtags(languageCode: 'bn', countryCode: 'BN'), - Locale.fromSubtags(languageCode: 'ca'), - Locale.fromSubtags(languageCode: 'cs', countryCode: 'CZ'), - Locale.fromSubtags(languageCode: 'de', countryCode: 'DE'), - Locale.fromSubtags(languageCode: 'es', countryCode: 'VE'), - Locale.fromSubtags(languageCode: 'fr', countryCode: 'CA'), - Locale.fromSubtags(languageCode: 'fr', countryCode: 'FR'), - Locale.fromSubtags(languageCode: 'hi', countryCode: 'IN'), - Locale.fromSubtags(languageCode: 'hu', countryCode: 'HU'), - Locale.fromSubtags(languageCode: 'id', countryCode: 'ID'), - Locale.fromSubtags(languageCode: 'it', countryCode: 'IT'), - Locale.fromSubtags(languageCode: 'ja', countryCode: 'JP'), - Locale.fromSubtags(languageCode: 'ml', countryCode: 'IN'), - Locale.fromSubtags(languageCode: 'nl', countryCode: 'NL'), - Locale.fromSubtags(languageCode: 'pl', countryCode: 'PL'), - Locale.fromSubtags(languageCode: 'pt', countryCode: 'BR'), - Locale.fromSubtags(languageCode: 'pt', countryCode: 'PT'), - Locale.fromSubtags(languageCode: 'ru', countryCode: 'RU'), - Locale.fromSubtags(languageCode: 'tr', countryCode: 'TR'), - Locale.fromSubtags(languageCode: 'zh', countryCode: 'CN'), - Locale.fromSubtags(languageCode: 'zh', countryCode: 'TW'), - ]; - } - - @override - bool isSupported(Locale locale) => _isSupported(locale); - @override - Future load(Locale locale) => - AppFlowyEditorLocalizations.load(locale); - @override - bool shouldReload(AppLocalizationDelegate old) => false; - - bool _isSupported(Locale locale) { - for (var supportedLocale in supportedLocales) { - if (supportedLocale.languageCode == locale.languageCode) { - return true; - } - } - return false; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart deleted file mode 100644 index f04539d94a0ce..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/service/render_plugin_service.dart'; - -class EditorEntryWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return EditorNodeWidget( - key: context.node.key, - node: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return node.type == 'editor'; - }); -} - -class EditorNodeWidget extends StatelessWidget { - const EditorNodeWidget({ - Key? key, - required this.node, - required this.editorState, - }) : super(key: key); - - final Node node; - final EditorState editorState; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: node.children - .map( - (child) => - editorState.service.renderPluginService.buildPluginWidget( - child is TextNode - ? NodeWidgetContext( - context: context, - node: child, - editorState: editorState, - ) - : NodeWidgetContext( - context: context, - node: child, - editorState: editorState, - ), - ), - ) - .toList(), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart deleted file mode 100644 index 2c84869fbf6b3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/service/render_plugin_service.dart'; -import 'package:flutter/material.dart'; -import 'package:rich_clipboard/rich_clipboard.dart'; - -import 'image_node_widget.dart'; - -class ImageNodeBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - final src = context.node.attributes['image_src']; - final align = context.node.attributes['align']; - double? width; - if (context.node.attributes.containsKey('width')) { - width = context.node.attributes['width'].toDouble(); - } - return ImageNodeWidget( - key: context.node.key, - node: context.node, - src: src, - width: width, - alignment: _textToAlignment(align), - onCopy: () { - RichClipboard.setData(RichClipboardData(text: src)); - }, - onDelete: () { - final transaction = context.editorState.transaction - ..deleteNode(context.node); - context.editorState.apply(transaction); - }, - onAlign: (alignment) { - final transaction = context.editorState.transaction - ..updateNode(context.node, { - 'align': _alignmentToText(alignment), - }); - context.editorState.apply(transaction); - }, - onResize: (width) { - final transaction = context.editorState.transaction - ..updateNode(context.node, { - 'width': width, - }); - context.editorState.apply(transaction); - }, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return node.type == 'image' && - node.attributes.containsKey('image_src') && - node.attributes.containsKey('align'); - }); - - Alignment _textToAlignment(String text) { - if (text == 'left') { - return Alignment.centerLeft; - } else if (text == 'right') { - return Alignment.centerRight; - } - return Alignment.center; - } - - String _alignmentToText(Alignment alignment) { - if (alignment == Alignment.centerLeft) { - return 'left'; - } else if (alignment == Alignment.centerRight) { - return 'right'; - } - return 'center'; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart deleted file mode 100644 index 5f9b2142fefa3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart +++ /dev/null @@ -1,393 +0,0 @@ -import 'package:appflowy_editor/src/extensions/object_extensions.dart'; -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/location/position.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/infra/flowy_svg.dart'; -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:flutter/material.dart'; - -class ImageNodeWidget extends StatefulWidget { - const ImageNodeWidget({ - Key? key, - required this.node, - required this.src, - this.width, - required this.alignment, - required this.onCopy, - required this.onDelete, - required this.onAlign, - required this.onResize, - }) : super(key: key); - - final Node node; - final String src; - final double? width; - final Alignment alignment; - final VoidCallback onCopy; - final VoidCallback onDelete; - final void Function(Alignment alignment) onAlign; - final void Function(double width) onResize; - - @override - State createState() => _ImageNodeWidgetState(); -} - -class _ImageNodeWidgetState extends State - with SelectableMixin { - final _imageKey = GlobalKey(); - - double? _imageWidth; - double _initial = 0; - double _distance = 0; - bool _onFocus = false; - - ImageStream? _imageStream; - late ImageStreamListener _imageStreamListener; - - @override - void initState() { - super.initState(); - - _imageWidth = widget.width; - _imageStreamListener = ImageStreamListener( - (image, _) { - _imageWidth = _imageKey.currentContext - ?.findRenderObject() - ?.unwrapOrNull() - ?.size - .width; - }, - ); - } - - @override - void dispose() { - _imageStream?.removeListener(_imageStreamListener); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - // only support network image. - return Container( - key: _imageKey, - padding: const EdgeInsets.only(top: 8, bottom: 8), - child: _buildNetworkImage(context), - ); - } - - @override - Position start() { - return Position(path: widget.node.path, offset: 0); - } - - @override - Position end() { - return Position(path: widget.node.path, offset: 1); - } - - @override - Position getPositionInOffset(Offset start) { - return end(); - } - - @override - Rect? getCursorRectInPosition(Position position) { - return null; - } - - @override - List getRectsInSelection(Selection selection) { - final renderBox = context.findRenderObject() as RenderBox; - return [Offset.zero & renderBox.size]; - } - - @override - Selection getSelectionInRange(Offset start, Offset end) { - if (start <= end) { - return Selection(start: this.start(), end: this.end()); - } else { - return Selection(start: this.end(), end: this.start()); - } - } - - @override - Offset localToGlobal(Offset offset) { - final renderBox = context.findRenderObject() as RenderBox; - return renderBox.localToGlobal(offset); - } - - Widget _buildNetworkImage(BuildContext context) { - return Align( - alignment: widget.alignment, - child: MouseRegion( - onEnter: (event) => setState(() { - _onFocus = true; - }), - onExit: (event) => setState(() { - _onFocus = false; - }), - child: _buildResizableImage(context), - ), - ); - } - - Widget _buildResizableImage(BuildContext context) { - final networkImage = Image.network( - widget.src, - width: _imageWidth == null ? null : _imageWidth! - _distance, - gaplessPlayback: true, - loadingBuilder: (context, child, loadingProgress) => - loadingProgress == null ? child : _buildLoading(context), - errorBuilder: (context, error, stackTrace) { - // _imageWidth ??= defaultMaxTextNodeWidth; - return _buildError(context); - }, - ); - if (_imageWidth == null) { - _imageStream = networkImage.image.resolve(const ImageConfiguration()) - ..addListener(_imageStreamListener); - } - return Stack( - children: [ - networkImage, - _buildEdgeGesture( - context, - top: 0, - left: 0, - bottom: 0, - width: 5, - onUpdate: (distance) { - setState(() { - _distance = distance; - }); - }, - ), - _buildEdgeGesture( - context, - top: 0, - right: 0, - bottom: 0, - width: 5, - onUpdate: (distance) { - setState(() { - _distance = -distance; - }); - }, - ), - if (_onFocus) - ImageToolbar( - top: 8, - right: 8, - height: 30, - alignment: widget.alignment, - onAlign: widget.onAlign, - onCopy: widget.onCopy, - onDelete: widget.onDelete, - ) - ], - ); - } - - Widget _buildLoading(BuildContext context) { - return SizedBox( - height: 150, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox.fromSize( - size: const Size(18, 18), - child: const CircularProgressIndicator(), - ), - SizedBox.fromSize( - size: const Size(10, 10), - ), - const Text('Loading'), - ], - ), - ); - } - - Widget _buildError(BuildContext context) { - return Container( - height: 100, - width: _imageWidth, - alignment: Alignment.center, - padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(4.0)), - border: Border.all(width: 1, color: Colors.black), - ), - child: const Text('Could not load the image'), - ); - } - - Widget _buildEdgeGesture( - BuildContext context, { - double? top, - double? left, - double? right, - double? bottom, - double? width, - void Function(double distance)? onUpdate, - }) { - return Positioned( - top: top, - left: left, - right: right, - bottom: bottom, - width: width, - child: GestureDetector( - onHorizontalDragStart: (details) { - _initial = details.globalPosition.dx; - }, - onHorizontalDragUpdate: (details) { - if (onUpdate != null) { - onUpdate(details.globalPosition.dx - _initial); - } - }, - onHorizontalDragEnd: (details) { - _imageWidth = _imageWidth! - _distance; - _initial = 0; - _distance = 0; - - widget.onResize(_imageWidth!); - }, - child: MouseRegion( - cursor: SystemMouseCursors.resizeLeftRight, - child: _onFocus - ? Center( - child: Container( - height: 40, - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.2), - borderRadius: const BorderRadius.all( - Radius.circular(5.0), - ), - ), - ), - ) - : null, - ), - ), - ); - } -} - -@visibleForTesting -class ImageToolbar extends StatelessWidget { - const ImageToolbar({ - Key? key, - required this.top, - required this.right, - required this.height, - required this.alignment, - required this.onCopy, - required this.onDelete, - required this.onAlign, - }) : super(key: key); - - final double top; - final double right; - final double height; - final Alignment alignment; - final VoidCallback onCopy; - final VoidCallback onDelete; - final void Function(Alignment alignment) onAlign; - - @override - Widget build(BuildContext context) { - return Positioned( - top: top, - right: right, - height: height, - child: Container( - decoration: BoxDecoration( - color: const Color(0xFF333333), - boxShadow: [ - BoxShadow( - blurRadius: 5, - spreadRadius: 1, - color: Colors.black.withOpacity(0.1), - ), - ], - borderRadius: BorderRadius.circular(8.0), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - IconButton( - hoverColor: Colors.transparent, - constraints: const BoxConstraints(), - padding: const EdgeInsets.fromLTRB(6.0, 4.0, 0.0, 4.0), - icon: FlowySvg( - name: 'image_toolbar/align_left', - color: alignment == Alignment.centerLeft - ? const Color(0xFF00BCF0) - : null, - ), - onPressed: () { - onAlign(Alignment.centerLeft); - }, - ), - IconButton( - hoverColor: Colors.transparent, - constraints: const BoxConstraints(), - padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 4.0), - icon: FlowySvg( - name: 'image_toolbar/align_center', - color: alignment == Alignment.center - ? const Color(0xFF00BCF0) - : null, - ), - onPressed: () { - onAlign(Alignment.center); - }, - ), - IconButton( - hoverColor: Colors.transparent, - constraints: const BoxConstraints(), - padding: const EdgeInsets.fromLTRB(0.0, 4.0, 4.0, 4.0), - icon: FlowySvg( - name: 'image_toolbar/align_right', - color: alignment == Alignment.centerRight - ? const Color(0xFF00BCF0) - : null, - ), - onPressed: () { - onAlign(Alignment.centerRight); - }, - ), - const Center( - child: FlowySvg( - name: 'image_toolbar/divider', - ), - ), - IconButton( - hoverColor: Colors.transparent, - constraints: const BoxConstraints(), - padding: const EdgeInsets.fromLTRB(4.0, 4.0, 0.0, 4.0), - icon: const FlowySvg( - name: 'image_toolbar/copy', - ), - onPressed: () { - onCopy(); - }, - ), - IconButton( - hoverColor: Colors.transparent, - constraints: const BoxConstraints(), - padding: const EdgeInsets.fromLTRB(0.0, 4.0, 6.0, 4.0), - icon: const FlowySvg( - name: 'image_toolbar/delete', - ), - onPressed: () { - onDelete(); - }, - ), - ], - ), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart deleted file mode 100644 index a8909d16c5940..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart +++ /dev/null @@ -1,204 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/infra/flowy_svg.dart'; -import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart'; -import 'package:appflowy_editor/src/render/style/editor_style.dart'; -import 'package:flutter/material.dart'; - -OverlayEntry? _imageUploadMenu; -EditorState? _editorState; -void showImageUploadMenu( - EditorState editorState, - SelectionMenuService menuService, - BuildContext context, -) { - menuService.dismiss(); - - _imageUploadMenu?.remove(); - _imageUploadMenu = OverlayEntry(builder: (context) { - return Positioned( - top: menuService.topLeft.dy, - left: menuService.topLeft.dx, - child: Material( - child: ImageUploadMenu( - editorState: editorState, - onSubmitted: (text) { - // _dismissImageUploadMenu(); - editorState.insertImageNode(text); - }, - onUpload: (text) { - // _dismissImageUploadMenu(); - editorState.insertImageNode(text); - }, - ), - ), - ); - }); - - Overlay.of(context)?.insert(_imageUploadMenu!); - - editorState.service.selectionService.currentSelection - .addListener(_dismissImageUploadMenu); -} - -void _dismissImageUploadMenu() { - _imageUploadMenu?.remove(); - _imageUploadMenu = null; - - _editorState?.service.selectionService.currentSelection - .removeListener(_dismissImageUploadMenu); - _editorState = null; -} - -class ImageUploadMenu extends StatefulWidget { - const ImageUploadMenu({ - Key? key, - required this.onSubmitted, - required this.onUpload, - this.editorState, - }) : super(key: key); - - final void Function(String text) onSubmitted; - final void Function(String text) onUpload; - final EditorState? editorState; - - @override - State createState() => _ImageUploadMenuState(); -} - -class _ImageUploadMenuState extends State { - final _textEditingController = TextEditingController(); - final _focusNode = FocusNode(); - - EditorStyle? get style => widget.editorState?.editorStyle; - - @override - void initState() { - super.initState(); - _focusNode.requestFocus(); - } - - @override - void dispose() { - _focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Container( - width: 300, - padding: const EdgeInsets.all(24.0), - decoration: BoxDecoration( - color: style?.selectionMenuBackgroundColor ?? Colors.white, - boxShadow: [ - BoxShadow( - blurRadius: 5, - spreadRadius: 1, - color: Colors.black.withOpacity(0.1), - ), - ], - // borderRadius: BorderRadius.circular(6.0), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(context), - const SizedBox(height: 16.0), - _buildInput(), - const SizedBox(height: 18.0), - _buildUploadButton(context), - ], - ), - ); - } - - Widget _buildHeader(BuildContext context) { - return Text( - 'URL Image', - textAlign: TextAlign.left, - style: TextStyle( - fontSize: 14.0, - color: style?.selectionMenuItemTextColor ?? Colors.black, - fontWeight: FontWeight.w500, - ), - ); - } - - Widget _buildInput() { - return TextField( - focusNode: _focusNode, - style: const TextStyle(fontSize: 14.0), - textAlign: TextAlign.left, - controller: _textEditingController, - onSubmitted: widget.onSubmitted, - decoration: InputDecoration( - hintText: 'URL', - hintStyle: const TextStyle(fontSize: 14.0), - contentPadding: const EdgeInsets.all(16.0), - isDense: true, - suffixIcon: IconButton( - padding: const EdgeInsets.all(4.0), - icon: const FlowySvg( - name: 'clear', - width: 24, - height: 24, - ), - onPressed: () { - _textEditingController.clear(); - }, - ), - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(12.0)), - borderSide: BorderSide(color: Color(0xFFBDBDBD)), - ), - ), - ); - } - - Widget _buildUploadButton(BuildContext context) { - return SizedBox( - width: 170, - height: 48, - child: TextButton( - style: ButtonStyle( - backgroundColor: MaterialStateProperty.all(const Color(0xFF00BCF0)), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), - ), - ), - onPressed: () { - widget.onUpload(_textEditingController.text); - }, - child: const Text( - 'Upload', - style: TextStyle(color: Colors.white, fontSize: 14.0), - ), - ), - ); - } -} - -extension on EditorState { - void insertImageNode(String src) { - final selection = service.selectionService.currentSelection.value; - if (selection == null) { - return; - } - final imageNode = Node( - type: 'image', - attributes: { - 'image_src': src, - 'align': 'center', - }, - ); - final transaction = this.transaction; - transaction.insertNode( - selection.start.path, - imageNode, - ); - apply(transaction); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart deleted file mode 100644 index 58e8111793c86..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/infra/flowy_svg.dart'; -import 'package:appflowy_editor/src/render/style/editor_style.dart'; -import 'package:flutter/material.dart'; - -class LinkMenu extends StatefulWidget { - const LinkMenu({ - Key? key, - this.linkText, - this.editorState, - required this.onSubmitted, - required this.onOpenLink, - required this.onCopyLink, - required this.onRemoveLink, - required this.onFocusChange, - }) : super(key: key); - - final String? linkText; - final EditorState? editorState; - final void Function(String text) onSubmitted; - final VoidCallback onOpenLink; - final VoidCallback onCopyLink; - final VoidCallback onRemoveLink; - final void Function(bool value) onFocusChange; - - @override - State createState() => _LinkMenuState(); -} - -class _LinkMenuState extends State { - final _textEditingController = TextEditingController(); - final _focusNode = FocusNode(); - - EditorStyle? get style => widget.editorState?.editorStyle; - - @override - void initState() { - super.initState(); - _textEditingController.text = widget.linkText ?? ''; - _focusNode.requestFocus(); - _focusNode.addListener(_onFocusChange); - } - - @override - void dispose() { - _textEditingController.dispose(); - _focusNode.removeListener(_onFocusChange); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 350, - child: Container( - decoration: BoxDecoration( - color: style?.selectionMenuBackgroundColor ?? Colors.white, - boxShadow: [ - BoxShadow( - blurRadius: 5, - spreadRadius: 1, - color: Colors.black.withOpacity(0.1), - ), - ], - borderRadius: BorderRadius.circular(6.0), - ), - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _buildHeader(), - const SizedBox(height: 16.0), - _buildInput(), - const SizedBox(height: 16.0), - if (widget.linkText != null) ...[ - _buildIconButton( - iconName: 'link', - color: style?.selectionMenuItemIconColor, - text: 'Open link', - onPressed: widget.onOpenLink, - ), - _buildIconButton( - iconName: 'copy', - color: style?.selectionMenuItemIconColor, - text: 'Copy link', - onPressed: widget.onCopyLink, - ), - _buildIconButton( - iconName: 'delete', - color: style?.selectionMenuItemIconColor, - text: 'Remove link', - onPressed: widget.onRemoveLink, - ), - ] - ], - ), - ), - ), - ); - } - - Widget _buildHeader() { - return const Text( - 'Add your link', - style: TextStyle( - color: Colors.grey, - fontWeight: FontWeight.bold, - ), - ); - } - - Widget _buildInput() { - return TextField( - focusNode: _focusNode, - style: const TextStyle(fontSize: 14.0), - textAlign: TextAlign.left, - controller: _textEditingController, - onSubmitted: widget.onSubmitted, - decoration: InputDecoration( - hintText: 'URL', - hintStyle: const TextStyle(fontSize: 14.0), - contentPadding: const EdgeInsets.all(16.0), - isDense: true, - suffixIcon: IconButton( - padding: const EdgeInsets.all(4.0), - icon: const FlowySvg( - name: 'clear', - width: 24, - height: 24, - ), - onPressed: () { - _textEditingController.clear(); - }, - ), - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(12.0)), - borderSide: BorderSide(color: Color(0xFFBDBDBD)), - ), - ), - ); - } - - Widget _buildIconButton({ - required String iconName, - Color? color, - required String text, - required VoidCallback onPressed, - }) { - return TextButton.icon( - icon: FlowySvg( - name: iconName, - color: color, - ), - style: TextButton.styleFrom( - minimumSize: const Size.fromHeight(40), - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - alignment: Alignment.centerLeft, - ), - label: Text( - text, - textAlign: TextAlign.left, - style: TextStyle( - color: style?.selectionMenuItemTextColor ?? Colors.black, - fontSize: 14.0, - ), - ), - onPressed: onPressed, - ); - } - - void _onFocusChange() { - widget.onFocusChange(_focusNode.hasFocus); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/built_in_text_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/built_in_text_widget.dart deleted file mode 100644 index 7fb2cee2cb379..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/built_in_text_widget.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; - -abstract class BuiltInTextWidget extends StatefulWidget { - const BuiltInTextWidget({ - Key? key, - }) : super(key: key); - - EditorState get editorState; - TextNode get textNode; -} - -mixin BuiltInTextWidgetMixin on State - implements DefaultSelectable { - @override - Widget build(BuildContext context) { - if (widget.textNode.children.isEmpty) { - return buildWithSingle(context); - } else { - return buildWithChildren(context); - } - } - - Widget buildWithSingle(BuildContext context); - - Widget buildWithChildren(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - buildWithSingle(context), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // TODO: customize - const SizedBox( - width: 20, - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: widget.textNode.children - .map( - (child) => widget.editorState.service.renderPluginService - .buildPluginWidget( - child is TextNode - ? NodeWidgetContext( - context: context, - node: child, - editorState: widget.editorState, - ) - : NodeWidgetContext( - context: context, - node: child, - editorState: widget.editorState, - ), - ), - ) - .toList(), - ), - ) - ], - ) - ], - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart deleted file mode 100644 index 3f6927df4cedc..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart'; -import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; -import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart'; -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:appflowy_editor/src/render/style/plugin_styles.dart'; -import 'package:appflowy_editor/src/service/render_plugin_service.dart'; -import 'package:flutter/material.dart'; -import 'package:appflowy_editor/src/extensions/text_style_extension.dart'; -import 'package:appflowy_editor/src/extensions/theme_extension.dart'; - -class BulletedListTextNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return BulletedListTextNodeWidget( - key: context.node.key, - textNode: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return true; - }); -} - -class BulletedListTextNodeWidget extends BuiltInTextWidget { - const BulletedListTextNodeWidget({ - Key? key, - required this.textNode, - required this.editorState, - }) : super(key: key); - - @override - final TextNode textNode; - @override - final EditorState editorState; - - @override - State createState() => - _BulletedListTextNodeWidgetState(); -} - -// customize - -class _BulletedListTextNodeWidgetState extends State - with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin { - @override - final iconKey = GlobalKey(); - - final _richTextKey = GlobalKey(debugLabel: 'bulleted_list_text'); - - @override - SelectableMixin get forward => - _richTextKey.currentState as SelectableMixin; - - @override - Offset get baseOffset { - return super.baseOffset.translate(0, padding.top); - } - - BulletedListPluginStyle get style => - Theme.of(context).extensionOrNull() ?? - BulletedListPluginStyle.light; - - EdgeInsets get padding => style.padding( - widget.editorState, - widget.textNode, - ); - - TextStyle get textStyle => style.textStyle( - widget.editorState, - widget.textNode, - ); - - Widget get icon => style.icon( - widget.editorState, - widget.textNode, - ); - - @override - Widget buildWithSingle(BuildContext context) { - return Padding( - padding: padding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - key: iconKey, - child: icon, - ), - Flexible( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'List', - textSpanDecorator: (textSpan) => - textSpan.updateTextStyle(textStyle), - placeholderTextSpanDecorator: (textSpan) => - textSpan.updateTextStyle(textStyle), - lineHeight: widget.editorState.editorStyle.lineHeight, - textNode: widget.textNode, - editorState: widget.editorState, - ), - ) - ], - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart deleted file mode 100644 index a2e0aa5d3230f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/commands/text/text_commands.dart'; -import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart'; - -import 'package:appflowy_editor/src/extensions/text_style_extension.dart'; -import 'package:flutter/material.dart'; -import 'package:appflowy_editor/src/extensions/theme_extension.dart'; - -class CheckboxNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return CheckboxNodeWidget( - key: context.node.key, - textNode: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return node.attributes.containsKey(BuiltInAttributeKey.checkbox); - }); -} - -class CheckboxNodeWidget extends BuiltInTextWidget { - const CheckboxNodeWidget({ - Key? key, - required this.textNode, - required this.editorState, - }) : super(key: key); - - @override - final TextNode textNode; - @override - final EditorState editorState; - - @override - State createState() => _CheckboxNodeWidgetState(); -} - -class _CheckboxNodeWidgetState extends State - with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin { - @override - final iconKey = GlobalKey(); - - final _richTextKey = GlobalKey(debugLabel: 'checkbox_text'); - - @override - SelectableMixin get forward => - _richTextKey.currentState as SelectableMixin; - - @override - Offset get baseOffset { - return super.baseOffset.translate(0, padding.top); - } - - CheckboxPluginStyle get style => - Theme.of(context).extensionOrNull() ?? - CheckboxPluginStyle.light; - - EdgeInsets get padding => style.padding( - widget.editorState, - widget.textNode, - ); - - TextStyle get textStyle => style.textStyle( - widget.editorState, - widget.textNode, - ); - - Widget get icon => style.icon( - widget.editorState, - widget.textNode, - ); - - @override - Widget buildWithSingle(BuildContext context) { - final check = widget.textNode.attributes.check; - return Padding( - padding: padding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - key: iconKey, - child: icon, - onTap: () async { - await widget.editorState.formatTextToCheckbox( - widget.editorState, - !check, - textNode: widget.textNode, - ); - }, - ), - Flexible( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'To-do', - lineHeight: widget.editorState.editorStyle.lineHeight, - textNode: widget.textNode, - textSpanDecorator: (textSpan) => - textSpan.updateTextStyle(textStyle), - placeholderTextSpanDecorator: (textSpan) => - textSpan.updateTextStyle(textStyle), - editorState: widget.editorState, - ), - ), - ], - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart deleted file mode 100644 index 724a45b4698da..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:appflowy_editor/src/core/location/position.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:flutter/material.dart'; - -mixin DefaultSelectable { - SelectableMixin get forward; - - GlobalKey? get iconKey; - - Offset get baseOffset { - if (iconKey != null) { - final renderBox = iconKey!.currentContext?.findRenderObject(); - if (renderBox is RenderBox) { - return Offset(renderBox.size.width, 0); - } - } - return Offset.zero; - } - - Position getPositionInOffset(Offset start) => - forward.getPositionInOffset(start); - - Rect? getCursorRectInPosition(Position position) => - forward.getCursorRectInPosition(position)?.shift(baseOffset); - - List getRectsInSelection(Selection selection) => forward - .getRectsInSelection(selection) - .map((rect) => rect.shift(baseOffset)) - .toList(growable: false); - - Selection getSelectionInRange(Offset start, Offset end) => - forward.getSelectionInRange(start, end); - - Offset localToGlobal(Offset offset) => - forward.localToGlobal(offset) - baseOffset; - - Selection? getWorldBoundaryInOffset(Offset offset) => - forward.getWorldBoundaryInOffset(offset); - - Position start() => forward.start(); - - Position end() => forward.end(); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart deleted file mode 100644 index 8d96d143cffd3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart +++ /dev/null @@ -1,315 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; - -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/document/path.dart'; -import 'package:appflowy_editor/src/core/location/position.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/core/document/text_delta.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart'; -import 'package:appflowy_editor/src/extensions/text_style_extension.dart'; -import 'package:appflowy_editor/src/extensions/attributes_extension.dart'; - -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; - -const _kRichTextDebugMode = false; - -typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan); - -class FlowyRichText extends StatefulWidget { - const FlowyRichText({ - Key? key, - this.cursorHeight, - this.cursorWidth = 1.5, - this.lineHeight = 1.0, - this.textSpanDecorator, - this.placeholderText = ' ', - this.placeholderTextSpanDecorator, - required this.textNode, - required this.editorState, - }) : super(key: key); - - final TextNode textNode; - final EditorState editorState; - final double? cursorHeight; - final double cursorWidth; - final double lineHeight; - final FlowyTextSpanDecorator? textSpanDecorator; - final String placeholderText; - final FlowyTextSpanDecorator? placeholderTextSpanDecorator; - - @override - State createState() => _FlowyRichTextState(); -} - -class _FlowyRichTextState extends State with SelectableMixin { - var _textKey = GlobalKey(); - final _placeholderTextKey = GlobalKey(); - - RenderParagraph get _renderParagraph => - _textKey.currentContext?.findRenderObject() as RenderParagraph; - - RenderParagraph? get _placeholderRenderParagraph => - _placeholderTextKey.currentContext?.findRenderObject() as RenderParagraph; - - @override - void didUpdateWidget(covariant FlowyRichText oldWidget) { - super.didUpdateWidget(oldWidget); - - // https://github.com/flutter/flutter/issues/110342 - if (_textKey.currentWidget is RichText) { - // Force refresh the RichText widget. - _textKey = GlobalKey(); - } - } - - @override - Widget build(BuildContext context) { - return _buildRichText(context); - } - - @override - Position start() => Position(path: widget.textNode.path, offset: 0); - - @override - Position end() => Position( - path: widget.textNode.path, offset: widget.textNode.toPlainText().length); - - @override - Rect? getCursorRectInPosition(Position position) { - final textPosition = TextPosition(offset: position.offset); - - var cursorHeight = _renderParagraph.getFullHeightForCaret(textPosition); - var cursorOffset = - _renderParagraph.getOffsetForCaret(textPosition, Rect.zero); - if (cursorHeight == null) { - cursorHeight = - _placeholderRenderParagraph?.getFullHeightForCaret(textPosition); - cursorOffset = _placeholderRenderParagraph?.getOffsetForCaret( - textPosition, Rect.zero) ?? - Offset.zero; - } - final rect = Rect.fromLTWH( - cursorOffset.dx - (widget.cursorWidth / 2.0), - cursorOffset.dy, - widget.cursorWidth, - widget.cursorHeight ?? cursorHeight ?? 16.0, - ); - return rect; - } - - @override - Position getPositionInOffset(Offset start) { - final offset = _renderParagraph.globalToLocal(start); - final baseOffset = _renderParagraph.getPositionForOffset(offset).offset; - return Position(path: widget.textNode.path, offset: baseOffset); - } - - @override - Selection? getWorldBoundaryInOffset(Offset offset) { - final localOffset = _renderParagraph.globalToLocal(offset); - final textPosition = _renderParagraph.getPositionForOffset(localOffset); - final textRange = _renderParagraph.getWordBoundary(textPosition); - final start = Position(path: widget.textNode.path, offset: textRange.start); - final end = Position(path: widget.textNode.path, offset: textRange.end); - return Selection(start: start, end: end); - } - - @override - List getRectsInSelection(Selection selection) { - assert(selection.isSingle && - selection.start.path.equals(widget.textNode.path)); - - final textSelection = TextSelection( - baseOffset: selection.start.offset, - extentOffset: selection.end.offset, - ); - final rects = _renderParagraph - .getBoxesForSelection(textSelection, boxHeightStyle: BoxHeightStyle.max) - .map((box) => box.toRect()) - .toList(growable: false); - if (rects.isEmpty) { - // If the rich text widget does not contain any text, - // there will be no selection boxes, - // so we need to return to the default selection. - return [Rect.fromLTWH(0, 0, 0, _renderParagraph.size.height)]; - } - return rects; - } - - @override - Selection getSelectionInRange(Offset start, Offset end) { - final localStart = _renderParagraph.globalToLocal(start); - final localEnd = _renderParagraph.globalToLocal(end); - final baseOffset = _renderParagraph.getPositionForOffset(localStart).offset; - final extentOffset = _renderParagraph.getPositionForOffset(localEnd).offset; - return Selection.single( - path: widget.textNode.path, - startOffset: baseOffset, - endOffset: extentOffset, - ); - } - - @override - Offset localToGlobal(Offset offset) { - return _renderParagraph.localToGlobal(offset); - } - - Widget _buildRichText(BuildContext context) { - return MouseRegion( - cursor: SystemMouseCursors.text, - child: widget.textNode.toPlainText().isEmpty - ? Stack( - children: [ - _buildPlaceholderText(context), - _buildSingleRichText(context), - ], - ) - : _buildSingleRichText(context), - ); - } - - Widget _buildPlaceholderText(BuildContext context) { - final textSpan = _placeholderTextSpan; - return RichText( - key: _placeholderTextKey, - textHeightBehavior: const TextHeightBehavior( - applyHeightToFirstAscent: false, applyHeightToLastDescent: false), - text: widget.placeholderTextSpanDecorator != null - ? widget.placeholderTextSpanDecorator!(textSpan) - : textSpan, - ); - } - - Widget _buildSingleRichText(BuildContext context) { - final textSpan = _textSpan; - return RichText( - key: _textKey, - textHeightBehavior: const TextHeightBehavior( - applyHeightToFirstAscent: false, - applyHeightToLastDescent: false, - ), - text: widget.textSpanDecorator != null - ? widget.textSpanDecorator!(textSpan) - : textSpan, - ); - } - - TextSpan get _placeholderTextSpan { - final placeholderTextStyle = - widget.editorState.editorStyle.placeholderTextStyle; - return TextSpan( - children: [ - TextSpan( - text: widget.placeholderText, - style: placeholderTextStyle, - ), - ], - ); - } - - TextSpan get _textSpan { - var offset = 0; - List textSpans = []; - final style = widget.editorState.editorStyle; - final textInserts = widget.textNode.delta.whereType(); - for (final textInsert in textInserts) { - var textStyle = style.textStyle!; - GestureRecognizer? recognizer; - final attributes = textInsert.attributes; - if (attributes != null) { - if (attributes.bold == true) { - textStyle = textStyle.combine(style.bold); - } - if (attributes.italic == true) { - textStyle = textStyle.combine(style.italic); - } - if (attributes.underline == true) { - textStyle = textStyle.combine(style.underline); - } - if (attributes.strikethrough == true) { - textStyle = textStyle.combine(style.strikethrough); - } - if (attributes.href != null) { - textStyle = textStyle.combine(style.href); - recognizer = _buildTapHrefGestureRecognizer( - attributes.href!, - Selection.single( - path: widget.textNode.path, - startOffset: offset, - endOffset: offset + textInsert.length, - ), - ); - } - if (attributes.code == true) { - textStyle = textStyle.combine(style.code); - } - if (attributes.backgroundColor != null) { - textStyle = textStyle.combine( - TextStyle(backgroundColor: attributes.backgroundColor), - ); - } - } - offset += textInsert.length; - textSpans.add( - TextSpan( - text: textInsert.text, - style: textStyle, - recognizer: recognizer, - ), - ); - } - if (_kRichTextDebugMode) { - textSpans.add( - TextSpan( - text: '${widget.textNode.path}', - style: const TextStyle( - backgroundColor: Colors.red, - fontSize: 16.0, - ), - ), - ); - } - return TextSpan( - children: textSpans, - ); - } - - GestureRecognizer _buildTapHrefGestureRecognizer( - String href, Selection selection) { - Timer? timer; - var tapCount = 0; - final tapGestureRecognizer = TapGestureRecognizer() - ..onTap = () async { - // implement a simple double tap logic - tapCount += 1; - timer?.cancel(); - - if (tapCount == 2) { - tapCount = 0; - safeLaunchUrl(href); - return; - } - - timer = Timer(const Duration(milliseconds: 200), () { - tapCount = 0; - widget.editorState.service.selectionService - .updateSelection(selection); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - showLinkMenu( - context, - widget.editorState, - customSelection: selection, - ); - }); - }); - }; - return tapGestureRecognizer; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart deleted file mode 100644 index f8f5bd0f92658..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart'; -import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; -import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart'; -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:appflowy_editor/src/render/style/plugin_styles.dart'; -import 'package:appflowy_editor/src/service/render_plugin_service.dart'; -import 'package:flutter/material.dart'; -import 'package:appflowy_editor/src/extensions/attributes_extension.dart'; -import 'package:appflowy_editor/src/extensions/text_style_extension.dart'; -import 'package:appflowy_editor/src/extensions/theme_extension.dart'; - -class HeadingTextNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return HeadingTextNodeWidget( - key: context.node.key, - textNode: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return node.attributes.heading != null; - }); -} - -class HeadingTextNodeWidget extends BuiltInTextWidget { - const HeadingTextNodeWidget({ - Key? key, - required this.textNode, - required this.editorState, - }) : super(key: key); - - @override - final TextNode textNode; - @override - final EditorState editorState; - - @override - State createState() => _HeadingTextNodeWidgetState(); -} - -// customize -class _HeadingTextNodeWidgetState extends State - with SelectableMixin, DefaultSelectable { - @override - GlobalKey? get iconKey => null; - - final _richTextKey = GlobalKey(debugLabel: 'heading_text'); - - @override - SelectableMixin get forward => - _richTextKey.currentState as SelectableMixin; - - @override - Offset get baseOffset { - return padding.topLeft; - } - - HeadingPluginStyle get style => - Theme.of(context).extensionOrNull() ?? - HeadingPluginStyle.light; - - EdgeInsets get padding => style.padding( - widget.editorState, - widget.textNode, - ); - - TextStyle get textStyle => style.textStyle( - widget.editorState, - widget.textNode, - ); - - @override - Widget build(BuildContext context) { - return Padding( - padding: padding, - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'Heading', - placeholderTextSpanDecorator: (textSpan) => - textSpan.updateTextStyle(textStyle), - textSpanDecorator: (textSpan) => textSpan.updateTextStyle(textStyle), - lineHeight: widget.editorState.editorStyle.lineHeight, - textNode: widget.textNode, - editorState: widget.editorState, - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart deleted file mode 100644 index 60698d6aada20..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart'; -import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; -import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart'; -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:appflowy_editor/src/render/style/plugin_styles.dart'; -import 'package:appflowy_editor/src/service/render_plugin_service.dart'; -import 'package:flutter/material.dart'; -import 'package:appflowy_editor/src/extensions/attributes_extension.dart'; -import 'package:appflowy_editor/src/extensions/text_style_extension.dart'; -import 'package:appflowy_editor/src/extensions/theme_extension.dart'; - -class NumberListTextNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return NumberListTextNodeWidget( - key: context.node.key, - textNode: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return node.attributes.number != null; - }); -} - -class NumberListTextNodeWidget extends BuiltInTextWidget { - const NumberListTextNodeWidget({ - Key? key, - required this.textNode, - required this.editorState, - }) : super(key: key); - - @override - final TextNode textNode; - @override - final EditorState editorState; - - @override - State createState() => - _NumberListTextNodeWidgetState(); -} - -class _NumberListTextNodeWidgetState extends State - with SelectableMixin, DefaultSelectable { - @override - final iconKey = GlobalKey(); - - final _richTextKey = GlobalKey(debugLabel: 'number_list_text'); - - @override - SelectableMixin get forward => - _richTextKey.currentState as SelectableMixin; - - @override - Offset get baseOffset { - return super.baseOffset.translate(0, padding.top); - } - - NumberListPluginStyle get style => - Theme.of(context).extensionOrNull() ?? - NumberListPluginStyle.light; - - EdgeInsets get padding => style.padding( - widget.editorState, - widget.textNode, - ); - - TextStyle get textStyle => style.textStyle( - widget.editorState, - widget.textNode, - ); - - Widget get icon => style.icon( - widget.editorState, - widget.textNode, - ); - - @override - Widget build(BuildContext context) { - return Padding( - padding: padding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - key: iconKey, - child: icon, - ), - Flexible( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'List', - textNode: widget.textNode, - editorState: widget.editorState, - lineHeight: widget.editorState.editorStyle.lineHeight, - placeholderTextSpanDecorator: (textSpan) => - textSpan.updateTextStyle(textStyle), - textSpanDecorator: (textSpan) => - textSpan.updateTextStyle(textStyle), - ), - ), - ], - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart deleted file mode 100644 index 370d328d1eb41..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart'; -import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; -import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart'; -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:appflowy_editor/src/render/style/plugin_styles.dart'; -import 'package:appflowy_editor/src/service/render_plugin_service.dart'; -import 'package:flutter/material.dart'; -import 'package:appflowy_editor/src/extensions/text_style_extension.dart'; -import 'package:appflowy_editor/src/extensions/theme_extension.dart'; - -class QuotedTextNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return QuotedTextNodeWidget( - key: context.node.key, - textNode: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return true; - }); -} - -class QuotedTextNodeWidget extends BuiltInTextWidget { - const QuotedTextNodeWidget({ - Key? key, - required this.textNode, - required this.editorState, - }) : super(key: key); - - @override - final TextNode textNode; - @override - final EditorState editorState; - - @override - State createState() => _QuotedTextNodeWidgetState(); -} - -// customize - -class _QuotedTextNodeWidgetState extends State - with SelectableMixin, DefaultSelectable { - @override - final iconKey = GlobalKey(); - - final _richTextKey = GlobalKey(debugLabel: 'quoted_text'); - - @override - SelectableMixin get forward => - _richTextKey.currentState as SelectableMixin; - - @override - Offset get baseOffset { - return super.baseOffset.translate(0, padding.top); - } - - QuotedTextPluginStyle get style => - Theme.of(context).extensionOrNull() ?? - QuotedTextPluginStyle.light; - - EdgeInsets get padding => style.padding( - widget.editorState, - widget.textNode, - ); - - TextStyle get textStyle => style.textStyle( - widget.editorState, - widget.textNode, - ); - - Widget get icon => style.icon( - widget.editorState, - widget.textNode, - ); - - @override - Widget build(BuildContext context) { - return Padding( - padding: padding, - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - key: iconKey, - child: icon, - ), - Flexible( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'Quote', - textNode: widget.textNode, - textSpanDecorator: (textSpan) => - textSpan.updateTextStyle(textStyle), - placeholderTextSpanDecorator: (textSpan) => - textSpan.updateTextStyle(textStyle), - lineHeight: widget.editorState.editorStyle.lineHeight, - editorState: widget.editorState, - ), - ), - ], - ), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart deleted file mode 100644 index f48714045b1bc..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart'; -import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; -import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart'; -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:appflowy_editor/src/render/style/editor_style.dart'; -import 'package:appflowy_editor/src/service/render_plugin_service.dart'; -import 'package:flutter/material.dart'; -import 'package:appflowy_editor/src/extensions/text_style_extension.dart'; - -class RichTextNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return RichTextNodeWidget( - key: context.node.key, - textNode: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return true; - }); -} - -class RichTextNodeWidget extends BuiltInTextWidget { - const RichTextNodeWidget({ - Key? key, - required this.textNode, - required this.editorState, - }) : super(key: key); - - @override - final TextNode textNode; - @override - final EditorState editorState; - - @override - State createState() => _RichTextNodeWidgetState(); -} - -// customize - -class _RichTextNodeWidgetState extends State - with SelectableMixin, DefaultSelectable, BuiltInTextWidgetMixin { - @override - GlobalKey? get iconKey => null; - - final _richTextKey = GlobalKey(debugLabel: 'rich_text'); - - @override - SelectableMixin get forward => - _richTextKey.currentState as SelectableMixin; - - @override - Offset get baseOffset { - return textPadding.topLeft; - } - - EditorStyle get style => widget.editorState.editorStyle; - - EdgeInsets get textPadding => style.textPadding!; - - TextStyle get textStyle => style.textStyle!; - - @override - Widget buildWithSingle(BuildContext context) { - return Padding( - padding: textPadding, - child: FlowyRichText( - key: _richTextKey, - textNode: widget.textNode, - textSpanDecorator: (textSpan) => textSpan.updateTextStyle(textStyle), - placeholderTextSpanDecorator: (textSpan) => - textSpan.updateTextStyle(textStyle), - lineHeight: widget.editorState.editorStyle.lineHeight, - editorState: widget.editorState, - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/cursor_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/cursor_widget.dart deleted file mode 100644 index a7b68d410dc86..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/cursor_widget.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:flutter/material.dart'; - -class CursorWidget extends StatefulWidget { - const CursorWidget({ - Key? key, - required this.layerLink, - required this.rect, - required this.color, - this.blinkingInterval = 0.5, - this.shouldBlink = true, - this.cursorStyle = CursorStyle.verticalLine, - }) : super(key: key); - - final double blinkingInterval; // milliseconds - final bool shouldBlink; - final CursorStyle cursorStyle; - final Color color; - final Rect rect; - final LayerLink layerLink; - - @override - State createState() => CursorWidgetState(); -} - -class CursorWidgetState extends State { - bool showCursor = true; - late Timer timer; - - @override - void initState() { - super.initState(); - - timer = _initTimer(); - } - - @override - void dispose() { - timer.cancel(); - super.dispose(); - } - - Timer _initTimer() { - return Timer.periodic( - Duration(milliseconds: (widget.blinkingInterval * 1000).toInt()), - (timer) { - setState(() { - showCursor = !showCursor; - }); - }); - } - - /// force the cursor widget to show for a while - show() { - setState(() { - showCursor = true; - }); - timer.cancel(); - timer = _initTimer(); - } - - @override - Widget build(BuildContext context) { - return Positioned.fromRect( - rect: widget.rect, - child: CompositedTransformFollower( - link: widget.layerLink, - offset: widget.rect.topCenter, - showWhenUnlinked: true, - // Ignore the gestures in cursor - // to solve the problem that cursor area cannot be selected. - child: IgnorePointer( - child: _buildCursor(context), - ), - ), - ); - } - - Widget _buildCursor(BuildContext context) { - var color = widget.color; - if (widget.shouldBlink && !showCursor) { - color = Colors.transparent; - } - switch (widget.cursorStyle) { - case CursorStyle.verticalLine: - return Container( - color: color, - ); - case CursorStyle.borderLine: - return Container( - decoration: BoxDecoration( - border: Border.all(color: color, width: 2), - ), - ); - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selectable.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selectable.dart deleted file mode 100644 index 523f60de47d5b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selectable.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:appflowy_editor/src/core/location/position.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:flutter/material.dart'; - -enum CursorStyle { - verticalLine, - borderLine, -} - -/// [SelectableMixin] is used for the editor to calculate the position -/// and size of the selection. -/// -/// The widget returned by NodeWidgetBuilder must be with [SelectableMixin], -/// otherwise the [AppFlowySelectionService] will not work properly. -mixin SelectableMixin on State { - /// Returns the [Selection] surrounded by start and end - /// in current widget. - /// - /// [start] and [end] are the offsets under the global coordinate system. - /// - Selection getSelectionInRange(Offset start, Offset end); - - /// Returns a [List] of the [Rect] area within selection - /// in current widget. - /// - /// The return result must be a [List] of the [Rect] - /// under the local coordinate system. - List getRectsInSelection(Selection selection); - - /// Returns [Position] for the offset in current widget. - /// - /// [start] is the offset of the global coordination system. - Position getPositionInOffset(Offset start); - - /// Returns [Rect] for the position in current widget. - /// - /// The return result must be an offset of the local coordinate system. - Rect? getCursorRectInPosition(Position position) { - return null; - } - - /// Return global offset from local offset. - Offset localToGlobal(Offset offset); - - Position start(); - Position end(); - - /// For [TextNode] only. - /// - /// Only the widget rendered by [TextNode] need to implement the detail, - /// and the rest can return null. - TextSelection? getTextSelectionInSelection(Selection selection) => null; - - /// For [TextNode] only. - /// - /// Only the widget rendered by [TextNode] need to implement the detail, - /// and the rest can return null. - Selection? getWorldBoundaryInOffset(Offset start) { - return null; - } - - bool get shouldCursorBlink => true; - - CursorStyle get cursorStyle => CursorStyle.verticalLine; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selection_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selection_widget.dart deleted file mode 100644 index e3dea7af3452c..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selection_widget.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; - -class SelectionWidget extends StatefulWidget { - const SelectionWidget({ - Key? key, - required this.layerLink, - required this.rect, - required this.color, - }) : super(key: key); - - final Color color; - final Rect rect; - final LayerLink layerLink; - - @override - State createState() => _SelectionWidgetState(); -} - -class _SelectionWidgetState extends State { - @override - Widget build(BuildContext context) { - return Positioned.fromRect( - rect: widget.rect, - child: CompositedTransformFollower( - link: widget.layerLink, - offset: widget.rect.topLeft, - showWhenUnlinked: true, - // Ignore the gestures in selection overlays - // to solve the problem that selection areas cannot overlap. - child: IgnorePointer( - child: Container( - color: widget.color, - ), - ), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_item_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_item_widget.dart deleted file mode 100644 index 912d9447ff3b1..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_item_widget.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart'; -import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; -import 'package:flutter/material.dart'; - -class SelectionMenuItemWidget extends StatefulWidget { - const SelectionMenuItemWidget({ - Key? key, - required this.editorState, - required this.menuService, - required this.item, - required this.isSelected, - this.width = 140.0, - }) : super(key: key); - - final EditorState editorState; - final SelectionMenuService menuService; - final SelectionMenuItem item; - final double width; - final bool isSelected; - - @override - State createState() => - _SelectionMenuItemWidgetState(); -} - -class _SelectionMenuItemWidgetState extends State { - var _onHover = false; - - @override - Widget build(BuildContext context) { - final editorStyle = widget.editorState.editorStyle; - return Container( - padding: const EdgeInsets.fromLTRB(8.0, 5.0, 8.0, 5.0), - child: SizedBox( - width: widget.width, - child: TextButton.icon( - icon: widget.item - .icon(widget.editorState, widget.isSelected || _onHover), - style: ButtonStyle( - alignment: Alignment.centerLeft, - overlayColor: MaterialStateProperty.all( - editorStyle.selectionMenuItemSelectedColor), - backgroundColor: widget.isSelected - ? MaterialStateProperty.all( - editorStyle.selectionMenuItemSelectedColor) - : MaterialStateProperty.all(Colors.transparent), - ), - label: Text( - widget.item.name(), - textAlign: TextAlign.left, - style: TextStyle( - color: (widget.isSelected || _onHover) - ? editorStyle.selectionMenuItemSelectedTextColor - : editorStyle.selectionMenuItemTextColor, - fontSize: 12.0, - ), - ), - onPressed: () { - widget.item - .handler(widget.editorState, widget.menuService, context); - }, - onHover: (value) { - setState(() { - _onHover = value; - }); - }, - ), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart deleted file mode 100644 index 402e7cee84635..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/infra/flowy_svg.dart'; -import 'package:appflowy_editor/src/l10n/l10n.dart'; -import 'package:appflowy_editor/src/render/image/image_upload_widget.dart'; -import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; -import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart'; - -import 'package:flutter/material.dart'; -import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart'; - -abstract class SelectionMenuService { - Offset get topLeft; - - void show(); - void dismiss(); -} - -class SelectionMenu implements SelectionMenuService { - SelectionMenu({ - required this.context, - required this.editorState, - }); - - final BuildContext context; - final EditorState editorState; - - OverlayEntry? _selectionMenuEntry; - bool _selectionUpdateByInner = false; - Offset? _topLeft; - - @override - void dismiss() { - if (_selectionMenuEntry != null) { - editorState.service.keyboardService?.enable(); - editorState.service.scrollService?.enable(); - } - - _selectionMenuEntry?.remove(); - _selectionMenuEntry = null; - - // workaround: SelectionService has been released after hot reload. - final isSelectionDisposed = - editorState.service.selectionServiceKey.currentState == null; - if (!isSelectionDisposed) { - final selectionService = editorState.service.selectionService; - selectionService.currentSelection.removeListener(_onSelectionChange); - } - } - - @override - void show() { - dismiss(); - - final selectionService = editorState.service.selectionService; - final selectionRects = selectionService.selectionRects; - if (selectionRects.isEmpty) { - return; - } - // Workaround: We can customize the padding through the [EditorStyle], - // but the coordinates of overlay are not properly converted currently. - // Just subtract the padding here as a result. - const menuHeight = 200.0; - const menuOffset = Offset(0, 10); - final editorOffset = - editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; - final editorHeight = editorState.renderBox!.size.height; - - // show below defualt - var showBelow = true; - final bottomRight = selectionRects.first.bottomRight; - final topRight = selectionRects.first.topRight; - var offset = bottomRight + menuOffset; - // overflow - if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) { - // show above - offset = topRight - menuOffset; - showBelow = false; - } - _topLeft = offset; - - _selectionMenuEntry = OverlayEntry(builder: (context) { - return Positioned( - top: showBelow ? offset.dy : null, - bottom: - showBelow ? null : MediaQuery.of(context).size.height - offset.dy, - left: offset.dx, - child: SelectionMenuWidget( - items: [ - ..._defaultSelectionMenuItems, - ...editorState.selectionMenuItems, - ], - maxItemInRow: 5, - editorState: editorState, - menuService: this, - onExit: () { - dismiss(); - }, - onSelectionUpdate: () { - _selectionUpdateByInner = true; - }, - ), - ); - }); - - Overlay.of(context)?.insert(_selectionMenuEntry!); - - editorState.service.keyboardService?.disable(); - editorState.service.scrollService?.disable(); - selectionService.currentSelection.addListener(_onSelectionChange); - } - - @override - Offset get topLeft { - return _topLeft ?? Offset.zero; - } - - void _onSelectionChange() { - // workaround: SelectionService has been released after hot reload. - final isSelectionDisposed = - editorState.service.selectionServiceKey.currentState == null; - if (!isSelectionDisposed) { - final selectionService = editorState.service.selectionService; - if (selectionService.currentSelection.value == null) { - return; - } - } - - if (_selectionUpdateByInner) { - _selectionUpdateByInner = false; - return; - } - - dismiss(); - } -} - -@visibleForTesting -List get defaultSelectionMenuItems => - _defaultSelectionMenuItems; -final List _defaultSelectionMenuItems = [ - SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.text, - icon: (editorState, onSelected) => - _selectionMenuIcon('text', editorState, onSelected), - keywords: ['text'], - handler: (editorState, _, __) { - insertTextNodeAfterSelection(editorState, {}); - }, - ), - SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.heading1, - icon: (editorState, onSelected) => - _selectionMenuIcon('h1', editorState, onSelected), - keywords: ['heading 1, h1'], - handler: (editorState, _, __) { - insertHeadingAfterSelection(editorState, BuiltInAttributeKey.h1); - }, - ), - SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.heading2, - icon: (editorState, onSelected) => - _selectionMenuIcon('h2', editorState, onSelected), - keywords: ['heading 2, h2'], - handler: (editorState, _, __) { - insertHeadingAfterSelection(editorState, BuiltInAttributeKey.h2); - }, - ), - SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.heading3, - icon: (editorState, onSelected) => - _selectionMenuIcon('h3', editorState, onSelected), - keywords: ['heading 3, h3'], - handler: (editorState, _, __) { - insertHeadingAfterSelection(editorState, BuiltInAttributeKey.h3); - }, - ), - SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.image, - icon: (editorState, onSelected) => - _selectionMenuIcon('image', editorState, onSelected), - keywords: ['image'], - handler: showImageUploadMenu, - ), - SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.bulletedList, - icon: (editorState, onSelected) => - _selectionMenuIcon('bulleted_list', editorState, onSelected), - keywords: ['bulleted list', 'list', 'unordered list'], - handler: (editorState, _, __) { - insertBulletedListAfterSelection(editorState); - }, - ), - SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.numberedList, - icon: (editorState, onSelected) => - _selectionMenuIcon('number', editorState, onSelected), - keywords: ['numbered list', 'list', 'ordered list'], - handler: (editorState, _, __) { - insertNumberedListAfterSelection(editorState); - }, - ), - SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.checkbox, - icon: (editorState, onSelected) => - _selectionMenuIcon('checkbox', editorState, onSelected), - keywords: ['todo list', 'list', 'checkbox list'], - handler: (editorState, _, __) { - insertCheckboxAfterSelection(editorState); - }, - ), - SelectionMenuItem( - name: () => AppFlowyEditorLocalizations.current.quote, - icon: (editorState, onSelected) => - _selectionMenuIcon('quote', editorState, onSelected), - keywords: ['quote', 'refer'], - handler: (editorState, _, __) { - insertQuoteAfterSelection(editorState); - }, - ), -]; - -Widget _selectionMenuIcon( - String name, EditorState editorState, bool onSelected) { - return FlowySvg( - name: 'selection_menu/$name', - color: onSelected - ? editorState.editorStyle.selectionMenuItemSelectedIconColor - : editorState.editorStyle.selectionMenuItemIconColor, - width: 18.0, - height: 18.0, - ); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart deleted file mode 100644 index 2007c172f5d45..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart +++ /dev/null @@ -1,307 +0,0 @@ -import 'dart:math'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart'; -import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -typedef SelectionMenuItemHandler = void Function( - EditorState editorState, - SelectionMenuService menuService, - BuildContext context, -); - -/// Selection Menu Item -class SelectionMenuItem { - SelectionMenuItem({ - required this.name, - required this.icon, - required this.keywords, - required SelectionMenuItemHandler handler, - }) { - this.handler = (editorState, menuService, context) { - _deleteToSlash(editorState); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - handler(editorState, menuService, context); - }); - }; - } - - final String Function() name; - final Widget Function(EditorState editorState, bool onSelected) icon; - - /// Customizes keywords for item. - /// - /// The keywords are used to quickly retrieve items. - final List keywords; - late final SelectionMenuItemHandler handler; - - void _deleteToSlash(EditorState editorState) { - final selectionService = editorState.service.selectionService; - final selection = selectionService.currentSelection.value; - final nodes = selectionService.currentSelectedNodes; - if (selection != null && nodes.length == 1) { - final node = nodes.first as TextNode; - final end = selection.start.offset; - final start = node.toPlainText().substring(0, end).lastIndexOf('/'); - final transaction = editorState.transaction - ..deleteText( - node, - start, - selection.start.offset - start, - ); - editorState.apply(transaction); - } - } -} - -class SelectionMenuWidget extends StatefulWidget { - const SelectionMenuWidget({ - Key? key, - required this.items, - required this.maxItemInRow, - required this.editorState, - required this.menuService, - required this.onExit, - required this.onSelectionUpdate, - }) : super(key: key); - - final List items; - final int maxItemInRow; - - final SelectionMenuService menuService; - final EditorState editorState; - - final VoidCallback onSelectionUpdate; - final VoidCallback onExit; - - @override - State createState() => _SelectionMenuWidgetState(); -} - -class _SelectionMenuWidgetState extends State { - final _focusNode = FocusNode(debugLabel: 'popup_list_widget'); - - int _selectedIndex = 0; - List _showingItems = []; - - String _keyword = ''; - String get keyword => _keyword; - set keyword(String newKeyword) { - _keyword = newKeyword; - - // Search items according to the keyword, and calculate the length of - // the longest keyword, which is used to dismiss the selection_service. - var maxKeywordLength = 0; - final items = widget.items - .where( - (item) => item.keywords.any((keyword) { - final value = keyword.contains(newKeyword); - if (value) { - maxKeywordLength = max(maxKeywordLength, keyword.length); - } - return value; - }), - ) - .toList(growable: false); - - Log.ui.debug('$items'); - - if (keyword.length >= maxKeywordLength + 2) { - widget.onExit(); - } else { - setState(() { - _showingItems = items; - }); - } - } - - @override - void initState() { - super.initState(); - - _showingItems = widget.items; - - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); - } - - @override - void dispose() { - _focusNode.dispose(); - - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Focus( - focusNode: _focusNode, - onKey: _onKey, - child: Container( - decoration: BoxDecoration( - color: widget.editorState.editorStyle.selectionMenuBackgroundColor, - boxShadow: [ - BoxShadow( - blurRadius: 5, - spreadRadius: 1, - color: Colors.black.withOpacity(0.1), - ), - ], - borderRadius: BorderRadius.circular(6.0), - ), - child: _showingItems.isEmpty - ? _buildNoResultsWidget(context) - : _buildResultsWidget( - context, - _showingItems, - _selectedIndex, - ), - ), - ); - } - - Widget _buildResultsWidget( - BuildContext buildContext, - List items, - int selectedIndex, - ) { - List columns = []; - List itemWidgets = []; - for (var i = 0; i < items.length; i++) { - if (i != 0 && i % (widget.maxItemInRow) == 0) { - columns.add(Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: itemWidgets, - )); - itemWidgets = []; - } - itemWidgets.add(SelectionMenuItemWidget( - item: items[i], - isSelected: selectedIndex == i, - editorState: widget.editorState, - menuService: widget.menuService, - )); - } - if (itemWidgets.isNotEmpty) { - columns.add(Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: itemWidgets, - )); - itemWidgets = []; - } - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: columns, - ); - } - - Widget _buildNoResultsWidget(BuildContext context) { - return const Align( - alignment: Alignment.centerLeft, - child: Material( - child: Padding( - padding: EdgeInsets.all(12.0), - child: Text( - 'No results', - style: TextStyle(color: Colors.grey), - ), - ), - ), - ); - } - - /// Handles arrow keys to switch selected items - /// Handles keyword searches - /// Handles enter to select item and esc to exit - KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { - Log.keyboard.debug('slash command, on key $event'); - if (event is! RawKeyDownEvent) { - return KeyEventResult.ignored; - } - - final arrowKeys = [ - LogicalKeyboardKey.arrowLeft, - LogicalKeyboardKey.arrowRight, - LogicalKeyboardKey.arrowUp, - LogicalKeyboardKey.arrowDown - ]; - - if (event.logicalKey == LogicalKeyboardKey.enter) { - if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) { - _showingItems[_selectedIndex] - .handler(widget.editorState, widget.menuService, context); - return KeyEventResult.handled; - } - } else if (event.logicalKey == LogicalKeyboardKey.escape) { - widget.onExit(); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.backspace) { - if (keyword.isEmpty) { - widget.onExit(); - } else { - keyword = keyword.substring(0, keyword.length - 1); - } - _deleteLastCharacters(); - return KeyEventResult.handled; - } else if (event.character != null && - !arrowKeys.contains(event.logicalKey)) { - keyword += event.character!; - _insertText(event.character!); - return KeyEventResult.handled; - } - - var newSelectedIndex = _selectedIndex; - if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - newSelectedIndex -= widget.maxItemInRow; - } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - newSelectedIndex += widget.maxItemInRow; - } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - newSelectedIndex -= 1; - } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - newSelectedIndex += 1; - } - if (newSelectedIndex != _selectedIndex) { - setState(() { - _selectedIndex = newSelectedIndex.clamp(0, _showingItems.length - 1); - }); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - - void _deleteLastCharacters({int length = 1}) { - final selectionService = widget.editorState.service.selectionService; - final selection = selectionService.currentSelection.value; - final nodes = selectionService.currentSelectedNodes; - if (selection != null && nodes.length == 1) { - widget.onSelectionUpdate(); - final transaction = widget.editorState.transaction - ..deleteText( - nodes.first as TextNode, - selection.start.offset - length, - length, - ); - widget.editorState.apply(transaction); - } - } - - void _insertText(String text) { - final selection = - widget.editorState.service.selectionService.currentSelection.value; - final nodes = - widget.editorState.service.selectionService.currentSelectedNodes; - if (selection != null && nodes.length == 1) { - widget.onSelectionUpdate(); - final transaction = widget.editorState.transaction - ..insertText( - nodes.first as TextNode, - selection.end.offset, - text, - ); - widget.editorState.apply(transaction); - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart deleted file mode 100644 index 30e4465bf8d76..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart +++ /dev/null @@ -1,199 +0,0 @@ -import 'package:flutter/material.dart'; - -Iterable> get lightEditorStyleExtension => [ - EditorStyle.light, - ]; - -Iterable> get darkEditorStyleExtension => [ - EditorStyle.dark, - ]; - -class EditorStyle extends ThemeExtension { - // Editor styles - final EdgeInsets? padding; - final Color? cursorColor; - final Color? selectionColor; - - // Selection menu styles - final Color? selectionMenuBackgroundColor; - final Color? selectionMenuItemTextColor; - final Color? selectionMenuItemIconColor; - final Color? selectionMenuItemSelectedTextColor; - final Color? selectionMenuItemSelectedIconColor; - final Color? selectionMenuItemSelectedColor; - - // Text styles - final EdgeInsets? textPadding; - final TextStyle? textStyle; - final TextStyle? placeholderTextStyle; - final double lineHeight; - - // Rich text styles - final TextStyle? bold; - final TextStyle? italic; - final TextStyle? underline; - final TextStyle? strikethrough; - final TextStyle? href; - final TextStyle? code; - final String? highlightColorHex; - - EditorStyle({ - required this.padding, - required this.cursorColor, - required this.selectionColor, - required this.selectionMenuBackgroundColor, - required this.selectionMenuItemTextColor, - required this.selectionMenuItemIconColor, - required this.selectionMenuItemSelectedTextColor, - required this.selectionMenuItemSelectedIconColor, - required this.selectionMenuItemSelectedColor, - required this.textPadding, - required this.textStyle, - required this.placeholderTextStyle, - required this.bold, - required this.italic, - required this.underline, - required this.strikethrough, - required this.href, - required this.code, - required this.highlightColorHex, - required this.lineHeight, - }); - - @override - EditorStyle copyWith({ - EdgeInsets? padding, - Color? cursorColor, - Color? selectionColor, - Color? selectionMenuBackgroundColor, - Color? selectionMenuItemTextColor, - Color? selectionMenuItemIconColor, - Color? selectionMenuItemSelectedTextColor, - Color? selectionMenuItemSelectedIconColor, - Color? selectionMenuItemSelectedColor, - TextStyle? textStyle, - TextStyle? placeholderTextStyle, - TextStyle? bold, - TextStyle? italic, - TextStyle? underline, - TextStyle? strikethrough, - TextStyle? href, - TextStyle? code, - String? highlightColorHex, - double? lineHeight, - }) { - return EditorStyle( - padding: padding ?? this.padding, - cursorColor: cursorColor ?? this.cursorColor, - selectionColor: selectionColor ?? this.selectionColor, - selectionMenuBackgroundColor: - selectionMenuBackgroundColor ?? this.selectionMenuBackgroundColor, - selectionMenuItemTextColor: - selectionMenuItemTextColor ?? this.selectionMenuItemTextColor, - selectionMenuItemIconColor: - selectionMenuItemIconColor ?? this.selectionMenuItemIconColor, - selectionMenuItemSelectedTextColor: selectionMenuItemSelectedTextColor ?? - this.selectionMenuItemSelectedTextColor, - selectionMenuItemSelectedIconColor: selectionMenuItemSelectedIconColor ?? - this.selectionMenuItemSelectedIconColor, - selectionMenuItemSelectedColor: - selectionMenuItemSelectedColor ?? this.selectionMenuItemSelectedColor, - textPadding: textPadding ?? textPadding, - textStyle: textStyle ?? this.textStyle, - placeholderTextStyle: placeholderTextStyle ?? this.placeholderTextStyle, - bold: bold ?? this.bold, - italic: italic ?? this.italic, - underline: underline ?? this.underline, - strikethrough: strikethrough ?? this.strikethrough, - href: href ?? this.href, - code: code ?? this.code, - highlightColorHex: highlightColorHex ?? this.highlightColorHex, - lineHeight: lineHeight ?? this.lineHeight, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, double t) { - if (other == null || other is! EditorStyle) { - return this; - } - return EditorStyle( - padding: EdgeInsets.lerp(padding, other.padding, t), - cursorColor: Color.lerp(cursorColor, other.cursorColor, t), - textPadding: EdgeInsets.lerp(textPadding, other.textPadding, t), - selectionColor: Color.lerp(selectionColor, other.selectionColor, t), - selectionMenuBackgroundColor: Color.lerp( - selectionMenuBackgroundColor, other.selectionMenuBackgroundColor, t), - selectionMenuItemTextColor: Color.lerp( - selectionMenuItemTextColor, other.selectionMenuItemTextColor, t), - selectionMenuItemIconColor: Color.lerp( - selectionMenuItemIconColor, other.selectionMenuItemIconColor, t), - selectionMenuItemSelectedTextColor: Color.lerp( - selectionMenuItemSelectedTextColor, - other.selectionMenuItemSelectedTextColor, - t), - selectionMenuItemSelectedIconColor: Color.lerp( - selectionMenuItemSelectedIconColor, - other.selectionMenuItemSelectedIconColor, - t), - selectionMenuItemSelectedColor: Color.lerp(selectionMenuItemSelectedColor, - other.selectionMenuItemSelectedColor, t), - textStyle: TextStyle.lerp(textStyle, other.textStyle, t), - placeholderTextStyle: - TextStyle.lerp(placeholderTextStyle, other.placeholderTextStyle, t), - bold: TextStyle.lerp(bold, other.bold, t), - italic: TextStyle.lerp(italic, other.italic, t), - underline: TextStyle.lerp(underline, other.underline, t), - strikethrough: TextStyle.lerp(strikethrough, other.strikethrough, t), - href: TextStyle.lerp(href, other.href, t), - code: TextStyle.lerp(code, other.code, t), - highlightColorHex: highlightColorHex, - lineHeight: lineHeight, - ); - } - - static final light = EditorStyle( - padding: const EdgeInsets.fromLTRB(200.0, 0.0, 200.0, 0.0), - cursorColor: const Color(0xFF00BCF0), - selectionColor: const Color.fromARGB(53, 111, 201, 231), - selectionMenuBackgroundColor: const Color(0xFFFFFFFF), - selectionMenuItemTextColor: const Color(0xFF333333), - selectionMenuItemIconColor: const Color(0xFF333333), - selectionMenuItemSelectedTextColor: const Color(0xFF333333), - selectionMenuItemSelectedIconColor: const Color(0xFF333333), - selectionMenuItemSelectedColor: const Color(0xFFE0F8FF), - textPadding: const EdgeInsets.symmetric(vertical: 8.0), - textStyle: const TextStyle(fontSize: 16.0, color: Colors.black), - placeholderTextStyle: const TextStyle(fontSize: 16.0, color: Colors.grey), - bold: const TextStyle(fontWeight: FontWeight.bold), - italic: const TextStyle(fontStyle: FontStyle.italic), - underline: const TextStyle(decoration: TextDecoration.underline), - strikethrough: const TextStyle(decoration: TextDecoration.lineThrough), - href: const TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - ), - code: const TextStyle( - fontFamily: 'monospace', - color: Color(0xFF00BCF0), - backgroundColor: Color(0xFFE0F8FF), - ), - highlightColorHex: '0x6000BCF0', - lineHeight: 1.5, - ); - - static final dark = light.copyWith( - textStyle: const TextStyle(fontSize: 16.0, color: Colors.white), - placeholderTextStyle: TextStyle( - fontSize: 16.0, - color: Colors.white.withOpacity(0.3), - ), - selectionMenuBackgroundColor: const Color(0xFF282E3A), - selectionMenuItemTextColor: const Color(0xFFBBC3CD), - selectionMenuItemIconColor: const Color(0xFFBBC3CD), - selectionMenuItemSelectedTextColor: const Color(0xFF131720), - selectionMenuItemSelectedIconColor: const Color(0xFF131720), - selectionMenuItemSelectedColor: const Color(0xFF00BCF0), - ); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/plugin_styles.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/plugin_styles.dart deleted file mode 100644 index 44fabea57370a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/plugin_styles.dart +++ /dev/null @@ -1,333 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/infra/flowy_svg.dart'; -import 'package:flutter/material.dart'; - -Iterable> get lightPlguinStyleExtension => [ - HeadingPluginStyle.light, - CheckboxPluginStyle.light, - NumberListPluginStyle.light, - QuotedTextPluginStyle.light, - ]; - -Iterable> get darkPlguinStyleExtension => [ - HeadingPluginStyle.dark, - CheckboxPluginStyle.dark, - NumberListPluginStyle.dark, - QuotedTextPluginStyle.dark, - BulletedListPluginStyle.dark, - ]; - -typedef TextStyleCustomizer = TextStyle Function( - EditorState editorState, TextNode textNode); -typedef PaddingCustomizer = EdgeInsets Function( - EditorState editorState, TextNode textNode); -typedef IconCustomizer = Widget Function( - EditorState editorState, TextNode textNode); - -class HeadingPluginStyle extends ThemeExtension { - const HeadingPluginStyle({ - required this.textStyle, - required this.padding, - }); - - final TextStyleCustomizer textStyle; - final PaddingCustomizer padding; - - @override - HeadingPluginStyle copyWith({ - TextStyleCustomizer? textStyle, - PaddingCustomizer? padding, - }) { - return HeadingPluginStyle( - textStyle: textStyle ?? this.textStyle, - padding: padding ?? this.padding, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, double t) { - if (other is! HeadingPluginStyle) { - return this; - } - return HeadingPluginStyle( - textStyle: other.textStyle, - padding: other.padding, - ); - } - - static final light = HeadingPluginStyle( - padding: (_, __) => const EdgeInsets.symmetric(vertical: 8.0), - textStyle: (editorState, textNode) { - final headingToFontSize = { - 'h1': 32.0, - 'h2': 28.0, - 'h3': 24.0, - 'h4': 18.0, - 'h5': 18.0, - 'h6': 18.0, - }; - final fontSize = headingToFontSize[textNode.attributes.heading] ?? 18.0; - return TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.bold, - ); - }, - ); - - static final dark = light; -} - -class CheckboxPluginStyle extends ThemeExtension { - const CheckboxPluginStyle({ - required this.textStyle, - required this.padding, - required this.icon, - }); - - final TextStyleCustomizer textStyle; - final PaddingCustomizer padding; - final IconCustomizer icon; - - @override - CheckboxPluginStyle copyWith({ - TextStyleCustomizer? textStyle, - PaddingCustomizer? padding, - IconCustomizer? icon, - }) { - return CheckboxPluginStyle( - textStyle: textStyle ?? this.textStyle, - padding: padding ?? this.padding, - icon: icon ?? this.icon, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, double t) { - if (other is! CheckboxPluginStyle) { - return this; - } - return CheckboxPluginStyle( - textStyle: other.textStyle, - padding: other.padding, - icon: other.icon, - ); - } - - static final light = CheckboxPluginStyle( - padding: (_, __) => const EdgeInsets.symmetric(vertical: 8.0), - textStyle: (editorState, textNode) { - final isCheck = textNode.attributes.check; - return TextStyle( - decoration: isCheck ? TextDecoration.lineThrough : null, - color: isCheck ? Colors.grey.shade400 : null, - ); - }, - icon: (editorState, textNode) { - final isCheck = textNode.attributes.check; - const iconSize = Size.square(20.0); - const iconPadding = EdgeInsets.only(right: 5.0); - return FlowySvg( - width: iconSize.width, - height: iconSize.height, - padding: iconPadding, - name: isCheck ? 'check' : 'uncheck', - ); - }, - ); - - static final dark = light; -} - -class BulletedListPluginStyle extends ThemeExtension { - const BulletedListPluginStyle({ - required this.textStyle, - required this.padding, - required this.icon, - }); - - final TextStyleCustomizer textStyle; - final PaddingCustomizer padding; - final IconCustomizer icon; - - @override - BulletedListPluginStyle copyWith({ - TextStyleCustomizer? textStyle, - PaddingCustomizer? padding, - IconCustomizer? icon, - }) { - return BulletedListPluginStyle( - textStyle: textStyle ?? this.textStyle, - padding: padding ?? this.padding, - icon: icon ?? this.icon, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, double t) { - if (other is! BulletedListPluginStyle) { - return this; - } - return BulletedListPluginStyle( - textStyle: other.textStyle, - padding: other.padding, - icon: other.icon, - ); - } - - static final light = BulletedListPluginStyle( - padding: (_, __) => const EdgeInsets.symmetric(vertical: 8.0), - textStyle: (_, __) => const TextStyle(), - icon: (_, __) { - const iconSize = Size.square(20.0); - const iconPadding = EdgeInsets.only(right: 5.0); - return FlowySvg( - width: iconSize.width, - height: iconSize.height, - padding: iconPadding, - color: Colors.black, - name: 'point', - ); - }, - ); - - static final dark = light.copyWith(icon: (_, __) { - const iconSize = Size.square(20.0); - const iconPadding = EdgeInsets.only(right: 5.0); - return FlowySvg( - width: iconSize.width, - height: iconSize.height, - padding: iconPadding, - color: Colors.white, - name: 'point', - ); - }); -} - -class NumberListPluginStyle extends ThemeExtension { - const NumberListPluginStyle({ - required this.textStyle, - required this.padding, - required this.icon, - }); - - final TextStyleCustomizer textStyle; - final PaddingCustomizer padding; - final IconCustomizer icon; - - @override - NumberListPluginStyle copyWith({ - TextStyleCustomizer? textStyle, - PaddingCustomizer? padding, - IconCustomizer? icon, - }) { - return NumberListPluginStyle( - textStyle: textStyle ?? this.textStyle, - padding: padding ?? this.padding, - icon: icon ?? this.icon, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, - double t, - ) { - if (other is! NumberListPluginStyle) { - return this; - } - return NumberListPluginStyle( - textStyle: other.textStyle, - padding: other.padding, - icon: other.icon, - ); - } - - static final light = NumberListPluginStyle( - padding: (_, __) => const EdgeInsets.symmetric(vertical: 8.0), - textStyle: (_, __) => const TextStyle(), - icon: (_, textNode) { - const iconPadding = EdgeInsets.only(left: 5.0, right: 5.0); - return Container( - padding: iconPadding, - child: Text( - '${textNode.attributes.number.toString()}.', - style: const TextStyle( - fontSize: 16, - color: Colors.black, - ), - ), - ); - }, - ); - - static final dark = light.copyWith(icon: (editorState, textNode) { - const iconPadding = EdgeInsets.only(left: 5.0, right: 5.0); - return Container( - padding: iconPadding, - child: Text( - '${textNode.attributes.number.toString()}.', - style: const TextStyle( - fontSize: 16, - color: Colors.white, - ), - ), - ); - }); -} - -class QuotedTextPluginStyle extends ThemeExtension { - const QuotedTextPluginStyle({ - required this.textStyle, - required this.padding, - required this.icon, - }); - - final TextStyleCustomizer textStyle; - final PaddingCustomizer padding; - final IconCustomizer icon; - - @override - QuotedTextPluginStyle copyWith({ - TextStyleCustomizer? textStyle, - PaddingCustomizer? padding, - IconCustomizer? icon, - }) { - return QuotedTextPluginStyle( - textStyle: textStyle ?? this.textStyle, - padding: padding ?? this.padding, - icon: icon ?? this.icon, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, double t) { - if (other is! QuotedTextPluginStyle) { - return this; - } - return QuotedTextPluginStyle( - textStyle: other.textStyle, - padding: other.padding, - icon: other.icon, - ); - } - - static final light = QuotedTextPluginStyle( - padding: (_, __) => const EdgeInsets.symmetric(vertical: 8.0), - textStyle: (_, __) => const TextStyle(), - icon: (_, __) { - const iconSize = Size.square(20.0); - const iconPadding = EdgeInsets.only(right: 5.0); - return FlowySvg( - width: iconSize.width, - padding: iconPadding, - name: 'quote', - ); - }, - ); - - static final dark = light; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart deleted file mode 100644 index 5ab7f6cc50041..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart +++ /dev/null @@ -1,419 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/commands/text/text_commands.dart'; -import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart'; -import 'package:appflowy_editor/src/flutter/overlay.dart'; -import 'package:appflowy_editor/src/infra/flowy_svg.dart'; -import 'package:appflowy_editor/src/render/link_menu/link_menu.dart'; -import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; -import 'package:appflowy_editor/src/extensions/editor_state_extensions.dart'; -import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart'; - -import 'package:flutter/material.dart' hide Overlay, OverlayEntry; -import 'package:rich_clipboard/rich_clipboard.dart'; - -typedef ToolbarItemEventHandler = void Function( - EditorState editorState, BuildContext context); -typedef ToolbarItemValidator = bool Function(EditorState editorState); -typedef ToolbarItemHighlightCallback = bool Function(EditorState editorState); - -class ToolbarItem { - ToolbarItem({ - required this.id, - required this.type, - required this.iconBuilder, - this.tooltipsMessage = '', - required this.validator, - required this.highlightCallback, - required this.handler, - }); - - final String id; - final int type; - final Widget Function(bool isHighlight) iconBuilder; - final String tooltipsMessage; - final ToolbarItemValidator validator; - final ToolbarItemEventHandler handler; - final ToolbarItemHighlightCallback highlightCallback; - - factory ToolbarItem.divider() { - return ToolbarItem( - id: 'divider', - type: -1, - iconBuilder: (_) => const FlowySvg(name: 'toolbar/divider'), - validator: (editorState) => true, - handler: (editorState, context) {}, - highlightCallback: (editorState) => false, - ); - } - - @override - bool operator ==(Object other) { - if (other is! ToolbarItem) { - return false; - } - if (identical(this, other)) { - return true; - } - return id == other.id; - } - - @override - int get hashCode => id.hashCode; -} - -List defaultToolbarItems = [ - ToolbarItem( - id: 'appflowy.toolbar.h1', - type: 1, - tooltipsMessage: AppFlowyEditorLocalizations.current.heading1, - iconBuilder: (isHighlight) => FlowySvg( - name: 'toolbar/h1', - color: isHighlight ? Colors.lightBlue : null, - ), - validator: _onlyShowInSingleTextSelection, - highlightCallback: (editorState) => _allSatisfy( - editorState, - BuiltInAttributeKey.heading, - (value) => value == BuiltInAttributeKey.h1, - ), - handler: (editorState, context) => - formatHeading(editorState, BuiltInAttributeKey.h1), - ), - ToolbarItem( - id: 'appflowy.toolbar.h2', - type: 1, - tooltipsMessage: AppFlowyEditorLocalizations.current.heading2, - iconBuilder: (isHighlight) => FlowySvg( - name: 'toolbar/h2', - color: isHighlight ? Colors.lightBlue : null, - ), - validator: _onlyShowInSingleTextSelection, - highlightCallback: (editorState) => _allSatisfy( - editorState, - BuiltInAttributeKey.heading, - (value) => value == BuiltInAttributeKey.h2, - ), - handler: (editorState, context) => - formatHeading(editorState, BuiltInAttributeKey.h2), - ), - ToolbarItem( - id: 'appflowy.toolbar.h3', - type: 1, - tooltipsMessage: AppFlowyEditorLocalizations.current.heading3, - iconBuilder: (isHighlight) => FlowySvg( - name: 'toolbar/h3', - color: isHighlight ? Colors.lightBlue : null, - ), - validator: _onlyShowInSingleTextSelection, - highlightCallback: (editorState) => _allSatisfy( - editorState, - BuiltInAttributeKey.heading, - (value) => value == BuiltInAttributeKey.h3, - ), - handler: (editorState, context) => - formatHeading(editorState, BuiltInAttributeKey.h3), - ), - ToolbarItem( - id: 'appflowy.toolbar.bold', - type: 2, - tooltipsMessage: AppFlowyEditorLocalizations.current.bold, - iconBuilder: (isHighlight) => FlowySvg( - name: 'toolbar/bold', - color: isHighlight ? Colors.lightBlue : null, - ), - validator: _showInBuiltInTextSelection, - highlightCallback: (editorState) => _allSatisfy( - editorState, - BuiltInAttributeKey.bold, - (value) => value == true, - ), - handler: (editorState, context) => formatBold(editorState), - ), - ToolbarItem( - id: 'appflowy.toolbar.italic', - type: 2, - tooltipsMessage: AppFlowyEditorLocalizations.current.italic, - iconBuilder: (isHighlight) => FlowySvg( - name: 'toolbar/italic', - color: isHighlight ? Colors.lightBlue : null, - ), - validator: _showInBuiltInTextSelection, - highlightCallback: (editorState) => _allSatisfy( - editorState, - BuiltInAttributeKey.italic, - (value) => value == true, - ), - handler: (editorState, context) => formatItalic(editorState), - ), - ToolbarItem( - id: 'appflowy.toolbar.underline', - type: 2, - tooltipsMessage: AppFlowyEditorLocalizations.current.underline, - iconBuilder: (isHighlight) => FlowySvg( - name: 'toolbar/underline', - color: isHighlight ? Colors.lightBlue : null, - ), - validator: _showInBuiltInTextSelection, - highlightCallback: (editorState) => _allSatisfy( - editorState, - BuiltInAttributeKey.underline, - (value) => value == true, - ), - handler: (editorState, context) => formatUnderline(editorState), - ), - ToolbarItem( - id: 'appflowy.toolbar.strikethrough', - type: 2, - tooltipsMessage: AppFlowyEditorLocalizations.current.strikethrough, - iconBuilder: (isHighlight) => FlowySvg( - name: 'toolbar/strikethrough', - color: isHighlight ? Colors.lightBlue : null, - ), - validator: _showInBuiltInTextSelection, - highlightCallback: (editorState) => _allSatisfy( - editorState, - BuiltInAttributeKey.strikethrough, - (value) => value == true, - ), - handler: (editorState, context) => formatStrikethrough(editorState), - ), - ToolbarItem( - id: 'appflowy.toolbar.code', - type: 2, - tooltipsMessage: AppFlowyEditorLocalizations.current.embedCode, - iconBuilder: (isHighlight) => FlowySvg( - name: 'toolbar/code', - color: isHighlight ? Colors.lightBlue : null, - ), - validator: _showInBuiltInTextSelection, - highlightCallback: (editorState) => _allSatisfy( - editorState, - BuiltInAttributeKey.code, - (value) => value == true, - ), - handler: (editorState, context) => formatEmbedCode(editorState), - ), - ToolbarItem( - id: 'appflowy.toolbar.quote', - type: 3, - tooltipsMessage: AppFlowyEditorLocalizations.current.quote, - iconBuilder: (isHighlight) => FlowySvg( - name: 'toolbar/quote', - color: isHighlight ? Colors.lightBlue : null, - ), - validator: _onlyShowInSingleTextSelection, - highlightCallback: (editorState) => _allSatisfy( - editorState, - BuiltInAttributeKey.subtype, - (value) => value == BuiltInAttributeKey.quote, - ), - handler: (editorState, context) { - formatQuote(editorState); - }, - ), - ToolbarItem( - id: 'appflowy.toolbar.bulleted_list', - type: 3, - tooltipsMessage: AppFlowyEditorLocalizations.current.bulletedList, - iconBuilder: (isHighlight) => FlowySvg( - name: 'toolbar/bulleted_list', - color: isHighlight ? Colors.lightBlue : null, - ), - validator: _onlyShowInSingleTextSelection, - highlightCallback: (editorState) => _allSatisfy( - editorState, - BuiltInAttributeKey.subtype, - (value) => value == BuiltInAttributeKey.bulletedList, - ), - handler: (editorState, context) => formatBulletedList(editorState), - ), - ToolbarItem( - id: 'appflowy.toolbar.link', - type: 4, - tooltipsMessage: AppFlowyEditorLocalizations.current.link, - iconBuilder: (isHighlight) => FlowySvg( - name: 'toolbar/link', - color: isHighlight ? Colors.lightBlue : null, - ), - validator: _onlyShowInSingleTextSelection, - highlightCallback: (editorState) => _allSatisfy( - editorState, - BuiltInAttributeKey.href, - (value) => value != null, - ), - handler: (editorState, context) => showLinkMenu(context, editorState), - ), - ToolbarItem( - id: 'appflowy.toolbar.highlight', - type: 4, - tooltipsMessage: AppFlowyEditorLocalizations.current.highlight, - iconBuilder: (isHighlight) => FlowySvg( - name: 'toolbar/highlight', - color: isHighlight ? Colors.lightBlue : null, - ), - validator: _showInBuiltInTextSelection, - highlightCallback: (editorState) => _allSatisfy( - editorState, - BuiltInAttributeKey.backgroundColor, - (value) => value != null, - ), - handler: (editorState, context) => formatHighlight( - editorState, - editorState.editorStyle.highlightColorHex!, - ), - ), -]; - -ToolbarItemValidator _onlyShowInSingleTextSelection = (editorState) { - final result = _showInBuiltInTextSelection(editorState); - if (!result) { - return false; - } - final nodes = editorState.service.selectionService.currentSelectedNodes; - return (nodes.length == 1 && nodes.first is TextNode); -}; - -ToolbarItemValidator _showInBuiltInTextSelection = (editorState) { - final nodes = editorState.service.selectionService.currentSelectedNodes - .whereType() - .where( - (textNode) => - BuiltInAttributeKey.globalStyleKeys.contains(textNode.subtype) || - textNode.subtype == null, - ); - return nodes.isNotEmpty; -}; - -bool _allSatisfy( - EditorState editorState, - String styleKey, - bool Function(dynamic value) test, -) { - final selection = editorState.service.selectionService.currentSelection.value; - return selection != null && - editorState.selectedTextNodes.allSatisfyInSelection( - selection, - styleKey, - test, - ); -} - -OverlayEntry? _linkMenuOverlay; -EditorState? _editorState; -bool _changeSelectionInner = false; -void showLinkMenu( - BuildContext context, - EditorState editorState, { - Selection? customSelection, -}) { - final rects = editorState.service.selectionService.selectionRects; - var maxBottom = 0.0; - late Rect matchRect; - for (final rect in rects) { - if (rect.bottom > maxBottom) { - maxBottom = rect.bottom; - matchRect = rect; - } - } - final baseOffset = - editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; - matchRect = matchRect.shift(-baseOffset); - - _dismissLinkMenu(); - _editorState = editorState; - - // Since the link menu will only show in single text selection, - // We get the text node directly instead of judging details again. - final selection = customSelection ?? - editorState.service.selectionService.currentSelection.value; - final node = editorState.service.selectionService.currentSelectedNodes; - if (selection == null || node.isEmpty || node.first is! TextNode) { - return; - } - final index = - selection.isBackward ? selection.start.offset : selection.end.offset; - final length = (selection.start.offset - selection.end.offset).abs(); - final textNode = node.first as TextNode; - String? linkText; - if (textNode.allSatisfyLinkInSelection(selection)) { - linkText = textNode.getAttributeInSelection( - selection, - BuiltInAttributeKey.href, - ); - } - _linkMenuOverlay = OverlayEntry(builder: (context) { - return Positioned( - top: matchRect.bottom + 5.0, - left: matchRect.left, - child: Material( - child: LinkMenu( - linkText: linkText, - editorState: editorState, - onOpenLink: () async { - await safeLaunchUrl(linkText); - }, - onSubmitted: (text) async { - await editorState.formatLinkInText( - editorState, - text, - textNode: textNode, - ); - _dismissLinkMenu(); - }, - onCopyLink: () { - RichClipboard.setData(RichClipboardData(text: linkText)); - _dismissLinkMenu(); - }, - onRemoveLink: () { - final transaction = editorState.transaction - ..formatText( - textNode, - index, - length, - {BuiltInAttributeKey.href: null}, - ); - editorState.apply(transaction); - _dismissLinkMenu(); - }, - onFocusChange: (value) { - if (value && customSelection != null) { - _changeSelectionInner = true; - editorState.service.selectionService - .updateSelection(customSelection); - } - }, - ), - ), - ); - }); - Overlay.of(context)?.insert(_linkMenuOverlay!); - - editorState.service.scrollService?.disable(); - editorState.service.keyboardService?.disable(); - editorState.service.selectionService.currentSelection - .addListener(_dismissLinkMenu); -} - -void _dismissLinkMenu() { - // workaround: SelectionService has been released after hot reload. - final isSelectionDisposed = - _editorState?.service.selectionServiceKey.currentState == null; - if (isSelectionDisposed) { - return; - } - if (_editorState?.service.selectionService.currentSelection.value == null) { - return; - } - if (_changeSelectionInner) { - _changeSelectionInner = false; - return; - } - _linkMenuOverlay?.remove(); - _linkMenuOverlay = null; - - _editorState?.service.scrollService?.enable(); - _editorState?.service.keyboardService?.enable(); - _editorState?.service.selectionService.currentSelection - .removeListener(_dismissLinkMenu); - _editorState = null; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart deleted file mode 100644 index 4b6170620bc63..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'toolbar_item.dart'; - -class ToolbarItemWidget extends StatelessWidget { - const ToolbarItemWidget({ - Key? key, - required this.item, - required this.isHighlight, - required this.onPressed, - }) : super(key: key); - - final ToolbarItem item; - final VoidCallback onPressed; - final bool isHighlight; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 28, - height: 28, - child: Tooltip( - preferBelow: false, - message: item.tooltipsMessage, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: IconButton( - hoverColor: Colors.transparent, - highlightColor: Colors.transparent, - padding: EdgeInsets.zero, - icon: item.iconBuilder(isHighlight), - iconSize: 28, - onPressed: onPressed, - ), - ), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart deleted file mode 100644 index d987b2f87b826..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:appflowy_editor/src/flutter/overlay.dart'; -import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; -import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart'; -import 'package:flutter/material.dart' hide Overlay, OverlayEntry; - -import 'package:appflowy_editor/src/editor_state.dart'; - -mixin ToolbarMixin on State { - void hide(); -} - -class ToolbarWidget extends StatefulWidget { - const ToolbarWidget({ - Key? key, - required this.editorState, - required this.layerLink, - required this.offset, - required this.items, - }) : super(key: key); - - final EditorState editorState; - final LayerLink layerLink; - final Offset offset; - final List items; - - @override - State createState() => _ToolbarWidgetState(); -} - -class _ToolbarWidgetState extends State with ToolbarMixin { - OverlayEntry? _listToolbarOverlay; - - @override - Widget build(BuildContext context) { - return Positioned( - top: widget.offset.dx, - left: widget.offset.dy, - child: CompositedTransformFollower( - link: widget.layerLink, - showWhenUnlinked: true, - offset: widget.offset, - child: _buildToolbar(context), - ), - ); - } - - @override - void hide() { - _listToolbarOverlay?.remove(); - _listToolbarOverlay = null; - } - - Widget _buildToolbar(BuildContext context) { - return Material( - borderRadius: BorderRadius.circular(8.0), - color: const Color(0xFF333333), - child: Padding( - padding: const EdgeInsets.only(left: 8.0, right: 8.0), - child: SizedBox( - height: 32.0, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: widget.items - .map( - (item) => Center( - child: ToolbarItemWidget( - item: item, - isHighlight: item.highlightCallback(widget.editorState), - onPressed: () { - item.handler(widget.editorState, context); - widget.editorState.service.keyboardService?.enable(); - }, - ), - ), - ) - .toList(growable: false), - ), - ), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/context_menu/built_in_context_menu_item.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/context_menu/built_in_context_menu_item.dart deleted file mode 100644 index b850f78e9ae63..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/context_menu/built_in_context_menu_item.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:appflowy_editor/src/service/context_menu/context_menu.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart'; - -final builtInContextMenuItems = [ - [ - // cut - ContextMenuItem( - name: 'Cut', - onPressed: (editorState) { - cutEventHandler(editorState, null); - }, - ), - // copy - ContextMenuItem( - name: 'Copy', - onPressed: (editorState) { - copyEventHandler(editorState, null); - }, - ), - // Paste - ContextMenuItem( - name: 'Paste', - onPressed: (editorState) { - pasteEventHandler(editorState, null); - }, - ), - ], -]; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/context_menu/context_menu.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/context_menu/context_menu.dart deleted file mode 100644 index 92b43abdbbf2f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/context_menu/context_menu.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:flutter/material.dart'; - -class ContextMenuItem { - ContextMenuItem({ - required this.name, - required this.onPressed, - }); - - final String name; - final void Function(EditorState editorState) onPressed; -} - -class ContextMenu extends StatelessWidget { - const ContextMenu({ - Key? key, - required this.position, - required this.editorState, - required this.items, - required this.onPressed, - }) : super(key: key); - - final Offset position; - final EditorState editorState; - final List> items; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final children = []; - for (var i = 0; i < items.length; i++) { - for (var j = 0; j < items[i].length; j++) { - var onHover = false; - children.add( - StatefulBuilder( - builder: (BuildContext context, setState) { - return Material( - color: editorState.editorStyle.selectionMenuBackgroundColor, - child: InkWell( - hoverColor: - editorState.editorStyle.selectionMenuItemSelectedColor, - customBorder: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), - ), - onTap: () { - items[i][j].onPressed(editorState); - onPressed(); - }, - onHover: (value) => setState(() { - onHover = value; - }), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - items[i][j].name, - textAlign: TextAlign.start, - style: TextStyle( - fontSize: 14, - color: onHover - ? editorState - .editorStyle.selectionMenuItemSelectedTextColor - : editorState - .editorStyle.selectionMenuItemTextColor, - ), - ), - ), - ), - ); - }, - ), - ); - } - if (i != items.length - 1) { - children.add(const Divider()); - } - } - - return Positioned( - top: position.dy, - left: position.dx, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), - constraints: const BoxConstraints( - minWidth: 140, - ), - decoration: BoxDecoration( - color: editorState.editorStyle.selectionMenuBackgroundColor, - boxShadow: [ - BoxShadow( - blurRadius: 5, - spreadRadius: 1, - color: Colors.black.withOpacity(0.1), - ), - ], - borderRadius: BorderRadius.circular(6.0), - ), - child: IntrinsicWidth( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, - ), - ), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart deleted file mode 100644 index f0312bdeff95e..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart +++ /dev/null @@ -1,259 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; - -void insertHeadingAfterSelection(EditorState editorState, String heading) { - insertTextNodeAfterSelection(editorState, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading, - BuiltInAttributeKey.heading: heading, - }); -} - -void insertQuoteAfterSelection(EditorState editorState) { - insertTextNodeAfterSelection(editorState, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.quote, - }); -} - -void insertCheckboxAfterSelection(EditorState editorState) { - insertTextNodeAfterSelection(editorState, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, - BuiltInAttributeKey.checkbox: false, - }); -} - -void insertBulletedListAfterSelection(EditorState editorState) { - insertTextNodeAfterSelection(editorState, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList, - }); -} - -void insertNumberedListAfterSelection(EditorState editorState) { - insertTextNodeAfterSelection(editorState, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.numberList, - BuiltInAttributeKey.number: 1, - }); -} - -bool insertTextNodeAfterSelection( - EditorState editorState, Attributes attributes) { - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - if (selection == null || nodes.isEmpty) { - return false; - } - - final node = nodes.first; - if (node is TextNode && node.delta.isEmpty) { - formatTextNodes(editorState, attributes); - } else { - final next = selection.end.path.next; - final transaction = editorState.transaction - ..insertNode( - next, - TextNode.empty(attributes: attributes), - ) - ..afterSelection = Selection.collapsed( - Position(path: next, offset: 0), - ); - editorState.apply(transaction); - } - - return true; -} - -void formatText(EditorState editorState) { - formatTextNodes(editorState, {}); -} - -void formatHeading(EditorState editorState, String heading) { - formatTextNodes(editorState, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading, - BuiltInAttributeKey.heading: heading, - }); -} - -void formatQuote(EditorState editorState) { - formatTextNodes(editorState, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.quote, - }); -} - -void formatCheckbox(EditorState editorState, bool check) { - formatTextNodes(editorState, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, - BuiltInAttributeKey.checkbox: check, - }); -} - -void formatBulletedList(EditorState editorState) { - formatTextNodes(editorState, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList, - }); -} - -/// Format the current selection with the given attributes. -/// -/// If the selected nodes are not text nodes, this method will do nothing. -/// If the selected text nodes already contain the style in attributes, this method will remove the existing style. -bool formatTextNodes(EditorState editorState, Attributes attributes) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final textNodes = nodes.whereType().toList(); - - if (textNodes.isEmpty) { - return false; - } - - final transaction = editorState.transaction; - - for (final textNode in textNodes) { - var newAttributes = {...textNode.attributes}; - for (final globalStyleKey in BuiltInAttributeKey.globalStyleKeys) { - if (newAttributes.keys.contains(globalStyleKey)) { - newAttributes[globalStyleKey] = null; - } - } - - // if an attribute already exists in the node, it should be removed instead - for (final entry in attributes.entries) { - if (textNode.attributes.containsKey(entry.key) && - textNode.attributes[entry.key] == entry.value) { - // attribute is not added to the node new attributes - } else { - newAttributes.addEntries([entry]); - } - } - transaction - ..updateNode( - textNode, - newAttributes, - ) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: textNode.toPlainText().length, - ), - ); - } - - editorState.apply(transaction); - return true; -} - -bool formatBold(EditorState editorState) { - return formatRichTextPartialStyle(editorState, BuiltInAttributeKey.bold); -} - -bool formatItalic(EditorState editorState) { - return formatRichTextPartialStyle(editorState, BuiltInAttributeKey.italic); -} - -bool formatUnderline(EditorState editorState) { - return formatRichTextPartialStyle(editorState, BuiltInAttributeKey.underline); -} - -bool formatStrikethrough(EditorState editorState) { - return formatRichTextPartialStyle( - editorState, BuiltInAttributeKey.strikethrough); -} - -bool formatEmbedCode(EditorState editorState) { - return formatRichTextPartialStyle(editorState, BuiltInAttributeKey.code); -} - -bool formatHighlight(EditorState editorState, String colorHex) { - bool value = _allSatisfyInSelection( - editorState, - BuiltInAttributeKey.backgroundColor, - colorHex, - ); - return formatRichTextPartialStyle( - editorState, - BuiltInAttributeKey.backgroundColor, - customValue: value ? '0x00000000' : colorHex, - ); -} - -bool formatRichTextPartialStyle(EditorState editorState, String styleKey, - {Object? customValue}) { - Attributes attributes = { - styleKey: customValue ?? - !_allSatisfyInSelection( - editorState, - styleKey, - customValue ?? true, - ), - }; - - return formatRichTextStyle(editorState, attributes); -} - -bool _allSatisfyInSelection( - EditorState editorState, - String styleKey, - dynamic matchValue, -) { - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - final textNodes = nodes.whereType().toList(growable: false); - - if (selection == null || textNodes.isEmpty) { - return false; - } - - return textNodes.allSatisfyInSelection(selection, styleKey, (value) { - return value == matchValue; - }); -} - -bool formatRichTextStyle(EditorState editorState, Attributes attributes) { - var selection = editorState.service.selectionService.currentSelection.value; - var nodes = editorState.service.selectionService.currentSelectedNodes; - - if (selection == null) { - return false; - } - - nodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false); - selection = selection.isBackward ? selection : selection.reversed; - - var textNodes = nodes.whereType().toList(); - if (textNodes.isEmpty) { - return false; - } - - final transaction = editorState.transaction; - - // 1. All nodes are text nodes. - // 2. The first node is not TextNode. - // 3. The last node is not TextNode. - if (nodes.length == textNodes.length && textNodes.length == 1) { - transaction.formatText( - textNodes.first, - selection.start.offset, - selection.end.offset - selection.start.offset, - attributes, - ); - } else { - for (var i = 0; i < textNodes.length; i++) { - final textNode = textNodes[i]; - var index = 0; - var length = textNode.toPlainText().length; - if (i == 0 && textNode == nodes.first) { - index = selection.start.offset; - length = textNode.toPlainText().length - selection.start.offset; - } else if (i == textNodes.length - 1 && textNode == nodes.last) { - length = selection.end.offset; - } - transaction.formatText( - textNode, - index, - length, - attributes, - ); - } - } - - editorState.apply(transaction); - - return true; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart deleted file mode 100644 index 21805347566dd..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/flutter/overlay.dart'; -import 'package:appflowy_editor/src/render/image/image_node_builder.dart'; -import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart'; -import 'package:flutter/material.dart' hide Overlay, OverlayEntry; - -import 'package:appflowy_editor/src/render/editor/editor_entry.dart'; -import 'package:appflowy_editor/src/render/rich_text/bulleted_list_text.dart'; -import 'package:appflowy_editor/src/render/rich_text/checkbox_text.dart'; -import 'package:appflowy_editor/src/render/rich_text/heading_text.dart'; -import 'package:appflowy_editor/src/render/rich_text/number_list_text.dart'; -import 'package:appflowy_editor/src/render/rich_text/quoted_text.dart'; -import 'package:appflowy_editor/src/render/rich_text/rich_text.dart'; - -NodeWidgetBuilders defaultBuilders = { - 'editor': EditorEntryWidgetBuilder(), - 'text': RichTextNodeWidgetBuilder(), - 'text/checkbox': CheckboxNodeWidgetBuilder(), - 'text/heading': HeadingTextNodeWidgetBuilder(), - 'text/bulleted-list': BulletedListTextNodeWidgetBuilder(), - 'text/number-list': NumberListTextNodeWidgetBuilder(), - 'text/quote': QuotedTextNodeWidgetBuilder(), - 'image': ImageNodeBuilder(), -}; - -class AppFlowyEditor extends StatefulWidget { - AppFlowyEditor({ - Key? key, - required this.editorState, - this.customBuilders = const {}, - this.shortcutEvents = const [], - this.selectionMenuItems = const [], - this.editable = true, - this.autoFocus = false, - ThemeData? themeData, - }) : super(key: key) { - this.themeData = themeData ?? - ThemeData.light().copyWith(extensions: [ - ...lightEditorStyleExtension, - ...lightPlguinStyleExtension, - ]); - } - - final EditorState editorState; - - /// Render plugins. - final NodeWidgetBuilders customBuilders; - - /// Keyboard event handlers. - final List shortcutEvents; - - final List selectionMenuItems; - - late final ThemeData themeData; - - final bool editable; - - /// Set the value to true to focus the editor on the start of the document. - final bool autoFocus; - - @override - State createState() => _AppFlowyEditorState(); -} - -class _AppFlowyEditorState extends State { - Widget? services; - - EditorState get editorState => widget.editorState; - EditorStyle get editorStyle => - editorState.themeData.extension() ?? EditorStyle.light; - - @override - void initState() { - super.initState(); - - editorState.selectionMenuItems = widget.selectionMenuItems; - editorState.themeData = widget.themeData; - editorState.service.renderPluginService = _createRenderPlugin(); - editorState.editable = widget.editable; - - // auto focus - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (widget.editable && widget.autoFocus) { - editorState.service.selectionService.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - } - }); - } - - @override - void didUpdateWidget(covariant AppFlowyEditor oldWidget) { - super.didUpdateWidget(oldWidget); - - if (editorState.service != oldWidget.editorState.service) { - editorState.selectionMenuItems = widget.selectionMenuItems; - editorState.service.renderPluginService = _createRenderPlugin(); - } - - editorState.themeData = widget.themeData; - editorState.editable = widget.editable; - services = null; - } - - @override - Widget build(BuildContext context) { - services ??= _buildServices(context); - return Overlay( - initialEntries: [ - OverlayEntry( - builder: (context) => services!, - ), - ], - ); - } - - Widget _buildServices(BuildContext context) { - return Theme( - data: widget.themeData, - child: AppFlowyScroll( - key: editorState.service.scrollServiceKey, - child: Padding( - padding: editorStyle.padding!, - child: AppFlowySelection( - key: editorState.service.selectionServiceKey, - cursorColor: editorStyle.cursorColor!, - selectionColor: editorStyle.selectionColor!, - editorState: editorState, - editable: widget.editable, - child: AppFlowyInput( - key: editorState.service.inputServiceKey, - editorState: editorState, - editable: widget.editable, - child: AppFlowyKeyboard( - key: editorState.service.keyboardServiceKey, - editable: widget.editable, - shortcutEvents: [ - ...widget.shortcutEvents, - ...builtInShortcutEvents, - ], - editorState: editorState, - child: FlowyToolbar( - key: editorState.service.toolbarServiceKey, - editorState: editorState, - child: - editorState.service.renderPluginService.buildPluginWidget( - NodeWidgetContext( - context: context, - node: editorState.document.root, - editorState: editorState, - ), - ), - ), - ), - ), - ), - ), - ), - ); - } - - AppFlowyRenderPlugin _createRenderPlugin() => AppFlowyRenderPlugin( - editorState: editorState, - builders: { - ...defaultBuilders, - ...widget.customBuilders, - }, - ); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart deleted file mode 100644 index 4933866c31aaf..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart +++ /dev/null @@ -1,327 +0,0 @@ -import 'package:appflowy_editor/src/infra/log.dart'; -import 'package:appflowy_editor/src/core/transform/transaction.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/extensions/node_extensions.dart'; - -/// [AppFlowyInputService] is responsible for processing text input, -/// including text insertion, deletion and replacement. -/// -/// Usually, this service can be obtained by the following code. -/// ```dart -/// final inputService = editorState.service.inputService; -/// -/// /** update text editing value*/ -/// inputService?.attach(...); -/// -/// /** apply text editing deltas*/ -/// inputService?.apply(...); -/// ``` -/// -abstract class AppFlowyInputService { - /// Updates the [TextEditingValue] of the text currently being edited. - /// - /// Note that if there are IME-related requirements, - /// please config `composing` value within [TextEditingValue] - void attach(TextEditingValue textEditingValue); - - /// Applies insertion, deletion and replacement - /// to the text currently being edited. - /// - /// For more information, please check [TextEditingDelta]. - void apply(List deltas); - - /// Closes the editing state of the text currently being edited. - void close(); -} - -/// Processes text input -class AppFlowyInput extends StatefulWidget { - const AppFlowyInput({ - Key? key, - this.editable = true, - required this.editorState, - required this.child, - }) : super(key: key); - - final EditorState editorState; - final bool editable; - final Widget child; - - @override - State createState() => _AppFlowyInputState(); -} - -class _AppFlowyInputState extends State - implements AppFlowyInputService, DeltaTextInputClient { - TextInputConnection? _textInputConnection; - TextRange? _composingTextRange; - - EditorState get _editorState => widget.editorState; - - // Disable space shortcut on the Web platform. - final Map _shortcuts = kIsWeb - ? { - LogicalKeySet(LogicalKeyboardKey.space): - DoNothingAndStopPropagationIntent(), - } - : {}; - - @override - void initState() { - super.initState(); - - if (widget.editable) { - _editorState.service.selectionService.currentSelection - .addListener(_onSelectionChange); - } - } - - @override - void dispose() { - if (widget.editable) { - close(); - _editorState.service.selectionService.currentSelection - .removeListener(_onSelectionChange); - } - - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Shortcuts( - shortcuts: _shortcuts, - child: widget.child, - ); - } - - @override - void attach(TextEditingValue textEditingValue) { - if (_textInputConnection == null || - _textInputConnection!.attached == false) { - _textInputConnection = TextInput.attach( - this, - const TextInputConfiguration( - // TODO: customize - enableDeltaModel: true, - inputType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - ), - ); - } - - _textInputConnection! - ..setEditingState(textEditingValue) - ..show(); - } - - @override - void apply(List deltas) { - // TODO: implement the detail - for (final delta in deltas) { - _updateComposing(delta); - - if (delta is TextEditingDeltaInsertion) { - _applyInsert(delta); - } else if (delta is TextEditingDeltaDeletion) { - _applyDelete(delta); - } else if (delta is TextEditingDeltaReplacement) { - _applyReplacement(delta); - } else if (delta is TextEditingDeltaNonTextUpdate) {} - } - } - - void _updateComposing(TextEditingDelta delta) { - if (delta is! TextEditingDeltaNonTextUpdate) { - if (_composingTextRange != null && - delta.composing.end != -1 && - _composingTextRange!.start != -1) { - _composingTextRange = TextRange( - start: _composingTextRange!.start, - end: delta.composing.end, - ); - } else { - _composingTextRange = delta.composing; - } - } - } - - void _applyInsert(TextEditingDeltaInsertion delta) { - final selectionService = _editorState.service.selectionService; - final currentSelection = selectionService.currentSelection.value; - if (currentSelection == null) { - return; - } - if (currentSelection.isSingle) { - final textNode = selectionService.currentSelectedNodes.first as TextNode; - final transaction = _editorState.transaction; - transaction.insertText( - textNode, - delta.insertionOffset, - delta.textInserted, - ); - _editorState.apply(transaction); - } else { - // TODO: implement - } - } - - void _applyDelete(TextEditingDeltaDeletion delta) { - final selectionService = _editorState.service.selectionService; - final currentSelection = selectionService.currentSelection.value; - if (currentSelection == null) { - return; - } - if (currentSelection.isSingle) { - final textNode = selectionService.currentSelectedNodes.first as TextNode; - final length = delta.deletedRange.end - delta.deletedRange.start; - final transaction = _editorState.transaction; - transaction.deleteText(textNode, delta.deletedRange.start, length); - _editorState.apply(transaction); - } else { - // TODO: implement - } - } - - void _applyReplacement(TextEditingDeltaReplacement delta) { - final selectionService = _editorState.service.selectionService; - final currentSelection = selectionService.currentSelection.value; - if (currentSelection == null) { - return; - } - if (currentSelection.isSingle) { - final textNode = selectionService.currentSelectedNodes.first as TextNode; - final length = delta.replacedRange.end - delta.replacedRange.start; - final transaction = _editorState.transaction; - transaction.replaceText( - textNode, delta.replacedRange.start, length, delta.replacementText); - _editorState.apply(transaction); - } else { - // TODO: implement - } - } - - @override - void close() { - _textInputConnection?.close(); - _textInputConnection = null; - } - - @override - void connectionClosed() { - // TODO: implement connectionClosed - } - - @override - // TODO: implement currentAutofillScope - AutofillScope? get currentAutofillScope => throw UnimplementedError(); - - @override - // TODO: implement currentTextEditingValue - TextEditingValue? get currentTextEditingValue => throw UnimplementedError(); - - @override - void insertTextPlaceholder(Size size) { - // TODO: implement insertTextPlaceholder - } - - @override - void performAction(TextInputAction action) { - // TODO: implement performAction - } - - @override - void performPrivateCommand(String action, Map data) { - // TODO: implement performPrivateCommand - } - - @override - void removeTextPlaceholder() { - // TODO: implement removeTextPlaceholder - } - - @override - void showAutocorrectionPromptRect(int start, int end) { - // TODO: implement showAutocorrectionPromptRect - } - - @override - void showToolbar() { - // TODO: implement showToolbar - } - - @override - void updateEditingValue(TextEditingValue value) { - // TODO: implement updateEditingValue - } - - @override - void updateEditingValueWithDeltas(List textEditingDeltas) { - Log.input - .debug(textEditingDeltas.map((delta) => delta.toString()).toString()); - - apply(textEditingDeltas); - } - - @override - void updateFloatingCursor(RawFloatingCursorPoint point) { - // TODO: implement updateFloatingCursor - } - - void _onSelectionChange() { - final textNodes = _editorState.service.selectionService.currentSelectedNodes - .whereType(); - final selection = - _editorState.service.selectionService.currentSelection.value; - // FIXME: upward and selection update. - if (textNodes.isNotEmpty && selection != null) { - final text = textNodes.fold( - '', (sum, textNode) => '$sum${textNode.toPlainText()}\n'); - attach( - TextEditingValue( - text: text, - selection: TextSelection( - baseOffset: selection.start.offset, - extentOffset: selection.end.offset, - ), - composing: _composingTextRange ?? const TextRange.collapsed(-1), - ), - ); - if (textNodes.length == 1) { - _updateCaretPosition(textNodes.first, selection); - } - } else { - // https://github.com/flutter/flutter/issues/104944 - // Disable IME for the Web. - if (kIsWeb) { - close(); - } - } - } - - // TODO: support IME in linux / windows / ios / android - // Only support macOS now. - void _updateCaretPosition(TextNode textNode, Selection selection) { - if (!selection.isCollapsed) { - return; - } - final renderBox = textNode.renderBox; - final selectable = textNode.selectable; - if (renderBox != null && selectable != null) { - final size = renderBox.size; - final transform = renderBox.getTransformTo(null); - final rect = selectable.getCursorRectInPosition(selection.end); - if (rect != null) { - _textInputConnection - ?..setEditableSizeAndTransform(size, transform) - ..setCaretRect(rect); - } - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart deleted file mode 100644 index 94c857547b359..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart +++ /dev/null @@ -1,373 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/extensions/node_extensions.dart'; -import 'package:flutter/material.dart'; - -ShortcutEventHandler cursorLeftSelect = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final selection = editorState.service.selectionService.currentSelection.value; - if (nodes.isEmpty || selection == null) { - return KeyEventResult.ignored; - } - final end = selection.end.goLeft(editorState); - if (end == null) { - return KeyEventResult.ignored; - } - editorState.service.selectionService.updateSelection( - selection.copyWith(end: end), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorRightSelect = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final selection = editorState.service.selectionService.currentSelection.value; - if (nodes.isEmpty || selection == null) { - return KeyEventResult.ignored; - } - final end = selection.end.goRight(editorState); - if (end == null) { - return KeyEventResult.ignored; - } - editorState.service.selectionService.updateSelection( - selection.copyWith(end: end), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorUpSelect = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final selection = editorState.service.selectionService.currentSelection.value; - if (nodes.isEmpty || selection == null) { - return KeyEventResult.ignored; - } - final end = _goUp(editorState); - if (end == null) { - return KeyEventResult.ignored; - } - editorState.service.selectionService.updateSelection( - selection.copyWith(end: end), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorDownSelect = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final selection = editorState.service.selectionService.currentSelection.value; - if (nodes.isEmpty || selection == null) { - return KeyEventResult.ignored; - } - final end = _goDown(editorState); - if (end == null) { - return KeyEventResult.ignored; - } - editorState.service.selectionService.updateSelection( - selection.copyWith(end: end), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorTop = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - if (nodes.isEmpty) { - return KeyEventResult.ignored; - } - final position = editorState.document.root.children - .whereType() - .first - .selectable - ?.start(); - if (position == null) { - return KeyEventResult.ignored; - } - editorState.service.selectionService.updateSelection( - Selection.collapsed(position), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorBottom = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - if (nodes.isEmpty) { - return KeyEventResult.ignored; - } - final position = editorState.document.root.children - .whereType() - .last - .selectable - ?.end(); - if (position == null) { - return KeyEventResult.ignored; - } - editorState.service.selectionService.updateSelection( - Selection.collapsed(position), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorBegin = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - if (nodes.isEmpty) { - return KeyEventResult.ignored; - } - final position = nodes.first.selectable?.start(); - if (position == null) { - return KeyEventResult.ignored; - } - editorState.service.selectionService.updateSelection( - Selection.collapsed(position), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorEnd = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - if (nodes.isEmpty) { - return KeyEventResult.ignored; - } - final position = nodes.first.selectable?.end(); - if (position == null) { - return KeyEventResult.ignored; - } - editorState.service.selectionService.updateSelection( - Selection.collapsed(position), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorTopSelect = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final selection = editorState.service.selectionService.currentSelection.value; - if (nodes.isEmpty || selection == null) { - return KeyEventResult.ignored; - } - - var start = selection.start; - var end = selection.end; - final position = editorState.document.root.children - .whereType() - .first - .selectable - ?.start(); - if (position != null) { - end = position; - } - editorState.service.selectionService.updateSelection( - selection.copyWith(start: start, end: end), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorBottomSelect = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final selection = editorState.service.selectionService.currentSelection.value; - if (nodes.isEmpty || selection == null) { - return KeyEventResult.ignored; - } - var start = selection.start; - var end = selection.end; - final position = editorState.document.root.children - .whereType() - .last - .selectable - ?.end(); - if (position != null) { - end = position; - } - editorState.service.selectionService.updateSelection( - selection.copyWith(start: start, end: end), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorBeginSelect = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final selection = editorState.service.selectionService.currentSelection.value; - if (nodes.isEmpty || selection == null) { - return KeyEventResult.ignored; - } - - var start = selection.start; - var end = selection.end; - final position = nodes.last.selectable?.start(); - if (position != null) { - end = position; - } - editorState.service.selectionService.updateSelection( - selection.copyWith(start: start, end: end), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorEndSelect = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final selection = editorState.service.selectionService.currentSelection.value; - if (nodes.isEmpty || selection == null) { - return KeyEventResult.ignored; - } - - var start = selection.start; - var end = selection.end; - final position = nodes.last.selectable?.end(); - if (position != null) { - end = position; - } - editorState.service.selectionService.updateSelection( - selection.copyWith(start: start, end: end), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorUp = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final selection = - editorState.service.selectionService.currentSelection.value?.normalized; - if (nodes.isEmpty || selection == null) { - return KeyEventResult.ignored; - } - final upPosition = _goUp(editorState); - editorState.updateCursorSelection( - upPosition == null ? null : Selection.collapsed(upPosition), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorDown = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final selection = - editorState.service.selectionService.currentSelection.value?.normalized; - if (nodes.isEmpty || selection == null) { - return KeyEventResult.ignored; - } - final downPosition = _goDown(editorState); - editorState.updateCursorSelection( - downPosition == null ? null : Selection.collapsed(downPosition), - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorLeft = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final selection = - editorState.service.selectionService.currentSelection.value?.normalized; - if (nodes.isEmpty || selection == null) { - return KeyEventResult.ignored; - } - if (selection.isCollapsed) { - final leftPosition = selection.start.goLeft(editorState); - if (leftPosition != null) { - editorState.service.selectionService.updateSelection( - Selection.collapsed(leftPosition), - ); - } - } else { - editorState.service.selectionService.updateSelection( - Selection.collapsed(selection.start), - ); - } - return KeyEventResult.handled; -}; - -ShortcutEventHandler cursorRight = (editorState, event) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final selection = - editorState.service.selectionService.currentSelection.value?.normalized; - if (nodes.isEmpty || selection == null) { - return KeyEventResult.ignored; - } - if (selection.isCollapsed) { - final rightPosition = selection.start.goRight(editorState); - if (rightPosition != null) { - editorState.service.selectionService.updateSelection( - Selection.collapsed(rightPosition), - ); - } - } else { - editorState.service.selectionService.updateSelection( - Selection.collapsed(selection.end), - ); - } - return KeyEventResult.handled; -}; - -extension on Position { - Position? goLeft(EditorState editorState) { - final node = editorState.document.nodeAtPath(path); - if (node == null) { - return null; - } - if (offset == 0) { - final previousEnd = node.previous?.selectable?.end(); - if (previousEnd != null) { - return previousEnd; - } - return null; - } - if (node is TextNode) { - return Position(path: path, offset: node.delta.prevRunePosition(offset)); - } else { - return Position(path: path, offset: offset); - } - } - - Position? goRight(EditorState editorState) { - final node = editorState.document.nodeAtPath(path); - if (node == null) { - return null; - } - final end = node.selectable?.end(); - if (end != null && offset >= end.offset) { - final nextStart = node.next?.selectable?.start(); - if (nextStart != null) { - return nextStart; - } - return null; - } - if (node is TextNode) { - return Position(path: path, offset: node.delta.nextRunePosition(offset)); - } else { - return Position(path: path, offset: offset); - } - } -} - -Position? _goUp(EditorState editorState) { - final selection = editorState.service.selectionService.currentSelection.value; - final rects = editorState.service.selectionService.selectionRects; - if (rects.isEmpty || selection == null) { - return null; - } - Offset offset; - if (selection.isBackward) { - final rect = rects.reduce( - (current, next) => current.bottom >= next.bottom ? current : next, - ); - offset = rect.topRight.translate(0, -rect.height); - } else { - final rect = rects.reduce( - (current, next) => current.top <= next.top ? current : next, - ); - offset = rect.topLeft.translate(0, -rect.height); - } - return editorState.service.selectionService.getPositionInOffset(offset); -} - -Position? _goDown(EditorState editorState) { - final selection = editorState.service.selectionService.currentSelection.value; - final rects = editorState.service.selectionService.selectionRects; - if (rects.isEmpty || selection == null) { - return null; - } - Offset offset; - if (selection.isBackward) { - final rect = rects.reduce( - (current, next) => current.bottom >= next.bottom ? current : next, - ); - offset = rect.bottomRight.translate(0, rect.height); - } else { - final rect = rects.reduce( - (current, next) => current.top <= next.top ? current : next, - ); - offset = rect.bottomLeft.translate(0, rect.height); - } - return editorState.service.selectionService.getPositionInOffset(offset); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart deleted file mode 100644 index 69335e5145792..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'package:appflowy_editor/src/infra/infra.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart'; -import 'package:flutter/material.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; - -ShortcutEventHandler backspaceEventHandler = (editorState, event) { - var selection = editorState.service.selectionService.currentSelection.value; - if (selection == null) { - return KeyEventResult.ignored; - } - var nodes = editorState.service.selectionService.currentSelectedNodes; - nodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false); - selection = selection.isBackward ? selection : selection.reversed; - final textNodes = nodes.whereType().toList(); - final List nonTextNodes = - nodes.where((node) => node is! TextNode).toList(growable: false); - - final transaction = editorState.transaction; - List? cancelNumberListPath; - - if (nonTextNodes.isNotEmpty) { - transaction.deleteNodes(nonTextNodes); - } - - if (textNodes.length == 1) { - final textNode = textNodes.first; - final index = textNode.delta.prevRunePosition(selection.start.offset); - if (index < 0 && selection.isCollapsed) { - // 1. style - if (textNode.subtype != null) { - if (textNode.subtype == BuiltInAttributeKey.numberList) { - cancelNumberListPath = textNode.path; - } - transaction - ..updateNode(textNode, { - BuiltInAttributeKey.subtype: null, - textNode.subtype!: null, - }) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: 0, - ), - ); - } else { - // 2. non-style - // find previous text node. - return _backDeleteToPreviousTextNode( - editorState, - textNode, - transaction, - nonTextNodes, - selection, - ); - } - } else { - if (selection.isCollapsed) { - transaction.deleteText( - textNode, - index, - selection.start.offset - index, - ); - } else { - transaction.deleteText( - textNode, - selection.start.offset, - selection.end.offset - selection.start.offset, - ); - } - } - } else { - if (textNodes.isEmpty) { - if (nonTextNodes.isNotEmpty) { - transaction.afterSelection = Selection.collapsed(selection.start); - } - editorState.apply(transaction); - return KeyEventResult.handled; - } - final startPosition = selection.start; - final nodeAtStart = editorState.document.nodeAtPath(startPosition.path)!; - _deleteTextNodes(transaction, textNodes, selection); - editorState.apply(transaction); - - if (nodeAtStart is TextNode && - nodeAtStart.subtype == BuiltInAttributeKey.numberList) { - makeFollowingNodesIncremental( - editorState, - startPosition.path, - transaction.afterSelection!, - ); - } - return KeyEventResult.handled; - } - - if (transaction.operations.isNotEmpty) { - if (nonTextNodes.isNotEmpty) { - transaction.afterSelection = Selection.collapsed(selection.start); - } - editorState.apply(transaction); - } - - if (cancelNumberListPath != null) { - makeFollowingNodesIncremental( - editorState, - cancelNumberListPath, - Selection.collapsed(selection.start), - beginNum: 0, - ); - } - - return KeyEventResult.handled; -}; - -KeyEventResult _backDeleteToPreviousTextNode( - EditorState editorState, - TextNode textNode, - Transaction transaction, - List nonTextNodes, - Selection selection, -) { - if (textNode.next == null && - textNode.children.isEmpty && - textNode.parent?.parent != null) { - transaction - ..deleteNode(textNode) - ..insertNode(textNode.parent!.path.next, textNode) - ..afterSelection = Selection.collapsed( - Position(path: textNode.parent!.path.next, offset: 0), - ); - editorState.apply(transaction); - return KeyEventResult.handled; - } - - bool prevIsNumberList = false; - final previousTextNode = Infra.forwardNearestTextNode(textNode); - if (previousTextNode != null) { - if (previousTextNode.subtype == BuiltInAttributeKey.numberList) { - prevIsNumberList = true; - } - - transaction.mergeText(previousTextNode, textNode); - if (textNode.children.isNotEmpty) { - transaction.insertNodes( - previousTextNode.path.next, - textNode.children.toList(growable: false), - ); - } - transaction.deleteNode(textNode); - transaction.afterSelection = Selection.collapsed( - Position( - path: previousTextNode.path, - offset: previousTextNode.toPlainText().length, - ), - ); - } - - if (transaction.operations.isNotEmpty) { - if (nonTextNodes.isNotEmpty) { - transaction.afterSelection = Selection.collapsed(selection.start); - } - editorState.apply(transaction); - } - - if (prevIsNumberList) { - makeFollowingNodesIncremental( - editorState, previousTextNode!.path, transaction.afterSelection!); - } - - return KeyEventResult.handled; -} - -ShortcutEventHandler deleteEventHandler = (editorState, event) { - var selection = editorState.service.selectionService.currentSelection.value; - if (selection == null) { - return KeyEventResult.ignored; - } - var nodes = editorState.service.selectionService.currentSelectedNodes; - nodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false); - selection = selection.isBackward ? selection : selection.reversed; - // make sure all nodes is [TextNode]. - final textNodes = nodes.whereType().toList(); - if (textNodes.length != nodes.length) { - return KeyEventResult.ignored; - } - - final transaction = editorState.transaction; - if (textNodes.length == 1) { - final textNode = textNodes.first; - // The cursor is at the end of the line, - // merge next line into this line. - if (selection.start.offset >= textNode.delta.length) { - return _mergeNextLineIntoThisLine( - editorState, - textNode, - transaction, - selection, - ); - } - final index = textNode.delta.nextRunePosition(selection.start.offset); - if (selection.isCollapsed) { - transaction.deleteText( - textNode, - selection.start.offset, - index - selection.start.offset, - ); - } else { - transaction.deleteText( - textNode, - selection.start.offset, - selection.end.offset - selection.start.offset, - ); - } - editorState.apply(transaction); - } else { - final startPosition = selection.start; - final nodeAtStart = editorState.document.nodeAtPath(startPosition.path)!; - _deleteTextNodes(transaction, textNodes, selection); - editorState.apply(transaction); - - if (nodeAtStart is TextNode && - nodeAtStart.subtype == BuiltInAttributeKey.numberList) { - makeFollowingNodesIncremental( - editorState, startPosition.path, transaction.afterSelection!); - } - } - - return KeyEventResult.handled; -}; - -KeyEventResult _mergeNextLineIntoThisLine(EditorState editorState, - TextNode textNode, Transaction transaction, Selection selection) { - final nextNode = textNode.next; - if (nextNode == null) { - return KeyEventResult.ignored; - } - if (nextNode is TextNode) { - transaction.mergeText(textNode, nextNode); - } - transaction.deleteNode(nextNode); - editorState.apply(transaction); - - if (textNode.subtype == BuiltInAttributeKey.numberList) { - makeFollowingNodesIncremental(editorState, textNode.path, selection); - } - - return KeyEventResult.handled; -} - -void _deleteTextNodes( - Transaction transaction, List textNodes, Selection selection) { - final first = textNodes.first; - final last = textNodes.last; - var content = textNodes.last.toPlainText(); - content = content.substring(selection.end.offset, content.length); - // Merge the fist and the last text node content, - // and delete the all nodes expect for the first. - transaction - ..deleteNodes(textNodes.sublist(1)) - ..mergeText( - first, - last, - firstOffset: selection.start.offset, - secondOffset: selection.end.offset, - ); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart deleted file mode 100644 index 9ab74cdb147d4..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart +++ /dev/null @@ -1,386 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/infra/html_converter.dart'; -import 'package:appflowy_editor/src/core/document/node_iterator.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart'; -import 'package:flutter/material.dart'; -import 'package:rich_clipboard/rich_clipboard.dart'; - -int _textLengthOfNode(Node node) { - if (node is TextNode) { - return node.delta.length; - } - - return 0; -} - -Selection _computeSelectionAfterPasteMultipleNodes( - EditorState editorState, List nodes) { - final currentSelection = editorState.cursorSelection!; - final currentCursor = currentSelection.start; - final currentPath = [...currentCursor.path]; - currentPath[currentPath.length - 1] += nodes.length; - int lenOfLastNode = _textLengthOfNode(nodes.last); - return Selection.collapsed( - Position(path: currentPath, offset: lenOfLastNode)); -} - -void _handleCopy(EditorState editorState) async { - final selection = editorState.cursorSelection?.normalized; - if (selection == null || selection.isCollapsed) { - return; - } - if (selection.start.path.equals(selection.end.path)) { - final nodeAtPath = editorState.document.nodeAtPath(selection.end.path)!; - if (nodeAtPath.type == "text") { - final textNode = nodeAtPath as TextNode; - final htmlString = NodesToHTMLConverter( - nodes: [textNode], - startOffset: selection.start.offset, - endOffset: selection.end.offset) - .toHTMLString(); - Log.keyboard.debug('copy html: $htmlString'); - RichClipboard.setData(RichClipboardData( - html: htmlString, - text: textNode.toPlainText().substring( - selection.startIndex, - selection.endIndex, - ), - )); - } else { - Log.keyboard.debug('unimplemented: copy non-text'); - } - return; - } - - final beginNode = editorState.document.nodeAtPath(selection.start.path)!; - final endNode = editorState.document.nodeAtPath(selection.end.path)!; - - final nodes = NodeIterator( - document: editorState.document, - startNode: beginNode, - endNode: endNode, - ).toList(); - - final html = NodesToHTMLConverter( - nodes: nodes, - startOffset: selection.start.offset, - endOffset: selection.end.offset, - ).toHTMLString(); - var text = ''; - for (final node in nodes) { - if (node is TextNode) { - if (node.path == selection.start.path) { - text += node.toPlainText().substring(selection.start.offset); - } else if (node.path == selection.end.path) { - text += node.toPlainText().substring(0, selection.end.offset); - } else { - text += node.toPlainText(); - } - } - text += '\n'; - } - RichClipboard.setData(RichClipboardData(html: html, text: text)); -} - -void _pasteHTML(EditorState editorState, String html) { - final selection = editorState.cursorSelection?.normalized; - if (selection == null) { - return; - } - - assert(selection.isCollapsed); - - final path = [...selection.end.path]; - if (path.isEmpty) { - return; - } - - Log.keyboard.debug('paste html: $html'); - final nodes = HTMLToNodesConverter(html).toNodes(); - - if (nodes.isEmpty) { - return; - } else if (nodes.length == 1) { - final firstNode = nodes[0]; - final nodeAtPath = editorState.document.nodeAtPath(path)!; - final tb = editorState.transaction; - final startOffset = selection.start.offset; - if (nodeAtPath.type == "text" && firstNode.type == "text") { - final textNodeAtPath = nodeAtPath as TextNode; - final firstTextNode = firstNode as TextNode; - tb.updateText( - textNodeAtPath, (Delta()..retain(startOffset)) + firstTextNode.delta); - tb.updateNode(textNodeAtPath, firstTextNode.attributes); - tb.afterSelection = (Selection.collapsed(Position( - path: path, offset: startOffset + firstTextNode.delta.length))); - editorState.apply(tb); - return; - } - } - - _pasteMultipleLinesInText(editorState, path, selection.start.offset, nodes); -} - -void _pasteMultipleLinesInText( - EditorState editorState, List path, int offset, List nodes) { - final tb = editorState.transaction; - - final firstNode = nodes[0]; - final nodeAtPath = editorState.document.nodeAtPath(path)!; - - if (nodeAtPath.type == 'text' && firstNode.type == 'text') { - int? startNumber; - if (nodeAtPath.subtype == BuiltInAttributeKey.numberList) { - startNumber = nodeAtPath.attributes[BuiltInAttributeKey.number] as int; - } - - // split and merge - final textNodeAtPath = nodeAtPath as TextNode; - final firstTextNode = firstNode as TextNode; - final remain = textNodeAtPath.delta.slice(offset); - - tb.updateText( - textNodeAtPath, - (Delta() - ..retain(offset) - ..delete(remain.length)) + - firstTextNode.delta); - tb.updateNode(textNodeAtPath, firstTextNode.attributes); - - final tailNodes = nodes.sublist(1); - final originalPath = [...path]; - path[path.length - 1]++; - - final afterSelection = - _computeSelectionAfterPasteMultipleNodes(editorState, tailNodes); - - if (tailNodes.isNotEmpty) { - if (tailNodes.last.type == "text") { - final tailTextNode = tailNodes.last as TextNode; - tailTextNode.delta = tailTextNode.delta + remain; - } else if (remain.isNotEmpty) { - tailNodes.add(TextNode(delta: remain)); - } - } else { - tailNodes.add(TextNode(delta: remain)); - } - - tb.afterSelection = afterSelection; - tb.insertNodes(path, tailNodes); - editorState.apply(tb); - - if (startNumber != null) { - makeFollowingNodesIncremental(editorState, originalPath, afterSelection, - beginNum: startNumber); - } - return; - } - - final afterSelection = - _computeSelectionAfterPasteMultipleNodes(editorState, nodes); - - path[path.length - 1]++; - tb.afterSelection = afterSelection; - tb.insertNodes(path, nodes); - editorState.apply(tb); -} - -void _handlePaste(EditorState editorState) async { - final data = await RichClipboard.getData(); - - if (editorState.cursorSelection?.isCollapsed ?? false) { - _pastRichClipboard(editorState, data); - return; - } - - _deleteSelectedContent(editorState); - - WidgetsBinding.instance.addPostFrameCallback((_) { - _pastRichClipboard(editorState, data); - }); -} - -void _pastRichClipboard(EditorState editorState, RichClipboardData data) { - if (data.html != null) { - _pasteHTML(editorState, data.html!); - return; - } - if (data.text != null) { - _handlePastePlainText(editorState, data.text!); - return; - } -} - -void _pasteSingleLine( - EditorState editorState, Selection selection, String line) { - final node = editorState.document.nodeAtPath(selection.end.path)! as TextNode; - final beginOffset = selection.end.offset; - final transaction = editorState.transaction - ..updateText( - node, - Delta() - ..retain(beginOffset) - ..addAll(_lineContentToDelta(line))) - ..afterSelection = (Selection.collapsed( - Position(path: selection.end.path, offset: beginOffset + line.length))); - editorState.apply(transaction); -} - -/// parse url from the line text -/// reference: https://stackoverflow.com/questions/59444837/flutter-dart-regex-to-extract-urls-from-a-string -Delta _lineContentToDelta(String lineContent) { - final exp = RegExp(r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+'); - final Iterable matches = exp.allMatches(lineContent); - - final delta = Delta(); - - var lastUrlEndOffset = 0; - - for (final match in matches) { - if (lastUrlEndOffset < match.start) { - delta.insert(lineContent.substring(lastUrlEndOffset, match.start)); - } - final linkContent = lineContent.substring(match.start, match.end); - delta.insert(linkContent, attributes: {"href": linkContent}); - lastUrlEndOffset = match.end; - } - - if (lastUrlEndOffset < lineContent.length) { - delta.insert(lineContent.substring(lastUrlEndOffset, lineContent.length)); - } - - return delta; -} - -void _handlePastePlainText(EditorState editorState, String plainText) { - final selection = editorState.cursorSelection?.normalized; - if (selection == null) { - return; - } - - final lines = plainText - .split("\n") - .map((e) => e.replaceAll(RegExp(r'\r'), "")) - .toList(); - - if (lines.isEmpty) { - return; - } else if (lines.length == 1) { - // single line - _pasteSingleLine(editorState, selection, lines.first); - } else { - final firstLine = lines[0]; - final beginOffset = selection.end.offset; - final remains = lines.sublist(1); - - final path = [...selection.end.path]; - if (path.isEmpty) { - return; - } - - final node = - editorState.document.nodeAtPath(selection.end.path)! as TextNode; - final insertedLineSuffix = node.delta.slice(beginOffset); - - path[path.length - 1]++; - final tb = editorState.transaction; - final List nodes = - remains.map((e) => TextNode(delta: _lineContentToDelta(e))).toList(); - - final afterSelection = - _computeSelectionAfterPasteMultipleNodes(editorState, nodes); - - // append remain text to the last line - if (nodes.isNotEmpty) { - final last = nodes.last; - nodes[nodes.length - 1] = - TextNode(delta: last.delta..addAll(insertedLineSuffix)); - } - - // insert first line - tb.updateText( - node, - Delta() - ..retain(beginOffset) - ..insert(firstLine) - ..delete(node.delta.length - beginOffset)); - // insert remains - tb.insertNodes(path, nodes); - tb.afterSelection = afterSelection; - editorState.apply(tb); - } -} - -/// 1. copy the selected content -/// 2. delete selected content -void _handleCut(EditorState editorState) { - _handleCopy(editorState); - _deleteSelectedContent(editorState); -} - -void _deleteSelectedContent(EditorState editorState) { - final selection = editorState.cursorSelection?.normalized; - if (selection == null || selection.isCollapsed) { - return; - } - final beginNode = editorState.document.nodeAtPath(selection.start.path)!; - final endNode = editorState.document.nodeAtPath(selection.end.path)!; - if (selection.start.path.equals(selection.end.path) && - beginNode.type == "text") { - final textItem = beginNode as TextNode; - final tb = editorState.transaction; - final len = selection.end.offset - selection.start.offset; - tb.updateText( - textItem, - Delta() - ..retain(selection.start.offset) - ..delete(len)); - tb.afterSelection = Selection.collapsed(selection.start); - editorState.apply(tb); - return; - } - final traverser = NodeIterator( - document: editorState.document, - startNode: beginNode, - endNode: endNode, - ); - final tb = editorState.transaction; - while (traverser.moveNext()) { - final item = traverser.current; - if (item.type == "text" && beginNode == item) { - final textItem = item as TextNode; - final deleteLen = textItem.delta.length - selection.start.offset; - tb.updateText(textItem, () { - final delta = Delta() - ..retain(selection.start.offset) - ..delete(deleteLen); - - if (endNode is TextNode) { - final remain = endNode.delta.slice(selection.end.offset); - delta.addAll(remain); - } - - return delta; - }()); - } else { - tb.deleteNode(item); - } - } - tb.afterSelection = Selection.collapsed(selection.start); - editorState.apply(tb); -} - -ShortcutEventHandler copyEventHandler = (editorState, event) { - _handleCopy(editorState); - return KeyEventResult.handled; -}; - -ShortcutEventHandler pasteEventHandler = (editorState, event) { - _handlePaste(editorState); - return KeyEventResult.handled; -}; - -ShortcutEventHandler cutEventHandler = (editorState, event) { - _handleCut(editorState); - return KeyEventResult.handled; -}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart deleted file mode 100644 index 41fbb88541498..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; - -import './number_list_helper.dart'; - -/// Handle some cases where enter is pressed and shift is not pressed. -/// -/// 1. Multiple selection and the selected nodes are [TextNode] -/// 1.1 delete the nodes expect for the first and the last, -/// and delete the text in the first and the last node by case. -/// 2. Single selection and the selected node is [TextNode] -/// 2.1 split the node into two nodes with style -/// 2.2 or insert a empty text node before. -ShortcutEventHandler enterWithoutShiftInTextNodesHandler = - (editorState, event) { - var selection = editorState.service.selectionService.currentSelection.value; - var nodes = editorState.service.selectionService.currentSelectedNodes; - if (selection == null) { - return KeyEventResult.ignored; - } - if (selection.isForward) { - selection = selection.reversed; - nodes = nodes.reversed.toList(growable: false); - } - final textNodes = nodes.whereType().toList(growable: false); - - if (nodes.length != textNodes.length) { - return KeyEventResult.ignored; - } - - // Multiple selection - if (!selection.isSingle) { - final startNode = editorState.document.nodeAtPath(selection.start.path)!; - final length = textNodes.length; - final List subTextNodes = - length >= 3 ? textNodes.sublist(1, textNodes.length - 1) : []; - final afterSelection = Selection.collapsed( - Position(path: textNodes.first.path.next, offset: 0), - ); - final transaction = editorState.transaction - ..deleteText( - textNodes.first, - selection.start.offset, - textNodes.first.toPlainText().length, - ) - ..deleteNodes(subTextNodes) - ..deleteText( - textNodes.last, - 0, - selection.end.offset, - ) - ..afterSelection = afterSelection; - editorState.apply(transaction); - - if (startNode is TextNode && - startNode.subtype == BuiltInAttributeKey.numberList) { - makeFollowingNodesIncremental( - editorState, selection.start.path, afterSelection); - } - - return KeyEventResult.handled; - } - - // Single selection and the selected node is [TextNode] - if (textNodes.length != 1) { - return KeyEventResult.ignored; - } - - final textNode = textNodes.first; - - // If selection is collapsed and position.start.offset == 0, - // insert a empty text node before. - if (selection.isCollapsed && selection.start.offset == 0) { - if (textNode.toPlainText().isEmpty && textNode.subtype != null) { - final afterSelection = Selection.collapsed( - Position(path: textNode.path, offset: 0), - ); - final transaction = editorState.transaction - ..updateNode(textNode, { - BuiltInAttributeKey.subtype: null, - }) - ..afterSelection = afterSelection; - editorState.apply(transaction); - - final nextNode = textNode.next; - if (nextNode is TextNode && - nextNode.subtype == BuiltInAttributeKey.numberList) { - makeFollowingNodesIncremental( - editorState, textNode.path, afterSelection, - beginNum: 0); - } - } else { - final subtype = textNode.subtype; - final afterSelection = Selection.collapsed( - Position(path: textNode.path.next, offset: 0), - ); - - if (subtype == BuiltInAttributeKey.numberList) { - final prevNumber = - textNode.attributes[BuiltInAttributeKey.number] as int; - final newNode = TextNode.empty(); - newNode.attributes[BuiltInAttributeKey.subtype] = - BuiltInAttributeKey.numberList; - newNode.attributes[BuiltInAttributeKey.number] = prevNumber; - final insertPath = textNode.path; - final transaction = editorState.transaction - ..insertNode( - insertPath, - newNode, - ) - ..afterSelection = afterSelection; - editorState.apply(transaction); - - makeFollowingNodesIncremental(editorState, insertPath, afterSelection, - beginNum: prevNumber); - } else { - bool needCopyAttributes = ![ - BuiltInAttributeKey.heading, - BuiltInAttributeKey.quote, - ].contains(subtype); - final transaction = editorState.transaction - ..insertNode( - textNode.path, - textNode.copyWith( - children: LinkedList(), - delta: Delta(), - attributes: needCopyAttributes ? null : {}, - ), - ) - ..afterSelection = afterSelection; - editorState.apply(transaction); - } - } - return KeyEventResult.handled; - } - - // Otherwise, - // split the node into two nodes with style - Attributes attributes = _attributesFromPreviousLine(textNode); - - final nextPath = textNode.path.next; - final afterSelection = Selection.collapsed( - Position(path: nextPath, offset: 0), - ); - - final transaction = editorState.transaction; - transaction.insertNode( - textNode.path.next, - textNode.copyWith( - attributes: attributes, - delta: textNode.delta.slice(selection.end.offset), - ), - ); - if (selection.end.offset != textNode.toPlainText().length) { - transaction.deleteText( - textNode, - selection.start.offset, - textNode.toPlainText().length - selection.start.offset, - ); - } - if (textNode.children.isNotEmpty) { - final children = textNode.children.toList(growable: false); - transaction.deleteNodes(children); - } - transaction.afterSelection = afterSelection; - editorState.apply(transaction); - - // If the new type of a text node is number list, - // the numbers of the following nodes should be incremental. - if (textNode.subtype == BuiltInAttributeKey.numberList) { - makeFollowingNodesIncremental(editorState, nextPath, afterSelection); - } - - return KeyEventResult.handled; -}; - -Attributes _attributesFromPreviousLine(TextNode textNode) { - final prevAttributes = textNode.attributes; - final subType = textNode.subtype; - if (subType == null || - subType == BuiltInAttributeKey.heading || - subType == BuiltInAttributeKey.quote) { - return {}; - } - - final copy = Attributes.from(prevAttributes); - if (subType == BuiltInAttributeKey.numberList) { - return _nextNumberAttributesFromPreviousLine(copy, textNode); - } - - if (subType == BuiltInAttributeKey.checkbox) { - copy[BuiltInAttributeKey.checkbox] = false; - return copy; - } - - return copy; -} - -Attributes _nextNumberAttributesFromPreviousLine( - Attributes copy, TextNode textNode) { - final prevNum = textNode.attributes[BuiltInAttributeKey.number] as int?; - copy[BuiltInAttributeKey.number] = prevNum == null ? 1 : prevNum + 1; - return copy; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/exit_editing_mode_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/exit_editing_mode_handler.dart deleted file mode 100644 index 2ffe0f3b96467..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/exit_editing_mode_handler.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart'; -import 'package:flutter/material.dart'; - -ShortcutEventHandler exitEditingModeEventHandler = (editorState, event) { - editorState.service.selectionService.clearSelection(); - return KeyEventResult.handled; -}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/format_style_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/format_style_handler.dart deleted file mode 100644 index 1535efdb94c6e..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/format_style_handler.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart'; -import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart'; -import 'package:flutter/material.dart'; - -import 'package:appflowy_editor/src/core/document/node.dart'; - -ShortcutEventHandler formatBoldEventHandler = (editorState, event) { - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - final textNodes = nodes.whereType().toList(growable: false); - if (selection == null || textNodes.isEmpty) { - return KeyEventResult.ignored; - } - formatBold(editorState); - return KeyEventResult.handled; -}; - -ShortcutEventHandler formatItalicEventHandler = (editorState, event) { - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - final textNodes = nodes.whereType().toList(growable: false); - if (selection == null || textNodes.isEmpty) { - return KeyEventResult.ignored; - } - formatItalic(editorState); - return KeyEventResult.handled; -}; - -ShortcutEventHandler formatUnderlineEventHandler = (editorState, event) { - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - final textNodes = nodes.whereType().toList(growable: false); - if (selection == null || textNodes.isEmpty) { - return KeyEventResult.ignored; - } - formatUnderline(editorState); - return KeyEventResult.handled; -}; - -ShortcutEventHandler formatStrikethroughEventHandler = (editorState, event) { - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - final textNodes = nodes.whereType().toList(growable: false); - if (selection == null || textNodes.isEmpty) { - return KeyEventResult.ignored; - } - formatStrikethrough(editorState); - return KeyEventResult.handled; -}; - -ShortcutEventHandler formatHighlightEventHandler = (editorState, event) { - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - final textNodes = nodes.whereType().toList(growable: false); - if (selection == null || textNodes.isEmpty) { - return KeyEventResult.ignored; - } - formatHighlight( - editorState, - editorState.editorStyle.highlightColorHex!, - ); - return KeyEventResult.handled; -}; - -ShortcutEventHandler formatLinkEventHandler = (editorState, event) { - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - final textNodes = nodes.whereType().toList(growable: false); - if (selection == null || textNodes.isEmpty) { - return KeyEventResult.ignored; - } - if (editorState.service.toolbarService - ?.triggerHandler('appflowy.toolbar.link') == - true) { - return KeyEventResult.handled; - } - return KeyEventResult.ignored; -}; - -ShortcutEventHandler formatEmbedCodeEventHandler = (editorState, event) { - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - final textNodes = nodes.whereType().toList(growable: false); - if (selection == null || textNodes.isEmpty) { - return KeyEventResult.ignored; - } - formatEmbedCode(editorState); - return KeyEventResult.ignored; -}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart deleted file mode 100644 index 94caff83b1082..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart +++ /dev/null @@ -1,440 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; -import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart'; - -import 'package:flutter/material.dart'; - -bool _isCodeStyle(TextNode textNode, int index) { - return textNode.allSatisfyCodeInSelection(Selection.single( - path: textNode.path, startOffset: index, endOffset: index + 1)); -} - -// enter escape mode when start two backquote -bool _isEscapeBackquote(String text, List backquoteIndexes) { - if (backquoteIndexes.length >= 2) { - final firstBackquoteIndex = backquoteIndexes[0]; - final secondBackquoteIndex = backquoteIndexes[1]; - return firstBackquoteIndex == secondBackquoteIndex - 1; - } - return false; -} - -// find all the index of `, exclusion in code style. -List _findBackquoteIndexes(String text, TextNode textNode) { - final backquoteIndexes = []; - for (var i = 0; i < text.length; i++) { - if (text[i] == '`' && _isCodeStyle(textNode, i) == false) { - backquoteIndexes.add(i); - } - } - return backquoteIndexes; -} - -/// To denote a word or phrase as code, enclose it in backticks (`). -/// If the word or phrase you want to denote as code includes one or more -/// backticks, you can escape it by enclosing the word or phrase in double -/// backticks (``). -ShortcutEventHandler backquoteToCodeHandler = (editorState, event) { - final selectionService = editorState.service.selectionService; - final selection = selectionService.currentSelection.value; - final textNodes = selectionService.currentSelectedNodes.whereType(); - - if (selection == null || !selection.isSingle || textNodes.length != 1) { - return KeyEventResult.ignored; - } - - final textNode = textNodes.first; - final selectionText = textNode - .toPlainText() - .substring(selection.start.offset, selection.end.offset); - - // toggle code style when selected some text - if (selectionText.isNotEmpty) { - formatEmbedCode(editorState); - return KeyEventResult.handled; - } - - final text = textNode.toPlainText().substring(0, selection.end.offset); - final backquoteIndexes = _findBackquoteIndexes(text, textNode); - if (backquoteIndexes.isEmpty) { - return KeyEventResult.ignored; - } - - final endIndex = selection.end.offset; - - if (_isEscapeBackquote(text, backquoteIndexes)) { - final firstBackquoteIndex = backquoteIndexes[0]; - final secondBackquoteIndex = backquoteIndexes[1]; - final lastBackquoteIndex = backquoteIndexes[backquoteIndexes.length - 1]; - if (secondBackquoteIndex == lastBackquoteIndex || - secondBackquoteIndex == lastBackquoteIndex - 1 || - lastBackquoteIndex != endIndex - 1) { - // ``(`),```(`),``...`...(`) should ignored - return KeyEventResult.ignored; - } - - final transaction = editorState.transaction - ..deleteText(textNode, lastBackquoteIndex, 1) - ..deleteText(textNode, firstBackquoteIndex, 2) - ..formatText( - textNode, - firstBackquoteIndex, - endIndex - firstBackquoteIndex - 3, - { - BuiltInAttributeKey.code: true, - }, - ) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: endIndex - 3, - ), - ); - editorState.apply(transaction); - - return KeyEventResult.handled; - } - - // handle single backquote - final startIndex = backquoteIndexes[0]; - if (startIndex == endIndex - 1) { - return KeyEventResult.ignored; - } - - // delete the backquote. - // update the style of the text surround by ` ` to code. - // and update the cursor position. - final transaction = editorState.transaction - ..deleteText(textNode, startIndex, 1) - ..formatText( - textNode, - startIndex, - endIndex - startIndex - 1, - { - BuiltInAttributeKey.code: true, - }, - ) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: endIndex - 1, - ), - ); - editorState.apply(transaction); - - return KeyEventResult.handled; -}; - -// convert ~~abc~~ to strikethrough abc. -ShortcutEventHandler doubleTildeToStrikethrough = (editorState, event) { - final selectionService = editorState.service.selectionService; - final selection = selectionService.currentSelection.value; - final textNodes = selectionService.currentSelectedNodes.whereType(); - if (selection == null || !selection.isSingle || textNodes.length != 1) { - return KeyEventResult.ignored; - } - - final textNode = textNodes.first; - final text = textNode.toPlainText().substring(0, selection.end.offset); - - // make sure the last two characters are ~~. - if (text.length < 2 || text[selection.end.offset - 1] != '~') { - return KeyEventResult.ignored; - } - - // find all the index of `~`. - final tildeIndexes = []; - for (var i = 0; i < text.length; i++) { - if (text[i] == '~') { - tildeIndexes.add(i); - } - } - - if (tildeIndexes.length < 3) { - return KeyEventResult.ignored; - } - - // make sure the second to last and third to last tildes are connected. - final thirdToLastTildeIndex = tildeIndexes[tildeIndexes.length - 3]; - final secondToLastTildeIndex = tildeIndexes[tildeIndexes.length - 2]; - final lastTildeIndex = tildeIndexes[tildeIndexes.length - 1]; - if (secondToLastTildeIndex != thirdToLastTildeIndex + 1 || - lastTildeIndex == secondToLastTildeIndex + 1) { - return KeyEventResult.ignored; - } - - // delete the last three tildes. - // update the style of the text surround by `~~ ~~` to strikethrough. - // and update the cursor position. - final transaction = editorState.transaction - ..deleteText(textNode, lastTildeIndex, 1) - ..deleteText(textNode, thirdToLastTildeIndex, 2) - ..formatText( - textNode, - thirdToLastTildeIndex, - selection.end.offset - thirdToLastTildeIndex - 2, - { - BuiltInAttributeKey.strikethrough: true, - }, - ) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: selection.end.offset - 3, - ), - ); - editorState.apply(transaction); - - return KeyEventResult.handled; -}; - -ShortcutEventHandler markdownLinkOrImageHandler = (editorState, event) { - final selectionService = editorState.service.selectionService; - final selection = selectionService.currentSelection.value; - final textNodes = selectionService.currentSelectedNodes.whereType(); - if (selection == null || !selection.isSingle || textNodes.length != 1) { - return KeyEventResult.ignored; - } - - // Find all of the indexes of the relevant characters - final textNode = textNodes.first; - final text = textNode.toPlainText(); - final firstExclamation = text.indexOf('!'); - final firstOpeningBracket = text.indexOf('['); - final firstClosingBracket = text.indexOf(']'); - - // Use RegEx to determine whether it's an image or a link - // Difference between image and link syntax is that image - // has an exclamation point at the beginning. - // Note: The RegEx enforces that the URL has http or https - final imgRegEx = - RegExp(r'\!\[([\w\s\d]+)\]\(((?:\/|https?:\/\/)[\w\d-./?=#%&]+)$'); - final lnkRegEx = - RegExp(r'\[([\w\s\d]+)\]\(((?:\/|https?:\/\/)[\w\d-./?=#%&]+)$'); - - if (imgRegEx.firstMatch(text) != null) { - // Extract the alt text and the URL of the image - final match = lnkRegEx.firstMatch(text); - final imgUrl = match?.group(2); - - // Delete the text and replace it with the image pointed to by the URL - final transaction = editorState.transaction - ..deleteText(textNode, firstExclamation, text.length) - ..insertNode( - textNode.path, - Node.fromJson({ - 'type': 'image', - 'attributes': { - 'image_src': imgUrl, - 'align': 'center', - } - })); - editorState.apply(transaction); - } else if (lnkRegEx.firstMatch(text) != null) { - // Extract the text and the URL of the link - final match = lnkRegEx.firstMatch(text); - final linkText = match?.group(1); - final linkUrl = match?.group(2); - - // Delete the initial opening bracket, - // update the href attribute of the text surrounded by [ ] to the url, - // delete everything after the text, - // and update the cursor position. - final transaction = editorState.transaction - ..deleteText(textNode, firstOpeningBracket, 1) - ..formatText( - textNode, - firstOpeningBracket, - firstClosingBracket - firstOpeningBracket - 1, - { - BuiltInAttributeKey.href: linkUrl, - }, - ) - ..deleteText(textNode, firstClosingBracket - 1, - selection.end.offset - firstClosingBracket) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: firstOpeningBracket + linkText!.length, - ), - ); - editorState.apply(transaction); - } else { - return KeyEventResult.ignored; - } - return KeyEventResult.handled; -}; - -// convert **abc** to bold abc. -ShortcutEventHandler doubleAsterisksToBold = (editorState, event) { - final selectionService = editorState.service.selectionService; - final selection = selectionService.currentSelection.value; - final textNodes = selectionService.currentSelectedNodes.whereType(); - if (selection == null || !selection.isSingle || textNodes.length != 1) { - return KeyEventResult.ignored; - } - - final textNode = textNodes.first; - final text = textNode.toPlainText().substring(0, selection.end.offset); - - // make sure the last two characters are **. - if (text.length < 2 || text[selection.end.offset - 1] != '*') { - return KeyEventResult.ignored; - } - - // find all the index of `*`. - final asteriskIndexes = []; - for (var i = 0; i < text.length; i++) { - if (text[i] == '*') { - asteriskIndexes.add(i); - } - } - - if (asteriskIndexes.length < 3) { - return KeyEventResult.ignored; - } - - // make sure the second to last and third to last asterisks are connected. - final thirdToLastAsteriskIndex = asteriskIndexes[asteriskIndexes.length - 3]; - final secondToLastAsteriskIndex = asteriskIndexes[asteriskIndexes.length - 2]; - final lastAsterisIndex = asteriskIndexes[asteriskIndexes.length - 1]; - if (secondToLastAsteriskIndex != thirdToLastAsteriskIndex + 1 || - lastAsterisIndex == secondToLastAsteriskIndex + 1) { - return KeyEventResult.ignored; - } - - // delete the last three asterisks. - // update the style of the text surround by `** **` to bold. - // and update the cursor position. - final transaction = editorState.transaction - ..deleteText(textNode, lastAsterisIndex, 1) - ..deleteText(textNode, thirdToLastAsteriskIndex, 2) - ..formatText( - textNode, - thirdToLastAsteriskIndex, - selection.end.offset - thirdToLastAsteriskIndex - 3, - { - BuiltInAttributeKey.bold: true, - BuiltInAttributeKey.defaultFormating: true, - }, - ) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: selection.end.offset - 3, - ), - ); - editorState.apply(transaction); - - return KeyEventResult.handled; -}; - -// convert __abc__ to bold abc. -ShortcutEventHandler doubleUnderscoresToBold = (editorState, event) { - final selectionService = editorState.service.selectionService; - final selection = selectionService.currentSelection.value; - final textNodes = selectionService.currentSelectedNodes.whereType(); - if (selection == null || !selection.isSingle || textNodes.length != 1) { - return KeyEventResult.ignored; - } - - final textNode = textNodes.first; - final text = textNode.toPlainText().substring(0, selection.end.offset); - - // make sure the last two characters are __. - if (text.length < 2 || text[selection.end.offset - 1] != '_') { - return KeyEventResult.ignored; - } - - // find all the index of `_`. - final underscoreIndexes = []; - for (var i = 0; i < text.length; i++) { - if (text[i] == '_') { - underscoreIndexes.add(i); - } - } - - if (underscoreIndexes.length < 3) { - return KeyEventResult.ignored; - } - - // make sure the second to last and third to last underscores are connected. - final thirdToLastUnderscoreIndex = - underscoreIndexes[underscoreIndexes.length - 3]; - final secondToLastUnderscoreIndex = - underscoreIndexes[underscoreIndexes.length - 2]; - final lastAsterisIndex = underscoreIndexes[underscoreIndexes.length - 1]; - if (secondToLastUnderscoreIndex != thirdToLastUnderscoreIndex + 1 || - lastAsterisIndex == secondToLastUnderscoreIndex + 1) { - return KeyEventResult.ignored; - } - - // delete the last three underscores. - // update the style of the text surround by `__ __` to bold. - // and update the cursor position. - final transaction = editorState.transaction - ..deleteText(textNode, lastAsterisIndex, 1) - ..deleteText(textNode, thirdToLastUnderscoreIndex, 2) - ..formatText( - textNode, - thirdToLastUnderscoreIndex, - selection.end.offset - thirdToLastUnderscoreIndex - 3, - { - BuiltInAttributeKey.bold: true, - BuiltInAttributeKey.defaultFormating: true, - }, - ) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: selection.end.offset - 3, - ), - ); - editorState.apply(transaction); - return KeyEventResult.handled; -}; - -ShortcutEventHandler underscoreToItalicHandler = (editorState, event) { - // Obtain the selection and selected nodes of the current document through the 'selectionService' - // to determine whether the selection is collapsed and whether the selected node is a text node. - final selectionService = editorState.service.selectionService; - final selection = selectionService.currentSelection.value; - final textNodes = selectionService.currentSelectedNodes.whereType(); - if (selection == null || !selection.isSingle || textNodes.length != 1) { - return KeyEventResult.ignored; - } - - final textNode = textNodes.first; - final text = textNode.toPlainText(); - // Determine if an 'underscore' already exists in the text node and only once. - final firstUnderscore = text.indexOf('_'); - final lastUnderscore = text.lastIndexOf('_'); - if (firstUnderscore == -1 || - firstUnderscore != lastUnderscore || - firstUnderscore == selection.start.offset - 1) { - return KeyEventResult.ignored; - } - - // Delete the previous 'underscore', - // update the style of the text surrounded by the two underscores to 'italic', - // and update the cursor position. - final transaction = editorState.transaction - ..deleteText(textNode, firstUnderscore, 1) - ..formatText( - textNode, - firstUnderscore, - selection.end.offset - firstUnderscore - 1, - { - BuiltInAttributeKey.italic: true, - }, - ) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: selection.end.offset - 1, - ), - ); - editorState.apply(transaction); - - return KeyEventResult.handled; -}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/number_list_helper.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/number_list_helper.dart deleted file mode 100644 index d4d14bc204791..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/number_list_helper.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart'; -import 'package:appflowy_editor/src/core/document/attributes.dart'; - -void makeFollowingNodesIncremental( - EditorState editorState, List insertPath, Selection afterSelection, - {int? beginNum}) { - final insertNode = editorState.document.nodeAtPath(insertPath); - if (insertNode == null) { - return; - } - beginNum ??= insertNode.attributes[BuiltInAttributeKey.number] as int; - - int numPtr = beginNum + 1; - var ptr = insertNode.next; - - final transaction = editorState.transaction; - - while (ptr != null) { - if (ptr.subtype != BuiltInAttributeKey.numberList) { - break; - } - final currentNum = ptr.attributes[BuiltInAttributeKey.number] as int; - if (currentNum != numPtr) { - Attributes updateAttributes = {}; - updateAttributes[BuiltInAttributeKey.number] = numPtr; - transaction.updateNode(ptr, updateAttributes); - } - - ptr = ptr.next; - numPtr++; - } - - transaction.afterSelection = afterSelection; - editorState.apply(transaction); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/page_up_down_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/page_up_down_handler.dart deleted file mode 100644 index f10559ff2569b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/page_up_down_handler.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart'; - -ShortcutEventHandler pageUpHandler = (editorState, _) { - final scrollHeight = editorState.service.scrollService?.onePageHeight; - final scrollService = editorState.service.scrollService; - if (scrollHeight != null && scrollService != null) { - scrollService.scrollTo(scrollService.dy - scrollHeight); - } - return KeyEventResult.handled; -}; - -ShortcutEventHandler pageDownHandler = (editorState, _) { - final scrollHeight = editorState.service.scrollService?.onePageHeight; - final scrollService = editorState.service.scrollService; - if (scrollHeight != null && scrollService != null) { - scrollService.scrollTo(scrollService.dy + scrollHeight); - } - return KeyEventResult.handled; -}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/redo_undo_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/redo_undo_handler.dart deleted file mode 100644 index e20d6dc43d009..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/redo_undo_handler.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart'; - -ShortcutEventHandler redoEventHandler = (editorState, event) { - editorState.undoManager.redo(); - return KeyEventResult.handled; -}; - -ShortcutEventHandler undoEventHandler = (editorState, event) { - editorState.undoManager.undo(); - return KeyEventResult.handled; -}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/select_all_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/select_all_handler.dart deleted file mode 100644 index 800877b435b38..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/select_all_handler.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/location/position.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart'; -import 'package:flutter/material.dart'; - -ShortcutEventHandler selectAllHandler = (editorState, event) { - if (editorState.document.root.children.isEmpty) { - return KeyEventResult.handled; - } - final firstNode = editorState.document.root.children.first; - final lastNode = editorState.document.root.children.last; - var offset = 0; - if (lastNode is TextNode) { - offset = lastNode.delta.length; - } - editorState.updateCursorSelection( - Selection( - start: Position(path: firstNode.path, offset: 0), - end: Position(path: lastNode.path, offset: offset), - ), - ); - return KeyEventResult.handled; -}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart deleted file mode 100644 index 9fa7eacce6c6f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/transform/transaction.dart'; -import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart'; -import 'package:appflowy_editor/src/extensions/node_extensions.dart'; -import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart'; -import 'package:flutter/material.dart'; - -SelectionMenuService? _selectionMenuService; -ShortcutEventHandler slashShortcutHandler = (editorState, event) { - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); - if (textNodes.length != 1) { - return KeyEventResult.ignored; - } - - final selection = editorState.service.selectionService.currentSelection.value; - final textNode = textNodes.first; - final context = textNode.context; - final selectable = textNode.selectable; - if (selection == null || context == null || selectable == null) { - return KeyEventResult.ignored; - } - final transaction = editorState.transaction - ..replaceText( - textNode, - selection.start.offset, - selection.end.offset - selection.start.offset, - '/', - ); - editorState.apply(transaction); - - WidgetsBinding.instance.addPostFrameCallback((_) { - _selectionMenuService = - SelectionMenu(context: context, editorState: editorState); - _selectionMenuService?.show(); - }); - - return KeyEventResult.handled; -}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart deleted file mode 100644 index a74e3e3c96d82..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/commands/text/text_commands.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -ShortcutEventHandler spaceOnWebHandler = (editorState, event) { - final selection = editorState.service.selectionService.currentSelection.value; - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType() - .toList(growable: false); - if (selection == null || - !selection.isCollapsed || - !kIsWeb || - textNodes.length != 1) { - return KeyEventResult.ignored; - } - - editorState.insertText( - selection.startIndex, - ' ', - textNode: textNodes.first, - ); - - return KeyEventResult.handled; -}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart deleted file mode 100644 index 3b3091bbb238f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; - -ShortcutEventHandler tabHandler = (editorState, event) { - // Only Supports BulletedList For Now. - - final selection = editorState.service.selectionService.currentSelection.value; - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); - if (textNodes.length != 1 || selection == null || !selection.isSingle) { - return KeyEventResult.ignored; - } - - final textNode = textNodes.first; - final previous = textNode.previous; - - if (textNode.subtype != BuiltInAttributeKey.bulletedList) { - final transaction = editorState.transaction - ..insertText(textNode, selection.end.offset, ' ' * 4); - editorState.apply(transaction); - return KeyEventResult.handled; - } - - if (previous == null || - previous.subtype != BuiltInAttributeKey.bulletedList) { - return KeyEventResult.ignored; - } - - final path = previous.path + [previous.children.length]; - final afterSelection = Selection( - start: selection.start.copyWith(path: path), - end: selection.end.copyWith(path: path), - ); - final transaction = editorState.transaction - ..deleteNode(textNode) - ..insertNode(path, textNode) - ..afterSelection = afterSelection; - editorState.apply(transaction); - - return KeyEventResult.handled; -}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart deleted file mode 100644 index 8732c0f0eae87..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'package:appflowy_editor/src/core/transform/transaction.dart'; -import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart'; -import 'package:flutter/material.dart'; -import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart'; -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/location/position.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import './number_list_helper.dart'; -import 'package:appflowy_editor/src/extensions/attributes_extension.dart'; - -@visibleForTesting -List get checkboxListSymbols => _checkboxListSymbols; -@visibleForTesting -List get unCheckboxListSymbols => _unCheckboxListSymbols; -@visibleForTesting -List get bulletedListSymbols => _bulletedListSymbols; - -const _bulletedListSymbols = ['*', '-']; -const _checkboxListSymbols = ['[x]', '-[x]']; -const _unCheckboxListSymbols = ['[]', '-[]']; - -final _numberRegex = RegExp(r'^(\d+)\.'); - -ShortcutEventHandler whiteSpaceHandler = (editorState, event) { - /// Process markdown input style. - /// - /// like, #, *, -, 1., -[], - - final selection = editorState.service.selectionService.currentSelection.value; - if (selection == null || !selection.isCollapsed) { - return KeyEventResult.ignored; - } - - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); - if (textNodes.length != 1) { - return KeyEventResult.ignored; - } - - final textNode = textNodes.first; - final text = textNode.toPlainText().substring(0, selection.end.offset); - - final numberMatch = _numberRegex.firstMatch(text); - - if ((_checkboxListSymbols + _unCheckboxListSymbols).contains(text)) { - return _toCheckboxList(editorState, textNode); - } else if (_bulletedListSymbols.contains(text)) { - return _toBulletedList(editorState, textNode); - } else if (_countOfSign(text, selection) != 0) { - return _toHeadingStyle(editorState, textNode, selection); - } else if (numberMatch != null) { - final matchText = numberMatch.group(0); - final numText = numberMatch.group(1); - if (matchText != null && numText != null) { - return _toNumberList(editorState, textNode, matchText, numText); - } - } - - return KeyEventResult.ignored; -}; - -KeyEventResult _toNumberList(EditorState editorState, TextNode textNode, - String matchText, String numText) { - if (textNode.subtype == BuiltInAttributeKey.bulletedList) { - return KeyEventResult.ignored; - } - - final numValue = int.tryParse(numText); - if (numValue == null) { - return KeyEventResult.ignored; - } - - // The user types number + . + space, he wants to turn - // this line into number list, but we should check if previous line - // is number list. - // - // Check whether the number input by the user is the successor of the previous - // line. If it's not, ignore it. - final prevNode = textNode.previous; - if (prevNode != null && - prevNode is TextNode && - prevNode.attributes[BuiltInAttributeKey.subtype] == - BuiltInAttributeKey.numberList) { - final prevNumber = prevNode.attributes[BuiltInAttributeKey.number] as int; - if (numValue != prevNumber + 1) { - return KeyEventResult.ignored; - } - } - - final afterSelection = Selection.collapsed(Position( - path: textNode.path, - offset: 0, - )); - - final insertPath = textNode.path; - final transaction = editorState.transaction - ..deleteText(textNode, 0, matchText.length) - ..updateNode(textNode, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.numberList, - BuiltInAttributeKey.number: numValue - }) - ..afterSelection = afterSelection; - editorState.apply(transaction); - - makeFollowingNodesIncremental(editorState, insertPath, afterSelection); - - return KeyEventResult.handled; -} - -KeyEventResult _toBulletedList(EditorState editorState, TextNode textNode) { - if (textNode.subtype == BuiltInAttributeKey.bulletedList) { - return KeyEventResult.ignored; - } - final transaction = editorState.transaction - ..deleteText(textNode, 0, 1) - ..updateNode(textNode, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList, - }) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: 0, - ), - ); - editorState.apply(transaction); - return KeyEventResult.handled; -} - -KeyEventResult _toCheckboxList(EditorState editorState, TextNode textNode) { - if (textNode.subtype == BuiltInAttributeKey.checkbox) { - return KeyEventResult.ignored; - } - final String symbol; - bool check = false; - final symbols = List.from(_checkboxListSymbols) - ..retainWhere(textNode.toPlainText().startsWith); - if (symbols.isNotEmpty) { - symbol = symbols.first; - check = true; - } else { - symbol = (List.from(_unCheckboxListSymbols) - ..retainWhere(textNode.toPlainText().startsWith)) - .first; - check = false; - } - - final transaction = editorState.transaction - ..deleteText(textNode, 0, symbol.length) - ..updateNode(textNode, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, - BuiltInAttributeKey.checkbox: check, - }) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: 0, - ), - ); - editorState.apply(transaction); - return KeyEventResult.handled; -} - -KeyEventResult _toHeadingStyle( - EditorState editorState, TextNode textNode, Selection selection) { - final x = _countOfSign( - textNode.toPlainText(), - selection, - ); - final hX = 'h$x'; - if (textNode.attributes.heading == hX) { - return KeyEventResult.ignored; - } - final transaction = editorState.transaction - ..deleteText(textNode, 0, x) - ..updateNode(textNode, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading, - BuiltInAttributeKey.heading: hX, - }) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: 0, - ), - ); - editorState.apply(transaction); - return KeyEventResult.handled; -} - -int _countOfSign(String text, Selection selection) { - for (var i = 6; i >= 0; i--) { - final heading = text.substring(0, selection.end.offset); - if (heading.contains('#' * i) && heading.length == i) { - return i; - } - } - return 0; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart deleted file mode 100644 index d5154bc2b544c..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart +++ /dev/null @@ -1,148 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/services.dart'; - -import 'package:flutter/material.dart'; - -/// [AppFlowyKeyboardService] is responsible for processing shortcut keys, -/// like command, shift, control keys. -/// -/// Usually, this service can be obtained by the following code. -/// ```dart -/// final keyboardService = editorState.service.keyboardService; -/// -/// /** Simulates shortcut key input*/ -/// keyboardService?.onKey(...); -/// -/// /** Enables or disables this service */ -/// keyboardService?.enable(); -/// keyboardService?.disable(); -/// ``` -/// -abstract class AppFlowyKeyboardService { - /// Processes shortcut key input. - KeyEventResult onKey(RawKeyEvent event); - - /// Gets the shortcut events - List get shortcutEvents; - - /// Enables shortcuts service. - void enable(); - - /// Disables shortcuts service. - /// - /// In some cases, if your custom component needs to monitor - /// keyboard events separately, - /// you can disable the keyboard service of flowy_editor. - /// But you need to call the `enable` function to restore after exiting - /// your custom component, otherwise the keyboard service will fails. - void disable(); -} - -/// Process keyboard events -class AppFlowyKeyboard extends StatefulWidget { - const AppFlowyKeyboard({ - Key? key, - this.editable = true, - required this.shortcutEvents, - required this.editorState, - required this.child, - }) : super(key: key); - - final EditorState editorState; - final Widget child; - final List shortcutEvents; - final bool editable; - - @override - State createState() => _AppFlowyKeyboardState(); -} - -class _AppFlowyKeyboardState extends State - implements AppFlowyKeyboardService { - final FocusNode _focusNode = FocusNode(debugLabel: 'flowy_keyboard_service'); - - bool isFocus = true; - - @override - List get shortcutEvents => widget.shortcutEvents; - - @override - Widget build(BuildContext context) { - return Focus( - focusNode: _focusNode, - onKey: _onKey, - onFocusChange: _onFocusChange, - child: widget.child, - ); - } - - @override - void initState() { - super.initState(); - - enable(); - } - - @override - void dispose() { - _focusNode.dispose(); - - super.dispose(); - } - - @override - void enable() { - if (widget.editable) { - isFocus = true; - _focusNode.requestFocus(); - } else { - disable(); - } - } - - @override - void disable() { - isFocus = false; - _focusNode.unfocus(); - } - - @override - KeyEventResult onKey(RawKeyEvent event) { - if (!isFocus) { - return KeyEventResult.ignored; - } - - Log.keyboard.debug('on keyboard event $event'); - - if (event is! RawKeyDownEvent) { - return KeyEventResult.ignored; - } - - // TODO: use cache to optimize the searching time. - for (final shortcutEvent in widget.shortcutEvents) { - if (shortcutEvent.keybindings.containsKeyEvent(event)) { - final result = shortcutEvent.handler(widget.editorState, event); - if (result == KeyEventResult.handled) { - return KeyEventResult.handled; - } else if (result == KeyEventResult.skipRemainingHandlers) { - return KeyEventResult.skipRemainingHandlers; - } - continue; - } - } - - return KeyEventResult.ignored; - } - - void _onFocusChange(bool value) { - Log.keyboard.debug('on keyboard event focus change $value'); - isFocus = value; - if (!value) { - widget.editorState.service.selectionService.clearCursor(); - } - } - - KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { - return onKey(event); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart deleted file mode 100644 index 38f4b9f8b30a8..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/infra/log.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -typedef NodeValidator = bool Function(T node); - -abstract class NodeWidgetBuilder { - NodeValidator get nodeValidator; - - Widget build(NodeWidgetContext context); -} - -typedef NodeWidgetBuilders = Map; - -abstract class AppFlowyRenderPluginService { - /// Register render plugin with specified [name]. - /// - /// [name] should be [Node].type - /// or `[Node].type + '/' + [Node].attributes['subtype']`. - /// - /// e.g. 'text', 'text/checkbox', or 'text/heading' - /// - /// [name] could be empty. - void register(String name, NodeWidgetBuilder builder); - void registerAll(Map builders); - - /// UnRegister plugin with specified [name]. - void unRegister(String name); - - Widget buildPluginWidget(NodeWidgetContext context); -} - -class NodeWidgetContext { - final BuildContext context; - final T node; - final EditorState editorState; - - NodeWidgetContext({ - required this.context, - required this.node, - required this.editorState, - }); - - NodeWidgetContext copyWith({ - BuildContext? context, - T? node, - EditorState? editorState, - }) { - return NodeWidgetContext( - context: context ?? this.context, - node: node ?? this.node, - editorState: editorState ?? this.editorState, - ); - } -} - -class AppFlowyRenderPlugin extends AppFlowyRenderPluginService { - AppFlowyRenderPlugin({ - required this.editorState, - required NodeWidgetBuilders builders, - }) { - registerAll(builders); - } - - final NodeWidgetBuilders _builders = {}; - final EditorState editorState; - - @override - Widget buildPluginWidget(NodeWidgetContext context) { - final node = context.node; - final name = - node.subtype == null ? node.type : '${node.type}/${node.subtype!}'; - final builder = _builders[name]; - if (builder != null && builder.nodeValidator(node)) { - final key = GlobalKey(debugLabel: name); - node.key = key; - return _autoUpdateNodeWidget(builder, context); - } else { - // Returns a SizeBox with 0 height if no builder found. - return const SizedBox( - height: 0, - ); - } - } - - @override - void register(String name, NodeWidgetBuilder builder) { - Log.editor.info('registers plugin($name)...'); - _validatePlugin(name); - _builders[name] = builder; - } - - @override - void registerAll(Map builders) { - builders.forEach(register); - } - - @override - void unRegister(String name) { - _validatePlugin(name); - _builders.remove(name); - } - - Widget _autoUpdateNodeWidget( - NodeWidgetBuilder builder, NodeWidgetContext context) { - Widget notifier; - if (context.node is TextNode) { - notifier = ChangeNotifierProvider.value( - value: context.node as TextNode, - builder: (_, child) { - return Consumer( - builder: ((_, value, child) { - Log.ui.debug('TextNode is rebuilding...'); - return builder.build(context); - }), - ); - }); - } else { - notifier = ChangeNotifierProvider.value( - value: context.node, - builder: (_, child) { - return Consumer( - builder: ((_, value, child) { - Log.ui.debug('Node is rebuilding...'); - return builder.build(context); - }), - ); - }); - } - return CompositedTransformTarget( - link: context.node.layerLink, - child: notifier, - ); - } - - void _validatePlugin(String name) { - final paths = name.split('/'); - if (paths.length > 2) { - throw Exception('Plugin name must contain at most one or zero slash'); - } - if (_builders.containsKey(name)) { - throw Exception('Plugin name($name) already exists.'); - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/scroll_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/scroll_service.dart deleted file mode 100644 index f00edc4a56c2d..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/scroll_service.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:appflowy_editor/src/infra/log.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:appflowy_editor/src/extensions/object_extensions.dart'; - -/// [AppFlowyScrollService] is responsible for processing document scrolling. -/// -/// Usually, this service can be obtained by the following code. -/// ```dart -/// final keyboardService = editorState.service.scrollService; -/// ``` -/// -abstract class AppFlowyScrollService { - /// Returns the offset of the current document on the vertical axis. - double get dy; - - /// Returns the height of the current document. - double? get onePageHeight; - - /// Returns the number of pages in the current document. - int? get page; - - /// Returns the maximum scroll height on the vertical axis. - double get maxScrollExtent; - - /// Returns the minimum scroll height on the vertical axis. - double get minScrollExtent; - - /// Scrolls to the specified position. - /// - /// This function will filter illegal values. - /// Only within the range of minScrollExtent and maxScrollExtent are legal values. - void scrollTo(double dy); - - /// Enables scroll service. - void enable(); - - /// Disables scroll service. - /// - /// In some cases, you can disable scroll service of flowy_editor - /// when your custom component appears, - /// - /// But you need to call the `enable` function to restore after exiting - /// your custom component, otherwise the scroll service will fails. - void disable(); -} - -class AppFlowyScroll extends StatefulWidget { - const AppFlowyScroll({ - Key? key, - required this.child, - }) : super(key: key); - - final Widget child; - - @override - State createState() => _AppFlowyScrollState(); -} - -class _AppFlowyScrollState extends State - implements AppFlowyScrollService { - final _scrollController = ScrollController(); - final _scrollViewKey = GlobalKey(); - - bool _scrollEnabled = true; - - @override - double get dy => _scrollController.position.pixels; - - @override - double? get onePageHeight { - final renderBox = context.findRenderObject()?.unwrapOrNull(); - return renderBox?.size.height; - } - - @override - double get maxScrollExtent => _scrollController.position.maxScrollExtent; - - @override - double get minScrollExtent => _scrollController.position.minScrollExtent; - - @override - int? get page { - if (onePageHeight != null) { - final scrollExtent = maxScrollExtent - minScrollExtent; - return (scrollExtent / onePageHeight!).ceil(); - } - return null; - } - - @override - Widget build(BuildContext context) { - return Listener( - onPointerSignal: _onPointerSignal, - child: CustomScrollView( - key: _scrollViewKey, - physics: const NeverScrollableScrollPhysics(), - controller: _scrollController, - slivers: [ - SliverFillRemaining( - hasScrollBody: false, - child: widget.child, - ) - ], - ), - ); - } - - @override - void scrollTo(double dy) { - _scrollController.position.jumpTo( - dy.clamp( - _scrollController.position.minScrollExtent, - _scrollController.position.maxScrollExtent, - ), - ); - } - - @override - void disable() { - _scrollEnabled = false; - Log.scroll.debug('disable scroll service'); - } - - @override - void enable() { - _scrollEnabled = true; - Log.scroll.debug('enable scroll service'); - } - - void _onPointerSignal(PointerSignalEvent event) { - if (event is PointerScrollEvent && _scrollEnabled) { - final dy = (_scrollController.position.pixels + event.scrollDelta.dy); - scrollTo(dy); - } - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection/selection_gesture.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection/selection_gesture.dart deleted file mode 100644 index e233b35446e3c..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection/selection_gesture.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; - -/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer] -/// for a while. So we need to implement our own GestureDetector. -@immutable -class SelectionGestureDetector extends StatefulWidget { - const SelectionGestureDetector({ - Key? key, - this.child, - this.onTapDown, - this.onDoubleTapDown, - this.onTripleTapDown, - this.onSecondaryTapDown, - this.onPanStart, - this.onPanUpdate, - this.onPanEnd, - }) : super(key: key); - - @override - State createState() => - SelectionGestureDetectorState(); - - final Widget? child; - - final GestureTapDownCallback? onTapDown; - final GestureTapDownCallback? onDoubleTapDown; - final GestureTapDownCallback? onTripleTapDown; - final GestureTapDownCallback? onSecondaryTapDown; - final GestureDragStartCallback? onPanStart; - final GestureDragUpdateCallback? onPanUpdate; - final GestureDragEndCallback? onPanEnd; -} - -class SelectionGestureDetectorState extends State { - bool _isDoubleTap = false; - Timer? _doubleTapTimer; - int _tripleTabCount = 0; - Timer? _tripleTabTimer; - - final kTripleTapTimeout = const Duration(milliseconds: 500); - - @override - Widget build(BuildContext context) { - return RawGestureDetector( - behavior: HitTestBehavior.translucent, - gestures: { - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), - (recognizer) { - recognizer - ..onStart = widget.onPanStart - ..onUpdate = widget.onPanUpdate - ..onEnd = widget.onPanEnd; - }, - ), - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(), - (recognizer) { - recognizer.onTapDown = _tapDownDelegate; - recognizer.onSecondaryTapDown = widget.onSecondaryTapDown; - }, - ), - }, - child: widget.child, - ); - } - - _tapDownDelegate(TapDownDetails tapDownDetails) { - if (_tripleTabCount == 2) { - _tripleTabCount = 0; - _tripleTabTimer?.cancel(); - _tripleTabTimer = null; - if (widget.onTripleTapDown != null) { - widget.onTripleTapDown!(tapDownDetails); - } - } else if (_isDoubleTap) { - _isDoubleTap = false; - _doubleTapTimer?.cancel(); - _doubleTapTimer = null; - if (widget.onDoubleTapDown != null) { - widget.onDoubleTapDown!(tapDownDetails); - } - _tripleTabCount++; - } else { - if (widget.onTapDown != null) { - widget.onTapDown!(tapDownDetails); - } - - _isDoubleTap = true; - _doubleTapTimer?.cancel(); - _doubleTapTimer = Timer(kDoubleTapTimeout, () { - _isDoubleTap = false; - _doubleTapTimer = null; - }); - - _tripleTabCount = 1; - _tripleTabTimer?.cancel(); - _tripleTabTimer = Timer(kTripleTapTimeout, () { - _tripleTabCount = 0; - _tripleTabTimer = null; - }); - } - } - - @override - void dispose() { - _doubleTapTimer?.cancel(); - _tripleTabTimer?.cancel(); - super.dispose(); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart deleted file mode 100644 index dc0ef59203277..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart +++ /dev/null @@ -1,636 +0,0 @@ -import 'package:appflowy_editor/src/flutter/overlay.dart'; -import 'package:appflowy_editor/src/infra/log.dart'; -import 'package:appflowy_editor/src/service/context_menu/built_in_context_menu_item.dart'; -import 'package:appflowy_editor/src/service/context_menu/context_menu.dart'; -import 'package:flutter/material.dart' hide Overlay, OverlayEntry; - -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/core/document/node_iterator.dart'; -import 'package:appflowy_editor/src/core/document/path.dart'; -import 'package:appflowy_editor/src/core/location/position.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/extensions/node_extensions.dart'; -import 'package:appflowy_editor/src/extensions/object_extensions.dart'; -import 'package:appflowy_editor/src/render/selection/cursor_widget.dart'; -import 'package:appflowy_editor/src/render/selection/selectable.dart'; -import 'package:appflowy_editor/src/render/selection/selection_widget.dart'; -import 'package:appflowy_editor/src/service/selection/selection_gesture.dart'; - -/// [AppFlowySelectionService] is responsible for processing -/// the [Selection] changes and updates. -/// -/// Usually, this service can be obtained by the following code. -/// ```dart -/// final selectionService = editorState.service.selectionService; -/// -/// /** get current selection value*/ -/// final selection = selectionService.currentSelection.value; -/// -/// /** get current selected nodes*/ -/// final nodes = selectionService.currentSelectedNodes; -/// ``` -/// -abstract class AppFlowySelectionService { - /// The current [Selection] in editor. - /// - /// The value is null if there is no nodes are selected. - ValueNotifier get currentSelection; - - /// The current selected [Node]s in editor. - /// - /// The order of the result is determined according to the [currentSelection]. - /// The result are ordered from back to front if the selection is forward. - /// The result are ordered from front to back if the selection is backward. - /// - /// For example, Here is an array of selected nodes, `[n1, n2, n3]`. - /// The result will be `[n3, n2, n1]` if the selection is forward, - /// and `[n1, n2, n3]` if the selection is backward. - /// - /// Returns empty result if there is no nodes are selected. - List get currentSelectedNodes; - - /// Updates the selection. - /// - /// The editor will update selection area and toolbar area - /// if the [selection] is not collapsed, - /// otherwise, will update the cursor area. - void updateSelection(Selection? selection); - - /// Clears the selection area, cursor area and the popup list area. - void clearSelection(); - - /// Clears the cursor area. - void clearCursor(); - - /// Returns the [Node]s in [Selection]. - List getNodesInSelection(Selection selection); - - /// Returns the [Node] containing to the [offset]. - /// - /// [offset] must be under the global coordinate system. - Node? getNodeInOffset(Offset offset); - - /// Returns the [Position] closest to the [offset]. - /// - /// Returns null if there is no nodes are selected. - /// - /// [offset] must be under the global coordinate system. - Position? getPositionInOffset(Offset offset); - - /// The current selection areas's rect in editor. - List get selectionRects; -} - -class AppFlowySelection extends StatefulWidget { - const AppFlowySelection({ - Key? key, - this.cursorColor = const Color(0xFF00BCF0), - this.selectionColor = const Color.fromARGB(53, 111, 201, 231), - this.editable = true, - required this.editorState, - required this.child, - }) : super(key: key); - - final EditorState editorState; - final Widget child; - final Color cursorColor; - final Color selectionColor; - final bool editable; - - @override - State createState() => _AppFlowySelectionState(); -} - -class _AppFlowySelectionState extends State - with WidgetsBindingObserver - implements AppFlowySelectionService { - final _cursorKey = GlobalKey(debugLabel: 'cursor'); - - @override - final List selectionRects = []; - final List _selectionAreas = []; - final List _cursorAreas = []; - final List _contextMenuAreas = []; - - // OverlayEntry? _debugOverlay; - - /// Pan - Offset? _panStartOffset; - double? _panStartScrollDy; - - EditorState get editorState => widget.editorState; - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addObserver(this); - currentSelection.addListener(_onSelectionChange); - } - - @override - void didChangeMetrics() { - super.didChangeMetrics(); - - // Need to refresh the selection when the metrics changed. - if (currentSelection.value != null) { - updateSelection(currentSelection.value!); - } - } - - @override - void dispose() { - clearSelection(); - WidgetsBinding.instance.removeObserver(this); - currentSelection.removeListener(_onSelectionChange); - - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!widget.editable) { - return Container( - child: widget.child, - ); - } else { - return SelectionGestureDetector( - onPanStart: _onPanStart, - onPanUpdate: _onPanUpdate, - onPanEnd: _onPanEnd, - onTapDown: _onTapDown, - onSecondaryTapDown: _onSecondaryTapDown, - onDoubleTapDown: _onDoubleTapDown, - onTripleTapDown: _onTripleTapDown, - child: widget.child, - ); - } - } - - @override - ValueNotifier currentSelection = ValueNotifier(null); - - @override - List currentSelectedNodes = []; - - @override - List getNodesInSelection(Selection selection) { - final start = - selection.isBackward ? selection.start.path : selection.end.path; - final end = - selection.isBackward ? selection.end.path : selection.start.path; - assert(start <= end); - final startNode = editorState.document.nodeAtPath(start); - final endNode = editorState.document.nodeAtPath(end); - if (startNode != null && endNode != null) { - final nodes = NodeIterator( - document: editorState.document, - startNode: startNode, - endNode: endNode, - ).toList(); - if (selection.isBackward) { - return nodes; - } else { - return nodes.reversed.toList(growable: false); - } - } - return []; - } - - @override - void updateSelection(Selection? selection) { - if (!widget.editable) { - return; - } - - selectionRects.clear(); - clearSelection(); - - if (selection != null) { - if (selection.isCollapsed) { - // updates cursor area. - Log.selection.debug('update cursor area, $selection'); - _updateCursorAreas(selection.start); - } else { - // updates selection area. - Log.selection.debug('update cursor area, $selection'); - _updateSelectionAreas(selection); - } - } - - currentSelection.value = selection; - editorState.updateCursorSelection(selection, CursorUpdateReason.uiEvent); - } - - @override - void clearSelection() { - currentSelectedNodes = []; - currentSelection.value = null; - - clearCursor(); - // clear selection areas - _selectionAreas - ..forEach((overlay) => overlay.remove()) - ..clear(); - // clear cursor areas - - // hide toolbar - editorState.service.toolbarService?.hide(); - - // clear context menu - _clearContextMenu(); - } - - @override - void clearCursor() { - // clear cursor areas - _cursorAreas - ..forEach((overlay) => overlay.remove()) - ..clear(); - } - - void _clearContextMenu() { - _contextMenuAreas - ..forEach((overlay) => overlay.remove()) - ..clear(); - } - - @override - Node? getNodeInOffset(Offset offset) { - final sortedNodes = - editorState.document.root.children.toList(growable: false); - return _getNodeInOffset( - sortedNodes, - offset, - 0, - sortedNodes.length - 1, - ); - } - - @override - Position? getPositionInOffset(Offset offset) { - final node = getNodeInOffset(offset); - final selectable = node?.selectable; - if (selectable == null) { - clearSelection(); - return null; - } - return selectable.getPositionInOffset(offset); - } - - void _onTapDown(TapDownDetails details) { - // clear old state. - _panStartOffset = null; - - final position = getPositionInOffset(details.globalPosition); - if (position == null) { - return; - } - final selection = Selection.collapsed(position); - updateSelection(selection); - - _enableInteraction(); - - _showDebugLayerIfNeeded(offset: details.globalPosition); - } - - void _onDoubleTapDown(TapDownDetails details) { - final offset = details.globalPosition; - final node = getNodeInOffset(offset); - final selection = node?.selectable?.getWorldBoundaryInOffset(offset); - if (selection == null) { - clearSelection(); - return; - } - updateSelection(selection); - - _enableInteraction(); - } - - void _onTripleTapDown(TapDownDetails details) { - final offset = details.globalPosition; - final node = getNodeInOffset(offset); - final selectable = node?.selectable; - if (selectable == null) { - clearSelection(); - return; - } - Selection selection = Selection( - start: selectable.start(), - end: selectable.end(), - ); - updateSelection(selection); - - _enableInteraction(); - } - - void _onSecondaryTapDown(TapDownDetails details) { - // if selection is null, or - // selection.isCollapsedand and the selected node is TextNode. - // try to select the word. - final selection = currentSelection.value; - if (selection == null || - (selection.isCollapsed == true && - currentSelectedNodes.first is TextNode)) { - _onDoubleTapDown(details); - } - - _showContextMenu(details); - } - - void _onPanStart(DragStartDetails details) { - clearSelection(); - - _panStartOffset = details.globalPosition; - _panStartScrollDy = editorState.service.scrollService?.dy; - - _enableInteraction(); - } - - void _onPanUpdate(DragUpdateDetails details) { - if (_panStartOffset == null || _panStartScrollDy == null) { - return; - } - - _enableInteraction(); - - final panEndOffset = details.globalPosition; - final dy = editorState.service.scrollService?.dy; - final panStartOffset = dy == null - ? _panStartOffset! - : _panStartOffset!.translate(0, _panStartScrollDy! - dy); - - final first = getNodeInOffset(panStartOffset)?.selectable; - final last = getNodeInOffset(panEndOffset)?.selectable; - - // compute the selection in range. - if (first != null && last != null) { - Log.selection.debug('first = $first, last = $last'); - final start = - first.getSelectionInRange(panStartOffset, panEndOffset).start; - final end = last.getSelectionInRange(panStartOffset, panEndOffset).end; - final selection = Selection(start: start, end: end); - updateSelection(selection); - } - - _showDebugLayerIfNeeded(offset: panEndOffset); - } - - void _onPanEnd(DragEndDetails details) { - // do nothing - } - - void _updateSelectionAreas(Selection selection) { - final nodes = getNodesInSelection(selection); - - currentSelectedNodes = nodes; - - // TODO: need to be refactored. - Offset? toolbarOffset; - LayerLink? layerLink; - final editorOffset = - editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; - - final backwardNodes = - selection.isBackward ? nodes : nodes.reversed.toList(growable: false); - final normalizedSelection = selection.normalized; - assert(normalizedSelection.isBackward); - - Log.selection.debug('update selection areas, $normalizedSelection'); - - for (var i = 0; i < backwardNodes.length; i++) { - final node = backwardNodes[i]; - final selectable = node.selectable; - if (selectable == null) { - continue; - } - - var newSelection = normalizedSelection.copyWith(); - - /// In the case of multiple selections, - /// we need to return a new selection for each selected node individually. - /// - /// < > means selected. - /// text: abcdopqr - /// - if (!normalizedSelection.isSingle) { - if (i == 0) { - newSelection = newSelection.copyWith(end: selectable.end()); - } else if (i == nodes.length - 1) { - newSelection = newSelection.copyWith(start: selectable.start()); - } else { - newSelection = Selection( - start: selectable.start(), - end: selectable.end(), - ); - } - } - - const baseToolbarOffset = Offset(0, 35.0); - final rects = selectable.getRectsInSelection(newSelection); - for (final rect in rects) { - final selectionRect = _transformRectToGlobal(selectable, rect); - selectionRects.add(selectionRect); - - // TODO: Need to compute more precise location. - if ((selectionRect.topLeft.dy - editorOffset.dy) <= - baseToolbarOffset.dy) { - toolbarOffset ??= rect.bottomLeft; - } else { - toolbarOffset ??= rect.topLeft - baseToolbarOffset; - } - layerLink ??= node.layerLink; - - final overlay = OverlayEntry( - builder: (context) => SelectionWidget( - color: widget.selectionColor, - layerLink: node.layerLink, - rect: rect, - ), - ); - _selectionAreas.add(overlay); - } - } - - Overlay.of(context)?.insertAll(_selectionAreas); - - if (toolbarOffset != null && layerLink != null) { - editorState.service.toolbarService?.showInOffset( - toolbarOffset, - layerLink, - ); - } - } - - void _updateCursorAreas(Position position) { - final node = editorState.document.root.childAtPath(position.path); - - if (node == null) { - assert(false); - return; - } - - currentSelectedNodes = [node]; - - _showCursor(node, position); - } - - void _showCursor(Node node, Position position) { - final selectable = node.selectable; - final cursorRect = selectable?.getCursorRectInPosition(position); - if (selectable != null && cursorRect != null) { - final cursorArea = OverlayEntry( - builder: (context) => CursorWidget( - key: _cursorKey, - rect: cursorRect, - color: widget.cursorColor, - layerLink: node.layerLink, - shouldBlink: selectable.shouldCursorBlink, - cursorStyle: selectable.cursorStyle, - ), - ); - - _cursorAreas.add(cursorArea); - selectionRects.add(_transformRectToGlobal(selectable, cursorRect)); - Overlay.of(context)?.insertAll(_cursorAreas); - - _forceShowCursor(); - } - } - - void _forceShowCursor() { - _cursorKey.currentState?.unwrapOrNull()?.show(); - } - - void _showContextMenu(TapDownDetails details) { - _clearContextMenu(); - - final baseOffset = - editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; - final offset = details.globalPosition + const Offset(10, 10) - baseOffset; - final contextMenu = OverlayEntry( - builder: (context) => ContextMenu( - position: offset, - editorState: editorState, - items: builtInContextMenuItems, - onPressed: () => _clearContextMenu(), - ), - ); - - _contextMenuAreas.add(contextMenu); - Overlay.of(context)?.insert(contextMenu); - } - - void _scrollUpOrDownIfNeeded() { - final dy = editorState.service.scrollService?.dy; - final selectNodes = currentSelectedNodes; - final selection = currentSelection.value; - if (dy == null || selection == null || selectNodes.isEmpty) { - return; - } - - final rect = selectNodes.last.rect; - - final size = MediaQuery.of(context).size.height; - final topLimit = size * 0.3; - final bottomLimit = size * 0.8; - - // TODO: It is necessary to calculate the relative speed - // according to the gap and move forward more gently. - if (rect.top >= bottomLimit) { - if (selection.isSingle) { - editorState.service.scrollService?.scrollTo(dy + size * 0.2); - } else if (selection.isBackward) { - editorState.service.scrollService?.scrollTo(dy + 10.0); - } - } else if (rect.bottom <= topLimit) { - if (selection.isForward) { - editorState.service.scrollService?.scrollTo(dy - 10.0); - } - } - } - - Node? _getNodeInOffset( - List sortedNodes, Offset offset, int start, int end) { - if (start < 0 && end >= sortedNodes.length) { - return null; - } - var min = start; - var max = end; - while (min <= max) { - final mid = min + ((max - min) >> 1); - final rect = sortedNodes[mid].rect; - if (rect.bottom <= offset.dy) { - min = mid + 1; - } else { - max = mid - 1; - } - } - min = min.clamp(start, end); - final node = sortedNodes[min]; - if (node.children.isNotEmpty && node.children.first.rect.top <= offset.dy) { - final children = node.children.toList(growable: false); - return _getNodeInOffset( - children, - offset, - 0, - children.length - 1, - ); - } - return node; - } - - void _enableInteraction() { - editorState.service.keyboardService?.enable(); - editorState.service.scrollService?.enable(); - } - - Rect _transformRectToGlobal(SelectableMixin selectable, Rect r) { - final Offset topLeft = selectable.localToGlobal(Offset(r.left, r.top)); - return Rect.fromLTWH(topLeft.dx, topLeft.dy, r.width, r.height); - } - - void _onSelectionChange() { - _scrollUpOrDownIfNeeded(); - } - - void _showDebugLayerIfNeeded({Offset? offset}) { - // remove false to show debug overlay. - // if (kDebugMode && false) { - // _debugOverlay?.remove(); - // if (offset != null) { - // _debugOverlay = OverlayEntry( - // builder: (context) => Positioned.fromRect( - // rect: Rect.fromPoints(offset, offset.translate(20, 20)), - // child: Container( - // color: Colors.red.withOpacity(0.2), - // ), - // ), - // ); - // Overlay.of(context)?.insert(_debugOverlay!); - // } else if (_panStartOffset != null) { - // _debugOverlay = OverlayEntry( - // builder: (context) => Positioned.fromRect( - // rect: Rect.fromPoints( - // _panStartOffset?.translate( - // 0, - // -(editorState.service.scrollService!.dy - - // _panStartScrollDy!), - // ) ?? - // Offset.zero, - // offset ?? Offset.zero), - // child: Container( - // color: Colors.red.withOpacity(0.2), - // ), - // ), - // ); - // Overlay.of(context)?.insert(_debugOverlay!); - // } else { - // _debugOverlay = null; - // } - // } - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/service.dart deleted file mode 100644 index 7b64e9e36d284..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/service.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:appflowy_editor/src/service/input_service.dart'; -import 'package:appflowy_editor/src/service/keyboard_service.dart'; -import 'package:appflowy_editor/src/service/render_plugin_service.dart'; -import 'package:appflowy_editor/src/service/scroll_service.dart'; -import 'package:appflowy_editor/src/service/selection_service.dart'; -import 'package:appflowy_editor/src/service/toolbar_service.dart'; -import 'package:flutter/material.dart'; - -class FlowyService { - // selection service - final selectionServiceKey = GlobalKey(debugLabel: 'flowy_selection_service'); - AppFlowySelectionService get selectionService { - assert(selectionServiceKey.currentState != null && - selectionServiceKey.currentState is AppFlowySelectionService); - return selectionServiceKey.currentState! as AppFlowySelectionService; - } - - // keyboard service - final keyboardServiceKey = GlobalKey(debugLabel: 'flowy_keyboard_service'); - AppFlowyKeyboardService? get keyboardService { - if (keyboardServiceKey.currentState != null && - keyboardServiceKey.currentState is AppFlowyKeyboardService) { - return keyboardServiceKey.currentState! as AppFlowyKeyboardService; - } - return null; - } - - // input service - final inputServiceKey = GlobalKey(debugLabel: 'flowy_input_service'); - AppFlowyInputService? get inputService { - if (inputServiceKey.currentState != null && - inputServiceKey.currentState is AppFlowyInputService) { - return inputServiceKey.currentState! as AppFlowyInputService; - } - return null; - } - - // render plugin service - late AppFlowyRenderPlugin renderPluginService; - - // toolbar service - final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service'); - AppFlowyToolbarService? get toolbarService { - if (toolbarServiceKey.currentState != null && - toolbarServiceKey.currentState is AppFlowyToolbarService) { - return toolbarServiceKey.currentState! as AppFlowyToolbarService; - } - return null; - } - - // scroll service - final scrollServiceKey = GlobalKey(debugLabel: 'flowy_scroll_service'); - AppFlowyScrollService? get scrollService { - if (scrollServiceKey.currentState != null && - scrollServiceKey.currentState is AppFlowyScrollService) { - return scrollServiceKey.currentState! as AppFlowyScrollService; - } - return null; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart deleted file mode 100644 index b70c7279e2c8b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart +++ /dev/null @@ -1,303 +0,0 @@ -// List<> - -import 'package:appflowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/backspace_handler.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/exit_editing_mode_handler.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/markdown_syntax_to_styled_text.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/page_up_down_handler.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/format_style_handler.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/space_on_web_handler.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/tab_handler.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart'; -import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart'; -import 'package:flutter/foundation.dart'; - -List builtInShortcutEvents = [ - ShortcutEvent( - key: 'Move cursor up', - command: 'arrow up', - handler: cursorUp, - ), - ShortcutEvent( - key: 'Move cursor down', - command: 'arrow down', - handler: cursorDown, - ), - ShortcutEvent( - key: 'Move cursor left', - command: 'arrow left', - handler: cursorLeft, - ), - ShortcutEvent( - key: 'Move cursor right', - command: 'arrow right', - handler: cursorRight, - ), - ShortcutEvent( - key: 'Cursor up select', - command: 'shift+arrow up', - handler: cursorUpSelect, - ), - ShortcutEvent( - key: 'Cursor down select', - command: 'shift+arrow down', - handler: cursorDownSelect, - ), - ShortcutEvent( - key: 'Cursor left select', - command: 'shift+arrow left', - handler: cursorLeftSelect, - ), - ShortcutEvent( - key: 'Cursor right select', - command: 'shift+arrow right', - handler: cursorRightSelect, - ), - ShortcutEvent( - key: 'Move cursor top', - command: 'meta+arrow up', - windowsCommand: 'ctrl+arrow up', - linuxCommand: 'ctrl+arrow up', - handler: cursorTop, - ), - ShortcutEvent( - key: 'Move cursor bottom', - command: 'meta+arrow down', - windowsCommand: 'ctrl+arrow down', - linuxCommand: 'ctrl+arrow down', - handler: cursorBottom, - ), - ShortcutEvent( - key: 'Move cursor begin', - command: 'meta+arrow left', - windowsCommand: 'ctrl+arrow left', - linuxCommand: 'ctrl+arrow left', - handler: cursorBegin, - ), - ShortcutEvent( - key: 'Move cursor end', - command: 'meta+arrow right', - windowsCommand: 'ctrl+arrow right', - linuxCommand: 'ctrl+arrow right', - handler: cursorEnd, - ), - ShortcutEvent( - key: 'Cursor top select', - command: 'meta+shift+arrow up', - windowsCommand: 'ctrl+shift+arrow up', - linuxCommand: 'ctrl+shift+arrow up', - handler: cursorTopSelect, - ), - ShortcutEvent( - key: 'Cursor bottom select', - command: 'meta+shift+arrow down', - windowsCommand: 'ctrl+shift+arrow down', - linuxCommand: 'ctrl+shift+arrow down', - handler: cursorBottomSelect, - ), - ShortcutEvent( - key: 'Cursor begin select', - command: 'meta+shift+arrow left', - windowsCommand: 'ctrl+shift+arrow left', - linuxCommand: 'ctrl+shift+arrow left', - handler: cursorBeginSelect, - ), - ShortcutEvent( - key: 'Cursor end select', - command: 'meta+shift+arrow right', - windowsCommand: 'ctrl+shift+arrow right', - linuxCommand: 'ctrl+shift+arrow right', - handler: cursorEndSelect, - ), - ShortcutEvent( - key: 'Redo', - command: 'meta+shift+z', - windowsCommand: 'ctrl+shift+z', - linuxCommand: 'ctrl+shift+z', - handler: redoEventHandler, - ), - ShortcutEvent( - key: 'Undo', - command: 'meta+z', - windowsCommand: 'ctrl+z', - linuxCommand: 'ctrl+z', - handler: undoEventHandler, - ), - ShortcutEvent( - key: 'Format bold', - command: 'meta+b', - windowsCommand: 'ctrl+b', - linuxCommand: 'ctrl+b', - handler: formatBoldEventHandler, - ), - ShortcutEvent( - key: 'Format italic', - command: 'meta+i', - windowsCommand: 'ctrl+i', - linuxCommand: 'ctrl+i', - handler: formatItalicEventHandler, - ), - ShortcutEvent( - key: 'Format underline', - command: 'meta+u', - windowsCommand: 'ctrl+u', - linuxCommand: 'ctrl+u', - handler: formatUnderlineEventHandler, - ), - ShortcutEvent( - key: 'Format strikethrough', - command: 'meta+shift+s', - windowsCommand: 'ctrl+shift+s', - linuxCommand: 'ctrl+shift+s', - handler: formatStrikethroughEventHandler, - ), - ShortcutEvent( - key: 'Format highlight', - command: 'meta+shift+h', - windowsCommand: 'ctrl+shift+h', - linuxCommand: 'ctrl+shift+h', - handler: formatHighlightEventHandler, - ), - ShortcutEvent( - key: 'Format embed code', - command: 'meta+e', - windowsCommand: 'ctrl+e', - linuxCommand: 'ctrl+e', - handler: formatEmbedCodeEventHandler, - ), - ShortcutEvent( - key: 'Format link', - command: 'meta+k', - windowsCommand: 'ctrl+k', - linuxCommand: 'ctrl+k', - handler: formatLinkEventHandler, - ), - ShortcutEvent( - key: 'Copy', - command: 'meta+c', - windowsCommand: 'ctrl+c', - linuxCommand: 'ctrl+c', - handler: copyEventHandler, - ), - ShortcutEvent( - key: 'Paste', - command: 'meta+v', - windowsCommand: 'ctrl+v', - linuxCommand: 'ctrl+v', - handler: pasteEventHandler, - ), - ShortcutEvent( - key: 'Cut', - command: 'meta+x', - windowsCommand: 'ctrl+x', - linuxCommand: 'ctrl+x', - handler: cutEventHandler, - ), - ShortcutEvent( - key: 'Home', - command: 'home', - handler: cursorBegin, - ), - ShortcutEvent( - key: 'End', - command: 'end', - handler: cursorEnd, - ), - ShortcutEvent( - key: 'Delete Text by backspace', - command: 'backspace', - handler: backspaceEventHandler, - ), - ShortcutEvent( - key: 'Delete Text', - command: 'delete', - handler: deleteEventHandler, - ), - ShortcutEvent( - key: 'selection menu', - command: 'slash', - handler: slashShortcutHandler, - ), - ShortcutEvent( - key: 'enter', - command: 'enter', - handler: enterWithoutShiftInTextNodesHandler, - ), - ShortcutEvent( - key: 'markdown', - command: 'space', - handler: whiteSpaceHandler, - ), - ShortcutEvent( - key: 'select all', - command: 'meta+a', - windowsCommand: 'ctrl+a', - linuxCommand: 'ctrl+a', - handler: selectAllHandler, - ), - ShortcutEvent( - key: 'Page up', - command: 'page up', - handler: pageUpHandler, - ), - ShortcutEvent( - key: 'Page down', - command: 'page down', - handler: pageDownHandler, - ), - ShortcutEvent( - key: 'Tab', - command: 'tab', - handler: tabHandler, - ), - ShortcutEvent( - key: 'Double stars to bold', - command: 'shift+asterisk', - handler: doubleAsterisksToBold, - ), - ShortcutEvent( - key: 'Double underscores to bold', - command: 'shift+underscore', - handler: doubleUnderscoresToBold, - ), - ShortcutEvent( - key: 'Backquote to code', - command: 'backquote', - handler: backquoteToCodeHandler, - ), - ShortcutEvent( - key: 'Double tilde to strikethrough', - command: 'shift+tilde', - handler: doubleTildeToStrikethrough, - ), - ShortcutEvent( - key: 'Markdown link or image', - command: 'shift+parenthesis right', - handler: markdownLinkOrImageHandler, - ), - ShortcutEvent( - key: 'Exit editing mode', - command: 'escape', - handler: exitEditingModeEventHandler, - ), - ShortcutEvent( - key: 'Underscore to italic', - command: 'shift+underscore', - handler: underscoreToItalicHandler, - ), - // https://github.com/flutter/flutter/issues/104944 - // Workaround: Using space editing on the web platform often results in errors, - // so adding a shortcut event to handle the space input instead of using the - // `input_service`. - if (kIsWeb) - ShortcutEvent( - key: 'Space on the Web', - command: 'space', - handler: spaceOnWebHandler, - ), -]; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/key_mapping.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/key_mapping.dart deleted file mode 100644 index bddbd09711785..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/key_mapping.dart +++ /dev/null @@ -1,451 +0,0 @@ -/// Keyboard key to keycode mapping table -/// -/// Copy from flutter project, keyboard_key.dart. -/// - -Map keyToCodeMapping = { - 'Space': 0x00000000020, - 'Exclamation': 0x00000000021, - 'Quote': 0x00000000022, - 'Number Sign': 0x00000000023, - 'Dollar': 0x00000000024, - 'Percent': 0x00000000025, - 'Ampersand': 0x00000000026, - 'Quote Single': 0x00000000027, - 'Parenthesis Left': 0x00000000028, - 'Parenthesis Right': 0x00000000029, - 'Asterisk': 0x0000000002a, - 'Add': 0x0000000002b, - 'Comma': 0x0000000002c, - 'Minus': 0x0000000002d, - 'Period': 0x0000000002e, - 'Slash': 0x0000000002f, - 'Digit 0': 0x00000000030, - 'Digit 1': 0x00000000031, - 'Digit 2': 0x00000000032, - 'Digit 3': 0x00000000033, - 'Digit 4': 0x00000000034, - 'Digit 5': 0x00000000035, - 'Digit 6': 0x00000000036, - 'Digit 7': 0x00000000037, - 'Digit 8': 0x00000000038, - 'Digit 9': 0x00000000039, - 'Colon': 0x0000000003a, - 'Semicolon': 0x0000000003b, - 'Less': 0x0000000003c, - 'Equal': 0x0000000003d, - 'Greater': 0x0000000003e, - 'Question': 0x0000000003f, - 'At': 0x00000000040, - 'Bracket Left': 0x0000000005b, - 'Backslash': 0x0000000005c, - 'Bracket Right': 0x0000000005d, - 'Caret': 0x0000000005e, - 'Underscore': 0x0000000005f, - 'Backquote': 0x00000000060, - 'A': 0x00000000061, - 'B': 0x00000000062, - 'C': 0x00000000063, - 'D': 0x00000000064, - 'E': 0x00000000065, - 'F': 0x00000000066, - 'G': 0x00000000067, - 'H': 0x00000000068, - 'I': 0x00000000069, - 'J': 0x0000000006a, - 'K': 0x0000000006b, - 'L': 0x0000000006c, - 'M': 0x0000000006d, - 'N': 0x0000000006e, - 'O': 0x0000000006f, - 'P': 0x00000000070, - 'Q': 0x00000000071, - 'R': 0x00000000072, - 'S': 0x00000000073, - 'T': 0x00000000074, - 'U': 0x00000000075, - 'V': 0x00000000076, - 'W': 0x00000000077, - 'X': 0x00000000078, - 'Y': 0x00000000079, - 'Z': 0x0000000007a, - 'Brace Left': 0x0000000007b, - 'Bar': 0x0000000007c, - 'Brace Right': 0x0000000007d, - 'Tilde': 0x0000000007e, - 'Unidentified': 0x00100000001, - 'Backspace': 0x00100000008, - 'Tab': 0x00100000009, - 'Enter': 0x0010000000d, - 'Escape': 0x0010000001b, - 'Delete': 0x0010000007f, - 'Accel': 0x00100000101, - 'Alt Graph': 0x00100000103, - 'Caps Lock': 0x00100000104, - 'Fn': 0x00100000106, - 'Fn Lock': 0x00100000107, - 'Hyper': 0x00100000108, - 'Num Lock': 0x0010000010a, - 'Scroll Lock': 0x0010000010c, - 'Super': 0x0010000010e, - 'Symbol': 0x0010000010f, - 'Symbol Lock': 0x00100000110, - 'Shift Level 5': 0x00100000111, - 'Arrow Down': 0x00100000301, - 'Arrow Left': 0x00100000302, - 'Arrow Right': 0x00100000303, - 'Arrow Up': 0x00100000304, - 'End': 0x00100000305, - 'Home': 0x00100000306, - 'Page Down': 0x00100000307, - 'Page Up': 0x00100000308, - 'Clear': 0x00100000401, - 'Copy': 0x00100000402, - 'Cr Sel': 0x00100000403, - 'Cut': 0x00100000404, - 'Erase Eof': 0x00100000405, - 'Ex Sel': 0x00100000406, - 'Insert': 0x00100000407, - 'Paste': 0x00100000408, - 'Redo': 0x00100000409, - 'Undo': 0x0010000040a, - 'Accept': 0x00100000501, - 'Again': 0x00100000502, - 'Attn': 0x00100000503, - 'Cancel': 0x00100000504, - 'Context Menu': 0x00100000505, - 'Execute': 0x00100000506, - 'Find': 0x00100000507, - 'Help': 0x00100000508, - 'Pause': 0x00100000509, - 'Play': 0x0010000050a, - 'Props': 0x0010000050b, - 'Select': 0x0010000050c, - 'Zoom In': 0x0010000050d, - 'Zoom Out': 0x0010000050e, - 'Brightness Down': 0x00100000601, - 'Brightness Up': 0x00100000602, - 'Camera': 0x00100000603, - 'Eject': 0x00100000604, - 'Log Off': 0x00100000605, - 'Power': 0x00100000606, - 'Power Off': 0x00100000607, - 'Print Screen': 0x00100000608, - 'Hibernate': 0x00100000609, - 'Standby': 0x0010000060a, - 'Wake Up': 0x0010000060b, - 'All Candidates': 0x00100000701, - 'Alphanumeric': 0x00100000702, - 'Code Input': 0x00100000703, - 'Compose': 0x00100000704, - 'Convert': 0x00100000705, - 'Final Mode': 0x00100000706, - 'Group First': 0x00100000707, - 'Group Last': 0x00100000708, - 'Group Next': 0x00100000709, - 'Group Previous': 0x0010000070a, - 'Mode Change': 0x0010000070b, - 'Next Candidate': 0x0010000070c, - 'Non Convert': 0x0010000070d, - 'Previous Candidate': 0x0010000070e, - 'Process': 0x0010000070f, - 'Single Candidate': 0x00100000710, - 'Hangul Mode': 0x00100000711, - 'Hanja Mode': 0x00100000712, - 'Junja Mode': 0x00100000713, - 'Eisu': 0x00100000714, - 'Hankaku': 0x00100000715, - 'Hiragana': 0x00100000716, - 'Hiragana Katakana': 0x00100000717, - 'Kana Mode': 0x00100000718, - 'Kanji Mode': 0x00100000719, - 'Katakana': 0x0010000071a, - 'Romaji': 0x0010000071b, - 'Zenkaku': 0x0010000071c, - 'Zenkaku Hankaku': 0x0010000071d, - 'F1': 0x00100000801, - 'F2': 0x00100000802, - 'F3': 0x00100000803, - 'F4': 0x00100000804, - 'F5': 0x00100000805, - 'F6': 0x00100000806, - 'F7': 0x00100000807, - 'F8': 0x00100000808, - 'F9': 0x00100000809, - 'F10': 0x0010000080a, - 'F11': 0x0010000080b, - 'F12': 0x0010000080c, - 'F13': 0x0010000080d, - 'F14': 0x0010000080e, - 'F15': 0x0010000080f, - 'F16': 0x00100000810, - 'F17': 0x00100000811, - 'F18': 0x00100000812, - 'F19': 0x00100000813, - 'F20': 0x00100000814, - 'F21': 0x00100000815, - 'F22': 0x00100000816, - 'F23': 0x00100000817, - 'F24': 0x00100000818, - 'Soft 1': 0x00100000901, - 'Soft 2': 0x00100000902, - 'Soft 3': 0x00100000903, - 'Soft 4': 0x00100000904, - 'Soft 5': 0x00100000905, - 'Soft 6': 0x00100000906, - 'Soft 7': 0x00100000907, - 'Soft 8': 0x00100000908, - 'Close': 0x00100000a01, - 'Mail Forward': 0x00100000a02, - 'Mail Reply': 0x00100000a03, - 'Mail Send': 0x00100000a04, - 'Media Play Pause': 0x00100000a05, - 'Media Stop': 0x00100000a07, - 'Media Track Next': 0x00100000a08, - 'Media Track Previous': 0x00100000a09, - 'New': 0x00100000a0a, - 'Open': 0x00100000a0b, - 'Print': 0x00100000a0c, - 'Save': 0x00100000a0d, - 'Spell Check': 0x00100000a0e, - 'Audio Volume Down': 0x00100000a0f, - 'Audio Volume Up': 0x00100000a10, - 'Audio Volume Mute': 0x00100000a11, - 'Launch Application 2': 0x00100000b01, - 'Launch Calendar': 0x00100000b02, - 'Launch Mail': 0x00100000b03, - 'Launch Media Player': 0x00100000b04, - 'Launch Music Player': 0x00100000b05, - 'Launch Application 1': 0x00100000b06, - 'Launch Screen Saver': 0x00100000b07, - 'Launch Spreadsheet': 0x00100000b08, - 'Launch Web Browser': 0x00100000b09, - 'Launch Web Cam': 0x00100000b0a, - 'Launch Word Processor': 0x00100000b0b, - 'Launch Contacts': 0x00100000b0c, - 'Launch Phone': 0x00100000b0d, - 'Launch Assistant': 0x00100000b0e, - 'Launch Control Panel': 0x00100000b0f, - 'Browser Back': 0x00100000c01, - 'Browser Favorites': 0x00100000c02, - 'Browser Forward': 0x00100000c03, - 'Browser Home': 0x00100000c04, - 'Browser Refresh': 0x00100000c05, - 'Browser Search': 0x00100000c06, - 'Browser Stop': 0x00100000c07, - 'Audio Balance Left': 0x00100000d01, - 'Audio Balance Right': 0x00100000d02, - 'Audio Bass Boost Down': 0x00100000d03, - 'Audio Bass Boost Up': 0x00100000d04, - 'Audio Fader Front': 0x00100000d05, - 'Audio Fader Rear': 0x00100000d06, - 'Audio Surround Mode Next': 0x00100000d07, - 'AVR Input': 0x00100000d08, - 'AVR Power': 0x00100000d09, - 'Channel Down': 0x00100000d0a, - 'Channel Up': 0x00100000d0b, - 'Color F0 Red': 0x00100000d0c, - 'Color F1 Green': 0x00100000d0d, - 'Color F2 Yellow': 0x00100000d0e, - 'Color F3 Blue': 0x00100000d0f, - 'Color F4 Grey': 0x00100000d10, - 'Color F5 Brown': 0x00100000d11, - 'Closed Caption Toggle': 0x00100000d12, - 'Dimmer': 0x00100000d13, - 'Display Swap': 0x00100000d14, - 'Exit': 0x00100000d15, - 'Favorite Clear 0': 0x00100000d16, - 'Favorite Clear 1': 0x00100000d17, - 'Favorite Clear 2': 0x00100000d18, - 'Favorite Clear 3': 0x00100000d19, - 'Favorite Recall 0': 0x00100000d1a, - 'Favorite Recall 1': 0x00100000d1b, - 'Favorite Recall 2': 0x00100000d1c, - 'Favorite Recall 3': 0x00100000d1d, - 'Favorite Store 0': 0x00100000d1e, - 'Favorite Store 1': 0x00100000d1f, - 'Favorite Store 2': 0x00100000d20, - 'Favorite Store 3': 0x00100000d21, - 'Guide': 0x00100000d22, - 'Guide Next Day': 0x00100000d23, - 'Guide Previous Day': 0x00100000d24, - 'Info': 0x00100000d25, - 'Instant Replay': 0x00100000d26, - 'Link': 0x00100000d27, - 'List Program': 0x00100000d28, - 'Live Content': 0x00100000d29, - 'Lock': 0x00100000d2a, - 'Media Apps': 0x00100000d2b, - 'Media Fast Forward': 0x00100000d2c, - 'Media Last': 0x00100000d2d, - 'Media Pause': 0x00100000d2e, - 'Media Play': 0x00100000d2f, - 'Media Record': 0x00100000d30, - 'Media Rewind': 0x00100000d31, - 'Media Skip': 0x00100000d32, - 'Next Favorite Channel': 0x00100000d33, - 'Next User Profile': 0x00100000d34, - 'On Demand': 0x00100000d35, - 'P In P Down': 0x00100000d36, - 'P In P Move': 0x00100000d37, - 'P In P Toggle': 0x00100000d38, - 'P In P Up': 0x00100000d39, - 'Play Speed Down': 0x00100000d3a, - 'Play Speed Reset': 0x00100000d3b, - 'Play Speed Up': 0x00100000d3c, - 'Random Toggle': 0x00100000d3d, - 'Rc Low Battery': 0x00100000d3e, - 'Record Speed Next': 0x00100000d3f, - 'Rf Bypass': 0x00100000d40, - 'Scan Channels Toggle': 0x00100000d41, - 'Screen Mode Next': 0x00100000d42, - 'Settings': 0x00100000d43, - 'Split Screen Toggle': 0x00100000d44, - 'STB Input': 0x00100000d45, - 'STB Power': 0x00100000d46, - 'Subtitle': 0x00100000d47, - 'Teletext': 0x00100000d48, - 'TV': 0x00100000d49, - 'TV Input': 0x00100000d4a, - 'TV Power': 0x00100000d4b, - 'Video Mode Next': 0x00100000d4c, - 'Wink': 0x00100000d4d, - 'Zoom Toggle': 0x00100000d4e, - 'DVR': 0x00100000d4f, - 'Media Audio Track': 0x00100000d50, - 'Media Skip Backward': 0x00100000d51, - 'Media Skip Forward': 0x00100000d52, - 'Media Step Backward': 0x00100000d53, - 'Media Step Forward': 0x00100000d54, - 'Media Top Menu': 0x00100000d55, - 'Navigate In': 0x00100000d56, - 'Navigate Next': 0x00100000d57, - 'Navigate Out': 0x00100000d58, - 'Navigate Previous': 0x00100000d59, - 'Pairing': 0x00100000d5a, - 'Media Close': 0x00100000d5b, - 'Audio Bass Boost Toggle': 0x00100000e02, - 'Audio Treble Down': 0x00100000e04, - 'Audio Treble Up': 0x00100000e05, - 'Microphone Toggle': 0x00100000e06, - 'Microphone Volume Down': 0x00100000e07, - 'Microphone Volume Up': 0x00100000e08, - 'Microphone Volume Mute': 0x00100000e09, - 'Speech Correction List': 0x00100000f01, - 'Speech Input Toggle': 0x00100000f02, - 'App Switch': 0x00100001001, - 'Call': 0x00100001002, - 'Camera Focus': 0x00100001003, - 'End Call': 0x00100001004, - 'Go Back': 0x00100001005, - 'Go Home': 0x00100001006, - 'Headset Hook': 0x00100001007, - 'Last Number Redial': 0x00100001008, - 'Notification': 0x00100001009, - 'Manner Mode': 0x0010000100a, - 'Voice Dial': 0x0010000100b, - 'TV 3 D Mode': 0x00100001101, - 'TV Antenna Cable': 0x00100001102, - 'TV Audio Description': 0x00100001103, - 'TV Audio Description Mix Dow': 0x00100001104, - 'TV Audio Description Mix Up': 0x00100001105, - 'TV Contents Menu': 0x00100001106, - 'TV Data Service': 0x00100001107, - 'TV Input Component 1': 0x00100001108, - 'TV Input Component 2': 0x00100001109, - 'TV Input Composite 1': 0x0010000110a, - 'TV Input Composite 2': 0x0010000110b, - 'TV Input HDMI 1': 0x0010000110c, - 'TV Input HDMI 2': 0x0010000110d, - 'TV Input HDMI 3': 0x0010000110e, - 'TV Input HDMI 4': 0x0010000110f, - 'TV Input VGA 1': 0x00100001110, - 'TV Media Context': 0x00100001111, - 'TV Network': 0x00100001112, - 'TV Number Entry': 0x00100001113, - 'TV Radio Service': 0x00100001114, - 'TV Satellite': 0x00100001115, - 'TV Satellite BS': 0x00100001116, - 'TV Satellite CS': 0x00100001117, - 'TV Satellite Toggle': 0x00100001118, - 'TV Terrestrial Analog': 0x00100001119, - 'TV Terrestrial Digital': 0x0010000111a, - 'TV Timer': 0x0010000111b, - 'Key 11': 0x00100001201, - 'Key 12': 0x00100001202, - 'Suspend': 0x00200000000, - 'Resume': 0x00200000001, - 'Sleep': 0x00200000002, - 'Abort': 0x00200000003, - 'Lang 1': 0x00200000010, - 'Lang 2': 0x00200000011, - 'Lang 3': 0x00200000012, - 'Lang 4': 0x00200000013, - 'Lang 5': 0x00200000014, - 'Intl Backslash': 0x00200000020, - 'Intl Ro': 0x00200000021, - 'Intl Yen': 0x00200000022, - 'Control Left': 0x00200000100, - 'Control Right': 0x00200000101, - 'Shift Left': 0x00200000102, - 'Shift Right': 0x00200000103, - 'Alt Left': 0x00200000104, - 'Alt Right': 0x00200000105, - 'Meta Left': 0x00200000106, - 'Meta Right': 0x00200000107, - 'Control': 0x002000001f0, - 'Shift': 0x002000001f2, - 'Alt': 0x002000001f4, - 'Meta': 0x002000001f6, - 'Numpad Enter': 0x0020000020d, - 'Numpad Paren Left': 0x00200000228, - 'Numpad Paren Right': 0x00200000229, - 'Numpad Multiply': 0x0020000022a, - 'Numpad Add': 0x0020000022b, - 'Numpad Comma': 0x0020000022c, - 'Numpad Subtract': 0x0020000022d, - 'Numpad Decimal': 0x0020000022e, - 'Numpad Divide': 0x0020000022f, - 'Numpad 0': 0x00200000230, - 'Numpad 1': 0x00200000231, - 'Numpad 2': 0x00200000232, - 'Numpad 3': 0x00200000233, - 'Numpad 4': 0x00200000234, - 'Numpad 5': 0x00200000235, - 'Numpad 6': 0x00200000236, - 'Numpad 7': 0x00200000237, - 'Numpad 8': 0x00200000238, - 'Numpad 9': 0x00200000239, - 'Numpad Equal': 0x0020000023d, - 'Game Button 1': 0x00200000301, - 'Game Button 2': 0x00200000302, - 'Game Button 3': 0x00200000303, - 'Game Button 4': 0x00200000304, - 'Game Button 5': 0x00200000305, - 'Game Button 6': 0x00200000306, - 'Game Button 7': 0x00200000307, - 'Game Button 8': 0x00200000308, - 'Game Button 9': 0x00200000309, - 'Game Button 10': 0x0020000030a, - 'Game Button 11': 0x0020000030b, - 'Game Button 12': 0x0020000030c, - 'Game Button 13': 0x0020000030d, - 'Game Button 14': 0x0020000030e, - 'Game Button 15': 0x0020000030f, - 'Game Button 16': 0x00200000310, - 'Game Button A': 0x00200000311, - 'Game Button B': 0x00200000312, - 'Game Button C': 0x00200000313, - 'Game Button Left 1': 0x00200000314, - 'Game Button Left 2': 0x00200000315, - 'Game Button Mode': 0x00200000316, - 'Game Button Right 1': 0x00200000317, - 'Game Button Right 2': 0x00200000318, - 'Game Button Select': 0x00200000319, - 'Game Button Start': 0x0020000031a, - 'Game Button Thumb Left': 0x0020000031b, - 'Game Button Thumb Right': 0x0020000031c, - 'Game Button X': 0x0020000031d, - 'Game Button Y': 0x0020000031e, - 'Game Button Z': 0x0020000031f, -}.map((key, value) => MapEntry(key.toLowerCase(), value)); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/keybinding.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/keybinding.dart deleted file mode 100644 index f346514f7c21f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/keybinding.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'dart:convert'; - -import 'package:appflowy_editor/src/service/shortcut_event/key_mapping.dart'; -import 'package:flutter/material.dart'; - -extension KeybindingsExtension on List { - bool containsKeyEvent(RawKeyEvent keyEvent) { - for (final keybinding in this) { - if (keybinding.isMetaPressed == keyEvent.isMetaPressed && - keybinding.isControlPressed == keyEvent.isControlPressed && - keybinding.isAltPressed == keyEvent.isAltPressed && - keybinding.isShiftPressed == keyEvent.isShiftPressed && - keybinding.keyCode == keyEvent.logicalKey.keyId) { - return true; - } - } - return false; - } -} - -class Keybinding { - Keybinding({ - required this.isAltPressed, - required this.isControlPressed, - required this.isMetaPressed, - required this.isShiftPressed, - required this.keyLabel, - }); - - factory Keybinding.parse(String command) { - command = command.toLowerCase().trim(); - - var isAltPressed = false; - var isControlPressed = false; - var isMetaPressed = false; - var isShiftPressed = false; - - var matchedModifier = false; - - do { - matchedModifier = false; - if (RegExp(r'^alt(\+|\-)').hasMatch(command)) { - isAltPressed = true; - command = command.substring(4); // 4 = 'alt '.length - matchedModifier = true; - } - if (RegExp(r'^ctrl(\+|\-)').hasMatch(command)) { - isControlPressed = true; - command = command.substring(5); // 5 = 'ctrl '.length - matchedModifier = true; - } - if (RegExp(r'^shift(\+|\-)').hasMatch(command)) { - isShiftPressed = true; - command = command.substring(6); // 6 = 'shift '.length - matchedModifier = true; - } - if (RegExp(r'^meta(\+|\-)').hasMatch(command)) { - isMetaPressed = true; - command = command.substring(5); // 5 = 'meta '.length - matchedModifier = true; - } - if (RegExp(r'^cmd(\+|\-)').hasMatch(command) || - RegExp(r'^win(\+|\-)').hasMatch(command)) { - isMetaPressed = true; - command = command.substring(4); // 4 = 'win '.length - matchedModifier = true; - } - } while (matchedModifier); - - return Keybinding( - isAltPressed: isAltPressed, - isControlPressed: isControlPressed, - isMetaPressed: isMetaPressed, - isShiftPressed: isShiftPressed, - keyLabel: command, - ); - } - - final bool isAltPressed; - final bool isControlPressed; - final bool isMetaPressed; - final bool isShiftPressed; - final String keyLabel; - - int get keyCode => keyToCodeMapping[keyLabel.toLowerCase()]!; - - Keybinding copyWith({ - bool? isAltPressed, - bool? isControlPressed, - bool? isMetaPressed, - bool? isShiftPressed, - String? keyLabel, - }) { - return Keybinding( - isAltPressed: isAltPressed ?? this.isAltPressed, - isControlPressed: isControlPressed ?? this.isControlPressed, - isMetaPressed: isMetaPressed ?? this.isMetaPressed, - isShiftPressed: isShiftPressed ?? this.isShiftPressed, - keyLabel: keyLabel ?? this.keyLabel, - ); - } - - Map toMap() { - return { - 'isAltPressed': isAltPressed, - 'isControlPressed': isControlPressed, - 'isMetaPressed': isMetaPressed, - 'isShiftPressed': isShiftPressed, - 'keyLabel': keyLabel, - }; - } - - factory Keybinding.fromMap(Map map) { - return Keybinding( - isAltPressed: map['isAltPressed'] ?? false, - isControlPressed: map['isControlPressed'] ?? false, - isMetaPressed: map['isMetaPressed'] ?? false, - isShiftPressed: map['isShiftPressed'] ?? false, - keyLabel: map['keyLabel'] ?? '', - ); - } - - String toJson() => json.encode(toMap()); - - factory Keybinding.fromJson(String source) => - Keybinding.fromMap(json.decode(source)); - - @override - String toString() { - return 'Keybinding(isAltPressed: $isAltPressed, isControlPressed: $isControlPressed, isMetaPressed: $isMetaPressed, isShiftPressed: $isShiftPressed, keyLabel: $keyLabel)'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is Keybinding && - other.isAltPressed == isAltPressed && - other.isControlPressed == isControlPressed && - other.isMetaPressed == isMetaPressed && - other.isShiftPressed == isShiftPressed && - other.keyCode == keyCode; - } - - @override - int get hashCode { - return isAltPressed.hashCode ^ - isControlPressed.hashCode ^ - isMetaPressed.hashCode ^ - isShiftPressed.hashCode ^ - keyCode; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event.dart deleted file mode 100644 index fb1a245b00cfb..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'dart:io'; - -import 'package:appflowy_editor/src/service/shortcut_event/keybinding.dart'; -import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart'; -import 'package:flutter/foundation.dart'; - -/// Defines the implementation of shortcut event. -class ShortcutEvent { - ShortcutEvent({ - required this.key, - required this.command, - required this.handler, - String? windowsCommand, - String? macOSCommand, - String? linuxCommand, - }) { - updateCommand( - command: command, - windowsCommand: windowsCommand, - macOSCommand: macOSCommand, - linuxCommand: linuxCommand, - ); - } - - /// The unique key. - /// - /// Usually, uses the description as the key. - final String key; - - /// The string representation for the keyboard keys. - /// - /// The following is the mapping relationship of modify key. - /// ctrl: Ctrl - /// meta: Command in macOS or Control in Windows. - /// alt: Alt - /// shift: Shift - /// cmd: meta - /// win: meta - /// - /// Refer to [keyMapping] for other keys. - /// - /// Uses ',' to split different keyboard key combinations. - /// - /// Like, 'ctrl+c,cmd+c' - /// - String command; - - final ShortcutEventHandler handler; - - List get keybindings => _keybindings; - List _keybindings = []; - - void updateCommand({ - String? command, - String? windowsCommand, - String? macOSCommand, - String? linuxCommand, - }) { - var matched = false; - if (kIsWeb && command != null && command.isNotEmpty) { - this.command = command; - matched = true; - } else if (Platform.isWindows && - windowsCommand != null && - windowsCommand.isNotEmpty) { - this.command = windowsCommand; - matched = true; - } else if (Platform.isMacOS && - macOSCommand != null && - macOSCommand.isNotEmpty) { - this.command = macOSCommand; - matched = true; - } else if (Platform.isLinux && - linuxCommand != null && - linuxCommand.isNotEmpty) { - this.command = linuxCommand; - matched = true; - } else if (command != null && command.isNotEmpty) { - this.command = command; - matched = true; - } - - if (matched) { - _keybindings = this - .command - .split(',') - .map((e) => Keybinding.parse(e)) - .toList(growable: false); - } - } - - ShortcutEvent copyWith({ - String? key, - String? command, - ShortcutEventHandler? handler, - }) { - return ShortcutEvent( - key: key ?? this.key, - command: command ?? this.command, - handler: handler ?? this.handler, - ); - } - - @override - String toString() => - 'ShortcutEvent(key: $key, command: $command, handler: $handler)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is ShortcutEvent && - other.key == key && - other.command == command && - other.handler == handler; - } - - @override - int get hashCode => key.hashCode ^ command.hashCode ^ handler.hashCode; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event_handler.dart deleted file mode 100644 index 07719e045d9cf..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event_handler.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:flutter/material.dart'; - -typedef ShortcutEventHandler = KeyEventResult Function( - EditorState editorState, - RawKeyEvent? event, -); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart deleted file mode 100644 index 8991d0a30aafa..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:appflowy_editor/src/flutter/overlay.dart'; -import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; -import 'package:flutter/material.dart' hide Overlay, OverlayEntry; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart'; -import 'package:appflowy_editor/src/extensions/object_extensions.dart'; - -abstract class AppFlowyToolbarService { - /// Show the toolbar widget beside the offset. - void showInOffset(Offset offset, LayerLink layerLink); - - /// Hide the toolbar widget. - void hide(); - - /// Trigger the specified handler. - bool triggerHandler(String id); -} - -class FlowyToolbar extends StatefulWidget { - const FlowyToolbar({ - Key? key, - required this.editorState, - required this.child, - }) : super(key: key); - - final EditorState editorState; - final Widget child; - - @override - State createState() => _FlowyToolbarState(); -} - -class _FlowyToolbarState extends State - implements AppFlowyToolbarService { - OverlayEntry? _toolbarOverlay; - final _toolbarWidgetKey = GlobalKey(debugLabel: '_toolbar_widget'); - - @override - void showInOffset(Offset offset, LayerLink layerLink) { - hide(); - final items = _filterItems(defaultToolbarItems); - if (items.isEmpty) { - return; - } - _toolbarOverlay = OverlayEntry( - builder: (context) => ToolbarWidget( - key: _toolbarWidgetKey, - editorState: widget.editorState, - layerLink: layerLink, - offset: offset, - items: items, - ), - ); - Overlay.of(context)?.insert(_toolbarOverlay!); - } - - @override - void hide() { - _toolbarWidgetKey.currentState?.unwrapOrNull()?.hide(); - _toolbarOverlay?.remove(); - _toolbarOverlay = null; - } - - @override - bool triggerHandler(String id) { - final items = defaultToolbarItems.where((item) => item.id == id); - if (items.length != 1) { - assert(items.length == 1, 'The toolbar item\'s id must be unique'); - return false; - } - items.first.handler(widget.editorState, context); - return true; - } - - @override - Widget build(BuildContext context) { - return Container( - child: widget.child, - ); - } - - @override - void dispose() { - hide(); - - super.dispose(); - } - - // Filter items that should not be displayed, sort according to type, - // and insert dividers between different types. - List _filterItems(List items) { - final filterItems = items - .where((item) => item.validator(widget.editorState)) - .toList(growable: false) - ..sort((a, b) => a.type.compareTo(b.type)); - if (filterItems.isEmpty) { - return []; - } - final List dividedItems = [filterItems.first]; - for (var i = 1; i < filterItems.length; i++) { - if (filterItems[i].type != filterItems[i - 1].type) { - dividedItems.add(ToolbarItem.divider()); - } - dividedItems.add(filterItems[i]); - } - return dividedItems; - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/pubspec.yaml b/frontend/app_flowy/packages/appflowy_editor/pubspec.yaml deleted file mode 100644 index 574757cb79a49..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/pubspec.yaml +++ /dev/null @@ -1,84 +0,0 @@ -name: appflowy_editor -description: A highly customizable rich-text editor for Flutter -version: 0.0.7 -homepage: https://github.com/AppFlowy-IO/AppFlowy - -platforms: - linux: - macos: - windows: - web: - -environment: - sdk: ">=2.17.0 <3.0.0" - flutter: ">=1.17.0" - -dependencies: - flutter: - sdk: flutter - - rich_clipboard: ^1.0.0 - html: ^0.15.0 - flutter_svg: ^1.1.1+1 - provider: ^6.0.3 - url_launcher: ^6.1.5 - logging: ^1.0.2 - intl_utils: ^2.7.0 - intl: - flutter_localizations: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.1 - network_image_mock: ^2.1.1 - mockito: ^5.3.2 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - # To add assets to your package, add an assets section, like this: - assets: - - assets/images/toolbar/ - - assets/images/selection_menu/ - - assets/images/image_toolbar/ - - assets/images/ - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages - -flutter_intl: - enabled: true - class_name: AppFlowyEditorLocalizations - main_locale: en - arb_dir: lib/l10n - output_dir: lib/src/l10n - use_deferred_loading: false - localizely: - project_id: b7199c7d-eca0-4025-894d-230cdcafa9aa diff --git a/frontend/app_flowy/packages/appflowy_editor/test/core/document/attributes_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/core/document/attributes_test.dart deleted file mode 100644 index 873ab2788be7a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/core/document/attributes_test.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - group('attributes.dart', () { - test('composeAttributes', () { - final base = { - 'a': 1, - 'b': 2, - }; - final other = { - 'b': 3, - 'c': 4, - 'd': null, - }; - expect(composeAttributes(base, other, keepNull: false), { - 'a': 1, - 'b': 3, - 'c': 4, - }); - expect(composeAttributes(base, other, keepNull: true), { - 'a': 1, - 'b': 3, - 'c': 4, - 'd': null, - }); - expect(composeAttributes(null, other, keepNull: false), { - 'b': 3, - 'c': 4, - }); - expect(composeAttributes(base, null, keepNull: false), { - 'a': 1, - 'b': 2, - }); - }); - - test('invertAttributes', () { - final base = { - 'a': 1, - 'b': 2, - }; - final other = { - 'b': 3, - 'c': 4, - 'd': null, - }; - expect(invertAttributes(base, other), { - 'a': 1, - 'b': 2, - 'c': null, - }); - expect(invertAttributes(other, base), { - 'a': null, - 'b': 3, - 'c': 4, - }); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/core/document/document_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/core/document/document_test.dart deleted file mode 100644 index 26e3a12c57b2d..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/core/document/document_test.dart +++ /dev/null @@ -1,138 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - group('documemnt.dart', () { - test('insert', () { - final document = Document.empty(); - - expect(document.insert([-1], []), false); - expect(document.insert([100], []), false); - - final node0 = Node(type: '0'); - final node1 = Node(type: '1'); - expect(document.insert([0], [node0, node1]), true); - expect(document.nodeAtPath([0])?.type, '0'); - expect(document.nodeAtPath([1])?.type, '1'); - }); - - test('delete', () { - final document = Document(root: Node(type: 'root')); - - expect(document.delete([-1], 1), false); - expect(document.delete([100], 1), false); - - for (var i = 0; i < 10; i++) { - final node = Node(type: '$i'); - document.insert([i], [node]); - } - - document.delete([0], 10); - expect(document.root.children.isEmpty, true); - }); - - test('update', () { - final node = Node(type: 'example', attributes: {'a': 'a'}); - final document = Document(root: Node(type: 'root')); - document.insert([0], [node]); - - final attributes = { - 'a': 'b', - 'b': 'c', - }; - - expect(document.update([0], attributes), true); - expect(document.nodeAtPath([0])?.attributes, attributes); - - expect(document.update([-1], attributes), false); - }); - - test('updateText', () { - final delta = Delta()..insert('Editor'); - final textNode = TextNode(delta: delta); - final document = Document(root: Node(type: 'root')); - document.insert([0], [textNode]); - document.updateText([0], Delta()..insert('AppFlowy')); - expect((document.nodeAtPath([0]) as TextNode).toPlainText(), - 'AppFlowyEditor'); - }); - - test('serialize', () { - final json = { - 'document': { - 'type': 'editor', - 'children': [ - { - 'type': 'text', - 'delta': [], - } - ], - 'attributes': {'a': 'a'} - } - }; - final document = Document.fromJson(json); - expect(document.toJson(), json); - }); - - test('isEmpty', () { - expect( - true, - Document.fromJson({ - 'document': { - 'type': 'editor', - 'children': [ - { - 'type': 'text', - 'delta': [], - } - ], - } - }).isEmpty, - ); - - expect( - true, - Document.fromJson({ - 'document': { - 'type': 'editor', - 'children': [], - } - }).isEmpty, - ); - - expect( - true, - Document.fromJson({ - 'document': { - 'type': 'editor', - 'children': [ - { - 'type': 'text', - 'delta': [ - {'insert': ''} - ], - } - ], - } - }).isEmpty, - ); - - expect( - false, - Document.fromJson({ - 'document': { - 'type': 'editor', - 'children': [ - { - 'type': 'text', - 'delta': [ - {'insert': 'Welcome to AppFlowy!'} - ], - } - ], - } - }).isEmpty, - ); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart deleted file mode 100644 index 05a0090ec3a44..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/core/document/node_iterator.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - group('node_iterator.dart', () { - test('', () { - final root = Node(type: 'root'); - for (var i = 1; i <= 10; i++) { - final node = Node(type: 'node_$i'); - for (var j = 1; j <= i; j++) { - node.insert(Node(type: 'node_${i}_$j')); - } - root.insert(node); - } - final nodes = NodeIterator( - document: Document(root: root), - startNode: root.childAtPath([0])!, - endNode: root.childAtPath([10, 10]), - ); - - for (var i = 1; i <= 10; i++) { - nodes.moveNext(); - expect(nodes.current.type, 'node_$i'); - for (var j = 1; j <= i; j++) { - nodes.moveNext(); - expect(nodes.current.type, 'node_${i}_$j'); - } - } - expect(nodes.moveNext(), false); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_test.dart deleted file mode 100644 index 4e407fd3213df..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_test.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - group('node.dart', () { - test('test node copyWith', () { - final node = Node( - type: 'example', - children: LinkedList(), - attributes: { - 'example': 'example', - }, - ); - expect(node.toJson(), { - 'type': 'example', - 'attributes': { - 'example': 'example', - }, - }); - expect( - node.copyWith().toJson(), - node.toJson(), - ); - - final nodeWithChildren = Node( - type: 'example', - children: LinkedList()..add(node), - attributes: { - 'example': 'example', - }, - ); - expect(nodeWithChildren.toJson(), { - 'type': 'example', - 'attributes': { - 'example': 'example', - }, - 'children': [ - { - 'type': 'example', - 'attributes': { - 'example': 'example', - }, - }, - ], - }); - expect( - nodeWithChildren.copyWith().toJson(), - nodeWithChildren.toJson(), - ); - }); - - test('test textNode copyWith', () { - final textNode = TextNode( - children: LinkedList(), - attributes: { - 'example': 'example', - }, - delta: Delta()..insert('AppFlowy'), - ); - expect(textNode.toJson(), { - 'type': 'text', - 'attributes': { - 'example': 'example', - }, - 'delta': [ - {'insert': 'AppFlowy'}, - ], - }); - expect( - textNode.copyWith().toJson(), - textNode.toJson(), - ); - - final textNodeWithChildren = TextNode( - children: LinkedList()..add(textNode), - attributes: { - 'example': 'example', - }, - delta: Delta()..insert('AppFlowy'), - ); - expect(textNodeWithChildren.toJson(), { - 'type': 'text', - 'attributes': { - 'example': 'example', - }, - 'delta': [ - {'insert': 'AppFlowy'}, - ], - 'children': [ - { - 'type': 'text', - 'attributes': { - 'example': 'example', - }, - 'delta': [ - {'insert': 'AppFlowy'}, - ], - }, - ], - }); - expect( - textNodeWithChildren.copyWith().toJson(), - textNodeWithChildren.toJson(), - ); - }); - - test('test node path', () { - Node previous = Node( - type: 'example', - attributes: {}, - children: LinkedList(), - ); - const len = 10; - for (var i = 0; i < len; i++) { - final node = Node( - type: 'example_$i', - attributes: {}, - children: LinkedList(), - ); - previous.children.add(node..parent = previous); - previous = node; - } - expect(previous.path, List.filled(len, 0)); - }); - - test('test copy with', () { - final child = Node( - type: 'child', - attributes: {}, - children: LinkedList(), - ); - final base = Node( - type: 'base', - attributes: {}, - children: LinkedList()..add(child), - ); - final node = base.copyWith( - type: 'node', - ); - expect(identical(node.attributes, base.attributes), false); - expect(identical(node.children, base.children), false); - expect(identical(node.children.first, base.children.first), false); - }); - - test('test insert', () { - final base = Node( - type: 'base', - ); - - // insert at the front when node's children is empty - final childA = Node( - type: 'child', - ); - base.insert(childA); - expect( - identical(base.childAtIndex(0), childA), - true, - ); - - // insert at the front - final childB = Node( - type: 'child', - ); - base.insert(childB, index: -1); - expect( - identical(base.childAtIndex(0), childB), - true, - ); - - // insert at the last - final childC = Node( - type: 'child', - ); - base.insert(childC, index: 1000); - expect( - identical(base.childAtIndex(base.children.length - 1), childC), - true, - ); - - // insert at the last - final childD = Node( - type: 'child', - ); - base.insert(childD); - expect( - identical(base.childAtIndex(base.children.length - 1), childD), - true, - ); - - // insert at the second - final childE = Node( - type: 'child', - ); - base.insert(childE, index: 1); - expect( - identical(base.childAtIndex(1), childE), - true, - ); - }); - - test('test fromJson', () { - final node = Node.fromJson({ - 'type': 'text', - 'delta': [ - {'insert': 'example'}, - ], - 'children': [ - { - 'type': 'example', - 'attributes': { - 'example': 'example', - }, - }, - ], - }); - expect(node.type, 'text'); - expect(node is TextNode, true); - expect((node as TextNode).delta.toPlainText(), 'example'); - expect(node.attributes, {}); - expect(node.children.length, 1); - expect(node.children.first.type, 'example'); - expect(node.children.first.attributes, {'example': 'example'}); - }); - - test('test toPlainText', () { - final textNode = TextNode.empty()..delta = (Delta()..insert('AppFlowy')); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/core/document/path_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/core/document/path_test.dart deleted file mode 100644 index cf11a96dd6f52..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/core/document/path_test.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - group('path.dart', () { - test('test path equality', () { - var p1 = [0, 0]; - var p2 = [0]; - - expect(p1 > p2, true); - expect(p1 >= p2, true); - expect(p1 < p2, false); - expect(p1 <= p2, false); - - p1 = [1, 1, 2]; - p2 = [1, 1, 3]; - - expect(p2 > p1, true); - expect(p2 >= p1, true); - expect(p2 < p1, false); - expect(p2 <= p1, false); - - p1 = [2, 0, 1]; - p2 = [2, 0, 1]; - - expect(p2 > p1, false); - expect(p1 > p2, false); - expect(p2 >= p1, true); - expect(p2 <= p1, true); - expect(p1.equals(p2), true); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/core/document/text_delta_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/core/document/text_delta_test.dart deleted file mode 100644 index c0c39466367eb..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/core/document/text_delta_test.dart +++ /dev/null @@ -1,332 +0,0 @@ -import 'package:appflowy_editor/src/core/document/attributes.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:appflowy_editor/src/core/document/text_delta.dart'; - -void main() { - group('text_delta.dart', () { - group('compose', () { - test('test delta', () { - final delta = Delta(operations: [ - TextInsert('Gandalf', attributes: { - 'bold': true, - }), - TextInsert(' the '), - TextInsert('Grey', attributes: { - 'color': '#ccc', - }) - ]); - - final death = Delta() - ..retain(12) - ..insert("White", attributes: { - 'color': '#fff', - }) - ..delete(4); - - final restores = delta.compose(death); - expect(restores.toList(), [ - TextInsert('Gandalf', attributes: {'bold': true}), - TextInsert(' the '), - TextInsert('White', attributes: {'color': '#fff'}), - ]); - }); - test('compose()', () { - final a = Delta()..insert('A'); - final b = Delta()..insert('B'); - final expected = Delta() - ..insert('B') - ..insert('A'); - expect(a.compose(b), expected); - }); - test('insert + retain', () { - final a = Delta()..insert('A'); - final b = Delta() - ..retain(1, attributes: { - 'bold': true, - 'color': 'red', - }); - final expected = Delta() - ..insert('A', attributes: { - 'bold': true, - 'color': 'red', - }); - expect(a.compose(b), expected); - }); - test('insert + delete', () { - final a = Delta()..insert('A'); - final b = Delta()..delete(1); - final expected = Delta(); - expect(a.compose(b), expected); - }); - test('delete + insert', () { - final a = Delta()..delete(1); - final b = Delta()..insert('B'); - final expected = Delta() - ..insert('B') - ..delete(1); - expect(a.compose(b), expected); - }); - test('delete + retain', () { - final a = Delta()..delete(1); - final b = Delta() - ..retain(1, attributes: { - 'bold': true, - 'color': 'red', - }); - final expected = Delta() - ..delete(1) - ..retain(1, attributes: { - 'bold': true, - 'color': 'red', - }); - expect(a.compose(b), expected); - }); - test('delete + delete', () { - final a = Delta()..delete(1); - final b = Delta()..delete(1); - final expected = Delta()..delete(2); - expect(a.compose(b), expected); - }); - test('retain + insert', () { - final a = Delta()..retain(1, attributes: {'color': 'blue'}); - final b = Delta()..insert('B'); - final expected = Delta() - ..insert('B') - ..retain(1, attributes: { - 'color': 'blue', - }); - expect(a.compose(b), expected); - }); - test('retain + retain', () { - final a = Delta() - ..retain(1, attributes: { - 'color': 'blue', - }); - final b = Delta() - ..retain(1, attributes: { - 'bold': true, - 'color': 'red', - }); - final expected = Delta() - ..retain(1, attributes: { - 'bold': true, - 'color': 'red', - }); - expect(a.compose(b), expected); - }); - test('retain + delete', () { - final a = Delta() - ..retain(1, attributes: { - 'color': 'blue', - }); - final b = Delta()..delete(1); - final expected = Delta()..delete(1); - expect(a.compose(b), expected); - }); - test('insert in middle of text', () { - final a = Delta()..insert('Hello'); - final b = Delta() - ..retain(3) - ..insert('X'); - final expected = Delta()..insert('HelXlo'); - expect(a.compose(b), expected); - }); - test('insert and delete ordering', () { - final a = Delta()..insert('Hello'); - final b = Delta()..insert('Hello'); - final insertFirst = Delta() - ..retain(3) - ..insert('X') - ..delete(1); - final deleteFirst = Delta() - ..retain(3) - ..delete(1) - ..insert('X'); - final expected = Delta()..insert('HelXo'); - expect(a.compose(insertFirst), expected); - expect(b.compose(deleteFirst), expected); - }); - test('delete entire text', () { - final a = Delta() - ..retain(4) - ..insert('Hello'); - final b = Delta()..delete(9); - final expected = Delta()..delete(4); - expect(a.compose(b), expected); - }); - test('retain more than length of text', () { - final a = Delta()..insert('Hello'); - final b = Delta()..retain(10); - final expected = Delta()..insert('Hello'); - expect(a.compose(b), expected); - }); - test('retain start optimization', () { - final a = Delta() - ..insert('A', attributes: {'bold': true}) - ..insert('B') - ..insert('C', attributes: {'bold': true}) - ..delete(1); - final b = Delta() - ..retain(3) - ..insert('D'); - final expected = Delta() - ..insert('A', attributes: {'bold': true}) - ..insert('B') - ..insert('C', attributes: {'bold': true}) - ..insert('D') - ..delete(1); - expect(a.compose(b), expected); - }); - test('retain end optimization', () { - final a = Delta() - ..insert('A', attributes: {'bold': true}) - ..insert('B') - ..insert('C', attributes: {'bold': true}); - final b = Delta()..delete(1); - final expected = Delta() - ..insert('B') - ..insert('C', attributes: {'bold': true}); - expect(a.compose(b), expected); - }); - test('retain end optimization join', () { - final a = Delta() - ..insert('A', attributes: {'bold': true}) - ..insert('B') - ..insert('C', attributes: {'bold': true}) - ..insert('D') - ..insert('E', attributes: {'bold': true}) - ..insert('F'); - final b = Delta() - ..retain(1) - ..delete(1); - final expected = Delta() - ..insert('AC', attributes: {'bold': true}) - ..insert('D') - ..insert('E', attributes: {'bold': true}) - ..insert('F'); - expect(a.compose(b), expected); - }); - }); - group('invert', () { - test('insert', () { - final delta = Delta() - ..retain(2) - ..insert('A'); - final base = Delta()..insert('12346'); - final expected = Delta() - ..retain(2) - ..delete(1); - final inverted = delta.invert(base); - expect(expected, inverted); - expect(base.compose(delta).compose(inverted), base); - }); - test('delete', () { - final delta = Delta() - ..retain(2) - ..delete(3); - final base = Delta()..insert('123456'); - final expected = Delta() - ..retain(2) - ..insert('345'); - final inverted = delta.invert(base); - expect(expected, inverted); - expect(base.compose(delta).compose(inverted), base); - }); - test('retain', () { - final delta = Delta() - ..retain(2) - ..retain(3, attributes: {'bold': true}); - final base = Delta()..insert('123456'); - final expected = Delta() - ..retain(2) - ..retain(3, attributes: {'bold': null}); - final inverted = delta.invert(base); - expect(expected, inverted); - final t = base.compose(delta).compose(inverted); - expect(t, base); - }); - }); - group('json', () { - test('toJson()', () { - final delta = Delta() - ..retain(2) - ..insert('A') - ..delete(3); - expect(delta.toJson(), [ - {'retain': 2}, - {'insert': 'A'}, - {'delete': 3} - ]); - }); - test('attributes', () { - final delta = Delta() - ..retain(2, attributes: {'bold': true}) - ..insert('A', attributes: {'italic': true}); - expect(delta.toJson(), [ - { - 'retain': 2, - 'attributes': {'bold': true}, - }, - { - 'insert': 'A', - 'attributes': {'italic': true}, - }, - ]); - }); - test('fromJson()', () { - final delta = Delta.fromJson([ - {'retain': 2}, - {'insert': 'A'}, - {'delete': 3}, - ]); - final expected = Delta() - ..retain(2) - ..insert('A') - ..delete(3); - expect(delta, expected); - }); - }); - group('runes', () { - test("stringIndexes", () { - final indexes = stringIndexes('😊'); - expect(indexes[0], 0); - expect(indexes[1], 0); - }); - test("next rune 1", () { - final delta = Delta()..insert('😊'); - expect(delta.nextRunePosition(0), 2); - }); - test("next rune 2", () { - final delta = Delta()..insert('😊a'); - expect(delta.nextRunePosition(0), 2); - }); - test("next rune 3", () { - final delta = Delta()..insert('😊陈'); - expect(delta.nextRunePosition(2), 3); - }); - test("prev rune 1", () { - final delta = Delta()..insert('😊陈'); - expect(delta.prevRunePosition(2), 0); - }); - test("prev rune 2", () { - final delta = Delta()..insert('😊'); - expect(delta.prevRunePosition(2), 0); - }); - test("prev rune 3", () { - final delta = Delta()..insert('😊'); - expect(delta.prevRunePosition(0), -1); - }); - }); - group("attributes", () { - test("compose", () { - final attrs = - composeAttributes({'a': null}, {'b': null}, keepNull: true); - expect(attrs != null, true); - expect(attrs?.containsKey("a"), true); - expect(attrs?.containsKey("b"), true); - expect(attrs?["a"], null); - expect(attrs?["b"], null); - }); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/core/location/position_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/core/location/position_test.dart deleted file mode 100644 index ded398e968d64..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/core/location/position_test.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - group('position.dart', () { - test('test position equality', () { - final positionA = Position(path: [0, 1, 2], offset: 3); - final positionB = Position(path: [0, 1, 2], offset: 3); - expect(positionA, positionB); - - final positionC = positionA.copyWith(offset: 4); - final positionD = positionB.copyWith(path: [1, 2, 3]); - expect(positionC.offset, 4); - expect(positionD.path, [1, 2, 3]); - - expect(positionA.toJson(), { - 'path': [0, 1, 2], - 'offset': 3, - }); - expect(positionC.toJson(), { - 'path': [0, 1, 2], - 'offset': 4, - }); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/core/location/selection_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/core/location/selection_test.dart deleted file mode 100644 index 4361ffcf67633..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/core/location/selection_test.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - group('selection.dart', () { - test('test selection equality', () { - final position = Position(path: [0, 1, 2], offset: 3); - final selectionA = Selection(start: position, end: position); - final selectionB = Selection.collapsed(position); - expect(selectionA, selectionB); - expect(selectionA.hashCode, selectionB.hashCode); - - final newPosition = Position(path: [1, 2, 3], offset: 4); - - final selectionC = selectionA.copyWith(start: newPosition); - expect(selectionC.start, newPosition); - expect(selectionC.end, position); - expect(selectionC.isCollapsed, false); - - final selectionD = selectionA.copyWith(end: newPosition); - expect(selectionD.start, position); - expect(selectionD.end, newPosition); - expect(selectionD.isCollapsed, false); - - final selectionE = Selection.single(path: [0, 1, 2], startOffset: 3); - expect(selectionE, selectionA); - expect(selectionE.isSingle, true); - expect(selectionE.isCollapsed, true); - }); - - test('test selection direction', () { - final start = Position(path: [0, 1, 2], offset: 3); - final end = Position(path: [1, 2, 3], offset: 3); - final backwardSelection = Selection(start: start, end: end); - expect(backwardSelection.isBackward, true); - final forwardSelection = Selection(start: end, end: start); - expect(forwardSelection.isForward, true); - - expect(backwardSelection.reversed, forwardSelection); - expect(forwardSelection.normalized, backwardSelection); - - expect(backwardSelection.startIndex, 3); - expect(backwardSelection.endIndex, 3); - }); - - test('test selection collapsed', () { - final start = Position(path: [0, 1, 2], offset: 3); - final end = Position(path: [1, 2, 3], offset: 3); - final selection = Selection(start: start, end: end); - final collapsedAtStart = selection.collapse(atStart: true); - expect(collapsedAtStart.isCollapsed, true); - expect(collapsedAtStart.start, start); - expect(collapsedAtStart.end, start); - - final collapsedAtEnd = selection.collapse(atStart: false); - expect(collapsedAtEnd.isCollapsed, true); - expect(collapsedAtEnd.start, end); - expect(collapsedAtEnd.end, end); - }); - - test('test selection toJson', () { - final start = Position(path: [0, 1, 2], offset: 3); - final end = Position(path: [1, 2, 3], offset: 3); - final selection = Selection(start: start, end: end); - expect(selection.toJson(), { - 'start': { - 'path': [0, 1, 2], - 'offset': 3 - }, - 'end': { - 'path': [1, 2, 3], - 'offset': 3 - } - }); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/core/transform/operation_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/core/transform/operation_test.dart deleted file mode 100644 index d52ba43221ba7..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/core/transform/operation_test.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - group('operation.dart', () { - test('test insert operation', () { - final node = Node(type: 'example'); - final op = InsertOperation([0], [node]); - final json = op.toJson(); - expect(json, { - 'op': 'insert', - 'path': [0], - 'nodes': [ - { - 'type': 'example', - } - ] - }); - expect(InsertOperation.fromJson(json), op); - expect(op.invert().invert(), op); - expect(op.copyWith(), op); - }); - - test('test update operation', () { - final op = UpdateOperation([0], {'a': 1}, {'a': 0}); - final json = op.toJson(); - expect(json, { - 'op': 'update', - 'path': [0], - 'attributes': {'a': 1}, - 'oldAttributes': {'a': 0} - }); - expect(UpdateOperation.fromJson(json), op); - expect(op.invert().invert(), op); - expect(op.copyWith(), op); - }); - - test('test delete operation', () { - final node = Node(type: 'example'); - final op = DeleteOperation([0], [node]); - final json = op.toJson(); - expect(json, { - 'op': 'delete', - 'path': [0], - 'nodes': [ - { - 'type': 'example', - } - ] - }); - expect(DeleteOperation.fromJson(json), op); - expect(op.invert().invert(), op); - expect(op.copyWith(), op); - }); - - test('test update text operation', () { - final app = Delta()..insert('App'); - final appflowy = Delta() - ..retain(3) - ..insert('Flowy'); - final op = UpdateTextOperation([0], app, appflowy.invert(app)); - final json = op.toJson(); - expect(json, { - 'op': 'update_text', - 'path': [0], - 'delta': [ - {'insert': 'App'} - ], - 'inverted': [ - {'retain': 3}, - {'delete': 5} - ] - }); - expect(UpdateTextOperation.fromJson(json), op); - expect(op.invert().invert(), op); - expect(op.copyWith(), op); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/extensions/attributes_extension_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/extensions/attributes_extension_test.dart deleted file mode 100644 index 427e53f32a3f9..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/extensions/attributes_extension_test.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('NodeAttributesExtensions::', () { - test('heading', () { - final Attributes attribute = { - 'subtype': 'heading', - 'heading': 'AppFlowy', - }; - expect(attribute.heading, 'AppFlowy'); - }); - - test('heading - text is not String return null', () { - final Attributes attribute = { - 'subtype': 'heading', - 'heading': 123, - }; - expect(attribute.heading, null); - }); - - test('heading - subtype is not "heading" return null', () { - final Attributes attribute = { - 'subtype': 'code', - 'heading': 'Hello World!', - }; - expect(attribute.heading, null); - }); - - test('quote', () { - final Attributes attribute = { - 'quote': 'quote text', - }; - expect(attribute.quote, true); - }); - - test('number - int', () { - final Attributes attribute = { - 'number': 99, - }; - expect(attribute.number, 99); - }); - - test('number - double', () { - final Attributes attribute = { - 'number': 12.34, - }; - expect(attribute.number, 12.34); - }); - - test('number - return null', () { - final Attributes attribute = { - 'code': 12.34, - }; - expect(attribute.number, null); - }); - - test('code', () { - final Attributes attribute = { - 'code': true, - }; - expect(attribute.code, true); - }); - - test('code - return false', () { - final Attributes attribute = { - 'quote': true, - }; - expect(attribute.code, false); - }); - - test('check', () { - final Attributes attribute = { - 'checkbox': true, - }; - expect(attribute.check, true); - }); - - test('check - return false', () { - final Attributes attribute = { - 'quote': true, - }; - expect(attribute.check, false); - }); - }); - - group('DeltaAttributesExtensions::', () { - test('bold', () { - final Attributes attribute = { - 'bold': true, - }; - expect(attribute.bold, true); - }); - - test('bold - return false', () { - final Attributes attribute = { - 'bold': 123, - }; - expect(attribute.bold, false); - }); - - test('italic', () { - final Attributes attribute = { - 'italic': true, - }; - expect(attribute.italic, true); - }); - - test('italic - return false', () { - final Attributes attribute = { - 'italic': 123, - }; - expect(attribute.italic, false); - }); - - test('underline', () { - final Attributes attribute = { - 'underline': true, - }; - expect(attribute.underline, true); - }); - - test('underline - return false', () { - final Attributes attribute = { - 'underline': 123, - }; - expect(attribute.underline, false); - }); - - test('strikethrough', () { - final Attributes attribute = { - 'strikethrough': true, - }; - expect(attribute.strikethrough, true); - }); - - test('strikethrough - return false', () { - final Attributes attribute = { - 'strikethrough': 123, - }; - expect(attribute.strikethrough, false); - }); - - test('color', () { - final Attributes attribute = { - 'color': '0xff212fff', - }; - expect(attribute.color, const Color(0XFF212FFF)); - }); - - test('color - return null', () { - final Attributes attribute = { - 'color': 123, - }; - expect(attribute.color, null); - }); - - test('color - parse failure return white', () { - final Attributes attribute = { - 'color': 'hello123', - }; - expect(attribute.color, const Color(0XFFFFFFFF)); - }); - - test('backgroundColor', () { - final Attributes attribute = { - 'backgroundColor': '0xff678fff', - }; - expect(attribute.backgroundColor, const Color(0XFF678FFF)); - }); - - test('backgroundColor - return null', () { - final Attributes attribute = { - 'backgroundColor': 123, - }; - expect(attribute.backgroundColor, null); - }); - - test('backgroundColor - parse failure return white', () { - final Attributes attribute = { - 'backgroundColor': 'hello123', - }; - expect(attribute.backgroundColor, const Color(0XFFFFFFFF)); - }); - - test('href', () { - final Attributes attribute = { - 'href': '/app/flowy', - }; - expect(attribute.href, '/app/flowy'); - }); - - test('href - return null', () { - final Attributes attribute = { - 'href': 123, - }; - expect(attribute.href, null); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/extensions/color_extension_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/extensions/color_extension_test.dart deleted file mode 100644 index 929a5c037827f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/extensions/color_extension_test.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:appflowy_editor/src/extensions/color_extension.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('ColorExtension::', () { - const white = Color(0XFFFFFFFF); - const black = Color(0XFF000000); - const blue = Color(0XFF000FFF); - const blueRgba = 'rgba(0, 15, 255, 255)'; - test('ToRgbaString', () { - expect(blue.toRgbaString(), 'rgba(0, 15, 255, 255)'); - expect(white.toRgbaString(), 'rgba(255, 255, 255, 255)'); - expect(black.toRgbaString(), 'rgba(0, 0, 0, 255)'); - }); - - test('tryFromRgbaString', () { - final color = ColorExtension.tryFromRgbaString(blueRgba); - expect(color, const Color.fromARGB(255, 0, 15, 255)); - }); - - test('tryFromRgbaString - wrong rgba format return null', () { - const wrongRgba = 'abc(1,2,3,4)'; - final color = ColorExtension.tryFromRgbaString(wrongRgba); - expect(color, null); - }); - - test('tryFromRgbaString - wrong length return null', () { - const wrongRgba = 'rgba(0, 15, 255)'; - final color = ColorExtension.tryFromRgbaString(wrongRgba); - expect(color, null); - }); - - test('tryFromRgbaString - wrong values return null', () { - const wrongRgba = 'rgba(-12, 999, 1234, 619)'; - final color = ColorExtension.tryFromRgbaString(wrongRgba); - expect(color, null); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/extensions/node_extension_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/extensions/node_extension_test.dart deleted file mode 100644 index 0e31aa5a96c38..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/extensions/node_extension_test.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:collection'; -import 'dart:ui'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:appflowy_editor/src/extensions/node_extensions.dart'; - -class MockNode extends Mock implements Node {} - -void main() { - final mockNode = MockNode(); - - group('NodeExtensions::', () { - final selection = Selection( - start: Position(path: [0]), - end: Position(path: [1]), - ); - - test('rect - renderBox is null', () { - when(mockNode.renderBox).thenReturn(null); - final result = mockNode.rect; - expect(result, Rect.zero); - }); - - test('inSelection', () { - // I use an empty implementation instead of mock, because the mocked - // version throws error trying to access the path. - - final subLinkedList = LinkedList() - ..addAll([ - Node(type: 'type', children: LinkedList(), attributes: {}), - Node(type: 'type', children: LinkedList(), attributes: {}), - Node(type: 'type', children: LinkedList(), attributes: {}), - Node(type: 'type', children: LinkedList(), attributes: {}), - Node(type: 'type', children: LinkedList(), attributes: {}), - ]); - - final linkedList = LinkedList() - ..addAll([ - Node( - type: 'type', - children: subLinkedList, - attributes: {}, - ), - ]); - - final node = Node( - type: 'type', - children: linkedList, - attributes: {}, - ); - final result = node.inSelection(selection); - expect(result, false); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/extensions/object_extension_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/extensions/object_extension_test.dart deleted file mode 100644 index 151df9cc31d39..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/extensions/object_extension_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:appflowy_editor/src/extensions/object_extensions.dart'; - -void main() { - group('FlowyObjectExtensions::', () { - test('unwrapOrNull', () { - final result = const TextSpan().unwrapOrNull(); - assert(result is TextSpan); - }); - - test('unwrapOrNull - return null', () { - final result = const TextSpan().unwrapOrNull(); - expect(result, null); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/extensions/path_extensions_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/extensions/path_extensions_test.dart deleted file mode 100644 index 231be64c0a627..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/extensions/path_extensions_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('path_extensions.dart', () { - test('test path equality', () { - var p1 = [0, 0]; - var p2 = [0]; - - expect(p1 > p2, true); - expect(p1 >= p2, true); - expect(p1 < p2, false); - expect(p1 <= p2, false); - - p1 = [1, 1, 2]; - p2 = [1, 1, 3]; - - expect(p2 > p1, true); - expect(p2 >= p1, true); - expect(p2 < p1, false); - expect(p2 <= p1, false); - - p1 = [2, 0, 1]; - p2 = [2, 0, 1]; - - expect(p2 > p1, false); - expect(p1 > p2, false); - expect(p2 >= p1, true); - expect(p2 <= p1, true); - expect(p1.equals(p2), true); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/extensions/text_node_extensions_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/extensions/text_node_extensions_test.dart deleted file mode 100644 index 0d7f1ba1254fa..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/extensions/text_node_extensions_test.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('TextNodeExtension::', () { - test('description', () {}); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/extensions/text_style_extension_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/extensions/text_style_extension_test.dart deleted file mode 100644 index 572178d0f37ca..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/extensions/text_style_extension_test.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:appflowy_editor/src/extensions/text_style_extension.dart'; - -void main() { - group('TextStyleExtensions::', () { - const style = TextStyle( - color: Colors.blue, - backgroundColor: Colors.white, - fontSize: 14, - height: 100, - wordSpacing: 2, - fontWeight: FontWeight.w700, - ); - - const otherStyle = TextStyle( - color: Colors.red, - backgroundColor: Colors.black, - fontSize: 12, - height: 10, - wordSpacing: 1, - ); - test('combine', () { - final result = style.combine(otherStyle); - expect(result.color, Colors.red); - expect(result.backgroundColor, Colors.black); - expect(result.fontSize, 12); - expect(result.height, 10); - expect(result.wordSpacing, 1); - }); - - test('combine - return this', () { - final result = style.combine(null); - expect(result, style); - }); - - test('combine - return null with inherit', () { - final styleCopy = otherStyle.copyWith(inherit: false); - final result = style.combine(styleCopy); - expect(result, styleCopy); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/extensions/url_launcher_extension_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/extensions/url_launcher_extension_test.dart deleted file mode 100644 index 81b5b51e37ace..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/extensions/url_launcher_extension_test.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('safeLaunchUrl without scheme', () async { - const href = null; - final result = await safeLaunchUrl(href); - expect(result, false); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/infra/infra_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/infra/infra_test.dart deleted file mode 100644 index 9ed4a150b2cb4..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/infra/infra_test.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/infra/infra.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - group('infra.dart', () { - test('find the last text node', () { - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - const text = 'Welcome to Appflowy 😁'; - TextNode textNode() { - return TextNode( - delta: Delta()..insert(text), - ); - } - - final node110 = textNode(); - final node111 = textNode(); - final node11 = textNode() - ..insert(node110) - ..insert(node111); - final node10 = textNode(); - final node1 = textNode() - ..insert(node10) - ..insert(node11); - final node0 = textNode(); - final node = textNode() - ..insert(node0) - ..insert(node1); - - expect(Infra.findLastTextNode(node)?.path, [1, 1, 1]); - expect(Infra.findLastTextNode(node0)?.path, [0]); - expect(Infra.findLastTextNode(node1)?.path, [1, 1, 1]); - expect(Infra.findLastTextNode(node10)?.path, [1, 0]); - expect(Infra.findLastTextNode(node11)?.path, [1, 1, 1]); - - expect(Infra.forwardNearestTextNode(node111)?.path, [1, 1, 0]); - expect(Infra.forwardNearestTextNode(node110)?.path, [1, 1]); - expect(Infra.forwardNearestTextNode(node11)?.path, [1, 0]); - expect(Infra.forwardNearestTextNode(node10)?.path, [1]); - expect(Infra.forwardNearestTextNode(node1)?.path, [0]); - expect(Infra.forwardNearestTextNode(node0)?.path, []); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/infra/log_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/infra/log_test.dart deleted file mode 100644 index f49a32c130183..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/infra/log_test.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:appflowy_editor/src/infra/log.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'test_editor.dart'; - -void main() async { - group('log.dart', () { - testWidgets('test LogConfiguration in EditorState', (tester) async { - TestWidgetsFlutterBinding.ensureInitialized(); - - const text = 'Welcome to Appflowy 😁'; - - final List logs = []; - - final editor = tester.editor; - editor.editorState.logConfiguration - ..level = LogLevel.all - ..handler = (message) { - logs.add(message); - }; - - Log.editor.debug(text); - expect(logs.last.contains('DEBUG'), true); - expect(logs.length, 1); - }); - - test('test LogLevel.all', () { - const text = 'Welcome to Appflowy 😁'; - - final List logs = []; - LogConfiguration() - ..level = LogLevel.all - ..handler = (message) { - logs.add(message); - }; - - Log.editor.debug(text); - expect(logs.last.contains('DEBUG'), true); - Log.editor.info(text); - expect(logs.last.contains('INFO'), true); - Log.editor.warn(text); - expect(logs.last.contains('WARN'), true); - Log.editor.error(text); - expect(logs.last.contains('ERROR'), true); - - expect(logs.length, 4); - }); - - test('test LogLevel.off', () { - const text = 'Welcome to Appflowy 😁'; - - final List logs = []; - LogConfiguration() - ..level = LogLevel.off - ..handler = (message) { - logs.add(message); - }; - - Log.editor.debug(text); - Log.editor.info(text); - Log.editor.warn(text); - Log.editor.error(text); - - expect(logs.length, 0); - }); - - test('test LogLevel.error', () { - const text = 'Welcome to Appflowy 😁'; - - final List logs = []; - LogConfiguration() - ..level = LogLevel.error - ..handler = (message) { - logs.add(message); - }; - - Log.editor.debug(text); - Log.editor.info(text); - Log.editor.warn(text); - Log.editor.error(text); - - expect(logs.length, 1); - }); - - test('test LogLevel.warn', () { - const text = 'Welcome to Appflowy 😁'; - - final List logs = []; - LogConfiguration() - ..level = LogLevel.warn - ..handler = (message) { - logs.add(message); - }; - - Log.editor.debug(text); - Log.editor.info(text); - Log.editor.warn(text); - Log.editor.error(text); - - expect(logs.length, 2); - }); - - test('test LogLevel.info', () { - const text = 'Welcome to Appflowy 😁'; - - final List logs = []; - LogConfiguration() - ..level = LogLevel.info - ..handler = (message) { - logs.add(message); - }; - - Log.editor.debug(text); - Log.editor.info(text); - Log.editor.warn(text); - Log.editor.error(text); - - expect(logs.length, 3); - }); - - test('test LogLevel.debug', () { - const text = 'Welcome to Appflowy 😁'; - - final List logs = []; - LogConfiguration() - ..level = LogLevel.debug - ..handler = (message) { - logs.add(message); - }; - - Log.editor.debug(text); - Log.editor.info(text); - Log.editor.warn(text); - Log.editor.error(text); - - expect(logs.length, 4); - }); - - test('test logger', () { - const text = 'Welcome to Appflowy 😁'; - - final List logs = []; - LogConfiguration() - ..level = LogLevel.all - ..handler = (message) { - logs.add(message); - }; - - Log.editor.debug(text); - expect(logs.last.contains('editor'), true); - - Log.selection.debug(text); - expect(logs.last.contains('selection'), true); - - Log.keyboard.debug(text); - expect(logs.last.contains('keyboard'), true); - - Log.input.debug(text); - expect(logs.last.contains('input'), true); - - Log.scroll.debug(text); - expect(logs.last.contains('scroll'), true); - - Log.ui.debug(text); - expect(logs.last.contains('ui'), true); - - expect(logs.length, 6); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart deleted file mode 100644 index d37189548795b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'test_raw_key_event.dart'; - -class EditorWidgetTester { - EditorWidgetTester({ - required this.tester, - }); - - final WidgetTester tester; - late EditorState _editorState; - - EditorState get editorState => _editorState; - Node get root => _editorState.document.root; - - Document get document => _editorState.document; - int get documentLength => _editorState.document.root.children.length; - Selection? get documentSelection => - _editorState.service.selectionService.currentSelection.value; - - Future startTesting({ - Locale locale = const Locale('en'), - }) async { - final app = MaterialApp( - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - AppFlowyEditorLocalizations.delegate, - ], - supportedLocales: AppFlowyEditorLocalizations.delegate.supportedLocales, - locale: locale, - home: Scaffold( - body: AppFlowyEditor( - editorState: _editorState, - ), - ), - ); - await tester.pumpWidget(app); - await tester.pump(); - return this; - } - - void initialize() { - _editorState = _createEmptyDocument(); - } - - void insert(T node) { - _editorState.document.root.insert(node); - } - - void insertEmptyTextNode() { - insert(TextNode.empty()); - } - - void insertTextNode(String? text, {Attributes? attributes, Delta? delta}) { - insert( - TextNode( - delta: delta ?? Delta(operations: [TextInsert(text ?? 'Test')]), - attributes: attributes, - ), - ); - } - - void insertImageNode(String src, {String? align}) { - insert( - Node( - type: 'image', - children: LinkedList(), - attributes: { - 'image_src': src, - 'align': align ?? 'center', - }, - ), - ); - } - - Node? nodeAtPath(Path path) { - return root.childAtPath(path); - } - - Future updateSelection(Selection? selection) async { - if (selection == null) { - _editorState.service.selectionService.clearSelection(); - } else { - _editorState.service.selectionService.updateSelection(selection); - } - await tester.pump(const Duration(milliseconds: 200)); - - expect(_editorState.service.selectionService.currentSelection.value, - selection); - } - - Future insertText(TextNode textNode, String text, int offset, - {Selection? selection}) async { - await apply([ - TextEditingDeltaInsertion( - oldText: textNode.toPlainText(), - textInserted: text, - insertionOffset: offset, - selection: selection != null - ? TextSelection( - baseOffset: selection.start.offset, - extentOffset: selection.end.offset) - : TextSelection.collapsed(offset: offset), - composing: TextRange.empty, - ) - ]); - } - - Future apply(List deltas) async { - _editorState.service.inputService?.apply(deltas); - await tester.pumpAndSettle(); - } - - Future pressLogicKey( - LogicalKeyboardKey key, { - bool isControlPressed = false, - bool isShiftPressed = false, - bool isAltPressed = false, - bool isMetaPressed = false, - }) async { - if (!isControlPressed && - !isShiftPressed && - !isAltPressed && - !isMetaPressed) { - await tester.sendKeyDownEvent(key); - } else { - final testRawKeyEventData = TestRawKeyEventData( - logicalKey: key, - isControlPressed: isControlPressed, - isShiftPressed: isShiftPressed, - isAltPressed: isAltPressed, - isMetaPressed: isMetaPressed, - ).toKeyEvent; - _editorState.service.keyboardService!.onKey(testRawKeyEventData); - } - await tester.pumpAndSettle(); - } - - Node _createEmptyEditorRoot() { - return Node( - type: 'editor', - children: LinkedList(), - attributes: {}, - ); - } - - EditorState _createEmptyDocument() { - return EditorState( - document: Document( - root: _createEmptyEditorRoot(), - ), - )..disableSealTimer = true; - } -} - -extension TestString on String { - String safeSubString([int start = 0, int? end]) { - end ??= length - 1; - end = end.clamp(start, length - 1); - final sRunes = runes; - return String.fromCharCodes(sRunes, start, end); - } -} - -extension TestEditorExtension on WidgetTester { - EditorWidgetTester get editor => - EditorWidgetTester(tester: this)..initialize(); - EditorState get editorState => editor.editorState; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart deleted file mode 100644 index 5102407e1b447..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:flutter/services.dart'; - -class TestRawKeyEvent extends RawKeyDownEvent { - const TestRawKeyEvent({ - required super.data, - this.isControlPressed = false, - this.isShiftPressed = false, - this.isAltPressed = false, - this.isMetaPressed = false, - }); - - @override - final bool isControlPressed; - - @override - final bool isShiftPressed; - - @override - final bool isAltPressed; - - @override - final bool isMetaPressed; -} - -class TestRawKeyEventData extends RawKeyEventData { - const TestRawKeyEventData({ - required this.logicalKey, - this.isControlPressed = false, - this.isShiftPressed = false, - this.isAltPressed = false, - this.isMetaPressed = false, - }); - - @override - final bool isControlPressed; - - @override - final bool isShiftPressed; - - @override - final bool isAltPressed; - - @override - final bool isMetaPressed; - - @override - final LogicalKeyboardKey logicalKey; - - @override - PhysicalKeyboardKey get physicalKey => logicalKey.toPhysicalKey; - - @override - KeyboardSide? getModifierSide(ModifierKey key) { - throw UnimplementedError(); - } - - @override - bool isModifierPressed(ModifierKey key, - {KeyboardSide side = KeyboardSide.any}) { - throw UnimplementedError(); - } - - @override - String get keyLabel => throw UnimplementedError(); - - RawKeyEvent get toKeyEvent { - return TestRawKeyEvent( - data: this, - isAltPressed: isAltPressed, - isControlPressed: isControlPressed, - isMetaPressed: isMetaPressed, - isShiftPressed: isShiftPressed, - ); - } -} - -extension on LogicalKeyboardKey { - PhysicalKeyboardKey get toPhysicalKey { - if (this == LogicalKeyboardKey.enter) { - return PhysicalKeyboardKey.enter; - } - if (this == LogicalKeyboardKey.space) { - return PhysicalKeyboardKey.space; - } - if (this == LogicalKeyboardKey.backspace) { - return PhysicalKeyboardKey.backspace; - } - if (this == LogicalKeyboardKey.delete) { - return PhysicalKeyboardKey.delete; - } - if (this == LogicalKeyboardKey.arrowRight) { - return PhysicalKeyboardKey.arrowRight; - } - if (this == LogicalKeyboardKey.arrowLeft) { - return PhysicalKeyboardKey.arrowLeft; - } - if (this == LogicalKeyboardKey.pageDown) { - return PhysicalKeyboardKey.pageDown; - } - if (this == LogicalKeyboardKey.pageUp) { - return PhysicalKeyboardKey.pageUp; - } - if (this == LogicalKeyboardKey.slash) { - return PhysicalKeyboardKey.slash; - } - if (this == LogicalKeyboardKey.arrowUp) { - return PhysicalKeyboardKey.arrowUp; - } - if (this == LogicalKeyboardKey.arrowDown) { - return PhysicalKeyboardKey.arrowDown; - } - if (this == LogicalKeyboardKey.keyA) { - return PhysicalKeyboardKey.keyA; - } - if (this == LogicalKeyboardKey.keyB) { - return PhysicalKeyboardKey.keyB; - } - if (this == LogicalKeyboardKey.keyC) { - return PhysicalKeyboardKey.keyC; - } - if (this == LogicalKeyboardKey.keyE) { - return PhysicalKeyboardKey.keyE; - } - if (this == LogicalKeyboardKey.keyI) { - return PhysicalKeyboardKey.keyI; - } - if (this == LogicalKeyboardKey.keyK) { - return PhysicalKeyboardKey.keyK; - } - if (this == LogicalKeyboardKey.keyS) { - return PhysicalKeyboardKey.keyS; - } - if (this == LogicalKeyboardKey.keyU) { - return PhysicalKeyboardKey.keyU; - } - if (this == LogicalKeyboardKey.keyH) { - return PhysicalKeyboardKey.keyH; - } - if (this == LogicalKeyboardKey.keyZ) { - return PhysicalKeyboardKey.keyZ; - } - if (this == LogicalKeyboardKey.asterisk) { - return PhysicalKeyboardKey.digit8; - } - if (this == LogicalKeyboardKey.underscore) { - return PhysicalKeyboardKey.minus; - } - if (this == LogicalKeyboardKey.tilde) { - return PhysicalKeyboardKey.backquote; - } - throw UnimplementedError(); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/l10n/l10n_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/l10n/l10n_test.dart deleted file mode 100644 index 2e149d7b4ff05..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/l10n/l10n_test.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('l10n.dart', () { - for (final locale - in AppFlowyEditorLocalizations.delegate.supportedLocales) { - testWidgets('test localization', (tester) async { - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(locale: locale); - }); - } - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/legacy/flowy_editor_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/legacy/flowy_editor_test.dart deleted file mode 100644 index 29c38650f3a11..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/legacy/flowy_editor_test.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:appflowy_editor/src/core/document/path.dart'; -import 'package:appflowy_editor/src/core/location/position.dart'; -import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - test('create state tree', () async { - // final String response = await rootBundle.loadString('assets/document.json'); - // final data = Map.from(json.decode(response)); - // final document = Document.fromJson(data); - // expect(document.root.type, 'root'); - // expect(document.root.toJson(), data['document']); - }); - - test('search node by Path in state tree', () async { - // final String response = await rootBundle.loadString('assets/document.json'); - // final data = Map.from(json.decode(response)); - // final document = Document.fromJson(data); - // final checkBoxNode = document.root.childAtPath([1, 0]); - // expect(checkBoxNode != null, true); - // final textType = checkBoxNode!.attributes['text-type']; - // expect(textType != null, true); - }); - - test('search node by Self in state tree', () async { - // final String response = await rootBundle.loadString('assets/document.json'); - // final data = Map.from(json.decode(response)); - // final document = Document.fromJson(data); - // final checkBoxNode = document.root.childAtPath([1, 0]); - // expect(checkBoxNode != null, true); - // final textType = checkBoxNode!.attributes['text-type']; - // expect(textType != null, true); - // final path = checkBoxNode.path; - // expect(pathEquals(path, [1, 0]), true); - }); - - test('insert node in state tree', () async { - // final String response = await rootBundle.loadString('assets/document.json'); - // final data = Map.from(json.decode(response)); - // final document = Document.fromJson(data); - // final insertNode = Node.fromJson({ - // 'type': 'text', - // }); - // bool result = document.insert([1, 1], [insertNode]); - // expect(result, true); - // expect(identical(insertNode, document.nodeAtPath([1, 1])), true); - }); - - test('delete node in state tree', () async { - // final String response = await rootBundle.loadString('assets/document.json'); - // final data = Map.from(json.decode(response)); - // final document = Document.fromJson(data); - // document.delete([1, 1], 1); - // final node = document.nodeAtPath([1, 1]); - // expect(node != null, true); - // expect(node!.attributes['tag'], '**'); - }); - - test('update node in state tree', () async { - // final String response = await rootBundle.loadString('assets/document.json'); - // final data = Map.from(json.decode(response)); - // final document = Document.fromJson(data); - // final test = document.update([1, 1], {'text-type': 'heading1'}); - // expect(test, true); - // final updatedNode = document.nodeAtPath([1, 1]); - // expect(updatedNode != null, true); - // expect(updatedNode!.attributes['text-type'], 'heading1'); - }); - - test('test path utils 1', () { - final path1 = [1]; - final path2 = [1]; - expect(path1.equals(path2), true); - - expect(Object.hashAll(path1), Object.hashAll(path2)); - }); - - test('test path utils 2', () { - final path1 = [1]; - final path2 = [2]; - expect(path1.equals(path2), false); - - expect(Object.hashAll(path1) != Object.hashAll(path2), true); - }); - - test('test position comparator', () { - final pos1 = Position(path: [1], offset: 0); - final pos2 = Position(path: [1], offset: 0); - expect(pos1 == pos2, true); - expect(pos1.hashCode == pos2.hashCode, true); - }); - - test('test position comparator with offset', () { - final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100); - final pos2 = Position(path: [1, 1, 1, 1, 1], offset: 100); - expect(pos1, pos2); - expect(pos1.hashCode, pos2.hashCode); - }); - - test('test position comparator false', () { - final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100); - final pos2 = Position(path: [1, 1, 2, 1, 1], offset: 100); - expect(pos1 == pos2, false); - expect(pos1.hashCode == pos2.hashCode, false); - }); - - test('test position comparator with offset false', () { - final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100); - final pos2 = Position(path: [1, 1, 1, 1, 1], offset: 101); - expect(pos1 == pos2, false); - expect(pos1.hashCode == pos2.hashCode, false); - }); - - test('test selection comparator', () { - final pos = Position(path: [0], offset: 0); - final sel = Selection.collapsed(pos); - expect(sel.start, sel.end); - expect(sel.isCollapsed, true); - }); - - test('test selection collapse', () { - final start = Position(path: [0], offset: 0); - final end = Position(path: [0], offset: 10); - final sel = Selection(start: start, end: end); - - final collapsedSelAtStart = sel.collapse(atStart: true); - expect(collapsedSelAtStart.start, start); - expect(collapsedSelAtStart.end, start); - - final collapsedSelAtEnd = sel.collapse(); - expect(collapsedSelAtEnd.start, end); - expect(collapsedSelAtEnd.end, end); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart deleted file mode 100644 index 2516240b17ae7..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:appflowy_editor/src/core/transform/operation.dart'; -import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/core/document/document.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - group('transform path', () { - test('transform path changed', () { - expect(transformPath([0, 1], [0, 1]), [0, 2]); - expect(transformPath([0, 1], [0, 2]), [0, 3]); - expect(transformPath([0, 1], [0, 2, 7, 8, 9]), [0, 3, 7, 8, 9]); - expect(transformPath([0, 1, 2], [0, 0, 7, 8, 9]), [0, 0, 7, 8, 9]); - }); - test("transform path not changed", () { - expect(transformPath([0, 1, 2], [0, 0, 7, 8, 9]), [0, 0, 7, 8, 9]); - expect(transformPath([0, 1, 2], [0, 1]), [0, 1]); - expect(transformPath([1, 1], [1, 0]), [1, 0]); - }); - test("transform path delta", () { - expect(transformPath([0, 1], [0, 1], 5), [0, 6]); - }); - }); - group('transform operation', () { - test('insert + insert', () { - final t = transformOperation( - InsertOperation([0, 1], - [Node(type: "node", attributes: {}, children: LinkedList())]), - InsertOperation([0, 1], - [Node(type: "node", attributes: {}, children: LinkedList())])); - expect(t.path, [0, 2]); - }); - test('delete + delete', () { - final t = transformOperation( - DeleteOperation([0, 1], - [Node(type: "node", attributes: {}, children: LinkedList())]), - DeleteOperation([0, 2], - [Node(type: "node", attributes: {}, children: LinkedList())])); - expect(t.path, [0, 1]); - }); - }); - test('transform transaction builder', () { - final item1 = Node(type: "node", attributes: {}, children: LinkedList()); - final item2 = Node(type: "node", attributes: {}, children: LinkedList()); - final item3 = Node(type: "node", attributes: {}, children: LinkedList()); - final root = Node( - type: "root", - attributes: {}, - children: LinkedList() - ..addAll([ - item1, - item2, - item3, - ]), - ); - final state = EditorState(document: Document(root: root)); - - expect(item1.path, [0]); - expect(item2.path, [1]); - expect(item3.path, [2]); - - final transaction = state.transaction; - transaction.deleteNode(item1); - transaction.deleteNode(item2); - transaction.deleteNode(item3); - state.apply(transaction); - expect(transaction.operations[0].path, [0]); - expect(transaction.operations[1].path, [0]); - expect(transaction.operations[2].path, [0]); - }); - group("toJson", () { - test("insert", () { - final root = Node(type: "root", attributes: {}, children: LinkedList()); - final state = EditorState(document: Document(root: root)); - - final item1 = Node(type: "node", attributes: {}, children: LinkedList()); - final transaction = state.transaction; - transaction.insertNode([0], item1); - state.apply(transaction); - expect(transaction.toJson(), { - "operations": [ - { - "op": "insert", - "path": [0], - "nodes": [item1.toJson()], - } - ] - }); - }); - test("delete", () { - final item1 = Node(type: "node", attributes: {}, children: LinkedList()); - final root = Node( - type: "root", - attributes: {}, - children: LinkedList() - ..addAll([ - item1, - ]), - ); - final state = EditorState(document: Document(root: root)); - final transaction = state.transaction; - transaction.deleteNode(item1); - state.apply(transaction); - expect(transaction.toJson(), { - "operations": [ - { - "op": "delete", - "path": [0], - "nodes": [item1.toJson()], - } - ], - }); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/legacy/undo_manager_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/legacy/undo_manager_test.dart deleted file mode 100644 index 7fbf4ec162875..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/legacy/undo_manager_test.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'dart:collection'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/history/undo_manager.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - Node _createEmptyEditorRoot() { - return Node( - type: 'editor', - children: LinkedList(), - attributes: {}, - ); - } - - test("HistoryItem #1", () { - final document = Document(root: _createEmptyEditorRoot()); - final editorState = EditorState(document: document); - - final historyItem = HistoryItem(); - historyItem - .add(DeleteOperation([0], [TextNode(delta: Delta()..insert('0'))])); - historyItem - .add(DeleteOperation([0], [TextNode(delta: Delta()..insert('1'))])); - historyItem - .add(DeleteOperation([0], [TextNode(delta: Delta()..insert('2'))])); - - final transaction = historyItem.toTransaction(editorState); - assert(isInsertAndPathEqual(transaction.operations[0], [0], '2')); - assert(isInsertAndPathEqual(transaction.operations[1], [0], '1')); - assert(isInsertAndPathEqual(transaction.operations[2], [0], '0')); - }); - - test("HistoryItem #2", () { - final document = Document(root: _createEmptyEditorRoot()); - final editorState = EditorState(document: document); - - final historyItem = HistoryItem(); - historyItem - .add(DeleteOperation([0], [TextNode(delta: Delta()..insert('0'))])); - historyItem - .add(UpdateOperation([0], {"subType": "number"}, {"subType": null})); - historyItem.add(DeleteOperation([0], [TextNode.empty(), TextNode.empty()])); - historyItem.add(DeleteOperation([0], [TextNode.empty()])); - - final transaction = historyItem.toTransaction(editorState); - assert(isInsertAndPathEqual(transaction.operations[0], [0])); - assert(isInsertAndPathEqual(transaction.operations[1], [0])); - assert(transaction.operations[2] is UpdateOperation); - assert(isInsertAndPathEqual(transaction.operations[3], [0], '0')); - }); -} - -bool isInsertAndPathEqual(Operation operation, Path path, [String? content]) { - if (operation is! InsertOperation) { - return false; - } - - if (!operation.path.equals(path)) { - return false; - } - - final firstNode = operation.nodes.first; - if (firstNode is! TextNode) { - return false; - } - - if (content == null) { - return true; - } - - return firstNode.delta.toPlainText() == content; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart deleted file mode 100644 index 201f07861ac60..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:appflowy_editor/src/render/image/image_node_widget.dart'; -import 'package:appflowy_editor/src/service/editor_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:network_image_mock/network_image_mock.dart'; - -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('image_node_builder.dart', () { - testWidgets('render image node', (tester) async { - mockNetworkImagesFor(() async { - const text = 'Welcome to Appflowy 😁'; - const src = - 'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'; - final editor = tester.editor - ..insertTextNode(text) - ..insertImageNode(src) - ..insertTextNode(text); - await editor.startTesting(); - - expect(editor.documentLength, 3); - expect(find.byType(Image), findsOneWidget); - }); - }); - - testWidgets('render image align', (tester) async { - mockNetworkImagesFor(() async { - const text = 'Welcome to Appflowy 😁'; - const src = - 'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'; - final editor = tester.editor - ..insertTextNode(text) - ..insertImageNode(src, align: 'left') - ..insertImageNode(src, align: 'center') - ..insertImageNode(src, align: 'right') - ..insertTextNode(text); - await editor.startTesting(); - - expect(editor.documentLength, 5); - final imageFinder = find.byType(Image); - expect(imageFinder, findsNWidgets(3)); - - final editorFinder = find.byType(AppFlowyEditor); - final editorRect = tester.getRect(editorFinder); - - final leftImageRect = tester.getRect(imageFinder.at(0)); - expect( - leftImageRect.left, editor.editorState.editorStyle.padding!.left); - final rightImageRect = tester.getRect(imageFinder.at(2)); - expect(rightImageRect.right, - editorRect.right - editor.editorState.editorStyle.padding!.right); - final centerImageRect = tester.getRect(imageFinder.at(1)); - expect(centerImageRect.left, - (leftImageRect.left + rightImageRect.left) / 2.0); - expect(leftImageRect.size, centerImageRect.size); - expect(rightImageRect.size, centerImageRect.size); - - final imageNodeWidgetFinder = find.byType(ImageNodeWidget); - - final leftImage = - tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget; - - leftImage.onAlign(Alignment.center); - await tester.pump(const Duration(milliseconds: 100)); - expect( - tester.getRect(imageFinder.at(0)).left, - centerImageRect.left, - ); - - leftImage.onAlign(Alignment.centerRight); - await tester.pump(const Duration(milliseconds: 100)); - expect( - tester.getRect(imageFinder.at(0)).right, - rightImageRect.right, - ); - }); - }); - - testWidgets('render image copy', (tester) async { - mockNetworkImagesFor(() async { - const text = 'Welcome to Appflowy 😁'; - const src = - 'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'; - final editor = tester.editor - ..insertTextNode(text) - ..insertImageNode(src) - ..insertTextNode(text); - await editor.startTesting(); - - expect(editor.documentLength, 3); - final imageFinder = find.byType(Image); - expect(imageFinder, findsOneWidget); - - final imageNodeWidgetFinder = find.byType(ImageNodeWidget); - final image = - tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget; - image.onCopy(); - }); - }); - - testWidgets('render image delete', (tester) async { - mockNetworkImagesFor(() async { - const text = 'Welcome to Appflowy 😁'; - const src = - 'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'; - final editor = tester.editor - ..insertTextNode(text) - ..insertImageNode(src) - ..insertImageNode(src) - ..insertTextNode(text); - await editor.startTesting(); - - expect(editor.documentLength, 4); - final imageFinder = find.byType(Image); - expect(imageFinder, findsNWidgets(2)); - - final imageNodeWidgetFinder = find.byType(ImageNodeWidget); - final image = - tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget; - image.onDelete(); - - await tester.pump(const Duration(milliseconds: 100)); - expect(editor.documentLength, 3); - expect(find.byType(Image), findsNWidgets(1)); - }); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart deleted file mode 100644 index a566b7ec078c5..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/render/image/image_node_widget.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:network_image_mock/network_image_mock.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('image_node_widget.dart', () { - testWidgets('build the image node widget', (tester) async { - mockNetworkImagesFor(() async { - var onCopyHit = false; - var onDeleteHit = false; - var onAlignHit = false; - const src = - 'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'; - - final widget = ImageNodeWidget( - src: src, - node: Node( - type: 'image', - children: LinkedList(), - attributes: { - 'image_src': src, - 'align': 'center', - }, - ), - alignment: Alignment.center, - onCopy: () { - onCopyHit = true; - }, - onDelete: () { - onDeleteHit = true; - }, - onAlign: (alignment) { - onAlignHit = true; - }, - onResize: (width) {}, - ); - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: widget, - ), - ), - ); - expect(find.byType(ImageNodeWidget), findsOneWidget); - - final gesture = - await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - - expect(find.byType(ImageToolbar), findsNothing); - - addTearDown(gesture.removePointer); - await tester.pump(); - await gesture.moveTo(tester.getCenter(find.byType(ImageNodeWidget))); - await tester.pump(); - - expect(find.byType(ImageToolbar), findsOneWidget); - - final iconFinder = find.byType(IconButton); - expect(iconFinder, findsNWidgets(5)); - - await tester.tap(iconFinder.at(0)); - expect(onAlignHit, true); - onAlignHit = false; - - await tester.tap(iconFinder.at(1)); - expect(onAlignHit, true); - onAlignHit = false; - - await tester.tap(iconFinder.at(2)); - expect(onAlignHit, true); - onAlignHit = false; - - await tester.tap(iconFinder.at(3)); - expect(onCopyHit, true); - - await tester.tap(iconFinder.at(4)); - expect(onDeleteHit, true); - }); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart deleted file mode 100644 index c20748779a8f3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/render/link_menu/link_menu.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('link_menu.dart', () { - testWidgets('test empty link menu actions', (tester) async { - const link = 'appflowy.io'; - var submittedText = ''; - final linkMenu = LinkMenu( - onOpenLink: () {}, - onCopyLink: () {}, - onRemoveLink: () {}, - onFocusChange: (value) {}, - onSubmitted: (text) { - submittedText = text; - }, - ); - await tester.pumpWidget( - MaterialApp( - home: Material( - child: linkMenu, - ), - ), - ); - - expect(find.byType(TextButton), findsNothing); - expect(find.byType(TextField), findsOneWidget); - - await tester.tap(find.byType(TextField)); - await tester.enterText(find.byType(TextField), link); - await tester.pumpAndSettle(); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); - - expect(submittedText, link); - }); - - testWidgets('test tap linked text', (tester) async { - const link = 'appflowy.io'; - // This is a link [appflowy.io](appflowy.io) - final editor = tester.editor - ..insertTextNode( - null, - delta: Delta() - ..insert( - 'appflowy.io', - attributes: { - BuiltInAttributeKey.href: link, - }, - ), - ); - await editor.startTesting(); - final finder = find.byType(RichText); - expect(finder, findsOneWidget); - - // tap the link - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0, endOffset: link.length), - ); - await tester.tap(finder); - await tester.pumpAndSettle(const Duration(milliseconds: 350)); - final linkMenu = find.byType(LinkMenu); - expect(linkMenu, findsOneWidget); - expect(find.text(link, findRichText: true), findsNWidgets(2)); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart deleted file mode 100644 index b13fa456c9854..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('checkbox_text_handler.dart', () { - testWidgets('Click checkbox icon', (tester) async { - // Before - // - // [BIUS]Welcome to Appflowy 😁[BIUS] - // - // After - // - // [checkbox]Welcome to Appflowy 😁 - // - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode( - '', - attributes: { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, - BuiltInAttributeKey.checkbox: false, - }, - delta: Delta(operations: [ - TextInsert(text, attributes: { - BuiltInAttributeKey.bold: true, - BuiltInAttributeKey.italic: true, - BuiltInAttributeKey.underline: true, - BuiltInAttributeKey.strikethrough: true, - }), - ]), - ); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - - final selection = - Selection.single(path: [0], startOffset: 0, endOffset: text.length); - var node = editor.nodeAtPath([0]) as TextNode; - var state = node.key?.currentState as DefaultSelectable; - var checkboxWidget = find.byKey(state.iconKey!); - await tester.tap(checkboxWidget); - await tester.pumpAndSettle(); - - expect(node.attributes.check, true); - - expect(node.allSatisfyBoldInSelection(selection), true); - expect(node.allSatisfyItalicInSelection(selection), true); - expect(node.allSatisfyUnderlineInSelection(selection), true); - expect(node.allSatisfyStrikethroughInSelection(selection), true); - - node = editor.nodeAtPath([0]) as TextNode; - state = node.key?.currentState as DefaultSelectable; - await tester.ensureVisible(find.byKey(state.iconKey!)); - await tester.tap(find.byKey(state.iconKey!)); - await tester.pump(); - - expect(node.attributes.check, false); - expect(node.allSatisfyBoldInSelection(selection), true); - expect(node.allSatisfyItalicInSelection(selection), true); - expect(node.allSatisfyUnderlineInSelection(selection), true); - expect(node.allSatisfyStrikethroughInSelection(selection), true); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/toolbar_rich_text_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/toolbar_rich_text_test.dart deleted file mode 100644 index c9d9ef9e701cc..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/toolbar_rich_text_test.dart +++ /dev/null @@ -1,330 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; -import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart'; -import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - const singleLineText = "One Line Of Text"; - - group('toolbar, heading', (() { - testWidgets('Select Text, Click toolbar and set style for h1 heading', - (tester) async { - final editor = tester.editor..insertTextNode(singleLineText); - await editor.startTesting(); - - final h1 = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [0], offset: singleLineText.length)); - - await editor.updateSelection(h1); - - expect(find.byType(ToolbarWidget), findsOneWidget); - - final h1Button = find.byWidgetPredicate((widget) { - if (widget is ToolbarItemWidget) { - return widget.item.id == 'appflowy.toolbar.h1'; - } - return false; - }); - - expect(h1Button, findsOneWidget); - await tester.tap(h1Button); - await tester.pumpAndSettle(); - - final node = editor.nodeAtPath([0]) as TextNode; - expect(node.attributes.heading, 'h1'); - }); - - testWidgets('Select Text, Click toolbar and set style for h2 heading', - (tester) async { - final editor = tester.editor..insertTextNode(singleLineText); - await editor.startTesting(); - - final h2 = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [0], offset: singleLineText.length)); - - await editor.updateSelection(h2); - expect(find.byType(ToolbarWidget), findsOneWidget); - - final h2Button = find.byWidgetPredicate((widget) { - if (widget is ToolbarItemWidget) { - return widget.item.id == 'appflowy.toolbar.h2'; - } - return false; - }); - expect(h2Button, findsOneWidget); - await tester.tap(h2Button); - await tester.pumpAndSettle(); - final node = editor.nodeAtPath([0]) as TextNode; - expect(node.attributes.heading, 'h2'); - }); - - testWidgets('Select Text, Click toolbar and set style for h3 heading', - (tester) async { - final editor = tester.editor..insertTextNode(singleLineText); - await editor.startTesting(); - - final h3 = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [0], offset: singleLineText.length)); - - await editor.updateSelection(h3); - expect(find.byType(ToolbarWidget), findsOneWidget); - - final h3Button = find.byWidgetPredicate((widget) { - if (widget is ToolbarItemWidget) { - return widget.item.id == 'appflowy.toolbar.h3'; - } - return false; - }); - expect(h3Button, findsOneWidget); - await tester.tap(h3Button); - await tester.pumpAndSettle(); - final node = editor.nodeAtPath([0]) as TextNode; - expect(node.attributes.heading, 'h3'); - }); - })); - - group('toolbar, underline', (() { - testWidgets('Select text, click toolbar and set style for underline', - (tester) async { - final editor = tester.editor..insertTextNode(singleLineText); - await editor.startTesting(); - - final underline = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [0], offset: singleLineText.length)); - - await editor.updateSelection(underline); - expect(find.byType(ToolbarWidget), findsOneWidget); - final underlineButton = find.byWidgetPredicate((widget) { - if (widget is ToolbarItemWidget) { - return widget.item.id == 'appflowy.toolbar.underline'; - } - return false; - }); - - expect(underlineButton, findsOneWidget); - await tester.tap(underlineButton); - await tester.pumpAndSettle(); - final node = editor.nodeAtPath([0]) as TextNode; - // expect(node.attributes.underline, true); - expect(node.allSatisfyUnderlineInSelection(underline), true); - }); - })); - - group('toolbar, bold', (() { - testWidgets('Select Text, Click Toolbar and set style for bold', - (tester) async { - final editor = tester.editor..insertTextNode(singleLineText); - await editor.startTesting(); - - final bold = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [0], offset: singleLineText.length)); - - await editor.updateSelection(bold); - expect(find.byType(ToolbarWidget), findsOneWidget); - final boldButton = find.byWidgetPredicate((widget) { - if (widget is ToolbarItemWidget) { - return widget.item.id == 'appflowy.toolbar.bold'; - } - return false; - }); - - expect(boldButton, findsOneWidget); - await tester.tap(boldButton); - await tester.pumpAndSettle(); - final node = editor.nodeAtPath([0]) as TextNode; - expect(node.allSatisfyBoldInSelection(bold), true); - }); - })); - - group('toolbar, italic', (() { - testWidgets('Select Text, Click Toolbar and set style for italic', - (tester) async { - final editor = tester.editor..insertTextNode(singleLineText); - await editor.startTesting(); - - final italic = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [0], offset: singleLineText.length)); - - await editor.updateSelection(italic); - expect(find.byType(ToolbarWidget), findsOneWidget); - final italicButton = find.byWidgetPredicate((widget) { - if (widget is ToolbarItemWidget) { - return widget.item.id == 'appflowy.toolbar.italic'; - } - return false; - }); - - expect(italicButton, findsOneWidget); - await tester.tap(italicButton); - await tester.pumpAndSettle(); - final node = editor.nodeAtPath([0]) as TextNode; - expect(node.allSatisfyItalicInSelection(italic), true); - }); - })); - - group('toolbar, strikethrough', (() { - testWidgets('Select Text, Click Toolbar and set style for strikethrough', - (tester) async { - final editor = tester.editor..insertTextNode(singleLineText); - await editor.startTesting(); - - final strikeThrough = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [0], offset: singleLineText.length)); - - await editor.updateSelection(strikeThrough); - - expect(find.byType(ToolbarWidget), findsOneWidget); - final strikeThroughButton = find.byWidgetPredicate((widget) { - if (widget is ToolbarItemWidget) { - return widget.item.id == 'appflowy.toolbar.strikethrough'; - } - return false; - }); - - expect(strikeThroughButton, findsOneWidget); - await tester.tap(strikeThroughButton); - await tester.pumpAndSettle(); - final node = editor.nodeAtPath([0]) as TextNode; - expect(node.allSatisfyStrikethroughInSelection(strikeThrough), true); - }); - })); - - group('toolbar, code', (() { - testWidgets('Select Text, Click Toolbar and set style for code', - (tester) async { - final editor = tester.editor..insertTextNode(singleLineText); - await editor.startTesting(); - - final code = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [0], offset: singleLineText.length)); - - await editor.updateSelection(code); - expect(find.byType(ToolbarWidget), findsOneWidget); - final codeButton = find.byWidgetPredicate((widget) { - if (widget is ToolbarItemWidget) { - return widget.item.id == 'appflowy.toolbar.code'; - } - return false; - }); - - expect(codeButton, findsOneWidget); - await tester.tap(codeButton); - await tester.pumpAndSettle(); - final node = editor.nodeAtPath([0]) as TextNode; - expect( - node.allSatisfyInSelection( - code, - BuiltInAttributeKey.code, - (value) { - return value == true; - }, - ), - true, - ); - }); - })); - - group('toolbar, quote', (() { - testWidgets('Select Text, Click Toolbar and set style for quote', - (tester) async { - final editor = tester.editor..insertTextNode(singleLineText); - await editor.startTesting(); - - final quote = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [0], offset: singleLineText.length)); - - await editor.updateSelection(quote); - expect(find.byType(ToolbarWidget), findsOneWidget); - final quoteButton = find.byWidgetPredicate((widget) { - if (widget is ToolbarItemWidget) { - return widget.item.id == 'appflowy.toolbar.quote'; - } - return false; - }); - expect(quoteButton, findsOneWidget); - await tester.tap(quoteButton); - await tester.pumpAndSettle(); - final node = editor.nodeAtPath([0]) as TextNode; - expect(node.subtype, 'quote'); - }); - })); - - group('toolbar, bullet list', (() { - testWidgets('Select Text, Click Toolbar and set style for bullet', - (tester) async { - final editor = tester.editor..insertTextNode(singleLineText); - await editor.startTesting(); - - final bulletList = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [0], offset: singleLineText.length)); - - await editor.updateSelection(bulletList); - expect(find.byType(ToolbarWidget), findsOneWidget); - final bulletListButton = find.byWidgetPredicate((widget) { - if (widget is ToolbarItemWidget) { - return widget.item.id == 'appflowy.toolbar.bulleted_list'; - } - return false; - }); - - expect(bulletListButton, findsOneWidget); - await tester.tap(bulletListButton); - await tester.pumpAndSettle(); - final node = editor.nodeAtPath([0]) as TextNode; - expect(node.subtype, 'bulleted-list'); - }); - })); - - group('toolbar, highlight', (() { - testWidgets('Select Text, Click Toolbar and set style for highlighted text', - (tester) async { - // FIXME: Use a const value instead of the magic string. - const blue = '0x6000BCF0'; - final editor = tester.editor..insertTextNode(singleLineText); - await editor.startTesting(); - - final node = editor.nodeAtPath([0]) as TextNode; - final selection = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [0], offset: singleLineText.length)); - - await editor.updateSelection(selection); - expect(find.byType(ToolbarWidget), findsOneWidget); - final highlightButton = find.byWidgetPredicate((widget) { - if (widget is ToolbarItemWidget) { - return widget.item.id == 'appflowy.toolbar.highlight'; - } - return false; - }); - expect(highlightButton, findsOneWidget); - await tester.tap(highlightButton); - await tester.pumpAndSettle(); - expect( - node.allSatisfyInSelection( - selection, - BuiltInAttributeKey.backgroundColor, - (value) { - return value == blue; - }, - ), - true, - ); - }); - })); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_widget_test.dart deleted file mode 100644 index d058fc21998b1..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_widget_test.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart'; -import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('selection_menu_widget.dart', () { - for (var i = 0; i < defaultSelectionMenuItems.length; i += 1) { - testWidgets('Selects number.$i item in selection menu with enter', - (tester) async { - final editor = await _prepare(tester); - for (var j = 0; j < i; j++) { - await editor.pressLogicKey(LogicalKeyboardKey.arrowDown); - } - - await editor.pressLogicKey(LogicalKeyboardKey.enter); - expect( - find.byType(SelectionMenuWidget, skipOffstage: false), - findsNothing, - ); - if (defaultSelectionMenuItems[i].name() != 'Image') { - await _testDefaultSelectionMenuItems(i, editor); - } - }); - - testWidgets('Selects number.$i item in selection menu with click', - (tester) async { - final editor = await _prepare(tester); - - await tester.tap(find.byType(SelectionMenuItemWidget).at(i)); - await tester.pumpAndSettle(); - - expect( - find.byType(SelectionMenuWidget, skipOffstage: false), - findsNothing, - ); - if (defaultSelectionMenuItems[i].name() != 'Image') { - await _testDefaultSelectionMenuItems(i, editor); - } - }); - } - - testWidgets('Search item in selection menu util no results', - (tester) async { - final editor = await _prepare(tester); - await editor.pressLogicKey(LogicalKeyboardKey.keyT); - await editor.pressLogicKey(LogicalKeyboardKey.keyE); - expect( - find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNWidgets(3), - ); - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect( - find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNWidgets(5), - ); - await editor.pressLogicKey(LogicalKeyboardKey.keyE); - expect( - find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNWidgets(3), - ); - await editor.pressLogicKey(LogicalKeyboardKey.keyX); - expect( - find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNWidgets(1), - ); - await editor.pressLogicKey(LogicalKeyboardKey.keyT); - expect( - find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNWidgets(1), - ); - await editor.pressLogicKey(LogicalKeyboardKey.keyT); - expect( - find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNothing, - ); - }); - - testWidgets('Search item in selection menu and presses esc', - (tester) async { - final editor = await _prepare(tester); - await editor.pressLogicKey(LogicalKeyboardKey.keyT); - await editor.pressLogicKey(LogicalKeyboardKey.keyE); - expect( - find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNWidgets(3), - ); - await editor.pressLogicKey(LogicalKeyboardKey.escape); - expect( - find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNothing, - ); - }); - - testWidgets('Search item in selection menu and presses backspace', - (tester) async { - final editor = await _prepare(tester); - await editor.pressLogicKey(LogicalKeyboardKey.keyT); - await editor.pressLogicKey(LogicalKeyboardKey.keyE); - expect( - find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNWidgets(3), - ); - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect( - find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNothing, - ); - }); - }); -} - -Future _prepare(WidgetTester tester) async { - const text = 'Welcome to Appflowy 😁'; - const lines = 3; - final editor = tester.editor; - for (var i = 0; i < lines; i++) { - editor.insertTextNode(text); - } - await editor.startTesting(); - await editor.updateSelection(Selection.single(path: [1], startOffset: 0)); - await editor.pressLogicKey(LogicalKeyboardKey.slash); - - await tester.pumpAndSettle(const Duration(milliseconds: 1000)); - - expect( - find.byType(SelectionMenuWidget, skipOffstage: false), - findsOneWidget, - ); - - for (final item in defaultSelectionMenuItems) { - expect(find.text(item.name()), findsOneWidget); - } - - return Future.value(editor); -} - -Future _testDefaultSelectionMenuItems( - int index, EditorWidgetTester editor) async { - expect(editor.documentLength, 4); - expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0)); - expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), - 'Welcome to Appflowy 😁'); - final node = editor.nodeAtPath([2]); - final item = defaultSelectionMenuItems[index]; - final itemName = item.name(); - if (itemName == 'Text') { - expect(node?.subtype == null, true); - } else if (itemName == 'Heading 1') { - expect(node?.subtype, BuiltInAttributeKey.heading); - expect(node?.attributes.heading, BuiltInAttributeKey.h1); - } else if (itemName == 'Heading 2') { - expect(node?.subtype, BuiltInAttributeKey.heading); - expect(node?.attributes.heading, BuiltInAttributeKey.h2); - } else if (itemName == 'Heading 3') { - expect(node?.subtype, BuiltInAttributeKey.heading); - expect(node?.attributes.heading, BuiltInAttributeKey.h3); - } else if (itemName == 'Bulleted list') { - expect(node?.subtype, BuiltInAttributeKey.bulletedList); - } else if (itemName == 'Checkbox') { - expect(node?.subtype, BuiltInAttributeKey.checkbox); - expect(node?.attributes.check, false); - } else if (itemName == 'Quote') { - expect(node?.subtype, BuiltInAttributeKey.quote); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_item_widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_item_widget_test.dart deleted file mode 100644 index 3d212691cb8f4..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_item_widget_test.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; -import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('toolbar_item_widget.dart', () { - testWidgets('test single toolbar item widget', (tester) async { - final key = GlobalKey(); - final iconKey = GlobalKey(); - var hit = false; - final item = ToolbarItem( - id: 'appflowy.toolbar.test', - type: 1, - iconBuilder: (isHighlight) { - return Icon( - key: iconKey, - Icons.abc, - color: isHighlight ? Colors.lightBlue : null, - ); - }, - validator: (editorState) => true, - handler: (editorState, context) {}, - highlightCallback: (editorState) { - return true; - }, - ); - final widget = ToolbarItemWidget( - key: key, - item: item, - isHighlight: true, - onPressed: (() { - hit = true; - }), - ); - - await tester.pumpWidget( - MaterialApp( - home: Material( - child: widget, - ), - ), - ); - - expect(find.byKey(key), findsOneWidget); - expect(find.byKey(iconKey), findsOneWidget); - expect( - (tester.firstWidget(find.byKey(iconKey)) as Icon).color, - Colors.lightBlue, - ); - - await tester.tap(find.byKey(key)); - await tester.pumpAndSettle(); - - expect(hit, true); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_widget_test.dart deleted file mode 100644 index d7e6b906f86a4..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_widget_test.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('toolbar_widget.dart', () { - testWidgets('test toolbar widget', (tester) async {}); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/default_text_operations/format_rich_text_style_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/default_text_operations/format_rich_text_style_test.dart deleted file mode 100644 index ccece41022021..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/default_text_operations/format_rich_text_style_test.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/render/rich_text/quoted_text.dart'; -import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('format_rich_text_style.dart', () { - testWidgets('formatTextNodes', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor..insertTextNode(text); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0, endOffset: text.length), - ); - - // format the text to Quote - formatTextNodes(editor.editorState, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.quote, - }); - await tester.pumpAndSettle(const Duration(seconds: 1)); - expect(find.byType(QuotedTextNodeWidget), findsOneWidget); - - // format the text to Quote again. The style should be removed. - formatTextNodes(editor.editorState, { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.quote, - }); - await tester.pumpAndSettle(); - expect(find.byType(QuotedTextNodeWidget), findsNothing); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart deleted file mode 100644 index b9b163f4895a4..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart +++ /dev/null @@ -1,468 +0,0 @@ -import 'dart:io'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('arrow_keys_handler.dart', () { - testWidgets('Presses arrow right key, move the cursor from left to right', - (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.pressLogicKey(LogicalKeyboardKey.arrowRight); - - if (i == text.length - 1) { - // Wrap to next node if the cursor is at the end of the current node. - expect( - editor.documentSelection, - Selection.single( - path: [1], - startOffset: 0, - ), - ); - } else { - expect( - editor.documentSelection, - Selection.single( - path: [0], - startOffset: textNode.delta.nextRunePosition(i), - ), - ); - } - } - }); - }); - - testWidgets( - 'Presses arrow left/right key since selection is not collapsed and backward', - (tester) async { - await _testPressArrowKeyInNotCollapsedSelection(tester, true); - }); - - testWidgets( - 'Presses arrow left/right key since selection is not collapsed and forward', - (tester) async { - await _testPressArrowKeyInNotCollapsedSelection(tester, false); - }); - - testWidgets('Presses arrow left/right + shift in collapsed selection', - (tester) async { - const text = 'Welcome to Appflowy'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - const offset = 8; - final selection = Selection.single(path: [1], startOffset: offset); - await editor.updateSelection(selection); - for (var i = offset - 1; i >= 0; i--) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowLeft, - isShiftPressed: true, - ); - expect( - editor.documentSelection, - selection.copyWith( - end: Position(path: [1], offset: i), - ), - ); - } - for (var i = text.length; i >= 0; i--) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowLeft, - isShiftPressed: true, - ); - expect( - editor.documentSelection, - selection.copyWith( - end: Position(path: [0], offset: i), - ), - ); - } - for (var i = 1; i <= text.length; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowRight, - isShiftPressed: true, - ); - expect( - editor.documentSelection, - selection.copyWith( - end: Position(path: [0], offset: i), - ), - ); - } - for (var i = 0; i < text.length; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowRight, - isShiftPressed: true, - ); - expect( - editor.documentSelection, - selection.copyWith( - end: Position(path: [1], offset: i), - ), - ); - } - }); - - testWidgets( - 'Presses arrow left/right + shift in not collapsed and backward selection', - (tester) async { - const text = 'Welcome to Appflowy'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - const start = 8; - const end = 12; - final selection = Selection.single( - path: [0], - startOffset: start, - endOffset: end, - ); - await editor.updateSelection(selection); - for (var i = end + 1; i <= text.length; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowRight, - isShiftPressed: true, - ); - expect( - editor.documentSelection, - selection.copyWith( - end: Position(path: [0], offset: i), - ), - ); - } - for (var i = text.length - 1; i >= 0; i--) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowLeft, - isShiftPressed: true, - ); - expect( - editor.documentSelection, - selection.copyWith( - end: Position(path: [0], offset: i), - ), - ); - } - }); - - testWidgets( - 'Presses arrow left/right + command in not collapsed and forward selection', - (tester) async { - const text = 'Welcome to Appflowy'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - const start = 12; - const end = 8; - final selection = Selection.single( - path: [0], - startOffset: start, - endOffset: end, - ); - await editor.updateSelection(selection); - for (var i = end - 1; i >= 0; i--) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowLeft, - isShiftPressed: true, - ); - expect( - editor.documentSelection, - selection.copyWith( - end: Position(path: [0], offset: i), - ), - ); - } - for (var i = 1; i <= text.length; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowRight, - isShiftPressed: true, - ); - expect( - editor.documentSelection, - selection.copyWith( - end: Position(path: [0], offset: i), - ), - ); - } - }); - - testWidgets('Presses arrow left/right/up/down + meta in collapsed selection', - (tester) async { - await _testPressArrowKeyWithMetaInSelection(tester, true, false); - }); - - testWidgets( - 'Presses arrow left/right/up/down + meta in not collapsed and backward selection', - (tester) async { - await _testPressArrowKeyWithMetaInSelection(tester, false, true); - }); - - testWidgets( - 'Presses arrow left/right/up/down + meta in not collapsed and forward selection', - (tester) async { - await _testPressArrowKeyWithMetaInSelection(tester, false, false); - }); - - testWidgets('Presses arrow up/down + shift in not collapsed selection', - (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text) - ..insertTextNode(null) - ..insertTextNode(text) - ..insertTextNode(null) - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - final selection = Selection.single(path: [3], startOffset: 8); - await editor.updateSelection(selection); - for (int i = 0; i < 3; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowUp, - isShiftPressed: true, - ); - } - expect( - editor.documentSelection, - selection.copyWith( - end: Position(path: [0], offset: 0), - ), - ); - for (int i = 0; i < 7; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowDown, - isShiftPressed: true, - ); - } - expect( - editor.documentSelection, - selection.copyWith( - end: Position(path: [6], offset: 0), - ), - ); - for (int i = 0; i < 3; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowUp, - isShiftPressed: true, - ); - } - expect( - editor.documentSelection, - selection.copyWith( - end: Position(path: [3], offset: 0), - ), - ); - }); - - testWidgets('Presses shift + arrow down and meta/ctrl + shift + right', - (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - final selection = Selection.single(path: [0], startOffset: 8); - await editor.updateSelection(selection); - await editor.pressLogicKey( - LogicalKeyboardKey.arrowDown, - isShiftPressed: true, - ); - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowRight, - isShiftPressed: true, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowRight, - isShiftPressed: true, - isMetaPressed: true, - ); - } - expect( - editor.documentSelection, - selection.copyWith( - end: Position(path: [1], offset: text.length), - ), - ); - }); - - testWidgets('Presses shift + arrow up and meta/ctrl + shift + left', - (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - final selection = Selection.single(path: [1], startOffset: 8); - await editor.updateSelection(selection); - await editor.pressLogicKey( - LogicalKeyboardKey.arrowUp, - isShiftPressed: true, - ); - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowLeft, - isShiftPressed: true, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowLeft, - isShiftPressed: true, - isMetaPressed: true, - ); - } - expect( - editor.documentSelection, - selection.copyWith( - end: Position(path: [0], offset: 0), - ), - ); - }); -} - -Future _testPressArrowKeyInNotCollapsedSelection( - WidgetTester tester, bool isBackward) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - final start = Position(path: [0], offset: 5); - final end = Position(path: [1], offset: 10); - final selection = Selection( - start: isBackward ? start : end, - end: isBackward ? end : start, - ); - await editor.updateSelection(selection); - await editor.pressLogicKey(LogicalKeyboardKey.arrowLeft); - expect(editor.documentSelection?.start, start); - - await editor.updateSelection(selection); - await editor.pressLogicKey(LogicalKeyboardKey.arrowRight); - expect(editor.documentSelection?.end, end); -} - -Future _testPressArrowKeyWithMetaInSelection( - WidgetTester tester, - bool isSingle, - bool isBackward, -) async { - const text = 'Welcome to Appflowy'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - Selection selection; - if (isSingle) { - selection = Selection.single( - path: [0], - startOffset: 8, - ); - } else { - if (isBackward) { - selection = Selection.single( - path: [0], - startOffset: 8, - endOffset: text.length, - ); - } else { - selection = Selection.single( - path: [0], - startOffset: text.length, - endOffset: 8, - ); - } - } - await editor.updateSelection(selection); - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowLeft, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowLeft, - isMetaPressed: true, - ); - } - - expect( - editor.documentSelection, - Selection.single(path: [0], startOffset: 0), - ); - - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowRight, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowRight, - isMetaPressed: true, - ); - } - - expect( - editor.documentSelection, - Selection.single(path: [0], startOffset: text.length), - ); - - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowUp, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowUp, - isMetaPressed: true, - ); - } - - expect( - editor.documentSelection, - Selection.single(path: [0], startOffset: 0), - ); - - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowDown, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowDown, - isMetaPressed: true, - ); - } - - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: text.length), - ); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart deleted file mode 100644 index e786377de0a29..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart +++ /dev/null @@ -1,642 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/render/image/image_node_widget.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:network_image_mock/network_image_mock.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('backspace_handler.dart', () { - testWidgets('Presses backspace key in empty document', (tester) async { - // Before - // - // [Empty Line] - // - // After - // - // [Empty Line] - // - final editor = tester.editor..insertEmptyTextNode(); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - // Pressing the backspace key continuously. - for (int i = 1; i <= 1; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.backspace, - ); - expect(editor.documentLength, 1); - expect(editor.documentSelection, - Selection.single(path: [0], startOffset: 0)); - } - }); - }); - - // Before - // - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // - // After - // - // Welcome to Appflowy 😁 - // Welcome t Appflowy 😁 - // Welcome Appflowy 😁 - // - // Then - // Welcome to Appflowy 😁 - // - testWidgets( - 'Presses backspace key in non-empty document and selection is backward', - (tester) async { - await _deleteTextByBackspace(tester, true); - }); - testWidgets( - 'Presses backspace key in non-empty document and selection is forward', - (tester) async { - await _deleteTextByBackspace(tester, false); - }); - - // Before - // - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // - // After - // - // Welcome to Appflowy 😁 - // Welcome t Appflowy 😁 - // Welcome Appflowy 😁 - // - // Then - // Welcome to Appflowy 😁 - // - testWidgets( - 'Presses delete key in non-empty document and selection is backward', - (tester) async { - await _deleteTextByDelete(tester, true); - }); - testWidgets( - 'Presses delete key in non-empty document and selection is forward', - (tester) async { - await _deleteTextByDelete(tester, false); - }); - - // Before - // - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // - // After - // - // Welcome to Appflowy 😁Welcome Appflowy 😁 - testWidgets( - 'Presses delete key in non-empty document and selection is at the end of the text', - (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - // delete 'o' - await editor.updateSelection( - Selection.single(path: [0], startOffset: text.length), - ); - await editor.pressLogicKey(LogicalKeyboardKey.delete); - - expect(editor.documentLength, 1); - expect(editor.documentSelection, - Selection.single(path: [0], startOffset: text.length)); - expect((editor.nodeAtPath([0]) as TextNode).toPlainText(), text * 2); - }); - - // Before - // - // Welcome to Appflowy 😁 - // [Style] Welcome to Appflowy 😁 - // [Style] Welcome to Appflowy 😁 - // - // After - // - // Welcome to Appflowy 😁 - // [Style] Welcome to Appflowy 😁Welcome to Appflowy 😁 - // - testWidgets('Presses backspace key in styled text (checkbox)', - (tester) async { - await _deleteStyledTextByBackspace(tester, BuiltInAttributeKey.checkbox); - }); - testWidgets('Presses backspace key in styled text (bulletedList)', - (tester) async { - await _deleteStyledTextByBackspace( - tester, BuiltInAttributeKey.bulletedList); - }); - testWidgets('Presses backspace key in styled text (heading)', (tester) async { - await _deleteStyledTextByBackspace(tester, BuiltInAttributeKey.heading); - }); - testWidgets('Presses backspace key in styled text (quote)', (tester) async { - await _deleteStyledTextByBackspace(tester, BuiltInAttributeKey.quote); - }); - - // Before - // - // Welcome to Appflowy 😁 - // [Style] Welcome to Appflowy 😁 - // [Style] Welcome to Appflowy 😁 - // - // After - // - // Welcome to Appflowy 😁 - // [Style] Welcome to Appflowy 😁 - // - testWidgets('Presses delete key in styled text (checkbox)', (tester) async { - await _deleteStyledTextByDelete(tester, BuiltInAttributeKey.checkbox); - }); - testWidgets('Presses delete key in styled text (bulletedList)', - (tester) async { - await _deleteStyledTextByDelete(tester, BuiltInAttributeKey.bulletedList); - }); - testWidgets('Presses delete key in styled text (heading)', (tester) async { - await _deleteStyledTextByDelete(tester, BuiltInAttributeKey.heading); - }); - testWidgets('Presses delete key in styled text (quote)', (tester) async { - await _deleteStyledTextByDelete(tester, BuiltInAttributeKey.quote); - }); - - // Before - // - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // [Image] - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // - // After - // - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // - testWidgets('Deletes the image surrounded by text', (tester) async { - mockNetworkImagesFor(() async { - const text = 'Welcome to Appflowy 😁'; - const src = 'https://s1.ax1x.com/2022/08/26/v2sSbR.jpg'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text) - ..insertImageNode(src) - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - expect(editor.documentLength, 5); - expect(find.byType(ImageNodeWidget), findsOneWidget); - - await editor.updateSelection( - Selection( - start: Position(path: [1], offset: 0), - end: Position(path: [3], offset: text.length), - ), - ); - - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect(editor.documentLength, 3); - expect(find.byType(ImageNodeWidget), findsNothing); - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: 0), - ); - }); - }); - - testWidgets('Deletes the first image, and selection is backward', - (tester) async { - await _deleteFirstImage(tester, true); - }); - - testWidgets('Deletes the first image, and selection is not backward', - (tester) async { - await _deleteFirstImage(tester, false); - }); - - testWidgets('Deletes the last image and selection is backward', - (tester) async { - await _deleteLastImage(tester, true); - }); - - testWidgets('Deletes the last image and selection is not backward', - (tester) async { - await _deleteLastImage(tester, false); - }); - - testWidgets('Removes the style of heading text and revert', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor..insertTextNode(text); - await editor.startTesting(); - - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - - final textNode = editor.nodeAtPath([0]) as TextNode; - - await editor.insertText(textNode, '#', 0); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect( - (editor.nodeAtPath([0]) as TextNode).attributes.heading, - BuiltInAttributeKey.h1, - ); - - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect( - textNode.attributes.heading, - null, - ); - - await editor.insertText(textNode, '#', 0); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect( - (editor.nodeAtPath([0]) as TextNode).attributes.heading, - BuiltInAttributeKey.h1, - ); - }); - - testWidgets('Delete the nested bulleted list', (tester) async { - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - const text = 'Welcome to Appflowy 😁'; - final node = TextNode( - delta: Delta()..insert(text), - attributes: { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList, - }, - ); - node.insert( - node.copyWith() - ..insert( - node.copyWith(), - ), - ); - - final editor = tester.editor..insert(node); - await editor.startTesting(); - - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - await editor.updateSelection( - Selection.single(path: [0, 0, 0], startOffset: 0), - ); - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect(editor.nodeAtPath([0, 0, 0])?.subtype, null); - await editor.updateSelection( - Selection.single(path: [0, 0, 0], startOffset: 0), - ); - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect(editor.nodeAtPath([0, 1]) != null, true); - await editor.updateSelection( - Selection.single(path: [0, 1], startOffset: 0), - ); - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect(editor.nodeAtPath([1]) != null, true); - await editor.updateSelection( - Selection.single(path: [1], startOffset: 0), - ); - - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁Welcome to Appflowy 😁 - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect( - editor.documentSelection, - Selection.single(path: [0, 0], startOffset: text.length), - ); - expect((editor.nodeAtPath([0, 0]) as TextNode).toPlainText(), text * 2); - }); - - testWidgets('Delete the complicated nested bulleted list', (tester) async { - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - const text = 'Welcome to Appflowy 😁'; - final node = TextNode( - delta: Delta()..insert(text), - attributes: { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList, - }, - ); - - node - ..insert( - node.copyWith(children: LinkedList()), - ) - ..insert( - node.copyWith(children: LinkedList()) - ..insert( - node.copyWith(children: LinkedList()), - ) - ..insert( - node.copyWith(children: LinkedList()), - ), - ); - - final editor = tester.editor..insert(node); - await editor.startTesting(); - - await editor.updateSelection( - Selection.single(path: [0, 1], startOffset: 0), - ); - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect( - editor.nodeAtPath([0, 1])!.subtype != BuiltInAttributeKey.bulletedList, - true, - ); - expect( - editor.nodeAtPath([0, 1, 0])!.subtype, - BuiltInAttributeKey.bulletedList, - ); - expect( - editor.nodeAtPath([0, 1, 1])!.subtype, - BuiltInAttributeKey.bulletedList, - ); - expect(find.byType(FlowyRichText), findsNWidgets(5)); - - // Before - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // After - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect( - editor.nodeAtPath([0, 0])!.subtype == BuiltInAttributeKey.bulletedList, - true, - ); - expect( - (editor.nodeAtPath([0, 0]) as TextNode).toPlainText() == text * 2, - true, - ); - expect( - editor.nodeAtPath([0, 1])!.subtype == BuiltInAttributeKey.bulletedList, - true, - ); - expect( - editor.nodeAtPath([0, 2])!.subtype == BuiltInAttributeKey.bulletedList, - true, - ); - }); -} - -Future _deleteFirstImage(WidgetTester tester, bool isBackward) async { - mockNetworkImagesFor(() async { - const text = 'Welcome to Appflowy 😁'; - const src = 'https://s1.ax1x.com/2022/08/26/v2sSbR.jpg'; - final editor = tester.editor - ..insertImageNode(src) - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - expect(editor.documentLength, 3); - expect(find.byType(ImageNodeWidget), findsOneWidget); - - final start = Position(path: [0], offset: 0); - final end = Position(path: [1], offset: 1); - await editor.updateSelection( - Selection( - start: isBackward ? start : end, - end: isBackward ? end : start, - ), - ); - - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect(editor.documentLength, 2); - expect(find.byType(ImageNodeWidget), findsNothing); - expect(editor.documentSelection, Selection.collapsed(start)); - }); -} - -Future _deleteLastImage(WidgetTester tester, bool isBackward) async { - mockNetworkImagesFor(() async { - const text = 'Welcome to Appflowy 😁'; - const src = 'https://s1.ax1x.com/2022/08/26/v2sSbR.jpg'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text) - ..insertImageNode(src); - await editor.startTesting(); - - expect(editor.documentLength, 3); - expect(find.byType(ImageNodeWidget), findsOneWidget); - - final start = Position(path: [1], offset: 0); - final end = Position(path: [2], offset: 1); - await editor.updateSelection( - Selection( - start: isBackward ? start : end, - end: isBackward ? end : start, - ), - ); - - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect(editor.documentLength, 2); - expect(find.byType(ImageNodeWidget), findsNothing); - expect(editor.documentSelection, Selection.collapsed(start)); - }); -} - -Future _deleteStyledTextByBackspace( - WidgetTester tester, String style) async { - const text = 'Welcome to Appflowy 😁'; - Attributes attributes = { - BuiltInAttributeKey.subtype: style, - }; - if (style == BuiltInAttributeKey.checkbox) { - attributes[BuiltInAttributeKey.checkbox] = true; - } else if (style == BuiltInAttributeKey.numberList) { - attributes[BuiltInAttributeKey.number] = 1; - } else if (style == BuiltInAttributeKey.heading) { - attributes[BuiltInAttributeKey.heading] = BuiltInAttributeKey.h1; - } - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text, attributes: attributes) - ..insertTextNode(text, attributes: attributes); - - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [2], startOffset: 0), - ); - await editor.pressLogicKey( - LogicalKeyboardKey.backspace, - ); - expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0)); - - await editor.pressLogicKey( - LogicalKeyboardKey.backspace, - ); - expect(editor.documentLength, 2); - expect(editor.documentSelection, - Selection.single(path: [1], startOffset: text.length)); - expect(editor.nodeAtPath([1])?.subtype, style); - expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), text * 2); - - await editor.updateSelection( - Selection.single(path: [1], startOffset: 0), - ); - await editor.pressLogicKey( - LogicalKeyboardKey.backspace, - ); - expect(editor.documentLength, 2); - expect(editor.documentSelection, Selection.single(path: [1], startOffset: 0)); - expect(editor.nodeAtPath([1])?.subtype, null); -} - -Future _deleteStyledTextByDelete( - WidgetTester tester, String style) async { - const text = 'Welcome to Appflowy 😁'; - Attributes attributes = { - BuiltInAttributeKey.subtype: style, - }; - if (style == BuiltInAttributeKey.checkbox) { - attributes[BuiltInAttributeKey.checkbox] = true; - } else if (style == BuiltInAttributeKey.numberList) { - attributes[BuiltInAttributeKey.number] = 1; - } else if (style == BuiltInAttributeKey.heading) { - attributes[BuiltInAttributeKey.heading] = BuiltInAttributeKey.h1; - } - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text, attributes: attributes) - ..insertTextNode(text, attributes: attributes); - - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [1], startOffset: 0), - ); - for (var i = 1; i < text.length; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.delete, - ); - expect( - editor.documentSelection, Selection.single(path: [1], startOffset: 0)); - expect(editor.nodeAtPath([1])?.subtype, style); - expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), - text.safeSubString(i)); - } - - await editor.pressLogicKey( - LogicalKeyboardKey.delete, - ); - expect(editor.documentLength, 2); - expect(editor.documentSelection, Selection.single(path: [1], startOffset: 0)); - expect(editor.nodeAtPath([1])?.subtype, style); - expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), text); -} - -Future _deleteTextByBackspace( - WidgetTester tester, bool isBackwardSelection) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - // delete 'o' - await editor.updateSelection( - Selection.single(path: [1], startOffset: 10), - ); - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - - expect(editor.documentLength, 3); - expect(editor.documentSelection, Selection.single(path: [1], startOffset: 9)); - expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), - 'Welcome t Appflowy 😁'); - - // delete 'to ' - await editor.updateSelection( - Selection.single(path: [2], startOffset: 8, endOffset: 11), - ); - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect(editor.documentLength, 3); - expect(editor.documentSelection, Selection.single(path: [2], startOffset: 8)); - expect((editor.nodeAtPath([2]) as TextNode).toPlainText(), - 'Welcome Appflowy 😁'); - - // delete 'Appflowy 😁 - // Welcome t Appflowy 😁 - // Welcome ' - final start = Position(path: [0], offset: 11); - final end = Position(path: [2], offset: 8); - await editor.updateSelection(Selection( - start: isBackwardSelection ? start : end, - end: isBackwardSelection ? end : start)); - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect(editor.documentLength, 1); - expect( - editor.documentSelection, Selection.single(path: [0], startOffset: 11)); - expect((editor.nodeAtPath([0]) as TextNode).toPlainText(), - 'Welcome to Appflowy 😁'); -} - -Future _deleteTextByDelete( - WidgetTester tester, bool isBackwardSelection) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - // delete 'o' - await editor.updateSelection( - Selection.single(path: [1], startOffset: 9), - ); - await editor.pressLogicKey(LogicalKeyboardKey.delete); - - expect(editor.documentLength, 3); - expect(editor.documentSelection, Selection.single(path: [1], startOffset: 9)); - expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), - 'Welcome t Appflowy 😁'); - - // delete 'to ' - await editor.updateSelection( - Selection.single(path: [2], startOffset: 8, endOffset: 11), - ); - await editor.pressLogicKey(LogicalKeyboardKey.delete); - expect(editor.documentLength, 3); - expect(editor.documentSelection, Selection.single(path: [2], startOffset: 8)); - expect((editor.nodeAtPath([2]) as TextNode).toPlainText(), - 'Welcome Appflowy 😁'); - - // delete 'Appflowy 😁 - // Welcome t Appflowy 😁 - // Welcome ' - final start = Position(path: [0], offset: 11); - final end = Position(path: [2], offset: 8); - await editor.updateSelection(Selection( - start: isBackwardSelection ? start : end, - end: isBackwardSelection ? end : start)); - await editor.pressLogicKey(LogicalKeyboardKey.delete); - expect(editor.documentLength, 1); - expect( - editor.documentSelection, Selection.single(path: [0], startOffset: 11)); - expect((editor.nodeAtPath([0]) as TextNode).toPlainText(), - 'Welcome to Appflowy 😁'); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart deleted file mode 100644 index 74797b84024e5..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('enter_without_shift_in_text_node_handler.dart', () { - testWidgets('Presses enter key in empty document', (tester) async { - // Before - // - // [Empty Line] - // - // After - // - // [Empty Line] * 10 - // - final editor = tester.editor..insertEmptyTextNode(); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - // Pressing the enter key continuously. - for (int i = 1; i <= 10; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.enter, - ); - expect(editor.documentLength, i + 1); - expect(editor.documentSelection, - Selection.single(path: [i], startOffset: 0)); - } - }); - - testWidgets('Presses enter key in non-empty document', (tester) async { - // Before - // - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // - // After - // - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // [Empty Line] - // Welcome to Appflowy 😁 - // - const text = 'Welcome to Appflowy 😁'; - var lines = 3; - - final editor = tester.editor; - for (var i = 1; i <= lines; i++) { - editor.insertTextNode(text); - } - await editor.startTesting(); - - expect(editor.documentLength, lines); - - // Presses the enter key in last line. - await editor.updateSelection( - Selection.single(path: [lines - 1], startOffset: 0), - ); - await editor.pressLogicKey( - LogicalKeyboardKey.enter, - ); - lines += 1; - expect(editor.documentLength, lines); - expect(editor.documentSelection, - Selection.single(path: [lines - 1], startOffset: 0)); - var lastNode = editor.nodeAtPath([lines - 1]); - expect(lastNode != null, true); - expect(lastNode is TextNode, true); - lastNode = lastNode as TextNode; - expect(lastNode.delta.toPlainText(), text); - expect((lastNode.previous as TextNode).delta.toPlainText(), ''); - expect( - (lastNode.previous!.previous as TextNode).delta.toPlainText(), text); - }); - - // Before - // - // Welcome to Appflowy 😁 - // [Style] Welcome to Appflowy 😁 - // [Style] Welcome to Appflowy 😁 - // - // After - // - // Welcome to Appflowy 😁 - // [Empty Line] - // [Style] Welcome to Appflowy 😁 - // [Style] Welcome to Appflowy 😁 - // [Style] - testWidgets('Presses enter key in bulleted list', (tester) async { - await _testStyleNeedToBeCopy(tester, BuiltInAttributeKey.bulletedList); - }); - testWidgets('Presses enter key in numbered list', (tester) async { - await _testStyleNeedToBeCopy(tester, BuiltInAttributeKey.numberList); - }); - testWidgets('Presses enter key in checkbox styled text', (tester) async { - await _testStyleNeedToBeCopy(tester, BuiltInAttributeKey.checkbox); - }); - testWidgets('Presses enter key in quoted text', (tester) async { - await _testStyleNeedToBeCopy(tester, BuiltInAttributeKey.quote); - }); - - testWidgets('Presses enter key in multiple selection from top to bottom', - (tester) async { - _testMultipleSelection(tester, true); - }); - - testWidgets('Presses enter key in multiple selection from bottom to top', - (tester) async { - _testMultipleSelection(tester, false); - }); - - testWidgets('Presses enter key in the first line', (tester) async { - // Before - // - // Welcome to Appflowy 😁 - // - // After - // - // [Empty Line] - // Welcome to Appflowy 😁 - // - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor..insertTextNode(text); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - await editor.pressLogicKey(LogicalKeyboardKey.enter); - expect(editor.documentLength, 2); - expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), text); - }); - }); -} - -Future _testStyleNeedToBeCopy(WidgetTester tester, String style) async { - const text = 'Welcome to Appflowy 😁'; - Attributes attributes = { - BuiltInAttributeKey.subtype: style, - }; - if (style == BuiltInAttributeKey.checkbox) { - attributes[BuiltInAttributeKey.checkbox] = true; - } else if (style == BuiltInAttributeKey.numberList) { - attributes[BuiltInAttributeKey.number] = 1; - } - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text, attributes: attributes) - ..insertTextNode(text, attributes: attributes); - - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [1], startOffset: 0), - ); - await editor.pressLogicKey( - LogicalKeyboardKey.enter, - ); - expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0)); - - await editor.updateSelection( - Selection.single(path: [3], startOffset: text.length), - ); - await editor.pressLogicKey( - LogicalKeyboardKey.enter, - ); - expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0)); - - if ([BuiltInAttributeKey.heading, BuiltInAttributeKey.quote] - .contains(style)) { - expect(editor.nodeAtPath([4])?.subtype, null); - - await editor.pressLogicKey( - LogicalKeyboardKey.enter, - ); - expect( - editor.documentSelection, Selection.single(path: [5], startOffset: 0)); - expect(editor.nodeAtPath([5])?.subtype, null); - } else { - expect(editor.nodeAtPath([4])?.subtype, style); - - await editor.pressLogicKey( - LogicalKeyboardKey.enter, - ); - expect( - editor.documentSelection, Selection.single(path: [4], startOffset: 0)); - expect(editor.nodeAtPath([4])?.subtype, null); - } -} - -Future _testMultipleSelection( - WidgetTester tester, bool isBackwardSelection) async { - // Before - // - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // - // After - // - // Welcome - // to Appflowy 😁 - // - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor; - var lines = 4; - - for (var i = 1; i <= lines; i++) { - editor.insertTextNode(text); - } - - await editor.startTesting(); - final start = Position(path: [0], offset: 7); - final end = Position(path: [3], offset: 8); - await editor.updateSelection(Selection( - start: isBackwardSelection ? start : end, - end: isBackwardSelection ? end : start, - )); - await editor.pressLogicKey( - LogicalKeyboardKey.enter, - ); - - expect(editor.documentLength, 2); - expect((editor.nodeAtPath([0]) as TextNode).toPlainText(), 'Welcome'); - expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), 'to Appflowy 😁'); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/exit_editing_mode_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/exit_editing_mode_handler_test.dart deleted file mode 100644 index 6ee51e4da6fdf..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/exit_editing_mode_handler_test.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('exit_editing_mode_handler.dart', () { - testWidgets('Exit editing mode', (tester) async { - const text = 'Welcome to Appflowy 😁'; - const lines = 3; - final editor = tester.editor; - for (var i = 0; i < lines; i++) { - editor.insertTextNode(text); - } - await editor.startTesting(); - - // collaspsed selection - await _testSelection(editor, Selection.single(path: [1], startOffset: 0)); - - // single selection - await _testSelection( - editor, - Selection.single(path: [1], startOffset: 0, endOffset: text.length), - ); - - // mutliple selection - await _testSelection( - editor, - Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [2], offset: text.length), - ), - ); - }); - - // Future _testSelection() - }); -} - -Future _testSelection( - EditorWidgetTester editor, Selection selection) async { - await editor.updateSelection(selection); - expect(editor.documentSelection, selection); - await editor.pressLogicKey(LogicalKeyboardKey.escape); - expect(editor.documentSelection, null); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/format_style_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/format_style_handler_test.dart deleted file mode 100644 index 0cb4c71600fee..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/format_style_handler_test.dart +++ /dev/null @@ -1,309 +0,0 @@ -import 'dart:io'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/render/link_menu/link_menu.dart'; -import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; -import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('format_style_handler.dart', () { - testWidgets('Presses Command + B to update text style', (tester) async { - await _testUpdateTextStyleByCommandX( - tester, - BuiltInAttributeKey.bold, - true, - LogicalKeyboardKey.keyB, - ); - }); - testWidgets('Presses Command + I to update text style', (tester) async { - await _testUpdateTextStyleByCommandX( - tester, - BuiltInAttributeKey.italic, - true, - LogicalKeyboardKey.keyI, - ); - }); - testWidgets('Presses Command + U to update text style', (tester) async { - await _testUpdateTextStyleByCommandX( - tester, - BuiltInAttributeKey.underline, - true, - LogicalKeyboardKey.keyU, - ); - }); - testWidgets('Presses Command + Shift + S to update text style', - (tester) async { - await _testUpdateTextStyleByCommandX( - tester, - BuiltInAttributeKey.strikethrough, - true, - LogicalKeyboardKey.keyS, - ); - }); - - testWidgets('Presses Command + Shift + H to update text style', - (tester) async { - // FIXME: customize the highlight color instead of using magic number. - await _testUpdateTextStyleByCommandX( - tester, - BuiltInAttributeKey.backgroundColor, - '0x6000BCF0', - LogicalKeyboardKey.keyH, - ); - }); - - testWidgets('Presses Command + K to trigger link menu', (tester) async { - await _testLinkMenuInSingleTextSelection(tester); - }); - - testWidgets('Presses Command + E to update text style', (tester) async { - await _testUpdateTextStyleByCommandX( - tester, - BuiltInAttributeKey.code, - true, - LogicalKeyboardKey.keyE, - ); - }); - }); -} - -Future _testUpdateTextStyleByCommandX( - WidgetTester tester, - String matchStyle, - dynamic matchValue, - LogicalKeyboardKey key, -) async { - final isShiftPressed = - key == LogicalKeyboardKey.keyS || key == LogicalKeyboardKey.keyH; - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - var selection = - Selection.single(path: [1], startOffset: 2, endOffset: text.length - 2); - await editor.updateSelection(selection); - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - key, - isShiftPressed: isShiftPressed, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - key, - isShiftPressed: isShiftPressed, - isMetaPressed: true, - ); - } - var textNode = editor.nodeAtPath([1]) as TextNode; - expect( - textNode.allSatisfyInSelection( - selection, - matchStyle, - (value) { - return value == matchValue; - }, - ), - true); - - selection = - Selection.single(path: [1], startOffset: 0, endOffset: text.length); - await editor.updateSelection(selection); - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - key, - isShiftPressed: isShiftPressed, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - key, - isShiftPressed: isShiftPressed, - isMetaPressed: true, - ); - } - textNode = editor.nodeAtPath([1]) as TextNode; - expect( - textNode.allSatisfyInSelection( - selection, - matchStyle, - (value) { - return value == matchValue; - }, - ), - true); - - await editor.updateSelection(selection); - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - key, - isShiftPressed: isShiftPressed, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - key, - isShiftPressed: isShiftPressed, - isMetaPressed: true, - ); - } - textNode = editor.nodeAtPath([1]) as TextNode; - expect(textNode.allNotSatisfyInSelection(matchStyle, matchValue, selection), - true); - - selection = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [2], offset: text.length), - ); - await editor.updateSelection(selection); - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - key, - isShiftPressed: isShiftPressed, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - key, - isShiftPressed: isShiftPressed, - isMetaPressed: true, - ); - } - var nodes = editor.editorState.service.selectionService.currentSelectedNodes - .whereType(); - expect(nodes.length, 3); - for (final node in nodes) { - expect( - node.allSatisfyInSelection( - Selection.single( - path: node.path, - startOffset: 0, - endOffset: text.length, - ), - matchStyle, - (value) { - return value == matchValue; - }, - ), - true, - ); - } - - await editor.updateSelection(selection); - - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - key, - isShiftPressed: isShiftPressed, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - key, - isShiftPressed: isShiftPressed, - isMetaPressed: true, - ); - } - nodes = editor.editorState.service.selectionService.currentSelectedNodes - .whereType(); - expect(nodes.length, 3); - for (final node in nodes) { - expect( - node.allNotSatisfyInSelection( - matchStyle, - matchValue, - Selection.single( - path: node.path, startOffset: 0, endOffset: text.length), - ), - true, - ); - } -} - -Future _testLinkMenuInSingleTextSelection(WidgetTester tester) async { - const link = 'appflowy.io'; - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - final selection = - Selection.single(path: [1], startOffset: 0, endOffset: text.length); - await editor.updateSelection(selection); - - // show toolbar - expect(find.byType(ToolbarWidget), findsOneWidget); - - // trigger the link menu - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey(LogicalKeyboardKey.keyK, isControlPressed: true); - } else { - await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true); - } - expect(find.byType(LinkMenu), findsOneWidget); - - await tester.enterText(find.byType(TextField), link); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); - - expect(find.byType(LinkMenu), findsNothing); - - final node = editor.nodeAtPath([1]) as TextNode; - expect( - node.allSatisfyInSelection( - selection, - BuiltInAttributeKey.href, - (value) => value == link, - ), - true); - - await editor.updateSelection(selection); - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey(LogicalKeyboardKey.keyK, isControlPressed: true); - } else { - await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true); - } - expect(find.byType(LinkMenu), findsOneWidget); - expect( - find.text(link, findRichText: true, skipOffstage: false), findsOneWidget); - - // Copy link - final copyLink = find.text('Copy link'); - expect(copyLink, findsOneWidget); - await tester.tap(copyLink); - await tester.pumpAndSettle(); - expect(find.byType(LinkMenu), findsNothing); - - // Remove link - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey(LogicalKeyboardKey.keyK, isControlPressed: true); - } else { - await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true); - } - final removeLink = find.text('Remove link'); - expect(removeLink, findsOneWidget); - await tester.tap(removeLink); - await tester.pumpAndSettle(); - expect(find.byType(LinkMenu), findsNothing); - - expect( - node.allSatisfyInSelection( - selection, - BuiltInAttributeKey.href, - (value) => value == link, - ), - false); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler_test.dart deleted file mode 100644 index f39d58e6a35a3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_handler_test.dart +++ /dev/null @@ -1,277 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('markdown_syntax_to_styled_text_handler.dart', () { - group('convert double asterisks to bold', () { - Future insertAsterisk( - EditorWidgetTester editor, { - int repeat = 1, - }) async { - for (var i = 0; i < repeat; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.asterisk, - isShiftPressed: true, - ); - } - } - - testWidgets('**AppFlowy** to bold AppFlowy', (tester) async { - const text = '**AppFlowy*'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertAsterisk(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('App**Flowy** to bold AppFlowy', (tester) async { - const text = 'App**Flowy*'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertAsterisk(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 3, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('***AppFlowy** to bold *AppFlowy', (tester) async { - const text = '***AppFlowy*'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertAsterisk(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 1, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, true); - expect(textNode.toPlainText(), '*AppFlowy'); - }); - - testWidgets('**AppFlowy** application to bold AppFlowy only', - (tester) async { - const boldText = '**AppFlowy*'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - - for (var i = 0; i < boldText.length; i++) { - await editor.insertText(textNode, boldText[i], i); - } - await insertAsterisk(editor); - final boldTextLength = boldText.replaceAll('*', '').length; - final appFlowyBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: boldTextLength, - ), - ); - expect(appFlowyBold, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('**** nothing changes', (tester) async { - const text = '***'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertAsterisk(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, false); - expect(textNode.toPlainText(), text); - }); - }); - - group('convert double underscores to bold', () { - Future insertUnderscore( - EditorWidgetTester editor, { - int repeat = 1, - }) async { - for (var i = 0; i < repeat; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.underscore, - isShiftPressed: true, - ); - } - } - - testWidgets('__AppFlowy__ to bold AppFlowy', (tester) async { - const text = '__AppFlowy_'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertUnderscore(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('App__Flowy__ to bold AppFlowy', (tester) async { - const text = 'App__Flowy_'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertUnderscore(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 3, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('___AppFlowy__ to bold _AppFlowy', (tester) async { - const text = '___AppFlowy_'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertUnderscore(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 1, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, true); - expect(textNode.toPlainText(), '_AppFlowy'); - }); - - testWidgets('__AppFlowy__ application to bold AppFlowy only', - (tester) async { - const boldText = '__AppFlowy_'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - - for (var i = 0; i < boldText.length; i++) { - await editor.insertText(textNode, boldText[i], i); - } - await insertUnderscore(editor); - final boldTextLength = boldText.replaceAll('_', '').length; - final appFlowyBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: boldTextLength, - ), - ); - expect(appFlowyBold, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('____ nothing changes', (tester) async { - const text = '___'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertUnderscore(editor); - final allBold = textNode.allSatisfyBoldInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allBold, false); - expect(textNode.toPlainText(), text); - }); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart deleted file mode 100644 index 662c7982b4c6d..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/markdown_syntax_to_styled_text_test.dart +++ /dev/null @@ -1,260 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('markdown_syntax_to_styled_text.dart', () { - group('convert single backquote to code', () { - Future insertBackquote( - EditorWidgetTester editor, { - int repeat = 1, - }) async { - for (var i = 0; i < repeat; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.backquote, - ); - } - } - - testWidgets('`AppFlowy` to code AppFlowy', (tester) async { - const text = '`AppFlowy'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertBackquote(editor); - final allCode = textNode.allSatisfyCodeInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allCode, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('App`Flowy` to code AppFlowy', (tester) async { - const text = 'App`Flowy'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertBackquote(editor); - final allCode = textNode.allSatisfyCodeInSelection( - Selection.single( - path: [0], - startOffset: 3, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allCode, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('`` nothing changes', (tester) async { - const text = '`'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertBackquote(editor); - final allCode = textNode.allSatisfyCodeInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allCode, false); - expect(textNode.toPlainText(), text); - }); - }); - - group('convert double backquote to code', () { - Future insertBackquote( - EditorWidgetTester editor, { - int repeat = 1, - }) async { - for (var i = 0; i < repeat; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.backquote, - ); - } - } - - testWidgets('```AppFlowy`` to code `AppFlowy', (tester) async { - const text = '```AppFlowy`'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertBackquote(editor); - final allCode = textNode.allSatisfyCodeInSelection( - Selection.single( - path: [0], - startOffset: 1, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allCode, true); - expect(textNode.toPlainText(), '`AppFlowy'); - }); - - testWidgets('```` nothing changes', (tester) async { - const text = '```'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertBackquote(editor); - final allCode = textNode.allSatisfyCodeInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allCode, false); - expect(textNode.toPlainText(), text); - }); - }); - - group('convert double tilde to strikethrough', () { - Future insertTilde( - EditorWidgetTester editor, { - int repeat = 1, - }) async { - for (var i = 0; i < repeat; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.tilde, - isShiftPressed: true, - ); - } - } - - testWidgets('~~AppFlowy~~ to strikethrough AppFlowy', (tester) async { - const text = '~~AppFlowy~'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertTilde(editor); - final allStrikethrough = textNode.allSatisfyStrikethroughInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allStrikethrough, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('App~~Flowy~~ to strikethrough AppFlowy', (tester) async { - const text = 'App~~Flowy~'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertTilde(editor); - final allStrikethrough = textNode.allSatisfyStrikethroughInSelection( - Selection.single( - path: [0], - startOffset: 3, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allStrikethrough, true); - expect(textNode.toPlainText(), 'AppFlowy'); - }); - - testWidgets('~~~AppFlowy~~ to bold ~AppFlowy', (tester) async { - const text = '~~~AppFlowy~'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertTilde(editor); - final allStrikethrough = textNode.allSatisfyStrikethroughInSelection( - Selection.single( - path: [0], - startOffset: 1, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allStrikethrough, true); - expect(textNode.toPlainText(), '~AppFlowy'); - }); - - testWidgets('~~~~ nothing changes', (tester) async { - const text = '~~~'; - final editor = tester.editor..insertTextNode(''); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - final textNode = editor.nodeAtPath([0]) as TextNode; - for (var i = 0; i < text.length; i++) { - await editor.insertText(textNode, text[i], i); - } - await insertTilde(editor); - final allStrikethrough = textNode.allSatisfyStrikethroughInSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: textNode.toPlainText().length, - ), - ); - expect(allStrikethrough, false); - expect(textNode.toPlainText(), text); - }); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/page_up_down_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/page_up_down_handler_test.dart deleted file mode 100644 index 465d198e8ec21..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/page_up_down_handler_test.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('page_up_down_handler_test.dart', () { - testWidgets('Presses PageUp and pageDown key in large document', - (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor; - for (var i = 0; i < 1000; i++) { - editor.insertTextNode(text); - } - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - - final scrollService = editor.editorState.service.scrollService; - - expect(scrollService != null, true); - - if (scrollService == null) { - return; - } - - final page = scrollService.page; - final onePageHeight = scrollService.onePageHeight; - expect(page != null, true); - expect(onePageHeight != null, true); - - // Pressing the pageDown key continuously. - var currentOffsetY = 0.0; - for (int i = 1; i <= page!; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.pageDown, - ); - if (i == page) { - currentOffsetY = scrollService.maxScrollExtent; - } else { - currentOffsetY += onePageHeight!; - } - final dy = scrollService.dy; - expect(dy, currentOffsetY); - } - - for (int i = 1; i <= 5; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.pageDown, - ); - final dy = scrollService.dy; - expect(dy == scrollService.maxScrollExtent, true); - } - - // Pressing the pageUp key continuously. - for (int i = page; i >= 1; i--) { - await editor.pressLogicKey( - LogicalKeyboardKey.pageUp, - ); - if (i == 1) { - currentOffsetY = scrollService.minScrollExtent; - } else { - currentOffsetY -= onePageHeight!; - } - final dy = editor.editorState.service.scrollService?.dy; - expect(dy, currentOffsetY); - } - - for (int i = 1; i <= 5; i++) { - await editor.pressLogicKey( - LogicalKeyboardKey.pageUp, - ); - final dy = scrollService.dy; - expect(dy == scrollService.minScrollExtent, true); - } - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/redo_undo_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/redo_undo_handler_test.dart deleted file mode 100644 index 5e4260a4a1a0c..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/redo_undo_handler_test.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'dart:io'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('redo_undo_handler_test.dart', () { - // TODO: need to test more cases. - testWidgets('Redo, Undo for backspace key, and selection is downward', - (tester) async { - await _testBackspaceUndoRedo(tester, true); - }); - - testWidgets('Redo, Undo for backspace key, and selection is forward', - (tester) async { - await _testBackspaceUndoRedo(tester, false); - }); - }); -} - -Future _testBackspaceUndoRedo( - WidgetTester tester, bool isDownwardSelection) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - final start = Position(path: [0], offset: text.length); - final end = Position(path: [1], offset: text.length); - final selection = Selection( - start: isDownwardSelection ? start : end, - end: isDownwardSelection ? end : start, - ); - await editor.updateSelection(selection); - await editor.pressLogicKey(LogicalKeyboardKey.backspace); - expect(editor.documentLength, 2); - - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - LogicalKeyboardKey.keyZ, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - LogicalKeyboardKey.keyZ, - isMetaPressed: true, - ); - } - - expect(editor.documentLength, 3); - expect((editor.nodeAtPath([1]) as TextNode).toPlainText(), text); - expect(editor.documentSelection, selection); - - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - LogicalKeyboardKey.keyZ, - isControlPressed: true, - isShiftPressed: true, - ); - } else { - await editor.pressLogicKey( - LogicalKeyboardKey.keyZ, - isMetaPressed: true, - isShiftPressed: true, - ); - } - - expect(editor.documentLength, 2); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/select_all_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/select_all_handler_test.dart deleted file mode 100644 index 937988da5880f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/select_all_handler_test.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'dart:io'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('select_all_handler_test.dart', () { - testWidgets('Presses Command + A in small document', (tester) async { - await _testSelectAllHandler(tester, 10); - }); - - testWidgets('Presses Command + A in small document', (tester) async { - await _testSelectAllHandler(tester, 1000); - }); - }); -} - -Future _testSelectAllHandler(WidgetTester tester, int lines) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor; - for (var i = 0; i < lines; i++) { - editor.insertTextNode(text); - } - await editor.startTesting(); - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey(LogicalKeyboardKey.keyA, isControlPressed: true); - } else { - await editor.pressLogicKey(LogicalKeyboardKey.keyA, isMetaPressed: true); - } - - expect( - editor.documentSelection, - Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [lines - 1], offset: text.length), - ), - ); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/slash_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/slash_handler_test.dart deleted file mode 100644 index 0b53b43af9ac9..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/slash_handler_test.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart'; -import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('slash_handler.dart', () { - testWidgets('Presses / to trigger selection menu', (tester) async { - const text = 'Welcome to Appflowy 😁'; - const lines = 3; - final editor = tester.editor; - for (var i = 0; i < lines; i++) { - editor.insertTextNode(text); - } - await editor.startTesting(); - await editor.updateSelection(Selection.single(path: [1], startOffset: 0)); - await editor.pressLogicKey(LogicalKeyboardKey.slash); - - await tester.pumpAndSettle(const Duration(milliseconds: 1000)); - - expect( - find.byType(SelectionMenuWidget, skipOffstage: false), - findsOneWidget, - ); - - for (final item in defaultSelectionMenuItems) { - expect(find.text(item.name()), findsOneWidget); - } - - await editor.updateSelection(Selection.single(path: [1], startOffset: 0)); - - await tester.pumpAndSettle(const Duration(milliseconds: 200)); - - expect( - find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNothing, - ); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart deleted file mode 100644 index a49d882fa36a4..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('space_on_web_handler.dart', () { - testWidgets('Presses space key on web', (tester) async { - if (!kIsWeb) return; - const count = 10; - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor; - for (var i = 0; i < count; i++) { - editor.insertTextNode(text); - } - await editor.startTesting(); - - for (var i = 0; i < count; i++) { - await editor.updateSelection( - Selection.single(path: [i], startOffset: 1), - ); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect( - (editor.nodeAtPath([i]) as TextNode).toPlainText(), - 'W elcome to Appflowy 😁', - ); - } - for (var i = 0; i < count; i++) { - await editor.updateSelection( - Selection.single(path: [i], startOffset: text.length + 1), - ); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect( - (editor.nodeAtPath([i]) as TextNode).toPlainText(), - 'W elcome to Appflowy 😁 ', - ); - } - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart deleted file mode 100644 index 641282c55fee3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('tab_handler.dart', () { - testWidgets('press tab in plain text', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - var selection = Selection.single(path: [0], startOffset: 0); - await editor.updateSelection(selection); - await editor.pressLogicKey(LogicalKeyboardKey.tab); - - expect( - editor.documentSelection, - Selection.single(path: [0], startOffset: 4), - ); - - selection = Selection.single(path: [1], startOffset: 0); - await editor.updateSelection(selection); - await editor.pressLogicKey(LogicalKeyboardKey.tab); - - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: 4), - ); - }); - - testWidgets('press tab in bulleted list', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode( - text, - attributes: { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList - }, - ) - ..insertTextNode( - text, - attributes: { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList - }, - ) - ..insertTextNode( - text, - attributes: { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList - }, - ); - await editor.startTesting(); - var document = editor.document; - - var selection = Selection.single(path: [0], startOffset: 0); - await editor.updateSelection(selection); - await editor.pressLogicKey(LogicalKeyboardKey.tab); - - // nothing happens - expect( - editor.documentSelection, - Selection.single(path: [0], startOffset: 0), - ); - expect(editor.document.toJson(), document.toJson()); - - // Before - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // After - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - - selection = Selection.single(path: [1], startOffset: 0); - await editor.updateSelection(selection); - - await editor.pressLogicKey(LogicalKeyboardKey.tab); - - expect( - editor.documentSelection, - Selection.single(path: [0, 0], startOffset: 0), - ); - expect(editor.nodeAtPath([0])!.subtype, BuiltInAttributeKey.bulletedList); - expect(editor.nodeAtPath([1])!.subtype, BuiltInAttributeKey.bulletedList); - expect(editor.nodeAtPath([2]), null); - expect( - editor.nodeAtPath([0, 0])!.subtype, BuiltInAttributeKey.bulletedList); - - selection = Selection.single(path: [1], startOffset: 0); - await editor.updateSelection(selection); - await editor.pressLogicKey(LogicalKeyboardKey.tab); - - expect( - editor.documentSelection, - Selection.single(path: [0, 1], startOffset: 0), - ); - expect(editor.nodeAtPath([0])!.subtype, BuiltInAttributeKey.bulletedList); - expect(editor.nodeAtPath([1]), null); - expect(editor.nodeAtPath([2]), null); - expect( - editor.nodeAtPath([0, 0])!.subtype, BuiltInAttributeKey.bulletedList); - expect( - editor.nodeAtPath([0, 1])!.subtype, BuiltInAttributeKey.bulletedList); - - // Before - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // After - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - // * Welcome to Appflowy 😁 - document = editor.document; - selection = Selection.single(path: [0, 0], startOffset: 0); - await editor.updateSelection(selection); - await editor.pressLogicKey(LogicalKeyboardKey.tab); - - expect( - editor.documentSelection, - Selection.single(path: [0, 0], startOffset: 0), - ); - expect(editor.document.toJson(), document.toJson()); - - selection = Selection.single(path: [0, 1], startOffset: 0); - await editor.updateSelection(selection); - await editor.pressLogicKey(LogicalKeyboardKey.tab); - - expect( - editor.documentSelection, - Selection.single(path: [0, 0, 0], startOffset: 0), - ); - expect( - editor.nodeAtPath([0])!.subtype, - BuiltInAttributeKey.bulletedList, - ); - expect( - editor.nodeAtPath([0, 0])!.subtype, - BuiltInAttributeKey.bulletedList, - ); - expect(editor.nodeAtPath([0, 1]), null); - expect( - editor.nodeAtPath([0, 0, 0])!.subtype, - BuiltInAttributeKey.bulletedList, - ); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/white_space_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/white_space_handler_test.dart deleted file mode 100644 index f62d977ecec11..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/white_space_handler_test.dart +++ /dev/null @@ -1,231 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('white_space_handler.dart', () { - // Before - // - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // - // After - // [h1]Welcome to Appflowy 😁 - // [h2]Welcome to Appflowy 😁 - // [h3]Welcome to Appflowy 😁 - // [h4]Welcome to Appflowy 😁 - // [h5]Welcome to Appflowy 😁 - // [h6]Welcome to Appflowy 😁 - // - testWidgets('Presses whitespace key after #*', (tester) async { - const maxSignCount = 6; - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor; - for (var i = 1; i <= maxSignCount; i++) { - editor.insertTextNode('${'#' * i}$text'); - } - await editor.startTesting(); - - for (var i = 1; i <= maxSignCount; i++) { - await editor.updateSelection( - Selection.single(path: [i - 1], startOffset: i), - ); - await editor.pressLogicKey(LogicalKeyboardKey.space); - - final textNode = (editor.nodeAtPath([i - 1]) as TextNode); - - expect(textNode.subtype, BuiltInAttributeKey.heading); - // BuiltInAttributeKey.h1 ~ BuiltInAttributeKey.h6 - expect(textNode.attributes.heading, 'h$i'); - } - }); - - // Before - // - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // Welcome to Appflowy 😁 - // - // After - // [h1]##Welcome to Appflowy 😁 - // [h2]##Welcome to Appflowy 😁 - // [h3]##Welcome to Appflowy 😁 - // [h4]##Welcome to Appflowy 😁 - // [h5]##Welcome to Appflowy 😁 - // [h6]##Welcome to Appflowy 😁 - // - testWidgets('Presses whitespace key inside #*', (tester) async { - const maxSignCount = 6; - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor; - for (var i = 1; i <= maxSignCount; i++) { - editor.insertTextNode('${'###' * i}$text'); - } - await editor.startTesting(); - - for (var i = 1; i <= maxSignCount; i++) { - await editor.updateSelection( - Selection.single(path: [i - 1], startOffset: i), - ); - await editor.pressLogicKey(LogicalKeyboardKey.space); - - final textNode = (editor.nodeAtPath([i - 1]) as TextNode); - - expect(textNode.subtype, BuiltInAttributeKey.heading); - // BuiltInAttributeKey.h1 ~ BuiltInAttributeKey.h6 - expect(textNode.attributes.heading, 'h$i'); - expect(textNode.toPlainText().startsWith('##'), true); - } - }); - - // Before - // - // Welcome to Appflowy 😁 - // - // After - // [h1 ~ h6]##Welcome to Appflowy 😁 - // - testWidgets('Presses whitespace key in heading styled text', - (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor..insertTextNode(text); - - await editor.startTesting(); - - const maxSignCount = 6; - for (var i = 1; i <= maxSignCount; i++) { - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - - final textNode = (editor.nodeAtPath([0]) as TextNode); - - await editor.insertText(textNode, '#' * i, 0); - await editor.pressLogicKey(LogicalKeyboardKey.space); - - expect(textNode.subtype, BuiltInAttributeKey.heading); - // BuiltInAttributeKey.h2 ~ BuiltInAttributeKey.h6 - expect(textNode.attributes.heading, 'h$i'); - } - }); - - testWidgets('Presses whitespace key after (un)checkbox symbols', - (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor..insertTextNode(text); - await editor.startTesting(); - - final textNode = editor.nodeAtPath([0]) as TextNode; - for (final symbol in unCheckboxListSymbols) { - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - await editor.insertText(textNode, symbol, 0); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect(textNode.subtype, BuiltInAttributeKey.checkbox); - expect(textNode.attributes.check, false); - } - }); - - testWidgets('Presses whitespace key after checkbox symbols', - (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor..insertTextNode(text); - await editor.startTesting(); - - final textNode = editor.nodeAtPath([0]) as TextNode; - for (final symbol in checkboxListSymbols) { - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - await editor.insertText(textNode, symbol, 0); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect(textNode.subtype, BuiltInAttributeKey.checkbox); - expect(textNode.attributes.check, true); - } - }); - - testWidgets('Presses whitespace key after bulleted list', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor..insertTextNode(text); - await editor.startTesting(); - - final textNode = editor.nodeAtPath([0]) as TextNode; - for (final symbol in bulletedListSymbols) { - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - await editor.insertText(textNode, symbol, 0); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect(textNode.subtype, BuiltInAttributeKey.bulletedList); - } - }); - - testWidgets('Presses whitespace key in edge cases', (tester) async { - const text = ''; - final editor = tester.editor..insertTextNode(text); - await editor.startTesting(); - - final textNode = editor.nodeAtPath([0]) as TextNode; - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0), - ); - - await editor.insertText(textNode, '*', 0); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect(textNode.subtype, BuiltInAttributeKey.bulletedList); - - await editor.insertText(textNode, '[]', 0); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect(textNode.subtype, BuiltInAttributeKey.checkbox); - expect(textNode.attributes.check, false); - - await editor.insertText(textNode, '1.', 0); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect(textNode.subtype, BuiltInAttributeKey.numberList); - - await editor.insertText(textNode, '#', 0); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect(textNode.subtype, BuiltInAttributeKey.heading); - - await editor.insertText(textNode, '[x]', 0); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect(textNode.subtype, BuiltInAttributeKey.checkbox); - expect(textNode.attributes.check, true); - - const insertedText = '[]AppFlowy'; - await editor.insertText(textNode, insertedText, 0); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect(textNode.subtype, BuiltInAttributeKey.checkbox); - expect(textNode.attributes.check, true); - expect(textNode.toPlainText(), insertedText); - }); - - testWidgets('Presses # at the end of the text', (tester) async { - const text = 'Welcome to Appflowy 😁 #'; - final editor = tester.editor..insertTextNode(text); - await editor.startTesting(); - - final textNode = editor.nodeAtPath([0]) as TextNode; - await editor.updateSelection( - Selection.single(path: [0], startOffset: text.length), - ); - await editor.pressLogicKey(LogicalKeyboardKey.space); - expect(textNode.subtype, null); - expect(textNode.toPlainText(), text); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/scroll_service_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/scroll_service_test.dart deleted file mode 100644 index cf724a731cb53..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/scroll_service_test.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('Testing Scroll With Gestures', () { - testWidgets('Test Gestsure Scroll', (tester) async { - final editor = tester.editor; - for (var i = 0; i < 100; i++) { - editor.insertTextNode('$i'); - } - editor.insertTextNode('mark'); - for (var i = 100; i < 200; i++) { - editor.insertTextNode('$i'); - } - await editor.startTesting(); - - final listFinder = find.byType(Scrollable); - final itemFinder = find.text('mark', findRichText: true); - - await tester.scrollUntilVisible(itemFinder, 500.0, - scrollable: listFinder); - - expect(itemFinder, findsOneWidget); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/selection_service_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/selection_service_test.dart deleted file mode 100644 index 5df8e3631969c..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/selection_service_test.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'dart:io'; - -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/service/context_menu/context_menu.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('selection_service.dart', () { - testWidgets('Single tap test ', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - final secondTextNode = editor.nodeAtPath([1]); - final finder = find.byKey(secondTextNode!.key!); - - final rect = tester.getRect(finder); - // tap at the beginning - await tester.tapAt(rect.centerLeft); - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: 0), - ); - - // tap at the ending - await tester.tapAt(rect.centerRight); - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: text.length), - ); - }); - - testWidgets('Test double tap', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - final secondTextNode = editor.nodeAtPath([1]); - final finder = find.byKey(secondTextNode!.key!); - - final rect = tester.getRect(finder); - // double tap - await tester.tapAt(rect.centerLeft + const Offset(10.0, 0.0)); - await tester.tapAt(rect.centerLeft + const Offset(10.0, 0.0)); - await tester.pump(); - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: 0, endOffset: 7), - ); - }); - - testWidgets('Test triple tap', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - final secondTextNode = editor.nodeAtPath([1]); - final finder = find.byKey(secondTextNode!.key!); - - final rect = tester.getRect(finder); - // triple tap - await tester.tapAt(rect.centerLeft + const Offset(10.0, 0.0)); - await tester.tapAt(rect.centerLeft + const Offset(10.0, 0.0)); - await tester.tapAt(rect.centerLeft + const Offset(10.0, 0.0)); - await tester.pump(); - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: 0, endOffset: text.length), - ); - }); - - testWidgets('Test secondary tap', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - final secondTextNode = editor.nodeAtPath([1]) as TextNode; - final finder = find.byKey(secondTextNode.key!); - - final rect = tester.getRect(finder); - // secondary tap - await tester.tapAt( - rect.centerLeft + const Offset(10.0, 0.0), - buttons: kSecondaryButton, - ); - await tester.pump(); - - const welcome = 'Welcome'; - expect( - editor.documentSelection, - Selection.single( - path: [1], - startOffset: 0, - endOffset: welcome.length, - ), // Welcome - ); - - final contextMenu = find.byType(ContextMenu); - expect(contextMenu, findsOneWidget); - - // test built in context menu items - - // Skip the Windows platform because the rich_clipboard package doesn't support it perfectly. - if (Platform.isWindows) { - return; - } - - // cut - await tester.tap(find.text('Cut')); - await tester.pump(); - expect( - secondTextNode.toPlainText(), - text.replaceAll(welcome, ''), - ); - - // TODO: the copy and paste test is not working during test env. - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/shortcut_event/keybinding_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/shortcut_event/keybinding_test.dart deleted file mode 100644 index 48b335c6c744e..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/shortcut_event/keybinding_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('keybinding_test.dart', () { - test('keybinding parse(cmd+shift+alt+ctrl+a)', () { - const command = 'cmd+shift+alt+ctrl+a'; - final keybinding = Keybinding.parse(command); - expect(keybinding.isAltPressed, true); - expect(keybinding.isShiftPressed, true); - expect(keybinding.isMetaPressed, true); - expect(keybinding.isControlPressed, true); - expect(keybinding.keyLabel, 'a'); - }); - - test('keybinding parse(cmd+shift+alt+a)', () { - const command = 'cmd+shift+alt+a'; - final keybinding = Keybinding.parse(command); - expect(keybinding.isAltPressed, true); - expect(keybinding.isShiftPressed, true); - expect(keybinding.isMetaPressed, true); - expect(keybinding.isControlPressed, false); - expect(keybinding.keyLabel, 'a'); - }); - - test('keybinding parse(cmd+shift+ctrl+a)', () { - const command = 'cmd+shift+ctrl+a'; - final keybinding = Keybinding.parse(command); - expect(keybinding.isAltPressed, false); - expect(keybinding.isShiftPressed, true); - expect(keybinding.isMetaPressed, true); - expect(keybinding.isControlPressed, true); - expect(keybinding.keyLabel, 'a'); - }); - - test('keybinding parse(cmd+alt+ctrl+a)', () { - const command = 'cmd+alt+ctrl+a'; - final keybinding = Keybinding.parse(command); - expect(keybinding.isAltPressed, true); - expect(keybinding.isShiftPressed, false); - expect(keybinding.isMetaPressed, true); - expect(keybinding.isControlPressed, true); - expect(keybinding.keyLabel, 'a'); - }); - - test('keybinding parse(shift+alt+ctrl+a)', () { - const command = 'shift+alt+ctrl+a'; - final keybinding = Keybinding.parse(command); - expect(keybinding.isAltPressed, true); - expect(keybinding.isShiftPressed, true); - expect(keybinding.isMetaPressed, false); - expect(keybinding.isControlPressed, true); - expect(keybinding.keyLabel, 'a'); - }); - - test('keybinding copyWith', () { - const command = 'shift+alt+ctrl+a'; - final keybinding = - Keybinding.parse(command).copyWith(isMetaPressed: true); - expect(keybinding.isAltPressed, true); - expect(keybinding.isShiftPressed, true); - expect(keybinding.isMetaPressed, true); - expect(keybinding.isControlPressed, true); - expect(keybinding.keyLabel, 'a'); - }); - - test('keybinding equal', () { - const command = 'cmd+shift+alt+ctrl+a'; - expect(Keybinding.parse(command), Keybinding.parse(command)); - }); - - test('keybinding toMap', () { - const command = 'cmd+shift+alt+ctrl+a'; - final keybinding = Keybinding.parse(command); - expect(keybinding, Keybinding.fromMap(keybinding.toMap())); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/shortcut_event/shortcut_event_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/shortcut_event/shortcut_event_test.dart deleted file mode 100644 index 034d91a93e2c6..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/shortcut_event/shortcut_event_test.dart +++ /dev/null @@ -1,223 +0,0 @@ -import 'dart:io'; - -import 'package:appflowy_editor/src/service/shortcut_event/built_in_shortcut_events.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import '../../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('shortcut_event.dart', () { - test('redefine shortcut event command', () { - final shortcutEvent = ShortcutEvent( - key: 'Sample', - command: 'cmd+shift+alt+ctrl+a', - handler: (editorState, event) { - return KeyEventResult.handled; - }, - ); - shortcutEvent.updateCommand(command: 'cmd+shift+alt+ctrl+b'); - expect(shortcutEvent.keybindings.length, 1); - expect(shortcutEvent.keybindings.first.isMetaPressed, true); - expect(shortcutEvent.keybindings.first.isShiftPressed, true); - expect(shortcutEvent.keybindings.first.isAltPressed, true); - expect(shortcutEvent.keybindings.first.isControlPressed, true); - expect(shortcutEvent.keybindings.first.keyLabel, 'b'); - }); - - testWidgets('redefine move cursor begin command', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [1], startOffset: text.length), - ); - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowLeft, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowLeft, - isMetaPressed: true, - ); - } - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: 0), - ); - await editor.updateSelection( - Selection.single(path: [1], startOffset: text.length), - ); - - for (final event in builtInShortcutEvents) { - if (event.key == 'Move cursor begin') { - event.updateCommand( - windowsCommand: 'alt+arrow left', - linuxCommand: 'alt+arrow left', - macOSCommand: 'alt+arrow left', - ); - } - } - if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowLeft, - isAltPressed: true, - ); - } else { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowLeft, - isMetaPressed: true, - ); - } - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: 0), - ); - - tester.pumpAndSettle(); - }); - - testWidgets('redefine move cursor end command', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [1], startOffset: 0), - ); - if (Platform.isWindows || Platform.isLinux) { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowRight, - isControlPressed: true, - ); - } else { - await editor.pressLogicKey( - LogicalKeyboardKey.arrowRight, - isMetaPressed: true, - ); - } - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: text.length), - ); - await editor.updateSelection( - Selection.single(path: [1], startOffset: 0), - ); - - for (final event in builtInShortcutEvents) { - if (event.key == 'Move cursor end') { - event.updateCommand( - windowsCommand: 'alt+arrow right', - linuxCommand: 'alt+arrow right', - macOSCommand: 'alt+arrow right', - ); - } - } - await editor.pressLogicKey( - LogicalKeyboardKey.arrowRight, - isAltPressed: true, - ); - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: text.length), - ); - }); - - testWidgets('Test Home Key to move to start of current text', - (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [1], startOffset: text.length), - ); - if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { - await editor.pressLogicKey( - LogicalKeyboardKey.home, - ); - } - - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: 0), - ); - await editor.updateSelection( - Selection.single(path: [1], startOffset: text.length), - ); - - for (final event in builtInShortcutEvents) { - if (event.key == 'Move cursor begin') { - event.updateCommand( - windowsCommand: 'home', - linuxCommand: 'home', - macOSCommand: 'home', - ); - } - } - if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { - await editor.pressLogicKey( - LogicalKeyboardKey.home, - ); - } - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: 0), - ); - }); - - testWidgets('Test End Key to move to end of current text', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [1], startOffset: text.length), - ); - if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { - await editor.pressLogicKey( - LogicalKeyboardKey.end, - ); - } - - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: text.length), - ); - await editor.updateSelection( - Selection.single(path: [1], startOffset: 0), - ); - - for (final event in builtInShortcutEvents) { - if (event.key == 'Move cursor end') { - event.updateCommand( - windowsCommand: 'end', - linuxCommand: 'end', - macOSCommand: 'end', - ); - } - } - if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { - await editor.pressLogicKey( - LogicalKeyboardKey.end, - ); - } - expect( - editor.documentSelection, - Selection.single(path: [1], startOffset: text.length), - ); - }); - }); -} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/toolbar_service_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/toolbar_service_test.dart deleted file mode 100644 index 2388507003765..0000000000000 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/toolbar_service_test.dart +++ /dev/null @@ -1,231 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; -import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart'; -import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart'; -import 'package:flutter_test/flutter_test.dart'; -import '../infra/test_editor.dart'; - -void main() async { - setUpAll(() { - TestWidgetsFlutterBinding.ensureInitialized(); - }); - - group('toolbar_service.dart', () { - testWidgets('Test toolbar service in multi text selection', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode(text) - ..insertTextNode(text); - await editor.startTesting(); - - final selection = Selection( - start: Position(path: [0], offset: 0), - end: Position(path: [1], offset: text.length), - ); - await editor.updateSelection(selection); - - expect(find.byType(ToolbarWidget), findsOneWidget); - - // no link item - final item = defaultToolbarItems - .where((item) => item.id == 'appflowy.toolbar.link') - .first; - final finder = find.byType(ToolbarItemWidget); - - expect( - tester - .widgetList(finder) - .toList(growable: false) - .where((element) => element.item.id == item.id) - .isEmpty, - true, - ); - }); - - testWidgets( - 'Test toolbar service in single text selection with BuiltInAttributeKey.partialStyleKeys', - (tester) async { - final attributes = BuiltInAttributeKey.partialStyleKeys - .fold({}, (previousValue, element) { - if (element == BuiltInAttributeKey.backgroundColor) { - previousValue[element] = '0x6000BCF0'; - } else if (element == BuiltInAttributeKey.href) { - previousValue[element] = 'appflowy.io'; - } else { - previousValue[element] = true; - } - return previousValue; - }); - - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor - ..insertTextNode(text) - ..insertTextNode( - null, - delta: Delta(operations: [ - TextInsert(text), - TextInsert(text, attributes: attributes), - TextInsert(text), - ]), - ); - await editor.startTesting(); - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0, endOffset: text.length), - ); - expect(find.byType(ToolbarWidget), findsOneWidget); - - void testHighlight(bool expectedValue) { - for (final styleKey in BuiltInAttributeKey.partialStyleKeys) { - var key = styleKey; - if (styleKey == BuiltInAttributeKey.backgroundColor) { - key = 'highlight'; - } else if (styleKey == BuiltInAttributeKey.href) { - key = 'link'; - } else { - continue; - } - final itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.$key'); - expect(itemWidget.isHighlight, expectedValue); - } - } - - await editor.updateSelection( - Selection.single(path: [1], startOffset: 0, endOffset: text.length * 2), - ); - testHighlight(false); - - await editor.updateSelection( - Selection.single( - path: [1], - startOffset: text.length, - endOffset: text.length * 2, - ), - ); - testHighlight(true); - - await editor.updateSelection( - Selection.single( - path: [1], - startOffset: text.length + 2, - endOffset: text.length * 2 - 2, - ), - ); - testHighlight(true); - }); - - testWidgets( - 'Test toolbar service in single text selection with BuiltInAttributeKey.globalStyleKeys', - (tester) async { - const text = 'Welcome to Appflowy 😁'; - - final editor = tester.editor - ..insertTextNode(text, attributes: { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading, - BuiltInAttributeKey.heading: BuiltInAttributeKey.h1, - }) - ..insertTextNode( - text, - attributes: {BuiltInAttributeKey.subtype: BuiltInAttributeKey.quote}, - ) - ..insertTextNode( - text, - attributes: { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList - }, - ); - await editor.startTesting(); - - await editor.updateSelection( - Selection.single(path: [0], startOffset: 0, endOffset: text.length), - ); - expect(find.byType(ToolbarWidget), findsOneWidget); - var itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.h1'); - expect(itemWidget.isHighlight, true); - - await editor.updateSelection( - Selection.single(path: [1], startOffset: 0, endOffset: text.length), - ); - expect(find.byType(ToolbarWidget), findsOneWidget); - itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.quote'); - expect(itemWidget.isHighlight, true); - - await editor.updateSelection( - Selection.single(path: [2], startOffset: 0, endOffset: text.length), - ); - expect(find.byType(ToolbarWidget), findsOneWidget); - itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.bulleted_list'); - expect(itemWidget.isHighlight, true); - }); - - testWidgets('Test toolbar service in multi text selection', (tester) async { - const text = 'Welcome to Appflowy 😁'; - - /// [h1][bold] Welcome to Appflowy 😁 - /// [EmptyLine] - /// Welcome to Appflowy 😁 - final editor = tester.editor - ..insertTextNode( - null, - attributes: { - BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading, - BuiltInAttributeKey.heading: BuiltInAttributeKey.h1, - }, - delta: Delta(operations: [ - TextInsert(text, attributes: { - BuiltInAttributeKey.bold: true, - }) - ]), - ) - ..insertTextNode(null) - ..insertTextNode(text); - await editor.startTesting(); - - await editor.updateSelection( - Selection.single(path: [2], startOffset: text.length, endOffset: 0), - ); - expect(find.byType(ToolbarWidget), findsOneWidget); - expect( - _itemWidgetForId(tester, 'appflowy.toolbar.h1').isHighlight, - false, - ); - expect( - _itemWidgetForId(tester, 'appflowy.toolbar.bold').isHighlight, - false, - ); - - await editor.updateSelection( - Selection( - start: Position(path: [2], offset: text.length), - end: Position(path: [1], offset: 0), - ), - ); - expect(find.byType(ToolbarWidget), findsOneWidget); - expect( - _itemWidgetForId(tester, 'appflowy.toolbar.bold').isHighlight, - false, - ); - - await editor.updateSelection( - Selection( - start: Position(path: [2], offset: text.length), - end: Position(path: [0], offset: 0), - ), - ); - expect(find.byType(ToolbarWidget), findsOneWidget); - expect( - _itemWidgetForId(tester, 'appflowy.toolbar.bold').isHighlight, - false, - ); - }); - }); -} - -ToolbarItemWidget _itemWidgetForId(WidgetTester tester, String id) { - final finder = find.byType(ToolbarItemWidget); - final itemWidgets = tester - .widgetList(finder) - .where((element) => element.item.id == id); - expect(itemWidgets.length, 1); - return itemWidgets.first; -} diff --git a/frontend/app_flowy/packages/appflowy_popover/.gitignore b/frontend/app_flowy/packages/appflowy_popover/.gitignore deleted file mode 100644 index 96486fd930243..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/.gitignore +++ /dev/null @@ -1,30 +0,0 @@ -# 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/ -.packages -build/ diff --git a/frontend/app_flowy/packages/appflowy_popover/.metadata b/frontend/app_flowy/packages/appflowy_popover/.metadata deleted file mode 100644 index e7011f64f39df..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# 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: f1875d570e39de09040c8f79aa13cc56baab8db1 - channel: stable - -project_type: package diff --git a/frontend/app_flowy/packages/appflowy_popover/example/.gitignore b/frontend/app_flowy/packages/appflowy_popover/example/.gitignore deleted file mode 100644 index a8e938c083971..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/.gitignore +++ /dev/null @@ -1,47 +0,0 @@ -# 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 -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -.pub-cache/ -.pub/ -/build/ - -# Web related -lib/generated_plugin_registrant.dart - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release diff --git a/frontend/app_flowy/packages/appflowy_popover/example/README.md b/frontend/app_flowy/packages/appflowy_popover/example/README.md deleted file mode 100644 index 2b3fce4c86a59..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# example - -A new Flutter project. - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. diff --git a/frontend/app_flowy/packages/appflowy_popover/example/analysis_options.yaml b/frontend/app_flowy/packages/appflowy_popover/example/analysis_options.yaml deleted file mode 100644 index 61b6c4de17c96..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/analysis_options.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/.gitignore b/frontend/app_flowy/packages/appflowy_popover/example/android/.gitignore deleted file mode 100644 index 6f568019d3c69..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/android/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java - -# Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app -key.properties -**/*.keystore -**/*.jks diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/build.gradle b/frontend/app_flowy/packages/appflowy_popover/example/android/app/build.gradle deleted file mode 100644 index 0833ecfca80e1..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/android/app/build.gradle +++ /dev/null @@ -1,71 +0,0 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -android { - compileSdkVersion flutter.compileSdkVersion - ndkVersion flutter.ndkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.example.example" - // You can update the following values to match your application needs. - // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. - minSdkVersion flutter.minSdkVersion - targetSdkVersion flutter.targetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig signingConfigs.debug - } - } -} - -flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/debug/AndroidManifest.xml b/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 45d523a2a2a10..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/AndroidManifest.xml b/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 3f41384dbc283..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index db77bb4b7b090..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 17987b79bb8a3..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 09d4391482be6..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index d5f1c8d34e7a8..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 4d6372eebdb28..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/values-night/styles.xml b/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be745f9f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/values/styles.xml b/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef88056edd..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/profile/AndroidManifest.xml b/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 45d523a2a2a10..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/build.gradle b/frontend/app_flowy/packages/appflowy_popover/example/android/build.gradle deleted file mode 100644 index 83ae220041c7e..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/android/build.gradle +++ /dev/null @@ -1,31 +0,0 @@ -buildscript { - ext.kotlin_version = '1.6.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.1.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -task clean(type: Delete) { - delete rootProject.buildDir -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/gradle/wrapper/gradle-wrapper.properties b/frontend/app_flowy/packages/appflowy_popover/example/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index cc5527d781a7b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,6 +0,0 @@ -#Fri Jun 23 08:50:38 CEST 2017 -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/settings.gradle b/frontend/app_flowy/packages/appflowy_popover/example/android/settings.gradle deleted file mode 100644 index 44e62bcf06ae6..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/android/settings.gradle +++ /dev/null @@ -1,11 +0,0 @@ -include ':app' - -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() - -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } - -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/.gitignore b/frontend/app_flowy/packages/appflowy_popover/example/ios/.gitignore deleted file mode 100644 index 7a7f9873ad7dc..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/ios/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Flutter/AppFrameworkInfo.plist b/frontend/app_flowy/packages/appflowy_popover/example/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 8d4492f977adc..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - MinimumOSVersion - 9.0 - - diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Flutter/Debug.xcconfig b/frontend/app_flowy/packages/appflowy_popover/example/ios/Flutter/Debug.xcconfig deleted file mode 100644 index 592ceee85b89b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Flutter/Release.xcconfig b/frontend/app_flowy/packages/appflowy_popover/example/ios/Flutter/Release.xcconfig deleted file mode 100644 index 592ceee85b89b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.pbxproj b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 6edd238e7c681..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,481 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 50; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 1300; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.example; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.example; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.example; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6254f0..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index c87d15a335208..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fab2d9de..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada4725e9b..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 28c6bf03016f6..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 2ccbfd967d969..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index f091b6b0bca85..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cde12118dda4..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index d0ef06e7edb86..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index dcdc2306c2850..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 2ccbfd967d969..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index c8f9ed8f5cee1..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index a6d6b8609df07..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index a6d6b8609df07..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index 75b2d164a5a98..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index c4df70d39da79..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 6a84f41e14e27..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index d0e1f58536026..0000000000000 Binary files a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Info.plist b/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Info.plist deleted file mode 100644 index 907f329fe0923..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Info.plist +++ /dev/null @@ -1,49 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Example - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - example - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - CADisableMinimumFrameDurationOnPhone - - - diff --git a/frontend/app_flowy/packages/appflowy_popover/example/lib/example_button.dart b/frontend/app_flowy/packages/appflowy_popover/example/lib/example_button.dart deleted file mode 100644 index 74f1645bd09d4..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/lib/example_button.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; - -class PopoverMenu extends StatefulWidget { - const PopoverMenu({Key? key}) : super(key: key); - - @override - State createState() => _PopoverMenuState(); -} - -class _PopoverMenuState extends State { - final PopoverMutex popOverMutex = PopoverMutex(); - - @override - Widget build(BuildContext context) { - return Material( - type: MaterialType.transparency, - child: Container( - width: 200, - height: 200, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.all(Radius.circular(8)), - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.5), - spreadRadius: 5, - blurRadius: 7, - offset: const Offset(0, 3), // changes position of shadow - ), - ], - ), - child: ListView(children: [ - Container( - margin: const EdgeInsets.all(8), - child: const Text("Popover", - style: TextStyle( - fontSize: 14, - color: Colors.black, - fontStyle: null, - decoration: null)), - ), - Popover( - triggerActions: - PopoverTriggerFlags.hover | PopoverTriggerFlags.click, - mutex: popOverMutex, - offset: const Offset(10, 0), - popupBuilder: (BuildContext context) { - return const PopoverMenu(); - }, - child: TextButton( - onPressed: () {}, - child: const Text("First"), - ), - ), - Popover( - triggerActions: - PopoverTriggerFlags.hover | PopoverTriggerFlags.click, - mutex: popOverMutex, - offset: const Offset(10, 0), - popupBuilder: (BuildContext context) { - return const PopoverMenu(); - }, - child: TextButton( - onPressed: () {}, - child: const Text("Second"), - ), - ), - ]), - )); - } -} - -class ExampleButton extends StatelessWidget { - final String label; - final Offset? offset; - final PopoverDirection? direction; - - const ExampleButton({ - Key? key, - required this.label, - this.direction, - this.offset = Offset.zero, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Popover( - triggerActions: PopoverTriggerFlags.click, - offset: offset, - direction: direction ?? PopoverDirection.rightWithTopAligned, - child: TextButton(child: Text(label), onPressed: () {}), - popupBuilder: (BuildContext context) { - return const PopoverMenu(); - }, - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/lib/main.dart b/frontend/app_flowy/packages/appflowy_popover/example/lib/main.dart deleted file mode 100644 index a6b09bb4d3dce..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/lib/main.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flutter/material.dart'; -import "./example_button.dart"; - -void main() { - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({Key? key}) : super(key: key); - - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // Try running your application with "flutter run". You'll see the - // application has a blue toolbar. Then, without quitting the app, try - // changing the primarySwatch below to Colors.green and then invoke - // "hot reload" (press "r" in the console where you ran "flutter run", - // or simply save your changes to "hot reload" in a Flutter IDE). - // Notice that the counter didn't reset back to zero; the application - // is not restarted. - primarySwatch: Colors.blue, - ), - home: const MyHomePage(title: 'AppFlowy Popover Example'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({Key? key, required this.title}) : super(key: key); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Row(children: [ - Column(children: [ - const ExampleButton( - label: "Left top", - offset: Offset(0, 10), - direction: PopoverDirection.bottomWithLeftAligned, - ), - Expanded(child: Container()), - const ExampleButton( - label: "Left bottom", - offset: Offset(0, -10), - direction: PopoverDirection.topWithLeftAligned, - ), - ]), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const ExampleButton( - label: "Top", - offset: Offset(0, 10), - direction: PopoverDirection.bottomWithCenterAligned, - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: const [ - ExampleButton( - label: "Central", - offset: Offset(0, 10), - direction: PopoverDirection.bottomWithCenterAligned, - ), - ], - ), - ), - const ExampleButton( - label: "Bottom", - offset: Offset(0, -10), - direction: PopoverDirection.topWithCenterAligned, - ), - ], - ), - ), - Column( - children: [ - const ExampleButton( - label: "Right top", - offset: Offset(0, 10), - direction: PopoverDirection.bottomWithRightAligned, - ), - Expanded(child: Container()), - const ExampleButton( - label: "Right bottom", - offset: Offset(0, -10), - direction: PopoverDirection.topWithRightAligned, - ), - ], - ) - ]), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/linux/.gitignore b/frontend/app_flowy/packages/appflowy_popover/example/linux/.gitignore deleted file mode 100644 index d3896c98444fb..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/linux/.gitignore +++ /dev/null @@ -1 +0,0 @@ -flutter/ephemeral diff --git a/frontend/app_flowy/packages/appflowy_popover/example/linux/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_popover/example/linux/CMakeLists.txt deleted file mode 100644 index 74c66dd4463d3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/linux/CMakeLists.txt +++ /dev/null @@ -1,138 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.10) -project(runner LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "example") -# The unique GTK application identifier for this application. See: -# https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.example.example") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(SET CMP0063 NEW) - -# Load bundled libraries from the lib/ directory relative to the binary. -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Root filesystem for cross-building. -if(FLUTTER_TARGET_PLATFORM_SYSROOT) - set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) - set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) -endif() - -# Define build configuration options. -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") -endif() - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) - target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) - -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -# Define the application target. To change its name, change BINARY_NAME above, -# not the value here, or `flutter run` will no longer work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add dependency libraries. Add any application-specific dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) - -# Only the install-generated bundle's copy of the executable will launch -# correctly, since the resources must in the right relative locations. To avoid -# people trying to run the unbundled copy, put it in a subdirectory instead of -# the default top-level location. -set_target_properties(${BINARY_NAME} - PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" -) - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# By default, "installing" just makes a relocatable bundle in the build -# directory. -set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -# Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) - install(FILES "${bundled_library}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endforeach(bundled_library) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") - install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() diff --git a/frontend/app_flowy/packages/appflowy_popover/example/linux/flutter/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_popover/example/linux/flutter/CMakeLists.txt deleted file mode 100644 index d5bd01648a96d..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/linux/flutter/CMakeLists.txt +++ /dev/null @@ -1,88 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.10) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. - -# Serves the same purpose as list(TRANSFORM ... PREPEND ...), -# which isn't available in 3.10. -function(list_prepend LIST_NAME PREFIX) - set(NEW_LIST "") - foreach(element ${${LIST_NAME}}) - list(APPEND NEW_LIST "${PREFIX}${element}") - endforeach(element) - set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) -endfunction() - -# === Flutter Library === -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) -pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) -pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) - -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "fl_basic_message_channel.h" - "fl_binary_codec.h" - "fl_binary_messenger.h" - "fl_dart_project.h" - "fl_engine.h" - "fl_json_message_codec.h" - "fl_json_method_codec.h" - "fl_message_codec.h" - "fl_method_call.h" - "fl_method_channel.h" - "fl_method_codec.h" - "fl_method_response.h" - "fl_plugin_registrar.h" - "fl_plugin_registry.h" - "fl_standard_message_codec.h" - "fl_standard_method_codec.h" - "fl_string_codec.h" - "fl_value.h" - "fl_view.h" - "flutter_linux.h" -) -list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") -target_link_libraries(flutter INTERFACE - PkgConfig::GTK - PkgConfig::GLIB - PkgConfig::GIO -) -add_dependencies(flutter flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CMAKE_CURRENT_BINARY_DIR}/_phony_ - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" - ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} -) diff --git a/frontend/app_flowy/packages/appflowy_popover/example/linux/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/packages/appflowy_popover/example/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index e71a16d23d058..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,11 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - - -void fl_register_plugins(FlPluginRegistry* registry) { -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/linux/flutter/generated_plugin_registrant.h b/frontend/app_flowy/packages/appflowy_popover/example/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47bc08f3..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/linux/flutter/generated_plugins.cmake b/frontend/app_flowy/packages/appflowy_popover/example/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 2e1de87a7eb61..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,23 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/frontend/app_flowy/packages/appflowy_popover/example/linux/main.cc b/frontend/app_flowy/packages/appflowy_popover/example/linux/main.cc deleted file mode 100644 index e7c5c54370372..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/linux/main.cc +++ /dev/null @@ -1,6 +0,0 @@ -#include "my_application.h" - -int main(int argc, char** argv) { - g_autoptr(MyApplication) app = my_application_new(); - return g_application_run(G_APPLICATION(app), argc, argv); -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/linux/my_application.cc b/frontend/app_flowy/packages/appflowy_popover/example/linux/my_application.cc deleted file mode 100644 index 0ba8f43096dba..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/linux/my_application.cc +++ /dev/null @@ -1,104 +0,0 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -// Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "example"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "example"); - } - - gtk_window_set_default_size(window, 1280, 720); - gtk_widget_show(GTK_WIDGET(window)); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - gtk_widget_grab_focus(GTK_WIDGET(view)); -} - -// Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -// Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) {} - -MyApplication* my_application_new() { - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, - "flags", G_APPLICATION_NON_UNIQUE, - nullptr)); -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/linux/my_application.h b/frontend/app_flowy/packages/appflowy_popover/example/linux/my_application.h deleted file mode 100644 index 72271d5e41701..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/linux/my_application.h +++ /dev/null @@ -1,18 +0,0 @@ -#ifndef FLUTTER_MY_APPLICATION_H_ -#define FLUTTER_MY_APPLICATION_H_ - -#include - -G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, - GtkApplication) - -/** - * my_application_new: - * - * Creates a new Flutter-based application. - * - * Returns: a new #MyApplication. - */ -MyApplication* my_application_new(); - -#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/.gitignore b/frontend/app_flowy/packages/appflowy_popover/example/macos/.gitignore deleted file mode 100644 index 746adbb6b9e14..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/macos/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Flutter-related -**/Flutter/ephemeral/ -**/Pods/ - -# Xcode-related -**/dgph -**/xcuserdata/ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Flutter/Flutter-Debug.xcconfig b/frontend/app_flowy/packages/appflowy_popover/example/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index c2efd0b608ba8..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Flutter/Flutter-Release.xcconfig b/frontend/app_flowy/packages/appflowy_popover/example/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index c2efd0b608ba8..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/app_flowy/packages/appflowy_popover/example/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index cccf817a52206..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index c84862c675761..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,572 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 51; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* example.app */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 33CC10EC2044A3C60003C045 /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - 3399D490228B24CF009A79C7 /* ShellScript */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* example.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - isa = PBXProject; - attributes = { - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index fb7259e17785a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a16ed0f1..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/AppDelegate.swift b/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/AppDelegate.swift deleted file mode 100644 index d53ef6437726b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/AppDelegate.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Cocoa -import FlutterMacOS - -@NSApplicationMain -class AppDelegate: FlutterAppDelegate { - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Base.lproj/MainMenu.xib b/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Base.lproj/MainMenu.xib deleted file mode 100644 index 80e867a4e06b4..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Configs/AppInfo.xcconfig b/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Configs/AppInfo.xcconfig deleted file mode 100644 index 8b42559e8758e..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Configs/AppInfo.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. - -// The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = example - -// The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.example.example - -// The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2022 com.example. All rights reserved. diff --git a/frontend/app_flowy/packages/appflowy_popover/example/pubspec.yaml b/frontend/app_flowy/packages/appflowy_popover/example/pubspec.yaml deleted file mode 100644 index 8a685fe20269a..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/pubspec.yaml +++ /dev/null @@ -1,89 +0,0 @@ -name: example -description: A new Flutter project. - -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: "none" # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.0+1 - -environment: - sdk: ">=2.17.0 <3.0.0" - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 - appflowy_popover: - path: ../ - -dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^2.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/packages/appflowy_popover/example/web/index.html b/frontend/app_flowy/packages/appflowy_popover/example/web/index.html deleted file mode 100644 index 41b3bc336f404..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/web/index.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - example - - - - - - - - - - diff --git a/frontend/app_flowy/packages/appflowy_popover/example/web/manifest.json b/frontend/app_flowy/packages/appflowy_popover/example/web/manifest.json deleted file mode 100644 index 096edf8fe4cd7..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "example", - "short_name": "example", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_popover/example/windows/CMakeLists.txt deleted file mode 100644 index c0270746b1b9b..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/windows/CMakeLists.txt +++ /dev/null @@ -1,101 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.14) -project(example LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "example") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(SET CMP0063 NEW) - -# Define build configuration option. -get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) -if(IS_MULTICONFIG) - set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" - CACHE STRING "" FORCE) -else() - if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") - endif() -endif() -# Define settings for the Profile build mode. -set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") -set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") -set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") -set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") - -# Use Unicode for all projects. -add_definitions(-DUNICODE -D_UNICODE) - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") - target_compile_options(${TARGET} PRIVATE /EHsc) - target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") - target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# Support files are copied into place next to the executable, so that it can -# run in place. This is done instead of making a separate bundle (as on Linux) -# so that building and running from within Visual Studio will work. -set(BUILD_BUNDLE_DIR "$") -# Make the "install" step default, as it's required to run. -set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -if(PLUGIN_BUNDLED_LIBRARIES) - install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - CONFIGURATIONS Profile;Release - COMPONENT Runtime) diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/flutter/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_popover/example/windows/flutter/CMakeLists.txt deleted file mode 100644 index 930d2071a324e..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/windows/flutter/CMakeLists.txt +++ /dev/null @@ -1,104 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.14) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. -set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") - -# === Flutter Library === -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "flutter_export.h" - "flutter_windows.h" - "flutter_messenger.h" - "flutter_plugin_registrar.h" - "flutter_texture_registrar.h" -) -list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") -add_dependencies(flutter flutter_assemble) - -# === Wrapper === -list(APPEND CPP_WRAPPER_SOURCES_CORE - "core_implementations.cc" - "standard_codec.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_PLUGIN - "plugin_registrar.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_APP - "flutter_engine.cc" - "flutter_view_controller.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") - -# Wrapper sources needed for a plugin. -add_library(flutter_wrapper_plugin STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} -) -apply_standard_settings(flutter_wrapper_plugin) -set_target_properties(flutter_wrapper_plugin PROPERTIES - POSITION_INDEPENDENT_CODE ON) -set_target_properties(flutter_wrapper_plugin PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) -target_include_directories(flutter_wrapper_plugin PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_plugin flutter_assemble) - -# Wrapper sources needed for the runner. -add_library(flutter_wrapper_app STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_APP} -) -apply_standard_settings(flutter_wrapper_app) -target_link_libraries(flutter_wrapper_app PUBLIC flutter) -target_include_directories(flutter_wrapper_app PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_app flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") -set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} - ${PHONY_OUTPUT} - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} -) diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/packages/appflowy_popover/example/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 8b6d4680af388..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,11 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - - -void RegisterPlugins(flutter::PluginRegistry* registry) { -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/flutter/generated_plugin_registrant.h b/frontend/app_flowy/packages/appflowy_popover/example/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d85a9310..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/flutter/generated_plugins.cmake b/frontend/app_flowy/packages/appflowy_popover/example/windows/flutter/generated_plugins.cmake deleted file mode 100644 index b93c4c30c1670..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,23 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/CMakeLists.txt deleted file mode 100644 index b9e550fba8e17..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/CMakeLists.txt +++ /dev/null @@ -1,32 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} WIN32 - "flutter_window.cpp" - "main.cpp" - "utils.cpp" - "win32_window.cpp" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" - "Runner.rc" - "runner.exe.manifest" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Disable Windows macros that collide with C++ standard library functions. -target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") - -# Add dependency libraries and include directories. Add any application-specific -# dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/Runner.rc b/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/Runner.rc deleted file mode 100644 index 5fdea291cf193..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/Runner.rc +++ /dev/null @@ -1,121 +0,0 @@ -// Microsoft Visual C++ generated resource script. -// -#pragma code_page(65001) -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_APP_ICON ICON "resources\\app_icon.ico" - - -///////////////////////////////////////////////////////////////////////////// -// -// Version -// - -#ifdef FLUTTER_BUILD_NUMBER -#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER -#else -#define VERSION_AS_NUMBER 1,0,0 -#endif - -#ifdef FLUTTER_BUILD_NAME -#define VERSION_AS_STRING #FLUTTER_BUILD_NAME -#else -#define VERSION_AS_STRING "1.0.0" -#endif - -VS_VERSION_INFO VERSIONINFO - FILEVERSION VERSION_AS_NUMBER - PRODUCTVERSION VERSION_AS_NUMBER - FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG - FILEFLAGS VS_FF_DEBUG -#else - FILEFLAGS 0x0L -#endif - FILEOS VOS__WINDOWS32 - FILETYPE VFT_APP - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904e4" - BEGIN - VALUE "CompanyName", "com.example" "\0" - VALUE "FileDescription", "example" "\0" - VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "example" "\0" - VALUE "LegalCopyright", "Copyright (C) 2022 com.example. All rights reserved." "\0" - VALUE "OriginalFilename", "example.exe" "\0" - VALUE "ProductName", "example" "\0" - VALUE "ProductVersion", VERSION_AS_STRING "\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/main.cpp b/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/main.cpp deleted file mode 100644 index bcb57b0e2aacb..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/main.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include -#include -#include - -#include "flutter_window.h" -#include "utils.h" - -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { - // Attach to console when present (e.g., 'flutter run') or create a - // new console when running with a debugger. - if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { - CreateAndAttachConsole(); - } - - // Initialize COM, so that it is available for use in the library and/or - // plugins. - ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - - flutter::DartProject project(L"data"); - - std::vector command_line_arguments = - GetCommandLineArguments(); - - project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - - FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); - if (!window.CreateAndShow(L"example", origin, size)) { - return EXIT_FAILURE; - } - window.SetQuitOnClose(true); - - ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { - ::TranslateMessage(&msg); - ::DispatchMessage(&msg); - } - - ::CoUninitialize(); - return EXIT_SUCCESS; -} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/utils.cpp b/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/utils.cpp deleted file mode 100644 index f5bf9fa0f536c..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/utils.cpp +++ /dev/null @@ -1,64 +0,0 @@ -#include "utils.h" - -#include -#include -#include -#include - -#include - -void CreateAndAttachConsole() { - if (::AllocConsole()) { - FILE *unused; - if (freopen_s(&unused, "CONOUT$", "w", stdout)) { - _dup2(_fileno(stdout), 1); - } - if (freopen_s(&unused, "CONOUT$", "w", stderr)) { - _dup2(_fileno(stdout), 2); - } - std::ios::sync_with_stdio(); - FlutterDesktopResyncOutputStreams(); - } -} - -std::vector GetCommandLineArguments() { - // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. - int argc; - wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); - if (argv == nullptr) { - return std::vector(); - } - - std::vector command_line_arguments; - - // Skip the first argument as it's the binary name. - for (int i = 1; i < argc; i++) { - command_line_arguments.push_back(Utf8FromUtf16(argv[i])); - } - - ::LocalFree(argv); - - return command_line_arguments; -} - -std::string Utf8FromUtf16(const wchar_t* utf16_string) { - if (utf16_string == nullptr) { - return std::string(); - } - int target_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, nullptr, 0, nullptr, nullptr); - std::string utf8_string; - if (target_length == 0 || target_length > utf8_string.max_size()) { - return utf8_string; - } - utf8_string.resize(target_length); - int converted_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, utf8_string.data(), - target_length, nullptr, nullptr); - if (converted_length == 0) { - return std::string(); - } - return utf8_string; -} diff --git a/frontend/app_flowy/packages/appflowy_popover/lib/src/layout.dart b/frontend/app_flowy/packages/appflowy_popover/lib/src/layout.dart deleted file mode 100644 index f9763db15b372..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/lib/src/layout.dart +++ /dev/null @@ -1,353 +0,0 @@ -import 'dart:math' as math; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import './popover.dart'; - -class PopoverLayoutDelegate extends SingleChildLayoutDelegate { - PopoverLink link; - PopoverDirection direction; - final Offset offset; - - PopoverLayoutDelegate({ - required this.link, - required this.direction, - required this.offset, - }); - - @override - bool shouldRelayout(PopoverLayoutDelegate oldDelegate) { - if (direction != oldDelegate.direction) { - return true; - } - - if (link != oldDelegate.link) { - return true; - } - - if (link.leaderOffset != oldDelegate.link.leaderOffset) { - return true; - } - - if (link.leaderSize != oldDelegate.link.leaderSize) { - return true; - } - - return false; - } - - @override - BoxConstraints getConstraintsForChild(BoxConstraints constraints) { - return constraints.loosen(); - // assert(link.leaderSize != null); - // // if (link.leaderSize == null) { - // // return constraints.loosen(); - // // } - // final anchorRect = Rect.fromLTWH( - // link.leaderOffset!.dx, - // link.leaderOffset!.dy, - // link.leaderSize!.width, - // link.leaderSize!.height, - // ); - // BoxConstraints childConstraints; - // switch (direction) { - // case PopoverDirection.topLeft: - // childConstraints = BoxConstraints.loose(Size( - // anchorRect.left, - // anchorRect.top, - // )); - // break; - // case PopoverDirection.topRight: - // childConstraints = BoxConstraints.loose(Size( - // constraints.maxWidth - anchorRect.right, - // anchorRect.top, - // )); - // break; - // case PopoverDirection.bottomLeft: - // childConstraints = BoxConstraints.loose(Size( - // anchorRect.left, - // constraints.maxHeight - anchorRect.bottom, - // )); - // break; - // case PopoverDirection.bottomRight: - // childConstraints = BoxConstraints.loose(Size( - // constraints.maxWidth - anchorRect.right, - // constraints.maxHeight - anchorRect.bottom, - // )); - // break; - // case PopoverDirection.center: - // childConstraints = BoxConstraints.loose(Size( - // constraints.maxWidth, - // constraints.maxHeight, - // )); - // break; - // case PopoverDirection.topWithLeftAligned: - // childConstraints = BoxConstraints.loose(Size( - // constraints.maxWidth - anchorRect.left, - // anchorRect.top, - // )); - // break; - // case PopoverDirection.topWithCenterAligned: - // childConstraints = BoxConstraints.loose(Size( - // constraints.maxWidth, - // anchorRect.top, - // )); - // break; - // case PopoverDirection.topWithRightAligned: - // childConstraints = BoxConstraints.loose(Size( - // anchorRect.right, - // anchorRect.top, - // )); - // break; - // case PopoverDirection.rightWithTopAligned: - // childConstraints = BoxConstraints.loose(Size( - // constraints.maxWidth - anchorRect.right, - // constraints.maxHeight - anchorRect.top, - // )); - // break; - // case PopoverDirection.rightWithCenterAligned: - // childConstraints = BoxConstraints.loose(Size( - // constraints.maxWidth - anchorRect.right, - // constraints.maxHeight, - // )); - // break; - // case PopoverDirection.rightWithBottomAligned: - // childConstraints = BoxConstraints.loose(Size( - // constraints.maxWidth - anchorRect.right, - // anchorRect.bottom, - // )); - // break; - // case PopoverDirection.bottomWithLeftAligned: - // childConstraints = BoxConstraints.loose(Size( - // anchorRect.left, - // constraints.maxHeight - anchorRect.bottom, - // )); - // break; - // case PopoverDirection.bottomWithCenterAligned: - // childConstraints = BoxConstraints.loose(Size( - // constraints.maxWidth, - // constraints.maxHeight - anchorRect.bottom, - // )); - // break; - // case PopoverDirection.bottomWithRightAligned: - // childConstraints = BoxConstraints.loose(Size( - // anchorRect.right, - // constraints.maxHeight - anchorRect.bottom, - // )); - // break; - // case PopoverDirection.leftWithTopAligned: - // childConstraints = BoxConstraints.loose(Size( - // anchorRect.left, - // constraints.maxHeight - anchorRect.top, - // )); - // break; - // case PopoverDirection.leftWithCenterAligned: - // childConstraints = BoxConstraints.loose(Size( - // anchorRect.left, - // constraints.maxHeight, - // )); - // break; - // case PopoverDirection.leftWithBottomAligned: - // childConstraints = BoxConstraints.loose(Size( - // anchorRect.left, - // anchorRect.bottom, - // )); - // break; - // case PopoverDirection.custom: - // childConstraints = constraints.loosen(); - // break; - // default: - // throw UnimplementedError(); - // } - // return childConstraints; - } - - @override - Offset getPositionForChild(Size size, Size childSize) { - if (link.leaderSize == null) { - return Offset.zero; - } - final anchorRect = Rect.fromLTWH( - link.leaderOffset!.dx + offset.dx, - link.leaderOffset!.dy + offset.dy, - link.leaderSize!.width, - link.leaderSize!.height, - ); - Offset position; - switch (direction) { - case PopoverDirection.topLeft: - position = Offset( - anchorRect.left - childSize.width, - anchorRect.top - childSize.height, - ); - break; - case PopoverDirection.topRight: - position = Offset( - anchorRect.right, - anchorRect.top - childSize.height, - ); - break; - case PopoverDirection.bottomLeft: - position = Offset( - anchorRect.left - childSize.width, - anchorRect.bottom, - ); - break; - case PopoverDirection.bottomRight: - position = Offset( - anchorRect.right, - anchorRect.bottom, - ); - break; - case PopoverDirection.center: - position = anchorRect.center; - break; - case PopoverDirection.topWithLeftAligned: - position = Offset( - anchorRect.left, - anchorRect.top - childSize.height, - ); - break; - case PopoverDirection.topWithCenterAligned: - position = Offset( - anchorRect.left + anchorRect.width / 2.0 - childSize.width / 2.0, - anchorRect.top - childSize.height, - ); - break; - case PopoverDirection.topWithRightAligned: - position = Offset( - anchorRect.right - childSize.width, - anchorRect.top - childSize.height, - ); - break; - case PopoverDirection.rightWithTopAligned: - position = Offset(anchorRect.right, anchorRect.top); - break; - case PopoverDirection.rightWithCenterAligned: - position = Offset( - anchorRect.right, - anchorRect.top + anchorRect.height / 2.0 - childSize.height / 2.0, - ); - break; - case PopoverDirection.rightWithBottomAligned: - position = Offset( - anchorRect.right, - anchorRect.bottom - childSize.height, - ); - break; - case PopoverDirection.bottomWithLeftAligned: - position = Offset( - anchorRect.left, - anchorRect.bottom, - ); - break; - case PopoverDirection.bottomWithCenterAligned: - position = Offset( - anchorRect.left + anchorRect.width / 2.0 - childSize.width / 2.0, - anchorRect.bottom, - ); - break; - case PopoverDirection.bottomWithRightAligned: - position = Offset( - anchorRect.right - childSize.width, - anchorRect.bottom, - ); - break; - case PopoverDirection.leftWithTopAligned: - position = Offset( - anchorRect.left - childSize.width, - anchorRect.top, - ); - break; - case PopoverDirection.leftWithCenterAligned: - position = Offset( - anchorRect.left - childSize.width, - anchorRect.top + anchorRect.height / 2.0 - childSize.height / 2.0, - ); - break; - case PopoverDirection.leftWithBottomAligned: - position = Offset( - anchorRect.left - childSize.width, - anchorRect.bottom - childSize.height, - ); - break; - default: - throw UnimplementedError(); - } - return Offset( - math.max(0.0, math.min(size.width - childSize.width, position.dx)), - math.max(0.0, math.min(size.height - childSize.height, position.dy)), - ); - } -} - -class PopoverTarget extends SingleChildRenderObjectWidget { - final PopoverLink link; - const PopoverTarget({ - super.key, - super.child, - required this.link, - }); - - @override - PopoverTargetRenderBox createRenderObject(BuildContext context) { - return PopoverTargetRenderBox( - link: link, - ); - } - - @override - void updateRenderObject( - BuildContext context, PopoverTargetRenderBox renderObject) { - renderObject.link = link; - } -} - -class PopoverTargetRenderBox extends RenderProxyBox { - PopoverLink link; - PopoverTargetRenderBox({required this.link, RenderBox? child}) : super(child); - - @override - bool get alwaysNeedsCompositing => true; - - @override - void performLayout() { - super.performLayout(); - link.leaderSize = size; - } - - @override - void paint(PaintingContext context, Offset offset) { - link.leaderOffset = localToGlobal(Offset.zero); - super.paint(context, offset); - } - - @override - void detach() { - super.detach(); - link.leaderOffset = null; - link.leaderSize = null; - } - - @override - void attach(covariant PipelineOwner owner) { - super.attach(owner); - if (hasSize) { - // The leaderSize was set after [performLayout], but was - // set to null when [detach] get called. - // - // set the leaderSize when attach get called - link.leaderSize = size; - } - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('link', link)); - } -} - -class PopoverLink { - Offset? leaderOffset; - Size? leaderSize; -} diff --git a/frontend/app_flowy/packages/appflowy_popover/lib/src/mask.dart b/frontend/app_flowy/packages/appflowy_popover/lib/src/mask.dart deleted file mode 100644 index 815321774f379..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/lib/src/mask.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'dart:collection'; - -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -typedef EntryMap = LinkedHashMap; - -class RootOverlayEntry { - final EntryMap _entries = EntryMap(); - RootOverlayEntry(); - - void addEntry( - BuildContext context, - PopoverState newState, - OverlayEntry entry, - bool asBarrier, - ) { - _entries[newState] = OverlayEntryContext(entry, newState, asBarrier); - Overlay.of(context)?.insert(entry); - } - - bool contains(PopoverState oldState) { - return _entries.containsKey(oldState); - } - - void removeEntry(PopoverState oldState) { - if (_entries.isEmpty) return; - - final removedEntry = _entries.remove(oldState); - removedEntry?.overlayEntry.remove(); - } - - bool get isEmpty => _entries.isEmpty; - - bool get isNotEmpty => _entries.isNotEmpty; - - bool hasEntry() { - return _entries.isNotEmpty; - } - - PopoverState? popEntry() { - if (_entries.isEmpty) return null; - - final lastEntry = _entries.values.last; - _entries.remove(lastEntry.popoverState); - lastEntry.overlayEntry.remove(); - lastEntry.popoverState.widget.onClose?.call(); - - if (lastEntry.asBarrier) { - return lastEntry.popoverState; - } else { - return popEntry(); - } - } -} - -class OverlayEntryContext { - final bool asBarrier; - final PopoverState popoverState; - final OverlayEntry overlayEntry; - - OverlayEntryContext( - this.overlayEntry, - this.popoverState, - this.asBarrier, - ); -} - -class PopoverMask extends StatefulWidget { - final void Function() onTap; - final void Function()? onExit; - final Decoration? decoration; - - const PopoverMask( - {Key? key, required this.onTap, this.onExit, this.decoration}) - : super(key: key); - - @override - State createState() => _PopoverMaskState(); -} - -class _PopoverMaskState extends State { - @override - void initState() { - HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent); - super.initState(); - } - - bool _handleGlobalKeyEvent(KeyEvent event) { - if (event.logicalKey == LogicalKeyboardKey.escape) { - if (widget.onExit != null) { - widget.onExit!(); - } - return true; - } else { - return false; - } - } - - @override - void deactivate() { - HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); - super.deactivate(); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: widget.onTap, - child: Container( - decoration: widget.decoration, - ), - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_popover/lib/src/popover.dart b/frontend/app_flowy/packages/appflowy_popover/lib/src/popover.dart deleted file mode 100644 index a08787df00c5c..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/lib/src/popover.dart +++ /dev/null @@ -1,240 +0,0 @@ -import 'dart:async'; -import 'package:appflowy_popover/src/layout.dart'; -import 'package:flutter/material.dart'; -import 'mask.dart'; -import 'mutex.dart'; - -class PopoverController { - PopoverState? _state; - - close() { - _state?.close(); - } - - show() { - _state?.showOverlay(); - } -} - -class PopoverTriggerFlags { - static const int none = 0x00; - static const int click = 0x01; - static const int hover = 0x02; -} - -enum PopoverDirection { - // Corner aligned with a corner of the SourceWidget - topLeft, - topRight, - bottomLeft, - bottomRight, - center, - - // Edge aligned with a edge of the SourceWidget - topWithLeftAligned, - topWithCenterAligned, - topWithRightAligned, - rightWithTopAligned, - rightWithCenterAligned, - rightWithBottomAligned, - bottomWithLeftAligned, - bottomWithCenterAligned, - bottomWithRightAligned, - leftWithTopAligned, - leftWithCenterAligned, - leftWithBottomAligned, - - custom, -} - -class Popover extends StatefulWidget { - final PopoverController? controller; - - final Offset? offset; - - final Decoration? maskDecoration; - - /// The function used to build the popover. - final Widget? Function(BuildContext context) popupBuilder; - - final int triggerActions; - - /// If multiple popovers are exclusive, - /// pass the same mutex to them. - final PopoverMutex? mutex; - - /// The direction of the popover - final PopoverDirection direction; - - final void Function()? onClose; - - final bool asBarrier; - - /// The content area of the popover. - final Widget child; - - const Popover({ - Key? key, - required this.child, - required this.popupBuilder, - this.controller, - this.offset, - this.maskDecoration = const BoxDecoration( - color: Color.fromARGB(0, 244, 67, 54), - ), - this.triggerActions = 0, - this.direction = PopoverDirection.rightWithTopAligned, - this.mutex, - this.onClose, - this.asBarrier = false, - }) : super(key: key); - - @override - State createState() => PopoverState(); -} - -class PopoverState extends State { - static final RootOverlayEntry _rootEntry = RootOverlayEntry(); - final PopoverLink popoverLink = PopoverLink(); - - @override - void initState() { - widget.controller?._state = this; - super.initState(); - } - - void showOverlay() { - close(); - - if (widget.mutex != null) { - widget.mutex?.state = this; - } - final shouldAddMask = _rootEntry.isEmpty; - final newEntry = OverlayEntry(builder: (context) { - final children = []; - if (shouldAddMask) { - children.add( - PopoverMask( - decoration: widget.maskDecoration, - onTap: () => _removeRootOverlay(), - onExit: () => _removeRootOverlay(), - ), - ); - } - - children.add( - PopoverContainer( - direction: widget.direction, - popoverLink: popoverLink, - offset: widget.offset ?? Offset.zero, - popupBuilder: widget.popupBuilder, - onClose: () => close(), - onCloseAll: () => _removeRootOverlay(), - ), - ); - - return Stack(children: children); - }); - _rootEntry.addEntry(context, this, newEntry, widget.asBarrier); - } - - void close() { - if (_rootEntry.contains(this)) { - _rootEntry.removeEntry(this); - widget.onClose?.call(); - } - } - - void _removeRootOverlay() { - _rootEntry.popEntry(); - - if (widget.mutex?.state == this) { - widget.mutex?.removeState(); - } - } - - @override - void deactivate() { - close(); - super.deactivate(); - } - - @override - Widget build(BuildContext context) { - return PopoverTarget( - link: popoverLink, - child: _buildChild(context), - ); - } - - Widget _buildChild(BuildContext context) { - if (widget.triggerActions == 0) { - return widget.child; - } - - return MouseRegion( - onEnter: (event) { - if (widget.triggerActions & PopoverTriggerFlags.hover != 0) { - showOverlay(); - } - }, - child: Listener( - child: widget.child, - onPointerDown: (PointerDownEvent event) { - if (widget.triggerActions & PopoverTriggerFlags.click != 0) { - showOverlay(); - } - }, - ), - ); - } -} - -class PopoverContainer extends StatefulWidget { - final Widget? Function(BuildContext context) popupBuilder; - final PopoverDirection direction; - final PopoverLink popoverLink; - final Offset offset; - final void Function() onClose; - final void Function() onCloseAll; - - const PopoverContainer({ - Key? key, - required this.popupBuilder, - required this.direction, - required this.popoverLink, - required this.offset, - required this.onClose, - required this.onCloseAll, - }) : super(key: key); - - @override - State createState() => PopoverContainerState(); - - static PopoverContainerState of(BuildContext context) { - if (context is StatefulElement && context.state is PopoverContainerState) { - return context.state as PopoverContainerState; - } - final PopoverContainerState? result = - context.findAncestorStateOfType(); - return result!; - } -} - -class PopoverContainerState extends State { - @override - Widget build(BuildContext context) { - return CustomSingleChildLayout( - delegate: PopoverLayoutDelegate( - direction: widget.direction, - link: widget.popoverLink, - offset: widget.offset, - ), - child: widget.popupBuilder(context), - ); - } - - void close() => widget.onClose(); - - void closeAll() => widget.onCloseAll(); -} diff --git a/frontend/app_flowy/packages/appflowy_popover/pubspec.yaml b/frontend/app_flowy/packages/appflowy_popover/pubspec.yaml deleted file mode 100644 index 80e808f890b4f..0000000000000 --- a/frontend/app_flowy/packages/appflowy_popover/pubspec.yaml +++ /dev/null @@ -1,54 +0,0 @@ -name: appflowy_popover -description: A new Flutter package project. -version: 0.0.1 -homepage: - -environment: - sdk: ">=2.17.0 <3.0.0" - flutter: ">=1.17.0" - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/packages/flowy_infra/lib/image.dart b/frontend/app_flowy/packages/flowy_infra/lib/image.dart deleted file mode 100644 index e70c8b4bffe11..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra/lib/image.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -Widget svgWidget(String name, {Color? color}) { - final Widget svg = SvgPicture.asset('assets/images/$name.svg', color: color); - - return svg; -} - -Widget svgWithSize(String name, Size size) { - return SizedBox.fromSize( - size: size, - child: svgWidget(name), - ); -} diff --git a/frontend/app_flowy/packages/flowy_infra/lib/language.dart b/frontend/app_flowy/packages/flowy_infra/lib/language.dart deleted file mode 100644 index 3e18606815686..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra/lib/language.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; - -String languageFromLocale(Locale locale) { - switch (locale.languageCode) { - // Most often used languages - case "en": - return "English"; - case "zh": - return "简体中文"; - - // Then in alphabetical order - case "ca": - return "Català"; - case "de": - return "Deutsch"; - case "es": - return "Español"; - case "fr": - switch (locale.countryCode) { - case "CA": - return "Français (CA)"; - case "FR": - return "Français (FR)"; - default: - return locale.languageCode; - } - case "hu": - return "Magyar"; - case "id": - return "Bahasa"; - case "it": - return "Italiano"; - case "ja": - return "日本語"; - case "ko": - return "한국어"; - case "pl": - return "Polski"; - case "pt": - return "Português"; - case "ru": - return "русский"; - case "sv": - return "Svenska"; - case "tr": - return "Türkçe"; - - // If not found then the language code will be displayed - default: - return locale.languageCode; - } -} diff --git a/frontend/app_flowy/packages/flowy_infra/lib/text_style.dart b/frontend/app_flowy/packages/flowy_infra/lib/text_style.dart deleted file mode 100644 index aea4398f0b267..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra/lib/text_style.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flowy_infra/size.dart'; -import 'package:flutter/material.dart'; - -class Fonts { - static String general = "Poppins"; - - static String monospace = "SF Mono"; - - static String emoji = "Noto Color Emoji"; -} - -class TextStyles { - static TextStyle general({ - double? fontSize, - FontWeight fontWeight = FontWeight.w500, - Color? color, - }) => - TextStyle( - fontFamily: Fonts.general, - fontSize: fontSize ?? FontSizes.s12, - color: color, - fontWeight: fontWeight, - fontFamilyFallback: [Fonts.emoji], - letterSpacing: (fontSize ?? FontSizes.s12) * 0.005, - ); - - static TextStyle monospace({ - String? fontFamily, - double? fontSize, - FontWeight fontWeight = FontWeight.w400, - }) => - TextStyle( - fontFamily: fontFamily ?? Fonts.monospace, - fontSize: fontSize ?? FontSizes.s12, - fontWeight: fontWeight, - fontFamilyFallback: [Fonts.emoji], - ); - - static TextStyle get title => general( - fontSize: FontSizes.s18, - fontWeight: FontWeight.w600, - ); - - static TextStyle get subheading => general( - fontSize: FontSizes.s16, - fontWeight: FontWeight.w600, - ); - - static TextStyle get subtitle => general( - fontSize: FontSizes.s16, - fontWeight: FontWeight.w600, - ); - - static TextStyle get body1 => general( - fontSize: FontSizes.s12, - fontWeight: FontWeight.w500, - ); - - static TextStyle get body2 => general( - fontSize: FontSizes.s12, - fontWeight: FontWeight.w400, - ); - - static TextStyle get callout => general( - fontSize: FontSizes.s11, - fontWeight: FontWeight.w600, - ); - - static TextStyle get caption => general( - fontSize: FontSizes.s11, - fontWeight: FontWeight.w400, - ); -} diff --git a/frontend/app_flowy/packages/flowy_infra/lib/theme.dart b/frontend/app_flowy/packages/flowy_infra/lib/theme.dart deleted file mode 100644 index cba65aa7fc28e..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra/lib/theme.dart +++ /dev/null @@ -1,207 +0,0 @@ -import 'package:flutter/material.dart'; - -enum ThemeType { - light, - dark, -} - -ThemeType themeTypeFromString(String name) { - ThemeType themeType = ThemeType.light; - if (name == "dark") { - themeType = ThemeType.dark; - } - return themeType; -} - -String themeTypeToString(ThemeType ty) { - switch (ty) { - case ThemeType.light: - return "light"; - case ThemeType.dark: - return "dark"; - } -} - -//Color Pallettes -const _black = Color(0xff000000); -const _white = Color(0xFFFFFFFF); - -class AppTheme { - ThemeType ty; - bool isDark; - late Color surface; // - late Color hover; - late Color selector; - late Color red; - late Color yellow; - late Color green; - - late Color shader1; - late Color shader2; - late Color shader3; - late Color shader4; - late Color shader5; - late Color shader6; - late Color shader7; - - late Color bg1; - late Color bg2; - late Color bg3; - late Color bg4; - - late Color tint1; - late Color tint2; - late Color tint3; - late Color tint4; - late Color tint5; - late Color tint6; - late Color tint7; - late Color tint8; - late Color tint9; - late Color textColor; - late Color iconColor; - late Color disableIconColor; - - late Color main1; - late Color main2; - - late Color shadowColor; - - /// Default constructor - AppTheme({required this.ty, this.isDark = false}); - - factory AppTheme.fromName({required String name}) { - return AppTheme.fromType(themeTypeFromString(name)); - } - - /// fromType factory constructor - factory AppTheme.fromType(ThemeType themeType) { - switch (themeType) { - case ThemeType.light: - return AppTheme(ty: themeType, isDark: false) - ..surface = Colors.white - ..hover = const Color(0xFFe0f8ff) // - ..selector = const Color(0xfff2fcff) - ..red = const Color(0xfffb006d) - ..yellow = const Color(0xffffd667) - ..green = const Color(0xff66cf80) - ..shader1 = const Color(0xff333333) - ..shader2 = const Color(0xff4f4f4f) - ..shader3 = const Color(0xff828282) - ..shader4 = const Color(0xffbdbdbd) - ..shader5 = const Color(0xffe0e0e0) - ..shader6 = const Color(0xfff2f2f2) - ..shader7 = const Color(0xffffffff) - ..bg1 = const Color(0xfff7f8fc) - ..bg2 = const Color(0xffedeef2) - ..bg3 = const Color(0xffe2e4eb) - ..bg4 = const Color(0xff2c144b) - ..tint1 = const Color(0xffe8e0ff) - ..tint2 = const Color(0xffffe7fd) - ..tint3 = const Color(0xffffe7ee) - ..tint4 = const Color(0xffffefe3) - ..tint5 = const Color(0xfffff2cd) - ..tint6 = const Color(0xfff5ffdc) - ..tint7 = const Color(0xffddffd6) - ..tint8 = const Color(0xffdefff1) - ..tint9 = const Color(0xffe1fbff) - ..main1 = const Color(0xff00bcf0) - ..main2 = const Color(0xff00b7ea) - ..textColor = _black - ..iconColor = _black - ..shadowColor = _black - ..disableIconColor = const Color(0xffbdbdbd); - - case ThemeType.dark: - return AppTheme(ty: themeType, isDark: true) - ..surface = const Color(0xff292929) - ..hover = const Color(0xff1f1f1f) - ..selector = const Color(0xff333333) - ..red = const Color(0xfffb006d) - ..yellow = const Color(0xffffd667) - ..green = const Color(0xff66cf80) - ..shader1 = _white - ..shader2 = const Color(0xffffffff) - ..shader3 = const Color(0xff828282) - ..shader4 = const Color(0xffbdbdbd) - ..shader5 = _white - ..shader6 = _black - ..shader7 = _black - ..bg1 = _black - ..bg2 = _black - ..bg3 = const Color(0xff4f4f4f) - ..bg4 = const Color(0xff2c144b) - ..tint1 = const Color(0xffc3adff) - ..tint2 = const Color(0xffffadf9) - ..tint3 = const Color(0xffffadad) - ..tint4 = const Color(0xffffcfad) - ..tint5 = const Color(0xfffffead) - ..tint6 = const Color(0xffe6ffa3) - ..tint7 = const Color(0xffbcffad) - ..tint8 = const Color(0xffadffe2) - ..tint9 = const Color(0xffade4ff) - ..main1 = const Color(0xff00bcf0) - ..main2 = const Color(0xff009cc7) - ..textColor = _white - ..iconColor = _white - ..shadowColor = _white - ..disableIconColor = const Color(0xff333333); - } - } - - ThemeData get themeData { - var t = ThemeData( - textTheme: TextTheme(bodyText2: TextStyle(color: textColor)), - textSelectionTheme: TextSelectionThemeData( - cursorColor: main2, selectionHandleColor: main2), - primaryIconTheme: IconThemeData(color: hover), - iconTheme: IconThemeData(color: shader1), - canvasColor: shader6, - //Don't use this property because of the redo/undo button in the toolbar use the hoverColor. - // hoverColor: main2, - colorScheme: ColorScheme( - brightness: isDark ? Brightness.dark : Brightness.light, - primary: main1, - secondary: main2, - background: surface, - surface: surface, - onBackground: surface, - onSurface: surface, - onError: red, - onPrimary: bg1, - onSecondary: bg1, - error: red), - ); - - return t.copyWith( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - highlightColor: main1, - indicatorColor: main1, - toggleableActiveColor: main1); - } - - Color shift(Color c, double d) => - ColorUtils.shiftHsl(c, d * (isDark ? -1 : 1)); -} - -class ColorUtils { - static Color shiftHsl(Color c, [double amt = 0]) { - var hslc = HSLColor.fromColor(c); - return hslc.withLightness((hslc.lightness + amt).clamp(0.0, 1.0)).toColor(); - } - - static Color parseHex(String value) => - Color(int.parse(value.substring(1, 7), radix: 16) + 0xFF000000); - - static Color blend(Color dst, Color src, double opacity) { - return Color.fromARGB( - 255, - (dst.red.toDouble() * (1.0 - opacity) + src.red.toDouble() * opacity) - .toInt(), - (dst.green.toDouble() * (1.0 - opacity) + src.green.toDouble() * opacity) - .toInt(), - (dst.blue.toDouble() * (1.0 - opacity) + src.blue.toDouble() * opacity) - .toInt(), - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra/lib/uuid.dart b/frontend/app_flowy/packages/flowy_infra/lib/uuid.dart deleted file mode 100644 index df8aee9ddaa88..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra/lib/uuid.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:uuid/uuid.dart'; - -String uuid() { - return const Uuid().v4(); -} diff --git a/frontend/app_flowy/packages/flowy_infra/pubspec.lock b/frontend/app_flowy/packages/flowy_infra/pubspec.lock deleted file mode 100644 index 23fd739d68468..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra/pubspec.lock +++ /dev/null @@ -1,231 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.8.2" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.16.0" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - flutter_svg: - dependency: "direct main" - description: - name: flutter_svg - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.4" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - lints: - dependency: transitive - description: - name: lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.11" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.4" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.1" - path_drawing: - dependency: transitive - description: - name: path_drawing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - petitparser: - dependency: transitive - description: - name: petitparser - url: "https://pub.dartlang.org" - source: hosted - version: "5.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.9" - textstyle_extensions: - dependency: "direct main" - description: - name: textstyle_extensions - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0-nullsafety" - time: - dependency: "direct main" - description: - name: time - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - uuid: - dependency: "direct main" - description: - name: uuid - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.4" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - xml: - dependency: transitive - description: - name: xml - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.0" -sdks: - dart: ">=2.17.0 <3.0.0" - flutter: ">=2.11.0-0.1.pre" diff --git a/frontend/app_flowy/packages/flowy_infra/pubspec.yaml b/frontend/app_flowy/packages/flowy_infra/pubspec.yaml deleted file mode 100644 index 63f1b66b4ebcb..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra/pubspec.yaml +++ /dev/null @@ -1,58 +0,0 @@ -name: flowy_infra -description: A new Flutter package project. -version: 0.0.1 -homepage: - -environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.17.0" - -dependencies: - flutter: - sdk: flutter - time: '>=2.0.0' - uuid: ">=2.2.2" - textstyle_extensions: '2.0.0-nullsafety' - flutter_svg: ^1.1.1 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.1 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/packages/flowy_infra_ui/.vscode/launch.json b/frontend/app_flowy/packages/flowy_infra_ui/.vscode/launch.json deleted file mode 100644 index ba345105b4ba3..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/.vscode/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Infra UI Example", - "type": "dart", - "request": "launch", - "program": "example/lib/main.dart" - }, - ] -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/.settings/org.eclipse.buildship.core.prefs b/frontend/app_flowy/packages/flowy_infra_ui/android/.settings/org.eclipse.buildship.core.prefs deleted file mode 100644 index e8895216fd3c0..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/android/.settings/org.eclipse.buildship.core.prefs +++ /dev/null @@ -1,2 +0,0 @@ -connection.project.dir= -eclipse.preferences.version=1 diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/build.gradle b/frontend/app_flowy/packages/flowy_infra_ui/android/build.gradle deleted file mode 100644 index d129628362999..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/android/build.gradle +++ /dev/null @@ -1,35 +0,0 @@ -group 'com.example.flowy_infra_ui' -version '1.0' - -buildscript { - ext.kotlin_version = '1.6.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' - } -} - -rootProject.allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: 'com.android.library' - -android { - compileSdkVersion 30 - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - dependencies { - implementation "androidx.core:core:1.5.0-rc01" - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/.metadata b/frontend/app_flowy/packages/flowy_infra_ui/example/.metadata deleted file mode 100644 index a8ebbd603e2d3..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# 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: fa5883b78e566877613ad1ccb48dd92075cb5c23 - channel: dev - -project_type: app diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/analysis_options.yaml b/frontend/app_flowy/packages/flowy_infra_ui/example/analysis_options.yaml deleted file mode 100644 index 61b6c4de17c96..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/analysis_options.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/.gitignore b/frontend/app_flowy/packages/flowy_infra_ui/example/android/.gitignore deleted file mode 100644 index 6f568019d3c69..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/android/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java - -# Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app -key.properties -**/*.keystore -**/*.jks diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/.settings/org.eclipse.buildship.core.prefs b/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/.settings/org.eclipse.buildship.core.prefs deleted file mode 100644 index b1886adb46c08..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/.settings/org.eclipse.buildship.core.prefs +++ /dev/null @@ -1,2 +0,0 @@ -connection.project.dir=.. -eclipse.preferences.version=1 diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt deleted file mode 100644 index e793a000d6a91..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.example - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity: FlutterActivity() { -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/drawable-v21/launch_background.xml b/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f3f6a2b..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/drawable/launch_background.xml b/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f884201..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index db77bb4b7b090..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 17987b79bb8a3..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 09d4391482be6..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index d5f1c8d34e7a8..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 4d6372eebdb28..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Flutter/Debug.xcconfig b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Flutter/Debug.xcconfig deleted file mode 100644 index ec97fc6f30212..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "Generated.xcconfig" diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Flutter/Release.xcconfig b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Flutter/Release.xcconfig deleted file mode 100644 index c4855bfe2000b..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "Generated.xcconfig" diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Podfile b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Podfile deleted file mode 100644 index 1e8c3c90a55e3..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Podfile +++ /dev/null @@ -1,41 +0,0 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '9.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6254f0..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003d68d..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5ea15f..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003d68d..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5ea15f..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/AppDelegate.swift b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/AppDelegate.swift deleted file mode 100644 index 70693e4a8c128..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import UIKit -import Flutter - -@UIApplicationMain -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fab2d9de..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada4725e9b..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 28c6bf03016f6..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 2ccbfd967d969..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index f091b6b0bca85..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cde12118dda4..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index d0ef06e7edb86..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index dcdc2306c2850..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 2ccbfd967d969..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index c8f9ed8f5cee1..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index a6d6b8609df07..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index a6d6b8609df07..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index 75b2d164a5a98..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index c4df70d39da79..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 6a84f41e14e27..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index d0e1f58536026..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2fd4678..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19eacad3b0..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19eacad3b0..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19eacad3b0..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b70f18..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c7c9390..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Base.lproj/Main.storyboard b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c28516fb38e..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Runner-Bridging-Header.h b/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a560b42f..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/lib/home/home_screen.dart b/frontend/app_flowy/packages/flowy_infra_ui/example/lib/home/home_screen.dart deleted file mode 100644 index 954930e9cd053..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/lib/home/home_screen.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../overlay/overlay_screen.dart'; -import '../keyboard/keyboard_screen.dart'; -import 'demo_item.dart'; - -class HomeScreen extends StatelessWidget { - const HomeScreen({Key? key}) : super(key: key); - - static List items = [ - SectionHeaderItem('Widget Demos'), - KeyboardItem(), - OverlayItem(), - ]; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Demos'), - ), - body: ListView.builder( - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - if (item is SectionHeaderItem) { - return Container( - constraints: const BoxConstraints(maxHeight: 48.0), - color: Colors.grey[300], - alignment: Alignment.center, - child: ListTile( - title: Text(item.title), - ), - ); - } else if (item is DemoItem) { - return ListTile( - title: Text(item.buildTitle()), - onTap: () => item.handleTap(context), - ); - } - return const ListTile( - title: Text('Unknow.'), - ); - }, - ), - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/lib/main.dart b/frontend/app_flowy/packages/flowy_infra_ui/example/lib/main.dart deleted file mode 100644 index b0ae8d3c326bd..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/lib/main.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; -import 'package:flutter/material.dart'; - -import 'home/home_screen.dart'; - -void main() { - runApp(const ExampleApp()); -} - -class ExampleApp extends StatelessWidget { - const ExampleApp({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return MaterialApp( - builder: overlayManagerBuilder(), - title: "Flowy Infra Title", - home: const HomeScreen(), - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/.gitignore b/frontend/app_flowy/packages/flowy_infra_ui/example/linux/.gitignore deleted file mode 100644 index d3896c98444fb..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/.gitignore +++ /dev/null @@ -1 +0,0 @@ -flutter/ephemeral diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/packages/flowy_infra_ui/example/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 28d352c9d0d85..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) flowy_infra_ui_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FlowyInfraUIPlugin"); - flowy_infra_u_i_plugin_register_with_registrar(flowy_infra_ui_registrar); -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/flutter/generated_plugin_registrant.h b/frontend/app_flowy/packages/flowy_infra_ui/example/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47bc08f3..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/flutter/generated_plugins.cmake b/frontend/app_flowy/packages/flowy_infra_ui/example/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 98453e694db97..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,24 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - flowy_infra_ui -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/main.cc b/frontend/app_flowy/packages/flowy_infra_ui/example/linux/main.cc deleted file mode 100644 index e7c5c54370372..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/main.cc +++ /dev/null @@ -1,6 +0,0 @@ -#include "my_application.h" - -int main(int argc, char** argv) { - g_autoptr(MyApplication) app = my_application_new(); - return g_application_run(G_APPLICATION(app), argc, argv); -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/my_application.h b/frontend/app_flowy/packages/flowy_infra_ui/example/linux/my_application.h deleted file mode 100644 index 72271d5e41701..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/my_application.h +++ /dev/null @@ -1,18 +0,0 @@ -#ifndef FLUTTER_MY_APPLICATION_H_ -#define FLUTTER_MY_APPLICATION_H_ - -#include - -G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, - GtkApplication) - -/** - * my_application_new: - * - * Creates a new Flutter-based application. - * - * Returns: a new #MyApplication. - */ -MyApplication* my_application_new(); - -#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Flutter/Flutter-Debug.xcconfig b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index 4b81f9b2d200f..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Flutter/Flutter-Release.xcconfig b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index 5caa9d1579e48..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 0c920d53871ed..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import flowy_infra_ui - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FlowyInfraUIPlugin.register(with: registry.registrar(forPlugin: "FlowyInfraUIPlugin")) -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Podfile b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Podfile deleted file mode 100644 index dade8dfad0dcf..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Podfile +++ /dev/null @@ -1,40 +0,0 @@ -platform :osx, '10.11' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_macos_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_macos_build_settings(target) - end -end diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003d68d..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003d68d..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/AppDelegate.swift b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/AppDelegate.swift deleted file mode 100644 index d53ef6437726b..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/AppDelegate.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Cocoa -import FlutterMacOS - -@NSApplicationMain -class AppDelegate: FlutterAppDelegate { - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index a2ec33f19f110..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" - }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png deleted file mode 100644 index 3c4935a7ca84f..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png deleted file mode 100644 index ed4cc16421680..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png deleted file mode 100644 index 483be61389733..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png deleted file mode 100644 index bcbf36df2f2aa..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png deleted file mode 100644 index 9c0a652864769..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png deleted file mode 100644 index e71a726136a47..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png deleted file mode 100644 index 8a31fe2dd3f91..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Configs/Debug.xcconfig b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Configs/Debug.xcconfig deleted file mode 100644 index 36b0fd9464f45..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Configs/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Debug.xcconfig" -#include "Warnings.xcconfig" diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Configs/Release.xcconfig b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Configs/Release.xcconfig deleted file mode 100644 index dff4f49561c81..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Configs/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Release.xcconfig" -#include "Warnings.xcconfig" diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Configs/Warnings.xcconfig b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Configs/Warnings.xcconfig deleted file mode 100644 index 42bcbf4780b18..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Configs/Warnings.xcconfig +++ /dev/null @@ -1,13 +0,0 @@ -WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings -GCC_WARN_UNDECLARED_SELECTOR = YES -CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES -CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE -CLANG_WARN__DUPLICATE_METHOD_MATCH = YES -CLANG_WARN_PRAGMA_PACK = YES -CLANG_WARN_STRICT_PROTOTYPES = YES -CLANG_WARN_COMMA = YES -GCC_WARN_STRICT_SELECTOR_MATCH = YES -CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES -CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES -GCC_WARN_SHADOW = YES -CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Info.plist b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Info.plist deleted file mode 100644 index 4789daa6a443e..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Info.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - - diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/MainFlutterWindow.swift b/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/MainFlutterWindow.swift deleted file mode 100644 index 2722837ec9181..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/MainFlutterWindow.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Cocoa -import FlutterMacOS - -class MainFlutterWindow: NSWindow { - override func awakeFromNib() { - let flutterViewController = FlutterViewController.init() - let windowFrame = self.frame - self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) - - RegisterGeneratedPlugins(registry: flutterViewController) - - super.awakeFromNib() - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.lock b/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.lock deleted file mode 100644 index 398e3d0c892eb..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.lock +++ /dev/null @@ -1,348 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - animations: - dependency: transitive - description: - name: animations - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - appflowy_popover: - dependency: transitive - description: - path: "../../appflowy_popover" - relative: true - source: path - version: "0.0.1" - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.8.2" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.16.0" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.3" - dartz: - dependency: transitive - description: - name: dartz - url: "https://pub.dartlang.org" - source: hosted - version: "0.10.0-nullsafety.2" - equatable: - dependency: transitive - description: - name: equatable - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.3" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - flowy_infra: - dependency: transitive - description: - path: "../../flowy_infra" - relative: true - source: path - version: "0.0.1" - flowy_infra_ui: - dependency: "direct main" - description: - path: ".." - relative: true - source: path - version: "0.0.1" - flowy_infra_ui_platform_interface: - dependency: transitive - description: - path: "../flowy_infra_ui_platform_interface" - relative: true - source: path - version: "0.0.1" - flowy_infra_ui_web: - dependency: transitive - description: - path: "../flowy_infra_ui_web" - relative: true - source: path - version: "0.0.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - flutter_svg: - dependency: transitive - description: - name: flutter_svg - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.4" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - js: - dependency: transitive - description: - name: js - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.4" - lint: - dependency: transitive - description: - name: lint - url: "https://pub.dartlang.org" - source: hosted - version: "1.5.3" - lints: - dependency: transitive - description: - name: lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - loading_indicator: - dependency: transitive - description: - name: loading_indicator - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.11" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.4" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - nested: - dependency: transitive - description: - name: nested - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.1" - path_drawing: - dependency: transitive - description: - name: path_drawing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - petitparser: - dependency: transitive - description: - name: petitparser - url: "https://pub.dartlang.org" - source: hosted - version: "5.0.0" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - provider: - dependency: "direct main" - description: - name: provider - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - styled_widget: - dependency: transitive - description: - name: styled_widget - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.1+2" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.9" - textstyle_extensions: - dependency: transitive - description: - name: textstyle_extensions - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0-nullsafety" - time: - dependency: transitive - description: - name: time - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - uuid: - dependency: transitive - description: - name: uuid - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.4" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - xml: - dependency: transitive - description: - name: xml - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.0" -sdks: - dart: ">=2.17.0 <3.0.0" - flutter: ">=2.11.0-0.1.pre" diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.yaml b/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.yaml deleted file mode 100644 index b9c8ec058e80e..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: flowy_infra_ui_example -description: Demonstrates how to use the flowy_infra_ui plugin. - -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -environment: - sdk: ">=2.12.0 <3.0.0" - -dependencies: - flutter: - sdk: flutter - - flowy_infra_ui: - path: ../ - - cupertino_icons: ^1.0.2 - provider: - -dev_dependencies: - flutter_test: - sdk: flutter - - flutter_lints: ^2.0.1 - -flutter: - uses-material-design: true diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/web/favicon.png b/frontend/app_flowy/packages/flowy_infra_ui/example/web/favicon.png deleted file mode 100644 index 8aaa46ac1ae21..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/web/favicon.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/web/icons/Icon-192.png b/frontend/app_flowy/packages/flowy_infra_ui/example/web/icons/Icon-192.png deleted file mode 100644 index b749bfef07473..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/web/icons/Icon-192.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/web/icons/Icon-512.png b/frontend/app_flowy/packages/flowy_infra_ui/example/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48dff116..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/web/icons/Icon-512.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/web/icons/Icon-maskable-192.png b/frontend/app_flowy/packages/flowy_infra_ui/example/web/icons/Icon-maskable-192.png deleted file mode 100644 index eb9b4d76e5255..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/web/icons/Icon-maskable-512.png b/frontend/app_flowy/packages/flowy_infra_ui/example/web/icons/Icon-maskable-512.png deleted file mode 100644 index d69c56691fbdb..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/.gitignore b/frontend/app_flowy/packages/flowy_infra_ui/example/windows/.gitignore deleted file mode 100644 index d492d0d98c8fd..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -flutter/ephemeral/ - -# Visual Studio user-specific files. -*.suo -*.user -*.userosscache -*.sln.docstates - -# Visual Studio build-related files. -x64/ -x86/ - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/packages/flowy_infra_ui/example/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 8c8c37a3f7cb2..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,14 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - FlowyInfraUIPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FlowyInfraUIPlugin")); -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/flutter/generated_plugin_registrant.h b/frontend/app_flowy/packages/flowy_infra_ui/example/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d85a9310..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/flutter/generated_plugins.cmake b/frontend/app_flowy/packages/flowy_infra_ui/example/windows/flutter/generated_plugins.cmake deleted file mode 100644 index 8571d2708556a..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,24 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - flowy_infra_ui -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/flutter_window.cpp b/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/flutter_window.cpp deleted file mode 100644 index b43b9095ea3aa..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/flutter_window.cpp +++ /dev/null @@ -1,61 +0,0 @@ -#include "flutter_window.h" - -#include - -#include "flutter/generated_plugin_registrant.h" - -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} - -FlutterWindow::~FlutterWindow() {} - -bool FlutterWindow::OnCreate() { - if (!Win32Window::OnCreate()) { - return false; - } - - RECT frame = GetClientArea(); - - // The size here must match the window dimensions to avoid unnecessary surface - // creation / destruction in the startup path. - flutter_controller_ = std::make_unique( - frame.right - frame.left, frame.bottom - frame.top, project_); - // Ensure that basic setup of the controller was successful. - if (!flutter_controller_->engine() || !flutter_controller_->view()) { - return false; - } - RegisterPlugins(flutter_controller_->engine()); - SetChildContent(flutter_controller_->view()->GetNativeWindow()); - return true; -} - -void FlutterWindow::OnDestroy() { - if (flutter_controller_) { - flutter_controller_ = nullptr; - } - - Win32Window::OnDestroy(); -} - -LRESULT -FlutterWindow::MessageHandler(HWND hwnd, UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - // Give Flutter, including plugins, an opportunity to handle window messages. - if (flutter_controller_) { - std::optional result = - flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, - lparam); - if (result) { - return *result; - } - } - - switch (message) { - case WM_FONTCHANGE: - flutter_controller_->engine()->ReloadSystemFonts(); - break; - } - - return Win32Window::MessageHandler(hwnd, message, wparam, lparam); -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/resources/app_icon.ico b/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/resources/app_icon.ico deleted file mode 100644 index c04e20caf6370..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/resources/app_icon.ico and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/win32_window.cpp b/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/win32_window.cpp deleted file mode 100644 index c10f08dc7da60..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/win32_window.cpp +++ /dev/null @@ -1,245 +0,0 @@ -#include "win32_window.h" - -#include - -#include "resource.h" - -namespace { - -constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; - -// The number of Win32Window objects that currently exist. -static int g_active_window_count = 0; - -using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); - -// Scale helper to convert logical scaler values to physical using passed in -// scale factor -int Scale(int source, double scale_factor) { - return static_cast(source * scale_factor); -} - -// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. -// This API is only needed for PerMonitor V1 awareness mode. -void EnableFullDpiSupportIfAvailable(HWND hwnd) { - HMODULE user32_module = LoadLibraryA("User32.dll"); - if (!user32_module) { - return; - } - auto enable_non_client_dpi_scaling = - reinterpret_cast( - GetProcAddress(user32_module, "EnableNonClientDpiScaling")); - if (enable_non_client_dpi_scaling != nullptr) { - enable_non_client_dpi_scaling(hwnd); - FreeLibrary(user32_module); - } -} - -} // namespace - -// Manages the Win32Window's window class registration. -class WindowClassRegistrar { - public: - ~WindowClassRegistrar() = default; - - // Returns the singleton registar instance. - static WindowClassRegistrar* GetInstance() { - if (!instance_) { - instance_ = new WindowClassRegistrar(); - } - return instance_; - } - - // Returns the name of the window class, registering the class if it hasn't - // previously been registered. - const wchar_t* GetWindowClass(); - - // Unregisters the window class. Should only be called if there are no - // instances of the window. - void UnregisterWindowClass(); - - private: - WindowClassRegistrar() = default; - - static WindowClassRegistrar* instance_; - - bool class_registered_ = false; -}; - -WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; - -const wchar_t* WindowClassRegistrar::GetWindowClass() { - if (!class_registered_) { - WNDCLASS window_class{}; - window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); - window_class.lpszClassName = kWindowClassName; - window_class.style = CS_HREDRAW | CS_VREDRAW; - window_class.cbClsExtra = 0; - window_class.cbWndExtra = 0; - window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); - window_class.hbrBackground = 0; - window_class.lpszMenuName = nullptr; - window_class.lpfnWndProc = Win32Window::WndProc; - RegisterClass(&window_class); - class_registered_ = true; - } - return kWindowClassName; -} - -void WindowClassRegistrar::UnregisterWindowClass() { - UnregisterClass(kWindowClassName, nullptr); - class_registered_ = false; -} - -Win32Window::Win32Window() { - ++g_active_window_count; -} - -Win32Window::~Win32Window() { - --g_active_window_count; - Destroy(); -} - -bool Win32Window::CreateAndShow(const std::wstring& title, - const Point& origin, - const Size& size) { - Destroy(); - - const wchar_t* window_class = - WindowClassRegistrar::GetInstance()->GetWindowClass(); - - const POINT target_point = {static_cast(origin.x), - static_cast(origin.y)}; - HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); - UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); - double scale_factor = dpi / 96.0; - - HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, - Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), - Scale(size.width, scale_factor), Scale(size.height, scale_factor), - nullptr, nullptr, GetModuleHandle(nullptr), this); - - if (!window) { - return false; - } - - return OnCreate(); -} - -// static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); - - auto that = static_cast(window_struct->lpCreateParams); - EnableFullDpiSupportIfAvailable(window); - that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { - return that->MessageHandler(window, message, wparam, lparam); - } - - return DefWindowProc(window, message, wparam, lparam); -} - -LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - switch (message) { - case WM_DESTROY: - window_handle_ = nullptr; - Destroy(); - if (quit_on_close_) { - PostQuitMessage(0); - } - return 0; - - case WM_DPICHANGED: { - auto newRectSize = reinterpret_cast(lparam); - LONG newWidth = newRectSize->right - newRectSize->left; - LONG newHeight = newRectSize->bottom - newRectSize->top; - - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); - - return 0; - } - case WM_SIZE: { - RECT rect = GetClientArea(); - if (child_content_ != nullptr) { - // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return 0; - } - - case WM_ACTIVATE: - if (child_content_ != nullptr) { - SetFocus(child_content_); - } - return 0; - } - - return DefWindowProc(window_handle_, message, wparam, lparam); -} - -void Win32Window::Destroy() { - OnDestroy(); - - if (window_handle_) { - DestroyWindow(window_handle_); - window_handle_ = nullptr; - } - if (g_active_window_count == 0) { - WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); - } -} - -Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( - GetWindowLongPtr(window, GWLP_USERDATA)); -} - -void Win32Window::SetChildContent(HWND content) { - child_content_ = content; - SetParent(content, window_handle_); - RECT frame = GetClientArea(); - - MoveWindow(content, frame.left, frame.top, frame.right - frame.left, - frame.bottom - frame.top, true); - - SetFocus(child_content_); -} - -RECT Win32Window::GetClientArea() { - RECT frame; - GetClientRect(window_handle_, &frame); - return frame; -} - -HWND Win32Window::GetHandle() { - return window_handle_; -} - -void Win32Window::SetQuitOnClose(bool quit_on_close) { - quit_on_close_ = quit_on_close; -} - -bool Win32Window::OnCreate() { - // No-op; provided for subclasses. - return true; -} - -void Win32Window::OnDestroy() { - // No-op; provided for subclasses. -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/win32_window.h b/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/win32_window.h deleted file mode 100644 index 17ba431125b4b..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/win32_window.h +++ /dev/null @@ -1,98 +0,0 @@ -#ifndef RUNNER_WIN32_WINDOW_H_ -#define RUNNER_WIN32_WINDOW_H_ - -#include - -#include -#include -#include - -// A class abstraction for a high DPI-aware Win32 Window. Intended to be -// inherited from by classes that wish to specialize with custom -// rendering and input handling -class Win32Window { - public: - struct Point { - unsigned int x; - unsigned int y; - Point(unsigned int x, unsigned int y) : x(x), y(y) {} - }; - - struct Size { - unsigned int width; - unsigned int height; - Size(unsigned int width, unsigned int height) - : width(width), height(height) {} - }; - - Win32Window(); - virtual ~Win32Window(); - - // Creates and shows a win32 window with |title| and position and size using - // |origin| and |size|. New windows are created on the default monitor. Window - // sizes are specified to the OS in physical pixels, hence to ensure a - // consistent size to will treat the width height passed in to this function - // as logical pixels and scale to appropriate for the default monitor. Returns - // true if the window was created successfully. - bool CreateAndShow(const std::wstring& title, - const Point& origin, - const Size& size); - - // Release OS resources associated with window. - void Destroy(); - - // Inserts |content| into the window tree. - void SetChildContent(HWND content); - - // Returns the backing Window handle to enable clients to set icon and other - // window properties. Returns nullptr if the window has been destroyed. - HWND GetHandle(); - - // If true, closing this window will quit the application. - void SetQuitOnClose(bool quit_on_close); - - // Return a RECT representing the bounds of the current client area. - RECT GetClientArea(); - - protected: - // Processes and route salient window messages for mouse handling, - // size change and DPI. Delegates handling of these to member overloads that - // inheriting classes can handle. - virtual LRESULT MessageHandler(HWND window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Called when CreateAndShow is called, allowing subclass window-related - // setup. Subclasses should return false if setup fails. - virtual bool OnCreate(); - - // Called when Destroy is called. - virtual void OnDestroy(); - - private: - friend class WindowClassRegistrar; - - // OS callback called by message pump. Handles the WM_NCCREATE message which - // is passed when the non-client area is being created and enables automatic - // non-client DPI scaling so that the non-client area automatically - // responsponds to changes in DPI. All other messages are handled by - // MessageHandler. - static LRESULT CALLBACK WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Retrieves a class instance pointer for |window| - static Win32Window* GetThisFromHandle(HWND const window) noexcept; - - bool quit_on_close_ = false; - - // window handle for top level window. - HWND window_handle_ = nullptr; - - // window handle for hosted content. - HWND child_content_ = nullptr; -}; - -#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/analysis_options.yaml b/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/analysis_options.yaml deleted file mode 100644 index a5744c1cfbe77..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -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/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/src/method_channel_flowy_infra_ui.dart b/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/src/method_channel_flowy_infra_ui.dart deleted file mode 100644 index ad8051f51527b..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/src/method_channel_flowy_infra_ui.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flowy_infra_ui_platform_interface/flowy_infra_ui_platform_interface.dart'; -import 'package:flutter/services.dart'; - -import '../flowy_infra_ui_platform_interface.dart'; - -// ignore_for_file: constant_identifier_names -const INFRA_UI_METHOD_CHANNEL_NAME = 'flowy_infra_ui_method'; -const INFRA_UI_KEYBOARD_EVENT_CHANNEL_NAME = 'flowy_infra_ui_event/keyboard'; -const INFRA_UI_METHOD_GET_PLATFORM_VERSION = 'getPlatformVersion'; - -class MethodChannelFlowyInfraUI extends FlowyInfraUIPlatform { - final MethodChannel _methodChannel = const MethodChannel(INFRA_UI_METHOD_CHANNEL_NAME); - final EventChannel _keyboardChannel = const EventChannel(INFRA_UI_KEYBOARD_EVENT_CHANNEL_NAME); - - late final Stream _onKeyboardVisibilityChange = - _keyboardChannel.receiveBroadcastStream().map((event) => event as bool); - - @override - Stream get onKeyboardVisibilityChange => _onKeyboardVisibilityChange; - - @override - Future getPlatformVersion() async { - String? version = await _methodChannel.invokeMethod(INFRA_UI_METHOD_GET_PLATFORM_VERSION); - return version ?? 'unknow'; - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.lock b/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.lock deleted file mode 100644 index f736121e484db..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.lock +++ /dev/null @@ -1,168 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.8.2" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.16.0" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - lints: - dependency: transitive - description: - name: lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.11" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.4" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.1" - plugin_platform_interface: - dependency: "direct main" - description: - name: plugin_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.9" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" -sdks: - dart: ">=2.17.0 <3.0.0" - flutter: ">=1.17.0" diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml b/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml deleted file mode 100644 index 2f375be36765c..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: flowy_infra_ui_platform_interface -description: A new Flutter package project. -version: 0.0.1 -homepage: - -environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.17.0" - -dependencies: - flutter: - sdk: flutter - - plugin_platform_interface: ^2.0.0 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.1 - -flutter: \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/analysis_options.yaml b/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/analysis_options.yaml deleted file mode 100644 index a5744c1cfbe77..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -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/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.lock b/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.lock deleted file mode 100644 index e05d8bd65cc29..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.lock +++ /dev/null @@ -1,187 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.8.2" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.16.0" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - flowy_infra_ui_platform_interface: - dependency: "direct main" - description: - path: "../flowy_infra_ui_platform_interface" - relative: true - source: path - version: "0.0.1" - flutter: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - js: - dependency: transitive - description: - name: js - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.4" - lints: - dependency: transitive - description: - name: lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.11" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.4" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.1" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.9" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" -sdks: - dart: ">=2.17.0 <3.0.0" - flutter: ">=1.17.0" diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml b/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml deleted file mode 100644 index 224d7bf47feff..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: flowy_infra_ui_web -description: A new Flutter package project. -version: 0.0.1 -homepage: -publish_to: none - -environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.17.0" - -dependencies: - flutter_web_plugins: - sdk: flutter - - flowy_infra_ui_platform_interface: - path: ../flowy_infra_ui_platform_interface - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.1 - -flutter: - plugin: - platforms: - web: - pluginClass: FlowyInfraUIPlugin - fileName: flowy_infra_ui_web.dart \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_infra_ui/ios/Classes/FlowyInfraUiPlugin.h b/frontend/app_flowy/packages/flowy_infra_ui/ios/Classes/FlowyInfraUiPlugin.h deleted file mode 100644 index 9f353812bab3c..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/ios/Classes/FlowyInfraUiPlugin.h +++ /dev/null @@ -1,4 +0,0 @@ -#import - -@interface FlowyInfraUiPlugin : NSObject -@end diff --git a/frontend/app_flowy/packages/flowy_infra_ui/ios/Classes/FlowyInfraUiPlugin.m b/frontend/app_flowy/packages/flowy_infra_ui/ios/Classes/FlowyInfraUiPlugin.m deleted file mode 100644 index 6609bdcf24f30..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/ios/Classes/FlowyInfraUiPlugin.m +++ /dev/null @@ -1,15 +0,0 @@ -#import "FlowyInfraUiPlugin.h" -#if __has_include() -#import -#else -// Support project import fallback if the generated compatibility header -// is not copied when this plugin is created as a library. -// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 -#import "flowy_infra_ui-Swift.h" -#endif - -@implementation FlowyInfraUiPlugin -+ (void)registerWithRegistrar:(NSObject*)registrar { - [SwiftFlowyInfraUiPlugin registerWithRegistrar:registrar]; -} -@end diff --git a/frontend/app_flowy/packages/flowy_infra_ui/ios/Classes/SwiftFlowyInfraUiPlugin.swift b/frontend/app_flowy/packages/flowy_infra_ui/ios/Classes/SwiftFlowyInfraUiPlugin.swift deleted file mode 100644 index 5459739470596..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/ios/Classes/SwiftFlowyInfraUiPlugin.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Flutter -import UIKit - -public class SwiftFlowyInfraUiPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "flowy_infra_ui", binaryMessenger: registrar.messenger()) - let instance = SwiftFlowyInfraUiPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - result("iOS " + UIDevice.current.systemVersion) - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/basis.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/basis.dart deleted file mode 100644 index 59a2bc653362f..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/basis.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter/material.dart'; - -// MARK: - Shared Builder - -typedef WidgetBuilder = Widget Function(); - -typedef IndexedCallback = void Function(int index); -typedef IndexedValueCallback = void Function(T value, int index); diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/flowy_infra_ui.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/flowy_infra_ui.dart deleted file mode 100644 index fa20e5729469e..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/flowy_infra_ui.dart +++ /dev/null @@ -1,12 +0,0 @@ -// Basis -export 'basis.dart'; - -// Keyboard -export 'src/keyboard/keyboard_visibility_detector.dart'; - -// Overlay -export 'src/flowy_overlay/flowy_overlay.dart'; -export 'src/flowy_overlay/list_overlay.dart'; -export 'src/flowy_overlay/option_overlay.dart'; -export 'src/flowy_overlay/flowy_dialog.dart'; -export 'src/flowy_overlay/appflowy_popover.dart'; diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart deleted file mode 100644 index a98ac0c8557b1..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:flutter/material.dart'; - -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/style_widget/decoration.dart'; -import 'package:provider/provider.dart'; - -class AppFlowyPopover extends StatelessWidget { - final Widget child; - final PopoverController? controller; - final Widget Function(BuildContext context) popupBuilder; - final PopoverDirection direction; - final int triggerActions; - final BoxConstraints constraints; - final void Function()? onClose; - final PopoverMutex? mutex; - final Offset? offset; - final bool asBarrier; - final EdgeInsets margin; - - const AppFlowyPopover({ - Key? key, - required this.child, - required this.popupBuilder, - this.direction = PopoverDirection.rightWithTopAligned, - this.onClose, - this.constraints = const BoxConstraints(maxWidth: 240, maxHeight: 600), - this.mutex, - this.triggerActions = PopoverTriggerFlags.click, - this.offset, - this.controller, - this.asBarrier = false, - this.margin = const EdgeInsets.all(6), - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Popover( - controller: controller, - onClose: onClose, - direction: direction, - mutex: mutex, - asBarrier: asBarrier, - triggerActions: triggerActions, - popupBuilder: (context) { - final child = popupBuilder(context); - debugPrint('Show $child popover'); - return _PopoverContainer( - constraints: constraints, - margin: margin, - child: child, - ); - }, - child: child, - ); - } -} - -class _PopoverContainer extends StatelessWidget { - final Widget child; - final BoxConstraints constraints; - final EdgeInsets margin; - const _PopoverContainer({ - required this.child, - required this.margin, - required this.constraints, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - final decoration = FlowyDecoration.decoration( - theme.surface, - theme.shadowColor.withOpacity(0.15), - ); - - return Material( - type: MaterialType.transparency, - child: Container( - padding: margin, - decoration: decoration, - constraints: constraints, - child: child, - - // SingleChildScrollView( - // scrollDirection: Axis.horizontal, - // child: ConstrainedBox( - // constraints: constraints, - // child: child, - // ), - // ), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart deleted file mode 100644 index e0158d5c0cd7d..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/material.dart'; -import 'dart:math'; - -const _overlayContainerPadding = EdgeInsets.all(12); -const overlayContainerMaxWidth = 760.0; -const overlayContainerMinWidth = 320.0; - -class FlowyDialog extends StatelessWidget { - final Widget? title; - final ShapeBorder? shape; - final Widget child; - final BoxConstraints? constraints; - final EdgeInsets padding; - const FlowyDialog({ - required this.child, - this.title, - this.shape, - this.constraints, - this.padding = _overlayContainerPadding, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final windowSize = MediaQuery.of(context).size; - final size = windowSize * 0.7; - return SimpleDialog( - title: title, - shape: shape ?? - RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - children: [ - Material( - type: MaterialType.transparency, - child: Container( - padding: padding, - height: size.height, - width: max(min(size.width, overlayContainerMaxWidth), - overlayContainerMinWidth), - constraints: constraints, - child: child, - ), - ) - ]); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/button.dart deleted file mode 100644 index 5057f206f9a1f..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/button.dart +++ /dev/null @@ -1,175 +0,0 @@ -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/material.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -class FlowyButton extends StatelessWidget { - final Widget text; - final VoidCallback? onTap; - final void Function(bool)? onHover; - final EdgeInsets margin; - final Widget? leftIcon; - final Widget? rightIcon; - final Color hoverColor; - final bool isSelected; - final BorderRadius radius; - - const FlowyButton({ - Key? key, - required this.text, - this.onTap, - this.onHover, - this.margin = const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - this.leftIcon, - this.rightIcon, - this.hoverColor = Colors.transparent, - this.isSelected = false, - this.radius = const BorderRadius.all(Radius.circular(6)), - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: FlowyHover( - style: HoverStyle( - borderRadius: radius, - hoverColor: hoverColor, - ), - onHover: onHover, - isSelected: () => isSelected, - builder: (context, onHover) => _render(), - ), - ); - } - - Widget _render() { - List children = List.empty(growable: true); - - if (leftIcon != null) { - children.add( - SizedBox.fromSize(size: const Size.square(16), child: leftIcon!)); - children.add(const HSpace(6)); - } - - children.add(Expanded(child: text)); - - if (rightIcon != null) { - children.add( - SizedBox.fromSize(size: const Size.square(16), child: rightIcon!)); - } - - return Padding( - padding: margin, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: children, - ), - ); - } -} - -class FlowyTextButton extends StatelessWidget { - final String text; - final double fontSize; - final TextOverflow overflow; - final FontWeight fontWeight; - - final VoidCallback? onPressed; - final EdgeInsets padding; - final Widget? heading; - final Color? hoverColor; - final Color? fillColor; - final BorderRadius? radius; - final MainAxisAlignment mainAxisAlignment; - final String? tooltip; - - // final HoverDisplayConfig? hoverDisplay; - const FlowyTextButton( - this.text, { - Key? key, - this.onPressed, - this.fontSize = 16, - this.overflow = TextOverflow.ellipsis, - this.fontWeight = FontWeight.w400, - this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - this.hoverColor, - this.fillColor, - this.heading, - this.radius, - this.mainAxisAlignment = MainAxisAlignment.start, - this.tooltip, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - List children = []; - if (heading != null) { - children.add(heading!); - children.add(const HSpace(6)); - } - children.add( - FlowyText( - text, - overflow: overflow, - fontWeight: fontWeight, - fontSize: fontSize, - textAlign: TextAlign.center, - ), - ); - - Widget child = Padding( - padding: padding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: mainAxisAlignment, - children: children, - ), - ); - - child = RawMaterialButton( - visualDensity: VisualDensity.compact, - hoverElevation: 0, - highlightElevation: 0, - shape: RoundedRectangleBorder( - borderRadius: radius ?? BorderRadius.circular(2)), - fillColor: fillColor, - hoverColor: hoverColor ?? Colors.transparent, - focusColor: Colors.transparent, - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - elevation: 0, - onPressed: onPressed, - child: child, - ); - - if (tooltip != null) { - child = Tooltip( - message: tooltip!, - textStyle: TextStyles.caption.textColor(Colors.white), - child: child, - ); - } - - return child; - } -} -// return TextButton( -// style: ButtonStyle( -// textStyle: MaterialStateProperty.all(TextStyle(fontSize: fontSize)), -// alignment: Alignment.centerLeft, -// foregroundColor: MaterialStateProperty.all(Colors.black), -// padding: MaterialStateProperty.all( -// const EdgeInsets.symmetric(horizontal: 2)), -// ), -// onPressed: onPressed, -// child: Text( -// text, -// overflow: TextOverflow.ellipsis, -// softWrap: false, -// ), -// ); diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/extension.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/extension.dart deleted file mode 100644 index bd6245563f78c..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/extension.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; -export 'package:styled_widget/styled_widget.dart'; - -extension FlowyStyledWidget on Widget { - Widget bottomBorder({double width = 0.5, Color color = Colors.grey}) { - return Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(width: width, color: color), - ), - ), - child: this, - ); - } - - Widget topBorder({double width = 0.5, Color color = Colors.grey}) { - return Container( - decoration: BoxDecoration( - border: Border( - top: BorderSide(width: width, color: color), - ), - ), - child: this, - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/hover.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/hover.dart deleted file mode 100644 index d6f3eeb6e8561..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/hover.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'package:flutter/material.dart'; -// ignore: unused_import -import 'package:flowy_infra/time/duration.dart'; - -typedef HoverBuilder = Widget Function(BuildContext context, bool onHover); - -class FlowyHover extends StatefulWidget { - final HoverStyle style; - final HoverBuilder? builder; - final Widget? child; - - final bool Function()? isSelected; - final void Function(bool)? onHover; - final MouseCursor? cursor; - - /// Determined whether the [builder] should get called when onEnter/onExit - /// happened - /// - /// [FlowyHover] show hover when [MouseRegion]'s onEnter get called - /// [FlowyHover] hide hover when [MouseRegion]'s onExit get called - /// - final bool Function()? buildWhenOnHover; - - const FlowyHover({ - Key? key, - this.builder, - this.child, - required this.style, - this.isSelected, - this.onHover, - this.cursor, - this.buildWhenOnHover, - }) : super(key: key); - - @override - State createState() => _FlowyHoverState(); -} - -class _FlowyHoverState extends State { - bool _onHover = false; - - @override - void didUpdateWidget(covariant FlowyHover oldWidget) { - // Reset the _onHover to false when the parent widget get rebuild. - _onHover = false; - super.didUpdateWidget(oldWidget); - } - - @override - Widget build(BuildContext context) { - return MouseRegion( - cursor: widget.cursor != null ? widget.cursor! : SystemMouseCursors.click, - opaque: false, - onHover: (p) { - if (_onHover) return; - - if (widget.buildWhenOnHover?.call() ?? true) { - setState(() => _onHover = true); - if (widget.onHover != null) { - widget.onHover!(true); - } - } - }, - onExit: (p) { - if (_onHover == false) return; - - if (widget.buildWhenOnHover?.call() ?? true) { - setState(() => _onHover = false); - if (widget.onHover != null) { - widget.onHover!(false); - } - } - }, - child: renderWidget(), - ); - } - - Widget renderWidget() { - var showHover = _onHover; - if (!showHover && widget.isSelected != null) { - showHover = widget.isSelected!(); - } - - final child = widget.child ?? widget.builder!(context, _onHover); - if (showHover) { - return FlowyHoverContainer( - style: widget.style, - child: child, - ); - } else { - return Container(color: widget.style.backgroundColor, child: child); - } - } -} - -class HoverStyle { - final Color borderColor; - final double borderWidth; - final Color hoverColor; - final BorderRadius borderRadius; - final EdgeInsets contentMargin; - final Color backgroundColor; - - const HoverStyle( - {this.borderColor = Colors.transparent, - this.borderWidth = 0, - this.borderRadius = const BorderRadius.all(Radius.circular(6)), - this.contentMargin = EdgeInsets.zero, - this.backgroundColor = Colors.transparent, - required this.hoverColor}); -} - -class FlowyHoverContainer extends StatelessWidget { - final HoverStyle style; - final Widget? child; - - const FlowyHoverContainer({ - Key? key, - this.child, - required this.style, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final hoverBorder = Border.all( - color: style.borderColor, - width: style.borderWidth, - ); - - return Container( - margin: style.contentMargin, - decoration: BoxDecoration( - border: hoverBorder, - color: style.hoverColor, - borderRadius: style.borderRadius, - ), - child: child, - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/icon_button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/icon_button.dart deleted file mode 100644 index 0f56541516729..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/icon_button.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'dart:math'; - -import 'package:flowy_infra/image.dart'; -import 'package:flutter/material.dart'; - -class FlowyIconButton extends StatelessWidget { - final double width; - final double? height; - final Widget icon; - final VoidCallback? onPressed; - final Color? fillColor; - final Color? hoverColor; - final EdgeInsets iconPadding; - final BorderRadius? radius; - final String? tooltipText; - - const FlowyIconButton({ - Key? key, - this.width = 30, - this.height, - this.onPressed, - this.fillColor = Colors.transparent, - this.hoverColor = Colors.transparent, - this.iconPadding = EdgeInsets.zero, - this.radius, - this.tooltipText, - required this.icon, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - Widget child = icon; - final size = Size(width, height ?? width); - - assert(size.width > iconPadding.horizontal); - assert(size.height > iconPadding.vertical); - - final childWidth = min(size.width - iconPadding.horizontal, size.height - iconPadding.vertical); - final childSize = Size(childWidth, childWidth); - - return ConstrainedBox( - constraints: BoxConstraints.tightFor(width: size.width, height: size.height), - child: Tooltip( - message: tooltipText ?? '', - showDuration: Duration.zero, - child: RawMaterialButton( - visualDensity: VisualDensity.compact, - hoverElevation: 0, - highlightElevation: 0, - shape: RoundedRectangleBorder(borderRadius: radius ?? BorderRadius.circular(2)), - fillColor: fillColor, - hoverColor: hoverColor, - focusColor: Colors.transparent, - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - elevation: 0, - onPressed: onPressed, - child: Padding( - padding: iconPadding, - child: SizedBox.fromSize(size: childSize, child: child), - ), - ), - ), - ); - } -} - -class FlowyDropdownButton extends StatelessWidget { - final VoidCallback? onPressed; - const FlowyDropdownButton({ - Key? key, - this.onPressed, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return FlowyIconButton( - width: 16, - onPressed: onPressed, - icon: svgWidget("home/drop_down_show"), - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart deleted file mode 100644 index 619d8b1eaa8c3..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart +++ /dev/null @@ -1,268 +0,0 @@ -import 'dart:math'; -import 'dart:async'; -import 'package:async/async.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/widget/mouse_hover_builder.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:styled_widget/styled_widget.dart'; - -class StyledScrollbar extends StatefulWidget { - final double? size; - final Axis axis; - final ScrollController controller; - final Function(double)? onDrag; - final bool showTrack; - final bool autoHideScrollbar; - final Color? handleColor; - final Color? trackColor; - - // ignore: todo - // TODO: Remove contentHeight if we can fix this issue - // https://stackoverflow.com/questions/60855712/flutter-how-to-force-scrollcontroller-to-recalculate-position-maxextents - final double? contentSize; - - const StyledScrollbar( - {Key? key, - this.size, - required this.axis, - required this.controller, - this.onDrag, - this.contentSize, - this.showTrack = false, - this.autoHideScrollbar = true, - this.handleColor, - this.trackColor}) - : super(key: key); - - @override - ScrollbarState createState() => ScrollbarState(); -} - -class ScrollbarState extends State { - double _viewExtent = 100; - CancelableOperation? _hideScrollbarOperation; - bool hideHandler = false; - - @override - void initState() { - widget.controller.addListener(() => setState(() {})); - widget.controller.position.isScrollingNotifier.addListener( - () { - if (!mounted) return; - if (!widget.autoHideScrollbar) return; - _hideScrollbarOperation?.cancel(); - if (!widget.controller.position.isScrollingNotifier.value) { - _hideScrollbarOperation = CancelableOperation.fromFuture( - Future.delayed(const Duration(seconds: 2), () {}), - ).then((_) { - hideHandler = true; - if (mounted) { - setState(() {}); - } - }); - } else { - hideHandler = false; - } - }, - ); - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - @override - void didUpdateWidget(StyledScrollbar oldWidget) { - if (oldWidget.contentSize != widget.contentSize) setState(() {}); - super.didUpdateWidget(oldWidget); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return LayoutBuilder( - builder: (_, BoxConstraints constraints) { - double maxExtent; - final contentSize = widget.contentSize; - switch (widget.axis) { - case Axis.vertical: - // Use supplied contentSize if we have it, otherwise just fallback to maxScrollExtents - if (contentSize != null && contentSize > 0) { - maxExtent = contentSize - constraints.maxHeight; - } else { - maxExtent = widget.controller.position.maxScrollExtent; - } - - _viewExtent = constraints.maxHeight; - - break; - case Axis.horizontal: - // Use supplied contentSize if we have it, otherwise just fallback to maxScrollExtents - if (contentSize != null && contentSize > 0) { - maxExtent = contentSize - constraints.maxWidth; - } else { - maxExtent = widget.controller.position.maxScrollExtent; - } - _viewExtent = constraints.maxWidth; - - break; - } - - final contentExtent = maxExtent + _viewExtent; - // Calculate the alignment for the handle, this is a value between 0 and 1, - // it automatically takes the handle size into acct - // ignore: omit_local_variable_types - double handleAlignment = - maxExtent == 0 ? 0 : widget.controller.offset / maxExtent; - - // Convert handle alignment from [0, 1] to [-1, 1] - handleAlignment *= 2.0; - handleAlignment -= 1.0; - - // Calculate handleSize by comparing the total content size to our viewport - var handleExtent = _viewExtent; - if (contentExtent > _viewExtent) { - // Make sure handle is never small than the minSize - handleExtent = max(60, _viewExtent * _viewExtent / contentExtent); - } - - // Hide the handle if content is < the viewExtent - var showHandle = contentExtent > _viewExtent && contentExtent > 0; - - if (hideHandler) { - showHandle = false; - } - - // Handle color - var handleColor = widget.handleColor ?? - (theme.isDark ? theme.bg2.withOpacity(.2) : theme.bg2); - // Track color - var trackColor = widget.trackColor ?? - (theme.isDark - ? theme.bg2.withOpacity(.1) - : theme.bg2.withOpacity(.3)); - - //Layout the stack, it just contains a child, and - return Stack(children: [ - /// TRACK, thin strip, aligned along the end of the parent - if (widget.showTrack) - Align( - alignment: const Alignment(1, 1), - child: Container( - color: trackColor, - width: widget.axis == Axis.vertical - ? widget.size - : double.infinity, - height: widget.axis == Axis.horizontal - ? widget.size - : double.infinity, - ), - ), - - /// HANDLE - Clickable shape that changes scrollController when dragged - Align( - // Use calculated alignment to position handle from -1 to 1, let Alignment do the rest of the work - alignment: Alignment( - widget.axis == Axis.vertical ? 1 : handleAlignment, - widget.axis == Axis.horizontal ? 1 : handleAlignment, - ), - child: GestureDetector( - onVerticalDragUpdate: _handleVerticalDrag, - onHorizontalDragUpdate: _handleHorizontalDrag, - // HANDLE SHAPE - child: MouseHoverBuilder( - builder: (_, isHovered) => Container( - width: - widget.axis == Axis.vertical ? widget.size : handleExtent, - height: widget.axis == Axis.horizontal - ? widget.size - : handleExtent, - decoration: BoxDecoration( - color: handleColor.withOpacity(isHovered ? 1 : .85), - borderRadius: Corners.s3Border), - ), - ), - ), - ) - ]).opacity(showHandle ? 1.0 : 0.0, animate: true); - }, - ); - } - - void _handleHorizontalDrag(DragUpdateDetails details) { - var pos = widget.controller.offset; - var pxRatio = (widget.controller.position.maxScrollExtent + _viewExtent) / - _viewExtent; - widget.controller.jumpTo((pos + details.delta.dx * pxRatio) - .clamp(0.0, widget.controller.position.maxScrollExtent)); - widget.onDrag?.call(details.delta.dx); - } - - void _handleVerticalDrag(DragUpdateDetails details) { - var pos = widget.controller.offset; - var pxRatio = (widget.controller.position.maxScrollExtent + _viewExtent) / - _viewExtent; - widget.controller.jumpTo((pos + details.delta.dy * pxRatio) - .clamp(0.0, widget.controller.position.maxScrollExtent)); - widget.onDrag?.call(details.delta.dy); - } -} - -class ScrollbarListStack extends StatelessWidget { - final double barSize; - final Axis axis; - final Widget child; - final ScrollController controller; - final double? contentSize; - final EdgeInsets? scrollbarPadding; - final Color? handleColor; - final Color? trackColor; - final bool autoHideScrollbar; - - const ScrollbarListStack( - {Key? key, - required this.barSize, - required this.axis, - required this.child, - required this.controller, - this.contentSize, - this.scrollbarPadding, - this.handleColor, - this.autoHideScrollbar = true, - this.trackColor}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - /// LIST - /// Wrap with a bit of padding on the right - child.padding( - right: axis == Axis.vertical ? barSize + Insets.sm : 0, - bottom: axis == Axis.horizontal ? barSize + Insets.sm : 0, - ), - - /// SCROLLBAR - Padding( - padding: scrollbarPadding ?? EdgeInsets.zero, - child: StyledScrollbar( - size: barSize, - axis: axis, - controller: controller, - contentSize: contentSize, - trackColor: trackColor, - handleColor: handleColor, - autoHideScrollbar: autoHideScrollbar, - ), - ) - // The animate will be used by the children that using styled_widget. - .animate(const Duration(milliseconds: 250), Curves.easeOut), - ], - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart deleted file mode 100644 index b57b4059cf2bf..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'styled_list.dart'; -import 'styled_scroll_bar.dart'; - -class StyledSingleChildScrollView extends StatefulWidget { - final double? contentSize; - final Axis axis; - final Color? trackColor; - final Color? handleColor; - final ScrollController? controller; - final EdgeInsets? scrollbarPadding; - final double barSize; - - final Widget? child; - - const StyledSingleChildScrollView({ - Key? key, - @required this.child, - this.contentSize, - this.axis = Axis.vertical, - this.trackColor, - this.handleColor, - this.controller, - this.scrollbarPadding, - this.barSize = 12, - }) : super(key: key); - - @override - State createState() => - StyledSingleChildScrollViewState(); -} - -class StyledSingleChildScrollViewState - extends State { - late ScrollController scrollController; - - @override - void initState() { - scrollController = widget.controller ?? ScrollController(); - super.initState(); - } - - @override - void dispose() { - // scrollController.dispose(); - super.dispose(); - } - - @override - void didUpdateWidget(StyledSingleChildScrollView oldWidget) { - if (oldWidget.child != widget.child) { - setState(() {}); - } - super.didUpdateWidget(oldWidget); - } - - @override - Widget build(BuildContext context) { - return ScrollbarListStack( - contentSize: widget.contentSize, - axis: widget.axis, - controller: scrollController, - scrollbarPadding: widget.scrollbarPadding, - barSize: widget.barSize, - trackColor: widget.trackColor, - handleColor: widget.handleColor, - child: SingleChildScrollView( - scrollDirection: widget.axis, - physics: StyledScrollPhysics(), - controller: scrollController, - child: widget.child, - ), - ); - } -} - -class StyledCustomScrollView extends StatefulWidget { - final Axis axis; - final Color? trackColor; - final Color? handleColor; - final ScrollController? verticalController; - final List slivers; - final double barSize; - - const StyledCustomScrollView({ - Key? key, - this.axis = Axis.vertical, - this.trackColor, - this.handleColor, - this.verticalController, - this.slivers = const [], - this.barSize = 12, - }) : super(key: key); - - @override - StyledCustomScrollViewState createState() => StyledCustomScrollViewState(); -} - -class StyledCustomScrollViewState extends State { - late ScrollController controller; - - @override - void initState() { - controller = widget.verticalController ?? ScrollController(); - - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - @override - void didUpdateWidget(StyledCustomScrollView oldWidget) { - if (oldWidget.slivers != widget.slivers) { - setState(() {}); - } - super.didUpdateWidget(oldWidget); - } - - @override - Widget build(BuildContext context) { - var child = ScrollConfiguration( - behavior: const ScrollBehavior().copyWith(scrollbars: false), - child: CustomScrollView( - scrollDirection: widget.axis, - physics: StyledScrollPhysics(), - controller: controller, - slivers: widget.slivers, - ), - ); - - return ScrollbarListStack( - axis: widget.axis, - controller: controller, - barSize: widget.barSize, - trackColor: widget.trackColor, - handleColor: widget.handleColor, - child: child, - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/snap_bar.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/snap_bar.dart deleted file mode 100644 index 8c63a330ca337..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/snap_bar.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; - -void showSnapBar(BuildContext context, String title) { - ScaffoldMessenger.of(context).clearSnackBars(); - - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: WillPopScope( - onWillPop: () async { - ScaffoldMessenger.of(context).removeCurrentSnackBar(); - return true; - }, - child: Text( - title, - style: const TextStyle( - color: Colors.black, - ), - ), - ), - ), - ) - .closed - .then((value) => null); -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text.dart deleted file mode 100644 index 874b88ea2eb33..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class FlowyText extends StatelessWidget { - final String title; - final TextOverflow? overflow; - final double fontSize; - final FontWeight fontWeight; - final TextAlign? textAlign; - final int? maxLines; - final Color? color; - - const FlowyText( - this.title, { - Key? key, - this.overflow = TextOverflow.clip, - this.fontSize = 16, - this.fontWeight = FontWeight.w400, - this.textAlign, - this.color, - this.maxLines = 1, - }) : super(key: key); - - const FlowyText.semibold( - this.title, { - Key? key, - this.fontSize = 16, - this.overflow, - this.color, - this.textAlign, - this.maxLines = 1, - }) : fontWeight = FontWeight.w600, - super(key: key); - - const FlowyText.medium( - this.title, { - Key? key, - this.fontSize = 16, - this.overflow, - this.color, - this.textAlign, - this.maxLines = 1, - }) : fontWeight = FontWeight.w500, - super(key: key); - - const FlowyText.regular( - this.title, { - Key? key, - this.fontSize = 16, - this.overflow, - this.color, - this.textAlign, - this.maxLines = 1, - }) : fontWeight = FontWeight.w400, - super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return Text( - title, - maxLines: maxLines, - textAlign: textAlign, - overflow: overflow ?? TextOverflow.clip, - style: TextStyles.general( - fontSize: fontSize, - fontWeight: fontWeight, - color: color ?? theme.textColor, - ), - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text_input.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text_input.dart deleted file mode 100644 index e0d90be399810..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text_input.dart +++ /dev/null @@ -1,354 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -// ignore: import_of_legacy_library_into_null_safe -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -class FlowyFormTextInput extends StatelessWidget { - static EdgeInsets kDefaultTextInputPadding = - EdgeInsets.only(bottom: Insets.sm, top: 4); - - final String? label; - final bool? autoFocus; - final String? initialValue; - final String? hintText; - final EdgeInsets? contentPadding; - final TextStyle? textStyle; - final int? maxLines; - final TextEditingController? controller; - final TextCapitalization? capitalization; - final Function(String)? onChanged; - final Function()? onEditingComplete; - final Function(bool)? onFocusChanged; - final Function(FocusNode)? onFocusCreated; - - const FlowyFormTextInput( - {Key? key, - this.label, - this.autoFocus, - this.initialValue, - this.onChanged, - this.onEditingComplete, - this.hintText, - this.onFocusChanged, - this.onFocusCreated, - this.controller, - this.contentPadding, - this.capitalization, - this.textStyle, - this.maxLines}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return StyledSearchTextInput( - capitalization: capitalization, - label: label, - autoFocus: autoFocus, - initialValue: initialValue, - onChanged: onChanged, - onFocusCreated: onFocusCreated, - style: textStyle ?? TextStyles.body1, - onEditingComplete: onEditingComplete, - onFocusChanged: onFocusChanged, - controller: controller, - maxLines: maxLines, - inputDecoration: InputDecoration( - isDense: true, - contentPadding: contentPadding ?? kDefaultTextInputPadding, - border: const ThinUnderlineBorder( - borderSide: BorderSide(width: 5, color: Colors.red)), - //focusedBorder: UnderlineInputBorder(borderSide: BorderSide(width: .5, color: Colors.red)), - hintText: hintText, - ), - ); - } -} - -class StyledSearchTextInput extends StatefulWidget { - final String? label; - final TextStyle? style; - final EdgeInsets? contentPadding; - final bool? autoFocus; - final bool? obscureText; - final IconData? icon; - final String? initialValue; - final int? maxLines; - final TextEditingController? controller; - final TextCapitalization? capitalization; - final TextInputType? type; - final bool? enabled; - final bool? autoValidate; - final bool? enableSuggestions; - final bool? autoCorrect; - final String? errorText; - final String? hintText; - final Widget? prefixIcon; - final Widget? suffixIcon; - final InputDecoration? inputDecoration; - - final Function(String)? onChanged; - final Function()? onEditingComplete; - final Function()? onEditingCancel; - final Function(bool)? onFocusChanged; - final Function(FocusNode)? onFocusCreated; - final Function(String)? onFieldSubmitted; - final Function(String?)? onSaved; - final VoidCallback? onTap; - - const StyledSearchTextInput({ - Key? key, - this.label, - this.autoFocus = false, - this.obscureText = false, - this.type = TextInputType.text, - this.icon, - this.initialValue = '', - this.controller, - this.enabled, - this.autoValidate = false, - this.enableSuggestions = true, - this.autoCorrect = true, - this.errorText, - this.style, - this.contentPadding, - this.prefixIcon, - this.suffixIcon, - this.inputDecoration, - this.onChanged, - this.onEditingComplete, - this.onEditingCancel, - this.onFocusChanged, - this.onFocusCreated, - this.onFieldSubmitted, - this.onSaved, - this.onTap, - this.hintText, - this.capitalization, - this.maxLines, - }) : super(key: key); - - @override - StyledSearchTextInputState createState() => StyledSearchTextInputState(); -} - -class StyledSearchTextInputState extends State { - late TextEditingController _controller; - late FocusNode _focusNode; - - @override - void initState() { - _controller = - widget.controller ?? TextEditingController(text: widget.initialValue); - _focusNode = FocusNode( - debugLabel: widget.label ?? '', - onKey: (FocusNode node, RawKeyEvent evt) { - if (evt is RawKeyDownEvent) { - if (evt.logicalKey == LogicalKeyboardKey.escape) { - widget.onEditingCancel?.call(); - return KeyEventResult.handled; - } - } - - return KeyEventResult.ignored; - }, - canRequestFocus: true, - ); - // Listen for focus out events - _focusNode - .addListener(() => widget.onFocusChanged?.call(_focusNode.hasFocus)); - widget.onFocusCreated?.call(_focusNode); - if (widget.autoFocus ?? false) { - scheduleMicrotask(() => _focusNode.requestFocus()); - } - super.initState(); - } - - @override - void dispose() { - _controller.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - void clear() => _controller.clear(); - - String get text => _controller.text; - - set text(String value) => _controller.text = value; - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return Container( - padding: EdgeInsets.symmetric(vertical: Insets.sm), - child: TextFormField( - onChanged: widget.onChanged, - onEditingComplete: widget.onEditingComplete, - onFieldSubmitted: widget.onFieldSubmitted, - onSaved: widget.onSaved, - onTap: widget.onTap, - autofocus: widget.autoFocus ?? false, - focusNode: _focusNode, - keyboardType: widget.type, - obscureText: widget.obscureText ?? false, - autocorrect: widget.autoCorrect ?? false, - enableSuggestions: widget.enableSuggestions ?? false, - style: widget.style ?? TextStyles.body1, - cursorColor: theme.main1, - controller: _controller, - showCursor: true, - enabled: widget.enabled, - maxLines: widget.maxLines, - textCapitalization: widget.capitalization ?? TextCapitalization.none, - decoration: widget.inputDecoration ?? - InputDecoration( - prefixIcon: widget.prefixIcon, - suffixIcon: widget.suffixIcon, - contentPadding: - widget.contentPadding ?? EdgeInsets.all(Insets.m), - border: const OutlineInputBorder(borderSide: BorderSide.none), - isDense: true, - icon: widget.icon == null ? null : Icon(widget.icon), - errorText: widget.errorText, - errorMaxLines: 2, - hintText: widget.hintText, - hintStyle: TextStyles.body1.textColor(theme.shader4), - labelText: widget.label), - ), - ); - } -} - -class ThinUnderlineBorder extends InputBorder { - /// Creates an underline border for an [InputDecorator]. - /// - /// The [borderSide] parameter defaults to [BorderSide.none] (it must not be - /// null). Applications typically do not specify a [borderSide] parameter - /// because the input decorator substitutes its own, using [copyWith], based - /// on the current theme and [InputDecorator.isFocused]. - /// - /// The [borderRadius] parameter defaults to a value where the top left - /// and right corners have a circular radius of 4.0. The [borderRadius] - /// parameter must not be null. - const ThinUnderlineBorder({ - BorderSide borderSide = const BorderSide(), - this.borderRadius = const BorderRadius.only( - topLeft: Radius.circular(4.0), - topRight: Radius.circular(4.0), - ), - }) : super(borderSide: borderSide); - - /// The radii of the border's rounded rectangle corners. - /// - /// When this border is used with a filled input decorator, see - /// [InputDecoration.filled], the border radius defines the shape - /// of the background fill as well as the bottom left and right - /// edges of the underline itself. - /// - /// By default the top right and top left corners have a circular radius - /// of 4.0. - final BorderRadius borderRadius; - - @override - bool get isOutline => false; - - @override - UnderlineInputBorder copyWith( - {BorderSide? borderSide, BorderRadius? borderRadius}) { - return UnderlineInputBorder( - borderSide: borderSide ?? this.borderSide, - borderRadius: borderRadius ?? this.borderRadius, - ); - } - - @override - EdgeInsetsGeometry get dimensions { - return EdgeInsets.only(bottom: borderSide.width); - } - - @override - UnderlineInputBorder scale(double t) { - return UnderlineInputBorder(borderSide: borderSide.scale(t)); - } - - @override - Path getInnerPath(Rect rect, {TextDirection? textDirection}) { - return Path() - ..addRect(Rect.fromLTWH(rect.left, rect.top, rect.width, - math.max(0.0, rect.height - borderSide.width))); - } - - @override - Path getOuterPath(Rect rect, {TextDirection? textDirection}) { - return Path()..addRRect(borderRadius.resolve(textDirection).toRRect(rect)); - } - - @override - ShapeBorder? lerpFrom(ShapeBorder? a, double t) { - if (a is UnderlineInputBorder) { - final newBorderRadius = - BorderRadius.lerp(a.borderRadius, borderRadius, t); - - if (newBorderRadius != null) { - return UnderlineInputBorder( - borderSide: BorderSide.lerp(a.borderSide, borderSide, t), - borderRadius: newBorderRadius, - ); - } - } - return super.lerpFrom(a, t); - } - - @override - ShapeBorder? lerpTo(ShapeBorder? b, double t) { - if (b is UnderlineInputBorder) { - final newBorderRadius = - BorderRadius.lerp(b.borderRadius, borderRadius, t); - if (newBorderRadius != null) { - return UnderlineInputBorder( - borderSide: BorderSide.lerp(borderSide, b.borderSide, t), - borderRadius: newBorderRadius, - ); - } - } - return super.lerpTo(b, t); - } - - /// Draw a horizontal line at the bottom of [rect]. - /// - /// The [borderSide] defines the line's color and weight. The `textDirection` - /// `gap` and `textDirection` parameters are ignored. - /// @override - - @override - void paint( - Canvas canvas, - Rect rect, { - double? gapStart, - double gapExtent = 0.0, - double gapPercentage = 0.0, - TextDirection? textDirection, - }) { - if (borderRadius.bottomLeft != Radius.zero || - borderRadius.bottomRight != Radius.zero) { - canvas.clipPath(getOuterPath(rect, textDirection: textDirection)); - } - canvas.drawLine(rect.bottomLeft, rect.bottomRight, borderSide.toPaint()); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other.runtimeType != runtimeType) return false; - return other is InputBorder && other.borderSide == borderSide; - } - - @override - int get hashCode => borderSide.hashCode; -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart deleted file mode 100644 index 64e02922dc61f..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class BaseStyledButton extends StatefulWidget { - final Widget child; - final VoidCallback? onPressed; - final Function(bool)? onFocusChanged; - final Function(bool)? onHighlightChanged; - final Color? bgColor; - final Color? focusColor; - final Color? hoverColor; - final Color? downColor; - final EdgeInsets? contentPadding; - final double? minWidth; - final double? minHeight; - final BorderRadius? borderRadius; - final bool useBtnText; - final bool autoFocus; - - final ShapeBorder? shape; - - final Color outlineColor; - - const BaseStyledButton({ - Key? key, - required this.child, - this.onPressed, - this.onFocusChanged, - this.onHighlightChanged, - this.bgColor, - this.focusColor, - this.contentPadding, - this.minWidth, - this.minHeight, - this.borderRadius, - this.hoverColor, - this.downColor, - this.shape, - this.useBtnText = true, - this.autoFocus = false, - this.outlineColor = Colors.transparent, - }) : super(key: key); - - @override - State createState() => BaseStyledBtnState(); -} - -class BaseStyledBtnState extends State { - late FocusNode _focusNode; - bool _isFocused = false; - - @override - void initState() { - super.initState(); - _focusNode = FocusNode(debugLabel: '', canRequestFocus: true); - _focusNode.addListener(() { - if (_focusNode.hasFocus != _isFocused) { - setState(() => _isFocused = _focusNode.hasFocus); - widget.onFocusChanged?.call(_isFocused); - } - }); - } - - @override - void dispose() { - _focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return Container( - decoration: BoxDecoration( - color: widget.bgColor ?? theme.surface, - borderRadius: widget.borderRadius ?? Corners.s10Border, - boxShadow: _isFocused - ? [ - BoxShadow( - color: theme.shader6, - offset: Offset.zero, - blurRadius: 8.0, - spreadRadius: 0.0), - BoxShadow( - color: widget.bgColor ?? theme.surface, - offset: Offset.zero, - blurRadius: 8.0, - spreadRadius: -4.0), - ] - : [], - ), - foregroundDecoration: _isFocused - ? ShapeDecoration( - shape: RoundedRectangleBorder( - side: BorderSide( - width: 1.8, - color: theme.shader6, - ), - borderRadius: widget.borderRadius ?? Corners.s10Border, - ), - ) - : null, - child: RawMaterialButton( - focusNode: _focusNode, - autofocus: widget.autoFocus, - textStyle: widget.useBtnText ? TextStyles.body1 : null, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - // visualDensity: VisualDensity.compact, - splashColor: Colors.transparent, - mouseCursor: SystemMouseCursors.click, - elevation: 0, - hoverElevation: 0, - highlightElevation: 0, - focusElevation: 0, - fillColor: Colors.transparent, - hoverColor: widget.hoverColor ?? theme.hover, - highlightColor: widget.downColor ?? theme.main1, - focusColor: widget.focusColor ?? Colors.grey.withOpacity(0.35), - constraints: BoxConstraints( - minHeight: widget.minHeight ?? 0, minWidth: widget.minWidth ?? 0), - onPressed: widget.onPressed, - shape: widget.shape ?? - RoundedRectangleBorder( - side: BorderSide(color: widget.outlineColor, width: 1.5), - borderRadius: widget.borderRadius ?? Corners.s10Border, - ), - child: Opacity( - opacity: widget.onPressed != null ? 1 : .7, - child: Padding( - padding: widget.contentPadding ?? EdgeInsets.all(Insets.m), - child: widget.child, - ), - ), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart deleted file mode 100644 index 63e9ce2698644..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme.dart'; -import 'base_styled_button.dart'; - -class PrimaryTextButton extends StatelessWidget { - final String label; - final VoidCallback? onPressed; - final bool bigMode; - - const PrimaryTextButton(this.label, - {Key? key, this.onPressed, this.bigMode = false}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return PrimaryButton( - bigMode: bigMode, - onPressed: onPressed, - child: FlowyText.regular( - label, - color: theme.surface, - ), - ); - } -} - -class PrimaryButton extends StatelessWidget { - final Widget child; - final VoidCallback? onPressed; - final bool bigMode; - - const PrimaryButton( - {Key? key, required this.child, this.onPressed, this.bigMode = false}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BaseStyledButton( - minWidth: bigMode ? 100 : 80, - minHeight: bigMode ? 40 : 38, - contentPadding: EdgeInsets.zero, - bgColor: theme.main1, - hoverColor: theme.main1, - downColor: theme.main1, - borderRadius: bigMode ? Corners.s12Border : Corners.s8Border, - onPressed: onPressed, - child: child, - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart deleted file mode 100644 index ef7a6e6051ecd..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flowy_infra_ui/style_widget/text.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -// ignore: import_of_legacy_library_into_null_safe -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme.dart'; -import 'base_styled_button.dart'; - -class SecondaryTextButton extends StatelessWidget { - final String label; - final VoidCallback? onPressed; - final bool bigMode; - - const SecondaryTextButton(this.label, - {Key? key, this.onPressed, this.bigMode = false}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return SecondaryButton( - bigMode: bigMode, - onPressed: onPressed, - child: FlowyText.regular( - label, - color: theme.main1, - ), - ); - } -} - -class SecondaryButton extends StatelessWidget { - final Widget child; - final VoidCallback? onPressed; - final bool bigMode; - - const SecondaryButton( - {Key? key, required this.child, this.onPressed, this.bigMode = false}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = context.watch(); - return BaseStyledButton( - minWidth: bigMode ? 100 : 80, - minHeight: bigMode ? 40 : 38, - contentPadding: EdgeInsets.zero, - bgColor: theme.shader7, - hoverColor: theme.hover, - downColor: theme.main1, - outlineColor: theme.main1, - borderRadius: bigMode ? Corners.s12Border : Corners.s8Border, - onPressed: onPressed, - child: child, - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/clickable_extension.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/clickable_extension.dart deleted file mode 100644 index 267913fe9ab73..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/clickable_extension.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/material.dart'; - -extension ClickableExtensions on Widget { - Widget clickable(void Function() action, {bool opaque = true}) { - return GestureDetector( - behavior: opaque ? HitTestBehavior.opaque : HitTestBehavior.deferToChild, - onTap: action, - child: MouseRegion( - cursor: SystemMouseCursors.click, - opaque: opaque, - child: this, - ), - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/error_page.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/error_page.dart deleted file mode 100644 index 0b586ce839525..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/error_page.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter/material.dart'; - -class FlowyErrorPage extends StatelessWidget { - final String error; - const FlowyErrorPage(this.error, {Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Text(error); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/rounded_button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/rounded_button.dart deleted file mode 100644 index d336205f51fff..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/rounded_button.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flutter/material.dart'; - -class RoundedTextButton extends StatelessWidget { - final VoidCallback? onPressed; - final String? title; - final double? width; - final double? height; - final BorderRadius borderRadius; - final Color borderColor; - final Color color; - final Color textColor; - final double fontSize; - - const RoundedTextButton({ - Key? key, - this.onPressed, - this.title, - this.width, - this.height, - this.borderRadius = Corners.s12Border, - this.borderColor = Colors.transparent, - this.color = Colors.transparent, - this.textColor = Colors.white, - this.fontSize = 16, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: BoxConstraints( - minWidth: 10, - maxWidth: width ?? double.infinity, - minHeight: 10, - maxHeight: height ?? 60, - ), - child: Container( - decoration: BoxDecoration( - border: Border.all(color: borderColor), - borderRadius: borderRadius, - color: color, - ), - child: SizedBox.expand( - child: TextButton( - onPressed: onPressed, - child: Text( - title ?? '', - style: TextStyles.general( - fontSize: fontSize, - color: textColor, - ), - ), - ), - ), - ), - ); - } -} - -class RoundedImageButton extends StatelessWidget { - final VoidCallback? press; - final double size; - final BorderRadius borderRadius; - final Color borderColor; - final Color color; - final Widget child; - - const RoundedImageButton({ - Key? key, - this.press, - required this.size, - this.borderRadius = BorderRadius.zero, - this.borderColor = Colors.transparent, - this.color = Colors.transparent, - required this.child, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: size, - height: size, - child: TextButton( - onPressed: press, - style: ButtonStyle( - shape: MaterialStateProperty.all( - RoundedRectangleBorder(borderRadius: borderRadius))), - child: child, - ), - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/rounded_input_field.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/rounded_input_field.dart deleted file mode 100644 index 9dec787c2e55d..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/rounded_input_field.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/text_style.dart'; -import 'package:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flutter/material.dart'; -import 'package:flowy_infra/time/duration.dart'; -import 'package:flutter/services.dart'; -import 'package:textstyle_extensions/textstyle_extensions.dart'; - -class RoundedInputField extends StatefulWidget { - final String? hintText; - final bool obscureText; - final Widget? obscureIcon; - final Widget? obscureHideIcon; - final Color normalBorderColor; - final Color errorBorderColor; - final Color cursorColor; - final Color? focusBorderColor; - final String errorText; - final TextStyle style; - final ValueChanged? onChanged; - final Function(String)? onEditingComplete; - final String? initialValue; - final EdgeInsets margin; - final EdgeInsets padding; - final EdgeInsets contentPadding; - final double height; - final FocusNode? focusNode; - final TextEditingController? controller; - final bool autoFocus; - final int? maxLength; - - const RoundedInputField({ - Key? key, - this.hintText, - this.errorText = "", - this.initialValue, - this.obscureText = false, - this.obscureIcon, - this.obscureHideIcon, - this.onChanged, - this.onEditingComplete, - this.normalBorderColor = Colors.transparent, - this.errorBorderColor = Colors.transparent, - this.focusBorderColor, - this.cursorColor = Colors.black, - this.style = const TextStyle(fontSize: 20, fontWeight: FontWeight.w500), - this.margin = EdgeInsets.zero, - this.padding = EdgeInsets.zero, - this.contentPadding = const EdgeInsets.symmetric(horizontal: 10), - this.height = 48, - this.focusNode, - this.controller, - this.autoFocus = false, - this.maxLength, - }) : super(key: key); - - @override - State createState() => _RoundedInputFieldState(); -} - -class _RoundedInputFieldState extends State { - String inputText = ""; - bool obscuteText = false; - - @override - void initState() { - obscuteText = widget.obscureText; - if (widget.controller != null) { - inputText = widget.controller!.text; - } else { - inputText = widget.initialValue ?? ""; - } - - super.initState(); - } - - @override - Widget build(BuildContext context) { - var borderColor = widget.normalBorderColor; - var focusBorderColor = widget.focusBorderColor ?? borderColor; - - if (widget.errorText.isNotEmpty) { - borderColor = widget.errorBorderColor; - focusBorderColor = borderColor; - } - - List children = [ - Container( - margin: widget.margin, - padding: widget.padding, - height: widget.height, - child: TextFormField( - controller: widget.controller, - initialValue: widget.initialValue, - focusNode: widget.focusNode, - autofocus: widget.autoFocus, - maxLength: widget.maxLength, - maxLengthEnforcement: - MaxLengthEnforcement.truncateAfterCompositionEnds, - onChanged: (value) { - inputText = value; - if (widget.onChanged != null) { - widget.onChanged!(value); - } - setState(() {}); - }, - onEditingComplete: () { - if (widget.onEditingComplete != null) { - widget.onEditingComplete!(inputText); - } - }, - cursorColor: widget.cursorColor, - obscureText: obscuteText, - style: widget.style, - decoration: InputDecoration( - contentPadding: widget.contentPadding, - hintText: widget.hintText, - hintStyle: TextStyles.body1.textColor(widget.normalBorderColor), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: borderColor, - width: 1.0, - ), - borderRadius: Corners.s10Border, - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: focusBorderColor, - width: 1.0, - ), - borderRadius: Corners.s10Border, - ), - suffixIcon: obscureIcon(), - ), - ), - ), - ]; - - if (widget.errorText.isNotEmpty) { - children.add( - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - widget.errorText, - style: widget.style, - ), - ), - ), - ); - } - - return AnimatedSize( - duration: .4.seconds, - curve: Curves.easeInOut, - child: Column(children: children), - ); - } - - Widget? obscureIcon() { - if (widget.obscureText == false) { - return null; - } - - const double iconWidth = 16; - if (inputText.isEmpty) { - return SizedBox.fromSize(size: const Size.square(iconWidth)); - } - - assert(widget.obscureIcon != null && widget.obscureHideIcon != null); - Widget? icon; - if (obscuteText) { - icon = widget.obscureIcon!; - } else { - icon = widget.obscureHideIcon!; - } - - return RoundedImageButton( - size: iconWidth, - press: () { - obscuteText = !obscuteText; - setState(() {}); - }, - child: icon, - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/seperated_column.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/seperated_column.dart deleted file mode 100644 index 7362f989e5e2d..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/seperated_column.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; - -typedef SeparatorBuilder = Widget Function(); - -class SeparatedColumn extends StatelessWidget { - final List children; - final SeparatorBuilder? separatorBuilder; - final MainAxisAlignment mainAxisAlignment; - final CrossAxisAlignment crossAxisAlignment; - final MainAxisSize mainAxisSize; - final TextBaseline? textBaseline; - final TextDirection? textDirection; - final VerticalDirection verticalDirection; - - const SeparatedColumn({ - Key? key, - required this.children, - this.separatorBuilder, - this.mainAxisAlignment = MainAxisAlignment.start, - this.crossAxisAlignment = CrossAxisAlignment.center, - this.mainAxisSize = MainAxisSize.max, - this.verticalDirection = VerticalDirection.down, - this.textBaseline, - this.textDirection, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - var c = children.toList(); - for (var i = c.length; i-- > 0;) { - if (i > 0 && separatorBuilder != null) c.insert(i, separatorBuilder!()); - } - return Column( - mainAxisAlignment: mainAxisAlignment, - crossAxisAlignment: crossAxisAlignment, - mainAxisSize: mainAxisSize, - textBaseline: textBaseline, - textDirection: textDirection, - verticalDirection: verticalDirection, - children: c, - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/spacing.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/spacing.dart deleted file mode 100644 index e2f90460155ef..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/spacing.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/cupertino.dart'; - -class Space extends StatelessWidget { - final double width; - final double height; - - const Space(this.width, this.height, {Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) => SizedBox(width: width, height: height); -} - -class VSpace extends StatelessWidget { - final double size; - - const VSpace(this.size, {Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) => Space(0, size); -} - -class HSpace extends StatelessWidget { - final double size; - - const HSpace(this.size, {Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) => Space(size, 0); -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/pubspec.lock b/frontend/app_flowy/packages/flowy_infra_ui/pubspec.lock deleted file mode 100644 index 04805bd70746a..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/pubspec.lock +++ /dev/null @@ -1,334 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - animations: - dependency: "direct main" - description: - name: animations - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - appflowy_popover: - dependency: "direct main" - description: - path: "../appflowy_popover" - relative: true - source: path - version: "0.0.1" - async: - dependency: "direct main" - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.8.2" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.16.0" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - dartz: - dependency: "direct main" - description: - name: dartz - url: "https://pub.dartlang.org" - source: hosted - version: "0.10.0-nullsafety.2" - equatable: - dependency: "direct main" - description: - name: equatable - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.3" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - flowy_infra: - dependency: "direct main" - description: - path: "../flowy_infra" - relative: true - source: path - version: "0.0.1" - flowy_infra_ui_platform_interface: - dependency: "direct main" - description: - path: flowy_infra_ui_platform_interface - relative: true - source: path - version: "0.0.1" - flowy_infra_ui_web: - dependency: "direct main" - description: - path: flowy_infra_ui_web - relative: true - source: path - version: "0.0.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - flutter_svg: - dependency: transitive - description: - name: flutter_svg - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.4" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - js: - dependency: transitive - description: - name: js - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.4" - lint: - dependency: transitive - description: - name: lint - url: "https://pub.dartlang.org" - source: hosted - version: "1.5.3" - lints: - dependency: transitive - description: - name: lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - loading_indicator: - dependency: "direct main" - description: - name: loading_indicator - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.11" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.4" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - nested: - dependency: transitive - description: - name: nested - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.1" - path_drawing: - dependency: transitive - description: - name: path_drawing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - petitparser: - dependency: transitive - description: - name: petitparser - url: "https://pub.dartlang.org" - source: hosted - version: "5.0.0" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - provider: - dependency: "direct main" - description: - name: provider - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - styled_widget: - dependency: "direct main" - description: - name: styled_widget - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.1+2" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.9" - textstyle_extensions: - dependency: "direct main" - description: - name: textstyle_extensions - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0-nullsafety" - time: - dependency: transitive - description: - name: time - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - uuid: - dependency: transitive - description: - name: uuid - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.4" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - xml: - dependency: transitive - description: - name: xml - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.0" -sdks: - dart: ">=2.17.0 <3.0.0" - flutter: ">=2.11.0-0.1.pre" diff --git a/frontend/app_flowy/packages/flowy_infra_ui/pubspec.yaml b/frontend/app_flowy/packages/flowy_infra_ui/pubspec.yaml deleted file mode 100644 index 59d0c48b7e6e0..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/pubspec.yaml +++ /dev/null @@ -1,56 +0,0 @@ -name: flowy_infra_ui -description: A new flutter plugin project. -version: 0.0.1 -homepage: -publish_to: "none" - -environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.20.0" - -dependencies: - flutter: - sdk: flutter - - # Thirdparty packages - textstyle_extensions: "2.0.0-nullsafety" - dartz: - provider: ^6.0.1 - styled_widget: "^0.3.1" - equatable: "^2.0.3" - animations: ^2.0.0 - loading_indicator: ^3.0.1 - async: - - # Federated Platform Interface - flowy_infra_ui_platform_interface: - path: flowy_infra_ui_platform_interface - flowy_infra_ui_web: - path: flowy_infra_ui_web - appflowy_popover: - path: ../appflowy_popover - flowy_infra: - path: ../flowy_infra - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.1 - -flutter: - plugin: - platforms: - # TODO: uncomment android part will fail the Linux build process, will resolve later - # android: - # package: com.example.flowy_infra_ui - # pluginClass: FlowyInfraUIPlugin - ios: - pluginClass: FlowyInfraUIPlugin - macos: - pluginClass: FlowyInfraUIPlugin - windows: - pluginClass: FlowyInfraUIPlugin - linux: - pluginClass: FlowyInfraUIPlugin - web: - default_package: flowy_infra_ui_web diff --git a/frontend/app_flowy/packages/flowy_infra_ui/test/flowy_infra_ui_test.dart b/frontend/app_flowy/packages/flowy_infra_ui/test/flowy_infra_ui_test.dart deleted file mode 100644 index c1297b4c77e9f..0000000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/test/flowy_infra_ui_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - const MethodChannel channel = MethodChannel('flowy_infra_ui'); - - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() { - channel.setMockMethodCallHandler((MethodCall methodCall) async { - return '42'; - }); - }); - - tearDown(() { - channel.setMockMethodCallHandler(null); - }); - - test('getPlatformVersion', () async {}); -} diff --git a/frontend/app_flowy/packages/flowy_sdk/.metadata b/frontend/app_flowy/packages/flowy_sdk/.metadata deleted file mode 100644 index 632500f793a47..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# 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: 63062a64432cce03315d6b5196fda7912866eb37 - channel: dev - -project_type: plugin diff --git a/frontend/app_flowy/packages/flowy_sdk/README.md b/frontend/app_flowy/packages/flowy_sdk/README.md deleted file mode 100644 index c4f14b4b44ff2..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# flowy_sdk - -A new flutter plugin project. - -## Getting Started - -This project is a starting point for a Flutter -[plug-in package](https://flutter.dev/developing-packages/), -a specialized package that includes platform-specific implementation code for -Android and/or iOS. - -For help getting started with Flutter, view our -[online documentation](https://flutter.dev/docs), which offers tutorials, -samples, guidance on mobile development, and a full API reference. - -The plugin project was generated without specifying the `--platforms` flag, no platforms are currently supported. -To add platforms, run `flutter create -t plugin --platforms .` under the same -directory. You can also find a detailed instruction on how to add platforms in the `pubspec.yaml` at https://flutter.dev/docs/development/packages-and-plugins/developing-packages#plugin-platforms. diff --git a/frontend/app_flowy/packages/flowy_sdk/android/build.gradle b/frontend/app_flowy/packages/flowy_sdk/android/build.gradle deleted file mode 100644 index f28d45b3a1845..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/android/build.gradle +++ /dev/null @@ -1,40 +0,0 @@ -group 'com.plugin.flowy_sdk' -version '1.0-SNAPSHOT' - -buildscript { - ext.kotlin_version = '1.6.10' - repositories { - google() - jcenter() - } - - dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -rootProject.allprojects { - repositories { - google() - jcenter() - } -} - -apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' - -android { - compileSdkVersion 31 - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - defaultConfig { - minSdkVersion 16 - } -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/frontend/app_flowy/packages/flowy_sdk/android/gradle.properties b/frontend/app_flowy/packages/flowy_sdk/android/gradle.properties deleted file mode 100644 index 94adc3a3f97aa..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/android/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M -android.useAndroidX=true -android.enableJetifier=true diff --git a/frontend/app_flowy/packages/flowy_sdk/android/settings.gradle b/frontend/app_flowy/packages/flowy_sdk/android/settings.gradle deleted file mode 100644 index fab0d5f031408..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'flowy_sdk' diff --git a/frontend/app_flowy/packages/flowy_sdk/android/src/main/AndroidManifest.xml b/frontend/app_flowy/packages/flowy_sdk/android/src/main/AndroidManifest.xml deleted file mode 100644 index 8aaf86b5f537f..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/frontend/app_flowy/packages/flowy_sdk/android/src/main/kotlin/com/plugin/flowy_sdk/FlowySdkPlugin.kt b/frontend/app_flowy/packages/flowy_sdk/android/src/main/kotlin/com/plugin/flowy_sdk/FlowySdkPlugin.kt deleted file mode 100644 index bac76ffca2428..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/android/src/main/kotlin/com/plugin/flowy_sdk/FlowySdkPlugin.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.plugin.flowy_sdk - -import androidx.annotation.NonNull - -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result -import io.flutter.plugin.common.PluginRegistry.Registrar - -/** FlowySdkPlugin */ -class FlowySdkPlugin: FlutterPlugin, MethodCallHandler { - /// The MethodChannel that will the communication between Flutter and native Android - /// - /// This local reference serves to register the plugin with the Flutter Engine and unregister it - /// when the Flutter Engine is detached from the Activity - private lateinit var channel : MethodChannel - - override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - channel = MethodChannel(flutterPluginBinding.binaryMessenger, "flowy_sdk") - channel.setMethodCallHandler(this) - } - - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - if (call.method == "getPlatformVersion") { - result.success("Android ${android.os.Build.VERSION.RELEASE}") - } else { - result.notImplemented() - } - } - - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { - channel.setMethodCallHandler(null) - } -} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/README.md b/frontend/app_flowy/packages/flowy_sdk/example/README.md deleted file mode 100644 index 6b1eb3ecae0f1..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# flowy_sdk_example - -Demonstrates how to use the flowy_sdk plugin. - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) - -For help getting started with Flutter, view our -[online documentation](https://flutter.dev/docs), which offers tutorials, -samples, guidance on mobile development, and a full API reference. diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/build.gradle b/frontend/app_flowy/packages/flowy_sdk/example/android/app/build.gradle deleted file mode 100644 index aa5daf1e9d0b3..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/android/app/build.gradle +++ /dev/null @@ -1,59 +0,0 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -android { - compileSdkVersion 30 - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.plugin.flowy_sdk_example" - minSdkVersion 16 - targetSdkVersion 31 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig signingConfigs.debug - } - } -} - -flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/drawable-v21/launch_background.xml b/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f3f6a2b..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/drawable/launch_background.xml b/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f884201..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index db77bb4b7b090..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 17987b79bb8a3..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 09d4391482be6..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index d5f1c8d34e7a8..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 4d6372eebdb28..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/gradle.properties b/frontend/app_flowy/packages/flowy_sdk/example/android/gradle.properties deleted file mode 100644 index 94adc3a3f97aa..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/android/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M -android.useAndroidX=true -android.enableJetifier=true diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/settings.gradle b/frontend/app_flowy/packages/flowy_sdk/example/android/settings.gradle deleted file mode 100644 index 44e62bcf06ae6..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/android/settings.gradle +++ /dev/null @@ -1,11 +0,0 @@ -include ':app' - -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() - -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } - -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Podfile b/frontend/app_flowy/packages/flowy_sdk/example/ios/Podfile deleted file mode 100644 index 1e8c3c90a55e3..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Podfile +++ /dev/null @@ -1,41 +0,0 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '9.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a16ed0f1..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003d68d..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5ea15f..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a16ed0f1..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003d68d..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5ea15f..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/AppDelegate.swift b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/AppDelegate.swift deleted file mode 100644 index 70693e4a8c128..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import UIKit -import Flutter - -@UIApplicationMain -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fab2d9de..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada4725e9b..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 28c6bf03016f6..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 2ccbfd967d969..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index f091b6b0bca85..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cde12118dda4..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index d0ef06e7edb86..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index dcdc2306c2850..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 2ccbfd967d969..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index c8f9ed8f5cee1..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index a6d6b8609df07..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index a6d6b8609df07..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index 75b2d164a5a98..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index c4df70d39da79..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 6a84f41e14e27..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index d0e1f58536026..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2fd4678..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19eacad3b0..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19eacad3b0..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19eacad3b0..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b70f18..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c7c9390..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Base.lproj/Main.storyboard b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c28516fb38e..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Runner-Bridging-Header.h b/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a560b42f..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/frontend/app_flowy/packages/flowy_sdk/example/lib/main.dart b/frontend/app_flowy/packages/flowy_sdk/example/lib/main.dart deleted file mode 100644 index df64a3b08c584..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/lib/main.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter/material.dart'; -import 'dart:async'; - -import 'package:flutter/services.dart'; -import 'package:flowy_sdk/flowy_sdk.dart'; - -void main() { - runApp(MyApp()); -} - -class MyApp extends StatefulWidget { - @override - _MyAppState createState() => _MyAppState(); -} - -class _MyAppState extends State { - String _platformVersion = 'Unknown'; - - @override - void initState() { - super.initState(); - initPlatformState(); - } - - // Platform messages are asynchronous, so we initialize in an async method. - Future initPlatformState() async { - String platformVersion; - // Platform messages may fail, so we use a try/catch PlatformException. - try { - platformVersion = await FlowySDK.platformVersion; - } on PlatformException { - platformVersion = 'Failed to get platform version.'; - } - - // If the widget was removed from the tree while the asynchronous platform - // message was in flight, we want to discard the reply rather than calling - // setState to update our non-existent appearance. - if (!mounted) return; - setState(() { - _platformVersion = platformVersion; - }); - } - - @override - Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - appBar: AppBar( - title: const Text('Plugin example app'), - ), - body: Center( - child: Text('Running on: $_platformVersion\n'), - ), - ), - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/app_flowy/packages/flowy_sdk/example/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 7f32fa30c5e78..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import flowy_sdk - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FlowySdkPlugin.register(with: registry.registrar(forPlugin: "FlowySdkPlugin")) -} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Podfile b/frontend/app_flowy/packages/flowy_sdk/example/macos/Podfile deleted file mode 100644 index d60ec71028444..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Podfile +++ /dev/null @@ -1,82 +0,0 @@ -platform :osx, '10.11' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def parse_KV_file(file, separator='=') - file_abs_path = File.expand_path(file) - if !File.exists? file_abs_path - return []; - end - pods_ary = [] - skip_line_start_symbols = ["#", "/"] - File.foreach(file_abs_path) { |line| - next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } - plugin = line.split(pattern=separator) - if plugin.length == 2 - podname = plugin[0].strip() - path = plugin[1].strip() - podpath = File.expand_path("#{path}", file_abs_path) - pods_ary.push({:name => podname, :path => podpath}); - else - puts "Invalid plugin specification: #{line}" - end - } - return pods_ary -end - -def pubspec_supports_macos(file) - file_abs_path = File.expand_path(file) - if !File.exists? file_abs_path - return false; - end - File.foreach(file_abs_path) { |line| - return true if line =~ /^\s*macos:/ - } - return false -end - -target 'Runner' do - use_frameworks! - use_modular_headers! - - # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock - # referring to absolute paths on developers' machines. - ephemeral_dir = File.join('Flutter', 'ephemeral') - symlink_dir = File.join(ephemeral_dir, '.symlinks') - symlink_plugins_dir = File.join(symlink_dir, 'plugins') - system("rm -rf #{symlink_dir}") - system("mkdir -p #{symlink_plugins_dir}") - - # Flutter Pods - generated_xcconfig = parse_KV_file(File.join(ephemeral_dir, 'Flutter-Generated.xcconfig')) - if generated_xcconfig.empty? - puts "Flutter-Generated.xcconfig must exist. If you're running pod install manually, make sure flutter packages get is executed first." - end - generated_xcconfig.map { |p| - if p[:name] == 'FLUTTER_FRAMEWORK_DIR' - symlink = File.join(symlink_dir, 'flutter') - File.symlink(File.dirname(p[:path]), symlink) - pod 'FlutterMacOS', :path => File.join(symlink, File.basename(p[:path])) - end - } - - # Plugin Pods - plugin_pods = parse_KV_file('../.flutter-plugins') - plugin_pods.map { |p| - symlink = File.join(symlink_plugins_dir, p[:name]) - File.symlink(p[:path], symlink) - if pubspec_supports_macos(File.join(symlink, 'pubspec.yaml')) - pod p[:name], :path => File.join(symlink, 'macos') - end - } -end - -# Prevent Cocoapods from embedding a second Flutter framework and causing an error with the new Xcode build system. -install! 'cocoapods', :disable_input_output_paths => true diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Podfile.lock b/frontend/app_flowy/packages/flowy_sdk/example/macos/Podfile.lock deleted file mode 100644 index bae4dc357866c..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Podfile.lock +++ /dev/null @@ -1,39 +0,0 @@ -PODS: - - flowy_sqlite (0.0.1): - - FlutterMacOS - - flowy_sdk (0.0.1): - - FlutterMacOS - - FlutterMacOS (1.0.0) - - path_provider (0.0.1) - - path_provider_macos (0.0.1): - - FlutterMacOS - -DEPENDENCIES: - - flowy_sqlite (from `Flutter/ephemeral/.symlinks/plugins/flowy_sqlite/macos`) - - flowy_sdk (from `Flutter/ephemeral/.symlinks/plugins/flowy_sdk/macos`) - - FlutterMacOS (from `Flutter/ephemeral/.symlinks/flutter/darwin-x64`) - - path_provider (from `Flutter/ephemeral/.symlinks/plugins/path_provider/macos`) - - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) - -EXTERNAL SOURCES: - flowy_sqlite: - :path: Flutter/ephemeral/.symlinks/plugins/flowy_sqlite/macos - flowy_sdk: - :path: Flutter/ephemeral/.symlinks/plugins/flowy_sdk/macos - FlutterMacOS: - :path: Flutter/ephemeral/.symlinks/flutter/darwin-x64 - path_provider: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider/macos - path_provider_macos: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos - -SPEC CHECKSUMS: - flowy_sqlite: 6638deea1ca0baeef75156d16236123d4703161d - flowy_sdk: 12d2c047ed260a0aa8788a0b9616da46e2312025 - FlutterMacOS: 15bea8a44d2fa024068daa0140371c020b4b6ff9 - path_provider: e0848572d1d38b9a7dd099e79cf83f5b7e2cde9f - path_provider_macos: a0a3fd666cb7cd0448e936fb4abad4052961002b - -PODFILE CHECKSUM: d8ba9b3e9e93c62c74a660b46c6fcb09f03991a7 - -COCOAPODS: 1.10.1 diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003d68d..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 21a3cc14c74e9..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003d68d..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/AppDelegate.swift b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/AppDelegate.swift deleted file mode 100644 index d53ef6437726b..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/AppDelegate.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Cocoa -import FlutterMacOS - -@NSApplicationMain -class AppDelegate: FlutterAppDelegate { - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } -} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index a2ec33f19f110..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" - }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png deleted file mode 100644 index 3c4935a7ca84f..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png deleted file mode 100644 index ed4cc16421680..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png deleted file mode 100644 index 483be61389733..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png deleted file mode 100644 index bcbf36df2f2aa..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png deleted file mode 100644 index 9c0a652864769..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png deleted file mode 100644 index e71a726136a47..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png deleted file mode 100644 index 8a31fe2dd3f91..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Configs/Debug.xcconfig b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Configs/Debug.xcconfig deleted file mode 100644 index 36b0fd9464f45..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Configs/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Debug.xcconfig" -#include "Warnings.xcconfig" diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Configs/Release.xcconfig b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Configs/Release.xcconfig deleted file mode 100644 index dff4f49561c81..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Configs/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Release.xcconfig" -#include "Warnings.xcconfig" diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Configs/Warnings.xcconfig b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Configs/Warnings.xcconfig deleted file mode 100644 index 42bcbf4780b18..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Configs/Warnings.xcconfig +++ /dev/null @@ -1,13 +0,0 @@ -WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings -GCC_WARN_UNDECLARED_SELECTOR = YES -CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES -CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE -CLANG_WARN__DUPLICATE_METHOD_MATCH = YES -CLANG_WARN_PRAGMA_PACK = YES -CLANG_WARN_STRICT_PROTOTYPES = YES -CLANG_WARN_COMMA = YES -GCC_WARN_STRICT_SELECTOR_MATCH = YES -CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES -CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES -GCC_WARN_SHADOW = YES -CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/DebugProfile.entitlements b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/DebugProfile.entitlements deleted file mode 100644 index dddb8a30c851e..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Info.plist b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Info.plist deleted file mode 100644 index 4789daa6a443e..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Info.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/MainFlutterWindow.swift b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/MainFlutterWindow.swift deleted file mode 100644 index 2722837ec9181..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/MainFlutterWindow.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Cocoa -import FlutterMacOS - -class MainFlutterWindow: NSWindow { - override func awakeFromNib() { - let flutterViewController = FlutterViewController.init() - let windowFrame = self.frame - self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) - - RegisterGeneratedPlugins(registry: flutterViewController) - - super.awakeFromNib() - } -} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Release.entitlements b/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Release.entitlements deleted file mode 100644 index 852fa1a4728ae..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Release.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.app-sandbox - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/pubspec.lock b/frontend/app_flowy/packages/flowy_sdk/example/pubspec.lock deleted file mode 100644 index 4caaa46dea37f..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/pubspec.lock +++ /dev/null @@ -1,309 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - archive: - dependency: transitive - description: - name: archive - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.11" - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.8.2" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.16.0" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.2" - dartz: - dependency: transitive - description: - name: dartz - url: "https://pub.dartlang.org" - source: hosted - version: "0.10.1" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - ffi: - dependency: transitive - description: - name: ffi - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - file: - dependency: transitive - description: - name: file - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.2" - fixnum: - dependency: transitive - description: - name: fixnum - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - flowy_sdk: - dependency: "direct main" - description: - path: ".." - relative: true - source: path - version: "0.0.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_driver: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - freezed_annotation: - dependency: transitive - description: - name: freezed_annotation - url: "https://pub.dartlang.org" - source: hosted - version: "0.14.1" - fuchsia_remote_debug_protocol: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - integration_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - isolates: - dependency: transitive - description: - name: isolates - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.3+8" - json_annotation: - dependency: transitive - description: - name: json_annotation - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.1" - lints: - dependency: transitive - description: - name: lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - logger: - dependency: transitive - description: - name: logger - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.11" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.4" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.1" - platform: - dependency: transitive - description: - name: platform - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.0" - process: - dependency: transitive - description: - name: process - url: "https://pub.dartlang.org" - source: hosted - version: "4.2.4" - protobuf: - dependency: transitive - description: - name: protobuf - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - sync_http: - dependency: transitive - description: - name: sync_http - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.9" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - vm_service: - dependency: transitive - description: - name: vm_service - url: "https://pub.dartlang.org" - source: hosted - version: "8.2.2" - webdriver: - dependency: transitive - description: - name: webdriver - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.0" -sdks: - dart: ">=2.17.0 <3.0.0" - flutter: ">=1.17.0" diff --git a/frontend/app_flowy/packages/flowy_sdk/example/pubspec.yaml b/frontend/app_flowy/packages/flowy_sdk/example/pubspec.yaml deleted file mode 100644 index 954a50e831cbd..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/pubspec.yaml +++ /dev/null @@ -1,74 +0,0 @@ -name: flowy_sdk_example -description: Demonstrates how to use the flowy_sdk plugin. - -# The following line prevents the package from being accidentally published to -# pub.dev using `pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -environment: - sdk: ">=2.7.0 <3.0.0" - -dependencies: - flutter: - sdk: flutter - - flowy_sdk: - # When depending on this package from a real application you should use: - # flowy_sdk: ^x.y.z - # See https://dart.dev/tools/pub/dependencies#version-constraints - # The example app is bundled with the plugin so we use a path dependency on - # the parent directory to use the current plugin's version. - path: ../ - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.1 - -dev_dependencies: - flutter_test: - sdk: flutter - integration_test: - sdk: flutter - flutter_lints: ^2.0.1 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/.gitignore b/frontend/app_flowy/packages/flowy_sdk/example/windows/.gitignore deleted file mode 100644 index d492d0d98c8fd..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/windows/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -flutter/ephemeral/ - -# Visual Studio user-specific files. -*.suo -*.user -*.userosscache -*.sln.docstates - -# Visual Studio build-related files. -x64/ -x86/ - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/packages/flowy_sdk/example/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 64e66a15ea1f2..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,14 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - FlowySdkPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FlowySdkPlugin")); -} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/flutter/generated_plugin_registrant.h b/frontend/app_flowy/packages/flowy_sdk/example/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d85a9310..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/flutter/generated_plugins.cmake b/frontend/app_flowy/packages/flowy_sdk/example/windows/flutter/generated_plugins.cmake deleted file mode 100644 index 33c0fd0169454..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,24 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - flowy_sdk -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/Runner.rc b/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/Runner.rc deleted file mode 100644 index ca566ff641b1b..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/Runner.rc +++ /dev/null @@ -1,121 +0,0 @@ -// Microsoft Visual C++ generated resource script. -// -#pragma code_page(65001) -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_APP_ICON ICON "resources\\app_icon.ico" - - -///////////////////////////////////////////////////////////////////////////// -// -// Version -// - -#ifdef FLUTTER_BUILD_NUMBER -#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER -#else -#define VERSION_AS_NUMBER 1,0,0 -#endif - -#ifdef FLUTTER_BUILD_NAME -#define VERSION_AS_STRING #FLUTTER_BUILD_NAME -#else -#define VERSION_AS_STRING "1.0.0" -#endif - -VS_VERSION_INFO VERSIONINFO - FILEVERSION VERSION_AS_NUMBER - PRODUCTVERSION VERSION_AS_NUMBER - FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG - FILEFLAGS VS_FF_DEBUG -#else - FILEFLAGS 0x0L -#endif - FILEOS VOS__WINDOWS32 - FILETYPE VFT_APP - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904e4" - BEGIN - VALUE "CompanyName", "com.plugin" "\0" - VALUE "FileDescription", "Demonstrates how to use the flowy_sdk plugin." "\0" - VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "flowy_sdk_example" "\0" - VALUE "LegalCopyright", "Copyright (C) 2021 com.plugin. All rights reserved." "\0" - VALUE "OriginalFilename", "flowy_sdk_example.exe" "\0" - VALUE "ProductName", "flowy_sdk_example" "\0" - VALUE "ProductVersion", VERSION_AS_STRING "\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/flutter_window.cpp b/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/flutter_window.cpp deleted file mode 100644 index b43b9095ea3aa..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/flutter_window.cpp +++ /dev/null @@ -1,61 +0,0 @@ -#include "flutter_window.h" - -#include - -#include "flutter/generated_plugin_registrant.h" - -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} - -FlutterWindow::~FlutterWindow() {} - -bool FlutterWindow::OnCreate() { - if (!Win32Window::OnCreate()) { - return false; - } - - RECT frame = GetClientArea(); - - // The size here must match the window dimensions to avoid unnecessary surface - // creation / destruction in the startup path. - flutter_controller_ = std::make_unique( - frame.right - frame.left, frame.bottom - frame.top, project_); - // Ensure that basic setup of the controller was successful. - if (!flutter_controller_->engine() || !flutter_controller_->view()) { - return false; - } - RegisterPlugins(flutter_controller_->engine()); - SetChildContent(flutter_controller_->view()->GetNativeWindow()); - return true; -} - -void FlutterWindow::OnDestroy() { - if (flutter_controller_) { - flutter_controller_ = nullptr; - } - - Win32Window::OnDestroy(); -} - -LRESULT -FlutterWindow::MessageHandler(HWND hwnd, UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - // Give Flutter, including plugins, an opportunity to handle window messages. - if (flutter_controller_) { - std::optional result = - flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, - lparam); - if (result) { - return *result; - } - } - - switch (message) { - case WM_FONTCHANGE: - flutter_controller_->engine()->ReloadSystemFonts(); - break; - } - - return Win32Window::MessageHandler(hwnd, message, wparam, lparam); -} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/flutter_window.h b/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/flutter_window.h deleted file mode 100644 index 6da0652f05f28..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/flutter_window.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef RUNNER_FLUTTER_WINDOW_H_ -#define RUNNER_FLUTTER_WINDOW_H_ - -#include -#include - -#include - -#include "win32_window.h" - -// A window that does nothing but host a Flutter view. -class FlutterWindow : public Win32Window { - public: - // Creates a new FlutterWindow hosting a Flutter view running |project|. - explicit FlutterWindow(const flutter::DartProject& project); - virtual ~FlutterWindow(); - - protected: - // Win32Window: - bool OnCreate() override; - void OnDestroy() override; - LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, - LPARAM const lparam) noexcept override; - - private: - // The project to run. - flutter::DartProject project_; - - // The Flutter instance hosted by this window. - std::unique_ptr flutter_controller_; -}; - -#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/resource.h b/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/resource.h deleted file mode 100644 index 66a65d1e4a79f..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/resource.h +++ /dev/null @@ -1,16 +0,0 @@ -//{{NO_DEPENDENCIES}} -// Microsoft Visual C++ generated include file. -// Used by Runner.rc -// -#define IDI_APP_ICON 101 - -// Next default values for new objects -// -#ifdef APSTUDIO_INVOKED -#ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 102 -#define _APS_NEXT_COMMAND_VALUE 40001 -#define _APS_NEXT_CONTROL_VALUE 1001 -#define _APS_NEXT_SYMED_VALUE 101 -#endif -#endif diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/resources/app_icon.ico b/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/resources/app_icon.ico deleted file mode 100644 index c04e20caf6370..0000000000000 Binary files a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/resources/app_icon.ico and /dev/null differ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/runner.exe.manifest b/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/runner.exe.manifest deleted file mode 100644 index c977c4a42589b..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/runner.exe.manifest +++ /dev/null @@ -1,20 +0,0 @@ - - - - - PerMonitorV2 - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/utils.h b/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/utils.h deleted file mode 100644 index 3879d54755798..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/utils.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef RUNNER_UTILS_H_ -#define RUNNER_UTILS_H_ - -#include -#include - -// Creates a console for the process, and redirects stdout and stderr to -// it for both the runner and the Flutter library. -void CreateAndAttachConsole(); - -// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string -// encoded in UTF-8. Returns an empty std::string on failure. -std::string Utf8FromUtf16(const wchar_t* utf16_string); - -// Gets the command line arguments passed in as a std::vector, -// encoded in UTF-8. Returns an empty std::vector on failure. -std::vector GetCommandLineArguments(); - -#endif // RUNNER_UTILS_H_ diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/win32_window.cpp b/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/win32_window.cpp deleted file mode 100644 index c10f08dc7da60..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/win32_window.cpp +++ /dev/null @@ -1,245 +0,0 @@ -#include "win32_window.h" - -#include - -#include "resource.h" - -namespace { - -constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; - -// The number of Win32Window objects that currently exist. -static int g_active_window_count = 0; - -using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); - -// Scale helper to convert logical scaler values to physical using passed in -// scale factor -int Scale(int source, double scale_factor) { - return static_cast(source * scale_factor); -} - -// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. -// This API is only needed for PerMonitor V1 awareness mode. -void EnableFullDpiSupportIfAvailable(HWND hwnd) { - HMODULE user32_module = LoadLibraryA("User32.dll"); - if (!user32_module) { - return; - } - auto enable_non_client_dpi_scaling = - reinterpret_cast( - GetProcAddress(user32_module, "EnableNonClientDpiScaling")); - if (enable_non_client_dpi_scaling != nullptr) { - enable_non_client_dpi_scaling(hwnd); - FreeLibrary(user32_module); - } -} - -} // namespace - -// Manages the Win32Window's window class registration. -class WindowClassRegistrar { - public: - ~WindowClassRegistrar() = default; - - // Returns the singleton registar instance. - static WindowClassRegistrar* GetInstance() { - if (!instance_) { - instance_ = new WindowClassRegistrar(); - } - return instance_; - } - - // Returns the name of the window class, registering the class if it hasn't - // previously been registered. - const wchar_t* GetWindowClass(); - - // Unregisters the window class. Should only be called if there are no - // instances of the window. - void UnregisterWindowClass(); - - private: - WindowClassRegistrar() = default; - - static WindowClassRegistrar* instance_; - - bool class_registered_ = false; -}; - -WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; - -const wchar_t* WindowClassRegistrar::GetWindowClass() { - if (!class_registered_) { - WNDCLASS window_class{}; - window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); - window_class.lpszClassName = kWindowClassName; - window_class.style = CS_HREDRAW | CS_VREDRAW; - window_class.cbClsExtra = 0; - window_class.cbWndExtra = 0; - window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); - window_class.hbrBackground = 0; - window_class.lpszMenuName = nullptr; - window_class.lpfnWndProc = Win32Window::WndProc; - RegisterClass(&window_class); - class_registered_ = true; - } - return kWindowClassName; -} - -void WindowClassRegistrar::UnregisterWindowClass() { - UnregisterClass(kWindowClassName, nullptr); - class_registered_ = false; -} - -Win32Window::Win32Window() { - ++g_active_window_count; -} - -Win32Window::~Win32Window() { - --g_active_window_count; - Destroy(); -} - -bool Win32Window::CreateAndShow(const std::wstring& title, - const Point& origin, - const Size& size) { - Destroy(); - - const wchar_t* window_class = - WindowClassRegistrar::GetInstance()->GetWindowClass(); - - const POINT target_point = {static_cast(origin.x), - static_cast(origin.y)}; - HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); - UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); - double scale_factor = dpi / 96.0; - - HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, - Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), - Scale(size.width, scale_factor), Scale(size.height, scale_factor), - nullptr, nullptr, GetModuleHandle(nullptr), this); - - if (!window) { - return false; - } - - return OnCreate(); -} - -// static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); - - auto that = static_cast(window_struct->lpCreateParams); - EnableFullDpiSupportIfAvailable(window); - that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { - return that->MessageHandler(window, message, wparam, lparam); - } - - return DefWindowProc(window, message, wparam, lparam); -} - -LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - switch (message) { - case WM_DESTROY: - window_handle_ = nullptr; - Destroy(); - if (quit_on_close_) { - PostQuitMessage(0); - } - return 0; - - case WM_DPICHANGED: { - auto newRectSize = reinterpret_cast(lparam); - LONG newWidth = newRectSize->right - newRectSize->left; - LONG newHeight = newRectSize->bottom - newRectSize->top; - - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); - - return 0; - } - case WM_SIZE: { - RECT rect = GetClientArea(); - if (child_content_ != nullptr) { - // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return 0; - } - - case WM_ACTIVATE: - if (child_content_ != nullptr) { - SetFocus(child_content_); - } - return 0; - } - - return DefWindowProc(window_handle_, message, wparam, lparam); -} - -void Win32Window::Destroy() { - OnDestroy(); - - if (window_handle_) { - DestroyWindow(window_handle_); - window_handle_ = nullptr; - } - if (g_active_window_count == 0) { - WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); - } -} - -Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( - GetWindowLongPtr(window, GWLP_USERDATA)); -} - -void Win32Window::SetChildContent(HWND content) { - child_content_ = content; - SetParent(content, window_handle_); - RECT frame = GetClientArea(); - - MoveWindow(content, frame.left, frame.top, frame.right - frame.left, - frame.bottom - frame.top, true); - - SetFocus(child_content_); -} - -RECT Win32Window::GetClientArea() { - RECT frame; - GetClientRect(window_handle_, &frame); - return frame; -} - -HWND Win32Window::GetHandle() { - return window_handle_; -} - -void Win32Window::SetQuitOnClose(bool quit_on_close) { - quit_on_close_ = quit_on_close; -} - -bool Win32Window::OnCreate() { - // No-op; provided for subclasses. - return true; -} - -void Win32Window::OnDestroy() { - // No-op; provided for subclasses. -} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/win32_window.h b/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/win32_window.h deleted file mode 100644 index 17ba431125b4b..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/win32_window.h +++ /dev/null @@ -1,98 +0,0 @@ -#ifndef RUNNER_WIN32_WINDOW_H_ -#define RUNNER_WIN32_WINDOW_H_ - -#include - -#include -#include -#include - -// A class abstraction for a high DPI-aware Win32 Window. Intended to be -// inherited from by classes that wish to specialize with custom -// rendering and input handling -class Win32Window { - public: - struct Point { - unsigned int x; - unsigned int y; - Point(unsigned int x, unsigned int y) : x(x), y(y) {} - }; - - struct Size { - unsigned int width; - unsigned int height; - Size(unsigned int width, unsigned int height) - : width(width), height(height) {} - }; - - Win32Window(); - virtual ~Win32Window(); - - // Creates and shows a win32 window with |title| and position and size using - // |origin| and |size|. New windows are created on the default monitor. Window - // sizes are specified to the OS in physical pixels, hence to ensure a - // consistent size to will treat the width height passed in to this function - // as logical pixels and scale to appropriate for the default monitor. Returns - // true if the window was created successfully. - bool CreateAndShow(const std::wstring& title, - const Point& origin, - const Size& size); - - // Release OS resources associated with window. - void Destroy(); - - // Inserts |content| into the window tree. - void SetChildContent(HWND content); - - // Returns the backing Window handle to enable clients to set icon and other - // window properties. Returns nullptr if the window has been destroyed. - HWND GetHandle(); - - // If true, closing this window will quit the application. - void SetQuitOnClose(bool quit_on_close); - - // Return a RECT representing the bounds of the current client area. - RECT GetClientArea(); - - protected: - // Processes and route salient window messages for mouse handling, - // size change and DPI. Delegates handling of these to member overloads that - // inheriting classes can handle. - virtual LRESULT MessageHandler(HWND window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Called when CreateAndShow is called, allowing subclass window-related - // setup. Subclasses should return false if setup fails. - virtual bool OnCreate(); - - // Called when Destroy is called. - virtual void OnDestroy(); - - private: - friend class WindowClassRegistrar; - - // OS callback called by message pump. Handles the WM_NCCREATE message which - // is passed when the non-client area is being created and enables automatic - // non-client DPI scaling so that the non-client area automatically - // responsponds to changes in DPI. All other messages are handled by - // MessageHandler. - static LRESULT CALLBACK WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Retrieves a class instance pointer for |window| - static Win32Window* GetThisFromHandle(HWND const window) noexcept; - - bool quit_on_close_ = false; - - // window handle for top level window. - HWND window_handle_ = nullptr; - - // window handle for hosted content. - HWND child_content_ = nullptr; -}; - -#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/frontend/app_flowy/packages/flowy_sdk/ios/Classes/FlowySdkPlugin.h b/frontend/app_flowy/packages/flowy_sdk/ios/Classes/FlowySdkPlugin.h deleted file mode 100644 index 813ed1c19bd60..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/ios/Classes/FlowySdkPlugin.h +++ /dev/null @@ -1,4 +0,0 @@ -#import - -@interface FlowySdkPlugin : NSObject -@end diff --git a/frontend/app_flowy/packages/flowy_sdk/ios/Classes/FlowySdkPlugin.m b/frontend/app_flowy/packages/flowy_sdk/ios/Classes/FlowySdkPlugin.m deleted file mode 100644 index 0343b9ec20699..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/ios/Classes/FlowySdkPlugin.m +++ /dev/null @@ -1,15 +0,0 @@ -#import "FlowySdkPlugin.h" -#if __has_include() -#import -#else -// Support project import fallback if the generated compatibility header -// is not copied when this plugin is created as a library. -// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 -#import "flowy_sdk-Swift.h" -#endif - -@implementation FlowySdkPlugin -+ (void)registerWithRegistrar:(NSObject*)registrar { - [SwiftFlowySdkPlugin registerWithRegistrar:registrar]; -} -@end diff --git a/frontend/app_flowy/packages/flowy_sdk/ios/Classes/SwiftFlowySdkPlugin.swift b/frontend/app_flowy/packages/flowy_sdk/ios/Classes/SwiftFlowySdkPlugin.swift deleted file mode 100644 index 736d69d565968..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/ios/Classes/SwiftFlowySdkPlugin.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Flutter -import UIKit - -public class SwiftFlowySdkPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "flowy_sdk", binaryMessenger: registrar.messenger()) - let instance = SwiftFlowySdkPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - result("iOS " + UIDevice.current.systemVersion) - } - - public static func dummyMethodToEnforceBundling() { - link_me_please() - } -} diff --git a/frontend/app_flowy/packages/flowy_sdk/ios/flowy_sdk.podspec b/frontend/app_flowy/packages/flowy_sdk/ios/flowy_sdk.podspec deleted file mode 100644 index ed15693e27063..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/ios/flowy_sdk.podspec +++ /dev/null @@ -1,25 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint flowy_sdk.podspec' to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'flowy_sdk' - s.version = '0.0.1' - s.summary = 'A new flutter plugin project.' - s.description = <<-DESC -A new flutter plugin project. - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'AppFlowy' => 'annie@appflowy.io' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.dependency 'Flutter' - s.platform = :ios, '8.0' - - # Flutter.framework does not contain a i386 slice. - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' - s.static_framework = true - s.vendored_libraries = "libdart_ffi.a" -end diff --git a/frontend/app_flowy/packages/flowy_sdk/lib/dispatch/dispatch.dart b/frontend/app_flowy/packages/flowy_sdk/lib/dispatch/dispatch.dart deleted file mode 100644 index 020c8f1ed0f2e..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/lib/dispatch/dispatch.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'dart:ffi'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/log.dart'; -// ignore: unnecessary_import -import 'package:flowy_sdk/protobuf/dart-ffi/ffi_response.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-net/network_state.pb.dart'; -import 'package:isolates/isolates.dart'; -import 'package:isolates/ports.dart'; -import 'package:ffi/ffi.dart'; -// ignore: unused_import -import 'package:flutter/services.dart'; -import 'dart:async'; -import 'dart:typed_data'; -import 'package:flowy_sdk/ffi.dart' as ffi; -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart'; -import 'package:flowy_sdk/protobuf/dart-ffi/protobuf.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/protobuf.dart'; -import 'package:flowy_sdk/protobuf/flowy-document/protobuf.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart'; - -// ignore: unused_import -import 'package:protobuf/protobuf.dart'; -import 'dart:convert' show utf8; -import '../protobuf/flowy-net/event_map.pb.dart'; -import 'error.dart'; - -part 'dart_event/flowy-folder/dart_event.dart'; -part 'dart_event/flowy-net/dart_event.dart'; -part 'dart_event/flowy-user/dart_event.dart'; -part 'dart_event/flowy-grid/dart_event.dart'; -part 'dart_event/flowy-document/dart_event.dart'; - -enum FFIException { - RequestIsEmpty, -} - -class DispatchException implements Exception { - FFIException type; - DispatchException(this.type); -} - -class Dispatch { - static Future> asyncRequest(FFIRequest request) { - // FFIRequest => Rust SDK - final bytesFuture = _sendToRust(request); - - // Rust SDK => FFIResponse - final responseFuture = _extractResponse(bytesFuture); - - // FFIResponse's payload is the bytes of the Response object - final payloadFuture = _extractPayload(responseFuture); - - return payloadFuture; - } -} - -Future> _extractPayload( - Future> responseFuture) { - return responseFuture.then((result) { - return result.fold( - (response) { - switch (response.code) { - case FFIStatusCode.Ok: - return left(Uint8List.fromList(response.payload)); - case FFIStatusCode.Err: - return right(Uint8List.fromList(response.payload)); - case FFIStatusCode.Internal: - final error = utf8.decode(response.payload); - Log.error("Dispatch internal error: $error"); - return right(emptyBytes()); - default: - Log.error("Impossible to here"); - return right(emptyBytes()); - } - }, - (error) { - Log.error("Response should not be empty $error"); - return right(emptyBytes()); - }, - ); - }); -} - -Future> _extractResponse( - Completer bytesFuture) { - return bytesFuture.future.then((bytes) { - try { - final response = FFIResponse.fromBuffer(bytes); - return left(response); - } catch (e, s) { - final error = StackTraceError(e, s); - Log.error('Deserialize response failed. ${error.toString()}'); - return right(error.asFlowyError()); - } - }); -} - -Completer _sendToRust(FFIRequest request) { - Uint8List bytes = request.writeToBuffer(); - assert(bytes.isEmpty == false); - if (bytes.isEmpty) { - throw DispatchException(FFIException.RequestIsEmpty); - } - - final Pointer input = calloc.allocate(bytes.length); - final list = input.asTypedList(bytes.length); - list.setAll(0, bytes); - - final completer = Completer(); - final port = singleCompletePort(completer); - ffi.async_event(port.nativePort, input, bytes.length); - calloc.free(input); - - return completer; -} - -Uint8List requestToBytes(T? message) { - try { - if (message != null) { - return message.writeToBuffer(); - } else { - return emptyBytes(); - } - } catch (e, s) { - final error = StackTraceError(e, s); - Log.error('Serial request failed. ${error.toString()}'); - return emptyBytes(); - } -} - -Uint8List emptyBytes() { - return Uint8List.fromList([]); -} diff --git a/frontend/app_flowy/packages/flowy_sdk/lib/dispatch/error.dart b/frontend/app_flowy/packages/flowy_sdk/lib/dispatch/error.dart deleted file mode 100644 index b29a37f28098a..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/lib/dispatch/error.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flowy_sdk/protobuf/dart-ffi/protobuf.dart'; - -class FlowyInternalError { - late FFIStatusCode _statusCode; - late String _error; - - FFIStatusCode get statusCode { - return _statusCode; - } - - String get error { - return _error; - } - - bool get has_error { - return _statusCode != FFIStatusCode.Ok; - } - - String toString() { - return "$_statusCode: $_error"; - } - - FlowyInternalError({required FFIStatusCode statusCode, required String error}) { - _statusCode = statusCode; - _error = error; - } - - factory FlowyInternalError.from(FFIResponse resp) { - return FlowyInternalError(statusCode: resp.code, error: ""); - } -} - -class StackTraceError { - Object error; - StackTrace trace; - StackTraceError( - this.error, - this.trace, - ); - - FlowyInternalError asFlowyError() { - return FlowyInternalError(statusCode: FFIStatusCode.Err, error: this.toString()); - } - - String toString() { - return '${error.runtimeType}. Stack trace: $trace'; - } -} diff --git a/frontend/app_flowy/packages/flowy_sdk/lib/flowy_sdk.dart b/frontend/app_flowy/packages/flowy_sdk/lib/flowy_sdk.dart deleted file mode 100644 index f6f34113cfad2..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/lib/flowy_sdk.dart +++ /dev/null @@ -1,37 +0,0 @@ -export 'package:async/async.dart'; -import 'dart:io'; -import 'dart:async'; -import 'package:flowy_sdk/rust_stream.dart'; -import 'package:flutter/services.dart'; -import 'dart:ffi'; -import 'ffi.dart' as ffi; -import 'package:ffi/ffi.dart'; - -enum ExceptionType { - AppearanceSettingsIsEmpty, -} - -class FlowySDKException implements Exception { - ExceptionType type; - FlowySDKException(this.type); -} - -class FlowySDK { - static const MethodChannel _channel = MethodChannel('flowy_sdk'); - static Future get platformVersion async { - final String version = await _channel.invokeMethod('getPlatformVersion'); - return version; - } - - const FlowySDK(); - - void dispose() {} - - Future init(Directory sdkDir) async { - final port = RustStreamReceiver.shared.port; - ffi.set_stream_port(port); - - ffi.store_dart_post_cobject(NativeApi.postCObject); - ffi.init_sdk(sdkDir.path.toNativeUtf8()); - } -} diff --git a/frontend/app_flowy/packages/flowy_sdk/lib/log.dart b/frontend/app_flowy/packages/flowy_sdk/lib/log.dart deleted file mode 100644 index ad61b70c4f726..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/lib/log.dart +++ /dev/null @@ -1,40 +0,0 @@ -// ignore: import_of_legacy_library_into_null_safe -import 'package:logger/logger.dart'; - -class Log { - static final shared = Log(); - late Logger _logger; - - Log() { - _logger = Logger( - printer: PrettyPrinter( - methodCount: 2, // number of method calls to be displayed - errorMethodCount: 8, // number of method calls if stacktrace is provided - lineLength: 120, // width of the output - colors: true, // Colorful log messages - printEmojis: true, // Print an emoji for each log message - printTime: false // Should each log print contain a timestamp - ), - ); - } - - static void info(dynamic msg) { - Log.shared._logger.i(msg); - } - - static void debug(dynamic msg) { - Log.shared._logger.d(msg); - } - - static void warn(dynamic msg) { - Log.shared._logger.w(msg); - } - - static void trace(dynamic msg) { - Log.shared._logger.v(msg); - } - - static void error(dynamic msg) { - Log.shared._logger.e(msg); - } -} diff --git a/frontend/app_flowy/packages/flowy_sdk/linux/Classes/binding.h b/frontend/app_flowy/packages/flowy_sdk/linux/Classes/binding.h deleted file mode 100644 index 036d56aa2a668..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/linux/Classes/binding.h +++ /dev/null @@ -1,15 +0,0 @@ -#include -#include -#include -#include - - -int64_t init_sdk(char *path); - -void async_command(int64_t port, const uint8_t *input, uintptr_t len); - -const uint8_t *sync_command(const uint8_t *input, uintptr_t len); - -int32_t set_stream_port(int64_t port); - -void link_me_please(void); \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_sdk/macos/Classes/FlowySdkPlugin.swift b/frontend/app_flowy/packages/flowy_sdk/macos/Classes/FlowySdkPlugin.swift deleted file mode 100644 index 320d758f2c71a..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/macos/Classes/FlowySdkPlugin.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Cocoa -import FlutterMacOS - -public class FlowySdkPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "flowy_sdk", binaryMessenger: registrar.messenger) - let instance = FlowySdkPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "getPlatformVersion": - result("macOS " + ProcessInfo.processInfo.operatingSystemVersionString) - default: - result(FlutterMethodNotImplemented) - } - } - - public static func dummyMethodToEnforceBundling() { - link_me_please() - } -} diff --git a/frontend/app_flowy/packages/flowy_sdk/macos/Classes/binding.h b/frontend/app_flowy/packages/flowy_sdk/macos/Classes/binding.h deleted file mode 100644 index a45d19dcefb40..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/macos/Classes/binding.h +++ /dev/null @@ -1,14 +0,0 @@ -#include -#include -#include -#include - -int64_t init_sdk(char *path); - -void async_event(int64_t port, const uint8_t *input, uintptr_t len); - -const uint8_t *sync_event(const uint8_t *input, uintptr_t len); - -int32_t set_stream_port(int64_t port); - -void link_me_please(void); \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_sdk/macos/flowy_sdk.podspec b/frontend/app_flowy/packages/flowy_sdk/macos/flowy_sdk.podspec deleted file mode 100644 index 497388b99f2ad..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/macos/flowy_sdk.podspec +++ /dev/null @@ -1,25 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint flowy_sdk.podspec' to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'flowy_sdk' - s.version = '0.0.1' - s.summary = 'A new flutter plugin project.' - s.description = <<-DESC -A new flutter plugin project. - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'AppFlowy' => 'annie@appflowy.io' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.public_header_files = 'Classes/**/*.h' - s.dependency 'FlutterMacOS' - - s.platform = :osx, '10.11' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } - s.swift_version = '5.0' - s.static_framework = true - s.vendored_libraries = "libdart_ffi.a" -end diff --git a/frontend/app_flowy/packages/flowy_sdk/pubspec.lock b/frontend/app_flowy/packages/flowy_sdk/pubspec.lock deleted file mode 100644 index 395abe99e6000..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/pubspec.lock +++ /dev/null @@ -1,504 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - url: "https://pub.dartlang.org" - source: hosted - version: "20.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - url: "https://pub.dartlang.org" - source: hosted - version: "1.4.0" - args: - dependency: transitive - description: - name: args - url: "https://pub.dartlang.org" - source: hosted - version: "1.6.0" - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.8.2" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - build: - dependency: transitive - description: - name: build - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - build_config: - dependency: transitive - description: - name: build_config - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.7" - build_daemon: - dependency: transitive - description: - name: build_daemon - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.10" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - build_runner: - dependency: "direct dev" - description: - name: build_runner - url: "https://pub.dartlang.org" - source: hosted - version: "1.12.2" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.12" - built_collection: - dependency: transitive - description: - name: built_collection - url: "https://pub.dartlang.org" - source: hosted - version: "5.1.0" - built_value: - dependency: transitive - description: - name: built_value - url: "https://pub.dartlang.org" - source: hosted - version: "8.1.0" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - cli_util: - dependency: transitive - description: - name: cli_util - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.0" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - code_builder: - dependency: transitive - description: - name: code_builder - url: "https://pub.dartlang.org" - source: hosted - version: "3.6.0" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.16.0" - convert: - dependency: transitive - description: - name: convert - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.0" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - dart_style: - dependency: transitive - description: - name: dart_style - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - dartz: - dependency: "direct main" - description: - name: dartz - url: "https://pub.dartlang.org" - source: hosted - version: "0.10.1" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - ffi: - dependency: "direct main" - description: - name: ffi - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - file: - dependency: transitive - description: - name: file - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.0" - fixnum: - dependency: transitive - description: - name: fixnum - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - freezed: - dependency: "direct dev" - description: - name: freezed - url: "https://pub.dartlang.org" - source: hosted - version: "0.14.1+2" - freezed_annotation: - dependency: "direct main" - description: - name: freezed_annotation - url: "https://pub.dartlang.org" - source: hosted - version: "0.14.1" - glob: - dependency: transitive - description: - name: glob - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - graphs: - dependency: transitive - description: - name: graphs - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.0" - http_parser: - dependency: transitive - description: - name: http_parser - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.4" - io: - dependency: transitive - description: - name: io - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.4" - isolates: - dependency: "direct main" - description: - name: isolates - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.3+8" - js: - dependency: transitive - description: - name: js - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.3" - json_annotation: - dependency: transitive - description: - name: json_annotation - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.1" - lints: - dependency: transitive - description: - name: lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - logger: - dependency: "direct main" - description: - name: logger - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - logging: - dependency: transitive - description: - name: logging - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.11" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.4" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - mime: - dependency: transitive - description: - name: mime - url: "https://pub.dartlang.org" - source: hosted - version: "0.9.7" - package_config: - dependency: transitive - description: - name: package_config - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.1" - pedantic: - dependency: transitive - description: - name: pedantic - url: "https://pub.dartlang.org" - source: hosted - version: "1.11.0" - pool: - dependency: transitive - description: - name: pool - url: "https://pub.dartlang.org" - source: hosted - version: "1.5.0" - protobuf: - dependency: "direct main" - description: - name: protobuf - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - pub_semver: - dependency: transitive - description: - name: pub_semver - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - shelf: - dependency: transitive - description: - name: shelf - url: "https://pub.dartlang.org" - source: hosted - version: "0.7.9" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.3" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_gen: - dependency: transitive - description: - name: source_gen - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - stream_transform: - dependency: transitive - description: - name: stream_transform - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.9" - timing: - dependency: transitive - description: - name: timing - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.1+3" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - watcher: - dependency: transitive - description: - name: watcher - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - yaml: - dependency: transitive - description: - name: yaml - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.0" -sdks: - dart: ">=2.17.0 <3.0.0" - flutter: ">=1.17.0" diff --git a/frontend/app_flowy/packages/flowy_sdk/pubspec.yaml b/frontend/app_flowy/packages/flowy_sdk/pubspec.yaml deleted file mode 100644 index 186381d84887f..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/pubspec.yaml +++ /dev/null @@ -1,78 +0,0 @@ -name: flowy_sdk -description: A new flutter plugin project. -version: 0.0.1 -homepage: -publish_to: "none" - -environment: - sdk: ">=2.12.0-0 <3.0.0" - flutter: ">=1.17.0" - -dependencies: - flutter: - sdk: flutter - ffi: ^1.0.0 - isolates: ^3.0.3+8 - protobuf: "2.0.0" - dartz: ^0.10.1 - freezed_annotation: - logger: ^1.0.0 - -dev_dependencies: - flutter_test: - sdk: flutter - build_runner: - freezed: - flutter_lints: ^2.0.1 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - # This section identifies this Flutter project as a plugin project. - # The 'pluginClass' and Android 'package' identifiers should not ordinarily - # be modified. They are used by the tooling to maintain consistency when - # adding or updating assets for this project. - plugin: - platforms: - android: - package: com.plugin.flowy_sdk - pluginClass: FlowySdkPlugin - ios: - pluginClass: FlowySdkPlugin - macos: - pluginClass: FlowySdkPlugin - windows: - pluginClass: FlowySdkPlugin - - # To add assets to your plugin package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # To add custom fonts to your plugin package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/packages/flowy_sdk/test/flowy_sdk_test.dart b/frontend/app_flowy/packages/flowy_sdk/test/flowy_sdk_test.dart deleted file mode 100644 index f19b9258bfe04..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/test/flowy_sdk_test.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flowy_sdk/flowy_sdk.dart'; - -void main() { - const MethodChannel channel = MethodChannel('flowy_sdk'); - - TestWidgetsFlutterBinding.ensureInitialized(); - - setUp(() { - channel.setMockMethodCallHandler((MethodCall methodCall) async { - return '42'; - }); - }); - - tearDown(() { - channel.setMockMethodCallHandler(null); - }); - - test('getPlatformVersion', () async { - expect(await FlowySDK.platformVersion, '42'); - }); -} diff --git a/frontend/app_flowy/packages/flowy_sdk/windows/CMakeLists.txt b/frontend/app_flowy/packages/flowy_sdk/windows/CMakeLists.txt deleted file mode 100644 index 91580129ce304..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/windows/CMakeLists.txt +++ /dev/null @@ -1,27 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -set(PROJECT_NAME "flowy_sdk") -project(${PROJECT_NAME} LANGUAGES CXX) - -# This value is used when generating builds using this plugin, so it must -# not be changed -set(PLUGIN_NAME "flowy_sdk_plugin") - -add_library(${PLUGIN_NAME} SHARED - "flowy_sdk_plugin.cpp" -) -apply_standard_settings(${PLUGIN_NAME}) -set_target_properties(${PLUGIN_NAME} PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) -target_include_directories(${PLUGIN_NAME} INTERFACE - "${CMAKE_CURRENT_SOURCE_DIR}/include") -target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) - -# List of absolute paths to libraries that should be bundled with the plugin -set(flowy_sdk_bundled_libraries - "" - PARENT_SCOPE -) - - - diff --git a/frontend/app_flowy/packages/flowy_sdk/windows/flowy_sdk_plugin.cpp b/frontend/app_flowy/packages/flowy_sdk/windows/flowy_sdk_plugin.cpp deleted file mode 100644 index c8cee3f6188d7..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/windows/flowy_sdk_plugin.cpp +++ /dev/null @@ -1,82 +0,0 @@ -#include "include/flowy_sdk/flowy_sdk_plugin.h" - -// This must be included before many other Windows headers. -#include - -// For getPlatformVersion; remove unless needed for your plugin implementation. -#include - -#include -#include -#include - -#include -#include -#include - -namespace { - -class FlowySdkPlugin : public flutter::Plugin { - public: - static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); - - FlowySdkPlugin(); - - virtual ~FlowySdkPlugin(); - - private: - // Called when a method is called on this plugin's channel from Dart. - void HandleMethodCall( - const flutter::MethodCall &method_call, - std::unique_ptr> result); -}; - -// static -void FlowySdkPlugin::RegisterWithRegistrar( - flutter::PluginRegistrarWindows *registrar) { - auto channel = - std::make_unique>( - registrar->messenger(), "flowy_sdk", - &flutter::StandardMethodCodec::GetInstance()); - - auto plugin = std::make_unique(); - - channel->SetMethodCallHandler( - [plugin_pointer = plugin.get()](const auto &call, auto result) { - plugin_pointer->HandleMethodCall(call, std::move(result)); - }); - - registrar->AddPlugin(std::move(plugin)); -} - -FlowySdkPlugin::FlowySdkPlugin() {} - -FlowySdkPlugin::~FlowySdkPlugin() {} - -void FlowySdkPlugin::HandleMethodCall( - const flutter::MethodCall &method_call, - std::unique_ptr> result) { - if (method_call.method_name().compare("getPlatformVersion") == 0) { - std::ostringstream version_stream; - version_stream << "Windows "; - if (IsWindows10OrGreater()) { - version_stream << "10+"; - } else if (IsWindows8OrGreater()) { - version_stream << "8"; - } else if (IsWindows7OrGreater()) { - version_stream << "7"; - } - result->Success(flutter::EncodableValue(version_stream.str())); - } else { - result->NotImplemented(); - } -} - -} // namespace - -void FlowySdkPluginRegisterWithRegistrar( - FlutterDesktopPluginRegistrarRef registrar) { - FlowySdkPlugin::RegisterWithRegistrar( - flutter::PluginRegistrarManager::GetInstance() - ->GetRegistrar(registrar)); -} diff --git a/frontend/app_flowy/packages/flowy_sdk/windows/include/flowy_sdk/flowy_sdk_plugin.h b/frontend/app_flowy/packages/flowy_sdk/windows/include/flowy_sdk/flowy_sdk_plugin.h deleted file mode 100644 index 1752d1972f29f..0000000000000 --- a/frontend/app_flowy/packages/flowy_sdk/windows/include/flowy_sdk/flowy_sdk_plugin.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifndef FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ -#define FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ - -#include - -#ifdef FLUTTER_PLUGIN_IMPL -#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) -#else -#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) -#endif - -#if defined(__cplusplus) -extern "C" { -#endif - -FLUTTER_PLUGIN_EXPORT void FlowySdkPluginRegisterWithRegistrar( - FlutterDesktopPluginRegistrarRef registrar); - -#if defined(__cplusplus) -} // extern "C" -#endif - -#endif // FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ diff --git a/frontend/app_flowy/pubspec.lock b/frontend/app_flowy/pubspec.lock deleted file mode 100644 index 2711b7651dd51..0000000000000 --- a/frontend/app_flowy/pubspec.lock +++ /dev/null @@ -1,1555 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - url: "https://pub.dartlang.org" - source: hosted - version: "46.0.0" - analyzer: - dependency: "direct overridden" - description: - name: analyzer - url: "https://pub.dartlang.org" - source: hosted - version: "4.6.0" - animations: - dependency: transitive - description: - name: animations - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.3" - appflowy_board: - dependency: "direct main" - description: - path: "packages/appflowy_board" - relative: true - source: path - version: "0.0.9" - appflowy_editor: - dependency: "direct main" - description: - path: "packages/appflowy_editor" - relative: true - source: path - version: "0.0.7" - appflowy_popover: - dependency: "direct main" - description: - path: "packages/appflowy_popover" - relative: true - source: path - version: "0.0.1" - archive: - dependency: transitive - description: - name: archive - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.11" - args: - dependency: transitive - description: - name: args - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.1" - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.8.2" - bloc: - dependency: "direct main" - description: - name: bloc - url: "https://pub.dartlang.org" - source: hosted - version: "8.1.0" - bloc_test: - dependency: "direct dev" - description: - name: bloc_test - url: "https://pub.dartlang.org" - source: hosted - version: "9.0.3" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - build: - dependency: transitive - description: - name: build - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.0" - build_config: - dependency: transitive - description: - name: build_config - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - build_daemon: - dependency: transitive - description: - name: build_daemon - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.0" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.9" - build_runner: - dependency: "direct dev" - description: - name: build_runner - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.0" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - url: "https://pub.dartlang.org" - source: hosted - version: "7.2.3" - built_collection: - dependency: transitive - description: - name: built_collection - url: "https://pub.dartlang.org" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - url: "https://pub.dartlang.org" - source: hosted - version: "8.3.2" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - charcode: - dependency: "direct main" - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - clipboard: - dependency: "direct main" - description: - name: clipboard - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.3" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - code_builder: - dependency: transitive - description: - name: code_builder - url: "https://pub.dartlang.org" - source: hosted - version: "4.1.0" - collection: - dependency: "direct main" - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.16.0" - connectivity_plus: - dependency: "direct main" - description: - name: connectivity_plus - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.6+1" - connectivity_plus_linux: - dependency: transitive - description: - name: connectivity_plus_linux - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - connectivity_plus_macos: - dependency: transitive - description: - name: connectivity_plus_macos - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.4" - connectivity_plus_platform_interface: - dependency: transitive - description: - name: connectivity_plus_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - connectivity_plus_web: - dependency: transitive - description: - name: connectivity_plus_web - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.3" - connectivity_plus_windows: - dependency: transitive - description: - name: connectivity_plus_windows - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.2" - convert: - dependency: transitive - description: - name: convert - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - coverage: - dependency: transitive - description: - name: coverage - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - cross_file: - dependency: transitive - description: - name: cross_file - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.3+1" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - csslib: - dependency: transitive - description: - name: csslib - url: "https://pub.dartlang.org" - source: hosted - version: "0.17.1" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.4" - dart_style: - dependency: transitive - description: - name: dart_style - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.3" - dartz: - dependency: "direct main" - description: - name: dartz - url: "https://pub.dartlang.org" - source: hosted - version: "0.10.1" - dbus: - dependency: transitive - description: - name: dbus - url: "https://pub.dartlang.org" - source: hosted - version: "0.7.4" - device_info_plus: - dependency: "direct main" - description: - name: device_info_plus - url: "https://pub.dartlang.org" - source: hosted - version: "3.2.3" - device_info_plus_linux: - dependency: transitive - description: - name: device_info_plus_linux - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - device_info_plus_macos: - dependency: transitive - description: - name: device_info_plus_macos - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.3" - device_info_plus_platform_interface: - dependency: transitive - description: - name: device_info_plus_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.0+1" - device_info_plus_web: - dependency: transitive - description: - name: device_info_plus_web - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - device_info_plus_windows: - dependency: transitive - description: - name: device_info_plus_windows - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - diff_match_patch: - dependency: transitive - description: - name: diff_match_patch - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.1" - easy_localization: - dependency: "direct main" - description: - name: easy_localization - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - easy_logger: - dependency: transitive - description: - name: easy_logger - url: "https://pub.dartlang.org" - source: hosted - version: "0.0.2" - equatable: - dependency: "direct main" - description: - name: equatable - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.3" - expandable: - dependency: "direct main" - description: - name: expandable - url: "https://pub.dartlang.org" - source: hosted - version: "5.0.1" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - ffi: - dependency: transitive - description: - name: ffi - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.1" - file: - dependency: transitive - description: - name: file - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.2" - file_picker: - dependency: "direct main" - description: - name: file_picker - url: "https://pub.dartlang.org" - source: hosted - version: "4.6.1" - fixnum: - dependency: "direct main" - description: - name: fixnum - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - flowy_infra: - dependency: "direct main" - description: - path: "packages/flowy_infra" - relative: true - source: path - version: "0.0.1" - flowy_infra_ui: - dependency: "direct main" - description: - path: "packages/flowy_infra_ui" - relative: true - source: path - version: "0.0.1" - flowy_infra_ui_platform_interface: - dependency: transitive - description: - path: "packages/flowy_infra_ui/flowy_infra_ui_platform_interface" - relative: true - source: path - version: "0.0.1" - flowy_infra_ui_web: - dependency: transitive - description: - path: "packages/flowy_infra_ui/flowy_infra_ui_web" - relative: true - source: path - version: "0.0.1" - flowy_sdk: - dependency: "direct main" - description: - path: "packages/flowy_sdk" - relative: true - source: path - version: "0.0.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_bloc: - dependency: "direct main" - description: - name: flutter_bloc - url: "https://pub.dartlang.org" - source: hosted - version: "8.0.1" - flutter_colorpicker: - dependency: "direct main" - description: - name: flutter_colorpicker - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.1" - flutter_driver: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_inappwebview: - dependency: transitive - description: - name: flutter_inappwebview - url: "https://pub.dartlang.org" - source: hosted - version: "5.4.3+7" - flutter_keyboard_visibility: - dependency: transitive - description: - name: flutter_keyboard_visibility - url: "https://pub.dartlang.org" - source: hosted - version: "5.2.0" - flutter_keyboard_visibility_platform_interface: - dependency: transitive - description: - name: flutter_keyboard_visibility_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - flutter_keyboard_visibility_web: - dependency: transitive - description: - name: flutter_keyboard_visibility_web - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - flutter_localizations: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.6" - flutter_quill: - dependency: "direct main" - description: - path: "." - ref: "306fd78b7a134abdde0fed6be67f59e8a6068509" - resolved-ref: "306fd78b7a134abdde0fed6be67f59e8a6068509" - url: "https://github.com/appflowy/flutter-quill.git" - source: git - version: "2.0.13" - flutter_svg: - dependency: transitive - description: - name: flutter_svg - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.4" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - fluttertoast: - dependency: "direct main" - description: - name: fluttertoast - url: "https://pub.dartlang.org" - source: hosted - version: "8.0.9" - freezed: - dependency: "direct dev" - description: - name: freezed - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0+1" - freezed_annotation: - dependency: "direct main" - description: - name: freezed_annotation - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.3" - fuchsia_remote_debug_protocol: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - get_it: - dependency: "direct main" - description: - name: get_it - url: "https://pub.dartlang.org" - source: hosted - version: "7.2.0" - gettext_parser: - dependency: transitive - description: - name: gettext_parser - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.0" - glob: - dependency: transitive - description: - name: glob - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.2" - google_fonts: - dependency: "direct main" - description: - name: google_fonts - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - graphs: - dependency: transitive - description: - name: graphs - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - hotkey_manager: - dependency: "direct main" - description: - name: hotkey_manager - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.7" - html: - dependency: transitive - description: - name: html - url: "https://pub.dartlang.org" - source: hosted - version: "0.15.0" - http: - dependency: transitive - description: - name: http - url: "https://pub.dartlang.org" - source: hosted - version: "0.13.4" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - url: "https://pub.dartlang.org" - source: hosted - version: "3.2.0" - http_parser: - dependency: transitive - description: - name: http_parser - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.1" - i18n_extension: - dependency: transitive - description: - name: i18n_extension - url: "https://pub.dartlang.org" - source: hosted - version: "4.2.1" - image_picker: - dependency: transitive - description: - name: image_picker - url: "https://pub.dartlang.org" - source: hosted - version: "0.8.5+3" - image_picker_android: - dependency: transitive - description: - name: image_picker_android - url: "https://pub.dartlang.org" - source: hosted - version: "0.8.4+13" - image_picker_for_web: - dependency: transitive - description: - name: image_picker_for_web - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.8" - image_picker_ios: - dependency: transitive - description: - name: image_picker_ios - url: "https://pub.dartlang.org" - source: hosted - version: "0.8.5+5" - image_picker_platform_interface: - dependency: transitive - description: - name: image_picker_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.5.0" - integration_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - intl: - dependency: "direct main" - description: - name: intl - url: "https://pub.dartlang.org" - source: hosted - version: "0.17.0" - intl_utils: - dependency: transitive - description: - name: intl_utils - url: "https://pub.dartlang.org" - source: hosted - version: "2.7.0" - io: - dependency: transitive - description: - name: io - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.3" - isolates: - dependency: transitive - description: - name: isolates - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.3+8" - js: - dependency: transitive - description: - name: js - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.4" - json_annotation: - dependency: transitive - description: - name: json_annotation - url: "https://pub.dartlang.org" - source: hosted - version: "4.5.0" - linked_scroll_controller: - dependency: "direct main" - description: - name: linked_scroll_controller - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.0" - lint: - dependency: transitive - description: - name: lint - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.2" - lints: - dependency: transitive - description: - name: lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - loading_indicator: - dependency: transitive - description: - name: loading_indicator - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.0" - logger: - dependency: transitive - description: - name: logger - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - logging: - dependency: transitive - description: - name: logging - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.2" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.11" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.4" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - mime: - dependency: transitive - description: - name: mime - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.2" - mocktail: - dependency: transitive - description: - name: mocktail - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.0" - nested: - dependency: transitive - description: - name: nested - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - nm: - dependency: transitive - description: - name: nm - url: "https://pub.dartlang.org" - source: hosted - version: "0.5.0" - node_preamble: - dependency: transitive - description: - name: node_preamble - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - package_config: - dependency: transitive - description: - name: package_config - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.2" - package_info_plus: - dependency: "direct main" - description: - name: package_info_plus - url: "https://pub.dartlang.org" - source: hosted - version: "1.4.2" - package_info_plus_linux: - dependency: transitive - description: - name: package_info_plus_linux - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.5" - package_info_plus_macos: - dependency: transitive - description: - name: package_info_plus_macos - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.2" - package_info_plus_web: - dependency: transitive - description: - name: package_info_plus_web - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.5" - package_info_plus_windows: - dependency: transitive - description: - name: package_info_plus_windows - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.5" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.1" - path_drawing: - dependency: transitive - description: - name: path_drawing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - path_provider: - dependency: "direct main" - description: - name: path_provider - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.10" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.14" - path_provider_ios: - dependency: transitive - description: - name: path_provider_ios - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.9" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.6" - path_provider_macos: - dependency: transitive - description: - name: path_provider_macos - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.6" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.4" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.6" - pedantic: - dependency: transitive - description: - name: pedantic - url: "https://pub.dartlang.org" - source: hosted - version: "1.11.1" - petitparser: - dependency: transitive - description: - name: petitparser - url: "https://pub.dartlang.org" - source: hosted - version: "5.0.0" - photo_view: - dependency: transitive - description: - name: photo_view - url: "https://pub.dartlang.org" - source: hosted - version: "0.13.0" - platform: - dependency: transitive - description: - name: platform - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.0" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - pool: - dependency: transitive - description: - name: pool - url: "https://pub.dartlang.org" - source: hosted - version: "1.5.0" - process: - dependency: transitive - description: - name: process - url: "https://pub.dartlang.org" - source: hosted - version: "4.2.4" - protobuf: - dependency: "direct main" - description: - name: protobuf - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - provider: - dependency: "direct main" - description: - name: provider - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.3" - pub_semver: - dependency: transitive - description: - name: pub_semver - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - quiver: - dependency: transitive - description: - name: quiver - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.0" - reorderables: - dependency: "direct main" - description: - name: reorderables - url: "https://pub.dartlang.org" - source: hosted - version: "0.5.0" - rich_clipboard: - dependency: transitive - description: - name: rich_clipboard - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - rich_clipboard_android: - dependency: transitive - description: - name: rich_clipboard_android - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - rich_clipboard_ios: - dependency: transitive - description: - name: rich_clipboard_ios - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - rich_clipboard_linux: - dependency: transitive - description: - name: rich_clipboard_linux - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - rich_clipboard_macos: - dependency: transitive - description: - name: rich_clipboard_macos - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - rich_clipboard_platform_interface: - dependency: transitive - description: - name: rich_clipboard_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - rich_clipboard_web: - dependency: transitive - description: - name: rich_clipboard_web - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - rich_clipboard_windows: - dependency: transitive - description: - name: rich_clipboard_windows - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.15" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.12" - shared_preferences_ios: - dependency: transitive - description: - name: shared_preferences_ios - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - shared_preferences_macos: - dependency: transitive - description: - name: shared_preferences_macos - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.4" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.4" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - shelf: - dependency: transitive - description: - name: shelf - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.0" - shelf_static: - dependency: transitive - description: - name: shelf_static - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - simple_gesture_detector: - dependency: transitive - description: - name: simple_gesture_detector - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.0" - sized_context: - dependency: "direct main" - description: - name: sized_context - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0+1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_gen: - dependency: transitive - description: - name: source_gen - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.2" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - source_maps: - dependency: transitive - description: - name: source_maps - url: "https://pub.dartlang.org" - source: hosted - version: "0.10.10" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.2" - sprintf: - dependency: transitive - description: - name: sprintf - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - stream_transform: - dependency: transitive - description: - name: stream_transform - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - string_validator: - dependency: transitive - description: - name: string_validator - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.0" - styled_widget: - dependency: "direct main" - description: - name: styled_widget - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.1+2" - sync_http: - dependency: transitive - description: - name: sync_http - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.0" - table_calendar: - dependency: "direct main" - description: - name: table_calendar - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.5" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - test: - dependency: transitive - description: - name: test - url: "https://pub.dartlang.org" - source: hosted - version: "1.21.1" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.9" - test_core: - dependency: transitive - description: - name: test_core - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.13" - textfield_tags: - dependency: "direct main" - description: - name: textfield_tags - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0+1" - textstyle_extensions: - dependency: "direct main" - description: - name: textstyle_extensions - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0-nullsafety" - time: - dependency: "direct main" - description: - name: time - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - timing: - dependency: transitive - description: - name: timing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - tuple: - dependency: "direct main" - description: - name: tuple - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - universal_platform: - dependency: transitive - description: - name: universal_platform - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0+1" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.5" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.17" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.17" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.11" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - uuid: - dependency: transitive - description: - name: uuid - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.6" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - video_player: - dependency: transitive - description: - name: video_player - url: "https://pub.dartlang.org" - source: hosted - version: "2.4.2" - video_player_android: - dependency: transitive - description: - name: video_player_android - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.4" - video_player_avfoundation: - dependency: transitive - description: - name: video_player_avfoundation - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.4" - video_player_platform_interface: - dependency: transitive - description: - name: video_player_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "5.1.2" - video_player_web: - dependency: transitive - description: - name: video_player_web - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.10" - vm_service: - dependency: transitive - description: - name: vm_service - url: "https://pub.dartlang.org" - source: hosted - version: "8.2.2" - watcher: - dependency: transitive - description: - name: watcher - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.0" - webdriver: - dependency: transitive - description: - name: webdriver - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.0" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - win32: - dependency: transitive - description: - name: win32 - url: "https://pub.dartlang.org" - source: hosted - version: "2.6.1" - window_size: - dependency: "direct main" - description: - path: "plugins/window_size" - ref: e48abe7c3e9ebfe0b81622167c5201d4e783bb81 - resolved-ref: e48abe7c3e9ebfe0b81622167c5201d4e783bb81 - url: "https://github.com/google/flutter-desktop-embedding.git" - source: git - version: "0.1.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.0+1" - xml: - dependency: transitive - description: - name: xml - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.0" - yaml: - dependency: transitive - description: - name: yaml - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.1" - youtube_player_flutter: - dependency: transitive - description: - name: youtube_player_flutter - url: "https://pub.dartlang.org" - source: hosted - version: "8.1.0" -sdks: - dart: ">=2.17.0 <3.0.0" - flutter: ">=3.0.0" diff --git a/frontend/app_flowy/pubspec.yaml b/frontend/app_flowy/pubspec.yaml deleted file mode 100644 index bb40a5c51cabb..0000000000000 --- a/frontend/app_flowy/pubspec.yaml +++ /dev/null @@ -1,191 +0,0 @@ -name: app_flowy -description: A new Flutter project. - -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: "none" # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.0+1 - -environment: - sdk: ">=2.17.0 <3.0.0" - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - flutter_localizations: - sdk: flutter - flowy_sdk: - path: packages/flowy_sdk - flowy_infra_ui: - path: packages/flowy_infra_ui - flowy_infra: - path: packages/flowy_infra - appflowy_board: - path: packages/appflowy_board - appflowy_editor: - path: packages/appflowy_editor - appflowy_popover: - path: packages/appflowy_popover - flutter_quill: - git: - url: https://github.com/appflowy/flutter-quill.git - ref: 306fd78b7a134abdde0fed6be67f59e8a6068509 - - # third party packages - intl: ^0.17.0 - time: "^2.0.0" - equatable: "^2.0.3" - freezed_annotation: ^2.1.0 - get_it: "^7.1.3" - flutter_bloc: "^8.0.1" - dartz: ^0.10.1 - provider: ^6.0.1 - path_provider: ^2.0.1 - window_size: - git: - url: https://github.com/google/flutter-desktop-embedding.git - path: plugins/window_size - ref: e48abe7c3e9ebfe0b81622167c5201d4e783bb81 - sized_context: ^1.0.0+1 - styled_widget: "^0.3.1" - expandable: ^5.0.1 - flutter_colorpicker: ^0.6.0 - package_info_plus: ^1.3.0 - url_launcher: ^6.0.2 - # file_picker: ^4.2.1 - clipboard: ^0.1.3 - connectivity_plus: ^2.3.6+1 - easy_localization: ^3.0.0 - textfield_tags: ^2.0.0 - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 - device_info_plus: ^3.2.1 - fluttertoast: ^8.0.9 - table_calendar: ^3.0.5 - reorderables: ^0.5.0 - linked_scroll_controller: ^0.2.0 - hotkey_manager: ^0.1.7 - fixnum: ^1.0.1 - tuple: ^2.0.0 - protobuf: "2.0.0" - charcode: ^1.3.1 - collection: ^1.16.0 - bloc: ^8.1.0 - textstyle_extensions: "2.0.0-nullsafety" - shared_preferences: ^2.0.15 - google_fonts: ^3.0.1 - file_picker: <=5.0.0 - -dev_dependencies: - flutter_lints: ^2.0.1 - - flutter_test: - sdk: flutter - integration_test: - sdk: flutter - build_runner: ^2.2.0 - freezed: ^2.1.0+1 - bloc_test: ^9.0.2 - -dependency_overrides: - analyzer: ">=4.4.0 <5.0.0" - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - # Automatic code generation for l10n and i18n - generate: true - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - fonts: - - family: FlowyIconData - fonts: - - asset: assets/fonts/FlowyIconData.ttf - - family: Poppins - fonts: - - asset: assets/google_fonts/Poppins/Poppins-ExtraLight.ttf - weight: 100 - - asset: assets/google_fonts/Poppins/Poppins-Thin.ttf - weight: 200 - - asset: assets/google_fonts/Poppins/Poppins-Light.ttf - weight: 300 - - asset: assets/google_fonts/Poppins/Poppins-Regular.ttf - weight: 400 - - asset: assets/google_fonts/Poppins/Poppins-Medium.ttf - weight: 500 - - asset: assets/google_fonts/Poppins/Poppins-SemiBold.ttf - weight: 600 - - asset: assets/google_fonts/Poppins/Poppins-Bold.ttf - weight: 700 - - asset: assets/google_fonts/Poppins/Poppins-Black.ttf - weight: 800 - - asset: assets/google_fonts/Poppins/Poppins-ExtraBold.ttf - weight: 900 - - # To add assets to your application, add an assets section, like this: - assets: - - assets/images/ - - assets/images/home/ - - assets/images/editor/ - - assets/images/grid/ - - assets/images/emoji/ - - assets/images/grid/field/ - - assets/images/grid/setting/ - - assets/translations/ - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/test/bloc_test/app_setting_test/appearance_test.dart b/frontend/app_flowy/test/bloc_test/app_setting_test/appearance_test.dart deleted file mode 100644 index 08d9436c1ef0f..0000000000000 --- a/frontend/app_flowy/test/bloc_test/app_setting_test/appearance_test.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:app_flowy/user/application/user_settings_service.dart'; -import 'package:app_flowy/workspace/application/appearance.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../util.dart'; - -void main() { - // ignore: unused_local_variable - late AppFlowyUnitTest context; - setUpAll(() async { - context = await AppFlowyUnitTest.ensureInitialized(); - }); - - group('$AppearanceSetting', () { - late AppearanceSetting appearanceSetting; - setUp(() async { - final setting = await SettingsFFIService().getAppearanceSetting(); - appearanceSetting = AppearanceSetting(setting); - await blocResponseFuture(); - }); - - test('default theme', () { - expect(appearanceSetting.theme.ty, ThemeType.light); - }); - - test('save key/value', () async { - appearanceSetting.setKeyValue("123", "456"); - }); - - test('read key/value', () { - expect(appearanceSetting.getValue("123"), "456"); - }); - - test('remove key/value', () { - appearanceSetting.setKeyValue("123", null); - }); - - test('read key/value', () { - expect(appearanceSetting.getValue("123"), null); - }); - }); -} diff --git a/frontend/app_flowy/test/bloc_test/board_test/create_card_test.dart b/frontend/app_flowy/test/bloc_test/board_test/create_card_test.dart deleted file mode 100644 index 52609f323f43c..0000000000000 --- a/frontend/app_flowy/test/bloc_test/board_test/create_card_test.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:app_flowy/plugins/board/application/board_bloc.dart'; -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'util.dart'; - -void main() { - late AppFlowyBoardTest boardTest; - - setUpAll(() async { - boardTest = await AppFlowyBoardTest.ensureInitialized(); - }); - - group('$BoardBloc', () { - late BoardBloc boardBloc; - late String groupId; - - setUp(() async { - await boardTest.context.createTestBoard(); - boardBloc = BoardBloc(view: boardTest.context.gridView) - ..add(const BoardEvent.initial()); - await boardResponseFuture(); - groupId = boardBloc.state.groupIds.first; - - // the group at index 0 is the 'No status' group; - assert(boardBloc.groupControllers[groupId]!.group.rows.isEmpty); - assert(boardBloc.state.groupIds.length == 4); - }); - - blocTest( - "create card", - build: () => boardBloc, - act: (bloc) async { - boardBloc.add(BoardEvent.createBottomRow(boardBloc.state.groupIds[0])); - }, - wait: boardResponseDuration(), - verify: (bloc) { - // - - assert(bloc.groupControllers[groupId]!.group.rows.length == 1); - }, - ); - }); -} diff --git a/frontend/app_flowy/test/bloc_test/board_test/group_by_field_test.dart b/frontend/app_flowy/test/bloc_test/board_test/group_by_field_test.dart deleted file mode 100644 index 833ff7cab7d81..0000000000000 --- a/frontend/app_flowy/test/bloc_test/board_test/group_by_field_test.dart +++ /dev/null @@ -1,230 +0,0 @@ -import 'package:app_flowy/plugins/board/application/board_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/application/cell/select_option_editor_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_editor_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/setting/group_bloc.dart'; -import 'package:bloc_test/bloc_test.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'util.dart'; - -void main() { - late AppFlowyBoardTest boardTest; - - setUpAll(() async { - boardTest = await AppFlowyBoardTest.ensureInitialized(); - }); - - // Group by multi-select with no options - group('Group by multi-select with no options', () { - // - late FieldPB multiSelectField; - late String expectedGroupName; - - setUpAll(() async { - await boardTest.context.createTestBoard(); - }); - - test('create multi-select field', () async { - await boardTest.context.createField(FieldType.MultiSelect); - await boardResponseFuture(); - - assert(boardTest.context.fieldContexts.length == 3); - multiSelectField = boardTest.context.fieldContexts.last.field; - expectedGroupName = "No ${multiSelectField.name}"; - assert(multiSelectField.fieldType == FieldType.MultiSelect); - }); - - blocTest( - "set grouped by multi-select field", - build: () => GridGroupBloc( - viewId: boardTest.context.gridView.id, - fieldController: boardTest.context.fieldController, - ), - act: (bloc) async { - bloc.add(GridGroupEvent.setGroupByField( - multiSelectField.id, - multiSelectField.fieldType, - )); - }, - wait: boardResponseDuration(), - ); - - blocTest( - "assert only have the 'No status' group", - build: () => BoardBloc(view: boardTest.context.gridView) - ..add(const BoardEvent.initial()), - wait: boardResponseDuration(), - verify: (bloc) { - assert(bloc.groupControllers.values.length == 1, - "Expected 1, but receive ${bloc.groupControllers.values.length}"); - - assert( - bloc.groupControllers.values.first.group.desc == expectedGroupName, - "Expected $expectedGroupName, but receive ${bloc.groupControllers.values.first.group.desc}"); - }, - ); - }); - - group('Group by multi-select with two options', () { - late FieldPB multiSelectField; - - setUpAll(() async { - await boardTest.context.createTestBoard(); - }); - - test('create multi-select field', () async { - await boardTest.context.createField(FieldType.MultiSelect); - await boardResponseFuture(); - - assert(boardTest.context.fieldContexts.length == 3); - multiSelectField = boardTest.context.fieldContexts.last.field; - assert(multiSelectField.fieldType == FieldType.MultiSelect); - - final cellController = - await boardTest.context.makeCellController(multiSelectField.id) - as GridSelectOptionCellController; - - final multiSelectOptionBloc = - SelectOptionCellEditorBloc(cellController: cellController); - multiSelectOptionBloc.add(const SelectOptionEditorEvent.initial()); - await boardResponseFuture(); - - multiSelectOptionBloc.add(const SelectOptionEditorEvent.newOption("A")); - await boardResponseFuture(); - - multiSelectOptionBloc.add(const SelectOptionEditorEvent.newOption("B")); - await boardResponseFuture(); - }); - - blocTest( - "set grouped by multi-select field", - build: () => GridGroupBloc( - viewId: boardTest.context.gridView.id, - fieldController: boardTest.context.fieldController, - ), - act: (bloc) async { - bloc.add(GridGroupEvent.setGroupByField( - multiSelectField.id, - multiSelectField.fieldType, - )); - }, - wait: boardResponseDuration(), - ); - - blocTest( - "check the groups' order", - build: () => BoardBloc(view: boardTest.context.gridView) - ..add(const BoardEvent.initial()), - wait: boardResponseDuration(), - verify: (bloc) { - assert(bloc.groupControllers.values.length == 3, - "Expected 3, but receive ${bloc.groupControllers.values.length}"); - - final groups = - bloc.groupControllers.values.map((e) => e.group).toList(); - assert(groups[0].desc == "No ${multiSelectField.name}"); - assert(groups[1].desc == "B"); - assert(groups[2].desc == "A"); - }, - ); - }); - - // Group by checkbox field - group('Group by checkbox field:', () { - late BoardBloc boardBloc; - late FieldPB checkboxField; - setUpAll(() async { - await boardTest.context.createTestBoard(); - }); - - setUp(() async { - boardBloc = BoardBloc(view: boardTest.context.gridView) - ..add(const BoardEvent.initial()); - await boardResponseFuture(); - }); - - blocTest( - "initial", - build: () => boardBloc, - wait: boardResponseDuration(), - verify: (bloc) { - assert(bloc.groupControllers.values.length == 4); - assert(boardTest.context.fieldContexts.length == 2); - }, - ); - - test('create checkbox field', () async { - await boardTest.context.createField(FieldType.Checkbox); - await boardResponseFuture(); - - assert(boardTest.context.fieldContexts.length == 3); - checkboxField = boardTest.context.fieldContexts.last.field; - assert(checkboxField.fieldType == FieldType.Checkbox); - }); - - blocTest( - "set grouped by checkbox field", - build: () => GridGroupBloc( - viewId: boardTest.context.gridView.id, - fieldController: boardTest.context.fieldController, - ), - act: (bloc) async { - bloc.add(GridGroupEvent.setGroupByField( - checkboxField.id, - checkboxField.fieldType, - )); - }, - wait: boardResponseDuration(), - ); - - blocTest( - "check the number of groups is 2", - build: () => boardBloc, - wait: boardResponseDuration(), - verify: (bloc) { - assert(bloc.groupControllers.values.length == 2); - }, - ); - }); - - // Group with not support grouping field - group('Group with not support grouping field:', () { - late FieldEditorBloc editorBloc; - setUpAll(() async { - await boardTest.context.createTestBoard(); - final fieldContext = boardTest.context.singleSelectFieldContext(); - editorBloc = boardTest.context.createFieldEditor( - fieldContext: fieldContext, - )..add(const FieldEditorEvent.initial()); - - await boardResponseFuture(); - }); - - blocTest( - "switch to text field", - build: () => editorBloc, - wait: boardResponseDuration(), - act: (bloc) async { - bloc.add(const FieldEditorEvent.switchToField(FieldType.RichText)); - }, - verify: (bloc) { - bloc.state.field.fold( - () => throw Exception(), - (field) => field.fieldType == FieldType.RichText, - ); - }, - ); - blocTest( - 'assert the number of groups is 1', - build: () => BoardBloc(view: boardTest.context.gridView) - ..add(const BoardEvent.initial()), - wait: boardResponseDuration(), - verify: (bloc) { - assert(bloc.groupControllers.values.length == 1, - "Expected 1, but receive ${bloc.groupControllers.values.length}"); - }, - ); - }); -} diff --git a/frontend/app_flowy/test/bloc_test/board_test/util.dart b/frontend/app_flowy/test/bloc_test/board_test/util.dart deleted file mode 100644 index a757a97babc93..0000000000000 --- a/frontend/app_flowy/test/bloc_test/board_test/util.dart +++ /dev/null @@ -1,19 +0,0 @@ -import '../grid_test/util.dart'; - -class AppFlowyBoardTest { - final AppFlowyGridTest context; - AppFlowyBoardTest(this.context); - - static Future ensureInitialized() async { - final inner = await AppFlowyGridTest.ensureInitialized(); - return AppFlowyBoardTest(inner); - } -} - -Future boardResponseFuture() { - return Future.delayed(boardResponseDuration(milliseconds: 200)); -} - -Duration boardResponseDuration({int milliseconds = 200}) { - return Duration(milliseconds: milliseconds); -} diff --git a/frontend/app_flowy/test/bloc_test/grid_test/field_edit_bloc_test.dart b/frontend/app_flowy/test/bloc_test/grid_test/field_edit_bloc_test.dart deleted file mode 100644 index 8bbb13be18a4d..0000000000000 --- a/frontend/app_flowy/test/bloc_test/grid_test/field_edit_bloc_test.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import 'package:bloc_test/bloc_test.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'util.dart'; - -void main() { - late AppFlowyGridTest gridTest; - - setUpAll(() async { - gridTest = await AppFlowyGridTest.ensureInitialized(); - }); - - group('$FieldEditorBloc', () { - late FieldEditorBloc editorBloc; - - setUp(() async { - await gridTest.createTestGrid(); - final fieldContext = gridTest.singleSelectFieldContext(); - final loader = FieldTypeOptionLoader( - gridId: gridTest.gridView.id, - field: fieldContext.field, - ); - - editorBloc = FieldEditorBloc( - gridId: gridTest.gridView.id, - fieldName: fieldContext.name, - isGroupField: fieldContext.isGroupField, - loader: loader, - )..add(const FieldEditorEvent.initial()); - - await gridResponseFuture(); - }); - - blocTest( - "rename field", - build: () => editorBloc, - act: (bloc) async { - editorBloc.add(const FieldEditorEvent.updateName('Hello world')); - }, - wait: gridResponseDuration(), - verify: (bloc) { - bloc.state.field.fold( - () => throw Exception("The field should not be none"), - (field) { - assert(field.name == 'Hello world'); - }, - ); - }, - ); - - blocTest( - "switch to text field", - build: () => editorBloc, - act: (bloc) async { - editorBloc - .add(const FieldEditorEvent.switchToField(FieldType.RichText)); - }, - wait: gridResponseDuration(), - verify: (bloc) { - bloc.state.field.fold( - () => throw Exception("The field should not be none"), - (field) { - // The default length of the fields is 3. The length of the fields - // should not change after switching to other field type - assert(gridTest.fieldContexts.length == 3); - assert(field.fieldType == FieldType.RichText); - }, - ); - }, - ); - - blocTest( - "delete field", - build: () => editorBloc, - act: (bloc) async { - editorBloc.add(const FieldEditorEvent.deleteField()); - }, - wait: gridResponseDuration(), - verify: (bloc) { - assert(gridTest.fieldContexts.length == 2); - }, - ); - }); -} diff --git a/frontend/app_flowy/test/bloc_test/grid_test/grid_bloc_test.dart b/frontend/app_flowy/test/bloc_test/grid_test/grid_bloc_test.dart deleted file mode 100644 index a703cbe330b40..0000000000000 --- a/frontend/app_flowy/test/bloc_test/grid_test/grid_bloc_test.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/grid_bloc.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:bloc_test/bloc_test.dart'; -import 'util.dart'; - -void main() { - late AppFlowyGridTest gridTest; - setUpAll(() async { - gridTest = await AppFlowyGridTest.ensureInitialized(); - await gridTest.createTestGrid(); - }); - - group('GridBloc', () { - blocTest( - "Create row", - build: () => - GridBloc(view: gridTest.gridView)..add(const GridEvent.initial()), - act: (bloc) => bloc.add(const GridEvent.createRow()), - wait: const Duration(milliseconds: 300), - verify: (bloc) { - assert(bloc.state.rowInfos.length == 4); - }, - ); - }); - - group('GridBloc', () { - late GridBloc gridBloc; - setUpAll(() async { - gridBloc = GridBloc(view: gridTest.gridView) - ..add(const GridEvent.initial()); - await gridResponseFuture(); - }); - - // The initial number of rows is three - test('', () async { - assert(gridBloc.state.rowInfos.length == 3); - }); - - test('delete row', () async { - gridBloc.add(GridEvent.deleteRow(gridBloc.state.rowInfos.last)); - await gridResponseFuture(); - assert(gridBloc.state.rowInfos.length == 2); - }); - }); -} diff --git a/frontend/app_flowy/test/bloc_test/grid_test/grid_header_bloc_test.dart b/frontend/app_flowy/test/bloc_test/grid_test/grid_header_bloc_test.dart deleted file mode 100644 index 3d03bc4463385..0000000000000 --- a/frontend/app_flowy/test/bloc_test/grid_test/grid_header_bloc_test.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/field/field_action_sheet_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/grid_header_bloc.dart'; -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'util.dart'; - -void main() { - late AppFlowyGridTest gridTest; - - setUpAll(() async { - gridTest = await AppFlowyGridTest.ensureInitialized(); - }); - - group('$GridHeaderBloc', () { - late FieldActionSheetBloc actionSheetBloc; - setUp(() async { - await gridTest.createTestGrid(); - actionSheetBloc = FieldActionSheetBloc( - fieldCellContext: gridTest.singleSelectFieldCellContext(), - ); - }); - - blocTest( - "hides property", - build: () { - final bloc = GridHeaderBloc( - gridId: gridTest.gridView.id, - fieldController: gridTest.fieldController, - )..add(const GridHeaderEvent.initial()); - return bloc; - }, - act: (bloc) async { - actionSheetBloc.add(const FieldActionSheetEvent.hideField()); - await Future.delayed(gridResponseDuration()); - }, - wait: gridResponseDuration(), - verify: (bloc) { - assert(bloc.state.fields.length == 2); - }, - ); - - blocTest( - "shows property", - build: () { - final bloc = GridHeaderBloc( - gridId: gridTest.gridView.id, - fieldController: gridTest.fieldController, - )..add(const GridHeaderEvent.initial()); - return bloc; - }, - act: (bloc) async { - actionSheetBloc.add(const FieldActionSheetEvent.hideField()); - await Future.delayed(gridResponseDuration()); - actionSheetBloc.add(const FieldActionSheetEvent.showField()); - await Future.delayed(gridResponseDuration()); - }, - wait: gridResponseDuration(), - verify: (bloc) { - assert(bloc.state.fields.length == 3); - }, - ); - - blocTest( - "duplicate property", - build: () { - final bloc = GridHeaderBloc( - gridId: gridTest.gridView.id, - fieldController: gridTest.fieldController, - )..add(const GridHeaderEvent.initial()); - return bloc; - }, - act: (bloc) async { - actionSheetBloc.add(const FieldActionSheetEvent.duplicateField()); - await Future.delayed(gridResponseDuration()); - }, - wait: gridResponseDuration(), - verify: (bloc) { - assert(bloc.state.fields.length == 4); - }, - ); - - blocTest( - "delete property", - build: () { - final bloc = GridHeaderBloc( - gridId: gridTest.gridView.id, - fieldController: gridTest.fieldController, - )..add(const GridHeaderEvent.initial()); - return bloc; - }, - act: (bloc) async { - actionSheetBloc.add(const FieldActionSheetEvent.deleteField()); - await Future.delayed(gridResponseDuration()); - }, - wait: gridResponseDuration(), - verify: (bloc) { - assert(bloc.state.fields.length == 2); - }, - ); - - blocTest( - "update name", - build: () { - final bloc = GridHeaderBloc( - gridId: gridTest.gridView.id, - fieldController: gridTest.fieldController, - )..add(const GridHeaderEvent.initial()); - return bloc; - }, - act: (bloc) async { - actionSheetBloc - .add(const FieldActionSheetEvent.updateFieldName("Hello world")); - await Future.delayed(gridResponseDuration()); - }, - wait: gridResponseDuration(), - verify: (bloc) { - final field = bloc.state.fields.firstWhere( - (element) => element.id == actionSheetBloc.fieldService.fieldId); - - assert(field.name == "Hello world"); - }, - ); - }); -} diff --git a/frontend/app_flowy/test/bloc_test/grid_test/select_option_bloc_test.dart b/frontend/app_flowy/test/bloc_test/grid_test/select_option_bloc_test.dart deleted file mode 100644 index 20726f79110bd..0000000000000 --- a/frontend/app_flowy/test/bloc_test/grid_test/select_option_bloc_test.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/application/cell/select_option_editor_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; -import 'package:dartz/dartz.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/select_type_option.pb.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:bloc_test/bloc_test.dart'; -import 'util.dart'; - -void main() { - late AppFlowyGridSelectOptionCellTest cellTest; - setUpAll(() async { - cellTest = await AppFlowyGridSelectOptionCellTest.ensureInitialized(); - }); - - group('SingleSelectOptionBloc', () { - late GridSelectOptionCellController cellController; - setUp(() async { - await cellTest.createTestGrid(); - await cellTest.createTestRow(); - cellController = await cellTest.makeCellController( - FieldType.SingleSelect, - ); - }); - - blocTest( - "delete options", - build: () { - final bloc = SelectOptionCellEditorBloc(cellController: cellController); - bloc.add(const SelectOptionEditorEvent.initial()); - return bloc; - }, - act: (bloc) async { - bloc.add(const SelectOptionEditorEvent.newOption("A")); - await Future.delayed(gridResponseDuration()); - bloc.add(const SelectOptionEditorEvent.newOption("B")); - await Future.delayed(gridResponseDuration()); - bloc.add(const SelectOptionEditorEvent.newOption("C")); - await Future.delayed(gridResponseDuration()); - bloc.add(const SelectOptionEditorEvent.deleteAllOptions()); - }, - wait: gridResponseDuration(), - verify: (bloc) { - assert(bloc.state.options.isEmpty); - }, - ); - - blocTest( - "create option", - build: () { - final bloc = SelectOptionCellEditorBloc(cellController: cellController); - bloc.add(const SelectOptionEditorEvent.initial()); - return bloc; - }, - act: (bloc) async { - bloc.add(const SelectOptionEditorEvent.newOption("A")); - }, - wait: gridResponseDuration(), - verify: (bloc) { - expect(bloc.state.options.length, 1); - expect(bloc.state.options[0].name, "A"); - }, - ); - - blocTest( - "delete option", - build: () { - final bloc = SelectOptionCellEditorBloc(cellController: cellController); - bloc.add(const SelectOptionEditorEvent.initial()); - return bloc; - }, - act: (bloc) async { - bloc.add(const SelectOptionEditorEvent.newOption("A")); - await Future.delayed(gridResponseDuration()); - bloc.add(SelectOptionEditorEvent.deleteOption(bloc.state.options[0])); - }, - wait: gridResponseDuration(), - verify: (bloc) { - assert(bloc.state.options.isEmpty); - }, - ); - - blocTest( - "update option", - build: () { - final bloc = SelectOptionCellEditorBloc(cellController: cellController); - bloc.add(const SelectOptionEditorEvent.initial()); - return bloc; - }, - act: (bloc) async { - bloc.add(const SelectOptionEditorEvent.newOption("A")); - await Future.delayed(gridResponseDuration()); - SelectOptionPB optionUpdate = bloc.state.options[0] - ..color = SelectOptionColorPB.Aqua - ..name = "B"; - bloc.add(SelectOptionEditorEvent.updateOption(optionUpdate)); - }, - wait: gridResponseDuration(), - verify: (bloc) { - assert(bloc.state.options.length == 1); - expect(bloc.state.options[0].color, SelectOptionColorPB.Aqua); - expect(bloc.state.options[0].name, "B"); - }, - ); - - blocTest( - "select/unselect option", - build: () { - final bloc = SelectOptionCellEditorBloc(cellController: cellController); - bloc.add(const SelectOptionEditorEvent.initial()); - return bloc; - }, - act: (bloc) async { - bloc.add(const SelectOptionEditorEvent.newOption("A")); - await Future.delayed(gridResponseDuration()); - expect(bloc.state.selectedOptions.length, 1); - final optionId = bloc.state.options[0].id; - bloc.add(SelectOptionEditorEvent.unSelectOption(optionId)); - await Future.delayed(gridResponseDuration()); - assert(bloc.state.selectedOptions.isEmpty); - bloc.add(SelectOptionEditorEvent.selectOption(optionId)); - }, - wait: gridResponseDuration(), - verify: (bloc) { - assert(bloc.state.selectedOptions.length == 1); - expect(bloc.state.selectedOptions[0].name, "A"); - }, - ); - - blocTest( - "select an option or create one", - build: () { - final bloc = SelectOptionCellEditorBloc(cellController: cellController); - bloc.add(const SelectOptionEditorEvent.initial()); - return bloc; - }, - act: (bloc) async { - bloc.add(const SelectOptionEditorEvent.newOption("A")); - await Future.delayed(gridResponseDuration()); - bloc.add(const SelectOptionEditorEvent.trySelectOption("B")); - await Future.delayed(gridResponseDuration()); - bloc.add(const SelectOptionEditorEvent.trySelectOption("A")); - }, - wait: gridResponseDuration(), - verify: (bloc) { - assert(bloc.state.selectedOptions.length == 1); - assert(bloc.state.options.length == 2); - expect(bloc.state.selectedOptions[0].name, "A"); - }, - ); - - blocTest( - "select multiple options", - build: () { - final bloc = SelectOptionCellEditorBloc(cellController: cellController); - bloc.add(const SelectOptionEditorEvent.initial()); - return bloc; - }, - act: (bloc) async { - bloc.add(const SelectOptionEditorEvent.newOption("A")); - await Future.delayed(gridResponseDuration()); - bloc.add(const SelectOptionEditorEvent.newOption("B")); - await Future.delayed(gridResponseDuration()); - bloc.add(const SelectOptionEditorEvent.selectMultipleOptions( - ["A", "B", "C"], "x")); - }, - wait: gridResponseDuration(), - verify: (bloc) { - assert(bloc.state.selectedOptions.length == 1); - expect(bloc.state.selectedOptions[0].name, "A"); - expect(bloc.state.filter, const Some("x")); - }, - ); - - blocTest( - "filter options", - build: () { - final bloc = SelectOptionCellEditorBloc(cellController: cellController); - bloc.add(const SelectOptionEditorEvent.initial()); - return bloc; - }, - act: (bloc) async { - bloc.add(const SelectOptionEditorEvent.newOption("abcd")); - await Future.delayed(gridResponseDuration()); - bloc.add(const SelectOptionEditorEvent.newOption("aaaa")); - await Future.delayed(gridResponseDuration()); - bloc.add(const SelectOptionEditorEvent.newOption("defg")); - await Future.delayed(gridResponseDuration()); - bloc.add(const SelectOptionEditorEvent.filterOption("a")); - }, - wait: gridResponseDuration(), - verify: (bloc) { - expect(bloc.state.options.length, 2); - expect(bloc.state.allOptions.length, 3); - expect(bloc.state.createOption, const Some("a")); - expect(bloc.state.filter, const Some("a")); - }, - ); - }); -} diff --git a/frontend/app_flowy/test/bloc_test/grid_test/util.dart b/frontend/app_flowy/test/bloc_test/grid_test/util.dart deleted file mode 100644 index d09c1584cdb0a..0000000000000 --- a/frontend/app_flowy/test/bloc_test/grid_test/util.dart +++ /dev/null @@ -1,261 +0,0 @@ -import 'dart:collection'; -import 'package:app_flowy/plugins/board/application/board_data_controller.dart'; -import 'package:app_flowy/plugins/board/board.dart'; -import 'package:app_flowy/plugins/grid/application/block/block_cache.dart'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_controller.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_editor_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; -import 'package:app_flowy/plugins/grid/application/grid_data_controller.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_data_controller.dart'; -import 'package:app_flowy/plugins/grid/grid.dart'; -import 'package:app_flowy/workspace/application/app/app_service.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; - -import '../../util.dart'; - -/// Create a empty Grid for test -class AppFlowyGridTest { - final AppFlowyUnitTest unitTest; - late ViewPB gridView; - GridDataController? _gridDataController; - BoardDataController? _boardDataController; - - AppFlowyGridTest({required this.unitTest}); - - static Future ensureInitialized() async { - final inner = await AppFlowyUnitTest.ensureInitialized(); - return AppFlowyGridTest(unitTest: inner); - } - - List get rowInfos { - if (_gridDataController != null) { - return _gridDataController!.rowInfos; - } - - if (_boardDataController != null) { - return _boardDataController!.rowInfos; - } - - throw Exception(); - } - - UnmodifiableMapView get blocks { - if (_gridDataController != null) { - return _gridDataController!.blocks; - } - - if (_boardDataController != null) { - return _boardDataController!.blocks; - } - - throw Exception(); - } - - List get fieldContexts => fieldController.fieldContexts; - - GridFieldController get fieldController { - if (_gridDataController != null) { - return _gridDataController!.fieldController; - } - - if (_boardDataController != null) { - return _boardDataController!.fieldController; - } - - throw Exception(); - } - - Future createRow() async { - if (_gridDataController != null) { - return _gridDataController!.createRow(); - } - - throw Exception(); - } - - FieldEditorBloc createFieldEditor({ - GridFieldContext? fieldContext, - }) { - IFieldTypeOptionLoader loader; - if (fieldContext == null) { - loader = NewFieldTypeOptionLoader(gridId: gridView.id); - } else { - loader = - FieldTypeOptionLoader(gridId: gridView.id, field: fieldContext.field); - } - - final editorBloc = FieldEditorBloc( - fieldName: fieldContext?.name ?? '', - isGroupField: fieldContext?.isGroupField ?? false, - loader: loader, - gridId: gridView.id, - ); - return editorBloc; - } - - Future makeCellController(String fieldId) async { - final builder = await makeCellControllerBuilder(fieldId); - return builder.build(); - } - - Future makeCellControllerBuilder( - String fieldId, - ) async { - final RowInfo rowInfo = rowInfos.last; - final blockCache = blocks[rowInfo.rowPB.blockId]; - final rowCache = blockCache?.rowCache; - late GridFieldController fieldController; - if (_gridDataController != null) { - fieldController = _gridDataController!.fieldController; - } - - if (_boardDataController != null) { - fieldController = _boardDataController!.fieldController; - } - - final rowDataController = GridRowDataController( - rowInfo: rowInfo, - fieldController: fieldController, - rowCache: rowCache!, - ); - - final rowBloc = RowBloc( - rowInfo: rowInfo, - dataController: rowDataController, - )..add(const RowEvent.initial()); - await gridResponseFuture(); - - return GridCellControllerBuilder( - cellId: rowBloc.state.gridCellMap[fieldId]!, - cellCache: rowCache.cellCache, - delegate: rowDataController, - ); - } - - Future createField(FieldType fieldType) async { - final editorBloc = createFieldEditor() - ..add(const FieldEditorEvent.initial()); - await gridResponseFuture(); - editorBloc.add(FieldEditorEvent.switchToField(fieldType)); - await gridResponseFuture(); - return Future(() => editorBloc); - } - - GridFieldContext singleSelectFieldContext() { - final fieldContext = fieldContexts - .firstWhere((element) => element.fieldType == FieldType.SingleSelect); - return fieldContext; - } - - GridFieldCellContext singleSelectFieldCellContext() { - final field = singleSelectFieldContext().field; - return GridFieldCellContext(gridId: gridView.id, field: field); - } - - Future createTestGrid() async { - final app = await unitTest.createTestApp(); - final builder = GridPluginBuilder(); - final result = await AppService().createView( - appId: app.id, - name: "Test Grid", - dataFormatType: builder.dataFormatType, - pluginType: builder.pluginType, - layoutType: builder.layoutType!, - ); - await result.fold( - (view) async { - gridView = view; - _gridDataController = GridDataController(view: view); - final result = await _gridDataController!.openGrid(); - result.fold((l) => null, (r) => throw Exception(r)); - }, - (error) {}, - ); - } - - Future createTestBoard() async { - final app = await unitTest.createTestApp(); - final builder = BoardPluginBuilder(); - final result = await AppService().createView( - appId: app.id, - name: "Test Board", - dataFormatType: builder.dataFormatType, - pluginType: builder.pluginType, - layoutType: builder.layoutType!, - ); - await result.fold( - (view) async { - _boardDataController = BoardDataController(view: view); - final result = await _boardDataController!.openGrid(); - result.fold((l) => null, (r) => throw Exception(r)); - gridView = view; - }, - (error) {}, - ); - } -} - -/// Create a new Grid for cell test -class AppFlowyGridCellTest { - final AppFlowyGridTest gridTest; - AppFlowyGridCellTest({required this.gridTest}); - - static Future ensureInitialized() async { - final gridTest = await AppFlowyGridTest.ensureInitialized(); - return AppFlowyGridCellTest(gridTest: gridTest); - } - - Future createTestRow() async { - await gridTest.createRow(); - } - - Future createTestGrid() async { - await gridTest.createTestGrid(); - } -} - -class AppFlowyGridSelectOptionCellTest { - final AppFlowyGridCellTest _gridCellTest; - - AppFlowyGridSelectOptionCellTest(AppFlowyGridCellTest cellTest) - : _gridCellTest = cellTest; - - static Future ensureInitialized() async { - final gridTest = await AppFlowyGridCellTest.ensureInitialized(); - return AppFlowyGridSelectOptionCellTest(gridTest); - } - - Future createTestGrid() async { - await _gridCellTest.createTestGrid(); - } - - Future createTestRow() async { - await _gridCellTest.createTestRow(); - } - - Future makeCellController( - FieldType fieldType) async { - assert(fieldType == FieldType.SingleSelect || - fieldType == FieldType.MultiSelect); - - final fieldContexts = _gridCellTest.gridTest.fieldContexts; - final field = - fieldContexts.firstWhere((element) => element.fieldType == fieldType); - final cellController = await _gridCellTest.gridTest - .makeCellController(field.id) as GridSelectOptionCellController; - return cellController; - } -} - -Future gridResponseFuture() { - return Future.delayed(gridResponseDuration(milliseconds: 200)); -} - -Duration gridResponseDuration({int milliseconds = 200}) { - return Duration(milliseconds: milliseconds); -} diff --git a/frontend/app_flowy/test/bloc_test/home_test/app_bloc_test.dart b/frontend/app_flowy/test/bloc_test/home_test/app_bloc_test.dart deleted file mode 100644 index 9a4e7153555ea..0000000000000 --- a/frontend/app_flowy/test/bloc_test/home_test/app_bloc_test.dart +++ /dev/null @@ -1,328 +0,0 @@ -import 'package:app_flowy/plugins/board/application/board_bloc.dart'; -import 'package:app_flowy/plugins/board/board.dart'; -import 'package:app_flowy/plugins/doc/application/doc_bloc.dart'; -import 'package:app_flowy/plugins/doc/document.dart'; -import 'package:app_flowy/plugins/grid/application/grid_bloc.dart'; -import 'package:app_flowy/plugins/grid/grid.dart'; -import 'package:app_flowy/workspace/application/app/app_bloc.dart'; -import 'package:app_flowy/workspace/application/menu/menu_view_section_bloc.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:bloc_test/bloc_test.dart'; -import '../../util.dart'; - -void main() { - late AppFlowyUnitTest testContext; - setUpAll(() async { - testContext = await AppFlowyUnitTest.ensureInitialized(); - }); - - group( - '$AppBloc', - () { - late AppPB app; - setUp(() async { - app = await testContext.createTestApp(); - }); - - blocTest( - "Create a document", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - act: (bloc) { - bloc.add( - AppEvent.createView("Test document", DocumentPluginBuilder())); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.views.length == 1); - assert(bloc.state.views.last.name == "Test document"); - assert(bloc.state.views.last.layout == ViewLayoutTypePB.Document); - }, - ); - - blocTest( - "Create a grid", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - act: (bloc) { - bloc.add(AppEvent.createView("Test grid", GridPluginBuilder())); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.views.length == 1); - assert(bloc.state.views.last.name == "Test grid"); - assert(bloc.state.views.last.layout == ViewLayoutTypePB.Grid); - }, - ); - - blocTest( - "Create a Kanban board", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - act: (bloc) { - bloc.add(AppEvent.createView("Test board", BoardPluginBuilder())); - }, - wait: const Duration(milliseconds: 100), - verify: (bloc) { - assert(bloc.state.views.length == 1); - assert(bloc.state.views.last.name == "Test board"); - assert(bloc.state.views.last.layout == ViewLayoutTypePB.Board); - }, - ); - }, - ); - - group('$AppBloc', () { - late AppPB app; - setUpAll(() async { - app = await testContext.createTestApp(); - }); - - blocTest( - "rename the app", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - wait: blocResponseDuration(), - act: (bloc) => bloc.add(const AppEvent.rename('Hello world')), - verify: (bloc) { - assert(bloc.state.app.name == 'Hello world'); - }, - ); - - blocTest( - "delete the app", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - wait: blocResponseDuration(), - act: (bloc) => bloc.add(const AppEvent.delete()), - verify: (bloc) async { - final apps = await testContext.loadApps(); - assert(apps.where((element) => element.id == app.id).isEmpty); - }, - ); - }); - - group('$AppBloc', () { - late ViewPB view; - late AppPB app; - setUpAll(() async { - app = await testContext.createTestApp(); - }); - - blocTest( - "create a document", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - act: (bloc) { - bloc.add(AppEvent.createView("Test document", DocumentPluginBuilder())); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.views.length == 1); - view = bloc.state.views.last; - }, - ); - - blocTest( - "delete the document", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - act: (bloc) => bloc.add(AppEvent.deleteView(view.id)), - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.views.isEmpty); - }, - ); - }); - - group('$AppBloc', () { - late AppPB app; - setUpAll(() async { - app = await testContext.createTestApp(); - }); - blocTest( - "create documents' order test", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - act: (bloc) async { - bloc.add(AppEvent.createView("1", DocumentPluginBuilder())); - await blocResponseFuture(); - bloc.add(AppEvent.createView("2", DocumentPluginBuilder())); - await blocResponseFuture(); - bloc.add(AppEvent.createView("3", DocumentPluginBuilder())); - await blocResponseFuture(); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.views[0].name == '1'); - assert(bloc.state.views[1].name == '2'); - assert(bloc.state.views[2].name == '3'); - }, - ); - }); - - group('$AppBloc', () { - late AppPB app; - setUpAll(() async { - app = await testContext.createTestApp(); - }); - blocTest( - "reorder documents", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - act: (bloc) async { - bloc.add(AppEvent.createView("1", DocumentPluginBuilder())); - await blocResponseFuture(); - bloc.add(AppEvent.createView("2", DocumentPluginBuilder())); - await blocResponseFuture(); - bloc.add(AppEvent.createView("3", DocumentPluginBuilder())); - await blocResponseFuture(); - - final appViewData = AppViewDataContext(appId: app.id); - appViewData.views = bloc.state.views; - final viewSectionBloc = ViewSectionBloc( - appViewData: appViewData, - )..add(const ViewSectionEvent.initial()); - await blocResponseFuture(); - - viewSectionBloc.add(const ViewSectionEvent.moveView(0, 2)); - await blocResponseFuture(); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.views[0].name == '2'); - assert(bloc.state.views[1].name == '3'); - assert(bloc.state.views[2].name == '1'); - }, - ); - }); - - group('$AppBloc', () { - late AppPB app; - setUpAll(() async { - app = await testContext.createTestApp(); - }); - blocTest( - "assert initial latest create view is null after initialize", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.latestCreatedView == null); - }, - ); - blocTest( - "create a view and assert the latest create view is this view", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - act: (bloc) async { - bloc.add(AppEvent.createView("1", DocumentPluginBuilder())); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.latestCreatedView!.id == bloc.state.views.last.id); - }, - ); - - blocTest( - "create a view and assert the latest create view is this view", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - act: (bloc) async { - bloc.add(AppEvent.createView("2", DocumentPluginBuilder())); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.views[0].name == "1"); - assert(bloc.state.latestCreatedView!.id == bloc.state.views.last.id); - }, - ); - blocTest( - "check latest create view is null after reinitialize", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.latestCreatedView == null); - }, - ); - }); - - group('$AppBloc', () { - late AppPB app; - late ViewPB latestCreatedView; - setUpAll(() async { - app = await testContext.createTestApp(); - }); - -// Document - blocTest( - "create a document view", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - act: (bloc) async { - bloc.add(AppEvent.createView("New document", DocumentPluginBuilder())); - }, - wait: blocResponseDuration(), - verify: (bloc) { - latestCreatedView = bloc.state.views.last; - }, - ); - - blocTest( - "open the document", - build: () => DocumentBloc(view: latestCreatedView) - ..add(const DocumentEvent.initial()), - wait: blocResponseDuration(), - ); - - test('check latest opened view is this document', () async { - final workspaceSetting = await FolderEventReadCurrentWorkspace() - .send() - .then((result) => result.fold((l) => l, (r) => throw Exception())); - workspaceSetting.latestView.id == latestCreatedView.id; - }); - -// Grid - blocTest( - "create a grid view", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - act: (bloc) async { - bloc.add(AppEvent.createView("New grid", GridPluginBuilder())); - }, - wait: blocResponseDuration(), - verify: (bloc) { - latestCreatedView = bloc.state.views.last; - }, - ); - blocTest( - "open the grid", - build: () => - GridBloc(view: latestCreatedView)..add(const GridEvent.initial()), - wait: blocResponseDuration(), - ); - - test('check latest opened view is this grid', () async { - final workspaceSetting = await FolderEventReadCurrentWorkspace() - .send() - .then((result) => result.fold((l) => l, (r) => throw Exception())); - workspaceSetting.latestView.id == latestCreatedView.id; - }); - -// Board - blocTest( - "create a board view", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - act: (bloc) async { - bloc.add(AppEvent.createView("New board", BoardPluginBuilder())); - }, - wait: blocResponseDuration(), - verify: (bloc) { - latestCreatedView = bloc.state.views.last; - }, - ); - - blocTest( - "open the board", - build: () => - BoardBloc(view: latestCreatedView)..add(const BoardEvent.initial()), - wait: blocResponseDuration(), - ); - - test('check latest opened view is this board', () async { - final workspaceSetting = await FolderEventReadCurrentWorkspace() - .send() - .then((result) => result.fold((l) => l, (r) => throw Exception())); - workspaceSetting.latestView.id == latestCreatedView.id; - }); - }); -} diff --git a/frontend/app_flowy/test/bloc_test/home_test/home_bloc_test.dart b/frontend/app_flowy/test/bloc_test/home_test/home_bloc_test.dart deleted file mode 100644 index 97a6f5f506375..0000000000000 --- a/frontend/app_flowy/test/bloc_test/home_test/home_bloc_test.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:app_flowy/plugins/doc/application/doc_bloc.dart'; -import 'package:app_flowy/plugins/doc/document.dart'; -import 'package:app_flowy/workspace/application/app/app_bloc.dart'; -import 'package:app_flowy/workspace/application/home/home_bloc.dart'; -import 'package:bloc_test/bloc_test.dart'; -import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../util.dart'; - -void main() { - late AppFlowyUnitTest testContext; - late WorkspaceSettingPB workspaceSetting; - setUpAll(() async { - testContext = await AppFlowyUnitTest.ensureInitialized(); - }); - - setUp(() async { - workspaceSetting = await FolderEventReadCurrentWorkspace() - .send() - .then((result) => result.fold((l) => l, (r) => throw Exception())); - await blocResponseFuture(); - }); - - group('$HomeBloc', () { - blocTest( - "initial", - build: () => HomeBloc(testContext.userProfile, workspaceSetting) - ..add(const HomeEvent.initial()), - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.workspaceSetting.hasLatestView()); - }, - ); - }); - - group('$HomeBloc', () { - late AppPB app; - late ViewPB latestCreatedView; - late HomeBloc homeBloc; - setUpAll(() async { - app = await testContext.createTestApp(); - homeBloc = HomeBloc(testContext.userProfile, workspaceSetting) - ..add(const HomeEvent.initial()); - }); - - blocTest( - "create a document view", - build: () => AppBloc(app: app)..add(const AppEvent.initial()), - act: (bloc) async { - bloc.add(AppEvent.createView("New document", DocumentPluginBuilder())); - }, - wait: blocResponseDuration(), - verify: (bloc) { - latestCreatedView = bloc.state.views.last; - }, - ); - - blocTest( - "open the document", - build: () => DocumentBloc(view: latestCreatedView) - ..add(const DocumentEvent.initial()), - wait: blocResponseDuration(), - ); - - test('check the latest view is the document', () async { - assert(homeBloc.state.workspaceSetting.latestView.id == - latestCreatedView.id); - }); - }); -} diff --git a/frontend/app_flowy/test/bloc_test/home_test/menu_bloc_test.dart b/frontend/app_flowy/test/bloc_test/home_test/menu_bloc_test.dart deleted file mode 100644 index c8f38d9250891..0000000000000 --- a/frontend/app_flowy/test/bloc_test/home_test/menu_bloc_test.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:app_flowy/workspace/application/menu/menu_bloc.dart'; -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../util.dart'; - -void main() { - late AppFlowyUnitTest test; - setUpAll(() async { - test = await AppFlowyUnitTest.ensureInitialized(); - }); - - group('$MenuBloc', () { - late MenuBloc menuBloc; - setUp(() async { - menuBloc = MenuBloc( - user: test.userProfile, - workspace: test.currentWorkspace, - )..add(const MenuEvent.initial()); - - await blocResponseFuture(); - }); - blocTest( - "assert initial apps is the build-in app", - build: () => menuBloc, - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.apps.length == 1); - }, - ); - // - blocTest( - "create apps", - build: () => menuBloc, - act: (bloc) async { - bloc.add(const MenuEvent.createApp("App 1")); - await blocResponseFuture(); - bloc.add(const MenuEvent.createApp("App 2")); - await blocResponseFuture(); - bloc.add(const MenuEvent.createApp("App 3")); - }, - wait: blocResponseDuration(), - verify: (bloc) { - // apps[0] is the build-in app - assert(bloc.state.apps[1].name == 'App 1'); - assert(bloc.state.apps[2].name == 'App 2'); - assert(bloc.state.apps[3].name == 'App 3'); - }, - ); - blocTest( - "reorder apps", - build: () => menuBloc, - act: (bloc) async { - bloc.add(const MenuEvent.moveApp(1, 3)); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.apps[1].name == 'App 2'); - assert(bloc.state.apps[2].name == 'App 3'); - assert(bloc.state.apps[3].name == 'App 1'); - }, - ); - }); - - // -} diff --git a/frontend/app_flowy/test/bloc_test/home_test/trash_bloc_test.dart b/frontend/app_flowy/test/bloc_test/home_test/trash_bloc_test.dart deleted file mode 100644 index bebe459a52695..0000000000000 --- a/frontend/app_flowy/test/bloc_test/home_test/trash_bloc_test.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:app_flowy/plugins/doc/document.dart'; -import 'package:app_flowy/plugins/trash/application/trash_bloc.dart'; -import 'package:app_flowy/workspace/application/app/app_bloc.dart'; -import 'package:bloc_test/bloc_test.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../util.dart'; - -void main() { - late AppFlowyUnitTest test; - late AppPB app; - late AppBloc appBloc; - late TrashBloc trashBloc; - setUpAll(() async { - test = await AppFlowyUnitTest.ensureInitialized(); - }); - - // 1. Create three views - // 2. Delete a view and check the state - // 3. Delete all views and check the state - // 4. Put back a view - // 5. Put back all views - group('$TrashBloc', () { - late ViewPB deletedView; - late List allViews; - setUpAll(() async { - /// Create a new app with three documents - app = await test.createTestApp(); - appBloc = AppBloc(app: app) - ..add(const AppEvent.initial()) - ..add(AppEvent.createView( - "Document 1", - DocumentPluginBuilder(), - )) - ..add(AppEvent.createView( - "Document 2", - DocumentPluginBuilder(), - )) - ..add( - AppEvent.createView( - "Document 3", - DocumentPluginBuilder(), - ), - ); - await blocResponseFuture(millisecond: 200); - allViews = [...appBloc.state.app.belongings.items]; - assert(allViews.length == 3); - }); - - setUp(() async { - trashBloc = TrashBloc()..add(const TrashEvent.initial()); - await blocResponseFuture(); - }); - - blocTest( - "delete a view", - build: () => trashBloc, - act: (bloc) async { - deletedView = appBloc.state.app.belongings.items[0]; - appBloc.add(AppEvent.deleteView(deletedView.id)); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(appBloc.state.app.belongings.items.length == 2); - assert(bloc.state.objects.length == 1); - assert(bloc.state.objects.first.id == deletedView.id); - }, - ); - - blocTest( - "delete all views", - build: () => trashBloc, - act: (bloc) async { - for (final view in appBloc.state.app.belongings.items) { - appBloc.add(AppEvent.deleteView(view.id)); - await blocResponseFuture(); - } - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.objects[0].id == allViews[0].id); - assert(bloc.state.objects[1].id == allViews[1].id); - assert(bloc.state.objects[2].id == allViews[2].id); - }, - ); - blocTest( - "put back a trash", - build: () => trashBloc, - act: (bloc) async { - bloc.add(TrashEvent.putback(allViews[0].id)); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(appBloc.state.app.belongings.items.length == 1); - assert(bloc.state.objects.length == 2); - }, - ); - blocTest( - "put back all trash", - build: () => trashBloc, - act: (bloc) async { - bloc.add(const TrashEvent.restoreAll()); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(appBloc.state.app.belongings.items.length == 3); - assert(bloc.state.objects.isEmpty); - }, - ); - // - }); - - // 1. Create three views - // 2. Delete a trash permanently and check the state - // 3. Delete all views permanently - group('$TrashBloc', () { - setUpAll(() async { - /// Create a new app with three documents - app = await test.createTestApp(); - appBloc = AppBloc(app: app) - ..add(const AppEvent.initial()) - ..add(AppEvent.createView( - "Document 1", - DocumentPluginBuilder(), - )) - ..add(AppEvent.createView( - "Document 2", - DocumentPluginBuilder(), - )) - ..add( - AppEvent.createView( - "Document 3", - DocumentPluginBuilder(), - ), - ); - await blocResponseFuture(millisecond: 200); - }); - - setUp(() async { - trashBloc = TrashBloc()..add(const TrashEvent.initial()); - await blocResponseFuture(); - }); - - blocTest( - "delete a view permanently", - build: () => trashBloc, - act: (bloc) async { - final view = appBloc.state.app.belongings.items[0]; - appBloc.add(AppEvent.deleteView(view.id)); - await blocResponseFuture(); - - trashBloc.add(TrashEvent.delete(trashBloc.state.objects[0])); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(appBloc.state.app.belongings.items.length == 2); - assert(bloc.state.objects.isEmpty); - }, - ); - blocTest( - "delete all view permanently", - build: () => trashBloc, - act: (bloc) async { - for (final view in appBloc.state.app.belongings.items) { - appBloc.add(AppEvent.deleteView(view.id)); - await blocResponseFuture(); - } - trashBloc.add(const TrashEvent.deleteAll()); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(appBloc.state.app.belongings.items.isEmpty); - assert(bloc.state.objects.isEmpty); - }, - ); - }); -} diff --git a/frontend/app_flowy/test/bloc_test/home_test/view_bloc_test.dart b/frontend/app_flowy/test/bloc_test/home_test/view_bloc_test.dart deleted file mode 100644 index b80e3e52822df..0000000000000 --- a/frontend/app_flowy/test/bloc_test/home_test/view_bloc_test.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:app_flowy/plugins/doc/document.dart'; -import 'package:app_flowy/workspace/application/app/app_bloc.dart'; -import 'package:app_flowy/workspace/application/view/view_bloc.dart'; -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../util.dart'; - -void main() { - late AppFlowyUnitTest test; - setUpAll(() async { - test = await AppFlowyUnitTest.ensureInitialized(); - }); - - group('$ViewBloc', () { - late AppBloc appBloc; - - setUpAll(() async { - final app = await test.createTestApp(); - appBloc = AppBloc(app: app)..add(const AppEvent.initial()); - appBloc.add(AppEvent.createView( - "Test document", - DocumentPluginBuilder(), - )); - await blocResponseFuture(); - }); - - blocTest( - "rename view", - build: () => ViewBloc(view: appBloc.state.views.first) - ..add(const ViewEvent.initial()), - act: (bloc) { - bloc.add(const ViewEvent.rename('Hello world')); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(bloc.state.view.name == "Hello world"); - }, - ); - - blocTest( - "duplicate view", - build: () => ViewBloc(view: appBloc.state.views.first) - ..add(const ViewEvent.initial()), - act: (bloc) { - bloc.add(const ViewEvent.duplicate()); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(appBloc.state.views.length == 2); - }, - ); - - blocTest( - "delete view", - build: () => ViewBloc(view: appBloc.state.views.first) - ..add(const ViewEvent.initial()), - act: (bloc) { - bloc.add(const ViewEvent.delete()); - }, - wait: blocResponseDuration(), - verify: (bloc) { - assert(appBloc.state.views.length == 1); - }, - ); - }); -} diff --git a/frontend/app_flowy/test/util.dart b/frontend/app_flowy/test/util.dart deleted file mode 100644 index 3637cf0a2293c..0000000000000 --- a/frontend/app_flowy/test/util.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/user/application/auth_service.dart'; -import 'package:app_flowy/user/application/user_service.dart'; -import 'package:app_flowy/workspace/application/workspace/workspace_service.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/uuid.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/app.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-folder/workspace.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-user/protobuf.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:app_flowy/main.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class AppFlowyIntegrateTest { - static Future ensureInitialized() async { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - SharedPreferences.setMockInitialValues({}); - main(); - return AppFlowyIntegrateTest(); - } -} - -class AppFlowyUnitTest { - late UserProfilePB userProfile; - late UserService userService; - late WorkspaceService workspaceService; - late List workspaces; - - static Future ensureInitialized() async { - TestWidgetsFlutterBinding.ensureInitialized(); - SharedPreferences.setMockInitialValues({}); - _pathProviderInitialized(); - - await EasyLocalization.ensureInitialized(); - await FlowyRunner.run(FlowyTestApp()); - - final test = AppFlowyUnitTest(); - await test._signIn(); - await test._loadWorkspace(); - - await test._initialServices(); - return test; - } - - Future _signIn() async { - final authService = getIt(); - const password = "AppFlowy123@"; - final uid = uuid(); - final userEmail = "$uid@appflowy.io"; - final result = await authService.signUp( - name: "TestUser", - password: password, - email: userEmail, - ); - return result.fold( - (user) { - userProfile = user; - userService = UserService(userId: userProfile.id); - }, - (error) {}, - ); - } - - WorkspacePB get currentWorkspace => workspaces[0]; - - Future _loadWorkspace() async { - final result = await userService.getWorkspaces(); - result.fold( - (value) => workspaces = value, - (error) { - throw Exception(error); - }, - ); - } - - Future _initialServices() async { - workspaceService = WorkspaceService(workspaceId: currentWorkspace.id); - } - - Future createTestApp() async { - final result = await workspaceService.createApp(name: "Test App"); - return result.fold( - (app) => app, - (error) => throw Exception(error), - ); - } - - Future> loadApps() async { - final result = await workspaceService.getApps(); - - return result.fold( - (apps) => apps, - (error) => throw Exception(error), - ); - } -} - -void _pathProviderInitialized() { - const MethodChannel channel = - MethodChannel('plugins.flutter.io/path_provider'); - channel.setMockMethodCallHandler((MethodCall methodCall) async { - return "."; - }); -} - -class FlowyTestApp implements EntryPoint { - @override - Widget create() { - return Container(); - } -} - -Future blocResponseFuture({int millisecond = 200}) { - return Future.delayed(Duration(milliseconds: millisecond)); -} - -Duration blocResponseDuration({int milliseconds = 200}) { - return Duration(milliseconds: milliseconds); -} diff --git a/frontend/app_flowy/test/widget_test/select_option_text_field_test.dart b/frontend/app_flowy/test/widget_test/select_option_text_field_test.dart deleted file mode 100644 index a8f0168f45df5..0000000000000 --- a/frontend/app_flowy/test/widget_test/select_option_text_field_test.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:collection'; - -import 'package:app_flowy/plugins/grid/presentation/widgets/cell/select_option_cell/text_field.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:provider/provider.dart'; -import 'package:textfield_tags/textfield_tags.dart'; - -import '../bloc_test/grid_test/util.dart'; - -void main() { - setUpAll(() { - AppFlowyGridTest.ensureInitialized(); - }); - - group('text_field.dart', () { - String submit = ''; - String remainder = ''; - List select = []; - - final textField = SelectOptionTextField( - options: const [], - selectedOptionMap: LinkedHashMap(), - distanceToText: 0.0, - tagController: TextfieldTagsController(), - onSubmitted: (text) => submit = text, - onPaste: (options, remaining) { - remainder = remaining; - select = options; - }, - newText: (_) {}, - textSeparators: const [','], - textController: TextEditingController(), - ); - - testWidgets('SelectOptionTextField callback outputs', - (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: Material( - child: Provider.value( - value: AppTheme.fromType(ThemeType.light), - child: textField, - ), - ), - ), - ); - - // test that the input field exists - expect(find.byType(TextField), findsOneWidget); - - // simulate normal input - await tester.enterText(find.byType(TextField), 'abcd'); - expect(remainder, 'abcd'); - - await tester.enterText(find.byType(TextField), ' '); - expect(remainder, ''); - - // test submit functionality (aka pressing enter) - await tester.enterText(find.byType(TextField), 'an option'); - await tester.testTextInput.receiveAction(TextInputAction.done); - expect(submit, 'an option'); - - await tester.enterText(find.byType(TextField), ' another one '); - await tester.testTextInput.receiveAction(TextInputAction.done); - expect(submit, 'another one'); - - // test inputs containing commas - await tester.enterText(find.byType(TextField), ' abcd,'); - expect(remainder, ''); - expect(select, ['abcd']); - - await tester.enterText(find.byType(TextField), ',acd, aaaa '); - expect(remainder, 'aaaa '); - expect(select, ['acd']); - - await tester.enterText(find.byType(TextField), 'a a, bbbb , '); - expect(remainder, ''); - expect(select, ['a a', 'bbbb']); - - // test paste followed by submit - await tester.enterText(find.byType(TextField), 'aaa, bbb, c'); - await tester.testTextInput.receiveAction(TextInputAction.done); - expect(select, ['aaa', 'bbb']); - expect(submit, 'c'); - }); - }); -} diff --git a/frontend/app_flowy/web/favicon.png b/frontend/app_flowy/web/favicon.png deleted file mode 100644 index 8aaa46ac1ae21..0000000000000 Binary files a/frontend/app_flowy/web/favicon.png and /dev/null differ diff --git a/frontend/app_flowy/web/icons/Icon-192.png b/frontend/app_flowy/web/icons/Icon-192.png deleted file mode 100644 index b749bfef07473..0000000000000 Binary files a/frontend/app_flowy/web/icons/Icon-192.png and /dev/null differ diff --git a/frontend/app_flowy/web/icons/Icon-512.png b/frontend/app_flowy/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48dff116..0000000000000 Binary files a/frontend/app_flowy/web/icons/Icon-512.png and /dev/null differ diff --git a/frontend/app_flowy/web/icons/Icon-maskable-192.png b/frontend/app_flowy/web/icons/Icon-maskable-192.png deleted file mode 100644 index eb9b4d76e5255..0000000000000 Binary files a/frontend/app_flowy/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/frontend/app_flowy/web/icons/Icon-maskable-512.png b/frontend/app_flowy/web/icons/Icon-maskable-512.png deleted file mode 100644 index d69c56691fbdb..0000000000000 Binary files a/frontend/app_flowy/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/frontend/app_flowy/web/index.html b/frontend/app_flowy/web/index.html deleted file mode 100644 index 284a6312ec89e..0000000000000 --- a/frontend/app_flowy/web/index.html +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - app_flowy - - - - - - - diff --git a/frontend/app_flowy/web/manifest.json b/frontend/app_flowy/web/manifest.json deleted file mode 100644 index 05b16ccb95f0c..0000000000000 --- a/frontend/app_flowy/web/manifest.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "app_flowy", - "short_name": "app_flowy", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - } - ] -} diff --git a/frontend/app_flowy/windows/CMakeLists.txt b/frontend/app_flowy/windows/CMakeLists.txt deleted file mode 100644 index 8cd643ca40181..0000000000000 --- a/frontend/app_flowy/windows/CMakeLists.txt +++ /dev/null @@ -1,99 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -project(app_flowy LANGUAGES CXX) - -set(BINARY_NAME "app_flowy") - -cmake_policy(SET CMP0063 NEW) - -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Configure build options. -get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) -if(IS_MULTICONFIG) - set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" - CACHE STRING "" FORCE) -else() - if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") - endif() -endif() - -set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") -set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") -set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") -set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") - -# Use Unicode for all projects. -add_definitions(-DUNICODE -D_UNICODE) - -# Compilation settings that should be applied to most targets. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") - target_compile_options(${TARGET} PRIVATE /EHsc) - target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") - target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") -endfunction() - -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") - -# Flutter library and tool build rules. -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# Application build -add_subdirectory("runner") - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# Support files are copied into place next to the executable, so that it can -# run in place. This is done instead of making a separate bundle (as on Linux) -# so that building and running from within Visual Studio will work. -set(BUILD_BUNDLE_DIR "$") -# Make the "install" step default, as it's required to run. -set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -set(DART_FFI_DIR "${CMAKE_INSTALL_PREFIX}") -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${DART_FFI_DLL}" DESTINATION "${DART_FFI_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -if(PLUGIN_BUNDLED_LIBRARIES) - install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - CONFIGURATIONS Profile;Release - COMPONENT Runtime) diff --git a/frontend/app_flowy/windows/flutter/CMakeLists.txt b/frontend/app_flowy/windows/flutter/CMakeLists.txt deleted file mode 100644 index 8ec917b8c3143..0000000000000 --- a/frontend/app_flowy/windows/flutter/CMakeLists.txt +++ /dev/null @@ -1,104 +0,0 @@ -cmake_minimum_required(VERSION 3.14) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. -set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") - -# === Flutter Library === -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(DART_FFI_DLL "${CMAKE_CURRENT_SOURCE_DIR}/dart_ffi/dart_ffi.dll" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "flutter_export.h" - "flutter_windows.h" - "flutter_messenger.h" - "flutter_plugin_registrar.h" - "flutter_texture_registrar.h" -) -list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") -add_dependencies(flutter flutter_assemble) - -# === Wrapper === -list(APPEND CPP_WRAPPER_SOURCES_CORE - "core_implementations.cc" - "standard_codec.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_PLUGIN - "plugin_registrar.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_APP - "flutter_engine.cc" - "flutter_view_controller.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") - -# Wrapper sources needed for a plugin. -add_library(flutter_wrapper_plugin STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} -) -apply_standard_settings(flutter_wrapper_plugin) -set_target_properties(flutter_wrapper_plugin PROPERTIES - POSITION_INDEPENDENT_CODE ON) -set_target_properties(flutter_wrapper_plugin PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) -target_include_directories(flutter_wrapper_plugin PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_plugin flutter_assemble) - -# Wrapper sources needed for the runner. -add_library(flutter_wrapper_app STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_APP} -) -apply_standard_settings(flutter_wrapper_app) -target_link_libraries(flutter_wrapper_app PUBLIC flutter) -target_include_directories(flutter_wrapper_app PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_app flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") -set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} - ${PHONY_OUTPUT} - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} -) diff --git a/frontend/app_flowy/windows/runner/CMakeLists.txt b/frontend/app_flowy/windows/runner/CMakeLists.txt deleted file mode 100644 index df6e8b99b5dd2..0000000000000 --- a/frontend/app_flowy/windows/runner/CMakeLists.txt +++ /dev/null @@ -1,22 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -project(runner LANGUAGES CXX) - -add_executable(${BINARY_NAME} WIN32 - "flutter_window.cpp" - "main.cpp" - "utils.cpp" - "win32_window.cpp" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" - "Runner.rc" - "runner.exe.manifest" -) -apply_standard_settings(${BINARY_NAME}) -target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") -target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") -add_dependencies(${BINARY_NAME} flutter_assemble) - - -# === Flutter Library === -#set(DART_FFI "${CMAKE_CURRENT_SOURCE_DIR}/dart_ffi/dart_ffi.dll") -#set(DART_FFI ${DART_FFI} PARENT_SCOPE) \ No newline at end of file diff --git a/frontend/app_flowy/windows/runner/Runner.rc b/frontend/app_flowy/windows/runner/Runner.rc deleted file mode 100644 index a4a5a67fb0dc5..0000000000000 --- a/frontend/app_flowy/windows/runner/Runner.rc +++ /dev/null @@ -1,121 +0,0 @@ -// Microsoft Visual C++ generated resource script. -// -#pragma code_page(65001) -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_APP_ICON ICON "resources\\app_icon.ico" - - -///////////////////////////////////////////////////////////////////////////// -// -// Version -// - -#ifdef FLUTTER_BUILD_NUMBER -#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER -#else -#define VERSION_AS_NUMBER 1,0,0 -#endif - -#ifdef FLUTTER_BUILD_NAME -#define VERSION_AS_STRING #FLUTTER_BUILD_NAME -#else -#define VERSION_AS_STRING "1.0.0" -#endif - -VS_VERSION_INFO VERSIONINFO - FILEVERSION VERSION_AS_NUMBER - PRODUCTVERSION VERSION_AS_NUMBER - FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG - FILEFLAGS VS_FF_DEBUG -#else - FILEFLAGS 0x0L -#endif - FILEOS VOS__WINDOWS32 - FILETYPE VFT_APP - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904e4" - BEGIN - VALUE "CompanyName", "com.example" "\0" - VALUE "FileDescription", "A new Flutter project." "\0" - VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "app_flowy" "\0" - VALUE "LegalCopyright", "Copyright (C) 2021 com.example. All rights reserved." "\0" - VALUE "OriginalFilename", "app_flowy.exe" "\0" - VALUE "ProductName", "app_flowy" "\0" - VALUE "ProductVersion", VERSION_AS_STRING "\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED diff --git a/frontend/app_flowy/windows/runner/flutter_window.cpp b/frontend/app_flowy/windows/runner/flutter_window.cpp deleted file mode 100644 index b43b9095ea3aa..0000000000000 --- a/frontend/app_flowy/windows/runner/flutter_window.cpp +++ /dev/null @@ -1,61 +0,0 @@ -#include "flutter_window.h" - -#include - -#include "flutter/generated_plugin_registrant.h" - -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} - -FlutterWindow::~FlutterWindow() {} - -bool FlutterWindow::OnCreate() { - if (!Win32Window::OnCreate()) { - return false; - } - - RECT frame = GetClientArea(); - - // The size here must match the window dimensions to avoid unnecessary surface - // creation / destruction in the startup path. - flutter_controller_ = std::make_unique( - frame.right - frame.left, frame.bottom - frame.top, project_); - // Ensure that basic setup of the controller was successful. - if (!flutter_controller_->engine() || !flutter_controller_->view()) { - return false; - } - RegisterPlugins(flutter_controller_->engine()); - SetChildContent(flutter_controller_->view()->GetNativeWindow()); - return true; -} - -void FlutterWindow::OnDestroy() { - if (flutter_controller_) { - flutter_controller_ = nullptr; - } - - Win32Window::OnDestroy(); -} - -LRESULT -FlutterWindow::MessageHandler(HWND hwnd, UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - // Give Flutter, including plugins, an opportunity to handle window messages. - if (flutter_controller_) { - std::optional result = - flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, - lparam); - if (result) { - return *result; - } - } - - switch (message) { - case WM_FONTCHANGE: - flutter_controller_->engine()->ReloadSystemFonts(); - break; - } - - return Win32Window::MessageHandler(hwnd, message, wparam, lparam); -} diff --git a/frontend/app_flowy/windows/runner/flutter_window.h b/frontend/app_flowy/windows/runner/flutter_window.h deleted file mode 100644 index 6da0652f05f28..0000000000000 --- a/frontend/app_flowy/windows/runner/flutter_window.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef RUNNER_FLUTTER_WINDOW_H_ -#define RUNNER_FLUTTER_WINDOW_H_ - -#include -#include - -#include - -#include "win32_window.h" - -// A window that does nothing but host a Flutter view. -class FlutterWindow : public Win32Window { - public: - // Creates a new FlutterWindow hosting a Flutter view running |project|. - explicit FlutterWindow(const flutter::DartProject& project); - virtual ~FlutterWindow(); - - protected: - // Win32Window: - bool OnCreate() override; - void OnDestroy() override; - LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, - LPARAM const lparam) noexcept override; - - private: - // The project to run. - flutter::DartProject project_; - - // The Flutter instance hosted by this window. - std::unique_ptr flutter_controller_; -}; - -#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/frontend/app_flowy/windows/runner/main.cpp b/frontend/app_flowy/windows/runner/main.cpp deleted file mode 100644 index 7e58d9fad12f0..0000000000000 --- a/frontend/app_flowy/windows/runner/main.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include -#include -#include - -#include "flutter_window.h" -#include "utils.h" - -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { - // Attach to console when present (e.g., 'flutter run') or create a - // new console when running with a debugger. - if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { - CreateAndAttachConsole(); - } - - // Initialize COM, so that it is available for use in the library and/or - // plugins. - ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - - flutter::DartProject project(L"data"); - - std::vector command_line_arguments = - GetCommandLineArguments(); - - project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - - FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); - if (!window.CreateAndShow(L"app_flowy", origin, size)) { - return EXIT_FAILURE; - } - window.SetQuitOnClose(true); - - ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { - ::TranslateMessage(&msg); - ::DispatchMessage(&msg); - } - - ::CoUninitialize(); - return EXIT_SUCCESS; -} diff --git a/frontend/app_flowy/windows/runner/resource.h b/frontend/app_flowy/windows/runner/resource.h deleted file mode 100644 index 66a65d1e4a79f..0000000000000 --- a/frontend/app_flowy/windows/runner/resource.h +++ /dev/null @@ -1,16 +0,0 @@ -//{{NO_DEPENDENCIES}} -// Microsoft Visual C++ generated include file. -// Used by Runner.rc -// -#define IDI_APP_ICON 101 - -// Next default values for new objects -// -#ifdef APSTUDIO_INVOKED -#ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 102 -#define _APS_NEXT_COMMAND_VALUE 40001 -#define _APS_NEXT_CONTROL_VALUE 1001 -#define _APS_NEXT_SYMED_VALUE 101 -#endif -#endif diff --git a/frontend/app_flowy/windows/runner/runner.exe.manifest b/frontend/app_flowy/windows/runner/runner.exe.manifest deleted file mode 100644 index c977c4a42589b..0000000000000 --- a/frontend/app_flowy/windows/runner/runner.exe.manifest +++ /dev/null @@ -1,20 +0,0 @@ - - - - - PerMonitorV2 - - - - - - - - - - - - - - - diff --git a/frontend/app_flowy/windows/runner/utils.h b/frontend/app_flowy/windows/runner/utils.h deleted file mode 100644 index 3879d54755798..0000000000000 --- a/frontend/app_flowy/windows/runner/utils.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef RUNNER_UTILS_H_ -#define RUNNER_UTILS_H_ - -#include -#include - -// Creates a console for the process, and redirects stdout and stderr to -// it for both the runner and the Flutter library. -void CreateAndAttachConsole(); - -// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string -// encoded in UTF-8. Returns an empty std::string on failure. -std::string Utf8FromUtf16(const wchar_t* utf16_string); - -// Gets the command line arguments passed in as a std::vector, -// encoded in UTF-8. Returns an empty std::vector on failure. -std::vector GetCommandLineArguments(); - -#endif // RUNNER_UTILS_H_ diff --git a/frontend/app_flowy/windows/runner/win32_window.cpp b/frontend/app_flowy/windows/runner/win32_window.cpp deleted file mode 100644 index c10f08dc7da60..0000000000000 --- a/frontend/app_flowy/windows/runner/win32_window.cpp +++ /dev/null @@ -1,245 +0,0 @@ -#include "win32_window.h" - -#include - -#include "resource.h" - -namespace { - -constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; - -// The number of Win32Window objects that currently exist. -static int g_active_window_count = 0; - -using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); - -// Scale helper to convert logical scaler values to physical using passed in -// scale factor -int Scale(int source, double scale_factor) { - return static_cast(source * scale_factor); -} - -// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. -// This API is only needed for PerMonitor V1 awareness mode. -void EnableFullDpiSupportIfAvailable(HWND hwnd) { - HMODULE user32_module = LoadLibraryA("User32.dll"); - if (!user32_module) { - return; - } - auto enable_non_client_dpi_scaling = - reinterpret_cast( - GetProcAddress(user32_module, "EnableNonClientDpiScaling")); - if (enable_non_client_dpi_scaling != nullptr) { - enable_non_client_dpi_scaling(hwnd); - FreeLibrary(user32_module); - } -} - -} // namespace - -// Manages the Win32Window's window class registration. -class WindowClassRegistrar { - public: - ~WindowClassRegistrar() = default; - - // Returns the singleton registar instance. - static WindowClassRegistrar* GetInstance() { - if (!instance_) { - instance_ = new WindowClassRegistrar(); - } - return instance_; - } - - // Returns the name of the window class, registering the class if it hasn't - // previously been registered. - const wchar_t* GetWindowClass(); - - // Unregisters the window class. Should only be called if there are no - // instances of the window. - void UnregisterWindowClass(); - - private: - WindowClassRegistrar() = default; - - static WindowClassRegistrar* instance_; - - bool class_registered_ = false; -}; - -WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; - -const wchar_t* WindowClassRegistrar::GetWindowClass() { - if (!class_registered_) { - WNDCLASS window_class{}; - window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); - window_class.lpszClassName = kWindowClassName; - window_class.style = CS_HREDRAW | CS_VREDRAW; - window_class.cbClsExtra = 0; - window_class.cbWndExtra = 0; - window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); - window_class.hbrBackground = 0; - window_class.lpszMenuName = nullptr; - window_class.lpfnWndProc = Win32Window::WndProc; - RegisterClass(&window_class); - class_registered_ = true; - } - return kWindowClassName; -} - -void WindowClassRegistrar::UnregisterWindowClass() { - UnregisterClass(kWindowClassName, nullptr); - class_registered_ = false; -} - -Win32Window::Win32Window() { - ++g_active_window_count; -} - -Win32Window::~Win32Window() { - --g_active_window_count; - Destroy(); -} - -bool Win32Window::CreateAndShow(const std::wstring& title, - const Point& origin, - const Size& size) { - Destroy(); - - const wchar_t* window_class = - WindowClassRegistrar::GetInstance()->GetWindowClass(); - - const POINT target_point = {static_cast(origin.x), - static_cast(origin.y)}; - HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); - UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); - double scale_factor = dpi / 96.0; - - HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, - Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), - Scale(size.width, scale_factor), Scale(size.height, scale_factor), - nullptr, nullptr, GetModuleHandle(nullptr), this); - - if (!window) { - return false; - } - - return OnCreate(); -} - -// static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); - - auto that = static_cast(window_struct->lpCreateParams); - EnableFullDpiSupportIfAvailable(window); - that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { - return that->MessageHandler(window, message, wparam, lparam); - } - - return DefWindowProc(window, message, wparam, lparam); -} - -LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - switch (message) { - case WM_DESTROY: - window_handle_ = nullptr; - Destroy(); - if (quit_on_close_) { - PostQuitMessage(0); - } - return 0; - - case WM_DPICHANGED: { - auto newRectSize = reinterpret_cast(lparam); - LONG newWidth = newRectSize->right - newRectSize->left; - LONG newHeight = newRectSize->bottom - newRectSize->top; - - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); - - return 0; - } - case WM_SIZE: { - RECT rect = GetClientArea(); - if (child_content_ != nullptr) { - // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return 0; - } - - case WM_ACTIVATE: - if (child_content_ != nullptr) { - SetFocus(child_content_); - } - return 0; - } - - return DefWindowProc(window_handle_, message, wparam, lparam); -} - -void Win32Window::Destroy() { - OnDestroy(); - - if (window_handle_) { - DestroyWindow(window_handle_); - window_handle_ = nullptr; - } - if (g_active_window_count == 0) { - WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); - } -} - -Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( - GetWindowLongPtr(window, GWLP_USERDATA)); -} - -void Win32Window::SetChildContent(HWND content) { - child_content_ = content; - SetParent(content, window_handle_); - RECT frame = GetClientArea(); - - MoveWindow(content, frame.left, frame.top, frame.right - frame.left, - frame.bottom - frame.top, true); - - SetFocus(child_content_); -} - -RECT Win32Window::GetClientArea() { - RECT frame; - GetClientRect(window_handle_, &frame); - return frame; -} - -HWND Win32Window::GetHandle() { - return window_handle_; -} - -void Win32Window::SetQuitOnClose(bool quit_on_close) { - quit_on_close_ = quit_on_close; -} - -bool Win32Window::OnCreate() { - // No-op; provided for subclasses. - return true; -} - -void Win32Window::OnDestroy() { - // No-op; provided for subclasses. -} diff --git a/frontend/app_flowy/windows/runner/win32_window.h b/frontend/app_flowy/windows/runner/win32_window.h deleted file mode 100644 index 17ba431125b4b..0000000000000 --- a/frontend/app_flowy/windows/runner/win32_window.h +++ /dev/null @@ -1,98 +0,0 @@ -#ifndef RUNNER_WIN32_WINDOW_H_ -#define RUNNER_WIN32_WINDOW_H_ - -#include - -#include -#include -#include - -// A class abstraction for a high DPI-aware Win32 Window. Intended to be -// inherited from by classes that wish to specialize with custom -// rendering and input handling -class Win32Window { - public: - struct Point { - unsigned int x; - unsigned int y; - Point(unsigned int x, unsigned int y) : x(x), y(y) {} - }; - - struct Size { - unsigned int width; - unsigned int height; - Size(unsigned int width, unsigned int height) - : width(width), height(height) {} - }; - - Win32Window(); - virtual ~Win32Window(); - - // Creates and shows a win32 window with |title| and position and size using - // |origin| and |size|. New windows are created on the default monitor. Window - // sizes are specified to the OS in physical pixels, hence to ensure a - // consistent size to will treat the width height passed in to this function - // as logical pixels and scale to appropriate for the default monitor. Returns - // true if the window was created successfully. - bool CreateAndShow(const std::wstring& title, - const Point& origin, - const Size& size); - - // Release OS resources associated with window. - void Destroy(); - - // Inserts |content| into the window tree. - void SetChildContent(HWND content); - - // Returns the backing Window handle to enable clients to set icon and other - // window properties. Returns nullptr if the window has been destroyed. - HWND GetHandle(); - - // If true, closing this window will quit the application. - void SetQuitOnClose(bool quit_on_close); - - // Return a RECT representing the bounds of the current client area. - RECT GetClientArea(); - - protected: - // Processes and route salient window messages for mouse handling, - // size change and DPI. Delegates handling of these to member overloads that - // inheriting classes can handle. - virtual LRESULT MessageHandler(HWND window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Called when CreateAndShow is called, allowing subclass window-related - // setup. Subclasses should return false if setup fails. - virtual bool OnCreate(); - - // Called when Destroy is called. - virtual void OnDestroy(); - - private: - friend class WindowClassRegistrar; - - // OS callback called by message pump. Handles the WM_NCCREATE message which - // is passed when the non-client area is being created and enables automatic - // non-client DPI scaling so that the non-client area automatically - // responsponds to changes in DPI. All other messages are handled by - // MessageHandler. - static LRESULT CALLBACK WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Retrieves a class instance pointer for |window| - static Win32Window* GetThisFromHandle(HWND const window) noexcept; - - bool quit_on_close_ = false; - - // window handle for top level window. - HWND window_handle_ = nullptr; - - // window handle for hosted content. - HWND child_content_ = nullptr; -}; - -#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/frontend/appflowy_flutter/.gitignore b/frontend/appflowy_flutter/.gitignore new file mode 100644 index 0000000000000..9c3bb46d62145 --- /dev/null +++ b/frontend/appflowy_flutter/.gitignore @@ -0,0 +1,80 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# 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 +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +generated* +Generated* + +# Web related +lib/generated_plugin_registrant.dart + +# Language related generated files +lib/generated/ + +# Freezed generated files +*.g.dart +*.freezed.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +/packages/flowy_protobuf +/packages/flutter-quill + +product/** +windows/flutter/dart_ffi/ + +**/**/*.dylib +**/**/*.a +**/**/*.lib +**/**/*.dll +**/**/*.so +**/**/Brewfile.lock.json +**/.sandbox +**/.vscode/ + +.env +.env.* + +coverage/ + +**/failures/*.png + +assets/translations/ +assets/flowy_icons/* \ No newline at end of file diff --git a/frontend/appflowy_flutter/.metadata b/frontend/appflowy_flutter/.metadata new file mode 100644 index 0000000000000..7da2cb55fdd75 --- /dev/null +++ b/frontend/appflowy_flutter/.metadata @@ -0,0 +1,30 @@ +# 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. + +version: + revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + channel: unknown + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + - platform: android + create_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + base_revision: 682aa387cfe4fbd71ccd5418b2c2a075729a1c66 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/frontend/appflowy_flutter/Makefile b/frontend/appflowy_flutter/Makefile new file mode 100644 index 0000000000000..6018e6981acbd --- /dev/null +++ b/frontend/appflowy_flutter/Makefile @@ -0,0 +1,7 @@ +.PHONY: freeze_build, free_watch + +freeze_build: + dart run build_runner build -d + +watch: + dart run build_runner watch \ No newline at end of file diff --git a/frontend/appflowy_flutter/README.md b/frontend/appflowy_flutter/README.md new file mode 100644 index 0000000000000..116cd63f22b54 --- /dev/null +++ b/frontend/appflowy_flutter/README.md @@ -0,0 +1,50 @@ +

AppFlowy_Flutter

+
+ + +
+ +> Documentation for Contributors + +This Repository contains the codebase for the frontend of the application, currently we use Flutter as our frontend framework. + +### Platforms Supported Using Flutter 💻 + +- Linux +- macOS +- Windows + > We are actively working on support for Android & iOS! + +_Additionally, we are working on a Web version built with Tauri!_ + +### Am I Eligible to Contribute? + +Yes! You are eligible to contribute, check out the ways in which you can [contribute to AppFlowy](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy). Some of the ways in which you can contribute are: + +- Non-Coding Contributions + - Documentation + - Feature Requests and Feedbacks + - Report Bugs + - Improve Translations +- Coding Contributions + +To contribute to `AppFlowy_Flutter` codebase specifically (coding contribution) we suggest you to have basic knowledge of Flutter. In case you are new to Flutter, we suggest you learn the basics, and then contribute afterwards. To get started with Flutter read [here](https://flutter.dev/docs/get-started/codelab). + +### What OS should I use for development? + +We support all OS for Development i.e. Linux, MacOS and Windows. However, most of us promote macOS and Linux over Windows. We have detailed [docs](https://docs.appflowy.io/docs/documentation/appflowy/from-source/environment-setup) on how to setup `AppFlowy_Flutter` on your local system respectively per operating system. + +### Getting Started ❇ + +We have detailed documentation on how to [get started](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy) with the project, and make your first contribution. However, we do have some specific picks for you: + +- [Code Architecture](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/frontend/frontend/codemap) +- [Styleguide & Conventions](https://docs.appflowy.io/docs/documentation/software-contributions/conventions/naming-conventions) +- [Making Your First PR](https://docs.appflowy.io/docs/documentation/software-contributions/submitting-code/submitting-your-first-pull-request) +- [All AppFlowy Documentation](https://docs.appflowy.io/docs/documentation/appflowy) - Contribution guide, build and run, debugging, testing, localization, etc. + +### Need Help? + +- New to GitHub? Follow [these](https://docs.appflowy.io/docs/documentation/software-contributions/submitting-code/setting-up-your-repositories) steps to get started +- Stuck Somewhere? Join our [Discord](https://discord.gg/9Q2xaN37tV), we're there to help you! +- Find out more about the [community initiatives](https://docs.appflowy.io/docs/appflowy/community). diff --git a/frontend/appflowy_flutter/analysis_options.yaml b/frontend/appflowy_flutter/analysis_options.yaml new file mode 100644 index 0000000000000..8da401ef2651d --- /dev/null +++ b/frontend/appflowy_flutter/analysis_options.yaml @@ -0,0 +1,34 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + +linter: + rules: + - require_trailing_commas + + - prefer_collection_literals + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + + - sized_box_for_whitespace + - use_decorated_box + + - unnecessary_parenthesis + - unnecessary_await_in_return + - unnecessary_raw_strings + + - avoid_unnecessary_containers + - avoid_redundant_argument_values + - avoid_unused_constructor_parameters + + - always_declare_return_types + + - sort_constructors_first + - unawaited_futures + +errors: + invalid_annotation_target: ignore diff --git a/frontend/appflowy_flutter/android/.gitignore b/frontend/appflowy_flutter/android/.gitignore new file mode 100644 index 0000000000000..d34d43f48e02b --- /dev/null +++ b/frontend/appflowy_flutter/android/.gitignore @@ -0,0 +1,15 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks + +.cxx diff --git a/frontend/app_flowy/android/README.md b/frontend/appflowy_flutter/android/README.md similarity index 100% rename from frontend/app_flowy/android/README.md rename to frontend/appflowy_flutter/android/README.md diff --git a/frontend/appflowy_flutter/android/app/build.gradle b/frontend/appflowy_flutter/android/app/build.gradle new file mode 100644 index 0000000000000..3110b5b8fff3f --- /dev/null +++ b/frontend/appflowy_flutter/android/app/build.gradle @@ -0,0 +1,106 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + compileSdkVersion 34 + ndkVersion "24.0.8215888" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + main.jniLibs.srcDirs += 'jniLibs/' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "io.appflowy.appflowy" + minSdkVersion 29 + targetSdkVersion 34 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + multiDexEnabled true + externalNativeBuild { + cmake { + arguments "-DANDROID_ARM_NEON=TRUE", "-DANDROID_STL=c++_shared" + } + } + } + + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + buildTypes { + release { + // use release instead when publishing the application to google play. + // signingConfig signingConfigs.release + signingConfig signingConfigs.debug + } + } + + namespace 'io.appflowy.appflowy' + + externalNativeBuild { + cmake { + path "src/main/CMakeLists.txt" + } + } + + // only support arm64-v8a + defaultConfig { + ndk { + abiFilters "arm64-v8a" + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "com.android.support:multidex:2.0.1" +} diff --git a/frontend/appflowy_flutter/android/app/src/debug/AndroidManifest.xml b/frontend/appflowy_flutter/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000000..f880684a6a9c7 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + diff --git a/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml b/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000000..74d5c5e494305 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/android/app/src/main/CMakeLists.txt b/frontend/appflowy_flutter/android/app/src/main/CMakeLists.txt new file mode 100644 index 0000000000000..455c5081b6b75 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/main/CMakeLists.txt @@ -0,0 +1,24 @@ +cmake_minimum_required(VERSION 3.10.0) + +project(AppFlowy) + +message(CONFIGURE_LOG "NDK PATH: ${ANDROID_NDK}") +message(CONFIGURE_LOG "Copying libc++_shared.so") + +# arm64-v8a +file(COPY + ${ANDROID_NDK}/sources/cxx-stl/llvm-libc++/libs/arm64-v8a/libc++_shared.so + DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/jniLibs/arm64-v8a +) + +# armeabi-v7a +file(COPY + ${ANDROID_NDK}/sources/cxx-stl/llvm-libc++/libs/armeabi-v7a/libc++_shared.so + DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/jniLibs/armeabi-v7a +) + +# x86_64 +file(COPY + ${ANDROID_NDK}/sources/cxx-stl/llvm-libc++/libs/x86_64/libc++_shared.so + DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/jniLibs/x86_64 +) \ No newline at end of file diff --git a/frontend/appflowy_flutter/android/app/src/main/Classes/binding.h b/frontend/appflowy_flutter/android/app/src/main/Classes/binding.h new file mode 100644 index 0000000000000..78992141ca082 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/main/Classes/binding.h @@ -0,0 +1,20 @@ +#include +#include +#include +#include + +int64_t init_sdk(int64_t port, char *data); + +void async_event(int64_t port, const uint8_t *input, uintptr_t len); + +const uint8_t *sync_event(const uint8_t *input, uintptr_t len); + +int32_t set_stream_port(int64_t port); + +int32_t set_log_stream_port(int64_t port); + +void link_me_please(void); + +void rust_log(int64_t level, const char *data); + +void set_env(const char *data); diff --git a/frontend/appflowy_flutter/android/app/src/main/ic_launcher-playstore.png b/frontend/appflowy_flutter/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000..c691e14bdc563 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/ic_launcher-playstore.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/kotlin/com/example/app_flowy/MainActivity.kt b/frontend/appflowy_flutter/android/app/src/main/kotlin/com/example/app_flowy/MainActivity.kt new file mode 100644 index 0000000000000..03d9e1fcdab87 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/main/kotlin/com/example/app_flowy/MainActivity.kt @@ -0,0 +1,6 @@ +package io.appflowy.appflowy + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/frontend/app_flowy/android/app/src/main/res/drawable-v21/launch_background.xml b/frontend/appflowy_flutter/android/app/src/main/res/drawable-v21/launch_background.xml similarity index 100% rename from frontend/app_flowy/android/app/src/main/res/drawable-v21/launch_background.xml rename to frontend/appflowy_flutter/android/app/src/main/res/drawable-v21/launch_background.xml diff --git a/frontend/app_flowy/android/app/src/main/res/drawable/launch_background.xml b/frontend/appflowy_flutter/android/app/src/main/res/drawable/launcher_background.xml similarity index 100% rename from frontend/app_flowy/android/app/src/main/res/drawable/launch_background.xml rename to frontend/appflowy_flutter/android/app/src/main/res/drawable/launcher_background.xml diff --git a/frontend/appflowy_flutter/android/app/src/main/res/drawable/launcher_foreground.xml b/frontend/appflowy_flutter/android/app/src/main/res/drawable/launcher_foreground.xml new file mode 100644 index 0000000000000..c7ec6fdd6f5c7 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/main/res/drawable/launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000000..ba42ab6878248 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000000..036d09bc5fd52 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000..911ee844c765f Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000..1b466c0eb266a Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000..56ea852799b4d Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000..f4d14c0d6000d Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000..fe7a94797a341 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000..15fb3c4ddf8d5 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000..63fa775f58c4e Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000000..fda3c7fa3e197 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000..61e49810e883f Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000..132a0e9ff077a Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000..f9e393537dc8a Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000..8efe0ff281f5d Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000..be4cf46069de6 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000..95a312fbc55cf Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000..a63acece70328 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000..727cb0c58a7f3 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000..c9e8059fe3c58 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000..d5ce932756139 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000000000..ad1543e064b8c Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000..010733d23d4a7 Binary files /dev/null and b/frontend/appflowy_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/frontend/app_flowy/android/app/src/main/res/values-night/styles.xml b/frontend/appflowy_flutter/android/app/src/main/res/values-night/styles.xml similarity index 100% rename from frontend/app_flowy/android/app/src/main/res/values-night/styles.xml rename to frontend/appflowy_flutter/android/app/src/main/res/values-night/styles.xml diff --git a/frontend/appflowy_flutter/android/app/src/main/res/values/ic_launcher_background.xml b/frontend/appflowy_flutter/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000000..c5d5899fdf0a1 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/frontend/app_flowy/android/app/src/main/res/values/styles.xml b/frontend/appflowy_flutter/android/app/src/main/res/values/styles.xml similarity index 100% rename from frontend/app_flowy/android/app/src/main/res/values/styles.xml rename to frontend/appflowy_flutter/android/app/src/main/res/values/styles.xml diff --git a/frontend/appflowy_flutter/android/app/src/profile/AndroidManifest.xml b/frontend/appflowy_flutter/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000000000..f880684a6a9c7 --- /dev/null +++ b/frontend/appflowy_flutter/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + diff --git a/frontend/appflowy_flutter/android/build.gradle b/frontend/appflowy_flutter/android/build.gradle new file mode 100644 index 0000000000000..aca8b4a2016c0 --- /dev/null +++ b/frontend/appflowy_flutter/android/build.gradle @@ -0,0 +1,29 @@ +buildscript { + ext.kotlin_version = '1.8.0' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.4.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/frontend/appflowy_flutter/android/gradle.properties b/frontend/appflowy_flutter/android/gradle.properties new file mode 100644 index 0000000000000..3aaa740c58117 --- /dev/null +++ b/frontend/appflowy_flutter/android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true +org.gradle.caching=true +android.suppressUnsupportedCompileSdk=33 diff --git a/frontend/appflowy_flutter/android/gradle/wrapper/gradle-wrapper.properties b/frontend/appflowy_flutter/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000000..fe1a99c2af7c6 --- /dev/null +++ b/frontend/appflowy_flutter/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/frontend/app_flowy/android/settings.gradle b/frontend/appflowy_flutter/android/settings.gradle similarity index 100% rename from frontend/app_flowy/android/settings.gradle rename to frontend/appflowy_flutter/android/settings.gradle diff --git a/frontend/app_flowy/assets/fonts/FlowyIconData.ttf b/frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf similarity index 100% rename from frontend/app_flowy/assets/fonts/FlowyIconData.ttf rename to frontend/appflowy_flutter/assets/fonts/FlowyIconData.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/OFL.txt b/frontend/appflowy_flutter/assets/google_fonts/Poppins/OFL.txt similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/OFL.txt rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/OFL.txt diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Black.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Black.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Black.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Black.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-BlackItalic.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-BlackItalic.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-BlackItalic.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-BlackItalic.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Bold.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Bold.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Bold.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Bold.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-BoldItalic.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-BoldItalic.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-BoldItalic.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-BoldItalic.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-ExtraBold.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-ExtraBold.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-ExtraBold.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-ExtraBold.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-ExtraBoldItalic.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-ExtraBoldItalic.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-ExtraBoldItalic.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-ExtraBoldItalic.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-ExtraLight.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-ExtraLight.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-ExtraLight.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-ExtraLight.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-ExtraLightItalic.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-ExtraLightItalic.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-ExtraLightItalic.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-ExtraLightItalic.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Italic.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Italic.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Italic.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Italic.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Light.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Light.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Light.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Light.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-LightItalic.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-LightItalic.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-LightItalic.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-LightItalic.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Medium.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Medium.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Medium.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Medium.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-MediumItalic.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-MediumItalic.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-MediumItalic.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-MediumItalic.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Regular.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Regular.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Regular.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Regular.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-SemiBold.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-SemiBold.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-SemiBold.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-SemiBold.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-SemiBoldItalic.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-SemiBoldItalic.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-SemiBoldItalic.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-SemiBoldItalic.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Thin.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Thin.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-Thin.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-Thin.ttf diff --git a/frontend/app_flowy/assets/google_fonts/Poppins/Poppins-ThinItalic.ttf b/frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-ThinItalic.ttf similarity index 100% rename from frontend/app_flowy/assets/google_fonts/Poppins/Poppins-ThinItalic.ttf rename to frontend/appflowy_flutter/assets/google_fonts/Poppins/Poppins-ThinItalic.ttf diff --git a/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/LICENSE.txt b/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/LICENSE.txt new file mode 100644 index 0000000000000..75b52484ea471 --- /dev/null +++ b/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf b/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf new file mode 100644 index 0000000000000..61e5303325a1b Binary files /dev/null and b/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf differ diff --git a/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf b/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf new file mode 100644 index 0000000000000..6df2b25360309 Binary files /dev/null and b/frontend/appflowy_flutter/assets/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf differ diff --git a/frontend/appflowy_flutter/assets/icons/icons.json b/frontend/appflowy_flutter/assets/icons/icons.json new file mode 100644 index 0000000000000..4ad858c4141d8 --- /dev/null +++ b/frontend/appflowy_flutter/assets/icons/icons.json @@ -0,0 +1 @@ +{ "artificial_intelligence": [ { "name": "ai-chip-spark", "keywords": [ "chip", "processor", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-cloud-spark", "keywords": [ "cloud", "internet", "server", "network", "artificial", "intelligence", "ai" ], "content": "\n \n \n \n \n \n \n \n \n\n" }, { "name": "ai-edit-spark", "keywords": [ "change", "edit", "modify", "pencil", "write", "writing", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-email-generator-spark", "keywords": [ "mail", "envelope", "inbox", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-gaming-spark", "keywords": [ "remote", "control", "controller", "technology", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-generate-landscape-image-spark", "keywords": [ "picture", "photography", "photo", "image", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-generate-music-spark", "keywords": [ "music", "audio", "note", "entertainment", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-generate-portrait-image-spark", "keywords": [ "picture", "photography", "photo", "image", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-generate-variation-spark", "keywords": [ "module", "application", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-navigation-spark", "keywords": [ "map", "location", "direction", "travel", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-network-spark", "keywords": [ "globe", "internet", "world", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-prompt-spark", "keywords": [ "app", "code", "apps", "window", "website", "web", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-redo-spark", "keywords": [ "arrow", "refresh", "sync", "synchronize", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-science-spark", "keywords": [ "atom", "scientific", "experiment", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-settings-spark", "keywords": [ "cog", "gear", "settings", "machine", "artificial", "intelligence" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-technology-spark", "keywords": [ "lightbulb", "idea", "bright", "lighting", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-upscale-spark", "keywords": [ "magnifier", "zoom", "view", "find", "search", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ai-vehicle-spark-1", "keywords": [ "car", "automated", "transportation", "artificial", "intelligence", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "artificial-intelligence-spark", "keywords": [ "brain", "thought", "ai", "automated", "ai" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "computer_devices": [ { "name": "adobe", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "alt", "keywords": [ "windows", "key", "alt", "pc", "keyboard" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "amazon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "android", "keywords": [ "android", "code", "apps", "bugdroid", "programming" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "app-store", "keywords": [], "content": "\n\n\n" }, { "name": "apple", "keywords": [ "os", "system", "apple" ], "content": "\n\n\n" }, { "name": "asterisk-1", "keywords": [ "asterisk", "star", "keyboard" ], "content": "\n\n\n" }, { "name": "battery-alert-1", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "power", "battery", "alert", "warning" ], "content": "\n\n\n" }, { "name": "battery-charging", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "power", "battery", "charging" ], "content": "\n\n\n" }, { "name": "battery-empty-1", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "empty", "power", "battery" ], "content": "\n\n\n" }, { "name": "battery-empty-2", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "empty", "power", "battery" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "battery-full-1", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "power", "battery", "full" ], "content": "\n\n\n" }, { "name": "battery-low-1", "keywords": [ "phone", "mobile", "charge", "device", "electricity", "power", "battery", "low" ], "content": "\n\n\n" }, { "name": "battery-medium-1", "keywords": [ "phone", "mobile", "charge", "medium", "device", "electricity", "power", "battery" ], "content": "\n\n\n" }, { "name": "bluetooth", "keywords": [ "bluetooth", "internet", "server", "network", "wireless", "connection" ], "content": "\n\n\n" }, { "name": "bluetooth-disabled", "keywords": [ "bluetooth", "internet", "server", "network", "wireless", "disabled", "off", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bluetooth-searching", "keywords": [ "bluetooth", "internet", "server", "network", "wireless", "searching", "connecting", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-wifi", "keywords": [ "wireless", "wifi", "internet", "server", "network", "browser", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chrome", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "command", "keywords": [ "mac", "command", "apple", "keyboard" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "computer-chip-1", "keywords": [ "computer", "device", "chip", "electronics", "cpu", "microprocessor" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "computer-chip-2", "keywords": [ "core", "microprocessor", "device", "electronics", "chip", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "computer-pc-desktop", "keywords": [ "screen", "desktop", "monitor", "device", "electronics", "display", "pc", "computer" ], "content": "\n\n\n" }, { "name": "controller", "keywords": [ "remote", "quadcopter", "drones", "flying", "drone", "control", "controller", "technology", "fly" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "controller-1", "keywords": [ "remote", "quadcopter", "drones", "flying", "drone", "control", "controller", "technology", "fly" ], "content": "\n\n\n" }, { "name": "controller-wireless", "keywords": [ "remote", "gaming", "drones", "drone", "control", "controller", "technology", "console" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cursor-click", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cyborg", "keywords": [ "artificial", "robotics", "intelligence", "machine", "technology", "android" ], "content": "\n\n\n" }, { "name": "cyborg-2", "keywords": [ "artificial", "robotics", "intelligence", "machine", "technology", "android" ], "content": "\n\n\n" }, { "name": "database", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-check", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "check", "approve" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-lock", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "password", "security", "protection", "lock", "secure" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-refresh", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "refresh" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-remove", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "remove", "delete", "cross" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-server-1", "keywords": [ "server", "network", "internet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-server-2", "keywords": [ "server", "network", "internet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-setting", "keywords": [ "raid", "storage", "code", "disk", "programming", "database", "array", "hard", "disc", "setting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "database-subtract-2-raid-storage-code-disk-programming-database-array-hard-disc-minus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "delete-keyboard", "keywords": [], "content": "\n\n\n" }, { "name": "desktop-chat", "keywords": [ "bubble", "chat", "customer", "service", "conversation", "display", "device" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-check", "keywords": [ "success", "approve", "device", "display", "desktop", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-code", "keywords": [ "desktop", "device", "display", "computer", "code", "terminal", "html", "css", "programming", "system" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-delete", "keywords": [ "device", "remove", "display", "computer", "deny", "desktop", "fail", "failure", "cross" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-dollar", "keywords": [ "cash", "desktop", "display", "device", "notification", "computer", "money", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-emoji", "keywords": [ "device", "display", "desktop", "padlock", "smiley" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-favorite-star", "keywords": [ "desktop", "device", "display", "like", "favorite", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-game", "keywords": [ "controller", "display", "device", "computer", "games", "leisure" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "desktop-help", "keywords": [ "device", "help", "information", "display", "desktop", "question", "info" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "device-database-encryption-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discord", "keywords": [], "content": "\n\n\n" }, { "name": "drone", "keywords": [ "artificial", "robotics", "intelligence", "machine", "technology", "android", "flying" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dropbox", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "eject", "keywords": [ "eject", "unmount", "dismount", "remove", "keyboard" ], "content": "\n\n\n" }, { "name": "electric-cord-1", "keywords": [ "electricity", "electronic", "appliances", "device", "cord", "cable", "plug", "connection" ], "content": "\n\n\n" }, { "name": "electric-cord-3", "keywords": [ "electricity", "electronic", "appliances", "device", "cord", "cable", "plug", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "facebook-1", "keywords": [ "media", "facebook", "social" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "figma", "keywords": [], "content": "\n\n\n" }, { "name": "floppy-disk", "keywords": [ "disk", "floppy", "electronics", "device", "disc", "computer", "storage" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gmail", "keywords": [], "content": "\n\n\n" }, { "name": "google", "keywords": [ "media", "google", "social" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "google-drive", "keywords": [], "content": "\n\n\n" }, { "name": "hand-held", "keywords": [ "tablet", "kindle", "device", "electronics", "ipad", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hand-held-tablet-drawing", "keywords": [ "tablet", "kindle", "device", "electronics", "ipad", "digital", "drawing", "canvas" ], "content": "\n\n\n" }, { "name": "hand-held-tablet-writing", "keywords": [ "tablet", "kindle", "device", "electronics", "ipad", "writing", "digital", "paper", "notepad" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hard-disk", "keywords": [ "device", "disc", "drive", "disk", "electronics", "platter", "turntable", "raid", "storage" ], "content": "\n\n\n" }, { "name": "hard-drive-1", "keywords": [ "disk", "device", "electronics", "disc", "drive", "raid", "storage" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "instagram", "keywords": [], "content": "\n\n\n" }, { "name": "keyboard", "keywords": [ "keyboard", "device", "electronics", "dvorak", "qwerty" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "keyboard-virtual", "keywords": [ "remote", "device", "electronics", "qwerty", "keyboard", "virtual", "interface" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "keyboard-wireless-2", "keywords": [ "remote", "device", "wireless", "electronics", "qwerty", "keyboard", "bluetooth" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "laptop-charging", "keywords": [ "device", "laptop", "electronics", "computer", "notebook", "charging" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "linkedin", "keywords": [ "network", "linkedin", "professional" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "local-storage-folder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "meta", "keywords": [], "content": "\n\n\n" }, { "name": "mouse", "keywords": [ "device", "electronics", "mouse" ], "content": "\n\n\n" }, { "name": "mouse-wireless", "keywords": [ "remote", "wireless", "device", "electronics", "mouse", "computer" ], "content": "\n\n\n" }, { "name": "mouse-wireless-1", "keywords": [ "remote", "wireless", "device", "electronics", "mouse", "computer" ], "content": "\n\n\n" }, { "name": "netflix", "keywords": [], "content": "\n\n\n" }, { "name": "network", "keywords": [ "network", "server", "internet", "ethernet", "connection" ], "content": "\n\n\n" }, { "name": "next", "keywords": [ "next", "arrow", "right", "keyboard" ], "content": "\n\n\n" }, { "name": "paypal", "keywords": [ "payment", "paypal" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-store", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "printer", "keywords": [ "scan", "device", "electronics", "printer", "print", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "return-2", "keywords": [ "arrow", "return", "enter", "keyboard" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "screen-1", "keywords": [ "screen", "device", "electronics", "monitor", "diplay", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "screen-2", "keywords": [ "screen", "device", "electronics", "monitor", "diplay", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "screen-curve", "keywords": [ "screen", "curved", "device", "electronics", "monitor", "diplay", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "screensaver-monitor-wallpaper", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shift", "keywords": [ "key", "shift", "up", "arrow", "keyboard" ], "content": "\n\n\n" }, { "name": "shredder", "keywords": [ "device", "electronics", "shred", "paper", "cut", "destroy", "remove", "delete" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "signal-loading", "keywords": [ "bracket", "loading", "internet", "angle", "signal", "server", "network", "connecting", "connection" ], "content": "\n\n\n" }, { "name": "slack", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "spotify", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "telegram", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tiktok", "keywords": [], "content": "\n\n\n" }, { "name": "tinder", "keywords": [], "content": "\n\n\n" }, { "name": "twitter", "keywords": [ "media", "twitter", "social" ], "content": "\n\n\n" }, { "name": "usb-drive", "keywords": [ "usb", "drive", "stick", "memory", "storage", "data", "connection" ], "content": "\n\n\n" }, { "name": "virtual-reality", "keywords": [ "gaming", "virtual", "gear", "controller", "reality", "games", "headset", "technology", "vr", "eyewear" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "voice-mail", "keywords": [ "mic", "audio", "mike", "music", "microphone" ], "content": "\n\n\n" }, { "name": "voice-mail-off", "keywords": [ "mic", "audio", "mike", "music", "microphone", "mute", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "VPN-connection", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "watch-1", "keywords": [ "device", "timepiece", "cirle", "electronics", "face", "blank", "watch", "smart" ], "content": "\n\n\n" }, { "name": "watch-2", "keywords": [ "device", "square", "timepiece", "electronics", "face", "blank", "watch", "smart" ], "content": "\n\n\n" }, { "name": "watch-circle-charging", "keywords": [ "device", "timepiece", "circle", "watch", "round", "charge", "charging", "power" ], "content": "\n\n\n" }, { "name": "watch-circle-heartbeat-monitor-1", "keywords": [ "device", "timepiece", "circle", "watch", "round", "heart", "beat", "monitor", "healthcare" ], "content": "\n\n\n" }, { "name": "watch-circle-heartbeat-monitor-2", "keywords": [ "device", "timepiece", "circle", "watch", "round", "heart", "beat", "monitor", "healthcare" ], "content": "\n\n\n" }, { "name": "watch-circle-menu", "keywords": [ "device", "timepiece", "circle", "watch", "round", "menu", "list", "option", "app" ], "content": "\n\n\n" }, { "name": "watch-circle-time", "keywords": [ "device", "timepiece", "circle", "watch", "round", "time", "clock", "analog" ], "content": "\n\n\n" }, { "name": "webcam", "keywords": [ "webcam", "camera", "future", "tech", "chat", "skype", "technology", "video" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "webcam-video", "keywords": [ "work", "video", "meeting", "camera", "company", "conference", "office" ], "content": "\n\n\n" }, { "name": "webcam-video-circle", "keywords": [ "work", "video", "meeting", "camera", "company", "conference", "office" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "webcam-video-off", "keywords": [ "work", "video", "meeting", "camera", "company", "conference", "office", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "whatsapp", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wifi", "keywords": [ "wireless", "wifi", "internet", "server", "network", "connection" ], "content": "\n\n\n" }, { "name": "wifi-antenna", "keywords": [ "wireless", "wifi", "internet", "server", "network", "antenna", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wifi-disabled", "keywords": [ "wireless", "wifi", "internet", "server", "network", "disabled", "off", "offline", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wifi-horizontal", "keywords": [ "wireless", "wifi", "internet", "server", "network", "horizontal", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wifi-router", "keywords": [ "wireless", "wifi", "internet", "server", "network", "connection" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "windows", "keywords": [ "os", "system", "microsoft" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "culture": [ { "name": "christian-cross-1", "keywords": [ "religion", "christian", "cross", "culture", "bold" ], "content": "\n\n\n" }, { "name": "christian-cross-2", "keywords": [ "religion", "christian", "cross", "culture", "bold" ], "content": "\n\n\n" }, { "name": "christianity", "keywords": [ "religion", "jesus", "christianity", "christ", "fish", "culture" ], "content": "\n\n\n" }, { "name": "dhammajak", "keywords": [ "religion", "dhammajak", "culture", "bhuddhism", "buddish" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hexagram", "keywords": [ "star", "jew", "jewish", "judaism", "hexagram", "culture", "religion", "david" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hinduism", "keywords": [ "religion", "hinduism", "culture", "hindu" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "islam", "keywords": [ "religion", "islam", "moon", "crescent", "muslim", "culture", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "news-paper", "keywords": [ "newspaper", "periodical", "fold", "content", "entertainment" ], "content": "\n\n\n" }, { "name": "peace-symbol", "keywords": [ "religion", "peace", "war", "culture", "symbol" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "politics-compaign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "politics-speech", "keywords": [], "content": "\n\n\n" }, { "name": "politics-vote-2", "keywords": [], "content": "\n\n\n" }, { "name": "ticket-1", "keywords": [ "hobby", "ticket", "event", "entertainment", "stub", "theater", "entertainment", "culture" ], "content": "\n\n\n" }, { "name": "tickets", "keywords": [ "hobby", "ticket", "event", "entertainment", "stub", "theater", "entertainment", "culture" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "yin-yang-symbol", "keywords": [ "religion", "tao", "yin", "yang", "taoism", "culture", "symbol" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-1", "keywords": [ "sign", "astrology", "stars", "space", "scorpio" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-10", "keywords": [ "sign", "astrology", "stars", "space", "pisces" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-11", "keywords": [ "sign", "astrology", "stars", "space", "sagittarius" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-12", "keywords": [ "sign", "astrology", "stars", "space", "cancer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-2", "keywords": [ "sign", "astrology", "stars", "space", "virgo" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-3", "keywords": [ "sign", "astrology", "stars", "space", "leo" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-4", "keywords": [ "sign", "astrology", "stars", "space", "aquarius" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-5", "keywords": [ "sign", "astrology", "stars", "space", "taurus" ], "content": "\n\n\n" }, { "name": "zodiac-6", "keywords": [ "sign", "astrology", "stars", "space", "capricorn" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-7", "keywords": [ "sign", "astrology", "stars", "space", "ares" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "zodiac-8", "keywords": [ "sign", "astrology", "stars", "space", "libra" ], "content": "\n\n\n" }, { "name": "zodiac-9", "keywords": [ "sign", "astrology", "stars", "space", "gemini" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "entertainment": [ { "name": "balloon", "keywords": [ "hobby", "entertainment", "party", "balloon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bow", "keywords": [ "entertainment", "gaming", "bow", "weapon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-fast-forward-1", "keywords": [ "button", "controls", "fast", "forward", "movies", "television", "video", "tv" ], "content": "\n\n\n" }, { "name": "button-fast-forward-2", "keywords": [ "button", "controls", "fast", "forward", "movies", "television", "video", "tv" ], "content": "\n\n\n" }, { "name": "button-next", "keywords": [ "button", "television", "buttons", "movies", "skip", "next", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-pause-2", "keywords": [ "button", "television", "buttons", "movies", "tv", "pause", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-play", "keywords": [ "button", "television", "buttons", "movies", "play", "tv", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-power-1", "keywords": [ "power", "button", "on", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-previous", "keywords": [ "button", "television", "buttons", "movies", "skip", "previous", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-record-3", "keywords": [ "button", "television", "buttons", "movies", "record", "tv", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "button-rewind-1", "keywords": [ "rewind", "television", "button", "movies", "buttons", "tv", "video", "controls" ], "content": "\n\n\n" }, { "name": "button-rewind-2", "keywords": [ "rewind", "television", "button", "movies", "buttons", "tv", "video", "controls" ], "content": "\n\n\n" }, { "name": "button-stop", "keywords": [ "button", "television", "buttons", "movies", "stop", "tv", "video", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "camera-video", "keywords": [ "film", "television", "tv", "camera", "movies", "video", "recorder" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cards", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chess-bishop", "keywords": [], "content": "\n\n\n" }, { "name": "chess-king", "keywords": [], "content": "\n\n\n" }, { "name": "chess-knight", "keywords": [], "content": "\n\n\n" }, { "name": "chess-pawn", "keywords": [], "content": "\n\n\n" }, { "name": "cloud-gaming-1", "keywords": [ "entertainment", "cloud", "gaming" ], "content": "\n\n\n" }, { "name": "clubs-symbol", "keywords": [ "entertainment", "gaming", "card", "clubs", "symbol" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "diamonds-symbol", "keywords": [ "entertainment", "gaming", "card", "diamonds", "symbol" ], "content": "\n\n\n" }, { "name": "dice-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-4", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-5", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dice-6", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dices-entertainment-gaming-dices", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "earpods", "keywords": [ "airpods", "audio", "earpods", "music", "earbuds", "true", "wireless", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "epic-games-1", "keywords": [ "epic", "games", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "esports", "keywords": [ "entertainment", "gaming", "esports" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fireworks-rocket", "keywords": [ "hobby", "entertainment", "party", "fireworks", "rocket" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gameboy", "keywords": [ "entertainment", "gaming", "device", "gameboy" ], "content": "\n\n\n" }, { "name": "gramophone", "keywords": [ "music", "audio", "note", "gramophone", "player", "vintage", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hearts-symbol", "keywords": [ "entertainment", "gaming", "card", "hearts", "symbol" ], "content": "\n\n\n" }, { "name": "music-equalizer", "keywords": [ "music", "audio", "note", "wave", "sound", "equalizer", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "music-note-1", "keywords": [ "music", "audio", "note", "entertainment" ], "content": "\n\n\n" }, { "name": "music-note-2", "keywords": [ "music", "audio", "note", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "music-note-off-1", "keywords": [ "music", "audio", "note", "off", "mute", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "music-note-off-2", "keywords": [ "music", "audio", "note", "off", "mute", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "nintendo-switch", "keywords": [ "nintendo", "switch", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "one-vesus-one", "keywords": [ "entertainment", "gaming", "one", "vesus", "one" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pacman", "keywords": [ "entertainment", "gaming", "pacman", "video" ], "content": "\n\n\n" }, { "name": "party-popper", "keywords": [ "hobby", "entertainment", "party", "popper", "confetti", "event" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-4", "keywords": [ "screen", "television", "display", "player", "movies", "players", "tv", "media", "video", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-5", "keywords": [ "player", "television", "movies", "slider", "media", "tv", "players", "video", "stack", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-8", "keywords": [ "player", "television", "movies", "slider", "media", "tv", "players", "video", "stack", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-9", "keywords": [ "player", "television", "movies", "slider", "media", "tv", "players", "video", "stack", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-list-folder", "keywords": [ "player", "television", "movies", "slider", "media", "tv", "players", "video" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "play-station", "keywords": [ "play", "station", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "radio", "keywords": [ "antenna", "audio", "music", "radio", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "recording-tape-bubble-circle", "keywords": [ "phone", "device", "mail", "mobile", "voice", "machine", "answering", "chat", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "recording-tape-bubble-square", "keywords": [ "phone", "device", "mail", "mobile", "voice", "machine", "answering", "chat", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "song-recommendation", "keywords": [ "song", "recommendation", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "spades-symbol", "keywords": [ "entertainment", "gaming", "card", "spades", "symbol" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "speaker-1", "keywords": [ "speaker", "music", "audio", "subwoofer", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "speaker-2", "keywords": [ "speakers", "music", "audio", "entertainment" ], "content": "\n\n\n" }, { "name": "stream", "keywords": [ "stream", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tape-cassette-record", "keywords": [ "music", "entertainment", "tape", "cassette", "record" ], "content": "\n\n\n" }, { "name": "volume-down", "keywords": [ "speaker", "down", "volume", "control", "audio", "music", "decrease", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-level-high", "keywords": [ "speaker", "high", "volume", "control", "audio", "music", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-level-low", "keywords": [ "volume", "speaker", "lower", "down", "control", "music", "low", "audio", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-level-off", "keywords": [ "volume", "speaker", "control", "music", "audio", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-mute", "keywords": [ "speaker", "remove", "volume", "control", "audio", "music", "mute", "off", "cross", "entertainment" ], "content": "\n\n\n" }, { "name": "volume-off", "keywords": [ "speaker", "music", "mute", "volume", "control", "audio", "off", "mute", "entertainment" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "vr-headset-1", "keywords": [ "entertainment", "gaming", "vr", "headset" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "vr-headset-2", "keywords": [ "entertainment", "gaming", "vr", "headset" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "xbox", "keywords": [ "xbox", "entertainment", "gaming" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "food_drink": [ { "name": "beer-mug", "keywords": [ "beer", "cook", "brewery", "drink", "mug", "cooking", "nutrition", "brew", "brewing", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "beer-pitch", "keywords": [ "drink", "glass", "beer", "pitch" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "burger", "keywords": [ "burger", "fast", "cook", "cooking", "nutrition", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "burrito-fastfood", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cake-slice", "keywords": [ "cherry", "cake", "birthday", "event", "special", "sweet", "bake" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "candy-cane", "keywords": [ "candy", "sweet", "cane", "christmas" ], "content": "\n\n\n" }, { "name": "champagne-party-alcohol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cheese", "keywords": [ "cook", "cheese", "animal", "products", "cooking", "nutrition", "dairy", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cherries", "keywords": [ "cook", "plant", "cherry", "plants", "cooking", "nutrition", "vegetarian", "fruit", "food", "cherries" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chicken-grilled-stream", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cocktail", "keywords": [ "cook", "alcohol", "food", "cocktail", "drink", "cooking", "nutrition", "alcoholic", "beverage", "glass" ], "content": "\n\n\n" }, { "name": "coffee-bean", "keywords": [ "cook", "cooking", "nutrition", "coffee", "bean" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "coffee-mug", "keywords": [ "coffee", "cook", "cup", "drink", "mug", "cooking", "nutrition", "cafe", "caffeine", "food" ], "content": "\n\n\n" }, { "name": "coffee-takeaway-cup", "keywords": [ "cup", "coffee", "hot", "takeaway", "drink", "caffeine" ], "content": "\n\n\n" }, { "name": "donut", "keywords": [ "dessert", "donut" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fork-knife", "keywords": [ "fork", "spoon", "knife", "food", "dine", "cook", "utensils", "eat", "restaurant", "dining", "kitchenware" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fork-spoon", "keywords": [ "fork", "spoon", "food", "dine", "cook", "utensils", "eat", "restaurant", "dining", "kitchenware" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ice-cream-2", "keywords": [ "cook", "frozen", "popsicle", "freezer", "nutrition", "cream", "stick", "cold", "ice", "cooking" ], "content": "\n\n\n" }, { "name": "ice-cream-3", "keywords": [ "cook", "frozen", "cone", "cream", "ice", "cooking", "nutrition", "freezer", "cold", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lemon-fruit-seasoning", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "microwave", "keywords": [ "cook", "food", "appliances", "cooking", "nutrition", "appliance", "microwave", "kitchenware" ], "content": "\n\n\n" }, { "name": "milkshake", "keywords": [ "milkshake", "drink", "takeaway", "cup", "cold", "beverage" ], "content": "\n\n\n" }, { "name": "popcorn", "keywords": [ "cook", "corn", "movie", "snack", "cooking", "nutrition", "bake", "popcorn" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pork-meat", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "refrigerator", "keywords": [ "fridge", "cook", "appliances", "cooking", "nutrition", "freezer", "appliance", "food", "kitchenware" ], "content": "\n\n\n" }, { "name": "serving-dome", "keywords": [ "cook", "tool", "dome", "kitchen", "serving", "paltter", "dish", "tools", "food", "kitchenware" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shrimp", "keywords": [ "sea", "food", "shrimp" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "strawberry", "keywords": [ "fruit", "sweet", "berries", "plant", "strawberry" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tea-cup", "keywords": [ "herbal", "cook", "tea", "tisane", "cup", "drink", "cooking", "nutrition", "mug", "food" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "toast", "keywords": [ "bread", "toast", "breakfast" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "water-glass", "keywords": [ "glass", "water", "juice", "drink", "liquid" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wine", "keywords": [ "drink", "cook", "glass", "cooking", "wine", "nutrition", "food" ], "content": "\n\n\n" } ], "health": [ { "name": "ambulance", "keywords": [ "car", "emergency", "health", "medical", "ambulance" ], "content": "\n\n\n" }, { "name": "bacteria-virus-cells-biology", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bandage", "keywords": [ "health", "medical", "hospital", "medicine", "capsule", "bandage", "vaccine" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blood-bag-donation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blood-donate-drop", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blood-drop-donation", "keywords": [], "content": "\n\n\n" }, { "name": "brain", "keywords": [ "medical", "health", "brain" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "brain-cognitive", "keywords": [ "health", "medical", "brain", "cognitive", "specialities" ], "content": "\n\n\n" }, { "name": "call-center-support-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "checkup-medical-report-clipboard", "keywords": [], "content": "\n\n\n" }, { "name": "ear-hearing", "keywords": [ "health", "medical", "hearing", "ear" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "eye-optic", "keywords": [ "health", "medical", "eye", "optic" ], "content": "\n\n\n" }, { "name": "flu-mask", "keywords": [ "health", "medical", "hospital", "mask", "flu", "vaccine", "protection" ], "content": "\n\n\n" }, { "name": "health-care-2", "keywords": [ "health", "medical", "hospital", "heart", "care", "symbol" ], "content": "\n\n\n" }, { "name": "heart-rate-pulse-graph", "keywords": [], "content": "\n\n\n" }, { "name": "heart-rate-search", "keywords": [ "health", "medical", "monitor", "heart", "rate", "search" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hospital-sign-circle", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "circle", "emergency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hospital-sign-square", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "square", "emergency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "insurance-hand", "keywords": [ "health", "medical", "insurance", "hand", "cross" ], "content": "\n\n\n" }, { "name": "medical-bag", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "bag", "medicine", "medkit" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "medical-cross-sign-healthcare", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "medical-cross-symbol", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "emergency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "medical-files-report-history", "keywords": [], "content": "\n\n\n" }, { "name": "medical-ribbon-1", "keywords": [ "ribbon", "medical", "cancer", "health", "beauty", "symbol" ], "content": "\n\n\n" }, { "name": "medical-search-diagnosis", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "microscope-observation-sciene", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "nurse-assistant-emergency", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "nurse-hat", "keywords": [ "health", "medical", "hospital", "nurse", "doctor", "cap" ], "content": "\n\n\n" }, { "name": "online-medical-call-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "online-medical-service-monitor", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "online-medical-web-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "petri-dish-lab-equipment", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pharmacy", "keywords": [ "health", "medical", "pharmacy", "sign", "medicine", "mortar", "pestle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "prescription-pills-drugs-healthcare", "keywords": [], "content": "\n\n\n" }, { "name": "sign-cross-square", "keywords": [ "health", "sign", "medical", "symbol", "hospital", "cross", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sos-help-emergency-sign", "keywords": [], "content": "\n\n\n" }, { "name": "stethoscope", "keywords": [ "instrument", "health", "medical", "stethoscope" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "syringe", "keywords": [ "instrument", "medical", "syringe", "health", "beauty", "needle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tablet-capsule", "keywords": [ "health", "medical", "hospital", "medicine", "capsule", "tablet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tooth", "keywords": [ "health", "medical", "tooth" ], "content": "\n\n\n" }, { "name": "virus-antivirus", "keywords": [ "health", "medical", "covid19", "flu", "influenza", "virus", "antivirus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "waiting-appointments-calendar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wheelchair", "keywords": [ "health", "medical", "hospital", "wheelchair", "disable", "help", "sign" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "images_photography": [ { "name": "auto-flash", "keywords": [], "content": "\n\n\n" }, { "name": "camera-1", "keywords": [ "photos", "picture", "camera", "photography", "photo", "pictures" ], "content": "\n\n\n" }, { "name": "camera-disabled", "keywords": [ "photos", "picture", "camera", "photography", "photo", "pictures", "disabled", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "camera-loading", "keywords": [ "photos", "picture", "camera", "photography", "photo", "pictures", "loading", "option", "setting" ], "content": "\n\n\n" }, { "name": "camera-square", "keywords": [ "photos", "picture", "camera", "photography", "photo", "pictures", "frame", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "composition-oval", "keywords": [ "camera", "frame", "composition", "photography", "pictures", "landscape", "photo", "oval" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "composition-vertical", "keywords": [ "camera", "portrait", "frame", "vertical", "composition", "photography", "photo" ], "content": "\n\n\n" }, { "name": "compsition-horizontal", "keywords": [ "camera", "horizontal", "panorama", "composition", "photography", "photo", "pictures" ], "content": "\n\n\n" }, { "name": "edit-image-photo", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "film-roll-1", "keywords": [ "photos", "camera", "shutter", "picture", "photography", "pictures", "photo", "film", "roll" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "film-slate", "keywords": [ "pictures", "photo", "film", "slate" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flash-1", "keywords": [ "flash", "power", "connect", "charge", "electricity", "lightning" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flash-2", "keywords": [ "flash", "power", "connect", "charge", "electricity", "lightning" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flash-3", "keywords": [ "flash", "power", "connect", "charge", "electricity", "lightning" ], "content": "\n\n\n" }, { "name": "flash-off", "keywords": [ "flash", "power", "connect", "charge", "off", "electricity", "lightning" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flower", "keywords": [ "photos", "photo", "picture", "camera", "photography", "pictures", "flower", "image" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "focus-points", "keywords": [ "camera", "frame", "photography", "pictures", "photo", "focus", "position" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "landscape-2", "keywords": [ "photos", "photo", "landscape", "picture", "photography", "camera", "pictures", "image" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "landscape-setting", "keywords": [ "design", "composition", "horizontal", "lanscape" ], "content": "\n\n\n" }, { "name": "laptop-camera", "keywords": [ "photos", "photo", "picture", "photography", "camera", "pictures", "laptop", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mobile-phone-camera", "keywords": [ "photos", "photo", "picture", "photography", "camera", "pictures", "phone" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "orientation-landscape", "keywords": [ "photos", "photo", "orientation", "landscape", "picture", "photography", "camera", "pictures", "image" ], "content": "\n\n\n" }, { "name": "orientation-portrait", "keywords": [ "photos", "photo", "orientation", "portrait", "picture", "photography", "camera", "pictures", "image" ], "content": "\n\n\n" }, { "name": "polaroid-four", "keywords": [ "photos", "camera", "polaroid", "picture", "photography", "pictures", "four", "photo", "image" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "interface_essential": [ { "name": "add-1", "keywords": [ "expand", "cross", "buttons", "button", "more", "remove", "plus", "add", "+", "mathematics", "math" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "add-bell-notification", "keywords": [ "notification", "alarm", "alert", "bell", "add" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "add-circle", "keywords": [ "button", "remove", "cross", "add", "buttons", "plus", "circle", "+", "mathematics", "math" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "add-layer-2", "keywords": [ "layer", "add", "design", "plus", "layers", "square", "box" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "add-square", "keywords": [ "square", "remove", "cross", "buttons", "add", "plus", "button", "+", "mathematics", "math" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "alarm-clock", "keywords": [ "time", "tock", "stopwatch", "measure", "clock", "tick" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-back-1", "keywords": [ "back", "design", "layer", "layers", "pile", "stack", "arrange", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-center", "keywords": [ "text", "alignment", "align", "paragraph", "centered", "formatting", "center" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-front-1", "keywords": [ "design", "front", "layer", "layers", "pile", "stack", "arrange", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-left", "keywords": [ "paragraph", "text", "alignment", "align", "left", "formatting", "right" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "align-right", "keywords": [ "rag", "paragraph", "text", "alignment", "align", "right", "formatting", "left" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ampersand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "archive-box", "keywords": [ "box", "content", "banker", "archive", "file" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-bend-left-down-2", "keywords": [ "arrow", "bend", "curve", "change", "direction", "left", "to", "down" ], "content": "\n\n\n" }, { "name": "arrow-bend-right-down-2", "keywords": [ "arrow", "bend", "curve", "change", "direction", "right", "to", "down" ], "content": "\n\n\n" }, { "name": "arrow-crossover-down", "keywords": [ "cross", "move", "over", "arrow", "arrows", "down" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-crossover-left", "keywords": [ "cross", "move", "over", "arrow", "arrows", "left" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-crossover-right", "keywords": [ "cross", "move", "over", "arrow", "arrows", "ight" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-crossover-up", "keywords": [ "cross", "move", "over", "arrow", "arrows", "right" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-cursor-1", "keywords": [ "mouse", "select", "cursor" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-cursor-2", "keywords": [ "mouse", "select", "cursor" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-curvy-up-down-1", "keywords": [ "both", "direction", "arrow", "curvy", "diagram", "zigzag", "vertical" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-curvy-up-down-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-down-2", "keywords": [ "down", "move", "arrow", "arrows" ], "content": "\n\n\n" }, { "name": "arrow-down-dashed-square", "keywords": [ "arrow", "keyboard", "button", "down", "square", "dashes" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-expand", "keywords": [ "expand", "small", "bigger", "retract", "smaller", "big" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-infinite-loop", "keywords": [ "arrow", "diagram", "loop", "infinity", "repeat" ], "content": "\n\n\n" }, { "name": "arrow-move", "keywords": [ "move", "button", "arrows", "direction" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-reload-horizontal-1", "keywords": [ "arrows", "load", "arrow", "sync", "square", "loading", "reload", "synchronize" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-reload-horizontal-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-reload-vertical-1", "keywords": [ "arrows", "load", "arrow", "sync", "square", "loading", "reload", "synchronize" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-reload-vertical-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-roadmap", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-round-left", "keywords": [ "diagram", "round", "arrow", "left" ], "content": "\n\n\n" }, { "name": "arrow-round-right", "keywords": [ "diagram", "round", "arrow", "right" ], "content": "\n\n\n" }, { "name": "arrow-shrink", "keywords": [ "expand", "retract", "shrink", "bigger", "big", "small", "smaller" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-shrink-diagonal-1", "keywords": [ "expand", "retract", "shrink", "bigger", "big", "small", "smaller" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-shrink-diagonal-2", "keywords": [ "expand", "retract", "shrink", "bigger", "big", "small", "smaller" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-transfer-diagonal-1", "keywords": [ "arrows", "arrow", "server", "data", "diagonal", "internet", "transfer", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-transfer-diagonal-2", "keywords": [ "arrows", "arrow", "server", "data", "diagonal", "internet", "transfer", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-transfer-diagonal-3", "keywords": [ "arrows", "arrow", "server", "data", "diagonal", "internet", "transfer", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-up-1", "keywords": [ "arrow", "up", "keyboard" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "arrow-up-dashed-square", "keywords": [ "arrow", "keyboard", "button", "up", "square", "dashes" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ascending-number-order", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "attribution", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blank-calendar", "keywords": [ "blank", "calendar", "date", "day", "month", "empty" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "blank-notepad", "keywords": [ "content", "notes", "book", "notepad", "notebook" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "block-bell-notification", "keywords": [ "notification", "alarm", "alert", "bell", "block" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bomb", "keywords": [ "delete", "bomb", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bookmark", "keywords": [ "bookmarks", "tags", "favorite" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "braces-circle", "keywords": [ "interface", "math", "braces", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "brightness-1", "keywords": [ "bright", "adjust", "brightness", "adjustment", "sun", "raise", "controls" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "brightness-2", "keywords": [ "bright", "adjust", "brightness", "adjustment", "sun", "raise", "controls", "half" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "brightness-3", "keywords": [ "bright", "adjust", "brightness", "adjustment", "sun", "raise", "controls", "dot", "small" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "broken-link-2", "keywords": [ "break", "broken", "hyperlink", "link", "remove", "unlink", "chain" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bullet-list", "keywords": [ "points", "bullet", "unordered", "list", "lists", "bullets" ], "content": "\n\n\n" }, { "name": "calendar-add", "keywords": [ "add", "calendar", "date", "day", "month" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "calendar-edit", "keywords": [ "calendar", "date", "day", "compose", "edit", "note" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "calendar-jump-to-date", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "calendar-star", "keywords": [ "calendar", "date", "day", "favorite", "like", "month", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "celsius", "keywords": [ "degrees", "temperature", "centigrade", "celsius", "degree", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "check", "keywords": [ "check", "form", "validation", "checkmark", "success", "add", "addition", "tick" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "check-square", "keywords": [ "check", "form", "validation", "checkmark", "success", "add", "addition", "box", "square", "tick" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "circle", "keywords": [ "geometric", "circle", "round", "design", "shape", "shapes", "shape" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "circle-clock", "keywords": [ "clock", "loading", "measure", "time", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "clipboard-add", "keywords": [ "edit", "task", "edition", "add", "clipboard", "form" ], "content": "\n\n\n" }, { "name": "clipboard-check", "keywords": [ "checkmark", "edit", "task", "edition", "checklist", "check", "success", "clipboard", "form" ], "content": "\n\n\n" }, { "name": "clipboard-remove", "keywords": [ "edit", "task", "edition", "remove", "delete", "clipboard", "form" ], "content": "\n\n\n" }, { "name": "cloud", "keywords": [ "cloud", "meteorology", "cloudy", "overcast", "cover", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cog", "keywords": [ "work", "loading", "cog", "gear", "settings", "machine" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "color-palette", "keywords": [ "color", "palette", "company", "office", "supplies", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "color-picker", "keywords": [ "color", "colors", "design", "dropper", "eye", "eyedrop", "eyedropper", "painting", "picker" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "color-swatches", "keywords": [ "color", "colors", "design", "painting", "palette", "sample", "swatch" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cone-shape", "keywords": [], "content": "\n\n\n" }, { "name": "convert-PDF-2", "keywords": [ "essential", "files", "folder", "convert", "to", "PDF" ], "content": "\n\n\n" }, { "name": "copy-paste", "keywords": [ "clipboard", "copy", "cut", "paste" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "creative-commons", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "crop-selection", "keywords": [ "artboard", "crop", "design", "image", "picture" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "crown", "keywords": [ "reward", "social", "rating", "media", "queen", "vip", "king", "crown" ], "content": "\n\n\n" }, { "name": "customer-support-1", "keywords": [ "customer", "headset", "help", "microphone", "phone", "support" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cut", "keywords": [ "coupon", "cut", "discount", "price", "prices", "scissors" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dark-dislay-mode", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dashboard-3", "keywords": [ "app", "application", "dashboard", "home", "layout", "vertical" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dashboard-circle", "keywords": [ "app", "application", "dashboard", "home", "layout", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "delete-1", "keywords": [ "remove", "add", "button", "buttons", "delete", "cross", "x", "mathematics", "multiply", "math" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "descending-number-order", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "disable-bell-notification", "keywords": [ "disable", "silent", "notification", "off", "silence", "alarm", "bell", "alert" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "disable-heart", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "division-circle", "keywords": [ "interface", "math", "divided", "by", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "download-box-1", "keywords": [ "arrow", "box", "down", "download", "internet", "network", "server", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "download-circle", "keywords": [ "arrow", "circle", "down", "download", "internet", "network", "server", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "download-computer", "keywords": [ "action", "actions", "computer", "desktop", "device", "display", "download", "monitor", "screen" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "download-file", "keywords": [], "content": "\n\n\n" }, { "name": "empty-clipboard", "keywords": [ "work", "plain", "clipboard", "task", "list", "company", "office" ], "content": "\n\n\n" }, { "name": "equal-sign", "keywords": [ "interface", "math", "equal", "sign", "mathematics" ], "content": "\n\n\n" }, { "name": "expand", "keywords": [ "big", "bigger", "design", "expand", "larger", "resize", "size", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "expand-horizontal-1", "keywords": [ "expand", "resize", "bigger", "horizontal", "smaller", "size", "arrow", "arrows", "big" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "expand-window-2", "keywords": [ "expand", "small", "bigger", "retract", "smaller", "big" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "face-scan-1", "keywords": [ "identification", "angle", "secure", "human", "id", "person", "face", "security", "brackets" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "factorial", "keywords": [ "interface", "math", "number", "factorial", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fahrenheit", "keywords": [ "degrees", "temperature", "fahrenheit", "degree", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fastforward-clock", "keywords": [ "time", "clock", "reset", "stopwatch", "circle", "measure", "loading" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "file-add-alternate", "keywords": [ "file", "common", "add" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "file-delete-alternate", "keywords": [ "file", "common", "delete", "cross" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "file-remove-alternate", "keywords": [ "file", "common", "remove", "minus", "subtract" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "filter-2", "keywords": [ "funnel", "filter", "angle", "oil" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fingerprint-1", "keywords": [ "identification", "password", "touch", "id", "secure", "fingerprint", "finger", "security" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fingerprint-2", "keywords": [ "identification", "password", "touch", "id", "secure", "fingerprint", "finger", "security" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fist", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fit-to-height-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flip-vertical-arrow-2", "keywords": [ "arrow", "design", "flip", "reflect", "up", "down" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flip-vertical-circle-1", "keywords": [ "flip", "bottom", "object", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flip-vertical-square-2", "keywords": [ "design", "up", "flip", "reflect", "vertical" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "folder-add", "keywords": [ "add", "folder", "plus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "folder-check", "keywords": [ "remove", "check", "folder" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "folder-delete", "keywords": [ "remove", "minus", "folder", "subtract", "delete" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "front-camera", "keywords": [], "content": "\n\n\n" }, { "name": "gif-format", "keywords": [], "content": "\n\n\n" }, { "name": "give-gift", "keywords": [ "reward", "social", "rating", "media", "queen", "vip", "gift" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "glasses", "keywords": [ "vision", "sunglasses", "protection", "spectacles", "correction", "sun", "eye", "glasses" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "half-star-1", "keywords": [ "reward", "rating", "rate", "social", "star", "media", "favorite", "like", "stars", "half" ], "content": "\n\n\n" }, { "name": "hand-cursor", "keywords": [ "hand", "select", "cursor", "finger" ], "content": "\n\n\n" }, { "name": "hand-grab", "keywords": [ "hand", "select", "cursor", "finger", "grab" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "heading-1-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "heading-2-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "heading-3-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "heart", "keywords": [ "reward", "social", "rating", "media", "heart", "it", "like", "favorite", "love" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "help-chat-2", "keywords": [ "bubble", "help", "mark", "message", "query", "question", "speech", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "help-question-1", "keywords": [ "circle", "faq", "frame", "help", "info", "mark", "more", "query", "question" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hierarchy-10", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n" }, { "name": "hierarchy-13", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hierarchy-14", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hierarchy-2", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hierarchy-4", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n" }, { "name": "hierarchy-7", "keywords": [ "node", "organization", "links", "structure", "link", "nodes", "network", "hierarchy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "home-3", "keywords": [ "home", "house", "roof", "shelter" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "home-4", "keywords": [ "home", "house", "roof", "shelter" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "horizontal-menu-circle", "keywords": [ "navigation", "dots", "three", "circle", "button", "horizontal", "menu" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "humidity-none", "keywords": [ "humidity", "drop", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "image-blur", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "image-saturation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "information-circle", "keywords": [ "information", "frame", "info", "more", "help", "point", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "input-box", "keywords": [ "cursor", "text", "formatting", "type", "format" ], "content": "\n\n\n" }, { "name": "insert-side", "keywords": [ "points", "bullet", "align", "paragraph", "formatting", "bullets", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "insert-top-left", "keywords": [ "alignment", "wrap", "formatting", "paragraph", "image", "left", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "insert-top-right", "keywords": [ "paragraph", "image", "text", "alignment", "wrap", "right", "formatting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "invisible-1", "keywords": [ "disable", "eye", "eyeball", "hide", "off", "view" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "invisible-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "jump-object", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "key", "keywords": [ "entry", "key", "lock", "login", "pass", "unlock", "access" ], "content": "\n\n\n" }, { "name": "keyhole-lock-circle", "keywords": [ "circle", "frame", "key", "keyhole", "lock", "locked", "secure", "security" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lasso-tool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layers-1", "keywords": [ "design", "layer", "layers", "pile", "stack", "align" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layers-2", "keywords": [ "design", "layer", "layers", "pile", "stack", "align" ], "content": "\n\n\n" }, { "name": "layout-window-1", "keywords": [ "column", "layout", "layouts", "left", "sidebar" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layout-window-11", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layout-window-2", "keywords": [ "column", "header", "layout", "layouts", "masthead", "sidebar" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "layout-window-8", "keywords": [ "grid", "header", "layout", "layouts", "masthead" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lightbulb", "keywords": [ "lighting", "light", "incandescent", "bulb", "lights" ], "content": "\n\n\n" }, { "name": "like-1", "keywords": [ "reward", "social", "up", "rating", "media", "like", "thumb", "hand" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "link-chain", "keywords": [ "create", "hyperlink", "link", "make", "unlink", "connection", "chain" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "live-video", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lock-rotation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "login-1", "keywords": [ "arrow", "enter", "frame", "left", "login", "point", "rectangle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "logout-1", "keywords": [ "arrow", "exit", "frame", "leave", "logout", "rectangle", "right" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "loop-1", "keywords": [ "multimedia", "multi", "button", "repeat", "media", "loop", "infinity", "controls" ], "content": "\n\n\n" }, { "name": "magic-wand-2", "keywords": [ "design", "magic", "star", "supplies", "tool", "wand" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "magnifying-glass", "keywords": [ "glass", "search", "magnifying" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "magnifying-glass-circle", "keywords": [ "circle", "glass", "search", "magnifying" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "manual-book", "keywords": [], "content": "\n\n\n" }, { "name": "megaphone-2", "keywords": [ "bullhorn", "loud", "megaphone", "share", "speaker", "transmit" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "minimize-window-2", "keywords": [ "expand", "retract", "shrink", "bigger", "big", "small", "smaller" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "moon-cloud", "keywords": [ "cloud", "meteorology", "cloudy", "partly", "sunny", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "move-left", "keywords": [ "move", "left", "arrows" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "move-right", "keywords": [ "move", "right", "arrows" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "multiple-file-2", "keywords": [ "double", "common", "file" ], "content": "\n\n\n" }, { "name": "music-folder-song", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "new-file", "keywords": [ "empty", "common", "file", "content" ], "content": "\n\n\n" }, { "name": "new-folder", "keywords": [ "empty", "folder" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "new-sticky-note", "keywords": [ "empty", "common", "file" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "not-equal-sign", "keywords": [ "interface", "math", "not", "equal", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ok-hand", "keywords": [], "content": "\n\n\n" }, { "name": "one-finger-drag-horizontal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "one-finger-drag-vertical", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "one-finger-hold", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "one-finger-tap", "keywords": [], "content": "\n\n\n" }, { "name": "open-book", "keywords": [ "content", "books", "book", "open" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "open-umbrella", "keywords": [ "storm", "rain", "umbrella", "open", "weather" ], "content": "\n\n\n" }, { "name": "padlock-square-1", "keywords": [ "combination", "combo", "lock", "locked", "padlock", "secure", "security", "shield", "keyhole" ], "content": "\n\n\n" }, { "name": "page-setting", "keywords": [ "page", "setting", "square", "triangle", "circle", "line", "combination", "variation" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paint-bucket", "keywords": [ "bucket", "color", "colors", "design", "paint", "painting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paint-palette", "keywords": [ "color", "colors", "design", "paint", "painting", "palette" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paintbrush-1", "keywords": [ "brush", "color", "colors", "design", "paint", "painting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paintbrush-2", "keywords": [ "brush", "color", "colors", "design", "paint", "painting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paperclip-1", "keywords": [ "attachment", "link", "paperclip", "unlink" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "paragraph", "keywords": [ "alignment", "paragraph", "formatting", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-divide", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-exclude", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-intersect", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-merge", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-minus-front-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-trim", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pathfinder-union", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "peace-hand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pen-3", "keywords": [ "content", "creation", "edit", "pen", "pens", "write" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pen-draw", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pen-tool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pencil", "keywords": [ "change", "edit", "modify", "pencil", "write", "writing" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pentagon", "keywords": [ "pentagon", "design", "geometric", "shape", "shapes", "shape" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pi-symbol-circle", "keywords": [ "interface", "math", "pi", "sign", "mathematics", "22", "7" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pictures-folder-memories", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "podium", "keywords": [ "work", "desk", "notes", "company", "presentation", "office", "podium", "microphone" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "polygon", "keywords": [ "polygon", "octangle", "design", "geometric", "shape", "shapes", "shape" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "praying-hand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "projector-board", "keywords": [ "projector", "screen", "work", "meeting", "presentation" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "pyramid-shape", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "quotation-2", "keywords": [ "quote", "quotation", "format", "formatting", "open", "close", "marks", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "radioactive-2", "keywords": [ "warning", "radioactive", "radiation", "emergency", "danger", "safety" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rain-cloud", "keywords": [ "cloud", "rain", "rainy", "meteorology", "precipitation", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "recycle-bin-2", "keywords": [ "remove", "delete", "empty", "bin", "trash", "garbage" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ringing-bell-notification", "keywords": [ "notification", "vibrate", "ring", "sound", "alarm", "alert", "bell", "noise" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rock-and-roll-hand", "keywords": [], "content": "\n\n\n" }, { "name": "rotate-angle-45", "keywords": [ "rotate", "angle", "company", "office", "supplies", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "round-cap", "keywords": [], "content": "\n\n\n" }, { "name": "satellite-dish", "keywords": [ "broadcast", "satellite", "share", "transmit", "satellite" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "scanner", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "search-visual", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "select-circle-area-1", "keywords": [ "select", "area", "object", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "share-link", "keywords": [ "share", "transmit" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shield-1", "keywords": [ "shield", "protection", "security", "defend", "crime", "war", "cover" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shield-2", "keywords": [ "shield", "protection", "security", "defend", "crime", "war", "cover" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shield-check", "keywords": [ "shield", "protection", "security", "defend", "crime", "war", "cover", "check" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shield-cross", "keywords": [ "shield", "secure", "security", "cross", "add", "plus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shrink-horizontal-1", "keywords": [ "resize", "shrink", "bigger", "horizontal", "smaller", "size", "arrow", "arrows", "big" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shuffle", "keywords": [ "multimedia", "shuffle", "multi", "button", "controls", "media" ], "content": "\n\n\n" }, { "name": "sigma", "keywords": [ "formula", "text", "format", "sigma", "formatting", "sum" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "skull-1", "keywords": [ "crash", "death", "delete", "die", "error", "garbage", "remove", "skull", "trash" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sleep", "keywords": [], "content": "\n\n\n" }, { "name": "snow-flake", "keywords": [ "winter", "freeze", "snow", "freezing", "ice", "cold", "weather", "snowflake" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sort-descending", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "spiral-shape", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "split-vertical", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "spray-paint", "keywords": [ "can", "color", "colors", "design", "paint", "painting", "spray" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "square-brackets-circle", "keywords": [ "interface", "math", "brackets", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "square-cap", "keywords": [], "content": "\n\n\n" }, { "name": "square-clock", "keywords": [ "clock", "loading", "frame", "measure", "time", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "square-root-x-circle", "keywords": [ "interface", "math", "square", "root", "sign", "mathematics" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "star-1", "keywords": [ "reward", "rating", "rate", "social", "star", "media", "favorite", "like", "stars" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "star-2", "keywords": [ "reward", "rating", "rate", "social", "star", "media", "favorite", "like", "stars", "spark" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "star-badge", "keywords": [ "ribbon", "reward", "like", "social", "rating", "media" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "straight-cap", "keywords": [], "content": "\n\n\n" }, { "name": "subtract-1", "keywords": [ "button", "delete", "buttons", "subtract", "horizontal", "remove", "line", "add", "mathematics", "math", "minus" ], "content": "\n\n\n" }, { "name": "subtract-circle", "keywords": [ "delete", "add", "circle", "subtract", "button", "buttons", "remove", "mathematics", "math", "minus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "subtract-square", "keywords": [ "subtract", "buttons", "remove", "add", "button", "square", "delete", "mathematics", "math", "minus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sun-cloud", "keywords": [ "cloud", "meteorology", "cloudy", "partly", "sunny", "weather" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "synchronize-disable", "keywords": [ "arrows", "loading", "load", "sync", "synchronize", "arrow", "reload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "synchronize-warning", "keywords": [ "arrow", "fail", "notification", "sync", "warning", "failure", "synchronize", "error" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "table-lamp-1", "keywords": [ "lighting", "light", "incandescent", "bulb", "lights", "table", "lamp" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tag", "keywords": [ "tags", "bookmark", "favorite" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "text-flow-rows", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "text-square", "keywords": [ "text", "options", "formatting", "format", "square", "color", "border", "fill" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "text-style", "keywords": [ "text", "style", "formatting", "format" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "thermometer", "keywords": [ "temperature", "thermometer", "weather", "level", "meter", "mercury", "measure" ], "content": "\n\n\n" }, { "name": "trending-content", "keywords": [ "lit", "flame", "torch", "trending" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "trophy", "keywords": [ "reward", "rating", "trophy", "social", "award", "media" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "two-finger-drag-hotizontal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "two-finger-tap", "keywords": [], "content": "\n\n\n" }, { "name": "underline-text-1", "keywords": [ "text", "underline", "formatting", "format" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "upload-box-1", "keywords": [ "arrow", "box", "download", "internet", "network", "server", "up", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "upload-circle", "keywords": [ "arrow", "circle", "download", "internet", "network", "server", "up", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "upload-computer", "keywords": [ "action", "actions", "computer", "desktop", "device", "display", "monitor", "screen", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "upload-file", "keywords": [], "content": "\n\n\n" }, { "name": "user-add-plus", "keywords": [ "actions", "add", "close", "geometric", "human", "person", "plus", "single", "up", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-check-validate", "keywords": [ "actions", "close", "checkmark", "check", "geometric", "human", "person", "single", "success", "up", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-circle-single", "keywords": [ "circle", "geometric", "human", "person", "single", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-identifier-card", "keywords": [], "content": "\n\n\n" }, { "name": "user-multiple-circle", "keywords": [ "close", "geometric", "human", "multiple", "person", "up", "user", "circle" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-multiple-group", "keywords": [ "close", "geometric", "human", "multiple", "person", "up", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-profile-focus", "keywords": [ "close", "geometric", "human", "person", "profile", "focus", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-protection-2", "keywords": [ "shield", "secure", "security", "profile", "person" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-remove-subtract", "keywords": [ "actions", "remove", "close", "geometric", "human", "person", "minus", "single", "up", "user" ], "content": "\n\n\n" }, { "name": "user-single-neutral-male", "keywords": [ "close", "geometric", "human", "person", "single", "up", "user", "male" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "user-sync-online-in-person", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "vertical-slider-square", "keywords": [ "adjustment", "adjust", "controls", "fader", "vertical", "settings", "slider", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "video-swap-camera", "keywords": [], "content": "\n\n\n" }, { "name": "visible", "keywords": [ "eye", "eyeball", "open", "view" ], "content": "\n\n\n" }, { "name": "voice-scan-2", "keywords": [ "identification", "secure", "id", "soundwave", "sound", "voice", "brackets", "security" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "waning-cresent-moon", "keywords": [ "night", "new", "moon", "crescent", "weather", "time", "waning" ], "content": "\n\n\n" }, { "name": "warning-octagon", "keywords": [ "frame", "alert", "warning", "octagon", "exclamation", "caution" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "warning-triangle", "keywords": [ "frame", "alert", "warning", "triangle", "exclamation", "caution" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "mail": [ { "name": "chat-bubble-oval", "keywords": [ "messages", "message", "bubble", "chat", "oval" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-oval-notification", "keywords": [ "messages", "message", "bubble", "chat", "oval", "notify", "ping" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-oval-smiley-1", "keywords": [ "messages", "message", "bubble", "chat", "oval", "smiley", "smile" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-oval-smiley-2", "keywords": [ "messages", "message", "bubble", "chat", "oval", "smiley", "smile" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-square-block", "keywords": [ "messages", "message", "bubble", "chat", "square", "block" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-square-question", "keywords": [ "bubble", "square", "messages", "notification", "chat", "message", "question", "help" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-square-warning", "keywords": [ "bubble", "square", "messages", "notification", "chat", "message", "warning", "alert" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-square-write", "keywords": [ "messages", "message", "bubble", "chat", "square", "write", "review", "pen", "pencil", "compose" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-text-square", "keywords": [ "messages", "message", "bubble", "text", "square", "chat" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-bubble-typing-oval", "keywords": [ "messages", "message", "bubble", "typing", "chat" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "chat-two-bubbles-oval", "keywords": [ "messages", "message", "bubble", "chat", "oval", "conversation" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discussion-converstion-reply", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "happy-face", "keywords": [ "smiley", "chat", "message", "smile", "emoji", "face", "satisfied" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-block", "keywords": [ "mail", "envelope", "email", "message", "block", "spam", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-favorite", "keywords": [ "mail", "envelope", "email", "message", "star", "favorite", "important", "bookmark" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-favorite-heart", "keywords": [ "mail", "envelope", "email", "message", "heart", "favorite", "like", "love", "important", "bookmark" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-lock", "keywords": [ "mail", "envelope", "email", "message", "secure", "password", "lock", "encryption" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-tray-1", "keywords": [ "mail", "email", "outbox", "drawer", "empty", "open", "inbox", "arrow", "down" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "inbox-tray-2", "keywords": [ "mail", "email", "outbox", "drawer", "empty", "open", "inbox", "arrow", "up" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mail-incoming", "keywords": [ "inbox", "envelope", "email", "message", "down", "arrow", "inbox" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mail-search", "keywords": [ "inbox", "envelope", "email", "message", "search" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mail-send-email-message", "keywords": [ "send", "email", "paper", "airplane", "deliver" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "mail-send-envelope", "keywords": [ "envelope", "email", "message", "unopened", "sealed", "close" ], "content": "\n\n\n" }, { "name": "mail-send-reply-all", "keywords": [ "email", "message", "reply", "all", "actions", "action", "arrow" ], "content": "\n\n\n" }, { "name": "sad-face", "keywords": [ "smiley", "chat", "message", "emoji", "sad", "face", "unsatisfied" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "send-email", "keywords": [ "mail", "send", "email", "paper", "airplane" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sign-at", "keywords": [ "mail", "email", "at", "sign", "read", "address" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sign-hashtag", "keywords": [ "mail", "sharp", "sign", "hashtag", "tag" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-angry", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-cool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-crying-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-cute", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-drool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-emoji-kiss-nervous", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-emoji-terrified", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-grumpy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-happy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-in-love", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-kiss", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "smiley-laughing-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "map_travel": [ { "name": "airplane", "keywords": [ "travel", "plane", "adventure", "airplane", "transportation" ], "content": "\n\n\n" }, { "name": "airport-plane-transit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "airport-plane", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "airport-security", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "anchor", "keywords": [ "anchor", "marina", "harbor", "port", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "baggage", "keywords": [ "check", "baggage", "travel", "adventure", "luggage", "bag", "checked", "airport" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "beach", "keywords": [ "island", "waves", "outdoor", "recreation", "tree", "beach", "palm", "wave", "water", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bicycle-bike", "keywords": [], "content": "\n\n\n" }, { "name": "braille-blind", "keywords": [ "disability", "braille", "blind" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bus", "keywords": [ "transportation", "travel", "bus", "transit", "transport", "motorcoach", "public" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "camping-tent", "keywords": [ "outdoor", "recreation", "camping", "tent", "teepee", "tipi", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cane", "keywords": [ "disability", "cane" ], "content": "\n\n\n" }, { "name": "capitol", "keywords": [ "capitol", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "car-battery-charging", "keywords": [], "content": "\n\n\n" }, { "name": "car-taxi-1", "keywords": [ "transportation", "travel", "taxi", "transport", "cab", "car" ], "content": "\n\n\n\n\n" }, { "name": "city-hall", "keywords": [ "city", "hall", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "compass-navigator", "keywords": [], "content": "\n\n\n" }, { "name": "crutch", "keywords": [ "disability", "crutch" ], "content": "\n\n\n" }, { "name": "dangerous-zone-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "earth-1", "keywords": [ "planet", "earth", "globe", "world" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "earth-airplane", "keywords": [ "travel", "plane", "trip", "airplane", "international", "adventure", "globe", "world", "airport" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "emergency-exit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fire-alarm-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "fire-extinguisher-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gas-station-fuel-petroleum", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hearing-deaf-1", "keywords": [ "disability", "hearing", "deaf" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hearing-deaf-2", "keywords": [ "disability", "hearing", "deaf" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "high-speed-train-front", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hot-spring", "keywords": [ "relax", "location", "outdoor", "recreation", "spa", "travel", "places" ], "content": "\n\n\n" }, { "name": "hotel-air-conditioner", "keywords": [ "heating", "ac", "air", "hvac", "cool", "cooling", "cold", "hot", "conditioning", "hotel" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hotel-bed-2", "keywords": [ "bed", "double", "bedroom", "bedrooms", "queen", "king", "full", "hotel", "hotel" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hotel-laundry", "keywords": [ "laundry", "machine", "hotel" ], "content": "\n\n\n" }, { "name": "hotel-one-star", "keywords": [ "one", "star", "reviews", "review", "rating", "hotel", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "hotel-shower-head", "keywords": [ "bathe", "bath", "bathroom", "shower", "water", "head", "hotel" ], "content": "\n\n\n" }, { "name": "hotel-two-star", "keywords": [ "two", "stars", "reviews", "review", "rating", "hotel", "star" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "information-desk-customer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "information-desk", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "iron", "keywords": [ "laundry", "iron", "heat", "hotel" ], "content": "\n\n\n" }, { "name": "ladder", "keywords": [ "business", "product", "metaphor", "ladder" ], "content": "\n\n\n" }, { "name": "lift", "keywords": [ "arrow", "up", "human", "down", "person", "user", "lift", "elevator" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lift-disability", "keywords": [ "arrow", "up", "human", "down", "person", "user", "lift", "elevator", "disability", "wheelchair", "accessible" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "location-compass-1", "keywords": [ "arrow", "compass", "location", "gps", "map", "maps", "point" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "location-pin-3", "keywords": [ "navigation", "map", "maps", "pin", "gps", "location" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "location-pin-disabled", "keywords": [ "navigation", "map", "maps", "pin", "gps", "location", "disabled", "off" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "location-target-1", "keywords": [ "navigation", "location", "map", "services", "maps", "gps", "target" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lost-and-found", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "man-symbol", "keywords": [ "geometric", "gender", "boy", "person", "male", "human", "user" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "map-fold", "keywords": [ "navigation", "map", "maps", "gps", "travel", "fold" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "navigation-arrow-off", "keywords": [ "compass", "arrow", "map", "bearing", "navigation", "maps", "heading", "gps", "off", "disable" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "navigation-arrow-on", "keywords": [ "compass", "arrow", "map", "bearing", "navigation", "maps", "heading", "gps", "off", "disable" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "parking-sign", "keywords": [ "discount", "coupon", "parking", "price", "prices", "hotel" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "parliament", "keywords": [ "travel", "places", "parliament" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "passport", "keywords": [ "travel", "book", "id", "adventure", "visa", "airport" ], "content": "\n\n\n" }, { "name": "pet-paw", "keywords": [ "paw", "foot", "animals", "pets", "footprint", "track", "hotel" ], "content": "\n\n\n" }, { "name": "pets-allowed", "keywords": [ "travel", "wayfinder", "pets", "allowed" ], "content": "\n\n\n" }, { "name": "pool-ladder", "keywords": [ "pool", "stairs", "swim", "swimming", "water", "ladder", "hotel" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rock-slide", "keywords": [ "hill", "cliff", "sign", "danger", "stone" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sail-ship", "keywords": [ "travel", "boat", "transportation", "transport", "ocean", "ship", "sea", "water" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "school-bus-side", "keywords": [], "content": "\n\n\n" }, { "name": "smoke-detector", "keywords": [ "smoke", "alert", "fire", "signal" ], "content": "\n\n\n" }, { "name": "smoking-area", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "snorkle", "keywords": [ "diving", "scuba", "outdoor", "recreation", "ocean", "mask", "water", "sea", "snorkle", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "steering-wheel", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "street-road", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "street-sign", "keywords": [ "crossroad", "street", "sign", "metaphor", "directions", "travel", "places" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "take-off", "keywords": [ "travel", "plane", "adventure", "airplane", "take", "off", "airport" ], "content": "\n\n\n" }, { "name": "toilet-man", "keywords": [ "travel", "wayfinder", "toilet", "man" ], "content": "\n\n\n" }, { "name": "toilet-sign-man-woman-2", "keywords": [ "toilet", "sign", "restroom", "bathroom", "user", "human", "person" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "toilet-women", "keywords": [ "travel", "wayfinder", "toilet", "women" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "traffic-cone", "keywords": [ "street", "sign", "traffic", "cone", "road" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "triangle-flag", "keywords": [ "navigation", "map", "maps", "flag", "gps", "location", "destination", "goal" ], "content": "\n\n\n" }, { "name": "wheelchair-1", "keywords": [ "person", "access", "wheelchair", "accomodation", "human", "disability", "disabled", "user" ], "content": "\n\n\n" }, { "name": "woman-symbol", "keywords": [ "geometric", "gender", "female", "person", "human", "user" ], "content": "\n\n\n" } ], "money_shopping": [ { "name": "annoncement-megaphone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "backpack", "keywords": [ "bag", "backpack", "school", "baggage", "cloth", "clothing", "accessories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-dollar", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-pound", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-rupee", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-suitcase-1", "keywords": [ "product", "business", "briefcase" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-suitcase-2", "keywords": [ "product", "business", "briefcase" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bag-yen", "keywords": [ "bag", "payment", "cash", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ball", "keywords": [ "sports", "ball", "sport", "basketball", "shopping", "catergories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bank", "keywords": [ "institution", "saving", "bank", "payment", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "beanie", "keywords": [ "beanie", "winter", "hat", "warm", "cloth", "clothing", "wearable", "accessories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bill-1", "keywords": [ "billing", "bills", "payment", "finance", "cash", "currency", "money", "accounting" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bill-2", "keywords": [ "currency", "billing", "payment", "finance", "cash", "bill", "money", "accounting" ], "content": "\n\n\n" }, { "name": "bill-4", "keywords": [ "accounting", "billing", "payment", "finance", "cash", "currency", "money", "bill", "dollar", "stack" ], "content": "\n\n\n" }, { "name": "bill-cashless", "keywords": [ "currency", "billing", "payment", "finance", "no", "cash", "bill", "money", "accounting", "cashless" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "binance-circle", "keywords": [ "crypto", "circle", "payment", "blockchain", "finance", "binance", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bitcoin", "keywords": [ "crypto", "circle", "payment", "blokchain", "finance", "bitcoin", "money", "currency" ], "content": "\n\n\n" }, { "name": "bow-tie", "keywords": [ "bow", "tie", "dress", "gentleman", "cloth", "clothing", "accessories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "briefcase-dollar", "keywords": [ "briefcase", "payment", "cash", "money", "finance", "baggage", "bag" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "building-2", "keywords": [ "real", "home", "tower", "building", "house", "estate" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "business-card", "keywords": [ "name", "card", "business", "information", "money", "payment" ], "content": "\n\n\n" }, { "name": "business-handshake", "keywords": [ "deal", "contract", "business", "money", "payment", "agreement" ], "content": "\n\n\n" }, { "name": "business-idea-money", "keywords": [], "content": "\n\n\n" }, { "name": "business-profession-home-office", "keywords": [ "workspace", "home", "office", "work", "business", "remote", "working" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "business-progress-bar-2", "keywords": [ "business", "production", "arrow", "workflow", "money", "flag", "timeline" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "business-user-curriculum", "keywords": [], "content": "\n\n\n" }, { "name": "calculator-1", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "calculate", "math" ], "content": "\n\n\n" }, { "name": "calculator-2", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "calculate", "math", "sign" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cane", "keywords": [ "walking", "stick", "cane", "accessories", "gentleman", "accessories" ], "content": "\n\n\n" }, { "name": "chair", "keywords": [ "chair", "business", "product", "comfort", "decoration", "sit", "furniture" ], "content": "\n\n\n" }, { "name": "closet", "keywords": [ "closet", "dressing", "dresser", "product", "decoration", "cloth", "clothing", "cabinet", "furniture" ], "content": "\n\n\n" }, { "name": "coin-share", "keywords": [ "payment", "cash", "money", "finance", "receive", "give", "coin", "hand" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "coins-stack", "keywords": [ "accounting", "billing", "payment", "stack", "cash", "coins", "currency", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "credit-card-1", "keywords": [ "credit", "pay", "payment", "debit", "card", "finance", "plastic", "money", "atm" ], "content": "\n\n\n" }, { "name": "credit-card-2", "keywords": [ "deposit", "payment", "finance", "atm", "withdraw", "atm" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "diamond-2", "keywords": [ "diamond", "money", "payment", "finance", "wealth", "jewelry" ], "content": "\n\n\n" }, { "name": "discount-percent-badge", "keywords": [ "shop", "shops", "stores", "discount", "coupon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discount-percent-circle", "keywords": [ "store", "shop", "shops", "stores", "discount", "coupon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discount-percent-coupon", "keywords": [ "shop", "shops", "stores", "discount", "coupon", "voucher" ], "content": "\n\n\n" }, { "name": "discount-percent-cutout", "keywords": [ "store", "shop", "shops", "stores", "discount", "coupon" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "discount-percent-fire", "keywords": [ "shop", "shops", "stores", "discount", "coupon", "hot", "trending" ], "content": "\n\n\n" }, { "name": "dollar-coin", "keywords": [ "accounting", "billing", "payment", "cash", "coin", "currency", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dollar-coin-1", "keywords": [ "accounting", "billing", "payment", "cash", "coin", "currency", "money", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dressing-table", "keywords": [ "makeup", "dressing", "table", "mirror", "cabinet", "product", "decoration", "furniture" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "ethereum", "keywords": [ "crypto", "circle", "payment", "blokchain", "finance", "ethereum", "eth", "currency" ], "content": "\n\n\n" }, { "name": "ethereum-circle", "keywords": [ "crypto", "circle", "payment", "blockchain", "finance", "eth", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "euro", "keywords": [ "exchange", "payment", "euro", "forex", "finance", "foreign", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gift", "keywords": [ "reward", "box", "social", "present", "gift", "media", "rating", "bow" ], "content": "\n\n\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "gift-2", "keywords": [ "reward", "box", "social", "present", "gift", "media", "rating", "bow" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "gold", "keywords": [ "gold", "money", "payment", "bars", "finance", "wealth", "bullion", "jewelry" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graph", "keywords": [ "analytics", "business", "product", "graph", "data", "chart", "analysis" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graph-arrow-decrease", "keywords": [ "down", "stats", "graph", "descend", "right", "arrow" ], "content": "\n\n\n" }, { "name": "graph-arrow-increase", "keywords": [ "ascend", "growth", "up", "arrow", "stats", "graph", "right", "grow" ], "content": "\n\n\n" }, { "name": "graph-bar-decrease", "keywords": [ "arrow", "product", "performance", "down", "decrease", "graph", "business", "chart" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graph-bar-increase", "keywords": [ "up", "product", "performance", "increase", "arrow", "graph", "business", "chart" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graph-dot", "keywords": [ "product", "data", "bars", "analysis", "analytics", "graph", "business", "chart", "dot" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "investment-selection", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "justice-hammer", "keywords": [ "hammer", "work", "mallet", "office", "company", "gavel", "justice", "judge", "arbitration", "court" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "justice-scale-1", "keywords": [ "office", "work", "scale", "justice", "company", "arbitration", "balance", "court" ], "content": "\n\n\n" }, { "name": "justice-scale-2", "keywords": [ "office", "work", "scale", "justice", "unequal", "company", "arbitration", "unbalance", "court" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "lipstick", "keywords": [ "fashion", "beauty", "lip", "lipstick", "makeup", "shopping" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "make-up-brush", "keywords": [ "fashion", "beauty", "make", "up", "brush" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "moustache", "keywords": [ "fashion", "beauty", "moustache", "grooming" ], "content": "\n\n\n" }, { "name": "mouth-lip", "keywords": [ "fashion", "beauty", "mouth", "lip" ], "content": "\n\n\n" }, { "name": "necklace", "keywords": [ "diamond", "money", "payment", "finance", "wealth", "accessory", "necklace", "jewelry" ], "content": "\n\n\n" }, { "name": "necktie", "keywords": [ "necktie", "businessman", "business", "cloth", "clothing", "gentleman", "accessories" ], "content": "\n\n\n" }, { "name": "payment-10", "keywords": [ "deposit", "payment", "finance", "atm", "transfer", "dollar" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "payment-cash-out-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "pie-chart", "keywords": [ "product", "data", "analysis", "analytics", "pie", "business", "chart" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "piggy-bank", "keywords": [ "institution", "saving", "bank", "payment", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "polka-dot-circle", "keywords": [ "crypto", "circle", "payment", "blockchain", "finance", "polka", "dot", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "production-belt", "keywords": [ "production", "produce", "box", "belt", "factory", "product", "package", "business" ], "content": "\n\n\n" }, { "name": "qr-code", "keywords": [ "codes", "tags", "code", "qr" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "receipt", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "bill", "receipt" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "receipt-add", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "bill", "receipt", "add", "plus", "new" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "receipt-check", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "bill", "receipt", "check", "confirm" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "receipt-subtract", "keywords": [ "shop", "shopping", "pay", "payment", "store", "cash", "bill", "receipt", "subtract", "minus", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "safe-vault", "keywords": [ "saving", "combo", "payment", "safe", "combination", "finance" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "scanner-3", "keywords": [ "payment", "electronic", "cash", "dollar", "codes", "tags", "upc", "barcode", "qr" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "scanner-bar-code", "keywords": [ "codes", "tags", "upc", "barcode" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shelf", "keywords": [ "shelf", "drawer", "cabinet", "prodcut", "decoration", "furniture" ], "content": "\n\n\n" }, { "name": "shopping-bag-hand-bag-2", "keywords": [ "shopping", "bag", "purse", "goods", "item", "products" ], "content": "\n\n\n" }, { "name": "shopping-basket-1", "keywords": [ "shopping", "basket" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-basket-2", "keywords": [ "shopping", "basket" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-1", "keywords": [ "shopping", "cart", "checkout" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-2", "keywords": [ "shopping", "cart", "checkout" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-3", "keywords": [ "shopping", "cart", "checkout" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-add", "keywords": [ "shopping", "cart", "checkout", "add", "plus", "new" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-check", "keywords": [ "shopping", "cart", "checkout", "check", "confirm" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shopping-cart-subtract", "keywords": [ "shopping", "cart", "checkout", "subtract", "minus", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "signage-3", "keywords": [ "street", "sandwich", "shops", "shop", "stores", "board", "sign", "store" ], "content": "\n\n\n" }, { "name": "signage-4", "keywords": [ "street", "billboard", "shops", "shop", "stores", "board", "sign", "ads", "banner" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "startup", "keywords": [ "shop", "rocket", "launch", "startup" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "stock", "keywords": [ "price", "stock", "wallstreet", "dollar", "money", "currency", "fluctuate", "candlestick", "business" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "store-1", "keywords": [ "store", "shop", "shops", "stores" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "store-2", "keywords": [ "store", "shop", "shops", "stores" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "store-computer", "keywords": [ "store", "shop", "shops", "stores", "online", "computer", "website", "desktop", "app" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "subscription-cashflow", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tag", "keywords": [ "codes", "tags", "tag", "product", "label" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tall-hat", "keywords": [ "tall", "hat", "cloth", "clothing", "wearable", "magician", "gentleman", "accessories" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "target", "keywords": [ "shop", "bullseye", "arrow", "target" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "target-3", "keywords": [ "shop", "bullseye", "shooting", "target" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wallet", "keywords": [ "money", "payment", "finance", "wallet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "wallet-purse", "keywords": [ "money", "payment", "finance", "wallet", "purse" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "xrp-circle", "keywords": [ "crypto", "circle", "payment", "blockchain", "finance", "xrp", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "yuan", "keywords": [ "exchange", "payment", "forex", "finance", "yuan", "foreign", "currency" ], "content": "\n\n\n" }, { "name": "yuan-circle", "keywords": [ "exchange", "payment", "forex", "finance", "yuan", "foreign", "currency" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "nature_ecology": [ { "name": "affordable-and-clean-energy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "alien", "keywords": [ "science", "extraterristerial", "life", "form", "space", "universe", "head", "astronomy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bone", "keywords": [ "nature", "pet", "dog", "bone", "food", "snack" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cat-1", "keywords": [ "nature", "head", "cat", "pet", "animals", "felyne" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "circle-flask", "keywords": [ "science", "experiment", "lab", "flask", "chemistry", "solution" ], "content": "\n\n\n" }, { "name": "clean-water-and-sanitation", "keywords": [], "content": "\n\n\n" }, { "name": "comet", "keywords": [ "nature", "meteor", "fall", "space", "object", "danger" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "decent-work-and-economic-growth", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dna", "keywords": [ "science", "biology", "experiment", "lab", "science" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "erlenmeyer-flask", "keywords": [ "science", "experiment", "lab", "flask", "chemistry", "solution" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "flower", "keywords": [ "nature", "plant", "tree", "flower", "petals", "bloom" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "galaxy-1", "keywords": [ "science", "space", "universe", "astronomy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "galaxy-2", "keywords": [ "science", "space", "universe", "astronomy" ], "content": "\n\n\n" }, { "name": "gender-equality", "keywords": [], "content": "\n\n\n" }, { "name": "good-health-and-well-being", "keywords": [], "content": "\n\n\n" }, { "name": "industry-innovation-and-infrastructure", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "leaf", "keywords": [ "nature", "environment", "leaf", "ecology", "plant", "plants", "eco" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "log", "keywords": [ "nature", "tree", "plant", "circle", "round", "log" ], "content": "\n\n\n" }, { "name": "no-poverty", "keywords": [], "content": "\n\n\n" }, { "name": "octopus", "keywords": [ "nature", "sealife", "animals" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "planet", "keywords": [ "science", "solar", "system", "ring", "planet", "saturn", "space", "astronomy", "astronomy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "potted-flower-tulip", "keywords": [ "nature", "flower", "plant", "tree", "pot" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "quality-education", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rainbow", "keywords": [ "nature", "arch", "rain", "colorful", "rainbow", "curve", "half", "circle" ], "content": "\n\n\n" }, { "name": "recycle-1", "keywords": [ "nature", "sign", "environment", "protect", "save", "arrows" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "reduced-inequalities", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rose", "keywords": [ "nature", "flower", "rose", "plant", "tree" ], "content": "\n\n\n" }, { "name": "shell", "keywords": [ "nature", "sealife", "animals" ], "content": "\n\n\n" }, { "name": "shovel-rake", "keywords": [ "nature", "crops", "plants" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "sprout", "keywords": [], "content": "\n\n\n" }, { "name": "telescope", "keywords": [ "science", "experiment", "star", "gazing", "sky", "night", "space", "universe", "astronomy", "astronomy" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "test-tube", "keywords": [ "science", "experiment", "lab", "chemistry", "test", "tube", "solution" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tidal-wave", "keywords": [ "nature", "ocean", "wave" ], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "name": "tree-2", "keywords": [ "nature", "tree", "plant", "circle", "round", "park" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "tree-3", "keywords": [ "nature", "tree", "plant", "cloud", "shape", "park" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "volcano", "keywords": [ "nature", "eruption", "erupt", "mountain", "volcano", "lava", "magma", "explosion" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "windmill", "keywords": [], "content": "\n\n\n" }, { "name": "zero-hunger", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "phone": [ { "name": "airplane-disabled", "keywords": [ "server", "plane", "airplane", "disabled", "off", "wireless", "mode", "internet", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "airplane-enabled", "keywords": [ "server", "plane", "airplane", "enabled", "on", "wireless", "mode", "internet", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "back-camera-1", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "camera", "lenses" ], "content": "\n\n\n" }, { "name": "call-hang-up", "keywords": [ "phone", "telephone", "mobile", "device", "smartphone", "call", "hang", "up" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cellular-network-4g", "keywords": [], "content": "\n\n\n" }, { "name": "cellular-network-5g", "keywords": [], "content": "\n\n\n" }, { "name": "cellular-network-lte", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "contact-phonebook-2", "keywords": [], "content": "\n\n\n" }, { "name": "hang-up-1", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "telephone", "hang", "up" ], "content": "\n\n\n" }, { "name": "hang-up-2", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "telephone", "hang", "up" ], "content": "\n\n\n" }, { "name": "incoming-call", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "ringing", "incoming", "call" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "missed-call", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "missed", "call" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "notification-alarm-2", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "alert", "bell", "alarm" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "notification-application-1", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "alert", "box" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "notification-application-2", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "alert", "box" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "notification-message-alert", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "alert", "message", "text" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "outgoing-call", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "ringing", "outgoing", "call" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "phone", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "phone-mobile-phone", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone" ], "content": "\n\n\n" }, { "name": "phone-qr", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "qr", "code", "scan" ], "content": "\n\n\n" }, { "name": "phone-ringing-1", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "ringing", "incoming", "call" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "phone-ringing-2", "keywords": [ "android", "phone", "mobile", "device", "smartphone", "iphone", "ringing" ], "content": "\n\n\n" }, { "name": "signal-full", "keywords": [ "phone", "mobile", "device", "signal", "wireless", "smartphone", "iphone", "bar", "bars", "full", "android" ], "content": "\n\n\n" }, { "name": "signal-low", "keywords": [ "phone", "mobile", "device", "signal", "wireless", "smartphone", "iphone", "bar", "low", "bars", "android" ], "content": "\n\n\n" }, { "name": "signal-medium", "keywords": [ "smartphone", "phone", "mobile", "device", "iphone", "signal", "medium", "wireless", "bar", "bars", "android" ], "content": "\n\n\n" }, { "name": "signal-none", "keywords": [ "phone", "mobile", "device", "signal", "wireless", "smartphone", "iphone", "bar", "bars", "no", "zero", "android" ], "content": "\n\n\n" } ], "programing": [ { "name": "application-add", "keywords": [ "application", "new", "add", "square" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bracket", "keywords": [ "code", "angle", "programming", "file", "bracket" ], "content": "\n\n\n" }, { "name": "browser-add", "keywords": [ "app", "code", "apps", "add", "window", "plus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-block", "keywords": [ "block", "access", "denied", "window", "browser", "privacy", "remove" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-build", "keywords": [ "build", "website", "development", "window", "code", "web", "backend", "browser", "dev" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-check", "keywords": [ "checkmark", "pass", "window", "app", "code", "success", "check", "apps" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-delete", "keywords": [ "app", "code", "apps", "fail", "delete", "window", "remove", "cross" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-hash", "keywords": [ "window", "hash", "code", "internet", "language", "browser", "web", "tag" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-lock", "keywords": [ "secure", "password", "window", "browser", "lock", "security", "login", "encryption" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-multiple-window", "keywords": [ "app", "code", "apps", "two", "window", "cascade" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-remove", "keywords": [ "app", "code", "apps", "subtract", "window", "minus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "browser-website-1", "keywords": [ "app", "code", "apps", "window", "website", "web" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug", "keywords": [ "code", "bug", "security", "programming", "secure", "computer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-antivirus-debugging", "keywords": [ "code", "bug", "security", "programming", "secure", "computer", "antivirus", "block", "protection", "malware", "debugging" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-antivirus-shield", "keywords": [ "code", "bug", "security", "programming", "secure", "computer", "antivirus", "shield", "protection", "malware" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-virus-browser", "keywords": [ "bug", "browser", "file", "virus", "threat", "danger", "internet" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-virus-document", "keywords": [ "bug", "document", "file", "virus", "threat", "danger" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "bug-virus-folder", "keywords": [ "bug", "document", "folder", "virus", "threat", "danger" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-add", "keywords": [ "cloud", "network", "internet", "add", "server", "plus" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-block", "keywords": [ "cloud", "network", "internet", "block", "server", "deny" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-check", "keywords": [ "cloud", "network", "internet", "check", "server", "approve" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-data-transfer", "keywords": [ "cloud", "data", "transfer", "internet", "server", "network" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-refresh", "keywords": [ "cloud", "network", "internet", "server", "refresh" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-share", "keywords": [ "cloud", "network", "internet", "server", "share" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-warning", "keywords": [ "cloud", "network", "internet", "server", "warning", "alert" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "cloud-wifi", "keywords": [ "cloud", "wifi", "internet", "server", "network" ], "content": "\n\n\n" }, { "name": "code-analysis", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "code-monitor-1", "keywords": [ "code", "tags", "angle", "bracket", "monitor" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "code-monitor-2", "keywords": [ "code", "tags", "angle", "image", "ui", "ux", "design" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "css-three", "keywords": [ "language", "three", "code", "programming", "html", "css" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "curly-brackets", "keywords": [], "content": "\n\n\n" }, { "name": "file-code-1", "keywords": [ "code", "files", "angle", "programming", "file", "bracket" ], "content": "\n\n\n" }, { "name": "incognito-mode", "keywords": [ "internet", "safe", "mode", "browser" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "insert-cloud-video", "keywords": [], "content": "\n\n\n" }, { "name": "markdown-circle-programming", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "markdown-document-programming", "keywords": [], "content": "\n\n\n" }, { "name": "module-puzzle-1", "keywords": [ "code", "puzzle", "module", "programming", "plugin", "piece" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "module-puzzle-3", "keywords": [ "code", "puzzle", "module", "programming", "plugin", "piece" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "module-three", "keywords": [ "code", "three", "module", "programming", "plugin" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "rss-square", "keywords": [ "wireless", "rss", "feed", "square", "transmit", "broadcast" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "shipping": [ { "name": "box-sign", "keywords": [ "box", "package", "label", "delivery", "shipment", "shipping", "this", "way", "up", "arrow", "sign", "sticker" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "container", "keywords": [ "box", "package", "label", "delivery", "shipment", "shipping", "container" ], "content": "\n\n\n" }, { "name": "fragile", "keywords": [ "fragile", "shipping", "glass", "delivery", "wine", "crack", "shipment", "sign", "sticker" ], "content": "\n\n\n" }, { "name": "parachute-drop", "keywords": [ "package", "box", "fulfillment", "cart", "warehouse", "shipping", "delivery", "drop", "parachute" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-add", "keywords": [ "shipping", "parcel", "shipment", "add" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-check", "keywords": [ "shipping", "parcel", "shipment", "check", "approved" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-download", "keywords": [ "shipping", "parcel", "shipment", "download" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-remove", "keywords": [ "shipping", "parcel", "shipment", "remove", "subtract" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipment-upload", "keywords": [ "shipping", "parcel", "shipment", "upload" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipping-box-1", "keywords": [ "box", "package", "label", "delivery", "shipment", "shipping" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "shipping-truck", "keywords": [ "truck", "shipping", "delivery", "transfer" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "transfer-motorcycle", "keywords": [ "motorcycle", "shipping", "delivery", "courier", "transfer" ], "content": "\n\n\n" }, { "name": "transfer-van", "keywords": [ "van", "shipping", "delivery", "transfer" ], "content": "\n\n\n" }, { "name": "warehouse-1", "keywords": [ "delivery", "warehouse", "shipping", "fulfillment" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "work_education": [ { "name": "book-reading", "keywords": [ "book", "reading", "learning" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "class-lesson", "keywords": [ "class", "lesson", "education", "teacher" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "collaborations-idea", "keywords": [ "collaborations", "idea", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "definition-search-book", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "dictionary-language-book", "keywords": [], "content": "\n\n\n" }, { "name": "global-learning", "keywords": [ "global", "learning", "education" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "graduation-cap", "keywords": [ "graduation", "cap", "education" ], "content": "\n\n\n" }, { "name": "group-meeting-call", "keywords": [ "group", "meeting", "call", "work" ], "content": "\n\n\n" }, { "name": "office-building-1", "keywords": [ "office", "building", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "office-worker", "keywords": [ "office", "worker", "human", "resources" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "search-dollar", "keywords": [ "search", "pay", "product", "currency", "query", "magnifying", "cash", "business", "money", "glass" ], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "name": "strategy-tasks", "keywords": [ "strategy", "tasks", "work" ], "content": "\n\n\n" }, { "name": "task-list", "keywords": [ "task", "list", "work" ], "content": "\n\n\n" }, { "name": "workspace-desk", "keywords": [ "workspace", "desk", "work" ], "content": "\n\n\n\n\n\n\n\n\n\n" } ] } \ No newline at end of file diff --git a/frontend/appflowy_flutter/assets/images/app_flowy_abstract_cover_1.jpg b/frontend/appflowy_flutter/assets/images/app_flowy_abstract_cover_1.jpg new file mode 100644 index 0000000000000..ebfbc367a6309 Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/app_flowy_abstract_cover_1.jpg differ diff --git a/frontend/appflowy_flutter/assets/images/app_flowy_abstract_cover_2.jpg b/frontend/appflowy_flutter/assets/images/app_flowy_abstract_cover_2.jpg new file mode 100644 index 0000000000000..aeaa6a0f29b2d Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/app_flowy_abstract_cover_2.jpg differ diff --git a/frontend/appflowy_flutter/assets/images/appearance/dark.png b/frontend/appflowy_flutter/assets/images/appearance/dark.png new file mode 100644 index 0000000000000..f40e6a884bc40 Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/appearance/dark.png differ diff --git a/frontend/appflowy_flutter/assets/images/appearance/light.png b/frontend/appflowy_flutter/assets/images/appearance/light.png new file mode 100644 index 0000000000000..49f32bf3aa4cc Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/appearance/light.png differ diff --git a/frontend/appflowy_flutter/assets/images/appearance/system.png b/frontend/appflowy_flutter/assets/images/appearance/system.png new file mode 100644 index 0000000000000..4097cae1edd32 Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/appearance/system.png differ diff --git a/frontend/app_flowy/assets/images/appflowy_launch_splash.jpg b/frontend/appflowy_flutter/assets/images/appflowy_launch_splash.jpg similarity index 100% rename from frontend/app_flowy/assets/images/appflowy_launch_splash.jpg rename to frontend/appflowy_flutter/assets/images/appflowy_launch_splash.jpg diff --git a/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_1.png b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_1.png new file mode 100644 index 0000000000000..fb720222877b6 Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_1.png differ diff --git a/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_2.png b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_2.png new file mode 100644 index 0000000000000..9ecf02d253c15 Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_2.png differ diff --git a/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_3.png b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_3.png new file mode 100644 index 0000000000000..97072b04f49d3 Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_3.png differ diff --git a/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_4.png b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_4.png new file mode 100644 index 0000000000000..00d26a05000be Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_4.png differ diff --git a/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_5.png b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_5.png new file mode 100644 index 0000000000000..3ecc9546c11ad Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_5.png differ diff --git a/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_6.png b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_6.png new file mode 100644 index 0000000000000..0abd2700e86bf Binary files /dev/null and b/frontend/appflowy_flutter/assets/images/built_in_cover_images/m_cover_image_6.png differ diff --git a/frontend/app_flowy/assets/images/emoji/1F42F.svg b/frontend/appflowy_flutter/assets/images/emoji/1F42F.svg similarity index 100% rename from frontend/app_flowy/assets/images/emoji/1F42F.svg rename to frontend/appflowy_flutter/assets/images/emoji/1F42F.svg diff --git a/frontend/app_flowy/assets/images/emoji/1F431.svg b/frontend/appflowy_flutter/assets/images/emoji/1F431.svg similarity index 100% rename from frontend/app_flowy/assets/images/emoji/1F431.svg rename to frontend/appflowy_flutter/assets/images/emoji/1F431.svg diff --git a/frontend/app_flowy/assets/images/emoji/1F435.svg b/frontend/appflowy_flutter/assets/images/emoji/1F435.svg similarity index 100% rename from frontend/app_flowy/assets/images/emoji/1F435.svg rename to frontend/appflowy_flutter/assets/images/emoji/1F435.svg diff --git a/frontend/app_flowy/assets/images/emoji/1F43A.svg b/frontend/appflowy_flutter/assets/images/emoji/1F43A.svg similarity index 100% rename from frontend/app_flowy/assets/images/emoji/1F43A.svg rename to frontend/appflowy_flutter/assets/images/emoji/1F43A.svg diff --git a/frontend/app_flowy/assets/images/emoji/1F600.svg b/frontend/appflowy_flutter/assets/images/emoji/1F600.svg similarity index 100% rename from frontend/app_flowy/assets/images/emoji/1F600.svg rename to frontend/appflowy_flutter/assets/images/emoji/1F600.svg diff --git a/frontend/app_flowy/assets/images/emoji/1F984.svg b/frontend/appflowy_flutter/assets/images/emoji/1F984.svg similarity index 100% rename from frontend/app_flowy/assets/images/emoji/1F984.svg rename to frontend/appflowy_flutter/assets/images/emoji/1F984.svg diff --git a/frontend/app_flowy/assets/images/emoji/1F9CC.svg b/frontend/appflowy_flutter/assets/images/emoji/1F9CC.svg similarity index 100% rename from frontend/app_flowy/assets/images/emoji/1F9CC.svg rename to frontend/appflowy_flutter/assets/images/emoji/1F9CC.svg diff --git a/frontend/app_flowy/assets/images/emoji/1F9DB.svg b/frontend/appflowy_flutter/assets/images/emoji/1F9DB.svg similarity index 100% rename from frontend/app_flowy/assets/images/emoji/1F9DB.svg rename to frontend/appflowy_flutter/assets/images/emoji/1F9DB.svg diff --git a/frontend/app_flowy/assets/images/emoji/1F9DD-200D-2642-FE0F.svg b/frontend/appflowy_flutter/assets/images/emoji/1F9DD-200D-2642-FE0F.svg similarity index 100% rename from frontend/app_flowy/assets/images/emoji/1F9DD-200D-2642-FE0F.svg rename to frontend/appflowy_flutter/assets/images/emoji/1F9DD-200D-2642-FE0F.svg diff --git a/frontend/app_flowy/assets/images/emoji/1F9DE-200D-2642-FE0F.svg b/frontend/appflowy_flutter/assets/images/emoji/1F9DE-200D-2642-FE0F.svg similarity index 100% rename from frontend/app_flowy/assets/images/emoji/1F9DE-200D-2642-FE0F.svg rename to frontend/appflowy_flutter/assets/images/emoji/1F9DE-200D-2642-FE0F.svg diff --git a/frontend/app_flowy/assets/images/emoji/1F9DF.svg b/frontend/appflowy_flutter/assets/images/emoji/1F9DF.svg similarity index 100% rename from frontend/app_flowy/assets/images/emoji/1F9DF.svg rename to frontend/appflowy_flutter/assets/images/emoji/1F9DF.svg diff --git a/frontend/app_flowy/assets/images/flowy_logo.svg b/frontend/appflowy_flutter/assets/images/flowy_logo.svg similarity index 100% rename from frontend/app_flowy/assets/images/flowy_logo.svg rename to frontend/appflowy_flutter/assets/images/flowy_logo.svg diff --git a/frontend/appflowy_flutter/assets/images/flowy_logo_dark_mode.svg b/frontend/appflowy_flutter/assets/images/flowy_logo_dark_mode.svg new file mode 100644 index 0000000000000..01f81375458cc --- /dev/null +++ b/frontend/appflowy_flutter/assets/images/flowy_logo_dark_mode.svg @@ -0,0 +1 @@ +Asset 16 \ No newline at end of file diff --git a/frontend/app_flowy/assets/images/flowy_logo_with_text.svg b/frontend/appflowy_flutter/assets/images/flowy_logo_with_text.svg similarity index 100% rename from frontend/app_flowy/assets/images/flowy_logo_with_text.svg rename to frontend/appflowy_flutter/assets/images/flowy_logo_with_text.svg diff --git a/frontend/appflowy_flutter/assets/images/login/discord-mark.svg b/frontend/appflowy_flutter/assets/images/login/discord-mark.svg new file mode 100644 index 0000000000000..640e06af950ce --- /dev/null +++ b/frontend/appflowy_flutter/assets/images/login/discord-mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_flutter/assets/images/login/github-light.svg b/frontend/appflowy_flutter/assets/images/login/github-light.svg new file mode 100644 index 0000000000000..5128fd0cdaa0a --- /dev/null +++ b/frontend/appflowy_flutter/assets/images/login/github-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_flutter/assets/images/login/github-mark.svg b/frontend/appflowy_flutter/assets/images/login/github-mark.svg new file mode 100644 index 0000000000000..d5e64918546d9 --- /dev/null +++ b/frontend/appflowy_flutter/assets/images/login/github-mark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/appflowy_flutter/assets/images/login/google-mark.svg b/frontend/appflowy_flutter/assets/images/login/google-mark.svg new file mode 100644 index 0000000000000..5a65dae32d43f --- /dev/null +++ b/frontend/appflowy_flutter/assets/images/login/google-mark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/appflowy_flutter/assets/images/login/language.svg b/frontend/appflowy_flutter/assets/images/login/language.svg new file mode 100644 index 0000000000000..ef3e996590e8c --- /dev/null +++ b/frontend/appflowy_flutter/assets/images/login/language.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/appflowy_flutter/assets/template/readme.afdoc b/frontend/appflowy_flutter/assets/template/readme.afdoc new file mode 100644 index 0000000000000..c5aea3838e69b --- /dev/null +++ b/frontend/appflowy_flutter/assets/template/readme.afdoc @@ -0,0 +1,254 @@ +{ + "document": { + "type": "editor", + "children": [ + { "type": "cover" }, + { + "type": "text", + "attributes": { "subtype": "heading", "heading": "h1" }, + "delta": [{ "insert": "Welcome to AppFlowy!" }] + }, + { + "type": "text", + "attributes": { "subtype": "heading", "heading": "h2" }, + "delta": [{ "insert": "Here are the basics" }] + }, + { + "type": "text", + "attributes": { "subtype": "checkbox", "checkbox": null }, + "delta": [{ "insert": "Click anywhere and just start typing." }] + }, + { + "type": "text", + "attributes": { "subtype": "checkbox", "checkbox": false }, + "delta": [ + { + "insert": "Highlight ", + "attributes": { "backgroundColor": "0x4dffeb3b" } + }, + { "insert": "any text, and use the editing menu to " }, + { "insert": "style", "attributes": { "italic": true } }, + { "insert": " " }, + { "insert": "your", "attributes": { "bold": true } }, + { "insert": " " }, + { "insert": "writing", "attributes": { "underline": true } }, + { "insert": " " }, + { "insert": "however", "attributes": { "code": true } }, + { "insert": " you " }, + { "insert": "like.", "attributes": { "strikethrough": true } } + ] + }, + { + "type": "text", + "attributes": { "subtype": "checkbox", "checkbox": null }, + "delta": [ + { "insert": "As soon as you type " }, + { + "insert": "/", + "attributes": { "code": true, "color": "0xff00b5ff" } + }, + { "insert": " a menu will pop up. Select " }, + { + "insert": "different types", + "attributes": { "backgroundColor": "0x4d9c27b0" } + }, + { "insert": " of content blocks you can add." } + ] + }, + { + "type": "text", + "attributes": { "subtype": "checkbox", "checkbox": null }, + "delta": [ + { "insert": "Type " }, + { "insert": "/", "attributes": { "code": true } }, + { "insert": " followed by " }, + { "insert": "/bullet", "attributes": { "code": true } }, + { "insert": " or " }, + { "insert": "/num", "attributes": { "code": true } }, + { "insert": " to create a list.", "attributes": { "code": false } } + ] + }, + { + "type": "text", + "attributes": { "subtype": "checkbox", "checkbox": true }, + "delta": [ + { "insert": "Click " }, + { "insert": "+ New Page ", "attributes": { "code": true } }, + { + "insert": "button at the bottom of your sidebar to add a new page." + } + ] + }, + { + "type": "text", + "attributes": { "subtype": "checkbox", "checkbox": null }, + "delta": [ + { "insert": "Click " }, + { "insert": "+", "attributes": { "code": true } }, + { "insert": " next to any page title in the sidebar to " }, + { "insert": "quickly", "attributes": { "color": "0xff8427e0" } }, + { "insert": " add a new subpage, " }, + { "insert": "Document", "attributes": { "code": true } }, + { "insert": ", ", "attributes": { "code": false } }, + { "insert": "Grid", "attributes": { "code": true } }, + { "insert": ", or ", "attributes": { "code": false } }, + { "insert": "Kanban Board", "attributes": { "code": true } }, + { "insert": ".", "attributes": { "code": false } } + ] + }, + { "type": "text", "delta": [] }, + { "type": "divider" }, + { "type": "text", "attributes": { "checkbox": null }, "delta": [] }, + { + "type": "text", + "attributes": { + "subtype": "heading", + "checkbox": null, + "heading": "h2" + }, + "delta": [{ "insert": "Keyboard shortcuts, markdown, and code block" }] + }, + { + "type": "text", + "attributes": { + "subtype": "number-list", + "number": 1, + "heading": null + }, + "delta": [ + { "insert": "Keyboard shortcuts " }, + { + "insert": "guide", + "attributes": { + "href": "https://appflowy.gitbook.io/docs/essential-documentation/shortcuts" + } + }, + { "retain": 1, "attributes": { "strikethrough": true } } + ] + }, + { + "type": "text", + "attributes": { + "subtype": "number-list", + "number": 2, + "heading": null + }, + "delta": [ + { "insert": "Markdown " }, + { + "insert": "reference", + "attributes": { + "href": "https://appflowy.gitbook.io/docs/essential-documentation/markdown" + } + }, + { "retain": 1, "attributes": { "strikethrough": true } } + ] + }, + { + "type": "text", + "attributes": { "number": 3, "subtype": "number-list" }, + "delta": [ + { "insert": "Type " }, + { "insert": "/code", "attributes": { "code": true } }, + { + "insert": " to insert a code block", + "attributes": { "code": false } + } + ] + }, + { + "type": "text", + "attributes": { + "subtype": "code_block", + "number": 3, + "heading": null, + "number-list": null, + "theme": "vs", + "language": "rust" + }, + "delta": [ + { + "insert": "// This is the main function.\nfn main() {\n // Print text to the console.\n println!(\"Hello World!\");\n}" + }, + { "retain": 1, "attributes": { "strikethrough": true } } + ] + }, + { "type": "text", "attributes": { "checkbox": null }, "delta": [] }, + { + "type": "text", + "attributes": { + "subtype": "heading", + "checkbox": null, + "heading": "h2" + }, + "delta": [{ "insert": "Have a question❓" }] + }, + { + "type": "text", + "attributes": { "subtype": "quote" }, + "delta": [ + { "insert": "Click " }, + { "insert": "?", "attributes": { "code": true } }, + { "insert": " at the bottom right for help and support." } + ] + }, + { "type": "text", "delta": [] }, + { + "type": "callout", + "children": [ + { "type": "text", "delta": [] }, + { + "type": "text", + "attributes": { "subtype": "heading", "heading": "h2" }, + "delta": [{ "insert": "Like AppFlowy? Follow us:" }] + }, + { + "type": "text", + "attributes": { "subtype": "bulleted-list" }, + "delta": [ + { + "insert": "GitHub", + "attributes": { + "href": "https://github.com/AppFlowy-IO/AppFlowy" + } + } + ] + }, + { + "type": "text", + "attributes": { "subtype": "bulleted-list" }, + "delta": [ + { + "insert": "Twitter", + "attributes": { "href": "https://twitter.com/appflowy" } + }, + { "insert": ": @appflowy" } + ] + }, + { + "type": "text", + "attributes": { "subtype": "bulleted-list" }, + "delta": [ + { + "insert": "Newsletter", + "attributes": { "href": "https://blog-appflowy.ghost.io/" } + } + ] + } + ], + "attributes": { "emoji": "😀" } + }, + { "type": "text", "delta": [] }, + { + "type": "text", + "attributes": { "subtype": null, "heading": null }, + "delta": [] + }, + { + "type": "text", + "attributes": { "subtype": null, "heading": null }, + "delta": [] + } + ] + } + } diff --git a/frontend/appflowy_flutter/assets/template/readme.json b/frontend/appflowy_flutter/assets/template/readme.json new file mode 100644 index 0000000000000..9fab2df113bf4 --- /dev/null +++ b/frontend/appflowy_flutter/assets/template/readme.json @@ -0,0 +1,254 @@ +{ + "document": { + "type": "editor", + "children": [ + { "type": "cover" }, + { + "type": "text", + "attributes": { "subtype": "heading", "heading": "h1" }, + "delta": [{ "insert": "Welcome to AppFlowy!" }] + }, + { + "type": "text", + "attributes": { "subtype": "heading", "heading": "h2" }, + "delta": [{ "insert": "Here are the basics" }] + }, + { + "type": "text", + "attributes": { "subtype": "checkbox", "checkbox": null }, + "delta": [{ "insert": "Click anywhere and just start typing." }] + }, + { + "type": "text", + "attributes": { "subtype": "checkbox", "checkbox": false }, + "delta": [ + { + "insert": "Highlight ", + "attributes": { "backgroundColor": "0x4dffeb3b" } + }, + { "insert": "any text, and use the editing menu to " }, + { "insert": "style", "attributes": { "italic": true } }, + { "insert": " " }, + { "insert": "your", "attributes": { "bold": true } }, + { "insert": " " }, + { "insert": "writing", "attributes": { "underline": true } }, + { "insert": " " }, + { "insert": "however", "attributes": { "code": true } }, + { "insert": " you " }, + { "insert": "like.", "attributes": { "strikethrough": true } } + ] + }, + { + "type": "text", + "attributes": { "subtype": "checkbox", "checkbox": null }, + "delta": [ + { "insert": "As soon as you type " }, + { + "insert": "/", + "attributes": { "code": true, "color": "0xff00b5ff" } + }, + { "insert": " a menu will pop up. Select " }, + { + "insert": "different types", + "attributes": { "backgroundColor": "0x4d9c27b0" } + }, + { "insert": " of content blocks you can add." } + ] + }, + { + "type": "text", + "attributes": { "subtype": "checkbox", "checkbox": null }, + "delta": [ + { "insert": "Type " }, + { "insert": "/", "attributes": { "code": true } }, + { "insert": " followed by " }, + { "insert": "/bullet", "attributes": { "code": true } }, + { "insert": " or " }, + { "insert": "/num", "attributes": { "code": true } }, + { "insert": " to create a list.", "attributes": { "code": false } } + ] + }, + { + "type": "text", + "attributes": { "subtype": "checkbox", "checkbox": true }, + "delta": [ + { "insert": "Click " }, + { "insert": "+ New Page", "attributes": { "code": true } }, + { + "insert": " button at the bottom of your sidebar to add a new page." + } + ] + }, + { + "type": "text", + "attributes": { "subtype": "checkbox", "checkbox": null }, + "delta": [ + { "insert": "Click " }, + { "insert": "+", "attributes": { "code": true } }, + { "insert": " next to any page title in the sidebar to " }, + { "insert": "quickly", "attributes": { "color": "0xff8427e0" } }, + { "insert": " add a new subpage, " }, + { "insert": "Document", "attributes": { "code": true } }, + { "insert": ", ", "attributes": { "code": false } }, + { "insert": "Grid", "attributes": { "code": true } }, + { "insert": ", or ", "attributes": { "code": false } }, + { "insert": "Kanban Board", "attributes": { "code": true } }, + { "insert": ".", "attributes": { "code": false } } + ] + }, + { "type": "text", "delta": [] }, + { "type": "divider" }, + { "type": "text", "attributes": { "checkbox": null }, "delta": [] }, + { + "type": "text", + "attributes": { + "subtype": "heading", + "checkbox": null, + "heading": "h2" + }, + "delta": [{ "insert": "Keyboard shortcuts, markdown, and code block" }] + }, + { + "type": "text", + "attributes": { + "subtype": "number-list", + "number": 1, + "heading": null + }, + "delta": [ + { "insert": "Keyboard shortcuts " }, + { + "insert": "guide", + "attributes": { + "href": "https://appflowy.gitbook.io/docs/essential-documentation/shortcuts" + } + }, + { "retain": 1, "attributes": { "strikethrough": true } } + ] + }, + { + "type": "text", + "attributes": { + "subtype": "number-list", + "number": 2, + "heading": null + }, + "delta": [ + { "insert": "Markdown " }, + { + "insert": "reference", + "attributes": { + "href": "https://appflowy.gitbook.io/docs/essential-documentation/markdown" + } + }, + { "retain": 1, "attributes": { "strikethrough": true } } + ] + }, + { + "type": "text", + "attributes": { "number": 3, "subtype": "number-list" }, + "delta": [ + { "insert": "Type " }, + { "insert": "/code", "attributes": { "code": true } }, + { + "insert": " to insert a code block", + "attributes": { "code": false } + } + ] + }, + { + "type": "text", + "attributes": { + "subtype": "code_block", + "number": 3, + "heading": null, + "number-list": null, + "theme": "vs", + "language": "rust" + }, + "delta": [ + { + "insert": "// This is the main function.\nfn main() {\n // Print text to the console.\n println!(\"Hello World!\");\n}" + }, + { "retain": 1, "attributes": { "strikethrough": true } } + ] + }, + { "type": "text", "attributes": { "checkbox": null }, "delta": [] }, + { + "type": "text", + "attributes": { + "subtype": "heading", + "checkbox": null, + "heading": "h2" + }, + "delta": [{ "insert": "Have a question❓" }] + }, + { + "type": "text", + "attributes": { "subtype": "quote" }, + "delta": [ + { "insert": "Click " }, + { "insert": "?", "attributes": { "code": true } }, + { "insert": " at the bottom right for help and support." } + ] + }, + { "type": "text", "delta": [] }, + { + "type": "callout", + "children": [ + { "type": "text", "delta": [] }, + { + "type": "text", + "attributes": { "subtype": "heading", "heading": "h2" }, + "delta": [{ "insert": "Like AppFlowy? Follow us:" }] + }, + { + "type": "text", + "attributes": { "subtype": "bulleted-list" }, + "delta": [ + { + "insert": "GitHub", + "attributes": { + "href": "https://github.com/AppFlowy-IO/AppFlowy" + } + } + ] + }, + { + "type": "text", + "attributes": { "subtype": "bulleted-list" }, + "delta": [ + { + "insert": "Twitter", + "attributes": { "href": "https://twitter.com/appflowy" } + }, + { "insert": ": @appflowy" } + ] + }, + { + "type": "text", + "attributes": { "subtype": "bulleted-list" }, + "delta": [ + { + "insert": "Newsletter", + "attributes": { "href": "https://blog-appflowy.ghost.io/" } + } + ] + } + ], + "attributes": { "emoji": "😀" } + }, + { "type": "text", "delta": [] }, + { + "type": "text", + "attributes": { "subtype": null, "heading": null }, + "delta": [] + }, + { + "type": "text", + "attributes": { "subtype": null, "heading": null }, + "delta": [] + } + ] + } + } diff --git a/frontend/appflowy_flutter/assets/test/images/sample.gif b/frontend/appflowy_flutter/assets/test/images/sample.gif new file mode 100644 index 0000000000000..b3b85f4a402b1 Binary files /dev/null and b/frontend/appflowy_flutter/assets/test/images/sample.gif differ diff --git a/frontend/appflowy_flutter/assets/test/images/sample.jpeg b/frontend/appflowy_flutter/assets/test/images/sample.jpeg new file mode 100644 index 0000000000000..d2f50a4148176 Binary files /dev/null and b/frontend/appflowy_flutter/assets/test/images/sample.jpeg differ diff --git a/frontend/appflowy_flutter/assets/test/images/sample.png b/frontend/appflowy_flutter/assets/test/images/sample.png new file mode 100644 index 0000000000000..84f633437f7ee Binary files /dev/null and b/frontend/appflowy_flutter/assets/test/images/sample.png differ diff --git a/frontend/appflowy_flutter/assets/test/workspaces/ai_workspace.zip b/frontend/appflowy_flutter/assets/test/workspaces/ai_workspace.zip new file mode 100644 index 0000000000000..b523b64ed045f Binary files /dev/null and b/frontend/appflowy_flutter/assets/test/workspaces/ai_workspace.zip differ diff --git a/frontend/appflowy_flutter/assets/test/workspaces/board.zip b/frontend/appflowy_flutter/assets/test/workspaces/board.zip new file mode 100644 index 0000000000000..1177d039964ba Binary files /dev/null and b/frontend/appflowy_flutter/assets/test/workspaces/board.zip differ diff --git a/frontend/appflowy_flutter/assets/test/workspaces/cover_image.zip b/frontend/appflowy_flutter/assets/test/workspaces/cover_image.zip new file mode 100644 index 0000000000000..d705090c3b893 Binary files /dev/null and b/frontend/appflowy_flutter/assets/test/workspaces/cover_image.zip differ diff --git a/frontend/appflowy_flutter/assets/test/workspaces/database/v020.afdb b/frontend/appflowy_flutter/assets/test/workspaces/database/v020.afdb new file mode 100644 index 0000000000000..04a49dead37e0 --- /dev/null +++ b/frontend/appflowy_flutter/assets/test/workspaces/database/v020.afdb @@ -0,0 +1,11 @@ +"{""id"":""2_OVWb"",""name"":""Name"",""field_type"":0,""visibility"":true,""width"":150,""type_options"":{""0"":{""data"":""""}},""is_primary"":true}","{""id"":""xjmOSi"",""name"":""Type"",""field_type"":3,""visibility"":true,""width"":150,""type_options"":{""3"":{""content"":""{\""options\"":[{\""id\"":\""t1WZ\"",\""name\"":\""s6\"",\""color\"":\""Lime\""},{\""id\"":\""GzNa\"",\""name\"":\""s5\"",\""color\"":\""Yellow\""},{\""id\"":\""l_8w\"",\""name\"":\""s4\"",\""color\"":\""Orange\""},{\""id\"":\""TzVT\"",\""name\"":\""s3\"",\""color\"":\""LightPink\""},{\""id\"":\""b5WF\"",\""name\"":\""s2\"",\""color\"":\""Pink\""},{\""id\"":\""AcHA\"",\""name\"":\""s1\"",\""color\"":\""Purple\""}],\""disable_color\"":false}""}},""is_primary"":false}","{""id"":""Hpbiwr"",""name"":""Done"",""field_type"":5,""visibility"":true,""width"":150,""type_options"":{""5"":{""is_selected"":false}},""is_primary"":false}","{""id"":""F7WLnw"",""name"":""checklist"",""field_type"":7,""visibility"":true,""width"":120,""type_options"":{""0"":{""data"":""""},""7"":{}},""is_primary"":false}","{""id"":""KABhMe"",""name"":""number"",""field_type"":1,""visibility"":true,""width"":120,""type_options"":{""1"":{""format"":0,""symbol"":""RUB"",""scale"":0,""name"":""Number""},""0"":{""scale"":0,""data"":"""",""format"":0,""name"":""Number"",""symbol"":""RUB""}},""is_primary"":false}","{""id"":""lEn6Bv"",""name"":""date"",""field_type"":2,""visibility"":true,""width"":120,""type_options"":{""2"":{""field_type"":2,""time_format"":1,""timezone_id"":"""",""date_format"":3},""0"":{""field_type"":2,""date_format"":3,""time_format"":1,""data"":"""",""timezone_id"":""""}},""is_primary"":false}","{""id"":""B8Prnx"",""name"":""url"",""field_type"":6,""visibility"":true,""width"":120,""type_options"":{""6"":{""content"":"""",""url"":""""},""0"":{""content"":"""",""data"":"""",""url"":""""}},""is_primary"":false}","{""id"":""MwUow4"",""name"":""multi-select"",""field_type"":4,""visibility"":true,""width"":240,""type_options"":{""0"":{""content"":""{\""options\"":[],\""disable_color\"":false}"",""data"":""""},""4"":{""content"":""{\""options\"":[{\""id\"":\""__Us\"",\""name\"":\""m7\"",\""color\"":\""Green\""},{\""id\"":\""n9-g\"",\""name\"":\""m6\"",\""color\"":\""Lime\""},{\""id\"":\""KFYu\"",\""name\"":\""m5\"",\""color\"":\""Yellow\""},{\""id\"":\""KftP\"",\""name\"":\""m4\"",\""color\"":\""Orange\""},{\""id\"":\""5lWo\"",\""name\"":\""m3\"",\""color\"":\""LightPink\""},{\""id\"":\""Djrz\"",\""name\"":\""m2\"",\""color\"":\""Pink\""},{\""id\"":\""2uRu\"",\""name\"":\""m1\"",\""color\"":\""Purple\""}],\""disable_color\"":false}""}},""is_primary"":false}" +"{""field_type"":0,""created_at"":1686793246,""data"":""A"",""last_modified"":1686793246}","{""last_modified"":1686793275,""created_at"":1686793261,""data"":""AcHA"",""field_type"":3}","{""created_at"":1686793241,""field_type"":5,""last_modified"":1686793241,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""pi1A\"",\""name\"":\""t1\"",\""color\"":\""Purple\""},{\""id\"":\""6Pym\"",\""name\"":\""t2\"",\""color\"":\""Purple\""},{\""id\"":\""erEe\"",\""name\"":\""t3\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""pi1A\"",\""6Pym\""]}"",""created_at"":1686793302,""field_type"":7,""last_modified"":1686793308}","{""created_at"":1686793333,""field_type"":1,""data"":""-1"",""last_modified"":1686793333}","{""last_modified"":1686793370,""field_type"":2,""data"":""1685583770"",""include_time"":false,""created_at"":1686793370}","{""created_at"":1686793395,""data"":""appflowy.io"",""field_type"":6,""last_modified"":1686793399,""url"":""https://appflowy.io""}","{""last_modified"":1686793446,""field_type"":4,""data"":""2uRu"",""created_at"":1686793428}" +"{""last_modified"":1686793247,""data"":""B"",""field_type"":0,""created_at"":1686793247}","{""created_at"":1686793278,""data"":""b5WF"",""field_type"":3,""last_modified"":1686793278}","{""created_at"":1686793292,""last_modified"":1686793292,""data"":""Yes"",""field_type"":5}","{""data"":""{\""options\"":[{\""id\"":\""YHDO\"",\""name\"":\""t1\"",\""color\"":\""Purple\""},{\""id\"":\""QjtW\"",\""name\"":\""t2\"",\""color\"":\""Purple\""},{\""id\"":\""K2nM\"",\""name\"":\""t3\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""YHDO\""]}"",""field_type"":7,""last_modified"":1686793318,""created_at"":1686793311}","{""data"":""-2"",""last_modified"":1686793335,""created_at"":1686793335,""field_type"":1}","{""field_type"":2,""data"":""1685670174"",""include_time"":false,""created_at"":1686793374,""last_modified"":1686793374}","{""last_modified"":1686793403,""field_type"":6,""created_at"":1686793399,""url"":"""",""data"":""no url""}","{""data"":""2uRu,Djrz"",""field_type"":4,""last_modified"":1686793449,""created_at"":1686793449}" +"{""data"":""C"",""created_at"":1686793248,""last_modified"":1686793248,""field_type"":0}","{""created_at"":1686793280,""field_type"":3,""data"":""TzVT"",""last_modified"":1686793280}","{""data"":""Yes"",""last_modified"":1686793292,""field_type"":5,""created_at"":1686793292}","{""last_modified"":1686793329,""field_type"":7,""created_at"":1686793322,""data"":""{\""options\"":[{\""id\"":\""iWM1\"",\""name\"":\""t1\"",\""color\"":\""Purple\""},{\""id\"":\""WDvF\"",\""name\"":\""t2\"",\""color\"":\""Purple\""},{\""id\"":\""w3k7\"",\""name\"":\""t3\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""iWM1\"",\""WDvF\"",\""w3k7\""]}""}","{""field_type"":1,""last_modified"":1686793339,""data"":""0.1"",""created_at"":1686793339}","{""last_modified"":1686793377,""data"":""1685756577"",""created_at"":1686793377,""include_time"":false,""field_type"":2}","{""created_at"":1686793403,""field_type"":6,""data"":""appflowy.io"",""last_modified"":1686793408,""url"":""https://appflowy.io""}","{""data"":""2uRu,Djrz,5lWo"",""created_at"":1686793453,""last_modified"":1686793454,""field_type"":4}" +"{""data"":""D"",""last_modified"":1686793249,""created_at"":1686793249,""field_type"":0}","{""data"":""l_8w"",""created_at"":1686793284,""last_modified"":1686793284,""field_type"":3}","{""data"":""Yes"",""created_at"":1686793293,""last_modified"":1686793293,""field_type"":5}",,"{""field_type"":1,""last_modified"":1686793341,""created_at"":1686793341,""data"":""0.2""}","{""created_at"":1686793379,""last_modified"":1686793379,""field_type"":2,""data"":""1685842979"",""include_time"":false}","{""last_modified"":1686793419,""field_type"":6,""created_at"":1686793408,""data"":""https://github.com/AppFlowy-IO/"",""url"":""https://github.com/AppFlowy-IO/""}","{""data"":""2uRu,Djrz,5lWo"",""last_modified"":1686793459,""field_type"":4,""created_at"":1686793459}" +"{""field_type"":0,""last_modified"":1686793250,""created_at"":1686793250,""data"":""E""}","{""field_type"":3,""last_modified"":1686793290,""created_at"":1686793290,""data"":""GzNa""}","{""last_modified"":1686793294,""created_at"":1686793294,""data"":""Yes"",""field_type"":5}",,"{""created_at"":1686793346,""field_type"":1,""last_modified"":1686793346,""data"":""1""}","{""last_modified"":1686793383,""data"":""1685929383"",""field_type"":2,""include_time"":false,""created_at"":1686793383}","{""field_type"":6,""url"":"""",""data"":"""",""last_modified"":1686793421,""created_at"":1686793419}","{""field_type"":4,""last_modified"":1686793465,""data"":""2uRu,Djrz,5lWo,KFYu,KftP"",""created_at"":1686793463}" +"{""field_type"":0,""created_at"":1686793251,""data"":"""",""last_modified"":1686793289}",,,,"{""data"":""2"",""field_type"":1,""created_at"":1686793347,""last_modified"":1686793347}","{""include_time"":false,""data"":""1685929385"",""last_modified"":1686793385,""field_type"":2,""created_at"":1686793385}",, +"{""created_at"":1686793254,""field_type"":0,""last_modified"":1686793288,""data"":""""}",,,,"{""created_at"":1686793351,""last_modified"":1686793351,""data"":""10"",""field_type"":1}","{""include_time"":false,""data"":""1686879792"",""field_type"":2,""created_at"":1686793392,""last_modified"":1686793392}",, +,,,,"{""last_modified"":1686793354,""created_at"":1686793354,""field_type"":1,""data"":""11""}",,, +,,,,"{""field_type"":1,""last_modified"":1686793356,""data"":""12"",""created_at"":1686793356}",,, +,,,,,,, diff --git a/frontend/appflowy_flutter/assets/test/workspaces/database/v069.afdb b/frontend/appflowy_flutter/assets/test/workspaces/database/v069.afdb new file mode 100644 index 0000000000000..9c497cff5d647 --- /dev/null +++ b/frontend/appflowy_flutter/assets/test/workspaces/database/v069.afdb @@ -0,0 +1,14 @@ +"{""id"":""RGmzka"",""name"":""Name"",""field_type"":0,""type_options"":{""0"":{""data"":""""}},""is_primary"":true}","{""id"":""oYoH-q"",""name"":""Time Slot"",""field_type"":2,""type_options"":{""0"":{""date_format"":3,""data"":"""",""time_format"":1,""timezone_id"":""""},""2"":{""date_format"":3,""time_format"":1,""timezone_id"":""""}},""is_primary"":false}","{""id"":""zVrp17"",""name"":""Amount"",""field_type"":1,""type_options"":{""1"":{""scale"":0,""format"":4,""name"":""Number"",""symbol"":""RUB""},""0"":{""data"":"""",""symbol"":""RUB"",""name"":""Number"",""format"":0,""scale"":0}},""is_primary"":false}","{""id"":""_p4EGt"",""name"":""Delta"",""field_type"":1,""type_options"":{""1"":{""name"":""Number"",""format"":36,""symbol"":""RUB"",""scale"":0},""0"":{""data"":"""",""symbol"":""RUB"",""name"":""Number"",""format"":0,""scale"":0}},""is_primary"":false}","{""id"":""Z909lc"",""name"":""Email"",""field_type"":6,""type_options"":{""6"":{""url"":"""",""content"":""""},""0"":{""data"":"""",""content"":"""",""url"":""""}},""is_primary"":false}","{""id"":""dBrSc7"",""name"":""Registration Complete"",""field_type"":5,""type_options"":{""5"":{}},""is_primary"":false}","{""id"":""VoigvK"",""name"":""Progress"",""field_type"":7,""type_options"":{""0"":{""data"":""""},""7"":{}},""is_primary"":false}","{""id"":""gbbQwh"",""name"":""Attachments"",""field_type"":14,""type_options"":{""0"":{""data"":"""",""content"":""{\""files\"":[]}""},""14"":{""content"":""{\""files\"":[]}""}},""is_primary"":false}","{""id"":""id3L0G"",""name"":""Priority"",""field_type"":3,""type_options"":{""3"":{""content"":""{\""options\"":[{\""id\"":\""cplL\"",\""name\"":\""VIP\"",\""color\"":\""Purple\""},{\""id\"":\""GSf_\"",\""name\"":\""High\"",\""color\"":\""Blue\""},{\""id\"":\""qnja\"",\""name\"":\""Medium\"",\""color\"":\""Green\""}],\""disable_color\"":false}""}},""is_primary"":false}","{""id"":""541SFC"",""name"":""Tags"",""field_type"":4,""type_options"":{""0"":{""data"":"""",""content"":""{\""options\"":[],\""disable_color\"":false}""},""4"":{""content"":""{\""options\"":[{\""id\"":\""1i4f\"",\""name\"":\""Education\"",\""color\"":\""Yellow\""},{\""id\"":\""yORP\"",\""name\"":\""Health\"",\""color\"":\""Orange\""},{\""id\"":\""SEUo\"",\""name\"":\""Hobby\"",\""color\"":\""LightPink\""},{\""id\"":\""uRAO\"",\""name\"":\""Family\"",\""color\"":\""Pink\""},{\""id\"":\""R9I7\"",\""name\"":\""Work\"",\""color\"":\""Purple\""}],\""disable_color\"":false}""}},""is_primary"":false}","{""id"":""lg0B7O"",""name"":""Last modified"",""field_type"":8,""type_options"":{""0"":{""time_format"":1,""field_type"":8,""date_format"":3,""data"":"""",""include_time"":true},""8"":{""date_format"":3,""field_type"":8,""time_format"":1,""include_time"":true}},""is_primary"":false}","{""id"":""5riGR7"",""name"":""Created at"",""field_type"":9,""type_options"":{""0"":{""field_type"":9,""include_time"":true,""date_format"":3,""time_format"":1,""data"":""""},""9"":{""include_time"":true,""field_type"":9,""date_format"":3,""time_format"":1}},""is_primary"":false}" +"{""data"":""Olaf"",""created_at"":1726063289,""last_modified"":1726063289,""field_type"":0}","{""last_modified"":1726122374,""created_at"":1726110045,""reminder_id"":"""",""is_range"":true,""include_time"":true,""end_timestamp"":""1725415200"",""field_type"":2,""data"":""1725256800""}","{""field_type"":1,""data"":""55200"",""last_modified"":1726063592,""created_at"":1726063592}","{""last_modified"":1726062441,""created_at"":1726062441,""data"":""0.5"",""field_type"":1}","{""created_at"":1726063719,""last_modified"":1726063732,""data"":""doyouwannabuildasnowman@arendelle.gov"",""field_type"":6}",,"{""field_type"":7,""last_modified"":1726064207,""data"":""{\""options\"":[{\""id\"":\""oqXQ\"",\""name\"":\""find elsa\"",\""color\"":\""Purple\""},{\""id\"":\""eQwp\"",\""name\"":\""find anna\"",\""color\"":\""Purple\""},{\""id\"":\""5-B3\"",\""name\"":\""play in the summertime\"",\""color\"":\""Purple\""},{\""id\"":\""UBFn\"",\""name\"":\""get a personal flurry\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""oqXQ\"",\""eQwp\"",\""UBFn\""]}"",""created_at"":1726064129}",,"{""created_at"":1726065208,""data"":""cplL"",""last_modified"":1726065282,""field_type"":3}","{""field_type"":4,""data"":""1i4f"",""last_modified"":1726105102,""created_at"":1726105102}","{""field_type"":8,""data"":""1726122374""}","{""data"":""1726060476"",""field_type"":9}" +"{""field_type"":0,""last_modified"":1726063323,""data"":""Beatrice"",""created_at"":1726063323}",,"{""last_modified"":1726063638,""data"":""828600"",""created_at"":1726063607,""field_type"":1}","{""field_type"":1,""created_at"":1726062488,""data"":""-2.25"",""last_modified"":1726062488}","{""last_modified"":1726063790,""data"":""btreee17@gmail.com"",""field_type"":6,""created_at"":1726063790}","{""created_at"":1726062718,""data"":""Yes"",""field_type"":5,""last_modified"":1726062724}","{""created_at"":1726064277,""data"":""{\""options\"":[{\""id\"":\""BDuH\"",\""name\"":\""get the leaf node\"",\""color\"":\""Purple\""},{\""id\"":\""GXAr\"",\""name\"":\""upgrade to b+\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""field_type"":7,""last_modified"":1726064293}",,"{""data"":""GSf_"",""created_at"":1726065288,""last_modified"":1726065288,""field_type"":3}","{""created_at"":1726105110,""data"":""yORP,uRAO"",""last_modified"":1726105111,""field_type"":4}","{""data"":""1726105111"",""field_type"":8}","{""field_type"":9,""data"":""1726060476""}" +"{""last_modified"":1726063355,""created_at"":1726063355,""field_type"":0,""data"":""Lancelot""}","{""data"":""1726468159"",""is_range"":true,""end_timestamp"":""1726727359"",""reminder_id"":"""",""include_time"":false,""field_type"":2,""created_at"":1726122403,""last_modified"":1726122559}","{""created_at"":1726063617,""last_modified"":1726063617,""data"":""22500"",""field_type"":1}","{""data"":""11.6"",""last_modified"":1726062504,""field_type"":1,""created_at"":1726062504}","{""field_type"":6,""data"":""sir.lancelot@gmail.com"",""last_modified"":1726063812,""created_at"":1726063812}","{""data"":""No"",""field_type"":5,""last_modified"":1726062724,""created_at"":1726062375}",,,"{""data"":""cplL"",""created_at"":1726065286,""last_modified"":1726065286,""field_type"":3}","{""last_modified"":1726105237,""data"":""SEUo"",""created_at"":1726105237,""field_type"":4}","{""field_type"":8,""data"":""1726122559""}","{""field_type"":9,""data"":""1726060476""}" +"{""data"":""Scotty"",""last_modified"":1726063399,""created_at"":1726063399,""field_type"":0}","{""reminder_id"":"""",""last_modified"":1726122418,""include_time"":true,""data"":""1725868800"",""end_timestamp"":""1726646400"",""created_at"":1726122381,""field_type"":2,""is_range"":true}","{""created_at"":1726063650,""last_modified"":1726063650,""data"":""10900"",""field_type"":1}","{""data"":""0"",""created_at"":1726062581,""last_modified"":1726062581,""field_type"":1}","{""last_modified"":1726063835,""created_at"":1726063835,""field_type"":6,""data"":""scottylikestosing@outlook.com""}","{""data"":""Yes"",""field_type"":5,""created_at"":1726062718,""last_modified"":1726062718}","{""created_at"":1726064309,""data"":""{\""options\"":[{\""id\"":\""Cw0K\"",\""name\"":\""vocal warmup\"",\""color\"":\""Purple\""},{\""id\"":\""nYMo\"",\""name\"":\""mixed voice training\"",\""color\"":\""Purple\""},{\""id\"":\""i-OX\"",\""name\"":\""belting training\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""Cw0K\"",\""nYMo\"",\""i-OX\""]}"",""field_type"":7,""last_modified"":1726064325}","{""last_modified"":1726122911,""created_at"":1726122835,""data"":[""{\""id\"":\""746a741d-98f8-4cc6-b807-a82d2e78c221\"",\""name\"":\""googlelogo_color_272x92dp.png\"",\""url\"":\""https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Image\""}"",""{\""id\"":\""cbbab3ee-32ab-4438-a909-3f69f935a8bd\"",\""name\"":\""tL_v571NdZ0.svg\"",\""url\"":\""https://static.xx.fbcdn.net/rsrc.php/y9/r/tL_v571NdZ0.svg\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Link\""}""],""field_type"":14}",,"{""data"":""SEUo,yORP"",""field_type"":4,""last_modified"":1726105123,""created_at"":1726105115}","{""data"":""1726122911"",""field_type"":8}","{""data"":""1726060539"",""field_type"":9}" +"{""field_type"":0,""created_at"":1726063405,""last_modified"":1726063421,""data"":""""}",,,"{""last_modified"":1726062625,""field_type"":1,""data"":"""",""created_at"":1726062607}",,"{""data"":""No"",""last_modified"":1726062702,""created_at"":1726062393,""field_type"":5}",,,,,"{""data"":""1726063421"",""field_type"":8}","{""data"":""1726060539"",""field_type"":9}" +"{""field_type"":0,""data"":""Thomas"",""last_modified"":1726063421,""created_at"":1726063421}","{""reminder_id"":"""",""field_type"":2,""data"":""1725627600"",""is_range"":false,""created_at"":1726122583,""last_modified"":1726122593,""end_timestamp"":"""",""include_time"":true}","{""last_modified"":1726063666,""field_type"":1,""data"":""465800"",""created_at"":1726063666}","{""last_modified"":1726062516,""field_type"":1,""created_at"":1726062516,""data"":""-0.03""}","{""field_type"":6,""last_modified"":1726063848,""created_at"":1726063848,""data"":""tfp3827@gmail.com""}","{""field_type"":5,""last_modified"":1726062725,""data"":""Yes"",""created_at"":1726062376}","{""created_at"":1726064344,""data"":""{\""options\"":[{\""id\"":\""D6X8\"",\""name\"":\""brainstorm\"",\""color\"":\""Purple\""},{\""id\"":\""XVN9\"",\""name\"":\""schedule\"",\""color\"":\""Purple\""},{\""id\"":\""nJx8\"",\""name\"":\""shoot\"",\""color\"":\""Purple\""},{\""id\"":\""7Mrm\"",\""name\"":\""edit\"",\""color\"":\""Purple\""},{\""id\"":\""o6vg\"",\""name\"":\""publish\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""D6X8\""]}"",""last_modified"":1726064379,""field_type"":7}",,"{""last_modified"":1726065298,""created_at"":1726065298,""field_type"":3,""data"":""GSf_""}","{""data"":""yORP,SEUo"",""field_type"":4,""last_modified"":1726105229,""created_at"":1726105229}","{""data"":""1726122593"",""field_type"":8}","{""field_type"":9,""data"":""1726060540""}" +"{""data"":""Juan"",""last_modified"":1726063423,""created_at"":1726063423,""field_type"":0}","{""created_at"":1726122510,""reminder_id"":"""",""include_time"":false,""is_range"":true,""last_modified"":1726122515,""data"":""1725604115"",""end_timestamp"":""1725776915"",""field_type"":2}","{""field_type"":1,""created_at"":1726063677,""last_modified"":1726063677,""data"":""93100""}","{""field_type"":1,""data"":""4.86"",""created_at"":1726062597,""last_modified"":1726062597}",,"{""last_modified"":1726062377,""field_type"":5,""data"":""Yes"",""created_at"":1726062377}","{""last_modified"":1726064412,""field_type"":7,""data"":""{\""options\"":[{\""id\"":\""tTDq\"",\""name\"":\""complete onboarding\"",\""color\"":\""Purple\""},{\""id\"":\""E8Ds\"",\""name\"":\""contact support\"",\""color\"":\""Purple\""},{\""id\"":\""RoGN\"",\""name\"":\""get started\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""tTDq\"",\""E8Ds\""]}"",""created_at"":1726064396}",,"{""created_at"":1726065278,""field_type"":3,""data"":""qnja"",""last_modified"":1726065278}","{""data"":""R9I7,yORP,1i4f"",""field_type"":4,""created_at"":1726105126,""last_modified"":1726105127}","{""data"":""1726122515"",""field_type"":8}","{""data"":""1726060541"",""field_type"":9}" +"{""data"":""Alex"",""created_at"":1726063432,""last_modified"":1726063432,""field_type"":0}","{""reminder_id"":"""",""data"":""1725292800"",""include_time"":true,""last_modified"":1726122448,""created_at"":1726122422,""is_range"":true,""end_timestamp"":""1725551940"",""field_type"":2}","{""field_type"":1,""last_modified"":1726063683,""created_at"":1726063683,""data"":""3560""}","{""created_at"":1726062561,""data"":""1.96"",""last_modified"":1726062561,""field_type"":1}","{""last_modified"":1726063952,""created_at"":1726063931,""data"":""al3x1343@protonmail.com"",""field_type"":6}","{""last_modified"":1726062375,""field_type"":5,""created_at"":1726062375,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""qNyr\"",\""name\"":\""finish reading book\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""created_at"":1726064616,""last_modified"":1726064616,""field_type"":7}",,"{""data"":""qnja"",""created_at"":1726065272,""last_modified"":1726065272,""field_type"":3}","{""created_at"":1726105180,""last_modified"":1726105180,""field_type"":4,""data"":""R9I7,1i4f""}","{""field_type"":8,""data"":""1726122448""}","{""field_type"":9,""data"":""1726060541""}" +"{""last_modified"":1726063478,""created_at"":1726063436,""field_type"":0,""data"":""Alexander""}",,"{""field_type"":1,""last_modified"":1726063691,""created_at"":1726063691,""data"":""2073""}","{""field_type"":1,""data"":""0.5"",""last_modified"":1726062577,""created_at"":1726062577}","{""last_modified"":1726063991,""field_type"":6,""created_at"":1726063991,""data"":""alexandernotthedra@gmail.com""}","{""field_type"":5,""last_modified"":1726062378,""created_at"":1726062377,""data"":""No""}",,,"{""created_at"":1726065291,""data"":""GSf_"",""last_modified"":1726065291,""field_type"":3}","{""last_modified"":1726105142,""created_at"":1726105133,""data"":""SEUo"",""field_type"":4}","{""field_type"":8,""data"":""1726105142""}","{""field_type"":9,""data"":""1726060542""}" +"{""field_type"":0,""created_at"":1726063454,""last_modified"":1726063454,""data"":""George""}","{""created_at"":1726122467,""end_timestamp"":""1726468070"",""include_time"":false,""is_range"":true,""reminder_id"":"""",""field_type"":2,""data"":""1726295270"",""last_modified"":1726122470}",,,"{""field_type"":6,""data"":""george.aq@appflowy.io"",""last_modified"":1726064104,""created_at"":1726064016}","{""last_modified"":1726062376,""created_at"":1726062376,""field_type"":5,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""s_dQ\"",\""name\"":\""bug triage\"",\""color\"":\""Purple\""},{\""id\"":\""-Zfo\"",\""name\"":\""fix bugs\"",\""color\"":\""Purple\""},{\""id\"":\""wsDN\"",\""name\"":\""attend meetings\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""s_dQ\"",\""-Zfo\""]}"",""last_modified"":1726064468,""created_at"":1726064424,""field_type"":7}","{""data"":[""{\""id\"":\""8a77f84d-64e9-4e67-b902-fa23980459ec\"",\""name\"":\""BQdTmxpRI6f.png\"",\""url\"":\""https://static.cdninstagram.com/rsrc.php/v3/ym/r/BQdTmxpRI6f.png\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Image\""}""],""field_type"":14,""created_at"":1726122956,""last_modified"":1726122956}","{""field_type"":3,""data"":""qnja"",""created_at"":1726065313,""last_modified"":1726065313}","{""data"":""R9I7,yORP"",""field_type"":4,""last_modified"":1726105198,""created_at"":1726105187}","{""data"":""1726122956"",""field_type"":8}","{""data"":""1726060543"",""field_type"":9}" +"{""field_type"":0,""last_modified"":1726063467,""data"":""Joanna"",""created_at"":1726063467}","{""include_time"":false,""end_timestamp"":""1727072893"",""is_range"":true,""last_modified"":1726122493,""created_at"":1726122483,""data"":""1726554493"",""field_type"":2,""reminder_id"":""""}","{""last_modified"":1726065463,""data"":""16470"",""field_type"":1,""created_at"":1726065463}","{""created_at"":1726062626,""field_type"":1,""last_modified"":1726062626,""data"":""-5.36""}","{""last_modified"":1726064069,""data"":""joannastrawberry29+hello@gmail.com"",""created_at"":1726064069,""field_type"":6}",,"{""field_type"":7,""created_at"":1726064444,""last_modified"":1726064460,""data"":""{\""options\"":[{\""id\"":\""ZxJz\"",\""name\"":\""post on Twitter\"",\""color\"":\""Purple\""},{\""id\"":\""upwi\"",\""name\"":\""watch Youtube videos\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""upwi\""]}""}",,"{""created_at"":1726065317,""last_modified"":1726065317,""field_type"":3,""data"":""qnja""}","{""field_type"":4,""last_modified"":1726105173,""data"":""uRAO,yORP"",""created_at"":1726105170}","{""data"":""1726122493"",""field_type"":8}","{""data"":""1726060545"",""field_type"":9}" +"{""last_modified"":1726063457,""created_at"":1726063457,""data"":""George"",""field_type"":0}","{""include_time"":true,""reminder_id"":"""",""field_type"":2,""is_range"":true,""created_at"":1726122521,""end_timestamp"":""1725829200"",""data"":""1725822900"",""last_modified"":1726122535}","{""last_modified"":1726065493,""field_type"":1,""data"":""9500"",""created_at"":1726065493}","{""last_modified"":1726062680,""created_at"":1726062680,""field_type"":1,""data"":""1.7""}","{""data"":""plgeorgebball@gmail.com"",""field_type"":6,""last_modified"":1726064087,""created_at"":1726064036}",,"{""last_modified"":1726064513,""data"":""{\""options\"":[{\""id\"":\""zy0x\"",\""name\"":\""game vs celtics\"",\""color\"":\""Purple\""},{\""id\"":\""WJsv\"",\""name\"":\""training\"",\""color\"":\""Purple\""},{\""id\"":\""w-f8\"",\""name\"":\""game vs spurs\"",\""color\"":\""Purple\""},{\""id\"":\""p1VQ\"",\""name\"":\""game vs knicks\"",\""color\"":\""Purple\""},{\""id\"":\""VjUA\"",\""name\"":\""recovery\"",\""color\"":\""Purple\""},{\""id\"":\""sQ8X\"",\""name\"":\""don't get injured\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""created_at"":1726064486,""field_type"":7}",,"{""field_type"":3,""last_modified"":1726065310,""data"":""qnja"",""created_at"":1726065310}","{""created_at"":1726105205,""field_type"":4,""last_modified"":1726105249,""data"":""R9I7,1i4f,yORP,SEUo""}","{""data"":""1726122535"",""field_type"":8}","{""field_type"":9,""data"":""1726060546""}" +"{""data"":""Judy"",""created_at"":1726063475,""field_type"":0,""last_modified"":1726063487}","{""end_timestamp"":"""",""reminder_id"":"""",""data"":""1726640950"",""field_type"":2,""include_time"":false,""created_at"":1726122550,""last_modified"":1726122550,""is_range"":false}",,,"{""created_at"":1726063882,""field_type"":6,""last_modified"":1726064000,""data"":""judysmithjr@outlook.com""}","{""last_modified"":1726062712,""field_type"":5,""data"":""Yes"",""created_at"":1726062712}","{""created_at"":1726064549,""field_type"":7,""data"":""{\""options\"":[{\""id\"":\""j8cC\"",\""name\"":\""finish training\"",\""color\"":\""Purple\""},{\""id\"":\""SmSk\"",\""name\"":\""brainwash\"",\""color\"":\""Purple\""},{\""id\"":\""mnf5\"",\""name\"":\""welcome to ba sing se\"",\""color\"":\""Purple\""},{\""id\"":\""hcrj\"",\""name\"":\""don't mess up\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""j8cC\"",\""SmSk\"",\""mnf5\"",\""hcrj\""]}"",""last_modified"":1726064591}",,,"{""field_type"":4,""last_modified"":1726105152,""created_at"":1726105152,""data"":""R9I7""}","{""field_type"":8,""data"":""1726122550""}","{""field_type"":9,""data"":""1726060549""}" diff --git a/frontend/appflowy_flutter/assets/test/workspaces/empty_document.zip b/frontend/appflowy_flutter/assets/test/workspaces/empty_document.zip new file mode 100644 index 0000000000000..ec70e5dcb39fb Binary files /dev/null and b/frontend/appflowy_flutter/assets/test/workspaces/empty_document.zip differ diff --git a/frontend/appflowy_flutter/assets/test/workspaces/markdowns/markdown_with_table.md b/frontend/appflowy_flutter/assets/test/workspaces/markdowns/markdown_with_table.md new file mode 100644 index 0000000000000..5998220774a1f --- /dev/null +++ b/frontend/appflowy_flutter/assets/test/workspaces/markdowns/markdown_with_table.md @@ -0,0 +1,11 @@ +# AppFlowy Test Markdown import with table + +# Table + +| S.No. | Column 2 | +| --- | --- | +| 1. | row 1 | +| 2. | row 2 | +| 3. | row 3 | +| 4. | row 4 | +| 5. | row 5 | \ No newline at end of file diff --git a/frontend/appflowy_flutter/assets/test/workspaces/markdowns/test1.md b/frontend/appflowy_flutter/assets/test/workspaces/markdowns/test1.md new file mode 100644 index 0000000000000..47d854f6efda4 --- /dev/null +++ b/frontend/appflowy_flutter/assets/test/workspaces/markdowns/test1.md @@ -0,0 +1,22 @@ +# Welcome to AppFlowy! + +## Here are the basics + +- [ ] Click anywhere and just start typing. +- [ ] Highlight any text, and use the editing menu to _style_ **your** writing `however` you ~~like.~~ +- [ ] As soon as you type /a menu will pop up. Select different types of content blocks you can add. +- [ ] Type `/` followed by `/bullet` or `/num` to create a list. +- [x] Click `+ New Page `button at the bottom of your sidebar to add a new page. +- [ ] Click `+` next to any page title in the sidebar to quickly add a new subpage, `Document`, `Grid`, or `Kanban Board`. + +--- + +## Keyboard shortcuts, markdown, and code block + +1. Keyboard shortcuts [guide](https://appflowy.gitbook.io/docs/essential-documentation/shortcuts) +1. Markdown [reference](https://appflowy.gitbook.io/docs/essential-documentation/markdown) +1. Type `/code` to insert a code block + +## Have a question❓ + +> Click `?` at the bottom right for help and support. diff --git a/frontend/appflowy_flutter/assets/test/workspaces/markdowns/test2.md b/frontend/appflowy_flutter/assets/test/workspaces/markdowns/test2.md new file mode 100644 index 0000000000000..83c831f0b085c --- /dev/null +++ b/frontend/appflowy_flutter/assets/test/workspaces/markdowns/test2.md @@ -0,0 +1 @@ +# test diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/grid_header_listener.dart b/frontend/appflowy_flutter/build.yaml similarity index 100% rename from frontend/app_flowy/lib/plugins/grid/application/field/grid_header_listener.dart rename to frontend/appflowy_flutter/build.yaml diff --git a/frontend/appflowy_flutter/cargokit_options.yaml b/frontend/appflowy_flutter/cargokit_options.yaml new file mode 100644 index 0000000000000..70d10df337ddd --- /dev/null +++ b/frontend/appflowy_flutter/cargokit_options.yaml @@ -0,0 +1 @@ +use_precompiled_binaries: true diff --git a/frontend/appflowy_flutter/dart_dependency_validator.yaml b/frontend/appflowy_flutter/dart_dependency_validator.yaml new file mode 100644 index 0000000000000..cb1df68bb60c2 --- /dev/null +++ b/frontend/appflowy_flutter/dart_dependency_validator.yaml @@ -0,0 +1,12 @@ +# dart_dependency_validator.yaml + +allow_pins: true + +include: + - "lib/**" + +exclude: + - "packages/**" + +ignore: + - analyzer diff --git a/frontend/appflowy_flutter/dev.env b/frontend/appflowy_flutter/dev.env new file mode 100644 index 0000000000000..6463736cc9bcb --- /dev/null +++ b/frontend/appflowy_flutter/dev.env @@ -0,0 +1 @@ +APPFLOWY_CLOUD_URL= \ No newline at end of file diff --git a/frontend/appflowy_flutter/devtools_options.yaml b/frontend/appflowy_flutter/devtools_options.yaml new file mode 100644 index 0000000000000..7e7e7f67dee6f --- /dev/null +++ b/frontend/appflowy_flutter/devtools_options.yaml @@ -0,0 +1 @@ +extensions: diff --git a/frontend/appflowy_flutter/integration_test/desktop/board/board_add_row_test.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_add_row_test.dart new file mode 100644 index 0000000000000..84db6a5be00b4 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_add_row_test.dart @@ -0,0 +1,113 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; +import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; +import 'package:appflowy/plugins/database/widgets/card/card.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +const defaultFirstCardName = 'Card 1'; +const defaultLastCardName = 'Card 3'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('board add row test:', () { + testWidgets('from header', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + + final firstCard = find.byType(RowCard).first; + + expect( + find.descendant( + of: firstCard, + matching: find.text(defaultFirstCardName), + ), + findsOneWidget, + ); + + await tester.tap( + find + .descendant( + of: find.byType(BoardColumnHeader), + matching: find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.add_s, + ), + ) + .at(1), + ); + await tester.pumpAndSettle(); + + const newCardName = 'Card 4'; + await tester.enterText( + find.descendant( + of: firstCard, + matching: find.byType(TextField), + ), + newCardName, + ); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + await tester.tap(find.byType(AppFlowyBoard)); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(RowCard).first, + matching: find.text(newCardName), + ), + findsOneWidget, + ); + }); + + testWidgets('from footer', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + + final lastCard = find.byType(RowCard).last; + + expect( + find.descendant( + of: lastCard, + matching: find.text(defaultLastCardName), + ), + findsOneWidget, + ); + + await tester.tapButton( + find.byType(BoardColumnFooter).at(1), + ); + + const newCardName = 'Card 4'; + await tester.enterText( + find.descendant( + of: find.byType(BoardColumnFooter), + matching: find.byType(TextField), + ), + newCardName, + ); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + await tester.tap(find.byType(AppFlowyBoard)); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(RowCard).last, + matching: find.text(newCardName), + ), + findsOneWidget, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/board/board_field_test.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_field_test.dart new file mode 100644 index 0000000000000..bdd0ecdb2c71b --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_field_test.dart @@ -0,0 +1,38 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('board field test', () { + testWidgets('change field type whithin card #5360', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + const name = 'Card 1'; + final card1 = find.text(name); + await tester.tapButton(card1); + + const fieldName = "test change field"; + await tester.createField( + FieldType.RichText, + name: fieldName, + layout: ViewLayoutPB.Board, + ); + await tester.dismissRowDetailPage(); + await tester.tapButton(card1); + await tester.changeFieldTypeOfFieldWithName( + fieldName, + FieldType.Checkbox, + layout: ViewLayoutPB.Board, + ); + await tester.hoverOnWidget(find.text('Card 2')); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/board/board_group_test.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_group_test.dart new file mode 100644 index 0000000000000..3eedbdb3bfe8e --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_group_test.dart @@ -0,0 +1,154 @@ +import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; +import 'package:appflowy/plugins/database/widgets/card/card.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/select/select_option_editor.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_property.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('board group test:', () { + testWidgets('move row to another group', (tester) async { + const card1Name = 'Card 1'; + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + final card1 = find.ancestor( + of: find.text(card1Name), + matching: find.byType(RowCard), + ); + final doingGroup = find.text('Doing'); + final doingGroupCenter = tester.getCenter(doingGroup); + final card1Center = tester.getCenter(card1); + + await tester.timedDrag( + card1, + doingGroupCenter.translate(-card1Center.dx, -card1Center.dy), + const Duration(seconds: 1), + ); + await tester.pumpAndSettle(); + await tester.tap(card1); + await tester.pumpAndSettle(); + + final card1StatusFinder = find.descendant( + of: find.byType(RowPropertyList), + matching: find.descendant( + of: find.byType(SelectOptionTag), + matching: find.byType(Text), + ), + ); + expect(card1StatusFinder, findsNWidgets(1)); + final card1StatusText = tester.widget(card1StatusFinder).data; + expect(card1StatusText, 'Doing'); + }); + + testWidgets('rename group', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + + final headers = find.byType(BoardColumnHeader); + expect(headers, findsNWidgets(4)); + + // try to tap no status + final noStatus = headers.first; + expect( + find.descendant(of: noStatus, matching: find.text("No Status")), + findsOneWidget, + ); + await tester.tapButton(noStatus); + expect( + find.descendant(of: noStatus, matching: find.byType(TextField)), + findsNothing, + ); + + // tap on To Do and edit it + final todo = headers.at(1); + expect( + find.descendant(of: todo, matching: find.text("To Do")), + findsOneWidget, + ); + await tester.tapButton(todo); + await tester.enterText( + find.descendant(of: todo, matching: find.byType(TextField)), + "tada", + ); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + final newHeaders = find.byType(BoardColumnHeader); + expect(newHeaders, findsNWidgets(4)); + final tada = find.byType(BoardColumnHeader).at(1); + expect( + find.descendant(of: tada, matching: find.byType(TextField)), + findsNothing, + ); + expect( + find.descendant( + of: tada, + matching: find.text("tada"), + ), + findsOneWidget, + ); + }); + + testWidgets('edit select option from row detail', (tester) async { + const card1Name = 'Card 1'; + + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + + await tester.tapButton( + find.descendant( + of: find.byType(RowCard), + matching: find.text(card1Name), + ), + ); + + await tester.tapGridFieldWithNameInRowDetailPage("Status"); + await tester.tapButton( + find.byWidgetPredicate( + (widget) => + widget is SelectOptionTagCell && widget.option.name == "To Do", + ), + ); + final editor = find.byType(SelectOptionEditor); + await tester.enterText( + find.descendant(of: editor, matching: find.byType(TextField)), + "tada", + ); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + await tester.dismissFieldEditor(); + await tester.dismissRowDetailPage(); + + final newHeaders = find.byType(BoardColumnHeader); + expect(newHeaders, findsNWidgets(4)); + final tada = find.byType(BoardColumnHeader).at(1); + expect( + find.descendant(of: tada, matching: find.byType(TextField)), + findsNothing, + ); + expect( + find.descendant( + of: tada, + matching: find.text("tada"), + ), + findsOneWidget, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart new file mode 100644 index 0000000000000..15da47f0f14da --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_hide_groups_test.dart @@ -0,0 +1,122 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; +import 'package:appflowy/plugins/database/board/presentation/widgets/board_hidden_groups.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('board group options:', () { + testWidgets('expand/collapse hidden groups', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + + final collapseFinder = find.byFlowySvg(FlowySvgs.pull_left_outlined_s); + final expandFinder = find.byFlowySvg(FlowySvgs.hamburger_s_s); + + // Is expanded by default + expect(collapseFinder, findsOneWidget); + expect(expandFinder, findsNothing); + + // Collapse hidden groups + await tester.tap(collapseFinder); + await tester.pumpAndSettle(); + + // Is collapsed + expect(collapseFinder, findsNothing); + expect(expandFinder, findsOneWidget); + + // Expand hidden groups + await tester.tap(expandFinder); + await tester.pumpAndSettle(); + + // Is expanded + expect(collapseFinder, findsOneWidget); + expect(expandFinder, findsNothing); + }); + + testWidgets('hide first group, and show it again', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + + // Tap the options of the first group + final optionsFinder = find + .descendant( + of: find.byType(BoardColumnHeader), + matching: find.byFlowySvg(FlowySvgs.details_horizontal_s), + ) + .first; + + await tester.tap(optionsFinder); + await tester.pumpAndSettle(); + + // Tap the hide option + await tester.tap(find.byFlowySvg(FlowySvgs.hide_s)); + await tester.pumpAndSettle(); + + int shownGroups = + tester.widgetList(find.byType(BoardColumnHeader)).length; + + // We still show Doing, Done, No Status + expect(shownGroups, 3); + + final hiddenCardFinder = find.byType(HiddenGroupCard); + await tester.hoverOnWidget(hiddenCardFinder); + await tester.tap(find.byFlowySvg(FlowySvgs.show_m)); + await tester.pumpAndSettle(); + + shownGroups = tester.widgetList(find.byType(BoardColumnHeader)).length; + expect(shownGroups, 4); + }); + + testWidgets('delete a group', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + + expect(tester.widgetList(find.byType(BoardColumnHeader)).length, 4); + + // tap group option button for the first group. Delete shouldn't show up + await tester.tapButton( + find + .descendant( + of: find.byType(BoardColumnHeader), + matching: find.byFlowySvg(FlowySvgs.details_horizontal_s), + ) + .first, + ); + expect(find.byFlowySvg(FlowySvgs.delete_s), findsNothing); + + // dismiss the popup + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + // tap group option button for the first group. Delete should show up + await tester.tapButton( + find + .descendant( + of: find.byType(BoardColumnHeader), + matching: find.byFlowySvg(FlowySvgs.details_horizontal_s), + ) + .at(1), + ); + expect(find.byFlowySvg(FlowySvgs.delete_s), findsOneWidget); + + // Tap the delete button and confirm + await tester.tapButton(find.byFlowySvg(FlowySvgs.delete_s)); + await tester.tapButtonWithName(LocaleKeys.space_delete.tr()); + + // Expect number of groups to decrease by one + expect(tester.widgetList(find.byType(BoardColumnHeader)).length, 3); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/board/board_row_test.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_row_test.dart new file mode 100644 index 0000000000000..868c27d302a94 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_row_test.dart @@ -0,0 +1,162 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/card/card.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:time/time.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('board row test', () { + testWidgets('edit item in ToDo card', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + const name = 'Card 1'; + final card1 = find.ancestor( + matching: find.byType(RowCard), + of: find.text(name), + ); + await tester.hoverOnWidget( + card1, + onHover: () async { + final editCard = find.byType(EditCardAccessory); + await tester.tapButton(editCard); + }, + ); + await tester.showKeyboard(card1); + tester.testTextInput.enterText(""); + await tester.pump(300.milliseconds); + tester.testTextInput.enterText("a"); + await tester.pump(300.milliseconds); + expect(find.text('a'), findsOneWidget); + }); + + testWidgets('delete item in ToDo card', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + const name = 'Card 1'; + final card1 = find.text(name); + await tester.hoverOnWidget( + card1, + onHover: () async { + final moreOption = find.byType(MoreCardOptionsAccessory); + await tester.tapButton(moreOption); + }, + ); + await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); + await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); + expect(find.text(name), findsNothing); + }); + + testWidgets('duplicate item in ToDo card', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + const name = 'Card 1'; + final card1 = find.text(name); + await tester.hoverOnWidget( + card1, + onHover: () async { + final moreOption = find.byType(MoreCardOptionsAccessory); + await tester.tapButton(moreOption); + }, + ); + await tester.tapButtonWithName(LocaleKeys.button_duplicate.tr()); + expect(find.textContaining(name, findRichText: true), findsNWidgets(2)); + }); + + testWidgets('duplicate item in ToDo card then delete', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + const name = 'Card 1'; + final card1 = find.text(name); + await tester.hoverOnWidget( + card1, + onHover: () async { + final moreOption = find.byType(MoreCardOptionsAccessory); + await tester.tapButton(moreOption); + }, + ); + await tester.tapButtonWithName(LocaleKeys.button_duplicate.tr()); + expect(find.textContaining(name, findRichText: true), findsNWidgets(2)); + + // get the last widget that contains the name + final duplicatedCard = find.textContaining(name, findRichText: true).last; + await tester.hoverOnWidget( + duplicatedCard, + onHover: () async { + final moreOption = find.byType(MoreCardOptionsAccessory); + await tester.tapButton(moreOption); + }, + ); + await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); + await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); + expect(find.textContaining(name, findRichText: true), findsNWidgets(1)); + }); + + testWidgets('add new group', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + + // assert number of groups + tester.assertNumberOfGroups(4); + + // scroll the board horizontally to ensure add new group button appears + await tester.scrollBoardToEnd(); + + // assert and click on add new group button + tester.assertNewGroupTextField(false); + await tester.tapNewGroupButton(); + tester.assertNewGroupTextField(true); + + // enter new group name and submit + await tester.enterNewGroupName('needs design', submit: true); + + // assert number of groups has increased + tester.assertNumberOfGroups(5); + + // assert text field has disappeared + await tester.scrollBoardToEnd(); + tester.assertNewGroupTextField(false); + + // click on add new group button + await tester.tapNewGroupButton(); + tester.assertNewGroupTextField(true); + + // type some things + await tester.enterNewGroupName('needs planning', submit: false); + + // click on clear button and assert empty contents + await tester.clearNewGroupTextField(); + + // press escape to cancel + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + tester.assertNewGroupTextField(false); + + // click on add new group button + await tester.tapNewGroupButton(); + tester.assertNewGroupTextField(true); + + // press elsewhere to cancel + await tester.tap(find.byType(AppFlowyBoard)); + await tester.pumpAndSettle(); + tester.assertNewGroupTextField(false); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/board/board_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/board/board_test_runner.dart new file mode 100644 index 0000000000000..75323a1c80652 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/board/board_test_runner.dart @@ -0,0 +1,18 @@ +import 'package:integration_test/integration_test.dart'; + +import 'board_add_row_test.dart' as board_add_row_test; +import 'board_group_test.dart' as board_group_test; +import 'board_row_test.dart' as board_row_test; +import 'board_field_test.dart' as board_field_test; +import 'board_hide_groups_test.dart' as board_hide_groups_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Board integration tests + board_row_test.main(); + board_add_row_test.main(); + board_group_test.main(); + board_field_test.main(); + board_hide_groups_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/cloud_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/cloud_runner.dart new file mode 100644 index 0000000000000..0b35cffe51eac --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/cloud_runner.dart @@ -0,0 +1,29 @@ +import 'data_migration/data_migration_test_runner.dart' + as data_migration_test_runner; +import 'document/document_test_runner.dart' as document_test_runner; +import 'set_env.dart' as preset_af_cloud_env_test; +import 'sidebar/sidebar_move_page_test.dart' as sidebar_move_page_test; +import 'sidebar/sidebar_rename_untitled_test.dart' + as sidebar_rename_untitled_test; +import 'uncategorized/uncategorized_test_runner.dart' + as uncategorized_test_runner; +import 'workspace/workspace_test_runner.dart' as workspace_test_runner; + +Future main() async { + preset_af_cloud_env_test.main(); + + data_migration_test_runner.main(); + + // uncategorized + uncategorized_test_runner.main(); + + // workspace + workspace_test_runner.main(); + + // document + document_test_runner.main(); + + // sidebar + sidebar_move_page_test.main(); + sidebar_rename_untitled_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart new file mode 100644 index 0000000000000..06c27093f9ecd --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/anon_user_data_migration_test.dart @@ -0,0 +1,57 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('appflowy cloud', () { + testWidgets('anon user -> sign in -> open imported space', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + + await tester.tapContinousAnotherWay(); + await tester.tapAnonymousSignInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const pageName = 'Test Document'; + await tester.createNewPageWithNameUnderParent(name: pageName); + tester.expectToSeePageName(pageName); + + // rename the name of the anon user + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.account); + await tester.pumpAndSettle(); + + await tester.enterUserName('local_user'); + + // Scroll to sign-in + await tester.scrollUntilVisible( + find.byType(AccountSignInOutButton), + 100, + scrollable: find.findSettingsScrollable(), + ); + + await tester.tapButton(find.byType(AccountSignInOutButton)); + + // sign up with Google + await tester.tapGoogleLoginInButton(); + // await tester.pumpAndSettle(const Duration(seconds: 16)); + + // open the imported space + await tester.expectToSeeHomePage(); + await tester.clickSpaceHeader(); + + // After import the anon user data, we will create a new space for it + await tester.openSpace("Getting started"); + await tester.openPage(pageName); + + await tester.pumpAndSettle(); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/data_migration_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/data_migration_test_runner.dart new file mode 100644 index 0000000000000..a69c0480ce7df --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/data_migration/data_migration_test_runner.dart @@ -0,0 +1,5 @@ +import 'anon_user_data_migration_test.dart' as anon_user_test; + +void main() async { + anon_user_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_ai_writer_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_ai_writer_test.dart new file mode 100644 index 0000000000000..32737a23d16da --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_ai_writer_test.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/constants.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('AI Writer:', () { + testWidgets('the ai writer transaction should only apply in memory', + (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const pageName = 'Document'; + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + pageName: pageName, + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_aiWriter.tr(), + ); + expect(find.byType(AIWriterBlockComponent), findsOneWidget); + + // switch to another page + await tester.openPage(Constants.gettingStartedPageName); + // switch back to the page + await tester.openPage(pageName); + + // expect the ai writer block is not in the document + expect(find.byType(AIWriterBlockComponent), findsNothing); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_copy_link_to_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_copy_link_to_block_test.dart new file mode 100644 index 0000000000000..24106cf99aa5c --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_copy_link_to_block_test.dart @@ -0,0 +1,275 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/document_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/constants.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // copy link to block + group('copy link to block:', () { + testWidgets('copy link to check if the clipboard has the correct content', + (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // open getting started page + await tester.openPage(Constants.gettingStartedPageName); + await tester.editor.copyLinkToBlock([0]); + await tester.pumpAndSettle(Durations.short1); + + // check the clipboard + final content = await Clipboard.getData(Clipboard.kTextPlain); + expect( + content?.text, + matches(appflowySharePageLinkPattern), + ); + }); + + testWidgets('copy link to block(another page) and paste it in doc', + (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // open getting started page + await tester.openPage(Constants.gettingStartedPageName); + await tester.editor.copyLinkToBlock([0]); + + // create a new page and paste it + const pageName = 'copy link to block'; + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + pageName: pageName, + ); + + // paste the link to the new page + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.paste(); + await tester.pumpAndSettle(); + + // check the content of the block + final node = tester.editor.getNodeAtPath([0]); + final delta = node.delta!; + final insert = (delta.first as TextInsert).text; + final attributes = delta.first.attributes; + expect(insert, MentionBlockKeys.mentionChar); + final mention = + attributes?[MentionBlockKeys.mention] as Map; + expect(mention[MentionBlockKeys.type], MentionType.page.name); + expect(mention[MentionBlockKeys.blockId], isNotNull); + expect(mention[MentionBlockKeys.pageId], isNotNull); + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.textContaining( + Constants.gettingStartedPageName, + findRichText: true, + ), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.textContaining( + // the pasted block content is 'Welcome to AppFlowy' + 'Welcome to AppFlowy', + findRichText: true, + ), + ), + findsOneWidget, + ); + + // tap the mention block to jump to the page + await tester.tapButton(find.byType(MentionPageBlock)); + await tester.pumpAndSettle(); + + // expect to go to the getting started page + final documentPage = find.byType(DocumentPage); + expect(documentPage, findsOneWidget); + expect( + tester.widget(documentPage).view.name, + Constants.gettingStartedPageName, + ); + // and the block is selected + expect( + tester.widget(documentPage).initialBlockId, + mention[MentionBlockKeys.blockId], + ); + expect( + tester.editor.getCurrentEditorState().selection, + Selection.collapsed( + Position( + path: [0], + ), + ), + ); + }); + + testWidgets('copy link to block(same page) and paste it in doc', + (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // create a new page and paste it + const pageName = 'copy link to block'; + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + pageName: pageName, + ); + + // copy the link to block from the first line + const inputText = 'Hello World'; + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText(inputText); + await tester.ime.insertCharacter('\n'); + await tester.pumpAndSettle(); + await tester.editor.copyLinkToBlock([0]); + + // paste the link to the second line + await tester.editor.tapLineOfEditorAt(1); + await tester.editor.paste(); + await tester.pumpAndSettle(); + + // check the content of the block + final node = tester.editor.getNodeAtPath([1]); + final delta = node.delta!; + final insert = (delta.first as TextInsert).text; + final attributes = delta.first.attributes; + expect(insert, MentionBlockKeys.mentionChar); + final mention = + attributes?[MentionBlockKeys.mention] as Map; + expect(mention[MentionBlockKeys.type], MentionType.page.name); + expect(mention[MentionBlockKeys.blockId], isNotNull); + expect(mention[MentionBlockKeys.pageId], isNotNull); + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.textContaining( + inputText, + findRichText: true, + ), + ), + findsNWidgets(2), + ); + + // edit the pasted block + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText('!'); + await tester.pumpAndSettle(); + + // check the content of the block + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.textContaining( + '$inputText!', + findRichText: true, + ), + ), + findsNWidgets(2), + ); + + // tap the mention block + await tester.tapButton(find.byType(MentionPageBlock)); + expect( + tester.editor.getCurrentEditorState().selection, + Selection.collapsed( + Position( + path: [0], + ), + ), + ); + }); + + testWidgets('''1. copy link to block from another page + 2. paste the link to the new page + 3. delete the original page + 4. check the content of the block, it should be no access to the page + ''', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // open getting started page + await tester.openPage(Constants.gettingStartedPageName); + await tester.editor.copyLinkToBlock([0]); + + // create a new page and paste it + const pageName = 'copy link to block'; + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + pageName: pageName, + ); + + // paste the link to the new page + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.paste(); + await tester.pumpAndSettle(); + + // tap the mention block to jump to the page + await tester.tapButton(find.byType(MentionPageBlock)); + await tester.pumpAndSettle(); + + // expect to go to the getting started page + final documentPage = find.byType(DocumentPage); + expect(documentPage, findsOneWidget); + expect( + tester.widget(documentPage).view.name, + Constants.gettingStartedPageName, + ); + // delete the getting started page + await tester.hoverOnPageName( + Constants.gettingStartedPageName, + onHover: () async => tester.tapDeletePageButton(), + ); + tester.expectToSeeDocumentBanner(); + tester.expectNotToSeePageName(gettingStarted); + + // delete the page permanently + await tester.tapDeletePermanentlyButton(); + + // go back the page + await tester.openPage(pageName); + await tester.pumpAndSettle(); + + // check the content of the block + // it should be no access to the page + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.findTextInFlowyText( + LocaleKeys.document_mention_noAccess.tr(), + ), + ), + findsOneWidget, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_option_actions_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_option_actions_test.dart new file mode 100644 index 0000000000000..0289fbe1768ba --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_option_actions_test.dart @@ -0,0 +1,114 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/constants.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document option actions:', () { + testWidgets('drag block to the top', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // open getting started page + await tester.openPage(Constants.gettingStartedPageName); + + // before move + final beforeMoveBlock = tester.editor.getNodeAtPath([1]); + + // move the desktop guide to the top, above the getting started + await tester.editor.dragBlock( + [1], + const Offset(20, -80), + ); + + // wait for the move animation to complete + await tester.pumpAndSettle(Durations.short1); + + // check if the block is moved to the top + final afterMoveBlock = tester.editor.getNodeAtPath([0]); + expect(afterMoveBlock.delta, beforeMoveBlock.delta); + }); + + testWidgets('drag block to other block\'s child', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // open getting started page + await tester.openPage(Constants.gettingStartedPageName); + + // before move + final beforeMoveBlock = tester.editor.getNodeAtPath([10]); + + // move the checkbox to the child of the block at path [9] + await tester.editor.dragBlock( + [10], + const Offset(80, -30), + ); + + // wait for the move animation to complete + await tester.pumpAndSettle(Durations.short1); + + // check if the block is moved to the child of the block at path [9] + final afterMoveBlock = tester.editor.getNodeAtPath([9, 0]); + expect(afterMoveBlock.delta, beforeMoveBlock.delta); + }); + + testWidgets('hover on the block and delete it', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // open getting started page + await tester.openPage(Constants.gettingStartedPageName); + + // before delete + final path = [1]; + final beforeDeletedBlock = tester.editor.getNodeAtPath(path); + + // hover on the block and delete it + final optionButton = find.byWidgetPredicate( + (widget) => + widget is DraggableOptionButton && + widget.blockComponentContext.node.path.equals(path), + ); + + await tester.hoverOnWidget( + optionButton, + onHover: () async { + // click the delete button + await tester.tapButton(optionButton); + }, + ); + await tester.pumpAndSettle(Durations.short1); + + // click the delete button + final deleteButton = + find.findTextInFlowyText(LocaleKeys.button_delete.tr()); + await tester.tapButton(deleteButton); + + // wait for the deletion + await tester.pumpAndSettle(Durations.short1); + + // check if the block is deleted + final afterDeletedBlock = tester.editor.getNodeAtPath([1]); + expect(afterDeletedBlock.id, isNot(equals(beforeDeletedBlock.id))); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_publish_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_publish_test.dart new file mode 100644 index 0000000000000..7877143116ede --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_publish_test.dart @@ -0,0 +1,220 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/shared/share/publish_tab.dart'; +import 'package:appflowy/plugins/shared/share/share_menu.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/constants.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Publish:', () { + testWidgets('publish document', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const pageName = 'Document'; + + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + pageName: pageName, + ); + + // open the publish menu + await tester.openPublishMenu(); + + // publish the document + final publishButton = find.byType(PublishButton); + final unpublishButton = find.byType(UnPublishButton); + await tester.tapButton(publishButton); + + // expect to see unpublish, visit site and manage all sites button + expect(unpublishButton, findsOneWidget); + expect(find.text(LocaleKeys.shareAction_visitSite.tr()), findsOneWidget); + + // unpublish the document + await tester.tapButton(unpublishButton); + + // expect to see publish button + expect(publishButton, findsOneWidget); + }); + + testWidgets('rename path name', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const pageName = 'Document'; + + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + pageName: pageName, + ); + + // open the publish menu + await tester.openPublishMenu(); + + // publish the document + final publishButton = find.byType(PublishButton); + await tester.tapButton(publishButton); + + // rename the path name + final inputField = find.descendant( + of: find.byType(ShareMenu), + matching: find.byType(TextField), + ); + + // rename with invalid name + await tester.tap(inputField); + await tester.enterText(inputField, '&&&&????'); + await tester.tapButton(find.text(LocaleKeys.button_save.tr())); + await tester.pumpAndSettle(); + + // expect to see the toast with error message + final errorToast1 = find.text( + LocaleKeys.settings_sites_error_publishNameContainsInvalidCharacters + .tr(), + ); + await tester.pumpUntilFound(errorToast1); + await tester.pumpUntilNotFound(errorToast1); + + // rename with long name + await tester.tap(inputField); + await tester.enterText(inputField, 'long-path-name' * 200); + await tester.tapButton(find.text(LocaleKeys.button_save.tr())); + await tester.pumpAndSettle(); + + // expect to see the toast with error message + final errorToast2 = find.text( + LocaleKeys.settings_sites_error_publishNameTooLong.tr(), + ); + await tester.pumpUntilFound(errorToast2); + await tester.pumpUntilNotFound(errorToast2); + + // rename with empty name + await tester.tap(inputField); + await tester.enterText(inputField, ''); + await tester.tapButton(find.text(LocaleKeys.button_save.tr())); + await tester.pumpAndSettle(); + + // expect to see the toast with error message + final errorToast3 = find.text( + LocaleKeys.settings_sites_error_publishNameCannotBeEmpty.tr(), + ); + await tester.pumpUntilFound(errorToast3); + await tester.pumpUntilNotFound(errorToast3); + + // input the new path name + await tester.tap(inputField); + await tester.enterText(inputField, 'new-path-name'); + // click save button + await tester.tapButton(find.text(LocaleKeys.button_save.tr())); + await tester.pumpAndSettle(); + + // expect to see the toast with success message + final successToast = find.text( + LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), + ); + await tester.pumpUntilFound(successToast); + await tester.pumpUntilNotFound(successToast); + + // click the copy link button + await tester.tapButton( + find.byWidgetPredicate( + (widget) => + widget is FlowySvg && + widget.svg.path == FlowySvgs.m_toolbar_link_m.path, + ), + ); + await tester.pumpAndSettle(); + // check the clipboard has the link + final content = await Clipboard.getData(Clipboard.kTextPlain); + expect( + content?.text?.contains('new-path-name'), + isTrue, + ); + }); + + testWidgets('re-publish the document', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const pageName = 'Document'; + + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + pageName: pageName, + ); + + // open the publish menu + await tester.openPublishMenu(); + + // publish the document + final publishButton = find.byType(PublishButton); + await tester.tapButton(publishButton); + + // rename the path name + final inputField = find.descendant( + of: find.byType(ShareMenu), + matching: find.byType(TextField), + ); + + // input the new path name + const newName = 'new-path-name'; + await tester.enterText(inputField, newName); + // click save button + await tester.tapButton(find.text(LocaleKeys.button_save.tr())); + await tester.pumpAndSettle(); + + // expect to see the toast with success message + final successToast = find.text( + LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), + ); + await tester.pumpUntilNotFound(successToast); + + // unpublish the document + final unpublishButton = find.byType(UnPublishButton); + await tester.tapButton(unpublishButton); + + final unpublishSuccessToast = find.text( + LocaleKeys.publish_unpublishSuccessfully.tr(), + ); + await tester.pumpUntilNotFound(unpublishSuccessToast); + + // re-publish the document + await tester.tapButton(publishButton); + + // expect to see the toast with success message + final rePublishSuccessToast = find.text( + LocaleKeys.publish_publishSuccessfully.tr(), + ); + await tester.pumpUntilNotFound(rePublishSuccessToast); + + // check the clipboard has the link + final content = await Clipboard.getData(Clipboard.kTextPlain); + expect( + content?.text?.contains(newName), + isTrue, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_test_runner.dart new file mode 100644 index 0000000000000..58a9d7398b852 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/document/document_test_runner.dart @@ -0,0 +1,16 @@ +import 'package:integration_test/integration_test.dart'; + +import 'document_ai_writer_test.dart' as document_ai_writer_test; +import 'document_copy_link_to_block_test.dart' + as document_copy_link_to_block_test; +import 'document_option_actions_test.dart' as document_option_actions_test; +import 'document_publish_test.dart' as document_publish_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + document_option_actions_test.main(); + document_copy_link_to_block_test.main(); + document_publish_test.main(); + document_ai_writer_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/set_env.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/set_env.dart new file mode 100644 index 0000000000000..b24c0faf2754a --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/set_env.dart @@ -0,0 +1,18 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +// This test is meaningless, just for preventing the CI from failing. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Empty', () { + testWidgets('set appflowy cloud', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/sidebar/sidebar_move_page_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/sidebar/sidebar_move_page_test.dart new file mode 100644 index 0000000000000..37abd19ebc751 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/sidebar/sidebar_move_page_test.dart @@ -0,0 +1,108 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../../shared/constants.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('sidebar move page: ', () { + testWidgets('create a new document and move it to Getting started', + (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const pageName = 'Document'; + + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + pageName: pageName, + ); + + // click the ... button and move to Getting started + await tester.hoverOnPageName( + pageName, + onHover: () async { + await tester.tapPageOptionButton(); + await tester.tapButtonWithName( + LocaleKeys.disclosureAction_moveTo.tr(), + ); + }, + ); + + // expect to see two pages + // one is in the sidebar, the other is in the move to page list + // 1. Getting started + // 2. To-dos + final gettingStarted = find.findTextInFlowyText( + Constants.gettingStartedPageName, + ); + final toDos = find.findTextInFlowyText(Constants.toDosPageName); + await tester.pumpUntilFound(gettingStarted); + await tester.pumpUntilFound(toDos); + expect(gettingStarted, findsNWidgets(2)); + + // skip the length check on Linux temporarily, + // because it failed in expect check but the previous pumpUntilFound is successful + if (!UniversalPlatform.isLinux) { + expect(toDos, findsNWidgets(2)); + + // hover on the todos page, and will see a forbidden icon + await tester.hoverOnWidget( + toDos.last, + onHover: () async { + final tooltips = find.byTooltip( + LocaleKeys.space_cannotMovePageToDatabase.tr(), + ); + expect(tooltips, findsOneWidget); + }, + ); + await tester.pumpAndSettle(); + } + + // Attempt right-click on the page name and expect not to see + await tester.tap(gettingStarted.last, buttons: kSecondaryButton); + await tester.pumpAndSettle(); + expect( + find.text(LocaleKeys.disclosureAction_moveTo.tr()), + findsOneWidget, + ); + + // move the current page to Getting started + await tester.tapButton( + gettingStarted.last, + ); + + await tester.pumpAndSettle(); + + // after moving, expect to not see the page name in the sidebar + final page = tester.findPageName(pageName); + expect(page, findsNothing); + + // click to expand the getting started page + await tester.expandOrCollapsePage( + pageName: Constants.gettingStartedPageName, + layout: ViewLayoutPB.Document, + ); + await tester.pumpAndSettle(); + + // expect to see the page name in the getting started page + final pageInGettingStarted = tester.findPageName( + pageName, + parentName: Constants.gettingStartedPageName, + ); + expect(pageInGettingStarted, findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/sidebar/sidebar_rename_untitled_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/sidebar/sidebar_rename_untitled_test.dart new file mode 100644 index 0000000000000..8226b68b265d0 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/sidebar/sidebar_rename_untitled_test.dart @@ -0,0 +1,55 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text_input.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/constants.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Rename empty name view (untitled)', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + ); + + // click the ... button and open rename dialog + await tester.hoverOnPageName( + ViewLayoutPB.Document.defaultName, + onHover: () async { + await tester.tapPageOptionButton(); + await tester.tapButtonWithName( + LocaleKeys.disclosureAction_rename.tr(), + ); + }, + ); + await tester.pumpAndSettle(); + + expect(find.byType(NavigatorTextFieldDialog), findsOneWidget); + + final textField = tester.widget( + find.descendant( + of: find.byType(NavigatorTextFieldDialog), + matching: find.byType(FlowyFormTextInput), + ), + ); + + expect( + textField.controller!.text, + LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + ); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/appflowy_cloud_auth_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/appflowy_cloud_auth_test.dart new file mode 100644 index 0000000000000..0b649fd6d50d3 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/appflowy_cloud_auth_test.dart @@ -0,0 +1,97 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('appflowy cloud auth', () { + testWidgets('sign in', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + }); + + testWidgets('sign out', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + + // Open the setting page and sign out + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.account); + + // Scroll to sign-out + await tester.scrollUntilVisible( + find.byType(AccountSignInOutButton), + 100, + scrollable: find.findSettingsScrollable(), + ); + await tester.tapButton(find.byType(AccountSignInOutButton)); + + tester.expectToSeeText(LocaleKeys.button_ok.tr()); + await tester.tapButtonWithName(LocaleKeys.button_ok.tr()); + + // Go to the sign in page again + await tester.pumpAndSettle(const Duration(seconds: 5)); + tester.expectToSeeGoogleLoginButton(); + }); + + testWidgets('sign in as anonymous', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapSignInAsGuest(); + + // should not see the sync setting page when sign in as anonymous + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.account); + + // Scroll to sign-in + await tester.scrollUntilVisible( + find.byType(AccountSignInOutButton), + 100, + scrollable: find.findSettingsScrollable(), + ); + await tester.tapButton(find.byType(AccountSignInOutButton)); + + tester.expectToSeeGoogleLoginButton(); + }); + + testWidgets('enable sync', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + + await tester.tapGoogleLoginInButton(); + // Open the setting page and sign out + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.cloud); + await tester.pumpAndSettle(); + + // the switch should be on by default + tester.assertAppFlowyCloudEnableSyncSwitchValue(true); + await tester.toggleEnableSync(AppFlowyCloudEnableSync); + // wait for the switch animation + await tester.wait(250); + + // the switch should be off + tester.assertAppFlowyCloudEnableSyncSwitchValue(false); + + // the switch should be on after toggling + await tester.toggleEnableSync(AppFlowyCloudEnableSync); + tester.assertAppFlowyCloudEnableSyncSwitchValue(true); + await tester.wait(250); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/document_sync_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/document_sync_test.dart new file mode 100644 index 0000000000000..b6b4ecf0257f9 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/document_sync_test.dart @@ -0,0 +1,55 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final email = '${uuid()}@appflowy.io'; + const inputContent = 'Hello world, this is a test document'; + +// The test will create a new document called Sample, and sync it to the server. +// Then the test will logout the user, and login with the same user. The data will +// be synced from the server. + group('appflowy cloud document', () { + testWidgets('sync local docuemnt to server', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + email: email, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // create a new document called Sample + await tester.createNewPage(); + + // focus on the editor + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText(inputContent); + expect(find.text(inputContent, findRichText: true), findsOneWidget); + + // 6 seconds for data sync + await tester.waitForSeconds(6); + + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.account); + await tester.logout(); + }); + + testWidgets('sync doc from server', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + email: email, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePage(); + + // the latest document will be opened, so the content must be the inputContent + await tester.pumpAndSettle(); + expect(find.text(inputContent, findRichText: true), findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/uncategorized_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/uncategorized_test_runner.dart new file mode 100644 index 0000000000000..278d880965ae5 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/uncategorized_test_runner.dart @@ -0,0 +1,7 @@ +import 'appflowy_cloud_auth_test.dart' as appflowy_cloud_auth_test; +import 'user_setting_sync_test.dart' as user_sync_test; + +void main() async { + appflowy_cloud_auth_test.main(); + user_sync_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/user_setting_sync_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/user_setting_sync_test.dart new file mode 100644 index 0000000000000..e666289bf5960 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/uncategorized/user_setting_sync_test.dart @@ -0,0 +1,52 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account_user_profile.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final email = '${uuid()}@appflowy.io'; + const name = 'nathan'; + + group('appflowy cloud setting', () { + testWidgets('sync user name and icon to server', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + email: email, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.account); + + await tester.enterUserName(name); + await tester.pumpAndSettle(const Duration(seconds: 6)); + await tester.logout(); + + await tester.pumpAndSettle(const Duration(seconds: 2)); + }); + }); + testWidgets('get user icon and name from server', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + email: email, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + await tester.pumpAndSettle(); + + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.account); + + // Verify name + final profileSetting = + tester.widget(find.byType(AccountUserProfile)) as AccountUserProfile; + + expect(profileSetting.name, name); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/change_name_and_icon_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/change_name_and_icon_test.dart new file mode 100644 index 0000000000000..f205b35354c8e --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/change_name_and_icon_test.dart @@ -0,0 +1,75 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/util.dart'; +import '../../../shared/workspace.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const icon = '😄'; + const name = 'AppFlowy'; + final email = '${uuid()}@appflowy.io'; + + testWidgets('change name and icon', (tester) async { + // only run the test when the feature flag is on + if (!FeatureFlag.collaborativeWorkspace.isOn) { + return; + } + + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + email: email, // use the same email to check the next test + ); + + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + var workspaceIcon = tester.widget( + find.byType(WorkspaceIcon), + ); + expect(workspaceIcon.workspace.icon, ''); + + await tester.openWorkspaceMenu(); + await tester.changeWorkspaceIcon(icon); + await tester.changeWorkspaceName(name); + + await tester.pumpUntilNotFound( + find.text(LocaleKeys.workspace_renameSuccess.tr()), + ); + + workspaceIcon = tester.widget( + find.byType(WorkspaceIcon), + ); + expect(workspaceIcon.workspace.icon, icon); + expect(find.findTextInFlowyText(name), findsOneWidget); + }); + + testWidgets('verify the result again after relaunching', (tester) async { + // only run the test when the feature flag is on + if (!FeatureFlag.collaborativeWorkspace.isOn) { + return; + } + + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + email: email, // use the same email to check the next test + ); + + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // check the result again + final workspaceIcon = tester.widget( + find.byType(WorkspaceIcon), + ); + expect(workspaceIcon.workspace.icon, icon); + expect(workspaceIcon.workspace.name, name); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/collaborative_workspace_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/collaborative_workspace_test.dart new file mode 100644 index 0000000000000..2de6fb8fa7c08 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/collaborative_workspace_test.dart @@ -0,0 +1,152 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('collaborative workspace:', () { + // combine the create and delete workspace test to reduce the time + testWidgets('create a new workspace, open it and then delete it', + (tester) async { + // only run the test when the feature flag is on + if (!FeatureFlag.collaborativeWorkspace.isOn) { + return; + } + + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const name = 'AppFlowy.IO'; + // the workspace will be opened after created + await tester.createCollaborativeWorkspace(name); + + final loading = find.byType(Loading); + await tester.pumpUntilNotFound(loading); + + Finder success; + + final Finder items = find.byType(WorkspaceMenuItem); + + // delete the newly created workspace + await tester.openCollaborativeWorkspaceMenu(); + await tester.pumpUntilFound(items); + + expect(items, findsNWidgets(2)); + expect( + tester.widget(items.last).workspace.name, + name, + ); + + final secondWorkspace = find.byType(WorkspaceMenuItem).last; + await tester.hoverOnWidget( + secondWorkspace, + onHover: () async { + // click the more button + final moreButton = find.byType(WorkspaceMoreActionList); + expect(moreButton, findsOneWidget); + await tester.tapButton(moreButton); + // click the delete button + final deleteButton = find.text(LocaleKeys.button_delete.tr()); + expect(deleteButton, findsOneWidget); + await tester.tapButton(deleteButton); + // see the delete confirm dialog + final confirm = + find.text(LocaleKeys.workspace_deleteWorkspaceHintText.tr()); + expect(confirm, findsOneWidget); + await tester.tapButton(find.text(LocaleKeys.button_ok.tr())); + // delete success + success = find.text(LocaleKeys.workspace_createSuccess.tr()); + await tester.pumpUntilFound(success); + expect(success, findsOneWidget); + await tester.pumpUntilNotFound(success); + }, + ); + }); + + testWidgets('check the member count immediately after creating a workspace', + (tester) async { + // only run the test when the feature flag is on + if (!FeatureFlag.collaborativeWorkspace.isOn) { + return; + } + + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const name = 'AppFlowy.IO'; + // the workspace will be opened after created + await tester.createCollaborativeWorkspace(name); + + final loading = find.byType(Loading); + await tester.pumpUntilNotFound(loading); + + await tester.openCollaborativeWorkspaceMenu(); + + // expect to see the member count + final memberCount = find.text('1 member'); + expect(memberCount, findsNWidgets(2)); + }); + + testWidgets('only display one menu item in the workspace menu', + (tester) async { + // only run the test when the feature flag is on + if (!FeatureFlag.collaborativeWorkspace.isOn) { + return; + } + + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const name = 'AppFlowy.IO'; + // the workspace will be opened after created + await tester.createCollaborativeWorkspace(name); + + final loading = find.byType(Loading); + await tester.pumpUntilNotFound(loading); + + await tester.openCollaborativeWorkspaceMenu(); + + // hover on the workspace and click the more button + final workspaceItem = find.byWidgetPredicate( + (w) => w is WorkspaceMenuItem && w.workspace.name == name, + ); + await tester.hoverOnWidget( + workspaceItem, + onHover: () async { + final moreButton = find.byWidgetPredicate( + (w) => w is WorkspaceMoreActionList && w.workspace.name == name, + ); + expect(moreButton, findsOneWidget); + await tester.tapButton(moreButton); + + // click it again + await tester.tapButton(moreButton); + + // nothing should happen + expect( + find.text(LocaleKeys.button_rename.tr()), + findsOneWidget, + ); + }, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/share_menu_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/share_menu_test.dart new file mode 100644 index 0000000000000..c2dc3783866ce --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/share_menu_test.dart @@ -0,0 +1,65 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/plugins/shared/share/share_menu.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/constants.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Share menu:', () { + testWidgets('share tab', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const pageName = 'Document'; + + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + pageName: pageName, + ); + + // click the share button + await tester.tapShareButton(); + + // expect the share menu is shown + final shareMenu = find.byType(ShareMenu); + expect(shareMenu, findsOneWidget); + + // click the copy link button + final copyLinkButton = find.textContaining( + LocaleKeys.button_copyLink.tr(), + ); + await tester.tapButton(copyLinkButton); + + // read the clipboard content + final clipboardContent = await getIt().getData(); + final plainText = clipboardContent.plainText; + expect( + plainText, + matches(appflowySharePageLinkPattern), + ); + + final shareValues = plainText! + .replaceAll('https://${ShareConstants.shareBaseUrl}/', '') + .split('/'); + final workspaceId = shareValues[0]; + expect(workspaceId, isNotEmpty); + final pageId = shareValues[1]; + expect(pageId, isNotEmpty); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/tabs_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/tabs_test.dart new file mode 100644 index 0000000000000..6661075878d98 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/tabs_test.dart @@ -0,0 +1,75 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; +import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart'; +import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/constants.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Tabs', () { + testWidgets('close other tabs before opening a new workspace', + (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const name = 'AppFlowy.IO'; + // the workspace will be opened after created + await tester.createCollaborativeWorkspace(name); + + final loading = find.byType(Loading); + await tester.pumpUntilNotFound(loading); + + // create new tabs in the workspace + expect(find.byType(FlowyTab), findsNothing); + + const documentOneName = 'document one'; + const documentTwoName = 'document two'; + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + pageName: documentOneName, + ); + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + pageName: documentTwoName, + ); + + /// Open second menu item in a new tab + await tester.openAppInNewTab(documentOneName, ViewLayoutPB.Document); + + /// Open third menu item in a new tab + await tester.openAppInNewTab(documentTwoName, ViewLayoutPB.Document); + + expect( + find.descendant( + of: find.byType(TabsManager), + matching: find.byType(FlowyTab), + ), + findsNWidgets(2), + ); + + // switch to the another workspace + final Finder items = find.byType(WorkspaceMenuItem); + await tester.openCollaborativeWorkspaceMenu(); + await tester.pumpUntilFound(items); + expect(items, findsNWidgets(2)); + + // open the first workspace + await tester.tap(items.first); + await tester.pumpUntilNotFound(loading); + + expect(find.byType(FlowyTab), findsNothing); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/workspace_icon_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/workspace_icon_test.dart new file mode 100644 index 0000000000000..e9ad06caee99b --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/workspace_icon_test.dart @@ -0,0 +1,44 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/util.dart'; +import '../../../shared/workspace.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('workspace icon:', () { + testWidgets('remove icon from workspace', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + await tester.openWorkspaceMenu(); + + // click the workspace icon + await tester.tapButton( + find.descendant( + of: find.byType(WorkspaceMenuItem), + matching: find.byType(WorkspaceIcon), + ), + ); + // click the remove icon button + await tester.tapButton( + find.text(LocaleKeys.button_remove.tr()), + ); + + // nothing should happen + expect( + find.text(LocaleKeys.workspace_updateIconSuccess.tr()), + findsNothing, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/workspace_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/workspace_settings_test.dart new file mode 100644 index 0000000000000..8b081c04c6b1b --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/workspace_settings_test.dart @@ -0,0 +1,345 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/plugins/shared/share/publish_tab.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_more_action.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_item.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_more_action.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/constants.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('workspace settings: ', () { + testWidgets( + 'change document width', + (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.workspace); + + final documentWidthSettings = find.findTextInFlowyText( + LocaleKeys.settings_appearance_documentSettings_width.tr(), + ); + + final scrollable = find.ancestor( + of: find.byType(SettingsWorkspaceView), + matching: find.descendant( + of: find.byType(SingleChildScrollView), + matching: find.byType(Scrollable), + ), + ); + + await tester.scrollUntilVisible( + documentWidthSettings, + 0, + scrollable: scrollable, + ); + await tester.pumpAndSettle(); + + // change the document width + final slider = find.byType(Slider); + final oldValue = tester.widget(slider).value; + await tester.drag(slider, const Offset(-100, 0)); + await tester.pumpAndSettle(); + + // check the document width is changed + expect(tester.widget(slider).value, lessThan(oldValue)); + + // click the reset button + final resetButton = find.descendant( + of: find.byType(DocumentPaddingSetting), + matching: find.byType(SettingsResetButton), + ); + await tester.tap(resetButton); + await tester.pumpAndSettle(); + + // check the document width is reset + expect( + tester.widget(slider).value, + EditorStyleCustomizer.maxDocumentWidth, + ); + }, + ); + }); + + group('sites settings:', () { + testWidgets( + 'manage published page, set it as homepage, remove the homepage', + (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const pageName = 'Document'; + + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + pageName: pageName, + ); + + // open the publish menu + await tester.openPublishMenu(); + + // publish the document + await tester.tapButton(find.byType(PublishButton)); + + // click empty area to close the publish menu + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + // check if the page is published in sites page + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.sites); + // wait the backend return the sites data + await tester.wait(1000); + + // check if the page is published in sites page + final pageItem = find.byWidgetPredicate( + (widget) => + widget is PublishedViewItem && + widget.publishInfoView.view.name == pageName, + ); + expect(pageItem, findsOneWidget); + + // comment it out because it's not allowed to update the namespace in free plan + // // set it to homepage + // await tester.tapButton( + // find.textContaining( + // LocaleKeys.settings_sites_selectHomePage.tr(), + // ), + // ); + // await tester.tapButton( + // find.descendant( + // of: find.byType(SelectHomePageMenu), + // matching: find.text(pageName), + // ), + // ); + // await tester.pumpAndSettle(); + + // // check if the page is set to homepage + // final homePageItem = find.descendant( + // of: find.byType(DomainItem), + // matching: find.text(pageName), + // ); + // expect(homePageItem, findsOneWidget); + + // // remove the homepage + // await tester.tapButton(find.byType(DomainMoreAction)); + // await tester.tapButton( + // find.text(LocaleKeys.settings_sites_removeHomepage.tr()), + // ); + // await tester.pumpAndSettle(); + + // // check if the page is removed from homepage + // expect(homePageItem, findsNothing); + }); + + testWidgets('update namespace', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // check if the page is published in sites page + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.sites); + // wait the backend return the sites data + await tester.wait(1000); + + // update the domain + final domainMoreAction = find.byType(DomainMoreAction); + await tester.tapButton(domainMoreAction); + final updateNamespaceButton = find.text( + LocaleKeys.settings_sites_updateNamespace.tr(), + ); + await tester.pumpUntilFound(updateNamespaceButton); + + // click the update namespace button + + await tester.tapButton(updateNamespaceButton); + + // comment it out because it's not allowed to update the namespace in free plan + // expect to see the dialog + // await tester.updateNamespace('&&&???'); + + // // need to upgrade to pro plan to update the namespace + // final errorToast = find.text( + // LocaleKeys.settings_sites_error_proPlanLimitation.tr(), + // ); + // await tester.pumpUntilFound(errorToast); + // expect(errorToast, findsOneWidget); + // await tester.pumpUntilNotFound(errorToast); + + // comment it out because it's not allowed to update the namespace in free plan + // // short namespace + // await tester.updateNamespace('a'); + + // // expect to see the toast with error message + // final errorToast2 = find.text( + // LocaleKeys.settings_sites_error_namespaceTooShort.tr(), + // ); + // await tester.pumpUntilFound(errorToast2); + // expect(errorToast2, findsOneWidget); + // await tester.pumpUntilNotFound(errorToast2); + // // valid namespace + // await tester.updateNamespace('AppFlowy'); + + // // expect to see the toast with success message + // final successToast = find.text( + // LocaleKeys.settings_sites_success_namespaceUpdated.tr(), + // ); + // await tester.pumpUntilFound(successToast); + // expect(successToast, findsOneWidget); + }); + + testWidgets(''' +More actions for published page: +1. visit site +2. copy link +3. settings +4. unpublish +5. custom url +''', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + const pageName = 'Document'; + + await tester.createNewPageInSpace( + spaceName: Constants.generalSpaceName, + layout: ViewLayoutPB.Document, + pageName: pageName, + ); + + // open the publish menu + await tester.openPublishMenu(); + + // publish the document + await tester.tapButton(find.byType(PublishButton)); + + // click empty area to close the publish menu + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + // check if the page is published in sites page + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.sites); + // wait the backend return the sites data + await tester.wait(1000); + + // check if the page is published in sites page + final pageItem = find.byWidgetPredicate( + (widget) => + widget is PublishedViewItem && + widget.publishInfoView.view.name == pageName, + ); + expect(pageItem, findsOneWidget); + + final copyLinkItem = find.text(LocaleKeys.shareAction_copyLink.tr()); + final customUrlItem = find.text(LocaleKeys.settings_sites_customUrl.tr()); + final unpublishItem = find.text(LocaleKeys.shareAction_unPublish.tr()); + + // custom url + final publishMoreAction = find.byType(PublishedViewMoreAction); + + // click the copy link button + { + await tester.tapButton(publishMoreAction); + await tester.pumpAndSettle(); + await tester.pumpUntilFound(copyLinkItem); + await tester.tapButton(copyLinkItem); + await tester.pumpAndSettle(); + await tester.pumpUntilNotFound(copyLinkItem); + + final clipboardContent = await getIt().getData(); + final plainText = clipboardContent.plainText; + expect( + plainText, + contains(pageName), + ); + } + + // custom url + { + await tester.tapButton(publishMoreAction); + await tester.pumpAndSettle(); + await tester.pumpUntilFound(customUrlItem); + await tester.tapButton(customUrlItem); + await tester.pumpAndSettle(); + await tester.pumpUntilNotFound(customUrlItem); + + // see the custom url dialog + final customUrlDialog = find.byType(PublishedViewSettingsDialog); + expect(customUrlDialog, findsOneWidget); + + // rename the custom url + final textField = find.descendant( + of: customUrlDialog, + matching: find.byType(TextField), + ); + await tester.enterText(textField, 'hello-world'); + await tester.pumpAndSettle(); + + // click the save button + final saveButton = find.descendant( + of: customUrlDialog, + matching: find.text(LocaleKeys.button_save.tr()), + ); + await tester.tapButton(saveButton); + await tester.pumpAndSettle(); + + // expect to see the toast with success message + final successToast = find.text( + LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), + ); + await tester.pumpUntilFound(successToast); + expect(successToast, findsOneWidget); + } + + // unpublish + { + await tester.tapButton(publishMoreAction); + await tester.pumpAndSettle(); + await tester.pumpUntilFound(unpublishItem); + await tester.tapButton(unpublishItem); + await tester.pumpAndSettle(); + await tester.pumpUntilNotFound(unpublishItem); + + // expect to see the toast with success message + final successToast = find.text( + LocaleKeys.publish_unpublishSuccessfully.tr(), + ); + await tester.pumpUntilFound(successToast); + expect(successToast, findsOneWidget); + await tester.pumpUntilNotFound(successToast); + + // check if the page is unpublished in sites page + expect(pageItem, findsNothing); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/workspace_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/workspace_test_runner.dart new file mode 100644 index 0000000000000..4d2862038e18c --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/cloud/workspace/workspace_test_runner.dart @@ -0,0 +1,19 @@ +import 'package:integration_test/integration_test.dart'; + +import 'change_name_and_icon_test.dart' as change_name_and_icon_test; +import 'collaborative_workspace_test.dart' as collaborative_workspace_test; +import 'share_menu_test.dart' as share_menu_test; +import 'tabs_test.dart' as tabs_test; +import 'workspace_icon_test.dart' as workspace_icon_test; +import 'workspace_settings_test.dart' as workspace_settings_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + workspace_settings_test.main(); + share_menu_test.main(); + collaborative_workspace_test.main(); + change_name_and_icon_test.main(); + workspace_icon_test.main(); + tabs_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/command_palette/command_palette_test.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/command_palette_test.dart new file mode 100644 index 0000000000000..7bf9a86966504 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/command_palette/command_palette_test.dart @@ -0,0 +1,22 @@ +import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Command Palette', () { + testWidgets('Toggle command palette', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.toggleCommandPalette(); + expect(find.byType(CommandPaletteModal), findsOneWidget); + + await tester.toggleCommandPalette(); + expect(find.byType(CommandPaletteModal), findsNothing); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/command_palette/command_palette_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/command_palette_test_runner.dart new file mode 100644 index 0000000000000..b1e990361a730 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/command_palette/command_palette_test_runner.dart @@ -0,0 +1,14 @@ +import 'package:integration_test/integration_test.dart'; + +import 'command_palette_test.dart' as command_palette_test; +import 'folder_search_test.dart' as folder_search_test; +import 'recent_history_test.dart' as recent_history_test; + +void startTesting() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Command Palette integration tests + command_palette_test.main(); + folder_search_test.main(); + recent_history_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart new file mode 100644 index 0000000000000..80c907b9e9966 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/command_palette/folder_search_test.dart @@ -0,0 +1,53 @@ +import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_tile.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Folder Search', () { + testWidgets('Search for views', (tester) async { + const firstDocument = "ViewOne"; + const secondDocument = "ViewOna"; + + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(name: firstDocument); + await tester.createNewPageWithNameUnderParent(name: secondDocument); + + await tester.toggleCommandPalette(); + expect(find.byType(CommandPaletteModal), findsOneWidget); + + final searchFieldFinder = find.descendant( + of: find.byType(SearchField), + matching: find.byType(FlowyTextField), + ); + + await tester.enterText(searchFieldFinder, secondDocument); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + // Expect two search results "ViewOna" and "ViewOne" (Distance 1 to ViewOna) + expect(find.byType(SearchResultTile), findsNWidgets(2)); + + // The score should be higher for "ViewOna" thus it should be shown first + final secondDocumentWidget = tester + .widget(find.byType(SearchResultTile).first) as SearchResultTile; + expect(secondDocumentWidget.result.data, secondDocument); + + // Change search to "ViewOne" + await tester.enterText(searchFieldFinder, firstDocument); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // The score should be higher for "ViewOne" thus it should be shown first + final firstDocumentWidget = tester + .widget(find.byType(SearchResultTile).first) as SearchResultTile; + expect(firstDocumentWidget.result.data, firstDocument); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart b/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart new file mode 100644 index 0000000000000..277ae8f21ec77 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/command_palette/recent_history_test.dart @@ -0,0 +1,38 @@ +import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_view_tile.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Recent History', () { + testWidgets('Search for views', (tester) async { + const firstDocument = "First"; + const secondDocument = "Second"; + + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(name: firstDocument); + await tester.createNewPageWithNameUnderParent(name: secondDocument); + + await tester.toggleCommandPalette(); + expect(find.byType(CommandPaletteModal), findsOneWidget); + + // Expect history list + expect(find.byType(RecentViewsList), findsOneWidget); + + // Expect three recent history items + expect(find.byType(RecentViewTile), findsNWidgets(3)); + + // Expect the first item to be the last viewed document + final firstDocumentWidget = + tester.widget(find.byType(RecentViewTile).first) as RecentViewTile; + expect(firstDocumentWidget.view.name, secondDocument); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart new file mode 100644 index 0000000000000..d6df648bb3931 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart @@ -0,0 +1,350 @@ +import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_editor.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('calendar', () { + testWidgets('update calendar layout', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent( + layout: ViewLayoutPB.Calendar, + ); + + // open setting + await tester.tapDatabaseSettingButton(); + await tester.tapDatabaseLayoutButton(); + await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Board); + await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Board); + + await tester.tapDatabaseSettingButton(); + await tester.tapDatabaseLayoutButton(); + await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Grid); + await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Grid); + + await tester.pumpAndSettle(); + }); + + testWidgets('calendar start from day setting', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create calendar view + const name = 'calendar'; + await tester.createNewPageWithNameUnderParent( + name: name, + layout: ViewLayoutPB.Calendar, + ); + + // Open setting + await tester.tapDatabaseSettingButton(); + await tester.tapCalendarLayoutSettingButton(); + + // select the first day of week is Monday + await tester.tapFirstDayOfWeek(); + await tester.tapFirstDayOfWeekStartFromMonday(); + + // Open the other page and open the new calendar page again + await tester.openPage(gettingStarted); + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + await tester.openPage(name, layout: ViewLayoutPB.Calendar); + + // Open setting again and check the start from Monday is selected + await tester.tapDatabaseSettingButton(); + await tester.tapCalendarLayoutSettingButton(); + await tester.tapFirstDayOfWeek(); + tester.assertFirstDayOfWeekStartFromMonday(); + + await tester.pumpAndSettle(); + }); + + testWidgets('creating and editing calendar events', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create the calendar view + await tester.createNewPageWithNameUnderParent( + layout: ViewLayoutPB.Calendar, + ); + + // Scroll until today's date cell is visible + await tester.scrollToToday(); + + // Hover over today's calendar cell + await tester.hoverOnTodayCalendarCell( + // Tap on create new event button + onHover: tester.tapAddCalendarEventButton, + ); + + // Make sure that the event editor popup is shown + tester.assertEventEditorOpen(); + + tester.assertNumberOfEventsInCalendar(1); + + // Dismiss the event editor popup + await tester.dismissEventEditor(); + + // Double click on today's calendar cell to create a new event + await tester.doubleClickCalendarCell(DateTime.now()); + + // Make sure that the event is inserted in the cell + tester.assertNumberOfEventsInCalendar(2); + + // Click on the event + await tester.openCalendarEvent(index: 0); + tester.assertEventEditorOpen(); + + // Change the title of the event + await tester.editEventTitle('hello world'); + await tester.dismissEventEditor(); + + // Make sure that the event is edited + tester.assertNumberOfEventsInCalendar(1, title: 'hello world'); + tester.assertNumberOfEventsOnSpecificDay(2, DateTime.now()); + + // Click on the event + await tester.openCalendarEvent(index: 0); + tester.assertEventEditorOpen(); + + // Click on the open icon + await tester.openEventToRowDetailPage(); + tester.assertRowDetailPageOpened(); + + // Duplicate the event + await tester.tapRowDetailPageRowActionButton(); + await tester.tapRowDetailPageDuplicateRowButton(); + await tester.dismissRowDetailPage(); + + // Check that there are 2 events + tester.assertNumberOfEventsInCalendar(2, title: 'hello world'); + tester.assertNumberOfEventsOnSpecificDay(3, DateTime.now()); + + // Delete an event + await tester.openCalendarEvent(index: 1); + await tester.deleteEventFromEventEditor(); + + // Check that there is 1 event + tester.assertNumberOfEventsInCalendar(1, title: 'hello world'); + tester.assertNumberOfEventsOnSpecificDay(2, DateTime.now()); + + // Delete event from row detail page + await tester.openCalendarEvent(index: 0); + await tester.openEventToRowDetailPage(); + tester.assertRowDetailPageOpened(); + + await tester.tapRowDetailPageRowActionButton(); + await tester.tapRowDetailPageDeleteRowButton(); + + // Check that there is 0 event + tester.assertNumberOfEventsInCalendar(0, title: 'hello world'); + tester.assertNumberOfEventsOnSpecificDay(1, DateTime.now()); + }); + + testWidgets('create and duplicate calendar event', (tester) async { + const customTitle = "EventTitleCustom"; + + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create the calendar view + await tester.createNewPageWithNameUnderParent( + layout: ViewLayoutPB.Calendar, + ); + + // Scroll until today's date cell is visible + await tester.scrollToToday(); + + // Hover over today's calendar cell + await tester.hoverOnTodayCalendarCell( + // Tap on create new event button + onHover: () async => tester.tapAddCalendarEventButton(), + ); + + // Make sure that the event editor popup is shown + tester.assertEventEditorOpen(); + + tester.assertNumberOfEventsInCalendar(1); + + // Change the title of the event + await tester.editEventTitle(customTitle); + + // Duplicate event + final duplicateBtnFinder = find + .descendant( + of: find.byType(CalendarEventEditor), + matching: find.byType( + FlowyIconButton, + ), + ) + .first; + await tester.tap(duplicateBtnFinder); + await tester.pumpAndSettle(); + + tester.assertNumberOfEventsInCalendar(2, title: customTitle); + }); + + testWidgets('rescheduling events', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create the calendar view + await tester.createNewPageWithNameUnderParent( + layout: ViewLayoutPB.Calendar, + ); + + // Create a new event on the first of this month + final today = DateTime.now(); + final firstOfThisMonth = DateTime(today.year, today.month); + await tester.doubleClickCalendarCell(firstOfThisMonth); + await tester.dismissEventEditor(); + + // Drag and drop the event onto the next week, same day + await tester.dragDropRescheduleCalendarEvent(); + + // Make sure that the event has been rescheduled to the new date + final sameDayNextWeek = firstOfThisMonth.add(const Duration(days: 7)); + tester.assertNumberOfEventsInCalendar(1); + tester.assertNumberOfEventsOnSpecificDay(1, sameDayNextWeek); + + // Delete the event + await tester.openCalendarEvent(index: 0, date: sameDayNextWeek); + await tester.deleteEventFromEventEditor(); + + // Create another event on the 5th of this month + final fifthOfThisMonth = DateTime(today.year, today.month, 5); + await tester.doubleClickCalendarCell(fifthOfThisMonth); + await tester.dismissEventEditor(); + + // Make sure that the event is on the 4t + tester.assertNumberOfEventsOnSpecificDay(1, fifthOfThisMonth); + + // Click on the event + await tester.openCalendarEvent(index: 0, date: fifthOfThisMonth); + + // Open the date editor of the event + await tester.tapDateCellInRowDetailPage(); + await tester.findDateEditor(findsOneWidget); + + // Edit the event's date + final newDate = fifthOfThisMonth.add(const Duration(days: 1)); + await tester.selectDay(content: newDate.day); + await tester.dismissCellEditor(); + + // Dismiss the event editor + await tester.dismissEventEditor(); + + // Make sure that the event is edited + tester.assertNumberOfEventsInCalendar(1); + tester.assertNumberOfEventsOnSpecificDay(1, newDate); + + // Click on the unscheduled events button + await tester.openUnscheduledEventsPopup(); + + // Assert that nothing shows up + tester.findUnscheduledPopup(findsNothing, 0); + + // Click on the event in the calendar + await tester.openCalendarEvent(index: 0, date: newDate); + + // Open the date editor of the event + await tester.tapDateCellInRowDetailPage(); + await tester.findDateEditor(findsOneWidget); + + // Clear the date of the event + await tester.clearDate(); + + // Dismiss the event editor + await tester.dismissEventEditor(); + tester.assertNumberOfEventsInCalendar(0); + + // Click on the unscheduled events button + await tester.openUnscheduledEventsPopup(); + + // Assert that a popup appears and 1 unscheduled event + tester.findUnscheduledPopup(findsOneWidget, 1); + + // Click on the unscheduled event + await tester.clickUnscheduledEvent(); + + tester.assertRowDetailPageOpened(); + }); + + testWidgets('filter calendar events', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create the calendar view + await tester.createNewPageWithNameUnderParent( + layout: ViewLayoutPB.Calendar, + ); + + // Create a new event on the first of this month + final today = DateTime.now(); + final firstOfThisMonth = DateTime(today.year, today.month); + await tester.doubleClickCalendarCell(firstOfThisMonth); + await tester.dismissEventEditor(); + + tester.assertNumberOfEventsInCalendar(1); + + await tester.openCalendarEvent(index: 0, date: firstOfThisMonth); + await tester.tapButton(finderForFieldType(FieldType.MultiSelect)); + await tester.createOption(name: "asdf"); + await tester.createOption(name: "qwer"); + await tester.selectOption(name: "asdf"); + await tester.dismissCellEditor(); + await tester.dismissCellEditor(); + + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType(FieldType.MultiSelect, "Tags"); + + await tester.tapFilterButtonInGrid('Tags'); + await tester.tapOptionFilterWithName('asdf'); + await tester.dismissCellEditor(); + + tester.assertNumberOfEventsInCalendar(0); + + await tester.tapFilterButtonInGrid('Tags'); + await tester.tapOptionFilterWithName('asdf'); + await tester.dismissCellEditor(); + + tester.assertNumberOfEventsInCalendar(1); + + await tester.tapFilterButtonInGrid('Tags'); + await tester.tapOptionFilterWithName('asdf'); + await tester.dismissCellEditor(); + + tester.assertNumberOfEventsInCalendar(0); + + final secondOfThisMonth = DateTime(today.year, today.month, 2); + await tester.doubleClickCalendarCell(secondOfThisMonth); + await tester.dismissEventEditor(); + tester.assertNumberOfEventsInCalendar(1); + + await tester.openCalendarEvent(index: 0, date: secondOfThisMonth); + await tester.tapButton(finderForFieldType(FieldType.MultiSelect)); + await tester.selectOption(name: "asdf"); + await tester.dismissCellEditor(); + await tester.dismissCellEditor(); + + tester.assertNumberOfEventsInCalendar(0); + + await tester.tapFilterButtonInGrid('Tags'); + await tester.changeSelectFilterCondition( + SelectOptionFilterConditionPB.OptionIsEmpty, + ); + await tester.dismissCellEditor(); + + tester.assertNumberOfEventsInCalendar(1); + tester.assertNumberOfEventsOnSpecificDay(1, secondOfThisMonth); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_cell_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_cell_test.dart new file mode 100644 index 0000000000000..ca565474ec0c9 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_cell_test.dart @@ -0,0 +1,548 @@ +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:intl/intl.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('edit grid cell:', () { + testWidgets('text', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + await tester.editCell( + rowIndex: 0, + fieldType: FieldType.RichText, + input: 'hello world', + ); + + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.RichText, + content: 'hello world', + ); + + await tester.pumpAndSettle(); + }); + + // Make sure the text cells are filled with the right content when there are + // multiple text cell + testWidgets('multiple text cells', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'my grid', + layout: ViewLayoutPB.Grid, + ); + await tester.createField(FieldType.RichText, name: 'description'); + + await tester.editCell( + rowIndex: 0, + fieldType: FieldType.RichText, + input: 'hello', + ); + + await tester.editCell( + rowIndex: 0, + fieldType: FieldType.RichText, + input: 'world', + cellIndex: 1, + ); + + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.RichText, + content: 'hello', + ); + + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.RichText, + content: 'world', + cellIndex: 1, + ); + + await tester.pumpAndSettle(); + }); + + testWidgets('number', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + const fieldType = FieldType.Number; + + // Create a number field + await tester.createField(fieldType); + + await tester.editCell( + rowIndex: 0, + fieldType: fieldType, + input: '-1', + ); + // edit the next cell to force the previous cell at row 0 to lose focus + await tester.editCell( + rowIndex: 1, + fieldType: fieldType, + input: '0.2', + ); + // -1 -> -1 + tester.assertCellContent( + rowIndex: 0, + fieldType: fieldType, + content: '-1', + ); + + // edit the next cell to force the previous cell at row 1 to lose focus + await tester.editCell( + rowIndex: 2, + fieldType: fieldType, + input: '.1', + ); + // 0.2 -> 0.2 + tester.assertCellContent( + rowIndex: 1, + fieldType: fieldType, + content: '0.2', + ); + + // edit the next cell to force the previous cell at row 2 to lose focus + await tester.editCell( + rowIndex: 0, + fieldType: fieldType, + input: '', + ); + // .1 -> 0.1 + tester.assertCellContent( + rowIndex: 2, + fieldType: fieldType, + content: '0.1', + ); + + await tester.pumpAndSettle(); + }); + + testWidgets('checkbox', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + await tester.assertCheckboxCell(rowIndex: 0, isSelected: false); + await tester.tapCheckboxCellInGrid(rowIndex: 0); + await tester.assertCheckboxCell(rowIndex: 0, isSelected: true); + + await tester.tapCheckboxCellInGrid(rowIndex: 1); + await tester.tapCheckboxCellInGrid(rowIndex: 2); + await tester.assertCheckboxCell(rowIndex: 1, isSelected: true); + await tester.assertCheckboxCell(rowIndex: 2, isSelected: true); + + await tester.pumpAndSettle(); + }); + + testWidgets('created time', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + const fieldType = FieldType.CreatedTime; + // Create a create time field + // The create time field is not editable + await tester.createField(fieldType); + + await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); + + await tester.findDateEditor(findsNothing); + + await tester.pumpAndSettle(); + }); + + testWidgets('last modified time', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + const fieldType = FieldType.LastEditedTime; + // Create a last time field + // The last time field is not editable + await tester.createField(fieldType); + + await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); + + await tester.findDateEditor(findsNothing); + + await tester.pumpAndSettle(); + }); + + testWidgets('date time', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + const fieldType = FieldType.DateTime; + await tester.createField(fieldType); + + // Tap the cell to invoke the field editor + await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); + await tester.findDateEditor(findsOneWidget); + + // Toggle include time + await tester.toggleIncludeTime(); + + // Dismiss the cell editor + await tester.dismissCellEditor(); + + await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); + await tester.findDateEditor(findsOneWidget); + + // Turn off include time + await tester.toggleIncludeTime(); + + // Select a date + DateTime now = DateTime.now(); + await tester.selectDay(content: now.day); + + await tester.dismissCellEditor(); + + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.DateTime, + content: DateFormat('MMM dd, y').format(now), + ); + + await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); + + // Toggle include time + now = DateTime.now(); + await tester.toggleIncludeTime(); + + await tester.dismissCellEditor(); + + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.DateTime, + content: DateFormat('MMM dd, y HH:mm').format(now), + ); + + await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); + await tester.findDateEditor(findsOneWidget); + + // Change date format + await tester.tapChangeDateTimeFormatButton(); + await tester.changeDateFormat(); + + await tester.dismissCellEditor(); + + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.DateTime, + content: DateFormat('dd/MM/y HH:mm').format(now), + ); + + await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); + await tester.findDateEditor(findsOneWidget); + + // Change time format + await tester.tapChangeDateTimeFormatButton(); + await tester.changeTimeFormat(); + + await tester.dismissCellEditor(); + + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.DateTime, + content: DateFormat('dd/MM/y hh:mm a').format(now), + ); + + await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType); + await tester.findDateEditor(findsOneWidget); + + // Clear the date and time + await tester.clearDate(); + + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.DateTime, + content: '', + ); + + await tester.pumpAndSettle(); + }); + + testWidgets('single select', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const fieldType = FieldType.SingleSelect; + + // When create a grid, it will create a single select field by default + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Tap the cell to invoke the selection option editor + await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); + await tester.findSelectOptionEditor(findsOneWidget); + + // Create a new select option + await tester.createOption(name: 'tag 1'); + await tester.dismissCellEditor(); + + // Make sure the option is created and displayed in the cell + tester.findSelectOptionWithNameInGrid( + rowIndex: 0, + name: 'tag 1', + ); + + await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); + await tester.findSelectOptionEditor(findsOneWidget); + + // Create another select option + await tester.createOption(name: 'tag 2'); + await tester.dismissCellEditor(); + + tester.findSelectOptionWithNameInGrid( + rowIndex: 0, + name: 'tag 2', + ); + + tester.assertNumberOfSelectedOptionsInGrid( + rowIndex: 0, + matcher: findsOneWidget, + ); + + await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); + await tester.findSelectOptionEditor(findsOneWidget); + + // switch to first option + await tester.selectOption(name: 'tag 1'); + await tester.dismissCellEditor(); + + tester.findSelectOptionWithNameInGrid( + rowIndex: 0, + name: 'tag 1', + ); + + tester.assertNumberOfSelectedOptionsInGrid( + rowIndex: 0, + matcher: findsOneWidget, + ); + + await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); + await tester.findSelectOptionEditor(findsOneWidget); + + // Deselect the currently-selected option + await tester.selectOption(name: 'tag 1'); + await tester.dismissCellEditor(); + + tester.assertNumberOfSelectedOptionsInGrid( + rowIndex: 0, + matcher: findsNothing, + ); + + await tester.pumpAndSettle(); + }); + + testWidgets('multi select', (tester) async { + final tags = [ + 'tag 1', + 'tag 2', + 'tag 3', + 'tag 4', + ]; + + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + const fieldType = FieldType.MultiSelect; + await tester.createField(fieldType, name: fieldType.i18n); + + // Tap the cell to invoke the selection option editor + await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); + await tester.findSelectOptionEditor(findsOneWidget); + + // Create a new select option + await tester.createOption(name: tags.first); + await tester.dismissCellEditor(); + + // Make sure the option is created and displayed in the cell + tester.findSelectOptionWithNameInGrid( + rowIndex: 0, + name: tags.first, + ); + + await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); + await tester.findSelectOptionEditor(findsOneWidget); + + // Create some other select options + await tester.createOption(name: tags[1]); + await tester.createOption(name: tags[2]); + await tester.createOption(name: tags[3]); + await tester.dismissCellEditor(); + + for (final tag in tags) { + tester.findSelectOptionWithNameInGrid( + rowIndex: 0, + name: tag, + ); + } + + tester.assertNumberOfSelectedOptionsInGrid( + rowIndex: 0, + matcher: findsNWidgets(4), + ); + + await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); + await tester.findSelectOptionEditor(findsOneWidget); + + // Deselect all options + for (final tag in tags) { + await tester.selectOption(name: tag); + } + await tester.dismissCellEditor(); + + tester.assertNumberOfSelectedOptionsInGrid( + rowIndex: 0, + matcher: findsNothing, + ); + + await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType); + await tester.findSelectOptionEditor(findsOneWidget); + + // Select some options + await tester.selectOption(name: tags[1]); + await tester.selectOption(name: tags[3]); + await tester.dismissCellEditor(); + + tester.findSelectOptionWithNameInGrid( + rowIndex: 0, + name: tags[1], + ); + tester.findSelectOptionWithNameInGrid( + rowIndex: 0, + name: tags[3], + ); + + tester.assertNumberOfSelectedOptionsInGrid( + rowIndex: 0, + matcher: findsNWidgets(2), + ); + + await tester.pumpAndSettle(); + }); + + testWidgets('checklist', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + const fieldType = FieldType.Checklist; + await tester.createField(fieldType); + + // assert that there is no progress bar in the grid + tester.assertChecklistCellInGrid(rowIndex: 0, percent: null); + + // tap on the first checklist cell + await tester.tapChecklistCellInGrid(rowIndex: 0); + + // assert that the checklist editor is shown + tester.assertChecklistEditorVisible(visible: true); + + // create a new task with enter + await tester.createNewChecklistTask(name: "task 1", enter: true); + + // assert that the task is displayed + tester.assertChecklistTaskInEditor( + index: 0, + name: "task 1", + isChecked: false, + ); + + // update the task's name + await tester.renameChecklistTask(index: 0, name: "task 11"); + + // assert that the task's name is updated + tester.assertChecklistTaskInEditor( + index: 0, + name: "task 11", + isChecked: false, + ); + + // dismiss new task editor + await tester.dismissCellEditor(); + + // dismiss checklist cell editor + await tester.dismissCellEditor(); + + // assert that progress bar is shown in grid at 0% + tester.assertChecklistCellInGrid(rowIndex: 0, percent: 0); + + // start editing the first checklist cell again + await tester.tapChecklistCellInGrid(rowIndex: 0); + + // create another task with the create button + await tester.createNewChecklistTask(name: "task 2", button: true); + + // assert that the task was inserted + tester.assertChecklistTaskInEditor( + index: 1, + name: "task 2", + isChecked: false, + ); + + // mark it as complete + await tester.checkChecklistTask(index: 1); + + // assert that the task was checked in the editor + tester.assertChecklistTaskInEditor( + index: 1, + name: "task 2", + isChecked: true, + ); + + // dismiss checklist editor + await tester.dismissCellEditor(); + await tester.dismissCellEditor(); + + // assert that progressbar is shown in grid at 50% + tester.assertChecklistCellInGrid(rowIndex: 0, percent: 0.5); + + // re-open the cell editor + await tester.tapChecklistCellInGrid(rowIndex: 0); + + // hover over first task and delete it + await tester.deleteChecklistTask(index: 0); + + // dismiss cell editor + await tester.dismissCellEditor(); + + // assert that progressbar is shown in grid at 100% + tester.assertChecklistCellInGrid(rowIndex: 0, percent: 1); + + // re-open the cell edior + await tester.tapChecklistCellInGrid(rowIndex: 0); + + // delete the remaining task + await tester.deleteChecklistTask(index: 0); + + // dismiss the cell editor + await tester.dismissCellEditor(); + + // check that the progress bar is not viisble + tester.assertChecklistCellInGrid(rowIndex: 0, percent: null); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart new file mode 100644 index 0000000000000..9b9434d3d7278 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_settings_test.dart @@ -0,0 +1,64 @@ +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('grid field settings test:', () { + testWidgets('field visibility', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid); + + // create a field + await tester.scrollToRight(find.byType(GridPage)); + await tester.tapNewPropertyButton(); + await tester.renameField('New field 1'); + await tester.dismissFieldEditor(); + + // hide the field + await tester.tapGridFieldWithName('New field 1'); + await tester.tapHidePropertyButton(); + tester.noFieldWithName('New field 1'); + + // go back to inline database view, expect field to be shown + await tester.tapTabBarLinkedViewByViewName('Untitled'); + tester.findFieldWithName('New field 1'); + + // go back to linked database view, expect field to be hidden + await tester.tapTabBarLinkedViewByViewName('Grid'); + tester.noFieldWithName('New field 1'); + + // use the settings button to show the field + await tester.tapDatabaseSettingButton(); + await tester.tapViewPropertiesButton(); + await tester.tapViewTogglePropertyVisibilityButtonByName('New field 1'); + await tester.dismissFieldEditor(); + tester.findFieldWithName('New field 1'); + + // open first row in popup then hide the field + await tester.openFirstRowDetailPage(); + await tester.tapGridFieldWithNameInRowDetailPage('New field 1'); + await tester.tapHidePropertyButtonInFieldEditor(); + await tester.dismissRowDetailPage(); + tester.noFieldWithName('New field 1'); + + // the field should still be sort and filter-able + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType( + FieldType.RichText, + "New field 1", + ); + await tester.tapDatabaseSortButton(); + await tester.tapCreateSortByFieldType(FieldType.RichText, "New field 1"); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart new file mode 100644 index 0000000000000..1422aa8aeeea2 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart @@ -0,0 +1,593 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/select/select_option.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('grid edit field test:', () { + testWidgets('rename existing field', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Invoke the field editor + await tester.tapGridFieldWithName('Name'); + + await tester.renameField('hello world'); + await tester.dismissFieldEditor(); + + await tester.tapGridFieldWithName('hello world'); + await tester.pumpAndSettle(); + }); + + testWidgets('edit field icon', (tester) async { + const icon = 'artificial_intelligence/ai-upscale-spark'; + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + tester.assertFieldSvg('Name', FieldType.RichText); + + // choose specific icon + await tester.tapGridFieldWithName('Name'); + await tester.changeFieldIcon(icon); + await tester.dismissFieldEditor(); + + tester.assertFieldCustomSvg('Name', icon); + + // remove icon + await tester.tapGridFieldWithName('Name'); + await tester.changeFieldIcon(''); + await tester.dismissFieldEditor(); + + tester.assertFieldSvg('Name', FieldType.RichText); + + await tester.pumpAndSettle(); + }); + + testWidgets('update field type of existing field', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Invoke the field editor + await tester.changeFieldTypeOfFieldWithName('Type', FieldType.Checkbox); + + await tester.assertFieldTypeWithFieldName( + 'Type', + FieldType.Checkbox, + ); + await tester.pumpAndSettle(); + }); + + testWidgets('create a field and rename it', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new grid + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // create a field + await tester.createField(FieldType.Checklist); + tester.findFieldWithName(FieldType.Checklist.i18n); + + // editing field type during field creation should change title + await tester.createField(FieldType.MultiSelect); + tester.findFieldWithName(FieldType.MultiSelect.i18n); + + // not if the user changes the title manually though + const name = "New field"; + await tester.createField(FieldType.DateTime); + await tester.tapGridFieldWithName(FieldType.DateTime.i18n); + await tester.renameField(name); + await tester.tapEditFieldButton(); + await tester.tapSwitchFieldTypeButton(); + await tester.selectFieldType(FieldType.URL); + tester.findFieldWithName(name); + }); + + testWidgets('delete field', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // create a field + await tester.createField(FieldType.Checkbox, name: 'New field 1'); + + // Delete the field + await tester.tapGridFieldWithName('New field 1'); + await tester.tapDeletePropertyButton(); + + // confirm delete + await tester.tapButtonWithName(LocaleKeys.space_delete.tr()); + + tester.noFieldWithName('New field 1'); + await tester.pumpAndSettle(); + }); + + testWidgets('duplicate field', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // create a field + await tester.createField(FieldType.RichText, name: 'New field 1'); + + // duplicate the field + await tester.tapGridFieldWithName('New field 1'); + await tester.tapDuplicatePropertyButton(); + + tester.findFieldWithName('New field 1 (copy)'); + await tester.pumpAndSettle(); + }); + + testWidgets('insert field on either side of a field', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + await tester.scrollToRight(find.byType(GridPage)); + + // insert new field to the right + await tester.tapGridFieldWithName('Type'); + await tester.tapInsertFieldButton(left: false, name: 'Right'); + await tester.dismissFieldEditor(); + tester.findFieldWithName('Right'); + + // insert new field to the left + await tester.tapGridFieldWithName('Type'); + await tester.tapInsertFieldButton(left: true, name: "Left"); + await tester.dismissFieldEditor(); + tester.findFieldWithName('Left'); + + await tester.pumpAndSettle(); + }); + + testWidgets('create list of fields', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + for (final fieldType in [ + FieldType.Checklist, + FieldType.DateTime, + FieldType.Number, + FieldType.URL, + FieldType.MultiSelect, + FieldType.LastEditedTime, + FieldType.CreatedTime, + FieldType.Checkbox, + ]) { + await tester.createField(fieldType); + + // After update the field type, the cells should be updated + tester.findCellByFieldType(fieldType); + await tester.pumpAndSettle(); + } + }); + + testWidgets('field types with empty type option editor', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + for (final fieldType in [ + FieldType.RichText, + FieldType.Checkbox, + FieldType.Checklist, + FieldType.URL, + ]) { + await tester.createField(fieldType); + + // open the field editor + await tester.tapGridFieldWithName(fieldType.i18n); + await tester.tapEditFieldButton(); + + // check type option editor is empty + tester.expectEmptyTypeOptionEditor(); + await tester.dismissFieldEditor(); + } + }); + + testWidgets('number field type option', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + await tester.scrollToRight(find.byType(GridPage)); + + // create a number field + await tester.createField(FieldType.Number); + + // enter some data into the first number cell + await tester.editCell( + rowIndex: 0, + fieldType: FieldType.Number, + input: '123', + ); + // edit the next cell to force the previous cell at row 0 to lose focus + await tester.editCell( + rowIndex: 1, + fieldType: FieldType.Number, + input: '0.2', + ); + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.Number, + content: '123', + ); + + // open editor and change number format + await tester.tapGridFieldWithName(FieldType.Number.i18n); + await tester.tapEditFieldButton(); + await tester.changeNumberFieldFormat(); + await tester.dismissFieldEditor(); + + // assert number format has been changed + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.Number, + content: '\$123', + ); + }); + + testWidgets('add option', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent( + layout: ViewLayoutPB.Grid, + ); + + // invoke the field editor + await tester.tapGridFieldWithName('Type'); + await tester.tapEditFieldButton(); + + // tap 'add option' button + await tester.tapAddSelectOptionButton(); + const text = 'Hello AppFlowy'; + final inputField = find.descendant( + of: find.byType(CreateOptionTextField), + matching: find.byType(TextField), + ); + await tester.enterText(inputField, text); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + // check the result + tester.expectToSeeText(text); + }); + + testWidgets('date time field type options', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + await tester.scrollToRight(find.byType(GridPage)); + + // create a date field + await tester.createField(FieldType.DateTime); + + // edit the first date cell + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); + await tester.toggleIncludeTime(); + final now = DateTime.now(); + await tester.selectDay(content: now.day); + + await tester.dismissCellEditor(); + + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.DateTime, + content: DateFormat('MMM dd, y HH:mm').format(now), + ); + + // open editor and change date & time format + await tester.tapGridFieldWithName(FieldType.DateTime.i18n); + await tester.tapEditFieldButton(); + await tester.changeDateFormat(); + await tester.changeTimeFormat(); + await tester.dismissFieldEditor(); + + // assert date format has been changed + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.DateTime, + content: DateFormat('dd/MM/y hh:mm a').format(now), + ); + }); + + testWidgets('text in viewport while typing', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + await tester.changeCalculateAtIndex(0, CalculationType.Count); + + // add very large text with 200 lines + final largeText = List.generate( + 200, + (index) => 'Line ${index + 1}', + ).join('\n'); + + await tester.editCell( + rowIndex: 2, + fieldType: FieldType.RichText, + input: largeText, + ); + + // checks if last line is in view port + tester.expectToSeeText('Line 200'); + }); + + // Disable this test because it fails on CI randomly + // testWidgets('last modified and created at field type options', + // (tester) async { + // await tester.initializeAppFlowy(); + // await tester.tapGoButton(); + + // await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + // final created = DateTime.now(); + + // // create a created at field + // await tester.tapNewPropertyButton(); + // await tester.renameField(FieldType.CreatedTime.i18n); + // await tester.tapSwitchFieldTypeButton(); + // await tester.selectFieldType(FieldType.CreatedTime); + // await tester.dismissFieldEditor(); + + // // create a last modified field + // await tester.tapNewPropertyButton(); + // await tester.renameField(FieldType.LastEditedTime.i18n); + // await tester.tapSwitchFieldTypeButton(); + + // // get time just before modifying + // final modified = DateTime.now(); + + // // create a last modified field (cont'd) + // await tester.selectFieldType(FieldType.LastEditedTime); + // await tester.dismissFieldEditor(); + + // tester.assertCellContent( + // rowIndex: 0, + // fieldType: FieldType.CreatedTime, + // content: DateFormat('MMM dd, y HH:mm').format(created), + // ); + // tester.assertCellContent( + // rowIndex: 0, + // fieldType: FieldType.LastEditedTime, + // content: DateFormat('MMM dd, y HH:mm').format(modified), + // ); + + // // open field editor and change date & time format + // await tester.tapGridFieldWithName(FieldType.LastEditedTime.i18n); + // await tester.tapEditFieldButton(); + // await tester.changeDateFormat(); + // await tester.changeTimeFormat(); + // await tester.dismissFieldEditor(); + + // // open field editor and change date & time format + // await tester.tapGridFieldWithName(FieldType.CreatedTime.i18n); + // await tester.tapEditFieldButton(); + // await tester.changeDateFormat(); + // await tester.changeTimeFormat(); + // await tester.dismissFieldEditor(); + + // // assert format has been changed + // tester.assertCellContent( + // rowIndex: 0, + // fieldType: FieldType.CreatedTime, + // content: DateFormat('dd/MM/y hh:mm a').format(created), + // ); + // tester.assertCellContent( + // rowIndex: 0, + // fieldType: FieldType.LastEditedTime, + // content: DateFormat('dd/MM/y hh:mm a').format(modified), + // ); + // }); + + testWidgets('select option transform', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent( + layout: ViewLayoutPB.Grid, + ); + + // invoke the field editor of existing Single-Select field Type + await tester.tapGridFieldWithName('Type'); + await tester.tapEditFieldButton(); + + // add some select options + await tester.tapAddSelectOptionButton(); + for (final optionName in ['A', 'B', 'C']) { + final inputField = find.descendant( + of: find.byType(CreateOptionTextField), + matching: find.byType(TextField), + ); + await tester.enterText(inputField, optionName); + await tester.testTextInput.receiveAction(TextInputAction.done); + } + await tester.dismissFieldEditor(); + + // select A in first row's cell under the Type field + await tester.tapCellInGrid( + rowIndex: 0, + fieldType: FieldType.SingleSelect, + ); + await tester.selectOption(name: 'A'); + await tester.dismissCellEditor(); + tester.findSelectOptionWithNameInGrid(name: 'A', rowIndex: 0); + + await tester.changeFieldTypeOfFieldWithName('Type', FieldType.RichText); + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.RichText, + content: "A", + cellIndex: 1, + ); + + // add some random text in the second row + await tester.editCell( + rowIndex: 1, + fieldType: FieldType.RichText, + input: "random", + cellIndex: 1, + ); + tester.assertCellContent( + rowIndex: 1, + fieldType: FieldType.RichText, + content: "random", + cellIndex: 1, + ); + + await tester.changeFieldTypeOfFieldWithName( + 'Type', + FieldType.SingleSelect, + ); + tester.findSelectOptionWithNameInGrid(name: 'A', rowIndex: 0); + tester.assertNumberOfSelectedOptionsInGrid( + rowIndex: 1, + matcher: findsNothing, + ); + + // create a new field for testing + await tester.createField(FieldType.RichText, name: 'Test'); + + // edit the first 2 rows + await tester.editCell( + rowIndex: 0, + fieldType: FieldType.RichText, + input: "E,F", + cellIndex: 1, + ); + await tester.editCell( + rowIndex: 1, + fieldType: FieldType.RichText, + input: "G", + cellIndex: 1, + ); + + await tester.changeFieldTypeOfFieldWithName( + 'Test', + FieldType.MultiSelect, + ); + tester.assertMultiSelectOption(contents: ['E', 'F'], rowIndex: 0); + tester.assertMultiSelectOption(contents: ['G'], rowIndex: 1); + + await tester.tapCellInGrid( + rowIndex: 2, + fieldType: FieldType.MultiSelect, + ); + await tester.selectOption(name: 'G'); + await tester.createOption(name: 'H'); + await tester.dismissCellEditor(); + tester.findSelectOptionWithNameInGrid(name: 'A', rowIndex: 0); + tester.assertMultiSelectOption(contents: ['G', 'H'], rowIndex: 2); + + await tester.changeFieldTypeOfFieldWithName( + 'Test', + FieldType.RichText, + ); + tester.assertCellContent( + rowIndex: 2, + fieldType: FieldType.RichText, + content: "G,H", + cellIndex: 1, + ); + await tester.changeFieldTypeOfFieldWithName( + 'Test', + FieldType.MultiSelect, + ); + + tester.assertMultiSelectOption(contents: ['E', 'F'], rowIndex: 0); + tester.assertMultiSelectOption(contents: ['G'], rowIndex: 1); + tester.assertMultiSelectOption(contents: ['G', 'H'], rowIndex: 2); + }); + + testWidgets('date time transform', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + await tester.scrollToRight(find.byType(GridPage)); + + // create a date field + await tester.createField(FieldType.DateTime); + + // edit the first date cell + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); + final now = DateTime.now(); + await tester.toggleIncludeTime(); + await tester.selectDay(content: now.day); + + await tester.dismissCellEditor(); + + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.DateTime, + content: DateFormat('MMM dd, y HH:mm').format(now), + ); + + await tester.changeFieldTypeOfFieldWithName( + 'Date', + FieldType.RichText, + ); + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.RichText, + content: DateFormat('MMM dd, y HH:mm').format(now), + cellIndex: 1, + ); + + await tester.editCell( + rowIndex: 1, + fieldType: FieldType.RichText, + input: "Oct 5, 2024", + cellIndex: 1, + ); + tester.assertCellContent( + rowIndex: 1, + fieldType: FieldType.RichText, + content: "Oct 5, 2024", + cellIndex: 1, + ); + + await tester.changeFieldTypeOfFieldWithName( + 'Date', + FieldType.DateTime, + ); + tester.assertCellContent( + rowIndex: 0, + fieldType: FieldType.DateTime, + content: DateFormat('MMM dd, y').format(now), + ); + tester.assertCellContent( + rowIndex: 1, + fieldType: FieldType.DateTime, + content: "Oct 05, 2024", + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_filter_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_filter_test.dart new file mode 100644 index 0000000000000..8e79445503950 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_filter_test.dart @@ -0,0 +1,224 @@ +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('grid filter:', () { + testWidgets('add text filter', (tester) async { + await tester.openTestDatabase(v020GridFileName); + + // create a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType(FieldType.RichText, 'Name'); + await tester.tapFilterButtonInGrid('Name'); + + // enter 'A' in the filter text field + tester.assertNumberOfRowsInGridPage(10); + await tester.enterTextInTextFilter('A'); + tester.assertNumberOfRowsInGridPage(1); + + // after remove the filter, the grid should show all rows + await tester.enterTextInTextFilter(''); + tester.assertNumberOfRowsInGridPage(10); + + await tester.enterTextInTextFilter('B'); + tester.assertNumberOfRowsInGridPage(1); + + // open the menu to delete the filter + await tester.tapDisclosureButtonInFinder(find.byType(TextFilterEditor)); + await tester.tapDeleteFilterButtonInGrid(); + tester.assertNumberOfRowsInGridPage(10); + + await tester.pumpAndSettle(); + }); + + testWidgets('add checkbox filter', (tester) async { + await tester.openTestDatabase(v020GridFileName); + + // create a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType(FieldType.Checkbox, 'Done'); + tester.assertNumberOfRowsInGridPage(5); + + await tester.tapFilterButtonInGrid('Done'); + await tester.tapCheckboxFilterButtonInGrid(); + + await tester.tapUnCheckedButtonOnCheckboxFilter(); + tester.assertNumberOfRowsInGridPage(5); + + await tester + .tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor)); + await tester.tapDeleteFilterButtonInGrid(); + tester.assertNumberOfRowsInGridPage(10); + + await tester.pumpAndSettle(); + }); + + testWidgets('add checklist filter', (tester) async { + await tester.openTestDatabase(v020GridFileName); + + // create a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType(FieldType.Checklist, 'checklist'); + + // By default, the condition of checklist filter is 'uncompleted' + tester.assertNumberOfRowsInGridPage(9); + + await tester.tapFilterButtonInGrid('checklist'); + await tester.tapChecklistFilterButtonInGrid(); + + await tester.tapCompletedButtonOnChecklistFilter(); + tester.assertNumberOfRowsInGridPage(1); + + await tester.pumpAndSettle(); + }); + + testWidgets('add single select filter', (tester) async { + await tester.openTestDatabase(v020GridFileName); + + // create a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType(FieldType.SingleSelect, 'Type'); + + await tester.tapFilterButtonInGrid('Type'); + + // select the option 's6' + await tester.tapOptionFilterWithName('s6'); + tester.assertNumberOfRowsInGridPage(0); + + // unselect the option 's6' + await tester.tapOptionFilterWithName('s6'); + tester.assertNumberOfRowsInGridPage(10); + + // select the option 's5' + await tester.tapOptionFilterWithName('s5'); + tester.assertNumberOfRowsInGridPage(1); + + // select the option 's4' + await tester.tapOptionFilterWithName('s4'); + + // The row with 's4' should be shown. + tester.assertNumberOfRowsInGridPage(2); + + await tester.pumpAndSettle(); + }); + + testWidgets('add multi select filter', (tester) async { + await tester.openTestDatabase(v020GridFileName); + + // create a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType( + FieldType.MultiSelect, + 'multi-select', + ); + + await tester.tapFilterButtonInGrid('multi-select'); + await tester.scrollOptionFilterListByOffset(const Offset(0, -200)); + + // select the option 'm1'. Any option with 'm1' should be shown. + await tester.tapOptionFilterWithName('m1'); + tester.assertNumberOfRowsInGridPage(5); + await tester.tapOptionFilterWithName('m1'); + + // select the option 'm2'. Any option with 'm2' should be shown. + await tester.tapOptionFilterWithName('m2'); + tester.assertNumberOfRowsInGridPage(4); + await tester.tapOptionFilterWithName('m2'); + + // select the option 'm4'. Any option with 'm4' should be shown. + await tester.tapOptionFilterWithName('m4'); + tester.assertNumberOfRowsInGridPage(1); + + await tester.pumpAndSettle(); + }); + + testWidgets('add date filter', (tester) async { + await tester.openTestDatabase(v020GridFileName); + + // create a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType(FieldType.DateTime, 'date'); + + // By default, the condition of date filter is current day and time + tester.assertNumberOfRowsInGridPage(0); + + await tester.tapFilterButtonInGrid('date'); + await tester.changeDateFilterCondition(DateTimeFilterCondition.before); + tester.assertNumberOfRowsInGridPage(7); + + await tester.changeDateFilterCondition(DateTimeFilterCondition.isEmpty); + tester.assertNumberOfRowsInGridPage(3); + + await tester.pumpAndSettle(); + }); + + testWidgets('add timestamp filter', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + await tester.createField( + FieldType.CreatedTime, + name: 'Created at', + ); + + // create a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType( + FieldType.CreatedTime, + 'Created at', + ); + await tester.pumpAndSettle(); + + tester.assertNumberOfRowsInGridPage(3); + + await tester.tapFilterButtonInGrid('Created at'); + await tester.changeDateFilterCondition(DateTimeFilterCondition.before); + tester.assertNumberOfRowsInGridPage(0); + + await tester.pumpAndSettle(); + }); + + testWidgets('create new row when filters don\'t autofill', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // create a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType( + FieldType.RichText, + 'Name', + ); + tester.assertNumberOfRowsInGridPage(3); + + await tester.tapCreateRowButtonInGrid(); + tester.assertNumberOfRowsInGridPage(4); + + await tester.tapFilterButtonInGrid('Name'); + await tester + .changeTextFilterCondition(TextFilterConditionPB.TextIsNotEmpty); + await tester.dismissCellEditor(); + tester.assertNumberOfRowsInGridPage(0); + + await tester.tapCreateRowButtonInGrid(); + tester.assertNumberOfRowsInGridPage(0); + expect(find.byType(RowDetailPage), findsOneWidget); + + await tester.pumpAndSettle(); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart new file mode 100644 index 0000000000000..cb24a949bbdf9 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart @@ -0,0 +1,297 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('media type option in database', () { + testWidgets('add media field and add files two times', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Invoke the field editor + await tester.tapGridFieldWithName('Type'); + await tester.tapEditFieldButton(); + + // Change to media type + await tester.tapSwitchFieldTypeButton(); + await tester.selectFieldType(FieldType.Media); + await tester.dismissFieldEditor(); + + // Open media cell editor + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media); + await tester.findMediaCellEditor(findsOneWidget); + + // Prepare files for upload from local + final firstImage = + await rootBundle.load('assets/test/images/sample.jpeg'); + final secondImage = + await rootBundle.load('assets/test/images/sample.gif'); + final tempDirectory = await getTemporaryDirectory(); + + final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final firstFile = File(firstImagePath) + ..writeAsBytesSync(firstImage.buffer.asUint8List()); + + final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); + final secondFile = File(secondImagePath) + ..writeAsBytesSync(secondImage.buffer.asUint8List()); + + mockPickFilePaths(paths: [firstImagePath]); + await getIt().set(KVKeys.kCloudType, '0'); + + // Click on add file button in the Media Cell Editor + await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr())); + await tester.pumpAndSettle(); + + // Tap on the upload interaction + await tester.tapFileUploadHint(); + + // Expect one file + expect(find.byType(RenderMedia), findsOneWidget); + + // Mock second file + mockPickFilePaths(paths: [secondImagePath]); + + // Click on add file button in the Media Cell Editor + await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr())); + await tester.pumpAndSettle(); + + // Tap on the upload interaction + await tester.tapFileUploadHint(); + await tester.pumpAndSettle(); + + // Expect two files + expect(find.byType(RenderMedia), findsNWidgets(2)); + + // Remove the temp files + await Future.wait([firstFile.delete(), secondFile.delete()]); + }); + + testWidgets('add two files at once', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Invoke the field editor + await tester.tapGridFieldWithName('Type'); + await tester.tapEditFieldButton(); + + // Change to media type + await tester.tapSwitchFieldTypeButton(); + await tester.selectFieldType(FieldType.Media); + await tester.dismissFieldEditor(); + + // Open media cell editor + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media); + await tester.findMediaCellEditor(findsOneWidget); + + // Prepare files for upload from local + final firstImage = + await rootBundle.load('assets/test/images/sample.jpeg'); + final secondImage = + await rootBundle.load('assets/test/images/sample.gif'); + final tempDirectory = await getTemporaryDirectory(); + + final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final firstFile = File(firstImagePath) + ..writeAsBytesSync(firstImage.buffer.asUint8List()); + + final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); + final secondFile = File(secondImagePath) + ..writeAsBytesSync(secondImage.buffer.asUint8List()); + + mockPickFilePaths(paths: [firstImagePath, secondImagePath]); + await getIt().set(KVKeys.kCloudType, '0'); + + // Click on add file button in the Media Cell Editor + await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr())); + await tester.pumpAndSettle(); + + // Tap on the upload interaction + await tester.tapFileUploadHint(); + + // Expect two files + expect(find.byType(RenderMedia), findsNWidgets(2)); + + // Remove the temp files + await Future.wait([firstFile.delete(), secondFile.delete()]); + }); + + testWidgets('delete files', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Invoke the field editor + await tester.tapGridFieldWithName('Type'); + await tester.tapEditFieldButton(); + + // Change to media type + await tester.tapSwitchFieldTypeButton(); + await tester.selectFieldType(FieldType.Media); + await tester.dismissFieldEditor(); + + // Open media cell editor + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media); + await tester.findMediaCellEditor(findsOneWidget); + + // Prepare files for upload from local + final firstImage = + await rootBundle.load('assets/test/images/sample.jpeg'); + final secondImage = + await rootBundle.load('assets/test/images/sample.gif'); + final tempDirectory = await getTemporaryDirectory(); + + final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final firstFile = File(firstImagePath) + ..writeAsBytesSync(firstImage.buffer.asUint8List()); + + final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); + final secondFile = File(secondImagePath) + ..writeAsBytesSync(secondImage.buffer.asUint8List()); + + mockPickFilePaths(paths: [firstImagePath, secondImagePath]); + await getIt().set(KVKeys.kCloudType, '0'); + + // Click on add file button in the Media Cell Editor + await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr())); + await tester.pumpAndSettle(); + + // Tap on the upload interaction + await tester.tapFileUploadHint(); + + // Expect two files + expect(find.byType(RenderMedia), findsNWidgets(2)); + + // Tap on the three dots menu for the first RenderMedia + final mediaMenuFinder = find.descendant( + of: find.byType(RenderMedia), + matching: find.byFlowySvg(FlowySvgs.three_dots_s), + ); + + await tester.tap(mediaMenuFinder.first); + await tester.pumpAndSettle(); + + // Tap on the delete button + await tester.tap(find.text(LocaleKeys.grid_media_delete.tr())); + await tester.pumpAndSettle(); + + // Tap on Delete button in the confirmation dialog + await tester.tap( + find.descendant( + of: find.byType(SpaceCancelOrConfirmButton), + matching: find.text(LocaleKeys.grid_media_delete.tr()), + ), + ); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Expect one file + expect(find.byType(RenderMedia), findsOneWidget); + + // Remove the temp files + await Future.wait([firstFile.delete(), secondFile.delete()]); + }); + + testWidgets('show file names', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Invoke the field editor + await tester.tapGridFieldWithName('Type'); + await tester.tapEditFieldButton(); + + // Change to media type + await tester.tapSwitchFieldTypeButton(); + await tester.selectFieldType(FieldType.Media); + await tester.dismissFieldEditor(); + + // Open media cell editor + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media); + await tester.findMediaCellEditor(findsOneWidget); + + // Prepare files for upload from local + final firstImage = + await rootBundle.load('assets/test/images/sample.jpeg'); + final secondImage = + await rootBundle.load('assets/test/images/sample.gif'); + final tempDirectory = await getTemporaryDirectory(); + + final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final firstFile = File(firstImagePath) + ..writeAsBytesSync(firstImage.buffer.asUint8List()); + + final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); + final secondFile = File(secondImagePath) + ..writeAsBytesSync(secondImage.buffer.asUint8List()); + + mockPickFilePaths(paths: [firstImagePath, secondImagePath]); + await getIt().set(KVKeys.kCloudType, '0'); + + // Click on add file button in the Media Cell Editor + await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr())); + await tester.pumpAndSettle(); + + // Tap on the upload interaction + await tester.tapFileUploadHint(); + + // Expect two files + expect(find.byType(RenderMedia), findsNWidgets(2)); + + await tester.dismissCellEditor(); + await tester.pumpAndSettle(); + + // Open first row in row detail view then toggle show file names + await tester.openFirstRowDetailPage(); + await tester.pumpAndSettle(); + + // Expect file names to not be shown (hidden) + expect(find.text('sample.jpeg'), findsNothing); + expect(find.text('sample.gif'), findsNothing); + + await tester.tapGridFieldWithNameInRowDetailPage('Type'); + await tester.pumpAndSettle(); + + // Toggle show file names + await tester.tap(find.byType(Toggle)); + await tester.pumpAndSettle(); + + // Expect file names to be shown + expect(find.text('sample.jpeg'), findsOneWidget); + expect(find.text('sample.gif'), findsOneWidget); + + await tester.dismissRowDetailPage(); + await tester.pumpAndSettle(); + + // Remove the temp files + await Future.wait([firstFile.delete(), secondFile.delete()]); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_reminder_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_reminder_test.dart new file mode 100644 index 0000000000000..efd365c04355d --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_reminder_test.dart @@ -0,0 +1,206 @@ +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('reminder in database', () { + testWidgets('add date field and add reminder', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Invoke the field editor + await tester.tapGridFieldWithName('Type'); + await tester.tapEditFieldButton(); + + // Change to date type + await tester.tapSwitchFieldTypeButton(); + await tester.selectFieldType(FieldType.DateTime); + await tester.dismissFieldEditor(); + + // Open date picker + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); + await tester.findDateEditor(findsOneWidget); + + // Select date + final isToday = await tester.selectLastDateInPicker(); + + // Select "On day of event" reminder + await tester.selectReminderOption(ReminderOption.onDayOfEvent); + + // Expect "On day of event" to be displayed + tester.expectSelectedReminder(ReminderOption.onDayOfEvent); + + // Dismiss the cell/date editor + await tester.dismissCellEditor(); + + // Open date picker again + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); + await tester.findDateEditor(findsOneWidget); + + // Expect "On day of event" to be displayed + tester.expectSelectedReminder(ReminderOption.onDayOfEvent); + + // Dismiss the cell/date editor + await tester.dismissCellEditor(); + + int tabIndex = 1; + final now = DateTime.now(); + if (isToday && now.hour >= 9) { + tabIndex = 0; + } + + // Open "Upcoming" in Notification hub + await tester.openNotificationHub(tabIndex: tabIndex); + + // Expect 1 notification + tester.expectNotificationItems(1); + }); + + testWidgets('navigate from reminder to open row', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Invoke the field editor + await tester.tapGridFieldWithName('Type'); + await tester.tapEditFieldButton(); + + // Change to date type + await tester.tapSwitchFieldTypeButton(); + await tester.selectFieldType(FieldType.DateTime); + await tester.dismissFieldEditor(); + + // Open date picker + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); + await tester.findDateEditor(findsOneWidget); + + // Select date + final isToday = await tester.selectLastDateInPicker(); + + // Select "On day of event"-reminder + await tester.selectReminderOption(ReminderOption.onDayOfEvent); + + // Expect "On day of event" to be displayed + tester.expectSelectedReminder(ReminderOption.onDayOfEvent); + + // Dismiss the cell/date editor + await tester.dismissCellEditor(); + + // Open date picker again + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); + await tester.findDateEditor(findsOneWidget); + + // Expect "On day of event" to be displayed + tester.expectSelectedReminder(ReminderOption.onDayOfEvent); + + // Dismiss the cell/date editor + await tester.dismissCellEditor(); + + // Create and Navigate to a new document + await tester.createNewPageWithNameUnderParent(); + await tester.pumpAndSettle(); + + int tabIndex = 1; + final now = DateTime.now(); + if (isToday && now.hour >= 9) { + tabIndex = 0; + } + + // Open correct tab in Notification hub + await tester.openNotificationHub(tabIndex: tabIndex); + + // Expect 1 notification + tester.expectNotificationItems(1); + + // Tap on the notification + await tester.tap(find.byType(NotificationItem)); + await tester.pumpAndSettle(); + + // Expect to see Row Editor Dialog + tester.expectToSeeRowDetailsPageDialog(); + }); + + testWidgets( + 'toggle include time sets reminder option correctly', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent( + layout: ViewLayoutPB.Grid, + ); + + // Invoke the field editor + await tester.tapGridFieldWithName('Type'); + await tester.tapEditFieldButton(); + + // Change to date type + await tester.tapSwitchFieldTypeButton(); + await tester.selectFieldType(FieldType.DateTime); + await tester.dismissFieldEditor(); + + // Open date picker + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); + await tester.findDateEditor(findsOneWidget); + + // Select date + await tester.selectLastDateInPicker(); + + // Select "On day of event"-reminder + await tester.selectReminderOption(ReminderOption.onDayOfEvent); + + // Expect "On day of event" to be displayed + tester.expectSelectedReminder(ReminderOption.onDayOfEvent); + + // Dismiss the cell/date editor + await tester.dismissCellEditor(); + + // Open date picker again + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); + await tester.findDateEditor(findsOneWidget); + + // Expect "On day of event" to be displayed + tester.expectSelectedReminder(ReminderOption.onDayOfEvent); + + // Toggle include time on + await tester.toggleIncludeTime(); + + // Expect "At time of event" to be displayed + tester.expectSelectedReminder(ReminderOption.atTimeOfEvent); + + // Dismiss the cell/date editor + await tester.dismissCellEditor(); + + // Open date picker again + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime); + await tester.findDateEditor(findsOneWidget); + + // Expect "At time of event" to be displayed + tester.expectSelectedReminder(ReminderOption.atTimeOfEvent); + + // Select "One hour before"-reminder + await tester.selectReminderOption(ReminderOption.oneHourBefore); + + // Expect "One hour before" to be displayed + tester.expectSelectedReminder(ReminderOption.oneHourBefore); + + // Toggle include time off + await tester.toggleIncludeTime(); + + // Expect "On day of event" to be displayed + tester.expectSelectedReminder(ReminderOption.onDayOfEvent); + }, + ); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_row_cover_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_cover_test.dart new file mode 100644 index 0000000000000..8741dcd75fdc1 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_cover_test.dart @@ -0,0 +1,131 @@ +import 'dart:io'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/card/card.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_banner.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/af_image.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('database row cover', () { + testWidgets('add and remove cover from Row Detail Card', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Open first row in row detail view + await tester.openFirstRowDetailPage(); + await tester.pumpAndSettle(); + + // Expect no cover + expect(find.byType(RowCover), findsNothing); + + // Hover on RowBanner to show Add Cover button + await tester.hoverRowBanner(); + + // Click on Add Cover button + await tester.tapAddCoverButton(); + + // Expect a cover to be shown - the default asset cover + expect(find.byType(RowCover), findsOneWidget); + + // Tap on the delete cover button + await tester.tapButton(find.byType(DeleteCoverButton)); + await tester.pumpAndSettle(); + + // Expect no cover to be shown + expect(find.byType(AFImage), findsNothing); + }); + + testWidgets('add and change cover and check in Board', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board); + await tester.pumpAndSettle(); + + // Open "Card 1" + await tester.tap(find.text('Card 1'), warnIfMissed: false); + await tester.pumpAndSettle(); + + // Expect no cover + expect(find.byType(RowCover), findsNothing); + + // Hover on RowBanner to show Add Cover button + await tester.hoverRowBanner(); + + // Click on Add Cover button + await tester.tapAddCoverButton(); + + // Expect default cover to be shown + expect(find.byType(RowCover), findsOneWidget); + + // Prepare image for upload from local + final image = await rootBundle.load('assets/test/images/sample.jpeg'); + final tempDirectory = await getTemporaryDirectory(); + final imagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final file = File(imagePath) + ..writeAsBytesSync(image.buffer.asUint8List()); + + mockPickFilePaths(paths: [imagePath]); + await getIt().set(KVKeys.kCloudType, '0'); + + // Hover on RowBanner to show Change Cover button + await tester.hoverRowBanner(); + + // Tap on the change cover button + await tester.tapButtonWithName( + LocaleKeys.document_plugins_cover_changeCover.tr(), + ); + await tester.pumpAndSettle(); + + // Change tab to Upload tab + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_upload_label.tr(), + ); + + // Tab on the upload button + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_upload_placeholder.tr(), + ); + + // Expect one cover + expect(find.byType(RowCover), findsOneWidget); + + // Expect the cover to be shown both in RowCover and in CardCover + expect(find.byType(AFImage), findsNWidgets(2)); + + // Dismiss Row Detail Page + await tester.dismissRowDetailPage(); + + // Expect a cover to be shown in CardCover + expect( + find.descendant( + of: find.byType(CardCover), + matching: find.byType(AFImage), + ), + findsOneWidget, + ); + + // Remove the temp file + await Future.wait([file.delete()]); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart new file mode 100644 index 0000000000000..22f059d199648 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_row_page_test.dart @@ -0,0 +1,508 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; +import 'package:appflowy/plugins/document/document_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/emoji.dart'; +import '../../shared/util.dart'; + +void main() { + setUpAll(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + RecentIcons.enable = false; + }); + + tearDownAll(() { + RecentIcons.enable = true; + }); + + group('grid row detail page:', () { + testWidgets('opens', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create a new grid + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Hover first row and then open the row page + await tester.openFirstRowDetailPage(); + + // Make sure that the row page is opened + tester.assertRowDetailPageOpened(); + + // Each row detail page should have a document + await tester.assertDocumentExistInRowDetailPage(); + }); + + testWidgets('add and update emoji', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create a new grid + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Hover first row and then open the row page + await tester.openFirstRowDetailPage(); + await tester.hoverRowBanner(); + await tester.openEmojiPicker(); + await tester.tapEmoji('😀'); + + // expect to find the emoji selected + final firstEmojiFinder = find.byWidgetPredicate( + (w) => w is FlowyText && w.text == '😀', + ); + + // There are 2 eomjis - one in the row banner and another in the primary cell + expect(firstEmojiFinder, findsNWidgets(2)); + + // Update existing selected emoji - tap on it to update + await tester.tapButton(find.byType(EmojiIconWidget)); + await tester.pumpAndSettle(); + + await tester.tapEmoji('😅'); + + // The emoji already displayed in the row banner + final emojiText = find.byWidgetPredicate( + (widget) => widget is FlowyText && widget.text == '😅', + ); + + // The number of emoji should be two. One in the row displayed in the grid + // one in the row detail page. + expect(emojiText, findsNWidgets(2)); + + // insert a sub page in database + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.pumpAndSettle(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_subPage_name.tr(), + offset: 100, + ); + await tester.pumpAndSettle(); + + // the row detail page should be closed + final rowDetailPage = find.byType(RowDetailPage); + await tester.pumpUntilNotFound(rowDetailPage); + + // expect to see a document page + final documentPage = find.byType(DocumentPage); + expect(documentPage, findsOneWidget); + }); + + testWidgets('remove emoji', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create a new grid + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Hover first row and then open the row page + await tester.openFirstRowDetailPage(); + await tester.hoverRowBanner(); + await tester.openEmojiPicker(); + await tester.tapEmoji('😀'); + + // Remove the emoji + await tester.tapButton(find.byType(EmojiIconWidget)); + await tester.tapButton(find.text(LocaleKeys.button_remove.tr())); + + final emojiText = find.byWidgetPredicate( + (widget) => widget is FlowyText && widget.text == '😀', + ); + expect(emojiText, findsNothing); + }); + + testWidgets('create list of fields', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create a new grid + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Hover first row and then open the row page + await tester.openFirstRowDetailPage(); + + for (final fieldType in [ + FieldType.Checklist, + FieldType.DateTime, + FieldType.Number, + FieldType.URL, + FieldType.MultiSelect, + FieldType.LastEditedTime, + FieldType.CreatedTime, + FieldType.Checkbox, + ]) { + await tester.tapRowDetailPageCreatePropertyButton(); + + // Open the type option menu + await tester.tapSwitchFieldTypeButton(); + + await tester.selectFieldType(fieldType); + + final field = find.descendant( + of: find.byType(RowDetailPage), + matching: find.byWidgetPredicate( + (widget) => + widget is FieldCellButton && + widget.field.name == fieldType.i18n, + ), + ); + expect(field, findsOneWidget); + + // After update the field type, the cells should be updated + tester.findCellByFieldType(fieldType); + await tester.scrollRowDetailByOffset(const Offset(0, -50)); + } + }); + + testWidgets('change order of fields and cells', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create a new grid + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Hover first row and then open the row page + await tester.openFirstRowDetailPage(); + + // Assert that the first field in the row details page is the select + // option type + tester.assertFirstFieldInRowDetailByType(FieldType.SingleSelect); + + // Reorder first field in list + final gesture = await tester.hoverOnFieldInRowDetail(index: 0); + await tester.pumpAndSettle(); + await tester.reorderFieldInRowDetail(offset: 30); + + // Orders changed, now the checkbox is first + tester.assertFirstFieldInRowDetailByType(FieldType.Checkbox); + await gesture.removePointer(); + await tester.pumpAndSettle(); + + // Reorder second field in list + await tester.hoverOnFieldInRowDetail(index: 1); + await tester.pumpAndSettle(); + await tester.reorderFieldInRowDetail(offset: -30); + + // First field is now back to select option + tester.assertFirstFieldInRowDetailByType(FieldType.SingleSelect); + }); + + testWidgets('hide and show hidden fields', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create a new grid + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Hover first row and then open the row page + await tester.openFirstRowDetailPage(); + + // Assert that the show hidden fields button isn't visible + tester.assertToggleShowHiddenFieldsVisibility(false); + + // Hide the first field in the field list + await tester.tapGridFieldWithNameInRowDetailPage("Type"); + await tester.tapHidePropertyButtonInFieldEditor(); + + // Assert that the field is now hidden + tester.noFieldWithName("Type"); + + // Assert that the show hidden fields button appears + tester.assertToggleShowHiddenFieldsVisibility(true); + + // Click on the show hidden fields button + await tester.toggleShowHiddenFields(); + + // Assert that the hidden field is shown again and that the show + // hidden fields button is still present + tester.findFieldWithName("Type"); + tester.assertToggleShowHiddenFieldsVisibility(true); + + // Click hide hidden fields + await tester.toggleShowHiddenFields(); + + // Assert that the hidden field has vanished + tester.noFieldWithName("Type"); + + // Click show hidden fields + await tester.toggleShowHiddenFields(); + + // delete the hidden field + await tester.tapGridFieldWithNameInRowDetailPage("Type"); + await tester.tapDeletePropertyInFieldEditor(); + + // Assert that the that the show hidden fields button is gone + tester.assertToggleShowHiddenFieldsVisibility(false); + }); + + testWidgets('update the contents of the document and re-open it', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create a new grid + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Hover first row and then open the row page + await tester.openFirstRowDetailPage(); + + // Wait for the document to be loaded + await tester.wait(500); + + // Focus on the editor + final textBlock = find.byType(ParagraphBlockComponentWidget); + await tester.tapAt(tester.getCenter(textBlock)); + await tester.pumpAndSettle(); + + // Input some text + const inputText = 'Hello World'; + await tester.ime.insertText(inputText); + expect( + find.textContaining(inputText, findRichText: true), + findsOneWidget, + ); + + // Tap outside to dismiss the field + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + // Re-open the document + await tester.openFirstRowDetailPage(); + expect( + find.textContaining(inputText, findRichText: true), + findsOneWidget, + ); + }); + + testWidgets( + 'check if the title wraps properly when a long text is inserted', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create a new grid + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Hover first row and then open the row page + await tester.openFirstRowDetailPage(); + + // Wait for the document to be loaded + await tester.wait(500); + + // Focus on the editor + final textField = find + .descendant( + of: find.byType(SimpleDialog), + matching: find.byType(TextField), + ) + .first; + + // Input a long text + await tester.enterText(textField, 'Long text' * 25); + await tester.pumpAndSettle(); + + // Tap outside to dismiss the field + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + // Check if there is any overflow in the widget tree + expect(tester.takeException(), isNull); + + // Re-open the document + await tester.openFirstRowDetailPage(); + + // Check again if there is any overflow in the widget tree + expect(tester.takeException(), isNull); + }); + + testWidgets('delete row', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create a new grid + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Hover first row and then open the row page + await tester.openFirstRowDetailPage(); + + await tester.tapRowDetailPageRowActionButton(); + await tester.tapRowDetailPageDeleteRowButton(); + await tester.tapEscButton(); + + tester.assertNumberOfRowsInGridPage(2); + }); + + testWidgets('duplicate row', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Create a new grid + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Hover first row and then open the row page + await tester.openFirstRowDetailPage(); + + await tester.tapRowDetailPageRowActionButton(); + await tester.tapRowDetailPageDuplicateRowButton(); + await tester.tapEscButton(); + + tester.assertNumberOfRowsInGridPage(4); + }); + + testWidgets('edit checklist cell', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + const fieldType = FieldType.Checklist; + await tester.createField(fieldType); + + await tester.openFirstRowDetailPage(); + await tester.hoverOnWidget( + find.byType(ChecklistRowDetailCell), + onHover: () async { + await tester.tapButton(find.byType(ChecklistItemControl)); + }, + ); + + tester.assertPhantomChecklistItemAtIndex(index: 0); + await tester.enterText(find.byType(PhantomChecklistItem), 'task 1'); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + tester.assertChecklistTaskInEditor( + index: 0, + name: "task 1", + isChecked: false, + ); + tester.assertPhantomChecklistItemAtIndex(index: 1); + tester.assertPhantomChecklistItemContent(""); + + await tester.enterText(find.byType(PhantomChecklistItem), 'task 2'); + await tester.pumpAndSettle(); + await tester.hoverOnWidget( + find.byType(ChecklistRowDetailCell), + onHover: () async { + await tester.tapButton(find.byType(ChecklistItemControl)); + }, + ); + + tester.assertChecklistTaskInEditor( + index: 1, + name: "task 2", + isChecked: false, + ); + tester.assertPhantomChecklistItemAtIndex(index: 2); + tester.assertPhantomChecklistItemContent(""); + + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.byType(PhantomChecklistItem), findsNothing); + + await tester.renameChecklistTask(index: 0, name: "task -1", enter: false); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + + tester.assertChecklistTaskInEditor( + index: 0, + name: "task -1", + isChecked: false, + ); + tester.assertPhantomChecklistItemAtIndex(index: 1); + + await tester.enterText(find.byType(PhantomChecklistItem), 'task 0'); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + tester.assertPhantomChecklistItemAtIndex(index: 2); + + await tester.checkChecklistTask(index: 1); + expect(find.byType(PhantomChecklistItem), findsNothing); + expect(find.byType(ChecklistItem), findsNWidgets(3)); + + tester.assertChecklistTaskInEditor( + index: 0, + name: "task -1", + isChecked: false, + ); + tester.assertChecklistTaskInEditor( + index: 1, + name: "task 0", + isChecked: true, + ); + tester.assertChecklistTaskInEditor( + index: 2, + name: "task 2", + isChecked: false, + ); + + await tester.tapButton( + find.descendant( + of: find.byType(ProgressAndHideCompleteButton), + matching: find.byType(FlowyIconButton), + ), + ); + expect(find.byType(ChecklistItem), findsNWidgets(2)); + + tester.assertChecklistTaskInEditor( + index: 0, + name: "task -1", + isChecked: false, + ); + tester.assertChecklistTaskInEditor( + index: 1, + name: "task 2", + isChecked: false, + ); + + await tester.renameChecklistTask(index: 1, name: "task 3", enter: false); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + + await tester.renameChecklistTask(index: 0, name: "task 1", enter: false); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + + await tester.enterText(find.byType(PhantomChecklistItem), 'task 2'); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + tester.assertChecklistTaskInEditor( + index: 0, + name: "task 1", + isChecked: false, + ); + tester.assertChecklistTaskInEditor( + index: 1, + name: "task 2", + isChecked: false, + ); + tester.assertChecklistTaskInEditor( + index: 2, + name: "task 3", + isChecked: false, + ); + tester.assertPhantomChecklistItemAtIndex(index: 2); + + await tester.checkChecklistTask(index: 1); + expect(find.byType(ChecklistItem), findsNWidgets(2)); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_setting_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_setting_test.dart new file mode 100644 index 0000000000000..7c8058425eff9 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_setting_test.dart @@ -0,0 +1,50 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('grid', () { + testWidgets('update layout', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // open setting + await tester.tapDatabaseSettingButton(); + // select the layout + await tester.tapDatabaseLayoutButton(); + // select layout by board + await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Board); + await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Board); + + await tester.pumpAndSettle(); + }); + + testWidgets('update layout multiple times', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // open setting + await tester.tapDatabaseSettingButton(); + await tester.tapDatabaseLayoutButton(); + await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Board); + await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Board); + + await tester.tapDatabaseSettingButton(); + await tester.tapDatabaseLayoutButton(); + await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Calendar); + await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Calendar); + + await tester.pumpAndSettle(); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_share_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_share_test.dart new file mode 100644 index 0000000000000..2beb74a5f2bd0 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_share_test.dart @@ -0,0 +1,166 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('database', () { + testWidgets('import v0.2.0 database data', (tester) async { + await tester.openTestDatabase(v020GridFileName); + // wait the database data is loaded + await tester.pumpAndSettle(const Duration(microseconds: 500)); + + // check the text cell + final textCells = ['A', 'B', 'C', 'D', 'E', '', '', '', '', '']; + for (final (index, content) in textCells.indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.RichText, + content: content, + ); + } + + // check the checkbox cell + final checkboxCells = [ + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + ]; + for (final (index, content) in checkboxCells.indexed) { + await tester.assertCheckboxCell( + rowIndex: index, + isSelected: content, + ); + } + + // check the number cell + final numberCells = [ + '-1', + '-2', + '0.1', + '0.2', + '1', + '2', + '10', + '11', + '12', + '', + ]; + for (final (index, content) in numberCells.indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.Number, + content: content, + ); + } + + // check the url cell + final urlCells = [ + 'appflowy.io', + 'no url', + 'appflowy.io', + 'https://github.com/AppFlowy-IO/', + '', + '', + ]; + for (final (index, content) in urlCells.indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.URL, + content: content, + ); + } + + // check the single select cell + final singleSelectCells = [ + 's1', + 's2', + 's3', + 's4', + 's5', + '', + '', + '', + '', + '', + ]; + for (final (index, content) in singleSelectCells.indexed) { + await tester.assertSingleSelectOption( + rowIndex: index, + content: content, + ); + } + + // check the multi select cell + final List> multiSelectCells = [ + ['m1'], + ['m1', 'm2'], + ['m1', 'm2', 'm3'], + ['m1', 'm2', 'm3'], + ['m1', 'm2', 'm3', 'm4', 'm5'], + [], + [], + [], + [], + [], + ]; + for (final (index, contents) in multiSelectCells.indexed) { + tester.assertMultiSelectOption( + rowIndex: index, + contents: contents, + ); + } + + // check the checklist cell + final List checklistCells = [ + 0.67, + 0.33, + 1.0, + null, + null, + null, + null, + null, + null, + null, + ]; + for (final (index, percent) in checklistCells.indexed) { + tester.assertChecklistCellInGrid( + rowIndex: index, + percent: percent, + ); + } + + // check the date cell + final List dateCells = [ + 'Jun 01, 2023', + 'Jun 02, 2023', + 'Jun 03, 2023', + 'Jun 04, 2023', + 'Jun 05, 2023', + 'Jun 05, 2023', + 'Jun 16, 2023', + '', + '', + '', + ]; + for (final (index, content) in dateCells.indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.DateTime, + content: content, + ); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_sort_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_sort_test.dart new file mode 100644 index 0000000000000..e09d8718bedbc --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_sort_test.dart @@ -0,0 +1,471 @@ +import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_editor.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('grid sort:', () { + testWidgets('text sort', (tester) async { + await tester.openTestDatabase(v020GridFileName); + // create a sort + await tester.tapDatabaseSortButton(); + await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); + + // check the text cell order + final textCells = [ + 'A', + 'B', + 'C', + 'D', + 'E', + '', + '', + '', + '', + '', + ]; + for (final (index, content) in textCells.indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.RichText, + content: content, + ); + } + + // open the sort menu and select order by descending + await tester.tapSortMenuInSettingBar(); + await tester.tapEditSortConditionButtonByFieldName('Name'); + await tester.tapSortByDescending(); + for (final (index, content) in [ + 'E', + 'D', + 'C', + 'B', + 'A', + '', + '', + '', + '', + '', + ].indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.RichText, + content: content, + ); + } + + // delete all sorts + await tester.tapSortMenuInSettingBar(); + await tester.tapDeleteAllSortsButton(); + + // check the text cell order + for (final (index, content) in [ + 'A', + 'B', + 'C', + 'D', + 'E', + '', + '', + '', + '', + '', + ].indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.RichText, + content: content, + ); + } + await tester.pumpAndSettle(); + }); + + testWidgets('checkbox', (tester) async { + await tester.openTestDatabase(v020GridFileName); + // create a sort + await tester.tapDatabaseSortButton(); + await tester.tapCreateSortByFieldType(FieldType.Checkbox, 'Done'); + + // check the checkbox cell order + for (final (index, content) in [ + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + ].indexed) { + await tester.assertCheckboxCell( + rowIndex: index, + isSelected: content, + ); + } + + // open the sort menu and select order by descending + await tester.tapSortMenuInSettingBar(); + await tester.tapEditSortConditionButtonByFieldName('Done'); + await tester.tapSortByDescending(); + for (final (index, content) in [ + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + ].indexed) { + await tester.assertCheckboxCell( + rowIndex: index, + isSelected: content, + ); + } + + await tester.pumpAndSettle(); + }); + + testWidgets('number', (tester) async { + await tester.openTestDatabase(v020GridFileName); + // create a sort + await tester.tapDatabaseSortButton(); + await tester.tapCreateSortByFieldType(FieldType.Number, 'number'); + + // check the number cell order + for (final (index, content) in [ + '-2', + '-1', + '0.1', + '0.2', + '1', + '2', + '10', + '11', + '12', + '', + ].indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.Number, + content: content, + ); + } + + // open the sort menu and select order by descending + await tester.tapSortMenuInSettingBar(); + await tester.tapEditSortConditionButtonByFieldName('number'); + await tester.tapSortByDescending(); + for (final (index, content) in [ + '12', + '11', + '10', + '2', + '1', + '0.2', + '0.1', + '-1', + '-2', + '', + ].indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.Number, + content: content, + ); + } + + await tester.pumpAndSettle(); + }); + + testWidgets('checkbox and number', (tester) async { + await tester.openTestDatabase(v020GridFileName); + // create a sort + await tester.tapDatabaseSortButton(); + await tester.tapCreateSortByFieldType(FieldType.Checkbox, 'Done'); + + // open the sort menu and sort checkbox by descending + await tester.tapSortMenuInSettingBar(); + await tester.tapEditSortConditionButtonByFieldName('Done'); + await tester.tapSortByDescending(); + for (final (index, content) in [ + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + ].indexed) { + await tester.assertCheckboxCell( + rowIndex: index, + isSelected: content, + ); + } + + // add another sort, this time by number descending + await tester.tapSortMenuInSettingBar(); + await tester.tapCreateSortByFieldTypeInSortMenu( + FieldType.Number, + 'number', + ); + await tester.tapEditSortConditionButtonByFieldName('number'); + await tester.tapSortByDescending(); + + // check checkbox cell order + for (final (index, content) in [ + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + ].indexed) { + await tester.assertCheckboxCell( + rowIndex: index, + isSelected: content, + ); + } + + // check number cell order + for (final (index, content) in [ + '1', + '0.2', + '0.1', + '-1', + '-2', + '12', + '11', + '10', + '2', + '', + ].indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.Number, + content: content, + ); + } + + await tester.pumpAndSettle(); + }); + + testWidgets('reorder sort', (tester) async { + await tester.openTestDatabase(v020GridFileName); + // create a sort + await tester.tapDatabaseSortButton(); + await tester.tapCreateSortByFieldType(FieldType.Checkbox, 'Done'); + + // open the sort menu and sort checkbox by descending + await tester.tapSortMenuInSettingBar(); + await tester.tapEditSortConditionButtonByFieldName('Done'); + await tester.tapSortByDescending(); + + // add another sort, this time by number descending + await tester.tapSortMenuInSettingBar(); + await tester.tapCreateSortByFieldTypeInSortMenu( + FieldType.Number, + 'number', + ); + await tester.tapEditSortConditionButtonByFieldName('number'); + await tester.tapSortByDescending(); + + // check checkbox cell order + for (final (index, content) in [ + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + ].indexed) { + await tester.assertCheckboxCell( + rowIndex: index, + isSelected: content, + ); + } + + // check number cell order + for (final (index, content) in [ + '1', + '0.2', + '0.1', + '-1', + '-2', + '12', + '11', + '10', + '2', + '', + ].indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.Number, + content: content, + ); + } + + // reorder sort + await tester.tapSortMenuInSettingBar(); + await tester.reorderSort( + (FieldType.Number, 'number'), + (FieldType.Checkbox, 'Done'), + ); + + // check checkbox cell order + for (final (index, content) in [ + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + ].indexed) { + await tester.assertCheckboxCell( + rowIndex: index, + isSelected: content, + ); + } + + // check the number cell order + for (final (index, content) in [ + '12', + '11', + '10', + '2', + '1', + '0.2', + '0.1', + '-1', + '-2', + '', + ].indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.Number, + content: content, + ); + } + }); + + testWidgets('edit field', (tester) async { + await tester.openTestDatabase(v020GridFileName); + + // create a number sort + await tester.tapDatabaseSortButton(); + await tester.tapCreateSortByFieldType(FieldType.Number, 'number'); + + // check the number cell order + for (final (index, content) in [ + '-2', + '-1', + '0.1', + '0.2', + '1', + '2', + '10', + '11', + '12', + '', + ].indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.Number, + content: content, + ); + } + + final textCells = [ + 'B', + 'A', + 'C', + 'D', + 'E', + '', + '', + '', + '', + '', + ]; + for (final (index, content) in textCells.indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.RichText, + content: content, + ); + } + + // edit the name of the number field + await tester.tapGridFieldWithName('number'); + + await tester.renameField('hello world'); + await tester.dismissFieldEditor(); + + await tester.tapGridFieldWithName('hello world'); + await tester.dismissFieldEditor(); + + // expect name to be changed as well + await tester.tapSortMenuInSettingBar(); + final sortItem = find.ancestor( + of: find.text('hello world'), + matching: find.byType(DatabaseSortItem), + ); + expect(sortItem, findsOneWidget); + + // change the field type of the field to checkbox + await tester.tapGridFieldWithName('hello world'); + await tester.changeFieldTypeOfFieldWithName( + 'hello world', + FieldType.Checkbox, + ); + + // expect name to be changed as well + await tester.tapSortMenuInSettingBar(); + expect(sortItem, findsOneWidget); + + final newTextCells = [ + 'A', + 'B', + 'C', + 'D', + 'E', + '', + '', + '', + '', + '', + ]; + for (final (index, content) in newTextCells.indexed) { + tester.assertCellContent( + rowIndex: index, + fieldType: FieldType.RichText, + content: content, + ); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_test_runner_1.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_test_runner_1.dart new file mode 100644 index 0000000000000..3a5854bc1bbf6 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_test_runner_1.dart @@ -0,0 +1,20 @@ +import 'package:integration_test/integration_test.dart'; + +import 'database_cell_test.dart' as database_cell_test; +import 'database_field_settings_test.dart' as database_field_settings_test; +import 'database_field_test.dart' as database_field_test; +import 'database_row_page_test.dart' as database_row_page_test; +import 'database_setting_test.dart' as database_setting_test; +import 'database_share_test.dart' as database_share_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + database_cell_test.main(); + database_field_test.main(); + database_field_settings_test.main(); + database_share_test.main(); + database_row_page_test.main(); + database_setting_test.main(); + // DON'T add more tests here. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_test_runner_2.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_test_runner_2.dart new file mode 100644 index 0000000000000..26b64af495465 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_test_runner_2.dart @@ -0,0 +1,22 @@ +import 'package:integration_test/integration_test.dart'; + +import 'database_calendar_test.dart' as database_calendar_test; +import 'database_filter_test.dart' as database_filter_test; +import 'database_media_test.dart' as database_media_test; +import 'database_row_cover_test.dart' as database_row_cover_test; +import 'database_share_test.dart' as database_share_test; +import 'database_sort_test.dart' as database_sort_test; +import 'database_view_test.dart' as database_view_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + database_filter_test.main(); + database_sort_test.main(); + database_view_test.main(); + database_calendar_test.main(); + database_media_test.main(); + database_row_cover_test.main(); + database_share_test.main(); + // DON'T add more tests here. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart new file mode 100644 index 0000000000000..e35c9cc9d8aae --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_view_test.dart @@ -0,0 +1,77 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('database', () { + testWidgets('create linked view', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Create board view + await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Board); + tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Board); + + // Create grid view + await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid); + tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Grid); + + // Create calendar view + await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Calendar); + tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Calendar); + + await tester.pumpAndSettle(); + }); + + testWidgets('rename and delete linked view', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Create board view + await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Board); + tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Board); + + // rename board view + await tester.renameLinkedView( + tester.findTabBarLinkViewByViewLayout(ViewLayoutPB.Board), + 'new board', + ); + final findBoard = tester.findTabBarLinkViewByViewName('new board'); + expect(findBoard, findsOneWidget); + + // delete the board + await tester.deleteDatebaseView(findBoard); + expect(tester.findTabBarLinkViewByViewName('new board'), findsNothing); + + await tester.pumpAndSettle(); + }); + + testWidgets('delete the last database view', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Create board view + await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Board); + tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Board); + + // delete the board + await tester.deleteDatebaseView( + tester.findTabBarLinkViewByViewLayout(ViewLayoutPB.Board), + ); + + await tester.pumpAndSettle(); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart new file mode 100644 index 0000000000000..d95d907881e92 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_alignment_test.dart @@ -0,0 +1,98 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document alignment', () { + testWidgets('edit alignment in toolbar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final selection = Selection.single( + path: [0], + startOffset: 0, + endOffset: 1, + ); + // click the first line of the readme + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.updateSelection(selection); + await tester.pumpAndSettle(); + + // click the align center + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_left_s); + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_center_s); + + // expect to see the align center + final editorState = tester.editor.getCurrentEditorState(); + final first = editorState.getNodeAtPath([0])!; + expect(first.attributes[blockComponentAlign], 'center'); + + // click the align right + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_center_s); + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_right_s); + expect(first.attributes[blockComponentAlign], 'right'); + + // click the align left + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_right_s); + await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_left_s); + expect(first.attributes[blockComponentAlign], 'left'); + }); + + testWidgets('edit alignment using shortcut', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // click the first line of the readme + await tester.editor.tapLineOfEditorAt(0); + + await tester.pumpAndSettle(); + + final editorState = tester.editor.getCurrentEditorState(); + final first = editorState.getNodeAtPath([0])!; + + // expect to see text aligned to the right + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.control, + LogicalKeyboardKey.shift, + LogicalKeyboardKey.keyR, + ], + tester: tester, + withKeyUp: true, + ); + expect(first.attributes[blockComponentAlign], rightAlignmentKey); + + // expect to see text aligned to the center + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.control, + LogicalKeyboardKey.shift, + LogicalKeyboardKey.keyE, + ], + tester: tester, + withKeyUp: true, + ); + expect(first.attributes[blockComponentAlign], centerAlignmentKey); + + // expect to see text aligned to the left + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.control, + LogicalKeyboardKey.shift, + LogicalKeyboardKey.keyL, + ], + tester: tester, + withKeyUp: true, + ); + expect(first.attributes[blockComponentAlign], leftAlignmentKey); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_app_lifecycle_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_app_lifecycle_test.dart new file mode 100644 index 0000000000000..fdde8bbeb89c9 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_app_lifecycle_test.dart @@ -0,0 +1,72 @@ +import 'dart:ui'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Editor AppLifeCycle tests', () { + testWidgets( + 'Selection is added back after pausing AppFlowy', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final selection = Selection.single(path: [4], startOffset: 0); + await tester.editor.updateSelection(selection); + + binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + expect(tester.editor.getCurrentEditorState().selection, null); + + binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + + expect(tester.editor.getCurrentEditorState().selection, selection); + }, + ); + + testWidgets( + 'Null selection is retained after pausing AppFlowy', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final selection = Selection.single(path: [4], startOffset: 0); + await tester.editor.updateSelection(selection); + await tester.editor.updateSelection(null); + + binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + expect(tester.editor.getCurrentEditorState().selection, null); + + binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + + expect(tester.editor.getCurrentEditorState().selection, null); + }, + ); + + testWidgets( + 'Non-collapsed selection is retained after pausing AppFlowy', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final selection = Selection( + start: Position(path: [3]), + end: Position(path: [3], offset: 8), + ); + await tester.editor.updateSelection(selection); + + binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + + expect(tester.editor.getCurrentEditorState().selection, selection); + }, + ); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_block_option_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_block_option_test.dart new file mode 100644 index 0000000000000..76e5dfcb6c8c8 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_block_option_test.dart @@ -0,0 +1,47 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Block option interaction tests', () { + testWidgets('has correct block selection on tap option button', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // We edit the document by entering some characters, to ensure the document has focus + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [2])), + ); + + // Insert character 'a' three times - easy to identify + await tester.ime.insertText('aaa'); + await tester.pumpAndSettle(); + + final editorState = tester.editor.getCurrentEditorState(); + final node = editorState.getNodeAtPath([2]); + expect(node?.delta?.toPlainText(), startsWith('aaa')); + + final multiSelection = Selection( + start: Position(path: [2], offset: 3), + end: Position(path: [4], offset: 40), + ); + + // Select multiple items + await tester.editor.updateSelection(multiSelection); + await tester.pumpAndSettle(); + + // Press the block option menu + await tester.editor.hoverAndClickOptionMenuButton([2]); + await tester.pumpAndSettle(); + + // Expect the selection to be Block type and not have changed + expect(editorState.selectionType, SelectionType.block); + expect(editorState.selection, multiSelection); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_codeblock_paste_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_codeblock_paste_test.dart new file mode 100644 index 0000000000000..a498086952d50 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_codeblock_paste_test.dart @@ -0,0 +1,62 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('paste in codeblock:', () { + testWidgets('paste multiple lines in codeblock', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(name: 'Test Document'); + // focus on the editor + await tester.tapButton(find.byType(AppFlowyEditor)); + + // mock the clipboard + const lines = 3; + final text = List.generate(lines, (index) => 'line $index').join('\n'); + AppFlowyClipboard.mockSetData(AppFlowyClipboardData(text: text)); + ClipboardService.mockSetData(ClipboardServiceData(plainText: text)); + + await insertCodeBlockInDocument(tester); + + // paste the text + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + final editorState = tester.editor.getCurrentEditorState(); + expect(editorState.document.root.children.length, 1); + expect( + editorState.getNodeAtPath([0])!.delta!.toPlainText(), + text, + ); + }); + }); +} + +/// Inserts an codeBlock in the document +Future insertCodeBlockInDocument(WidgetTester tester) async { + // open the actions menu and insert the codeBlock + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_code.tr(), + offset: 150, + ); + // wait for the codeBlock to be inserted + await tester.pumpAndSettle(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart new file mode 100644 index 0000000000000..8da4aeccce3cf --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart @@ -0,0 +1,553 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('copy and paste in document', () { + testWidgets('paste multiple lines at the first line', (tester) async { + // mock the clipboard + const lines = 3; + await tester.pasteContent( + plainText: List.generate(lines, (index) => 'line $index').join('\n'), + (editorState) { + expect(editorState.document.root.children.length, 1); + final text = + editorState.document.root.children.first.delta!.toPlainText(); + final textLines = text.split('\n'); + for (var i = 0; i < lines; i++) { + expect( + textLines[i], + 'line $i', + ); + } + }, + ); + }); + + // ## **User Installation** + // - [Windows/Mac/Linux](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/mac-windows-linux-packages) + // - [Docker](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/installing-with-docker) + // - [Source](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/from-source) + testWidgets('paste content from html, sample 1', (tester) async { + await tester.pasteContent( + html: + '''

User Installation

+''', + (editorState) { + expect(editorState.document.root.children.length, 4); + final node1 = editorState.getNodeAtPath([0])!; + final node2 = editorState.getNodeAtPath([1])!; + final node3 = editorState.getNodeAtPath([2])!; + final node4 = editorState.getNodeAtPath([3])!; + expect(node1.delta!.toJson(), [ + { + "insert": "User Installation", + "attributes": {"bold": true}, + } + ]); + expect(node2.delta!.toJson(), [ + { + "insert": "Windows/Mac/Linux", + "attributes": { + "href": + "https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/mac-windows-linux-packages", + }, + } + ]); + expect( + node3.delta!.toJson(), + [ + { + "insert": "Docker", + "attributes": { + "href": + "https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/installing-with-docker", + }, + } + ], + ); + expect( + node4.delta!.toJson(), + [ + { + "insert": "Source", + "attributes": { + "href": + "https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/from-source", + }, + } + ], + ); + }, + ); + }); + + testWidgets('paste code from VSCode', (tester) async { + await tester.pasteContent( + html: + '''
void main() {
runApp(const MyApp());
}
''', + (editorState) { + expect(editorState.document.root.children.length, 3); + final node1 = editorState.getNodeAtPath([0])!; + final node2 = editorState.getNodeAtPath([1])!; + final node3 = editorState.getNodeAtPath([2])!; + expect(node1.type, ParagraphBlockKeys.type); + expect(node2.type, ParagraphBlockKeys.type); + expect(node3.type, ParagraphBlockKeys.type); + expect(node1.delta!.toJson(), [ + {'insert': 'void main() {'}, + ]); + expect(node2.delta!.toJson(), [ + {'insert': " runApp(const MyApp());"}, + ]); + expect(node3.delta!.toJson(), [ + {"insert": "}"}, + ]); + }); + }); + + testWidgets('paste bulleted list in numbered list', (tester) async { + const inAppJson = + '{"document":{"type":"page","children":[{"type":"bulleted_list","children":[{"type":"bulleted_list","data":{"delta":[{"insert":"World"}]}}],"data":{"delta":[{"insert":"Hello"}]}}]}}'; + + await tester.pasteContent( + inAppJson: inAppJson, + beforeTest: (editorState) async { + final transaction = editorState.transaction; + // Insert two numbered list nodes + // 1. Parent One + // 2. + transaction.insertNodes( + [0], + [ + Node( + type: NumberedListBlockKeys.type, + attributes: { + 'delta': [ + {"insert": "One"}, + ], + }, + ), + Node( + type: NumberedListBlockKeys.type, + attributes: {'delta': []}, + ), + ], + ); + + // Set the selection to the second numbered list node (which has empty delta) + transaction.afterSelection = Selection.collapsed(Position(path: [1])); + + await editorState.apply(transaction); + await tester.pumpAndSettle(); + }, + (editorState) { + final secondNode = editorState.getNodeAtPath([1]); + expect(secondNode?.delta?.toPlainText(), 'Hello'); + expect(secondNode?.children.length, 1); + + final childNode = secondNode?.children.first; + expect(childNode?.delta?.toPlainText(), 'World'); + expect(childNode?.type, BulletedListBlockKeys.type); + }, + ); + }); + }); + + testWidgets('paste text on part of bullet list', (tester) async { + const plainText = 'test'; + + await tester.pasteContent( + plainText: plainText, + beforeTest: (editorState) async { + final transaction = editorState.transaction; + transaction.insertNodes( + [0], + [ + Node( + type: BulletedListBlockKeys.type, + attributes: { + 'delta': [ + {"insert": "bullet list"}, + ], + }, + ), + ], + ); + + // Set the selection to the second numbered list node (which has empty delta) + transaction.afterSelection = Selection( + start: Position(path: [0], offset: 7), + end: Position(path: [0], offset: 11), + ); + + await editorState.apply(transaction); + await tester.pumpAndSettle(); + }, + (editorState) { + final node = editorState.getNodeAtPath([0]); + expect(node?.delta?.toPlainText(), 'bullet test'); + expect(node?.type, BulletedListBlockKeys.type); + }, + ); + }); + + testWidgets('paste image(png) from memory', (tester) async { + final image = await rootBundle.load('assets/test/images/sample.png'); + final bytes = image.buffer.asUint8List(); + await tester.pasteContent(image: ('png', bytes), (editorState) { + expect(editorState.document.root.children.length, 1); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], isNotNull); + }); + }); + + testWidgets('paste image(jpeg) from memory', (tester) async { + final image = await rootBundle.load('assets/test/images/sample.jpeg'); + final bytes = image.buffer.asUint8List(); + await tester.pasteContent(image: ('jpeg', bytes), (editorState) { + expect(editorState.document.root.children.length, 1); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], isNotNull); + }); + }); + + testWidgets('paste image(gif) from memory', (tester) async { + final image = await rootBundle.load('assets/test/images/sample.gif'); + final bytes = image.buffer.asUint8List(); + await tester.pasteContent(image: ('gif', bytes), (editorState) { + expect(editorState.document.root.children.length, 1); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], isNotNull); + }); + }); + + testWidgets( + 'format the selected text to href when pasting url if available', + (tester) async { + const text = 'appflowy'; + const url = 'https://appflowy.io'; + await tester.pasteContent( + plainText: url, + beforeTest: (editorState) async { + await tester.ime.insertText(text); + await tester.editor.updateSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: text.length, + ), + ); + }, + (editorState) { + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ParagraphBlockKeys.type); + expect(node.delta!.toJson(), [ + { + 'insert': text, + 'attributes': {'href': url}, + } + ]); + }, + ); + }, + ); + + // https://github.com/AppFlowy-IO/AppFlowy/issues/3263 + testWidgets( + 'paste the image from clipboard when html and image are both available', + (tester) async { + const html = + '''image'''; + final image = await rootBundle.load('assets/test/images/sample.png'); + final bytes = image.buffer.asUint8List(); + await tester.pasteContent( + html: html, + image: ('png', bytes), + (editorState) { + expect(editorState.document.root.children.length, 1); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + }, + ); + }, + ); + + testWidgets('paste the html content contains section', (tester) async { + const html = + '''
AppFlowy
Hello World
'''; + await tester.pasteContent(html: html, (editorState) { + expect(editorState.document.root.children.length, 2); + final node1 = editorState.getNodeAtPath([0])!; + final node2 = editorState.getNodeAtPath([1])!; + expect(node1.type, ParagraphBlockKeys.type); + expect(node2.type, ParagraphBlockKeys.type); + }); + }); + + testWidgets('paste the html from google translation', (tester) async { + const html = + '''
new force
Assessment focus: potential motivations, empathy

➢Personality characteristics and potential motivations:
-Reflection of self-worth
-Need to be respected
-Have a unique definition of success
-Be true to your own lifestyle
'''; + await tester.pasteContent(html: html, (editorState) { + expect(editorState.document.root.children.length, 8); + }); + }); + + testWidgets( + 'auto convert url to link preview block', + (tester) async { + const url = 'https://appflowy.io'; + await tester.pasteContent(plainText: url, (editorState) async { + // the second one is the paragraph node + expect(editorState.document.root.children.length, 2); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, LinkPreviewBlockKeys.type); + expect(node.attributes[LinkPreviewBlockKeys.url], url); + }); + + // hover on the link preview block + // click the more button + // and select convert to link + await tester.hoverOnWidget( + find.byType(CustomLinkPreviewWidget), + onHover: () async { + final convertToLinkButton = find.byWidgetPredicate((widget) { + return widget is MenuBlockButton && + widget.tooltip == + LocaleKeys.document_plugins_urlPreview_convertToLink.tr(); + }); + expect(convertToLinkButton, findsOneWidget); + await tester.tap(convertToLinkButton); + await tester.pumpAndSettle(); + }, + ); + + await tester.pumpAndSettle(); + + final editorState = tester.editor.getCurrentEditorState(); + final textNode = editorState.getNodeAtPath([0])!; + expect(textNode.type, ParagraphBlockKeys.type); + expect(textNode.delta!.toJson(), [ + { + 'insert': url, + 'attributes': {'href': url}, + } + ]); + }, + ); + + testWidgets( + 'ctrl/cmd+z to undo the auto convert url to link preview block', + (tester) async { + const url = 'https://appflowy.io'; + await tester.pasteContent(plainText: url, (editorState) async { + // the second one is the paragraph node + expect(editorState.document.root.children.length, 2); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, LinkPreviewBlockKeys.type); + expect(node.attributes[LinkPreviewBlockKeys.url], url); + }); + + await tester.editor.tapLineOfEditorAt(0); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + final editorState = tester.editor.getCurrentEditorState(); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ParagraphBlockKeys.type); + expect(node.delta!.toJson(), [ + { + 'insert': url, + 'attributes': {'href': url}, + } + ]); + }, + ); + + testWidgets( + 'paste the nodes start with non-delta node', + (tester) async { + await tester.pasteContent((_) {}); + const text = 'Hello World'; + final editorState = tester.editor.getCurrentEditorState(); + final transaction = editorState.transaction; + // [image_block] + // [paragraph_block] + transaction.insertNodes([ + 0, + ], [ + customImageNode(url: ''), + paragraphNode(text: text), + ]); + await editorState.apply(transaction); + await tester.pumpAndSettle(); + + await tester.editor.tapLineOfEditorAt(0); + // select all and copy + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyA, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyC, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + + // put the cursor to the end of the paragraph block + await tester.editor.tapLineOfEditorAt(0); + + // paste the content + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + // expect the image and the paragraph block are inserted below the cursor + expect(editorState.getNodeAtPath([0])!.type, CustomImageBlockKeys.type); + expect(editorState.getNodeAtPath([1])!.type, ParagraphBlockKeys.type); + expect(editorState.getNodeAtPath([2])!.type, CustomImageBlockKeys.type); + expect(editorState.getNodeAtPath([3])!.type, ParagraphBlockKeys.type); + }, + ); + + testWidgets('paste the url without protocol', (tester) async { + // paste the image that from local file + const plainText = '1.jpg'; + final image = await rootBundle.load('assets/test/images/sample.jpeg'); + final bytes = image.buffer.asUint8List(); + await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes), + (editorState) { + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], isNotEmpty); + }); + }); + + testWidgets('paste the image url', (tester) async { + const plainText = 'https://appflowy.io/1.jpg'; + final image = await rootBundle.load('assets/test/images/sample.jpeg'); + final bytes = image.buffer.asUint8List(); + await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes), + (editorState) { + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], isNotEmpty); + }); + }); + + const testMarkdownText = ''' +# I'm h1 +## I'm h2 +### I'm h3 +#### I'm h4 +##### I'm h5 +###### I'm h6'''; + + testWidgets('paste markdowns', (tester) async { + await tester.pasteContent( + plainText: testMarkdownText, + (editorState) { + final children = editorState.document.root.children; + expect(children.length, 6); + for (int i = 1; i <= children.length; i++) { + final text = children[i - 1].delta!.toPlainText(); + expect(text, 'I\'m h$i'); + } + }, + ); + }); + + testWidgets('paste markdowns as plain', (tester) async { + await tester.pasteContent( + plainText: testMarkdownText, + pasteAsPlain: true, + (editorState) { + final children = editorState.document.root.children; + expect(children.length, 6); + for (int i = 1; i <= children.length; i++) { + final text = children[i - 1].delta!.toPlainText(); + final expectText = '${'#' * i} I\'m h$i'; + expect(text, expectText); + } + }, + ); + }); +} + +extension on WidgetTester { + Future pasteContent( + void Function(EditorState editorState) test, { + Future Function(EditorState editorState)? beforeTest, + String? plainText, + String? html, + String? inAppJson, + bool pasteAsPlain = false, + (String, Uint8List?)? image, + }) async { + await initializeAppFlowy(); + await tapAnonymousSignInButton(); + + // create a new document + await createNewPageWithNameUnderParent(name: 'Test Document'); + // tap the editor + await tapButton(find.byType(AppFlowyEditor)); + + await beforeTest?.call(editor.getCurrentEditorState()); + + // mock the clipboard + await getIt().setData( + ClipboardServiceData( + plainText: plainText, + html: html, + inAppJson: inAppJson, + image: image, + ), + ); + + // paste the text + await simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isShiftPressed: pasteAsPlain, + isMetaPressed: Platform.isMacOS, + ); + await pumpAndSettle(); + + test(editor.getCurrentEditorState()); + } +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart new file mode 100644 index 0000000000000..43320509ce2ee --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_create_and_delete_test.dart @@ -0,0 +1,74 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('create and delete the document', () { + testWidgets('create a new document when launching app in first time', + (tester) async { + await tester.initializeAppFlowy(); + + await tester.tapAnonymousSignInButton(); + + // create a new document + const pageName = 'Test Document'; + await tester.createNewPageWithNameUnderParent(name: pageName); + + // expect to see a new document + tester.expectToSeePageName(pageName); + // and with one paragraph block + expect(find.byType(ParagraphBlockComponentWidget), findsOneWidget); + }); + + testWidgets('delete the readme page and restore it', (tester) async { + await tester.initializeAppFlowy(); + + await tester.tapAnonymousSignInButton(); + + // delete the readme page + await tester.hoverOnPageName( + gettingStarted, + onHover: () async => tester.tapDeletePageButton(), + ); + + // the banner should show up and the readme page should be gone + tester.expectToSeeDocumentBanner(); + tester.expectNotToSeePageName(gettingStarted); + + // restore the readme page + await tester.tapRestoreButton(); + + // the banner should be gone and the readme page should be back + tester.expectNotToSeeDocumentBanner(); + tester.expectToSeePageName(gettingStarted); + }); + + testWidgets('delete the readme page and delete it permanently', + (tester) async { + await tester.initializeAppFlowy(); + + await tester.tapAnonymousSignInButton(); + + // delete the readme page + await tester.hoverOnPageName( + gettingStarted, + onHover: () async => tester.tapDeletePageButton(), + ); + + // the banner should show up and the readme page should be gone + tester.expectToSeeDocumentBanner(); + tester.expectNotToSeePageName(gettingStarted); + + // delete the page permanently + await tester.tapDeletePermanentlyButton(); + + // the banner should be gone and the readme page should be gone + tester.expectNotToSeeDocumentBanner(); + tester.expectNotToSeePageName(gettingStarted); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_customer_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_customer_test.dart new file mode 100644 index 0000000000000..5cbb133f9d16d --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_customer_test.dart @@ -0,0 +1,61 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('customer:', () { + testWidgets('backtick issue - inline code', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const pageName = 'backtick issue'; + await tester.createNewPageWithNameUnderParent(name: pageName); + + // focus on the editor + await tester.tap(find.byType(AppFlowyEditor)); + // input backtick + const text = '`Hello` AppFlowy'; + + for (var i = 0; i < text.length; i++) { + await tester.ime.insertCharacter(text[i]); + } + + final node = tester.editor.getNodeAtPath([0]); + expect( + node.delta?.toJson(), + equals([ + { + "insert": "Hello", + "attributes": {"code": true}, + }, + {"insert": " AppFlowy"}, + ]), + ); + }); + + testWidgets('backtick issue - inline code', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const pageName = 'backtick issue'; + await tester.createNewPageWithNameUnderParent(name: pageName); + + // focus on the editor + await tester.tap(find.byType(AppFlowyEditor)); + // input backtick + const text = '```'; + + for (var i = 0; i < text.length; i++) { + await tester.ime.insertCharacter(text[i]); + } + + final node = tester.editor.getNodeAtPath([0]); + expect(node.type, equals(CodeBlockKeys.type)); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_deletion_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_deletion_test.dart new file mode 100644 index 0000000000000..f38138ce8a033 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_deletion_test.dart @@ -0,0 +1,84 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +import 'document_inline_page_reference_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Document deletion', () { + testWidgets('Trash breadcrumb', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // This test shares behavior with the inline page reference test, thus + // we utilize the same helper functions there. + final name = await createDocumentToReference(tester); + await tester.editor.tapLineOfEditorAt(0); + await tester.pumpAndSettle(); + + await triggerReferenceDocumentBySlashMenu(tester); + + // Search for prefix of document + await enterDocumentText(tester); + + // Select result + final optionFinder = find.descendant( + of: find.byType(InlineActionsHandler), + matching: find.text(name), + ); + + await tester.tap(optionFinder); + await tester.pumpAndSettle(); + + final mentionBlock = find.byType(MentionPageBlock); + expect(mentionBlock, findsOneWidget); + + // Delete the page + await tester.hoverOnPageName( + name, + onHover: () async => tester.tapDeletePageButton(), + ); + await tester.pumpAndSettle(); + + // Navigate to the deleted page from the inline mention + await tester.tap(mentionBlock); + await tester.pumpUntilFound(find.byType(TrashBreadcrumb)); + + expect(find.byType(TrashBreadcrumb), findsOneWidget); + + // Navigate using the trash breadcrumb + await tester.tap( + find.descendant( + of: find.byType(TrashBreadcrumb), + matching: find.text( + LocaleKeys.trash_text.tr(), + ), + ), + ); + await tester.pumpUntilFound(find.text(LocaleKeys.trash_restoreAll.tr())); + + // Restore all + await tester.tap(find.text(LocaleKeys.trash_restoreAll.tr())); + await tester.pumpAndSettle(); + await tester.tap(find.text(LocaleKeys.trash_restore.tr())); + await tester.pumpAndSettle(); + + // Navigate back to the document + await tester.openPage('Getting started'); + await tester.pumpAndSettle(); + + await tester.tap(mentionBlock); + await tester.pumpAndSettle(); + + expect(find.byType(TrashBreadcrumb), findsNothing); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart new file mode 100644 index 0000000000000..6212e7d9cf180 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart @@ -0,0 +1,160 @@ +import 'dart:math'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + String generateRandomString(int len) { + final r = Random(); + return String.fromCharCodes( + List.generate(len, (index) => r.nextInt(33) + 89), + ); + } + + testWidgets( + 'document find menu test', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(); + + // tap editor to get focus + await tester.tapButton(find.byType(AppFlowyEditor)); + + // set clipboard data + final data = [ + "123456\n\n", + ...List.generate(100, (_) => "${generateRandomString(50)}\n\n"), + "1234567\n\n", + ...List.generate(100, (_) => "${generateRandomString(50)}\n\n"), + "12345678\n\n", + ...List.generate(100, (_) => "${generateRandomString(50)}\n\n"), + ].join(); + await getIt().setData( + ClipboardServiceData( + plainText: data, + ), + ); + + // paste + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + // go back to beginning of document + // FIXME: Cannot run Ctrl+F unless selection is on screen + await tester.editor + .updateSelection(Selection.collapsed(Position(path: [0]))); + await tester.pumpAndSettle(); + + expect(find.byType(FindAndReplaceMenuWidget), findsNothing); + + // press cmd/ctrl+F to display the find menu + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyF, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget); + + final textField = find.descendant( + of: find.byType(FindAndReplaceMenuWidget), + matching: find.byType(TextField), + ); + + await tester.enterText( + textField, + "123456", + ); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text("123456", findRichText: true), + ), + findsOneWidget, + ); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text("1234567", findRichText: true), + ), + findsOneWidget, + ); + + await tester.showKeyboard(textField); + await tester.idle(); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text("12345678", findRichText: true), + ), + findsOneWidget, + ); + + // tap next button, go back to beginning of document + await tester.tapButton( + find.descendant( + of: find.byType(FindMenu), + matching: find.byFlowySvg(FlowySvgs.arrow_down_s), + ), + ); + + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.text("123456", findRichText: true), + ), + findsOneWidget, + ); + + /// press cmd/ctrl+F to display the find menu + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyF, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget); + + /// press esc to dismiss the find menu + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.byType(FindAndReplaceMenuWidget), findsNothing); + }, + ); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_inline_page_reference_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_inline_page_reference_test.dart new file mode 100644 index 0000000000000..f1699108405cf --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_inline_page_reference_test.dart @@ -0,0 +1,136 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('insert inline document reference', () { + testWidgets('insert by slash menu', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final name = await createDocumentToReference(tester); + + await tester.editor.tapLineOfEditorAt(0); + await tester.pumpAndSettle(); + + await triggerReferenceDocumentBySlashMenu(tester); + + // Search for prefix of document + await enterDocumentText(tester); + + // Select result + final optionFinder = find.descendant( + of: find.byType(InlineActionsHandler), + matching: find.text(name), + ); + + await tester.tap(optionFinder); + await tester.pumpAndSettle(); + + final mentionBlock = find.byType(MentionPageBlock); + expect(mentionBlock, findsOneWidget); + }); + + testWidgets('insert by `[[` character shortcut', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final name = await createDocumentToReference(tester); + + await tester.editor.tapLineOfEditorAt(0); + await tester.pumpAndSettle(); + + await tester.ime.insertText('[['); + await tester.pumpAndSettle(); + + // Select result + await tester.editor.tapAtMenuItemWithName(name); + await tester.pumpAndSettle(); + + final mentionBlock = find.byType(MentionPageBlock); + expect(mentionBlock, findsOneWidget); + }); + + testWidgets('insert by `+` character shortcut', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final name = await createDocumentToReference(tester); + + await tester.editor.tapLineOfEditorAt(0); + await tester.pumpAndSettle(); + + await tester.ime.insertText('+'); + await tester.pumpAndSettle(); + + // Select result + await tester.editor.tapAtMenuItemWithName(name); + await tester.pumpAndSettle(); + + final mentionBlock = find.byType(MentionPageBlock); + expect(mentionBlock, findsOneWidget); + }); + }); +} + +Future createDocumentToReference(WidgetTester tester) async { + final name = 'document_${uuid()}'; + + await tester.createNewPageWithNameUnderParent( + name: name, + openAfterCreated: false, + ); + + // This is a workaround since the openAfterCreated + // option does not work in createNewPageWithName method + await tester.tap(find.byType(SingleInnerViewItem).first); + await tester.pumpAndSettle(); + + return name; +} + +Future triggerReferenceDocumentBySlashMenu(WidgetTester tester) async { + await tester.editor.showSlashMenu(); + await tester.pumpAndSettle(); + + // Search for referenced document action + await enterDocumentText(tester); + + // Select item + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.enter, + ], + tester: tester, + withKeyUp: true, + ); + + await tester.pumpAndSettle(); +} + +Future enterDocumentText(WidgetTester tester) async { + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.keyD, + LogicalKeyboardKey.keyO, + LogicalKeyboardKey.keyC, + LogicalKeyboardKey.keyU, + LogicalKeyboardKey.keyM, + LogicalKeyboardKey.keyE, + LogicalKeyboardKey.keyN, + LogicalKeyboardKey.keyT, + ], + tester: tester, + withKeyUp: true, + ); + await tester.pumpAndSettle(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_inline_sub_page_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_inline_sub_page_test.dart new file mode 100644 index 0000000000000..30e115774a185 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_inline_sub_page_test.dart @@ -0,0 +1,382 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; + +const _firstDocName = "Inline Sub Page Mention"; +const _createdPageName = "hi world"; + +// Test cases that are covered in this file: +// - [x] Insert sub page mention from action menu (+) +// - [x] Delete sub page mention from editor +// - [x] Delete page from sidebar +// - [x] Delete page from sidebar and then trash +// - [x] Undo delete sub page mention +// - [x] Cut+paste in same document +// - [x] Cut+paste in different document +// - [x] Cut+paste in same document and then paste again in same document +// - [x] Turn paragraph with sub page mention into a heading +// - [x] Turn heading with sub page mention into a paragraph +// - [x] Duplicate a Block containing two sub page mentions + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document inline sub-page mention tests:', () { + testWidgets('Insert (& delete) a sub page mention from action menu', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createOpenRenameDocumentUnderParent(name: _firstDocName); + + await tester.insertInlineSubPageFromPlusMenu(); + + await tester.expandOrCollapsePage( + pageName: _firstDocName, + layout: ViewLayoutPB.Document, + ); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNWidgets(2)); + expect(find.byType(MentionSubPageBlock), findsOneWidget); + + // Delete from editor + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + + await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNothing); + expect(find.byType(MentionSubPageBlock), findsNothing); + + // Undo + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNWidgets(2)); + expect(find.byType(MentionSubPageBlock), findsOneWidget); + + // Move to trash (delete from sidebar) + await tester.rightClickOnPageName(_createdPageName); + await tester.tapButtonWithName(ViewMoreActionType.delete.name); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsOneWidget); + expect(find.byType(MentionSubPageBlock), findsOneWidget); + expect( + find.text(LocaleKeys.document_mention_trashHint.tr()), + findsOneWidget, + ); + + // Delete from trash + await tester.tapTrashButton(); + await tester.pumpAndSettle(); + + await tester.tap(find.text(LocaleKeys.trash_deleteAll.tr())); + await tester.pumpAndSettle(); + + await tester.tap(find.text(LocaleKeys.button_delete.tr())); + await tester.pumpAndSettle(); + + await tester.openPage(_firstDocName); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNothing); + expect(find.byType(MentionSubPageBlock), findsOneWidget); + expect( + find.text(LocaleKeys.document_mention_deletedPage.tr()), + findsOneWidget, + ); + }); + + testWidgets( + 'Cut+paste in same document and cut+paste in different document', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createOpenRenameDocumentUnderParent(name: _firstDocName); + + await tester.insertInlineSubPageFromPlusMenu(); + + await tester.expandOrCollapsePage( + pageName: _firstDocName, + layout: ViewLayoutPB.Document, + ); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNWidgets(2)); + expect(find.byType(MentionSubPageBlock), findsOneWidget); + + // Cut from editor + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyX, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNothing); + expect(find.byType(MentionSubPageBlock), findsNothing); + + // Paste in same document + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNWidgets(2)); + expect(find.byType(MentionSubPageBlock), findsOneWidget); + + // Cut again + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyX, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + // Create another document + const anotherDocName = "Another Document"; + await tester.createOpenRenameDocumentUnderParent( + name: anotherDocName, + ); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNothing); + expect(find.byType(MentionSubPageBlock), findsNothing); + + await tester.editor.tapLineOfEditorAt(0); + await tester.pumpAndSettle(); + + // Paste in document + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpUntilFound(find.byType(MentionSubPageBlock)); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsOneWidget); + + await tester.expandOrCollapsePage( + pageName: anotherDocName, + layout: ViewLayoutPB.Document, + ); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNWidgets(2)); + }); + testWidgets( + 'Cut+paste in same docuemnt and then paste again in same document', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createOpenRenameDocumentUnderParent(name: _firstDocName); + + await tester.insertInlineSubPageFromPlusMenu(); + + await tester.expandOrCollapsePage( + pageName: _firstDocName, + layout: ViewLayoutPB.Document, + ); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNWidgets(2)); + expect(find.byType(MentionSubPageBlock), findsOneWidget); + + // Cut from editor + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyX, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNothing); + expect(find.byType(MentionSubPageBlock), findsNothing); + + // Paste in same document + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNWidgets(2)); + expect(find.byType(MentionSubPageBlock), findsOneWidget); + + // Paste again + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(const Duration(seconds: 2)); + + expect(find.text(_createdPageName), findsNWidgets(2)); + expect(find.byType(MentionSubPageBlock), findsNWidgets(2)); + expect(find.text('$_createdPageName (copy)'), findsNWidgets(2)); + }); + + testWidgets('Turn into w/ sub page mentions', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createOpenRenameDocumentUnderParent(name: _firstDocName); + + await tester.insertInlineSubPageFromPlusMenu(); + + await tester.expandOrCollapsePage( + pageName: _firstDocName, + layout: ViewLayoutPB.Document, + ); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNWidgets(2)); + expect(find.byType(MentionSubPageBlock), findsOneWidget); + + final headingText = LocaleKeys.document_slashMenu_name_heading1.tr(); + final paragraphText = LocaleKeys.document_slashMenu_name_text.tr(); + + // Turn into heading + await tester.editor.openTurnIntoMenu([0]); + await tester.tapButton(find.findTextInFlowyText(headingText)); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNWidgets(2)); + expect(find.byType(MentionSubPageBlock), findsOneWidget); + + // Turn into paragraph + await tester.editor.openTurnIntoMenu([0]); + await tester.tapButton(find.findTextInFlowyText(paragraphText)); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsNWidgets(2)); + expect(find.byType(MentionSubPageBlock), findsOneWidget); + }); + + testWidgets('Duplicate a block containing two sub page mentions', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createOpenRenameDocumentUnderParent(name: _firstDocName); + + await tester.insertInlineSubPageFromPlusMenu(); + + // Copy paste it + await tester.editor.updateSelection( + Selection( + start: Position(path: [0]), + end: Position(path: [0], offset: 1), + ), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyC, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsOneWidget); + expect(find.text("$_createdPageName (copy)"), findsOneWidget); + expect(find.byType(MentionSubPageBlock), findsNWidgets(2)); + + // Duplicate node from block action menu + await tester.editor.hoverAndClickOptionMenuButton([0]); + await tester.tapButtonWithName(LocaleKeys.button_duplicate.tr()); + await tester.pumpAndSettle(); + + expect(find.text(_createdPageName), findsOneWidget); + expect(find.text("$_createdPageName (copy)"), findsNWidgets(2)); + expect(find.text("$_createdPageName (copy) (copy)"), findsOneWidget); + }); + + testWidgets('Cancel inline page reference menu by space', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createOpenRenameDocumentUnderParent(name: _firstDocName); + + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showPlusMenu(); + + // Cancel by space + await tester.simulateKeyEvent( + LogicalKeyboardKey.space, + ); + await tester.pumpAndSettle(); + + expect(find.byType(InlineActionsMenu), findsNothing); + }); + }); +} + +extension _InlineSubPageTestHelper on WidgetTester { + Future insertInlineSubPageFromPlusMenu() async { + await editor.tapLineOfEditorAt(0); + + await editor.showPlusMenu(); + + // Workaround to allow typing a document name + await FlowyTestKeyboard.simulateKeyDownEvent( + tester: this, + withKeyUp: true, + [ + LogicalKeyboardKey.keyH, + LogicalKeyboardKey.keyI, + LogicalKeyboardKey.space, + LogicalKeyboardKey.keyW, + LogicalKeyboardKey.keyO, + LogicalKeyboardKey.keyR, + LogicalKeyboardKey.keyL, + LogicalKeyboardKey.keyD, + ], + ); + + await FlowyTestKeyboard.simulateKeyDownEvent( + tester: this, + withKeyUp: true, + [LogicalKeyboardKey.enter], + ); + await pumpUntilFound(find.byType(MentionSubPageBlock)); + } +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_more_actions_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_more_actions_test.dart new file mode 100644 index 0000000000000..d4cc11d7f0518 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_more_actions_test.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('MoreViewActions', () { + testWidgets('can duplicate and delete from menu', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.pumpAndSettle(); + + final pageFinder = find.byType(ViewItem); + expect(pageFinder, findsNWidgets(1)); + + // Duplicate + await tester.openMoreViewActions(); + await tester.duplicateByMoreViewActions(); + await tester.pumpAndSettle(); + + expect(pageFinder, findsNWidgets(2)); + + // Delete + await tester.openMoreViewActions(); + await tester.deleteByMoreViewActions(); + await tester.pumpAndSettle(); + + expect(pageFinder, findsNWidgets(1)); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart new file mode 100644 index 0000000000000..cbc634cf02564 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart @@ -0,0 +1,180 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // +, ... button beside the block component. + group('block option action:', () { + Future turnIntoBlock( + WidgetTester tester, + Path path, { + required String menuText, + required String afterType, + }) async { + await tester.editor.openTurnIntoMenu(path); + await tester.tapButton( + find.findTextInFlowyText(menuText), + ); + final node = tester.editor.getCurrentEditorState().getNodeAtPath(path); + expect(node?.type, afterType); + } + + testWidgets('''click + to add a block after current selection, + and click + and option key to add a block before current selection''', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + var editorState = tester.editor.getCurrentEditorState(); + expect(editorState.getNodeAtPath([1])?.delta?.toPlainText(), isNotEmpty); + + // add a new block after the current selection + await tester.editor.hoverAndClickOptionAddButton([0], false); + // await tester.pumpAndSettle(); + expect(editorState.getNodeAtPath([1])?.delta?.toPlainText(), isEmpty); + + // cancel the selection menu + await tester.tapAt(Offset.zero); + + await tester.editor.hoverAndClickOptionAddButton([0], true); + await tester.pumpAndSettle(); + expect(editorState.getNodeAtPath([0])?.delta?.toPlainText(), isEmpty); + // cancel the selection menu + await tester.tapAt(Offset.zero); + await tester.tapAt(Offset.zero); + + await tester.createNewPageWithNameUnderParent(name: 'test'); + await tester.openPage(gettingStarted); + + // check the status again + editorState = tester.editor.getCurrentEditorState(); + expect(editorState.getNodeAtPath([0])?.delta?.toPlainText(), isEmpty); + expect(editorState.getNodeAtPath([2])?.delta?.toPlainText(), isEmpty); + }); + + testWidgets('turn into - single line', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const name = 'Test Document'; + await tester.createNewPageWithNameUnderParent(name: name); + await tester.openPage(name); + + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText('turn into'); + + // click the block option button to convert it to another blocks + final values = { + LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type, + LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type, + LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type, + LocaleKeys.document_slashMenu_name_bulletedList.tr(): + BulletedListBlockKeys.type, + LocaleKeys.document_slashMenu_name_numberedList.tr(): + NumberedListBlockKeys.type, + LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type, + LocaleKeys.document_slashMenu_name_todoList.tr(): + TodoListBlockKeys.type, + LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type, + LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type, + }; + + for (final value in values.entries) { + final menuText = value.key; + final afterType = value.value; + await turnIntoBlock( + tester, + [0], + menuText: menuText, + afterType: afterType, + ); + } + }); + + testWidgets('turn into - multi lines', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const name = 'Test Document'; + await tester.createNewPageWithNameUnderParent(name: name); + await tester.openPage(name); + + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText('turn into 1'); + await tester.ime.insertCharacter('\n'); + await tester.ime.insertText('turn into 2'); + + // click the block option button to convert it to another blocks + final values = { + LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type, + LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type, + LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type, + LocaleKeys.document_slashMenu_name_bulletedList.tr(): + BulletedListBlockKeys.type, + LocaleKeys.document_slashMenu_name_numberedList.tr(): + NumberedListBlockKeys.type, + LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type, + LocaleKeys.document_slashMenu_name_todoList.tr(): + TodoListBlockKeys.type, + LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type, + LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type, + }; + + for (final value in values.entries) { + final editorState = tester.editor.getCurrentEditorState(); + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [1], offset: 2), + ); + final menuText = value.key; + final afterType = value.value; + await turnIntoBlock( + tester, + [0], + menuText: menuText, + afterType: afterType, + ); + } + }); + + testWidgets( + 'selecting the parent should deselect all the child nodes as well', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const name = 'Test Document'; + await tester.createNewPageWithNameUnderParent(name: name); + await tester.openPage(name); + + // create a nested list + // Item 1 + // Nested Item 1 + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText('Item 1'); + await tester.ime.insertCharacter('\n'); + await tester.simulateKeyEvent(LogicalKeyboardKey.tab); + await tester.ime.insertText('Nested Item 1'); + + // select the 'Nested Item 1' and then tap the option button of the 'Item 1' + final editorState = tester.editor.getCurrentEditorState(); + final selection = Selection.collapsed( + Position(path: [0, 0], offset: 1), + ); + editorState.selection = selection; + await tester.pumpAndSettle(); + expect(editorState.selection, selection); + await tester.editor.hoverAndClickOptionMenuButton([0]); + expect(editorState.selection, Selection.collapsed(Position(path: [0]))); + }, + ); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart new file mode 100644 index 0000000000000..bd0fd18c50c2a --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_selection_test.dart @@ -0,0 +1,51 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document selection:', () { + testWidgets('select text from start to end by pan gesture ', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(); + + final editor = tester.editor; + final editorState = editor.getCurrentEditorState(); + // insert a paragraph + final transaction = editorState.transaction; + transaction.insertNode( + [0], + paragraphNode( + text: + '''Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.''', + ), + ); + await editorState.apply(transaction); + await tester.pumpAndSettle(Durations.short1); + + final textBlocks = find.byType(AppFlowyRichText); + final topLeft = tester.getTopLeft(textBlocks.at(0)); + + final gesture = await tester.startGesture( + topLeft, + pointer: 7, + ); + await tester.pumpAndSettle(); + + for (var i = 0; i < 10; i++) { + await gesture.moveBy(const Offset(10, 0)); + await tester.pump(Durations.short1); + } + + expect(editorState.selection!.start.offset, 0); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_shortcuts_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_shortcuts_test.dart new file mode 100644 index 0000000000000..cf33a66947922 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_shortcuts_test.dart @@ -0,0 +1,140 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document shortcuts:', () { + testWidgets('custom cut command', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const pageName = 'Test Document Shortcuts'; + await tester.createNewPageWithNameUnderParent(name: pageName); + + // focus on the editor + await tester.tap(find.byType(AppFlowyEditor)); + + // mock the data + final editorState = tester.editor.getCurrentEditorState(); + final transaction = editorState.transaction; + const text1 = '1. First line'; + const text2 = '2. Second line'; + transaction.insertNodes([ + 0, + ], [ + paragraphNode(text: text1), + paragraphNode(text: text2), + ]); + await editorState.apply(transaction); + await tester.pumpAndSettle(); + + // focus on the end of the first line + await tester.editor.updateSelection( + Selection.collapsed( + Position(path: [0], offset: text1.length), + ), + ); + // press the keybinding + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyX, + isControlPressed: !UniversalPlatform.isMacOS, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + // check the clipboard + final clipboard = await Clipboard.getData(Clipboard.kTextPlain); + expect( + clipboard?.text, + equals(text1), + ); + + final node = tester.editor.getNodeAtPath([0]); + expect( + node.delta?.toPlainText(), + equals(text2), + ); + + // select the whole line + await tester.editor.updateSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: text2.length, + ), + ); + + // press the keybinding + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyX, + isControlPressed: !UniversalPlatform.isMacOS, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + // all the text should be deleted + expect( + node.delta?.toPlainText(), + equals(''), + ); + + final clipboard2 = await Clipboard.getData(Clipboard.kTextPlain); + expect( + clipboard2?.text, + equals(text2), + ); + }); + + testWidgets( + 'custom copy command - copy whole line when selection is collapsed', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const pageName = 'Test Document Shortcuts'; + await tester.createNewPageWithNameUnderParent(name: pageName); + + // focus on the editor + await tester.tap(find.byType(AppFlowyEditor)); + + // mock the data + final editorState = tester.editor.getCurrentEditorState(); + final transaction = editorState.transaction; + const text1 = '1. First line'; + transaction.insertNodes([ + 0, + ], [ + paragraphNode(text: text1), + ]); + await editorState.apply(transaction); + await tester.pumpAndSettle(); + + // focus on the end of the first line + await tester.editor.updateSelection( + Selection.collapsed( + Position(path: [0], offset: text1.length), + ), + ); + // press the keybinding + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyC, + isControlPressed: !UniversalPlatform.isMacOS, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + // check the clipboard + final clipboard = await Clipboard.getData(Clipboard.kTextPlain); + expect( + clipboard?.text, + equals(text1), + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_sub_page_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_sub_page_test.dart new file mode 100644 index 0000000000000..e6bdf9e6b66d9 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_sub_page_test.dart @@ -0,0 +1,538 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +// Test cases for the Document SubPageBlock that needs to be covered: +// - [x] Insert a new SubPageBlock from Slash menu items (Expect it will create a child view under current view) +// - [x] Delete a SubPageBlock from Block Action Menu (Expect the view is moved to trash / deleted) +// - [x] Delete a SubPageBlock with backspace when selected (Expect the view is moved to trash / deleted) +// - [x] Copy+paste a SubPageBlock in same Document (Expect a new view is created under current view with same content and name) +// - [x] Copy+paste a SubPageBlock in different Document (Expect a new view is created under current view with same content and name) +// - [x] Cut+paste a SubPageBlock in same Document (Expect the view to be deleted on Cut, and brought back on Paste) +// - [x] Cut+paste a SubPageBlock in different Document (Expect the view to be deleted on Cut, and brought back on Paste) +// - [x] Undo adding a SubPageBlock (Expect the view to be deleted) +// - [x] Undo delete of a SubPageBlock (Expect the view to be brought back to original position) +// - [x] Redo adding a SubPageBlock (Expect the view to be restored) +// - [x] Redo delete of a SubPageBlock (Expect the view to be moved to trash again) +// - [x] Renaming a child view (Expect the view name to be updated in the document) +// - [x] Deleting a view (to trash) linked to a SubPageBlock deleted the SubPageBlock (Expect the SubPageBlock to be deleted) +// - [x] Duplicating a SubPageBlock node from Action Menu (Expect a new view is created under current view with same content and name + (copy)) +// - [x] Dragging a SubPageBlock node to a new position in the document (Expect everything to be normal) + +/// The defaut page name is empty, if we're looking for a "text" we can look for +/// [LocaleKeys.menuAppHeader_defaultNewPageName] but it won't work for eg. hoverOnPageName +/// as it looks at the text provided instead of the actual displayed text. +/// +const _defaultPageName = ""; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Document SubPageBlock tests', () { + testWidgets('Insert a new SubPageBlock from Slash menu items', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); + + await tester.insertSubPageFromSlashMenu(); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock', + layout: ViewLayoutPB.Document, + ); + + expect( + find.text(LocaleKeys.menuAppHeader_defaultNewPageName.tr()), + findsNWidgets(3), + ); + }); + + testWidgets('Rename and then Delete a SubPageBlock from Block Action Menu', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); + + await tester.insertSubPageFromSlashMenu(); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock', + layout: ViewLayoutPB.Document, + ); + await tester.pumpAndSettle(); + + await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); + expect(find.text('Child page'), findsNWidgets(2)); + + await tester.editor.hoverAndClickOptionMenuButton([0]); + + await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); + await tester.pumpAndSettle(); + + expect(find.text('Child page'), findsNothing); + }); + + testWidgets('Copy+paste a SubPageBlock in same Document', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); + + await tester.insertSubPageFromSlashMenu(); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock', + layout: ViewLayoutPB.Document, + ); + + await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); + expect(find.text('Child page'), findsNWidgets(2)); + + await tester.editor.hoverAndClickOptionAddButton([0], false); + await tester.editor.tapLineOfEditorAt(1); + + // This is a workaround to allow CTRL+A and CTRL+C to work to copy + // the SubPageBlock as well. + await tester.ime.insertText('ABC'); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyA, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyC, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + await tester.editor.hoverAndClickOptionAddButton([1], false); + await tester.editor.tapLineOfEditorAt(2); + await tester.pumpAndSettle(); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(const Duration(seconds: 5)); + + expect(find.byType(SubPageBlockComponent), findsNWidgets(2)); + expect(find.text('Child page'), findsNWidgets(2)); + expect(find.text('Child page (copy)'), findsNWidgets(2)); + }); + + testWidgets('Copy+paste a SubPageBlock in different Document', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); + + await tester.insertSubPageFromSlashMenu(); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock', + layout: ViewLayoutPB.Document, + ); + + await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); + expect(find.text('Child page'), findsNWidgets(2)); + + await tester.editor.hoverAndClickOptionAddButton([0], false); + await tester.editor.tapLineOfEditorAt(1); + + // This is a workaround to allow CTRL+A and CTRL+C to work to copy + // the SubPageBlock as well. + await tester.ime.insertText('ABC'); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyA, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyC, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock-2'); + + await tester.editor.tapLineOfEditorAt(0); + await tester.pumpAndSettle(); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock-2', + layout: ViewLayoutPB.Document, + ); + + expect(find.byType(SubPageBlockComponent), findsOneWidget); + expect(find.text('Child page'), findsOneWidget); + expect(find.text('Child page (copy)'), findsNWidgets(2)); + }); + + testWidgets('Cut+paste a SubPageBlock in same Document', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); + + await tester.insertSubPageFromSlashMenu(); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock', + layout: ViewLayoutPB.Document, + ); + + await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); + expect(find.text('Child page'), findsNWidgets(2)); + + await tester.editor + .updateSelection(Selection.single(path: [0], startOffset: 0)); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyX, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(SubPageBlockComponent), findsNothing); + expect(find.text('Child page'), findsNothing); + + await tester.editor.tapLineOfEditorAt(0); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(SubPageBlockComponent), findsOneWidget); + expect(find.text('Child page'), findsNWidgets(2)); + }); + + testWidgets('Cut+paste a SubPageBlock in different Document', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); + + await tester.insertSubPageFromSlashMenu(); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock', + layout: ViewLayoutPB.Document, + ); + + await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); + expect(find.text('Child page'), findsNWidgets(2)); + + await tester.editor + .updateSelection(Selection.single(path: [0], startOffset: 0)); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyX, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(SubPageBlockComponent), findsNothing); + expect(find.text('Child page'), findsNothing); + + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock-2'); + + await tester.editor.tapLineOfEditorAt(0); + await tester.pumpAndSettle(); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock-2', + layout: ViewLayoutPB.Document, + ); + + expect(find.byType(SubPageBlockComponent), findsOneWidget); + expect(find.text('Child page'), findsNWidgets(2)); + expect(find.text('Child page (copy)'), findsNothing); + }); + + testWidgets('Undo delete of a SubPageBlock', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); + + await tester.insertSubPageFromSlashMenu(); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock', + layout: ViewLayoutPB.Document, + ); + + await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); + expect(find.text('Child page'), findsNWidgets(2)); + + await tester.editor.hoverAndClickOptionMenuButton([0]); + await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); + await tester.pumpAndSettle(); + + expect(find.text('Child page'), findsNothing); + expect(find.byType(SubPageBlockComponent), findsNothing); + + // Since there is no selection active in editor before deleting Node, + // we need to give focus back to the editor + await tester.editor + .updateSelection(Selection.collapsed(Position(path: [0]))); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.text('Child page'), findsNWidgets(2)); + expect(find.byType(SubPageBlockComponent), findsOneWidget); + }); + + // Redo: undoing deleting a subpage block, then redoing to delete it again + // -> Add a subpage block + // -> Delete + // -> Undo + // -> Redo + testWidgets('Redo delete of a SubPageBlock', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); + + await tester.insertSubPageFromSlashMenu(true); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock', + layout: ViewLayoutPB.Document, + ); + + await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); + expect(find.text('Child page'), findsNWidgets(2)); + + // Delete + await tester.editor.hoverAndClickOptionMenuButton([1]); + await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); + await tester.pumpAndSettle(); + + expect(find.text('Child page'), findsNothing); + expect(find.byType(SubPageBlockComponent), findsNothing); + + await tester.editor.tapLineOfEditorAt(0); + + // Undo + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + expect(find.byType(SubPageBlockComponent), findsOneWidget); + expect(find.text('Child page'), findsNWidgets(2)); + + // Redo + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isShiftPressed: true, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(SubPageBlockComponent), findsNothing); + expect(find.text('Child page'), findsNothing); + }); + + testWidgets('Delete a view from sidebar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); + + await tester.insertSubPageFromSlashMenu(); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock', + layout: ViewLayoutPB.Document, + ); + + await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); + expect(find.text('Child page'), findsNWidgets(2)); + expect(find.byType(SubPageBlockComponent), findsOneWidget); + + await tester.hoverOnPageName( + 'Child page', + onHover: () async { + await tester.tapDeletePageButton(); + }, + ); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(find.text('Child page'), findsNothing); + expect(find.byType(SubPageBlockComponent), findsNothing); + }); + + testWidgets('Duplicate SubPageBlock from Block Menu', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); + + await tester.insertSubPageFromSlashMenu(); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock', + layout: ViewLayoutPB.Document, + ); + + await tester.renamePageWithSecondary(_defaultPageName, 'Child page'); + expect(find.text('Child page'), findsNWidgets(2)); + + await tester.editor.hoverAndClickOptionMenuButton([0]); + + await tester.tapButtonWithName(LocaleKeys.button_duplicate.tr()); + await tester.pumpAndSettle(); + + expect(find.text('Child page'), findsNWidgets(2)); + expect(find.text('Child page (copy)'), findsNWidgets(2)); + expect(find.byType(SubPageBlockComponent), findsNWidgets(2)); + }); + + testWidgets('Drag SubPageBlock to top of Document', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); + + await tester.insertSubPageFromSlashMenu(true); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock', + layout: ViewLayoutPB.Document, + ); + + expect(find.byType(SubPageBlockComponent), findsOneWidget); + + final beforeNode = tester.editor.getNodeAtPath([1]); + + await tester.editor.dragBlock([1], const Offset(20, -45)); + await tester.pumpAndSettle(Durations.long1); + + final afterNode = tester.editor.getNodeAtPath([0]); + + expect(afterNode.type, SubPageBlockKeys.type); + expect(afterNode.type, beforeNode.type); + expect(find.byType(SubPageBlockComponent), findsOneWidget); + }); + + testWidgets('turn into page', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock'); + + final editorState = tester.editor.getCurrentEditorState(); + + // Insert nested list + final transaction = editorState.transaction; + transaction.insertNode( + [0], + bulletedListNode( + text: 'Parent', + children: [ + bulletedListNode(text: 'Child 1'), + bulletedListNode(text: 'Child 2'), + ], + ), + ); + await editorState.apply(transaction); + await tester.pumpAndSettle(); + + expect(find.byType(SubPageBlockComponent), findsNothing); + + await tester.editor.hoverAndClickOptionMenuButton([0]); + await tester.tapButtonWithName( + LocaleKeys.document_plugins_optionAction_turnInto.tr(), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text(LocaleKeys.editor_page.tr())); + await tester.pumpAndSettle(); + + expect(find.byType(SubPageBlockComponent), findsOneWidget); + + await tester.expandOrCollapsePage( + pageName: 'SubPageBlock', + layout: ViewLayoutPB.Document, + ); + + expect(find.text('Parent'), findsNWidgets(2)); + }); + }); +} + +extension _SubPageTestHelper on WidgetTester { + Future insertSubPageFromSlashMenu([bool withTextNode = false]) async { + await editor.tapLineOfEditorAt(0); + + if (withTextNode) { + await ime.insertText('ABC'); + await editor.getCurrentEditorState().insertNewLine(); + await pumpAndSettle(); + } + + await editor.showSlashMenu(); + await editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_subPage_name.tr(), + offset: 100, + ); + + // Navigate to the previous page to see the SubPageBlock + await openPage('SubPageBlock'); + await pumpAndSettle(); + + await pumpUntilFound(find.byType(SubPageBlockComponent)); + } + + Future renamePageWithSecondary( + String currentName, + String newName, + ) async { + await hoverOnPageName(currentName, onHover: () async => pumpAndSettle()); + await rightClickOnPageName(currentName); + await tapButtonWithName(ViewMoreActionType.rename.name); + await enterText(find.byType(TextFormField), newName); + await tapOKButton(); + await pumpAndSettle(); + } +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_1.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_1.dart new file mode 100644 index 0000000000000..6a4ad5cb62dcc --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_1.dart @@ -0,0 +1,23 @@ +import 'package:integration_test/integration_test.dart'; + +import 'document_create_and_delete_test.dart' + as document_create_and_delete_test; +import 'document_with_cover_image_test.dart' as document_with_cover_image_test; +import 'document_with_database_test.dart' as document_with_database_test; +import 'document_with_inline_math_equation_test.dart' + as document_with_inline_math_equation_test; +import 'document_with_inline_page_test.dart' as document_with_inline_page_test; +import 'edit_document_test.dart' as document_edit_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Document integration tests + document_create_and_delete_test.main(); + document_edit_test.main(); + document_with_database_test.main(); + document_with_inline_page_test.main(); + document_with_inline_math_equation_test.main(); + document_with_cover_image_test.main(); + // Don't add new tests here. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_2.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_2.dart new file mode 100644 index 0000000000000..f32db64aa7a9a --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_2.dart @@ -0,0 +1,26 @@ +import 'package:integration_test/integration_test.dart'; + +import 'document_app_lifecycle_test.dart' as document_app_lifecycle_test; +import 'document_deletion_test.dart' as document_deletion_test; +import 'document_inline_sub_page_test.dart' as document_inline_sub_page_test; +import 'document_option_action_test.dart' as document_option_action_test; +import 'document_title_test.dart' as document_title_test; +import 'document_with_date_reminder_test.dart' + as document_with_date_reminder_test; +import 'document_with_toggle_heading_block_test.dart' + as document_with_toggle_heading_block_test; +import 'document_sub_page_test.dart' as document_sub_page_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Document integration tests + document_title_test.main(); + document_app_lifecycle_test.main(); + document_with_date_reminder_test.main(); + document_deletion_test.main(); + document_option_action_test.main(); + document_inline_sub_page_test.main(); + document_with_toggle_heading_block_test.main(); + document_sub_page_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_3.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_3.dart new file mode 100644 index 0000000000000..cecdaca5806a8 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_3.dart @@ -0,0 +1,22 @@ +import 'package:integration_test/integration_test.dart'; + +import 'document_alignment_test.dart' as document_alignment_test; +import 'document_codeblock_paste_test.dart' as document_codeblock_paste_test; +import 'document_copy_and_paste_test.dart' as document_copy_and_paste_test; +import 'document_text_direction_test.dart' as document_text_direction_test; +import 'document_with_outline_block_test.dart' as document_with_outline_block; +import 'document_with_toggle_list_test.dart' as document_with_toggle_list_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Document integration tests + document_with_outline_block.main(); + document_with_toggle_list_test.main(); + document_copy_and_paste_test.main(); + document_codeblock_paste_test.main(); + document_alignment_test.main(); + document_text_direction_test.main(); + + // Don't add new tests here. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart new file mode 100644 index 0000000000000..a05545753e4bf --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_4.dart @@ -0,0 +1,31 @@ +import 'package:integration_test/integration_test.dart'; + +import 'document_block_option_test.dart' as document_block_option_test; +import 'document_find_menu_test.dart' as document_find_menu_test; +import 'document_inline_page_reference_test.dart' + as document_inline_page_reference_test; +import 'document_more_actions_test.dart' as document_more_actions_test; +import 'document_shortcuts_test.dart' as document_shortcuts_test; +import 'document_toolbar_test.dart' as document_toolbar_test; +import 'document_with_file_test.dart' as document_with_file_test; +import 'document_with_image_block_test.dart' as document_with_image_block_test; +import 'document_with_multi_image_block_test.dart' + as document_with_multi_image_block_test; +import 'document_with_simple_table_test.dart' + as document_with_simple_table_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Document integration tests + document_with_image_block_test.main(); + document_with_multi_image_block_test.main(); + document_inline_page_reference_test.main(); + document_more_actions_test.main(); + document_with_file_test.main(); + document_shortcuts_test.main(); + document_block_option_test.main(); + document_find_menu_test.main(); + document_toolbar_test.main(); + document_with_simple_table_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_text_direction_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_text_direction_test.dart new file mode 100644 index 0000000000000..2a6f730385091 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_text_direction_test.dart @@ -0,0 +1,57 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('text direction', () { + testWidgets( + '''no text direction items will be displayed in the default/LTR mode, and three text direction items will be displayed when toggle is enabled.''', + (tester) async { + // combine the two tests into one to avoid the time-consuming process of initializing the app + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final selection = Selection.single( + path: [0], + startOffset: 0, + endOffset: 1, + ); + // click the first line of the readme + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.updateSelection(selection); + await tester.pumpAndSettle(); + + // because this icons are defined in the appflowy_editor package, we can't fetch the icons by SVG data. [textDirectionItems] + final textDirectionIconNames = [ + 'toolbar/text_direction_auto', + 'toolbar/text_direction_ltr', + 'toolbar/text_direction_rtl', + ]; + // no text direction items by default + var button = find.byWidgetPredicate( + (widget) => + widget is SVGIconItemWidget && + textDirectionIconNames.contains(widget.iconName), + ); + expect(button, findsNothing); + + // switch to the RTL mode + await tester.toggleEnableRTLToolbarItems(); + + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.updateSelection(selection); + await tester.pumpAndSettle(); + + button = find.byWidgetPredicate( + (widget) => + widget is SVGIconItemWidget && + textDirectionIconNames.contains(widget.iconName), + ); + expect(button, findsNWidgets(3)); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_title_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_title_test.dart new file mode 100644 index 0000000000000..c694ba8d6b161 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_title_test.dart @@ -0,0 +1,373 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../shared/constants.dart'; +import '../../shared/util.dart'; + +const _testDocumentName = 'Test Document'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document title:', () { + testWidgets('create a new document and edit title', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(); + + final title = tester.editor.findDocumentTitle(''); + expect(title, findsOneWidget); + + // input name + await tester.enterText(title, _testDocumentName); + await tester.pumpAndSettle(); + + final newTitle = tester.editor.findDocumentTitle(_testDocumentName); + expect(newTitle, findsOneWidget); + + // press enter to create a new line + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + const firstLine = 'First line of text'; + await tester.ime.insertText(firstLine); + await tester.pumpAndSettle(); + + final firstLineText = find.text(firstLine, findRichText: true); + expect(firstLineText, findsOneWidget); + + // press cmd/ctrl+left to move the cursor to the start of the line + if (UniversalPlatform.isMacOS) { + await tester.simulateKeyEvent( + LogicalKeyboardKey.arrowLeft, + isMetaPressed: true, + ); + } else { + await tester.simulateKeyEvent(LogicalKeyboardKey.home); + } + await tester.pumpAndSettle(); + + // press arrow left to delete the first line + await tester.simulateKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + // check if the title is on focus + final titleOnFocus = tester.editor.findDocumentTitle(_testDocumentName); + final titleWidget = tester.widget(titleOnFocus); + expect(titleWidget.focusNode?.hasFocus, isTrue); + + // press the right arrow key to move the cursor to the first line + await tester.simulateKeyEvent(LogicalKeyboardKey.arrowRight); + + // check if the title is not on focus + expect(titleWidget.focusNode?.hasFocus, isFalse); + + final editorState = tester.editor.getCurrentEditorState(); + expect(editorState.selection, Selection.collapsed(Position(path: [0]))); + + // press the backspace key to go to the title + await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); + + expect(editorState.selection, null); + expect(titleWidget.focusNode?.hasFocus, isTrue); + }); + + testWidgets('check if the title is saved', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(); + + final title = tester.editor.findDocumentTitle(''); + expect(title, findsOneWidget); + + // input name + await tester.enterText(title, _testDocumentName); + await tester.pumpAndSettle(); + + if (UniversalPlatform.isLinux) { + // wait for the name to be saved + await tester.wait(250); + } + + // go to the get started page + await tester.tapButton( + tester.findPageName(Constants.gettingStartedPageName), + ); + + // go back to the page + await tester.tapButton(tester.findPageName(_testDocumentName)); + + // check if the title is saved + final testDocumentTitle = tester.editor.findDocumentTitle( + _testDocumentName, + ); + expect(testDocumentTitle, findsOneWidget); + }); + + testWidgets('arrow up from first line moves focus to title', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + + final title = tester.editor.findDocumentTitle(''); + await tester.enterText(title, _testDocumentName); + await tester.pumpAndSettle(); + + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + await tester.ime.insertText('First line of text'); + await tester.pumpAndSettle(); + + await tester.simulateKeyEvent(LogicalKeyboardKey.home); + + // press the arrow upload + await tester.simulateKeyEvent(LogicalKeyboardKey.arrowUp); + + final titleWidget = tester.widget( + tester.editor.findDocumentTitle(_testDocumentName), + ); + expect(titleWidget.focusNode?.hasFocus, isTrue); + + final editorState = tester.editor.getCurrentEditorState(); + expect(editorState.selection, null); + }); + + testWidgets( + 'backspace at start of first line moves focus to title and deletes empty paragraph', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + + final title = tester.editor.findDocumentTitle(''); + await tester.enterText(title, _testDocumentName); + await tester.pumpAndSettle(); + + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + + final editorState = tester.editor.getCurrentEditorState(); + expect(editorState.document.root.children.length, equals(2)); + + await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); + + final titleWidget = tester.widget( + tester.editor.findDocumentTitle(_testDocumentName), + ); + expect(titleWidget.focusNode?.hasFocus, isTrue); + + // at least one empty paragraph node is created + expect(editorState.document.root.children.length, equals(1)); + }); + + testWidgets('arrow right from end of title moves focus to first line', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + + final title = tester.editor.findDocumentTitle(''); + await tester.enterText(title, _testDocumentName); + await tester.pumpAndSettle(); + + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + await tester.ime.insertText('First line of text'); + + await tester.tapButton( + tester.editor.findDocumentTitle(_testDocumentName), + ); + await tester.simulateKeyEvent(LogicalKeyboardKey.end); + await tester.simulateKeyEvent(LogicalKeyboardKey.arrowRight); + + final editorState = tester.editor.getCurrentEditorState(); + expect( + editorState.selection, + Selection.collapsed( + Position(path: [0]), + ), + ); + }); + + testWidgets('change the title via sidebar, check the title is updated', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + + final title = tester.editor.findDocumentTitle(''); + expect(title, findsOneWidget); + + await tester.hoverOnPageName( + '', + onHover: () async { + await tester.renamePage(_testDocumentName); + await tester.pumpAndSettle(); + }, + ); + await tester.pumpAndSettle(); + + final newTitle = tester.editor.findDocumentTitle(_testDocumentName); + expect(newTitle, findsOneWidget); + }); + + testWidgets('execute undo and redo in title', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + + final title = tester.editor.findDocumentTitle(''); + await tester.enterText(title, _testDocumentName); + // press a random key to make the undo stack not empty + await tester.simulateKeyEvent(LogicalKeyboardKey.keyA); + await tester.pumpAndSettle(); + + // undo + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: !UniversalPlatform.isMacOS, + isMetaPressed: UniversalPlatform.isMacOS, + ); + // wait for the undo to be applied + await tester.pumpAndSettle(Durations.long1); + + // expect the title is empty + expect( + tester + .widget( + tester.editor.findDocumentTitle(''), + ) + .controller + ?.text, + '', + ); + + // redo + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: !UniversalPlatform.isMacOS, + isMetaPressed: UniversalPlatform.isMacOS, + isShiftPressed: true, + ); + + await tester.pumpAndSettle(Durations.short1); + + if (UniversalPlatform.isMacOS) { + expect( + tester + .widget( + tester.editor.findDocumentTitle(_testDocumentName), + ) + .controller + ?.text, + _testDocumentName, + ); + } + }); + + testWidgets('escape key should exit the editing mode', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + + final title = tester.editor.findDocumentTitle(''); + await tester.enterText(title, _testDocumentName); + await tester.pumpAndSettle(); + + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect( + tester + .widget( + tester.editor.findDocumentTitle(_testDocumentName), + ) + .focusNode + ?.hasFocus, + isFalse, + ); + }); + + testWidgets('press arrow down key in title, check if the cursor flashes', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + + final title = tester.editor.findDocumentTitle(''); + await tester.enterText(title, _testDocumentName); + await tester.pumpAndSettle(); + + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + const inputText = 'Hello World'; + await tester.ime.insertText(inputText); + + await tester.tapButton( + tester.editor.findDocumentTitle(_testDocumentName), + ); + await tester.simulateKeyEvent(LogicalKeyboardKey.arrowDown); + final editorState = tester.editor.getCurrentEditorState(); + expect( + editorState.selection, + Selection.collapsed( + Position(path: [0], offset: inputText.length), + ), + ); + }); + + testWidgets( + 'hover on the cover title, check if the add icon & add cover button are shown', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + + final title = tester.editor.findDocumentTitle(''); + await tester.hoverOnWidget( + title, + onHover: () async { + expect(find.byType(DocumentCoverWidget), findsOneWidget); + }, + ); + + await tester.pumpAndSettle(); + }); + + testWidgets('paste text in title, check if the text is updated', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + + await Clipboard.setData(const ClipboardData(text: _testDocumentName)); + + final title = tester.editor.findDocumentTitle(''); + await tester.tapButton(title); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isMetaPressed: UniversalPlatform.isMacOS, + isControlPressed: !UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + final newTitle = tester.editor.findDocumentTitle(_testDocumentName); + expect(newTitle, findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart new file mode 100644 index 0000000000000..c3a086626fcdb --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart @@ -0,0 +1,50 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document toolbar:', () { + testWidgets('font family', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + + await tester.editor.tapLineOfEditorAt(0); + const text = 'font family'; + await tester.ime.insertText(text); + await tester.editor.updateSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: text.length, + ), + ); + + // tap the font family button + final fontFamilyButton = find.byKey(kFontFamilyToolbarItemKey); + await tester.tapButton(fontFamilyButton); + + // expect to see the font family dropdown immediately + expect(find.byType(FontFamilyDropDown), findsOneWidget); + + // click the font family 'Abel' + const abel = 'Abel'; + await tester.tapButton(find.text(abel)); + + // check the text is updated to 'Abel' + final editorState = tester.editor.getCurrentEditorState(); + expect( + editorState.getDeltaAttributeValueInSelection( + AppFlowyRichTextKeys.fontFamily, + ), + abel, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_cover_image_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_cover_image_test.dart new file mode 100644 index 0000000000000..c9f844f374d51 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_cover_image_test.dart @@ -0,0 +1,163 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/emoji.dart'; +import '../../shared/util.dart'; + +void main() { + setUpAll(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + RecentIcons.enable = false; + }); + + tearDownAll(() { + RecentIcons.enable = true; + }); + + group('cover image:', () { + testWidgets('document cover tests', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + tester.expectToSeeNoDocumentCover(); + + // Hover over cover toolbar to show 'Add Cover' and 'Add Icon' buttons + await tester.editor.hoverOnCoverToolbar(); + + // Insert a document cover + await tester.editor.tapOnAddCover(); + tester.expectToSeeDocumentCover(CoverType.asset); + + // Hover over the cover to show the 'Change Cover' and delete buttons + await tester.editor.hoverOnCover(); + tester.expectChangeCoverAndDeleteButton(); + + // Change cover to a solid color background + await tester.editor.tapOnChangeCover(); + await tester.editor.switchSolidColorBackground(); + await tester.editor.dismissCoverPicker(); + tester.expectToSeeDocumentCover(CoverType.color); + + // Change cover to a network image + const imageUrl = + "https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy/main/frontend/appflowy_flutter/assets/images/appflowy_launch_splash.jpg"; + await tester.editor.hoverOnCover(); + await tester.editor.tapOnChangeCover(); + await tester.editor.addNetworkImageCover(imageUrl); + tester.expectToSeeDocumentCover(CoverType.file); + + // Remove the cover + await tester.editor.hoverOnCover(); + await tester.editor.tapOnRemoveCover(); + tester.expectToSeeNoDocumentCover(); + }); + + testWidgets('document icon tests', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + tester.expectToSeeDocumentIcon('⭐️'); + + // Insert a document icon + await tester.editor.tapGettingStartedIcon(); + await tester.tapEmoji('😀'); + tester.expectToSeeDocumentIcon('😀'); + + // Remove the document icon from the cover toolbar + await tester.editor.hoverOnCoverToolbar(); + await tester.editor.tapRemoveIconButton(); + tester.expectToSeeDocumentIcon(null); + + // Add the icon back for further testing + await tester.editor.hoverOnCoverToolbar(); + await tester.editor.tapAddIconButton(); + await tester.tapEmoji('😀'); + tester.expectToSeeDocumentIcon('😀'); + + // Change the document icon + await tester.editor.tapOnIconWidget(); + await tester.tapEmoji('😅'); + tester.expectToSeeDocumentIcon('😅'); + + // Remove the document icon from the icon picker + await tester.editor.tapOnIconWidget(); + await tester.editor.tapRemoveIconButton(isInPicker: true); + tester.expectToSeeDocumentIcon(null); + }); + + testWidgets('icon and cover at the same time', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + tester.expectToSeeDocumentIcon('⭐️'); + tester.expectToSeeNoDocumentCover(); + + // Insert a document icon + await tester.editor.tapGettingStartedIcon(); + await tester.tapEmoji('😀'); + + // Insert a document cover + await tester.editor.hoverOnCoverToolbar(); + await tester.editor.tapOnAddCover(); + + // Expect to see the icon and cover at the same time + tester.expectToSeeDocumentIcon('😀'); + tester.expectToSeeDocumentCover(CoverType.asset); + + // Hover over the cover toolbar and see that neither icons are shown + await tester.editor.hoverOnCoverToolbar(); + tester.expectToSeeEmptyDocumentHeaderToolbar(); + }); + + testWidgets('shuffle icon', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.editor.tapGettingStartedIcon(); + + // click the shuffle button + await tester.tapButton( + find.byTooltip(LocaleKeys.emoji_random.tr()), + ); + tester.expectDocumentIconNotNull(); + }); + + testWidgets('change skin tone', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.editor.tapGettingStartedIcon(); + + final searchEmojiTextField = find.byWidgetPredicate( + (widget) => + widget is TextField && + widget.decoration!.hintText == LocaleKeys.search_label.tr(), + ); + await tester.enterText( + searchEmojiTextField, + 'punch', + ); + + // change skin tone + await tester.editor.changeEmojiSkinTone(EmojiSkinTone.dark); + + // select an icon with skin tone + const punch = '👊🏿'; + await tester.tapEmoji(punch); + tester.expectToSeeDocumentIcon(punch); + tester.expectViewHasIcon( + gettingStarted, + ViewLayoutPB.Document, + EmojiIconData.emoji(punch), + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_database_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_database_test.dart new file mode 100644 index 0000000000000..158eb501e3f43 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_database_test.dart @@ -0,0 +1,343 @@ +import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; +import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/footer/grid_footer.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/row/row.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; +import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('database view in document', () { + testWidgets('insert a referenced grid', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await insertLinkedDatabase(tester, ViewLayoutPB.Grid); + + // validate the referenced grid is inserted + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.byType(GridPage), + ), + findsOneWidget, + ); + + // https://github.com/AppFlowy-IO/AppFlowy/issues/3533 + // test: the selection of editor should be clear when editing the grid + await tester.editor.updateSelection( + Selection.collapsed( + Position(path: [1]), + ), + ); + final gridTextCell = find.byType(EditableTextCell).first; + await tester.tapButton(gridTextCell); + + expect(tester.editor.getCurrentEditorState().selection, isNull); + }); + + testWidgets('insert a referenced board', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await insertLinkedDatabase(tester, ViewLayoutPB.Board); + + // validate the referenced board is inserted + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.byType(DesktopBoardPage), + ), + findsOneWidget, + ); + }); + + testWidgets('insert multiple referenced boards', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new grid + final id = uuid(); + final name = '${ViewLayoutPB.Board.name}_$id'; + await tester.createNewPageWithNameUnderParent( + name: name, + layout: ViewLayoutPB.Board, + openAfterCreated: false, + ); + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'insert_a_reference_${ViewLayoutPB.Board.name}', + ); + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + // insert a referenced view + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + ViewLayoutPB.Board.slashMenuLinkedName, + ); + final referencedDatabase1 = find.descendant( + of: find.byType(InlineActionsHandler), + matching: find.findTextInFlowyText(name), + ); + expect(referencedDatabase1, findsOneWidget); + await tester.tapButton(referencedDatabase1); + + await tester.editor.tapLineOfEditorAt(1); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + ViewLayoutPB.Board.slashMenuLinkedName, + ); + final referencedDatabase2 = find.descendant( + of: find.byType(InlineActionsHandler), + matching: find.findTextInFlowyText(name), + ); + expect(referencedDatabase2, findsOneWidget); + await tester.tapButton(referencedDatabase2); + + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.byType(DesktopBoardPage), + ), + findsNWidgets(2), + ); + }); + + testWidgets('insert a referenced calendar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await insertLinkedDatabase(tester, ViewLayoutPB.Calendar); + + // validate the referenced grid is inserted + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.byType(CalendarPage), + ), + findsOneWidget, + ); + }); + + testWidgets('create a grid inside a document', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await createInlineDatabase(tester, ViewLayoutPB.Grid); + + // validate the inline grid is created + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.byType(GridPage), + ), + findsOneWidget, + ); + }); + + testWidgets('create a board inside a document', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await createInlineDatabase(tester, ViewLayoutPB.Board); + + // validate the inline board is created + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.byType(DesktopBoardPage), + ), + findsOneWidget, + ); + }); + + testWidgets('create a calendar inside a document', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await createInlineDatabase(tester, ViewLayoutPB.Calendar); + + // validate the inline calendar is created + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.byType(CalendarPage), + ), + findsOneWidget, + ); + }); + + testWidgets('insert a referenced grid with many rows (load more option)', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await insertLinkedDatabase(tester, ViewLayoutPB.Grid); + + // validate the referenced grid is inserted + expect( + find.descendant( + of: find.byType(AppFlowyEditor), + matching: find.byType(GridPage), + ), + findsOneWidget, + ); + + // https://github.com/AppFlowy-IO/AppFlowy/issues/3533 + // test: the selection of editor should be clear when editing the grid + await tester.editor.updateSelection( + Selection.collapsed( + Position(path: [1]), + ), + ); + final gridTextCell = find.byType(EditableTextCell).first; + await tester.tapButton(gridTextCell); + + expect(tester.editor.getCurrentEditorState().selection, isNull); + + final editorScrollable = find + .descendant( + of: find.byType(AppFlowyEditor), + matching: find.byWidgetPredicate( + (w) => w is Scrollable && w.axis == Axis.vertical, + ), + ) + .first; + + // Add 100 Rows to the linked database + final addRowFinder = find.byType(GridAddRowButton); + for (var i = 0; i < 100; i++) { + await tester.scrollUntilVisible( + addRowFinder, + 100, + scrollable: editorScrollable, + ); + await tester.tapButton(addRowFinder); + await tester.pumpAndSettle(); + } + + // Since all rows visible are those we added, we should see all of them + expect(find.byType(GridRow), findsNWidgets(103)); + + // Navigate to getting started + await tester.openPage(gettingStarted); + + // Navigate back to the document + await tester.openPage('insert_a_reference_${ViewLayoutPB.Grid.name}'); + + // We see only 25 Grid Rows + expect(find.byType(GridRow), findsNWidgets(25)); + + // We see Add row and load more button + expect(find.byType(GridAddRowButton), findsOneWidget); + expect(find.byType(GridRowLoadMoreButton), findsOneWidget); + + // Load more rows, expect 50 visible + await _loadMoreRows(tester, editorScrollable, 50); + + // Load more rows, expect 75 visible + await _loadMoreRows(tester, editorScrollable, 75); + + // Load more rows, expect 100 visible + await _loadMoreRows(tester, editorScrollable, 100); + + // Load more rows, expect 103 visible + await _loadMoreRows(tester, editorScrollable, 103); + + // We no longer see load more option + expect(find.byType(GridRowLoadMoreButton), findsNothing); + }); + }); +} + +Future _loadMoreRows( + WidgetTester tester, + Finder scrollable, [ + int? expectedRows, +]) async { + await tester.scrollUntilVisible( + find.byType(GridRowLoadMoreButton), + 100, + scrollable: scrollable, + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(GridRowLoadMoreButton)); + await tester.pumpAndSettle(); + + if (expectedRows != null) { + expect(find.byType(GridRow), findsNWidgets(expectedRows)); + } +} + +/// Insert a referenced database of [layout] into the document +Future insertLinkedDatabase( + WidgetTester tester, + ViewLayoutPB layout, +) async { + // create a new grid + final id = uuid(); + final name = '${layout.name}_$id'; + await tester.createNewPageWithNameUnderParent( + name: name, + layout: layout, + openAfterCreated: false, + ); + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'insert_a_reference_${layout.name}', + ); + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + // insert a referenced view + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + layout.slashMenuLinkedName, + ); + + final linkToPageMenu = find.byType(InlineActionsHandler); + expect(linkToPageMenu, findsOneWidget); + final referencedDatabase = find.descendant( + of: linkToPageMenu, + matching: find.findTextInFlowyText(name), + ); + expect(referencedDatabase, findsOneWidget); + await tester.tapButton(referencedDatabase); +} + +Future createInlineDatabase( + WidgetTester tester, + ViewLayoutPB layout, +) async { + // create a new document + final documentName = 'insert_a_inline_${layout.name}'; + await tester.createNewPageWithNameUnderParent( + name: documentName, + ); + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + // insert a referenced view + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + layout.slashMenuName, + offset: 100, + ); + await tester.pumpAndSettle(); + + final childViews = tester + .widget(tester.findPageName(documentName)) + .view + .childViews; + expect(childViews.length, 1); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_date_reminder_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_date_reminder_test.dart new file mode 100644 index 0000000000000..ccfdbae76e13c --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_date_reminder_test.dart @@ -0,0 +1,466 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:table_calendar/table_calendar.dart'; + +import '../../shared/util.dart'; + +void main() { + setUp(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('date or reminder block in document:', () { + testWidgets("insert date with time block", (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'Date with time test', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), + ); + + final dateTimeSettings = DateTimeSettingsPB( + dateFormat: UserDateFormatPB.Friendly, + timeFormat: UserTimeFormatPB.TwentyFourHour, + ); + final DateTime currentDateTime = DateTime.now(); + final String formattedDate = + dateTimeSettings.dateFormat.formatDate(currentDateTime, false); + + // get current date in editor + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + + // tap on date field + await tester.tap(find.byType(MentionDateBlock)); + await tester.pumpAndSettle(); + + // tap the toggle of include time + await tester.tap(find.byType(Toggle)); + await tester.pumpAndSettle(); + + // add time 11:12 + final textField = find + .descendant( + of: find.byType(DesktopAppFlowyDatePicker), + matching: find.byType(TextField), + ) + .last; + await tester.pumpUntilFound(textField); + await tester.enterText(textField, "11:12"); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + // we will get field with current date and 11:12 as time + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate 11:12'), findsOneWidget); + }); + + testWidgets("insert date with reminder block", (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'Date with reminder test', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), + ); + + final dateTimeSettings = DateTimeSettingsPB( + dateFormat: UserDateFormatPB.Friendly, + timeFormat: UserTimeFormatPB.TwentyFourHour, + ); + final DateTime currentDateTime = DateTime.now(); + final String formattedDate = + dateTimeSettings.dateFormat.formatDate(currentDateTime, false); + + // get current date in editor + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + + // tap on date field + await tester.tap(find.byType(MentionDateBlock)); + await tester.pumpAndSettle(); + + // tap reminder and set reminder to 1 day before + await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr())); + await tester.pumpAndSettle(); + await tester.tap( + find.textContaining( + LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(), + ), + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.pumpAndSettle(); + + // we will get field with current date reminder_clock.svg icon + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); + }); + + testWidgets("copy, cut and paste a date mention", (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'copy, cut and paste a date mention', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), + ); + + final dateTimeSettings = DateTimeSettingsPB( + dateFormat: UserDateFormatPB.Friendly, + timeFormat: UserTimeFormatPB.TwentyFourHour, + ); + final DateTime currentDateTime = DateTime.now(); + final String formattedDate = + dateTimeSettings.dateFormat.formatDate(currentDateTime, false); + + // get current date in editor + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + + // update selection and copy + await tester.editor.updateSelection( + Selection( + start: Position(path: [0]), + end: Position(path: [0], offset: 1), + ), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyC, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + // update selection and paste + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsNWidgets(2)); + expect(find.text('@$formattedDate'), findsNWidgets(2)); + + // update selection and cut + await tester.editor.updateSelection( + Selection( + start: Position(path: [0], offset: 1), + end: Position(path: [0], offset: 2), + ), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyX, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + + // update selection and paste + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsNWidgets(2)); + expect(find.text('@$formattedDate'), findsNWidgets(2)); + }); + + testWidgets("copy, cut and paste a reminder mention", (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'copy, cut and paste a reminder mention', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), + ); + + // trigger popup + await tester.tapButton(find.byType(MentionDateBlock)); + await tester.pumpAndSettle(); + + // set date to be fifteenth of the next month + await tester.tap( + find.descendant( + of: find.byType(DesktopAppFlowyDatePicker), + matching: find.byFlowySvg(FlowySvgs.arrow_right_s), + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(TableCalendar), + matching: find.text(15.toString()), + ), + ); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + // add a reminder + await tester.tap(find.byType(MentionDateBlock)); + await tester.pumpAndSettle(); + await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr())); + await tester.pumpAndSettle(); + await tester.tap( + find.textContaining( + LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(), + ), + ); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + // verify + final dateTimeSettings = DateTimeSettingsPB( + dateFormat: UserDateFormatPB.Friendly, + timeFormat: UserTimeFormatPB.TwentyFourHour, + ); + final now = DateTime.now(); + final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15); + final formattedDate = + dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false); + + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); + expect(getIt().state.reminders.map((e) => e.id).length, 1); + + // update selection and copy + await tester.editor.updateSelection( + Selection( + start: Position(path: [0]), + end: Position(path: [0], offset: 1), + ), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyC, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + // update selection and paste + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsNWidgets(2)); + expect(find.text('@$formattedDate'), findsNWidgets(2)); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNWidgets(2)); + expect( + getIt().state.reminders.map((e) => e.id).toSet().length, + 2, + ); + + // update selection and cut + await tester.editor.updateSelection( + Selection( + start: Position(path: [0], offset: 1), + end: Position(path: [0], offset: 2), + ), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyX, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); + expect(getIt().state.reminders.map((e) => e.id).length, 1); + + // update selection and paste + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsNWidgets(2)); + expect(find.text('@$formattedDate'), findsNWidgets(2)); + expect(find.byType(MentionDateBlock), findsNWidgets(2)); + expect(find.text('@$formattedDate'), findsNWidgets(2)); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNWidgets(2)); + expect( + getIt().state.reminders.map((e) => e.id).toSet().length, + 2, + ); + }); + + testWidgets("delete, undo and redo a reminder mention", (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'delete, undo and redo a reminder mention', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), + ); + + // trigger popup + await tester.tapButton(find.byType(MentionDateBlock)); + await tester.pumpAndSettle(); + + // set date to be fifteenth of the next month + await tester.tap( + find.descendant( + of: find.byType(DesktopAppFlowyDatePicker), + matching: find.byFlowySvg(FlowySvgs.arrow_right_s), + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(TableCalendar), + matching: find.text(15.toString()), + ), + ); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + // add a reminder + await tester.tap(find.byType(MentionDateBlock)); + await tester.pumpAndSettle(); + await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr())); + await tester.pumpAndSettle(); + await tester.tap( + find.textContaining( + LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(), + ), + ); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + // verify + final dateTimeSettings = DateTimeSettingsPB( + dateFormat: UserDateFormatPB.Friendly, + timeFormat: UserTimeFormatPB.TwentyFourHour, + ); + final now = DateTime.now(); + final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15); + final formattedDate = + dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false); + + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); + expect(getIt().state.reminders.map((e) => e.id).length, 1); + + // update selection and backspace to delete the mention + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsNothing); + expect(find.text('@$formattedDate'), findsNothing); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNothing); + expect(getIt().state.reminders.isEmpty, isTrue); + + // undo + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: Platform.isWindows || Platform.isLinux, + isMetaPressed: Platform.isMacOS, + ); + + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); + expect(getIt().state.reminders.map((e) => e.id).length, 1); + + // redo + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: Platform.isWindows || Platform.isLinux, + isMetaPressed: Platform.isMacOS, + isShiftPressed: true, + ); + + expect(find.byType(MentionDateBlock), findsNothing); + expect(find.text('@$formattedDate'), findsNothing); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNothing); + expect(getIt().state.reminders.isEmpty, isTrue); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_file_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_file_test.dart new file mode 100644 index 0000000000000..9d7a97e6a8a1c --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_file_test.dart @@ -0,0 +1,161 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); + + group('file block in document', () { + testWidgets('insert a file from local file + rename file', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(name: 'Insert file test'); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_file.tr(), + ); + expect(find.byType(FileBlockComponent), findsOneWidget); + expect(find.byType(FileUploadMenu), findsOneWidget); + + final image = await rootBundle.load('assets/test/images/sample.jpeg'); + final tempDirectory = await getTemporaryDirectory(); + final filePath = p.join(tempDirectory.path, 'sample.jpeg'); + final file = File(filePath)..writeAsBytesSync(image.buffer.asUint8List()); + + mockPickFilePaths(paths: [filePath]); + + await getIt().set(KVKeys.kCloudType, '0'); + await tester.tapFileUploadHint(); + await tester.pumpAndSettle(); + + expect(find.byType(FileUploadMenu), findsNothing); + expect(find.byType(FileBlockComponent), findsOneWidget); + + final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(node.type, FileBlockKeys.type); + expect(node.attributes[FileBlockKeys.url], isNotEmpty); + expect( + node.attributes[FileBlockKeys.urlType], + FileUrlType.local.toIntValue(), + ); + + // Check the name of the file is correctly extracted + expect(node.attributes[FileBlockKeys.name], 'sample.jpeg'); + expect(find.text('sample.jpeg'), findsOneWidget); + + const newName = "Renamed file"; + + // Hover on the widget to see the three dots to open FileBlockMenu + await tester.hoverOnWidget( + find.byType(FileBlockComponent), + onHover: () async { + await tester.tap(find.byType(FileMenuTrigger)); + await tester.pumpAndSettle(); + + await tester.tap( + find.text(LocaleKeys.document_plugins_file_renameFile_title.tr()), + ); + }, + ); + await tester.pumpAndSettle(); + + expect(find.byType(FlowyTextField), findsOneWidget); + await tester.enterText(find.byType(FlowyTextField), newName); + await tester.pump(); + + await tester.tap(find.text(LocaleKeys.button_save.tr())); + await tester.pumpAndSettle(); + + final updatedNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(updatedNode.attributes[FileBlockKeys.name], newName); + expect(find.text(newName), findsOneWidget); + + // remove the temp file + file.deleteSync(); + }); + + testWidgets('insert a file from network', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(name: 'Insert file test'); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_file.tr(), + ); + expect(find.byType(FileBlockComponent), findsOneWidget); + expect(find.byType(FileUploadMenu), findsOneWidget); + + // Navigate to integrate link tab + await tester.tapButtonWithName( + LocaleKeys.document_plugins_file_networkTab.tr(), + ); + await tester.pumpAndSettle(); + + const url = + 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640'; + await tester.enterText( + find.descendant( + of: find.byType(FileUploadMenu), + matching: find.byType(FlowyTextField), + ), + url, + ); + await tester.tapButton( + find.descendant( + of: find.byType(FileUploadMenu), + matching: find.text( + LocaleKeys.document_plugins_file_networkAction.tr(), + findRichText: true, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(FileUploadMenu), findsNothing); + expect(find.byType(FileBlockComponent), findsOneWidget); + + final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(node.type, FileBlockKeys.type); + expect(node.attributes[FileBlockKeys.url], isNotEmpty); + expect( + node.attributes[FileBlockKeys.urlType], + FileUrlType.network.toIntValue(), + ); + + // Check the name is correctly extracted from the url + expect( + node.attributes[FileBlockKeys.name], + 'photo-1469474968028-56623f02e42e', + ); + expect(find.text('photo-1469474968028-56623f02e42e'), findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_image_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_image_block_test.dart new file mode 100644 index 0000000000000..2e6d8959c12c5 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_image_block_test.dart @@ -0,0 +1,235 @@ +import 'dart:io'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + hide UploadImageMenu, ResizableImage; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:run_with_network_images/run_with_network_images.dart'; + +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); + + group('image block in document', () { + Future testEmbedImage(WidgetTester tester, String url) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(), + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_image.tr(), + ); + expect(find.byType(CustomImageBlockComponent), findsOneWidget); + expect(find.byType(ImagePlaceholder), findsOneWidget); + expect( + find.descendant( + of: find.byType(ImagePlaceholder), + matching: find.byType(AppFlowyPopover), + ), + findsOneWidget, + ); + expect(find.byType(UploadImageMenu), findsOneWidget); + + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + ); + await tester.enterText( + find.descendant( + of: find.byType(EmbedImageUrlWidget), + matching: find.byType(TextField), + ), + url, + ); + await tester.tapButton( + find.descendant( + of: find.byType(EmbedImageUrlWidget), + matching: find.text( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + findRichText: true, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(ResizableImage), findsOneWidget); + final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], url); + } + + testWidgets('insert an image from local file', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(), + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_image.tr(), + ); + expect(find.byType(CustomImageBlockComponent), findsOneWidget); + expect(find.byType(ImagePlaceholder), findsOneWidget); + expect( + find.descendant( + of: find.byType(ImagePlaceholder), + matching: find.byType(AppFlowyPopover), + ), + findsOneWidget, + ); + expect(find.byType(UploadImageMenu), findsOneWidget); + + final image = await rootBundle.load('assets/test/images/sample.jpeg'); + final tempDirectory = await getTemporaryDirectory(); + final imagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final file = File(imagePath) + ..writeAsBytesSync(image.buffer.asUint8List()); + + mockPickFilePaths( + paths: [imagePath], + ); + + await getIt().set(KVKeys.kCloudType, '0'); + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_upload_placeholder.tr(), + ); + await tester.pumpAndSettle(); + expect(find.byType(ResizableImage), findsOneWidget); + final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], isNotEmpty); + + // remove the temp file + file.deleteSync(); + }); + + testWidgets('insert a gif image from network', (tester) async { + await testEmbedImage( + tester, + 'https://www.easygifanimator.net/images/samples/sparkles.gif', + ); + }); + + testWidgets('insert an image from unsplash', (tester) async { + await runWithNetworkImages(() async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(), + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_image.tr(), + ); + expect(find.byType(CustomImageBlockComponent), findsOneWidget); + expect(find.byType(ImagePlaceholder), findsOneWidget); + expect( + find.descendant( + of: find.byType(ImagePlaceholder), + matching: find.byType(AppFlowyPopover), + ), + findsOneWidget, + ); + expect(find.byType(UploadImageMenu), findsOneWidget); + expect(find.text('Unsplash'), findsOneWidget); + }); + }); + + testWidgets('insert two images from local file at once', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(), + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_image.tr(), + ); + expect(find.byType(CustomImageBlockComponent), findsOneWidget); + expect(find.byType(ImagePlaceholder), findsOneWidget); + expect( + find.descendant( + of: find.byType(ImagePlaceholder), + matching: find.byType(AppFlowyPopover), + ), + findsOneWidget, + ); + expect(find.byType(UploadImageMenu), findsOneWidget); + + final firstImage = + await rootBundle.load('assets/test/images/sample.jpeg'); + final secondImage = + await rootBundle.load('assets/test/images/sample.gif'); + final tempDirectory = await getTemporaryDirectory(); + + final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final firstFile = File(firstImagePath) + ..writeAsBytesSync(firstImage.buffer.asUint8List()); + + final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); + final secondFile = File(secondImagePath) + ..writeAsBytesSync(secondImage.buffer.asUint8List()); + + mockPickFilePaths(paths: [firstImagePath, secondImagePath]); + + await getIt().set(KVKeys.kCloudType, '0'); + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_upload_placeholder.tr(), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ResizableImage), findsNWidgets(2)); + + final firstNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(firstNode.type, ImageBlockKeys.type); + expect(firstNode.attributes[ImageBlockKeys.url], isNotEmpty); + + final secondNode = + tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(secondNode.type, ImageBlockKeys.type); + expect(secondNode.attributes[ImageBlockKeys.url], isNotEmpty); + + // remove the temp files + await Future.wait([firstFile.delete(), secondFile.delete()]); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart new file mode 100644 index 0000000000000..45f688bd58174 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test.dart @@ -0,0 +1,167 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); + + group('inline math equation in document', () { + testWidgets('insert an inline math equation', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'math equation', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + // insert a inline page + const formula = 'E = MC ^ 2'; + await tester.ime.insertText(formula); + await tester.editor.updateSelection( + Selection.single(path: [0], startOffset: 0, endOffset: formula.length), + ); + + // tap the inline math equation button + final inlineMathEquationButton = find.findFlowyTooltip( + LocaleKeys.document_plugins_createInlineMathEquation.tr(), + ); + await tester.tapButton(inlineMathEquationButton); + + // expect to see the math equation block + final inlineMathEquation = find.byType(InlineMathEquation); + expect(inlineMathEquation, findsOneWidget); + + // tap it and update the content + await tester.tapButton(inlineMathEquation); + final textFormField = find.descendant( + of: find.byType(MathInputTextField), + matching: find.byType(TextFormField), + ); + const newFormula = 'E = MC ^ 3'; + await tester.enterText(textFormField, newFormula); + await tester.tapButton( + find.descendant( + of: find.byType(MathInputTextField), + matching: find.byType(FlowyButton), + ), + ); + await tester.pumpAndSettle(); + }); + + testWidgets('remove the inline math equation format', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'math equation', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + // insert a inline page + const formula = 'E = MC ^ 2'; + await tester.ime.insertText(formula); + await tester.editor.updateSelection( + Selection.single(path: [0], startOffset: 0, endOffset: formula.length), + ); + + // tap the inline math equation button + var inlineMathEquationButton = find.findFlowyTooltip( + LocaleKeys.document_plugins_createInlineMathEquation.tr(), + ); + await tester.tapButton(inlineMathEquationButton); + + // expect to see the math equation block + var inlineMathEquation = find.byType(InlineMathEquation); + expect(inlineMathEquation, findsOneWidget); + + // highlight the math equation block + await tester.editor.updateSelection( + Selection.single(path: [0], startOffset: 0, endOffset: 1), + ); + + // expect to the see the inline math equation button is highlighted + inlineMathEquationButton = find.descendant( + of: find.findFlowyTooltip( + LocaleKeys.document_plugins_createInlineMathEquation.tr(), + ), + matching: find.byType(SVGIconItemWidget), + ); + expect( + tester.widget(inlineMathEquationButton).isHighlight, + isTrue, + ); + + // cancel the format + await tester.tapButton(inlineMathEquationButton); + + // expect to see the math equation block is removed + inlineMathEquation = find.byType(InlineMathEquation); + expect(inlineMathEquation, findsNothing); + + tester.expectToSeeText(formula); + }); + + testWidgets('insert a inline math equation and type something after it', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'math equation', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + // insert a inline page + const formula = 'E = MC ^ 2'; + await tester.ime.insertText(formula); + await tester.editor.updateSelection( + Selection.single(path: [0], startOffset: 0, endOffset: formula.length), + ); + + // tap the inline math equation button + final inlineMathEquationButton = find.findFlowyTooltip( + LocaleKeys.document_plugins_createInlineMathEquation.tr(), + ); + await tester.tapButton(inlineMathEquationButton); + + // expect to see the math equation block + final inlineMathEquation = find.byType(InlineMathEquation); + expect(inlineMathEquation, findsOneWidget); + + await tester.editor.tapLineOfEditorAt(0); + const text = 'Hello World'; + await tester.ime.insertText(text); + + final inlineText = find.textContaining(text, findRichText: true); + expect(inlineText, findsOneWidget); + + // the text should be in the same line with the math equation + final inlineMathEquationPosition = tester.getRect(inlineMathEquation); + final textPosition = tester.getRect(inlineText); + // allow 5px difference + expect( + (textPosition.top - inlineMathEquationPosition.top).abs(), + lessThan(5), + ); + expect( + (textPosition.bottom - inlineMathEquationPosition.bottom).abs(), + lessThan(5), + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test_1.png b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test_1.png new file mode 100644 index 0000000000000..8795110cf95d9 Binary files /dev/null and b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test_1.png differ diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test_2.png b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test_2.png new file mode 100644 index 0000000000000..de3d4f6113f2a Binary files /dev/null and b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_math_equation_test_2.png differ diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_page_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_page_test.dart new file mode 100644 index 0000000000000..12047bd37f0e2 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_inline_page_test.dart @@ -0,0 +1,141 @@ +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('inline page view in document', () { + testWidgets('insert a inline page - grid', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await insertInlinePage(tester, ViewLayoutPB.Grid); + + final mentionBlock = find.byType(MentionPageBlock); + expect(mentionBlock, findsOneWidget); + await tester.tapButton(mentionBlock); + }); + + testWidgets('insert a inline page - board', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await insertInlinePage(tester, ViewLayoutPB.Board); + + final mentionBlock = find.byType(MentionPageBlock); + expect(mentionBlock, findsOneWidget); + await tester.tapButton(mentionBlock); + }); + + testWidgets('insert a inline page - calendar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await insertInlinePage(tester, ViewLayoutPB.Calendar); + + final mentionBlock = find.byType(MentionPageBlock); + expect(mentionBlock, findsOneWidget); + await tester.tapButton(mentionBlock); + }); + + testWidgets('insert a inline page - document', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await insertInlinePage(tester, ViewLayoutPB.Document); + + final mentionBlock = find.byType(MentionPageBlock); + expect(mentionBlock, findsOneWidget); + await tester.tapButton(mentionBlock); + }); + + testWidgets('insert a inline page and rename it', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final pageName = await insertInlinePage(tester, ViewLayoutPB.Document); + + // rename + const newName = 'RenameToNewPageName'; + await tester.hoverOnPageName( + pageName, + onHover: () async => tester.renamePage(newName), + ); + final finder = find.descendant( + of: find.byType(MentionPageBlock), + matching: find.findTextInFlowyText(newName), + ); + expect(finder, findsOneWidget); + }); + + testWidgets('insert a inline page and delete it', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final pageName = await insertInlinePage(tester, ViewLayoutPB.Grid); + + // rename + await tester.hoverOnPageName( + pageName, + layout: ViewLayoutPB.Grid, + onHover: () async => tester.tapDeletePageButton(), + ); + final finder = find.descendant( + of: find.byType(MentionPageBlock), + matching: find.findTextInFlowyText(pageName), + ); + expect(finder, findsOneWidget); + await tester.tapButton(finder); + expect(find.byType(GridPage), findsOneWidget); + }); + + testWidgets('insert a inline page and type something after the page', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await insertInlinePage(tester, ViewLayoutPB.Grid); + + await tester.editor.tapLineOfEditorAt(0); + const text = 'Hello World'; + await tester.ime.insertText(text); + + expect(find.textContaining(text, findRichText: true), findsOneWidget); + }); + }); +} + +/// Insert a referenced database of [layout] into the document +Future insertInlinePage( + WidgetTester tester, + ViewLayoutPB layout, +) async { + // create a new grid + final id = uuid(); + final name = '${layout.name}_$id'; + await tester.createNewPageWithNameUnderParent( + name: name, + layout: layout, + openAfterCreated: false, + ); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'insert_a_inline_page_${layout.name}', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + + // insert a inline page + await tester.editor.showAtMenu(); + await tester.editor.tapAtMenuItemWithName(name); + + return name; +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_link_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_link_test.dart new file mode 100644 index 0000000000000..c369af9002488 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_link_test.dart @@ -0,0 +1,82 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('test editing link in document', () { + late MockUrlLauncher mock; + + setUp(() { + mock = MockUrlLauncher(); + UrlLauncherPlatform.instance = mock; + }); + + testWidgets('insert/edit/open link', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + // insert a inline page + const link = 'AppFlowy'; + await tester.ime.insertText(link); + await tester.editor.updateSelection( + Selection.single(path: [0], startOffset: 0, endOffset: link.length), + ); + + // tap the link button + final linkButton = find.byTooltip( + 'Link', + ); + await tester.tapButton(linkButton); + expect(find.text('Add your link', findRichText: true), findsOneWidget); + + // input the link + const url = 'https://appflowy.io'; + final textField = find.byWidgetPredicate( + (widget) => widget is TextField && widget.decoration!.hintText == 'URL', + ); + await tester.enterText(textField, url); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + // single-click the link menu to show the menu + await tester.tapButton(find.text(link, findRichText: true)); + expect(find.text('Open link', findRichText: true), findsOneWidget); + expect(find.text('Copy link', findRichText: true), findsOneWidget); + expect(find.text('Remove link', findRichText: true), findsOneWidget); + + // double-click the link menu to open the link + mock + ..setLaunchExpectations( + url: url, + useSafariVC: false, + useWebView: false, + universalLinksOnly: false, + enableJavaScript: true, + enableDomStorage: true, + headers: {}, + webOnlyWindowName: null, + launchMode: PreferredLaunchMode.platformDefault, + ) + ..setResponse(true); + + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.doubleTapAt( + tester.getTopLeft(find.text(link, findRichText: true)).translate(5, 5), + ); + expect(mock.canLaunchCalled, isTrue); + expect(mock.launchCalled, isTrue); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart new file mode 100644 index 0000000000000..68ad7db7e5514 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_multi_image_block_test.dart @@ -0,0 +1,291 @@ +import 'dart:io'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; + +void main() { + setUp(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('multi image block in document', () { + testWidgets('insert images from local and use interactive viewer', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'multi image block test', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_photoGallery.tr(), + offset: 80, + ); + expect(find.byType(MultiImageBlockComponent), findsOneWidget); + expect(find.byType(MultiImagePlaceholder), findsOneWidget); + + await tester.tap(find.byType(MultiImagePlaceholder)); + await tester.pumpAndSettle(); + + expect(find.byType(UploadImageMenu), findsOneWidget); + + final firstImage = + await rootBundle.load('assets/test/images/sample.jpeg'); + final secondImage = + await rootBundle.load('assets/test/images/sample.gif'); + final tempDirectory = await getTemporaryDirectory(); + + final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final firstFile = File(firstImagePath) + ..writeAsBytesSync(firstImage.buffer.asUint8List()); + + final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); + final secondFile = File(secondImagePath) + ..writeAsBytesSync(secondImage.buffer.asUint8List()); + + mockPickFilePaths(paths: [firstImagePath, secondImagePath]); + + await getIt().set(KVKeys.kCloudType, '0'); + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_upload_placeholder.tr(), + ); + await tester.pumpAndSettle(); + expect(find.byType(ImageBrowserLayout), findsOneWidget); + final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(node.type, MultiImageBlockKeys.type); + + final data = MultiImageData.fromJson( + node.attributes[MultiImageBlockKeys.images], + ); + + expect(data.images.length, 2); + + // Start using the interactive viewer to view the image(s) + final imageFinder = find + .byWidgetPredicate( + (w) => + w is Image && + w.image is FileImage && + (w.image as FileImage).file.path.endsWith('.jpeg'), + ) + .first; + await tester.tap(imageFinder); + await tester.pump(kDoubleTapMinTime); + await tester.tap(imageFinder); + await tester.pumpAndSettle(); + + final ivFinder = find.byType(InteractiveImageViewer); + expect(ivFinder, findsOneWidget); + + // go to next image + await tester.tap(find.byFlowySvg(FlowySvgs.arrow_right_s)); + await tester.pumpAndSettle(); + + // Expect image to end with .gif + final gifImageFinder = find.byWidgetPredicate( + (w) => + w is Image && + w.image is FileImage && + (w.image as FileImage).file.path.endsWith('.gif'), + ); + + gifImageFinder.evaluate(); + expect(gifImageFinder.found.length, 2); + + // go to previous image + await tester.tap(find.byFlowySvg(FlowySvgs.arrow_left_s)); + await tester.pumpAndSettle(); + + gifImageFinder.evaluate(); + expect(gifImageFinder.found.length, 1); + + // remove the temp files + await Future.wait([firstFile.delete(), secondFile.delete()]); + }); + + testWidgets('insert and delete images from network', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'multi image block test', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_photoGallery.tr(), + offset: 80, + ); + expect(find.byType(MultiImageBlockComponent), findsOneWidget); + expect(find.byType(MultiImagePlaceholder), findsOneWidget); + + await tester.tap(find.byType(MultiImagePlaceholder)); + await tester.pumpAndSettle(); + + expect(find.byType(UploadImageMenu), findsOneWidget); + + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + ); + + const url = + 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640'; + await tester.enterText( + find.descendant( + of: find.byType(EmbedImageUrlWidget), + matching: find.byType(TextField), + ), + url, + ); + await tester.pumpAndSettle(); + + await tester.tapButton( + find.descendant( + of: find.byType(EmbedImageUrlWidget), + matching: find.text( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + findRichText: true, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(ImageBrowserLayout), findsOneWidget); + final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!; + expect(node.type, MultiImageBlockKeys.type); + + final data = MultiImageData.fromJson( + node.attributes[MultiImageBlockKeys.images], + ); + + expect(data.images.length, 1); + + final imageFinder = find + .byWidgetPredicate( + (w) => w is FlowyNetworkImage && w.url == url, + ) + .first; + + // Insert two images from network + for (int i = 0; i < 2; i++) { + // Hover on the image to show the image toolbar + await tester.hoverOnWidget( + imageFinder, + onHover: () async { + // Click on the add + final addFinder = find.descendant( + of: find.byType(MultiImageMenu), + matching: find.byFlowySvg(FlowySvgs.add_s), + ); + + expect(addFinder, findsOneWidget); + await tester.tap(addFinder); + await tester.pumpAndSettle(); + + await tester.tapButtonWithName( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + ); + + await tester.enterText( + find.descendant( + of: find.byType(EmbedImageUrlWidget), + matching: find.byType(TextField), + ), + url, + ); + await tester.pumpAndSettle(); + + await tester.tapButton( + find.descendant( + of: find.byType(EmbedImageUrlWidget), + matching: find.text( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + findRichText: true, + ), + ), + ); + await tester.pumpAndSettle(); + }, + ); + } + + await tester.pumpAndSettle(); + + // There should be 4 images visible now, where 2 are thumbnails + expect(find.byType(ThumbnailItem), findsNWidgets(3)); + + // And all three use ImageRender + expect(find.byType(ImageRender), findsNWidgets(4)); + + // Hover on and delete the first thumbnail image + await tester.hoverOnWidget(find.byType(ThumbnailItem).first); + + final deleteFinder = find + .descendant( + of: find.byType(ThumbnailItem), + matching: find.byFlowySvg(FlowySvgs.delete_s), + ) + .first; + + expect(deleteFinder, findsOneWidget); + await tester.tap(deleteFinder); + await tester.pumpAndSettle(); + + expect(find.byType(ImageRender), findsNWidgets(3)); + + // Delete one from interactive viewer + await tester.tap(imageFinder); + await tester.pump(kDoubleTapMinTime); + await tester.tap(imageFinder); + await tester.pumpAndSettle(); + + final ivFinder = find.byType(InteractiveImageViewer); + expect(ivFinder, findsOneWidget); + + await tester.tap( + find.descendant( + of: find.byType(InteractiveImageToolbar), + matching: find.byFlowySvg(FlowySvgs.delete_s), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(InteractiveImageViewer), findsNothing); + + // There should be 1 image and the thumbnail for said image still visible + expect(find.byType(ImageRender), findsNWidgets(2)); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_outline_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_outline_block_test.dart new file mode 100644 index 0000000000000..2f3f8c80b910b --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_outline_block_test.dart @@ -0,0 +1,209 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +const String heading1 = "Heading 1"; +const String heading2 = "Heading 2"; +const String heading3 = "Heading 3"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('outline block test', () { + testWidgets('insert an outline block', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent( + name: 'outline_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await insertOutlineInDocument(tester); + + // validate the outline is inserted + expect(find.byType(OutlineBlockWidget), findsOneWidget); + }); + + testWidgets('insert an outline block and check if headings are visible', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent( + name: 'outline_test', + ); + + await insertHeadingComponent(tester); + /* Results in: + * # Heading 1 + * ## Heading 2 + * ### Heading 3 + * > # Heading 1 + * > ## Heading 2 + * > ### Heading 3 + */ + + await tester.editor.tapLineOfEditorAt(3); + await insertOutlineInDocument(tester); + + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading1), + ), + findsNWidgets(2), + ); + + // Heading 2 is prefixed with a bullet + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading2), + ), + findsNWidgets(2), + ); + + // Heading 3 is prefixed with a dash + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading3), + ), + findsNWidgets(2), + ); + + // update the Heading 1 to Heading 1Hello world + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText('Hello world'); + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text('${heading1}Hello world'), + ), + findsOneWidget, + ); + }); + + testWidgets("control the depth of outline block", (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent( + name: 'outline_test', + ); + + await insertHeadingComponent(tester); + /* Results in: + * # Heading 1 + * ## Heading 2 + * ### Heading 3 + * > # Heading 1 + * > ## Heading 2 + * > ### Heading 3 + */ + + await tester.editor.tapLineOfEditorAt(7); + await insertOutlineInDocument(tester); + + // expect to find only the `heading1` widget under the [OutlineBlockWidget] + await hoverAndClickDepthOptionAction(tester, [6], 1); + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading2), + ), + findsNothing, + ); + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading3), + ), + findsNothing, + ); + ////// + + /// expect to find only the 'heading1' and 'heading2' under the [OutlineBlockWidget] + await hoverAndClickDepthOptionAction(tester, [6], 2); + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading3), + ), + findsNothing, + ); + ////// + + // expect to find all the headings under the [OutlineBlockWidget] + await hoverAndClickDepthOptionAction(tester, [6], 3); + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading1), + ), + findsNWidgets(2), + ); + + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading2), + ), + findsNWidgets(2), + ); + + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading3), + ), + findsNWidgets(2), + ); + ////// + }); + }); +} + +/// Inserts an outline block in the document +Future insertOutlineInDocument(WidgetTester tester) async { + // open the actions menu and insert the outline block + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_outline.tr(), + ); + await tester.pumpAndSettle(); +} + +Future hoverAndClickDepthOptionAction( + WidgetTester tester, + List path, + int level, +) async { + await tester.editor.openDepthMenu(path); + final type = OptionDepthType.fromLevel(level); + await tester.tapButton(find.findTextInFlowyText(type.description)); + await tester.pumpAndSettle(); +} + +Future insertHeadingComponent(WidgetTester tester) async { + await tester.editor.tapLineOfEditorAt(0); + + // # heading 1-3 + await tester.ime.insertText('# $heading1\n'); + await tester.ime.insertText('## $heading2\n'); + await tester.ime.insertText('### $heading3\n'); + + // > # toggle heading 1-3 + await tester.ime.insertText('> # $heading1\n'); + await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); + await tester.ime.insertText('> ## $heading2\n'); + await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); + await tester.ime.insertText('> ### $heading3\n'); + await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_simple_table_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_simple_table_test.dart new file mode 100644 index 0000000000000..bcf3fde24f421 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_simple_table_test.dart @@ -0,0 +1,783 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../shared/util.dart'; + +const String heading1 = "Heading 1"; +const String heading2 = "Heading 2"; +const String heading3 = "Heading 3"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('simple table block test:', () { + testWidgets('insert a simple table block', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + // validate the table is inserted + expect(find.byType(SimpleTableBlockWidget), findsOneWidget); + + final editorState = tester.editor.getCurrentEditorState(); + expect( + editorState.selection, + // table -> row -> cell -> paragraph + Selection.collapsed(Position(path: [0, 0, 0, 0])), + ); + + final firstCell = find.byType(SimpleTableCellBlockWidget).first; + expect( + tester + .state(firstCell) + .isEditingCellNotifier + .value, + isTrue, + ); + }); + + testWidgets('select all in table cell', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + const cell1Content = 'Cell 1'; + + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText('New Table'); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + await tester.editor.tapLineOfEditorAt(1); + await tester.insertTableInDocument(); + await tester.ime.insertText(cell1Content); + await tester.pumpAndSettle(); + // Select all in the cell + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyA, + isControlPressed: !UniversalPlatform.isMacOS, + isMetaPressed: UniversalPlatform.isMacOS, + ); + + expect( + tester.editor.getCurrentEditorState().selection, + Selection( + start: Position(path: [1, 0, 0, 0]), + end: Position(path: [1, 0, 0, 0], offset: cell1Content.length), + ), + ); + + // Press select all again, the selection should be the entire document + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyA, + isControlPressed: !UniversalPlatform.isMacOS, + isMetaPressed: UniversalPlatform.isMacOS, + ); + + expect( + tester.editor.getCurrentEditorState().selection, + Selection( + start: Position(path: [0]), + end: Position(path: [1, 1, 1, 0]), + ), + ); + }); + + testWidgets(''' +1. hover on the table + 1.1 click the add row button + 1.2 click the add column button + 1.3 click the add row and column button +2. validate the table is updated +3. delete the last column +4. delete the last row +5. validate the table is updated +''', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + // add a new row + final row = find.byWidgetPredicate((w) { + return w is SimpleTableRowBlockWidget && w.node.rowIndex == 1; + }); + await tester.hoverOnWidget( + row, + onHover: () async { + final addRowButton = find.byType(SimpleTableAddRowButton).first; + await tester.tap(addRowButton); + }, + ); + await tester.pumpAndSettle(); + + // add a new column + final column = find.byWidgetPredicate((w) { + return w is SimpleTableCellBlockWidget && w.node.columnIndex == 1; + }).first; + await tester.hoverOnWidget( + column, + onHover: () async { + final addColumnButton = find.byType(SimpleTableAddColumnButton).first; + await tester.tap(addColumnButton); + }, + ); + await tester.pumpAndSettle(); + + // add a new row and a new column + final row2 = find.byWidgetPredicate((w) { + return w is SimpleTableCellBlockWidget && + w.node.rowIndex == 2 && + w.node.columnIndex == 2; + }).first; + await tester.hoverOnWidget( + row2, + onHover: () async { + // click the add row and column button + final addRowAndColumnButton = + find.byType(SimpleTableAddColumnAndRowButton).first; + await tester.tap(addRowAndColumnButton); + }, + ); + await tester.pumpAndSettle(); + + final tableNode = + tester.editor.getCurrentEditorState().document.nodeAtPath([0])!; + expect(tableNode.columnLength, 4); + expect(tableNode.rowLength, 4); + + // delete the last row + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.row, + index: tableNode.rowLength - 1, + action: SimpleTableMoreAction.delete, + ); + await tester.pumpAndSettle(); + expect(tableNode.rowLength, 3); + expect(tableNode.columnLength, 4); + + // delete the last column + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.column, + index: tableNode.columnLength - 1, + action: SimpleTableMoreAction.delete, + ); + await tester.pumpAndSettle(); + + expect(tableNode.columnLength, 3); + expect(tableNode.rowLength, 3); + }); + + testWidgets('enable header column and header row', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + // enable the header row + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.row, + index: 0, + action: SimpleTableMoreAction.enableHeaderRow, + ); + await tester.pumpAndSettle(); + // enable the header column + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.column, + index: 0, + action: SimpleTableMoreAction.enableHeaderColumn, + ); + await tester.pumpAndSettle(); + + final tableNode = + tester.editor.getCurrentEditorState().document.nodeAtPath([0])!; + + expect(tableNode.isHeaderColumnEnabled, isTrue); + expect(tableNode.isHeaderRowEnabled, isTrue); + + // disable the header row + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.row, + index: 0, + action: SimpleTableMoreAction.enableHeaderRow, + ); + await tester.pumpAndSettle(); + expect(tableNode.isHeaderColumnEnabled, isTrue); + expect(tableNode.isHeaderRowEnabled, isFalse); + + // disable the header column + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.column, + index: 0, + action: SimpleTableMoreAction.enableHeaderColumn, + ); + await tester.pumpAndSettle(); + expect(tableNode.isHeaderColumnEnabled, isFalse); + expect(tableNode.isHeaderRowEnabled, isFalse); + }); + + testWidgets('duplicate a column / row', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + // duplicate the row + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.row, + index: 0, + action: SimpleTableMoreAction.duplicate, + ); + await tester.pumpAndSettle(); + + // duplicate the column + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.column, + index: 0, + action: SimpleTableMoreAction.duplicate, + ); + await tester.pumpAndSettle(); + + final tableNode = + tester.editor.getCurrentEditorState().document.nodeAtPath([0])!; + expect(tableNode.columnLength, 3); + expect(tableNode.rowLength, 3); + }); + + testWidgets('insert left / insert right', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + // insert left + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.column, + index: 0, + action: SimpleTableMoreAction.insertLeft, + ); + await tester.pumpAndSettle(); + + // insert right + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.column, + index: 0, + action: SimpleTableMoreAction.insertRight, + ); + await tester.pumpAndSettle(); + + final tableNode = + tester.editor.getCurrentEditorState().document.nodeAtPath([0])!; + expect(tableNode.columnLength, 4); + expect(tableNode.rowLength, 2); + }); + + testWidgets('insert above / insert below', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + // insert above + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.row, + index: 0, + action: SimpleTableMoreAction.insertAbove, + ); + await tester.pumpAndSettle(); + + // insert below + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.row, + index: 0, + action: SimpleTableMoreAction.insertBelow, + ); + await tester.pumpAndSettle(); + + final tableNode = + tester.editor.getCurrentEditorState().document.nodeAtPath([0])!; + expect(tableNode.rowLength, 4); + expect(tableNode.columnLength, 2); + }); + }); + + testWidgets('set column width to page width (1)', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + final tableNode = tester.editor.getNodeAtPath([0]); + final beforeWidth = tableNode.width; + + // set the column width to page width + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.column, + index: 0, + action: SimpleTableMoreAction.setToPageWidth, + ); + await tester.pumpAndSettle(); + + final afterWidth = tableNode.width; + expect(afterWidth, greaterThan(beforeWidth)); + }); + + testWidgets('set column width to page width (2)', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + final tableNode = tester.editor.getNodeAtPath([0]); + final beforeWidth = tableNode.width; + + // set the column width to page width + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.row, + index: 0, + action: SimpleTableMoreAction.setToPageWidth, + ); + await tester.pumpAndSettle(); + + final afterWidth = tableNode.width; + expect(afterWidth, greaterThan(beforeWidth)); + }); + + testWidgets('distribute columns evenly (1)', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + final tableNode = tester.editor.getNodeAtPath([0]); + final beforeWidth = tableNode.width; + + // set the column width to page width + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.row, + index: 0, + action: SimpleTableMoreAction.distributeColumnsEvenly, + ); + await tester.pumpAndSettle(); + + final afterWidth = tableNode.width; + expect(afterWidth, equals(beforeWidth)); + + final distributeColumnWidthsEvenly = + tableNode.attributes[SimpleTableBlockKeys.distributeColumnWidthsEvenly]; + expect(distributeColumnWidthsEvenly, isTrue); + }); + + testWidgets('distribute columns evenly (2)', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + final tableNode = tester.editor.getNodeAtPath([0]); + final beforeWidth = tableNode.width; + + // set the column width to page width + await tester.clickMoreActionItemInTableMenu( + type: SimpleTableMoreActionType.column, + index: 0, + action: SimpleTableMoreAction.distributeColumnsEvenly, + ); + await tester.pumpAndSettle(); + + final afterWidth = tableNode.width; + expect(afterWidth, equals(beforeWidth)); + + final distributeColumnWidthsEvenly = + tableNode.attributes[SimpleTableBlockKeys.distributeColumnWidthsEvenly]; + expect(distributeColumnWidthsEvenly, isTrue); + }); + + testWidgets('using option menu to set column width', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + await tester.editor.hoverAndClickOptionMenuButton([0]); + + final editorState = tester.editor.getCurrentEditorState(); + final beforeWidth = editorState.document.nodeAtPath([0])!.width; + + await tester.tapButton( + find.text( + LocaleKeys.document_plugins_simpleTable_moreActions_setToPageWidth.tr(), + ), + ); + await tester.pumpAndSettle(); + + final afterWidth = editorState.document.nodeAtPath([0])!.width; + expect(afterWidth, greaterThan(beforeWidth)); + + await tester.editor.hoverAndClickOptionMenuButton([0]); + await tester.tapButton( + find.text( + LocaleKeys + .document_plugins_simpleTable_moreActions_distributeColumnsWidth + .tr(), + ), + ); + await tester.pumpAndSettle(); + + final afterWidth2 = editorState.document.nodeAtPath([0])!.width; + expect(afterWidth2, equals(afterWidth)); + }); + + testWidgets('insert a table and use select all the delete it', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + await tester.editor.tapLineOfEditorAt(1); + await tester.ime.insertText('Hello World'); + + // select all + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyA, + isMetaPressed: UniversalPlatform.isMacOS, + isControlPressed: !UniversalPlatform.isMacOS, + ); + + await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); + await tester.pumpAndSettle(); + + final editorState = tester.editor.getCurrentEditorState(); + // only one paragraph left + expect(editorState.document.root.children.length, 1); + final paragraphNode = editorState.document.nodeAtPath([0])!; + expect(paragraphNode.delta, isNull); + }); + + testWidgets('use tab or shift+tab to navigate in table', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + await tester.simulateKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + final editorState = tester.editor.getCurrentEditorState(); + final selection = editorState.selection; + expect(selection, isNotNull); + expect(selection!.start.path, [0, 0, 1, 0]); + expect(selection.end.path, [0, 0, 1, 0]); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.tab, + isShiftPressed: true, + ); + await tester.pumpAndSettle(); + + final selection2 = editorState.selection; + expect(selection2, isNotNull); + expect(selection2!.start.path, [0, 0, 0, 0]); + expect(selection2.end.path, [0, 0, 0, 0]); + }); + + testWidgets('shift+enter to insert a new line in table', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.enter, + isShiftPressed: true, + ); + await tester.pumpAndSettle(); + + final editorState = tester.editor.getCurrentEditorState(); + final node = editorState.document.nodeAtPath([0, 0, 0])!; + expect(node.children.length, 1); + }); + + testWidgets('using option menu to set table align', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + await tester.editor.hoverAndClickOptionMenuButton([0]); + + final editorState = tester.editor.getCurrentEditorState(); + final beforeAlign = editorState.document.nodeAtPath([0])!.tableAlign; + expect(beforeAlign, TableAlign.left); + + await tester.tapButton( + find.text( + LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), + ), + ); + await tester.pumpAndSettle(); + await tester.tapButton( + find.text( + LocaleKeys.document_plugins_optionAction_center.tr(), + ), + ); + await tester.pumpAndSettle(); + + final afterAlign = editorState.document.nodeAtPath([0])!.tableAlign; + expect(afterAlign, TableAlign.center); + + await tester.editor.hoverAndClickOptionMenuButton([0]); + await tester.tapButton( + find.text( + LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), + ), + ); + await tester.pumpAndSettle(); + await tester.tapButton( + find.text( + LocaleKeys.document_plugins_optionAction_right.tr(), + ), + ); + await tester.pumpAndSettle(); + + final afterAlign2 = editorState.document.nodeAtPath([0])!.tableAlign; + expect(afterAlign2, TableAlign.right); + + await tester.editor.hoverAndClickOptionMenuButton([0]); + await tester.tapButton( + find.text( + LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), + ), + ); + await tester.pumpAndSettle(); + await tester.tapButton( + find.text( + LocaleKeys.document_plugins_optionAction_left.tr(), + ), + ); + await tester.pumpAndSettle(); + + final afterAlign3 = editorState.document.nodeAtPath([0])!.tableAlign; + expect(afterAlign3, TableAlign.left); + }); + + testWidgets('using option menu to set table align', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + await tester.editor.hoverAndClickOptionMenuButton([0]); + + final editorState = tester.editor.getCurrentEditorState(); + final beforeAlign = editorState.document.nodeAtPath([0])!.tableAlign; + expect(beforeAlign, TableAlign.left); + + await tester.tapButton( + find.text( + LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), + ), + ); + await tester.pumpAndSettle(); + await tester.tapButton( + find.text( + LocaleKeys.document_plugins_optionAction_center.tr(), + ), + ); + await tester.pumpAndSettle(); + + final afterAlign = editorState.document.nodeAtPath([0])!.tableAlign; + expect(afterAlign, TableAlign.center); + + await tester.editor.hoverAndClickOptionMenuButton([0]); + await tester.tapButton( + find.text( + LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), + ), + ); + await tester.pumpAndSettle(); + await tester.tapButton( + find.text( + LocaleKeys.document_plugins_optionAction_right.tr(), + ), + ); + await tester.pumpAndSettle(); + + final afterAlign2 = editorState.document.nodeAtPath([0])!.tableAlign; + expect(afterAlign2, TableAlign.right); + + await tester.editor.hoverAndClickOptionMenuButton([0]); + await tester.tapButton( + find.text( + LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), + ), + ); + await tester.pumpAndSettle(); + await tester.tapButton( + find.text( + LocaleKeys.document_plugins_optionAction_left.tr(), + ), + ); + await tester.pumpAndSettle(); + + final afterAlign3 = editorState.document.nodeAtPath([0])!.tableAlign; + expect(afterAlign3, TableAlign.left); + }); + + testWidgets('support slash menu in table', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createNewPageWithNameUnderParent( + name: 'simple_table_test', + ); + + final editorState = tester.editor.getCurrentEditorState(); + + await tester.editor.tapLineOfEditorAt(0); + await tester.insertTableInDocument(); + + final path = [0, 0, 0, 0]; + final selection = Selection.collapsed(Position(path: path)); + editorState.selection = selection; + await tester.editor.showSlashMenu(); + await tester.pumpAndSettle(); + + final paragraphItem = find.byWidgetPredicate((w) { + return w is SelectionMenuItemWidget && + w.item.name == LocaleKeys.document_slashMenu_name_text.tr(); + }); + expect(paragraphItem, findsOneWidget); + + await tester.tap(paragraphItem); + await tester.pumpAndSettle(); + + final paragraphNode = editorState.document.nodeAtPath(path)!; + expect(paragraphNode.type, equals(ParagraphBlockKeys.type)); + }); +} + +extension on WidgetTester { + /// Insert a table in the document + Future insertTableInDocument() async { + // open the actions menu and insert the outline block + await editor.showSlashMenu(); + await editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_table.tr(), + ); + await pumpAndSettle(); + } + + Future clickMoreActionItemInTableMenu({ + required SimpleTableMoreActionType type, + required int index, + required SimpleTableMoreAction action, + }) async { + if (type == SimpleTableMoreActionType.row) { + final row = find.byWidgetPredicate((w) { + return w is SimpleTableRowBlockWidget && w.node.rowIndex == index; + }); + await hoverOnWidget( + row, + onHover: () async { + final moreActionButton = find.byWidgetPredicate((w) { + return w is SimpleTableMoreActionMenu && + w.type == SimpleTableMoreActionType.row && + w.index == index; + }); + await tapButton(moreActionButton); + await tapButton(find.text(action.name)); + }, + ); + await pumpAndSettle(); + } else if (type == SimpleTableMoreActionType.column) { + final column = find.byWidgetPredicate((w) { + return w is SimpleTableCellBlockWidget && w.node.columnIndex == index; + }).first; + await hoverOnWidget( + column, + onHover: () async { + final moreActionButton = find.byWidgetPredicate((w) { + return w is SimpleTableMoreActionMenu && + w.type == SimpleTableMoreActionType.column && + w.index == index; + }); + await tapButton(moreActionButton); + await tapButton(find.text(action.name)); + }, + ); + await pumpAndSettle(); + } + + await tapAt(Offset.zero); + } +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_heading_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_heading_block_test.dart new file mode 100644 index 0000000000000..8eb47fc15fb38 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_heading_block_test.dart @@ -0,0 +1,128 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +const String _heading1 = 'Heading 1'; +const String _heading2 = 'Heading 2'; +const String _heading3 = 'Heading 3'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('toggle heading block test:', () { + testWidgets('insert toggle heading 1 - 3 block', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent( + name: 'toggle heading block test', + ); + + for (var i = 1; i <= 3; i++) { + await tester.editor.tapLineOfEditorAt(0); + await _insertToggleHeadingBlockInDocument(tester, i); + await tester.pumpAndSettle(); + expect( + find.byWidgetPredicate( + (widget) => + widget is ToggleListBlockComponentWidget && + widget.node.attributes[ToggleListBlockKeys.level] == i, + ), + findsOneWidget, + ); + } + }); + + testWidgets('insert toggle heading 1 - 3 block by shortcuts', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent( + name: 'toggle heading block test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText('# > $_heading1\n'); + await tester.ime.insertText('## > $_heading2\n'); + await tester.ime.insertText('### > $_heading3\n'); + await tester.ime.insertText('> # $_heading1\n'); + await tester.ime.insertText('> ## $_heading2\n'); + await tester.ime.insertText('> ### $_heading3\n'); + await tester.pumpAndSettle(); + + expect( + find.byType(ToggleListBlockComponentWidget), + findsNWidgets(6), + ); + }); + + testWidgets('insert toggle heading and convert it to heading', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent( + name: 'toggle heading block test', + ); + + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText('# > $_heading1\n'); + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + await tester.ime.insertText('item 1'); + await tester.pumpAndSettle(); + + await tester.editor.updateSelection( + Selection( + start: Position(path: [0]), + end: Position(path: [0], offset: _heading1.length), + ), + ); + + await tester.tapButton(find.byType(HeadingPopup)); + await tester.pumpAndSettle(); + + expect( + find.byType(HeadingButton), + findsNWidgets(3), + ); + + // tap the H1 button + await tester.tapButton(find.byType(HeadingButton).at(0)); + await tester.pumpAndSettle(); + + final editorState = tester.editor.getCurrentEditorState(); + final node1 = editorState.document.nodeAtPath([0])!; + expect(node1.type, HeadingBlockKeys.type); + expect(node1.attributes[HeadingBlockKeys.level], 1); + + final node2 = editorState.document.nodeAtPath([1])!; + expect(node2.type, ParagraphBlockKeys.type); + expect(node2.delta!.toPlainText(), 'item 1'); + }); + }); +} + +Future _insertToggleHeadingBlockInDocument( + WidgetTester tester, + int level, +) async { + final name = switch (level) { + 1 => LocaleKeys.document_slashMenu_name_toggleHeading1.tr(), + 2 => LocaleKeys.document_slashMenu_name_toggleHeading2.tr(), + 3 => LocaleKeys.document_slashMenu_name_toggleHeading3.tr(), + _ => throw Exception('Invalid level: $level'), + }; + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + name, + offset: 150, + ); + await tester.pumpAndSettle(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_list_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_list_test.dart new file mode 100644 index 0000000000000..a4d011dccb711 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_toggle_list_test.dart @@ -0,0 +1,288 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + TestWidgetsFlutterBinding.ensureInitialized(); + + group('toggle list in document', () { + Finder findToggleListIcon({ + required bool isExpanded, + }) { + final turns = isExpanded ? 0.25 : 0.0; + return find.byWidgetPredicate( + (widget) => widget is AnimatedRotation && widget.turns == turns, + ); + } + + void expectToggleListOpened() { + expect(findToggleListIcon(isExpanded: true), findsOneWidget); + expect(findToggleListIcon(isExpanded: false), findsNothing); + } + + void expectToggleListClosed() { + expect(findToggleListIcon(isExpanded: false), findsOneWidget); + expect(findToggleListIcon(isExpanded: true), findsNothing); + } + + testWidgets('convert > to toggle list, and click the icon to close it', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + // insert a toggle list + const text = 'This is a toggle list sample'; + await tester.ime.insertText('> $text'); + + final editorState = tester.editor.getCurrentEditorState(); + final toggleList = editorState.document.nodeAtPath([0])!; + expect( + toggleList.type, + ToggleListBlockKeys.type, + ); + expect( + toggleList.attributes[ToggleListBlockKeys.collapsed], + false, + ); + expect( + toggleList.delta!.toPlainText(), + text, + ); + + // Simulate pressing enter key to move the cursor to the next line + await tester.ime.insertCharacter('\n'); + const text2 = 'This is a child node'; + await tester.ime.insertText(text2); + expect(find.text(text2, findRichText: true), findsOneWidget); + + // Click the toggle list icon to close it + final toggleListIcon = find.byIcon(Icons.arrow_right); + await tester.tapButton(toggleListIcon); + + // expect the toggle list to be closed + expect(find.text(text2, findRichText: true), findsNothing); + }); + + testWidgets('press enter key when the toggle list is closed', + (tester) async { + // if the toggle list is closed, press enter key will insert a new toggle list after it + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + // insert a toggle list + const text = 'Hello AppFlowy'; + await tester.ime.insertText('> $text'); + + // Click the toggle list icon to close it + final toggleListIcon = find.byIcon(Icons.arrow_right); + await tester.tapButton(toggleListIcon); + + // Press the enter key + await tester.editor.updateSelection( + Selection.collapsed( + Position(path: [0], offset: 'Hello '.length), + ), + ); + await tester.ime.insertCharacter('\n'); + + final editorState = tester.editor.getCurrentEditorState(); + final node0 = editorState.getNodeAtPath([0])!; + final node1 = editorState.getNodeAtPath([1])!; + + expect(node0.type, ToggleListBlockKeys.type); + expect(node0.attributes[ToggleListBlockKeys.collapsed], true); + expect(node0.delta!.toPlainText(), 'Hello '); + expect(node1.type, ToggleListBlockKeys.type); + expect(node1.delta!.toPlainText(), 'AppFlowy'); + }); + + testWidgets('press enter key when the toggle list is open', (tester) async { + // if the toggle list is open, press enter key will insert a new paragraph inside it + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + // insert a toggle list + const text = 'Hello AppFlowy'; + await tester.ime.insertText('> $text'); + + // Press the enter key + await tester.editor.updateSelection( + Selection.collapsed( + Position(path: [0], offset: 'Hello '.length), + ), + ); + await tester.ime.insertCharacter('\n'); + + final editorState = tester.editor.getCurrentEditorState(); + final node0 = editorState.getNodeAtPath([0])!; + final node00 = editorState.getNodeAtPath([0, 0])!; + final node1 = editorState.getNodeAtPath([1]); + + expect(node0.type, ToggleListBlockKeys.type); + expect(node0.attributes[ToggleListBlockKeys.collapsed], false); + expect(node0.delta!.toPlainText(), 'Hello '); + expect(node00.type, ParagraphBlockKeys.type); + expect(node00.delta!.toPlainText(), 'AppFlowy'); + expect(node1, isNull); + }); + + testWidgets('clear the format if toggle list if empty', (tester) async { + // if the toggle list is open, press enter key will insert a new paragraph inside it + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + // insert a toggle list + await tester.ime.insertText('> '); + + // Press the enter key + // Click the toggle list icon to close it + final toggleListIcon = find.byIcon(Icons.arrow_right); + await tester.tapButton(toggleListIcon); + + await tester.editor + .updateSelection(Selection.collapsed(Position(path: [0]))); + await tester.ime.insertCharacter('\n'); + + final editorState = tester.editor.getCurrentEditorState(); + final node0 = editorState.getNodeAtPath([0])!; + + expect(node0.type, ParagraphBlockKeys.type); + }); + + testWidgets('use cmd/ctrl + enter to open/close the toggle list', + (tester) async { + // if the toggle list is open, press enter key will insert a new paragraph inside it + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent(); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + // insert a toggle list + await tester.ime.insertText('> Hello'); + + expectToggleListOpened(); + + await tester.editor.updateSelection( + Selection.collapsed( + Position(path: [0]), + ), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.enter, + isMetaPressed: Platform.isMacOS, + isControlPressed: Platform.isLinux || Platform.isWindows, + ); + + expectToggleListClosed(); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.enter, + isMetaPressed: Platform.isMacOS, + isControlPressed: Platform.isLinux || Platform.isWindows, + ); + + expectToggleListOpened(); + }); + + Future prepareToggleHeadingBlock( + WidgetTester tester, + String text, + ) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText(text); + } + + testWidgets('> + # to toggle heading 1 block', (tester) async { + await prepareToggleHeadingBlock(tester, '> # Hello'); + final editorState = tester.editor.getCurrentEditorState(); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ToggleListBlockKeys.type); + expect(node.attributes[ToggleListBlockKeys.level], 1); + expect(node.delta!.toPlainText(), 'Hello'); + }); + + testWidgets('> + ### to toggle heading 3 block', (tester) async { + await prepareToggleHeadingBlock(tester, '> ### Hello'); + final editorState = tester.editor.getCurrentEditorState(); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ToggleListBlockKeys.type); + expect(node.attributes[ToggleListBlockKeys.level], 3); + expect(node.delta!.toPlainText(), 'Hello'); + }); + + testWidgets('# + > to toggle heading 1 block', (tester) async { + await prepareToggleHeadingBlock(tester, '# > Hello'); + final editorState = tester.editor.getCurrentEditorState(); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ToggleListBlockKeys.type); + expect(node.attributes[ToggleListBlockKeys.level], 1); + expect(node.delta!.toPlainText(), 'Hello'); + }); + + testWidgets('### + > to toggle heading 3 block', (tester) async { + await prepareToggleHeadingBlock(tester, '### > Hello'); + final editorState = tester.editor.getCurrentEditorState(); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ToggleListBlockKeys.type); + expect(node.attributes[ToggleListBlockKeys.level], 3); + expect(node.delta!.toPlainText(), 'Hello'); + }); + + testWidgets('click the toggle list to create a new paragraph', + (tester) async { + await prepareToggleHeadingBlock(tester, '> # Hello'); + final emptyHintText = find.text( + LocaleKeys.document_plugins_emptyToggleHeading.tr( + args: ['1'], + ), + ); + expect(emptyHintText, findsOneWidget); + + await tester.tapButton(emptyHintText); + await tester.pumpAndSettle(); + + // check the new paragraph is created + final editorState = tester.editor.getCurrentEditorState(); + final node = editorState.getNodeAtPath([0, 0])!; + expect(node.type, ParagraphBlockKeys.type); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/edit_document_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/edit_document_test.dart new file mode 100644 index 0000000000000..e4804a2a574dd --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/document/edit_document_test.dart @@ -0,0 +1,127 @@ +import 'dart:io'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('edit document', () { + testWidgets('redo & undo', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document called Sample + const pageName = 'Sample'; + await tester.createNewPageWithNameUnderParent(name: pageName); + + // focus on the editor + await tester.editor.tapLineOfEditorAt(0); + + // insert 1. to trigger it to be a numbered list + await tester.ime.insertText('1. '); + expect(find.text('1.', findRichText: true), findsOneWidget); + expect( + tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type, + NumberedListBlockKeys.type, + ); + + // undo + // numbered list will be reverted to paragraph + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: Platform.isWindows || Platform.isLinux, + isMetaPressed: Platform.isMacOS, + ); + expect( + tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type, + ParagraphBlockKeys.type, + ); + + // redo + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: Platform.isWindows || Platform.isLinux, + isMetaPressed: Platform.isMacOS, + isShiftPressed: true, + ); + expect( + tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type, + NumberedListBlockKeys.type, + ); + + // switch to other page and switch back + await tester.openPage(gettingStarted); + await tester.openPage(pageName); + + // the numbered list should be kept + expect( + tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type, + NumberedListBlockKeys.type, + ); + }); + + testWidgets('write a readme document', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document called Sample + const pageName = 'Sample'; + await tester.createNewPageWithNameUnderParent(name: pageName); + + // focus on the editor + await tester.editor.tapLineOfEditorAt(0); + + // mock inputting the sample + final lines = _sample.split('\n'); + for (final line in lines) { + await tester.ime.insertText(line); + await tester.ime.insertCharacter('\n'); + } + + // switch to other page and switch back + await tester.openPage(gettingStarted); + await tester.openPage(pageName); + + // this screenshots are different on different platform, so comment it out temporarily. + // check the document + // await expectLater( + // find.byType(AppFlowyEditor), + // matchesGoldenFile('document/edit_document_test.png'), + // ); + }); + }); +} + +const _sample = ''' +# Heading 1 +## Heading 2 +### Heading 3 +--- +[] Highlight any text, and use the editing menu to _style_ **your** writing `however` you ~~like.~~ + +[] Type followed by bullet or num to create a list. + +[x] Click `New Page` button at the bottom of your sidebar to add a new page. + +[] Click the plus sign next to any page title in the sidebar to quickly add a new subpage, `Document`, `Grid`, or `Kanban Board`. +--- +* bulleted list 1 + +* bulleted list 2 + +* bulleted list 3 +bulleted list 4 +--- +1. numbered list 1 + +2. numbered list 2 + +3. numbered list 3 +numbered list 4 +--- +" quote'''; diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/edit_document_test.png b/frontend/appflowy_flutter/integration_test/desktop/document/edit_document_test.png new file mode 100644 index 0000000000000..6490cb4de2a36 Binary files /dev/null and b/frontend/appflowy_flutter/integration_test/desktop/document/edit_document_test.png differ diff --git a/frontend/appflowy_flutter/integration_test/desktop/first_test/first_test.dart b/frontend/appflowy_flutter/integration_test/desktop/first_test/first_test.dart new file mode 100644 index 0000000000000..36c0e391fb7ae --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/first_test/first_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +// This test is meaningless, just for preventing the CI from failing. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Empty', () { + testWidgets('empty test', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.wait(500); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/grid/grid_calculations_test.dart b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_calculations_test.dart new file mode 100644 index 0000000000000..2eaa7ea6a5115 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_calculations_test.dart @@ -0,0 +1,107 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Grid Calculations', () { + testWidgets('add calculation and update cell', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Change one Field to Number + await tester.changeFieldTypeOfFieldWithName('Type', FieldType.Number); + + expect(find.text('Calculate'), findsOneWidget); + + await tester.changeCalculateAtIndex(1, CalculationType.Sum); + + // Enter values in cells + await tester.editCell( + rowIndex: 0, + fieldType: FieldType.Number, + input: '100', + ); + + await tester.editCell( + rowIndex: 1, + fieldType: FieldType.Number, + input: '100', + ); + + // Dismiss edit cell + await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(find.text('200'), findsOneWidget); + }); + + testWidgets('add calculations and remove row', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Change two Fields to Number + await tester.changeFieldTypeOfFieldWithName('Type', FieldType.Number); + await tester.changeFieldTypeOfFieldWithName('Done', FieldType.Number); + + expect(find.text('Calculate'), findsNWidgets(2)); + + await tester.changeCalculateAtIndex(1, CalculationType.Sum); + await tester.changeCalculateAtIndex(2, CalculationType.Min); + + // Enter values in cells + await tester.editCell( + rowIndex: 0, + fieldType: FieldType.Number, + input: '100', + ); + await tester.editCell( + rowIndex: 1, + fieldType: FieldType.Number, + input: '150', + ); + await tester.editCell( + rowIndex: 0, + fieldType: FieldType.Number, + input: '50', + cellIndex: 1, + ); + await tester.editCell( + rowIndex: 1, + fieldType: FieldType.Number, + input: '100', + cellIndex: 1, + ); + + await tester.pumpAndSettle(); + + // Dismiss edit cell + await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.text('250'), findsOneWidget); + expect(find.text('50'), findsNWidgets(2)); + + // Delete 1st row + await tester.hoverOnFirstRowOfGrid(); + await tester.tapRowMenuButtonInGrid(); + await tester.tapDeleteOnRowMenu(); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(find.text('150'), findsNWidgets(2)); + expect(find.text('100'), findsNWidgets(2)); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/grid/grid_edit_row_test.dart b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_edit_row_test.dart new file mode 100644 index 0000000000000..64b7a40ad1059 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_edit_row_test.dart @@ -0,0 +1,137 @@ +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:time/time.dart'; + +import '../../shared/database_test_op.dart'; +import 'grid_test_extensions.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('grid edit row test:', () { + testWidgets('with sort configured', (tester) async { + await tester.openTestDatabase(v069GridFileName); + + // get grid data + final unsorted = tester.getGridRows(); + + // add a sort + await tester.tapDatabaseSortButton(); + await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); + + final sorted = [ + unsorted[7], + unsorted[8], + unsorted[1], + unsorted[9], + unsorted[11], + unsorted[10], + unsorted[6], + unsorted[12], + unsorted[2], + unsorted[0], + unsorted[3], + unsorted[5], + unsorted[4], + ]; + + List actual = tester.getGridRows(); + expect(actual, orderedEquals(sorted)); + + await tester.editCell( + rowIndex: 4, + fieldType: FieldType.RichText, + input: "x", + ); + await tester.pumpAndSettle(200.milliseconds); + + final reSorted = [ + unsorted[7], + unsorted[8], + unsorted[1], + unsorted[9], + unsorted[10], + unsorted[6], + unsorted[12], + unsorted[2], + unsorted[0], + unsorted[3], + unsorted[5], + unsorted[11], + unsorted[4], + ]; + + // verify grid data + actual = tester.getGridRows(); + expect(actual, orderedEquals(reSorted)); + + // delete the sort + await tester.tapSortMenuInSettingBar(); + await tester.tapDeleteAllSortsButton(); + + // verify grid data + actual = tester.getGridRows(); + expect(actual, orderedEquals(unsorted)); + }); + + testWidgets('with filter configured', (tester) async { + await tester.openTestDatabase(v069GridFileName); + + // get grid data + final original = tester.getGridRows(); + + // create a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType( + FieldType.Checkbox, + 'Registration Complete', + ); + + final filtered = [ + original[1], + original[3], + original[5], + original[6], + original[7], + original[9], + original[12], + ]; + + // verify grid data + List actual = tester.getGridRows(); + expect(actual, orderedEquals(filtered)); + expect(actual.length, equals(7)); + tester.assertNumberOfRowsInGridPage(7); + + await tester.tapCheckboxCellInGrid(rowIndex: 0); + await tester.pumpAndSettle(200.milliseconds); + + // verify grid data + actual = tester.getGridRows(); + expect(actual.length, equals(6)); + tester.assertNumberOfRowsInGridPage(6); + final edited = [ + original[3], + original[5], + original[6], + original[7], + original[9], + original[12], + ]; + expect(actual, orderedEquals(edited)); + + // delete the filter + await tester.tapFilterButtonInGrid('Registration Complete'); + await tester + .tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor)); + await tester.tapDeleteFilterButtonInGrid(); + + // verify grid data + actual = tester.getGridRows(); + expect(actual.length, equals(13)); + tester.assertNumberOfRowsInGridPage(13); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/grid/grid_filter_and_sort_test.dart b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_filter_and_sort_test.dart new file mode 100644 index 0000000000000..c9fed5b02ea31 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_filter_and_sort_test.dart @@ -0,0 +1,120 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import 'grid_test_extensions.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('grid simultaneous sort and filter test:', () { + // testWidgets('delete filter with active sort', (tester) async { + // await tester.openTestDatabase(v069GridFileName); + + // // get grid data + // final original = tester.getGridRows(); + + // // add a filter + // await tester.tapDatabaseFilterButton(); + // await tester.tapCreateFilterByFieldType( + // FieldType.Checkbox, + // 'Registration Complete', + // ); + + // // add a sort + // await tester.tapDatabaseSortButton(); + // await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); + + // final filteredAndSorted = [ + // original[7], + // original[1], + // original[9], + // original[6], + // original[12], + // original[3], + // original[5], + // ]; + + // // verify grid data + // List actual = tester.getGridRows(); + // expect(actual, orderedEquals(filteredAndSorted)); + + // // delete the filter + // await tester.tapFilterButtonInGrid('Registration Complete'); + // await tester + // .tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor)); + // await tester.tapDeleteFilterButtonInGrid(); + + // final sorted = [ + // original[7], + // original[8], + // original[1], + // original[9], + // original[11], + // original[10], + // original[6], + // original[12], + // original[2], + // original[0], + // original[3], + // original[5], + // original[4], + // ]; + + // // verify grid data + // actual = tester.getGridRows(); + // expect(actual, orderedEquals(sorted)); + // }); + + testWidgets('delete sort with active fiilter', (tester) async { + await tester.openTestDatabase(v069GridFileName); + + // get grid data + final original = tester.getGridRows(); + + // add a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType( + FieldType.Checkbox, + 'Registration Complete', + ); + + // add a sort + await tester.tapDatabaseSortButton(); + await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); + + final filteredAndSorted = [ + original[7], + original[1], + original[9], + original[6], + original[12], + original[3], + original[5], + ]; + + // verify grid data + List actual = tester.getGridRows(); + expect(actual, orderedEquals(filteredAndSorted)); + + // delete the sort + await tester.tapSortMenuInSettingBar(); + await tester.tapDeleteAllSortsButton(); + + final filtered = [ + original[1], + original[3], + original[5], + original[6], + original[7], + original[9], + original[12], + ]; + + // verify grid data + actual = tester.getGridRows(); + expect(actual, orderedEquals(filtered)); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/grid/grid_reopen_test.dart b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_reopen_test.dart new file mode 100644 index 0000000000000..a4363f7a83cf6 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_reopen_test.dart @@ -0,0 +1,183 @@ +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; +import 'grid_test_extensions.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('grid reopen test:', () { + testWidgets('base case', (tester) async { + await tester.openTestDatabase(v069GridFileName); + + final expected = tester.getGridRows(); + + // go to another page and come back + await tester.openPage('Getting started'); + await tester.openPage('v069', layout: ViewLayoutPB.Grid); + + // verify grid data + final actual = tester.getGridRows(); + + expect(actual, orderedEquals(expected)); + }); + + testWidgets('with sort configured', (tester) async { + await tester.openTestDatabase(v069GridFileName); + + // get grid data + final unsorted = tester.getGridRows(); + + // add a sort + await tester.tapDatabaseSortButton(); + await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); + + final sorted = [ + unsorted[7], + unsorted[8], + unsorted[1], + unsorted[9], + unsorted[11], + unsorted[10], + unsorted[6], + unsorted[12], + unsorted[2], + unsorted[0], + unsorted[3], + unsorted[5], + unsorted[4], + ]; + + // verify grid data + List actual = tester.getGridRows(); + expect(actual, orderedEquals(sorted)); + + // go to another page and come back + await tester.openPage('Getting started'); + await tester.openPage('v069', layout: ViewLayoutPB.Grid); + + // verify grid data + actual = tester.getGridRows(); + expect(actual, orderedEquals(sorted)); + + // delete sorts + // TODO(RS): Shouldn't the sort/filter list show automatically!? + await tester.tapDatabaseSortButton(); + await tester.tapSortMenuInSettingBar(); + await tester.tapDeleteAllSortsButton(); + + // verify grid data + actual = tester.getGridRows(); + expect(actual, orderedEquals(unsorted)); + + // go to another page and come back + await tester.openPage('Getting started'); + await tester.openPage('v069', layout: ViewLayoutPB.Grid); + + // verify grid data + actual = tester.getGridRows(); + expect(actual, orderedEquals(unsorted)); + }); + + testWidgets('with filter configured', (tester) async { + await tester.openTestDatabase(v069GridFileName); + + // get grid data + final unfiltered = tester.getGridRows(); + + // add a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType( + FieldType.Checkbox, + 'Registration Complete', + ); + + final filtered = [ + unfiltered[1], + unfiltered[3], + unfiltered[5], + unfiltered[6], + unfiltered[7], + unfiltered[9], + unfiltered[12], + ]; + + // verify grid data + List actual = tester.getGridRows(); + expect(actual, orderedEquals(filtered)); + + // go to another page and come back + await tester.openPage('Getting started'); + await tester.openPage('v069', layout: ViewLayoutPB.Grid); + + // verify grid data + actual = tester.getGridRows(); + expect(actual, orderedEquals(filtered)); + + // delete the filter + // TODO(RS): Shouldn't the sort/filter list show automatically!? + await tester.tapDatabaseFilterButton(); + await tester.tapFilterButtonInGrid('Registration Complete'); + await tester + .tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor)); + await tester.tapDeleteFilterButtonInGrid(); + + // verify grid data + actual = tester.getGridRows(); + expect(actual, orderedEquals(unfiltered)); + + // go to another page and come back + await tester.openPage('Getting started'); + await tester.openPage('v069', layout: ViewLayoutPB.Grid); + + // verify grid data + actual = tester.getGridRows(); + expect(actual, orderedEquals(unfiltered)); + }); + + testWidgets('with both filter and sort configured', (tester) async { + await tester.openTestDatabase(v069GridFileName); + + // get grid data + final original = tester.getGridRows(); + + // add a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType( + FieldType.Checkbox, + 'Registration Complete', + ); + + // add a sort + await tester.tapDatabaseSortButton(); + await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); + + final filteredAndSorted = [ + original[7], + original[1], + original[9], + original[6], + original[12], + original[3], + original[5], + ]; + + // verify grid data + List actual = tester.getGridRows(); + expect(actual, orderedEquals(filteredAndSorted)); + + // go to another page and come back + await tester.openPage('Getting started'); + await tester.openPage('v069', layout: ViewLayoutPB.Grid); + + // verify grid data + actual = tester.getGridRows(); + expect(actual, orderedEquals(filteredAndSorted)); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/grid/grid_reorder_row_test.dart b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_reorder_row_test.dart new file mode 100644 index 0000000000000..40f7252a91502 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_reorder_row_test.dart @@ -0,0 +1,214 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; +import 'grid_test_extensions.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('grid reorder row test:', () { + testWidgets('base case', (tester) async { + await tester.openTestDatabase(v069GridFileName); + + // get grid data + final original = tester.getGridRows(); + + // reorder row + await tester.reorderRow(original[4], original[1]); + + // verify grid data + List reordered = [ + original[0], + original[4], + original[1], + original[2], + original[3], + original[5], + original[6], + original[7], + original[8], + original[9], + original[10], + original[11], + original[12], + ]; + List actual = tester.getGridRows(); + expect(actual, orderedEquals(reordered)); + + // reorder row + await tester.reorderRow(reordered[1], reordered[3]); + + // verify grid data + reordered = [ + original[0], + original[1], + original[2], + original[4], + original[3], + original[5], + original[6], + original[7], + original[8], + original[9], + original[10], + original[11], + original[12], + ]; + actual = tester.getGridRows(); + expect(actual, orderedEquals(reordered)); + + // reorder row + await tester.reorderRow(reordered[2], reordered[0]); + + // verify grid data + reordered = [ + original[2], + original[0], + original[1], + original[4], + original[3], + original[5], + original[6], + original[7], + original[8], + original[9], + original[10], + original[11], + original[12], + ]; + actual = tester.getGridRows(); + expect(actual, orderedEquals(reordered)); + }); + + testWidgets('with active sort', (tester) async { + await tester.openTestDatabase(v069GridFileName); + + // get grid data + final original = tester.getGridRows(); + + // add a sort + await tester.tapDatabaseSortButton(); + await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); + + // verify grid data + final sorted = [ + original[7], + original[8], + original[1], + original[9], + original[11], + original[10], + original[6], + original[12], + original[2], + original[0], + original[3], + original[5], + original[4], + ]; + List actual = tester.getGridRows(); + expect(actual, orderedEquals(sorted)); + + // reorder row + await tester.reorderRow(original[4], original[1]); + expect(find.byType(ConfirmPopup), findsOneWidget); + await tester.tapButtonWithName(LocaleKeys.button_cancel.tr()); + + // verify grid data + actual = tester.getGridRows(); + expect(actual, orderedEquals(sorted)); + }); + + testWidgets('with active filter', (tester) async { + await tester.openTestDatabase(v069GridFileName); + + // get grid data + final original = tester.getGridRows(); + + // add a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType( + FieldType.Checkbox, + 'Registration Complete', + ); + + final filtered = [ + original[1], + original[3], + original[5], + original[6], + original[7], + original[9], + original[12], + ]; + + // verify grid data + List actual = tester.getGridRows(); + expect(actual, orderedEquals(filtered)); + + // reorder row + await tester.reorderRow(filtered[3], filtered[1]); + + // verify grid data + List reordered = [ + original[1], + original[6], + original[3], + original[5], + original[7], + original[9], + original[12], + ]; + actual = tester.getGridRows(); + expect(actual, orderedEquals(reordered)); + + // reorder row + await tester.reorderRow(reordered[3], reordered[5]); + + // verify grid data + reordered = [ + original[1], + original[6], + original[3], + original[7], + original[9], + original[5], + original[12], + ]; + actual = tester.getGridRows(); + expect(actual, orderedEquals(reordered)); + + // delete the filter + await tester.tapFilterButtonInGrid('Registration Complete'); + await tester + .tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor)); + await tester.tapDeleteFilterButtonInGrid(); + + // verify grid data + final expected = [ + original[0], + original[1], + original[2], + original[6], + original[3], + original[4], + original[7], + original[8], + original[9], + original[5], + original[10], + original[11], + original[12], + ]; + actual = tester.getGridRows(); + expect(actual, orderedEquals(expected)); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/grid/grid_row_test.dart b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_row_test.dart new file mode 100644 index 0000000000000..d7efb797f05a7 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_row_test.dart @@ -0,0 +1,234 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/util.dart'; + +import 'grid_test_extensions.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('grid row test:', () { + testWidgets('create from the bottom', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + final expected = tester.getGridRows(); + + // create row + await tester.tapCreateRowButtonInGrid(); + + final actual = tester.getGridRows(); + expect(actual.slice(0, 3), orderedEquals(expected)); + expect(actual.length, equals(4)); + tester.assertNumberOfRowsInGridPage(4); + }); + + testWidgets('create from a row\'s menu', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + final expected = tester.getGridRows(); + + // create row + await tester.hoverOnFirstRowOfGrid(); + await tester.tapCreateRowButtonAfterHoveringOnGridRow(); + + final actual = tester.getGridRows(); + expect([actual[0], actual[2], actual[3]], orderedEquals(expected)); + expect(actual.length, equals(4)); + tester.assertNumberOfRowsInGridPage(4); + }); + + testWidgets('create with sort configured', (tester) async { + await tester.openTestDatabase(v069GridFileName); + + // get grid data + final unsorted = tester.getGridRows(); + + // add a sort + await tester.tapDatabaseSortButton(); + await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name'); + + final sorted = [ + unsorted[7], + unsorted[8], + unsorted[1], + unsorted[9], + unsorted[11], + unsorted[10], + unsorted[6], + unsorted[12], + unsorted[2], + unsorted[0], + unsorted[3], + unsorted[5], + unsorted[4], + ]; + + List actual = tester.getGridRows(); + expect(actual, orderedEquals(sorted)); + + // create row + await tester.hoverOnFirstRowOfGrid(); + await tester.tapCreateRowButtonAfterHoveringOnGridRow(); + + // cancel + expect(find.byType(ConfirmPopup), findsOneWidget); + await tester.tapButtonWithName(LocaleKeys.button_cancel.tr()); + + // verify grid data + actual = tester.getGridRows(); + expect(actual, orderedEquals(sorted)); + + // try again, but confirm this time + await tester.hoverOnFirstRowOfGrid(); + await tester.tapCreateRowButtonAfterHoveringOnGridRow(); + expect(find.byType(ConfirmPopup), findsOneWidget); + await tester.tapButtonWithName(LocaleKeys.button_remove.tr()); + + // verify grid data + actual = tester.getGridRows(); + expect(actual.length, equals(14)); + tester.assertNumberOfRowsInGridPage(14); + }); + + testWidgets('create with filter configured', (tester) async { + await tester.openTestDatabase(v069GridFileName); + + // get grid data + final original = tester.getGridRows(); + + // create a filter + await tester.tapDatabaseFilterButton(); + await tester.tapCreateFilterByFieldType( + FieldType.Checkbox, + 'Registration Complete', + ); + + final filtered = [ + original[1], + original[3], + original[5], + original[6], + original[7], + original[9], + original[12], + ]; + + // verify grid data + List actual = tester.getGridRows(); + expect(actual, orderedEquals(filtered)); + + // create row (one before and after the first row, and one at the bottom) + await tester.tapCreateRowButtonInGrid(); + await tester.hoverOnFirstRowOfGrid(); + await tester.tapCreateRowButtonAfterHoveringOnGridRow(); + await tester.hoverOnFirstRowOfGrid(() async { + await tester.tapRowMenuButtonInGrid(); + await tester.tapCreateRowAboveButtonInRowMenu(); + }); + + actual = tester.getGridRows(); + expect(actual.length, equals(10)); + tester.assertNumberOfRowsInGridPage(10); + actual = [ + actual[1], + actual[3], + actual[4], + actual[5], + actual[6], + actual[7], + actual[8], + ]; + expect(actual, orderedEquals(filtered)); + + // delete the filter + await tester.tapFilterButtonInGrid('Registration Complete'); + await tester + .tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor)); + await tester.tapDeleteFilterButtonInGrid(); + + // verify grid data + actual = tester.getGridRows(); + expect(actual.length, equals(16)); + tester.assertNumberOfRowsInGridPage(16); + actual = [ + actual[0], + actual[2], + actual[4], + actual[5], + actual[6], + actual[7], + actual[8], + actual[9], + actual[10], + actual[11], + actual[12], + actual[13], + actual[14], + ]; + expect(actual, orderedEquals(original)); + }); + + testWidgets('delete row of the grid', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + await tester.hoverOnFirstRowOfGrid(() async { + // Open the row menu and click the delete button + await tester.tapRowMenuButtonInGrid(); + await tester.tapDeleteOnRowMenu(); + }); + expect(find.byType(ConfirmPopup), findsOneWidget); + await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); + + tester.assertNumberOfRowsInGridPage(2); + }); + + testWidgets('delete row in two views', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + await tester.renameLinkedView( + tester.findTabBarLinkViewByViewLayout(ViewLayoutPB.Grid), + 'grid 1', + ); + tester.assertNumberOfRowsInGridPage(3); + + await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid); + await tester.renameLinkedView( + tester.findTabBarLinkViewByViewLayout(ViewLayoutPB.Grid).at(1), + 'grid 2', + ); + tester.assertNumberOfRowsInGridPage(3); + + await tester.hoverOnFirstRowOfGrid(() async { + // Open the row menu and click the delete button + await tester.tapRowMenuButtonInGrid(); + await tester.tapDeleteOnRowMenu(); + }); + expect(find.byType(ConfirmPopup), findsOneWidget); + await tester.tapButtonWithName(LocaleKeys.button_delete.tr()); + // 3 initial rows - 1 deleted + tester.assertNumberOfRowsInGridPage(2); + + await tester.tapTabBarLinkedViewByViewName('grid 1'); + tester.assertNumberOfRowsInGridPage(2); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/grid/grid_test_extensions.dart b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_test_extensions.dart new file mode 100644 index 0000000000000..c5a0b404e7300 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_test_extensions.dart @@ -0,0 +1,13 @@ +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension GridTestExtensions on WidgetTester { + List getGridRows() { + final databaseController = + widget(find.byType(GridPage)).databaseController; + return [ + ...databaseController.rowCache.rowInfos.map((e) => e.rowId), + ]; + } +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/grid/grid_test_runner_1.dart b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_test_runner_1.dart new file mode 100644 index 0000000000000..ff42bb6cc280a --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/grid/grid_test_runner_1.dart @@ -0,0 +1,19 @@ +import 'package:integration_test/integration_test.dart'; + +import 'grid_edit_row_test.dart' as grid_edit_row_test_runner; +import 'grid_filter_and_sort_test.dart' as grid_filter_and_sort_test_runner; +import 'grid_reopen_test.dart' as grid_reopen_test_runner; +import 'grid_reorder_row_test.dart' as grid_reorder_row_test_runner; +import 'grid_row_test.dart' as grid_row_test_runner; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + grid_reopen_test_runner.main(); + grid_row_test_runner.main(); + grid_reorder_row_test_runner.main(); + grid_filter_and_sort_test_runner.main(); + grid_edit_row_test_runner.main(); + // grid_calculations_test_runner.main(); + // DON'T add more tests here. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/reminder/document_reminder_test.dart b/frontend/appflowy_flutter/integration_test/desktop/reminder/document_reminder_test.dart new file mode 100644 index 0000000000000..bdfe2dae9f2ee --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/reminder/document_reminder_test.dart @@ -0,0 +1,168 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/user/application/user_settings_service.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/base.dart'; +import '../../shared/common_operations.dart'; +import '../../shared/document_test_operations.dart'; +import '../../shared/expectation.dart'; +import '../../shared/keyboard.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Reminder in Document', () { + testWidgets('Add reminder for tomorrow, and include time', (tester) async { + const time = "23:59"; + + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final dateTimeSettings = + await UserSettingsBackendService().getDateTimeSettings(); + + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.getCurrentEditorState().insertNewLine(); + + await tester.pumpAndSettle(); + + // Trigger inline action menu and type 'remind tomorrow' + final tomorrow = await _insertReminderTomorrow(tester); + + Node node = tester.editor.getCurrentEditorState().getNodeAtPath([1])!; + Map mentionAttr = + node.delta!.first.attributes![MentionBlockKeys.mention]; + + expect(node.type, 'paragraph'); + expect(mentionAttr['type'], MentionType.date.name); + expect(mentionAttr['date'], tomorrow.toIso8601String()); + + await tester.tap( + find.text(dateTimeSettings.dateFormat.formatDate(tomorrow, false)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Toggle)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(FlowyTextField), time); + + // Leave text field to submit + await tester.tap(find.text(LocaleKeys.grid_field_includeTime.tr())); + await tester.pumpAndSettle(); + + node = tester.editor.getCurrentEditorState().getNodeAtPath([1])!; + mentionAttr = node.delta!.first.attributes![MentionBlockKeys.mention]; + + final tomorrowWithTime = + _dateWithTime(dateTimeSettings.timeFormat, tomorrow, time); + + expect(node.type, 'paragraph'); + expect(mentionAttr['type'], MentionType.date.name); + expect(mentionAttr['date'], tomorrowWithTime.toIso8601String()); + }); + + testWidgets('Add reminder for tomorrow, and navigate to it', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.getCurrentEditorState().insertNewLine(); + + await tester.pumpAndSettle(); + + // Trigger inline action menu and type 'remind tomorrow' + final tomorrow = await _insertReminderTomorrow(tester); + + final Node node = + tester.editor.getCurrentEditorState().getNodeAtPath([1])!; + final Map mentionAttr = + node.delta!.first.attributes![MentionBlockKeys.mention]; + + expect(node.type, 'paragraph'); + expect(mentionAttr['type'], MentionType.date.name); + expect(mentionAttr['date'], tomorrow.toIso8601String()); + + // Create and Navigate to a new document + await tester.createNewPageWithNameUnderParent(); + await tester.pumpAndSettle(); + + // Open "Upcoming" in Notification hub + await tester.openNotificationHub(tabIndex: 1); + + // Expect 1 notification + tester.expectNotificationItems(1); + + // Tap on the notification + await tester.tap(find.byType(NotificationItem)); + await tester.pumpAndSettle(); + + // Expect node at path 1 to be the date/reminder + expect( + tester.editor + .getCurrentEditorState() + .getNodeAtPath([1]) + ?.delta + ?.first + .attributes?[MentionBlockKeys.mention]['type'], + MentionType.date.name, + ); + }); + }); +} + +Future _insertReminderTomorrow(WidgetTester tester) async { + await tester.editor.showAtMenu(); + + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.keyR, + LogicalKeyboardKey.keyE, + LogicalKeyboardKey.keyM, + LogicalKeyboardKey.keyI, + LogicalKeyboardKey.keyN, + LogicalKeyboardKey.keyD, + LogicalKeyboardKey.space, + LogicalKeyboardKey.keyT, + LogicalKeyboardKey.keyO, + LogicalKeyboardKey.keyM, + LogicalKeyboardKey.keyO, + LogicalKeyboardKey.keyR, + LogicalKeyboardKey.keyR, + LogicalKeyboardKey.keyO, + LogicalKeyboardKey.keyW, + ], + tester: tester, + ); + + await FlowyTestKeyboard.simulateKeyDownEvent( + [LogicalKeyboardKey.enter], + tester: tester, + ); + + return DateTime.now().add(const Duration(days: 1)).withoutTime; +} + +DateTime _dateWithTime(UserTimeFormatPB format, DateTime date, String time) { + final t = format == UserTimeFormatPB.TwelveHour + ? DateFormat.jm().parse(time) + : DateFormat.Hm().parse(time); + + return DateTime.parse( + '${date.year}${_padZeroLeft(date.month)}${_padZeroLeft(date.day)} ${_padZeroLeft(t.hour)}:${_padZeroLeft(t.minute)}', + ); +} + +String _padZeroLeft(int a) => a.toString().padLeft(2, '0'); diff --git a/frontend/appflowy_flutter/integration_test/desktop/settings/notifications_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/settings/notifications_settings_test.dart new file mode 100644 index 0000000000000..570c482fb5072 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/settings/notifications_settings_test.dart @@ -0,0 +1,41 @@ +import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('notification test', () { + testWidgets('enable notification', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.notifications); + await tester.pumpAndSettle(); + + final toggleFinder = find.byType(Toggle).first; + + // Defaults to enabled + Toggle toggleWidget = tester.widget(toggleFinder); + expect(toggleWidget.value, true); + + // Disable + await tester.tap(toggleFinder); + await tester.pumpAndSettle(); + + toggleWidget = tester.widget(toggleFinder); + expect(toggleWidget.value, false); + + // Enable again + await tester.tap(toggleFinder); + await tester.pumpAndSettle(); + + toggleWidget = tester.widget(toggleFinder); + expect(toggleWidget.value, true); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/settings/settings_billing_test.dart b/frontend/appflowy_flutter/integration_test/desktop/settings/settings_billing_test.dart new file mode 100644 index 0000000000000..a311eb8377a5d --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/settings/settings_billing_test.dart @@ -0,0 +1,50 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/auth_operation.dart'; +import '../../shared/base.dart'; +import '../../shared/expectation.dart'; +import '../../shared/settings.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Settings Billing', () { + testWidgets('Local auth cannot see plan+billing', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapSignInAsGuest(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + await tester.openSettings(); + await tester.pumpAndSettle(); + + // We check that another settings page is present to ensure + // it's not a fluke + expect( + find.text( + LocaleKeys.settings_workspacePage_menuLabel.tr(), + skipOffstage: false, + ), + findsOneWidget, + ); + + expect( + find.text( + LocaleKeys.settings_planPage_menuLabel.tr(), + skipOffstage: false, + ), + findsNothing, + ); + + expect( + find.text( + LocaleKeys.settings_billingPage_menuLabel.tr(), + skipOffstage: false, + ), + findsNothing, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/settings/settings_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/settings/settings_runner.dart new file mode 100644 index 0000000000000..617d495265dc6 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/settings/settings_runner.dart @@ -0,0 +1,15 @@ +import 'package:integration_test/integration_test.dart'; + +import 'notifications_settings_test.dart' as notifications_settings_test; +import 'settings_billing_test.dart' as settings_billing_test; +import 'shortcuts_settings_test.dart' as shortcuts_settings_test; +import 'sign_in_page_settings_test.dart' as sign_in_page_settings_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + notifications_settings_test.main(); + settings_billing_test.main(); + shortcuts_settings_test.main(); + sign_in_page_settings_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/settings/shortcuts_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/settings/shortcuts_settings_test.dart new file mode 100644 index 0000000000000..7913a8829479b --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/settings/shortcuts_settings_test.dart @@ -0,0 +1,92 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_shortcuts_view.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('shortcuts:', () { + testWidgets('change and overwrite shortcut', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.shortcuts); + await tester.pumpAndSettle(); + + final backspaceCmd = + LocaleKeys.settings_shortcutsPage_keybindings_backspace.tr(); + + // Input "Delete" into the search field + final inputField = find.descendant( + of: find.byType(SettingsShortcutsView), + matching: find.byType(TextField), + ); + await tester.enterText(inputField, backspaceCmd); + await tester.pumpAndSettle(); + + await tester.hoverOnWidget( + find.descendant( + of: find.byType(ShortcutSettingTile), + matching: find.text(backspaceCmd), + ), + onHover: () async { + await tester.tap(find.byFlowySvg(FlowySvgs.edit_s)); + await tester.pumpAndSettle(); + + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.delete, + LogicalKeyboardKey.enter, + ], + tester: tester, + ); + await tester.pumpAndSettle(); + }, + ); + + // We expect to see conflict dialog + expect( + find.text( + LocaleKeys.settings_shortcutsPage_conflictDialog_confirmLabel.tr(), + ), + findsOneWidget, + ); + + // Press on confirm label + await tester.tap( + find.text( + LocaleKeys.settings_shortcutsPage_conflictDialog_confirmLabel.tr(), + ), + ); + await tester.pumpAndSettle(); + + // We expect the first ShortcutSettingTile to have one + // [KeyBadge] with `delete` label + final first = tester.widget(find.byType(ShortcutSettingTile).first) + as ShortcutSettingTile; + expect( + first.command.command, + 'delete', + ); + + // And the second one which is `Delete left character` to have none + // as it will have been overwritten + final second = tester.widget(find.byType(ShortcutSettingTile).at(1)) + as ShortcutSettingTile; + expect( + second.command.command, + '', + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/settings/sign_in_page_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/settings/sign_in_page_settings_test.dart new file mode 100644 index 0000000000000..b7074be3570d3 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/settings/sign_in_page_settings_test.dart @@ -0,0 +1,103 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart'; +import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:toastification/toastification.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + Finder findServerType(AuthenticatorType type) { + return find + .descendant( + of: find.byType(SettingsServerDropdownMenu), + matching: find.findTextInFlowyText( + type.label, + ), + ) + .last; + } + + group('sign-in page settings: ', () { + testWidgets('change server type', (tester) async { + await tester.initializeAppFlowy(); + + // reset the app to the default state + await useAppFlowyBetaCloudWithURL( + kAppflowyCloudUrl, + AuthenticatorType.appflowyCloud, + ); + + // open the settings page + final settingsButton = find.byType(DesktopSignInSettingsButton); + await tester.tapButton(settingsButton); + + expect(find.byType(SimpleSettingsDialog), findsOneWidget); + + // the default type should be appflowy cloud + final appflowyCloudType = findServerType(AuthenticatorType.appflowyCloud); + expect(appflowyCloudType, findsOneWidget); + + // change the server type to self-host + await tester.tapButton(appflowyCloudType); + final selfhostedButton = findServerType( + AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapButton(selfhostedButton); + + // update server url + const serverUrl = 'https://test.appflowy.cloud'; + await tester.enterText( + find.byKey(kSelfHostedTextInputFieldKey), + serverUrl, + ); + await tester.pumpAndSettle(); + await tester.tapButton( + find.findTextInFlowyText(LocaleKeys.button_save.tr()), + ); + + // wait the app to restart, and the tooltip to disappear + await tester.pumpUntilNotFound(find.byType(BuiltInToastBuilder)); + await tester.pumpAndSettle(const Duration(milliseconds: 250)); + + // open settings page to check the result + await tester.tapButton(settingsButton); + + // check the server type + expect( + findServerType(AuthenticatorType.appflowyCloudSelfHost), + findsOneWidget, + ); + // check the server url + expect( + find.text(serverUrl), + findsOneWidget, + ); + + // reset to appflowy cloud + await tester.tapButton( + findServerType(AuthenticatorType.appflowyCloudSelfHost), + ); + // change the server type to appflowy cloud + await tester.tapButton( + findServerType(AuthenticatorType.appflowyCloud), + ); + + // wait the app to restart, and the tooltip to disappear + await tester.pumpUntilNotFound(find.byType(BuiltInToastBuilder)); + await tester.pumpAndSettle(const Duration(milliseconds: 250)); + + // check the server type + await tester.tapButton(settingsButton); + expect( + findServerType(AuthenticatorType.appflowyCloud), + findsOneWidget, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/rename_current_item_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/rename_current_item_test.dart new file mode 100644 index 0000000000000..8780465c32fa9 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/rename_current_item_test.dart @@ -0,0 +1,50 @@ +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Rename current view item', () { + testWidgets('by F2 shortcut', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await FlowyTestKeyboard.simulateKeyDownEvent( + [LogicalKeyboardKey.f2], + tester: tester, + ); + await tester.pumpAndSettle(); + + expect(find.byType(RenameViewPopover), findsOneWidget); + + await tester.enterText( + find.descendant( + of: find.byType(RenameViewPopover), + matching: find.byType(FlowyTextField), + ), + 'hello', + ); + await tester.pumpAndSettle(); + + // Dismiss rename popover + await tester.tap(find.byType(AppFlowyEditor)); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(SingleInnerViewItem), + matching: find.text('hello'), + ), + findsOneWidget, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_expand_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_expand_test.dart new file mode 100644 index 0000000000000..f2a1fae8ae9f2 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_expand_test.dart @@ -0,0 +1,48 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('sidebar expand test', () { + bool isExpanded({required FolderSpaceType type}) { + if (type == FolderSpaceType.private) { + return find + .descendant( + of: find.byType(PrivateSectionFolder), + matching: find.byType(ViewItem), + ) + .evaluate() + .isNotEmpty; + } + return false; + } + + testWidgets('first time the personal folder is expanded', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // first time is expanded + expect(isExpanded(type: FolderSpaceType.private), true); + + // collapse the personal folder + await tester.tapButton( + find.byTooltip(LocaleKeys.sideBar_clickToHidePrivate.tr()), + ); + expect(isExpanded(type: FolderSpaceType.private), false); + + // expand the personal folder + await tester.tapButton( + find.byTooltip(LocaleKeys.sideBar_clickToHidePrivate.tr()), + ); + expect(isExpanded(type: FolderSpaceType.private), true); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_favorites_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_favorites_test.dart new file mode 100644 index 0000000000000..729ee62a3ed40 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_favorites_test.dart @@ -0,0 +1,200 @@ +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/base.dart'; +import '../../shared/common_operations.dart'; +import '../../shared/expectation.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Favorites', () { + testWidgets( + 'Toggle favorites for views creates / removes the favorite header along with favorite views', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // no favorite folder + expect(find.byType(FavoriteFolder), findsNothing); + + // create the nested views + final names = [ + 1, + 2, + ].map((e) => 'document_$e').toList(); + for (var i = 0; i < names.length; i++) { + final parentName = i == 0 ? gettingStarted : names[i - 1]; + await tester.createNewPageWithNameUnderParent( + name: names[i], + parentName: parentName, + ); + tester.expectToSeePageName(names[i], parentName: parentName); + } + + await tester.favoriteViewByName(gettingStarted); + expect( + tester.findFavoritePageName(gettingStarted), + findsOneWidget, + ); + + await tester.favoriteViewByName(names[1]); + expect( + tester.findFavoritePageName(names[1]), + findsNWidgets(1), + ); + + await tester.unfavoriteViewByName(gettingStarted); + expect( + tester.findFavoritePageName(gettingStarted), + findsNothing, + ); + expect( + tester.findFavoritePageName( + names[1], + ), + findsOneWidget, + ); + + await tester.unfavoriteViewByName(names[1]); + expect( + tester.findFavoritePageName( + names[1], + ), + findsNothing, + ); + }); + + testWidgets( + 'renaming a favorite view updates name under favorite header', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const name = 'test'; + await tester.favoriteViewByName(gettingStarted); + await tester.hoverOnPageName( + gettingStarted, + onHover: () async { + await tester.renamePage(name); + await tester.pumpAndSettle(); + }, + ); + expect( + tester.findPageName(name), + findsNWidgets(2), + ); + expect( + tester.findFavoritePageName(name), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'deleting first level favorite view removes its instance from favorite header, deleting root level views leads to removal of all favorites that are its children', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final names = [1, 2].map((e) => 'document_$e').toList(); + for (var i = 0; i < names.length; i++) { + final parentName = i == 0 ? gettingStarted : names[i - 1]; + await tester.createNewPageWithNameUnderParent( + name: names[i], + parentName: parentName, + ); + tester.expectToSeePageName(names[i], parentName: parentName); + } + await tester.favoriteViewByName(gettingStarted); + await tester.favoriteViewByName(names[0]); + await tester.favoriteViewByName(names[1]); + + expect( + find.byWidgetPredicate( + (widget) => + widget is SingleInnerViewItem && + widget.view.isFavorite && + widget.spaceType == FolderSpaceType.favorite, + ), + findsNWidgets(3), + ); + + await tester.hoverOnPageName( + names[1], + onHover: () async { + await tester.tapDeletePageButton(); + await tester.pumpAndSettle(); + }, + ); + + expect( + tester.findAllFavoritePages(), + findsNWidgets(2), + ); + + await tester.hoverOnPageName( + gettingStarted, + onHover: () async { + await tester.tapDeletePageButton(); + await tester.pumpAndSettle(); + }, + ); + + expect( + tester.findAllFavoritePages(), + findsNothing, + ); + }, + ); + + testWidgets( + 'view selection is synced between favorites and personal folder', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + await tester.favoriteViewByName(gettingStarted); + expect( + find.byWidgetPredicate( + (widget) => + widget is FlowyHover && + widget.isSelected != null && + widget.isSelected!(), + ), + findsNWidgets(1), + ); + }, + ); + + testWidgets( + 'context menu opens up for favorites', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(); + await tester.favoriteViewByName(gettingStarted); + await tester.hoverOnPageName( + gettingStarted, + useLast: false, + onHover: () async { + await tester.tapPageOptionButton(); + await tester.pumpAndSettle(); + expect( + find.byType(PopoverContainer), + findsOneWidget, + ); + }, + ); + await tester.pumpAndSettle(); + }, + ); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart new file mode 100644 index 0000000000000..7c7b9e1f1c7e7 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_icon_test.dart @@ -0,0 +1,229 @@ +import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/base.dart'; +import '../../shared/common_operations.dart'; +import '../../shared/emoji.dart'; +import '../../shared/expectation.dart'; + +void main() { + final emoji = EmojiIconData.emoji('😁'); + + setUpAll(() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + RecentIcons.enable = false; + }); + + tearDownAll(() { + RecentIcons.enable = true; + }); + + Future loadIcon() async { + await loadIconGroups(); + final groups = kIconGroups!; + final firstGroup = groups.first; + final firstIcon = firstGroup.icons.first; + return EmojiIconData.icon( + IconsData( + firstGroup.name, + firstIcon.content, + firstIcon.name, + builtInSpaceColors.first, + ), + ); + } + + testWidgets('Update page emoji in sidebar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create document, board, grid and calendar views + for (final value in ViewLayoutPB.values) { + if (value == ViewLayoutPB.Chat) { + continue; + } + await tester.createNewPageWithNameUnderParent( + name: value.name, + parentName: gettingStarted, + layout: value, + ); + + // update its emoji + await tester.updatePageIconInSidebarByName( + name: value.name, + parentName: gettingStarted, + layout: value, + icon: emoji, + ); + + tester.expectViewHasIcon( + value.name, + value, + emoji, + ); + } + }); + + testWidgets('Update page emoji in title bar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create document, board, grid and calendar views + for (final value in ViewLayoutPB.values) { + if (value == ViewLayoutPB.Chat) { + continue; + } + + await tester.createNewPageWithNameUnderParent( + name: value.name, + parentName: gettingStarted, + layout: value, + ); + + // update its emoji + await tester.updatePageIconInTitleBarByName( + name: value.name, + layout: value, + icon: emoji, + ); + + tester.expectViewHasIcon( + value.name, + value, + emoji, + ); + + tester.expectViewTitleHasIcon( + value.name, + value, + emoji, + ); + } + }); + + testWidgets('Emoji Search Bar Get Focus', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create document, board, grid and calendar views + for (final value in ViewLayoutPB.values) { + if (value == ViewLayoutPB.Chat) { + continue; + } + + await tester.createNewPageWithNameUnderParent( + name: value.name, + parentName: gettingStarted, + layout: value, + ); + + await tester.openPage( + value.name, + layout: value, + ); + final title = find.descendant( + of: find.byType(ViewTitleBar), + matching: find.text(value.name), + ); + await tester.tapButton(title); + await tester.tapButton(find.byType(EmojiPickerButton)); + + final emojiPicker = find.byType(FlowyEmojiPicker); + expect(emojiPicker, findsOneWidget); + final textField = find.descendant( + of: emojiPicker, + matching: find.byType(FlowyTextField), + ); + expect(textField, findsOneWidget); + final textFieldWidget = + textField.evaluate().first.widget as FlowyTextField; + assert(textFieldWidget.focusNode!.hasFocus); + await tester.tapEmoji(emoji.emoji); + await tester.pumpAndSettle(); + tester.expectViewHasIcon( + value.name, + value, + emoji, + ); + } + }); + + testWidgets('Update page icon in sidebar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + final iconData = await loadIcon(); + + // create document, board, grid and calendar views + for (final value in ViewLayoutPB.values) { + if (value == ViewLayoutPB.Chat) { + continue; + } + await tester.createNewPageWithNameUnderParent( + name: value.name, + parentName: gettingStarted, + layout: value, + ); + + // update its icon + await tester.updatePageIconInSidebarByName( + name: value.name, + parentName: gettingStarted, + layout: value, + icon: iconData, + ); + + tester.expectViewHasIcon( + value.name, + value, + iconData, + ); + } + }); + + testWidgets('Update page icon in title bar', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + final iconData = await loadIcon(); + + // create document, board, grid and calendar views + for (final value in ViewLayoutPB.values) { + if (value == ViewLayoutPB.Chat) { + continue; + } + + await tester.createNewPageWithNameUnderParent( + name: value.name, + parentName: gettingStarted, + layout: value, + ); + + // update its icon + await tester.updatePageIconInTitleBarByName( + name: value.name, + layout: value, + icon: iconData, + ); + + tester.expectViewHasIcon( + value.name, + value, + iconData, + ); + + tester.expectViewTitleHasIcon( + value.name, + value, + iconData, + ); + } + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart new file mode 100644 index 0000000000000..304e8e2e35e35 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test.dart @@ -0,0 +1,206 @@ +import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; +import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('sidebar:', () { + testWidgets('create a new page', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new page + await tester.tapNewPageButton(); + + // expect to see a new document + tester.expectToSeePageName(''); + // and with one paragraph block + expect(find.byType(ParagraphBlockComponentWidget), findsOneWidget); + }); + + testWidgets('create a new document, grid, board and calendar', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + for (final layout in ViewLayoutPB.values) { + if (layout == ViewLayoutPB.Chat) { + continue; + } + // create a new page + final name = 'AppFlowy_$layout'; + await tester.createNewPageWithNameUnderParent( + name: name, + layout: layout, + ); + + // expect to see a new page + tester.expectToSeePageName( + name, + layout: layout, + ); + + switch (layout) { + case ViewLayoutPB.Document: + // and with one paragraph block + expect(find.byType(ParagraphBlockComponentWidget), findsOneWidget); + break; + case ViewLayoutPB.Grid: + expect(find.byType(GridPage), findsOneWidget); + break; + case ViewLayoutPB.Board: + expect(find.byType(DesktopBoardPage), findsOneWidget); + break; + case ViewLayoutPB.Calendar: + expect(find.byType(CalendarPage), findsOneWidget); + break; + case ViewLayoutPB.Chat: + break; + } + + await tester.openPage(gettingStarted); + } + }); + + testWidgets('create some nested pages, and move them', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final names = [1, 2, 3, 4].map((e) => 'document_$e').toList(); + for (var i = 0; i < names.length; i++) { + final parentName = i == 0 ? gettingStarted : names[i - 1]; + await tester.createNewPageWithNameUnderParent( + name: names[i], + parentName: parentName, + ); + tester.expectToSeePageName(names[i], parentName: parentName); + } + + // move the document_3 to the getting started page + await tester.movePageToOtherPage( + name: names[3], + parentName: gettingStarted, + layout: ViewLayoutPB.Document, + parentLayout: ViewLayoutPB.Document, + ); + final fromId = tester + .widget(tester.findPageName(names[3])) + .view + .parentViewId; + final toId = tester + .widget(tester.findPageName(gettingStarted)) + .view + .id; + expect(fromId, toId); + + // move the document_2 before document_1 + await tester.movePageToOtherPage( + name: names[2], + parentName: gettingStarted, + layout: ViewLayoutPB.Document, + parentLayout: ViewLayoutPB.Document, + position: DraggableHoverPosition.bottom, + ); + final childViews = tester + .widget(tester.findPageName(gettingStarted)) + .view + .childViews; + expect( + childViews[0].id, + tester + .widget(tester.findPageName(names[2])) + .view + .id, + ); + expect( + childViews[1].id, + tester + .widget(tester.findPageName(names[0])) + .view + .id, + ); + expect( + childViews[2].id, + tester + .widget(tester.findPageName(names[3])) + .view + .id, + ); + }); + + testWidgets('unable to move a document into a database', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const document = 'document'; + await tester.createNewPageWithNameUnderParent( + name: document, + openAfterCreated: false, + ); + tester.expectToSeePageName(document); + + const grid = 'grid'; + await tester.createNewPageWithNameUnderParent( + name: grid, + layout: ViewLayoutPB.Grid, + openAfterCreated: false, + ); + tester.expectToSeePageName(grid, layout: ViewLayoutPB.Grid); + + // move the document to the grid page + await tester.movePageToOtherPage( + name: document, + parentName: grid, + layout: ViewLayoutPB.Document, + parentLayout: ViewLayoutPB.Grid, + ); + + // it should not be moved + final childViews = tester + .widget(tester.findPageName(gettingStarted)) + .view + .childViews; + expect( + childViews[0].name, + document, + ); + expect( + childViews[1].name, + grid, + ); + }); + + testWidgets('unable to create a new database inside the existing one', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const grid = 'grid'; + await tester.createNewPageWithNameUnderParent( + name: grid, + layout: ViewLayoutPB.Grid, + ); + tester.expectToSeePageName(grid, layout: ViewLayoutPB.Grid); + + await tester.hoverOnPageName( + grid, + layout: ViewLayoutPB.Grid, + onHover: () async { + expect(find.byType(ViewAddButton), findsNothing); + expect(find.byType(ViewMoreActionPopover), findsOneWidget); + }, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test_runner.dart new file mode 100644 index 0000000000000..9e4212f95597f --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_test_runner.dart @@ -0,0 +1,17 @@ +import 'package:integration_test/integration_test.dart'; + +import 'sidebar_favorites_test.dart' as sidebar_favorite_test; +import 'sidebar_icon_test.dart' as sidebar_icon_test; +import 'sidebar_test.dart' as sidebar_test; +import 'sidebar_view_item_test.dart' as sidebar_view_item_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Sidebar integration tests + sidebar_test.main(); + // sidebar_expanded_test.main(); + sidebar_favorite_test.main(); + sidebar_icon_test.main(); + sidebar_view_item_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_view_item_test.dart b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_view_item_test.dart new file mode 100644 index 0000000000000..1400ccebe31ed --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/sidebar/sidebar_view_item_test.dart @@ -0,0 +1,49 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/emoji.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Sidebar view item tests', () { + testWidgets('Access view item context menu by right click', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // Right click on the view item and change icon + await tester.hoverOnWidget( + find.byType(ViewItem), + onHover: () async { + await tester.tap(find.byType(ViewItem), buttons: kSecondaryButton); + await tester.pumpAndSettle(); + }, + ); + + // Change icon + final changeIconButton = + find.text(LocaleKeys.document_plugins_cover_changeIcon.tr()); + + await tester.tapButton(changeIconButton); + await tester.pumpUntilFound(find.byType(FlowyEmojiPicker)); + + const emoji = '😁'; + await tester.tapEmoji(emoji); + await tester.pumpAndSettle(); + + tester.expectViewHasIcon( + gettingStarted, + ViewLayoutPB.Document, + EmojiIconData.emoji(emoji), + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/board_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/board_test.dart new file mode 100644 index 0000000000000..5e88b386979c3 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/board_test.dart @@ -0,0 +1,44 @@ +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +/// Integration tests for an empty board. The [TestWorkspaceService] will load +/// a workspace from an empty board `assets/test/workspaces/board.zip` for all +/// tests. +/// +/// To create another integration test with a preconfigured workspace. +/// Use the following steps. +/// 1. Create a new workspace from the AppFlowy launch screen. +/// 2. Modify the workspace until it is suitable as the starting point for +/// the integration test you need to land. +/// 3. Use a zip utility program to zip the workspace folder that you created. +/// 4. Add the zip file under `assets/test/workspaces/` +/// 5. Add a new enumeration to [TestWorkspace] in `integration_test/utils/data.dart`. +/// For example, if you added a workspace called `empty_calendar.zip`, +/// then [TestWorkspace] should have the following value: +/// ```dart +/// enum TestWorkspace { +/// board('board'), +/// empty_calendar('empty_calendar'); +/// +/// /* code */ +/// } +/// ``` +/// 6. Double check that the .zip file that you added is included as an asset in +/// the pubspec.yaml file under appflowy_flutter. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + const service = TestWorkspaceService(TestWorkspace.board); + + group('board', () { + setUpAll(() async => service.setUpAll()); + setUp(() async => service.setUp()); + + testWidgets('open the board with data structure in v0.2.0', (tester) async { + await tester.initializeAppFlowy(); + expect(find.byType(AppFlowyBoard), findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/code_block_language_selector_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/code_block_language_selector_test.dart new file mode 100644 index 0000000000000..e522e2fc73c2c --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/code_block_language_selector_test.dart @@ -0,0 +1,91 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_language_selector.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/base.dart'; +import '../../shared/common_operations.dart'; +import '../../shared/document_test_operations.dart'; +import '../document/document_codeblock_paste_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Code Block Language Selector Test', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + /// create a new document + await tester.createNewPageWithNameUnderParent(); + + /// tap editor to get focus + await tester.tapButton(find.byType(AppFlowyEditor)); + + expect(find.byType(CodeBlockLanguageSelector), findsNothing); + await insertCodeBlockInDocument(tester); + + ///tap button + await tester.hoverOnWidget(find.byType(CodeBlockComponentWidget)); + await tester + .tapButtonWithName(LocaleKeys.document_codeBlock_language_auto.tr()); + expect(find.byType(CodeBlockLanguageSelector), findsOneWidget); + + for (var i = 0; i < 3; ++i) { + await onKey(tester, LogicalKeyboardKey.arrowDown); + } + for (var i = 0; i < 2; ++i) { + await onKey(tester, LogicalKeyboardKey.arrowUp); + } + + await onKey(tester, LogicalKeyboardKey.enter); + + final editorState = tester.editor.getCurrentEditorState(); + String language = editorState + .getNodeAtPath([0])! + .attributes[CodeBlockKeys.language] + .toString(); + expect( + language.toLowerCase(), + defaultCodeBlockSupportedLanguages.first.toLowerCase(), + ); + + await tester.hoverOnWidget(find.byType(CodeBlockComponentWidget)); + await tester.tapButtonWithName(language); + + await onKey(tester, LogicalKeyboardKey.arrowUp); + await onKey(tester, LogicalKeyboardKey.enter); + + language = editorState + .getNodeAtPath([0])! + .attributes[CodeBlockKeys.language] + .toString(); + expect( + language.toLowerCase(), + defaultCodeBlockSupportedLanguages.last.toLowerCase(), + ); + + await tester.hoverOnWidget(find.byType(CodeBlockComponentWidget)); + await tester.tapButtonWithName(language); + tester.testTextInput.enterText("rust"); + await onKey(tester, LogicalKeyboardKey.delete); + await onKey(tester, LogicalKeyboardKey.delete); + await onKey(tester, LogicalKeyboardKey.arrowDown); + tester.testTextInput.enterText("st"); + await onKey(tester, LogicalKeyboardKey.arrowDown); + await onKey(tester, LogicalKeyboardKey.enter); + language = editorState + .getNodeAtPath([0])! + .attributes[CodeBlockKeys.language] + .toString(); + expect(language.toLowerCase(), 'rust'); + }); +} + +Future onKey(WidgetTester tester, LogicalKeyboardKey key) async { + await tester.simulateKeyEvent(key); + await tester.pumpAndSettle(); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart new file mode 100644 index 0000000000000..554a6eecbfb5c --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/emoji_shortcut_test.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/editor_component/service/editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + // May be better to move this to an existing test but unsure what it fits with + group('Keyboard shortcuts related to emojis', () { + testWidgets('cmd/ctrl+alt+e shortcut opens the emoji picker', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final Finder editor = find.byType(AppFlowyEditor); + await tester.tap(editor); + await tester.pumpAndSettle(); + + expect(find.byType(EmojiSelectionMenu), findsNothing); + + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + Platform.isMacOS + ? LogicalKeyboardKey.meta + : LogicalKeyboardKey.control, + LogicalKeyboardKey.alt, + LogicalKeyboardKey.keyE, + ], + tester: tester, + ); + + expect(find.byType(EmojiSelectionMenu), findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/empty_document_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/empty_document_test.dart new file mode 100644 index 0000000000000..6712e6959dd37 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/empty_document_test.dart @@ -0,0 +1,132 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; + +/// Integration tests for an empty document. The [TestWorkspaceService] will load a workspace from an empty document `assets/test/workspaces/empty_document.zip` for all tests. +/// +/// To create another integration test with a preconfigured workspace. Use the following steps: +/// 1. Create a new workspace from the AppFlowy launch screen. +/// 2. Modify the workspace until it is suitable as the starting point for the integration test you need to land. +/// 3. Use a zip utility program to zip the workspace folder that you created. +/// 4. Add the zip file under `assets/test/workspaces/` +/// 5. Add a new enumeration to [TestWorkspace] in `integration_test/utils/data.dart`. For example, if you added a workspace called `empty_calendar.zip`, then [TestWorkspace] should have the following value: +/// ```dart +/// enum TestWorkspace { +/// board('board'), +/// empty_calendar('empty_calendar'); +/// +/// /* code */ +/// } +/// ``` +/// 6. Double check that the .zip file that you added is included as an asset in the pubspec.yaml file under appflowy_flutter. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + const service = TestWorkspaceService(TestWorkspace.emptyDocument); + + group('Tests on a workspace with only an empty document', () { + setUpAll(() async => service.setUpAll()); + setUp(() async => service.setUp()); + + testWidgets('/board shortcut creates a new board and view of the board', + (tester) async { + await tester.initializeAppFlowy(); + + // Needs tab to obtain focus for the app flowy editor. + // by default the tap appears at the center of the widget. + final Finder editor = find.byType(AppFlowyEditor); + await tester.tap(editor); + await tester.pumpAndSettle(); + + // tester.sendText() cannot be used since the editor + // does not contain any EditableText widgets. + // to interact with the app during an integration test, + // simulate physical keyboard events. + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.slash, + LogicalKeyboardKey.keyB, + LogicalKeyboardKey.keyO, + LogicalKeyboardKey.keyA, + LogicalKeyboardKey.keyR, + LogicalKeyboardKey.keyD, + LogicalKeyboardKey.arrowDown, + ], + tester: tester, + ); + + // Checks whether the options in the selection menu + // for /board exist. + expect(find.byType(SelectionMenuItemWidget), findsAtLeastNWidgets(2)); + + // Finalizes the slash command that creates the board. + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.enter, + ], + tester: tester, + ); + + // Checks whether new board is referenced and properly on the page. + expect(find.byType(BuiltInPageWidget), findsOneWidget); + + // Checks whether the new database was created + const newBoardLabel = "Untitled"; + expect(find.text(newBoardLabel), findsOneWidget); + + // Checks whether a view of the database was created + const viewOfBoardLabel = "View of Untitled"; + expect(find.text(viewOfBoardLabel), findsNWidgets(2)); + }); + + testWidgets('/grid shortcut creates a new grid and view of the grid', + (tester) async { + await tester.initializeAppFlowy(); + + // Needs tab to obtain focus for the app flowy editor. + // by default the tap appears at the center of the widget. + final Finder editor = find.byType(AppFlowyEditor); + await tester.tap(editor); + await tester.pumpAndSettle(); + + // tester.sendText() cannot be used since the editor + // does not contain any EditableText widgets. + // to interact with the app during an integration test, + // simulate physical keyboard events. + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + LogicalKeyboardKey.slash, + LogicalKeyboardKey.keyG, + LogicalKeyboardKey.keyR, + LogicalKeyboardKey.keyI, + LogicalKeyboardKey.keyD, + LogicalKeyboardKey.arrowDown, + ], + tester: tester, + ); + + // Checks whether the options in the selection menu + // for /grid exist. + expect(find.byType(SelectionMenuItemWidget), findsAtLeastNWidgets(2)); + + // Finalizes the slash command that creates the board. + await simulateKeyDownEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + // Checks whether new board is referenced and properly on the page. + expect(find.byType(BuiltInPageWidget), findsOneWidget); + + // Checks whether the new database was created + const newTableLabel = "Untitled"; + expect(find.text(newTableLabel), findsOneWidget); + + // Checks whether a view of the database was created + const viewOfTableLabel = "View of Untitled"; + expect(find.text(viewOfTableLabel), findsNWidgets(2)); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart new file mode 100644 index 0000000000000..4a38dde92025d --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart'; +import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('hotkeys test', () { + testWidgets('toggle theme mode', (tester) async { + await tester.initializeAppFlowy(); + + await tester.tapAnonymousSignInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.workspace); + await tester.pumpAndSettle(); + + final appFinder = find.byType(MaterialApp).first; + ThemeMode? themeMode = tester.widget(appFinder).themeMode; + + expect(themeMode, ThemeMode.system); + + await tester.tapButton( + find.bySemanticsLabel( + LocaleKeys.settings_workspacePage_appearance_options_light.tr(), + ), + ); + await tester.pumpAndSettle(); + + themeMode = tester.widget(appFinder).themeMode; + expect(themeMode, ThemeMode.light); + + await tester.tapButton( + find.bySemanticsLabel( + LocaleKeys.settings_workspacePage_appearance_options_dark.tr(), + ), + ); + await tester.pumpAndSettle(); + + themeMode = tester.widget(appFinder).themeMode; + expect(themeMode, ThemeMode.dark); + + await tester.tap(find.byType(SettingsDialog)); + await tester.pumpAndSettle(); + + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + Platform.isMacOS + ? LogicalKeyboardKey.meta + : LogicalKeyboardKey.control, + LogicalKeyboardKey.shift, + LogicalKeyboardKey.keyL, + ], + tester: tester, + ); + await tester.pumpAndSettle(); + + themeMode = tester.widget(appFinder).themeMode; + expect(themeMode, ThemeMode.light); + }); + + testWidgets('show or hide home menu', (tester) async { + await tester.initializeAppFlowy(); + + await tester.tapAnonymousSignInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + await tester.pumpAndSettle(); + + expect(find.byType(HomeSideBar), findsOneWidget); + + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + Platform.isMacOS + ? LogicalKeyboardKey.meta + : LogicalKeyboardKey.control, + LogicalKeyboardKey.backslash, + ], + tester: tester, + ); + + await tester.pumpAndSettle(); + + expect(find.byType(HomeSideBar), findsNothing); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/import_files_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/import_files_test.dart new file mode 100644 index 0000000000000..84da89f6b78f7 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/import_files_test.dart @@ -0,0 +1,95 @@ +import 'dart:io'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; + +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('import files', () { + testWidgets('import multiple markdown files', (tester) async { + final context = await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // expect to see a getting started page + tester.expectToSeePageName(gettingStarted); + + await tester.tapAddViewButton(); + await tester.tapImportButton(); + + final testFileNames = ['test1.md', 'test2.md']; + final paths = []; + for (final fileName in testFileNames) { + final str = await rootBundle.loadString( + 'assets/test/workspaces/markdowns/$fileName', + ); + final path = p.join(context.applicationDataDirectory, fileName); + paths.add(path); + File(path).writeAsStringSync(str); + } + // mock get files + mockPickFilePaths( + paths: testFileNames + .map((e) => p.join(context.applicationDataDirectory, e)) + .toList(), + ); + + await tester.tapTextAndMarkdownButton(); + + tester.expectToSeePageName('test1'); + tester.expectToSeePageName('test2'); + }); + + testWidgets('import markdown file with table', (tester) async { + final context = await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // expect to see a getting started page + tester.expectToSeePageName(gettingStarted); + + await tester.tapAddViewButton(); + await tester.tapImportButton(); + + const testFileName = 'markdown_with_table.md'; + final paths = []; + final str = await rootBundle.loadString( + 'assets/test/workspaces/markdowns/$testFileName', + ); + final path = p.join(context.applicationDataDirectory, testFileName); + paths.add(path); + File(path).writeAsStringSync(str); + // mock get files + mockPickFilePaths( + paths: paths, + ); + + await tester.tapTextAndMarkdownButton(); + + tester.expectToSeePageName('markdown_with_table'); + + // expect to see all content of markdown file along with table + await tester.openPage('markdown_with_table'); + + final importedPageEditorState = tester.editor.getCurrentEditorState(); + expect( + importedPageEditorState.getNodeAtPath([0])!.type, + HeadingBlockKeys.type, + ); + expect( + importedPageEditorState.getNodeAtPath([1])!.type, + HeadingBlockKeys.type, + ); + expect( + importedPageEditorState.getNodeAtPath([2])!.type, + SimpleTableBlockKeys.type, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/language_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/language_test.dart new file mode 100644 index 0000000000000..3c07a2df5e8bf --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/language_test.dart @@ -0,0 +1,35 @@ +import 'package:appflowy/user/presentation/screens/skip_log_in_screen.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document', () { + testWidgets( + 'change the language successfully when launching the app for the first time', + (tester) async { + await tester.initializeAppFlowy(); + + await tester.tapLanguageSelectorOnWelcomePage(); + expect(find.byType(LanguageItemsListView), findsOneWidget); + + await tester.tapLanguageItem(languageCode: 'zh', countryCode: 'CN'); + tester.expectToSeeText('开始'); + + await tester.tapLanguageItem(languageCode: 'en', scrollDelta: -100); + tester.expectToSeeText('Quick Start'); + + await tester.tapLanguageItem(languageCode: 'it', countryCode: 'IT'); + tester.expectToSeeText('Andiamo'); + }); + + /// Make sure this test is executed after the test above. + testWidgets('check the language after relaunching the app', (tester) async { + await tester.initializeAppFlowy(); + tester.expectToSeeText('Andiamo'); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart new file mode 100644 index 0000000000000..9a4fe30815b1e --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/share_markdown_test.dart @@ -0,0 +1,117 @@ +import 'dart:io'; + +import 'package:appflowy/plugins/shared/share/share_button.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; + +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('share markdown in document page', () { + testWidgets('click the share button in document page', (tester) async { + final context = await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // mock the file picker + final path = await mockSaveFilePath( + p.join(context.applicationDataDirectory, 'test.md'), + ); + // click the share button and select markdown + await tester.tapShareButton(); + await tester.tapMarkdownButton(); + + // expect to see the success dialog + tester.expectToExportSuccess(); + + final file = File(path); + final isExist = file.existsSync(); + expect(isExist, true); + final markdown = file.readAsStringSync(); + expect(markdown, expectedMarkdown); + }); + + testWidgets( + 'share the markdown after renaming the document name', + (tester) async { + final context = await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // expect to see a getting started page + tester.expectToSeePageName(gettingStarted); + + // rename the document + await tester.hoverOnPageName( + gettingStarted, + onHover: () async { + await tester.renamePage('example'); + }, + ); + + final shareButton = find.byType(ShareButton); + final shareButtonState = tester.widget(shareButton) as ShareButton; + + final path = await mockSaveFilePath( + p.join( + context.applicationDataDirectory, + '${shareButtonState.view.name}.md', + ), + ); + + // click the share button and select markdown + await tester.tapShareButton(); + await tester.tapMarkdownButton(); + + // expect to see the success dialog + tester.expectToExportSuccess(); + + final file = File(path); + final isExist = file.existsSync(); + expect(isExist, true); + }, + ); + }); +} + +const expectedMarkdown = ''' +# Welcome to AppFlowy! +## Here are the basics +- [ ] Click anywhere and just start typing. +- [ ] Highlight any text, and use the editing menu to _style_ **your** writing `however` you ~~like.~~ +- [ ] As soon as you type `/` a menu will pop up. Select different types of content blocks you can add. +- [ ] Type `/` followed by `/bullet` or `/num` to create a list. +- [x] Click `+ New Page `button at the bottom of your sidebar to add a new page. +- [ ] Click `+` next to any page title in the sidebar to quickly add a new subpage, `Document`, `Grid`, or `Kanban Board`. + +--- + +## Keyboard shortcuts, markdown, and code block +1. Keyboard shortcuts [guide](https://appflowy.gitbook.io/docs/essential-documentation/shortcuts) +1. Markdown [reference](https://appflowy.gitbook.io/docs/essential-documentation/markdown) +1. Type `/code` to insert a code block +```rust +// This is the main function. +fn main() { + // Print text to the console. + println!("Hello World!"); +} +``` + +## Have a question❓ +> Click `?` at the bottom right for help and support. + +> 🥰 +> +> Like AppFlowy? Follow us: +> [GitHub](https://github.com/AppFlowy-IO/AppFlowy) +> [Twitter](https://twitter.com/appflowy): @appflowy +> [Newsletter](https://blog-appflowy.ghost.io/) +> + + + + +'''; diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/switch_folder_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/switch_folder_test.dart new file mode 100644 index 0000000000000..b9e1303279775 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/switch_folder_test.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/prelude.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('customize the folder path', () { + if (Platform.isWindows) { + return; + } + + // testWidgets('switch to B from A, then switch to A again', (tester) async { + // const userA = 'UserA'; + // const userB = 'UserB'; + + // final initialPath = p.join(userA, appFlowyDataFolder); + // final context = await tester.initializeAppFlowy( + // pathExtension: initialPath, + // ); + // // remove the last extension + // final rootPath = context.applicationDataDirectory.replaceFirst( + // initialPath, + // '', + // ); + + // await tester.tapGoButton(); + // await tester.expectToSeeHomePageWithGetStartedPage(); + + // // switch to user B + // { + // // set user name for userA + // await tester.openSettings(); + // await tester.openSettingsPage(SettingsPage.user); + // await tester.enterUserName(userA); + + // await tester.openSettingsPage(SettingsPage.files); + // await tester.pumpAndSettle(); + + // // mock the file_picker result + // await mockGetDirectoryPath( + // p.join(rootPath, userB), + // ); + // await tester.tapCustomLocationButton(); + // await tester.pumpAndSettle(); + // await tester.expectToSeeHomePageWithGetStartedPage(); + + // // set user name for userB + // await tester.openSettings(); + // await tester.openSettingsPage(SettingsPage.user); + // await tester.enterUserName(userB); + // } + + // // switch to the userA + // { + // await tester.openSettingsPage(SettingsPage.files); + // await tester.pumpAndSettle(); + + // // mock the file_picker result + // await mockGetDirectoryPath( + // p.join(rootPath, userA), + // ); + // await tester.tapCustomLocationButton(); + + // await tester.pumpAndSettle(); + // await tester.expectToSeeHomePageWithGetStartedPage(); + // tester.expectToSeeUserName(userA); + // } + + // // switch to the userB again + // { + // await tester.openSettings(); + // await tester.openSettingsPage(SettingsPage.files); + // await tester.pumpAndSettle(); + + // // mock the file_picker result + // await mockGetDirectoryPath( + // p.join(rootPath, userB), + // ); + // await tester.tapCustomLocationButton(); + + // await tester.pumpAndSettle(); + // await tester.expectToSeeHomePageWithGetStartedPage(); + // tester.expectToSeeUserName(userB); + // } + // }); + + testWidgets('reset to default location', (tester) async { + await tester.initializeAppFlowy(); + + await tester.tapAnonymousSignInButton(); + + // home and readme document + await tester.expectToSeeHomePageWithGetStartedPage(); + + // open settings and restore the location + await tester.openSettings(); + await tester.openSettingsPage(SettingsPage.manageData); + await tester.restoreLocation(); + + expect( + await appFlowyApplicationDataDirectory().then((value) => value.path), + await getIt().getPath(), + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart new file mode 100644 index 0000000000000..d0bc391987e66 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/tabs_test.dart @@ -0,0 +1,334 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart'; +import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart'; +import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/keyboard.dart'; +import '../../shared/util.dart'; + +const _documentName = 'First Doc'; +const _documentTwoName = 'Second Doc'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Tabs', () { + testWidgets('open/navigate/close tabs', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // No tabs rendered yet + expect(find.byType(FlowyTab), findsNothing); + + await tester.createNewPageWithNameUnderParent(name: _documentName); + + await tester.createNewPageWithNameUnderParent(name: _documentTwoName); + + /// Open second menu item in a new tab + await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document); + + /// Open third menu item in a new tab + await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document); + + expect( + find.descendant( + of: find.byType(TabsManager), + matching: find.byType(FlowyTab), + ), + findsNWidgets(3), + ); + + /// Navigate to the second tab + await tester.tap( + find.descendant( + of: find.byType(FlowyTab), + matching: find.text(gettingStarted), + ), + ); + + /// Close tab by shortcut + await FlowyTestKeyboard.simulateKeyDownEvent( + [ + Platform.isMacOS + ? LogicalKeyboardKey.meta + : LogicalKeyboardKey.control, + LogicalKeyboardKey.keyW, + ], + tester: tester, + ); + + expect( + find.descendant( + of: find.byType(TabsManager), + matching: find.byType(FlowyTab), + ), + findsNWidgets(2), + ); + }); + + testWidgets('right click show tab menu, close others', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + expect( + find.descendant( + of: find.byType(TabsManager), + matching: find.byType(TabBar), + ), + findsNothing, + ); + + await tester.createNewPageWithNameUnderParent(name: _documentName); + await tester.createNewPageWithNameUnderParent(name: _documentTwoName); + + /// Open second menu item in a new tab + await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document); + + /// Open third menu item in a new tab + await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document); + + expect( + find.descendant( + of: find.byType(TabsManager), + matching: find.byType(FlowyTab), + ), + findsNWidgets(3), + ); + + /// Right click on second tab + await tester.tap( + buttons: kSecondaryButton, + find.descendant( + of: find.byType(FlowyTab), + matching: find.text(gettingStarted), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(TabMenu), findsOneWidget); + + final firstTabFinder = find.descendant( + of: find.byType(FlowyTab), + matching: find.text(_documentTwoName), + ); + final secondTabFinder = find.descendant( + of: find.byType(FlowyTab), + matching: find.text(gettingStarted), + ); + final thirdTabFinder = find.descendant( + of: find.byType(FlowyTab), + matching: find.text(_documentName), + ); + + expect(firstTabFinder, findsOneWidget); + expect(secondTabFinder, findsOneWidget); + expect(thirdTabFinder, findsOneWidget); + + // Close other tabs than the second item + await tester.tap(find.text(LocaleKeys.tabMenu_closeOthers.tr())); + await tester.pumpAndSettle(); + + // We expect to not find any tabs + expect(firstTabFinder, findsNothing); + expect(secondTabFinder, findsNothing); + expect(thirdTabFinder, findsNothing); + + // Expect second tab to be current page (current page has breadcrumb, cover title, + // and in this case view name in sidebar) + expect(find.text(gettingStarted), findsNWidgets(3)); + }); + + testWidgets('cannot close pinned tabs', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + expect( + find.descendant( + of: find.byType(TabsManager), + matching: find.byType(TabBar), + ), + findsNothing, + ); + + await tester.createNewPageWithNameUnderParent(name: _documentName); + await tester.createNewPageWithNameUnderParent(name: _documentTwoName); + + // Open second menu item in a new tab + await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document); + + // Open third menu item in a new tab + await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document); + + expect( + find.descendant( + of: find.byType(TabsManager), + matching: find.byType(FlowyTab), + ), + findsNWidgets(3), + ); + + const firstTab = _documentTwoName; + const secondTab = gettingStarted; + const thirdTab = _documentName; + + expect(tester.isTabAtIndex(firstTab, 0), isTrue); + expect(tester.isTabAtIndex(secondTab, 1), isTrue); + expect(tester.isTabAtIndex(thirdTab, 2), isTrue); + + expect(tester.isTabPinned(gettingStarted), isFalse); + + // Right click on second tab + await tester.openTabMenu(gettingStarted); + expect(find.byType(TabMenu), findsOneWidget); + + // Pin second tab + await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr())); + await tester.pumpAndSettle(); + + expect(tester.isTabPinned(gettingStarted), isTrue); + + /// Right click on first unpinned tab (second tab) + await tester.openTabMenu(_documentTwoName); + + // Close others + await tester.tap(find.text(LocaleKeys.tabMenu_closeOthers.tr())); + await tester.pumpAndSettle(); + + // We expect to find 2 tabs, the first pinned tab and the second tab + expect(find.byType(FlowyTab), findsNWidgets(2)); + expect(tester.isTabAtIndex(gettingStarted, 0), isTrue); + expect(tester.isTabAtIndex(_documentTwoName, 1), isTrue); + }); + + testWidgets('pin/unpin tabs proper order', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + expect( + find.descendant( + of: find.byType(TabsManager), + matching: find.byType(TabBar), + ), + findsNothing, + ); + + await tester.createNewPageWithNameUnderParent(name: _documentName); + await tester.createNewPageWithNameUnderParent(name: _documentTwoName); + + // Open second menu item in a new tab + await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document); + + // Open third menu item in a new tab + await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document); + + expect( + find.descendant( + of: find.byType(TabsManager), + matching: find.byType(FlowyTab), + ), + findsNWidgets(3), + ); + + const firstTabName = _documentTwoName; + const secondTabName = gettingStarted; + const thirdTabName = _documentName; + + // Expect correct order + expect(tester.isTabAtIndex(firstTabName, 0), isTrue); + expect(tester.isTabAtIndex(secondTabName, 1), isTrue); + expect(tester.isTabAtIndex(thirdTabName, 2), isTrue); + + // Pin second tab + await tester.openTabMenu(secondTabName); + await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr())); + await tester.pumpAndSettle(); + + expect(tester.isTabPinned(secondTabName), isTrue); + + // Expect correct order + expect(tester.isTabAtIndex(secondTabName, 0), isTrue); + expect(tester.isTabAtIndex(firstTabName, 1), isTrue); + expect(tester.isTabAtIndex(thirdTabName, 2), isTrue); + + // Pin new second tab (first tab) + await tester.openTabMenu(firstTabName); + await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr())); + await tester.pumpAndSettle(); + + expect(tester.isTabPinned(firstTabName), isTrue); + expect(tester.isTabPinned(secondTabName), isTrue); + expect(tester.isTabPinned(thirdTabName), isFalse); + + expect(tester.isTabAtIndex(secondTabName, 0), isTrue); + expect(tester.isTabAtIndex(firstTabName, 1), isTrue); + expect(tester.isTabAtIndex(thirdTabName, 2), isTrue); + + // Unpin second tab + await tester.openTabMenu(secondTabName); + await tester.tap(find.text(LocaleKeys.tabMenu_unpinTab.tr())); + await tester.pumpAndSettle(); + + expect(tester.isTabPinned(firstTabName), isTrue); + expect(tester.isTabPinned(secondTabName), isFalse); + expect(tester.isTabPinned(thirdTabName), isFalse); + + expect(tester.isTabAtIndex(firstTabName, 0), isTrue); + expect(tester.isTabAtIndex(secondTabName, 1), isTrue); + expect(tester.isTabAtIndex(thirdTabName, 2), isTrue); + }); + }); +} + +extension _TabsTester on WidgetTester { + bool isTabPinned(String tabName) { + final tabFinder = find.ancestor( + of: find.byWidgetPredicate( + (w) => w is ViewTabBarItem && w.view.name == tabName, + ), + matching: find.byType(FlowyTab), + ); + + final FlowyTab tabWidget = widget(tabFinder); + return tabWidget.pageManager.isPinned; + } + + bool isTabAtIndex(String tabName, int index) { + final tabFinder = find.ancestor( + of: find.byWidgetPredicate( + (w) => w is ViewTabBarItem && w.view.name == tabName, + ), + matching: find.byType(FlowyTab), + ); + + final pluginId = (widget(tabFinder) as FlowyTab).pageManager.plugin.id; + + final pluginIds = find + .byType(FlowyTab) + .evaluate() + .map((e) => (e.widget as FlowyTab).pageManager.plugin.id); + + return pluginIds.elementAt(index) == pluginId; + } + + Future openTabMenu(String tabName) async { + await tap( + buttons: kSecondaryButton, + find.ancestor( + of: find.byWidgetPredicate( + (w) => w is ViewTabBarItem && w.view.name == tabName, + ), + matching: find.byType(FlowyTab), + ), + ); + await pumpAndSettle(); + } +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart new file mode 100644 index 0000000000000..f7d94e8b4ad3e --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/uncategorized_test_runner_1.dart @@ -0,0 +1,21 @@ +import 'package:integration_test/integration_test.dart'; + +import 'emoji_shortcut_test.dart' as emoji_shortcut_test; +import 'hotkeys_test.dart' as hotkeys_test; +import 'import_files_test.dart' as import_files_test; +import 'share_markdown_test.dart' as share_markdown_test; +import 'zoom_in_out_test.dart' as zoom_in_out_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // This test must be run first, otherwise the CI will fail. + hotkeys_test.main(); + emoji_shortcut_test.main(); + hotkeys_test.main(); + emoji_shortcut_test.main(); + share_markdown_test.main(); + import_files_test.main(); + zoom_in_out_test.main(); + // DON'T add more tests here. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/zoom_in_out_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/zoom_in_out_test.dart new file mode 100644 index 0000000000000..f0cddadf68224 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/zoom_in_out_test.dart @@ -0,0 +1,122 @@ +import 'package:appflowy/startup/tasks/app_window_size_manager.dart'; +import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hotkey_manager/hotkey_manager.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../shared/base.dart'; +import '../../shared/common_operations.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Zoom in/out:', () { + Future resetAppFlowyScaleFactor( + WindowSizeManager windowSizeManager, + ) async { + appflowyScaleFactor = 1.0; + await windowSizeManager.setScaleFactor(1.0); + } + + testWidgets('Zoom in', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + double currentScaleFactor = 1.0; + + // this value can't be defined in the setUp method, because the windowSizeManager is not initialized yet. + final windowSizeManager = WindowSizeManager(); + await resetAppFlowyScaleFactor(windowSizeManager); + + // zoom in 2 times + for (final keycode in zoomInKeyCodes) { + if (UniversalPlatform.isLinux && + keycode.logicalKey == LogicalKeyboardKey.add) { + // Key LogicalKeyboardKey#79c9b(keyId: "0x0000002b", keyLabel: "+", debugName: "Add") not found in + // linux keyCode map + continue; + } + + // test each keycode 2 times + for (var i = 0; i < 2; i++) { + await tester.simulateKeyEvent( + keycode.logicalKey, + isControlPressed: !UniversalPlatform.isMacOS, + isMetaPressed: UniversalPlatform.isMacOS, + // Register the physical key for the "Add" key, otherwise the test will fail and throw an error: + // Physical key for LogicalKeyboardKey#79c9b(keyId: "0x0000002b", keyLabel: "+", debugName: "Add") + // not found in known physical keys + physicalKey: keycode.logicalKey == LogicalKeyboardKey.add + ? PhysicalKeyboardKey.equal + : null, + ); + + await tester.pumpAndSettle(); + + currentScaleFactor += 0.1; + + final scaleFactor = await windowSizeManager.getScaleFactor(); + expect(currentScaleFactor, appflowyScaleFactor); + expect(currentScaleFactor, scaleFactor); + } + } + }); + + testWidgets('Reset zoom', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + final windowSizeManager = WindowSizeManager(); + + for (final keycode in resetZoomKeyCodes) { + await tester.simulateKeyEvent( + keycode.logicalKey, + isControlPressed: !UniversalPlatform.isMacOS, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + final scaleFactor = await windowSizeManager.getScaleFactor(); + expect(1.0, appflowyScaleFactor); + expect(1.0, scaleFactor); + } + }); + + testWidgets('Zoom out', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + double currentScaleFactor = 1.0; + + final windowSizeManager = WindowSizeManager(); + await resetAppFlowyScaleFactor(windowSizeManager); + + // zoom out 2 times + for (final keycode in zoomOutKeyCodes) { + if (UniversalPlatform.isLinux && + keycode.logicalKey == LogicalKeyboardKey.numpadSubtract) { + // Key LogicalKeyboardKey#2c39f(keyId: "0x20000022d", keyLabel: "Numpad Subtract", debugName: "Numpad + // Subtract") not found in linux keyCode map + continue; + } + // test each keycode 2 times + for (var i = 0; i < 2; i++) { + await tester.simulateKeyEvent( + keycode.logicalKey, + isControlPressed: !UniversalPlatform.isMacOS, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + currentScaleFactor -= 0.1; + + final scaleFactor = await windowSizeManager.getScaleFactor(); + expect(currentScaleFactor, appflowyScaleFactor); + expect(currentScaleFactor, scaleFactor); + } + } + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_1.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_1.dart new file mode 100644 index 0000000000000..c91ba21edb823 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_1.dart @@ -0,0 +1,16 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/document/document_test_runner_1.dart' as document_test_runner_1; +import 'desktop/uncategorized/switch_folder_test.dart' as switch_folder_test; + +Future main() async { + await runIntegration1OnDesktop(); +} + +Future runIntegration1OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + switch_folder_test.main(); + document_test_runner_1.main(); + // DON'T add more tests here. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_2.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_2.dart new file mode 100644 index 0000000000000..99d6f7d58fbe6 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_2.dart @@ -0,0 +1,17 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/database/database_test_runner_1.dart' as database_test_runner_1; +import 'desktop/first_test/first_test.dart' as first_test; + +Future main() async { + await runIntegration2OnDesktop(); +} + +Future runIntegration2OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + first_test.main(); + + database_test_runner_1.main(); + // DON'T add more tests here. This is the second test runner for desktop. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_3.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_3.dart new file mode 100644 index 0000000000000..a9d3783f1db5e --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_3.dart @@ -0,0 +1,19 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/board/board_test_runner.dart' as board_test_runner; +import 'desktop/first_test/first_test.dart' as first_test; +import 'desktop/grid/grid_test_runner_1.dart' as grid_test_runner_1; + +Future main() async { + await runIntegration3OnDesktop(); +} + +Future runIntegration3OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + first_test.main(); + + board_test_runner.main(); + grid_test_runner_1.main(); + // DON'T add more tests here. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_4.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_4.dart new file mode 100644 index 0000000000000..e51c711549377 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_4.dart @@ -0,0 +1,17 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/document/document_test_runner_2.dart' as document_test_runner_2; +import 'desktop/first_test/first_test.dart' as first_test; + +Future main() async { + await runIntegration4OnDesktop(); +} + +Future runIntegration4OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + first_test.main(); + + document_test_runner_2.main(); + // DON'T add more tests here. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_5.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_5.dart new file mode 100644 index 0000000000000..be393e90c7338 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_5.dart @@ -0,0 +1,17 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/database/database_test_runner_2.dart' as database_test_runner_2; +import 'desktop/first_test/first_test.dart' as first_test; + +Future main() async { + await runIntegration5OnDesktop(); +} + +Future runIntegration5OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + first_test.main(); + + database_test_runner_2.main(); + // DON'T add more tests here. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_6.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_6.dart new file mode 100644 index 0000000000000..a1c5627b205bc --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_6.dart @@ -0,0 +1,22 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/first_test/first_test.dart' as first_test; +import 'desktop/settings/settings_runner.dart' as settings_test_runner; +import 'desktop/sidebar/sidebar_test_runner.dart' as sidebar_test_runner; +import 'desktop/uncategorized/uncategorized_test_runner_1.dart' + as uncategorized_test_runner_1; + +Future main() async { + await runIntegration6OnDesktop(); +} + +Future runIntegration6OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + first_test.main(); + + settings_test_runner.main(); + sidebar_test_runner.main(); + uncategorized_test_runner_1.main(); + // DON'T add more tests here. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_7.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_7.dart new file mode 100644 index 0000000000000..0200591c571b5 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_7.dart @@ -0,0 +1,17 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/document/document_test_runner_3.dart' as document_test_runner_3; +import 'desktop/first_test/first_test.dart' as first_test; + +Future main() async { + await runIntegration7OnDesktop(); +} + +Future runIntegration7OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + first_test.main(); + + document_test_runner_3.main(); + // DON'T add more tests here. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_8.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_8.dart new file mode 100644 index 0000000000000..5a706e5dec86b --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_8.dart @@ -0,0 +1,17 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/document/document_test_runner_4.dart' as document_test_runner_4; +import 'desktop/first_test/first_test.dart' as first_test; + +Future main() async { + await runIntegration8OnDesktop(); +} + +Future runIntegration8OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + first_test.main(); + + document_test_runner_4.main(); + // DON'T add more tests here. +} diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_9.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_9.dart new file mode 100644 index 0000000000000..3402db1fd91f5 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_9.dart @@ -0,0 +1,18 @@ +import 'package:integration_test/integration_test.dart'; + +import 'desktop/uncategorized/tabs_test.dart' as tabs_test; +import 'desktop/uncategorized/code_block_language_selector_test.dart' + as code_language_selector; +import 'desktop/first_test/first_test.dart' as first_test; + +Future main() async { + await runIntegration9OnDesktop(); +} + +Future runIntegration9OnDesktop() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + first_test.main(); + tabs_test.main(); + code_language_selector.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/cloud/cloud_runner.dart b/frontend/appflowy_flutter/integration_test/mobile/cloud/cloud_runner.dart new file mode 100644 index 0000000000000..c2f3d7103a49e --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/cloud/cloud_runner.dart @@ -0,0 +1,11 @@ +import 'document/publish_test.dart' as publish_test; +import 'document/share_link_test.dart' as share_link_test; +import 'space/space_test.dart' as space_test; +import 'workspace/workspace_operations_test.dart' as workspace_operations_test; + +Future main() async { + workspace_operations_test.main(); + share_link_test.main(); + publish_test.main(); + space_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/cloud/document/publish_test.dart b/frontend/appflowy_flutter/integration_test/mobile/cloud/document/publish_test.dart new file mode 100644 index 0000000000000..e6015d0896f95 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/cloud/document/publish_test.dart @@ -0,0 +1,110 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/constants.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('publish:', () { + testWidgets(''' +1. publish document +2. update path name +3. unpublish document +''', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + await tester.openPage(Constants.gettingStartedPageName); + await tester.editor.openMoreActionMenuOnMobile(); + + // click the publish button + await tester.editor.clickMoreActionItemOnMobile( + LocaleKeys.shareAction_publish.tr(), + ); + + // wait the notification dismiss + final publishSuccessText = find.findTextInFlowyText( + LocaleKeys.publish_publishSuccessfully.tr(), + ); + expect(publishSuccessText, findsOneWidget); + await tester.pumpUntilNotFound(publishSuccessText); + + // open the menu again, to check the publish status + await tester.editor.openMoreActionMenuOnMobile(); + // expect to see the unpublish button and the visit site button + expect( + find.text(LocaleKeys.shareAction_unPublish.tr()), + findsOneWidget, + ); + expect( + find.text(LocaleKeys.shareAction_visitSite.tr()), + findsOneWidget, + ); + + // update the path name + await tester.editor.clickMoreActionItemOnMobile( + LocaleKeys.shareAction_updatePathName.tr(), + ); + + const pathName1 = '???????????????'; + const pathName2 = 'AppFlowy'; + + final textField = find.descendant( + of: find.byType(EditWorkspaceNameBottomSheet), + matching: find.byType(TextFormField), + ); + await tester.enterText(textField, pathName1); + await tester.pumpAndSettle(); + + // wait 50ms to ensure the error message is shown + await tester.wait(50); + + // click the confirm button + final confirmButton = find.text(LocaleKeys.button_confirm.tr()); + await tester.tapButton(confirmButton); + + // expect to see the update path name failed toast + final updatePathFailedText = find.text( + LocaleKeys.settings_sites_error_publishNameContainsInvalidCharacters + .tr(), + ); + expect(updatePathFailedText, findsOneWidget); + + // input the valid path name + await tester.enterText(textField, pathName2); + await tester.pumpAndSettle(); + // click the confirm button + await tester.tapButton(confirmButton); + + // wait 50ms to ensure the error message is shown + await tester.wait(50); + + // expect to see the update path name success toast + final updatePathSuccessText = find.findTextInFlowyText( + LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), + ); + expect(updatePathSuccessText, findsOneWidget); + await tester.pumpUntilNotFound(updatePathSuccessText); + + // unpublish the document + await tester.editor.clickMoreActionItemOnMobile( + LocaleKeys.shareAction_unPublish.tr(), + ); + final unPublishSuccessText = find.findTextInFlowyText( + LocaleKeys.publish_unpublishSuccessfully.tr(), + ); + expect(unPublishSuccessText, findsOneWidget); + await tester.pumpUntilNotFound(unPublishSuccessText); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/cloud/document/share_link_test.dart b/frontend/appflowy_flutter/integration_test/mobile/cloud/document/share_link_test.dart new file mode 100644 index 0000000000000..bf0ddc87110a4 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/cloud/document/share_link_test.dart @@ -0,0 +1,42 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/constants.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('share link:', () { + testWidgets('copy share link and paste it on doc', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // open the getting started page and paste the link + await tester.openPage(Constants.gettingStartedPageName); + + // open the more action menu + await tester.editor.openMoreActionMenuOnMobile(); + + // click the share link item + await tester.editor.clickMoreActionItemOnMobile( + LocaleKeys.shareAction_copyLink.tr(), + ); + + // check the clipboard + final content = await Clipboard.getData(Clipboard.kTextPlain); + expect( + content?.text, + matches(appflowySharePageLinkPattern), + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/cloud/space/space_test.dart b/frontend/appflowy_flutter/integration_test/mobile/cloud/space/space_test.dart new file mode 100644 index 0000000000000..e7bf3afcc7982 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/cloud/space/space_test.dart @@ -0,0 +1,287 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/home/space/manage_space_widget.dart'; +import 'package:appflowy/mobile/presentation/home/space/mobile_space_header.dart'; +import 'package:appflowy/mobile/presentation/home/space/mobile_space_menu.dart'; +import 'package:appflowy/mobile/presentation/home/space/space_menu_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/home/space/widgets.dart'; +import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('space operations:', () { + Future openSpaceMenu(WidgetTester tester) async { + final spaceHeader = find.byType(MobileSpaceHeader); + await tester.tapButton(spaceHeader); + await tester.pumpUntilFound(find.byType(MobileSpaceMenu)); + } + + Future openSpaceMenuMoreOptions( + WidgetTester tester, + ViewPB space, + ) async { + final spaceMenuItemTrailing = find.byWidgetPredicate( + (w) => w is SpaceMenuItemTrailing && w.space.id == space.id, + ); + final moreOptions = find.descendant( + of: spaceMenuItemTrailing, + matching: find.byWidgetPredicate( + (w) => + w is FlowySvg && + w.svg.path == FlowySvgs.workspace_three_dots_s.path, + ), + ); + await tester.tapButton(moreOptions); + await tester.pumpUntilFound(find.byType(SpaceMenuMoreOptions)); + } + + // combine the tests together to reduce the CI time + testWidgets(''' +1. create a new space +2. update the space name +3. update the space permission +4. update the space icon +5. delete the space +''', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // 1. create a new space + // click the space menu + await openSpaceMenu(tester); + + // click the create a new space button + final createNewSpaceButton = find.text( + LocaleKeys.space_createNewSpace.tr(), + ); + await tester.pumpUntilFound(createNewSpaceButton); + await tester.tapButton(createNewSpaceButton); + + // input the new space name + final inputField = find.descendant( + of: find.byType(ManageSpaceWidget), + matching: find.byType(TextField), + ); + const newSpaceName = 'AppFlowy'; + await tester.enterText(inputField, newSpaceName); + await tester.pumpAndSettle(); + + // change the space permission to private + final permissionOption = find.byType(ManageSpacePermissionOption); + await tester.tapButton(permissionOption); + await tester.pumpAndSettle(); + + final privateOption = find.text(LocaleKeys.space_privatePermission.tr()); + await tester.tapButton(privateOption); + await tester.pumpAndSettle(); + + // change the space icon color + final color = builtInSpaceColors[1]; + final iconOption = find.descendant( + of: find.byType(ManageSpaceIconOption), + matching: find.byWidgetPredicate( + (w) => w is SpaceColorItem && w.color == color, + ), + ); + await tester.tapButton(iconOption); + await tester.pumpAndSettle(); + + // change the space icon + final icon = kIconGroups![0].icons[1]; + final iconItem = find.descendant( + of: find.byType(ManageSpaceIconOption), + matching: find.byWidgetPredicate( + (w) => w is SpaceIconItem && w.icon == icon, + ), + ); + await tester.tapButton(iconItem); + await tester.pumpAndSettle(); + + // click the done button + final doneButton = find.descendant( + of: find.byWidgetPredicate( + (w) => + w is BottomSheetHeader && + w.title == LocaleKeys.space_createSpace.tr(), + ), + matching: find.text(LocaleKeys.button_done.tr()), + ); + await tester.tapButton(doneButton); + await tester.pumpAndSettle(); + + // wait 100ms for the space to be created + await tester.wait(100); + + // verify the space is created + await openSpaceMenu(tester); + final spaceItems = find.byType(MobileSpaceMenuItem); + // expect to see 3 space items, 2 are built-in, 1 is the new space + expect(spaceItems, findsNWidgets(3)); + // convert the space item to a widget + final spaceWidget = + tester.widgetList(spaceItems).last; + final space = spaceWidget.space; + expect(space.name, newSpaceName); + expect(space.spacePermission, SpacePermission.private); + expect(space.spaceIcon, icon.iconPath); + expect(space.spaceIconColor, color); + + // open the SpaceMenuMoreOptions menu + await openSpaceMenuMoreOptions(tester, space); + + // 2. rename the space name + final renameOption = find.text(LocaleKeys.button_rename.tr()); + await tester.tapButton(renameOption); + await tester.pumpUntilFound(find.byType(EditWorkspaceNameBottomSheet)); + + // input the new space name + final renameInputField = find.descendant( + of: find.byType(EditWorkspaceNameBottomSheet), + matching: find.byType(TextField), + ); + const renameSpaceName = 'HelloWorld'; + await tester.enterText(renameInputField, renameSpaceName); + await tester.pumpAndSettle(); + await tester.tapButton(find.text(LocaleKeys.button_confirm.tr())); + + // click the done button + await tester.pumpAndSettle(); + + final renameSuccess = find.text( + LocaleKeys.space_success_renameSpace.tr(), + ); + await tester.pumpUntilNotFound(renameSuccess); + + // check the space name is updated + await openSpaceMenu(tester); + final renameSpaceItem = find.descendant( + of: find.byType(MobileSpaceMenuItem), + matching: find.text(renameSpaceName), + ); + expect(renameSpaceItem, findsOneWidget); + + // 3. manage the space + await openSpaceMenuMoreOptions(tester, space); + + final manageOption = find.text(LocaleKeys.space_manage.tr()); + await tester.tapButton(manageOption); + await tester.pumpUntilFound(find.byType(ManageSpaceWidget)); + + // 3.1 rename the space + final textField = find.descendant( + of: find.byType(ManageSpaceWidget), + matching: find.byType(TextField), + ); + await tester.enterText(textField, 'AppFlowy'); + await tester.pumpAndSettle(); + + // 3.2 change the permission + final permissionOption2 = find.byType(ManageSpacePermissionOption); + await tester.tapButton(permissionOption2); + await tester.pumpAndSettle(); + + final publicOption = find.text(LocaleKeys.space_publicPermission.tr()); + await tester.tapButton(publicOption); + await tester.pumpAndSettle(); + + // 3.3 change the icon + // change the space icon color + final color2 = builtInSpaceColors[2]; + final iconOption2 = find.descendant( + of: find.byType(ManageSpaceIconOption), + matching: find.byWidgetPredicate( + (w) => w is SpaceColorItem && w.color == color2, + ), + ); + await tester.tapButton(iconOption2); + await tester.pumpAndSettle(); + + // change the space icon + final icon2 = kIconGroups![0].icons[2]; + final iconItem2 = find.descendant( + of: find.byType(ManageSpaceIconOption), + matching: find.byWidgetPredicate( + (w) => w is SpaceIconItem && w.icon == icon2, + ), + ); + await tester.tapButton(iconItem2); + await tester.pumpAndSettle(); + + // click the done button + final doneButton2 = find.descendant( + of: find.byWidgetPredicate( + (w) => + w is BottomSheetHeader && + w.title == LocaleKeys.space_manageSpace.tr(), + ), + matching: find.text(LocaleKeys.button_done.tr()), + ); + await tester.tapButton(doneButton2); + await tester.pumpAndSettle(); + + // check the space is updated + final spaceItems2 = find.byType(MobileSpaceMenuItem); + final spaceWidget2 = + tester.widgetList(spaceItems2).last; + final space2 = spaceWidget2.space; + expect(space2.name, 'AppFlowy'); + expect(space2.spacePermission, SpacePermission.publicToAll); + expect(space2.spaceIcon, icon2.iconPath); + expect(space2.spaceIconColor, color2); + final manageSuccess = find.text( + LocaleKeys.space_success_updateSpace.tr(), + ); + await tester.pumpUntilNotFound(manageSuccess); + + // 4. duplicate the space + await openSpaceMenuMoreOptions(tester, space); + final duplicateOption = find.text(LocaleKeys.space_duplicate.tr()); + await tester.tapButton(duplicateOption); + final duplicateSuccess = find.text( + LocaleKeys.space_success_duplicateSpace.tr(), + ); + await tester.pumpUntilNotFound(duplicateSuccess); + + // check the space is duplicated + await openSpaceMenu(tester); + final spaceItems3 = find.byType(MobileSpaceMenuItem); + expect(spaceItems3, findsNWidgets(4)); + + // 5. delete the space + await openSpaceMenuMoreOptions(tester, space); + final deleteOption = find.text(LocaleKeys.button_delete.tr()); + await tester.tapButton(deleteOption); + final confirmDeleteButton = find.descendant( + of: find.byType(CupertinoDialogAction), + matching: find.text(LocaleKeys.button_delete.tr()), + ); + await tester.tapButton(confirmDeleteButton); + final deleteSuccess = find.text( + LocaleKeys.space_success_deleteSpace.tr(), + ); + await tester.pumpUntilNotFound(deleteSuccess); + + // check the space is deleted + final spaceItems4 = find.byType(MobileSpaceMenuItem); + expect(spaceItems4, findsNWidgets(3)); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/cloud/workspace/workspace_operations_test.dart b/frontend/appflowy_flutter/integration_test/mobile/cloud/workspace/workspace_operations_test.dart new file mode 100644 index 0000000000000..210d1bcf0e2cd --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/cloud/workspace/workspace_operations_test.dart @@ -0,0 +1,41 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../../shared/constants.dart'; +import '../../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('workspace operations:', () { + testWidgets('create a new workspace', (tester) async { + await tester.initializeAppFlowy( + cloudType: AuthenticatorType.appflowyCloudSelfHost, + ); + await tester.tapGoogleLoginInButton(); + await tester.expectToSeeHomePageWithGetStartedPage(); + + // click the create a new workspace button + await tester.tapButton(find.text(Constants.defaultWorkspaceName)); + await tester.tapButton(find.text(LocaleKeys.workspace_create.tr())); + + // input the new workspace name + final inputField = find.byType(TextFormField); + const newWorkspaceName = 'AppFlowy'; + await tester.enterText(inputField, newWorkspaceName); + await tester.pumpAndSettle(); + + // wait for the workspace to be created + await tester.pumpUntilFound( + find.text(LocaleKeys.workspace_createSuccess.tr()), + ); + + // expect to see the new workspace + expect(find.text(newWorkspaceName), findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart b/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart new file mode 100644 index 0000000000000..67fdaaa204fe1 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/document_test_runner.dart @@ -0,0 +1,16 @@ +import 'package:integration_test/integration_test.dart'; + +import 'page_style_test.dart' as page_style_test; +import 'plus_menu_test.dart' as plus_menu_test; +import 'simple_table_test.dart' as simple_table_test; +import 'title_test.dart' as title_test; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Document integration tests + title_test.main(); + page_style_test.main(); + plus_menu_test.main(); + simple_table_test.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/page_style_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/page_style_test.dart new file mode 100644 index 0000000000000..4d3486185079c --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/page_style_test.dart @@ -0,0 +1,118 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document page style:', () { + double getCurrentEditorFontSize() { + final editorPage = find + .byType(AppFlowyEditorPage) + .evaluate() + .single + .widget as AppFlowyEditorPage; + return editorPage.styleCustomizer + .style() + .textStyleConfiguration + .text + .fontSize!; + } + + double getCurrentEditorLineHeight() { + final editorPage = find + .byType(AppFlowyEditorPage) + .evaluate() + .single + .widget as AppFlowyEditorPage; + return editorPage.styleCustomizer + .style() + .textStyleConfiguration + .lineHeight; + } + + testWidgets('change font size in page style settings', (tester) async { + await tester.launchInAnonymousMode(); + + // click the getting start page + await tester.openPage(gettingStarted); + // click the layout button + await tester.tapButton(find.byType(MobileViewPageLayoutButton)); + expect(getCurrentEditorFontSize(), PageStyleFontLayout.normal.fontSize); + // change font size from normal to large + await tester.tapSvgButton(FlowySvgs.m_font_size_large_s); + expect(getCurrentEditorFontSize(), PageStyleFontLayout.large.fontSize); + // change font size from large to small + await tester.tapSvgButton(FlowySvgs.m_font_size_small_s); + expect(getCurrentEditorFontSize(), PageStyleFontLayout.small.fontSize); + }); + + testWidgets('change line height in page style settings', (tester) async { + await tester.launchInAnonymousMode(); + + // click the getting start page + await tester.openPage(gettingStarted); + // click the layout button + await tester.tapButton(find.byType(MobileViewPageLayoutButton)); + var lineHeight = getCurrentEditorLineHeight(); + expect( + lineHeight, + PageStyleLineHeightLayout.normal.lineHeight, + ); + // change line height from normal to large + await tester.tapSvgButton(FlowySvgs.m_layout_large_s); + await tester.pumpAndSettle(); + lineHeight = getCurrentEditorLineHeight(); + expect( + lineHeight, + PageStyleLineHeightLayout.large.lineHeight, + ); + // change line height from large to small + await tester.tapSvgButton(FlowySvgs.m_layout_small_s); + lineHeight = getCurrentEditorLineHeight(); + expect( + lineHeight, + PageStyleLineHeightLayout.small.lineHeight, + ); + }); + + testWidgets('use built-in image as cover', (tester) async { + await tester.launchInAnonymousMode(); + + // click the getting start page + await tester.openPage(gettingStarted); + // click the layout button + await tester.tapButton(find.byType(MobileViewPageLayoutButton)); + // toggle the preset button + await tester.tapSvgButton(FlowySvgs.m_page_style_presets_m); + + // select the first preset + final firstBuiltInImage = find.byWidgetPredicate( + (widget) => + widget is Image && + widget.image is AssetImage && + (widget.image as AssetImage).assetName == + PageStyleCoverImageType.builtInImagePath('1'), + ); + await tester.tap(firstBuiltInImage); + + // click done button to exit the page style settings + await tester.tapButton(find.byType(BottomSheetDoneButton).first); + + // check the cover + final builtInCover = find.descendant( + of: find.byType(DocumentImmersiveCover), + matching: firstBuiltInImage, + ); + expect(builtInCover, findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart new file mode 100644 index 0000000000000..bdd84f9098610 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/plus_menu_test.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document plus menu:', () { + testWidgets('add the toggle heading blocks via plus menu', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createNewDocumentOnMobile('toggle heading blocks'); + + final editorState = tester.editor.getCurrentEditorState(); + // focus on the editor + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [0])), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + // open the plus menu and select the toggle heading block + await tester.openPlusMenuAndClickButton( + LocaleKeys.document_slashMenu_name_toggleHeading1.tr(), + ); + + // check the block is inserted + final block1 = editorState.getNodeAtPath([0])!; + expect(block1.type, equals(ToggleListBlockKeys.type)); + expect(block1.attributes[ToggleListBlockKeys.level], equals(1)); + + // click the expand button won't cancel the selection + await tester.tapButton(find.byIcon(Icons.arrow_right)); + expect( + editorState.selection, + equals(Selection.collapsed(Position(path: [0]))), + ); + + // focus on the next line + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [1])), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + // open the plus menu and select the toggle heading block + await tester.openPlusMenuAndClickButton( + LocaleKeys.document_slashMenu_name_toggleHeading2.tr(), + ); + + // check the block is inserted + final block2 = editorState.getNodeAtPath([1])!; + expect(block2.type, equals(ToggleListBlockKeys.type)); + expect(block2.attributes[ToggleListBlockKeys.level], equals(2)); + + // focus on the next line + await tester.pumpAndSettle(); + + // open the plus menu and select the toggle heading block + await tester.openPlusMenuAndClickButton( + LocaleKeys.document_slashMenu_name_toggleHeading3.tr(), + ); + + // check the block is inserted + final block3 = editorState.getNodeAtPath([2])!; + expect(block3.type, equals(ToggleListBlockKeys.type)); + expect(block3.attributes[ToggleListBlockKeys.level], equals(3)); + + // wait a few milliseconds to ensure the selection is updated + await Future.delayed(const Duration(milliseconds: 100)); + // check the selection is collapsed + expect( + editorState.selection, + equals(Selection.collapsed(Position(path: [2]))), + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/simple_table_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/simple_table_test.dart new file mode 100644 index 0000000000000..fcd5494218fae --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/simple_table_test.dart @@ -0,0 +1,440 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('simple table:', () { + testWidgets(''' +1. insert a simple table via + menu +2. insert a row above the table +3. insert a row below the table +4. insert a column left to the table +5. insert a column right to the table +6. delete the first row +7. delete the first column +''', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createNewDocumentOnMobile('simple table'); + + final editorState = tester.editor.getCurrentEditorState(); + // focus on the editor + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [0])), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + final firstParagraphPath = [0, 0, 0, 0]; + + // open the plus menu and select the table block + { + await tester.openPlusMenuAndClickButton( + LocaleKeys.document_slashMenu_name_table.tr(), + ); + + // check the block is inserted + final table = editorState.getNodeAtPath([0])!; + expect(table.type, equals(SimpleTableBlockKeys.type)); + expect(table.rowLength, equals(2)); + expect(table.columnLength, equals(2)); + + // focus on the first cell + + final selection = editorState.selection!; + expect(selection.isCollapsed, isTrue); + expect(selection.start.path, equals(firstParagraphPath)); + } + + // insert left and insert right + { + // click the column menu button + await tester.clickColumnMenuButton(0); + + // insert left, insert right + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys.document_plugins_simpleTable_moreActions_insertLeft.tr(), + ), + ); + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys.document_plugins_simpleTable_moreActions_insertRight + .tr(), + ), + ); + + await tester.cancelTableActionMenu(); + + // check the table is updated + final table = editorState.getNodeAtPath([0])!; + expect(table.type, equals(SimpleTableBlockKeys.type)); + expect(table.rowLength, equals(2)); + expect(table.columnLength, equals(4)); + } + + // insert above and insert below + { + // focus on the first cell + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: firstParagraphPath)), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + // click the row menu button + await tester.clickRowMenuButton(0); + + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys.document_plugins_simpleTable_moreActions_insertAbove + .tr(), + ), + ); + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys.document_plugins_simpleTable_moreActions_insertBelow + .tr(), + ), + ); + await tester.cancelTableActionMenu(); + + // check the table is updated + final table = editorState.getNodeAtPath([0])!; + expect(table.rowLength, equals(4)); + expect(table.columnLength, equals(4)); + } + + // delete the first row + { + // focus on the first cell + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: firstParagraphPath)), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + // delete the first row + await tester.clickRowMenuButton(0); + await tester.clickSimpleTableQuickAction(SimpleTableMoreAction.delete); + await tester.cancelTableActionMenu(); + + // check the table is updated + final table = editorState.getNodeAtPath([0])!; + expect(table.rowLength, equals(3)); + expect(table.columnLength, equals(4)); + } + + // delete the first column + { + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: firstParagraphPath)), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + await tester.clickColumnMenuButton(0); + await tester.clickSimpleTableQuickAction(SimpleTableMoreAction.delete); + await tester.cancelTableActionMenu(); + + // check the table is updated + final table = editorState.getNodeAtPath([0])!; + expect(table.rowLength, equals(3)); + expect(table.columnLength, equals(3)); + } + }); + + testWidgets(''' +1. insert a simple table via + menu +2. enable header column +3. enable header row +4. set to page width +5. distribute columns evenly +''', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createNewDocumentOnMobile('simple table'); + + final editorState = tester.editor.getCurrentEditorState(); + // focus on the editor + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [0])), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + final firstParagraphPath = [0, 0, 0, 0]; + + // open the plus menu and select the table block + { + await tester.openPlusMenuAndClickButton( + LocaleKeys.document_slashMenu_name_table.tr(), + ); + + // check the block is inserted + final table = editorState.getNodeAtPath([0])!; + expect(table.type, equals(SimpleTableBlockKeys.type)); + expect(table.rowLength, equals(2)); + expect(table.columnLength, equals(2)); + + // focus on the first cell + + final selection = editorState.selection!; + expect(selection.isCollapsed, isTrue); + expect(selection.start.path, equals(firstParagraphPath)); + } + + // enable header column + { + // click the column menu button + await tester.clickColumnMenuButton(0); + + // enable header column + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys.document_plugins_simpleTable_moreActions_headerColumn + .tr(), + ), + ); + } + + // enable header row + { + // focus on the first cell + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: firstParagraphPath)), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + // click the row menu button + await tester.clickRowMenuButton(0); + + // enable header column + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys.document_plugins_simpleTable_moreActions_headerRow.tr(), + ), + ); + } + + // check the table is updated + final table = editorState.getNodeAtPath([0])!; + expect(table.type, equals(SimpleTableBlockKeys.type)); + expect(table.isHeaderColumnEnabled, isTrue); + expect(table.isHeaderRowEnabled, isTrue); + + // set to page width + { + final table = editorState.getNodeAtPath([0])!; + final beforeWidth = table.width; + // focus on the first cell + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: firstParagraphPath)), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + // click the row menu button + await tester.clickRowMenuButton(0); + + // enable header column + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys.document_plugins_simpleTable_moreActions_setToPageWidth + .tr(), + ), + ); + + // check the table is updated + expect(table.width, greaterThan(beforeWidth)); + } + + // distribute columns evenly + { + final table = editorState.getNodeAtPath([0])!; + final beforeWidth = table.width; + + // focus on the first cell + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: firstParagraphPath)), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + // click the column menu button + await tester.clickColumnMenuButton(0); + + // distribute columns evenly + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys + .document_plugins_simpleTable_moreActions_distributeColumnsWidth + .tr(), + ), + ); + + // check the table is updated + expect(table.width, equals(beforeWidth)); + } + }); + + testWidgets(''' +1. insert a simple table via + menu +2. bold +3. clear content +''', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createNewDocumentOnMobile('simple table'); + + final editorState = tester.editor.getCurrentEditorState(); + // focus on the editor + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [0])), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + final firstParagraphPath = [0, 0, 0, 0]; + + // open the plus menu and select the table block + { + await tester.openPlusMenuAndClickButton( + LocaleKeys.document_slashMenu_name_table.tr(), + ); + + // check the block is inserted + final table = editorState.getNodeAtPath([0])!; + expect(table.type, equals(SimpleTableBlockKeys.type)); + expect(table.rowLength, equals(2)); + expect(table.columnLength, equals(2)); + + // focus on the first cell + + final selection = editorState.selection!; + expect(selection.isCollapsed, isTrue); + expect(selection.start.path, equals(firstParagraphPath)); + } + + await tester.ime.insertText('Hello'); + + // enable bold + { + // click the column menu button + await tester.clickColumnMenuButton(0); + + // enable bold + await tester.clickSimpleTableBoldContentAction(); + await tester.cancelTableActionMenu(); + + // check the first cell is bold + final paragraph = editorState.getNodeAtPath(firstParagraphPath)!; + expect(paragraph.isInBoldColumn, isTrue); + } + + // clear content + { + // focus on the first cell + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: firstParagraphPath)), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + // click the column menu button + await tester.clickColumnMenuButton(0); + + // clear content + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys.document_plugins_simpleTable_moreActions_clearContents + .tr(), + ), + ); + await tester.cancelTableActionMenu(); + + // check the first cell is empty + final paragraph = editorState.getNodeAtPath(firstParagraphPath)!; + expect(paragraph.delta!, isEmpty); + } + }); + + testWidgets(''' +1. insert a simple table via + menu +2. insert a heading block in table cell +''', (tester) async { + await tester.launchInAnonymousMode(); + await tester.createNewDocumentOnMobile('simple table'); + + final editorState = tester.editor.getCurrentEditorState(); + // focus on the editor + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [0])), + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(); + + final firstParagraphPath = [0, 0, 0, 0]; + + // open the plus menu and select the table block + { + await tester.openPlusMenuAndClickButton( + LocaleKeys.document_slashMenu_name_table.tr(), + ); + + // check the block is inserted + final table = editorState.getNodeAtPath([0])!; + expect(table.type, equals(SimpleTableBlockKeys.type)); + expect(table.rowLength, equals(2)); + expect(table.columnLength, equals(2)); + + // focus on the first cell + + final selection = editorState.selection!; + expect(selection.isCollapsed, isTrue); + expect(selection.start.path, equals(firstParagraphPath)); + } + + // open the plus menu and select the heading block + { + await tester.openPlusMenuAndClickButton( + LocaleKeys.editor_toggleHeading1ShortForm.tr(), + ); + + // check the heading block is inserted + final heading = editorState.getNodeAtPath([0, 0, 0, 0])!; + expect(heading.type, equals(HeadingBlockKeys.type)); + expect(heading.level, equals(1)); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/document/title_test.dart b/frontend/appflowy_flutter/integration_test/mobile/document/title_test.dart new file mode 100644 index 0000000000000..01b1d574ce8d2 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/document/title_test.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('document title:', () { + testWidgets('create a new page, the title should be empty', (tester) async { + await tester.launchInAnonymousMode(); + + final createPageButton = find.byKey( + BottomNavigationBarItemType.add.valueKey, + ); + await tester.tapButton(createPageButton); + expect(find.byType(MobileDocumentScreen), findsOneWidget); + + final title = tester.editor.findDocumentTitle(''); + expect(title, findsOneWidget); + final textField = tester.widget(title); + expect(textField.focusNode!.hasFocus, isTrue); + + // input new name and press done button + const name = 'test document'; + await tester.enterText(title, name); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + final newTitle = tester.editor.findDocumentTitle(name); + expect(newTitle, findsOneWidget); + expect(textField.controller!.text, name); + + // the document should get focus + final editor = tester.widget( + find.byType(AppFlowyEditorPage), + ); + expect( + editor.editorState.selection, + Selection.collapsed(Position(path: [0])), + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/home_page/create_new_page_test.dart b/frontend/appflowy_flutter/integration_test/mobile/home_page/create_new_page_test.dart new file mode 100644 index 0000000000000..d64ab094dec90 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/home_page/create_new_page_test.dart @@ -0,0 +1,26 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('create new page in home page:', () { + testWidgets('create document', (tester) async { + await tester.launchInAnonymousMode(); + + // tap the create page button + final createPageButton = find.byWidgetPredicate( + (widget) => + widget is FlowySvg && + widget.svg.path == FlowySvgs.m_home_add_m.path, + ); + await tester.tapButton(createPageButton); + await tester.pumpAndSettle(); + expect(find.byType(MobileDocumentScreen), findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile/sign_in/anonymous_sign_in_test.dart b/frontend/appflowy_flutter/integration_test/mobile/sign_in/anonymous_sign_in_test.dart new file mode 100644 index 0000000000000..ab98ca190a74f --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile/sign_in/anonymous_sign_in_test.dart @@ -0,0 +1,18 @@ +import 'package:appflowy/mobile/presentation/home/home.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('anonymous sign in on mobile:', () { + testWidgets('anon user and then sign in', (tester) async { + await tester.launchInAnonymousMode(); + + // expect to see the home page + expect(find.byType(MobileHomeScreen), findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/mobile_runner_1.dart b/frontend/appflowy_flutter/integration_test/mobile_runner_1.dart new file mode 100644 index 0000000000000..a17fe909e76b2 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/mobile_runner_1.dart @@ -0,0 +1,20 @@ +import 'package:appflowy_backend/log.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'mobile/document/document_test_runner.dart' as document_test_runner; +import 'mobile/home_page/create_new_page_test.dart' as create_new_page_test; +import 'mobile/sign_in/anonymous_sign_in_test.dart' as anonymous_sign_in_test; + +Future main() async { + Log.shared.disableLog = true; + + await runIntegration1OnMobile(); +} + +Future runIntegration1OnMobile() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + anonymous_sign_in_test.main(); + create_new_page_test.main(); + document_test_runner.main(); +} diff --git a/frontend/appflowy_flutter/integration_test/runner.dart b/frontend/appflowy_flutter/integration_test/runner.dart new file mode 100644 index 0000000000000..0fc3c5d826e0c --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/runner.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'desktop_runner_1.dart'; +import 'desktop_runner_2.dart'; +import 'desktop_runner_3.dart'; +import 'desktop_runner_4.dart'; +import 'desktop_runner_5.dart'; +import 'desktop_runner_6.dart'; +import 'desktop_runner_7.dart'; +import 'desktop_runner_8.dart'; +import 'desktop_runner_9.dart'; +import 'mobile_runner_1.dart'; + +/// The main task runner for all integration tests in AppFlowy. +/// +/// Having a single entrypoint for integration tests is necessary due to an +/// [issue caused by switching files with integration testing](https://github.com/flutter/flutter/issues/101031). +/// If flutter/flutter#101031 is resolved, this file can be removed completely. +/// Once removed, the integration_test.yaml must be updated to exclude this as +/// as the test target. +Future main() async { + if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { + await runIntegration1OnDesktop(); + await runIntegration2OnDesktop(); + await runIntegration3OnDesktop(); + await runIntegration4OnDesktop(); + await runIntegration5OnDesktop(); + await runIntegration6OnDesktop(); + await runIntegration7OnDesktop(); + await runIntegration8OnDesktop(); + await runIntegration9OnDesktop(); + } else if (Platform.isIOS || Platform.isAndroid) { + await runIntegration1OnMobile(); + } else { + throw Exception('Unsupported platform'); + } +} diff --git a/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart b/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart new file mode 100644 index 0000000000000..88f9634afdd58 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart @@ -0,0 +1,73 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'util.dart'; + +extension AppFlowyAuthTest on WidgetTester { + Future tapGoogleLoginInButton({bool pumpAndSettle = true}) async { + await tapButton( + find.byKey(signInWithGoogleButtonKey), + pumpAndSettle: pumpAndSettle, + ); + } + + /// Requires being on the SettingsPage.account of the SettingsDialog + Future logout() async { + final scrollable = find.findSettingsScrollable(); + await scrollUntilVisible( + find.byType(AccountSignInOutButton), + 100, + scrollable: scrollable, + ); + + await tapButton(find.byType(AccountSignInOutButton)); + + expectToSeeText(LocaleKeys.button_ok.tr()); + await tapButtonWithName(LocaleKeys.button_ok.tr()); + } + + Future tapSignInAsGuest() async { + await tapButton(find.byType(SignInAnonymousButtonV2)); + } + + void expectToSeeGoogleLoginButton() { + expect(find.byKey(signInWithGoogleButtonKey), findsOneWidget); + } + + void assertSwitchValue(Finder finder, bool value) { + final Switch switchWidget = widget(finder); + final isSwitched = switchWidget.value; + assert(isSwitched == value); + } + + void assertToggleValue(Finder finder, bool value) { + final Toggle switchWidget = widget(finder); + final isSwitched = switchWidget.value; + assert(isSwitched == value); + } + + void assertAppFlowyCloudEnableSyncSwitchValue(bool value) { + assertToggleValue( + find.descendant( + of: find.byType(AppFlowyCloudEnableSync), + matching: find.byWidgetPredicate((widget) => widget is Toggle), + ), + value, + ); + } + + Future toggleEnableSync(Type syncButton) async { + final finder = find.descendant( + of: find.byType(syncButton), + matching: find.byWidgetPredicate((widget) => widget is Toggle), + ); + + await tapButton(finder); + } +} diff --git a/frontend/appflowy_flutter/integration_test/shared/base.dart b/frontend/appflowy_flutter/integration_test/shared/base.dart new file mode 100644 index 0000000000000..f6baa52721fe7 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/base.dart @@ -0,0 +1,254 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/env/cloud_env_test.dart'; +import 'package:appflowy/startup/entry_point.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/presentation/presentation.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class FlowyTestContext { + FlowyTestContext({required this.applicationDataDirectory}); + + final String applicationDataDirectory; +} + +extension AppFlowyTestBase on WidgetTester { + Future initializeAppFlowy({ + // use to append after the application data directory + String? pathExtension, + // use to specify the application data directory, if not specified, a temporary directory will be used. + String? dataDirectory, + Size windowSize = const Size(1600, 1200), + AuthenticatorType? cloudType, + String? email, + }) async { + if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + // Set the window size + await binding.setSurfaceSize(windowSize); + } + + mockHotKeyManagerHandlers(); + final applicationDataDirectory = dataDirectory ?? + await mockApplicationDataStorage( + pathExtension: pathExtension, + ); + + await FlowyRunner.run( + AppFlowyApplication(), + IntegrationMode.integrationTest, + rustEnvsBuilder: () { + final rustEnvs = {}; + if (cloudType != null) { + switch (cloudType) { + case AuthenticatorType.local: + break; + case AuthenticatorType.appflowyCloudSelfHost: + rustEnvs["GOTRUE_ADMIN_EMAIL"] = "admin@example.com"; + rustEnvs["GOTRUE_ADMIN_PASSWORD"] = "password"; + break; + default: + throw Exception("not supported"); + } + } + return rustEnvs; + }, + didInitGetItCallback: () { + return Future( + () async { + if (cloudType != null) { + switch (cloudType) { + case AuthenticatorType.local: + await useLocalServer(); + break; + case AuthenticatorType.appflowyCloudSelfHost: + await useTestSelfHostedAppFlowyCloud(); + getIt.unregister(); + getIt.registerFactory( + () => AppFlowyCloudMockAuthService(email: email), + ); + default: + throw Exception("not supported"); + } + } + }, + ); + }, + ); + + await waitUntilSignInPageShow(); + return FlowyTestContext( + applicationDataDirectory: applicationDataDirectory, + ); + } + + void mockHotKeyManagerHandlers() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(const MethodChannel('hotkey_manager'), + (MethodCall methodCall) async { + if (methodCall.method == 'unregisterAll') { + // do nothing + } + return; + }); + } + + Future waitUntilSignInPageShow() async { + if (isAuthEnabled || UniversalPlatform.isMobile) { + final finder = find.byType(SignInAnonymousButtonV2); + await pumpUntilFound(finder, timeout: const Duration(seconds: 30)); + expect(finder, findsOneWidget); + } else { + final finder = find.byType(GoButton); + await pumpUntilFound(finder); + expect(finder, findsOneWidget); + } + } + + Future waitForSeconds(int seconds) async { + await Future.delayed(Duration(seconds: seconds), () {}); + } + + Future pumpUntilFound( + Finder finder, { + Duration timeout = const Duration(seconds: 10), + Duration pumpInterval = const Duration( + milliseconds: 50, + ), // Interval between pumps + }) async { + bool timerDone = false; + final timer = Timer(timeout, () => timerDone = true); + while (!timerDone) { + await pump(pumpInterval); // Pump with an interval + if (any(finder)) { + break; + } + } + timer.cancel(); + } + + Future pumpUntilNotFound( + Finder finder, { + Duration timeout = const Duration(seconds: 10), + Duration pumpInterval = const Duration( + milliseconds: 50, + ), // Interval between pumps + }) async { + bool timerDone = false; + final timer = Timer(timeout, () => timerDone = true); + while (!timerDone) { + await pump(pumpInterval); // Pump with an interval + if (!any(finder)) { + break; + } + } + timer.cancel(); + } + + Future tapButton( + Finder finder, { + int buttons = kPrimaryButton, + bool warnIfMissed = false, + int milliseconds = 500, + bool pumpAndSettle = true, + }) async { + await tap(finder, buttons: buttons, warnIfMissed: warnIfMissed); + + if (pumpAndSettle) { + await this.pumpAndSettle( + Duration(milliseconds: milliseconds), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 15), + ); + } + } + + Future tapButtonWithName( + String tr, { + int milliseconds = 500, + bool pumpAndSettle = true, + }) async { + Finder button = find.text(tr, findRichText: true, skipOffstage: false); + if (button.evaluate().isEmpty) { + button = find.byWidgetPredicate( + (widget) => widget is FlowyText && widget.text == tr, + ); + } + await tapButton( + button, + milliseconds: milliseconds, + pumpAndSettle: pumpAndSettle, + ); + } + + Future doubleTapAt( + Offset location, { + int? pointer, + int buttons = kPrimaryButton, + int milliseconds = 500, + }) async { + await tapAt(location, pointer: pointer, buttons: buttons); + await pump(kDoubleTapMinTime); + await tapAt(location, pointer: pointer, buttons: buttons); + await pumpAndSettle(Duration(milliseconds: milliseconds)); + } + + Future wait(int milliseconds) async { + await pumpAndSettle(Duration(milliseconds: milliseconds)); + } +} + +extension AppFlowyFinderTestBase on CommonFinders { + Finder findTextInFlowyText(String text) { + return find.byWidgetPredicate( + (widget) => widget is FlowyText && widget.text == text, + ); + } + + Finder findFlowyTooltip(String richMessage, {bool skipOffstage = true}) { + return byWidgetPredicate( + (widget) => + widget is FlowyTooltip && + widget.richMessage != null && + widget.richMessage!.toPlainText().contains(richMessage), + skipOffstage: skipOffstage, + ); + } +} + +Future useTestSelfHostedAppFlowyCloud() async { + await useSelfHostedAppFlowyCloudWithURL(TestEnv.afCloudUrl); +} + +Future mockApplicationDataStorage({ + // use to append after the application data directory + String? pathExtension, +}) async { + final dir = await getTemporaryDirectory(); + + // Use a random uuid to avoid conflict. + String path = p.join(dir.path, 'appflowy_integration_test', uuid()); + if (pathExtension != null && pathExtension.isNotEmpty) { + path = '$path/$pathExtension'; + } + final directory = Directory(path); + if (!directory.existsSync()) { + await directory.create(recursive: true); + } + + MockApplicationDataStorage.initialPath = directory.path; + + return directory.path; +} diff --git a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart new file mode 100644 index 0000000000000..6ac67d0e33e35 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -0,0 +1,1006 @@ +import 'dart:io'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart'; +import 'package:appflowy/plugins/shared/share/share_button.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/presentation/screens/screens.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/flowy_tab.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'emoji.dart'; +import 'util.dart'; + +extension CommonOperations on WidgetTester { + /// Tap the GetStart button on the launch page. + Future tapAnonymousSignInButton() async { + // local version + final goButton = find.byType(GoButton); + if (goButton.evaluate().isNotEmpty) { + await tapButton(goButton); + } else { + // cloud version + final anonymousButton = find.byType(SignInAnonymousButtonV2); + await tapButton(anonymousButton); + } + + if (Platform.isWindows) { + await pumpAndSettle(const Duration(milliseconds: 200)); + } + } + + Future tapContinousAnotherWay() async { + // local version + await tapButtonWithName(LocaleKeys.signIn_continueAnotherWay.tr()); + if (Platform.isWindows) { + await pumpAndSettle(const Duration(milliseconds: 200)); + } + } + + /// Tap the + button on the home page. + Future tapAddViewButton({ + String name = gettingStarted, + ViewLayoutPB layout = ViewLayoutPB.Document, + }) async { + await hoverOnPageName( + name, + onHover: () async { + final addButton = find.byType(ViewAddButton); + await tapButton(addButton); + }, + ); + } + + /// Tap the 'New Page' Button on the sidebar. + Future tapNewPageButton() async { + final newPageButton = find.byType(SidebarNewPageButton); + await tapButton(newPageButton); + } + + /// Tap the import button. + /// + /// Must call [tapAddViewButton] first. + Future tapImportButton() async { + await tapButtonWithName(LocaleKeys.moreAction_import.tr()); + } + + /// Tap the import from text & markdown button. + /// + /// Must call [tapImportButton] first. + Future tapTextAndMarkdownButton() async { + await tapButtonWithName(LocaleKeys.importPanel_textAndMarkdown.tr()); + } + + /// Tap the LanguageSelectorOnWelcomePage widget on the launch page. + Future tapLanguageSelectorOnWelcomePage() async { + final languageSelector = find.byType(LanguageSelectorOnWelcomePage); + await tapButton(languageSelector); + } + + /// Tap languageItem on LanguageItemsListView. + /// + /// [scrollDelta] is the distance to scroll the ListView. + /// Default value is 100 + /// + /// If it is positive -> scroll down. + /// + /// If it is negative -> scroll up. + Future tapLanguageItem({ + required String languageCode, + String? countryCode, + double? scrollDelta, + }) async { + final languageItemsListView = find.descendant( + of: find.byType(ListView), + matching: find.byType(Scrollable), + ); + + final languageItem = find.byWidgetPredicate( + (widget) => + widget is LanguageItem && + widget.locale.languageCode == languageCode && + widget.locale.countryCode == countryCode, + ); + + // scroll the ListView until zHCNLanguageItem shows on the screen. + await scrollUntilVisible( + languageItem, + scrollDelta ?? 100, + scrollable: languageItemsListView, + // maxHeight of LanguageItemsListView + maxScrolls: 400, + ); + + try { + await tapButton(languageItem); + } on FlutterError catch (e) { + Log.warn('tapLanguageItem error: $e'); + } + } + + /// Hover on the widget. + Future hoverOnWidget( + Finder finder, { + Offset? offset, + Future Function()? onHover, + bool removePointer = true, + }) async { + try { + final gesture = await createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: offset ?? getCenter(finder)); + await pumpAndSettle(); + await onHover?.call(); + await gesture.removePointer(); + } catch (err) { + Log.error('hoverOnWidget error: $err'); + } + } + + /// Hover on the page name. + Future hoverOnPageName( + String name, { + ViewLayoutPB layout = ViewLayoutPB.Document, + Future Function()? onHover, + bool useLast = true, + }) async { + final pageNames = findPageName(name, layout: layout); + if (useLast) { + await hoverOnWidget(pageNames.last, onHover: onHover); + } else { + await hoverOnWidget(pageNames.first, onHover: onHover); + } + } + + /// Right click on the page name. + Future rightClickOnPageName( + String name, { + ViewLayoutPB layout = ViewLayoutPB.Document, + }) async { + final page = findPageName(name, layout: layout); + await hoverOnPageName( + name, + onHover: () async { + await tap(page, buttons: kSecondaryMouseButton); + await pumpAndSettle(); + }, + ); + } + + /// open the page with given name. + Future openPage( + String name, { + ViewLayoutPB layout = ViewLayoutPB.Document, + }) async { + final finder = findPageName(name, layout: layout); + expect(finder, findsOneWidget); + await tapButton(finder); + } + + /// Tap the ... button beside the page name. + /// + /// Must call [hoverOnPageName] first. + Future tapPageOptionButton() async { + final optionButton = find.descendant( + of: find.byType(ViewMoreActionPopover), + matching: find.byFlowySvg(FlowySvgs.workspace_three_dots_s), + ); + await tapButton(optionButton); + } + + /// Tap the delete page button. + Future tapDeletePageButton() async { + await tapPageOptionButton(); + await tapButtonWithName(ViewMoreActionType.delete.name); + } + + /// Tap the rename page button. + Future tapRenamePageButton() async { + await tapPageOptionButton(); + await tapButtonWithName(ViewMoreActionType.rename.name); + } + + /// Tap the favorite page button + Future tapFavoritePageButton() async { + await tapPageOptionButton(); + await tapButtonWithName(ViewMoreActionType.favorite.name); + } + + /// Tap the unfavorite page button + Future tapUnfavoritePageButton() async { + await tapPageOptionButton(); + await tapButtonWithName(ViewMoreActionType.unFavorite.name); + } + + /// Tap the Open in a new tab button + Future tapOpenInTabButton() async { + await tapPageOptionButton(); + await tapButtonWithName(ViewMoreActionType.openInNewTab.name); + } + + /// Rename the page. + Future renamePage(String name) async { + await tapRenamePageButton(); + await enterText(find.byType(TextFormField), name); + await tapOKButton(); + } + + Future tapTrashButton() async { + await tap(find.byType(SidebarTrashButton)); + } + + Future tapOKButton() async { + final okButton = find.byWidgetPredicate( + (widget) => + widget is PrimaryTextButton && + widget.label == LocaleKeys.button_ok.tr(), + ); + await tapButton(okButton); + } + + /// Expand or collapse the page. + Future expandOrCollapsePage({ + required String pageName, + required ViewLayoutPB layout, + }) async { + final page = findPageName(pageName, layout: layout); + await hoverOnWidget(page); + final expandButton = find.descendant( + of: page, + matching: find.byType(ViewItemDefaultLeftIcon), + ); + await tapButton(expandButton.first); + } + + /// Tap the restore button. + /// + /// the restore button will show after the current page is deleted. + Future tapRestoreButton() async { + final restoreButton = find.textContaining( + LocaleKeys.deletePagePrompt_restore.tr(), + ); + await tapButton(restoreButton); + } + + /// Tap the delete permanently button. + /// + /// the delete permanently button will show after the current page is deleted. + Future tapDeletePermanentlyButton() async { + final deleteButton = find.textContaining( + LocaleKeys.deletePagePrompt_deletePermanent.tr(), + ); + await tapButton(deleteButton); + await tap(find.text(LocaleKeys.button_delete.tr())); + await pumpAndSettle(); + } + + /// Tap the share button above the document page. + Future tapShareButton() async { + final shareButton = find.byWidgetPredicate( + (widget) => widget is ShareButton, + ); + await tapButton(shareButton); + } + + // open the share menu and then click the publish tab + Future openPublishMenu() async { + await tapShareButton(); + final publishButton = find.textContaining( + LocaleKeys.shareAction_publishTab.tr(), + ); + await tapButton(publishButton); + } + + /// Tap the export markdown button + /// + /// Must call [tapShareButton] first. + Future tapMarkdownButton() async { + final markdownButton = find.textContaining( + LocaleKeys.shareAction_markdown.tr(), + ); + await tapButton(markdownButton); + } + + Future createNewPageWithNameUnderParent({ + String? name, + ViewLayoutPB layout = ViewLayoutPB.Document, + String? parentName, + bool openAfterCreated = true, + }) async { + // create a new page + await tapAddViewButton(name: parentName ?? gettingStarted, layout: layout); + await tapButtonWithName(layout.menuName); + final settingsOrFailure = await getIt().getWithFormat( + KVKeys.showRenameDialogWhenCreatingNewFile, + (value) => bool.parse(value), + ); + final showRenameDialog = settingsOrFailure ?? false; + if (showRenameDialog) { + await tapOKButton(); + } + await pumpAndSettle(); + + // hover on it and change it's name + if (name != null) { + await hoverOnPageName( + layout.defaultName, + layout: layout, + onHover: () async { + await renamePage(name); + await pumpAndSettle(); + }, + ); + await pumpAndSettle(); + } + + // open the page after created + if (openAfterCreated) { + await openPage( + // if the name is null, use the default name + name ?? layout.defaultName, + layout: layout, + ); + await pumpAndSettle(); + } + } + + Future createOpenRenameDocumentUnderParent({ + required String name, + String? parentName, + }) async { + // create a new page + await tapAddViewButton(name: parentName ?? gettingStarted); + await tapButtonWithName(ViewLayoutPB.Document.menuName); + final settingsOrFailure = await getIt().getWithFormat( + KVKeys.showRenameDialogWhenCreatingNewFile, + (value) => bool.parse(value), + ); + final showRenameDialog = settingsOrFailure ?? false; + if (showRenameDialog) { + await tapOKButton(); + } + await pumpAndSettle(); + + // open the page after created + await openPage(ViewLayoutPB.Document.defaultName); + await pumpAndSettle(); + + // Enter new name in the document title + await enterText(find.byType(TextFieldWithMetricLines), name); + await pumpAndSettle(); + } + + /// Create a new page in the space + Future createNewPageInSpace({ + required String spaceName, + required ViewLayoutPB layout, + bool openAfterCreated = true, + String? pageName, + }) async { + final currentSpace = find.byWidgetPredicate( + (widget) => widget is CurrentSpace && widget.space.name == spaceName, + ); + if (currentSpace.evaluate().isEmpty) { + throw Exception('Current space not found'); + } + + await hoverOnWidget( + currentSpace, + onHover: () async { + // click the + button + await clickAddPageButtonInSpaceHeader(); + await tapButtonWithName(layout.menuName); + }, + ); + await pumpAndSettle(); + + if (pageName != null) { + // move the cursor to other place to disable to tooltips + await tapAt(Offset.zero); + + // hover on new created page and change it's name + await hoverOnPageName( + '', + layout: layout, + onHover: () async { + await renamePage(pageName); + await pumpAndSettle(); + }, + ); + await pumpAndSettle(); + } + + // open the page after created + if (openAfterCreated) { + // if the name is null, use empty string + await openPage(pageName ?? '', layout: layout); + await pumpAndSettle(); + } + } + + /// Click the + button in the space header + Future clickAddPageButtonInSpaceHeader() async { + final addPageButton = find.descendant( + of: find.byType(SidebarSpaceHeader), + matching: find.byType(ViewAddButton), + ); + await tapButton(addPageButton); + } + + /// Click the + button in the space header + Future clickSpaceHeader() async { + await tapButton(find.byType(SidebarSpaceHeader)); + } + + Future openSpace(String spaceName) async { + final space = find.descendant( + of: find.byType(SidebarSpaceMenuItem), + matching: find.text(spaceName), + ); + await tapButton(space); + } + + /// Create a new page on the top level + Future createNewPage({ + ViewLayoutPB layout = ViewLayoutPB.Document, + bool openAfterCreated = true, + }) async { + await tapButton(find.byType(SidebarNewPageButton)); + } + + Future simulateKeyEvent( + LogicalKeyboardKey key, { + bool isControlPressed = false, + bool isShiftPressed = false, + bool isAltPressed = false, + bool isMetaPressed = false, + PhysicalKeyboardKey? physicalKey, + }) async { + if (isControlPressed) { + await simulateKeyDownEvent(LogicalKeyboardKey.control); + } + if (isShiftPressed) { + await simulateKeyDownEvent(LogicalKeyboardKey.shift); + } + if (isAltPressed) { + await simulateKeyDownEvent(LogicalKeyboardKey.alt); + } + if (isMetaPressed) { + await simulateKeyDownEvent(LogicalKeyboardKey.meta); + } + await simulateKeyDownEvent( + key, + physicalKey: physicalKey, + ); + await simulateKeyUpEvent( + key, + physicalKey: physicalKey, + ); + if (isControlPressed) { + await simulateKeyUpEvent(LogicalKeyboardKey.control); + } + if (isShiftPressed) { + await simulateKeyUpEvent(LogicalKeyboardKey.shift); + } + if (isAltPressed) { + await simulateKeyUpEvent(LogicalKeyboardKey.alt); + } + if (isMetaPressed) { + await simulateKeyUpEvent(LogicalKeyboardKey.meta); + } + await pumpAndSettle(); + } + + Future openAppInNewTab(String name, ViewLayoutPB layout) async { + await hoverOnPageName( + name, + onHover: () async { + await tapOpenInTabButton(); + await pumpAndSettle(); + }, + ); + await pumpAndSettle(); + } + + Future favoriteViewByName( + String name, { + ViewLayoutPB layout = ViewLayoutPB.Document, + }) async { + await hoverOnPageName( + name, + layout: layout, + onHover: () async { + await tapFavoritePageButton(); + await pumpAndSettle(); + }, + ); + } + + Future unfavoriteViewByName( + String name, { + ViewLayoutPB layout = ViewLayoutPB.Document, + }) async { + await hoverOnPageName( + name, + layout: layout, + onHover: () async { + await tapUnfavoritePageButton(); + await pumpAndSettle(); + }, + ); + } + + Future movePageToOtherPage({ + required String name, + required String parentName, + required ViewLayoutPB layout, + required ViewLayoutPB parentLayout, + DraggableHoverPosition position = DraggableHoverPosition.center, + }) async { + final from = findPageName(name, layout: layout); + final to = findPageName(parentName, layout: parentLayout); + final gesture = await startGesture(getCenter(from)); + Offset offset = Offset.zero; + switch (position) { + case DraggableHoverPosition.center: + offset = getCenter(to); + break; + case DraggableHoverPosition.top: + offset = getTopLeft(to); + break; + case DraggableHoverPosition.bottom: + offset = getBottomLeft(to); + break; + default: + } + await gesture.moveTo(offset, timeStamp: const Duration(milliseconds: 400)); + await gesture.up(); + await pumpAndSettle(); + } + + // tap the button with [FlowySvgData] + Future tapButtonWithFlowySvgData(FlowySvgData svg) async { + final button = find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg.path == svg.path, + ); + await tapButton(button); + } + + // update the page icon in the sidebar + Future updatePageIconInSidebarByName({ + required String name, + required String parentName, + required ViewLayoutPB layout, + required EmojiIconData icon, + }) async { + final iconButton = find.descendant( + of: findPageName( + name, + layout: layout, + parentName: parentName, + ), + matching: + find.byTooltip(LocaleKeys.document_plugins_cover_changeIcon.tr()), + ); + await tapButton(iconButton); + if (icon.type == FlowyIconType.emoji) { + await tapEmoji(icon.emoji); + } else if (icon.type == FlowyIconType.icon) { + await tapIcon(icon); + } + await pumpAndSettle(); + } + + // update the page icon in the sidebar + Future updatePageIconInTitleBarByName({ + required String name, + required ViewLayoutPB layout, + required EmojiIconData icon, + }) async { + await openPage( + name, + layout: layout, + ); + final title = find.descendant( + of: find.byType(ViewTitleBar), + matching: find.text(name), + ); + await tapButton(title); + await tapButton(find.byType(EmojiPickerButton)); + if (icon.type == FlowyIconType.emoji) { + await tapEmoji(icon.emoji); + } else if (icon.type == FlowyIconType.icon) { + await tapIcon(icon); + } + await pumpAndSettle(); + } + + Future openNotificationHub({int tabIndex = 0}) async { + final finder = find.descendant( + of: find.byType(NotificationButton), + matching: find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.clock_alarm_s, + ), + ); + + await tap(finder); + await pumpAndSettle(); + + if (tabIndex == 1) { + final tabFinder = find.descendant( + of: find.byType(NotificationTabBar), + matching: find.byType(FlowyTabItem).at(1), + ); + + await tap(tabFinder); + await pumpAndSettle(); + } + } + + Future toggleCommandPalette() async { + // Press CMD+P or CTRL+P to open the command palette + await simulateKeyEvent( + LogicalKeyboardKey.keyP, + isControlPressed: !Platform.isMacOS, + isMetaPressed: Platform.isMacOS, + ); + await pumpAndSettle(); + } + + Future openCollaborativeWorkspaceMenu() async { + if (!FeatureFlag.collaborativeWorkspace.isOn) { + throw UnsupportedError('Collaborative workspace is not enabled'); + } + + final workspace = find.byType(SidebarWorkspace); + expect(workspace, findsOneWidget); + + await tapButton(workspace, pumpAndSettle: false); + await pump(const Duration(seconds: 5)); + } + + Future createCollaborativeWorkspace(String name) async { + if (!FeatureFlag.collaborativeWorkspace.isOn) { + throw UnsupportedError('Collaborative workspace is not enabled'); + } + await openCollaborativeWorkspaceMenu(); + // expect to see the workspace list, and there should be only one workspace + final workspacesMenu = find.byType(WorkspacesMenu); + expect(workspacesMenu, findsOneWidget); + + // click the create button + final createButton = find.byKey(createWorkspaceButtonKey); + expect(createButton, findsOneWidget); + await tapButton(createButton, pumpAndSettle: false); + await pump(const Duration(seconds: 5)); + + // see the create workspace dialog + final createWorkspaceDialog = find.byType(CreateWorkspaceDialog); + expect(createWorkspaceDialog, findsOneWidget); + + // input the workspace name + final workspaceNameInput = find.descendant( + of: createWorkspaceDialog, + matching: find.byType(TextField), + ); + await enterText(workspaceNameInput, name); + + await tapButtonWithName(LocaleKeys.button_ok.tr(), pumpAndSettle: false); + await pump(const Duration(seconds: 5)); + } + + // For mobile platform to launch the app in anonymous mode + Future launchInAnonymousMode() async { + assert( + [TargetPlatform.android, TargetPlatform.iOS] + .contains(defaultTargetPlatform), + 'This method is only supported on mobile platforms', + ); + + await initializeAppFlowy(); + + final anonymousSignInButton = find.byType(SignInAnonymousButtonV2); + expect(anonymousSignInButton, findsOneWidget); + await tapButton(anonymousSignInButton); + + await pumpUntilFound(find.byType(MobileHomeScreen)); + } + + Future tapSvgButton(FlowySvgData svg) async { + final button = find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg.path == svg.path, + ); + await tapButton(button); + } + + Future openMoreViewActions() async { + final button = find.byType(MoreViewActions); + await tapButton(button); + } + + /// Presses on the Duplicate ViewAction in the [MoreViewActions] popup. + /// + /// [openMoreViewActions] must be called beforehand! + /// + Future duplicateByMoreViewActions() async { + final button = find.byWidgetPredicate( + (widget) => + widget is ViewAction && widget.type == ViewMoreActionType.duplicate, + ); + await tap(button); + await pump(); + } + + /// Presses on the Delete ViewAction in the [MoreViewActions] popup. + /// + /// [openMoreViewActions] must be called beforehand! + /// + Future deleteByMoreViewActions() async { + final button = find.descendant( + of: find.byType(ListView), + matching: find.byWidgetPredicate( + (widget) => + widget is ViewAction && widget.type == ViewMoreActionType.delete, + ), + ); + await tap(button); + await pump(); + } + + Future tapFileUploadHint() async { + final finder = find.byWidgetPredicate( + (w) => + w is RichText && + w.text.toPlainText().contains( + LocaleKeys.document_plugins_file_fileUploadHint.tr(), + ), + ); + await tap(finder); + await pumpAndSettle(const Duration(seconds: 2)); + } + + /// Create a new document on mobile + Future createNewDocumentOnMobile(String name) async { + final createPageButton = find.byKey( + BottomNavigationBarItemType.add.valueKey, + ); + await tapButton(createPageButton); + expect(find.byType(MobileDocumentScreen), findsOneWidget); + + final title = editor.findDocumentTitle(''); + expect(title, findsOneWidget); + final textField = widget(title); + expect(textField.focusNode!.hasFocus, isTrue); + + // input new name and press done button + await enterText(title, name); + await testTextInput.receiveAction(TextInputAction.done); + await pumpAndSettle(); + final newTitle = editor.findDocumentTitle(name); + expect(newTitle, findsOneWidget); + expect(textField.controller!.text, name); + } + + /// Open the plus menu + Future openPlusMenuAndClickButton(String buttonName) async { + assert( + UniversalPlatform.isMobile, + 'This method is only supported on mobile platforms', + ); + + final plusMenuButton = find.byKey(addBlockToolbarItemKey); + final addMenuItem = find.byType(AddBlockMenu); + await tapButton(plusMenuButton); + await pumpUntilFound(addMenuItem); + + final toggleHeading1 = find.byWidgetPredicate( + (widget) => + widget is TypeOptionMenuItem && widget.value.text == buttonName, + ); + final scrollable = find.ancestor( + of: find.byType(TypeOptionGridView), + matching: find.byType(Scrollable), + ); + await scrollUntilVisible( + toggleHeading1, + 100, + scrollable: scrollable, + ); + await tapButton(toggleHeading1); + await pumpUntilNotFound(addMenuItem); + } + + /// Click the column menu button in the simple table + Future clickColumnMenuButton(int index) async { + final columnMenuButton = find.byWidgetPredicate( + (w) => + w is SimpleTableMobileReorderButton && + w.index == index && + w.type == SimpleTableMoreActionType.column, + ); + await tapButton(columnMenuButton); + await pumpUntilFound(find.byType(SimpleTableCellBottomSheet)); + } + + /// Click the row menu button in the simple table + Future clickRowMenuButton(int index) async { + final rowMenuButton = find.byWidgetPredicate( + (w) => + w is SimpleTableMobileReorderButton && + w.index == index && + w.type == SimpleTableMoreActionType.row, + ); + await tapButton(rowMenuButton); + await pumpUntilFound(find.byType(SimpleTableCellBottomSheet)); + } + + /// Click the SimpleTableQuickAction + Future clickSimpleTableQuickAction(SimpleTableMoreAction action) async { + final button = find.byWidgetPredicate( + (widget) => widget is SimpleTableQuickAction && widget.type == action, + ); + await tapButton(button); + } + + /// Click the SimpleTableContentAction + Future clickSimpleTableBoldContentAction() async { + final button = find.byType(SimpleTableContentBoldAction); + await tapButton(button); + } + + /// Cancel the table action menu + Future cancelTableActionMenu() async { + final finder = find.byType(SimpleTableCellBottomSheet); + if (finder.evaluate().isEmpty) { + return; + } + + await tapAt(Offset.zero); + await pumpUntilNotFound(finder); + } +} + +extension SettingsFinder on CommonFinders { + Finder findSettingsScrollable() => find + .descendant( + of: find + .descendant( + of: find.byType(SettingsBody), + matching: find.byType(SingleChildScrollView), + ) + .first, + matching: find.byType(Scrollable), + ) + .first; + + Finder findSettingsMenuScrollable() => find + .descendant( + of: find + .descendant( + of: find.byType(SettingsMenu), + matching: find.byType(SingleChildScrollView), + ) + .first, + matching: find.byType(Scrollable), + ) + .first; +} + +extension FlowySvgFinder on CommonFinders { + Finder byFlowySvg(FlowySvgData svg) => _FlowySvgFinder(svg); +} + +class _FlowySvgFinder extends MatchFinder { + _FlowySvgFinder(this.svg); + + final FlowySvgData svg; + + @override + String get description => 'flowy_svg "$svg"'; + + @override + bool matches(Element candidate) { + final Widget widget = candidate.widget; + return widget is FlowySvg && widget.svg == svg; + } +} + +extension ViewLayoutPBTest on ViewLayoutPB { + String get menuName { + switch (this) { + case ViewLayoutPB.Grid: + return LocaleKeys.grid_menuName.tr(); + case ViewLayoutPB.Board: + return LocaleKeys.board_menuName.tr(); + case ViewLayoutPB.Document: + return LocaleKeys.document_menuName.tr(); + case ViewLayoutPB.Calendar: + return LocaleKeys.calendar_menuName.tr(); + default: + throw UnsupportedError('Unsupported layout: $this'); + } + } + + String get referencedMenuName { + switch (this) { + case ViewLayoutPB.Grid: + return LocaleKeys.document_plugins_referencedGrid.tr(); + case ViewLayoutPB.Board: + return LocaleKeys.document_plugins_referencedBoard.tr(); + case ViewLayoutPB.Calendar: + return LocaleKeys.document_plugins_referencedCalendar.tr(); + default: + throw UnsupportedError('Unsupported layout: $this'); + } + } + + String get slashMenuName { + switch (this) { + case ViewLayoutPB.Grid: + return LocaleKeys.document_slashMenu_name_grid.tr(); + case ViewLayoutPB.Board: + return LocaleKeys.document_slashMenu_name_kanban.tr(); + case ViewLayoutPB.Document: + return LocaleKeys.document_slashMenu_name_doc.tr(); + case ViewLayoutPB.Calendar: + return LocaleKeys.document_slashMenu_name_calendar.tr(); + default: + throw UnsupportedError('Unsupported layout: $this'); + } + } + + String get slashMenuLinkedName { + switch (this) { + case ViewLayoutPB.Grid: + return LocaleKeys.document_slashMenu_name_linkedGrid.tr(); + case ViewLayoutPB.Board: + return LocaleKeys.document_slashMenu_name_linkedKanban.tr(); + case ViewLayoutPB.Document: + return LocaleKeys.document_slashMenu_name_linkedDoc.tr(); + case ViewLayoutPB.Calendar: + return LocaleKeys.document_slashMenu_name_linkedCalendar.tr(); + default: + throw UnsupportedError('Unsupported layout: $this'); + } + } +} diff --git a/frontend/appflowy_flutter/integration_test/shared/constants.dart b/frontend/appflowy_flutter/integration_test/shared/constants.dart new file mode 100644 index 0000000000000..bfe3349b10b08 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/constants.dart @@ -0,0 +1,8 @@ +class Constants { + // this page name is default page name in the new workspace + static const gettingStartedPageName = 'Getting started'; + static const toDosPageName = 'To-dos'; + static const generalSpaceName = 'General'; + + static const defaultWorkspaceName = 'My Workspace'; +} diff --git a/frontend/appflowy_flutter/integration_test/shared/data.dart b/frontend/appflowy_flutter/integration_test/shared/data.dart new file mode 100644 index 0000000000000..c1777638d3f06 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/data.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:archive/archive_io.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum TestWorkspace { + board("board"), + emptyDocument("empty_document"), + aiWorkSpace("ai_workspace"), + coverImage("cover_image"); + + const TestWorkspace(this._name); + + final String _name; + + Future get zip async { + final Directory parent = await TestWorkspace._parent; + final File out = File(p.join(parent.path, '$_name.zip')); + if (await out.exists()) return out; + await out.create(); + final ByteData data = await rootBundle.load(_asset); + await out.writeAsBytes(data.buffer.asUint8List()); + return out; + } + + Future get root async { + final Directory parent = await TestWorkspace._parent; + return Directory(p.join(parent.path, _name)); + } + + static Future get _parent async { + final Directory root = await getTemporaryDirectory(); + if (await root.exists()) return root; + await root.create(); + return root; + } + + String get _asset => 'assets/test/workspaces/$_name.zip'; +} + +class TestWorkspaceService { + const TestWorkspaceService(this.workspace); + + final TestWorkspace workspace; + + /// Instructs the application to read workspace data from the workspace found under this [TestWorkspace]'s path. + Future setUpAll() async { + final root = await workspace.root; + final path = root.path; + SharedPreferences.setMockInitialValues({KVKeys.pathLocation: path}); + } + + /// Workspaces that are checked into source are compressed. [TestWorkspaceService.setUp()] decompresses the file into an ephemeral directory that will be ignored by source control. + Future setUp() async { + final inputStream = + InputFileStream(await workspace.zip.then((value) => value.path)); + final archive = ZipDecoder().decodeBuffer(inputStream); + await extractArchiveToDisk( + archive, + await TestWorkspace._parent.then((value) => value.path), + ); + } +} diff --git a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart new file mode 100644 index 0000000000000..676c96f042710 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart @@ -0,0 +1,1763 @@ +import 'dart:io'; + +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart'; +import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; +import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; +import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart'; +import 'package:appflowy/plugins/database/calendar/presentation/calendar_day.dart'; +import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_card.dart'; +import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_editor.dart'; +import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; +import 'package:appflowy/plugins/database/calendar/presentation/toolbar/calendar_layout_setting.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculation_type_item.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/choicechip.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/disclosure_button.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/footer/grid_footer.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/row/row.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/create_sort_list.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/order_panel.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_editor.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_menu.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart'; +import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_add_button.dart'; +import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_header.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/number.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/timestamp.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/url.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/date_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_text_field.dart'; +import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; +import 'package:appflowy/plugins/database/widgets/field/field_editor.dart'; +import 'package:appflowy/plugins/database/widgets/field/field_type_list.dart'; +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart'; +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/number.dart'; +import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_action.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_banner.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_document.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_property.dart'; +import 'package:appflowy/plugins/database/widgets/setting/database_layout_selector.dart'; +import 'package:appflowy/plugins/database/widgets/setting/database_setting_action.dart'; +import 'package:appflowy/plugins/database/widgets/setting/database_settings_list.dart'; +import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; +import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/text_input.dart'; +import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +// Non-exported member of the table_calendar library +import 'package:table_calendar/src/widgets/cell_content.dart'; +import 'package:table_calendar/table_calendar.dart'; + +import 'base.dart'; +import 'common_operations.dart'; +import 'expectation.dart'; +import 'mock/mock_file_picker.dart'; + +const v020GridFileName = "v020.afdb"; +const v069GridFileName = "v069.afdb"; + +extension AppFlowyDatabaseTest on WidgetTester { + Future openTestDatabase(String fileName) async { + final context = await initializeAppFlowy(); + await tapAnonymousSignInButton(); + + // expect to see a readme page + expectToSeePageName(gettingStarted); + + await tapAddViewButton(); + await tapImportButton(); + + // Don't use the p.join to build the path that used in loadString. It + // is not working on windows. + final str = await rootBundle + .loadString("assets/test/workspaces/database/$fileName"); + + // Write the content to the file. + final path = p.join( + context.applicationDataDirectory, + fileName, + ); + final pageName = p.basenameWithoutExtension(path); + File(path).writeAsStringSync(str); + // mock get files + mockPickFilePaths( + paths: [path], + ); + await tapDatabaseRawDataButton(); + await openPage(pageName, layout: ViewLayoutPB.Grid); + } + + Future hoverOnFirstRowOfGrid([Future Function()? onHover]) async { + final findRow = find.byType(GridRow); + expect(findRow, findsWidgets); + + final firstRow = findRow.first; + await hoverOnWidget(firstRow, onHover: onHover); + } + + Future editCell({ + required int rowIndex, + required FieldType fieldType, + required String input, + int cellIndex = 0, + }) async { + final cell = cellFinder(rowIndex, fieldType, cellIndex: cellIndex); + + expect(cell, findsOneWidget); + await enterText(cell, input); + await testTextInput.receiveAction(TextInputAction.done); + await pumpAndSettle(); + } + + Finder cellFinder(int rowIndex, FieldType fieldType, {int cellIndex = 0}) { + final findRow = find.byType(GridRow, skipOffstage: false); + final findCell = finderForFieldType(fieldType); + return find + .descendant( + of: findRow.at(rowIndex), + matching: findCell, + skipOffstage: false, + ) + .at(cellIndex); + } + + Future tapCheckboxCellInGrid({ + required int rowIndex, + }) async { + final cell = cellFinder(rowIndex, FieldType.Checkbox); + + final button = find.descendant( + of: cell, + matching: find.byType(FlowyIconButton), + ); + + expect(cell, findsOneWidget); + await tapButton(button); + } + + Future assertCheckboxCell({ + required int rowIndex, + required bool isSelected, + }) async { + final cell = cellFinder(rowIndex, FieldType.Checkbox); + final finder = isSelected + ? find.byWidgetPredicate( + (widget) => + widget is FlowySvg && widget.svg == FlowySvgs.check_filled_s, + ) + : find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.uncheck_s, + ); + + expect( + find.descendant( + of: cell, + matching: finder, + ), + findsOneWidget, + ); + } + + Future tapCellInGrid({ + required int rowIndex, + required FieldType fieldType, + }) async { + final cell = cellFinder(rowIndex, fieldType); + expect(cell, findsOneWidget); + await tapButton(cell); + } + + /// The [fieldName] must be unique in the grid. + void assertCellContent({ + required int rowIndex, + required FieldType fieldType, + required String content, + int cellIndex = 0, + }) { + final findCell = cellFinder(rowIndex, fieldType, cellIndex: cellIndex); + final findContent = find.descendant( + of: findCell, + matching: find.text(content), + skipOffstage: false, + ); + expect(findContent, findsOneWidget); + } + + Future assertSingleSelectOption({ + required int rowIndex, + required String content, + }) async { + final findCell = cellFinder(rowIndex, FieldType.SingleSelect); + if (content.isNotEmpty) { + final finder = find.descendant( + of: findCell, + matching: find.byWidgetPredicate( + (widget) => + widget is SelectOptionTag && + (widget.name == content || widget.option?.name == content), + ), + ); + expect(finder, findsOneWidget); + } + } + + void assertMultiSelectOption({ + required int rowIndex, + required List contents, + }) { + final findCell = cellFinder(rowIndex, FieldType.MultiSelect); + for (final content in contents) { + if (content.isNotEmpty) { + final finder = find.descendant( + of: findCell, + matching: find.byWidgetPredicate( + (widget) => + widget is SelectOptionTag && + (widget.name == content || widget.option?.name == content), + ), + ); + expect(finder, findsOneWidget); + } + } + } + + /// null percent means no progress bar should be found + void assertChecklistCellInGrid({ + required int rowIndex, + required double? percent, + }) { + final findCell = cellFinder(rowIndex, FieldType.Checklist); + + if (percent == null) { + final finder = find.descendant( + of: findCell, + matching: find.byType(ChecklistProgressBar), + ); + expect(finder, findsNothing); + } else { + final finder = find.descendant( + of: findCell, + matching: find.byWidgetPredicate( + (widget) => + widget is ChecklistProgressBar && widget.percent == percent, + ), + ); + expect(finder, findsOneWidget); + } + } + + Future selectDay({ + required int content, + }) async { + final findCalendar = find.byType(TableCalendar); + final findDay = find.text(content.toString()); + + final finder = find.descendant( + of: findCalendar, + matching: findDay, + ); + + // if the day is very near the beginning or the end of the month, + // it may overlap with the same day in the next or previous month, + // respectively because it was spilling over. This will lead to 2 + // widgets being found and thus cannot be tapped correctly. + if (content < 15) { + // e.g., Jan 2 instead of Feb 2 + await tapButton(finder.first); + } else { + // e.g. Jun 28 instead of May 28 + await tapButton(finder.last); + } + } + + Future toggleIncludeTime() async { + final findDateEditor = find.byType(IncludeTimeButton); + final findToggle = find.byType(Toggle); + final finder = find.descendant( + of: findDateEditor, + matching: findToggle, + ); + await tapButton(finder); + } + + Future selectReminderOption(ReminderOption option) async { + await tapButton(find.byType(ReminderSelector)); + + final finder = find.descendant( + of: find.byType(FlowyButton), + matching: find.textContaining(option.label), + ); + + await tapButton(finder); + } + + Future selectLastDateInPicker() async { + final finder = find.byType(CellContent).last; + final w = widget(finder) as CellContent; + + await tapButton(finder); + + return w.isToday; + } + + Future tapChangeDateTimeFormatButton() async { + await tapButton(find.byType(DateTypeOptionButton)); + } + + Future changeDateFormat() async { + final findDateFormatButton = find.byType(DateFormatButton); + await tapButton(findDateFormatButton); + + final findNewDateFormat = find.text("Day/Month/Year"); + await tapButton(findNewDateFormat); + } + + Future changeTimeFormat() async { + final findDateFormatButton = find.byType(TimeFormatButton); + await tapButton(findDateFormatButton); + + final findNewDateFormat = find.text("12 hour"); + await tapButton(findNewDateFormat); + } + + Future clearDate() async { + final findDateEditor = find.byType(DateCellEditor); + final findClearButton = find.byType(ClearDateButton); + final finder = find.descendant( + of: findDateEditor, + matching: findClearButton, + ); + await tapButton(finder); + } + + Future tapSelectOptionCellInGrid({ + required int rowIndex, + required FieldType fieldType, + }) async { + assert( + fieldType == FieldType.SingleSelect || fieldType == FieldType.MultiSelect, + ); + + final findRow = find.byType(GridRow); + final findCell = finderForFieldType(fieldType); + + final cell = find.descendant( + of: findRow.at(rowIndex), + matching: findCell, + ); + + await tapButton(cell); + } + + /// The [SelectOptionCellEditor] must be opened first. + Future createOption({required String name}) async { + final findEditor = find.byType(SelectOptionCellEditor); + expect(findEditor, findsOneWidget); + + final findTextField = find.byType(SelectOptionTextField); + expect(findTextField, findsOneWidget); + + await enterText(findTextField, name); + await pump(); + + await testTextInput.receiveAction(TextInputAction.done); + await pumpAndSettle(); + } + + Future selectOption({required String name}) async { + final option = find.descendant( + of: find.byType(SelectOptionCellEditor), + matching: find.byWidgetPredicate( + (widget) => widget is SelectOptionTagCell && widget.option.name == name, + ), + ); + + await tapButton(option); + } + + void findSelectOptionWithNameInGrid({ + required int rowIndex, + required String name, + }) { + final findRow = find.byType(GridRow); + final option = find.byWidgetPredicate( + (widget) => + widget is SelectOptionTag && + (widget.name == name || widget.option?.name == name), + ); + + final cell = find.descendant(of: findRow.at(rowIndex), matching: option); + expect(cell, findsOneWidget); + } + + void assertNumberOfSelectedOptionsInGrid({ + required int rowIndex, + required Matcher matcher, + }) { + final findRow = find.byType(GridRow); + + final options = find.byWidgetPredicate( + (widget) => widget is SelectOptionTag, + ); + + final cell = find.descendant(of: findRow.at(rowIndex), matching: options); + expect(cell, matcher); + } + + Future tapChecklistCellInGrid({required int rowIndex}) async { + final findRow = find.byType(GridRow); + final findCell = finderForFieldType(FieldType.Checklist); + + final cell = find.descendant(of: findRow.at(rowIndex), matching: findCell); + await tapButton(cell); + } + + void assertChecklistEditorVisible({required bool visible}) { + final editor = find.byType(ChecklistCellEditor); + if (visible) { + return expect(editor, findsOneWidget); + } + expect(editor, findsNothing); + } + + Future createNewChecklistTask({ + required String name, + enter = false, + button = false, + }) async { + assert(!(enter && button)); + final textField = find.descendant( + of: find.byType(NewTaskItem), + matching: find.byType(TextField), + ); + + await enterText(textField, name); + await pumpAndSettle(); + if (enter) { + await testTextInput.receiveAction(TextInputAction.done); + await pumpAndSettle(const Duration(milliseconds: 500)); + } else { + await tapButton( + find.descendant( + of: find.byType(NewTaskItem), + matching: find.byType(FlowyTextButton), + ), + ); + } + } + + void assertChecklistTaskInEditor({ + required int index, + required String name, + required bool isChecked, + }) { + final task = find.byType(ChecklistItem).at(index); + final widget = this.widget(task); + assert( + widget.task.data.name == name && widget.task.isSelected == isChecked, + ); + } + + Future renameChecklistTask({ + required int index, + required String name, + bool enter = true, + }) async { + final textField = find + .descendant( + of: find.byType(ChecklistItem), + matching: find.byType(TextField), + ) + .at(index); + + await enterText(textField, name); + if (enter) { + await testTextInput.receiveAction(TextInputAction.done); + } + await pumpAndSettle(); + } + + Future checkChecklistTask({required int index}) async { + final button = find.descendant( + of: find.byType(ChecklistItem).at(index), + matching: find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.uncheck_s, + ), + ); + + await tapButton(button); + } + + Future deleteChecklistTask({required int index}) async { + final task = find.byType(ChecklistItem).at(index); + + await hoverOnWidget( + task, + onHover: () async { + final button = find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.delete_s, + ); + await tapButton(button); + }, + ); + } + + void assertPhantomChecklistItemAtIndex({required int index}) { + final paddings = find.descendant( + of: find.descendant( + of: find.byType(ChecklistRowDetailCell), + matching: find.byType(ReorderableListView), + ), + matching: find.byWidgetPredicate( + (widget) => + widget is Padding && + (widget.child is ChecklistItem || + widget.child is PhantomChecklistItem), + ), + ); + final phantom = widget(paddings.at(index)).child!; + expect(phantom is PhantomChecklistItem, true); + } + + void assertPhantomChecklistItemContent(String content) { + final phantom = find.byType(PhantomChecklistItem); + final text = find.text(content); + expect(find.descendant(of: phantom, matching: text), findsOneWidget); + } + + Future openFirstRowDetailPage() async { + await hoverOnFirstRowOfGrid(); + + final expandButton = find.byType(PrimaryCellAccessory); + expect(expandButton, findsOneWidget); + await tapButton(expandButton); + } + + void assertRowDetailPageOpened() async { + final findRowDetailPage = find.byType(RowDetailPage); + expect(findRowDetailPage, findsOneWidget); + } + + Future dismissRowDetailPage() async { + // use tap empty area instead of clicking ESC to dismiss the row detail page + // sometimes, the ESC key is not working. + await simulateKeyEvent(LogicalKeyboardKey.escape); + await pumpAndSettle(); + final findRowDetailPage = find.byType(RowDetailPage); + if (findRowDetailPage.evaluate().isNotEmpty) { + await tapAt(const Offset(0, 0)); + await pumpAndSettle(); + } + } + + Future hoverRowBanner() async { + final banner = find.byType(RowBanner); + expect(banner, findsOneWidget); + + await startGesture( + getCenter(banner) + const Offset(0, -10), + kind: PointerDeviceKind.mouse, + ); + await pumpAndSettle(); + } + + /// Used to open the add cover popover, by pressing on "Add cover"-button. + /// + /// Should call [hoverRowBanner] first. + /// + Future tapAddCoverButton() async { + await tapButtonWithName( + LocaleKeys.document_plugins_cover_addCover.tr(), + ); + } + + Future openEmojiPicker() async => + tapButton(find.text(LocaleKeys.document_plugins_cover_addIcon.tr())); + + Future tapDateCellInRowDetailPage() async { + final findDateCell = find.byType(EditableDateCell); + await tapButton(findDateCell); + } + + Future tapGridFieldWithNameInRowDetailPage(String name) async { + final fields = find.byWidgetPredicate( + (widget) => widget is FieldCellButton && widget.field.name == name, + ); + final field = find.descendant( + of: find.byType(RowDetailPage), + matching: fields, + ); + await tapButton(field); + await pumpAndSettle(); + } + + Future hoverOnFieldInRowDetail({required int index}) async { + final fieldButtons = find.byType(FieldCellButton); + final button = find + .descendant(of: find.byType(RowDetailPage), matching: fieldButtons) + .at(index); + return startGesture(getCenter(button), kind: PointerDeviceKind.mouse); + } + + Future reorderFieldInRowDetail({required double offset}) async { + final thumb = find + .byWidgetPredicate( + (widget) => widget is ReorderableDragStartListener && widget.enabled, + ) + .first; + await drag(thumb, Offset(0, offset), kind: PointerDeviceKind.mouse); + await pumpAndSettle(); + } + + void assertToggleShowHiddenFieldsVisibility(bool shown) { + final button = find.byType(ToggleHiddenFieldsVisibilityButton); + if (shown) { + expect(button, findsOneWidget); + } else { + expect(button, findsNothing); + } + } + + Future toggleShowHiddenFields() async { + final button = find.byType(ToggleHiddenFieldsVisibilityButton); + await tapButton(button); + } + + Future tapDeletePropertyInFieldEditor() async { + final deleteButton = find.byWidgetPredicate( + (w) => w is FieldActionCell && w.action == FieldAction.delete, + ); + await tapButton(deleteButton); + await tapButtonWithName(LocaleKeys.space_delete.tr()); + } + + Future scrollRowDetailByOffset(Offset offset) async { + await drag(find.byType(RowDetailPage), offset); + await pumpAndSettle(); + } + + Future scrollToRight(Finder find) async { + final size = getSize(find); + await drag(find, Offset(-size.width, 0), warnIfMissed: false); + await pumpAndSettle(const Duration(milliseconds: 500)); + } + + Future tapNewPropertyButton() async { + await tapButtonWithName(LocaleKeys.grid_field_newProperty.tr()); + await pumpAndSettle(); + } + + Future tapGridFieldWithName(String name) async { + final field = find.byWidgetPredicate( + (widget) => widget is FieldCellButton && widget.field.name == name, + ); + await tapButton(field); + await pumpAndSettle(); + } + + Future changeFieldTypeOfFieldWithName( + String name, + FieldType type, { + ViewLayoutPB layout = ViewLayoutPB.Grid, + }) async { + await tapGridFieldWithName(name); + if (layout == ViewLayoutPB.Grid) { + await tapEditFieldButton(); + } + + await tapSwitchFieldTypeButton(); + await selectFieldType(type); + await dismissFieldEditor(); + } + + Future changeFieldIcon(String icon) async { + await tapButton(find.byType(FieldEditIconButton)); + if (icon.isEmpty) { + final button = find.descendant( + of: find.byType(FlowyIconEmojiPicker), + matching: find.text( + LocaleKeys.button_remove.tr(), + ), + ); + await tapButton(button); + } else { + final svgContent = kIconGroups?.findSvgContent(icon); + await tapButton( + find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svgString == svgContent, + ), + ); + } + } + + void assertFieldSvg(String name, FieldType fieldType) { + final svgFinder = find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == fieldType.svgData, + ); + final fieldButton = find.byWidgetPredicate( + (widget) => widget is FieldCellButton && widget.field.name == name, + ); + expect( + find.descendant(of: fieldButton, matching: svgFinder), + findsOneWidget, + ); + } + + void assertFieldCustomSvg(String name, String svg) { + final svgContent = kIconGroups?.findSvgContent(svg); + final svgFinder = find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svgString == svgContent, + ); + final fieldButton = find.byWidgetPredicate( + (widget) => widget is FieldCellButton && widget.field.name == name, + ); + expect( + find.descendant(of: fieldButton, matching: svgFinder), + findsOneWidget, + ); + } + + Future changeCalculateAtIndex(int index, CalculationType type) async { + await tap(find.byType(CalculateCell).at(index)); + await pumpAndSettle(); + + await tap( + find.descendant( + of: find.byType(CalculationTypeItem), + matching: find.text(type.label), + ), + ); + await pumpAndSettle(); + } + + /// Should call [tapGridFieldWithName] first. + Future tapEditFieldButton() async { + await tapButtonWithName(LocaleKeys.grid_field_editProperty.tr()); + await pumpAndSettle(const Duration(milliseconds: 200)); + } + + /// Should call [tapGridFieldWithName] first. + Future tapDeletePropertyButton() async { + final field = find.byWidgetPredicate( + (w) => w is FieldActionCell && w.action == FieldAction.delete, + ); + await tapButton(field); + } + + /// A SimpleDialog must be shown first, e.g. when deleting a field. + Future tapDialogOkButton() async { + final field = find.byWidgetPredicate( + (w) => w is PrimaryTextButton && w.label == LocaleKeys.button_ok.tr(), + ); + await tapButton(field); + } + + /// Should call [tapGridFieldWithName] first. + Future tapDuplicatePropertyButton() async { + final field = find.byWidgetPredicate( + (w) => w is FieldActionCell && w.action == FieldAction.duplicate, + ); + await tapButton(field); + } + + Future tapInsertFieldButton({ + required bool left, + required String name, + }) async { + final field = find.byWidgetPredicate( + (widget) => + widget is FieldActionCell && + (left && widget.action == FieldAction.insertLeft || + !left && widget.action == FieldAction.insertRight), + ); + await tapButton(field); + await renameField(name); + } + + /// Should call [tapGridFieldWithName] first. + Future tapHidePropertyButton() async { + final field = find.byWidgetPredicate( + (w) => w is FieldActionCell && w.action == FieldAction.toggleVisibility, + ); + await tapButton(field); + } + + Future tapHidePropertyButtonInFieldEditor() async { + final button = find.byWidgetPredicate( + (w) => w is FieldActionCell && w.action == FieldAction.toggleVisibility, + ); + await tapButton(button); + } + + Future tapRowDetailPageRowActionButton() async => + tapButton(find.byType(RowActionButton)); + + Future tapRowDetailPageCreatePropertyButton() async => + tapButton(find.byType(CreateRowFieldButton)); + + Future tapRowDetailPageDeleteRowButton() async => + tapButton(find.byType(RowDetailPageDeleteButton)); + + Future tapRowDetailPageDuplicateRowButton() async => + tapButton(find.byType(RowDetailPageDuplicateButton)); + + Future tapSwitchFieldTypeButton() async => + tapButton(find.byType(SwitchFieldButton)); + + Future tapEscButton() async => sendKeyEvent(LogicalKeyboardKey.escape); + + /// Must call [tapSwitchFieldTypeButton] first. + Future selectFieldType(FieldType fieldType) async { + final fieldTypeCell = find.byType(FieldTypeCell); + final fieldTypeButton = find.descendant( + of: fieldTypeCell, + matching: find.byWidgetPredicate( + (widget) => widget is FlowyText && widget.text == fieldType.i18n, + ), + ); + await tapButton(fieldTypeButton); + } + + // Use in edit mode of FieldEditor + void expectEmptyTypeOptionEditor() => expect( + find.descendant( + of: find.byType(FieldTypeOptionEditor), + matching: find.byType(TypeOptionSeparator), + ), + findsNothing, + ); + + /// Each field has its own cell, so we can find the corresponding cell by + /// the field type after create a new field. + void findCellByFieldType(FieldType fieldType) { + final finder = finderForFieldType(fieldType); + expect(finder, findsWidgets); + } + + void assertNumberOfRowsInGridPage(int num) { + expect( + find.byType(GridRow, skipOffstage: false), + findsNWidgets(num), + ); + } + + Future assertDocumentExistInRowDetailPage() async { + expect(find.byType(RowDocument), findsOneWidget); + } + + /// Check the field type of the [FieldCellButton] is the same as the name. + Future assertFieldTypeWithFieldName(String name, FieldType type) async { + final field = find.byWidgetPredicate( + (widget) => + widget is FieldCellButton && + widget.field.fieldType == type && + widget.field.name == name, + ); + + expect(field, findsOneWidget); + } + + void assertFirstFieldInRowDetailByType(FieldType fieldType) { + final firstField = find + .descendant( + of: find.byType(RowDetailPage), + matching: find.byType(FieldCellButton), + ) + .first; + + final widget = this.widget(firstField); + expect(widget.field.fieldType, fieldType); + } + + void findFieldWithName(String name) { + final field = find.byWidgetPredicate( + (widget) => widget is FieldCellButton && widget.field.name == name, + ); + expect(field, findsOneWidget); + } + + void noFieldWithName(String name) { + final field = find.byWidgetPredicate( + (widget) => widget is FieldCellButton && widget.field.name == name, + ); + expect(field, findsNothing); + } + + Future renameField(String newName) async { + final textField = find.byType(FieldNameTextField); + expect(textField, findsOneWidget); + await enterText(textField, newName); + await pumpAndSettle(); + } + + Future dismissFieldEditor() async { + await sendKeyEvent(LogicalKeyboardKey.escape); + await pumpAndSettle(const Duration(milliseconds: 200)); + } + + Future findDateEditor(dynamic matcher) async { + final finder = find.byType(DateCellEditor); + expect(finder, matcher); + } + + Future findMediaCellEditor(dynamic matcher) async { + final finder = find.byType(MediaCellEditor); + expect(finder, matcher); + } + + Future findSelectOptionEditor(dynamic matcher) async { + final finder = find.byType(SelectOptionCellEditor); + expect(finder, matcher); + } + + Future dismissCellEditor() async { + await sendKeyEvent(LogicalKeyboardKey.escape); + await pumpAndSettle(); + } + + Future tapCreateRowButtonInGrid() async { + await tapButton(find.byType(GridAddRowButton)); + } + + Future tapCreateRowButtonAfterHoveringOnGridRow() async { + await tapButton(find.byType(InsertRowButton)); + } + + Future tapRowMenuButtonInGrid() async { + await tapButton(find.byType(RowMenuButton)); + } + + /// Should call [tapRowMenuButtonInGrid] first. + Future tapCreateRowAboveButtonInRowMenu() async { + await tapButtonWithName(LocaleKeys.grid_row_insertRecordAbove.tr()); + } + + /// Should call [tapRowMenuButtonInGrid] first. + Future tapDeleteOnRowMenu() async { + await tapButtonWithName(LocaleKeys.grid_row_delete.tr()); + } + + Future reorderRow( + String from, + String to, + ) async { + final fromRow = find.byWidgetPredicate( + (widget) => widget is GridRow && widget.rowId == from, + ); + final toRow = find.byWidgetPredicate( + (widget) => widget is GridRow && widget.rowId == to, + ); + await hoverOnWidget( + fromRow, + onHover: () async { + final dragElement = find.descendant( + of: fromRow, + matching: find.byType(ReorderableDragStartListener), + ); + await timedDrag( + dragElement, + getCenter(toRow) - getCenter(fromRow), + const Duration(milliseconds: 200), + ); + await pumpAndSettle(); + }, + ); + } + + Future createField( + FieldType fieldType, { + String? name, + ViewLayoutPB layout = ViewLayoutPB.Grid, + }) async { + if (layout == ViewLayoutPB.Grid) { + await scrollToRight(find.byType(GridPage)); + } + await tapNewPropertyButton(); + if (name != null) { + await renameField(name); + } + await tapSwitchFieldTypeButton(); + await selectFieldType(fieldType); + } + + Future tapDatabaseSettingButton() async { + await tapButton(find.byType(SettingButton)); + } + + Future tapDatabaseFilterButton() async { + await tapButton(find.byType(FilterButton)); + } + + Future tapDatabaseSortButton() async { + await tapButton(find.byType(SortButton)); + } + + Future tapCreateFilterByFieldType(FieldType type, String title) async { + final findFilter = find.byWidgetPredicate( + (widget) => + widget is FilterableFieldButton && + widget.fieldInfo.fieldType == type && + widget.fieldInfo.name == title, + ); + await tapButton(findFilter); + } + + Future tapFilterButtonInGrid(String name) async { + final button = find.byWidgetPredicate( + (widget) => widget is ChoiceChipButton && widget.fieldInfo.name == name, + ); + await tapButton(button); + } + + Future tapCreateSortByFieldType(FieldType type, String title) async { + final findSort = find.byWidgetPredicate( + (widget) => + widget is GridSortPropertyCell && + widget.fieldInfo.fieldType == type && + widget.fieldInfo.name == title, + ); + await tapButton(findSort); + } + + // Must call [tapSortMenuInSettingBar] first. + Future tapCreateSortByFieldTypeInSortMenu( + FieldType fieldType, + String title, + ) async { + await tapButton(find.byType(DatabaseAddSortButton)); + + final findSort = find.byWidgetPredicate( + (widget) => + widget is GridSortPropertyCell && + widget.fieldInfo.fieldType == fieldType && + widget.fieldInfo.name == title, + ); + + await tapButton(findSort); + await pumpAndSettle(); + } + + Future tapSortMenuInSettingBar() async { + await tapButton(find.byType(SortMenu)); + await pumpAndSettle(); + } + + /// Must call [tapSortMenuInSettingBar] first. + Future tapEditSortConditionButtonByFieldName(String name) async { + final sortItem = find.descendant( + of: find.ancestor( + of: find.text(name), + matching: find.byType(DatabaseSortItem), + ), + matching: find.byType(SortConditionButton), + ); + await tapButton(sortItem); + } + + /// Must call [tapSortMenuInSettingBar] first. + Future reorderSort( + (FieldType, String) from, + (FieldType, String) to, + ) async { + final fromSortItem = find.ancestor( + of: find.text(from.$2), + matching: find.byType(DatabaseSortItem), + ); + final toSortItem = find.ancestor( + of: find.text(to.$2), + matching: find.byType(DatabaseSortItem), + ); + // final fromSortItem = find.byWidgetPredicate( + // (widget) => + // widget is DatabaseSortItem && + // widget.sort.fieldInfo.fieldType == from.$1 && + // widget.sort.fieldInfo.name == from.$2, + // ); + // final toSortItem = find.byWidgetPredicate( + // (widget) => + // widget is DatabaseSortItem && + // widget.sort.fieldInfo.fieldType == to.$1 && + // widget.sort.fieldInfo.name == to.$2, + // ); + final dragElement = find.descendant( + of: fromSortItem, + matching: find.byType(ReorderableDragStartListener), + ); + await drag(dragElement, getCenter(toSortItem) - getCenter(fromSortItem)); + await pumpAndSettle(const Duration(milliseconds: 200)); + } + + /// Must call [tapEditSortConditionButtonByFieldName] first. + Future tapSortByDescending() async { + await tapButton( + find.byWidgetPredicate( + (widget) => + widget is OrderPanelItem && + widget.condition == SortConditionPB.Descending, + ), + ); + await sendKeyEvent(LogicalKeyboardKey.escape); + await pumpAndSettle(); + } + + /// Must call [tapSortMenuInSettingBar] first. + Future tapDeleteAllSortsButton() async { + await tapButton(find.byType(DeleteAllSortsButton)); + } + + Future scrollOptionFilterListByOffset(Offset offset) async { + await drag(find.byType(SelectOptionFilterEditor), offset); + await pumpAndSettle(); + } + + Future enterTextInTextFilter(String text) async { + final findEditor = find.byType(TextFilterEditor); + final findTextField = find.descendant( + of: findEditor, + matching: find.byType(FlowyTextField), + ); + + await enterText(findTextField, text); + await pumpAndSettle(const Duration(milliseconds: 300)); + } + + Future tapDisclosureButtonInFinder(Finder finder) async { + final findDisclosure = find.descendant( + of: finder, + matching: find.byType(DisclosureButton), + ); + + await tapButton(findDisclosure); + } + + /// must call [tapDisclosureButtonInFinder] first. + Future tapDeleteFilterButtonInGrid() async { + await tapButton(find.text(LocaleKeys.grid_settings_deleteFilter.tr())); + } + + Future tapCheckboxFilterButtonInGrid() async { + await tapButton(find.byType(CheckboxFilterConditionList)); + } + + Future tapChecklistFilterButtonInGrid() async { + await tapButton(find.byType(ChecklistFilterConditionList)); + } + + /// The [SelectOptionFilterList] must show up first. + Future tapOptionFilterWithName(String name) async { + final findCell = find.descendant( + of: find.byType(SelectOptionFilterList), + matching: find.byWidgetPredicate( + (widget) => + widget is SelectOptionFilterCell && widget.option.name == name, + skipOffstage: false, + ), + skipOffstage: false, + ); + expect(findCell, findsOneWidget); + await tapButton(findCell); + } + + Future tapUnCheckedButtonOnCheckboxFilter() async { + final button = find.descendant( + of: find.byType(HoverButton), + matching: find.text(LocaleKeys.grid_checkboxFilter_isUnchecked.tr()), + ); + + await tapButton(button); + } + + Future tapCompletedButtonOnChecklistFilter() async { + final button = find.descendant( + of: find.byType(HoverButton), + matching: find.text(LocaleKeys.grid_checklistFilter_isComplete.tr()), + ); + + await tapButton(button); + } + + Future changeTextFilterCondition( + TextFilterConditionPB condition, + ) async { + await tapButton(find.byType(TextFilterConditionList)); + final button = find.descendant( + of: find.byType(HoverButton), + matching: find.text( + condition.filterName, + ), + ); + + await tapButton(button); + } + + Future changeSelectFilterCondition( + SelectOptionFilterConditionPB condition, + ) async { + await tapButton(find.byType(SelectOptionFilterConditionList)); + final button = find.descendant( + of: find.byType(HoverButton), + matching: find.text(condition.i18n), + ); + + await tapButton(button); + } + + Future changeDateFilterCondition( + DateTimeFilterCondition condition, + ) async { + await tapButton(find.byType(DateFilterConditionList)); + final button = find.descendant( + of: find.byType(HoverButton), + matching: find.text(condition.filterName), + ); + + await tapButton(button); + } + + /// Should call [tapDatabaseSettingButton] first. + Future tapViewPropertiesButton() async { + final findSettingItem = find.byType(DatabaseSettingsList); + final findLayoutButton = find.byWidgetPredicate( + (widget) => + widget is FlowyText && + widget.text == DatabaseSettingAction.showProperties.title(), + ); + + final button = find.descendant( + of: findSettingItem, + matching: findLayoutButton, + ); + + await tapButton(button); + } + + /// Should call [tapDatabaseSettingButton] first. + Future tapDatabaseLayoutButton() async { + final findSettingItem = find.byType(DatabaseSettingsList); + final findLayoutButton = find.byWidgetPredicate( + (widget) => + widget is FlowyText && + widget.text == DatabaseSettingAction.showLayout.title(), + ); + + final button = find.descendant( + of: findSettingItem, + matching: findLayoutButton, + ); + + await tapButton(button); + } + + Future tapCalendarLayoutSettingButton() async { + final findSettingItem = find.byType(DatabaseSettingsList); + final findLayoutButton = find.byWidgetPredicate( + (widget) => + widget is FlowyText && + widget.text == DatabaseSettingAction.showCalendarLayout.title(), + ); + + final button = find.descendant( + of: findSettingItem, + matching: findLayoutButton, + ); + + await tapButton(button); + } + + Future tapFirstDayOfWeek() async => + tapButton(find.byType(FirstDayOfWeek)); + + Future tapFirstDayOfWeekStartFromMonday() async { + final finder = find.byWidgetPredicate( + (widget) => widget is StartFromButton && widget.dayIndex == 1, + ); + await tapButton(finder); + + // Dismiss the popover overlay in cause of obscure the tapButton + // in the next test case. + await sendKeyEvent(LogicalKeyboardKey.escape); + await pumpAndSettle(const Duration(milliseconds: 200)); + } + + void assertFirstDayOfWeekStartFromMonday() { + final finder = find.byWidgetPredicate( + (w) => w is StartFromButton && w.dayIndex == 1 && w.isSelected == true, + ); + expect(finder, findsOneWidget); + } + + void assertFirstDayOfWeekStartFromSunday() { + final finder = find.byWidgetPredicate( + (w) => w is StartFromButton && w.dayIndex == 0 && w.isSelected == true, + ); + expect(finder, findsOneWidget); + } + + Future scrollToToday() async { + final todayCell = find.byWidgetPredicate( + (widget) => widget is CalendarDayCard && widget.isToday, + ); + final scrollable = find + .descendant( + of: find.byType(MonthView), + matching: find.byWidgetPredicate( + (widget) => widget is Scrollable && widget.axis == Axis.vertical, + ), + ) + .first; + await scrollUntilVisible(todayCell, 300, scrollable: scrollable); + await pumpAndSettle(const Duration(milliseconds: 300)); + } + + Future hoverOnTodayCalendarCell({ + Future Function()? onHover, + }) async { + final todayCell = find.byWidgetPredicate( + (widget) => widget is CalendarDayCard && widget.isToday, + ); + + await hoverOnWidget(todayCell, onHover: onHover); + } + + Future tapAddCalendarEventButton() async { + final findFlowyButton = find.byType(FlowyIconButton); + final findNewEventButton = find.byType(NewEventButton); + final button = find.descendant( + of: findNewEventButton, + matching: findFlowyButton, + ); + await tapButton(button); + } + + /// Checks for a certain number of events. Parameters [date] and [title] can + /// also be provided to restrict the scope of the search + void assertNumberOfEventsInCalendar(int number, {String? title}) { + Finder findEvents = find.byType(EventCard); + if (title != null) { + findEvents = find.descendant(of: findEvents, matching: find.text(title)); + } + expect(findEvents, findsNWidgets(number)); + } + + void assertNumberOfEventsOnSpecificDay( + int number, + DateTime date, { + String? title, + }) { + final findDayCell = find.byWidgetPredicate( + (widget) => widget is CalendarDayCard && isSameDay(widget.date, date), + ); + Finder findEvents = find.descendant( + of: findDayCell, + matching: find.byType(EventCard), + ); + if (title != null) { + findEvents = find.descendant(of: findEvents, matching: find.text(title)); + } + expect(findEvents, findsNWidgets(number)); + } + + Future doubleClickCalendarCell(DateTime date) async { + final todayCell = find.byWidgetPredicate( + (widget) => widget is CalendarDayCard && isSameDay(date, widget.date), + ); + final location = getTopLeft(todayCell).translate(10, 10); + await doubleTapAt(location); + } + + Future openCalendarEvent({required int index, DateTime? date}) async { + final findDayCell = find.byWidgetPredicate( + (widget) => + widget is CalendarDayCard && + isSameDay(widget.date, date ?? DateTime.now()), + ); + final cards = find.descendant( + of: findDayCell, + matching: find.byType(EventCard), + ); + + await tapButton(cards.at(index), milliseconds: 1000); + } + + void assertEventEditorOpen() => + expect(find.byType(CalendarEventEditor), findsOneWidget); + + Future dismissEventEditor() async => + simulateKeyEvent(LogicalKeyboardKey.escape); + + Future editEventTitle(String title) async { + final textField = find.descendant( + of: find.byType(CalendarEventEditor), + matching: find.byType(FlowyTextField), + ); + + await enterText(textField, title); + await testTextInput.receiveAction(TextInputAction.done); + await pumpAndSettle(const Duration(milliseconds: 300)); + } + + Future openEventToRowDetailPage() async { + final button = find.descendant( + of: find.byType(CalendarEventEditor), + matching: find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.full_view_s, + ), + ); + + await tapButton(button); + } + + Future deleteEventFromEventEditor() async { + final button = find.descendant( + of: find.byType(CalendarEventEditor), + matching: find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.delete_s, + ), + ); + + await tapButton(button); + } + + Future dragDropRescheduleCalendarEvent() async { + final findEventCard = find.byType(EventCard); + await drag(findEventCard.first, const Offset(0, 300)); + await pumpAndSettle(const Duration(microseconds: 300)); + } + + Future openUnscheduledEventsPopup() async { + final button = find.byType(UnscheduledEventsButton); + await tapButton(button); + } + + void findUnscheduledPopup(Matcher matcher, int numUnscheduledEvents) { + expect(find.byType(UnscheduleEventsList), matcher); + if (matcher != findsNothing) { + expect( + find.byType(UnscheduledEventCell), + findsNWidgets(numUnscheduledEvents), + ); + } + } + + Future clickUnscheduledEvent() async { + final unscheduledEvent = find.byType(UnscheduledEventCell); + await tapButton(unscheduledEvent); + } + + Future tapCreateLinkedDatabaseViewButton( + DatabaseLayoutPB layoutType, + ) async { + final findAddButton = find.byType(AddDatabaseViewButton); + await tapButton(findAddButton); + + final findCreateButton = find.byWidgetPredicate( + (widget) => + widget is TabBarAddButtonActionCell && widget.action == layoutType, + ); + await tapButton(findCreateButton); + } + + void assertNumberOfGroups(int number) { + final groups = find.byType(BoardColumnHeader, skipOffstage: false); + expect(groups, findsNWidgets(number)); + } + + Future scrollBoardToEnd() async { + final scrollable = find + .descendant( + of: find.byType(AppFlowyBoard), + matching: find.byWidgetPredicate( + (widget) => widget is Scrollable && widget.axis == Axis.horizontal, + ), + ) + .first; + await scrollUntilVisible( + find.byType(BoardTrailing), + 300, + scrollable: scrollable, + ); + } + + Future tapNewGroupButton() async { + final button = find.descendant( + of: find.byType(BoardTrailing), + matching: find.byWidgetPredicate( + (widget) => widget is FlowySvg && widget.svg == FlowySvgs.add_s, + ), + ); + expect(button, findsOneWidget); + await tapButton(button); + } + + void assertNewGroupTextField(bool isVisible) { + final textField = find.descendant( + of: find.byType(BoardTrailing), + matching: find.byType(TextField), + ); + if (isVisible) { + return expect(textField, findsOneWidget); + } + expect(textField, findsNothing); + } + + Future enterNewGroupName(String name, {required bool submit}) async { + final textField = find.descendant( + of: find.byType(BoardTrailing), + matching: find.byType(TextField), + ); + await enterText(textField, name); + await pumpAndSettle(); + if (submit) { + await testTextInput.receiveAction(TextInputAction.done); + await pumpAndSettle(); + } + } + + Future clearNewGroupTextField() async { + final textField = find.descendant( + of: find.byType(BoardTrailing), + matching: find.byType(TextField), + ); + await tapButton( + find.descendant( + of: textField, + matching: find.byWidgetPredicate( + (widget) => + widget is FlowySvg && widget.svg == FlowySvgs.close_filled_m, + ), + ), + ); + final textFieldWidget = widget(textField); + assert( + textFieldWidget.controller != null && + textFieldWidget.controller!.text.isEmpty, + ); + } + + Future tapTabBarLinkedViewByViewName(String name) async { + final viewButton = findTabBarLinkViewByViewName(name); + await tapButton(viewButton); + } + + Finder findTabBarLinkViewByViewLayout(ViewLayoutPB layout) { + return find.byWidgetPredicate( + (widget) => widget is TabBarItemButton && widget.view.layout == layout, + ); + } + + Finder findTabBarLinkViewByViewName(String name) { + return find.byWidgetPredicate( + (widget) => widget is TabBarItemButton && widget.view.name == name, + ); + } + + Future renameLinkedView(Finder linkedView, String name) async { + await tap(linkedView, buttons: kSecondaryButton); + await pumpAndSettle(); + + await tapButton( + find.byWidgetPredicate( + (widget) => + widget is ActionCellWidget && + widget.action == TabBarViewAction.rename, + ), + ); + + await enterText( + find.descendant( + of: find.byType(FlowyFormTextInput), + matching: find.byType(TextFormField), + ), + name, + ); + + final field = find.byWidgetPredicate( + (widget) => + widget is PrimaryTextButton && + widget.label == LocaleKeys.button_ok.tr(), + ); + await tapButton(field); + } + + Future deleteDatebaseView(Finder linkedView) async { + await tap(linkedView, buttons: kSecondaryButton); + await pumpAndSettle(); + + await tapButton( + find.byWidgetPredicate( + (widget) => + widget is ActionCellWidget && + widget.action == TabBarViewAction.delete, + ), + ); + + final okButton = find.byWidgetPredicate( + (widget) => + widget is PrimaryTextButton && + widget.label == LocaleKeys.button_ok.tr(), + ); + await tapButton(okButton); + } + + void assertCurrentDatabaseTagIs(DatabaseLayoutPB layout) => switch (layout) { + DatabaseLayoutPB.Board => + expect(find.byType(DesktopBoardPage), findsOneWidget), + DatabaseLayoutPB.Calendar => + expect(find.byType(CalendarPage), findsOneWidget), + DatabaseLayoutPB.Grid => expect(find.byType(GridPage), findsOneWidget), + _ => throw Exception('Unknown database layout type: $layout'), + }; + + Future selectDatabaseLayoutType(DatabaseLayoutPB layout) async { + final findLayoutCell = find.byType(DatabaseViewLayoutCell); + final findText = find.byWidgetPredicate( + (widget) => widget is FlowyText && widget.text == layout.layoutName, + ); + + final button = find.descendant(of: findLayoutCell, matching: findText); + await tapButton(button); + } + + Future assertCurrentDatabaseLayoutType(DatabaseLayoutPB layout) async { + expect(finderForDatabaseLayoutType(layout), findsOneWidget); + } + + Future tapDatabaseRawDataButton() async { + await tapButtonWithName(LocaleKeys.importPanel_database.tr()); + } + + // Use in edit mode of FieldEditor + Future changeNumberFieldFormat() async { + final changeFormatButton = find.descendant( + of: find.byType(FieldTypeOptionEditor), + matching: find.text("Number"), + ); + await tapButton(changeFormatButton); + + await tapButton( + find.byWidgetPredicate( + (w) => w is NumberFormatCell && w.format == NumberFormatPB.USD, + ), + ); + } + + // Use in edit mode of FieldEditor + Future tapAddSelectOptionButton() async { + await tapButtonWithName(LocaleKeys.grid_field_addSelectOption.tr()); + } + + Future tapViewTogglePropertyVisibilityButtonByName( + String fieldName, + ) async { + final field = find.byWidgetPredicate( + (w) => w is DatabasePropertyCell && w.fieldInfo.name == fieldName, + ); + final toggleVisibilityButton = + find.descendant(of: field, matching: find.byType(FlowyIconButton)); + await tapButton(toggleVisibilityButton); + } +} + +Finder finderForDatabaseLayoutType(DatabaseLayoutPB layout) => switch (layout) { + DatabaseLayoutPB.Board => find.byType(DesktopBoardPage), + DatabaseLayoutPB.Calendar => find.byType(CalendarPage), + DatabaseLayoutPB.Grid => find.byType(GridPage), + _ => throw Exception('Unknown database layout type: $layout'), + }; + +Finder finderForFieldType(FieldType fieldType) { + switch (fieldType) { + case FieldType.Checkbox: + return find.byType(EditableCheckboxCell, skipOffstage: false); + case FieldType.DateTime: + return find.byType(EditableDateCell, skipOffstage: false); + case FieldType.LastEditedTime: + return find.byWidgetPredicate( + (widget) => + widget is EditableTimestampCell && + widget.fieldType == FieldType.LastEditedTime, + skipOffstage: false, + ); + case FieldType.CreatedTime: + return find.byWidgetPredicate( + (widget) => + widget is EditableTimestampCell && + widget.fieldType == FieldType.CreatedTime, + skipOffstage: false, + ); + case FieldType.SingleSelect: + return find.byWidgetPredicate( + (widget) => + widget is EditableSelectOptionCell && + widget.fieldType == FieldType.SingleSelect, + skipOffstage: false, + ); + case FieldType.MultiSelect: + return find.byWidgetPredicate( + (widget) => + widget is EditableSelectOptionCell && + widget.fieldType == FieldType.MultiSelect, + skipOffstage: false, + ); + case FieldType.Checklist: + return find.byType(EditableChecklistCell, skipOffstage: false); + case FieldType.Number: + return find.byType(EditableNumberCell, skipOffstage: false); + case FieldType.RichText: + return find.byType(EditableTextCell, skipOffstage: false); + case FieldType.URL: + return find.byType(EditableURLCell, skipOffstage: false); + case FieldType.Media: + return find.byType(EditableMediaCell, skipOffstage: false); + default: + throw Exception('Unknown field type: $fieldType'); + } +} diff --git a/frontend/appflowy_flutter/integration_test/shared/dir.dart b/frontend/appflowy_flutter/integration_test/shared/dir.dart new file mode 100644 index 0000000000000..56c6f302f03eb --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/dir.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:path/path.dart' as p; + +Future deleteDirectoriesWithSameBaseNameAsPrefix( + String path, +) async { + final dir = Directory(path); + final prefix = p.basename(dir.path); + final parentDir = dir.parent; + + // Check if the directory exists + if (!await parentDir.exists()) { + // ignore: avoid_print + print('Directory does not exist'); + return; + } + + // List all entities in the directory + await for (final entity in parentDir.list()) { + // Check if the entity is a directory and starts with the specified prefix + if (entity is Directory && p.basename(entity.path).startsWith(prefix)) { + try { + await entity.delete(recursive: true); + } catch (e) { + // ignore: avoid_print + print('Failed to delete directory: ${entity.path}, Error: $e'); + } + } + } +} + +Future unzipFile(File zipFile, Directory targetDirectory) async { + // Read the Zip file from disk. + final bytes = zipFile.readAsBytesSync(); + + // Decode the Zip file + final archive = ZipDecoder().decodeBytes(bytes); + + // Extract the contents of the Zip archive to disk. + for (final file in archive) { + final filename = file.name; + if (file.isFile) { + final data = file.content as List; + File(p.join(targetDirectory.path, filename)) + ..createSync(recursive: true) + ..writeAsBytesSync(data); + } else { + Directory(p.join(targetDirectory.path, filename)) + .createSync(recursive: true); + } + } +} diff --git a/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart b/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart new file mode 100644 index 0000000000000..491ac9432cf45 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart @@ -0,0 +1,437 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_title.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart'; +import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; +import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'util.dart'; + +extension EditorWidgetTester on WidgetTester { + EditorOperations get editor => EditorOperations(this); +} + +class EditorOperations { + const EditorOperations(this.tester); + + final WidgetTester tester; + + EditorState getCurrentEditorState() => + tester.widget(find.byType(AppFlowyEditor)).editorState; + + Node getNodeAtPath(Path path) { + final editorState = getCurrentEditorState(); + return editorState.getNodeAtPath(path)!; + } + + /// Tap the line of editor at [index] + Future tapLineOfEditorAt(int index) async { + final textBlocks = find.byType(AppFlowyRichText); + index = index.clamp(0, textBlocks.evaluate().length - 1); + final center = tester.getCenter(textBlocks.at(index)); + final right = tester.getTopRight(textBlocks.at(index)); + final centerRight = Offset(right.dx, center.dy); + await tester.tapAt(centerRight); + await tester.pumpAndSettle(); + } + + /// Hover on cover plugin button above the document + Future hoverOnCoverToolbar() async { + final coverToolbar = find.byType(DocumentHeaderToolbar); + await tester.startGesture( + tester.getBottomLeft(coverToolbar).translate(5, -5), + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + } + + /// Taps on the 'Add Icon' button in the cover toolbar + Future tapAddIconButton() async { + await tester.tapButtonWithName( + LocaleKeys.document_plugins_cover_addIcon.tr(), + ); + expect(find.byType(FlowyEmojiPicker), findsOneWidget); + } + + Future paste() async { + if (UniversalPlatform.isMacOS) { + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isMetaPressed: true, + ); + } else { + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: true, + ); + } + } + + Future tapGettingStartedIcon() async { + await tester.tapButton( + find.descendant( + of: find.byType(DocumentCoverWidget), + matching: find.findTextInFlowyText('⭐️'), + ), + ); + } + + /// Taps on the 'Skin tone' button + /// + /// Must call [tapAddIconButton] first. + Future changeEmojiSkinTone(EmojiSkinTone skinTone) async { + await tester.tapButton( + find.byTooltip(LocaleKeys.emoji_selectSkinTone.tr()), + ); + final skinToneButton = find.byKey(emojiSkinToneKey(skinTone.icon)); + await tester.tapButton(skinToneButton); + } + + /// Taps the 'Remove Icon' button in the cover toolbar and the icon popover + Future tapRemoveIconButton({bool isInPicker = false}) async { + final Finder button = !isInPicker + ? find.text(LocaleKeys.document_plugins_cover_removeIcon.tr()) + : find.descendant( + of: find.byType(FlowyIconEmojiPicker), + matching: find.text(LocaleKeys.button_remove.tr()), + ); + await tester.tapButton(button); + } + + /// Requires that the document must already have an icon. This opens the icon + /// picker + Future tapOnIconWidget() async { + final iconWidget = find.byType(EmojiIconWidget); + await tester.tapButton(iconWidget); + } + + Future tapOnAddCover() async { + await tester.tapButtonWithName( + LocaleKeys.document_plugins_cover_addCover.tr(), + ); + } + + Future tapOnChangeCover() async { + await tester.tapButtonWithName( + LocaleKeys.document_plugins_cover_changeCover.tr(), + ); + } + + Future switchSolidColorBackground() async { + final findPurpleButton = find.byWidgetPredicate( + (widget) => widget is ColorItem && widget.option.name == 'Purple', + ); + await tester.tapButton(findPurpleButton); + } + + Future addNetworkImageCover(String imageUrl) async { + final embedLinkButton = find.findTextInFlowyText( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + ); + await tester.tapButton(embedLinkButton); + + final imageUrlTextField = find.descendant( + of: find.byType(EmbedImageUrlWidget), + matching: find.byType(TextField), + ); + await tester.enterText(imageUrlTextField, imageUrl); + await tester.pumpAndSettle(); + await tester.tapButton( + find.descendant( + of: find.byType(EmbedImageUrlWidget), + matching: find.findTextInFlowyText( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + ), + ), + ); + } + + Future tapOnRemoveCover() async => + tester.tapButton(find.byType(DeleteCoverButton)); + + /// A cover must be present in the document to function properly since this + /// catches all cover types collectively + Future hoverOnCover() async { + final cover = find.byType(DocumentCover); + await tester.startGesture( + tester.getCenter(cover), + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + } + + Future dismissCoverPicker() async { + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + } + + /// trigger the slash command (selection menu) + Future showSlashMenu() async { + await tester.ime.insertCharacter('/'); + } + + /// trigger the mention (@) command + Future showAtMenu() async { + await tester.ime.insertCharacter('@'); + } + + /// trigger the plus action menu (+) command + Future showPlusMenu() async { + await tester.ime.insertCharacter('+'); + } + + /// Tap the slash menu item with [name] + /// + /// Must call [showSlashMenu] first. + Future tapSlashMenuItemWithName( + String name, { + double offset = 200, + }) async { + final slashMenu = find + .ancestor( + of: find.byType(SelectionMenuItemWidget), + matching: find.byWidgetPredicate( + (widget) => widget is Scrollable, + ), + ) + .first; + final slashMenuItem = find.text(name, findRichText: true); + await tester.scrollUntilVisible( + slashMenuItem, + offset, + scrollable: slashMenu, + duration: const Duration(milliseconds: 250), + ); + assert(slashMenuItem.hasFound); + await tester.tapButton(slashMenuItem); + } + + /// Tap the at menu item with [name] + /// + /// Must call [showAtMenu] first. + Future tapAtMenuItemWithName(String name) async { + final atMenuItem = find.descendant( + of: find.byType(InlineActionsHandler), + matching: find.text(name, findRichText: true), + ); + await tester.tapButton(atMenuItem); + } + + /// Update the editor's selection + Future updateSelection(Selection? selection) async { + final editorState = getCurrentEditorState(); + unawaited( + editorState.updateSelectionWithReason( + selection, + reason: SelectionUpdateReason.uiEvent, + ), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + } + + /// hover and click on the + button beside the block component. + Future hoverAndClickOptionAddButton( + Path path, + bool withModifiedKey, // alt on windows or linux, option on macos + ) async { + final optionAddButton = find.byWidgetPredicate( + (widget) => + widget is BlockComponentActionWrapper && + widget.node.path.equals(path), + ); + await tester.hoverOnWidget( + optionAddButton, + onHover: () async { + if (withModifiedKey) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + } + await tester.tapButton( + find.byWidgetPredicate( + (widget) => + widget is BlockAddButton && + widget.blockComponentContext.node.path.equals(path), + ), + ); + if (withModifiedKey) { + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + } + }, + ); + } + + /// hover and click on the option menu button beside the block component. + Future hoverAndClickOptionMenuButton(Path path) async { + final optionMenuButton = find.byWidgetPredicate( + (widget) => + widget is BlockComponentActionWrapper && + widget.node.path.equals(path), + ); + await tester.hoverOnWidget( + optionMenuButton, + onHover: () async { + await tester.tapButton( + find.byWidgetPredicate( + (widget) => + widget is BlockOptionButton && + widget.blockComponentContext.node.path.equals(path), + ), + ); + await tester.pumpUntilFound(find.byType(PopoverActionList)); + }, + ); + } + + /// open the turn into menu + Future openTurnIntoMenu(Path path) async { + await hoverAndClickOptionMenuButton(path); + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys.document_plugins_optionAction_turnInto.tr(), + ), + ); + await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu)); + } + + /// copy link to block + Future copyLinkToBlock(Path path) async { + await hoverAndClickOptionMenuButton(path); + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr(), + ), + ); + } + + Future openDepthMenu(Path path) async { + await hoverAndClickOptionMenuButton(path); + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys.document_plugins_optionAction_depth.tr(), + ), + ); + await tester.pumpUntilFound(find.byType(DepthOptionMenu)); + } + + /// Drag block + /// + /// [offset] is the offset to move the block. + /// + /// [path] is the path of the block to move. + Future dragBlock( + Path path, + Offset offset, + ) async { + final dragToMoveAction = find.byWidgetPredicate( + (widget) => + widget is DraggableOptionButton && + widget.blockComponentContext.node.path.equals(path), + ); + + await tester.hoverOnWidget( + dragToMoveAction, + onHover: () async { + final dragToMoveTooltip = find.findFlowyTooltip( + LocaleKeys.blockActions_dragTooltip.tr(), + ); + await tester.pumpUntilFound(dragToMoveTooltip); + final location = tester.getCenter(dragToMoveAction); + final gesture = await tester.startGesture( + location, + pointer: 7, + ); + await tester.pump(); + + // divide the steps to small move to avoid the drag area not found error + const steps = 5; + final stepOffset = Offset(offset.dx / steps, offset.dy / steps); + + for (var i = 0; i < steps; i++) { + await gesture.moveBy(stepOffset); + await tester.pump(Durations.short1); + } + + // check if the drag to move action is dragging + expect( + isDraggingAppFlowyEditorBlock.value, + isTrue, + ); + + await gesture.up(); + await tester.pump(); + }, + ); + await tester.pumpAndSettle(Durations.short1); + } + + Finder findDocumentTitle(String? title) { + final parent = UniversalPlatform.isDesktop + ? find.byType(CoverTitle) + : find.byType(DocumentImmersiveCover); + + return find.descendant( + of: parent, + matching: find.byWidgetPredicate( + (widget) { + if (widget is! TextField) { + return false; + } + + if (widget.controller?.text == title) { + return true; + } + + if (title == null) { + return true; + } + + if (title.isEmpty) { + return widget.controller?.text.isEmpty ?? false; + } + + return false; + }, + ), + ); + } + + /// open the more action menu on mobile + Future openMoreActionMenuOnMobile() async { + final moreActionButton = find.byType(MobileViewPageMoreButton); + await tester.tapButton(moreActionButton); + await tester.pumpAndSettle(); + } + + /// click the more action item on mobile + /// + /// rename, add collaborator, publish, delete, etc. + Future clickMoreActionItemOnMobile(String name) async { + final moreActionItem = find.descendant( + of: find.byType(MobileQuickActionButton), + matching: find.findTextInFlowyText(name), + ); + await tester.tapButton(moreActionItem); + await tester.pumpAndSettle(); + } +} diff --git a/frontend/appflowy_flutter/integration_test/shared/emoji.dart b/frontend/appflowy_flutter/integration_test/shared/emoji.dart new file mode 100644 index 0000000000000..e6c756edb1eec --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/emoji.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_color_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:flowy_svg/flowy_svg.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'base.dart'; + +extension EmojiTestExtension on WidgetTester { + Future tapEmoji(String emoji) async { + final emojiWidget = find.descendant( + of: find.byType(EmojiPicker), + matching: find.text(emoji), + ); + await tapButton(emojiWidget); + } + + Future tapIcon(EmojiIconData icon) async { + final iconsData = IconsData.fromJson(jsonDecode(icon.emoji)); + final pickTab = find.byType(PickerTab); + expect(pickTab, findsOneWidget); + await pumpAndSettle(); + final iconTab = find.descendant( + of: pickTab, + matching: find.text(PickerTabType.icon.tr), + ); + expect(iconTab, findsOneWidget); + expect(find.byType(FlowyIconPicker), findsNothing); + await tap(iconTab); + await pumpAndSettle(); + expect(find.byType(FlowyIconPicker), findsOneWidget); + final selectedSvg = find.descendant( + of: find.byType(FlowyIconPicker), + matching: find.byWidgetPredicate( + (w) => w is FlowySvg && w.svgString == iconsData.iconContent, + ), + ); + expect(find.byType(IconColorPicker), findsNothing); + await tapButton(selectedSvg); + final colorPicker = find.byType(IconColorPicker); + expect(colorPicker, findsOneWidget); + final selectedColor = find.descendant( + of: colorPicker, + matching: find.byWidgetPredicate((w) { + if (w is Container) { + final d = w.decoration; + if (d is ShapeDecoration) { + if (d.color == + Color(int.parse(iconsData.color ?? builtInSpaceColors.first))) { + return true; + } + } + } + return false; + }), + ); + await tapButton(selectedColor); + } +} diff --git a/frontend/appflowy_flutter/integration_test/shared/expectation.dart b/frontend/appflowy_flutter/integration_test/shared/expectation.dart new file mode 100644 index 0000000000000..cd2f1e313345f --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/expectation.dart @@ -0,0 +1,303 @@ +import 'dart:convert'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; +import 'package:appflowy/plugins/document/presentation/banner.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_svg/flowy_svg.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'util.dart'; + +// const String readme = 'Read me'; +const String gettingStarted = 'Getting started'; + +extension Expectation on WidgetTester { + /// Expect to see the home page and with a default read me page. + Future expectToSeeHomePageWithGetStartedPage() async { + if (UniversalPlatform.isDesktopOrWeb) { + final finder = find.byType(HomeStack); + await pumpUntilFound(finder); + expect(finder, findsOneWidget); + } else if (UniversalPlatform.isMobile) { + final finder = find.byType(MobileHomePage); + await pumpUntilFound(finder); + expect(finder, findsOneWidget); + } + + final docFinder = find.textContaining(gettingStarted); + await pumpUntilFound(docFinder); + } + + Future expectToSeeHomePage() async { + final finder = find.byType(HomeStack); + await pumpUntilFound(finder); + expect(finder, findsOneWidget); + } + + /// Expect to see the page name on the home page. + void expectToSeePageName( + String name, { + String? parentName, + ViewLayoutPB layout = ViewLayoutPB.Document, + ViewLayoutPB parentLayout = ViewLayoutPB.Document, + }) { + final pageName = findPageName( + name, + layout: layout, + parentName: parentName, + parentLayout: parentLayout, + ); + expect(pageName, findsOneWidget); + } + + /// Expect not to see the page name on the home page. + void expectNotToSeePageName( + String name, { + String? parentName, + ViewLayoutPB layout = ViewLayoutPB.Document, + ViewLayoutPB parentLayout = ViewLayoutPB.Document, + }) { + final pageName = findPageName( + name, + layout: layout, + parentName: parentName, + parentLayout: parentLayout, + ); + expect(pageName, findsNothing); + } + + /// Expect to see the document banner. + void expectToSeeDocumentBanner() { + expect(find.byType(DocumentBanner), findsOneWidget); + } + + /// Expect not to see the document banner. + void expectNotToSeeDocumentBanner() { + expect(find.byType(DocumentBanner), findsNothing); + } + + /// Expect to the markdown file export success dialog. + void expectToExportSuccess() { + final exportSuccess = find.byWidgetPredicate( + (widget) => + widget is FlowyText && + widget.text == LocaleKeys.settings_files_exportFileSuccess.tr(), + ); + expect(exportSuccess, findsOneWidget); + } + + /// Expect to see the document header toolbar empty + void expectToSeeEmptyDocumentHeaderToolbar() { + final addCover = find.textContaining( + LocaleKeys.document_plugins_cover_addCover.tr(), + ); + final addIcon = find.textContaining( + LocaleKeys.document_plugins_cover_addIcon.tr(), + ); + expect(addCover, findsNothing); + expect(addIcon, findsNothing); + } + + void expectToSeeDocumentIcon(String? emoji) { + if (emoji == null) { + final iconWidget = find.byType(EmojiIconWidget); + expect(iconWidget, findsNothing); + return; + } + final iconWidget = find.byWidgetPredicate( + (widget) => widget is EmojiIconWidget && widget.emoji.emoji == emoji, + ); + expect(iconWidget, findsOneWidget); + } + + void expectDocumentIconNotNull() { + final iconWidget = find.byWidgetPredicate( + (widget) => widget is EmojiIconWidget && widget.emoji.isNotEmpty, + ); + expect(iconWidget, findsOneWidget); + } + + void expectToSeeDocumentCover(CoverType type) { + final findCover = find.byWidgetPredicate( + (widget) => widget is DocumentCover && widget.coverType == type, + ); + expect(findCover, findsOneWidget); + } + + void expectToSeeNoDocumentCover() { + final findCover = find.byType(DocumentCover); + expect(findCover, findsNothing); + } + + void expectChangeCoverAndDeleteButton() { + final findChangeCover = find.text( + LocaleKeys.document_plugins_cover_changeCover.tr(), + ); + final findRemoveIcon = find.byType(DeleteCoverButton); + expect(findChangeCover, findsOneWidget); + expect(findRemoveIcon, findsOneWidget); + } + + /// Expect to see a text + void expectToSeeText(String text) { + Finder textWidget = find.textContaining(text, findRichText: true); + if (textWidget.evaluate().isEmpty) { + textWidget = find.byWidgetPredicate( + (widget) => widget is FlowyText && widget.text == text, + ); + } + expect(textWidget, findsOneWidget); + } + + /// Find if the page is favorite + Finder findFavoritePageName( + String name, { + ViewLayoutPB layout = ViewLayoutPB.Document, + String? parentName, + ViewLayoutPB parentLayout = ViewLayoutPB.Document, + }) => + find.byWidgetPredicate( + (widget) => + widget is SingleInnerViewItem && + widget.view.isFavorite && + widget.spaceType == FolderSpaceType.favorite && + widget.view.name == name && + widget.view.layout == layout, + skipOffstage: false, + ); + + Finder findAllFavoritePages() => find.byWidgetPredicate( + (widget) => + widget is SingleInnerViewItem && + widget.view.isFavorite && + widget.spaceType == FolderSpaceType.favorite, + ); + + Finder findPageName( + String name, { + ViewLayoutPB layout = ViewLayoutPB.Document, + String? parentName, + ViewLayoutPB parentLayout = ViewLayoutPB.Document, + }) { + if (UniversalPlatform.isDesktop) { + if (parentName == null) { + return find.byWidgetPredicate( + (widget) => + widget is SingleInnerViewItem && + widget.view.name == name && + widget.view.layout == layout, + skipOffstage: false, + ); + } + + return find.descendant( + of: find.byWidgetPredicate( + (widget) => + widget is InnerViewItem && + widget.view.name == parentName && + widget.view.layout == parentLayout, + skipOffstage: false, + ), + matching: findPageName(name, layout: layout), + ); + } + + return find.byWidgetPredicate( + (widget) => + widget is SingleMobileInnerViewItem && + widget.view.name == name && + widget.view.layout == layout, + skipOffstage: false, + ); + } + + void expectViewHasIcon(String name, ViewLayoutPB layout, EmojiIconData data) { + final pageName = findPageName( + name, + layout: layout, + ); + final type = data.type; + if (type == FlowyIconType.emoji) { + final icon = find.descendant( + of: pageName, + matching: find.text(data.emoji), + ); + expect(icon, findsOneWidget); + } else if (type == FlowyIconType.icon) { + final iconsData = IconsData.fromJson(jsonDecode(data.emoji)); + final icon = find.descendant( + of: pageName, + matching: find.byWidgetPredicate( + (w) => w is FlowySvg && w.svgString == iconsData.iconContent, + ), + ); + expect(icon, findsOneWidget); + } + } + + void expectViewTitleHasIcon( + String name, + ViewLayoutPB layout, + EmojiIconData data, + ) { + final type = data.type; + if (type == FlowyIconType.emoji) { + final icon = find.descendant( + of: find.byType(ViewTitleBar), + matching: find.text(data.emoji), + ); + expect(icon, findsOneWidget); + } else if (type == FlowyIconType.icon) { + final iconsData = IconsData.fromJson(jsonDecode(data.emoji)); + final icon = find.descendant( + of: find.byType(ViewTitleBar), + matching: find.byWidgetPredicate( + (w) => w is FlowySvg && w.svgString == iconsData.iconContent, + ), + ); + expect(icon, findsOneWidget); + } + } + + void expectSelectedReminder(ReminderOption option) { + final findSelectedText = find.descendant( + of: find.byType(ReminderSelector), + matching: find.text(option.label), + ); + + expect(findSelectedText, findsOneWidget); + } + + void expectNotificationItems(int amount) { + final findItems = find.byType(NotificationItem); + + expect(findItems, findsNWidgets(amount)); + } + + void expectToSeeRowDetailsPageDialog() { + expect( + find.descendant( + of: find.byType(RowDetailPage), + matching: find.byType(SimpleDialog), + ), + findsOneWidget, + ); + } +} diff --git a/frontend/appflowy_flutter/integration_test/shared/ime.dart b/frontend/appflowy_flutter/integration_test/shared/ime.dart new file mode 100644 index 0000000000000..e2b0a754b52c1 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/ime.dart @@ -0,0 +1,47 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension IME on WidgetTester { + IMESimulator get ime => IMESimulator(this); +} + +class IMESimulator { + IMESimulator(this.tester) { + client = findTextInputClient(); + } + + final WidgetTester tester; + late final TextInputClient client; + + Future insertText(String text) async { + for (final c in text.characters) { + await insertCharacter(c); + } + } + + Future insertCharacter(String character) async { + final value = client.currentTextEditingValue; + if (value == null) { + assert(false); + return; + } + final text = value.text + .replaceRange(value.selection.start, value.selection.end, character); + final textEditingValue = TextEditingValue( + text: text, + selection: TextSelection.collapsed( + offset: value.selection.baseOffset + 1, + ), + ); + client.updateEditingValue(textEditingValue); + await tester.pumpAndSettle(); + } + + TextInputClient findTextInputClient() { + final finder = find.byType(KeyboardServiceWidget); + final KeyboardServiceWidgetState state = tester.state(finder); + return state.textInputService as TextInputClient; + } +} diff --git a/frontend/appflowy_flutter/integration_test/shared/keyboard.dart b/frontend/appflowy_flutter/integration_test/shared/keyboard.dart new file mode 100644 index 0000000000000..567e7e548cf7f --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/keyboard.dart @@ -0,0 +1,22 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart' as flutter_test; + +class FlowyTestKeyboard { + static Future simulateKeyDownEvent( + List keys, { + required flutter_test.WidgetTester tester, + bool withKeyUp = false, + }) async { + for (final LogicalKeyboardKey key in keys) { + await flutter_test.simulateKeyDownEvent(key); + await tester.pumpAndSettle(); + } + + if (withKeyUp) { + for (final LogicalKeyboardKey key in keys) { + await flutter_test.simulateKeyUpEvent(key); + await tester.pumpAndSettle(); + } + } + } +} diff --git a/frontend/appflowy_flutter/integration_test/shared/mock/mock_file_picker.dart b/frontend/appflowy_flutter/integration_test/shared/mock/mock_file_picker.dart new file mode 100644 index 0000000000000..32a0c255d8781 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/mock/mock_file_picker.dart @@ -0,0 +1,67 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; + +class MockFilePicker implements FilePickerService { + MockFilePicker({ + this.mockPath = '', + this.mockPaths = const [], + }); + + final String mockPath; + final List mockPaths; + + @override + Future getDirectoryPath({String? title}) => Future.value(mockPath); + + @override + Future saveFile({ + String? dialogTitle, + String? fileName, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + bool lockParentWindow = false, + }) => + Future.value(mockPath); + + @override + Future pickFiles({ + String? dialogTitle, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + Function(FilePickerStatus p1)? onFileLoading, + bool allowCompression = true, + bool allowMultiple = false, + bool withData = false, + bool withReadStream = false, + bool lockParentWindow = false, + }) { + final platformFiles = + mockPaths.map((e) => PlatformFile(path: e, name: '', size: 0)).toList(); + return Future.value(FilePickerResult(platformFiles)); + } +} + +Future mockGetDirectoryPath(String path) async { + getIt.unregister(); + getIt.registerFactory( + () => MockFilePicker(mockPath: path), + ); +} + +Future mockSaveFilePath(String path) async { + getIt.unregister(); + getIt.registerFactory( + () => MockFilePicker(mockPath: path), + ); + return path; +} + +List mockPickFilePaths({required List paths}) { + getIt.unregister(); + getIt.registerFactory( + () => MockFilePicker(mockPaths: paths), + ); + return paths; +} diff --git a/frontend/appflowy_flutter/integration_test/shared/mock/mock_openai_repository.dart b/frontend/appflowy_flutter/integration_test/shared/mock/mock_openai_repository.dart new file mode 100644 index 0000000000000..7201bd89caa5d --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/mock/mock_openai_repository.dart @@ -0,0 +1,81 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/text_completion.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; + +class MyMockClient extends Mock implements http.Client { + @override + Future send(http.BaseRequest request) async { + final requestType = request.method; + final requestUri = request.url; + + if (requestType == 'POST' && + requestUri == OpenAIRequestType.textCompletion.uri) { + final responseHeaders = { + 'content-type': 'text/event-stream', + }; + final responseBody = Stream.fromIterable([ + utf8.encode( + '{ "choices": [{"text": "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula ", "index": 0, "logprobs": null, "finish_reason": null}]}', + ), + utf8.encode('\n'), + utf8.encode('[DONE]'), + ]); + + // Return a mocked response with the expected data + return http.StreamedResponse(responseBody, 200, headers: responseHeaders); + } + + // Return an error response for any other request + return http.StreamedResponse(const Stream.empty(), 404); + } +} + +class MockOpenAIRepository extends HttpOpenAIRepository { + MockOpenAIRepository() : super(apiKey: 'dummyKey', client: MyMockClient()); + + @override + Future getStreamedCompletions({ + required String prompt, + required Future Function() onStart, + required Future Function(TextCompletionResponse response) onProcess, + required Future Function() onEnd, + required void Function(AIError error) onError, + String? suffix, + int maxTokens = 2048, + double temperature = 0.3, + bool useAction = false, + }) async { + final request = http.Request('POST', OpenAIRequestType.textCompletion.uri); + final response = await client.send(request); + + String previousSyntax = ''; + if (response.statusCode == 200) { + await for (final chunk in response.stream + .transform(const Utf8Decoder()) + .transform(const LineSplitter())) { + await onStart(); + final data = chunk.trim().split('data: '); + if (data[0] != '[DONE]') { + final response = TextCompletionResponse.fromJson( + json.decode(data[0]), + ); + if (response.choices.isNotEmpty) { + final text = response.choices.first.text; + if (text == previousSyntax && text == '\n') { + continue; + } + await onProcess(response); + previousSyntax = response.choices.first.text; + } + } else { + await onEnd(); + } + } + } + } +} diff --git a/frontend/appflowy_flutter/integration_test/shared/mock/mock_url_launcher.dart b/frontend/appflowy_flutter/integration_test/shared/mock/mock_url_launcher.dart new file mode 100644 index 0000000000000..b0281ab4b4868 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/mock/mock_url_launcher.dart @@ -0,0 +1,103 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +class MockUrlLauncher extends Fake + with MockPlatformInterfaceMixin + implements UrlLauncherPlatform { + String? url; + PreferredLaunchMode? launchMode; + bool? useSafariVC; + bool? useWebView; + bool? enableJavaScript; + bool? enableDomStorage; + bool? universalLinksOnly; + Map? headers; + String? webOnlyWindowName; + + bool? response; + + bool closeWebViewCalled = false; + bool canLaunchCalled = false; + bool launchCalled = false; + + // ignore: use_setters_to_change_properties + void setCanLaunchExpectations(String url) => this.url = url; + + void setLaunchExpectations({ + required String url, + PreferredLaunchMode? launchMode, + bool? useSafariVC, + bool? useWebView, + required bool enableJavaScript, + required bool enableDomStorage, + required bool universalLinksOnly, + required Map headers, + required String? webOnlyWindowName, + }) { + this.url = url; + this.launchMode = launchMode; + this.useSafariVC = useSafariVC; + this.useWebView = useWebView; + this.enableJavaScript = enableJavaScript; + this.enableDomStorage = enableDomStorage; + this.universalLinksOnly = universalLinksOnly; + this.headers = headers; + this.webOnlyWindowName = webOnlyWindowName; + } + + void setResponse(bool response) => this.response = response; + + @override + LinkDelegate? get linkDelegate => null; + + @override + Future canLaunch(String url) async { + expect(url, this.url); + canLaunchCalled = true; + return response!; + } + + @override + Future launch( + String url, { + required bool useSafariVC, + required bool useWebView, + required bool enableJavaScript, + required bool enableDomStorage, + required bool universalLinksOnly, + required Map headers, + String? webOnlyWindowName, + }) async { + expect(url, this.url); + expect(useSafariVC, this.useSafariVC); + expect(useWebView, this.useWebView); + expect(enableJavaScript, this.enableJavaScript); + expect(enableDomStorage, this.enableDomStorage); + expect(universalLinksOnly, this.universalLinksOnly); + expect(headers, this.headers); + expect(webOnlyWindowName, this.webOnlyWindowName); + launchCalled = true; + return response!; + } + + @override + Future launchUrl(String url, LaunchOptions options) async { + expect(url, this.url); + expect(options.mode, launchMode); + expect(options.webViewConfiguration.enableJavaScript, enableJavaScript); + expect(options.webViewConfiguration.enableDomStorage, enableDomStorage); + expect(options.webViewConfiguration.headers, headers); + expect(options.webOnlyWindowName, webOnlyWindowName); + launchCalled = true; + return response!; + } + + @override + Future closeWebView() async => closeWebViewCalled = true; +} diff --git a/frontend/appflowy_flutter/integration_test/shared/settings.dart b/frontend/appflowy_flutter/integration_test/shared/settings.dart new file mode 100644 index 0000000000000..aade7bb4c9dee --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/settings.dart @@ -0,0 +1,137 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account_user_profile.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'base.dart'; +import 'common_operations.dart'; + +extension AppFlowySettings on WidgetTester { + /// Open settings page + Future openSettings() async { + final settingsDialog = find.byType(SettingsDialog); + // tap empty area to close the settings page + while (settingsDialog.evaluate().isNotEmpty) { + await tapAt(Offset.zero); + await pumpAndSettle(); + } + + final settingsButton = find.byType(UserSettingButton); + expect(settingsButton, findsOneWidget); + await tapButton(settingsButton); + + expect(settingsDialog, findsOneWidget); + return; + } + + /// Open the page that insides the settings page + Future openSettingsPage(SettingsPage page) async { + final button = find.byWidgetPredicate( + (widget) => widget is SettingsMenuElement && widget.page == page, + ); + + await scrollUntilVisible( + button, + 0, + scrollable: find.findSettingsMenuScrollable(), + ); + await pump(); + + expect(button, findsOneWidget); + await tapButton(button); + return; + } + + /// Restore the AppFlowy data storage location + Future restoreLocation() async { + final button = find.text(LocaleKeys.settings_common_reset.tr()); + expect(button, findsOneWidget); + await tapButton(button); + await pumpAndSettle(); + + final confirmButton = find.text(LocaleKeys.button_confirm.tr()); + expect(confirmButton, findsOneWidget); + await tapButton(confirmButton); + return; + } + + Future tapCustomLocationButton() async { + final button = find.byTooltip( + LocaleKeys.settings_files_changeLocationTooltips.tr(), + ); + expect(button, findsOneWidget); + await tapButton(button); + return; + } + + /// Enter user name + Future enterUserName(String name) async { + // Enable editing username + final editUsernameFinder = find.descendant( + of: find.byType(AccountUserProfile), + matching: find.byFlowySvg(FlowySvgs.edit_s), + ); + await tap(editUsernameFinder, warnIfMissed: false); + await pumpAndSettle(); + + final userNameFinder = find.descendant( + of: find.byType(AccountUserProfile), + matching: find.byType(FlowyTextField), + ); + await enterText(userNameFinder, name); + await pumpAndSettle(); + + await tap(find.text(LocaleKeys.button_save.tr())); + await pumpAndSettle(); + } + + // go to settings page and toggle enable RTL toolbar items + Future toggleEnableRTLToolbarItems() async { + await openSettings(); + await openSettingsPage(SettingsPage.workspace); + + final scrollable = find.findSettingsScrollable(); + await scrollUntilVisible( + find.byType(EnableRTLItemsSwitcher), + 0, + scrollable: scrollable, + ); + + final switcher = find.descendant( + of: find.byType(EnableRTLItemsSwitcher), + matching: find.byType(Toggle), + ); + + await tap(switcher); + + // tap anywhere to close the settings page + await tapAt(Offset.zero); + await pumpAndSettle(); + } + + Future updateNamespace(String namespace) async { + final dialog = find.byType(DomainSettingsDialog); + expect(dialog, findsOneWidget); + + // input the new namespace + await enterText( + find.descendant( + of: dialog, + matching: find.byType(TextField), + ), + namespace, + ); + await tapButton(find.text(LocaleKeys.button_save.tr())); + await pumpAndSettle(); + } +} diff --git a/frontend/appflowy_flutter/integration_test/shared/util.dart b/frontend/appflowy_flutter/integration_test/shared/util.dart new file mode 100644 index 0000000000000..5073425cadc1d --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/util.dart @@ -0,0 +1,9 @@ +export 'auth_operation.dart'; +export 'base.dart'; +export 'common_operations.dart'; +export 'data.dart'; +export 'document_test_operations.dart'; +export 'expectation.dart'; +export 'ime.dart'; +export 'mock/mock_url_launcher.dart'; +export 'settings.dart'; diff --git a/frontend/appflowy_flutter/integration_test/shared/workspace.dart b/frontend/appflowy_flutter/integration_test/shared/workspace.dart new file mode 100644 index 0000000000000..1b2f22b944e15 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/shared/workspace.dart @@ -0,0 +1,69 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'util.dart'; + +extension AppFlowyWorkspace on WidgetTester { + /// Open workspace menu + Future openWorkspaceMenu() async { + final workspaceWrapper = find.byType(SidebarSwitchWorkspaceButton); + expect(workspaceWrapper, findsOneWidget); + await tapButton(workspaceWrapper); + final workspaceMenu = find.byType(WorkspacesMenu); + expect(workspaceMenu, findsOneWidget); + } + + /// Open a workspace + Future openWorkspace(String name) async { + final workspace = find.descendant( + of: find.byType(WorkspaceMenuItem), + matching: find.findTextInFlowyText(name), + ); + expect(workspace, findsOneWidget); + await tapButton(workspace); + } + + Future changeWorkspaceName(String name) async { + final moreButton = find.descendant( + of: find.byType(WorkspaceMenuItem), + matching: find.byType(WorkspaceMoreActionList), + ); + expect(moreButton, findsOneWidget); + await hoverOnWidget( + moreButton, + onHover: () async { + await tapButton(moreButton); + // wait for the menu to open + final renameButton = find.findTextInFlowyText( + LocaleKeys.button_rename.tr(), + ); + await pumpUntilFound(renameButton); + expect(renameButton, findsOneWidget); + await tapButton(renameButton); + final input = find.byType(TextFormField); + expect(input, findsOneWidget); + await enterText(input, name); + await tapButton(find.text(LocaleKeys.button_ok.tr())); + }, + ); + } + + Future changeWorkspaceIcon(String icon) async { + final iconButton = find.descendant( + of: find.byType(WorkspaceMenuItem), + matching: find.byType(WorkspaceIcon), + ); + expect(iconButton, findsOneWidget); + await tapButton(iconButton); + final iconPicker = find.byType(FlowyIconEmojiPicker); + expect(iconPicker, findsOneWidget); + await tapButton(find.findTextInFlowyText(icon)); + } +} diff --git a/frontend/app_flowy/ios/.gitignore b/frontend/appflowy_flutter/ios/.gitignore similarity index 100% rename from frontend/app_flowy/ios/.gitignore rename to frontend/appflowy_flutter/ios/.gitignore diff --git a/frontend/appflowy_flutter/ios/Flutter/AppFrameworkInfo.plist b/frontend/appflowy_flutter/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000000..7c56964006274 --- /dev/null +++ b/frontend/appflowy_flutter/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/frontend/app_flowy/ios/Flutter/Debug.xcconfig b/frontend/appflowy_flutter/ios/Flutter/Debug.xcconfig similarity index 100% rename from frontend/app_flowy/ios/Flutter/Debug.xcconfig rename to frontend/appflowy_flutter/ios/Flutter/Debug.xcconfig diff --git a/frontend/app_flowy/ios/Flutter/Release.xcconfig b/frontend/appflowy_flutter/ios/Flutter/Release.xcconfig similarity index 100% rename from frontend/app_flowy/ios/Flutter/Release.xcconfig rename to frontend/appflowy_flutter/ios/Flutter/Release.xcconfig diff --git a/frontend/appflowy_flutter/ios/Podfile b/frontend/appflowy_flutter/ios/Podfile new file mode 100644 index 0000000000000..5e46cacdb4309 --- /dev/null +++ b/frontend/appflowy_flutter/ios/Podfile @@ -0,0 +1,67 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + + # dart: PermissionGroup.photos + 'PERMISSION_PHOTOS=1', + ] + + end + end + + installer.aggregate_targets.each do |target| + target.xcconfigs.each do |variant, xcconfig| + xcconfig_path = target.client_root + target.xcconfig_relative_path(variant) + IO.write(xcconfig_path, IO.read(xcconfig_path).gsub("DT_TOOLCHAIN_DIR", "TOOLCHAIN_DIR")) + end + end + + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.base_configuration_reference.is_a? Xcodeproj::Project::Object::PBXFileReference + xcconfig_path = config.base_configuration_reference.real_path + IO.write(xcconfig_path, IO.read(xcconfig_path).gsub("DT_TOOLCHAIN_DIR", "TOOLCHAIN_DIR")) + end + end + end +end diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock new file mode 100644 index 0000000000000..ac6f338698b5e --- /dev/null +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -0,0 +1,211 @@ +PODS: + - app_links (0.0.1): + - Flutter + - appflowy_backend (0.0.1): + - Flutter + - connectivity_plus (0.0.1): + - Flutter + - ReachabilitySwift + - device_info_plus (0.0.1): + - Flutter + - DKImagePickerController/Core (4.3.4): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.4) + - DKImagePickerController/PhotoGallery (4.3.4): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.4) + - DKPhotoGallery (0.0.17): + - DKPhotoGallery/Core (= 0.0.17) + - DKPhotoGallery/Model (= 0.0.17) + - DKPhotoGallery/Preview (= 0.0.17) + - DKPhotoGallery/Resource (= 0.0.17) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.17): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.17): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - flowy_infra_ui (0.0.1): + - Flutter + - Flutter (1.0.0) + - fluttertoast (0.0.2): + - Flutter + - Toast + - image_picker_ios (0.0.1): + - Flutter + - integration_test (0.0.1): + - Flutter + - irondash_engine_context (0.0.1): + - Flutter + - keyboard_height_plugin (0.0.1): + - Flutter + - open_filex (0.0.2): + - Flutter + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - permission_handler_apple (9.3.0): + - Flutter + - ReachabilitySwift (5.0.0) + - SDWebImage (5.14.2): + - SDWebImage/Core (= 5.14.2) + - SDWebImage/Core (5.14.2) + - Sentry/HybridSDK (8.35.1) + - sentry_flutter (8.8.0): + - Flutter + - FlutterMacOS + - Sentry/HybridSDK (= 8.35.1) + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite (0.0.3): + - Flutter + - FlutterMacOS + - super_native_extensions (0.0.1): + - Flutter + - SwiftyGif (5.4.3) + - Toast (4.0.0) + - url_launcher_ios (0.0.1): + - Flutter + - webview_flutter_wkwebview (0.0.1): + - Flutter + +DEPENDENCIES: + - app_links (from `.symlinks/plugins/app_links/ios`) + - appflowy_backend (from `.symlinks/plugins/appflowy_backend/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - flowy_infra_ui (from `.symlinks/plugins/flowy_infra_ui/ios`) + - Flutter (from `Flutter`) + - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) + - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) + - keyboard_height_plugin (from `.symlinks/plugins/keyboard_height_plugin/ios`) + - open_filex (from `.symlinks/plugins/open_filex/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite (from `.symlinks/plugins/sqflite/darwin`) + - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`) + +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - ReachabilitySwift + - SDWebImage + - Sentry + - SwiftyGif + - Toast + +EXTERNAL SOURCES: + app_links: + :path: ".symlinks/plugins/app_links/ios" + appflowy_backend: + :path: ".symlinks/plugins/appflowy_backend/ios" + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + flowy_infra_ui: + :path: ".symlinks/plugins/flowy_infra_ui/ios" + Flutter: + :path: Flutter + fluttertoast: + :path: ".symlinks/plugins/fluttertoast/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" + irondash_engine_context: + :path: ".symlinks/plugins/irondash_engine_context/ios" + keyboard_height_plugin: + :path: ".symlinks/plugins/keyboard_height_plugin/ios" + open_filex: + :path: ".symlinks/plugins/open_filex/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + sentry_flutter: + :path: ".symlinks/plugins/sentry_flutter/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite: + :path: ".symlinks/plugins/sqflite/darwin" + super_native_extensions: + :path: ".symlinks/plugins/super_native_extensions/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + webview_flutter_wkwebview: + :path: ".symlinks/plugins/webview_flutter_wkwebview/ios" + +SPEC CHECKSUMS: + app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795 + appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88 + connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d + device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d + DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac + DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 + file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 + flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 + integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 + irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9 + keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86 + open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4 + package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 + SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 + Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 + share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7 + SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 + Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1 + +PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca + +COCOAPODS: 1.15.2 diff --git a/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000000..804ad052be50a --- /dev/null +++ b/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,601 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + FB3C2A642AE0D57700490715 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 197F72694BED43249F1523E8 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 197F72694BED43249F1523E8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 35DA03217F6DD4F7AC9356F9 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4C2CB38DA64605A62D45B098 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 580A1ED8E012CA1552E5EFD3 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FBE00AD62AE8E46A006B563F /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FB3C2A642AE0D57700490715 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 78844014EF958DCBB6F9B4EA /* Frameworks */ = { + isa = PBXGroup; + children = ( + 197F72694BED43249F1523E8 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 9EC83BEE9154F1BD11D24F8F /* Pods */, + 78844014EF958DCBB6F9B4EA /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + FBE00AD62AE8E46A006B563F /* Runner.entitlements */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + 9EC83BEE9154F1BD11D24F8F /* Pods */ = { + isa = PBXGroup; + children = ( + 35DA03217F6DD4F7AC9356F9 /* Pods-Runner.debug.xcconfig */, + 580A1ED8E012CA1552E5EFD3 /* Pods-Runner.release.xcconfig */, + 4C2CB38DA64605A62D45B098 /* Pods-Runner.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + E790B8FE5609053209ED85CB /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 08FAA63113168DEC7FB74204 /* [CP] Embed Pods Frameworks */, + A548E58D5F4006A34D7DAA88 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 08FAA63113168DEC7FB74204 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + A548E58D5F4006A34D7DAA88 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + E790B8FE5609053209ED85CB /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEAD_CODE_STRIPPING = NO; + DEVELOPMENT_TEAM = VHB67HRSZG; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRIP_STYLE = "non-global"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEAD_CODE_STRIPPING = NO; + DEVELOPMENT_TEAM = VHB67HRSZG; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRIP_STYLE = "non-global"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEAD_CODE_STRIPPING = NO; + DEVELOPMENT_TEAM = VHB67HRSZG; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRIP_STYLE = "non-global"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/frontend/app_flowy/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to frontend/appflowy_flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/appflowy_flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from frontend/app_flowy/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to frontend/appflowy_flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/frontend/appflowy_flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000000..e67b2808af02f --- /dev/null +++ b/frontend/appflowy_flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/ios/Runner.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/ios/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/ios/Runner.xcworkspace/contents.xcworkspacedata rename to frontend/appflowy_flutter/ios/Runner.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/appflowy_flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from frontend/app_flowy/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to frontend/appflowy_flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/frontend/app_flowy/ios/Runner/AppDelegate.swift b/frontend/appflowy_flutter/ios/Runner/AppDelegate.swift similarity index 100% rename from frontend/app_flowy/ios/Runner/AppDelegate.swift rename to frontend/appflowy_flutter/ios/Runner/AppDelegate.swift diff --git a/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000000000..91ae2093cb42c Binary files /dev/null and b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 0000000000000..f9772d80c85a2 Binary files /dev/null and b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000000000..ef5098953cd99 Binary files /dev/null and b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000000000..7e64745a73935 Binary files /dev/null and b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 0000000000000..60bea23799d6b Binary files /dev/null and b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000000000..9160456c7de24 Binary files /dev/null and b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 0000000000000..a5fd34e2d9386 Binary files /dev/null and b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000000000..39bce9701e1b1 Binary files /dev/null and b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 0000000000000..62a6ef0fcb97d Binary files /dev/null and b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000000000..7b248658d19e3 Binary files /dev/null and b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 0000000000000..f0ccd901765be Binary files /dev/null and b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000000..73d3b7f6d1083 --- /dev/null +++ b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"}]} \ No newline at end of file diff --git a/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/Contents.json b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/Contents.json new file mode 100644 index 0000000000000..73c00596a7fca --- /dev/null +++ b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json rename to frontend/appflowy_flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png rename to frontend/appflowy_flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png rename to frontend/appflowy_flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png rename to frontend/appflowy_flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/frontend/appflowy_flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md rename to frontend/appflowy_flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md diff --git a/frontend/app_flowy/ios/Runner/Base.lproj/LaunchScreen.storyboard b/frontend/appflowy_flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from frontend/app_flowy/ios/Runner/Base.lproj/LaunchScreen.storyboard rename to frontend/appflowy_flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard diff --git a/frontend/app_flowy/ios/Runner/Base.lproj/Main.storyboard b/frontend/appflowy_flutter/ios/Runner/Base.lproj/Main.storyboard similarity index 100% rename from frontend/app_flowy/ios/Runner/Base.lproj/Main.storyboard rename to frontend/appflowy_flutter/ios/Runner/Base.lproj/Main.storyboard diff --git a/frontend/appflowy_flutter/ios/Runner/Info.plist b/frontend/appflowy_flutter/ios/Runner/Info.plist new file mode 100644 index 0000000000000..d1afb2a9dbaa8 --- /dev/null +++ b/frontend/appflowy_flutter/ios/Runner/Info.plist @@ -0,0 +1,75 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + + CFBundleName + AppFlowy + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLName + + CFBundleURLSchemes + + appflowy-flutter + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FLTEnableImpeller + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSPhotoLibraryUsageDescription + AppFlowy needs access to your photos to let you add images to your documents + UIApplicationSupportsIndirectInputEvents + + NSCameraUsageDescription + AppFlowy needs access to your camera to let you add images to your documents from camera + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportsDocumentBrowser + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/frontend/app_flowy/ios/Runner/Runner-Bridging-Header.h b/frontend/appflowy_flutter/ios/Runner/Runner-Bridging-Header.h similarity index 100% rename from frontend/app_flowy/ios/Runner/Runner-Bridging-Header.h rename to frontend/appflowy_flutter/ios/Runner/Runner-Bridging-Header.h diff --git a/frontend/appflowy_flutter/ios/Runner/Runner.entitlements b/frontend/appflowy_flutter/ios/Runner/Runner.entitlements new file mode 100644 index 0000000000000..e3bc137465373 --- /dev/null +++ b/frontend/appflowy_flutter/ios/Runner/Runner.entitlements @@ -0,0 +1,19 @@ + + + + + aps-environment + development + com.apple.developer.applesignin + + Default + + com.apple.developer.associated-domains + + applinks:appflowy.com + applinks:appflowy.io + applinks:test.appflowy.com + applinks:test.appflowy.io + + + diff --git a/frontend/appflowy_flutter/lib/core/config/kv.dart b/frontend/appflowy_flutter/lib/core/config/kv.dart new file mode 100644 index 0000000000000..b7c845b5470f0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/config/kv.dart @@ -0,0 +1,65 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +abstract class KeyValueStorage { + Future set(String key, String value); + Future get(String key); + Future getWithFormat( + String key, + T Function(String value) formatter, + ); + Future remove(String key); + Future clear(); +} + +class DartKeyValue implements KeyValueStorage { + SharedPreferences? _sharedPreferences; + SharedPreferences get sharedPreferences => _sharedPreferences!; + + @override + Future get(String key) async { + await _initSharedPreferencesIfNeeded(); + + final value = sharedPreferences.getString(key); + if (value != null) { + return value; + } + return null; + } + + @override + Future getWithFormat( + String key, + T Function(String value) formatter, + ) async { + final value = await get(key); + if (value == null) { + return null; + } + return formatter(value); + } + + @override + Future remove(String key) async { + await _initSharedPreferencesIfNeeded(); + + await sharedPreferences.remove(key); + } + + @override + Future set(String key, String value) async { + await _initSharedPreferencesIfNeeded(); + + await sharedPreferences.setString(key, value); + } + + @override + Future clear() async { + await _initSharedPreferencesIfNeeded(); + + await sharedPreferences.clear(); + } + + Future _initSharedPreferencesIfNeeded() async { + _sharedPreferences ??= await SharedPreferences.getInstance(); + } +} diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart new file mode 100644 index 0000000000000..a80d08b76ee51 --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart @@ -0,0 +1,117 @@ +class KVKeys { + const KVKeys._(); + + static const String prefix = 'io.appflowy.appflowy_flutter'; + + /// The key for the path location of the local data for the whole app. + static const String pathLocation = '$prefix.path_location'; + + /// The key for saving the window size + /// + /// The value is a json string with the following format: + /// {'height': 600.0, 'width': 800.0} + static const String windowSize = 'windowSize'; + + /// The key for saving the window position + /// + /// The value is a json string with the following format: + /// {'dx': 10.0, 'dy': 10.0} + static const String windowPosition = 'windowPosition'; + + /// The key for saving the window status + /// + /// The value is a json string with the following format: + /// { 'windowMaximized': true } + /// + static const String windowMaximized = 'windowMaximized'; + + static const String kDocumentAppearanceFontSize = + 'kDocumentAppearanceFontSize'; + static const String kDocumentAppearanceFontFamily = + 'kDocumentAppearanceFontFamily'; + static const String kDocumentAppearanceDefaultTextDirection = + 'kDocumentAppearanceDefaultTextDirection'; + static const String kDocumentAppearanceCursorColor = + 'kDocumentAppearanceCursorColor'; + static const String kDocumentAppearanceSelectionColor = + 'kDocumentAppearanceSelectionColor'; + static const String kDocumentAppearanceWidth = 'kDocumentAppearanceWidth'; + + /// The key for saving the expanded views + /// + /// The value is a json string with the following format: + /// {'viewId': true, 'viewId2': false} + static const String expandedViews = 'expandedViews'; + + /// The key for saving the expanded folder + /// + /// The value is a json string with the following format: + /// {'SidebarFolderCategoryType.value': true} + static const String expandedFolders = 'expandedFolders'; + + /// @deprecated in version 0.7.6 + /// The key for saving if showing the rename dialog when creating a new file + /// + /// The value is a boolean string. + static const String showRenameDialogWhenCreatingNewFile = + 'showRenameDialogWhenCreatingNewFile'; + + static const String kCloudType = 'kCloudType'; + static const String kAppflowyCloudBaseURL = 'kAppFlowyCloudBaseURL'; + static const String kAppFlowyEnableSyncTrace = 'kAppFlowyEnableSyncTrace'; + + /// The key for saving the text scale factor. + /// + /// The value is a double string. + /// The value range is from 0.8 to 1.0. If it's greater than 1.0, it will cause + /// the text to be too large and not aligned with the icon + static const String textScaleFactor = 'textScaleFactor'; + + /// The key for saving the feature flags + /// + /// The value is a json string with the following format: + /// {'feature_flag_1': true, 'feature_flag_2': false} + static const String featureFlag = 'featureFlag'; + + /// The key for saving show notification icon option + /// + /// The value is a boolean string + static const String showNotificationIcon = 'showNotificationIcon'; + + /// The key for saving the last opened workspace id + /// + /// The workspace id is a string. + @Deprecated('deprecated in version 0.5.5') + static const String lastOpenedWorkspaceId = 'lastOpenedWorkspaceId'; + + /// The key for saving the scale factor + /// + /// The value is a double string. + static const String scaleFactor = 'scaleFactor'; + + /// The key for saving the last opened tab (favorite, recent, space etc.) + /// + /// The value is a int string. + static const String lastOpenedSpace = 'lastOpenedSpace'; + + /// The key for saving the space tab order + /// + /// The value is a json string with the following format: + /// [0, 1, 2] + static const String spaceOrder = 'spaceOrder'; + + /// The key for saving the last opened space id (space A, space B) + /// + /// The value is a string. + static const String lastOpenedSpaceId = 'lastOpenedSpaceId'; + + /// The key for saving the upgrade space tag + /// + /// The value is a boolean string + static const String hasUpgradedSpace = 'hasUpgradedSpace060'; + + /// The key for saving the recent icons + /// + /// The value is a json string of [RecentIcons] + static const String recentIcons = 'kRecentIcons'; +} diff --git a/frontend/appflowy_flutter/lib/core/frameless_window.dart b/frontend/appflowy_flutter/lib/core/frameless_window.dart new file mode 100644 index 0000000000000..48f043483358e --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/frameless_window.dart @@ -0,0 +1,78 @@ +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class CocoaWindowChannel { + CocoaWindowChannel._(); + + final MethodChannel _channel = const MethodChannel("flutter/cocoaWindow"); + + static final CocoaWindowChannel instance = CocoaWindowChannel._(); + + Future setWindowPosition(Offset offset) async { + await _channel.invokeMethod("setWindowPosition", [offset.dx, offset.dy]); + } + + Future> getWindowPosition() async { + final raw = await _channel.invokeMethod("getWindowPosition"); + final arr = raw as List; + final List result = arr.map((s) => s as double).toList(); + return result; + } + + Future zoom() async { + await _channel.invokeMethod("zoom"); + } +} + +class MoveWindowDetector extends StatefulWidget { + const MoveWindowDetector({ + super.key, + this.child, + }); + + final Widget? child; + + @override + MoveWindowDetectorState createState() => MoveWindowDetectorState(); +} + +class MoveWindowDetectorState extends State { + double winX = 0; + double winY = 0; + + @override + Widget build(BuildContext context) { + // the frameless window is only supported on macOS + if (!UniversalPlatform.isMacOS) { + return widget.child ?? const SizedBox.shrink(); + } + + // For the macOS version 15 or higher, we can control the window position by using system APIs + if (ApplicationInfo.macOSMajorVersion != null && + ApplicationInfo.macOSMajorVersion! >= 15) { + return widget.child ?? const SizedBox.shrink(); + } + + return GestureDetector( + // https://stackoverflow.com/questions/52965799/flutter-gesturedetector-not-working-with-containers-in-stack + behavior: HitTestBehavior.translucent, + onDoubleTap: () async => CocoaWindowChannel.instance.zoom(), + onPanStart: (DragStartDetails details) { + winX = details.globalPosition.dx; + winY = details.globalPosition.dy; + }, + onPanUpdate: (DragUpdateDetails details) async { + final windowPos = await CocoaWindowChannel.instance.getWindowPosition(); + final double dx = windowPos[0]; + final double dy = windowPos[1]; + final deltaX = details.globalPosition.dx - winX; + final deltaY = details.globalPosition.dy - winY; + await CocoaWindowChannel.instance + .setWindowPosition(Offset(dx + deltaX, dy - deltaY)); + }, + child: widget.child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/core/helpers/helpers.dart b/frontend/appflowy_flutter/lib/core/helpers/helpers.dart new file mode 100644 index 0000000000000..e325b1a7bb615 --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/helpers/helpers.dart @@ -0,0 +1 @@ +export 'target_platform.dart'; diff --git a/frontend/appflowy_flutter/lib/core/helpers/target_platform.dart b/frontend/appflowy_flutter/lib/core/helpers/target_platform.dart new file mode 100644 index 0000000000000..ba50353a6e67b --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/helpers/target_platform.dart @@ -0,0 +1,12 @@ +import 'package:flutter/foundation.dart' show TargetPlatform, kIsWeb; + +extension TargetPlatformHelper on TargetPlatform { + /// Convenience function to check if the app is running on a desktop computer. + /// + /// Easily check if on desktop by checking `defaultTargetPlatform.isDesktop`. + bool get isDesktop => + !kIsWeb && + (this == TargetPlatform.linux || + this == TargetPlatform.macOS || + this == TargetPlatform.windows); +} diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart new file mode 100644 index 0000000000000..2b0bc7b345e54 --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart @@ -0,0 +1,161 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:string_validator/string_validator.dart'; +import 'package:url_launcher/url_launcher.dart' as launcher; + +typedef OnFailureCallback = void Function(Uri uri); + +/// Launch the uri +/// +/// If the uri is a local file path, it will be opened with the OpenFilex. +/// Otherwise, it will be launched with the url_launcher. +Future afLaunchUri( + Uri uri, { + BuildContext? context, + OnFailureCallback? onFailure, + launcher.LaunchMode mode = launcher.LaunchMode.platformDefault, + String? webOnlyWindowName, + bool addingHttpSchemeWhenFailed = false, +}) async { + final url = uri.toString(); + final decodedUrl = Uri.decodeComponent(url); + + // check if the uri is the local file path + if (localPathRegex.hasMatch(decodedUrl)) { + return _afLaunchLocalUri( + uri, + context: context, + onFailure: onFailure, + ); + } + + // try to launch the uri directly + bool result; + try { + result = await launcher.launchUrl( + uri, + mode: mode, + webOnlyWindowName: webOnlyWindowName, + ); + } on PlatformException catch (e) { + Log.error('Failed to open uri: $e'); + return false; + } + + // if the uri is not a valid url, try to launch it with http scheme + + if (addingHttpSchemeWhenFailed && + !result && + !isURL(url, {'require_protocol': true})) { + try { + final uriWithScheme = Uri.parse('http://$url'); + result = await launcher.launchUrl( + uriWithScheme, + mode: mode, + webOnlyWindowName: webOnlyWindowName, + ); + } on PlatformException catch (e) { + Log.error('Failed to open uri: $e'); + if (context != null && context.mounted) { + _errorHandler(uri, context: context, onFailure: onFailure, e: e); + } + } + } + + return result; +} + +/// Launch the url string +/// +/// See [afLaunchUri] for more details. +Future afLaunchUrlString( + String url, { + bool addingHttpSchemeWhenFailed = false, + BuildContext? context, + OnFailureCallback? onFailure, +}) async { + final Uri uri; + try { + uri = Uri.parse(url); + } on FormatException catch (e) { + Log.error('Failed to parse url: $e'); + return false; + } + + // try to launch the uri directly + return afLaunchUri( + uri, + addingHttpSchemeWhenFailed: addingHttpSchemeWhenFailed, + context: context, + onFailure: onFailure, + ); +} + +/// Launch the local uri +/// +/// See [afLaunchUri] for more details. +Future _afLaunchLocalUri( + Uri uri, { + BuildContext? context, + OnFailureCallback? onFailure, +}) async { + final decodedUrl = Uri.decodeComponent(uri.toString()); + // open the file with the OpenfileX + var result = await OpenFilex.open(decodedUrl); + if (result.type != ResultType.done) { + // For the file cant be opened, fallback to open the folder + final parentFolder = Directory(decodedUrl).parent.path; + result = await OpenFilex.open(parentFolder); + } + // show the toast if the file is not found + final message = switch (result.type) { + ResultType.done => LocaleKeys.openFileMessage_success.tr(), + ResultType.fileNotFound => LocaleKeys.openFileMessage_fileNotFound.tr(), + ResultType.noAppToOpen => LocaleKeys.openFileMessage_noAppToOpenFile.tr(), + ResultType.permissionDenied => + LocaleKeys.openFileMessage_permissionDenied.tr(), + ResultType.error => LocaleKeys.failedToOpenUrl.tr(), + }; + if (context != null && context.mounted) { + showToastNotification( + context, + message: message, + type: result.type == ResultType.done + ? ToastificationType.success + : ToastificationType.error, + ); + } + final openFileSuccess = result.type == ResultType.done; + if (!openFileSuccess && onFailure != null) { + onFailure(uri); + Log.error('Failed to open file: $result.message'); + } + return openFileSuccess; +} + +void _errorHandler( + Uri uri, { + BuildContext? context, + OnFailureCallback? onFailure, + PlatformException? e, +}) { + Log.error('Failed to open uri: $e'); + + if (onFailure != null) { + onFailure(uri); + } else { + showMessageToast( + LocaleKeys.failedToOpenUrl.tr(args: [e?.message ?? "PlatformException"]), + context: context, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/core/network_monitor.dart b/frontend/appflowy_flutter/lib/core/network_monitor.dart new file mode 100644 index 0000000000000..3d01204921bee --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/network_monitor.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/services.dart'; + +class NetworkListener { + NetworkListener() { + _connectivitySubscription = + _connectivity.onConnectivityChanged.listen(_updateConnectionStatus); + } + + final Connectivity _connectivity = Connectivity(); + late StreamSubscription _connectivitySubscription; + + Future start() async { + late ConnectivityResult result; + // Platform messages may fail, so we use a try/catch PlatformException. + try { + result = await _connectivity.checkConnectivity(); + } on PlatformException catch (e) { + Log.error("Couldn't check connectivity status. $e"); + return; + } + return _updateConnectionStatus(result); + } + + Future stop() async { + await _connectivitySubscription.cancel(); + } + + Future _updateConnectionStatus(ConnectivityResult result) async { + final networkType = () { + switch (result) { + case ConnectivityResult.wifi: + return NetworkTypePB.Wifi; + case ConnectivityResult.ethernet: + return NetworkTypePB.Ethernet; + case ConnectivityResult.mobile: + return NetworkTypePB.Cell; + case ConnectivityResult.bluetooth: + return NetworkTypePB.Bluetooth; + case ConnectivityResult.vpn: + return NetworkTypePB.VPN; + case ConnectivityResult.none: + case ConnectivityResult.other: + return NetworkTypePB.NetworkUnknown; + } + }(); + final state = NetworkStatePB.create()..ty = networkType; + return UserEventUpdateNetworkState(state).send().then((result) { + result.fold((l) {}, (e) => Log.error(e)); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/core/notification/document_notification.dart b/frontend/appflowy_flutter/lib/core/notification/document_notification.dart new file mode 100644 index 0000000000000..afe850f88f1ad --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/notification/document_notification.dart @@ -0,0 +1,18 @@ +import 'package:appflowy/core/notification/notification_helper.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; + +// This value should be the same as the DOCUMENT_OBSERVABLE_SOURCE value +const String _source = 'Document'; + +class DocumentNotificationParser + extends NotificationParser { + DocumentNotificationParser({ + super.id, + required super.callback, + }) : super( + tyParser: (ty, source) => + source == _source ? DocumentNotification.valueOf(ty) : null, + errorParser: (bytes) => FlowyError.fromBuffer(bytes), + ); +} diff --git a/frontend/appflowy_flutter/lib/core/notification/folder_notification.dart b/frontend/appflowy_flutter/lib/core/notification/folder_notification.dart new file mode 100644 index 0000000000000..a5b99484bc545 --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/notification/folder_notification.dart @@ -0,0 +1,51 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +import 'notification_helper.dart'; + +// This value should be the same as the FOLDER_OBSERVABLE_SOURCE value +const String _source = 'Workspace'; + +class FolderNotificationParser + extends NotificationParser { + FolderNotificationParser({ + super.id, + required super.callback, + }) : super( + tyParser: (ty, source) => + source == _source ? FolderNotification.valueOf(ty) : null, + errorParser: (bytes) => FlowyError.fromBuffer(bytes), + ); +} + +typedef FolderNotificationHandler = Function( + FolderNotification ty, + FlowyResult result, +); + +class FolderNotificationListener { + FolderNotificationListener({ + required String objectId, + required FolderNotificationHandler handler, + }) : _parser = FolderNotificationParser( + id: objectId, + callback: handler, + ) { + _subscription = + RustStreamReceiver.listen((observable) => _parser?.parse(observable)); + } + + FolderNotificationParser? _parser; + StreamSubscription? _subscription; + + Future stop() async { + _parser = null; + await _subscription?.cancel(); + } +} diff --git a/frontend/appflowy_flutter/lib/core/notification/grid_notification.dart b/frontend/appflowy_flutter/lib/core/notification/grid_notification.dart new file mode 100644 index 0000000000000..d5425a042dd59 --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/notification/grid_notification.dart @@ -0,0 +1,49 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +import 'notification_helper.dart'; + +// This value should be the same as the DATABASE_OBSERVABLE_SOURCE value +const String _source = 'Database'; + +class DatabaseNotificationParser + extends NotificationParser { + DatabaseNotificationParser({ + super.id, + required super.callback, + }) : super( + tyParser: (ty, source) => + source == _source ? DatabaseNotification.valueOf(ty) : null, + errorParser: (bytes) => FlowyError.fromBuffer(bytes), + ); +} + +typedef DatabaseNotificationHandler = Function( + DatabaseNotification ty, + FlowyResult result, +); + +class DatabaseNotificationListener { + DatabaseNotificationListener({ + required String objectId, + required DatabaseNotificationHandler handler, + }) : _parser = DatabaseNotificationParser(id: objectId, callback: handler) { + _subscription = + RustStreamReceiver.listen((observable) => _parser?.parse(observable)); + } + + DatabaseNotificationParser? _parser; + StreamSubscription? _subscription; + + Future stop() async { + _parser = null; + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/core/notification/notification_helper.dart b/frontend/appflowy_flutter/lib/core/notification/notification_helper.dart new file mode 100644 index 0000000000000..e6ed20fab0446 --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/notification/notification_helper.dart @@ -0,0 +1,40 @@ +import 'dart:typed_data'; + +import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class NotificationParser { + NotificationParser({ + this.id, + required this.callback, + required this.errorParser, + required this.tyParser, + }); + + String? id; + void Function(T, FlowyResult) callback; + E Function(Uint8List) errorParser; + T? Function(int, String) tyParser; + + void parse(SubscribeObject subject) { + if (id != null) { + if (subject.id != id) { + return; + } + } + + final ty = tyParser(subject.ty, subject.source); + if (ty == null) { + return; + } + + if (subject.hasError()) { + final bytes = Uint8List.fromList(subject.error); + final error = errorParser(bytes); + callback(ty, FlowyResult.failure(error)); + } else { + final bytes = Uint8List.fromList(subject.payload); + callback(ty, FlowyResult.success(bytes)); + } + } +} diff --git a/frontend/appflowy_flutter/lib/core/notification/search_notification.dart b/frontend/appflowy_flutter/lib/core/notification/search_notification.dart new file mode 100644 index 0000000000000..18f9b218f120a --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/notification/search_notification.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/notification.pbenum.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +import 'notification_helper.dart'; + +// This value must be identical to the value in the backend (SEARCH_OBSERVABLE_SOURCE) +const _source = 'Search'; + +class SearchNotificationParser + extends NotificationParser { + SearchNotificationParser({ + super.id, + required super.callback, + String? channel, + }) : super( + tyParser: (ty, source) => source == "$_source$channel" + ? SearchNotification.valueOf(ty) + : null, + errorParser: (bytes) => FlowyError.fromBuffer(bytes), + ); +} + +typedef SearchNotificationHandler = Function( + SearchNotification ty, + FlowyResult result, +); + +class SearchNotificationListener { + SearchNotificationListener({ + required String objectId, + required SearchNotificationHandler handler, + String? channel, + }) : _parser = SearchNotificationParser( + id: objectId, + callback: handler, + channel: channel, + ) { + _subscription = + RustStreamReceiver.listen((observable) => _parser?.parse(observable)); + } + + StreamSubscription? _subscription; + SearchNotificationParser? _parser; + + Future stop() async { + _parser = null; + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/core/notification/user_notification.dart b/frontend/appflowy_flutter/lib/core/notification/user_notification.dart new file mode 100644 index 0000000000000..c9582c3ddad22 --- /dev/null +++ b/frontend/appflowy_flutter/lib/core/notification/user_notification.dart @@ -0,0 +1,19 @@ +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; + +import 'notification_helper.dart'; + +// This value should be the same as the USER_OBSERVABLE_SOURCE value +const String _source = 'User'; + +class UserNotificationParser + extends NotificationParser { + UserNotificationParser({ + required String super.id, + required super.callback, + }) : super( + tyParser: (ty, source) => + source == _source ? UserNotification.valueOf(ty) : null, + errorParser: (bytes) => FlowyError.fromBuffer(bytes), + ); +} diff --git a/frontend/appflowy_flutter/lib/date/date_service.dart b/frontend/appflowy_flutter/lib/date/date_service.dart new file mode 100644 index 0000000000000..bf49bce7a57ab --- /dev/null +++ b/frontend/appflowy_flutter/lib/date/date_service.dart @@ -0,0 +1,25 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-date/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class DateService { + static Future> queryDate( + String search, + ) async { + final query = DateQueryPB.create()..query = search; + final result = await DateEventQueryDate(query).send(); + return result.fold( + (s) { + final date = DateTime.tryParse(s.date); + if (date != null) { + return FlowyResult.success(date); + } + return FlowyResult.failure( + FlowyError(msg: 'Could not parse Date (NLP) from String'), + ); + }, + (e) => FlowyResult.failure(e), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/env/backend_env.dart b/frontend/appflowy_flutter/lib/env/backend_env.dart new file mode 100644 index 0000000000000..a3f45f9f6097f --- /dev/null +++ b/frontend/appflowy_flutter/lib/env/backend_env.dart @@ -0,0 +1,69 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:json_annotation/json_annotation.dart'; +part 'backend_env.g.dart'; + +@JsonSerializable() +class AppFlowyConfiguration { + AppFlowyConfiguration({ + required this.root, + required this.app_version, + required this.custom_app_path, + required this.origin_app_path, + required this.device_id, + required this.platform, + required this.authenticator_type, + required this.appflowy_cloud_config, + required this.envs, + }); + + factory AppFlowyConfiguration.fromJson(Map json) => + _$AppFlowyConfigurationFromJson(json); + + final String root; + final String app_version; + final String custom_app_path; + final String origin_app_path; + final String device_id; + final String platform; + final int authenticator_type; + final AppFlowyCloudConfiguration appflowy_cloud_config; + final Map envs; + + Map toJson() => _$AppFlowyConfigurationToJson(this); +} + +@JsonSerializable() +class AppFlowyCloudConfiguration { + AppFlowyCloudConfiguration({ + required this.base_url, + required this.ws_base_url, + required this.gotrue_url, + required this.enable_sync_trace, + }); + + factory AppFlowyCloudConfiguration.fromJson(Map json) => + _$AppFlowyCloudConfigurationFromJson(json); + + final String base_url; + final String ws_base_url; + final String gotrue_url; + final bool enable_sync_trace; + + Map toJson() => _$AppFlowyCloudConfigurationToJson(this); + + static AppFlowyCloudConfiguration defaultConfig() { + return AppFlowyCloudConfiguration( + base_url: '', + ws_base_url: '', + gotrue_url: '', + enable_sync_trace: false, + ); + } + + bool get isValid { + return base_url.isNotEmpty && + ws_base_url.isNotEmpty && + gotrue_url.isNotEmpty; + } +} diff --git a/frontend/appflowy_flutter/lib/env/cloud_env.dart b/frontend/appflowy_flutter/lib/env/cloud_env.dart new file mode 100644 index 0000000000000..86b9636a9305b --- /dev/null +++ b/frontend/appflowy_flutter/lib/env/cloud_env.dart @@ -0,0 +1,313 @@ +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/env/backend_env.dart'; +import 'package:appflowy/env/env.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; + +/// Sets the cloud type for the application. +/// +/// This method updates the cloud type setting in the key-value storage +/// using the [KeyValueStorage] service. The cloud type is identified +/// by the [AuthenticatorType] enum. +/// +/// [ty] - The type of cloud to be set. It must be one of the values from +/// [AuthenticatorType] enum. The corresponding integer value of the enum is stored: +/// - `CloudType.local` is stored as "0". +/// - `CloudType.appflowyCloud` is stored as "2". +/// +/// The gap between [AuthenticatorType.local] and [AuthenticatorType.appflowyCloud] is +/// due to previously supporting Supabase, this has been deprecated since and removed. +/// To not cause conflicts with older clients, we keep the gap. +/// +Future _setAuthenticatorType(AuthenticatorType ty) async { + switch (ty) { + case AuthenticatorType.local: + await getIt().set(KVKeys.kCloudType, 0.toString()); + break; + case AuthenticatorType.appflowyCloud: + await getIt().set(KVKeys.kCloudType, 2.toString()); + break; + case AuthenticatorType.appflowyCloudSelfHost: + await getIt().set(KVKeys.kCloudType, 3.toString()); + break; + case AuthenticatorType.appflowyCloudDevelop: + await getIt().set(KVKeys.kCloudType, 4.toString()); + break; + } +} + +const String kAppflowyCloudUrl = "https://beta.appflowy.cloud"; + +/// Retrieves the currently set cloud type. +/// +/// This method fetches the cloud type setting from the key-value storage +/// using the [KeyValueStorage] service and returns the corresponding +/// [AuthenticatorType] enum value. +/// +/// Returns: +/// A Future that resolves to a [AuthenticatorType] enum value representing the +/// currently set cloud type. The default return value is `CloudType.local` +/// if no valid setting is found. +/// +Future getAuthenticatorType() async { + final value = await getIt().get(KVKeys.kCloudType); + if (value == null && !integrationMode().isUnitTest) { + // if the cloud type is not set, then set it to AppFlowy Cloud as default. + await useAppFlowyBetaCloudWithURL( + kAppflowyCloudUrl, + AuthenticatorType.appflowyCloud, + ); + return AuthenticatorType.appflowyCloud; + } + + switch (value ?? "0") { + case "0": + return AuthenticatorType.local; + case "2": + return AuthenticatorType.appflowyCloud; + case "3": + return AuthenticatorType.appflowyCloudSelfHost; + case "4": + return AuthenticatorType.appflowyCloudDevelop; + default: + await useAppFlowyBetaCloudWithURL( + kAppflowyCloudUrl, + AuthenticatorType.appflowyCloud, + ); + return AuthenticatorType.appflowyCloud; + } +} + +/// Determines whether authentication is enabled. +/// +/// This getter evaluates if authentication should be enabled based on the +/// current integration mode and cloud type settings. +/// +/// Returns: +/// A boolean value indicating whether authentication is enabled. It returns +/// `true` if the application is in release or develop mode, and the cloud type +/// is not set to `CloudType.local`. Additionally, it checks if either the +/// AppFlowy Cloud configuration is valid. +/// Returns `false` otherwise. +bool get isAuthEnabled { + final env = getIt(); + if (env.authenticatorType.isAppFlowyCloudEnabled) { + return env.appflowyCloudConfig.isValid; + } + + return false; +} + +/// Determines if AppFlowy Cloud is enabled. +bool get isAppFlowyCloudEnabled { + return currentCloudType().isAppFlowyCloudEnabled; +} + +enum AuthenticatorType { + local, + appflowyCloud, + appflowyCloudSelfHost, + // The 'appflowyCloudDevelop' type is used for develop purposes only. + appflowyCloudDevelop; + + bool get isLocal => this == AuthenticatorType.local; + + bool get isAppFlowyCloudEnabled => + this == AuthenticatorType.appflowyCloudSelfHost || + this == AuthenticatorType.appflowyCloudDevelop || + this == AuthenticatorType.appflowyCloud; + + int get value { + switch (this) { + case AuthenticatorType.local: + return 0; + case AuthenticatorType.appflowyCloud: + return 2; + case AuthenticatorType.appflowyCloudSelfHost: + return 3; + case AuthenticatorType.appflowyCloudDevelop: + return 4; + } + } + + static AuthenticatorType fromValue(int value) { + switch (value) { + case 0: + return AuthenticatorType.local; + case 2: + return AuthenticatorType.appflowyCloud; + case 3: + return AuthenticatorType.appflowyCloudSelfHost; + case 4: + return AuthenticatorType.appflowyCloudDevelop; + default: + return AuthenticatorType.local; + } + } +} + +AuthenticatorType currentCloudType() { + return getIt().authenticatorType; +} + +Future _setAppFlowyCloudUrl(String? url) async { + await getIt().set(KVKeys.kAppflowyCloudBaseURL, url ?? ''); +} + +Future useSelfHostedAppFlowyCloudWithURL(String url) async { + await _setAuthenticatorType(AuthenticatorType.appflowyCloudSelfHost); + await _setAppFlowyCloudUrl(url); +} + +Future useAppFlowyBetaCloudWithURL( + String url, + AuthenticatorType authenticatorType, +) async { + await _setAuthenticatorType(authenticatorType); + await _setAppFlowyCloudUrl(url); +} + +Future useLocalServer() async { + await _setAuthenticatorType(AuthenticatorType.local); +} + +/// Use getIt() to get the shared environment. +class AppFlowyCloudSharedEnv { + AppFlowyCloudSharedEnv({ + required AuthenticatorType authenticatorType, + required this.appflowyCloudConfig, + }) : _authenticatorType = authenticatorType; + + final AuthenticatorType _authenticatorType; + final AppFlowyCloudConfiguration appflowyCloudConfig; + + AuthenticatorType get authenticatorType => _authenticatorType; + + static Future fromEnv() async { + // If [Env.enableCustomCloud] is true, then use the custom cloud configuration. + if (Env.enableCustomCloud) { + // Use the custom cloud configuration. + var authenticatorType = await getAuthenticatorType(); + + final appflowyCloudConfig = authenticatorType.isAppFlowyCloudEnabled + ? await getAppFlowyCloudConfig(authenticatorType) + : AppFlowyCloudConfiguration.defaultConfig(); + + // In the backend, the value '2' represents the use of AppFlowy Cloud. However, in the frontend, + // we distinguish between [AuthenticatorType.appflowyCloudSelfHost] and [AuthenticatorType.appflowyCloud]. + // When the cloud type is [AuthenticatorType.appflowyCloudSelfHost] in the frontend, it should be + // converted to [AuthenticatorType.appflowyCloud] to align with the backend representation, + // where both types are indicated by the value '2'. + if (authenticatorType.isAppFlowyCloudEnabled) { + authenticatorType = AuthenticatorType.appflowyCloud; + } + return AppFlowyCloudSharedEnv( + authenticatorType: authenticatorType, + appflowyCloudConfig: appflowyCloudConfig, + ); + } else { + // Using the cloud settings from the .env file. + final appflowyCloudConfig = AppFlowyCloudConfiguration( + base_url: Env.afCloudUrl, + ws_base_url: await _getAppFlowyCloudWSUrl(Env.afCloudUrl), + gotrue_url: await _getAppFlowyCloudGotrueUrl(Env.afCloudUrl), + enable_sync_trace: false, + ); + + return AppFlowyCloudSharedEnv( + authenticatorType: AuthenticatorType.fromValue(Env.authenticatorType), + appflowyCloudConfig: appflowyCloudConfig, + ); + } + } + + @override + String toString() { + return 'authenticator: $_authenticatorType\n' + 'appflowy: ${appflowyCloudConfig.toJson()}\n'; + } +} + +Future configurationFromUri( + Uri baseUri, + String baseUrl, + AuthenticatorType authenticatorType, +) async { + // In development mode, the app is configured to access the AppFlowy cloud server directly through specific ports. + // This setup bypasses the need for Nginx, meaning that the AppFlowy cloud should be running without an Nginx server + // in the development environment. + if (authenticatorType == AuthenticatorType.appflowyCloudDevelop) { + return AppFlowyCloudConfiguration( + base_url: "$baseUrl:8000", + ws_base_url: "ws://${baseUri.host}:8000/ws/v1", + gotrue_url: "$baseUrl:9999", + enable_sync_trace: true, + ); + } else { + return AppFlowyCloudConfiguration( + base_url: baseUrl, + ws_base_url: await _getAppFlowyCloudWSUrl(baseUrl), + gotrue_url: await _getAppFlowyCloudGotrueUrl(baseUrl), + enable_sync_trace: await getSyncLogEnabled(), + ); + } +} + +Future getAppFlowyCloudConfig( + AuthenticatorType authenticatorType, +) async { + final baseURL = await getAppFlowyCloudUrl(); + + try { + final uri = Uri.parse(baseURL); + return await configurationFromUri(uri, baseURL, authenticatorType); + } catch (e) { + Log.error("Failed to parse AppFlowy Cloud URL: $e"); + return AppFlowyCloudConfiguration.defaultConfig(); + } +} + +Future getAppFlowyCloudUrl() async { + final result = + await getIt().get(KVKeys.kAppflowyCloudBaseURL); + return result ?? kAppflowyCloudUrl; +} + +Future getSyncLogEnabled() async { + final result = + await getIt().get(KVKeys.kAppFlowyEnableSyncTrace); + + if (result == null) { + return false; + } + + return result.toLowerCase() == "true"; +} + +Future setSyncLogEnabled(bool enable) async { + await getIt().set( + KVKeys.kAppFlowyEnableSyncTrace, + enable.toString().toLowerCase(), + ); +} + +Future _getAppFlowyCloudWSUrl(String baseURL) async { + try { + final uri = Uri.parse(baseURL); + + // Construct the WebSocket URL directly from the parsed URI. + final wsScheme = uri.isScheme('HTTPS') ? 'wss' : 'ws'; + final wsUrl = + Uri(scheme: wsScheme, host: uri.host, port: uri.port, path: '/ws/v1'); + + return wsUrl.toString(); + } catch (e) { + Log.error("Failed to get WebSocket URL: $e"); + return ""; + } +} + +Future _getAppFlowyCloudGotrueUrl(String baseURL) async { + return "$baseURL/gotrue"; +} diff --git a/frontend/appflowy_flutter/lib/env/cloud_env_test.dart b/frontend/appflowy_flutter/lib/env/cloud_env_test.dart new file mode 100644 index 0000000000000..87867f6d7e5bb --- /dev/null +++ b/frontend/appflowy_flutter/lib/env/cloud_env_test.dart @@ -0,0 +1,33 @@ +// lib/env/env.dart +// ignore_for_file: prefer_const_declarations + +import 'package:envied/envied.dart'; + +part 'cloud_env_test.g.dart'; + +/// Follow the guide on https://supabase.com/docs/guides/auth/social-login/auth-google to setup the auth provider. +/// +@Envied(path: '.env.cloud.test') +abstract class TestEnv { + /// AppFlowy Cloud Configuration + @EnviedField( + obfuscate: false, + varName: 'APPFLOWY_CLOUD_URL', + defaultValue: 'http://localhost', + ) + static final String afCloudUrl = _TestEnv.afCloudUrl; + + // Supabase Configuration: + @EnviedField( + obfuscate: false, + varName: 'SUPABASE_URL', + defaultValue: '', + ) + static final String supabaseUrl = _TestEnv.supabaseUrl; + @EnviedField( + obfuscate: false, + varName: 'SUPABASE_ANON_KEY', + defaultValue: '', + ) + static final String supabaseAnonKey = _TestEnv.supabaseAnonKey; +} diff --git a/frontend/appflowy_flutter/lib/env/env.dart b/frontend/appflowy_flutter/lib/env/env.dart new file mode 100644 index 0000000000000..cfd9837944d09 --- /dev/null +++ b/frontend/appflowy_flutter/lib/env/env.dart @@ -0,0 +1,46 @@ +// lib/env/env.dart +import 'package:appflowy/env/cloud_env.dart'; +import 'package:envied/envied.dart'; + +part 'env.g.dart'; + +@Envied(path: '.env') +abstract class Env { + // This flag is used to decide if users can dynamically configure cloud settings. It turns true when a .env file exists containing the APPFLOWY_CLOUD_URL variable. By default, this is set to false. + static bool get enableCustomCloud { + return Env.authenticatorType == + AuthenticatorType.appflowyCloudSelfHost.value || + Env.authenticatorType == AuthenticatorType.appflowyCloud.value || + Env.authenticatorType == AuthenticatorType.appflowyCloudDevelop.value && + _Env.afCloudUrl.isEmpty; + } + + @EnviedField( + obfuscate: false, + varName: 'AUTHENTICATOR_TYPE', + defaultValue: 2, + ) + static const int authenticatorType = _Env.authenticatorType; + + /// AppFlowy Cloud Configuration + @EnviedField( + obfuscate: false, + varName: 'APPFLOWY_CLOUD_URL', + defaultValue: '', + ) + static const String afCloudUrl = _Env.afCloudUrl; + + @EnviedField( + obfuscate: false, + varName: 'INTERNAL_BUILD', + defaultValue: '', + ) + static const String internalBuild = _Env.internalBuild; + + @EnviedField( + obfuscate: false, + varName: 'SENTRY_DSN', + defaultValue: '', + ) + static const String sentryDsn = _Env.sentryDsn; +} diff --git a/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart b/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart new file mode 100644 index 0000000000000..1cc846339b1c4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart @@ -0,0 +1,1048 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// TODO(Mathias): Make a PR in Flutter repository that enables customizing +// the dropdown menu without having to copy the entire file. +// This is a temporary solution! + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +const double _kMinimumWidth = 112.0; + +const double _kDefaultHorizontalPadding = 12.0; + +// Navigation shortcuts to move the selected menu items up or down. +final Map _kMenuTraversalShortcuts = + { + LogicalKeySet(LogicalKeyboardKey.arrowUp): const _ArrowUpIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const _ArrowDownIntent(), +}; + +/// A dropdown menu that can be opened from a [TextField]. The selected +/// menu item is displayed in that field. +/// +/// This widget is used to help people make a choice from a menu and put the +/// selected item into the text input field. People can also filter the list based +/// on the text input or search one item in the menu list. +/// +/// The menu is composed of a list of [DropdownMenuEntry]s. People can provide information, +/// such as: label, leading icon or trailing icon for each entry. The [TextField] +/// will be updated based on the selection from the menu entries. The text field +/// will stay empty if the selected entry is disabled. +/// +/// The dropdown menu can be traversed by pressing the up or down key. During the +/// process, the corresponding item will be highlighted and the text field will be updated. +/// Disabled items will be skipped during traversal. +/// +/// The menu can be scrollable if not all items in the list are displayed at once. +/// +/// {@tool dartpad} +/// This sample shows how to display outlined [AFDropdownMenu] and filled [AFDropdownMenu]. +/// +/// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [MenuAnchor], which is a widget used to mark the "anchor" for a set of submenus. +/// The [AFDropdownMenu] uses a [TextField] as the "anchor". +/// * [TextField], which is a text input widget that uses an [InputDecoration]. +/// * [DropdownMenuEntry], which is used to build the [MenuItemButton] in the [AFDropdownMenu] list. +class AFDropdownMenu extends StatefulWidget { + /// Creates a const [AFDropdownMenu]. + /// + /// The leading and trailing icons in the text field can be customized by using + /// [leadingIcon], [trailingIcon] and [selectedTrailingIcon] properties. They are + /// passed down to the [InputDecoration] properties, and will override values + /// in the [InputDecoration.prefixIcon] and [InputDecoration.suffixIcon]. + /// + /// Except leading and trailing icons, the text field can be configured by the + /// [InputDecorationTheme] property. The menu can be configured by the [menuStyle]. + const AFDropdownMenu({ + super.key, + this.enabled = true, + this.width, + this.menuHeight, + this.leadingIcon, + this.trailingIcon, + this.label, + this.hintText, + this.helperText, + this.errorText, + this.selectedTrailingIcon, + this.enableFilter = false, + this.enableSearch = true, + this.textStyle, + this.inputDecorationTheme, + this.menuStyle, + this.controller, + this.initialSelection, + this.onSelected, + this.requestFocusOnTap, + this.expandedInsets, + this.searchCallback, + required this.dropdownMenuEntries, + }); + + /// Determine if the [AFDropdownMenu] is enabled. + /// + /// Defaults to true. + final bool enabled; + + /// Determine the width of the [AFDropdownMenu]. + /// + /// If this is null, the width of the [AFDropdownMenu] will be the same as the width of the widest + /// menu item plus the width of the leading/trailing icon. + final double? width; + + /// Determine the height of the menu. + /// + /// If this is null, the menu will display as many items as possible on the screen. + final double? menuHeight; + + /// An optional Icon at the front of the text input field. + /// + /// Defaults to null. If this is not null, the menu items will have extra paddings to be aligned + /// with the text in the text field. + final Widget? leadingIcon; + + /// An optional icon at the end of the text field. + /// + /// Defaults to an [Icon] with [Icons.arrow_drop_down]. + final Widget? trailingIcon; + + /// Optional widget that describes the input field. + /// + /// When the input field is empty and unfocused, the label is displayed on + /// top of the input field (i.e., at the same location on the screen where + /// text may be entered in the input field). When the input field receives + /// focus (or if the field is non-empty), the label moves above, either + /// vertically adjacent to, or to the center of the input field. + /// + /// Defaults to null. + final Widget? label; + + /// Text that suggests what sort of input the field accepts. + /// + /// Defaults to null; + final String? hintText; + + /// Text that provides context about the [AFDropdownMenu]'s value, such + /// as how the value will be used. + /// + /// If non-null, the text is displayed below the input field, in + /// the same location as [errorText]. If a non-null [errorText] value is + /// specified then the helper text is not shown. + /// + /// Defaults to null; + /// + /// See also: + /// + /// * [InputDecoration.helperText], which is the text that provides context about the [InputDecorator.child]'s value. + final String? helperText; + + /// Text that appears below the input field and the border to show the error message. + /// + /// If non-null, the border's color animates to red and the [helperText] is not shown. + /// + /// Defaults to null; + /// + /// See also: + /// + /// * [InputDecoration.errorText], which is the text that appears below the [InputDecorator.child] and the border. + final String? errorText; + + /// An optional icon at the end of the text field to indicate that the text + /// field is pressed. + /// + /// Defaults to an [Icon] with [Icons.arrow_drop_up]. + final Widget? selectedTrailingIcon; + + /// Determine if the menu list can be filtered by the text input. + /// + /// Defaults to false. + final bool enableFilter; + + /// Determine if the first item that matches the text input can be highlighted. + /// + /// Defaults to true as the search function could be commonly used. + final bool enableSearch; + + /// The text style for the [TextField] of the [AFDropdownMenu]; + /// + /// Defaults to the overall theme's [TextTheme.bodyLarge] + /// if the dropdown menu theme's value is null. + final TextStyle? textStyle; + + /// Defines the default appearance of [InputDecoration] to show around the text field. + /// + /// By default, shows a outlined text field. + final InputDecorationTheme? inputDecorationTheme; + + /// The [MenuStyle] that defines the visual attributes of the menu. + /// + /// The default width of the menu is set to the width of the text field. + final MenuStyle? menuStyle; + + /// Controls the text being edited or selected in the menu. + /// + /// If null, this widget will create its own [TextEditingController]. + final TextEditingController? controller; + + /// The value used to for an initial selection. + /// + /// Defaults to null. + final T? initialSelection; + + /// The callback is called when a selection is made. + /// + /// Defaults to null. If null, only the text field is updated. + final ValueChanged? onSelected; + + /// Determine if the dropdown button requests focus and the on-screen virtual + /// keyboard is shown in response to a touch event. + /// + /// By default, on mobile platforms, tapping on the text field and opening + /// the menu will not cause a focus request and the virtual keyboard will not + /// appear. The default behavior for desktop platforms is for the dropdown to + /// take the focus. + /// + /// Defaults to null. Setting this field to true or false, rather than allowing + /// the implementation to choose based on the platform, can be useful for + /// applications that want to override the default behavior. + final bool? requestFocusOnTap; + + /// Descriptions of the menu items in the [AFDropdownMenu]. + /// + /// This is a required parameter. It is recommended that at least one [DropdownMenuEntry] + /// is provided. If this is an empty list, the menu will be empty and only + /// contain space for padding. + final List> dropdownMenuEntries; + + /// Defines the menu text field's width to be equal to its parent's width + /// plus the horizontal width of the specified insets. + /// + /// If this property is null, the width of the text field will be determined + /// by the width of menu items or [AFDropdownMenu.width]. If this property is not null, + /// the text field's width will match the parent's width plus the specified insets. + /// If the value of this property is [EdgeInsets.zero], the width of the text field will be the same + /// as its parent's width. + /// + /// The [expandedInsets]' top and bottom are ignored, only its left and right + /// properties are used. + /// + /// Defaults to null. + final EdgeInsets? expandedInsets; + + /// When [AFDropdownMenu.enableSearch] is true, this callback is used to compute + /// the index of the search result to be highlighted. + /// + /// {@tool snippet} + /// + /// In this example the `searchCallback` returns the index of the search result + /// that exactly matches the query. + /// + /// ```dart + /// DropdownMenu( + /// searchCallback: (List> entries, String query) { + /// if (query.isEmpty) { + /// return null; + /// } + /// final int index = entries.indexWhere((DropdownMenuEntry entry) => entry.label == query); + /// + /// return index != -1 ? index : null; + /// }, + /// dropdownMenuEntries: const >[], + /// ) + /// ``` + /// {@end-tool} + /// + /// Defaults to null. If this is null and [AFDropdownMenu.enableSearch] is true, + /// the default function will return the index of the first matching result + /// which contains the contents of the text input field. + final SearchCallback? searchCallback; + + @override + State> createState() => _AFDropdownMenuState(); +} + +class _AFDropdownMenuState extends State> { + final GlobalKey _anchorKey = GlobalKey(); + final GlobalKey _leadingKey = GlobalKey(); + late List buttonItemKeys; + final MenuController _controller = MenuController(); + late bool _enableFilter; + late List> filteredEntries; + List? _initialMenu; + int? currentHighlight; + double? leadingPadding; + bool _menuHasEnabledItem = false; + TextEditingController? _localTextEditingController; + TextEditingController get _textEditingController { + return widget.controller ?? + (_localTextEditingController ??= TextEditingController()); + } + + @override + void initState() { + super.initState(); + _enableFilter = widget.enableFilter; + filteredEntries = widget.dropdownMenuEntries; + buttonItemKeys = List.generate( + filteredEntries.length, + (int index) => GlobalKey(), + ); + _menuHasEnabledItem = + filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); + + final int index = filteredEntries.indexWhere( + (DropdownMenuEntry entry) => entry.value == widget.initialSelection, + ); + if (index != -1) { + _textEditingController.value = TextEditingValue( + text: filteredEntries[index].label, + selection: TextSelection.collapsed( + offset: filteredEntries[index].label.length, + ), + ); + } + refreshLeadingPadding(); + } + + @override + void dispose() { + _localTextEditingController?.dispose(); + _localTextEditingController = null; + super.dispose(); + } + + @override + void didUpdateWidget(AFDropdownMenu oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + if (widget.controller != null) { + _localTextEditingController?.dispose(); + _localTextEditingController = null; + } + } + if (oldWidget.enableSearch != widget.enableSearch) { + if (!widget.enableSearch) { + currentHighlight = null; + } + } + if (oldWidget.dropdownMenuEntries != widget.dropdownMenuEntries) { + currentHighlight = null; + filteredEntries = widget.dropdownMenuEntries; + buttonItemKeys = List.generate( + filteredEntries.length, + (int index) => GlobalKey(), + ); + _menuHasEnabledItem = + filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); + } + if (oldWidget.leadingIcon != widget.leadingIcon) { + refreshLeadingPadding(); + } + if (oldWidget.initialSelection != widget.initialSelection) { + final int index = filteredEntries.indexWhere( + (DropdownMenuEntry entry) => entry.value == widget.initialSelection, + ); + if (index != -1) { + _textEditingController.value = TextEditingValue( + text: filteredEntries[index].label, + selection: TextSelection.collapsed( + offset: filteredEntries[index].label.length, + ), + ); + } + } + } + + bool canRequestFocus() { + if (widget.requestFocusOnTap != null) { + return widget.requestFocusOnTap!; + } + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + return false; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + return true; + } + } + + void refreshLeadingPadding() { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + setState(() { + leadingPadding = getWidth(_leadingKey); + }); + }, + debugLabel: 'DropdownMenu.refreshLeadingPadding', + ); + } + + // Remove the code here, it will throw a FlutterError + // Unless we upgrade to Flutter 3.24 https://github.com/flutter/flutter/issues/146764 + void scrollToHighlight() { + // WidgetsBinding.instance.addPostFrameCallback( + // (_) { + // // try { + // final BuildContext? highlightContext = + // buttonItemKeys[currentHighlight!].currentContext; + // if (highlightContext != null) { + // Scrollable.ensureVisible(highlightContext); + // } + // } catch (_) { + // return; + // } + // }, + // debugLabel: 'DropdownMenu.scrollToHighlight', + // ); + } + + double? getWidth(GlobalKey key) { + final BuildContext? context = key.currentContext; + if (context != null) { + final RenderBox box = context.findRenderObject()! as RenderBox; + return box.hasSize ? box.size.width : null; + } + return null; + } + + List> filter( + List> entries, + TextEditingController textEditingController, + ) { + final String filterText = textEditingController.text.toLowerCase(); + return entries + .where( + (DropdownMenuEntry entry) => + entry.label.toLowerCase().contains(filterText), + ) + .toList(); + } + + int? search( + List> entries, + TextEditingController textEditingController, + ) { + final String searchText = textEditingController.value.text.toLowerCase(); + if (searchText.isEmpty) { + return null; + } + final int index = entries.indexWhere( + (DropdownMenuEntry entry) => + entry.label.toLowerCase().contains(searchText), + ); + + return index != -1 ? index : null; + } + + List _buildButtons( + List> filteredEntries, + TextDirection textDirection, { + int? focusedIndex, + bool enableScrollToHighlight = true, + }) { + final List result = []; + for (int i = 0; i < filteredEntries.length; i++) { + final DropdownMenuEntry entry = filteredEntries[i]; + + // By default, when the text field has a leading icon but a menu entry doesn't + // have one, the label of the entry should have extra padding to be aligned + // with the text in the text input field. When both the text field and the + // menu entry have leading icons, the menu entry should remove the extra + // paddings so its leading icon will be aligned with the leading icon of + // the text field. + final double padding = entry.leadingIcon == null + ? (leadingPadding ?? _kDefaultHorizontalPadding) + : _kDefaultHorizontalPadding; + final ButtonStyle defaultStyle; + switch (textDirection) { + case TextDirection.rtl: + defaultStyle = MenuItemButton.styleFrom( + padding: EdgeInsets.only( + left: _kDefaultHorizontalPadding, + right: padding, + ), + ); + case TextDirection.ltr: + defaultStyle = MenuItemButton.styleFrom( + padding: EdgeInsets.only( + left: padding, + right: _kDefaultHorizontalPadding, + ), + ); + } + + ButtonStyle effectiveStyle = entry.style ?? defaultStyle; + final Color focusedBackgroundColor = effectiveStyle.foregroundColor + ?.resolve({WidgetState.focused}) ?? + Theme.of(context).colorScheme.onSurface; + + Widget label = entry.labelWidget ?? Text(entry.label); + if (widget.width != null) { + final double horizontalPadding = padding + _kDefaultHorizontalPadding; + label = ConstrainedBox( + constraints: + BoxConstraints(maxWidth: widget.width! - horizontalPadding), + child: label, + ); + } + + // Simulate the focused state because the text field should always be focused + // during traversal. If the menu item has a custom foreground color, the "focused" + // color will also change to foregroundColor.withOpacity(0.12). + effectiveStyle = entry.enabled && i == focusedIndex + ? effectiveStyle.copyWith( + backgroundColor: WidgetStatePropertyAll( + focusedBackgroundColor.withOpacity(0.12), + ), + ) + : effectiveStyle; + + final Widget menuItemButton = Padding( + padding: const EdgeInsets.only(bottom: 6), + child: MenuItemButton( + key: enableScrollToHighlight ? buttonItemKeys[i] : null, + style: effectiveStyle, + leadingIcon: entry.leadingIcon, + trailingIcon: entry.trailingIcon, + onPressed: entry.enabled + ? () { + _textEditingController.value = TextEditingValue( + text: entry.label, + selection: + TextSelection.collapsed(offset: entry.label.length), + ); + currentHighlight = widget.enableSearch ? i : null; + widget.onSelected?.call(entry.value); + } + : null, + requestFocusOnHover: false, + child: label, + ), + ); + result.add(menuItemButton); + } + + return result; + } + + void handleUpKeyInvoke(_) { + setState(() { + if (!_menuHasEnabledItem || !_controller.isOpen) { + return; + } + _enableFilter = false; + currentHighlight ??= 0; + currentHighlight = (currentHighlight! - 1) % filteredEntries.length; + while (!filteredEntries[currentHighlight!].enabled) { + currentHighlight = (currentHighlight! - 1) % filteredEntries.length; + } + final String currentLabel = filteredEntries[currentHighlight!].label; + _textEditingController.value = TextEditingValue( + text: currentLabel, + selection: TextSelection.collapsed(offset: currentLabel.length), + ); + }); + } + + void handleDownKeyInvoke(_) { + setState(() { + if (!_menuHasEnabledItem || !_controller.isOpen) { + return; + } + _enableFilter = false; + currentHighlight ??= -1; + currentHighlight = (currentHighlight! + 1) % filteredEntries.length; + while (!filteredEntries[currentHighlight!].enabled) { + currentHighlight = (currentHighlight! + 1) % filteredEntries.length; + } + final String currentLabel = filteredEntries[currentHighlight!].label; + _textEditingController.value = TextEditingValue( + text: currentLabel, + selection: TextSelection.collapsed(offset: currentLabel.length), + ); + }); + } + + void handlePressed(MenuController controller) { + if (controller.isOpen) { + currentHighlight = null; + controller.close(); + } else { + // close to open + if (_textEditingController.text.isNotEmpty) { + _enableFilter = false; + } + controller.open(); + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final TextDirection textDirection = Directionality.of(context); + _initialMenu ??= _buildButtons( + widget.dropdownMenuEntries, + textDirection, + enableScrollToHighlight: false, + ); + final DropdownMenuThemeData theme = DropdownMenuTheme.of(context); + final DropdownMenuThemeData defaults = _DropdownMenuDefaultsM3(context); + + if (_enableFilter) { + filteredEntries = + filter(widget.dropdownMenuEntries, _textEditingController); + } + + if (widget.enableSearch) { + if (widget.searchCallback != null) { + currentHighlight = widget.searchCallback! + .call(filteredEntries, _textEditingController.text); + } else { + currentHighlight = search(filteredEntries, _textEditingController); + } + if (currentHighlight != null) { + scrollToHighlight(); + } + } + + final List menu = _buildButtons( + filteredEntries, + textDirection, + focusedIndex: currentHighlight, + ); + + final TextStyle? effectiveTextStyle = + widget.textStyle ?? theme.textStyle ?? defaults.textStyle; + + MenuStyle? effectiveMenuStyle = + widget.menuStyle ?? theme.menuStyle ?? defaults.menuStyle!; + + final double? anchorWidth = getWidth(_anchorKey); + if (widget.width != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith( + minimumSize: WidgetStatePropertyAll(Size(widget.width!, 0.0)), + ); + } else if (anchorWidth != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith( + minimumSize: WidgetStatePropertyAll(Size(anchorWidth, 0.0)), + ); + } + + if (widget.menuHeight != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith( + maximumSize: WidgetStatePropertyAll( + Size(double.infinity, widget.menuHeight!), + ), + ); + } + final InputDecorationTheme effectiveInputDecorationTheme = + widget.inputDecorationTheme ?? + theme.inputDecorationTheme ?? + defaults.inputDecorationTheme!; + + final MouseCursor effectiveMouseCursor = + canRequestFocus() ? SystemMouseCursors.text : SystemMouseCursors.click; + + Widget menuAnchor = MenuAnchor( + style: effectiveMenuStyle, + controller: _controller, + menuChildren: menu, + crossAxisUnconstrained: false, + builder: ( + BuildContext context, + MenuController controller, + Widget? child, + ) { + assert(_initialMenu != null); + final Widget trailingButton = Padding( + padding: const EdgeInsets.all(4.0), + child: IconButton( + splashRadius: 1, + isSelected: controller.isOpen, + icon: widget.trailingIcon ?? const Icon(Icons.arrow_drop_down), + selectedIcon: + widget.selectedTrailingIcon ?? const Icon(Icons.arrow_drop_up), + onPressed: () { + handlePressed(controller); + }, + ), + ); + + final Widget leadingButton = Padding( + padding: const EdgeInsets.all(8.0), + child: widget.leadingIcon ?? const SizedBox(), + ); + + final Widget textField = TextField( + key: _anchorKey, + mouseCursor: effectiveMouseCursor, + canRequestFocus: canRequestFocus(), + enableInteractiveSelection: canRequestFocus(), + textAlignVertical: TextAlignVertical.center, + style: effectiveTextStyle, + controller: _textEditingController, + onEditingComplete: () { + if (currentHighlight != null) { + final DropdownMenuEntry entry = + filteredEntries[currentHighlight!]; + if (entry.enabled) { + _textEditingController.value = TextEditingValue( + text: entry.label, + selection: + TextSelection.collapsed(offset: entry.label.length), + ); + widget.onSelected?.call(entry.value); + } + } else { + widget.onSelected?.call(null); + } + if (!widget.enableSearch) { + currentHighlight = null; + } + controller.close(); + }, + onTap: () { + handlePressed(controller); + }, + onChanged: (String text) { + controller.open(); + setState(() { + filteredEntries = widget.dropdownMenuEntries; + _enableFilter = widget.enableFilter; + }); + }, + decoration: InputDecoration( + enabled: widget.enabled, + label: widget.label, + hintText: widget.hintText, + helperText: widget.helperText, + errorText: widget.errorText, + prefixIcon: widget.leadingIcon != null + ? Container(key: _leadingKey, child: widget.leadingIcon) + : null, + suffixIcon: trailingButton, + ).applyDefaults(effectiveInputDecorationTheme), + ); + + if (widget.expandedInsets != null) { + // If [expandedInsets] is not null, the width of the text field should depend + // on its parent width. So we don't need to use `_DropdownMenuBody` to + // calculate the children's width. + return textField; + } + + return _DropdownMenuBody( + width: widget.width, + children: [ + textField, + for (final Widget item in _initialMenu!) item, + trailingButton, + leadingButton, + ], + ); + }, + ); + + if (widget.expandedInsets != null) { + menuAnchor = Container( + alignment: AlignmentDirectional.topStart, + padding: widget.expandedInsets?.copyWith(top: 0.0, bottom: 0.0), + child: menuAnchor, + ); + } + + return Shortcuts( + shortcuts: _kMenuTraversalShortcuts, + child: Actions( + actions: >{ + _ArrowUpIntent: CallbackAction<_ArrowUpIntent>( + onInvoke: handleUpKeyInvoke, + ), + _ArrowDownIntent: CallbackAction<_ArrowDownIntent>( + onInvoke: handleDownKeyInvoke, + ), + }, + child: menuAnchor, + ), + ); + } +} + +class _ArrowUpIntent extends Intent { + const _ArrowUpIntent(); +} + +class _ArrowDownIntent extends Intent { + const _ArrowDownIntent(); +} + +class _DropdownMenuBody extends MultiChildRenderObjectWidget { + const _DropdownMenuBody({ + super.children, + this.width, + }); + + final double? width; + + @override + _RenderDropdownMenuBody createRenderObject(BuildContext context) { + return _RenderDropdownMenuBody( + width: width, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderDropdownMenuBody renderObject, + ) { + renderObject.width = width; + } +} + +class _DropdownMenuBodyParentData extends ContainerBoxParentData {} + +class _RenderDropdownMenuBody extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + _RenderDropdownMenuBody({ + double? width, + }) : _width = width; + + double? get width => _width; + double? _width; + set width(double? value) { + if (_width == value) { + return; + } + _width = value; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _DropdownMenuBodyParentData) { + child.parentData = _DropdownMenuBodyParentData(); + } + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + double maxWidth = 0.0; + double? maxHeight; + RenderBox? child = firstChild; + + final BoxConstraints innerConstraints = BoxConstraints( + maxWidth: width ?? computeMaxIntrinsicWidth(constraints.maxWidth), + maxHeight: computeMaxIntrinsicHeight(constraints.maxHeight), + ); + while (child != null) { + if (child == firstChild) { + child.layout(innerConstraints, parentUsesSize: true); + maxHeight ??= child.size.height; + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + continue; + } + child.layout(innerConstraints, parentUsesSize: true); + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + childParentData.offset = Offset.zero; + maxWidth = math.max(maxWidth, child.size.width); + maxHeight ??= child.size.height; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + + assert(maxHeight != null); + maxWidth = math.max(_kMinimumWidth, maxWidth); + size = constraints.constrain(Size(width ?? maxWidth, maxHeight!)); + } + + @override + void paint(PaintingContext context, Offset offset) { + final RenderBox? child = firstChild; + if (child != null) { + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + context.paintChild(child, offset + childParentData.offset); + } + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final BoxConstraints constraints = this.constraints; + double maxWidth = 0.0; + double? maxHeight; + RenderBox? child = firstChild; + final BoxConstraints innerConstraints = BoxConstraints( + maxWidth: width ?? computeMaxIntrinsicWidth(constraints.maxWidth), + maxHeight: computeMaxIntrinsicHeight(constraints.maxHeight), + ); + + while (child != null) { + if (child == firstChild) { + final Size childSize = child.getDryLayout(innerConstraints); + maxHeight ??= childSize.height; + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + continue; + } + final Size childSize = child.getDryLayout(innerConstraints); + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + childParentData.offset = Offset.zero; + maxWidth = math.max(maxWidth, childSize.width); + maxHeight ??= childSize.height; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + + assert(maxHeight != null); + maxWidth = math.max(_kMinimumWidth, maxWidth); + return constraints.constrain(Size(width ?? maxWidth, maxHeight!)); + } + + @override + double computeMinIntrinsicWidth(double height) { + RenderBox? child = firstChild; + double width = 0; + while (child != null) { + if (child == firstChild) { + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + continue; + } + final double maxIntrinsicWidth = child.getMinIntrinsicWidth(height); + if (child == lastChild) { + width += maxIntrinsicWidth; + } + if (child == childBefore(lastChild!)) { + width += maxIntrinsicWidth; + } + width = math.max(width, maxIntrinsicWidth); + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + } + + return math.max(width, _kMinimumWidth); + } + + @override + double computeMaxIntrinsicWidth(double height) { + RenderBox? child = firstChild; + double width = 0; + while (child != null) { + if (child == firstChild) { + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + continue; + } + final double maxIntrinsicWidth = child.getMaxIntrinsicWidth(height); + // Add the width of leading Icon. + if (child == lastChild) { + width += maxIntrinsicWidth; + } + // Add the width of trailing Icon. + if (child == childBefore(lastChild!)) { + width += maxIntrinsicWidth; + } + width = math.max(width, maxIntrinsicWidth); + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + } + + return math.max(width, _kMinimumWidth); + } + + @override + double computeMinIntrinsicHeight(double height) { + final RenderBox? child = firstChild; + double width = 0; + if (child != null) { + width = math.max(width, child.getMinIntrinsicHeight(height)); + } + return width; + } + + @override + double computeMaxIntrinsicHeight(double height) { + final RenderBox? child = firstChild; + double width = 0; + if (child != null) { + width = math.max(width, child.getMaxIntrinsicHeight(height)); + } + return width; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + final RenderBox? child = firstChild; + if (child != null) { + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + final bool isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child.hitTest(result, position: transformed); + }, + ); + if (isHit) { + return true; + } + } + return false; + } +} + +// Hand coded defaults. These will be updated once we have tokens/spec. +class _DropdownMenuDefaultsM3 extends DropdownMenuThemeData { + _DropdownMenuDefaultsM3(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + + @override + TextStyle? get textStyle => _theme.textTheme.bodyLarge; + + @override + MenuStyle get menuStyle { + return const MenuStyle( + minimumSize: WidgetStatePropertyAll(Size(_kMinimumWidth, 0.0)), + maximumSize: WidgetStatePropertyAll(Size.infinite), + visualDensity: VisualDensity.standard, + ); + } + + @override + InputDecorationTheme get inputDecorationTheme { + return const InputDecorationTheme(border: OutlineInputBorder()); + } +} diff --git a/frontend/appflowy_flutter/lib/main.dart b/frontend/appflowy_flutter/lib/main.dart new file mode 100644 index 0000000000000..9117acfd1ba4a --- /dev/null +++ b/frontend/appflowy_flutter/lib/main.dart @@ -0,0 +1,11 @@ +import 'package:scaled_app/scaled_app.dart'; + +import 'startup/startup.dart'; + +Future main() async { + ScaledWidgetsFlutterBinding.ensureInitialized( + scaleFactor: (_) => 1.0, + ); + + await runAppFlowy(); +} diff --git a/frontend/appflowy_flutter/lib/mobile/application/base/mobile_view_page_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/base/mobile_view_page_bloc.dart new file mode 100644 index 0000000000000..50426b5761488 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/application/base/mobile_view_page_bloc.dart @@ -0,0 +1,106 @@ +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'mobile_view_page_bloc.freezed.dart'; + +class MobileViewPageBloc + extends Bloc { + MobileViewPageBloc({ + required this.viewId, + }) : _viewListener = ViewListener(viewId: viewId), + super(MobileViewPageState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + _registerListeners(); + + final userProfilePB = + await UserBackendService.getCurrentUserProfile() + .fold((s) => s, (f) => null); + final result = await ViewBackendService.getView(viewId); + final isImmersiveMode = + _isImmersiveMode(result.fold((s) => s, (f) => null)); + emit( + state.copyWith( + isLoading: false, + result: result, + isImmersiveMode: isImmersiveMode, + userProfilePB: userProfilePB, + ), + ); + }, + updateImmersionMode: (isImmersiveMode) { + emit( + state.copyWith( + isImmersiveMode: isImmersiveMode, + ), + ); + }, + ); + }, + ); + } + + final String viewId; + final ViewListener _viewListener; + + @override + Future close() { + _viewListener.stop(); + return super.close(); + } + + void _registerListeners() { + _viewListener.start( + onViewUpdated: (view) { + final isImmersiveMode = _isImmersiveMode(view); + add(MobileViewPageEvent.updateImmersionMode(isImmersiveMode)); + }, + ); + } + + // only the document page supports immersive mode (version 0.5.6) + bool _isImmersiveMode(ViewPB? view) { + if (view == null) { + return false; + } + + final cover = view.cover; + if (cover == null || cover.type == PageStyleCoverImageType.none) { + return false; + } else if (view.layout == ViewLayoutPB.Document && !cover.isPresets) { + // only support immersive mode for document layout + return true; + } + + return false; + } +} + +@freezed +class MobileViewPageEvent with _$MobileViewPageEvent { + const factory MobileViewPageEvent.initial() = Initial; + const factory MobileViewPageEvent.updateImmersionMode(bool isImmersiveMode) = + UpdateImmersionMode; +} + +@freezed +class MobileViewPageState with _$MobileViewPageState { + const factory MobileViewPageState({ + @Default(true) bool isLoading, + @Default(null) FlowyResult? result, + @Default(false) bool isImmersiveMode, + @Default(null) UserProfilePB? userProfilePB, + }) = _MobileViewPageState; + + factory MobileViewPageState.initial() => const MobileViewPageState(); +} diff --git a/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart new file mode 100644 index 0000000000000..8fc32ab5dbbdd --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/application/mobile_router.dart @@ -0,0 +1,102 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/mobile/presentation/chat/mobile_chat_screen.dart'; +import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart'; +import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dart'; +import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +extension MobileRouter on BuildContext { + Future pushView( + ViewPB view, { + Map? arguments, + bool addInRecent = true, + bool showMoreButton = true, + String? fixedTitle, + String? blockId, + }) async { + // set the current view before pushing the new view + getIt().latestOpenView = view; + unawaited(getIt().updateRecentViews([view.id], true)); + final queryParameters = view.queryParameters(arguments); + + if (view.layout == ViewLayoutPB.Document) { + queryParameters[MobileDocumentScreen.viewShowMoreButton] = + showMoreButton.toString(); + if (fixedTitle != null) { + queryParameters[MobileDocumentScreen.viewFixedTitle] = fixedTitle; + } + if (blockId != null) { + queryParameters[MobileDocumentScreen.viewBlockId] = blockId; + } + } + + final uri = Uri( + path: view.routeName, + queryParameters: queryParameters, + ).toString(); + await push(uri); + } +} + +extension on ViewPB { + String get routeName { + switch (layout) { + case ViewLayoutPB.Document: + return MobileDocumentScreen.routeName; + case ViewLayoutPB.Grid: + return MobileGridScreen.routeName; + case ViewLayoutPB.Calendar: + return MobileCalendarScreen.routeName; + case ViewLayoutPB.Board: + return MobileBoardScreen.routeName; + case ViewLayoutPB.Chat: + return MobileChatScreen.routeName; + + default: + throw UnimplementedError('routeName for $this is not implemented'); + } + } + + Map queryParameters([Map? arguments]) { + switch (layout) { + case ViewLayoutPB.Document: + return { + MobileDocumentScreen.viewId: id, + MobileDocumentScreen.viewTitle: name, + }; + case ViewLayoutPB.Grid: + return { + MobileGridScreen.viewId: id, + MobileGridScreen.viewTitle: name, + MobileGridScreen.viewArgs: jsonEncode(arguments), + }; + case ViewLayoutPB.Calendar: + return { + MobileCalendarScreen.viewId: id, + MobileCalendarScreen.viewTitle: name, + }; + case ViewLayoutPB.Board: + return { + MobileBoardScreen.viewId: id, + MobileBoardScreen.viewTitle: name, + }; + case ViewLayoutPB.Chat: + return { + MobileChatScreen.viewId: id, + MobileChatScreen.viewTitle: name, + }; + default: + throw UnimplementedError( + 'queryParameters for $this is not implemented', + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/application/notification/notification_reminder_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/notification/notification_reminder_bloc.dart new file mode 100644 index 0000000000000..25fee58a6480f --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/application/notification/notification_reminder_bloc.dart @@ -0,0 +1,219 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/application/document_service.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:time/time.dart'; + +part 'notification_reminder_bloc.freezed.dart'; + +class NotificationReminderBloc + extends Bloc { + NotificationReminderBloc() : super(NotificationReminderState.initial()) { + on((event, emit) async { + await event.when( + initial: (reminder, dateFormat, timeFormat) async { + this.reminder = reminder; + this.dateFormat = dateFormat; + this.timeFormat = timeFormat; + + add(const NotificationReminderEvent.reset()); + }, + reset: () async { + final createdAt = await _getCreatedAt( + reminder, + dateFormat, + timeFormat, + ); + final view = await _getView(reminder); + + if (view == null) { + emit( + NotificationReminderState( + createdAt: createdAt, + pageTitle: '', + reminderContent: '', + status: NotificationReminderStatus.error, + ), + ); + } + + final layout = view!.layout; + + if (layout.isDocumentView) { + final node = await _getContent(reminder); + if (node != null) { + emit( + NotificationReminderState( + createdAt: createdAt, + pageTitle: view.nameOrDefault, + view: view, + reminderContent: node.delta?.toPlainText() ?? '', + nodes: [node], + status: NotificationReminderStatus.loaded, + blockId: reminder.meta[ReminderMetaKeys.blockId], + ), + ); + } + } else if (layout.isDatabaseView) { + emit( + NotificationReminderState( + createdAt: createdAt, + pageTitle: view.nameOrDefault, + view: view, + reminderContent: reminder.message, + status: NotificationReminderStatus.loaded, + ), + ); + } + }, + ); + }); + } + + late final ReminderPB reminder; + late final UserDateFormatPB dateFormat; + late final UserTimeFormatPB timeFormat; + + Future _getCreatedAt( + ReminderPB reminder, + UserDateFormatPB dateFormat, + UserTimeFormatPB timeFormat, + ) async { + final rCreatedAt = reminder.createdAt; + final createdAt = rCreatedAt != null + ? _formatTimestamp( + rCreatedAt, + timeFormat: timeFormat, + dateFormate: dateFormat, + ) + : ''; + return createdAt; + } + + Future _getView(ReminderPB reminder) async { + return ViewBackendService.getView(reminder.objectId) + .fold((s) => s, (_) => null); + } + + Future _getContent(ReminderPB reminder) async { + final blockId = reminder.meta[ReminderMetaKeys.blockId]; + + if (blockId == null) { + return null; + } + + final document = await DocumentService() + .openDocument( + documentId: reminder.objectId, + ) + .fold((s) => s.toDocument(), (_) => null); + + if (document == null) { + return null; + } + + final node = _searchById(document.root, blockId); + + if (node == null) { + return null; + } + + return node; + } + + Node? _searchById(Node current, String id) { + if (current.id == id) { + return current; + } + + if (current.children.isNotEmpty) { + for (final child in current.children) { + final node = _searchById(child, id); + + if (node != null) { + return node; + } + } + } + + return null; + } + + String _formatTimestamp( + int timestamp, { + required UserDateFormatPB dateFormate, + required UserTimeFormatPB timeFormat, + }) { + final now = DateTime.now(); + final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp); + final difference = now.difference(dateTime); + final String date; + + if (difference.inMinutes < 1) { + date = LocaleKeys.sideBar_justNow.tr(); + } else if (difference.inHours < 1 && dateTime.isToday) { + // Less than 1 hour + date = LocaleKeys.sideBar_minutesAgo + .tr(namedArgs: {'count': difference.inMinutes.toString()}); + } else if (difference.inHours >= 1 && dateTime.isToday) { + // in same day + date = timeFormat.formatTime(dateTime); + } else { + date = dateFormate.formatDate(dateTime, false); + } + + return date; + } +} + +@freezed +class NotificationReminderEvent with _$NotificationReminderEvent { + const factory NotificationReminderEvent.initial( + ReminderPB reminder, + UserDateFormatPB dateFormat, + UserTimeFormatPB timeFormat, + ) = _Initial; + + const factory NotificationReminderEvent.reset() = _Reset; +} + +enum NotificationReminderStatus { + initial, + loading, + loaded, + error, +} + +@freezed +class NotificationReminderState with _$NotificationReminderState { + const NotificationReminderState._(); + + const factory NotificationReminderState({ + required String createdAt, + required String pageTitle, + required String reminderContent, + @Default(NotificationReminderStatus.initial) + NotificationReminderStatus status, + @Default([]) List nodes, + String? blockId, + ViewPB? view, + }) = _NotificationReminderState; + + factory NotificationReminderState.initial() => + const NotificationReminderState( + createdAt: '', + pageTitle: '', + reminderContent: '', + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart new file mode 100644 index 0000000000000..650fbf1d855e1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/application/page_style/document_page_style_bloc.dart @@ -0,0 +1,456 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'document_page_style_bloc.freezed.dart'; + +class DocumentPageStyleBloc + extends Bloc { + DocumentPageStyleBloc({ + required this.view, + }) : super(DocumentPageStyleState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + try { + if (view.id.isEmpty) { + return; + } + Map layoutObject = {}; + final data = await ViewBackendService.getView(view.id); + data.onSuccess((s) { + if (s.extra.isNotEmpty) { + layoutObject = jsonDecode(s.extra); + } + }); + final fontLayout = _getSelectedFontLayout(layoutObject); + final lineHeightLayout = _getSelectedLineHeightLayout( + layoutObject, + ); + final fontFamily = _getSelectedFontFamily(layoutObject); + final cover = _getSelectedCover(layoutObject); + final coverType = cover.$1; + final coverValue = cover.$2; + emit( + state.copyWith( + fontLayout: fontLayout, + fontFamily: fontFamily, + lineHeightLayout: lineHeightLayout, + coverImage: PageStyleCover( + type: coverType, + value: coverValue, + ), + iconPadding: calculateIconPadding( + fontLayout, + lineHeightLayout, + ), + ), + ); + } catch (e) { + Log.error('Failed to decode layout object: $e'); + } + }, + updateFont: (fontLayout) async { + emit( + state.copyWith( + fontLayout: fontLayout, + iconPadding: calculateIconPadding( + fontLayout, + state.lineHeightLayout, + ), + ), + ); + + unawaited(updateLayoutObject()); + }, + updateLineHeight: (lineHeightLayout) async { + emit( + state.copyWith( + lineHeightLayout: lineHeightLayout, + iconPadding: calculateIconPadding( + state.fontLayout, + lineHeightLayout, + ), + ), + ); + + unawaited(updateLayoutObject()); + }, + updateFontFamily: (fontFamily) async { + emit( + state.copyWith( + fontFamily: fontFamily, + ), + ); + + unawaited(updateLayoutObject()); + }, + updateCoverImage: (coverImage) async { + emit( + state.copyWith( + coverImage: coverImage, + ), + ); + + unawaited(updateLayoutObject()); + }, + ); + }, + ); + } + + final ViewPB view; + final ViewBackendService viewBackendService = ViewBackendService(); + + Future updateLayoutObject() async { + final layoutObject = decodeLayoutObject(); + if (layoutObject != null) { + await ViewBackendService.updateView( + viewId: view.id, + extra: layoutObject, + ); + } + } + + String? decodeLayoutObject() { + Map oldValue = {}; + try { + final extra = view.extra; + oldValue = jsonDecode(extra); + } catch (e) { + Log.error('Failed to decode layout object: $e'); + } + final newValue = { + ViewExtKeys.fontLayoutKey: state.fontLayout.toString(), + ViewExtKeys.lineHeightLayoutKey: state.lineHeightLayout.toString(), + ViewExtKeys.coverKey: { + ViewExtKeys.coverTypeKey: state.coverImage.type.toString(), + ViewExtKeys.coverValueKey: state.coverImage.value, + }, + ViewExtKeys.fontKey: state.fontFamily, + }; + final merged = mergeMaps(oldValue, newValue); + return jsonEncode(merged); + } + + // because the line height can not be calculated accurately, + // we need to adjust the icon padding manually. + double calculateIconPadding( + PageStyleFontLayout fontLayout, + PageStyleLineHeightLayout lineHeightLayout, + ) { + double padding = switch (fontLayout) { + PageStyleFontLayout.small => 1.0, + PageStyleFontLayout.normal => 1.0, + PageStyleFontLayout.large => 4.0, + }; + switch (lineHeightLayout) { + case PageStyleLineHeightLayout.small: + padding -= 1.0; + break; + case PageStyleLineHeightLayout.normal: + break; + case PageStyleLineHeightLayout.large: + padding += 3.0; + break; + } + return max(0, padding); + } + + double calculateIconScale( + PageStyleFontLayout fontLayout, + ) { + return switch (fontLayout) { + PageStyleFontLayout.small => 0.8, + PageStyleFontLayout.normal => 1.0, + PageStyleFontLayout.large => 1.2, + }; + } + + PageStyleFontLayout _getSelectedFontLayout(Map layoutObject) { + final fontLayout = layoutObject[ViewExtKeys.fontLayoutKey] ?? + PageStyleFontLayout.normal.toString(); + return PageStyleFontLayout.values.firstWhere( + (e) => e.toString() == fontLayout, + ); + } + + PageStyleLineHeightLayout _getSelectedLineHeightLayout(Map layoutObject) { + final lineHeightLayout = layoutObject[ViewExtKeys.lineHeightLayoutKey] ?? + PageStyleLineHeightLayout.normal.toString(); + return PageStyleLineHeightLayout.values.firstWhere( + (e) => e.toString() == lineHeightLayout, + ); + } + + String? _getSelectedFontFamily(Map layoutObject) { + return layoutObject[ViewExtKeys.fontKey]; + } + + (PageStyleCoverImageType, String colorValue) _getSelectedCover( + Map layoutObject, + ) { + final cover = layoutObject[ViewExtKeys.coverKey] ?? {}; + final coverType = cover[ViewExtKeys.coverTypeKey] ?? + PageStyleCoverImageType.none.toString(); + final coverValue = cover[ViewExtKeys.coverValueKey] ?? ''; + return ( + PageStyleCoverImageType.values.firstWhere( + (e) => e.toString() == coverType, + ), + coverValue, + ); + } +} + +@freezed +class DocumentPageStyleEvent with _$DocumentPageStyleEvent { + const factory DocumentPageStyleEvent.initial() = Initial; + const factory DocumentPageStyleEvent.updateFont( + PageStyleFontLayout fontLayout, + ) = UpdateFontSize; + const factory DocumentPageStyleEvent.updateLineHeight( + PageStyleLineHeightLayout lineHeightLayout, + ) = UpdateLineHeight; + const factory DocumentPageStyleEvent.updateFontFamily( + String? fontFamily, + ) = UpdateFontFamily; + const factory DocumentPageStyleEvent.updateCoverImage( + PageStyleCover coverImage, + ) = UpdateCoverImage; +} + +@freezed +class DocumentPageStyleState with _$DocumentPageStyleState { + const factory DocumentPageStyleState({ + @Default(PageStyleFontLayout.normal) PageStyleFontLayout fontLayout, + @Default(PageStyleLineHeightLayout.normal) + PageStyleLineHeightLayout lineHeightLayout, + // the default font family is null, which means the system font + @Default(null) String? fontFamily, + @Default(2.0) double iconPadding, + required PageStyleCover coverImage, + }) = _DocumentPageStyleState; + + factory DocumentPageStyleState.initial() => DocumentPageStyleState( + coverImage: PageStyleCover.none(), + ); +} + +enum PageStyleFontLayout { + small, + normal, + large; + + @override + String toString() { + switch (this) { + case PageStyleFontLayout.small: + return 'small'; + case PageStyleFontLayout.normal: + return 'normal'; + case PageStyleFontLayout.large: + return 'large'; + } + } + + static PageStyleFontLayout fromString(String value) { + return PageStyleFontLayout.values.firstWhereOrNull( + (e) => e.toString() == value, + ) ?? + PageStyleFontLayout.normal; + } + + double get fontSize { + switch (this) { + case PageStyleFontLayout.small: + return 14.0; + case PageStyleFontLayout.normal: + return 16.0; + case PageStyleFontLayout.large: + return 18.0; + } + } + + List get headingFontSizes { + switch (this) { + case PageStyleFontLayout.small: + return [22.0, 18.0, 16.0, 16.0, 16.0, 16.0]; + case PageStyleFontLayout.normal: + return [24.0, 20.0, 18.0, 18.0, 18.0, 18.0]; + case PageStyleFontLayout.large: + return [26.0, 22.0, 20.0, 20.0, 20.0, 20.0]; + } + } + + double get factor { + switch (this) { + case PageStyleFontLayout.small: + return PageStyleFontLayout.small.fontSize / + PageStyleFontLayout.normal.fontSize; + case PageStyleFontLayout.normal: + return 1.0; + case PageStyleFontLayout.large: + return PageStyleFontLayout.large.fontSize / + PageStyleFontLayout.normal.fontSize; + } + } +} + +enum PageStyleLineHeightLayout { + small, + normal, + large; + + @override + String toString() { + switch (this) { + case PageStyleLineHeightLayout.small: + return 'small'; + case PageStyleLineHeightLayout.normal: + return 'normal'; + case PageStyleLineHeightLayout.large: + return 'large'; + } + } + + static PageStyleLineHeightLayout fromString(String value) { + return PageStyleLineHeightLayout.values.firstWhereOrNull( + (e) => e.toString() == value, + ) ?? + PageStyleLineHeightLayout.normal; + } + + double get lineHeight { + switch (this) { + case PageStyleLineHeightLayout.small: + return 1.4; + case PageStyleLineHeightLayout.normal: + return 1.5; + case PageStyleLineHeightLayout.large: + return 1.75; + } + } + + double get padding { + switch (this) { + case PageStyleLineHeightLayout.small: + return 6.0; + case PageStyleLineHeightLayout.normal: + return 8.0; + case PageStyleLineHeightLayout.large: + return 8.0; + } + } + + List get headingPaddings { + switch (this) { + case PageStyleLineHeightLayout.small: + return [26.0, 22.0, 20.0, 20.0, 20.0, 20.0]; + case PageStyleLineHeightLayout.normal: + return [30.0, 24.0, 22.0, 22.0, 22.0, 22.0]; + case PageStyleLineHeightLayout.large: + return [34.0, 28.0, 26.0, 26.0, 26.0, 26.0]; + } + } +} + +// for the version above 0.5.5 +enum PageStyleCoverImageType { + none, + // normal color + pureColor, + // gradient color + gradientColor, + // built in images + builtInImage, + // custom images, uploaded by the user + customImage, + // local image + localImage, + // unsplash images + unsplashImage; + + @override + String toString() { + switch (this) { + case PageStyleCoverImageType.none: + return 'none'; + case PageStyleCoverImageType.pureColor: + return 'color'; + case PageStyleCoverImageType.gradientColor: + return 'gradient'; + case PageStyleCoverImageType.builtInImage: + return 'built_in'; + case PageStyleCoverImageType.customImage: + return 'custom'; + case PageStyleCoverImageType.localImage: + return 'local'; + case PageStyleCoverImageType.unsplashImage: + return 'unsplash'; + } + } + + static PageStyleCoverImageType fromString(String value) { + return PageStyleCoverImageType.values.firstWhereOrNull( + (e) => e.toString() == value, + ) ?? + PageStyleCoverImageType.none; + } + + static String builtInImagePath(String value) { + return 'assets/images/built_in_cover_images/m_cover_image_$value.png'; + } +} + +class PageStyleCover { + const PageStyleCover({ + required this.type, + required this.value, + }); + + factory PageStyleCover.none() => const PageStyleCover( + type: PageStyleCoverImageType.none, + value: '', + ); + + final PageStyleCoverImageType type; + + // there're 4 types of values: + // 1. pure color: enum value + // 2. gradient color: enum value + // 3. built-in image: the image name, read from the assets + // 4. custom image or unsplash image: the image url + final String value; + + bool get isPresets => isPureColor || isGradient || isBuiltInImage; + bool get isPhoto => isCustomImage || isLocalImage; + + bool get isNone => type == PageStyleCoverImageType.none; + bool get isPureColor => type == PageStyleCoverImageType.pureColor; + bool get isGradient => type == PageStyleCoverImageType.gradientColor; + bool get isBuiltInImage => type == PageStyleCoverImageType.builtInImage; + bool get isCustomImage => type == PageStyleCoverImageType.customImage; + bool get isUnsplashImage => type == PageStyleCoverImageType.unsplashImage; + bool get isLocalImage => type == PageStyleCoverImageType.localImage; + + @override + bool operator ==(Object other) { + if (other is! PageStyleCover) { + return false; + } + return type == other.type && value == other.value; + } + + @override + int get hashCode => Object.hash(type, value); +} diff --git a/frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart new file mode 100644 index 0000000000000..2d89b3b388bec --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/application/recent/recent_view_bloc.dart @@ -0,0 +1,166 @@ +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/document/application/document_listener.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; + +part 'recent_view_bloc.freezed.dart'; + +class RecentViewBloc extends Bloc { + RecentViewBloc({ + required this.view, + }) : _documentListener = DocumentListener(id: view.id), + _viewListener = ViewListener(viewId: view.id), + super(RecentViewState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + _documentListener.start( + onDocEventUpdate: (docEvent) async { + if (state.coverTypeV2 != null) { + return; + } + final (coverType, coverValue) = await getCoverV1(); + add( + RecentViewEvent.updateCover( + coverType, + null, + coverValue, + ), + ); + }, + ); + _viewListener.start( + onViewUpdated: (view) { + add( + RecentViewEvent.updateNameOrIcon( + view.name, + view.icon.toEmojiIconData(), + ), + ); + + if (view.extra.isNotEmpty) { + final cover = view.cover; + add( + RecentViewEvent.updateCover( + CoverType.none, + cover?.type, + cover?.value, + ), + ); + } + }, + ); + + // only document supports the cover + if (view.layout != ViewLayoutPB.Document) { + emit( + state.copyWith( + name: view.name, + icon: view.icon.toEmojiIconData(), + ), + ); + } + + final cover = getCoverV2(); + + if (cover != null) { + emit( + state.copyWith( + name: view.name, + icon: view.icon.toEmojiIconData(), + coverTypeV2: cover.type, + coverValue: cover.value, + ), + ); + } else { + final (coverTypeV1, coverValue) = await getCoverV1(); + emit( + state.copyWith( + name: view.name, + icon: view.icon.toEmojiIconData(), + coverTypeV1: coverTypeV1, + coverValue: coverValue, + ), + ); + } + }, + updateNameOrIcon: (name, icon) { + emit( + state.copyWith( + name: name, + icon: icon, + ), + ); + }, + updateCover: (coverTypeV1, coverTypeV2, coverValue) { + emit( + state.copyWith( + coverTypeV1: coverTypeV1, + coverTypeV2: coverTypeV2, + coverValue: coverValue, + ), + ); + }, + ); + }, + ); + } + + final ViewPB view; + final DocumentListener _documentListener; + final ViewListener _viewListener; + + PageStyleCover? getCoverV2() { + return view.cover; + } + + // for the version under 0.5.5 + Future<(CoverType, String?)> getCoverV1() async { + return (CoverType.none, null); + } + + @override + Future close() async { + await _documentListener.stop(); + await _viewListener.stop(); + return super.close(); + } +} + +@freezed +class RecentViewEvent with _$RecentViewEvent { + const factory RecentViewEvent.initial() = Initial; + + const factory RecentViewEvent.updateCover( + CoverType coverTypeV1, + // for the version under 0.5.5, including 0.5.5 + PageStyleCoverImageType? coverTypeV2, // for the version above 0.5.5 + String? coverValue, + ) = UpdateCover; + + const factory RecentViewEvent.updateNameOrIcon( + String name, + EmojiIconData icon, + ) = UpdateNameOrIcon; +} + +@freezed +class RecentViewState with _$RecentViewState { + const factory RecentViewState({ + required String name, + required EmojiIconData icon, + @Default(CoverType.none) CoverType coverTypeV1, + PageStyleCoverImageType? coverTypeV2, + @Default(null) String? coverValue, + }) = _RecentViewState; + + factory RecentViewState.initial() => + RecentViewState(name: '', icon: EmojiIconData.none()); +} diff --git a/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart b/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart new file mode 100644 index 0000000000000..1480cc02e9763 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/application/user_profile/user_profile_bloc.dart @@ -0,0 +1,65 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'user_profile_bloc.freezed.dart'; + +class UserProfileBloc extends Bloc { + UserProfileBloc() : super(const _Initial()) { + on((event, emit) async { + await event.when( + started: () async => _initialize(emit), + ); + }); + } + + Future _initialize(Emitter emit) async { + emit(const UserProfileState.loading()); + + final workspaceOrFailure = + await FolderEventGetCurrentWorkspaceSetting().send(); + + final userOrFailure = await getIt().getUser(); + + final workspaceSetting = workspaceOrFailure.fold( + (workspaceSettingPB) => workspaceSettingPB, + (error) => null, + ); + + final userProfile = userOrFailure.fold( + (userProfilePB) => userProfilePB, + (error) => null, + ); + + if (workspaceSetting == null || userProfile == null) { + return emit(const UserProfileState.workspaceFailure()); + } + + emit( + UserProfileState.success( + workspaceSettings: workspaceSetting, + userProfile: userProfile, + ), + ); + } +} + +@freezed +class UserProfileEvent with _$UserProfileEvent { + const factory UserProfileEvent.started() = _Started; +} + +@freezed +class UserProfileState with _$UserProfileState { + const factory UserProfileState.initial() = _Initial; + const factory UserProfileState.loading() = _Loading; + const factory UserProfileState.workspaceFailure() = _WorkspaceFailure; + const factory UserProfileState.success({ + required WorkspaceSettingPB workspaceSettings, + required UserProfilePB userProfile, + }) = _Success; +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/animated_gesture.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/animated_gesture.dart new file mode 100644 index 0000000000000..d0e973ae6447a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/animated_gesture.dart @@ -0,0 +1,54 @@ +import 'package:appflowy/shared/feedback_gesture_detector.dart'; +import 'package:flutter/material.dart'; + +class AnimatedGestureDetector extends StatefulWidget { + const AnimatedGestureDetector({ + super.key, + this.scaleFactor = 0.98, + this.feedback = true, + this.duration = const Duration(milliseconds: 100), + this.alignment = Alignment.center, + this.behavior = HitTestBehavior.opaque, + this.onTapUp, + required this.child, + }); + + final Widget child; + final double scaleFactor; + final Duration duration; + final Alignment alignment; + final bool feedback; + final HitTestBehavior behavior; + final VoidCallback? onTapUp; + + @override + State createState() => + _AnimatedGestureDetectorState(); +} + +class _AnimatedGestureDetectorState extends State { + double scale = 1.0; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: widget.behavior, + onTapUp: (details) { + setState(() => scale = 1.0); + + HapticFeedbackType.light.call(); + + widget.onTapUp?.call(); + }, + onTapDown: (details) { + setState(() => scale = widget.scaleFactor); + }, + child: AnimatedScale( + scale: scale, + alignment: widget.alignment, + duration: widget.duration, + child: widget.child, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar.dart new file mode 100644 index 0000000000000..396ecd6bb8630 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar.dart @@ -0,0 +1,67 @@ +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +enum FlowyAppBarLeadingType { + back, + close, + cancel; + + Widget getWidget(VoidCallback? onTap) { + switch (this) { + case FlowyAppBarLeadingType.back: + return AppBarImmersiveBackButton(onTap: onTap); + case FlowyAppBarLeadingType.close: + return AppBarCloseButton(onTap: onTap); + case FlowyAppBarLeadingType.cancel: + return AppBarCancelButton(onTap: onTap); + } + } + + double? get width { + switch (this) { + case FlowyAppBarLeadingType.back: + return 40.0; + case FlowyAppBarLeadingType.close: + return 40.0; + case FlowyAppBarLeadingType.cancel: + return 120; + } + } +} + +class FlowyAppBar extends AppBar { + FlowyAppBar({ + super.key, + super.actions, + Widget? title, + String? titleText, + FlowyAppBarLeadingType leadingType = FlowyAppBarLeadingType.back, + double? leadingWidth, + Widget? leading, + super.centerTitle, + VoidCallback? onTapLeading, + bool showDivider = true, + super.backgroundColor, + }) : super( + title: title ?? + FlowyText( + titleText ?? '', + fontSize: 15.0, + fontWeight: FontWeight.w500, + ), + titleSpacing: 0, + elevation: 0, + leading: leading ?? leadingType.getWidget(onTapLeading), + leadingWidth: leadingWidth ?? leadingType.width, + toolbarHeight: 44.0, + bottom: showDivider + ? const PreferredSize( + preferredSize: Size.fromHeight(0.5), + child: Divider( + height: 0.5, + ), + ) + : null, + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar_actions.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar_actions.dart new file mode 100644 index 0000000000000..72142d446bf84 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/app_bar/app_bar_actions.dart @@ -0,0 +1,223 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class AppBarBackButton extends StatelessWidget { + const AppBarBackButton({ + super.key, + this.onTap, + this.padding, + }); + + final VoidCallback? onTap; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + return AppBarButton( + onTap: (_) => (onTap ?? () => Navigator.pop(context)).call(), + padding: padding, + child: const FlowySvg( + FlowySvgs.m_app_bar_back_s, + ), + ); + } +} + +class AppBarImmersiveBackButton extends StatelessWidget { + const AppBarImmersiveBackButton({ + super.key, + this.onTap, + }); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return AppBarButton( + onTap: (_) => (onTap ?? () => Navigator.pop(context)).call(), + padding: const EdgeInsets.only( + left: 12.0, + top: 8.0, + bottom: 8.0, + right: 4.0, + ), + child: const FlowySvg( + FlowySvgs.m_app_bar_back_s, + ), + ); + } +} + +class AppBarCloseButton extends StatelessWidget { + const AppBarCloseButton({ + super.key, + this.onTap, + }); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return AppBarButton( + onTap: (_) => (onTap ?? () => Navigator.pop(context)).call(), + child: const FlowySvg( + FlowySvgs.m_app_bar_close_s, + ), + ); + } +} + +class AppBarCancelButton extends StatelessWidget { + const AppBarCancelButton({ + super.key, + this.onTap, + }); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return AppBarButton( + onTap: (_) => (onTap ?? () => Navigator.pop(context)).call(), + child: FlowyText( + LocaleKeys.button_cancel.tr(), + overflow: TextOverflow.ellipsis, + ), + ); + } +} + +class AppBarDoneButton extends StatelessWidget { + const AppBarDoneButton({ + super.key, + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return AppBarButton( + onTap: (_) => onTap(), + padding: const EdgeInsets.all(12), + child: FlowyText( + LocaleKeys.button_done.tr(), + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + textAlign: TextAlign.right, + ), + ); + } +} + +class AppBarSaveButton extends StatelessWidget { + const AppBarSaveButton({ + super.key, + required this.onTap, + this.enable = true, + this.padding = const EdgeInsets.all(12), + }); + + final VoidCallback onTap; + final bool enable; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return AppBarButton( + onTap: (_) { + if (enable) { + onTap(); + } + }, + padding: padding, + child: FlowyText( + LocaleKeys.button_save.tr(), + color: enable + ? Theme.of(context).colorScheme.primary + : Theme.of(context).disabledColor, + fontWeight: FontWeight.w500, + textAlign: TextAlign.right, + ), + ); + } +} + +class AppBarFilledDoneButton extends StatelessWidget { + const AppBarFilledDoneButton({super.key, required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 0, + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + enableFeedback: true, + backgroundColor: Theme.of(context).primaryColor, + ), + onPressed: onTap, + child: FlowyText.medium( + LocaleKeys.button_done.tr(), + fontSize: 16, + color: Theme.of(context).colorScheme.onPrimary, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } +} + +class AppBarMoreButton extends StatelessWidget { + const AppBarMoreButton({ + super.key, + required this.onTap, + }); + + final void Function(BuildContext context) onTap; + + @override + Widget build(BuildContext context) { + return AppBarButton( + padding: const EdgeInsets.all(12), + onTap: onTap, + child: const FlowySvg(FlowySvgs.three_dots_s), + ); + } +} + +class AppBarButton extends StatelessWidget { + const AppBarButton({ + super.key, + required this.onTap, + required this.child, + this.padding, + }); + + final void Function(BuildContext context) onTap; + final Widget child; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(context), + child: Padding( + padding: padding ?? const EdgeInsets.all(12), + child: child, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/flowy_search_text_field.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/flowy_search_text_field.dart new file mode 100644 index 0000000000000..8233f8bff5209 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/flowy_search_text_field.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class FlowySearchTextField extends StatelessWidget { + const FlowySearchTextField({ + super.key, + this.hintText, + this.controller, + this.onChanged, + this.onSubmitted, + }); + + final String? hintText; + final TextEditingController? controller; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44.0, + child: CupertinoSearchTextField( + controller: controller, + onChanged: onChanged, + onSubmitted: onSubmitted, + placeholder: hintText, + prefixIcon: const FlowySvg(FlowySvgs.m_search_m), + prefixInsets: const EdgeInsets.only(left: 16.0, right: 2.0), + suffixIcon: const Icon(Icons.close), + suffixInsets: const EdgeInsets.only(right: 16.0), + placeholderStyle: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).hintColor, + fontWeight: FontWeight.w400, + fontSize: 14.0, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart new file mode 100644 index 0000000000000..6e0a77169b64f --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/mobile_view_page.dart @@ -0,0 +1,319 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/document_collaborators.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileViewPage extends StatefulWidget { + const MobileViewPage({ + super.key, + required this.id, + required this.viewLayout, + this.title, + this.arguments, + this.fixedTitle, + this.showMoreButton = true, + this.blockId, + }); + + /// view id + final String id; + final ViewLayoutPB viewLayout; + final String? title; + final Map? arguments; + final bool showMoreButton; + final String? blockId; + + // only used in row page + final String? fixedTitle; + + @override + State createState() => _MobileViewPageState(); +} + +class _MobileViewPageState extends State { + // used to determine if the user has scrolled down and show the app bar in immersive mode + ScrollNotificationObserverState? _scrollNotificationObserver; + + // control the app bar opacity when in immersive mode + final ValueNotifier _appBarOpacity = ValueNotifier(1.0); + + @override + void initState() { + super.initState(); + + getIt().add(const ReminderEvent.started()); + } + + @override + void dispose() { + _appBarOpacity.dispose(); + + // there's no need to remove the listener, because the observer will be disposed when the widget is unmounted. + // inside the observer, the listener will be removed automatically. + // _scrollNotificationObserver?.removeListener(_onScrollNotification); + _scrollNotificationObserver = null; + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => MobileViewPageBloc(viewId: widget.id) + ..add(const MobileViewPageEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final view = state.result?.fold((s) => s, (f) => null); + final body = _buildBody(context, state); + + if (view == null) { + return _buildApp(context, null, body); + } + + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => + FavoriteBloc()..add(const FavoriteEvent.initial()), + ), + BlocProvider( + create: (_) => + ViewBloc(view: view)..add(const ViewEvent.initial()), + ), + BlocProvider.value( + value: getIt(), + ), + BlocProvider( + create: (_) => + ShareBloc(view: view)..add(const ShareEvent.initial()), + ), + if (state.userProfilePB != null) + BlocProvider( + create: (_) => + UserWorkspaceBloc(userProfile: state.userProfilePB!) + ..add(const UserWorkspaceEvent.initial()), + ), + if (view.layout.isDocumentView) + BlocProvider( + create: (_) => DocumentPageStyleBloc(view: view) + ..add(const DocumentPageStyleEvent.initial()), + ), + ], + child: Builder( + builder: (context) { + final view = context.watch().state.view; + return _buildApp(context, view, body); + }, + ), + ); + }, + ), + ); + } + + Widget _buildApp( + BuildContext context, + ViewPB? view, + Widget child, + ) { + final isDocument = view?.layout.isDocumentView ?? false; + final title = _buildTitle(context, view); + final actions = _buildAppBarActions(context, view); + final appBar = isDocument + ? MobileViewPageImmersiveAppBar( + preferredSize: Size( + double.infinity, + AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight, + ), + title: title, + appBarOpacity: _appBarOpacity, + actions: actions, + ) + : FlowyAppBar(title: title, actions: actions); + final body = isDocument + ? Builder( + builder: (context) { + _rebuildScrollNotificationObserver(context); + return child; + }, + ) + : SafeArea(child: child); + return Scaffold( + extendBodyBehindAppBar: isDocument, + appBar: appBar, + body: body, + ); + } + + Widget _buildBody(BuildContext context, MobileViewPageState state) { + if (state.isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + final result = state.result; + if (result == null) { + return FlowyMobileStateContainer.error( + emoji: '😔', + title: LocaleKeys.error_weAreSorry.tr(), + description: LocaleKeys.error_loadingViewError.tr(), + errorMsg: '', + ); + } + + return result.fold( + (view) { + final plugin = view.plugin(arguments: widget.arguments ?? const {}) + ..init(); + return plugin.widgetBuilder.buildWidget( + shrinkWrap: false, + context: PluginContext(userProfile: state.userProfilePB), + data: { + MobileDocumentScreen.viewFixedTitle: widget.fixedTitle, + MobileDocumentScreen.viewBlockId: widget.blockId, + }, + ); + }, + (error) { + return FlowyMobileStateContainer.error( + emoji: '😔', + title: LocaleKeys.error_weAreSorry.tr(), + description: LocaleKeys.error_loadingViewError.tr(), + errorMsg: error.toString(), + ); + }, + ); + } + + // Document: + // - [ collaborators, sync_indicator, layout_button, more_button] + // Database: + // - [ sync_indicator, more_button] + List _buildAppBarActions(BuildContext context, ViewPB? view) { + if (view == null) { + return []; + } + + final isImmersiveMode = + context.read().state.isImmersiveMode; + final actions = []; + + if (FeatureFlag.syncDocument.isOn) { + // only document supports displaying collaborators. + if (view.layout.isDocumentView) { + actions.addAll([ + DocumentCollaborators( + width: 60, + height: 44, + fontSize: 14, + padding: const EdgeInsets.symmetric(vertical: 8), + view: view, + ), + const HSpace(12.0), + ]); + } + } + + if (view.layout.isDocumentView) { + actions.addAll([ + MobileViewPageLayoutButton( + view: view, + isImmersiveMode: isImmersiveMode, + appBarOpacity: _appBarOpacity, + ), + ]); + } + + if (widget.showMoreButton) { + actions.addAll([ + MobileViewPageMoreButton( + view: view, + isImmersiveMode: isImmersiveMode, + appBarOpacity: _appBarOpacity, + ), + ]); + } else { + actions.addAll([ + const HSpace(18.0), + ]); + } + + return actions; + } + + Widget _buildTitle(BuildContext context, ViewPB? view) { + final icon = view?.icon; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + EmojiIconWidget( + emoji: icon.toEmojiIconData(), + emojiSize: 15, + ), + const HSpace(4), + ], + Expanded( + child: FlowyText.medium( + widget.fixedTitle ?? view?.name ?? widget.title ?? '', + fontSize: 15.0, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 18.0, + ), + ), + ], + ); + } + + void _rebuildScrollNotificationObserver(BuildContext context) { + _scrollNotificationObserver?.removeListener(_onScrollNotification); + _scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context); + _scrollNotificationObserver?.addListener(_onScrollNotification); + } + + // immersive mode related + // auto show or hide the app bar based on the scroll position + void _onScrollNotification(ScrollNotification notification) { + if (_scrollNotificationObserver == null) { + return; + } + + if (notification is ScrollUpdateNotification && + defaultScrollNotificationPredicate(notification)) { + final ScrollMetrics metrics = notification.metrics; + double height = MediaQuery.of(context).padding.top; + if (defaultTargetPlatform == TargetPlatform.android) { + height += AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight; + } + final progress = (metrics.pixels / height).clamp(0.0, 1.0); + // reduce the sensitivity of the app bar opacity change + if ((progress - _appBarOpacity.value).abs() >= 0.1 || + progress == 0 || + progress == 1.0) { + _appBarOpacity.value = progress; + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/option_color_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/option_color_list.dart new file mode 100644 index 0000000000000..d27085b3944df --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/option_color_list.dart @@ -0,0 +1,58 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flutter/material.dart'; + +class OptionColorList extends StatelessWidget { + const OptionColorList({ + super.key, + this.selectedColor, + required this.onSelectedColor, + }); + + final SelectOptionColorPB? selectedColor; + final void Function(SelectOptionColorPB color) onSelectedColor; + + @override + Widget build(BuildContext context) { + return GridView.count( + crossAxisCount: 6, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + children: SelectOptionColorPB.values.map( + (colorPB) { + final color = colorPB.toColor(context); + final isSelected = selectedColor?.value == colorPB.value; + return GestureDetector( + onTap: () => onSelectedColor(colorPB), + child: Container( + margin: const EdgeInsets.all( + 8.0, + ), + decoration: BoxDecoration( + color: color, + borderRadius: Corners.s12Border, + border: Border.all( + width: isSelected ? 2.0 : 1.0, + color: isSelected + ? const Color(0xff00C6F1) + : Theme.of(context).dividerColor, + ), + ), + alignment: Alignment.center, + child: isSelected + ? const FlowySvg( + FlowySvgs.m_blue_check_s, + size: Size.square(28.0), + blendMode: null, + ) + : null, + ), + ); + }, + ).toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/type_option_menu_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/type_option_menu_item.dart new file mode 100644 index 0000000000000..e0c3140ea9cb4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/type_option_menu_item.dart @@ -0,0 +1,162 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class TypeOptionMenuItemValue { + const TypeOptionMenuItemValue({ + required this.value, + required this.icon, + required this.text, + required this.backgroundColor, + required this.onTap, + this.iconPadding, + }); + + final T value; + final FlowySvgData icon; + final String text; + final Color backgroundColor; + final EdgeInsets? iconPadding; + final void Function(BuildContext context, T value) onTap; +} + +class TypeOptionMenu extends StatelessWidget { + const TypeOptionMenu({ + super.key, + required this.values, + this.width = 98, + this.iconWidth = 72, + this.scaleFactor = 1.0, + this.maxAxisSpacing = 18, + this.crossAxisCount = 3, + }); + + final List> values; + + final double iconWidth; + final double width; + final double scaleFactor; + final double maxAxisSpacing; + final int crossAxisCount; + + @override + Widget build(BuildContext context) { + return TypeOptionGridView( + crossAxisCount: crossAxisCount, + mainAxisSpacing: maxAxisSpacing * scaleFactor, + itemWidth: width * scaleFactor, + children: values + .map( + (value) => TypeOptionMenuItem( + value: value, + width: width, + iconWidth: iconWidth, + scaleFactor: scaleFactor, + iconPadding: value.iconPadding, + ), + ) + .toList(), + ); + } +} + +class TypeOptionMenuItem extends StatelessWidget { + const TypeOptionMenuItem({ + super.key, + required this.value, + this.width = 94, + this.iconWidth = 72, + this.scaleFactor = 1.0, + this.iconPadding, + }); + + final TypeOptionMenuItemValue value; + final double iconWidth; + final double width; + final double scaleFactor; + final EdgeInsets? iconPadding; + + double get scaledIconWidth => iconWidth * scaleFactor; + double get scaledWidth => width * scaleFactor; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => value.onTap(context, value.value), + child: Column( + children: [ + Container( + height: scaledIconWidth, + width: scaledIconWidth, + decoration: ShapeDecoration( + color: value.backgroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24 * scaleFactor), + ), + ), + padding: EdgeInsets.all(21 * scaleFactor) + + (iconPadding ?? EdgeInsets.zero), + child: FlowySvg( + value.icon, + ), + ), + const VSpace(6), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: scaledWidth, + ), + child: FlowyText( + value.text, + fontSize: 14.0, + maxLines: 2, + lineHeight: 1.0, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } +} + +class TypeOptionGridView extends StatelessWidget { + const TypeOptionGridView({ + super.key, + required this.children, + required this.crossAxisCount, + required this.mainAxisSpacing, + required this.itemWidth, + }); + + final List children; + final int crossAxisCount; + final double mainAxisSpacing; + final double itemWidth; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < children.length; i += crossAxisCount) + Padding( + padding: EdgeInsets.only(bottom: mainAxisSpacing), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + for (var j = 0; j < crossAxisCount; j++) + i + j < children.length + ? SizedBox( + width: itemWidth, + child: children[i + j], + ) + : HSpace(itemWidth), + ], + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart new file mode 100644 index 0000000000000..a228ac0229fef --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/app_bar_buttons.dart @@ -0,0 +1,235 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/view_page/more_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart'; +import 'package:appflowy/plugins/shared/share/share_bloc.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class MobileViewPageImmersiveAppBar extends StatelessWidget + implements PreferredSizeWidget { + const MobileViewPageImmersiveAppBar({ + super.key, + required this.preferredSize, + required this.appBarOpacity, + required this.title, + required this.actions, + }); + + final ValueListenable appBarOpacity; + final Widget title; + final List actions; + + @override + final Size preferredSize; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: appBarOpacity, + builder: (_, opacity, __) => FlowyAppBar( + backgroundColor: + AppBarTheme.of(context).backgroundColor?.withOpacity(opacity), + showDivider: false, + title: Opacity(opacity: opacity >= 0.99 ? 1.0 : 0, child: title), + leadingWidth: 44, + leading: Padding( + padding: const EdgeInsets.only(top: 4.0, bottom: 4.0, left: 12.0), + child: _buildAppBarBackButton(context), + ), + actions: actions, + ), + ); + } + + Widget _buildAppBarBackButton(BuildContext context) { + return AppBarButton( + padding: EdgeInsets.zero, + onTap: (context) => context.pop(), + child: _ImmersiveAppBarButton( + icon: FlowySvgs.m_app_bar_back_s, + dimension: 30.0, + iconPadding: 3.0, + isImmersiveMode: + context.read().state.isImmersiveMode, + appBarOpacity: appBarOpacity, + ), + ); + } +} + +class MobileViewPageMoreButton extends StatelessWidget { + const MobileViewPageMoreButton({ + super.key, + required this.view, + required this.isImmersiveMode, + required this.appBarOpacity, + }); + + final ViewPB view; + final bool isImmersiveMode; + final ValueListenable appBarOpacity; + + @override + Widget build(BuildContext context) { + return AppBarButton( + padding: const EdgeInsets.only(left: 8, right: 16), + onTap: (context) { + EditorNotification.exitEditing().post(); + + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + backgroundColor: AFThemeExtension.of(context).background, + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: context.read()), + BlocProvider.value(value: context.read()), + BlocProvider.value(value: context.read()), + BlocProvider.value(value: context.read()), + ], + child: MobileViewPageMoreBottomSheet(view: view), + ), + ); + }, + child: _ImmersiveAppBarButton( + icon: FlowySvgs.m_app_bar_more_s, + dimension: 30.0, + iconPadding: 3.0, + isImmersiveMode: isImmersiveMode, + appBarOpacity: appBarOpacity, + ), + ); + } +} + +class MobileViewPageLayoutButton extends StatelessWidget { + const MobileViewPageLayoutButton({ + super.key, + required this.view, + required this.isImmersiveMode, + required this.appBarOpacity, + }); + + final ViewPB view; + final bool isImmersiveMode; + final ValueListenable appBarOpacity; + + @override + Widget build(BuildContext context) { + // only display the layout button if the view is a document + if (view.layout != ViewLayoutPB.Document) { + return const SizedBox.shrink(); + } + + return AppBarButton( + padding: const EdgeInsets.symmetric(vertical: 2.0), + onTap: (context) { + EditorNotification.exitEditing().post(); + + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + showDoneButton: true, + showHeader: true, + title: LocaleKeys.pageStyle_title.tr(), + backgroundColor: AFThemeExtension.of(context).background, + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: context.read()), + BlocProvider.value(value: context.read()), + ], + child: PageStyleBottomSheet( + view: context.read().state.view, + ), + ), + ); + }, + child: _ImmersiveAppBarButton( + icon: FlowySvgs.m_layout_s, + dimension: 30.0, + iconPadding: 3.0, + isImmersiveMode: isImmersiveMode, + appBarOpacity: appBarOpacity, + ), + ); + } +} + +class _ImmersiveAppBarButton extends StatelessWidget { + const _ImmersiveAppBarButton({ + required this.icon, + required this.dimension, + required this.iconPadding, + required this.isImmersiveMode, + required this.appBarOpacity, + }); + + final FlowySvgData icon; + final double dimension; + final double iconPadding; + final bool isImmersiveMode; + final ValueListenable appBarOpacity; + + @override + Widget build(BuildContext context) { + assert( + dimension > 0.0 && dimension <= kToolbarHeight, + 'dimension must be greater than 0, and less than or equal to kToolbarHeight', + ); + + // if the immersive mode is on, the icon should be white and add a black background + // also, the icon opacity will change based on the app bar opacity + return UnconstrainedBox( + child: SizedBox.square( + dimension: dimension, + child: ValueListenableBuilder( + valueListenable: appBarOpacity, + builder: (context, appBarOpacity, child) { + Color? color; + + // if there's no cover or the cover is not immersive, + // make sure the app bar is always visible + if (!isImmersiveMode) { + color = null; + } else if (appBarOpacity < 0.99) { + color = Colors.white; + } + + Widget child = Container( + margin: EdgeInsets.all(iconPadding), + child: FlowySvg(icon, color: color), + ); + + if (isImmersiveMode && appBarOpacity <= 0.99) { + child = DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(dimension / 2.0), + color: Colors.black.withOpacity(0.2), + ), + child: child, + ); + } + + return child; + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart new file mode 100644 index 0000000000000..25541ed7d4cc8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart @@ -0,0 +1,339 @@ +import 'dart:async'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/plugins/shared/share/publish_name_generator.dart'; +import 'package:appflowy/plugins/shared/share/share_bloc.dart'; +import 'package:appflowy/shared/error_code/error_code_map.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class MobileViewPageMoreBottomSheet extends StatelessWidget { + const MobileViewPageMoreBottomSheet({super.key, required this.view}); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) => _showToast(context, state), + child: BlocListener( + listener: (context, state) { + if (state.successOrFailure.isSuccess && state.isDeleted) { + context.go('/home'); + } + }, + child: ViewPageBottomSheet( + view: view, + onAction: (action) async => _onAction(context, action), + onRename: (name) { + _onRename(context, name); + context.pop(); + }, + ), + ), + ); + } + + Future _onAction( + BuildContext context, + MobileViewBottomSheetBodyAction action, + ) async { + switch (action) { + case MobileViewBottomSheetBodyAction.duplicate: + _duplicate(context); + break; + case MobileViewBottomSheetBodyAction.delete: + context.read().add(const ViewEvent.delete()); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.addToFavorites: + _addFavorite(context); + break; + case MobileViewBottomSheetBodyAction.removeFromFavorites: + _removeFavorite(context); + break; + case MobileViewBottomSheetBodyAction.undo: + EditorNotification.undo().post(); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.redo: + EditorNotification.redo().post(); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.helpCenter: + // unimplemented + context.pop(); + break; + case MobileViewBottomSheetBodyAction.publish: + await _publish(context); + if (context.mounted) { + context.pop(); + } + break; + case MobileViewBottomSheetBodyAction.unpublish: + _unpublish(context); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.copyPublishLink: + _copyPublishLink(context); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.visitSite: + _visitPublishedSite(context); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.copyShareLink: + _copyShareLink(context); + context.pop(); + break; + case MobileViewBottomSheetBodyAction.updatePathName: + _updatePathName(context); + case MobileViewBottomSheetBodyAction.rename: + // no need to implement, rename is handled by the onRename callback. + throw UnimplementedError(); + } + } + + Future _publish(BuildContext context) async { + final id = context.read().view.id; + final lastPublishName = context.read().state.pathName; + final publishName = lastPublishName.orDefault( + await generatePublishName( + id, + view.name, + ), + ); + if (context.mounted) { + context.read().add( + ShareEvent.publish( + '', + publishName, + [view.id], + ), + ); + } + } + + void _duplicate(BuildContext context) { + context.read().add(const ViewEvent.duplicate()); + context.pop(); + + showToastNotification( + context, + message: LocaleKeys.button_duplicateSuccessfully.tr(), + ); + } + + void _addFavorite(BuildContext context) { + _toggleFavorite(context); + + showToastNotification( + context, + message: LocaleKeys.button_favoriteSuccessfully.tr(), + ); + } + + void _removeFavorite(BuildContext context) { + _toggleFavorite(context); + + showToastNotification( + context, + message: LocaleKeys.button_unfavoriteSuccessfully.tr(), + ); + } + + void _toggleFavorite(BuildContext context) { + context.read().add(FavoriteEvent.toggle(view)); + context.pop(); + } + + void _unpublish(BuildContext context) { + context.read().add(const ShareEvent.unPublish()); + } + + void _copyPublishLink(BuildContext context) { + final url = context.read().state.url; + if (url.isNotEmpty) { + unawaited( + getIt().setData( + ClipboardServiceData(plainText: url), + ), + ); + showToastNotification( + context, + message: LocaleKeys.grid_url_copy.tr(), + ); + } + } + + void _visitPublishedSite(BuildContext context) { + final url = context.read().state.url; + if (url.isNotEmpty) { + unawaited( + afLaunchUri( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ), + ); + } + } + + void _copyShareLink(BuildContext context) { + final workspaceId = context.read().state.workspaceId; + final viewId = context.read().state.viewId; + final url = ShareConstants.buildShareUrl( + workspaceId: workspaceId, + viewId: viewId, + ); + if (url.isNotEmpty) { + unawaited( + getIt().setData( + ClipboardServiceData(plainText: url), + ), + ); + showToastNotification( + context, + message: LocaleKeys.shareAction_copyLinkSuccess.tr(), + ); + } else { + showToastNotification( + context, + message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(), + type: ToastificationType.error, + ); + } + } + + void _onRename(BuildContext context, String name) { + if (name != view.name) { + context.read().add(ViewEvent.rename(name)); + } + } + + void _updatePathName(BuildContext context) async { + final shareBloc = context.read(); + final pathName = shareBloc.state.pathName; + await showMobileBottomSheet( + context, + showHeader: true, + title: LocaleKeys.shareAction_updatePathName.tr(), + showCloseButton: true, + showDragHandle: true, + showDivider: false, + padding: const EdgeInsets.symmetric(horizontal: 16), + builder: (bottomSheetContext) { + FlowyResult? previousUpdatePathNameResult; + return EditWorkspaceNameBottomSheet( + type: EditWorkspaceNameType.edit, + workspaceName: pathName, + hintText: '', + validator: (value) => null, + validatorBuilder: (context) { + return BlocProvider.value( + value: shareBloc, + child: BlocBuilder( + builder: (context, state) { + final updatePathNameResult = state.updatePathNameResult; + + if (updatePathNameResult == null && + previousUpdatePathNameResult == null) { + return const SizedBox.shrink(); + } + + if (updatePathNameResult != null) { + previousUpdatePathNameResult = updatePathNameResult; + } + + final widget = previousUpdatePathNameResult?.fold( + (value) => const SizedBox.shrink(), + (error) => FlowyText( + error.code.publishErrorMessage.orDefault( + LocaleKeys.settings_sites_error_updatePathNameFailed + .tr(), + ), + maxLines: 3, + fontSize: 12, + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).colorScheme.error, + ), + ) ?? + const SizedBox.shrink(); + + return widget; + }, + ), + ); + }, + onSubmitted: (name) { + // rename the path name + Log.info('rename the path name, from: $pathName, to: $name'); + + shareBloc.add(ShareEvent.updatePathName(name)); + }, + ); + }, + ); + shareBloc.add(const ShareEvent.clearPathNameResult()); + } + + void _showToast(BuildContext context, ShareState state) { + if (state.publishResult != null) { + state.publishResult!.fold( + (value) => showToastNotification( + context, + message: LocaleKeys.publish_publishSuccessfully.tr(), + ), + (error) => showToastNotification( + context, + message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}', + type: ToastificationType.error, + ), + ); + } else if (state.unpublishResult != null) { + state.unpublishResult!.fold( + (value) => showToastNotification( + context, + message: LocaleKeys.publish_unpublishSuccessfully.tr(), + ), + (error) => showToastNotification( + context, + message: LocaleKeys.publish_unpublishFailed.tr(), + description: error.msg, + type: ToastificationType.error, + ), + ); + } else if (state.updatePathNameResult != null) { + state.updatePathNameResult!.onSuccess( + (value) { + showToastNotification( + context, + message: + LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), + ); + + context.pop(); + }, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet.dart new file mode 100644 index 0000000000000..4c61f437a88cb --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet.dart @@ -0,0 +1,10 @@ +export 'bottom_sheet_action_widget.dart'; +export 'bottom_sheet_add_new_page.dart'; +export 'bottom_sheet_drag_handler.dart'; +export 'bottom_sheet_rename_widget.dart'; +export 'bottom_sheet_view_item.dart'; +export 'bottom_sheet_view_item_body.dart'; +export 'bottom_sheet_view_page.dart'; +export 'default_mobile_action_pane.dart'; +export 'show_mobile_bottom_sheet.dart'; +export 'show_transition_bottom_sheet.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_action_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_action_widget.dart new file mode 100644 index 0000000000000..6b54b1fda3b0d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_action_widget.dart @@ -0,0 +1,56 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class BottomSheetActionWidget extends StatelessWidget { + const BottomSheetActionWidget({ + super.key, + this.svg, + required this.text, + required this.onTap, + this.iconColor, + }); + + final FlowySvgData? svg; + final String text; + final VoidCallback onTap; + final Color? iconColor; + + @override + Widget build(BuildContext context) { + final iconColor = + this.iconColor ?? AFThemeExtension.of(context).onBackground; + + if (svg == null) { + return OutlinedButton( + style: Theme.of(context) + .outlinedButtonTheme + .style + ?.copyWith(alignment: Alignment.center), + onPressed: onTap, + child: FlowyText( + text, + textAlign: TextAlign.center, + ), + ); + } + + return OutlinedButton.icon( + icon: FlowySvg( + svg!, + size: const Size.square(22.0), + color: iconColor, + ), + label: FlowyText( + text, + overflow: TextOverflow.ellipsis, + ), + style: Theme.of(context) + .outlinedButtonTheme + .style + ?.copyWith(alignment: Alignment.centerLeft), + onPressed: onTap, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart new file mode 100644 index 0000000000000..3316b7049beb8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_add_new_page.dart @@ -0,0 +1,80 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class AddNewPageWidgetBottomSheet extends StatelessWidget { + const AddNewPageWidgetBottomSheet({ + super.key, + required this.view, + required this.onAction, + }); + + final ViewPB view; + final void Function(ViewLayoutPB layout) onAction; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + FlowyOptionTile.text( + text: LocaleKeys.document_menuName.tr(), + height: 52.0, + leftIcon: const FlowySvg( + FlowySvgs.icon_document_s, + size: Size.square(20), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction(ViewLayoutPB.Document), + ), + FlowyOptionTile.text( + text: LocaleKeys.grid_menuName.tr(), + height: 52.0, + leftIcon: const FlowySvg( + FlowySvgs.icon_grid_s, + size: Size.square(20), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction(ViewLayoutPB.Grid), + ), + FlowyOptionTile.text( + text: LocaleKeys.board_menuName.tr(), + height: 52.0, + leftIcon: const FlowySvg( + FlowySvgs.icon_board_s, + size: Size.square(20), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction(ViewLayoutPB.Board), + ), + FlowyOptionTile.text( + text: LocaleKeys.calendar_menuName.tr(), + height: 52.0, + leftIcon: const FlowySvg( + FlowySvgs.icon_calendar_s, + size: Size.square(20), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction(ViewLayoutPB.Calendar), + ), + FlowyOptionTile.text( + text: LocaleKeys.chat_newChat.tr(), + height: 52.0, + leftIcon: const FlowySvg( + FlowySvgs.chat_ai_page_s, + size: Size.square(20), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction(ViewLayoutPB.Chat), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart new file mode 100644 index 0000000000000..b8d0699969116 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart @@ -0,0 +1,78 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +enum BlockActionBottomSheetType { + delete, + duplicate, + insertAbove, + insertBelow, +} + +// Only works on mobile. +class BlockActionBottomSheet extends StatelessWidget { + const BlockActionBottomSheet({ + super.key, + required this.onAction, + this.extendActionWidgets = const [], + }); + + final void Function(BlockActionBottomSheetType layout) onAction; + final List extendActionWidgets; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // insert above, insert below + FlowyOptionTile.text( + text: LocaleKeys.button_insertAbove.tr(), + leftIcon: const FlowySvg( + FlowySvgs.arrow_up_s, + size: Size.square(20), + ), + showTopBorder: false, + onTap: () => onAction(BlockActionBottomSheetType.insertAbove), + ), + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.button_insertBelow.tr(), + leftIcon: const FlowySvg( + FlowySvgs.arrow_down_s, + size: Size.square(20), + ), + onTap: () => onAction(BlockActionBottomSheetType.insertBelow), + ), + // duplicate, delete + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.button_duplicate.tr(), + leftIcon: const Padding( + padding: EdgeInsets.all(2), + child: FlowySvg( + FlowySvgs.copy_s, + size: Size.square(16), + ), + ), + onTap: () => onAction(BlockActionBottomSheetType.duplicate), + ), + + ...extendActionWidgets, + + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.button_delete.tr(), + leftIcon: FlowySvg( + FlowySvgs.trash_s, + size: const Size.square(18), + color: Theme.of(context).colorScheme.error, + ), + textColor: Theme.of(context).colorScheme.error, + onTap: () => onAction(BlockActionBottomSheetType.delete), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart new file mode 100644 index 0000000000000..8adc2bebec79d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart @@ -0,0 +1,107 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class BottomSheetCloseButton extends StatelessWidget { + const BottomSheetCloseButton({ + super.key, + this.onTap, + }); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap ?? () => Navigator.pop(context), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + width: 18, + height: 18, + child: FlowySvg( + FlowySvgs.m_bottom_sheet_close_m, + ), + ), + ), + ); + } +} + +class BottomSheetDoneButton extends StatelessWidget { + const BottomSheetDoneButton({ + super.key, + this.onDone, + }); + + final VoidCallback? onDone; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onDone ?? () => Navigator.pop(context), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12.0), + child: FlowyText( + LocaleKeys.button_done.tr(), + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + textAlign: TextAlign.right, + ), + ), + ); + } +} + +class BottomSheetRemoveButton extends StatelessWidget { + const BottomSheetRemoveButton({ + super.key, + required this.onRemove, + }); + + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onRemove, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12.0), + child: FlowyText( + LocaleKeys.button_remove.tr(), + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + textAlign: TextAlign.right, + ), + ), + ); + } +} + +class BottomSheetBackButton extends StatelessWidget { + const BottomSheetBackButton({ + super.key, + this.onTap, + }); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap ?? () => Navigator.pop(context), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + width: 18, + height: 18, + child: FlowySvg( + FlowySvgs.m_bottom_sheet_back_s, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_drag_handler.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_drag_handler.dart new file mode 100644 index 0000000000000..4e9fcd3d7e3ee --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_drag_handler.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class MobileBottomSheetDragHandler extends StatelessWidget { + const MobileBottomSheetDragHandler({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Container( + width: 60, + height: 4, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2.0), + color: Theme.of(context).hintColor, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_edit_link_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_edit_link_widget.dart new file mode 100644 index 0000000000000..bed69e1cc4298 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_edit_link_widget.dart @@ -0,0 +1,104 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_header.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class MobileBottomSheetEditLinkWidget extends StatefulWidget { + const MobileBottomSheetEditLinkWidget({ + super.key, + required this.text, + required this.href, + required this.onEdit, + }); + + final String text; + final String? href; + final void Function(String text, String href) onEdit; + + @override + State createState() => + _MobileBottomSheetEditLinkWidgetState(); +} + +class _MobileBottomSheetEditLinkWidgetState + extends State { + late final TextEditingController textController; + late final TextEditingController hrefController; + + @override + void initState() { + super.initState(); + + textController = TextEditingController( + text: widget.text, + ); + hrefController = TextEditingController( + text: widget.href, + ); + } + + @override + void dispose() { + textController.dispose(); + hrefController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + BottomSheetHeader( + title: LocaleKeys.editor_editLink.tr(), + onClose: () => context.pop(), + onDone: () { + widget.onEdit(textController.text, hrefController.text); + }, + ), + const VSpace(20.0), + _buildTextField( + textController, + LocaleKeys.document_inlineLink_title_placeholder.tr(), + ), + const VSpace(12.0), + _buildTextField( + hrefController, + LocaleKeys.document_inlineLink_url_placeholder.tr(), + ), + const VSpace(12.0), + ], + ); + } + + Widget _buildTextField( + TextEditingController controller, + String? hintText, + ) { + return SizedBox( + height: 48.0, + child: FlowyTextField( + controller: controller, + hintText: hintText, + textStyle: const TextStyle(fontSize: 16.0), + hintStyle: const TextStyle(fontSize: 16.0), + suffixIcon: Padding( + padding: const EdgeInsets.all(4.0), + child: FlowyButton( + text: const FlowySvg( + FlowySvgs.close_lg, + ), + margin: EdgeInsets.zero, + useIntrinsicWidth: true, + onTap: () { + controller.clear(); + }, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_header.dart new file mode 100644 index 0000000000000..e1bc32a6f0fb0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_header.dart @@ -0,0 +1,49 @@ +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class BottomSheetHeader extends StatelessWidget { + const BottomSheetHeader({ + super.key, + this.title, + this.onClose, + this.onDone, + }); + + final String? title; + final VoidCallback? onClose; + final VoidCallback? onDone; + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + if (onClose != null) + Positioned( + left: 0, + child: Align( + alignment: Alignment.centerLeft, + child: BottomSheetCloseButton( + onTap: onClose, + ), + ), + ), + if (title != null) + Align( + child: FlowyText.medium( + title!, + fontSize: 16, + ), + ), + if (onDone != null) + Align( + alignment: Alignment.centerRight, + child: BottomSheetDoneButton( + onDone: onDone, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart new file mode 100644 index 0000000000000..11292e3194741 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart @@ -0,0 +1,96 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/mobile_file_upload_menu.dart'; +import 'package:appflowy/util/xfile_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pbenum.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +class MobileMediaUploadSheetContent extends StatelessWidget { + const MobileMediaUploadSheetContent({super.key, required this.dialogContext}); + + final BuildContext dialogContext; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(top: 12), + constraints: const BoxConstraints( + maxHeight: 340, + minHeight: 80, + ), + child: MobileFileUploadMenu( + onInsertLocalFile: (files) async { + dialogContext.pop(); + + await insertLocalFiles( + context, + files, + userProfile: context.read().state.userProfile, + documentId: context.read().rowId, + onUploadSuccess: (file, path, isLocalMode) { + final mediaCellBloc = context.read(); + if (mediaCellBloc.isClosed) { + return; + } + + mediaCellBloc.add( + MediaCellEvent.addFile( + url: path, + name: file.name, + uploadType: isLocalMode + ? FileUploadTypePB.LocalFile + : FileUploadTypePB.CloudFile, + fileType: file.fileType.toMediaFileTypePB(), + ), + ); + }, + ); + }, + onInsertNetworkFile: (url) async => _onInsertNetworkFile( + url, + dialogContext, + context, + ), + ), + ); + } + + Future _onInsertNetworkFile( + String url, + BuildContext dialogContext, + BuildContext context, + ) async { + dialogContext.pop(); + + if (url.isEmpty) return; + final uri = Uri.tryParse(url); + if (uri == null) { + return; + } + + final fakeFile = XFile(uri.path); + MediaFileTypePB fileType = fakeFile.fileType.toMediaFileTypePB(); + fileType = + fileType == MediaFileTypePB.Other ? MediaFileTypePB.Link : fileType; + + String name = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : ""; + if (name.isEmpty && uri.pathSegments.length > 1) { + name = uri.pathSegments[uri.pathSegments.length - 2]; + } else if (name.isEmpty) { + name = uri.host; + } + + context.read().add( + MediaCellEvent.addFile( + url: url, + name: name, + uploadType: FileUploadTypePB.NetworkFile, + fileType: fileType, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart new file mode 100644 index 0000000000000..8ac4d9b20e765 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_rename_widget.dart @@ -0,0 +1,78 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileBottomSheetRenameWidget extends StatefulWidget { + const MobileBottomSheetRenameWidget({ + super.key, + required this.name, + required this.onRename, + this.padding = const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), + }); + + final String name; + final void Function(String name) onRename; + final EdgeInsets padding; + + @override + State createState() => + _MobileBottomSheetRenameWidgetState(); +} + +class _MobileBottomSheetRenameWidgetState + extends State { + late final TextEditingController controller; + + @override + void initState() { + super.initState(); + controller = TextEditingController(text: widget.name) + ..selection = TextSelection( + baseOffset: 0, + extentOffset: widget.name.length, + ); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: widget.padding, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: SizedBox( + height: 42.0, + child: FlowyTextField( + controller: controller, + textStyle: Theme.of(context).textTheme.bodyMedium, + keyboardType: TextInputType.text, + onSubmitted: (text) => widget.onRename(text), + ), + ), + ), + const HSpace(12.0), + FlowyTextButton( + LocaleKeys.button_edit.tr(), + constraints: const BoxConstraints.tightFor(height: 42), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + fontColor: Colors.white, + fillColor: Theme.of(context).primaryColor, + onPressed: () { + widget.onRename(controller.text); + }, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart new file mode 100644 index 0000000000000..c1129af79dc22 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item.dart @@ -0,0 +1,155 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart'; +import 'package:appflowy/startup/tasks/app_widget.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; + +enum MobileBottomSheetType { + view, + rename, +} + +class MobileViewItemBottomSheet extends StatefulWidget { + const MobileViewItemBottomSheet({ + super.key, + required this.view, + required this.actions, + this.defaultType = MobileBottomSheetType.view, + }); + + final ViewPB view; + final MobileBottomSheetType defaultType; + final List actions; + + @override + State createState() => + _MobileViewItemBottomSheetState(); +} + +class _MobileViewItemBottomSheetState extends State { + MobileBottomSheetType type = MobileBottomSheetType.view; + final fToast = FToast(); + + @override + void initState() { + super.initState(); + + type = widget.defaultType; + fToast.init(AppGlobals.context); + } + + @override + Widget build(BuildContext context) { + switch (type) { + case MobileBottomSheetType.view: + return MobileViewItemBottomSheetBody( + actions: widget.actions, + isFavorite: widget.view.isFavorite, + onAction: (action) { + switch (action) { + case MobileViewItemBottomSheetBodyAction.rename: + setState(() { + type = MobileBottomSheetType.rename; + }); + break; + case MobileViewItemBottomSheetBodyAction.duplicate: + Navigator.pop(context); + context.read().add(const ViewEvent.duplicate()); + showToastNotification( + context, + message: LocaleKeys.button_duplicateSuccessfully.tr(), + ); + break; + case MobileViewItemBottomSheetBodyAction.share: + // unimplemented + Navigator.pop(context); + break; + case MobileViewItemBottomSheetBodyAction.delete: + Navigator.pop(context); + context.read().add(const ViewEvent.delete()); + break; + case MobileViewItemBottomSheetBodyAction.addToFavorites: + case MobileViewItemBottomSheetBodyAction.removeFromFavorites: + Navigator.pop(context); + context + .read() + .add(FavoriteEvent.toggle(widget.view)); + showToastNotification( + context, + message: !widget.view.isFavorite + ? LocaleKeys.button_favoriteSuccessfully.tr() + : LocaleKeys.button_unfavoriteSuccessfully.tr(), + ); + break; + case MobileViewItemBottomSheetBodyAction.removeFromRecent: + _removeFromRecent(context); + break; + case MobileViewItemBottomSheetBodyAction.divider: + break; + } + }, + ); + case MobileBottomSheetType.rename: + return MobileBottomSheetRenameWidget( + name: widget.view.name, + onRename: (name) { + if (name != widget.view.name) { + context.read().add(ViewEvent.rename(name)); + } + Navigator.pop(context); + }, + ); + } + } + + Future _removeFromRecent(BuildContext context) async { + final viewId = context.read().view.id; + final recentViewsBloc = context.read(); + Navigator.pop(context); + + await _showConfirmDialog( + onDelete: () { + recentViewsBloc.add(RecentViewsEvent.removeRecentViews([viewId])); + }, + ); + } + + Future _showConfirmDialog({required VoidCallback onDelete}) async { + await showFlowyCupertinoConfirmDialog( + title: LocaleKeys.sideBar_removePageFromRecent.tr(), + leftButton: FlowyText( + LocaleKeys.button_cancel.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + color: const Color(0xFF007AFF), + ), + rightButton: FlowyText( + LocaleKeys.button_delete.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w400, + color: const Color(0xFFFE0220), + ), + onRightButtonPressed: (context) { + onDelete(); + + Navigator.pop(context); + + showToastNotification( + context, + message: LocaleKeys.sideBar_removeSuccess.tr(), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart new file mode 100644 index 0000000000000..a078521aece28 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_item_body.dart @@ -0,0 +1,153 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +enum MobileViewItemBottomSheetBodyAction { + rename, + duplicate, + share, + delete, + addToFavorites, + removeFromFavorites, + divider, + removeFromRecent, +} + +class MobileViewItemBottomSheetBody extends StatelessWidget { + const MobileViewItemBottomSheetBody({ + super.key, + this.isFavorite = false, + required this.onAction, + required this.actions, + }); + + final bool isFavorite; + final void Function(MobileViewItemBottomSheetBodyAction action) onAction; + final List actions; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: + actions.map((action) => _buildActionButton(context, action)).toList(), + ); + } + + Widget _buildActionButton( + BuildContext context, + MobileViewItemBottomSheetBodyAction action, + ) { + switch (action) { + case MobileViewItemBottomSheetBodyAction.rename: + return FlowyOptionTile.text( + text: LocaleKeys.button_rename.tr(), + height: 52.0, + leftIcon: const FlowySvg( + FlowySvgs.view_item_rename_s, + size: Size.square(18), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + MobileViewItemBottomSheetBodyAction.rename, + ), + ); + case MobileViewItemBottomSheetBodyAction.duplicate: + return FlowyOptionTile.text( + text: LocaleKeys.button_duplicate.tr(), + height: 52.0, + leftIcon: const FlowySvg( + FlowySvgs.duplicate_s, + size: Size.square(18), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + MobileViewItemBottomSheetBodyAction.duplicate, + ), + ); + + case MobileViewItemBottomSheetBodyAction.share: + return FlowyOptionTile.text( + text: LocaleKeys.button_share.tr(), + height: 52.0, + leftIcon: const FlowySvg( + FlowySvgs.share_s, + size: Size.square(18), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + MobileViewItemBottomSheetBodyAction.share, + ), + ); + case MobileViewItemBottomSheetBodyAction.delete: + return FlowyOptionTile.text( + text: LocaleKeys.button_delete.tr(), + height: 52.0, + textColor: Theme.of(context).colorScheme.error, + leftIcon: FlowySvg( + FlowySvgs.trash_s, + size: const Size.square(18), + color: Theme.of(context).colorScheme.error, + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + MobileViewItemBottomSheetBodyAction.delete, + ), + ); + case MobileViewItemBottomSheetBodyAction.addToFavorites: + return FlowyOptionTile.text( + height: 52.0, + text: LocaleKeys.button_addToFavorites.tr(), + leftIcon: const FlowySvg( + FlowySvgs.favorite_s, + size: Size.square(18), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + MobileViewItemBottomSheetBodyAction.addToFavorites, + ), + ); + case MobileViewItemBottomSheetBodyAction.removeFromFavorites: + return FlowyOptionTile.text( + height: 52.0, + text: LocaleKeys.button_removeFromFavorites.tr(), + leftIcon: const FlowySvg( + FlowySvgs.favorite_section_remove_from_favorite_s, + size: Size.square(18), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + MobileViewItemBottomSheetBodyAction.removeFromFavorites, + ), + ); + case MobileViewItemBottomSheetBodyAction.removeFromRecent: + return FlowyOptionTile.text( + height: 52.0, + text: LocaleKeys.button_removeFromRecent.tr(), + leftIcon: const FlowySvg( + FlowySvgs.remove_from_recent_s, + size: Size.square(18), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + MobileViewItemBottomSheetBodyAction.removeFromRecent, + ), + ); + + case MobileViewItemBottomSheetBodyAction.divider: + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 12.0), + child: Divider(height: 0.5), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart new file mode 100644 index 0000000000000..5b379967ef773 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_view_page.dart @@ -0,0 +1,213 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; +import 'package:appflowy/plugins/shared/share/share_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +enum MobileViewBottomSheetBodyAction { + undo, + redo, + rename, + duplicate, + delete, + addToFavorites, + removeFromFavorites, + helpCenter, + publish, + unpublish, + copyPublishLink, + visitSite, + copyShareLink, + updatePathName, +} + +typedef MobileViewBottomSheetBodyActionCallback = void Function( + MobileViewBottomSheetBodyAction action, +); + +class ViewPageBottomSheet extends StatefulWidget { + const ViewPageBottomSheet({ + super.key, + required this.view, + required this.onAction, + required this.onRename, + }); + + final ViewPB view; + final MobileViewBottomSheetBodyActionCallback onAction; + final void Function(String name) onRename; + + @override + State createState() => _ViewPageBottomSheetState(); +} + +class _ViewPageBottomSheetState extends State { + MobileBottomSheetType type = MobileBottomSheetType.view; + + @override + Widget build(BuildContext context) { + switch (type) { + case MobileBottomSheetType.view: + return MobileViewBottomSheetBody( + view: widget.view, + onAction: (action) { + switch (action) { + case MobileViewBottomSheetBodyAction.rename: + setState(() { + type = MobileBottomSheetType.rename; + }); + break; + default: + widget.onAction(action); + } + }, + ); + + case MobileBottomSheetType.rename: + return MobileBottomSheetRenameWidget( + name: widget.view.name, + onRename: (name) { + widget.onRename(name); + }, + ); + } + } +} + +class MobileViewBottomSheetBody extends StatelessWidget { + const MobileViewBottomSheetBody({ + super.key, + required this.view, + required this.onAction, + }); + + final ViewPB view; + final MobileViewBottomSheetBodyActionCallback onAction; + + @override + Widget build(BuildContext context) { + final isFavorite = view.isFavorite; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MobileQuickActionButton( + text: LocaleKeys.button_rename.tr(), + icon: FlowySvgs.view_item_rename_s, + iconSize: const Size.square(18), + onTap: () => onAction( + MobileViewBottomSheetBodyAction.rename, + ), + ), + _divider(), + MobileQuickActionButton( + text: isFavorite + ? LocaleKeys.button_removeFromFavorites.tr() + : LocaleKeys.button_addToFavorites.tr(), + icon: isFavorite ? FlowySvgs.unfavorite_s : FlowySvgs.favorite_s, + iconSize: const Size.square(18), + onTap: () => onAction( + isFavorite + ? MobileViewBottomSheetBodyAction.removeFromFavorites + : MobileViewBottomSheetBodyAction.addToFavorites, + ), + ), + _divider(), + MobileQuickActionButton( + text: LocaleKeys.button_duplicate.tr(), + icon: FlowySvgs.duplicate_s, + iconSize: const Size.square(18), + onTap: () => onAction( + MobileViewBottomSheetBodyAction.duplicate, + ), + ), + // copy link + _divider(), + MobileQuickActionButton( + text: LocaleKeys.shareAction_copyLink.tr(), + icon: FlowySvgs.m_copy_link_s, + iconSize: const Size.square(18), + onTap: () => onAction( + MobileViewBottomSheetBodyAction.copyShareLink, + ), + ), + _divider(), + ..._buildPublishActions(context), + MobileQuickActionButton( + text: LocaleKeys.button_delete.tr(), + textColor: Theme.of(context).colorScheme.error, + icon: FlowySvgs.trash_s, + iconColor: Theme.of(context).colorScheme.error, + iconSize: const Size.square(18), + onTap: () => onAction( + MobileViewBottomSheetBodyAction.delete, + ), + ), + _divider(), + ], + ); + } + + List _buildPublishActions(BuildContext context) { + final userProfile = context.read().state.userProfilePB; + // the publish feature is only available for AppFlowy Cloud + if (userProfile == null || + userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) { + return []; + } + + final isPublished = context.watch().state.isPublished; + if (isPublished) { + return [ + MobileQuickActionButton( + text: LocaleKeys.shareAction_updatePathName.tr(), + icon: FlowySvgs.view_item_rename_s, + iconSize: const Size.square(18), + onTap: () => onAction( + MobileViewBottomSheetBodyAction.updatePathName, + ), + ), + _divider(), + MobileQuickActionButton( + text: LocaleKeys.shareAction_visitSite.tr(), + icon: FlowySvgs.m_visit_site_s, + iconSize: const Size.square(18), + onTap: () => onAction( + MobileViewBottomSheetBodyAction.visitSite, + ), + ), + _divider(), + MobileQuickActionButton( + text: LocaleKeys.shareAction_unPublish.tr(), + icon: FlowySvgs.m_unpublish_s, + iconSize: const Size.square(18), + onTap: () => onAction( + MobileViewBottomSheetBodyAction.unpublish, + ), + ), + _divider(), + ]; + } else { + return [ + MobileQuickActionButton( + text: LocaleKeys.shareAction_publish.tr(), + icon: FlowySvgs.m_publish_s, + onTap: () => onAction( + MobileViewBottomSheetBodyAction.publish, + ), + ), + _divider(), + ]; + } + } + + Widget _divider() => const Divider( + height: 8.5, + thickness: 0.5, + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart new file mode 100644 index 0000000000000..faf02707b7c21 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart @@ -0,0 +1,214 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/home/shared/mobile_page_card.dart'; +import 'package:appflowy/mobile/presentation/page_item/mobile_slide_action_button.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +enum MobilePaneActionType { + delete, + addToFavorites, + removeFromFavorites, + more, + add; + + MobileSlideActionButton actionButton( + BuildContext context, { + MobilePageCardType? cardType, + FolderSpaceType? spaceType, + }) { + switch (this) { + case MobilePaneActionType.delete: + return MobileSlideActionButton( + backgroundColor: Colors.red, + svg: FlowySvgs.delete_s, + size: 30.0, + onPressed: (context) => + context.read().add(const ViewEvent.delete()), + ); + case MobilePaneActionType.removeFromFavorites: + return MobileSlideActionButton( + backgroundColor: const Color(0xFFFA217F), + svg: FlowySvgs.favorite_section_remove_from_favorite_s, + size: 24.0, + onPressed: (context) { + showToastNotification( + context, + message: LocaleKeys.button_unfavoriteSuccessfully.tr(), + ); + + context + .read() + .add(FavoriteEvent.toggle(context.read().view)); + }, + ); + case MobilePaneActionType.addToFavorites: + return MobileSlideActionButton( + backgroundColor: const Color(0xFF00C8FF), + svg: FlowySvgs.favorite_s, + size: 24.0, + onPressed: (context) { + showToastNotification( + context, + message: LocaleKeys.button_favoriteSuccessfully.tr(), + ); + + context + .read() + .add(FavoriteEvent.toggle(context.read().view)); + }, + ); + case MobilePaneActionType.add: + return MobileSlideActionButton( + backgroundColor: const Color(0xFF00C8FF), + svg: FlowySvgs.add_m, + size: 28.0, + onPressed: (context) { + final viewBloc = context.read(); + final view = viewBloc.state.view; + final title = view.name; + showMobileBottomSheet( + context, + showHeader: true, + title: title, + showDragHandle: true, + showCloseButton: true, + useRootNavigator: true, + showDivider: false, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (sheetContext) { + return AddNewPageWidgetBottomSheet( + view: view, + onAction: (layout) { + Navigator.of(sheetContext).pop(); + viewBloc.add( + ViewEvent.createView( + layout.defaultName, + layout, + section: spaceType!.toViewSectionPB, + ), + ); + }, + ); + }, + ); + }, + ); + case MobilePaneActionType.more: + return MobileSlideActionButton( + backgroundColor: const Color(0xE5515563), + svg: FlowySvgs.three_dots_s, + size: 24.0, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(10), + bottomLeft: Radius.circular(10), + ), + onPressed: (context) { + final viewBloc = context.read(); + final favoriteBloc = context.read(); + final recentViewsBloc = context.read(); + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + useRootNavigator: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (context) { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: viewBloc), + BlocProvider.value(value: favoriteBloc), + if (recentViewsBloc != null) + BlocProvider.value(value: recentViewsBloc), + ], + child: BlocBuilder( + builder: (context, state) { + return MobileViewItemBottomSheet( + view: viewBloc.state.view, + actions: _buildActions(state.view, cardType: cardType), + ); + }, + ), + ); + }, + ); + }, + ); + } + } + + List _buildActions( + ViewPB view, { + MobilePageCardType? cardType, + }) { + final isFavorite = view.isFavorite; + + if (cardType != null) { + switch (cardType) { + case MobilePageCardType.recent: + return [ + isFavorite + ? MobileViewItemBottomSheetBodyAction.removeFromFavorites + : MobileViewItemBottomSheetBodyAction.addToFavorites, + MobileViewItemBottomSheetBodyAction.divider, + MobileViewItemBottomSheetBodyAction.divider, + MobileViewItemBottomSheetBodyAction.removeFromRecent, + ]; + case MobilePageCardType.favorite: + return [ + isFavorite + ? MobileViewItemBottomSheetBodyAction.removeFromFavorites + : MobileViewItemBottomSheetBodyAction.addToFavorites, + MobileViewItemBottomSheetBodyAction.divider, + ]; + } + } + + return [ + isFavorite + ? MobileViewItemBottomSheetBodyAction.removeFromFavorites + : MobileViewItemBottomSheetBodyAction.addToFavorites, + MobileViewItemBottomSheetBodyAction.divider, + MobileViewItemBottomSheetBodyAction.rename, + if (view.layout != ViewLayoutPB.Chat) + MobileViewItemBottomSheetBodyAction.duplicate, + MobileViewItemBottomSheetBodyAction.divider, + MobileViewItemBottomSheetBodyAction.delete, + ]; + } +} + +ActionPane buildEndActionPane( + BuildContext context, + List actions, { + bool needSpace = true, + MobilePageCardType? cardType, + FolderSpaceType? spaceType, + required double spaceRatio, +}) { + return ActionPane( + motion: const ScrollMotion(), + extentRatio: actions.length / spaceRatio, + children: [ + if (needSpace) const HSpace(60), + ...actions.map( + (action) => action.actionButton( + context, + spaceType: spaceType, + cardType: cardType, + ), + ), + ], + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart new file mode 100644 index 0000000000000..aa6661a834bc3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart @@ -0,0 +1,289 @@ +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart'; +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +extension BottomSheetPaddingExtension on BuildContext { + /// Calculates the total amount of space that should be added to the bottom of + /// a bottom sheet + double bottomSheetPadding({ + bool ignoreViewPadding = true, + }) { + final viewPadding = MediaQuery.viewPaddingOf(this); + final viewInsets = MediaQuery.viewInsetsOf(this); + double bottom = 0.0; + if (!ignoreViewPadding) { + bottom += viewPadding.bottom; + } + // for screens with 0 view padding, add some even more space + bottom += viewPadding.bottom == 0 ? 28.0 : 16.0; + bottom += viewInsets.bottom; + return bottom; + } +} + +Future showMobileBottomSheet( + BuildContext context, { + required WidgetBuilder builder, + bool useSafeArea = true, + bool isDragEnabled = true, + bool showDragHandle = false, + bool showHeader = false, + // this field is only used if showHeader is true + bool showBackButton = false, + bool showCloseButton = false, + bool showRemoveButton = false, + VoidCallback? onRemove, + // this field is only used if showHeader is true + String title = '', + bool isScrollControlled = true, + bool showDivider = true, + bool useRootNavigator = false, + ShapeBorder? shape, + // the padding of the content, the padding of the header area is fixed + EdgeInsets padding = EdgeInsets.zero, + Color? backgroundColor, + BoxConstraints? constraints, + Color? barrierColor, + double? elevation, + bool showDoneButton = false, + void Function(BuildContext context)? onDone, + bool enableDraggableScrollable = false, + bool enableScrollable = false, + // this field is only used if showDragHandle is true + Widget Function(BuildContext, ScrollController)? scrollableWidgetBuilder, + // only used when enableDraggableScrollable is true + double minChildSize = 0.5, + double maxChildSize = 0.8, + double initialChildSize = 0.51, + double bottomSheetPadding = 0, +}) async { + assert( + showHeader || + title.isEmpty && !showCloseButton && !showBackButton && !showDoneButton, + ); + assert(!(showCloseButton && showBackButton)); + + shape ??= const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(16), + ), + ); + + backgroundColor ??= Theme.of(context).brightness == Brightness.light + ? const Color(0xFFF7F8FB) + : const Color(0xFF23262B); + barrierColor ??= Colors.black.withOpacity(0.3); + + return showModalBottomSheet( + context: context, + isScrollControlled: isScrollControlled, + enableDrag: isDragEnabled, + useSafeArea: true, + clipBehavior: Clip.antiAlias, + constraints: constraints, + barrierColor: barrierColor, + elevation: elevation, + backgroundColor: backgroundColor, + shape: shape, + useRootNavigator: useRootNavigator, + builder: (context) { + final List children = []; + + final Widget child = builder(context); + + // if the children is only one, we don't need to wrap it with a column + if (!showDragHandle && !showHeader && !showDivider) { + return child; + } + + // ----- header area ----- + if (showDragHandle) { + children.add( + const DragHandle(), + ); + } + + if (showHeader) { + children.add( + BottomSheetHeader( + showCloseButton: showCloseButton, + showBackButton: showBackButton, + showDoneButton: showDoneButton, + showRemoveButton: showRemoveButton, + title: title, + onRemove: onRemove, + onDone: onDone, + ), + ); + + if (showDivider) { + children.add( + const Divider(height: 0.5, thickness: 0.5), + ); + } + } + + // ----- header area ----- + + if (enableDraggableScrollable) { + final keyboardSize = + context.bottomSheetPadding() / MediaQuery.of(context).size.height; + return DraggableScrollableSheet( + expand: false, + snap: true, + initialChildSize: (initialChildSize + keyboardSize).clamp(0, 1), + minChildSize: (minChildSize + keyboardSize).clamp(0, 1.0), + maxChildSize: (maxChildSize + keyboardSize).clamp(0, 1.0), + builder: (context, scrollController) { + return Column( + children: [ + ...children, + scrollableWidgetBuilder?.call( + context, + scrollController, + ) ?? + Expanded( + child: Scrollbar( + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: child, + ), + ), + ), + ], + ); + }, + ); + } else if (enableScrollable) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...children, + Flexible( + child: SingleChildScrollView( + child: child, + ), + ), + VSpace(bottomSheetPadding), + ], + ); + } + + // ----- content area ----- + // add content padding and extra bottom padding + children.add( + Padding( + padding: + padding + EdgeInsets.only(bottom: context.bottomSheetPadding()), + child: child, + ), + ); + // ----- content area ----- + + if (children.length == 1) { + return children.first; + } + + return useSafeArea + ? SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: children, + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: children, + ); + }, + ); +} + +class BottomSheetHeader extends StatelessWidget { + const BottomSheetHeader({ + super.key, + required this.showBackButton, + required this.showCloseButton, + required this.showRemoveButton, + required this.title, + required this.showDoneButton, + this.onRemove, + this.onDone, + this.onBack, + this.onClose, + }); + + final String title; + + final bool showBackButton; + final bool showCloseButton; + final bool showRemoveButton; + final bool showDoneButton; + + final VoidCallback? onRemove; + final VoidCallback? onBack; + final VoidCallback? onClose; + + final void Function(BuildContext context)? onDone; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: SizedBox( + height: 44.0, // the height of the header area is fixed + child: Stack( + children: [ + if (showBackButton) + Align( + alignment: Alignment.centerLeft, + child: BottomSheetBackButton( + onTap: onBack, + ), + ), + if (showCloseButton) + Align( + alignment: Alignment.centerLeft, + child: BottomSheetCloseButton( + onTap: onClose, + ), + ), + if (showRemoveButton) + Align( + alignment: Alignment.centerLeft, + child: BottomSheetRemoveButton( + onRemove: () => onRemove?.call(), + ), + ), + Align( + child: Container( + constraints: const BoxConstraints(maxWidth: 250), + child: FlowyText( + title, + fontSize: 17.0, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + ), + ), + ), + if (showDoneButton) + Align( + alignment: Alignment.centerRight, + child: BottomSheetDoneButton( + onDone: () { + if (onDone != null) { + onDone?.call(context); + } else { + Navigator.pop(context); + } + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart new file mode 100644 index 0000000000000..0ff2a6634ab3f --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart @@ -0,0 +1,346 @@ +import 'dart:math' as math; + +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:sheet/route.dart'; +import 'package:sheet/sheet.dart'; + +import 'show_mobile_bottom_sheet.dart'; + +Future showTransitionMobileBottomSheet( + BuildContext context, { + required WidgetBuilder builder, + bool useRootNavigator = false, + EdgeInsets contentPadding = EdgeInsets.zero, + Color? backgroundColor, + // drag handle + bool showDragHandle = false, + // header + bool showHeader = false, + String title = '', + bool showBackButton = false, + bool showCloseButton = false, + bool showDoneButton = false, + bool showDivider = true, + // stops + double initialStop = 1.0, + List? stops, +}) { + assert( + showHeader || + title.isEmpty && + !showCloseButton && + !showBackButton && + !showDoneButton && + !showDivider, + ); + assert(!(showCloseButton && showBackButton)); + + backgroundColor ??= Theme.of(context).brightness == Brightness.light + ? const Color(0xFFF7F8FB) + : const Color(0xFF23262B); + + return Navigator.of( + context, + rootNavigator: useRootNavigator, + ).push( + TransitionSheetRoute( + backgroundColor: backgroundColor, + initialStop: initialStop, + stops: stops, + builder: (context) { + final Widget child = builder(context); + + // if the children is only one, we don't need to wrap it with a column + if (!showDragHandle && !showHeader) { + return child; + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showDragHandle) const DragHandle(), + if (showHeader) ...[ + BottomSheetHeader( + showCloseButton: showCloseButton, + showBackButton: showBackButton, + showDoneButton: showDoneButton, + showRemoveButton: false, + title: title, + ), + if (showDivider) + const Divider( + height: 0.5, + thickness: 0.5, + ), + ], + Expanded( + child: Padding( + padding: contentPadding, + child: child, + ), + ), + ], + ); + }, + ), + ); +} + +/// The top offset that will be displayed from the bottom route +const double _kPreviousRouteVisibleOffset = 10.0; + +/// Minimal distance from the top of the screen to the top of the previous route +/// It will be used ff the top safe area is less than this value. +/// In iPhones the top SafeArea is more or equal to this distance. +const double _kSheetMinimalOffset = 10; + +const Curve _kCupertinoSheetCurve = Curves.easeOutExpo; +const Curve _kCupertinoTransitionCurve = Curves.linear; + +/// Wraps the child into a cupertino modal sheet appearance. This is used to +/// create a [SheetRoute]. +/// +/// Clip the child widget to rectangle with top rounded corners and adds +/// top padding and top safe area. +class _CupertinoSheetDecorationBuilder extends StatelessWidget { + const _CupertinoSheetDecorationBuilder({ + required this.child, + required this.topRadius, + this.backgroundColor, + }); + + /// The child contained by the modal sheet + final Widget child; + + /// The color to paint behind the child + final Color? backgroundColor; + + /// The top corners of this modal sheet are rounded by this Radius + final Radius topRadius; + + @override + Widget build(BuildContext context) { + return Builder( + builder: (BuildContext context) { + return Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.vertical(top: topRadius), + color: backgroundColor, + ), + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: child, + ), + ); + }, + ); + } +} + +/// Customized CupertinoSheetRoute from the sheets package +/// +/// A modal route that overlays a widget over the current route and animates +/// it from the bottom with a cupertino modal sheet appearance +/// +/// Clip the child widget to rectangle with top rounded corners and adds +/// top padding and top safe area. +class TransitionSheetRoute extends SheetRoute { + TransitionSheetRoute({ + required WidgetBuilder builder, + super.stops, + double initialStop = 1.0, + super.settings, + Color? backgroundColor, + super.maintainState = true, + super.fit, + }) : super( + builder: (BuildContext context) { + return _CupertinoSheetDecorationBuilder( + backgroundColor: backgroundColor, + topRadius: const Radius.circular(16), + child: Builder(builder: builder), + ); + }, + animationCurve: _kCupertinoSheetCurve, + initialExtent: initialStop, + ); + + @override + bool get draggable => true; + + final SheetController _sheetController = SheetController(); + + @override + SheetController createSheetController() => _sheetController; + + @override + Color? get barrierColor => Colors.transparent; + + @override + bool get barrierDismissible => true; + + @override + Widget buildSheet(BuildContext context, Widget child) { + final effectivePhysics = draggable + ? BouncingSheetPhysics( + parent: SnapSheetPhysics( + stops: stops ?? [0, 1], + parent: physics, + ), + ) + : const NeverDraggableSheetPhysics(); + final MediaQueryData mediaQuery = MediaQuery.of(context); + final double topMargin = + math.max(_kSheetMinimalOffset, mediaQuery.padding.top) + + _kPreviousRouteVisibleOffset; + return Sheet.raw( + initialExtent: initialExtent, + decorationBuilder: decorationBuilder, + fit: fit, + maxExtent: mediaQuery.size.height - topMargin, + physics: effectivePhysics, + controller: sheetController, + child: child, + ); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final double topPadding = MediaQuery.of(context).padding.top; + final double topOffset = math.max(_kSheetMinimalOffset, topPadding); + return AnimatedBuilder( + animation: secondaryAnimation, + child: child, + builder: (BuildContext context, Widget? child) { + final double progress = secondaryAnimation.value; + final double scale = 1 - progress / 10; + final double distanceWithScale = + (topOffset + _kPreviousRouteVisibleOffset) * 0.9; + final Offset offset = + Offset(0, progress * (topOffset - distanceWithScale)); + return Transform.translate( + offset: offset, + child: Transform.scale( + scale: scale, + alignment: Alignment.topCenter, + child: child, + ), + ); + }, + ); + } + + @override + bool canDriveSecondaryTransitionForPreviousRoute( + Route previousRoute, + ) => + true; + + @override + Widget buildSecondaryTransitionForPreviousRoute( + BuildContext context, + Animation secondaryAnimation, + Widget child, + ) { + final Animation delayAnimation = CurvedAnimation( + parent: _sheetController.animation, + curve: Interval( + initialExtent == 1 ? 0 : initialExtent, + 1, + ), + ); + + final Animation secondaryAnimation = CurvedAnimation( + parent: _sheetController.animation, + curve: Interval( + 0, + initialExtent, + ), + ); + + return CupertinoSheetBottomRouteTransition( + body: child, + sheetAnimation: delayAnimation, + secondaryAnimation: secondaryAnimation, + ); + } +} + +/// Animation for previous route when a [TransitionSheetRoute] enters/exits +@visibleForTesting +class CupertinoSheetBottomRouteTransition extends StatelessWidget { + const CupertinoSheetBottomRouteTransition({ + super.key, + required this.sheetAnimation, + required this.secondaryAnimation, + required this.body, + }); + + final Widget body; + + final Animation sheetAnimation; + final Animation secondaryAnimation; + + @override + Widget build(BuildContext context) { + final double topPadding = MediaQuery.of(context).padding.top; + final double topOffset = math.max(_kSheetMinimalOffset, topPadding); + + final CurvedAnimation curvedAnimation = CurvedAnimation( + parent: sheetAnimation, + curve: _kCupertinoTransitionCurve, + ); + + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: AnimatedBuilder( + animation: secondaryAnimation, + child: body, + builder: (BuildContext context, Widget? child) { + final double progress = curvedAnimation.value; + final double scale = 1 - progress / 10; + return Stack( + children: [ + Container(color: Colors.black), + Transform.translate( + offset: Offset(0, progress * topOffset), + child: Transform.scale( + scale: scale, + alignment: Alignment.topCenter, + child: ClipRRect( + borderRadius: BorderRadius.vertical( + top: Radius.lerp( + Radius.zero, + const Radius.circular(16.0), + progress, + )!, + ), + child: ColorFiltered( + colorFilter: ColorFilter.mode( + (Theme.of(context).brightness == Brightness.dark + ? Colors.grey + : Colors.black) + .withOpacity(secondaryAnimation.value * 0.1), + BlendMode.srcOver, + ), + child: child, + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/chat/mobile_chat_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/chat/mobile_chat_screen.dart new file mode 100644 index 0000000000000..31fcbdcdfd0b2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/chat/mobile_chat_screen.dart @@ -0,0 +1,28 @@ +import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; + +class MobileChatScreen extends StatelessWidget { + const MobileChatScreen({ + super.key, + required this.id, + this.title, + }); + + /// view id + final String id; + final String? title; + + static const routeName = '/chat'; + static const viewId = 'id'; + static const viewTitle = 'title'; + + @override + Widget build(BuildContext context) { + return MobileViewPage( + id: id, + title: title, + viewLayout: ViewLayoutPB.Chat, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/board.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/board.dart new file mode 100644 index 0000000000000..642a7ebeae725 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/board.dart @@ -0,0 +1,4 @@ +export 'mobile_board_screen.dart'; +export 'mobile_board_page.dart'; +export 'widgets/mobile_hidden_groups_column.dart'; +export 'widgets/mobile_board_trailing.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart new file mode 100644 index 0000000000000..2885f37bbd48e --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_page.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/database/board/board.dart'; +import 'package:appflowy/mobile/presentation/database/board/widgets/group_card_header.dart'; +import 'package:appflowy/mobile/presentation/database/card/card.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/card/card.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class MobileBoardPage extends StatefulWidget { + const MobileBoardPage({ + super.key, + required this.view, + required this.databaseController, + this.onEditStateChanged, + }); + + final ViewPB view; + + final DatabaseController databaseController; + + /// Called when edit state changed + final VoidCallback? onEditStateChanged; + + @override + State createState() => _MobileBoardPageState(); +} + +class _MobileBoardPageState extends State { + late final ValueNotifier _didCreateRow; + + @override + void initState() { + super.initState(); + _didCreateRow = ValueNotifier(null)..addListener(_handleDidCreateRow); + } + + @override + void dispose() { + _didCreateRow + ..removeListener(_handleDidCreateRow) + ..dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => BoardBloc( + databaseController: widget.databaseController, + didCreateRow: _didCreateRow, + )..add(const BoardEvent.initial()), + child: BlocBuilder( + builder: (context, state) => state.maybeMap( + loading: (_) => const Center( + child: CircularProgressIndicator.adaptive(), + ), + error: (err) => Center( + child: AppFlowyErrorPage( + error: err.error, + ), + ), + ready: (data) => const _BoardContent(), + orElse: () => const SizedBox.shrink(), + ), + ), + ); + } + + void _handleDidCreateRow() { + if (_didCreateRow.value != null) { + final result = _didCreateRow.value!; + switch (result.action) { + case DidCreateRowAction.openAsPage: + context.push( + MobileRowDetailPage.routeName, + extra: { + MobileRowDetailPage.argRowId: result.rowMeta.id, + MobileRowDetailPage.argDatabaseController: + widget.databaseController, + }, + ); + break; + default: + break; + } + } + } +} + +class _BoardContent extends StatefulWidget { + const _BoardContent(); + + @override + State<_BoardContent> createState() => _BoardContentState(); +} + +class _BoardContentState extends State<_BoardContent> { + late final ScrollController scrollController; + + @override + void initState() { + super.initState(); + scrollController = ScrollController(); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final config = AppFlowyBoardConfig( + groupCornerRadius: 8, + groupBackgroundColor: Theme.of(context).colorScheme.secondary, + groupMargin: const EdgeInsets.fromLTRB(4, 0, 4, 12), + groupHeaderPadding: const EdgeInsets.all(8), + groupBodyPadding: const EdgeInsets.all(4), + groupFooterPadding: const EdgeInsets.all(8), + cardMargin: const EdgeInsets.all(4), + ); + + return BlocBuilder( + builder: (context, state) { + return state.maybeMap( + orElse: () => const SizedBox.shrink(), + ready: (state) { + final showCreateGroupButton = context + .read() + .groupingFieldType + ?.canCreateNewGroup ?? + false; + final showHiddenGroups = state.hiddenGroups.isNotEmpty; + return AppFlowyBoard( + scrollController: scrollController, + controller: context.read().boardController, + groupConstraints: + BoxConstraints.tightFor(width: screenWidth * 0.7), + config: config, + leading: showHiddenGroups + ? MobileHiddenGroupsColumn( + padding: config.groupHeaderPadding, + ) + : const HSpace(16), + trailing: showCreateGroupButton + ? const MobileBoardTrailing() + : const HSpace(16), + headerBuilder: (_, groupData) => BlocProvider.value( + value: context.read(), + child: GroupCardHeader( + groupData: groupData, + ), + ), + footerBuilder: _buildFooter, + cardBuilder: (_, column, columnItem) => _buildCard( + context: context, + afGroupData: column, + afGroupItem: columnItem, + cardMargin: config.cardMargin, + ), + ); + }, + ); + }, + ); + } + + Widget _buildFooter(BuildContext context, AppFlowyGroupData columnData) { + final style = Theme.of(context); + + return SizedBox( + height: 42, + width: double.infinity, + child: TextButton.icon( + style: TextButton.styleFrom( + padding: const EdgeInsets.only(left: 8), + alignment: Alignment.centerLeft, + ), + icon: FlowySvg( + FlowySvgs.add_m, + color: style.colorScheme.onSurface, + ), + label: Text( + LocaleKeys.board_column_createNewCard.tr(), + style: style.textTheme.bodyMedium?.copyWith( + color: style.colorScheme.onSurface, + ), + ), + onPressed: () => context.read().add( + BoardEvent.createRow( + columnData.id, + OrderObjectPositionTypePB.End, + null, + null, + ), + ), + ), + ); + } + + Widget _buildCard({ + required BuildContext context, + required AppFlowyGroupData afGroupData, + required AppFlowyGroupItem afGroupItem, + required EdgeInsets cardMargin, + }) { + final boardBloc = context.read(); + final groupItem = afGroupItem as GroupItem; + final groupData = afGroupData.customData as GroupData; + final rowMeta = groupItem.row; + + final cellBuilder = + CardCellBuilder(databaseController: boardBloc.databaseController); + + final groupItemId = groupItem.row.id + groupData.group.groupId; + + return Container( + key: ValueKey(groupItemId), + margin: cardMargin, + decoration: _makeBoxDecoration(context), + child: BlocProvider.value( + value: boardBloc, + child: RowCard( + fieldController: boardBloc.fieldController, + rowMeta: rowMeta, + viewId: boardBloc.viewId, + rowCache: boardBloc.rowCache, + groupingFieldId: groupItem.fieldInfo.id, + isEditing: false, + cellBuilder: cellBuilder, + onTap: (context) { + context.push( + MobileRowDetailPage.routeName, + extra: { + MobileRowDetailPage.argRowId: rowMeta.id, + MobileRowDetailPage.argDatabaseController: + context.read().databaseController, + }, + ); + }, + onStartEditing: () {}, + onEndEditing: () {}, + styleConfiguration: RowCardStyleConfiguration( + cellStyleMap: mobileBoardCardCellStyleMap(context), + showAccessory: false, + ), + userProfile: boardBloc.userProfile, + ), + ), + ); + } + + BoxDecoration _makeBoxDecoration(BuildContext context) { + final themeMode = context.read().state.themeMode; + return BoxDecoration( + color: AFThemeExtension.of(context).background, + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: themeMode == ThemeMode.light + ? Border.fromBorderSide( + BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ) + : null, + boxShadow: themeMode == ThemeMode.light + ? [ + BoxShadow( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ] + : null, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_screen.dart new file mode 100644 index 0000000000000..492a8cb347db1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/mobile_board_screen.dart @@ -0,0 +1,28 @@ +import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; + +class MobileBoardScreen extends StatelessWidget { + const MobileBoardScreen({ + super.key, + required this.id, + this.title, + }); + + /// view id + final String id; + final String? title; + + static const routeName = '/board'; + static const viewId = 'id'; + static const viewTitle = 'title'; + + @override + Widget build(BuildContext context) { + return MobileViewPage( + id: id, + title: title, + viewLayout: ViewLayoutPB.Document, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/group_card_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/group_card_header.dart new file mode 100644 index 0000000000000..ebc492de09a6d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/group_card_header.dart @@ -0,0 +1,188 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +// similar to [BoardColumnHeader] in Desktop +class GroupCardHeader extends StatefulWidget { + const GroupCardHeader({ + super.key, + required this.groupData, + }); + + final AppFlowyGroupData groupData; + + @override + State createState() => _GroupCardHeaderState(); +} + +class _GroupCardHeaderState extends State { + late final TextEditingController _controller = + TextEditingController.fromValue( + TextEditingValue( + selection: TextSelection.collapsed( + offset: widget.groupData.headerData.groupName.length, + ), + text: widget.groupData.headerData.groupName, + ), + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final boardCustomData = widget.groupData.customData as GroupData; + final titleTextStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w600, + ); + return BlocBuilder( + builder: (context, state) { + Widget title = Text( + widget.groupData.headerData.groupName, + style: titleTextStyle, + overflow: TextOverflow.ellipsis, + ); + + // header can be edited if it's not default group(no status) and the field type can be edited + if (!boardCustomData.group.isDefault && + boardCustomData.fieldType.canEditHeader) { + title = GestureDetector( + onTap: () => context + .read() + .add(BoardEvent.startEditingHeader(widget.groupData.id)), + child: Text( + widget.groupData.headerData.groupName, + style: titleTextStyle, + overflow: TextOverflow.ellipsis, + ), + ); + } + + final isEditing = state.maybeMap( + ready: (value) => value.editingHeaderId == widget.groupData.id, + orElse: () => false, + ); + + if (isEditing) { + title = TextField( + controller: _controller, + autofocus: true, + onEditingComplete: () => context.read().add( + BoardEvent.endEditingHeader( + widget.groupData.id, + _controller.text, + ), + ), + style: titleTextStyle, + onTapOutside: (_) => context.read().add( + // group header switch from TextField to Text + // group name won't be changed + BoardEvent.endEditingHeader(widget.groupData.id, null), + ), + ); + } + + return Padding( + padding: const EdgeInsets.only(left: 16), + child: SizedBox( + height: 42, + child: Row( + children: [ + _buildHeaderIcon(boardCustomData), + Expanded(child: title), + IconButton( + icon: Icon( + Icons.more_horiz_rounded, + color: Theme.of(context).colorScheme.onSurface, + ), + splashRadius: 5, + onPressed: () => showMobileBottomSheet( + context, + showDragHandle: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (_) => SeparatedColumn( + crossAxisAlignment: CrossAxisAlignment.stretch, + separatorBuilder: () => const Divider( + height: 8.5, + thickness: 0.5, + ), + children: [ + MobileQuickActionButton( + text: LocaleKeys.board_column_renameColumn.tr(), + icon: FlowySvgs.edit_s, + onTap: () { + context.read().add( + BoardEvent.startEditingHeader( + widget.groupData.id, + ), + ); + context.pop(); + }, + ), + MobileQuickActionButton( + text: LocaleKeys.board_column_hideColumn.tr(), + icon: FlowySvgs.hide_s, + onTap: () { + context.read().add( + BoardEvent.setGroupVisibility( + widget.groupData.customData.group + as GroupPB, + false, + ), + ); + context.pop(); + }, + ), + ], + ), + ), + ), + IconButton( + icon: Icon( + Icons.add, + color: Theme.of(context).colorScheme.onSurface, + ), + splashRadius: 5, + onPressed: () { + context.read().add( + BoardEvent.createRow( + widget.groupData.id, + OrderObjectPositionTypePB.Start, + null, + null, + ), + ); + }, + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildHeaderIcon(GroupData customData) => + switch (customData.fieldType) { + FieldType.Checkbox => FlowySvg( + customData.asCheckboxGroup()!.isCheck + ? FlowySvgs.check_filled_s + : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, + ), + _ => const SizedBox.shrink(), + }; +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_board_trailing.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_board_trailing.dart new file mode 100644 index 0000000000000..184bd901c15c2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_board_trailing.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Add new group +class MobileBoardTrailing extends StatefulWidget { + const MobileBoardTrailing({super.key}); + + @override + State createState() => _MobileBoardTrailingState(); +} + +class _MobileBoardTrailingState extends State { + final TextEditingController _textController = TextEditingController(); + + bool isEditing = false; + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.of(context).size; + final style = Theme.of(context); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + child: SizedBox( + width: screenSize.width * 0.7, + child: isEditing + ? DecoratedBox( + decoration: BoxDecoration( + color: style.colorScheme.secondary, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _textController, + autofocus: true, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + suffixIcon: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _textController.text.isNotEmpty ? 1 : 0, + child: Material( + color: Colors.transparent, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: IconButton( + icon: Icon( + Icons.close, + color: style.colorScheme.onSurface, + ), + onPressed: () => + setState(() => _textController.clear()), + ), + ), + ), + isDense: true, + ), + onEditingComplete: () { + context.read().add( + BoardEvent.createGroup( + _textController.text, + ), + ); + _textController.clear(); + setState(() => isEditing = false); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + child: Text( + LocaleKeys.button_cancel.tr(), + style: style.textTheme.titleSmall?.copyWith( + color: style.colorScheme.onSurface, + ), + ), + onPressed: () => setState(() => isEditing = false), + ), + TextButton( + child: Text( + LocaleKeys.button_add.tr(), + style: style.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: style.colorScheme.onSurface, + ), + ), + onPressed: () { + context.read().add( + BoardEvent.createGroup( + _textController.text, + ), + ); + _textController.clear(); + setState(() => isEditing = false); + }, + ), + ], + ), + ], + ), + ), + ) + : ElevatedButton.icon( + style: ElevatedButton.styleFrom( + foregroundColor: style.colorScheme.onSurface, + backgroundColor: style.colorScheme.secondary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ).copyWith( + overlayColor: + WidgetStateProperty.all(Theme.of(context).hoverColor), + ), + icon: const Icon(Icons.add), + label: Text( + LocaleKeys.board_column_newGroup.tr(), + style: style.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w600, + ), + ), + onPressed: () => setState(() => isEditing = true), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart new file mode 100644 index 0000000000000..f80525786ed6d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/mobile_hidden_groups_column.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/database/card/card.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy/plugins/database/board/group_ext.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class MobileHiddenGroupsColumn extends StatelessWidget { + const MobileHiddenGroupsColumn({super.key, required this.padding}); + + final EdgeInsets padding; + + @override + Widget build(BuildContext context) { + final databaseController = context.read().databaseController; + return BlocSelector( + selector: (state) => state.maybeMap( + orElse: () => null, + ready: (value) => value.layoutSettings, + ), + builder: (context, layoutSettings) { + if (layoutSettings == null) { + return const SizedBox.shrink(); + } + final isCollapsed = layoutSettings.collapseHiddenGroups; + return Container( + padding: padding, + child: AnimatedSize( + alignment: AlignmentDirectional.topStart, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 150), + child: isCollapsed + ? SizedBox( + height: 50, + child: _collapseExpandIcon(context, isCollapsed), + ) + : SizedBox( + width: 180, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Spacer(), + _collapseExpandIcon(context, isCollapsed), + ], + ), + Text( + LocaleKeys.board_hiddenGroupSection_sectionTitle.tr(), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context).colorScheme.tertiary, + ), + ), + const VSpace(8), + Expanded( + child: MobileHiddenGroupList( + databaseController: databaseController, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _collapseExpandIcon(BuildContext context, bool isCollapsed) { + return CircleAvatar( + radius: 20, + backgroundColor: Theme.of(context).colorScheme.secondary, + child: IconButton( + icon: FlowySvg( + isCollapsed + ? FlowySvgs.hamburger_s_s + : FlowySvgs.pull_left_outlined_s, + size: isCollapsed ? const Size.square(12) : const Size.square(40), + ), + onPressed: () => context + .read() + .add(BoardEvent.toggleHiddenSectionVisibility(!isCollapsed)), + ), + ); + } +} + +class MobileHiddenGroupList extends StatelessWidget { + const MobileHiddenGroupList({ + super.key, + required this.databaseController, + }); + + final DatabaseController databaseController; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (_, state) { + return state.maybeMap( + orElse: () => const SizedBox.shrink(), + ready: (state) { + return ReorderableListView.builder( + itemCount: state.hiddenGroups.length, + itemBuilder: (_, index) => MobileHiddenGroup( + key: ValueKey(state.hiddenGroups[index].groupId), + group: state.hiddenGroups[index], + index: index, + ), + proxyDecorator: (child, index, animation) => BlocProvider.value( + value: context.read(), + child: Material(color: Colors.transparent, child: child), + ), + physics: const ClampingScrollPhysics(), + onReorder: (oldIndex, newIndex) { + if (oldIndex < newIndex) { + newIndex--; + } + final fromGroupId = state.hiddenGroups[oldIndex].groupId; + final toGroupId = state.hiddenGroups[newIndex].groupId; + context + .read() + .add(BoardEvent.reorderGroup(fromGroupId, toGroupId)); + }, + ); + }, + ); + }, + ); + } +} + +class MobileHiddenGroup extends StatelessWidget { + const MobileHiddenGroup({ + super.key, + required this.group, + required this.index, + }); + + final GroupPB group; + final int index; + + @override + Widget build(BuildContext context) { + final databaseController = context.read().databaseController; + final primaryField = databaseController.fieldController.fieldInfos + .firstWhereOrNull((element) => element.isPrimary)!; + + final cells = group.rows.map( + (item) { + final cellContext = + databaseController.rowCache.loadCells(item).firstWhere( + (cellContext) => cellContext.fieldId == primaryField.id, + ); + + return TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.bodyMedium, + foregroundColor: AFThemeExtension.of(context).onBackground, + visualDensity: VisualDensity.compact, + ), + child: CardCellBuilder( + databaseController: context.read().databaseController, + ).build( + cellContext: cellContext, + styleMap: {FieldType.RichText: _titleCellStyle(context)}, + hasNotes: !item.isDocumentEmpty, + ), + onPressed: () { + context.push( + MobileRowDetailPage.routeName, + extra: { + MobileRowDetailPage.argRowId: item.id, + MobileRowDetailPage.argDatabaseController: + context.read().databaseController, + }, + ); + }, + ); + }, + ).toList(); + + return ExpansionTile( + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + title: Row( + children: [ + Expanded( + child: Text( + group.generateGroupName(databaseController), + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + GestureDetector( + child: const Padding( + padding: EdgeInsets.all(4), + child: FlowySvg( + FlowySvgs.hide_m, + size: Size.square(20), + ), + ), + onTap: () => showFlowyMobileConfirmDialog( + context, + title: FlowyText(LocaleKeys.board_mobile_showGroup.tr()), + content: FlowyText( + LocaleKeys.board_mobile_showGroupContent.tr(), + ), + actionButtonTitle: LocaleKeys.button_yes.tr(), + actionButtonColor: Theme.of(context).colorScheme.primary, + onActionButtonPressed: () => context + .read() + .add(BoardEvent.setGroupVisibility(group, true)), + ), + ), + ], + ), + children: cells, + ); + } + + TextCardCellStyle _titleCellStyle(BuildContext context) { + return TextCardCellStyle( + padding: EdgeInsets.zero, + textStyle: Theme.of(context).textTheme.bodyMedium!, + maxLines: 2, + titleTextStyle: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(fontSize: 11, overflow: TextOverflow.ellipsis), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/widgets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/widgets.dart new file mode 100644 index 0000000000000..0b1d45ab65bac --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/board/widgets/widgets.dart @@ -0,0 +1,3 @@ +export 'group_card_header.dart'; +export 'mobile_board_trailing.dart'; +export 'mobile_hidden_groups_column.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card.dart new file mode 100644 index 0000000000000..64f41eceac484 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card.dart @@ -0,0 +1,2 @@ +export 'card_detail/mobile_card_detail_screen.dart'; +export 'mobile_card_content.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart new file mode 100644 index 0000000000000..0982354e36d0d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart @@ -0,0 +1,575 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_banner_bloc.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/grid/application/row/mobile_row_detail_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_property.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; +import 'package:appflowy/shared/af_image.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:go_router/go_router.dart'; + +import 'widgets/mobile_create_field_button.dart'; +import 'widgets/mobile_row_property_list.dart'; + +class MobileRowDetailPage extends StatefulWidget { + const MobileRowDetailPage({ + super.key, + required this.databaseController, + required this.rowId, + }); + + static const routeName = '/MobileRowDetailPage'; + static const argDatabaseController = 'databaseController'; + static const argRowId = 'rowId'; + + final DatabaseController databaseController; + final String rowId; + + @override + State createState() => _MobileRowDetailPageState(); +} + +class _MobileRowDetailPageState extends State { + late final MobileRowDetailBloc _bloc; + late final PageController _pageController; + + String get viewId => widget.databaseController.viewId; + RowCache get rowCache => widget.databaseController.rowCache; + FieldController get fieldController => + widget.databaseController.fieldController; + + @override + void initState() { + super.initState(); + _bloc = MobileRowDetailBloc( + databaseController: widget.databaseController, + )..add(MobileRowDetailEvent.initial(widget.rowId)); + final initialPage = rowCache.rowInfos + .indexWhere((rowInfo) => rowInfo.rowId == widget.rowId); + _pageController = + PageController(initialPage: initialPage == -1 ? 0 : initialPage); + } + + @override + void dispose() { + _bloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _bloc, + child: Scaffold( + appBar: FlowyAppBar( + leadingType: FlowyAppBarLeadingType.close, + showDivider: false, + actions: [ + AppBarMoreButton( + onTap: (_) => _showCardActions(context), + ), + ], + ), + body: BlocBuilder( + buildWhen: (previous, current) => + previous.rowInfos.length != current.rowInfos.length, + builder: (context, state) { + if (state.isLoading) { + return const SizedBox.shrink(); + } + return PageView.builder( + controller: _pageController, + onPageChanged: (page) { + final rowId = _bloc.state.rowInfos[page].rowId; + _bloc.add(MobileRowDetailEvent.changeRowId(rowId)); + }, + itemCount: state.rowInfos.length, + itemBuilder: (context, index) { + if (state.rowInfos.isEmpty || state.currentRowId == null) { + return const SizedBox.shrink(); + } + return MobileRowDetailPageContent( + databaseController: widget.databaseController, + rowMeta: state.rowInfos[index].rowMeta, + ); + }, + ); + }, + ), + floatingActionButton: RowDetailFab( + onTapPrevious: () => _pageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.ease, + ), + onTapNext: () => _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.ease, + ), + ), + ), + ); + } + + void _showCardActions(BuildContext context) { + showMobileBottomSheet( + context, + backgroundColor: AFThemeExtension.of(context).background, + showDragHandle: true, + builder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + MobileQuickActionButton( + onTap: () => + _performAction(viewId, _bloc.state.currentRowId, false), + icon: FlowySvgs.duplicate_s, + text: LocaleKeys.button_duplicate.tr(), + ), + const Divider(height: 8.5, thickness: 0.5), + MobileQuickActionButton( + onTap: () => showMobileBottomSheet( + context, + title: LocaleKeys.grid_media_addFileMobile.tr(), + showHeader: true, + showCloseButton: true, + showDragHandle: true, + builder: (dialogContext) => Container( + margin: const EdgeInsets.only(top: 12), + constraints: const BoxConstraints( + maxHeight: 340, + minHeight: 80, + ), + child: FileUploadMenu( + onInsertLocalFile: (files) async { + context + ..pop() + ..pop(); + + if (_bloc.state.currentRowId == null) { + return; + } + + await insertLocalFiles( + context, + files, + userProfile: _bloc.userProfile, + documentId: _bloc.state.currentRowId!, + onUploadSuccess: (file, path, isLocalMode) { + _bloc.add( + MobileRowDetailEvent.addCover( + RowCoverPB( + data: path, + uploadType: isLocalMode + ? FileUploadTypePB.LocalFile + : FileUploadTypePB.CloudFile, + coverType: CoverTypePB.FileCover, + ), + ), + ); + }, + ); + }, + onInsertNetworkFile: (url) async => + _onInsertNetworkFile(url, context), + ), + ), + ), + icon: FlowySvgs.add_cover_s, + text: 'Add cover', + ), + const Divider(height: 8.5, thickness: 0.5), + MobileQuickActionButton( + onTap: () => _performAction(viewId, _bloc.state.currentRowId, true), + text: LocaleKeys.button_delete.tr(), + textColor: Theme.of(context).colorScheme.error, + icon: FlowySvgs.trash_s, + iconColor: Theme.of(context).colorScheme.error, + ), + const Divider(height: 8.5, thickness: 0.5), + ], + ), + ); + } + + void _performAction(String viewId, String? rowId, bool deleteRow) { + if (rowId == null) { + return; + } + + deleteRow + ? RowBackendService.deleteRows(viewId, [rowId]) + : RowBackendService.duplicateRow(viewId, rowId); + + context + ..pop() + ..pop(); + Fluttertoast.showToast( + msg: deleteRow + ? LocaleKeys.board_cardDeleted.tr() + : LocaleKeys.board_cardDuplicated.tr(), + gravity: ToastGravity.BOTTOM, + ); + } + + Future _onInsertNetworkFile( + String url, + BuildContext context, + ) async { + context + ..pop() + ..pop(); + + if (url.isEmpty) return; + final uri = Uri.tryParse(url); + if (uri == null) { + return; + } + + String name = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : ""; + if (name.isEmpty && uri.pathSegments.length > 1) { + name = uri.pathSegments[uri.pathSegments.length - 2]; + } else if (name.isEmpty) { + name = uri.host; + } + + _bloc.add( + MobileRowDetailEvent.addCover( + RowCoverPB( + data: url, + uploadType: FileUploadTypePB.NetworkFile, + coverType: CoverTypePB.FileCover, + ), + ), + ); + } +} + +class RowDetailFab extends StatelessWidget { + const RowDetailFab({ + super.key, + required this.onTapPrevious, + required this.onTapNext, + }); + + final VoidCallback onTapPrevious; + final VoidCallback onTapNext; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final rowCount = state.rowInfos.length; + final rowIndex = state.rowInfos.indexWhere( + (rowInfo) => rowInfo.rowId == state.currentRowId, + ); + if (rowIndex == -1 || rowCount == 0) { + return const SizedBox.shrink(); + } + + final previousDisabled = rowIndex == 0; + final nextDisabled = rowIndex == rowCount - 1; + + return IntrinsicWidth( + child: Container( + height: 48, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(26), + boxShadow: const [ + BoxShadow( + offset: Offset(0, 8), + blurRadius: 20, + color: Color(0x191F2329), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.square( + dimension: 48, + child: Material( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(26), + borderOnForeground: false, + child: previousDisabled + ? Icon( + Icons.chevron_left_outlined, + color: Theme.of(context).disabledColor, + ) + : InkWell( + borderRadius: BorderRadius.circular(26), + onTap: onTapPrevious, + child: const Icon(Icons.chevron_left_outlined), + ), + ), + ), + FlowyText.medium( + "${rowIndex + 1} / $rowCount", + fontSize: 14, + ), + SizedBox.square( + dimension: 48, + child: Material( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(26), + borderOnForeground: false, + child: nextDisabled + ? Icon( + Icons.chevron_right_outlined, + color: Theme.of(context).disabledColor, + ) + : InkWell( + borderRadius: BorderRadius.circular(26), + onTap: onTapNext, + child: const Icon(Icons.chevron_right_outlined), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class MobileRowDetailPageContent extends StatefulWidget { + const MobileRowDetailPageContent({ + super.key, + required this.databaseController, + required this.rowMeta, + }); + + final DatabaseController databaseController; + final RowMetaPB rowMeta; + + @override + State createState() => + MobileRowDetailPageContentState(); +} + +class MobileRowDetailPageContentState + extends State { + late final RowController rowController; + late final EditableCellBuilder cellBuilder; + + String get viewId => widget.databaseController.viewId; + RowCache get rowCache => widget.databaseController.rowCache; + FieldController get fieldController => + widget.databaseController.fieldController; + ValueNotifier primaryFieldId = ValueNotifier(''); + + @override + void initState() { + super.initState(); + + rowController = RowController( + rowMeta: widget.rowMeta, + viewId: viewId, + rowCache: rowCache, + ); + rowController.initialize(); + + cellBuilder = EditableCellBuilder( + databaseController: widget.databaseController, + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => RowDetailBloc( + fieldController: fieldController, + rowController: rowController, + ), + child: BlocBuilder( + builder: (context, rowDetailState) => Column( + children: [ + if (rowDetailState.rowMeta.cover.data.isNotEmpty) ...[ + GestureDetector( + onTap: () => showMobileBottomSheet( + context, + backgroundColor: AFThemeExtension.of(context).background, + showDragHandle: true, + builder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + MobileQuickActionButton( + onTap: () { + context + ..pop() + ..read() + .add(const RowDetailEvent.removeCover()); + }, + text: LocaleKeys.button_delete.tr(), + textColor: Theme.of(context).colorScheme.error, + icon: FlowySvgs.trash_s, + iconColor: Theme.of(context).colorScheme.error, + ), + ], + ), + ), + child: SizedBox( + height: 200, + width: double.infinity, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + ), + child: AFImage( + url: rowDetailState.rowMeta.cover.data, + uploadType: widget.rowMeta.cover.uploadType, + userProfile: + context.read().userProfile, + ), + ), + ), + ), + ], + BlocProvider( + create: (context) => RowBannerBloc( + viewId: viewId, + fieldController: fieldController, + rowMeta: rowController.rowMeta, + )..add(const RowBannerEvent.initial()), + child: BlocConsumer( + listener: (context, state) { + if (state.primaryField == null) { + return; + } + primaryFieldId.value = state.primaryField!.id; + }, + builder: (context, state) { + if (state.primaryField == null) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: cellBuilder.buildCustom( + CellContext( + rowId: rowController.rowId, + fieldId: state.primaryField!.id, + ), + skinMap: EditableCellSkinMap(textSkin: _TitleSkin()), + ), + ); + }, + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.only(top: 9, bottom: 100), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: MobileRowPropertyList( + databaseController: widget.databaseController, + cellBuilder: cellBuilder, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(6, 6, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (rowDetailState.numHiddenFields != 0) ...[ + const ToggleHiddenFieldsVisibilityButton(), + ], + const VSpace(8.0), + ValueListenableBuilder( + valueListenable: primaryFieldId, + builder: (context, primaryFieldId, child) { + if (primaryFieldId.isEmpty) { + return const SizedBox.shrink(); + } + return OpenRowPageButton( + databaseController: widget.databaseController, + cellContext: CellContext( + rowId: rowController.rowId, + fieldId: primaryFieldId, + ), + documentId: rowController.rowMeta.documentId, + ); + }, + ), + MobileRowDetailCreateFieldButton( + viewId: viewId, + fieldController: fieldController, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _TitleSkin extends IEditableTextCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TextCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + maxLines: null, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 23, + fontWeight: FontWeight.w500, + ), + onEditingComplete: () { + bloc.add(TextCellEvent.updateText(textEditingController.text)); + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(vertical: 9), + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + hintText: LocaleKeys.grid_row_titlePlaceholder.tr(), + isDense: true, + isCollapsed: true, + ), + onTapOutside: (event) => focusNode.unfocus(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_create_field_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_create_field_button.dart new file mode 100644 index 0000000000000..1d3d3efcf5579 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_create_field_button.dart @@ -0,0 +1,52 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_sheets.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileRowDetailCreateFieldButton extends StatelessWidget { + const MobileRowDetailCreateFieldButton({ + super.key, + required this.viewId, + required this.fieldController, + }); + + final String viewId; + final FieldController fieldController; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints( + minWidth: double.infinity, + maxHeight: GridSize.headerHeight, + ), + child: TextButton.icon( + style: Theme.of(context).textButtonTheme.style?.copyWith( + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + ), + overlayColor: WidgetStateProperty.all( + Theme.of(context).hoverColor, + ), + alignment: AlignmentDirectional.centerStart, + splashFactory: NoSplash.splashFactory, + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 6, vertical: 2), + ), + ), + label: FlowyText.medium( + LocaleKeys.grid_field_newProperty.tr(), + fontSize: 15, + ), + onPressed: () => mobileCreateFieldWorkflow(context, viewId), + icon: const FlowySvg(FlowySvgs.add_m), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_row_property_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_row_property_list.dart new file mode 100644 index 0000000000000..42bb241ab8757 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_row_property_list.dart @@ -0,0 +1,98 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileRowPropertyList extends StatelessWidget { + const MobileRowPropertyList({ + super.key, + required this.databaseController, + required this.cellBuilder, + }); + + final DatabaseController databaseController; + final EditableCellBuilder cellBuilder; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final List visibleCells = + state.visibleCells.where((cell) => !_isCellPrimary(cell)).toList(); + + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: visibleCells.length, + padding: EdgeInsets.zero, + itemBuilder: (context, index) => _PropertyCell( + key: ValueKey('row_detail_${visibleCells[index].fieldId}'), + cellContext: visibleCells[index], + fieldController: databaseController.fieldController, + cellBuilder: cellBuilder, + ), + separatorBuilder: (_, __) => const VSpace(22), + ); + }, + ); + } + + bool _isCellPrimary(CellContext cell) => + databaseController.fieldController.getField(cell.fieldId)!.isPrimary; +} + +class _PropertyCell extends StatefulWidget { + const _PropertyCell({ + super.key, + required this.cellContext, + required this.fieldController, + required this.cellBuilder, + }); + + final CellContext cellContext; + final FieldController fieldController; + final EditableCellBuilder cellBuilder; + + @override + State createState() => _PropertyCellState(); +} + +class _PropertyCellState extends State<_PropertyCell> { + @override + Widget build(BuildContext context) { + final fieldInfo = + widget.fieldController.getField(widget.cellContext.fieldId)!; + final cell = widget.cellBuilder + .buildStyled(widget.cellContext, EditableCellStyle.mobileRowDetail); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + FieldIcon( + fieldInfo: fieldInfo, + ), + const HSpace(6), + Expanded( + child: FlowyText.regular( + fieldInfo.name, + overflow: TextOverflow.ellipsis, + fontSize: 14, + figmaLineHeight: 16.0, + color: Theme.of(context).hintColor, + ), + ), + ], + ), + const VSpace(6), + cell, + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/option_text_field.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/option_text_field.dart new file mode 100644 index 0000000000000..e2614296aff77 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/option_text_field.dart @@ -0,0 +1,67 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_sheets.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class OptionTextField extends StatelessWidget { + const OptionTextField({ + super.key, + required this.controller, + this.autoFocus = false, + required this.isPrimary, + required this.fieldType, + required this.onTextChanged, + required this.onFieldTypeChanged, + }); + + final TextEditingController controller; + final bool autoFocus; + final bool isPrimary; + final FieldType fieldType; + final void Function(String value) onTextChanged; + final void Function(FieldType value) onFieldTypeChanged; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.textField( + controller: controller, + autofocus: autoFocus, + textFieldPadding: const EdgeInsets.symmetric(horizontal: 12.0), + onTextChanged: onTextChanged, + leftIcon: GestureDetector( + onTap: () async { + if (isPrimary) { + return; + } + final fieldType = await showFieldTypeGridBottomSheet( + context, + title: LocaleKeys.grid_field_editProperty.tr(), + ); + if (fieldType != null) { + onFieldTypeChanged(fieldType); + } + }, + child: Container( + height: 38, + width: 38, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Theme.of(context).brightness == Brightness.light + ? fieldType.mobileIconBackgroundColor + : fieldType.mobileIconBackgroundColorDark, + ), + child: Center( + child: FlowySvg( + fieldType.svgData, + size: const Size.square(22), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart new file mode 100644 index 0000000000000..d7ac40d66a66e --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/row_page_button.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class OpenRowPageButton extends StatefulWidget { + const OpenRowPageButton({ + super.key, + required this.documentId, + required this.databaseController, + required this.cellContext, + }); + + final String documentId; + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _OpenRowPageButtonState(); +} + +class _OpenRowPageButtonState extends State { + late final cellBloc = TextCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + ViewPB? view; + + @override + void initState() { + super.initState(); + + _preloadView(context, createDocumentIfMissed: true); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + bloc: cellBloc, + builder: (context, state) { + return ConstrainedBox( + constraints: BoxConstraints( + minWidth: double.infinity, + maxHeight: GridSize.buttonHeight, + ), + child: TextButton.icon( + style: Theme.of(context).textButtonTheme.style?.copyWith( + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + ), + overlayColor: WidgetStateProperty.all( + Theme.of(context).hoverColor, + ), + alignment: AlignmentDirectional.centerStart, + splashFactory: NoSplash.splashFactory, + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 6), + ), + ), + label: FlowyText.medium( + LocaleKeys.grid_field_openRowDocument.tr(), + fontSize: 15, + ), + icon: const Padding( + padding: EdgeInsets.all(4.0), + child: FlowySvg( + FlowySvgs.full_view_s, + size: Size.square(16.0), + ), + ), + onPressed: () { + final name = state.content; + _openRowPage(context, name ?? ""); + }, + ), + ); + }, + ); + } + + Future _openRowPage(BuildContext context, String fieldName) async { + Log.info('Open row page(${widget.documentId})'); + + if (view == null) { + showToastNotification(context, message: 'Failed to open row page'); + // reload the view again + unawaited(_preloadView(context)); + Log.error('Failed to open row page(${widget.documentId})'); + return; + } + + if (context.mounted) { + // the document in row is an orphan document, so we don't add it to recent + await context.pushView( + view!, + addInRecent: false, + showMoreButton: false, + fixedTitle: fieldName, + ); + } + } + + // preload view to reduce the time to open the view + Future _preloadView( + BuildContext context, { + bool createDocumentIfMissed = false, + }) async { + Log.info('Preload row page(${widget.documentId})'); + final result = await ViewBackendService.getView(widget.documentId); + view = result.fold((s) => s, (f) => null); + + if (view == null && createDocumentIfMissed) { + // create view if not exists + Log.info('Create row page(${widget.documentId})'); + final result = await ViewBackendService.createOrphanView( + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + viewId: widget.documentId, + layoutType: ViewLayoutPB.Document, + ); + view = result.fold((s) => s, (f) => null); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/widgets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/widgets.dart new file mode 100644 index 0000000000000..c972c3a331d78 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/widgets.dart @@ -0,0 +1,3 @@ +export 'mobile_create_field_button.dart'; +export 'mobile_row_property_list.dart'; +export 'option_text_field.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart new file mode 100644 index 0000000000000..aa9d23308ad9b --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/widgets/card/card.dart'; +import 'package:appflowy/plugins/database/widgets/card/card_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; + +class MobileCardContent extends StatelessWidget { + const MobileCardContent({ + super.key, + required this.rowMeta, + required this.cellBuilder, + required this.cells, + required this.styleConfiguration, + required this.userProfile, + }); + + final RowMetaPB rowMeta; + final CardCellBuilder cellBuilder; + final List cells; + final RowCardStyleConfiguration styleConfiguration; + final UserProfilePB? userProfile; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (rowMeta.cover.data.isNotEmpty) ...[ + CardCover(cover: rowMeta.cover, userProfile: userProfile), + ], + Padding( + padding: styleConfiguration.cardPadding, + child: Column( + children: [ + ...cells.map( + (cellMeta) => cellBuilder.build( + cellContext: cellMeta.cellContext(), + styleMap: mobileBoardCardCellStyleMap(context), + hasNotes: !rowMeta.isDocumentEmpty, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart new file mode 100644 index 0000000000000..8262cf6408016 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart @@ -0,0 +1,121 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/mobile_date_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileDateCellEditScreen extends StatefulWidget { + const MobileDateCellEditScreen({ + super.key, + required this.controller, + this.showAsFullScreen = true, + }); + + final DateCellController controller; + final bool showAsFullScreen; + + static const routeName = '/edit_date_cell'; + + // the type is DateCellController + static const dateCellController = 'date_cell_controller'; + + // bool value, default is true + static const fullScreen = 'full_screen'; + + @override + State createState() => + _MobileDateCellEditScreenState(); +} + +class _MobileDateCellEditScreenState extends State { + @override + Widget build(BuildContext context) => + widget.showAsFullScreen ? _buildFullScreen() : _buildNotFullScreen(); + + Widget _buildFullScreen() { + return Scaffold( + appBar: FlowyAppBar(titleText: LocaleKeys.titleBar_date.tr()), + body: _buildDatePicker(), + ); + } + + Widget _buildNotFullScreen() { + return DraggableScrollableSheet( + expand: false, + snap: true, + initialChildSize: 0.7, + minChildSize: 0.4, + snapSizes: const [0.4, 0.7, 1.0], + builder: (_, controller) => Material( + color: Colors.transparent, + child: ListView( + controller: controller, + children: [ + ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: const Center(child: DragHandle()), + ), + const MobileDateHeader(), + _buildDatePicker(), + ], + ), + ), + ); + } + + Widget _buildDatePicker() { + return BlocProvider( + create: (_) => DateCellEditorBloc( + reminderBloc: getIt(), + cellController: widget.controller, + ), + child: BlocBuilder( + builder: (context, state) { + final dateCellBloc = context.read(); + return MobileAppFlowyDatePicker( + dateTime: state.dateTime, + endDateTime: state.endDateTime, + isRange: state.isRange, + includeTime: state.includeTime, + dateFormat: state.dateTypeOptionPB.dateFormat, + timeFormat: state.dateTypeOptionPB.timeFormat, + reminderOption: state.reminderOption, + onDaySelected: (selectedDay) { + dateCellBloc.add(DateCellEditorEvent.updateDateTime(selectedDay)); + }, + onRangeSelected: (start, end) { + dateCellBloc.add(DateCellEditorEvent.updateDateRange(start, end)); + }, + onIsRangeChanged: (value, dateTime, endDateTime) { + dateCellBloc.add( + DateCellEditorEvent.setIsRange(value, dateTime, endDateTime), + ); + }, + onIncludeTimeChanged: (value, dateTime, endDateTime) { + dateCellBloc.add( + DateCellEditorEvent.setIncludeTime( + value, + dateTime, + endDateTime, + ), + ); + }, + onClearDate: () { + dateCellBloc.add(const DateCellEditorEvent.clearDate()); + }, + onReminderSelected: (option) { + dateCellBloc.add(DateCellEditorEvent.setReminderOption(option)); + }, + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_create_field_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_create_field_screen.dart new file mode 100644 index 0000000000000..5abb2dc031887 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_create_field_screen.dart @@ -0,0 +1,93 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/database/field/mobile_full_field_editor.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class MobileNewPropertyScreen extends StatefulWidget { + const MobileNewPropertyScreen({ + super.key, + required this.viewId, + this.fieldType, + }); + + final String viewId; + final FieldType? fieldType; + + static const routeName = '/new_property'; + static const argViewId = 'view_id'; + static const argFieldTypeId = 'field_type_id'; + + @override + State createState() => + _MobileNewPropertyScreenState(); +} + +class _MobileNewPropertyScreenState extends State { + late FieldOptionValues optionValues; + + @override + void initState() { + super.initState(); + + final type = widget.fieldType ?? FieldType.RichText; + optionValues = FieldOptionValues( + type: type, + icon: "", + name: type.i18n, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FlowyAppBar( + centerTitle: true, + titleText: LocaleKeys.grid_field_newProperty.tr(), + leadingType: FlowyAppBarLeadingType.cancel, + actions: [ + _SaveButton( + onSave: () { + context.pop(optionValues); + }, + ), + ], + ), + body: MobileFieldEditor( + mode: FieldOptionMode.add, + defaultValues: optionValues, + onOptionValuesChanged: (optionValues) { + this.optionValues = optionValues; + }, + ), + ); + } +} + +class _SaveButton extends StatelessWidget { + const _SaveButton({ + required this.onSave, + }); + + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Align( + child: GestureDetector( + onTap: onSave, + child: FlowyText.medium( + LocaleKeys.button_save.tr(), + color: const Color(0xFF00ADDC), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart new file mode 100644 index 0000000000000..a51e3561f8464 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/database/field/mobile_full_field_editor.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/domain/field_backend_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:go_router/go_router.dart'; + +class MobileEditPropertyScreen extends StatefulWidget { + const MobileEditPropertyScreen({ + super.key, + required this.viewId, + required this.field, + }); + + final String viewId; + final FieldInfo field; + + static const routeName = '/edit_property'; + static const argViewId = 'view_id'; + static const argField = 'field'; + + @override + State createState() => + _MobileEditPropertyScreenState(); +} + +class _MobileEditPropertyScreenState extends State { + late final FieldBackendService fieldService; + late FieldOptionValues _fieldOptionValues; + + @override + void initState() { + super.initState(); + _fieldOptionValues = FieldOptionValues.fromField(field: widget.field.field); + fieldService = FieldBackendService( + viewId: widget.viewId, + fieldId: widget.field.id, + ); + } + + @override + Widget build(BuildContext context) { + final viewId = widget.viewId; + final fieldId = widget.field.id; + + return PopScope( + onPopInvoked: (didPop) { + if (!didPop) { + context.pop(_fieldOptionValues); + } + }, + child: Scaffold( + appBar: FlowyAppBar( + titleText: LocaleKeys.grid_field_editProperty.tr(), + onTapLeading: () => context.pop(_fieldOptionValues), + ), + body: MobileFieldEditor( + mode: FieldOptionMode.edit, + isPrimary: widget.field.isPrimary, + defaultValues: FieldOptionValues.fromField(field: widget.field.field), + actions: [ + widget.field.visibility?.isVisibleState() ?? true + ? FieldOptionAction.hide + : FieldOptionAction.show, + FieldOptionAction.duplicate, + FieldOptionAction.delete, + ], + onOptionValuesChanged: (fieldOptionValues) async { + await fieldService.updateField(name: fieldOptionValues.name); + + await FieldBackendService.updateFieldType( + viewId: widget.viewId, + fieldId: widget.field.id, + fieldType: fieldOptionValues.type, + ); + + final data = fieldOptionValues.getTypeOptionData(); + if (data != null) { + await FieldBackendService.updateFieldTypeOption( + viewId: widget.viewId, + fieldId: widget.field.id, + typeOptionData: data, + ); + } + setState(() { + _fieldOptionValues = fieldOptionValues; + }); + }, + onAction: (action) { + final service = FieldServices( + viewId: viewId, + fieldId: fieldId, + ); + switch (action) { + case FieldOptionAction.delete: + fieldService.delete(); + context.pop(); + return; + case FieldOptionAction.duplicate: + fieldService.duplicate(); + break; + case FieldOptionAction.hide: + service.hide(); + break; + case FieldOptionAction.show: + service.show(); + break; + } + context.pop(_fieldOptionValues); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart new file mode 100644 index 0000000000000..11a6b239e9e05 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:go_router/go_router.dart'; + +import 'mobile_create_field_screen.dart'; +import 'mobile_edit_field_screen.dart'; +import 'mobile_field_picker_list.dart'; +import 'mobile_full_field_editor.dart'; +import 'mobile_quick_field_editor.dart'; + +const mobileSupportedFieldTypes = [ + FieldType.RichText, + FieldType.Number, + FieldType.SingleSelect, + FieldType.MultiSelect, + FieldType.DateTime, + FieldType.Media, + FieldType.URL, + FieldType.Checkbox, + FieldType.Checklist, + FieldType.LastEditedTime, + FieldType.CreatedTime, + // FieldType.Time, +]; + +Future showFieldTypeGridBottomSheet( + BuildContext context, { + required String title, +}) { + return showMobileBottomSheet( + context, + showHeader: true, + showDragHandle: true, + showCloseButton: true, + elevation: 20, + title: title, + backgroundColor: AFThemeExtension.of(context).background, + enableDraggableScrollable: true, + builder: (context) { + final typeOptionMenuItemValue = mobileSupportedFieldTypes + .map( + (fieldType) => TypeOptionMenuItemValue( + value: fieldType, + backgroundColor: Theme.of(context).brightness == Brightness.light + ? fieldType.mobileIconBackgroundColor + : fieldType.mobileIconBackgroundColorDark, + text: fieldType.i18n, + icon: fieldType.svgData, + onTap: (context, fieldType) => + Navigator.of(context).pop(fieldType), + ), + ) + .toList(); + return Padding( + padding: EdgeInsets.all(16 * context.scale), + child: TypeOptionMenu( + values: typeOptionMenuItemValue, + scaleFactor: context.scale, + ), + ); + }, + ); +} + +/// Shows the field type grid and upon selection, allow users to edit the +/// field's properties and saving it when the user clicks save. +void mobileCreateFieldWorkflow( + BuildContext context, + String viewId, { + OrderObjectPositionPB? position, +}) async { + final fieldType = await showFieldTypeGridBottomSheet( + context, + title: LocaleKeys.grid_field_newProperty.tr(), + ); + if (fieldType == null || !context.mounted) { + return; + } + final optionValues = await context.push( + Uri( + path: MobileNewPropertyScreen.routeName, + queryParameters: { + MobileNewPropertyScreen.argViewId: viewId, + MobileNewPropertyScreen.argFieldTypeId: fieldType.value.toString(), + }, + ).toString(), + ); + if (optionValues != null) { + await optionValues.create(viewId: viewId, position: position); + } +} + +/// Used to edit a field. +Future showEditFieldScreen( + BuildContext context, + String viewId, + FieldInfo field, +) { + return context.push( + MobileEditPropertyScreen.routeName, + extra: { + MobileEditPropertyScreen.argViewId: viewId, + MobileEditPropertyScreen.argField: field, + }, + ); +} + +/// Shows some quick field options in a bottom sheet. +void showQuickEditField( + BuildContext context, + String viewId, + FieldController fieldController, + FieldInfo fieldInfo, +) { + showMobileBottomSheet( + context, + showDragHandle: true, + builder: (context) { + return SingleChildScrollView( + child: QuickEditField( + viewId: viewId, + fieldController: fieldController, + fieldInfo: fieldInfo, + ), + ); + }, + ); +} + +/// Display a list of fields in the current database that users can choose from. +Future showFieldPicker( + BuildContext context, + String title, + String? selectedFieldId, + FieldController fieldController, + bool Function(FieldInfo fieldInfo) filterBy, +) { + return showMobileBottomSheet( + context, + showDivider: false, + builder: (context) { + return MobileFieldPickerList( + title: title, + selectedFieldId: selectedFieldId, + fieldController: fieldController, + filterBy: filterBy, + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_picker_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_picker_list.dart new file mode 100644 index 0000000000000..04144241a0335 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_picker_list.dart @@ -0,0 +1,142 @@ +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class MobileFieldPickerList extends StatefulWidget { + MobileFieldPickerList({ + super.key, + required this.title, + required this.selectedFieldId, + required FieldController fieldController, + required bool Function(FieldInfo fieldInfo) filterBy, + }) : fields = fieldController.fieldInfos.where(filterBy).toList(); + + final String title; + final String? selectedFieldId; + final List fields; + + @override + State createState() => _MobileFieldPickerListState(); +} + +class _MobileFieldPickerListState extends State { + String? newFieldId; + + @override + void initState() { + super.initState(); + newFieldId = widget.selectedFieldId; + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + expand: false, + snap: true, + initialChildSize: 0.98, + minChildSize: 0.98, + maxChildSize: 0.98, + builder: (context, scrollController) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const DragHandle(), + _Header( + title: widget.title, + onDone: (context) => context.pop(newFieldId), + ), + SingleChildScrollView( + controller: scrollController, + child: ListView.builder( + shrinkWrap: true, + itemCount: widget.fields.length, + itemBuilder: (context, index) => _FieldButton( + field: widget.fields[index], + showTopBorder: index == 0, + isSelected: widget.fields[index].id == newFieldId, + onSelect: (fieldId) => setState(() => newFieldId = fieldId), + ), + ), + ), + ], + ); + }, + ); + } +} + +/// Same header as the one in showMobileBottomSheet, but allows popping the +/// sheet with a value. +class _Header extends StatelessWidget { + const _Header({ + required this.title, + required this.onDone, + }); + + final String title; + final void Function(BuildContext context) onDone; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: SizedBox( + height: 44.0, + child: Stack( + children: [ + const Align( + alignment: Alignment.centerLeft, + child: AppBarBackButton(), + ), + Align( + child: FlowyText.medium( + title, + fontSize: 16.0, + ), + ), + Align( + alignment: Alignment.centerRight, + child: AppBarDoneButton( + onTap: () => onDone(context), + ), + ), + ], + ), + ), + ); + } +} + +class _FieldButton extends StatelessWidget { + const _FieldButton({ + required this.field, + required this.isSelected, + required this.onSelect, + required this.showTopBorder, + }); + + final FieldInfo field; + final bool isSelected; + final void Function(String fieldId) onSelect; + final bool showTopBorder; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.checkbox( + text: field.name, + isSelected: isSelected, + leftIcon: FieldIcon( + fieldInfo: field, + dimension: 20, + ), + showTopBorder: showTopBorder, + onTap: () => onSelect(field.id), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_full_field_editor.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_full_field_editor.dart new file mode 100644 index 0000000000000..f3d71a7f0e8a2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_full_field_editor.dart @@ -0,0 +1,997 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart'; +import 'package:appflowy/mobile/presentation/base/option_color_list.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/widgets.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:protobuf/protobuf.dart'; + +import 'mobile_field_bottom_sheets.dart'; + +enum FieldOptionMode { + add, + edit, +} + +class FieldOptionValues { + FieldOptionValues({ + required this.type, + required this.name, + required this.icon, + this.dateFormat, + this.timeFormat, + this.includeTime, + this.numberFormat, + this.selectOption = const [], + }); + + factory FieldOptionValues.fromField({required FieldPB field}) { + final fieldType = field.fieldType; + final buffer = field.typeOptionData; + return FieldOptionValues( + type: fieldType, + name: field.name, + icon: field.icon, + numberFormat: fieldType == FieldType.Number + ? NumberTypeOptionPB.fromBuffer(buffer).format + : null, + dateFormat: switch (fieldType) { + FieldType.DateTime => DateTypeOptionPB.fromBuffer(buffer).dateFormat, + FieldType.LastEditedTime || + FieldType.CreatedTime => + TimestampTypeOptionPB.fromBuffer(buffer).dateFormat, + _ => null + }, + timeFormat: switch (fieldType) { + FieldType.DateTime => DateTypeOptionPB.fromBuffer(buffer).timeFormat, + FieldType.LastEditedTime || + FieldType.CreatedTime => + TimestampTypeOptionPB.fromBuffer(buffer).timeFormat, + _ => null + }, + includeTime: switch (fieldType) { + FieldType.LastEditedTime || + FieldType.CreatedTime => + TimestampTypeOptionPB.fromBuffer(buffer).includeTime, + _ => null + }, + selectOption: switch (fieldType) { + FieldType.SingleSelect => + SingleSelectTypeOptionPB.fromBuffer(buffer).options, + FieldType.MultiSelect => + MultiSelectTypeOptionPB.fromBuffer(buffer).options, + _ => [], + }, + ); + } + + FieldType type; + String name; + String icon; + + // FieldType.DateTime + // FieldType.LastEditedTime + // FieldType.CreatedTime + DateFormatPB? dateFormat; + TimeFormatPB? timeFormat; + + // FieldType.LastEditedTime + // FieldType.CreatedTime + bool? includeTime; + + // FieldType.Number + NumberFormatPB? numberFormat; + + // FieldType.Select + // FieldType.MultiSelect + List selectOption; + + Future create({ + required String viewId, + OrderObjectPositionPB? position, + }) async { + await FieldBackendService.createField( + viewId: viewId, + fieldType: type, + fieldName: name, + typeOptionData: getTypeOptionData(), + position: position, + ); + } + + Uint8List? getTypeOptionData() { + switch (type) { + case FieldType.RichText: + case FieldType.URL: + case FieldType.Checkbox: + case FieldType.Time: + return null; + case FieldType.Number: + return NumberTypeOptionPB( + format: numberFormat, + ).writeToBuffer(); + case FieldType.DateTime: + return DateTypeOptionPB( + dateFormat: dateFormat, + timeFormat: timeFormat, + ).writeToBuffer(); + case FieldType.SingleSelect: + return SingleSelectTypeOptionPB( + options: selectOption, + ).writeToBuffer(); + case FieldType.MultiSelect: + return MultiSelectTypeOptionPB( + options: selectOption, + ).writeToBuffer(); + case FieldType.Checklist: + return ChecklistTypeOptionPB().writeToBuffer(); + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + return TimestampTypeOptionPB( + dateFormat: dateFormat, + timeFormat: timeFormat, + includeTime: includeTime, + ).writeToBuffer(); + case FieldType.Media: + return MediaTypeOptionPB().writeToBuffer(); + default: + throw UnimplementedError(); + } + } +} + +enum FieldOptionAction { + hide, + show, + duplicate, + delete, +} + +class MobileFieldEditor extends StatefulWidget { + const MobileFieldEditor({ + super.key, + required this.mode, + required this.defaultValues, + required this.onOptionValuesChanged, + this.actions = const [], + this.onAction, + this.isPrimary = false, + }); + + final FieldOptionMode mode; + final FieldOptionValues defaultValues; + final void Function(FieldOptionValues values) onOptionValuesChanged; + + // only used in edit mode + final List actions; + final void Function(FieldOptionAction action)? onAction; + + // the primary field can't be deleted, duplicated, and changed type + final bool isPrimary; + + @override + State createState() => _MobileFieldEditorState(); +} + +class _MobileFieldEditorState extends State { + final controller = TextEditingController(); + bool isFieldNameChanged = false; + + late FieldOptionValues values; + + @override + void initState() { + super.initState(); + values = widget.defaultValues; + controller.text = values.name; + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final option = _buildOption(); + return Container( + color: Theme.of(context).brightness == Brightness.light + ? const Color(0xFFF7F8FB) + : const Color(0xFF23262B), + height: MediaQuery.of(context).size.height, + child: SingleChildScrollView( + child: Column( + children: [ + const _Divider(), + OptionTextField( + controller: controller, + autoFocus: widget.mode == FieldOptionMode.add, + fieldType: values.type, + isPrimary: widget.isPrimary, + onTextChanged: (value) { + isFieldNameChanged = true; + _updateOptionValues(name: value); + }, + onFieldTypeChanged: (type) { + setState( + () { + if (widget.mode == FieldOptionMode.add && + !isFieldNameChanged) { + controller.text = type.i18n; + _updateOptionValues(name: type.i18n); + } + _updateOptionValues(type: type); + }, + ); + }, + ), + const _Divider(), + if (!widget.isPrimary) ...[ + _PropertyType( + type: values.type, + onSelected: (type) { + setState( + () { + if (widget.mode == FieldOptionMode.add && + !isFieldNameChanged) { + controller.text = type.i18n; + _updateOptionValues(name: type.i18n); + } + _updateOptionValues(type: type); + }, + ); + }, + ), + const _Divider(), + if (option.isNotEmpty) ...[ + ...option, + const _Divider(), + ], + ], + ..._buildOptionActions(), + const _Divider(), + VSpace(MediaQuery.viewPaddingOf(context).bottom == 0 ? 28.0 : 16.0), + ], + ), + ), + ); + } + + List _buildOption() { + switch (values.type) { + case FieldType.Number: + return [ + _NumberOption( + selectedFormat: values.numberFormat ?? NumberFormatPB.Num, + onSelected: (format) => setState( + () => _updateOptionValues( + numberFormat: format, + ), + ), + ), + ]; + case FieldType.DateTime: + return [ + _DateOption( + selectedFormat: values.dateFormat ?? DateFormatPB.Local, + onSelected: (format) => _updateOptionValues( + dateFormat: format, + ), + ), + const _Divider(), + _TimeOption( + selectedFormat: values.timeFormat ?? TimeFormatPB.TwelveHour, + onSelected: (format) => _updateOptionValues( + timeFormat: format, + ), + ), + ]; + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + return [ + _DateOption( + selectedFormat: values.dateFormat ?? DateFormatPB.Local, + onSelected: (format) => _updateOptionValues( + dateFormat: format, + ), + ), + const _Divider(), + _TimeOption( + selectedFormat: values.timeFormat ?? TimeFormatPB.TwelveHour, + onSelected: (format) => _updateOptionValues( + timeFormat: format, + ), + ), + const _Divider(), + _IncludeTimeOption( + includeTime: values.includeTime ?? true, + onToggle: (includeTime) => _updateOptionValues( + includeTime: includeTime, + ), + ), + ]; + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return [ + _SelectOption( + mode: widget.mode, + selectOption: values.selectOption, + onAddOptions: (options) { + if (values.selectOption.lastOrNull?.name.isEmpty == true) { + // ignore the add action if the last one doesn't have a name + return; + } + setState(() { + _updateOptionValues( + selectOption: values.selectOption + options, + ); + }); + }, + onUpdateOptions: (options) { + _updateOptionValues(selectOption: options); + }, + ), + ]; + default: + return []; + } + } + + List _buildOptionActions() { + if (widget.mode == FieldOptionMode.add || widget.actions.isEmpty) { + return []; + } + + return [ + if (widget.actions.contains(FieldOptionAction.hide) && !widget.isPrimary) + FlowyOptionTile.text( + text: LocaleKeys.grid_field_hide.tr(), + leftIcon: const FlowySvg(FlowySvgs.m_field_hide_s), + onTap: () => widget.onAction?.call(FieldOptionAction.hide), + ), + if (widget.actions.contains(FieldOptionAction.show)) + FlowyOptionTile.text( + text: LocaleKeys.grid_field_show.tr(), + leftIcon: const FlowySvg(FlowySvgs.show_m, size: Size.square(16)), + onTap: () => widget.onAction?.call(FieldOptionAction.show), + ), + if (widget.actions.contains(FieldOptionAction.duplicate) && + !widget.isPrimary) + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.button_duplicate.tr(), + leftIcon: const FlowySvg(FlowySvgs.m_field_copy_s), + onTap: () => widget.onAction?.call(FieldOptionAction.duplicate), + ), + if (widget.actions.contains(FieldOptionAction.delete) && + !widget.isPrimary) + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.button_delete.tr(), + textColor: Theme.of(context).colorScheme.error, + leftIcon: FlowySvg( + FlowySvgs.m_delete_s, + color: Theme.of(context).colorScheme.error, + ), + onTap: () => widget.onAction?.call(FieldOptionAction.delete), + ), + ]; + } + + void _updateOptionValues({ + FieldType? type, + String? name, + DateFormatPB? dateFormat, + TimeFormatPB? timeFormat, + bool? includeTime, + NumberFormatPB? numberFormat, + List? selectOption, + }) { + if (type != null) { + values.type = type; + } + if (name != null) { + values.name = name; + } + if (dateFormat != null) { + values.dateFormat = dateFormat; + } + if (timeFormat != null) { + values.timeFormat = timeFormat; + } + if (includeTime != null) { + values.includeTime = includeTime; + } + if (numberFormat != null) { + values.numberFormat = numberFormat; + } + if (selectOption != null) { + values.selectOption = selectOption; + } + + widget.onOptionValuesChanged(values); + } +} + +class _PropertyType extends StatelessWidget { + const _PropertyType({ + required this.type, + required this.onSelected, + }); + + final FieldType type; + final void Function(FieldType type) onSelected; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.text( + text: LocaleKeys.grid_field_propertyType.tr(), + trailing: Row( + children: [ + FlowySvg( + type.svgData, + size: const Size.square(22), + color: Theme.of(context).hintColor, + ), + const HSpace(6.0), + FlowyText( + type.i18n, + color: Theme.of(context).hintColor, + ), + const HSpace(4.0), + FlowySvg( + FlowySvgs.arrow_right_s, + color: Theme.of(context).hintColor, + size: const Size.square(18.0), + ), + ], + ), + onTap: () async { + final fieldType = await showFieldTypeGridBottomSheet( + context, + title: LocaleKeys.grid_field_editProperty.tr(), + ); + if (fieldType != null) { + onSelected(fieldType); + } + }, + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return const VSpace( + 24.0, + ); + } +} + +class _DateOption extends StatefulWidget { + const _DateOption({ + required this.selectedFormat, + required this.onSelected, + }); + + final DateFormatPB selectedFormat; + final Function(DateFormatPB format) onSelected; + + @override + State<_DateOption> createState() => _DateOptionState(); +} + +class _DateOptionState extends State<_DateOption> { + DateFormatPB selectedFormat = DateFormatPB.Local; + + @override + void initState() { + super.initState(); + + selectedFormat = widget.selectedFormat; + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 4.0), + child: FlowyText( + LocaleKeys.grid_field_dateFormat.tr().toUpperCase(), + fontSize: 13, + color: Theme.of(context).hintColor, + ), + ), + ...DateFormatPB.values.mapIndexed((index, format) { + return FlowyOptionTile.checkbox( + text: format.title(), + isSelected: selectedFormat == format, + showTopBorder: index == 0, + onTap: () { + widget.onSelected(format); + setState(() { + selectedFormat = format; + }); + }, + ); + }), + ], + ); + } +} + +class _TimeOption extends StatefulWidget { + const _TimeOption({ + required this.selectedFormat, + required this.onSelected, + }); + + final TimeFormatPB selectedFormat; + final Function(TimeFormatPB format) onSelected; + + @override + State<_TimeOption> createState() => _TimeOptionState(); +} + +class _TimeOptionState extends State<_TimeOption> { + TimeFormatPB selectedFormat = TimeFormatPB.TwelveHour; + + @override + void initState() { + super.initState(); + + selectedFormat = widget.selectedFormat; + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 4.0), + child: FlowyText( + LocaleKeys.grid_field_timeFormat.tr().toUpperCase(), + fontSize: 13, + color: Theme.of(context).hintColor, + ), + ), + ...TimeFormatPB.values.mapIndexed((index, format) { + return FlowyOptionTile.checkbox( + text: format.title(), + isSelected: selectedFormat == format, + showTopBorder: index == 0, + onTap: () { + widget.onSelected(format); + setState(() { + selectedFormat = format; + }); + }, + ); + }), + ], + ); + } +} + +class _IncludeTimeOption extends StatefulWidget { + const _IncludeTimeOption({ + required this.includeTime, + required this.onToggle, + }); + + final bool includeTime; + final void Function(bool includeTime) onToggle; + + @override + State<_IncludeTimeOption> createState() => _IncludeTimeOptionState(); +} + +class _IncludeTimeOptionState extends State<_IncludeTimeOption> { + late bool includeTime = widget.includeTime; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.toggle( + text: LocaleKeys.grid_field_includeTime.tr(), + isSelected: includeTime, + onValueChanged: (value) { + widget.onToggle(value); + setState(() { + includeTime = value; + }); + }, + ); + } +} + +class _NumberOption extends StatelessWidget { + const _NumberOption({ + required this.selectedFormat, + required this.onSelected, + }); + + final NumberFormatPB selectedFormat; + final void Function(NumberFormatPB format) onSelected; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.text( + text: LocaleKeys.grid_field_numberFormat.tr(), + trailing: Row( + children: [ + FlowyText( + selectedFormat.title(), + color: Theme.of(context).hintColor, + ), + const HSpace(4.0), + FlowySvg( + FlowySvgs.arrow_right_s, + color: Theme.of(context).hintColor, + size: const Size.square(18.0), + ), + ], + ), + onTap: () { + showMobileBottomSheet( + context, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (context) { + return DraggableScrollableSheet( + expand: false, + snap: true, + minChildSize: 0.5, + builder: (context, scrollController) => _NumberFormatList( + scrollController: scrollController, + selectedFormat: selectedFormat, + onSelected: (type) { + onSelected(type); + context.pop(); + }, + ), + ); + }, + ); + }, + ); + } +} + +class _NumberFormatList extends StatefulWidget { + const _NumberFormatList({ + this.scrollController, + required this.selectedFormat, + required this.onSelected, + }); + + final NumberFormatPB selectedFormat; + final ScrollController? scrollController; + final void Function(NumberFormatPB format) onSelected; + + @override + State<_NumberFormatList> createState() => _NumberFormatListState(); +} + +class _NumberFormatListState extends State<_NumberFormatList> { + List formats = NumberFormatPB.values; + + @override + Widget build(BuildContext context) { + return ListView( + controller: widget.scrollController, + children: [ + const Center( + child: DragHandle(), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + height: 44.0, + child: FlowySearchTextField( + onChanged: (String value) { + setState(() { + formats = NumberFormatPB.values + .where( + (element) => element + .title() + .toLowerCase() + .contains(value.toLowerCase()), + ) + .toList(); + }); + }, + ), + ), + ...formats.mapIndexed( + (index, element) => FlowyOptionTile.checkbox( + text: element.title(), + content: Expanded( + child: Row( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + 4.0, + 16.0, + 12.0, + 16.0, + ), + child: FlowyText( + element.title(), + fontSize: 16, + ), + ), + FlowyText( + element.iconSymbol(), + fontSize: 16, + color: Theme.of(context).hintColor, + ), + widget.selectedFormat != element + ? const HSpace(30.0) + : const HSpace(6.0), + ], + ), + ), + isSelected: widget.selectedFormat == element, + showTopBorder: false, + onTap: () => widget.onSelected(element), + ), + ), + ], + ); + } +} + +// single select or multi select +class _SelectOption extends StatelessWidget { + _SelectOption({ + required this.mode, + required this.selectOption, + required this.onAddOptions, + required this.onUpdateOptions, + }); + + final List selectOption; + final void Function(List options) onAddOptions; + final void Function(List options) onUpdateOptions; + final FieldOptionMode mode; + + final random = Random(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 4.0), + child: FlowyText( + LocaleKeys.grid_field_optionTitle.tr().toUpperCase(), + fontSize: 13, + color: Theme.of(context).hintColor, + ), + ), + _SelectOptionList( + selectOptions: selectOption, + onUpdateOptions: onUpdateOptions, + ), + FlowyOptionTile.text( + text: LocaleKeys.grid_field_addOption.tr(), + leftIcon: const FlowySvg( + FlowySvgs.add_s, + size: Size.square(20), + ), + onTap: () { + onAddOptions([ + SelectOptionPB( + id: uuid(), + name: '', + color: SelectOptionColorPB.valueOf( + random.nextInt(SelectOptionColorPB.values.length), + ), + ), + ]); + }, + ), + ], + ); + } +} + +class _SelectOptionList extends StatefulWidget { + const _SelectOptionList({ + required this.selectOptions, + required this.onUpdateOptions, + }); + + final List selectOptions; + final void Function(List options) onUpdateOptions; + + @override + State<_SelectOptionList> createState() => _SelectOptionListState(); +} + +class _SelectOptionListState extends State<_SelectOptionList> { + late List options; + + @override + void initState() { + super.initState(); + + options = widget.selectOptions; + } + + @override + void didUpdateWidget(covariant _SelectOptionList oldWidget) { + super.didUpdateWidget(oldWidget); + + options = widget.selectOptions; + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.selectOptions.isEmpty) { + return const SizedBox.shrink(); + } + return ListView( + shrinkWrap: true, + padding: EdgeInsets.zero, + // disable the inner scroll physics, so the outer ListView can scroll + physics: const NeverScrollableScrollPhysics(), + children: widget.selectOptions + .mapIndexed( + (index, option) => _SelectOptionTile( + option: option, + showTopBorder: index == 0, + showBottomBorder: index != widget.selectOptions.length - 1, + onUpdateOption: (option) { + _updateOption(index, option); + }, + ), + ) + .toList(), + ); + } + + void _updateOption(int index, SelectOptionPB option) { + final options = [...this.options]; + options[index] = option; + this.options = options; + widget.onUpdateOptions(options); + } +} + +class _SelectOptionTile extends StatefulWidget { + const _SelectOptionTile({ + required this.option, + required this.showTopBorder, + required this.showBottomBorder, + required this.onUpdateOption, + }); + + final SelectOptionPB option; + final bool showTopBorder; + final bool showBottomBorder; + final void Function(SelectOptionPB option) onUpdateOption; + + @override + State<_SelectOptionTile> createState() => _SelectOptionTileState(); +} + +class _SelectOptionTileState extends State<_SelectOptionTile> { + final TextEditingController controller = TextEditingController(); + late SelectOptionPB option; + + @override + void initState() { + super.initState(); + + controller.text = widget.option.name; + option = widget.option; + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.textField( + controller: controller, + textFieldHintText: LocaleKeys.grid_field_typeANewOption.tr(), + showTopBorder: widget.showTopBorder, + showBottomBorder: widget.showBottomBorder, + trailing: _SelectOptionColor( + color: option.color, + onChanged: (color) { + setState(() { + option.freeze(); + option = option.rebuild((p0) => p0.color = color); + widget.onUpdateOption(option); + }); + context.pop(); + }, + ), + onTextChanged: (name) { + setState(() { + option.freeze(); + option = option.rebuild((p0) => p0.name = name); + widget.onUpdateOption(option); + }); + }, + ); + } +} + +class _SelectOptionColor extends StatelessWidget { + const _SelectOptionColor({ + required this.color, + required this.onChanged, + }); + + final SelectOptionColorPB color; + final void Function(SelectOptionColorPB) onChanged; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + showMobileBottomSheet( + context, + showHeader: true, + showCloseButton: true, + title: LocaleKeys.grid_selectOption_colorPanelTitle.tr(), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + builder: (context) { + return OptionColorList( + selectedColor: color, + onSelectedColor: onChanged, + ); + }, + ); + }, + child: Container( + decoration: BoxDecoration( + color: color.toColor(context), + borderRadius: Corners.s10Border, + ), + width: 32, + height: 32, + alignment: Alignment.center, + child: const FlowySvg( + FlowySvgs.arrow_down_s, + size: Size.square(20), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart new file mode 100644 index 0000000000000..f2b90e9c0d483 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/widgets.dart'; +import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_sheets.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/domain/field_backend_service.dart'; +import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class QuickEditField extends StatefulWidget { + const QuickEditField({ + super.key, + required this.viewId, + required this.fieldController, + required this.fieldInfo, + }); + + final String viewId; + final FieldController fieldController; + final FieldInfo fieldInfo; + + @override + State createState() => _QuickEditFieldState(); +} + +class _QuickEditFieldState extends State { + final TextEditingController controller = TextEditingController(); + + late final FieldServices service = FieldServices( + viewId: widget.viewId, + fieldId: widget.fieldInfo.field.id, + ); + + late FieldVisibility fieldVisibility; + + @override + void initState() { + super.initState(); + fieldVisibility = + widget.fieldInfo.visibility ?? FieldVisibility.AlwaysShown; + controller.text = widget.fieldInfo.field.name; + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => FieldEditorBloc( + viewId: widget.viewId, + fieldController: widget.fieldController, + fieldInfo: widget.fieldInfo, + isNew: false, + ), + child: BlocConsumer( + listenWhen: (previous, current) => + previous.field.name != current.field.name, + listener: (context, state) => controller.text = state.field.name, + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(16), + OptionTextField( + controller: controller, + isPrimary: state.field.isPrimary, + fieldType: state.field.fieldType, + onTextChanged: (text) { + context + .read() + .add(FieldEditorEvent.renameField(text)); + }, + onFieldTypeChanged: (fieldType) { + context + .read() + .add(FieldEditorEvent.switchFieldType(fieldType)); + }, + ), + const _Divider(), + FlowyOptionTile.text( + text: LocaleKeys.grid_field_editProperty.tr(), + leftIcon: const FlowySvg(FlowySvgs.m_field_edit_s), + onTap: () { + showEditFieldScreen( + context, + widget.viewId, + state.field, + ); + context.pop(); + }, + ), + if (!widget.fieldInfo.isPrimary) ...[ + FlowyOptionTile.text( + showTopBorder: false, + text: fieldVisibility.isVisibleState() + ? LocaleKeys.grid_field_hide.tr() + : LocaleKeys.grid_field_show.tr(), + leftIcon: const FlowySvg(FlowySvgs.m_field_hide_s), + onTap: () async { + context.pop(); + if (fieldVisibility.isVisibleState()) { + await service.hide(); + } else { + await service.hide(); + } + }, + ), + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.grid_field_insertLeft.tr(), + leftIcon: const FlowySvg(FlowySvgs.m_filed_insert_left_s), + onTap: () { + context.pop(); + mobileCreateFieldWorkflow( + context, + widget.viewId, + position: OrderObjectPositionPB( + position: OrderObjectPositionTypePB.Before, + objectId: widget.fieldInfo.id, + ), + ); + }, + ), + ], + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.grid_field_insertRight.tr(), + leftIcon: const FlowySvg(FlowySvgs.m_filed_insert_right_s), + onTap: () { + context.pop(); + mobileCreateFieldWorkflow( + context, + widget.viewId, + position: OrderObjectPositionPB( + position: OrderObjectPositionTypePB.After, + objectId: widget.fieldInfo.id, + ), + ); + }, + ), + if (!widget.fieldInfo.isPrimary) ...[ + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.button_duplicate.tr(), + leftIcon: const FlowySvg(FlowySvgs.m_field_copy_s), + onTap: () { + context.pop(); + service.duplicate(); + }, + ), + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.button_delete.tr(), + textColor: Theme.of(context).colorScheme.error, + leftIcon: FlowySvg( + FlowySvgs.m_field_delete_s, + color: Theme.of(context).colorScheme.error, + ), + onTap: () { + context.pop(); + service.delete(); + }, + ), + ], + ], + ); + }, + ), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return const VSpace(20); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_events_empty.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_events_empty.dart new file mode 100644 index 0000000000000..29fcb6bddefa2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_events_empty.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; + +class MobileCalendarEventsEmpty extends StatelessWidget { + const MobileCalendarEventsEmpty({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + LocaleKeys.calendar_mobileEventScreen_emptyTitle.tr(), + fontWeight: FontWeight.w700, + fontSize: 14, + ), + const VSpace(8), + FlowyText.regular( + LocaleKeys.calendar_mobileEventScreen_emptyBody.tr(), + textAlign: TextAlign.center, + maxLines: 2, + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_events_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_events_screen.dart new file mode 100644 index 0000000000000..53889be3114ee --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_events_screen.dart @@ -0,0 +1,108 @@ +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/database/mobile_calendar_events_empty.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart'; +import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_card.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileCalendarEventsScreen extends StatefulWidget { + const MobileCalendarEventsScreen({ + super.key, + required this.calendarBloc, + required this.date, + required this.events, + required this.rowCache, + required this.viewId, + }); + + final CalendarBloc calendarBloc; + final DateTime date; + final List events; + final RowCache rowCache; + final String viewId; + + static const routeName = '/calendar_events'; + + // GoRouter Arguments + static const calendarBlocKey = 'calendar_bloc'; + static const calendarDateKey = 'date'; + static const calendarEventsKey = 'events'; + static const calendarRowCacheKey = 'row_cache'; + static const calendarViewIdKey = 'view_id'; + + @override + State createState() => + _MobileCalendarEventsScreenState(); +} + +class _MobileCalendarEventsScreenState + extends State { + late final List _events = widget.events; + + @override + Widget build(BuildContext context) { + return Scaffold( + floatingActionButton: FloatingActionButton( + key: const Key('add_event_fab'), + elevation: 6, + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + onPressed: () => + widget.calendarBloc.add(CalendarEvent.createEvent(widget.date)), + child: const Text('+'), + ), + appBar: FlowyAppBar( + titleText: DateFormat.yMMMMd(context.locale.toLanguageTag()) + .format(widget.date), + ), + body: BlocProvider.value( + value: widget.calendarBloc, + child: BlocBuilder( + buildWhen: (p, c) => + p.newEvent != c.newEvent && + c.newEvent?.date.withoutTime == widget.date, + builder: (context, state) { + if (state.newEvent?.event != null && + _events + .none((e) => e.eventId == state.newEvent!.event!.eventId) && + state.newEvent!.date.withoutTime == widget.date) { + _events.add(state.newEvent!.event!); + } + + if (_events.isEmpty) { + return const MobileCalendarEventsEmpty(); + } + + return SingleChildScrollView( + child: Column( + children: [ + const VSpace(10), + ..._events.map((event) { + return EventCard( + databaseController: + widget.calendarBloc.databaseController, + event: event, + constraints: const BoxConstraints.expand(), + autoEdit: false, + isDraggable: false, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 3, + ), + ); + }), + const VSpace(24), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_screen.dart new file mode 100644 index 0000000000000..ec87b5c9bdc93 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_calendar_screen.dart @@ -0,0 +1,28 @@ +import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; + +class MobileCalendarScreen extends StatelessWidget { + const MobileCalendarScreen({ + super.key, + required this.id, + this.title, + }); + + /// view id + final String id; + final String? title; + + static const routeName = '/calendar'; + static const viewId = 'id'; + static const viewTitle = 'title'; + + @override + Widget build(BuildContext context) { + return MobileViewPage( + id: id, + title: title, + viewLayout: ViewLayoutPB.Document, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_grid_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_grid_screen.dart new file mode 100644 index 0000000000000..18c1f8f4d91fa --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/mobile_grid_screen.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; + +class MobileGridScreen extends StatelessWidget { + const MobileGridScreen({ + super.key, + required this.id, + this.title, + this.arguments, + }); + + /// view id + final String id; + final String? title; + final Map? arguments; + + static const routeName = '/grid'; + static const viewId = 'id'; + static const viewTitle = 'title'; + static const viewArgs = 'arguments'; + + @override + Widget build(BuildContext context) { + return MobileViewPage( + id: id, + title: title, + viewLayout: ViewLayoutPB.Grid, + arguments: arguments, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_field_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_field_list.dart new file mode 100644 index 0000000000000..9543a4593b428 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_field_list.dart @@ -0,0 +1,205 @@ +import 'dart:ui'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/setting/property_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../field/mobile_field_bottom_sheets.dart'; + +class MobileDatabaseFieldList extends StatelessWidget { + const MobileDatabaseFieldList({ + super.key, + required this.databaseController, + required this.canCreate, + }); + + final DatabaseController databaseController; + final bool canCreate; + + @override + Widget build(BuildContext context) { + return _MobileDatabaseFieldListBody( + databaseController: databaseController, + viewId: context.read().state.view.id, + canCreate: canCreate, + ); + } +} + +class _MobileDatabaseFieldListBody extends StatelessWidget { + const _MobileDatabaseFieldListBody({ + required this.databaseController, + required this.viewId, + required this.canCreate, + }); + + final DatabaseController databaseController; + final String viewId; + final bool canCreate; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => DatabasePropertyBloc( + viewId: viewId, + fieldController: databaseController.fieldController, + )..add(const DatabasePropertyEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + if (state.fieldContexts.isEmpty) { + return const SizedBox.shrink(); + } + + final fields = [...state.fieldContexts]; + final firstField = fields.removeAt(0); + final firstCell = DatabaseFieldListTile( + key: ValueKey(firstField.id), + viewId: viewId, + fieldController: databaseController.fieldController, + fieldInfo: firstField, + showTopBorder: false, + ); + final cells = fields + .mapIndexed( + (index, field) => DatabaseFieldListTile( + key: ValueKey(field.id), + viewId: viewId, + fieldController: databaseController.fieldController, + fieldInfo: field, + showTopBorder: false, + ), + ) + .toList(); + + return ReorderableListView.builder( + proxyDecorator: (_, index, anim) { + final field = fields[index]; + return AnimatedBuilder( + animation: anim, + builder: (BuildContext context, Widget? child) { + final double animValue = + Curves.easeInOut.transform(anim.value); + final double scale = lerpDouble(1, 1.05, animValue)!; + return Transform.scale( + scale: scale, + child: Material( + child: DatabaseFieldListTile( + key: ValueKey(field.id), + viewId: viewId, + fieldController: databaseController.fieldController, + fieldInfo: field, + showTopBorder: true, + ), + ), + ); + }, + ); + }, + shrinkWrap: true, + onReorder: (from, to) { + from++; + to++; + context + .read() + .add(DatabasePropertyEvent.moveField(from, to)); + }, + header: firstCell, + footer: canCreate + ? Padding( + padding: const EdgeInsets.only(top: 20), + child: _NewDatabaseFieldTile(viewId: viewId), + ) + : null, + itemCount: cells.length, + itemBuilder: (context, index) => cells[index], + padding: EdgeInsets.only( + bottom: context.bottomSheetPadding(ignoreViewPadding: false), + ), + ); + }, + ), + ); + } +} + +class DatabaseFieldListTile extends StatelessWidget { + const DatabaseFieldListTile({ + super.key, + required this.fieldInfo, + required this.viewId, + required this.fieldController, + required this.showTopBorder, + }); + + final FieldInfo fieldInfo; + final String viewId; + final FieldController fieldController; + final bool showTopBorder; + + @override + Widget build(BuildContext context) { + if (fieldInfo.field.isPrimary) { + return FlowyOptionTile.text( + text: fieldInfo.name, + leftIcon: FieldIcon( + fieldInfo: fieldInfo, + dimension: 20, + ), + onTap: () => showEditFieldScreen(context, viewId, fieldInfo), + showTopBorder: showTopBorder, + ); + } else { + return FlowyOptionTile.toggle( + isSelected: fieldInfo.visibility?.isVisibleState() ?? false, + text: fieldInfo.name, + leftIcon: FieldIcon( + fieldInfo: fieldInfo, + dimension: 20, + ), + showTopBorder: showTopBorder, + onTap: () => showEditFieldScreen(context, viewId, fieldInfo), + onValueChanged: (value) { + final newVisibility = fieldInfo.visibility!.toggle(); + context.read().add( + DatabasePropertyEvent.setFieldVisibility( + fieldInfo.id, + newVisibility, + ), + ); + }, + ); + } + } +} + +class _NewDatabaseFieldTile extends StatelessWidget { + const _NewDatabaseFieldTile({required this.viewId}); + + final String viewId; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.text( + text: LocaleKeys.grid_field_newProperty.tr(), + leftIcon: FlowySvg( + FlowySvgs.add_s, + size: const Size.square(20), + color: Theme.of(context).hintColor, + ), + textColor: Theme.of(context).hintColor, + onTap: () => mobileCreateFieldWorkflow(context, viewId), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_filter_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_filter_bottom_sheet.dart new file mode 100644 index 0000000000000..aea68ac27a14e --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_filter_bottom_sheet.dart @@ -0,0 +1,1108 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/database/view/database_filter_condition_list.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/select_option_loader.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart'; +import 'package:appflowy/util/debounce.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_editor.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:protobuf/protobuf.dart' hide FieldInfo; +import 'package:time/time.dart'; + +import 'database_filter_bottom_sheet_cubit.dart'; + +class MobileFilterEditor extends StatefulWidget { + const MobileFilterEditor({super.key}); + + @override + State createState() => _MobileFilterEditorState(); +} + +class _MobileFilterEditorState extends State { + final pageController = PageController(); + final scrollController = ScrollController(); + + @override + void dispose() { + pageController.dispose(); + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => MobileFilterEditorCubit( + pageController: pageController, + ), + child: Column( + children: [ + const _Header(), + SizedBox( + height: 400, + child: PageView.builder( + controller: pageController, + itemCount: 2, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return switch (index) { + 0 => _ActiveFilters(scrollController: scrollController), + 1 => const _FilterDetail(), + _ => const SizedBox.shrink(), + }; + }, + ), + ), + ], + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SizedBox( + height: 44.0, + child: Stack( + children: [ + if (_isBackButtonShown(state)) + Align( + alignment: Alignment.centerLeft, + child: AppBarBackButton( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + onTap: () => context + .read() + .returnToOverview(), + ), + ), + Align( + child: FlowyText.medium( + LocaleKeys.grid_settings_filter.tr(), + fontSize: 16.0, + ), + ), + if (_isSaveButtonShown(state)) + Align( + alignment: Alignment.centerRight, + child: AppBarSaveButton( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + enable: _isSaveButtonEnabled(state), + onTap: () => _saveOnTapHandler(context, state), + ), + ), + ], + ), + ); + }, + ); + } + + bool _isBackButtonShown(MobileFilterEditorState state) { + return state.maybeWhen( + overview: (_) => false, + orElse: () => true, + ); + } + + bool _isSaveButtonShown(MobileFilterEditorState state) { + return state.maybeWhen( + editCondition: (filterId, newFilter, showSave) => showSave, + editContent: (_, __) => true, + orElse: () => false, + ); + } + + bool _isSaveButtonEnabled(MobileFilterEditorState state) { + return state.maybeWhen( + editCondition: (_, __, enableSave) => enableSave, + editContent: (_, __) => true, + orElse: () => false, + ); + } + + void _saveOnTapHandler(BuildContext context, MobileFilterEditorState state) { + state.maybeWhen( + editCondition: (filterId, newFilter, _) { + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + editContent: (filterId, newFilter) { + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + orElse: () {}, + ); + context.read().returnToOverview(); + } +} + +class _ActiveFilters extends StatelessWidget { + const _ActiveFilters({ + required this.scrollController, + }); + + final ScrollController scrollController; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + child: Column( + children: [ + Expanded( + child: state.filters.isEmpty + ? _emptyBackground(context) + : _filterList(context, state), + ), + const VSpace(12), + const _CreateFilterButton(), + ], + ), + ); + }, + ); + } + + Widget _emptyBackground(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.filter_s, + size: const Size.square(60), + color: Theme.of(context).hintColor, + ), + FlowyText( + LocaleKeys.grid_filter_empty.tr(), + color: Theme.of(context).hintColor, + ), + ], + ), + ); + } + + Widget _filterList(BuildContext context, FilterEditorState state) { + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().state.maybeWhen( + overview: (scrollToBottom) { + if (scrollToBottom && scrollController.hasClients) { + scrollController + .jumpTo(scrollController.position.maxScrollExtent); + context.read().returnToOverview(); + } + }, + orElse: () {}, + ); + }); + + return ListView.separated( + controller: scrollController, + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + itemCount: state.filters.length, + itemBuilder: (context, index) { + final filter = state.filters[index]; + final field = context + .read() + .state + .fields + .firstWhereOrNull((field) => field.id == filter.fieldId); + return field == null + ? const SizedBox.shrink() + : _FilterItem(filter: filter, field: field); + }, + separatorBuilder: (context, index) => const VSpace(12.0), + ); + } +} + +class _CreateFilterButton extends StatelessWidget { + const _CreateFilterButton(); + + @override + Widget build(BuildContext context) { + return Container( + height: 44, + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide( + width: 0.5, + color: Theme.of(context).dividerColor, + ), + ), + borderRadius: Corners.s10Border, + ), + child: InkWell( + onTap: () { + if (context.read().state.fields.isEmpty) { + Fluttertoast.showToast( + msg: LocaleKeys.grid_filter_cannotFindCreatableField.tr(), + gravity: ToastGravity.BOTTOM, + ); + } else { + context.read().startCreatingFilter(); + } + }, + borderRadius: Corners.s10Border, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.add_s, + size: Size.square(16), + ), + const HSpace(6.0), + FlowyText( + LocaleKeys.grid_filter_addFilter.tr(), + fontSize: 15, + ), + ], + ), + ), + ), + ); + } +} + +class _FilterItem extends StatelessWidget { + const _FilterItem({ + required this.filter, + required this.field, + }); + + final DatabaseFilter filter; + final FieldInfo field; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).hoverColor, + borderRadius: BorderRadius.circular(12), + ), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: FlowyText.medium( + LocaleKeys.grid_filter_where.tr(), + fontSize: 15, + ), + ), + const VSpace(10), + Row( + children: [ + Expanded( + child: FilterItemInnerButton( + onTap: () => context + .read() + .startEditingFilterField(filter.filterId), + icon: field.fieldType.svgData, + child: FlowyText( + field.name, + overflow: TextOverflow.ellipsis, + ), + ), + ), + const HSpace(6), + Expanded( + child: FilterItemInnerButton( + onTap: () => context + .read() + .startEditingFilterCondition( + filter.filterId, + filter, + filter.fieldType == FieldType.DateTime, + ), + child: FlowyText( + filter.conditionName, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + if (filter.canAttachContent) ...[ + const VSpace(6), + filter.getMobileDescription( + field, + onExpand: () => context + .read() + .startEditingFilterContent(filter.filterId, filter), + onUpdate: (newFilter) => context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)), + ), + ], + ], + ), + ), + Positioned( + right: 8, + top: 6, + child: _deleteButton(context), + ), + ], + ), + ); + } + + Widget _deleteButton(BuildContext context) { + return InkWell( + onTap: () => context + .read() + .add(FilterEditorEvent.deleteFilter(filter.filterId)), + // steal from the container LongClickReorderWidget thing + onLongPress: () {}, + borderRadius: BorderRadius.circular(10), + child: SizedBox.square( + dimension: 34, + child: Center( + child: FlowySvg( + FlowySvgs.trash_m, + size: const Size.square(18), + color: Theme.of(context).hintColor, + ), + ), + ), + ); + } +} + +class FilterItemInnerButton extends StatelessWidget { + const FilterItemInnerButton({ + super.key, + required this.onTap, + required this.child, + this.icon, + }); + + final VoidCallback onTap; + final FlowySvgData? icon; + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + height: 44, + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide( + width: 0.5, + color: Theme.of(context).dividerColor, + ), + ), + borderRadius: Corners.s10Border, + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Center( + child: SeparatedRow( + separatorBuilder: () => const HSpace(6.0), + children: [ + if (icon != null) + FlowySvg( + icon!, + size: const Size.square(16), + ), + Expanded(child: child), + FlowySvg( + FlowySvgs.icon_right_small_ccm_outlined_s, + size: const Size.square(14), + color: Theme.of(context).hintColor, + ), + ], + ), + ), + ), + ); + } +} + +class FilterItemInnerTextField extends StatefulWidget { + const FilterItemInnerTextField({ + super.key, + required this.content, + required this.enabled, + required this.onSubmitted, + }); + + final String content; + final bool enabled; + final void Function(String) onSubmitted; + + @override + State createState() => + _FilterItemInnerTextFieldState(); +} + +class _FilterItemInnerTextFieldState extends State { + late final TextEditingController textController = + TextEditingController(text: widget.content); + final FocusNode focusNode = FocusNode(); + final Debounce debounce = Debounce(duration: 300.milliseconds); + + @override + void dispose() { + focusNode.dispose(); + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44, + child: TextField( + enabled: widget.enabled, + focusNode: focusNode, + controller: textController, + onSubmitted: widget.onSubmitted, + onChanged: (value) => debounce.call(() => widget.onSubmitted(value)), + onTapOutside: (_) => focusNode.unfocus(), + decoration: InputDecoration( + filled: true, + fillColor: widget.enabled + ? Theme.of(context).colorScheme.surface + : Theme.of(context).disabledColor, + enabledBorder: _getBorder(Theme.of(context).dividerColor), + border: _getBorder(Theme.of(context).dividerColor), + focusedBorder: _getBorder(Theme.of(context).colorScheme.primary), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + ), + ), + ); + } + + InputBorder _getBorder(Color color) { + return OutlineInputBorder( + borderSide: BorderSide( + width: 0.5, + color: color, + ), + borderRadius: Corners.s10Border, + ); + } +} + +class _FilterDetail extends StatelessWidget { + const _FilterDetail(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + create: () { + return _FilterableFieldList( + onSelectField: (field) { + context + .read() + .add(FilterEditorEvent.createFilter(field)); + context.read().returnToOverview( + scrollToBottom: true, + ); + }, + ); + }, + editField: (filterId) { + return _FilterableFieldList( + onSelectField: (field) { + final filter = context + .read() + .state + .filters + .firstWhereOrNull((filter) => filter.filterId == filterId); + if (filter != null && field.id != filter.fieldId) { + context.read().add( + FilterEditorEvent.changeFilteringField(filterId, field), + ); + } + context.read().returnToOverview(); + }, + ); + }, + editCondition: (filterId, newFilter, showSave) { + return _FilterConditionList( + filterId: filterId, + onSelect: (newFilter) { + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + context.read().returnToOverview(); + }, + ); + }, + editContent: (filterId, filter) { + return _FilterContentEditor( + filter: filter, + onUpdateFilter: (newFilter) { + context.read().updateFilter(newFilter); + }, + ); + }, + orElse: () => const SizedBox.shrink(), + ); + }, + ); + } +} + +class _FilterableFieldList extends StatelessWidget { + const _FilterableFieldList({ + required this.onSelectField, + }); + + final void Function(FieldInfo field) onSelectField; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(4.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: FlowyText( + LocaleKeys.grid_settings_filterBy.tr().toUpperCase(), + fontSize: 13, + color: Theme.of(context).hintColor, + ), + ), + const VSpace(4.0), + const Divider( + height: 0.5, + thickness: 0.5, + ), + Expanded( + child: BlocBuilder( + builder: (context, blocState) { + return ListView.builder( + itemCount: blocState.fields.length, + itemBuilder: (context, index) { + return FlowyOptionTile.text( + text: blocState.fields[index].name, + leftIcon: FieldIcon( + fieldInfo: blocState.fields[index], + ), + showTopBorder: false, + onTap: () => onSelectField(blocState.fields[index]), + ); + }, + ); + }, + ), + ), + ], + ); + } +} + +class _FilterConditionList extends StatelessWidget { + const _FilterConditionList({ + required this.filterId, + required this.onSelect, + }); + + final String filterId; + final void Function(DatabaseFilter filter) onSelect; + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.filters.firstWhereOrNull( + (filter) => filter.filterId == filterId, + ), + builder: (context, filter) { + if (filter == null) { + return const SizedBox.shrink(); + } + + if (filter is DateTimeFilter?) { + return _DateTimeFilterConditionList( + onSelect: (filter) { + if (filter.fieldType == FieldType.DateTime) { + context.read().updateFilter(filter); + } else { + onSelect(filter); + } + }, + ); + } + + final conditions = + FilterCondition.fromFieldType(filter.fieldType).conditions; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(4.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: FlowyText( + LocaleKeys.grid_filter_conditon.tr().toUpperCase(), + fontSize: 13, + color: Theme.of(context).hintColor, + ), + ), + const VSpace(4.0), + const Divider( + height: 0.5, + thickness: 0.5, + ), + Expanded( + child: ListView.builder( + itemCount: conditions.length, + itemBuilder: (context, index) { + return FlowyOptionTile.checkbox( + text: conditions[index].$2, + showTopBorder: false, + isSelected: _isSelected(filter, conditions[index].$1), + onTap: () { + final newFilter = + _updateCondition(filter, conditions[index].$1); + onSelect(newFilter); + }, + ); + }, + ), + ), + ], + ); + }, + ); + } + + bool _isSelected(DatabaseFilter filter, ProtobufEnum condition) { + return switch (filter.fieldType) { + FieldType.RichText || + FieldType.URL => + (filter as TextFilter).condition == condition, + FieldType.Number => (filter as NumberFilter).condition == condition, + FieldType.SingleSelect || + FieldType.MultiSelect => + (filter as SelectOptionFilter).condition == condition, + FieldType.Checkbox => (filter as CheckboxFilter).condition == condition, + FieldType.Checklist => (filter as ChecklistFilter).condition == condition, + _ => false, + }; + } + + DatabaseFilter _updateCondition( + DatabaseFilter filter, + ProtobufEnum condition, + ) { + return switch (filter.fieldType) { + FieldType.RichText || FieldType.URL => (filter as TextFilter) + .copyWith(condition: condition as TextFilterConditionPB), + FieldType.Number => (filter as NumberFilter) + .copyWith(condition: condition as NumberFilterConditionPB), + FieldType.SingleSelect || + FieldType.MultiSelect => + (filter as SelectOptionFilter) + .copyWith(condition: condition as SelectOptionFilterConditionPB), + FieldType.Checkbox => (filter as CheckboxFilter) + .copyWith(condition: condition as CheckboxFilterConditionPB), + FieldType.Checklist => (filter as ChecklistFilter) + .copyWith(condition: condition as ChecklistFilterConditionPB), + _ => filter, + }; + } +} + +class _DateTimeFilterConditionList extends StatelessWidget { + const _DateTimeFilterConditionList({ + required this.onSelect, + }); + + final void Function(DatabaseFilter) onSelect; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + orElse: () => const SizedBox.shrink(), + editCondition: (filterId, newFilter, _) { + final filter = newFilter as DateTimeFilter; + final conditions = + DateTimeFilterCondition.availableConditionsForFieldType( + filter.fieldType, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(4.0), + if (filter.fieldType == FieldType.DateTime) + _DateTimeFilterIsStartSelector( + isStart: filter.condition.isStart, + onSelect: (newValue) { + final newFilter = filter.copyWithCondition( + isStart: newValue, + condition: filter.condition.toCondition(), + ); + onSelect(newFilter); + }, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: FlowyText( + LocaleKeys.grid_filter_conditon.tr().toUpperCase(), + fontSize: 13, + color: Theme.of(context).hintColor, + ), + ), + const VSpace(4.0), + const Divider( + height: 0.5, + thickness: 0.5, + ), + Expanded( + child: ListView.builder( + itemCount: conditions.length, + itemBuilder: (context, index) { + return FlowyOptionTile.checkbox( + text: conditions[index].filterName, + showTopBorder: false, + isSelected: + filter.condition.toCondition() == conditions[index], + onTap: () { + final newFilter = filter.copyWithCondition( + isStart: filter.condition.isStart, + condition: conditions[index], + ); + onSelect(newFilter); + }, + ); + }, + ), + ), + ], + ); + }, + ); + }, + ); + } +} + +class _DateTimeFilterIsStartSelector extends StatelessWidget { + const _DateTimeFilterIsStartSelector({ + required this.isStart, + required this.onSelect, + }); + + final bool isStart; + final void Function(bool isStart) onSelect; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 20), + child: DefaultTabController( + length: 2, + initialIndex: isStart ? 0 : 1, + child: Container( + padding: const EdgeInsets.all(3.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).hoverColor, + ), + child: TabBar( + indicatorSize: TabBarIndicatorSize.label, + labelPadding: EdgeInsets.zero, + padding: EdgeInsets.zero, + indicatorWeight: 0, + indicator: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).colorScheme.surface, + ), + splashFactory: NoSplash.splashFactory, + overlayColor: const WidgetStatePropertyAll( + Colors.transparent, + ), + onTap: (index) => onSelect(index == 0), + tabs: [ + _tab(LocaleKeys.grid_dateFilter_startDate.tr()), + _tab(LocaleKeys.grid_dateFilter_endDate.tr()), + ], + ), + ), + ), + ); + } + + Tab _tab(String name) { + return Tab( + height: 34, + child: Center( + child: FlowyText( + name, + fontSize: 14, + ), + ), + ); + } +} + +class _FilterContentEditor extends StatelessWidget { + const _FilterContentEditor({ + required this.filter, + required this.onUpdateFilter, + }); + + final DatabaseFilter filter; + final void Function(DatabaseFilter) onUpdateFilter; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final field = state.fields + .firstWhereOrNull((field) => field.id == filter.fieldId); + if (field == null) return const SizedBox.shrink(); + return switch (field.fieldType) { + FieldType.SingleSelect || + FieldType.MultiSelect => + _SelectOptionFilterContentEditor( + filter: filter as SelectOptionFilter, + field: field, + ), + FieldType.CreatedTime || + FieldType.LastEditedTime || + FieldType.DateTime => + _DateTimeFilterContentEditor(filter: filter as DateTimeFilter), + _ => const SizedBox.shrink(), + }; + }, + ); + } +} + +class _SelectOptionFilterContentEditor extends StatefulWidget { + _SelectOptionFilterContentEditor({ + required this.filter, + required this.field, + }) : delegate = filter.makeDelegate(field); + + final SelectOptionFilter filter; + final FieldInfo field; + final SelectOptionFilterDelegate delegate; + + @override + State<_SelectOptionFilterContentEditor> createState() => + _SelectOptionFilterContentEditorState(); +} + +class _SelectOptionFilterContentEditorState + extends State<_SelectOptionFilterContentEditor> { + final TextEditingController textController = TextEditingController(); + String filterText = ""; + final List options = []; + + @override + void initState() { + super.initState(); + options.addAll(widget.delegate.getOptions(widget.field)); + } + + @override + void dispose() { + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const Divider( + height: 0.5, + thickness: 0.5, + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: FlowyMobileSearchTextField( + controller: textController, + onChanged: (text) { + if (textController.value.composing.isCollapsed) { + setState(() { + filterText = text; + filterOptions(); + }); + } + }, + onSubmitted: (_) {}, + hintText: LocaleKeys.grid_selectOption_searchOption.tr(), + ), + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + separatorBuilder: (context, index) => const VSpace(20), + itemCount: options.length, + itemBuilder: (context, index) { + return MobileSelectOption( + option: options[index], + isSelected: widget.filter.optionIds.contains(options[index].id), + onTap: (isSelected) { + _onTapHandler( + context, + options, + options[index], + isSelected, + ); + }, + indicator: MobileSelectedOptionIndicator.multi, + showMoreOptionsButton: false, + ); + }, + ), + ), + ], + ); + } + + void filterOptions() { + options + ..clear() + ..addAll(widget.delegate.getOptions(widget.field)); + + if (filterText.isNotEmpty) { + options.retainWhere((option) { + final name = option.name.toLowerCase(); + final lFilter = filterText.toLowerCase(); + return name.contains(lFilter); + }); + } + } + + void _onTapHandler( + BuildContext context, + List options, + SelectOptionPB option, + bool isSelected, + ) { + final selectedOptionIds = Set.from(widget.filter.optionIds); + if (isSelected) { + selectedOptionIds.remove(option.id); + } else { + selectedOptionIds.add(option.id); + } + _updateSelectOptions(context, options, selectedOptionIds); + } + + void _updateSelectOptions( + BuildContext context, + List options, + Set selectedOptionIds, + ) { + final optionIds = + options.map((e) => e.id).where(selectedOptionIds.contains).toList(); + final newFilter = widget.filter.copyWith(optionIds: optionIds); + context.read().updateFilter(newFilter); + } +} + +class _DateTimeFilterContentEditor extends StatefulWidget { + const _DateTimeFilterContentEditor({ + required this.filter, + }); + + final DateTimeFilter filter; + + @override + State<_DateTimeFilterContentEditor> createState() => + _DateTimeFilterContentEditorState(); +} + +class _DateTimeFilterContentEditorState + extends State<_DateTimeFilterContentEditor> { + late DateTime focusedDay; + + bool get isRange => widget.filter.condition.isRange; + + @override + void initState() { + super.initState(); + focusedDay = (isRange ? widget.filter.start : widget.filter.timestamp) ?? + DateTime.now(); + } + + @override + Widget build(BuildContext context) { + return MobileDatePicker( + isRange: isRange, + selectedDay: isRange ? widget.filter.start : widget.filter.timestamp, + startDay: isRange ? widget.filter.start : null, + endDay: isRange ? widget.filter.end : null, + focusedDay: focusedDay, + onDaySelected: (selectedDay) { + final newFilter = isRange + ? widget.filter.copyWithRange(start: selectedDay, end: null) + : widget.filter.copyWithTimestamp(timestamp: selectedDay); + context.read().updateFilter(newFilter); + }, + onRangeSelected: (start, end) { + final newFilter = widget.filter.copyWithRange( + start: start, + end: end, + ); + context.read().updateFilter(newFilter); + }, + onPageChanged: (focusedDay) { + setState(() => this.focusedDay = focusedDay); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_filter_bottom_sheet_cubit.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_filter_bottom_sheet_cubit.dart new file mode 100644 index 0000000000000..a62ef846ae684 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_filter_bottom_sheet_cubit.dart @@ -0,0 +1,104 @@ +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'database_filter_bottom_sheet_cubit.freezed.dart'; + +class MobileFilterEditorCubit extends Cubit { + MobileFilterEditorCubit({ + required this.pageController, + }) : super(MobileFilterEditorState.overview()); + + final PageController pageController; + + void returnToOverview({bool scrollToBottom = false}) { + _animateToPage(0); + emit(MobileFilterEditorState.overview(scrollToBottom: scrollToBottom)); + } + + void startCreatingFilter() { + _animateToPage(1); + emit(MobileFilterEditorState.create()); + } + + void startEditingFilterField(String filterId) { + _animateToPage(1); + emit(MobileFilterEditorState.editField(filterId: filterId)); + } + + void updateFilter(DatabaseFilter filter) { + emit( + state.maybeWhen( + editCondition: (filterId, newFilter, showSave) => + MobileFilterEditorState.editCondition( + filterId: filterId, + newFilter: filter, + showSave: showSave, + ), + editContent: (filterId, _) => MobileFilterEditorState.editContent( + filterId: filterId, + newFilter: filter, + ), + orElse: () => state, + ), + ); + } + + void startEditingFilterCondition( + String filterId, + DatabaseFilter filter, + bool showSave, + ) { + _animateToPage(1); + emit( + MobileFilterEditorState.editCondition( + filterId: filterId, + newFilter: filter, + showSave: showSave, + ), + ); + } + + void startEditingFilterContent(String filterId, DatabaseFilter filter) { + _animateToPage(1); + emit( + MobileFilterEditorState.editContent( + filterId: filterId, + newFilter: filter, + ), + ); + } + + Future _animateToPage(int page) async { + return pageController.animateToPage( + page, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ); + } +} + +@freezed +class MobileFilterEditorState with _$MobileFilterEditorState { + factory MobileFilterEditorState.overview({ + @Default(false) bool scrollToBottom, + }) = _OverviewState; + + factory MobileFilterEditorState.create() = _CreateState; + + factory MobileFilterEditorState.editField({ + required String filterId, + }) = _EditFieldState; + + factory MobileFilterEditorState.editCondition({ + required String filterId, + required DatabaseFilter newFilter, + required bool showSave, + }) = _EditConditionState; + + factory MobileFilterEditorState.editContent({ + required String filterId, + required DatabaseFilter newFilter, + }) = _EditContentState; +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_filter_condition_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_filter_condition_list.dart new file mode 100644 index 0000000000000..d3d0b9bcdd58b --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_filter_condition_list.dart @@ -0,0 +1,114 @@ +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +abstract class FilterCondition { + static FilterCondition fromFieldType(FieldType fieldType) { + return switch (fieldType) { + FieldType.RichText || FieldType.URL => TextFilterCondition().as(), + FieldType.Number => NumberFilterCondition().as(), + FieldType.Checkbox => CheckboxFilterCondition().as(), + FieldType.Checklist => ChecklistFilterCondition().as(), + FieldType.SingleSelect => SingleSelectOptionFilterCondition().as(), + FieldType.MultiSelect => MultiSelectOptionFilterCondition().as(), + _ => MultiSelectOptionFilterCondition().as(), + }; + } + + List<(C, String)> get conditions; +} + +mixin _GenericCastHelper { + FilterCondition as() => this as FilterCondition; +} + +final class TextFilterCondition + with _GenericCastHelper + implements FilterCondition { + @override + List<(TextFilterConditionPB, String)> get conditions { + return [ + TextFilterConditionPB.TextContains, + TextFilterConditionPB.TextDoesNotContain, + TextFilterConditionPB.TextIs, + TextFilterConditionPB.TextIsNot, + TextFilterConditionPB.TextStartsWith, + TextFilterConditionPB.TextEndsWith, + TextFilterConditionPB.TextIsEmpty, + TextFilterConditionPB.TextIsNotEmpty, + ].map((e) => (e, e.filterName)).toList(); + } +} + +final class NumberFilterCondition + with _GenericCastHelper + implements FilterCondition { + @override + List<(NumberFilterConditionPB, String)> get conditions { + return [ + NumberFilterConditionPB.Equal, + NumberFilterConditionPB.NotEqual, + NumberFilterConditionPB.LessThan, + NumberFilterConditionPB.LessThanOrEqualTo, + NumberFilterConditionPB.GreaterThan, + NumberFilterConditionPB.GreaterThanOrEqualTo, + NumberFilterConditionPB.NumberIsEmpty, + NumberFilterConditionPB.NumberIsNotEmpty, + ].map((e) => (e, e.filterName)).toList(); + } +} + +final class CheckboxFilterCondition + with _GenericCastHelper + implements FilterCondition { + @override + List<(CheckboxFilterConditionPB, String)> get conditions { + return [ + CheckboxFilterConditionPB.IsChecked, + CheckboxFilterConditionPB.IsUnChecked, + ].map((e) => (e, e.filterName)).toList(); + } +} + +final class ChecklistFilterCondition + with _GenericCastHelper + implements FilterCondition { + @override + List<(ChecklistFilterConditionPB, String)> get conditions { + return [ + ChecklistFilterConditionPB.IsComplete, + ChecklistFilterConditionPB.IsIncomplete, + ].map((e) => (e, e.filterName)).toList(); + } +} + +final class SingleSelectOptionFilterCondition + with _GenericCastHelper + implements FilterCondition { + @override + List<(SelectOptionFilterConditionPB, String)> get conditions { + return [ + SelectOptionFilterConditionPB.OptionIs, + SelectOptionFilterConditionPB.OptionIsNot, + SelectOptionFilterConditionPB.OptionIsEmpty, + SelectOptionFilterConditionPB.OptionIsNotEmpty, + ].map((e) => (e, e.i18n)).toList(); + } +} + +final class MultiSelectOptionFilterCondition + with _GenericCastHelper + implements FilterCondition { + @override + List<(SelectOptionFilterConditionPB, String)> get conditions { + return [ + SelectOptionFilterConditionPB.OptionContains, + SelectOptionFilterConditionPB.OptionDoesNotContain, + SelectOptionFilterConditionPB.OptionIsEmpty, + SelectOptionFilterConditionPB.OptionIsNotEmpty, + ].map((e) => (e, e.i18n)).toList(); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart new file mode 100644 index 0000000000000..009468a8f1d7f --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet.dart @@ -0,0 +1,581 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/sort_entities.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; + +import 'database_sort_bottom_sheet_cubit.dart'; + +class MobileSortEditor extends StatefulWidget { + const MobileSortEditor({ + super.key, + }); + + @override + State createState() => _MobileSortEditorState(); +} + +class _MobileSortEditorState extends State { + final PageController _pageController = PageController(); + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => MobileSortEditorCubit( + pageController: _pageController, + ), + child: Column( + children: [ + const _Header(), + SizedBox( + height: 400, //314, + child: PageView.builder( + controller: _pageController, + itemCount: 2, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return index == 0 + ? Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + child: const _Overview(), + ) + : const _SortDetail(); + }, + ), + ), + ], + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SizedBox( + height: 44.0, + child: Stack( + children: [ + if (state.showBackButton) + Align( + alignment: Alignment.centerLeft, + child: AppBarBackButton( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + onTap: () => context + .read() + .returnToOverview(), + ), + ), + Align( + child: FlowyText.medium( + LocaleKeys.grid_settings_sort.tr(), + fontSize: 16.0, + ), + ), + if (state.isCreatingNewSort) + Align( + alignment: Alignment.centerRight, + child: AppBarSaveButton( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + enable: state.newSortFieldId != null, + onTap: () { + _tryCreateSort(context, state); + context.read().returnToOverview(); + }, + ), + ), + ], + ), + ); + }, + ); + } + + void _tryCreateSort(BuildContext context, MobileSortEditorState state) { + if (state.newSortFieldId != null && state.newSortCondition != null) { + context.read().add( + SortEditorEvent.createSort( + fieldId: state.newSortFieldId!, + condition: state.newSortCondition!, + ), + ); + } + } +} + +class _Overview extends StatelessWidget { + const _Overview(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + children: [ + Expanded( + child: state.sorts.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.sort_descending_s, + size: const Size.square(60), + color: Theme.of(context).hintColor, + ), + FlowyText( + LocaleKeys.grid_sort_empty.tr(), + color: Theme.of(context).hintColor, + ), + ], + ), + ) + : ReorderableListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: child, + ), + onReorder: (oldIndex, newIndex) => context + .read() + .add(SortEditorEvent.reorderSort(oldIndex, newIndex)), + itemCount: state.sorts.length, + itemBuilder: (context, index) => _SortItem( + key: ValueKey("sort_item_$index"), + sort: state.sorts[index], + ), + ), + ), + Container( + height: 44, + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide( + width: 0.5, + color: Theme.of(context).dividerColor, + ), + ), + borderRadius: Corners.s10Border, + ), + child: InkWell( + onTap: () { + final firstField = context + .read() + .state + .creatableFields + .firstOrNull; + if (firstField == null) { + Fluttertoast.showToast( + msg: LocaleKeys.grid_sort_cannotFindCreatableField.tr(), + gravity: ToastGravity.BOTTOM, + ); + } else { + context.read().startCreatingSort(); + } + }, + borderRadius: Corners.s10Border, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.add_s, + size: Size.square(16), + ), + const HSpace(6.0), + FlowyText( + LocaleKeys.grid_sort_addSort.tr(), + fontSize: 15, + ), + ], + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +class _SortItem extends StatelessWidget { + const _SortItem({super.key, required this.sort}); + + final DatabaseSort sort; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric( + vertical: 4.0, + ), + decoration: BoxDecoration( + color: Theme.of(context).hoverColor, + borderRadius: BorderRadius.circular(12), + ), + child: Stack( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => context + .read() + .startEditingSort(sort.sortId), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: FlowyText.medium( + LocaleKeys.grid_sort_by.tr(), + fontSize: 15, + ), + ), + const VSpace(10), + Row( + children: [ + Expanded( + child: Container( + height: 44, + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide( + width: 0.5, + color: Theme.of(context).dividerColor, + ), + ), + borderRadius: Corners.s10Border, + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Center( + child: Row( + children: [ + Expanded( + child: BlocSelector( + selector: (state) => + state.allFields.firstWhereOrNull( + (field) => field.id == sort.fieldId, + ), + builder: (context, field) { + return FlowyText( + field?.name ?? "", + overflow: TextOverflow.ellipsis, + ); + }, + ), + ), + const HSpace(6.0), + FlowySvg( + FlowySvgs.icon_right_small_ccm_outlined_s, + size: const Size.square(14), + color: Theme.of(context).hintColor, + ), + ], + ), + ), + ), + ), + const HSpace(6), + Expanded( + child: Container( + height: 44, + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide( + width: 0.5, + color: Theme.of(context).dividerColor, + ), + ), + borderRadius: Corners.s10Border, + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsetsDirectional.only( + start: 12, + end: 10, + ), + child: Center( + child: Row( + children: [ + Expanded( + child: FlowyText( + sort.condition.name, + ), + ), + const HSpace(6.0), + FlowySvg( + FlowySvgs.icon_right_small_ccm_outlined_s, + size: const Size.square(14), + color: Theme.of(context).hintColor, + ), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + Positioned( + right: 8, + top: 6, + child: InkWell( + onTap: () => context + .read() + .add(SortEditorEvent.deleteSort(sort.sortId)), + // steal from the container LongClickReorderWidget thing + onLongPress: () {}, + borderRadius: BorderRadius.circular(10), + child: SizedBox.square( + dimension: 34, + child: Center( + child: FlowySvg( + FlowySvgs.trash_m, + size: const Size.square(18), + color: Theme.of(context).hintColor, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _SortDetail extends StatelessWidget { + const _SortDetail(); + + @override + Widget build(BuildContext context) { + final isCreatingNewSort = + context.read().state.isCreatingNewSort; + + return isCreatingNewSort + ? const _SortDetailContent() + : BlocSelector( + selector: (state) => state.sorts.firstWhere( + (sort) => + sort.sortId == + context.read().state.editingSortId, + ), + builder: (context, sort) { + return _SortDetailContent(sort: sort); + }, + ); + } +} + +class _SortDetailContent extends StatelessWidget { + const _SortDetailContent({ + this.sort, + }); + + final DatabaseSort? sort; + + bool get isCreatingNewSort => sort == null; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DefaultTabController( + length: 2, + initialIndex: isCreatingNewSort + ? 0 + : sort!.condition == SortConditionPB.Ascending + ? 0 + : 1, + child: Container( + padding: const EdgeInsets.all(3.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).hoverColor, + ), + child: TabBar( + indicatorSize: TabBarIndicatorSize.label, + labelPadding: EdgeInsets.zero, + padding: EdgeInsets.zero, + indicatorWeight: 0, + indicator: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).colorScheme.surface, + ), + splashFactory: NoSplash.splashFactory, + overlayColor: const WidgetStatePropertyAll( + Colors.transparent, + ), + onTap: (index) { + final newCondition = index == 0 + ? SortConditionPB.Ascending + : SortConditionPB.Descending; + _changeCondition(context, newCondition); + }, + tabs: [ + Tab( + height: 34, + child: Center( + child: FlowyText( + LocaleKeys.grid_sort_ascending.tr(), + fontSize: 14, + ), + ), + ), + Tab( + height: 34, + child: Center( + child: FlowyText( + LocaleKeys.grid_sort_descending.tr(), + fontSize: 14, + ), + ), + ), + ], + ), + ), + ), + ), + const VSpace(20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: FlowyText( + LocaleKeys.grid_settings_sortBy.tr().toUpperCase(), + fontSize: 13, + color: Theme.of(context).hintColor, + ), + ), + const VSpace(4.0), + const Divider( + height: 0.5, + thickness: 0.5, + ), + Expanded( + child: BlocBuilder( + builder: (context, state) { + final fields = state.allFields + .where((field) => field.fieldType.canCreateSort) + .toList(); + return ListView.builder( + itemCount: fields.length, + itemBuilder: (context, index) { + final fieldInfo = fields[index]; + final isSelected = isCreatingNewSort + ? context + .watch() + .state + .newSortFieldId == + fieldInfo.id + : sort!.fieldId == fieldInfo.id; + + final canSort = + fieldInfo.fieldType.canCreateSort && !fieldInfo.hasSort; + final beingEdited = + !isCreatingNewSort && sort!.fieldId == fieldInfo.id; + final enabled = canSort || beingEdited; + + return FlowyOptionTile.checkbox( + text: fieldInfo.field.name, + leftIcon: FieldIcon( + fieldInfo: fieldInfo, + ), + isSelected: isSelected, + textColor: enabled ? null : Theme.of(context).disabledColor, + showTopBorder: false, + onTap: () { + if (isSelected) { + return; + } + if (enabled) { + _changeFieldId(context, fieldInfo.id); + } else { + Fluttertoast.showToast( + msg: LocaleKeys.grid_sort_fieldInUse.tr(), + gravity: ToastGravity.BOTTOM, + ); + } + }, + ); + }, + ); + }, + ), + ), + ], + ); + } + + void _changeCondition(BuildContext context, SortConditionPB newCondition) { + if (isCreatingNewSort) { + context.read().changeSortCondition(newCondition); + } else { + context.read().add( + SortEditorEvent.editSort( + sortId: sort!.sortId, + condition: newCondition, + ), + ); + } + } + + void _changeFieldId(BuildContext context, String newFieldId) { + if (isCreatingNewSort) { + context.read().changeFieldId(newFieldId); + } else { + context.read().add( + SortEditorEvent.editSort( + sortId: sort!.sortId, + fieldId: newFieldId, + ), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet_cubit.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet_cubit.dart new file mode 100644 index 0000000000000..684f98e5640bc --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_sort_bottom_sheet_cubit.dart @@ -0,0 +1,77 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'database_sort_bottom_sheet_cubit.freezed.dart'; + +class MobileSortEditorCubit extends Cubit { + MobileSortEditorCubit({ + required this.pageController, + }) : super(MobileSortEditorState.initial()); + + final PageController pageController; + + void returnToOverview() { + _animateToPage(0); + emit(MobileSortEditorState.initial()); + } + + void startCreatingSort() { + _animateToPage(1); + emit( + state.copyWith( + showBackButton: true, + isCreatingNewSort: true, + newSortCondition: SortConditionPB.Ascending, + ), + ); + } + + void startEditingSort(String sortId) { + _animateToPage(1); + emit( + state.copyWith( + showBackButton: true, + editingSortId: sortId, + ), + ); + } + + /// only used when creating a new sort + void changeFieldId(String fieldId) { + emit(state.copyWith(newSortFieldId: fieldId)); + } + + /// only used when creating a new sort + void changeSortCondition(SortConditionPB condition) { + emit(state.copyWith(newSortCondition: condition)); + } + + Future _animateToPage(int page) async { + return pageController.animateToPage( + page, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ); + } +} + +@freezed +class MobileSortEditorState with _$MobileSortEditorState { + factory MobileSortEditorState({ + required bool showBackButton, + required String? editingSortId, + required bool isCreatingNewSort, + required String? newSortFieldId, + required SortConditionPB? newSortCondition, + }) = _MobileSortEditorState; + + factory MobileSortEditorState.initial() => MobileSortEditorState( + showBackButton: false, + editingSortId: null, + isCreatingNewSort: false, + newSortFieldId: null, + newSortCondition: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_layout.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_layout.dart new file mode 100644 index 0000000000000..fdce3d6d21d72 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_layout.dart @@ -0,0 +1,202 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/calendar/application/calendar_setting_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../field/mobile_field_bottom_sheets.dart'; + +/// [DatabaseViewLayoutPicker] is seen when changing the layout type of a +/// database view or creating a new database view. +class DatabaseViewLayoutPicker extends StatelessWidget { + const DatabaseViewLayoutPicker({ + super.key, + required this.selectedLayout, + required this.onSelect, + }); + + final DatabaseLayoutPB selectedLayout; + final void Function(DatabaseLayoutPB layout) onSelect; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildButton(DatabaseLayoutPB.Grid, true), + _buildButton(DatabaseLayoutPB.Board, false), + _buildButton(DatabaseLayoutPB.Calendar, false), + ], + ); + } + + Widget _buildButton(DatabaseLayoutPB layout, bool showTopBorder) { + return FlowyOptionTile.checkbox( + text: layout.layoutName, + leftIcon: FlowySvg(layout.icon, size: const Size.square(20)), + isSelected: selectedLayout == layout, + showTopBorder: showTopBorder, + onTap: () { + onSelect(layout); + }, + ); + } +} + +/// [MobileCalendarViewLayoutSettings] is used when the database layout is +/// calendar. It allows changing the field being used to layout the events, +/// and which day of the week the calendar starts on. +class MobileCalendarViewLayoutSettings extends StatelessWidget { + const MobileCalendarViewLayoutSettings({ + super.key, + required this.databaseController, + }); + + final DatabaseController databaseController; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) { + return CalendarSettingBloc( + databaseController: databaseController, + )..add(const CalendarSettingEvent.initial()); + }, + child: BlocBuilder( + builder: (context, state) { + if (state.layoutSetting == null) { + return const SizedBox.shrink(); + } + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CalendarLayoutField( + context: context, + databaseController: databaseController, + selectedFieldId: state.layoutSetting?.fieldId, + ), + _divider(), + ..._startWeek(context, state.layoutSetting?.firstDayOfWeek), + ], + ); + }, + ), + ); + } + + List _startWeek(BuildContext context, int? firstDayOfWeek) { + final symbols = DateFormat.EEEE(context.locale.toLanguageTag()).dateSymbols; + return [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 4.0), + child: FlowyText( + LocaleKeys.calendar_settings_firstDayOfWeek.tr().toUpperCase(), + fontSize: 13, + color: Theme.of(context).hintColor, + ), + ), + FlowyOptionTile.checkbox( + text: symbols.WEEKDAYS[0], + isSelected: firstDayOfWeek! == 0, + onTap: () { + context.read().add( + const CalendarSettingEvent.updateLayoutSetting( + firstDayOfWeek: 0, + ), + ); + }, + ), + FlowyOptionTile.checkbox( + text: symbols.WEEKDAYS[1], + isSelected: firstDayOfWeek == 1, + showTopBorder: false, + onTap: () { + context.read().add( + const CalendarSettingEvent.updateLayoutSetting( + firstDayOfWeek: 1, + ), + ); + }, + ), + ]; + } + + Widget _divider() => const VSpace(20); +} + +class _CalendarLayoutField extends StatelessWidget { + const _CalendarLayoutField({ + required this.context, + required this.databaseController, + required this.selectedFieldId, + }); + + final BuildContext context; + final DatabaseController databaseController; + final String? selectedFieldId; + + @override + Widget build(BuildContext context) { + FieldInfo? selectedField; + if (selectedFieldId != null) { + selectedField = + databaseController.fieldController.getField(selectedFieldId!); + } + return FlowyOptionTile.text( + text: LocaleKeys.calendar_settings_layoutDateField.tr(), + trailing: selectedFieldId == null + ? null + : Row( + children: [ + FlowyText( + selectedField!.name, + color: Theme.of(context).hintColor, + ), + const HSpace(8), + const FlowySvg(FlowySvgs.arrow_right_s), + ], + ), + onTap: () async { + final newFieldId = await showFieldPicker( + context, + LocaleKeys.calendar_settings_changeLayoutDateField.tr(), + selectedFieldId, + databaseController.fieldController, + (field) => field.fieldType == FieldType.DateTime, + ); + if (context.mounted && + newFieldId != null && + newFieldId != selectedFieldId) { + context.read().add( + CalendarSettingEvent.updateLayoutSetting( + layoutFieldId: newFieldId, + ), + ); + } + }, + ); + } +} + +class MobileBoardViewLayoutSettings extends StatelessWidget { + const MobileBoardViewLayoutSettings({ + super.key, + required this.databaseController, + }); + + final DatabaseController databaseController; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.text(text: LocaleKeys.board_groupBy.tr()); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_list.dart new file mode 100644 index 0000000000000..763da369184ee --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_list.dart @@ -0,0 +1,302 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import 'database_view_layout.dart'; +import 'database_view_quick_actions.dart'; + +/// [MobileDatabaseViewList] shows a list of all the views in the database and +/// adds a button to create a new database view. +class MobileDatabaseViewList extends StatelessWidget { + const MobileDatabaseViewList({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final views = [state.view, ...state.view.childViews]; + + return Column( + children: [ + _Header( + title: LocaleKeys.grid_settings_viewList.plural( + context.watch().state.tabBars.length, + namedArgs: { + 'count': + '${context.watch().state.tabBars.length}', + }, + ), + showBackButton: false, + useFilledDoneButton: false, + onDone: (context) => Navigator.pop(context), + ), + Expanded( + child: ListView( + shrinkWrap: true, + padding: EdgeInsets.zero, + children: [ + ...views.mapIndexed( + (index, view) => MobileDatabaseViewListButton( + view: view, + showTopBorder: index == 0, + ), + ), + const VSpace(20), + const MobileNewDatabaseViewButton(), + VSpace( + context.bottomSheetPadding(ignoreViewPadding: false), + ), + ], + ), + ), + ], + ); + }, + ); + } +} + +/// Same header as the one in showMobileBottomSheet, but allows popping the +/// sheet with a value. +class _Header extends StatelessWidget { + const _Header({ + required this.title, + required this.showBackButton, + required this.useFilledDoneButton, + required this.onDone, + }); + + final String title; + final bool showBackButton; + final bool useFilledDoneButton; + final void Function(BuildContext context) onDone; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: SizedBox( + height: 44.0, + child: Stack( + children: [ + if (showBackButton) + const Align( + alignment: Alignment.centerLeft, + child: AppBarBackButton(), + ), + Align( + child: FlowyText.medium( + title, + fontSize: 16.0, + ), + ), + useFilledDoneButton + ? Align( + alignment: Alignment.centerRight, + child: AppBarFilledDoneButton( + onTap: () => onDone(context), + ), + ) + : Align( + alignment: Alignment.centerRight, + child: AppBarDoneButton( + onTap: () => onDone(context), + ), + ), + ], + ), + ), + ); + } +} + +@visibleForTesting +class MobileDatabaseViewListButton extends StatelessWidget { + const MobileDatabaseViewListButton({ + super.key, + required this.view, + required this.showTopBorder, + }); + + final ViewPB view; + final bool showTopBorder; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final index = + state.tabBars.indexWhere((tabBar) => tabBar.viewId == view.id); + final isSelected = index == state.selectedIndex; + return FlowyOptionTile.text( + text: view.name, + onTap: () { + context + .read() + .add(DatabaseTabBarEvent.selectView(view.id)); + }, + leftIcon: _buildViewIconButton(context, view), + trailing: _trailing( + context, + state.tabBarControllerByViewId[view.id]!.controller, + isSelected, + ), + showTopBorder: showTopBorder, + ); + }, + ); + } + + Widget _buildViewIconButton(BuildContext context, ViewPB view) { + return SizedBox.square( + dimension: 20.0, + child: view.defaultIcon(), + ); + } + + Widget _trailing( + BuildContext context, + DatabaseController databaseController, + bool isSelected, + ) { + final more = FlowyIconButton( + icon: FlowySvg( + FlowySvgs.three_dots_s, + size: const Size.square(20), + color: Theme.of(context).hintColor, + ), + onPressed: () { + showMobileBottomSheet( + context, + showDragHandle: true, + backgroundColor: AFThemeExtension.of(context).background, + builder: (_) { + return BlocProvider( + create: (_) => + ViewBloc(view: view)..add(const ViewEvent.initial()), + child: MobileDatabaseViewQuickActions( + view: view, + databaseController: databaseController, + ), + ); + }, + ); + }, + ); + if (isSelected) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.m_blue_check_s, + size: Size.square(20), + blendMode: BlendMode.dst, + ), + const HSpace(8), + more, + ], + ); + } else { + return more; + } + } +} + +class MobileNewDatabaseViewButton extends StatelessWidget { + const MobileNewDatabaseViewButton({super.key}); + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.text( + text: LocaleKeys.grid_settings_createView.tr(), + textColor: Theme.of(context).hintColor, + leftIcon: FlowySvg( + FlowySvgs.add_s, + size: const Size.square(20), + color: Theme.of(context).hintColor, + ), + onTap: () async { + final result = await showMobileBottomSheet<(DatabaseLayoutPB, String)>( + context, + showDragHandle: true, + builder: (_) { + return const MobileCreateDatabaseView(); + }, + ); + if (context.mounted && result != null) { + context + .read() + .add(DatabaseTabBarEvent.createView(result.$1, result.$2)); + } + }, + ); + } +} + +class MobileCreateDatabaseView extends StatefulWidget { + const MobileCreateDatabaseView({super.key}); + + @override + State createState() => + _MobileCreateDatabaseViewState(); +} + +class _MobileCreateDatabaseViewState extends State { + late final TextEditingController controller; + DatabaseLayoutPB layoutType = DatabaseLayoutPB.Grid; + + @override + void initState() { + super.initState(); + controller = TextEditingController( + text: LocaleKeys.grid_title_placeholder.tr(), + ); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _Header( + title: LocaleKeys.grid_settings_createView.tr(), + showBackButton: true, + useFilledDoneButton: true, + onDone: (context) => + context.pop((layoutType, controller.text.trim())), + ), + FlowyOptionTile.textField( + autofocus: true, + controller: controller, + ), + const VSpace(20), + DatabaseViewLayoutPicker( + selectedLayout: layoutType, + onSelect: (layout) { + setState(() => layoutType = layout); + }, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_quick_actions.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_quick_actions.dart new file mode 100644 index 0000000000000..652c93496e78c --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/database_view_quick_actions.dart @@ -0,0 +1,122 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import 'edit_database_view_screen.dart'; + +/// [MobileDatabaseViewQuickActions] is gives users to quickly edit a database +/// view from the [MobileDatabaseViewList] +class MobileDatabaseViewQuickActions extends StatelessWidget { + const MobileDatabaseViewQuickActions({ + super.key, + required this.view, + required this.databaseController, + }); + + final ViewPB view; + final DatabaseController databaseController; + + @override + Widget build(BuildContext context) { + final isInline = view.childViews.isNotEmpty; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _actionButton(context, _Action.edit, () async { + final bloc = context.read(); + await showTransitionMobileBottomSheet( + context, + showHeader: true, + showDoneButton: true, + title: LocaleKeys.grid_settings_editView.tr(), + builder: (_) => BlocProvider.value( + value: bloc, + child: MobileEditDatabaseViewScreen( + databaseController: databaseController, + ), + ), + ); + if (context.mounted) { + context.pop(); + } + }), + _divider(), + _actionButton( + context, + _Action.duplicate, + () { + context.read().add(const ViewEvent.duplicate()); + context.pop(); + }, + !isInline, + ), + _divider(), + _actionButton( + context, + _Action.delete, + () { + context.read().add(const ViewEvent.delete()); + context.pop(); + }, + !isInline, + ), + _divider(), + ], + ); + } + + Widget _actionButton( + BuildContext context, + _Action action, + VoidCallback onTap, [ + bool enable = true, + ]) { + return MobileQuickActionButton( + icon: action.icon, + text: action.label, + textColor: action.color(context), + iconColor: action.color(context), + onTap: onTap, + enable: enable, + ); + } + + Widget _divider() => const Divider(height: 8.5, thickness: 0.5); +} + +enum _Action { + edit, + duplicate, + delete; + + String get label { + return switch (this) { + edit => LocaleKeys.grid_settings_editView.tr(), + duplicate => LocaleKeys.button_duplicate.tr(), + delete => LocaleKeys.button_delete.tr(), + }; + } + + FlowySvgData get icon { + return switch (this) { + edit => FlowySvgs.view_item_rename_s, + duplicate => FlowySvgs.duplicate_s, + delete => FlowySvgs.trash_s, + }; + } + + Color? color(BuildContext context) { + return switch (this) { + delete => Theme.of(context).colorScheme.error, + _ => null, + }; + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/view/edit_database_view_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/edit_database_view_screen.dart new file mode 100644 index 0000000000000..f5812541c8336 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/view/edit_database_view_screen.dart @@ -0,0 +1,288 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/domain/database_view_service.dart'; +import 'package:appflowy/plugins/database/domain/layout_service.dart'; +import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import 'database_field_list.dart'; +import 'database_view_layout.dart'; + +/// [MobileEditDatabaseViewScreen] is the main widget used to edit a database +/// view. It contains multiple sub-pages, and the current page is managed by +/// [MobileEditDatabaseViewCubit] +class MobileEditDatabaseViewScreen extends StatelessWidget { + const MobileEditDatabaseViewScreen({ + super.key, + required this.databaseController, + }); + + final DatabaseController databaseController; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + children: [ + _NameAndIcon(view: state.view), + _divider(), + DatabaseViewSettingTile( + setting: DatabaseViewSettings.layout, + databaseController: databaseController, + view: state.view, + showTopBorder: true, + ), + if (databaseController.databaseLayout == DatabaseLayoutPB.Calendar) + DatabaseViewSettingTile( + setting: DatabaseViewSettings.calendar, + databaseController: databaseController, + view: state.view, + ), + DatabaseViewSettingTile( + setting: DatabaseViewSettings.fields, + databaseController: databaseController, + view: state.view, + ), + _divider(), + ], + ); + }, + ); + } + + Widget _divider() => const VSpace(20); +} + +class _NameAndIcon extends StatefulWidget { + const _NameAndIcon({required this.view}); + + final ViewPB view; + + @override + State<_NameAndIcon> createState() => _NameAndIconState(); +} + +class _NameAndIconState extends State<_NameAndIcon> { + final TextEditingController textEditingController = TextEditingController(); + + @override + void initState() { + super.initState(); + textEditingController.text = widget.view.name; + } + + @override + void dispose() { + textEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + child: FlowyOptionTile.textField( + autofocus: true, + showTopBorder: false, + controller: textEditingController, + onTextChanged: (text) { + context.read().add(ViewEvent.rename(text)); + }, + ), + ); + } +} + +enum DatabaseViewSettings { + layout, + fields, + filter, + sort, + board, + calendar, + duplicate, + delete; + + String get label { + return switch (this) { + layout => LocaleKeys.grid_settings_databaseLayout.tr(), + fields => LocaleKeys.grid_settings_properties.tr(), + filter => LocaleKeys.grid_settings_filter.tr(), + sort => LocaleKeys.grid_settings_sort.tr(), + board => LocaleKeys.grid_settings_boardSettings.tr(), + calendar => LocaleKeys.grid_settings_calendarSettings.tr(), + duplicate => LocaleKeys.grid_settings_duplicateView.tr(), + delete => LocaleKeys.grid_settings_deleteView.tr(), + }; + } + + FlowySvgData get icon { + return switch (this) { + layout => FlowySvgs.card_view_s, + fields => FlowySvgs.disorder_list_s, + filter => FlowySvgs.filter_s, + sort => FlowySvgs.sort_ascending_s, + board => FlowySvgs.board_s, + calendar => FlowySvgs.calendar_s, + duplicate => FlowySvgs.copy_s, + delete => FlowySvgs.delete_s, + }; + } +} + +class DatabaseViewSettingTile extends StatelessWidget { + const DatabaseViewSettingTile({ + super.key, + required this.setting, + required this.databaseController, + required this.view, + this.showTopBorder = false, + }); + + final DatabaseViewSettings setting; + final DatabaseController databaseController; + final ViewPB view; + final bool showTopBorder; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.text( + text: setting.label, + leftIcon: FlowySvg(setting.icon, size: const Size.square(20)), + trailing: _trailing(context, setting, view, databaseController), + showTopBorder: showTopBorder, + onTap: () => _onTap(context), + ); + } + + Widget _trailing( + BuildContext context, + DatabaseViewSettings setting, + ViewPB view, + DatabaseController databaseController, + ) { + switch (setting) { + case DatabaseViewSettings.layout: + return Row( + children: [ + FlowyText( + lineHeight: 1.0, + databaseLayoutFromViewLayout(view.layout).layoutName, + color: Theme.of(context).hintColor, + ), + const HSpace(8), + const FlowySvg(FlowySvgs.arrow_right_s), + ], + ); + case DatabaseViewSettings.fields: + final numVisible = databaseController.fieldController.fieldInfos + .where((field) => field.visibility != FieldVisibility.AlwaysHidden) + .length; + return Row( + children: [ + FlowyText( + LocaleKeys.grid_settings_numberOfVisibleFields + .tr(args: [numVisible.toString()]), + color: Theme.of(context).hintColor, + ), + const HSpace(8), + const FlowySvg(FlowySvgs.arrow_right_s), + ], + ); + default: + return const SizedBox.shrink(); + } + } + + void _onTap(BuildContext context) async { + if (setting == DatabaseViewSettings.layout) { + final databaseLayout = databaseLayoutFromViewLayout(view.layout); + final newLayout = await showMobileBottomSheet( + context, + showDragHandle: true, + showHeader: true, + showDivider: false, + title: LocaleKeys.grid_settings_layout.tr(), + builder: (context) { + return DatabaseViewLayoutPicker( + selectedLayout: databaseLayout, + onSelect: (layout) => Navigator.of(context).pop(layout), + ); + }, + ); + if (newLayout != null && newLayout != databaseLayout) { + await DatabaseViewBackendService.updateLayout( + viewId: databaseController.viewId, + layout: newLayout, + ); + } + return; + } + + if (setting == DatabaseViewSettings.fields) { + await showTransitionMobileBottomSheet( + context, + showHeader: true, + showBackButton: true, + title: LocaleKeys.grid_settings_properties.tr(), + builder: (_) { + return BlocProvider.value( + value: context.read(), + child: MobileDatabaseFieldList( + databaseController: databaseController, + canCreate: true, + ), + ); + }, + ); + return; + } + + if (setting == DatabaseViewSettings.board) { + await showMobileBottomSheet( + context, + builder: (context) { + return Padding( + padding: const EdgeInsets.only(top: 24, bottom: 46), + child: MobileBoardViewLayoutSettings( + databaseController: databaseController, + ), + ); + }, + ); + return; + } + + if (setting == DatabaseViewSettings.calendar) { + await showMobileBottomSheet( + context, + showDragHandle: true, + showHeader: true, + showDivider: false, + title: LocaleKeys.calendar_settings_name.tr(), + builder: (context) { + return MobileCalendarViewLayoutSettings( + databaseController: databaseController, + ); + }, + ); + return; + } + + if (setting == DatabaseViewSettings.delete) { + context.read().add(const ViewEvent.delete()); + context.pop(true); + return; + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart new file mode 100644 index 0000000000000..ab056d174e4b1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/editor/mobile_editor_screen.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; + +class MobileDocumentScreen extends StatelessWidget { + const MobileDocumentScreen({ + super.key, + required this.id, + this.title, + this.showMoreButton = true, + this.fixedTitle, + this.blockId, + }); + + /// view id + final String id; + final String? title; + final bool showMoreButton; + final String? fixedTitle; + final String? blockId; + + static const routeName = '/docs'; + static const viewId = 'id'; + static const viewTitle = 'title'; + static const viewShowMoreButton = 'show_more_button'; + static const viewFixedTitle = 'fixed_title'; + static const viewBlockId = 'block_id'; + + @override + Widget build(BuildContext context) { + return MobileViewPage( + id: id, + title: title, + viewLayout: ViewLayoutPB.Document, + showMoreButton: showMoreButton, + fixedTitle: fixedTitle, + blockId: blockId, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart new file mode 100644 index 0000000000000..ca012891f6066 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_folder.dart @@ -0,0 +1,86 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +class MobileFavoritePageFolder extends StatelessWidget { + const MobileFavoritePageFolder({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final workspaceId = + context.read().state.currentWorkspace?.workspaceId ?? + ''; + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => SidebarSectionsBloc() + ..add(SidebarSectionsEvent.initial(userProfile, workspaceId)), + ), + BlocProvider( + create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), + ), + ], + child: BlocListener( + listener: (context, state) => + context.read().add(const FavoriteEvent.initial()), + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (p, c) => + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, + listener: (context, state) => + context.pushView(state.lastCreatedRootView!), + ), + ], + child: Builder( + builder: (context) { + final favoriteState = context.watch().state; + if (favoriteState.views.isEmpty) { + return FlowyMobileStateContainer.info( + emoji: '😁', + title: LocaleKeys.favorite_noFavorite.tr(), + description: LocaleKeys.favorite_noFavoriteHintText.tr(), + ); + } + return Scrollbar( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SlidableAutoCloseBehavior( + child: Column( + children: [ + MobileFavoriteFolder( + showHeader: false, + forceExpanded: true, + views: + favoriteState.views.map((e) => e.item).toList(), + ), + const VSpace(100.0), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart new file mode 100644 index 0000000000000..e6d2d895b1461 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/favorite/mobile_favorite_page.dart @@ -0,0 +1,112 @@ +import 'dart:io'; + +import 'package:appflowy/mobile/presentation/favorite/mobile_favorite_folder.dart'; +import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/workspace/application/user/prelude.dart'; +import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileFavoriteScreen extends StatelessWidget { + const MobileFavoriteScreen({ + super.key, + }); + + static const routeName = '/favorite'; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: Future.wait([ + FolderEventGetCurrentWorkspaceSetting().send(), + getIt().getUser(), + ]), + builder: (context, snapshots) { + if (!snapshots.hasData) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + + final workspaceSetting = snapshots.data?[0].fold( + (workspaceSettingPB) { + return workspaceSettingPB as WorkspaceSettingPB?; + }, + (error) => null, + ); + final userProfile = snapshots.data?[1].fold( + (userProfilePB) { + return userProfilePB as UserProfilePB?; + }, + (error) => null, + ); + + // In the unlikely case either of the above is null, eg. + // when a workspace is already open this can happen. + if (workspaceSetting == null || userProfile == null) { + return const WorkspaceFailedScreen(); + } + + return Scaffold( + body: SafeArea( + child: BlocProvider( + create: (_) => UserWorkspaceBloc(userProfile: userProfile) + ..add( + const UserWorkspaceEvent.initial(), + ), + child: BlocBuilder( + buildWhen: (previous, current) => + previous.currentWorkspace?.workspaceId != + current.currentWorkspace?.workspaceId, + builder: (context, state) { + return MobileFavoritePage( + userProfile: userProfile, + ); + }, + ), + ), + ), + ); + }, + ); + } +} + +class MobileFavoritePage extends StatelessWidget { + const MobileFavoritePage({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Header + Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: Platform.isAndroid ? 8.0 : 0.0, + ), + child: MobileHomePageHeader( + userProfile: userProfile, + ), + ), + const Divider(), + + // Folder + Expanded( + child: MobileFavoritePageFolder( + userProfile: userProfile, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/favorite_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/favorite_space.dart new file mode 100644 index 0000000000000..6282421109071 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/favorite_space.dart @@ -0,0 +1,129 @@ +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/mobile/presentation/home/shared/empty_placeholder.dart'; +import 'package:appflowy/mobile/presentation/home/shared/mobile_page_card.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/user/prelude.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileFavoriteSpace extends StatefulWidget { + const MobileFavoriteSpace({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + State createState() => _MobileFavoriteSpaceState(); +} + +class _MobileFavoriteSpaceState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + final workspaceId = + context.read().state.currentWorkspace?.workspaceId ?? + ''; + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => SidebarSectionsBloc() + ..add( + SidebarSectionsEvent.initial(widget.userProfile, workspaceId), + ), + ), + BlocProvider( + create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()), + ), + ], + child: BlocListener( + listener: (context, state) => + context.read().add(const FavoriteEvent.initial()), + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (p, c) => + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, + listener: (context, state) => + context.pushView(state.lastCreatedRootView!), + ), + ], + child: Builder( + builder: (context) { + final favoriteState = context.watch().state; + + if (favoriteState.isLoading) { + return const SizedBox.shrink(); + } + + if (favoriteState.views.isEmpty) { + return const EmptySpacePlaceholder( + type: MobilePageCardType.favorite, + ); + } + + return _FavoriteViews( + favoriteViews: favoriteState.views.reversed.toList(), + ); + }, + ), + ), + ), + ); + } +} + +class _FavoriteViews extends StatelessWidget { + const _FavoriteViews({ + required this.favoriteViews, + }); + + final List favoriteViews; + + @override + Widget build(BuildContext context) { + final borderColor = Theme.of(context).isLightMode + ? const Color(0xFFE9E9EC) + : const Color(0x1AFFFFFF); + return ListView.separated( + key: const PageStorageKey('favorite_views_page_storage_key'), + padding: EdgeInsets.only( + bottom: HomeSpaceViewSizes.mVerticalPadding + + MediaQuery.of(context).padding.bottom, + ), + itemBuilder: (context, index) { + final view = favoriteViews[index]; + return Container( + padding: const EdgeInsets.symmetric(vertical: 24.0), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: borderColor, + width: 0.5, + ), + ), + ), + child: MobileViewPage( + key: ValueKey(view.item.id), + view: view.item, + timestamp: view.timestamp, + type: MobilePageCardType.favorite, + ), + ); + }, + separatorBuilder: (context, index) => const HSpace(8), + itemCount: favoriteViews.length, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder.dart new file mode 100644 index 0000000000000..1efee460ebacd --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder.dart @@ -0,0 +1,85 @@ +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/default_mobile_action_pane.dart'; +import 'package:appflowy/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder_header.dart'; +import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileFavoriteFolder extends StatelessWidget { + const MobileFavoriteFolder({ + super.key, + required this.views, + this.showHeader = true, + this.forceExpanded = false, + }); + + final bool showHeader; + final bool forceExpanded; + final List views; + + @override + Widget build(BuildContext context) { + if (views.isEmpty) { + return const SizedBox.shrink(); + } + + return BlocProvider( + create: (context) => FolderBloc(type: FolderSpaceType.favorite) + ..add( + const FolderEvent.initial(), + ), + child: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + if (showHeader) ...[ + MobileFavoriteFolderHeader( + isExpanded: context.read().state.isExpanded, + onPressed: () => context + .read() + .add(const FolderEvent.expandOrUnExpand()), + onAdded: () => context.read().add( + const FolderEvent.expandOrUnExpand(isExpanded: true), + ), + ), + const VSpace(8.0), + const Divider( + height: 1, + ), + ], + if (forceExpanded || state.isExpanded) + ...views.map( + (view) => MobileViewItem( + key: ValueKey( + '${FolderSpaceType.favorite.name} ${view.id}', + ), + spaceType: FolderSpaceType.favorite, + isDraggable: false, + isFirstChild: view.id == views.first.id, + isFeedback: false, + view: view, + level: 0, + onSelected: context.pushView, + endActionPane: (context) => buildEndActionPane( + context, + [ + view.isFavorite + ? MobilePaneActionType.removeFromFavorites + : MobilePaneActionType.addToFavorites, + MobilePaneActionType.more, + ], + spaceType: FolderSpaceType.favorite, + spaceRatio: 5, + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder_header.dart new file mode 100644 index 0000000000000..b5ae0ad1bdfe8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/favorite_folder/mobile_home_favorite_folder_header.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileFavoriteFolderHeader extends StatefulWidget { + const MobileFavoriteFolderHeader({ + super.key, + required this.onPressed, + required this.onAdded, + required this.isExpanded, + }); + + final VoidCallback onPressed; + final VoidCallback onAdded; + final bool isExpanded; + + @override + State createState() => + _MobileFavoriteFolderHeaderState(); +} + +class _MobileFavoriteFolderHeaderState + extends State { + double _turns = 0; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FlowyButton( + text: FlowyText.semibold( + LocaleKeys.sideBar_favorites.tr(), + fontSize: 20.0, + ), + margin: const EdgeInsets.symmetric(vertical: 8), + expandText: false, + mainAxisAlignment: MainAxisAlignment.start, + rightIcon: AnimatedRotation( + duration: const Duration(milliseconds: 200), + turns: _turns, + child: const Icon( + Icons.keyboard_arrow_down_rounded, + color: Colors.grey, + ), + ), + onTap: () { + setState(() { + _turns = widget.isExpanded ? -0.25 : 0; + }); + widget.onPressed(); + }, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/home.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/home.dart new file mode 100644 index 0000000000000..31722276f9651 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/home.dart @@ -0,0 +1,3 @@ +export 'mobile_home_page.dart'; +export 'mobile_home_setting_page.dart'; +export 'mobile_home_trash_page.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/home_space/home_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/home_space/home_space.dart new file mode 100644 index 0000000000000..56513795226ab --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/home_space/home_space.dart @@ -0,0 +1,43 @@ +import 'package:appflowy/mobile/presentation/home/mobile_folders.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileHomeSpace extends StatefulWidget { + const MobileHomeSpace({super.key, required this.userProfile}); + + final UserProfilePB userProfile; + + @override + State createState() => _MobileHomeSpaceState(); +} + +class _MobileHomeSpaceState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + final workspaceId = + context.read().state.currentWorkspace?.workspaceId ?? + ''; + return SingleChildScrollView( + child: Padding( + padding: EdgeInsets.only( + top: HomeSpaceViewSizes.mVerticalPadding, + bottom: HomeSpaceViewSizes.mVerticalPadding + + MediaQuery.of(context).padding.bottom, + ), + child: MobileFolders( + user: widget.userProfile, + workspaceId: workspaceId, + showFavorite: false, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart new file mode 100644 index 0000000000000..0013650df94ac --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_folders.dart @@ -0,0 +1,115 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder.dart'; +import 'package:appflowy/mobile/presentation/home/space/mobile_space.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +// Contains Public And Private Sections +class MobileFolders extends StatelessWidget { + const MobileFolders({ + super.key, + required this.user, + required this.workspaceId, + required this.showFavorite, + }); + + final UserProfilePB user; + final String workspaceId; + final bool showFavorite; + + @override + Widget build(BuildContext context) { + final workspaceId = + context.read().state.currentWorkspace?.workspaceId ?? + ''; + return BlocListener( + listenWhen: (previous, current) => + previous.currentWorkspace?.workspaceId != + current.currentWorkspace?.workspaceId, + listener: (context, state) { + context.read().add( + SidebarSectionsEvent.initial( + user, + state.currentWorkspace?.workspaceId ?? workspaceId, + ), + ); + context.read().add( + SpaceEvent.reset( + user, + state.currentWorkspace?.workspaceId ?? workspaceId, + false, + ), + ); + }, + child: const _MobileFolder(), + ); + } +} + +class _MobileFolder extends StatefulWidget { + const _MobileFolder(); + + @override + State<_MobileFolder> createState() => _MobileFolderState(); +} + +class _MobileFolderState extends State<_MobileFolder> { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SlidableAutoCloseBehavior( + child: Column( + children: [ + ..._buildSpaceOrSection(context, state), + const VSpace(80.0), + ], + ), + ); + }, + ); + } + + List _buildSpaceOrSection( + BuildContext context, + SidebarSectionsState state, + ) { + if (context.watch().state.spaces.isNotEmpty) { + return [ + const MobileSpace(), + ]; + } + + if (context.read().state.isCollabWorkspaceOn) { + return [ + MobileSectionFolder( + title: LocaleKeys.sideBar_workspace.tr(), + spaceType: FolderSpaceType.public, + views: state.section.publicViews, + ), + const VSpace(8.0), + MobileSectionFolder( + title: LocaleKeys.sideBar_private.tr(), + spaceType: FolderSpaceType.private, + views: state.section.privateViews, + ), + ]; + } + + return [ + MobileSectionFolder( + title: LocaleKeys.sideBar_personal.tr(), + spaceType: FolderSpaceType.public, + views: state.section.publicViews, + ), + ]; + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart new file mode 100644 index 0000000000000..f90d7ab34a88c --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page.dart @@ -0,0 +1,335 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.dart'; +import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart'; +import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import 'package:sentry/sentry.dart'; + +class MobileHomeScreen extends StatelessWidget { + const MobileHomeScreen({super.key}); + + static const routeName = '/home'; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: Future.wait([ + FolderEventGetCurrentWorkspaceSetting().send(), + getIt().getUser(), + ]), + builder: (context, snapshots) { + if (!snapshots.hasData) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + + final workspaceSetting = snapshots.data?[0].fold( + (workspaceSettingPB) { + return workspaceSettingPB as WorkspaceSettingPB?; + }, + (error) => null, + ); + final userProfile = snapshots.data?[1].fold( + (userProfilePB) { + return userProfilePB as UserProfilePB?; + }, + (error) => null, + ); + + // In the unlikely case either of the above is null, eg. + // when a workspace is already open this can happen. + if (workspaceSetting == null || userProfile == null) { + return const WorkspaceFailedScreen(); + } + + Sentry.configureScope( + (scope) => scope.setUser( + SentryUser( + id: userProfile.id.toString(), + ), + ), + ); + + return Scaffold( + body: SafeArea( + bottom: false, + child: Provider.value( + value: userProfile, + child: MobileHomePage( + userProfile: userProfile, + workspaceSetting: workspaceSetting, + ), + ), + ), + ); + }, + ); + } +} + +final PropertyValueNotifier mCurrentWorkspace = + PropertyValueNotifier(null); + +class MobileHomePage extends StatefulWidget { + const MobileHomePage({ + super.key, + required this.userProfile, + required this.workspaceSetting, + }); + + final UserProfilePB userProfile; + final WorkspaceSettingPB workspaceSetting; + + @override + State createState() => _MobileHomePageState(); +} + +class _MobileHomePageState extends State { + Loading? loadingIndicator; + + @override + void initState() { + super.initState(); + + getIt().addLatestViewListener(_onLatestViewChange); + getIt().add(const ReminderEvent.started()); + } + + @override + void dispose() { + getIt().removeLatestViewListener(_onLatestViewChange); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => UserWorkspaceBloc(userProfile: widget.userProfile) + ..add(const UserWorkspaceEvent.initial()), + ), + BlocProvider( + create: (context) => + FavoriteBloc()..add(const FavoriteEvent.initial()), + ), + BlocProvider.value( + value: getIt()..add(const ReminderEvent.started()), + ), + ], + child: _HomePage(userProfile: widget.userProfile), + ); + } + + void _onLatestViewChange() async { + final id = getIt().latestOpenView?.id; + if (id == null) { + return; + } + await FolderEventSetLatestView(ViewIdPB(value: id)).send(); + } +} + +class _HomePage extends StatefulWidget { + const _HomePage({required this.userProfile}); + + final UserProfilePB userProfile; + + @override + State<_HomePage> createState() => _HomePageState(); +} + +class _HomePageState extends State<_HomePage> { + Loading? loadingIndicator; + + @override + Widget build(BuildContext context) { + return BlocConsumer( + buildWhen: (previous, current) => + previous.currentWorkspace?.workspaceId != + current.currentWorkspace?.workspaceId, + listener: (context, state) { + getIt().reset(); + mCurrentWorkspace.value = state.currentWorkspace; + + Debounce.debounce( + 'workspace_action_result', + const Duration(milliseconds: 150), + () { + _showResultDialog(context, state); + }, + ); + }, + builder: (context, state) { + if (state.currentWorkspace == null) { + return const SizedBox.shrink(); + } + + final workspaceId = state.currentWorkspace!.workspaceId; + + return Column( + key: ValueKey('mobile_home_page_$workspaceId'), + children: [ + // Header + Padding( + padding: const EdgeInsets.only( + left: HomeSpaceViewSizes.mHorizontalPadding, + right: 8.0, + ), + child: MobileHomePageHeader( + userProfile: widget.userProfile, + ), + ), + + Expanded( + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => + SpaceOrderBloc()..add(const SpaceOrderEvent.initial()), + ), + BlocProvider( + create: (_) => SidebarSectionsBloc() + ..add( + SidebarSectionsEvent.initial( + widget.userProfile, + workspaceId, + ), + ), + ), + BlocProvider( + create: (_) => + FavoriteBloc()..add(const FavoriteEvent.initial()), + ), + BlocProvider( + create: (_) => SpaceBloc( + userProfile: widget.userProfile, + workspaceId: workspaceId, + )..add( + const SpaceEvent.initial( + openFirstPage: false, + ), + ), + ), + ], + child: MobileSpaceTab( + userProfile: widget.userProfile, + ), + ), + ), + ], + ); + }, + ); + } + + void _showResultDialog(BuildContext context, UserWorkspaceState state) { + final actionResult = state.actionResult; + if (actionResult == null) { + return; + } + + Log.info('workspace action result: $actionResult'); + + final actionType = actionResult.actionType; + final result = actionResult.result; + final isLoading = actionResult.isLoading; + + if (isLoading) { + loadingIndicator ??= Loading(context)..start(); + return; + } else { + loadingIndicator?.stop(); + loadingIndicator = null; + } + + if (result == null) { + return; + } + + result.onFailure((f) { + Log.error( + '[Workspace] Failed to perform ${actionType.toString()} action: $f', + ); + }); + + final String? message; + ToastificationType toastType = ToastificationType.success; + switch (actionType) { + case UserWorkspaceActionType.open: + message = result.onFailure((e) { + toastType = ToastificationType.error; + return '${LocaleKeys.workspace_openFailed.tr()}: ${e.msg}'; + }); + break; + case UserWorkspaceActionType.delete: + message = result.fold( + (s) { + toastType = ToastificationType.success; + return LocaleKeys.workspace_deleteSuccess.tr(); + }, + (e) { + toastType = ToastificationType.error; + return '${LocaleKeys.workspace_deleteFailed.tr()}: ${e.msg}'; + }, + ); + break; + case UserWorkspaceActionType.leave: + message = result.fold( + (s) { + toastType = ToastificationType.success; + return LocaleKeys + .settings_workspacePage_leaveWorkspacePrompt_success + .tr(); + }, + (e) { + toastType = ToastificationType.error; + return '${LocaleKeys.settings_workspacePage_leaveWorkspacePrompt_fail.tr()}: ${e.msg}'; + }, + ); + break; + case UserWorkspaceActionType.rename: + message = result.fold( + (s) { + toastType = ToastificationType.success; + return LocaleKeys.workspace_renameSuccess.tr(); + }, + (e) { + toastType = ToastificationType.error; + return '${LocaleKeys.workspace_renameFailed.tr()}: ${e.msg}'; + }, + ); + break; + default: + message = null; + toastType = ToastificationType.error; + break; + } + + if (message != null) { + showToastNotification(context, message: message, type: toastType); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart new file mode 100644 index 0000000000000..27e1d3d341689 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart @@ -0,0 +1,252 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/built_in_svgs.dart'; +import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import 'setting/settings_popup_menu.dart'; + +class MobileHomePageHeader extends StatelessWidget { + const MobileHomePageHeader({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => getIt(param1: userProfile) + ..add(const SettingsUserEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final isCollaborativeWorkspace = + context.read().state.isCollabWorkspaceOn; + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: isCollaborativeWorkspace + ? _MobileWorkspace(userProfile: userProfile) + : _MobileUser(userProfile: userProfile), + ), + HomePageSettingsPopupMenu( + userProfile: userProfile, + ), + const HSpace(8.0), + ], + ), + ); + }, + ), + ); + } +} + +class _MobileUser extends StatelessWidget { + const _MobileUser({ + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final userIcon = userProfile.iconUrl; + return Row( + children: [ + _UserIcon(userIcon: userIcon), + const HSpace(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const FlowyText.medium('AppFlowy', fontSize: 18), + const VSpace(4), + FlowyText.regular( + userProfile.email.isNotEmpty + ? userProfile.email + : userProfile.name, + fontSize: 12, + color: Theme.of(context).colorScheme.onSurface, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + } +} + +class _MobileWorkspace extends StatelessWidget { + const _MobileWorkspace({ + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final currentWorkspace = state.currentWorkspace; + if (currentWorkspace == null) { + return const SizedBox.shrink(); + } + return AnimatedGestureDetector( + scaleFactor: 0.99, + alignment: Alignment.centerLeft, + onTapUp: () { + context.read().add( + const UserWorkspaceEvent.fetchWorkspaces(), + ); + _showSwitchWorkspacesBottomSheet(context); + }, + child: Row( + children: [ + WorkspaceIcon( + workspace: currentWorkspace, + iconSize: 36, + fontSize: 18.0, + enableEdit: true, + alignment: Alignment.centerLeft, + figmaLineHeight: 26.0, + emojiSize: 24.0, + borderRadius: 12.0, + showBorder: false, + onSelected: (result) => context.read().add( + UserWorkspaceEvent.updateWorkspaceIcon( + currentWorkspace.workspaceId, + result.emoji, + ), + ), + ), + currentWorkspace.icon.isNotEmpty + ? const HSpace(2) + : const HSpace(8), + Flexible( + child: FlowyText.semibold( + currentWorkspace.name, + fontSize: 20.0, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }, + ); + } + + void _showSwitchWorkspacesBottomSheet( + BuildContext context, + ) { + showMobileBottomSheet( + context, + showDivider: false, + showHeader: true, + showDragHandle: true, + showCloseButton: true, + useRootNavigator: true, + enableScrollable: true, + bottomSheetPadding: context.bottomSheetPadding(), + title: LocaleKeys.workspace_menuTitle.tr(), + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (sheetContext) { + return BlocProvider.value( + value: context.read(), + child: BlocBuilder( + builder: (context, state) { + final currentWorkspace = state.currentWorkspace; + final workspaces = state.workspaces; + if (currentWorkspace == null || workspaces.isEmpty) { + return const SizedBox.shrink(); + } + return MobileWorkspaceMenu( + userProfile: userProfile, + currentWorkspace: currentWorkspace, + workspaces: workspaces, + onWorkspaceSelected: (workspace) { + Navigator.of(sheetContext).pop(); + + if (workspace == currentWorkspace) { + return; + } + + context.read().add( + UserWorkspaceEvent.openWorkspace( + workspace.workspaceId, + ), + ); + }, + ); + }, + ), + ); + }, + ); + } +} + +class _UserIcon extends StatelessWidget { + const _UserIcon({ + required this.userIcon, + }); + + final String userIcon; + + @override + Widget build(BuildContext context) { + return FlowyButton( + useIntrinsicWidth: true, + text: builtInSVGIcons.contains(userIcon) + // to be compatible with old user icon + ? FlowySvg( + FlowySvgData('emoji/$userIcon'), + size: const Size.square(32), + blendMode: null, + ) + : FlowyText( + userIcon.isNotEmpty ? userIcon : '🐻', + fontSize: 26, + ), + onTap: () async { + final icon = await context.push( + Uri( + path: MobileEmojiPickerScreen.routeName, + queryParameters: { + MobileEmojiPickerScreen.pageTitle: + LocaleKeys.titleBar_userIcon.tr(), + }, + ).toString(), + ); + if (icon != null) { + if (context.mounted) { + context.read().add( + SettingsUserEvent.updateUserIcon( + iconUrl: icon.emoji, + ), + ); + } + } + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart new file mode 100644 index 0000000000000..1e0ddb5a5163f --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart @@ -0,0 +1,99 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/env/env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/mobile/presentation/setting/cloud/cloud_setting_group.dart'; +import 'package:appflowy/mobile/presentation/setting/user_session_setting_group.dart'; +import 'package:appflowy/mobile/presentation/setting/workspace/workspace_setting_group.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileHomeSettingPage extends StatefulWidget { + const MobileHomeSettingPage({ + super.key, + }); + + static const routeName = '/settings'; + + @override + State createState() => _MobileHomeSettingPageState(); +} + +class _MobileHomeSettingPageState extends State { + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: getIt().getUser(), + builder: (context, snapshot) { + String? errorMsg; + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + + final userProfile = snapshot.data?.fold( + (userProfile) { + return userProfile; + }, + (error) { + errorMsg = error.msg; + return null; + }, + ); + + return Scaffold( + appBar: FlowyAppBar( + titleText: LocaleKeys.settings_title.tr(), + ), + body: userProfile == null + ? _buildErrorWidget(errorMsg) + : _buildSettingsWidget(userProfile), + ); + }, + ); + } + + Widget _buildErrorWidget(String? errorMsg) { + return FlowyMobileStateContainer.error( + emoji: '🛸', + title: LocaleKeys.settings_mobile_userprofileError.tr(), + description: LocaleKeys.settings_mobile_userprofileErrorDescription.tr(), + errorMsg: errorMsg, + ); + } + + Widget _buildSettingsWidget(UserProfilePB userProfile) { + // show the third-party sign in buttons if user logged in with local session and auth is enabled. + + final showThirdPartyLogin = + userProfile.authenticator == AuthenticatorPB.Local && isAuthEnabled; + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + PersonalInfoSettingGroup( + userProfile: userProfile, + ), + const WorkspaceSettingGroup(), + const AppearanceSettingGroup(), + const LanguageSettingGroup(), + if (Env.enableCustomCloud) const CloudSettingGroup(), + const SupportSettingGroup(), + const AboutSettingGroup(), + UserSessionSettingGroup( + userProfile: userProfile, + showThirdPartyLogin: showThirdPartyLogin, + ), + const VSpace(20), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart new file mode 100644 index 0000000000000..73a5381d42c2c --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_trash_page.dart @@ -0,0 +1,267 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/plugins/trash/application/prelude.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:go_router/go_router.dart'; + +class MobileHomeTrashPage extends StatelessWidget { + const MobileHomeTrashPage({super.key}); + + static const routeName = '/trash'; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => getIt()..add(const TrashEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + return Scaffold( + appBar: AppBar( + title: Text(LocaleKeys.trash_text.tr()), + actions: [ + state.objects.isEmpty + ? const SizedBox.shrink() + : IconButton( + splashRadius: 20, + icon: const Icon(Icons.more_horiz), + onPressed: () { + final trashBloc = context.read(); + showMobileBottomSheet( + context, + showHeader: true, + showCloseButton: true, + showDragHandle: true, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + title: LocaleKeys.trash_mobile_actions.tr(), + builder: (_) => Row( + children: [ + Expanded( + child: _TrashActionAllButton( + trashBloc: trashBloc, + ), + ), + const SizedBox( + width: 16, + ), + Expanded( + child: _TrashActionAllButton( + trashBloc: trashBloc, + type: _TrashActionType.restoreAll, + ), + ), + ], + ), + ); + }, + ), + ], + ), + body: state.objects.isEmpty + ? const _EmptyTrashBin() + : _DeletedFilesListView(state), + ); + }, + ), + ); + } +} + +enum _TrashActionType { + restoreAll, + deleteAll, +} + +class _EmptyTrashBin extends StatelessWidget { + const _EmptyTrashBin(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.m_empty_trash_xl, + size: Size.square(46), + ), + const VSpace(16.0), + FlowyText.medium( + LocaleKeys.trash_mobile_empty.tr(), + fontSize: 18.0, + textAlign: TextAlign.center, + ), + const VSpace(8.0), + FlowyText.regular( + LocaleKeys.trash_mobile_emptyDescription.tr(), + fontSize: 17.0, + maxLines: 10, + textAlign: TextAlign.center, + lineHeight: 1.3, + color: Theme.of(context).hintColor, + ), + const VSpace(kBottomNavigationBarHeight + 36.0), + ], + ), + ); + } +} + +class _TrashActionAllButton extends StatelessWidget { + /// Switch between 'delete all' and 'restore all' feature + const _TrashActionAllButton({ + this.type = _TrashActionType.deleteAll, + required this.trashBloc, + }); + final _TrashActionType type; + final TrashBloc trashBloc; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDeleteAll = type == _TrashActionType.deleteAll; + return BlocProvider.value( + value: trashBloc, + child: BottomSheetActionWidget( + svg: isDeleteAll ? FlowySvgs.m_delete_m : FlowySvgs.m_restore_m, + text: isDeleteAll + ? LocaleKeys.trash_deleteAll.tr() + : LocaleKeys.trash_restoreAll.tr(), + onTap: () { + final trashList = trashBloc.state.objects; + if (trashList.isNotEmpty) { + context.pop(); + showFlowyMobileConfirmDialog( + context, + title: FlowyText( + isDeleteAll + ? LocaleKeys.trash_confirmDeleteAll_title.tr() + : LocaleKeys.trash_restoreAll.tr(), + ), + content: FlowyText( + isDeleteAll + ? LocaleKeys.trash_confirmDeleteAll_caption.tr() + : LocaleKeys.trash_confirmRestoreAll_caption.tr(), + ), + actionButtonTitle: isDeleteAll + ? LocaleKeys.trash_deleteAll.tr() + : LocaleKeys.trash_restoreAll.tr(), + actionButtonColor: isDeleteAll + ? theme.colorScheme.error + : theme.colorScheme.primary, + onActionButtonPressed: () { + if (isDeleteAll) { + trashBloc.add( + const TrashEvent.deleteAll(), + ); + } else { + trashBloc.add( + const TrashEvent.restoreAll(), + ); + } + }, + cancelButtonTitle: LocaleKeys.button_cancel.tr(), + ); + } else { + // when there is no deleted files + // show toast + Fluttertoast.showToast( + msg: LocaleKeys.trash_mobile_empty.tr(), + gravity: ToastGravity.CENTER, + ); + } + }, + ), + ); + } +} + +class _DeletedFilesListView extends StatelessWidget { + const _DeletedFilesListView( + this.state, + ); + + final TrashState state; + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ListView.builder( + itemBuilder: (context, index) { + final deletedFile = state.objects[index]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ListTile( + // TODO: show different file type icon, implement this feature after TrashPB has file type field + leading: FlowySvg( + FlowySvgs.document_s, + size: const Size.square(24), + color: theme.colorScheme.onSurface, + ), + title: Text( + deletedFile.name, + style: theme.textTheme.labelMedium + ?.copyWith(color: theme.colorScheme.onSurface), + ), + horizontalTitleGap: 0, + tileColor: theme.colorScheme.onSurface.withOpacity(0.1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + splashRadius: 20, + icon: FlowySvg( + FlowySvgs.m_restore_m, + size: const Size.square(24), + color: theme.colorScheme.onSurface, + ), + onPressed: () { + context + .read() + .add(TrashEvent.putback(deletedFile.id)); + Fluttertoast.showToast( + msg: + '${deletedFile.name} ${LocaleKeys.trash_mobile_isRestored.tr()}', + gravity: ToastGravity.BOTTOM, + ); + }, + ), + IconButton( + splashRadius: 20, + icon: FlowySvg( + FlowySvgs.m_delete_m, + size: const Size.square(24), + color: theme.colorScheme.onSurface, + ), + onPressed: () { + context + .read() + .add(TrashEvent.delete(deletedFile)); + Fluttertoast.showToast( + msg: + '${deletedFile.name} ${LocaleKeys.trash_mobile_isDeleted.tr()}', + gravity: ToastGravity.BOTTOM, + ); + }, + ), + ], + ), + ), + ); + }, + itemCount: state.objects.length, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart new file mode 100644 index 0000000000000..661a422e0c4ae --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_home_recent_views.dart @@ -0,0 +1,141 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/home/recent_folder/mobile_recent_view.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/recent/prelude.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class MobileRecentFolder extends StatefulWidget { + const MobileRecentFolder({super.key}); + + @override + State createState() => _MobileRecentFolderState(); +} + +class _MobileRecentFolderState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + RecentViewsBloc()..add(const RecentViewsEvent.initial()), + child: BlocListener( + listenWhen: (previous, current) => + current.currentWorkspace != null && + previous.currentWorkspace?.workspaceId != + current.currentWorkspace!.workspaceId, + listener: (context, state) => context + .read() + .add(const RecentViewsEvent.resetRecentViews()), + child: BlocBuilder( + builder: (context, state) { + final ids = {}; + + List recentViews = state.views.map((e) => e.item).toList(); + recentViews.retainWhere((element) => ids.add(element.id)); + + // only keep the first 20 items. + recentViews = recentViews.take(20).toList(); + + if (recentViews.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + children: [ + _RecentViews( + key: ValueKey(recentViews), + // the recent views are in reverse order + recentViews: recentViews, + ), + const VSpace(12.0), + ], + ); + }, + ), + ), + ); + } +} + +class _RecentViews extends StatelessWidget { + const _RecentViews({ + super.key, + required this.recentViews, + }); + + final List recentViews; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: GestureDetector( + child: FlowyText.semibold( + LocaleKeys.sideBar_recent.tr(), + fontSize: 20.0, + ), + onTap: () { + showMobileBottomSheet( + context, + showDivider: false, + showDragHandle: true, + backgroundColor: AFThemeExtension.of(context).background, + builder: (_) { + return Column( + children: [ + FlowyOptionTile.text( + text: LocaleKeys.button_clear.tr(), + leftIcon: FlowySvg( + FlowySvgs.m_delete_s, + color: Theme.of(context).colorScheme.error, + ), + textColor: Theme.of(context).colorScheme.error, + onTap: () { + context.read().add( + RecentViewsEvent.removeRecentViews( + recentViews.map((e) => e.id).toList(), + ), + ); + context.pop(); + }, + ), + ], + ); + }, + ); + }, + ), + ), + SizedBox( + height: 148, + child: ListView.separated( + key: const PageStorageKey('recent_views_page_storage_key'), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + final view = recentViews[index]; + return SizedBox.square( + dimension: 148, + child: MobileRecentView(view: view), + ); + }, + separatorBuilder: (context, index) => const HSpace(8), + itemCount: recentViews.length, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart new file mode 100644 index 0000000000000..b60d354ede755 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/mobile_recent_view.dart @@ -0,0 +1,231 @@ +import 'dart:io'; + +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/application/recent/recent_view_bloc.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/flowy_gradient_colors.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import 'package:string_validator/string_validator.dart'; + +class MobileRecentView extends StatelessWidget { + const MobileRecentView({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return BlocProvider( + create: (context) => RecentViewBloc(view: view) + ..add( + const RecentViewEvent.initial(), + ), + child: BlocBuilder( + builder: (context, state) { + return GestureDetector( + onTap: () => context.pushView(view), + child: Stack( + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.colorScheme.outline), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _buildCover(context, state)), + Expanded(child: _buildTitle(context, state)), + ], + ), + ), + Align( + alignment: Alignment.centerLeft, + child: _buildIcon(context, state), + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildCover(BuildContext context, RecentViewState state) { + return Padding( + padding: const EdgeInsets.only(top: 1.0, left: 1.0, right: 1.0), + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + child: _RecentCover( + coverTypeV1: state.coverTypeV1, + coverTypeV2: state.coverTypeV2, + value: state.coverValue, + ), + ), + ); + } + + Widget _buildTitle(BuildContext context, RecentViewState state) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 18, 8, 2), + // hack: minLines currently not supported in Text widget. + // https://github.com/flutter/flutter/issues/31134 + child: Stack( + children: [ + FlowyText.medium( + view.name, + fontSize: 16.0, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const FlowyText( + "\n\n", + maxLines: 2, + ), + ], + ), + ); + } + + Widget _buildIcon(BuildContext context, RecentViewState state) { + return Padding( + padding: const EdgeInsets.only(left: 8.0), + child: state.icon.isNotEmpty + ? EmojiText( + emoji: state.icon.emoji, + fontSize: 30.0, + ) + : SizedBox.square( + dimension: 32.0, + child: view.defaultIcon(), + ), + ); + } +} + +class _RecentCover extends StatelessWidget { + const _RecentCover({ + required this.coverTypeV1, + this.coverTypeV2, + this.value, + }); + + final CoverType coverTypeV1; + final PageStyleCoverImageType? coverTypeV2; + final String? value; + + @override + Widget build(BuildContext context) { + final placeholder = Container( + // random color, update it once we have a better placeholder + color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.2), + ); + final value = this.value; + if (value == null) { + return placeholder; + } + if (coverTypeV2 != null) { + return _buildCoverV2(context, value, placeholder); + } + return _buildCoverV1(context, value, placeholder); + } + + Widget _buildCoverV2(BuildContext context, String value, Widget placeholder) { + final type = coverTypeV2; + if (type == null) { + return placeholder; + } + if (type == PageStyleCoverImageType.customImage || + type == PageStyleCoverImageType.unsplashImage) { + final userProfilePB = Provider.of(context); + return FlowyNetworkImage( + url: value, + userProfilePB: userProfilePB, + ); + } + + if (type == PageStyleCoverImageType.builtInImage) { + return Image.asset( + PageStyleCoverImageType.builtInImagePath(value), + fit: BoxFit.cover, + ); + } + + if (type == PageStyleCoverImageType.pureColor) { + final color = value.coverColor(context); + if (color != null) { + return ColoredBox( + color: color, + ); + } + } + + if (type == PageStyleCoverImageType.gradientColor) { + return Container( + decoration: BoxDecoration( + gradient: FlowyGradientColor.fromId(value).linear, + ), + ); + } + + if (type == PageStyleCoverImageType.localImage) { + return Image.file( + File(value), + fit: BoxFit.cover, + ); + } + + return placeholder; + } + + Widget _buildCoverV1(BuildContext context, String value, Widget placeholder) { + switch (coverTypeV1) { + case CoverType.file: + if (isURL(value)) { + final userProfilePB = Provider.of(context); + return FlowyNetworkImage( + url: value, + userProfilePB: userProfilePB, + ); + } + final imageFile = File(value); + if (!imageFile.existsSync()) { + return placeholder; + } + return Image.file( + imageFile, + ); + case CoverType.asset: + return Image.asset( + value, + fit: BoxFit.cover, + ); + case CoverType.color: + final color = value.tryToColor() ?? Colors.white; + return Container( + color: color, + ); + case CoverType.none: + return placeholder; + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/recent_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/recent_space.dart new file mode 100644 index 0000000000000..c0baa641d9bb0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/recent_folder/recent_space.dart @@ -0,0 +1,102 @@ +import 'package:appflowy/mobile/presentation/home/shared/empty_placeholder.dart'; +import 'package:appflowy/mobile/presentation/home/shared/mobile_page_card.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/recent/prelude.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +class MobileRecentSpace extends StatefulWidget { + const MobileRecentSpace({super.key}); + + @override + State createState() => _MobileRecentSpaceState(); +} + +class _MobileRecentSpaceState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return BlocProvider( + create: (context) => + RecentViewsBloc()..add(const RecentViewsEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const SizedBox.shrink(); + } + + final recentViews = _filterRecentViews(state.views); + + if (recentViews.isEmpty) { + return const Center( + child: EmptySpacePlaceholder(type: MobilePageCardType.recent), + ); + } + + return _RecentViews(recentViews: recentViews); + }, + ), + ); + } + + List _filterRecentViews(List recentViews) { + final ids = {}; + final filteredRecentViews = recentViews.toList(); + filteredRecentViews.retainWhere((e) => ids.add(e.item.id)); + return filteredRecentViews; + } +} + +class _RecentViews extends StatelessWidget { + const _RecentViews({ + required this.recentViews, + }); + + final List recentViews; + + @override + Widget build(BuildContext context) { + final borderColor = Theme.of(context).isLightMode + ? const Color(0xFFE9E9EC) + : const Color(0x1AFFFFFF); + return SlidableAutoCloseBehavior( + child: ListView.separated( + key: const PageStorageKey('recent_views_page_storage_key'), + padding: EdgeInsets.only( + bottom: HomeSpaceViewSizes.mVerticalPadding + + MediaQuery.of(context).padding.bottom, + ), + itemBuilder: (context, index) { + final sectionView = recentViews[index]; + return Container( + padding: const EdgeInsets.symmetric(vertical: 24.0), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: borderColor, + width: 0.5, + ), + ), + ), + child: MobileViewPage( + key: ValueKey(sectionView.item.id), + view: sectionView.item, + timestamp: sectionView.timestamp, + type: MobilePageCardType.recent, + ), + ); + }, + separatorBuilder: (context, index) => const HSpace(8), + itemCount: recentViews.length, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart new file mode 100644 index 0000000000000..d610b4452b9d7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder.dart @@ -0,0 +1,124 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart'; +import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileSectionFolder extends StatelessWidget { + const MobileSectionFolder({ + super.key, + required this.title, + required this.views, + required this.spaceType, + }); + + final String title; + final List views; + final FolderSpaceType spaceType; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => FolderBloc(type: spaceType) + ..add( + const FolderEvent.initial(), + ), + child: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + SizedBox( + height: HomeSpaceViewSizes.mViewHeight, + child: MobileSectionFolderHeader( + title: title, + isExpanded: context.read().state.isExpanded, + onPressed: () => context + .read() + .add(const FolderEvent.expandOrUnExpand()), + onAdded: () => _createNewPage(context), + ), + ), + if (state.isExpanded) + Padding( + padding: const EdgeInsets.only( + left: HomeSpaceViewSizes.leftPadding, + ), + child: _Pages( + views: views, + spaceType: spaceType, + ), + ), + ], + ); + }, + ), + ); + } + + void _createNewPage(BuildContext context) { + context.read().add( + SidebarSectionsEvent.createRootViewInSection( + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + index: 0, + viewSection: spaceType.toViewSectionPB, + ), + ); + context.read().add( + const FolderEvent.expandOrUnExpand(isExpanded: true), + ); + } +} + +class _Pages extends StatelessWidget { + const _Pages({ + required this.views, + required this.spaceType, + }); + + final List views; + final FolderSpaceType spaceType; + + @override + Widget build(BuildContext context) { + return Column( + children: views + .map( + (view) => MobileViewItem( + key: ValueKey( + '${FolderSpaceType.private.name} ${view.id}', + ), + spaceType: spaceType, + isFirstChild: view.id == views.first.id, + view: view, + level: 0, + leftPadding: HomeSpaceViewSizes.leftPadding, + isFeedback: false, + onSelected: context.pushView, + endActionPane: (context) { + final view = context.read().state.view; + return buildEndActionPane( + context, + [ + MobilePaneActionType.more, + if (view.layout == ViewLayoutPB.Document) + MobilePaneActionType.add, + ], + spaceType: spaceType, + needSpace: false, + spaceRatio: 5, + ); + }, + ), + ) + .toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart new file mode 100644 index 0000000000000..b1d2bf690925a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart @@ -0,0 +1,78 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +@visibleForTesting +const Key mobileCreateNewPageButtonKey = Key('mobileCreateNewPageButtonKey'); + +class MobileSectionFolderHeader extends StatefulWidget { + const MobileSectionFolderHeader({ + super.key, + required this.title, + required this.onPressed, + required this.onAdded, + required this.isExpanded, + }); + + final String title; + final VoidCallback onPressed; + final VoidCallback onAdded; + final bool isExpanded; + + @override + State createState() => + _MobileSectionFolderHeaderState(); +} + +class _MobileSectionFolderHeaderState extends State { + double _turns = 0; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const HSpace(HomeSpaceViewSizes.mHorizontalPadding), + Expanded( + child: FlowyButton( + text: FlowyText.medium( + widget.title, + fontSize: 16.0, + ), + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 2.0), + expandText: false, + iconPadding: 2, + mainAxisAlignment: MainAxisAlignment.start, + rightIcon: AnimatedRotation( + duration: const Duration(milliseconds: 200), + turns: _turns, + child: const FlowySvg( + FlowySvgs.m_spaces_expand_s, + ), + ), + onTap: () { + setState(() { + _turns = widget.isExpanded ? -0.25 : 0; + }); + widget.onPressed(); + }, + ), + ), + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: widget.onAdded, + child: Container( + // expand the touch area + margin: const EdgeInsets.symmetric( + horizontal: HomeSpaceViewSizes.mHorizontalPadding, + vertical: 8.0, + ), + child: const FlowySvg( + FlowySvgs.m_space_add_s, + ), + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart new file mode 100644 index 0000000000000..cbbda8362a34a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart @@ -0,0 +1,154 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/mobile/presentation/setting/workspace/invite_members_screen.dart'; +import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart' + hide PopupMenuButton, PopupMenuDivider, PopupMenuItem, PopupMenuEntry; +import 'package:go_router/go_router.dart'; + +enum _MobileSettingsPopupMenuItem { + settings, + members, + trash, + help, +} + +class HomePageSettingsPopupMenu extends StatelessWidget { + const HomePageSettingsPopupMenu({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return PopupMenuButton<_MobileSettingsPopupMenuItem>( + offset: const Offset(0, 36), + padding: EdgeInsets.zero, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12.0), + ), + ), + shadowColor: const Color(0x68000000), + elevation: 10, + color: context.popupMenuBackgroundColor, + itemBuilder: (BuildContext context) => + >[ + _buildItem( + value: _MobileSettingsPopupMenuItem.settings, + svg: FlowySvgs.m_notification_settings_s, + text: LocaleKeys.settings_popupMenuItem_settings.tr(), + ), + // only show the member items in cloud mode + if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[ + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _MobileSettingsPopupMenuItem.members, + svg: FlowySvgs.m_settings_member_s, + text: LocaleKeys.settings_popupMenuItem_members.tr(), + ), + ], + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _MobileSettingsPopupMenuItem.trash, + svg: FlowySvgs.trash_s, + text: LocaleKeys.settings_popupMenuItem_trash.tr(), + ), + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _MobileSettingsPopupMenuItem.help, + svg: FlowySvgs.message_support_s, + text: LocaleKeys.settings_popupMenuItem_helpAndSupport.tr(), + ), + ], + onSelected: (_MobileSettingsPopupMenuItem value) { + switch (value) { + case _MobileSettingsPopupMenuItem.members: + _openMembersPage(context); + break; + case _MobileSettingsPopupMenuItem.trash: + _openTrashPage(context); + break; + case _MobileSettingsPopupMenuItem.settings: + _openSettingsPage(context); + break; + case _MobileSettingsPopupMenuItem.help: + _openHelpPage(context); + break; + } + }, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: FlowySvg( + FlowySvgs.m_settings_more_s, + ), + ), + ); + } + + PopupMenuItem _buildItem({ + required T value, + required FlowySvgData svg, + required String text, + }) { + return PopupMenuItem( + value: value, + padding: EdgeInsets.zero, + child: _PopupButton( + svg: svg, + text: text, + ), + ); + } + + void _openMembersPage(BuildContext context) { + context.push(InviteMembersScreen.routeName); + } + + void _openTrashPage(BuildContext context) { + context.push(MobileHomeTrashPage.routeName); + } + + void _openHelpPage(BuildContext context) { + afLaunchUrlString('https://discord.com/invite/9Q2xaN37tV'); + } + + void _openSettingsPage(BuildContext context) { + context.push(MobileHomeSettingPage.routeName); + } +} + +class _PopupButton extends StatelessWidget { + const _PopupButton({ + required this.svg, + required this.text, + }); + + final FlowySvgData svg; + final String text; + + @override + Widget build(BuildContext context) { + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + FlowySvg(svg, size: const Size.square(20)), + const HSpace(12), + FlowyText.regular( + text, + fontSize: 16, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart new file mode 100644 index 0000000000000..2de40600f2cc8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/empty_placeholder.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/home/shared/mobile_page_card.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class EmptySpacePlaceholder extends StatelessWidget { + const EmptySpacePlaceholder({ + super.key, + required this.type, + }); + + final MobilePageCardType type; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 48.0), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.m_empty_page_xl, + ), + const VSpace(16.0), + FlowyText.medium( + _emptyPageText, + fontSize: 18.0, + textAlign: TextAlign.center, + ), + const VSpace(8.0), + FlowyText.regular( + _emptyPageSubText, + fontSize: 17.0, + maxLines: 10, + textAlign: TextAlign.center, + lineHeight: 1.3, + color: Theme.of(context).hintColor, + ), + const VSpace(kBottomNavigationBarHeight + 36.0), + ], + ), + ); + } + + String get _emptyPageText => switch (type) { + MobilePageCardType.recent => LocaleKeys.sideBar_emptyRecent.tr(), + MobilePageCardType.favorite => LocaleKeys.sideBar_emptyFavorite.tr(), + }; + + String get _emptyPageSubText => switch (type) { + MobilePageCardType.recent => + LocaleKeys.sideBar_emptyRecentDescription.tr(), + MobilePageCardType.favorite => + LocaleKeys.sideBar_emptyFavoriteDescription.tr(), + }; +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_page_card.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_page_card.dart new file mode 100644 index 0000000000000..99bd4de494d94 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/shared/mobile_page_card.dart @@ -0,0 +1,408 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/application/recent/recent_view_bloc.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/flowy_gradient_colors.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:provider/provider.dart'; +import 'package:string_validator/string_validator.dart'; +import 'package:time/time.dart'; + +enum MobilePageCardType { + recent, + favorite; + + String get lastOperationHintText => switch (this) { + MobilePageCardType.recent => LocaleKeys.sideBar_lastViewed.tr(), + MobilePageCardType.favorite => LocaleKeys.sideBar_favoriteAt.tr(), + }; +} + +class MobileViewPage extends StatelessWidget { + const MobileViewPage({ + super.key, + required this.view, + this.timestamp, + required this.type, + }); + + final ViewPB view; + final Int64? timestamp; + final MobilePageCardType type; + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ViewBloc(view: view, shouldLoadChildViews: false) + ..add(const ViewEvent.initial()), + ), + BlocProvider( + create: (context) => + RecentViewBloc(view: view)..add(const RecentViewEvent.initial()), + ), + ], + child: BlocBuilder( + builder: (context, state) { + return Slidable( + endActionPane: buildEndActionPane( + context, + [ + MobilePaneActionType.more, + context.watch().state.view.isFavorite + ? MobilePaneActionType.removeFromFavorites + : MobilePaneActionType.addToFavorites, + ], + cardType: type, + spaceRatio: 4, + ), + child: AnimatedGestureDetector( + onTapUp: () => context.pushView(view), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(HomeSpaceViewSizes.mHorizontalPadding), + Expanded(child: _buildDescription(context, state)), + const HSpace(20.0), + SizedBox( + width: 84, + height: 60, + child: _buildCover(context, state), + ), + const HSpace(HomeSpaceViewSizes.mHorizontalPadding), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildDescription(BuildContext context, RecentViewState state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // page icon & page title + _buildTitle(context, state), + const VSpace(12.0), + // author & last viewed + _buildNameAndLastViewed(context, state), + ], + ); + } + + Widget _buildNameAndLastViewed(BuildContext context, RecentViewState state) { + final supportAvatar = isURL(state.icon.emoji); + if (!supportAvatar) { + return _buildLastViewed(context); + } + return Row( + children: [ + _buildAvatar(context, state), + Flexible(child: _buildAuthor(context, state)), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 3.0), + child: FlowySvg(FlowySvgs.dot_s), + ), + _buildLastViewed(context), + ], + ); + } + + Widget _buildAvatar(BuildContext context, RecentViewState state) { + final userProfile = Provider.of(context); + final iconUrl = userProfile?.iconUrl; + if (iconUrl == null || + iconUrl.isEmpty || + view.createdBy != userProfile?.id) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: 2, bottom: 2, right: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: SizedBox.square( + dimension: 16.0, + child: FlowyNetworkImage( + url: iconUrl, + ), + ), + ), + ); + } + + Widget _buildCover(BuildContext context, RecentViewState state) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _ViewCover( + layout: view.layout, + coverTypeV1: state.coverTypeV1, + coverTypeV2: state.coverTypeV2, + value: state.coverValue, + ), + ); + } + + Widget _buildTitle(BuildContext context, RecentViewState state) { + final name = state.name; + final icon = state.icon; + return RichText( + maxLines: 3, + overflow: TextOverflow.ellipsis, + text: TextSpan( + children: [ + WidgetSpan( + child: SizedBox( + width: 20, + child: EmojiIconWidget( + emoji: icon, + emojiSize: 17.0, + ), + ), + ), + if (icon.isNotEmpty) const WidgetSpan(child: HSpace(2.0)), + TextSpan( + text: name, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w600, + height: 1.3, + ), + ), + ], + ), + ); + } + + Widget _buildAuthor(BuildContext context, RecentViewState state) { + return FlowyText.regular( + // view.createdBy.toString(), + 'Lucas', + fontSize: 12.0, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ); + } + + Widget _buildLastViewed(BuildContext context) { + final textColor = Theme.of(context).isLightMode + ? const Color(0x7F171717) + : Colors.white.withOpacity(0.45); + if (timestamp == null) { + return const SizedBox.shrink(); + } + final date = _formatTimestamp( + context, + timestamp!.toInt() * 1000, + ); + return FlowyText.regular( + date, + fontSize: 13.0, + color: textColor, + ); + } + + String _formatTimestamp(BuildContext context, int timestamp) { + final now = DateTime.now(); + final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp); + final difference = now.difference(dateTime); + final String date; + + final dateFormate = + context.read().state.dateFormat; + final timeFormate = + context.read().state.timeFormat; + + if (difference.inMinutes < 1) { + date = LocaleKeys.sideBar_justNow.tr(); + } else if (difference.inHours < 1 && dateTime.isToday) { + // Less than 1 hour + date = LocaleKeys.sideBar_minutesAgo + .tr(namedArgs: {'count': difference.inMinutes.toString()}); + } else if (difference.inHours >= 1 && dateTime.isToday) { + // in same day + date = timeFormate.formatTime(dateTime); + } else { + date = dateFormate.formatDate(dateTime, false); + } + + if (difference.inHours >= 1) { + return '${type.lastOperationHintText} $date'; + } + + return date; + } +} + +class _ViewCover extends StatelessWidget { + const _ViewCover({ + required this.layout, + required this.coverTypeV1, + this.coverTypeV2, + this.value, + }); + + final ViewLayoutPB layout; + final CoverType coverTypeV1; + final PageStyleCoverImageType? coverTypeV2; + final String? value; + + @override + Widget build(BuildContext context) { + final placeholder = _buildPlaceholder(context); + final value = this.value; + if (value == null) { + return placeholder; + } + if (coverTypeV2 != null) { + return _buildCoverV2(context, value, placeholder); + } + return _buildCoverV1(context, value, placeholder); + } + + Widget _buildPlaceholder(BuildContext context) { + final isLightMode = Theme.of(context).isLightMode; + final (svg, color) = switch (layout) { + ViewLayoutPB.Document => ( + FlowySvgs.m_document_thumbnail_m, + isLightMode ? const Color(0xCCEDFBFF) : const Color(0x33658B90) + ), + ViewLayoutPB.Grid => ( + FlowySvgs.m_grid_thumbnail_m, + isLightMode ? const Color(0xFFF5F4FF) : const Color(0x338B80AD) + ), + ViewLayoutPB.Board => ( + FlowySvgs.m_board_thumbnail_m, + isLightMode ? const Color(0x7FE0FDD9) : const Color(0x3372936B), + ), + ViewLayoutPB.Calendar => ( + FlowySvgs.m_calendar_thumbnail_m, + isLightMode ? const Color(0xFFFFF7F0) : const Color(0x33A68B77) + ), + ViewLayoutPB.Chat => ( + FlowySvgs.m_chat_thumbnail_m, + isLightMode ? const Color(0x66FFE6FD) : const Color(0x33987195) + ), + _ => ( + FlowySvgs.m_document_thumbnail_m, + isLightMode ? Colors.black : Colors.white + ) + }; + return ColoredBox( + color: color, + child: Center( + child: FlowySvg( + svg, + blendMode: null, + ), + ), + ); + } + + Widget _buildCoverV2(BuildContext context, String value, Widget placeholder) { + final type = coverTypeV2; + if (type == null) { + return placeholder; + } + if (type == PageStyleCoverImageType.customImage || + type == PageStyleCoverImageType.unsplashImage) { + final userProfilePB = Provider.of(context); + return FlowyNetworkImage( + url: value, + userProfilePB: userProfilePB, + ); + } + + if (type == PageStyleCoverImageType.builtInImage) { + return Image.asset( + PageStyleCoverImageType.builtInImagePath(value), + fit: BoxFit.cover, + ); + } + + if (type == PageStyleCoverImageType.pureColor) { + final color = value.coverColor(context); + if (color != null) { + return ColoredBox( + color: color, + ); + } + } + + if (type == PageStyleCoverImageType.gradientColor) { + return Container( + decoration: BoxDecoration( + gradient: FlowyGradientColor.fromId(value).linear, + ), + ); + } + + if (type == PageStyleCoverImageType.localImage) { + return Image.file( + File(value), + fit: BoxFit.cover, + ); + } + + return placeholder; + } + + Widget _buildCoverV1(BuildContext context, String value, Widget placeholder) { + switch (coverTypeV1) { + case CoverType.file: + if (isURL(value)) { + final userProfilePB = Provider.of(context); + return FlowyNetworkImage( + url: value, + userProfilePB: userProfilePB, + ); + } + final imageFile = File(value); + if (!imageFile.existsSync()) { + return placeholder; + } + return Image.file( + imageFile, + ); + case CoverType.asset: + return Image.asset( + value, + fit: BoxFit.cover, + ); + case CoverType.color: + final color = value.tryToColor() ?? Colors.white; + return Container( + color: color, + ); + case CoverType.none: + return placeholder; + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/constants.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/constants.dart new file mode 100644 index 0000000000000..da7d13a174aa0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/constants.dart @@ -0,0 +1,3 @@ +class SpaceUIConstants { + static const itemHeight = 52.0; +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/manage_space_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/manage_space_widget.dart new file mode 100644 index 0000000000000..f24108c9457ec --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/manage_space_widget.dart @@ -0,0 +1,53 @@ +import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart' hide Icon; + +import 'widgets.dart'; + +enum ManageSpaceType { + create, + edit, +} + +class ManageSpaceWidget extends StatelessWidget { + const ManageSpaceWidget({ + super.key, + required this.controller, + required this.permission, + required this.selectedColor, + required this.selectedIcon, + required this.type, + }); + + final TextEditingController controller; + final ValueNotifier permission; + final ValueNotifier selectedColor; + final ValueNotifier selectedIcon; + final ManageSpaceType type; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ManageSpaceNameOption( + controller: controller, + type: type, + ), + ManageSpacePermissionOption(permission: permission), + ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 560, + ), + child: ManageSpaceIconOption( + selectedColor: selectedColor, + selectedIcon: selectedIcon, + ), + ), + const VSpace(60), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart new file mode 100644 index 0000000000000..a6545ce0538f8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space.dart @@ -0,0 +1,177 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/home/space/mobile_space_header.dart'; +import 'package:appflowy/mobile/presentation/home/space/mobile_space_menu.dart'; +import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart'; +import 'package:appflowy/shared/list_extension.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileSpace extends StatelessWidget { + const MobileSpace({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.spaces.isEmpty) { + return const SizedBox.shrink(); + } + + final currentSpace = state.currentSpace ?? state.spaces.first; + + return Column( + children: [ + MobileSpaceHeader( + isExpanded: state.isExpanded, + space: currentSpace, + onAdded: () => _showCreatePageMenu(context, currentSpace), + onPressed: () => _showSpaceMenu(context), + ), + Padding( + padding: const EdgeInsets.only( + left: HomeSpaceViewSizes.mHorizontalPadding, + ), + child: _Pages( + key: ValueKey(currentSpace.id), + space: currentSpace, + ), + ), + ], + ); + }, + ); + } + + void _showSpaceMenu(BuildContext context) { + showMobileBottomSheet( + context, + showDivider: false, + showHeader: true, + showDragHandle: true, + showCloseButton: true, + showDoneButton: true, + useRootNavigator: true, + title: LocaleKeys.space_title.tr(), + backgroundColor: Theme.of(context).colorScheme.surface, + enableScrollable: true, + bottomSheetPadding: context.bottomSheetPadding(), + builder: (_) { + return BlocProvider.value( + value: context.read(), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: MobileSpaceMenu(), + ), + ); + }, + ); + } + + void _showCreatePageMenu(BuildContext context, ViewPB space) { + final title = space.name; + showMobileBottomSheet( + context, + showHeader: true, + title: title, + showDragHandle: true, + showCloseButton: true, + useRootNavigator: true, + showDivider: false, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (sheetContext) { + return AddNewPageWidgetBottomSheet( + view: space, + onAction: (layout) { + Navigator.of(sheetContext).pop(); + context.read().add( + SpaceEvent.createPage( + name: layout.defaultName, + layout: layout, + index: 0, + openAfterCreate: true, + ), + ); + context.read().add( + SpaceEvent.expand(space, true), + ); + }, + ); + }, + ); + } +} + +class _Pages extends StatelessWidget { + const _Pages({ + super.key, + required this.space, + }); + + final ViewPB space; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + ViewBloc(view: space)..add(const ViewEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final spaceType = space.spacePermission == SpacePermission.publicToAll + ? FolderSpaceType.public + : FolderSpaceType.private; + final childViews = state.view.childViews.unique((view) => view.id); + if (childViews.length != state.view.childViews.length) { + final duplicatedViews = state.view.childViews + .where((view) => childViews.contains(view)) + .toList(); + Log.error('some view id are duplicated: $duplicatedViews'); + } + return Column( + children: childViews + .map( + (view) => MobileViewItem( + key: ValueKey( + '${space.id} ${view.id}', + ), + spaceType: spaceType, + isFirstChild: view.id == state.view.childViews.first.id, + view: view, + level: 0, + leftPadding: HomeSpaceViewSizes.leftPadding, + isFeedback: false, + onSelected: context.pushView, + endActionPane: (context) { + final view = context.read().state.view; + final actions = [ + MobilePaneActionType.more, + if (view.layout == ViewLayoutPB.Document) + MobilePaneActionType.add, + ]; + return buildEndActionPane( + context, + actions, + spaceType: spaceType, + spaceRatio: actions.length == 1 ? 3 : 4, + ); + }, + ), + ) + .toList(), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart new file mode 100644 index 0000000000000..0cd80ff1bbf3a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_header.dart @@ -0,0 +1,143 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +@visibleForTesting +const Key mobileCreateNewPageButtonKey = Key('mobileCreateNewPageButtonKey'); + +class MobileSpaceHeader extends StatelessWidget { + const MobileSpaceHeader({ + super.key, + required this.space, + required this.onPressed, + required this.onAdded, + required this.isExpanded, + }); + + final ViewPB space; + final VoidCallback onPressed; + final VoidCallback onAdded; + final bool isExpanded; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onPressed, + child: SizedBox( + height: 48, + child: Row( + children: [ + const HSpace(HomeSpaceViewSizes.mHorizontalPadding), + SpaceIcon( + dimension: 24, + space: space, + svgSize: 14, + textDimension: 18.0, + cornerRadius: 6.0, + ), + const HSpace(8), + FlowyText.medium( + space.name, + lineHeight: 1.15, + fontSize: 16.0, + ), + const HSpace(4.0), + const FlowySvg( + FlowySvgs.workspace_drop_down_menu_show_s, + ), + const Spacer(), + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onAdded, + child: Container( + // expand the touch area + margin: const EdgeInsets.symmetric( + horizontal: HomeSpaceViewSizes.mHorizontalPadding, + vertical: 8.0, + ), + child: const FlowySvg( + FlowySvgs.m_space_add_s, + ), + ), + ), + ], + ), + ), + ); + } + + // Future _onAction(SpaceMoreActionType type, dynamic data) async { + // switch (type) { + // case SpaceMoreActionType.rename: + // await _showRenameDialog(); + // break; + // case SpaceMoreActionType.changeIcon: + // final (String icon, String iconColor) = data; + // context.read().add(SpaceEvent.changeIcon(icon, iconColor)); + // break; + // case SpaceMoreActionType.manage: + // _showManageSpaceDialog(context); + // break; + // case SpaceMoreActionType.addNewSpace: + // break; + // case SpaceMoreActionType.collapseAllPages: + // break; + // case SpaceMoreActionType.delete: + // _showDeleteSpaceDialog(context); + // break; + // case SpaceMoreActionType.divider: + // break; + // } + // } + + // Future _showRenameDialog() async { + // await NavigatorTextFieldDialog( + // title: LocaleKeys.space_rename.tr(), + // value: space.name, + // autoSelectAllText: true, + // onConfirm: (name, _) { + // context.read().add(SpaceEvent.rename(space, name)); + // }, + // ).show(context); + // } + + // void _showManageSpaceDialog(BuildContext context) { + // final spaceBloc = context.read(); + // showDialog( + // context: context, + // builder: (_) { + // return Dialog( + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(12.0), + // ), + // child: BlocProvider.value( + // value: spaceBloc, + // child: const ManageSpacePopup(), + // ), + // ); + // }, + // ); + // } + + // void _showDeleteSpaceDialog(BuildContext context) { + // final spaceBloc = context.read(); + // showDialog( + // context: context, + // builder: (_) { + // return Dialog( + // shape: RoundedRectangleBorder( + // borderRadius: BorderRadius.circular(12.0), + // ), + // child: BlocProvider.value( + // value: spaceBloc, + // child: const SizedBox(width: 440, child: DeleteSpacePopup()), + // ), + // ); + // }, + // ); + // } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart new file mode 100644 index 0000000000000..0197f34940762 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/mobile_space_menu.dart @@ -0,0 +1,500 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/home/space/space_menu_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/util/navigator_context_extension.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart' hide Icon; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'constants.dart'; +import 'manage_space_widget.dart'; + +class MobileSpaceMenu extends StatelessWidget { + const MobileSpaceMenu({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const VSpace(4.0), + for (final space in state.spaces) + SizedBox( + height: SpaceUIConstants.itemHeight, + child: MobileSpaceMenuItem( + space: space, + isSelected: state.currentSpace?.id == space.id, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Divider( + height: 0.5, + ), + ), + const SizedBox( + height: SpaceUIConstants.itemHeight, + child: _CreateSpaceButton(), + ), + ], + ), + ); + }, + ); + } +} + +class MobileSpaceMenuItem extends StatelessWidget { + const MobileSpaceMenuItem({ + super.key, + required this.space, + required this.isSelected, + }); + + final ViewPB space; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return FlowyButton( + text: Row( + children: [ + FlowyText.medium( + space.name, + fontSize: 16.0, + ), + const HSpace(6.0), + if (space.spacePermission == SpacePermission.private) + const FlowySvg( + FlowySvgs.space_lock_s, + size: Size.square(12), + ), + ], + ), + margin: const EdgeInsets.symmetric(horizontal: 12.0), + iconPadding: 10, + leftIcon: SpaceIcon( + dimension: 24, + space: space, + svgSize: 14, + textDimension: 18.0, + cornerRadius: 6.0, + ), + leftIconSize: const Size.square(24), + rightIcon: SpaceMenuItemTrailing( + key: ValueKey('${space.id}_space_menu_item_trailing'), + space: space, + currentSpace: context.read().state.currentSpace, + ), + onTap: () { + context.read().add(SpaceEvent.open(space)); + Navigator.of(context).pop(); + }, + ); + } +} + +class _CreateSpaceButton extends StatefulWidget { + const _CreateSpaceButton(); + + @override + State<_CreateSpaceButton> createState() => _CreateSpaceButtonState(); +} + +class _CreateSpaceButtonState extends State<_CreateSpaceButton> { + final controller = TextEditingController(); + final permission = ValueNotifier( + SpacePermission.publicToAll, + ); + final selectedColor = ValueNotifier( + builtInSpaceColors.first, + ); + final selectedIcon = ValueNotifier( + kIconGroups?.first.icons.first, + ); + + @override + void dispose() { + controller.dispose(); + permission.dispose(); + selectedColor.dispose(); + selectedIcon.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FlowyButton( + text: FlowyText.regular(LocaleKeys.space_createNewSpace.tr()), + iconPadding: 10, + leftIcon: const Padding( + padding: EdgeInsets.all(2.0), + child: FlowySvg( + FlowySvgs.space_add_s, + ), + ), + margin: const EdgeInsets.symmetric(horizontal: 12.0), + leftIconSize: const Size.square(24), + onTap: () => _showCreateSpaceDialog(context), + ); + } + + Future _showCreateSpaceDialog(BuildContext context) async { + await showMobileBottomSheet( + context, + showHeader: true, + title: LocaleKeys.space_createSpace.tr(), + showCloseButton: true, + showDivider: false, + showDoneButton: true, + enableScrollable: true, + showDragHandle: true, + bottomSheetPadding: context.bottomSheetPadding(), + onDone: (bottomSheetContext) { + final iconPath = selectedIcon.value?.iconPath ?? ''; + context.read().add( + SpaceEvent.create( + name: controller.text.orDefault( + LocaleKeys.space_defaultSpaceName.tr(), + ), + permission: permission.value, + iconColor: selectedColor.value, + icon: iconPath, + createNewPageByDefault: true, + openAfterCreate: false, + ), + ); + Navigator.pop(bottomSheetContext); + Navigator.pop(context); + + Log.info( + 'create space on mobile, name: ${controller.text}, permission: ${permission.value}, color: ${selectedColor.value}, icon: $iconPath', + ); + }, + padding: const EdgeInsets.symmetric(horizontal: 16), + builder: (bottomSheetContext) => ManageSpaceWidget( + controller: controller, + permission: permission, + selectedColor: selectedColor, + selectedIcon: selectedIcon, + type: ManageSpaceType.create, + ), + ); + + _resetState(); + } + + void _resetState() { + controller.clear(); + permission.value = SpacePermission.publicToAll; + selectedColor.value = builtInSpaceColors.first; + selectedIcon.value = kIconGroups?.first.icons.first; + } +} + +class SpaceMenuItemTrailing extends StatefulWidget { + const SpaceMenuItemTrailing({ + super.key, + required this.space, + this.currentSpace, + }); + + final ViewPB space; + final ViewPB? currentSpace; + + @override + State createState() => _SpaceMenuItemTrailingState(); +} + +class _SpaceMenuItemTrailingState extends State { + final controller = TextEditingController(); + final permission = ValueNotifier( + SpacePermission.publicToAll, + ); + final selectedColor = ValueNotifier( + builtInSpaceColors.first, + ); + final selectedIcon = ValueNotifier( + kIconGroups?.first.icons.first, + ); + + @override + void dispose() { + controller.dispose(); + permission.dispose(); + selectedColor.dispose(); + selectedIcon.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const iconSize = Size.square(20); + return Row( + children: [ + const HSpace(12.0), + // show the check icon if the space is the current space + if (widget.space.id == widget.currentSpace?.id) + const FlowySvg( + FlowySvgs.m_blue_check_s, + size: iconSize, + blendMode: null, + ), + const HSpace(8.0), + // more options button + AnimatedGestureDetector( + onTapUp: () => _showMoreOptions(context), + child: const Padding( + padding: EdgeInsets.all(8.0), + child: FlowySvg( + FlowySvgs.workspace_three_dots_s, + size: iconSize, + ), + ), + ), + ], + ); + } + + void _showMoreOptions(BuildContext context) { + final actions = [ + SpaceMoreActionType.rename, + SpaceMoreActionType.duplicate, + SpaceMoreActionType.manage, + SpaceMoreActionType.delete, + ]; + + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + useRootNavigator: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (bottomSheetContext) { + return SpaceMenuMoreOptions( + actions: actions, + onAction: (action) => _onActions( + context, + bottomSheetContext, + action, + ), + ); + }, + ); + } + + void _onActions( + BuildContext context, + BuildContext bottomSheetContext, + SpaceMoreActionType action, + ) { + Log.info('execute action in space menu bottom sheet: $action'); + + switch (action) { + case SpaceMoreActionType.rename: + _showRenameSpaceBottomSheet(context); + break; + case SpaceMoreActionType.duplicate: + _duplicateSpace(context, bottomSheetContext); + break; + case SpaceMoreActionType.manage: + _showManageSpaceBottomSheet(context); + break; + case SpaceMoreActionType.delete: + _deleteSpace(context, bottomSheetContext); + break; + default: + assert(false, 'Unsupported action: $action'); + break; + } + } + + void _duplicateSpace(BuildContext context, BuildContext bottomSheetContext) { + Log.info('duplicate the space: ${widget.space.name}'); + + context.read().add(const SpaceEvent.duplicate()); + + showToastNotification( + context, + message: LocaleKeys.space_success_duplicateSpace.tr(), + ); + + Navigator.of(bottomSheetContext).pop(); + Navigator.of(context).pop(); + } + + void _showRenameSpaceBottomSheet(BuildContext context) { + Navigator.of(context).pop(); + + showMobileBottomSheet( + context, + showHeader: true, + title: LocaleKeys.space_renameSpace.tr(), + showCloseButton: true, + showDragHandle: true, + showDivider: false, + padding: const EdgeInsets.symmetric(horizontal: 16), + builder: (bottomSheetContext) { + return EditWorkspaceNameBottomSheet( + type: EditWorkspaceNameType.edit, + workspaceName: widget.space.name, + hintText: LocaleKeys.space_spaceNamePlaceholder.tr(), + validator: (value) => null, + onSubmitted: (name) { + // rename the workspace + Log.info('rename the space, from: ${widget.space.name}, to: $name'); + bottomSheetContext.popToHome(); + + context + .read() + .add(SpaceEvent.rename(space: widget.space, name: name)); + + showToastNotification( + context, + message: LocaleKeys.space_success_renameSpace.tr(), + ); + }, + ); + }, + ); + } + + Future _showManageSpaceBottomSheet(BuildContext context) async { + controller.text = widget.space.name; + permission.value = widget.space.spacePermission; + selectedColor.value = + widget.space.spaceIconColor ?? builtInSpaceColors.first; + selectedIcon.value = widget.space.spaceIcon?.icon; + + await showMobileBottomSheet( + context, + showHeader: true, + title: LocaleKeys.space_manageSpace.tr(), + showCloseButton: true, + showDivider: false, + showDoneButton: true, + enableScrollable: true, + showDragHandle: true, + bottomSheetPadding: context.bottomSheetPadding(), + onDone: (bottomSheetContext) { + String iconName = ''; + final icon = selectedIcon.value; + final iconGroup = icon?.iconGroup; + final iconId = icon?.name; + if (icon != null && iconGroup != null) { + iconName = '${iconGroup.name}/$iconId'; + } + Log.info( + 'update space on mobile, name: ${controller.text}, permission: ${permission.value}, color: ${selectedColor.value}, icon: $iconName', + ); + context.read().add( + SpaceEvent.update( + space: widget.space, + name: controller.text.orDefault( + LocaleKeys.space_defaultSpaceName.tr(), + ), + permission: permission.value, + iconColor: selectedColor.value, + icon: iconName, + ), + ); + + showToastNotification( + context, + message: LocaleKeys.space_success_updateSpace.tr(), + ); + + Navigator.pop(bottomSheetContext); + Navigator.pop(context); + }, + padding: const EdgeInsets.symmetric(horizontal: 16), + builder: (bottomSheetContext) => ManageSpaceWidget( + controller: controller, + permission: permission, + selectedColor: selectedColor, + selectedIcon: selectedIcon, + type: ManageSpaceType.edit, + ), + ); + } + + void _deleteSpace( + BuildContext context, + BuildContext bottomSheetContext, + ) { + Navigator.of(bottomSheetContext).pop(); + + _showConfirmDialog( + context, + '${LocaleKeys.space_delete.tr()}: ${widget.space.name}', + LocaleKeys.space_deleteConfirmationDescription.tr(), + LocaleKeys.button_delete.tr(), + (_) async { + context.read().add(SpaceEvent.delete(widget.space)); + + showToastNotification( + context, + message: LocaleKeys.space_success_deleteSpace.tr(), + ); + + Navigator.pop(context); + }, + ); + } + + void _showConfirmDialog( + BuildContext context, + String title, + String content, + String rightButtonText, + void Function(BuildContext context)? onRightButtonPressed, + ) { + showFlowyCupertinoConfirmDialog( + title: title, + content: FlowyText( + content, + fontSize: 14, + maxLines: 10, + ), + leftButton: FlowyText( + LocaleKeys.button_cancel.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + color: const Color(0xFF007AFF), + ), + rightButton: FlowyText( + rightButtonText, + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w400, + color: const Color(0xFFFE0220), + ), + onRightButtonPressed: onRightButtonPressed, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/space_menu_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/space_menu_bottom_sheet.dart new file mode 100644 index 0000000000000..2eaf609af0f6d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/space_menu_bottom_sheet.dart @@ -0,0 +1,99 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'constants.dart'; + +class SpaceMenuMoreOptions extends StatelessWidget { + const SpaceMenuMoreOptions({ + super.key, + required this.onAction, + required this.actions, + }); + + final void Function(SpaceMoreActionType action) onAction; + final List actions; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: actions + .map( + (action) => _buildActionButton(context, action), + ) + .toList(), + ); + } + + Widget _buildActionButton( + BuildContext context, + SpaceMoreActionType action, + ) { + switch (action) { + case SpaceMoreActionType.rename: + return FlowyOptionTile.text( + text: LocaleKeys.button_rename.tr(), + height: SpaceUIConstants.itemHeight, + leftIcon: const FlowySvg( + FlowySvgs.view_item_rename_s, + size: Size.square(18), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + SpaceMoreActionType.rename, + ), + ); + case SpaceMoreActionType.delete: + return FlowyOptionTile.text( + text: LocaleKeys.button_delete.tr(), + height: SpaceUIConstants.itemHeight, + textColor: Theme.of(context).colorScheme.error, + leftIcon: FlowySvg( + FlowySvgs.trash_s, + size: const Size.square(18), + color: Theme.of(context).colorScheme.error, + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + SpaceMoreActionType.delete, + ), + ); + case SpaceMoreActionType.manage: + return FlowyOptionTile.text( + text: LocaleKeys.space_manage.tr(), + height: SpaceUIConstants.itemHeight, + leftIcon: const FlowySvg( + FlowySvgs.settings_s, + size: Size.square(18), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + SpaceMoreActionType.manage, + ), + ); + case SpaceMoreActionType.duplicate: + return FlowyOptionTile.text( + text: SpaceMoreActionType.duplicate.name, + height: SpaceUIConstants.itemHeight, + leftIcon: const FlowySvg( + FlowySvgs.duplicate_s, + size: Size.square(18), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + SpaceMoreActionType.duplicate, + ), + ); + default: + return const SizedBox.shrink(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/space_permission_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/space_permission_bottom_sheet.dart new file mode 100644 index 0000000000000..85ad35a549728 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/space_permission_bottom_sheet.dart @@ -0,0 +1,53 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class SpacePermissionBottomSheet extends StatelessWidget { + const SpacePermissionBottomSheet({ + super.key, + required this.onAction, + required this.permission, + }); + + final SpacePermission permission; + final void Function(SpacePermission action) onAction; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyOptionTile.text( + text: LocaleKeys.space_publicPermission.tr(), + leftIcon: const FlowySvg( + FlowySvgs.space_permission_public_s, + ), + trailing: permission == SpacePermission.publicToAll + ? const FlowySvg( + FlowySvgs.m_blue_check_s, + blendMode: null, + ) + : null, + onTap: () => onAction(SpacePermission.publicToAll), + ), + FlowyOptionTile.text( + text: LocaleKeys.space_privatePermission.tr(), + showTopBorder: false, + leftIcon: const FlowySvg( + FlowySvgs.space_permission_private_s, + ), + trailing: permission == SpacePermission.private + ? const FlowySvg( + FlowySvgs.m_blue_check_s, + blendMode: null, + ) + : null, + onTap: () => onAction(SpacePermission.private), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/space/widgets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/widgets.dart new file mode 100644 index 0000000000000..ab3295a6305e4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/space/widgets.dart @@ -0,0 +1,389 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; +import 'package:appflowy/shared/icon_emoji_picker/colors.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart' hide Icon; + +import 'constants.dart'; +import 'manage_space_widget.dart'; +import 'space_permission_bottom_sheet.dart'; + +class ManageSpaceNameOption extends StatelessWidget { + const ManageSpaceNameOption({ + super.key, + required this.controller, + required this.type, + }); + + final TextEditingController controller; + final ManageSpaceType type; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 4), + child: FlowyText( + LocaleKeys.space_spaceName.tr(), + fontSize: 14, + figmaLineHeight: 20.0, + fontWeight: FontWeight.w400, + color: Theme.of(context).hintColor, + ), + ), + FlowyOptionTile.textField( + controller: controller, + autofocus: type == ManageSpaceType.create ? true : false, + textFieldHintText: LocaleKeys.space_spaceNamePlaceholder.tr(), + ), + const VSpace(16), + ], + ); + } +} + +class ManageSpacePermissionOption extends StatelessWidget { + const ManageSpacePermissionOption({ + super.key, + required this.permission, + }); + + final ValueNotifier permission; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 4), + child: FlowyText( + LocaleKeys.space_permission.tr(), + fontSize: 14, + figmaLineHeight: 20.0, + fontWeight: FontWeight.w400, + color: Theme.of(context).hintColor, + ), + ), + ValueListenableBuilder( + valueListenable: permission, + builder: (context, value, child) => FlowyOptionTile.text( + height: SpaceUIConstants.itemHeight, + text: value.i18n, + leftIcon: FlowySvg(value.icon), + trailing: const FlowySvg( + FlowySvgs.arrow_right_s, + ), + onTap: () { + showMobileBottomSheet( + context, + showHeader: true, + title: LocaleKeys.space_permission.tr(), + showCloseButton: true, + showDivider: false, + showDragHandle: true, + builder: (context) => SpacePermissionBottomSheet( + permission: value, + onAction: (value) { + permission.value = value; + Navigator.pop(context); + }, + ), + ); + }, + ), + ), + const VSpace(16), + ], + ); + } +} + +class ManageSpaceIconOption extends StatefulWidget { + const ManageSpaceIconOption({ + super.key, + required this.selectedColor, + required this.selectedIcon, + }); + + final ValueNotifier selectedColor; + final ValueNotifier selectedIcon; + + @override + State createState() => _ManageSpaceIconOptionState(); +} + +class _ManageSpaceIconOptionState extends State { + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ..._buildColorOption(context), + ..._buildSpaceIconOption(context), + ], + ); + } + + List _buildColorOption(BuildContext context) { + return [ + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 4), + child: FlowyText( + LocaleKeys.space_mSpaceIconColor.tr(), + fontSize: 14, + figmaLineHeight: 20.0, + fontWeight: FontWeight.w400, + color: Theme.of(context).hintColor, + ), + ), + ValueListenableBuilder( + valueListenable: widget.selectedColor, + builder: (context, selectedColor, child) { + return FlowyOptionDecorateBox( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: builtInSpaceColors.map((color) { + return SpaceColorItem( + color: color, + selectedColor: selectedColor, + onSelected: (color) => widget.selectedColor.value = color, + ); + }).toList(), + ), + ), + ), + ); + }, + ), + const VSpace(16), + ]; + } + + List _buildSpaceIconOption(BuildContext context) { + return [ + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 4), + child: FlowyText( + LocaleKeys.space_mSpaceIcon.tr(), + fontSize: 14, + figmaLineHeight: 20.0, + fontWeight: FontWeight.w400, + color: Theme.of(context).hintColor, + ), + ), + Expanded( + child: SizedBox( + width: double.infinity, + child: ValueListenableBuilder( + valueListenable: widget.selectedColor, + builder: (context, selectedColor, child) { + return ValueListenableBuilder( + valueListenable: widget.selectedIcon, + builder: (context, selectedIcon, child) { + return FlowyOptionDecorateBox( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: _buildIconGroups( + context, + selectedColor, + selectedIcon, + ), + ), + ); + }, + ); + }, + ), + ), + ), + const VSpace(16), + ]; + } + + Widget _buildIconGroups( + BuildContext context, + String selectedColor, + Icon? selectedIcon, + ) { + final iconGroups = kIconGroups; + if (iconGroups == null) { + return const SizedBox.shrink(); + } + + return ListView.builder( + itemCount: iconGroups.length, + itemBuilder: (context, index) { + final iconGroup = iconGroups[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(12.0), + FlowyText( + iconGroup.displayName.capitalize(), + fontSize: 12, + figmaLineHeight: 18.0, + color: context.pickerTextColor, + ), + const VSpace(4.0), + Center( + child: Wrap( + spacing: 10.0, + runSpacing: 8.0, + children: iconGroup.icons.map((icon) { + return SpaceIconItem( + icon: icon, + isSelected: selectedIcon?.name == icon.name, + selectedColor: selectedColor, + onSelectedIcon: (icon) => widget.selectedIcon.value = icon, + ); + }).toList(), + ), + ), + const VSpace(12.0), + if (index == iconGroups.length - 1) ...[ + const StreamlinePermit(), + ], + ], + ); + }, + ); + } +} + +class SpaceIconItem extends StatelessWidget { + const SpaceIconItem({ + super.key, + required this.icon, + required this.onSelectedIcon, + required this.isSelected, + required this.selectedColor, + }); + + final Icon icon; + final void Function(Icon icon) onSelectedIcon; + final bool isSelected; + final String selectedColor; + + @override + Widget build(BuildContext context) { + return AnimatedGestureDetector( + onTapUp: () => onSelectedIcon(icon), + child: Container( + width: 36, + height: 36, + decoration: isSelected + ? BoxDecoration( + color: Color(int.parse(selectedColor)), + borderRadius: BorderRadius.circular(8.0), + ) + : ShapeDecoration( + color: Colors.transparent, + shape: RoundedRectangleBorder( + side: const BorderSide( + width: 0.5, + color: Color(0x661F2329), + ), + borderRadius: BorderRadius.circular(8), + ), + ), + child: Center( + child: FlowySvg.string( + icon.content, + size: const Size.square(18), + color: isSelected + ? Theme.of(context).colorScheme.surface + : context.pickerIconColor, + opacity: isSelected ? 1.0 : 0.7, + ), + ), + ), + ); + } +} + +class SpaceColorItem extends StatelessWidget { + const SpaceColorItem({ + super.key, + required this.color, + required this.selectedColor, + required this.onSelected, + }); + + final String color; + final String selectedColor; + final void Function(String color) onSelected; + + @override + Widget build(BuildContext context) { + final child = Center( + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Color(int.parse(color)), + borderRadius: BorderRadius.circular(14), + ), + ), + ); + + final decoration = color != selectedColor + ? null + : ShapeDecoration( + color: Colors.transparent, + shape: RoundedRectangleBorder( + side: BorderSide( + width: 1.50, + color: Theme.of(context).colorScheme.primary, + ), + borderRadius: BorderRadius.circular(21), + ), + ); + + return AnimatedGestureDetector( + onTapUp: () => onSelected(color), + child: Container( + width: 36, + height: 36, + decoration: decoration, + child: child, + ), + ); + } +} + +extension on SpacePermission { + String get i18n { + switch (this) { + case SpacePermission.publicToAll: + return LocaleKeys.space_publicPermission.tr(); + case SpacePermission.private: + return LocaleKeys.space_privatePermission.tr(); + } + } + + FlowySvgData get icon { + switch (this) { + case SpacePermission.publicToAll: + return FlowySvgs.space_permission_public_s; + case SpacePermission.private: + return FlowySvgs.space_permission_private_s; + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_round_underline_tab_indicator.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_round_underline_tab_indicator.dart new file mode 100644 index 0000000000000..1a3eb121f3b77 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_round_underline_tab_indicator.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +class RoundUnderlineTabIndicator extends Decoration { + const RoundUnderlineTabIndicator({ + this.borderRadius, + this.borderSide = const BorderSide(width: 2.0, color: Colors.white), + this.insets = EdgeInsets.zero, + required this.width, + }); + + final BorderRadius? borderRadius; + final BorderSide borderSide; + final EdgeInsetsGeometry insets; + final double width; + + @override + Decoration? lerpFrom(Decoration? a, double t) { + if (a is UnderlineTabIndicator) { + return UnderlineTabIndicator( + borderSide: BorderSide.lerp(a.borderSide, borderSide, t), + insets: EdgeInsetsGeometry.lerp(a.insets, insets, t)!, + ); + } + return super.lerpFrom(a, t); + } + + @override + Decoration? lerpTo(Decoration? b, double t) { + if (b is UnderlineTabIndicator) { + return UnderlineTabIndicator( + borderSide: BorderSide.lerp(borderSide, b.borderSide, t), + insets: EdgeInsetsGeometry.lerp(insets, b.insets, t)!, + ); + } + return super.lerpTo(b, t); + } + + @override + BoxPainter createBoxPainter([VoidCallback? onChanged]) { + return _UnderlinePainter(this, borderRadius, onChanged); + } + + Rect _indicatorRectFor(Rect rect, TextDirection textDirection) { + final Rect indicator = insets.resolve(textDirection).deflateRect(rect); + final center = indicator.center.dx; + return Rect.fromLTWH( + center - width / 2.0, + indicator.bottom - borderSide.width, + width, + borderSide.width, + ); + } + + @override + Path getClipPath(Rect rect, TextDirection textDirection) { + if (borderRadius != null) { + return Path() + ..addRRect( + borderRadius!.toRRect(_indicatorRectFor(rect, textDirection)), + ); + } + return Path()..addRect(_indicatorRectFor(rect, textDirection)); + } +} + +class _UnderlinePainter extends BoxPainter { + _UnderlinePainter( + this.decoration, + this.borderRadius, + super.onChanged, + ); + + final RoundUnderlineTabIndicator decoration; + final BorderRadius? borderRadius; + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + assert(configuration.size != null); + final Rect rect = offset & configuration.size!; + final TextDirection textDirection = configuration.textDirection!; + final Paint paint; + if (borderRadius != null) { + paint = Paint()..color = decoration.borderSide.color; + final Rect indicator = decoration._indicatorRectFor(rect, textDirection); + final RRect rrect = RRect.fromRectAndCorners( + indicator, + topLeft: borderRadius!.topLeft, + topRight: borderRadius!.topRight, + bottomRight: borderRadius!.bottomRight, + bottomLeft: borderRadius!.bottomLeft, + ); + canvas.drawRRect(rrect, paint); + } else { + paint = decoration.borderSide.toPaint()..strokeCap = StrokeCap.round; + final Rect indicator = decoration + ._indicatorRectFor(rect, textDirection) + .deflate(decoration.borderSide.width / 2.0); + canvas.drawLine(indicator.bottomLeft, indicator.bottomRight, paint); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_tab_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_tab_bar.dart new file mode 100644 index 0000000000000..f8c9a0d3b11ad --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/_tab_bar.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart'; +import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:reorderable_tabbar/reorderable_tabbar.dart'; + +class MobileSpaceTabBar extends StatelessWidget { + const MobileSpaceTabBar({ + super.key, + this.height = 38.0, + required this.tabController, + required this.tabs, + required this.onReorder, + }); + + final double height; + final List tabs; + final TabController tabController; + final OnReorder onReorder; + + @override + Widget build(BuildContext context) { + final baseStyle = Theme.of(context).textTheme.bodyMedium; + final labelStyle = baseStyle?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 16.0, + height: 22.0 / 16.0, + ); + final unselectedLabelStyle = baseStyle?.copyWith( + fontWeight: FontWeight.w400, + fontSize: 15.0, + height: 22.0 / 15.0, + ); + + return Container( + height: height, + padding: const EdgeInsets.only(left: 8.0), + child: ReorderableTabBar( + controller: tabController, + tabs: tabs.map((e) => Tab(text: e.tr)).toList(), + indicatorSize: TabBarIndicatorSize.label, + indicatorColor: Theme.of(context).primaryColor, + isScrollable: true, + labelStyle: labelStyle, + labelColor: baseStyle?.color, + labelPadding: const EdgeInsets.symmetric(horizontal: 12.0), + unselectedLabelStyle: unselectedLabelStyle, + overlayColor: WidgetStateProperty.all(Colors.transparent), + indicator: RoundUnderlineTabIndicator( + width: 28.0, + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 3, + ), + ), + onReorder: onReorder, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart new file mode 100644 index 0000000000000..b3f10bbbd20d1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/ai_bubble_button.dart @@ -0,0 +1,84 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class FloatingAIEntry extends StatelessWidget { + const FloatingAIEntry({super.key}); + + @override + Widget build(BuildContext context) { + return AnimatedGestureDetector( + scaleFactor: 0.99, + onTapUp: () => mobileCreateNewAIChatNotifier.value = + mobileCreateNewAIChatNotifier.value + 1, + child: Hero( + tag: "ai_chat_prompt", + child: DecoratedBox( + decoration: _buildShadowDecoration(context), + child: Container( + decoration: _buildWrapperDecoration(context), + height: 48, + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 18), + child: _buildHintText(context), + ), + ), + ), + ), + ); + } + + BoxDecoration _buildShadowDecoration(BuildContext context) { + return BoxDecoration( + borderRadius: BorderRadius.circular(30), + boxShadow: [ + BoxShadow( + blurRadius: 20, + spreadRadius: 1, + offset: const Offset(0, 4), + color: Colors.black.withOpacity(0.05), + ), + ], + ); + } + + BoxDecoration _buildWrapperDecoration(BuildContext context) { + final outlineColor = Theme.of(context).colorScheme.outline; + final borderColor = Theme.of(context).isLightMode + ? outlineColor.withOpacity(0.7) + : outlineColor.withOpacity(0.3); + return BoxDecoration( + borderRadius: BorderRadius.circular(30), + color: Theme.of(context).colorScheme.surface, + border: Border.fromBorderSide( + BorderSide( + color: borderColor, + ), + ), + ); + } + + Widget _buildHintText(BuildContext context) { + return Row( + children: [ + FlowySvg( + FlowySvgs.toolbar_item_ai_s, + size: const Size.square(16.0), + color: Theme.of(context).hintColor, + opacity: 0.7, + ), + const HSpace(8), + FlowyText( + LocaleKeys.chat_inputMessageHint.tr(), + color: Theme.of(context).hintColor, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart new file mode 100644 index 0000000000000..9d78e39645a37 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/mobile_space_tab.dart @@ -0,0 +1,210 @@ +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/mobile/presentation/home/favorite_folder/favorite_space.dart'; +import 'package:appflowy/mobile/presentation/home/home_space/home_space.dart'; +import 'package:appflowy/mobile/presentation/home/recent_folder/recent_space.dart'; +import 'package:appflowy/mobile/presentation/home/tab/_tab_bar.dart'; +import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/mobile/presentation/setting/workspace/invite_members_screen.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +import 'ai_bubble_button.dart'; + +final ValueNotifier mobileCreateNewAIChatNotifier = ValueNotifier(0); + +class MobileSpaceTab extends StatefulWidget { + const MobileSpaceTab({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + State createState() => _MobileSpaceTabState(); +} + +class _MobileSpaceTabState extends State + with SingleTickerProviderStateMixin { + TabController? tabController; + + @override + void initState() { + super.initState(); + + mobileCreateNewPageNotifier.addListener(_createNewDocument); + mobileCreateNewAIChatNotifier.addListener(_createNewAIChat); + mobileLeaveWorkspaceNotifier.addListener(_leaveWorkspace); + } + + @override + void dispose() { + tabController?.removeListener(_onTabChange); + tabController?.dispose(); + + mobileCreateNewPageNotifier.removeListener(_createNewDocument); + mobileCreateNewAIChatNotifier.removeListener(_createNewAIChat); + mobileLeaveWorkspaceNotifier.removeListener(_leaveWorkspace); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Provider.value( + value: widget.userProfile, + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (p, c) => + p.lastCreatedPage?.id != c.lastCreatedPage?.id, + listener: (context, state) { + final lastCreatedPage = state.lastCreatedPage; + if (lastCreatedPage != null) { + context.pushView(lastCreatedPage); + } + }, + ), + BlocListener( + listenWhen: (p, c) => + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, + listener: (context, state) { + final lastCreatedPage = state.lastCreatedRootView; + if (lastCreatedPage != null) { + context.pushView(lastCreatedPage); + } + }, + ), + ], + child: BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const SizedBox.shrink(); + } + + _initTabController(state); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MobileSpaceTabBar( + tabController: tabController!, + tabs: state.tabsOrder, + onReorder: (from, to) { + context.read().add( + SpaceOrderEvent.reorder(from, to), + ); + }, + ), + const HSpace(12.0), + Expanded( + child: TabBarView( + controller: tabController, + children: _buildTabs(state), + ), + ), + ], + ); + }, + ), + ), + ); + } + + void _initTabController(SpaceOrderState state) { + if (tabController != null) { + return; + } + tabController = TabController( + length: state.tabsOrder.length, + vsync: this, + initialIndex: state.tabsOrder.indexOf(state.defaultTab), + ); + tabController?.addListener(_onTabChange); + } + + void _onTabChange() { + if (tabController == null) { + return; + } + context + .read() + .add(SpaceOrderEvent.open(tabController!.index)); + } + + List _buildTabs(SpaceOrderState state) { + return state.tabsOrder.map((tab) { + switch (tab) { + case MobileSpaceTabType.recent: + return const MobileRecentSpace(); + case MobileSpaceTabType.spaces: + return Stack( + children: [ + MobileHomeSpace(userProfile: widget.userProfile), + // only show ai chat button for cloud user + if (widget.userProfile.authenticator == + AuthenticatorPB.AppFlowyCloud) + Positioned( + bottom: MediaQuery.of(context).padding.bottom + 16, + left: 20, + right: 20, + child: const FloatingAIEntry(), + ), + ], + ); + case MobileSpaceTabType.favorites: + return MobileFavoriteSpace(userProfile: widget.userProfile); + default: + throw Exception('Unknown tab type: $tab'); + } + }).toList(); + } + + // quick create new page when clicking the add button in navigation bar + void _createNewDocument() => _createNewPage(ViewLayoutPB.Document); + + void _createNewAIChat() => _createNewPage(ViewLayoutPB.Chat); + + void _createNewPage(ViewLayoutPB layout) { + if (context.read().state.spaces.isNotEmpty) { + context.read().add( + SpaceEvent.createPage( + name: layout.defaultName, + layout: layout, + openAfterCreate: true, + ), + ); + } else if (layout == ViewLayoutPB.Document) { + // only support create document in section + context.read().add( + SidebarSectionsEvent.createRootViewInSection( + name: layout.defaultName, + index: 0, + viewSection: FolderSpaceType.public.toViewSectionPB, + ), + ); + } + } + + void _leaveWorkspace() { + final workspaceId = + context.read().state.currentWorkspace?.workspaceId; + if (workspaceId == null) { + return Log.error('Workspace ID is null'); + } + context + .read() + .add(UserWorkspaceEvent.leaveWorkspace(workspaceId)); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/space_order_bloc.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/space_order_bloc.dart new file mode 100644 index 0000000000000..e3c1439dd45e6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/tab/space_order_bloc.dart @@ -0,0 +1,127 @@ +import 'dart:convert'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:bloc/bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'space_order_bloc.freezed.dart'; + +enum MobileSpaceTabType { + // DO NOT CHANGE THE ORDER + spaces, + recent, + favorites; + + String get tr { + switch (this) { + case MobileSpaceTabType.recent: + return LocaleKeys.sideBar_RecentSpace.tr(); + case MobileSpaceTabType.spaces: + return LocaleKeys.sideBar_Spaces.tr(); + case MobileSpaceTabType.favorites: + return LocaleKeys.sideBar_favoriteSpace.tr(); + } + } +} + +class SpaceOrderBloc extends Bloc { + SpaceOrderBloc() : super(const SpaceOrderState()) { + on( + (event, emit) async { + await event.when( + initial: () async { + final tabsOrder = await _getTabsOrder(); + final defaultTab = await _getDefaultTab(); + emit( + state.copyWith( + tabsOrder: tabsOrder, + defaultTab: defaultTab, + isLoading: false, + ), + ); + }, + open: (index) async { + final tab = state.tabsOrder[index]; + await _setDefaultTab(tab); + }, + reorder: (from, to) async { + final tabsOrder = List.of(state.tabsOrder); + tabsOrder.insert(to, tabsOrder.removeAt(from)); + await _setTabsOrder(tabsOrder); + emit(state.copyWith(tabsOrder: tabsOrder)); + }, + ); + }, + ); + } + + final _storage = getIt(); + + Future _getDefaultTab() async { + try { + return await _storage.getWithFormat( + KVKeys.lastOpenedSpace, (value) { + return MobileSpaceTabType.values[int.parse(value)]; + }) ?? + MobileSpaceTabType.spaces; + } catch (e) { + return MobileSpaceTabType.spaces; + } + } + + Future _setDefaultTab(MobileSpaceTabType tab) async { + await _storage.set( + KVKeys.lastOpenedSpace, + tab.index.toString(), + ); + } + + Future> _getTabsOrder() async { + try { + return await _storage.getWithFormat>( + KVKeys.spaceOrder, (value) { + final order = jsonDecode(value).cast(); + if (order.isEmpty) { + return MobileSpaceTabType.values; + } + return order + .map((e) => MobileSpaceTabType.values[e]) + .cast() + .toList(); + }) ?? + MobileSpaceTabType.values; + } catch (e) { + return MobileSpaceTabType.values; + } + } + + Future _setTabsOrder(List tabsOrder) async { + await _storage.set( + KVKeys.spaceOrder, + jsonEncode(tabsOrder.map((e) => e.index).toList()), + ); + } +} + +@freezed +class SpaceOrderEvent with _$SpaceOrderEvent { + const factory SpaceOrderEvent.initial() = Initial; + const factory SpaceOrderEvent.open(int index) = Open; + const factory SpaceOrderEvent.reorder(int from, int to) = Reorder; +} + +@freezed +class SpaceOrderState with _$SpaceOrderState { + const factory SpaceOrderState({ + @Default(MobileSpaceTabType.spaces) MobileSpaceTabType defaultTab, + @Default(MobileSpaceTabType.values) List tabsOrder, + @Default(true) bool isLoading, + }) = _SpaceOrderState; + + factory SpaceOrderState.initial() => const SpaceOrderState(); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/create_workspace_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/create_workspace_menu.dart new file mode 100644 index 0000000000000..741cbd6fe9700 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/create_workspace_menu.dart @@ -0,0 +1,130 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +enum EditWorkspaceNameType { + create, + edit; + + String get title { + switch (this) { + case EditWorkspaceNameType.create: + return LocaleKeys.workspace_create.tr(); + case EditWorkspaceNameType.edit: + return LocaleKeys.workspace_renameWorkspace.tr(); + } + } + + String get actionTitle { + switch (this) { + case EditWorkspaceNameType.create: + return LocaleKeys.workspace_create.tr(); + case EditWorkspaceNameType.edit: + return LocaleKeys.button_confirm.tr(); + } + } +} + +class EditWorkspaceNameBottomSheet extends StatefulWidget { + const EditWorkspaceNameBottomSheet({ + super.key, + required this.type, + required this.onSubmitted, + required this.workspaceName, + this.hintText, + this.validator, + this.validatorBuilder, + }); + + final EditWorkspaceNameType type; + final void Function(String) onSubmitted; + + // if the workspace name is not empty, it will be used as the initial value of the text field. + final String? workspaceName; + + final String? hintText; + + final String? Function(String?)? validator; + + final WidgetBuilder? validatorBuilder; + + @override + State createState() => + _EditWorkspaceNameBottomSheetState(); +} + +class _EditWorkspaceNameBottomSheetState + extends State { + late final TextEditingController _textFieldController; + + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _textFieldController = TextEditingController( + text: widget.workspaceName, + ); + } + + @override + void dispose() { + _textFieldController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Form( + key: _formKey, + child: TextFormField( + autofocus: true, + controller: _textFieldController, + keyboardType: TextInputType.text, + decoration: InputDecoration( + hintText: + widget.hintText ?? LocaleKeys.workspace_defaultName.tr(), + ), + validator: widget.validator ?? + (value) { + if (value == null || value.isEmpty) { + return LocaleKeys.workspace_workspaceNameCannotBeEmpty.tr(); + } + return null; + }, + onEditingComplete: _onSubmit, + ), + ), + if (widget.validatorBuilder != null) ...[ + const VSpace(4), + widget.validatorBuilder!(context), + const VSpace(4), + ], + const VSpace(16), + SizedBox( + width: double.infinity, + child: PrimaryRoundedButton( + text: widget.type.actionTitle, + fontSize: 16, + margin: const EdgeInsets.symmetric( + vertical: 16, + ), + onTap: _onSubmit, + ), + ), + ], + ); + } + + void _onSubmit() { + if (_formKey.currentState!.validate()) { + final value = _textFieldController.text; + widget.onSubmitted.call(value); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart new file mode 100644 index 0000000000000..9a5bd8c511a50 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart @@ -0,0 +1,488 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/util/navigator_context_extension.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'create_workspace_menu.dart'; +import 'workspace_more_options.dart'; + +// Only works on mobile. +class MobileWorkspaceMenu extends StatelessWidget { + const MobileWorkspaceMenu({ + super.key, + required this.userProfile, + required this.currentWorkspace, + required this.workspaces, + required this.onWorkspaceSelected, + }); + + final UserProfilePB userProfile; + final UserWorkspacePB currentWorkspace; + final List workspaces; + final void Function(UserWorkspacePB workspace) onWorkspaceSelected; + + @override + Widget build(BuildContext context) { + // user profile + final List children = [ + _WorkspaceUserItem(userProfile: userProfile), + _buildDivider(), + ]; + + // workspace list + for (var i = 0; i < workspaces.length; i++) { + final workspace = workspaces[i]; + children.add( + _WorkspaceMenuItem( + key: ValueKey(workspace.workspaceId), + userProfile: userProfile, + workspace: workspace, + showTopBorder: false, + currentWorkspace: currentWorkspace, + onWorkspaceSelected: onWorkspaceSelected, + ), + ); + } + + // create workspace button + children.addAll([ + _buildDivider(), + const _CreateWorkspaceButton(), + ]); + + return Column( + mainAxisSize: MainAxisSize.min, + children: children, + ); + } + + Widget _buildDivider() { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Divider(height: 0.5), + ); + } +} + +class _CreateWorkspaceButton extends StatelessWidget { + const _CreateWorkspaceButton(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: FlowyOptionTile.text( + height: 60, + showTopBorder: false, + showBottomBorder: false, + leftIcon: _buildLeftIcon(context), + onTap: () => _showCreateWorkspaceBottomSheet(context), + content: Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: FlowyText.medium( + LocaleKeys.workspace_create.tr(), + fontSize: 14, + ), + ), + ), + ), + ); + } + + void _showCreateWorkspaceBottomSheet(BuildContext context) { + showMobileBottomSheet( + context, + showHeader: true, + title: LocaleKeys.workspace_create.tr(), + showCloseButton: true, + showDragHandle: true, + showDivider: false, + padding: const EdgeInsets.symmetric(horizontal: 16), + builder: (bottomSheetContext) { + return EditWorkspaceNameBottomSheet( + type: EditWorkspaceNameType.create, + workspaceName: LocaleKeys.workspace_defaultName.tr(), + onSubmitted: (name) { + // create a new workspace + Log.info('create a new workspace: $name'); + bottomSheetContext.popToHome(); + + context.read().add( + UserWorkspaceEvent.createWorkspace( + name, + ), + ); + }, + ); + }, + ); + } + + Widget _buildLeftIcon(BuildContext context) { + return Container( + width: 36.0, + height: 36.0, + padding: const EdgeInsets.all(7.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x01717171).withOpacity(0.12), + width: 0.8, + ), + ), + child: const FlowySvg(FlowySvgs.add_workspace_s), + ); + } +} + +class _WorkspaceUserItem extends StatelessWidget { + const _WorkspaceUserItem({required this.userProfile}); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).isLightMode + ? const Color(0x99333333) + : const Color(0x99CCCCCC); + return FlowyOptionTile.text( + height: 32, + showTopBorder: false, + showBottomBorder: false, + content: Expanded( + child: Padding( + padding: const EdgeInsets.only(), + child: FlowyText( + userProfile.email, + fontSize: 14, + color: color, + ), + ), + ), + ); + } +} + +class _WorkspaceMenuItem extends StatelessWidget { + const _WorkspaceMenuItem({ + super.key, + required this.userProfile, + required this.workspace, + required this.showTopBorder, + required this.currentWorkspace, + required this.onWorkspaceSelected, + }); + + final UserProfilePB userProfile; + final UserWorkspacePB workspace; + final bool showTopBorder; + final UserWorkspacePB currentWorkspace; + final void Function(UserWorkspacePB workspace) onWorkspaceSelected; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => WorkspaceMemberBloc( + userProfile: userProfile, + workspace: workspace, + )..add(const WorkspaceMemberEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + return FlowyOptionTile.text( + height: 60, + showTopBorder: showTopBorder, + showBottomBorder: false, + leftIcon: _WorkspaceMenuItemIcon(workspace: workspace), + trailing: _WorkspaceMenuItemTrailing( + workspace: workspace, + currentWorkspace: currentWorkspace, + ), + onTap: () => onWorkspaceSelected(workspace), + content: Expanded( + child: _WorkspaceMenuItemContent( + workspace: workspace, + ), + ), + ); + }, + ), + ); + } +} + +// - Workspace name +// - Workspace member count +class _WorkspaceMenuItemContent extends StatelessWidget { + const _WorkspaceMenuItemContent({ + required this.workspace, + }); + + final UserWorkspacePB workspace; + + @override + Widget build(BuildContext context) { + final memberCount = workspace.memberCount.toInt(); + return Padding( + padding: const EdgeInsets.only(left: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlowyText( + workspace.name, + fontSize: 14, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + ), + FlowyText( + memberCount == 0 + ? '' + : LocaleKeys.settings_appearance_members_membersCount.plural( + memberCount, + ), + fontSize: 10.0, + color: Theme.of(context).hintColor, + ), + ], + ), + ); + } +} + +class _WorkspaceMenuItemIcon extends StatelessWidget { + const _WorkspaceMenuItemIcon({ + required this.workspace, + }); + + final UserWorkspacePB workspace; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: WorkspaceIcon( + enableEdit: false, + iconSize: 36, + emojiSize: 24.0, + fontSize: 18.0, + figmaLineHeight: 26.0, + borderRadius: 12.0, + workspace: workspace, + onSelected: (result) => context.read().add( + UserWorkspaceEvent.updateWorkspaceIcon( + workspace.workspaceId, + result.emoji, + ), + ), + ), + ); + } +} + +class _WorkspaceMenuItemTrailing extends StatelessWidget { + const _WorkspaceMenuItemTrailing({ + required this.workspace, + required this.currentWorkspace, + }); + + final UserWorkspacePB workspace; + final UserWorkspacePB currentWorkspace; + + @override + Widget build(BuildContext context) { + const iconSize = Size.square(20); + return Row( + children: [ + const HSpace(12.0), + // show the check icon if the workspace is the current workspace + if (workspace.workspaceId == currentWorkspace.workspaceId) + const FlowySvg( + FlowySvgs.m_blue_check_s, + size: iconSize, + blendMode: null, + ), + const HSpace(8.0), + // more options button + AnimatedGestureDetector( + onTapUp: () => _showMoreOptions(context), + child: const Padding( + padding: EdgeInsets.all(8.0), + child: FlowySvg( + FlowySvgs.workspace_three_dots_s, + size: iconSize, + ), + ), + ), + ], + ); + } + + void _showMoreOptions(BuildContext context) { + final actions = + context.read().state.myRole == AFRolePB.Owner + ? [ + // only the owner can update workspace properties + WorkspaceMenuMoreOption.rename, + WorkspaceMenuMoreOption.delete, + ] + : [ + WorkspaceMenuMoreOption.leave, + ]; + + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + useRootNavigator: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (bottomSheetContext) { + return WorkspaceMenuMoreOptions( + actions: actions, + onAction: (action) => _onActions(context, bottomSheetContext, action), + ); + }, + ); + } + + void _onActions( + BuildContext context, + BuildContext bottomSheetContext, + WorkspaceMenuMoreOption action, + ) { + Log.info('execute action in workspace menu bottom sheet: $action'); + + switch (action) { + case WorkspaceMenuMoreOption.rename: + _showRenameWorkspaceBottomSheet(context); + break; + case WorkspaceMenuMoreOption.invite: + _pushToInviteMembersPage(context); + break; + case WorkspaceMenuMoreOption.delete: + _deleteWorkspace(context, bottomSheetContext); + break; + case WorkspaceMenuMoreOption.leave: + _leaveWorkspace(context, bottomSheetContext); + break; + } + } + + void _pushToInviteMembersPage(BuildContext context) { + // empty implementation + // we don't support invite members in workspace menu + } + + void _showRenameWorkspaceBottomSheet(BuildContext context) { + showMobileBottomSheet( + context, + showHeader: true, + title: LocaleKeys.workspace_renameWorkspace.tr(), + showCloseButton: true, + showDragHandle: true, + showDivider: false, + padding: const EdgeInsets.symmetric(horizontal: 16), + builder: (bottomSheetContext) { + return EditWorkspaceNameBottomSheet( + type: EditWorkspaceNameType.edit, + workspaceName: workspace.name, + onSubmitted: (name) { + // rename the workspace + Log.info('rename the workspace: $name'); + bottomSheetContext.popToHome(); + + context.read().add( + UserWorkspaceEvent.renameWorkspace( + workspace.workspaceId, + name, + ), + ); + }, + ); + }, + ); + } + + void _deleteWorkspace(BuildContext context, BuildContext bottomSheetContext) { + Navigator.of(bottomSheetContext).pop(); + + _showConfirmDialog( + context, + '${LocaleKeys.space_delete.tr()}: ${workspace.name}', + LocaleKeys.workspace_deleteWorkspaceHintText.tr(), + LocaleKeys.button_delete.tr(), + (_) async { + context.read().add( + UserWorkspaceEvent.deleteWorkspace( + workspace.workspaceId, + ), + ); + context.popToHome(); + }, + ); + } + + void _leaveWorkspace(BuildContext context, BuildContext bottomSheetContext) { + Navigator.of(bottomSheetContext).pop(); + + _showConfirmDialog( + context, + '${LocaleKeys.settings_workspacePage_leaveWorkspacePrompt_title.tr()}: ${workspace.name}', + LocaleKeys.settings_workspacePage_leaveWorkspacePrompt_content.tr(), + LocaleKeys.button_confirm.tr(), + (_) async { + context.read().add( + UserWorkspaceEvent.leaveWorkspace( + workspace.workspaceId, + ), + ); + context.popToHome(); + }, + ); + } + + void _showConfirmDialog( + BuildContext context, + String title, + String content, + String rightButtonText, + void Function(BuildContext context)? onRightButtonPressed, + ) { + showFlowyCupertinoConfirmDialog( + title: title, + content: FlowyText( + content, + fontSize: 14, + color: Theme.of(context).hintColor, + maxLines: 10, + ), + leftButton: FlowyText( + LocaleKeys.button_cancel.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + color: const Color(0xFF007AFF), + ), + rightButton: FlowyText( + rightButtonText, + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w400, + color: const Color(0xFFFE0220), + ), + onRightButtonPressed: onRightButtonPressed, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_more_options.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_more_options.dart new file mode 100644 index 0000000000000..5f6066930fe68 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_more_options.dart @@ -0,0 +1,108 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +enum WorkspaceMenuMoreOption { + rename, + invite, + delete, + leave, +} + +class WorkspaceMenuMoreOptions extends StatelessWidget { + const WorkspaceMenuMoreOptions({ + super.key, + this.isFavorite = false, + required this.onAction, + required this.actions, + }); + + final bool isFavorite; + final void Function(WorkspaceMenuMoreOption action) onAction; + final List actions; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: actions + .map( + (action) => _buildActionButton(context, action), + ) + .toList(), + ); + } + + Widget _buildActionButton( + BuildContext context, + WorkspaceMenuMoreOption action, + ) { + switch (action) { + case WorkspaceMenuMoreOption.rename: + return FlowyOptionTile.text( + text: LocaleKeys.button_rename.tr(), + height: 52.0, + leftIcon: const FlowySvg( + FlowySvgs.view_item_rename_s, + size: Size.square(18), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + WorkspaceMenuMoreOption.rename, + ), + ); + case WorkspaceMenuMoreOption.delete: + return FlowyOptionTile.text( + text: LocaleKeys.button_delete.tr(), + height: 52.0, + textColor: Theme.of(context).colorScheme.error, + leftIcon: FlowySvg( + FlowySvgs.trash_s, + size: const Size.square(18), + color: Theme.of(context).colorScheme.error, + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + WorkspaceMenuMoreOption.delete, + ), + ); + case WorkspaceMenuMoreOption.invite: + return FlowyOptionTile.text( + // i18n + text: 'Invite', + height: 52.0, + leftIcon: const FlowySvg( + FlowySvgs.workspace_add_member_s, + size: Size.square(18), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + WorkspaceMenuMoreOption.invite, + ), + ); + case WorkspaceMenuMoreOption.leave: + return FlowyOptionTile.text( + text: LocaleKeys.workspace_leaveCurrentWorkspace.tr(), + height: 52.0, + textColor: Theme.of(context).colorScheme.error, + leftIcon: FlowySvg( + FlowySvgs.leave_workspace_s, + size: const Size.square(18), + color: Theme.of(context).colorScheme.error, + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => onAction( + WorkspaceMenuMoreOption.leave, + ), + ); + default: + return const Placeholder(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart new file mode 100644 index 0000000000000..4d8bc16103be1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/mobile_bottom_navigation_bar.dart @@ -0,0 +1,383 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; +import 'package:appflowy/mobile/presentation/widgets/navigation_bar_button.dart'; +import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart'; +import 'package:appflowy/shared/red_dot.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +enum BottomNavigationBarActionType { + home, + notificationMultiSelect, +} + +final PropertyValueNotifier mobileCreateNewPageNotifier = + PropertyValueNotifier(null); +final ValueNotifier bottomNavigationBarType = + ValueNotifier(BottomNavigationBarActionType.home); + +enum BottomNavigationBarItemType { + home, + add, + notification; + + String get label { + return switch (this) { + BottomNavigationBarItemType.home => 'home', + BottomNavigationBarItemType.add => 'add', + BottomNavigationBarItemType.notification => 'notification', + }; + } + + ValueKey get valueKey { + return ValueKey(label); + } +} + +final _items = [ + BottomNavigationBarItem( + key: BottomNavigationBarItemType.home.valueKey, + label: BottomNavigationBarItemType.home.label, + icon: const FlowySvg(FlowySvgs.m_home_unselected_m), + activeIcon: const FlowySvg(FlowySvgs.m_home_selected_m, blendMode: null), + ), + BottomNavigationBarItem( + key: BottomNavigationBarItemType.add.valueKey, + label: BottomNavigationBarItemType.add.label, + icon: const FlowySvg(FlowySvgs.m_home_add_m), + ), + BottomNavigationBarItem( + key: BottomNavigationBarItemType.notification.valueKey, + label: BottomNavigationBarItemType.notification.label, + icon: const _NotificationNavigationBarItemIcon(), + activeIcon: const _NotificationNavigationBarItemIcon( + isActive: true, + ), + ), +]; + +/// Builds the "shell" for the app by building a Scaffold with a +/// BottomNavigationBar, where [child] is placed in the body of the Scaffold. +class MobileBottomNavigationBar extends StatefulWidget { + /// Constructs an [MobileBottomNavigationBar]. + const MobileBottomNavigationBar({ + required this.navigationShell, + super.key, + }); + + /// The navigation shell and container for the branch Navigators. + final StatefulNavigationShell navigationShell; + + @override + State createState() => + _MobileBottomNavigationBarState(); +} + +class _MobileBottomNavigationBarState extends State { + Widget? _bottomNavigationBar; + + @override + void initState() { + super.initState(); + + bottomNavigationBarType.addListener(_animate); + } + + @override + void dispose() { + bottomNavigationBarType.removeListener(_animate); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + _bottomNavigationBar = switch (bottomNavigationBarType.value) { + BottomNavigationBarActionType.home => + _buildHomePageNavigationBar(context), + BottomNavigationBarActionType.notificationMultiSelect => + _buildNotificationNavigationBar(context), + }; + + return Scaffold( + body: widget.navigationShell, + extendBody: true, + bottomNavigationBar: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: _transitionBuilder, + child: _bottomNavigationBar, + ), + ); + } + + Widget _buildHomePageNavigationBar(BuildContext context) { + return _HomePageNavigationBar( + navigationShell: widget.navigationShell, + ); + } + + Widget _buildNotificationNavigationBar(BuildContext context) { + return const _NotificationNavigationBar(); + } + + // widget A going down, widget B going up + Widget _transitionBuilder( + Widget child, + Animation animation, + ) { + return SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(animation), + child: child, + ); + } + + void _animate() { + setState(() {}); + } +} + +class _NotificationNavigationBarItemIcon extends StatelessWidget { + const _NotificationNavigationBarItemIcon({ + this.isActive = false, + }); + + final bool isActive; + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: getIt(), + child: BlocBuilder( + builder: (context, state) { + final hasUnreads = state.reminders.any( + (reminder) => !reminder.isRead, + ); + return Stack( + children: [ + isActive + ? const FlowySvg( + FlowySvgs.m_home_active_notification_m, + blendMode: null, + ) + : const FlowySvg( + FlowySvgs.m_home_notification_m, + ), + if (hasUnreads) + const Positioned( + top: 2, + right: 4, + child: NotificationRedDot(), + ), + ], + ); + }, + ), + ); + } +} + +class _HomePageNavigationBar extends StatelessWidget { + const _HomePageNavigationBar({ + required this.navigationShell, + }); + + final StatefulNavigationShell navigationShell; + + @override + Widget build(BuildContext context) { + return ClipRRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 3, + sigmaY: 3, + ), + child: DecoratedBox( + decoration: BoxDecoration( + border: context.border, + color: context.backgroundColor, + ), + child: Theme( + data: _getThemeData(context), + child: BottomNavigationBar( + showSelectedLabels: false, + showUnselectedLabels: false, + enableFeedback: false, + type: BottomNavigationBarType.fixed, + elevation: 0, + items: _items, + backgroundColor: Colors.transparent, + currentIndex: navigationShell.currentIndex, + onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex), + ), + ), + ), + ), + ); + } + + ThemeData _getThemeData(BuildContext context) { + if (Platform.isAndroid) { + return Theme.of(context); + } + + // hide the splash effect for iOS + return Theme.of(context).copyWith( + splashFactory: NoSplash.splashFactory, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ); + } + + /// Navigate to the current location of the branch at the provided index when + /// tapping an item in the BottomNavigationBar. + void _onTap(BuildContext context, int bottomBarIndex) { + // close the popup menu + closePopupMenu(); + + final label = _items[bottomBarIndex].label; + if (label == BottomNavigationBarItemType.add.label) { + // show an add dialog + mobileCreateNewPageNotifier.value = ViewLayoutPB.Document; + return; + } else if (label == BottomNavigationBarItemType.notification.label) { + getIt().add(const ReminderEvent.refresh()); + } + // When navigating to a new branch, it's recommended to use the goBranch + // method, as doing so makes sure the last navigation state of the + // Navigator for the branch is restored. + navigationShell.goBranch( + bottomBarIndex, + // A common pattern when using bottom navigation bars is to support + // navigating to the initial location when tapping the item that is + // already active. This example demonstrates how to support this behavior, + // using the initialLocation parameter of goBranch. + initialLocation: bottomBarIndex == navigationShell.currentIndex, + ); + } +} + +class _NotificationNavigationBar extends StatelessWidget { + const _NotificationNavigationBar(); + + @override + Widget build(BuildContext context) { + return Container( + // todo: use real height here. + height: 90, + decoration: BoxDecoration( + border: context.border, + color: context.backgroundColor, + ), + padding: const EdgeInsets.only(bottom: 20), + child: ValueListenableBuilder( + valueListenable: mSelectedNotificationIds, + builder: (context, value, child) { + if (value.isEmpty) { + // not editable + return IgnorePointer( + child: Opacity( + opacity: 0.3, + child: child, + ), + ); + } + + return child!; + }, + child: Row( + children: [ + const HSpace(20), + Expanded( + child: NavigationBarButton( + icon: FlowySvgs.m_notification_action_mark_as_read_s, + text: LocaleKeys.settings_notifications_action_markAsRead.tr(), + onTap: () => _onMarkAsRead(context), + ), + ), + const HSpace(16), + Expanded( + child: NavigationBarButton( + icon: FlowySvgs.m_notification_action_archive_s, + text: LocaleKeys.settings_notifications_action_archive.tr(), + onTap: () => _onArchive(context), + ), + ), + const HSpace(20), + ], + ), + ), + ); + } + + void _onMarkAsRead(BuildContext context) { + if (mSelectedNotificationIds.value.isEmpty) { + return; + } + + showToastNotification( + context, + message: LocaleKeys + .settings_notifications_markAsReadNotifications_allSuccess + .tr(), + ); + + getIt() + .add(ReminderEvent.markAsRead(mSelectedNotificationIds.value)); + + mSelectedNotificationIds.value = []; + } + + void _onArchive(BuildContext context) { + if (mSelectedNotificationIds.value.isEmpty) { + return; + } + + showToastNotification( + context, + message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess + .tr(), + ); + + getIt() + .add(ReminderEvent.archive(mSelectedNotificationIds.value)); + + mSelectedNotificationIds.value = []; + } +} + +extension on BuildContext { + Color get backgroundColor { + return Theme.of(this).isLightMode + ? Colors.white.withOpacity(0.95) + : const Color(0xFF23262B).withOpacity(0.95); + } + + Color get borderColor { + return Theme.of(this).isLightMode + ? const Color(0x141F2329) + : const Color(0xFF23262B).withOpacity(0.5); + } + + Border? get border { + return Theme.of(this).isLightMode + ? Border(top: BorderSide(color: borderColor)) + : null; + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart new file mode 100644 index 0000000000000..cf7ce35e802ba --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileNotificationsMultiSelectScreen extends StatelessWidget { + const MobileNotificationsMultiSelectScreen({super.key}); + + static const routeName = '/notifications_multi_select'; + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: getIt(), + child: const MobileNotificationMultiSelect(), + ); + } +} + +class MobileNotificationMultiSelect extends StatefulWidget { + const MobileNotificationMultiSelect({ + super.key, + }); + + @override + State createState() => + _MobileNotificationMultiSelectState(); +} + +class _MobileNotificationMultiSelectState + extends State { + @override + void dispose() { + mSelectedNotificationIds.value.clear(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MobileNotificationMultiSelectPageHeader(), + VSpace(12.0), + Expanded( + child: MultiSelectNotificationTab(), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart new file mode 100644 index 0000000000000..a8055b8ba2259 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_page.dart @@ -0,0 +1,166 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/user_profile/user_profile_bloc.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/mobile_notification_tab_bar.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/notification_filter/notification_filter_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; +import 'package:appflowy/workspace/presentation/notifications/reminder_extension.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/inbox_action_bar.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_view.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileNotificationsScreen extends StatefulWidget { + const MobileNotificationsScreen({super.key}); + + static const routeName = '/notifications'; + + @override + State createState() => + _MobileNotificationsScreenState(); +} + +class _MobileNotificationsScreenState extends State + with SingleTickerProviderStateMixin { + final ReminderBloc reminderBloc = getIt(); + late final TabController controller = TabController(length: 2, vsync: this); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => + UserProfileBloc()..add(const UserProfileEvent.started()), + ), + BlocProvider.value(value: reminderBloc), + BlocProvider( + create: (_) => NotificationFilterBloc(), + ), + ], + child: BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + orElse: () => + const Center(child: CircularProgressIndicator.adaptive()), + workspaceFailure: () => const WorkspaceFailedScreen(), + success: (workspaceSetting, userProfile) => + _NotificationScreenContent( + workspaceSetting: workspaceSetting, + userProfile: userProfile, + controller: controller, + reminderBloc: reminderBloc, + ), + ); + }, + ), + ); + } +} + +class _NotificationScreenContent extends StatelessWidget { + const _NotificationScreenContent({ + required this.workspaceSetting, + required this.userProfile, + required this.controller, + required this.reminderBloc, + }); + + final WorkspaceSettingPB workspaceSetting; + final UserProfilePB userProfile; + final TabController controller; + final ReminderBloc reminderBloc; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SidebarSectionsBloc() + ..add( + SidebarSectionsEvent.initial( + userProfile, + workspaceSetting.workspaceId, + ), + ), + child: BlocBuilder( + builder: (context, sectionState) => + BlocBuilder( + builder: (context, filterState) => + BlocBuilder( + builder: (context, state) { + // Workaround for rebuilding the Blocks by brightness + Theme.of(context).brightness; + + final List pastReminders = state.pastReminders + .where( + (r) => filterState.showUnreadsOnly ? !r.isRead : true, + ) + .sortByScheduledAt(); + + final List upcomingReminders = + state.upcomingReminders.sortByScheduledAt(); + + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + elevation: 0, + title: Text(LocaleKeys.notificationHub_mobile_title.tr()), + ), + body: SafeArea( + child: Column( + children: [ + MobileNotificationTabBar(controller: controller), + Expanded( + child: TabBarView( + controller: controller, + children: [ + NotificationsView( + shownReminders: pastReminders, + reminderBloc: reminderBloc, + views: sectionState.section.publicViews, + onAction: _onAction, + onReadChanged: _onReadChanged, + actionBar: InboxActionBar( + hasUnreads: state.hasUnreads, + showUnreadsOnly: filterState.showUnreadsOnly, + ), + ), + NotificationsView( + shownReminders: upcomingReminders, + reminderBloc: reminderBloc, + views: sectionState.section.publicViews, + isUpcoming: true, + onAction: _onAction, + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ); + } + + void _onAction(ReminderPB reminder, int? path, ViewPB? view) => + reminderBloc.add( + ReminderEvent.pressReminder( + reminderId: reminder.id, + path: path, + view: view, + ), + ); + + void _onReadChanged(ReminderPB reminder, bool isRead) => reminderBloc.add( + ReminderEvent.update(ReminderUpdate(id: reminder.id, isRead: isRead)), + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_screen.dart new file mode 100644 index 0000000000000..54a0b4e78215b --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/mobile_notifications_screen.dart @@ -0,0 +1,120 @@ +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +final PropertyValueNotifier> mSelectedNotificationIds = + PropertyValueNotifier([]); + +class MobileNotificationsScreenV2 extends StatefulWidget { + const MobileNotificationsScreenV2({super.key}); + + static const routeName = '/notifications'; + + @override + State createState() => + _MobileNotificationsScreenV2State(); +} + +class _MobileNotificationsScreenV2State + extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + + mCurrentWorkspace.addListener(_onRefresh); + } + + @override + void dispose() { + mCurrentWorkspace.removeListener(_onRefresh); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + return BlocProvider.value( + value: getIt(), + child: ValueListenableBuilder( + valueListenable: bottomNavigationBarType, + builder: (_, value, __) { + switch (value) { + case BottomNavigationBarActionType.home: + return const MobileNotificationsTab(); + case BottomNavigationBarActionType.notificationMultiSelect: + return const MobileNotificationMultiSelect(); + } + }, + ), + ); + } + + void _onRefresh() => getIt().add(const ReminderEvent.refresh()); +} + +class MobileNotificationsTab extends StatefulWidget { + const MobileNotificationsTab({super.key}); + + @override + State createState() => _MobileNotificationsTabState(); +} + +class _MobileNotificationsTabState extends State + with SingleTickerProviderStateMixin { + late TabController tabController; + + final tabs = [ + MobileNotificationTabType.inbox, + MobileNotificationTabType.unread, + MobileNotificationTabType.archive, + ]; + + @override + void initState() { + super.initState(); + tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MobileNotificationPageHeader(), + MobileNotificationTabBar( + tabController: tabController, + tabs: tabs, + ), + const VSpace(12.0), + Expanded( + child: TabBarView( + controller: tabController, + children: tabs.map((e) => NotificationTab(tabType: e)).toList(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/color.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/color.dart new file mode 100644 index 0000000000000..8a59336378028 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/color.dart @@ -0,0 +1,11 @@ +import 'package:appflowy/util/theme_extension.dart'; +import 'package:flutter/material.dart'; + +extension NotificationItemColors on BuildContext { + Color get notificationItemTextColor { + if (Theme.of(this).isLightMode) { + return const Color(0xFF171717); + } + return const Color(0xFFffffff).withOpacity(0.8); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/empty.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/empty.dart new file mode 100644 index 0000000000000..e5598cc6e591a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/empty.dart @@ -0,0 +1,58 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class EmptyNotification extends StatelessWidget { + const EmptyNotification({ + super.key, + required this.type, + }); + + final MobileNotificationTabType type; + + @override + Widget build(BuildContext context) { + final title = switch (type) { + MobileNotificationTabType.inbox => + LocaleKeys.settings_notifications_emptyInbox_title.tr(), + MobileNotificationTabType.archive => + LocaleKeys.settings_notifications_emptyArchived_title.tr(), + MobileNotificationTabType.unread => + LocaleKeys.settings_notifications_emptyUnread_title.tr(), + }; + final desc = switch (type) { + MobileNotificationTabType.inbox => + LocaleKeys.settings_notifications_emptyInbox_description.tr(), + MobileNotificationTabType.archive => + LocaleKeys.settings_notifications_emptyArchived_description.tr(), + MobileNotificationTabType.unread => + LocaleKeys.settings_notifications_emptyUnread_description.tr(), + }; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg(FlowySvgs.m_empty_notification_xl), + const VSpace(12.0), + FlowyText( + title, + fontSize: 16.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + ), + const VSpace(4.0), + Opacity( + opacity: 0.45, + child: FlowyText( + desc, + fontSize: 15.0, + figmaLineHeight: 22.0, + fontWeight: FontWeight.w400, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/header.dart new file mode 100644 index 0000000000000..9f1311d1e714f --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/header.dart @@ -0,0 +1,96 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/settings_popup_menu.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileNotificationPageHeader extends StatelessWidget { + const MobileNotificationPageHeader({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(18.0), + FlowyText( + LocaleKeys.settings_notifications_titles_notifications.tr(), + fontSize: 20, + fontWeight: FontWeight.w600, + ), + const Spacer(), + const NotificationSettingsPopupMenu(), + const HSpace(16.0), + ], + ), + ); + } +} + +class MobileNotificationMultiSelectPageHeader extends StatelessWidget { + const MobileNotificationMultiSelectPageHeader({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildCancelButton( + isOpaque: false, + padding: const EdgeInsets.symmetric(horizontal: 16), + onTap: () => bottomNavigationBarType.value = + BottomNavigationBarActionType.home, + ), + ValueListenableBuilder( + valueListenable: mSelectedNotificationIds, + builder: (_, value, __) { + return FlowyText( + // todo: i18n + '${value.length} Selected', + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + ); + }, + ), + // this button is used to align the text to the center + _buildCancelButton( + isOpaque: true, + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + ], + ), + ); + } + + // + Widget _buildCancelButton({ + required bool isOpaque, + required EdgeInsets padding, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Padding( + padding: padding, + child: FlowyText( + LocaleKeys.button_cancel.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w400, + color: isOpaque ? Colors.transparent : null, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/mobile_notification_tab_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/mobile_notification_tab_bar.dart new file mode 100644 index 0000000000000..59bfe61822bc8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/mobile_notification_tab_bar.dart @@ -0,0 +1,75 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/flowy_tab.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +class MobileNotificationTabBar extends StatefulWidget { + const MobileNotificationTabBar({super.key, required this.controller}); + + final TabController controller; + + @override + State createState() => + _MobileNotificationTabBarState(); +} + +class _MobileNotificationTabBarState extends State { + @override + void initState() { + super.initState(); + widget.controller.addListener(_updateState); + } + + @override + void dispose() { + widget.controller.removeListener(_updateState); + super.dispose(); + } + + void _updateState() => setState(() {}); + + @override + Widget build(BuildContext context) { + final borderSide = BorderSide( + color: AFThemeExtension.of(context).calloutBGColor, + ); + + return DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: borderSide, + top: borderSide, + ), + ), + child: Row( + children: [ + Expanded( + child: TabBar( + controller: widget.controller, + padding: const EdgeInsets.symmetric(horizontal: 8), + labelPadding: EdgeInsets.zero, + indicatorSize: TabBarIndicatorSize.label, + indicator: UnderlineTabIndicator( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + isScrollable: true, + tabs: [ + FlowyTabItem( + label: LocaleKeys.notificationHub_tabs_inbox.tr(), + isSelected: widget.controller.index == 0, + ), + FlowyTabItem( + label: LocaleKeys.notificationHub_tabs_upcoming.tr(), + isSelected: widget.controller.index == 1, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/multi_select_notification_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/multi_select_notification_item.dart new file mode 100644 index 0000000000000..ea57d5d391c23 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/multi_select_notification_item.dart @@ -0,0 +1,115 @@ +import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MultiSelectNotificationItem extends StatelessWidget { + const MultiSelectNotificationItem({ + super.key, + required this.reminder, + }); + + final ReminderPB reminder; + + @override + Widget build(BuildContext context) { + final settings = context.read().state; + final dateFormate = settings.dateFormat; + final timeFormate = settings.timeFormat; + return BlocProvider( + create: (context) => NotificationReminderBloc() + ..add( + NotificationReminderEvent.initial( + reminder, + dateFormate, + timeFormate, + ), + ), + child: BlocBuilder( + builder: (context, state) { + if (state.status == NotificationReminderStatus.loading || + state.status == NotificationReminderStatus.initial) { + return const SizedBox.shrink(); + } + + if (state.status == NotificationReminderStatus.error) { + // error handle. + return const SizedBox.shrink(); + } + + final child = ValueListenableBuilder( + valueListenable: mSelectedNotificationIds, + builder: (_, selectedIds, child) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + decoration: selectedIds.contains(reminder.id) + ? ShapeDecoration( + color: const Color(0x1900BCF0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ) + : null, + child: child, + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: _InnerNotificationItem( + reminder: reminder, + ), + ), + ); + + return AnimatedGestureDetector( + scaleFactor: 0.99, + onTapUp: () { + if (mSelectedNotificationIds.value.contains(reminder.id)) { + mSelectedNotificationIds.value = mSelectedNotificationIds.value + ..remove(reminder.id); + } else { + mSelectedNotificationIds.value = mSelectedNotificationIds.value + ..add(reminder.id); + } + }, + child: child, + ); + }, + ), + ); + } +} + +class _InnerNotificationItem extends StatelessWidget { + const _InnerNotificationItem({ + required this.reminder, + }); + + final ReminderPB reminder; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const HSpace(10.0), + NotificationCheckIcon( + isSelected: mSelectedNotificationIds.value.contains(reminder.id), + ), + const HSpace(3.0), + !reminder.isRead ? const UnreadRedDot() : const HSpace(6.0), + const HSpace(3.0), + NotificationIcon(reminder: reminder), + const HSpace(12.0), + Expanded( + child: NotificationContent(reminder: reminder), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/notification_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/notification_item.dart new file mode 100644 index 0000000000000..e5fe288755da6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/notification_item.dart @@ -0,0 +1,168 @@ +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +class NotificationItem extends StatelessWidget { + const NotificationItem({ + super.key, + required this.tabType, + required this.reminder, + }); + + final MobileNotificationTabType tabType; + final ReminderPB reminder; + + @override + Widget build(BuildContext context) { + final settings = context.read().state; + final dateFormate = settings.dateFormat; + final timeFormate = settings.timeFormat; + return BlocProvider( + create: (context) => NotificationReminderBloc() + ..add( + NotificationReminderEvent.initial( + reminder, + dateFormate, + timeFormate, + ), + ), + child: BlocBuilder( + builder: (context, state) { + if (state.status == NotificationReminderStatus.loading || + state.status == NotificationReminderStatus.initial) { + return const SizedBox.shrink(); + } + + if (state.status == NotificationReminderStatus.error) { + // error handle. + return const SizedBox.shrink(); + } + + final child = Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: _SlidableNotificationItem( + tabType: tabType, + reminder: reminder, + child: _InnerNotificationItem( + tabType: tabType, + reminder: reminder, + ), + ), + ); + + return AnimatedGestureDetector( + scaleFactor: 0.99, + child: child, + onTapUp: () async { + final view = state.view; + final blockId = state.blockId; + if (view == null) { + return; + } + + await context.pushView( + view, + blockId: blockId, + ); + + if (!reminder.isRead && context.mounted) { + context.read().add( + ReminderEvent.markAsRead([reminder.id]), + ); + } + }, + ); + }, + ), + ); + } +} + +class _InnerNotificationItem extends StatelessWidget { + const _InnerNotificationItem({ + required this.reminder, + required this.tabType, + }); + + final MobileNotificationTabType tabType; + final ReminderPB reminder; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const HSpace(8.0), + !reminder.isRead ? const UnreadRedDot() : const HSpace(6.0), + const HSpace(4.0), + NotificationIcon(reminder: reminder), + const HSpace(12.0), + Expanded( + child: NotificationContent(reminder: reminder), + ), + ], + ); + } +} + +class _SlidableNotificationItem extends StatelessWidget { + const _SlidableNotificationItem({ + required this.tabType, + required this.reminder, + required this.child, + }); + + final MobileNotificationTabType tabType; + final ReminderPB reminder; + final Widget child; + + @override + Widget build(BuildContext context) { + final List actions = switch (tabType) { + MobileNotificationTabType.inbox => [ + NotificationPaneActionType.more, + if (!reminder.isRead) NotificationPaneActionType.markAsRead, + ], + MobileNotificationTabType.unread => [ + NotificationPaneActionType.more, + NotificationPaneActionType.markAsRead, + ], + MobileNotificationTabType.archive => [ + if (kDebugMode) NotificationPaneActionType.unArchive, + ], + }; + + if (actions.isEmpty) { + return child; + } + + final children = actions + .map( + (action) => action.actionButton( + context, + tabType: tabType, + ), + ) + .toList(); + + final extentRatio = actions.length == 1 ? 1 / 5 : 1 / 3; + + return Slidable( + endActionPane: ActionPane( + motion: const ScrollMotion(), + extentRatio: extentRatio, + children: children, + ), + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart new file mode 100644 index 0000000000000..dfa277f2ef33d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/settings_popup_menu.dart @@ -0,0 +1,170 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' + hide PopupMenuButton, PopupMenuDivider, PopupMenuItem, PopupMenuEntry; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +enum _NotificationSettingsPopupMenuItem { + settings, + markAllAsRead, + archiveAll, + // only visible in debug mode + unarchiveAll; +} + +class NotificationSettingsPopupMenu extends StatelessWidget { + const NotificationSettingsPopupMenu({super.key}); + + @override + Widget build(BuildContext context) { + return PopupMenuButton<_NotificationSettingsPopupMenuItem>( + offset: const Offset(0, 36), + padding: EdgeInsets.zero, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12.0), + ), + ), + // todo: replace it with shadows + shadowColor: const Color(0x68000000), + elevation: 10, + color: context.popupMenuBackgroundColor, + itemBuilder: (BuildContext context) => + >[ + _buildItem( + value: _NotificationSettingsPopupMenuItem.settings, + svg: FlowySvgs.m_notification_settings_s, + text: LocaleKeys.settings_notifications_settings_settings.tr(), + ), + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _NotificationSettingsPopupMenuItem.markAllAsRead, + svg: FlowySvgs.m_notification_mark_as_read_s, + text: LocaleKeys.settings_notifications_settings_markAllAsRead.tr(), + ), + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _NotificationSettingsPopupMenuItem.archiveAll, + svg: FlowySvgs.m_notification_archived_s, + text: LocaleKeys.settings_notifications_settings_archiveAll.tr(), + ), + // only visible in debug mode + if (kDebugMode) ...[ + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _NotificationSettingsPopupMenuItem.unarchiveAll, + svg: FlowySvgs.m_notification_archived_s, + text: 'Unarchive all (Debug Mode)', + ), + ], + ], + onSelected: (_NotificationSettingsPopupMenuItem value) { + switch (value) { + case _NotificationSettingsPopupMenuItem.markAllAsRead: + _onMarkAllAsRead(context); + break; + case _NotificationSettingsPopupMenuItem.archiveAll: + _onArchiveAll(context); + break; + case _NotificationSettingsPopupMenuItem.settings: + context.push(MobileHomeSettingPage.routeName); + break; + case _NotificationSettingsPopupMenuItem.unarchiveAll: + _onUnarchiveAll(context); + break; + } + }, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: FlowySvg( + FlowySvgs.m_settings_more_s, + ), + ), + ); + } + + PopupMenuItem _buildItem({ + required T value, + required FlowySvgData svg, + required String text, + }) { + return PopupMenuItem( + value: value, + padding: EdgeInsets.zero, + child: _PopupButton( + svg: svg, + text: text, + ), + ); + } + + void _onMarkAllAsRead(BuildContext context) { + showToastNotification( + context, + message: LocaleKeys + .settings_notifications_markAsReadNotifications_allSuccess + .tr(), + ); + + context.read().add(const ReminderEvent.markAllRead()); + } + + void _onArchiveAll(BuildContext context) { + showToastNotification( + context, + message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess + .tr(), + ); + + context.read().add(const ReminderEvent.archiveAll()); + } + + void _onUnarchiveAll(BuildContext context) { + if (!kDebugMode) { + return; + } + + showToastNotification( + context, + message: 'Unarchive all success (Debug Mode)', + ); + + context.read().add(const ReminderEvent.unarchiveAll()); + } +} + +class _PopupButton extends StatelessWidget { + const _PopupButton({ + required this.svg, + required this.text, + }); + + final FlowySvgData svg; + final String text; + + @override + Widget build(BuildContext context) { + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + FlowySvg(svg), + const HSpace(12), + FlowyText.regular( + text, + fontSize: 16, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/shared.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/shared.dart new file mode 100644 index 0000000000000..70cc8c5214921 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/shared.dart @@ -0,0 +1,291 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/color.dart'; +import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +const _kNotificationIconHeight = 36.0; + +class NotificationIcon extends StatelessWidget { + const NotificationIcon({ + super.key, + required this.reminder, + }); + + final ReminderPB reminder; + + @override + Widget build(BuildContext context) { + return const FlowySvg( + FlowySvgs.m_notification_reminder_s, + size: Size.square(_kNotificationIconHeight), + blendMode: null, + ); + } +} + +class NotificationCheckIcon extends StatelessWidget { + const NotificationCheckIcon({super.key, required this.isSelected}); + + final bool isSelected; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _kNotificationIconHeight, + child: Center( + child: FlowySvg( + isSelected + ? FlowySvgs.m_notification_multi_select_s + : FlowySvgs.m_notification_multi_unselect_s, + blendMode: isSelected ? null : BlendMode.srcIn, + ), + ), + ); + } +} + +class UnreadRedDot extends StatelessWidget { + const UnreadRedDot({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: _kNotificationIconHeight, + child: Center( + child: SizedBox.square( + dimension: 6.0, + child: DecoratedBox( + decoration: ShapeDecoration( + color: Color(0xFFFF6331), + shape: OvalBorder(), + ), + ), + ), + ), + ); + } +} + +class NotificationContent extends StatefulWidget { + const NotificationContent({ + super.key, + required this.reminder, + }); + + final ReminderPB reminder; + + @override + State createState() => _NotificationContentState(); +} + +class _NotificationContentState extends State { + @override + void didUpdateWidget(covariant NotificationContent oldWidget) { + super.didUpdateWidget(oldWidget); + + context.read().add( + const NotificationReminderEvent.reset(), + ); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final view = state.view; + if (view == null) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // title + _buildHeader(), + + // time & page name + _buildTimeAndPageName( + context, + state.createdAt, + state.pageTitle, + ), + + // content + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: _buildContent(view, nodes: state.nodes), + ), + ], + ); + }, + ); + } + + Widget _buildContent(ViewPB view, {List? nodes}) { + if (view.layout.isDocumentView && nodes != null) { + return IntrinsicHeight( + child: BlocProvider( + create: (context) => DocumentPageStyleBloc(view: view), + child: NotificationDocumentContent( + reminder: widget.reminder, + nodes: nodes, + ), + ), + ); + } else if (view.layout.isDatabaseView) { + final opacity = widget.reminder.type == ReminderType.past ? 0.3 : 1.0; + return Opacity( + opacity: opacity, + child: FlowyText( + widget.reminder.message, + fontSize: 14, + figmaLineHeight: 22, + color: context.notificationItemTextColor, + ), + ); + } + + return const SizedBox.shrink(); + } + + Widget _buildHeader() { + return FlowyText.semibold( + LocaleKeys.settings_notifications_titles_reminder.tr(), + fontSize: 14, + figmaLineHeight: 20, + ); + } + + Widget _buildTimeAndPageName( + BuildContext context, + String createdAt, + String pageTitle, + ) { + return Opacity( + opacity: 0.5, + child: Row( + children: [ + // the legacy reminder doesn't contain the timestamp, so we don't show it + if (createdAt.isNotEmpty) ...[ + FlowyText.regular( + createdAt, + fontSize: 12, + figmaLineHeight: 18, + color: context.notificationItemTextColor, + ), + const NotificationEllipse(), + ], + FlowyText.regular( + pageTitle, + fontSize: 12, + figmaLineHeight: 18, + color: context.notificationItemTextColor, + ), + ], + ), + ); + } +} + +class NotificationEllipse extends StatelessWidget { + const NotificationEllipse({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: 2.50, + height: 2.50, + margin: const EdgeInsets.symmetric(horizontal: 5.0), + decoration: ShapeDecoration( + color: context.notificationItemTextColor, + shape: const OvalBorder(), + ), + ); + } +} + +class NotificationDocumentContent extends StatelessWidget { + const NotificationDocumentContent({ + super.key, + required this.reminder, + required this.nodes, + }); + + final ReminderPB reminder; + final List nodes; + + @override + Widget build(BuildContext context) { + final editorState = EditorState( + document: Document( + root: pageNode(children: nodes), + ), + ); + + final styleCustomizer = EditorStyleCustomizer( + context: context, + padding: EdgeInsets.zero, + ); + + final editorStyle = styleCustomizer.style().copyWith( + // hide the cursor + cursorColor: Colors.transparent, + cursorWidth: 0, + textStyleConfiguration: TextStyleConfiguration( + lineHeight: 22 / 14, + applyHeightToFirstAscent: true, + applyHeightToLastDescent: true, + text: TextStyle( + fontSize: 14, + color: context.notificationItemTextColor, + height: 22 / 14, + fontWeight: FontWeight.w400, + leadingDistribution: TextLeadingDistribution.even, + ), + ), + ); + + final blockBuilders = buildBlockComponentBuilders( + context: context, + editorState: editorState, + styleCustomizer: styleCustomizer, + // the editor is not editable in the chat + editable: false, + customHeadingPadding: UniversalPlatform.isDesktop + ? EdgeInsets.zero + : EdgeInsets.symmetric( + vertical: EditorStyleCustomizer.nodeHorizontalPadding, + ), + ); + + return IgnorePointer( + child: Opacity( + opacity: reminder.type == ReminderType.past ? 0.3 : 1, + child: AppFlowyEditor( + editorState: editorState, + editorStyle: editorStyle, + disableSelectionService: true, + disableKeyboardService: true, + disableScrollService: true, + editable: false, + shrinkWrap: true, + blockComponentBuilders: blockBuilders, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart new file mode 100644 index 0000000000000..85f468c76c1ee --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/slide_actions.dart @@ -0,0 +1,212 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/mobile/presentation/page_item/mobile_slide_action_button.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +enum NotificationPaneActionType { + more, + markAsRead, + // only used in the debug mode. + unArchive; + + MobileSlideActionButton actionButton( + BuildContext context, { + required MobileNotificationTabType tabType, + }) { + switch (this) { + case NotificationPaneActionType.markAsRead: + return MobileSlideActionButton( + backgroundColor: const Color(0xFF00C8FF), + svg: FlowySvgs.m_notification_action_mark_as_read_s, + size: 24.0, + onPressed: (context) { + showToastNotification( + context, + message: LocaleKeys + .settings_notifications_markAsReadNotifications_success + .tr(), + ); + + context.read().add( + ReminderEvent.update( + ReminderUpdate( + id: context.read().reminder.id, + isRead: true, + ), + ), + ); + }, + ); + // this action is only used in the debug mode. + case NotificationPaneActionType.unArchive: + return MobileSlideActionButton( + backgroundColor: const Color(0xFF00C8FF), + svg: FlowySvgs.m_notification_action_mark_as_read_s, + size: 24.0, + onPressed: (context) { + showToastNotification( + context, + message: 'Unarchive notification success', + ); + + context.read().add( + ReminderEvent.update( + ReminderUpdate( + id: context.read().reminder.id, + isArchived: false, + ), + ), + ); + }, + ); + case NotificationPaneActionType.more: + return MobileSlideActionButton( + backgroundColor: const Color(0xE5515563), + svg: FlowySvgs.three_dots_s, + size: 24.0, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(10), + bottomLeft: Radius.circular(10), + ), + onPressed: (context) { + final reminderBloc = context.read(); + final notificationReminderBloc = + context.read(); + + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + useRootNavigator: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (_) { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: reminderBloc), + BlocProvider.value(value: notificationReminderBloc), + ], + child: _NotificationMoreActions( + onClickMultipleChoice: () { + Future.delayed(const Duration(milliseconds: 250), () { + bottomNavigationBarType.value = + BottomNavigationBarActionType + .notificationMultiSelect; + }); + }, + ), + ); + }, + ); + }, + ); + } + } +} + +class _NotificationMoreActions extends StatelessWidget { + const _NotificationMoreActions({ + required this.onClickMultipleChoice, + }); + + final VoidCallback onClickMultipleChoice; + + @override + Widget build(BuildContext context) { + final reminder = context.read().reminder; + return Column( + children: [ + if (!reminder.isRead) + FlowyOptionTile.text( + height: 52.0, + text: LocaleKeys.settings_notifications_action_markAsRead.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_notification_action_mark_as_read_s, + size: Size.square(20), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => _onMarkAsRead(context), + ), + FlowyOptionTile.text( + height: 52.0, + text: LocaleKeys.settings_notifications_action_multipleChoice.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_notification_action_multiple_choice_s, + size: Size.square(20), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => _onMultipleChoice(context), + ), + if (!reminder.isArchived) + FlowyOptionTile.text( + height: 52.0, + text: LocaleKeys.settings_notifications_action_archive.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_notification_action_archive_s, + size: Size.square(20), + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () => _onArchive(context), + ), + ], + ); + } + + void _onMarkAsRead(BuildContext context) { + Navigator.of(context).pop(); + + showToastNotification( + context, + message: LocaleKeys.settings_notifications_markAsReadNotifications_success + .tr(), + ); + + context.read().add( + ReminderEvent.update( + ReminderUpdate( + id: context.read().reminder.id, + isRead: true, + ), + ), + ); + } + + void _onMultipleChoice(BuildContext context) { + Navigator.of(context).pop(); + + onClickMultipleChoice(); + } + + void _onArchive(BuildContext context) { + showToastNotification( + context, + message: LocaleKeys.settings_notifications_archiveNotifications_success + .tr() + .tr(), + ); + + context.read().add( + ReminderEvent.update( + ReminderUpdate( + id: context.read().reminder.id, + isRead: true, + isArchived: true, + ), + ), + ); + + Navigator.of(context).pop(); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart new file mode 100644 index 0000000000000..7dda8f0a14ff2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab.dart @@ -0,0 +1,137 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/shared/list_extension.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/appflowy_backend.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class NotificationTab extends StatefulWidget { + const NotificationTab({ + super.key, + required this.tabType, + }); + + final MobileNotificationTabType tabType; + + @override + State createState() => _NotificationTabState(); +} + +class _NotificationTabState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + + return BlocBuilder( + builder: (context, state) { + final reminders = _filterReminders(state.reminders); + + if (reminders.isEmpty) { + // add refresh indicator to the empty notification. + return EmptyNotification( + type: widget.tabType, + ); + } + + final child = ListView.separated( + itemCount: reminders.length, + separatorBuilder: (context, index) => const VSpace(8.0), + itemBuilder: (context, index) { + final reminder = reminders[index]; + return NotificationItem( + key: ValueKey('${widget.tabType}_${reminder.id}'), + tabType: widget.tabType, + reminder: reminder, + ); + }, + ); + + return RefreshIndicator.adaptive( + onRefresh: () async => _onRefresh(context), + child: child, + ); + }, + ); + } + + Future _onRefresh(BuildContext context) async { + context.read().add(const ReminderEvent.refresh()); + + // at least 0.5 seconds to dismiss the refresh indicator. + // otherwise, it will be dismissed immediately. + await context.read().stream.firstOrNull; + await Future.delayed(const Duration(milliseconds: 500)); + + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys.settings_notifications_refreshSuccess.tr(), + ); + } + } + + List _filterReminders(List reminders) { + switch (widget.tabType) { + case MobileNotificationTabType.inbox: + return reminders.reversed + .where((reminder) => !reminder.isArchived) + .toList() + .unique((reminder) => reminder.id); + case MobileNotificationTabType.archive: + return reminders.reversed + .where((reminder) => reminder.isArchived) + .toList() + .unique((reminder) => reminder.id); + case MobileNotificationTabType.unread: + return reminders.reversed + .where((reminder) => !reminder.isRead) + .toList() + .unique((reminder) => reminder.id); + } + } +} + +class MultiSelectNotificationTab extends StatelessWidget { + const MultiSelectNotificationTab({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + // find the reminders that are not archived or read. + final reminders = state.reminders.reversed + .where((reminder) => !reminder.isArchived || !reminder.isRead) + .toList(); + + if (reminders.isEmpty) { + // add refresh indicator to the empty notification. + return const SizedBox.shrink(); + } + + return ListView.separated( + itemCount: reminders.length, + separatorBuilder: (context, index) => const VSpace(8.0), + itemBuilder: (context, index) { + final reminder = reminders[index]; + return MultiSelectNotificationItem( + key: ValueKey(reminder.id), + reminder: reminder, + ); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab_bar.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab_bar.dart new file mode 100644 index 0000000000000..35fb6ea067249 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/tab_bar.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:reorderable_tabbar/reorderable_tabbar.dart'; + +enum MobileNotificationTabType { + inbox, + unread, + archive; + + String get tr { + switch (this) { + case MobileNotificationTabType.inbox: + return LocaleKeys.settings_notifications_tabs_inbox.tr(); + case MobileNotificationTabType.unread: + return LocaleKeys.settings_notifications_tabs_unread.tr(); + case MobileNotificationTabType.archive: + return LocaleKeys.settings_notifications_tabs_archived.tr(); + } + } + + List get actions { + switch (this) { + case MobileNotificationTabType.inbox: + return [ + NotificationPaneActionType.more, + NotificationPaneActionType.markAsRead, + ]; + case MobileNotificationTabType.unread: + case MobileNotificationTabType.archive: + return []; + } + } +} + +class MobileNotificationTabBar extends StatelessWidget { + const MobileNotificationTabBar({ + super.key, + this.height = 38.0, + required this.tabController, + required this.tabs, + }); + + final double height; + final List tabs; + final TabController tabController; + + @override + Widget build(BuildContext context) { + final baseStyle = Theme.of(context).textTheme.bodyMedium; + final labelStyle = baseStyle?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 16.0, + height: 22.0 / 16.0, + ); + final unselectedLabelStyle = baseStyle?.copyWith( + fontWeight: FontWeight.w400, + fontSize: 15.0, + height: 22.0 / 15.0, + ); + + return Container( + height: height, + padding: const EdgeInsets.only(left: 8.0), + child: ReorderableTabBar( + controller: tabController, + tabs: tabs.map((e) => Tab(text: e.tr)).toList(), + indicatorSize: TabBarIndicatorSize.label, + isScrollable: true, + labelStyle: labelStyle, + labelColor: baseStyle?.color, + labelPadding: const EdgeInsets.symmetric(horizontal: 12.0), + unselectedLabelStyle: unselectedLabelStyle, + overlayColor: WidgetStateProperty.all(Colors.transparent), + indicator: const RoundUnderlineTabIndicator( + width: 28.0, + borderSide: BorderSide( + color: Color(0xFF00C8FF), + width: 3, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/widgets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/widgets.dart new file mode 100644 index 0000000000000..92cd83a74e06d --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/notifications/widgets/widgets.dart @@ -0,0 +1,9 @@ +export 'empty.dart'; +export 'header.dart'; +export 'multi_select_notification_item.dart'; +export 'notification_item.dart'; +export 'settings_popup_menu.dart'; +export 'shared.dart'; +export 'slide_actions.dart'; +export 'tab.dart'; +export 'tab_bar.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_slide_action_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_slide_action_button.dart new file mode 100644 index 0000000000000..b3d021613e75a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_slide_action_button.dart @@ -0,0 +1,39 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +class MobileSlideActionButton extends StatelessWidget { + const MobileSlideActionButton({ + super.key, + required this.svg, + this.size = 32.0, + this.backgroundColor = Colors.transparent, + this.borderRadius = BorderRadius.zero, + required this.onPressed, + }); + + final FlowySvgData svg; + final double size; + final Color backgroundColor; + final SlidableActionCallback onPressed; + final BorderRadius borderRadius; + + @override + Widget build(BuildContext context) { + return CustomSlidableAction( + borderRadius: borderRadius, + backgroundColor: backgroundColor, + onPressed: (context) { + HapticFeedback.mediumImpact(); + onPressed(context); + }, + padding: EdgeInsets.zero, + child: FlowySvg( + svg, + size: Size.square(size), + color: Colors.white, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart new file mode 100644 index 0000000000000..ee4a371eb5418 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item.dart @@ -0,0 +1,352 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +typedef ViewItemOnSelected = void Function(ViewPB); +typedef ActionPaneBuilder = ActionPane Function(BuildContext context); + +class MobileViewItem extends StatelessWidget { + const MobileViewItem({ + super.key, + required this.view, + this.parentView, + required this.spaceType, + required this.level, + this.leftPadding = 10, + required this.onSelected, + this.isFirstChild = false, + this.isDraggable = true, + required this.isFeedback, + this.startActionPane, + this.endActionPane, + }); + + final ViewPB view; + final ViewPB? parentView; + + final FolderSpaceType spaceType; + + // indicate the level of the view item + // used to calculate the left padding + final int level; + + // the left padding of the view item for each level + // the left padding of the each level = level * leftPadding + final double leftPadding; + + // Selected by normal conventions + final ViewItemOnSelected onSelected; + + // used for indicating the first child of the parent view, so that we can + // add top border to the first child + final bool isFirstChild; + + // it should be false when it's rendered as feedback widget inside DraggableItem + final bool isDraggable; + + // identify if the view item is rendered as feedback widget inside DraggableItem + final bool isFeedback; + + // the actions of the view item, such as favorite, rename, delete, etc. + final ActionPaneBuilder? startActionPane; + final ActionPaneBuilder? endActionPane; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), + child: BlocConsumer( + listenWhen: (p, c) => + c.lastCreatedView != null && + p.lastCreatedView?.id != c.lastCreatedView!.id, + listener: (context, state) => context.pushView(state.lastCreatedView!), + builder: (context, state) { + return InnerMobileViewItem( + view: state.view, + parentView: parentView, + childViews: state.view.childViews, + spaceType: spaceType, + level: level, + leftPadding: leftPadding, + showActions: true, + isExpanded: state.isExpanded, + onSelected: onSelected, + isFirstChild: isFirstChild, + isDraggable: isDraggable, + isFeedback: isFeedback, + startActionPane: startActionPane, + endActionPane: endActionPane, + ); + }, + ), + ); + } +} + +class InnerMobileViewItem extends StatelessWidget { + const InnerMobileViewItem({ + super.key, + required this.view, + required this.parentView, + required this.childViews, + required this.spaceType, + this.isDraggable = true, + this.isExpanded = true, + required this.level, + required this.leftPadding, + required this.showActions, + required this.onSelected, + this.isFirstChild = false, + required this.isFeedback, + this.startActionPane, + this.endActionPane, + }); + + final ViewPB view; + final ViewPB? parentView; + final List childViews; + final FolderSpaceType spaceType; + + final bool isDraggable; + final bool isExpanded; + final bool isFirstChild; + + // identify if the view item is rendered as feedback widget inside DraggableItem + final bool isFeedback; + + final int level; + final double leftPadding; + + final bool showActions; + final ViewItemOnSelected onSelected; + + final ActionPaneBuilder? startActionPane; + final ActionPaneBuilder? endActionPane; + + @override + Widget build(BuildContext context) { + Widget child = SingleMobileInnerViewItem( + view: view, + parentView: parentView, + level: level, + showActions: showActions, + spaceType: spaceType, + onSelected: onSelected, + isExpanded: isExpanded, + isDraggable: isDraggable, + leftPadding: leftPadding, + isFeedback: isFeedback, + startActionPane: startActionPane, + endActionPane: endActionPane, + ); + + // if the view is expanded and has child views, render its child views + if (isExpanded) { + if (childViews.isNotEmpty) { + final children = childViews.map((childView) { + return MobileViewItem( + key: ValueKey('${spaceType.name} ${childView.id}'), + parentView: view, + spaceType: spaceType, + isFirstChild: childView.id == childViews.first.id, + view: childView, + level: level + 1, + onSelected: onSelected, + isDraggable: isDraggable, + leftPadding: leftPadding, + isFeedback: isFeedback, + startActionPane: startActionPane, + endActionPane: endActionPane, + ); + }).toList(); + + child = Column( + mainAxisSize: MainAxisSize.min, + children: [ + child, + ...children, + ], + ); + } + } + + // wrap the child with DraggableItem if isDraggable is true + if (isDraggable && !isReferencedDatabaseView(view, parentView)) { + child = DraggableViewItem( + isFirstChild: isFirstChild, + view: view, + centerHighlightColor: Colors.blue.shade200, + topHighlightColor: Colors.blue.shade200, + bottomHighlightColor: Colors.blue.shade200, + feedback: (context) { + return MobileViewItem( + view: view, + parentView: parentView, + spaceType: spaceType, + level: level, + onSelected: onSelected, + isDraggable: false, + leftPadding: leftPadding, + isFeedback: true, + startActionPane: startActionPane, + endActionPane: endActionPane, + ); + }, + child: child, + ); + } + + return child; + } +} + +class SingleMobileInnerViewItem extends StatefulWidget { + const SingleMobileInnerViewItem({ + super.key, + required this.view, + required this.parentView, + required this.isExpanded, + required this.level, + required this.leftPadding, + this.isDraggable = true, + required this.spaceType, + required this.showActions, + required this.onSelected, + required this.isFeedback, + this.startActionPane, + this.endActionPane, + }); + + final ViewPB view; + final ViewPB? parentView; + final bool isExpanded; + + // identify if the view item is rendered as feedback widget inside DraggableItem + final bool isFeedback; + + final int level; + final double leftPadding; + + final bool isDraggable; + final bool showActions; + final ViewItemOnSelected onSelected; + final FolderSpaceType spaceType; + final ActionPaneBuilder? startActionPane; + final ActionPaneBuilder? endActionPane; + + @override + State createState() => + _SingleMobileInnerViewItemState(); +} + +class _SingleMobileInnerViewItemState extends State { + @override + Widget build(BuildContext context) { + final children = [ + // expand icon + _buildLeftIcon(), + // icon + _buildViewIcon(), + const HSpace(8), + // title + Expanded( + child: FlowyText.regular( + widget.view.nameOrDefault, + fontSize: 16.0, + figmaLineHeight: 20.0, + overflow: TextOverflow.ellipsis, + ), + ), + ]; + + Widget child = InkWell( + borderRadius: BorderRadius.circular(4.0), + onTap: () => widget.onSelected(widget.view), + child: SizedBox( + height: HomeSpaceViewSizes.mViewHeight, + child: Padding( + padding: EdgeInsets.only(left: widget.level * widget.leftPadding), + child: Row( + children: children, + ), + ), + ), + ); + + if (widget.startActionPane != null || widget.endActionPane != null) { + child = Slidable( + // Specify a key if the Slidable is dismissible. + key: ValueKey(widget.view.hashCode), + startActionPane: widget.startActionPane?.call(context), + endActionPane: widget.endActionPane?.call(context), + child: child, + ); + } + + return child; + } + + Widget _buildViewIcon() { + final icon = widget.view.icon.value.isNotEmpty + ? EmojiIconWidget( + emoji: widget.view.icon.toEmojiIconData(), + emojiSize: Platform.isAndroid ? 16.0 : 18.0, + ) + : Opacity( + opacity: 0.7, + child: widget.view.defaultIcon(size: const Size.square(18)), + ); + return SizedBox( + width: 18.0, + child: icon, + ); + } + + // > button or · button + // show > if the view is expandable. + // show · if the view can't contain child views. + Widget _buildLeftIcon() { + const rightPadding = 6.0; + if (context.read().state.view.childViews.isEmpty) { + return HSpace(widget.leftPadding + rightPadding); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + child: Padding( + padding: + const EdgeInsets.only(right: rightPadding, top: 6.0, bottom: 6.0), + child: FlowySvg( + widget.isExpanded ? FlowySvgs.m_expand_s : FlowySvgs.m_collapse_s, + blendMode: null, + ), + ), + onTap: () { + context + .read() + .add(ViewEvent.setIsExpanded(!widget.isExpanded)); + }, + ); + } +} + +// workaround: we should use view.isEndPoint or something to check if the view can contain child views. But currently, we don't have that field. +bool isReferencedDatabaseView(ViewPB view, ViewPB? parentView) { + if (parentView == null) { + return false; + } + return view.layout.isDatabaseView && parentView.layout.isDatabaseView; +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item_add_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item_add_button.dart new file mode 100644 index 0000000000000..77bb57773fc0a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/page_item/mobile_view_item_add_button.dart @@ -0,0 +1,46 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileViewAddButton extends StatelessWidget { + const MobileViewAddButton({ + super.key, + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + width: HomeSpaceViewSizes.mViewButtonDimension, + height: HomeSpaceViewSizes.mViewButtonDimension, + icon: const FlowySvg( + FlowySvgs.m_space_add_s, + ), + onPressed: onPressed, + ); + } +} + +class MobileViewMoreButton extends StatelessWidget { + const MobileViewMoreButton({ + super.key, + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + width: HomeSpaceViewSizes.mViewButtonDimension, + height: HomeSpaceViewSizes.mViewButtonDimension, + icon: const FlowySvg( + FlowySvgs.m_space_more_s, + ), + onPressed: onPressed, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/presentation.dart b/frontend/appflowy_flutter/lib/mobile/presentation/presentation.dart new file mode 100644 index 0000000000000..69a3ae503c9d0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/presentation.dart @@ -0,0 +1,5 @@ +export 'editor/mobile_editor_screen.dart'; +export 'home/home.dart'; +export 'mobile_bottom_navigation_bar.dart'; +export 'root_placeholder_page.dart'; +export 'setting/setting.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/root_placeholder_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/root_placeholder_page.dart new file mode 100644 index 0000000000000..7465ccaa340d0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/root_placeholder_page.dart @@ -0,0 +1,33 @@ +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +/// Widget for the root/initial pages in the bottom navigation bar. +class RootPlaceholderScreen extends StatelessWidget { + /// Creates a RootScreen + const RootPlaceholderScreen({ + required this.label, + required this.detailsPath, + this.secondDetailsPath, + super.key, + }); + + /// The label + final String label; + + /// The path to the detail page + final String detailsPath; + + /// The path to another detail page + final String? secondDetailsPath; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: FlowyText.medium(label), + ), + body: const SizedBox.shrink(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about.dart new file mode 100644 index 0000000000000..dc5d3ea55edb4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about.dart @@ -0,0 +1 @@ +export 'about_setting_group.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart new file mode 100644 index 0000000000000..d4f0766626b9f --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/about/about_setting_group.dart @@ -0,0 +1,57 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/mobile_feature_flag_screen.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../widgets/widgets.dart'; + +class AboutSettingGroup extends StatelessWidget { + const AboutSettingGroup({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return MobileSettingGroup( + groupTitle: LocaleKeys.settings_mobile_about.tr(), + settingItemList: [ + MobileSettingItem( + name: LocaleKeys.settings_mobile_privacyPolicy.tr(), + trailing: const Icon( + Icons.chevron_right, + ), + onTap: () => afLaunchUrlString('https://appflowy.io/privacy'), + ), + MobileSettingItem( + name: LocaleKeys.settings_mobile_termsAndConditions.tr(), + trailing: const Icon( + Icons.chevron_right, + ), + onTap: () => afLaunchUrlString('https://appflowy.io/terms'), + ), + if (kDebugMode) + MobileSettingItem( + name: 'Feature Flags', + trailing: const Icon( + Icons.chevron_right, + ), + onTap: () { + context.push(FeatureFlagScreen.routeName); + }, + ), + MobileSettingItem( + name: LocaleKeys.settings_mobile_version.tr(), + trailing: FlowyText( + '${ApplicationInfo.applicationVersion} (${ApplicationInfo.buildNumber})', + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/appearance_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/appearance_setting_group.dart new file mode 100644 index 0000000000000..abb31817e5f4b --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/appearance_setting_group.dart @@ -0,0 +1,27 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/setting/appearance/rtl_setting.dart'; +import 'package:appflowy/mobile/presentation/setting/appearance/text_scale_setting.dart'; +import 'package:appflowy/mobile/presentation/setting/appearance/theme_setting.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../setting.dart'; + +class AppearanceSettingGroup extends StatelessWidget { + const AppearanceSettingGroup({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return MobileSettingGroup( + groupTitle: LocaleKeys.settings_menu_appearance.tr(), + settingItemList: const [ + ThemeSetting(), + FontSetting(), + TextScaleSetting(), + RTLSetting(), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart new file mode 100644 index 0000000000000..e61281c5c4cff --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/rtl_setting.dart @@ -0,0 +1,84 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../setting.dart'; + +class RTLSetting extends StatelessWidget { + const RTLSetting({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final layoutDirection = + context.watch().state.layoutDirection; + return MobileSettingItem( + name: LocaleKeys.settings_appearance_textDirection_label.tr(), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + _textDirectionLabelText(layoutDirection), + color: theme.colorScheme.onSurface, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: () { + showMobileBottomSheet( + context, + showHeader: true, + showDragHandle: true, + showDivider: false, + title: LocaleKeys.settings_appearance_textDirection_label.tr(), + builder: (context) { + final layoutDirection = + context.watch().state.layoutDirection; + return Column( + children: [ + FlowyOptionTile.checkbox( + text: LocaleKeys.settings_appearance_textDirection_ltr.tr(), + isSelected: layoutDirection == LayoutDirection.ltrLayout, + onTap: () { + context + .read() + .setLayoutDirection(LayoutDirection.ltrLayout); + Navigator.pop(context); + }, + ), + FlowyOptionTile.checkbox( + showTopBorder: false, + text: LocaleKeys.settings_appearance_textDirection_rtl.tr(), + isSelected: layoutDirection == LayoutDirection.rtlLayout, + onTap: () { + context + .read() + .setLayoutDirection(LayoutDirection.rtlLayout); + Navigator.pop(context); + }, + ), + ], + ); + }, + ); + }, + ); + } + + String _textDirectionLabelText(LayoutDirection? textDirection) { + switch (textDirection) { + case LayoutDirection.rtlLayout: + return LocaleKeys.settings_appearance_textDirection_rtl.tr(); + case LayoutDirection.ltrLayout: + default: + return LocaleKeys.settings_appearance_textDirection_ltr.tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/text_scale_setting.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/text_scale_setting.dart new file mode 100644 index 0000000000000..3bdb836a71861 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/text_scale_setting.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../setting.dart'; + +const int _divisions = 4; + +class TextScaleSetting extends StatelessWidget { + const TextScaleSetting({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textScaleFactor = + context.watch().state.textScaleFactor; + return MobileSettingItem( + name: LocaleKeys.settings_appearance_fontScaleFactor.tr(), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + // map the text scale factor to the 0-1 + // 0.8 - 0.0 + // 0.9 - 0.5 + // 1.0 - 1.0 + ((_divisions + 1) * textScaleFactor - _divisions) + .toStringAsFixed(2), + color: theme.colorScheme.onSurface, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: () { + showMobileBottomSheet( + context, + showHeader: true, + showDragHandle: true, + showDivider: false, + title: LocaleKeys.settings_appearance_fontScaleFactor.tr(), + builder: (context) { + return FontSizeStepper( + value: textScaleFactor, + minimumValue: 0.8, + maximumValue: 1.0, + divisions: _divisions, + onChanged: (newTextScaleFactor) { + context + .read() + .setTextScaleFactor(newTextScaleFactor); + }, + ); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/theme_setting.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/theme_setting.dart new file mode 100644 index 0000000000000..8893eab1059b5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/appearance/theme_setting.dart @@ -0,0 +1,95 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/util/theme_mode_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../setting.dart'; + +class ThemeSetting extends StatelessWidget { + const ThemeSetting({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final themeMode = context.watch().state.themeMode; + return MobileSettingItem( + name: LocaleKeys.settings_appearance_themeMode_label.tr(), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + themeMode.labelText, + color: theme.colorScheme.onSurface, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: () { + showMobileBottomSheet( + context, + showHeader: true, + showDragHandle: true, + showDivider: false, + title: LocaleKeys.settings_appearance_themeMode_label.tr(), + builder: (context) { + final themeMode = + context.read().state.themeMode; + return Column( + children: [ + FlowyOptionTile.checkbox( + text: LocaleKeys.settings_appearance_themeMode_system.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_theme_mode_system_s, + ), + isSelected: themeMode == ThemeMode.system, + onTap: () { + context + .read() + .setThemeMode(ThemeMode.system); + Navigator.pop(context); + }, + ), + FlowyOptionTile.checkbox( + showTopBorder: false, + text: LocaleKeys.settings_appearance_themeMode_light.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_theme_mode_light_s, + ), + isSelected: themeMode == ThemeMode.light, + onTap: () { + context + .read() + .setThemeMode(ThemeMode.light); + Navigator.pop(context); + }, + ), + FlowyOptionTile.checkbox( + showTopBorder: false, + text: LocaleKeys.settings_appearance_themeMode_dark.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_theme_mode_dark_s, + ), + isSelected: themeMode == ThemeMode.dark, + onTap: () { + context + .read() + .setThemeMode(ThemeMode.dark); + Navigator.pop(context); + }, + ), + ], + ); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart new file mode 100644 index 0000000000000..24c50f7ae6df4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/appflowy_cloud_page.dart @@ -0,0 +1,26 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class AppFlowyCloudPage extends StatelessWidget { + const AppFlowyCloudPage({super.key}); + + static const routeName = '/AppFlowyCloudPage'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FlowyAppBar( + titleText: LocaleKeys.settings_menu_cloudSettings.tr(), + ), + body: SettingCloud( + restartAppFlowy: () async { + await runAppFlowy(); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/cloud_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/cloud_setting_group.dart new file mode 100644 index 0000000000000..741055463245a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/cloud/cloud_setting_group.dart @@ -0,0 +1,33 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/setting/cloud/appflowy_cloud_page.dart'; +import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart'; +import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class CloudSettingGroup extends StatelessWidget { + const CloudSettingGroup({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) => MobileSettingGroup( + groupTitle: LocaleKeys.settings_menu_cloudSettings.tr(), + settingItemList: [ + MobileSettingItem( + name: LocaleKeys.settings_menu_cloudAppFlowy.tr(), + trailing: const Icon( + Icons.chevron_right, + ), + onTap: () => context.push(AppFlowyCloudPage.routeName), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_picker_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_picker_screen.dart new file mode 100644 index 0000000000000..390f0824de590 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_picker_screen.dart @@ -0,0 +1,145 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/shared/google_fonts_extension.dart'; +import 'package:appflowy/util/font_family_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +final List _availableFonts = [ + defaultFontFamily, + ...GoogleFonts.asMap().keys, +]; + +class FontPickerScreen extends StatelessWidget { + const FontPickerScreen({super.key}); + + static const routeName = '/font_picker'; + + @override + Widget build(BuildContext context) { + return const LanguagePickerPage(); + } +} + +class LanguagePickerPage extends StatefulWidget { + const LanguagePickerPage({super.key}); + + @override + State createState() => _LanguagePickerPageState(); +} + +class _LanguagePickerPageState extends State { + late List availableFonts; + + @override + void initState() { + super.initState(); + availableFonts = _availableFonts; + } + + @override + Widget build(BuildContext context) { + final selectedFontFamilyName = + context.watch().state.font; + return Scaffold( + appBar: FlowyAppBar( + titleText: LocaleKeys.titleBar_font.tr(), + ), + body: SafeArea( + child: Scrollbar( + child: FontSelector( + selectedFontFamilyName: selectedFontFamilyName, + onFontFamilySelected: (fontFamilyName) => + context.pop(fontFamilyName), + ), + ), + ), + ); + } +} + +class FontSelector extends StatefulWidget { + const FontSelector({ + super.key, + this.scrollController, + required this.selectedFontFamilyName, + required this.onFontFamilySelected, + }); + + final ScrollController? scrollController; + final String selectedFontFamilyName; + final void Function(String fontFamilyName) onFontFamilySelected; + + @override + State createState() => _FontSelectorState(); +} + +class _FontSelectorState extends State { + late List availableFonts; + + @override + void initState() { + super.initState(); + availableFonts = _availableFonts; + } + + @override + Widget build(BuildContext context) { + return ListView.builder( + controller: widget.scrollController, + itemCount: availableFonts.length + 1, // with search bar + itemBuilder: (context, index) { + if (index == 0) { + // search bar + return _buildSearchBar(context); + } + + final fontFamilyName = availableFonts[index - 1]; + final usingDefaultFontFamily = fontFamilyName == defaultFontFamily; + final fontFamily = !usingDefaultFontFamily + ? getGoogleFontSafely(fontFamilyName).fontFamily + : defaultFontFamily; + return FlowyOptionTile.checkbox( + text: fontFamilyName.fontFamilyDisplayName, + isSelected: widget.selectedFontFamilyName == fontFamilyName, + showTopBorder: false, + onTap: () => widget.onFontFamilySelected(fontFamilyName), + fontFamily: fontFamily, + backgroundColor: Colors.transparent, + ); + }, + ); + } + + Widget _buildSearchBar(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 12.0, + ), + child: FlowyMobileSearchTextField( + onChanged: (keyword) { + setState(() { + availableFonts = _availableFonts + .where( + (font) => + font.isEmpty || // keep the default one always + font + .parseFontFamilyName() + .toLowerCase() + .contains(keyword.toLowerCase()), + ) + .toList(); + }); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart new file mode 100644 index 0000000000000..1076b9dba6c64 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/font/font_setting.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/util/font_family_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../setting.dart'; + +class FontSetting extends StatelessWidget { + const FontSetting({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final selectedFont = context.watch().state.font; + final name = selectedFont.fontFamilyDisplayName; + return MobileSettingItem( + name: LocaleKeys.settings_appearance_fontFamily_label.tr(), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + lineHeight: 1.0, + name, + color: theme.colorScheme.onSurface, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: () async { + final newFont = await context.push(FontPickerScreen.routeName); + if (newFont != null && newFont != selectedFont) { + if (context.mounted) { + context.read().setFontFamily(newFont); + unawaited( + context.read().syncFontFamily(newFont), + ); + } + } + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/language/language_picker_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/language/language_picker_screen.dart new file mode 100644 index 0000000000000..9b7d395b61427 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/language/language_picker_screen.dart @@ -0,0 +1,52 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/language.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class LanguagePickerScreen extends StatelessWidget { + const LanguagePickerScreen({super.key}); + + static const routeName = '/language_picker'; + + @override + Widget build(BuildContext context) => const LanguagePickerPage(); +} + +class LanguagePickerPage extends StatefulWidget { + const LanguagePickerPage({ + super.key, + }); + + @override + State createState() => _LanguagePickerPageState(); +} + +class _LanguagePickerPageState extends State { + @override + Widget build(BuildContext context) { + final supportedLocales = EasyLocalization.of(context)!.supportedLocales; + return Scaffold( + appBar: FlowyAppBar( + titleText: LocaleKeys.titleBar_language.tr(), + ), + body: SafeArea( + child: ListView.builder( + itemBuilder: (context, index) { + final locale = supportedLocales[index]; + return FlowyOptionTile.checkbox( + text: languageFromLocale(locale), + isSelected: EasyLocalization.of(context)!.locale == locale, + showTopBorder: false, + onTap: () => context.pop(locale), + backgroundColor: Colors.transparent, + ); + }, + itemCount: supportedLocales.length, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/language_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/language_setting_group.dart new file mode 100644 index 0000000000000..6473485514a0f --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/language_setting_group.dart @@ -0,0 +1,65 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/setting/language/language_picker_screen.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/language.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import 'setting.dart'; + +class LanguageSettingGroup extends StatefulWidget { + const LanguageSettingGroup({ + super.key, + }); + + @override + State createState() => _LanguageSettingGroupState(); +} + +class _LanguageSettingGroupState extends State { + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) { + return state.locale; + }, + builder: (context, locale) { + final theme = Theme.of(context); + return MobileSettingGroup( + groupTitle: LocaleKeys.settings_menu_language.tr(), + settingItemList: [ + MobileSettingItem( + name: LocaleKeys.settings_menu_language.tr(), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + lineHeight: 1.0, + languageFromLocale(locale), + color: theme.colorScheme.onSurface, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: () async { + final newLocale = + await context.push(LanguagePickerScreen.routeName); + if (newLocale != null && newLocale != locale) { + if (context.mounted) { + context + .read() + .setLocale(context, newLocale); + } + } + }, + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/launch_settings_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/launch_settings_page.dart new file mode 100644 index 0000000000000..390b814d5c49c --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/launch_settings_page.dart @@ -0,0 +1,39 @@ +import 'package:appflowy/env/env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/mobile/presentation/setting/self_host_setting_group.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileLaunchSettingsPage extends StatelessWidget { + const MobileLaunchSettingsPage({ + super.key, + }); + + static const routeName = '/launch_settings'; + + @override + Widget build(BuildContext context) { + context.watch(); + return Scaffold( + appBar: FlowyAppBar( + titleText: LocaleKeys.settings_title.tr(), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const LanguageSettingGroup(), + if (Env.enableCustomCloud) const SelfHostSettingGroup(), + const SupportSettingGroup(), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/notifications_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/notifications_setting_group.dart new file mode 100644 index 0000000000000..8e77f362f68ce --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/notifications_setting_group.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'widgets/widgets.dart'; + +class NotificationsSettingGroup extends StatefulWidget { + const NotificationsSettingGroup({super.key}); + + @override + State createState() => + _NotificationsSettingGroupState(); +} + +class _NotificationsSettingGroupState extends State { + bool isPushNotificationOn = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return MobileSettingGroup( + groupTitle: LocaleKeys.notificationHub_title.tr(), + settingItemList: [ + MobileSettingItem( + name: LocaleKeys.settings_mobile_pushNotifications.tr(), + trailing: Switch.adaptive( + activeColor: theme.colorScheme.primary, + value: isPushNotificationOn, + onChanged: (bool value) { + setState(() { + isPushNotificationOn = value; + }); + }, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/edit_username_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/edit_username_bottom_sheet.dart new file mode 100644 index 0000000000000..04fe9e9cf7bad --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/edit_username_bottom_sheet.dart @@ -0,0 +1,77 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class EditUsernameBottomSheet extends StatefulWidget { + const EditUsernameBottomSheet( + this.context, { + this.userName, + required this.onSubmitted, + super.key, + }); + final BuildContext context; + final String? userName; + final void Function(String) onSubmitted; + @override + State createState() => + _EditUsernameBottomSheetState(); +} + +class _EditUsernameBottomSheetState extends State { + late TextEditingController _textFieldController; + + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _textFieldController = TextEditingController(text: widget.userName); + } + + @override + void dispose() { + _textFieldController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + void submitUserName() { + if (_formKey.currentState!.validate()) { + final value = _textFieldController.text; + widget.onSubmitted.call(value); + widget.context.pop(); + } + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Form( + key: _formKey, + child: TextFormField( + controller: _textFieldController, + keyboardType: TextInputType.text, + validator: (value) { + if (value == null || value.isEmpty) { + return LocaleKeys.settings_mobile_usernameEmptyError.tr(); + } + return null; + }, + onEditingComplete: submitUserName, + ), + ), + const VSpace(16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: submitUserName, + child: Text(LocaleKeys.button_update.tr()), + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info.dart new file mode 100644 index 0000000000000..368a6d6f48858 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info.dart @@ -0,0 +1,2 @@ +export 'edit_username_bottom_sheet.dart'; +export 'personal_info_setting_group.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart new file mode 100644 index 0000000000000..cfdf3defb0c2a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/user/prelude.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../widgets/widgets.dart'; + +import 'personal_info.dart'; + +class PersonalInfoSettingGroup extends StatelessWidget { + const PersonalInfoSettingGroup({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return BlocProvider( + create: (context) => getIt( + param1: userProfile, + )..add(const SettingsUserEvent.initial()), + child: BlocSelector( + selector: (state) => state.userProfile.name, + builder: (context, userName) { + return MobileSettingGroup( + groupTitle: LocaleKeys.settings_mobile_personalInfo.tr(), + settingItemList: [ + MobileSettingItem( + name: userName, + subtitle: isAuthEnabled && userProfile.email.isNotEmpty + ? Text( + userProfile.email, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ) + : null, + trailing: const Icon(Icons.chevron_right), + onTap: () { + showMobileBottomSheet( + context, + showHeader: true, + title: LocaleKeys.settings_mobile_username.tr(), + showCloseButton: true, + showDragHandle: true, + showDivider: false, + padding: const EdgeInsets.symmetric(horizontal: 16), + builder: (_) { + return EditUsernameBottomSheet( + context, + userName: userName, + onSubmitted: (value) => context + .read() + .add(SettingsUserEvent.updateUserName(value)), + ); + }, + ); + }, + ), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host/self_host_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host/self_host_bottom_sheet.dart new file mode 100644 index 0000000000000..ebc58290b9af1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host/self_host_bottom_sheet.dart @@ -0,0 +1,85 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/appflowy_cloud_urls_bloc.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class SelfHostUrlBottomSheet extends StatefulWidget { + const SelfHostUrlBottomSheet({ + super.key, + required this.url, + }); + + final String url; + + @override + State createState() => _SelfHostUrlBottomSheetState(); +} + +class _SelfHostUrlBottomSheetState extends State { + final TextEditingController _textFieldController = TextEditingController(); + + final _formKey = GlobalKey(); + @override + void initState() { + super.initState(); + + _textFieldController.text = widget.url; + } + + @override + void dispose() { + _textFieldController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Form( + key: _formKey, + child: TextFormField( + controller: _textFieldController, + keyboardType: TextInputType.text, + validator: (value) { + if (value == null || value.isEmpty) { + return LocaleKeys.settings_mobile_usernameEmptyError.tr(); + } + return null; + }, + onEditingComplete: _saveSelfHostUrl, + ), + ), + const SizedBox( + height: 16, + ), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _saveSelfHostUrl, + child: Text(LocaleKeys.settings_menu_restartApp.tr()), + ), + ), + ], + ); + } + + void _saveSelfHostUrl() { + if (_formKey.currentState!.validate()) { + final value = _textFieldController.text; + if (value.isNotEmpty) { + validateUrl(value).fold( + (url) async { + await useSelfHostedAppFlowyCloudWithURL(url); + await runAppFlowy(); + }, + (err) => Log.error(err), + ); + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host_setting_group.dart new file mode 100644 index 0000000000000..095214d6ef205 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/self_host_setting_group.dart @@ -0,0 +1,60 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/setting/self_host/self_host_bottom_sheet.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'setting.dart'; + +class SelfHostSettingGroup extends StatefulWidget { + const SelfHostSettingGroup({ + super.key, + }); + + @override + State createState() => _SelfHostSettingGroupState(); +} + +class _SelfHostSettingGroupState extends State { + final future = getAppFlowyCloudUrl(); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: future, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox.shrink(); + } + final url = snapshot.data ?? ''; + return MobileSettingGroup( + groupTitle: LocaleKeys.settings_menu_cloudAppFlowySelfHost.tr(), + settingItemList: [ + MobileSettingItem( + name: url, + onTap: () { + showMobileBottomSheet( + context, + showHeader: true, + title: LocaleKeys.editor_urlHint.tr(), + showCloseButton: true, + showDivider: false, + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + builder: (_) { + return SelfHostUrlBottomSheet( + url: url, + ); + }, + ); + }, + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/setting.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/setting.dart new file mode 100644 index 0000000000000..992e9be903dd6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/setting.dart @@ -0,0 +1,8 @@ +export 'about/about.dart'; +export 'appearance/appearance_setting_group.dart'; +export 'font/font_setting.dart'; +export 'language_setting_group.dart'; +export 'notifications_setting_group.dart'; +export 'personal_info/personal_info.dart'; +export 'support_setting_group.dart'; +export 'widgets/widgets.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart new file mode 100644 index 0000000000000..584b8677363a3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/support_setting_group.dart @@ -0,0 +1,128 @@ +import 'dart:io'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/shared/appflowy_cache_manager.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/share_log_files.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/fix_data_widget.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'widgets/widgets.dart'; + +class SupportSettingGroup extends StatelessWidget { + const SupportSettingGroup({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) => MobileSettingGroup( + groupTitle: LocaleKeys.settings_mobile_support.tr(), + settingItemList: [ + MobileSettingItem( + name: LocaleKeys.settings_mobile_joinDiscord.tr(), + trailing: const Icon( + Icons.chevron_right, + ), + onTap: () => afLaunchUrlString('https://discord.gg/JucBXeU2FE'), + ), + MobileSettingItem( + name: LocaleKeys.workspace_errorActions_reportIssue.tr(), + trailing: const Icon( + Icons.chevron_right, + ), + onTap: () { + showMobileBottomSheet( + context, + showDragHandle: true, + showHeader: true, + title: LocaleKeys.workspace_errorActions_reportIssue.tr(), + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (context) { + return _ReportIssuesWidget( + version: snapshot.data?.version ?? '', + ); + }, + ); + }, + ), + MobileSettingItem( + name: LocaleKeys.settings_files_clearCache.tr(), + trailing: const Icon( + Icons.chevron_right, + ), + onTap: () async { + await showFlowyMobileConfirmDialog( + context, + title: FlowyText( + LocaleKeys.settings_files_areYouSureToClearCache.tr(), + maxLines: 2, + ), + content: FlowyText( + LocaleKeys.settings_files_clearCacheDesc.tr(), + fontSize: 12, + maxLines: 4, + ), + actionButtonTitle: LocaleKeys.button_yes.tr(), + onActionButtonPressed: () async { + await getIt().clearAllCache(); + // check the workspace and space health + await WorkspaceDataManager.checkViewHealth( + dryRun: false, + ); + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys.settings_files_clearCacheSuccess.tr(), + ); + } + }, + ); + }, + ), + ], + ), + ); + } +} + +class _ReportIssuesWidget extends StatelessWidget { + const _ReportIssuesWidget({ + required this.version, + }); + + final String version; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.workspace_errorActions_reportIssueOnGithub.tr(), + onTap: () { + final String os = Platform.operatingSystem; + afLaunchUrlString( + 'https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&projects=&template=bug_report.yaml&title=[Bug]%20Mobile:%20&version=$version&os=$os', + ); + }, + ), + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.workspace_errorActions_exportLogFiles.tr(), + onTap: () => shareLogFiles(context), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart new file mode 100644 index 0000000000000..b3b7cb71c5f14 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart @@ -0,0 +1,220 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account_deletion.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class UserSessionSettingGroup extends StatelessWidget { + const UserSessionSettingGroup({ + super.key, + required this.userProfile, + required this.showThirdPartyLogin, + }); + + final UserProfilePB userProfile; + final bool showThirdPartyLogin; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // third party sign in buttons + if (showThirdPartyLogin) _buildThirdPartySignInButtons(context), + const VSpace(8.0), + + // logout button + MobileLogoutButton( + text: LocaleKeys.settings_menu_logout.tr(), + onPressed: () async => _showLogoutDialog(), + ), + + // delete account button + // only show the delete account button in cloud mode + if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[ + const VSpace(16.0), + MobileLogoutButton( + text: LocaleKeys.button_deleteAccount.tr(), + textColor: Theme.of(context).colorScheme.error, + onPressed: () => _showDeleteAccountDialog(context), + ), + ], + ], + ); + } + + Widget _buildThirdPartySignInButtons(BuildContext context) { + return BlocProvider( + create: (context) => getIt(), + child: BlocConsumer( + listener: (context, state) { + state.successOrFail?.fold( + (result) => runAppFlowy(), + (e) => Log.error(e), + ); + }, + builder: (context, state) { + return const ThirdPartySignInButtons( + expanded: true, + ); + }, + ), + ); + } + + Future _showDeleteAccountDialog(BuildContext context) async { + return showMobileBottomSheet( + context, + useRootNavigator: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (_) => const _DeleteAccountBottomSheet(), + ); + } + + Future _showLogoutDialog() async { + return showFlowyCupertinoConfirmDialog( + title: LocaleKeys.settings_menu_logoutPrompt.tr(), + leftButton: FlowyText( + LocaleKeys.button_cancel.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + color: const Color(0xFF007AFF), + ), + rightButton: FlowyText( + LocaleKeys.button_logout.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w400, + color: const Color(0xFFFE0220), + ), + onRightButtonPressed: (context) async { + Navigator.of(context).pop(); + await getIt().signOut(); + await runAppFlowy(); + }, + ); + } +} + +class _DeleteAccountBottomSheet extends StatefulWidget { + const _DeleteAccountBottomSheet(); + + @override + State<_DeleteAccountBottomSheet> createState() => + _DeleteAccountBottomSheetState(); +} + +class _DeleteAccountBottomSheetState extends State<_DeleteAccountBottomSheet> { + final controller = TextEditingController(); + final isChecked = ValueNotifier(false); + + @override + void dispose() { + controller.dispose(); + isChecked.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const VSpace(18.0), + const FlowySvg( + FlowySvgs.icon_warning_xl, + blendMode: null, + ), + const VSpace(12.0), + FlowyText( + LocaleKeys.newSettings_myAccount_deleteAccount_title.tr(), + fontSize: 20.0, + fontWeight: FontWeight.w500, + ), + const VSpace(12.0), + FlowyText( + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint1.tr(), + fontSize: 14.0, + fontWeight: FontWeight.w400, + maxLines: 10, + ), + const VSpace(18.0), + SizedBox( + height: 36.0, + child: FlowyTextField( + controller: controller, + textStyle: const TextStyle(fontSize: 14.0), + hintStyle: const TextStyle(fontSize: 14.0), + hintText: LocaleKeys + .newSettings_myAccount_deleteAccount_confirmHint3 + .tr(), + ), + ), + const VSpace(18.0), + _buildCheckbox(), + const VSpace(18.0), + MobileLogoutButton( + text: LocaleKeys.button_deleteAccount.tr(), + textColor: Theme.of(context).colorScheme.error, + onPressed: () => deleteMyAccount( + context, + controller.text.trim(), + isChecked.value, + ), + ), + const VSpace(12.0), + MobileLogoutButton( + text: LocaleKeys.button_cancel.tr(), + onPressed: () => Navigator.of(context).pop(), + ), + const VSpace(36.0), + ], + ), + ); + } + + Widget _buildCheckbox() { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => isChecked.value = !isChecked.value, + child: ValueListenableBuilder( + valueListenable: isChecked, + builder: (context, isChecked, _) { + return Padding( + padding: const EdgeInsets.all(1.0), + child: FlowySvg( + isChecked ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + size: const Size.square(16.0), + blendMode: isChecked ? null : BlendMode.srcIn, + ), + ); + }, + ), + ), + const HSpace(6.0), + Expanded( + child: FlowyText.regular( + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint2.tr(), + fontSize: 14.0, + figmaLineHeight: 18.0, + maxLines: 3, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart new file mode 100644 index 0000000000000..17e9a6286774e --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_group_widget.dart @@ -0,0 +1,31 @@ +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileSettingGroup extends StatelessWidget { + const MobileSettingGroup({ + required this.groupTitle, + required this.settingItemList, + this.showDivider = true, + super.key, + }); + + final String groupTitle; + final List settingItemList; + final bool showDivider; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(4.0), + FlowyText.semibold( + groupTitle, + ), + const VSpace(4.0), + ...settingItemList, + showDivider ? const Divider() : const SizedBox.shrink(), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart new file mode 100644 index 0000000000000..6dc45c1c40ead --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/mobile_setting_item_widget.dart @@ -0,0 +1,50 @@ +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileSettingItem extends StatelessWidget { + const MobileSettingItem({ + super.key, + required this.name, + this.padding = const EdgeInsets.only(bottom: 4), + this.trailing, + this.leadingIcon, + this.subtitle, + this.onTap, + }); + + final String name; + final EdgeInsets padding; + final Widget? trailing; + final Widget? leadingIcon; + final Widget? subtitle; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: ListTile( + title: Row( + children: [ + if (leadingIcon != null) ...[ + leadingIcon!, + const HSpace(8), + ], + Expanded( + child: FlowyText.medium( + name, + fontSize: 14.0, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + subtitle: subtitle, + trailing: trailing, + onTap: onTap, + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.only(left: 8.0), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/widgets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/widgets.dart new file mode 100644 index 0000000000000..fb090cd3b4083 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/widgets/widgets.dart @@ -0,0 +1,2 @@ +export 'mobile_setting_group_widget.dart'; +export 'mobile_setting_item_widget.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart new file mode 100644 index 0000000000000..2e805c5c5a188 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/invite_members_screen.dart @@ -0,0 +1,359 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:string_validator/string_validator.dart'; + +import 'member_list.dart'; + +ValueNotifier mobileLeaveWorkspaceNotifier = ValueNotifier(0); + +class InviteMembersScreen extends StatelessWidget { + const InviteMembersScreen({ + super.key, + }); + + static const routeName = '/invite_member'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FlowyAppBar( + titleText: LocaleKeys.settings_appearance_members_label.tr(), + ), + body: const _InviteMemberPage(), + resizeToAvoidBottomInset: false, + ); + } +} + +class _InviteMemberPage extends StatefulWidget { + const _InviteMemberPage(); + + @override + State<_InviteMemberPage> createState() => _InviteMemberPageState(); +} + +class _InviteMemberPageState extends State<_InviteMemberPage> { + final emailController = TextEditingController(); + late final Future userProfile; + bool exceededLimit = false; + + @override + void initState() { + super.initState(); + userProfile = UserBackendService.getCurrentUserProfile().fold( + (s) => s, + (f) => null, + ); + } + + @override + void dispose() { + emailController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: userProfile, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox.shrink(); + } + if (snapshot.hasError || snapshot.data == null) { + return _buildError(context); + } + + final userProfile = snapshot.data!; + + return BlocProvider( + create: (context) => WorkspaceMemberBloc(userProfile: userProfile) + ..add(const WorkspaceMemberEvent.initial()), + child: BlocConsumer( + listener: _onListener, + builder: (context, state) { + return Column( + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (state.myRole.isOwner) ...[ + Padding( + padding: const EdgeInsets.all(16.0), + child: _buildInviteMemberArea(context), + ), + const VSpace(16), + ], + if (state.members.isNotEmpty) ...[ + const VSpace(8), + MobileMemberList( + members: state.members, + userProfile: userProfile, + myRole: state.myRole, + ), + ], + ], + ), + ), + if (state.myRole.isMember) const _LeaveWorkspaceButton(), + const VSpace(48), + ], + ); + }, + ), + ); + }, + ); + } + + Widget _buildInviteMemberArea(BuildContext context) { + return Column( + children: [ + TextFormField( + autofocus: true, + controller: emailController, + keyboardType: TextInputType.text, + decoration: InputDecoration( + hintText: LocaleKeys.settings_appearance_members_inviteHint.tr(), + ), + ), + const VSpace(16), + if (exceededLimit) ...[ + FlowyText.regular( + LocaleKeys.settings_appearance_members_inviteFailedMemberLimitMobile + .tr(), + fontSize: 14.0, + maxLines: 3, + color: Theme.of(context).colorScheme.error, + ), + const VSpace(16), + ], + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => _inviteMember(context), + child: Text( + LocaleKeys.settings_appearance_members_sendInvite.tr(), + ), + ), + ), + ], + ); + } + + Widget _buildError(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 48.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.medium( + LocaleKeys.settings_appearance_members_workspaceMembersError.tr(), + fontSize: 18.0, + textAlign: TextAlign.center, + ), + const VSpace(8.0), + FlowyText.regular( + LocaleKeys + .settings_appearance_members_workspaceMembersErrorDescription + .tr(), + fontSize: 17.0, + maxLines: 10, + textAlign: TextAlign.center, + lineHeight: 1.3, + color: Theme.of(context).hintColor, + ), + ], + ), + ), + ); + } + + void _onListener(BuildContext context, WorkspaceMemberState state) { + final actionResult = state.actionResult; + if (actionResult == null) { + return; + } + + final actionType = actionResult.actionType; + final result = actionResult.result; + + // get keyboard height + final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + + // only show the result dialog when the action is WorkspaceMemberActionType.add + if (actionType == WorkspaceMemberActionType.add) { + result.fold( + (s) { + showToastNotification( + context, + message: + LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), + bottomPadding: keyboardHeight, + ); + }, + (f) { + Log.error('add workspace member failed: $f'); + final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded + ? LocaleKeys + .settings_appearance_members_inviteFailedMemberLimitMobile + .tr() + : LocaleKeys.settings_appearance_members_failedToAddMember.tr(); + setState(() { + exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; + }); + showToastNotification( + context, + type: ToastificationType.error, + bottomPadding: keyboardHeight, + message: message, + ); + }, + ); + } else if (actionType == WorkspaceMemberActionType.invite) { + result.fold( + (s) { + showToastNotification( + context, + message: + LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), + bottomPadding: keyboardHeight, + ); + }, + (f) { + Log.error('invite workspace member failed: $f'); + final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded + ? LocaleKeys + .settings_appearance_members_inviteFailedMemberLimitMobile + .tr() + : LocaleKeys.settings_appearance_members_failedToInviteMember + .tr(); + setState(() { + exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded; + }); + showToastNotification( + context, + type: ToastificationType.error, + message: message, + bottomPadding: keyboardHeight, + ); + }, + ); + } else if (actionType == WorkspaceMemberActionType.remove) { + result.fold( + (s) { + showToastNotification( + context, + message: LocaleKeys + .settings_appearance_members_removeFromWorkspaceSuccess + .tr(), + bottomPadding: keyboardHeight, + ); + }, + (f) { + showToastNotification( + context, + type: ToastificationType.error, + message: LocaleKeys + .settings_appearance_members_removeFromWorkspaceFailed + .tr(), + bottomPadding: keyboardHeight, + ); + }, + ); + } + } + + void _inviteMember(BuildContext context) { + final email = emailController.text; + if (!isEmail(email)) { + return showToastNotification( + context, + type: ToastificationType.error, + message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(), + ); + } + context + .read() + .add(WorkspaceMemberEvent.inviteWorkspaceMember(email)); + // clear the email field after inviting + emailController.clear(); + } +} + +class _LeaveWorkspaceButton extends StatelessWidget { + const _LeaveWorkspaceButton(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 16), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide( + color: Theme.of(context).colorScheme.error, + width: 0.5, + ), + ), + ), + onPressed: () => _leaveWorkspace(context), + child: FlowyText( + LocaleKeys.workspace_leaveCurrentWorkspace.tr(), + fontSize: 14.0, + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + void _leaveWorkspace(BuildContext context) { + showFlowyCupertinoConfirmDialog( + title: LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(), + leftButton: FlowyText( + LocaleKeys.button_cancel.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + color: const Color(0xFF007AFF), + ), + rightButton: FlowyText( + LocaleKeys.button_confirm.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w400, + color: const Color(0xFFFE0220), + ), + onRightButtonPressed: (buttonContext) async { + // try to use popUntil with a specific route name but failed + // so use pop twice as a workaround + Navigator.of(buttonContext).pop(); + Navigator.of(context).pop(); + Navigator.of(context).pop(); + + mobileLeaveWorkspaceNotifier.value = + mobileLeaveWorkspaceNotifier.value + 1; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart new file mode 100644 index 0000000000000..501fd18ef7235 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/member_list.dart @@ -0,0 +1,191 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class MobileMemberList extends StatelessWidget { + const MobileMemberList({ + super.key, + required this.members, + required this.myRole, + required this.userProfile, + }); + + final List members; + final AFRolePB myRole; + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return SlidableAutoCloseBehavior( + child: SeparatedColumn( + crossAxisAlignment: CrossAxisAlignment.start, + separatorBuilder: () => const FlowyDivider( + padding: EdgeInsets.symmetric(horizontal: 16.0), + ), + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: FlowyText.semibold( + LocaleKeys.settings_appearance_members_label.tr(), + fontSize: 16.0, + ), + ), + ...members.map( + (member) => _MemberItem( + member: member, + myRole: myRole, + userProfile: userProfile, + ), + ), + ], + ), + ); + } +} + +class _MemberItem extends StatelessWidget { + const _MemberItem({ + required this.member, + required this.myRole, + required this.userProfile, + }); + + final WorkspaceMemberPB member; + final AFRolePB myRole; + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final canDelete = myRole.canDelete && member.email != userProfile.email; + final textColor = member.role.isOwner ? Theme.of(context).hintColor : null; + + Widget child; + + if (UniversalPlatform.isDesktop) { + child = Row( + children: [ + Expanded( + child: FlowyText.medium( + member.name, + color: textColor, + fontSize: 15.0, + ), + ), + Expanded( + child: FlowyText.medium( + member.role.description, + color: textColor, + fontSize: 15.0, + textAlign: TextAlign.end, + ), + ), + ], + ); + } else { + child = Row( + children: [ + Expanded( + child: FlowyText.medium( + member.name, + color: textColor, + fontSize: 15.0, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(36.0), + FlowyText.medium( + member.role.description, + color: textColor, + fontSize: 15.0, + textAlign: TextAlign.end, + ), + ], + ); + } + + child = Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: child, + ); + + if (canDelete) { + child = Slidable( + key: ValueKey(member.email), + endActionPane: ActionPane( + extentRatio: 1 / 6.0, + motion: const ScrollMotion(), + children: [ + CustomSlidableAction( + backgroundColor: const Color(0xE5515563), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(10), + bottomLeft: Radius.circular(10), + ), + onPressed: (context) { + HapticFeedback.mediumImpact(); + _showDeleteMenu(context); + }, + padding: EdgeInsets.zero, + child: const FlowySvg( + FlowySvgs.three_dots_s, + size: Size.square(24), + color: Colors.white, + ), + ), + ], + ), + child: child, + ); + } + + return child; + } + + void _showDeleteMenu(BuildContext context) { + final workspaceMemberBloc = context.read(); + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + useRootNavigator: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (context) { + return FlowyOptionTile.text( + text: LocaleKeys.settings_appearance_members_removeFromWorkspace.tr(), + height: 52.0, + textColor: Theme.of(context).colorScheme.error, + leftIcon: FlowySvg( + FlowySvgs.trash_s, + size: const Size.square(18), + color: Theme.of(context).colorScheme.error, + ), + showTopBorder: false, + showBottomBorder: false, + onTap: () { + workspaceMemberBloc.add( + WorkspaceMemberEvent.removeWorkspaceMember( + member.email, + ), + ); + Navigator.of(context).pop(); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/workspace_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/workspace_setting_group.dart new file mode 100644 index 0000000000000..9c2161a4d16e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/workspace/workspace_setting_group.dart @@ -0,0 +1,29 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../widgets/widgets.dart'; +import 'invite_members_screen.dart'; + +class WorkspaceSettingGroup extends StatelessWidget { + const WorkspaceSettingGroup({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return MobileSettingGroup( + groupTitle: LocaleKeys.settings_appearance_members_label.tr(), + settingItemList: [ + MobileSettingItem( + name: LocaleKeys.settings_appearance_members_label.tr(), + trailing: const Icon(Icons.chevron_right), + onTap: () { + context.push(InviteMembersScreen.routeName); + }, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_option_decorate_box.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_option_decorate_box.dart new file mode 100644 index 0000000000000..55ba32d06e9f1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_option_decorate_box.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class FlowyOptionDecorateBox extends StatelessWidget { + const FlowyOptionDecorateBox({ + super.key, + this.showTopBorder = true, + this.showBottomBorder = true, + this.color, + required this.child, + }); + + final bool showTopBorder; + final bool showBottomBorder; + final Widget child; + final Color? color; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: color ?? Theme.of(context).colorScheme.surface, + border: Border( + top: showTopBorder + ? BorderSide( + color: Theme.of(context).dividerColor, + width: 0.5, + ) + : BorderSide.none, + bottom: showBottomBorder + ? BorderSide( + color: Theme.of(context).dividerColor, + width: 0.5, + ) + : BorderSide.none, + ), + ), + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart new file mode 100644 index 0000000000000..1c1121d7f5f07 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart @@ -0,0 +1,60 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileQuickActionButton extends StatelessWidget { + const MobileQuickActionButton({ + super.key, + required this.onTap, + required this.icon, + required this.text, + this.textColor, + this.iconColor, + this.iconSize, + this.enable = true, + }); + + final VoidCallback onTap; + final FlowySvgData icon; + final String text; + final Color? textColor; + final Color? iconColor; + final Size? iconSize; + final bool enable; + + @override + Widget build(BuildContext context) { + final iconSize = this.iconSize ?? const Size.square(18); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: InkWell( + onTap: enable ? onTap : null, + borderRadius: BorderRadius.circular(12), + overlayColor: + enable ? null : const WidgetStatePropertyAll(Colors.transparent), + splashColor: Colors.transparent, + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + FlowySvg( + icon, + size: iconSize, + color: enable ? iconColor : Theme.of(context).disabledColor, + ), + HSpace(30 - iconSize.width), + Expanded( + child: FlowyText.regular( + text, + fontSize: 16, + color: enable ? textColor : Theme.of(context).disabledColor, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_search_text_field.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_search_text_field.dart new file mode 100644 index 0000000000000..9ec953d6b6deb --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_search_text_field.dart @@ -0,0 +1,45 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class FlowyMobileSearchTextField extends StatelessWidget { + const FlowyMobileSearchTextField({ + super.key, + this.hintText, + this.controller, + this.onChanged, + this.onSubmitted, + }); + + final String? hintText; + final TextEditingController? controller; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44.0, + child: CupertinoSearchTextField( + controller: controller, + onChanged: onChanged, + onSubmitted: onSubmitted, + placeholder: hintText, + prefixIcon: const FlowySvg(FlowySvgs.m_search_m), + prefixInsets: const EdgeInsets.only(left: 16.0, right: 2.0), + suffixIcon: const Icon(Icons.close), + suffixInsets: const EdgeInsets.only(right: 16.0), + placeholderStyle: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).hintColor, + fontWeight: FontWeight.w400, + fontSize: 14.0, + ), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).textTheme.bodyMedium?.color, + fontWeight: FontWeight.w400, + fontSize: 14.0, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_state_container.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_state_container.dart new file mode 100644 index 0000000000000..8aea36fbb9fc5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_mobile_state_container.dart @@ -0,0 +1,109 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +enum _FlowyMobileStateContainerType { + info, + error, +} + +/// Used to display info(like empty state) or error state +/// error state has two buttons to report issue with error message or reach out on discord +class FlowyMobileStateContainer extends StatelessWidget { + const FlowyMobileStateContainer.error({ + this.emoji, + required this.title, + this.description, + required this.errorMsg, + super.key, + }) : _stateType = _FlowyMobileStateContainerType.error; + + const FlowyMobileStateContainer.info({ + this.emoji, + required this.title, + this.description, + super.key, + }) : errorMsg = null, + _stateType = _FlowyMobileStateContainerType.info; + + final String? emoji; + final String title; + final String? description; + final String? errorMsg; + final _FlowyMobileStateContainerType _stateType; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SizedBox.expand( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + emoji ?? + (_stateType == _FlowyMobileStateContainerType.error + ? '🛸' + : ''), + style: const TextStyle(fontSize: 40), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + title, + style: theme.textTheme.labelLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + description ?? '', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.hintColor, + ), + textAlign: TextAlign.center, + ), + if (_stateType == _FlowyMobileStateContainerType.error) ...[ + const SizedBox(height: 8), + FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + OutlinedButton( + onPressed: () { + final String? version = snapshot.data?.version; + final String os = Platform.operatingSystem; + afLaunchUrlString( + 'https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&projects=&template=bug_report.yaml&title=[Bug]%20Mobile:%20&version=$version&os=$os&context=Error%20log:%20$errorMsg', + ); + }, + child: Text( + LocaleKeys.workspace_errorActions_reportIssue.tr(), + ), + ), + OutlinedButton( + onPressed: () => + afLaunchUrlString('https://discord.gg/JucBXeU2FE'), + child: Text( + LocaleKeys.workspace_errorActions_reachOut.tr(), + ), + ), + ], + ); + }, + ), + ], + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart new file mode 100644 index 0000000000000..4f76003e23b98 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/flowy_option_tile.dart @@ -0,0 +1,308 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +enum FlowyOptionTileType { + text, + textField, + checkbox, + toggle, +} + +class FlowyOptionTile extends StatelessWidget { + const FlowyOptionTile._({ + super.key, + required this.type, + this.showTopBorder = true, + this.showBottomBorder = true, + this.text, + this.textColor, + this.controller, + this.leading, + this.onTap, + this.trailing, + this.textFieldPadding = const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 2.0, + ), + this.isSelected = false, + this.onValueChanged, + this.textFieldHintText, + this.onTextChanged, + this.onTextSubmitted, + this.autofocus, + this.content, + this.backgroundColor, + this.fontFamily, + this.height, + }); + + factory FlowyOptionTile.text({ + String? text, + Widget? content, + Color? textColor, + bool showTopBorder = true, + bool showBottomBorder = true, + Widget? leftIcon, + Widget? trailing, + VoidCallback? onTap, + double? height, + }) { + return FlowyOptionTile._( + type: FlowyOptionTileType.text, + text: text, + content: content, + textColor: textColor, + onTap: onTap, + showTopBorder: showTopBorder, + showBottomBorder: showBottomBorder, + leading: leftIcon, + trailing: trailing, + height: height, + ); + } + + factory FlowyOptionTile.textField({ + required TextEditingController controller, + void Function(String value)? onTextChanged, + void Function(String value)? onTextSubmitted, + EdgeInsets textFieldPadding = const EdgeInsets.symmetric( + vertical: 16.0, + ), + bool showTopBorder = true, + bool showBottomBorder = true, + Widget? leftIcon, + Widget? trailing, + String? textFieldHintText, + bool autofocus = false, + }) { + return FlowyOptionTile._( + type: FlowyOptionTileType.textField, + controller: controller, + textFieldPadding: textFieldPadding, + showTopBorder: showTopBorder, + showBottomBorder: showBottomBorder, + leading: leftIcon, + trailing: trailing, + textFieldHintText: textFieldHintText, + onTextChanged: onTextChanged, + onTextSubmitted: onTextSubmitted, + autofocus: autofocus, + ); + } + + factory FlowyOptionTile.checkbox({ + Key? key, + required String text, + required bool isSelected, + required VoidCallback? onTap, + Color? textColor, + Widget? leftIcon, + Widget? content, + bool showTopBorder = true, + bool showBottomBorder = true, + String? fontFamily, + Color? backgroundColor, + }) { + return FlowyOptionTile._( + key: key, + type: FlowyOptionTileType.checkbox, + isSelected: isSelected, + text: text, + textColor: textColor, + content: content, + onTap: onTap, + fontFamily: fontFamily, + backgroundColor: backgroundColor, + showTopBorder: showTopBorder, + showBottomBorder: showBottomBorder, + leading: leftIcon, + trailing: isSelected + ? const FlowySvg( + FlowySvgs.m_blue_check_s, + blendMode: null, + ) + : null, + ); + } + + factory FlowyOptionTile.toggle({ + required String text, + required bool isSelected, + required void Function(bool value) onValueChanged, + void Function()? onTap, + bool showTopBorder = true, + bool showBottomBorder = true, + Widget? leftIcon, + }) { + return FlowyOptionTile._( + type: FlowyOptionTileType.toggle, + text: text, + onTap: onTap ?? () => onValueChanged(!isSelected), + onValueChanged: onValueChanged, + showTopBorder: showTopBorder, + showBottomBorder: showBottomBorder, + leading: leftIcon, + trailing: _Toggle(value: isSelected, onChanged: onValueChanged), + ); + } + + final bool showTopBorder; + final bool showBottomBorder; + final String? text; + final Color? textColor; + final TextEditingController? controller; + final EdgeInsets textFieldPadding; + final void Function()? onTap; + final Widget? leading; + final Widget? trailing; + + // customize the content widget + final Widget? content; + + // only used in checkbox or switcher + final bool isSelected; + + // only used in switcher + final void Function(bool value)? onValueChanged; + + // only used in textfield + final String? textFieldHintText; + final void Function(String value)? onTextChanged; + final void Function(String value)? onTextSubmitted; + final bool? autofocus; + + final FlowyOptionTileType type; + + final Color? backgroundColor; + final String? fontFamily; + + final double? height; + + @override + Widget build(BuildContext context) { + final leadingWidget = _buildLeading(); + + final child = FlowyOptionDecorateBox( + color: backgroundColor, + showTopBorder: showTopBorder, + showBottomBorder: showBottomBorder, + child: SizedBox( + height: height, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + if (leadingWidget != null) leadingWidget, + if (content != null) content!, + if (content == null) _buildText(), + if (content == null) _buildTextField(), + if (trailing != null) trailing!, + ], + ), + ), + ), + ); + + if (type == FlowyOptionTileType.checkbox || + type == FlowyOptionTileType.toggle || + type == FlowyOptionTileType.text) { + return GestureDetector( + onTap: onTap, + child: child, + ); + } + + return child; + } + + Widget? _buildLeading() { + if (leading != null) { + return Center(child: leading); + } else { + return null; + } + } + + Widget _buildText() { + if (text == null || type == FlowyOptionTileType.textField) { + return const SizedBox.shrink(); + } + + final padding = EdgeInsets.symmetric( + horizontal: leading == null ? 0.0 : 12.0, + vertical: 14.0, + ); + + return Expanded( + child: Padding( + padding: padding, + child: FlowyText( + text!, + fontSize: 16, + color: textColor, + fontFamily: fontFamily, + ), + ), + ); + } + + Widget _buildTextField() { + if (controller == null) { + return const SizedBox.shrink(); + } + + return Expanded( + child: Container( + constraints: const BoxConstraints.tightFor( + height: 54.0, + ), + alignment: Alignment.center, + child: TextField( + controller: controller, + autofocus: autofocus ?? false, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: textFieldPadding, + hintText: textFieldHintText, + ), + onChanged: onTextChanged, + onSubmitted: onTextSubmitted, + ), + ), + ); + } +} + +class _Toggle extends StatelessWidget { + const _Toggle({ + required this.value, + required this.onChanged, + }); + + final bool value; + final void Function(bool value) onChanged; + + @override + Widget build(BuildContext context) { + // CupertinoSwitch adds a 8px margin all around. The original size of the + // switch is 38 x 22. + return SizedBox( + width: 46, + height: 30, + child: FittedBox( + fit: BoxFit.fill, + child: CupertinoSwitch( + value: value, + activeColor: Theme.of(context).colorScheme.primary, + onChanged: onChanged, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/navigation_bar_button.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/navigation_bar_button.dart new file mode 100644 index 0000000000000..2058e03e168bb --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/navigation_bar_button.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class NavigationBarButton extends StatelessWidget { + const NavigationBarButton({ + super.key, + required this.text, + required this.icon, + required this.onTap, + this.enable = true, + }); + + final String text; + final FlowySvgData icon; + final VoidCallback onTap; + final bool enable; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: enable ? 1.0 : 0.3, + child: Container( + height: 40, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide(color: Color(0x3F1F2329)), + borderRadius: BorderRadius.circular(10), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + expandText: false, + iconPadding: 8, + leftIcon: FlowySvg(icon), + onTap: enable ? onTap : null, + text: FlowyText( + text, + fontSize: 15.0, + figmaLineHeight: 18.0, + fontWeight: FontWeight.w400, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart new file mode 100644 index 0000000000000..9df9d2e6fde91 --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart @@ -0,0 +1,135 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/tasks/app_widget.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +enum ConfirmDialogActionAlignment { + // The action buttons are aligned vertically + // --------------------- + // | Action Button | + // | Cancel Button | + vertical, + // The action buttons are aligned horizontally + // --------------------- + // | Action Button | Cancel Button | + horizontal, +} + +/// show the dialog to confirm one single action +/// [onActionButtonPressed] and [onCancelButtonPressed] end with close the dialog +Future showFlowyMobileConfirmDialog( + BuildContext context, { + Widget? title, + Widget? content, + ConfirmDialogActionAlignment actionAlignment = + ConfirmDialogActionAlignment.horizontal, + required String actionButtonTitle, + required VoidCallback? onActionButtonPressed, + Color? actionButtonColor, + String? cancelButtonTitle, + Color? cancelButtonColor, + VoidCallback? onCancelButtonPressed, +}) async { + return showDialog( + context: context, + builder: (dialogContext) { + final foregroundColor = Theme.of(context).colorScheme.onSurface; + final actionButton = TextButton( + child: FlowyText( + actionButtonTitle, + color: actionButtonColor ?? foregroundColor, + ), + onPressed: () { + onActionButtonPressed?.call(); + // we cannot use dialogContext.pop() here because this is no GoRouter in dialogContext. Use Navigator instead to close the dialog. + Navigator.of(dialogContext).pop(); + }, + ); + final cancelButton = TextButton( + child: FlowyText( + cancelButtonTitle ?? LocaleKeys.button_cancel.tr(), + color: cancelButtonColor ?? foregroundColor, + ), + onPressed: () { + onCancelButtonPressed?.call(); + Navigator.of(dialogContext).pop(); + }, + ); + + final actions = switch (actionAlignment) { + ConfirmDialogActionAlignment.horizontal => [ + actionButton, + cancelButton, + ], + ConfirmDialogActionAlignment.vertical => [ + Column( + children: [ + actionButton, + const Divider(height: 1, color: Colors.grey), + cancelButton, + ], + ), + ], + }; + + return AlertDialog.adaptive( + title: title, + content: content, + contentPadding: const EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 4.0, + ), + actionsAlignment: MainAxisAlignment.center, + actions: actions, + ); + }, + ); +} + +Future showFlowyCupertinoConfirmDialog({ + BuildContext? context, + required String title, + Widget? content, + required Widget leftButton, + required Widget rightButton, + void Function(BuildContext context)? onLeftButtonPressed, + void Function(BuildContext context)? onRightButtonPressed, +}) { + return showDialog( + context: context ?? AppGlobals.context, + barrierColor: Colors.black.withOpacity(0.25), + builder: (context) => CupertinoAlertDialog( + title: FlowyText.medium( + title, + fontSize: 16, + maxLines: 10, + figmaLineHeight: 22.0, + ), + content: content, + actions: [ + CupertinoDialogAction( + onPressed: () { + if (onLeftButtonPressed != null) { + onLeftButtonPressed(context); + } else { + Navigator.of(context).pop(); + } + }, + child: leftButton, + ), + CupertinoDialogAction( + onPressed: () { + if (onRightButtonPressed != null) { + onRightButtonPressed(context); + } else { + Navigator.of(context).pop(); + } + }, + child: rightButton, + ), + ], + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/widgets/widgets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/widgets.dart new file mode 100644 index 0000000000000..4b1c1eedefd8a --- /dev/null +++ b/frontend/appflowy_flutter/lib/mobile/presentation/widgets/widgets.dart @@ -0,0 +1,4 @@ +export 'flowy_mobile_option_decorate_box.dart'; +export 'flowy_mobile_state_container.dart'; +export 'flowy_option_tile.dart'; +export 'show_flowy_mobile_confirm_dialog.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_prompt_input_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_prompt_input_bloc.dart new file mode 100644 index 0000000000000..c52a46419984e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/ai_prompt_input_bloc.dart @@ -0,0 +1,184 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'ai_prompt_input_bloc.freezed.dart'; + +class AIPromptInputBloc extends Bloc { + AIPromptInputBloc() + : _listener = LocalLLMListener(), + super(AIPromptInputState.initial()) { + _dispatch(); + _startListening(); + _init(); + } + + final LocalLLMListener _listener; + + @override + Future close() async { + await _listener.stop(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) { + event.when( + updateChatState: (LocalAIChatPB chatState) { + // Only user enable chat with file and the plugin is already running + final supportChatWithFile = chatState.fileEnabled && + chatState.pluginState.state == RunningStatePB.Running; + emit( + state.copyWith( + supportChatWithFile: supportChatWithFile, + chatState: chatState, + ), + ); + }, + updatePluginState: (LocalAIPluginStatePB chatState) { + final fileEnabled = state.chatState?.fileEnabled ?? false; + final supportChatWithFile = + fileEnabled && chatState.state == RunningStatePB.Running; + + final aiType = chatState.state == RunningStatePB.Running + ? AIType.localAI + : AIType.appflowyAI; + + emit( + state.copyWith( + supportChatWithFile: supportChatWithFile, + aiType: aiType, + ), + ); + }, + attachFile: (filePath, fileName) { + final newFile = ChatFile.fromFilePath(filePath); + if (newFile != null) { + emit( + state.copyWith( + attachedFiles: [...state.attachedFiles, newFile], + ), + ); + } + }, + removeFile: (file) { + final files = [...state.attachedFiles]; + files.remove(file); + emit( + state.copyWith( + attachedFiles: files, + ), + ); + }, + updateMentionedViews: (views) { + emit( + state.copyWith( + mentionedPages: views, + ), + ); + }, + clearMetadata: () { + emit( + state.copyWith( + attachedFiles: [], + mentionedPages: [], + ), + ); + }, + ); + }, + ); + } + + void _startListening() { + _listener.start( + stateCallback: (pluginState) { + if (!isClosed) { + add(AIPromptInputEvent.updatePluginState(pluginState)); + } + }, + chatStateCallback: (chatState) { + if (!isClosed) { + add(AIPromptInputEvent.updateChatState(chatState)); + } + }, + ); + } + + void _init() { + AIEventGetLocalAIChatState().send().fold( + (chatState) { + if (!isClosed) { + add(AIPromptInputEvent.updateChatState(chatState)); + } + }, + Log.error, + ); + } + + Map consumeMetadata() { + final metadata = { + for (final file in state.attachedFiles) file.filePath: file, + for (final page in state.mentionedPages) page.id: page, + }; + + if (metadata.isNotEmpty && !isClosed) { + add(const AIPromptInputEvent.clearMetadata()); + } + + return metadata; + } +} + +@freezed +class AIPromptInputEvent with _$AIPromptInputEvent { + const factory AIPromptInputEvent.updateChatState( + LocalAIChatPB chatState, + ) = _UpdateChatState; + const factory AIPromptInputEvent.updatePluginState( + LocalAIPluginStatePB chatState, + ) = _UpdatePluginState; + const factory AIPromptInputEvent.attachFile( + String filePath, + String fileName, + ) = _AttachFile; + const factory AIPromptInputEvent.removeFile(ChatFile file) = _RemoveFile; + const factory AIPromptInputEvent.updateMentionedViews(List views) = + _UpdateMentionedViews; + const factory AIPromptInputEvent.clearMetadata() = _ClearMetadata; +} + +@freezed +class AIPromptInputState with _$AIPromptInputState { + const factory AIPromptInputState({ + required AIType aiType, + required bool supportChatWithFile, + required LocalAIChatPB? chatState, + required List attachedFiles, + required List mentionedPages, + }) = _AIPromptInputState; + + factory AIPromptInputState.initial() => const AIPromptInputState( + aiType: AIType.appflowyAI, + supportChatWithFile: false, + chatState: null, + attachedFiles: [], + mentionedPages: [], + ); +} + +enum AIType { + appflowyAI, + localAI; + + bool get isLocalAI => this == localAI; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart new file mode 100644 index 0000000000000..aeccb1a3ca387 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_ai_message_bloc.dart @@ -0,0 +1,183 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'chat_message_service.dart'; + +part 'chat_ai_message_bloc.freezed.dart'; + +class ChatAIMessageBloc extends Bloc { + ChatAIMessageBloc({ + dynamic message, + String? refSourceJsonString, + required this.chatId, + required this.questionId, + }) : super( + ChatAIMessageState.initial( + message, + parseMetadata(refSourceJsonString), + ), + ) { + _dispatch(); + + if (state.stream != null) { + _startListening(); + + if (state.stream!.aiLimitReached) { + add(const ChatAIMessageEvent.onAIResponseLimit()); + } else if (state.stream!.error != null) { + add(ChatAIMessageEvent.receiveError(state.stream!.error!)); + } + } + } + + final String chatId; + final Int64? questionId; + + void _dispatch() { + on( + (event, emit) { + event.when( + updateText: (newText) { + emit( + state.copyWith( + text: newText, + messageState: const MessageState.ready(), + ), + ); + }, + receiveError: (error) { + emit(state.copyWith(messageState: MessageState.onError(error))); + }, + retry: () { + if (questionId is! Int64) { + Log.error("Question id is not Int64: $questionId"); + return; + } + emit( + state.copyWith( + messageState: const MessageState.loading(), + ), + ); + + final payload = ChatMessageIdPB( + chatId: chatId, + messageId: questionId, + ); + AIEventGetAnswerForQuestion(payload).send().then((result) { + if (!isClosed) { + result.fold( + (answer) { + add(ChatAIMessageEvent.retryResult(answer.content)); + }, + (err) { + Log.error("Failed to get answer: $err"); + add(ChatAIMessageEvent.receiveError(err.toString())); + }, + ); + } + }); + }, + retryResult: (String text) { + emit( + state.copyWith( + text: text, + messageState: const MessageState.ready(), + ), + ); + }, + onAIResponseLimit: () { + emit( + state.copyWith( + messageState: const MessageState.onAIResponseLimit(), + ), + ); + }, + receiveMetadata: (metadata) { + Log.debug("AI Steps: ${metadata.progress?.step}"); + emit( + state.copyWith( + sources: metadata.sources, + progress: metadata.progress, + ), + ); + }, + ); + }, + ); + } + + void _startListening() { + state.stream!.listen( + onData: (text) { + if (!isClosed) { + add(ChatAIMessageEvent.updateText(text)); + } + }, + onError: (error) { + if (!isClosed) { + add(ChatAIMessageEvent.receiveError(error.toString())); + } + }, + onAIResponseLimit: () { + if (!isClosed) { + add(const ChatAIMessageEvent.onAIResponseLimit()); + } + }, + onMetadata: (metadata) { + if (!isClosed) { + add(ChatAIMessageEvent.receiveMetadata(metadata)); + } + }, + ); + } +} + +@freezed +class ChatAIMessageEvent with _$ChatAIMessageEvent { + const factory ChatAIMessageEvent.updateText(String text) = _UpdateText; + const factory ChatAIMessageEvent.receiveError(String error) = _ReceiveError; + const factory ChatAIMessageEvent.retry() = _Retry; + const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult; + const factory ChatAIMessageEvent.onAIResponseLimit() = _OnAIResponseLimit; + const factory ChatAIMessageEvent.receiveMetadata( + MetadataCollection metadata, + ) = _ReceiveMetadata; +} + +@freezed +class ChatAIMessageState with _$ChatAIMessageState { + const factory ChatAIMessageState({ + AnswerStream? stream, + required String text, + required MessageState messageState, + required List sources, + required AIChatProgress? progress, + }) = _ChatAIMessageState; + + factory ChatAIMessageState.initial( + dynamic text, + MetadataCollection metadata, + ) { + return ChatAIMessageState( + text: text is String ? text : "", + stream: text is AnswerStream ? text : null, + messageState: const MessageState.ready(), + sources: metadata.sources, + progress: metadata.progress, + ); + } +} + +@freezed +class MessageState with _$MessageState { + const factory MessageState.onError(String error) = _Error; + const factory MessageState.onAIResponseLimit() = _AIResponseLimit; + const factory MessageState.ready() = _Ready; + const factory MessageState.loading() = _Loading; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart new file mode 100644 index 0000000000000..58146c42f1f2f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart @@ -0,0 +1,630 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:appflowy/util/int64_extension.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:nanoid/nanoid.dart'; + +import 'chat_entity.dart'; +import 'chat_message_listener.dart'; +import 'chat_message_service.dart'; +import 'chat_message_stream.dart'; + +part 'chat_bloc.freezed.dart'; + +class ChatBloc extends Bloc { + ChatBloc({ + required this.chatId, + required this.userId, + }) : chatController = InMemoryChatController(), + listener = ChatMessageListener(chatId: chatId), + super(ChatState.initial()) { + _startListening(); + _dispatch(); + _loadMessages(); + _loadSetting(); + } + + final String chatId; + final String userId; + final ChatMessageListener listener; + + final ChatController chatController; + + /// The last streaming message id + String answerStreamMessageId = ''; + String questionStreamMessageId = ''; + + ChatMessagePB? lastSentMessage; + + /// Using a temporary map to associate the real message ID with the last streaming message ID. + /// + /// When a message is streaming, it does not have a real message ID. To maintain the relationship + /// between the real message ID and the last streaming message ID, we use this map to store the associations. + /// + /// This map will be updated when receiving a message from the server and its author type + /// is 3 (AI response). + final HashMap temporaryMessageIDMap = HashMap(); + + bool isLoadingPreviousMessages = false; + bool hasMorePreviousMessages = true; + AnswerStream? answerStream; + int numSendMessage = 0; + + @override + Future close() async { + await answerStream?.dispose(); + await listener.stop(); + final request = ViewIdPB(value: chatId); + unawaited(FolderEventCloseView(request).send()); + + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + // Loading messages + didLoadLatestMessages: (List messages) async { + for (final message in messages) { + await chatController.insert(message, index: 0); + } + + switch (state.loadingState) { + case LoadChatMessageStatus.loading + when chatController.messages.isEmpty: + emit( + state.copyWith( + loadingState: LoadChatMessageStatus.loadingRemote, + ), + ); + break; + case LoadChatMessageStatus.loading: + case LoadChatMessageStatus.loadingRemote: + emit( + state.copyWith(loadingState: LoadChatMessageStatus.ready), + ); + break; + default: + break; + } + }, + loadPreviousMessages: () { + if (isLoadingPreviousMessages) { + return; + } + + final oldestMessage = _getOldestMessage(); + + if (oldestMessage != null) { + final oldestMessageId = Int64.tryParseInt(oldestMessage.id); + if (oldestMessageId == null) { + Log.error("Failed to parse message_id: ${oldestMessage.id}"); + return; + } + isLoadingPreviousMessages = true; + _loadPreviousMessages(oldestMessageId); + } + }, + didLoadPreviousMessages: (messages, hasMore) { + Log.debug("did load previous messages: ${messages.length}"); + + for (final message in messages) { + chatController.insert(message, index: 0); + } + + isLoadingPreviousMessages = false; + hasMorePreviousMessages = hasMore; + }, + didFinishAnswerStream: () { + emit( + state.copyWith(promptResponseState: PromptResponseState.ready), + ); + }, + didReceiveRelatedQuestions: (List questions) { + if (questions.isEmpty) { + return; + } + + final metadata = { + onetimeShotType: OnetimeShotType.relatedQuestion, + 'questions': questions, + }; + + final createdAt = DateTime.now(); + + final message = TextMessage( + id: "related_question_$createdAt", + text: '', + metadata: metadata, + author: const User(id: systemUserId), + createdAt: createdAt, + ); + + chatController.insert(message); + }, + receiveMessage: (Message message) { + final oldMessage = chatController.messages + .firstWhereOrNull((m) => m.id == message.id); + if (oldMessage == null) { + chatController.insert(message); + } else { + chatController.update(oldMessage, message); + } + }, + sendMessage: ( + String message, + Map? metadata, + ) { + numSendMessage += 1; + + _clearRelatedQuestions(); + _startStreamingMessage(message, metadata); + lastSentMessage = null; + + emit( + state.copyWith( + promptResponseState: PromptResponseState.sendingQuestion, + ), + ); + }, + finishSending: () { + emit( + state.copyWith( + promptResponseState: PromptResponseState.streamingAnswer, + ), + ); + }, + stopStream: () async { + if (answerStream == null) { + return; + } + + // tell backend to stop + final payload = StopStreamPB(chatId: chatId); + await AIEventStopStream(payload).send(); + + // allow user input + emit( + state.copyWith( + promptResponseState: PromptResponseState.ready, + ), + ); + + // no need to remove old message if stream has started already + if (answerStream!.hasStarted) { + return; + } + + // remove the non-started message from the list + final message = chatController.messages.lastWhereOrNull( + (e) => e.id == answerStreamMessageId, + ); + if (message != null) { + await chatController.remove(message); + } + + // set answer stream to null + await answerStream?.dispose(); + answerStream = null; + answerStreamMessageId = ''; + }, + failedSending: () { + final lastMessage = chatController.messages.lastOrNull; + if (lastMessage != null) { + chatController.remove(lastMessage); + } + emit( + state.copyWith( + promptResponseState: PromptResponseState.ready, + ), + ); + }, + regenerateAnswer: (id) { + _clearRelatedQuestions(); + _regenerateAnswer(id); + lastSentMessage = null; + + emit( + state.copyWith( + promptResponseState: PromptResponseState.sendingQuestion, + ), + ); + }, + didReceiveChatSettings: (settings) { + emit( + state.copyWith(selectedSourceIds: settings.ragIds), + ); + }, + updateSelectedSources: (selectedSourcesIds) async { + emit(state.copyWith(selectedSourceIds: selectedSourcesIds)); + + final payload = UpdateChatSettingsPB( + chatId: ChatId(value: chatId), + ragIds: selectedSourcesIds, + ); + await AIEventUpdateChatSettings(payload) + .send() + .onFailure(Log.error); + }, + ); + }, + ); + } + + void _startListening() { + listener.start( + chatMessageCallback: (pb) { + if (isClosed) { + return; + } + + // 3 mean message response from AI + if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) { + temporaryMessageIDMap[pb.messageId.toString()] = + answerStreamMessageId; + answerStreamMessageId = ''; + } + + // 1 mean message response from User + if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) { + temporaryMessageIDMap[pb.messageId.toString()] = + questionStreamMessageId; + questionStreamMessageId = ''; + } + + final message = _createTextMessage(pb); + add(ChatEvent.receiveMessage(message)); + }, + chatErrorMessageCallback: (err) { + if (!isClosed) { + Log.error("chat error: ${err.errorMessage}"); + add(const ChatEvent.didFinishAnswerStream()); + } + }, + latestMessageCallback: (list) { + if (!isClosed) { + final messages = list.messages.map(_createTextMessage).toList(); + add(ChatEvent.didLoadLatestMessages(messages)); + } + }, + prevMessageCallback: (list) { + if (!isClosed) { + final messages = list.messages.map(_createTextMessage).toList(); + add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore)); + } + }, + finishStreamingCallback: () async { + if (isClosed) { + return; + } + + add(const ChatEvent.didFinishAnswerStream()); + + // The answer stream will bet set to null after the streaming has + // finished, got cancelled, or errored. In this case, don't retrieve + // related questions. + if (answerStream == null || lastSentMessage == null) { + return; + } + + final payload = ChatMessageIdPB( + chatId: chatId, + messageId: lastSentMessage!.messageId, + ); + + // when previous numSendMessage is not equal to current numSendMessage, it means that the user + // has sent a new message. So we don't need to get related questions. + final preNumSendMessage = numSendMessage; + await AIEventGetRelatedQuestion(payload).send().fold( + (list) { + if (!isClosed && preNumSendMessage == numSendMessage) { + add( + ChatEvent.didReceiveRelatedQuestions( + list.items.map((e) => e.content).toList(), + ), + ); + } + }, + (err) => Log.error("Failed to get related questions: $err"), + ); + }, + ); + } + + void _loadSetting() async { + final getChatSettingsPayload = + AIEventGetChatSettings(ChatId(value: chatId)); + await getChatSettingsPayload.send().fold( + (settings) { + if (!isClosed) { + add(ChatEvent.didReceiveChatSettings(settings: settings)); + } + }, + Log.error, + ); + } + + void _loadMessages() async { + final loadMessagesPayload = LoadNextChatMessagePB( + chatId: chatId, + limit: Int64(10), + ); + await AIEventLoadNextMessage(loadMessagesPayload).send().fold( + (list) { + if (!isClosed) { + final messages = list.messages.map(_createTextMessage).toList(); + add(ChatEvent.didLoadLatestMessages(messages)); + } + }, + (err) => Log.error("Failed to load messages: $err"), + ); + } + + bool _isOneTimeMessage(Message message) { + return message.metadata != null && + message.metadata!.containsKey(onetimeShotType); + } + + /// get the last message that is not a one-time message + Message? _getOldestMessage() { + return chatController.messages + .firstWhereOrNull((message) => !_isOneTimeMessage(message)); + } + + void _loadPreviousMessages(Int64? beforeMessageId) { + final payload = LoadPrevChatMessagePB( + chatId: chatId, + limit: Int64(10), + beforeMessageId: beforeMessageId, + ); + AIEventLoadPrevMessage(payload).send(); + } + + Future _startStreamingMessage( + String message, + Map? metadata, + ) async { + await answerStream?.dispose(); + + answerStream = AnswerStream(); + final questionStream = QuestionStream(); + + // add a streaming question message + final questionStreamMessage = _createQuestionStreamMessage( + questionStream, + metadata, + ); + add(ChatEvent.receiveMessage(questionStreamMessage)); + + final payload = StreamChatPayloadPB( + chatId: chatId, + message: message, + messageType: ChatMessageTypePB.User, + questionStreamPort: Int64(questionStream.nativePort), + answerStreamPort: Int64(answerStream!.nativePort), + metadata: await metadataPBFromMetadata(metadata), + ); + + // stream the question to the server + await AIEventStreamMessage(payload).send().fold( + (question) { + if (!isClosed) { + final streamAnswer = _createAnswerStreamMessage( + answerStream!, + question.messageId, + ); + + lastSentMessage = question; + add(const ChatEvent.finishSending()); + add(ChatEvent.receiveMessage(streamAnswer)); + } + }, + (err) { + if (!isClosed) { + Log.error("Failed to send message: ${err.msg}"); + + final metadata = { + onetimeShotType: OnetimeShotType.error, + if (err.code != ErrorCode.Internal) errorMessageTextKey: err.msg, + }; + + final error = TextMessage( + text: '', + metadata: metadata, + author: const User(id: systemUserId), + id: systemUserId, + createdAt: DateTime.now(), + ); + + add(const ChatEvent.failedSending()); + add(ChatEvent.receiveMessage(error)); + } + }, + ); + } + + void _regenerateAnswer(String answerMessageIdString) async { + final id = temporaryMessageIDMap.entries + .firstWhereOrNull((e) => e.value == answerMessageIdString) + ?.key ?? + answerMessageIdString; + final answerMessageId = Int64.tryParseInt(id); + + if (answerMessageId == null) { + return; + } + + await answerStream?.dispose(); + answerStream = AnswerStream(); + + final payload = RegenerateResponsePB( + chatId: chatId, + answerMessageId: answerMessageId, + answerStreamPort: Int64(answerStream!.nativePort), + ); + + await AIEventRegenerateResponse(payload).send().fold( + (success) { + if (!isClosed) { + final streamAnswer = _createAnswerStreamMessage( + answerStream!, + answerMessageId - 1, + ).copyWith(id: answerMessageIdString); + + add(ChatEvent.receiveMessage(streamAnswer)); + add(const ChatEvent.finishSending()); + } + }, + (err) => Log.error("Failed to send message: ${err.msg}"), + ); + } + + Message _createAnswerStreamMessage( + AnswerStream stream, + Int64 questionMessageId, + ) { + answerStreamMessageId = (questionMessageId + 1).toString(); + + return TextMessage( + id: answerStreamMessageId, + text: '', + author: User(id: "streamId:${nanoid()}"), + metadata: { + "$AnswerStream": stream, + messageQuestionIdKey: questionMessageId, + "chatId": chatId, + }, + createdAt: DateTime.now(), + ); + } + + Message _createQuestionStreamMessage( + QuestionStream stream, + Map? sentMetadata, + ) { + final now = DateTime.now(); + + questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString(); + + return TextMessage( + author: User(id: userId), + metadata: { + "$QuestionStream": stream, + "chatId": chatId, + messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata), + }, + id: questionStreamMessageId, + createdAt: now, + text: '', + ); + } + + Message _createTextMessage(ChatMessagePB message) { + String messageId = message.messageId.toString(); + + /// If the message id is in the temporary map, we will use the previous fake message id + if (temporaryMessageIDMap.containsKey(messageId)) { + messageId = temporaryMessageIDMap[messageId]!; + } + + return TextMessage( + author: User(id: message.authorId), + id: messageId, + text: message.content, + createdAt: message.createdAt.toDateTime(), + metadata: { + messageRefSourceJsonStringKey: message.metadata, + }, + ); + } + + void _clearRelatedQuestions() { + final relatedQuestionMessages = chatController.messages + .where( + (message) => + onetimeMessageTypeFromMeta(message.metadata) == + OnetimeShotType.relatedQuestion, + ) + .toList(); + + for (final message in relatedQuestionMessages) { + chatController.remove(message); + } + } +} + +@freezed +class ChatEvent with _$ChatEvent { + // chat settings + const factory ChatEvent.didReceiveChatSettings({ + required ChatSettingsPB settings, + }) = _DidReceiveChatSettings; + const factory ChatEvent.updateSelectedSources({ + required List selectedSourcesIds, + }) = _UpdateSelectedSources; + + // send message + const factory ChatEvent.sendMessage({ + required String message, + Map? metadata, + }) = _SendMessage; + const factory ChatEvent.finishSending() = _FinishSendMessage; + const factory ChatEvent.failedSending() = _FailSendMessage; + + // regenerate + const factory ChatEvent.regenerateAnswer(String id) = _RegenerateAnswer; + + // streaming answer + const factory ChatEvent.stopStream() = _StopStream; + const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream; + + // receive message + const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage; + + // loading messages + const factory ChatEvent.didLoadLatestMessages(List messages) = + _DidLoadMessages; + const factory ChatEvent.loadPreviousMessages() = _LoadPreviousMessages; + const factory ChatEvent.didLoadPreviousMessages( + List messages, + bool hasMore, + ) = _DidLoadPreviousMessages; + + // related questions + const factory ChatEvent.didReceiveRelatedQuestions( + List questions, + ) = _DidReceiveRelatedQueston; +} + +@freezed +class ChatState with _$ChatState { + const factory ChatState({ + required List selectedSourceIds, + required LoadChatMessageStatus loadingState, + required PromptResponseState promptResponseState, + }) = _ChatState; + + factory ChatState.initial() => const ChatState( + selectedSourceIds: [], + loadingState: LoadChatMessageStatus.loading, + promptResponseState: PromptResponseState.ready, + ); +} + +bool isOtherUserMessage(Message message) { + return message.author.id != aiResponseUserId && + message.author.id != systemUserId && + !message.author.id.startsWith("streamId:"); +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_edit_document_service.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_edit_document_service.dart new file mode 100644 index 0000000000000..1591b5ca9615a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_edit_document_service.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; + +class ChatEditDocumentService { + static Future saveMessagesToNewPage( + String chatPageName, + String parentViewId, + List messages, + ) async { + if (messages.isEmpty) { + return null; + } + + // Convert messages to markdown and trim the last empty newline. + final completeMessage = messages.map((m) => m.text).join('\n').trimRight(); + if (completeMessage.isEmpty) { + return null; + } + + final document = customMarkdownToDocument(completeMessage); + final initialBytes = + DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer(); + if (initialBytes == null) { + Log.error('Failed to convert messages to document'); + return null; + } + + return ViewBackendService.createView( + name: LocaleKeys.chat_addToNewPageName.tr(args: [chatPageName]), + layoutType: ViewLayoutPB.Document, + parentViewId: parentViewId, + initialDataBytes: initialBytes, + ).toNullable(); + } + + static Future addMessageToPage( + String documentId, + TextMessage message, + ) async { + if (message.text.isEmpty) { + Log.error('Message is empty'); + return; + } + + final bloc = DocumentBloc( + documentId: documentId, + saveToBlocMap: false, + )..add(const DocumentEvent.initial()); + + if (bloc.state.editorState == null) { + await bloc.stream.firstWhere((state) => state.editorState != null); + } + + final editorState = bloc.state.editorState; + if (editorState == null) { + Log.error("Can't get EditorState of document"); + return; + } + + final messageDocument = customMarkdownToDocument(message.text); + if (messageDocument.isEmpty) { + Log.error('Failed to convert message to document'); + return; + } + + final lastNodeOrNull = editorState.document.root.children.lastOrNull; + + final rootIsEmpty = lastNodeOrNull == null; + final isLastLineEmpty = lastNodeOrNull?.children.isNotEmpty == false && + lastNodeOrNull?.delta?.isNotEmpty == false; + + final nodes = [ + if (rootIsEmpty || !isLastLineEmpty) paragraphNode(), + ...messageDocument.root.children, + ]; + final insertPath = rootIsEmpty || listEquals(lastNodeOrNull.path, const [0]) + ? const [0] + : lastNodeOrNull.path.next; + + final transaction = editorState.transaction..insertNodes(insertPath, nodes); + await editorState.apply(transaction); + await bloc.close(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart new file mode 100644 index 0000000000000..4494eca899e4a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_entity.dart @@ -0,0 +1,139 @@ +import 'dart:io'; + +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:equatable/equatable.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:path/path.dart' as path; + +part 'chat_entity.g.dart'; +part 'chat_entity.freezed.dart'; + +const errorMessageTextKey = "errorMessageText"; +const systemUserId = "system"; +const aiResponseUserId = "0"; + +/// `messageRefSourceJsonStringKey` is the key used for metadata that contains the reference source of a message. +/// Each message may include this information. +/// - When used in a sent message, it indicates that the message includes an attachment. +/// - When used in a received message, it indicates the AI reference sources used to answer a question. +const messageRefSourceJsonStringKey = "ref_source_json_string"; +const messageChatFileListKey = "chat_files"; +const messageQuestionIdKey = "question_id"; + +@JsonSerializable() +class ChatMessageRefSource { + ChatMessageRefSource({ + required this.id, + required this.name, + required this.source, + }); + + factory ChatMessageRefSource.fromJson(Map json) => + _$ChatMessageRefSourceFromJson(json); + + final String id; + final String name; + final String source; + + Map toJson() => _$ChatMessageRefSourceToJson(this); +} + +@JsonSerializable() +class AIChatProgress { + AIChatProgress({ + required this.step, + }); + + factory AIChatProgress.fromJson(Map json) => + _$AIChatProgressFromJson(json); + + final String step; + + Map toJson() => _$AIChatProgressToJson(this); +} + +enum PromptResponseState { + ready, + sendingQuestion, + streamingAnswer, +} + +class ChatFile extends Equatable { + const ChatFile({ + required this.filePath, + required this.fileName, + required this.fileType, + }); + + static ChatFile? fromFilePath(String filePath) { + final file = File(filePath); + if (!file.existsSync()) { + return null; + } + + final fileName = path.basename(filePath); + final extension = path.extension(filePath).toLowerCase(); + + ContextLoaderTypePB fileType; + switch (extension) { + case '.pdf': + fileType = ContextLoaderTypePB.PDF; + break; + case '.txt': + fileType = ContextLoaderTypePB.Txt; + break; + case '.md': + fileType = ContextLoaderTypePB.Markdown; + break; + default: + fileType = ContextLoaderTypePB.UnknownLoaderType; + } + + return ChatFile( + filePath: filePath, + fileName: fileName, + fileType: fileType, + ); + } + + final String filePath; + final String fileName; + final ContextLoaderTypePB fileType; + + @override + List get props => [filePath]; +} + +typedef ChatFileMap = Map; +typedef ChatMentionedPageMap = Map; + +@freezed +class ChatLoadingState with _$ChatLoadingState { + const factory ChatLoadingState.loading() = _Loading; + const factory ChatLoadingState.finish({FlowyError? error}) = _Finish; +} + +extension ChatLoadingStateExtension on ChatLoadingState { + bool get isLoading => this is _Loading; + bool get isFinish => this is _Finish; +} + +enum OnetimeShotType { + sendingMessage, + relatedQuestion, + error, +} + +const onetimeShotType = "OnetimeShotType"; + +OnetimeShotType? onetimeMessageTypeFromMeta(Map? metadata) { + return metadata?[onetimeShotType]; +} + +enum LoadChatMessageStatus { + loading, + loadingRemote, + ready, +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_control_cubit.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_control_cubit.dart new file mode 100644 index 0000000000000..63acb8be6de4c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_control_cubit.dart @@ -0,0 +1,246 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_input_control_cubit.freezed.dart'; + +class ChatInputControlCubit extends Cubit { + ChatInputControlCubit() : super(const ChatInputControlState.loading()); + + final List allViews = []; + final List selectedViewIds = []; + + /// used when mentioning a page + /// + /// the text position after the @ character + int _filterStartPosition = -1; + + /// used when mentioning a page + /// + /// the text position after the @ character, at the end of the filter + int _filterEndPosition = -1; + + /// used when mentioning a page + /// + /// the entire string input in the prompt + String _inputText = ""; + + /// used when mentioning a page + /// + /// the current filtering text, after the @ characater + String _filter = ""; + + String get inputText => _inputText; + int get filterStartPosition => _filterStartPosition; + int get filterEndPosition => _filterEndPosition; + + void refreshViews() async { + final newViews = await ViewBackendService.getAllViews().fold( + (result) { + return result.items + .where( + (v) => + !v.isSpace && + v.layout.isDocumentView && + v.parentViewId != v.id, + ) + .toList(); + }, + (err) { + Log.error(err); + return []; + }, + ); + allViews + ..clear() + ..addAll(newViews); + + // update visible views + newViews.retainWhere((v) => !selectedViewIds.contains(v.id)); + if (_filter.isNotEmpty) { + newViews.retainWhere( + (v) { + final nonEmptyName = v.name.isEmpty + ? LocaleKeys.document_title_placeholder.tr() + : v.name; + return nonEmptyName.toLowerCase().contains(_filter); + }, + ); + } + final focusedViewIndex = newViews.isEmpty ? -1 : 0; + emit( + ChatInputControlState.ready( + visibleViews: newViews, + focusedViewIndex: focusedViewIndex, + ), + ); + } + + void startSearching(TextEditingValue textEditingValue) { + _filterStartPosition = + _filterEndPosition = textEditingValue.selection.baseOffset; + _filter = ""; + _inputText = textEditingValue.text; + state.maybeMap( + ready: (readyState) { + emit( + readyState.copyWith( + visibleViews: allViews, + focusedViewIndex: allViews.isEmpty ? -1 : 0, + ), + ); + }, + orElse: () {}, + ); + } + + void reset() { + _filterStartPosition = _filterEndPosition = -1; + _filter = _inputText = ""; + state.maybeMap( + ready: (readyState) { + emit( + readyState.copyWith( + visibleViews: allViews, + focusedViewIndex: allViews.isEmpty ? -1 : 0, + ), + ); + }, + orElse: () {}, + ); + } + + void updateFilter( + String newInputText, + String newFilter, { + int? newEndPosition, + }) { + updateInputText(newInputText); + + // filter the views + _filter = newFilter.toLowerCase(); + if (newEndPosition != null) { + _filterEndPosition = newEndPosition; + } + + final newVisibleViews = + allViews.where((v) => !selectedViewIds.contains(v.id)).toList(); + + if (_filter.isNotEmpty) { + newVisibleViews.retainWhere( + (v) { + final nonEmptyName = v.name.isEmpty + ? LocaleKeys.document_title_placeholder.tr() + : v.name; + return nonEmptyName.toLowerCase().contains(_filter); + }, + ); + } + + state.maybeWhen( + ready: (_, oldFocusedIndex) { + final newFocusedViewIndex = oldFocusedIndex < newVisibleViews.length + ? oldFocusedIndex + : (newVisibleViews.isEmpty ? -1 : 0); + emit( + ChatInputControlState.ready( + visibleViews: newVisibleViews, + focusedViewIndex: newFocusedViewIndex, + ), + ); + }, + orElse: () {}, + ); + } + + void updateInputText(String newInputText) { + _inputText = newInputText; + + // input text is changed, see if there are any deletions + selectedViewIds.retainWhere(_inputText.contains); + _notifyUpdateSelectedViews(); + } + + void updateSelectionUp() { + state.maybeMap( + ready: (readyState) { + final newIndex = readyState.visibleViews.isEmpty + ? -1 + : (readyState.focusedViewIndex - 1) % + readyState.visibleViews.length; + emit( + readyState.copyWith(focusedViewIndex: newIndex), + ); + }, + orElse: () {}, + ); + } + + void updateSelectionDown() { + state.maybeMap( + ready: (readyState) { + final newIndex = readyState.visibleViews.isEmpty + ? -1 + : (readyState.focusedViewIndex + 1) % + readyState.visibleViews.length; + emit( + readyState.copyWith(focusedViewIndex: newIndex), + ); + }, + orElse: () {}, + ); + } + + void selectPage(ViewPB view) { + selectedViewIds.add(view.id); + _notifyUpdateSelectedViews(); + reset(); + } + + String formatIntputText(final String input) { + String result = input; + for (final viewId in selectedViewIds) { + if (!result.contains(viewId)) { + continue; + } + final view = allViews.firstWhereOrNull((view) => view.id == viewId); + if (view != null) { + final nonEmptyName = view.name.isEmpty + ? LocaleKeys.document_title_placeholder.tr() + : view.name; + result = result.replaceAll(RegExp(viewId), nonEmptyName); + } + } + return result; + } + + void _notifyUpdateSelectedViews() { + final stateCopy = state; + final selectedViews = + allViews.where((view) => selectedViewIds.contains(view.id)).toList(); + emit(ChatInputControlState.updateSelectedViews(selectedViews)); + emit(stateCopy); + } +} + +@freezed +class ChatInputControlState with _$ChatInputControlState { + const factory ChatInputControlState.loading() = _Loading; + + const factory ChatInputControlState.ready({ + required List visibleViews, + required int focusedViewIndex, + }) = _Ready; + + const factory ChatInputControlState.updateSelectedViews( + List selectedViews, + ) = _UpdateOneShot; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_file_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_file_bloc.dart new file mode 100644 index 0000000000000..31d58eb0005e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_input_file_bloc.dart @@ -0,0 +1,44 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_input_file_bloc.freezed.dart'; + +class ChatInputFileBloc extends Bloc { + ChatInputFileBloc({ + required this.file, + }) : super(const ChatInputFileState()) { + on( + (event, emit) async { + event.when( + updateUploadState: (UploadFileIndicator indicator) { + emit(state.copyWith(uploadFileIndicator: indicator)); + }, + ); + }, + ); + } + + final ChatFile file; +} + +@freezed +class ChatInputFileEvent with _$ChatInputFileEvent { + const factory ChatInputFileEvent.updateUploadState( + UploadFileIndicator indicator, + ) = _UpdateUploadState; +} + +@freezed +class ChatInputFileState with _$ChatInputFileState { + const factory ChatInputFileState({ + UploadFileIndicator? uploadFileIndicator, + }) = _ChatInputFileState; +} + +@freezed +class UploadFileIndicator with _$UploadFileIndicator { + const factory UploadFileIndicator.finish() = _Finish; + const factory UploadFileIndicator.uploading() = _Uploading; + const factory UploadFileIndicator.error(String error) = _Error; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart new file mode 100644 index 0000000000000..d1d0898ccc4a5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_member_bloc.dart @@ -0,0 +1,78 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:equatable/equatable.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_member_bloc.freezed.dart'; + +class ChatMemberBloc extends Bloc { + ChatMemberBloc() : super(const ChatMemberState()) { + on( + (event, emit) async { + event.when( + receiveMemberInfo: (String id, WorkspaceMemberPB memberInfo) { + final members = Map.from(state.members); + members[id] = ChatMember(info: memberInfo); + emit(state.copyWith(members: members)); + }, + getMemberInfo: (String userId) { + if (state.members.containsKey(userId)) { + // Member info already exists. Debouncing refresh member info from backend would be better. + return; + } + + final payload = WorkspaceMemberIdPB( + uid: Int64.parseInt(userId), + ); + UserEventGetMemberInfo(payload).send().then((result) { + if (!isClosed) { + result.fold((member) { + add( + ChatMemberEvent.receiveMemberInfo( + userId, + member, + ), + ); + }, (err) { + Log.error("Error getting member info: $err"); + }); + } + }); + }, + ); + }, + ); + } +} + +@freezed +class ChatMemberEvent with _$ChatMemberEvent { + const factory ChatMemberEvent.getMemberInfo( + String userId, + ) = _GetMemberInfo; + const factory ChatMemberEvent.receiveMemberInfo( + String id, + WorkspaceMemberPB memberInfo, + ) = _ReceiveMemberInfo; +} + +@freezed +class ChatMemberState with _$ChatMemberState { + const factory ChatMemberState({ + @Default({}) Map members, + }) = _ChatMemberState; +} + +class ChatMember extends Equatable { + ChatMember({ + required this.info, + }); + final DateTime _date = DateTime.now(); + final WorkspaceMemberPB info; + + @override + List get props => [_date, info]; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_listener.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_listener.dart new file mode 100644 index 0000000000000..46678062864f5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_listener.dart @@ -0,0 +1,81 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +import 'chat_notification.dart'; + +typedef ChatMessageCallback = void Function(ChatMessagePB message); +typedef ChatErrorMessageCallback = void Function(ChatMessageErrorPB message); +typedef LatestMessageCallback = void Function(ChatMessageListPB list); +typedef PrevMessageCallback = void Function(ChatMessageListPB list); + +class ChatMessageListener { + ChatMessageListener({required this.chatId}) { + _parser = ChatNotificationParser(id: chatId, callback: _callback); + _subscription = RustStreamReceiver.listen( + (observable) => _parser?.parse(observable), + ); + } + + final String chatId; + StreamSubscription? _subscription; + ChatNotificationParser? _parser; + + ChatMessageCallback? chatMessageCallback; + ChatErrorMessageCallback? chatErrorMessageCallback; + LatestMessageCallback? latestMessageCallback; + PrevMessageCallback? prevMessageCallback; + void Function()? finishStreamingCallback; + + void start({ + ChatMessageCallback? chatMessageCallback, + ChatErrorMessageCallback? chatErrorMessageCallback, + LatestMessageCallback? latestMessageCallback, + PrevMessageCallback? prevMessageCallback, + void Function()? finishStreamingCallback, + }) { + this.chatMessageCallback = chatMessageCallback; + this.chatErrorMessageCallback = chatErrorMessageCallback; + this.latestMessageCallback = latestMessageCallback; + this.prevMessageCallback = prevMessageCallback; + this.finishStreamingCallback = finishStreamingCallback; + } + + void _callback( + ChatNotification ty, + FlowyResult result, + ) { + result.map((r) { + switch (ty) { + case ChatNotification.DidReceiveChatMessage: + chatMessageCallback?.call(ChatMessagePB.fromBuffer(r)); + break; + case ChatNotification.StreamChatMessageError: + chatErrorMessageCallback?.call(ChatMessageErrorPB.fromBuffer(r)); + break; + case ChatNotification.DidLoadLatestChatMessage: + latestMessageCallback?.call(ChatMessageListPB.fromBuffer(r)); + break; + case ChatNotification.DidLoadPrevChatMessage: + prevMessageCallback?.call(ChatMessageListPB.fromBuffer(r)); + break; + case ChatNotification.FinishStreaming: + finishStreamingCallback?.call(); + break; + default: + break; + } + }); + } + + Future stop() async { + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart new file mode 100644 index 0000000000000..5bd8a35e5ba7e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_service.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:nanoid/nanoid.dart'; + +/// Indicate file source from appflowy document +const appflowySource = "appflowy"; + +List fileListFromMessageMetadata( + Map? map, +) { + final List metadata = []; + if (map != null) { + for (final entry in map.entries) { + if (entry.value is ChatFile) { + metadata.add(entry.value); + } + } + } + + return metadata; +} + +List chatFilesFromMetadataString(String? s) { + if (s == null || s.isEmpty || s == "null") { + return []; + } + + final metadataJson = jsonDecode(s); + if (metadataJson is Map) { + final file = chatFileFromMap(metadataJson); + if (file != null) { + return [file]; + } else { + return []; + } + } else if (metadataJson is List) { + return metadataJson + .map((e) => e as Map) + .map(chatFileFromMap) + .where((file) => file != null) + .cast() + .toList(); + } else { + Log.error("Invalid metadata: $metadataJson"); + return []; + } +} + +ChatFile? chatFileFromMap(Map? map) { + if (map == null) return null; + + final filePath = map['source'] as String?; + final fileName = map['name'] as String?; + + if (filePath == null || fileName == null) { + return null; + } + return ChatFile.fromFilePath(filePath); +} + +class MetadataCollection { + MetadataCollection({ + required this.sources, + this.progress, + }); + final List sources; + final AIChatProgress? progress; +} + +MetadataCollection parseMetadata(String? s) { + if (s == null || s.trim().isEmpty || s.toLowerCase() == "null") { + return MetadataCollection(sources: []); + } + + final List metadata = []; + AIChatProgress? progress; + + try { + final dynamic decodedJson = jsonDecode(s); + if (decodedJson == null) { + return MetadataCollection(sources: []); + } + + void processMap(Map map) { + if (map.containsKey("step") && map["step"] != null) { + progress = AIChatProgress.fromJson(map); + } else if (map.containsKey("id") && map["id"] != null) { + metadata.add(ChatMessageRefSource.fromJson(map)); + } else { + Log.info("Unsupported metadata format: $map"); + } + } + + if (decodedJson is Map) { + processMap(decodedJson); + } else if (decodedJson is List) { + for (final element in decodedJson) { + if (element is Map) { + processMap(element); + } else { + Log.error("Invalid metadata element: $element"); + } + } + } else { + Log.error("Invalid metadata format: $decodedJson"); + } + } catch (e, stacktrace) { + Log.error("Failed to parse metadata: $e, input: $s"); + Log.debug(stacktrace.toString()); + } + + return MetadataCollection(sources: metadata, progress: progress); +} + +Future> metadataPBFromMetadata( + Map? map, +) async { + if (map == null) return []; + + final List metadata = []; + + for (final value in map.values) { + switch (value) { + case ViewPB _ when value.layout.isDocumentView: + final payload = OpenDocumentPayloadPB(documentId: value.id); + await DocumentEventGetDocumentText(payload).send().fold( + (pb) { + metadata.add( + ChatMessageMetaPB( + id: value.id, + name: value.name, + data: pb.text, + loaderType: ContextLoaderTypePB.Txt, + source: appflowySource, + ), + ); + }, + (err) => Log.error('Failed to get document text: $err'), + ); + break; + case ChatFile( + filePath: final filePath, + fileName: final fileName, + fileType: final fileType, + ): + metadata.add( + ChatMessageMetaPB( + id: nanoid(8), + name: fileName, + data: filePath, + loaderType: fileType, + source: filePath, + ), + ); + break; + } + } + + return metadata; +} + +List chatFilesFromMessageMetadata( + Map? map, +) { + final List metadata = []; + if (map != null) { + for (final entry in map.entries) { + if (entry.value is ChatFile) { + metadata.add(entry.value); + } + } + } + + return metadata; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart new file mode 100644 index 0000000000000..fc63a7ac82bfb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:isolate'; + +import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart'; + +class AnswerStream { + AnswerStream() { + _port.handler = _controller.add; + _subscription = _controller.stream.listen( + (event) { + if (event.startsWith("data:")) { + _hasStarted = true; + final newText = event.substring(5); + _text += newText; + _onData?.call(_text); + } else if (event.startsWith("error:")) { + _error = event.substring(5); + _onError?.call(_error!); + } else if (event.startsWith("metadata:")) { + if (_onMetadata != null) { + final s = event.substring(9); + _onMetadata!(parseMetadata(s)); + } + } else if (event == "AI_RESPONSE_LIMIT") { + _aiLimitReached = true; + _onAIResponseLimit?.call(); + } + }, + onDone: () { + _onEnd?.call(); + }, + onError: (error) { + _onError?.call(error.toString()); + }, + ); + } + + final RawReceivePort _port = RawReceivePort(); + final StreamController _controller = StreamController.broadcast(); + late StreamSubscription _subscription; + bool _hasStarted = false; + bool _aiLimitReached = false; + String? _error; + String _text = ""; + + // Callbacks + void Function(String text)? _onData; + void Function()? _onStart; + void Function()? _onEnd; + void Function(String error)? _onError; + void Function()? _onAIResponseLimit; + void Function(MetadataCollection metadataCollection)? _onMetadata; + + int get nativePort => _port.sendPort.nativePort; + bool get hasStarted => _hasStarted; + bool get aiLimitReached => _aiLimitReached; + String? get error => _error; + String get text => _text; + + Future dispose() async { + await _controller.close(); + await _subscription.cancel(); + _port.close(); + } + + void listen({ + void Function(String text)? onData, + void Function()? onStart, + void Function()? onEnd, + void Function(String error)? onError, + void Function()? onAIResponseLimit, + void Function(MetadataCollection metadata)? onMetadata, + }) { + _onData = onData; + _onStart = onStart; + _onEnd = onEnd; + _onError = onError; + _onAIResponseLimit = onAIResponseLimit; + _onMetadata = onMetadata; + + _onStart?.call(); + } +} + +class QuestionStream { + QuestionStream() { + _port.handler = _controller.add; + _subscription = _controller.stream.listen( + (event) { + if (event.startsWith("data:")) { + _hasStarted = true; + final newText = event.substring(5); + _text += newText; + if (_onData != null) { + _onData!(_text); + } + } else if (event.startsWith("message_id:")) { + final messageId = event.substring(11); + _onMessageId?.call(messageId); + } else if (event.startsWith("start_index_file:")) { + final indexName = event.substring(17); + _onFileIndexStart?.call(indexName); + } else if (event.startsWith("end_index_file:")) { + final indexName = event.substring(10); + _onFileIndexEnd?.call(indexName); + } else if (event.startsWith("index_file_error:")) { + final indexName = event.substring(16); + _onFileIndexError?.call(indexName); + } else if (event.startsWith("index_start:")) { + _onIndexStart?.call(); + } else if (event.startsWith("index_end:")) { + _onIndexEnd?.call(); + } else if (event.startsWith("done:")) { + _onDone?.call(); + } else if (event.startsWith("error:")) { + _error = event.substring(5); + if (_onError != null) { + _onError!(_error!); + } + } + }, + onError: (error) { + if (_onError != null) { + _onError!(error.toString()); + } + }, + ); + } + + final RawReceivePort _port = RawReceivePort(); + final StreamController _controller = StreamController.broadcast(); + late StreamSubscription _subscription; + bool _hasStarted = false; + String? _error; + String _text = ""; + + // Callbacks + void Function(String text)? _onData; + void Function(String error)? _onError; + void Function(String messageId)? _onMessageId; + void Function(String indexName)? _onFileIndexStart; + void Function(String indexName)? _onFileIndexEnd; + void Function(String indexName)? _onFileIndexError; + void Function()? _onIndexStart; + void Function()? _onIndexEnd; + void Function()? _onDone; + + int get nativePort => _port.sendPort.nativePort; + bool get hasStarted => _hasStarted; + String? get error => _error; + String get text => _text; + + Future dispose() async { + await _controller.close(); + await _subscription.cancel(); + _port.close(); + } + + void listen({ + void Function(String text)? onData, + void Function(String error)? onError, + void Function(String messageId)? onMessageId, + void Function(String indexName)? onFileIndexStart, + void Function(String indexName)? onFileIndexEnd, + void Function(String indexName)? onFileIndexFail, + void Function()? onIndexStart, + void Function()? onIndexEnd, + void Function()? onDone, + }) { + _onData = onData; + _onError = onError; + _onMessageId = onMessageId; + + _onFileIndexStart = onFileIndexStart; + _onFileIndexEnd = onFileIndexEnd; + _onFileIndexError = onFileIndexFail; + + _onIndexStart = onIndexStart; + _onIndexEnd = onIndexEnd; + _onDone = onDone; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_notification.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_notification.dart new file mode 100644 index 0000000000000..7dc1b550c3070 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_notification.dart @@ -0,0 +1,45 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/notification_helper.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class ChatNotificationParser + extends NotificationParser { + ChatNotificationParser({ + super.id, + required super.callback, + }) : super( + tyParser: (ty, source) => + source == "Chat" ? ChatNotification.valueOf(ty) : null, + errorParser: (bytes) => FlowyError.fromBuffer(bytes), + ); +} + +typedef ChatNotificationHandler = Function( + ChatNotification ty, + FlowyResult result, +); + +class ChatNotificationListener { + ChatNotificationListener({ + required String objectId, + required ChatNotificationHandler handler, + }) : _parser = ChatNotificationParser(id: objectId, callback: handler) { + _subscription = + RustStreamReceiver.listen((observable) => _parser?.parse(observable)); + } + + ChatNotificationParser? _parser; + StreamSubscription? _subscription; + + Future stop() async { + _parser = null; + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_sources_cubit.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_sources_cubit.dart new file mode 100644 index 0000000000000..06ead7d8d8134 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_select_sources_cubit.dart @@ -0,0 +1,391 @@ +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_select_sources_cubit.freezed.dart'; + +const int _kMaxSelectedParentPageCount = 3; + +enum SourceSelectedStatus { + unselected, + selected, + partiallySelected; + + bool get isUnselected => this == unselected; + bool get isSelected => this == selected; + bool get isPartiallySelected => this == partiallySelected; +} + +class ChatSource { + ChatSource({ + required this.view, + required this.parentView, + required this.children, + required bool isExpanded, + required SourceSelectedStatus selectedStatus, + required IgnoreViewType ignoreStatus, + }) : isExpandedNotifier = ValueNotifier(isExpanded), + selectedStatusNotifier = ValueNotifier(selectedStatus), + ignoreStatusNotifier = ValueNotifier(ignoreStatus); + + final ViewPB view; + final ViewPB? parentView; + final List children; + final ValueNotifier isExpandedNotifier; + final ValueNotifier selectedStatusNotifier; + final ValueNotifier ignoreStatusNotifier; + + bool get isExpanded => isExpandedNotifier.value; + SourceSelectedStatus get selectedStatus => selectedStatusNotifier.value; + IgnoreViewType get ignoreStatus => ignoreStatusNotifier.value; + + void toggleIsExpanded() { + isExpandedNotifier.value = !isExpanded; + } + + ChatSource copy() { + return ChatSource( + view: view, + parentView: parentView, + children: children.map((child) => child.copy()).toList(), + ignoreStatus: ignoreStatus, + isExpanded: isExpanded, + selectedStatus: selectedStatus, + ); + } + + ChatSource? findChildBySourceId(String sourceId) { + if (view.id == sourceId) { + return this; + } + for (final child in children) { + final childResult = child.findChildBySourceId(sourceId); + if (childResult != null) { + return childResult; + } + } + return null; + } + + void resetIgnoreViewTypeRecursive() { + ignoreStatusNotifier.value = view.layout.isDocumentView + ? IgnoreViewType.none + : IgnoreViewType.disable; + + for (final child in children) { + child.resetIgnoreViewTypeRecursive(); + } + } + + void updateIgnoreViewTypeRecursive(IgnoreViewType newIgnoreViewType) { + ignoreStatusNotifier.value = newIgnoreViewType; + for (final child in children) { + child.updateIgnoreViewTypeRecursive(newIgnoreViewType); + } + } + + void dispose() { + for (final child in children) { + child.dispose(); + } + isExpandedNotifier.dispose(); + selectedStatusNotifier.dispose(); + ignoreStatusNotifier.dispose(); + } +} + +class ChatSettingsCubit extends Cubit { + ChatSettingsCubit() : super(ChatSettingsState.initial()); + + List selectedSourceIds = []; + ChatSource? source; + List selectedSources = []; + String filter = ''; + + void updateSelectedSources(List newSelectedSourceIds) { + selectedSourceIds = [...newSelectedSourceIds]; + } + + void refreshSources(ViewPB view) async { + filter = ""; + final newSource = await _recursiveBuild(view, null); + + _restrictSelectionIfNecessary(newSource.children); + + final selected = _buildSelectedSources(newSource).toList(); + + newSource.toggleIsExpanded(); + + emit( + state.copyWith( + selectedSources: selected, + visibleSources: [newSource], + ), + ); + + source?.dispose(); + source = newSource.copy(); + + selectedSources + ..forEach((e) => e.dispose()) + ..clear() + ..addAll(selected.map((e) => e.copy())); + } + + Future _recursiveBuild(ViewPB view, ViewPB? parentView) async { + SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected; + final isThisSourceSelected = selectedSourceIds.contains(view.id); + + final childrenViews = + await ViewBackendService.getChildViews(viewId: view.id).toNullable(); + + int selectedCount = 0; + final children = []; + + if (childrenViews != null) { + for (final childView in childrenViews) { + if (childView.layout == ViewLayoutPB.Chat) { + continue; + } + final childChatSource = await _recursiveBuild(childView, view); + if (childChatSource.selectedStatus.isSelected) { + selectedCount++; + } + children.add(childChatSource); + } + + final areAllChildrenSelectedOrNoChildren = + children.length == selectedCount; + final isAnyChildNotUnselected = + children.any((e) => !e.selectedStatus.isUnselected); + + if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) { + selectedStatus = SourceSelectedStatus.selected; + } else if (isThisSourceSelected || isAnyChildNotUnselected) { + selectedStatus = SourceSelectedStatus.partiallySelected; + } + } else if (isThisSourceSelected) { + selectedStatus = SourceSelectedStatus.selected; + } + + return ChatSource( + view: view, + parentView: parentView, + children: children, + ignoreStatus: view.layout.isDocumentView + ? IgnoreViewType.none + : IgnoreViewType.disable, + isExpanded: false, + selectedStatus: selectedStatus, + ); + } + + void _restrictSelectionIfNecessary(List sources) { + for (final source in sources) { + source.resetIgnoreViewTypeRecursive(); + } + if (sources.where((e) => !e.selectedStatus.isUnselected).length >= + _kMaxSelectedParentPageCount) { + sources + .where((e) => e.selectedStatus == SourceSelectedStatus.unselected) + .forEach( + (e) => e.updateIgnoreViewTypeRecursive(IgnoreViewType.disable), + ); + } + } + + void updateFilter(String filter) { + this.filter = filter; + for (final source in state.visibleSources) { + source.dispose(); + } + if (source == null) { + emit(ChatSettingsState.initial()); + } else { + final selected = + selectedSources.map(_buildSearchResults).nonNulls.toList(); + final visible = [_buildSearchResults(source!)].nonNulls.toList(); + emit( + state.copyWith( + selectedSources: selected, + visibleSources: visible, + ), + ); + } + } + + /// traverse tree to build up search query + ChatSource? _buildSearchResults(ChatSource chatSource) { + final isVisible = chatSource.view.nameOrDefault + .toLowerCase() + .contains(filter.toLowerCase()); + + final childrenResults = []; + for (final childSource in chatSource.children) { + final childResult = _buildSearchResults(childSource); + if (childResult != null) { + childrenResults.add(childResult); + } + } + + return isVisible || childrenResults.isNotEmpty + ? ChatSource( + view: chatSource.view, + parentView: chatSource.parentView, + children: childrenResults, + ignoreStatus: chatSource.ignoreStatus, + isExpanded: chatSource.isExpanded, + selectedStatus: chatSource.selectedStatus, + ) + : null; + } + + /// traverse tree to build up selected sources + Iterable _buildSelectedSources(ChatSource chatSource) { + final children = []; + + for (final childSource in chatSource.children) { + children.addAll(_buildSelectedSources(childSource)); + } + + return selectedSourceIds.contains(chatSource.view.id) + ? [ + ChatSource( + view: chatSource.view, + parentView: chatSource.parentView, + children: children, + ignoreStatus: chatSource.ignoreStatus, + selectedStatus: chatSource.selectedStatus, + isExpanded: true, + ), + ] + : children; + } + + void toggleSelectedStatus(ChatSource chatSource) { + if (chatSource.view.isSpace) { + return; + } + final allIds = _recursiveGetSourceIds(chatSource); + + if (chatSource.selectedStatus.isUnselected || + chatSource.selectedStatus.isPartiallySelected && + !chatSource.view.layout.isDocumentView) { + for (final id in allIds) { + if (!selectedSourceIds.contains(id)) { + selectedSourceIds.add(id); + } + } + } else { + for (final id in allIds) { + if (selectedSourceIds.contains(id)) { + selectedSourceIds.remove(id); + } + } + } + + updateSelectedStatus(); + } + + List _recursiveGetSourceIds(ChatSource chatSource) { + return [ + if (chatSource.view.layout.isDocumentView) chatSource.view.id, + for (final childSource in chatSource.children) + ..._recursiveGetSourceIds(childSource), + ]; + } + + void updateSelectedStatus() { + if (source == null) { + return; + } + _recursiveUpdateSelectedStatus(source!); + _restrictSelectionIfNecessary(source!.children); + for (final visibleSource in state.visibleSources) { + visibleSource.dispose(); + } + final visible = [_buildSearchResults(source!)].nonNulls.toList(); + + selectedSources + ..forEach((e) => e.dispose()) + ..clear() + ..addAll(_buildSelectedSources(source!)); + emit( + state.copyWith( + visibleSources: visible, + selectedSources: + selectedSources.map(_buildSearchResults).nonNulls.toList(), + ), + ); + } + + SourceSelectedStatus _recursiveUpdateSelectedStatus(ChatSource chatSource) { + SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected; + + int selectedCount = 0; + for (final childSource in chatSource.children) { + final childStatus = _recursiveUpdateSelectedStatus(childSource); + if (childStatus.isSelected) { + selectedCount++; + } + } + + final isThisSourceSelected = selectedSourceIds.contains(chatSource.view.id); + final areAllChildrenSelectedOrNoChildren = + chatSource.children.length == selectedCount; + final isAnyChildNotUnselected = + chatSource.children.any((e) => !e.selectedStatus.isUnselected); + + if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) { + selectedStatus = SourceSelectedStatus.selected; + } else if (isThisSourceSelected || isAnyChildNotUnselected) { + selectedStatus = SourceSelectedStatus.partiallySelected; + } + + chatSource.selectedStatusNotifier.value = selectedStatus; + return selectedStatus; + } + + void toggleIsExpanded(ChatSource chatSource, bool isSelectedSection) { + chatSource.toggleIsExpanded(); + if (isSelectedSection) { + for (final selectedSource in selectedSources) { + selectedSource + .findChildBySourceId(chatSource.view.id) + ?.toggleIsExpanded(); + } + } else { + source?.findChildBySourceId(chatSource.view.id)?.toggleIsExpanded(); + } + } + + @override + Future close() { + source?.dispose(); + for (final child in state.selectedSources) { + child.dispose(); + } + for (final child in state.visibleSources) { + child.dispose(); + } + return super.close(); + } +} + +@freezed +class ChatSettingsState with _$ChatSettingsState { + const factory ChatSettingsState({ + required List visibleSources, + required List selectedSources, + }) = _ChatSettingsState; + + factory ChatSettingsState.initial() => const ChatSettingsState( + visibleSources: [], + selectedSources: [], + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bloc.dart new file mode 100644 index 0000000000000..bcd3713550e1d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_user_message_bloc.dart @@ -0,0 +1,138 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chat_user_message_bloc.freezed.dart'; + +class ChatUserMessageBloc + extends Bloc { + ChatUserMessageBloc({ + required this.questionStream, + required String text, + }) : super(ChatUserMessageState.initial(text)) { + _dispatch(); + _startListening(); + } + + final QuestionStream? questionStream; + + void _dispatch() { + on( + (event, emit) { + event.when( + updateText: (String text) { + emit(state.copyWith(text: text)); + }, + updateMessageId: (String messageId) { + emit(state.copyWith(messageId: messageId)); + }, + receiveError: (String error) {}, + updateQuestionState: (QuestionMessageState newState) { + emit(state.copyWith(messageState: newState)); + }, + ); + }, + ); + } + + void _startListening() { + questionStream?.listen( + onData: (text) { + if (!isClosed) { + add(ChatUserMessageEvent.updateText(text)); + } + }, + onMessageId: (messageId) { + if (!isClosed) { + add(ChatUserMessageEvent.updateMessageId(messageId)); + } + }, + onError: (error) { + if (!isClosed) { + add(ChatUserMessageEvent.receiveError(error.toString())); + } + }, + onFileIndexStart: (indexName) { + Log.debug("index start: $indexName"); + }, + onFileIndexEnd: (indexName) { + Log.info("index end: $indexName"); + }, + onFileIndexFail: (indexName) { + Log.debug("index fail: $indexName"); + }, + onIndexStart: () { + if (!isClosed) { + add( + const ChatUserMessageEvent.updateQuestionState( + QuestionMessageState.indexStart(), + ), + ); + } + }, + onIndexEnd: () { + if (!isClosed) { + add( + const ChatUserMessageEvent.updateQuestionState( + QuestionMessageState.indexEnd(), + ), + ); + } + }, + onDone: () { + if (!isClosed) { + add( + const ChatUserMessageEvent.updateQuestionState( + QuestionMessageState.finish(), + ), + ); + } + }, + ); + } +} + +@freezed +class ChatUserMessageEvent with _$ChatUserMessageEvent { + const factory ChatUserMessageEvent.updateText(String text) = _UpdateText; + const factory ChatUserMessageEvent.updateQuestionState( + QuestionMessageState newState, + ) = _UpdateQuestionState; + const factory ChatUserMessageEvent.updateMessageId(String messageId) = + _UpdateMessageId; + const factory ChatUserMessageEvent.receiveError(String error) = _ReceiveError; +} + +@freezed +class ChatUserMessageState with _$ChatUserMessageState { + const factory ChatUserMessageState({ + required String text, + required String? messageId, + required QuestionMessageState messageState, + }) = _ChatUserMessageState; + + factory ChatUserMessageState.initial(String message) => ChatUserMessageState( + text: message, + messageId: null, + messageState: const QuestionMessageState.finish(), + ); +} + +@freezed +class QuestionMessageState with _$QuestionMessageState { + const factory QuestionMessageState.indexFileStart(String fileName) = + _IndexFileStart; + const factory QuestionMessageState.indexFileEnd(String fileName) = + _IndexFileEnd; + const factory QuestionMessageState.indexFileFail(String fileName) = + _IndexFileFail; + + const factory QuestionMessageState.indexStart() = _IndexStart; + const factory QuestionMessageState.indexEnd() = _IndexEnd; + const factory QuestionMessageState.finish() = _Finish; +} + +extension QuestionMessageStateX on QuestionMessageState { + bool get isFinish => this is _Finish; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart new file mode 100644 index 0000000000000..72e6b8256a816 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat.dart @@ -0,0 +1,137 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/ai_chat/chat_page.dart'; +import 'package:appflowy/plugins/util.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class AIChatPluginBuilder extends PluginBuilder { + @override + Plugin build(dynamic data) { + if (data is ViewPB) { + return AIChatPagePlugin(view: data); + } + + throw FlowyPluginException.invalidData; + } + + @override + String get menuName => "AI Chat"; + + @override + FlowySvgData get icon => FlowySvgs.chat_ai_page_s; + + @override + PluginType get pluginType => PluginType.chat; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Chat; +} + +class AIChatPluginConfig implements PluginConfig { + @override + bool get creatable => true; +} + +class AIChatPagePlugin extends Plugin { + AIChatPagePlugin({ + required ViewPB view, + }) : notifier = ViewPluginNotifier(view: view); + + late final ViewInfoBloc _viewInfoBloc; + + @override + final ViewPluginNotifier notifier; + + @override + PluginWidgetBuilder get widgetBuilder => AIChatPagePluginWidgetBuilder( + bloc: _viewInfoBloc, + notifier: notifier, + ); + + @override + PluginId get id => notifier.view.id; + + @override + PluginType get pluginType => PluginType.chat; + + @override + void init() { + _viewInfoBloc = ViewInfoBloc(view: notifier.view) + ..add(const ViewInfoEvent.started()); + } + + @override + void dispose() { + _viewInfoBloc.close(); + notifier.dispose(); + } +} + +class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder + with NavigationItem { + AIChatPagePluginWidgetBuilder({ + required this.bloc, + required this.notifier, + }); + + final ViewInfoBloc bloc; + final ViewPluginNotifier notifier; + int? deletedViewIndex; + + @override + String? get viewName => notifier.view.nameOrDefault; + + @override + Widget get leftBarItem => + ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view); + + @override + Widget tabBarItem(String pluginId, [bool shortForm = false]) => + ViewTabBarItem(view: notifier.view, shortForm: shortForm); + + @override + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + Map? data, + }) { + notifier.isDeleted.addListener(_onDeleted); + + if (context.userProfile == null) { + Log.error("User profile is null when opening AI Chat plugin"); + return const SizedBox(); + } + + return BlocProvider.value( + value: bloc, + child: AIChatPage( + userProfile: context.userProfile!, + key: ValueKey(notifier.view.id), + view: notifier.view, + onDeleted: () => + context.onDeleted?.call(notifier.view, deletedViewIndex), + ), + ); + } + + void _onDeleted() { + final deletedView = notifier.isDeleted.value; + if (deletedView != null && deletedView.hasIndex()) { + deletedViewIndex = deletedView.index; + } + } + + @override + List get navigationItems => [this]; + + @override + EdgeInsets get contentPadding => EdgeInsets.zero; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart new file mode 100644 index 0000000000000..dc6d9c649ce7a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/chat_page.dart @@ -0,0 +1,359 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart' + hide ChatAnimatedListReversed; +import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'application/ai_prompt_input_bloc.dart'; +import 'application/chat_bloc.dart'; +import 'application/chat_entity.dart'; +import 'application/chat_member_bloc.dart'; +import 'application/chat_message_stream.dart'; +import 'presentation/animated_chat_list.dart'; +import 'presentation/chat_input/desktop_ai_prompt_input.dart'; +import 'presentation/chat_input/mobile_ai_prompt_input.dart'; +import 'presentation/chat_related_question.dart'; +import 'presentation/chat_welcome_page.dart'; +import 'presentation/layout_define.dart'; +import 'presentation/message/ai_text_message.dart'; +import 'presentation/message/error_text_message.dart'; +import 'presentation/message/message_util.dart'; +import 'presentation/message/user_text_message.dart'; +import 'presentation/scroll_to_bottom.dart'; + +class AIChatPage extends StatelessWidget { + const AIChatPage({ + super.key, + required this.view, + required this.onDeleted, + required this.userProfile, + }); + + final ViewPB view; + final VoidCallback onDeleted; + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + if (userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) { + return Center( + child: FlowyText( + LocaleKeys.chat_unsupportedCloudPrompt.tr(), + fontSize: 20, + ), + ); + } + + return MultiBlocProvider( + providers: [ + /// [ChatBloc] is used to handle chat messages including send/receive message + BlocProvider( + create: (_) => ChatBloc( + chatId: view.id, + userId: userProfile.id.toString(), + ), + ), + + /// [AIPromptInputBloc] is used to handle the user prompt + BlocProvider(create: (_) => AIPromptInputBloc()), + BlocProvider(create: (_) => ChatMemberBloc()), + ], + child: Builder( + builder: (context) { + return DropTarget( + onDragDone: (DropDoneDetails detail) async { + if (context.read().state.supportChatWithFile) { + for (final file in detail.files) { + context + .read() + .add(AIPromptInputEvent.attachFile(file.path, file.name)); + } + } + }, + child: _ChatContentPage( + view: view, + userProfile: userProfile, + ), + ); + }, + ), + ); + } +} + +class _ChatContentPage extends StatelessWidget { + const _ChatContentPage({ + required this.view, + required this.userProfile, + }); + + final UserProfilePB userProfile; + final ViewPB view; + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 784), + margin: UniversalPlatform.isDesktop + ? const EdgeInsets.symmetric(horizontal: 60.0) + : null, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: BlocBuilder( + builder: (context, state) { + return switch (state.loadingState) { + LoadChatMessageStatus.ready => Column( + children: [ + Expanded( + child: Chat( + chatController: + context.read().chatController, + user: User(id: userProfile.id.toString()), + darkTheme: ChatTheme.fromThemeData(Theme.of(context)), + theme: ChatTheme.fromThemeData(Theme.of(context)), + builders: Builders( + inputBuilder: (_) => const SizedBox.shrink(), + textMessageBuilder: _buildTextMessage, + chatMessageBuilder: _buildChatMessage, + scrollToBottomBuilder: _buildScrollToBottom, + chatAnimatedListBuilder: _buildChatAnimatedList, + ), + ), + ), + _buildInput(context), + ], + ), + _ => const Center(child: CircularProgressIndicator.adaptive()), + }; + }, + ), + ), + ), + ); + } + + Widget _buildTextMessage( + BuildContext context, + TextMessage message, + ) { + final messageType = onetimeMessageTypeFromMeta( + message.metadata, + ); + + if (messageType == OnetimeShotType.error) { + return ChatErrorMessageWidget( + errorMessage: message.metadata?[errorMessageTextKey] ?? "", + ); + } + + if (messageType == OnetimeShotType.relatedQuestion) { + return RelatedQuestionList( + relatedQuestions: message.metadata!['questions'], + onQuestionSelected: (question) { + context + .read() + .add(ChatEvent.sendMessage(message: question)); + }, + ); + } + + if (message.author.id == userProfile.id.toString()) { + return ChatUserMessageWidget( + user: message.author, + message: message, + isCurrentUser: true, + ); + } + + if (isOtherUserMessage(message)) { + return ChatUserMessageWidget( + user: message.author, + message: message, + isCurrentUser: false, + ); + } + + final stream = message.metadata?["$AnswerStream"]; + final questionId = message.metadata?[messageQuestionIdKey]; + final refSourceJsonString = + message.metadata?[messageRefSourceJsonStringKey] as String?; + + return BlocBuilder( + builder: (context, state) { + final chatController = context.read().chatController; + final messages = chatController.messages + .where((e) => onetimeMessageTypeFromMeta(e.metadata) == null); + final isLastMessage = + messages.isEmpty ? false : messages.last.id == message.id; + return ChatAIMessageWidget( + user: message.author, + messageUserId: message.id, + message: message, + stream: stream is AnswerStream ? stream : null, + questionId: questionId, + chatId: view.id, + refSourceJsonString: refSourceJsonString, + isStreaming: state.promptResponseState != PromptResponseState.ready, + isLastMessage: isLastMessage, + onSelectedMetadata: (metadata) => + _onSelectMetadata(context, metadata), + onRegenerate: () => context + .read() + .add(ChatEvent.regenerateAnswer(message.id)), + ); + }, + ); + } + + Widget _buildChatMessage( + BuildContext context, + Message message, + Animation animation, + Widget child, + ) { + return ChatMessage( + message: message, + animation: animation, + padding: const EdgeInsets.symmetric(vertical: 12.0), + receivedMessageScaleAnimationAlignment: Alignment.center, + child: child, + ); + } + + Widget _buildScrollToBottom( + BuildContext context, + Animation animation, + VoidCallback onPressed, + ) { + return CustomScrollToBottom( + animation: animation, + onPressed: onPressed, + ); + } + + Widget _buildChatAnimatedList( + BuildContext context, + ScrollController scrollController, + ChatItem itemBuilder, + ) { + final bloc = context.read(); + + if (bloc.chatController.messages.isEmpty) { + return ChatWelcomePage( + userProfile: userProfile, + onSelectedQuestion: (question) { + bloc.add(ChatEvent.sendMessage(message: question)); + }, + ); + } + + return ChatAnimatedListReversed( + scrollController: scrollController, + itemBuilder: itemBuilder, + onLoadPreviousMessages: () { + bloc.add(const ChatEvent.loadPreviousMessages()); + }, + ); + } + + Widget _buildInput(BuildContext context) { + return Padding( + padding: AIChatUILayout.safeAreaInsets(context), + child: BlocSelector( + selector: (state) { + return state.promptResponseState == PromptResponseState.ready; + }, + builder: (context, canSendMessage) { + final chatBloc = context.read(); + + return UniversalPlatform.isDesktop + ? DesktopAIPromptInput( + chatId: view.id, + isStreaming: !canSendMessage, + onStopStreaming: () { + chatBloc.add(const ChatEvent.stopStream()); + }, + onSubmitted: (text, metadata) { + chatBloc.add( + ChatEvent.sendMessage( + message: text, + metadata: metadata, + ), + ); + }, + onUpdateSelectedSources: (ids) { + chatBloc.add( + ChatEvent.updateSelectedSources( + selectedSourcesIds: ids, + ), + ); + }, + ) + : MobileAIPromptInput( + chatId: view.id, + isStreaming: !canSendMessage, + onStopStreaming: () { + chatBloc.add(const ChatEvent.stopStream()); + }, + onSubmitted: (text, metadata) { + chatBloc.add( + ChatEvent.sendMessage( + message: text, + metadata: metadata, + ), + ); + }, + onUpdateSelectedSources: (ids) { + chatBloc.add( + ChatEvent.updateSelectedSources( + selectedSourcesIds: ids, + ), + ); + }, + ); + }, + ), + ); + } + + void _onSelectMetadata( + BuildContext context, + ChatMessageRefSource metadata, + ) async { + if (isURL(metadata.name)) { + late Uri uri; + try { + uri = Uri.parse(metadata.name); + // `Uri` identifies `localhost` as a scheme + if (!uri.hasScheme || uri.scheme == 'localhost') { + uri = Uri.parse("http://${metadata.name}"); + await InternetAddress.lookup(uri.host); + } + await launchUrl(uri); + } catch (err) { + Log.error("failed to open url $err"); + } + } else { + final sidebarView = + await ViewBackendService.getView(metadata.id).toNullable(); + if (context.mounted) { + openPageFromMessage(context, sidebarView); + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/animated_chat_list.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/animated_chat_list.dart new file mode 100644 index 0000000000000..e9d7b940b3005 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/animated_chat_list.dart @@ -0,0 +1,370 @@ +// ignore_for_file: implementation_imports + +import 'dart:async'; +import 'dart:math'; + +import 'package:diffutil_dart/diffutil.dart' as diffutil; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:provider/provider.dart'; + +import 'package:flutter_chat_ui/src/scroll_to_bottom.dart'; +import 'package:flutter_chat_ui/src/utils/message_list_diff.dart'; + +class ChatAnimatedListReversed extends StatefulWidget { + const ChatAnimatedListReversed({ + super.key, + required this.scrollController, + required this.itemBuilder, + this.insertAnimationDuration = const Duration(milliseconds: 250), + this.removeAnimationDuration = const Duration(milliseconds: 250), + this.scrollToEndAnimationDuration = const Duration(milliseconds: 250), + this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250), + this.bottomPadding = 8, + this.onLoadPreviousMessages, + }); + + final ScrollController scrollController; + final ChatItem itemBuilder; + final Duration insertAnimationDuration; + final Duration removeAnimationDuration; + final Duration scrollToEndAnimationDuration; + final Duration scrollToBottomAppearanceDelay; + final double? bottomPadding; + final VoidCallback? onLoadPreviousMessages; + + @override + ChatAnimatedListReversedState createState() => + ChatAnimatedListReversedState(); +} + +class ChatAnimatedListReversedState extends State + with SingleTickerProviderStateMixin { + final GlobalKey _listKey = GlobalKey(); + late ChatController _chatController; + late List _oldList; + late StreamSubscription _operationsSubscription; + + late final AnimationController _scrollToBottomController; + late final Animation _scrollToBottomAnimation; + Timer? _scrollToBottomShowTimer; + + bool _userHasScrolled = false; + bool _isScrollingToBottom = false; + String _lastInsertedMessageId = ''; + + @override + void initState() { + super.initState(); + _chatController = Provider.of(context, listen: false); + // TODO: Add assert for messages having same id + _oldList = List.from(_chatController.messages); + _operationsSubscription = _chatController.operationsStream.listen((event) { + switch (event.type) { + case ChatOperationType.insert: + assert( + event.index != null, + 'Index must be provided when inserting a message.', + ); + assert( + event.message != null, + 'Message must be provided when inserting a message.', + ); + _onInserted(0, event.message!); + _oldList = List.from(_chatController.messages); + break; + case ChatOperationType.remove: + assert( + event.index != null, + 'Index must be provided when removing a message.', + ); + assert( + event.message != null, + 'Message must be provided when removing a message.', + ); + _onRemoved(event.index!, event.message!); + _oldList = List.from(_chatController.messages); + break; + case ChatOperationType.set: + final newList = _chatController.messages; + + final updates = diffutil + .calculateDiff( + MessageListDiff(_oldList, newList), + ) + .getUpdatesWithData(); + + for (var i = updates.length - 1; i >= 0; i--) { + _onDiffUpdate(updates.elementAt(i)); + } + + _oldList = List.from(newList); + break; + default: + break; + } + }); + + _scrollToBottomController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _scrollToBottomAnimation = CurvedAnimation( + parent: _scrollToBottomController, + curve: Curves.easeInOut, + ); + + widget.scrollController.addListener(_handleLoadPreviousMessages); + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleLoadPreviousMessages(); + }); + } + + @override + void dispose() { + super.dispose(); + _scrollToBottomShowTimer?.cancel(); + _scrollToBottomController.dispose(); + _operationsSubscription.cancel(); + widget.scrollController.removeListener(_handleLoadPreviousMessages); + } + + @override + Widget build(BuildContext context) { + final builders = context.watch(); + + return NotificationListener( + onNotification: (notification) { + if (notification is UserScrollNotification) { + // When user scrolls up, save it to `_userHasScrolled` + if (notification.direction == ScrollDirection.reverse) { + _userHasScrolled = true; + } else { + // When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false + if (notification.metrics.pixels == + notification.metrics.minScrollExtent) { + _userHasScrolled = false; + } + } + } + + if (notification is ScrollUpdateNotification) { + _handleToggleScrollToBottom(); + } + + // Allow other listeners to get the notification + return false; + }, + child: Stack( + children: [ + CustomScrollView( + reverse: true, + controller: widget.scrollController, + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + top: widget.bottomPadding ?? 0, + ), + ), + SliverAnimatedList( + key: _listKey, + initialItemCount: _chatController.messages.length, + itemBuilder: ( + BuildContext context, + int index, + Animation animation, + ) { + final message = _chatController.messages[ + max(_chatController.messages.length - 1 - index, 0)]; + return widget.itemBuilder( + context, + animation, + message, + ); + }, + ), + ], + ), + builders.scrollToBottomBuilder?.call( + context, + _scrollToBottomAnimation, + _handleScrollToBottom, + ) ?? + ScrollToBottom( + animation: _scrollToBottomAnimation, + onPressed: _handleScrollToBottom, + ), + ], + ), + ); + } + + void _subsequentScrollToEnd(Message data) async { + final user = Provider.of(context, listen: false); + + // We only want to scroll to the bottom if user has not scrolled up + // or if the message is sent by the current user. + if (data.id == _lastInsertedMessageId && + widget.scrollController.offset > + widget.scrollController.position.minScrollExtent && + (user.id == data.author.id || !_userHasScrolled)) { + if (widget.scrollToEndAnimationDuration == Duration.zero) { + widget.scrollController + .jumpTo(widget.scrollController.position.minScrollExtent); + } else { + await widget.scrollController.animateTo( + widget.scrollController.position.minScrollExtent, + duration: widget.scrollToEndAnimationDuration, + curve: Curves.linearToEaseOut, + ); + } + + if (!widget.scrollController.hasClients || !mounted) return; + + // Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768 + // we need an additional jump to the end. Sometimes Flutter + // will not scroll to the very end. Sometimes it will not scroll to the + // very end even with this, so this is something that needs to be + // addressed by the Flutter team. + // + // Additionally here we have a check for the message id, because + // if new message arrives in the meantime it will trigger another + // scroll to the end animation, making this logic redundant. + if (data.id == _lastInsertedMessageId && + widget.scrollController.offset > + widget.scrollController.position.minScrollExtent && + (user.id == data.author.id || !_userHasScrolled)) { + widget.scrollController + .jumpTo(widget.scrollController.position.minScrollExtent); + } + } + } + + void _scrollToEnd(Message data) { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + if (!widget.scrollController.hasClients || !mounted) return; + + _subsequentScrollToEnd(data); + }, + ); + } + + void _handleScrollToBottom() { + _isScrollingToBottom = true; + _scrollToBottomController.reverse(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!widget.scrollController.hasClients || !mounted) return; + + if (widget.scrollToEndAnimationDuration == Duration.zero) { + widget.scrollController + .jumpTo(widget.scrollController.position.minScrollExtent); + } else { + await widget.scrollController.animateTo( + widget.scrollController.position.minScrollExtent, + duration: widget.scrollToEndAnimationDuration, + curve: Curves.linearToEaseOut, + ); + } + + if (!widget.scrollController.hasClients || !mounted) return; + + if (widget.scrollController.offset < + widget.scrollController.position.minScrollExtent) { + widget.scrollController.jumpTo( + widget.scrollController.position.minScrollExtent, + ); + } + + _isScrollingToBottom = false; + }); + } + + void _handleToggleScrollToBottom() { + if (_isScrollingToBottom) { + return; + } + + _scrollToBottomShowTimer?.cancel(); + if (widget.scrollController.offset > + widget.scrollController.position.minScrollExtent) { + _scrollToBottomShowTimer = + Timer(widget.scrollToBottomAppearanceDelay, () { + if (mounted) { + _scrollToBottomController.forward(); + } + }); + } else { + if (_scrollToBottomController.status != AnimationStatus.completed) { + _scrollToBottomController.stop(); + } + _scrollToBottomController.reverse(); + } + } + + void _onInserted(final int position, final Message data) { + // There is a scroll notification listener the controls the + // `_userHasScrolled` variable. + // + // If for some reason `_userHasScrolled` is true and the user is not at the + // bottom of the list, set `_userHasScrolled` to false so that the scroll + // animation is triggered. + if (_userHasScrolled && + widget.scrollController.offset >= + widget.scrollController.position.minScrollExtent) { + _userHasScrolled = false; + } + + _listKey.currentState!.insertItem( + position, + duration: widget.insertAnimationDuration, + ); + + // Used later to trigger scroll to end only for the last inserted message. + _lastInsertedMessageId = data.id; + + if (position == _oldList.length) { + _scrollToEnd(data); + } + } + + void _onRemoved(final int position, final Message data) { + final visualPosition = max(_oldList.length - position - 1, 0); + _listKey.currentState!.removeItem( + visualPosition, + (context, animation) => widget.itemBuilder( + context, + animation, + data, + isRemoved: true, + ), + duration: widget.removeAnimationDuration, + ); + } + + void _onChanged(int position, Message oldData, Message newData) { + _onRemoved(position, oldData); + _listKey.currentState!.insertItem( + max(_oldList.length - position - 1, 0), + duration: widget.insertAnimationDuration, + ); + } + + void _onDiffUpdate(diffutil.DataDiffUpdate update) { + update.when( + insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data), + remove: (pos, data) => _onRemoved(pos, data), + change: (pos, oldData, newData) => _onChanged(pos, oldData, newData), + move: (_, __, ___) => throw UnimplementedError('unused'), + ); + } + + void _handleLoadPreviousMessages() { + if (widget.scrollController.offset >= + widget.scrollController.position.maxScrollExtent) { + widget.onLoadPreviousMessages?.call(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart new file mode 100644 index 0000000000000..d9d4594240f6e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_avatar.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/built_in_svgs.dart'; +import 'package:appflowy/util/color_generator/color_generator.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:string_validator/string_validator.dart'; + +import 'layout_define.dart'; + +class ChatAIAvatar extends StatelessWidget { + const ChatAIAvatar({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: DesktopAIConvoSizes.avatarSize, + height: DesktopAIConvoSizes.avatarSize, + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(shape: BoxShape.circle), + foregroundDecoration: ShapeDecoration( + shape: CircleBorder( + side: BorderSide(color: Theme.of(context).colorScheme.outline), + ), + ), + child: const CircleAvatar( + backgroundColor: Colors.transparent, + child: FlowySvg( + FlowySvgs.flowy_logo_s, + size: Size.square(16), + blendMode: null, + ), + ), + ); + } +} + +class ChatUserAvatar extends StatelessWidget { + const ChatUserAvatar({ + super.key, + required this.iconUrl, + required this.name, + this.defaultName, + }); + + final String iconUrl; + final String name; + final String? defaultName; + + @override + Widget build(BuildContext context) { + late final Widget child; + if (iconUrl.isEmpty) { + child = _buildEmptyAvatar(context); + } else if (isURL(iconUrl)) { + child = _buildUrlAvatar(context); + } else { + child = _buildEmojiAvatar(context); + } + return Container( + width: DesktopAIConvoSizes.avatarSize, + height: DesktopAIConvoSizes.avatarSize, + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(shape: BoxShape.circle), + foregroundDecoration: ShapeDecoration( + shape: CircleBorder( + side: BorderSide(color: Theme.of(context).colorScheme.outline), + ), + ), + child: child, + ); + } + + Widget _buildEmptyAvatar(BuildContext context) { + final String nameOrDefault = _userName(name, defaultName); + + final Color color = ColorGenerator(name).toColor(); + const initialsCount = 2; + + // Taking the first letters of the name components and limiting to 2 elements + final nameInitials = nameOrDefault + .split(' ') + .where((element) => element.isNotEmpty) + .take(initialsCount) + .map((element) => element[0].toUpperCase()) + .join(); + + return ColoredBox( + color: color, + child: Center( + child: FlowyText.regular( + nameInitials, + color: Colors.black, + ), + ), + ); + } + + Widget _buildUrlAvatar(BuildContext context) { + return CircleAvatar( + backgroundColor: Colors.transparent, + radius: DesktopAIConvoSizes.avatarSize / 2, + child: Image.network( + iconUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _buildEmptyAvatar(context), + ), + ); + } + + Widget _buildEmojiAvatar(BuildContext context) { + return CircleAvatar( + backgroundColor: Colors.transparent, + radius: DesktopAIConvoSizes.avatarSize / 2, + child: builtInSVGIcons.contains(iconUrl) + ? FlowySvg( + FlowySvgData('emoji/$iconUrl'), + blendMode: null, + ) + : FlowyText.emoji( + iconUrl, + fontSize: 24, // cannot reduce + optimizeEmojiAlign: true, + ), + ); + } + + /// Return the user name. + /// + /// If the user name is empty, return the default user name. + String _userName(String name, String? defaultName) => + name.isEmpty ? (defaultName ?? LocaleKeys.defaultUsername.tr()) : name; +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_editor_style.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_editor_style.dart new file mode 100644 index 0000000000000..a979e80746e5c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_editor_style.dart @@ -0,0 +1,150 @@ +// ref appflowy_flutter/lib/plugins/document/presentation/editor_style.dart + +// diff: +// - text style +// - heading text style and padding builders +// - don't listen to document appearance cubit +// + +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/workspace/application/appearance_defaults.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class ChatEditorStyleCustomizer extends EditorStyleCustomizer { + ChatEditorStyleCustomizer({ + required super.context, + required super.padding, + super.width, + }); + + @override + EditorStyle desktop() { + final theme = Theme.of(context); + final afThemeExtension = AFThemeExtension.of(context); + final appearanceFont = context.read().state.font; + final appearance = context.read().state; + const fontSize = 14.0; + String fontFamily = appearance.fontFamily; + if (fontFamily.isEmpty && appearanceFont.isNotEmpty) { + fontFamily = appearanceFont; + } + + return EditorStyle.desktop( + padding: padding, + maxWidth: width, + cursorColor: appearance.cursorColor ?? + DefaultAppearanceSettings.getDefaultCursorColor(context), + selectionColor: appearance.selectionColor ?? + DefaultAppearanceSettings.getDefaultSelectionColor(context), + defaultTextDirection: appearance.defaultTextDirection, + textStyleConfiguration: TextStyleConfiguration( + lineHeight: 20 / 14, + applyHeightToFirstAscent: true, + applyHeightToLastDescent: true, + text: baseTextStyle(fontFamily).copyWith( + fontSize: fontSize, + color: afThemeExtension.onBackground, + ), + bold: baseTextStyle(fontFamily, fontWeight: FontWeight.bold).copyWith( + fontWeight: FontWeight.w600, + ), + italic: baseTextStyle(fontFamily).copyWith(fontStyle: FontStyle.italic), + underline: baseTextStyle(fontFamily).copyWith( + decoration: TextDecoration.underline, + ), + strikethrough: baseTextStyle(fontFamily).copyWith( + decoration: TextDecoration.lineThrough, + ), + href: baseTextStyle(fontFamily).copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + ), + code: GoogleFonts.robotoMono( + textStyle: baseTextStyle(fontFamily).copyWith( + fontSize: fontSize, + fontWeight: FontWeight.normal, + color: Colors.red, + backgroundColor: theme.colorScheme.inverseSurface.withOpacity(0.8), + ), + ), + ), + textSpanDecorator: customizeAttributeDecorator, + textScaleFactor: + context.watch().state.textScaleFactor, + ); + } + + @override + TextStyle headingStyleBuilder(int level) { + final String? fontFamily; + final List fontSizes; + const fontSize = 14.0; + + fontFamily = context.read().state.fontFamily; + fontSizes = [ + fontSize + 12, + fontSize + 10, + fontSize + 6, + fontSize + 2, + fontSize, + ]; + return baseTextStyle(fontFamily, fontWeight: FontWeight.w600).copyWith( + fontSize: fontSizes.elementAtOrNull(level - 1) ?? fontSize, + ); + } + + @override + CodeBlockStyle codeBlockStyleBuilder() { + final fontFamily = + context.read().state.codeFontFamily; + + return CodeBlockStyle( + textStyle: baseTextStyle(fontFamily).copyWith( + height: 1.4, + color: AFThemeExtension.of(context).onBackground, + ), + backgroundColor: AFThemeExtension.of(context).calloutBGColor, + foregroundColor: AFThemeExtension.of(context).textColor.withAlpha(155), + wrapLines: true, + ); + } + + @override + TextStyle calloutBlockStyleBuilder() { + if (UniversalPlatform.isMobile) { + final afThemeExtension = AFThemeExtension.of(context); + final pageStyle = context.read().state; + final fontFamily = pageStyle.fontFamily ?? defaultFontFamily; + final baseTextStyle = this.baseTextStyle(fontFamily); + return baseTextStyle.copyWith( + color: afThemeExtension.onBackground, + ); + } else { + final fontSize = context.read().state.fontSize; + return baseTextStyle(null).copyWith( + fontSize: fontSize, + height: 1.5, + ); + } + } + + @override + TextStyle outlineBlockPlaceholderStyleBuilder() { + return TextStyle( + fontFamily: defaultFontFamily, + height: 1.5, + color: AFThemeExtension.of(context).onBackground.withOpacity(0.6), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/ai_prompt_buttons.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/ai_prompt_buttons.dart new file mode 100644 index 0000000000000..7d0fc10e3fe96 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/ai_prompt_buttons.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; + +import '../layout_define.dart'; + +enum SendButtonState { enabled, streaming, disabled } + +class PromptInputAttachmentButton extends StatelessWidget { + const PromptInputAttachmentButton({required this.onTap, super.key}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_uploadFile.tr(), + child: SizedBox.square( + dimension: DesktopAIPromptSizes.actionBarButtonSize, + child: FlowyIconButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: BorderRadius.circular(8), + icon: FlowySvg( + FlowySvgs.ai_attachment_s, + size: const Size.square(16), + color: Theme.of(context).iconTheme.color, + ), + onPressed: onTap, + ), + ), + ); + } +} + +class PromptInputMentionButton extends StatelessWidget { + const PromptInputMentionButton({ + super.key, + required this.buttonSize, + required this.iconSize, + required this.onTap, + }); + + final double buttonSize; + final double iconSize; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_clickToMention.tr(), + preferBelow: false, + child: FlowyIconButton( + width: buttonSize, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: BorderRadius.circular(8), + icon: FlowySvg( + FlowySvgs.chat_at_s, + size: Size.square(iconSize), + color: Theme.of(context).iconTheme.color, + ), + onPressed: onTap, + ), + ); + } +} + +class PromptInputSendButton extends StatelessWidget { + const PromptInputSendButton({ + super.key, + required this.buttonSize, + required this.iconSize, + required this.state, + required this.onSendPressed, + required this.onStopStreaming, + }); + + final double buttonSize; + final double iconSize; + final SendButtonState state; + final VoidCallback onSendPressed; + final VoidCallback onStopStreaming; + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + width: buttonSize, + icon: switch (state) { + SendButtonState.enabled => FlowySvg( + FlowySvgs.ai_send_filled_s, + size: Size.square(iconSize), + color: Theme.of(context).colorScheme.primary, + ), + SendButtonState.disabled => FlowySvg( + FlowySvgs.ai_send_filled_s, + size: Size.square(iconSize), + color: Theme.of(context).disabledColor, + ), + SendButtonState.streaming => FlowySvg( + FlowySvgs.ai_stop_filled_s, + size: Size.square(iconSize), + color: Theme.of(context).colorScheme.primary, + ), + }, + onPressed: () { + switch (state) { + case SendButtonState.enabled: + onSendPressed(); + break; + case SendButtonState.streaming: + onStopStreaming(); + break; + case SendButtonState.disabled: + break; + } + }, + hoverColor: Colors.transparent, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_file.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_file.dart new file mode 100644 index 0000000000000..d2f6f6750e8e0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_file.dart @@ -0,0 +1,167 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_file_bloc.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:styled_widget/styled_widget.dart'; + +import '../layout_define.dart'; + +class ChatInputFile extends StatelessWidget { + const ChatInputFile({ + super.key, + required this.chatId, + required this.onDeleted, + }); + + final String chatId; + final void Function(ChatFile) onDeleted; + + @override + Widget build(BuildContext context) { + return BlocSelector>( + selector: (state) => state.attachedFiles, + builder: (context, files) { + if (files.isEmpty) { + return const SizedBox.shrink(); + } + return ListView.separated( + scrollDirection: Axis.horizontal, + padding: DesktopAIPromptSizes.attachedFilesBarPadding - + const EdgeInsets.only(top: 6), + separatorBuilder: (context, index) => const HSpace( + DesktopAIPromptSizes.attachedFilesPreviewSpacing - 6, + ), + itemCount: files.length, + itemBuilder: (context, index) => ChatFilePreview( + chatId: chatId, + file: files[index], + onDeleted: () => onDeleted(files[index]), + ), + ); + }, + ); + } +} + +class ChatFilePreview extends StatefulWidget { + const ChatFilePreview({ + required this.chatId, + required this.file, + required this.onDeleted, + super.key, + }); + + final String chatId; + final ChatFile file; + final VoidCallback onDeleted; + + @override + State createState() => _ChatFilePreviewState(); +} + +class _ChatFilePreviewState extends State { + bool isHover = false; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => ChatInputFileBloc(file: widget.file), + child: BlocBuilder( + builder: (context, state) { + return MouseRegion( + onEnter: (_) => setHover(true), + onExit: (_) => setHover(false), + child: Stack( + children: [ + Container( + margin: const EdgeInsetsDirectional.only(top: 6, end: 6), + constraints: const BoxConstraints(maxWidth: 240), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).dividerColor, + ), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration( + color: AFThemeExtension.of(context).tint1, + borderRadius: BorderRadius.circular(8), + ), + height: 32, + width: 32, + child: Center( + child: FlowySvg( + FlowySvgs.page_m, + size: const Size.square(16), + color: Theme.of(context).hintColor, + ), + ), + ), + const HSpace(8), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlowyText( + widget.file.fileName, + fontSize: 12.0, + ), + FlowyText( + widget.file.fileType.name, + color: Theme.of(context).hintColor, + fontSize: 12.0, + ), + ], + ), + ), + ], + ), + ), + if (isHover) + _CloseButton( + onTap: widget.onDeleted, + ).positioned(top: 0, right: 0), + ], + ), + ); + }, + ), + ); + } + + void setHover(bool value) { + if (value != isHover) { + setState(() => isHover = value); + } + } +} + +class _CloseButton extends StatelessWidget { + const _CloseButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap, + child: FlowySvg( + FlowySvgs.ai_close_filled_s, + color: AFThemeExtension.of(context).greyHover, + size: const Size.square(16), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_span.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_span.dart new file mode 100644 index 0000000000000..06d8248048e30 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_input_span.dart @@ -0,0 +1,78 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:extended_text_library/extended_text_library.dart'; +import 'package:flutter/material.dart'; + +class ChatInputTextSpanBuilder extends SpecialTextSpanBuilder { + ChatInputTextSpanBuilder({ + required this.inputControlCubit, + this.specialTextStyle, + }); + + final ChatInputControlCubit inputControlCubit; + final TextStyle? specialTextStyle; + + @override + SpecialText? createSpecialText( + String flag, { + TextStyle? textStyle, + SpecialTextGestureTapCallback? onTap, + int? index, + }) { + if (flag == '') { + return null; + } + + if (!isStart(flag, AtText.flag)) { + return null; + } + + // index is at the end of the start flag, so the start index should be index - (flag.length - 1) + return AtText( + inputControlCubit, + specialTextStyle ?? textStyle, + onTap, + // scrubbing over text is kinda funky + start: index! - (AtText.flag.length - 1), + ); + } +} + +class AtText extends SpecialText { + AtText( + this.inputControlCubit, + TextStyle? textStyle, + SpecialTextGestureTapCallback? onTap, { + this.start, + }) : super(flag, '', textStyle, onTap: onTap); + + static const String flag = '@'; + + final int? start; + final ChatInputControlCubit inputControlCubit; + + @override + bool isEnd(String value) => inputControlCubit.selectedViewIds.contains(value); + + @override + InlineSpan finishText() { + final String actualText = toString(); + + final viewName = inputControlCubit.allViews + .firstWhereOrNull((view) => view.id == actualText.substring(1)) + ?.name ?? + ""; + final nonEmptyName = viewName.isEmpty + ? LocaleKeys.document_title_placeholder.tr() + : viewName; + + return SpecialTextSpan( + text: "@$nonEmptyName", + actualText: actualText, + start: start!, + style: textStyle, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_mention_page_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_mention_page_bottom_sheet.dart new file mode 100644 index 0000000000000..cb2e7248d4bbb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_mention_page_bottom_sheet.dart @@ -0,0 +1,204 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'chat_mention_page_menu.dart'; + +Future showPageSelectorSheet( + BuildContext context, { + required bool Function(ViewPB view) filter, +}) async { + return showMobileBottomSheet( + context, + backgroundColor: Theme.of(context).colorScheme.surface, + maxChildSize: 0.98, + enableDraggableScrollable: true, + scrollableWidgetBuilder: (context, scrollController) { + return Expanded( + child: _MobilePageSelectorBody( + filter: filter, + scrollController: scrollController, + ), + ); + }, + builder: (context) => const SizedBox.shrink(), + ); +} + +class _MobilePageSelectorBody extends StatefulWidget { + const _MobilePageSelectorBody({ + this.filter, + this.scrollController, + }); + + final bool Function(ViewPB view)? filter; + final ScrollController? scrollController; + + @override + State<_MobilePageSelectorBody> createState() => + _MobilePageSelectorBodyState(); +} + +class _MobilePageSelectorBodyState extends State<_MobilePageSelectorBody> { + final textController = TextEditingController(); + late final Future> _viewsFuture = _fetchViews(); + + @override + void dispose() { + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CustomScrollView( + controller: widget.scrollController, + shrinkWrap: true, + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _Header( + child: ColoredBox( + color: Theme.of(context).cardColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const DragHandle(), + SizedBox( + height: 44.0, + child: Center( + child: FlowyText.medium( + LocaleKeys.document_mobilePageSelector_title.tr(), + fontSize: 16.0, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: SizedBox( + height: 44.0, + child: FlowySearchTextField( + controller: textController, + onChanged: (_) => setState(() {}), + ), + ), + ), + const Divider(height: 0.5, thickness: 0.5), + ], + ), + ), + ), + ), + FutureBuilder( + future: _viewsFuture, + builder: (_, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SliverToBoxAdapter( + child: CircularProgressIndicator.adaptive(), + ); + } + + if (snapshot.hasError || snapshot.data == null) { + return SliverToBoxAdapter( + child: FlowyText( + LocaleKeys.document_mobilePageSelector_failedToLoad.tr(), + ), + ); + } + + final views = snapshot.data! + .where((v) => widget.filter?.call(v) ?? true) + .toList(); + + final filtered = views.where( + (v) => + textController.text.isEmpty || + v.name + .toLowerCase() + .contains(textController.text.toLowerCase()), + ); + + if (filtered.isEmpty) { + return SliverToBoxAdapter( + child: FlowyText( + LocaleKeys.document_mobilePageSelector_noPagesFound.tr(), + ), + ); + } + + return SliverPadding( + padding: + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final view = filtered.elementAt(index); + return InkWell( + onTap: () => Navigator.of(context).pop(view), + borderRadius: BorderRadius.circular(12), + splashColor: Colors.transparent, + child: Container( + height: 44, + padding: const EdgeInsets.all(4.0), + child: Row( + children: [ + MentionViewIcon(view: view), + const HSpace(8), + Expanded( + child: MentionViewTitleAndAncestors(view: view), + ), + ], + ), + ), + ); + }, + childCount: filtered.length, + ), + ), + ); + }, + ), + ], + ); + } + + Future> _fetchViews() async => + (await ViewBackendService.getAllViews()).toNullable()?.items ?? []; +} + +class _Header extends SliverPersistentHeaderDelegate { + const _Header({ + required this.child, + }); + + final Widget child; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + return child; + } + + @override + double get maxExtent => 120.5; + + @override + double get minExtent => 120.5; + + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { + return false; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_mention_page_menu.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_mention_page_menu.dart new file mode 100644 index 0000000000000..6f3eb2d6db2f7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/chat_mention_page_menu.dart @@ -0,0 +1,429 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view_title/view_title_bar_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart' hide TextDirection; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; + +const double _itemHeight = 44.0; +const double _noPageHeight = 20.0; +const double _fixedWidth = 360.0; +const double _maxHeight = 328.0; + +class ChatInputAnchor { + ChatInputAnchor(this.anchorKey, this.layerLink); + + final GlobalKey> anchorKey; + final LayerLink layerLink; +} + +class ChatMentionPageMenu extends StatefulWidget { + const ChatMentionPageMenu({ + super.key, + required this.anchor, + required this.textController, + required this.onPageSelected, + }); + + final ChatInputAnchor anchor; + final TextEditingController textController; + final void Function(ViewPB view) onPageSelected; + + @override + State createState() => _ChatMentionPageMenuState(); +} + +class _ChatMentionPageMenuState extends State { + @override + void initState() { + super.initState(); + Future.delayed(Duration.zero, () { + context.read().refreshViews(); + }); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Stack( + children: [ + CompositedTransformFollower( + link: widget.anchor.layerLink, + showWhenUnlinked: false, + offset: Offset(getPopupOffsetX(), 0.0), + followerAnchor: Alignment.bottomLeft, + child: Container( + constraints: const BoxConstraints( + minWidth: _fixedWidth, + maxWidth: _fixedWidth, + maxHeight: _maxHeight, + ), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(6.0), + boxShadow: const [ + BoxShadow( + color: Color(0x0A1F2329), + blurRadius: 24, + offset: Offset(0, 8), + spreadRadius: 8, + ), + BoxShadow( + color: Color(0x0A1F2329), + blurRadius: 12, + offset: Offset(0, 6), + ), + BoxShadow( + color: Color(0x0F1F2329), + blurRadius: 8, + offset: Offset(0, 4), + spreadRadius: -8, + ), + ], + ), + child: TextFieldTapRegion( + child: ChatMentionPageList( + onPageSelected: widget.onPageSelected, + ), + ), + ), + ), + ], + ); + }, + ); + } + + double getPopupOffsetX() { + if (widget.anchor.anchorKey.currentContext == null) { + return 0.0; + } + + final cubit = context.read(); + if (cubit.filterStartPosition == -1) { + return 0.0; + } + + final textPosition = TextPosition(offset: cubit.filterEndPosition); + final renderBox = + widget.anchor.anchorKey.currentContext?.findRenderObject() as RenderBox; + + final textPainter = TextPainter( + text: TextSpan(text: cubit.formatIntputText(widget.textController.text)), + textDirection: TextDirection.ltr, + ); + textPainter.layout( + minWidth: renderBox.size.width, + maxWidth: renderBox.size.width, + ); + + final caretOffset = textPainter.getOffsetForCaret(textPosition, Rect.zero); + final boxes = textPainter.getBoxesForSelection( + TextSelection( + baseOffset: textPosition.offset, + extentOffset: textPosition.offset, + ), + ); + + if (boxes.isNotEmpty) { + return boxes.last.right; + } + + return caretOffset.dx; + } +} + +class ChatMentionPageList extends StatefulWidget { + const ChatMentionPageList({ + super.key, + required this.onPageSelected, + }); + + final void Function(ViewPB view) onPageSelected; + + @override + State createState() => _ChatMentionPageListState(); +} + +class _ChatMentionPageListState extends State { + final autoScrollController = SimpleAutoScrollController( + suggestedRowHeight: _itemHeight, + beginGetter: (rect) => rect.top + 8.0, + endGetter: (rect) => rect.bottom - 8.0, + ); + + @override + void dispose() { + autoScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (previous, current) { + return previous.maybeWhen( + ready: (_, pFocusedViewIndex) => current.maybeWhen( + ready: (_, cFocusedViewIndex) => + pFocusedViewIndex != cFocusedViewIndex, + orElse: () => false, + ), + orElse: () => false, + ); + }, + listener: (context, state) { + state.maybeWhen( + ready: (views, focusedViewIndex) { + if (focusedViewIndex == -1 || !autoScrollController.hasClients) { + return; + } + if (autoScrollController.isAutoScrolling) { + autoScrollController.position + .jumpTo(autoScrollController.position.pixels); + } + autoScrollController.scrollToIndex( + focusedViewIndex, + duration: const Duration(milliseconds: 200), + preferPosition: AutoScrollPosition.begin, + ); + }, + orElse: () {}, + ); + }, + builder: (context, state) { + return state.maybeWhen( + loading: () { + return const Padding( + padding: EdgeInsets.all(8.0), + child: SizedBox( + height: _noPageHeight, + child: Center( + child: CircularProgressIndicator.adaptive(), + ), + ), + ); + }, + ready: (views, focusedViewIndex) { + if (views.isEmpty) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: _noPageHeight, + child: Center( + child: FlowyText( + LocaleKeys.chat_inputActionNoPages.tr(), + ), + ), + ), + ); + } + + return ListView.builder( + shrinkWrap: true, + controller: autoScrollController, + padding: const EdgeInsets.all(8.0), + itemCount: views.length, + itemBuilder: (context, index) { + final view = views[index]; + return AutoScrollTag( + key: ValueKey("chat_mention_page_item_${view.id}"), + index: index, + controller: autoScrollController, + child: _ChatMentionPageItem( + view: view, + onTap: () => widget.onPageSelected(view), + isSelected: focusedViewIndex == index, + ), + ); + }, + ); + }, + orElse: () => const SizedBox.shrink(), + ); + }, + ); + } +} + +class _ChatMentionPageItem extends StatelessWidget { + const _ChatMentionPageItem({ + required this.view, + required this.isSelected, + required this.onTap, + }); + + final ViewPB view; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: view.name, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: FlowyHover( + isSelected: () => isSelected, + child: Container( + height: _itemHeight, + padding: const EdgeInsets.all(4.0), + child: Row( + children: [ + MentionViewIcon(view: view), + const HSpace(8.0), + Expanded(child: MentionViewTitleAndAncestors(view: view)), + ], + ), + ), + ), + ), + ), + ); + } +} + +class MentionViewIcon extends StatelessWidget { + const MentionViewIcon({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + final spaceIcon = view.buildSpaceIconSvg(context); + + if (view.icon.value.isNotEmpty) { + return SizedBox( + width: 16.0, + child: EmojiIconWidget( + emoji: view.icon.toEmojiIconData(), + emojiSize: 14, + ), + ); + } + + if (view.isSpace == true && spaceIcon != null) { + return SpaceIcon( + dimension: 16.0, + svgSize: 9.68, + space: view, + cornerRadius: 4, + ); + } + + return FlowySvg( + view.layout.icon, + size: const Size.square(16), + color: Theme.of(context).hintColor, + ); + } +} + +class MentionViewTitleAndAncestors extends StatelessWidget { + const MentionViewTitleAndAncestors({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => ViewTitleBarBloc(view: view), + child: BlocBuilder( + builder: (context, state) { + final nonEmptyName = view.name.isEmpty + ? LocaleKeys.document_title_placeholder.tr() + : view.name; + + final ancestorList = _getViewAncestorList(state.ancestors); + + if (state.ancestors.isEmpty || ancestorList.trim().isEmpty) { + return FlowyText( + nonEmptyName, + fontSize: 14.0, + overflow: TextOverflow.ellipsis, + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + nonEmptyName, + fontSize: 14.0, + figmaLineHeight: 20.0, + overflow: TextOverflow.ellipsis, + ), + FlowyText( + ancestorList, + fontSize: 12.0, + figmaLineHeight: 16.0, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ], + ); + }, + ), + ); + } + + /// see workspace/presentation/widgets/view_title_bar.dart, upon which this + /// function was based. This version doesn't include the current view in the + /// result, and returns a string rather than a list of widgets + String _getViewAncestorList( + List views, + ) { + const lowerBound = 2; + final upperBound = views.length - 2; + bool hasAddedEllipsis = false; + String result = ""; + + if (views.length <= 1) { + return ""; + } + + // ignore the workspace name, use section name instead in the future + // skip the workspace view + for (var i = 1; i < views.length - 1; i++) { + final view = views[i]; + + if (i >= lowerBound && i < upperBound) { + if (!hasAddedEllipsis) { + hasAddedEllipsis = true; + result += "… / "; + } + continue; + } + + final nonEmptyName = view.name.isEmpty + ? LocaleKeys.document_title_placeholder.tr() + : view.name; + + result += nonEmptyName; + + if (i != views.length - 2) { + // if not the last one, add a divider + result += " / "; + } + } + return result; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_ai_prompt_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_ai_prompt_input.dart new file mode 100644 index 0000000000000..a44f446433f99 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/desktop_ai_prompt_input.dart @@ -0,0 +1,598 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:extended_text_field/extended_text_field.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../layout_define.dart'; +import 'ai_prompt_buttons.dart'; +import 'chat_input_file.dart'; +import 'chat_input_span.dart'; +import 'chat_mention_page_menu.dart'; +import 'select_sources_menu.dart'; + +class DesktopAIPromptInput extends StatefulWidget { + const DesktopAIPromptInput({ + super.key, + required this.chatId, + required this.isStreaming, + required this.onStopStreaming, + required this.onSubmitted, + required this.onUpdateSelectedSources, + }); + + final String chatId; + final bool isStreaming; + final void Function() onStopStreaming; + final void Function(String, Map) onSubmitted; + final void Function(List) onUpdateSelectedSources; + + @override + State createState() => _DesktopAIPromptInputState(); +} + +class _DesktopAIPromptInputState extends State { + final textFieldKey = GlobalKey(); + final layerLink = LayerLink(); + final overlayController = OverlayPortalController(); + final inputControlCubit = ChatInputControlCubit(); + final focusNode = FocusNode(); + final textController = TextEditingController(); + + late SendButtonState sendButtonState; + + @override + void initState() { + super.initState(); + + textController.addListener(handleTextControllerChanged); + + // refresh border color on focus change and hide menu when lost focus + focusNode.addListener( + () => setState(() { + if (!focusNode.hasFocus) { + cancelMentionPage(); + } + }), + ); + + updateSendButtonState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + }); + } + + @override + void didUpdateWidget(covariant oldWidget) { + updateSendButtonState(); + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + focusNode.dispose(); + textController.dispose(); + inputControlCubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: inputControlCubit, + child: BlocListener( + listener: (context, state) { + state.maybeWhen( + updateSelectedViews: (selectedViews) { + context + .read() + .add(AIPromptInputEvent.updateMentionedViews(selectedViews)); + }, + orElse: () {}, + ); + }, + child: OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) { + return ChatMentionPageMenu( + anchor: ChatInputAnchor(textFieldKey, layerLink), + textController: textController, + onPageSelected: handlePageSelected, + ); + }, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: focusNode.hasFocus + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline, + width: focusNode.hasFocus ? 1.5 : 1.0, + ), + borderRadius: DesktopAIPromptSizes.promptFrameRadius, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + DesktopAIPromptSizes.attachedFilesBarPadding.vertical + + DesktopAIPromptSizes.attachedFilesPreviewHeight, + ), + child: TextFieldTapRegion( + child: ChatInputFile( + chatId: widget.chatId, + onDeleted: (file) => context + .read() + .add(AIPromptInputEvent.removeFile(file)), + ), + ), + ), + Stack( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + minHeight: DesktopAIPromptSizes.textFieldMinHeight + + DesktopAIPromptSizes.actionBarHeight + + DesktopAIPromptSizes.actionBarPadding.vertical, + maxHeight: 300, + ), + child: inputTextField(), + ), + Positioned.fill( + top: null, + child: TextFieldTapRegion( + child: _PromptBottomActions( + textController: textController, + overlayController: overlayController, + focusNode: focusNode, + sendButtonState: sendButtonState, + onSendPressed: handleSendPressed, + onStopStreaming: widget.onStopStreaming, + onUpdateSelectedSources: + widget.onUpdateSelectedSources, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + void cancelMentionPage() { + if (overlayController.isShowing) { + inputControlCubit.reset(); + overlayController.hide(); + } + } + + void updateSendButtonState() { + if (widget.isStreaming) { + sendButtonState = SendButtonState.streaming; + } else if (textController.text.trim().isEmpty) { + sendButtonState = SendButtonState.disabled; + } else { + sendButtonState = SendButtonState.enabled; + } + } + + void handleSendPressed() { + if (widget.isStreaming) { + return; + } + final trimmedText = inputControlCubit.formatIntputText( + textController.text.trim(), + ); + textController.clear(); + if (trimmedText.isEmpty) { + return; + } + + // get the attached files and mentioned pages + final metadata = context.read().consumeMetadata(); + + widget.onSubmitted(trimmedText, metadata); + } + + void handleTextControllerChanged() { + if (!textController.value.composing.isCollapsed) { + return; + } + + // update whether send button is clickable + setState(() => updateSendButtonState()); + + // handle text and selection changes ONLY when mentioning a page + + // disable mention + return; + // ignore: dead_code + if (!overlayController.isShowing || + inputControlCubit.filterStartPosition == -1) { + return; + } + + // handle cases where mention a page is cancelled + final textSelection = textController.value.selection; + final isSelectingMultipleCharacters = !textSelection.isCollapsed; + final isCaretBeforeStartOfRange = + textSelection.baseOffset < inputControlCubit.filterStartPosition; + final isCaretAfterEndOfRange = + textSelection.baseOffset > inputControlCubit.filterEndPosition; + final isTextSame = inputControlCubit.inputText == textController.text; + + if (isSelectingMultipleCharacters || + isTextSame && (isCaretBeforeStartOfRange || isCaretAfterEndOfRange)) { + cancelMentionPage(); + return; + } + + final previousLength = inputControlCubit.inputText.characters.length; + final currentLength = textController.text.characters.length; + + // delete "@" + if (previousLength != currentLength && isCaretBeforeStartOfRange) { + cancelMentionPage(); + return; + } + + // handle cases where mention the filter is updated + if (previousLength != currentLength) { + final diff = currentLength - previousLength; + final newEndPosition = inputControlCubit.filterEndPosition + diff; + final newFilter = textController.text.substring( + inputControlCubit.filterStartPosition, + newEndPosition, + ); + inputControlCubit.updateFilter( + textController.text, + newFilter, + newEndPosition: newEndPosition, + ); + } else if (!isTextSame) { + final newFilter = textController.text.substring( + inputControlCubit.filterStartPosition, + inputControlCubit.filterEndPosition, + ); + inputControlCubit.updateFilter(textController.text, newFilter); + } + } + + void handlePageSelected(ViewPB view) { + final newText = textController.text.replaceRange( + inputControlCubit.filterStartPosition, + inputControlCubit.filterEndPosition, + view.id, + ); + textController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: inputControlCubit.filterStartPosition + view.id.length, + affinity: TextAffinity.upstream, + ), + ); + + inputControlCubit.selectPage(view); + overlayController.hide(); + } + + Widget inputTextField() { + return Actions( + actions: buildActions(), + child: CompositedTransformTarget( + link: layerLink, + child: BlocBuilder( + builder: (context, state) { + return _PromptTextField( + key: textFieldKey, + cubit: inputControlCubit, + textController: textController, + textFieldFocusNode: focusNode, + hintText: switch (state.aiType) { + AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(), + AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr() + }, + // onStartMentioningPage: () { + // WidgetsBinding.instance.addPostFrameCallback((_) { + // inputControlCubit.startSearching(textController.value); + // overlayController.show(); + // }); + // }, + ); + }, + ), + ), + ); + } + + Map> buildActions() { + return { + _FocusPreviousItemIntent: CallbackAction<_FocusPreviousItemIntent>( + onInvoke: (intent) { + inputControlCubit.updateSelectionUp(); + return; + }, + ), + _FocusNextItemIntent: CallbackAction<_FocusNextItemIntent>( + onInvoke: (intent) { + inputControlCubit.updateSelectionDown(); + return; + }, + ), + _CancelMentionPageIntent: CallbackAction<_CancelMentionPageIntent>( + onInvoke: (intent) { + cancelMentionPage(); + return; + }, + ), + _SubmitOrMentionPageIntent: CallbackAction<_SubmitOrMentionPageIntent>( + onInvoke: (intent) { + if (overlayController.isShowing) { + inputControlCubit.state.maybeWhen( + ready: (visibleViews, focusedViewIndex) { + if (focusedViewIndex != -1 && + focusedViewIndex < visibleViews.length) { + handlePageSelected(visibleViews[focusedViewIndex]); + } + }, + orElse: () {}, + ); + } else { + handleSendPressed(); + } + return; + }, + ), + }; + } +} + +class _PromptTextField extends StatefulWidget { + const _PromptTextField({ + super.key, + required this.cubit, + required this.textController, + required this.textFieldFocusNode, + // required this.onStartMentioningPage, + this.hintText = "", + }); + + final ChatInputControlCubit cubit; + final TextEditingController textController; + final FocusNode textFieldFocusNode; + // final void Function() onStartMentioningPage; + final String hintText; + + @override + State<_PromptTextField> createState() => _PromptTextFieldState(); +} + +class _PromptTextFieldState extends State<_PromptTextField> { + bool isComposing = false; + + @override + void initState() { + super.initState(); + // widget.textFieldFocusNode.onKeyEvent = handleKeyEvent; + widget.textController.addListener(onTextChanged); + } + + @override + void dispose() { + // widget.textFieldFocusNode.onKeyEvent = null; + widget.textController.removeListener(onTextChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Shortcuts( + shortcuts: buildShortcuts(), + child: ExtendedTextField( + controller: widget.textController, + focusNode: widget.textFieldFocusNode, + decoration: InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: DesktopAIPromptSizes.textFieldContentPadding.add( + const EdgeInsets.only( + bottom: DesktopAIPromptSizes.actionBarHeight, + ), + ), + hintText: widget.hintText, + hintStyle: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Theme.of(context).hintColor), + isCollapsed: true, + isDense: true, + ), + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + minLines: 1, + maxLines: null, + style: Theme.of(context).textTheme.bodyMedium, + specialTextSpanBuilder: ChatInputTextSpanBuilder( + inputControlCubit: widget.cubit, + specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + + // KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) { + // if (event.character == '@') { + // widget.onStartMentioningPage(); + // } + // return KeyEventResult.ignored; + // } + + void onTextChanged() { + setState( + () => isComposing = !widget.textController.value.composing.isCollapsed, + ); + } + + Map buildShortcuts() { + if (isComposing) { + return const {}; + } + + return const { + SingleActivator(LogicalKeyboardKey.arrowUp): _FocusPreviousItemIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): _FocusNextItemIntent(), + SingleActivator(LogicalKeyboardKey.escape): _CancelMentionPageIntent(), + SingleActivator(LogicalKeyboardKey.enter): _SubmitOrMentionPageIntent(), + }; + } +} + +class _SubmitOrMentionPageIntent extends Intent { + const _SubmitOrMentionPageIntent(); +} + +class _CancelMentionPageIntent extends Intent { + const _CancelMentionPageIntent(); +} + +class _FocusPreviousItemIntent extends Intent { + const _FocusPreviousItemIntent(); +} + +class _FocusNextItemIntent extends Intent { + const _FocusNextItemIntent(); +} + +class _PromptBottomActions extends StatelessWidget { + const _PromptBottomActions({ + required this.textController, + required this.overlayController, + required this.focusNode, + required this.sendButtonState, + required this.onSendPressed, + required this.onStopStreaming, + required this.onUpdateSelectedSources, + }); + + final TextEditingController textController; + final OverlayPortalController overlayController; + final FocusNode focusNode; + final SendButtonState sendButtonState; + final void Function() onSendPressed; + final void Function() onStopStreaming; + final void Function(List) onUpdateSelectedSources; + + @override + Widget build(BuildContext context) { + return Container( + height: DesktopAIPromptSizes.actionBarHeight, + margin: DesktopAIPromptSizes.actionBarPadding, + child: BlocBuilder( + builder: (context, state) { + return Row( + children: [ + // predefinedFormatButton(), + const Spacer(), + if (state.aiType == AIType.appflowyAI) ...[ + _selectSourcesButton(context), + const HSpace( + DesktopAIPromptSizes.actionBarButtonSpacing, + ), + ], + // _mentionButton(context), + // const HSpace( + // DesktopAIPromptSizes.actionBarButtonSpacing, + // ), + if (state.supportChatWithFile) ...[ + _attachmentButton(context), + const HSpace( + DesktopAIPromptSizes.actionBarButtonSpacing, + ), + ], + _sendButton(), + ], + ); + }, + ), + ); + } + + Widget _selectSourcesButton(BuildContext context) { + return PromptInputDesktopSelectSourcesButton( + onUpdateSelectedSources: onUpdateSelectedSources, + ); + } + + // Widget _mentionButton(BuildContext context) { + // return PromptInputMentionButton( + // iconSize: DesktopAIPromptSizes.actionBarIconSize, + // buttonSize: DesktopAIPromptSizes.actionBarButtonSize, + // onTap: () { + // if (overlayController.isShowing) { + // return; + // } + // if (!focusNode.hasFocus) { + // focusNode.requestFocus(); + // } + // textController.text += '@'; + // Future.delayed(Duration.zero, () { + // context + // .read() + // .startSearching(textController.value); + // overlayController.show(); + // }); + // }, + // ); + // } + + Widget _attachmentButton(BuildContext context) { + return PromptInputAttachmentButton( + onTap: () async { + final path = await getIt().pickFiles( + dialogTitle: '', + type: FileType.custom, + allowedExtensions: ["pdf", "txt", "md"], + ); + + if (path == null) { + return; + } + + for (final file in path.files) { + if (file.path != null && context.mounted) { + context + .read() + .add(AIPromptInputEvent.attachFile(file.path!, file.name)); + } + } + }, + ); + } + + Widget _sendButton() { + return PromptInputSendButton( + buttonSize: DesktopAIPromptSizes.actionBarHeight, + iconSize: DesktopAIPromptSizes.sendButtonSize, + state: sendButtonState, + onSendPressed: onSendPressed, + onStopStreaming: onStopStreaming, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_ai_prompt_input.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_ai_prompt_input.dart new file mode 100644 index 0000000000000..65d2f138746c0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/mobile_ai_prompt_input.dart @@ -0,0 +1,321 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:extended_text_field/extended_text_field.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../layout_define.dart'; +import 'ai_prompt_buttons.dart'; +import 'chat_input_file.dart'; +import 'chat_input_span.dart'; +import 'chat_mention_page_bottom_sheet.dart'; +import 'select_sources_bottom_sheet.dart'; + +class MobileAIPromptInput extends StatefulWidget { + const MobileAIPromptInput({ + super.key, + required this.chatId, + required this.isStreaming, + required this.onStopStreaming, + required this.onSubmitted, + required this.onUpdateSelectedSources, + }); + + final String chatId; + final bool isStreaming; + final void Function() onStopStreaming; + final void Function(String, Map) onSubmitted; + final void Function(List) onUpdateSelectedSources; + + @override + State createState() => _MobileAIPromptInputState(); +} + +class _MobileAIPromptInputState extends State { + final inputControlCubit = ChatInputControlCubit(); + final focusNode = FocusNode(); + final textController = TextEditingController(); + + late SendButtonState sendButtonState; + + @override + void initState() { + super.initState(); + + textController.addListener(handleTextControllerChange); + // focusNode.onKeyEvent = handleKeyEvent; + + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + }); + + updateSendButtonState(); + } + + @override + void didUpdateWidget(covariant oldWidget) { + updateSendButtonState(); + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + focusNode.dispose(); + textController.dispose(); + inputControlCubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Hero( + tag: "ai_chat_prompt", + child: BlocProvider.value( + value: inputControlCubit, + child: BlocListener( + listener: (context, state) { + state.maybeWhen( + updateSelectedViews: (selectedViews) { + context.read().add( + AIPromptInputEvent.updateMentionedViews(selectedViews), + ); + }, + orElse: () {}, + ); + }, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Theme.of(context).colorScheme.outline), + ), + color: Theme.of(context).colorScheme.surface, + boxShadow: const [ + BoxShadow( + blurRadius: 4.0, + offset: Offset(0, -2), + color: Color.fromRGBO(0, 0, 0, 0.05), + ), + ], + borderRadius: MobileAIPromptSizes.promptFrameRadius, + ), + child: Column( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + MobileAIPromptSizes.attachedFilesBarPadding.vertical + + MobileAIPromptSizes.attachedFilesPreviewHeight, + ), + child: ChatInputFile( + chatId: widget.chatId, + onDeleted: (file) => context + .read() + .add(AIPromptInputEvent.removeFile(file)), + ), + ), + Container( + constraints: const BoxConstraints( + minHeight: MobileAIPromptSizes.textFieldMinHeight, + maxHeight: 220, + ), + child: IntrinsicHeight( + child: Row( + children: [ + const HSpace(8.0), + leadingButtons(context), + Expanded( + child: inputTextField(context), + ), + sendButton(), + const HSpace(12.0), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + void updateSendButtonState() { + if (widget.isStreaming) { + sendButtonState = SendButtonState.streaming; + } else if (textController.text.trim().isEmpty) { + sendButtonState = SendButtonState.disabled; + } else { + sendButtonState = SendButtonState.enabled; + } + } + + void handleSendPressed() { + if (widget.isStreaming) { + return; + } + final trimmedText = inputControlCubit.formatIntputText( + textController.text.trim(), + ); + textController.clear(); + if (trimmedText.isEmpty) { + return; + } + + // get the attached files and mentioned pages + final metadata = context.read().consumeMetadata(); + + widget.onSubmitted(trimmedText, metadata); + } + + void handleTextControllerChange() { + if (textController.value.isComposingRangeValid) { + return; + } + // inputControlCubit.updateInputText(textController.text); + setState(() => updateSendButtonState()); + } + + // KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) { + // if (event.character == '@') { + // WidgetsBinding.instance.addPostFrameCallback((_) { + // mentionPage(context); + // }); + // } + // return KeyEventResult.ignored; + // } + + Future mentionPage(BuildContext context) async { + // if the focus node is on focus, unfocus it for better animation + // otherwise, the page sheet animation will be blocked by the keyboard + inputControlCubit.refreshViews(); + inputControlCubit.startSearching(textController.value); + if (focusNode.hasFocus) { + focusNode.unfocus(); + await Future.delayed(const Duration(milliseconds: 100)); + } + + if (context.mounted) { + final selectedView = await showPageSelectorSheet( + context, + filter: (view) => + !view.isSpace && + view.layout.isDocumentView && + view.parentViewId != view.id && + !inputControlCubit.selectedViewIds.contains(view.id), + ); + if (selectedView != null) { + final newText = textController.text.replaceRange( + inputControlCubit.filterStartPosition, + inputControlCubit.filterStartPosition, + selectedView.id, + ); + textController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: + textController.selection.baseOffset + selectedView.id.length, + affinity: TextAffinity.upstream, + ), + ); + + inputControlCubit.selectPage(selectedView); + } + focusNode.requestFocus(); + inputControlCubit.reset(); + } + } + + Widget inputTextField(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return ExtendedTextField( + controller: textController, + focusNode: focusNode, + decoration: InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: MobileAIPromptSizes.textFieldContentPadding, + hintText: switch (state.aiType) { + AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(), + AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr() + }, + isCollapsed: true, + isDense: true, + ), + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + minLines: 1, + maxLines: null, + style: Theme.of(context).textTheme.bodyMedium, + specialTextSpanBuilder: ChatInputTextSpanBuilder( + inputControlCubit: inputControlCubit, + specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ); + }, + ); + } + + Widget leadingButtons(BuildContext context) { + return Container( + alignment: Alignment.bottomCenter, + padding: const EdgeInsets.only(bottom: 8.0), + child: _LeadingActions( + textController: textController, + // onMention: () { + // textController.text += '@'; + // if (!focusNode.hasFocus) { + // focusNode.requestFocus(); + // } + // WidgetsBinding.instance.addPostFrameCallback((_) { + // mentionPage(context); + // }); + // }, + onUpdateSelectedSources: widget.onUpdateSelectedSources, + ), + ); + } + + Widget sendButton() { + return Container( + alignment: Alignment.bottomCenter, + padding: const EdgeInsets.only(bottom: 8.0), + child: PromptInputSendButton( + buttonSize: MobileAIPromptSizes.sendButtonSize, + iconSize: MobileAIPromptSizes.sendButtonSize, + onSendPressed: handleSendPressed, + onStopStreaming: widget.onStopStreaming, + state: sendButtonState, + ), + ); + } +} + +class _LeadingActions extends StatelessWidget { + const _LeadingActions({ + required this.textController, + required this.onUpdateSelectedSources, + }); + + final TextEditingController textController; + final void Function(List) onUpdateSelectedSources; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).cardColor, + child: PromptInputMobileSelectSourcesButton( + onUpdateSelectedSources: onUpdateSelectedSources, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/select_sources_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/select_sources_bottom_sheet.dart new file mode 100644 index 0000000000000..2cd34bfdf0521 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/select_sources_bottom_sheet.dart @@ -0,0 +1,263 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart'; +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'select_sources_menu.dart'; + +class PromptInputMobileSelectSourcesButton extends StatefulWidget { + const PromptInputMobileSelectSourcesButton({ + super.key, + required this.onUpdateSelectedSources, + }); + + final void Function(List) onUpdateSelectedSources; + + @override + State createState() => + _PromptInputMobileSelectSourcesButtonState(); +} + +class _PromptInputMobileSelectSourcesButtonState + extends State { + late final cubit = ChatSettingsCubit(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + cubit.updateSelectedSources( + context.read().state.selectedSourceIds, + ); + }); + } + + @override + void dispose() { + cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final userProfile = context.read().userProfile; + final workspaceId = state.currentWorkspace?.workspaceId ?? ''; + return MultiBlocProvider( + providers: [ + BlocProvider( + key: ValueKey(workspaceId), + create: (context) => SpaceBloc( + userProfile: userProfile, + workspaceId: workspaceId, + )..add(const SpaceEvent.initial(openFirstPage: false)), + ), + BlocProvider.value( + value: cubit, + ), + ], + child: BlocSelector( + selector: (state) => state.currentSpace, + builder: (context, spaceView) { + return BlocListener( + listener: (context, state) { + cubit + ..updateSelectedSources(state.selectedSourceIds) + ..updateSelectedStatus(); + }, + child: FlowyButton( + margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6), + expandText: false, + text: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.ai_page_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(20.0), + ), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(10), + ), + ], + ), + onTap: () async { + if (spaceView != null) { + context + .read() + .refreshSources(spaceView); + } + await showMobileBottomSheet( + context, + backgroundColor: Theme.of(context).colorScheme.surface, + maxChildSize: 0.98, + enableDraggableScrollable: true, + scrollableWidgetBuilder: (_, scrollController) { + return Expanded( + child: BlocProvider.value( + value: cubit, + child: _MobileSelectSourcesSheetBody( + scrollController: scrollController, + ), + ), + ); + }, + builder: (context) => const SizedBox.shrink(), + ); + if (context.mounted) { + widget.onUpdateSelectedSources(cubit.selectedSourceIds); + } + }, + ), + ); + }, + ), + ); + }, + ); + } +} + +class _MobileSelectSourcesSheetBody extends StatefulWidget { + const _MobileSelectSourcesSheetBody({ + required this.scrollController, + }); + + final ScrollController scrollController; + + @override + State<_MobileSelectSourcesSheetBody> createState() => + _MobileSelectSourcesSheetBodyState(); +} + +class _MobileSelectSourcesSheetBodyState + extends State<_MobileSelectSourcesSheetBody> { + final textController = TextEditingController(); + + @override + void dispose() { + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CustomScrollView( + controller: widget.scrollController, + shrinkWrap: true, + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: _Header( + child: ColoredBox( + color: Theme.of(context).cardColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const DragHandle(), + SizedBox( + height: 44.0, + child: Center( + child: FlowyText.medium( + LocaleKeys.chat_selectSources.tr(), + fontSize: 16.0, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: SizedBox( + height: 44.0, + child: FlowySearchTextField( + controller: textController, + onChanged: (value) => context + .read() + .updateFilter(value), + ), + ), + ), + const Divider(height: 0.5, thickness: 0.5), + ], + ), + ), + ), + ), + BlocBuilder( + builder: (context, state) { + final sources = state.visibleSources + .where((e) => e.ignoreStatus != IgnoreViewType.hide); + return SliverList( + delegate: SliverChildBuilderDelegate( + childCount: sources.length, + (context, index) { + final source = sources.elementAt(index); + return ChatSourceTreeItem( + key: ValueKey( + 'visible_select_sources_tree_item_${source.view.id}', + ), + chatSource: source, + level: 0, + isDescendentOfSpace: source.view.isSpace, + isSelectedSection: false, + onSelected: (chatSource) { + context + .read() + .toggleSelectedStatus(chatSource); + }, + height: 40.0, + ); + }, + ), + ); + }, + ), + ], + ); + } +} + +class _Header extends SliverPersistentHeaderDelegate { + const _Header({ + required this.child, + }); + + final Widget child; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + return child; + } + + @override + double get maxExtent => 120.5; + + @override + double get minExtent => 120.5; + + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { + return false; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/select_sources_menu.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/select_sources_menu.dart new file mode 100644 index 0000000000000..47cfec9b4ae14 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_input/select_sources_menu.dart @@ -0,0 +1,523 @@ +import 'dart:math'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../layout_define.dart'; +import 'chat_mention_page_menu.dart'; + +class PromptInputDesktopSelectSourcesButton extends StatefulWidget { + const PromptInputDesktopSelectSourcesButton({ + super.key, + required this.onUpdateSelectedSources, + }); + + final void Function(List) onUpdateSelectedSources; + + @override + State createState() => + _PromptInputDesktopSelectSourcesButtonState(); +} + +class _PromptInputDesktopSelectSourcesButtonState + extends State { + late final cubit = ChatSettingsCubit(); + final popoverController = PopoverController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + cubit.updateSelectedSources( + context.read().state.selectedSourceIds, + ); + }); + } + + @override + void dispose() { + cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final userWorkspaceBloc = context.read(); + final userProfile = userWorkspaceBloc.userProfile; + final workspaceId = + userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? ''; + + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => SpaceBloc( + userProfile: userProfile, + workspaceId: workspaceId, + )..add(const SpaceEvent.initial(openFirstPage: false)), + ), + BlocProvider.value( + value: cubit, + ), + ], + child: BlocSelector( + selector: (state) => state.currentSpace, + builder: (context, spaceView) { + return BlocListener( + listener: (context, state) { + cubit + ..updateSelectedSources(state.selectedSourceIds) + ..updateSelectedStatus(); + }, + child: AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(320, 380)), + offset: const Offset(0.0, -10.0), + direction: PopoverDirection.topWithCenterAligned, + margin: EdgeInsets.zero, + controller: popoverController, + onOpen: () { + if (spaceView != null) { + context.read().refreshSources(spaceView); + } + }, + onClose: () { + widget.onUpdateSelectedSources(cubit.selectedSourceIds); + if (spaceView != null) { + context.read().refreshSources(spaceView); + } + }, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: const _PopoverContent(), + ); + }, + child: _IndicatorButton( + onTap: () => popoverController.show(), + ), + ), + ); + }, + ), + ); + } +} + +class _IndicatorButton extends StatelessWidget { + const _IndicatorButton({ + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: DesktopAIPromptSizes.actionBarButtonSize, + child: FlowyHover( + style: const HoverStyle( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.ai_page_s, + color: Theme.of(context).iconTheme.color, + ), + const HSpace(2.0), + BlocBuilder( + builder: (context, state) { + return FlowyText( + state.selectedSourceIds.length.toString(), + fontSize: 14, + figmaLineHeight: 16, + color: Theme.of(context).hintColor, + ); + }, + ), + const HSpace(2.0), + FlowySvg( + FlowySvgs.ai_source_drop_down_s, + color: Theme.of(context).hintColor, + size: const Size.square(10), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _PopoverContent extends StatelessWidget { + const _PopoverContent(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 12, 8, 8), + child: SpaceSearchField( + width: 600, + onSearch: (context, value) => + context.read().updateFilter(value), + ), + ), + _buildDivider(), + Flexible( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), + children: [ + ..._buildSelectedSources(context, state), + if (state.selectedSources.isNotEmpty && + state.visibleSources.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: _buildDivider(), + ), + ..._buildVisibleSources(context, state), + ], + ), + ), + ], + ); + }, + ); + } + + Widget _buildDivider() { + return const Divider( + height: 1.0, + thickness: 1.0, + indent: 8.0, + endIndent: 8.0, + ); + } + + Iterable _buildSelectedSources( + BuildContext context, + ChatSettingsState state, + ) { + return state.selectedSources + .where((e) => e.ignoreStatus != IgnoreViewType.hide) + .map( + (e) => ChatSourceTreeItem( + key: ValueKey( + 'selected_select_sources_tree_item_${e.view.id}', + ), + chatSource: e, + level: 0, + isDescendentOfSpace: e.view.isSpace, + isSelectedSection: true, + onSelected: (chatSource) { + context + .read() + .toggleSelectedStatus(chatSource); + }, + height: 30.0, + ), + ); + } + + Iterable _buildVisibleSources( + BuildContext context, + ChatSettingsState state, + ) { + return state.visibleSources + .where((e) => e.ignoreStatus != IgnoreViewType.hide) + .map( + (e) => ChatSourceTreeItem( + key: ValueKey( + 'visible_select_sources_tree_item_${e.view.id}', + ), + chatSource: e, + level: 0, + isDescendentOfSpace: e.view.isSpace, + isSelectedSection: false, + onSelected: (chatSource) { + context + .read() + .toggleSelectedStatus(chatSource); + }, + height: 30.0, + ), + ); + } +} + +class ChatSourceTreeItem extends StatefulWidget { + const ChatSourceTreeItem({ + super.key, + required this.chatSource, + required this.level, + required this.isDescendentOfSpace, + required this.isSelectedSection, + required this.onSelected, + required this.height, + this.showCheckbox = true, + }); + + final ChatSource chatSource; + + /// nested level of the view item + final int level; + + final bool isDescendentOfSpace; + + final bool isSelectedSection; + + final void Function(ChatSource chatSource) onSelected; + + final double height; + + final bool showCheckbox; + + @override + State createState() => _ChatSourceTreeItemState(); +} + +class _ChatSourceTreeItemState extends State { + @override + Widget build(BuildContext context) { + final child = SizedBox( + height: widget.height, + child: ChatSourceTreeItemInner( + chatSource: widget.chatSource, + level: widget.level, + isDescendentOfSpace: widget.isDescendentOfSpace, + isSelectedSection: widget.isSelectedSection, + showCheckbox: widget.showCheckbox, + onSelected: widget.onSelected, + ), + ); + + final disabledEnabledChild = + widget.chatSource.ignoreStatus == IgnoreViewType.disable + ? FlowyTooltip( + message: widget.showCheckbox + ? switch (widget.chatSource.view.layout) { + ViewLayoutPB.Document => + LocaleKeys.chat_sourcesLimitReached.tr(), + _ => LocaleKeys.chat_sourceUnsupported.tr(), + } + : "", + child: Opacity( + opacity: 0.5, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: IgnorePointer(child: child), + ), + ), + ) + : child; + + return ValueListenableBuilder( + valueListenable: widget.chatSource.isExpandedNotifier, + builder: (context, isExpanded, child) { + // filter the child views that should be ignored + final childViews = widget.chatSource.children + .where((e) => e.ignoreStatus != IgnoreViewType.hide) + .toList(); + + if (!isExpanded || childViews.isEmpty) { + return disabledEnabledChild; + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + disabledEnabledChild, + ...childViews.map( + (childSource) => ChatSourceTreeItem( + key: ValueKey( + 'select_sources_tree_item_${childSource.view.id}', + ), + chatSource: childSource, + level: widget.level + 1, + isDescendentOfSpace: widget.isDescendentOfSpace, + isSelectedSection: widget.isSelectedSection, + onSelected: widget.onSelected, + height: widget.height, + showCheckbox: widget.showCheckbox, + ), + ), + ], + ); + }, + ); + } +} + +class ChatSourceTreeItemInner extends StatelessWidget { + const ChatSourceTreeItemInner({ + super.key, + required this.chatSource, + required this.level, + required this.isDescendentOfSpace, + required this.isSelectedSection, + required this.showCheckbox, + this.onSelected, + }); + + final ChatSource chatSource; + final int level; + final bool isDescendentOfSpace; + final bool isSelectedSection; + final bool showCheckbox; + final void Function(ChatSource)? onSelected; + + @override + Widget build(BuildContext context) { + return FlowyHover( + style: HoverStyle( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => onSelected?.call(chatSource), + child: Row( + children: [ + const HSpace(4.0), + HSpace(max(20.0 * level - (isDescendentOfSpace ? 2 : 0), 0)), + // builds the >, ^ or · button + ToggleIsExpandedButton( + chatSource: chatSource, + isSelectedSection: isSelectedSection, + ), + const HSpace(2.0), + // checkbox + if (!chatSource.view.isSpace && showCheckbox) ...[ + SourceSelectedStatusCheckbox( + chatSource: chatSource, + ), + const HSpace(4.0), + ], + // icon + MentionViewIcon( + view: chatSource.view, + ), + const HSpace(6.0), + // title + Expanded( + child: FlowyText( + chatSource.view.nameOrDefault, + overflow: TextOverflow.ellipsis, + fontSize: 14.0, + figmaLineHeight: 18.0, + ), + ), + ], + ), + ), + ); + } +} + +class ToggleIsExpandedButton extends StatelessWidget { + const ToggleIsExpandedButton({ + super.key, + required this.chatSource, + required this.isSelectedSection, + }); + + final ChatSource chatSource; + final bool isSelectedSection; + + @override + Widget build(BuildContext context) { + if (isReferencedDatabaseView(chatSource.view, chatSource.parentView)) { + return const _DotIconWidget(); + } + + if (chatSource.children.isEmpty) { + return const SizedBox.square(dimension: 16.0); + } + + return FlowyHover( + child: GestureDetector( + child: ValueListenableBuilder( + valueListenable: chatSource.isExpandedNotifier, + builder: (context, value, _) => FlowySvg( + value + ? FlowySvgs.view_item_expand_s + : FlowySvgs.view_item_unexpand_s, + size: const Size.square(16.0), + ), + ), + onTap: () => context + .read() + .toggleIsExpanded(chatSource, isSelectedSection), + ), + ); + } +} + +class _DotIconWidget extends StatelessWidget { + const _DotIconWidget(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(6.0), + child: Container( + width: 4, + height: 4, + decoration: BoxDecoration( + color: Theme.of(context).iconTheme.color, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } +} + +class SourceSelectedStatusCheckbox extends StatelessWidget { + const SourceSelectedStatusCheckbox({ + super.key, + required this.chatSource, + }); + + final ChatSource chatSource; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: chatSource.selectedStatusNotifier, + builder: (context, selectedStatus, _) => FlowySvg( + switch (selectedStatus) { + SourceSelectedStatus.unselected => FlowySvgs.uncheck_s, + SourceSelectedStatus.selected => FlowySvgs.check_filled_s, + SourceSelectedStatus.partiallySelected => FlowySvgs.check_partial_s, + }, + size: const Size.square(18.0), + blendMode: null, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart new file mode 100644 index 0000000000000..fd937cc27f402 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_related_question.dart @@ -0,0 +1,88 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'layout_define.dart'; + +class RelatedQuestionList extends StatelessWidget { + const RelatedQuestionList({ + super.key, + required this.onQuestionSelected, + required this.relatedQuestions, + }); + + final Function(String) onQuestionSelected; + final List relatedQuestions; + + @override + Widget build(BuildContext context) { + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: relatedQuestions.length + 1, + padding: + const EdgeInsets.only(bottom: 8.0) + AIChatUILayout.messageMargin, + separatorBuilder: (context, index) => const VSpace(4.0), + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: FlowyText( + LocaleKeys.chat_relatedQuestion.tr(), + color: Theme.of(context).hintColor, + fontWeight: FontWeight.w600, + ), + ); + } else { + return Align( + alignment: AlignmentDirectional.centerStart, + child: RelatedQuestionItem( + question: relatedQuestions[index - 1], + onQuestionSelected: onQuestionSelected, + ), + ); + } + }, + ); + } +} + +class RelatedQuestionItem extends StatelessWidget { + const RelatedQuestionItem({ + required this.question, + required this.onQuestionSelected, + super.key, + }); + + final String question; + final Function(String) onQuestionSelected; + + @override + Widget build(BuildContext context) { + return FlowyButton( + mainAxisAlignment: MainAxisAlignment.start, + text: Flexible( + child: FlowyText( + question, + lineHeight: 1.4, + overflow: TextOverflow.ellipsis, + ), + ), + expandText: false, + margin: UniversalPlatform.isMobile + ? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0) + : const EdgeInsets.all(8.0), + leftIcon: FlowySvg( + FlowySvgs.ai_chat_outlined_s, + color: Theme.of(context).colorScheme.primary, + size: const Size.square(16.0), + ), + onTap: () => onQuestionSelected(question), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart new file mode 100644 index 0000000000000..6c3dd4bd4dbb5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/chat_welcome_page.dart @@ -0,0 +1,281 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class ChatWelcomePage extends StatelessWidget { + const ChatWelcomePage({ + required this.userProfile, + required this.onSelectedQuestion, + super.key, + }); + + final void Function(String) onSelectedQuestion; + final UserProfilePB userProfile; + + static final List desktopItems = [ + LocaleKeys.chat_question1.tr(), + LocaleKeys.chat_question2.tr(), + LocaleKeys.chat_question3.tr(), + LocaleKeys.chat_question4.tr(), + ]; + + static final List> mobileItems = [ + [ + LocaleKeys.chat_question1.tr(), + LocaleKeys.chat_question2.tr(), + ], + [ + LocaleKeys.chat_question3.tr(), + LocaleKeys.chat_question4.tr(), + ], + [ + LocaleKeys.chat_question5.tr(), + LocaleKeys.chat_question6.tr(), + ], + ]; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.flowy_logo_xl, + size: Size.square(32), + blendMode: null, + ), + const VSpace(16), + FlowyText( + fontSize: 15, + LocaleKeys.chat_questionDetail.tr(args: [userProfile.name]), + ), + UniversalPlatform.isDesktop ? const VSpace(32 - 16) : const VSpace(24), + ...UniversalPlatform.isDesktop + ? buildDesktopSampleQuestions(context) + : buildMobileSampleQuestions(context), + ], + ); + } + + Iterable buildDesktopSampleQuestions(BuildContext context) { + return desktopItems.map( + (question) => Padding( + padding: const EdgeInsets.only(top: 16.0), + child: WelcomeSampleQuestion( + question: question, + onSelected: onSelectedQuestion, + ), + ), + ); + } + + Iterable buildMobileSampleQuestions(BuildContext context) { + return [ + _AutoScrollingSampleQuestions( + key: const ValueKey('inf_scroll_1'), + onSelected: onSelectedQuestion, + questions: mobileItems[0], + offset: 60.0, + ), + const VSpace(8), + _AutoScrollingSampleQuestions( + key: const ValueKey('inf_scroll_2'), + onSelected: onSelectedQuestion, + questions: mobileItems[1], + offset: -50.0, + reverse: true, + ), + const VSpace(8), + _AutoScrollingSampleQuestions( + key: const ValueKey('inf_scroll_3'), + onSelected: onSelectedQuestion, + questions: mobileItems[2], + offset: 120.0, + ), + ]; + } +} + +class WelcomeSampleQuestion extends StatelessWidget { + const WelcomeSampleQuestion({ + required this.question, + required this.onSelected, + super.key, + }); + + final void Function(String) onSelected; + final String question; + + @override + Widget build(BuildContext context) { + final isLightMode = Theme.of(context).isLightMode; + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + offset: const Offset(0, 1), + blurRadius: 2, + spreadRadius: -2, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withOpacity(0.02), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 4, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withOpacity(0.02), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 8, + spreadRadius: 2, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withOpacity(0.02), + ), + ], + ), + child: TextButton( + onPressed: () => onSelected(question), + style: ButtonStyle( + padding: WidgetStatePropertyAll( + EdgeInsets.symmetric( + horizontal: 16, + vertical: UniversalPlatform.isDesktop ? 8 : 0, + ), + ), + backgroundColor: WidgetStateProperty.resolveWith((state) { + if (state.contains(WidgetState.hovered)) { + return isLightMode + ? const Color(0xFFF9FAFD) + : AFThemeExtension.of(context).lightGreyHover; + } + return Theme.of(context).colorScheme.surface; + }), + overlayColor: WidgetStateColor.transparent, + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + ), + child: FlowyText( + question, + color: isLightMode + ? Theme.of(context).hintColor + : const Color(0xFF666D76), + ), + ), + ); + } +} + +class _AutoScrollingSampleQuestions extends StatefulWidget { + const _AutoScrollingSampleQuestions({ + super.key, + required this.questions, + this.offset = 0.0, + this.reverse = false, + required this.onSelected, + }); + + final List questions; + final void Function(String) onSelected; + final double offset; + final bool reverse; + + @override + State<_AutoScrollingSampleQuestions> createState() => + _AutoScrollingSampleQuestionsState(); +} + +class _AutoScrollingSampleQuestionsState + extends State<_AutoScrollingSampleQuestions> { + late final scrollController = ScrollController( + initialScrollOffset: widget.offset, + ); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 36, + child: InfiniteScrollView( + scrollController: scrollController, + centerKey: UniqueKey(), + itemCount: widget.questions.length, + itemBuilder: (context, index) { + return WelcomeSampleQuestion( + question: widget.questions[index], + onSelected: widget.onSelected, + ); + }, + separatorBuilder: (context, index) => const HSpace(8), + ), + ); + } +} + +class InfiniteScrollView extends StatelessWidget { + const InfiniteScrollView({ + super.key, + required this.itemCount, + required this.centerKey, + required this.itemBuilder, + required this.separatorBuilder, + this.scrollController, + }); + + final int itemCount; + final Widget Function(BuildContext context, int index) itemBuilder; + final Widget Function(BuildContext context, int index) separatorBuilder; + final Key centerKey; + + final ScrollController? scrollController; + + @override + Widget build(BuildContext context) { + return CustomScrollView( + scrollDirection: Axis.horizontal, + controller: scrollController, + center: centerKey, + anchor: 0.5, + slivers: [ + _buildList(isForward: false), + SliverToBoxAdapter( + child: separatorBuilder.call(context, 0), + ), + SliverToBoxAdapter( + key: centerKey, + child: itemBuilder.call(context, 0), + ), + SliverToBoxAdapter( + child: separatorBuilder.call(context, 0), + ), + _buildList(isForward: true), + ], + ); + } + + Widget _buildList({required bool isForward}) { + return SliverList.separated( + itemBuilder: (context, index) { + index = (index + 1) % itemCount; + return itemBuilder(context, index); + }, + separatorBuilder: (context, index) { + index = (index + 1) % itemCount; + return separatorBuilder(context, index); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart new file mode 100644 index 0000000000000..45947ca2d679a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/layout_define.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class AIChatUILayout { + static EdgeInsets safeAreaInsets(BuildContext context) { + final query = MediaQuery.of(context); + return UniversalPlatform.isMobile + ? EdgeInsets.fromLTRB( + query.padding.left, + 0, + query.padding.right, + query.viewInsets.bottom + query.padding.bottom, + ) + : const EdgeInsets.only(bottom: 24); + } + + static EdgeInsets get messageMargin => UniversalPlatform.isMobile + ? const EdgeInsets.symmetric(horizontal: 16) + : EdgeInsets.zero; +} + +class DesktopAIPromptSizes { + static const promptFrameRadius = BorderRadius.all(Radius.circular(12.0)); + + static const attachedFilesBarPadding = + EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0); + static const attachedFilesPreviewHeight = 48.0; + static const attachedFilesPreviewSpacing = 12.0; + + static const textFieldMinHeight = 40.0; + static const textFieldContentPadding = + EdgeInsetsDirectional.fromSTEB(14.0, 12.0, 14.0, 8.0); + + static const actionBarHeight = 32.0; + static const actionBarPadding = EdgeInsetsDirectional.fromSTEB(8, 0, 8, 4); + static const actionBarButtonSize = 28.0; + static const actionBarIconSize = 16.0; + static const actionBarButtonSpacing = 4.0; + static const sendButtonSize = 24.0; +} + +class MobileAIPromptSizes { + static const promptFrameRadius = + BorderRadius.vertical(top: Radius.circular(8.0)); + + static const attachedFilesBarHeight = 68.0; + static const attachedFilesBarPadding = + EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0, bottom: 4.0); + static const attachedFilesPreviewHeight = 56.0; + static const attachedFilesPreviewSpacing = 8.0; + + static const textFieldMinHeight = 48.0; + static const textFieldContentPadding = EdgeInsets.all(8.0); + + static const mentionIconSize = 20.0; + static const sendButtonSize = 32.0; +} + +class DesktopAIConvoSizes { + static const avatarSize = 32.0; + + static const avatarAndChatBubbleSpacing = 12.0; + + static const actionBarIconSize = 28.0; + static const actionBarIconSpacing = 8.0; + static const hoverActionBarPadding = EdgeInsets.all(2.0); + static const hoverActionBarRadius = BorderRadius.all(Radius.circular(8.0)); + static const hoverActionBarIconRadius = + BorderRadius.all(Radius.circular(6.0)); + static const actionBarIconRadius = BorderRadius.all(Radius.circular(8.0)); +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart new file mode 100644 index 0000000000000..12c9cc7e68bc0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_markdown_text.dart @@ -0,0 +1,135 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../chat_editor_style.dart'; + +// Wrap the appflowy_editor as a chat text message widget +class AIMarkdownText extends StatelessWidget { + const AIMarkdownText({ + super.key, + required this.markdown, + }); + + final String markdown; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DocumentPageStyleBloc(view: ViewPB()) + ..add(const DocumentPageStyleEvent.initial()), + child: _AppFlowyEditorMarkdown(markdown: markdown), + ); + } +} + +class _AppFlowyEditorMarkdown extends StatefulWidget { + const _AppFlowyEditorMarkdown({ + required this.markdown, + }); + + // the text should be the markdown format + final String markdown; + + @override + State<_AppFlowyEditorMarkdown> createState() => + _AppFlowyEditorMarkdownState(); +} + +class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> { + late EditorState editorState; + late EditorScrollController scrollController; + + @override + void initState() { + super.initState(); + + editorState = _parseMarkdown(widget.markdown); + scrollController = EditorScrollController( + editorState: editorState, + shrinkWrap: true, + ); + } + + @override + void didUpdateWidget(covariant _AppFlowyEditorMarkdown oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.markdown != widget.markdown) { + editorState.dispose(); + editorState = _parseMarkdown(widget.markdown); + scrollController.dispose(); + scrollController = EditorScrollController( + editorState: editorState, + shrinkWrap: true, + ); + } + } + + @override + void dispose() { + scrollController.dispose(); + editorState.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // don't lazy load the styleCustomizer and blockBuilders, + // it needs the context to get the theme. + final styleCustomizer = ChatEditorStyleCustomizer( + context: context, + padding: EdgeInsets.zero, + ); + final editorStyle = styleCustomizer.style().copyWith( + // hide the cursor + cursorColor: Colors.transparent, + cursorWidth: 0, + ); + final blockBuilders = buildBlockComponentBuilders( + context: context, + editorState: editorState, + styleCustomizer: styleCustomizer, + // the editor is not editable in the chat + editable: false, + ); + return IntrinsicHeight( + child: AppFlowyEditor( + shrinkWrap: true, + // the editor is not editable in the chat + editable: false, + disableKeyboardService: UniversalPlatform.isMobile, + editorStyle: editorStyle, + editorScrollController: scrollController, + blockComponentBuilders: blockBuilders, + commandShortcutEvents: [customCopyCommand], + disableAutoScroll: true, + editorState: editorState, + contextMenuItems: [ + [ + ContextMenuItem( + getName: LocaleKeys.document_plugins_contextMenu_copy.tr, + onPressed: (editorState) => + customCopyCommand.execute(editorState), + ), + ] + ], + ), + ); + } + + EditorState _parseMarkdown(String markdown) { + final document = customMarkdownToDocument(markdown); + final editorState = EditorState(document: document); + return editorState; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart new file mode 100644 index 0000000000000..fe3f1413d9421 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart @@ -0,0 +1,451 @@ +import 'dart:convert'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/user/prelude.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; + +import '../chat_input/select_sources_menu.dart'; +import '../layout_define.dart'; +import 'message_util.dart'; + +class AIMessageActionBar extends StatelessWidget { + const AIMessageActionBar({ + super.key, + required this.message, + required this.showDecoration, + this.onRegenerate, + this.onOverrideVisibility, + }); + + final Message message; + final bool showDecoration; + final void Function()? onRegenerate; + final void Function(bool)? onOverrideVisibility; + + @override + Widget build(BuildContext context) { + final isLightMode = Theme.of(context).isLightMode; + + final child = SeparatedRow( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => + const HSpace(DesktopAIConvoSizes.actionBarIconSpacing), + children: _buildChildren(), + ); + + return showDecoration + ? Container( + padding: const EdgeInsets.all(2.0), + decoration: BoxDecoration( + borderRadius: DesktopAIConvoSizes.hoverActionBarRadius, + border: Border.all( + color: isLightMode + ? const Color(0x1F1F2329) + : Theme.of(context).dividerColor, + ), + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + offset: const Offset(0, 1), + blurRadius: 2, + spreadRadius: -2, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withOpacity(0.02), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 4, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withOpacity(0.02), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 8, + spreadRadius: 2, + color: isLightMode + ? const Color(0x051F2329) + : Theme.of(context).shadowColor.withOpacity(0.02), + ), + ], + ), + child: child, + ) + : child; + } + + List _buildChildren() { + return [ + CopyButton( + isInHoverBar: showDecoration, + textMessage: message as TextMessage, + ), + RegenerateButton( + isInHoverBar: showDecoration, + onTap: () => onRegenerate?.call(), + ), + SaveToPageButton( + textMessage: message as TextMessage, + isInHoverBar: showDecoration, + onOverrideVisibility: onOverrideVisibility, + ), + ]; + } +} + +class CopyButton extends StatelessWidget { + const CopyButton({ + super.key, + required this.isInHoverBar, + required this.textMessage, + }); + + final bool isInHoverBar; + final TextMessage textMessage; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.settings_menu_clickToCopy.tr(), + child: FlowyIconButton( + width: DesktopAIConvoSizes.actionBarIconSize, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: isInHoverBar + ? DesktopAIConvoSizes.hoverActionBarIconRadius + : DesktopAIConvoSizes.actionBarIconRadius, + icon: FlowySvg( + FlowySvgs.copy_s, + color: Theme.of(context).hintColor, + size: const Size.square(16), + ), + onPressed: () async { + final document = customMarkdownToDocument(textMessage.text); + await getIt().setData( + ClipboardServiceData( + plainText: textMessage.text, + inAppJson: jsonEncode(document.toJson()), + ), + ); + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys.grid_url_copiedNotification.tr(), + ); + } + }, + ), + ); + } +} + +class RegenerateButton extends StatelessWidget { + const RegenerateButton({ + super.key, + required this.isInHoverBar, + required this.onTap, + }); + + final bool isInHoverBar; + final void Function() onTap; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.chat_regenerate.tr(), + child: FlowyIconButton( + width: DesktopAIConvoSizes.actionBarIconSize, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: isInHoverBar + ? DesktopAIConvoSizes.hoverActionBarIconRadius + : DesktopAIConvoSizes.actionBarIconRadius, + icon: FlowySvg( + FlowySvgs.ai_undo_s, + color: Theme.of(context).hintColor, + size: const Size.square(16), + ), + onPressed: onTap, + ), + ); + } +} + +class SaveToPageButton extends StatefulWidget { + const SaveToPageButton({ + super.key, + required this.textMessage, + required this.isInHoverBar, + this.onOverrideVisibility, + }); + + final TextMessage textMessage; + final bool isInHoverBar; + final void Function(bool)? onOverrideVisibility; + + @override + State createState() => _SaveToPageButtonState(); +} + +class _SaveToPageButtonState extends State { + final popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + final userWorkspaceBloc = context.read(); + final userProfile = userWorkspaceBloc.userProfile; + final workspaceId = + userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? ''; + + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => SpaceBloc( + userProfile: userProfile, + workspaceId: workspaceId, + )..add(const SpaceEvent.initial(openFirstPage: false)), + ), + BlocProvider( + create: (context) => ChatSettingsCubit(), + ), + ], + child: BlocSelector( + selector: (state) => state.currentSpace, + builder: (context, spaceView) { + return AppFlowyPopover( + controller: popoverController, + triggerActions: PopoverTriggerFlags.none, + margin: EdgeInsets.zero, + offset: const Offset(8, 0), + direction: PopoverDirection.rightWithBottomAligned, + constraints: const BoxConstraints.tightFor(width: 300, height: 400), + onClose: () { + if (spaceView != null) { + context.read().refreshSources(spaceView); + } + widget.onOverrideVisibility?.call(false); + }, + child: buildButton(context, spaceView), + popupBuilder: (_) => buildPopover(context), + ); + }, + ), + ); + } + + Widget buildButton(BuildContext context, ViewPB? spaceView) { + return FlowyTooltip( + message: LocaleKeys.chat_addToPageButton.tr(), + child: FlowyIconButton( + width: DesktopAIConvoSizes.actionBarIconSize, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + radius: widget.isInHoverBar + ? DesktopAIConvoSizes.hoverActionBarIconRadius + : DesktopAIConvoSizes.actionBarIconRadius, + icon: FlowySvg( + FlowySvgs.ai_add_to_page_s, + color: Theme.of(context).hintColor, + size: const Size.square(16), + ), + onPressed: () async { + final documentId = getOpenedDocumentId(); + if (documentId != null) { + await onAddToExistingPage(documentId); + DocumentBloc.findOpen(documentId)?.forceReloadDocumentState(); + } else { + widget.onOverrideVisibility?.call(true); + if (spaceView != null) { + context.read().refreshSources(spaceView); + } + popoverController.show(); + } + }, + ), + ); + } + + Widget buildPopover(BuildContext context) { + return BlocProvider.value( + value: context.read(), + child: _SaveToPagePopoverContent( + onAddToNewPage: () { + addMessageToNewPage(context); + popoverController.close(); + }, + onAddToExistingPage: (documentId) async { + popoverController.close(); + await onAddToExistingPage(documentId); + final view = + await ViewBackendService.getView(documentId).toNullable(); + if (context.mounted) { + openPageFromMessage(context, view); + } + }, + ), + ); + } + + Future onAddToExistingPage(String documentId) async { + await ChatEditDocumentService.addMessageToPage( + documentId, + widget.textMessage, + ); + await Future.delayed(const Duration(milliseconds: 500)); + } + + void addMessageToNewPage(BuildContext context) async { + final chatView = await ViewBackendService.getView( + context.read().chatId, + ).toNullable(); + if (chatView != null) { + final newView = await ChatEditDocumentService.saveMessagesToNewPage( + chatView.nameOrDefault, + chatView.parentViewId, + [widget.textMessage], + ); + if (context.mounted) { + openPageFromMessage(context, newView); + } + } + } + + String? getOpenedDocumentId() { + final pageManager = getIt().state.currentPageManager; + if (!pageManager.showSecondaryPluginNotifier.value) { + return null; + } + return pageManager.secondaryNotifier.plugin.id; + } +} + +class _SaveToPagePopoverContent extends StatelessWidget { + const _SaveToPagePopoverContent({ + required this.onAddToNewPage, + required this.onAddToExistingPage, + }); + + final void Function() onAddToNewPage; + final void Function(String) onAddToExistingPage; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 24, + margin: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: FlowyText( + LocaleKeys.chat_addToPageTitle.tr(), + fontSize: 12.0, + color: Theme.of(context).hintColor, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 12, right: 12, bottom: 8), + child: SpaceSearchField( + width: 600, + onSearch: (context, value) => + context.read().updateFilter(value), + ), + ), + _buildDivider(), + Expanded( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), + children: _buildVisibleSources(context, state).toList(), + ), + ), + _buildDivider(), + _addToNewPageButton(context), + ], + ); + }, + ); + } + + Widget _buildDivider() { + return const Divider( + height: 1.0, + thickness: 1.0, + indent: 12.0, + endIndent: 12.0, + ); + } + + Iterable _buildVisibleSources( + BuildContext context, + ChatSettingsState state, + ) { + return state.visibleSources + .where((e) => e.ignoreStatus != IgnoreViewType.hide) + .map( + (e) => ChatSourceTreeItem( + key: ValueKey( + 'save_to_page_tree_item_${e.view.id}', + ), + chatSource: e, + level: 0, + isDescendentOfSpace: e.view.isSpace, + isSelectedSection: false, + showCheckbox: false, + onSelected: (source) { + if (!source.view.isSpace) { + onAddToExistingPage(source.view.id); + } + }, + height: 30.0, + ), + ); + } + + Widget _addToNewPageButton(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: SizedBox( + height: 30, + child: FlowyButton( + iconPadding: 8, + onTap: onAddToNewPage, + text: FlowyText( + LocaleKeys.chat_addToNewPage.tr(), + figmaLineHeight: 20, + ), + leftIcon: FlowySvg( + FlowySvgs.add_m, + size: const Size.square(16), + color: Theme.of(context).hintColor, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart new file mode 100644 index 0000000000000..93f350b1c429c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_bubble.dart @@ -0,0 +1,397 @@ +import 'dart:convert'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_mention_page_bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:go_router/go_router.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../chat_avatar.dart'; +import '../layout_define.dart'; +import 'ai_message_action_bar.dart'; +import 'message_util.dart'; + +/// Wraps an AI response message with the avatar and actions. On desktop, +/// the actions will be displayed below the response if the response is the +/// last message in the chat. For the others, the actions will be shown on hover +/// On mobile, the actions will be displayed in a bottom sheet on long press. +class ChatAIMessageBubble extends StatelessWidget { + const ChatAIMessageBubble({ + super.key, + required this.message, + required this.child, + required this.showActions, + this.isLastMessage = false, + this.onRegenerate, + }); + + final Message message; + final Widget child; + final bool showActions; + final bool isLastMessage; + final void Function()? onRegenerate; + + @override + Widget build(BuildContext context) { + final avatarAndMessage = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ChatAIAvatar(), + const HSpace(DesktopAIConvoSizes.avatarAndChatBubbleSpacing), + Expanded(child: child), + ], + ); + + return showActions + ? UniversalPlatform.isMobile + ? _wrapPopMenu(avatarAndMessage) + : isLastMessage + ? _wrapBottomActions(avatarAndMessage) + : _wrapHover(avatarAndMessage) + : avatarAndMessage; + } + + Widget _wrapBottomActions(Widget child) { + return ChatAIBottomInlineActions( + message: message, + onRegenerate: onRegenerate, + child: child, + ); + } + + Widget _wrapHover(Widget child) { + return ChatAIMessageHover( + message: message, + onRegenerate: onRegenerate, + child: child, + ); + } + + Widget _wrapPopMenu(Widget child) { + return ChatAIMessagePopup( + message: message, + onRegenerate: onRegenerate, + child: child, + ); + } +} + +class ChatAIBottomInlineActions extends StatelessWidget { + const ChatAIBottomInlineActions({ + super.key, + required this.child, + required this.message, + this.onRegenerate, + }); + + final Widget child; + final Message message; + final void Function()? onRegenerate; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + child, + const VSpace(16.0), + Padding( + padding: const EdgeInsetsDirectional.only( + start: DesktopAIConvoSizes.avatarSize + + DesktopAIConvoSizes.avatarAndChatBubbleSpacing, + ), + child: AIMessageActionBar( + message: message, + showDecoration: false, + onRegenerate: onRegenerate, + ), + ), + const VSpace(32.0), + ], + ); + } +} + +class ChatAIMessageHover extends StatefulWidget { + const ChatAIMessageHover({ + super.key, + required this.child, + required this.message, + this.onRegenerate, + }); + + final Widget child; + final Message message; + final void Function()? onRegenerate; + + @override + State createState() => _ChatAIMessageHoverState(); +} + +class _ChatAIMessageHoverState extends State { + final controller = OverlayPortalController(); + final layerLink = LayerLink(); + + bool hoverBubble = false; + bool hoverActionBar = false; + bool overrideVisibility = false; + + ScrollPosition? scrollPosition; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + addScrollListener(); + controller.show(); + }); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + opaque: false, + onEnter: (_) { + if (!hoverBubble && isBottomOfWidgetVisible(context)) { + setState(() => hoverBubble = true); + } + }, + onHover: (_) { + if (!hoverBubble && isBottomOfWidgetVisible(context)) { + setState(() => hoverBubble = true); + } + }, + onExit: (_) { + if (hoverBubble) { + setState(() => hoverBubble = false); + } + }, + child: OverlayPortal( + controller: controller, + overlayChildBuilder: (_) { + return CompositedTransformFollower( + showWhenUnlinked: false, + link: layerLink, + targetAnchor: Alignment.bottomLeft, + offset: const Offset( + DesktopAIConvoSizes.avatarSize + + DesktopAIConvoSizes.avatarAndChatBubbleSpacing, + 0, + ), + child: Align( + alignment: Alignment.topLeft, + child: MouseRegion( + opaque: false, + onEnter: (_) { + if (!hoverActionBar && isBottomOfWidgetVisible(context)) { + setState(() => hoverActionBar = true); + } + }, + onExit: (_) { + if (hoverActionBar) { + setState(() => hoverActionBar = false); + } + }, + child: Container( + constraints: BoxConstraints( + maxWidth: 784, + maxHeight: DesktopAIConvoSizes.actionBarIconSize + + DesktopAIConvoSizes.hoverActionBarPadding.vertical, + ), + alignment: Alignment.topLeft, + child: hoverBubble || hoverActionBar || overrideVisibility + ? AIMessageActionBar( + message: widget.message, + showDecoration: true, + onRegenerate: widget.onRegenerate, + onOverrideVisibility: (visibility) { + overrideVisibility = visibility; + }, + ) + : null, + ), + ), + ), + ); + }, + child: CompositedTransformTarget( + link: layerLink, + child: widget.child, + ), + ), + ); + } + + void addScrollListener() { + if (!mounted) { + return; + } + scrollPosition = Scrollable.maybeOf(context)?.position; + scrollPosition?.addListener(handleScroll); + } + + void handleScroll() { + if (!mounted) { + return; + } + if ((hoverActionBar || hoverBubble) && !isBottomOfWidgetVisible(context)) { + setState(() { + hoverBubble = false; + hoverActionBar = false; + }); + } + } + + bool isBottomOfWidgetVisible(BuildContext context) { + if (Scrollable.maybeOf(context) == null) { + return false; + } + final scrollableRenderBox = + Scrollable.of(context).context.findRenderObject() as RenderBox; + final scrollableHeight = scrollableRenderBox.size.height; + final scrollableOffset = scrollableRenderBox.localToGlobal(Offset.zero); + + final messageRenderBox = context.findRenderObject() as RenderBox; + final messageOffset = messageRenderBox.localToGlobal(Offset.zero); + final messageHeight = messageRenderBox.size.height; + + return messageOffset.dy + + messageHeight + + DesktopAIConvoSizes.actionBarIconSize + + DesktopAIConvoSizes.hoverActionBarPadding.vertical <= + scrollableOffset.dy + scrollableHeight; + } + + @override + void dispose() { + scrollPosition?.isScrollingNotifier.removeListener(handleScroll); + super.dispose(); + } +} + +class ChatAIMessagePopup extends StatelessWidget { + const ChatAIMessagePopup({ + super.key, + required this.child, + required this.message, + this.onRegenerate, + }); + + final Widget child; + final Message message; + final void Function()? onRegenerate; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPress: () { + showMobileBottomSheet( + context, + showDragHandle: true, + backgroundColor: AFThemeExtension.of(context).background, + builder: (bottomSheetContext) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const VSpace(16.0), + _copyButton(context, bottomSheetContext), + const Divider(height: 8.5, thickness: 0.5), + _regenerateButton(context), + const Divider(height: 8.5, thickness: 0.5), + _saveToPageButton(context), + const Divider(height: 8.5, thickness: 0.5), + ], + ); + }, + ); + }, + child: child, + ); + } + + Widget _copyButton(BuildContext context, BuildContext bottomSheetContext) { + return MobileQuickActionButton( + onTap: () async { + if (message is! TextMessage) { + return; + } + final textMessage = message as TextMessage; + final document = customMarkdownToDocument(textMessage.text); + await getIt().setData( + ClipboardServiceData( + plainText: textMessage.text, + inAppJson: jsonEncode(document.toJson()), + ), + ); + if (bottomSheetContext.mounted) { + Navigator.of(bottomSheetContext).pop(); + } + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys.grid_url_copiedNotification.tr(), + ); + } + }, + icon: FlowySvgs.copy_s, + iconSize: const Size.square(20), + text: LocaleKeys.button_copy.tr(), + ); + } + + Widget _regenerateButton(BuildContext context) { + return MobileQuickActionButton( + onTap: () { + onRegenerate?.call(); + Navigator.of(context).pop(); + }, + icon: FlowySvgs.ai_undo_s, + iconSize: const Size.square(20), + text: LocaleKeys.chat_regenerate.tr(), + ); + } + + Widget _saveToPageButton(BuildContext context) { + return MobileQuickActionButton( + onTap: () async { + final selectedView = await showPageSelectorSheet( + context, + filter: (view) => + !view.isSpace && + view.layout.isDocumentView && + view.parentViewId != view.id, + ); + if (selectedView == null) { + return; + } + + await ChatEditDocumentService.addMessageToPage( + selectedView.id, + message as TextMessage, + ); + + if (context.mounted) { + context.pop(); + openPageFromMessage(context, selectedView); + } + }, + icon: FlowySvgs.ai_add_to_page_s, + iconSize: const Size.square(20), + text: LocaleKeys.chat_addToPageButton.tr(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_metadata.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_metadata.dart new file mode 100644 index 0000000000000..d55be401f91a2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_metadata.dart @@ -0,0 +1,103 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:time/time.dart'; + +class AIMessageMetadata extends StatefulWidget { + const AIMessageMetadata({ + required this.sources, + required this.onSelectedMetadata, + super.key, + }); + + final List sources; + final void Function(ChatMessageRefSource metadata)? onSelectedMetadata; + + @override + State createState() => _AIMessageMetadataState(); +} + +class _AIMessageMetadataState extends State { + bool isExpanded = true; + + @override + Widget build(BuildContext context) { + return AnimatedSize( + duration: 150.milliseconds, + alignment: AlignmentDirectional.topStart, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(8.0), + ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 24, + maxWidth: 240, + ), + child: FlowyButton( + margin: const EdgeInsets.all(4.0), + useIntrinsicWidth: true, + hoverColor: Colors.transparent, + radius: BorderRadius.circular(8.0), + text: FlowyText( + LocaleKeys.chat_referenceSource.plural( + widget.sources.length, + namedArgs: {'count': '${widget.sources.length}'}, + ), + fontSize: 12, + color: Theme.of(context).hintColor, + ), + rightIcon: FlowySvg( + isExpanded ? FlowySvgs.arrow_up_s : FlowySvgs.arrow_down_s, + size: const Size.square(10), + ), + onTap: () { + setState(() => isExpanded = !isExpanded); + }, + ), + ), + if (isExpanded) ...[ + const VSpace(4.0), + Wrap( + spacing: 8.0, + runSpacing: 4.0, + children: widget.sources.map( + (m) { + return ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 24, + maxWidth: 240, + ), + child: FlowyButton( + margin: const EdgeInsets.all(4.0), + useIntrinsicWidth: true, + radius: BorderRadius.circular(8.0), + text: FlowyText( + m.name, + fontSize: 12, + overflow: TextOverflow.ellipsis, + ), + leftIcon: FlowySvg( + FlowySvgs.icon_document_s, + size: const Size.square(16), + color: Theme.of(context).hintColor, + ), + onTap: () { + widget.onSelectedMetadata?.call(m); + }, + ), + ); + }, + ).toList(), + ), + ], + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart new file mode 100644 index 0000000000000..a4ef77bd06250 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_text_message.dart @@ -0,0 +1,125 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../layout_define.dart'; +import 'ai_markdown_text.dart'; +import 'ai_message_bubble.dart'; +import 'ai_metadata.dart'; +import 'loading_indicator.dart'; +import 'error_text_message.dart'; + +/// [ChatAIMessageWidget] includes both the text of the AI response as well as +/// the avatar, decorations and hover effects that are also rendered. This is +/// different from [ChatUserMessageWidget] which only contains the message and +/// has to be separately wrapped with a bubble since the hover effects need to +/// know the current streaming status of the message. +class ChatAIMessageWidget extends StatelessWidget { + const ChatAIMessageWidget({ + super.key, + required this.user, + required this.messageUserId, + required this.message, + required this.stream, + required this.questionId, + required this.chatId, + required this.refSourceJsonString, + this.onSelectedMetadata, + this.onRegenerate, + this.isLastMessage = false, + this.isStreaming = false, + }); + + final User user; + final String messageUserId; + + final Message message; + final AnswerStream? stream; + final Int64? questionId; + final String chatId; + final String? refSourceJsonString; + final void Function(ChatMessageRefSource metadata)? onSelectedMetadata; + final void Function()? onRegenerate; + final bool isStreaming; + final bool isLastMessage; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => ChatAIMessageBloc( + message: stream ?? (message as TextMessage).text, + refSourceJsonString: refSourceJsonString, + chatId: chatId, + questionId: questionId, + ), + child: BlocBuilder( + builder: (context, state) { + final loadingText = + state.progress?.step ?? LocaleKeys.chat_generatingResponse.tr(); + + return Padding( + padding: AIChatUILayout.messageMargin, + child: state.messageState.when( + loading: () => ChatAIMessageBubble( + message: message, + showActions: false, + child: ChatAILoading(text: loadingText), + ), + ready: () { + return state.text.isEmpty + ? ChatAIMessageBubble( + message: message, + showActions: false, + child: ChatAILoading(text: loadingText), + ) + : ChatAIMessageBubble( + message: message, + isLastMessage: isLastMessage, + showActions: stream == null && + state.text.isNotEmpty && + !isStreaming, + onRegenerate: onRegenerate, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IgnorePointer( + ignoring: UniversalPlatform.isMobile, + child: AIMarkdownText(markdown: state.text), + ), + if (state.sources.isNotEmpty) + AIMessageMetadata( + sources: state.sources, + onSelectedMetadata: onSelectedMetadata, + ), + if (state.sources.isNotEmpty && !isLastMessage) + const VSpace(16.0), + ], + ), + ); + }, + onError: (error) { + return ChatErrorMessageWidget( + errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(), + ); + }, + onAIResponseLimit: () { + return ChatErrorMessageWidget( + errorMessage: + LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(), + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/error_text_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/error_text_message.dart new file mode 100644 index 0000000000000..66b39fe3083b4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/error_text_message.dart @@ -0,0 +1,107 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class ChatErrorMessageWidget extends StatefulWidget { + const ChatErrorMessageWidget({ + super.key, + required this.errorMessage, + this.onRetry, + }); + + final String errorMessage; + final VoidCallback? onRetry; + + @override + State createState() => _ChatErrorMessageWidgetState(); +} + +class _ChatErrorMessageWidgetState extends State { + late final TapGestureRecognizer recognizer; + + @override + void initState() { + super.initState(); + recognizer = TapGestureRecognizer()..onTap = widget.onRetry; + } + + @override + void dispose() { + recognizer.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + margin: const EdgeInsets.only(top: 16.0, bottom: 24.0) + + (UniversalPlatform.isMobile + ? const EdgeInsets.symmetric(horizontal: 16) + : EdgeInsets.zero), + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? const Color(0x80FFE7EE) + : const Color(0x80591734), + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + constraints: UniversalPlatform.isDesktop + ? const BoxConstraints(maxWidth: 480) + : null, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.warning_filled_s, + blendMode: null, + ), + const HSpace(8.0), + Flexible( + child: _buildText(), + ), + ], + ), + ), + ); + } + + Widget _buildText() { + final errorMessage = widget.errorMessage; + + return widget.onRetry != null + ? RichText( + text: TextSpan( + children: [ + TextSpan( + text: errorMessage, + style: Theme.of(context).textTheme.bodyMedium, + ), + TextSpan( + text: ' ', + style: Theme.of(context).textTheme.bodyMedium, + ), + TextSpan( + text: LocaleKeys.chat_retry.tr(), + recognizer: recognizer, + mouseCursor: SystemMouseCursors.click, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + ), + ], + ), + ) + : FlowyText( + errorMessage, + lineHeight: 1.4, + maxLines: null, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/loading_indicator.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/loading_indicator.dart new file mode 100644 index 0000000000000..be7bbf7ac98d5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/loading_indicator.dart @@ -0,0 +1,79 @@ +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; + +/// An animated generating indicator for an AI response +class ChatAILoading extends StatelessWidget { + const ChatAILoading({ + super.key, + this.text = "", + this.duration = const Duration(seconds: 1), + }); + + final String text; + final Duration duration; + + @override + Widget build(BuildContext context) { + final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5); + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: SizedBox( + height: 20, + child: SeparatedRow( + separatorBuilder: () => const HSpace(4), + children: [ + Padding( + padding: const EdgeInsetsDirectional.only(end: 4.0), + child: FlowyText( + text, + color: Theme.of(context).hintColor, + ), + ), + buildDot(const Color(0xFF9327FF)) + .animate(onPlay: (controller) => controller.repeat()) + .slideY(duration: slice, begin: 0, end: -1) + .then() + .slideY(begin: -1, end: 1) + .then() + .slideY(begin: 1, end: 0) + .then() + .slideY(duration: slice * 2, begin: 0, end: 0), + buildDot(const Color(0xFFFB006D)) + .animate(onPlay: (controller) => controller.repeat()) + .slideY(duration: slice, begin: 0, end: 0) + .then() + .slideY(begin: 0, end: -1) + .then() + .slideY(begin: -1, end: 1) + .then() + .slideY(begin: 1, end: 0) + .then() + .slideY(begin: 0, end: 0), + buildDot(const Color(0xFFFFCE00)) + .animate(onPlay: (controller) => controller.repeat()) + .slideY(duration: slice * 2, begin: 0, end: 0) + .then() + .slideY(duration: slice, begin: 0, end: -1) + .then() + .slideY(begin: -1, end: 1) + .then() + .slideY(begin: 1, end: 0), + ], + ), + ), + ); + } + + Widget buildDot(Color color) { + return SizedBox.square( + dimension: 4, + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart new file mode 100644 index 0000000000000..4673b9def18cb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/message_util.dart @@ -0,0 +1,24 @@ +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:flutter/widgets.dart'; +import 'package:universal_platform/universal_platform.dart'; + +/// Opens a message in the right hand sidebar on desktop, and push the page +/// on mobile +void openPageFromMessage(BuildContext context, ViewPB? view) { + if (view == null) { + return; + } + if (UniversalPlatform.isDesktop) { + getIt().add( + TabsEvent.openSecondaryPlugin( + plugin: view.plugin(), + ), + ); + } else { + context.pushView(view); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart new file mode 100644 index 0000000000000..778ca3d0e9d9d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_message_bubble.dart @@ -0,0 +1,170 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_member_bloc.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; + +import '../chat_avatar.dart'; +import '../layout_define.dart'; + +class ChatUserMessageBubble extends StatelessWidget { + const ChatUserMessageBubble({ + super.key, + required this.message, + required this.child, + required this.isCurrentUser, + this.files = const [], + }); + + final Message message; + final Widget child; + final bool isCurrentUser; + final List files; + + @override + Widget build(BuildContext context) { + context + .read() + .add(ChatMemberEvent.getMemberInfo(message.author.id)); + + return Padding( + padding: AIChatUILayout.messageMargin, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (files.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.only(right: 32), + child: _MessageFileList(files: files), + ), + const VSpace(6), + ], + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: getChildren(context), + ), + ], + ), + ); + } + + List getChildren(BuildContext context) { + if (isCurrentUser) { + return [ + const Spacer(), + _buildBubble(context), + const HSpace(DesktopAIConvoSizes.avatarAndChatBubbleSpacing), + _buildAvatar(), + ]; + } else { + return [ + _buildAvatar(), + const HSpace(DesktopAIConvoSizes.avatarAndChatBubbleSpacing), + _buildBubble(context), + const Spacer(), + ]; + } + } + + Widget _buildAvatar() { + return BlocBuilder( + builder: (context, state) { + final member = state.members[message.author.id]; + return ChatUserAvatar( + iconUrl: member?.info.avatarUrl ?? "", + name: member?.info.name ?? "", + ); + }, + ); + } + + Widget _buildBubble(BuildContext context) { + return Flexible( + flex: 5, + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(16.0)), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: child, + ), + ); + } +} + +class _MessageFileList extends StatelessWidget { + const _MessageFileList({required this.files}); + + final List files; + + @override + Widget build(BuildContext context) { + final List children = files + .map( + (file) => _MessageFile( + file: file, + ), + ) + .toList(); + + return Wrap( + direction: Axis.vertical, + crossAxisAlignment: WrapCrossAlignment.end, + spacing: 6, + runSpacing: 6, + children: children, + ); + } +} + +class _MessageFile extends StatelessWidget { + const _MessageFile({required this.file}); + + final ChatFile file; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Theme.of(context).colorScheme.secondary, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.page_m, + size: const Size.square(16), + color: Theme.of(context).hintColor, + ), + const HSpace(6), + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: FlowyText( + file.fileName, + fontSize: 12, + maxLines: 6, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart new file mode 100644 index 0000000000000..ae0d2863ce838 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/user_text_message.dart @@ -0,0 +1,90 @@ +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_user_message_bloc.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; + +import 'user_message_bubble.dart'; + +class ChatUserMessageWidget extends StatelessWidget { + const ChatUserMessageWidget({ + super.key, + required this.user, + required this.message, + required this.isCurrentUser, + }); + + final User user; + final TextMessage message; + final bool isCurrentUser; + + @override + Widget build(BuildContext context) { + final stream = message.metadata?["$QuestionStream"]; + final messageText = stream is QuestionStream ? stream.text : message.text; + + return BlocProvider( + create: (context) => ChatUserMessageBloc( + text: messageText, + questionStream: stream, + ), + child: ChatUserMessageBubble( + message: message, + isCurrentUser: isCurrentUser, + files: _getFiles(), + child: BlocBuilder( + builder: (context, state) { + return Opacity( + opacity: state.messageState.isFinish ? 1.0 : 0.8, + child: TextMessageText( + text: state.text, + ), + ); + }, + ), + ), + ); + } + + List _getFiles() { + if (message.metadata == null) { + return const []; + } + + final refSourceMetadata = + message.metadata?[messageRefSourceJsonStringKey] as String?; + if (refSourceMetadata != null) { + return chatFilesFromMetadataString(refSourceMetadata); + } + + final chatFileList = + message.metadata![messageChatFileListKey] as List?; + return chatFileList ?? []; + } +} + +/// Widget to reuse the markdown capabilities, e.g., for previews. +class TextMessageText extends StatelessWidget { + const TextMessageText({ + super.key, + required this.text, + }); + + /// Text that is shown as markdown. + final String text; + + @override + Widget build(BuildContext context) { + return FlowyText( + text, + lineHeight: 1.4, + maxLines: null, + selectable: true, + color: AFThemeExtension.of(context).textColor, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/scroll_to_bottom.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/scroll_to_bottom.dart new file mode 100644 index 0000000000000..0cfa2efe7bdab --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/scroll_to_bottom.dart @@ -0,0 +1,88 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +const BorderRadius _borderRadius = BorderRadius.all(Radius.circular(16)); + +class CustomScrollToBottom extends StatelessWidget { + const CustomScrollToBottom({ + super.key, + required this.animation, + required this.onPressed, + }); + + final Animation animation; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final isLightMode = Theme.of(context).isLightMode; + + return Positioned( + bottom: 24, + left: 0, + right: 0, + child: Center( + child: ScaleTransition( + scale: animation, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border.all( + color: Theme.of(context).dividerColor, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: _borderRadius, + boxShadow: [ + BoxShadow( + offset: const Offset(0, 8), + blurRadius: 16, + spreadRadius: 8, + color: isLightMode + ? const Color(0x0F1F2329) + : Theme.of(context).shadowColor.withOpacity(0.06), + ), + BoxShadow( + offset: const Offset(0, 4), + blurRadius: 8, + color: isLightMode + ? const Color(0x141F2329) + : Theme.of(context).shadowColor.withOpacity(0.08), + ), + BoxShadow( + offset: const Offset(0, 2), + blurRadius: 4, + color: isLightMode + ? const Color(0x1F1F2329) + : Theme.of(context).shadowColor.withOpacity(0.12), + ), + ], + ), + child: Material( + borderRadius: _borderRadius, + color: Colors.transparent, + borderOnForeground: false, + child: InkWell( + overlayColor: WidgetStateProperty.all( + AFThemeExtension.of(context).lightGreyHover, + ), + borderRadius: _borderRadius, + onTap: onPressed, + child: const SizedBox.square( + dimension: 32, + child: Center( + child: FlowySvg( + FlowySvgs.ai_scroll_to_bottom_s, + size: Size.square(20), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/color/color_picker.dart b/frontend/appflowy_flutter/lib/plugins/base/color/color_picker.dart new file mode 100644 index 0000000000000..635979debe2b9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/color/color_picker.dart @@ -0,0 +1,83 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class FlowyMobileColorPicker extends StatelessWidget { + const FlowyMobileColorPicker({ + super.key, + required this.onSelectedColor, + }); + + final void Function(FlowyColorOption? option) onSelectedColor; + + @override + Widget build(BuildContext context) { + const defaultColor = Colors.transparent; + final colors = [ + // reset to default background color + FlowyColorOption( + color: defaultColor, + i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(), + id: optionActionColorDefaultColor, + ), + ...FlowyTint.values.map( + (e) => FlowyColorOption( + color: e.color(context), + i18n: e.tintName(AppFlowyEditorL10n.current), + id: e.id, + ), + ), + ]; + return ListView.separated( + itemBuilder: (context, index) { + final color = colors[index]; + return SizedBox( + height: 56, + child: FlowyButton( + useIntrinsicWidth: true, + text: FlowyText( + color.i18n, + ), + leftIcon: _ColorIcon( + color: color.color, + ), + leftIconSize: const Size.square(36.0), + iconPadding: 12.0, + margin: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 16.0, + ), + onTap: () => onSelectedColor(color), + ), + ); + }, + separatorBuilder: (_, __) => const Divider( + height: 1, + ), + itemCount: colors.length, + ); + } +} + +class _ColorIcon extends StatelessWidget { + const _ColorIcon({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 24, + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/color/color_picker_screen.dart b/frontend/appflowy_flutter/lib/plugins/base/color/color_picker_screen.dart new file mode 100644 index 0000000000000..1e7ce9c64eab9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/color/color_picker_screen.dart @@ -0,0 +1,29 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/plugins/base/color/color_picker.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class MobileColorPickerScreen extends StatelessWidget { + const MobileColorPickerScreen({super.key, this.title}); + + final String? title; + + static const routeName = '/color_picker'; + static const pageTitle = 'title'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FlowyAppBar( + titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(), + ), + body: SafeArea( + child: FlowyMobileColorPicker( + onSelectedColor: (option) => context.pop(option), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/drag_handler.dart b/frontend/appflowy_flutter/lib/plugins/base/drag_handler.dart new file mode 100644 index 0000000000000..420d79a84cd4d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/drag_handler.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class DragHandle extends StatelessWidget { + const DragHandle({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 4, + width: 40, + margin: const EdgeInsets.symmetric(vertical: 6), + decoration: BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(2), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart new file mode 100644 index 0000000000000..517a0d68fadee --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker.dart @@ -0,0 +1,135 @@ +import 'dart:math'; + +import 'package:appflowy/plugins/base/emoji/emoji_picker_header.dart'; +import 'package:appflowy/shared/icon_emoji_picker/emoji_search_bar.dart'; +import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; + +// use a global value to store the selected emoji to prevent reloading every time. +EmojiData? kCachedEmojiData; +const _kRecentEmojiCategoryId = 'Recent'; + +class FlowyEmojiPicker extends StatefulWidget { + const FlowyEmojiPicker({ + super.key, + required this.onEmojiSelected, + this.emojiPerLine = 9, + this.ensureFocus = false, + }); + + final EmojiSelectedCallback onEmojiSelected; + final int emojiPerLine; + final bool ensureFocus; + + @override + State createState() => _FlowyEmojiPickerState(); +} + +class _FlowyEmojiPickerState extends State { + late EmojiData emojiData; + bool loaded = false; + + @override + void initState() { + super.initState(); + + // load the emoji data from cache if it's available + if (kCachedEmojiData != null) { + loadEmojis(kCachedEmojiData!); + } else { + EmojiData.builtIn().then( + (value) { + kCachedEmojiData = value; + loadEmojis(value); + }, + ); + } + } + + @override + Widget build(BuildContext context) { + if (!loaded) { + return const Center( + child: SizedBox.square( + dimension: 24.0, + child: CircularProgressIndicator( + strokeWidth: 2.0, + ), + ), + ); + } + + return EmojiPicker( + emojiData: emojiData, + configuration: EmojiPickerConfiguration( + showTabs: false, + defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none, + ), + onEmojiSelected: (id, emoji) { + widget.onEmojiSelected.call(id, emoji); + RecentIcons.putEmoji(id); + }, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + headerBuilder: (_, category) => FlowyEmojiHeader(category: category), + itemBuilder: (context, emojiId, emoji, callback) { + final name = emojiData.emojis[emojiId]?.name ?? ''; + return SizedBox.square( + dimension: 36.0, + child: FlowyButton( + margin: EdgeInsets.zero, + radius: Corners.s8Border, + text: FlowyTooltip( + message: name, + preferBelow: false, + child: FlowyText.emoji( + emoji, + fontSize: 24.0, + ), + ), + onTap: () => callback(emojiId, emoji), + ), + ); + }, + searchBarBuilder: (context, keyword, skinTone) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: FlowyEmojiSearchBar( + emojiData: emojiData, + ensureFocus: widget.ensureFocus, + onKeywordChanged: (value) { + keyword.value = value; + }, + onSkinToneChanged: (value) { + skinTone.value = value; + }, + onRandomEmojiSelected: widget.onEmojiSelected, + ), + ); + }, + ); + } + + void loadEmojis(EmojiData data) { + RecentIcons.getEmojiIds().then((v) { + if (v.isEmpty) { + emojiData = data; + setState(() => loaded = true); + return; + } + final categories = List.of(data.categories); + categories.insert( + 0, + Category( + id: _kRecentEmojiCategoryId, + emojiIds: v.sublist(0, min(widget.emojiPerLine, v.length)), + ), + ); + emojiData = EmojiData(categories: categories, emojis: data.emojis); + setState(() => loaded = true); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_header.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_header.dart new file mode 100644 index 0000000000000..9b41dd8bcefc5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_header.dart @@ -0,0 +1,54 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class FlowyEmojiHeader extends StatelessWidget { + const FlowyEmojiHeader({ + super.key, + required this.category, + }); + + final Category category; + + @override + Widget build(BuildContext context) { + if (UniversalPlatform.isDesktop) { + return Container( + height: 22, + color: Theme.of(context).cardColor, + child: Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: FlowyText.regular( + category.id.capitalize(), + color: Theme.of(context).hintColor, + ), + ), + ); + } else { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 40, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8.0), + color: Theme.of(context).cardColor, + child: Padding( + padding: const EdgeInsets.only( + top: 14.0, + bottom: 4.0, + ), + child: FlowyText.regular(category.id), + ), + ), + const Divider( + height: 1, + thickness: 1, + ), + ], + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_screen.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_screen.dart new file mode 100644 index 0000000000000..513b0fd224101 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_picker_screen.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../generated/locale_keys.g.dart'; +import '../../../mobile/presentation/base/app_bar/app_bar.dart'; +import '../../../shared/icon_emoji_picker/tab.dart'; + +class MobileEmojiPickerScreen extends StatelessWidget { + const MobileEmojiPickerScreen({ + super.key, + this.title, + this.tabs = const [PickerTabType.emoji, PickerTabType.icon], + }); + + final String? title; + final List tabs; + + static const routeName = '/emoji_picker'; + static const pageTitle = 'title'; + static const selectTabs = 'tabs'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FlowyAppBar( + titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(), + ), + body: SafeArea( + child: FlowyIconEmojiPicker( + tabs: tabs, + onSelectedEmoji: (r) { + context.pop(r); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_text.dart b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_text.dart new file mode 100644 index 0000000000000..884bd73151ed6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/emoji/emoji_text.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +// used to prevent loading font from google fonts every time +List? _cachedFallbackFontFamily; + +// Some emojis are not supported by the default font on Android or Linux, fallback to noto color emoji +class EmojiText extends StatelessWidget { + const EmojiText({ + super.key, + required this.emoji, + required this.fontSize, + this.textAlign, + this.lineHeight, + }); + + final String emoji; + final double fontSize; + final TextAlign? textAlign; + final double? lineHeight; + + @override + Widget build(BuildContext context) { + _loadFallbackFontFamily(); + return FlowyText( + emoji, + fontSize: fontSize, + textAlign: textAlign, + strutStyle: const StrutStyle(forceStrutHeight: true), + fallbackFontFamily: _cachedFallbackFontFamily, + lineHeight: lineHeight, + ); + } + + void _loadFallbackFontFamily() { + if (Platform.isLinux) { + final notoColorEmoji = GoogleFonts.notoColorEmoji().fontFamily; + if (notoColorEmoji != null) { + _cachedFallbackFontFamily = [notoColorEmoji]; + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/base/icon/icon_widget.dart b/frontend/appflowy_flutter/lib/plugins/base/icon/icon_widget.dart new file mode 100644 index 0000000000000..8f647e2a3871b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/base/icon/icon_widget.dart @@ -0,0 +1,29 @@ +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:flutter/material.dart'; + +import '../../../generated/flowy_svgs.g.dart'; + +class IconWidget extends StatelessWidget { + const IconWidget({ + super.key, + required this.size, + required this.data, + }); + + final IconsData data; + final double size; + + @override + Widget build(BuildContext context) { + final colorValue = int.tryParse(data.color ?? ''); + Color? color; + if (colorValue != null) { + color = Color(colorValue); + } + return FlowySvg.string( + data.iconContent, + size: Size.square(size), + color: color, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/blank/blank.dart b/frontend/appflowy_flutter/lib/plugins/blank/blank.dart new file mode 100644 index 0000000000000..b25bb5af063e6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/blank/blank.dart @@ -0,0 +1,88 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class BlankPluginBuilder extends PluginBuilder { + @override + Plugin build(dynamic data) { + return BlankPagePlugin(); + } + + @override + String get menuName => "Blank"; + + @override + FlowySvgData get icon => const FlowySvgData(''); + + @override + PluginType get pluginType => PluginType.blank; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Document; +} + +class BlankPluginConfig implements PluginConfig { + @override + bool get creatable => false; +} + +class BlankPagePlugin extends Plugin { + @override + PluginWidgetBuilder get widgetBuilder => BlankPagePluginWidgetBuilder(); + + @override + PluginId get id => "BlankStack"; + + @override + PluginType get pluginType => PluginType.blank; +} + +class BlankPagePluginWidgetBuilder extends PluginWidgetBuilder + with NavigationItem { + @override + String? get viewName => LocaleKeys.blankPageTitle.tr(); + + @override + Widget get leftBarItem => FlowyText.medium(LocaleKeys.blankPageTitle.tr()); + + @override + Widget tabBarItem(String pluginId, [bool shortForm = false]) => leftBarItem; + + @override + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + Map? data, + }) => + const BlankPage(); + + @override + List get navigationItems => [this]; +} + +class BlankPage extends StatefulWidget { + const BlankPage({super.key}); + + @override + State createState() => _BlankPageState(); +} + +class _BlankPageState extends State { + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: Container( + color: Theme.of(context).colorScheme.surface, + child: const Padding( + padding: EdgeInsets.all(10), + child: SizedBox.shrink(), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculation_type_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculation_type_ext.dart new file mode 100644 index 0000000000000..707671d4d6933 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculation_type_ext.dart @@ -0,0 +1,32 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; + +extension CalcTypeLabel on CalculationType { + String get label => switch (this) { + CalculationType.Average => + LocaleKeys.grid_calculationTypeLabel_average.tr(), + CalculationType.Max => LocaleKeys.grid_calculationTypeLabel_max.tr(), + CalculationType.Median => + LocaleKeys.grid_calculationTypeLabel_median.tr(), + CalculationType.Min => LocaleKeys.grid_calculationTypeLabel_min.tr(), + CalculationType.Sum => LocaleKeys.grid_calculationTypeLabel_sum.tr(), + CalculationType.Count => + LocaleKeys.grid_calculationTypeLabel_count.tr(), + CalculationType.CountEmpty => + LocaleKeys.grid_calculationTypeLabel_countEmpty.tr(), + CalculationType.CountNonEmpty => + LocaleKeys.grid_calculationTypeLabel_countNonEmpty.tr(), + _ => throw UnimplementedError( + 'Label for $this has not been implemented', + ), + }; + + String get shortLabel => switch (this) { + CalculationType.CountEmpty => + LocaleKeys.grid_calculationTypeLabel_countEmptyShort.tr(), + CalculationType.CountNonEmpty => + LocaleKeys.grid_calculationTypeLabel_countNonEmptyShort.tr(), + _ => label, + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_listener.dart new file mode 100644 index 0000000000000..e074a9b283c70 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_listener.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +typedef UpdateCalculationValue + = FlowyResult; + +class CalculationsListener { + CalculationsListener({required this.viewId}); + + final String viewId; + + PublishNotifier? _calculationNotifier = + PublishNotifier(); + DatabaseNotificationListener? _listener; + + void start({ + required void Function(UpdateCalculationValue) onCalculationChanged, + }) { + _calculationNotifier?.addPublishListener(onCalculationChanged); + _listener = DatabaseNotificationListener( + objectId: viewId, + handler: _handler, + ); + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateCalculation: + _calculationNotifier?.value = result.fold( + (payload) => FlowyResult.success( + CalculationChangesetNotificationPB.fromBuffer(payload), + ), + (err) => FlowyResult.failure(err), + ); + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _calculationNotifier?.dispose(); + _calculationNotifier = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_service.dart b/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_service.dart new file mode 100644 index 0000000000000..e3ef8d578ea89 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/calculations/calculations_service.dart @@ -0,0 +1,48 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class CalculationsBackendService { + const CalculationsBackendService({required this.viewId}); + + final String viewId; + + // Get Calculations (initial fetch) + + Future> + getCalculations() async { + final payload = DatabaseViewIdPB()..value = viewId; + + return DatabaseEventGetAllCalculations(payload).send(); + } + + Future updateCalculation( + String fieldId, + CalculationType type, { + String? calculationId, + }) async { + final payload = UpdateCalculationChangesetPB() + ..viewId = viewId + ..fieldId = fieldId + ..calculationType = type; + + if (calculationId != null) { + payload.calculationId = calculationId; + } + + await DatabaseEventUpdateCalculation(payload).send(); + } + + Future removeCalculation( + String fieldId, + String calculationId, + ) async { + final payload = RemoveCalculationChangesetPB() + ..viewId = viewId + ..fieldId = fieldId + ..calculationId = calculationId; + + await DatabaseEventRemoveCalculation(payload).send(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart new file mode 100644 index 0000000000000..4b32a30b5d7db --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart @@ -0,0 +1,97 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'checkbox_cell_bloc.freezed.dart'; + +class CheckboxCellBloc extends Bloc { + CheckboxCellBloc({ + required this.cellController, + }) : super(CheckboxCellState.initial(cellController)) { + _dispatch(); + } + + final CheckboxCellController cellController; + void Function()? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) { + event.when( + initial: () => _startListening(), + didUpdateCell: (isSelected) { + emit(state.copyWith(isSelected: isSelected)); + }, + didUpdateField: (fieldName) { + emit(state.copyWith(fieldName: fieldName)); + }, + select: () { + cellController.saveCellData(state.isSelected ? "No" : "Yes"); + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (cellData) { + if (!isClosed) { + add(CheckboxCellEvent.didUpdateCell(_isSelected(cellData))); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(CheckboxCellEvent.didUpdateField(fieldInfo.name)); + } + } +} + +@freezed +class CheckboxCellEvent with _$CheckboxCellEvent { + const factory CheckboxCellEvent.initial() = _Initial; + const factory CheckboxCellEvent.select() = _Selected; + const factory CheckboxCellEvent.didUpdateCell(bool isSelected) = + _DidUpdateCell; + const factory CheckboxCellEvent.didUpdateField(String fieldName) = + _DidUpdateField; +} + +@freezed +class CheckboxCellState with _$CheckboxCellState { + const factory CheckboxCellState({ + required bool isSelected, + required String fieldName, + }) = _CheckboxCellState; + + factory CheckboxCellState.initial(CheckboxCellController cellController) { + return CheckboxCellState( + isSelected: _isSelected(cellController.getCellData()), + fieldName: cellController.fieldInfo.field.name, + ); + } +} + +bool _isSelected(CheckboxCellDataPB? cellData) { + return cellData != null && cellData.isChecked; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checklist_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checklist_cell_bloc.dart new file mode 100644 index 0000000000000..34c922aaf90a1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/checklist_cell_bloc.dart @@ -0,0 +1,241 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/domain/checklist_cell_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'checklist_cell_bloc.freezed.dart'; + +class ChecklistSelectOption { + ChecklistSelectOption({required this.isSelected, required this.data}); + + final bool isSelected; + final SelectOptionPB data; +} + +class ChecklistCellBloc extends Bloc { + ChecklistCellBloc({required this.cellController}) + : _checklistCellService = ChecklistCellBackendService( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + rowId: cellController.rowId, + ), + super(ChecklistCellState.initial(cellController)) { + _dispatch(); + _startListening(); + } + + final ChecklistCellController cellController; + final ChecklistCellBackendService _checklistCellService; + void Function()? _onCellChangedFn; + + int? nextPhantomIndex; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener(onCellChanged: _onCellChangedFn!); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didUpdateCell: (data) { + if (data == null) { + emit( + const ChecklistCellState( + tasks: [], + percent: 0, + showIncompleteOnly: false, + phantomIndex: null, + ), + ); + return; + } + final phantomIndex = state.phantomIndex != null + ? nextPhantomIndex ?? state.phantomIndex + : null; + emit( + state.copyWith( + tasks: _makeChecklistSelectOptions(data), + percent: data.percentage, + phantomIndex: phantomIndex, + ), + ); + nextPhantomIndex = null; + }, + updateTaskName: (option, name) { + _updateOption(option, name); + }, + selectTask: (id) async { + await _checklistCellService.select(optionId: id); + }, + createNewTask: (name, index) async { + await _createTask(name, index); + }, + deleteTask: (id) async { + await _deleteOption([id]); + }, + reorderTask: (fromIndex, toIndex) async { + await _reorderTask(fromIndex, toIndex, emit); + }, + toggleShowIncompleteOnly: () { + emit(state.copyWith(showIncompleteOnly: !state.showIncompleteOnly)); + }, + updatePhantomIndex: (index) { + emit( + ChecklistCellState( + tasks: state.tasks, + percent: state.percent, + showIncompleteOnly: state.showIncompleteOnly, + phantomIndex: index, + ), + ); + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (data) { + if (!isClosed) { + add(ChecklistCellEvent.didUpdateCell(data)); + } + }, + ); + } + + Future _createTask(String name, int? index) async { + nextPhantomIndex = index == null ? state.tasks.length + 1 : index + 1; + + int? actualIndex = index; + if (index != null && state.showIncompleteOnly) { + int notSelectedTaskCount = 0; + for (int i = 0; i < state.tasks.length; i++) { + if (!state.tasks[i].isSelected) { + notSelectedTaskCount++; + } + + if (notSelectedTaskCount == index) { + actualIndex = i + 1; + break; + } + } + } + + final result = await _checklistCellService.create( + name: name, + index: actualIndex, + ); + result.fold((l) {}, (err) => Log.error(err)); + } + + void _updateOption(SelectOptionPB option, String name) async { + final result = + await _checklistCellService.updateName(option: option, name: name); + result.fold((l) => null, (err) => Log.error(err)); + } + + Future _deleteOption(List options) async { + final result = await _checklistCellService.delete(optionIds: options); + result.fold((l) => null, (err) => Log.error(err)); + } + + Future _reorderTask( + int fromIndex, + int toIndex, + Emitter emit, + ) async { + if (fromIndex < toIndex) { + toIndex--; + } + + final tasks = state.showIncompleteOnly + ? state.tasks.where((task) => !task.isSelected).toList() + : state.tasks; + + final fromId = tasks[fromIndex].data.id; + final toId = tasks[toIndex].data.id; + + final newTasks = [...state.tasks]; + newTasks.insert(toIndex, newTasks.removeAt(fromIndex)); + emit(state.copyWith(tasks: newTasks)); + final result = await _checklistCellService.reorder( + fromTaskId: fromId, + toTaskId: toId, + ); + result.fold((l) => null, (err) => Log.error(err)); + } +} + +@freezed +class ChecklistCellEvent with _$ChecklistCellEvent { + const factory ChecklistCellEvent.didUpdateCell( + ChecklistCellDataPB? data, + ) = _DidUpdateCell; + const factory ChecklistCellEvent.updateTaskName( + SelectOptionPB option, + String name, + ) = _UpdateTaskName; + const factory ChecklistCellEvent.selectTask(String taskId) = _SelectTask; + const factory ChecklistCellEvent.createNewTask( + String description, { + int? index, + }) = _CreateNewTask; + const factory ChecklistCellEvent.deleteTask(String taskId) = _DeleteTask; + const factory ChecklistCellEvent.reorderTask(int fromIndex, int toIndex) = + _ReorderTask; + + const factory ChecklistCellEvent.toggleShowIncompleteOnly() = _IncompleteOnly; + const factory ChecklistCellEvent.updatePhantomIndex(int? index) = + _UpdatePhantomIndex; +} + +@freezed +class ChecklistCellState with _$ChecklistCellState { + const factory ChecklistCellState({ + required List tasks, + required double percent, + required bool showIncompleteOnly, + required int? phantomIndex, + }) = _ChecklistCellState; + + factory ChecklistCellState.initial(ChecklistCellController cellController) { + final cellData = cellController.getCellData(loadIfNotExist: true); + + return ChecklistCellState( + tasks: _makeChecklistSelectOptions(cellData), + percent: cellData?.percentage ?? 0, + showIncompleteOnly: false, + phantomIndex: null, + ); + } +} + +List _makeChecklistSelectOptions( + ChecklistCellDataPB? data, +) { + if (data == null) { + return []; + } + return data.options + .map( + (option) => ChecklistSelectOption( + isSelected: data.selectedOptions.any( + (selected) => selected.id == option.id, + ), + data: option, + ), + ) + .toList(); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/date_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/date_cell_bloc.dart new file mode 100644 index 0000000000000..6f1d57fb50305 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/date_cell_bloc.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'date_cell_editor_bloc.dart'; + +part 'date_cell_bloc.freezed.dart'; + +class DateCellBloc extends Bloc { + DateCellBloc({required this.cellController}) + : super(DateCellState.initial(cellController)) { + _dispatch(); + _startListening(); + } + + final DateCellController cellController; + VoidCallback? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + event.when( + didReceiveCellUpdate: (DateCellDataPB? cellData) { + final dateCellData = DateCellData.fromPB(cellData); + emit( + state.copyWith( + cellData: dateCellData, + ), + ); + }, + didUpdateField: (fieldInfo) { + emit( + state.copyWith( + fieldInfo: fieldInfo, + ), + ); + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (data) { + if (!isClosed) { + add(DateCellEvent.didReceiveCellUpdate(data)); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(DateCellEvent.didUpdateField(fieldInfo)); + } + } +} + +@freezed +class DateCellEvent with _$DateCellEvent { + const factory DateCellEvent.didReceiveCellUpdate(DateCellDataPB? data) = + _DidReceiveCellUpdate; + const factory DateCellEvent.didUpdateField(FieldInfo fieldInfo) = + _DidUpdateField; +} + +@freezed +class DateCellState with _$DateCellState { + const factory DateCellState({ + required FieldInfo fieldInfo, + required DateCellData cellData, + }) = _DateCellState; + + factory DateCellState.initial(DateCellController cellController) { + final cellData = DateCellData.fromPB(cellController.getCellData()); + + return DateCellState( + fieldInfo: cellController.fieldInfo, + cellData: cellData, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart new file mode 100644 index 0000000000000..8f0c37fb0d35d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart @@ -0,0 +1,474 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/domain/date_cell_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/util/int64_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart' + show StringTranslateExtension; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:nanoid/non_secure.dart'; +import 'package:protobuf/protobuf.dart' hide FieldInfo; + +part 'date_cell_editor_bloc.freezed.dart'; + +class DateCellEditorBloc + extends Bloc { + DateCellEditorBloc({ + required this.cellController, + required ReminderBloc reminderBloc, + }) : _reminderBloc = reminderBloc, + _dateCellBackendService = DateCellBackendService( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + rowId: cellController.rowId, + ), + super(DateCellEditorState.initial(cellController, reminderBloc)) { + _dispatch(); + _startListening(); + } + + final DateCellBackendService _dateCellBackendService; + final DateCellController cellController; + final ReminderBloc _reminderBloc; + + void Function()? _onCellChangedFn; + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didReceiveCellUpdate: (DateCellDataPB? cellData) { + final dateCellData = DateCellData.fromPB(cellData); + + ReminderOption reminderOption = ReminderOption.none; + + if (dateCellData.reminderId.isNotEmpty && + dateCellData.dateTime != null) { + final reminder = _reminderBloc.state.reminders + .firstWhereOrNull((r) => r.id == dateCellData.reminderId); + if (reminder != null) { + reminderOption = ReminderOption.fromDateDifference( + dateCellData.dateTime!, + reminder.scheduledAt.toDateTime(), + ); + } + } + + emit( + state.copyWith( + dateTime: dateCellData.dateTime, + endDateTime: dateCellData.endDateTime, + includeTime: dateCellData.includeTime, + isRange: dateCellData.isRange, + reminderId: dateCellData.reminderId, + reminderOption: reminderOption, + ), + ); + }, + didUpdateField: (field) { + final typeOption = DateTypeOptionDataParser() + .fromBuffer(field.field.typeOptionData); + emit(state.copyWith(dateTypeOptionPB: typeOption)); + }, + updateDateTime: (date) async { + if (state.isRange) { + return; + } + await _updateDateData(date: date); + }, + updateDateRange: (DateTime start, DateTime end) async { + if (!state.isRange) { + return; + } + await _updateDateData(date: start, endDate: end); + }, + setIncludeTime: (includeTime, dateTime, endDateTime) async { + await _updateIncludeTime(includeTime, dateTime, endDateTime); + }, + setIsRange: (isRange, dateTime, endDateTime) async { + await _updateIsRange(isRange, dateTime, endDateTime); + }, + setDateFormat: (DateFormatPB dateFormat) async { + await _updateTypeOption(emit, dateFormat: dateFormat); + }, + setTimeFormat: (TimeFormatPB timeFormat) async { + await _updateTypeOption(emit, timeFormat: timeFormat); + }, + clearDate: () async { + // Remove reminder if neccessary + if (state.reminderId.isNotEmpty) { + _reminderBloc + .add(ReminderEvent.remove(reminderId: state.reminderId)); + } + + await _clearDate(); + }, + setReminderOption: (ReminderOption option) async { + await _setReminderOption(option); + }, + ); + }, + ); + } + + Future> _updateDateData({ + DateTime? date, + DateTime? endDate, + bool updateReminderIfNecessary = true, + }) async { + final result = await _dateCellBackendService.update( + date: date, + endDate: endDate, + ); + if (updateReminderIfNecessary) { + result.onSuccess((_) => _updateReminderIfNecessary(date)); + } + return result; + } + + Future _updateIsRange( + bool isRange, + DateTime? dateTime, + DateTime? endDateTime, + ) { + return _dateCellBackendService + .update( + date: dateTime, + endDate: endDateTime, + isRange: isRange, + ) + .fold((s) => _updateReminderIfNecessary(dateTime), Log.error); + } + + Future _updateIncludeTime( + bool includeTime, + DateTime? dateTime, + DateTime? endDateTime, + ) { + return _dateCellBackendService + .update( + date: dateTime, + endDate: endDateTime, + includeTime: includeTime, + ) + .fold((s) => _updateReminderIfNecessary(dateTime), Log.error); + } + + Future _clearDate() async { + final result = await _dateCellBackendService.clear(); + result.onFailure(Log.error); + } + + Future _setReminderOption(ReminderOption option) async { + if (state.reminderId.isEmpty) { + if (option == ReminderOption.none) { + // do nothing + return; + } + // if no date, fill it first + final fillerDateTime = + state.includeTime ? DateTime.now() : DateTime.now().withoutTime; + if (state.dateTime == null) { + final result = await _updateDateData( + date: fillerDateTime, + endDate: state.isRange ? fillerDateTime : null, + updateReminderIfNecessary: false, + ); + // return if filling date is unsuccessful + if (result.isFailure) { + return; + } + } + + // create a reminder + final reminderId = nanoid(); + await _updateCellReminderId(reminderId); + final dateTime = state.dateTime ?? fillerDateTime; + _reminderBloc.add( + ReminderEvent.addById( + reminderId: reminderId, + objectId: cellController.viewId, + meta: { + ReminderMetaKeys.includeTime: state.includeTime.toString(), + ReminderMetaKeys.rowId: cellController.rowId, + }, + scheduledAt: Int64( + option.getNotificationDateTime(dateTime).millisecondsSinceEpoch ~/ + 1000, + ), + ), + ); + } else { + if (option == ReminderOption.none) { + // remove reminder from reminder bloc and cell data + _reminderBloc.add(ReminderEvent.remove(reminderId: state.reminderId)); + await _updateCellReminderId(""); + } else { + // Update reminder + final scheduledAt = option.getNotificationDateTime(state.dateTime!); + _reminderBloc.add( + ReminderEvent.update( + ReminderUpdate( + id: state.reminderId, + scheduledAt: scheduledAt, + includeTime: state.includeTime, + ), + ), + ); + } + } + } + + Future _updateCellReminderId( + String reminderId, + ) async { + final result = await _dateCellBackendService.update( + reminderId: reminderId, + ); + result.onFailure(Log.error); + } + + void _updateReminderIfNecessary( + DateTime? dateTime, + ) { + if (state.reminderId.isEmpty || + state.reminderOption == ReminderOption.none || + dateTime == null) { + return; + } + + final scheduledAt = state.reminderOption.getNotificationDateTime(dateTime); + + // Update Reminder + _reminderBloc.add( + ReminderEvent.update( + ReminderUpdate( + id: state.reminderId, + scheduledAt: scheduledAt, + includeTime: state.includeTime, + ), + ), + ); + } + + String timeFormatPrompt(FlowyError error) { + return switch (state.dateTypeOptionPB.timeFormat) { + TimeFormatPB.TwelveHour => + "${LocaleKeys.grid_field_invalidTimeFormat.tr()}. e.g. 01:00 PM", + TimeFormatPB.TwentyFourHour => + "${LocaleKeys.grid_field_invalidTimeFormat.tr()}. e.g. 13:00", + _ => "", + }; + } + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + return super.close(); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (cell) { + if (!isClosed) { + add(DateCellEditorEvent.didReceiveCellUpdate(cell)); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(DateCellEditorEvent.didUpdateField(fieldInfo)); + } + } + + Future _updateTypeOption( + Emitter emit, { + DateFormatPB? dateFormat, + TimeFormatPB? timeFormat, + }) async { + state.dateTypeOptionPB.freeze(); + final newDateTypeOption = state.dateTypeOptionPB.rebuild((typeOption) { + if (dateFormat != null) { + typeOption.dateFormat = dateFormat; + } + + if (timeFormat != null) { + typeOption.timeFormat = timeFormat; + } + }); + + final result = await FieldBackendService.updateFieldTypeOption( + viewId: cellController.viewId, + fieldId: cellController.fieldInfo.id, + typeOptionData: newDateTypeOption.writeToBuffer(), + ); + + result.onFailure(Log.error); + } +} + +@freezed +class DateCellEditorEvent with _$DateCellEditorEvent { + const factory DateCellEditorEvent.didUpdateField( + FieldInfo fieldInfo, + ) = _DidUpdateField; + + // notification that cell is updated in the backend + const factory DateCellEditorEvent.didReceiveCellUpdate( + DateCellDataPB? data, + ) = _DidReceiveCellUpdate; + + const factory DateCellEditorEvent.updateDateTime(DateTime day) = + _UpdateDateTime; + + const factory DateCellEditorEvent.updateDateRange( + DateTime start, + DateTime end, + ) = _UpdateDateRange; + + const factory DateCellEditorEvent.setIncludeTime( + bool includeTime, + DateTime? dateTime, + DateTime? endDateTime, + ) = _IncludeTime; + + const factory DateCellEditorEvent.setIsRange( + bool isRange, + DateTime? dateTime, + DateTime? endDateTime, + ) = _SetIsRange; + + const factory DateCellEditorEvent.setReminderOption(ReminderOption option) = + _SetReminderOption; + + // date field type options are modified + const factory DateCellEditorEvent.setTimeFormat(TimeFormatPB timeFormat) = + _SetTimeFormat; + + const factory DateCellEditorEvent.setDateFormat(DateFormatPB dateFormat) = + _SetDateFormat; + + const factory DateCellEditorEvent.clearDate() = _ClearDate; +} + +@freezed +class DateCellEditorState with _$DateCellEditorState { + const factory DateCellEditorState({ + // the date field's type option + required DateTypeOptionPB dateTypeOptionPB, + + // cell data from the backend + required DateTime? dateTime, + required DateTime? endDateTime, + required bool includeTime, + required bool isRange, + required String reminderId, + @Default(ReminderOption.none) ReminderOption reminderOption, + }) = _DateCellEditorState; + + factory DateCellEditorState.initial( + DateCellController controller, + ReminderBloc reminderBloc, + ) { + final typeOption = controller.getTypeOption(DateTypeOptionDataParser()); + final cellData = controller.getCellData(); + final dateCellData = DateCellData.fromPB(cellData); + + ReminderOption reminderOption = ReminderOption.none; + + if (dateCellData.reminderId.isNotEmpty && dateCellData.dateTime != null) { + final reminder = reminderBloc.state.reminders + .firstWhereOrNull((r) => r.id == dateCellData.reminderId); + if (reminder != null) { + final eventDate = dateCellData.includeTime + ? dateCellData.dateTime! + : dateCellData.dateTime!.withoutTime; + reminderOption = ReminderOption.fromDateDifference( + eventDate, + reminder.scheduledAt.toDateTime(), + ); + } + } + + return DateCellEditorState( + dateTypeOptionPB: typeOption, + dateTime: dateCellData.dateTime, + endDateTime: dateCellData.endDateTime, + includeTime: dateCellData.includeTime, + isRange: dateCellData.isRange, + reminderId: dateCellData.reminderId, + reminderOption: reminderOption, + ); + } +} + +/// Helper class to parse ProtoBuf payloads into DateCellEditorState +class DateCellData { + const DateCellData({ + required this.dateTime, + required this.endDateTime, + required this.includeTime, + required this.isRange, + required this.reminderId, + }); + + const DateCellData.empty() + : dateTime = null, + endDateTime = null, + includeTime = false, + isRange = false, + reminderId = ""; + + factory DateCellData.fromPB(DateCellDataPB? cellData) { + // a null DateCellDataPB may be returned, indicating that all the fields are + // their default values: empty strings and false booleans + if (cellData == null) { + return const DateCellData.empty(); + } + + final dateTime = + cellData.hasTimestamp() ? cellData.timestamp.toDateTime() : null; + final endDateTime = dateTime == null || !cellData.isRange + ? null + : cellData.hasEndTimestamp() + ? cellData.endTimestamp.toDateTime() + : null; + + return DateCellData( + dateTime: dateTime, + endDateTime: endDateTime, + includeTime: cellData.includeTime, + isRange: cellData.isRange, + reminderId: cellData.reminderId, + ); + } + + final DateTime? dateTime; + final DateTime? endDateTime; + final bool includeTime; + final bool isRange; + final String reminderId; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart new file mode 100644 index 0000000000000..7a3075e0f23a3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart @@ -0,0 +1,247 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/cell_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'media_cell_bloc.freezed.dart'; + +class MediaCellBloc extends Bloc { + MediaCellBloc({ + required this.cellController, + }) : super(MediaCellState.initial(cellController)) { + _dispatch(); + _startListening(); + } + + late final RowBackendService _rowService = + RowBackendService(viewId: cellController.viewId); + final MediaCellController cellController; + + void Function()? _onCellChangedFn; + + String get databaseId => cellController.viewId; + String get rowId => cellController.rowId; + bool get wrapContent => cellController.fieldInfo.wrapCellContent ?? false; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () async { + // Fetch user profile + final userProfileResult = + await UserBackendService.getCurrentUserProfile(); + userProfileResult.fold( + (userProfile) => emit(state.copyWith(userProfile: userProfile)), + (l) => Log.error(l), + ); + }, + didUpdateCell: (files) { + emit(state.copyWith(files: files)); + }, + didUpdateField: (fieldName) { + final typeOption = + cellController.getTypeOption(MediaTypeOptionDataParser()); + + emit( + state.copyWith( + fieldName: fieldName, + hideFileNames: typeOption.hideFileNames, + ), + ); + }, + addFile: (url, name, uploadType, fileType) async { + final newFile = MediaFilePB( + id: uuid(), + url: url, + name: name, + uploadType: uploadType, + fileType: fileType, + ); + + final payload = MediaCellChangesetPB( + viewId: cellController.viewId, + cellId: CellIdPB( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + rowId: cellController.rowId, + ), + insertedFiles: [newFile], + removedIds: [], + ); + + final result = await DatabaseEventUpdateMediaCell(payload).send(); + result.fold((l) => null, (err) => Log.error(err)); + }, + removeFile: (id) async { + final payload = MediaCellChangesetPB( + viewId: cellController.viewId, + cellId: CellIdPB( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + rowId: cellController.rowId, + ), + insertedFiles: [], + removedIds: [id], + ); + + final result = await DatabaseEventUpdateMediaCell(payload).send(); + result.fold((l) => null, (err) => Log.error(err)); + }, + reorderFiles: (from, to) async { + final files = List.from(state.files); + files.insert(to, files.removeAt(from)); + + // We emit the new state first to update the UI + emit(state.copyWith(files: files)); + + final payload = MediaCellChangesetPB( + viewId: cellController.viewId, + cellId: CellIdPB( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + rowId: cellController.rowId, + ), + insertedFiles: files, + // In the backend we remove all files by id before we do inserts. + // So this will effectively reorder the files. + removedIds: files.map((file) => file.id).toList(), + ); + + final result = await DatabaseEventUpdateMediaCell(payload).send(); + result.fold((l) => null, (err) => Log.error(err)); + }, + renameFile: (fileId, name) async { + final payload = RenameMediaChangesetPB( + viewId: cellController.viewId, + cellId: CellIdPB( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + rowId: cellController.rowId, + ), + fileId: fileId, + name: name, + ); + + final result = await DatabaseEventRenameMediaFile(payload).send(); + result.fold((l) => null, (err) => Log.error(err)); + }, + toggleShowAllFiles: () { + emit(state.copyWith(showAllFiles: !state.showAllFiles)); + }, + setCover: (cover) => _rowService.updateMeta( + rowId: cellController.rowId, + cover: cover, + ), + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (cellData) { + if (!isClosed) { + add(MediaCellEvent.didUpdateCell(cellData?.files ?? const [])); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(MediaCellEvent.didUpdateField(fieldInfo.name)); + } + } + + void renameFile(String fileId, String name) => + add(MediaCellEvent.renameFile(fileId: fileId, name: name)); + + void deleteFile(String fileId) => + add(MediaCellEvent.removeFile(fileId: fileId)); +} + +@freezed +class MediaCellEvent with _$MediaCellEvent { + const factory MediaCellEvent.initial() = _Initial; + + const factory MediaCellEvent.didUpdateCell(List files) = + _DidUpdateCell; + + const factory MediaCellEvent.didUpdateField(String fieldName) = + _DidUpdateField; + + const factory MediaCellEvent.addFile({ + required String url, + required String name, + required FileUploadTypePB uploadType, + required MediaFileTypePB fileType, + }) = _AddFile; + + const factory MediaCellEvent.removeFile({ + required String fileId, + }) = _RemoveFile; + + const factory MediaCellEvent.reorderFiles({ + required int from, + required int to, + }) = _ReorderFiles; + + const factory MediaCellEvent.renameFile({ + required String fileId, + required String name, + }) = _RenameFile; + + const factory MediaCellEvent.toggleShowAllFiles() = _ToggleShowAllFiles; + + const factory MediaCellEvent.setCover(RowCoverPB cover) = _SetCover; +} + +@freezed +class MediaCellState with _$MediaCellState { + const factory MediaCellState({ + UserProfilePB? userProfile, + required String fieldName, + @Default([]) List files, + @Default(false) showAllFiles, + @Default(true) hideFileNames, + }) = _MediaCellState; + + factory MediaCellState.initial(MediaCellController cellController) { + final cellData = cellController.getCellData(); + final typeOption = + cellController.getTypeOption(MediaTypeOptionDataParser()); + + return MediaCellState( + fieldName: cellController.fieldInfo.field.name, + files: cellData?.files ?? const [], + hideFileNames: typeOption.hideFileNames, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/number_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/number_cell_bloc.dart new file mode 100644 index 0000000000000..df159b817b597 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/number_cell_bloc.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'number_cell_bloc.freezed.dart'; + +class NumberCellBloc extends Bloc { + NumberCellBloc({ + required this.cellController, + }) : super(NumberCellState.initial(cellController)) { + _dispatch(); + _startListening(); + } + + final NumberCellController cellController; + void Function()? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didReceiveCellUpdate: (cellData) { + emit(state.copyWith(content: cellData ?? "")); + }, + didUpdateField: (fieldInfo) { + final wrap = fieldInfo.wrapCellContent; + if (wrap != null) { + emit(state.copyWith(wrap: wrap)); + } + }, + updateCell: (text) async { + if (state.content != text) { + emit(state.copyWith(content: text)); + await cellController.saveCellData(text); + + // If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string. + // So for every cell data that will be formatted in the backend. + // It needs to get the formatted data after saving. + add( + NumberCellEvent.didReceiveCellUpdate( + cellController.getCellData(), + ), + ); + } + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (cellContent) { + if (!isClosed) { + add(NumberCellEvent.didReceiveCellUpdate(cellContent)); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(NumberCellEvent.didUpdateField(fieldInfo)); + } + } +} + +@freezed +class NumberCellEvent with _$NumberCellEvent { + const factory NumberCellEvent.didReceiveCellUpdate(String? cellContent) = + _DidReceiveCellUpdate; + const factory NumberCellEvent.didUpdateField(FieldInfo fieldInfo) = + _DidUpdateField; + const factory NumberCellEvent.updateCell(String text) = _UpdateCell; +} + +@freezed +class NumberCellState with _$NumberCellState { + const factory NumberCellState({ + required String content, + required bool wrap, + }) = _NumberCellState; + + factory NumberCellState.initial(TextCellController cellController) { + final wrap = cellController.fieldInfo.wrapCellContent; + return NumberCellState( + content: cellController.getCellData() ?? "", + wrap: wrap ?? true, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart new file mode 100644 index 0000000000000..70c5e074ab9b4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_cell_bloc.dart @@ -0,0 +1,202 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/relation_type_option_cubit.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'relation_cell_bloc.freezed.dart'; + +class RelationCellBloc extends Bloc { + RelationCellBloc({required this.cellController}) + : super(RelationCellState.initial(cellController)) { + _dispatch(); + _startListening(); + _init(); + } + + final RelationCellController cellController; + void Function()? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didUpdateCell: (cellData) async { + if (cellData == null || + cellData.rowIds.isEmpty || + state.relatedDatabaseMeta == null) { + emit(state.copyWith(rows: const [])); + return; + } + final payload = GetRelatedRowDataPB( + databaseId: state.relatedDatabaseMeta!.databaseId, + rowIds: cellData.rowIds, + ); + final result = + await DatabaseEventGetRelatedRowDatas(payload).send(); + final rows = result.fold( + (data) => data.rows, + (err) { + Log.error(err); + return const []; + }, + ); + emit(state.copyWith(rows: rows)); + }, + didUpdateField: (FieldInfo fieldInfo) async { + final wrap = fieldInfo.wrapCellContent; + if (wrap != null) { + emit(state.copyWith(wrap: wrap)); + } + final RelationTypeOptionPB typeOption = + cellController.getTypeOption(RelationTypeOptionDataParser()); + if (typeOption.databaseId.isEmpty) { + return; + } + final meta = await _loadDatabaseMeta(typeOption.databaseId); + emit(state.copyWith(relatedDatabaseMeta: meta)); + _loadCellData(); + }, + selectDatabaseId: (databaseId) async { + await _updateTypeOption(databaseId); + }, + selectRow: (rowId) async { + await _handleSelectRow(rowId); + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (data) { + if (!isClosed) { + add(RelationCellEvent.didUpdateCell(data)); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(RelationCellEvent.didUpdateField(fieldInfo)); + } + } + + void _init() { + add(RelationCellEvent.didUpdateField(cellController.fieldInfo)); + } + + void _loadCellData() { + final cellData = cellController.getCellData(); + if (!isClosed && cellData != null) { + add(RelationCellEvent.didUpdateCell(cellData)); + } + } + + Future _handleSelectRow(String rowId) async { + final payload = RelationCellChangesetPB( + viewId: cellController.viewId, + cellId: CellIdPB( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + rowId: cellController.rowId, + ), + ); + if (state.rows.any((row) => row.rowId == rowId)) { + payload.removedRowIds.add(rowId); + } else { + payload.insertedRowIds.add(rowId); + } + final result = await DatabaseEventUpdateRelationCell(payload).send(); + result.fold((l) => null, (err) => Log.error(err)); + } + + Future _loadDatabaseMeta(String databaseId) async { + final getDatabaseResult = await DatabaseEventGetDatabases().send(); + final databaseMeta = getDatabaseResult.fold( + (s) => s.items.firstWhereOrNull( + (metaPB) => metaPB.databaseId == databaseId, + ), + (f) => null, + ); + if (databaseMeta != null) { + final result = + await ViewBackendService.getView(databaseMeta.inlineViewId); + return result.fold( + (s) => DatabaseMeta( + databaseId: databaseId, + inlineViewId: databaseMeta.inlineViewId, + databaseName: s.name, + ), + (f) => null, + ); + } + return null; + } + + Future _updateTypeOption(String databaseId) async { + final newDateTypeOption = RelationTypeOptionPB( + databaseId: databaseId, + ); + + final result = await FieldBackendService.updateFieldTypeOption( + viewId: cellController.viewId, + fieldId: cellController.fieldInfo.id, + typeOptionData: newDateTypeOption.writeToBuffer(), + ); + result.fold((s) => null, (err) => Log.error(err)); + } +} + +@freezed +class RelationCellEvent with _$RelationCellEvent { + const factory RelationCellEvent.didUpdateCell(RelationCellDataPB? data) = + _DidUpdateCell; + const factory RelationCellEvent.didUpdateField(FieldInfo fieldInfo) = + _DidUpdateField; + const factory RelationCellEvent.selectDatabaseId( + String databaseId, + ) = _SelectDatabaseId; + const factory RelationCellEvent.selectRow(String rowId) = _SelectRowId; +} + +@freezed +class RelationCellState with _$RelationCellState { + const factory RelationCellState({ + required DatabaseMeta? relatedDatabaseMeta, + required List rows, + required bool wrap, + }) = _RelationCellState; + + factory RelationCellState.initial(RelationCellController cellController) { + final wrap = cellController.fieldInfo.wrapCellContent; + return RelationCellState( + relatedDatabaseMeta: null, + rows: [], + wrap: wrap ?? true, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart new file mode 100644 index 0000000000000..2e07af65110bf --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/relation_row_search_bloc.dart @@ -0,0 +1,134 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:bloc/bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'relation_row_search_bloc.freezed.dart'; + +class RelationRowSearchBloc + extends Bloc { + RelationRowSearchBloc({ + required this.databaseId, + }) : super(RelationRowSearchState.initial()) { + _dispatch(); + _init(); + } + + final String databaseId; + final List allRows = []; + + void _dispatch() { + on( + (event, emit) { + event.when( + didUpdateRowList: (List rowList) { + allRows + ..clear() + ..addAll(rowList); + emit( + state.copyWith( + filteredRows: allRows, + focusedRowId: state.focusedRowId ?? allRows.firstOrNull?.rowId, + ), + ); + }, + updateFilter: (String filter) => _updateFilter(filter, emit), + updateFocusedOption: (String rowId) { + emit(state.copyWith(focusedRowId: rowId)); + }, + focusPreviousOption: () => _focusOption(true, emit), + focusNextOption: () => _focusOption(false, emit), + ); + }, + ); + } + + Future _init() async { + final payload = DatabaseIdPB(value: databaseId); + final result = await DatabaseEventGetRelatedDatabaseRows(payload).send(); + result.fold( + (data) => add(RelationRowSearchEvent.didUpdateRowList(data.rows)), + (err) => Log.error(err), + ); + } + + void _updateFilter(String filter, Emitter emit) { + final rows = [...allRows]; + + if (filter.isNotEmpty) { + rows.retainWhere( + (row) => + row.name.toLowerCase().contains(filter.toLowerCase()) || + (row.name.isEmpty && + LocaleKeys.grid_row_titlePlaceholder + .tr() + .toLowerCase() + .contains(filter.toLowerCase())), + ); + } + + final focusedRowId = rows.isEmpty + ? null + : rows.any((row) => row.rowId == state.focusedRowId) + ? state.focusedRowId + : rows.first.rowId; + + emit( + state.copyWith( + filteredRows: rows, + focusedRowId: focusedRowId, + ), + ); + } + + void _focusOption(bool previous, Emitter emit) { + if (state.filteredRows.isEmpty) { + return; + } + + final rowIds = state.filteredRows.map((e) => e.rowId).toList(); + final currentIndex = state.focusedRowId == null + ? -1 + : rowIds.indexWhere((id) => id == state.focusedRowId); + + // If the current index is -1, it means that the focused row is not in the list of row ids. + // In this case, we set the new index to the last index if previous is true, otherwise to 0. + final newIndex = currentIndex == -1 + ? (previous ? rowIds.length - 1 : 0) + : (currentIndex + (previous ? -1 : 1)) % rowIds.length; + + emit(state.copyWith(focusedRowId: rowIds[newIndex])); + } +} + +@freezed +class RelationRowSearchEvent with _$RelationRowSearchEvent { + const factory RelationRowSearchEvent.didUpdateRowList( + List rowList, + ) = _DidUpdateRowList; + const factory RelationRowSearchEvent.updateFilter(String filter) = + _UpdateFilter; + const factory RelationRowSearchEvent.updateFocusedOption( + String rowId, + ) = _UpdateFocusedOption; + const factory RelationRowSearchEvent.focusPreviousOption() = + _FocusPreviousOption; + const factory RelationRowSearchEvent.focusNextOption() = _FocusNextOption; +} + +@freezed +class RelationRowSearchState with _$RelationRowSearchState { + const factory RelationRowSearchState({ + required List filteredRows, + required String? focusedRowId, + }) = _RelationRowSearchState; + + factory RelationRowSearchState.initial() => const RelationRowSearchState( + filteredRows: [], + focusedRowId: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_bloc.dart new file mode 100644 index 0000000000000..ca5af1e1a20ef --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_bloc.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'select_option_cell_bloc.freezed.dart'; + +class SelectOptionCellBloc + extends Bloc { + SelectOptionCellBloc({ + required this.cellController, + }) : super(SelectOptionCellState.initial(cellController)) { + _dispatch(); + _startListening(); + } + + final SelectOptionCellController cellController; + void Function()? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) { + event.when( + didReceiveOptions: (List selectedOptions) { + emit( + state.copyWith( + selectedOptions: selectedOptions, + ), + ); + }, + didUpdateField: (fieldInfo) { + final wrap = fieldInfo.wrapCellContent; + if (wrap != null) { + emit(state.copyWith(wrap: wrap)); + } + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (selectOptionCellData) { + if (!isClosed) { + add( + SelectOptionCellEvent.didReceiveOptions( + selectOptionCellData?.selectOptions ?? [], + ), + ); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(SelectOptionCellEvent.didUpdateField(fieldInfo)); + } + } +} + +@freezed +class SelectOptionCellEvent with _$SelectOptionCellEvent { + const factory SelectOptionCellEvent.didReceiveOptions( + List selectedOptions, + ) = _DidReceiveOptions; + const factory SelectOptionCellEvent.didUpdateField(FieldInfo fieldInfo) = + _DidUpdateField; +} + +@freezed +class SelectOptionCellState with _$SelectOptionCellState { + const factory SelectOptionCellState({ + required List selectedOptions, + required bool wrap, + }) = _SelectOptionCellState; + + factory SelectOptionCellState.initial( + SelectOptionCellController cellController, + ) { + final data = cellController.getCellData(); + final wrap = cellController.fieldInfo.wrapCellContent; + return SelectOptionCellState( + selectedOptions: data?.selectOptions ?? [], + wrap: wrap ?? true, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart new file mode 100644 index 0000000000000..f8ed915b6284a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart @@ -0,0 +1,453 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/select_type_option_actions.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/domain/select_option_cell_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'select_option_cell_editor_bloc.freezed.dart'; + +const String createSelectOptionSuggestionId = + "create_select_option_suggestion_id"; + +class SelectOptionCellEditorBloc + extends Bloc { + SelectOptionCellEditorBloc({ + required this.cellController, + }) : _selectOptionService = SelectOptionCellBackendService( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + rowId: cellController.rowId, + ), + _typeOptionAction = cellController.fieldType == FieldType.SingleSelect + ? SingleSelectAction( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + onTypeOptionUpdated: (typeOptionData) => + FieldBackendService.updateFieldTypeOption( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + typeOptionData: typeOptionData, + ), + ) + : MultiSelectAction( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + onTypeOptionUpdated: (typeOptionData) => + FieldBackendService.updateFieldTypeOption( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + typeOptionData: typeOptionData, + ), + ), + super(SelectOptionCellEditorState.initial(cellController)) { + _dispatch(); + _startListening(); + final loadedOptions = _loadAllOptions(cellController); + add(SelectOptionCellEditorEvent.didUpdateOptions(loadedOptions)); + } + + final SelectOptionCellBackendService _selectOptionService; + final ISelectOptionAction _typeOptionAction; + final SelectOptionCellController cellController; + + VoidCallback? _onCellChangedFn; + + final List allOptions = []; + String filter = ""; + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didUpdateCell: (selectedOptions) { + emit(state.copyWith(selectedOptions: selectedOptions)); + }, + didUpdateOptions: (options) { + allOptions + ..clear() + ..addAll(options); + final result = _getVisibleOptions(options); + emit( + state.copyWith( + options: result.options, + createSelectOptionSuggestion: + result.createSelectOptionSuggestion, + ), + ); + }, + createOption: () async { + if (state.createSelectOptionSuggestion == null) { + return; + } + filter = ""; + await _createOption( + name: state.createSelectOptionSuggestion!.name, + color: state.createSelectOptionSuggestion!.color, + ); + emit(state.copyWith(clearFilter: true)); + }, + deleteOption: (option) async { + await _deleteOption([option]); + }, + deleteAllOptions: () async { + if (allOptions.isNotEmpty) { + await _deleteOption(allOptions); + } + }, + updateOption: (option) async { + await _updateOption(option); + }, + selectOption: (optionId) async { + await _selectOptionService.select(optionIds: [optionId]); + }, + unselectOption: (optionId) async { + await _selectOptionService.unselect(optionIds: [optionId]); + }, + unselectLastOption: () async { + if (state.selectedOptions.isEmpty) { + return; + } + final lastSelectedOptionId = state.selectedOptions.last.id; + await _selectOptionService + .unselect(optionIds: [lastSelectedOptionId]); + }, + submitTextField: () { + _submitTextFieldValue(emit); + }, + selectMultipleOptions: (optionNames, remainder) { + if (optionNames.isNotEmpty) { + _selectMultipleOptions(optionNames); + } + _filterOption(remainder, emit); + }, + reorderOption: (fromOptionId, toOptionId) { + final options = _typeOptionAction.reorderOption( + allOptions, + fromOptionId, + toOptionId, + ); + allOptions + ..clear() + ..addAll(options); + final result = _getVisibleOptions(options); + emit(state.copyWith(options: result.options)); + }, + filterOption: (filterText) { + _filterOption(filterText, emit); + }, + focusPreviousOption: () { + _focusOption(true, emit); + }, + focusNextOption: () { + _focusOption(false, emit); + }, + updateFocusedOption: (optionId) { + emit(state.copyWith(focusedOptionId: optionId)); + }, + resetClearFilterFlag: () { + emit(state.copyWith(clearFilter: false)); + }, + ); + }, + ); + } + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + return super.close(); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (cellData) { + if (!isClosed) { + add( + SelectOptionCellEditorEvent.didUpdateCell( + cellData == null ? [] : cellData.selectOptions, + ), + ); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + final loadedOptions = _loadAllOptions(cellController); + add(SelectOptionCellEditorEvent.didUpdateOptions(loadedOptions)); + } + } + + Future _createOption({ + required String name, + required SelectOptionColorPB color, + }) async { + final result = await _selectOptionService.create( + name: name, + color: color, + ); + result.fold((l) => {}, (err) => Log.error(err)); + } + + Future _deleteOption(List options) async { + final result = await _selectOptionService.delete(options: options); + result.fold((l) => null, (err) => Log.error(err)); + } + + Future _updateOption(SelectOptionPB option) async { + final result = await _selectOptionService.update( + option: option, + ); + + result.fold((l) => null, (err) => Log.error(err)); + } + + void _submitTextFieldValue(Emitter emit) { + if (state.focusedOptionId == null) { + return; + } + + final focusedOptionId = state.focusedOptionId!; + + if (focusedOptionId == createSelectOptionSuggestionId) { + filter = ""; + _createOption( + name: state.createSelectOptionSuggestion!.name, + color: state.createSelectOptionSuggestion!.color, + ); + emit( + state.copyWith( + createSelectOptionSuggestion: null, + clearFilter: true, + ), + ); + } else if (!state.selectedOptions + .any((option) => option.id == focusedOptionId)) { + _selectOptionService.select(optionIds: [focusedOptionId]); + } + } + + void _selectMultipleOptions(List optionNames) { + final optionIds = optionNames + .map( + (name) => allOptions.firstWhereOrNull( + (option) => option.name.toLowerCase() == name.toLowerCase(), + ), + ) + .nonNulls + .map((option) => option.id) + .toList(); + + _selectOptionService.select(optionIds: optionIds); + } + + void _filterOption( + String filterText, + Emitter emit, + ) { + filter = filterText; + final _MakeOptionResult result = _getVisibleOptions( + allOptions, + ); + final focusedOptionId = result.options.isEmpty + ? result.createSelectOptionSuggestion == null + ? null + : createSelectOptionSuggestionId + : result.options.any((option) => option.id == state.focusedOptionId) + ? state.focusedOptionId + : result.options.first.id; + emit( + state.copyWith( + options: result.options, + createSelectOptionSuggestion: result.createSelectOptionSuggestion, + focusedOptionId: focusedOptionId, + ), + ); + } + + _MakeOptionResult _getVisibleOptions( + List allOptions, + ) { + final List options = List.from(allOptions); + String newOptionName = filter; + + if (filter.isNotEmpty) { + options.retainWhere((option) { + final name = option.name.toLowerCase(); + final lFilter = filter.toLowerCase(); + + if (name == lFilter) { + newOptionName = ""; + } + + return name.contains(lFilter); + }); + } + + return _MakeOptionResult( + options: options, + createSelectOptionSuggestion: newOptionName.isEmpty + ? null + : CreateSelectOptionSuggestion( + name: newOptionName, + color: newSelectOptionColor(allOptions), + ), + ); + } + + void _focusOption(bool previous, Emitter emit) { + if (state.options.isEmpty && state.createSelectOptionSuggestion == null) { + return; + } + + final optionIds = [ + ...state.options.map((e) => e.id), + if (state.createSelectOptionSuggestion != null) + createSelectOptionSuggestionId, + ]; + + if (state.focusedOptionId == null) { + emit( + state.copyWith( + focusedOptionId: previous ? optionIds.last : optionIds.first, + ), + ); + return; + } + + final currentIndex = + optionIds.indexWhere((id) => id == state.focusedOptionId); + + final newIndex = currentIndex == -1 + ? 0 + : (currentIndex + (previous ? -1 : 1)) % optionIds.length; + + emit(state.copyWith(focusedOptionId: optionIds[newIndex])); + } +} + +@freezed +class SelectOptionCellEditorEvent with _$SelectOptionCellEditorEvent { + const factory SelectOptionCellEditorEvent.didUpdateCell( + List selectedOptions, + ) = _DidUpdateCell; + const factory SelectOptionCellEditorEvent.didUpdateOptions( + List options, + ) = _DidUpdateOptions; + const factory SelectOptionCellEditorEvent.createOption() = _CreateOption; + const factory SelectOptionCellEditorEvent.selectOption(String optionId) = + _SelectOption; + const factory SelectOptionCellEditorEvent.unselectOption(String optionId) = + _UnselectOption; + const factory SelectOptionCellEditorEvent.unselectLastOption() = + _UnselectLastOption; + const factory SelectOptionCellEditorEvent.updateOption( + SelectOptionPB option, + ) = _UpdateOption; + const factory SelectOptionCellEditorEvent.deleteOption( + SelectOptionPB option, + ) = _DeleteOption; + const factory SelectOptionCellEditorEvent.deleteAllOptions() = + _DeleteAllOptions; + const factory SelectOptionCellEditorEvent.reorderOption( + String fromOptionId, + String toOptionId, + ) = _ReorderOption; + const factory SelectOptionCellEditorEvent.filterOption(String filterText) = + _SelectOptionFilter; + const factory SelectOptionCellEditorEvent.submitTextField() = + _SubmitTextField; + const factory SelectOptionCellEditorEvent.selectMultipleOptions( + List optionNames, + String remainder, + ) = _SelectMultipleOptions; + const factory SelectOptionCellEditorEvent.focusPreviousOption() = + _FocusPreviousOption; + const factory SelectOptionCellEditorEvent.focusNextOption() = + _FocusNextOption; + const factory SelectOptionCellEditorEvent.updateFocusedOption( + String? optionId, + ) = _UpdateFocusedOption; + const factory SelectOptionCellEditorEvent.resetClearFilterFlag() = + _ResetClearFilterFlag; +} + +@freezed +class SelectOptionCellEditorState with _$SelectOptionCellEditorState { + const factory SelectOptionCellEditorState({ + required List options, + required List selectedOptions, + required CreateSelectOptionSuggestion? createSelectOptionSuggestion, + required String? focusedOptionId, + required bool clearFilter, + }) = _SelectOptionEditorState; + + factory SelectOptionCellEditorState.initial( + SelectOptionCellController cellController, + ) { + final allOptions = _loadAllOptions(cellController); + final data = cellController.getCellData(); + return SelectOptionCellEditorState( + options: allOptions, + selectedOptions: data?.selectOptions ?? [], + createSelectOptionSuggestion: null, + focusedOptionId: null, + clearFilter: false, + ); + } +} + +class _MakeOptionResult { + _MakeOptionResult({ + required this.options, + required this.createSelectOptionSuggestion, + }); + + List options; + CreateSelectOptionSuggestion? createSelectOptionSuggestion; +} + +class CreateSelectOptionSuggestion { + CreateSelectOptionSuggestion({ + required this.name, + required this.color, + }); + + final String name; + final SelectOptionColorPB color; +} + +List _loadAllOptions( + SelectOptionCellController cellController, +) { + if (cellController.fieldType == FieldType.SingleSelect) { + return cellController + .getTypeOption( + SingleSelectTypeOptionDataParser(), + ) + .options; + } else { + return cellController + .getTypeOption( + MultiSelectTypeOptionDataParser(), + ) + .options; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/summary_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/summary_cell_bloc.dart new file mode 100644 index 0000000000000..34b3981f48d20 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/summary_cell_bloc.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'summary_cell_bloc.freezed.dart'; + +class SummaryCellBloc extends Bloc { + SummaryCellBloc({ + required this.cellController, + }) : super(SummaryCellState.initial(cellController)) { + _dispatch(); + _startListening(); + } + + final SummaryCellController cellController; + void Function()? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didReceiveCellUpdate: (cellData) { + emit( + state.copyWith(content: cellData ?? ""), + ); + }, + didUpdateField: (fieldInfo) { + final wrap = fieldInfo.wrapCellContent; + if (wrap != null) { + emit(state.copyWith(wrap: wrap)); + } + }, + updateCell: (text) async { + if (state.content != text) { + emit(state.copyWith(content: text)); + await cellController.saveCellData(text); + + // If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string. + // So for every cell data that will be formatted in the backend. + // It needs to get the formatted data after saving. + add( + SummaryCellEvent.didReceiveCellUpdate( + cellController.getCellData() ?? "", + ), + ); + } + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (cellContent) { + if (!isClosed) { + add( + SummaryCellEvent.didReceiveCellUpdate(cellContent ?? ""), + ); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(SummaryCellEvent.didUpdateField(fieldInfo)); + } + } +} + +@freezed +class SummaryCellEvent with _$SummaryCellEvent { + const factory SummaryCellEvent.didReceiveCellUpdate(String? cellContent) = + _DidReceiveCellUpdate; + const factory SummaryCellEvent.didUpdateField(FieldInfo fieldInfo) = + _DidUpdateField; + const factory SummaryCellEvent.updateCell(String text) = _UpdateCell; +} + +@freezed +class SummaryCellState with _$SummaryCellState { + const factory SummaryCellState({ + required String content, + required bool wrap, + }) = _SummaryCellState; + + factory SummaryCellState.initial(SummaryCellController cellController) { + final wrap = cellController.fieldInfo.wrapCellContent; + return SummaryCellState( + content: cellController.getCellData() ?? "", + wrap: wrap ?? true, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/summary_row_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/summary_row_bloc.dart new file mode 100644 index 0000000000000..fe69cdb3647ca --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/summary_row_bloc.dart @@ -0,0 +1,99 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'summary_row_bloc.freezed.dart'; + +class SummaryRowBloc extends Bloc { + SummaryRowBloc({ + required this.viewId, + required this.rowId, + required this.fieldId, + }) : super(SummaryRowState.initial()) { + _dispatch(); + } + + final String viewId; + final String rowId; + final String fieldId; + + void _dispatch() { + on( + (event, emit) async { + event.when( + startSummary: () { + final params = SummaryRowPB( + viewId: viewId, + rowId: rowId, + fieldId: fieldId, + ); + emit( + state.copyWith( + loadingState: const LoadingState.loading(), + error: null, + ), + ); + + DatabaseEventSummarizeRow(params).send().then( + (result) => { + if (!isClosed) add(SummaryRowEvent.finishSummary(result)), + }, + ); + }, + finishSummary: (result) { + result.fold( + (s) => { + emit( + state.copyWith( + loadingState: const LoadingState.finish(), + error: null, + ), + ), + }, + (err) => { + emit( + state.copyWith( + loadingState: const LoadingState.finish(), + error: err, + ), + ), + }, + ); + }, + ); + }, + ); + } +} + +@freezed +class SummaryRowEvent with _$SummaryRowEvent { + const factory SummaryRowEvent.startSummary() = _DidStartSummary; + const factory SummaryRowEvent.finishSummary( + FlowyResult result, + ) = _DidFinishSummary; +} + +@freezed +class SummaryRowState with _$SummaryRowState { + const factory SummaryRowState({ + required LoadingState loadingState, + required FlowyError? error, + }) = _SummaryRowState; + + factory SummaryRowState.initial() { + return const SummaryRowState( + loadingState: LoadingState.finish(), + error: null, + ); + } +} + +@freezed +class LoadingState with _$LoadingState { + const factory LoadingState.loading() = _Loading; + const factory LoadingState.finish() = _Finish; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/text_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/text_cell_bloc.dart new file mode 100644 index 0000000000000..7960b34d7c6ef --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/text_cell_bloc.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'text_cell_bloc.freezed.dart'; + +class TextCellBloc extends Bloc { + TextCellBloc({required this.cellController}) + : super(TextCellState.initial(cellController)) { + _dispatch(); + _startListening(); + } + + final TextCellController cellController; + void Function()? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) { + event.when( + didReceiveCellUpdate: (content) { + emit(state.copyWith(content: content)); + }, + didUpdateField: (fieldInfo) { + final wrap = fieldInfo.wrapCellContent; + if (wrap != null) { + emit(state.copyWith(wrap: wrap)); + } + }, + updateText: (String text) { + // If the content is null, it indicates that either the cell is empty (no data) + // or the cell data is still being fetched from the backend and is not yet available. + if (state.content != null && state.content != text) { + cellController.saveCellData(text, debounce: true); + } + }, + enableEdit: (bool enabled) { + emit(state.copyWith(enableEdit: enabled)); + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (cellContent) { + if (!isClosed) { + add(TextCellEvent.didReceiveCellUpdate(cellContent)); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(TextCellEvent.didUpdateField(fieldInfo)); + } + } +} + +@freezed +class TextCellEvent with _$TextCellEvent { + const factory TextCellEvent.didReceiveCellUpdate(String? cellContent) = + _DidReceiveCellUpdate; + const factory TextCellEvent.didUpdateField(FieldInfo fieldInfo) = + _DidUpdateField; + const factory TextCellEvent.updateText(String text) = _UpdateText; + const factory TextCellEvent.enableEdit(bool enabled) = _EnableEdit; +} + +@freezed +class TextCellState with _$TextCellState { + const factory TextCellState({ + required String? content, + required ValueNotifier? emoji, + required ValueNotifier? hasDocument, + required bool enableEdit, + required bool wrap, + }) = _TextCellState; + + factory TextCellState.initial(TextCellController cellController) { + final cellData = cellController.getCellData(); + final wrap = cellController.fieldInfo.wrapCellContent ?? true; + ValueNotifier? emoji; + ValueNotifier? hasDocument; + if (cellController.fieldInfo.isPrimary) { + emoji = cellController.icon; + hasDocument = cellController.hasDocument; + } + + return TextCellState( + content: cellData, + emoji: emoji, + enableEdit: false, + hasDocument: hasDocument, + wrap: wrap, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/time_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/time_cell_bloc.dart new file mode 100644 index 0000000000000..62ff95850f807 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/time_cell_bloc.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/util/time.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +part 'time_cell_bloc.freezed.dart'; + +class TimeCellBloc extends Bloc { + TimeCellBloc({ + required this.cellController, + }) : super(TimeCellState.initial(cellController)) { + _dispatch(); + _startListening(); + } + + final TimeCellController cellController; + void Function()? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didReceiveCellUpdate: (content) { + emit( + state.copyWith( + content: + content != null ? formatTime(content.time.toInt()) : "", + ), + ); + }, + didUpdateField: (fieldInfo) { + final wrap = fieldInfo.wrapCellContent; + if (wrap != null) { + emit(state.copyWith(wrap: wrap)); + } + }, + updateCell: (text) async { + text = parseTime(text)?.toString() ?? text; + if (state.content != text) { + emit(state.copyWith(content: text)); + await cellController.saveCellData(text); + + // If the input content is "abc" that can't parsered as number + // then the data stored in the backend will be an empty string. + // So for every cell data that will be formatted in the backend. + // It needs to get the formatted data after saving. + add( + TimeCellEvent.didReceiveCellUpdate( + cellController.getCellData(), + ), + ); + } + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (cellContent) { + if (!isClosed) { + add(TimeCellEvent.didReceiveCellUpdate(cellContent)); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(TimeCellEvent.didUpdateField(fieldInfo)); + } + } +} + +@freezed +class TimeCellEvent with _$TimeCellEvent { + const factory TimeCellEvent.didReceiveCellUpdate(TimeCellDataPB? cell) = + _DidReceiveCellUpdate; + const factory TimeCellEvent.didUpdateField(FieldInfo fieldInfo) = + _DidUpdateField; + const factory TimeCellEvent.updateCell(String text) = _UpdateCell; +} + +@freezed +class TimeCellState with _$TimeCellState { + const factory TimeCellState({ + required String content, + required bool wrap, + }) = _TimeCellState; + + factory TimeCellState.initial(TimeCellController cellController) { + final wrap = cellController.fieldInfo.wrapCellContent; + final cellData = cellController.getCellData(); + return TimeCellState( + content: cellData != null ? formatTime(cellData.time.toInt()) : "", + wrap: wrap ?? true, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart new file mode 100644 index 0000000000000..6d80109c71541 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/timestamp_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'timestamp_cell_bloc.freezed.dart'; + +class TimestampCellBloc extends Bloc { + TimestampCellBloc({ + required this.cellController, + }) : super(TimestampCellState.initial(cellController)) { + _dispatch(); + _startListening(); + } + + final TimestampCellController cellController; + void Function()? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + event.when( + didReceiveCellUpdate: (TimestampCellDataPB? cellData) { + emit( + state.copyWith( + data: cellData, + dateStr: cellData?.dateTime ?? "", + ), + ); + }, + didUpdateField: (fieldInfo) { + final wrap = fieldInfo.wrapCellContent; + if (wrap != null) { + emit(state.copyWith(wrap: wrap)); + } + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (data) { + if (!isClosed) { + add(TimestampCellEvent.didReceiveCellUpdate(data)); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(TimestampCellEvent.didUpdateField(fieldInfo)); + } + } +} + +@freezed +class TimestampCellEvent with _$TimestampCellEvent { + const factory TimestampCellEvent.didReceiveCellUpdate( + TimestampCellDataPB? data, + ) = _DidReceiveCellUpdate; + const factory TimestampCellEvent.didUpdateField(FieldInfo fieldInfo) = + _DidUpdateField; +} + +@freezed +class TimestampCellState with _$TimestampCellState { + const factory TimestampCellState({ + required TimestampCellDataPB? data, + required String dateStr, + required FieldInfo fieldInfo, + required bool wrap, + }) = _TimestampCellState; + + factory TimestampCellState.initial(TimestampCellController cellController) { + final cellData = cellController.getCellData(); + final wrap = cellController.fieldInfo.wrapCellContent; + + return TimestampCellState( + fieldInfo: cellController.fieldInfo, + data: cellData, + dateStr: cellData?.dateTime ?? "", + wrap: wrap ?? true, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/translate_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/translate_cell_bloc.dart new file mode 100644 index 0000000000000..f31a4a1c916c1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/translate_cell_bloc.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'translate_cell_bloc.freezed.dart'; + +class TranslateCellBloc extends Bloc { + TranslateCellBloc({ + required this.cellController, + }) : super(TranslateCellState.initial(cellController)) { + _dispatch(); + _startListening(); + } + + final TranslateCellController cellController; + void Function()? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didReceiveCellUpdate: (cellData) { + emit( + state.copyWith(content: cellData ?? ""), + ); + }, + didUpdateField: (fieldInfo) { + final wrap = fieldInfo.wrapCellContent; + if (wrap != null) { + emit(state.copyWith(wrap: wrap)); + } + }, + updateCell: (text) async { + if (state.content != text) { + emit(state.copyWith(content: text)); + await cellController.saveCellData(text); + + // If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string. + // So for every cell data that will be formatted in the backend. + // It needs to get the formatted data after saving. + add( + TranslateCellEvent.didReceiveCellUpdate( + cellController.getCellData() ?? "", + ), + ); + } + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (cellContent) { + if (!isClosed) { + add( + TranslateCellEvent.didReceiveCellUpdate(cellContent ?? ""), + ); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(TranslateCellEvent.didUpdateField(fieldInfo)); + } + } +} + +@freezed +class TranslateCellEvent with _$TranslateCellEvent { + const factory TranslateCellEvent.didReceiveCellUpdate(String? cellContent) = + _DidReceiveCellUpdate; + const factory TranslateCellEvent.didUpdateField(FieldInfo fieldInfo) = + _DidUpdateField; + const factory TranslateCellEvent.updateCell(String text) = _UpdateCell; +} + +@freezed +class TranslateCellState with _$TranslateCellState { + const factory TranslateCellState({ + required String content, + required bool wrap, + }) = _TranslateCellState; + + factory TranslateCellState.initial(TranslateCellController cellController) { + final wrap = cellController.fieldInfo.wrapCellContent; + return TranslateCellState( + content: cellController.getCellData() ?? "", + wrap: wrap ?? true, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/translate_row_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/translate_row_bloc.dart new file mode 100644 index 0000000000000..4778df2c2a011 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/translate_row_bloc.dart @@ -0,0 +1,100 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'translate_row_bloc.freezed.dart'; + +class TranslateRowBloc extends Bloc { + TranslateRowBloc({ + required this.viewId, + required this.rowId, + required this.fieldId, + }) : super(TranslateRowState.initial()) { + _dispatch(); + } + + final String viewId; + final String rowId; + final String fieldId; + + void _dispatch() { + on( + (event, emit) async { + event.when( + startTranslate: () { + final params = TranslateRowPB( + viewId: viewId, + rowId: rowId, + fieldId: fieldId, + ); + emit( + state.copyWith( + loadingState: const LoadingState.loading(), + error: null, + ), + ); + + DatabaseEventTranslateRow(params).send().then( + (result) => { + if (!isClosed) + add(TranslateRowEvent.finishTranslate(result)), + }, + ); + }, + finishTranslate: (result) { + result.fold( + (s) => { + emit( + state.copyWith( + loadingState: const LoadingState.finish(), + error: null, + ), + ), + }, + (err) => { + emit( + state.copyWith( + loadingState: const LoadingState.finish(), + error: err, + ), + ), + }, + ); + }, + ); + }, + ); + } +} + +@freezed +class TranslateRowEvent with _$TranslateRowEvent { + const factory TranslateRowEvent.startTranslate() = _DidStartTranslate; + const factory TranslateRowEvent.finishTranslate( + FlowyResult result, + ) = _DidFinishTranslate; +} + +@freezed +class TranslateRowState with _$TranslateRowState { + const factory TranslateRowState({ + required LoadingState loadingState, + required FlowyError? error, + }) = _TranslateRowState; + + factory TranslateRowState.initial() { + return const TranslateRowState( + loadingState: LoadingState.finish(), + error: null, + ); + } +} + +@freezed +class LoadingState with _$LoadingState { + const factory LoadingState.loading() = _Loading; + const factory LoadingState.finish() = _Finish; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/url_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/url_cell_bloc.dart new file mode 100644 index 0000000000000..81ea6d60d1fef --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/url_cell_bloc.dart @@ -0,0 +1,132 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/url_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'url_cell_bloc.freezed.dart'; + +class URLCellBloc extends Bloc { + URLCellBloc({ + required this.cellController, + }) : super(URLCellState.initial(cellController)) { + _dispatch(); + _startListening(); + } + + final URLCellController cellController; + void Function()? _onCellChangedFn; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didUpdateCell: (cellData) async { + final content = cellData?.content ?? ""; + final isValid = await _isUrlValid(content); + emit( + state.copyWith( + content: content, + isValid: isValid, + ), + ); + }, + didUpdateField: (fieldInfo) { + final wrap = fieldInfo.wrapCellContent; + if (wrap != null) { + emit(state.copyWith(wrap: wrap)); + } + }, + updateURL: (String url) { + cellController.saveCellData(url, debounce: true); + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (cellData) { + if (!isClosed) { + add(URLCellEvent.didUpdateCell(cellData)); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(URLCellEvent.didUpdateField(fieldInfo)); + } + } + + Future _isUrlValid(String content) async { + if (content.isEmpty) { + return true; + } + + try { + // check protocol is provided + const linkPrefix = [ + 'http://', + 'https://', + ]; + final shouldAddScheme = + !linkPrefix.any((pattern) => content.startsWith(pattern)); + final url = shouldAddScheme ? 'http://$content' : content; + + // get hostname and check validity + final uri = Uri.parse(url); + final hostName = uri.host; + await InternetAddress.lookup(hostName); + } catch (_) { + return false; + } + return true; + } +} + +@freezed +class URLCellEvent with _$URLCellEvent { + const factory URLCellEvent.updateURL(String url) = _UpdateURL; + const factory URLCellEvent.didUpdateCell(URLCellDataPB? cell) = + _DidUpdateCell; + const factory URLCellEvent.didUpdateField(FieldInfo fieldInfo) = + _DidUpdateField; +} + +@freezed +class URLCellState with _$URLCellState { + const factory URLCellState({ + required String content, + required bool isValid, + required bool wrap, + }) = _URLCellState; + + factory URLCellState.initial(URLCellController cellController) { + final cellData = cellController.getCellData(); + final wrap = cellController.fieldInfo.wrapCellContent; + return URLCellState( + content: cellData?.content ?? "", + isValid: true, + wrap: wrap ?? true, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_cache.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_cache.dart new file mode 100644 index 0000000000000..171f86f11d1a4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_cache.dart @@ -0,0 +1,36 @@ +import 'package:appflowy/plugins/database/application/row/row_service.dart'; + +import 'cell_controller.dart'; + +/// CellMemCache is used to cache cell data of each block. +/// We use CellContext to index the cell in the cache. +/// Read https://docs.appflowy.io/docs/documentation/software-contributions/architecture/frontend/frontend/grid +/// for more information +class CellMemCache { + CellMemCache(); + + /// fieldId: {rowId: cellData} + final Map> _cellByFieldId = {}; + + void removeCellWithFieldId(String fieldId) { + _cellByFieldId.remove(fieldId); + } + + void remove(CellContext context) { + _cellByFieldId[context.fieldId]?.remove(context.rowId); + } + + void insert(CellContext context, T data) { + _cellByFieldId.putIfAbsent(context.fieldId, () => {}); + _cellByFieldId[context.fieldId]![context.rowId] = data; + } + + T? get(CellContext context) { + final value = _cellByFieldId[context.fieldId]?[context.rowId]; + return value is T ? value : null; + } + + void dispose() { + _cellByFieldId.clear(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart new file mode 100644 index 0000000000000..d9009b2ba06e5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller.dart @@ -0,0 +1,252 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/domain/cell_listener.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'cell_cache.dart'; +import 'cell_data_loader.dart'; +import 'cell_data_persistence.dart'; + +part 'cell_controller.freezed.dart'; + +@freezed +class CellContext with _$CellContext { + const factory CellContext({ + required String fieldId, + required RowId rowId, + }) = _DatabaseCellContext; +} + +/// [CellController] is used to manipulate the cell and receive notifications. +/// The cell data is stored in the [RowCache]'s [CellMemCache]. +/// +/// * Read/write cell data +/// * Listen on field/cell notifications. +/// +/// T represents the type of the cell data. +/// D represents the type of data that will be saved to the disk. +class CellController { + CellController({ + required this.viewId, + required FieldController fieldController, + required CellContext cellContext, + required RowCache rowCache, + required CellDataLoader cellDataLoader, + required CellDataPersistence cellDataPersistence, + }) : _fieldController = fieldController, + _cellContext = cellContext, + _rowCache = rowCache, + _cellDataLoader = cellDataLoader, + _cellDataPersistence = cellDataPersistence, + _cellDataNotifier = + CellDataNotifier(value: rowCache.cellCache.get(cellContext)) { + _startListening(); + } + + final String viewId; + final FieldController _fieldController; + final CellContext _cellContext; + final RowCache _rowCache; + final CellDataLoader _cellDataLoader; + final CellDataPersistence _cellDataPersistence; + + CellListener? _cellListener; + CellDataNotifier? _cellDataNotifier; + + Timer? _loadDataOperation; + Timer? _saveDataOperation; + + Completer? _completer; + + RowId get rowId => _cellContext.rowId; + String get fieldId => _cellContext.fieldId; + FieldInfo get fieldInfo => _fieldController.getField(_cellContext.fieldId)!; + FieldType get fieldType => + _fieldController.getField(_cellContext.fieldId)!.fieldType; + ValueNotifier? get icon => _rowCache.getRow(rowId)?.rowIconNotifier; + ValueNotifier? get hasDocument => + _rowCache.getRow(rowId)?.rowDocumentNotifier; + CellMemCache get _cellCache => _rowCache.cellCache; + + /// casting method for painless type coersion + CellController as() => this as CellController; + + /// Start listening to backend changes + void _startListening() { + _cellListener = CellListener( + rowId: _cellContext.rowId, + fieldId: _cellContext.fieldId, + ); + + // 1. Listen on user edit event and load the new cell data if needed. + // For example: + // user input: 12 + // cell display: $12 + _cellListener?.start( + onCellChanged: (result) { + result.fold( + (_) => _loadData(), + (err) => Log.error(err), + ); + }, + ); + + // 2. Listen on the field event and load the cell data if needed. + _fieldController.addSingleFieldListener( + fieldId, + onFieldChanged: _onFieldChangedListener, + ); + } + + /// Add a new listener + VoidCallback? addListener({ + required void Function(T?) onCellChanged, + void Function(FieldInfo fieldInfo)? onFieldChanged, + }) { + /// an adaptor for the onCellChanged listener + void onCellChangedFn() => onCellChanged(_cellDataNotifier?.value); + _cellDataNotifier?.addListener(onCellChangedFn); + + if (onFieldChanged != null) { + _fieldController.addSingleFieldListener( + fieldId, + onFieldChanged: onFieldChanged, + ); + } + + // Return the function pointer that can be used when calling removeListener. + return onCellChangedFn; + } + + void removeListener({ + required VoidCallback onCellChanged, + void Function(FieldInfo fieldInfo)? onFieldChanged, + VoidCallback? onRowMetaChanged, + }) { + _cellDataNotifier?.removeListener(onCellChanged); + + if (onFieldChanged != null) { + _fieldController.removeSingleFieldListener( + fieldId: fieldId, + onFieldChanged: onFieldChanged, + ); + } + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + // reloadOnFieldChanged should be true if you want to reload the cell + // data when the corresponding field is changed. + // For example: + // ¥12 -> $12 + if (_cellDataLoader.reloadOnFieldChange) { + _loadData(); + } + } + + /// Get the cell data. The cell data will be read from the cache first, + /// and load from disk if it doesn't exist. You can set [loadIfNotExist] to + /// false to disable this behavior. + T? getCellData({bool loadIfNotExist = true}) { + final T? data = _cellCache.get(_cellContext); + if (data == null && loadIfNotExist) { + _loadData(); + } + return data; + } + + /// Return the TypeOptionPB that can be parsed into corresponding class using the [parser]. + /// [PD] is the type that the parser return. + PD getTypeOption(TypeOptionParser parser) { + return parser.fromBuffer(fieldInfo.field.typeOptionData); + } + + /// Saves the cell data to disk. You can set [debounce] to reduce the amount + /// of save operations, which is useful when editing a [TextField]. + Future saveCellData( + D data, { + bool debounce = false, + void Function(FlowyError?)? onFinish, + }) async { + _loadDataOperation?.cancel(); + if (debounce) { + _saveDataOperation?.cancel(); + _completer = Completer(); + _saveDataOperation = Timer(const Duration(milliseconds: 300), () async { + final result = await _cellDataPersistence.save( + viewId: viewId, + cellContext: _cellContext, + data: data, + ); + onFinish?.call(result); + _completer?.complete(); + }); + } else { + final result = await _cellDataPersistence.save( + viewId: viewId, + cellContext: _cellContext, + data: data, + ); + onFinish?.call(result); + } + } + + void _loadData() { + _saveDataOperation?.cancel(); + _loadDataOperation?.cancel(); + + _loadDataOperation = Timer(const Duration(milliseconds: 10), () { + _cellDataLoader + .loadData(viewId: viewId, cellContext: _cellContext) + .then((data) { + if (data != null) { + _cellCache.insert(_cellContext, data); + } else { + _cellCache.remove(_cellContext); + } + _cellDataNotifier?.value = data; + }); + }); + } + + Future dispose() async { + await _cellListener?.stop(); + _cellListener = null; + + _fieldController.removeSingleFieldListener( + fieldId: fieldId, + onFieldChanged: _onFieldChangedListener, + ); + + _loadDataOperation?.cancel(); + await _completer?.future; + _saveDataOperation?.cancel(); + _cellDataNotifier?.dispose(); + _cellDataNotifier = null; + } +} + +class CellDataNotifier extends ChangeNotifier { + CellDataNotifier({required T value, this.listenWhen}) : _value = value; + + T _value; + bool Function(T? oldValue, T? newValue)? listenWhen; + + set value(T newValue) { + if (listenWhen != null && !listenWhen!.call(_value, newValue)) { + return; + } + _value = newValue; + notifyListeners(); + } + + T get value => _value; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart new file mode 100644 index 0000000000000..afe05e8b70bf5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart @@ -0,0 +1,188 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +import 'cell_controller.dart'; +import 'cell_data_loader.dart'; +import 'cell_data_persistence.dart'; + +typedef TextCellController = CellController; +typedef CheckboxCellController = CellController; +typedef NumberCellController = CellController; +typedef SelectOptionCellController + = CellController; +typedef ChecklistCellController = CellController; +typedef DateCellController = CellController; +typedef TimestampCellController = CellController; +typedef URLCellController = CellController; +typedef RelationCellController = CellController; +typedef SummaryCellController = CellController; +typedef TimeCellController = CellController; +typedef TranslateCellController = CellController; +typedef MediaCellController = CellController; + +CellController makeCellController( + DatabaseController databaseController, + CellContext cellContext, +) { + final DatabaseController(:viewId, :rowCache, :fieldController) = + databaseController; + final fieldType = fieldController.getField(cellContext.fieldId)!.fieldType; + switch (fieldType) { + case FieldType.Checkbox: + return CheckboxCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: CheckboxCellDataParser(), + ), + cellDataPersistence: TextCellDataPersistence(), + ); + case FieldType.DateTime: + return DateCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: DateCellDataParser(), + reloadOnFieldChange: true, + ), + cellDataPersistence: TextCellDataPersistence(), + ); + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + return TimestampCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: TimestampCellDataParser(), + reloadOnFieldChange: true, + ), + cellDataPersistence: TextCellDataPersistence(), + ); + case FieldType.Number: + return NumberCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: NumberCellDataParser(), + reloadOnFieldChange: true, + ), + cellDataPersistence: TextCellDataPersistence(), + ); + case FieldType.RichText: + return TextCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: StringCellDataParser(), + ), + cellDataPersistence: TextCellDataPersistence(), + ); + case FieldType.MultiSelect: + case FieldType.SingleSelect: + return SelectOptionCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: SelectOptionCellDataParser(), + reloadOnFieldChange: true, + ), + cellDataPersistence: TextCellDataPersistence(), + ); + case FieldType.Checklist: + return ChecklistCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: ChecklistCellDataParser(), + reloadOnFieldChange: true, + ), + cellDataPersistence: TextCellDataPersistence(), + ); + case FieldType.URL: + return URLCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: URLCellDataParser(), + ), + cellDataPersistence: TextCellDataPersistence(), + ); + case FieldType.Relation: + return RelationCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: RelationCellDataParser(), + reloadOnFieldChange: true, + ), + cellDataPersistence: TextCellDataPersistence(), + ); + case FieldType.Summary: + return SummaryCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: StringCellDataParser(), + reloadOnFieldChange: true, + ), + cellDataPersistence: TextCellDataPersistence(), + ); + case FieldType.Time: + return TimeCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: TimeCellDataParser(), + reloadOnFieldChange: true, + ), + cellDataPersistence: TextCellDataPersistence(), + ); + case FieldType.Translate: + return TranslateCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: StringCellDataParser(), + reloadOnFieldChange: true, + ), + cellDataPersistence: TextCellDataPersistence(), + ); + case FieldType.Media: + return MediaCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: MediaCellDataParser(), + reloadOnFieldChange: true, + ), + cellDataPersistence: TextCellDataPersistence(), + ); + } + throw UnimplementedError; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart new file mode 100644 index 0000000000000..cfab4668ae1c8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart @@ -0,0 +1,214 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/database/domain/cell_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +import 'cell_controller.dart'; + +abstract class CellDataParser { + T? parserData(List data); +} + +class CellDataLoader { + CellDataLoader({ + required this.parser, + this.reloadOnFieldChange = false, + }); + + final CellDataParser parser; + + /// Reload the cell data if the field is changed. + final bool reloadOnFieldChange; + + Future loadData({ + required String viewId, + required CellContext cellContext, + }) { + return CellBackendService.getCell( + viewId: viewId, + cellContext: cellContext, + ).then( + (result) => result.fold( + (CellPB cell) { + try { + return parser.parserData(cell.data); + } catch (e, s) { + Log.error('$parser parser cellData failed, $e'); + Log.error('Stack trace \n $s'); + return null; + } + }, + (err) { + Log.error(err); + return null; + }, + ), + ); + } +} + +class StringCellDataParser implements CellDataParser { + @override + String? parserData(List data) { + try { + final s = utf8.decode(data); + return s; + } catch (e) { + Log.error("Failed to parse string data: $e"); + return null; + } + } +} + +class CheckboxCellDataParser implements CellDataParser { + @override + CheckboxCellDataPB? parserData(List data) { + if (data.isEmpty) { + return null; + } + + try { + return CheckboxCellDataPB.fromBuffer(data); + } catch (e) { + Log.error("Failed to parse checkbox data: $e"); + return null; + } + } +} + +class NumberCellDataParser implements CellDataParser { + @override + String? parserData(List data) { + try { + return utf8.decode(data); + } catch (e) { + Log.error("Failed to parse number data: $e"); + return null; + } + } +} + +class DateCellDataParser implements CellDataParser { + @override + DateCellDataPB? parserData(List data) { + if (data.isEmpty) { + return null; + } + try { + return DateCellDataPB.fromBuffer(data); + } catch (e) { + Log.error("Failed to parse date data: $e"); + return null; + } + } +} + +class TimestampCellDataParser implements CellDataParser { + @override + TimestampCellDataPB? parserData(List data) { + if (data.isEmpty) { + return null; + } + try { + return TimestampCellDataPB.fromBuffer(data); + } catch (e) { + Log.error("Failed to parse timestamp data: $e"); + return null; + } + } +} + +class SelectOptionCellDataParser + implements CellDataParser { + @override + SelectOptionCellDataPB? parserData(List data) { + if (data.isEmpty) { + return null; + } + try { + return SelectOptionCellDataPB.fromBuffer(data); + } catch (e) { + Log.error("Failed to parse select option data: $e"); + return null; + } + } +} + +class ChecklistCellDataParser implements CellDataParser { + @override + ChecklistCellDataPB? parserData(List data) { + if (data.isEmpty) { + return null; + } + + try { + return ChecklistCellDataPB.fromBuffer(data); + } catch (e) { + Log.error("Failed to parse checklist data: $e"); + return null; + } + } +} + +class URLCellDataParser implements CellDataParser { + @override + URLCellDataPB? parserData(List data) { + if (data.isEmpty) { + return null; + } + try { + return URLCellDataPB.fromBuffer(data); + } catch (e) { + Log.error("Failed to parse url data: $e"); + return null; + } + } +} + +class RelationCellDataParser implements CellDataParser { + @override + RelationCellDataPB? parserData(List data) { + if (data.isEmpty) { + return null; + } + + try { + return RelationCellDataPB.fromBuffer(data); + } catch (e) { + Log.error("Failed to parse relation data: $e"); + return null; + } + } +} + +class TimeCellDataParser implements CellDataParser { + @override + TimeCellDataPB? parserData(List data) { + if (data.isEmpty) { + return null; + } + try { + return TimeCellDataPB.fromBuffer(data); + } catch (e) { + Log.error("Failed to parse timer data: $e"); + return null; + } + } +} + +class MediaCellDataParser implements CellDataParser { + @override + MediaCellDataPB? parserData(List data) { + if (data.isEmpty) { + return null; + } + + try { + return MediaCellDataPB.fromBuffer(data); + } catch (e) { + Log.error("Failed to parse media cell data: $e"); + return null; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_persistence.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_persistence.dart new file mode 100644 index 0000000000000..f377515ac4191 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_persistence.dart @@ -0,0 +1,37 @@ +import 'package:appflowy/plugins/database/domain/cell_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; + +import 'cell_controller.dart'; + +/// Save the cell data to disk +/// You can extend this class to do custom operations. +abstract class CellDataPersistence { + Future save({ + required String viewId, + required CellContext cellContext, + required D data, + }); +} + +class TextCellDataPersistence implements CellDataPersistence { + TextCellDataPersistence(); + + @override + Future save({ + required String viewId, + required CellContext cellContext, + required String data, + }) async { + final fut = CellBackendService.updateCell( + viewId: viewId, + cellContext: cellContext, + data: data, + ); + return fut.then((result) { + return result.fold( + (l) => null, + (err) => err, + ); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart new file mode 100644 index 0000000000000..5d0bb760fe840 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/database_controller.dart @@ -0,0 +1,379 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/view/view_cache.dart'; +import 'package:appflowy/plugins/database/domain/database_view_service.dart'; +import 'package:appflowy/plugins/database/domain/group_listener.dart'; +import 'package:appflowy/plugins/database/domain/layout_service.dart'; +import 'package:appflowy/plugins/database/domain/layout_setting_listener.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; + +import 'defines.dart'; +import 'row/row_cache.dart'; + +typedef OnGroupConfigurationChanged = void Function(List); +typedef OnGroupByField = void Function(List); +typedef OnUpdateGroup = void Function(List); +typedef OnDeleteGroup = void Function(List); +typedef OnInsertGroup = void Function(InsertedGroupPB); + +class GroupCallbacks { + GroupCallbacks({ + this.onGroupConfigurationChanged, + this.onGroupByField, + this.onUpdateGroup, + this.onDeleteGroup, + this.onInsertGroup, + }); + + final OnGroupConfigurationChanged? onGroupConfigurationChanged; + final OnGroupByField? onGroupByField; + final OnUpdateGroup? onUpdateGroup; + final OnDeleteGroup? onDeleteGroup; + final OnInsertGroup? onInsertGroup; +} + +class DatabaseLayoutSettingCallbacks { + DatabaseLayoutSettingCallbacks({required this.onLayoutSettingsChanged}); + + final void Function(DatabaseLayoutSettingPB) onLayoutSettingsChanged; +} + +class DatabaseCallbacks { + DatabaseCallbacks({ + this.onDatabaseChanged, + this.onNumOfRowsChanged, + this.onFieldsChanged, + this.onFiltersChanged, + this.onSortsChanged, + this.onRowsUpdated, + this.onRowsDeleted, + this.onRowsCreated, + }); + + OnDatabaseChanged? onDatabaseChanged; + OnFieldsChanged? onFieldsChanged; + OnFiltersChanged? onFiltersChanged; + OnSortsChanged? onSortsChanged; + OnNumOfRowsChanged? onNumOfRowsChanged; + OnRowsDeleted? onRowsDeleted; + OnRowsUpdated? onRowsUpdated; + OnRowsCreated? onRowsCreated; +} + +class DatabaseController { + DatabaseController({required this.view}) + : _databaseViewBackendSvc = DatabaseViewBackendService(viewId: view.id), + fieldController = FieldController(viewId: view.id), + _groupListener = DatabaseGroupListener(view.id), + databaseLayout = databaseLayoutFromViewLayout(view.layout), + _layoutListener = DatabaseLayoutSettingListener(view.id) { + _viewCache = DatabaseViewCache( + viewId: viewId, + fieldController: fieldController, + ); + + _listenOnRowsChanged(); + _listenOnFieldsChanged(); + _listenOnGroupChanged(); + _listenOnLayoutChanged(); + } + + final ViewPB view; + final DatabaseViewBackendService _databaseViewBackendSvc; + final FieldController fieldController; + DatabaseLayoutPB databaseLayout; + DatabaseLayoutSettingPB? databaseLayoutSetting; + late DatabaseViewCache _viewCache; + + // Callbacks + final List _databaseCallbacks = []; + final List _groupCallbacks = []; + final List _layoutCallbacks = []; + + // Getters + RowCache get rowCache => _viewCache.rowCache; + String get viewId => view.id; + + // Listener + final DatabaseGroupListener _groupListener; + final DatabaseLayoutSettingListener _layoutListener; + + final ValueNotifier _isLoading = ValueNotifier(true); + + void setIsLoading(bool isLoading) { + _isLoading.value = isLoading; + } + + ValueNotifier get isLoading => _isLoading; + + void addListener({ + DatabaseCallbacks? onDatabaseChanged, + DatabaseLayoutSettingCallbacks? onLayoutSettingsChanged, + GroupCallbacks? onGroupChanged, + }) { + if (onLayoutSettingsChanged != null) { + _layoutCallbacks.add(onLayoutSettingsChanged); + } + + if (onDatabaseChanged != null) { + _databaseCallbacks.add(onDatabaseChanged); + } + + if (onGroupChanged != null) { + _groupCallbacks.add(onGroupChanged); + } + } + + void removeListener({ + DatabaseCallbacks? onDatabaseChanged, + DatabaseLayoutSettingCallbacks? onLayoutSettingsChanged, + GroupCallbacks? onGroupChanged, + }) { + if (onDatabaseChanged != null) { + _databaseCallbacks.remove(onDatabaseChanged); + } + + if (onLayoutSettingsChanged != null) { + _layoutCallbacks.remove(onLayoutSettingsChanged); + } + + if (onGroupChanged != null) { + _groupCallbacks.remove(onGroupChanged); + } + } + + Future> open() async { + return _databaseViewBackendSvc.openDatabase().then((result) { + return result.fold( + (DatabasePB database) async { + databaseLayout = database.layoutType; + + // Load the actual database field data. + final fieldsOrFail = await fieldController.loadFields( + fieldIds: database.fields, + ); + return fieldsOrFail.fold( + (fields) { + // Notify the database is changed after the fields are loaded. + // The database won't can't be used until the fields are loaded. + for (final callback in _databaseCallbacks) { + callback.onDatabaseChanged?.call(database); + } + _viewCache.rowCache.setInitialRows(database.rows); + return Future(() async { + await _loadGroups(); + await _loadLayoutSetting(); + return FlowyResult.success(fields); + }); + }, + (err) { + Log.error(err); + return FlowyResult.failure(err); + }, + ); + }, + (err) => FlowyResult.failure(err), + ); + }); + } + + Future> moveGroupRow({ + required RowMetaPB fromRow, + required String fromGroupId, + required String toGroupId, + RowMetaPB? toRow, + }) { + return _databaseViewBackendSvc.moveGroupRow( + fromRowId: fromRow.id, + fromGroupId: fromGroupId, + toGroupId: toGroupId, + toRowId: toRow?.id, + ); + } + + Future> moveRow({ + required String fromRowId, + required String toRowId, + }) { + return _databaseViewBackendSvc.moveRow( + fromRowId: fromRowId, + toRowId: toRowId, + ); + } + + Future> moveGroup({ + required String fromGroupId, + required String toGroupId, + }) { + return _databaseViewBackendSvc.moveGroup( + fromGroupId: fromGroupId, + toGroupId: toGroupId, + ); + } + + Future updateLayoutSetting({ + BoardLayoutSettingPB? boardLayoutSetting, + CalendarLayoutSettingPB? calendarLayoutSetting, + }) async { + await _databaseViewBackendSvc + .updateLayoutSetting( + boardLayoutSetting: boardLayoutSetting, + calendarLayoutSetting: calendarLayoutSetting, + layoutType: databaseLayout, + ) + .then((result) { + result.fold((l) => null, (r) => Log.error(r)); + }); + } + + Future dispose() async { + await _databaseViewBackendSvc.closeView(); + await fieldController.dispose(); + await _groupListener.stop(); + await _viewCache.dispose(); + _databaseCallbacks.clear(); + _groupCallbacks.clear(); + _layoutCallbacks.clear(); + _isLoading.dispose(); + } + + Future _loadGroups() async { + final groupsResult = await _databaseViewBackendSvc.loadGroups(); + groupsResult.fold( + (groups) { + for (final callback in _groupCallbacks) { + callback.onGroupByField?.call(groups.items); + } + }, + (err) => Log.error(err), + ); + } + + Future _loadLayoutSetting() { + return _databaseViewBackendSvc + .getLayoutSetting(databaseLayout) + .then((result) { + result.fold( + (newDatabaseLayoutSetting) { + databaseLayoutSetting = newDatabaseLayoutSetting; + + for (final callback in _layoutCallbacks) { + callback.onLayoutSettingsChanged(newDatabaseLayoutSetting); + } + }, + (r) => Log.error(r), + ); + }); + } + + void _listenOnRowsChanged() { + final callbacks = DatabaseViewCallbacks( + onNumOfRowsChanged: (rows, rowByRowId, reason) { + for (final callback in _databaseCallbacks) { + callback.onNumOfRowsChanged?.call(rows, rowByRowId, reason); + } + }, + onRowsDeleted: (ids) { + for (final callback in _databaseCallbacks) { + callback.onRowsDeleted?.call(ids); + } + }, + onRowsUpdated: (ids, reason) { + for (final callback in _databaseCallbacks) { + callback.onRowsUpdated?.call(ids, reason); + } + }, + onRowsCreated: (ids) { + for (final callback in _databaseCallbacks) { + callback.onRowsCreated?.call(ids); + } + }, + ); + _viewCache.addListener(callbacks); + } + + void _listenOnFieldsChanged() { + fieldController.addListener( + onReceiveFields: (fields) { + for (final callback in _databaseCallbacks) { + callback.onFieldsChanged?.call(UnmodifiableListView(fields)); + } + }, + onSorts: (sorts) { + for (final callback in _databaseCallbacks) { + callback.onSortsChanged?.call(sorts); + } + }, + onFilters: (filters) { + for (final callback in _databaseCallbacks) { + callback.onFiltersChanged?.call(filters); + } + }, + ); + } + + void _listenOnGroupChanged() { + _groupListener.start( + onNumOfGroupsChanged: (result) { + result.fold( + (changeset) { + if (changeset.updateGroups.isNotEmpty) { + for (final callback in _groupCallbacks) { + callback.onUpdateGroup?.call(changeset.updateGroups); + } + } + + if (changeset.deletedGroups.isNotEmpty) { + for (final callback in _groupCallbacks) { + callback.onDeleteGroup?.call(changeset.deletedGroups); + } + } + + for (final insertedGroup in changeset.insertedGroups) { + for (final callback in _groupCallbacks) { + callback.onInsertGroup?.call(insertedGroup); + } + } + }, + (r) => Log.error(r), + ); + }, + onGroupByNewField: (result) { + result.fold( + (groups) { + for (final callback in _groupCallbacks) { + callback.onGroupByField?.call(groups); + } + }, + (r) => Log.error(r), + ); + }, + ); + } + + void _listenOnLayoutChanged() { + _layoutListener.start( + onLayoutChanged: (result) { + result.fold( + (newLayout) { + databaseLayoutSetting = newLayout; + databaseLayoutSetting?.freeze(); + + for (final callback in _layoutCallbacks) { + callback.onLayoutSettingsChanged(newLayout); + } + }, + (r) => Log.error(r), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/defines.dart b/frontend/appflowy_flutter/lib/plugins/database/application/defines.dart new file mode 100644 index 0000000000000..65deae7e58743 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/defines.dart @@ -0,0 +1,46 @@ +import 'dart:collection'; + +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'field/field_info.dart'; +import 'field/sort_entities.dart'; +import 'row/row_cache.dart'; +import 'row/row_service.dart'; + +part 'defines.freezed.dart'; + +typedef OnFieldsChanged = void Function(UnmodifiableListView); +typedef OnFiltersChanged = void Function(List); +typedef OnSortsChanged = void Function(List); +typedef OnDatabaseChanged = void Function(DatabasePB); + +typedef OnRowsCreated = void Function(List rows); +typedef OnRowsUpdated = void Function( + List rowIds, + ChangedReason reason, +); +typedef OnRowsDeleted = void Function(List rowIds); +typedef OnNumOfRowsChanged = void Function( + UnmodifiableListView rows, + UnmodifiableMapView rowById, + ChangedReason reason, +); +typedef OnRowsVisibilityChanged = void Function( + List<(RowId, bool)> rowVisibilityChanges, +); + +@freezed +class LoadingState with _$LoadingState { + const factory LoadingState.idle() = _Idle; + const factory LoadingState.loading() = _Loading; + const factory LoadingState.finish( + FlowyResult successOrFail, + ) = _Finish; + + const LoadingState._(); + bool isLoading() => this is _Loading; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_cell_bloc.dart new file mode 100644 index 0000000000000..5a72fcccc0fda --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_cell_bloc.dart @@ -0,0 +1,72 @@ +import 'dart:math'; + +import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'field_info.dart'; + +part 'field_cell_bloc.freezed.dart'; + +class FieldCellBloc extends Bloc { + FieldCellBloc({required String viewId, required FieldInfo fieldInfo}) + : _fieldSettingsService = FieldSettingsBackendService(viewId: viewId), + super(FieldCellState.initial(fieldInfo)) { + _dispatch(); + } + + final FieldSettingsBackendService _fieldSettingsService; + + void _dispatch() { + on( + (event, emit) async { + event.when( + onFieldChanged: (newFieldInfo) => + emit(FieldCellState.initial(newFieldInfo)), + onResizeStart: () => + emit(state.copyWith(isResizing: true, resizeStart: state.width)), + startUpdateWidth: (offset) { + final width = max(offset + state.resizeStart, 50).toDouble(); + emit(state.copyWith(width: width)); + }, + endUpdateWidth: () { + if (state.width != state.fieldInfo.width) { + _fieldSettingsService.updateFieldSettings( + fieldId: state.fieldInfo.id, + width: state.width, + ); + } + emit(state.copyWith(isResizing: false, resizeStart: 0)); + }, + ); + }, + ); + } +} + +@freezed +class FieldCellEvent with _$FieldCellEvent { + const factory FieldCellEvent.onFieldChanged(FieldInfo newFieldInfo) = + _OnFieldChanged; + const factory FieldCellEvent.onResizeStart() = _OnResizeStart; + const factory FieldCellEvent.startUpdateWidth(double offset) = + _StartUpdateWidth; + const factory FieldCellEvent.endUpdateWidth() = _EndUpdateWidth; +} + +@freezed +class FieldCellState with _$FieldCellState { + factory FieldCellState.initial(FieldInfo fieldInfo) => FieldCellState( + fieldInfo: fieldInfo, + isResizing: false, + width: fieldInfo.width!.toDouble(), + resizeStart: 0, + ); + + const factory FieldCellState({ + required FieldInfo fieldInfo, + required double width, + required bool isResizing, + required double resizeStart, + }) = _FieldCellState; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart new file mode 100644 index 0000000000000..8370bd9bff740 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_controller.dart @@ -0,0 +1,768 @@ +import 'dart:collection'; + +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/application/setting/setting_listener.dart'; +import 'package:appflowy/plugins/database/domain/database_view_service.dart'; +import 'package:appflowy/plugins/database/domain/field_listener.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_listener.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; +import 'package:appflowy/plugins/database/domain/filter_listener.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; +import 'package:appflowy/plugins/database/domain/sort_listener.dart'; +import 'package:appflowy/plugins/database/domain/sort_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; + +import '../setting/setting_service.dart'; +import 'field_info.dart'; +import 'filter_entities.dart'; +import 'sort_entities.dart'; + +class _GridFieldNotifier extends ChangeNotifier { + List _fieldInfos = []; + + set fieldInfos(List fieldInfos) { + _fieldInfos = fieldInfos; + notifyListeners(); + } + + void notify() { + notifyListeners(); + } + + UnmodifiableListView get fieldInfos => + UnmodifiableListView(_fieldInfos); +} + +class _GridFilterNotifier extends ChangeNotifier { + List _filters = []; + + set filters(List filters) { + _filters = filters; + notifyListeners(); + } + + void notify() { + notifyListeners(); + } + + List get filters => _filters; +} + +class _GridSortNotifier extends ChangeNotifier { + List _sorts = []; + + set sorts(List sorts) { + _sorts = sorts; + notifyListeners(); + } + + void notify() { + notifyListeners(); + } + + List get sorts => _sorts; +} + +typedef OnReceiveUpdateFields = void Function(List); +typedef OnReceiveField = void Function(FieldInfo); +typedef OnReceiveFields = void Function(List); +typedef OnReceiveFilters = void Function(List); +typedef OnReceiveSorts = void Function(List); + +class FieldController { + FieldController({required this.viewId}) + : _fieldListener = FieldsListener(viewId: viewId), + _settingListener = DatabaseSettingListener(viewId: viewId), + _filterBackendSvc = FilterBackendService(viewId: viewId), + _filtersListener = FiltersListener(viewId: viewId), + _databaseViewBackendSvc = DatabaseViewBackendService(viewId: viewId), + _sortBackendSvc = SortBackendService(viewId: viewId), + _sortsListener = SortsListener(viewId: viewId), + _fieldSettingsListener = FieldSettingsListener(viewId: viewId), + _fieldSettingsBackendSvc = FieldSettingsBackendService(viewId: viewId) { + // Start listeners + _listenOnFieldChanges(); + _listenOnSettingChanges(); + _listenOnFilterChanges(); + _listenOnSortChanged(); + _listenOnFieldSettingsChanged(); + } + + final String viewId; + + // Listeners + final FieldsListener _fieldListener; + final DatabaseSettingListener _settingListener; + final FiltersListener _filtersListener; + final SortsListener _sortsListener; + final FieldSettingsListener _fieldSettingsListener; + + // FFI services + final DatabaseViewBackendService _databaseViewBackendSvc; + final FilterBackendService _filterBackendSvc; + final SortBackendService _sortBackendSvc; + final FieldSettingsBackendService _fieldSettingsBackendSvc; + + bool _isDisposed = false; + + // Field callbacks + final Map _fieldCallbacks = {}; + final _GridFieldNotifier _fieldNotifier = _GridFieldNotifier(); + + // Field updated callbacks + final Map)> + _updatedFieldCallbacks = {}; + + // Filter callbacks + final Map _filterCallbacks = {}; + _GridFilterNotifier? _filterNotifier = _GridFilterNotifier(); + + // Sort callbacks + final Map _sortCallbacks = {}; + _GridSortNotifier? _sortNotifier = _GridSortNotifier(); + + // Database settings temporary storage + final Map _groupConfigurationByFieldId = {}; + final List _fieldSettings = []; + + // Getters + List get fieldInfos => [..._fieldNotifier.fieldInfos]; + List get filters => [..._filterNotifier?.filters ?? []]; + List get sorts => [..._sortNotifier?.sorts ?? []]; + List get groupSettings => + _groupConfigurationByFieldId.entries.map((e) => e.value).toList(); + + FieldInfo? getField(String fieldId) { + return _fieldNotifier.fieldInfos + .firstWhereOrNull((element) => element.id == fieldId); + } + + DatabaseFilter? getFilterByFilterId(String filterId) { + return _filterNotifier?.filters + .firstWhereOrNull((element) => element.filterId == filterId); + } + + DatabaseFilter? getFilterByFieldId(String fieldId) { + return _filterNotifier?.filters + .firstWhereOrNull((element) => element.fieldId == fieldId); + } + + DatabaseSort? getSortBySortId(String sortId) { + return _sortNotifier?.sorts + .firstWhereOrNull((element) => element.sortId == sortId); + } + + DatabaseSort? getSortByFieldId(String fieldId) { + return _sortNotifier?.sorts + .firstWhereOrNull((element) => element.fieldId == fieldId); + } + + /// Listen for filter changes in the backend. + void _listenOnFilterChanges() { + _filtersListener.start( + onFilterChanged: (result) { + if (_isDisposed) { + return; + } + + result.fold( + (FilterChangesetNotificationPB changeset) { + _filterNotifier?.filters = + _filterListFromPBs(changeset.filters.items); + _fieldNotifier.fieldInfos = + _updateFieldInfos(_fieldNotifier.fieldInfos); + }, + (err) => Log.error(err), + ); + }, + ); + } + + /// Listen for sort changes in the backend. + void _listenOnSortChanged() { + void deleteSortFromChangeset( + List newDatabaseSorts, + SortChangesetNotificationPB changeset, + ) { + final deleteSortIds = changeset.deleteSorts.map((e) => e.id).toList(); + if (deleteSortIds.isNotEmpty) { + newDatabaseSorts.retainWhere( + (element) => !deleteSortIds.contains(element.sortId), + ); + } + } + + void insertSortFromChangeset( + List newDatabaseSorts, + SortChangesetNotificationPB changeset, + ) { + for (final newSortPB in changeset.insertSorts) { + final sortIndex = newDatabaseSorts + .indexWhere((element) => element.sortId == newSortPB.sort.id); + if (sortIndex == -1) { + newDatabaseSorts.insert( + newSortPB.index, + DatabaseSort.fromPB(newSortPB.sort), + ); + } + } + } + + void updateSortFromChangeset( + List newDatabaseSorts, + SortChangesetNotificationPB changeset, + ) { + for (final updatedSort in changeset.updateSorts) { + final newDatabaseSort = DatabaseSort.fromPB(updatedSort); + + final sortIndex = newDatabaseSorts.indexWhere( + (element) => element.sortId == updatedSort.id, + ); + + if (sortIndex != -1) { + newDatabaseSorts.removeAt(sortIndex); + newDatabaseSorts.insert(sortIndex, newDatabaseSort); + } else { + newDatabaseSorts.add(newDatabaseSort); + } + } + } + + void updateFieldInfos( + List newDatabaseSorts, + SortChangesetNotificationPB changeset, + ) { + final changedFieldIds = HashSet.from([ + ...changeset.insertSorts.map((sort) => sort.sort.fieldId), + ...changeset.updateSorts.map((sort) => sort.fieldId), + ...changeset.deleteSorts.map((sort) => sort.fieldId), + ...?_sortNotifier?.sorts.map((sort) => sort.fieldId), + ]); + + final newFieldInfos = [...fieldInfos]; + + for (final fieldId in changedFieldIds) { + final index = + newFieldInfos.indexWhere((fieldInfo) => fieldInfo.id == fieldId); + if (index == -1) { + continue; + } + newFieldInfos[index] = newFieldInfos[index].copyWith( + hasSort: newDatabaseSorts.any((sort) => sort.fieldId == fieldId), + ); + } + + _fieldNotifier.fieldInfos = newFieldInfos; + } + + _sortsListener.start( + onSortChanged: (result) { + if (_isDisposed) { + return; + } + result.fold( + (SortChangesetNotificationPB changeset) { + final List newDatabaseSorts = sorts; + deleteSortFromChangeset(newDatabaseSorts, changeset); + insertSortFromChangeset(newDatabaseSorts, changeset); + updateSortFromChangeset(newDatabaseSorts, changeset); + + updateFieldInfos(newDatabaseSorts, changeset); + _sortNotifier?.sorts = newDatabaseSorts; + }, + (err) => Log.error(err), + ); + }, + ); + } + + /// Listen for database setting changes in the backend. + void _listenOnSettingChanges() { + _settingListener.start( + onSettingUpdated: (result) { + if (_isDisposed) { + return; + } + + result.fold( + (setting) => _updateSetting(setting), + (r) => Log.error(r), + ); + }, + ); + } + + /// Listen for field changes in the backend. + void _listenOnFieldChanges() { + Future attachFieldSettings(FieldInfo fieldInfo) async { + return _fieldSettingsBackendSvc + .getFieldSettings(fieldInfo.id) + .then((result) { + final fieldSettings = result.fold( + (fieldSettings) => fieldSettings, + (err) => null, + ); + if (fieldSettings == null) { + return fieldInfo; + } + final updatedFieldInfo = + fieldInfo.copyWith(fieldSettings: fieldSettings); + + final index = _fieldSettings + .indexWhere((element) => element.fieldId == fieldInfo.id); + if (index != -1) { + _fieldSettings.removeAt(index); + } + _fieldSettings.add(fieldSettings); + + return updatedFieldInfo; + }); + } + + List deleteFields(List deletedFields) { + if (deletedFields.isEmpty) { + return fieldInfos; + } + final List newFields = fieldInfos; + final Map deletedFieldMap = { + for (final fieldOrder in deletedFields) fieldOrder.fieldId: fieldOrder, + }; + + newFields.retainWhere((field) => deletedFieldMap[field.id] == null); + return newFields; + } + + Future> insertFields( + List insertedFields, + List fieldInfos, + ) async { + if (insertedFields.isEmpty) { + return fieldInfos; + } + final List newFieldInfos = fieldInfos; + for (final indexField in insertedFields) { + final initial = FieldInfo.initial(indexField.field_1); + final fieldInfo = await attachFieldSettings(initial); + if (newFieldInfos.length > indexField.index) { + newFieldInfos.insert(indexField.index, fieldInfo); + } else { + newFieldInfos.add(fieldInfo); + } + } + return newFieldInfos; + } + + Future<(List, List)> updateFields( + List updatedFieldPBs, + List fieldInfos, + ) async { + if (updatedFieldPBs.isEmpty) { + return ([], fieldInfos); + } + + final List newFieldInfo = fieldInfos; + final List updatedFields = []; + for (final updatedFieldPB in updatedFieldPBs) { + final index = + newFieldInfo.indexWhere((field) => field.id == updatedFieldPB.id); + if (index != -1) { + newFieldInfo.removeAt(index); + final initial = FieldInfo.initial(updatedFieldPB); + final fieldInfo = await attachFieldSettings(initial); + newFieldInfo.insert(index, fieldInfo); + updatedFields.add(fieldInfo); + } + } + + return (updatedFields, newFieldInfo); + } + + // Listen on field's changes + _fieldListener.start( + onFieldsChanged: (result) async { + result.fold( + (changeset) async { + if (_isDisposed) { + return; + } + List updatedFields; + List fieldInfos = deleteFields(changeset.deletedFields); + fieldInfos = + await insertFields(changeset.insertedFields, fieldInfos); + (updatedFields, fieldInfos) = + await updateFields(changeset.updatedFields, fieldInfos); + + _fieldNotifier.fieldInfos = _updateFieldInfos(fieldInfos); + for (final listener in _updatedFieldCallbacks.values) { + listener(updatedFields); + } + }, + (err) => Log.error(err), + ); + }, + ); + } + + /// Listen for field setting changes in the backend. + void _listenOnFieldSettingsChanged() { + FieldInfo? updateFieldSettings(FieldSettingsPB updatedFieldSettings) { + final List newFields = fieldInfos; + var updatedField = newFields.firstOrNull; + + if (updatedField == null) { + return null; + } + + final index = newFields + .indexWhere((field) => field.id == updatedFieldSettings.fieldId); + if (index != -1) { + newFields[index] = + newFields[index].copyWith(fieldSettings: updatedFieldSettings); + updatedField = newFields[index]; + } + + _fieldNotifier.fieldInfos = newFields; + return updatedField; + } + + _fieldSettingsListener.start( + onFieldSettingsChanged: (result) { + if (_isDisposed) { + return; + } + result.fold( + (fieldSettings) { + final updatedFieldInfo = updateFieldSettings(fieldSettings); + if (updatedFieldInfo == null) { + return; + } + + for (final listener in _updatedFieldCallbacks.values) { + listener([updatedFieldInfo]); + } + }, + (err) => Log.error(err), + ); + }, + ); + } + + /// Updates sort, filter, group and field info from `DatabaseViewSettingPB` + void _updateSetting(DatabaseViewSettingPB setting) { + _groupConfigurationByFieldId.clear(); + for (final configuration in setting.groupSettings.items) { + _groupConfigurationByFieldId[configuration.fieldId] = configuration; + } + + _filterNotifier?.filters = _filterListFromPBs(setting.filters.items); + + _sortNotifier?.sorts = _sortListFromPBs(setting.sorts.items); + + _fieldSettings.clear(); + _fieldSettings.addAll(setting.fieldSettings.items); + + _fieldNotifier.fieldInfos = _updateFieldInfos(_fieldNotifier.fieldInfos); + } + + /// Attach sort, filter, group information and field settings to `FieldInfo` + List _updateFieldInfos(List fieldInfos) { + return fieldInfos + .map( + (field) => field.copyWith( + fieldSettings: _fieldSettings + .firstWhereOrNull((setting) => setting.fieldId == field.id), + isGroupField: _groupConfigurationByFieldId[field.id] != null, + hasFilter: getFilterByFieldId(field.id) != null, + hasSort: getSortByFieldId(field.id) != null, + ), + ) + .toList(); + } + + /// Load all of the fields. This is required when opening the database + Future> loadFields({ + required List fieldIds, + }) async { + final result = await _databaseViewBackendSvc.getFields(fieldIds: fieldIds); + return Future( + () => result.fold( + (newFields) async { + if (_isDisposed) { + return FlowyResult.success(null); + } + + _fieldNotifier.fieldInfos = + newFields.map((field) => FieldInfo.initial(field)).toList(); + await Future.wait([ + _loadFilters(), + _loadSorts(), + _loadAllFieldSettings(), + _loadSettings(), + ]); + _fieldNotifier.fieldInfos = + _updateFieldInfos(_fieldNotifier.fieldInfos); + + return FlowyResult.success(null); + }, + (err) => FlowyResult.failure(err), + ), + ); + } + + /// Load all the filters from the backend. Required by `loadFields` + Future> _loadFilters() async { + return _filterBackendSvc.getAllFilters().then((result) { + return result.fold( + (filterPBs) { + _filterNotifier?.filters = _filterListFromPBs(filterPBs); + return FlowyResult.success(null); + }, + (err) => FlowyResult.failure(err), + ); + }); + } + + /// Load all the sorts from the backend. Required by `loadFields` + Future> _loadSorts() async { + return _sortBackendSvc.getAllSorts().then((result) { + return result.fold( + (sortPBs) { + _sortNotifier?.sorts = _sortListFromPBs(sortPBs); + return FlowyResult.success(null); + }, + (err) => FlowyResult.failure(err), + ); + }); + } + + /// Load all the field settings from the backend. Required by `loadFields` + Future> _loadAllFieldSettings() async { + return _fieldSettingsBackendSvc.getAllFieldSettings().then((result) { + return result.fold( + (fieldSettingsList) { + _fieldSettings.clear(); + _fieldSettings.addAll(fieldSettingsList); + return FlowyResult.success(null); + }, + (err) => FlowyResult.failure(err), + ); + }); + } + + Future> _loadSettings() async { + return SettingBackendService(viewId: viewId).getSetting().then( + (result) => result.fold( + (setting) { + _groupConfigurationByFieldId.clear(); + for (final configuration in setting.groupSettings.items) { + _groupConfigurationByFieldId[configuration.fieldId] = + configuration; + } + return FlowyResult.success(null); + }, + (err) => FlowyResult.failure(err), + ), + ); + } + + /// Attach corresponding `FieldInfo`s to the `FilterPB`s + List _filterListFromPBs(List filterPBs) { + return filterPBs.map(DatabaseFilter.fromPB).toList(); + } + + /// Attach corresponding `FieldInfo`s to the `SortPB`s + List _sortListFromPBs(List sortPBs) { + return sortPBs.map(DatabaseSort.fromPB).toList(); + } + + void addListener({ + OnReceiveFields? onReceiveFields, + OnReceiveUpdateFields? onFieldsChanged, + OnReceiveFilters? onFilters, + OnReceiveSorts? onSorts, + bool Function()? listenWhen, + }) { + if (onFieldsChanged != null) { + void callback(List updateFields) { + if (listenWhen != null && listenWhen() == false) { + return; + } + onFieldsChanged(updateFields); + } + + _updatedFieldCallbacks[onFieldsChanged] = callback; + } + + if (onReceiveFields != null) { + void callback() { + if (listenWhen != null && listenWhen() == false) { + return; + } + onReceiveFields(fieldInfos); + } + + _fieldCallbacks[onReceiveFields] = callback; + _fieldNotifier.addListener(callback); + } + + if (onFilters != null) { + void callback() { + if (listenWhen != null && listenWhen() == false) { + return; + } + onFilters(filters); + } + + _filterCallbacks[onFilters] = callback; + _filterNotifier?.addListener(callback); + } + + if (onSorts != null) { + void callback() { + if (listenWhen != null && listenWhen() == false) { + return; + } + onSorts(sorts); + } + + _sortCallbacks[onSorts] = callback; + _sortNotifier?.addListener(callback); + } + } + + void addSingleFieldListener( + String fieldId, { + required OnReceiveField onFieldChanged, + bool Function()? listenWhen, + }) { + void key(List fieldInfos) { + final fieldInfo = fieldInfos.firstWhereOrNull( + (fieldInfo) => fieldInfo.id == fieldId, + ); + if (fieldInfo != null) { + onFieldChanged(fieldInfo); + } + } + + void callback() { + if (listenWhen != null && listenWhen() == false) { + return; + } + key(fieldInfos); + } + + _fieldCallbacks[key] = callback; + _fieldNotifier.addListener(callback); + } + + void removeListener({ + OnReceiveFields? onFieldsListener, + OnReceiveSorts? onSortsListener, + OnReceiveFilters? onFiltersListener, + OnReceiveUpdateFields? onChangesetListener, + }) { + if (onFieldsListener != null) { + final callback = _fieldCallbacks.remove(onFieldsListener); + if (callback != null) { + _fieldNotifier.removeListener(callback); + } + } + if (onFiltersListener != null) { + final callback = _filterCallbacks.remove(onFiltersListener); + if (callback != null) { + _filterNotifier?.removeListener(callback); + } + } + + if (onSortsListener != null) { + final callback = _sortCallbacks.remove(onSortsListener); + if (callback != null) { + _sortNotifier?.removeListener(callback); + } + } + } + + void removeSingleFieldListener({ + required String fieldId, + required OnReceiveField onFieldChanged, + }) { + void key(List fieldInfos) { + final fieldInfo = fieldInfos.firstWhereOrNull( + (fieldInfo) => fieldInfo.id == fieldId, + ); + if (fieldInfo != null) { + onFieldChanged(fieldInfo); + } + } + + final callback = _fieldCallbacks.remove(key); + if (callback != null) { + _fieldNotifier.removeListener(callback); + } + } + + /// Stop listeners, dispose notifiers and clear listener callbacks + Future dispose() async { + if (_isDisposed) { + Log.warn('FieldController is already disposed'); + return; + } + _isDisposed = true; + await _fieldListener.stop(); + await _filtersListener.stop(); + await _settingListener.stop(); + await _sortsListener.stop(); + await _fieldSettingsListener.stop(); + + for (final callback in _fieldCallbacks.values) { + _fieldNotifier.removeListener(callback); + } + _fieldNotifier.dispose(); + + for (final callback in _filterCallbacks.values) { + _filterNotifier?.removeListener(callback); + } + _filterNotifier?.dispose(); + _filterNotifier = null; + + for (final callback in _sortCallbacks.values) { + _sortNotifier?.removeListener(callback); + } + _sortNotifier?.dispose(); + _sortNotifier = null; + } +} + +class RowCacheDependenciesImpl extends RowFieldsDelegate with RowLifeCycle { + RowCacheDependenciesImpl(FieldController cache) : _fieldController = cache; + + final FieldController _fieldController; + OnReceiveFields? _onFieldFn; + + @override + UnmodifiableListView get fieldInfos => + UnmodifiableListView(_fieldController.fieldInfos); + + @override + void onFieldsChanged(void Function(List) callback) { + if (_onFieldFn != null) { + _fieldController.removeListener(onFieldsListener: _onFieldFn!); + } + + _onFieldFn = (fieldInfos) => callback(fieldInfos); + _fieldController.addListener(onReceiveFields: _onFieldFn); + } + + @override + void onRowDisposed() { + if (_onFieldFn != null) { + _fieldController.removeListener(onFieldsListener: _onFieldFn!); + _onFieldFn = null; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_editor_bloc.dart new file mode 100644 index 0000000000000..1c056b146192e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_editor_bloc.dart @@ -0,0 +1,187 @@ +import 'dart:typed_data'; + +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'field_controller.dart'; +import 'field_info.dart'; + +part 'field_editor_bloc.freezed.dart'; + +class FieldEditorBloc extends Bloc { + FieldEditorBloc({ + required this.viewId, + required this.fieldInfo, + required this.fieldController, + this.onFieldInserted, + required this.isNew, + }) : _fieldService = FieldBackendService( + viewId: viewId, + fieldId: fieldInfo.id, + ), + fieldSettingsService = FieldSettingsBackendService(viewId: viewId), + super(FieldEditorState(field: fieldInfo)) { + _dispatch(); + _startListening(); + _init(); + } + + final String viewId; + final FieldInfo fieldInfo; + final bool isNew; + final FieldController fieldController; + final FieldBackendService _fieldService; + final FieldSettingsBackendService fieldSettingsService; + final void Function(String newFieldId)? onFieldInserted; + + late final OnReceiveField _listener; + + String get fieldId => fieldInfo.id; + + @override + Future close() { + fieldController.removeSingleFieldListener( + fieldId: fieldId, + onFieldChanged: _listener, + ); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didUpdateField: (fieldInfo) { + emit(state.copyWith(field: fieldInfo)); + }, + switchFieldType: (fieldType) async { + String? fieldName; + if (!state.wasRenameManually && isNew) { + fieldName = fieldType.i18n; + } + + await _fieldService.updateType( + fieldType: fieldType, + fieldName: fieldName, + ); + }, + renameField: (newName) async { + final result = await _fieldService.updateField(name: newName); + _logIfError(result); + emit(state.copyWith(wasRenameManually: true)); + }, + updateIcon: (icon) async { + final result = await _fieldService.updateField(icon: icon); + _logIfError(result); + }, + updateTypeOption: (typeOptionData) async { + final result = await FieldBackendService.updateFieldTypeOption( + viewId: viewId, + fieldId: fieldId, + typeOptionData: typeOptionData, + ); + _logIfError(result); + }, + insertLeft: () async { + final result = await _fieldService.createBefore(); + result.fold( + (newField) => onFieldInserted?.call(newField.id), + (err) => Log.error("Failed creating field $err"), + ); + }, + insertRight: () async { + final result = await _fieldService.createAfter(); + result.fold( + (newField) => onFieldInserted?.call(newField.id), + (err) => Log.error("Failed creating field $err"), + ); + }, + toggleFieldVisibility: () async { + final currentVisibility = + state.field.visibility ?? FieldVisibility.AlwaysShown; + final newVisibility = + currentVisibility == FieldVisibility.AlwaysHidden + ? FieldVisibility.AlwaysShown + : FieldVisibility.AlwaysHidden; + final result = await fieldSettingsService.updateFieldSettings( + fieldId: fieldId, + fieldVisibility: newVisibility, + ); + _logIfError(result); + }, + toggleWrapCellContent: () async { + final currentWrap = state.field.wrapCellContent ?? false; + final result = await fieldSettingsService.updateFieldSettings( + fieldId: state.field.id, + wrapCellContent: !currentWrap, + ); + _logIfError(result); + }, + ); + }, + ); + } + + void _startListening() { + _listener = (field) { + if (!isClosed) { + add(FieldEditorEvent.didUpdateField(field)); + } + }; + fieldController.addSingleFieldListener( + fieldId, + onFieldChanged: _listener, + ); + } + + void _init() async { + await Future.delayed(const Duration(milliseconds: 50)); + if (!isClosed) { + final field = fieldController.getField(fieldId); + if (field != null) { + add(FieldEditorEvent.didUpdateField(field)); + } + } + } + + void _logIfError(FlowyResult result) { + result.fold( + (l) => null, + (err) => Log.error(err), + ); + } +} + +@freezed +class FieldEditorEvent with _$FieldEditorEvent { + const factory FieldEditorEvent.didUpdateField(final FieldInfo fieldInfo) = + _DidUpdateField; + const factory FieldEditorEvent.switchFieldType(final FieldType fieldType) = + _SwitchFieldType; + const factory FieldEditorEvent.updateTypeOption( + final Uint8List typeOptionData, + ) = _UpdateTypeOption; + const factory FieldEditorEvent.renameField(final String name) = _RenameField; + const factory FieldEditorEvent.updateIcon(String icon) = _UpdateIcon; + const factory FieldEditorEvent.insertLeft() = _InsertLeft; + const factory FieldEditorEvent.insertRight() = _InsertRight; + const factory FieldEditorEvent.toggleFieldVisibility() = + _ToggleFieldVisiblity; + const factory FieldEditorEvent.toggleWrapCellContent() = + _ToggleWrapCellContent; +} + +@freezed +class FieldEditorState with _$FieldEditorState { + const factory FieldEditorState({ + required final FieldInfo field, + @Default(false) bool wasRenameManually, + }) = _FieldEditorState; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart new file mode 100644 index 0000000000000..46fc8659cafa4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart @@ -0,0 +1,41 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'field_info.freezed.dart'; + +@freezed +class FieldInfo with _$FieldInfo { + const FieldInfo._(); + + factory FieldInfo.initial(FieldPB field) => FieldInfo( + field: field, + fieldSettings: null, + hasFilter: false, + hasSort: false, + isGroupField: false, + ); + + const factory FieldInfo({ + required FieldPB field, + required FieldSettingsPB? fieldSettings, + required bool isGroupField, + required bool hasFilter, + required bool hasSort, + }) = _FieldInfo; + + String get id => field.id; + + FieldType get fieldType => field.fieldType; + + String get name => field.name; + + String get icon => field.icon; + + bool get isPrimary => field.isPrimary; + + double? get width => fieldSettings?.width.toDouble(); + + FieldVisibility? get visibility => fieldSettings?.visibility; + + bool? get wrapCellContent => fieldSettings?.wrapCellContent; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/filter_entities.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/filter_entities.dart new file mode 100644 index 0000000000000..46867e1f97d9b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/filter_entities.dart @@ -0,0 +1,748 @@ +import 'dart:typed_data'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/database/view/database_filter_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/select_option_loader.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy/util/int64_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:equatable/equatable.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/widgets.dart'; + +abstract class DatabaseFilter extends Equatable { + const DatabaseFilter({ + required this.filterId, + required this.fieldId, + required this.fieldType, + }); + + factory DatabaseFilter.fromPB(FilterPB filterPB) { + final FilterDataPB(:fieldId, :fieldType) = filterPB.data; + switch (fieldType) { + case FieldType.RichText: + case FieldType.URL: + final data = TextFilterPB.fromBuffer(filterPB.data.data); + return TextFilter( + filterId: filterPB.id, + fieldId: fieldId, + fieldType: fieldType, + condition: data.condition, + content: data.content, + ); + case FieldType.Number: + final data = NumberFilterPB.fromBuffer(filterPB.data.data); + return NumberFilter( + filterId: filterPB.id, + fieldId: fieldId, + fieldType: fieldType, + condition: data.condition, + content: data.content, + ); + case FieldType.Checkbox: + final data = CheckboxFilterPB.fromBuffer(filterPB.data.data); + return CheckboxFilter( + filterId: filterPB.id, + fieldId: fieldId, + fieldType: fieldType, + condition: data.condition, + ); + case FieldType.Checklist: + final data = ChecklistFilterPB.fromBuffer(filterPB.data.data); + return ChecklistFilter( + filterId: filterPB.id, + fieldId: fieldId, + fieldType: fieldType, + condition: data.condition, + ); + case FieldType.SingleSelect: + case FieldType.MultiSelect: + final data = SelectOptionFilterPB.fromBuffer(filterPB.data.data); + return SelectOptionFilter( + filterId: filterPB.id, + fieldId: fieldId, + fieldType: fieldType, + condition: data.condition, + optionIds: data.optionIds, + ); + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + case FieldType.DateTime: + final data = DateFilterPB.fromBuffer(filterPB.data.data); + return DateTimeFilter( + filterId: filterPB.id, + fieldId: fieldId, + fieldType: fieldType, + condition: data.condition, + timestamp: data.hasTimestamp() ? data.timestamp.toDateTime() : null, + start: data.hasStart() ? data.start.toDateTime() : null, + end: data.hasEnd() ? data.end.toDateTime() : null, + ); + default: + throw ArgumentError(); + } + } + + final String filterId; + final String fieldId; + final FieldType fieldType; + + String get conditionName; + + bool get canAttachContent; + + String getContentDescription(FieldInfo field); + + Widget getMobileDescription( + FieldInfo field, { + required VoidCallback onExpand, + required void Function(DatabaseFilter filter) onUpdate, + }) => + const SizedBox.shrink(); + + Uint8List writeToBuffer(); +} + +final class TextFilter extends DatabaseFilter { + TextFilter({ + required super.filterId, + required super.fieldId, + required super.fieldType, + required this.condition, + required String content, + }) { + this.content = canAttachContent ? content : ""; + } + + final TextFilterConditionPB condition; + late final String content; + + @override + String get conditionName => condition.filterName; + + @override + bool get canAttachContent => + condition != TextFilterConditionPB.TextIsEmpty && + condition != TextFilterConditionPB.TextIsNotEmpty; + + @override + String getContentDescription(FieldInfo field) { + final filterDesc = condition.choicechipPrefix; + + if (condition == TextFilterConditionPB.TextIsEmpty || + condition == TextFilterConditionPB.TextIsNotEmpty) { + return filterDesc; + } + + return content.isEmpty ? filterDesc : "$filterDesc $content"; + } + + @override + Widget getMobileDescription( + FieldInfo field, { + required VoidCallback onExpand, + required void Function(DatabaseFilter filter) onUpdate, + }) { + return FilterItemInnerTextField( + content: content, + enabled: canAttachContent, + onSubmitted: (content) { + final newFilter = copyWith(content: content); + onUpdate(newFilter); + }, + ); + } + + @override + Uint8List writeToBuffer() { + final filterPB = TextFilterPB()..condition = condition; + + if (condition != TextFilterConditionPB.TextIsEmpty && + condition != TextFilterConditionPB.TextIsNotEmpty) { + filterPB.content = content; + } + return filterPB.writeToBuffer(); + } + + TextFilter copyWith({ + TextFilterConditionPB? condition, + String? content, + }) { + return TextFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: fieldType, + condition: condition ?? this.condition, + content: content ?? this.content, + ); + } + + @override + List get props => [filterId, fieldId, condition, content]; +} + +final class NumberFilter extends DatabaseFilter { + NumberFilter({ + required super.filterId, + required super.fieldId, + required super.fieldType, + required this.condition, + required String content, + }) { + this.content = canAttachContent ? content : ""; + } + + final NumberFilterConditionPB condition; + late final String content; + + @override + String get conditionName => condition.filterName; + + @override + bool get canAttachContent => + condition != NumberFilterConditionPB.NumberIsEmpty && + condition != NumberFilterConditionPB.NumberIsNotEmpty; + + @override + String getContentDescription(FieldInfo field) { + if (condition == NumberFilterConditionPB.NumberIsEmpty || + condition == NumberFilterConditionPB.NumberIsNotEmpty) { + return condition.shortName; + } + + return "${condition.shortName} $content"; + } + + @override + Widget getMobileDescription( + FieldInfo field, { + required VoidCallback onExpand, + required void Function(DatabaseFilter filter) onUpdate, + }) { + return FilterItemInnerTextField( + content: content, + enabled: canAttachContent, + onSubmitted: (content) { + final newFilter = copyWith(content: content); + onUpdate(newFilter); + }, + ); + } + + @override + Uint8List writeToBuffer() { + final filterPB = NumberFilterPB()..condition = condition; + + if (condition != NumberFilterConditionPB.NumberIsEmpty && + condition != NumberFilterConditionPB.NumberIsNotEmpty) { + filterPB.content = content; + } + return filterPB.writeToBuffer(); + } + + NumberFilter copyWith({ + NumberFilterConditionPB? condition, + String? content, + }) { + return NumberFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: fieldType, + condition: condition ?? this.condition, + content: content ?? this.content, + ); + } + + @override + List get props => [filterId, fieldId, condition, content]; +} + +final class CheckboxFilter extends DatabaseFilter { + const CheckboxFilter({ + required super.filterId, + required super.fieldId, + required super.fieldType, + required this.condition, + }); + + final CheckboxFilterConditionPB condition; + + @override + String get conditionName => condition.filterName; + + @override + bool get canAttachContent => false; + + @override + String getContentDescription(FieldInfo field) => condition.filterName; + + @override + Uint8List writeToBuffer() { + return (CheckboxFilterPB()..condition = condition).writeToBuffer(); + } + + CheckboxFilter copyWith({ + CheckboxFilterConditionPB? condition, + }) { + return CheckboxFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: fieldType, + condition: condition ?? this.condition, + ); + } + + @override + List get props => [filterId, fieldId, condition]; +} + +final class ChecklistFilter extends DatabaseFilter { + const ChecklistFilter({ + required super.filterId, + required super.fieldId, + required super.fieldType, + required this.condition, + }); + + final ChecklistFilterConditionPB condition; + + @override + String get conditionName => condition.filterName; + + @override + bool get canAttachContent => false; + + @override + String getContentDescription(FieldInfo field) => condition.filterName; + + @override + Uint8List writeToBuffer() { + return (ChecklistFilterPB()..condition = condition).writeToBuffer(); + } + + ChecklistFilter copyWith({ + ChecklistFilterConditionPB? condition, + }) { + return ChecklistFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: fieldType, + condition: condition ?? this.condition, + ); + } + + @override + List get props => [filterId, fieldId, condition]; +} + +final class SelectOptionFilter extends DatabaseFilter { + SelectOptionFilter({ + required super.filterId, + required super.fieldId, + required super.fieldType, + required this.condition, + required List optionIds, + }) { + if (canAttachContent) { + this.optionIds.addAll(optionIds); + } + } + + final SelectOptionFilterConditionPB condition; + final List optionIds = []; + + @override + String get conditionName => condition.i18n; + + @override + bool get canAttachContent => + condition != SelectOptionFilterConditionPB.OptionIsEmpty && + condition != SelectOptionFilterConditionPB.OptionIsNotEmpty; + + @override + String getContentDescription(FieldInfo field) { + if (!canAttachContent || optionIds.isEmpty) { + return condition.i18n; + } + + final delegate = makeDelegate(field); + final options = delegate.getOptions(field); + + final optionNames = options + .where((option) => optionIds.contains(option.id)) + .map((option) => option.name) + .join(', '); + return "${condition.i18n} $optionNames"; + } + + @override + Widget getMobileDescription( + FieldInfo field, { + required VoidCallback onExpand, + required void Function(DatabaseFilter filter) onUpdate, + }) { + final delegate = makeDelegate(field); + final options = delegate + .getOptions(field) + .where((option) => optionIds.contains(option.id)) + .toList(); + + return FilterItemInnerButton( + onTap: onExpand, + child: ListView.separated( + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + separatorBuilder: (context, index) => const HSpace(8), + itemCount: options.length, + itemBuilder: (context, index) => SelectOptionTag( + option: options[index], + fontSize: 14, + borderRadius: BorderRadius.circular(9), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + ), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + ); + } + + @override + Uint8List writeToBuffer() { + final filterPB = SelectOptionFilterPB()..condition = condition; + + if (canAttachContent) { + filterPB.optionIds.addAll(optionIds); + } + + return filterPB.writeToBuffer(); + } + + SelectOptionFilter copyWith({ + SelectOptionFilterConditionPB? condition, + List? optionIds, + }) { + return SelectOptionFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: fieldType, + condition: condition ?? this.condition, + optionIds: optionIds ?? this.optionIds, + ); + } + + SelectOptionFilterDelegate makeDelegate(FieldInfo field) => + field.fieldType == FieldType.SingleSelect + ? const SingleSelectOptionFilterDelegateImpl() + : const MultiSelectOptionFilterDelegateImpl(); + + @override + List get props => [filterId, fieldId, condition, optionIds]; +} + +enum DateTimeFilterCondition { + on, + before, + after, + onOrBefore, + onOrAfter, + between, + isEmpty, + isNotEmpty; + + DateFilterConditionPB toPB(bool isStart) { + return isStart + ? switch (this) { + on => DateFilterConditionPB.DateStartsOn, + before => DateFilterConditionPB.DateStartsBefore, + after => DateFilterConditionPB.DateStartsAfter, + onOrBefore => DateFilterConditionPB.DateStartsOnOrBefore, + onOrAfter => DateFilterConditionPB.DateStartsOnOrAfter, + between => DateFilterConditionPB.DateStartsBetween, + isEmpty => DateFilterConditionPB.DateStartIsEmpty, + isNotEmpty => DateFilterConditionPB.DateStartIsNotEmpty, + } + : switch (this) { + on => DateFilterConditionPB.DateEndsOn, + before => DateFilterConditionPB.DateEndsBefore, + after => DateFilterConditionPB.DateEndsAfter, + onOrBefore => DateFilterConditionPB.DateEndsOnOrBefore, + onOrAfter => DateFilterConditionPB.DateEndsOnOrAfter, + between => DateFilterConditionPB.DateEndsBetween, + isEmpty => DateFilterConditionPB.DateEndIsEmpty, + isNotEmpty => DateFilterConditionPB.DateEndIsNotEmpty, + }; + } + + String get choiceChipPrefix { + return switch (this) { + on => "", + before => LocaleKeys.grid_dateFilter_choicechipPrefix_before.tr(), + after => LocaleKeys.grid_dateFilter_choicechipPrefix_after.tr(), + onOrBefore => LocaleKeys.grid_dateFilter_choicechipPrefix_onOrBefore.tr(), + onOrAfter => LocaleKeys.grid_dateFilter_choicechipPrefix_onOrAfter.tr(), + between => LocaleKeys.grid_dateFilter_choicechipPrefix_between.tr(), + isEmpty => LocaleKeys.grid_dateFilter_choicechipPrefix_isEmpty.tr(), + isNotEmpty => LocaleKeys.grid_dateFilter_choicechipPrefix_isNotEmpty.tr(), + }; + } + + String get filterName { + return switch (this) { + on => LocaleKeys.grid_dateFilter_is.tr(), + before => LocaleKeys.grid_dateFilter_before.tr(), + after => LocaleKeys.grid_dateFilter_after.tr(), + onOrBefore => LocaleKeys.grid_dateFilter_onOrBefore.tr(), + onOrAfter => LocaleKeys.grid_dateFilter_onOrAfter.tr(), + between => LocaleKeys.grid_dateFilter_between.tr(), + isEmpty => LocaleKeys.grid_dateFilter_empty.tr(), + isNotEmpty => LocaleKeys.grid_dateFilter_notEmpty.tr(), + }; + } + + static List availableConditionsForFieldType( + FieldType fieldType, + ) { + final result = [...values]; + if (fieldType == FieldType.CreatedTime || + fieldType == FieldType.LastEditedTime) { + result.remove(isEmpty); + result.remove(isNotEmpty); + } + + return result; + } +} + +final class DateTimeFilter extends DatabaseFilter { + DateTimeFilter({ + required super.filterId, + required super.fieldId, + required super.fieldType, + required this.condition, + DateTime? timestamp, + DateTime? start, + DateTime? end, + }) { + if (canAttachContent) { + if (condition == DateFilterConditionPB.DateStartsBetween || + condition == DateFilterConditionPB.DateEndsBetween) { + this.start = start; + this.end = end; + this.timestamp = null; + } else { + this.timestamp = timestamp; + this.start = null; + this.end = null; + } + } else { + this.timestamp = null; + this.start = null; + this.end = null; + } + } + + final DateFilterConditionPB condition; + late final DateTime? timestamp; + late final DateTime? start; + late final DateTime? end; + + @override + String get conditionName => condition.toCondition().filterName; + + @override + bool get canAttachContent => ![ + DateFilterConditionPB.DateStartIsEmpty, + DateFilterConditionPB.DateStartIsNotEmpty, + DateFilterConditionPB.DateEndIsEmpty, + DateFilterConditionPB.DateEndIsNotEmpty, + ].contains(condition); + + @override + String getContentDescription(FieldInfo field) { + return switch (condition) { + DateFilterConditionPB.DateStartIsEmpty || + DateFilterConditionPB.DateStartIsNotEmpty || + DateFilterConditionPB.DateEndIsEmpty || + DateFilterConditionPB.DateEndIsNotEmpty => + condition.toCondition().choiceChipPrefix, + DateFilterConditionPB.DateStartsOn || + DateFilterConditionPB.DateEndsOn => + timestamp?.defaultFormat ?? "", + DateFilterConditionPB.DateStartsBetween || + DateFilterConditionPB.DateEndsBetween => + "${condition.toCondition().choiceChipPrefix} ${start?.defaultFormat ?? ""} - ${end?.defaultFormat ?? ""}", + _ => + "${condition.toCondition().choiceChipPrefix} ${timestamp?.defaultFormat ?? ""}" + }; + } + + @override + Widget getMobileDescription( + FieldInfo field, { + required VoidCallback onExpand, + required void Function(DatabaseFilter filter) onUpdate, + }) { + String? text; + + if (condition.isRange) { + text = "${start?.defaultFormat ?? ""} - ${end?.defaultFormat ?? ""}"; + text = text == " - " ? null : text; + } else { + text = timestamp.defaultFormat; + } + return FilterItemInnerButton( + onTap: onExpand, + child: FlowyText( + text ?? "", + overflow: TextOverflow.ellipsis, + ), + ); + } + + @override + Uint8List writeToBuffer() { + final filterPB = DateFilterPB()..condition = condition; + + Int64 dateTimeToInt(DateTime dateTime) { + return Int64(dateTime.millisecondsSinceEpoch ~/ 1000); + } + + switch (condition) { + case DateFilterConditionPB.DateStartsOn: + case DateFilterConditionPB.DateStartsBefore: + case DateFilterConditionPB.DateStartsOnOrBefore: + case DateFilterConditionPB.DateStartsAfter: + case DateFilterConditionPB.DateStartsOnOrAfter: + case DateFilterConditionPB.DateEndsOn: + case DateFilterConditionPB.DateEndsBefore: + case DateFilterConditionPB.DateEndsOnOrBefore: + case DateFilterConditionPB.DateEndsAfter: + case DateFilterConditionPB.DateEndsOnOrAfter: + if (timestamp != null) { + filterPB.timestamp = dateTimeToInt(timestamp!); + } + break; + case DateFilterConditionPB.DateStartsBetween: + case DateFilterConditionPB.DateEndsBetween: + if (start != null) { + filterPB.start = dateTimeToInt(start!); + } + if (end != null) { + filterPB.end = dateTimeToInt(end!); + } + break; + default: + break; + } + + return filterPB.writeToBuffer(); + } + + DateTimeFilter copyWithCondition({ + required bool isStart, + required DateTimeFilterCondition condition, + }) { + return DateTimeFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: fieldType, + condition: condition.toPB(isStart), + start: start, + end: end, + timestamp: timestamp, + ); + } + + DateTimeFilter copyWithTimestamp({ + required DateTime timestamp, + }) { + return DateTimeFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: fieldType, + condition: condition, + start: start, + end: end, + timestamp: timestamp, + ); + } + + DateTimeFilter copyWithRange({ + required DateTime? start, + required DateTime? end, + }) { + return DateTimeFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: fieldType, + condition: condition, + start: start, + end: end, + timestamp: timestamp, + ); + } + + @override + List get props => + [filterId, fieldId, condition, timestamp, start, end]; +} + +final class TimeFilter extends DatabaseFilter { + const TimeFilter({ + required super.filterId, + required super.fieldId, + required super.fieldType, + required this.condition, + required this.content, + }); + + final NumberFilterConditionPB condition; + final String content; + + @override + String get conditionName => condition.filterName; + + @override + bool get canAttachContent => + condition != NumberFilterConditionPB.NumberIsEmpty && + condition != NumberFilterConditionPB.NumberIsNotEmpty; + + @override + String getContentDescription(FieldInfo field) { + if (condition == NumberFilterConditionPB.NumberIsEmpty || + condition == NumberFilterConditionPB.NumberIsNotEmpty) { + return condition.shortName; + } + + return "${condition.shortName} $content"; + } + + @override + Uint8List writeToBuffer() { + return (NumberFilterPB() + ..condition = condition + ..content = content) + .writeToBuffer(); + } + + TimeFilter copyWith({NumberFilterConditionPB? condition, String? content}) { + return TimeFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: fieldType, + condition: condition ?? this.condition, + content: content ?? this.content, + ); + } + + @override + List get props => [filterId, fieldId, condition, content]; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/sort_entities.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/sort_entities.dart new file mode 100644 index 0000000000000..a5aeaa3e28548 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/sort_entities.dart @@ -0,0 +1,22 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:equatable/equatable.dart'; + +final class DatabaseSort extends Equatable { + const DatabaseSort({ + required this.sortId, + required this.fieldId, + required this.condition, + }); + + DatabaseSort.fromPB(SortPB sort) + : sortId = sort.id, + fieldId = sort.fieldId, + condition = sort.condition; + + final String sortId; + final String fieldId; + final SortConditionPB condition; + + @override + List get props => [sortId, fieldId, condition]; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/edit_select_option_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/edit_select_option_bloc.dart new file mode 100644 index 0000000000000..59abf7d136c6c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/edit_select_option_bloc.dart @@ -0,0 +1,64 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'edit_select_option_bloc.freezed.dart'; + +class EditSelectOptionBloc + extends Bloc { + EditSelectOptionBloc({required SelectOptionPB option}) + : super(EditSelectOptionState.initial(option)) { + on( + (event, emit) async { + event.when( + updateName: (name) { + emit(state.copyWith(option: _updateName(name))); + }, + updateColor: (color) { + emit(state.copyWith(option: _updateColor(color))); + }, + delete: () { + emit(state.copyWith(deleted: true)); + }, + ); + }, + ); + } + + SelectOptionPB _updateColor(SelectOptionColorPB color) { + state.option.freeze(); + return state.option.rebuild((option) { + option.color = color; + }); + } + + SelectOptionPB _updateName(String name) { + state.option.freeze(); + return state.option.rebuild((option) { + option.name = name; + }); + } +} + +@freezed +class EditSelectOptionEvent with _$EditSelectOptionEvent { + const factory EditSelectOptionEvent.updateName(String name) = _UpdateName; + const factory EditSelectOptionEvent.updateColor(SelectOptionColorPB color) = + _UpdateColor; + const factory EditSelectOptionEvent.delete() = _Delete; +} + +@freezed +class EditSelectOptionState with _$EditSelectOptionState { + const factory EditSelectOptionState({ + required SelectOptionPB option, + required bool deleted, + }) = _EditSelectOptionState; + + factory EditSelectOptionState.initial(SelectOptionPB option) => + EditSelectOptionState( + option: option, + deleted: false, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/number_format_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/number_format_bloc.dart new file mode 100644 index 0000000000000..c4c31b880e6b5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/number_format_bloc.dart @@ -0,0 +1,209 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/number_entities.pbenum.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'number_format_bloc.freezed.dart'; + +class NumberFormatBloc extends Bloc { + NumberFormatBloc() : super(NumberFormatState.initial()) { + on( + (event, emit) async { + event.map( + setFilter: (_SetFilter value) { + final List formats = + List.from(NumberFormatPB.values); + if (value.filter.isNotEmpty) { + formats.retainWhere( + (element) => element + .title() + .toLowerCase() + .contains(value.filter.toLowerCase()), + ); + } + emit(state.copyWith(formats: formats, filter: value.filter)); + }, + ); + }, + ); + } +} + +@freezed +class NumberFormatEvent with _$NumberFormatEvent { + const factory NumberFormatEvent.setFilter(String filter) = _SetFilter; +} + +@freezed +class NumberFormatState with _$NumberFormatState { + const factory NumberFormatState({ + required List formats, + required String filter, + }) = _NumberFormatState; + + factory NumberFormatState.initial() { + return const NumberFormatState( + formats: NumberFormatPB.values, + filter: "", + ); + } +} + +extension NumberFormatExtension on NumberFormatPB { + String title() { + switch (this) { + case NumberFormatPB.ArgentinePeso: + return "Argentine peso"; + case NumberFormatPB.Baht: + return "Baht"; + case NumberFormatPB.CanadianDollar: + return "Canadian dollar"; + case NumberFormatPB.ChileanPeso: + return "Chilean peso"; + case NumberFormatPB.ColombianPeso: + return "Colombian peso"; + case NumberFormatPB.DanishKrone: + return "Danish crown"; + case NumberFormatPB.Dirham: + return "Dirham"; + case NumberFormatPB.EUR: + return "Euro"; + case NumberFormatPB.Forint: + return "Forint"; + case NumberFormatPB.Franc: + return "Franc"; + case NumberFormatPB.HongKongDollar: + return "Hone Kong dollar"; + case NumberFormatPB.Koruna: + return "Koruna"; + case NumberFormatPB.Krona: + return "Krona"; + case NumberFormatPB.Leu: + return "Leu"; + case NumberFormatPB.Lira: + return "Lira"; + case NumberFormatPB.MexicanPeso: + return "Mexican peso"; + case NumberFormatPB.NewTaiwanDollar: + return "New Taiwan dollar"; + case NumberFormatPB.NewZealandDollar: + return "New Zealand dollar"; + case NumberFormatPB.NorwegianKrone: + return "Norwegian krone"; + case NumberFormatPB.Num: + return "Number"; + case NumberFormatPB.Percent: + return "Percent"; + case NumberFormatPB.PhilippinePeso: + return "Philippine peso"; + case NumberFormatPB.Pound: + return "Pound"; + case NumberFormatPB.Rand: + return "Rand"; + case NumberFormatPB.Real: + return "Real"; + case NumberFormatPB.Ringgit: + return "Ringgit"; + case NumberFormatPB.Riyal: + return "Riyal"; + case NumberFormatPB.Ruble: + return "Ruble"; + case NumberFormatPB.Rupee: + return "Rupee"; + case NumberFormatPB.Rupiah: + return "Rupiah"; + case NumberFormatPB.Shekel: + return "Skekel"; + case NumberFormatPB.USD: + return "US dollar"; + case NumberFormatPB.UruguayanPeso: + return "Uruguayan peso"; + case NumberFormatPB.Won: + return "Won"; + case NumberFormatPB.Yen: + return "Yen"; + case NumberFormatPB.Yuan: + return "Yuan"; + default: + throw UnimplementedError; + } + } + + String iconSymbol([bool defaultPrefixInc = true]) { + switch (this) { + case NumberFormatPB.ArgentinePeso: + return "\$"; + case NumberFormatPB.Baht: + return "฿"; + case NumberFormatPB.CanadianDollar: + return "C\$"; + case NumberFormatPB.ChileanPeso: + return "\$"; + case NumberFormatPB.ColombianPeso: + return "\$"; + case NumberFormatPB.DanishKrone: + return "kr"; + case NumberFormatPB.Dirham: + return "د.إ"; + case NumberFormatPB.EUR: + return "€"; + case NumberFormatPB.Forint: + return "Ft"; + case NumberFormatPB.Franc: + return "Fr"; + case NumberFormatPB.HongKongDollar: + return "HK\$"; + case NumberFormatPB.Koruna: + return "Kč"; + case NumberFormatPB.Krona: + return "kr"; + case NumberFormatPB.Leu: + return "lei"; + case NumberFormatPB.Lira: + return "₺"; + case NumberFormatPB.MexicanPeso: + return "\$"; + case NumberFormatPB.NewTaiwanDollar: + return "NT\$"; + case NumberFormatPB.NewZealandDollar: + return "NZ\$"; + case NumberFormatPB.NorwegianKrone: + return "kr"; + case NumberFormatPB.Num: + return defaultPrefixInc ? "#" : ""; + case NumberFormatPB.Percent: + return "%"; + case NumberFormatPB.PhilippinePeso: + return "₱"; + case NumberFormatPB.Pound: + return "£"; + case NumberFormatPB.Rand: + return "R"; + case NumberFormatPB.Real: + return "R\$"; + case NumberFormatPB.Ringgit: + return "RM"; + case NumberFormatPB.Riyal: + return "ر.س"; + case NumberFormatPB.Ruble: + return "₽"; + case NumberFormatPB.Rupee: + return "₹"; + case NumberFormatPB.Rupiah: + return "Rp"; + case NumberFormatPB.Shekel: + return "₪"; + case NumberFormatPB.USD: + return "\$"; + case NumberFormatPB.UruguayanPeso: + return "\$U"; + case NumberFormatPB.Won: + return "₩"; + case NumberFormatPB.Yen: + return "JPY ¥"; + case NumberFormatPB.Yuan: + return "¥"; + default: + throw UnimplementedError; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart new file mode 100644 index 0000000000000..691b6b7227cab --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/relation_type_option_cubit.dart @@ -0,0 +1,62 @@ +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'relation_type_option_cubit.freezed.dart'; + +class RelationDatabaseListCubit extends Cubit { + RelationDatabaseListCubit() : super(RelationDatabaseListState.initial()) { + _loadDatabaseMetas(); + } + + void _loadDatabaseMetas() async { + final metaPBs = await DatabaseEventGetDatabases() + .send() + .fold>((s) => s.items, (f) => []); + final futures = metaPBs.map((meta) { + return ViewBackendService.getView(meta.inlineViewId).then( + (result) => result.fold( + (s) => DatabaseMeta( + databaseId: meta.databaseId, + inlineViewId: meta.inlineViewId, + databaseName: s.name, + ), + (f) => null, + ), + ); + }); + final databaseMetas = await Future.wait(futures); + emit( + RelationDatabaseListState( + databaseMetas: databaseMetas.nonNulls.toList(), + ), + ); + } +} + +@freezed +class DatabaseMeta with _$DatabaseMeta { + factory DatabaseMeta({ + /// id of the database + required String databaseId, + + /// id of the inline view + required String inlineViewId, + + /// name of the database, currently identical to the name of the inline view + required String databaseName, + }) = _DatabaseMeta; +} + +@freezed +class RelationDatabaseListState with _$RelationDatabaseListState { + factory RelationDatabaseListState({ + required List databaseMetas, + }) = _RelationDatabaseListState; + + factory RelationDatabaseListState.initial() => + RelationDatabaseListState(databaseMetas: []); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_option_type_option_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_option_type_option_bloc.dart new file mode 100644 index 0000000000000..72f49dd0846a4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_option_type_option_bloc.dart @@ -0,0 +1,92 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'select_type_option_actions.dart'; + +part 'select_option_type_option_bloc.freezed.dart'; + +class SelectOptionTypeOptionBloc + extends Bloc { + SelectOptionTypeOptionBloc({ + required List options, + required this.typeOptionAction, + }) : super(SelectOptionTypeOptionState.initial(options)) { + _dispatch(); + } + + final ISelectOptionAction typeOptionAction; + + void _dispatch() { + on( + (event, emit) async { + event.when( + createOption: (optionName) { + final List options = + typeOptionAction.insertOption(state.options, optionName); + emit(state.copyWith(options: options)); + }, + addingOption: () { + emit(state.copyWith(isEditingOption: true, newOptionName: null)); + }, + endAddingOption: () { + emit(state.copyWith(isEditingOption: false, newOptionName: null)); + }, + updateOption: (option) { + final options = + typeOptionAction.updateOption(state.options, option); + emit(state.copyWith(options: options)); + }, + deleteOption: (option) { + final options = + typeOptionAction.deleteOption(state.options, option); + emit(state.copyWith(options: options)); + }, + reorderOption: (fromOptionId, toOptionId) { + final options = typeOptionAction.reorderOption( + state.options, + fromOptionId, + toOptionId, + ); + emit(state.copyWith(options: options)); + }, + ); + }, + ); + } +} + +@freezed +class SelectOptionTypeOptionEvent with _$SelectOptionTypeOptionEvent { + const factory SelectOptionTypeOptionEvent.createOption(String optionName) = + _CreateOption; + const factory SelectOptionTypeOptionEvent.addingOption() = _AddingOption; + const factory SelectOptionTypeOptionEvent.endAddingOption() = + _EndAddingOption; + const factory SelectOptionTypeOptionEvent.updateOption( + SelectOptionPB option, + ) = _UpdateOption; + const factory SelectOptionTypeOptionEvent.deleteOption( + SelectOptionPB option, + ) = _DeleteOption; + const factory SelectOptionTypeOptionEvent.reorderOption( + String fromOptionId, + String toOptionId, + ) = _ReorderOption; +} + +@freezed +class SelectOptionTypeOptionState with _$SelectOptionTypeOptionState { + const factory SelectOptionTypeOptionState({ + required List options, + required bool isEditingOption, + required String? newOptionName, + }) = _SelectOptionTypeOptionState; + + factory SelectOptionTypeOptionState.initial(List options) => + SelectOptionTypeOptionState( + options: options, + isEditingOption: false, + newOptionName: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_type_option_actions.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_type_option_actions.dart new file mode 100644 index 0000000000000..235bdb60eb5fd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/select_type_option_actions.dart @@ -0,0 +1,133 @@ +import 'package:appflowy/plugins/database/domain/type_option_service.dart'; +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/builder.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:nanoid/nanoid.dart'; + +abstract class ISelectOptionAction { + ISelectOptionAction({ + required this.onTypeOptionUpdated, + required String viewId, + required String fieldId, + }) : service = TypeOptionBackendService(viewId: viewId, fieldId: fieldId); + + final TypeOptionBackendService service; + final TypeOptionDataCallback onTypeOptionUpdated; + + void updateTypeOption(List options) { + final newTypeOption = MultiSelectTypeOptionPB()..options.addAll(options); + onTypeOptionUpdated(newTypeOption.writeToBuffer()); + } + + List insertOption( + List options, + String optionName, + ) { + if (options.any((element) => element.name == optionName)) { + return options; + } + + final newOptions = List.from(options); + + final newSelectOption = SelectOptionPB() + ..id = nanoid(4) + ..color = newSelectOptionColor(options) + ..name = optionName; + + newOptions.insert(0, newSelectOption); + + updateTypeOption(newOptions); + return newOptions; + } + + List deleteOption( + List options, + SelectOptionPB deletedOption, + ) { + final newOptions = List.from(options); + final index = + newOptions.indexWhere((option) => option.id == deletedOption.id); + if (index != -1) { + newOptions.removeAt(index); + } + + updateTypeOption(newOptions); + return newOptions; + } + + List updateOption( + List options, + SelectOptionPB option, + ) { + final newOptions = List.from(options); + final index = newOptions.indexWhere((element) => element.id == option.id); + if (index != -1) { + newOptions[index] = option; + } + + updateTypeOption(newOptions); + return newOptions; + } + + List reorderOption( + List options, + String fromOptionId, + String toOptionId, + ) { + final newOptions = List.from(options); + final fromIndex = + newOptions.indexWhere((element) => element.id == fromOptionId); + final toIndex = + newOptions.indexWhere((element) => element.id == toOptionId); + + if (fromIndex != -1 && toIndex != -1) { + newOptions.insert(toIndex, newOptions.removeAt(fromIndex)); + } + + updateTypeOption(newOptions); + return newOptions; + } +} + +class MultiSelectAction extends ISelectOptionAction { + MultiSelectAction({ + required super.viewId, + required super.fieldId, + required super.onTypeOptionUpdated, + }); + + @override + void updateTypeOption(List options) { + final newTypeOption = MultiSelectTypeOptionPB()..options.addAll(options); + onTypeOptionUpdated(newTypeOption.writeToBuffer()); + } +} + +class SingleSelectAction extends ISelectOptionAction { + SingleSelectAction({ + required super.viewId, + required super.fieldId, + required super.onTypeOptionUpdated, + }); + + @override + void updateTypeOption(List options) { + final newTypeOption = SingleSelectTypeOptionPB()..options.addAll(options); + onTypeOptionUpdated(newTypeOption.writeToBuffer()); + } +} + +SelectOptionColorPB newSelectOptionColor(List options) { + final colorFrequency = List.filled(SelectOptionColorPB.values.length, 0); + + for (final option in options) { + colorFrequency[option.color.value]++; + } + + final minIndex = colorFrequency + .asMap() + .entries + .reduce((a, b) => a.value <= b.value ? a : b) + .key; + + return SelectOptionColorPB.valueOf(minIndex) ?? SelectOptionColorPB.Purple; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/translate_type_option_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/translate_type_option_bloc.dart new file mode 100644 index 0000000000000..43e990f6d497b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/translate_type_option_bloc.dart @@ -0,0 +1,82 @@ +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/translate_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'translate_type_option_bloc.freezed.dart'; + +class TranslateTypeOptionBloc + extends Bloc { + TranslateTypeOptionBloc({required TranslateTypeOptionPB option}) + : super(TranslateTypeOptionState.initial(option)) { + on( + (event, emit) async { + event.when( + selectLanguage: (languageType) { + emit( + state.copyWith( + option: _updateLanguage(languageType), + language: languageTypeToLanguage(languageType), + ), + ); + }, + ); + }, + ); + } + + TranslateTypeOptionPB _updateLanguage(TranslateLanguagePB languageType) { + state.option.freeze(); + return state.option.rebuild((option) { + option.language = languageType; + }); + } +} + +@freezed +class TranslateTypeOptionEvent with _$TranslateTypeOptionEvent { + const factory TranslateTypeOptionEvent.selectLanguage( + TranslateLanguagePB languageType, + ) = _SelectLanguage; +} + +@freezed +class TranslateTypeOptionState with _$TranslateTypeOptionState { + const factory TranslateTypeOptionState({ + required TranslateTypeOptionPB option, + required String language, + }) = _TranslateTypeOptionState; + + factory TranslateTypeOptionState.initial(TranslateTypeOptionPB option) => + TranslateTypeOptionState( + option: option, + language: languageTypeToLanguage(option.language), + ); +} + +String languageTypeToLanguage(TranslateLanguagePB langaugeType) { + switch (langaugeType) { + case TranslateLanguagePB.SimplifiedChinese: + return 'Simplified Chinese'; + case TranslateLanguagePB.TraditionalChinese: + return 'Traditional Chinese'; + case TranslateLanguagePB.English: + return 'English'; + case TranslateLanguagePB.French: + return 'French'; + case TranslateLanguagePB.German: + return 'German'; + case TranslateLanguagePB.Spanish: + return 'Spanish'; + case TranslateLanguagePB.Hindi: + return 'Hindi'; + case TranslateLanguagePB.Portuguese: + return 'Portuguese'; + case TranslateLanguagePB.StandardArabic: + return 'Standard Arabic'; + default: + Log.error('Unknown language type: $langaugeType'); + return 'English'; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_data_parser.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_data_parser.dart new file mode 100644 index 0000000000000..c76e6d095c9d4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/type_option/type_option_data_parser.dart @@ -0,0 +1,66 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +abstract class TypeOptionParser { + T fromBuffer(List buffer); +} + +class NumberTypeOptionDataParser extends TypeOptionParser { + @override + NumberTypeOptionPB fromBuffer(List buffer) { + return NumberTypeOptionPB.fromBuffer(buffer); + } +} + +class DateTypeOptionDataParser extends TypeOptionParser { + @override + DateTypeOptionPB fromBuffer(List buffer) { + return DateTypeOptionPB.fromBuffer(buffer); + } +} + +class TimestampTypeOptionDataParser + extends TypeOptionParser { + @override + TimestampTypeOptionPB fromBuffer(List buffer) { + return TimestampTypeOptionPB.fromBuffer(buffer); + } +} + +class SingleSelectTypeOptionDataParser + extends TypeOptionParser { + @override + SingleSelectTypeOptionPB fromBuffer(List buffer) { + return SingleSelectTypeOptionPB.fromBuffer(buffer); + } +} + +class MultiSelectTypeOptionDataParser + extends TypeOptionParser { + @override + MultiSelectTypeOptionPB fromBuffer(List buffer) { + return MultiSelectTypeOptionPB.fromBuffer(buffer); + } +} + +class RelationTypeOptionDataParser + extends TypeOptionParser { + @override + RelationTypeOptionPB fromBuffer(List buffer) { + return RelationTypeOptionPB.fromBuffer(buffer); + } +} + +class TranslateTypeOptionDataParser + extends TypeOptionParser { + @override + TranslateTypeOptionPB fromBuffer(List buffer) { + return TranslateTypeOptionPB.fromBuffer(buffer); + } +} + +class MediaTypeOptionDataParser extends TypeOptionParser { + @override + MediaTypeOptionPB fromBuffer(List buffer) { + return MediaTypeOptionPB.fromBuffer(buffer); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart new file mode 100644 index 0000000000000..a5dd0d9ca1fbd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/layout/layout_bloc.dart @@ -0,0 +1,53 @@ +import 'package:appflowy/plugins/database/domain/database_view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'layout_bloc.freezed.dart'; + +class DatabaseLayoutBloc + extends Bloc { + DatabaseLayoutBloc({ + required String viewId, + required DatabaseLayoutPB databaseLayout, + }) : super(DatabaseLayoutState.initial(viewId, databaseLayout)) { + on( + (event, emit) async { + event.when( + initial: () {}, + updateLayout: (DatabaseLayoutPB layout) { + DatabaseViewBackendService.updateLayout( + viewId: viewId, + layout: layout, + ); + emit(state.copyWith(databaseLayout: layout)); + }, + ); + }, + ); + } +} + +@freezed +class DatabaseLayoutEvent with _$DatabaseLayoutEvent { + const factory DatabaseLayoutEvent.initial() = _Initial; + const factory DatabaseLayoutEvent.updateLayout(DatabaseLayoutPB layout) = + _UpdateLayout; +} + +@freezed +class DatabaseLayoutState with _$DatabaseLayoutState { + const factory DatabaseLayoutState({ + required String viewId, + required DatabaseLayoutPB databaseLayout, + }) = _DatabaseLayoutState; + + factory DatabaseLayoutState.initial( + String viewId, + DatabaseLayoutPB databaseLayout, + ) => + DatabaseLayoutState( + viewId: viewId, + databaseLayout: databaseLayout, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart new file mode 100644 index 0000000000000..4f975cd1a6f9c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/related_row_detail_bloc.dart @@ -0,0 +1,135 @@ +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../database_controller.dart'; + +import 'row_controller.dart'; + +part 'related_row_detail_bloc.freezed.dart'; + +class RelatedRowDetailPageBloc + extends Bloc { + RelatedRowDetailPageBloc({ + required String databaseId, + required String initialRowId, + }) : super(const RelatedRowDetailPageState.loading()) { + _dispatch(); + _init(databaseId, initialRowId); + } + + UserProfilePB? _userProfile; + UserProfilePB? get userProfile => _userProfile; + + @override + Future close() { + state.whenOrNull( + ready: (databaseController, rowController) async { + await rowController.dispose(); + await databaseController.dispose(); + }, + ); + return super.close(); + } + + void _dispatch() { + on((event, emit) async { + await event.when( + didInitialize: (databaseController, rowController) async { + final response = await UserEventGetUserProfile().send(); + response.fold( + (userProfile) => _userProfile = userProfile, + (err) => Log.error(err), + ); + + await rowController.initialize(); + + await state.maybeWhen( + ready: (_, oldRowController) async { + await oldRowController.dispose(); + emit( + RelatedRowDetailPageState.ready( + databaseController: databaseController, + rowController: rowController, + ), + ); + }, + orElse: () { + emit( + RelatedRowDetailPageState.ready( + databaseController: databaseController, + rowController: rowController, + ), + ); + }, + ); + }, + ); + }); + } + + /// initialize bloc through the `database_id` and `row_id`. The process is as + /// follows: + /// 1. use the `database_id` to get the database meta, which contains the + /// `inline_view_id` + /// 2. use the `inline_view_id` to instantiate a `DatabaseController`. + /// 3. use the `row_id` with the DatabaseController` to create `RowController` + void _init(String databaseId, String initialRowId) async { + final databaseMeta = + await DatabaseEventGetDatabaseMeta(DatabaseIdPB(value: databaseId)) + .send() + .fold((s) => s, (f) => null); + if (databaseMeta == null) { + return; + } + final inlineView = + await ViewBackendService.getView(databaseMeta.inlineViewId) + .fold((viewPB) => viewPB, (f) => null); + if (inlineView == null) { + return; + } + final databaseController = DatabaseController(view: inlineView); + await databaseController.open().fold( + (s) => databaseController.setIsLoading(false), + (f) => null, + ); + final rowInfo = databaseController.rowCache.getRow(initialRowId); + if (rowInfo == null) { + return; + } + final rowController = RowController( + rowMeta: rowInfo.rowMeta, + viewId: inlineView.id, + rowCache: databaseController.rowCache, + ); + + add( + RelatedRowDetailPageEvent.didInitialize( + databaseController, + rowController, + ), + ); + } +} + +@freezed +class RelatedRowDetailPageEvent with _$RelatedRowDetailPageEvent { + const factory RelatedRowDetailPageEvent.didInitialize( + DatabaseController databaseController, + RowController rowController, + ) = _DidInitialize; +} + +@freezed +class RelatedRowDetailPageState with _$RelatedRowDetailPageState { + const factory RelatedRowDetailPageState.loading() = _LoadingState; + const factory RelatedRowDetailPageState.ready({ + required DatabaseController databaseController, + required RowController rowController, + }) = _ReadyState; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_banner_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_banner_bloc.dart new file mode 100644 index 0000000000000..7714b7727f930 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_banner_bloc.dart @@ -0,0 +1,166 @@ +import 'package:flutter/foundation.dart'; + +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/row_meta_listener.dart'; + +part 'row_banner_bloc.freezed.dart'; + +class RowBannerBloc extends Bloc { + RowBannerBloc({ + required this.viewId, + required this.fieldController, + required RowMetaPB rowMeta, + }) : _rowBackendSvc = RowBackendService(viewId: viewId), + _metaListener = RowMetaListener(rowMeta.id), + super(RowBannerState.initial(rowMeta)) { + _dispatch(); + } + + final String viewId; + final FieldController fieldController; + final RowBackendService _rowBackendSvc; + final RowMetaListener _metaListener; + + UserProfilePB? _userProfile; + UserProfilePB? get userProfile => _userProfile; + + bool get hasCover => state.rowMeta.cover.data.isNotEmpty; + + @override + Future close() async { + await _metaListener.stop(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) { + event.when( + initial: () async { + await _loadPrimaryField(); + _listenRowMetaChanged(); + final result = await UserEventGetUserProfile().send(); + result.fold( + (userProfile) => _userProfile = userProfile, + (error) => Log.error(error), + ); + }, + didReceiveRowMeta: (RowMetaPB rowMeta) { + emit(state.copyWith(rowMeta: rowMeta)); + }, + setCover: (RowCoverPB cover) => _updateMeta(cover: cover), + setIcon: (String iconURL) => _updateMeta(iconURL: iconURL), + removeCover: () => _removeCover(), + didReceiveFieldUpdate: (updatedField) { + emit( + state.copyWith( + primaryField: updatedField, + loadingState: const LoadingState.finish(), + ), + ); + }, + ); + }, + ); + } + + Future _loadPrimaryField() async { + final fieldOrError = + await FieldBackendService.getPrimaryField(viewId: viewId); + fieldOrError.fold( + (primaryField) { + if (!isClosed) { + fieldController.addSingleFieldListener( + primaryField.id, + onFieldChanged: (updatedField) { + if (!isClosed) { + add(RowBannerEvent.didReceiveFieldUpdate(updatedField.field)); + } + }, + ); + add(RowBannerEvent.didReceiveFieldUpdate(primaryField)); + } + }, + (r) => Log.error(r), + ); + } + + /// Listen the changes of the row meta and then update the banner + void _listenRowMetaChanged() { + _metaListener.start( + callback: (rowMeta) { + if (!isClosed) { + add(RowBannerEvent.didReceiveRowMeta(rowMeta)); + } + }, + ); + } + + /// Update the meta of the row and the view + Future _updateMeta({String? iconURL, RowCoverPB? cover}) async { + final result = await _rowBackendSvc.updateMeta( + iconURL: iconURL, + cover: cover, + rowId: state.rowMeta.id, + ); + result.fold((l) => null, (err) => Log.error(err)); + } + + Future _removeCover() async { + final result = await _rowBackendSvc.removeCover(state.rowMeta.id); + result.fold((l) => null, (err) => Log.error(err)); + } +} + +@freezed +class RowBannerEvent with _$RowBannerEvent { + const factory RowBannerEvent.initial() = _Initial; + const factory RowBannerEvent.didReceiveRowMeta(RowMetaPB rowMeta) = + _DidReceiveRowMeta; + const factory RowBannerEvent.didReceiveFieldUpdate(FieldPB field) = + _DidReceiveFieldUpdate; + const factory RowBannerEvent.setIcon(String iconURL) = _SetIcon; + const factory RowBannerEvent.setCover(RowCoverPB cover) = _SetCover; + const factory RowBannerEvent.removeCover() = _RemoveCover; +} + +@freezed +class RowBannerState extends Equatable with _$RowBannerState { + const RowBannerState._(); + + const factory RowBannerState({ + required FieldPB? primaryField, + required RowMetaPB rowMeta, + required LoadingState loadingState, + }) = _RowBannerState; + + factory RowBannerState.initial(RowMetaPB rowMetaPB) => RowBannerState( + primaryField: null, + rowMeta: rowMetaPB, + loadingState: const LoadingState.loading(), + ); + + @override + List get props => [ + rowMeta.cover.data, + rowMeta.icon, + primaryField, + loadingState, + ]; +} + +@freezed +class LoadingState with _$LoadingState { + const factory LoadingState.loading() = _Loading; + const factory LoadingState.finish() = _Finish; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart new file mode 100644 index 0000000000000..be5ba29dfc76a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_cache.dart @@ -0,0 +1,416 @@ +import 'dart:collection'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../cell/cell_cache.dart'; +import '../cell/cell_controller.dart'; + +import 'row_list.dart'; +import 'row_service.dart'; + +part 'row_cache.freezed.dart'; + +typedef RowUpdateCallback = void Function(); + +/// A delegate that provides the fields of the row. +abstract class RowFieldsDelegate { + UnmodifiableListView get fieldInfos; + void onFieldsChanged(void Function(List) callback); +} + +abstract mixin class RowLifeCycle { + void onRowDisposed(); +} + +/// Read https://docs.appflowy.io/docs/documentation/software-contributions/architecture/frontend/frontend/grid for more information. + +class RowCache { + RowCache({ + required this.viewId, + required RowFieldsDelegate fieldsDelegate, + required RowLifeCycle rowLifeCycle, + }) : _cellMemCache = CellMemCache(), + _changedNotifier = RowChangesetNotifier(), + _rowLifeCycle = rowLifeCycle, + _fieldDelegate = fieldsDelegate { + // Listen to field changes. If a field is deleted, we can safely remove the + // cells corresponding to that field from our cache. + fieldsDelegate.onFieldsChanged((fieldInfos) { + for (final fieldInfo in fieldInfos) { + _cellMemCache.removeCellWithFieldId(fieldInfo.id); + } + + _changedNotifier?.receive(const ChangedReason.fieldDidChange()); + }); + } + + final String viewId; + final RowList _rowList = RowList(); + final CellMemCache _cellMemCache; + final RowLifeCycle _rowLifeCycle; + final RowFieldsDelegate _fieldDelegate; + RowChangesetNotifier? _changedNotifier; + bool _isInitialRows = false; + final List _pendingVisibilityChanges = []; + + /// Returns a unmodifiable list of RowInfo + UnmodifiableListView get rowInfos { + final visibleRows = [..._rowList.rows]; + return UnmodifiableListView(visibleRows); + } + + /// Returns a unmodifiable map of RowInfo + UnmodifiableMapView get rowByRowId { + return UnmodifiableMapView(_rowList.rowInfoByRowId); + } + + CellMemCache get cellCache => _cellMemCache; + ChangedReason get changeReason => + _changedNotifier?.reason ?? const InitialListState(); + + RowInfo? getRow(RowId rowId) { + return _rowList.get(rowId); + } + + void setInitialRows(List rows) { + for (final row in rows) { + final rowInfo = buildGridRow(row); + _rowList.add(rowInfo); + } + _isInitialRows = true; + _changedNotifier?.receive(const ChangedReason.setInitialRows()); + + for (final changeset in _pendingVisibilityChanges) { + applyRowsVisibility(changeset); + } + _pendingVisibilityChanges.clear(); + } + + void setRowMeta(RowMetaPB rowMeta) { + final rowInfo = _rowList.get(rowMeta.id); + if (rowInfo != null) { + rowInfo.updateRowMeta(rowMeta); + } + + _changedNotifier?.receive(const ChangedReason.didFetchRow()); + } + + void dispose() { + _rowList.dispose(); + _rowLifeCycle.onRowDisposed(); + _changedNotifier?.dispose(); + _changedNotifier = null; + _cellMemCache.dispose(); + } + + void applyRowsChanged(RowsChangePB changeset) { + _deleteRows(changeset.deletedRows); + _insertRows(changeset.insertedRows); + _updateRows(changeset.updatedRows); + } + + void applyRowsVisibility(RowsVisibilityChangePB changeset) { + if (_isInitialRows) { + _hideRows(changeset.invisibleRows); + _showRows(changeset.visibleRows); + _changedNotifier?.receive( + ChangedReason.updateRowsVisibility(changeset), + ); + } else { + _pendingVisibilityChanges.add(changeset); + } + } + + void reorderAllRows(List rowIds) { + _rowList.reorderWithRowIds(rowIds); + _changedNotifier?.receive(const ChangedReason.reorderRows()); + } + + void reorderSingleRow(ReorderSingleRowPB reorderRow) { + final rowInfo = _rowList.get(reorderRow.rowId); + if (rowInfo != null) { + _rowList.moveRow( + reorderRow.rowId, + reorderRow.oldIndex, + reorderRow.newIndex, + ); + _changedNotifier?.receive( + ChangedReason.reorderSingleRow( + reorderRow, + rowInfo, + ), + ); + } + } + + void _deleteRows(List deletedRowIds) { + for (final rowId in deletedRowIds) { + final deletedRow = _rowList.remove(rowId); + if (deletedRow != null) { + _changedNotifier?.receive(ChangedReason.delete(deletedRow)); + } + } + } + + void _insertRows(List insertRows) { + final InsertedIndexs insertedIndices = []; + for (final insertedRow in insertRows) { + if (insertedRow.hasIndex()) { + final index = _rowList.insert( + insertedRow.index, + buildGridRow(insertedRow.rowMeta), + ); + if (index != null) { + insertedIndices.add(index); + } + } + } + _changedNotifier?.receive(ChangedReason.insert(insertedIndices)); + } + + void _updateRows(List updatedRows) { + if (updatedRows.isEmpty) return; + final List updatedList = []; + for (final updatedRow in updatedRows) { + for (final fieldId in updatedRow.fieldIds) { + final key = CellContext( + fieldId: fieldId, + rowId: updatedRow.rowId, + ); + _cellMemCache.remove(key); + } + if (updatedRow.hasRowMeta()) { + updatedList.add(updatedRow.rowMeta); + } + } + + final updatedIndexs = _rowList.updateRows( + rowMetas: updatedList, + builder: (rowId) => buildGridRow(rowId), + ); + + if (updatedIndexs.isNotEmpty) { + _changedNotifier?.receive(ChangedReason.update(updatedIndexs)); + } + } + + void _hideRows(List invisibleRows) { + for (final rowId in invisibleRows) { + final deletedRow = _rowList.remove(rowId); + if (deletedRow != null) { + _changedNotifier?.receive(ChangedReason.delete(deletedRow)); + } + } + } + + void _showRows(List visibleRows) { + for (final insertedRow in visibleRows) { + final insertedIndex = + _rowList.insert(insertedRow.index, buildGridRow(insertedRow.rowMeta)); + if (insertedIndex != null) { + _changedNotifier?.receive(ChangedReason.insert([insertedIndex])); + } + } + } + + void onRowsChanged(void Function(ChangedReason) onRowChanged) { + _changedNotifier?.addListener(() { + if (_changedNotifier != null) { + onRowChanged(_changedNotifier!.reason); + } + }); + } + + RowUpdateCallback addListener({ + required RowId rowId, + void Function(List, ChangedReason)? onRowChanged, + }) { + void listenerHandler() async { + if (onRowChanged != null) { + final rowInfo = _rowList.get(rowId); + if (rowInfo != null) { + final cellDataMap = _makeCells(rowInfo.rowMeta); + if (_changedNotifier != null) { + onRowChanged(cellDataMap, _changedNotifier!.reason); + } + } + } + } + + _changedNotifier?.addListener(listenerHandler); + return listenerHandler; + } + + void removeRowListener(VoidCallback callback) { + _changedNotifier?.removeListener(callback); + } + + List loadCells(RowMetaPB rowMeta) { + final rowInfo = _rowList.get(rowMeta.id); + if (rowInfo == null) { + _loadRow(rowMeta.id); + } + final cells = _makeCells(rowMeta); + return cells; + } + + Future _loadRow(RowId rowId) async { + final result = await RowBackendService.getRow(viewId: viewId, rowId: rowId); + result.fold( + (rowMetaPB) { + final rowInfo = _rowList.get(rowMetaPB.id); + final rowIndex = _rowList.indexOfRow(rowMetaPB.id); + if (rowInfo != null && rowIndex != null) { + rowInfo.rowMetaNotifier.value = rowMetaPB; + + final UpdatedIndexMap updatedIndexs = UpdatedIndexMap(); + updatedIndexs[rowMetaPB.id] = UpdatedIndex( + index: rowIndex, + rowId: rowMetaPB.id, + ); + + _changedNotifier?.receive(ChangedReason.update(updatedIndexs)); + } + }, + (err) => Log.error(err), + ); + } + + List _makeCells(RowMetaPB rowMeta) { + return _fieldDelegate.fieldInfos + .map( + (fieldInfo) => CellContext( + rowId: rowMeta.id, + fieldId: fieldInfo.id, + ), + ) + .toList(); + } + + RowInfo buildGridRow(RowMetaPB rowMetaPB) { + return RowInfo( + fields: _fieldDelegate.fieldInfos, + rowMeta: rowMetaPB, + ); + } +} + +class RowChangesetNotifier extends ChangeNotifier { + RowChangesetNotifier(); + + ChangedReason reason = const InitialListState(); + + void receive(ChangedReason newReason) { + reason = newReason; + reason.map( + insert: (_) => notifyListeners(), + delete: (_) => notifyListeners(), + update: (_) => notifyListeners(), + fieldDidChange: (_) => notifyListeners(), + initial: (_) {}, + reorderRows: (_) => notifyListeners(), + reorderSingleRow: (_) => notifyListeners(), + updateRowsVisibility: (_) => notifyListeners(), + setInitialRows: (_) => notifyListeners(), + didFetchRow: (_) => notifyListeners(), + ); + } +} + +class RowInfo extends Equatable { + RowInfo({ + required this.fields, + required RowMetaPB rowMeta, + }) : rowMetaNotifier = ValueNotifier(rowMeta), + rowIconNotifier = ValueNotifier(rowMeta.icon), + rowDocumentNotifier = ValueNotifier( + !(rowMeta.hasIsDocumentEmpty() ? rowMeta.isDocumentEmpty : true), + ); + + final UnmodifiableListView fields; + final ValueNotifier rowMetaNotifier; + final ValueNotifier rowIconNotifier; + final ValueNotifier rowDocumentNotifier; + + String get rowId => rowMetaNotifier.value.id; + + RowMetaPB get rowMeta => rowMetaNotifier.value; + + /// Updates the RowMeta and automatically updates the related notifiers. + void updateRowMeta(RowMetaPB newMeta) { + rowMetaNotifier.value = newMeta; + rowIconNotifier.value = newMeta.icon; + rowDocumentNotifier.value = !newMeta.isDocumentEmpty; + } + + /// Dispose of the notifiers when they are no longer needed. + void dispose() { + rowMetaNotifier.dispose(); + rowIconNotifier.dispose(); + rowDocumentNotifier.dispose(); + } + + @override + List get props => [rowMeta]; +} + +typedef InsertedIndexs = List; +typedef DeletedIndexs = List; +// key: id of the row +// value: UpdatedIndex +typedef UpdatedIndexMap = LinkedHashMap; + +@freezed +class ChangedReason with _$ChangedReason { + const factory ChangedReason.insert(InsertedIndexs items) = _Insert; + const factory ChangedReason.delete(DeletedIndex item) = _Delete; + const factory ChangedReason.update(UpdatedIndexMap indexs) = _Update; + const factory ChangedReason.fieldDidChange() = _FieldDidChange; + const factory ChangedReason.initial() = InitialListState; + const factory ChangedReason.didFetchRow() = _DidFetchRow; + const factory ChangedReason.reorderRows() = _ReorderRows; + const factory ChangedReason.reorderSingleRow( + ReorderSingleRowPB reorderRow, + RowInfo rowInfo, + ) = _ReorderSingleRow; + const factory ChangedReason.updateRowsVisibility( + RowsVisibilityChangePB changeset, + ) = _UpdateRowsVisibility; + const factory ChangedReason.setInitialRows() = _SetInitialRows; +} + +class InsertedIndex { + InsertedIndex({ + required this.index, + required this.rowId, + }); + + final int index; + final RowId rowId; +} + +class DeletedIndex { + DeletedIndex({ + required this.index, + required this.rowInfo, + }); + + final int index; + final RowInfo rowInfo; +} + +class UpdatedIndex { + UpdatedIndex({ + required this.index, + required this.rowId, + }); + + final int index; + final RowId rowId; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart new file mode 100644 index 0000000000000..0d2bf4985d641 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_controller.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/domain/row_listener.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter/material.dart'; + +import '../cell/cell_cache.dart'; +import '../cell/cell_controller.dart'; +import 'row_cache.dart'; + +typedef OnRowChanged = void Function(List, ChangedReason); + +class RowController { + RowController({ + required RowMetaPB rowMeta, + required this.viewId, + required RowCache rowCache, + this.groupId, + }) : _rowMeta = rowMeta, + _rowCache = rowCache, + _rowBackendSvc = RowBackendService(viewId: viewId), + _rowListener = RowListener(rowMeta.id); + + RowMetaPB _rowMeta; + final String? groupId; + VoidCallback? _onRowMetaChanged; + final String viewId; + final List _onRowChangedListeners = []; + final RowCache _rowCache; + final RowListener _rowListener; + final RowBackendService _rowBackendSvc; + bool _isDisposed = false; + + String get rowId => _rowMeta.id; + RowMetaPB get rowMeta => _rowMeta; + CellMemCache get cellCache => _rowCache.cellCache; + + List loadCells() => _rowCache.loadCells(rowMeta); + + /// This method must be called to initialize the row controller; otherwise, the row will not sync between devices. + /// When creating a row controller, calling [initialize] immediately may not be necessary. + /// Only call [initialize] when the row becomes visible. This approach helps reduce unnecessary sync operations. + Future initialize() async { + await _rowBackendSvc.initRow(rowMeta.id); + unawaited( + _rowBackendSvc.getRowMeta(rowId).then( + (result) { + if (_isDisposed) { + return; + } + + result.fold( + (rowMeta) { + _rowMeta = rowMeta; + _rowCache.setRowMeta(rowMeta); + _onRowMetaChanged?.call(); + }, + (error) => debugPrint(error.toString()), + ); + }, + ), + ); + + _rowListener.start( + onRowFetched: (DidFetchRowPB row) { + _rowCache.setRowMeta(row.meta); + }, + onMetaChanged: (newRowMeta) { + if (_isDisposed) { + return; + } + _rowMeta = newRowMeta; + _rowCache.setRowMeta(newRowMeta); + _onRowMetaChanged?.call(); + }, + ); + } + + void addListener({ + OnRowChanged? onRowChanged, + VoidCallback? onMetaChanged, + }) { + final fn = _rowCache.addListener( + rowId: rowMeta.id, + onRowChanged: (context, reasons) { + if (_isDisposed) { + return; + } + onRowChanged?.call(context, reasons); + }, + ); + + // Add the listener to the list so that we can remove it later. + _onRowChangedListeners.add(fn); + _onRowMetaChanged = onMetaChanged; + } + + Future dispose() async { + _isDisposed = true; + await _rowListener.stop(); + for (final fn in _onRowChangedListeners) { + _rowCache.removeRowListener(fn); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_list.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_list.dart new file mode 100644 index 0000000000000..00b074544870b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_list.dart @@ -0,0 +1,176 @@ +import 'dart:collection'; +import 'dart:math'; + +import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; + +import 'row_cache.dart'; +import 'row_service.dart'; + +class RowList { + /// Use List to reverse the order of the row. + List _rowInfos = []; + + List get rows => List.from(_rowInfos); + + /// Use Map for faster access the raw row data. + final HashMap rowInfoByRowId = HashMap(); + + RowInfo? get(RowId rowId) { + return rowInfoByRowId[rowId]; + } + + int? indexOfRow(RowId rowId) { + final rowInfo = rowInfoByRowId[rowId]; + if (rowInfo != null) { + return _rowInfos.indexOf(rowInfo); + } + return null; + } + + void add(RowInfo rowInfo) { + final rowId = rowInfo.rowId; + if (contains(rowId)) { + final index = _rowInfos.indexWhere((element) => element.rowId == rowId); + _rowInfos.removeAt(index); + _rowInfos.insert(index, rowInfo); + } else { + _rowInfos.add(rowInfo); + } + rowInfoByRowId[rowId] = rowInfo; + } + + InsertedIndex? insert(int index, RowInfo rowInfo) { + final rowId = rowInfo.rowId; + var insertedIndex = index; + if (_rowInfos.length <= insertedIndex) { + insertedIndex = _rowInfos.length; + } + + final oldRowInfo = get(rowId); + if (oldRowInfo != null) { + _rowInfos.insert(insertedIndex, rowInfo); + _rowInfos.remove(oldRowInfo); + rowInfoByRowId[rowId] = rowInfo; + return null; + } else { + _rowInfos.insert(insertedIndex, rowInfo); + rowInfoByRowId[rowId] = rowInfo; + return InsertedIndex(index: insertedIndex, rowId: rowId); + } + } + + DeletedIndex? remove(RowId rowId) { + final rowInfo = rowInfoByRowId[rowId]; + if (rowInfo != null) { + final index = _rowInfos.indexOf(rowInfo); + if (index != -1) { + rowInfoByRowId.remove(rowInfo.rowId); + _rowInfos.remove(rowInfo); + } + return DeletedIndex(index: index, rowInfo: rowInfo); + } else { + return null; + } + } + + InsertedIndexs insertRows( + List insertedRows, + RowInfo Function(RowMetaPB) builder, + ) { + final InsertedIndexs insertIndexs = []; + for (final insertRow in insertedRows) { + final isContains = contains(insertRow.rowMeta.id); + + var index = insertRow.index; + if (_rowInfos.length < index) { + index = _rowInfos.length; + } + insert(index, builder(insertRow.rowMeta)); + + if (!isContains) { + insertIndexs.add( + InsertedIndex( + index: index, + rowId: insertRow.rowMeta.id, + ), + ); + } + } + return insertIndexs; + } + + DeletedIndexs removeRows(List rowIds) { + final List newRows = []; + final DeletedIndexs deletedIndex = []; + final Map deletedRowByRowId = { + for (final rowId in rowIds) rowId: rowId, + }; + + _rowInfos.asMap().forEach((index, RowInfo rowInfo) { + if (deletedRowByRowId[rowInfo.rowId] == null) { + newRows.add(rowInfo); + } else { + rowInfoByRowId.remove(rowInfo.rowId); + deletedIndex.add(DeletedIndex(index: index, rowInfo: rowInfo)); + } + }); + _rowInfos = newRows; + return deletedIndex; + } + + UpdatedIndexMap updateRows({ + required List rowMetas, + required RowInfo Function(RowMetaPB) builder, + }) { + final UpdatedIndexMap updatedIndexs = UpdatedIndexMap(); + for (final rowMeta in rowMetas) { + final index = _rowInfos.indexWhere( + (rowInfo) => rowInfo.rowId == rowMeta.id, + ); + if (index != -1) { + rowInfoByRowId[rowMeta.id]?.updateRowMeta(rowMeta); + } else { + final insertIndex = max(index, _rowInfos.length); + final rowInfo = builder(rowMeta); + insert(insertIndex, rowInfo); + updatedIndexs[rowMeta.id] = UpdatedIndex( + index: insertIndex, + rowId: rowMeta.id, + ); + } + } + return updatedIndexs; + } + + void reorderWithRowIds(List rowIds) { + _rowInfos.clear(); + + for (final rowId in rowIds) { + final rowInfo = rowInfoByRowId[rowId]; + if (rowInfo != null) { + _rowInfos.add(rowInfo); + } + } + } + + void moveRow(RowId rowId, int oldIndex, int newIndex) { + final index = _rowInfos.indexWhere( + (rowInfo) => rowInfo.rowId == rowId, + ); + if (index != -1) { + final rowInfo = remove(rowId)!.rowInfo; + insert(newIndex, rowInfo); + } + } + + bool contains(RowId rowId) { + return rowInfoByRowId[rowId] != null; + } + + void dispose() { + for (final rowInfo in _rowInfos) { + rowInfo.dispose(); + } + _rowInfos.clear(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart new file mode 100644 index 0000000000000..151a32d9619f0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/row/row_service.dart @@ -0,0 +1,161 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +import '../field/field_info.dart'; + +typedef RowId = String; + +class RowBackendService { + RowBackendService({required this.viewId}); + + final String viewId; + + static Future> createRow({ + required String viewId, + String? groupId, + void Function(RowDataBuilder builder)? withCells, + OrderObjectPositionTypePB? position, + String? targetRowId, + }) { + final payload = CreateRowPayloadPB( + viewId: viewId, + groupId: groupId, + rowPosition: OrderObjectPositionPB( + position: position, + objectId: targetRowId, + ), + ); + + if (withCells != null) { + final rowBuilder = RowDataBuilder(); + withCells(rowBuilder); + payload.data.addAll(rowBuilder.build()); + } + + return DatabaseEventCreateRow(payload).send(); + } + + Future> initRow(RowId rowId) async { + final payload = DatabaseViewRowIdPB() + ..viewId = viewId + ..rowId = rowId; + + return DatabaseEventInitRow(payload).send(); + } + + Future> createRowBefore(RowId rowId) { + return createRow( + viewId: viewId, + position: OrderObjectPositionTypePB.Before, + targetRowId: rowId, + ); + } + + Future> createRowAfter(RowId rowId) { + return createRow( + viewId: viewId, + position: OrderObjectPositionTypePB.After, + targetRowId: rowId, + ); + } + + static Future> getRow({ + required String viewId, + required String rowId, + }) { + final payload = DatabaseViewRowIdPB() + ..viewId = viewId + ..rowId = rowId; + + return DatabaseEventGetRowMeta(payload).send(); + } + + Future> getRowMeta(RowId rowId) { + final payload = DatabaseViewRowIdPB.create() + ..viewId = viewId + ..rowId = rowId; + + return DatabaseEventGetRowMeta(payload).send(); + } + + Future> updateMeta({ + required String rowId, + String? iconURL, + RowCoverPB? cover, + bool? isDocumentEmpty, + }) { + final payload = UpdateRowMetaChangesetPB.create() + ..viewId = viewId + ..id = rowId; + + if (iconURL != null) { + payload.iconUrl = iconURL; + } + if (cover != null) { + payload.cover = cover; + } + + if (isDocumentEmpty != null) { + payload.isDocumentEmpty = isDocumentEmpty; + } + + return DatabaseEventUpdateRowMeta(payload).send(); + } + + Future> removeCover(String rowId) async { + final payload = RemoveCoverPayloadPB.create() + ..viewId = viewId + ..rowId = rowId; + + return DatabaseEventRemoveCover(payload).send(); + } + + static Future> deleteRows( + String viewId, + List rowIds, + ) { + final payload = RepeatedRowIdPB.create() + ..viewId = viewId + ..rowIds.addAll(rowIds); + + return DatabaseEventDeleteRows(payload).send(); + } + + static Future> duplicateRow( + String viewId, + RowId rowId, + ) { + final payload = DatabaseViewRowIdPB( + viewId: viewId, + rowId: rowId, + ); + + return DatabaseEventDuplicateRow(payload).send(); + } +} + +class RowDataBuilder { + final _cellDataByFieldId = {}; + + void insertText(FieldInfo fieldInfo, String text) { + assert(fieldInfo.fieldType == FieldType.RichText); + _cellDataByFieldId[fieldInfo.field.id] = text; + } + + void insertNumber(FieldInfo fieldInfo, int num) { + assert(fieldInfo.fieldType == FieldType.Number); + _cellDataByFieldId[fieldInfo.field.id] = num.toString(); + } + + void insertDate(FieldInfo fieldInfo, DateTime date) { + assert(fieldInfo.fieldType == FieldType.DateTime); + final timestamp = date.millisecondsSinceEpoch ~/ 1000; + _cellDataByFieldId[fieldInfo.field.id] = timestamp.toString(); + } + + Map build() { + return _cellDataByFieldId; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/setting/group_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/setting/group_bloc.dart new file mode 100644 index 0000000000000..c62e30b742cbd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/setting/group_bloc.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/domain/group_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/board_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'group_bloc.freezed.dart'; + +class DatabaseGroupBloc extends Bloc { + DatabaseGroupBloc({ + required String viewId, + required DatabaseController databaseController, + }) : _databaseController = databaseController, + _groupBackendSvc = GroupBackendService(viewId), + super( + DatabaseGroupState.initial( + viewId, + databaseController.fieldController.fieldInfos, + databaseController.databaseLayoutSetting!.board, + databaseController.fieldController.groupSettings, + ), + ) { + _dispatch(); + } + + final DatabaseController _databaseController; + final GroupBackendService _groupBackendSvc; + Function(List)? _onFieldsFn; + DatabaseLayoutSettingCallbacks? _layoutSettingCallbacks; + + @override + Future close() async { + if (_onFieldsFn != null) { + _databaseController.fieldController + .removeListener(onFieldsListener: _onFieldsFn!); + _onFieldsFn = null; + } + _layoutSettingCallbacks = null; + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () async => _startListening(), + didReceiveFieldUpdate: (fieldInfos) { + emit( + state.copyWith( + fieldInfos: fieldInfos, + groupSettings: + _databaseController.fieldController.groupSettings, + ), + ); + }, + setGroupByField: ( + String fieldId, + FieldType fieldType, [ + List? settingContent, + ]) async { + final result = await _groupBackendSvc.groupByField( + fieldId: fieldId, + settingContent: settingContent ?? [], + ); + result.fold((l) => null, (err) => Log.error(err)); + }, + didUpdateLayoutSettings: (layoutSettings) { + emit(state.copyWith(layoutSettings: layoutSettings)); + }, + ); + }, + ); + } + + void _startListening() { + _onFieldsFn = (fieldInfos) => + add(DatabaseGroupEvent.didReceiveFieldUpdate(fieldInfos)); + _databaseController.fieldController.addListener( + onReceiveFields: _onFieldsFn, + listenWhen: () => !isClosed, + ); + + _layoutSettingCallbacks = DatabaseLayoutSettingCallbacks( + onLayoutSettingsChanged: (layoutSettings) { + if (isClosed || !layoutSettings.hasBoard()) { + return; + } + add( + DatabaseGroupEvent.didUpdateLayoutSettings(layoutSettings.board), + ); + }, + ); + _databaseController.addListener( + onLayoutSettingsChanged: _layoutSettingCallbacks, + ); + } +} + +@freezed +class DatabaseGroupEvent with _$DatabaseGroupEvent { + const factory DatabaseGroupEvent.initial() = _Initial; + const factory DatabaseGroupEvent.setGroupByField( + String fieldId, + FieldType fieldType, [ + @Default([]) List settingContent, + ]) = _DatabaseGroupEvent; + const factory DatabaseGroupEvent.didReceiveFieldUpdate( + List fields, + ) = _DidReceiveFieldUpdate; + const factory DatabaseGroupEvent.didUpdateLayoutSettings( + BoardLayoutSettingPB layoutSettings, + ) = _DidUpdateLayoutSettings; +} + +@freezed +class DatabaseGroupState with _$DatabaseGroupState { + const factory DatabaseGroupState({ + required String viewId, + required List fieldInfos, + required BoardLayoutSettingPB layoutSettings, + required List groupSettings, + }) = _DatabaseGroupState; + + factory DatabaseGroupState.initial( + String viewId, + List fieldInfos, + BoardLayoutSettingPB layoutSettings, + List groupSettings, + ) => + DatabaseGroupState( + viewId: viewId, + fieldInfos: fieldInfos, + layoutSettings: layoutSettings, + groupSettings: groupSettings, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/setting/property_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/setting/property_bloc.dart new file mode 100644 index 0000000000000..46414791f26c0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/setting/property_bloc.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'property_bloc.freezed.dart'; + +class DatabasePropertyBloc + extends Bloc { + DatabasePropertyBloc({ + required String viewId, + required FieldController fieldController, + }) : _fieldController = fieldController, + super( + DatabasePropertyState.initial( + viewId, + fieldController.fieldInfos, + ), + ) { + _dispatch(); + } + + final FieldController _fieldController; + Function(List)? _onFieldsFn; + + @override + Future close() async { + if (_onFieldsFn != null) { + _fieldController.removeListener(onFieldsListener: _onFieldsFn!); + _onFieldsFn = null; + } + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () { + _startListening(); + }, + setFieldVisibility: (fieldId, visibility) async { + final fieldSettingsSvc = + FieldSettingsBackendService(viewId: state.viewId); + + final result = await fieldSettingsSvc.updateFieldSettings( + fieldId: fieldId, + fieldVisibility: visibility, + ); + + result.fold((l) => null, (err) => Log.error(err)); + }, + didReceiveFieldUpdate: (fields) { + emit(state.copyWith(fieldContexts: fields)); + }, + moveField: (fromIndex, toIndex) async { + if (fromIndex < toIndex) { + toIndex--; + } + final fromId = state.fieldContexts[fromIndex].field.id; + final toId = state.fieldContexts[toIndex].field.id; + + final fieldContexts = List.from(state.fieldContexts); + fieldContexts.insert(toIndex, fieldContexts.removeAt(fromIndex)); + emit(state.copyWith(fieldContexts: fieldContexts)); + + final result = await FieldBackendService.moveField( + viewId: state.viewId, + fromFieldId: fromId, + toFieldId: toId, + ); + + result.fold((l) => null, (r) => Log.error(r)); + }, + ); + }, + ); + } + + void _startListening() { + _onFieldsFn = + (fields) => add(DatabasePropertyEvent.didReceiveFieldUpdate(fields)); + _fieldController.addListener( + onReceiveFields: _onFieldsFn, + listenWhen: () => !isClosed, + ); + } +} + +@freezed +class DatabasePropertyEvent with _$DatabasePropertyEvent { + const factory DatabasePropertyEvent.initial() = _Initial; + const factory DatabasePropertyEvent.setFieldVisibility( + String fieldId, + FieldVisibility visibility, + ) = _SetFieldVisibility; + const factory DatabasePropertyEvent.didReceiveFieldUpdate( + List fields, + ) = _DidReceiveFieldUpdate; + const factory DatabasePropertyEvent.moveField(int fromIndex, int toIndex) = + _MoveField; +} + +@freezed +class DatabasePropertyState with _$DatabasePropertyState { + const factory DatabasePropertyState({ + required String viewId, + required List fieldContexts, + }) = _GridPropertyState; + + factory DatabasePropertyState.initial( + String viewId, + List fieldContexts, + ) => + DatabasePropertyState( + viewId: viewId, + fieldContexts: fieldContexts, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_listener.dart new file mode 100644 index 0000000000000..34dfba53d4864 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_listener.dart @@ -0,0 +1,53 @@ +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +typedef UpdateSettingNotifiedValue + = FlowyResult; + +class DatabaseSettingListener { + DatabaseSettingListener({required this.viewId}); + + final String viewId; + + DatabaseNotificationListener? _listener; + PublishNotifier? _updateSettingNotifier = + PublishNotifier(); + + void start({ + required void Function(UpdateSettingNotifiedValue) onSettingUpdated, + }) { + _updateSettingNotifier?.addPublishListener(onSettingUpdated); + _listener = + DatabaseNotificationListener(objectId: viewId, handler: _handler); + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateSettings: + result.fold( + (payload) => _updateSettingNotifier?.value = FlowyResult.success( + DatabaseViewSettingPB.fromBuffer(payload), + ), + (error) => _updateSettingNotifier?.value = FlowyResult.failure(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _updateSettingNotifier?.dispose(); + _updateSettingNotifier = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_service.dart b/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_service.dart new file mode 100644 index 0000000000000..d34fab1aea8de --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/setting/setting_service.dart @@ -0,0 +1,16 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class SettingBackendService { + const SettingBackendService({required this.viewId}); + + final String viewId; + + Future> getSetting() { + final payload = DatabaseViewIdPB.create()..value = viewId; + return DatabaseEventGetDatabaseSetting(payload).send(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/share_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/share_bloc.dart new file mode 100644 index 0000000000000..1beaaedbfb263 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/share_bloc.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:appflowy/workspace/application/settings/share/export_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'share_bloc.freezed.dart'; + +class DatabaseShareBloc extends Bloc { + DatabaseShareBloc({ + required this.view, + }) : super(const DatabaseShareState.initial()) { + on(_onShareCSV); + } + + final ViewPB view; + + Future _onShareCSV( + ShareCSV event, + Emitter emit, + ) async { + emit(const DatabaseShareState.loading()); + + final result = await BackendExportService.exportDatabaseAsCSV(view.id); + result.fold( + (l) => _saveCSVToPath(l.data, event.path), + (r) => Log.error(r), + ); + + emit( + DatabaseShareState.finish( + result.fold( + (l) { + _saveCSVToPath(l.data, event.path); + return FlowyResult.success(null); + }, + (r) => FlowyResult.failure(r), + ), + ), + ); + } + + ExportDataPB _saveCSVToPath(String markdown, String path) { + File(path).writeAsStringSync(markdown); + return ExportDataPB() + ..data = markdown + ..exportType = ExportType.Markdown; + } +} + +@freezed +class DatabaseShareEvent with _$DatabaseShareEvent { + const factory DatabaseShareEvent.shareCSV(String path) = ShareCSV; +} + +@freezed +class DatabaseShareState with _$DatabaseShareState { + const factory DatabaseShareState.initial() = _Initial; + const factory DatabaseShareState.loading() = _Loading; + const factory DatabaseShareState.finish( + FlowyResult successOrFail, + ) = _Finish; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart new file mode 100644 index 0000000000000..ae0b9173c712c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_bloc.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/sync/database_sync_state_listener.dart'; +import 'package:appflowy/plugins/database/domain/database_view_service.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'database_sync_bloc.freezed.dart'; + +class DatabaseSyncBloc extends Bloc { + DatabaseSyncBloc({ + required this.view, + }) : super(DatabaseSyncBlocState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + final userProfile = await getIt().getUser().then( + (value) => value.fold((s) => s, (f) => null), + ); + final databaseId = await DatabaseViewBackendService(viewId: view.id) + .getDatabaseId() + .then((value) => value.fold((s) => s, (f) => null)); + emit( + state.copyWith( + shouldShowIndicator: userProfile?.authenticator == + AuthenticatorPB.AppFlowyCloud && + databaseId != null, + ), + ); + if (databaseId != null) { + _syncStateListener = + DatabaseSyncStateListener(databaseId: databaseId) + ..start( + didReceiveSyncState: (syncState) { + Log.info( + 'database sync state changed, from ${state.syncState} to $syncState', + ); + add(DatabaseSyncEvent.syncStateChanged(syncState)); + }, + ); + } + + final isNetworkConnected = await _connectivity + .checkConnectivity() + .then((value) => value != ConnectivityResult.none); + emit(state.copyWith(isNetworkConnected: isNetworkConnected)); + + connectivityStream = + _connectivity.onConnectivityChanged.listen((result) { + add(DatabaseSyncEvent.networkStateChanged(result)); + }); + }, + syncStateChanged: (syncState) { + emit(state.copyWith(syncState: syncState.value)); + }, + networkStateChanged: (result) { + emit( + state.copyWith( + isNetworkConnected: result != ConnectivityResult.none, + ), + ); + }, + ); + }, + ); + } + + final ViewPB view; + final _connectivity = Connectivity(); + + StreamSubscription? connectivityStream; + DatabaseSyncStateListener? _syncStateListener; + + @override + Future close() async { + await connectivityStream?.cancel(); + await _syncStateListener?.stop(); + return super.close(); + } +} + +@freezed +class DatabaseSyncEvent with _$DatabaseSyncEvent { + const factory DatabaseSyncEvent.initial() = Initial; + const factory DatabaseSyncEvent.syncStateChanged( + DatabaseSyncStatePB syncState, + ) = syncStateChanged; + const factory DatabaseSyncEvent.networkStateChanged( + ConnectivityResult result, + ) = NetworkStateChanged; +} + +@freezed +class DatabaseSyncBlocState with _$DatabaseSyncBlocState { + const factory DatabaseSyncBlocState({ + required DatabaseSyncState syncState, + @Default(true) bool isNetworkConnected, + @Default(false) bool shouldShowIndicator, + }) = _DatabaseSyncState; + + factory DatabaseSyncBlocState.initial() => const DatabaseSyncBlocState( + syncState: DatabaseSyncState.Syncing, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_state_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_state_listener.dart new file mode 100644 index 0000000000000..67914e3007d56 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/sync/database_sync_state_listener.dart @@ -0,0 +1,63 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +typedef DatabaseSyncStateCallback = void Function( + DatabaseSyncStatePB syncState, +); + +class DatabaseSyncStateListener { + DatabaseSyncStateListener({ + // NOTE: NOT the view id. + required this.databaseId, + }); + + final String databaseId; + StreamSubscription? _subscription; + DatabaseNotificationParser? _parser; + + DatabaseSyncStateCallback? didReceiveSyncState; + + void start({ + DatabaseSyncStateCallback? didReceiveSyncState, + }) { + this.didReceiveSyncState = didReceiveSyncState; + + _parser = DatabaseNotificationParser( + id: databaseId, + callback: _callback, + ); + _subscription = RustStreamReceiver.listen( + (observable) => _parser?.parse(observable), + ); + } + + void _callback( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateDatabaseSyncUpdate: + result.map( + (r) { + final value = DatabaseSyncStatePB.fromBuffer(r); + didReceiveSyncState?.call(value); + }, + ); + break; + default: + break; + } + } + + Future stop() async { + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart new file mode 100644 index 0000000000000..bb892f4b479d3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/tab_bar_bloc.dart @@ -0,0 +1,298 @@ +import 'package:appflowy/plugins/database/domain/database_view_service.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'database_controller.dart'; + +part 'tab_bar_bloc.freezed.dart'; + +class DatabaseTabBarBloc + extends Bloc { + DatabaseTabBarBloc({required ViewPB view}) + : super(DatabaseTabBarState.initial(view)) { + on( + (event, emit) async { + await event.when( + initial: () { + _listenInlineViewChanged(); + _loadChildView(); + }, + didLoadChildViews: (List childViews) { + emit( + state.copyWith( + tabBars: [ + ...state.tabBars, + ...childViews.map( + (newChildView) => DatabaseTabBar(view: newChildView), + ), + ], + tabBarControllerByViewId: _extendsTabBarController(childViews), + ), + ); + }, + selectView: (String viewId) { + final index = + state.tabBars.indexWhere((element) => element.viewId == viewId); + if (index != -1) { + emit( + state.copyWith(selectedIndex: index), + ); + } + }, + createView: (layout, name) { + _createLinkedView(layout.layoutType, name ?? layout.layoutName); + }, + deleteView: (String viewId) async { + final result = await ViewBackendService.deleteView(viewId: viewId); + result.fold( + (l) {}, + (r) => Log.error(r), + ); + }, + renameView: (String viewId, String newName) { + ViewBackendService.updateView(viewId: viewId, name: newName); + }, + didUpdateChildViews: (updatePB) async { + if (updatePB.createChildViews.isNotEmpty) { + final allTabBars = [ + ...state.tabBars, + ...updatePB.createChildViews + .map((e) => DatabaseTabBar(view: e)), + ]; + emit( + state.copyWith( + tabBars: allTabBars, + selectedIndex: state.tabBars.length, + tabBarControllerByViewId: + _extendsTabBarController(updatePB.createChildViews), + ), + ); + } + + if (updatePB.deleteChildViews.isNotEmpty) { + final allTabBars = [...state.tabBars]; + final tabBarControllerByViewId = { + ...state.tabBarControllerByViewId, + }; + var newSelectedIndex = state.selectedIndex; + for (final viewId in updatePB.deleteChildViews) { + final index = allTabBars.indexWhere( + (element) => element.viewId == viewId, + ); + if (index != -1) { + final tabBar = allTabBars.removeAt(index); + // Dispose the controller when the tab is removed. + final controller = + tabBarControllerByViewId.remove(tabBar.viewId); + await controller?.dispose(); + } + + if (index == state.selectedIndex) { + if (index > 0 && allTabBars.isNotEmpty) { + newSelectedIndex = index - 1; + } + } + } + emit( + state.copyWith( + tabBars: allTabBars, + selectedIndex: newSelectedIndex, + tabBarControllerByViewId: tabBarControllerByViewId, + ), + ); + } + }, + viewDidUpdate: (ViewPB updatedView) { + final index = state.tabBars.indexWhere( + (element) => element.viewId == updatedView.id, + ); + if (index != -1) { + final allTabBars = [...state.tabBars]; + final updatedTabBar = DatabaseTabBar(view: updatedView); + allTabBars[index] = updatedTabBar; + emit(state.copyWith(tabBars: allTabBars)); + } + }, + ); + }, + ); + } + + @override + Future close() async { + for (final tabBar in state.tabBars) { + await state.tabBarControllerByViewId[tabBar.viewId]?.dispose(); + tabBar.dispose(); + } + return super.close(); + } + + void _listenInlineViewChanged() { + final controller = state.tabBarControllerByViewId[state.parentView.id]; + controller?.onViewUpdated = (newView) { + add(DatabaseTabBarEvent.viewDidUpdate(newView)); + }; + + // Only listen the child view changes when the parent view is inline. + controller?.onViewChildViewChanged = (update) { + add(DatabaseTabBarEvent.didUpdateChildViews(update)); + }; + } + + /// Create tab bar controllers for the new views and return the updated map. + Map _extendsTabBarController( + List newViews, + ) { + final tabBarControllerByViewId = {...state.tabBarControllerByViewId}; + for (final view in newViews) { + final controller = DatabaseTabBarController(view: view); + controller.onViewUpdated = (newView) { + add(DatabaseTabBarEvent.viewDidUpdate(newView)); + }; + + tabBarControllerByViewId[view.id] = controller; + } + return tabBarControllerByViewId; + } + + Future _createLinkedView(ViewLayoutPB layoutType, String name) async { + final viewId = state.parentView.id; + final databaseIdOrError = + await DatabaseViewBackendService(viewId: viewId).getDatabaseId(); + databaseIdOrError.fold( + (databaseId) async { + final linkedViewOrError = + await ViewBackendService.createDatabaseLinkedView( + parentViewId: viewId, + databaseId: databaseId, + layoutType: layoutType, + name: name, + ); + + linkedViewOrError.fold( + (linkedView) {}, + (err) => Log.error(err), + ); + }, + (r) => Log.error(r), + ); + } + + void _loadChildView() async { + final viewsOrFail = + await ViewBackendService.getChildViews(viewId: state.parentView.id); + + viewsOrFail.fold( + (views) { + if (!isClosed) { + add(DatabaseTabBarEvent.didLoadChildViews(views)); + } + }, + (err) => Log.error(err), + ); + } +} + +@freezed +class DatabaseTabBarEvent with _$DatabaseTabBarEvent { + const factory DatabaseTabBarEvent.initial() = _Initial; + const factory DatabaseTabBarEvent.didLoadChildViews( + List childViews, + ) = _DidLoadChildViews; + const factory DatabaseTabBarEvent.selectView(String viewId) = _DidSelectView; + const factory DatabaseTabBarEvent.createView( + DatabaseLayoutPB layout, + String? name, + ) = _CreateView; + const factory DatabaseTabBarEvent.renameView(String viewId, String newName) = + _RenameView; + const factory DatabaseTabBarEvent.deleteView(String viewId) = _DeleteView; + const factory DatabaseTabBarEvent.didUpdateChildViews( + ChildViewUpdatePB updatePB, + ) = _DidUpdateChildViews; + const factory DatabaseTabBarEvent.viewDidUpdate(ViewPB view) = _ViewDidUpdate; +} + +@freezed +class DatabaseTabBarState with _$DatabaseTabBarState { + const factory DatabaseTabBarState({ + required ViewPB parentView, + required int selectedIndex, + required List tabBars, + required Map tabBarControllerByViewId, + }) = _DatabaseTabBarState; + + factory DatabaseTabBarState.initial(ViewPB view) { + final tabBar = DatabaseTabBar(view: view); + return DatabaseTabBarState( + parentView: view, + selectedIndex: 0, + tabBars: [tabBar], + tabBarControllerByViewId: { + view.id: DatabaseTabBarController( + view: view, + ), + }, + ); + } +} + +class DatabaseTabBar extends Equatable { + DatabaseTabBar({ + required this.view, + }) : _builder = UniversalPlatform.isMobile + ? view.mobileTabBarItem() + : view.tabBarItem(); + + final ViewPB view; + final DatabaseTabBarItemBuilder _builder; + + String get viewId => view.id; + DatabaseTabBarItemBuilder get builder => _builder; + ViewLayoutPB get layout => view.layout; + + @override + List get props => [view.hashCode]; + + void dispose() { + _builder.dispose(); + } +} + +typedef OnViewUpdated = void Function(ViewPB newView); +typedef OnViewChildViewChanged = void Function( + ChildViewUpdatePB childViewUpdate, +); + +class DatabaseTabBarController { + DatabaseTabBarController({required this.view}) + : controller = DatabaseController(view: view), + viewListener = ViewListener(viewId: view.id) { + viewListener.start( + onViewChildViewsUpdated: (update) => onViewChildViewChanged?.call(update), + onViewUpdated: (newView) { + view = newView; + onViewUpdated?.call(newView); + }, + ); + } + + ViewPB view; + final DatabaseController controller; + final ViewListener viewListener; + OnViewUpdated? onViewUpdated; + OnViewChildViewChanged? onViewChildViewChanged; + + Future dispose() async { + await Future.wait([viewListener.stop(), controller.dispose()]); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart new file mode 100644 index 0000000000000..754b2d1c239d0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_cache.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy_backend/log.dart'; + +import '../defines.dart'; +import '../field/field_controller.dart'; +import '../row/row_cache.dart'; + +import 'view_listener.dart'; + +class DatabaseViewCallbacks { + const DatabaseViewCallbacks({ + this.onNumOfRowsChanged, + this.onRowsCreated, + this.onRowsUpdated, + this.onRowsDeleted, + }); + + /// Will get called when number of rows were changed that includes + /// update/delete/insert rows. The [onNumOfRowsChanged] will return all + /// the rows of the current database + final OnNumOfRowsChanged? onNumOfRowsChanged; + + // Will get called when creating new rows + final OnRowsCreated? onRowsCreated; + + /// Will get called when rows were updated + final OnRowsUpdated? onRowsUpdated; + + /// Will get called when number of rows were deleted + final OnRowsDeleted? onRowsDeleted; +} + +/// Read https://docs.appflowy.io/docs/documentation/software-contributions/architecture/frontend/frontend/grid for more information +class DatabaseViewCache { + DatabaseViewCache({ + required this.viewId, + required FieldController fieldController, + }) : _databaseViewListener = DatabaseViewListener(viewId: viewId) { + final depsImpl = RowCacheDependenciesImpl(fieldController); + _rowCache = RowCache( + viewId: viewId, + fieldsDelegate: depsImpl, + rowLifeCycle: depsImpl, + ); + + _databaseViewListener.start( + onRowsChanged: (result) => result.fold( + (changeset) { + // Update the cache + _rowCache.applyRowsChanged(changeset); + + if (changeset.deletedRows.isNotEmpty) { + for (final callback in _callbacks) { + callback.onRowsDeleted?.call(changeset.deletedRows); + } + } + + if (changeset.updatedRows.isNotEmpty) { + for (final callback in _callbacks) { + callback.onRowsUpdated?.call( + changeset.updatedRows.map((e) => e.rowId).toList(), + _rowCache.changeReason, + ); + } + } + + if (changeset.insertedRows.isNotEmpty) { + for (final callback in _callbacks) { + callback.onRowsCreated?.call(changeset.insertedRows); + } + } + }, + (err) => Log.error(err), + ), + onRowsVisibilityChanged: (result) => result.fold( + (changeset) => _rowCache.applyRowsVisibility(changeset), + (err) => Log.error(err), + ), + onReorderAllRows: (result) => result.fold( + (rowIds) => _rowCache.reorderAllRows(rowIds), + (err) => Log.error(err), + ), + onReorderSingleRow: (result) => result.fold( + (reorderRow) => _rowCache.reorderSingleRow(reorderRow), + (err) => Log.error(err), + ), + ); + + _rowCache.onRowsChanged( + (reason) { + for (final callback in _callbacks) { + callback.onNumOfRowsChanged + ?.call(rowInfos, _rowCache.rowByRowId, reason); + } + }, + ); + } + + final String viewId; + late RowCache _rowCache; + final DatabaseViewListener _databaseViewListener; + final List _callbacks = []; + + UnmodifiableListView get rowInfos => _rowCache.rowInfos; + RowCache get rowCache => _rowCache; + + RowInfo? getRow(RowId rowId) => _rowCache.getRow(rowId); + + Future dispose() async { + await _databaseViewListener.stop(); + _rowCache.dispose(); + _callbacks.clear(); + } + + void addListener(DatabaseViewCallbacks callbacks) { + _callbacks.add(callbacks); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart new file mode 100644 index 0000000000000..3f97304296ba8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/view/view_listener.dart @@ -0,0 +1,103 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/view_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +typedef RowsVisibilityCallback = void Function( + FlowyResult, +); +typedef NumberOfRowsCallback = void Function( + FlowyResult, +); +typedef ReorderAllRowsCallback = void Function( + FlowyResult, FlowyError>, +); +typedef SingleRowCallback = void Function( + FlowyResult, +); + +class DatabaseViewListener { + DatabaseViewListener({required this.viewId}); + + final String viewId; + DatabaseNotificationListener? _listener; + + void start({ + required NumberOfRowsCallback onRowsChanged, + required ReorderAllRowsCallback onReorderAllRows, + required SingleRowCallback onReorderSingleRow, + required RowsVisibilityCallback onRowsVisibilityChanged, + }) { + // Stop any existing listener + _listener?.stop(); + + // Initialize the notification listener + _listener = DatabaseNotificationListener( + objectId: viewId, + handler: (ty, result) => _handler( + ty, + result, + onRowsChanged, + onReorderAllRows, + onReorderSingleRow, + onRowsVisibilityChanged, + ), + ); + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + NumberOfRowsCallback onRowsChanged, + ReorderAllRowsCallback onReorderAllRows, + SingleRowCallback onReorderSingleRow, + RowsVisibilityCallback onRowsVisibilityChanged, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateViewRowsVisibility: + result.fold( + (payload) => onRowsVisibilityChanged( + FlowyResult.success(RowsVisibilityChangePB.fromBuffer(payload)), + ), + (error) => onRowsVisibilityChanged(FlowyResult.failure(error)), + ); + break; + case DatabaseNotification.DidUpdateRow: + result.fold( + (payload) => onRowsChanged( + FlowyResult.success(RowsChangePB.fromBuffer(payload)), + ), + (error) => onRowsChanged(FlowyResult.failure(error)), + ); + break; + case DatabaseNotification.DidReorderRows: + result.fold( + (payload) => onReorderAllRows( + FlowyResult.success(ReorderAllRowsPB.fromBuffer(payload).rowOrders), + ), + (error) => onReorderAllRows(FlowyResult.failure(error)), + ); + break; + case DatabaseNotification.DidReorderSingleRow: + result.fold( + (payload) => onReorderSingleRow( + FlowyResult.success(ReorderSingleRowPB.fromBuffer(payload)), + ), + (error) => onReorderSingleRow(FlowyResult.failure(error)), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _listener = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/application/board_actions_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_actions_bloc.dart new file mode 100644 index 0000000000000..12a1603430867 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_actions_bloc.dart @@ -0,0 +1,94 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'board_actions_bloc.freezed.dart'; + +class BoardActionsCubit extends Cubit { + BoardActionsCubit({ + required this.databaseController, + }) : super(const BoardActionsState.initial()); + + final DatabaseController databaseController; + + void startEditingRow(GroupedRowId groupedRowId) { + emit(BoardActionsState.startEditingRow(groupedRowId: groupedRowId)); + emit(const BoardActionsState.initial()); + } + + void endEditing(GroupedRowId groupedRowId) { + emit(const BoardActionsState.endEditingRow()); + emit(BoardActionsState.setFocus(groupedRowIds: [groupedRowId])); + emit(const BoardActionsState.initial()); + } + + void openCard(RowMetaPB rowMeta) { + emit(BoardActionsState.openCard(rowMeta: rowMeta)); + emit(const BoardActionsState.initial()); + } + + void openCardWithRowId(rowId) { + final rowMeta = databaseController.rowCache.getRow(rowId)!.rowMeta; + openCard(rowMeta); + } + + void setFocus(List groupedRowIds) { + emit(BoardActionsState.setFocus(groupedRowIds: groupedRowIds)); + emit(const BoardActionsState.initial()); + } + + void startCreateBottomRow(String groupId) { + emit(const BoardActionsState.setFocus(groupedRowIds: [])); + emit(BoardActionsState.startCreateBottomRow(groupId: groupId)); + emit(const BoardActionsState.initial()); + } + + void createRow( + GroupedRowId? groupedRowId, + CreateBoardCardRelativePosition relativePosition, + ) { + emit( + BoardActionsState.createRow( + groupedRowId: groupedRowId, + position: relativePosition, + ), + ); + emit(const BoardActionsState.initial()); + } +} + +@freezed +class BoardActionsState with _$BoardActionsState { + const factory BoardActionsState.initial() = _BoardActionsInitialState; + + const factory BoardActionsState.openCard({ + required RowMetaPB rowMeta, + }) = _BoardActionsOpenCardState; + + const factory BoardActionsState.startEditingRow({ + required GroupedRowId groupedRowId, + }) = _BoardActionsStartEditingRowState; + + const factory BoardActionsState.endEditingRow() = + _BoardActionsEndEditingRowState; + + const factory BoardActionsState.setFocus({ + required List groupedRowIds, + }) = _BoardActionsSetFocusState; + + const factory BoardActionsState.startCreateBottomRow({ + required String groupId, + }) = _BoardActionsStartCreateBottomRowState; + + const factory BoardActionsState.createRow({ + required GroupedRowId? groupedRowId, + required CreateBoardCardRelativePosition position, + }) = _BoardActionCreateRowState; +} + +enum CreateBoardCardRelativePosition { + before, + after, +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart new file mode 100644 index 0000000000000..a2c6c895783c7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/application/board_bloc.dart @@ -0,0 +1,812 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:appflowy/plugins/database/application/defines.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/board/group_ext.dart'; +import 'package:appflowy/plugins/database/domain/group_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart' hide FieldInfo; +import 'package:universal_platform/universal_platform.dart'; + +import '../../application/database_controller.dart'; +import '../../application/field/field_controller.dart'; +import '../../application/row/row_cache.dart'; +import 'group_controller.dart'; + +part 'board_bloc.freezed.dart'; + +class BoardBloc extends Bloc { + BoardBloc({ + required this.databaseController, + this.didCreateRow, + AppFlowyBoardController? boardController, + }) : super(const BoardState.loading()) { + groupBackendSvc = GroupBackendService(viewId); + _initBoardController(boardController); + _dispatch(); + } + + final DatabaseController databaseController; + late final AppFlowyBoardController boardController; + final LinkedHashMap groupControllers = + LinkedHashMap(); + final List groupList = []; + + final ValueNotifier? didCreateRow; + + late final GroupBackendService groupBackendSvc; + + UserProfilePB? _userProfile; + UserProfilePB? get userProfile => _userProfile; + + FieldController get fieldController => databaseController.fieldController; + String get viewId => databaseController.viewId; + + DatabaseCallbacks? _databaseCallbacks; + DatabaseLayoutSettingCallbacks? _layoutSettingsCallback; + GroupCallbacks? _groupCallbacks; + + void _initBoardController(AppFlowyBoardController? controller) { + boardController = controller ?? + AppFlowyBoardController( + onMoveGroup: (fromGroupId, fromIndex, toGroupId, toIndex) => + databaseController.moveGroup( + fromGroupId: fromGroupId, + toGroupId: toGroupId, + ), + onMoveGroupItem: (groupId, fromIndex, toIndex) { + final fromRow = groupControllers[groupId]?.rowAtIndex(fromIndex); + final toRow = groupControllers[groupId]?.rowAtIndex(toIndex); + if (fromRow != null) { + databaseController.moveGroupRow( + fromRow: fromRow, + toRow: toRow, + fromGroupId: groupId, + toGroupId: groupId, + ); + } + }, + onMoveGroupItemToGroup: (fromGroupId, fromIndex, toGroupId, toIndex) { + final fromRow = + groupControllers[fromGroupId]?.rowAtIndex(fromIndex); + final toRow = groupControllers[toGroupId]?.rowAtIndex(toIndex); + if (fromRow != null) { + databaseController.moveGroupRow( + fromRow: fromRow, + toRow: toRow, + fromGroupId: fromGroupId, + toGroupId: toGroupId, + ); + } + }, + ); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () async { + emit(BoardState.initial(viewId)); + _startListening(); + await _openDatabase(emit); + + final result = await UserEventGetUserProfile().send(); + result.fold( + (profile) => _userProfile = profile, + (err) => Log.error('Failed to fetch user profile: ${err.msg}'), + ); + }, + createRow: (groupId, position, title, targetRowId) async { + final primaryField = databaseController.fieldController.fieldInfos + .firstWhereOrNull((element) => element.isPrimary)!; + final void Function(RowDataBuilder)? cellBuilder = title == null + ? null + : (builder) => builder.insertText(primaryField, title); + + final result = await RowBackendService.createRow( + viewId: databaseController.viewId, + groupId: groupId, + position: position, + targetRowId: targetRowId, + withCells: cellBuilder, + ); + + final startEditing = position != OrderObjectPositionTypePB.End; + final action = UniversalPlatform.isMobile + ? DidCreateRowAction.openAsPage + : startEditing + ? DidCreateRowAction.startEditing + : DidCreateRowAction.none; + + result.fold( + (rowMeta) { + state.maybeMap( + ready: (value) { + didCreateRow?.value = DidCreateRowResult( + action: action, + rowMeta: rowMeta, + groupId: groupId, + ); + }, + orElse: () {}, + ); + }, + (err) => Log.error(err), + ); + }, + createGroup: (name) async { + final result = await groupBackendSvc.createGroup(name: name); + result.onFailure(Log.error); + }, + deleteGroup: (groupId) async { + final result = await groupBackendSvc.deleteGroup(groupId: groupId); + result.onFailure(Log.error); + }, + renameGroup: (groupId, name) async { + final result = await groupBackendSvc.updateGroup( + groupId: groupId, + name: name, + ); + result.onFailure(Log.error); + }, + didReceiveError: (error) { + emit(BoardState.error(error: error)); + }, + didReceiveGroups: (List groups) { + state.maybeMap( + ready: (state) { + emit( + state.copyWith( + hiddenGroups: _filterHiddenGroups(hideUngrouped, groups), + groupIds: groups.map((group) => group.groupId).toList(), + ), + ); + }, + orElse: () {}, + ); + }, + didUpdateLayoutSettings: (layoutSettings) { + state.maybeMap( + ready: (state) { + emit( + state.copyWith( + layoutSettings: layoutSettings, + hiddenGroups: _filterHiddenGroups(hideUngrouped, groupList), + ), + ); + }, + orElse: () {}, + ); + }, + setGroupVisibility: (GroupPB group, bool isVisible) async { + await _setGroupVisibility(group, isVisible); + }, + toggleHiddenSectionVisibility: (isVisible) async { + await state.maybeMap( + ready: (state) async { + final newLayoutSettings = state.layoutSettings!; + newLayoutSettings.freeze(); + + final newLayoutSetting = newLayoutSettings.rebuild( + (message) => message.collapseHiddenGroups = isVisible, + ); + + await databaseController.updateLayoutSetting( + boardLayoutSetting: newLayoutSetting, + ); + }, + orElse: () {}, + ); + }, + reorderGroup: (fromGroupId, toGroupId) async { + _reorderGroup(fromGroupId, toGroupId, emit); + }, + startEditingHeader: (String groupId) { + state.maybeMap( + ready: (state) => emit(state.copyWith(editingHeaderId: groupId)), + orElse: () {}, + ); + }, + endEditingHeader: (String groupId, String? groupName) async { + final group = groupControllers[groupId]?.group; + if (group != null) { + final currentName = group.generateGroupName(databaseController); + if (currentName != groupName) { + await groupBackendSvc.updateGroup( + groupId: groupId, + name: groupName, + ); + } + } + + state.maybeMap( + ready: (state) => emit(state.copyWith(editingHeaderId: null)), + orElse: () {}, + ); + }, + deleteCards: (groupedRowIds) async { + final rowIds = groupedRowIds.map((e) => e.rowId).toList(); + await RowBackendService.deleteRows(viewId, rowIds); + }, + moveGroupToAdjacentGroup: (groupedRowId, toPrevious) async { + final fromRow = + databaseController.rowCache.getRow(groupedRowId.rowId)?.rowMeta; + final currentGroupIndex = + boardController.groupIds.indexOf(groupedRowId.groupId); + final toGroupIndex = + toPrevious ? currentGroupIndex - 1 : currentGroupIndex + 1; + if (fromRow != null && + toGroupIndex > -1 && + toGroupIndex < boardController.groupIds.length) { + final toGroupId = boardController.groupDatas[toGroupIndex].id; + final result = await databaseController.moveGroupRow( + fromRow: fromRow, + fromGroupId: groupedRowId.groupId, + toGroupId: toGroupId, + ); + result.fold( + (s) { + final previousState = state; + emit( + BoardState.setFocus( + groupedRowIds: [ + GroupedRowId( + groupId: toGroupId, + rowId: groupedRowId.rowId, + ), + ], + ), + ); + emit(previousState); + }, + (f) {}, + ); + } + }, + openRowDetail: (rowMeta) { + final copyState = state; + emit(BoardState.openRowDetail(rowMeta: rowMeta)); + emit(copyState); + }, + ); + }, + ); + } + + Future _setGroupVisibility(GroupPB group, bool isVisible) async { + if (group.isDefault) { + await state.maybeMap( + ready: (state) async { + final newLayoutSettings = state.layoutSettings!; + newLayoutSettings.freeze(); + + final newLayoutSetting = newLayoutSettings.rebuild( + (message) => message.hideUngroupedColumn = !isVisible, + ); + + await databaseController.updateLayoutSetting( + boardLayoutSetting: newLayoutSetting, + ); + }, + orElse: () {}, + ); + } else { + await groupBackendSvc.updateGroup( + groupId: group.groupId, + visible: isVisible, + ); + } + } + + void _reorderGroup( + String fromGroupId, + String toGroupId, + Emitter emit, + ) async { + final fromIndex = groupList.indexWhere((g) => g.groupId == fromGroupId); + final toIndex = groupList.indexWhere((g) => g.groupId == toGroupId); + final group = groupList.removeAt(fromIndex); + groupList.insert(toIndex, group); + add(BoardEvent.didReceiveGroups(groupList)); + final result = await databaseController.moveGroup( + fromGroupId: fromGroupId, + toGroupId: toGroupId, + ); + result.fold((l) => {}, (err) => Log.error(err)); + } + + @override + Future close() async { + for (final controller in groupControllers.values) { + await controller.dispose(); + } + + databaseController.removeListener( + onDatabaseChanged: _databaseCallbacks, + onLayoutSettingsChanged: _layoutSettingsCallback, + onGroupChanged: _groupCallbacks, + ); + + _databaseCallbacks = null; + _layoutSettingsCallback = null; + _groupCallbacks = null; + + boardController.dispose(); + return super.close(); + } + + bool get hideUngrouped => + databaseController.databaseLayoutSetting?.board.hideUngroupedColumn ?? + false; + + FieldType? get groupingFieldType { + if (groupList.isEmpty) { + return null; + } + return databaseController.fieldController + .getField(groupList.first.fieldId) + ?.fieldType; + } + + void initializeGroups(List groups) { + for (final controller in groupControllers.values) { + controller.dispose(); + } + + groupControllers.clear(); + boardController.clear(); + groupList.clear(); + groupList.addAll(groups); + + boardController.addGroups( + groups + .where((group) { + final field = fieldController.getField(group.fieldId); + return field != null && + (!group.isDefault && group.isVisible || + group.isDefault && + !hideUngrouped && + field.fieldType != FieldType.Checkbox); + }) + .map((group) => _initializeGroupData(group)) + .toList(), + ); + + for (final group in groups) { + final controller = _initializeGroupController(group); + groupControllers[controller.group.groupId] = controller; + } + } + + RowCache get rowCache => databaseController.rowCache; + + void _startListening() { + _layoutSettingsCallback = DatabaseLayoutSettingCallbacks( + onLayoutSettingsChanged: (layoutSettings) { + if (isClosed) { + return; + } + final index = groupList.indexWhere((element) => element.isDefault); + if (index != -1) { + if (layoutSettings.board.hideUngroupedColumn) { + boardController.removeGroup(groupList[index].fieldId); + } else { + final newGroup = _initializeGroupData(groupList[index]); + final visibleGroups = [...groupList] + ..retainWhere((g) => g.isVisible || g.isDefault); + final indexInVisibleGroups = + visibleGroups.indexWhere((g) => g.isDefault); + if (indexInVisibleGroups != -1) { + boardController.insertGroup(indexInVisibleGroups, newGroup); + } + } + } + add(BoardEvent.didUpdateLayoutSettings(layoutSettings.board)); + }, + ); + _groupCallbacks = GroupCallbacks( + onGroupByField: (groups) { + if (isClosed) { + return; + } + + initializeGroups(groups); + add(BoardEvent.didReceiveGroups(groups)); + }, + onDeleteGroup: (groupIds) { + if (isClosed) { + return; + } + + boardController.removeGroups(groupIds); + groupList.removeWhere((group) => groupIds.contains(group.groupId)); + add(BoardEvent.didReceiveGroups(groupList)); + }, + onInsertGroup: (insertGroups) { + if (isClosed) { + return; + } + + final group = insertGroups.group; + final newGroup = _initializeGroupData(group); + final controller = _initializeGroupController(group); + groupControllers[controller.group.groupId] = controller; + boardController.addGroup(newGroup); + groupList.insert(insertGroups.index, group); + add(BoardEvent.didReceiveGroups(groupList)); + }, + onUpdateGroup: (updatedGroups) async { + if (isClosed) { + return; + } + + // workaround: update group most of the time gets called before fields in + // field controller are updated. For single and multi-select group + // renames, this is required before generating the new group name. + await Future.delayed(const Duration(milliseconds: 50)); + + for (final group in updatedGroups) { + // see if the column is already in the board + final index = groupList.indexWhere((g) => g.groupId == group.groupId); + if (index == -1) { + continue; + } + + final columnController = + boardController.getGroupController(group.groupId); + if (columnController != null) { + // remove the group or update its name + columnController.updateGroupName( + group.generateGroupName(databaseController), + ); + if (!group.isVisible) { + boardController.removeGroup(group.groupId); + } + } else { + final newGroup = _initializeGroupData(group); + final visibleGroups = [...groupList]..retainWhere( + (g) => + (g.isVisible && !g.isDefault) || + g.isDefault && !hideUngrouped || + g.groupId == group.groupId, + ); + final indexInVisibleGroups = + visibleGroups.indexWhere((g) => g.groupId == group.groupId); + if (indexInVisibleGroups != -1) { + boardController.insertGroup(indexInVisibleGroups, newGroup); + } + } + + groupList.removeAt(index); + groupList.insert(index, group); + } + add(BoardEvent.didReceiveGroups(groupList)); + }, + ); + _databaseCallbacks = DatabaseCallbacks( + onRowsCreated: (rows) { + for (final row in rows) { + if (!isClosed && row.isHiddenInView) { + add(BoardEvent.openRowDetail(row.rowMeta)); + } + } + }, + ); + + databaseController.addListener( + onDatabaseChanged: _databaseCallbacks, + onLayoutSettingsChanged: _layoutSettingsCallback, + onGroupChanged: _groupCallbacks, + ); + } + + List _buildGroupItems(GroupPB group) { + final items = group.rows.map((row) { + final fieldInfo = fieldController.getField(group.fieldId); + return GroupItem( + row: row, + fieldInfo: fieldInfo!, + ); + }).toList(); + + return [...items]; + } + + Future _openDatabase(Emitter emit) { + return databaseController.open().fold( + (datbasePB) => databaseController.setIsLoading(false), + (err) => emit(BoardState.error(error: err)), + ); + } + + GroupController _initializeGroupController(GroupPB group) { + group.freeze(); + + final delegate = GroupControllerDelegateImpl( + controller: boardController, + fieldController: fieldController, + onNewColumnItem: (groupId, row, index) {}, + ); + + final controller = GroupController( + group: group, + delegate: delegate, + onGroupChanged: (newGroup) { + if (isClosed) return; + + final index = + groupList.indexWhere((g) => g.groupId == newGroup.groupId); + if (index != -1) { + groupList.removeAt(index); + groupList.insert(index, newGroup); + add(BoardEvent.didReceiveGroups(groupList)); + } + }, + ); + + return controller..startListening(); + } + + AppFlowyGroupData _initializeGroupData(GroupPB group) { + return AppFlowyGroupData( + id: group.groupId, + name: group.generateGroupName(databaseController), + items: _buildGroupItems(group), + customData: GroupData( + group: group, + fieldInfo: fieldController.getField(group.fieldId)!, + ), + ); + } +} + +@freezed +class BoardEvent with _$BoardEvent { + const factory BoardEvent.initial() = _InitialBoard; + const factory BoardEvent.createRow( + String groupId, + OrderObjectPositionTypePB position, + String? title, + String? targetRowId, + ) = _CreateRow; + const factory BoardEvent.createGroup(String name) = _CreateGroup; + const factory BoardEvent.startEditingHeader(String groupId) = + _StartEditingHeader; + const factory BoardEvent.endEditingHeader(String groupId, String? groupName) = + _EndEditingHeader; + const factory BoardEvent.setGroupVisibility( + GroupPB group, + bool isVisible, + ) = _SetGroupVisibility; + const factory BoardEvent.toggleHiddenSectionVisibility(bool isVisible) = + _ToggleHiddenSectionVisibility; + const factory BoardEvent.renameGroup(String groupId, String name) = + _RenameGroup; + const factory BoardEvent.deleteGroup(String groupId) = _DeleteGroup; + const factory BoardEvent.reorderGroup(String fromGroupId, String toGroupId) = + _ReorderGroup; + const factory BoardEvent.didReceiveError(FlowyError error) = _DidReceiveError; + const factory BoardEvent.didReceiveGroups(List groups) = + _DidReceiveGroups; + const factory BoardEvent.didUpdateLayoutSettings( + BoardLayoutSettingPB layoutSettings, + ) = _DidUpdateLayoutSettings; + const factory BoardEvent.deleteCards(List groupedRowIds) = + _DeleteCards; + const factory BoardEvent.moveGroupToAdjacentGroup( + GroupedRowId groupedRowId, + bool toPrevious, + ) = _MoveGroupToAdjacentGroup; + const factory BoardEvent.openRowDetail(RowMetaPB rowMeta) = _OpenRowDetail; +} + +@freezed +class BoardState with _$BoardState { + const BoardState._(); + + const factory BoardState.loading() = _BoardLoadingState; + + const factory BoardState.error({ + required FlowyError error, + }) = _BoardErrorState; + + const factory BoardState.ready({ + required String viewId, + required List groupIds, + required LoadingState loadingState, + required FlowyError? noneOrError, + required BoardLayoutSettingPB? layoutSettings, + required List hiddenGroups, + String? editingHeaderId, + }) = _BoardReadyState; + + const factory BoardState.setFocus({ + required List groupedRowIds, + }) = _BoardSetFocusState; + + const factory BoardState.openRowDetail({ + required RowMetaPB rowMeta, + }) = _BoardOpenRowDetailState; + + factory BoardState.initial(String viewId) => BoardState.ready( + viewId: viewId, + groupIds: [], + noneOrError: null, + loadingState: const LoadingState.loading(), + layoutSettings: null, + hiddenGroups: [], + ); + + bool get isLoading => maybeMap(loading: (_) => true, orElse: () => false); + bool get isError => maybeMap(error: (_) => true, orElse: () => false); + bool get isReady => maybeMap(ready: (_) => true, orElse: () => false); + bool get isSetFocus => maybeMap(setFocus: (_) => true, orElse: () => false); +} + +List _filterHiddenGroups(bool hideUngrouped, List groups) { + return [...groups]..retainWhere( + (group) => !group.isVisible || group.isDefault && hideUngrouped, + ); +} + +class GroupItem extends AppFlowyGroupItem { + GroupItem({ + required this.row, + required this.fieldInfo, + }); + + final RowMetaPB row; + final FieldInfo fieldInfo; + + @override + String get id => row.id.toString(); +} + +/// Identifies a card in a database view that has grouping. To support cases +/// in which a card can belong to more than one group at the same time (e.g. +/// FieldType.Multiselect), we include the card's group id as well. +/// +class GroupedRowId extends Equatable { + const GroupedRowId({ + required this.rowId, + required this.groupId, + }); + + final String rowId; + final String groupId; + + @override + List get props => [rowId, groupId]; +} + +class GroupControllerDelegateImpl extends GroupControllerDelegate { + GroupControllerDelegateImpl({ + required this.controller, + required this.fieldController, + required this.onNewColumnItem, + }); + + final FieldController fieldController; + final AppFlowyBoardController controller; + final void Function(String, RowMetaPB, int?) onNewColumnItem; + + @override + bool hasGroup(String groupId) => controller.groupIds.contains(groupId); + + @override + void insertRow(GroupPB group, RowMetaPB row, int? index) { + final fieldInfo = fieldController.getField(group.fieldId); + if (fieldInfo == null) { + return Log.warn("fieldInfo should not be null"); + } + + if (index != null) { + final item = GroupItem( + row: row, + fieldInfo: fieldInfo, + ); + controller.insertGroupItem(group.groupId, index, item); + } else { + final item = GroupItem( + row: row, + fieldInfo: fieldInfo, + ); + controller.addGroupItem(group.groupId, item); + } + } + + @override + void removeRow(GroupPB group, RowId rowId) => + controller.removeGroupItem(group.groupId, rowId.toString()); + + @override + void updateRow(GroupPB group, RowMetaPB row) { + final fieldInfo = fieldController.getField(group.fieldId); + if (fieldInfo == null) { + return Log.warn("fieldInfo should not be null"); + } + + controller.updateGroupItem( + group.groupId, + GroupItem( + row: row, + fieldInfo: fieldInfo, + ), + ); + } + + @override + void addNewRow(GroupPB group, RowMetaPB row, int? index) { + final fieldInfo = fieldController.getField(group.fieldId); + if (fieldInfo == null) { + return Log.warn("fieldInfo should not be null"); + } + + final item = GroupItem(row: row, fieldInfo: fieldInfo); + + if (index != null) { + controller.insertGroupItem(group.groupId, index, item); + } else { + controller.addGroupItem(group.groupId, item); + } + + onNewColumnItem(group.groupId, row, index); + } +} + +class GroupData { + const GroupData({ + required this.group, + required this.fieldInfo, + }); + + final GroupPB group; + final FieldInfo fieldInfo; + + CheckboxGroup? asCheckboxGroup() => + fieldType == FieldType.Checkbox ? CheckboxGroup(group) : null; + + FieldType get fieldType => fieldInfo.fieldType; +} + +class CheckboxGroup { + const CheckboxGroup(this.group); + + final GroupPB group; + + // Hardcode value: "Yes" that equal to the value defined in Rust + // pub const CHECK: &str = "Yes"; + bool get isCheck => group.groupId == "Yes"; +} + +enum DidCreateRowAction { + none, + openAsPage, + startEditing, +} + +class DidCreateRowResult { + DidCreateRowResult({ + required this.action, + required this.rowMeta, + required this.groupId, + }); + + final DidCreateRowAction action; + final RowMetaPB rowMeta; + final String groupId; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/application/group_controller.dart b/frontend/appflowy_flutter/lib/plugins/database/board/application/group_controller.dart new file mode 100644 index 0000000000000..4e0ad9badadb8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/application/group_controller.dart @@ -0,0 +1,151 @@ +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; +import 'package:protobuf/protobuf.dart'; + +abstract class GroupControllerDelegate { + bool hasGroup(String groupId); + void removeRow(GroupPB group, RowId rowId); + void insertRow(GroupPB group, RowMetaPB row, int? index); + void updateRow(GroupPB group, RowMetaPB row); + void addNewRow(GroupPB group, RowMetaPB row, int? index); +} + +class GroupController { + GroupController({ + required this.group, + required this.delegate, + required this.onGroupChanged, + }) : _listener = SingleGroupListener(group); + + GroupPB group; + final SingleGroupListener _listener; + final GroupControllerDelegate delegate; + final void Function(GroupPB group) onGroupChanged; + + RowMetaPB? rowAtIndex(int index) => group.rows.elementAtOrNull(index); + + RowMetaPB? firstRow() => group.rows.firstOrNull; + + RowMetaPB? lastRow() => group.rows.lastOrNull; + + void startListening() { + _listener.start( + onGroupChanged: (result) { + result.fold( + (GroupRowsNotificationPB changeset) { + final newItems = [...group.rows]; + final isGroupExist = delegate.hasGroup(group.groupId); + for (final deletedRow in changeset.deletedRows) { + newItems.removeWhere((rowPB) => rowPB.id == deletedRow); + if (isGroupExist) { + delegate.removeRow(group, deletedRow); + } + } + + for (final insertedRow in changeset.insertedRows) { + if (newItems.any((rowPB) => rowPB.id == insertedRow.rowMeta.id)) { + continue; + } + + final index = insertedRow.hasIndex() ? insertedRow.index : null; + if (insertedRow.hasIndex() && + newItems.length > insertedRow.index) { + newItems.insert(insertedRow.index, insertedRow.rowMeta); + } else { + newItems.add(insertedRow.rowMeta); + } + + if (isGroupExist) { + if (insertedRow.isNew) { + delegate.addNewRow(group, insertedRow.rowMeta, index); + } else { + delegate.insertRow(group, insertedRow.rowMeta, index); + } + } + } + + for (final updatedRow in changeset.updatedRows) { + final index = newItems.indexWhere( + (rowPB) => rowPB.id == updatedRow.id, + ); + + if (index != -1) { + newItems[index] = updatedRow; + if (isGroupExist) { + delegate.updateRow(group, updatedRow); + } + } + } + + group = group.rebuild((group) { + group.rows.clear(); + group.rows.addAll(newItems); + }); + group.freeze(); + Log.debug( + "Build GroupPB:${group.groupId}: items: ${group.rows.length}", + ); + onGroupChanged(group); + }, + (err) => Log.error(err), + ); + }, + ); + } + + Future dispose() async { + await _listener.stop(); + } +} + +typedef UpdateGroupNotifiedValue + = FlowyResult; + +class SingleGroupListener { + SingleGroupListener(this.group); + + final GroupPB group; + + PublishNotifier? _groupNotifier = PublishNotifier(); + DatabaseNotificationListener? _listener; + + void start({ + required void Function(UpdateGroupNotifiedValue) onGroupChanged, + }) { + _groupNotifier?.addPublishListener(onGroupChanged); + _listener = DatabaseNotificationListener( + objectId: group.groupId, + handler: _handler, + ); + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateGroupRow: + result.fold( + (payload) => _groupNotifier?.value = + FlowyResult.success(GroupRowsNotificationPB.fromBuffer(payload)), + (error) => _groupNotifier?.value = FlowyResult.failure(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _groupNotifier?.dispose(); + _groupNotifier = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/board.dart b/frontend/appflowy_flutter/lib/plugins/database/board/board.dart new file mode 100644 index 0000000000000..f8fb18a56101e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/board.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class BoardPluginBuilder implements PluginBuilder { + @override + Plugin build(dynamic data) { + if (data is ViewPB) { + return DatabaseTabBarViewPlugin(pluginType: pluginType, view: data); + } else { + throw FlowyPluginException.invalidData; + } + } + + @override + String get menuName => LocaleKeys.board_menuName.tr(); + + @override + FlowySvgData get icon => FlowySvgs.icon_board_s; + + @override + PluginType get pluginType => PluginType.board; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Board; +} + +class BoardPluginConfig implements PluginConfig { + @override + bool get creatable => true; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/group_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/board/group_ext.dart new file mode 100644 index 0000000000000..5c69a66e62a0b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/group_ext.dart @@ -0,0 +1,106 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; + +extension GroupName on GroupPB { + String generateGroupName(DatabaseController databaseController) { + final fieldController = databaseController.fieldController; + final field = fieldController.getField(fieldId); + if (field == null) { + return ""; + } + + // if the group is the default group, then + if (isDefault) { + return "No ${field.name}"; + } + + final groupSettings = databaseController.fieldController.groupSettings + .firstWhereOrNull((gs) => gs.fieldId == field.id); + + switch (field.fieldType) { + case FieldType.SingleSelect: + final options = + SingleSelectTypeOptionPB.fromBuffer(field.field.typeOptionData) + .options; + final option = + options.firstWhereOrNull((option) => option.id == groupId); + return option == null ? "" : option.name; + case FieldType.MultiSelect: + final options = + MultiSelectTypeOptionPB.fromBuffer(field.field.typeOptionData) + .options; + final option = + options.firstWhereOrNull((option) => option.id == groupId); + return option == null ? "" : option.name; + case FieldType.Checkbox: + return groupId; + case FieldType.URL: + return groupId; + case FieldType.DateTime: + final config = groupSettings?.content != null + ? DateGroupConfigurationPB.fromBuffer(groupSettings!.content) + : DateGroupConfigurationPB(); + final dateFormat = DateFormat("y/MM/dd"); + try { + final targetDateTime = dateFormat.parseLoose(groupId); + switch (config.condition) { + case DateConditionPB.Day: + return DateFormat("MMM dd, y").format(targetDateTime); + case DateConditionPB.Week: + final beginningOfWeek = targetDateTime + .subtract(Duration(days: targetDateTime.weekday - 1)); + final endOfWeek = targetDateTime.add( + Duration(days: DateTime.daysPerWeek - targetDateTime.weekday), + ); + + final beginningOfWeekFormat = + beginningOfWeek.year != endOfWeek.year + ? "MMM dd y" + : "MMM dd"; + final endOfWeekFormat = beginningOfWeek.month != endOfWeek.month + ? "MMM dd y" + : "dd y"; + + return LocaleKeys.board_dateCondition_weekOf.tr( + args: [ + DateFormat(beginningOfWeekFormat).format(beginningOfWeek), + DateFormat(endOfWeekFormat).format(endOfWeek), + ], + ); + case DateConditionPB.Month: + return DateFormat("MMM y").format(targetDateTime); + case DateConditionPB.Year: + return DateFormat("y").format(targetDateTime); + case DateConditionPB.Relative: + final targetDateTimeDay = DateTime( + targetDateTime.year, + targetDateTime.month, + targetDateTime.day, + ); + final nowDay = DateTime.now().withoutTime; + final diff = targetDateTimeDay.difference(nowDay).inDays; + return switch (diff) { + 0 => LocaleKeys.board_dateCondition_today.tr(), + -1 => LocaleKeys.board_dateCondition_yesterday.tr(), + 1 => LocaleKeys.board_dateCondition_tomorrow.tr(), + -7 => LocaleKeys.board_dateCondition_lastSevenDays.tr(), + 2 => LocaleKeys.board_dateCondition_nextSevenDays.tr(), + -30 => LocaleKeys.board_dateCondition_lastThirtyDays.tr(), + 8 => LocaleKeys.board_dateCondition_nextThirtyDays.tr(), + _ => DateFormat("MMM y").format(targetDateTimeDay) + }; + default: + return ""; + } + } on FormatException { + return ""; + } + default: + return ""; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart new file mode 100644 index 0000000000000..b373e33b2f7d5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart @@ -0,0 +1,865 @@ +import 'dart:io'; + +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:flutter/material.dart' hide Card; +import 'package:flutter/services.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/database/board/mobile_board_page.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/board/application/board_actions_bloc.dart'; +import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/database/widgets/card/card_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; +import 'package:appflowy/shared/conditional_listenable_builder.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../widgets/card/card.dart'; +import '../../widgets/cell/card_cell_builder.dart'; +import '../application/board_bloc.dart'; + +import 'toolbar/board_setting_bar.dart'; +import 'widgets/board_focus_scope.dart'; +import 'widgets/board_hidden_groups.dart'; +import 'widgets/board_shortcut_container.dart'; + +class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { + final _toggleExtension = ToggleExtensionNotifier(); + + @override + Widget content( + BuildContext context, + ViewPB view, + DatabaseController controller, + bool shrinkWrap, + String? initialRowId, + ) => + UniversalPlatform.isDesktop + ? DesktopBoardPage( + key: _makeValueKey(controller), + view: view, + databaseController: controller, + shrinkWrap: shrinkWrap, + ) + : MobileBoardPage( + key: _makeValueKey(controller), + view: view, + databaseController: controller, + ); + + @override + Widget settingBar(BuildContext context, DatabaseController controller) => + BoardSettingBar( + key: _makeValueKey(controller), + databaseController: controller, + toggleExtension: _toggleExtension, + ); + + @override + Widget settingBarExtension( + BuildContext context, + DatabaseController controller, + ) { + return DatabaseViewSettingExtension( + key: _makeValueKey(controller), + viewId: controller.viewId, + databaseController: controller, + toggleExtension: _toggleExtension, + ); + } + + @override + void dispose() { + _toggleExtension.dispose(); + super.dispose(); + } + + ValueKey _makeValueKey(DatabaseController controller) => + ValueKey(controller.viewId); +} + +class DesktopBoardPage extends StatefulWidget { + const DesktopBoardPage({ + super.key, + required this.view, + required this.databaseController, + this.onEditStateChanged, + this.shrinkWrap = false, + }); + + final ViewPB view; + + final DatabaseController databaseController; + + /// Called when edit state changed + final VoidCallback? onEditStateChanged; + + /// If true, the board will shrink wrap its content + final bool shrinkWrap; + + @override + State createState() => _DesktopBoardPageState(); +} + +class _DesktopBoardPageState extends State { + late final AppFlowyBoardController _boardController = AppFlowyBoardController( + onMoveGroup: (fromGroupId, fromIndex, toGroupId, toIndex) => + widget.databaseController.moveGroup( + fromGroupId: fromGroupId, + toGroupId: toGroupId, + ), + onMoveGroupItem: (groupId, fromIndex, toIndex) { + final groupControllers = _boardBloc.groupControllers; + final fromRow = groupControllers[groupId]?.rowAtIndex(fromIndex); + final toRow = groupControllers[groupId]?.rowAtIndex(toIndex); + if (fromRow != null) { + widget.databaseController.moveGroupRow( + fromRow: fromRow, + toRow: toRow, + fromGroupId: groupId, + toGroupId: groupId, + ); + } + }, + onMoveGroupItemToGroup: (fromGroupId, fromIndex, toGroupId, toIndex) { + final groupControllers = _boardBloc.groupControllers; + final fromRow = groupControllers[fromGroupId]?.rowAtIndex(fromIndex); + final toRow = groupControllers[toGroupId]?.rowAtIndex(toIndex); + if (fromRow != null) { + widget.databaseController.moveGroupRow( + fromRow: fromRow, + toRow: toRow, + fromGroupId: fromGroupId, + toGroupId: toGroupId, + ); + } + }, + onStartDraggingCard: (groupId, index) { + final groupControllers = _boardBloc.groupControllers; + final toRow = groupControllers[groupId]?.rowAtIndex(index); + if (toRow != null) { + _focusScope.clear(); + } + }, + ); + + late final _focusScope = BoardFocusScope( + boardController: _boardController, + ); + late final BoardBloc _boardBloc; + late final BoardActionsCubit _boardActionsCubit; + late final ValueNotifier _didCreateRow; + + @override + void initState() { + super.initState(); + _didCreateRow = ValueNotifier(null)..addListener(_handleDidCreateRow); + _boardBloc = BoardBloc( + databaseController: widget.databaseController, + didCreateRow: _didCreateRow, + boardController: _boardController, + )..add(const BoardEvent.initial()); + _boardActionsCubit = BoardActionsCubit( + databaseController: widget.databaseController, + ); + } + + @override + void dispose() { + _focusScope.dispose(); + _boardBloc.close(); + _boardActionsCubit.close(); + _didCreateRow.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: _boardBloc), + BlocProvider.value(value: _boardActionsCubit), + ], + child: BlocBuilder( + builder: (context, state) => state.maybeMap( + loading: (_) => const Center( + child: CircularProgressIndicator.adaptive(), + ), + error: (err) => Center(child: AppFlowyErrorPage(error: err.error)), + orElse: () => _BoardContent( + shrinkWrap: widget.shrinkWrap, + onEditStateChanged: widget.onEditStateChanged, + focusScope: _focusScope, + boardController: _boardController, + ), + ), + ), + ); + } + + void _handleDidCreateRow() async { + // work around: wait for the new card to be inserted into the board before enabling edit + await Future.delayed(const Duration(milliseconds: 50)); + if (_didCreateRow.value != null) { + final result = _didCreateRow.value!; + switch (result.action) { + case DidCreateRowAction.openAsPage: + _boardActionsCubit.openCard(result.rowMeta); + break; + case DidCreateRowAction.startEditing: + _boardActionsCubit.startEditingRow( + GroupedRowId( + groupId: result.groupId, + rowId: result.rowMeta.id, + ), + ); + break; + default: + break; + } + } + } +} + +class _BoardContent extends StatefulWidget { + const _BoardContent({ + required this.boardController, + required this.focusScope, + this.onEditStateChanged, + this.shrinkWrap = false, + }); + + final AppFlowyBoardController boardController; + final BoardFocusScope focusScope; + final VoidCallback? onEditStateChanged; + final bool shrinkWrap; + + @override + State<_BoardContent> createState() => _BoardContentState(); +} + +class _BoardContentState extends State<_BoardContent> { + final ScrollController scrollController = ScrollController(); + final AppFlowyBoardScrollController scrollManager = + AppFlowyBoardScrollController(); + + final config = const AppFlowyBoardConfig( + groupMargin: EdgeInsets.symmetric(horizontal: 4), + groupBodyPadding: EdgeInsets.symmetric(horizontal: 4), + groupFooterPadding: EdgeInsets.fromLTRB(8, 14, 8, 4), + groupHeaderPadding: EdgeInsets.symmetric(horizontal: 8), + cardMargin: EdgeInsets.symmetric(horizontal: 4, vertical: 3), + stretchGroupHeight: false, + ); + + late final cellBuilder = CardCellBuilder( + databaseController: databaseController, + ); + + DatabaseController get databaseController => + context.read().databaseController; + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + state.maybeMap( + ready: (value) { + widget.onEditStateChanged?.call(); + }, + openRowDetail: (value) { + _openCard( + context: context, + databaseController: + context.read().databaseController, + rowMeta: value.rowMeta, + ); + }, + orElse: () {}, + ); + }, + ), + BlocListener( + listener: (context, state) { + state.maybeMap( + openCard: (value) { + _openCard( + context: context, + databaseController: + context.read().databaseController, + rowMeta: value.rowMeta, + ); + }, + setFocus: (value) { + widget.focusScope.focusedGroupedRows = value.groupedRowIds; + }, + startEditingRow: (value) { + widget.boardController.enableGroupDragging(false); + widget.focusScope.clear(); + }, + endEditingRow: (value) { + widget.boardController.enableGroupDragging(true); + }, + orElse: () {}, + ); + }, + ), + ], + child: FocusScope( + autofocus: true, + child: BoardShortcutContainer( + focusScope: widget.focusScope, + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: AppFlowyBoard( + boardScrollController: scrollManager, + scrollController: scrollController, + controller: context.read().boardController, + groupConstraints: const BoxConstraints.tightFor(width: 256), + config: config, + leading: HiddenGroupsColumn(margin: config.groupHeaderPadding), + trailing: context + .read() + .groupingFieldType + ?.canCreateNewGroup ?? + false + ? BoardTrailing(scrollController: scrollController) + : const HSpace(40), + headerBuilder: (_, groupData) => BlocProvider.value( + value: context.read(), + child: BoardColumnHeader( + databaseController: databaseController, + groupData: groupData, + margin: config.groupHeaderPadding, + ), + ), + footerBuilder: (_, groupData) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: context.read()), + BlocProvider.value(value: context.read()), + ], + child: BoardColumnFooter( + columnData: groupData, + boardConfig: config, + scrollManager: scrollManager, + ), + ), + cardBuilder: (_, column, columnItem) => MultiBlocProvider( + key: ValueKey("board_card_${column.id}_${columnItem.id}"), + providers: [ + BlocProvider.value( + value: context.read(), + ), + BlocProvider.value( + value: context.read(), + ), + ], + child: _BoardCard( + afGroupData: column, + groupItem: columnItem as GroupItem, + boardConfig: config, + notifier: widget.focusScope, + cellBuilder: cellBuilder, + ), + ), + ), + ), + ), + ), + ); + } +} + +@visibleForTesting +class BoardColumnFooter extends StatefulWidget { + const BoardColumnFooter({ + super.key, + required this.columnData, + required this.boardConfig, + required this.scrollManager, + }); + + final AppFlowyGroupData columnData; + final AppFlowyBoardConfig boardConfig; + final AppFlowyBoardScrollController scrollManager; + + @override + State createState() => _BoardColumnFooterState(); +} + +class _BoardColumnFooterState extends State { + final TextEditingController _textController = TextEditingController(); + late final FocusNode _focusNode; + bool _isCreating = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode( + onKeyEvent: (node, event) { + if (_focusNode.hasFocus && + event.logicalKey == LogicalKeyboardKey.escape) { + _focusNode.unfocus(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + )..addListener(() { + if (!_focusNode.hasFocus) { + setState(() => _isCreating = false); + } + }); + } + + @override + void dispose() { + _textController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_isCreating) { + _focusNode.requestFocus(); + } + }); + return Padding( + padding: widget.boardConfig.groupFooterPadding, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + child: + _isCreating ? _createCardsTextField() : _startCreatingCardsButton(), + ), + ); + } + + Widget _createCardsTextField() { + const nada = DoNothingAndStopPropagationIntent(); + return Shortcuts( + shortcuts: { + const SingleActivator(LogicalKeyboardKey.arrowUp): nada, + const SingleActivator(LogicalKeyboardKey.arrowDown): nada, + const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): nada, + const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): nada, + const SingleActivator(LogicalKeyboardKey.keyE): nada, + const SingleActivator(LogicalKeyboardKey.keyN): nada, + const SingleActivator(LogicalKeyboardKey.delete): nada, + // const SingleActivator(LogicalKeyboardKey.backspace): nada, + const SingleActivator(LogicalKeyboardKey.enter): nada, + const SingleActivator(LogicalKeyboardKey.numpadEnter): nada, + const SingleActivator(LogicalKeyboardKey.comma): nada, + const SingleActivator(LogicalKeyboardKey.period): nada, + SingleActivator( + LogicalKeyboardKey.arrowUp, + shift: true, + meta: Platform.isMacOS, + control: !Platform.isMacOS, + ): nada, + }, + child: FlowyTextField( + hintTextConstraints: const BoxConstraints(maxHeight: 36), + controller: _textController, + focusNode: _focusNode, + onSubmitted: (name) { + context.read().add( + BoardEvent.createRow( + widget.columnData.id, + OrderObjectPositionTypePB.End, + name, + null, + ), + ); + widget.scrollManager.scrollToBottom(widget.columnData.id); + _textController.clear(); + _focusNode.requestFocus(); + }, + ), + ); + } + + Widget _startCreatingCardsButton() { + return BlocListener( + listener: (context, state) { + state.maybeWhen( + startCreateBottomRow: (groupId) { + if (groupId == widget.columnData.id) { + setState(() => _isCreating = true); + } + }, + orElse: () {}, + ); + }, + child: FlowyTooltip( + message: LocaleKeys.board_column_addToColumnBottomTooltip.tr(), + child: SizedBox( + height: 36, + child: FlowyButton( + leftIcon: FlowySvg( + FlowySvgs.add_s, + color: Theme.of(context).hintColor, + ), + text: FlowyText( + LocaleKeys.board_column_createNewCard.tr(), + color: Theme.of(context).hintColor, + ), + onTap: () { + context + .read() + .startCreateBottomRow(widget.columnData.id); + }, + ), + ), + ), + ); + } +} + +class _BoardCard extends StatefulWidget { + const _BoardCard({ + required this.afGroupData, + required this.groupItem, + required this.boardConfig, + required this.cellBuilder, + required this.notifier, + }); + + final AppFlowyGroupData afGroupData; + final GroupItem groupItem; + final AppFlowyBoardConfig boardConfig; + final CardCellBuilder cellBuilder; + final BoardFocusScope notifier; + + @override + State<_BoardCard> createState() => _BoardCardState(); +} + +class _BoardCardState extends State<_BoardCard> { + bool _isEditing = false; + + @override + Widget build(BuildContext context) { + final boardBloc = context.read(); + final groupData = widget.afGroupData.customData as GroupData; + final rowCache = boardBloc.rowCache; + final databaseController = boardBloc.databaseController; + final rowMeta = + rowCache.getRow(widget.groupItem.id)?.rowMeta ?? widget.groupItem.row; + + const nada = DoNothingAndStopPropagationIntent(); + + return BlocListener( + listener: (context, state) { + state.maybeMap( + startEditingRow: (value) { + if (value.groupedRowId.rowId == widget.groupItem.id && + value.groupedRowId.groupId == groupData.group.groupId) { + setState(() => _isEditing = true); + } + }, + endEditingRow: (_) { + if (_isEditing) { + setState(() => _isEditing = false); + } + }, + createRow: (value) { + if ((_isEditing && value.groupedRowId == null) || + (value.groupedRowId?.rowId == widget.groupItem.id && + value.groupedRowId?.groupId == groupData.group.groupId)) { + context.read().add( + BoardEvent.createRow( + groupData.group.groupId, + value.position == CreateBoardCardRelativePosition.before + ? OrderObjectPositionTypePB.Before + : OrderObjectPositionTypePB.After, + null, + widget.groupItem.row.id, + ), + ); + } + }, + orElse: () {}, + ); + }, + child: Shortcuts( + shortcuts: { + const SingleActivator(LogicalKeyboardKey.arrowUp): nada, + const SingleActivator(LogicalKeyboardKey.arrowDown): nada, + const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): nada, + const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): + nada, + const SingleActivator(LogicalKeyboardKey.keyE): nada, + const SingleActivator(LogicalKeyboardKey.keyN): nada, + const SingleActivator(LogicalKeyboardKey.delete): nada, + // const SingleActivator(LogicalKeyboardKey.backspace): nada, + const SingleActivator(LogicalKeyboardKey.enter): nada, + const SingleActivator(LogicalKeyboardKey.numpadEnter): nada, + const SingleActivator(LogicalKeyboardKey.comma): nada, + const SingleActivator(LogicalKeyboardKey.period): nada, + SingleActivator( + LogicalKeyboardKey.arrowUp, + shift: true, + meta: Platform.isMacOS, + control: !Platform.isMacOS, + ): nada, + }, + child: ConditionalListenableBuilder>( + valueListenable: widget.notifier, + buildWhen: (previous, current) { + final focusItem = GroupedRowId( + groupId: groupData.group.groupId, + rowId: rowMeta.id, + ); + final previousContainsFocus = previous.contains(focusItem); + final currentContainsFocus = current.contains(focusItem); + + return previousContainsFocus != currentContainsFocus; + }, + builder: (context, focusedItems, child) => Container( + margin: widget.boardConfig.cardMargin, + decoration: _makeBoxDecoration( + context, + groupData.group.groupId, + widget.groupItem.id, + ), + child: child, + ), + child: RowCard( + fieldController: databaseController.fieldController, + rowMeta: rowMeta, + viewId: boardBloc.viewId, + rowCache: rowCache, + groupingFieldId: widget.groupItem.fieldInfo.id, + isEditing: _isEditing, + cellBuilder: widget.cellBuilder, + onTap: (context) => _openCard( + context: context, + databaseController: databaseController, + rowMeta: context.read().rowController.rowMeta, + ), + onShiftTap: (_) { + Focus.of(context).requestFocus(); + widget.notifier.toggle( + GroupedRowId( + rowId: widget.groupItem.row.id, + groupId: groupData.group.groupId, + ), + ); + }, + styleConfiguration: RowCardStyleConfiguration( + cellStyleMap: desktopBoardCardCellStyleMap(context), + hoverStyle: HoverStyle( + hoverColor: Theme.of(context).brightness == Brightness.light + ? const Color(0x0F1F2329) + : const Color(0x0FEFF4FB), + foregroundColorOnHover: + AFThemeExtension.of(context).onBackground, + ), + ), + onStartEditing: () => + context.read().startEditingRow( + GroupedRowId( + groupId: groupData.group.groupId, + rowId: rowMeta.id, + ), + ), + onEndEditing: () => context.read().endEditing( + GroupedRowId( + groupId: groupData.group.groupId, + rowId: rowMeta.id, + ), + ), + userProfile: context.read().userProfile, + ), + ), + ), + ); + } + + BoxDecoration _makeBoxDecoration( + BuildContext context, + String groupId, + String rowId, + ) { + return BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.all(Radius.circular(6)), + border: Border.fromBorderSide( + BorderSide( + color: widget.notifier + .isFocused(GroupedRowId(rowId: rowId, groupId: groupId)) + ? Theme.of(context).colorScheme.primary + : Theme.of(context).brightness == Brightness.light + ? const Color(0xFF1F2329).withOpacity(0.12) + : const Color(0xFF59647A), + ), + ), + boxShadow: [ + BoxShadow( + blurRadius: 4, + color: const Color(0xFF1F2329).withOpacity(0.02), + ), + BoxShadow( + blurRadius: 4, + spreadRadius: -2, + color: const Color(0xFF1F2329).withOpacity(0.02), + ), + ], + ); + } +} + +class BoardTrailing extends StatefulWidget { + const BoardTrailing({super.key, required this.scrollController}); + + final ScrollController scrollController; + + @override + State createState() => _BoardTrailingState(); +} + +class _BoardTrailingState extends State { + final TextEditingController _textController = TextEditingController(); + late final FocusNode _focusNode; + + bool isEditing = false; + + void _cancelAddNewGroup() { + _textController.clear(); + setState(() => isEditing = false); + } + + @override + void initState() { + super.initState(); + _focusNode = FocusNode( + onKeyEvent: (node, event) { + if (_focusNode.hasFocus && + event.logicalKey == LogicalKeyboardKey.escape) { + _cancelAddNewGroup(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + )..addListener(_onFocusChanged); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChanged); + _focusNode.dispose(); + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // call after every setState + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isEditing) { + _focusNode.requestFocus(); + widget.scrollController.jumpTo( + widget.scrollController.position.maxScrollExtent, + ); + } + }); + + return Container( + padding: const EdgeInsets.only(left: 8.0, top: 12, right: 40), + alignment: AlignmentDirectional.topStart, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: isEditing + ? SizedBox( + width: 256, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _textController, + focusNode: _focusNode, + decoration: InputDecoration( + suffixIcon: Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8.0), + child: FlowyIconButton( + icon: const FlowySvg(FlowySvgs.close_filled_m), + hoverColor: Colors.transparent, + onPressed: () => _textController.clear(), + ), + ), + suffixIconConstraints: + BoxConstraints.loose(const Size(20, 24)), + border: const UnderlineInputBorder(), + contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + isDense: true, + ), + style: Theme.of(context).textTheme.bodySmall, + onSubmitted: (groupName) => context + .read() + .add(BoardEvent.createGroup(groupName)), + ), + ), + ) + : FlowyTooltip( + message: LocaleKeys.board_column_createNewColumn.tr(), + child: FlowyIconButton( + width: 26, + icon: const FlowySvg(FlowySvgs.add_s), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + onPressed: () => setState(() => isEditing = true), + ), + ), + ), + ); + } + + void _onFocusChanged() { + if (!_focusNode.hasFocus) { + _cancelAddNewGroup(); + } + } +} + +void _openCard({ + required BuildContext context, + required DatabaseController databaseController, + required RowMetaPB rowMeta, +}) { + final rowController = RowController( + rowMeta: rowMeta, + viewId: databaseController.viewId, + rowCache: databaseController.rowCache, + ); + + FlowyOverlay.show( + context: context, + builder: (_) => RowDetailPage( + databaseController: databaseController, + rowController: rowController, + userProfile: context.read().userProfile, + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/toolbar/board_setting_bar.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/toolbar/board_setting_bar.dart new file mode 100644 index 0000000000000..9e3203e093eaa --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/toolbar/board_setting_bar.dart @@ -0,0 +1,52 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart'; +import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BoardSettingBar extends StatelessWidget { + const BoardSettingBar({ + super.key, + required this.databaseController, + required this.toggleExtension, + }); + + final DatabaseController databaseController; + final ToggleExtensionNotifier toggleExtension; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => FilterEditorBloc( + viewId: databaseController.viewId, + fieldController: databaseController.fieldController, + ), + child: ValueListenableBuilder( + valueListenable: databaseController.isLoading, + builder: (context, value, child) { + if (value) { + return const SizedBox.shrink(); + } + return SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilterButton( + toggleExtension: toggleExtension, + ), + const HSpace(2), + SettingButton( + databaseController: databaseController, + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_checkbox_column_header.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_checkbox_column_header.dart new file mode 100644 index 0000000000000..8ac06bf2df8ac --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_checkbox_column_header.dart @@ -0,0 +1,58 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy/plugins/database/board/group_ext.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'board_column_header.dart'; + +class CheckboxColumnHeader extends StatelessWidget { + const CheckboxColumnHeader({ + super.key, + required this.databaseController, + required this.groupData, + }); + + final DatabaseController databaseController; + final AppFlowyGroupData groupData; + + @override + Widget build(BuildContext context) { + final customData = groupData.customData as GroupData; + final groupName = customData.group.generateGroupName(databaseController); + return Row( + children: [ + FlowySvg( + customData.asCheckboxGroup()!.isCheck + ? FlowySvgs.check_filled_s + : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, + size: const Size.square(18), + ), + const HSpace(6), + Expanded( + child: Align( + alignment: AlignmentDirectional.centerStart, + child: FlowyTooltip( + message: groupName, + child: FlowyText.medium( + groupName, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + const HSpace(6), + GroupOptionsButton( + groupData: groupData, + ), + const HSpace(4), + CreateCardFromTopButton( + groupId: groupData.id, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_column_header.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_column_header.dart new file mode 100644 index 0000000000000..abd28ac022e17 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_column_header.dart @@ -0,0 +1,250 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy/plugins/database/board/group_ext.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'board_checkbox_column_header.dart'; +import 'board_editable_column_header.dart'; + +class BoardColumnHeader extends StatefulWidget { + const BoardColumnHeader({ + super.key, + required this.databaseController, + required this.groupData, + required this.margin, + }); + + final DatabaseController databaseController; + final AppFlowyGroupData groupData; + final EdgeInsets margin; + + @override + State createState() => _BoardColumnHeaderState(); +} + +class _BoardColumnHeaderState extends State { + final ValueNotifier isEditing = ValueNotifier(false); + + GroupData get customData => widget.groupData.customData; + + @override + void dispose() { + isEditing.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Widget child = switch (customData.fieldType) { + FieldType.MultiSelect || + FieldType.SingleSelect when !customData.group.isDefault => + EditableColumnHeader( + databaseController: widget.databaseController, + groupData: widget.groupData, + isEditing: isEditing, + onSubmitted: (columnName) { + context + .read() + .add(BoardEvent.renameGroup(widget.groupData.id, columnName)); + }, + ), + FieldType.Checkbox => CheckboxColumnHeader( + databaseController: widget.databaseController, + groupData: widget.groupData, + ), + _ => _DefaultColumnHeaderContent( + databaseController: widget.databaseController, + groupData: widget.groupData, + ), + }; + + return Container( + padding: widget.margin, + height: 50, + child: child, + ); + } +} + +class GroupOptionsButton extends StatelessWidget { + const GroupOptionsButton({ + super.key, + required this.groupData, + this.isEditing, + }); + + final AppFlowyGroupData groupData; + final ValueNotifier? isEditing; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + clickHandler: PopoverClickHandler.gestureDetector, + margin: const EdgeInsets.all(8), + constraints: BoxConstraints.loose(const Size(168, 300)), + direction: PopoverDirection.bottomWithLeftAligned, + child: FlowyIconButton( + width: 20, + icon: const FlowySvg(FlowySvgs.details_horizontal_s), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + ), + popupBuilder: (popoverContext) { + final customGroupData = groupData.customData as GroupData; + final isDefault = customGroupData.group.isDefault; + final menuItems = GroupOption.values.toList(); + if (!customGroupData.fieldType.canEditHeader || isDefault) { + menuItems.remove(GroupOption.rename); + } + if (!customGroupData.fieldType.canDeleteGroup || isDefault) { + menuItems.remove(GroupOption.delete); + } + return SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const VSpace(4), + children: [ + ...menuItems.map( + (action) => SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + leftIcon: FlowySvg(action.icon), + text: FlowyText( + action.text, + lineHeight: 1.0, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + run(context, action, customGroupData.group); + PopoverContainer.of(popoverContext).close(); + }, + ), + ), + ), + ], + ); + }, + ); + } + + void run(BuildContext context, GroupOption option, GroupPB group) { + switch (option) { + case GroupOption.rename: + isEditing?.value = true; + break; + case GroupOption.hide: + context + .read() + .add(BoardEvent.setGroupVisibility(group, false)); + break; + case GroupOption.delete: + showConfirmDeletionDialog( + context: context, + name: LocaleKeys.board_column_label.tr(), + description: LocaleKeys.board_column_deleteColumnConfirmation.tr(), + onConfirm: () { + context + .read() + .add(BoardEvent.deleteGroup(group.groupId)); + }, + ); + break; + } + } +} + +class CreateCardFromTopButton extends StatelessWidget { + const CreateCardFromTopButton({ + super.key, + required this.groupId, + }); + + final String groupId; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.board_column_addToColumnTopTooltip.tr(), + preferBelow: false, + child: FlowyIconButton( + width: 20, + icon: const FlowySvg(FlowySvgs.add_s), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + onPressed: () => context.read().add( + BoardEvent.createRow( + groupId, + OrderObjectPositionTypePB.Start, + null, + null, + ), + ), + ), + ); + } +} + +class _DefaultColumnHeaderContent extends StatelessWidget { + const _DefaultColumnHeaderContent({ + required this.databaseController, + required this.groupData, + }); + + final DatabaseController databaseController; + final AppFlowyGroupData groupData; + + @override + Widget build(BuildContext context) { + final customData = groupData.customData as GroupData; + final groupName = customData.group.generateGroupName(databaseController); + return Row( + children: [ + Expanded( + child: Align( + alignment: AlignmentDirectional.centerStart, + child: FlowyTooltip( + message: groupName, + child: FlowyText.medium( + groupName, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + const HSpace(6), + GroupOptionsButton( + groupData: groupData, + ), + const HSpace(4), + CreateCardFromTopButton( + groupId: groupData.id, + ), + ], + ); + } +} + +enum GroupOption { + rename, + hide, + delete; + + FlowySvgData get icon => switch (this) { + rename => FlowySvgs.edit_s, + hide => FlowySvgs.hide_s, + delete => FlowySvgs.delete_s, + }; + + String get text => switch (this) { + rename => LocaleKeys.board_column_renameColumn.tr(), + hide => LocaleKeys.board_column_hideColumn.tr(), + delete => LocaleKeys.board_column_deleteColumn.tr(), + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_editable_column_header.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_editable_column_header.dart new file mode 100644 index 0000000000000..e6ecca43bce73 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_editable_column_header.dart @@ -0,0 +1,262 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy/plugins/database/board/group_ext.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'board_column_header.dart'; + +/// This column header is used for the MultiSelect and SingleSelect field types. +class EditableColumnHeader extends StatefulWidget { + const EditableColumnHeader({ + super.key, + required this.databaseController, + required this.groupData, + required this.isEditing, + required this.onSubmitted, + }); + + final DatabaseController databaseController; + final AppFlowyGroupData groupData; + final ValueNotifier isEditing; + final void Function(String columnName) onSubmitted; + + @override + State createState() => _EditableColumnHeaderState(); +} + +class _EditableColumnHeaderState extends State { + late final FocusNode focusNode; + late final TextEditingController textController = TextEditingController( + text: _generateGroupName(), + ); + + GroupData get customData => widget.groupData.customData; + + @override + void initState() { + super.initState(); + focusNode = FocusNode( + onKeyEvent: (node, event) { + if (event.logicalKey == LogicalKeyboardKey.escape && + event is KeyUpEvent) { + focusNode.unfocus(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + )..addListener(onFocusChanged); + } + + @override + void didUpdateWidget(covariant oldWidget) { + if (oldWidget.groupData.customData != widget.groupData.customData) { + textController.text = _generateGroupName(); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + focusNode + ..removeListener(onFocusChanged) + ..dispose(); + textController.dispose(); + super.dispose(); + } + + void onFocusChanged() { + if (!focusNode.hasFocus) { + widget.isEditing.value = false; + widget.onSubmitted(textController.text); + } else { + textController.selection = TextSelection( + baseOffset: 0, + extentOffset: textController.text.length, + ); + } + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: ValueListenableBuilder( + valueListenable: widget.isEditing, + builder: (context, isEditing, _) { + if (isEditing) { + focusNode.requestFocus(); + } + return isEditing ? _buildTextField() : _buildTitle(); + }, + ), + ), + const HSpace(6), + GroupOptionsButton( + groupData: widget.groupData, + isEditing: widget.isEditing, + ), + const HSpace(4), + CreateCardFromTopButton( + groupId: widget.groupData.id, + ), + ], + ); + } + + Widget _buildTitle() { + final (backgroundColor, dotColor) = _generateGroupColor(); + final groupName = _generateGroupName(); + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + widget.isEditing.value = true; + }, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: FlowyTooltip( + message: groupName, + child: Container( + height: 20, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + height: 6, + width: 6, + decoration: BoxDecoration( + color: dotColor, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + const HSpace(4.0), + Flexible( + child: FlowyText.medium( + groupName, + overflow: TextOverflow.ellipsis, + lineHeight: 1.0, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildTextField() { + return TextField( + controller: textController, + focusNode: focusNode, + onEditingComplete: () { + widget.isEditing.value = false; + }, + onSubmitted: widget.onSubmitted, + style: Theme.of(context).textTheme.bodyMedium, + decoration: InputDecoration( + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + hoverColor: Colors.transparent, + contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + isDense: true, + ), + ); + } + + String _generateGroupName() { + return customData.group.generateGroupName(widget.databaseController); + } + + (Color? backgroundColor, Color? dotColor) _generateGroupColor() { + Color? backgroundColor; + Color? dotColor; + + final groupId = widget.groupData.id; + final fieldId = customData.fieldInfo.id; + final field = widget.databaseController.fieldController.getField(fieldId); + if (field != null) { + final selectOptions = switch (field.fieldType) { + FieldType.MultiSelect => MultiSelectTypeOptionDataParser() + .fromBuffer(field.field.typeOptionData) + .options, + FieldType.SingleSelect => SingleSelectTypeOptionDataParser() + .fromBuffer(field.field.typeOptionData) + .options, + _ => [], + }; + + final colorPB = + selectOptions.firstWhereOrNull((e) => e.id == groupId)?.color; + + if (colorPB != null) { + backgroundColor = colorPB.toColor(context); + dotColor = getColorOfDot(colorPB); + } + } + + return (backgroundColor, dotColor); + } + + // move to theme file and allow theme customization once palette is finalized + Color getColorOfDot(SelectOptionColorPB color) { + return switch (Theme.of(context).brightness) { + Brightness.light => switch (color) { + SelectOptionColorPB.Purple => const Color(0xFFAB8DFF), + SelectOptionColorPB.Pink => const Color(0xFFFF8EF5), + SelectOptionColorPB.LightPink => const Color(0xFFFF85A9), + SelectOptionColorPB.Orange => const Color(0xFFFFBC7E), + SelectOptionColorPB.Yellow => const Color(0xFFFCD86F), + SelectOptionColorPB.Lime => const Color(0xFFC6EC41), + SelectOptionColorPB.Green => const Color(0xFF74F37D), + SelectOptionColorPB.Aqua => const Color(0xFF40F0D1), + SelectOptionColorPB.Blue => const Color(0xFF00C8FF), + _ => throw ArgumentError, + }, + Brightness.dark => switch (color) { + SelectOptionColorPB.Purple => const Color(0xFF502FD6), + SelectOptionColorPB.Pink => const Color(0xFFBF1CC0), + SelectOptionColorPB.LightPink => const Color(0xFFC42A53), + SelectOptionColorPB.Orange => const Color(0xFFD77922), + SelectOptionColorPB.Yellow => const Color(0xFFC59A1A), + SelectOptionColorPB.Lime => const Color(0xFFA4C824), + SelectOptionColorPB.Green => const Color(0xFF23CA2E), + SelectOptionColorPB.Aqua => const Color(0xFF19CCAC), + SelectOptionColorPB.Blue => const Color(0xFF04A9D7), + _ => throw ArgumentError, + } + }; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_focus_scope.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_focus_scope.dart new file mode 100644 index 0000000000000..ed329904b95fe --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_focus_scope.dart @@ -0,0 +1,373 @@ +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; + +class BoardFocusScope extends ChangeNotifier + implements ValueListenable> { + BoardFocusScope({ + required this.boardController, + }); + + final AppFlowyBoardController boardController; + List _focusedCards = []; + + @override + List get value => _focusedCards; + + UnmodifiableListView get focusedGroupedRows => + UnmodifiableListView(_focusedCards); + + set focusedGroupedRows(List focusedGroupedRows) { + _deepCopy(); + _focusedCards + ..clear() + ..addAll(focusedGroupedRows); + notifyListeners(); + } + + bool isFocused(GroupedRowId groupedRowId) => + _focusedCards.contains(groupedRowId); + + void toggle(GroupedRowId groupedRowId) { + _deepCopy(); + if (_focusedCards.contains(groupedRowId)) { + _focusedCards.remove(groupedRowId); + } else { + _focusedCards.add(groupedRowId); + } + notifyListeners(); + } + + bool focusNext() { + _deepCopy(); + + // if no card is focused, focus on the first card in the board + if (_focusedCards.isEmpty) { + _focusFirstCard(); + notifyListeners(); + return true; + } + + final lastFocusedCard = _focusedCards.last; + final groupController = boardController.controller(lastFocusedCard.groupId); + final iterable = groupController?.items + .skipWhile((item) => item.id != lastFocusedCard.rowId); + + // if the last-focused card's group cannot be found, or if the last-focused card cannot be found in the group, focus on the first card in the board + if (iterable == null || iterable.isEmpty) { + _focusFirstCard(); + notifyListeners(); + return true; + } + + if (iterable.length == 1) { + // focus on the first card in the next group + final group = boardController.groupDatas + .skipWhile((item) => item.id != lastFocusedCard.groupId) + .skip(1) + .firstWhereOrNull((groupData) => groupData.items.isNotEmpty); + if (group != null) { + _focusedCards + ..clear() + ..add( + GroupedRowId( + rowId: group.items.first.id, + groupId: group.id, + ), + ); + } + } else { + // focus on the next card in the same group + _focusedCards + ..clear() + ..add( + GroupedRowId( + rowId: iterable.elementAt(1).id, + groupId: lastFocusedCard.groupId, + ), + ); + } + + notifyListeners(); + + return true; + } + + bool focusPrevious() { + _deepCopy(); + + // if no card is focused, focus on the last card in the board + if (_focusedCards.isEmpty) { + _focusLastCard(); + notifyListeners(); + return true; + } + + final lastFocusedCard = _focusedCards.last; + final groupController = boardController.controller(lastFocusedCard.groupId); + final iterable = groupController?.items.reversed + .skipWhile((item) => item.id != lastFocusedCard.rowId); + + // if the last-focused card's group cannot be found or if the last-focused card cannot be found in the group, focus on the last card in the board + if (iterable == null || iterable.isEmpty) { + _focusLastCard(); + notifyListeners(); + return true; + } + + if (iterable.length == 1) { + // focus on the last card in the previous group + final group = boardController.groupDatas.reversed + .skipWhile((item) => item.id != lastFocusedCard.groupId) + .skip(1) + .firstWhereOrNull((groupData) => groupData.items.isNotEmpty); + if (group != null) { + _focusedCards + ..clear() + ..add( + GroupedRowId( + rowId: group.items.last.id, + groupId: group.id, + ), + ); + } + } else { + // focus on the next card in the same group + _focusedCards + ..clear() + ..add( + GroupedRowId( + rowId: iterable.elementAt(1).id, + groupId: lastFocusedCard.groupId, + ), + ); + } + + notifyListeners(); + + return true; + } + + bool adjustRangeDown() { + _deepCopy(); + + // if no card is focused, focus on the first card in the board + if (_focusedCards.isEmpty) { + _focusFirstCard(); + notifyListeners(); + return true; + } + + final firstFocusedCard = _focusedCards.first; + final lastFocusedCard = _focusedCards.last; + + // determine whether to shrink or expand the selection + bool isExpand = false; + if (_focusedCards.length == 1) { + isExpand = true; + } else { + final firstGroupIndex = boardController.groupDatas + .indexWhere((element) => element.id == firstFocusedCard.groupId); + final lastGroupIndex = boardController.groupDatas + .indexWhere((element) => element.id == lastFocusedCard.groupId); + + if (firstGroupIndex == -1 || lastGroupIndex == -1) { + _focusFirstCard(); + notifyListeners(); + return true; + } + + if (firstGroupIndex < lastGroupIndex) { + isExpand = true; + } else if (firstGroupIndex > lastGroupIndex) { + isExpand = false; + } else { + final groupItems = + boardController.groupDatas.elementAt(firstGroupIndex).items; + final firstCardIndex = + groupItems.indexWhere((item) => item.id == firstFocusedCard.rowId); + final lastCardIndex = + groupItems.indexWhere((item) => item.id == lastFocusedCard.rowId); + + if (firstCardIndex == -1 || lastCardIndex == -1) { + _focusFirstCard(); + notifyListeners(); + return true; + } + + isExpand = firstCardIndex < lastCardIndex; + } + } + + if (isExpand) { + final groupController = + boardController.controller(lastFocusedCard.groupId); + + if (groupController == null) { + _focusFirstCard(); + notifyListeners(); + return true; + } + + final iterable = groupController.items + .skipWhile((item) => item.id != lastFocusedCard.rowId); + + if (iterable.length == 1) { + // focus on the first card in the next group + final group = boardController.groupDatas + .skipWhile((item) => item.id != lastFocusedCard.groupId) + .skip(1) + .firstWhereOrNull((groupData) => groupData.items.isNotEmpty); + if (group != null) { + _focusedCards.add( + GroupedRowId( + rowId: group.items.first.id, + groupId: group.id, + ), + ); + } + } else { + _focusedCards.add( + GroupedRowId( + rowId: iterable.elementAt(1).id, + groupId: lastFocusedCard.groupId, + ), + ); + } + } else { + _focusedCards.removeLast(); + } + + notifyListeners(); + return true; + } + + bool adjustRangeUp() { + _deepCopy(); + + // if no card is focused, focus on the first card in the board + if (_focusedCards.isEmpty) { + _focusLastCard(); + notifyListeners(); + return true; + } + + final firstFocusedCard = _focusedCards.first; + final lastFocusedCard = _focusedCards.last; + + // determine whether to shrink or expand the selection + bool isExpand = false; + if (_focusedCards.length == 1) { + isExpand = true; + } else { + final firstGroupIndex = boardController.groupDatas + .indexWhere((element) => element.id == firstFocusedCard.groupId); + final lastGroupIndex = boardController.groupDatas + .indexWhere((element) => element.id == lastFocusedCard.groupId); + + if (firstGroupIndex == -1 || lastGroupIndex == -1) { + _focusLastCard(); + notifyListeners(); + return true; + } + + if (firstGroupIndex < lastGroupIndex) { + isExpand = false; + } else if (firstGroupIndex > lastGroupIndex) { + isExpand = true; + } else { + final groupItems = + boardController.groupDatas.elementAt(firstGroupIndex).items; + final firstCardIndex = + groupItems.indexWhere((item) => item.id == firstFocusedCard.rowId); + final lastCardIndex = + groupItems.indexWhere((item) => item.id == lastFocusedCard.rowId); + + if (firstCardIndex == -1 || lastCardIndex == -1) { + _focusLastCard(); + notifyListeners(); + return true; + } + + isExpand = firstCardIndex > lastCardIndex; + } + } + + if (isExpand) { + final groupController = + boardController.controller(lastFocusedCard.groupId); + + if (groupController == null) { + _focusLastCard(); + notifyListeners(); + return true; + } + + final iterable = groupController.items.reversed + .skipWhile((item) => item.id != lastFocusedCard.rowId); + + if (iterable.length == 1) { + // focus on the last card in the previous group + final group = boardController.groupDatas.reversed + .skipWhile((item) => item.id != lastFocusedCard.groupId) + .skip(1) + .firstWhereOrNull((groupData) => groupData.items.isNotEmpty); + if (group != null) { + _focusedCards.add( + GroupedRowId( + rowId: group.items.last.id, + groupId: group.id, + ), + ); + } + } else { + _focusedCards.add( + GroupedRowId( + rowId: iterable.elementAt(1).id, + groupId: lastFocusedCard.groupId, + ), + ); + } + } else { + _focusedCards.removeLast(); + } + + notifyListeners(); + + return true; + } + + bool clear() { + _deepCopy(); + _focusedCards.clear(); + notifyListeners(); + return true; + } + + void _focusFirstCard() { + _focusedCards.clear(); + final firstGroup = boardController.groupDatas + .firstWhereOrNull((group) => group.items.isNotEmpty); + final firstCard = firstGroup?.items.firstOrNull; + if (firstCard != null) { + _focusedCards + .add(GroupedRowId(rowId: firstCard.id, groupId: firstGroup!.id)); + } + } + + void _focusLastCard() { + _focusedCards.clear(); + final lastGroup = boardController.groupDatas + .lastWhereOrNull((group) => group.items.isNotEmpty); + final lastCard = lastGroup?.items.lastOrNull; + if (lastCard != null) { + _focusedCards + .add(GroupedRowId(rowId: lastCard.id, groupId: lastGroup!.id)); + } + } + + void _deepCopy() { + _focusedCards = [..._focusedCards]; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart new file mode 100644 index 0000000000000..c2a90fb49e8f9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_hidden_groups.dart @@ -0,0 +1,498 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy/plugins/database/board/group_ext.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class HiddenGroupsColumn extends StatelessWidget { + const HiddenGroupsColumn({ + super.key, + required this.margin, + }); + + final EdgeInsets margin; + + @override + Widget build(BuildContext context) { + final databaseController = context.read().databaseController; + return BlocSelector( + selector: (state) => state.maybeMap( + orElse: () => null, + ready: (value) => value.layoutSettings, + ), + builder: (context, layoutSettings) { + if (layoutSettings == null) { + return const SizedBox.shrink(); + } + final isCollapsed = layoutSettings.collapseHiddenGroups; + final leftPadding = margin.left + + context.read().horizontalPadding; + return AnimatedSize( + alignment: AlignmentDirectional.topStart, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 150), + child: isCollapsed + ? SizedBox( + height: 50, + child: Padding( + padding: const EdgeInsets.only(left: 80, right: 8), + child: Center( + child: _collapseExpandIcon(context, isCollapsed), + ), + ), + ) + : Container( + width: 274, + padding: EdgeInsets.only( + left: leftPadding, + right: margin.right + 4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 50, + child: Row( + children: [ + Expanded( + child: FlowyText.medium( + LocaleKeys.board_hiddenGroupSection_sectionTitle + .tr(), + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, + ), + ), + _collapseExpandIcon(context, isCollapsed), + ], + ), + ), + Expanded( + child: HiddenGroupList( + databaseController: databaseController, + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _collapseExpandIcon(BuildContext context, bool isCollapsed) { + return FlowyTooltip( + message: isCollapsed + ? LocaleKeys.board_hiddenGroupSection_expandTooltip.tr() + : LocaleKeys.board_hiddenGroupSection_collapseTooltip.tr(), + preferBelow: false, + child: FlowyIconButton( + width: 20, + height: 20, + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + onPressed: () => context + .read() + .add(BoardEvent.toggleHiddenSectionVisibility(!isCollapsed)), + icon: FlowySvg( + isCollapsed + ? FlowySvgs.hamburger_s_s + : FlowySvgs.pull_left_outlined_s, + ), + ), + ); + } +} + +class HiddenGroupList extends StatelessWidget { + const HiddenGroupList({ + super.key, + required this.databaseController, + }); + + final DatabaseController databaseController; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state.maybeMap( + orElse: () => const SizedBox.shrink(), + ready: (state) => ReorderableListView.builder( + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: Stack( + children: [ + child, + MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: const SizedBox.expand(), + ), + ], + ), + ), + buildDefaultDragHandles: false, + itemCount: state.hiddenGroups.length, + itemBuilder: (_, index) => Padding( + padding: const EdgeInsets.only(bottom: 4), + key: ValueKey("hiddenGroup${state.hiddenGroups[index].groupId}"), + child: HiddenGroupCard( + group: state.hiddenGroups[index], + index: index, + bloc: context.read(), + ), + ), + onReorder: (oldIndex, newIndex) { + if (oldIndex < newIndex) { + newIndex--; + } + final fromGroupId = state.hiddenGroups[oldIndex].groupId; + final toGroupId = state.hiddenGroups[newIndex].groupId; + context + .read() + .add(BoardEvent.reorderGroup(fromGroupId, toGroupId)); + }, + ), + ); + }, + ); + } +} + +class HiddenGroupCard extends StatefulWidget { + const HiddenGroupCard({ + super.key, + required this.group, + required this.index, + required this.bloc, + }); + + final GroupPB group; + final BoardBloc bloc; + final int index; + + @override + State createState() => _HiddenGroupCardState(); +} + +class _HiddenGroupCardState extends State { + final PopoverController _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + final databaseController = widget.bloc.databaseController; + final primaryField = databaseController.fieldController.fieldInfos + .firstWhereOrNull((element) => element.isPrimary)!; + return AppFlowyPopover( + controller: _popoverController, + direction: PopoverDirection.bottomWithCenterAligned, + triggerActions: PopoverTriggerFlags.none, + constraints: const BoxConstraints(maxWidth: 234, maxHeight: 300), + popupBuilder: (popoverContext) { + return BlocProvider.value( + value: context.read(), + child: HiddenGroupPopupItemList( + viewId: databaseController.viewId, + groupId: widget.group.groupId, + primaryFieldId: primaryField.id, + rowCache: databaseController.rowCache, + ), + ); + }, + child: HiddenGroupButtonContent( + popoverController: _popoverController, + groupId: widget.group.groupId, + index: widget.index, + bloc: widget.bloc, + ), + ); + } +} + +class HiddenGroupButtonContent extends StatelessWidget { + const HiddenGroupButtonContent({ + super.key, + required this.popoverController, + required this.groupId, + required this.index, + required this.bloc, + }); + + final PopoverController popoverController; + final String groupId; + final int index; + final BoardBloc bloc; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: popoverController.show, + child: FlowyHover( + builder: (context, isHovering) { + return BlocProvider.value( + value: bloc, + child: BlocBuilder( + builder: (context, state) { + return state.maybeMap( + orElse: () => const SizedBox.shrink(), + ready: (state) { + final group = state.hiddenGroups.firstWhereOrNull( + (g) => g.groupId == groupId, + ); + if (group == null) { + return const SizedBox.shrink(); + } + + return SizedBox( + height: 32, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 3, + ), + child: Row( + children: [ + HiddenGroupCardActions( + isVisible: isHovering, + index: index, + ), + const HSpace(4), + Expanded( + child: Row( + children: [ + Flexible( + child: FlowyText( + group.generateGroupName( + bloc.databaseController, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(6), + FlowyText( + group.rows.length.toString(), + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, + ), + ], + ), + ), + if (isHovering) ...[ + const HSpace(6), + FlowyIconButton( + width: 20, + icon: const FlowySvg( + FlowySvgs.show_m, + size: Size.square(16), + ), + onPressed: () => + context.read().add( + BoardEvent.setGroupVisibility( + group, + true, + ), + ), + ), + ], + ], + ), + ), + ); + }, + ); + }, + ), + ); + }, + ), + ), + ); + } +} + +class HiddenGroupCardActions extends StatelessWidget { + const HiddenGroupCardActions({ + super.key, + required this.isVisible, + required this.index, + }); + + final bool isVisible; + final int index; + + @override + Widget build(BuildContext context) { + return ReorderableDragStartListener( + index: index, + enabled: isVisible, + child: MouseRegion( + cursor: SystemMouseCursors.grab, + child: SizedBox( + height: 14, + width: 14, + child: isVisible + ? FlowySvg( + FlowySvgs.drag_element_s, + color: Theme.of(context).hintColor, + ) + : const SizedBox.shrink(), + ), + ), + ); + } +} + +class HiddenGroupPopupItemList extends StatelessWidget { + const HiddenGroupPopupItemList({ + super.key, + required this.groupId, + required this.viewId, + required this.primaryFieldId, + required this.rowCache, + }); + + final String groupId; + final String viewId; + final String primaryFieldId; + final RowCache rowCache; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state.maybeMap( + orElse: () => const SizedBox.shrink(), + ready: (state) { + final group = state.hiddenGroups.firstWhereOrNull( + (g) => g.groupId == groupId, + ); + if (group == null) { + return const SizedBox.shrink(); + } + final bloc = context.read(); + final cells = [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: FlowyText( + group.generateGroupName(bloc.databaseController), + fontSize: 10, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ), + ...group.rows.map( + (item) { + final rowController = RowController( + rowMeta: item, + viewId: viewId, + rowCache: rowCache, + ); + rowController.initialize(); + + final databaseController = + context.read().databaseController; + + return HiddenGroupPopupItem( + cellContext: rowCache.loadCells(item).firstWhere( + (cellContext) => + cellContext.fieldId == primaryFieldId, + ), + rowController: rowController, + rowMeta: item, + cellBuilder: CardCellBuilder( + databaseController: databaseController, + ), + onPressed: () { + FlowyOverlay.show( + context: context, + builder: (_) => RowDetailPage( + databaseController: databaseController, + rowController: rowController, + userProfile: context.read().userProfile, + ), + ); + PopoverContainer.of(context).close(); + }, + ); + }, + ), + ]; + + return ListView.separated( + itemBuilder: (context, index) => cells[index], + itemCount: cells.length, + separatorBuilder: (context, index) => + VSpace(GridSize.typeOptionSeparatorHeight), + shrinkWrap: true, + ); + }, + ); + }, + ); + } +} + +class HiddenGroupPopupItem extends StatelessWidget { + const HiddenGroupPopupItem({ + super.key, + required this.rowMeta, + required this.cellContext, + required this.onPressed, + required this.cellBuilder, + required this.rowController, + }); + + final RowMetaPB rowMeta; + final CellContext cellContext; + final RowController rowController; + final CardCellBuilder cellBuilder; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 26, + child: FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + text: cellBuilder.build( + cellContext: cellContext, + styleMap: {FieldType.RichText: _titleCellStyle(context)}, + hasNotes: false, + ), + onTap: onPressed, + ), + ); + } + + TextCardCellStyle _titleCellStyle(BuildContext context) { + return TextCardCellStyle( + padding: EdgeInsets.zero, + textStyle: Theme.of(context).textTheme.bodyMedium!, + titleTextStyle: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(fontSize: 11, overflow: TextOverflow.ellipsis), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_shortcut_container.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_shortcut_container.dart new file mode 100644 index 0000000000000..c69269a1b7fea --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/widgets/board_shortcut_container.dart @@ -0,0 +1,177 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/board/application/board_actions_bloc.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy/plugins/shared/callback_shortcuts.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'board_focus_scope.dart'; + +class BoardShortcutContainer extends StatelessWidget { + const BoardShortcutContainer({ + super.key, + required this.focusScope, + required this.child, + }); + + final BoardFocusScope focusScope; + final Widget child; + + @override + Widget build(BuildContext context) { + return AFCallbackShortcuts( + bindings: _shortcutBindings(context), + child: FocusScope( + child: Focus( + child: Builder( + builder: (context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + final focusNode = Focus.of(context); + focusNode.requestFocus(); + focusScope.clear(); + }, + child: child, + ); + }, + ), + ), + ), + ); + } + + Map _shortcutBindings( + BuildContext context, + ) { + return { + const SingleActivator(LogicalKeyboardKey.arrowUp): + focusScope.focusPrevious, + const SingleActivator(LogicalKeyboardKey.arrowDown): focusScope.focusNext, + const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): + focusScope.adjustRangeUp, + const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): + focusScope.adjustRangeDown, + const SingleActivator(LogicalKeyboardKey.escape): focusScope.clear, + const SingleActivator(LogicalKeyboardKey.delete): () => + _removeHandler(context), + const SingleActivator(LogicalKeyboardKey.backspace): () => + _removeHandler(context), + SingleActivator( + LogicalKeyboardKey.arrowUp, + shift: true, + meta: Platform.isMacOS, + control: !Platform.isMacOS, + ): () => _shiftCmdUpHandler(context), + const SingleActivator(LogicalKeyboardKey.enter): () => + _enterHandler(context), + const SingleActivator(LogicalKeyboardKey.numpadEnter): () => + _enterHandler(context), + const SingleActivator(LogicalKeyboardKey.enter, shift: true): () => + _shiftEnterHandler(context), + const SingleActivator(LogicalKeyboardKey.comma): () => + _moveGroupToAdjacentGroup(context, true), + const SingleActivator(LogicalKeyboardKey.period): () => + _moveGroupToAdjacentGroup(context, false), + const SingleActivator(LogicalKeyboardKey.keyE): () => + _keyEHandler(context), + const SingleActivator(LogicalKeyboardKey.keyN): () => + _keyNHandler(context), + }; + } + + bool _keyEHandler(BuildContext context) { + if (focusScope.value.length != 1) { + return false; + } + context.read().startEditingRow(focusScope.value.first); + return true; + } + + bool _keyNHandler(BuildContext context) { + if (focusScope.value.length != 1) { + return false; + } + context + .read() + .startCreateBottomRow(focusScope.value.first.groupId); + focusScope.clear(); + return true; + } + + bool _enterHandler(BuildContext context) { + if (focusScope.value.length != 1) { + return false; + } + context + .read() + .openCardWithRowId(focusScope.value.first.rowId); + return true; + } + + bool _shiftEnterHandler(BuildContext context) { + if (focusScope.value.isEmpty) { + context + .read() + .createRow(null, CreateBoardCardRelativePosition.after); + } else if (focusScope.value.length == 1) { + context.read().createRow( + focusScope.value.first, + CreateBoardCardRelativePosition.after, + ); + } else { + return false; + } + return true; + } + + bool _shiftCmdUpHandler(BuildContext context) { + if (focusScope.value.isEmpty) { + context + .read() + .createRow(null, CreateBoardCardRelativePosition.before); + } else if (focusScope.value.length == 1) { + context.read().createRow( + focusScope.value.first, + CreateBoardCardRelativePosition.before, + ); + } else { + return false; + } + return true; + } + + bool _removeHandler(BuildContext context) { + if (focusScope.value.length != 1) { + return false; + } + + NavigatorOkCancelDialog( + message: LocaleKeys.grid_row_deleteCardPrompt.tr(), + onOkPressed: () { + context.read().add(BoardEvent.deleteCards(focusScope.value)); + }, + ).show(context); + + return true; + } + + bool _moveGroupToAdjacentGroup(BuildContext context, bool toPrevious) { + if (focusScope.value.length != 1) { + return false; + } + context.read().add( + BoardEvent.moveGroupToAdjacentGroup( + focusScope.value.first, + toPrevious, + ), + ); + focusScope.clear(); + return true; + } +} diff --git a/frontend/app_flowy/lib/plugins/board/tests/integrate_test/card_test.dart b/frontend/appflowy_flutter/lib/plugins/database/board/tests/integrate_test/card_test.dart similarity index 89% rename from frontend/app_flowy/lib/plugins/board/tests/integrate_test/card_test.dart rename to frontend/appflowy_flutter/lib/plugins/database/board/tests/integrate_test/card_test.dart index fa267744c73b4..29308d84dc911 100644 --- a/frontend/app_flowy/lib/plugins/board/tests/integrate_test/card_test.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/tests/integrate_test/card_test.dart @@ -1,6 +1,6 @@ // import 'package:flutter_test/flutter_test.dart'; // import 'package:integration_test/integration_test.dart'; -// import 'package:app_flowy/main.dart' as app; +// import 'package:appflowy/main.dart' as app; // void main() { // IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart new file mode 100644 index 0000000000000..45157b1a4742e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_bloc.dart @@ -0,0 +1,531 @@ +import 'package:appflowy/plugins/database/application/cell/cell_cache.dart'; +import 'package:appflowy/plugins/database/application/defines.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../application/database_controller.dart'; +import '../../application/row/row_cache.dart'; + +part 'calendar_bloc.freezed.dart'; + +class CalendarBloc extends Bloc { + CalendarBloc({required this.databaseController}) + : super(CalendarState.initial()) { + _dispatch(); + } + + final DatabaseController databaseController; + Map fieldInfoByFieldId = {}; + + // Getters + String get viewId => databaseController.viewId; + FieldController get fieldController => databaseController.fieldController; + CellMemCache get cellCache => databaseController.rowCache.cellCache; + RowCache get rowCache => databaseController.rowCache; + + UserProfilePB? _userProfile; + UserProfilePB? get userProfile => _userProfile; + + DatabaseCallbacks? _databaseCallbacks; + DatabaseLayoutSettingCallbacks? _layoutSettingCallbacks; + + @override + Future close() async { + databaseController.removeListener( + onDatabaseChanged: _databaseCallbacks, + onLayoutSettingsChanged: _layoutSettingCallbacks, + ); + _databaseCallbacks = null; + _layoutSettingCallbacks = null; + await super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () async { + final result = await UserEventGetUserProfile().send(); + result.fold( + (profile) => _userProfile = profile, + (err) => Log.error('Failed to get user profile: $err'), + ); + + _startListening(); + await _openDatabase(emit); + _loadAllEvents(); + }, + didReceiveCalendarSettings: (CalendarLayoutSettingPB settings) { + // If the field id changed, reload all events + if (state.settings?.fieldId != settings.fieldId) { + _loadAllEvents(); + } + emit(state.copyWith(settings: settings)); + }, + didReceiveDatabaseUpdate: (DatabasePB database) { + emit(state.copyWith(database: database)); + }, + didLoadAllEvents: (events) { + final calenderEvents = _calendarEventDataFromEventPBs(events); + emit( + state.copyWith( + initialEvents: calenderEvents, + allEvents: calenderEvents, + ), + ); + }, + createEvent: (DateTime date) async { + await _createEvent(date); + }, + duplicateEvent: (String viewId, String rowId) async { + final result = await RowBackendService.duplicateRow(viewId, rowId); + result.fold( + (_) => null, + (e) => Log.error('Failed to duplicate event: $e', e), + ); + }, + deleteEvent: (String viewId, String rowId) async { + final result = await RowBackendService.deleteRows(viewId, [rowId]); + result.fold( + (_) => null, + (e) => Log.error('Failed to delete event: $e', e), + ); + }, + newEventPopupDisplayed: () { + emit(state.copyWith(editingEvent: null)); + }, + moveEvent: (CalendarDayEvent event, DateTime date) async { + await _moveEvent(event, date); + }, + didCreateEvent: (CalendarEventData event) { + emit(state.copyWith(editingEvent: event)); + }, + updateCalendarLayoutSetting: + (CalendarLayoutSettingPB layoutSetting) async { + await _updateCalendarLayoutSetting(layoutSetting); + }, + didUpdateEvent: (CalendarEventData eventData) { + final allEvents = [...state.allEvents]; + final index = allEvents.indexWhere( + (element) => element.event!.eventId == eventData.event!.eventId, + ); + if (index != -1) { + allEvents[index] = eventData; + } + emit(state.copyWith(allEvents: allEvents, updateEvent: eventData)); + }, + didDeleteEvents: (List deletedRowIds) { + final events = [...state.allEvents]; + events.retainWhere( + (element) => !deletedRowIds.contains(element.event!.eventId), + ); + emit( + state.copyWith( + allEvents: events, + deleteEventIds: deletedRowIds, + ), + ); + emit(state.copyWith(deleteEventIds: const [])); + }, + didReceiveEvent: (CalendarEventData event) { + emit( + state.copyWith( + allEvents: [...state.allEvents, event], + newEvent: event, + ), + ); + emit(state.copyWith(newEvent: null)); + }, + openRowDetail: (row) { + emit(state.copyWith(openRow: row)); + emit(state.copyWith(openRow: null)); + }, + ); + }, + ); + } + + FieldInfo? _getCalendarFieldInfo(String fieldId) { + final fieldInfos = databaseController.fieldController.fieldInfos; + final index = fieldInfos.indexWhere( + (element) => element.field.id == fieldId, + ); + if (index != -1) { + return fieldInfos[index]; + } else { + return null; + } + } + + Future _openDatabase(Emitter emit) async { + final result = await databaseController.open(); + result.fold( + (database) { + databaseController.setIsLoading(false); + emit( + state.copyWith( + loadingState: LoadingState.finish(FlowyResult.success(null)), + ), + ); + }, + (err) => emit( + state.copyWith( + loadingState: LoadingState.finish(FlowyResult.failure(err)), + ), + ), + ); + } + + Future _createEvent(DateTime date) async { + final settings = state.settings; + if (settings == null) { + Log.warn('Calendar settings not found'); + return; + } + final dateField = _getCalendarFieldInfo(settings.fieldId); + if (dateField != null) { + final newRow = await RowBackendService.createRow( + viewId: viewId, + withCells: (builder) => builder.insertDate(dateField, date), + ).then( + (result) => result.fold( + (newRow) => newRow, + (err) { + Log.error(err); + return null; + }, + ), + ); + + if (newRow != null) { + final event = await _loadEvent(newRow.id); + if (event != null && !isClosed) { + add(CalendarEvent.didCreateEvent(event)); + } + } + } + } + + Future _moveEvent(CalendarDayEvent event, DateTime date) async { + final timestamp = _eventTimestamp(event, date); + final payload = MoveCalendarEventPB( + cellPath: CellIdPB( + viewId: viewId, + rowId: event.eventId, + fieldId: event.dateFieldId, + ), + timestamp: timestamp, + ); + return DatabaseEventMoveCalendarEvent(payload).send().then((result) { + return result.fold( + (_) async { + final modifiedEvent = await _loadEvent(event.eventId); + add(CalendarEvent.didUpdateEvent(modifiedEvent!)); + }, + (err) { + Log.error(err); + return null; + }, + ); + }); + } + + Future _updateCalendarLayoutSetting( + CalendarLayoutSettingPB layoutSetting, + ) async { + return databaseController.updateLayoutSetting( + calendarLayoutSetting: layoutSetting, + ); + } + + Future?> _loadEvent(RowId rowId) async { + final payload = DatabaseViewRowIdPB(viewId: viewId, rowId: rowId); + return DatabaseEventGetCalendarEvent(payload).send().fold( + (eventPB) => _calendarEventDataFromEventPB(eventPB), + (r) { + Log.error(r); + return null; + }, + ); + } + + void _loadAllEvents() async { + final payload = CalendarEventRequestPB.create()..viewId = viewId; + final result = await DatabaseEventGetAllCalendarEvents(payload).send(); + result.fold( + (events) { + if (!isClosed) { + add(CalendarEvent.didLoadAllEvents(events.items)); + } + }, + (r) => Log.error(r), + ); + } + + List> _calendarEventDataFromEventPBs( + List eventPBs, + ) { + final calendarEvents = >[]; + for (final eventPB in eventPBs) { + final event = _calendarEventDataFromEventPB(eventPB); + if (event != null) { + calendarEvents.add(event); + } + } + return calendarEvents; + } + + CalendarEventData? _calendarEventDataFromEventPB( + CalendarEventPB eventPB, + ) { + final fieldInfo = fieldInfoByFieldId[eventPB.dateFieldId]; + if (fieldInfo == null) { + return null; + } + + // timestamp is stored as seconds, but constructor requires milliseconds + final date = DateTime.fromMillisecondsSinceEpoch( + eventPB.timestamp.toInt() * 1000, + ); + + final eventData = CalendarDayEvent( + event: eventPB, + eventId: eventPB.rowMeta.id, + dateFieldId: eventPB.dateFieldId, + date: date, + ); + + return CalendarEventData( + title: eventPB.title, + date: date, + event: eventData, + ); + } + + void _startListening() { + _databaseCallbacks = DatabaseCallbacks( + onDatabaseChanged: (database) { + if (isClosed) return; + }, + onFieldsChanged: (fieldInfos) { + if (isClosed) { + return; + } + fieldInfoByFieldId = { + for (final fieldInfo in fieldInfos) fieldInfo.field.id: fieldInfo, + }; + }, + onRowsCreated: (rows) async { + if (isClosed) { + return; + } + for (final row in rows) { + if (row.isHiddenInView) { + add(CalendarEvent.openRowDetail(row.rowMeta)); + } else { + final event = await _loadEvent(row.rowMeta.id); + if (event != null) { + add(CalendarEvent.didReceiveEvent(event)); + } + } + } + }, + onRowsDeleted: (rowIds) { + if (isClosed) { + return; + } + add(CalendarEvent.didDeleteEvents(rowIds)); + }, + onRowsUpdated: (rowIds, reason) async { + if (isClosed) { + return; + } + for (final id in rowIds) { + final event = await _loadEvent(id); + if (event != null) { + if (isEventDayChanged(event)) { + add(CalendarEvent.didDeleteEvents([id])); + add(CalendarEvent.didReceiveEvent(event)); + } else { + add(CalendarEvent.didUpdateEvent(event)); + } + } + } + }, + onNumOfRowsChanged: (rows, rowById, reason) { + reason.maybeWhen( + updateRowsVisibility: (changeset) async { + if (isClosed) { + return; + } + for (final id in changeset.invisibleRows) { + if (_containsEvent(id)) { + add(CalendarEvent.didDeleteEvents([id])); + } + } + for (final row in changeset.visibleRows) { + final id = row.rowMeta.id; + if (!_containsEvent(id)) { + final event = await _loadEvent(id); + if (event != null) { + add(CalendarEvent.didReceiveEvent(event)); + } + } + } + }, + orElse: () {}, + ); + }, + ); + + _layoutSettingCallbacks = DatabaseLayoutSettingCallbacks( + onLayoutSettingsChanged: _didReceiveLayoutSetting, + ); + + databaseController.addListener( + onDatabaseChanged: _databaseCallbacks, + onLayoutSettingsChanged: _layoutSettingCallbacks, + ); + } + + void _didReceiveLayoutSetting(DatabaseLayoutSettingPB layoutSetting) { + if (layoutSetting.hasCalendar()) { + if (isClosed) { + return; + } + add(CalendarEvent.didReceiveCalendarSettings(layoutSetting.calendar)); + } + } + + bool isEventDayChanged(CalendarEventData event) { + final index = state.allEvents.indexWhere( + (element) => element.event!.eventId == event.event!.eventId, + ); + if (index == -1) { + return false; + } + return state.allEvents[index].date.day != event.date.day; + } + + bool _containsEvent(String rowId) { + return state.allEvents.any((element) => element.event!.eventId == rowId); + } + + Int64 _eventTimestamp(CalendarDayEvent event, DateTime date) { + final time = + event.date.hour * 3600 + event.date.minute * 60 + event.date.second; + return Int64(date.millisecondsSinceEpoch ~/ 1000 + time); + } +} + +typedef Events = List>; + +@freezed +class CalendarEvent with _$CalendarEvent { + const factory CalendarEvent.initial() = _InitialCalendar; + + // Called after loading the calendar layout setting from the backend + const factory CalendarEvent.didReceiveCalendarSettings( + CalendarLayoutSettingPB settings, + ) = _ReceiveCalendarSettings; + + // Called after loading all the current evnets + const factory CalendarEvent.didLoadAllEvents(List events) = + _ReceiveCalendarEvents; + + // Called when specific event was updated + const factory CalendarEvent.didUpdateEvent( + CalendarEventData event, + ) = _DidUpdateEvent; + + // Called after creating a new event + const factory CalendarEvent.didCreateEvent( + CalendarEventData event, + ) = _DidReceiveNewEvent; + + // Called after creating a new event + const factory CalendarEvent.newEventPopupDisplayed() = + _NewEventPopupDisplayed; + + // Called when receive a new event + const factory CalendarEvent.didReceiveEvent( + CalendarEventData event, + ) = _DidReceiveEvent; + + // Called when deleting events + const factory CalendarEvent.didDeleteEvents(List rowIds) = + _DidDeleteEvents; + + // Called when creating a new event + const factory CalendarEvent.createEvent(DateTime date) = _CreateEvent; + + // Called when moving an event + const factory CalendarEvent.moveEvent(CalendarDayEvent event, DateTime date) = + _MoveEvent; + + // Called when updating the calendar's layout settings + const factory CalendarEvent.updateCalendarLayoutSetting( + CalendarLayoutSettingPB layoutSetting, + ) = _UpdateCalendarLayoutSetting; + + const factory CalendarEvent.didReceiveDatabaseUpdate(DatabasePB database) = + _ReceiveDatabaseUpdate; + + const factory CalendarEvent.duplicateEvent(String viewId, String rowId) = + _DuplicateEvent; + + const factory CalendarEvent.deleteEvent(String viewId, String rowId) = + _DeleteEvent; + + const factory CalendarEvent.openRowDetail(RowMetaPB row) = _OpenRowDetail; +} + +@freezed +class CalendarState with _$CalendarState { + const factory CalendarState({ + required DatabasePB? database, + // events by row id + required Events allEvents, + required Events initialEvents, + CalendarEventData? editingEvent, + CalendarEventData? newEvent, + CalendarEventData? updateEvent, + required List deleteEventIds, + required CalendarLayoutSettingPB? settings, + required RowMetaPB? openRow, + required LoadingState loadingState, + required FlowyError? noneOrError, + }) = _CalendarState; + + factory CalendarState.initial() => const CalendarState( + database: null, + allEvents: [], + initialEvents: [], + deleteEventIds: [], + settings: null, + openRow: null, + noneOrError: null, + loadingState: LoadingState.loading(), + ); +} + +@freezed +class CalendarDayEvent with _$CalendarDayEvent { + const factory CalendarDayEvent({ + required CalendarEventPB event, + required String dateFieldId, + required String eventId, + required DateTime date, + }) = _CalendarDayEvent; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_event_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_event_editor_bloc.dart new file mode 100644 index 0000000000000..48e159475c4c8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_event_editor_bloc.dart @@ -0,0 +1,115 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/calendar_entities.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'calendar_event_editor_bloc.freezed.dart'; + +class CalendarEventEditorBloc + extends Bloc { + CalendarEventEditorBloc({ + required this.fieldController, + required this.rowController, + required this.layoutSettings, + }) : super(CalendarEventEditorState.initial()) { + _dispatch(); + } + + final FieldController fieldController; + final RowController rowController; + final CalendarLayoutSettingPB layoutSettings; + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () { + rowController.initialize(); + + _startListening(); + final primaryFieldId = fieldController.fieldInfos + .firstWhere((fieldInfo) => fieldInfo.isPrimary) + .id; + final cells = rowController + .loadCells() + .where( + (cellContext) => + _filterCellContext(cellContext, primaryFieldId), + ) + .toList(); + add(CalendarEventEditorEvent.didReceiveCellDatas(cells)); + }, + didReceiveCellDatas: (cells) { + emit(state.copyWith(cells: cells)); + }, + delete: () async { + final result = await RowBackendService.deleteRows( + rowController.viewId, + [rowController.rowId], + ); + result.fold((l) => null, (err) => Log.error(err)); + }, + ); + }, + ); + } + + void _startListening() { + rowController.addListener( + onRowChanged: (cells, reason) { + if (isClosed) { + return; + } + final primaryFieldId = fieldController.fieldInfos + .firstWhere((fieldInfo) => fieldInfo.isPrimary) + .id; + final cellData = cells + .where( + (cellContext) => _filterCellContext(cellContext, primaryFieldId), + ) + .toList(); + add(CalendarEventEditorEvent.didReceiveCellDatas(cellData)); + }, + ); + } + + bool _filterCellContext(CellContext cellContext, String primaryFieldId) { + return fieldController + .getField(cellContext.fieldId)! + .fieldSettings! + .visibility + .isVisibleState() || + cellContext.fieldId == layoutSettings.fieldId || + cellContext.fieldId == primaryFieldId; + } + + @override + Future close() async { + await rowController.dispose(); + return super.close(); + } +} + +@freezed +class CalendarEventEditorEvent with _$CalendarEventEditorEvent { + const factory CalendarEventEditorEvent.initial() = _Initial; + const factory CalendarEventEditorEvent.didReceiveCellDatas( + List cells, + ) = _DidReceiveCellDatas; + const factory CalendarEventEditorEvent.delete() = _Delete; +} + +@freezed +class CalendarEventEditorState with _$CalendarEventEditorState { + const factory CalendarEventEditorState({ + required List cells, + }) = _CalendarEventEditorState; + + factory CalendarEventEditorState.initial() => + const CalendarEventEditorState(cells: []); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_setting_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_setting_bloc.dart new file mode 100644 index 0000000000000..c1288b157dc05 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/calendar_setting_bloc.dart @@ -0,0 +1,142 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/domain/layout_setting_listener.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'calendar_setting_bloc.freezed.dart'; + +class CalendarSettingBloc + extends Bloc { + CalendarSettingBloc({required DatabaseController databaseController}) + : _databaseController = databaseController, + _listener = DatabaseLayoutSettingListener(databaseController.viewId), + super( + CalendarSettingState.initial( + databaseController.databaseLayoutSetting?.calendar, + ), + ) { + _dispatch(); + } + + final DatabaseController _databaseController; + final DatabaseLayoutSettingListener _listener; + + @override + Future close() async { + await _listener.stop(); + return super.close(); + } + + void _dispatch() { + on((event, emit) { + event.when( + initial: () { + _startListening(); + }, + didUpdateLayoutSetting: (CalendarLayoutSettingPB setting) { + emit(state.copyWith(layoutSetting: layoutSetting)); + }, + updateLayoutSetting: ( + bool? showWeekends, + bool? showWeekNumbers, + int? firstDayOfWeek, + String? layoutFieldId, + ) { + _updateLayoutSettings( + showWeekends, + showWeekNumbers, + firstDayOfWeek, + layoutFieldId, + emit, + ); + }, + ); + }); + } + + void _updateLayoutSettings( + bool? showWeekends, + bool? showWeekNumbers, + int? firstDayOfWeek, + String? layoutFieldId, + Emitter emit, + ) { + final currentSetting = state.layoutSetting; + if (currentSetting == null) { + return; + } + currentSetting.freeze(); + final newSetting = currentSetting.rebuild((setting) { + if (showWeekends != null) { + setting.showWeekends = !showWeekends; + } + + if (showWeekNumbers != null) { + setting.showWeekNumbers = !showWeekNumbers; + } + + if (firstDayOfWeek != null) { + setting.firstDayOfWeek = firstDayOfWeek; + } + + if (layoutFieldId != null) { + setting.fieldId = layoutFieldId; + } + }); + + _databaseController.updateLayoutSetting( + calendarLayoutSetting: newSetting, + ); + emit(state.copyWith(layoutSetting: newSetting)); + } + + CalendarLayoutSettingPB? get layoutSetting => + _databaseController.databaseLayoutSetting?.calendar; + + void _startListening() { + _listener.start( + onLayoutChanged: (result) { + if (isClosed) { + return; + } + + result.fold( + (setting) => add( + CalendarSettingEvent.didUpdateLayoutSetting(setting.calendar), + ), + (r) => Log.error(r), + ); + }, + ); + } +} + +@freezed +class CalendarSettingState with _$CalendarSettingState { + const factory CalendarSettingState({ + required CalendarLayoutSettingPB? layoutSetting, + }) = _CalendarSettingState; + + factory CalendarSettingState.initial( + CalendarLayoutSettingPB? layoutSettings, + ) { + return CalendarSettingState(layoutSetting: layoutSettings); + } +} + +@freezed +class CalendarSettingEvent with _$CalendarSettingEvent { + const factory CalendarSettingEvent.initial() = _Initial; + const factory CalendarSettingEvent.didUpdateLayoutSetting( + CalendarLayoutSettingPB setting, + ) = _DidUpdateLayoutSetting; + const factory CalendarSettingEvent.updateLayoutSetting({ + bool? showWeekends, + bool? showWeekNumbers, + int? firstDayOfWeek, + String? layoutFieldId, + }) = _UpdateLayoutSetting; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/application/unschedule_event_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/unschedule_event_bloc.dart new file mode 100644 index 0000000000000..1e67ad9e9d67b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/application/unschedule_event_bloc.dart @@ -0,0 +1,181 @@ +import 'package:appflowy/plugins/database/application/cell/cell_cache.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../application/database_controller.dart'; +import '../../application/row/row_cache.dart'; + +part 'unschedule_event_bloc.freezed.dart'; + +class UnscheduleEventsBloc + extends Bloc { + UnscheduleEventsBloc({required this.databaseController}) + : super(UnscheduleEventsState.initial()) { + _dispatch(); + } + + final DatabaseController databaseController; + Map fieldInfoByFieldId = {}; + + // Getters + String get viewId => databaseController.viewId; + FieldController get fieldController => databaseController.fieldController; + CellMemCache get cellCache => databaseController.rowCache.cellCache; + RowCache get rowCache => databaseController.rowCache; + + DatabaseCallbacks? _databaseCallbacks; + + @override + Future close() async { + databaseController.removeListener(onDatabaseChanged: _databaseCallbacks); + _databaseCallbacks = null; + await super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () async { + _startListening(); + _loadAllEvents(); + }, + didLoadAllEvents: (events) { + emit( + state.copyWith( + allEvents: events, + unscheduleEvents: + events.where((element) => !element.hasTimestamp()).toList(), + ), + ); + }, + didDeleteEvents: (List deletedRowIds) { + final events = [...state.allEvents]; + events.retainWhere( + (element) => !deletedRowIds.contains(element.rowMeta.id), + ); + emit( + state.copyWith( + allEvents: events, + unscheduleEvents: + events.where((element) => !element.hasTimestamp()).toList(), + ), + ); + }, + didReceiveEvent: (CalendarEventPB event) { + final events = [...state.allEvents, event]; + emit( + state.copyWith( + allEvents: events, + unscheduleEvents: + events.where((element) => !element.hasTimestamp()).toList(), + ), + ); + }, + ); + }, + ); + } + + Future _loadEvent( + RowId rowId, + ) async { + final payload = DatabaseViewRowIdPB(viewId: viewId, rowId: rowId); + return DatabaseEventGetCalendarEvent(payload).send().then( + (result) => result.fold( + (eventPB) => eventPB, + (r) { + Log.error(r); + return null; + }, + ), + ); + } + + void _loadAllEvents() async { + final payload = CalendarEventRequestPB.create()..viewId = viewId; + final result = await DatabaseEventGetAllCalendarEvents(payload).send(); + result.fold( + (events) { + if (!isClosed) { + add(UnscheduleEventsEvent.didLoadAllEvents(events.items)); + } + }, + (r) => Log.error(r), + ); + } + + void _startListening() { + _databaseCallbacks = DatabaseCallbacks( + onRowsCreated: (rows) async { + if (isClosed) { + return; + } + for (final row in rows) { + final event = await _loadEvent(row.rowMeta.id); + if (event != null && !isClosed) { + add(UnscheduleEventsEvent.didReceiveEvent(event)); + } + } + }, + onRowsDeleted: (rowIds) { + if (isClosed) { + return; + } + add(UnscheduleEventsEvent.didDeleteEvents(rowIds)); + }, + onRowsUpdated: (rowIds, reason) async { + if (isClosed) { + return; + } + for (final id in rowIds) { + final event = await _loadEvent(id); + if (event != null) { + add(UnscheduleEventsEvent.didDeleteEvents([id])); + add(UnscheduleEventsEvent.didReceiveEvent(event)); + } + } + }, + ); + + databaseController.addListener(onDatabaseChanged: _databaseCallbacks); + } +} + +@freezed +class UnscheduleEventsEvent with _$UnscheduleEventsEvent { + const factory UnscheduleEventsEvent.initial() = _InitialCalendar; + + // Called after loading all the current evnets + const factory UnscheduleEventsEvent.didLoadAllEvents( + List events, + ) = _ReceiveUnscheduleEventsEvents; + + const factory UnscheduleEventsEvent.didDeleteEvents(List rowIds) = + _DidDeleteEvents; + + const factory UnscheduleEventsEvent.didReceiveEvent( + CalendarEventPB event, + ) = _DidReceiveEvent; +} + +@freezed +class UnscheduleEventsState with _$UnscheduleEventsState { + const factory UnscheduleEventsState({ + required DatabasePB? database, + required List allEvents, + required List unscheduleEvents, + }) = _UnscheduleEventsState; + + factory UnscheduleEventsState.initial() => const UnscheduleEventsState( + database: null, + allEvents: [], + unscheduleEvents: [], + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/calendar.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/calendar.dart new file mode 100644 index 0000000000000..54667d5f695d2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/calendar.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class CalendarPluginBuilder extends PluginBuilder { + @override + Plugin build(dynamic data) { + if (data is ViewPB) { + return DatabaseTabBarViewPlugin(pluginType: pluginType, view: data); + } else { + throw FlowyPluginException.invalidData; + } + } + + @override + String get menuName => LocaleKeys.calendar_menuName.tr(); + + @override + FlowySvgData get icon => FlowySvgs.icon_calendar_s; + + @override + PluginType get pluginType => PluginType.calendar; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Calendar; +} + +class CalendarPluginConfig implements PluginConfig { + @override + bool get creatable => true; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_day.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_day.dart new file mode 100644 index 0000000000000..de5648291e751 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_day.dart @@ -0,0 +1,399 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/database/mobile_calendar_events_screen.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra/time/duration.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../grid/presentation/layout/sizes.dart'; +import '../application/calendar_bloc.dart'; +import 'calendar_event_card.dart'; + +class CalendarDayCard extends StatelessWidget { + const CalendarDayCard({ + super.key, + required this.viewId, + required this.isToday, + required this.isInMonth, + required this.date, + required this.rowCache, + required this.events, + required this.onCreateEvent, + required this.position, + }); + + final String viewId; + final bool isToday; + final bool isInMonth; + final DateTime date; + final RowCache rowCache; + final List events; + final void Function(DateTime) onCreateEvent; + final CellPosition position; + + @override + Widget build(BuildContext context) { + final hoverBackgroundColor = + Theme.of(context).brightness == Brightness.light + ? Theme.of(context).colorScheme.secondaryContainer + : Colors.transparent; + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return ChangeNotifierProvider( + create: (_) => _CardEnterNotifier(), + builder: (context, child) { + final child = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _Header( + date: date, + isInMonth: isInMonth, + isToday: isToday, + ), + + // Add a separator between the header and the content. + const VSpace(6.0), + + // List of cards or empty space + if (events.isNotEmpty && !UniversalPlatform.isMobile) ...[ + _EventList( + events: events, + viewId: viewId, + rowCache: rowCache, + constraints: constraints, + ), + ] else if (events.isNotEmpty && UniversalPlatform.isMobile) ...[ + const _EventIndicator(), + ], + ], + ); + + return Stack( + children: [ + GestureDetector( + onDoubleTap: () => onCreateEvent(date), + onTap: UniversalPlatform.isMobile + ? () => _mobileOnTap(context) + : null, + child: Container( + decoration: BoxDecoration( + color: date.isWeekend + ? AFThemeExtension.of(context).calendarWeekendBGColor + : Colors.transparent, + border: _borderFromPosition(context, position), + ), + ), + ), + DragTarget( + builder: (context, candidate, __) { + return Stack( + children: [ + Container( + width: double.infinity, + height: double.infinity, + color: + candidate.isEmpty ? null : hoverBackgroundColor, + padding: const EdgeInsets.only(top: 5.0), + child: child, + ), + if (candidate.isEmpty && !UniversalPlatform.isMobile) + NewEventButton( + onCreate: () => onCreateEvent(date), + ), + ], + ); + }, + onAcceptWithDetails: (details) { + final event = details.data; + if (event.date != date) { + context + .read() + .add(CalendarEvent.moveEvent(event, date)); + } + }, + ), + MouseRegion( + onEnter: (p) => notifyEnter(context, true), + onExit: (p) => notifyEnter(context, false), + opaque: false, + hitTestBehavior: HitTestBehavior.translucent, + ), + ], + ); + }, + ); + }, + ); + } + + void _mobileOnTap(BuildContext context) { + context.push( + MobileCalendarEventsScreen.routeName, + extra: { + MobileCalendarEventsScreen.calendarBlocKey: + context.read(), + MobileCalendarEventsScreen.calendarDateKey: date, + MobileCalendarEventsScreen.calendarEventsKey: events, + MobileCalendarEventsScreen.calendarRowCacheKey: rowCache, + MobileCalendarEventsScreen.calendarViewIdKey: viewId, + }, + ); + } + + bool notifyEnter(BuildContext context, bool isEnter) => + Provider.of<_CardEnterNotifier>(context, listen: false).onEnter = isEnter; + + Border _borderFromPosition(BuildContext context, CellPosition position) { + final BorderSide borderSide = + BorderSide(color: Theme.of(context).dividerColor); + + return Border( + top: borderSide, + left: borderSide, + bottom: [ + CellPosition.bottom, + CellPosition.bottomLeft, + CellPosition.bottomRight, + ].contains(position) + ? borderSide + : BorderSide.none, + right: [ + CellPosition.topRight, + CellPosition.bottomRight, + CellPosition.right, + ].contains(position) + ? borderSide + : BorderSide.none, + ); + } +} + +class _EventIndicator extends StatelessWidget { + const _EventIndicator(); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).hintColor, + ), + ), + ], + ); + } +} + +class _Header extends StatelessWidget { + const _Header({ + required this.isToday, + required this.isInMonth, + required this.date, + }); + + final bool isToday; + final bool isInMonth; + final DateTime date; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: _DayBadge(isToday: isToday, isInMonth: isInMonth, date: date), + ); + } +} + +@visibleForTesting +class NewEventButton extends StatelessWidget { + const NewEventButton({super.key, required this.onCreate}); + + final VoidCallback onCreate; + + @override + Widget build(BuildContext context) { + return Consumer<_CardEnterNotifier>( + builder: (context, notifier, _) { + if (!notifier.onEnter) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.all(4.0), + child: FlowyIconButton( + onPressed: onCreate, + icon: const FlowySvg(FlowySvgs.add_s), + fillColor: Theme.of(context).colorScheme.surface, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + width: 22, + tooltipText: LocaleKeys.calendar_newEventButtonTooltip.tr(), + radius: Corners.s6Border, + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide( + color: Theme.of(context).brightness == Brightness.light + ? const Color(0xffd0d3d6) + : const Color(0xff59647a), + width: 0.5, + ), + ), + borderRadius: Corners.s6Border, + boxShadow: [ + BoxShadow( + spreadRadius: -2, + color: const Color(0xFF1F2329).withOpacity(0.02), + blurRadius: 2, + ), + BoxShadow( + color: const Color(0xFF1F2329).withOpacity(0.02), + blurRadius: 4, + ), + BoxShadow( + spreadRadius: 2, + color: const Color(0xFF1F2329).withOpacity(0.02), + blurRadius: 8, + ), + ], + ), + ), + ); + }, + ); + } +} + +class _DayBadge extends StatelessWidget { + const _DayBadge({ + required this.isToday, + required this.isInMonth, + required this.date, + }); + + final bool isToday; + final bool isInMonth; + final DateTime date; + + @override + Widget build(BuildContext context) { + Color dayTextColor = AFThemeExtension.of(context).onBackground; + Color monthTextColor = AFThemeExtension.of(context).onBackground; + final String monthString = + DateFormat("MMM ", context.locale.toLanguageTag()).format(date); + final String dayString = date.day.toString(); + + if (!isInMonth) { + dayTextColor = Theme.of(context).disabledColor; + monthTextColor = Theme.of(context).disabledColor; + } + if (isToday) { + dayTextColor = Theme.of(context).colorScheme.onPrimary; + } + + final double size = UniversalPlatform.isMobile ? 20 : 18; + + return SizedBox( + height: size, + child: Row( + mainAxisAlignment: UniversalPlatform.isMobile + ? MainAxisAlignment.center + : MainAxisAlignment.end, + children: [ + if (date.day == 1 && !UniversalPlatform.isMobile) + FlowyText.medium( + monthString, + fontSize: 11, + color: monthTextColor, + ), + Container( + decoration: BoxDecoration( + color: isToday ? Theme.of(context).colorScheme.primary : null, + borderRadius: BorderRadius.circular(10), + ), + width: isToday ? size : null, + height: isToday ? size : null, + child: Center( + child: FlowyText( + dayString, + fontSize: UniversalPlatform.isMobile ? 12 : 11, + color: dayTextColor, + ), + ), + ), + ], + ), + ); + } +} + +class _EventList extends StatelessWidget { + const _EventList({ + required this.events, + required this.viewId, + required this.rowCache, + required this.constraints, + }); + + final List events; + final String viewId; + final RowCache rowCache; + final BoxConstraints constraints; + + @override + Widget build(BuildContext context) { + final editingEvent = context.watch().state.editingEvent; + + return Flexible( + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: true), + child: ListView.separated( + itemBuilder: (BuildContext context, int index) { + final autoEdit = + editingEvent?.event?.eventId == events[index].eventId; + return EventCard( + databaseController: + context.read().databaseController, + event: events[index], + constraints: constraints, + autoEdit: autoEdit, + ); + }, + itemCount: events.length, + padding: const EdgeInsets.fromLTRB(4.0, 0, 4.0, 4.0), + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), + shrinkWrap: true, + ), + ), + ); + } +} + +class _CardEnterNotifier extends ChangeNotifier { + _CardEnterNotifier(); + + bool _onEnter = false; + + set onEnter(bool value) { + if (_onEnter != value) { + _onEnter = value; + notifyListeners(); + } + } + + bool get onEnter => _onEnter; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart new file mode 100644 index 0000000000000..d4abb79a32ad0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_card.dart @@ -0,0 +1,222 @@ +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/widgets/card/card.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../application/calendar_bloc.dart'; + +import 'calendar_event_editor.dart'; + +class EventCard extends StatefulWidget { + const EventCard({ + super.key, + required this.databaseController, + required this.event, + required this.constraints, + required this.autoEdit, + this.isDraggable = true, + this.padding = EdgeInsets.zero, + }); + + final DatabaseController databaseController; + final CalendarDayEvent event; + final BoxConstraints constraints; + final bool autoEdit; + final bool isDraggable; + final EdgeInsets padding; + + @override + State createState() => _EventCardState(); +} + +class _EventCardState extends State { + final PopoverController _popoverController = PopoverController(); + + String get viewId => widget.databaseController.viewId; + RowCache get rowCache => widget.databaseController.rowCache; + + @override + void initState() { + super.initState(); + if (widget.autoEdit) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _popoverController.show(); + context + .read() + .add(const CalendarEvent.newEventPopupDisplayed()); + }); + } + } + + @override + Widget build(BuildContext context) { + final rowInfo = rowCache.getRow(widget.event.eventId); + if (rowInfo == null) { + return const SizedBox.shrink(); + } + + final cellBuilder = CardCellBuilder( + databaseController: widget.databaseController, + ); + + Widget card = RowCard( + // Add the key here to make sure the card is rebuilt when the cells + // in this row are updated. + key: ValueKey(widget.event.eventId), + fieldController: widget.databaseController.fieldController, + rowMeta: rowInfo.rowMeta, + viewId: viewId, + rowCache: rowCache, + isEditing: false, + cellBuilder: cellBuilder, + isCompact: true, + onTap: (context) { + if (UniversalPlatform.isMobile) { + context.push( + MobileRowDetailPage.routeName, + extra: { + MobileRowDetailPage.argRowId: rowInfo.rowId, + MobileRowDetailPage.argDatabaseController: + widget.databaseController, + }, + ); + } else { + _popoverController.show(); + } + }, + styleConfiguration: RowCardStyleConfiguration( + cellStyleMap: desktopCalendarCardCellStyleMap(context), + showAccessory: false, + cardPadding: const EdgeInsets.all(6), + hoverStyle: HoverStyle( + hoverColor: Theme.of(context).brightness == Brightness.light + ? const Color(0x0F1F2329) + : const Color(0x0FEFF4FB), + foregroundColorOnHover: AFThemeExtension.of(context).onBackground, + ), + ), + onStartEditing: () {}, + onEndEditing: () {}, + userProfile: context.read().userProfile, + ); + + final decoration = BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border.fromBorderSide( + BorderSide( + color: Theme.of(context).brightness == Brightness.light + ? const Color(0xffd0d3d6) + : const Color(0xff59647a), + width: 0.5, + ), + ), + borderRadius: Corners.s6Border, + boxShadow: [ + BoxShadow( + spreadRadius: -2, + color: const Color(0xFF1F2329).withOpacity(0.02), + blurRadius: 2, + ), + BoxShadow( + color: const Color(0xFF1F2329).withOpacity(0.02), + blurRadius: 4, + ), + BoxShadow( + spreadRadius: 2, + color: const Color(0xFF1F2329).withOpacity(0.02), + blurRadius: 8, + ), + ], + ); + + card = AppFlowyPopover( + triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.rightWithCenterAligned, + controller: _popoverController, + constraints: const BoxConstraints(maxWidth: 360, maxHeight: 348), + asBarrier: true, + margin: EdgeInsets.zero, + offset: const Offset(10.0, 0), + popupBuilder: (_) { + final settings = context.watch().state.settings; + if (settings == null) { + return const SizedBox.shrink(); + } + return MultiBlocProvider( + providers: [ + BlocProvider.value( + value: context.read(), + ), + BlocProvider.value( + value: context.read(), + ), + ], + child: CalendarEventEditor( + databaseController: widget.databaseController, + rowMeta: widget.event.event.rowMeta, + layoutSettings: settings, + onExpand: () { + final rowController = RowController( + rowMeta: widget.event.event.rowMeta, + viewId: widget.databaseController.viewId, + rowCache: widget.databaseController.rowCache, + ); + + FlowyOverlay.show( + context: context, + builder: (_) => RowDetailPage( + databaseController: widget.databaseController, + rowController: rowController, + userProfile: context.read().userProfile, + ), + ); + }, + ), + ); + }, + child: Padding( + padding: widget.padding, + child: Material( + color: Colors.transparent, + child: DecoratedBox( + decoration: decoration, + child: card, + ), + ), + ), + ); + + if (widget.isDraggable) { + return Draggable( + data: widget.event, + feedback: Container( + constraints: BoxConstraints( + maxWidth: widget.constraints.maxWidth - 8.0, + ), + decoration: decoration, + child: Opacity( + opacity: 0.6, + child: card, + ), + ), + child: card, + ); + } + + return card; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart new file mode 100644 index 0000000000000..40f57b5e9a69b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart @@ -0,0 +1,286 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart'; +import 'package:appflowy/plugins/database/calendar/application/calendar_event_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; +import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class CalendarEventEditor extends StatelessWidget { + CalendarEventEditor({ + super.key, + required RowMetaPB rowMeta, + required this.layoutSettings, + required this.databaseController, + required this.onExpand, + }) : rowController = RowController( + rowMeta: rowMeta, + viewId: databaseController.viewId, + rowCache: databaseController.rowCache, + ), + cellBuilder = + EditableCellBuilder(databaseController: databaseController); + + final CalendarLayoutSettingPB layoutSettings; + final DatabaseController databaseController; + final RowController rowController; + final EditableCellBuilder cellBuilder; + final VoidCallback onExpand; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => CalendarEventEditorBloc( + fieldController: databaseController.fieldController, + rowController: rowController, + layoutSettings: layoutSettings, + )..add(const CalendarEventEditorEvent.initial()), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + EventEditorControls( + rowController: rowController, + databaseController: databaseController, + onExpand: onExpand, + ), + Flexible( + child: EventPropertyList( + fieldController: databaseController.fieldController, + dateFieldId: layoutSettings.fieldId, + cellBuilder: cellBuilder, + ), + ), + ], + ), + ); + } +} + +class EventEditorControls extends StatelessWidget { + const EventEditorControls({ + super.key, + required this.rowController, + required this.databaseController, + required this.onExpand, + }); + + final RowController rowController; + final DatabaseController databaseController; + final VoidCallback onExpand; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12.0, 8.0, 12.0, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlowyTooltip( + message: LocaleKeys.calendar_duplicateEvent.tr(), + child: FlowyIconButton( + width: 20, + icon: const FlowySvg( + FlowySvgs.m_duplicate_s, + size: Size.square(17), + ), + iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + onPressed: () => context.read().add( + CalendarEvent.duplicateEvent( + rowController.viewId, + rowController.rowId, + ), + ), + ), + ), + const HSpace(8.0), + FlowyIconButton( + width: 20, + icon: const FlowySvg(FlowySvgs.delete_s), + iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + onPressed: () => context.read().add( + CalendarEvent.deleteEvent( + rowController.viewId, + rowController.rowId, + ), + ), + ), + const HSpace(8.0), + FlowyIconButton( + width: 20, + icon: const FlowySvg(FlowySvgs.full_view_s), + iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + onPressed: () { + PopoverContainer.of(context).close(); + onExpand.call(); + }, + ), + ], + ), + ); + } +} + +class EventPropertyList extends StatelessWidget { + const EventPropertyList({ + super.key, + required this.fieldController, + required this.dateFieldId, + required this.cellBuilder, + }); + + final FieldController fieldController; + final String dateFieldId; + final EditableCellBuilder cellBuilder; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final primaryFieldId = fieldController.fieldInfos + .firstWhereOrNull((fieldInfo) => fieldInfo.isPrimary)! + .id; + final reorderedList = List.from(state.cells) + ..retainWhere((cell) => cell.fieldId != primaryFieldId); + + final primaryCellContext = state.cells + .firstWhereOrNull((cell) => cell.fieldId == primaryFieldId); + final dateFieldIndex = + reorderedList.indexWhere((cell) => cell.fieldId == dateFieldId); + + if (primaryCellContext == null || dateFieldIndex == -1) { + return const SizedBox.shrink(); + } + + reorderedList.insert(0, reorderedList.removeAt(dateFieldIndex)); + + final children = [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0), + child: cellBuilder.buildCustom( + primaryCellContext, + skinMap: EditableCellSkinMap(textSkin: _TitleTextCellSkin()), + ), + ), + ...reorderedList.map( + (cellContext) => PropertyCell( + fieldController: fieldController, + cellContext: cellContext, + cellBuilder: cellBuilder, + ), + ), + ]; + + return ListView( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 16.0), + children: children, + ); + }, + ); + } +} + +class PropertyCell extends StatefulWidget { + const PropertyCell({ + super.key, + required this.fieldController, + required this.cellContext, + required this.cellBuilder, + }); + + final FieldController fieldController; + final CellContext cellContext; + final EditableCellBuilder cellBuilder; + + @override + State createState() => _PropertyCellState(); +} + +class _PropertyCellState extends State { + @override + Widget build(BuildContext context) { + final fieldInfo = + widget.fieldController.getField(widget.cellContext.fieldId)!; + final cell = widget.cellBuilder + .buildStyled(widget.cellContext, EditableCellStyle.desktopRowDetail); + + final gesture = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => cell.requestFocus.notify(), + child: AccessoryHover( + fieldType: fieldInfo.fieldType, + child: cell, + ), + ); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + constraints: const BoxConstraints(minHeight: 28), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 88, + height: 28, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6), + child: Row( + children: [ + FieldIcon( + fieldInfo: fieldInfo, + dimension: 14, + ), + const HSpace(4.0), + Expanded( + child: FlowyText.regular( + fieldInfo.name, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + fontSize: 11, + ), + ), + ], + ), + ), + ), + const HSpace(8), + Expanded(child: gesture), + ], + ), + ); + } +} + +class _TitleTextCellSkin extends IEditableTextCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TextCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return FlowyTextField( + controller: textEditingController, + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 14), + focusNode: focusNode, + hintText: LocaleKeys.calendar_defaultNewCalendarTitle.tr(), + onEditingComplete: () { + bloc.add(TextCellEvent.updateText(textEditingController.text)); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart new file mode 100644 index 0000000000000..231c8830d924a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_page.dart @@ -0,0 +1,625 @@ +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/database/card/card.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart'; +import 'package:appflowy/plugins/database/calendar/application/unschedule_event_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../application/row/row_controller.dart'; +import '../../widgets/row/row_detail.dart'; + +import 'calendar_day.dart'; +import 'layout/sizes.dart'; +import 'toolbar/calendar_setting_bar.dart'; + +class CalendarPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder { + final _toggleExtension = ToggleExtensionNotifier(); + + @override + Widget content( + BuildContext context, + ViewPB view, + DatabaseController controller, + bool shrinkWrap, + String? initialRowId, + ) { + return CalendarPage( + key: _makeValueKey(controller), + view: view, + databaseController: controller, + shrinkWrap: shrinkWrap, + ); + } + + @override + Widget settingBar(BuildContext context, DatabaseController controller) { + return CalendarSettingBar( + key: _makeValueKey(controller), + databaseController: controller, + toggleExtension: _toggleExtension, + ); + } + + @override + Widget settingBarExtension( + BuildContext context, + DatabaseController controller, + ) { + return DatabaseViewSettingExtension( + key: _makeValueKey(controller), + viewId: controller.viewId, + databaseController: controller, + toggleExtension: _toggleExtension, + ); + } + + @override + void dispose() { + _toggleExtension.dispose(); + super.dispose(); + } + + ValueKey _makeValueKey(DatabaseController controller) { + return ValueKey(controller.viewId); + } +} + +class CalendarPage extends StatefulWidget { + const CalendarPage({ + super.key, + required this.view, + required this.databaseController, + this.shrinkWrap = false, + }); + + final ViewPB view; + final DatabaseController databaseController; + final bool shrinkWrap; + + @override + State createState() => _CalendarPageState(); +} + +class _CalendarPageState extends State { + final _eventController = EventController(); + late final CalendarBloc _calendarBloc; + GlobalKey? _calendarState; + + @override + void initState() { + super.initState(); + _calendarState = GlobalKey(); + _calendarBloc = CalendarBloc( + databaseController: widget.databaseController, + )..add(const CalendarEvent.initial()); + } + + @override + void dispose() { + _calendarBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CalendarControllerProvider( + controller: _eventController, + child: BlocProvider.value( + value: _calendarBloc, + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (p, c) => p.initialEvents != c.initialEvents, + listener: (context, state) { + _eventController.removeWhere((_) => true); + _eventController.addAll(state.initialEvents); + }, + ), + BlocListener( + listenWhen: (p, c) => p.deleteEventIds != c.deleteEventIds, + listener: (context, state) { + _eventController.removeWhere( + (element) => + state.deleteEventIds.contains(element.event!.eventId), + ); + }, + ), + BlocListener( + // Event create by click the + button or double click on the + // calendar + listenWhen: (p, c) => p.newEvent != c.newEvent, + listener: (context, state) { + if (state.newEvent != null) { + _eventController.add(state.newEvent!); + } + }, + ), + BlocListener( + // When an event is rescheduled + listenWhen: (p, c) => p.updateEvent != c.updateEvent, + listener: (context, state) { + if (state.updateEvent != null) { + _eventController.removeWhere( + (element) => + element.event!.eventId == + state.updateEvent!.event!.eventId, + ); + _eventController.add(state.updateEvent!); + } + }, + ), + BlocListener( + listenWhen: (p, c) => p.openRow != c.openRow, + listener: (context, state) { + if (state.openRow != null) { + showEventDetails( + context: context, + databaseController: _calendarBloc.databaseController, + rowMeta: state.openRow!, + ); + } + }, + ), + ], + child: BlocBuilder( + builder: (context, state) { + return ValueListenableBuilder( + valueListenable: widget.databaseController.isLoading, + builder: (_, value, ___) { + if (value) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + return _buildCalendar( + context, + _eventController, + state.settings?.firstDayOfWeek ?? 0, + ); + }, + ); + }, + ), + ), + ), + ); + } + + Widget _buildCalendar( + BuildContext context, + EventController eventController, + int firstDayOfWeek, + ) { + return LayoutBuilder( + // must specify MonthView width for useAvailableVerticalSpace to work properly + builder: (context, constraints) { + EdgeInsets padding = UniversalPlatform.isMobile + ? CalendarSize.contentInsetsMobile + : CalendarSize.contentInsets + + const EdgeInsets.symmetric(horizontal: 40); + final double horizontalPadding = + context.read().horizontalPadding; + if (horizontalPadding == 0) { + padding = padding.copyWith(left: 0, right: 0); + } + return Padding( + padding: padding, + child: ScrollConfiguration( + behavior: + ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: MonthView( + key: _calendarState, + controller: _eventController, + width: constraints.maxWidth, + cellAspectRatio: UniversalPlatform.isMobile ? 0.9 : 0.6, + startDay: _weekdayFromInt(firstDayOfWeek), + showBorder: false, + headerBuilder: _headerNavigatorBuilder, + weekDayBuilder: _headerWeekDayBuilder, + cellBuilder: _calendarDayBuilder, + useAvailableVerticalSpace: widget.shrinkWrap, + ), + ), + ); + }, + ); + } + + Widget _headerNavigatorBuilder(DateTime currentMonth) { + return SizedBox( + height: 24, + child: Row( + children: [ + GestureDetector( + onTap: UniversalPlatform.isMobile + ? () => showMobileBottomSheet( + context, + title: LocaleKeys.calendar_quickJumpYear.tr(), + showHeader: true, + showCloseButton: true, + builder: (_) => SizedBox( + height: 200, + child: YearPicker( + firstDate: CalendarConstants.epochDate.withoutTime, + lastDate: CalendarConstants.maxDate.withoutTime, + selectedDate: currentMonth, + currentDate: DateTime.now(), + onChanged: (newDate) { + _calendarState?.currentState?.jumpToMonth(newDate); + context.pop(); + }, + ), + ), + ) + : null, + child: Row( + children: [ + FlowyText.medium( + DateFormat('MMMM y', context.locale.toLanguageTag()) + .format(currentMonth), + ), + if (UniversalPlatform.isMobile) ...[ + const HSpace(6), + const FlowySvg(FlowySvgs.arrow_down_s), + ], + ], + ), + ), + const Spacer(), + FlowyIconButton( + width: CalendarSize.navigatorButtonWidth, + height: CalendarSize.navigatorButtonHeight, + icon: const FlowySvg(FlowySvgs.arrow_left_s), + tooltipText: LocaleKeys.calendar_navigation_previousMonth.tr(), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onPressed: () => _calendarState?.currentState?.previousPage(), + ), + FlowyTextButton( + LocaleKeys.calendar_navigation_today.tr(), + fillColor: Colors.transparent, + fontWeight: FontWeight.w400, + fontSize: 10, + fontColor: AFThemeExtension.of(context).textColor, + tooltip: LocaleKeys.calendar_navigation_jumpToday.tr(), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onPressed: () => + _calendarState?.currentState?.animateToMonth(DateTime.now()), + ), + FlowyIconButton( + width: CalendarSize.navigatorButtonWidth, + height: CalendarSize.navigatorButtonHeight, + icon: const FlowySvg(FlowySvgs.arrow_right_s), + tooltipText: LocaleKeys.calendar_navigation_nextMonth.tr(), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onPressed: () => _calendarState?.currentState?.nextPage(), + ), + const HSpace(6.0), + UnscheduledEventsButton( + databaseController: widget.databaseController, + ), + ], + ), + ); + } + + Widget _headerWeekDayBuilder(day) { + // incoming day starts from Monday, the symbols start from Sunday + final symbols = DateFormat.EEEE(context.locale.toLanguageTag()).dateSymbols; + String weekDayString = symbols.WEEKDAYS[(day + 1) % 7]; + + if (UniversalPlatform.isMobile) { + weekDayString = weekDayString.substring(0, 3); + } + + return Center( + child: Padding( + padding: CalendarSize.daysOfWeekInsets, + child: FlowyText.regular( + weekDayString, + fontSize: 9, + color: Theme.of(context).hintColor, + ), + ), + ); + } + + Widget _calendarDayBuilder( + DateTime date, + List> calenderEvents, + isToday, + isInMonth, + position, + ) { + // Sort the events by timestamp. Because the database view is not + // reserving the order of the events. Reserving the order of the rows/events + // is implemnted in the develop branch(WIP). Will be replaced with that. + final events = calenderEvents.map((value) => value.event!).toList() + ..sort((a, b) => a.event.timestamp.compareTo(b.event.timestamp)); + + return CalendarDayCard( + viewId: widget.view.id, + isToday: isToday, + isInMonth: isInMonth, + events: events, + date: date, + rowCache: _calendarBloc.rowCache, + onCreateEvent: (date) => + _calendarBloc.add(CalendarEvent.createEvent(date)), + position: position, + ); + } + + WeekDays _weekdayFromInt(int dayOfWeek) { + // dayOfWeek starts from Sunday, WeekDays starts from Monday + return WeekDays.values[(dayOfWeek - 1) % 7]; + } +} + +void showEventDetails({ + required BuildContext context, + required DatabaseController databaseController, + required RowMetaPB rowMeta, +}) { + final rowController = RowController( + rowMeta: rowMeta, + viewId: databaseController.viewId, + rowCache: databaseController.rowCache, + ); + + FlowyOverlay.show( + context: context, + builder: (BuildContext overlayContext) { + return BlocProvider.value( + value: context.read(), + child: RowDetailPage( + rowController: rowController, + databaseController: databaseController, + userProfile: context.read().userProfile, + ), + ); + }, + ); +} + +class UnscheduledEventsButton extends StatefulWidget { + const UnscheduledEventsButton({super.key, required this.databaseController}); + + final DatabaseController databaseController; + + @override + State createState() => + _UnscheduledEventsButtonState(); +} + +class _UnscheduledEventsButtonState extends State { + final PopoverController _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => + UnscheduleEventsBloc(databaseController: widget.databaseController) + ..add(const UnscheduleEventsEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithCenterAligned, + triggerActions: PopoverTriggerFlags.none, + controller: _popoverController, + offset: const Offset(0, 8), + constraints: const BoxConstraints(maxWidth: 282, maxHeight: 600), + child: OutlinedButton( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).dividerColor), + borderRadius: Corners.s6Border, + ), + side: BorderSide(color: Theme.of(context).dividerColor), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + onPressed: () { + if (state.unscheduleEvents.isNotEmpty) { + if (UniversalPlatform.isMobile) { + _showUnscheduledEventsMobile(state.unscheduleEvents); + } else { + _popoverController.show(); + } + } + }, + child: FlowyTooltip( + message: LocaleKeys.calendar_settings_noDateHint.plural( + state.unscheduleEvents.length, + namedArgs: {'count': '${state.unscheduleEvents.length}'}, + ), + child: FlowyText.regular( + "${LocaleKeys.calendar_settings_noDateTitle.tr()} (${state.unscheduleEvents.length})", + fontSize: 10, + ), + ), + ), + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: BlocProvider.value( + value: context.read(), + child: UnscheduleEventsList( + databaseController: widget.databaseController, + unscheduleEvents: state.unscheduleEvents, + ), + ), + ), + ); + }, + ), + ); + } + + void _showUnscheduledEventsMobile(List events) => + showMobileBottomSheet( + context, + builder: (_) { + return Column( + children: [ + FlowyText( + LocaleKeys.calendar_settings_unscheduledEventsTitle.tr(), + ), + UnscheduleEventsList( + databaseController: widget.databaseController, + unscheduleEvents: events, + ), + ], + ); + }, + ); +} + +class UnscheduleEventsList extends StatelessWidget { + const UnscheduleEventsList({ + super.key, + required this.unscheduleEvents, + required this.databaseController, + }); + + final List unscheduleEvents; + final DatabaseController databaseController; + + @override + Widget build(BuildContext context) { + final cells = [ + if (!UniversalPlatform.isMobile) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: FlowyText( + LocaleKeys.calendar_settings_clickToAdd.tr(), + fontSize: 10, + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ), + ...unscheduleEvents.map( + (event) => UnscheduledEventCell( + event: event, + onPressed: () { + if (UniversalPlatform.isMobile) { + context.push( + MobileRowDetailPage.routeName, + extra: { + MobileRowDetailPage.argRowId: event.rowMeta.id, + MobileRowDetailPage.argDatabaseController: databaseController, + }, + ); + context.pop(); + } else { + showEventDetails( + context: context, + rowMeta: event.rowMeta, + databaseController: databaseController, + ); + PopoverContainer.of(context).close(); + } + }, + ), + ), + ]; + + final child = ListView.separated( + itemBuilder: (context, index) => cells[index], + itemCount: cells.length, + separatorBuilder: (context, index) => + VSpace(GridSize.typeOptionSeparatorHeight), + shrinkWrap: true, + ); + + if (UniversalPlatform.isMobile) { + return Flexible(child: child); + } + + return child; + } +} + +class UnscheduledEventCell extends StatelessWidget { + const UnscheduledEventCell({ + super.key, + required this.event, + required this.onPressed, + }); + + final CalendarEventPB event; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return UniversalPlatform.isMobile + ? MobileUnscheduledEventTile(event: event, onPressed: onPressed) + : DesktopUnscheduledEventTile(event: event, onPressed: onPressed); + } +} + +class DesktopUnscheduledEventTile extends StatelessWidget { + const DesktopUnscheduledEventTile({ + super.key, + required this.event, + required this.onPressed, + }); + + final CalendarEventPB event; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 26, + child: FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + text: FlowyText( + event.title.isEmpty + ? LocaleKeys.calendar_defaultNewCalendarTitle.tr() + : event.title, + fontSize: 11, + ), + onTap: onPressed, + ), + ); + } +} + +class MobileUnscheduledEventTile extends StatelessWidget { + const MobileUnscheduledEventTile({ + super.key, + required this.event, + required this.onPressed, + }); + + final CalendarEventPB event; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return MobileSettingItem( + name: event.title.isEmpty + ? LocaleKeys.calendar_defaultNewCalendarTitle.tr() + : event.title, + onTap: onPressed, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/layout/sizes.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/layout/sizes.dart new file mode 100644 index 0000000000000..73b006691f23e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/layout/sizes.dart @@ -0,0 +1,28 @@ +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:flutter/widgets.dart'; + +class CalendarSize { + static double scale = 1; + + static double get headerContainerPadding => 12 * scale; + + static EdgeInsets get contentInsets => EdgeInsets.fromLTRB( + GridSize.horizontalHeaderPadding, + CalendarSize.headerContainerPadding, + GridSize.horizontalHeaderPadding, + CalendarSize.headerContainerPadding, + ); + + static EdgeInsets get contentInsetsMobile => EdgeInsets.fromLTRB( + GridSize.horizontalHeaderPadding / 2, + 0, + GridSize.horizontalHeaderPadding / 2, + 0, + ); + + static double get scrollBarSize => 8 * scale; + static double get navigatorButtonWidth => 20 * scale; + static double get navigatorButtonHeight => 24 * scale; + static EdgeInsets get daysOfWeekInsets => + EdgeInsets.only(top: 12.0 * scale, bottom: 5.0 * scale); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/toolbar/calendar_layout_setting.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/toolbar/calendar_layout_setting.dart new file mode 100644 index 0000000000000..8a661919504ff --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/toolbar/calendar_layout_setting.dart @@ -0,0 +1,383 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/setting/property_bloc.dart'; +import 'package:appflowy/plugins/database/calendar/application/calendar_setting_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Widget that displays a list of settings that alters the appearance of the +/// calendar +class CalendarLayoutSetting extends StatefulWidget { + const CalendarLayoutSetting({ + super.key, + required this.databaseController, + }); + + final DatabaseController databaseController; + + @override + State createState() => _CalendarLayoutSettingState(); +} + +class _CalendarLayoutSettingState extends State { + final PopoverMutex popoverMutex = PopoverMutex(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) { + return CalendarSettingBloc( + databaseController: widget.databaseController, + )..add(const CalendarSettingEvent.initial()); + }, + child: BlocBuilder( + builder: (context, state) { + final CalendarLayoutSettingPB? settings = state.layoutSetting; + + if (settings == null) { + return const CircularProgressIndicator(); + } + final availableSettings = _availableCalendarSettings(settings); + final bloc = context.read(); + final items = availableSettings.map((setting) { + switch (setting) { + case CalendarLayoutSettingAction.showWeekNumber: + return ShowWeekNumber( + showWeekNumbers: settings.showWeekNumbers, + onUpdated: (showWeekNumbers) => bloc.add( + CalendarSettingEvent.updateLayoutSetting( + showWeekNumbers: showWeekNumbers, + ), + ), + ); + case CalendarLayoutSettingAction.showWeekends: + return ShowWeekends( + showWeekends: settings.showWeekends, + onUpdated: (showWeekends) => bloc.add( + CalendarSettingEvent.updateLayoutSetting( + showWeekends: showWeekends, + ), + ), + ); + case CalendarLayoutSettingAction.firstDayOfWeek: + return FirstDayOfWeek( + firstDayOfWeek: settings.firstDayOfWeek, + popoverMutex: popoverMutex, + onUpdated: (firstDayOfWeek) => bloc.add( + CalendarSettingEvent.updateLayoutSetting( + firstDayOfWeek: firstDayOfWeek, + ), + ), + ); + case CalendarLayoutSettingAction.layoutField: + return LayoutDateField( + databaseController: widget.databaseController, + fieldId: settings.fieldId, + popoverMutex: popoverMutex, + onUpdated: (fieldId) => bloc.add( + CalendarSettingEvent.updateLayoutSetting( + layoutFieldId: fieldId, + ), + ), + ); + default: + return const SizedBox.shrink(); + } + }).toList(); + + return SizedBox( + width: 200, + child: ListView.separated( + shrinkWrap: true, + itemCount: items.length, + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), + physics: StyledScrollPhysics(), + itemBuilder: (_, int index) => items[index], + padding: const EdgeInsets.all(6.0), + ), + ); + }, + ), + ); + } + + List _availableCalendarSettings( + CalendarLayoutSettingPB layoutSettings, + ) { + final List settings = [ + CalendarLayoutSettingAction.layoutField, + ]; + + switch (layoutSettings.layoutTy) { + case CalendarLayoutPB.DayLayout: + break; + case CalendarLayoutPB.MonthLayout: + case CalendarLayoutPB.WeekLayout: + settings.add(CalendarLayoutSettingAction.firstDayOfWeek); + break; + } + + return settings; + } +} + +class LayoutDateField extends StatelessWidget { + const LayoutDateField({ + super.key, + required this.databaseController, + required this.fieldId, + required this.popoverMutex, + required this.onUpdated, + }); + + final DatabaseController databaseController; + final String fieldId; + final PopoverMutex popoverMutex; + final Function(String fieldId) onUpdated; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.leftWithTopAligned, + triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + constraints: BoxConstraints.loose(const Size(300, 400)), + mutex: popoverMutex, + offset: const Offset(-14, 0), + popupBuilder: (context) { + return BlocProvider( + create: (context) => DatabasePropertyBloc( + viewId: databaseController.viewId, + fieldController: databaseController.fieldController, + )..add(const DatabasePropertyEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final items = state.fieldContexts + .where((field) => field.fieldType == FieldType.DateTime) + .map( + (fieldInfo) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText( + fieldInfo.name, + lineHeight: 1.0, + ), + onTap: () { + onUpdated(fieldInfo.id); + popoverMutex.close(); + }, + leftIcon: const FlowySvg(FlowySvgs.date_s), + rightIcon: fieldInfo.id == fieldId + ? const FlowySvg(FlowySvgs.check_s) + : null, + ), + ); + }, + ).toList(); + + return SizedBox( + width: 200, + child: ListView.separated( + shrinkWrap: true, + itemBuilder: (_, index) => items[index], + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), + itemCount: items.length, + ), + ); + }, + ), + ); + }, + child: SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + margin: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0), + text: FlowyText( + lineHeight: 1.0, + LocaleKeys.calendar_settings_layoutDateField.tr(), + ), + ), + ), + ); + } +} + +class ShowWeekNumber extends StatelessWidget { + const ShowWeekNumber({ + super.key, + required this.showWeekNumbers, + required this.onUpdated, + }); + + final bool showWeekNumbers; + final Function(bool showWeekNumbers) onUpdated; + + @override + Widget build(BuildContext context) { + return _toggleItem( + onToggle: (showWeekNumbers) => onUpdated(!showWeekNumbers), + value: showWeekNumbers, + text: LocaleKeys.calendar_settings_showWeekNumbers.tr(), + ); + } +} + +class ShowWeekends extends StatelessWidget { + const ShowWeekends({ + super.key, + required this.showWeekends, + required this.onUpdated, + }); + + final bool showWeekends; + final Function(bool showWeekends) onUpdated; + + @override + Widget build(BuildContext context) { + return _toggleItem( + onToggle: (showWeekends) => onUpdated(!showWeekends), + value: showWeekends, + text: LocaleKeys.calendar_settings_showWeekends.tr(), + ); + } +} + +class FirstDayOfWeek extends StatelessWidget { + const FirstDayOfWeek({ + super.key, + required this.firstDayOfWeek, + required this.popoverMutex, + required this.onUpdated, + }); + + final int firstDayOfWeek; + final PopoverMutex popoverMutex; + final Function(int firstDayOfWeek) onUpdated; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.leftWithTopAligned, + constraints: BoxConstraints.loose(const Size(300, 400)), + triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + mutex: popoverMutex, + offset: const Offset(-14, 0), + popupBuilder: (context) { + final symbols = + DateFormat.EEEE(context.locale.toLanguageTag()).dateSymbols; + // starts from sunday + const len = 2; + final items = symbols.WEEKDAYS.take(len).indexed.map((entry) { + return StartFromButton( + title: entry.$2, + dayIndex: entry.$1, + isSelected: firstDayOfWeek == entry.$1, + onTap: (index) { + onUpdated(index); + popoverMutex.close(); + }, + ); + }).toList(); + + return SizedBox( + width: 100, + child: ListView.separated( + shrinkWrap: true, + itemBuilder: (_, index) => items[index], + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), + itemCount: len, + ), + ); + }, + child: SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + margin: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0), + text: FlowyText( + lineHeight: 1.0, + LocaleKeys.calendar_settings_firstDayOfWeek.tr(), + ), + ), + ), + ); + } +} + +Widget _toggleItem({ + required String text, + required bool value, + required void Function(bool) onToggle, +}) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 10.0), + child: Row( + children: [ + FlowyText(text), + const Spacer(), + Toggle( + value: value, + onChanged: (value) => onToggle(value), + padding: EdgeInsets.zero, + ), + ], + ), + ), + ); +} + +enum CalendarLayoutSettingAction { + layoutField, + layoutType, + showWeekends, + firstDayOfWeek, + showWeekNumber, + showTimeLine, +} + +class StartFromButton extends StatelessWidget { + const StartFromButton({ + super.key, + required this.title, + required this.dayIndex, + required this.onTap, + required this.isSelected, + }); + + final String title; + final int dayIndex; + final void Function(int) onTap; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText( + title, + lineHeight: 1.0, + ), + onTap: () => onTap(dayIndex), + rightIcon: isSelected ? const FlowySvg(FlowySvgs.check_s) : null, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/toolbar/calendar_setting_bar.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/toolbar/calendar_setting_bar.dart new file mode 100644 index 0000000000000..c2307c63a5643 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/toolbar/calendar_setting_bar.dart @@ -0,0 +1,52 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart'; +import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class CalendarSettingBar extends StatelessWidget { + const CalendarSettingBar({ + super.key, + required this.databaseController, + required this.toggleExtension, + }); + + final DatabaseController databaseController; + final ToggleExtensionNotifier toggleExtension; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => FilterEditorBloc( + viewId: databaseController.viewId, + fieldController: databaseController.fieldController, + ), + child: ValueListenableBuilder( + valueListenable: databaseController.isLoading, + builder: (context, value, child) { + if (value) { + return const SizedBox.shrink(); + } + return SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilterButton( + toggleExtension: toggleExtension, + ), + const HSpace(2), + SettingButton( + databaseController: databaseController, + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/cell_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/cell_listener.dart new file mode 100644 index 0000000000000..0b58300b97795 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/cell_listener.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +import '../application/row/row_service.dart'; + +typedef UpdateFieldNotifiedValue = FlowyResult; + +class CellListener { + CellListener({required this.rowId, required this.fieldId}); + + final RowId rowId; + final String fieldId; + + PublishNotifier? _updateCellNotifier = + PublishNotifier(); + DatabaseNotificationListener? _listener; + + void start({required void Function(UpdateFieldNotifiedValue) onCellChanged}) { + _updateCellNotifier?.addPublishListener(onCellChanged); + _listener = DatabaseNotificationListener( + objectId: "$rowId:$fieldId", + handler: _handler, + ); + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateCell: + result.fold( + (payload) => _updateCellNotifier?.value = FlowyResult.success(null), + (error) => _updateCellNotifier?.value = FlowyResult.failure(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _updateCellNotifier?.dispose(); + _updateCellNotifier = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/cell_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/cell_service.dart new file mode 100644 index 0000000000000..a4090d7c88c85 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/cell_service.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +import '../application/cell/cell_controller.dart'; + +class CellBackendService { + CellBackendService(); + + static Future> updateCell({ + required String viewId, + required CellContext cellContext, + required String data, + }) { + final payload = CellChangesetPB() + ..viewId = viewId + ..fieldId = cellContext.fieldId + ..rowId = cellContext.rowId + ..cellChangeset = data; + return DatabaseEventUpdateCell(payload).send(); + } + + static Future> getCell({ + required String viewId, + required CellContext cellContext, + }) { + final payload = CellIdPB() + ..viewId = viewId + ..fieldId = cellContext.fieldId + ..rowId = cellContext.rowId; + return DatabaseEventGetCell(payload).send(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/checklist_cell_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/checklist_cell_service.dart new file mode 100644 index 0000000000000..3dcba2ca37a6d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/checklist_cell_service.dart @@ -0,0 +1,86 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:protobuf/protobuf.dart'; + +class ChecklistCellBackendService { + ChecklistCellBackendService({ + required this.viewId, + required this.fieldId, + required this.rowId, + }); + + final String viewId; + final String fieldId; + final String rowId; + + Future> create({ + required String name, + int? index, + }) { + final insert = ChecklistCellInsertPB()..name = name; + if (index != null) { + insert.index = index; + } + + final payload = ChecklistCellDataChangesetPB() + ..cellId = _makdeCellId() + ..insertTask.add(insert); + + return DatabaseEventUpdateChecklistCell(payload).send(); + } + + Future> delete({ + required List optionIds, + }) { + final payload = ChecklistCellDataChangesetPB() + ..cellId = _makdeCellId() + ..deleteTasks.addAll(optionIds); + + return DatabaseEventUpdateChecklistCell(payload).send(); + } + + Future> select({ + required String optionId, + }) { + final payload = ChecklistCellDataChangesetPB() + ..cellId = _makdeCellId() + ..completedTasks.add(optionId); + + return DatabaseEventUpdateChecklistCell(payload).send(); + } + + Future> updateName({ + required SelectOptionPB option, + required name, + }) { + option.freeze(); + final newOption = option.rebuild((option) { + option.name = name; + }); + final payload = ChecklistCellDataChangesetPB() + ..cellId = _makdeCellId() + ..updateTasks.add(newOption); + + return DatabaseEventUpdateChecklistCell(payload).send(); + } + + Future> reorder({ + required fromTaskId, + required toTaskId, + }) { + final payload = ChecklistCellDataChangesetPB() + ..cellId = _makdeCellId() + ..reorder = "$fromTaskId $toTaskId"; + + return DatabaseEventUpdateChecklistCell(payload).send(); + } + + CellIdPB _makdeCellId() { + return CellIdPB() + ..viewId = viewId + ..fieldId = fieldId + ..rowId = rowId; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/database_view_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/database_view_service.dart new file mode 100644 index 0000000000000..de06c8d1d80eb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/database_view_service.dart @@ -0,0 +1,136 @@ +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +import 'layout_service.dart'; + +class DatabaseViewBackendService { + DatabaseViewBackendService({required this.viewId}); + + final String viewId; + + /// Returns the database id associated with the view. + Future> getDatabaseId() async { + final payload = DatabaseViewIdPB(value: viewId); + return DatabaseEventGetDatabaseId(payload) + .send() + .then((value) => value.map((l) => l.value)); + } + + static Future> updateLayout({ + required String viewId, + required DatabaseLayoutPB layout, + }) { + final payload = UpdateViewPayloadPB.create() + ..viewId = viewId + ..layout = viewLayoutFromDatabaseLayout(layout); + + return FolderEventUpdateView(payload).send(); + } + + Future> openDatabase() async { + final payload = DatabaseViewIdPB(value: viewId); + return DatabaseEventGetDatabase(payload).send(); + } + + Future> moveGroupRow({ + required RowId fromRowId, + required String fromGroupId, + required String toGroupId, + RowId? toRowId, + }) { + final payload = MoveGroupRowPayloadPB.create() + ..viewId = viewId + ..fromRowId = fromRowId + ..fromGroupId = fromGroupId + ..toGroupId = toGroupId; + + if (toRowId != null) { + payload.toRowId = toRowId; + } + + return DatabaseEventMoveGroupRow(payload).send(); + } + + Future> moveRow({ + required String fromRowId, + required String toRowId, + }) { + final payload = MoveRowPayloadPB.create() + ..viewId = viewId + ..fromRowId = fromRowId + ..toRowId = toRowId; + + return DatabaseEventMoveRow(payload).send(); + } + + Future> moveGroup({ + required String fromGroupId, + required String toGroupId, + }) { + final payload = MoveGroupPayloadPB.create() + ..viewId = viewId + ..fromGroupId = fromGroupId + ..toGroupId = toGroupId; + + return DatabaseEventMoveGroup(payload).send(); + } + + Future, FlowyError>> getFields({ + List? fieldIds, + }) { + final payload = GetFieldPayloadPB.create()..viewId = viewId; + + if (fieldIds != null) { + payload.fieldIds = RepeatedFieldIdPB(items: fieldIds); + } + return DatabaseEventGetFields(payload).send().then((result) { + return result.fold( + (l) => FlowyResult.success(l.items), + (r) => FlowyResult.failure(r), + ); + }); + } + + Future> getLayoutSetting( + DatabaseLayoutPB layoutType, + ) { + final payload = DatabaseLayoutMetaPB.create() + ..viewId = viewId + ..layout = layoutType; + return DatabaseEventGetLayoutSetting(payload).send(); + } + + Future> updateLayoutSetting({ + required DatabaseLayoutPB layoutType, + BoardLayoutSettingPB? boardLayoutSetting, + CalendarLayoutSettingPB? calendarLayoutSetting, + }) { + final payload = LayoutSettingChangesetPB.create() + ..viewId = viewId + ..layoutType = layoutType; + + if (boardLayoutSetting != null) { + payload.board = boardLayoutSetting; + } + + if (calendarLayoutSetting != null) { + payload.calendar = calendarLayoutSetting; + } + + return DatabaseEventSetLayoutSetting(payload).send(); + } + + Future> closeView() { + final request = ViewIdPB(value: viewId); + return FolderEventCloseView(request).send(); + } + + Future> loadGroups() { + final payload = DatabaseViewIdPB(value: viewId); + return DatabaseEventGetGroups(payload).send(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/date_cell_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/date_cell_service.dart new file mode 100644 index 0000000000000..4afd41ad9c5eb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/date_cell_service.dart @@ -0,0 +1,57 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/cell_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:fixnum/fixnum.dart'; + +final class DateCellBackendService { + DateCellBackendService({ + required String viewId, + required String fieldId, + required String rowId, + }) : cellId = CellIdPB() + ..viewId = viewId + ..fieldId = fieldId + ..rowId = rowId; + + final CellIdPB cellId; + + Future> update({ + bool? includeTime, + bool? isRange, + DateTime? date, + DateTime? endDate, + String? reminderId, + }) { + final payload = DateCellChangesetPB()..cellId = cellId; + + if (includeTime != null) { + payload.includeTime = includeTime; + } + if (isRange != null) { + payload.isRange = isRange; + } + if (date != null) { + final dateTimestamp = date.millisecondsSinceEpoch ~/ 1000; + payload.timestamp = Int64(dateTimestamp); + } + if (endDate != null) { + final dateTimestamp = endDate.millisecondsSinceEpoch ~/ 1000; + payload.endTimestamp = Int64(dateTimestamp); + } + if (reminderId != null) { + payload.reminderId = reminderId; + } + + return DatabaseEventUpdateDateCell(payload).send(); + } + + Future> clear() { + final payload = DateCellChangesetPB() + ..cellId = cellId + ..clearFlag = true; + + return DatabaseEventUpdateDateCell(payload).send(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/field_backend_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/field_backend_service.dart new file mode 100644 index 0000000000000..21933700ba4cc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/field_backend_service.dart @@ -0,0 +1,79 @@ +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +// This class is used for combining the +// 1. FieldBackendService +// 2. FieldSettingsBackendService +// 3. TypeOptionBackendService +// +// including, +// hide, delete, duplicated, +// insertLeft, insertRight, +// updateName +class FieldServices { + FieldServices({ + required this.viewId, + required this.fieldId, + }) : fieldBackendService = FieldBackendService( + viewId: viewId, + fieldId: fieldId, + ), + fieldSettingsService = FieldSettingsBackendService( + viewId: viewId, + ); + + final String viewId; + final String fieldId; + + final FieldBackendService fieldBackendService; + final FieldSettingsBackendService fieldSettingsService; + + Future hide() async { + await fieldSettingsService.updateFieldSettings( + fieldId: fieldId, + fieldVisibility: FieldVisibility.AlwaysHidden, + ); + } + + Future show() async { + await fieldSettingsService.updateFieldSettings( + fieldId: fieldId, + fieldVisibility: FieldVisibility.AlwaysShown, + ); + } + + Future delete() async { + await fieldBackendService.delete(); + } + + Future duplicate() async { + await fieldBackendService.duplicate(); + } + + Future insertLeft() async { + await FieldBackendService.createField( + viewId: viewId, + position: OrderObjectPositionPB( + position: OrderObjectPositionTypePB.Before, + objectId: fieldId, + ), + ); + } + + Future insertRight() async { + await FieldBackendService.createField( + viewId: viewId, + position: OrderObjectPositionPB( + position: OrderObjectPositionTypePB.After, + objectId: fieldId, + ), + ); + } + + Future updateName(String name) async { + await fieldBackendService.updateField( + name: name, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/field_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/field_listener.dart new file mode 100644 index 0000000000000..4adbe7301bab3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/field_listener.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +typedef UpdateFieldsNotifiedValue + = FlowyResult; + +class FieldsListener { + FieldsListener({required this.viewId}); + + final String viewId; + + PublishNotifier? updateFieldsNotifier = + PublishNotifier(); + DatabaseNotificationListener? _listener; + + void start({ + required void Function(UpdateFieldsNotifiedValue) onFieldsChanged, + }) { + updateFieldsNotifier?.addPublishListener(onFieldsChanged); + _listener = DatabaseNotificationListener( + objectId: viewId, + handler: _handler, + ); + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateFields: + result.fold( + (payload) => updateFieldsNotifier?.value = + FlowyResult.success(DatabaseFieldChangesetPB.fromBuffer(payload)), + (error) => updateFieldsNotifier?.value = FlowyResult.failure(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + updateFieldsNotifier?.dispose(); + updateFieldsNotifier = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart new file mode 100644 index 0000000000000..66c941891d80c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/field_service.dart @@ -0,0 +1,206 @@ +import 'dart:typed_data'; + +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +/// FieldService provides many field-related interfaces event functions. Check out +/// `rust-lib/flowy-database/event_map.rs` for a list of events and their +/// implementations. +class FieldBackendService { + FieldBackendService({required this.viewId, required this.fieldId}); + + final String viewId; + final String fieldId; + + /// Create a field in a database view. The position will only be applicable + /// in this view; for other views it will be appended to the end + static Future> createField({ + required String viewId, + FieldType fieldType = FieldType.RichText, + String? fieldName, + String? icon, + Uint8List? typeOptionData, + OrderObjectPositionPB? position, + }) { + final payload = CreateFieldPayloadPB( + viewId: viewId, + fieldType: fieldType, + fieldName: fieldName, + typeOptionData: typeOptionData, + fieldPosition: position, + ); + + return DatabaseEventCreateField(payload).send(); + } + + /// Reorder a field within a database view + static Future> moveField({ + required String viewId, + required String fromFieldId, + required String toFieldId, + }) { + final payload = MoveFieldPayloadPB( + viewId: viewId, + fromFieldId: fromFieldId, + toFieldId: toFieldId, + ); + + return DatabaseEventMoveField(payload).send(); + } + + /// Delete a field + static Future> deleteField({ + required String viewId, + required String fieldId, + }) { + final payload = DeleteFieldPayloadPB( + viewId: viewId, + fieldId: fieldId, + ); + + return DatabaseEventDeleteField(payload).send(); + } + + // Clear all data of all cells in a Field + static Future> clearField({ + required String viewId, + required String fieldId, + }) { + final payload = ClearFieldPayloadPB( + viewId: viewId, + fieldId: fieldId, + ); + + return DatabaseEventClearField(payload).send(); + } + + /// Duplicate a field + static Future> duplicateField({ + required String viewId, + required String fieldId, + }) { + final payload = DuplicateFieldPayloadPB(viewId: viewId, fieldId: fieldId); + + return DatabaseEventDuplicateField(payload).send(); + } + + /// Update a field's properties + Future> updateField({ + String? name, + String? icon, + bool? frozen, + }) { + final payload = FieldChangesetPB.create() + ..viewId = viewId + ..fieldId = fieldId; + + if (name != null) { + payload.name = name; + } + + if (icon != null) { + payload.icon = icon; + } + + if (frozen != null) { + payload.frozen = frozen; + } + + return DatabaseEventUpdateField(payload).send(); + } + + /// Change a field's type + static Future> updateFieldType({ + required String viewId, + required String fieldId, + required FieldType fieldType, + String? fieldName, + }) { + final payload = UpdateFieldTypePayloadPB() + ..viewId = viewId + ..fieldId = fieldId + ..fieldType = fieldType; + + // Only set if fieldName is not null + if (fieldName != null) { + payload.fieldName = fieldName; + } + + return DatabaseEventUpdateFieldType(payload).send(); + } + + /// Update a field's type option data + static Future> updateFieldTypeOption({ + required String viewId, + required String fieldId, + required List typeOptionData, + }) { + final payload = TypeOptionChangesetPB.create() + ..viewId = viewId + ..fieldId = fieldId + ..typeOptionData = typeOptionData; + + return DatabaseEventUpdateFieldTypeOption(payload).send(); + } + + /// Returns the primary field of the view. + static Future> getPrimaryField({ + required String viewId, + }) { + final payload = DatabaseViewIdPB.create()..value = viewId; + return DatabaseEventGetPrimaryField(payload).send(); + } + + Future> createBefore({ + FieldType fieldType = FieldType.RichText, + String? fieldName, + Uint8List? typeOptionData, + }) { + return createField( + viewId: viewId, + fieldType: fieldType, + fieldName: fieldName, + typeOptionData: typeOptionData, + position: OrderObjectPositionPB( + position: OrderObjectPositionTypePB.Before, + objectId: fieldId, + ), + ); + } + + Future> createAfter({ + FieldType fieldType = FieldType.RichText, + String? fieldName, + Uint8List? typeOptionData, + }) { + return createField( + viewId: viewId, + fieldType: fieldType, + fieldName: fieldName, + typeOptionData: typeOptionData, + position: OrderObjectPositionPB( + position: OrderObjectPositionTypePB.After, + objectId: fieldId, + ), + ); + } + + Future> updateType({ + required FieldType fieldType, + String? fieldName, + }) => + updateFieldType( + viewId: viewId, + fieldId: fieldId, + fieldType: fieldType, + fieldName: fieldName, + ); + + Future> delete() => + deleteField(viewId: viewId, fieldId: fieldId); + + Future> duplicate() => + duplicateField(viewId: viewId, fieldId: fieldId); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_listener.dart new file mode 100644 index 0000000000000..13139390917d3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_listener.dart @@ -0,0 +1,52 @@ +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +typedef FieldSettingsValue = FlowyResult; + +class FieldSettingsListener { + FieldSettingsListener({required this.viewId}); + + final String viewId; + + PublishNotifier? _fieldSettingsNotifier = + PublishNotifier(); + DatabaseNotificationListener? _listener; + + void start({ + required void Function(FieldSettingsValue) onFieldSettingsChanged, + }) { + _fieldSettingsNotifier?.addPublishListener(onFieldSettingsChanged); + _listener = DatabaseNotificationListener( + objectId: viewId, + handler: _handler, + ); + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateFieldSettings: + result.fold( + (payload) => _fieldSettingsNotifier?.value = + FlowyResult.success(FieldSettingsPB.fromBuffer(payload)), + (error) => _fieldSettingsNotifier?.value = FlowyResult.failure(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _fieldSettingsNotifier?.dispose(); + _fieldSettingsNotifier = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_service.dart new file mode 100644 index 0000000000000..0c36d1864e937 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/field_settings_service.dart @@ -0,0 +1,81 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class FieldSettingsBackendService { + FieldSettingsBackendService({required this.viewId}); + + final String viewId; + + Future> getFieldSettings( + String fieldId, + ) { + final id = FieldIdPB(fieldId: fieldId); + final ids = RepeatedFieldIdPB()..items.add(id); + final payload = FieldIdsPB() + ..viewId = viewId + ..fieldIds = ids; + + return DatabaseEventGetFieldSettings(payload).send().then((result) { + return result.fold( + (repeatedFieldSettings) { + final fieldSetting = repeatedFieldSettings.items.first; + if (!fieldSetting.hasVisibility()) { + fieldSetting.visibility = FieldVisibility.AlwaysShown; + } + + return FlowyResult.success(fieldSetting); + }, + (r) => FlowyResult.failure(r), + ); + }); + } + + Future, FlowyError>> getAllFieldSettings() { + final payload = DatabaseViewIdPB()..value = viewId; + + return DatabaseEventGetAllFieldSettings(payload).send().then((result) { + return result.fold( + (repeatedFieldSettings) { + final fieldSettings = []; + + for (final fieldSetting in repeatedFieldSettings.items) { + if (!fieldSetting.hasVisibility()) { + fieldSetting.visibility = FieldVisibility.AlwaysShown; + } + fieldSettings.add(fieldSetting); + } + + return FlowyResult.success(fieldSettings); + }, + (r) => FlowyResult.failure(r), + ); + }); + } + + Future> updateFieldSettings({ + required String fieldId, + FieldVisibility? fieldVisibility, + double? width, + bool? wrapCellContent, + }) { + final FieldSettingsChangesetPB payload = FieldSettingsChangesetPB.create() + ..viewId = viewId + ..fieldId = fieldId; + + if (fieldVisibility != null) { + payload.visibility = fieldVisibility; + } + + if (width != null) { + payload.width = width.round(); + } + + if (wrapCellContent != null) { + payload.wrapCellContent = wrapCellContent; + } + + return DatabaseEventUpdateFieldSettings(payload).send(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_listener.dart new file mode 100644 index 0000000000000..a9295e38dd87f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_listener.dart @@ -0,0 +1,112 @@ +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/filter_changeset.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/util.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +typedef UpdateFilterNotifiedValue + = FlowyResult; + +class FiltersListener { + FiltersListener({required this.viewId}); + + final String viewId; + + PublishNotifier? _filterNotifier = + PublishNotifier(); + DatabaseNotificationListener? _listener; + + void start({ + required void Function(UpdateFilterNotifiedValue) onFilterChanged, + }) { + _filterNotifier?.addPublishListener(onFilterChanged); + _listener = DatabaseNotificationListener( + objectId: viewId, + handler: _handler, + ); + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateFilter: + result.fold( + (payload) => _filterNotifier?.value = FlowyResult.success( + FilterChangesetNotificationPB.fromBuffer(payload), + ), + (error) => _filterNotifier?.value = FlowyResult.failure(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _filterNotifier?.dispose(); + _filterNotifier = null; + } +} + +class FilterListener { + FilterListener({required this.viewId, required this.filterId}); + + final String viewId; + final String filterId; + + PublishNotifier? _onUpdateNotifier = PublishNotifier(); + + DatabaseNotificationListener? _listener; + + void start({void Function(FilterPB)? onUpdated}) { + _onUpdateNotifier?.addPublishListener((filter) { + onUpdated?.call(filter); + }); + + _listener = DatabaseNotificationListener( + objectId: viewId, + handler: _handler, + ); + } + + void handleChangeset(FilterChangesetNotificationPB changeset) { + final filters = changeset.filters.items; + final updatedIndex = filters.indexWhere( + (filter) => filter.id == filterId, + ); + if (updatedIndex != -1) { + _onUpdateNotifier?.value = filters[updatedIndex]; + } + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateFilter: + result.fold( + (payload) => handleChangeset( + FilterChangesetNotificationPB.fromBuffer(payload), + ), + (error) {}, + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _onUpdateNotifier?.dispose(); + _onUpdateNotifier = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart new file mode 100644 index 0000000000000..4e191bf019e0a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart @@ -0,0 +1,320 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:fixnum/fixnum.dart' as $fixnum; + +class FilterBackendService { + const FilterBackendService({required this.viewId}); + + final String viewId; + + Future, FlowyError>> getAllFilters() { + final payload = DatabaseViewIdPB()..value = viewId; + + return DatabaseEventGetAllFilters(payload).send().then((result) { + return result.fold( + (repeated) => FlowyResult.success(repeated.items), + (r) => FlowyResult.failure(r), + ); + }); + } + + Future> insertTextFilter({ + required String fieldId, + String? filterId, + required TextFilterConditionPB condition, + required String content, + }) { + final filter = TextFilterPB() + ..condition = condition + ..content = content; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.RichText, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.RichText, + data: filter.writeToBuffer(), + ); + } + + Future> insertCheckboxFilter({ + required String fieldId, + String? filterId, + required CheckboxFilterConditionPB condition, + }) { + final filter = CheckboxFilterPB()..condition = condition; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.Checkbox, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.Checkbox, + data: filter.writeToBuffer(), + ); + } + + Future> insertNumberFilter({ + required String fieldId, + String? filterId, + required NumberFilterConditionPB condition, + String content = "", + }) { + final filter = NumberFilterPB() + ..condition = condition + ..content = content; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.Number, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.Number, + data: filter.writeToBuffer(), + ); + } + + Future> insertDateFilter({ + required String fieldId, + required FieldType fieldType, + String? filterId, + required DateFilterConditionPB condition, + int? start, + int? end, + int? timestamp, + }) { + final filter = DateFilterPB()..condition = condition; + + if (timestamp != null) { + filter.timestamp = $fixnum.Int64(timestamp); + } + if (start != null) { + filter.start = $fixnum.Int64(start); + } + if (end != null) { + filter.end = $fixnum.Int64(end); + } + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: fieldType, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: fieldType, + data: filter.writeToBuffer(), + ); + } + + Future> insertURLFilter({ + required String fieldId, + String? filterId, + required TextFilterConditionPB condition, + String content = "", + }) { + final filter = TextFilterPB() + ..condition = condition + ..content = content; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.URL, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.URL, + data: filter.writeToBuffer(), + ); + } + + Future> insertSelectOptionFilter({ + required String fieldId, + required FieldType fieldType, + required SelectOptionFilterConditionPB condition, + String? filterId, + List optionIds = const [], + }) { + final filter = SelectOptionFilterPB() + ..condition = condition + ..optionIds.addAll(optionIds); + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: fieldType, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: fieldType, + data: filter.writeToBuffer(), + ); + } + + Future> insertChecklistFilter({ + required String fieldId, + required ChecklistFilterConditionPB condition, + String? filterId, + List optionIds = const [], + }) { + final filter = ChecklistFilterPB()..condition = condition; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.Checklist, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.Checklist, + data: filter.writeToBuffer(), + ); + } + + Future> insertTimeFilter({ + required String fieldId, + String? filterId, + required NumberFilterConditionPB condition, + String content = "", + }) { + final filter = TimeFilterPB() + ..condition = condition + ..content = content; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.Time, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.Time, + data: filter.writeToBuffer(), + ); + } + + Future> insertFilter({ + required String fieldId, + required FieldType fieldType, + required List data, + }) async { + final filterData = FilterDataPB() + ..fieldId = fieldId + ..fieldType = fieldType + ..data = data; + + final insertFilterPayload = InsertFilterPB()..data = filterData; + + final payload = DatabaseSettingChangesetPB() + ..viewId = viewId + ..insertFilter = insertFilterPayload; + + final result = await DatabaseEventUpdateDatabaseSetting(payload).send(); + return result.fold( + (l) => FlowyResult.success(l), + (err) { + Log.error(err); + return FlowyResult.failure(err); + }, + ); + } + + Future> updateFilter({ + required String filterId, + required String fieldId, + required FieldType fieldType, + required List data, + }) async { + final filterData = FilterDataPB() + ..fieldId = fieldId + ..fieldType = fieldType + ..data = data; + + final updateFilterPayload = UpdateFilterDataPB() + ..filterId = filterId + ..data = filterData; + + final payload = DatabaseSettingChangesetPB() + ..viewId = viewId + ..updateFilterData = updateFilterPayload; + + final result = await DatabaseEventUpdateDatabaseSetting(payload).send(); + return result.fold( + (l) => FlowyResult.success(l), + (err) { + Log.error(err); + return FlowyResult.failure(err); + }, + ); + } + + Future> insertMediaFilter({ + required String fieldId, + String? filterId, + required MediaFilterConditionPB condition, + String content = "", + }) { + final filter = MediaFilterPB() + ..condition = condition + ..content = content; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.Media, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.Media, + data: filter.writeToBuffer(), + ); + } + + Future> deleteFilter({ + required String filterId, + }) async { + final deleteFilterPayload = DeleteFilterPB()..filterId = filterId; + + final payload = DatabaseSettingChangesetPB() + ..viewId = viewId + ..deleteFilter = deleteFilterPayload; + + final result = await DatabaseEventUpdateDatabaseSetting(payload).send(); + return result.fold( + (l) => FlowyResult.success(l), + (err) { + Log.error(err); + return FlowyResult.failure(err); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/group_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/group_listener.dart new file mode 100644 index 0000000000000..8720c188664e3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/group_listener.dart @@ -0,0 +1,69 @@ +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/group_changeset.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +typedef GroupUpdateValue = FlowyResult; +typedef GroupByNewFieldValue = FlowyResult, FlowyError>; + +class DatabaseGroupListener { + DatabaseGroupListener(this.viewId); + + final String viewId; + + PublishNotifier? _numOfGroupsNotifier = PublishNotifier(); + PublishNotifier? _groupByFieldNotifier = + PublishNotifier(); + DatabaseNotificationListener? _listener; + + void start({ + required void Function(GroupUpdateValue) onNumOfGroupsChanged, + required void Function(GroupByNewFieldValue) onGroupByNewField, + }) { + _numOfGroupsNotifier?.addPublishListener(onNumOfGroupsChanged); + _groupByFieldNotifier?.addPublishListener(onGroupByNewField); + _listener = DatabaseNotificationListener( + objectId: viewId, + handler: _handler, + ); + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateNumOfGroups: + result.fold( + (payload) => _numOfGroupsNotifier?.value = + FlowyResult.success(GroupChangesPB.fromBuffer(payload)), + (error) => _numOfGroupsNotifier?.value = FlowyResult.failure(error), + ); + break; + case DatabaseNotification.DidGroupByField: + result.fold( + (payload) => _groupByFieldNotifier?.value = FlowyResult.success( + GroupChangesPB.fromBuffer(payload).initialGroups, + ), + (error) => _groupByFieldNotifier?.value = FlowyResult.failure(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _numOfGroupsNotifier?.dispose(); + _numOfGroupsNotifier = null; + + _groupByFieldNotifier?.dispose(); + _groupByFieldNotifier = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart new file mode 100644 index 0000000000000..f7a9be4a9cbae --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/group_service.dart @@ -0,0 +1,61 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class GroupBackendService { + GroupBackendService(this.viewId); + + final String viewId; + + Future> groupByField({ + required String fieldId, + required List settingContent, + }) { + final payload = GroupByFieldPayloadPB.create() + ..viewId = viewId + ..fieldId = fieldId + ..settingContent = settingContent; + + return DatabaseEventSetGroupByField(payload).send(); + } + + Future> updateGroup({ + required String groupId, + String? name, + bool? visible, + }) { + final payload = UpdateGroupPB.create() + ..viewId = viewId + ..groupId = groupId; + + if (name != null) { + payload.name = name; + } + if (visible != null) { + payload.visible = visible; + } + return DatabaseEventUpdateGroup(payload).send(); + } + + Future> createGroup({ + required String name, + String groupConfigId = "", + }) { + final payload = CreateGroupPayloadPB.create() + ..viewId = viewId + ..name = name; + + return DatabaseEventCreateGroup(payload).send(); + } + + Future> deleteGroup({ + required String groupId, + }) { + final payload = DeleteGroupPayloadPB.create() + ..viewId = viewId + ..groupId = groupId; + + return DatabaseEventDeleteGroup(payload).send(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/layout_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/layout_service.dart new file mode 100644 index 0000000000000..58f06d06a8069 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/layout_service.dart @@ -0,0 +1,28 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; + +ViewLayoutPB viewLayoutFromDatabaseLayout(DatabaseLayoutPB databaseLayout) { + switch (databaseLayout) { + case DatabaseLayoutPB.Board: + return ViewLayoutPB.Board; + case DatabaseLayoutPB.Calendar: + return ViewLayoutPB.Calendar; + case DatabaseLayoutPB.Grid: + return ViewLayoutPB.Grid; + default: + throw UnimplementedError; + } +} + +DatabaseLayoutPB databaseLayoutFromViewLayout(ViewLayoutPB viewLayout) { + switch (viewLayout) { + case ViewLayoutPB.Board: + return DatabaseLayoutPB.Board; + case ViewLayoutPB.Calendar: + return DatabaseLayoutPB.Calendar; + case ViewLayoutPB.Grid: + return DatabaseLayoutPB.Grid; + default: + throw UnimplementedError; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/layout_setting_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/layout_setting_listener.dart new file mode 100644 index 0000000000000..fd213f74beb1f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/layout_setting_listener.dart @@ -0,0 +1,53 @@ +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +typedef LayoutSettingsValue = FlowyResult; + +class DatabaseLayoutSettingListener { + DatabaseLayoutSettingListener(this.viewId); + + final String viewId; + + PublishNotifier>? + _settingNotifier = PublishNotifier(); + DatabaseNotificationListener? _listener; + + void start({ + required void Function(LayoutSettingsValue) + onLayoutChanged, + }) { + _settingNotifier?.addPublishListener(onLayoutChanged); + _listener = DatabaseNotificationListener( + objectId: viewId, + handler: _handler, + ); + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateLayoutSettings: + result.fold( + (payload) => _settingNotifier?.value = + FlowyResult.success(DatabaseLayoutSettingPB.fromBuffer(payload)), + (error) => _settingNotifier?.value = FlowyResult.failure(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _settingNotifier?.dispose(); + _settingNotifier = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/row_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/row_listener.dart new file mode 100644 index 0000000000000..3d033128a80e3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/row_listener.dart @@ -0,0 +1,70 @@ +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +typedef DidFetchRowCallback = void Function(DidFetchRowPB); +typedef RowMetaCallback = void Function(RowMetaPB); + +class RowListener { + RowListener(this.rowId); + + final String rowId; + + DidFetchRowCallback? _onRowFetchedCallback; + RowMetaCallback? _onMetaChangedCallback; + DatabaseNotificationListener? _listener; + + /// OnMetaChanged will be called when the row meta is changed. + /// OnRowFetched will be called when the row is fetched from remote storage + void start({ + RowMetaCallback? onMetaChanged, + DidFetchRowCallback? onRowFetched, + }) { + _onMetaChangedCallback = onMetaChanged; + _onRowFetchedCallback = onRowFetched; + _listener = DatabaseNotificationListener( + objectId: rowId, + handler: _handler, + ); + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateRowMeta: + result.fold( + (payload) { + if (_onMetaChangedCallback != null) { + _onMetaChangedCallback!(RowMetaPB.fromBuffer(payload)); + } + }, + (error) => Log.error(error), + ); + break; + case DatabaseNotification.DidFetchRow: + result.fold( + (payload) { + if (_onRowFetchedCallback != null) { + _onRowFetchedCallback!(DidFetchRowPB.fromBuffer(payload)); + } + }, + (error) => Log.error(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _onMetaChangedCallback = null; + _onRowFetchedCallback = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/row_meta_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/row_meta_listener.dart new file mode 100644 index 0000000000000..1b7e66fd75fe0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/row_meta_listener.dart @@ -0,0 +1,51 @@ +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +typedef RowMetaCallback = void Function(RowMetaPB); + +class RowMetaListener { + RowMetaListener(this.rowId); + + final String rowId; + + RowMetaCallback? _callback; + DatabaseNotificationListener? _listener; + + void start({required RowMetaCallback callback}) { + _callback = callback; + _listener = DatabaseNotificationListener( + objectId: rowId, + handler: _handler, + ); + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateRowMeta: + result.fold( + (payload) { + if (_callback != null) { + _callback!(RowMetaPB.fromBuffer(payload)); + } + }, + (error) => Log.error(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _callback = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/select_option_cell_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/select_option_cell_service.dart new file mode 100644 index 0000000000000..2e0c24718e6ed --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/select_option_cell_service.dart @@ -0,0 +1,89 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:nanoid/nanoid.dart'; + +class SelectOptionCellBackendService { + SelectOptionCellBackendService({ + required this.viewId, + required this.fieldId, + required this.rowId, + }); + + final String viewId; + final String fieldId; + final String rowId; + + Future> create({ + required String name, + SelectOptionColorPB? color, + bool isSelected = true, + }) { + final option = SelectOptionPB() + ..id = nanoid(4) + ..name = name; + if (color != null) { + option.color = color; + } + + final payload = RepeatedSelectOptionPayload() + ..viewId = viewId + ..fieldId = fieldId + ..rowId = rowId + ..items.add(option); + + return DatabaseEventInsertOrUpdateSelectOption(payload).send(); + } + + Future> update({ + required SelectOptionPB option, + }) { + final payload = RepeatedSelectOptionPayload() + ..items.add(option) + ..viewId = viewId + ..fieldId = fieldId + ..rowId = rowId; + + return DatabaseEventInsertOrUpdateSelectOption(payload).send(); + } + + Future> delete({ + required Iterable options, + }) { + final payload = RepeatedSelectOptionPayload() + ..items.addAll(options) + ..viewId = viewId + ..fieldId = fieldId + ..rowId = rowId; + + return DatabaseEventDeleteSelectOption(payload).send(); + } + + Future> select({ + required Iterable optionIds, + }) { + final payload = SelectOptionCellChangesetPB() + ..cellIdentifier = _cellIdentifier() + ..insertOptionIds.addAll(optionIds); + + return DatabaseEventUpdateSelectOptionCell(payload).send(); + } + + Future> unselect({ + required Iterable optionIds, + }) { + final payload = SelectOptionCellChangesetPB() + ..cellIdentifier = _cellIdentifier() + ..deleteOptionIds.addAll(optionIds); + + return DatabaseEventUpdateSelectOptionCell(payload).send(); + } + + CellIdPB _cellIdentifier() { + return CellIdPB() + ..viewId = viewId + ..fieldId = fieldId + ..rowId = rowId; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/sort_listener.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/sort_listener.dart new file mode 100644 index 0000000000000..83c9310331cb0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/sort_listener.dart @@ -0,0 +1,54 @@ +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/grid_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +typedef SortNotifiedValue + = FlowyResult; + +class SortsListener { + SortsListener({required this.viewId}); + + final String viewId; + + PublishNotifier? _notifier = PublishNotifier(); + DatabaseNotificationListener? _listener; + + void start({ + required void Function(SortNotifiedValue) onSortChanged, + }) { + _notifier?.addPublishListener(onSortChanged); + _listener = DatabaseNotificationListener( + objectId: viewId, + handler: _handler, + ); + } + + void _handler( + DatabaseNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DatabaseNotification.DidUpdateSort: + result.fold( + (payload) => _notifier?.value = FlowyResult.success( + SortChangesetNotificationPB.fromBuffer(payload), + ), + (error) => _notifier?.value = FlowyResult.failure(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _notifier?.dispose(); + _notifier = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/sort_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/sort_service.dart new file mode 100644 index 0000000000000..bdd8ea97164ff --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/sort_service.dart @@ -0,0 +1,121 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class SortBackendService { + SortBackendService({required this.viewId}); + + final String viewId; + + Future, FlowyError>> getAllSorts() { + final payload = DatabaseViewIdPB()..value = viewId; + + return DatabaseEventGetAllSorts(payload).send().then((result) { + return result.fold( + (repeated) => FlowyResult.success(repeated.items), + (r) => FlowyResult.failure(r), + ); + }); + } + + Future> updateSort({ + required String sortId, + required String fieldId, + required SortConditionPB condition, + }) { + final insertSortPayload = UpdateSortPayloadPB.create() + ..viewId = viewId + ..sortId = sortId + ..fieldId = fieldId + ..condition = condition; + + final payload = DatabaseSettingChangesetPB.create() + ..viewId = viewId + ..updateSort = insertSortPayload; + return DatabaseEventUpdateDatabaseSetting(payload).send().then((result) { + return result.fold( + (l) => FlowyResult.success(l), + (err) { + Log.error(err); + return FlowyResult.failure(err); + }, + ); + }); + } + + Future> insertSort({ + required String fieldId, + required SortConditionPB condition, + }) { + final insertSortPayload = UpdateSortPayloadPB.create() + ..fieldId = fieldId + ..viewId = viewId + ..condition = condition; + + final payload = DatabaseSettingChangesetPB.create() + ..viewId = viewId + ..updateSort = insertSortPayload; + return DatabaseEventUpdateDatabaseSetting(payload).send().then((result) { + return result.fold( + (l) => FlowyResult.success(l), + (err) { + Log.error(err); + return FlowyResult.failure(err); + }, + ); + }); + } + + Future> reorderSort({ + required String fromSortId, + required String toSortId, + }) { + final payload = DatabaseSettingChangesetPB() + ..viewId = viewId + ..reorderSort = (ReorderSortPayloadPB() + ..viewId = viewId + ..fromSortId = fromSortId + ..toSortId = toSortId); + + return DatabaseEventUpdateDatabaseSetting(payload).send(); + } + + Future> deleteSort({ + required String sortId, + }) { + final deleteSortPayload = DeleteSortPayloadPB.create() + ..sortId = sortId + ..viewId = viewId; + + final payload = DatabaseSettingChangesetPB.create() + ..viewId = viewId + ..deleteSort = deleteSortPayload; + + return DatabaseEventUpdateDatabaseSetting(payload).send().then((result) { + return result.fold( + (l) => FlowyResult.success(l), + (err) { + Log.error(err); + return FlowyResult.failure(err); + }, + ); + }); + } + + Future> deleteAllSorts() { + final payload = DatabaseViewIdPB(value: viewId); + return DatabaseEventDeleteAllSorts(payload).send().then((result) { + return result.fold( + (l) => FlowyResult.success(l), + (err) { + Log.error(err); + return FlowyResult.failure(err); + }, + ); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/type_option_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/type_option_service.dart new file mode 100644 index 0000000000000..a222e69853473 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/type_option_service.dart @@ -0,0 +1,25 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class TypeOptionBackendService { + TypeOptionBackendService({ + required this.viewId, + required this.fieldId, + }); + + final String viewId; + final String fieldId; + + Future> newOption({ + required String name, + }) { + final payload = CreateSelectOptionPayloadPB.create() + ..optionName = name + ..viewId = viewId + ..fieldId = fieldId; + + return DatabaseEventCreateSelectOption(payload).send(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/calculations_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/calculations_bloc.dart new file mode 100644 index 0000000000000..a2b80a29df973 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/calculations_bloc.dart @@ -0,0 +1,188 @@ +import 'package:appflowy/plugins/database/application/calculations/calculations_listener.dart'; +import 'package:appflowy/plugins/database/application/calculations/calculations_service.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/calculation_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pbenum.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'calculations_bloc.freezed.dart'; + +class CalculationsBloc extends Bloc { + CalculationsBloc({ + required this.viewId, + required FieldController fieldController, + }) : _fieldController = fieldController, + _calculationsListener = CalculationsListener(viewId: viewId), + _calculationsService = CalculationsBackendService(viewId: viewId), + super(CalculationsState.initial()) { + _dispatch(); + } + + final String viewId; + final FieldController _fieldController; + final CalculationsListener _calculationsListener; + late final CalculationsBackendService _calculationsService; + + @override + Future close() async { + _fieldController.removeListener(onFieldsListener: _onReceiveFields); + await _calculationsListener.stop(); + await super.close(); + } + + void _dispatch() { + on((event, emit) async { + await event.when( + started: () async { + _startListening(); + await _getAllCalculations(); + + if (!isClosed) { + add( + CalculationsEvent.didReceiveFieldUpdate( + _fieldController.fieldInfos, + ), + ); + } + }, + didReceiveFieldUpdate: (fields) async { + emit( + state.copyWith( + fields: fields + .where( + (e) => + e.visibility != null && + e.visibility != FieldVisibility.AlwaysHidden, + ) + .toList(), + ), + ); + }, + didReceiveCalculationsUpdate: (calculationsMap) async { + emit( + state.copyWith( + calculationsByFieldId: calculationsMap, + ), + ); + }, + updateCalculationType: (fieldId, type, calculationId) async { + await _calculationsService.updateCalculation( + fieldId, + type, + calculationId: calculationId, + ); + }, + removeCalculation: (fieldId, calculationId) async { + await _calculationsService.removeCalculation(fieldId, calculationId); + }, + ); + }); + } + + void _startListening() { + _fieldController.addListener( + listenWhen: () => !isClosed, + onReceiveFields: _onReceiveFields, + ); + + _calculationsListener.start( + onCalculationChanged: (changesetOrFailure) { + if (isClosed) { + return; + } + + changesetOrFailure.fold( + (changeset) { + final calculationsMap = {...state.calculationsByFieldId}; + if (changeset.insertCalculations.isNotEmpty) { + for (final insert in changeset.insertCalculations) { + calculationsMap[insert.fieldId] = insert; + } + } + + if (changeset.updateCalculations.isNotEmpty) { + for (final update in changeset.updateCalculations) { + calculationsMap.removeWhere((key, _) => key == update.fieldId); + calculationsMap.addAll({update.fieldId: update}); + } + } + + if (changeset.deleteCalculations.isNotEmpty) { + for (final delete in changeset.deleteCalculations) { + calculationsMap.removeWhere((key, _) => key == delete.fieldId); + } + } + + add( + CalculationsEvent.didReceiveCalculationsUpdate( + calculationsMap, + ), + ); + }, + (_) => null, + ); + }, + ); + } + + void _onReceiveFields(List fields) => + add(CalculationsEvent.didReceiveFieldUpdate(fields)); + + Future _getAllCalculations() async { + final calculationsOrFailure = await _calculationsService.getCalculations(); + + if (isClosed) { + return; + } + + final RepeatedCalculationsPB? calculations = + calculationsOrFailure.fold((s) => s, (e) => null); + if (calculations != null) { + final calculationMap = {}; + for (final calculation in calculations.items) { + calculationMap[calculation.fieldId] = calculation; + } + + add(CalculationsEvent.didReceiveCalculationsUpdate(calculationMap)); + } + } +} + +@freezed +class CalculationsEvent with _$CalculationsEvent { + const factory CalculationsEvent.started() = _Started; + + const factory CalculationsEvent.didReceiveFieldUpdate( + List fields, + ) = _DidReceiveFieldUpdate; + + const factory CalculationsEvent.didReceiveCalculationsUpdate( + Map calculationsByFieldId, + ) = _DidReceiveCalculationsUpdate; + + const factory CalculationsEvent.updateCalculationType( + String fieldId, + CalculationType type, { + @Default(null) String? calculationId, + }) = _UpdateCalculationType; + + const factory CalculationsEvent.removeCalculation( + String fieldId, + String calculationId, + ) = _RemoveCalculation; +} + +@freezed +class CalculationsState with _$CalculationsState { + const factory CalculationsState({ + required List fields, + required Map calculationsByFieldId, + }) = _CalculationsState; + + factory CalculationsState.initial() => const CalculationsState( + fields: [], + calculationsByFieldId: {}, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/field_type_calc_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/field_type_calc_ext.dart new file mode 100644 index 0000000000000..8fbb40ffde3ba --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/field_type_calc_ext.dart @@ -0,0 +1,40 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +extension AvailableCalculations on FieldType { + List calculationsForFieldType() { + final calculationTypes = [ + CalculationType.Count, + ]; + + // These FieldTypes cannot be empty, or might hold secondary + // data causing them to be seen as not empty when in fact they + // are empty. + if (![ + FieldType.URL, + FieldType.Checkbox, + FieldType.LastEditedTime, + FieldType.CreatedTime, + ].contains(this)) { + calculationTypes.addAll([ + CalculationType.CountEmpty, + CalculationType.CountNonEmpty, + ]); + } + + switch (this) { + case FieldType.Number: + calculationTypes.addAll([ + CalculationType.Sum, + CalculationType.Average, + CalculationType.Min, + CalculationType.Max, + CalculationType.Median, + ]); + break; + default: + break; + } + + return calculationTypes; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_editor_bloc.dart new file mode 100644 index 0000000000000..6a386ff130644 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_editor_bloc.dart @@ -0,0 +1,227 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'filter_editor_bloc.freezed.dart'; + +class FilterEditorBloc extends Bloc { + FilterEditorBloc({required this.viewId, required this.fieldController}) + : _filterBackendSvc = FilterBackendService(viewId: viewId), + super( + FilterEditorState.initial( + viewId, + fieldController.filters, + _getCreatableFilter(fieldController.fieldInfos), + ), + ) { + _dispatch(); + _startListening(); + } + + final String viewId; + final FieldController fieldController; + final FilterBackendService _filterBackendSvc; + + void Function(List)? _onFilterFn; + void Function(List)? _onFieldFn; + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didReceiveFilters: (filters) { + emit(state.copyWith(filters: filters)); + }, + didReceiveFields: (List fields) { + emit( + state.copyWith( + fields: _getCreatableFilter(fields), + ), + ); + }, + createFilter: (field) { + return _createDefaultFilter(null, field); + }, + changeFilteringField: (filterId, field) { + return _createDefaultFilter(filterId, field); + }, + updateFilter: (filter) { + return _filterBackendSvc.updateFilter( + filterId: filter.filterId, + fieldId: filter.fieldId, + fieldType: filter.fieldType, + data: filter.writeToBuffer(), + ); + }, + deleteFilter: (filterId) async { + return _filterBackendSvc.deleteFilter(filterId: filterId); + }, + ); + }, + ); + } + + void _startListening() { + _onFilterFn = (filters) { + add(FilterEditorEvent.didReceiveFilters(filters)); + }; + + _onFieldFn = (fields) { + add(FilterEditorEvent.didReceiveFields(fields)); + }; + + fieldController.addListener( + onFilters: _onFilterFn, + onReceiveFields: _onFieldFn, + ); + } + + @override + Future close() async { + if (_onFilterFn != null) { + fieldController.removeListener(onFiltersListener: _onFilterFn!); + _onFilterFn = null; + } + if (_onFieldFn != null) { + fieldController.removeListener(onFieldsListener: _onFieldFn!); + _onFieldFn = null; + } + return super.close(); + } + + Future> _createDefaultFilter( + String? filterId, + FieldInfo field, + ) async { + final fieldId = field.id; + switch (field.fieldType) { + case FieldType.Checkbox: + return _filterBackendSvc.insertCheckboxFilter( + filterId: filterId, + fieldId: fieldId, + condition: CheckboxFilterConditionPB.IsChecked, + ); + case FieldType.DateTime: + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + final now = DateTime.now(); + final timestamp = + DateTime(now.year, now.month, now.day).millisecondsSinceEpoch ~/ + 1000; + return _filterBackendSvc.insertDateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: field.fieldType, + condition: DateFilterConditionPB.DateStartsOn, + timestamp: timestamp, + ); + case FieldType.MultiSelect: + return _filterBackendSvc.insertSelectOptionFilter( + filterId: filterId, + fieldId: fieldId, + condition: SelectOptionFilterConditionPB.OptionContains, + fieldType: FieldType.MultiSelect, + ); + case FieldType.Checklist: + return _filterBackendSvc.insertChecklistFilter( + filterId: filterId, + fieldId: fieldId, + condition: ChecklistFilterConditionPB.IsIncomplete, + ); + case FieldType.Number: + return _filterBackendSvc.insertNumberFilter( + filterId: filterId, + fieldId: fieldId, + condition: NumberFilterConditionPB.Equal, + ); + case FieldType.Time: + return _filterBackendSvc.insertTimeFilter( + filterId: filterId, + fieldId: fieldId, + condition: NumberFilterConditionPB.Equal, + ); + case FieldType.RichText: + return _filterBackendSvc.insertTextFilter( + filterId: filterId, + fieldId: fieldId, + condition: TextFilterConditionPB.TextContains, + content: '', + ); + case FieldType.SingleSelect: + return _filterBackendSvc.insertSelectOptionFilter( + filterId: filterId, + fieldId: fieldId, + condition: SelectOptionFilterConditionPB.OptionIs, + fieldType: FieldType.SingleSelect, + ); + case FieldType.URL: + return _filterBackendSvc.insertURLFilter( + filterId: filterId, + fieldId: fieldId, + condition: TextFilterConditionPB.TextContains, + ); + case FieldType.Media: + return _filterBackendSvc.insertMediaFilter( + filterId: filterId, + fieldId: fieldId, + condition: MediaFilterConditionPB.MediaIsNotEmpty, + ); + default: + throw UnimplementedError(); + } + } +} + +@freezed +class FilterEditorEvent with _$FilterEditorEvent { + const factory FilterEditorEvent.didReceiveFilters( + List filters, + ) = _DidReceiveFilters; + const factory FilterEditorEvent.didReceiveFields(List fields) = + _DidReceiveFields; + const factory FilterEditorEvent.createFilter(FieldInfo field) = _CreateFilter; + const factory FilterEditorEvent.updateFilter(DatabaseFilter filter) = + _UpdateFilter; + const factory FilterEditorEvent.changeFilteringField( + String filterId, + FieldInfo field, + ) = _ChangeFilteringField; + const factory FilterEditorEvent.deleteFilter(String filterId) = _DeleteFilter; +} + +@freezed +class FilterEditorState with _$FilterEditorState { + const factory FilterEditorState({ + required String viewId, + required List filters, + required List fields, + }) = _FilterEditorState; + + factory FilterEditorState.initial( + String viewId, + List filterInfos, + List fields, + ) => + FilterEditorState( + viewId: viewId, + filters: filterInfos, + fields: fields, + ); +} + +List _getCreatableFilter(List fieldInfos) { + final List creatableFields = List.from(fieldInfos); + creatableFields.retainWhere( + (field) => field.fieldType.canCreateFilter && !field.isGroupField, + ); + return creatableFields; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_loader.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_loader.dart new file mode 100644 index 0000000000000..0e7c59bbb6671 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/select_option_loader.dart @@ -0,0 +1,32 @@ +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +abstract class SelectOptionFilterDelegate { + const SelectOptionFilterDelegate(); + + List getOptions(FieldInfo fieldInfo); +} + +class SingleSelectOptionFilterDelegateImpl + implements SelectOptionFilterDelegate { + const SingleSelectOptionFilterDelegateImpl(); + + @override + List getOptions(FieldInfo fieldInfo) { + final parser = SingleSelectTypeOptionDataParser(); + return parser.fromBuffer(fieldInfo.field.typeOptionData).options; + } +} + +class MultiSelectOptionFilterDelegateImpl + implements SelectOptionFilterDelegate { + const MultiSelectOptionFilterDelegateImpl(); + + @override + List getOptions(FieldInfo fieldInfo) { + return MultiSelectTypeOptionDataParser() + .fromBuffer(fieldInfo.field.typeOptionData) + .options; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_accessory_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_accessory_bloc.dart new file mode 100644 index 0000000000000..11495c87d5c70 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_accessory_bloc.dart @@ -0,0 +1,48 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'grid_accessory_bloc.freezed.dart'; + +class DatabaseViewSettingExtensionBloc extends Bloc< + DatabaseViewSettingExtensionEvent, DatabaseViewSettingExtensionState> { + DatabaseViewSettingExtensionBloc({required this.viewId}) + : super(DatabaseViewSettingExtensionState.initial(viewId)) { + on( + (event, emit) async { + event.when( + initial: () {}, + toggleMenu: () { + emit(state.copyWith(isVisible: !state.isVisible)); + }, + ); + }, + ); + } + + final String viewId; +} + +@freezed +class DatabaseViewSettingExtensionEvent + with _$DatabaseViewSettingExtensionEvent { + const factory DatabaseViewSettingExtensionEvent.initial() = _Initial; + const factory DatabaseViewSettingExtensionEvent.toggleMenu() = + _MenuVisibleChange; +} + +@freezed +class DatabaseViewSettingExtensionState + with _$DatabaseViewSettingExtensionState { + const factory DatabaseViewSettingExtensionState({ + required String viewId, + required bool isVisible, + }) = _DatabaseViewSettingExtensionState; + + factory DatabaseViewSettingExtensionState.initial( + String viewId, + ) => + DatabaseViewSettingExtensionState( + viewId: viewId, + isVisible: false, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart new file mode 100644 index 0000000000000..9b59b997b1a5c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_bloc.dart @@ -0,0 +1,267 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/defines.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/application/field/sort_entities.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../application/database_controller.dart'; + +part 'grid_bloc.freezed.dart'; + +class GridBloc extends Bloc { + GridBloc({ + required ViewPB view, + required this.databaseController, + this.shrinkWrapped = false, + }) : super(GridState.initial(view.id)) { + _dispatch(); + } + + final DatabaseController databaseController; + + /// When true will emit the count of visible rows to show + /// + final bool shrinkWrapped; + + String get viewId => databaseController.viewId; + + UserProfilePB? _userProfile; + UserProfilePB? get userProfile => _userProfile; + + DatabaseCallbacks? _databaseCallbacks; + + @override + Future close() async { + databaseController.removeListener(onDatabaseChanged: _databaseCallbacks); + _databaseCallbacks = null; + await super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () async { + final response = await UserEventGetUserProfile().send(); + response.fold( + (userProfile) => _userProfile = userProfile, + (err) => Log.error(err), + ); + + _startListening(); + await _openGrid(emit); + }, + openRowDetail: (row) { + emit( + state.copyWith( + createdRow: row, + openRowDetail: true, + ), + ); + }, + createRow: (openRowDetail) async { + final lastVisibleRowId = + shrinkWrapped ? state.lastVisibleRow?.rowId : null; + + final result = await RowBackendService.createRow( + viewId: viewId, + position: lastVisibleRowId != null + ? OrderObjectPositionTypePB.After + : null, + targetRowId: lastVisibleRowId, + ); + result.fold( + (createdRow) => emit( + state.copyWith( + createdRow: createdRow, + openRowDetail: openRowDetail ?? false, + visibleRows: state.visibleRows + 1, + ), + ), + (err) => Log.error(err), + ); + }, + resetCreatedRow: () { + emit(state.copyWith(createdRow: null, openRowDetail: false)); + }, + deleteRow: (rowInfo) async { + await RowBackendService.deleteRows(viewId, [rowInfo.rowId]); + }, + moveRow: (int from, int to) { + final List rows = [...state.rowInfos]; + + final fromRow = rows[from].rowId; + final toRow = rows[to].rowId; + + rows.insert(to, rows.removeAt(from)); + emit(state.copyWith(rowInfos: rows)); + + databaseController.moveRow(fromRowId: fromRow, toRowId: toRow); + }, + didReceiveFieldUpdate: (fields) { + emit(state.copyWith(fields: fields)); + }, + didLoadRows: (newRowInfos, reason) { + emit( + state.copyWith( + rowInfos: newRowInfos, + rowCount: newRowInfos.length, + reason: reason, + ), + ); + }, + didReceveFilters: (filters) { + emit(state.copyWith(filters: filters)); + }, + didReceveSorts: (sorts) { + emit(state.copyWith(reorderable: sorts.isEmpty, sorts: sorts)); + }, + loadMoreRows: () { + emit(state.copyWith(visibleRows: state.visibleRows + 25)); + }, + ); + }, + ); + } + + RowCache get rowCache => databaseController.rowCache; + + void _startListening() { + _databaseCallbacks = DatabaseCallbacks( + onNumOfRowsChanged: (rowInfos, _, reason) { + if (!isClosed) { + add(GridEvent.didLoadRows(rowInfos, reason)); + } + }, + onRowsCreated: (rows) { + for (final row in rows) { + if (!isClosed && row.isHiddenInView) { + add(GridEvent.openRowDetail(row.rowMeta)); + } + } + }, + onRowsUpdated: (rows, reason) { + // TODO(nathan): separate different reasons + if (!isClosed) { + add( + GridEvent.didLoadRows(databaseController.rowCache.rowInfos, reason), + ); + } + }, + onFieldsChanged: (fields) { + if (!isClosed) { + add(GridEvent.didReceiveFieldUpdate(fields)); + } + }, + onFiltersChanged: (filters) { + if (!isClosed) { + add(GridEvent.didReceveFilters(filters)); + } + }, + onSortsChanged: (sorts) { + if (!isClosed) { + add(GridEvent.didReceveSorts(sorts)); + } + }, + ); + databaseController.addListener(onDatabaseChanged: _databaseCallbacks); + } + + Future _openGrid(Emitter emit) async { + final result = await databaseController.open(); + result.fold( + (grid) { + databaseController.setIsLoading(false); + emit( + state.copyWith( + loadingState: LoadingState.finish(FlowyResult.success(null)), + ), + ); + }, + (err) => emit( + state.copyWith( + loadingState: LoadingState.finish(FlowyResult.failure(err)), + ), + ), + ); + } +} + +@freezed +class GridEvent with _$GridEvent { + const factory GridEvent.initial() = InitialGrid; + const factory GridEvent.openRowDetail(RowMetaPB row) = _OpenRowDetail; + const factory GridEvent.createRow({bool? openRowDetail}) = _CreateRow; + const factory GridEvent.resetCreatedRow() = _ResetCreatedRow; + const factory GridEvent.deleteRow(RowInfo rowInfo) = _DeleteRow; + const factory GridEvent.moveRow(int from, int to) = _MoveRow; + const factory GridEvent.didLoadRows( + List rows, + ChangedReason reason, + ) = _DidReceiveRowUpdate; + const factory GridEvent.didReceiveFieldUpdate( + List fields, + ) = _DidReceiveFieldUpdate; + const factory GridEvent.didReceveFilters(List filters) = + _DidReceiveFilters; + const factory GridEvent.didReceveSorts(List sorts) = + _DidReceiveSorts; + const factory GridEvent.loadMoreRows() = _LoadMoreRows; +} + +@freezed +class GridState with _$GridState { + const factory GridState({ + required String viewId, + required List fields, + required List rowInfos, + required int rowCount, + required RowMetaPB? createdRow, + required LoadingState loadingState, + required bool reorderable, + required ChangedReason reason, + required List sorts, + required List filters, + required bool openRowDetail, + @Default(0) int visibleRows, + }) = _GridState; + + factory GridState.initial(String viewId) => GridState( + fields: [], + rowInfos: [], + rowCount: 0, + createdRow: null, + viewId: viewId, + reorderable: true, + loadingState: const LoadingState.loading(), + reason: const InitialListState(), + filters: [], + sorts: [], + openRowDetail: false, + visibleRows: 25, + ); +} + +extension _LastVisibleRow on GridState { + /// Returns the last visible [RowInfo] in the list of [rowInfos]. + /// Only returns if the visibleRows is less than the rowCount, otherwise returns null. + /// + RowInfo? get lastVisibleRow { + if (visibleRows < rowCount) { + return rowInfos[visibleRows - 1]; + } + + return null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_header_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_header_bloc.dart new file mode 100644 index 0000000000000..a869b636d2325 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/grid_header_bloc.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/field_service.dart'; + +part 'grid_header_bloc.freezed.dart'; + +class GridHeaderBloc extends Bloc { + GridHeaderBloc({required this.viewId, required this.fieldController}) + : super(GridHeaderState.initial()) { + _dispatch(); + } + + final String viewId; + final FieldController fieldController; + + @override + Future close() async { + fieldController.removeListener(onFieldsListener: _onReceiveFields); + await super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () { + _startListening(); + add( + GridHeaderEvent.didReceiveFieldUpdate(fieldController.fieldInfos), + ); + }, + didReceiveFieldUpdate: (List fields) { + emit( + state.copyWith( + fields: fields + .where( + (element) => + element.visibility != null && + element.visibility != FieldVisibility.AlwaysHidden, + ) + .toList(), + ), + ); + }, + startEditingField: (fieldId) { + emit(state.copyWith(editingFieldId: fieldId)); + }, + startEditingNewField: (fieldId) { + emit(state.copyWith(editingFieldId: fieldId, newFieldId: fieldId)); + }, + endEditingField: () { + emit(state.copyWith(editingFieldId: null, newFieldId: null)); + }, + moveField: (fromIndex, toIndex) async { + await _moveField(fromIndex, toIndex, emit); + }, + ); + }, + ); + } + + Future _moveField( + int fromIndex, + int toIndex, + Emitter emit, + ) async { + final fromId = state.fields[fromIndex].id; + final toId = state.fields[toIndex].id; + + final fields = List.from(state.fields); + fields.insert(toIndex, fields.removeAt(fromIndex)); + emit(state.copyWith(fields: fields)); + + final result = await FieldBackendService.moveField( + viewId: viewId, + fromFieldId: fromId, + toFieldId: toId, + ); + result.fold((l) {}, (err) => Log.error(err)); + } + + void _startListening() { + fieldController.addListener( + onReceiveFields: _onReceiveFields, + listenWhen: () => !isClosed, + ); + } + + void _onReceiveFields(List fields) => + add(GridHeaderEvent.didReceiveFieldUpdate(fields)); +} + +@freezed +class GridHeaderEvent with _$GridHeaderEvent { + const factory GridHeaderEvent.initial() = _InitialHeader; + const factory GridHeaderEvent.didReceiveFieldUpdate(List fields) = + _DidReceiveFieldUpdate; + const factory GridHeaderEvent.startEditingField(String fieldId) = + _StartEditingField; + const factory GridHeaderEvent.startEditingNewField(String fieldId) = + _StartEditingNewField; + const factory GridHeaderEvent.endEditingField() = _EndEditingField; + const factory GridHeaderEvent.moveField( + int fromIndex, + int toIndex, + ) = _MoveField; +} + +@freezed +class GridHeaderState with _$GridHeaderState { + const factory GridHeaderState({ + required List fields, + required String? editingFieldId, + required String? newFieldId, + }) = _GridHeaderState; + + factory GridHeaderState.initial() => + const GridHeaderState(fields: [], editingFieldId: null, newFieldId: null); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/mobile_row_detail_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/mobile_row_detail_bloc.dart new file mode 100644 index 0000000000000..ec42cd5cac3df --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/mobile_row_detail_bloc.dart @@ -0,0 +1,123 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'mobile_row_detail_bloc.freezed.dart'; + +class MobileRowDetailBloc + extends Bloc { + MobileRowDetailBloc({required this.databaseController}) + : super(MobileRowDetailState.initial()) { + rowBackendService = RowBackendService(viewId: databaseController.viewId); + _dispatch(); + } + + final DatabaseController databaseController; + late final RowBackendService rowBackendService; + + UserProfilePB? _userProfile; + UserProfilePB? get userProfile => _userProfile; + + DatabaseCallbacks? _databaseCallbacks; + + @override + Future close() async { + databaseController.removeListener(onDatabaseChanged: _databaseCallbacks); + _databaseCallbacks = null; + await super.close(); + } + + void _dispatch() { + on( + (event, emit) { + event.when( + initial: (rowId) async { + _startListening(); + + emit( + state.copyWith( + isLoading: false, + currentRowId: rowId, + rowInfos: databaseController.rowCache.rowInfos, + ), + ); + + final result = await UserEventGetUserProfile().send(); + result.fold( + (profile) => _userProfile = profile, + (error) => Log.error(error), + ); + }, + didLoadRows: (rows) { + emit(state.copyWith(rowInfos: rows)); + }, + changeRowId: (rowId) { + emit(state.copyWith(currentRowId: rowId)); + }, + addCover: (rowCover) async { + if (state.currentRowId == null) { + return; + } + + await rowBackendService.updateMeta( + rowId: state.currentRowId!, + cover: rowCover, + ); + }, + ); + }, + ); + } + + void _startListening() { + _databaseCallbacks = DatabaseCallbacks( + onNumOfRowsChanged: (rowInfos, _, reason) { + if (!isClosed) { + add(MobileRowDetailEvent.didLoadRows(rowInfos)); + } + }, + onRowsUpdated: (rows, reason) { + if (!isClosed) { + add( + MobileRowDetailEvent.didLoadRows( + databaseController.rowCache.rowInfos, + ), + ); + } + }, + ); + databaseController.addListener(onDatabaseChanged: _databaseCallbacks); + } +} + +@freezed +class MobileRowDetailEvent with _$MobileRowDetailEvent { + const factory MobileRowDetailEvent.initial(String rowId) = _Initial; + const factory MobileRowDetailEvent.didLoadRows(List rows) = + _DidLoadRows; + const factory MobileRowDetailEvent.changeRowId(String rowId) = _ChangeRowId; + const factory MobileRowDetailEvent.addCover(RowCoverPB cover) = _AddCover; +} + +@freezed +class MobileRowDetailState with _$MobileRowDetailState { + const factory MobileRowDetailState({ + required bool isLoading, + required String? currentRowId, + required List rowInfos, + }) = _MobileRowDetailState; + + factory MobileRowDetailState.initial() { + return const MobileRowDetailState( + isLoading: true, + rowInfos: [], + currentRowId: null, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_bloc.dart new file mode 100644 index 0000000000000..402ae6e5966f2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_bloc.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../application/row/row_cache.dart'; +import '../../../application/row/row_controller.dart'; +import '../../../application/row/row_service.dart'; + +part 'row_bloc.freezed.dart'; + +class RowBloc extends Bloc { + RowBloc({ + required this.fieldController, + required this.rowId, + required this.viewId, + required RowController rowController, + }) : _rowBackendSvc = RowBackendService(viewId: viewId), + _rowController = rowController, + super(RowState.initial()) { + _dispatch(); + _startListening(); + _init(); + rowController.initialize(); + } + + final FieldController fieldController; + final RowBackendService _rowBackendSvc; + final RowController _rowController; + final String viewId; + final String rowId; + + @override + Future close() async { + await _rowController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + event.when( + createRow: () { + _rowBackendSvc.createRowAfter(rowId); + }, + didReceiveCells: (List cellContexts, reason) { + final visibleCellContexts = cellContexts + .where( + (cellContext) => fieldController + .getField(cellContext.fieldId)! + .fieldSettings! + .visibility + .isVisibleState(), + ) + .toList(); + emit( + state.copyWith( + cellContexts: visibleCellContexts, + changeReason: reason, + ), + ); + }, + ); + }, + ); + } + + void _startListening() => + _rowController.addListener(onRowChanged: _onRowChanged); + + void _onRowChanged(List cells, ChangedReason reason) { + if (!isClosed) { + add(RowEvent.didReceiveCells(cells, reason)); + } + } + + void _init() { + add( + RowEvent.didReceiveCells( + _rowController.loadCells(), + const ChangedReason.setInitialRows(), + ), + ); + } +} + +@freezed +class RowEvent with _$RowEvent { + const factory RowEvent.createRow() = _CreateRow; + const factory RowEvent.didReceiveCells( + List cellsByFieldId, + ChangedReason reason, + ) = _DidReceiveCells; +} + +@freezed +class RowState with _$RowState { + const factory RowState({ + required List cellContexts, + ChangedReason? changeReason, + }) = _RowState; + + factory RowState.initial() => const RowState(cellContexts: []); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_detail_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_detail_bloc.dart new file mode 100644 index 0000000000000..e7d4658df8456 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_detail_bloc.dart @@ -0,0 +1,294 @@ +import 'package:flutter/foundation.dart'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; +import 'package:appflowy/plugins/database/domain/row_meta_listener.dart'; +import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'row_detail_bloc.freezed.dart'; + +class RowDetailBloc extends Bloc { + RowDetailBloc({ + required this.fieldController, + required this.rowController, + }) : _metaListener = RowMetaListener(rowController.rowId), + _rowService = RowBackendService(viewId: rowController.viewId), + super(RowDetailState.initial(rowController.rowMeta)) { + _dispatch(); + _startListening(); + _init(); + + rowController.initialize(); + } + + final FieldController fieldController; + final RowController rowController; + final RowMetaListener _metaListener; + final RowBackendService _rowService; + final List allCells = []; + + @override + Future close() async { + await rowController.dispose(); + await _metaListener.stop(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didReceiveCellDatas: (visibleCells, numHiddenFields) { + emit( + state.copyWith( + visibleCells: visibleCells, + numHiddenFields: numHiddenFields, + ), + ); + }, + didUpdateFields: (fields) { + emit(state.copyWith(fields: fields)); + }, + deleteField: (fieldId) async { + final result = await FieldBackendService.deleteField( + viewId: rowController.viewId, + fieldId: fieldId, + ); + result.fold((l) {}, (err) => Log.error(err)); + }, + toggleFieldVisibility: (fieldId) async { + await _toggleFieldVisibility(fieldId, emit); + }, + reorderField: (fromIndex, toIndex) async { + await _reorderField(fromIndex, toIndex, emit); + }, + toggleHiddenFieldVisibility: () { + final showHiddenFields = !state.showHiddenFields; + final visibleCells = List.from( + allCells.where((cellContext) { + final fieldInfo = fieldController.getField(cellContext.fieldId); + return fieldInfo != null && + !fieldInfo.isPrimary && + (fieldInfo.visibility!.isVisibleState() || + showHiddenFields); + }), + ); + + emit( + state.copyWith( + showHiddenFields: showHiddenFields, + visibleCells: visibleCells, + ), + ); + }, + startEditingField: (fieldId) { + emit(state.copyWith(editingFieldId: fieldId)); + }, + startEditingNewField: (fieldId) { + emit(state.copyWith(editingFieldId: fieldId, newFieldId: fieldId)); + }, + endEditingField: () { + emit(state.copyWith(editingFieldId: "", newFieldId: "")); + }, + removeCover: () => _rowService.removeCover(rowController.rowId), + setCover: (cover) => + _rowService.updateMeta(rowId: rowController.rowId, cover: cover), + didReceiveRowMeta: (rowMeta) { + emit(state.copyWith(rowMeta: rowMeta)); + }, + ); + }, + ); + } + + void _startListening() { + _metaListener.start( + callback: (rowMeta) { + if (!isClosed) { + add(RowDetailEvent.didReceiveRowMeta(rowMeta)); + } + }, + ); + + rowController.addListener( + onRowChanged: (cellMap, reason) { + if (isClosed) { + return; + } + allCells.clear(); + allCells.addAll(cellMap); + int numHiddenFields = 0; + final visibleCells = []; + + for (final cellContext in allCells) { + final fieldInfo = fieldController.getField(cellContext.fieldId); + if (fieldInfo == null || fieldInfo.isPrimary) { + continue; + } + final isHidden = !fieldInfo.visibility!.isVisibleState(); + if (!isHidden || state.showHiddenFields) { + visibleCells.add(cellContext); + } + if (isHidden) { + numHiddenFields++; + } + } + + add( + RowDetailEvent.didReceiveCellDatas( + visibleCells, + numHiddenFields, + ), + ); + }, + ); + fieldController.addListener( + onReceiveFields: (fields) => add(RowDetailEvent.didUpdateFields(fields)), + listenWhen: () => !isClosed, + ); + } + + void _init() { + allCells.addAll(rowController.loadCells()); + int numHiddenFields = 0; + final visibleCells = []; + for (final cell in allCells) { + final fieldInfo = fieldController.getField(cell.fieldId); + if (fieldInfo == null || fieldInfo.isPrimary) { + continue; + } + final isHidden = !fieldInfo.visibility!.isVisibleState(); + if (!isHidden) { + visibleCells.add(cell); + } else { + numHiddenFields++; + } + } + add( + RowDetailEvent.didReceiveCellDatas( + visibleCells, + numHiddenFields, + ), + ); + add(RowDetailEvent.didUpdateFields(fieldController.fieldInfos)); + } + + Future _toggleFieldVisibility( + String fieldId, + Emitter emit, + ) async { + final fieldInfo = fieldController.getField(fieldId)!; + final fieldVisibility = fieldInfo.visibility == FieldVisibility.AlwaysShown + ? FieldVisibility.AlwaysHidden + : FieldVisibility.AlwaysShown; + final result = + await FieldSettingsBackendService(viewId: rowController.viewId) + .updateFieldSettings( + fieldId: fieldId, + fieldVisibility: fieldVisibility, + ); + result.fold((l) {}, (err) => Log.error(err)); + } + + Future _reorderField( + int fromIndex, + int toIndex, + Emitter emit, + ) async { + if (fromIndex < toIndex) { + toIndex--; + } + final fromId = state.visibleCells[fromIndex].fieldId; + final toId = state.visibleCells[toIndex].fieldId; + + final cells = List.from(state.visibleCells); + cells.insert(toIndex, cells.removeAt(fromIndex)); + emit(state.copyWith(visibleCells: cells)); + + final result = await FieldBackendService.moveField( + viewId: rowController.viewId, + fromFieldId: fromId, + toFieldId: toId, + ); + result.fold((l) {}, (err) => Log.error(err)); + } +} + +@freezed +class RowDetailEvent with _$RowDetailEvent { + const factory RowDetailEvent.didUpdateFields(List fields) = + _DidUpdateFields; + + /// Triggered by listeners to update row data + const factory RowDetailEvent.didReceiveCellDatas( + List visibleCells, + int numHiddenFields, + ) = _DidReceiveCellDatas; + + /// Used to delete a field + const factory RowDetailEvent.deleteField(String fieldId) = _DeleteField; + + /// Used to show/hide a field + const factory RowDetailEvent.toggleFieldVisibility(String fieldId) = + _ToggleFieldVisibility; + + /// Used to reorder a field + const factory RowDetailEvent.reorderField( + int fromIndex, + int toIndex, + ) = _ReorderField; + + /// Used to hide/show the hidden fields in the row detail page + const factory RowDetailEvent.toggleHiddenFieldVisibility() = + _ToggleHiddenFieldVisibility; + + /// Begin editing an event; + const factory RowDetailEvent.startEditingField(String fieldId) = + _StartEditingField; + + const factory RowDetailEvent.startEditingNewField(String fieldId) = + _StartEditingNewField; + + /// End editing an event + const factory RowDetailEvent.endEditingField() = _EndEditingField; + + const factory RowDetailEvent.removeCover() = _RemoveCover; + + const factory RowDetailEvent.setCover(RowCoverPB cover) = _SetCover; + + const factory RowDetailEvent.didReceiveRowMeta(RowMetaPB rowMeta) = + _DidReceiveRowMeta; +} + +@freezed +class RowDetailState with _$RowDetailState { + const factory RowDetailState({ + required List fields, + required List visibleCells, + required bool showHiddenFields, + required int numHiddenFields, + required String editingFieldId, + required String newFieldId, + required RowMetaPB rowMeta, + }) = _RowDetailState; + + factory RowDetailState.initial(RowMetaPB rowMeta) => RowDetailState( + fields: [], + visibleCells: [], + showHiddenFields: false, + numHiddenFields: 0, + editingFieldId: "", + newFieldId: "", + rowMeta: rowMeta, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_document_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_document_bloc.dart new file mode 100644 index 0000000000000..743d854b23808 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/row/row_document_bloc.dart @@ -0,0 +1,142 @@ +import 'package:flutter/foundation.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../application/row/row_service.dart'; + +part 'row_document_bloc.freezed.dart'; + +class RowDocumentBloc extends Bloc { + RowDocumentBloc({ + required this.rowId, + required String viewId, + }) : _rowBackendSvc = RowBackendService(viewId: viewId), + super(RowDocumentState.initial()) { + _dispatch(); + } + + final String rowId; + final RowBackendService _rowBackendSvc; + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () { + _getRowDocumentView(); + }, + didReceiveRowDocument: (view) { + emit( + state.copyWith( + viewPB: view, + loadingState: const LoadingState.finish(), + ), + ); + }, + didReceiveError: (FlowyError error) { + emit( + state.copyWith( + loadingState: LoadingState.error(error), + ), + ); + }, + updateIsEmpty: (isEmpty) async { + final unitOrFailure = await _rowBackendSvc.updateMeta( + rowId: rowId, + isDocumentEmpty: isEmpty, + ); + + unitOrFailure.fold((l) => null, (err) => Log.error(err)); + }, + ); + }, + ); + } + + Future _getRowDocumentView() async { + final rowDetailOrError = await _rowBackendSvc.getRowMeta(rowId); + rowDetailOrError.fold( + (RowMetaPB rowMeta) async { + final viewsOrError = + await ViewBackendService.getView(rowMeta.documentId); + + if (isClosed) { + return; + } + + viewsOrError.fold( + (view) => add(RowDocumentEvent.didReceiveRowDocument(view)), + (error) async { + if (error.code == ErrorCode.RecordNotFound) { + // By default, the document of the row is not exist. So creating a + // new document for the given document id of the row. + final documentView = + await _createRowDocumentView(rowMeta.documentId); + if (documentView != null && !isClosed) { + add(RowDocumentEvent.didReceiveRowDocument(documentView)); + } + } else { + add(RowDocumentEvent.didReceiveError(error)); + } + }, + ); + }, + (err) => Log.error('Failed to get row detail: $err'), + ); + } + + Future _createRowDocumentView(String viewId) async { + final result = await ViewBackendService.createOrphanView( + viewId: viewId, + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + desc: '', + layoutType: ViewLayoutPB.Document, + ); + return result.fold( + (view) => view, + (error) { + Log.error(error); + return null; + }, + ); + } +} + +@freezed +class RowDocumentEvent with _$RowDocumentEvent { + const factory RowDocumentEvent.initial() = _InitialRow; + const factory RowDocumentEvent.didReceiveRowDocument(ViewPB view) = + _DidReceiveRowDocument; + const factory RowDocumentEvent.didReceiveError(FlowyError error) = + _DidReceiveError; + const factory RowDocumentEvent.updateIsEmpty(bool isDocumentEmpty) = + _UpdateIsEmpty; +} + +@freezed +class RowDocumentState with _$RowDocumentState { + const factory RowDocumentState({ + ViewPB? viewPB, + required LoadingState loadingState, + }) = _RowDocumentState; + + factory RowDocumentState.initial() => const RowDocumentState( + loadingState: LoadingState.loading(), + ); +} + +@freezed +class LoadingState with _$LoadingState { + const factory LoadingState.loading() = _Loading; + const factory LoadingState.error(FlowyError error) = _Error; + const factory LoadingState.finish() = _Finish; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/simple_text_filter_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/simple_text_filter_bloc.dart new file mode 100644 index 0000000000000..bbf010d2794e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/simple_text_filter_bloc.dart @@ -0,0 +1,65 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'simple_text_filter_bloc.freezed.dart'; + +class SimpleTextFilterBloc + extends Bloc, SimpleTextFilterState> { + SimpleTextFilterBloc({ + required this.values, + required this.comparator, + this.filterText = "", + }) : super(SimpleTextFilterState(values: values)) { + _dispatch(); + } + + final String Function(T) comparator; + + final List values; + String filterText; + + void _dispatch() { + on>((event, emit) async { + event.when( + updateFilter: (String filter) { + filterText = filter.toLowerCase(); + _filter(emit); + }, + receiveNewValues: (List newValues) { + values + ..clear() + ..addAll(newValues); + _filter(emit); + }, + ); + }); + } + + void _filter(Emitter> emit) { + final List result = [...values]; + + result.retainWhere((value) { + if (filterText.isNotEmpty) { + return comparator(value).toLowerCase().contains(filterText); + } + return true; + }); + + emit(SimpleTextFilterState(values: result)); + } +} + +@freezed +class SimpleTextFilterEvent with _$SimpleTextFilterEvent { + const factory SimpleTextFilterEvent.updateFilter(String filter) = + _UpdateFilter; + const factory SimpleTextFilterEvent.receiveNewValues(List newValues) = + _ReceiveNewValues; +} + +@freezed +class SimpleTextFilterState with _$SimpleTextFilterState { + const factory SimpleTextFilterState({ + required List values, + }) = _SimpleTextFilterState; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_editor_bloc.dart new file mode 100644 index 0000000000000..37b0e377474b6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/sort/sort_editor_bloc.dart @@ -0,0 +1,187 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/sort_entities.dart'; +import 'package:appflowy/plugins/database/domain/sort_service.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sort_editor_bloc.freezed.dart'; + +class SortEditorBloc extends Bloc { + SortEditorBloc({ + required this.viewId, + required this.fieldController, + }) : _sortBackendSvc = SortBackendService(viewId: viewId), + super( + SortEditorState.initial( + fieldController.sorts, + fieldController.fieldInfos, + ), + ) { + _dispatch(); + _startListening(); + } + + final String viewId; + final SortBackendService _sortBackendSvc; + final FieldController fieldController; + + void Function(List)? _onFieldFn; + void Function(List)? _onSortsFn; + + void _dispatch() { + on( + (event, emit) async { + await event.when( + didReceiveFields: (List fields) { + emit( + state.copyWith( + allFields: fields, + creatableFields: _getCreatableSorts(fields), + ), + ); + }, + createSort: ( + String fieldId, + SortConditionPB? condition, + ) async { + final result = await _sortBackendSvc.insertSort( + fieldId: fieldId, + condition: condition ?? SortConditionPB.Ascending, + ); + result.fold((l) => {}, (err) => Log.error(err)); + }, + editSort: ( + String sortId, + String? fieldId, + SortConditionPB? condition, + ) async { + final sort = state.sorts + .firstWhereOrNull((element) => element.sortId == sortId); + if (sort == null) { + return; + } + + final result = await _sortBackendSvc.updateSort( + sortId: sortId, + fieldId: fieldId ?? sort.fieldId, + condition: condition ?? sort.condition, + ); + result.fold((l) => {}, (err) => Log.error(err)); + }, + deleteAllSorts: () async { + final result = await _sortBackendSvc.deleteAllSorts(); + result.fold((l) => {}, (err) => Log.error(err)); + }, + didReceiveSorts: (sorts) { + emit(state.copyWith(sorts: sorts)); + }, + deleteSort: (sortId) async { + final result = await _sortBackendSvc.deleteSort( + sortId: sortId, + ); + result.fold((l) => null, (err) => Log.error(err)); + }, + reorderSort: (fromIndex, toIndex) async { + if (fromIndex < toIndex) { + toIndex--; + } + + final fromId = state.sorts[fromIndex].sortId; + final toId = state.sorts[toIndex].sortId; + + final newSorts = [...state.sorts]; + newSorts.insert(toIndex, newSorts.removeAt(fromIndex)); + emit(state.copyWith(sorts: newSorts)); + final result = await _sortBackendSvc.reorderSort( + fromSortId: fromId, + toSortId: toId, + ); + result.fold((l) => null, (err) => Log.error(err)); + }, + ); + }, + ); + } + + void _startListening() { + _onFieldFn = (fields) { + add(SortEditorEvent.didReceiveFields(List.from(fields))); + }; + _onSortsFn = (sorts) { + add(SortEditorEvent.didReceiveSorts(sorts)); + }; + + fieldController.addListener( + listenWhen: () => !isClosed, + onReceiveFields: _onFieldFn, + onSorts: _onSortsFn, + ); + } + + @override + Future close() async { + fieldController.removeListener( + onFieldsListener: _onFieldFn, + onSortsListener: _onSortsFn, + ); + _onFieldFn = null; + _onSortsFn = null; + return super.close(); + } +} + +@freezed +class SortEditorEvent with _$SortEditorEvent { + const factory SortEditorEvent.didReceiveFields(List fieldInfos) = + _DidReceiveFields; + const factory SortEditorEvent.didReceiveSorts(List sorts) = + _DidReceiveSorts; + const factory SortEditorEvent.createSort({ + required String fieldId, + SortConditionPB? condition, + }) = _CreateSort; + const factory SortEditorEvent.editSort({ + required String sortId, + String? fieldId, + SortConditionPB? condition, + }) = _EditSort; + const factory SortEditorEvent.reorderSort(int oldIndex, int newIndex) = + _ReorderSort; + const factory SortEditorEvent.deleteSort(String sortId) = _DeleteSort; + const factory SortEditorEvent.deleteAllSorts() = _DeleteAllSorts; +} + +@freezed +class SortEditorState with _$SortEditorState { + const factory SortEditorState({ + required List sorts, + required List allFields, + required List creatableFields, + }) = _SortEditorState; + + factory SortEditorState.initial( + List sorts, + List fields, + ) { + return SortEditorState( + sorts: sorts, + allFields: fields, + creatableFields: _getCreatableSorts(fields), + ); + } +} + +List _getCreatableSorts(List fieldInfos) { + final List creatableFields = List.from(fieldInfos); + creatableFields.retainWhere( + (field) => field.fieldType.canCreateSort && !field.hasSort, + ); + return creatableFields; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/grid.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/grid.dart new file mode 100644 index 0000000000000..b43c425a9da84 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/grid.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class GridPluginBuilder implements PluginBuilder { + @override + Plugin build(dynamic data) { + if (data is ViewPB) { + return DatabaseTabBarViewPlugin(pluginType: pluginType, view: data); + } else { + throw FlowyPluginException.invalidData; + } + } + + @override + String get menuName => LocaleKeys.grid_menuName.tr(); + + @override + FlowySvgData get icon => FlowySvgs.icon_grid_s; + + @override + PluginType get pluginType => PluginType.grid; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Grid; +} + +class GridPluginConfig implements PluginConfig { + @override + bool get creatable => true; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart new file mode 100755 index 0000000000000..19330221c6426 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart @@ -0,0 +1,675 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart'; +import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; +import 'package:appflowy/plugins/database/domain/sort_service.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:linked_scroll_controller/linked_scroll_controller.dart'; +import 'package:provider/provider.dart'; + +import '../../application/database_controller.dart'; +import '../../application/row/row_controller.dart'; +import '../../tab_bar/tab_bar_view.dart'; +import '../../widgets/row/row_detail.dart'; +import '../application/grid_bloc.dart'; + +import 'grid_scroll.dart'; +import 'layout/layout.dart'; +import 'layout/sizes.dart'; +import 'widgets/footer/grid_footer.dart'; +import 'widgets/header/grid_header.dart'; +import 'widgets/row/row.dart'; +import 'widgets/shortcuts.dart'; + +class ToggleExtensionNotifier extends ChangeNotifier { + bool _isToggled = false; + + bool get isToggled => _isToggled; + + void toggle() { + _isToggled = !_isToggled; + notifyListeners(); + } +} + +class DesktopGridTabBarBuilderImpl extends DatabaseTabBarItemBuilder { + final _toggleExtension = ToggleExtensionNotifier(); + + @override + Widget content( + BuildContext context, + ViewPB view, + DatabaseController controller, + bool shrinkWrap, + String? initialRowId, + ) { + return GridPage( + key: _makeValueKey(controller), + view: view, + databaseController: controller, + initialRowId: initialRowId, + shrinkWrap: shrinkWrap, + ); + } + + @override + Widget settingBar(BuildContext context, DatabaseController controller) { + return GridSettingBar( + key: _makeValueKey(controller), + controller: controller, + toggleExtension: _toggleExtension, + ); + } + + @override + Widget settingBarExtension( + BuildContext context, + DatabaseController controller, + ) { + return DatabaseViewSettingExtension( + key: _makeValueKey(controller), + viewId: controller.viewId, + databaseController: controller, + toggleExtension: _toggleExtension, + ); + } + + @override + void dispose() { + _toggleExtension.dispose(); + super.dispose(); + } + + ValueKey _makeValueKey(DatabaseController controller) { + return ValueKey(controller.viewId); + } +} + +class GridPage extends StatefulWidget { + const GridPage({ + super.key, + required this.view, + required this.databaseController, + this.onDeleted, + this.initialRowId, + this.shrinkWrap = false, + }); + + final ViewPB view; + final DatabaseController databaseController; + final VoidCallback? onDeleted; + final String? initialRowId; + final bool shrinkWrap; + + @override + State createState() => _GridPageState(); +} + +class _GridPageState extends State { + bool _didOpenInitialRow = false; + + late final GridBloc gridBloc = GridBloc( + view: widget.view, + databaseController: widget.databaseController, + shrinkWrapped: widget.shrinkWrap, + )..add(const GridEvent.initial()); + + @override + void dispose() { + gridBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => gridBloc, + child: BlocListener( + listener: (context, state) { + final action = state.action; + if (action?.type == ActionType.openRow && + action?.objectId == widget.view.id) { + final rowId = action!.arguments?[ActionArgumentKeys.rowId]; + if (rowId != null) { + // If Reminder in existing database is pressed + // then open the row + _openRow(context, rowId); + } + } + }, + child: BlocConsumer( + listener: listener, + builder: (context, state) => state.loadingState.map( + idle: (_) => const SizedBox.shrink(), + loading: (_) => const Center( + child: CircularProgressIndicator.adaptive(), + ), + finish: (result) => result.successOrFail.fold( + (_) => GridShortcuts( + child: GridPageContent( + key: ValueKey(widget.view.id), + view: widget.view, + shrinkWrap: widget.shrinkWrap, + ), + ), + (err) => Center(child: AppFlowyErrorPage(error: err)), + ), + ), + ), + ), + ); + } + + void _openRow(BuildContext context, String rowId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final gridBloc = context.read(); + final rowCache = gridBloc.rowCache; + final rowMeta = rowCache.getRow(rowId)?.rowMeta; + if (rowMeta == null) { + return; + } + + final rowController = RowController( + viewId: widget.view.id, + rowMeta: rowMeta, + rowCache: rowCache, + ); + + FlowyOverlay.show( + context: context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: RowDetailPage( + databaseController: context.read().databaseController, + rowController: rowController, + userProfile: context.read().userProfile, + ), + ), + ); + }); + } + + void listener(BuildContext context, GridState state) { + state.loadingState.whenOrNull( + // If initial row id is defined, open row details overlay + finish: (_) async { + if (widget.initialRowId != null && !_didOpenInitialRow) { + _didOpenInitialRow = true; + + _openRow(context, widget.initialRowId!); + return; + } + + final bloc = context.read(); + final isCurrentView = + bloc.state.tabBars[bloc.state.selectedIndex].viewId == + widget.view.id; + + if (state.openRowDetail && state.createdRow != null && isCurrentView) { + final rowController = RowController( + viewId: widget.view.id, + rowMeta: state.createdRow!, + rowCache: context.read().rowCache, + ); + unawaited( + FlowyOverlay.show( + context: context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: RowDetailPage( + databaseController: + context.read().databaseController, + rowController: rowController, + userProfile: context.read().userProfile, + ), + ), + ), + ); + context.read().add(const GridEvent.resetCreatedRow()); + } + }, + ); + } +} + +class GridPageContent extends StatefulWidget { + const GridPageContent({ + super.key, + required this.view, + this.shrinkWrap = false, + }); + + final ViewPB view; + final bool shrinkWrap; + + @override + State createState() => _GridPageContentState(); +} + +class _GridPageContentState extends State { + final _scrollController = GridScrollController( + scrollGroupController: LinkedScrollControllerGroup(), + ); + late final ScrollController headerScrollController; + + @override + void initState() { + super.initState(); + headerScrollController = _scrollController.linkHorizontalController(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _GridHeader(headerScrollController: headerScrollController), + _GridRows( + viewId: widget.view.id, + scrollController: _scrollController, + shrinkWrap: widget.shrinkWrap, + ), + ], + ); + } +} + +class _GridHeader extends StatelessWidget { + const _GridHeader({required this.headerScrollController}); + + final ScrollController headerScrollController; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (_, state) => GridHeaderSliverAdaptor( + viewId: state.viewId, + anchorScrollController: headerScrollController, + ), + ); + } +} + +class _GridRows extends StatefulWidget { + const _GridRows({ + required this.viewId, + required this.scrollController, + this.shrinkWrap = false, + }); + + final String viewId; + final GridScrollController scrollController; + + /// When [shrinkWrap] is active, the Grid will show items according to + /// GridState.visibleRows and will not have a vertical scroll area. + /// + final bool shrinkWrap; + + @override + State<_GridRows> createState() => _GridRowsState(); +} + +class _GridRowsState extends State<_GridRows> { + bool showFloatingCalculations = false; + bool isAtBottom = false; + + @override + void initState() { + super.initState(); + if (!widget.shrinkWrap) { + _evaluateFloatingCalculations(); + widget.scrollController.verticalController.addListener(_onScrollChanged); + } + } + + void _onScrollChanged() { + final controller = widget.scrollController.verticalController; + final isAtBottom = controller.position.atEdge && controller.offset > 0 || + controller.offset >= controller.position.maxScrollExtent - 1; + if (isAtBottom != this.isAtBottom) { + setState(() => this.isAtBottom = isAtBottom); + } + } + + @override + void dispose() { + if (!widget.shrinkWrap) { + widget.scrollController.verticalController + .removeListener(_onScrollChanged); + } + super.dispose(); + } + + void _evaluateFloatingCalculations() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !widget.shrinkWrap) { + setState(() { + final verticalController = widget.scrollController.verticalController; + // maxScrollExtent is 0.0 if scrolling is not possible + showFloatingCalculations = + verticalController.position.maxScrollExtent > 0; + + isAtBottom = verticalController.position.atEdge && + verticalController.offset > 0; + }); + } + }); + } + + @override + Widget build(BuildContext context) { + Widget child; + if (widget.shrinkWrap) { + child = Scrollbar( + controller: widget.scrollController.horizontalController, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: widget.scrollController.horizontalController, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: GridLayout.headerWidth( + context + .read() + .horizontalPadding, + context.read().state.fields, + ), + ), + child: _renderList(context), + ), + ), + ); + } else { + child = _WrapScrollView( + scrollController: widget.scrollController, + contentWidth: GridLayout.headerWidth( + context.read().horizontalPadding, + context.read().state.fields, + ), + child: BlocListener( + listenWhen: (previous, current) => + previous.rowCount != current.rowCount, + listener: (context, state) => _evaluateFloatingCalculations(), + child: ScrollConfiguration( + behavior: + ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: _renderList(context), + ), + ), + ); + } + + if (widget.shrinkWrap) { + return child; + } + + return Flexible(child: child); + } + + Widget _renderList(BuildContext context) { + final state = context.read().state; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + widget.shrinkWrap + ? _reorderableListView(state) + : Expanded(child: _reorderableListView(state)), + if (showFloatingCalculations && !widget.shrinkWrap) ...[ + _PositionedCalculationsRow( + viewId: widget.viewId, + isAtBottom: isAtBottom, + ), + ], + ], + ); + } + + Widget _reorderableListView(GridState state) { + final List footer = [ + const GridRowBottomBar(), + if (widget.shrinkWrap && state.visibleRows < state.rowInfos.length) + const GridRowLoadMoreButton(), + if (!showFloatingCalculations) GridCalculationsRow(viewId: widget.viewId), + ]; + + // If we are using shrinkWrap, we need to show at most + // state.visibleRows + 1 items. The visibleRows can be larger + // than the actual rowInfos length. + final itemCount = widget.shrinkWrap + ? (state.visibleRows + 1).clamp(0, state.rowInfos.length + 1) + : state.rowInfos.length + 1; + + return ReorderableListView.builder( + cacheExtent: 500, + scrollController: widget.scrollController.verticalController, + physics: const ClampingScrollPhysics(), + buildDefaultDragHandles: false, + shrinkWrap: widget.shrinkWrap, + proxyDecorator: (child, _, __) => Provider.value( + value: context.read(), + child: Material( + color: Colors.white.withOpacity(.1), + child: Opacity(opacity: .5, child: child), + ), + ), + onReorder: (fromIndex, newIndex) { + final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex; + + if (state.sorts.isNotEmpty) { + showCancelAndDeleteDialog( + context: context, + title: LocaleKeys.grid_sort_sortsActive.tr( + namedArgs: { + 'intention': LocaleKeys.grid_row_reorderRowDescription.tr(), + }, + ), + description: LocaleKeys.grid_sort_removeSorting.tr(), + confirmLabel: LocaleKeys.button_remove.tr(), + closeOnAction: true, + onDelete: () { + SortBackendService(viewId: widget.viewId).deleteAllSorts(); + moveRow(fromIndex, toIndex); + }, + ); + } else { + moveRow(fromIndex, toIndex); + } + }, + itemCount: itemCount, + itemBuilder: (context, index) { + if (index == itemCount - 1) { + return Column( + key: const Key('grid_footer'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: footer, + ); + } + + return _renderRow( + context, + state.rowInfos[index].rowId, + index: index, + ); + }, + ); + } + + Widget _renderRow( + BuildContext context, + RowId rowId, { + required int index, + Animation? animation, + }) { + final databaseController = context.read().databaseController; + final DatabaseController(:viewId, :rowCache) = databaseController; + final rowMeta = rowCache.getRow(rowId)?.rowMeta; + + /// Return placeholder widget if the rowMeta is null. + if (rowMeta == null) { + Log.warn('RowMeta is null for rowId: $rowId'); + return const SizedBox.shrink(); + } + + final child = GridRow( + key: ValueKey("grid_row_$rowId"), + shrinkWrap: widget.shrinkWrap, + fieldController: databaseController.fieldController, + rowId: rowId, + viewId: viewId, + index: index, + rowController: RowController( + viewId: viewId, + rowMeta: rowMeta, + rowCache: rowCache, + ), + cellBuilder: EditableCellBuilder(databaseController: databaseController), + openDetailPage: (rowDetailContext) => FlowyOverlay.show( + context: rowDetailContext, + builder: (_) { + final rowMeta = rowCache.getRow(rowId)?.rowMeta; + if (rowMeta == null) { + return const SizedBox.shrink(); + } + + return BlocProvider.value( + value: context.read(), + child: RowDetailPage( + rowController: RowController( + viewId: viewId, + rowMeta: rowMeta, + rowCache: rowCache, + ), + databaseController: databaseController, + userProfile: context.read().userProfile, + ), + ); + }, + ), + ); + + if (animation != null) { + return SizeTransition(sizeFactor: animation, child: child); + } + + return child; + } + + void moveRow(int from, int to) { + if (from != to) { + context.read().add(GridEvent.moveRow(from, to)); + } + } +} + +class _WrapScrollView extends StatelessWidget { + const _WrapScrollView({ + required this.contentWidth, + required this.scrollController, + required this.child, + }); + + final GridScrollController scrollController; + final double contentWidth; + final Widget child; + + @override + Widget build(BuildContext context) { + return ScrollbarListStack( + includeInsets: false, + axis: Axis.vertical, + controller: scrollController.verticalController, + barSize: GridSize.scrollBarSize, + autoHideScrollbar: false, + child: StyledSingleChildScrollView( + autoHideScrollbar: false, + includeInsets: false, + controller: scrollController.horizontalController, + axis: Axis.horizontal, + child: SizedBox( + width: contentWidth, + child: child, + ), + ), + ); + } +} + +/// This Widget is used to show the Calculations Row at the bottom of the Grids ScrollView +/// when the ScrollView is scrollable. +/// +class _PositionedCalculationsRow extends StatefulWidget { + const _PositionedCalculationsRow({ + required this.viewId, + this.isAtBottom = false, + }); + + final String viewId; + + /// We don't need to show the top border if the scroll offset + /// is at the bottom of the ScrollView. + /// + final bool isAtBottom; + + @override + State<_PositionedCalculationsRow> createState() => + _PositionedCalculationsRowState(); +} + +class _PositionedCalculationsRowState + extends State<_PositionedCalculationsRow> { + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.only( + left: context.read().horizontalPadding, + ), + padding: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: Theme.of(context).canvasColor, + border: widget.isAtBottom + ? null + : Border( + top: BorderSide( + color: AFThemeExtension.of(context).borderColor, + ), + ), + ), + child: SizedBox( + height: 36, + width: double.infinity, + child: GridCalculationsRow( + key: const Key('floating_grid_calculations'), + viewId: widget.viewId, + includeDefaultInsets: false, + ), + ), + ); + } +} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/controller/grid_scroll.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_scroll.dart similarity index 85% rename from frontend/app_flowy/lib/plugins/grid/presentation/controller/grid_scroll.dart rename to frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_scroll.dart index 72b5152aea816..866a9d11b5c90 100755 --- a/frontend/app_flowy/lib/plugins/grid/presentation/controller/grid_scroll.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_scroll.dart @@ -2,18 +2,18 @@ import 'package:flutter/material.dart'; import 'package:linked_scroll_controller/linked_scroll_controller.dart'; class GridScrollController { + GridScrollController({ + required LinkedScrollControllerGroup scrollGroupController, + }) : _scrollGroupController = scrollGroupController, + verticalController = ScrollController(), + horizontalController = scrollGroupController.addAndGet(); + final LinkedScrollControllerGroup _scrollGroupController; final ScrollController verticalController; final ScrollController horizontalController; final List _linkHorizontalControllers = []; - GridScrollController( - {required LinkedScrollControllerGroup scrollGroupController}) - : _scrollGroupController = scrollGroupController, - verticalController = ScrollController(), - horizontalController = scrollGroupController.addAndGet(); - ScrollController linkHorizontalController() { final controller = _scrollGroupController.addAndGet(); _linkHorizontalControllers.add(controller); diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart new file mode 100755 index 0000000000000..903ecbb864188 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/layout.dart @@ -0,0 +1,20 @@ +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pbenum.dart'; +import 'sizes.dart'; + +class GridLayout { + static double headerWidth(double padding, List fields) { + if (fields.isEmpty) return 0; + + final fieldsWidth = fields + .where( + (element) => + element.visibility != null && + element.visibility != FieldVisibility.AlwaysHidden, + ) + .map((fieldInfo) => fieldInfo.width!.toDouble()) + .reduce((value, element) => value + element); + + return fieldsWidth + padding + GridSize.newPropertyButtonWidth; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart new file mode 100755 index 0000000000000..18883e8a0cb57 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/layout/sizes.dart @@ -0,0 +1,45 @@ +import 'package:flutter/widgets.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class GridSize { + static double scale = 1; + + static double get scrollBarSize => 8 * scale; + static double get headerHeight => 36 * scale; + static double get buttonHeight => 38 * scale; + static double get footerHeight => 36 * scale; + static double get horizontalHeaderPadding => + UniversalPlatform.isDesktop ? 40 * scale : 16 * scale; + static double get cellHPadding => 10 * scale; + static double get cellVPadding => 10 * scale; + static double get popoverItemHeight => 26 * scale; + static double get typeOptionSeparatorHeight => 4 * scale; + static double get newPropertyButtonWidth => 140 * scale; + static double get mobileNewPropertyButtonWidth => 200 * scale; + + static EdgeInsets get cellContentInsets => EdgeInsets.symmetric( + horizontal: GridSize.cellHPadding, + vertical: GridSize.cellVPadding, + ); + + static EdgeInsets get fieldContentInsets => EdgeInsets.symmetric( + horizontal: GridSize.cellHPadding, + vertical: GridSize.cellVPadding, + ); + + static EdgeInsets get typeOptionContentInsets => const EdgeInsets.all(4); + + static EdgeInsets get toolbarSettingButtonInsets => + const EdgeInsets.symmetric(horizontal: 6, vertical: 2); + + static EdgeInsets get footerContentInsets => EdgeInsets.fromLTRB( + GridSize.horizontalHeaderPadding, + 0, + UniversalPlatform.isMobile ? GridSize.horizontalHeaderPadding : 0, + UniversalPlatform.isMobile ? 100 : 0, + ); + + static EdgeInsets get contentInsets => EdgeInsets.symmetric( + horizontal: GridSize.horizontalHeaderPadding, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart new file mode 100644 index 0000000000000..f70d98ab7722d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/mobile_grid_page.dart @@ -0,0 +1,448 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/shortcuts.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:linked_scroll_controller/linked_scroll_controller.dart'; + +import 'grid_scroll.dart'; +import 'layout/sizes.dart'; +import 'widgets/header/mobile_grid_header.dart'; +import 'widgets/mobile_fab.dart'; +import 'widgets/row/mobile_row.dart'; + +class MobileGridTabBarBuilderImpl extends DatabaseTabBarItemBuilder { + @override + Widget content( + BuildContext context, + ViewPB view, + DatabaseController controller, + bool shrinkWrap, + String? initialRowId, + ) { + return MobileGridPage( + key: _makeValueKey(controller), + view: view, + databaseController: controller, + initialRowId: initialRowId, + ); + } + + @override + Widget settingBar(BuildContext context, DatabaseController controller) => + const SizedBox.shrink(); + + @override + Widget settingBarExtension( + BuildContext context, + DatabaseController controller, + ) => + const SizedBox.shrink(); + + ValueKey _makeValueKey(DatabaseController controller) { + return ValueKey(controller.viewId); + } +} + +class MobileGridPage extends StatefulWidget { + const MobileGridPage({ + super.key, + required this.view, + required this.databaseController, + this.onDeleted, + this.initialRowId, + }); + + final ViewPB view; + final DatabaseController databaseController; + final VoidCallback? onDeleted; + final String? initialRowId; + + @override + State createState() => _MobileGridPageState(); +} + +class _MobileGridPageState extends State { + bool _didOpenInitialRow = false; + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider.value( + value: getIt(), + ), + BlocProvider( + create: (context) => GridBloc( + view: widget.view, + databaseController: widget.databaseController, + )..add(const GridEvent.initial()), + ), + ], + child: BlocBuilder( + builder: (context, state) { + return state.loadingState.map( + loading: (_) => + const Center(child: CircularProgressIndicator.adaptive()), + finish: (result) { + _openRow(context, widget.initialRowId, true); + return result.successOrFail.fold( + (_) => GridShortcuts(child: GridPageContent(view: widget.view)), + (err) => Center( + child: AppFlowyErrorPage( + error: err, + ), + ), + ); + }, + idle: (_) => const SizedBox.shrink(), + ); + }, + ), + ); + } + + void _openRow( + BuildContext context, + String? rowId, [ + bool initialRow = false, + ]) { + if (rowId != null && (!initialRow || (initialRow && !_didOpenInitialRow))) { + _didOpenInitialRow = initialRow; + + WidgetsBinding.instance.addPostFrameCallback((_) { + context.push( + MobileRowDetailPage.routeName, + extra: { + MobileRowDetailPage.argRowId: rowId, + MobileRowDetailPage.argDatabaseController: + widget.databaseController, + }, + ); + }); + } + } +} + +class GridPageContent extends StatefulWidget { + const GridPageContent({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + State createState() => _GridPageContentState(); +} + +class _GridPageContentState extends State { + final _scrollController = GridScrollController( + scrollGroupController: LinkedScrollControllerGroup(), + ); + late final ScrollController contentScrollController; + late final ScrollController reorderableController; + + @override + void initState() { + super.initState(); + contentScrollController = _scrollController.linkHorizontalController(); + reorderableController = _scrollController.linkHorizontalController(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => + previous.createdRow != current.createdRow, + listener: (context, state) { + if (state.createdRow == null || !state.openRowDetail) { + return; + } + final bloc = context.read(); + context.push( + MobileRowDetailPage.routeName, + extra: { + MobileRowDetailPage.argRowId: state.createdRow!.id, + MobileRowDetailPage.argDatabaseController: bloc.databaseController, + }, + ); + bloc.add(const GridEvent.resetCreatedRow()); + }, + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _GridHeader( + contentScrollController: contentScrollController, + reorderableController: reorderableController, + ), + _GridRows( + viewId: widget.view.id, + scrollController: _scrollController, + ), + ], + ), + Positioned( + bottom: 16, + right: 16, + child: getGridFabs(context), + ), + ], + ), + ); + } +} + +class _GridHeader extends StatelessWidget { + const _GridHeader({ + required this.contentScrollController, + required this.reorderableController, + }); + + final ScrollController contentScrollController; + final ScrollController reorderableController; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return MobileGridHeader( + viewId: state.viewId, + contentScrollController: contentScrollController, + reorderableController: reorderableController, + ); + }, + ); + } +} + +class _GridRows extends StatelessWidget { + const _GridRows({ + required this.viewId, + required this.scrollController, + }); + + final String viewId; + final GridScrollController scrollController; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => previous.fields != current.fields, + builder: (context, state) { + final double contentWidth = getMobileGridContentWidth(state.fields); + return Expanded( + child: _WrapScrollView( + scrollController: scrollController, + contentWidth: contentWidth, + child: BlocBuilder( + buildWhen: (previous, current) => current.reason.maybeWhen( + reorderRows: () => true, + reorderSingleRow: (reorderRow, rowInfo) => true, + delete: (item) => true, + insert: (item) => true, + orElse: () => false, + ), + builder: (context, state) { + final behavior = ScrollConfiguration.of(context).copyWith( + scrollbars: false, + physics: const ClampingScrollPhysics(), + ); + return ScrollConfiguration( + behavior: behavior, + child: _renderList(context, state), + ); + }, + ), + ), + ); + }, + ); + } + + Widget _renderList( + BuildContext context, + GridState state, + ) { + final children = state.rowInfos.mapIndexed((index, rowInfo) { + return ReorderableDelayedDragStartListener( + key: ValueKey(rowInfo.rowMeta.id), + index: index, + child: _renderRow( + context, + rowInfo.rowId, + isDraggable: state.reorderable, + index: index, + ), + ); + }).toList(); + + return ReorderableListView.builder( + scrollController: scrollController.verticalController, + buildDefaultDragHandles: false, + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: child, + ), + onReorder: (fromIndex, newIndex) { + final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex; + if (fromIndex == toIndex) { + return; + } + context.read().add(GridEvent.moveRow(fromIndex, toIndex)); + }, + itemCount: state.rowInfos.length, + itemBuilder: (context, index) => children[index], + footer: Padding( + padding: GridSize.footerContentInsets, + child: _AddRowButton(), + ), + ); + } + + Widget _renderRow( + BuildContext context, + RowId rowId, { + int? index, + required bool isDraggable, + Animation? animation, + }) { + final rowMeta = context + .read() + .databaseController + .rowCache + .getRow(rowId) + ?.rowMeta; + + if (rowMeta == null) { + Log.warn('RowMeta is null for rowId: $rowId'); + return const SizedBox.shrink(); + } + + final databaseController = context.read().databaseController; + + final child = MobileGridRow( + key: ValueKey(rowMeta.id), + rowId: rowId, + isDraggable: isDraggable, + databaseController: databaseController, + openDetailPage: (context) { + context.push( + MobileRowDetailPage.routeName, + extra: { + MobileRowDetailPage.argRowId: rowId, + MobileRowDetailPage.argDatabaseController: databaseController, + }, + ); + }, + ); + + if (animation != null) { + return SizeTransition( + sizeFactor: animation, + child: child, + ); + } + + return child; + } +} + +class _WrapScrollView extends StatelessWidget { + const _WrapScrollView({ + required this.contentWidth, + required this.scrollController, + required this.child, + }); + + final GridScrollController scrollController; + final double contentWidth; + final Widget child; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + controller: scrollController.horizontalController, + scrollDirection: Axis.horizontal, + child: SizedBox( + width: contentWidth, + child: child, + ), + ); + } +} + +class _AddRowButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + final borderSide = BorderSide( + color: Theme.of(context).dividerColor, + ); + const radius = BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ); + final decoration = BoxDecoration( + borderRadius: radius, + border: BorderDirectional( + start: borderSide, + end: borderSide, + bottom: borderSide, + ), + ); + return Container( + height: 54, + decoration: decoration, + child: FlowyButton( + text: FlowyText( + LocaleKeys.grid_row_newRow.tr(), + fontSize: 15, + color: Theme.of(context).hintColor, + ), + margin: const EdgeInsets.symmetric(horizontal: 20.0), + radius: radius, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onTap: () => context.read().add(const GridEvent.createRow()), + leftIcon: FlowySvg( + FlowySvgs.add_s, + color: Theme.of(context).hintColor, + size: const Size.square(18), + ), + leftIconSize: const Size.square(18), + ), + ); + } +} + +double getMobileGridContentWidth(List fields) { + final visibleFields = fields.where( + (field) => field.visibility != FieldVisibility.AlwaysHidden, + ); + return (visibleFields.length + 1) * 200 + + GridSize.horizontalHeaderPadding * 2; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart new file mode 100644 index 0000000000000..5ea364bc727a1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart @@ -0,0 +1,210 @@ +import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/calculations/calculations_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/calculations/field_type_calc_ext.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculation_selector.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculation_type_item.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/remove_calculation_button.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/calculation_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/number_entities.pb.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class CalculateCell extends StatefulWidget { + const CalculateCell({ + super.key, + required this.fieldInfo, + required this.width, + this.calculation, + }); + + final FieldInfo fieldInfo; + final double width; + final CalculationPB? calculation; + + @override + State createState() => _CalculateCellState(); +} + +class _CalculateCellState extends State { + final _cellScrollController = ScrollController(); + bool isSelected = false; + bool isScrollable = false; + + @override + void initState() { + super.initState(); + _checkScrollable(); + } + + @override + void didUpdateWidget(covariant CalculateCell oldWidget) { + _checkScrollable(); + super.didUpdateWidget(oldWidget); + } + + void _checkScrollable() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_cellScrollController.hasClients) { + setState( + () => + isScrollable = _cellScrollController.position.maxScrollExtent > 0, + ); + } + }); + } + + @override + void dispose() { + _cellScrollController.dispose(); + super.dispose(); + } + + void setIsSelected(bool selected) => setState(() => isSelected = selected); + + @override + Widget build(BuildContext context) { + final prefix = _prefixFromFieldType(widget.fieldInfo.fieldType); + + return SizedBox( + height: 35, + width: widget.width, + child: AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(150, 200)), + direction: PopoverDirection.bottomWithCenterAligned, + onClose: () => setIsSelected(false), + popupBuilder: (_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setIsSelected(true); + } + }); + + return SingleChildScrollView( + child: Column( + children: [ + if (widget.calculation != null) + RemoveCalculationButton( + onTap: () => context.read().add( + CalculationsEvent.removeCalculation( + widget.fieldInfo.id, + widget.calculation!.id, + ), + ), + ), + ...widget.fieldInfo.fieldType.calculationsForFieldType().map( + (type) => CalculationTypeItem( + type: type, + onTap: () { + if (type != widget.calculation?.calculationType) { + context.read().add( + CalculationsEvent.updateCalculationType( + widget.fieldInfo.id, + type, + calculationId: widget.calculation?.id, + ), + ); + } + }, + ), + ), + ], + ), + ); + }, + child: widget.calculation != null + ? _showCalculateValue(context, prefix) + : CalculationSelector(isSelected: isSelected), + ), + ); + } + + Widget _showCalculateValue(BuildContext context, String? prefix) { + prefix = prefix != null ? '$prefix ' : ''; + final calculateValue = + '$prefix${_withoutTrailingZeros(widget.calculation!.value)}'; + + return FlowyTooltip( + message: !isScrollable ? "" : null, + richMessage: !isScrollable + ? null + : TextSpan( + children: [ + TextSpan( + text: widget.calculation!.calculationType.shortLabel + .toUpperCase(), + style: context.tooltipTextStyle(), + ), + const TextSpan(text: ' '), + TextSpan( + text: calculateValue, + style: context + .tooltipTextStyle() + ?.copyWith(fontWeight: FontWeight.w500), + ), + ], + ), + child: FlowyButton( + radius: BorderRadius.zero, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + text: Row( + children: [ + Expanded( + child: SingleChildScrollView( + controller: _cellScrollController, + key: ValueKey(widget.calculation!.id), + reverse: true, + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlowyText( + lineHeight: 1.0, + widget.calculation!.calculationType.shortLabel + .toUpperCase(), + color: Theme.of(context).hintColor, + fontSize: 10, + ), + if (widget.calculation!.value.isNotEmpty) ...[ + const HSpace(8), + FlowyText( + lineHeight: 1.0, + calculateValue, + color: AFThemeExtension.of(context).textColor, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } + + String _withoutTrailingZeros(String value) { + if (trailingZerosRegex.hasMatch(value)) { + final match = trailingZerosRegex.firstMatch(value)!; + return match.group(1)!; + } + + return value; + } + + String? _prefixFromFieldType(FieldType fieldType) => switch (fieldType) { + FieldType.Number => + NumberTypeOptionPB.fromBuffer(widget.fieldInfo.field.typeOptionData) + .format + .iconSymbol(false), + _ => null, + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculation_selector.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculation_selector.dart new file mode 100644 index 0000000000000..b95295818cd98 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculation_selector.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; + +class CalculationSelector extends StatefulWidget { + const CalculationSelector({ + super.key, + required this.isSelected, + }); + + final bool isSelected; + + @override + State createState() => _CalculationSelectorState(); +} + +class _CalculationSelectorState extends State { + bool _isHovering = false; + + void _setHovering(bool isHovering) => + setState(() => _isHovering = isHovering); + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => _setHovering(true), + onExit: (_) => _setHovering(false), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 100), + opacity: widget.isSelected || _isHovering ? 1 : 0, + child: FlowyButton( + radius: BorderRadius.zero, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + text: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: FlowyText( + LocaleKeys.grid_calculate.tr(), + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(8), + FlowySvg( + FlowySvgs.arrow_down_s, + color: Theme.of(context).hintColor, + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculation_type_item.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculation_type_item.dart new file mode 100644 index 0000000000000..b1b696d7900a0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculation_type_item.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/calculation_entities.pbenum.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; + +class CalculationTypeItem extends StatelessWidget { + const CalculationTypeItem({ + super.key, + required this.type, + required this.onTap, + }); + + final CalculationType type; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText( + type.label, + overflow: TextOverflow.ellipsis, + lineHeight: 1.0, + ), + onTap: () { + onTap(); + PopoverContainer.of(context).close(); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart new file mode 100644 index 0000000000000..5524633a46617 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart @@ -0,0 +1,54 @@ +import 'package:appflowy/plugins/database/grid/application/calculations/calculations_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class GridCalculationsRow extends StatelessWidget { + const GridCalculationsRow({ + super.key, + required this.viewId, + this.includeDefaultInsets = true, + }); + + final String viewId; + final bool includeDefaultInsets; + + @override + Widget build(BuildContext context) { + final gridBloc = context.read(); + + return BlocProvider( + create: (context) => CalculationsBloc( + viewId: gridBloc.databaseController.viewId, + fieldController: gridBloc.databaseController.fieldController, + )..add(const CalculationsEvent.started()), + child: BlocBuilder( + builder: (context, state) { + final padding = + context.read().horizontalPadding; + return Padding( + padding: includeDefaultInsets + ? EdgeInsets.symmetric(horizontal: padding) + : EdgeInsets.zero, + child: Row( + children: [ + ...state.fields.map( + (field) => CalculateCell( + key: Key( + '${field.id}-${state.calculationsByFieldId[field.id]?.id}', + ), + width: field.width!.toDouble(), + fieldInfo: field, + calculation: state.calculationsByFieldId[field.id], + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/remove_calculation_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/remove_calculation_button.dart new file mode 100644 index 0000000000000..982ee992b6e3f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/remove_calculation_button.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; + +class RemoveCalculationButton extends StatelessWidget { + const RemoveCalculationButton({ + super.key, + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText( + LocaleKeys.grid_calculationTypeLabel_none.tr(), + overflow: TextOverflow.ellipsis, + ), + onTap: () { + onTap(); + PopoverContainer.of(context).close(); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/common/type_option_separator.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/common/type_option_separator.dart new file mode 100644 index 0000000000000..1d316ca5cb343 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/common/type_option_separator.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +class TypeOptionSeparator extends StatelessWidget { + const TypeOptionSeparator({this.spacing = 6.0, super.key}); + + final double spacing; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(vertical: spacing), + child: Container( + color: Theme.of(context).dividerColor, + height: 1.0, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart new file mode 100644 index 0000000000000..bda8634cdbe97 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart @@ -0,0 +1,187 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../condition_button.dart'; +import '../disclosure_button.dart'; + +import 'choicechip.dart'; + +class CheckboxFilterChoicechip extends StatelessWidget { + const CheckboxFilterChoicechip({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(200, 76)), + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: CheckboxFilterEditor( + filterId: filterId, + ), + ); + }, + child: SingleFilterBlocSelector( + filterId: filterId, + builder: (context, filter, field) { + return ChoiceChipButton( + fieldInfo: field, + filterDesc: filter.condition.filterName, + ); + }, + ), + ); + } +} + +class CheckboxFilterEditor extends StatefulWidget { + const CheckboxFilterEditor({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + State createState() => _CheckboxFilterEditorState(); +} + +class _CheckboxFilterEditorState extends State { + final popoverMutex = PopoverMutex(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleFilterBlocSelector( + filterId: widget.filterId, + builder: (context, filter, field) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: FlowyText( + field.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + CheckboxFilterConditionList( + filter: filter, + popoverMutex: popoverMutex, + onCondition: (condition) { + final newFilter = filter.copyWith(condition: condition); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + ), + DisclosureButton( + popoverMutex: popoverMutex, + onAction: (action) { + switch (action) { + case FilterDisclosureAction.delete: + context.read().add( + FilterEditorEvent.deleteFilter( + filter.filterId, + ), + ); + break; + } + }, + ), + ], + ), + ), + ); + }, + ); + } +} + +class CheckboxFilterConditionList extends StatelessWidget { + const CheckboxFilterConditionList({ + super.key, + required this.filter, + required this.popoverMutex, + required this.onCondition, + }); + + final CheckboxFilter filter; + final PopoverMutex popoverMutex; + final void Function(CheckboxFilterConditionPB) onCondition; + + @override + Widget build(BuildContext context) { + return PopoverActionList( + asBarrier: true, + mutex: popoverMutex, + direction: PopoverDirection.bottomWithCenterAligned, + actions: CheckboxFilterConditionPB.values + .map( + (action) => ConditionWrapper( + action, + filter.condition == action, + ), + ) + .toList(), + buildChild: (controller) { + return ConditionButton( + conditionName: filter.conditionName, + onTap: () => controller.show(), + ); + }, + onSelected: (action, controller) { + onCondition(action.inner); + controller.close(); + }, + ); + } +} + +class ConditionWrapper extends ActionCell { + ConditionWrapper(this.inner, this.isSelected); + + final CheckboxFilterConditionPB inner; + final bool isSelected; + + @override + Widget? rightIcon(Color iconColor) => + isSelected ? const FlowySvg(FlowySvgs.check_s) : null; + + @override + String get name => inner.filterName; +} + +extension TextFilterConditionPBExtension on CheckboxFilterConditionPB { + String get filterName { + switch (this) { + case CheckboxFilterConditionPB.IsChecked: + return LocaleKeys.grid_checkboxFilter_isChecked.tr(); + case CheckboxFilterConditionPB.IsUnChecked: + return LocaleKeys.grid_checkboxFilter_isUnchecked.tr(); + default: + return ""; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart new file mode 100644 index 0000000000000..9dd302ecf71f3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart @@ -0,0 +1,170 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/checklist_filter.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../condition_button.dart'; +import '../disclosure_button.dart'; +import 'choicechip.dart'; + +class ChecklistFilterChoicechip extends StatelessWidget { + const ChecklistFilterChoicechip({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: PopoverController(), + constraints: BoxConstraints.loose(const Size(200, 160)), + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: ChecklistFilterEditor(filterId: filterId), + ); + }, + child: SingleFilterBlocSelector( + filterId: filterId, + builder: (context, filter, field) { + return ChoiceChipButton( + fieldInfo: field, + filterDesc: filter.getContentDescription(field), + ); + }, + ), + ); + } +} + +class ChecklistFilterEditor extends StatefulWidget { + const ChecklistFilterEditor({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + ChecklistState createState() => ChecklistState(); +} + +class ChecklistState extends State { + final PopoverMutex popoverMutex = PopoverMutex(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleFilterBlocSelector( + filterId: widget.filterId, + builder: (context, filter, field) { + return SizedBox( + height: 20, + child: Row( + children: [ + Expanded( + child: FlowyText( + field.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + ChecklistFilterConditionList( + filter: filter, + popoverMutex: popoverMutex, + onCondition: (condition) { + final newFilter = filter.copyWith(condition: condition); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + ), + DisclosureButton( + popoverMutex: popoverMutex, + onAction: (action) { + switch (action) { + case FilterDisclosureAction.delete: + context + .read() + .add(FilterEditorEvent.deleteFilter(filter.filterId)); + break; + } + }, + ), + ], + ), + ); + }, + ); + } +} + +class ChecklistFilterConditionList extends StatelessWidget { + const ChecklistFilterConditionList({ + super.key, + required this.filter, + required this.popoverMutex, + required this.onCondition, + }); + + final ChecklistFilter filter; + final PopoverMutex popoverMutex; + final void Function(ChecklistFilterConditionPB) onCondition; + + @override + Widget build(BuildContext context) { + return PopoverActionList( + asBarrier: true, + direction: PopoverDirection.bottomWithCenterAligned, + mutex: popoverMutex, + actions: ChecklistFilterConditionPB.values + .map((action) => ConditionWrapper(action)) + .toList(), + buildChild: (controller) { + return ConditionButton( + conditionName: filter.condition.filterName, + onTap: () => controller.show(), + ); + }, + onSelected: (action, controller) { + onCondition(action.inner); + controller.close(); + }, + ); + } +} + +class ConditionWrapper extends ActionCell { + ConditionWrapper(this.inner); + + final ChecklistFilterConditionPB inner; + + @override + String get name => inner.filterName; +} + +extension ChecklistFilterConditionPBExtension on ChecklistFilterConditionPB { + String get filterName { + switch (this) { + case ChecklistFilterConditionPB.IsComplete: + return LocaleKeys.grid_checklistFilter_isComplete.tr(); + case ChecklistFilterConditionPB.IsIncomplete: + return LocaleKeys.grid_checklistFilter_isIncomplted.tr(); + default: + return ""; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/choicechip.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/choicechip.dart new file mode 100644 index 0000000000000..99804ad69b68b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/choicechip.dart @@ -0,0 +1,113 @@ +import 'dart:math' as math; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ChoiceChipButton extends StatelessWidget { + const ChoiceChipButton({ + super.key, + required this.fieldInfo, + this.filterDesc = '', + this.onTap, + }); + + final FieldInfo fieldInfo; + final String filterDesc; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final buttonText = + filterDesc.isEmpty ? fieldInfo.name : "${fieldInfo.name}: $filterDesc"; + + return SizedBox( + height: 28, + child: FlowyButton( + decoration: BoxDecoration( + color: Colors.transparent, + border: Border.fromBorderSide( + BorderSide( + color: AFThemeExtension.of(context).toggleOffFill, + ), + ), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + useIntrinsicWidth: true, + text: FlowyText( + buttonText, + lineHeight: 1.0, + color: AFThemeExtension.of(context).textColor, + ), + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + radius: const BorderRadius.all(Radius.circular(14)), + leftIcon: FieldIcon( + fieldInfo: fieldInfo, + ), + rightIcon: const _ChoicechipDownArrow(), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onTap: onTap, + ), + ); + } +} + +class _ChoicechipDownArrow extends StatelessWidget { + const _ChoicechipDownArrow(); + + @override + Widget build(BuildContext context) { + return Transform.rotate( + angle: -math.pi / 2, + child: FlowySvg( + FlowySvgs.arrow_left_s, + color: AFThemeExtension.of(context).textColor, + ), + ); + } +} + +class SingleFilterBlocSelector + extends StatelessWidget { + const SingleFilterBlocSelector({ + super.key, + required this.filterId, + required this.builder, + }); + + final String filterId; + final Widget Function(BuildContext, T, FieldInfo) builder; + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) { + final filter = state.filters + .firstWhereOrNull((filter) => filter.filterId == filterId) as T?; + if (filter == null) { + return null; + } + final field = state.fields + .firstWhereOrNull((field) => field.id == filter.fieldId); + if (field == null) { + return null; + } + return (filter, field); + }, + builder: (context, selection) { + if (selection == null) { + return const SizedBox.shrink(); + } + return builder(context, selection.$1, selection.$2); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart new file mode 100644 index 0000000000000..a2d61cc5f4439 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart @@ -0,0 +1,420 @@ +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../condition_button.dart'; +import '../disclosure_button.dart'; + +import 'choicechip.dart'; + +class DateFilterChoicechip extends StatelessWidget { + const DateFilterChoicechip({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(275, 120)), + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: DateFilterEditor(filterId: filterId), + ); + }, + child: SingleFilterBlocSelector( + filterId: filterId, + builder: (context, filter, field) { + return ChoiceChipButton( + fieldInfo: field, + filterDesc: filter.getContentDescription(field), + ); + }, + ), + ); + } +} + +class DateFilterEditor extends StatefulWidget { + const DateFilterEditor({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + State createState() => _DateFilterEditorState(); +} + +class _DateFilterEditorState extends State { + final popoverMutex = PopoverMutex(); + final popooverController = PopoverController(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleFilterBlocSelector( + filterId: widget.filterId, + builder: (context, filter, field) { + final List children = [ + _buildFilterPanel(filter, field), + if (![ + DateFilterConditionPB.DateStartIsEmpty, + DateFilterConditionPB.DateStartIsNotEmpty, + DateFilterConditionPB.DateEndIsEmpty, + DateFilterConditionPB.DateStartIsNotEmpty, + ].contains(filter.condition)) ...[ + const VSpace(4), + _buildFilterContentField(filter), + ], + ]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: IntrinsicHeight(child: Column(children: children)), + ); + }, + ); + } + + Widget _buildFilterPanel( + DateTimeFilter filter, + FieldInfo field, + ) { + return SizedBox( + height: 20, + child: Row( + children: [ + Expanded( + child: field.fieldType == FieldType.DateTime + ? DateFilterIsStartList( + filter: filter, + popoverMutex: popoverMutex, + onChangeIsStart: (isStart) { + final newFilter = filter.copyWithCondition( + isStart: isStart, + condition: filter.condition.toCondition(), + ); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + ) + : FlowyText( + field.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + Expanded( + child: DateFilterConditionList( + filter: filter, + popoverMutex: popoverMutex, + onCondition: (condition) { + final newFilter = filter.copyWithCondition( + isStart: filter.condition.isStart, + condition: condition, + ); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + ), + ), + const HSpace(4), + DisclosureButton( + popoverMutex: popoverMutex, + onAction: (action) { + switch (action) { + case FilterDisclosureAction.delete: + context + .read() + .add(FilterEditorEvent.deleteFilter(filter.filterId)); + break; + } + }, + ), + ], + ), + ); + } + + Widget _buildFilterContentField(DateTimeFilter filter) { + final isRange = filter.condition.isRange; + String? text; + + if (isRange) { + text = + "${filter.start?.defaultFormat ?? ""} - ${filter.end?.defaultFormat ?? ""}"; + text = text == " - " ? null : text; + } else { + text = filter.timestamp.defaultFormat; + } + + return AppFlowyPopover( + controller: popooverController, + triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: BoxConstraints.loose(const Size(260, 620)), + offset: const Offset(0, 4), + margin: EdgeInsets.zero, + mutex: popoverMutex, + child: FlowyButton( + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), + ), + borderRadius: Corners.s6Border, + ), + onTap: popooverController.show, + text: FlowyText( + text ?? "", + overflow: TextOverflow.ellipsis, + ), + ), + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: SingleFilterBlocSelector( + filterId: widget.filterId, + builder: (context, filter, field) { + return DesktopAppFlowyDatePicker( + isRange: isRange, + includeTime: false, + dateFormat: DateFormatPB.Friendly, + timeFormat: TimeFormatPB.TwentyFourHour, + dateTime: isRange ? filter.start : filter.timestamp, + endDateTime: isRange ? filter.end : null, + onDaySelected: (selectedDay) { + final newFilter = isRange + ? filter.copyWithRange(start: selectedDay, end: null) + : filter.copyWithTimestamp(timestamp: selectedDay); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + if (isRange) { + popooverController.close(); + } + }, + onRangeSelected: (start, end) { + final newFilter = filter.copyWithRange( + start: start, + end: end, + ); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + ); + }, + ), + ); + }, + ); + } +} + +class DateFilterIsStartList extends StatelessWidget { + const DateFilterIsStartList({ + super.key, + required this.filter, + required this.popoverMutex, + required this.onChangeIsStart, + }); + + final DateTimeFilter filter; + final PopoverMutex popoverMutex; + final Function(bool isStart) onChangeIsStart; + + @override + Widget build(BuildContext context) { + return PopoverActionList<_IsStartWrapper>( + asBarrier: true, + mutex: popoverMutex, + direction: PopoverDirection.bottomWithCenterAligned, + actions: [ + _IsStartWrapper( + true, + filter.condition.isStart, + ), + _IsStartWrapper( + false, + !filter.condition.isStart, + ), + ], + buildChild: (controller) { + return ConditionButton( + conditionName: filter.condition.isStart + ? LocaleKeys.grid_dateFilter_startDate.tr() + : LocaleKeys.grid_dateFilter_endDate.tr(), + onTap: () => controller.show(), + ); + }, + onSelected: (action, controller) { + onChangeIsStart(action.inner); + controller.close(); + }, + ); + } +} + +class _IsStartWrapper extends ActionCell { + _IsStartWrapper(this.inner, this.isSelected); + + final bool inner; + final bool isSelected; + + @override + Widget? rightIcon(Color iconColor) => + isSelected ? const FlowySvg(FlowySvgs.check_s) : null; + + @override + String get name => inner + ? LocaleKeys.grid_dateFilter_startDate.tr() + : LocaleKeys.grid_dateFilter_endDate.tr(); +} + +class DateFilterConditionList extends StatelessWidget { + const DateFilterConditionList({ + super.key, + required this.filter, + required this.popoverMutex, + required this.onCondition, + }); + + final DateTimeFilter filter; + final PopoverMutex popoverMutex; + final Function(DateTimeFilterCondition) onCondition; + + @override + Widget build(BuildContext context) { + final conditions = DateTimeFilterCondition.availableConditionsForFieldType( + filter.fieldType, + ); + return PopoverActionList( + asBarrier: true, + mutex: popoverMutex, + direction: PopoverDirection.bottomWithCenterAligned, + actions: conditions + .map( + (action) => ConditionWrapper( + action, + filter.condition.toCondition() == action, + ), + ) + .toList(), + buildChild: (controller) { + return ConditionButton( + conditionName: filter.condition.toCondition().filterName, + onTap: () => controller.show(), + ); + }, + onSelected: (action, controller) { + onCondition(action.inner); + controller.close(); + }, + ); + } +} + +class ConditionWrapper extends ActionCell { + ConditionWrapper(this.inner, this.isSelected); + + final DateTimeFilterCondition inner; + final bool isSelected; + + @override + Widget? rightIcon(Color iconColor) => + isSelected ? const FlowySvg(FlowySvgs.check_s) : null; + + @override + String get name => inner.filterName; +} + +extension DateFilterConditionPBExtension on DateFilterConditionPB { + bool get isStart { + return switch (this) { + DateFilterConditionPB.DateStartsOn || + DateFilterConditionPB.DateStartsBefore || + DateFilterConditionPB.DateStartsAfter || + DateFilterConditionPB.DateStartsOnOrBefore || + DateFilterConditionPB.DateStartsOnOrAfter || + DateFilterConditionPB.DateStartsBetween || + DateFilterConditionPB.DateStartIsEmpty || + DateFilterConditionPB.DateStartIsNotEmpty => + true, + _ => false + }; + } + + bool get isRange { + return switch (this) { + DateFilterConditionPB.DateStartsBetween || + DateFilterConditionPB.DateEndsBetween => + true, + _ => false, + }; + } + + DateTimeFilterCondition toCondition() { + return switch (this) { + DateFilterConditionPB.DateStartsOn || + DateFilterConditionPB.DateEndsOn => + DateTimeFilterCondition.on, + DateFilterConditionPB.DateStartsBefore || + DateFilterConditionPB.DateEndsBefore => + DateTimeFilterCondition.before, + DateFilterConditionPB.DateStartsAfter || + DateFilterConditionPB.DateEndsAfter => + DateTimeFilterCondition.after, + DateFilterConditionPB.DateStartsOnOrBefore || + DateFilterConditionPB.DateEndsOnOrBefore => + DateTimeFilterCondition.onOrBefore, + DateFilterConditionPB.DateStartsOnOrAfter || + DateFilterConditionPB.DateEndsOnOrAfter => + DateTimeFilterCondition.onOrAfter, + DateFilterConditionPB.DateStartsBetween || + DateFilterConditionPB.DateEndsBetween => + DateTimeFilterCondition.between, + DateFilterConditionPB.DateStartIsEmpty || + DateFilterConditionPB.DateEndIsEmpty => + DateTimeFilterCondition.isEmpty, + DateFilterConditionPB.DateStartIsNotEmpty || + DateFilterConditionPB.DateEndIsNotEmpty => + DateTimeFilterCondition.isNotEmpty, + _ => throw ArgumentError(), + }; + } +} + +extension DateTimeChoicechipExtension on DateTime { + DateTime get considerLocal { + return DateTime(year, month, day); + } +} + +extension DateTimeDefaultFormatExtension on DateTime? { + String? get defaultFormat { + return this != null ? DateFormat('dd/MM/yyyy').format(this!) : null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart new file mode 100644 index 0000000000000..cc38b4eaba940 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/number.dart @@ -0,0 +1,249 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../condition_button.dart'; +import '../disclosure_button.dart'; + +import 'choicechip.dart'; + +class NumberFilterChoiceChip extends StatelessWidget { + const NumberFilterChoiceChip({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(200, 100)), + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: NumberFilterEditor(filterId: filterId), + ); + }, + child: SingleFilterBlocSelector( + filterId: filterId, + builder: (context, filter, field) { + return ChoiceChipButton( + fieldInfo: field, + filterDesc: filter.getContentDescription(field), + ); + }, + ), + ); + } +} + +class NumberFilterEditor extends StatefulWidget { + const NumberFilterEditor({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + State createState() => _NumberFilterEditorState(); +} + +class _NumberFilterEditorState extends State { + final popoverMutex = PopoverMutex(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleFilterBlocSelector( + filterId: widget.filterId, + builder: (context, filter, field) { + final List children = [ + _buildFilterPanel(filter, field), + if (filter.condition != NumberFilterConditionPB.NumberIsEmpty && + filter.condition != NumberFilterConditionPB.NumberIsNotEmpty) ...[ + const VSpace(4), + _buildFilterNumberField(filter), + ], + ]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: IntrinsicHeight(child: Column(children: children)), + ); + }, + ); + } + + Widget _buildFilterPanel( + NumberFilter filter, + FieldInfo field, + ) { + return SizedBox( + height: 20, + child: Row( + children: [ + Expanded( + child: FlowyText( + field.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + Expanded( + child: NumberFilterConditionList( + filter: filter, + popoverMutex: popoverMutex, + onCondition: (condition) { + final newFilter = filter.copyWith(condition: condition); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + ), + ), + const HSpace(4), + DisclosureButton( + popoverMutex: popoverMutex, + onAction: (action) { + switch (action) { + case FilterDisclosureAction.delete: + context.read().add( + FilterEditorEvent.deleteFilter( + filter.filterId, + ), + ); + break; + } + }, + ), + ], + ), + ); + } + + Widget _buildFilterNumberField( + NumberFilter filter, + ) { + return FlowyTextField( + text: filter.content, + hintText: LocaleKeys.grid_settings_typeAValue.tr(), + debounceDuration: const Duration(milliseconds: 300), + autoFocus: false, + onChanged: (text) { + final newFilter = filter.copyWith(content: text); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + ); + } +} + +class NumberFilterConditionList extends StatelessWidget { + const NumberFilterConditionList({ + super.key, + required this.filter, + required this.popoverMutex, + required this.onCondition, + }); + + final NumberFilter filter; + final PopoverMutex popoverMutex; + final void Function(NumberFilterConditionPB) onCondition; + + @override + Widget build(BuildContext context) { + return PopoverActionList( + asBarrier: true, + mutex: popoverMutex, + direction: PopoverDirection.bottomWithCenterAligned, + actions: NumberFilterConditionPB.values + .map( + (action) => ConditionWrapper( + action, + filter.condition == action, + ), + ) + .toList(), + buildChild: (controller) { + return ConditionButton( + conditionName: filter.condition.filterName, + onTap: () => controller.show(), + ); + }, + onSelected: (action, controller) { + onCondition(action.inner); + controller.close(); + }, + ); + } +} + +class ConditionWrapper extends ActionCell { + ConditionWrapper(this.inner, this.isSelected); + + final NumberFilterConditionPB inner; + final bool isSelected; + + @override + Widget? rightIcon(Color iconColor) => + isSelected ? const FlowySvg(FlowySvgs.check_s) : null; + + @override + String get name => inner.filterName; +} + +extension NumberFilterConditionPBExtension on NumberFilterConditionPB { + String get shortName { + return switch (this) { + NumberFilterConditionPB.Equal => "=", + NumberFilterConditionPB.NotEqual => "≠", + NumberFilterConditionPB.LessThan => "<", + NumberFilterConditionPB.LessThanOrEqualTo => "≤", + NumberFilterConditionPB.GreaterThan => ">", + NumberFilterConditionPB.GreaterThanOrEqualTo => "≥", + NumberFilterConditionPB.NumberIsEmpty => + LocaleKeys.grid_numberFilter_isEmpty.tr(), + NumberFilterConditionPB.NumberIsNotEmpty => + LocaleKeys.grid_numberFilter_isNotEmpty.tr(), + _ => "", + }; + } + + String get filterName { + return switch (this) { + NumberFilterConditionPB.Equal => LocaleKeys.grid_numberFilter_equal.tr(), + NumberFilterConditionPB.NotEqual => + LocaleKeys.grid_numberFilter_notEqual.tr(), + NumberFilterConditionPB.LessThan => + LocaleKeys.grid_numberFilter_lessThan.tr(), + NumberFilterConditionPB.LessThanOrEqualTo => + LocaleKeys.grid_numberFilter_lessThanOrEqualTo.tr(), + NumberFilterConditionPB.GreaterThan => + LocaleKeys.grid_numberFilter_greaterThan.tr(), + NumberFilterConditionPB.GreaterThanOrEqualTo => + LocaleKeys.grid_numberFilter_greaterThanOrEqualTo.tr(), + NumberFilterConditionPB.NumberIsEmpty => + LocaleKeys.grid_numberFilter_isEmpty.tr(), + NumberFilterConditionPB.NumberIsNotEmpty => + LocaleKeys.grid_numberFilter_isNotEmpty.tr(), + _ => "", + }; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart new file mode 100644 index 0000000000000..a8c8f6901657f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart @@ -0,0 +1,91 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/database/view/database_filter_condition_list.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/condition_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/widgets.dart'; + +class SelectOptionFilterConditionList extends StatelessWidget { + const SelectOptionFilterConditionList({ + super.key, + required this.filter, + required this.fieldType, + required this.popoverMutex, + required this.onCondition, + }); + + final SelectOptionFilter filter; + final FieldType fieldType; + final PopoverMutex popoverMutex; + final void Function(SelectOptionFilterConditionPB) onCondition; + + @override + Widget build(BuildContext context) { + final conditions = (fieldType == FieldType.SingleSelect + ? SingleSelectOptionFilterCondition().conditions + : MultiSelectOptionFilterCondition().conditions); + return PopoverActionList( + asBarrier: true, + mutex: popoverMutex, + direction: PopoverDirection.bottomWithCenterAligned, + actions: conditions + .map( + (action) => ConditionWrapper( + action.$1, + filter.condition == action.$1, + ), + ) + .toList(), + buildChild: (controller) { + return ConditionButton( + conditionName: filter.condition.i18n, + onTap: () => controller.show(), + ); + }, + onSelected: (action, controller) async { + onCondition(action.inner); + controller.close(); + }, + ); + } +} + +class ConditionWrapper extends ActionCell { + ConditionWrapper(this.inner, this.isSelected); + + final SelectOptionFilterConditionPB inner; + final bool isSelected; + + @override + Widget? rightIcon(Color iconColor) { + return isSelected ? const FlowySvg(FlowySvgs.check_s) : null; + } + + @override + String get name => inner.i18n; +} + +extension SelectOptionFilterConditionPBExtension + on SelectOptionFilterConditionPB { + String get i18n { + return switch (this) { + SelectOptionFilterConditionPB.OptionIs => + LocaleKeys.grid_selectOptionFilter_is.tr(), + SelectOptionFilterConditionPB.OptionIsNot => + LocaleKeys.grid_selectOptionFilter_isNot.tr(), + SelectOptionFilterConditionPB.OptionContains => + LocaleKeys.grid_selectOptionFilter_contains.tr(), + SelectOptionFilterConditionPB.OptionDoesNotContain => + LocaleKeys.grid_selectOptionFilter_doesNotContain.tr(), + SelectOptionFilterConditionPB.OptionIsEmpty => + LocaleKeys.grid_selectOptionFilter_isEmpty.tr(), + SelectOptionFilterConditionPB.OptionIsNotEmpty => + LocaleKeys.grid_selectOptionFilter_isNotEmpty.tr(), + _ => "", + }; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart new file mode 100644 index 0000000000000..3e9db2df1b1f4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart @@ -0,0 +1,112 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SelectOptionFilterList extends StatelessWidget { + const SelectOptionFilterList({ + super.key, + required this.filter, + required this.field, + required this.options, + required this.onTap, + }); + + final SelectOptionFilter filter; + final FieldInfo field; + final List options; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: options.length, + separatorBuilder: (context, index) => + VSpace(GridSize.typeOptionSeparatorHeight), + itemBuilder: (context, index) { + final option = options[index]; + final isSelected = filter.optionIds.contains(option.id); + return SelectOptionFilterCell( + option: option, + isSelected: isSelected, + onTap: () => _onTapHandler(context, option, isSelected), + ); + }, + ); + } + + void _onTapHandler( + BuildContext context, + SelectOptionPB option, + bool isSelected, + ) { + final selectedOptionIds = Set.from(filter.optionIds); + if (isSelected) { + selectedOptionIds.remove(option.id); + } else { + selectedOptionIds.add(option.id); + } + _updateSelectOptions(context, filter, selectedOptionIds); + onTap(); + } + + void _updateSelectOptions( + BuildContext context, + SelectOptionFilter filter, + Set selectedOptionIds, + ) { + final optionIds = + options.map((e) => e.id).where(selectedOptionIds.contains).toList(); + final newFilter = filter.copyWith(optionIds: optionIds); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + } +} + +class SelectOptionFilterCell extends StatelessWidget { + const SelectOptionFilterCell({ + super.key, + required this.option, + required this.isSelected, + required this.onTap, + }); + + final SelectOptionPB option; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyHover( + resetHoverOnRebuild: false, + style: HoverStyle( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + child: SelectOptionTagCell( + option: option, + onSelected: onTap, + children: [ + if (isSelected) + const Padding( + padding: EdgeInsets.only(right: 6), + child: FlowySvg(FlowySvgs.check_s), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart new file mode 100644 index 0000000000000..50984bbf3d4d9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart @@ -0,0 +1,146 @@ +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../disclosure_button.dart'; +import '../choicechip.dart'; + +import 'condition_list.dart'; +import 'option_list.dart'; + +class SelectOptionFilterChoicechip extends StatelessWidget { + const SelectOptionFilterChoicechip({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(240, 160)), + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: SelectOptionFilterEditor(filterId: filterId), + ); + }, + child: SingleFilterBlocSelector( + filterId: filterId, + builder: (context, filter, field) { + return ChoiceChipButton( + fieldInfo: field, + filterDesc: filter.getContentDescription(field), + ); + }, + ), + ); + } +} + +class SelectOptionFilterEditor extends StatefulWidget { + const SelectOptionFilterEditor({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + State createState() => + _SelectOptionFilterEditorState(); +} + +class _SelectOptionFilterEditorState extends State { + final popoverMutex = PopoverMutex(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleFilterBlocSelector( + filterId: widget.filterId, + builder: (context, filter, field) { + final List slivers = [ + SliverToBoxAdapter(child: _buildFilterPanel(filter, field)), + ]; + + if (filter.canAttachContent) { + slivers + ..add(const SliverToBoxAdapter(child: VSpace(4))) + ..add( + SliverToBoxAdapter( + child: SelectOptionFilterList( + filter: filter, + field: field, + options: filter.makeDelegate(field).getOptions(field), + onTap: () => popoverMutex.close(), + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: CustomScrollView( + shrinkWrap: true, + slivers: slivers, + physics: StyledScrollPhysics(), + ), + ); + }, + ); + } + + Widget _buildFilterPanel( + SelectOptionFilter filter, + FieldInfo field, + ) { + return SizedBox( + height: 20, + child: Row( + children: [ + Expanded( + child: FlowyText( + field.field.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + SelectOptionFilterConditionList( + filter: filter, + fieldType: field.fieldType, + popoverMutex: popoverMutex, + onCondition: (condition) { + final newFilter = filter.copyWith(condition: condition); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + ), + DisclosureButton( + popoverMutex: popoverMutex, + onAction: (action) { + switch (action) { + case FilterDisclosureAction.delete: + context + .read() + .add(FilterEditorEvent.deleteFilter(filter.filterId)); + break; + } + }, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart new file mode 100644 index 0000000000000..548f21efbe116 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart @@ -0,0 +1,253 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../condition_button.dart'; +import '../disclosure_button.dart'; + +import 'choicechip.dart'; + +class TextFilterChoicechip extends StatelessWidget { + const TextFilterChoicechip({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(200, 76)), + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: TextFilterEditor(filterId: filterId), + ); + }, + child: SingleFilterBlocSelector( + filterId: filterId, + builder: (context, filter, field) { + return ChoiceChipButton( + fieldInfo: field, + filterDesc: filter.getContentDescription(field), + ); + }, + ), + ); + } +} + +class TextFilterEditor extends StatefulWidget { + const TextFilterEditor({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + State createState() => _TextFilterEditorState(); +} + +class _TextFilterEditorState extends State { + final popoverMutex = PopoverMutex(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleFilterBlocSelector( + filterId: widget.filterId, + builder: (context, filter, field) { + final List children = [ + _buildFilterPanel(filter, field), + ]; + + if (filter.condition != TextFilterConditionPB.TextIsEmpty && + filter.condition != TextFilterConditionPB.TextIsNotEmpty) { + children.add(const VSpace(4)); + children.add(_buildFilterTextField(filter, field)); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: IntrinsicHeight(child: Column(children: children)), + ); + }, + ); + } + + Widget _buildFilterPanel(TextFilter filter, FieldInfo field) { + return SizedBox( + height: 20, + child: Row( + children: [ + Expanded( + child: FlowyText( + field.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + Expanded( + child: TextFilterConditionList( + filter: filter, + popoverMutex: popoverMutex, + onCondition: (condition) { + final newFilter = filter.copyWith(condition: condition); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + ), + ), + const HSpace(4), + DisclosureButton( + popoverMutex: popoverMutex, + onAction: (action) { + switch (action) { + case FilterDisclosureAction.delete: + context + .read() + .add(FilterEditorEvent.deleteFilter(filter.filterId)); + break; + } + }, + ), + ], + ), + ); + } + + Widget _buildFilterTextField(TextFilter filter, FieldInfo field) { + return FlowyTextField( + text: filter.content, + hintText: LocaleKeys.grid_settings_typeAValue.tr(), + debounceDuration: const Duration(milliseconds: 300), + autoFocus: false, + onChanged: (text) { + final newFilter = filter.copyWith(content: text); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + ); + } +} + +class TextFilterConditionList extends StatelessWidget { + const TextFilterConditionList({ + super.key, + required this.filter, + required this.popoverMutex, + required this.onCondition, + }); + + final TextFilter filter; + final PopoverMutex popoverMutex; + final void Function(TextFilterConditionPB) onCondition; + + @override + Widget build(BuildContext context) { + return PopoverActionList( + asBarrier: true, + mutex: popoverMutex, + direction: PopoverDirection.bottomWithCenterAligned, + actions: TextFilterConditionPB.values + .map( + (action) => ConditionWrapper( + action, + filter.condition == action, + ), + ) + .toList(), + buildChild: (controller) { + return ConditionButton( + conditionName: filter.condition.filterName, + onTap: () => controller.show(), + ); + }, + onSelected: (action, controller) async { + onCondition(action.inner); + controller.close(); + }, + ); + } +} + +class ConditionWrapper extends ActionCell { + ConditionWrapper(this.inner, this.isSelected); + + final TextFilterConditionPB inner; + final bool isSelected; + + @override + Widget? rightIcon(Color iconColor) { + if (isSelected) { + return const FlowySvg(FlowySvgs.check_s); + } else { + return null; + } + } + + @override + String get name => inner.filterName; +} + +extension TextFilterConditionPBExtension on TextFilterConditionPB { + String get filterName { + switch (this) { + case TextFilterConditionPB.TextContains: + return LocaleKeys.grid_textFilter_contains.tr(); + case TextFilterConditionPB.TextDoesNotContain: + return LocaleKeys.grid_textFilter_doesNotContain.tr(); + case TextFilterConditionPB.TextEndsWith: + return LocaleKeys.grid_textFilter_endsWith.tr(); + case TextFilterConditionPB.TextIs: + return LocaleKeys.grid_textFilter_is.tr(); + case TextFilterConditionPB.TextIsNot: + return LocaleKeys.grid_textFilter_isNot.tr(); + case TextFilterConditionPB.TextStartsWith: + return LocaleKeys.grid_textFilter_startWith.tr(); + case TextFilterConditionPB.TextIsEmpty: + return LocaleKeys.grid_textFilter_isEmpty.tr(); + case TextFilterConditionPB.TextIsNotEmpty: + return LocaleKeys.grid_textFilter_isNotEmpty.tr(); + default: + return ""; + } + } + + String get choicechipPrefix { + switch (this) { + case TextFilterConditionPB.TextDoesNotContain: + return LocaleKeys.grid_textFilter_choicechipPrefix_isNot.tr(); + case TextFilterConditionPB.TextEndsWith: + return LocaleKeys.grid_textFilter_choicechipPrefix_endWith.tr(); + case TextFilterConditionPB.TextIsNot: + return LocaleKeys.grid_textFilter_choicechipPrefix_isNot.tr(); + case TextFilterConditionPB.TextStartsWith: + return LocaleKeys.grid_textFilter_choicechipPrefix_startWith.tr(); + case TextFilterConditionPB.TextIsEmpty: + return LocaleKeys.grid_textFilter_choicechipPrefix_isEmpty.tr(); + case TextFilterConditionPB.TextIsNotEmpty: + return LocaleKeys.grid_textFilter_choicechipPrefix_isNotEmpty.tr(); + default: + return ""; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/time.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/time.dart new file mode 100644 index 0000000000000..dcd33f66c383a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/time.dart @@ -0,0 +1,230 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../condition_button.dart'; +import '../disclosure_button.dart'; + +import 'choicechip.dart'; + +class TimeFilterChoiceChip extends StatelessWidget { + const TimeFilterChoiceChip({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(200, 100)), + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: TimeFilterEditor(filterId: filterId), + ); + }, + child: SingleFilterBlocSelector( + filterId: filterId, + builder: (context, filter, field) { + return ChoiceChipButton( + fieldInfo: field, + ); + }, + ), + ); + } +} + +class TimeFilterEditor extends StatefulWidget { + const TimeFilterEditor({ + super.key, + required this.filterId, + }); + + final String filterId; + @override + State createState() => _TimeFilterEditorState(); +} + +class _TimeFilterEditorState extends State { + final popoverMutex = PopoverMutex(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleFilterBlocSelector( + filterId: widget.filterId, + builder: (context, filter, field) { + final List children = [ + _buildFilterPanel(filter, field), + if (filter.condition != NumberFilterConditionPB.NumberIsEmpty && + filter.condition != NumberFilterConditionPB.NumberIsNotEmpty) ...[ + const VSpace(4), + _buildFilterTimeField(filter, field), + ], + ]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: IntrinsicHeight(child: Column(children: children)), + ); + }, + ); + } + + Widget _buildFilterPanel( + TimeFilter filter, + FieldInfo field, + ) { + return SizedBox( + height: 20, + child: Row( + children: [ + Expanded( + child: FlowyText( + field.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + Expanded( + child: TimeFilterConditionList( + filter: filter, + popoverMutex: popoverMutex, + onCondition: (condition) { + final newFilter = filter.copyWith(condition: condition); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + ), + ), + const HSpace(4), + DisclosureButton( + popoverMutex: popoverMutex, + onAction: (action) { + switch (action) { + case FilterDisclosureAction.delete: + context + .read() + .add(FilterEditorEvent.deleteFilter(filter.filterId)); + break; + } + }, + ), + ], + ), + ); + } + + Widget _buildFilterTimeField( + TimeFilter filter, + FieldInfo field, + ) { + return FlowyTextField( + text: filter.content, + hintText: LocaleKeys.grid_settings_typeAValue.tr(), + debounceDuration: const Duration(milliseconds: 300), + autoFocus: false, + onChanged: (text) { + final newFilter = filter.copyWith(content: text); + context + .read() + .add(FilterEditorEvent.updateFilter(newFilter)); + }, + ); + } +} + +class TimeFilterConditionList extends StatelessWidget { + const TimeFilterConditionList({ + super.key, + required this.filter, + required this.popoverMutex, + required this.onCondition, + }); + + final TimeFilter filter; + final PopoverMutex popoverMutex; + final void Function(NumberFilterConditionPB) onCondition; + + @override + Widget build(BuildContext context) { + return PopoverActionList( + asBarrier: true, + mutex: popoverMutex, + direction: PopoverDirection.bottomWithCenterAligned, + actions: NumberFilterConditionPB.values + .map( + (action) => ConditionWrapper( + action, + filter.condition == action, + ), + ) + .toList(), + buildChild: (controller) { + return ConditionButton( + conditionName: filter.condition.filterName, + onTap: () => controller.show(), + ); + }, + onSelected: (action, controller) { + onCondition(action.inner); + controller.close(); + }, + ); + } +} + +class ConditionWrapper extends ActionCell { + ConditionWrapper(this.inner, this.isSelected); + + final NumberFilterConditionPB inner; + final bool isSelected; + + @override + Widget? rightIcon(Color iconColor) => + isSelected ? const FlowySvg(FlowySvgs.check_s) : null; + + @override + String get name => inner.filterName; +} + +extension TimeFilterConditionPBExtension on NumberFilterConditionPB { + String get filterName { + return switch (this) { + NumberFilterConditionPB.Equal => LocaleKeys.grid_numberFilter_equal.tr(), + NumberFilterConditionPB.NotEqual => + LocaleKeys.grid_numberFilter_notEqual.tr(), + NumberFilterConditionPB.LessThan => + LocaleKeys.grid_numberFilter_lessThan.tr(), + NumberFilterConditionPB.LessThanOrEqualTo => + LocaleKeys.grid_numberFilter_lessThanOrEqualTo.tr(), + NumberFilterConditionPB.GreaterThan => + LocaleKeys.grid_numberFilter_greaterThan.tr(), + NumberFilterConditionPB.GreaterThanOrEqualTo => + LocaleKeys.grid_numberFilter_greaterThanOrEqualTo.tr(), + NumberFilterConditionPB.NumberIsEmpty => + LocaleKeys.grid_numberFilter_isEmpty.tr(), + NumberFilterConditionPB.NumberIsNotEmpty => + LocaleKeys.grid_numberFilter_isNotEmpty.tr(), + _ => "", + }; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/url.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/url.dart new file mode 100644 index 0000000000000..7e453b9ab7cdd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/choicechip/url.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'choicechip.dart'; + +class URLFilterChoicechip extends StatelessWidget { + const URLFilterChoicechip({ + super.key, + required this.filterId, + }); + + final String filterId; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(200, 76)), + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: TextFilterEditor(filterId: filterId), + ); + }, + child: SingleFilterBlocSelector( + filterId: filterId, + builder: (context, filter, field) { + return ChoiceChipButton( + fieldInfo: field, + filterDesc: filter.getContentDescription(field), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/condition_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/condition_button.dart new file mode 100644 index 0000000000000..2be7810546dfc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/condition_button.dart @@ -0,0 +1,49 @@ +import 'dart:math' as math; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; + +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class ConditionButton extends StatelessWidget { + const ConditionButton({ + super.key, + required this.conditionName, + required this.onTap, + }); + + final String conditionName; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final arrow = Transform.rotate( + angle: -math.pi / 2, + child: FlowySvg( + FlowySvgs.arrow_left_s, + color: AFThemeExtension.of(context).textColor, + ), + ); + + return SizedBox( + height: 20, + child: FlowyButton( + useIntrinsicWidth: true, + text: FlowyText( + lineHeight: 1.0, + conditionName, + fontSize: 10, + color: AFThemeExtension.of(context).textColor, + overflow: TextOverflow.ellipsis, + ), + margin: const EdgeInsets.symmetric(horizontal: 4), + radius: Corners.s6Border, + rightIcon: arrow, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onTap: onTap, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart new file mode 100644 index 0000000000000..9cf2cd8322f14 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart @@ -0,0 +1,148 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/simple_text_filter_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class CreateDatabaseViewFilterList extends StatelessWidget { + const CreateDatabaseViewFilterList({ + super.key, + this.onTap, + }); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final filterBloc = context.read(); + return BlocProvider( + create: (_) => SimpleTextFilterBloc( + values: List.from(filterBloc.state.fields), + comparator: (val) => val.name, + ), + child: BlocListener( + listenWhen: (previous, current) => previous.fields != current.fields, + listener: (context, state) { + context + .read>() + .add(SimpleTextFilterEvent.receiveNewValues(state.fields)); + }, + child: BlocBuilder, + SimpleTextFilterState>( + builder: (context, state) { + final cells = state.values.map((fieldInfo) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FilterableFieldButton( + fieldInfo: fieldInfo, + onTap: () { + context + .read() + .add(FilterEditorEvent.createFilter(fieldInfo)); + onTap?.call(); + }, + ), + ); + }).toList(); + + final List slivers = [ + SliverPersistentHeader( + pinned: true, + delegate: _FilterTextFieldDelegate(), + ), + SliverToBoxAdapter( + child: ListView.separated( + shrinkWrap: true, + itemCount: cells.length, + itemBuilder: (_, int index) => cells[index], + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), + ), + ), + ]; + return CustomScrollView( + shrinkWrap: true, + slivers: slivers, + physics: StyledScrollPhysics(), + ); + }, + ), + ), + ); + } +} + +class _FilterTextFieldDelegate extends SliverPersistentHeaderDelegate { + _FilterTextFieldDelegate(); + + double fixHeight = 36; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + return Container( + padding: const EdgeInsets.only(bottom: 4), + color: Theme.of(context).cardColor, + height: fixHeight, + child: FlowyTextField( + hintText: LocaleKeys.grid_settings_filterBy.tr(), + onChanged: (text) { + context + .read>() + .add(SimpleTextFilterEvent.updateFilter(text)); + }, + ), + ); + } + + @override + double get maxExtent => fixHeight; + + @override + double get minExtent => fixHeight; + + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { + return false; + } +} + +class FilterableFieldButton extends StatelessWidget { + const FilterableFieldButton({ + super.key, + required this.fieldInfo, + required this.onTap, + }); + + final FieldInfo fieldInfo; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + text: FlowyText( + lineHeight: 1.0, + fieldInfo.field.name, + color: AFThemeExtension.of(context).textColor, + ), + onTap: onTap, + leftIcon: FieldIcon( + fieldInfo: fieldInfo, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/disclosure_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/disclosure_button.dart new file mode 100644 index 0000000000000..4f71e48ab33b1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/disclosure_button.dart @@ -0,0 +1,76 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flutter/material.dart'; + +class DisclosureButton extends StatefulWidget { + const DisclosureButton({ + super.key, + required this.popoverMutex, + required this.onAction, + }); + + final PopoverMutex popoverMutex; + final Function(FilterDisclosureAction) onAction; + + @override + State createState() => _DisclosureButtonState(); +} + +class _DisclosureButtonState extends State { + @override + Widget build(BuildContext context) { + return PopoverActionList( + asBarrier: true, + mutex: widget.popoverMutex, + actions: FilterDisclosureAction.values + .map((action) => FilterDisclosureActionWrapper(action)) + .toList(), + buildChild: (controller) { + return FlowyIconButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + width: 20, + icon: FlowySvg( + FlowySvgs.details_s, + color: Theme.of(context).iconTheme.color, + ), + onPressed: () => controller.show(), + ); + }, + onSelected: (action, controller) async { + widget.onAction(action.inner); + controller.close(); + }, + ); + } +} + +enum FilterDisclosureAction { + delete, +} + +class FilterDisclosureActionWrapper extends ActionCell { + FilterDisclosureActionWrapper(this.inner); + + final FilterDisclosureAction inner; + + @override + Widget? leftIcon(Color iconColor) => null; + + @override + String get name => inner.name; +} + +extension FilterDisclosureActionExtension on FilterDisclosureAction { + String get name { + switch (this) { + case FilterDisclosureAction.delete: + return LocaleKeys.grid_settings_deleteFilter.tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu.dart new file mode 100644 index 0000000000000..c91b47e2b726c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu.dart @@ -0,0 +1,124 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'create_filter_list.dart'; +import 'filter_menu_item.dart'; + +class FilterMenu extends StatelessWidget { + const FilterMenu({ + super.key, + required this.fieldController, + }); + + final FieldController fieldController; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => FilterEditorBloc( + viewId: fieldController.viewId, + fieldController: fieldController, + ), + child: BlocBuilder( + buildWhen: (previous, current) { + final previousIds = previous.filters.map((e) => e.filterId).toList(); + final currentIds = current.filters.map((e) => e.filterId).toList(); + return !listEquals(previousIds, currentIds); + }, + builder: (context, state) { + final List children = []; + children.addAll( + state.filters + .map( + (filter) => FilterMenuItem( + key: ValueKey(filter.filterId), + filterId: filter.filterId, + fieldType: state.fields + .firstWhere( + (element) => element.id == filter.fieldId, + ) + .fieldType, + ), + ) + .toList(), + ); + + if (state.fields.isNotEmpty) { + children.add( + AddFilterButton( + viewId: state.viewId, + ), + ); + } + + return Wrap( + spacing: 6, + runSpacing: 4, + children: children, + ); + }, + ), + ); + } +} + +class AddFilterButton extends StatefulWidget { + const AddFilterButton({required this.viewId, super.key}); + + final String viewId; + + @override + State createState() => _AddFilterButtonState(); +} + +class _AddFilterButtonState extends State { + final PopoverController popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return wrapPopover( + SizedBox( + height: 28, + child: FlowyButton( + text: FlowyText( + lineHeight: 1.0, + LocaleKeys.grid_settings_addFilter.tr(), + color: AFThemeExtension.of(context).textColor, + ), + useIntrinsicWidth: true, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + leftIcon: FlowySvg( + FlowySvgs.add_s, + color: Theme.of(context).iconTheme.color, + ), + onTap: () => popoverController.show(), + ), + ), + ); + } + + Widget wrapPopover(Widget child) { + return AppFlowyPopover( + controller: popoverController, + constraints: BoxConstraints.loose(const Size(200, 300)), + triggerActions: PopoverTriggerFlags.none, + child: child, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: CreateDatabaseViewFilterList( + onTap: () => popoverController.close(), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart new file mode 100644 index 0000000000000..d7e45840e64f5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/filter_menu_item.dart @@ -0,0 +1,42 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter/material.dart'; + +import 'choicechip/checkbox.dart'; +import 'choicechip/checklist.dart'; +import 'choicechip/date.dart'; +import 'choicechip/number.dart'; +import 'choicechip/select_option/select_option.dart'; +import 'choicechip/text.dart'; +import 'choicechip/url.dart'; + +class FilterMenuItem extends StatelessWidget { + const FilterMenuItem({ + super.key, + required this.fieldType, + required this.filterId, + }); + + final FieldType fieldType; + final String filterId; + + @override + Widget build(BuildContext context) { + return switch (fieldType) { + FieldType.RichText => TextFilterChoicechip(filterId: filterId), + FieldType.Number => NumberFilterChoiceChip(filterId: filterId), + FieldType.URL => URLFilterChoicechip(filterId: filterId), + FieldType.Checkbox => CheckboxFilterChoicechip(filterId: filterId), + FieldType.Checklist => ChecklistFilterChoicechip(filterId: filterId), + FieldType.DateTime || + FieldType.LastEditedTime || + FieldType.CreatedTime => + DateFilterChoicechip(filterId: filterId), + FieldType.SingleSelect || + FieldType.MultiSelect => + SelectOptionFilterChoicechip(filterId: filterId), + // FieldType.Time => + // TimeFilterChoiceChip(filterInfo: filterInfo), + _ => const SizedBox.shrink(), + }; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/footer/grid_footer.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/footer/grid_footer.dart new file mode 100755 index 0000000000000..1b973554cdd76 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/footer/grid_footer.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class GridAddRowButton extends StatelessWidget { + const GridAddRowButton({super.key}); + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).brightness == Brightness.light + ? const Color(0xFF171717).withOpacity(0.4) + : const Color(0xFFFFFFFF).withOpacity(0.4); + return FlowyButton( + radius: BorderRadius.zero, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: AFThemeExtension.of(context).borderColor), + ), + ), + text: FlowyText( + lineHeight: 1.0, + LocaleKeys.grid_row_newRow.tr(), + color: color, + ), + margin: const EdgeInsets.symmetric(horizontal: 12), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onTap: () => context.read().add(const GridEvent.createRow()), + leftIcon: FlowySvg( + FlowySvgs.add_less_padding_s, + color: color, + ), + ); + } +} + +class GridRowBottomBar extends StatelessWidget { + const GridRowBottomBar({super.key}); + + @override + Widget build(BuildContext context) { + final padding = + context.read().horizontalPadding; + return Container( + padding: GridSize.footerContentInsets.copyWith(left: 0) + + EdgeInsets.only(left: padding), + height: GridSize.footerHeight, + child: const GridAddRowButton(), + ); + } +} + +class GridRowLoadMoreButton extends StatelessWidget { + const GridRowLoadMoreButton({super.key}); + + @override + Widget build(BuildContext context) { + final padding = + context.read().horizontalPadding; + final color = Theme.of(context).brightness == Brightness.light + ? const Color(0xFF171717).withOpacity(0.4) + : const Color(0xFFFFFFFF).withOpacity(0.4); + + return Container( + padding: GridSize.footerContentInsets.copyWith(left: 0) + + EdgeInsets.only(left: padding), + height: GridSize.footerHeight, + child: FlowyButton( + radius: BorderRadius.zero, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: AFThemeExtension.of(context).borderColor), + ), + ), + text: FlowyText( + lineHeight: 1.0, + LocaleKeys.grid_row_loadMore.tr(), + color: color, + ), + margin: const EdgeInsets.symmetric(horizontal: 12), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onTap: () => context.read().add( + const GridEvent.loadMoreRows(), + ), + leftIcon: FlowySvg( + FlowySvgs.load_more_s, + color: color, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart new file mode 100755 index 0000000000000..f0597c15e4fbf --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart @@ -0,0 +1,278 @@ +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/application/field/field_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/widgets/field/field_editor.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../layout/sizes.dart'; + +class GridFieldCell extends StatefulWidget { + const GridFieldCell({ + super.key, + required this.viewId, + required this.fieldController, + required this.fieldInfo, + required this.onTap, + required this.onEditorOpened, + required this.onFieldInsertedOnEitherSide, + required this.isEditing, + required this.isNew, + }); + + final String viewId; + final FieldController fieldController; + final FieldInfo fieldInfo; + final VoidCallback onTap; + final VoidCallback onEditorOpened; + final void Function(String fieldId) onFieldInsertedOnEitherSide; + final bool isEditing; + final bool isNew; + + @override + State createState() => _GridFieldCellState(); +} + +class _GridFieldCellState extends State { + final PopoverController popoverController = PopoverController(); + late final FieldCellBloc _bloc; + + @override + void initState() { + super.initState(); + _bloc = FieldCellBloc(viewId: widget.viewId, fieldInfo: widget.fieldInfo); + if (widget.isEditing) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + popoverController.show(); + }); + } + } + + @override + void didUpdateWidget(covariant oldWidget) { + if (widget.fieldInfo != oldWidget.fieldInfo && !_bloc.isClosed) { + _bloc.add(FieldCellEvent.onFieldChanged(widget.fieldInfo)); + } + if (widget.isEditing) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + popoverController.show(); + }); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _bloc, + child: BlocBuilder( + builder: (context, state) { + final button = AppFlowyPopover( + triggerActions: PopoverTriggerFlags.none, + constraints: const BoxConstraints(), + margin: EdgeInsets.zero, + direction: PopoverDirection.bottomWithLeftAligned, + controller: popoverController, + popupBuilder: (BuildContext context) { + widget.onEditorOpened(); + return FieldEditor( + viewId: widget.viewId, + fieldController: widget.fieldController, + fieldInfo: widget.fieldInfo, + isNewField: widget.isNew, + initialPage: widget.isNew + ? FieldEditorPage.details + : FieldEditorPage.general, + onFieldInserted: widget.onFieldInsertedOnEitherSide, + ); + }, + child: SizedBox( + height: GridSize.headerHeight, + child: FieldCellButton( + field: widget.fieldInfo.field, + onTap: widget.onTap, + margin: const EdgeInsetsDirectional.fromSTEB(12, 9, 10, 9), + ), + ), + ); + + const line = Positioned( + top: 0, + bottom: 0, + right: 0, + child: _DragToExpandLine(), + ); + + return _GridHeaderCellContainer( + width: state.width, + child: Stack( + alignment: Alignment.centerRight, + children: [button, line], + ), + ); + }, + ), + ); + } + + @override + void dispose() { + _bloc.close(); + super.dispose(); + } +} + +class _GridHeaderCellContainer extends StatelessWidget { + const _GridHeaderCellContainer({ + required this.child, + required this.width, + }); + + final Widget child; + final double width; + + @override + Widget build(BuildContext context) { + final borderSide = + BorderSide(color: AFThemeExtension.of(context).borderColor); + final decoration = BoxDecoration( + border: Border( + right: borderSide, + bottom: borderSide, + ), + ); + + return Container( + width: width, + decoration: decoration, + child: child, + ); + } +} + +class _DragToExpandLine extends StatelessWidget { + const _DragToExpandLine(); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () {}, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (details) { + context + .read() + .add(const FieldCellEvent.onResizeStart()); + }, + onHorizontalDragUpdate: (value) { + context + .read() + .add(FieldCellEvent.startUpdateWidth(value.localPosition.dx)); + }, + onHorizontalDragEnd: (end) { + context + .read() + .add(const FieldCellEvent.endUpdateWidth()); + }, + child: FlowyHover( + cursor: SystemMouseCursors.resizeLeftRight, + style: HoverStyle( + hoverColor: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.zero, + contentMargin: const EdgeInsets.only(left: 6), + ), + child: const SizedBox(width: 4), + ), + ), + ); + } +} + +class FieldCellButton extends StatelessWidget { + const FieldCellButton({ + super.key, + required this.field, + required this.onTap, + this.maxLines = 1, + this.radius = BorderRadius.zero, + this.margin, + }); + + final FieldPB field; + final VoidCallback onTap; + final int? maxLines; + final BorderRadius? radius; + final EdgeInsetsGeometry? margin; + + @override + Widget build(BuildContext context) { + return FlowyButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onTap: onTap, + leftIcon: FieldIcon( + fieldInfo: FieldInfo.initial(field), + ), + rightIcon: field.fieldType.rightIcon != null + ? FlowySvg( + field.fieldType.rightIcon!, + blendMode: null, + size: const Size.square(18), + ) + : null, + radius: radius, + text: FlowyText( + field.name, + lineHeight: 1.0, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + color: AFThemeExtension.of(context).textColor, + ), + margin: margin ?? GridSize.cellContentInsets, + ); + } +} + +class FieldIcon extends StatelessWidget { + const FieldIcon({ + super.key, + required this.fieldInfo, + this.dimension = 16.0, + }); + + final FieldInfo fieldInfo; + final double dimension; + + @override + Widget build(BuildContext context) { + final svgContent = kIconGroups?.findSvgContent( + fieldInfo.icon, + ); + final color = + Theme.of(context).isLightMode ? const Color(0xFF171717) : Colors.white; + return svgContent == null + ? FlowySvg( + fieldInfo.fieldType.svgData, + color: color.withOpacity(0.6), + size: Size.square(dimension), + ) + : SizedBox.square( + dimension: dimension, + child: Center( + child: FlowySvg.string( + svgContent, + color: color.withOpacity(0.45), + size: Size.square(dimension - 2), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_extension.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_extension.dart new file mode 100644 index 0000000000000..c59327113be80 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_extension.dart @@ -0,0 +1,8 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; + +extension RowDetailAccessoryExtension on FieldType { + bool get showRowDetailAccessory => switch (this) { + FieldType.Media => false, + _ => true, + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/grid_header.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/grid_header.dart new file mode 100644 index 0000000000000..bcb9139406706 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/grid_header.dart @@ -0,0 +1,209 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_header_bloc.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:reorderables/reorderables.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../layout/sizes.dart'; +import 'desktop_field_cell.dart'; + +class GridHeaderSliverAdaptor extends StatefulWidget { + const GridHeaderSliverAdaptor({ + super.key, + required this.viewId, + required this.anchorScrollController, + }); + + final String viewId; + final ScrollController anchorScrollController; + + @override + State createState() => + _GridHeaderSliverAdaptorState(); +} + +class _GridHeaderSliverAdaptorState extends State { + @override + Widget build(BuildContext context) { + final fieldController = + context.read().databaseController.fieldController; + return BlocProvider( + create: (context) { + return GridHeaderBloc( + viewId: widget.viewId, + fieldController: fieldController, + )..add(const GridHeaderEvent.initial()); + }, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: widget.anchorScrollController, + child: _GridHeader( + viewId: widget.viewId, + fieldController: fieldController, + ), + ), + ); + } +} + +class _GridHeader extends StatefulWidget { + const _GridHeader({required this.viewId, required this.fieldController}); + + final String viewId; + final FieldController fieldController; + + @override + State<_GridHeader> createState() => _GridHeaderState(); +} + +class _GridHeaderState extends State<_GridHeader> { + final Map> _gridMap = {}; + final _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final cells = state.fields + .map( + (fieldInfo) => GridFieldCell( + key: _getKeyById(fieldInfo.id), + viewId: widget.viewId, + fieldInfo: fieldInfo, + fieldController: widget.fieldController, + onTap: () => context + .read() + .add(GridHeaderEvent.startEditingField(fieldInfo.id)), + onFieldInsertedOnEitherSide: (fieldId) => context + .read() + .add(GridHeaderEvent.startEditingNewField(fieldId)), + onEditorOpened: () => context + .read() + .add(const GridHeaderEvent.endEditingField()), + isEditing: state.editingFieldId == fieldInfo.id, + isNew: state.newFieldId == fieldInfo.id, + ), + ) + .toList(); + + return RepaintBoundary( + child: ReorderableRow( + scrollController: _scrollController, + buildDraggableFeedback: (context, constraints, child) => Material( + color: Colors.transparent, + child: child, + ), + draggingWidgetOpacity: 0, + header: _cellLeading(), + needsLongPressDraggable: UniversalPlatform.isMobile, + footer: _CellTrailing(viewId: widget.viewId), + onReorder: (int oldIndex, int newIndex) { + context + .read() + .add(GridHeaderEvent.moveField(oldIndex, newIndex)); + }, + children: cells, + ), + ); + }, + ); + } + + /// This is a workaround for [ReorderableRow]. + /// [ReorderableRow] warps the child's key with a [GlobalKey]. + /// It will trigger the child's widget's to recreate. + /// The state will lose. + ValueKey? _getKeyById(String id) { + if (_gridMap.containsKey(id)) { + return _gridMap[id]; + } + final newKey = ValueKey(id); + _gridMap[id] = newKey; + return newKey; + } + + Widget _cellLeading() { + return SizedBox( + width: context.read().horizontalPadding, + ); + } +} + +class _CellTrailing extends StatelessWidget { + const _CellTrailing({required this.viewId}); + + final String viewId; + + @override + Widget build(BuildContext context) { + return Container( + width: GridSize.newPropertyButtonWidth, + height: GridSize.headerHeight, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: AFThemeExtension.of(context).borderColor), + ), + ), + child: CreateFieldButton( + viewId: viewId, + onFieldCreated: (fieldId) => context + .read() + .add(GridHeaderEvent.startEditingNewField(fieldId)), + ), + ); + } +} + +class CreateFieldButton extends StatelessWidget { + const CreateFieldButton({ + super.key, + required this.viewId, + required this.onFieldCreated, + }); + + final String viewId; + final void Function(String fieldId) onFieldCreated; + + @override + Widget build(BuildContext context) { + return FlowyButton( + margin: GridSize.cellContentInsets, + radius: BorderRadius.zero, + text: FlowyText( + lineHeight: 1.0, + LocaleKeys.grid_field_newProperty.tr(), + overflow: TextOverflow.ellipsis, + ), + hoverColor: AFThemeExtension.of(context).greyHover, + onTap: () async { + final result = await FieldBackendService.createField( + viewId: viewId, + ); + result.fold( + (field) => onFieldCreated(field.id), + (err) => Log.error("Failed to create field type option: $err"), + ); + }, + leftIcon: const FlowySvg( + FlowySvgs.add_less_padding_s, + size: Size.square(16), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_field_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_field_button.dart new file mode 100755 index 0000000000000..20cc030ff4046 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_field_button.dart @@ -0,0 +1,83 @@ +import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_sheets.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileFieldButton extends StatelessWidget { + const MobileFieldButton.first({ + super.key, + required this.viewId, + required this.fieldController, + required this.fieldInfo, + }) : radius = const BorderRadius.only(topLeft: Radius.circular(24)), + margin = const EdgeInsets.symmetric(vertical: 14, horizontal: 18), + index = null; + + const MobileFieldButton({ + super.key, + required this.viewId, + required this.fieldController, + required this.fieldInfo, + required this.index, + }) : radius = BorderRadius.zero, + margin = const EdgeInsets.symmetric(vertical: 14, horizontal: 12); + + final String viewId; + final int? index; + final FieldController fieldController; + final FieldInfo fieldInfo; + final BorderRadius? radius; + final EdgeInsets? margin; + + @override + Widget build(BuildContext context) { + Widget child = Container( + width: 200, + decoration: _getDecoration(context), + child: FlowyButton( + onTap: () => + showQuickEditField(context, viewId, fieldController, fieldInfo), + radius: radius, + margin: margin, + leftIconSize: const Size.square(18), + leftIcon: FieldIcon( + fieldInfo: fieldInfo, + dimension: 18, + ), + text: FlowyText( + fieldInfo.name, + fontSize: 15, + overflow: TextOverflow.ellipsis, + ), + ), + ); + + if (index != null) { + child = ReorderableDelayedDragStartListener(index: index!, child: child); + } + + return child; + } + + BoxDecoration? _getDecoration(BuildContext context) { + final borderSide = BorderSide( + color: Theme.of(context).dividerColor, + ); + + if (index == null) { + return BoxDecoration( + borderRadius: const BorderRadiusDirectional.only( + topStart: Radius.circular(24), + ), + border: BorderDirectional( + top: borderSide, + start: borderSide, + ), + ); + } else { + return null; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_grid_header.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_grid_header.dart new file mode 100644 index 0000000000000..e20beba726dfa --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/mobile_grid_header.dart @@ -0,0 +1,235 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_sheets.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_header_bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../layout/sizes.dart'; +import '../../mobile_grid_page.dart'; +import 'mobile_field_button.dart'; + +const double _kGridHeaderHeight = 50.0; + +class MobileGridHeader extends StatefulWidget { + const MobileGridHeader({ + super.key, + required this.viewId, + required this.contentScrollController, + required this.reorderableController, + }); + + final String viewId; + final ScrollController contentScrollController; + final ScrollController reorderableController; + + @override + State createState() => _MobileGridHeaderState(); +} + +class _MobileGridHeaderState extends State { + @override + Widget build(BuildContext context) { + final fieldController = + context.read().databaseController.fieldController; + return BlocProvider( + create: (context) { + return GridHeaderBloc( + viewId: widget.viewId, + fieldController: fieldController, + )..add(const GridHeaderEvent.initial()); + }, + child: Stack( + children: [ + BlocBuilder( + builder: (context, state) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: widget.contentScrollController, + child: Stack( + children: [ + Positioned( + top: 0, + left: GridSize.horizontalHeaderPadding + 24, + right: GridSize.horizontalHeaderPadding + 24, + child: _divider(), + ), + Positioned( + bottom: 0, + left: GridSize.horizontalHeaderPadding, + right: GridSize.horizontalHeaderPadding, + child: _divider(), + ), + SizedBox( + height: _kGridHeaderHeight, + width: getMobileGridContentWidth(state.fields), + ), + ], + ), + ); + }, + ), + SizedBox( + height: _kGridHeaderHeight, + child: _GridHeader( + viewId: widget.viewId, + fieldController: fieldController, + scrollController: widget.reorderableController, + ), + ), + ], + ), + ); + } + + Widget _divider() { + return Divider( + height: 1, + thickness: 1, + color: Theme.of(context).dividerColor, + ); + } +} + +class _GridHeader extends StatefulWidget { + const _GridHeader({ + required this.viewId, + required this.fieldController, + required this.scrollController, + }); + + final String viewId; + final FieldController fieldController; + final ScrollController scrollController; + + @override + State<_GridHeader> createState() => _GridHeaderState(); +} + +class _GridHeaderState extends State<_GridHeader> { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final fields = [...state.fields]; + FieldInfo? firstField; + if (fields.isNotEmpty) { + firstField = fields.removeAt(0); + } + + final cells = fields + .mapIndexed( + (index, fieldInfo) => MobileFieldButton( + key: ValueKey(fieldInfo.id), + index: index, + viewId: widget.viewId, + fieldController: widget.fieldController, + fieldInfo: fieldInfo, + ), + ) + .toList(); + + return ReorderableListView.builder( + scrollController: widget.scrollController, + shrinkWrap: true, + scrollDirection: Axis.horizontal, + proxyDecorator: (child, index, anim) => Material( + color: Colors.transparent, + child: child, + ), + padding: EdgeInsets.symmetric( + horizontal: GridSize.horizontalHeaderPadding, + ), + header: firstField != null + ? MobileFieldButton.first( + viewId: widget.viewId, + fieldController: widget.fieldController, + fieldInfo: firstField, + ) + : null, + footer: CreateFieldButton( + viewId: widget.viewId, + onFieldCreated: (fieldId) => context + .read() + .add(GridHeaderEvent.startEditingNewField(fieldId)), + ), + onReorder: (int oldIndex, int newIndex) { + if (oldIndex < newIndex) { + newIndex--; + } + oldIndex++; + newIndex++; + context + .read() + .add(GridHeaderEvent.moveField(oldIndex, newIndex)); + }, + itemCount: cells.length, + itemBuilder: (context, index) => cells[index], + ); + }, + ); + } +} + +class CreateFieldButton extends StatelessWidget { + const CreateFieldButton({ + super.key, + required this.viewId, + required this.onFieldCreated, + }); + + final String viewId; + final void Function(String fieldId) onFieldCreated; + + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints( + maxWidth: GridSize.mobileNewPropertyButtonWidth, + minHeight: GridSize.headerHeight, + ), + decoration: _getDecoration(context), + child: FlowyButton( + margin: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + radius: const BorderRadius.only(topRight: Radius.circular(24)), + text: FlowyText( + LocaleKeys.grid_field_newProperty.tr(), + fontSize: 15, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, + ), + hoverColor: AFThemeExtension.of(context).greyHover, + onTap: () => mobileCreateFieldWorkflow(context, viewId), + leftIconSize: const Size.square(18), + leftIcon: FlowySvg( + FlowySvgs.add_s, + size: const Size.square(18), + color: Theme.of(context).hintColor, + ), + ), + ); + } + + BoxDecoration? _getDecoration(BuildContext context) { + final borderSide = BorderSide( + color: Theme.of(context).dividerColor, + ); + + return BoxDecoration( + borderRadius: const BorderRadiusDirectional.only( + topEnd: Radius.circular(24), + ), + border: BorderDirectional( + top: borderSide, + end: borderSide, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/mobile_fab.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/mobile_fab.dart new file mode 100644 index 0000000000000..7c18bb927f8ad --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/mobile_fab.dart @@ -0,0 +1,114 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +Widget getGridFabs(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + MobileGridFab( + backgroundColor: Theme.of(context).colorScheme.surface, + foregroundColor: Theme.of(context).primaryColor, + onTap: () { + final bloc = context.read(); + if (bloc.state.rowInfos.isNotEmpty) { + context.push( + MobileRowDetailPage.routeName, + extra: { + MobileRowDetailPage.argRowId: bloc.state.rowInfos.first.rowId, + MobileRowDetailPage.argDatabaseController: + bloc.databaseController, + }, + ); + } + }, + boxShadow: const BoxShadow( + offset: Offset(0, 8), + color: Color(0x145D7D8B), + blurRadius: 20, + ), + icon: FlowySvgs.properties_s, + iconSize: const Size.square(24), + ), + const HSpace(16), + MobileGridFab( + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + onTap: () { + context + .read() + .add(const GridEvent.createRow(openRowDetail: true)); + }, + overlayColor: const WidgetStatePropertyAll(Color(0xFF009FD1)), + boxShadow: const BoxShadow( + offset: Offset(0, 8), + color: Color(0x6612BFEF), + blurRadius: 18, + spreadRadius: -5, + ), + icon: FlowySvgs.add_s, + iconSize: const Size.square(24), + ), + ], + ); +} + +class MobileGridFab extends StatelessWidget { + const MobileGridFab({ + super.key, + required this.backgroundColor, + required this.foregroundColor, + required this.boxShadow, + required this.onTap, + required this.icon, + required this.iconSize, + this.overlayColor, + }); + + final Color backgroundColor; + final Color foregroundColor; + final BoxShadow boxShadow; + final VoidCallback onTap; + final FlowySvgData icon; + final Size iconSize; + final WidgetStateProperty? overlayColor; + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(20); + return DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + border: const Border.fromBorderSide( + BorderSide(width: 0.5, color: Color(0xFFE4EDF0)), + ), + borderRadius: radius, + boxShadow: [boxShadow], + ), + child: Material( + borderOnForeground: false, + color: Colors.transparent, + borderRadius: radius, + child: InkWell( + borderRadius: radius, + overlayColor: overlayColor, + onTap: onTap, + child: SizedBox.square( + dimension: 56, + child: Center( + child: FlowySvg( + icon, + color: foregroundColor, + size: iconSize, + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/action.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/action.dart new file mode 100644 index 0000000000000..d212c507464fc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/action.dart @@ -0,0 +1,144 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/domain/sort_service.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class RowActionMenu extends StatelessWidget { + const RowActionMenu({ + super.key, + required this.viewId, + required this.rowId, + this.actions = RowAction.values, + this.groupId, + }); + + const RowActionMenu.board({ + super.key, + required this.viewId, + required this.rowId, + required this.groupId, + }) : actions = const [RowAction.duplicate, RowAction.delete]; + + final String viewId; + final RowId rowId; + final List actions; + final String? groupId; + + @override + Widget build(BuildContext context) { + final cells = + actions.map((action) => _actionCell(context, action)).toList(); + + return SeparatedColumn( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => VSpace(GridSize.typeOptionSeparatorHeight), + children: cells, + ); + } + + Widget _actionCell(BuildContext context, RowAction action) { + Widget icon = FlowySvg(action.icon); + if (action == RowAction.insertAbove) { + icon = RotatedBox(quarterTurns: 1, child: icon); + } + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText( + action.text, + overflow: TextOverflow.ellipsis, + lineHeight: 1.0, + ), + onTap: () { + action.performAction(context, viewId, rowId); + PopoverContainer.of(context).close(); + }, + leftIcon: icon, + ), + ); + } +} + +enum RowAction { + insertAbove, + insertBelow, + duplicate, + delete; + + FlowySvgData get icon { + return switch (this) { + insertAbove => FlowySvgs.arrow_s, + insertBelow => FlowySvgs.add_s, + duplicate => FlowySvgs.duplicate_s, + delete => FlowySvgs.delete_s, + }; + } + + String get text { + return switch (this) { + insertAbove => LocaleKeys.grid_row_insertRecordAbove.tr(), + insertBelow => LocaleKeys.grid_row_insertRecordBelow.tr(), + duplicate => LocaleKeys.grid_row_duplicate.tr(), + delete => LocaleKeys.grid_row_delete.tr(), + }; + } + + void performAction(BuildContext context, String viewId, String rowId) { + switch (this) { + case insertAbove: + case insertBelow: + final position = this == insertAbove + ? OrderObjectPositionTypePB.Before + : OrderObjectPositionTypePB.After; + final intention = this == insertAbove + ? LocaleKeys.grid_row_createRowAboveDescription.tr() + : LocaleKeys.grid_row_createRowBelowDescription.tr(); + if (context.read().state.sorts.isNotEmpty) { + showCancelAndDeleteDialog( + context: context, + title: LocaleKeys.grid_sort_sortsActive.tr( + namedArgs: {'intention': intention}, + ), + description: LocaleKeys.grid_sort_removeSorting.tr(), + confirmLabel: LocaleKeys.button_remove.tr(), + closeOnAction: true, + onDelete: () { + SortBackendService(viewId: viewId).deleteAllSorts(); + RowBackendService.createRow( + viewId: viewId, + position: position, + targetRowId: rowId, + ); + }, + ); + } else { + RowBackendService.createRow( + viewId: viewId, + position: position, + targetRowId: rowId, + ); + } + break; + case duplicate: + RowBackendService.duplicateRow(viewId, rowId); + break; + case delete: + showConfirmDeletionDialog( + context: context, + name: LocaleKeys.grid_row_label.tr(), + description: LocaleKeys.grid_row_deleteRowPrompt.tr(), + onConfirm: () => RowBackendService.deleteRows(viewId, [rowId]), + ); + break; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart new file mode 100755 index 0000000000000..209c439ad164d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/grid/application/row/row_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/mobile_cell_container.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../layout/sizes.dart'; + +class MobileGridRow extends StatefulWidget { + const MobileGridRow({ + super.key, + required this.rowId, + required this.databaseController, + required this.openDetailPage, + this.isDraggable = false, + }); + + final RowId rowId; + final DatabaseController databaseController; + final void Function(BuildContext context) openDetailPage; + final bool isDraggable; + + @override + State createState() => _MobileGridRowState(); +} + +class _MobileGridRowState extends State { + late final RowController _rowController; + late final EditableCellBuilder _cellBuilder; + + String get viewId => widget.databaseController.viewId; + RowCache get rowCache => widget.databaseController.rowCache; + + @override + void initState() { + super.initState(); + _rowController = RowController( + rowMeta: rowCache.getRow(widget.rowId)!.rowMeta, + viewId: viewId, + rowCache: rowCache, + ); + _cellBuilder = EditableCellBuilder( + databaseController: widget.databaseController, + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => RowBloc( + fieldController: widget.databaseController.fieldController, + rowId: widget.rowId, + rowController: _rowController, + viewId: viewId, + ), + child: BlocBuilder( + builder: (context, state) { + return Row( + children: [ + SizedBox(width: GridSize.horizontalHeaderPadding), + Expanded( + child: RowContent( + fieldController: widget.databaseController.fieldController, + builder: _cellBuilder, + onExpand: () => widget.openDetailPage(context), + ), + ), + ], + ); + }, + ), + ); + } + + @override + Future dispose() async { + super.dispose(); + await _rowController.dispose(); + } +} + +class RowContent extends StatelessWidget { + const RowContent({ + super.key, + required this.fieldController, + required this.onExpand, + required this.builder, + }); + + final FieldController fieldController; + final VoidCallback onExpand; + final EditableCellBuilder builder; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SizedBox( + height: 52, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ..._makeCells(context, state.cellContexts), + _finalCellDecoration(context), + ], + ), + ); + }, + ); + } + + List _makeCells( + BuildContext context, + List cellContexts, + ) { + return cellContexts.map( + (cellContext) { + final fieldInfo = fieldController.getField(cellContext.fieldId)!; + final EditableCellWidget child = builder.buildStyled( + cellContext, + EditableCellStyle.mobileGrid, + ); + return MobileCellContainer( + isPrimary: fieldInfo.field.isPrimary, + onPrimaryFieldCellTap: onExpand, + child: child, + ); + }, + ).toList(); + } + + Widget _finalCellDecoration(BuildContext context) { + return Container( + width: 200, + constraints: const BoxConstraints(minHeight: 46), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + right: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart new file mode 100755 index 0000000000000..4de6f20bc2995 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart @@ -0,0 +1,365 @@ +import 'package:appflowy/plugins/database/domain/sort_service.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import "package:appflowy/generated/locale_keys.g.dart"; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/grid/application/row/row_bloc.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +import '../../../../widgets/row/accessory/cell_accessory.dart'; +import '../../../../widgets/row/cells/cell_container.dart'; +import '../../layout/sizes.dart'; + +import 'action.dart'; + +class GridRow extends StatelessWidget { + const GridRow({ + super.key, + required this.fieldController, + required this.viewId, + required this.rowId, + required this.rowController, + required this.cellBuilder, + required this.openDetailPage, + required this.index, + this.shrinkWrap = false, + }); + + final FieldController fieldController; + final String viewId; + final RowId rowId; + final RowController rowController; + final EditableCellBuilder cellBuilder; + final void Function(BuildContext context) openDetailPage; + final int index; + final bool shrinkWrap; + + @override + Widget build(BuildContext context) { + Widget rowContent = RowContent( + fieldController: fieldController, + cellBuilder: cellBuilder, + onExpand: () => openDetailPage(context), + ); + + if (!shrinkWrap) { + rowContent = Expanded(child: rowContent); + } + + return BlocProvider( + create: (_) => RowBloc( + fieldController: fieldController, + rowId: rowId, + rowController: rowController, + viewId: viewId, + ), + child: _RowEnterRegion( + child: Row( + children: [ + _RowLeading(viewId: viewId, index: index), + rowContent, + ], + ), + ), + ); + } +} + +class _RowLeading extends StatefulWidget { + const _RowLeading({ + required this.viewId, + required this.index, + }); + + final String viewId; + final int index; + + @override + State<_RowLeading> createState() => _RowLeadingState(); +} + +class _RowLeadingState extends State<_RowLeading> { + final PopoverController popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + triggerActions: PopoverTriggerFlags.none, + constraints: BoxConstraints.loose(const Size(200, 200)), + direction: PopoverDirection.rightWithCenterAligned, + margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), + popupBuilder: (_) { + final bloc = context.read(); + return BlocProvider.value( + value: context.read(), + child: RowActionMenu( + viewId: bloc.viewId, + rowId: bloc.rowId, + ), + ); + }, + child: Consumer( + builder: (context, state, _) { + return SizedBox( + width: context + .read() + .horizontalPadding, + child: state.onEnter ? _activeWidget() : null, + ); + }, + ), + ); + } + + Widget _activeWidget() { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + InsertRowButton(viewId: widget.viewId), + ReorderableDragStartListener( + index: widget.index, + child: RowMenuButton( + openMenu: popoverController.show, + ), + ), + ], + ); + } +} + +class InsertRowButton extends StatelessWidget { + const InsertRowButton({ + super.key, + required this.viewId, + }); + + final String viewId; + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + tooltipText: LocaleKeys.tooltip_addNewRow.tr(), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + width: 20, + height: 30, + onPressed: () { + final rowBloc = context.read(); + if (context.read().state.sorts.isNotEmpty) { + showCancelAndDeleteDialog( + context: context, + title: LocaleKeys.grid_sort_sortsActive.tr( + namedArgs: { + 'intention': LocaleKeys.grid_row_createRowBelowDescription.tr(), + }, + ), + description: LocaleKeys.grid_sort_removeSorting.tr(), + confirmLabel: LocaleKeys.button_remove.tr(), + closeOnAction: true, + onDelete: () { + SortBackendService(viewId: viewId).deleteAllSorts(); + rowBloc.add(const RowEvent.createRow()); + }, + ); + } else { + rowBloc.add(const RowEvent.createRow()); + } + }, + iconPadding: const EdgeInsets.all(3), + icon: FlowySvg( + FlowySvgs.add_s, + color: Theme.of(context).colorScheme.tertiary, + ), + ); + } +} + +class RowMenuButton extends StatefulWidget { + const RowMenuButton({ + super.key, + required this.openMenu, + }); + + final VoidCallback openMenu; + + @override + State createState() => _RowMenuButtonState(); +} + +class _RowMenuButtonState extends State { + @override + Widget build(BuildContext context) { + return FlowyIconButton( + richTooltipText: TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.tooltip_dragRow.tr()}\n', + style: context.tooltipTextStyle(), + ), + TextSpan( + text: LocaleKeys.tooltip_openMenu.tr(), + style: context.tooltipTextStyle(), + ), + ], + ), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + width: 20, + height: 30, + onPressed: () => widget.openMenu(), + iconPadding: const EdgeInsets.all(3), + icon: FlowySvg( + FlowySvgs.drag_element_s, + color: Theme.of(context).colorScheme.tertiary, + ), + ); + } +} + +class RowContent extends StatelessWidget { + const RowContent({ + super.key, + required this.fieldController, + required this.cellBuilder, + required this.onExpand, + }); + + final FieldController fieldController; + final VoidCallback onExpand; + final EditableCellBuilder cellBuilder; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ..._makeCells(context, state.cellContexts), + _finalCellDecoration(context), + ], + ), + ); + }, + ); + } + + List _makeCells( + BuildContext context, + List cellContexts, + ) { + return cellContexts.map( + (cellContext) { + final fieldInfo = fieldController.getField(cellContext.fieldId)!; + final EditableCellWidget child = cellBuilder.buildStyled( + cellContext, + EditableCellStyle.desktopGrid, + ); + return CellContainer( + width: fieldInfo.width!.toDouble(), + isPrimary: fieldInfo.field.isPrimary, + accessoryBuilder: (buildContext) { + final builder = child.accessoryBuilder; + final List accessories = []; + if (fieldInfo.field.isPrimary) { + accessories.add( + GridCellAccessoryBuilder( + builder: (key) => PrimaryCellAccessory( + key: key, + onTap: onExpand, + isCellEditing: buildContext.isCellEditing, + ), + ), + ); + } + + if (builder != null) { + accessories.addAll(builder(buildContext)); + } + + return accessories; + }, + child: child, + ); + }, + ).toList(); + } + + Widget _finalCellDecoration(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.basic, + child: Container( + width: GridSize.newPropertyButtonWidth, + constraints: const BoxConstraints(minHeight: 36), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: AFThemeExtension.of(context).borderColor), + ), + ), + ), + ); + } +} + +class RegionStateNotifier extends ChangeNotifier { + bool _onEnter = false; + + set onEnter(bool value) { + if (_onEnter != value) { + _onEnter = value; + notifyListeners(); + } + } + + bool get onEnter => _onEnter; +} + +class _RowEnterRegion extends StatefulWidget { + const _RowEnterRegion({required this.child}); + + final Widget child; + + @override + State<_RowEnterRegion> createState() => _RowEnterRegionState(); +} + +class _RowEnterRegionState extends State<_RowEnterRegion> { + late final RegionStateNotifier _rowStateNotifier; + + @override + void initState() { + super.initState(); + _rowStateNotifier = RegionStateNotifier(); + } + + @override + Future dispose() async { + _rowStateNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: _rowStateNotifier, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (p) => _rowStateNotifier.onEnter = true, + onExit: (p) => _rowStateNotifier.onEnter = false, + child: widget.child, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/shortcuts.dart new file mode 100644 index 0000000000000..7d743702fc8d3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/shortcuts.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class GridShortcuts extends StatelessWidget { + const GridShortcuts({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Shortcuts( + shortcuts: bindKeys([]), + child: Actions( + dispatcher: LoggingActionDispatcher(), + actions: const {}, + child: child, + ), + ); + } +} + +Map bindKeys(List keys) { + return {for (final key in keys) LogicalKeySet(key): KeyboardKeyIdent(key)}; +} + +class KeyboardKeyIdent extends Intent { + const KeyboardKeyIdent(this.key); + + final KeyboardKey key; +} + +class LoggingActionDispatcher extends ActionDispatcher { + @override + Object? invokeAction( + covariant Action action, + covariant Intent intent, [ + BuildContext? context, + ]) { + // print('Action invoked: $action($intent) from $context'); + super.invokeAction(action, intent, context); + + return null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/create_sort_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/create_sort_list.dart new file mode 100644 index 0000000000000..5218a60ee5625 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/create_sort_list.dart @@ -0,0 +1,147 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/grid/application/simple_text_filter_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class CreateDatabaseViewSortList extends StatelessWidget { + const CreateDatabaseViewSortList({ + super.key, + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final sortBloc = context.read(); + return BlocProvider( + create: (_) => SimpleTextFilterBloc( + values: List.from(sortBloc.state.creatableFields), + comparator: (val) => val.name, + ), + child: BlocListener( + listenWhen: (previous, current) => + previous.creatableFields != current.creatableFields, + listener: (context, state) { + context.read>().add( + SimpleTextFilterEvent.receiveNewValues(state.creatableFields), + ); + }, + child: BlocBuilder, + SimpleTextFilterState>( + builder: (context, state) { + final cells = state.values.map((fieldInfo) { + return GridSortPropertyCell( + fieldInfo: fieldInfo, + onTap: () { + context + .read() + .add(SortEditorEvent.createSort(fieldId: fieldInfo.id)); + onTap.call(); + }, + ); + }).toList(); + + final List slivers = [ + SliverPersistentHeader( + pinned: true, + delegate: _SortTextFieldDelegate(), + ), + SliverToBoxAdapter( + child: ListView.separated( + shrinkWrap: true, + itemCount: cells.length, + itemBuilder: (_, index) => cells[index], + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), + ), + ), + ]; + return CustomScrollView( + shrinkWrap: true, + slivers: slivers, + physics: StyledScrollPhysics(), + ); + }, + ), + ), + ); + } +} + +class _SortTextFieldDelegate extends SliverPersistentHeaderDelegate { + _SortTextFieldDelegate(); + + double fixHeight = 36; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + return Container( + padding: const EdgeInsets.only(bottom: 4), + color: Theme.of(context).cardColor, + height: fixHeight, + child: FlowyTextField( + hintText: LocaleKeys.grid_settings_sortBy.tr(), + onChanged: (text) { + context + .read>() + .add(SimpleTextFilterEvent.updateFilter(text)); + }, + ), + ); + } + + @override + double get maxExtent => fixHeight; + + @override + double get minExtent => fixHeight; + + @override + bool shouldRebuild(covariant oldDelegate) => false; +} + +class GridSortPropertyCell extends StatelessWidget { + const GridSortPropertyCell({ + super.key, + required this.fieldInfo, + required this.onTap, + }); + + final FieldInfo fieldInfo; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + text: FlowyText( + fieldInfo.name, + lineHeight: 1.0, + color: AFThemeExtension.of(context).textColor, + ), + onTap: onTap, + leftIcon: FieldIcon( + fieldInfo: fieldInfo, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/order_panel.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/order_panel.dart new file mode 100644 index 0000000000000..9bf8f36c85615 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/order_panel.dart @@ -0,0 +1,55 @@ +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_editor.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pbenum.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class OrderPanel extends StatelessWidget { + const OrderPanel({required this.onCondition, super.key}); + + final Function(SortConditionPB) onCondition; + + @override + Widget build(BuildContext context) { + final List children = SortConditionPB.values.map((condition) { + return OrderPanelItem( + condition: condition, + onCondition: onCondition, + ); + }).toList(); + + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: 160), + child: IntrinsicWidth( + child: IntrinsicHeight( + child: Column( + children: children, + ), + ), + ), + ); + } +} + +class OrderPanelItem extends StatelessWidget { + const OrderPanelItem({ + super.key, + required this.condition, + required this.onCondition, + }); + + final SortConditionPB condition; + final Function(SortConditionPB) onCondition; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText(condition.title), + onTap: () => onCondition(condition), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_choice_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_choice_button.dart new file mode 100644 index 0000000000000..4d509b386284d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_choice_button.dart @@ -0,0 +1,51 @@ +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class SortChoiceButton extends StatelessWidget { + const SortChoiceButton({ + super.key, + required this.text, + this.onTap, + this.radius = const Radius.circular(14), + this.leftIcon, + this.rightIcon, + this.editable = true, + }); + + final String text; + final VoidCallback? onTap; + final Radius radius; + final Widget? leftIcon; + final Widget? rightIcon; + final bool editable; + + @override + Widget build(BuildContext context) { + return FlowyButton( + decoration: BoxDecoration( + color: Colors.transparent, + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).dividerColor), + ), + borderRadius: BorderRadius.all(radius), + ), + useIntrinsicWidth: true, + text: FlowyText( + text, + lineHeight: 1.0, + color: AFThemeExtension.of(context).textColor, + overflow: TextOverflow.ellipsis, + ), + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + radius: BorderRadius.all(radius), + leftIcon: leftIcon, + rightIcon: rightIcon, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onTap: onTap, + disable: !editable, + disableOpacity: 1.0, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_editor.dart new file mode 100644 index 0000000000000..2f7f68e2f6b7b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_editor.dart @@ -0,0 +1,314 @@ +import 'dart:io'; + +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/sort_entities.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/sort_entities.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'create_sort_list.dart'; +import 'order_panel.dart'; +import 'sort_choice_button.dart'; + +class SortEditor extends StatefulWidget { + const SortEditor({super.key}); + + @override + State createState() => _SortEditorState(); +} + +class _SortEditorState extends State { + final popoverMutex = PopoverMutex(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return ReorderableListView.builder( + onReorder: (oldIndex, newIndex) => context + .read() + .add(SortEditorEvent.reorderSort(oldIndex, newIndex)), + itemCount: state.sorts.length, + itemBuilder: (context, index) => DatabaseSortItem( + key: ValueKey(state.sorts[index].sortId), + index: index, + sort: state.sorts[index], + popoverMutex: popoverMutex, + ), + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: Stack( + children: [ + BlocProvider.value( + value: context.read(), + child: child, + ), + MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: const SizedBox.expand(), + ), + ], + ), + ), + shrinkWrap: true, + buildDefaultDragHandles: false, + footer: Row( + children: [ + Flexible( + child: DatabaseAddSortButton( + disable: state.creatableFields.isEmpty, + popoverMutex: popoverMutex, + ), + ), + const HSpace(6), + Flexible( + child: DeleteAllSortsButton( + popoverMutex: popoverMutex, + ), + ), + ], + ), + ); + }, + ); + } +} + +class DatabaseSortItem extends StatelessWidget { + const DatabaseSortItem({ + super.key, + required this.index, + required this.popoverMutex, + required this.sort, + }); + + final int index; + final PopoverMutex popoverMutex; + final DatabaseSort sort; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 6), + color: Theme.of(context).cardColor, + child: Row( + children: [ + ReorderableDragStartListener( + index: index, + child: MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grab, + child: SizedBox( + width: 14 + 12, + height: 14, + child: FlowySvg( + FlowySvgs.drag_element_s, + size: const Size.square(14), + color: Theme.of(context).iconTheme.color, + ), + ), + ), + ), + Flexible( + fit: FlexFit.tight, + child: SizedBox( + height: 26, + child: BlocSelector( + selector: (state) => state.allFields.firstWhereOrNull( + (field) => field.id == sort.fieldId, + ), + builder: (context, field) { + return SortChoiceButton( + text: field?.name ?? "", + editable: false, + ); + }, + ), + ), + ), + const HSpace(6), + Flexible( + fit: FlexFit.tight, + child: SizedBox( + height: 26, + child: SortConditionButton( + sort: sort, + popoverMutex: popoverMutex, + ), + ), + ), + const HSpace(6), + FlowyIconButton( + width: 26, + onPressed: () { + context + .read() + .add(SortEditorEvent.deleteSort(sort.sortId)); + PopoverContainer.of(context).close(); + }, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + icon: FlowySvg( + FlowySvgs.trash_m, + color: Theme.of(context).iconTheme.color, + size: const Size.square(16), + ), + ), + ], + ), + ); + } +} + +extension SortConditionExtension on SortConditionPB { + String get title { + return switch (this) { + SortConditionPB.Ascending => LocaleKeys.grid_sort_ascending.tr(), + SortConditionPB.Descending => LocaleKeys.grid_sort_descending.tr(), + _ => throw UnimplementedError(), + }; + } +} + +class DatabaseAddSortButton extends StatefulWidget { + const DatabaseAddSortButton({ + super.key, + required this.disable, + required this.popoverMutex, + }); + + final bool disable; + final PopoverMutex popoverMutex; + + @override + State createState() => _DatabaseAddSortButtonState(); +} + +class _DatabaseAddSortButtonState extends State { + final _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: _popoverController, + mutex: widget.popoverMutex, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: BoxConstraints.loose(const Size(200, 300)), + offset: const Offset(-6, 8), + triggerActions: PopoverTriggerFlags.none, + asBarrier: true, + popupBuilder: (popoverContext) { + return BlocProvider.value( + value: context.read(), + child: CreateDatabaseViewSortList( + onTap: () => _popoverController.close(), + ), + ); + }, + child: SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + hoverColor: AFThemeExtension.of(context).greyHover, + disable: widget.disable, + text: FlowyText(LocaleKeys.grid_sort_addSort.tr()), + onTap: () => _popoverController.show(), + leftIcon: const FlowySvg(FlowySvgs.add_s), + ), + ), + ); + } +} + +class DeleteAllSortsButton extends StatelessWidget { + const DeleteAllSortsButton({super.key, required this.popoverMutex}); + + final PopoverMutex popoverMutex; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText(LocaleKeys.grid_sort_deleteAllSorts.tr()), + onTap: () { + context + .read() + .add(const SortEditorEvent.deleteAllSorts()); + PopoverContainer.of(context).close(); + }, + leftIcon: const FlowySvg(FlowySvgs.delete_s), + ), + ); + }, + ); + } +} + +class SortConditionButton extends StatefulWidget { + const SortConditionButton({ + super.key, + required this.popoverMutex, + required this.sort, + }); + + final PopoverMutex popoverMutex; + final DatabaseSort sort; + + @override + State createState() => _SortConditionButtonState(); +} + +class _SortConditionButtonState extends State { + final PopoverController popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + mutex: widget.popoverMutex, + constraints: BoxConstraints.loose(const Size(340, 200)), + direction: PopoverDirection.bottomWithLeftAligned, + triggerActions: PopoverTriggerFlags.none, + popupBuilder: (BuildContext popoverContext) { + return OrderPanel( + onCondition: (condition) { + context.read().add( + SortEditorEvent.editSort( + sortId: widget.sort.sortId, + condition: condition, + ), + ); + popoverController.close(); + }, + ); + }, + child: SortChoiceButton( + text: widget.sort.condition.title, + rightIcon: FlowySvg( + FlowySvgs.arrow_down_s, + color: Theme.of(context).iconTheme.color, + ), + onTap: () => popoverController.show(), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_menu.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_menu.dart new file mode 100644 index 0000000000000..fc35e76241bc3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/sort/sort_menu.dart @@ -0,0 +1,93 @@ +import 'dart:math' as math; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/sort_entities.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'sort_choice_button.dart'; +import 'sort_editor.dart'; + +class SortMenu extends StatelessWidget { + const SortMenu({ + super.key, + required this.fieldController, + }); + + final FieldController fieldController; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SortEditorBloc( + viewId: fieldController.viewId, + fieldController: fieldController, + ), + child: BlocBuilder( + builder: (context, state) { + if (state.sorts.isEmpty) { + return const SizedBox.shrink(); + } + + return AppFlowyPopover( + controller: PopoverController(), + constraints: BoxConstraints.loose(const Size(320, 200)), + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 5), + margin: const EdgeInsets.fromLTRB(6.0, 0.0, 6.0, 6.0), + popupBuilder: (BuildContext popoverContext) { + return BlocProvider.value( + value: context.read(), + child: const SortEditor(), + ); + }, + child: SortChoiceChip(sorts: state.sorts), + ); + }, + ), + ); + } +} + +class SortChoiceChip extends StatelessWidget { + const SortChoiceChip({ + super.key, + required this.sorts, + this.onTap, + }); + + final List sorts; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final arrow = Transform.rotate( + angle: -math.pi / 2, + child: FlowySvg( + FlowySvgs.arrow_left_s, + color: Theme.of(context).iconTheme.color, + ), + ); + + final text = LocaleKeys.grid_settings_sort.tr(); + final leftIcon = FlowySvg( + FlowySvgs.sort_ascending_s, + color: Theme.of(context).iconTheme.color, + ); + + return SizedBox( + height: 28, + child: SortChoiceButton( + text: text, + leftIcon: leftIcon, + rightIcon: arrow, + onTap: onTap, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart new file mode 100644 index 0000000000000..ece2658cf237f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart @@ -0,0 +1,83 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../layout/sizes.dart'; +import '../filter/create_filter_list.dart'; + +class FilterButton extends StatefulWidget { + const FilterButton({ + super.key, + required this.toggleExtension, + }); + + final ToggleExtensionNotifier toggleExtension; + + @override + State createState() => _FilterButtonState(); +} + +class _FilterButtonState extends State { + final _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final textColor = state.filters.isEmpty + ? Theme.of(context).hintColor + : Theme.of(context).colorScheme.primary; + + return _wrapPopover( + FlowyTextButton( + LocaleKeys.grid_settings_filter.tr(), + fontColor: textColor, + fontSize: FontSizes.s12, + fillColor: Colors.transparent, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + padding: GridSize.toolbarSettingButtonInsets, + radius: Corners.s4Border, + onPressed: () { + final bloc = context.read(); + if (bloc.state.filters.isEmpty) { + _popoverController.show(); + } else { + widget.toggleExtension.toggle(); + } + }, + ), + ); + }, + ); + } + + Widget _wrapPopover(Widget child) { + return AppFlowyPopover( + controller: _popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: BoxConstraints.loose(const Size(200, 300)), + offset: const Offset(0, 8), + triggerActions: PopoverTriggerFlags.none, + child: child, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: CreateDatabaseViewFilterList( + onTap: () { + if (!widget.toggleExtension.isToggled) { + widget.toggleExtension.toggle(); + } + _popoverController.close(); + }, + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart new file mode 100644 index 0000000000000..047781c6ccaf2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart @@ -0,0 +1,65 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'filter_button.dart'; +import 'sort_button.dart'; + +class GridSettingBar extends StatelessWidget { + const GridSettingBar({ + super.key, + required this.controller, + required this.toggleExtension, + }); + + final DatabaseController controller; + final ToggleExtensionNotifier toggleExtension; + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => FilterEditorBloc( + viewId: controller.viewId, + fieldController: controller.fieldController, + ), + ), + BlocProvider( + create: (context) => SortEditorBloc( + viewId: controller.viewId, + fieldController: controller.fieldController, + ), + ), + ], + child: ValueListenableBuilder( + valueListenable: controller.isLoading, + builder: (context, isLoading, child) { + if (isLoading) { + return const SizedBox.shrink(); + } + return SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilterButton(toggleExtension: toggleExtension), + const HSpace(6), + SortButton(toggleExtension: toggleExtension), + const HSpace(6), + SettingButton( + databaseController: controller, + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart new file mode 100644 index 0000000000000..e16c851f3a4e8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart @@ -0,0 +1,79 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; + +import '../sort/create_sort_list.dart'; + +class SortButton extends StatefulWidget { + const SortButton({super.key, required this.toggleExtension}); + + final ToggleExtensionNotifier toggleExtension; + + @override + State createState() => _SortButtonState(); +} + +class _SortButtonState extends State { + final _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final textColor = state.sorts.isEmpty + ? Theme.of(context).hintColor + : Theme.of(context).colorScheme.primary; + + return wrapPopover( + FlowyTextButton( + LocaleKeys.grid_settings_sort.tr(), + fontColor: textColor, + fontSize: FontSizes.s12, + fillColor: Colors.transparent, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + padding: GridSize.toolbarSettingButtonInsets, + radius: Corners.s4Border, + onPressed: () { + if (state.sorts.isEmpty) { + _popoverController.show(); + } else { + widget.toggleExtension.toggle(); + } + }, + ), + ); + }, + ); + } + + Widget wrapPopover(Widget child) { + return AppFlowyPopover( + controller: _popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: BoxConstraints.loose(const Size(200, 300)), + offset: const Offset(0, 8), + triggerActions: PopoverTriggerFlags.none, + popupBuilder: (popoverContext) { + return BlocProvider.value( + value: context.read(), + child: CreateDatabaseViewSortList( + onTap: () { + if (!widget.toggleExtension.isToggled) { + widget.toggleExtension.toggle(); + } + _popoverController.close(); + }, + ), + ); + }, + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/setting_menu.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/setting_menu.dart new file mode 100644 index 0000000000000..69e5d27d37998 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/setting_menu.dart @@ -0,0 +1,81 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_accessory_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_menu.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/sort/sort_menu.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +class DatabaseViewSettingExtension extends StatelessWidget { + const DatabaseViewSettingExtension({ + super.key, + required this.viewId, + required this.databaseController, + required this.toggleExtension, + }); + + final String viewId; + final DatabaseController databaseController; + final ToggleExtensionNotifier toggleExtension; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: toggleExtension, + child: Consumer( + builder: (context, value, child) { + if (value.isToggled) { + return BlocProvider( + create: (context) => + DatabaseViewSettingExtensionBloc(viewId: viewId), + child: _DatabaseViewSettingContent( + fieldController: databaseController.fieldController, + ), + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + ); + } +} + +class _DatabaseViewSettingContent extends StatelessWidget { + const _DatabaseViewSettingContent({required this.fieldController}); + + final FieldController fieldController; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + SortMenu(fieldController: fieldController), + const HSpace(6), + Expanded( + child: FilterMenu(fieldController: fieldController), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_add_button.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_add_button.dart new file mode 100644 index 0000000000000..71b9fddda5d80 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_add_button.dart @@ -0,0 +1,117 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/extension.dart'; +import 'package:flutter/material.dart'; + +class AddDatabaseViewButton extends StatefulWidget { + const AddDatabaseViewButton({super.key, required this.onTap}); + + final Function(DatabaseLayoutPB) onTap; + + @override + State createState() => _AddDatabaseViewButtonState(); +} + +class _AddDatabaseViewButtonState extends State { + final popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + constraints: BoxConstraints.loose(const Size(200, 400)), + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 8), + margin: EdgeInsets.zero, + triggerActions: PopoverTriggerFlags.none, + child: Padding( + padding: const EdgeInsetsDirectional.only( + top: 2.0, + bottom: 7.0, + start: 6.0, + ), + child: FlowyIconButton( + width: 26, + hoverColor: AFThemeExtension.of(context).greyHover, + onPressed: () => popoverController.show(), + radius: Corners.s4Border, + icon: FlowySvg( + FlowySvgs.add_s, + color: Theme.of(context).hintColor, + ), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + ), + ), + popupBuilder: (BuildContext context) { + return TabBarAddButtonAction( + onTap: (action) { + popoverController.close(); + widget.onTap(action); + }, + ); + }, + ); + } +} + +class TabBarAddButtonAction extends StatelessWidget { + const TabBarAddButtonAction({super.key, required this.onTap}); + + final Function(DatabaseLayoutPB) onTap; + + @override + Widget build(BuildContext context) { + final cells = DatabaseLayoutPB.values.map((layout) { + return TabBarAddButtonActionCell( + action: layout, + onTap: onTap, + ); + }).toList(); + + return ListView.separated( + shrinkWrap: true, + itemCount: cells.length, + itemBuilder: (BuildContext context, int index) => cells[index], + separatorBuilder: (BuildContext context, int index) => + VSpace(GridSize.typeOptionSeparatorHeight), + padding: const EdgeInsets.symmetric(vertical: 4.0), + ); + } +} + +class TabBarAddButtonActionCell extends StatelessWidget { + const TabBarAddButtonActionCell({ + super.key, + required this.action, + required this.onTap, + }); + + final DatabaseLayoutPB action; + final void Function(DatabaseLayoutPB) onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + text: FlowyText( + '${LocaleKeys.grid_createView.tr()} ${action.layoutName}', + color: AFThemeExtension.of(context).textColor, + ), + leftIcon: FlowySvg( + action.icon, + color: Theme.of(context).iconTheme.color, + ), + onTap: () => onTap(action), + ).padding(horizontal: 6.0), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart new file mode 100644 index 0000000000000..e9408660ee632 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/desktop/tab_bar_header.dart @@ -0,0 +1,296 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'tab_bar_add_button.dart'; + +class TabBarHeader extends StatelessWidget { + const TabBarHeader({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 35, + padding: EdgeInsets.symmetric( + horizontal: + context.read().horizontalPadding, + ), + child: Stack( + children: [ + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Divider( + color: AFThemeExtension.of(context).borderColor, + height: 1, + thickness: 1, + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Expanded( + child: DatabaseTabBar(), + ), + Flexible( + child: BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.only(top: 6.0), + child: pageSettingBarFromState(context, state), + ); + }, + ), + ), + ], + ), + ], + ), + ); + } + + Widget pageSettingBarFromState( + BuildContext context, + DatabaseTabBarState state, + ) { + if (state.tabBars.length < state.selectedIndex) { + return const SizedBox.shrink(); + } + final tabBar = state.tabBars[state.selectedIndex]; + final controller = + state.tabBarControllerByViewId[tabBar.viewId]!.controller; + return tabBar.builder.settingBar(context, controller); + } +} + +class DatabaseTabBar extends StatefulWidget { + const DatabaseTabBar({super.key}); + + @override + State createState() => _DatabaseTabBarState(); +} + +class _DatabaseTabBarState extends State { + final _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return ListView.separated( + controller: _scrollController, + scrollDirection: Axis.horizontal, + shrinkWrap: true, + itemCount: state.tabBars.length + 1, + itemBuilder: (context, index) => index == state.tabBars.length + ? AddDatabaseViewButton( + onTap: (layoutType) { + context + .read() + .add(DatabaseTabBarEvent.createView(layoutType, null)); + }, + ) + : DatabaseTabBarItem( + key: ValueKey(state.tabBars[index].viewId), + view: state.tabBars[index].view, + isSelected: state.selectedIndex == index, + onTap: (selectedView) { + context.read().add( + DatabaseTabBarEvent.selectView(selectedView.id), + ); + }, + ), + separatorBuilder: (context, index) => VerticalDivider( + width: 1.0, + thickness: 1.0, + indent: 8, + endIndent: 13, + color: Theme.of(context).dividerColor, + ), + ); + }, + ); + } +} + +class DatabaseTabBarItem extends StatelessWidget { + const DatabaseTabBarItem({ + super.key, + required this.view, + required this.isSelected, + required this.onTap, + }); + + final ViewPB view; + final bool isSelected; + final Function(ViewPB) onTap; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 160), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: SizedBox( + height: 26, + child: TabBarItemButton( + view: view, + isSelected: isSelected, + onTap: () => onTap(view), + ), + ), + ), + if (isSelected) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Divider( + height: 2, + thickness: 2, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ); + } +} + +class TabBarItemButton extends StatelessWidget { + const TabBarItemButton({ + super.key, + required this.view, + required this.isSelected, + required this.onTap, + }); + + final ViewPB view; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return PopoverActionList( + direction: PopoverDirection.bottomWithCenterAligned, + actions: TabBarViewAction.values, + buildChild: (controller) { + Color? color; + if (!isSelected) { + color = Theme.of(context).hintColor; + } + if (Theme.of(context).brightness == Brightness.dark) { + color = null; + } + return IntrinsicWidth( + child: FlowyButton( + radius: Corners.s6Border, + hoverColor: AFThemeExtension.of(context).greyHover, + onTap: onTap, + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + onSecondaryTap: () { + controller.show(); + }, + leftIcon: FlowySvg( + view.iconData, + size: const Size(14, 14), + color: color, + ), + text: FlowyText( + view.nameOrDefault, + lineHeight: 1.0, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + color: color, + fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400, + ), + ), + ); + }, + onSelected: (action, controller) { + switch (action) { + case TabBarViewAction.rename: + NavigatorTextFieldDialog( + title: LocaleKeys.menuAppHeader_renameDialog.tr(), + value: view.nameOrDefault, + onConfirm: (newValue, _) { + context.read().add( + DatabaseTabBarEvent.renameView(view.id, newValue), + ); + }, + ).show(context); + break; + case TabBarViewAction.delete: + NavigatorAlertDialog( + title: LocaleKeys.grid_deleteView.tr(), + confirm: () { + context.read().add( + DatabaseTabBarEvent.deleteView(view.id), + ); + }, + ).show(context); + + break; + } + controller.close(); + }, + ); + } +} + +enum TabBarViewAction implements ActionCell { + rename, + delete; + + @override + String get name { + switch (this) { + case TabBarViewAction.rename: + return LocaleKeys.disclosureAction_rename.tr(); + case TabBarViewAction.delete: + return LocaleKeys.disclosureAction_delete.tr(); + } + } + + Widget icon(Color iconColor) { + switch (this) { + case TabBarViewAction.rename: + return const FlowySvg(FlowySvgs.edit_s); + case TabBarViewAction.delete: + return const FlowySvg(FlowySvgs.delete_s); + } + } + + @override + Widget? leftIcon(Color iconColor) => icon(iconColor); + + @override + Widget? rightIcon(Color iconColor) => null; + + @override + Color? textColor(BuildContext context) { + return null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/mobile/mobile_tab_bar_header.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/mobile/mobile_tab_bar_header.dart new file mode 100644 index 0000000000000..3606ed82a3d34 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/mobile/mobile_tab_bar_header.dart @@ -0,0 +1,155 @@ +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/database/view/database_view_list.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; +import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/setting/mobile_database_controls.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileTabBarHeader extends StatelessWidget { + const MobileTabBarHeader({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: GridSize.horizontalHeaderPadding, + top: 14.0, + right: GridSize.horizontalHeaderPadding - 5.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const _DatabaseViewSelectorButton(), + const Spacer(), + BlocBuilder( + builder: (context, state) { + final currentView = state.tabBars.firstWhereIndexedOrNull( + (index, tabBar) => index == state.selectedIndex, + ); + + if (currentView == null) { + return const SizedBox.shrink(); + } + + return MobileDatabaseControls( + controller: state + .tabBarControllerByViewId[currentView.viewId]!.controller, + features: switch (currentView.layout) { + ViewLayoutPB.Board || ViewLayoutPB.Calendar => [ + MobileDatabaseControlFeatures.filter, + ], + ViewLayoutPB.Grid => [ + MobileDatabaseControlFeatures.sort, + MobileDatabaseControlFeatures.filter, + ], + _ => [], + }, + ); + }, + ), + ], + ), + ); + } +} + +class _DatabaseViewSelectorButton extends StatelessWidget { + const _DatabaseViewSelectorButton(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final tabBar = state.tabBars.firstWhereIndexedOrNull( + (index, tabBar) => index == state.selectedIndex, + ); + + if (tabBar == null) { + return const SizedBox.shrink(); + } + + return TextButton( + style: ButtonStyle( + padding: const WidgetStatePropertyAll( + EdgeInsets.fromLTRB(12, 8, 8, 8), + ), + maximumSize: const WidgetStatePropertyAll(Size(200, 48)), + minimumSize: const WidgetStatePropertyAll(Size(48, 0)), + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + backgroundColor: WidgetStatePropertyAll( + Theme.of(context).brightness == Brightness.light + ? const Color(0x0F212729) + : const Color(0x0FFFFFFF), + ), + overlayColor: WidgetStatePropertyAll( + Theme.of(context).colorScheme.secondary, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildViewIconButton(context, tabBar.view), + const HSpace(6), + Flexible( + child: FlowyText.medium( + tabBar.view.nameOrDefault, + fontSize: 14, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(8), + const FlowySvg( + FlowySvgs.arrow_tight_s, + size: Size.square(10), + ), + ], + ), + onPressed: () { + showTransitionMobileBottomSheet( + context, + showDivider: false, + builder: (_) { + return MultiBlocProvider( + providers: [ + BlocProvider.value( + value: context.read(), + ), + BlocProvider.value( + value: context.read(), + ), + ], + child: const MobileDatabaseViewList(), + ); + }, + ); + }, + ); + }, + ); + } + + Widget _buildViewIconButton(BuildContext context, ViewPB view) { + return view.icon.value.isNotEmpty + ? EmojiText( + emoji: view.icon.value, + fontSize: 16.0, + ) + : SizedBox.square( + dimension: 16.0, + child: view.defaultIcon(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart new file mode 100644 index 0000000000000..542938a574ab8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/tab_bar/tab_bar_view.dart @@ -0,0 +1,299 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/shared/share/share_button.dart'; +import 'package:appflowy/plugins/util.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.dart'; +import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'desktop/tab_bar_header.dart'; +import 'mobile/mobile_tab_bar_header.dart'; + +abstract class DatabaseTabBarItemBuilder { + const DatabaseTabBarItemBuilder(); + + /// Returns the content of the tab bar item. The content is shown when the tab + /// bar item is selected. It can be any kind of database view. + Widget content( + BuildContext context, + ViewPB view, + DatabaseController controller, + bool shrinkWrap, + String? initialRowId, + ); + + /// Returns the setting bar of the tab bar item. The setting bar is shown on the + /// top right conner when the tab bar item is selected. + Widget settingBar( + BuildContext context, + DatabaseController controller, + ); + + Widget settingBarExtension( + BuildContext context, + DatabaseController controller, + ); + + /// Should be called in case a builder has resources it + /// needs to dispose of. + /// + // If we add any logic in this method, add @mustCallSuper ! + void dispose() {} +} + +class DatabaseTabBarView extends StatelessWidget { + const DatabaseTabBarView({ + super.key, + required this.view, + required this.shrinkWrap, + this.initialRowId, + }); + + final ViewPB view; + final bool shrinkWrap; + + /// Used to open a Row on plugin load + /// + final String? initialRowId; + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => DatabaseTabBarBloc(view: view) + ..add(const DatabaseTabBarEvent.initial()), + ), + BlocProvider( + create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), + ), + ], + child: BlocBuilder( + builder: (_, state) { + final layout = state.tabBars[state.selectedIndex].layout; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (UniversalPlatform.isMobile) const VSpace(12), + ValueListenableBuilder( + valueListenable: state + .tabBarControllerByViewId[state.parentView.id]! + .controller + .isLoading, + builder: (_, value, ___) { + if (value) { + return const SizedBox.shrink(); + } + + return UniversalPlatform.isDesktop + ? const TabBarHeader() + : const MobileTabBarHeader(); + }, + ), + pageSettingBarExtensionFromState(context, state), + wrapContent( + layout: layout, + child: pageContentFromState(context, state), + ), + ], + ); + }, + ), + ); + } + + Widget wrapContent({required ViewLayoutPB layout, required Widget child}) { + if (shrinkWrap) { + if (layout.shrinkWrappable) { + return child; + } + + return SizedBox( + height: layout.pluginHeight, + child: child, + ); + } + + return Expanded(child: child); + } + + Widget pageContentFromState(BuildContext context, DatabaseTabBarState state) { + final tab = state.tabBars[state.selectedIndex]; + final controller = state.tabBarControllerByViewId[tab.viewId]!.controller; + + return tab.builder.content( + context, + tab.view, + controller, + shrinkWrap, + initialRowId, + ); + } + + Widget pageSettingBarExtensionFromState( + BuildContext context, + DatabaseTabBarState state, + ) { + if (state.tabBars.length < state.selectedIndex) { + return const SizedBox.shrink(); + } + final tabBar = state.tabBars[state.selectedIndex]; + final controller = + state.tabBarControllerByViewId[tabBar.viewId]!.controller; + return Padding( + padding: EdgeInsets.symmetric( + horizontal: + context.read().horizontalPadding, + ), + child: tabBar.builder.settingBarExtension( + context, + controller, + ), + ); + } +} + +class DatabaseTabBarViewPlugin extends Plugin { + DatabaseTabBarViewPlugin({ + required ViewPB view, + required PluginType pluginType, + this.initialRowId, + }) : _pluginType = pluginType, + notifier = ViewPluginNotifier(view: view); + + @override + final ViewPluginNotifier notifier; + + final PluginType _pluginType; + late final ViewInfoBloc _viewInfoBloc; + + /// Used to open a Row on plugin load + /// + final String? initialRowId; + + @override + PluginWidgetBuilder get widgetBuilder => DatabasePluginWidgetBuilder( + bloc: _viewInfoBloc, + notifier: notifier, + initialRowId: initialRowId, + ); + + @override + PluginId get id => notifier.view.id; + + @override + PluginType get pluginType => _pluginType; + + @override + void init() { + _viewInfoBloc = ViewInfoBloc(view: notifier.view) + ..add(const ViewInfoEvent.started()); + } + + @override + void dispose() { + _viewInfoBloc.close(); + notifier.dispose(); + } +} + +const kDatabasePluginWidgetBuilderHorizontalPadding = 'horizontal_padding'; + +class DatabasePluginWidgetBuilderSize { + const DatabasePluginWidgetBuilderSize({required this.horizontalPadding}); + + final double horizontalPadding; +} + +class DatabasePluginWidgetBuilder extends PluginWidgetBuilder { + DatabasePluginWidgetBuilder({ + required this.bloc, + required this.notifier, + this.initialRowId, + }); + + final ViewInfoBloc bloc; + final ViewPluginNotifier notifier; + + /// Used to open a Row on plugin load + /// + final String? initialRowId; + + @override + String? get viewName => notifier.view.nameOrDefault; + + @override + Widget get leftBarItem => + ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view); + + @override + Widget tabBarItem(String pluginId, [bool shortForm = false]) => + ViewTabBarItem(view: notifier.view, shortForm: shortForm); + + @override + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + Map? data, + }) { + notifier.isDeleted.addListener(() { + final deletedView = notifier.isDeleted.value; + if (deletedView != null && deletedView.hasIndex()) { + context.onDeleted?.call(notifier.view, deletedView.index); + } + }); + + final horizontalPadding = + data?[kDatabasePluginWidgetBuilderHorizontalPadding] as double? ?? + GridSize.horizontalHeaderPadding + 40; + + return Provider( + create: (context) => DatabasePluginWidgetBuilderSize( + horizontalPadding: horizontalPadding, + ), + child: DatabaseTabBarView( + key: ValueKey(notifier.view.id), + view: notifier.view, + shrinkWrap: shrinkWrap, + initialRowId: initialRowId, + ), + ); + } + + @override + List get navigationItems => [this]; + + @override + Widget? get rightBarItem { + final view = notifier.view; + return BlocProvider.value( + value: bloc, + child: Row( + children: [ + ShareButton(key: ValueKey(view.id), view: view), + const HSpace(10), + ViewFavoriteButton(view: view), + const HSpace(4), + MoreViewActions(view: view, isDocument: false), + ], + ), + ); + } + + @override + EdgeInsets get contentPadding => const EdgeInsets.only(top: 28); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart new file mode 100644 index 0000000000000..e7c61504483cf --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart @@ -0,0 +1,473 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/database/card/card.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/row/action.dart'; +import 'package:appflowy/shared/af_image.dart'; +import 'package:appflowy/shared/flowy_gradient_colors.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../cell/card_cell_builder.dart'; +import '../cell/card_cell_skeleton/card_cell.dart'; + +import 'card_bloc.dart'; +import 'container/accessory.dart'; +import 'container/card_container.dart'; + +/// Edit a database row with card style widget +class RowCard extends StatefulWidget { + const RowCard({ + super.key, + required this.fieldController, + required this.rowMeta, + required this.viewId, + required this.isEditing, + required this.rowCache, + required this.cellBuilder, + required this.onTap, + required this.onStartEditing, + required this.onEndEditing, + required this.styleConfiguration, + this.onShiftTap, + this.groupingFieldId, + this.groupId, + required this.userProfile, + this.isCompact = false, + }); + + final FieldController fieldController; + final RowMetaPB rowMeta; + final String viewId; + final String? groupingFieldId; + final String? groupId; + + final bool isEditing; + final RowCache rowCache; + + /// The [CardCellBuilder] is used to build the card cells. + final CardCellBuilder cellBuilder; + + /// Called when the user taps on the card. + final void Function(BuildContext context) onTap; + + final void Function(BuildContext context)? onShiftTap; + + /// Called when the user starts editing the card. + final VoidCallback onStartEditing; + + /// Called when the user ends editing the card. + final VoidCallback onEndEditing; + + final RowCardStyleConfiguration styleConfiguration; + + /// Specifically the token is used to handle requests to retrieve images + /// from cloud storage, such as the card cover. + final UserProfilePB? userProfile; + + /// Whether the card is in a narrow space. + /// This is used to determine eg. the Cover height. + final bool isCompact; + + @override + State createState() => _RowCardState(); +} + +class _RowCardState extends State { + final popoverController = PopoverController(); + late final CardBloc _cardBloc; + + @override + void initState() { + super.initState(); + final rowController = RowController( + viewId: widget.viewId, + rowMeta: widget.rowMeta, + rowCache: widget.rowCache, + ); + + _cardBloc = CardBloc( + fieldController: widget.fieldController, + viewId: widget.viewId, + groupFieldId: widget.groupingFieldId, + isEditing: widget.isEditing, + rowController: rowController, + )..add(const CardEvent.initial()); + } + + @override + void didUpdateWidget(covariant oldWidget) { + if (widget.isEditing != _cardBloc.state.isEditing) { + _cardBloc.add(CardEvent.setIsEditing(widget.isEditing)); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _cardBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cardBloc, + child: BlocListener( + listenWhen: (previous, current) => + previous.isEditing != current.isEditing, + listener: (context, state) { + if (!state.isEditing) { + widget.onEndEditing(); + } + }, + child: UniversalPlatform.isMobile ? _mobile() : _desktop(), + ), + ); + } + + Widget _mobile() { + return BlocBuilder( + builder: (context, state) { + return GestureDetector( + onTap: () => widget.onTap(context), + behavior: HitTestBehavior.opaque, + child: MobileCardContent( + userProfile: widget.userProfile, + rowMeta: state.rowMeta, + cellBuilder: widget.cellBuilder, + styleConfiguration: widget.styleConfiguration, + cells: state.cells, + ), + ); + }, + ); + } + + Widget _desktop() { + final accessories = widget.styleConfiguration.showAccessory + ? const [ + EditCardAccessory(), + MoreCardOptionsAccessory(), + ] + : null; + return AppFlowyPopover( + controller: popoverController, + triggerActions: PopoverTriggerFlags.none, + constraints: BoxConstraints.loose(const Size(140, 200)), + direction: PopoverDirection.rightWithCenterAligned, + popupBuilder: (_) => RowActionMenu.board( + viewId: _cardBloc.viewId, + rowId: _cardBloc.rowController.rowId, + groupId: widget.groupId, + ), + child: Builder( + builder: (context) { + return RowCardContainer( + buildAccessoryWhen: () => + !context.watch().state.isEditing, + accessories: accessories ?? [], + openAccessory: _handleOpenAccessory, + onTap: widget.onTap, + onShiftTap: widget.onShiftTap, + child: BlocBuilder( + builder: (context, state) { + return _CardContent( + rowMeta: state.rowMeta, + cellBuilder: widget.cellBuilder, + styleConfiguration: widget.styleConfiguration, + cells: state.cells, + userProfile: widget.userProfile, + isCompact: widget.isCompact, + ); + }, + ), + ); + }, + ), + ); + } + + void _handleOpenAccessory(AccessoryType newAccessoryType) { + switch (newAccessoryType) { + case AccessoryType.edit: + widget.onStartEditing(); + break; + case AccessoryType.more: + popoverController.show(); + break; + } + } +} + +class _CardContent extends StatelessWidget { + const _CardContent({ + required this.rowMeta, + required this.cellBuilder, + required this.cells, + required this.styleConfiguration, + this.userProfile, + this.isCompact = false, + }); + + final RowMetaPB rowMeta; + final CardCellBuilder cellBuilder; + final List cells; + final RowCardStyleConfiguration styleConfiguration; + final UserProfilePB? userProfile; + final bool isCompact; + + @override + Widget build(BuildContext context) { + final child = Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + CardCover( + cover: rowMeta.cover, + userProfile: userProfile, + isCompact: isCompact, + ), + Padding( + padding: styleConfiguration.cardPadding, + child: Column( + children: _makeCells(context, rowMeta, cells), + ), + ), + ], + ); + return styleConfiguration.hoverStyle == null + ? child + : FlowyHover( + style: styleConfiguration.hoverStyle, + buildWhenOnHover: () => !context.read().state.isEditing, + child: child, + ); + } + + List _makeCells( + BuildContext context, + RowMetaPB rowMeta, + List cells, + ) { + return cells + .mapIndexed( + (int index, CellMeta cellMeta) => _CardContentCell( + cellBuilder: cellBuilder, + cellMeta: cellMeta, + rowMeta: rowMeta, + isTitle: index == 0, + styleMap: styleConfiguration.cellStyleMap, + ), + ) + .toList(); + } +} + +class _CardContentCell extends StatefulWidget { + const _CardContentCell({ + required this.cellBuilder, + required this.cellMeta, + required this.rowMeta, + required this.isTitle, + required this.styleMap, + }); + + final CellMeta cellMeta; + final RowMetaPB rowMeta; + final CardCellBuilder cellBuilder; + final CardCellStyleMap styleMap; + final bool isTitle; + + @override + State<_CardContentCell> createState() => _CardContentCellState(); +} + +class _CardContentCellState extends State<_CardContentCell> { + late final EditableCardNotifier? cellNotifier; + + @override + void initState() { + super.initState(); + cellNotifier = widget.isTitle ? EditableCardNotifier() : null; + cellNotifier?.isCellEditing.addListener(listener); + } + + void listener() { + final isEditing = cellNotifier!.isCellEditing.value; + context.read().add(CardEvent.setIsEditing(isEditing)); + } + + @override + void dispose() { + cellNotifier?.isCellEditing.removeListener(listener); + cellNotifier?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => + previous.isEditing != current.isEditing, + listener: (context, state) { + cellNotifier?.isCellEditing.value = state.isEditing; + }, + child: widget.cellBuilder.build( + cellContext: widget.cellMeta.cellContext(), + styleMap: widget.styleMap, + cellNotifier: cellNotifier, + hasNotes: !widget.rowMeta.isDocumentEmpty, + ), + ); + } +} + +class CardCover extends StatelessWidget { + const CardCover({ + super.key, + this.cover, + this.userProfile, + this.isCompact = false, + }); + + final RowCoverPB? cover; + final UserProfilePB? userProfile; + final bool isCompact; + + @override + Widget build(BuildContext context) { + if (cover == null || + cover!.data.isEmpty || + cover!.uploadType == FileUploadTypePB.CloudFile && + userProfile == null) { + return const SizedBox.shrink(); + } + + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(4), + ), + color: Theme.of(context).cardColor, + ), + child: Row( + children: [ + Expanded(child: _renderCover(context, cover!)), + ], + ), + ); + } + + Widget _renderCover(BuildContext context, RowCoverPB cover) { + final height = isCompact ? 50.0 : 100.0; + + if (cover.coverType == CoverTypePB.FileCover) { + return SizedBox( + height: height, + width: double.infinity, + child: AFImage( + url: cover.data, + uploadType: cover.uploadType, + userProfile: userProfile, + ), + ); + } + + if (cover.coverType == CoverTypePB.AssetCover) { + return SizedBox( + height: height, + width: double.infinity, + child: Image.asset( + PageStyleCoverImageType.builtInImagePath(cover.data), + fit: BoxFit.cover, + ), + ); + } + + if (cover.coverType == CoverTypePB.ColorCover) { + final color = FlowyTint.fromId(cover.data)?.color(context) ?? + cover.data.tryToColor(); + return Container( + height: height, + width: double.infinity, + color: color, + ); + } + + if (cover.coverType == CoverTypePB.GradientCover) { + return Container( + height: height, + width: double.infinity, + decoration: BoxDecoration( + gradient: FlowyGradientColor.fromId(cover.data).linear, + ), + ); + } + + return const SizedBox.shrink(); + } +} + +class EditCardAccessory extends StatelessWidget with CardAccessory { + const EditCardAccessory({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(3.0), + child: FlowySvg( + FlowySvgs.edit_s, + color: Theme.of(context).hintColor, + ), + ); + } + + @override + AccessoryType get type => AccessoryType.edit; +} + +class MoreCardOptionsAccessory extends StatelessWidget with CardAccessory { + const MoreCardOptionsAccessory({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(3.0), + child: FlowySvg( + FlowySvgs.three_dots_s, + color: Theme.of(context).hintColor, + ), + ); + } + + @override + AccessoryType get type => AccessoryType.more; +} + +class RowCardStyleConfiguration { + const RowCardStyleConfiguration({ + required this.cellStyleMap, + this.showAccessory = true, + this.cardPadding = const EdgeInsets.all(8), + this.hoverStyle, + }); + + final CardCellStyleMap cellStyleMap; + final bool showAccessory; + final EdgeInsets cardPadding; + final HoverStyle? hoverStyle; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart new file mode 100644 index 0000000000000..04f9bb652cdda --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card_bloc.dart @@ -0,0 +1,170 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'card_bloc.freezed.dart'; + +class CardBloc extends Bloc { + CardBloc({ + required this.fieldController, + required this.groupFieldId, + required this.viewId, + required bool isEditing, + required this.rowController, + }) : super( + CardState.initial( + _makeCells( + fieldController, + groupFieldId, + rowController, + ), + isEditing, + rowController.rowMeta, + ), + ) { + rowController.initialize(); + _dispatch(); + } + + final FieldController fieldController; + final String? groupFieldId; + final String viewId; + final RowController rowController; + + VoidCallback? _rowCallback; + + @override + Future close() async { + if (_rowCallback != null) { + _rowCallback = null; + } + await rowController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () async { + await _startListening(); + }, + didReceiveCells: (cells, reason) async { + emit( + state.copyWith( + cells: cells, + changeReason: reason, + ), + ); + }, + setIsEditing: (bool isEditing) { + if (isEditing != state.isEditing) { + emit(state.copyWith(isEditing: isEditing)); + } + }, + didUpdateRowMeta: (rowMeta) { + emit(state.copyWith(rowMeta: rowMeta)); + }, + ); + }, + ); + } + + Future _startListening() async { + rowController.addListener( + onRowChanged: (cellMap, reason) { + if (!isClosed) { + final cells = + _makeCells(fieldController, groupFieldId, rowController); + add(CardEvent.didReceiveCells(cells, reason)); + } + }, + onMetaChanged: () { + if (!isClosed) { + add(CardEvent.didUpdateRowMeta(rowController.rowMeta)); + } + }, + ); + } +} + +List _makeCells( + FieldController fieldController, + String? groupFieldId, + RowController rowController, +) { + // Only show the non-hidden cells and cells that aren't of the grouping field + final cellContext = rowController.loadCells(); + + cellContext.removeWhere((cellContext) { + final fieldInfo = fieldController.getField(cellContext.fieldId); + return fieldInfo == null || + !(fieldInfo.visibility?.isVisibleState() ?? false) || + (groupFieldId != null && cellContext.fieldId == groupFieldId); + }); + return cellContext + .map( + (cellCtx) => CellMeta( + fieldId: cellCtx.fieldId, + rowId: cellCtx.rowId, + fieldType: fieldController.getField(cellCtx.fieldId)!.fieldType, + ), + ) + .toList(); +} + +@freezed +class CardEvent with _$CardEvent { + const factory CardEvent.initial() = _InitialRow; + const factory CardEvent.setIsEditing(bool isEditing) = _IsEditing; + const factory CardEvent.didReceiveCells( + List cells, + ChangedReason reason, + ) = _DidReceiveCells; + const factory CardEvent.didUpdateRowMeta(RowMetaPB rowMeta) = + _DidUpdateRowMeta; +} + +@freezed +class CellMeta with _$CellMeta { + const CellMeta._(); + + const factory CellMeta({ + required String fieldId, + required RowId rowId, + required FieldType fieldType, + }) = _DatabaseCellMeta; + + CellContext cellContext() => CellContext(fieldId: fieldId, rowId: rowId); +} + +@freezed +class CardState with _$CardState { + const factory CardState({ + required List cells, + required bool isEditing, + required RowMetaPB rowMeta, + ChangedReason? changeReason, + }) = _RowCardState; + + factory CardState.initial( + List cells, + bool isEditing, + RowMetaPB rowMeta, + ) => + CardState( + cells: cells, + isEditing: isEditing, + rowMeta: rowMeta, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/accessory.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/accessory.dart new file mode 100644 index 0000000000000..70786858456a2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/accessory.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra_ui/style_widget/hover.dart'; + +enum AccessoryType { + edit, + more, +} + +abstract mixin class CardAccessory implements Widget { + AccessoryType get type; + void onTap(BuildContext context) {} +} + +class CardAccessoryContainer extends StatelessWidget { + const CardAccessoryContainer({ + super.key, + required this.accessories, + required this.onTapAccessory, + }); + + final List accessories; + final void Function(AccessoryType) onTapAccessory; + + @override + Widget build(BuildContext context) { + if (accessories.isEmpty) { + return const SizedBox.shrink(); + } + + final children = accessories.map((accessory) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + accessory.onTap(context); + onTapAccessory(accessory.type); + }, + child: _wrapHover(context, accessory), + ); + }).toList(); + + children.insert( + 1, + VerticalDivider( + width: 1, + thickness: 1, + color: Theme.of(context).brightness == Brightness.light + ? const Color(0xFF1F2329).withOpacity(0.12) + : const Color(0xff59647a), + ), + ); + + return _wrapDecoration( + context, + IntrinsicHeight(child: Row(children: children)), + ); + } + + Widget _wrapHover(BuildContext context, CardAccessory accessory) { + return SizedBox( + width: 24, + height: 22, + child: FlowyHover( + style: HoverStyle( + backgroundColor: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.zero, + ), + child: accessory, + ), + ); + } + + Widget _wrapDecoration(BuildContext context, Widget child) { + final decoration = BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.all(Radius.circular(4)), + border: Border.fromBorderSide( + BorderSide( + color: Theme.of(context).brightness == Brightness.light + ? const Color(0xFF1F2329).withOpacity(0.12) + : const Color(0xff59647a), + ), + ), + boxShadow: [ + BoxShadow( + blurRadius: 4, + color: const Color(0xFF1F2329).withOpacity(0.02), + ), + BoxShadow( + blurRadius: 4, + spreadRadius: -2, + color: const Color(0xFF1F2329).withOpacity(0.02), + ), + ], + ); + return Container( + clipBehavior: Clip.hardEdge, + decoration: decoration, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: child, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/card_container.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/card_container.dart new file mode 100644 index 0000000000000..ba71fcd30b155 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/container/card_container.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import 'accessory.dart'; + +class RowCardContainer extends StatelessWidget { + const RowCardContainer({ + super.key, + required this.child, + required this.onTap, + required this.openAccessory, + required this.accessories, + this.buildAccessoryWhen, + this.onShiftTap, + }); + + final Widget child; + final void Function(BuildContext) onTap; + final void Function(BuildContext)? onShiftTap; + final void Function(AccessoryType) openAccessory; + final List accessories; + final bool Function()? buildAccessoryWhen; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => _CardContainerNotifier(), + child: Consumer<_CardContainerNotifier>( + builder: (context, notifier, _) { + final shouldBuildAccessory = buildAccessoryWhen?.call() ?? true; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (HardwareKeyboard.instance.isShiftPressed) { + onShiftTap?.call(context); + } else { + onTap(context); + } + }, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 42), + child: _CardEnterRegion( + shouldBuildAccessory: shouldBuildAccessory, + accessories: accessories, + onTapAccessory: openAccessory, + child: child, + ), + ), + ); + }, + ), + ); + } +} + +class _CardEnterRegion extends StatelessWidget { + const _CardEnterRegion({ + required this.shouldBuildAccessory, + required this.child, + required this.accessories, + required this.onTapAccessory, + }); + + final bool shouldBuildAccessory; + final Widget child; + final List accessories; + final void Function(AccessoryType) onTapAccessory; + + @override + Widget build(BuildContext context) { + return Selector<_CardContainerNotifier, bool>( + selector: (context, notifier) => notifier.onEnter, + builder: (context, onEnter, _) { + final List children = [ + child, + if (onEnter && shouldBuildAccessory) + Positioned( + top: 10.0, + right: 10.0, + child: CardAccessoryContainer( + accessories: accessories, + onTapAccessory: onTapAccessory, + ), + ), + ]; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (p) => + Provider.of<_CardContainerNotifier>(context, listen: false) + .onEnter = true, + onExit: (p) => + Provider.of<_CardContainerNotifier>(context, listen: false) + .onEnter = false, + child: IntrinsicHeight( + child: Stack( + alignment: AlignmentDirectional.topEnd, + fit: StackFit.expand, + children: children, + ), + ), + ); + }, + ); + } +} + +class _CardContainerNotifier extends ChangeNotifier { + _CardContainerNotifier(); + + bool _onEnter = false; + + set onEnter(bool value) { + if (_onEnter != value) { + _onEnter = value; + notifyListeners(); + } + } + + bool get onEnter => _onEnter; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart new file mode 100644 index 0000000000000..d17c522de6a87 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart @@ -0,0 +1,126 @@ +import 'package:flutter/widgets.dart'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; + +import 'card_cell_skeleton/card_cell.dart'; +import 'card_cell_skeleton/checkbox_card_cell.dart'; +import 'card_cell_skeleton/checklist_card_cell.dart'; +import 'card_cell_skeleton/date_card_cell.dart'; +import 'card_cell_skeleton/media_card_cell.dart'; +import 'card_cell_skeleton/number_card_cell.dart'; +import 'card_cell_skeleton/relation_card_cell.dart'; +import 'card_cell_skeleton/select_option_card_cell.dart'; +import 'card_cell_skeleton/summary_card_cell.dart'; +import 'card_cell_skeleton/text_card_cell.dart'; +import 'card_cell_skeleton/time_card_cell.dart'; +import 'card_cell_skeleton/timestamp_card_cell.dart'; +import 'card_cell_skeleton/translate_card_cell.dart'; +import 'card_cell_skeleton/url_card_cell.dart'; + +typedef CardCellStyleMap = Map; + +class CardCellBuilder { + CardCellBuilder({required this.databaseController}); + + final DatabaseController databaseController; + + Widget build({ + required CellContext cellContext, + required CardCellStyleMap styleMap, + EditableCardNotifier? cellNotifier, + required bool hasNotes, + }) { + final fieldType = databaseController.fieldController + .getField(cellContext.fieldId)! + .fieldType; + final key = ValueKey( + "${databaseController.viewId}${cellContext.fieldId}${cellContext.rowId}", + ); + final style = styleMap[fieldType]; + return switch (fieldType) { + FieldType.Checkbox => CheckboxCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), + FieldType.Checklist => ChecklistCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), + FieldType.DateTime => DateCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), + FieldType.LastEditedTime || FieldType.CreatedTime => TimestampCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), + FieldType.SingleSelect || FieldType.MultiSelect => SelectOptionCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), + FieldType.Number => NumberCardCell( + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + key: key, + ), + FieldType.RichText => TextCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + editableNotifier: cellNotifier, + showNotes: hasNotes, + ), + FieldType.URL => URLCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), + FieldType.Relation => RelationCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), + FieldType.Summary => SummaryCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), + FieldType.Time => TimeCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), + FieldType.Translate => TranslateCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), + FieldType.Media => MediaCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), + _ => throw UnimplementedError, + }; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/card_cell.dart new file mode 100644 index 0000000000000..7b0353925273b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/card_cell.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +abstract class CardCell extends StatefulWidget { + const CardCell({super.key, required this.style}); + + final T style; +} + +abstract class CardCellStyle { + const CardCellStyle({required this.padding}); + + final EdgeInsetsGeometry padding; +} + +S? isStyleOrNull(CardCellStyle? style) { + if (style is S) { + return style as S; + } else { + return null; + } +} + +class EditableCardNotifier { + EditableCardNotifier({bool isEditing = false}) + : isCellEditing = ValueNotifier(isEditing); + + final ValueNotifier isCellEditing; + + void dispose() { + isCellEditing.dispose(); + } +} + +abstract mixin class EditableCell { + // Each cell notifier will be bind to the [EditableRowNotifier], which enable + // the row notifier receive its cells event. For example: begin editing the + // cell or end editing the cell. + // + EditableCardNotifier? get editableNotifier; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checkbox_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checkbox_card_cell.dart new file mode 100644 index 0000000000000..0d1f4687fe546 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checkbox_card_cell.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'card_cell.dart'; + +class CheckboxCardCellStyle extends CardCellStyle { + CheckboxCardCellStyle({ + required super.padding, + required this.iconSize, + required this.showFieldName, + this.textStyle, + }) : assert(!showFieldName || showFieldName && textStyle != null); + + final Size iconSize; + final bool showFieldName; + final TextStyle? textStyle; +} + +class CheckboxCardCell extends CardCell { + const CheckboxCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _CheckboxCellState(); +} + +class _CheckboxCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) { + return CheckboxCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + )..add(const CheckboxCellEvent.initial()); + }, + child: BlocBuilder( + builder: (context, state) { + return Container( + alignment: AlignmentDirectional.centerStart, + padding: widget.style.padding, + child: Row( + children: [ + FlowyIconButton( + icon: FlowySvg( + state.isSelected + ? FlowySvgs.check_filled_s + : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, + size: widget.style.iconSize, + ), + width: 20, + onPressed: () => context + .read() + .add(const CheckboxCellEvent.select()), + ), + if (widget.style.showFieldName) ...[ + const HSpace(6.0), + Text( + state.fieldName, + style: widget.style.textStyle, + ), + ], + ], + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checklist_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checklist_card_cell.dart new file mode 100644 index 0000000000000..a45b621dded76 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/checklist_card_cell.dart @@ -0,0 +1,64 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'card_cell.dart'; + +class ChecklistCardCellStyle extends CardCellStyle { + ChecklistCardCellStyle({ + required super.padding, + required this.textStyle, + }); + + final TextStyle textStyle; +} + +class ChecklistCardCell extends CardCell { + const ChecklistCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _ChecklistCellState(); +} + +class _ChecklistCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) { + return ChecklistCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + }, + child: BlocBuilder( + builder: (context, state) { + if (state.tasks.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: widget.style.padding, + child: ChecklistProgressBar( + tasks: state.tasks, + percent: state.percent, + textStyle: widget.style.textStyle, + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/date_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/date_card_cell.dart new file mode 100644 index 0000000000000..c459d8cc60487 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/date_card_cell.dart @@ -0,0 +1,83 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/date.dart'; +import 'card_cell.dart'; + +class DateCardCellStyle extends CardCellStyle { + DateCardCellStyle({ + required super.padding, + required this.textStyle, + }); + + final TextStyle textStyle; +} + +class DateCardCell extends CardCell { + const DateCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _DateCellState(); +} + +class _DateCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) { + return DateCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + }, + child: BlocBuilder( + builder: (context, state) { + final dateStr = getDateCellStrFromCellData( + state.fieldInfo, + state.cellData, + ); + + if (dateStr.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + alignment: Alignment.centerLeft, + padding: widget.style.padding, + child: Row( + children: [ + Flexible( + child: Text( + dateStr, + style: widget.style.textStyle, + overflow: TextOverflow.ellipsis, + ), + ), + if (state.cellData.reminderId.isNotEmpty) ...[ + const HSpace(4), + const FlowySvg(FlowySvgs.clock_alarm_s), + ], + ], + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart new file mode 100644 index 0000000000000..969f80d17b535 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart @@ -0,0 +1,81 @@ +import 'package:flutter/widgets.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/card_cell.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MediaCardCellStyle extends CardCellStyle { + const MediaCardCellStyle({ + required super.padding, + required this.textStyle, + }); + + final TextStyle textStyle; +} + +class MediaCardCell extends CardCell { + const MediaCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _MediaCellState(); +} + +class _MediaCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => MediaCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + )..add(const MediaCellEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + if (state.files.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(left: 4), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.media_s, + size: Size.square(12), + ), + const HSpace(6), + Flexible( + child: FlowyText.regular( + LocaleKeys.grid_media_attachmentsHint + .tr(args: ['${state.files.length}']), + fontSize: 12, + color: AFThemeExtension.of(context).secondaryTextColor, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/number_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/number_card_cell.dart new file mode 100644 index 0000000000000..ee160f09bebcc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/number_card_cell.dart @@ -0,0 +1,62 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'card_cell.dart'; + +class NumberCardCellStyle extends CardCellStyle { + const NumberCardCellStyle({ + required super.padding, + required this.textStyle, + }); + + final TextStyle textStyle; +} + +class NumberCardCell extends CardCell { + const NumberCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _NumberCellState(); +} + +class _NumberCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) { + return NumberCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + }, + child: BlocBuilder( + buildWhen: (previous, current) => previous.content != current.content, + builder: (context, state) { + if (state.content.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + alignment: AlignmentDirectional.centerStart, + padding: widget.style.padding, + child: Text(state.content, style: widget.style.textStyle), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart new file mode 100644 index 0000000000000..023048355ed8c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'card_cell.dart'; + +class RelationCardCellStyle extends CardCellStyle { + RelationCardCellStyle({ + required super.padding, + required this.textStyle, + required this.wrap, + }); + + final TextStyle textStyle; + final bool wrap; +} + +class RelationCardCell extends CardCell { + const RelationCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _RelationCellState(); +} + +class _RelationCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) { + return RelationCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + }, + child: BlocBuilder( + builder: (context, state) { + if (state.rows.isEmpty) { + return const SizedBox.shrink(); + } + + final children = state.rows.map( + (row) { + final isEmpty = row.name.isEmpty; + return Text( + isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, + style: widget.style.textStyle.copyWith( + color: isEmpty ? Theme.of(context).hintColor : null, + decoration: TextDecoration.underline, + ), + overflow: TextOverflow.ellipsis, + ); + }, + ).toList(); + + return Container( + alignment: AlignmentDirectional.topStart, + padding: widget.style.padding, + child: widget.style.wrap + ? Wrap(spacing: 4, runSpacing: 4, children: children) + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: children, + ), + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/select_option_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/select_option_card_cell.dart new file mode 100644 index 0000000000000..b705e93abb927 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/select_option_card_cell.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'card_cell.dart'; + +class SelectOptionCardCellStyle extends CardCellStyle { + SelectOptionCardCellStyle({ + required super.padding, + required this.tagFontSize, + required this.wrap, + required this.tagPadding, + }); + + final double tagFontSize; + final bool wrap; + final EdgeInsets tagPadding; +} + +class SelectOptionCardCell extends CardCell { + const SelectOptionCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _SelectOptionCellState(); +} + +class _SelectOptionCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) { + return SelectOptionCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + }, + child: BlocBuilder( + buildWhen: (previous, current) { + return previous.selectedOptions != current.selectedOptions; + }, + builder: (context, state) { + if (state.selectedOptions.isEmpty) { + return const SizedBox.shrink(); + } + + final children = state.selectedOptions + .map( + (option) => SelectOptionTag( + option: option, + fontSize: widget.style.tagFontSize, + padding: widget.style.tagPadding, + ), + ) + .toList(); + + return Container( + alignment: AlignmentDirectional.topStart, + padding: widget.style.padding, + child: widget.style.wrap + ? Wrap(spacing: 4, runSpacing: 4, children: children) + : SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: children, + ), + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart new file mode 100644 index 0000000000000..8d7577b6ea31a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart @@ -0,0 +1,62 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'card_cell.dart'; + +class SummaryCardCellStyle extends CardCellStyle { + const SummaryCardCellStyle({ + required super.padding, + required this.textStyle, + }); + + final TextStyle textStyle; +} + +class SummaryCardCell extends CardCell { + const SummaryCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _SummaryCellState(); +} + +class _SummaryCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) { + return SummaryCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + }, + child: BlocBuilder( + buildWhen: (previous, current) => previous.content != current.content, + builder: (context, state) { + if (state.content.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + alignment: AlignmentDirectional.centerStart, + padding: widget.style.padding, + child: Text(state.content, style: widget.style.textStyle), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart new file mode 100644 index 0000000000000..a3758029d1383 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart @@ -0,0 +1,276 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_builder.dart'; +import 'card_cell.dart'; + +class TextCardCellStyle extends CardCellStyle { + TextCardCellStyle({ + required super.padding, + required this.textStyle, + required this.titleTextStyle, + this.maxLines = 1, + }); + + final TextStyle textStyle; + final TextStyle titleTextStyle; + final int? maxLines; +} + +class TextCardCell extends CardCell with EditableCell { + const TextCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + this.showNotes = false, + this.editableNotifier, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final bool showNotes; + + @override + final EditableCardNotifier? editableNotifier; + + @override + State createState() => _TextCellState(); +} + +class _TextCellState extends State { + late final cellBloc = TextCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + late final TextEditingController _textEditingController; + final focusNode = SingleListenerFocusNode(); + + @override + void initState() { + super.initState(); + _textEditingController = + TextEditingController(text: cellBloc.state.content); + + if (widget.editableNotifier?.isCellEditing.value ?? false) { + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + cellBloc.add(const TextCellEvent.enableEdit(true)); + }); + } + + // If the focusNode lost its focus, the widget's editableNotifier will + // set to false, which will cause the [EditableRowNotifier] to receive + // end edit event. + focusNode.addListener(_onFocusChanged); + _bindEditableNotifier(); + } + + void _onFocusChanged() { + if (!focusNode.hasFocus) { + widget.editableNotifier?.isCellEditing.value = false; + cellBloc.add(const TextCellEvent.enableEdit(false)); + cellBloc.add(TextCellEvent.updateText(_textEditingController.text)); + } + } + + void _bindEditableNotifier() { + widget.editableNotifier?.isCellEditing.addListener(() { + if (!mounted) { + return; + } + + final isEditing = widget.editableNotifier?.isCellEditing.value ?? false; + if (isEditing) { + WidgetsBinding.instance + .addPostFrameCallback((_) => focusNode.requestFocus()); + } + cellBloc.add(TextCellEvent.enableEdit(isEditing)); + }); + } + + @override + void didUpdateWidget(covariant oldWidget) { + if (oldWidget.editableNotifier != widget.editableNotifier) { + _bindEditableNotifier(); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + final isTitle = cellBloc.cellController.fieldInfo.isPrimary; + return BlocProvider.value( + value: cellBloc, + child: BlocListener( + listenWhen: (previous, current) => previous.content != current.content, + listener: (context, state) { + _textEditingController.text = state.content ?? ""; + }, + child: isTitle ? _buildTitle() : _buildText(), + ), + ); + } + + @override + void dispose() { + _textEditingController.dispose(); + widget.editableNotifier?.isCellEditing + .removeListener(_bindEditableNotifier); + focusNode.dispose(); + cellBloc.close(); + super.dispose(); + } + + Widget? _buildIcon(TextCellState state) { + if (state.emoji?.value.isNotEmpty ?? false) { + return FlowyText.emoji( + optimizeEmojiAlign: true, + state.emoji?.value ?? '', + ); + } + + if (widget.showNotes) { + return FlowyTooltip( + message: LocaleKeys.board_notesTooltip.tr(), + child: Padding( + padding: const EdgeInsets.all(1.0), + child: FlowySvg( + FlowySvgs.notes_s, + color: Theme.of(context).hintColor, + ), + ), + ); + } + return null; + } + + Widget _buildText() { + return BlocBuilder( + builder: (context, state) { + final content = state.content ?? ""; + + return content.isEmpty + ? const SizedBox.shrink() + : Container( + padding: widget.style.padding, + alignment: AlignmentDirectional.centerStart, + child: Text( + content, + style: widget.style.textStyle, + maxLines: widget.style.maxLines, + ), + ); + }, + ); + } + + Widget _buildTitle() { + final textField = _buildTextField(); + return BlocBuilder( + builder: (context, state) { + final icon = _buildIcon(state); + if (icon == null) { + return textField; + } + final resolved = + widget.style.padding.resolve(Directionality.of(context)); + final padding = EdgeInsetsDirectional.only( + start: resolved.left, + top: resolved.top, + bottom: resolved.bottom, + ); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: padding, + child: icon, + ), + Expanded(child: textField), + ], + ); + }, + ); + } + + Widget _buildTextField() { + return BlocSelector( + selector: (state) => state.enableEdit, + builder: (context, isEditing) { + return IgnorePointer( + ignoring: !isEditing, + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => + focusNode.unfocus(), + const SimpleActivator(LogicalKeyboardKey.enter): () => + focusNode.unfocus(), + }, + child: TextField( + controller: _textEditingController, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + maxLines: null, + minLines: 1, + textInputAction: TextInputAction.done, + readOnly: !isEditing, + enableInteractiveSelection: isEditing, + style: widget.style.titleTextStyle, + decoration: InputDecoration( + contentPadding: widget.style.padding, + border: InputBorder.none, + enabledBorder: InputBorder.none, + isDense: true, + isCollapsed: true, + hintText: LocaleKeys.grid_row_titlePlaceholder.tr(), + hintStyle: widget.style.titleTextStyle.copyWith( + color: Theme.of(context).hintColor, + ), + ), + onTapOutside: (_) {}, + ), + ), + ); + }, + ); + } +} + +class SimpleActivator with Diagnosticable implements ShortcutActivator { + const SimpleActivator( + this.trigger, { + this.includeRepeats = true, + }); + + final LogicalKeyboardKey trigger; + final bool includeRepeats; + + @override + bool accepts(KeyEvent event, HardwareKeyboard state) { + return (event is KeyDownEvent || + (includeRepeats && event is KeyRepeatEvent)) && + trigger == event.logicalKey; + } + + @override + String debugDescribeKeys() => + kDebugMode ? trigger.debugName ?? trigger.toStringShort() : ''; + + @override + Iterable? get triggers => [trigger]; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/time_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/time_card_cell.dart new file mode 100644 index 0000000000000..68a95e53e247f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/time_card_cell.dart @@ -0,0 +1,62 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; + +import 'card_cell.dart'; + +class TimeCardCellStyle extends CardCellStyle { + const TimeCardCellStyle({ + required super.padding, + required this.textStyle, + }); + + final TextStyle textStyle; +} + +class TimeCardCell extends CardCell { + const TimeCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _TimeCellState(); +} + +class _TimeCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) { + return TimeCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + }, + child: BlocBuilder( + buildWhen: (previous, current) => previous.content != current.content, + builder: (context, state) { + if (state.content.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + alignment: AlignmentDirectional.centerStart, + padding: widget.style.padding, + child: Text(state.content, style: widget.style.textStyle), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/timestamp_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/timestamp_card_cell.dart new file mode 100644 index 0000000000000..5e560bbee4f87 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/timestamp_card_cell.dart @@ -0,0 +1,65 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'card_cell.dart'; + +class TimestampCardCellStyle extends CardCellStyle { + TimestampCardCellStyle({ + required super.padding, + required this.textStyle, + }); + + final TextStyle textStyle; +} + +class TimestampCardCell extends CardCell { + const TimestampCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _TimestampCellState(); +} + +class _TimestampCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) { + return TimestampCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + }, + child: BlocBuilder( + buildWhen: (previous, current) => previous.dateStr != current.dateStr, + builder: (context, state) { + if (state.dateStr.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + alignment: AlignmentDirectional.centerStart, + padding: widget.style.padding, + child: Text( + state.dateStr, + style: widget.style.textStyle, + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart new file mode 100644 index 0000000000000..e9c233af18aff --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart @@ -0,0 +1,62 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'card_cell.dart'; + +class TranslateCardCellStyle extends CardCellStyle { + const TranslateCardCellStyle({ + required super.padding, + required this.textStyle, + }); + + final TextStyle textStyle; +} + +class TranslateCardCell extends CardCell { + const TranslateCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _TranslateCellState(); +} + +class _TranslateCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) { + return TranslateCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + }, + child: BlocBuilder( + buildWhen: (previous, current) => previous.content != current.content, + builder: (context, state) { + if (state.content.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + alignment: AlignmentDirectional.centerStart, + padding: widget.style.padding, + child: Text(state.content, style: widget.style.textStyle), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/url_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/url_card_cell.dart new file mode 100644 index 0000000000000..92b210d184434 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/url_card_cell.dart @@ -0,0 +1,64 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'card_cell.dart'; + +class URLCardCellStyle extends CardCellStyle { + URLCardCellStyle({ + required super.padding, + required this.textStyle, + }); + + final TextStyle textStyle; +} + +class URLCardCell extends CardCell { + const URLCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _URLCellState(); +} + +class _URLCellState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) { + return URLCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + }, + child: BlocBuilder( + buildWhen: (previous, current) => previous.content != current.content, + builder: (context, state) { + if (state.content.isEmpty) { + return const SizedBox.shrink(); + } + return Container( + alignment: AlignmentDirectional.centerStart, + padding: widget.style.padding, + child: Text( + state.content, + style: widget.style.textStyle, + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart new file mode 100644 index 0000000000000..23a8a2451feb9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +import '../card_cell_builder.dart'; +import '../card_cell_skeleton/checkbox_card_cell.dart'; +import '../card_cell_skeleton/checklist_card_cell.dart'; +import '../card_cell_skeleton/date_card_cell.dart'; +import '../card_cell_skeleton/media_card_cell.dart'; +import '../card_cell_skeleton/number_card_cell.dart'; +import '../card_cell_skeleton/relation_card_cell.dart'; +import '../card_cell_skeleton/select_option_card_cell.dart'; +import '../card_cell_skeleton/summary_card_cell.dart'; +import '../card_cell_skeleton/text_card_cell.dart'; +import '../card_cell_skeleton/timestamp_card_cell.dart'; +import '../card_cell_skeleton/translate_card_cell.dart'; +import '../card_cell_skeleton/url_card_cell.dart'; + +CardCellStyleMap desktopCalendarCardCellStyleMap(BuildContext context) { + const EdgeInsetsGeometry padding = EdgeInsets.symmetric(vertical: 2); + final TextStyle textStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 10, + overflow: TextOverflow.ellipsis, + fontWeight: FontWeight.w400, + ); + + return { + FieldType.Checkbox: CheckboxCardCellStyle( + padding: padding, + iconSize: const Size.square(16), + showFieldName: true, + textStyle: textStyle, + ), + FieldType.Checklist: ChecklistCardCellStyle( + padding: padding, + textStyle: textStyle.copyWith(color: Theme.of(context).hintColor), + ), + FieldType.CreatedTime: TimestampCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.DateTime: DateCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.LastEditedTime: TimestampCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.MultiSelect: SelectOptionCardCellStyle( + padding: padding, + tagFontSize: 9, + wrap: true, + tagPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + ), + FieldType.Number: NumberCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.RichText: TextCardCellStyle( + padding: padding, + textStyle: textStyle, + titleTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 11, + overflow: TextOverflow.ellipsis, + ), + ), + FieldType.SingleSelect: SelectOptionCardCellStyle( + padding: padding, + tagFontSize: 9, + wrap: true, + tagPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + ), + FieldType.URL: URLCardCellStyle( + padding: padding, + textStyle: textStyle.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + FieldType.Relation: RelationCardCellStyle( + padding: padding, + wrap: true, + textStyle: textStyle, + ), + FieldType.Summary: SummaryCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.Translate: TranslateCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.Media: MediaCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart new file mode 100644 index 0000000000000..6264fea958a2b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +import '../card_cell_builder.dart'; +import '../card_cell_skeleton/checkbox_card_cell.dart'; +import '../card_cell_skeleton/checklist_card_cell.dart'; +import '../card_cell_skeleton/date_card_cell.dart'; +import '../card_cell_skeleton/media_card_cell.dart'; +import '../card_cell_skeleton/number_card_cell.dart'; +import '../card_cell_skeleton/relation_card_cell.dart'; +import '../card_cell_skeleton/select_option_card_cell.dart'; +import '../card_cell_skeleton/summary_card_cell.dart'; +import '../card_cell_skeleton/text_card_cell.dart'; +import '../card_cell_skeleton/time_card_cell.dart'; +import '../card_cell_skeleton/timestamp_card_cell.dart'; +import '../card_cell_skeleton/translate_card_cell.dart'; +import '../card_cell_skeleton/url_card_cell.dart'; + +CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) { + const EdgeInsetsGeometry padding = EdgeInsets.all(4); + final TextStyle textStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 11, + overflow: TextOverflow.ellipsis, + ); + + return { + FieldType.Checkbox: CheckboxCardCellStyle( + padding: padding, + iconSize: const Size.square(16), + showFieldName: true, + textStyle: textStyle, + ), + FieldType.Checklist: ChecklistCardCellStyle( + padding: padding, + textStyle: textStyle.copyWith(color: Theme.of(context).hintColor), + ), + FieldType.CreatedTime: TimestampCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.DateTime: DateCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.LastEditedTime: TimestampCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.MultiSelect: SelectOptionCardCellStyle( + padding: padding, + tagFontSize: 11, + wrap: true, + tagPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + ), + FieldType.Number: NumberCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.RichText: TextCardCellStyle( + padding: padding, + textStyle: textStyle, + maxLines: 2, + titleTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + overflow: TextOverflow.ellipsis, + ), + ), + FieldType.SingleSelect: SelectOptionCardCellStyle( + padding: padding, + tagFontSize: 11, + wrap: true, + tagPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + ), + FieldType.URL: URLCardCellStyle( + padding: padding, + textStyle: textStyle.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + FieldType.Relation: RelationCardCellStyle( + padding: padding, + wrap: true, + textStyle: textStyle, + ), + FieldType.Summary: SummaryCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.Time: TimeCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.Translate: TranslateCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.Media: MediaCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart new file mode 100644 index 0000000000000..93d98f013ee77 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +import '../card_cell_builder.dart'; +import '../card_cell_skeleton/checkbox_card_cell.dart'; +import '../card_cell_skeleton/checklist_card_cell.dart'; +import '../card_cell_skeleton/date_card_cell.dart'; +import '../card_cell_skeleton/number_card_cell.dart'; +import '../card_cell_skeleton/relation_card_cell.dart'; +import '../card_cell_skeleton/select_option_card_cell.dart'; +import '../card_cell_skeleton/text_card_cell.dart'; +import '../card_cell_skeleton/time_card_cell.dart'; +import '../card_cell_skeleton/timestamp_card_cell.dart'; +import '../card_cell_skeleton/url_card_cell.dart'; + +CardCellStyleMap mobileBoardCardCellStyleMap(BuildContext context) { + const EdgeInsetsGeometry padding = EdgeInsets.all(4); + final TextStyle textStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 14, + overflow: TextOverflow.ellipsis, + fontWeight: FontWeight.w400, + ); + + return { + FieldType.Checkbox: CheckboxCardCellStyle( + padding: padding, + iconSize: const Size.square(24), + showFieldName: true, + textStyle: textStyle, + ), + FieldType.Checklist: ChecklistCardCellStyle( + padding: padding, + textStyle: textStyle.copyWith(color: Theme.of(context).hintColor), + ), + FieldType.CreatedTime: TimestampCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.DateTime: DateCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.LastEditedTime: TimestampCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.MultiSelect: SelectOptionCardCellStyle( + padding: padding, + tagFontSize: 12, + wrap: true, + tagPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + FieldType.Number: NumberCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.RichText: TextCardCellStyle( + padding: padding, + textStyle: textStyle, + titleTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + overflow: TextOverflow.ellipsis, + ), + ), + FieldType.SingleSelect: SelectOptionCardCellStyle( + padding: padding, + tagFontSize: 12, + wrap: true, + tagPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + FieldType.URL: URLCardCellStyle( + padding: padding, + textStyle: textStyle.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + FieldType.Relation: RelationCardCellStyle( + padding: padding, + textStyle: textStyle, + wrap: true, + ), + FieldType.Summary: SummaryCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.Time: TimeCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.Translate: TranslateCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + FieldType.Media: MediaCardCellStyle( + padding: padding, + textStyle: textStyle, + ), + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checkbox_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checkbox_cell.dart new file mode 100644 index 0000000000000..befe49dd6d53f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checkbox_cell.dart @@ -0,0 +1,33 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/checkbox.dart'; + +class DesktopGridCheckboxCellSkin extends IEditableCheckboxCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + CheckboxCellBloc bloc, + CheckboxCellState state, + ) { + return Container( + alignment: AlignmentDirectional.centerStart, + padding: GridSize.cellContentInsets, + child: FlowyIconButton( + hoverColor: Colors.transparent, + onPressed: () => bloc.add(const CheckboxCellEvent.select()), + icon: FlowySvg( + state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, + size: const Size.square(20), + ), + width: 20, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checklist_cell.dart new file mode 100644 index 0000000000000..f5ad4f397074b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_checklist_cell.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/checklist.dart'; + +class DesktopGridChecklistCellSkin extends IEditableChecklistCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + ChecklistCellBloc bloc, + PopoverController popoverController, + ) { + return AppFlowyPopover( + margin: EdgeInsets.zero, + controller: popoverController, + constraints: BoxConstraints.loose(const Size(360, 400)), + direction: PopoverDirection.bottomWithLeftAligned, + triggerActions: PopoverTriggerFlags.none, + skipTraversal: true, + popupBuilder: (popoverContext) { + WidgetsBinding.instance.addPostFrameCallback((_) { + cellContainerNotifier.isFocus = true; + }); + return BlocProvider.value( + value: bloc, + child: ChecklistCellEditor( + cellController: bloc.cellController, + ), + ); + }, + onClose: () => cellContainerNotifier.isFocus = false, + child: BlocBuilder( + builder: (context, state) { + return Container( + alignment: AlignmentDirectional.centerStart, + padding: GridSize.cellContentInsets, + child: state.tasks.isEmpty + ? const SizedBox.shrink() + : ChecklistProgressBar( + tasks: state.tasks, + percent: state.percent, + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_date_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_date_cell.dart new file mode 100644 index 0000000000000..21bbee23ff797 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_date_cell.dart @@ -0,0 +1,79 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/date_cell_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/widgets.dart'; + +import '../editable_cell_skeleton/date.dart'; + +class DesktopGridDateCellSkin extends IEditableDateCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + DateCellBloc bloc, + DateCellState state, + PopoverController popoverController, + ) { + return AppFlowyPopover( + controller: popoverController, + triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: BoxConstraints.loose(const Size(260, 620)), + margin: EdgeInsets.zero, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: state.fieldInfo.wrapCellContent ?? false + ? _buildCellContent(state) + : SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + child: _buildCellContent(state), + ), + ), + popupBuilder: (BuildContext popoverContent) { + return DateCellEditor( + cellController: bloc.cellController, + onDismissed: () => cellContainerNotifier.isFocus = false, + ); + }, + onClose: () { + cellContainerNotifier.isFocus = false; + }, + ); + } + + Widget _buildCellContent(DateCellState state) { + final wrap = state.fieldInfo.wrapCellContent ?? false; + final dateStr = getDateCellStrFromCellData( + state.fieldInfo, + state.cellData, + ); + return Padding( + padding: GridSize.cellContentInsets, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: FlowyText( + dateStr, + overflow: wrap ? null : TextOverflow.ellipsis, + maxLines: wrap ? null : 1, + ), + ), + if (state.cellData.reminderId.isNotEmpty) ...[ + const HSpace(4), + FlowyTooltip( + message: LocaleKeys.grid_field_reminderOnDateTooltip.tr(), + child: const FlowySvg(FlowySvgs.clock_alarm_s), + ), + ], + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart new file mode 100644 index 0000000000000..f66d106ed0404 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart @@ -0,0 +1,265 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/shared/af_image.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class GridMediaCellSkin extends IEditableMediaCellSkin { + const GridMediaCellSkin({this.isMobileRowDetail = false}); + + final bool isMobileRowDetail; + + @override + void dispose() {} + + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + PopoverController popoverController, + MediaCellBloc bloc, + ) { + final isMobile = UniversalPlatform.isMobile; + + Widget child = BlocBuilder( + builder: (context, state) { + final wrapContent = context.read().wrapContent; + final List children = state.files + .map( + (file) => GestureDetector( + onTap: () => _openOrExpandFile(context, file, state.files), + child: Padding( + padding: wrapContent + ? const EdgeInsets.only(right: 4) + : EdgeInsets.zero, + child: _FilePreviewRender(file: file), + ), + ), + ) + .toList(); + + if (isMobileRowDetail && state.files.isEmpty) { + children.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + LocaleKeys.grid_row_textPlaceholder.tr(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 16, + color: Theme.of(context).hintColor, + ), + ), + ), + ); + } + + if (!isMobile && wrapContent) { + return Padding( + padding: const EdgeInsets.all(4), + child: SizedBox( + width: double.infinity, + child: Wrap( + runSpacing: 4, + children: children, + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: SizedBox( + width: double.infinity, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SeparatedRow( + separatorBuilder: () => const HSpace(4), + children: children..add(const SizedBox(width: 16)), + ), + ), + ), + ); + }, + ); + + if (!isMobile) { + child = AppFlowyPopover( + controller: popoverController, + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + maxHeight: 400, + ), + margin: EdgeInsets.zero, + triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: const MediaCellEditor(), + ), + onClose: () => cellContainerNotifier.isFocus = false, + child: child, + ); + } else { + child = Align( + alignment: AlignmentDirectional.centerStart, + child: child, + ); + + if (isMobileRowDetail) { + child = Container( + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), + ), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + alignment: AlignmentDirectional.centerStart, + child: child, + ); + } + + child = InkWell( + borderRadius: + isMobileRowDetail ? BorderRadius.circular(12) : BorderRadius.zero, + onTap: () => _tapCellMobile(context), + hoverColor: Colors.transparent, + child: child, + ); + } + + return BlocProvider.value( + value: bloc, + child: Builder(builder: (context) => child), + ); + } + + void _openOrExpandFile( + BuildContext context, + MediaFilePB file, + List files, + ) { + if (file.fileType != MediaFileTypePB.Image) { + afLaunchUrlString(file.url, context: context); + return; + } + + final images = + files.where((f) => f.fileType == MediaFileTypePB.Image).toList(); + final index = images.indexOf(file); + + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfile, + imageProvider: AFBlockImageProvider( + initialIndex: index, + images: images + .map( + (e) => ImageBlockData( + url: e.url, + type: e.uploadType.toCustomImageType(), + ), + ) + .toList(), + onDeleteImage: (index) { + final deleteFile = images[index]; + context.read().deleteFile(deleteFile.id); + }, + ), + ), + ); + } + + void _tapCellMobile(BuildContext context) { + final files = context.read().state.files; + + if (files.isEmpty) { + showMobileBottomSheet( + context, + title: LocaleKeys.grid_media_addFileMobile.tr(), + showHeader: true, + showCloseButton: true, + showDragHandle: true, + builder: (dContext) => BlocProvider.value( + value: context.read(), + child: MobileMediaUploadSheetContent( + dialogContext: dContext, + ), + ), + ); + return; + } + + showMobileBottomSheet( + context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: const MobileMediaCellEditor(), + ), + ); + } +} + +class _FilePreviewRender extends StatelessWidget { + const _FilePreviewRender({required this.file}); + + final MediaFilePB file; + + @override + Widget build(BuildContext context) { + if (file.fileType != MediaFileTypePB.Image) { + return FlowyTooltip( + message: file.name, + child: Container( + height: 28, + width: 28, + clipBehavior: Clip.antiAlias, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + borderRadius: BorderRadius.circular(4), + ), + child: FlowySvg( + file.fileType.icon, + size: const Size.square(12), + color: AFThemeExtension.of(context).textColor, + ), + ), + ); + } + + return FlowyTooltip( + message: file.name, + child: Container( + height: 28, + width: 28, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(4)), + child: AFImage( + url: file.url, + uploadType: file.uploadType, + userProfile: context.read().state.userProfile, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_number_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_number_cell.dart new file mode 100644 index 0000000000000..04368bc725f47 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_number_cell.dart @@ -0,0 +1,37 @@ +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/number.dart'; + +class DesktopGridNumberCellSkin extends IEditableNumberCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + NumberCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + maxLines: context.watch().state.wrap ? null : 1, + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + contentPadding: GridSize.cellContentInsets, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart new file mode 100644 index 0000000000000..f4b8fb97f014b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_relation_cell.dart @@ -0,0 +1,95 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/relation.dart'; + +class DesktopGridRelationCellSkin extends IEditableRelationCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + RelationCellBloc bloc, + RelationCellState state, + PopoverController popoverController, + ) { + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400), + margin: EdgeInsets.zero, + onClose: () => cellContainerNotifier.isFocus = false, + popupBuilder: (context) { + return BlocProvider.value( + value: bloc, + child: const RelationCellEditor(), + ); + }, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: state.wrap + ? _buildWrapRows(context, state.rows) + : _buildNoWrapRows(context, state.rows), + ), + ); + } + + Widget _buildWrapRows( + BuildContext context, + List rows, + ) { + return Padding( + padding: GridSize.cellContentInsets, + child: Wrap( + runSpacing: 4, + spacing: 4.0, + children: rows.map( + (row) { + final isEmpty = row.name.isEmpty; + return FlowyText( + isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, + color: isEmpty ? Theme.of(context).hintColor : null, + decoration: TextDecoration.underline, + overflow: TextOverflow.ellipsis, + ); + }, + ).toList(), + ), + ); + } + + Widget _buildNoWrapRows( + BuildContext context, + List rows, + ) { + return SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + child: Padding( + padding: GridSize.cellContentInsets, + child: SeparatedRow( + separatorBuilder: () => const HSpace(4.0), + mainAxisSize: MainAxisSize.min, + children: rows.map( + (row) { + final isEmpty = row.name.isEmpty; + return FlowyText( + isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, + color: isEmpty ? Theme.of(context).hintColor : null, + decoration: TextDecoration.underline, + overflow: TextOverflow.ellipsis, + ); + }, + ).toList(), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_select_option_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_select_option_cell.dart new file mode 100644 index 0000000000000..8cebb2d77a304 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_select_option_cell.dart @@ -0,0 +1,98 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/select_option.dart'; + +class DesktopGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + SelectOptionCellBloc bloc, + PopoverController popoverController, + ) { + return AppFlowyPopover( + controller: popoverController, + constraints: const BoxConstraints.tightFor(width: 300), + margin: EdgeInsets.zero, + triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (BuildContext popoverContext) { + return SelectOptionCellEditor( + cellController: bloc.cellController, + ); + }, + onClose: () => cellContainerNotifier.isFocus = false, + child: BlocBuilder( + builder: (context, state) { + return Align( + alignment: AlignmentDirectional.centerStart, + child: state.wrap + ? _buildWrapOptions(context, state.selectedOptions) + : _buildNoWrapOptions(context, state.selectedOptions), + ); + }, + ), + ); + } + + Widget _buildWrapOptions(BuildContext context, List options) { + return Padding( + padding: GridSize.cellContentInsets, + child: Wrap( + runSpacing: 4, + children: options.map( + (option) { + return Padding( + padding: const EdgeInsets.only(right: 4), + child: SelectOptionTag( + option: option, + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 8, + ), + ), + ); + }, + ).toList(), + ), + ); + } + + Widget _buildNoWrapOptions( + BuildContext context, + List options, + ) { + return SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + child: Padding( + padding: GridSize.cellContentInsets, + child: Row( + mainAxisSize: MainAxisSize.min, + children: options.map( + (option) { + return Padding( + padding: const EdgeInsets.only(right: 4), + child: SelectOptionTag( + option: option, + padding: const EdgeInsets.symmetric( + vertical: 1, + horizontal: 8, + ), + ), + ); + }, + ).toList(), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart new file mode 100644 index 0000000000000..b5d915b022a43 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart @@ -0,0 +1,102 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class DesktopGridSummaryCellSkin extends IEditableSummaryCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + SummaryCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return ChangeNotifierProvider( + create: (_) => SummaryMouseNotifier(), + builder: (context, child) { + return MouseRegion( + cursor: SystemMouseCursors.click, + opaque: false, + onEnter: (p) => + Provider.of(context, listen: false) + .onEnter = true, + onExit: (p) => + Provider.of(context, listen: false) + .onEnter = false, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: GridSize.headerHeight, + ), + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: TextField( + controller: textEditingController, + enabled: false, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + maxLines: null, + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + contentPadding: GridSize.cellContentInsets, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ), + ), + Padding( + padding: EdgeInsets.symmetric( + horizontal: GridSize.cellVPadding, + ), + child: Consumer( + builder: ( + BuildContext context, + SummaryMouseNotifier notifier, + Widget? child, + ) { + if (notifier.onEnter) { + return SummaryCellAccessory( + viewId: bloc.cellController.viewId, + fieldId: bloc.cellController.fieldId, + rowId: bloc.cellController.rowId, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + ).positioned(right: 0, bottom: 8), + ], + ), + ), + ); + }, + ); + } +} + +class SummaryMouseNotifier extends ChangeNotifier { + SummaryMouseNotifier(); + + bool _onEnter = false; + + set onEnter(bool value) { + if (_onEnter != value) { + _onEnter = value; + notifyListeners(); + } + } + + bool get onEnter => _onEnter; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_text_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_text_cell.dart new file mode 100644 index 0000000000000..f28cb756c84ec --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_text_cell.dart @@ -0,0 +1,101 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/text.dart'; + +class DesktopGridTextCellSkin extends IEditableTextCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TextCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return Padding( + padding: GridSize.cellContentInsets, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _IconOrEmoji(), + Expanded( + child: TextField( + controller: textEditingController, + focusNode: focusNode, + maxLines: context.watch().state.wrap ? null : 1, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: context + .read() + .cellController + .fieldInfo + .isPrimary + ? FontWeight.w500 + : null, + ), + decoration: const InputDecoration( + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + isCollapsed: true, + ), + ), + ), + ], + ), + ); + } +} + +class _IconOrEmoji extends StatelessWidget { + const _IconOrEmoji(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + // if not a title cell, return empty widget + if (state.emoji == null || state.hasDocument == null) { + return const SizedBox.shrink(); + } + + return ValueListenableBuilder( + valueListenable: state.emoji!, + builder: (context, emoji, _) { + return emoji.isNotEmpty + ? Padding( + padding: const EdgeInsetsDirectional.only(end: 6.0), + child: FlowyText.emoji( + optimizeEmojiAlign: true, + emoji, + ), + ) + : ValueListenableBuilder( + valueListenable: state.hasDocument!, + builder: (context, hasDocument, _) { + return hasDocument + ? Padding( + padding: + const EdgeInsetsDirectional.only(end: 6.0) + .add(const EdgeInsets.all(1)), + child: FlowySvg( + FlowySvgs.notes_s, + color: Theme.of(context).hintColor, + ), + ) + : const SizedBox.shrink(); + }, + ); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_time_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_time_cell.dart new file mode 100644 index 0000000000000..a948e92b03cb8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_time_cell.dart @@ -0,0 +1,37 @@ +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/time.dart'; + +class DesktopGridTimeCellSkin extends IEditableTimeCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimeCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + maxLines: context.watch().state.wrap ? null : 1, + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + contentPadding: GridSize.cellContentInsets, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_timestamp_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_timestamp_cell.dart new file mode 100644 index 0000000000000..a1690310d4a8f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_timestamp_cell.dart @@ -0,0 +1,39 @@ +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/widgets.dart'; + +import '../editable_cell_skeleton/timestamp.dart'; + +class DesktopGridTimestampCellSkin extends IEditableTimestampCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimestampCellBloc bloc, + TimestampCellState state, + ) { + return Container( + alignment: AlignmentDirectional.centerStart, + child: state.wrap + ? _buildCellContent(state) + : SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + child: _buildCellContent(state), + ), + ); + } + + Widget _buildCellContent(TimestampCellState state) { + return Padding( + padding: GridSize.cellContentInsets, + child: FlowyText( + state.dateStr, + overflow: state.wrap ? null : TextOverflow.ellipsis, + maxLines: state.wrap ? null : 1, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart new file mode 100644 index 0000000000000..aece28373cf1a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart @@ -0,0 +1,102 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class DesktopGridTranslateCellSkin extends IEditableTranslateCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TranslateCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return ChangeNotifierProvider( + create: (_) => TranslateMouseNotifier(), + builder: (context, child) { + return MouseRegion( + cursor: SystemMouseCursors.click, + opaque: false, + onEnter: (p) => + Provider.of(context, listen: false) + .onEnter = true, + onExit: (p) => + Provider.of(context, listen: false) + .onEnter = false, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: GridSize.headerHeight, + ), + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: TextField( + controller: textEditingController, + readOnly: true, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + maxLines: null, + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + contentPadding: GridSize.cellContentInsets, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ), + ), + Padding( + padding: EdgeInsets.symmetric( + horizontal: GridSize.cellVPadding, + ), + child: Consumer( + builder: ( + BuildContext context, + TranslateMouseNotifier notifier, + Widget? child, + ) { + if (notifier.onEnter) { + return TranslateCellAccessory( + viewId: bloc.cellController.viewId, + fieldId: bloc.cellController.fieldId, + rowId: bloc.cellController.rowId, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + ).positioned(right: 0, bottom: 8), + ], + ), + ), + ); + }, + ); + } +} + +class TranslateMouseNotifier extends ChangeNotifier { + TranslateMouseNotifier(); + + bool _onEnter = false; + + set onEnter(bool value) { + if (_onEnter != value) { + _onEnter = value; + notifyListeners(); + } + } + + bool get onEnter => _onEnter; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart new file mode 100644 index 0000000000000..17a3519d3d9a8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart @@ -0,0 +1,217 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/url.dart'; + +class DesktopGridURLSkin extends IEditableURLCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + URLCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + URLCellDataNotifier cellDataNotifier, + ) { + return BlocSelector( + selector: (state) => state.wrap, + builder: (context, wrap) => TextField( + controller: textEditingController, + focusNode: focusNode, + maxLines: wrap ? null : 1, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + decoration: InputDecoration( + contentPadding: GridSize.cellContentInsets, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + hintStyle: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Theme.of(context).hintColor), + isDense: true, + ), + onTapOutside: (_) => focusNode.unfocus(), + ), + ); + } + + @override + List accessoryBuilder( + GridCellAccessoryBuildContext context, + URLCellDataNotifier cellDataNotifier, + ) { + return [ + accessoryFromType( + GridURLCellAccessoryType.visitURL, + cellDataNotifier, + ), + accessoryFromType( + GridURLCellAccessoryType.copyURL, + cellDataNotifier, + ), + ]; + } +} + +GridCellAccessoryBuilder accessoryFromType( + GridURLCellAccessoryType ty, + URLCellDataNotifier cellDataNotifier, +) { + switch (ty) { + case GridURLCellAccessoryType.visitURL: + return VisitURLCellAccessoryBuilder( + builder: (Key key) => _VisitURLAccessory( + key: key, + cellDataNotifier: cellDataNotifier, + ), + ); + case GridURLCellAccessoryType.copyURL: + return CopyURLCellAccessoryBuilder( + builder: (Key key) => _CopyURLAccessory( + key: key, + cellDataNotifier: cellDataNotifier, + ), + ); + } +} + +enum GridURLCellAccessoryType { + copyURL, + visitURL, +} + +typedef CopyURLCellAccessoryBuilder + = GridCellAccessoryBuilder>; + +class _CopyURLAccessory extends StatefulWidget { + const _CopyURLAccessory({ + super.key, + required this.cellDataNotifier, + }); + + final URLCellDataNotifier cellDataNotifier; + + @override + State<_CopyURLAccessory> createState() => _CopyURLAccessoryState(); +} + +class _CopyURLAccessoryState extends State<_CopyURLAccessory> + with GridCellAccessoryState { + @override + Widget build(BuildContext context) { + if (widget.cellDataNotifier.value.isNotEmpty) { + return FlowyTooltip( + message: LocaleKeys.grid_url_copy.tr(), + preferBelow: false, + child: _URLAccessoryIconContainer( + child: FlowySvg( + FlowySvgs.copy_s, + color: AFThemeExtension.of(context).textColor, + ), + ), + ); + } else { + return const SizedBox.shrink(); + } + } + + @override + void onTap() { + final content = widget.cellDataNotifier.value; + if (content.isEmpty) { + return; + } + Clipboard.setData(ClipboardData(text: content)); + showMessageToast(LocaleKeys.grid_row_copyProperty.tr()); + } +} + +typedef VisitURLCellAccessoryBuilder + = GridCellAccessoryBuilder>; + +class _VisitURLAccessory extends StatefulWidget { + const _VisitURLAccessory({ + super.key, + required this.cellDataNotifier, + }); + + final URLCellDataNotifier cellDataNotifier; + + @override + State<_VisitURLAccessory> createState() => _VisitURLAccessoryState(); +} + +class _VisitURLAccessoryState extends State<_VisitURLAccessory> + with GridCellAccessoryState { + @override + Widget build(BuildContext context) { + if (widget.cellDataNotifier.value.isNotEmpty) { + return FlowyTooltip( + message: LocaleKeys.grid_url_launch.tr(), + preferBelow: false, + child: _URLAccessoryIconContainer( + child: FlowySvg( + FlowySvgs.url_s, + color: AFThemeExtension.of(context).textColor, + ), + ), + ); + } else { + return const SizedBox.shrink(); + } + } + + @override + bool enable() => widget.cellDataNotifier.value.isNotEmpty; + + @override + void onTap() => openUrlCellLink(widget.cellDataNotifier.value); +} + +class _URLAccessoryIconContainer extends StatelessWidget { + const _URLAccessoryIconContainer({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + width: 26, + height: 26, + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).dividerColor), + ), + borderRadius: Corners.s6Border, + ), + child: FlowyHover( + style: HoverStyle( + backgroundColor: AFThemeExtension.of(context).background, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + child: Center( + child: child, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checkbox_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checkbox_cell.dart new file mode 100644 index 0000000000000..bb15cd5d9f52b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checkbox_cell.dart @@ -0,0 +1,32 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/checkbox.dart'; + +class DesktopRowDetailCheckboxCellSkin extends IEditableCheckboxCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + CheckboxCellBloc bloc, + CheckboxCellState state, + ) { + return Container( + alignment: AlignmentDirectional.centerStart, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + child: FlowyIconButton( + hoverColor: Colors.transparent, + onPressed: () => bloc.add(const CheckboxCellEvent.select()), + icon: FlowySvg( + state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, + size: const Size.square(20), + ), + width: 20, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart new file mode 100644 index 0000000000000..d7e1d64fc3479 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart @@ -0,0 +1,452 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +import '../editable_cell_skeleton/checklist.dart'; + +class DesktopRowDetailChecklistCellSkin extends IEditableChecklistCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + ChecklistCellBloc bloc, + PopoverController popoverController, + ) { + return ChecklistRowDetailCell( + context: context, + cellContainerNotifier: cellContainerNotifier, + bloc: bloc, + popoverController: popoverController, + ); + } +} + +class ChecklistRowDetailCell extends StatefulWidget { + const ChecklistRowDetailCell({ + super.key, + required this.context, + required this.cellContainerNotifier, + required this.bloc, + required this.popoverController, + }); + + final BuildContext context; + final CellContainerNotifier cellContainerNotifier; + final ChecklistCellBloc bloc; + final PopoverController popoverController; + + @override + State createState() => _ChecklistRowDetailCellState(); +} + +class _ChecklistRowDetailCellState extends State { + final phantomTextController = TextEditingController(); + + @override + void dispose() { + phantomTextController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: AlignmentDirectional.centerStart, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ProgressAndHideCompleteButton( + onToggleHideComplete: () => context + .read() + .add(const ChecklistCellEvent.toggleShowIncompleteOnly()), + ), + const VSpace(2.0), + _ChecklistItems( + phantomTextController: phantomTextController, + onStartCreatingTaskAfter: (index) { + context + .read() + .add(ChecklistCellEvent.updatePhantomIndex(index + 1)); + }, + ), + ChecklistItemControl( + cellNotifer: widget.cellContainerNotifier, + onTap: () { + final bloc = context.read(); + if (bloc.state.phantomIndex == null) { + bloc.add( + ChecklistCellEvent.updatePhantomIndex( + bloc.state.showIncompleteOnly + ? bloc.state.tasks + .where((task) => !task.isSelected) + .length + : bloc.state.tasks.length, + ), + ); + } else { + bloc.add( + ChecklistCellEvent.createNewTask( + phantomTextController.text, + index: bloc.state.phantomIndex, + ), + ); + } + phantomTextController.clear(); + }, + ), + ], + ), + ); + } +} + +@visibleForTesting +class ProgressAndHideCompleteButton extends StatelessWidget { + const ProgressAndHideCompleteButton({ + super.key, + required this.onToggleHideComplete, + }); + + final VoidCallback onToggleHideComplete; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.showIncompleteOnly != current.showIncompleteOnly, + builder: (context, state) { + return Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: BlocBuilder( + builder: (context, state) { + return ChecklistProgressBar( + tasks: state.tasks, + percent: state.percent, + ); + }, + ), + ), + const HSpace(6.0), + FlowyIconButton( + tooltipText: state.showIncompleteOnly + ? LocaleKeys.grid_checklist_showComplete.tr() + : LocaleKeys.grid_checklist_hideComplete.tr(), + width: 32, + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + icon: FlowySvg( + state.showIncompleteOnly + ? FlowySvgs.show_m + : FlowySvgs.hide_m, + size: const Size.square(16), + ), + onPressed: onToggleHideComplete, + ), + ], + ), + ); + }, + ); + } +} + +class _ChecklistItems extends StatelessWidget { + const _ChecklistItems({ + required this.phantomTextController, + required this.onStartCreatingTaskAfter, + }); + + final TextEditingController phantomTextController; + final void Function(int index) onStartCreatingTaskAfter; + + @override + Widget build(BuildContext context) { + return Actions( + actions: { + _CancelCreatingFromPhantomIntent: + CallbackAction<_CancelCreatingFromPhantomIntent>( + onInvoke: (_CancelCreatingFromPhantomIntent intent) { + phantomTextController.clear(); + context + .read() + .add(const ChecklistCellEvent.updatePhantomIndex(null)); + return; + }, + ), + }, + child: BlocBuilder( + builder: (context, state) { + final children = _makeChildren(context, state); + return ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + proxyDecorator: (child, index, _) => Material( + color: Colors.transparent, + child: Stack( + children: [ + BlocProvider.value( + value: context.read(), + child: child, + ), + MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: const SizedBox.expand(), + ), + ], + ), + ), + buildDefaultDragHandles: false, + itemCount: children.length, + itemBuilder: (_, index) => children[index], + onReorder: (from, to) { + context + .read() + .add(ChecklistCellEvent.reorderTask(from, to)); + }, + ); + }, + ), + ); + } + + List _makeChildren(BuildContext context, ChecklistCellState state) { + final children = []; + + final tasks = [...state.tasks]; + + if (state.showIncompleteOnly) { + tasks.removeWhere((task) => task.isSelected); + } + + children.addAll( + tasks.mapIndexed( + (index, task) => Padding( + key: ValueKey('checklist_row_detail_cell_task_${task.data.id}'), + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: ChecklistItem( + task: task, + index: index, + onSubmitted: () { + onStartCreatingTaskAfter(index); + }, + ), + ), + ), + ); + + if (state.phantomIndex != null) { + children.insert( + state.phantomIndex!, + Padding( + key: const ValueKey('new_checklist_cell_task'), + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: PhantomChecklistItem( + index: state.phantomIndex!, + textController: phantomTextController, + ), + ), + ); + } + + return children; + } +} + +class _CancelCreatingFromPhantomIntent extends Intent { + const _CancelCreatingFromPhantomIntent(); +} + +class _SubmitPhantomTaskIntent extends Intent { + const _SubmitPhantomTaskIntent({ + required this.taskDescription, + required this.index, + }); + + final String taskDescription; + final int index; +} + +@visibleForTesting +class PhantomChecklistItem extends StatefulWidget { + const PhantomChecklistItem({ + super.key, + required this.index, + required this.textController, + }); + + final int index; + final TextEditingController textController; + + @override + State createState() => _PhantomChecklistItemState(); +} + +class _PhantomChecklistItemState extends State { + final focusNode = FocusNode(); + + bool isComposing = false; + + @override + void initState() { + super.initState(); + widget.textController.addListener(_onTextChanged); + focusNode.addListener(_onFocusChanged); + WidgetsBinding.instance + .addPostFrameCallback((_) => focusNode.requestFocus()); + } + + void _onTextChanged() => setState( + () => isComposing = !widget.textController.value.composing.isCollapsed, + ); + + void _onFocusChanged() { + if (!focusNode.hasFocus) { + widget.textController.clear(); + Actions.maybeInvoke( + context, + const _CancelCreatingFromPhantomIntent(), + ); + } + } + + @override + void dispose() { + widget.textController.removeListener(_onTextChanged); + focusNode.removeListener(_onFocusChanged); + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Actions( + actions: { + _SubmitPhantomTaskIntent: CallbackAction<_SubmitPhantomTaskIntent>( + onInvoke: (_SubmitPhantomTaskIntent intent) { + context.read().add( + ChecklistCellEvent.createNewTask( + intent.taskDescription, + index: intent.index, + ), + ); + widget.textController.clear(); + return; + }, + ), + }, + child: Shortcuts( + shortcuts: _buildShortcuts(), + child: Container( + constraints: const BoxConstraints(minHeight: 32), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).lightGreyHover, + borderRadius: Corners.s6Border, + ), + child: Center( + child: ChecklistCellTextfield( + textController: widget.textController, + focusNode: focusNode, + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + ), + ), + ), + ), + ); + } + + Map _buildShortcuts() { + return isComposing + ? const {} + : { + const SingleActivator(LogicalKeyboardKey.enter): + _SubmitPhantomTaskIntent( + taskDescription: widget.textController.text, + index: widget.index, + ), + const SingleActivator(LogicalKeyboardKey.escape): + const _CancelCreatingFromPhantomIntent(), + }; + } +} + +@visibleForTesting +class ChecklistItemControl extends StatelessWidget { + const ChecklistItemControl({ + super.key, + required this.cellNotifer, + required this.onTap, + }); + + final CellContainerNotifier cellNotifer; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: cellNotifer, + child: Consumer( + builder: (buildContext, notifier, _) => TextFieldTapRegion( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + margin: const EdgeInsets.fromLTRB(8.0, 2.0, 8.0, 0), + height: 12, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + child: notifier.isHover + ? FlowyTooltip( + message: LocaleKeys.grid_checklist_addNew.tr(), + child: Row( + children: [ + const Flexible(child: Center(child: Divider())), + const HSpace(12.0), + FilledButton( + style: FilledButton.styleFrom( + minimumSize: const Size.square(12), + maximumSize: const Size.square(12), + padding: EdgeInsets.zero, + ), + onPressed: onTap, + child: FlowySvg( + FlowySvgs.add_s, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + const HSpace(12.0), + const Flexible(child: Center(child: Divider())), + ], + ), + ) + : const SizedBox.expand(), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_date_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_date_cell.dart new file mode 100644 index 0000000000000..f10f9a927918d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_date_cell.dart @@ -0,0 +1,69 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/date_cell_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class DesktopRowDetailDateCellSkin extends IEditableDateCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + DateCellBloc bloc, + DateCellState state, + PopoverController popoverController, + ) { + final dateStr = getDateCellStrFromCellData( + state.fieldInfo, + state.cellData, + ); + final text = + dateStr.isEmpty ? LocaleKeys.grid_row_textPlaceholder.tr() : dateStr; + final color = dateStr.isEmpty ? Theme.of(context).hintColor : null; + + return AppFlowyPopover( + controller: popoverController, + triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: BoxConstraints.loose(const Size(260, 620)), + margin: EdgeInsets.zero, + asBarrier: true, + child: Container( + alignment: AlignmentDirectional.centerStart, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: FlowyText( + text, + color: color, + overflow: TextOverflow.ellipsis, + ), + ), + if (state.cellData.reminderId.isNotEmpty) ...[ + const HSpace(4), + FlowyTooltip( + message: LocaleKeys.grid_field_reminderOnDateTooltip.tr(), + child: const FlowySvg(FlowySvgs.clock_alarm_s), + ), + ], + ], + ), + ), + popupBuilder: (BuildContext popoverContent) { + return DateCellEditor( + cellController: bloc.cellController, + onDismissed: () => cellContainerNotifier.isFocus = false, + ); + }, + onClose: () { + cellContainerNotifier.isFocus = false; + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart new file mode 100644 index 0000000000000..34d8a18ad84ce --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart @@ -0,0 +1,748 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/shared/af_image.dart'; +import 'package:appflowy/util/xfile_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:reorderables/reorderables.dart'; + +const _dropFileKey = 'files_media'; +const _itemWidth = 86.4; + +class DekstopRowDetailMediaCellSkin extends IEditableMediaCellSkin { + final mutex = PopoverMutex(); + + @override + void dispose() { + mutex.dispose(); + } + + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + PopoverController popoverController, + MediaCellBloc bloc, + ) { + return BlocProvider.value( + value: bloc, + child: BlocBuilder( + builder: (context, state) => LayoutBuilder( + builder: (context, constraints) { + if (state.files.isEmpty) { + return _AddFileButton( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + mutex: mutex, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + child: FlowyText( + LocaleKeys.grid_row_textPlaceholder.tr(), + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } + + int itemsToShow = state.showAllFiles ? state.files.length : 0; + if (!state.showAllFiles) { + // The row width is surrounded by 8px padding on each side + final rowWidth = constraints.maxWidth - 16; + + // Each item needs 94.4 px to render, 86.4px width + 8px runSpacing + final itemsPerRow = rowWidth ~/ (_itemWidth + 8); + + // We show at most 2 rows + itemsToShow = itemsPerRow * 2; + } + + final filesToDisplay = + state.showAllFiles || itemsToShow >= state.files.length + ? state.files + : state.files.take(itemsToShow - 1).toList(); + final extraCount = state.files.length - itemsToShow; + final images = state.files + .where((f) => f.fileType == MediaFileTypePB.Image) + .toList(); + + final size = constraints.maxWidth / 2 - 6; + return _AddFileButton( + controller: popoverController, + mutex: mutex, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: ReorderableWrap( + needsLongPressDraggable: false, + runSpacing: 8, + spacing: 8, + onReorder: (from, to) => context + .read() + .add(MediaCellEvent.reorderFiles(from: from, to: to)), + footer: extraCount > 0 && !state.showAllFiles + ? GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _toggleShowAllFiles(context), + child: _FilePreviewRender( + key: ValueKey(state.files[itemsToShow - 1].id), + file: state.files[itemsToShow - 1], + index: 9, + images: images, + size: size, + mutex: mutex, + hideFileNames: state.hideFileNames, + foregroundText: LocaleKeys.grid_media_extraCount + .tr(args: [extraCount.toString()]), + ), + ) + : null, + buildDraggableFeedback: (_, __, child) => + BlocProvider.value( + value: context.read(), + child: _FilePreviewFeedback(child: child), + ), + children: filesToDisplay + .mapIndexed( + (index, file) => _FilePreviewRender( + key: ValueKey(file.id), + file: file, + index: index, + images: images, + size: size, + mutex: mutex, + hideFileNames: state.hideFileNames, + ), + ) + .toList(), + ), + ), + Padding( + padding: const EdgeInsets.all(4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowySvg( + FlowySvgs.add_thin_s, + size: const Size.square(12), + color: Theme.of(context).hintColor, + ), + const HSpace(6), + FlowyText.medium( + LocaleKeys.grid_media_addFileOrImage.tr(), + fontSize: 12, + color: Theme.of(context).hintColor, + figmaLineHeight: 18, + ), + ], + ), + ), + ], + ), + ); + }, + ), + ), + ); + } + + void _toggleShowAllFiles(BuildContext context) { + context + .read() + .add(const MediaCellEvent.toggleShowAllFiles()); + } +} + +class _FilePreviewFeedback extends StatelessWidget { + const _FilePreviewFeedback({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all( + width: 2, + color: const Color(0xFF00BCF0), + ), + ), + child: DecoratedBox( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: const Color(0xFF1F2329).withOpacity(.2), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: BlocProvider.value( + value: context.read(), + child: Material( + type: MaterialType.transparency, + child: child, + ), + ), + ), + ); + } +} + +const _menuWidth = 350.0; + +class _AddFileButton extends StatefulWidget { + const _AddFileButton({ + this.mutex, + required this.controller, + this.direction = PopoverDirection.bottomWithCenterAligned, + required this.child, + }); + + final PopoverController controller; + final PopoverMutex? mutex; + final PopoverDirection direction; + final Widget child; + + @override + State<_AddFileButton> createState() => _AddFileButtonState(); +} + +class _AddFileButtonState extends State<_AddFileButton> { + Offset? position; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + triggerActions: PopoverTriggerFlags.none, + controller: widget.controller, + mutex: widget.mutex, + offset: const Offset(0, 10), + direction: widget.direction, + constraints: const BoxConstraints(maxWidth: _menuWidth), + margin: EdgeInsets.zero, + asBarrier: true, + onClose: () => + context.read().remove(_dropFileKey), + popupBuilder: (_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().add(_dropFileKey); + }); + + return FileUploadMenu( + allowMultipleFiles: true, + onInsertLocalFile: (files) => insertLocalFiles( + context, + files, + userProfile: context.read().state.userProfile, + documentId: context.read().rowId, + onUploadSuccess: (file, path, isLocalMode) { + final mediaCellBloc = context.read(); + if (mediaCellBloc.isClosed) { + return; + } + + mediaCellBloc.add( + MediaCellEvent.addFile( + url: path, + name: file.name, + uploadType: isLocalMode + ? FileUploadTypePB.LocalFile + : FileUploadTypePB.CloudFile, + fileType: file.fileType.toMediaFileTypePB(), + ), + ); + + widget.controller.close(); + }, + ), + onInsertNetworkFile: (url) { + if (url.isEmpty) return; + final uri = Uri.tryParse(url); + if (uri == null) { + return; + } + + final fakeFile = XFile(uri.path); + MediaFileTypePB fileType = fakeFile.fileType.toMediaFileTypePB(); + fileType = fileType == MediaFileTypePB.Other + ? MediaFileTypePB.Link + : fileType; + + String name = + uri.pathSegments.isNotEmpty ? uri.pathSegments.last : ""; + if (name.isEmpty && uri.pathSegments.length > 1) { + name = uri.pathSegments[uri.pathSegments.length - 2]; + } else if (name.isEmpty) { + name = uri.host; + } + + context.read().add( + MediaCellEvent.addFile( + url: url, + name: name, + uploadType: FileUploadTypePB.NetworkFile, + fileType: fileType, + ), + ); + + widget.controller.close(); + }, + ); + }, + child: MouseRegion( + onEnter: (event) => position = event.position, + onExit: (_) => position = null, + onHover: (event) => position = event.position, + child: GestureDetector( + onTap: () { + if (position != null) { + widget.controller.showAt( + position! - const Offset(_menuWidth / 2, 0), + ); + } + }, + behavior: HitTestBehavior.translucent, + child: FlowyHover(resetHoverOnRebuild: false, child: widget.child), + ), + ), + ); + } +} + +class _FilePreviewRender extends StatefulWidget { + const _FilePreviewRender({ + super.key, + required this.file, + required this.images, + required this.index, + required this.size, + required this.mutex, + this.hideFileNames = false, + this.foregroundText, + }); + + final MediaFilePB file; + final List images; + final int index; + final double size; + final PopoverMutex mutex; + final bool hideFileNames; + final String? foregroundText; + + @override + State<_FilePreviewRender> createState() => _FilePreviewRenderState(); +} + +class _FilePreviewRenderState extends State<_FilePreviewRender> { + final nameController = TextEditingController(); + final controller = PopoverController(); + bool isHovering = false; + bool isSelected = false; + + late int thisIndex; + + MediaFilePB get file => widget.file; + + @override + void initState() { + super.initState(); + thisIndex = widget.images.indexOf(file); + } + + @override + void dispose() { + nameController.dispose(); + controller.close(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _FilePreviewRender oldWidget) { + thisIndex = widget.images.indexOf(file); + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + Widget child; + if (file.fileType == MediaFileTypePB.Image) { + child = AFImage( + url: file.url, + uploadType: file.uploadType, + userProfile: context.read().state.userProfile, + width: _itemWidth, + borderRadius: BorderRadius.only( + topLeft: Corners.s5Radius, + topRight: Corners.s5Radius, + bottomLeft: widget.hideFileNames ? Corners.s5Radius : Radius.zero, + bottomRight: widget.hideFileNames ? Corners.s5Radius : Radius.zero, + ), + ); + } else { + child = DecoratedBox( + decoration: BoxDecoration(color: file.fileType.color), + child: Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: FlowySvg( + file.fileType.icon, + color: const Color(0xFF666D76), + ), + ), + ), + ); + } + + if (widget.foregroundText != null) { + child = Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration(color: Colors.black.withOpacity(0.5)), + child: child, + ), + ), + Positioned.fill( + child: Center( + child: FlowyText.semibold( + widget.foregroundText!, + color: Colors.white, + fontSize: 14, + ), + ), + ), + ], + ); + } + + return MouseRegion( + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + child: FlowyTooltip( + message: file.name, + child: AppFlowyPopover( + controller: controller, + constraints: const BoxConstraints(maxWidth: 240), + offset: const Offset(0, 5), + triggerActions: PopoverTriggerFlags.none, + onClose: () => setState(() => isSelected = false), + asBarrier: true, + popupBuilder: (popoverContext) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: context.read()), + BlocProvider.value(value: context.read()), + ], + child: _FileMenu( + parentContext: context, + index: thisIndex, + file: file, + images: widget.images, + controller: controller, + nameController: nameController, + ), + ), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: widget.foregroundText != null + ? null + : () { + if (file.uploadType == FileUploadTypePB.LocalFile) { + afLaunchUrlString(file.url); + return; + } + + if (file.fileType != MediaFileTypePB.Image) { + afLaunchUrlString(widget.file.url); + return; + } + + openInteractiveViewerFromFiles( + context, + widget.images, + userProfile: + context.read().state.userProfile, + initialIndex: thisIndex, + onDeleteImage: (index) { + final deleteFile = widget.images[index]; + context.read().deleteFile(deleteFile.id); + }, + ); + }, + child: Container( + width: _itemWidth, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Corners.s6Radius), + border: Border.all(color: Theme.of(context).dividerColor), + color: Theme.of(context).cardColor, + ), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 68, child: child), + if (!widget.hideFileNames) + Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(4), + child: FlowyText( + file.name, + fontSize: 10, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 16, + ), + ), + ), + ], + ), + ], + ), + if (widget.foregroundText == null && + (isHovering || isSelected)) + Positioned( + top: 3, + right: 3, + child: FlowyIconButton( + onPressed: () { + setState(() => isSelected = true); + controller.show(); + }, + fillColor: Colors.black.withOpacity(0.4), + width: 18, + radius: BorderRadius.circular(4), + icon: const FlowySvg( + FlowySvgs.three_dots_s, + color: Colors.white, + size: Size.square(16), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _FileMenu extends StatefulWidget { + const _FileMenu({ + required this.parentContext, + required this.index, + required this.file, + required this.images, + required this.controller, + required this.nameController, + }); + + /// Parent [BuildContext] used to retrieve the [MediaCellBloc] + final BuildContext parentContext; + + /// Index of this file in [widget.images] + final int index; + + /// The current [MediaFilePB] being previewed + final MediaFilePB file; + + /// All images in the field, excluding non-image files- + final List images; + + /// The [PopoverController] to close the popover + final PopoverController controller; + + /// The [TextEditingController] for renaming the file + final TextEditingController nameController; + + @override + State<_FileMenu> createState() => _FileMenuState(); +} + +class _FileMenuState extends State<_FileMenu> { + final errorMessage = ValueNotifier(null); + + @override + void dispose() { + errorMessage.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SeparatedColumn( + separatorBuilder: () => const VSpace(8), + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.file.fileType == MediaFileTypePB.Image) ...[ + MediaMenuItem( + onTap: () { + widget.controller.close(); + _showInteractiveViewer(context); + }, + icon: FlowySvgs.full_view_s, + label: LocaleKeys.grid_media_expand.tr(), + ), + MediaMenuItem( + onTap: () { + widget.controller.close(); + _setCover(context); + }, + icon: FlowySvgs.cover_s, + label: LocaleKeys.grid_media_setAsCover.tr(), + ), + ], + MediaMenuItem( + onTap: () { + widget.controller.close(); + afLaunchUrlString(widget.file.url); + }, + icon: FlowySvgs.open_in_browser_s, + label: LocaleKeys.grid_media_openInBrowser.tr(), + ), + MediaMenuItem( + onTap: () { + widget.controller.close(); + widget.nameController.text = widget.file.name; + widget.nameController.selection = TextSelection( + baseOffset: 0, + extentOffset: widget.nameController.text.length, + ); + + _showRenameConfirmDialog(); + }, + icon: FlowySvgs.rename_s, + label: LocaleKeys.grid_media_rename.tr(), + ), + if (widget.file.uploadType == FileUploadTypePB.CloudFile) ...[ + MediaMenuItem( + onTap: () async => downloadMediaFile( + context, + widget.file, + userProfile: context.read().state.userProfile, + ), + icon: FlowySvgs.save_as_s, + label: LocaleKeys.button_download.tr(), + ), + ], + MediaMenuItem( + onTap: () { + widget.controller.close(); + showConfirmDeletionDialog( + context: context, + name: widget.file.name, + description: LocaleKeys.grid_media_deleteFileDescription.tr(), + onConfirm: () => widget.parentContext + .read() + .add(MediaCellEvent.removeFile(fileId: widget.file.id)), + ); + }, + icon: FlowySvgs.trash_s, + label: LocaleKeys.button_delete.tr(), + ), + ], + ); + } + + void _saveName(BuildContext context) { + final newName = widget.nameController.text.trim(); + if (newName.isEmpty) { + return; + } + + context + .read() + .add(MediaCellEvent.renameFile(fileId: widget.file.id, name: newName)); + Navigator.of(context).pop(); + } + + void _showRenameConfirmDialog() { + showCustomConfirmDialog( + context: widget.parentContext, + title: LocaleKeys.document_plugins_file_renameFile_title.tr(), + description: LocaleKeys.document_plugins_file_renameFile_description.tr(), + closeOnConfirm: false, + builder: (builderContext) => FileRenameTextField( + nameController: widget.nameController, + errorMessage: errorMessage, + onSubmitted: () => _saveName(widget.parentContext), + disposeController: false, + ), + style: ConfirmPopupStyle.cancelAndOk, + confirmLabel: LocaleKeys.button_save.tr(), + onConfirm: () => _saveName(widget.parentContext), + onCancel: Navigator.of(widget.parentContext).pop, + ); + } + + void _setCover(BuildContext context) => context.read().add( + RowDetailEvent.setCover( + RowCoverPB( + data: widget.file.url, + uploadType: widget.file.uploadType, + coverType: CoverTypePB.FileCover, + ), + ), + ); + + void _showInteractiveViewer(BuildContext context) => showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: + widget.parentContext.read().state.userProfile, + imageProvider: AFBlockImageProvider( + initialIndex: widget.index, + images: widget.images + .map( + (e) => ImageBlockData( + url: e.url, + type: e.uploadType.toCustomImageType(), + ), + ) + .toList(), + onDeleteImage: (index) { + final deleteFile = widget.images[index]; + widget.parentContext + .read() + .deleteFile(deleteFile.id); + }, + ), + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_number_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_number_cell.dart new file mode 100644 index 0000000000000..97f8f805698ab --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_number_cell.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/number.dart'; + +class DesktopRowDetailNumberCellSkin extends IEditableNumberCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + NumberCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + hintText: LocaleKeys.grid_row_textPlaceholder.tr(), + hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + ), + isDense: true, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart new file mode 100644 index 0000000000000..b3096d49e42db --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart @@ -0,0 +1,69 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/relation.dart'; + +class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + RelationCellBloc bloc, + RelationCellState state, + PopoverController popoverController, + ) { + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400), + margin: EdgeInsets.zero, + asBarrier: true, + onClose: () => cellContainerNotifier.isFocus = false, + popupBuilder: (context) { + return BlocProvider.value( + value: bloc, + child: const RelationCellEditor(), + ); + }, + child: Container( + alignment: AlignmentDirectional.centerStart, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: state.rows.isEmpty + ? _buildPlaceholder(context) + : _buildRows(context, state.rows), + ), + ); + } + + Widget _buildPlaceholder(BuildContext context) { + return FlowyText( + LocaleKeys.grid_row_textPlaceholder.tr(), + color: Theme.of(context).hintColor, + ); + } + + Widget _buildRows(BuildContext context, List rows) { + return Wrap( + runSpacing: 4.0, + spacing: 4.0, + children: rows.map( + (row) { + final isEmpty = row.name.isEmpty; + return FlowyText( + isEmpty ? LocaleKeys.grid_row_titlePlaceholder.tr() : row.name, + color: isEmpty ? Theme.of(context).hintColor : null, + decoration: TextDecoration.underline, + overflow: TextOverflow.ellipsis, + ); + }, + ).toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart new file mode 100644 index 0000000000000..3d41a824ce54a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/select_option.dart'; + +class DesktopRowDetailSelectOptionCellSkin + extends IEditableSelectOptionCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + SelectOptionCellBloc bloc, + PopoverController popoverController, + ) { + return AppFlowyPopover( + controller: popoverController, + constraints: const BoxConstraints.tightFor(width: 300), + margin: EdgeInsets.zero, + asBarrier: true, + triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.bottomWithLeftAligned, + onClose: () => cellContainerNotifier.isFocus = false, + onOpen: () => cellContainerNotifier.isFocus = true, + popupBuilder: (_) => SelectOptionCellEditor( + cellController: bloc.cellController, + ), + child: BlocBuilder( + builder: (context, state) { + return Container( + alignment: AlignmentDirectional.centerStart, + padding: state.selectedOptions.isEmpty + ? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0) + : const EdgeInsets.symmetric(horizontal: 8.0, vertical: 5.0), + child: state.selectedOptions.isEmpty + ? _buildPlaceholder(context) + : _buildOptions(context, state.selectedOptions), + ); + }, + ), + ); + } + + Widget _buildPlaceholder(BuildContext context) { + return FlowyText( + LocaleKeys.grid_row_textPlaceholder.tr(), + color: Theme.of(context).hintColor, + ); + } + + Widget _buildOptions(BuildContext context, List options) { + return Wrap( + runSpacing: 4, + spacing: 4, + children: options.map( + (option) { + return SelectOptionTag( + option: option, + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 8, + ), + ); + }, + ).toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_summary_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_summary_cell.dart new file mode 100644 index 0000000000000..d8a8902a8cf5e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_summary_cell.dart @@ -0,0 +1,66 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class DesktopRowDetailSummaryCellSkin extends IEditableSummaryCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + SummaryCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Stack( + children: [ + TextField( + controller: textEditingController, + readOnly: true, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + maxLines: null, + minLines: 1, + decoration: InputDecoration( + contentPadding: GridSize.cellContentInsets, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ), + ChangeNotifierProvider.value( + value: cellContainerNotifier, + child: Selector( + selector: (_, notifier) => notifier.isHover, + builder: (context, isHover, child) { + return Visibility( + visible: isHover, + child: Row( + children: [ + const Spacer(), + SummaryCellAccessory( + viewId: bloc.cellController.viewId, + fieldId: bloc.cellController.fieldId, + rowId: bloc.cellController.rowId, + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_text_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_text_cell.dart new file mode 100644 index 0000000000000..b1e10e4da3845 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_text_cell.dart @@ -0,0 +1,38 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/text.dart'; + +class DesktopRowDetailTextCellSkin extends IEditableTextCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TextCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + maxLines: null, + style: Theme.of(context).textTheme.bodyMedium, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + hintText: LocaleKeys.grid_row_textPlaceholder.tr(), + hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + ), + isDense: true, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_time_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_time_cell.dart new file mode 100644 index 0000000000000..ffd68933c9c58 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_time_cell.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/time.dart'; + +class DesktopRowDetailTimeCellSkin extends IEditableTimeCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimeCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + hintText: LocaleKeys.grid_row_textPlaceholder.tr(), + hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + ), + isDense: true, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_timestamp_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_timestamp_cell.dart new file mode 100644 index 0000000000000..af212c6dfdad5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_timestamp_cell.dart @@ -0,0 +1,25 @@ +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/widgets.dart'; + +import '../editable_cell_skeleton/timestamp.dart'; + +class DesktopRowDetailTimestampCellSkin extends IEditableTimestampCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimestampCellBloc bloc, + TimestampCellState state, + ) { + return Container( + alignment: AlignmentDirectional.centerStart, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6.0), + child: FlowyText( + state.dateStr, + maxLines: null, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_url_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_url_cell.dart new file mode 100644 index 0000000000000..00d7372027a2e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_url_cell.dart @@ -0,0 +1,113 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_url_cell.dart'; +import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../editable_cell_skeleton/url.dart'; + +class DesktopRowDetailURLSkin extends IEditableURLCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + URLCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + URLCellDataNotifier cellDataNotifier, + ) { + return LinkTextField( + controller: textEditingController, + focusNode: focusNode, + ); + } + + @override + List accessoryBuilder( + GridCellAccessoryBuildContext context, + URLCellDataNotifier cellDataNotifier, + ) { + return [ + accessoryFromType( + GridURLCellAccessoryType.visitURL, + cellDataNotifier, + ), + ]; + } +} + +class LinkTextField extends StatefulWidget { + const LinkTextField({ + super.key, + required this.controller, + required this.focusNode, + }); + + final TextEditingController controller; + final FocusNode focusNode; + + @override + State createState() => _LinkTextFieldState(); +} + +class _LinkTextFieldState extends State { + bool isLinkClickable = false; + + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); + super.dispose(); + } + + bool _handleGlobalKeyEvent(KeyEvent event) { + final keyboard = HardwareKeyboard.instance; + final canOpenLink = event is KeyDownEvent && + (keyboard.isControlPressed || keyboard.isMetaPressed); + if (canOpenLink != isLinkClickable) { + setState(() => isLinkClickable = canOpenLink); + } + + return false; + } + + @override + Widget build(BuildContext context) { + return TextField( + mouseCursor: + isLinkClickable ? SystemMouseCursors.click : SystemMouseCursors.text, + controller: widget.controller, + focusNode: widget.focusNode, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + onTap: () { + if (isLinkClickable) { + openUrlCellLink(widget.controller.text); + } + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + hintText: LocaleKeys.grid_row_textPlaceholder.tr(), + hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + ), + isDense: true, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart new file mode 100644 index 0000000000000..1c7bab9f9225e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart @@ -0,0 +1,66 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class DesktopRowDetailTranslateCellSkin extends IEditableTranslateCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TranslateCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Stack( + children: [ + TextField( + controller: textEditingController, + focusNode: focusNode, + readOnly: true, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + maxLines: null, + minLines: 1, + decoration: InputDecoration( + contentPadding: GridSize.cellContentInsets, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ), + ChangeNotifierProvider.value( + value: cellContainerNotifier, + child: Selector( + selector: (_, notifier) => notifier.isHover, + builder: (context, isHover, child) { + return Visibility( + visible: isHover, + child: Row( + children: [ + const Spacer(), + TranslateCellAccessory( + viewId: bloc.cellController.viewId, + fieldId: bloc.cellController.fieldId, + rowId: bloc.cellController.rowId, + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart new file mode 100755 index 0000000000000..e7b5d0d79bad6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart @@ -0,0 +1,441 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +import '../row/accessory/cell_accessory.dart'; +import '../row/accessory/cell_shortcuts.dart'; +import '../row/cells/cell_container.dart'; + +import 'editable_cell_skeleton/checkbox.dart'; +import 'editable_cell_skeleton/checklist.dart'; +import 'editable_cell_skeleton/date.dart'; +import 'editable_cell_skeleton/number.dart'; +import 'editable_cell_skeleton/relation.dart'; +import 'editable_cell_skeleton/select_option.dart'; +import 'editable_cell_skeleton/summary.dart'; +import 'editable_cell_skeleton/text.dart'; +import 'editable_cell_skeleton/time.dart'; +import 'editable_cell_skeleton/timestamp.dart'; +import 'editable_cell_skeleton/url.dart'; + +enum EditableCellStyle { + desktopGrid, + desktopRowDetail, + mobileGrid, + mobileRowDetail, +} + +/// Build an editable cell widget +class EditableCellBuilder { + EditableCellBuilder({required this.databaseController}); + + final DatabaseController databaseController; + + EditableCellWidget buildStyled( + CellContext cellContext, + EditableCellStyle style, + ) { + final fieldType = databaseController.fieldController + .getField(cellContext.fieldId)! + .fieldType; + final key = ValueKey( + "${databaseController.viewId}${cellContext.fieldId}${cellContext.rowId}", + ); + return switch (fieldType) { + FieldType.Checkbox => EditableCheckboxCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableCheckboxCellSkin.fromStyle(style), + key: key, + ), + FieldType.Checklist => EditableChecklistCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableChecklistCellSkin.fromStyle(style), + key: key, + ), + FieldType.CreatedTime => EditableTimestampCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableTimestampCellSkin.fromStyle(style), + key: key, + fieldType: FieldType.CreatedTime, + ), + FieldType.DateTime => EditableDateCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableDateCellSkin.fromStyle(style), + key: key, + ), + FieldType.LastEditedTime => EditableTimestampCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableTimestampCellSkin.fromStyle(style), + key: key, + fieldType: FieldType.LastEditedTime, + ), + FieldType.MultiSelect => EditableSelectOptionCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableSelectOptionCellSkin.fromStyle(style), + key: key, + fieldType: FieldType.MultiSelect, + ), + FieldType.Number => EditableNumberCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableNumberCellSkin.fromStyle(style), + key: key, + ), + FieldType.RichText => EditableTextCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableTextCellSkin.fromStyle(style), + key: key, + ), + FieldType.SingleSelect => EditableSelectOptionCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableSelectOptionCellSkin.fromStyle(style), + key: key, + fieldType: FieldType.SingleSelect, + ), + FieldType.URL => EditableURLCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableURLCellSkin.fromStyle(style), + key: key, + ), + FieldType.Relation => EditableRelationCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableRelationCellSkin.fromStyle(style), + key: key, + ), + FieldType.Summary => EditableSummaryCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableSummaryCellSkin.fromStyle(style), + key: key, + ), + FieldType.Time => EditableTimeCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableTimeCellSkin.fromStyle(style), + key: key, + ), + FieldType.Translate => EditableTranslateCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableTranslateCellSkin.fromStyle(style), + key: key, + ), + FieldType.Media => EditableMediaCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableMediaCellSkin.fromStyle(style), + style: style, + key: key, + ), + _ => throw UnimplementedError(), + }; + } + + EditableCellWidget buildCustom( + CellContext cellContext, { + required EditableCellSkinMap skinMap, + }) { + final DatabaseController(:fieldController) = databaseController; + final fieldType = fieldController.getField(cellContext.fieldId)!.fieldType; + + final key = ValueKey( + "${databaseController.viewId}${cellContext.fieldId}${cellContext.rowId}", + ); + assert(skinMap.has(fieldType)); + return switch (fieldType) { + FieldType.Checkbox => EditableCheckboxCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.checkboxSkin!, + key: key, + ), + FieldType.Checklist => EditableChecklistCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.checklistSkin!, + key: key, + ), + FieldType.CreatedTime => EditableTimestampCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.timestampSkin!, + key: key, + fieldType: FieldType.CreatedTime, + ), + FieldType.DateTime => EditableDateCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.dateSkin!, + key: key, + ), + FieldType.LastEditedTime => EditableTimestampCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.timestampSkin!, + key: key, + fieldType: FieldType.LastEditedTime, + ), + FieldType.MultiSelect => EditableSelectOptionCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.selectOptionSkin!, + key: key, + fieldType: FieldType.MultiSelect, + ), + FieldType.Number => EditableNumberCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.numberSkin!, + key: key, + ), + FieldType.RichText => EditableTextCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.textSkin!, + key: key, + ), + FieldType.SingleSelect => EditableSelectOptionCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.selectOptionSkin!, + key: key, + fieldType: FieldType.SingleSelect, + ), + FieldType.URL => EditableURLCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.urlSkin!, + key: key, + ), + FieldType.Relation => EditableRelationCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.relationSkin!, + key: key, + ), + FieldType.Time => EditableTimeCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.timeSkin!, + key: key, + ), + FieldType.Media => EditableMediaCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.mediaSkin!, + style: EditableCellStyle.desktopGrid, + key: key, + ), + _ => throw UnimplementedError(), + }; + } +} + +abstract class CellEditable { + SingleListenerChangeNotifier get requestFocus; + + CellContainerNotifier get cellContainerNotifier; +} + +typedef AccessoryBuilder = List Function( + GridCellAccessoryBuildContext buildContext, +); + +abstract class CellAccessory extends Widget { + const CellAccessory({super.key}); + + AccessoryBuilder? get accessoryBuilder; +} + +abstract class EditableCellWidget extends StatefulWidget + implements CellAccessory, CellEditable, CellShortcuts { + EditableCellWidget({super.key}); + + @override + final CellContainerNotifier cellContainerNotifier = CellContainerNotifier(); + + @override + AccessoryBuilder? get accessoryBuilder => null; + + @override + final requestFocus = SingleListenerChangeNotifier(); + + @override + final Map shortcutHandlers = {}; +} + +abstract class GridCellState extends State { + @override + void initState() { + super.initState(); + widget.requestFocus.addListener(onRequestFocus); + } + + @override + void didUpdateWidget(covariant T oldWidget) { + if (oldWidget != this) { + oldWidget.requestFocus.removeListener(onRequestFocus); + widget.requestFocus.addListener(onRequestFocus); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + widget.requestFocus.removeListener(onRequestFocus); + widget.requestFocus.dispose(); + super.dispose(); + } + + /// Subclass can override this method to request focus. + void onRequestFocus(); + + String? onCopy() => null; +} + +abstract class GridEditableTextCell + extends GridCellState { + SingleListenerFocusNode get focusNode; + + @override + void initState() { + super.initState(); + widget.shortcutHandlers[CellKeyboardKey.onEnter] = + () => focusNode.unfocus(); + _listenOnFocusNodeChanged(); + } + + @override + void dispose() { + widget.shortcutHandlers.clear(); + focusNode.removeAllListener(); + focusNode.dispose(); + super.dispose(); + } + + @override + void onRequestFocus() { + if (!focusNode.hasFocus && focusNode.canRequestFocus) { + FocusScope.of(context).requestFocus(focusNode); + } + } + + void _listenOnFocusNodeChanged() { + widget.cellContainerNotifier.isFocus = focusNode.hasFocus; + focusNode.setListener(() { + widget.cellContainerNotifier.isFocus = focusNode.hasFocus; + focusChanged(); + }); + } + + Future focusChanged() async {} +} + +class SingleListenerChangeNotifier extends ChangeNotifier { + VoidCallback? _listener; + + @override + void addListener(VoidCallback listener) { + if (_listener != null) { + removeListener(_listener!); + } + _listener = listener; + super.addListener(listener); + } + + @override + void dispose() { + _listener = null; + super.dispose(); + } + + void notify() => notifyListeners(); +} + +class SingleListenerFocusNode extends FocusNode { + VoidCallback? _listener; + + void setListener(VoidCallback listener) { + if (_listener != null) { + removeListener(_listener!); + } + + _listener = listener; + super.addListener(listener); + } + + void removeAllListener() { + if (_listener != null) { + removeListener(_listener!); + } + } + + @override + void dispose() { + removeAllListener(); + super.dispose(); + } +} + +class EditableCellSkinMap { + EditableCellSkinMap({ + this.checkboxSkin, + this.checklistSkin, + this.timestampSkin, + this.dateSkin, + this.selectOptionSkin, + this.numberSkin, + this.textSkin, + this.urlSkin, + this.relationSkin, + this.timeSkin, + this.mediaSkin, + }); + + final IEditableCheckboxCellSkin? checkboxSkin; + final IEditableChecklistCellSkin? checklistSkin; + final IEditableTimestampCellSkin? timestampSkin; + final IEditableDateCellSkin? dateSkin; + final IEditableSelectOptionCellSkin? selectOptionSkin; + final IEditableNumberCellSkin? numberSkin; + final IEditableTextCellSkin? textSkin; + final IEditableURLCellSkin? urlSkin; + final IEditableRelationCellSkin? relationSkin; + final IEditableTimeCellSkin? timeSkin; + final IEditableMediaCellSkin? mediaSkin; + + bool has(FieldType fieldType) { + return switch (fieldType) { + FieldType.Checkbox => checkboxSkin != null, + FieldType.Checklist => checklistSkin != null, + FieldType.CreatedTime || + FieldType.LastEditedTime => + timestampSkin != null, + FieldType.DateTime => dateSkin != null, + FieldType.MultiSelect || + FieldType.SingleSelect => + selectOptionSkin != null, + FieldType.Number => numberSkin != null, + FieldType.RichText => textSkin != null, + FieldType.URL => urlSkin != null, + FieldType.Time => timeSkin != null, + FieldType.Media => mediaSkin != null, + _ => throw UnimplementedError(), + }; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart new file mode 100644 index 0000000000000..4b7bd2c442d00 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart @@ -0,0 +1,93 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../desktop_grid/desktop_grid_checkbox_cell.dart'; +import '../desktop_row_detail/desktop_row_detail_checkbox_cell.dart'; +import '../mobile_grid/mobile_grid_checkbox_cell.dart'; +import '../mobile_row_detail/mobile_row_detail_checkbox_cell.dart'; + +abstract class IEditableCheckboxCellSkin { + const IEditableCheckboxCellSkin(); + + factory IEditableCheckboxCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridCheckboxCellSkin(), + EditableCellStyle.desktopRowDetail => DesktopRowDetailCheckboxCellSkin(), + EditableCellStyle.mobileGrid => MobileGridCheckboxCellSkin(), + EditableCellStyle.mobileRowDetail => MobileRowDetailCheckboxCellSkin(), + }; + } + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + CheckboxCellBloc bloc, + CheckboxCellState state, + ); +} + +class EditableCheckboxCell extends EditableCellWidget { + EditableCheckboxCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableCheckboxCellSkin skin; + + @override + GridCellState createState() => _CheckboxCellState(); +} + +class _CheckboxCellState extends GridCellState { + late final cellBloc = CheckboxCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + )..add(const CheckboxCellEvent.initial()); + + @override + void dispose() { + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: BlocBuilder( + builder: (context, state) { + return widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + state, + ); + }, + ), + ); + } + + @override + void onRequestFocus() => cellBloc.add(const CheckboxCellEvent.select()); + + @override + String? onCopy() { + if (cellBloc.state.isSelected) { + return "Yes"; + } else { + return "No"; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart new file mode 100644 index 0000000000000..4cdebee36d86c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../desktop_grid/desktop_grid_checklist_cell.dart'; +import '../desktop_row_detail/desktop_row_detail_checklist_cell.dart'; +import '../mobile_grid/mobile_grid_checklist_cell.dart'; +import '../mobile_row_detail/mobile_row_detail_checklist_cell.dart'; + +abstract class IEditableChecklistCellSkin { + const IEditableChecklistCellSkin(); + + factory IEditableChecklistCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridChecklistCellSkin(), + EditableCellStyle.desktopRowDetail => DesktopRowDetailChecklistCellSkin(), + EditableCellStyle.mobileGrid => MobileGridChecklistCellSkin(), + EditableCellStyle.mobileRowDetail => MobileRowDetailChecklistCellSkin(), + }; + } + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + ChecklistCellBloc bloc, + PopoverController popoverController, + ); +} + +class EditableChecklistCell extends EditableCellWidget { + EditableChecklistCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableChecklistCellSkin skin; + + @override + GridCellState createState() => + GridChecklistCellState(); +} + +class GridChecklistCellState extends GridCellState { + final PopoverController _popover = PopoverController(); + late final cellBloc = ChecklistCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void dispose() { + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + _popover, + ), + ); + } + + @override + void onRequestFocus() { + if (widget.skin is DesktopGridChecklistCellSkin) { + _popover.show(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/date.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/date.dart new file mode 100644 index 0000000000000..877b4c6dfbda6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/date.dart @@ -0,0 +1,139 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; + +import '../desktop_grid/desktop_grid_date_cell.dart'; +import '../desktop_row_detail/desktop_row_detail_date_cell.dart'; +import '../mobile_grid/mobile_grid_date_cell.dart'; +import '../mobile_row_detail/mobile_row_detail_date_cell.dart'; + +abstract class IEditableDateCellSkin { + const IEditableDateCellSkin(); + + factory IEditableDateCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridDateCellSkin(), + EditableCellStyle.desktopRowDetail => DesktopRowDetailDateCellSkin(), + EditableCellStyle.mobileGrid => MobileGridDateCellSkin(), + EditableCellStyle.mobileRowDetail => MobileRowDetailDateCellSkin(), + }; + } + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + DateCellBloc bloc, + DateCellState state, + PopoverController popoverController, + ); +} + +class EditableDateCell extends EditableCellWidget { + EditableDateCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableDateCellSkin skin; + + @override + GridCellState createState() => _DateCellState(); +} + +class _DateCellState extends GridCellState { + final PopoverController _popover = PopoverController(); + late final cellBloc = DateCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void dispose() { + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: BlocBuilder( + builder: (context, state) { + return widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + state, + _popover, + ); + }, + ), + ); + } + + @override + void onRequestFocus() { + _popover.show(); + widget.cellContainerNotifier.isFocus = true; + } + + @override + String? onCopy() => getDateCellStrFromCellData( + cellBloc.state.fieldInfo, + cellBloc.state.cellData, + ); +} + +String getDateCellStrFromCellData(FieldInfo field, DateCellData cellData) { + if (cellData.dateTime == null) { + return ""; + } + + final DateTypeOptionPB(:dateFormat, :timeFormat) = + DateTypeOptionDataParser().fromBuffer(field.field.typeOptionData); + + final format = cellData.includeTime + ? DateFormat("${dateFormat.pattern} ${timeFormat.pattern}") + : DateFormat(dateFormat.pattern); + + if (cellData.isRange) { + return "${format.format(cellData.dateTime!)} → ${format.format(cellData.endDateTime!)}"; + } else { + return format.format(cellData.dateTime!); + } +} + +extension GetDateFormatExtension on DateFormatPB { + String get pattern => switch (this) { + DateFormatPB.Local => 'MM/dd/y', + DateFormatPB.US => 'y/MM/dd', + DateFormatPB.ISO => 'y-MM-dd', + DateFormatPB.Friendly => 'MMM dd, y', + DateFormatPB.DayMonthYear => 'dd/MM/y', + _ => 'MMM dd, y', + }; +} + +extension GetTimeFormatExtension on TimeFormatPB { + String get pattern => switch (this) { + TimeFormatPB.TwelveHour => 'hh:mm a', + _ => 'HH:mm', + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart new file mode 100644 index 0000000000000..55adb853345d0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../application/cell/cell_controller_builder.dart'; + +abstract class IEditableMediaCellSkin { + const IEditableMediaCellSkin(); + + factory IEditableMediaCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => const GridMediaCellSkin(), + EditableCellStyle.desktopRowDetail => DekstopRowDetailMediaCellSkin(), + EditableCellStyle.mobileGrid => const GridMediaCellSkin(), + EditableCellStyle.mobileRowDetail => + const GridMediaCellSkin(isMobileRowDetail: true), + }; + } + + bool autoShowPopover(EditableCellStyle style) => switch (style) { + EditableCellStyle.desktopGrid => true, + EditableCellStyle.desktopRowDetail => false, + EditableCellStyle.mobileGrid => false, + EditableCellStyle.mobileRowDetail => false, + }; + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + PopoverController popoverController, + MediaCellBloc bloc, + ); + + void dispose(); +} + +class EditableMediaCell extends EditableCellWidget { + EditableMediaCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + required this.style, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableMediaCellSkin skin; + final EditableCellStyle style; + + @override + GridEditableTextCell createState() => + _EditableMediaCellState(); +} + +class _EditableMediaCellState extends GridEditableTextCell { + final PopoverController popoverController = PopoverController(); + + late final cellBloc = MediaCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void dispose() { + cellBloc.close(); + widget.skin.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc..add(const MediaCellEvent.initial()), + child: Builder( + builder: (context) => widget.skin.build( + context, + widget.cellContainerNotifier, + popoverController, + cellBloc, + ), + ), + ); + } + + @override + SingleListenerFocusNode focusNode = SingleListenerFocusNode(); + + @override + void onRequestFocus() => widget.skin.autoShowPopover(widget.style) + ? popoverController.show() + : null; + + @override + String? onCopy() => null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/number.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/number.dart new file mode 100644 index 0000000000000..b218c78195872 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/number.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../desktop_grid/desktop_grid_number_cell.dart'; +import '../desktop_row_detail/desktop_row_detail_number_cell.dart'; +import '../mobile_grid/mobile_grid_number_cell.dart'; +import '../mobile_row_detail/mobile_row_detail_number_cell.dart'; + +abstract class IEditableNumberCellSkin { + const IEditableNumberCellSkin(); + + factory IEditableNumberCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridNumberCellSkin(), + EditableCellStyle.desktopRowDetail => DesktopRowDetailNumberCellSkin(), + EditableCellStyle.mobileGrid => MobileGridNumberCellSkin(), + EditableCellStyle.mobileRowDetail => MobileRowDetailNumberCellSkin(), + }; + } + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + NumberCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ); +} + +class EditableNumberCell extends EditableCellWidget { + EditableNumberCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableNumberCellSkin skin; + + @override + GridEditableTextCell createState() => _NumberCellState(); +} + +class _NumberCellState extends GridEditableTextCell { + late final TextEditingController _textEditingController; + late final cellBloc = NumberCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void initState() { + super.initState(); + _textEditingController = + TextEditingController(text: cellBloc.state.content); + } + + @override + void dispose() { + _textEditingController.dispose(); + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: BlocListener( + listener: (context, state) { + if (!focusNode.hasFocus) { + _textEditingController.text = state.content; + } + }, + child: Builder( + builder: (context) { + return widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + focusNode, + _textEditingController, + ); + }, + ), + ), + ); + } + + @override + SingleListenerFocusNode focusNode = SingleListenerFocusNode(); + + @override + void onRequestFocus() { + focusNode.requestFocus(); + } + + @override + String? onCopy() => cellBloc.state.content; + + @override + Future focusChanged() async { + if (mounted && + !cellBloc.isClosed && + cellBloc.state.content != _textEditingController.text.trim()) { + cellBloc + .add(NumberCellEvent.updateCell(_textEditingController.text.trim())); + } + return super.focusChanged(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart new file mode 100644 index 0000000000000..4e39900abf9f9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart @@ -0,0 +1,94 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../desktop_grid/desktop_grid_relation_cell.dart'; +import '../desktop_row_detail/desktop_row_detail_relation_cell.dart'; +import '../mobile_grid/mobile_grid_relation_cell.dart'; +import '../mobile_row_detail/mobile_row_detail_relation_cell.dart'; + +abstract class IEditableRelationCellSkin { + factory IEditableRelationCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridRelationCellSkin(), + EditableCellStyle.desktopRowDetail => DesktopRowDetailRelationCellSkin(), + EditableCellStyle.mobileGrid => MobileGridRelationCellSkin(), + EditableCellStyle.mobileRowDetail => MobileRowDetailRelationCellSkin(), + }; + } + + const IEditableRelationCellSkin(); + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + RelationCellBloc bloc, + RelationCellState state, + PopoverController popoverController, + ); +} + +class EditableRelationCell extends EditableCellWidget { + EditableRelationCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableRelationCellSkin skin; + + @override + GridCellState createState() => _RelationCellState(); +} + +class _RelationCellState extends GridCellState { + final PopoverController _popover = PopoverController(); + late final cellBloc = RelationCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void dispose() { + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: BlocBuilder( + builder: (context, state) { + return widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + state, + _popover, + ); + }, + ), + ); + } + + @override + void onRequestFocus() { + _popover.show(); + widget.cellContainerNotifier.isFocus = true; + } + + @override + String? onCopy() => ""; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart new file mode 100644 index 0000000000000..b45018f4f57d6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart @@ -0,0 +1,90 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../desktop_grid/desktop_grid_select_option_cell.dart'; +import '../desktop_row_detail/desktop_row_detail_select_option_cell.dart'; +import '../mobile_grid/mobile_grid_select_option_cell.dart'; +import '../mobile_row_detail/mobile_row_detail_select_cell_option.dart'; + +abstract class IEditableSelectOptionCellSkin { + const IEditableSelectOptionCellSkin(); + + factory IEditableSelectOptionCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridSelectOptionCellSkin(), + EditableCellStyle.desktopRowDetail => + DesktopRowDetailSelectOptionCellSkin(), + EditableCellStyle.mobileGrid => MobileGridSelectOptionCellSkin(), + EditableCellStyle.mobileRowDetail => + MobileRowDetailSelectOptionCellSkin(), + }; + } + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + SelectOptionCellBloc bloc, + PopoverController popoverController, + ); +} + +class EditableSelectOptionCell extends EditableCellWidget { + EditableSelectOptionCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + required this.fieldType, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableSelectOptionCellSkin skin; + + final FieldType fieldType; + + @override + GridCellState createState() => + _SelectOptionCellState(); +} + +class _SelectOptionCellState extends GridCellState { + final PopoverController _popover = PopoverController(); + + late final cellBloc = SelectOptionCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void dispose() { + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + _popover, + ), + ); + } + + @override + void onRequestFocus() => _popover.show(); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart new file mode 100644 index 0000000000000..d3b43b0d1786c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/summary_row_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_summary_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/cell/mobile_grid/mobile_grid_summary_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_summary_cell.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/dispatch/error.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +abstract class IEditableSummaryCellSkin { + const IEditableSummaryCellSkin(); + + factory IEditableSummaryCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridSummaryCellSkin(), + EditableCellStyle.desktopRowDetail => DesktopRowDetailSummaryCellSkin(), + EditableCellStyle.mobileGrid => MobileGridSummaryCellSkin(), + EditableCellStyle.mobileRowDetail => MobileRowDetailSummaryCellSkin(), + }; + } + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + SummaryCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ); +} + +class EditableSummaryCell extends EditableCellWidget { + EditableSummaryCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableSummaryCellSkin skin; + + @override + GridEditableTextCell createState() => + _SummaryCellState(); +} + +class _SummaryCellState extends GridEditableTextCell { + late final TextEditingController _textEditingController; + late final cellBloc = SummaryCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void initState() { + super.initState(); + _textEditingController = + TextEditingController(text: cellBloc.state.content); + } + + @override + void dispose() { + _textEditingController.dispose(); + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: BlocListener( + listener: (context, state) { + _textEditingController.text = state.content; + }, + child: Builder( + builder: (context) { + return widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + focusNode, + _textEditingController, + ); + }, + ), + ), + ); + } + + @override + SingleListenerFocusNode focusNode = SingleListenerFocusNode(); + + @override + void onRequestFocus() { + focusNode.requestFocus(); + } + + @override + String? onCopy() => cellBloc.state.content; + + @override + Future focusChanged() { + if (mounted && + !cellBloc.isClosed && + cellBloc.state.content != _textEditingController.text.trim()) { + cellBloc + .add(SummaryCellEvent.updateCell(_textEditingController.text.trim())); + } + return super.focusChanged(); + } +} + +class SummaryCellAccessory extends StatelessWidget { + const SummaryCellAccessory({ + required this.viewId, + required this.rowId, + required this.fieldId, + super.key, + }); + + final String viewId; + final String rowId; + final String fieldId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SummaryRowBloc( + viewId: viewId, + rowId: rowId, + fieldId: fieldId, + ), + child: BlocConsumer( + listenWhen: (previous, current) { + return previous.error != current.error; + }, + listener: (context, state) { + if (state.error != null) { + if (state.error!.isAIResponseLimitExceeded) { + showSnackBarMessage( + context, + LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(), + ); + } else { + showSnackBarMessage(context, state.error!.msg); + } + } + }, + builder: (context, state) { + return const Row( + children: [SummaryButton(), HSpace(6), CopyButton()], + ); + }, + ), + ); + } +} + +class SummaryButton extends StatelessWidget { + const SummaryButton({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state.loadingState.when( + loading: () { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + }, + finish: () { + return FlowyTooltip( + message: LocaleKeys.tooltip_aiGenerate.tr(), + child: Container( + width: 26, + height: 26, + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).dividerColor), + ), + borderRadius: Corners.s6Border, + ), + child: FlowyIconButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + fillColor: Theme.of(context).cardColor, + icon: FlowySvg( + FlowySvgs.ai_summary_generate_s, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () { + context + .read() + .add(const SummaryRowEvent.startSummary()); + }, + ), + ), + ); + }, + ); + }, + ); + } +} + +class CopyButton extends StatelessWidget { + const CopyButton({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (blocContext, state) { + return FlowyTooltip( + message: LocaleKeys.settings_menu_clickToCopy.tr(), + child: Container( + width: 26, + height: 26, + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).dividerColor), + ), + borderRadius: Corners.s6Border, + ), + child: FlowyIconButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + fillColor: Theme.of(context).cardColor, + icon: FlowySvg( + FlowySvgs.ai_copy_s, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () { + Clipboard.setData(ClipboardData(text: state.content)); + showMessageToast(LocaleKeys.grid_row_copyProperty.tr()); + }, + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart new file mode 100644 index 0000000000000..7666919c49b10 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/text.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../desktop_grid/desktop_grid_text_cell.dart'; +import '../desktop_row_detail/desktop_row_detail_text_cell.dart'; +import '../mobile_grid/mobile_grid_text_cell.dart'; +import '../mobile_row_detail/mobile_row_detail_text_cell.dart'; + +abstract class IEditableTextCellSkin { + const IEditableTextCellSkin(); + + factory IEditableTextCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridTextCellSkin(), + EditableCellStyle.desktopRowDetail => DesktopRowDetailTextCellSkin(), + EditableCellStyle.mobileGrid => MobileGridTextCellSkin(), + EditableCellStyle.mobileRowDetail => MobileRowDetailTextCellSkin(), + }; + } + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TextCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ); +} + +class EditableTextCell extends EditableCellWidget { + EditableTextCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableTextCellSkin skin; + + @override + GridEditableTextCell createState() => _TextCellState(); +} + +class _TextCellState extends GridEditableTextCell { + late final TextEditingController _textEditingController; + late final cellBloc = TextCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void initState() { + super.initState(); + _textEditingController = + TextEditingController(text: cellBloc.state.content); + } + + @override + void dispose() { + _textEditingController.dispose(); + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: BlocListener( + listenWhen: (previous, current) => previous.content != current.content, + listener: (context, state) { + // It's essential to set the new content to the textEditingController. + // If you don't, the old value in textEditingController will persist and + // overwrite the correct value, leading to inconsistencies between the + // displayed text and the actual data. + _textEditingController.text = state.content ?? ""; + }, + child: Builder( + builder: (context) { + return widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + focusNode, + _textEditingController, + ); + }, + ), + ), + ); + } + + @override + SingleListenerFocusNode focusNode = SingleListenerFocusNode(); + + @override + void onRequestFocus() { + focusNode.requestFocus(); + } + + @override + String? onCopy() => cellBloc.state.content; + + @override + Future focusChanged() { + if (mounted && + !cellBloc.isClosed && + cellBloc.state.content != _textEditingController.text.trim()) { + cellBloc + .add(TextCellEvent.updateText(_textEditingController.text.trim())); + } + return super.focusChanged(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/time.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/time.dart new file mode 100644 index 0000000000000..83c34bdf5d93a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/time.dart @@ -0,0 +1,120 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../desktop_grid/desktop_grid_time_cell.dart'; +import '../desktop_row_detail/desktop_row_detail_time_cell.dart'; +import '../mobile_grid/mobile_grid_time_cell.dart'; +import '../mobile_row_detail/mobile_row_detail_time_cell.dart'; + +abstract class IEditableTimeCellSkin { + const IEditableTimeCellSkin(); + + factory IEditableTimeCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridTimeCellSkin(), + EditableCellStyle.desktopRowDetail => DesktopRowDetailTimeCellSkin(), + EditableCellStyle.mobileGrid => MobileGridTimeCellSkin(), + EditableCellStyle.mobileRowDetail => MobileRowDetailTimeCellSkin(), + }; + } + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimeCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ); +} + +class EditableTimeCell extends EditableCellWidget { + EditableTimeCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableTimeCellSkin skin; + + @override + GridEditableTextCell createState() => _TimeCellState(); +} + +class _TimeCellState extends GridEditableTextCell { + late final TextEditingController _textEditingController; + late final cellBloc = TimeCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void initState() { + super.initState(); + _textEditingController = + TextEditingController(text: cellBloc.state.content); + } + + @override + void dispose() { + _textEditingController.dispose(); + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: BlocListener( + listener: (context, state) => + _textEditingController.text = state.content, + child: Builder( + builder: (context) { + return widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + focusNode, + _textEditingController, + ); + }, + ), + ), + ); + } + + @override + SingleListenerFocusNode focusNode = SingleListenerFocusNode(); + + @override + void onRequestFocus() { + focusNode.requestFocus(); + } + + @override + String? onCopy() => cellBloc.state.content; + + @override + Future focusChanged() async { + if (mounted && + !cellBloc.isClosed && + cellBloc.state.content != _textEditingController.text.trim()) { + cellBloc + .add(TimeCellEvent.updateCell(_textEditingController.text.trim())); + } + return super.focusChanged(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/timestamp.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/timestamp.dart new file mode 100644 index 0000000000000..6c00e8b4b49c3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/timestamp.dart @@ -0,0 +1,92 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../desktop_grid/desktop_grid_timestamp_cell.dart'; +import '../desktop_row_detail/desktop_row_detail_timestamp_cell.dart'; +import '../mobile_grid/mobile_grid_timestamp_cell.dart'; +import '../mobile_row_detail/mobile_row_detail_timestamp_cell.dart'; + +abstract class IEditableTimestampCellSkin { + const IEditableTimestampCellSkin(); + + factory IEditableTimestampCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridTimestampCellSkin(), + EditableCellStyle.desktopRowDetail => DesktopRowDetailTimestampCellSkin(), + EditableCellStyle.mobileGrid => MobileGridTimestampCellSkin(), + EditableCellStyle.mobileRowDetail => MobileRowDetailTimestampCellSkin(), + }; + } + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimestampCellBloc bloc, + TimestampCellState state, + ); +} + +class EditableTimestampCell extends EditableCellWidget { + EditableTimestampCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + required this.fieldType, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableTimestampCellSkin skin; + final FieldType fieldType; + + @override + GridCellState createState() => _TimestampCellState(); +} + +class _TimestampCellState extends GridCellState { + late final cellBloc = TimestampCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void dispose() { + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: BlocBuilder( + builder: (context, state) { + return widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + state, + ); + }, + ), + ); + } + + @override + void onRequestFocus() { + widget.cellContainerNotifier.isFocus = true; + } + + @override + String? onCopy() => cellBloc.state.dateStr; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart new file mode 100644 index 0000000000000..b4ff26d946545 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/translate_row_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/destop_row_detail_translate_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/dispatch/error.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +abstract class IEditableTranslateCellSkin { + const IEditableTranslateCellSkin(); + + factory IEditableTranslateCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridTranslateCellSkin(), + EditableCellStyle.desktopRowDetail => DesktopRowDetailTranslateCellSkin(), + EditableCellStyle.mobileGrid => MobileGridTranslateCellSkin(), + EditableCellStyle.mobileRowDetail => MobileRowDetailTranslateCellSkin(), + }; + } + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TranslateCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ); +} + +class EditableTranslateCell extends EditableCellWidget { + EditableTranslateCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableTranslateCellSkin skin; + + @override + GridEditableTextCell createState() => + _TranslateCellState(); +} + +class _TranslateCellState extends GridEditableTextCell { + late final TextEditingController _textEditingController; + late final cellBloc = TranslateCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void initState() { + super.initState(); + _textEditingController = + TextEditingController(text: cellBloc.state.content); + } + + @override + void dispose() { + _textEditingController.dispose(); + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: BlocListener( + listener: (context, state) { + _textEditingController.text = state.content; + }, + child: Builder( + builder: (context) { + return widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + focusNode, + _textEditingController, + ); + }, + ), + ), + ); + } + + @override + SingleListenerFocusNode focusNode = SingleListenerFocusNode(); + + @override + void onRequestFocus() { + focusNode.requestFocus(); + } + + @override + String? onCopy() => cellBloc.state.content; + + @override + Future focusChanged() { + if (mounted && + !cellBloc.isClosed && + cellBloc.state.content != _textEditingController.text.trim()) { + cellBloc.add( + TranslateCellEvent.updateCell(_textEditingController.text.trim()), + ); + } + return super.focusChanged(); + } +} + +class TranslateCellAccessory extends StatelessWidget { + const TranslateCellAccessory({ + required this.viewId, + required this.rowId, + required this.fieldId, + super.key, + }); + + final String viewId; + final String rowId; + final String fieldId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => TranslateRowBloc( + viewId: viewId, + rowId: rowId, + fieldId: fieldId, + ), + child: BlocConsumer( + listenWhen: (previous, current) { + return previous.error != current.error; + }, + listener: (context, state) { + if (state.error != null) { + if (state.error!.isAIResponseLimitExceeded) { + showSnackBarMessage( + context, + LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(), + ); + } else { + showSnackBarMessage(context, state.error!.msg); + } + } + }, + builder: (context, state) { + return const Row( + children: [TranslateButton(), HSpace(6), CopyButton()], + ); + }, + ), + ); + } +} + +class TranslateButton extends StatelessWidget { + const TranslateButton({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state.loadingState.map( + loading: (_) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + }, + finish: (_) { + return FlowyTooltip( + message: LocaleKeys.tooltip_aiGenerate.tr(), + child: Container( + width: 26, + height: 26, + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).dividerColor), + ), + borderRadius: Corners.s6Border, + ), + child: FlowyIconButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + fillColor: Theme.of(context).cardColor, + icon: FlowySvg( + FlowySvgs.ai_summary_generate_s, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () { + context + .read() + .add(const TranslateRowEvent.startTranslate()); + }, + ), + ), + ); + }, + ); + }, + ); + } +} + +class CopyButton extends StatelessWidget { + const CopyButton({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (blocContext, state) { + return FlowyTooltip( + message: LocaleKeys.settings_menu_clickToCopy.tr(), + child: Container( + width: 26, + height: 26, + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).dividerColor), + ), + borderRadius: Corners.s6Border, + ), + child: FlowyIconButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + fillColor: Theme.of(context).cardColor, + icon: FlowySvg( + FlowySvgs.ai_copy_s, + color: Theme.of(context).colorScheme.primary, + ), + onPressed: () { + Clipboard.setData(ClipboardData(text: state.content)); + showMessageToast(LocaleKeys.grid_row_copyProperty.tr()); + }, + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart new file mode 100644 index 0000000000000..ef18573e1a6c4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/url.dart @@ -0,0 +1,235 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../desktop_grid/desktop_grid_url_cell.dart'; +import '../desktop_row_detail/desktop_row_detail_url_cell.dart'; +import '../mobile_grid/mobile_grid_url_cell.dart'; +import '../mobile_row_detail/mobile_row_detail_url_cell.dart'; + +abstract class IEditableURLCellSkin { + const IEditableURLCellSkin(); + + factory IEditableURLCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => DesktopGridURLSkin(), + EditableCellStyle.desktopRowDetail => DesktopRowDetailURLSkin(), + EditableCellStyle.mobileGrid => MobileGridURLCellSkin(), + EditableCellStyle.mobileRowDetail => MobileRowDetailURLCellSkin(), + }; + } + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + URLCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + URLCellDataNotifier cellDataNotifier, + ); + + List accessoryBuilder( + GridCellAccessoryBuildContext context, + URLCellDataNotifier cellDataNotifier, + ); +} + +typedef URLCellDataNotifier = CellDataNotifier; + +class EditableURLCell extends EditableCellWidget { + EditableURLCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + }) : _cellDataNotifier = CellDataNotifier(value: ''); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableURLCellSkin skin; + final URLCellDataNotifier _cellDataNotifier; + + @override + List Function( + GridCellAccessoryBuildContext buildContext, + ) get accessoryBuilder => (context) { + return skin.accessoryBuilder(context, _cellDataNotifier); + }; + + @override + GridCellState createState() => _GridURLCellState(); +} + +class _GridURLCellState extends GridEditableTextCell { + late final TextEditingController _textEditingController; + late final cellBloc = URLCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + SingleListenerFocusNode focusNode = SingleListenerFocusNode(); + + @override + void initState() { + super.initState(); + _textEditingController = + TextEditingController(text: cellBloc.state.content); + } + + @override + void dispose() { + widget._cellDataNotifier.dispose(); + _textEditingController.dispose(); + cellBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc, + child: BlocListener( + listenWhen: (previous, current) => previous.content != current.content, + listener: (context, state) { + if (!focusNode.hasFocus) { + _textEditingController.value = + _textEditingController.value.copyWith(text: state.content); + } + widget._cellDataNotifier.value = state.content; + }, + child: widget.skin.build( + context, + widget.cellContainerNotifier, + cellBloc, + focusNode, + _textEditingController, + widget._cellDataNotifier, + ), + ), + ); + } + + @override + Future focusChanged() async { + if (mounted && + !cellBloc.isClosed && + cellBloc.state.content != _textEditingController.text) { + cellBloc.add(URLCellEvent.updateURL(_textEditingController.text)); + } + return super.focusChanged(); + } + + @override + String? onCopy() => cellBloc.state.content; +} + +class MobileURLEditor extends StatelessWidget { + const MobileURLEditor({ + super.key, + required this.textEditingController, + }); + + final TextEditingController textEditingController; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const VSpace(4.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FlowyTextField( + controller: textEditingController, + hintStyle: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Theme.of(context).hintColor), + hintText: LocaleKeys.grid_url_textFieldHint.tr(), + textStyle: Theme.of(context).textTheme.bodyMedium, + keyboardType: TextInputType.url, + hintTextConstraints: const BoxConstraints(maxHeight: 52), + error: context.watch().state.isValid + ? null + : const SizedBox.shrink(), + onChanged: (_) { + if (textEditingController.value.composing.isCollapsed) { + context + .read() + .add(URLCellEvent.updateURL(textEditingController.text)); + } + }, + onSubmitted: (text) => + context.read().add(URLCellEvent.updateURL(text)), + ), + ), + const VSpace(8.0), + MobileQuickActionButton( + enable: context.watch().state.content.isNotEmpty, + onTap: () { + openUrlCellLink(textEditingController.text); + context.pop(); + }, + icon: FlowySvgs.url_s, + text: LocaleKeys.grid_url_launch.tr(), + ), + const Divider(height: 8.5, thickness: 0.5), + MobileQuickActionButton( + enable: context.watch().state.content.isNotEmpty, + onTap: () { + Clipboard.setData( + ClipboardData(text: textEditingController.text), + ); + Fluttertoast.showToast( + msg: LocaleKeys.grid_url_copiedNotification.tr(), + gravity: ToastGravity.BOTTOM, + ); + context.pop(); + }, + icon: FlowySvgs.copy_s, + text: LocaleKeys.grid_url_copy.tr(), + ), + const Divider(height: 8.5, thickness: 0.5), + ], + ); + } +} + +void openUrlCellLink(String content) async { + late Uri uri; + + try { + uri = Uri.parse(content); + // `Uri` identifies `localhost` as a scheme + if (!uri.hasScheme || uri.scheme == 'localhost') { + uri = Uri.parse("http://$content"); + await InternetAddress.lookup(uri.host); + } + } catch (_) { + uri = Uri.parse( + "https://www.google.com/search?q=${Uri.encodeComponent(content)}", + ); + } finally { + await launchUrl(uri); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checkbox_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checkbox_cell.dart new file mode 100644 index 0000000000000..8859372c2abe1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checkbox_cell.dart @@ -0,0 +1,28 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/checkbox.dart'; + +class MobileGridCheckboxCellSkin extends IEditableCheckboxCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + CheckboxCellBloc bloc, + CheckboxCellState state, + ) { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: FlowySvg( + state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, + size: const Size.square(24), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checklist_cell.dart new file mode 100644 index 0000000000000..ff9f83319f6c4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_checklist_cell.dart @@ -0,0 +1,53 @@ +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/checklist.dart'; + +class MobileGridChecklistCellSkin extends IEditableChecklistCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + ChecklistCellBloc bloc, + PopoverController popoverController, + ) { + return BlocBuilder( + builder: (context, state) { + return FlowyButton( + radius: BorderRadius.zero, + hoverColor: Colors.transparent, + text: Container( + alignment: Alignment.centerLeft, + padding: GridSize.cellContentInsets, + child: state.tasks.isEmpty + ? const SizedBox.shrink() + : ChecklistProgressBar( + tasks: state.tasks, + percent: state.percent, + textStyle: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontSize: 15), + ), + ), + onTap: () => showMobileBottomSheet( + context, + builder: (context) { + return BlocProvider.value( + value: bloc, + child: const MobileChecklistCellEditScreen(), + ); + }, + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_date_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_date_cell.dart new file mode 100644 index 0000000000000..43b6b7f347c06 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_date_cell.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class MobileGridDateCellSkin extends IEditableDateCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + DateCellBloc bloc, + DateCellState state, + PopoverController popoverController, + ) { + final dateStr = getDateCellStrFromCellData( + state.fieldInfo, + state.cellData, + ); + return FlowyButton( + radius: BorderRadius.zero, + hoverColor: Colors.transparent, + margin: EdgeInsets.zero, + text: Align( + alignment: AlignmentDirectional.centerStart, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + if (state.cellData.reminderId.isNotEmpty) ...[ + const FlowySvg(FlowySvgs.clock_alarm_s), + const HSpace(6), + ], + FlowyText( + dateStr, + fontSize: 15, + ), + ], + ), + ), + ), + onTap: () { + showMobileBottomSheet( + context, + builder: (context) { + return MobileDateCellEditScreen( + controller: bloc.cellController, + showAsFullScreen: false, + ); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_number_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_number_cell.dart new file mode 100644 index 0000000000000..c02cf6aa8d1b4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_number_cell.dart @@ -0,0 +1,29 @@ +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/number.dart'; + +class MobileGridNumberCellSkin extends IEditableNumberCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + NumberCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 15), + decoration: const InputDecoration( + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 12), + isCollapsed: true, + ), + onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart new file mode 100644 index 0000000000000..0951e2fb0d28e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_relation_cell.dart @@ -0,0 +1,52 @@ +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/relation.dart'; + +class MobileGridRelationCellSkin extends IEditableRelationCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + RelationCellBloc bloc, + RelationCellState state, + PopoverController popoverController, + ) { + return FlowyButton( + radius: BorderRadius.zero, + hoverColor: Colors.transparent, + margin: EdgeInsets.zero, + text: Align( + alignment: AlignmentDirectional.centerStart, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: state.rows + .map( + (row) => FlowyText( + row.name, + fontSize: 15, + decoration: TextDecoration.underline, + ), + ) + .toList(), + ), + ), + ), + onTap: () { + showMobileBottomSheet( + context, + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + builder: (context) { + return const FlowyText("Coming soon"); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_select_option_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_select_option_cell.dart new file mode 100644 index 0000000000000..61f67fec4f6d0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_select_option_cell.dart @@ -0,0 +1,68 @@ +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/select_option.dart'; + +class MobileGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + SelectOptionCellBloc bloc, + PopoverController popoverController, + ) { + return BlocBuilder( + builder: (context, state) { + return FlowyButton( + hoverColor: Colors.transparent, + radius: BorderRadius.zero, + margin: EdgeInsets.zero, + text: Align( + alignment: AlignmentDirectional.centerStart, + child: state.selectedOptions.isEmpty + ? const SizedBox.shrink() + : _buildOptions(context, state.selectedOptions), + ), + onTap: () { + showMobileBottomSheet( + context, + builder: (context) { + return MobileSelectOptionEditor( + cellController: bloc.cellController, + ); + }, + ); + }, + ); + }, + ); + } + + Widget _buildOptions(BuildContext context, List options) { + final children = options + .mapIndexed( + (index, option) => SelectOptionTag( + option: option, + fontSize: 14, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + ), + ) + .toList(); + + return ListView.separated( + scrollDirection: Axis.horizontal, + separatorBuilder: (context, index) => const HSpace(8), + itemCount: children.length, + itemBuilder: (context, index) => children[index], + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_summary_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_summary_cell.dart new file mode 100644 index 0000000000000..0da8d6bc64c84 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_summary_cell.dart @@ -0,0 +1,79 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_summary_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class MobileGridSummaryCellSkin extends IEditableSummaryCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + SummaryCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return ChangeNotifierProvider( + create: (_) => SummaryMouseNotifier(), + builder: (context, child) { + return MouseRegion( + cursor: SystemMouseCursors.click, + opaque: false, + onEnter: (p) => + Provider.of(context, listen: false) + .onEnter = true, + onExit: (p) => + Provider.of(context, listen: false) + .onEnter = false, + child: Stack( + children: [ + TextField( + controller: textEditingController, + readOnly: true, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + contentPadding: GridSize.cellContentInsets, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ), + Padding( + padding: EdgeInsets.symmetric( + horizontal: GridSize.cellVPadding, + ), + child: Consumer( + builder: ( + BuildContext context, + SummaryMouseNotifier notifier, + Widget? child, + ) { + if (notifier.onEnter) { + return SummaryCellAccessory( + viewId: bloc.cellController.viewId, + fieldId: bloc.cellController.fieldId, + rowId: bloc.cellController.rowId, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + ).positioned(right: 0, bottom: 0), + ], + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_text_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_text_cell.dart new file mode 100644 index 0000000000000..40e8c3531982c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_text_cell.dart @@ -0,0 +1,50 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/text.dart'; + +class MobileGridTextCellSkin extends IEditableTextCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TextCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return Row( + children: [ + const HSpace(10), + BlocBuilder( + buildWhen: (p, c) => p.emoji != c.emoji, + builder: (context, state) => Center( + child: FlowyText.emoji( + state.emoji?.value ?? "", + fontSize: 15, + optimizeEmojiAlign: true, + ), + ), + ), + Expanded( + child: TextField( + controller: textEditingController, + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 15, + ), + decoration: const InputDecoration( + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 4), + isCollapsed: true, + ), + onTapOutside: (event) => focusNode.unfocus(), + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_time_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_time_cell.dart new file mode 100644 index 0000000000000..08ab04c7c795e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_time_cell.dart @@ -0,0 +1,29 @@ +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/time.dart'; + +class MobileGridTimeCellSkin extends IEditableTimeCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimeCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 15), + decoration: const InputDecoration( + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 12), + isCollapsed: true, + ), + onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_timestamp_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_timestamp_cell.dart new file mode 100644 index 0000000000000..d9e020eece456 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_timestamp_cell.dart @@ -0,0 +1,26 @@ +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/timestamp.dart'; + +class MobileGridTimestampCellSkin extends IEditableTimestampCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimestampCellBloc bloc, + TimestampCellState state, + ) { + return Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: FlowyText( + state.dateStr, + fontSize: 15, + overflow: TextOverflow.ellipsis, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart new file mode 100644 index 0000000000000..3a7b44cbc5f94 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_translate_cell.dart @@ -0,0 +1,79 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_translate_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class MobileGridTranslateCellSkin extends IEditableTranslateCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TranslateCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return ChangeNotifierProvider( + create: (_) => TranslateMouseNotifier(), + builder: (context, child) { + return MouseRegion( + cursor: SystemMouseCursors.click, + opaque: false, + onEnter: (p) => + Provider.of(context, listen: false) + .onEnter = true, + onExit: (p) => + Provider.of(context, listen: false) + .onEnter = false, + child: Stack( + children: [ + TextField( + controller: textEditingController, + readOnly: true, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + contentPadding: GridSize.cellContentInsets, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ), + Padding( + padding: EdgeInsets.symmetric( + horizontal: GridSize.cellVPadding, + ), + child: Consumer( + builder: ( + BuildContext context, + TranslateMouseNotifier notifier, + Widget? child, + ) { + if (notifier.onEnter) { + return TranslateCellAccessory( + viewId: bloc.cellController.viewId, + fieldId: bloc.cellController.fieldId, + rowId: bloc.cellController.rowId, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + ).positioned(right: 0, bottom: 0), + ], + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_url_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_url_cell.dart new file mode 100644 index 0000000000000..cddb8219431a4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_grid/mobile_grid_url_cell.dart @@ -0,0 +1,71 @@ +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/url.dart'; + +class MobileGridURLCellSkin extends IEditableURLCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + URLCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + URLCellDataNotifier cellDataNotifier, + ) { + return BlocSelector( + selector: (state) => state.content, + builder: (context, content) { + return GestureDetector( + onTap: () => _showURLEditor(context, bloc, textEditingController), + behavior: HitTestBehavior.opaque, + child: Container( + alignment: AlignmentDirectional.centerStart, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + content, + maxLines: 1, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + decoration: TextDecoration.underline, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + ); + }, + ); + } + + void _showURLEditor( + BuildContext context, + URLCellBloc bloc, + TextEditingController textEditingController, + ) { + showMobileBottomSheet( + context, + showDragHandle: true, + backgroundColor: AFThemeExtension.of(context).background, + builder: (context) => BlocProvider.value( + value: bloc, + child: MobileURLEditor( + textEditingController: textEditingController, + ), + ), + ); + } + + @override + List>> accessoryBuilder( + GridCellAccessoryBuildContext context, + URLCellDataNotifier cellDataNotifier, + ) => + const []; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checkbox_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checkbox_cell.dart new file mode 100644 index 0000000000000..279e79091386e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checkbox_cell.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flowy_infra/theme_extension.dart'; + +import '../editable_cell_skeleton/checkbox.dart'; + +class MobileRowDetailCheckboxCellSkin extends IEditableCheckboxCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + CheckboxCellBloc bloc, + CheckboxCellState state, + ) { + return InkWell( + onTap: () => bloc.add(const CheckboxCellEvent.select()), + borderRadius: const BorderRadius.all(Radius.circular(14)), + child: Container( + constraints: const BoxConstraints( + minHeight: 48, + minWidth: double.infinity, + ), + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), + ), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + alignment: AlignmentDirectional.centerStart, + child: FlowySvg( + state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + color: AFThemeExtension.of(context).onBackground, + blendMode: BlendMode.dst, + size: const Size.square(24), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checklist_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checklist_cell.dart new file mode 100644 index 0000000000000..daf085e2ce10f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checklist_cell.dart @@ -0,0 +1,69 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/checklist.dart'; + +class MobileRowDetailChecklistCellSkin extends IEditableChecklistCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + ChecklistCellBloc bloc, + PopoverController popoverController, + ) { + return BlocBuilder( + builder: (context, state) { + return InkWell( + borderRadius: const BorderRadius.all(Radius.circular(14)), + onTap: () => showMobileBottomSheet( + context, + backgroundColor: AFThemeExtension.of(context).background, + builder: (context) { + return BlocProvider.value( + value: bloc, + child: const MobileChecklistCellEditScreen(), + ); + }, + ), + child: Container( + constraints: const BoxConstraints( + minHeight: 48, + minWidth: double.infinity, + ), + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), + ), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + alignment: AlignmentDirectional.centerStart, + child: state.tasks.isEmpty + ? FlowyText( + LocaleKeys.grid_row_textPlaceholder.tr(), + fontSize: 15, + color: Theme.of(context).hintColor, + ) + : ChecklistProgressBar( + tasks: state.tasks, + percent: state.percent, + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 15, + color: Theme.of(context).hintColor, + ), + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_date_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_date_cell.dart new file mode 100644 index 0000000000000..8671bacd8f771 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_date_cell.dart @@ -0,0 +1,69 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileRowDetailDateCellSkin extends IEditableDateCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + DateCellBloc bloc, + DateCellState state, + PopoverController popoverController, + ) { + final dateStr = getDateCellStrFromCellData( + state.fieldInfo, + state.cellData, + ); + final text = + dateStr.isEmpty ? LocaleKeys.grid_row_textPlaceholder.tr() : dateStr; + final color = dateStr.isEmpty ? Theme.of(context).hintColor : null; + + return InkWell( + borderRadius: const BorderRadius.all(Radius.circular(14)), + onTap: () => showMobileBottomSheet( + context, + builder: (context) { + return MobileDateCellEditScreen( + controller: bloc.cellController, + showAsFullScreen: false, + ); + }, + ), + child: Container( + constraints: const BoxConstraints( + minHeight: 48, + minWidth: double.infinity, + ), + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), + ), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + child: Row( + children: [ + if (state.cellData.reminderId.isNotEmpty) ...[ + const FlowySvg(FlowySvgs.clock_alarm_s), + const HSpace(6), + ], + FlowyText.regular( + text, + fontSize: 16, + color: color, + maxLines: null, + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_number_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_number_cell.dart new file mode 100644 index 0000000000000..6e32fbbbdc258 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_number_cell.dart @@ -0,0 +1,50 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/number.dart'; + +class MobileRowDetailNumberCellSkin extends IEditableNumberCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + NumberCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + keyboardType: const TextInputType.numberWithOptions( + signed: true, + decimal: true, + ), + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 16), + decoration: InputDecoration( + enabledBorder: + _getInputBorder(color: Theme.of(context).colorScheme.outline), + focusedBorder: + _getInputBorder(color: Theme.of(context).colorScheme.primary), + hintText: LocaleKeys.grid_row_textPlaceholder.tr(), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + isCollapsed: true, + isDense: true, + constraints: const BoxConstraints(), + ), + // close keyboard when tapping outside of the text field + onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), + ); + } + + InputBorder _getInputBorder({Color? color}) { + return OutlineInputBorder( + borderSide: BorderSide(color: color!), + borderRadius: const BorderRadius.all(Radius.circular(14)), + gapPadding: 0, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart new file mode 100644 index 0000000000000..61a39a867a4e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_relation_cell.dart @@ -0,0 +1,54 @@ +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/relation.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileRowDetailRelationCellSkin extends IEditableRelationCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + RelationCellBloc bloc, + RelationCellState state, + PopoverController popoverController, + ) { + return InkWell( + borderRadius: const BorderRadius.all(Radius.circular(14)), + onTap: () => showMobileBottomSheet( + context, + builder: (context) { + return const FlowyText("Coming soon"); + }, + ), + child: Container( + constraints: const BoxConstraints( + minHeight: 48, + minWidth: double.infinity, + ), + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), + ), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + child: Wrap( + runSpacing: 4.0, + spacing: 4.0, + children: state.rows + .map( + (row) => FlowyText( + row.name, + fontSize: 16, + decoration: TextDecoration.underline, + overflow: TextOverflow.ellipsis, + ), + ) + .toList(), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_select_cell_option.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_select_cell_option.dart new file mode 100644 index 0000000000000..9dafb6afe054b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_select_cell_option.dart @@ -0,0 +1,109 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../editable_cell_skeleton/select_option.dart'; + +class MobileRowDetailSelectOptionCellSkin + extends IEditableSelectOptionCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + SelectOptionCellBloc bloc, + PopoverController popoverController, + ) { + return BlocBuilder( + builder: (context, state) { + return InkWell( + borderRadius: const BorderRadius.all(Radius.circular(14)), + onTap: () => showMobileBottomSheet( + context, + builder: (context) { + return MobileSelectOptionEditor( + cellController: bloc.cellController, + ); + }, + ), + child: Container( + constraints: const BoxConstraints( + minHeight: 48, + minWidth: double.infinity, + ), + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: state.selectedOptions.isEmpty ? 13 : 10, + ), + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), + ), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + child: Row( + children: [ + Expanded( + child: state.selectedOptions.isEmpty + ? _buildPlaceholder(context) + : _buildOptions(context, state.selectedOptions), + ), + const HSpace(6), + RotatedBox( + quarterTurns: 3, + child: Icon( + Icons.chevron_left, + color: Theme.of(context).hintColor, + ), + ), + const HSpace(2), + ], + ), + ), + ); + }, + ); + } + + Widget _buildPlaceholder(BuildContext context) { + return Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(vertical: 1), + child: FlowyText( + LocaleKeys.grid_row_textPlaceholder.tr(), + color: Theme.of(context).hintColor, + ), + ); + } + + Widget _buildOptions(BuildContext context, List options) { + final children = options.mapIndexed( + (index, option) { + return Padding( + padding: EdgeInsets.only(left: index == 0 ? 0 : 4), + child: SelectOptionTag( + option: option, + fontSize: 14, + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 5), + ), + ); + }, + ).toList(); + + return Align( + alignment: AlignmentDirectional.centerStart, + child: Wrap( + runSpacing: 4, + children: children, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_summary_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_summary_cell.dart new file mode 100644 index 0000000000000..1e709bdeb95dc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_summary_cell.dart @@ -0,0 +1,66 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/summary_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flutter/material.dart'; + +class MobileRowDetailSummaryCellSkin extends IEditableSummaryCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + SummaryCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return Container( + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), + ), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + child: Column( + children: [ + TextField( + controller: textEditingController, + readOnly: true, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + maxLines: null, + minLines: 1, + decoration: InputDecoration( + contentPadding: GridSize.cellContentInsets, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ), + Row( + children: [ + const Spacer(), + Padding( + padding: const EdgeInsets.all(8.0), + child: SummaryCellAccessory( + viewId: bloc.cellController.viewId, + fieldId: bloc.cellController.fieldId, + rowId: bloc.cellController.rowId, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_text_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_text_cell.dart new file mode 100644 index 0000000000000..1cdde84c27dcd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_text_cell.dart @@ -0,0 +1,49 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/text.dart'; + +class MobileRowDetailTextCellSkin extends IEditableTextCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TextCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + maxLines: null, + decoration: InputDecoration( + enabledBorder: + _getInputBorder(color: Theme.of(context).colorScheme.outline), + focusedBorder: + _getInputBorder(color: Theme.of(context).colorScheme.primary), + hintText: LocaleKeys.grid_row_textPlaceholder.tr(), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + isCollapsed: true, + isDense: true, + constraints: const BoxConstraints(minHeight: 48), + hintStyle: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Theme.of(context).hintColor), + ), + onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), + ); + } + + InputBorder _getInputBorder({Color? color}) { + return OutlineInputBorder( + borderSide: BorderSide(color: color!), + borderRadius: const BorderRadius.all(Radius.circular(14)), + gapPadding: 0, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_time_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_time_cell.dart new file mode 100644 index 0000000000000..159f2063a45dc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_time_cell.dart @@ -0,0 +1,46 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/time.dart'; + +class MobileRowDetailTimeCellSkin extends IEditableTimeCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimeCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 16), + decoration: InputDecoration( + enabledBorder: + _getInputBorder(color: Theme.of(context).colorScheme.outline), + focusedBorder: + _getInputBorder(color: Theme.of(context).colorScheme.primary), + hintText: LocaleKeys.grid_row_textPlaceholder.tr(), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + isCollapsed: true, + isDense: true, + constraints: const BoxConstraints(), + ), + // close keyboard when tapping outside of the text field + onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), + ); + } + + InputBorder _getInputBorder({Color? color}) { + return OutlineInputBorder( + borderSide: BorderSide(color: color!), + borderRadius: const BorderRadius.all(Radius.circular(14)), + gapPadding: 0, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_timestamp_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_timestamp_cell.dart new file mode 100644 index 0000000000000..7ddda492c9b45 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_timestamp_cell.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/timestamp_cell_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import '../editable_cell_skeleton/timestamp.dart'; + +class MobileRowDetailTimestampCellSkin extends IEditableTimestampCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TimestampCellBloc bloc, + TimestampCellState state, + ) { + return Container( + constraints: const BoxConstraints( + minHeight: 48, + minWidth: double.infinity, + ), + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), + ), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + child: FlowyText( + state.dateStr.isEmpty + ? LocaleKeys.grid_row_textPlaceholder.tr() + : state.dateStr, + fontSize: 16, + color: state.dateStr.isEmpty ? Theme.of(context).hintColor : null, + maxLines: null, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart new file mode 100644 index 0000000000000..a1e4b4bf294b2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_translate_cell.dart @@ -0,0 +1,66 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:flutter/material.dart'; + +class MobileRowDetailTranslateCellSkin extends IEditableTranslateCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TranslateCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return Container( + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), + ), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + child: Column( + children: [ + TextField( + readOnly: true, + controller: textEditingController, + focusNode: focusNode, + onEditingComplete: () => focusNode.unfocus(), + onSubmitted: (_) => focusNode.unfocus(), + style: Theme.of(context).textTheme.bodyMedium, + textInputAction: TextInputAction.done, + maxLines: null, + minLines: 1, + decoration: InputDecoration( + contentPadding: GridSize.cellContentInsets, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + ), + ), + Row( + children: [ + const Spacer(), + Padding( + padding: const EdgeInsets.all(8.0), + child: TranslateCellAccessory( + viewId: bloc.cellController.viewId, + fieldId: bloc.cellController.fieldId, + rowId: bloc.cellController.rowId, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_url_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_url_cell.dart new file mode 100644 index 0000000000000..f87b22549272c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_url_cell.dart @@ -0,0 +1,80 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/url_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flowy_infra/theme_extension.dart'; + +import '../editable_cell_skeleton/url.dart'; + +class MobileRowDetailURLCellSkin extends IEditableURLCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + URLCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + URLCellDataNotifier cellDataNotifier, + ) { + return BlocSelector( + selector: (state) => state.content, + builder: (context, content) { + return InkWell( + borderRadius: const BorderRadius.all(Radius.circular(14)), + onTap: () => showMobileBottomSheet( + context, + showDragHandle: true, + backgroundColor: AFThemeExtension.of(context).background, + builder: (_) { + return BlocProvider.value( + value: bloc, + child: MobileURLEditor( + textEditingController: textEditingController, + ), + ); + }, + ), + child: Container( + constraints: const BoxConstraints( + minHeight: 48, + minWidth: double.infinity, + ), + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), + ), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + child: Text( + content.isEmpty + ? LocaleKeys.grid_row_textPlaceholder.tr() + : content, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 16, + decoration: + content.isEmpty ? null : TextDecoration.underline, + color: content.isEmpty + ? Theme.of(context).hintColor + : Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + ); + }, + ); + } + + @override + List>> accessoryBuilder( + GridCellAccessoryBuildContext context, + URLCellDataNotifier cellDataNotifier, + ) => + const []; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart new file mode 100644 index 0000000000000..b788d6bd387be --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_editor.dart @@ -0,0 +1,471 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; +import 'package:appflowy/util/debounce.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../application/cell/bloc/checklist_cell_bloc.dart'; +import 'checklist_cell_textfield.dart'; +import 'checklist_progress_bar.dart'; + +class ChecklistCellEditor extends StatefulWidget { + const ChecklistCellEditor({required this.cellController, super.key}); + + final ChecklistCellController cellController; + + @override + State createState() => _ChecklistCellEditorState(); +} + +class _ChecklistCellEditorState extends State { + /// Focus node for the new task text field + late final FocusNode newTaskFocusNode; + + @override + void initState() { + super.initState(); + newTaskFocusNode = FocusNode( + onKeyEvent: (node, event) { + if (event.logicalKey == LogicalKeyboardKey.escape) { + node.unfocus(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + ); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + if (state.tasks.isEmpty) { + newTaskFocusNode.requestFocus(); + } + }, + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (state.tasks.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: ChecklistProgressBar( + tasks: state.tasks, + percent: state.percent, + ), + ), + ChecklistItemList( + options: state.tasks, + onUpdateTask: () => newTaskFocusNode.requestFocus(), + ), + if (state.tasks.isNotEmpty) const TypeOptionSeparator(spacing: 0.0), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: NewTaskItem(focusNode: newTaskFocusNode), + ), + ], + ); + }, + ); + } + + @override + void dispose() { + newTaskFocusNode.dispose(); + super.dispose(); + } +} + +/// Displays the a list of all the existing tasks and an input field to create +/// a new task if `isAddingNewTask` is true +class ChecklistItemList extends StatelessWidget { + const ChecklistItemList({ + super.key, + required this.options, + required this.onUpdateTask, + }); + + final List options; + final VoidCallback onUpdateTask; + + @override + Widget build(BuildContext context) { + if (options.isEmpty) { + return const SizedBox.shrink(); + } + + final itemList = options + .mapIndexed( + (index, option) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0), + key: ValueKey(option.data.id), + child: ChecklistItem( + task: option, + index: index, + onSubmitted: index == options.length - 1 ? onUpdateTask : null, + ), + ), + ) + .toList(); + + return Flexible( + child: ReorderableListView.builder( + shrinkWrap: true, + proxyDecorator: (child, index, _) => Material( + color: Colors.transparent, + child: Stack( + children: [ + BlocProvider.value( + value: context.read(), + child: child, + ), + MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: const SizedBox.expand(), + ), + ], + ), + ), + buildDefaultDragHandles: false, + itemBuilder: (context, index) => itemList[index], + itemCount: itemList.length, + padding: const EdgeInsets.symmetric(vertical: 6.0), + onReorder: (from, to) { + context + .read() + .add(ChecklistCellEvent.reorderTask(from, to)); + }, + ), + ); + } +} + +class _SelectTaskIntent extends Intent { + const _SelectTaskIntent(); +} + +class _EndEditingTaskIntent extends Intent { + const _EndEditingTaskIntent(); +} + +class _UpdateTaskDescriptionIntent extends Intent { + const _UpdateTaskDescriptionIntent(); +} + +class ChecklistItem extends StatefulWidget { + const ChecklistItem({ + super.key, + required this.task, + required this.index, + this.onSubmitted, + this.autofocus = false, + }); + + final ChecklistSelectOption task; + final int index; + final VoidCallback? onSubmitted; + final bool autofocus; + + @override + State createState() => _ChecklistItemState(); +} + +class _ChecklistItemState extends State { + TextEditingController textController = TextEditingController(); + final textFieldFocusNode = FocusNode(); + final focusNode = FocusNode(skipTraversal: true); + + bool isHovered = false; + bool isFocused = false; + bool isComposing = false; + + final _debounceOnChanged = Debounce( + duration: const Duration(milliseconds: 300), + ); + + @override + void initState() { + super.initState(); + textController.text = widget.task.data.name; + textController.addListener(_onTextChanged); + if (widget.autofocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + textFieldFocusNode.requestFocus(); + }); + } + } + + void _onTextChanged() => + setState(() => isComposing = !textController.value.composing.isCollapsed); + + @override + void didUpdateWidget(covariant oldWidget) { + if (!focusNode.hasFocus && + oldWidget.task.data.name != widget.task.data.name) { + textController.text = widget.task.data.name; + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _debounceOnChanged.dispose(); + + textController.removeListener(_onTextChanged); + textController.dispose(); + focusNode.dispose(); + textFieldFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isFocusedOrHovered = isHovered || isFocused; + final color = isFocusedOrHovered || textFieldFocusNode.hasFocus + ? AFThemeExtension.of(context).lightGreyHover + : Colors.transparent; + return FocusableActionDetector( + focusNode: focusNode, + onShowHoverHighlight: (value) => setState(() => isHovered = value), + onFocusChange: (value) => setState(() => isFocused = value), + actions: _buildActions(), + shortcuts: _buildShortcuts(), + child: Container( + constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight), + decoration: BoxDecoration(color: color, borderRadius: Corners.s6Border), + child: _buildChild(isFocusedOrHovered && !textFieldFocusNode.hasFocus), + ), + ); + } + + Widget _buildChild(bool showTrash) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ReorderableDragStartListener( + index: widget.index, + child: MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grab, + child: SizedBox( + width: 20, + height: 32, + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: FlowySvg( + FlowySvgs.drag_element_s, + size: const Size.square(14), + color: AFThemeExtension.of(context).onBackground, + ), + ), + ), + ), + ), + ChecklistCellCheckIcon(task: widget.task), + Expanded( + child: ChecklistCellTextfield( + textController: textController, + focusNode: textFieldFocusNode, + onChanged: () { + _debounceOnChanged.call(() { + if (!isComposing) { + _submitUpdateTaskDescription(textController.text); + } + }); + }, + onSubmitted: () { + _submitUpdateTaskDescription(textController.text); + + if (widget.onSubmitted != null) { + widget.onSubmitted?.call(); + } else { + Actions.invoke(context, const NextFocusIntent()); + } + }, + ), + ), + if (showTrash) + ChecklistCellDeleteButton( + onPressed: () => context + .read() + .add(ChecklistCellEvent.deleteTask(widget.task.data.id)), + ), + ], + ); + } + + Map _buildShortcuts() { + return { + SingleActivator( + LogicalKeyboardKey.enter, + meta: Platform.isMacOS, + control: !Platform.isMacOS, + ): const _SelectTaskIntent(), + if (!isComposing) + const SingleActivator(LogicalKeyboardKey.enter): + const _UpdateTaskDescriptionIntent(), + if (!isComposing) + const SingleActivator(LogicalKeyboardKey.escape): + const _EndEditingTaskIntent(), + }; + } + + Map> _buildActions() { + return { + _SelectTaskIntent: CallbackAction<_SelectTaskIntent>( + onInvoke: (_SelectTaskIntent intent) { + context + .read() + .add(ChecklistCellEvent.selectTask(widget.task.data.id)); + return; + }, + ), + _UpdateTaskDescriptionIntent: + CallbackAction<_UpdateTaskDescriptionIntent>( + onInvoke: (_UpdateTaskDescriptionIntent intent) { + textFieldFocusNode.unfocus(); + widget.onSubmitted?.call(); + return; + }, + ), + _EndEditingTaskIntent: CallbackAction<_EndEditingTaskIntent>( + onInvoke: (_EndEditingTaskIntent intent) { + textFieldFocusNode.unfocus(); + return; + }, + ), + }; + } + + void _submitUpdateTaskDescription(String description) => context + .read() + .add(ChecklistCellEvent.updateTaskName(widget.task.data, description)); +} + +/// Creates a new task after entering the description and pressing enter. +/// This can be cancelled by pressing escape +@visibleForTesting +class NewTaskItem extends StatefulWidget { + const NewTaskItem({super.key, required this.focusNode}); + + final FocusNode focusNode; + + @override + State createState() => _NewTaskItemState(); +} + +class _NewTaskItemState extends State { + final textController = TextEditingController(); + + bool isCreateButtonEnabled = false; + bool isComposing = false; + + @override + void initState() { + super.initState(); + textController.addListener(_onTextChanged); + if (widget.focusNode.canRequestFocus) { + widget.focusNode.requestFocus(); + } + } + + void _onTextChanged() => + setState(() => isComposing = !textController.value.composing.isCollapsed); + + @override + void dispose() { + textController.removeListener(_onTextChanged); + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight), + child: Row( + children: [ + const HSpace(8), + Expanded( + child: CallbackShortcuts( + bindings: isComposing + ? const {} + : { + const SingleActivator(LogicalKeyboardKey.enter): () => + _createNewTask(context), + }, + child: TextField( + focusNode: widget.focusNode, + controller: textController, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: null, + decoration: InputDecoration( + border: InputBorder.none, + isCollapsed: true, + contentPadding: const EdgeInsets.symmetric( + vertical: 6.0, + horizontal: 2.0, + ), + hintText: LocaleKeys.grid_checklist_addNew.tr(), + ), + onSubmitted: (_) => _createNewTask(context), + onChanged: (_) => setState( + () => isCreateButtonEnabled = textController.text.isNotEmpty, + ), + ), + ), + ), + FlowyTextButton( + LocaleKeys.grid_checklist_submitNewTask.tr(), + fontSize: 11, + fillColor: isCreateButtonEnabled + ? Theme.of(context).colorScheme.primary + : Theme.of(context).disabledColor, + hoverColor: isCreateButtonEnabled + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).disabledColor, + fontColor: Theme.of(context).colorScheme.onPrimary, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + onPressed: isCreateButtonEnabled + ? () { + context.read().add( + ChecklistCellEvent.createNewTask(textController.text), + ); + widget.focusNode.requestFocus(); + textController.clear(); + } + : null, + ), + ], + ), + ); + } + + void _createNewTask(BuildContext context) { + final taskDescription = textController.text; + if (taskDescription.isNotEmpty) { + context + .read() + .add(ChecklistCellEvent.createNewTask(taskDescription)); + textController.clear(); + } + widget.focusNode.requestFocus(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart new file mode 100644 index 0000000000000..789a4adf46efb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_cell_textfield.dart @@ -0,0 +1,130 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../application/cell/bloc/checklist_cell_bloc.dart'; + +class ChecklistCellCheckIcon extends StatelessWidget { + const ChecklistCellCheckIcon({ + super.key, + required this.task, + }); + + final ChecklistSelectOption task; + + @override + Widget build(BuildContext context) { + return ExcludeFocus( + child: FlowyIconButton( + width: 32, + icon: FlowySvg( + task.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + blendMode: BlendMode.dst, + ), + hoverColor: Colors.transparent, + onPressed: () => context.read().add( + ChecklistCellEvent.selectTask(task.data.id), + ), + ), + ); + } +} + +class ChecklistCellTextfield extends StatelessWidget { + const ChecklistCellTextfield({ + super.key, + required this.textController, + required this.focusNode, + this.onChanged, + this.contentPadding = const EdgeInsets.symmetric( + vertical: 8, + horizontal: 2, + ), + this.onSubmitted, + }); + + final TextEditingController textController; + final FocusNode focusNode; + final EdgeInsetsGeometry contentPadding; + final VoidCallback? onSubmitted; + final VoidCallback? onChanged; + + @override + Widget build(BuildContext context) { + return TextField( + controller: textController, + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: null, + decoration: InputDecoration( + border: InputBorder.none, + isCollapsed: true, + isDense: true, + contentPadding: contentPadding, + hintText: LocaleKeys.grid_checklist_taskHint.tr(), + ), + textInputAction: onSubmitted == null ? TextInputAction.next : null, + onChanged: (_) => onChanged?.call(), + onSubmitted: (_) => onSubmitted?.call(), + ); + } +} + +class ChecklistCellDeleteButton extends StatefulWidget { + const ChecklistCellDeleteButton({ + super.key, + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + State createState() => + _ChecklistCellDeleteButtonState(); +} + +class _ChecklistCellDeleteButtonState extends State { + final _materialStatesController = WidgetStatesController(); + + @override + void dispose() { + _materialStatesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: widget.onPressed, + onHover: (_) => setState(() {}), + onFocusChange: (_) => setState(() {}), + style: ButtonStyle( + fixedSize: const WidgetStatePropertyAll(Size.square(32)), + minimumSize: const WidgetStatePropertyAll(Size.square(32)), + maximumSize: const WidgetStatePropertyAll(Size.square(32)), + overlayColor: WidgetStateProperty.resolveWith((state) { + if (state.contains(WidgetState.focused)) { + return AFThemeExtension.of(context).greyHover; + } + return Colors.transparent; + }), + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: Corners.s6Border), + ), + ), + statesController: _materialStatesController, + child: FlowySvg( + FlowySvgs.delete_s, + color: _materialStatesController.value.contains(WidgetState.hovered) || + _materialStatesController.value.contains(WidgetState.focused) + ? Theme.of(context).colorScheme.error + : null, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_progress_bar.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_progress_bar.dart new file mode 100644 index 0000000000000..dd831282cd507 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/checklist_progress_bar.dart @@ -0,0 +1,83 @@ +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:percent_indicator/percent_indicator.dart'; + +import '../../application/cell/bloc/checklist_cell_bloc.dart'; + +class ChecklistProgressBar extends StatefulWidget { + const ChecklistProgressBar({ + super.key, + required this.tasks, + required this.percent, + this.textStyle, + }); + + final List tasks; + final double percent; + final TextStyle? textStyle; + final int segmentLimit = 5; + + @override + State createState() => _ChecklistProgressBarState(); +} + +class _ChecklistProgressBarState extends State { + @override + Widget build(BuildContext context) { + final numFinishedTasks = widget.tasks.where((e) => e.isSelected).length; + final completedTaskColor = numFinishedTasks == widget.tasks.length + ? AFThemeExtension.of(context).success + : Theme.of(context).colorScheme.primary; + + return Row( + children: [ + Expanded( + child: Row( + children: [ + if (widget.tasks.isNotEmpty && + widget.tasks.length <= widget.segmentLimit) + ...List.generate( + widget.tasks.length, + (index) => Flexible( + child: Container( + decoration: BoxDecoration( + borderRadius: + const BorderRadius.all(Radius.circular(2)), + color: index < numFinishedTasks + ? completedTaskColor + : AFThemeExtension.of(context).progressBarBGColor, + ), + margin: const EdgeInsets.symmetric(horizontal: 1), + height: 4.0, + ), + ), + ) + else + Expanded( + child: LinearPercentIndicator( + lineHeight: 4.0, + percent: widget.percent, + padding: EdgeInsets.zero, + progressColor: completedTaskColor, + backgroundColor: + AFThemeExtension.of(context).progressBarBGColor, + barRadius: const Radius.circular(2), + ), + ), + ], + ), + ), + SizedBox( + width: 45, + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Text( + "${(widget.percent * 100).round()}%", + style: widget.textStyle, + ), + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/date_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/date_cell_editor.dart new file mode 100644 index 0000000000000..3ee7f1ef561fe --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/date_cell_editor.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../application/cell/bloc/date_cell_editor_bloc.dart'; + +class DateCellEditor extends StatefulWidget { + const DateCellEditor({ + super.key, + required this.onDismissed, + required this.cellController, + }); + + final VoidCallback onDismissed; + final DateCellController cellController; + + @override + State createState() => _DateCellEditor(); +} + +class _DateCellEditor extends State { + final PopoverMutex popoverMutex = PopoverMutex(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DateCellEditorBloc( + reminderBloc: getIt(), + cellController: widget.cellController, + ), + child: BlocBuilder( + builder: (context, state) { + final dateCellBloc = context.read(); + return DesktopAppFlowyDatePicker( + dateTime: state.dateTime, + endDateTime: state.endDateTime, + dateFormat: state.dateTypeOptionPB.dateFormat, + timeFormat: state.dateTypeOptionPB.timeFormat, + includeTime: state.includeTime, + isRange: state.isRange, + reminderOption: state.reminderOption, + popoverMutex: popoverMutex, + options: [ + OptionGroup( + options: [ + DateTypeOptionButton( + popoverMutex: popoverMutex, + dateFormat: state.dateTypeOptionPB.dateFormat, + timeFormat: state.dateTypeOptionPB.timeFormat, + onDateFormatChanged: (format) { + dateCellBloc + .add(DateCellEditorEvent.setDateFormat(format)); + }, + onTimeFormatChanged: (format) { + dateCellBloc + .add(DateCellEditorEvent.setTimeFormat(format)); + }, + ), + ClearDateButton( + onClearDate: () { + dateCellBloc.add(const DateCellEditorEvent.clearDate()); + }, + ), + ], + ), + ], + onIncludeTimeChanged: (value, dateTime, endDateTime) { + dateCellBloc.add( + DateCellEditorEvent.setIncludeTime( + value, + dateTime, + endDateTime, + ), + ); + }, + onIsRangeChanged: (value, dateTime, endDateTime) { + dateCellBloc.add( + DateCellEditorEvent.setIsRange(value, dateTime, endDateTime), + ); + }, + onDaySelected: (selectedDay) { + dateCellBloc.add(DateCellEditorEvent.updateDateTime(selectedDay)); + }, + onRangeSelected: (start, end) { + dateCellBloc.add(DateCellEditorEvent.updateDateRange(start, end)); + }, + onReminderSelected: (option) { + dateCellBloc.add(DateCellEditorEvent.setReminderOption(option)); + }, + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/extension.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/extension.dart new file mode 100644 index 0000000000000..c29c7a23c15bc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/extension.dart @@ -0,0 +1,124 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +extension SelectOptionColorExtension on SelectOptionColorPB { + Color toColor(BuildContext context) { + switch (this) { + case SelectOptionColorPB.Purple: + return AFThemeExtension.of(context).tint1; + case SelectOptionColorPB.Pink: + return AFThemeExtension.of(context).tint2; + case SelectOptionColorPB.LightPink: + return AFThemeExtension.of(context).tint3; + case SelectOptionColorPB.Orange: + return AFThemeExtension.of(context).tint4; + case SelectOptionColorPB.Yellow: + return AFThemeExtension.of(context).tint5; + case SelectOptionColorPB.Lime: + return AFThemeExtension.of(context).tint6; + case SelectOptionColorPB.Green: + return AFThemeExtension.of(context).tint7; + case SelectOptionColorPB.Aqua: + return AFThemeExtension.of(context).tint8; + case SelectOptionColorPB.Blue: + return AFThemeExtension.of(context).tint9; + default: + throw ArgumentError; + } + } + + String colorName() { + switch (this) { + case SelectOptionColorPB.Purple: + return LocaleKeys.grid_selectOption_purpleColor.tr(); + case SelectOptionColorPB.Pink: + return LocaleKeys.grid_selectOption_pinkColor.tr(); + case SelectOptionColorPB.LightPink: + return LocaleKeys.grid_selectOption_lightPinkColor.tr(); + case SelectOptionColorPB.Orange: + return LocaleKeys.grid_selectOption_orangeColor.tr(); + case SelectOptionColorPB.Yellow: + return LocaleKeys.grid_selectOption_yellowColor.tr(); + case SelectOptionColorPB.Lime: + return LocaleKeys.grid_selectOption_limeColor.tr(); + case SelectOptionColorPB.Green: + return LocaleKeys.grid_selectOption_greenColor.tr(); + case SelectOptionColorPB.Aqua: + return LocaleKeys.grid_selectOption_aquaColor.tr(); + case SelectOptionColorPB.Blue: + return LocaleKeys.grid_selectOption_blueColor.tr(); + default: + throw ArgumentError; + } + } +} + +class SelectOptionTag extends StatelessWidget { + const SelectOptionTag({ + super.key, + this.option, + this.name, + this.fontSize, + this.color, + this.textStyle, + this.onRemove, + this.textAlign, + this.isExpanded = false, + this.borderRadius, + required this.padding, + }) : assert(option != null || name != null && color != null); + + final SelectOptionPB? option; + final String? name; + final double? fontSize; + final Color? color; + final TextStyle? textStyle; + final void Function(String)? onRemove; + final EdgeInsets padding; + final BorderRadius? borderRadius; + final TextAlign? textAlign; + final bool isExpanded; + + @override + Widget build(BuildContext context) { + final optionName = option?.name ?? name!; + final optionColor = option?.color.toColor(context) ?? color!; + final text = FlowyText( + optionName, + fontSize: fontSize, + overflow: TextOverflow.ellipsis, + color: AFThemeExtension.of(context).textColor, + textAlign: textAlign, + ); + + return Container( + padding: onRemove == null ? padding : padding.copyWith(right: 2.0), + decoration: BoxDecoration( + color: optionColor, + borderRadius: borderRadius ?? + BorderRadius.circular(UniversalPlatform.isDesktopOrWeb ? 6 : 11), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + isExpanded ? Expanded(child: text) : Flexible(child: text), + if (onRemove != null) ...[ + const HSpace(4), + FlowyIconButton( + width: 16.0, + onPressed: () => onRemove?.call(optionName), + hoverColor: Colors.transparent, + icon: const FlowySvg(FlowySvgs.close_s), + ), + ], + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart new file mode 100644 index 0000000000000..eba42c1f97694 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart @@ -0,0 +1,622 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/shared/af_image.dart'; +import 'package:appflowy/util/xfile_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MediaCellEditor extends StatefulWidget { + const MediaCellEditor({super.key}); + + @override + State createState() => _MediaCellEditorState(); +} + +class _MediaCellEditorState extends State { + final addFilePopoverController = PopoverController(); + final itemMutex = PopoverMutex(); + + @override + void dispose() { + addFilePopoverController.close(); + itemMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final images = state.files + .where((file) => file.fileType == MediaFileTypePB.Image) + .toList(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (state.files.isNotEmpty) ...[ + Flexible( + child: ReorderableListView.builder( + padding: const EdgeInsets.all(6), + physics: const ClampingScrollPhysics(), + shrinkWrap: true, + buildDefaultDragHandles: false, + itemBuilder: (_, index) => BlocProvider.value( + key: Key(state.files[index].id), + value: context.read(), + child: RenderMedia( + file: state.files[index], + images: images, + index: index, + enableReordering: state.files.length > 1, + mutex: itemMutex, + ), + ), + itemCount: state.files.length, + onReorder: (from, to) { + if (from < to) { + to--; + } + + context + .read() + .add(MediaCellEvent.reorderFiles(from: from, to: to)); + }, + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: SizeTransition( + sizeFactor: animation, + child: child, + ), + ), + ), + ), + ], + _AddButton(addFilePopoverController: addFilePopoverController), + ], + ); + }, + ); + } +} + +class _AddButton extends StatelessWidget { + const _AddButton({required this.addFilePopoverController}); + + final PopoverController addFilePopoverController; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: addFilePopoverController, + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 10), + constraints: const BoxConstraints(maxWidth: 350), + triggerActions: PopoverTriggerFlags.none, + popupBuilder: (popoverContext) => FileUploadMenu( + allowMultipleFiles: true, + onInsertLocalFile: (files) async => insertLocalFiles( + context, + files, + userProfile: context.read().state.userProfile, + documentId: context.read().rowId, + onUploadSuccess: (file, path, isLocalMode) { + final mediaCellBloc = context.read(); + if (mediaCellBloc.isClosed) { + return; + } + + mediaCellBloc.add( + MediaCellEvent.addFile( + url: path, + name: file.name, + uploadType: isLocalMode + ? FileUploadTypePB.LocalFile + : FileUploadTypePB.CloudFile, + fileType: file.fileType.toMediaFileTypePB(), + ), + ); + + addFilePopoverController.close(); + }, + ), + onInsertNetworkFile: (url) { + if (url.isEmpty) return; + + final uri = Uri.tryParse(url); + if (uri == null) { + return; + } + + final fakeFile = XFile(uri.path); + MediaFileTypePB fileType = fakeFile.fileType.toMediaFileTypePB(); + fileType = fileType == MediaFileTypePB.Other + ? MediaFileTypePB.Link + : fileType; + + String name = + uri.pathSegments.isNotEmpty ? uri.pathSegments.last : ""; + if (name.isEmpty && uri.pathSegments.length > 1) { + name = uri.pathSegments[uri.pathSegments.length - 2]; + } else if (name.isEmpty) { + name = uri.host; + } + + context.read().add( + MediaCellEvent.addFile( + url: url, + name: name, + uploadType: FileUploadTypePB.NetworkFile, + fileType: fileType, + ), + ); + + addFilePopoverController.close(); + }, + ), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: addFilePopoverController.show, + child: FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Row( + children: [ + FlowySvg( + FlowySvgs.add_thin_s, + size: const Size.square(14), + color: AFThemeExtension.of(context).lightIconColor, + ), + const HSpace(8), + FlowyText.regular( + LocaleKeys.grid_media_addFileOrImage.tr(), + figmaLineHeight: 20, + fontSize: 14, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +extension ToCustomImageType on FileUploadTypePB { + CustomImageType toCustomImageType() => switch (this) { + FileUploadTypePB.NetworkFile => CustomImageType.external, + FileUploadTypePB.CloudFile => CustomImageType.internal, + _ => CustomImageType.local, + }; +} + +@visibleForTesting +class RenderMedia extends StatefulWidget { + const RenderMedia({ + super.key, + required this.index, + required this.file, + required this.images, + required this.enableReordering, + required this.mutex, + }); + + final int index; + final MediaFilePB file; + final List images; + final bool enableReordering; + final PopoverMutex mutex; + + @override + State createState() => _RenderMediaState(); +} + +class _RenderMediaState extends State { + bool isHovering = false; + int? imageIndex; + + MediaFilePB get file => widget.file; + + late final controller = PopoverController(); + + @override + void initState() { + super.initState(); + imageIndex = widget.images.indexOf(file); + } + + @override + void didUpdateWidget(covariant RenderMedia oldWidget) { + imageIndex = widget.images.indexOf(file); + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: MouseRegion( + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + cursor: SystemMouseCursors.click, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isHovering + ? AFThemeExtension.of(context).greyHover + : Colors.transparent, + ), + child: Row( + crossAxisAlignment: widget.file.fileType == MediaFileTypePB.Image + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + ReorderableDragStartListener( + index: widget.index, + enabled: widget.enableReordering, + child: Padding( + padding: const EdgeInsets.all(4), + child: FlowySvg( + FlowySvgs.drag_element_s, + color: AFThemeExtension.of(context).lightIconColor, + ), + ), + ), + const HSpace(4), + if (widget.file.fileType == MediaFileTypePB.Image) ...[ + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: _openInteractiveViewer( + context, + files: widget.images, + index: imageIndex!, + child: AFImage( + url: widget.file.url, + uploadType: widget.file.uploadType, + userProfile: + context.read().state.userProfile, + ), + ), + ), + ), + ] else ...[ + Expanded( + child: GestureDetector( + onTap: () => afLaunchUrlString(file.url), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: FlowySvg( + file.fileType.icon, + color: AFThemeExtension.of(context).strongText, + size: const Size.square(12), + ), + ), + const HSpace(8), + Flexible( + child: Padding( + padding: const EdgeInsets.only(bottom: 1), + child: FlowyText( + file.name, + overflow: TextOverflow.ellipsis, + fontSize: 14, + ), + ), + ), + ], + ), + ), + ), + ], + const HSpace(4), + AppFlowyPopover( + controller: controller, + mutex: widget.mutex, + asBarrier: true, + offset: const Offset(0, 4), + constraints: const BoxConstraints(maxWidth: 240), + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (popoverContext) => BlocProvider.value( + value: context.read(), + child: MediaItemMenu( + file: file, + images: widget.images, + index: imageIndex ?? -1, + closeContext: popoverContext, + onAction: () => controller.close(), + ), + ), + child: FlowyIconButton( + hoverColor: Colors.transparent, + width: 24, + icon: FlowySvg( + FlowySvgs.three_dots_s, + size: const Size.square(16), + color: AFThemeExtension.of(context).lightIconColor, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _openInteractiveViewer( + BuildContext context, { + required List files, + required int index, + required Widget child, + }) => + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => openInteractiveViewerFromFiles( + context, + files, + onDeleteImage: (index) { + final deleteFile = files[index]; + context.read().deleteFile(deleteFile.id); + }, + userProfile: context.read().state.userProfile, + initialIndex: index, + ), + child: child, + ); +} + +class MediaItemMenu extends StatefulWidget { + const MediaItemMenu({ + super.key, + required this.file, + required this.images, + required this.index, + this.closeContext, + this.onAction, + }); + + /// The [MediaFilePB] this menu concerns + final MediaFilePB file; + + /// The list of [MediaFilePB] which are images + /// This is used to show the [InteractiveImageViewer] + final List images; + + /// The index of the [MediaFilePB] in the [images] list + final int index; + + /// The [BuildContext] used to show the [InteractiveImageViewer] + final BuildContext? closeContext; + + /// Callback to be called when an action is performed + final VoidCallback? onAction; + + @override + State createState() => _MediaItemMenuState(); +} + +class _MediaItemMenuState extends State { + late final nameController = TextEditingController(text: widget.file.name); + final errorMessage = ValueNotifier(null); + + BuildContext? renameContext; + + @override + void dispose() { + nameController.dispose(); + errorMessage.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SeparatedColumn( + separatorBuilder: () => const VSpace(8), + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.file.fileType == MediaFileTypePB.Image) ...[ + MediaMenuItem( + onTap: () { + widget.onAction?.call(); + _showInteractiveViewer(); + }, + icon: FlowySvgs.full_view_s, + label: LocaleKeys.grid_media_expand.tr(), + ), + MediaMenuItem( + onTap: () { + context.read().add( + MediaCellEvent.setCover( + RowCoverPB( + data: widget.file.url, + uploadType: widget.file.uploadType, + coverType: CoverTypePB.FileCover, + ), + ), + ); + widget.onAction?.call(); + }, + icon: FlowySvgs.cover_s, + label: LocaleKeys.grid_media_setAsCover.tr(), + ), + ], + MediaMenuItem( + onTap: () { + widget.onAction?.call(); + afLaunchUrlString(widget.file.url); + }, + icon: FlowySvgs.open_in_browser_s, + label: LocaleKeys.grid_media_openInBrowser.tr(), + ), + MediaMenuItem( + onTap: () async { + await _showRenameDialog(); + widget.onAction?.call(); + }, + icon: FlowySvgs.rename_s, + label: LocaleKeys.grid_media_rename.tr(), + ), + if (widget.file.uploadType == FileUploadTypePB.CloudFile) ...[ + MediaMenuItem( + onTap: () async { + await downloadMediaFile( + context, + widget.file, + userProfile: context.read().state.userProfile, + ); + widget.onAction?.call(); + }, + icon: FlowySvgs.save_as_s, + label: LocaleKeys.button_download.tr(), + ), + ], + MediaMenuItem( + onTap: () async { + await showConfirmDeletionDialog( + context: context, + name: widget.file.name, + description: LocaleKeys.grid_media_deleteFileDescription.tr(), + onConfirm: () => context + .read() + .add(MediaCellEvent.removeFile(fileId: widget.file.id)), + ); + widget.onAction?.call(); + }, + icon: FlowySvgs.trash_s, + label: LocaleKeys.button_delete.tr(), + ), + ], + ); + } + + Future _showRenameDialog() async { + nameController.selection = TextSelection( + baseOffset: 0, + extentOffset: nameController.text.length, + ); + + await showCustomConfirmDialog( + context: context, + title: LocaleKeys.document_plugins_file_renameFile_title.tr(), + description: LocaleKeys.document_plugins_file_renameFile_description.tr(), + closeOnConfirm: false, + builder: (dialogContext) { + renameContext = dialogContext; + return FileRenameTextField( + nameController: nameController, + errorMessage: errorMessage, + onSubmitted: () => _saveName(context), + disposeController: false, + ); + }, + confirmLabel: LocaleKeys.button_save.tr(), + onConfirm: () => _saveName(context), + ); + } + + void _saveName(BuildContext context) { + if (nameController.text.isEmpty) { + errorMessage.value = + LocaleKeys.document_plugins_file_renameFile_nameEmptyError.tr(); + return; + } + + context.read().add( + MediaCellEvent.renameFile( + fileId: widget.file.id, + name: nameController.text, + ), + ); + + if (renameContext != null) { + Navigator.of(renameContext!).pop(); + } + } + + void _showInteractiveViewer() { + showDialog( + context: widget.closeContext ?? context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfile, + imageProvider: AFBlockImageProvider( + initialIndex: widget.index, + images: widget.images + .map( + (e) => ImageBlockData( + url: e.url, + type: e.uploadType.toCustomImageType(), + ), + ) + .toList(), + onDeleteImage: (index) { + final deleteFile = widget.images[index]; + context.read().deleteFile(deleteFile.id); + }, + ), + ), + ); + } +} + +class MediaMenuItem extends StatelessWidget { + const MediaMenuItem({ + super.key, + required this.onTap, + required this.icon, + required this.label, + }); + + final VoidCallback onTap; + final FlowySvgData icon; + final String label; + + @override + Widget build(BuildContext context) { + return FlowyButton( + onTap: onTap, + leftIcon: FlowySvg(icon), + text: Padding( + padding: const EdgeInsets.only(left: 4, top: 1, bottom: 1), + child: FlowyText.regular( + label, + figmaLineHeight: 20, + ), + ), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart new file mode 100644 index 0000000000000..6e86d88e5bdf2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart @@ -0,0 +1,326 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class MobileChecklistCellEditScreen extends StatefulWidget { + const MobileChecklistCellEditScreen({super.key}); + + @override + State createState() => + _MobileChecklistCellEditScreenState(); +} + +class _MobileChecklistCellEditScreenState + extends State { + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints.tightFor(height: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const DragHandle(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: _buildHeader(context), + ), + const Divider(), + const Expanded(child: _TaskList()), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Stack( + children: [ + SizedBox( + height: 44.0, + child: Align( + child: FlowyText.medium( + LocaleKeys.grid_field_checklistFieldName.tr(), + fontSize: 18, + ), + ), + ), + ], + ); + } +} + +class _TaskList extends StatelessWidget { + const _TaskList(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final cells = []; + cells.addAll( + state.tasks + .mapIndexed( + (index, task) => _ChecklistItem( + key: ValueKey('mobile_checklist_task_${task.data.id}'), + task: task, + index: index, + autofocus: state.phantomIndex != null && + index == state.tasks.length - 1, + onAutofocus: () { + context + .read() + .add(const ChecklistCellEvent.updatePhantomIndex(null)); + }, + ), + ) + .toList(), + ); + cells.add( + const _NewTaskButton(key: ValueKey('mobile_checklist_new_task')), + ); + + return ReorderableListView.builder( + shrinkWrap: true, + proxyDecorator: (child, index, _) => Material( + color: Colors.transparent, + child: BlocProvider.value( + value: context.read(), + child: child, + ), + ), + buildDefaultDragHandles: false, + itemCount: cells.length, + itemBuilder: (_, index) => cells[index], + padding: const EdgeInsets.only(bottom: 12.0), + onReorder: (from, to) { + context + .read() + .add(ChecklistCellEvent.reorderTask(from, to)); + }, + ); + }, + ); + } +} + +class _ChecklistItem extends StatefulWidget { + const _ChecklistItem({ + super.key, + required this.task, + required this.index, + required this.autofocus, + this.onAutofocus, + }); + + final ChecklistSelectOption task; + final int index; + final bool autofocus; + final VoidCallback? onAutofocus; + + @override + State<_ChecklistItem> createState() => _ChecklistItemState(); +} + +class _ChecklistItemState extends State<_ChecklistItem> { + late final TextEditingController textController; + final FocusNode focusNode = FocusNode(); + Timer? _debounceOnChanged; + + @override + void initState() { + super.initState(); + textController = TextEditingController(text: widget.task.data.name); + if (widget.autofocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + widget.onAutofocus?.call(); + }); + } + } + + @override + void didUpdateWidget(covariant oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.task.data.name != oldWidget.task.data.name && + !focusNode.hasFocus) { + textController.text = widget.task.data.name; + } + } + + @override + void dispose() { + textController.dispose(); + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 4), + constraints: const BoxConstraints(minHeight: 44), + child: Row( + children: [ + ReorderableDelayedDragStartListener( + index: widget.index, + child: InkWell( + borderRadius: BorderRadius.circular(22), + onTap: () => context + .read() + .add(ChecklistCellEvent.selectTask(widget.task.data.id)), + child: SizedBox.square( + dimension: 44, + child: Center( + child: FlowySvg( + widget.task.isSelected + ? FlowySvgs.check_filled_s + : FlowySvgs.uncheck_s, + size: const Size.square(20.0), + blendMode: BlendMode.dst, + ), + ), + ), + ), + ), + Expanded( + child: TextField( + controller: textController, + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyMedium, + keyboardType: TextInputType.multiline, + maxLines: null, + decoration: InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isCollapsed: true, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + hintText: LocaleKeys.grid_checklist_taskHint.tr(), + ), + onChanged: _debounceOnChangedText, + onSubmitted: (description) { + _submitUpdateTaskDescription(description); + }, + ), + ), + InkWell( + borderRadius: BorderRadius.circular(22), + onTap: _showDeleteTaskBottomSheet, + child: SizedBox.square( + dimension: 44, + child: Center( + child: FlowySvg( + FlowySvgs.three_dots_s, + color: Theme.of(context).hintColor, + ), + ), + ), + ), + ], + ), + ); + } + + void _debounceOnChangedText(String text) { + _debounceOnChanged?.cancel(); + _debounceOnChanged = Timer(const Duration(milliseconds: 300), () { + _submitUpdateTaskDescription(text); + }); + } + + void _submitUpdateTaskDescription(String description) { + context.read().add( + ChecklistCellEvent.updateTaskName( + widget.task.data, + description.trim(), + ), + ); + } + + void _showDeleteTaskBottomSheet() { + showMobileBottomSheet( + context, + showDragHandle: true, + builder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: InkWell( + onTap: () { + context.read().add( + ChecklistCellEvent.deleteTask(widget.task.data.id), + ); + context.pop(); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + FlowySvg( + FlowySvgs.m_delete_m, + size: const Size.square(20), + color: Theme.of(context).colorScheme.error, + ), + const HSpace(8), + FlowyText( + LocaleKeys.button_delete.tr(), + fontSize: 15, + color: Theme.of(context).colorScheme.error, + ), + ], + ), + ), + ), + ), + const Divider(height: 9), + ], + ), + ); + } +} + +class _NewTaskButton extends StatelessWidget { + const _NewTaskButton({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + context + .read() + .add(const ChecklistCellEvent.updatePhantomIndex(-1)); + context + .read() + .add(const ChecklistCellEvent.createNewTask("")); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 13), + child: Row( + children: [ + const FlowySvg(FlowySvgs.add_s, size: Size.square(20)), + const HSpace(11), + FlowyText(LocaleKeys.grid_checklist_addNew.tr(), fontSize: 15), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart new file mode 100644 index 0000000000000..90e4916fa853c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart @@ -0,0 +1,311 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../document/presentation/editor_plugins/openai/widgets/loading.dart'; + +class MobileMediaCellEditor extends StatelessWidget { + const MobileMediaCellEditor({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints.tightFor(height: 420), + child: BlocProvider.value( + value: context.read(), + child: BlocBuilder( + builder: (context, state) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const DragHandle(), + SizedBox( + height: 46.0, + child: Stack( + children: [ + Align( + child: FlowyText.medium( + LocaleKeys.grid_field_mediaFieldName.tr(), + fontSize: 18, + ), + ), + Positioned( + top: 8, + right: 18, + child: GestureDetector( + onTap: () => showMobileBottomSheet( + context, + title: LocaleKeys.grid_media_addFileMobile.tr(), + showHeader: true, + showCloseButton: true, + showDragHandle: true, + builder: (dContext) => BlocProvider.value( + value: context.read(), + child: MobileMediaUploadSheetContent( + dialogContext: dContext, + ), + ), + ), + child: const FlowySvg( + FlowySvgs.add_m, + size: Size.square(28), + ), + ), + ), + ], + ), + ), + const Divider(height: 0.5), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + if (state.files.isNotEmpty) const Divider(height: .5), + ...state.files.map( + (file) => Padding( + padding: const EdgeInsets.only(bottom: 2), + child: _FileItem(key: Key(file.id), file: file), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _FileItem extends StatelessWidget { + const _FileItem({super.key, required this.file}); + + final MediaFilePB file; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + title: Row( + crossAxisAlignment: file.fileType == MediaFileTypePB.Image + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + if (file.fileType != MediaFileTypePB.Image) ...[ + Flexible( + child: GestureDetector( + onTap: () => afLaunchUrlString(file.url), + child: Row( + children: [ + FlowySvg(file.fileType.icon, size: const Size.square(24)), + const HSpace(12), + Expanded( + child: FlowyText( + file.name, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ] else ...[ + Expanded( + child: Container( + alignment: Alignment.centerLeft, + constraints: const BoxConstraints(maxHeight: 96), + child: GestureDetector( + onTap: () => openInteractiveViewer(context), + child: ImageRender( + userProfile: + context.read().state.userProfile, + fit: BoxFit.fitHeight, + borderRadius: BorderRadius.zero, + image: ImageBlockData( + url: file.url, + type: file.uploadType.toCustomImageType(), + ), + ), + ), + ), + ), + ], + FlowyIconButton( + width: 20, + icon: const FlowySvg( + FlowySvgs.three_dots_s, + size: Size.square(20), + ), + onPressed: () => showMobileBottomSheet( + context, + backgroundColor: Theme.of(context).colorScheme.surface, + showDragHandle: true, + builder: (_) => BlocProvider.value( + value: context.read(), + child: _EditFileSheet(file: file), + ), + ), + ), + const HSpace(6), + ], + ), + ), + ); + } + + void openInteractiveViewer(BuildContext context) => + openInteractiveViewerFromFile( + context, + file, + onDeleteImage: (_) => context.read().deleteFile(file.id), + userProfile: context.read().state.userProfile, + ); +} + +class _EditFileSheet extends StatefulWidget { + const _EditFileSheet({required this.file}); + + final MediaFilePB file; + + @override + State<_EditFileSheet> createState() => _EditFileSheetState(); +} + +class _EditFileSheetState extends State<_EditFileSheet> { + late final controller = TextEditingController(text: widget.file.name); + Loading? loader; + + MediaFilePB get file => widget.file; + + @override + void dispose() { + controller.dispose(); + loader?.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + children: [ + const VSpace(16), + if (file.fileType == MediaFileTypePB.Image) ...[ + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.grid_media_expand.tr(), + leftIcon: const FlowySvg( + FlowySvgs.full_view_s, + size: Size.square(20), + ), + onTap: () => openInteractiveViewer(context), + ), + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.grid_media_setAsCover.tr(), + leftIcon: const FlowySvg( + FlowySvgs.cover_s, + size: Size.square(20), + ), + onTap: () => context.read().add( + MediaCellEvent.setCover( + RowCoverPB( + data: file.url, + uploadType: file.uploadType, + coverType: CoverTypePB.FileCover, + ), + ), + ), + ), + ], + FlowyOptionTile.text( + showTopBorder: file.fileType == MediaFileTypePB.Image, + text: LocaleKeys.grid_media_openInBrowser.tr(), + leftIcon: const FlowySvg( + FlowySvgs.open_in_browser_s, + size: Size.square(20), + ), + onTap: () => afLaunchUrlString(file.url), + ), + // TODO(Mathias): Rename interaction need design + // FlowyOptionTile.text( + // text: LocaleKeys.grid_media_rename.tr(), + // leftIcon: const FlowySvg( + // FlowySvgs.rename_s, + // size: Size.square(20), + // ), + // onTap: () {}, + // ), + if (widget.file.uploadType == FileUploadTypePB.CloudFile) ...[ + FlowyOptionTile.text( + onTap: () async => downloadMediaFile( + context, + file, + userProfile: context.read().state.userProfile, + onDownloadBegin: () { + loader?.stop(); + loader = Loading(context); + loader?.start(); + }, + onDownloadEnd: () => loader?.stop(), + ), + text: LocaleKeys.button_download.tr(), + leftIcon: const FlowySvg( + FlowySvgs.save_as_s, + size: Size.square(20), + ), + ), + ], + FlowyOptionTile.text( + text: LocaleKeys.grid_media_delete.tr(), + textColor: Theme.of(context).colorScheme.error, + leftIcon: FlowySvg( + FlowySvgs.trash_s, + size: const Size.square(20), + color: Theme.of(context).colorScheme.error, + ), + onTap: () { + context.pop(); + context.read().deleteFile(file.id); + }, + ), + ], + ), + ); + } + + void openInteractiveViewer(BuildContext context) => + openInteractiveViewerFromFile( + context, + file, + onDeleteImage: (_) => context.read().deleteFile(file.id), + userProfile: context.read().state.userProfile, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart new file mode 100644 index 0000000000000..bd84c9074d27f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_select_option_editor.dart @@ -0,0 +1,552 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/base/option_color_list.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:protobuf/protobuf.dart'; + +// include single select and multiple select +class MobileSelectOptionEditor extends StatefulWidget { + const MobileSelectOptionEditor({ + super.key, + required this.cellController, + }); + + final SelectOptionCellController cellController; + + @override + State createState() => + _MobileSelectOptionEditorState(); +} + +class _MobileSelectOptionEditorState extends State { + final searchController = TextEditingController(); + final renameController = TextEditingController(); + + String typingOption = ''; + FieldType get fieldType => widget.cellController.fieldType; + + bool showMoreOptions = false; + SelectOptionPB? option; + + @override + void dispose() { + searchController.dispose(); + renameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints.tightFor(height: 420), + child: BlocProvider( + create: (context) => SelectOptionCellEditorBloc( + cellController: widget.cellController, + ), + child: BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const DragHandle(), + _buildHeader(context), + const Divider(height: 0.5), + Expanded( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: showMoreOptions ? 0.0 : 16.0, + ), + child: _buildBody(context), + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + const height = 44.0; + return Stack( + children: [ + if (showMoreOptions) + Align( + alignment: Alignment.centerLeft, + child: AppBarBackButton(onTap: _popOrBack), + ), + SizedBox( + height: 44.0, + child: Align( + child: FlowyText.medium( + _headerTitle(), + fontSize: 18, + ), + ), + ), + ].map((e) => SizedBox(height: height, child: e)).toList(), + ); + } + + Widget _buildBody(BuildContext context) { + if (showMoreOptions && option != null) { + return _MoreOptions( + initialOption: option!, + controller: renameController, + onDelete: () { + context + .read() + .add(SelectOptionCellEditorEvent.deleteOption(option!)); + _popOrBack(); + }, + onUpdate: (name, color) { + final option = this.option; + if (option == null) { + return; + } + option.freeze(); + context.read().add( + SelectOptionCellEditorEvent.updateOption( + option.rebuild((p0) { + if (name != null) { + p0.name = name; + } + if (color != null) { + p0.color = color; + } + }), + ), + ); + }, + ); + } + + return SingleChildScrollView( + child: Column( + children: [ + const VSpace(16), + _SearchField( + controller: searchController, + hintText: LocaleKeys.grid_selectOption_searchOrCreateOption.tr(), + onSubmitted: (_) { + context + .read() + .add(const SelectOptionCellEditorEvent.submitTextField()); + searchController.clear(); + }, + onChanged: (value) { + typingOption = value; + context.read().add( + SelectOptionCellEditorEvent.selectMultipleOptions( + [], + value, + ), + ); + }, + ), + const VSpace(22), + _OptionList( + fieldType: widget.cellController.fieldType, + onCreateOption: (optionName) { + context + .read() + .add(const SelectOptionCellEditorEvent.createOption()); + searchController.clear(); + }, + onCheck: (option, isSelected) { + if (isSelected) { + context + .read() + .add(SelectOptionCellEditorEvent.unselectOption(option.id)); + } else { + context + .read() + .add(SelectOptionCellEditorEvent.selectOption(option.id)); + } + }, + onMoreOptions: (option) { + setState(() { + this.option = option; + renameController.text = option.name; + showMoreOptions = true; + }); + }, + ), + ], + ), + ); + } + + String _headerTitle() { + switch (fieldType) { + case FieldType.SingleSelect: + return LocaleKeys.grid_field_singleSelectFieldName.tr(); + case FieldType.MultiSelect: + return LocaleKeys.grid_field_multiSelectFieldName.tr(); + default: + throw UnimplementedError(); + } + } + + void _popOrBack() { + if (showMoreOptions) { + setState(() { + showMoreOptions = false; + option = null; + }); + } else { + context.pop(); + } + } +} + +class _SearchField extends StatelessWidget { + const _SearchField({ + this.hintText, + required this.onChanged, + required this.onSubmitted, + required this.controller, + }); + + final String? hintText; + final void Function(String value) onChanged; + final void Function(String value) onSubmitted; + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + return FlowyMobileSearchTextField( + controller: controller, + onChanged: onChanged, + onSubmitted: onSubmitted, + hintText: hintText, + ); + } +} + +class _OptionList extends StatelessWidget { + const _OptionList({ + required this.fieldType, + required this.onCreateOption, + required this.onCheck, + required this.onMoreOptions, + }); + + final FieldType fieldType; + final void Function(String optionName) onCreateOption; + final void Function(SelectOptionPB option, bool value) onCheck; + final void Function(SelectOptionPB option) onMoreOptions; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + // existing options + final List cells = []; + + // create an option cell + if (state.createSelectOptionSuggestion != null) { + cells.add( + _CreateOptionCell( + name: state.createSelectOptionSuggestion!.name, + color: state.createSelectOptionSuggestion!.color, + onTap: () => onCreateOption( + state.createSelectOptionSuggestion!.name, + ), + ), + ); + } + + cells.addAll( + state.options.map( + (option) => MobileSelectOption( + indicator: fieldType == FieldType.MultiSelect + ? MobileSelectedOptionIndicator.multi + : MobileSelectedOptionIndicator.single, + option: option, + isSelected: state.selectedOptions.contains(option), + onTap: (value) => onCheck(option, value), + onMoreOptions: () => onMoreOptions(option), + ), + ), + ); + + return ListView.separated( + shrinkWrap: true, + itemCount: cells.length, + separatorBuilder: (_, __) => const VSpace(20), + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (_, int index) => cells[index], + padding: const EdgeInsets.only(bottom: 12.0), + ); + }, + ); + } +} + +class MobileSelectOption extends StatelessWidget { + const MobileSelectOption({ + super.key, + required this.indicator, + required this.option, + required this.isSelected, + required this.onTap, + this.showMoreOptionsButton = true, + this.onMoreOptions, + }); + + final MobileSelectedOptionIndicator indicator; + final SelectOptionPB option; + final bool isSelected; + final void Function(bool value) onTap; + final bool showMoreOptionsButton; + final VoidCallback? onMoreOptions; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 40, + child: GestureDetector( + // no need to add click effect, so using gesture detector + behavior: HitTestBehavior.translucent, + onTap: () => onTap(isSelected), + child: Row( + children: [ + // checked or selected icon + SizedBox( + height: 20, + width: 20, + child: _IsSelectedIndicator( + indicator: indicator, + isSelected: isSelected, + ), + ), + // padding + const HSpace(12), + // option tag + Expanded( + child: Align( + alignment: AlignmentDirectional.centerStart, + child: SelectOptionTag( + option: option, + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 14, + ), + textAlign: TextAlign.center, + fontSize: 15.0, + ), + ), + ), + if (showMoreOptionsButton) ...[ + const HSpace(24), + // more options + FlowyIconButton( + icon: const FlowySvg( + FlowySvgs.m_field_more_s, + ), + onPressed: onMoreOptions, + ), + ], + ], + ), + ), + ); + } +} + +class _CreateOptionCell extends StatelessWidget { + const _CreateOptionCell({ + required this.name, + required this.color, + required this.onTap, + }); + + final String name; + final SelectOptionColorPB color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onTap, + child: Row( + children: [ + FlowyText( + LocaleKeys.grid_selectOption_create.tr(), + color: Theme.of(context).hintColor, + ), + const HSpace(8), + Expanded( + child: Align( + alignment: AlignmentDirectional.centerStart, + child: SelectOptionTag( + name: name, + color: color.toColor(context), + textAlign: TextAlign.center, + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 14, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _MoreOptions extends StatefulWidget { + const _MoreOptions({ + required this.initialOption, + required this.onDelete, + required this.onUpdate, + required this.controller, + }); + + final SelectOptionPB initialOption; + final VoidCallback onDelete; + final void Function(String? name, SelectOptionColorPB? color) onUpdate; + final TextEditingController controller; + + @override + State<_MoreOptions> createState() => _MoreOptionsState(); +} + +class _MoreOptionsState extends State<_MoreOptions> { + late SelectOptionPB option = widget.initialOption; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRenameTextField(context), + const VSpace(16.0), + _buildDeleteButton(context), + const VSpace(16.0), + Padding( + padding: const EdgeInsets.only(left: 12.0), + child: FlowyText( + LocaleKeys.grid_selectOption_colorPanelTitle.tr().toUpperCase(), + color: Theme.of(context).hintColor, + fontSize: 13, + ), + ), + const VSpace(4.0), + FlowyOptionDecorateBox( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 12.0, + horizontal: 6.0, + ), + child: OptionColorList( + selectedColor: option.color, + onSelectedColor: (color) { + widget.onUpdate(null, color); + setState(() { + option.freeze(); + option = option.rebuild((option) => option.color = color); + }); + }, + ), + ), + ), + ], + ), + ); + } + + Widget _buildRenameTextField(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints.tightFor(height: 52.0), + child: FlowyOptionTile.textField( + showTopBorder: false, + onTextChanged: (name) => widget.onUpdate(name, null), + controller: widget.controller, + ), + ); + } + + Widget _buildDeleteButton(BuildContext context) { + return FlowyOptionTile.text( + text: LocaleKeys.button_delete.tr(), + textColor: Theme.of(context).colorScheme.error, + leftIcon: FlowySvg( + FlowySvgs.m_delete_s, + color: Theme.of(context).colorScheme.error, + ), + onTap: widget.onDelete, + ); + } +} + +enum MobileSelectedOptionIndicator { single, multi } + +class _IsSelectedIndicator extends StatelessWidget { + const _IsSelectedIndicator({ + required this.indicator, + required this.isSelected, + }); + + final MobileSelectedOptionIndicator indicator; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return isSelected + ? DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.primary, + ), + child: Center( + child: indicator == MobileSelectedOptionIndicator.multi + ? FlowySvg( + FlowySvgs.checkmark_tiny_s, + color: Theme.of(context).colorScheme.onPrimary, + ) + : Container( + width: 7.5, + height: 7.5, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ) + : DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.fromBorderSide( + BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart new file mode 100644 index 0000000000000..3a739cc69cd4b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/relation_cell_editor.dart @@ -0,0 +1,553 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/relation_type_option_cubit.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/database/widgets/row/relation_row_detail.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../application/cell/bloc/relation_cell_bloc.dart'; +import '../../application/cell/bloc/relation_row_search_bloc.dart'; + +class RelationCellEditor extends StatelessWidget { + const RelationCellEditor({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, cellState) { + return cellState.relatedDatabaseMeta == null + ? const _RelationCellEditorDatabasePicker() + : _RelationCellEditorContent( + relatedDatabaseMeta: cellState.relatedDatabaseMeta!, + selectedRowIds: cellState.rows.map((e) => e.rowId).toList(), + ); + }, + ); + } +} + +class _RelationCellEditorContent extends StatefulWidget { + const _RelationCellEditorContent({ + required this.relatedDatabaseMeta, + required this.selectedRowIds, + }); + + final DatabaseMeta relatedDatabaseMeta; + final List selectedRowIds; + + @override + State<_RelationCellEditorContent> createState() => + _RelationCellEditorContentState(); +} + +class _RelationCellEditorContentState + extends State<_RelationCellEditorContent> { + final textEditingController = TextEditingController(); + late final FocusNode focusNode; + late final bloc = RelationRowSearchBloc( + databaseId: widget.relatedDatabaseMeta.databaseId, + ); + + @override + void initState() { + super.initState(); + focusNode = FocusNode( + onKeyEvent: (node, event) { + switch (event.logicalKey) { + case LogicalKeyboardKey.arrowUp when event is! KeyUpEvent: + if (textEditingController.value.composing.isCollapsed) { + bloc.add(const RelationRowSearchEvent.focusPreviousOption()); + return KeyEventResult.handled; + } + break; + case LogicalKeyboardKey.arrowDown when event is! KeyUpEvent: + if (textEditingController.value.composing.isCollapsed) { + bloc.add(const RelationRowSearchEvent.focusNextOption()); + return KeyEventResult.handled; + } + break; + case LogicalKeyboardKey.escape when event is! KeyUpEvent: + if (!textEditingController.value.composing.isCollapsed) { + final end = textEditingController.value.composing.end; + final text = textEditingController.text; + + textEditingController.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: end), + ); + return KeyEventResult.handled; + } + break; + } + return KeyEventResult.ignored; + }, + ); + } + + @override + void dispose() { + textEditingController.dispose(); + focusNode.dispose(); + bloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: bloc, + child: BlocBuilder( + buildWhen: (previous, current) => + !listEquals(previous.filteredRows, current.filteredRows), + builder: (context, state) { + final selected = []; + final unselected = []; + for (final row in state.filteredRows) { + if (widget.selectedRowIds.contains(row.rowId)) { + selected.add(row); + } else { + unselected.add(row); + } + } + return TextFieldTapRegion( + child: CustomScrollView( + shrinkWrap: true, + slivers: [ + _CellEditorTitle( + databaseMeta: widget.relatedDatabaseMeta, + ), + _SearchField( + focusNode: focusNode, + textEditingController: textEditingController, + ), + const SliverToBoxAdapter( + child: TypeOptionSeparator(spacing: 0.0), + ), + if (state.filteredRows.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(6.0) + + GridSize.typeOptionContentInsets, + child: FlowyText.regular( + LocaleKeys.grid_relation_emptySearchResult.tr(), + color: Theme.of(context).hintColor, + ), + ), + ), + if (selected.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0) + + GridSize.typeOptionContentInsets, + child: FlowyText.regular( + LocaleKeys.grid_relation_linkedRowListLabel.plural( + selected.length, + namedArgs: {'count': '${selected.length}'}, + ), + fontSize: 11, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, + ), + ), + ), + _RowList( + databaseId: widget.relatedDatabaseMeta.databaseId, + rows: selected, + isSelected: true, + ), + const SliverToBoxAdapter( + child: VSpace(4.0), + ), + ], + if (unselected.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0) + + GridSize.typeOptionContentInsets, + child: FlowyText.regular( + LocaleKeys.grid_relation_unlinkedRowListLabel.tr(), + fontSize: 11, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, + ), + ), + ), + _RowList( + databaseId: widget.relatedDatabaseMeta.databaseId, + rows: unselected, + isSelected: false, + ), + const SliverToBoxAdapter( + child: VSpace(4.0), + ), + ], + ], + ), + ); + }, + ), + ); + } +} + +class _CellEditorTitle extends StatelessWidget { + const _CellEditorTitle({ + required this.databaseMeta, + }); + + final DatabaseMeta databaseMeta; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0) + + GridSize.typeOptionContentInsets, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.regular( + LocaleKeys.grid_relation_inRelatedDatabase.tr(), + fontSize: 11, + color: Theme.of(context).hintColor, + ), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => _openRelatedDatbase(context), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: FlowyText.regular( + databaseMeta.databaseName, + fontSize: 11, + overflow: TextOverflow.ellipsis, + decoration: TextDecoration.underline, + ), + ), + ), + ), + ], + ), + ), + ); + } + + void _openRelatedDatbase(BuildContext context) { + FolderEventGetView(ViewIdPB(value: databaseMeta.inlineViewId)) + .send() + .then((result) { + result.fold( + (view) { + PopoverContainer.of(context).closeAll(); + Navigator.of(context).maybePop(); + getIt().add( + TabsEvent.openPlugin( + plugin: DatabaseTabBarViewPlugin( + view: view, + pluginType: view.pluginType, + ), + ), + ); + }, + (err) => Log.error(err), + ); + }); + } +} + +class _SearchField extends StatelessWidget { + const _SearchField({ + required this.focusNode, + required this.textEditingController, + }); + + final FocusNode focusNode; + final TextEditingController textEditingController; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 6.0, bottom: 6.0, right: 6.0), + child: FlowyTextField( + focusNode: focusNode, + controller: textEditingController, + hintText: LocaleKeys.grid_relation_rowSearchTextFieldPlaceholder.tr(), + hintStyle: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).hintColor), + onChanged: (text) { + if (textEditingController.value.composing.isCollapsed) { + context + .read() + .add(RelationRowSearchEvent.updateFilter(text)); + } + }, + onSubmitted: (_) { + final focusedRowId = + context.read().state.focusedRowId; + if (focusedRowId != null) { + final row = context + .read() + .state + .rows + .firstWhereOrNull((e) => e.rowId == focusedRowId); + if (row != null) { + FlowyOverlay.show( + context: context, + builder: (BuildContext overlayContext) { + return RelatedRowDetailPage( + databaseId: context + .read() + .state + .relatedDatabaseMeta! + .databaseId, + rowId: row.rowId, + ); + }, + ); + PopoverContainer.of(context).close(); + } else { + context + .read() + .add(RelationCellEvent.selectRow(focusedRowId)); + } + } + focusNode.requestFocus(); + }, + ), + ), + ); + } +} + +class _RowList extends StatelessWidget { + const _RowList({ + required this.databaseId, + required this.rows, + required this.isSelected, + }); + + final String databaseId; + final List rows; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _RowListItem( + row: rows[index], + databaseId: databaseId, + isSelected: isSelected, + ), + childCount: rows.length, + ), + ); + } +} + +class _RowListItem extends StatelessWidget { + const _RowListItem({ + required this.row, + required this.isSelected, + required this.databaseId, + }); + + final RelatedRowDataPB row; + final String databaseId; + final bool isSelected; + + @override + Widget build(BuildContext context) { + final isHovered = + context.watch().state.focusedRowId == row.rowId; + return Container( + height: 28, + margin: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 2.0), + decoration: BoxDecoration( + color: isHovered ? AFThemeExtension.of(context).lightGreyHover : null, + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + child: GestureDetector( + onTap: () { + if (isSelected) { + FlowyOverlay.show( + context: context, + builder: (BuildContext overlayContext) { + return RelatedRowDetailPage( + databaseId: databaseId, + rowId: row.rowId, + ); + }, + ); + PopoverContainer.of(context).close(); + } else { + context + .read() + .add(RelationCellEvent.selectRow(row.rowId)); + } + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onHover: (_) => context + .read() + .add(RelationRowSearchEvent.updateFocusedOption(row.rowId)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4.0), + child: Row( + children: [ + Expanded( + child: FlowyText( + row.name.trim().isEmpty + ? LocaleKeys.grid_title_placeholder.tr() + : row.name, + color: row.name.trim().isEmpty + ? Theme.of(context).hintColor + : null, + overflow: TextOverflow.ellipsis, + ), + ), + if (isSelected && isHovered) + _UnselectRowButton( + onPressed: () => context + .read() + .add(RelationCellEvent.selectRow(row.rowId)), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _UnselectRowButton extends StatefulWidget { + const _UnselectRowButton({ + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + State<_UnselectRowButton> createState() => _UnselectRowButtonState(); +} + +class _UnselectRowButtonState extends State<_UnselectRowButton> { + final _materialStatesController = WidgetStatesController(); + + @override + void dispose() { + _materialStatesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: widget.onPressed, + onHover: (_) => setState(() {}), + onFocusChange: (_) => setState(() {}), + style: ButtonStyle( + fixedSize: const WidgetStatePropertyAll(Size.square(32)), + minimumSize: const WidgetStatePropertyAll(Size.square(32)), + maximumSize: const WidgetStatePropertyAll(Size.square(32)), + overlayColor: WidgetStateProperty.resolveWith((state) { + if (state.contains(WidgetState.focused)) { + return AFThemeExtension.of(context).greyHover; + } + return Colors.transparent; + }), + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: Corners.s6Border), + ), + ), + statesController: _materialStatesController, + child: Container( + color: _materialStatesController.value.contains(WidgetState.hovered) || + _materialStatesController.value.contains(WidgetState.focused) + ? Theme.of(context).colorScheme.primary + : AFThemeExtension.of(context).onBackground, + width: 12, + height: 1, + ), + ); + } +} + +class _RelationCellEditorDatabasePicker extends StatelessWidget { + const _RelationCellEditorDatabasePicker(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => RelationDatabaseListCubit(), + child: BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(6, 6, 6, 0), + child: FlowyText( + LocaleKeys.grid_relation_noDatabaseSelected.tr(), + maxLines: null, + fontSize: 10, + color: Theme.of(context).hintColor, + ), + ), + Flexible( + child: ListView.separated( + padding: const EdgeInsets.all(6), + separatorBuilder: (context, index) => + VSpace(GridSize.typeOptionSeparatorHeight), + itemCount: state.databaseMetas.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final databaseMeta = state.databaseMetas[index]; + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + onTap: () => context.read().add( + RelationCellEvent.selectDatabaseId( + databaseMeta.databaseId, + ), + ), + text: FlowyText( + databaseMeta.databaseName, + overflow: TextOverflow.ellipsis, + ), + ), + ); + }, + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_cell_editor.dart new file mode 100644 index 0000000000000..6ac2a5b807dbe --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_cell_editor.dart @@ -0,0 +1,535 @@ +import 'dart:collection'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../grid/presentation/widgets/common/type_option_separator.dart'; +import '../field/type_option_editor/select/select_option_editor.dart'; + +import 'extension.dart'; +import 'select_option_text_field.dart'; + +const double _editorPanelWidth = 300; + +class SelectOptionCellEditor extends StatefulWidget { + const SelectOptionCellEditor({super.key, required this.cellController}); + + final SelectOptionCellController cellController; + + @override + State createState() => _SelectOptionCellEditorState(); +} + +class _SelectOptionCellEditorState extends State { + final textEditingController = TextEditingController(); + final scrollController = ScrollController(); + final popoverMutex = PopoverMutex(); + late final bloc = SelectOptionCellEditorBloc( + cellController: widget.cellController, + ); + late final FocusNode focusNode; + + @override + void initState() { + super.initState(); + focusNode = FocusNode( + onKeyEvent: (node, event) { + switch (event.logicalKey) { + case LogicalKeyboardKey.arrowUp when event is! KeyUpEvent: + if (textEditingController.value.composing.isCollapsed) { + bloc.add(const SelectOptionCellEditorEvent.focusPreviousOption()); + return KeyEventResult.handled; + } + break; + case LogicalKeyboardKey.arrowDown when event is! KeyUpEvent: + if (textEditingController.value.composing.isCollapsed) { + bloc.add(const SelectOptionCellEditorEvent.focusNextOption()); + return KeyEventResult.handled; + } + break; + case LogicalKeyboardKey.escape when event is! KeyUpEvent: + if (!textEditingController.value.composing.isCollapsed) { + final end = textEditingController.value.composing.end; + final text = textEditingController.text; + + textEditingController.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: end), + ); + return KeyEventResult.handled; + } + break; + case LogicalKeyboardKey.backspace when event is KeyUpEvent: + if (!textEditingController.text.isNotEmpty) { + bloc.add(const SelectOptionCellEditorEvent.unselectLastOption()); + return KeyEventResult.handled; + } + break; + } + return KeyEventResult.ignored; + }, + ); + } + + @override + void dispose() { + popoverMutex.dispose(); + textEditingController.dispose(); + scrollController.dispose(); + bloc.close(); + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: bloc, + child: TextFieldTapRegion( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _TextField( + textEditingController: textEditingController, + scrollController: scrollController, + focusNode: focusNode, + popoverMutex: popoverMutex, + ), + const TypeOptionSeparator(spacing: 0.0), + Flexible( + child: Focus( + descendantsAreFocusable: false, + child: _OptionList( + textEditingController: textEditingController, + popoverMutex: popoverMutex, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _OptionList extends StatelessWidget { + const _OptionList({ + required this.textEditingController, + required this.popoverMutex, + }); + + final TextEditingController textEditingController; + final PopoverMutex popoverMutex; + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (prev, curr) => prev.clearFilter != curr.clearFilter, + listener: (context, state) { + if (state.clearFilter) { + textEditingController.clear(); + context + .read() + .add(const SelectOptionCellEditorEvent.resetClearFilterFlag()); + } + }, + buildWhen: (previous, current) => + !listEquals(previous.options, current.options) || + previous.createSelectOptionSuggestion != + current.createSelectOptionSuggestion, + builder: (context, state) => ReorderableListView.builder( + shrinkWrap: true, + proxyDecorator: (child, index, _) => Material( + color: Colors.transparent, + child: Stack( + children: [ + BlocProvider.value( + value: context.read(), + child: child, + ), + MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: const SizedBox.expand(), + ), + ], + ), + ), + buildDefaultDragHandles: false, + itemCount: state.options.length, + onReorderStart: (_) => popoverMutex.close(), + itemBuilder: (_, int index) { + final option = state.options[index]; + return _SelectOptionCell( + key: ValueKey("select_cell_option_list_${option.id}"), + index: index, + option: option, + popoverMutex: popoverMutex, + ); + }, + onReorder: (oldIndex, newIndex) { + if (oldIndex < newIndex) { + newIndex--; + } + final fromOptionId = state.options[oldIndex].id; + final toOptionId = state.options[newIndex].id; + context.read().add( + SelectOptionCellEditorEvent.reorderOption( + fromOptionId, + toOptionId, + ), + ); + }, + header: Padding( + padding: EdgeInsets.only( + bottom: state.createSelectOptionSuggestion != null || + state.options.isNotEmpty + ? 12 + : 0, + ), + child: const _Title(), + ), + footer: state.createSelectOptionSuggestion != null + ? _CreateOptionCell( + suggestion: state.createSelectOptionSuggestion!, + ) + : null, + padding: const EdgeInsets.symmetric(vertical: 8), + ), + ); + } +} + +class _TextField extends StatelessWidget { + const _TextField({ + required this.textEditingController, + required this.scrollController, + required this.focusNode, + required this.popoverMutex, + }); + + final TextEditingController textEditingController; + final ScrollController scrollController; + final FocusNode focusNode; + final PopoverMutex popoverMutex; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final optionMap = LinkedHashMap.fromIterable( + state.selectedOptions, + key: (option) => option.name, + value: (option) => option, + ); + + return Material( + color: Colors.transparent, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: SelectOptionTextField( + options: state.options, + focusNode: focusNode, + selectedOptionMap: optionMap, + distanceToText: _editorPanelWidth * 0.7, + textController: textEditingController, + scrollController: scrollController, + textSeparators: const [','], + onClick: () => popoverMutex.close(), + newText: (text) => context + .read() + .add(SelectOptionCellEditorEvent.filterOption(text)), + onSubmitted: () { + context + .read() + .add(const SelectOptionCellEditorEvent.submitTextField()); + focusNode.requestFocus(); + }, + onPaste: (tagNames, remainder) { + context.read().add( + SelectOptionCellEditorEvent.selectMultipleOptions( + tagNames, + remainder, + ), + ); + }, + onRemove: (name) => + context.read().add( + SelectOptionCellEditorEvent.unselectOption( + optionMap[name]!.id, + ), + ), + ), + ), + ); + }, + ); + } +} + +class _Title extends StatelessWidget { + const _Title(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: FlowyText.regular( + LocaleKeys.grid_selectOption_panelTitle.tr(), + color: Theme.of(context).hintColor, + ), + ); + } +} + +class _SelectOptionCell extends StatefulWidget { + const _SelectOptionCell({ + super.key, + required this.option, + required this.index, + required this.popoverMutex, + }); + + final SelectOptionPB option; + final int index; + final PopoverMutex popoverMutex; + + @override + State<_SelectOptionCell> createState() => _SelectOptionCellState(); +} + +class _SelectOptionCellState extends State<_SelectOptionCell> { + final _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: _popoverController, + offset: const Offset(8, 0), + margin: EdgeInsets.zero, + asBarrier: true, + constraints: BoxConstraints.loose(const Size(200, 470)), + mutex: widget.popoverMutex, + clickHandler: PopoverClickHandler.gestureDetector, + popupBuilder: (popoverContext) => SelectOptionEditor( + key: ValueKey(widget.option.id), + option: widget.option, + onDeleted: () { + context + .read() + .add(SelectOptionCellEditorEvent.deleteOption(widget.option)); + PopoverContainer.of(popoverContext).close(); + }, + onUpdated: (updatedOption) => context + .read() + .add(SelectOptionCellEditorEvent.updateOption(updatedOption)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0), + child: MouseRegion( + onEnter: (_) => context.read().add( + SelectOptionCellEditorEvent.updateFocusedOption( + widget.option.id, + ), + ), + child: Container( + height: 28, + decoration: BoxDecoration( + color: context + .watch() + .state + .focusedOptionId == + widget.option.id + ? AFThemeExtension.of(context).lightGreyHover + : null, + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + child: SelectOptionTagCell( + option: widget.option, + index: widget.index, + onSelected: _onTap, + children: [ + if (context + .watch() + .state + .selectedOptions + .contains(widget.option)) + FlowyIconButton( + width: 20, + hoverColor: Colors.transparent, + onPressed: _onTap, + icon: FlowySvg( + FlowySvgs.check_s, + color: Theme.of(context).iconTheme.color, + ), + ), + FlowyIconButton( + onPressed: () => _popoverController.show(), + iconPadding: const EdgeInsets.symmetric(horizontal: 6.0), + hoverColor: Colors.transparent, + icon: FlowySvg( + FlowySvgs.three_dots_s, + size: const Size.square(16), + color: AFThemeExtension.of(context).onBackground, + ), + ), + ], + ), + ), + ), + ), + ); + } + + void _onTap() { + widget.popoverMutex.close(); + final bloc = context.read(); + if (bloc.state.selectedOptions.contains(widget.option)) { + bloc.add(SelectOptionCellEditorEvent.unselectOption(widget.option.id)); + } else { + bloc.add(SelectOptionCellEditorEvent.selectOption(widget.option.id)); + } + } +} + +class SelectOptionTagCell extends StatelessWidget { + const SelectOptionTagCell({ + super.key, + required this.option, + required this.onSelected, + this.children = const [], + this.index, + }); + + final SelectOptionPB option; + final VoidCallback onSelected; + final List children; + final int? index; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (index != null) + ReorderableDragStartListener( + index: index!, + child: MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grab, + child: GestureDetector( + onTap: onSelected, + child: SizedBox( + width: 26, + child: Center( + child: FlowySvg( + FlowySvgs.drag_element_s, + size: const Size.square(14), + color: AFThemeExtension.of(context).onBackground, + ), + ), + ), + ), + ), + ), + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onSelected, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + alignment: AlignmentDirectional.centerStart, + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: SelectOptionTag( + fontSize: 14, + option: option, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + ), + ), + ), + ), + ), + ...children, + ], + ); + } +} + +class _CreateOptionCell extends StatelessWidget { + const _CreateOptionCell({required this.suggestion}); + + final CreateSelectOptionSuggestion suggestion; + + @override + Widget build(BuildContext context) { + return Container( + height: 32, + margin: const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: + context.watch().state.focusedOptionId == + createSelectOptionSuggestionId + ? AFThemeExtension.of(context).lightGreyHover + : null, + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + child: GestureDetector( + onTap: () => context + .read() + .add(const SelectOptionCellEditorEvent.createOption()), + child: MouseRegion( + onEnter: (_) { + context.read().add( + const SelectOptionCellEditorEvent.updateFocusedOption( + createSelectOptionSuggestionId, + ), + ); + }, + child: Row( + children: [ + FlowyText( + LocaleKeys.grid_selectOption_create.tr(), + color: Theme.of(context).hintColor, + ), + const HSpace(10), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: SelectOptionTag( + name: suggestion.name, + color: suggestion.color.toColor(context), + fontSize: 14, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_text_field.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_text_field.dart new file mode 100644 index 0000000000000..a03167df9d888 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/select_option_text_field.dart @@ -0,0 +1,200 @@ +import 'dart:collection'; + +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import 'extension.dart'; + +class SelectOptionTextField extends StatefulWidget { + const SelectOptionTextField({ + super.key, + required this.options, + required this.selectedOptionMap, + required this.distanceToText, + required this.textSeparators, + required this.textController, + required this.focusNode, + required this.onSubmitted, + required this.newText, + required this.onPaste, + required this.onRemove, + this.scrollController, + this.onClick, + }); + + final List options; + final LinkedHashMap selectedOptionMap; + final double distanceToText; + final List textSeparators; + final TextEditingController textController; + final ScrollController? scrollController; + final FocusNode focusNode; + + final Function() onSubmitted; + final Function(String) newText; + final Function(List, String) onPaste; + final Function(String) onRemove; + final VoidCallback? onClick; + + @override + State createState() => _SelectOptionTextFieldState(); +} + +class _SelectOptionTextFieldState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.focusNode.requestFocus(); + _scrollToEnd(); + }); + widget.textController.addListener(_onChanged); + } + + @override + void didUpdateWidget(covariant oldWidget) { + if (oldWidget.selectedOptionMap.length < widget.selectedOptionMap.length) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToEnd(); + }); + } + + if (oldWidget.textController != widget.textController) { + oldWidget.textController.removeListener(_onChanged); + widget.textController.addListener(_onChanged); + } + + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + widget.textController.removeListener(_onChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: widget.textController, + focusNode: widget.focusNode, + onTap: widget.onClick, + onSubmitted: (_) => widget.onSubmitted(), + style: Theme.of(context).textTheme.bodyMedium, + decoration: InputDecoration( + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: Theme.of(context).colorScheme.outline), + borderRadius: Corners.s10Border, + ), + isDense: true, + prefixIcon: _renderTags(context), + prefixIconConstraints: BoxConstraints(maxWidth: widget.distanceToText), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), + borderRadius: Corners.s10Border, + ), + ), + ); + } + + void _scrollToEnd() { + if (widget.scrollController?.hasClients ?? false) { + widget.scrollController?.animateTo( + widget.scrollController!.position.maxScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ); + } + } + + void _onChanged() { + if (!widget.textController.value.composing.isCollapsed) { + return; + } + + // split input + final (submitted, remainder) = splitInput( + widget.textController.text.trimLeft(), + widget.textSeparators, + ); + + if (submitted.isNotEmpty) { + widget.textController.text = remainder; + widget.textController.selection = + TextSelection.collapsed(offset: widget.textController.text.length); + } + widget.onPaste(submitted, remainder); + } + + Widget? _renderTags(BuildContext context) { + if (widget.selectedOptionMap.isEmpty) { + return null; + } + + final children = widget.selectedOptionMap.values + .map( + (option) => SelectOptionTag( + option: option, + onRemove: (option) => widget.onRemove(option), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + ), + ) + .toList(); + + return Focus( + descendantsAreFocusable: false, + child: MouseRegion( + cursor: SystemMouseCursors.basic, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.mouse, + PointerDeviceKind.touch, + PointerDeviceKind.trackpad, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + }, + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: widget.scrollController, + child: Wrap(spacing: 4, children: children), + ), + ), + ), + ), + ); + } +} + +@visibleForTesting +(List, String) splitInput(String input, List textSeparators) { + final List splits = []; + String currentString = ''; + + // split the string into tokens + for (final char in input.split('')) { + if (textSeparators.contains(char)) { + if (currentString.trim().isNotEmpty) { + splits.add(currentString.trim()); + } + currentString = ''; + continue; + } + currentString += char; + } + // add the remainder (might be '') + splits.add(currentString); + + final submittedOptions = splits.sublist(0, splits.length - 1).toList(); + final remainder = splits.elementAt(splits.length - 1).trimLeft(); + + return (submittedOptions, remainder); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/database_layout_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/database_layout_ext.dart new file mode 100644 index 0000000000000..f8118a7e51cab --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/database_layout_ext.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; + +extension DatabaseLayoutExtension on DatabaseLayoutPB { + String get layoutName { + return switch (this) { + DatabaseLayoutPB.Board => LocaleKeys.board_menuName.tr(), + DatabaseLayoutPB.Calendar => LocaleKeys.calendar_menuName.tr(), + DatabaseLayoutPB.Grid => LocaleKeys.grid_menuName.tr(), + _ => "", + }; + } + + ViewLayoutPB get layoutType { + return switch (this) { + DatabaseLayoutPB.Board => ViewLayoutPB.Board, + DatabaseLayoutPB.Calendar => ViewLayoutPB.Calendar, + DatabaseLayoutPB.Grid => ViewLayoutPB.Grid, + _ => throw UnimplementedError(), + }; + } + + FlowySvgData get icon { + return switch (this) { + DatabaseLayoutPB.Board => FlowySvgs.board_s, + DatabaseLayoutPB.Calendar => FlowySvgs.calendar_s, + DatabaseLayoutPB.Grid => FlowySvgs.grid_s, + _ => throw UnimplementedError(), + }; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart new file mode 100644 index 0000000000000..c979ee0829f63 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/database_view_widget.dart @@ -0,0 +1,79 @@ +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; + +class DatabaseViewWidget extends StatefulWidget { + const DatabaseViewWidget({ + super.key, + required this.view, + this.shrinkWrap = true, + }); + + final ViewPB view; + final bool shrinkWrap; + + @override + State createState() => _DatabaseViewWidgetState(); +} + +class _DatabaseViewWidgetState extends State { + /// Listens to the view updates. + late final ViewListener _listener; + + /// Notifies the view layout type changes. When the layout type changes, + /// the widget of the view will be updated. + late final ValueNotifier _layoutTypeChangeNotifier; + + /// The view will be updated by the [ViewListener]. + late ViewPB view; + + late Plugin viewPlugin; + + @override + void initState() { + super.initState(); + view = widget.view; + viewPlugin = view.plugin()..init(); + _listenOnViewUpdated(); + } + + @override + void dispose() { + _layoutTypeChangeNotifier.dispose(); + _listener.stop(); + viewPlugin.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _layoutTypeChangeNotifier, + builder: (_, __, ___) => viewPlugin.widgetBuilder.buildWidget( + shrinkWrap: widget.shrinkWrap, + context: PluginContext(), + data: { + kDatabasePluginWidgetBuilderHorizontalPadding: + view.layout == ViewLayoutPB.Grid ? 40.0 : 0.0, + }, + ), + ); + } + + void _listenOnViewUpdated() { + _listener = ViewListener(viewId: widget.view.id) + ..start( + onViewUpdated: (updatedView) { + if (mounted) { + view = updatedView; + _layoutTypeChangeNotifier.value = view.layout; + } + }, + ); + + _layoutTypeChangeNotifier = ValueNotifier(widget.view.layout); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart new file mode 100644 index 0000000000000..88cd88ee68832 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart @@ -0,0 +1,778 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../shared/icon_emoji_picker/icon_picker.dart'; +import 'field_type_list.dart'; +import 'type_option_editor/builder.dart'; + +enum FieldEditorPage { + general, + details, +} + +class FieldEditor extends StatefulWidget { + const FieldEditor({ + super.key, + required this.viewId, + required this.fieldInfo, + required this.fieldController, + required this.isNewField, + this.initialPage = FieldEditorPage.details, + this.onFieldInserted, + }); + + final String viewId; + final FieldInfo fieldInfo; + final FieldController fieldController; + final FieldEditorPage initialPage; + final void Function(String fieldId)? onFieldInserted; + final bool isNewField; + + @override + State createState() => _FieldEditorState(); +} + +class _FieldEditorState extends State { + final PopoverMutex popoverMutex = PopoverMutex(); + late FieldEditorPage _currentPage; + late final TextEditingController textController = + TextEditingController(text: widget.fieldInfo.name); + + @override + void initState() { + super.initState(); + _currentPage = widget.initialPage; + } + + @override + void dispose() { + popoverMutex.dispose(); + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => FieldEditorBloc( + viewId: widget.viewId, + fieldInfo: widget.fieldInfo, + fieldController: widget.fieldController, + onFieldInserted: widget.onFieldInserted, + isNew: widget.isNewField, + ), + child: _currentPage == FieldEditorPage.general + ? _fieldGeneral() + : _fieldDetails(), + ); + } + + Widget _fieldGeneral() { + return SizedBox( + width: 240, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _NameAndIcon( + popoverMutex: popoverMutex, + textController: textController, + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + ), + VSpace(GridSize.typeOptionSeparatorHeight), + _EditFieldButton( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + onTap: () { + setState(() => _currentPage = FieldEditorPage.details); + }, + ), + VSpace(GridSize.typeOptionSeparatorHeight), + _actionCell(FieldAction.insertLeft), + VSpace(GridSize.typeOptionSeparatorHeight), + _actionCell(FieldAction.insertRight), + VSpace(GridSize.typeOptionSeparatorHeight), + _actionCell(FieldAction.toggleVisibility), + VSpace(GridSize.typeOptionSeparatorHeight), + _actionCell(FieldAction.duplicate), + VSpace(GridSize.typeOptionSeparatorHeight), + _actionCell(FieldAction.clearData), + VSpace(GridSize.typeOptionSeparatorHeight), + _actionCell(FieldAction.delete), + const TypeOptionSeparator(spacing: 8.0), + _actionCell(FieldAction.wrap), + const VSpace(8.0), + ], + ), + ); + } + + Widget _actionCell(FieldAction action) { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: FieldActionCell( + viewId: widget.viewId, + fieldInfo: state.field, + action: action, + ), + ); + }, + ); + } + + Widget _fieldDetails() { + return SizedBox( + width: 260, + child: FieldDetailsEditor( + viewId: widget.viewId, + textEditingController: textController, + ), + ); + } +} + +class _EditFieldButton extends StatelessWidget { + const _EditFieldButton({ + required this.padding, + this.onTap, + }); + + final EdgeInsetsGeometry padding; + final void Function()? onTap; + + @override + Widget build(BuildContext context) { + return Container( + height: GridSize.popoverItemHeight, + padding: padding, + child: FlowyButton( + leftIcon: const FlowySvg(FlowySvgs.edit_s), + text: FlowyText( + lineHeight: 1.0, + LocaleKeys.grid_field_editProperty.tr(), + ), + onTap: onTap, + ), + ); + } +} + +class FieldActionCell extends StatelessWidget { + const FieldActionCell({ + super.key, + required this.viewId, + required this.fieldInfo, + required this.action, + this.popoverMutex, + }); + + final String viewId; + final FieldInfo fieldInfo; + final FieldAction action; + final PopoverMutex? popoverMutex; + + @override + Widget build(BuildContext context) { + bool enable = true; + // If the field is primary, delete and duplicate are disabled. + if (fieldInfo.isPrimary && + (action == FieldAction.duplicate || action == FieldAction.delete)) { + enable = false; + } + return FlowyIconTextButton( + resetHoverOnRebuild: false, + disable: !enable, + onHover: (_) => popoverMutex?.close(), + onTap: () => action.run(context, viewId, fieldInfo), + // show the error color when delete is hovered + textBuilder: (onHover) => FlowyText( + action.title(fieldInfo), + lineHeight: 1.0, + color: enable + ? action == FieldAction.delete && onHover + ? Theme.of(context).colorScheme.error + : null + : Theme.of(context).disabledColor, + ), + leftIconBuilder: (onHover) => action.leading( + fieldInfo, + enable + ? action == FieldAction.delete && onHover + ? Theme.of(context).colorScheme.error + : null + : Theme.of(context).disabledColor, + ), + rightIconBuilder: (_) => action.trailing(context, fieldInfo), + ); + } +} + +enum FieldAction { + insertLeft, + insertRight, + toggleVisibility, + duplicate, + clearData, + delete, + wrap; + + Widget? leading(FieldInfo fieldInfo, Color? color) { + FlowySvgData? svgData; + switch (this) { + case FieldAction.insertLeft: + svgData = FlowySvgs.arrow_s; + case FieldAction.insertRight: + svgData = FlowySvgs.arrow_s; + case FieldAction.toggleVisibility: + if (fieldInfo.visibility != null && + fieldInfo.visibility == FieldVisibility.AlwaysHidden) { + svgData = FlowySvgs.show_m; + } else { + svgData = FlowySvgs.hide_s; + } + case FieldAction.duplicate: + svgData = FlowySvgs.copy_s; + case FieldAction.clearData: + svgData = FlowySvgs.reload_s; + case FieldAction.delete: + svgData = FlowySvgs.delete_s; + default: + } + + if (svgData == null) { + return null; + } + final icon = FlowySvg( + svgData, + size: const Size.square(16), + color: color, + ); + return this == FieldAction.insertRight + ? Transform.flip(flipX: true, child: icon) + : icon; + } + + Widget? trailing(BuildContext context, FieldInfo fieldInfo) { + if (this == FieldAction.wrap) { + return Toggle( + value: fieldInfo.wrapCellContent ?? false, + onChanged: (_) => context + .read() + .add(const FieldEditorEvent.toggleWrapCellContent()), + padding: EdgeInsets.zero, + ); + } + + return null; + } + + String title(FieldInfo fieldInfo) { + switch (this) { + case FieldAction.insertLeft: + return LocaleKeys.grid_field_insertLeft.tr(); + case FieldAction.insertRight: + return LocaleKeys.grid_field_insertRight.tr(); + case FieldAction.toggleVisibility: + if (fieldInfo.visibility != null && + fieldInfo.visibility == FieldVisibility.AlwaysHidden) { + return LocaleKeys.grid_field_show.tr(); + } else { + return LocaleKeys.grid_field_hide.tr(); + } + case FieldAction.duplicate: + return LocaleKeys.grid_field_duplicate.tr(); + case FieldAction.clearData: + return LocaleKeys.grid_field_clear.tr(); + case FieldAction.delete: + return LocaleKeys.grid_field_delete.tr(); + case FieldAction.wrap: + return LocaleKeys.grid_field_wrapCellContent.tr(); + } + } + + void run(BuildContext context, String viewId, FieldInfo fieldInfo) { + switch (this) { + case FieldAction.insertLeft: + PopoverContainer.of(context).close(); + context + .read() + .add(const FieldEditorEvent.insertLeft()); + break; + case FieldAction.insertRight: + PopoverContainer.of(context).close(); + context + .read() + .add(const FieldEditorEvent.insertRight()); + break; + case FieldAction.toggleVisibility: + PopoverContainer.of(context).close(); + context + .read() + .add(const FieldEditorEvent.toggleFieldVisibility()); + break; + case FieldAction.duplicate: + PopoverContainer.of(context).close(); + FieldBackendService.duplicateField( + viewId: viewId, + fieldId: fieldInfo.id, + ); + break; + case FieldAction.clearData: + PopoverContainer.of(context).closeAll(); + showCancelAndConfirmDialog( + context: context, + title: LocaleKeys.grid_field_label.tr(), + description: LocaleKeys.grid_field_clearFieldPromptMessage.tr(), + confirmLabel: LocaleKeys.button_confirm.tr(), + onConfirm: () { + FieldBackendService.clearField( + viewId: viewId, + fieldId: fieldInfo.id, + ); + }, + ); + break; + case FieldAction.delete: + PopoverContainer.of(context).closeAll(); + showConfirmDeletionDialog( + context: context, + name: LocaleKeys.grid_field_label.tr(), + description: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(), + onConfirm: () { + FieldBackendService.deleteField( + viewId: viewId, + fieldId: fieldInfo.id, + ); + }, + ); + break; + case FieldAction.wrap: + context + .read() + .add(const FieldEditorEvent.toggleWrapCellContent()); + break; + } + } +} + +class FieldDetailsEditor extends StatefulWidget { + const FieldDetailsEditor({ + super.key, + required this.viewId, + required this.textEditingController, + this.onAction, + }); + + final String viewId; + final TextEditingController textEditingController; + final Function()? onAction; + + @override + State createState() => _FieldDetailsEditorState(); +} + +class _FieldDetailsEditorState extends State { + final PopoverMutex popoverMutex = PopoverMutex(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final List children = [ + _NameAndIcon( + popoverMutex: popoverMutex, + textController: widget.textEditingController, + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + ), + const VSpace(8.0), + SwitchFieldButton(popoverMutex: popoverMutex), + const TypeOptionSeparator(spacing: 8.0), + Flexible( + child: FieldTypeOptionEditor( + viewId: widget.viewId, + popoverMutex: popoverMutex, + ), + ), + _addFieldVisibilityToggleButton(), + _addDuplicateFieldButton(), + _addDeleteFieldButton(), + ]; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: children, + ), + ); + } + + Widget _addFieldVisibilityToggleButton() { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: FieldActionCell( + viewId: widget.viewId, + fieldInfo: state.field, + action: FieldAction.toggleVisibility, + popoverMutex: popoverMutex, + ), + ); + }, + ); + } + + Widget _addDeleteFieldButton() { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.fromLTRB(8.0, 4.0, 8.0, 0), + child: FieldActionCell( + viewId: widget.viewId, + fieldInfo: state.field, + action: FieldAction.delete, + popoverMutex: popoverMutex, + ), + ); + }, + ); + } + + Widget _addDuplicateFieldButton() { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.fromLTRB(8.0, 4.0, 8.0, 0), + child: FieldActionCell( + viewId: widget.viewId, + fieldInfo: state.field, + action: FieldAction.duplicate, + popoverMutex: popoverMutex, + ), + ); + }, + ); + } +} + +class FieldTypeOptionEditor extends StatelessWidget { + const FieldTypeOptionEditor({ + super.key, + required this.viewId, + required this.popoverMutex, + }); + + final String viewId; + final PopoverMutex popoverMutex; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.field.isPrimary) { + return const SizedBox.shrink(); + } + final typeOptionEditor = makeTypeOptionEditor( + context: context, + viewId: viewId, + field: state.field.field, + popoverMutex: popoverMutex, + onTypeOptionUpdated: (Uint8List typeOptionData) { + context + .read() + .add(FieldEditorEvent.updateTypeOption(typeOptionData)); + }, + ); + + if (typeOptionEditor == null) { + return const SizedBox.shrink(); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: typeOptionEditor), + const TypeOptionSeparator(spacing: 8.0), + ], + ); + }, + ); + } +} + +class _NameAndIcon extends StatelessWidget { + const _NameAndIcon({ + required this.textController, + this.padding = EdgeInsets.zero, + this.popoverMutex, + }); + + final TextEditingController textController; + final PopoverMutex? popoverMutex; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: Row( + children: [ + FieldEditIconButton( + popoverMutex: popoverMutex, + ), + const HSpace(6), + Expanded( + child: FieldNameTextField( + textController: textController, + popoverMutex: popoverMutex, + ), + ), + ], + ), + ); + } +} + +class FieldEditIconButton extends StatefulWidget { + const FieldEditIconButton({ + super.key, + this.popoverMutex, + }); + + final PopoverMutex? popoverMutex; + + @override + State createState() => _FieldEditIconButtonState(); +} + +class _FieldEditIconButtonState extends State { + final PopoverController popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + offset: const Offset(0, 4), + constraints: BoxConstraints.loose(const Size(360, 432)), + margin: EdgeInsets.zero, + direction: PopoverDirection.bottomWithLeftAligned, + controller: popoverController, + mutex: widget.popoverMutex, + child: FlowyIconButton( + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + ), + borderRadius: Corners.s8Border, + ), + icon: BlocBuilder( + builder: (context, state) { + return FieldIcon(fieldInfo: state.field); + }, + ), + width: 32, + onPressed: () => popoverController.show(), + ), + popupBuilder: (popoverContext) { + return FlowyIconEmojiPicker( + enableBackgroundColorSelection: false, + tabs: const [PickerTabType.icon], + onSelectedEmoji: (r) { + if (r.type == FlowyIconType.icon) { + try { + final iconsData = IconsData.fromJson(jsonDecode(r.emoji)); + context.read().add( + FieldEditorEvent.updateIcon( + '${iconsData.groupName}/${iconsData.iconName}', + ), + ); + } on FormatException catch (e) { + Log.warn('FieldEditIconButton onSelectedEmoji error:$e'); + context + .read() + .add(const FieldEditorEvent.updateIcon('')); + } + } + PopoverContainer.of(popoverContext).close(); + }, + ); + }, + ); + } +} + +class FieldNameTextField extends StatefulWidget { + const FieldNameTextField({ + super.key, + required this.textController, + this.popoverMutex, + }); + + final TextEditingController textController; + final PopoverMutex? popoverMutex; + + @override + State createState() => _FieldNameTextFieldState(); +} + +class _FieldNameTextFieldState extends State { + final focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + + focusNode.addListener(_onFocusChanged); + widget.popoverMutex?.addPopoverListener(_onPopoverChanged); + } + + @override + void dispose() { + widget.popoverMutex?.removePopoverListener(_onPopoverChanged); + focusNode.removeListener(_onFocusChanged); + focusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FlowyTextField( + focusNode: focusNode, + controller: widget.textController, + onSubmitted: (_) => PopoverContainer.of(context).close(), + onChanged: (newName) { + context + .read() + .add(FieldEditorEvent.renameField(newName)); + }, + ); + } + + void _onFocusChanged() { + if (focusNode.hasFocus) { + widget.popoverMutex?.close(); + } + } + + void _onPopoverChanged() { + if (focusNode.hasFocus) { + focusNode.unfocus(); + } + } +} + +class SwitchFieldButton extends StatefulWidget { + const SwitchFieldButton({ + super.key, + required this.popoverMutex, + }); + + final PopoverMutex popoverMutex; + + @override + State createState() => _SwitchFieldButtonState(); +} + +class _SwitchFieldButtonState extends State { + final PopoverController _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.field.isPrimary) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: FlowyTooltip( + message: LocaleKeys.grid_field_switchPrimaryFieldTooltip.tr(), + child: FlowyButton( + text: FlowyText( + state.field.fieldType.i18n, + lineHeight: 1.0, + color: Theme.of(context).disabledColor, + ), + leftIcon: FlowySvg( + state.field.fieldType.svgData, + color: Theme.of(context).disabledColor, + ), + rightIcon: FlowySvg( + FlowySvgs.more_s, + color: Theme.of(context).disabledColor, + ), + ), + ), + ), + ); + } + return SizedBox( + height: GridSize.popoverItemHeight, + child: AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(460, 540)), + triggerActions: PopoverTriggerFlags.hover, + mutex: widget.popoverMutex, + controller: _popoverController, + offset: const Offset(8, 0), + margin: const EdgeInsets.all(8), + popupBuilder: (BuildContext popoverContext) { + return FieldTypeList( + onSelectField: (newFieldType) { + context + .read() + .add(FieldEditorEvent.switchFieldType(newFieldType)); + }, + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: FlowyButton( + onTap: () => _popoverController.show(), + text: FlowyText( + state.field.fieldType.i18n, + lineHeight: 1.0, + ), + leftIcon: FlowySvg( + state.field.fieldType.svgData, + ), + rightIcon: const FlowySvg( + FlowySvgs.more_s, + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart new file mode 100644 index 0000000000000..6661d5cd2dc83 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +typedef SelectFieldCallback = void Function(FieldType); + +const List _supportedFieldTypes = [ + FieldType.RichText, + FieldType.Number, + FieldType.SingleSelect, + FieldType.MultiSelect, + FieldType.DateTime, + FieldType.Media, + FieldType.URL, + FieldType.Checkbox, + FieldType.Checklist, + FieldType.LastEditedTime, + FieldType.CreatedTime, + FieldType.Relation, + FieldType.Summary, + FieldType.Translate, + // FieldType.Time, +]; + +class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate { + const FieldTypeList({required this.onSelectField, super.key}); + + final SelectFieldCallback onSelectField; + + @override + Widget build(BuildContext context) { + final cells = _supportedFieldTypes.map((fieldType) { + return FieldTypeCell( + fieldType: fieldType, + onSelectField: (fieldType) { + onSelectField(fieldType); + PopoverContainer.of(context).closeAll(); + }, + ); + }).toList(); + + return SizedBox( + width: 140, + child: ListView.separated( + shrinkWrap: true, + itemCount: cells.length, + separatorBuilder: (context, index) { + return VSpace(GridSize.typeOptionSeparatorHeight); + }, + physics: StyledScrollPhysics(), + itemBuilder: (BuildContext context, int index) { + return cells[index]; + }, + ), + ); + } +} + +class FieldTypeCell extends StatelessWidget { + const FieldTypeCell({ + super.key, + required this.fieldType, + required this.onSelectField, + }); + + final FieldType fieldType; + final SelectFieldCallback onSelectField; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText(fieldType.i18n, lineHeight: 1.0), + onTap: () => onSelectField(fieldType), + leftIcon: FlowySvg( + fieldType.svgData, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart new file mode 100644 index 0000000000000..624f9f1fb2d76 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart @@ -0,0 +1,71 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/media.dart'; +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/translate.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; + +import 'checkbox.dart'; +import 'checklist.dart'; +import 'date.dart'; +import 'multi_select.dart'; +import 'number.dart'; +import 'relation.dart'; +import 'rich_text.dart'; +import 'single_select.dart'; +import 'summary.dart'; +import 'time.dart'; +import 'timestamp.dart'; +import 'url.dart'; + +typedef TypeOptionDataCallback = void Function(Uint8List typeOptionData); + +abstract class TypeOptionEditorFactory { + factory TypeOptionEditorFactory.makeBuilder(FieldType fieldType) { + return switch (fieldType) { + FieldType.RichText => const RichTextTypeOptionEditorFactory(), + FieldType.Number => const NumberTypeOptionEditorFactory(), + FieldType.URL => const URLTypeOptionEditorFactory(), + FieldType.DateTime => const DateTypeOptionEditorFactory(), + FieldType.LastEditedTime => const TimestampTypeOptionEditorFactory(), + FieldType.CreatedTime => const TimestampTypeOptionEditorFactory(), + FieldType.SingleSelect => const SingleSelectTypeOptionEditorFactory(), + FieldType.MultiSelect => const MultiSelectTypeOptionEditorFactory(), + FieldType.Checkbox => const CheckboxTypeOptionEditorFactory(), + FieldType.Checklist => const ChecklistTypeOptionEditorFactory(), + FieldType.Relation => const RelationTypeOptionEditorFactory(), + FieldType.Summary => const SummaryTypeOptionEditorFactory(), + FieldType.Time => const TimeTypeOptionEditorFactory(), + FieldType.Translate => const TranslateTypeOptionEditorFactory(), + FieldType.Media => const MediaTypeOptionEditorFactory(), + _ => throw UnimplementedError(), + }; + } + + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }); +} + +Widget? makeTypeOptionEditor({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, +}) { + final editorBuilder = TypeOptionEditorFactory.makeBuilder(field.fieldType); + return editorBuilder.build( + context: context, + viewId: viewId, + field: field, + onTypeOptionUpdated: onTypeOptionUpdated, + popoverMutex: popoverMutex, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/checkbox.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/checkbox.dart new file mode 100644 index 0000000000000..70701fb29001c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/checkbox.dart @@ -0,0 +1,19 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; + +import 'builder.dart'; + +class CheckboxTypeOptionEditorFactory implements TypeOptionEditorFactory { + const CheckboxTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) => + null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/checklist.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/checklist.dart new file mode 100644 index 0000000000000..0e045cfe56e2c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/checklist.dart @@ -0,0 +1,19 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; + +import 'builder.dart'; + +class ChecklistTypeOptionEditorFactory implements TypeOptionEditorFactory { + const ChecklistTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) => + null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/date.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/date.dart new file mode 100644 index 0000000000000..ab216c7b9845d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/date.dart @@ -0,0 +1,120 @@ +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:protobuf/protobuf.dart'; + +import '../../../grid/presentation/layout/sizes.dart'; +import 'builder.dart'; +import 'date/date_time_format.dart'; + +class DateTypeOptionEditorFactory implements TypeOptionEditorFactory { + const DateTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) { + final typeOption = _parseTypeOptionData(field.typeOptionData); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _renderDateFormatButton( + typeOption, + popoverMutex, + onTypeOptionUpdated, + ), + VSpace(GridSize.typeOptionSeparatorHeight), + _renderTimeFormatButton( + typeOption, + popoverMutex, + onTypeOptionUpdated, + ), + ], + ); + } + + Widget _renderDateFormatButton( + DateTypeOptionPB typeOption, + PopoverMutex popoverMutex, + TypeOptionDataCallback onTypeOptionUpdated, + ) { + return AppFlowyPopover( + mutex: popoverMutex, + asBarrier: true, + triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + offset: const Offset(8, 0), + constraints: BoxConstraints.loose(const Size(460, 440)), + popupBuilder: (popoverContext) { + return DateFormatList( + selectedFormat: typeOption.dateFormat, + onSelected: (format) { + final newTypeOption = + _updateTypeOption(typeOption: typeOption, dateFormat: format); + onTypeOptionUpdated(newTypeOption.writeToBuffer()); + PopoverContainer.of(popoverContext).close(); + }, + ); + }, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 12.0), + child: DateFormatButton(), + ), + ); + } + + Widget _renderTimeFormatButton( + DateTypeOptionPB typeOption, + PopoverMutex popoverMutex, + TypeOptionDataCallback onTypeOptionUpdated, + ) { + return AppFlowyPopover( + mutex: popoverMutex, + asBarrier: true, + triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + offset: const Offset(8, 0), + constraints: BoxConstraints.loose(const Size(460, 440)), + popupBuilder: (BuildContext popoverContext) { + return TimeFormatList( + selectedFormat: typeOption.timeFormat, + onSelected: (format) { + final newTypeOption = + _updateTypeOption(typeOption: typeOption, timeFormat: format); + onTypeOptionUpdated(newTypeOption.writeToBuffer()); + PopoverContainer.of(popoverContext).close(); + }, + ); + }, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 12.0), + child: TimeFormatButton(), + ), + ); + } + + DateTypeOptionPB _parseTypeOptionData(List data) { + return DateTypeOptionDataParser().fromBuffer(data); + } + + DateTypeOptionPB _updateTypeOption({ + required DateTypeOptionPB typeOption, + DateFormatPB? dateFormat, + TimeFormatPB? timeFormat, + }) { + typeOption.freeze(); + return typeOption.rebuild((typeOption) { + if (dateFormat != null) { + typeOption.dateFormat = dateFormat; + } + + if (timeFormat != null) { + typeOption.timeFormat = timeFormat; + } + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart new file mode 100644 index 0000000000000..862e46fc3bc37 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class DateFormatButton extends StatelessWidget { + const DateFormatButton({ + super.key, + this.onTap, + this.onHover, + }); + + final VoidCallback? onTap; + final void Function(bool)? onHover; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText( + LocaleKeys.grid_field_dateFormat.tr(), + lineHeight: 1.0, + ), + onTap: onTap, + onHover: onHover, + rightIcon: const FlowySvg(FlowySvgs.more_s), + ), + ); + } +} + +class TimeFormatButton extends StatelessWidget { + const TimeFormatButton({ + super.key, + this.onTap, + this.onHover, + }); + + final VoidCallback? onTap; + final void Function(bool)? onHover; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText( + LocaleKeys.grid_field_timeFormat.tr(), + lineHeight: 1.0, + ), + onTap: onTap, + onHover: onHover, + rightIcon: const FlowySvg(FlowySvgs.more_s), + ), + ); + } +} + +class DateFormatList extends StatelessWidget { + const DateFormatList({ + super.key, + required this.selectedFormat, + required this.onSelected, + }); + + final DateFormatPB selectedFormat; + final Function(DateFormatPB format) onSelected; + + @override + Widget build(BuildContext context) { + final cells = DateFormatPB.values + .where((value) => value != DateFormatPB.FriendlyFull) + .map((format) { + return DateFormatCell( + dateFormat: format, + onSelected: onSelected, + isSelected: selectedFormat == format, + ); + }).toList(); + + return SizedBox( + width: 180, + child: ListView.separated( + shrinkWrap: true, + separatorBuilder: (context, index) { + return VSpace(GridSize.typeOptionSeparatorHeight); + }, + itemCount: cells.length, + itemBuilder: (BuildContext context, int index) { + return cells[index]; + }, + ), + ); + } +} + +class DateFormatCell extends StatelessWidget { + const DateFormatCell({ + super.key, + required this.dateFormat, + required this.onSelected, + required this.isSelected, + }); + + final DateFormatPB dateFormat; + final Function(DateFormatPB format) onSelected; + final bool isSelected; + + @override + Widget build(BuildContext context) { + Widget? checkmark; + if (isSelected) { + checkmark = const FlowySvg(FlowySvgs.check_s); + } + + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText( + dateFormat.title(), + lineHeight: 1.0, + ), + rightIcon: checkmark, + onTap: () => onSelected(dateFormat), + ), + ); + } +} + +extension DateFormatExtension on DateFormatPB { + String title() { + switch (this) { + case DateFormatPB.Friendly: + return LocaleKeys.grid_field_dateFormatFriendly.tr(); + case DateFormatPB.ISO: + return LocaleKeys.grid_field_dateFormatISO.tr(); + case DateFormatPB.Local: + return LocaleKeys.grid_field_dateFormatLocal.tr(); + case DateFormatPB.US: + return LocaleKeys.grid_field_dateFormatUS.tr(); + case DateFormatPB.DayMonthYear: + return LocaleKeys.grid_field_dateFormatDayMonthYear.tr(); + case DateFormatPB.FriendlyFull: + return LocaleKeys.grid_field_dateFormatFriendly.tr(); + default: + throw UnimplementedError; + } + } +} + +class TimeFormatList extends StatelessWidget { + const TimeFormatList({ + super.key, + required this.selectedFormat, + required this.onSelected, + }); + + final TimeFormatPB selectedFormat; + final Function(TimeFormatPB format) onSelected; + + @override + Widget build(BuildContext context) { + final cells = TimeFormatPB.values.map((format) { + return TimeFormatCell( + isSelected: format == selectedFormat, + timeFormat: format, + onSelected: onSelected, + ); + }).toList(); + + return SizedBox( + width: 120, + child: ListView.separated( + shrinkWrap: true, + separatorBuilder: (context, index) { + return VSpace(GridSize.typeOptionSeparatorHeight); + }, + itemCount: cells.length, + itemBuilder: (BuildContext context, int index) { + return cells[index]; + }, + ), + ); + } +} + +class TimeFormatCell extends StatelessWidget { + const TimeFormatCell({ + super.key, + required this.timeFormat, + required this.onSelected, + required this.isSelected, + }); + + final TimeFormatPB timeFormat; + final bool isSelected; + final Function(TimeFormatPB format) onSelected; + + @override + Widget build(BuildContext context) { + Widget? checkmark; + if (isSelected) { + checkmark = const FlowySvg(FlowySvgs.check_s); + } + + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText( + timeFormat.title(), + lineHeight: 1.0, + ), + rightIcon: checkmark, + onTap: () => onSelected(timeFormat), + ), + ); + } +} + +extension TimeFormatExtension on TimeFormatPB { + String title() { + switch (this) { + case TimeFormatPB.TwelveHour: + return LocaleKeys.grid_field_timeFormatTwelveHour.tr(); + case TimeFormatPB.TwentyFourHour: + return LocaleKeys.grid_field_timeFormatTwentyFourHour.tr(); + default: + throw UnimplementedError; + } + } +} + +class IncludeTimeButton extends StatelessWidget { + const IncludeTimeButton({ + super.key, + required this.onChanged, + required this.includeTime, + }); + + final Function(bool value) onChanged; + final bool includeTime; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: Padding( + padding: GridSize.typeOptionContentInsets, + child: Row( + children: [ + FlowySvg( + FlowySvgs.clock_alarm_s, + color: Theme.of(context).iconTheme.color, + ), + const HSpace(6), + FlowyText(LocaleKeys.grid_field_includeTime.tr()), + const Spacer(), + Toggle( + value: includeTime, + onChanged: onChanged, + padding: EdgeInsets.zero, + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart new file mode 100644 index 0000000000000..07dc2bafd0e6e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/builder.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:protobuf/protobuf.dart'; + +class MediaTypeOptionEditorFactory implements TypeOptionEditorFactory { + const MediaTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) { + final typeOption = _parseTypeOptionData(field.typeOptionData); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + height: GridSize.popoverItemHeight, + alignment: Alignment.centerLeft, + child: FlowyButton( + resetHoverOnRebuild: false, + text: FlowyText( + LocaleKeys.grid_media_showFileNames.tr(), + lineHeight: 1.0, + ), + onHover: (_) => popoverMutex.close(), + rightIcon: Toggle( + value: !typeOption.hideFileNames, + onChanged: (val) => onTypeOptionUpdated( + _toggleHideFiles(typeOption, !val).writeToBuffer(), + ), + padding: EdgeInsets.zero, + ), + ), + ); + } + + MediaTypeOptionPB _parseTypeOptionData(List data) { + return MediaTypeOptionDataParser().fromBuffer(data); + } + + MediaTypeOptionPB _toggleHideFiles( + MediaTypeOptionPB typeOption, + bool hideFileNames, + ) { + typeOption.freeze(); + return typeOption.rebuild((to) => to.hideFileNames = hideFileNames); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/multi_select.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/multi_select.dart new file mode 100644 index 0000000000000..1e1de37ece248 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/multi_select.dart @@ -0,0 +1,38 @@ +import 'package:appflowy/plugins/database/application/field/type_option/select_type_option_actions.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter/material.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; + +import 'builder.dart'; +import 'select/select_option.dart'; + +class MultiSelectTypeOptionEditorFactory implements TypeOptionEditorFactory { + const MultiSelectTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) { + final typeOption = _parseTypeOptionData(field.typeOptionData); + + return SelectOptionTypeOptionWidget( + options: typeOption.options, + beginEdit: () => PopoverContainer.of(context).closeAll(), + popoverMutex: popoverMutex, + typeOptionAction: MultiSelectAction( + viewId: viewId, + fieldId: field.id, + onTypeOptionUpdated: onTypeOptionUpdated, + ), + ); + } + + MultiSelectTypeOptionPB _parseTypeOptionData(List data) { + return MultiSelectTypeOptionDataParser().fromBuffer(data); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/number.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/number.dart new file mode 100644 index 0000000000000..b8a40907c670a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/number.dart @@ -0,0 +1,194 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:protobuf/protobuf.dart'; + +import '../../../grid/presentation/layout/sizes.dart'; +import '../../../grid/presentation/widgets/common/type_option_separator.dart'; +import 'builder.dart'; + +class NumberTypeOptionEditorFactory implements TypeOptionEditorFactory { + const NumberTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) { + final typeOption = _parseTypeOptionData(field.typeOptionData); + + final selectNumUnitButton = SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + rightIcon: const FlowySvg(FlowySvgs.more_s), + text: FlowyText( + lineHeight: 1.0, + typeOption.format.title(), + ), + ), + ); + + final numFormatTitle = Container( + padding: const EdgeInsets.only(left: 6), + height: GridSize.popoverItemHeight, + alignment: Alignment.centerLeft, + child: FlowyText.regular( + LocaleKeys.grid_field_numberFormat.tr(), + color: Theme.of(context).hintColor, + fontSize: 11, + ), + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + numFormatTitle, + AppFlowyPopover( + mutex: popoverMutex, + triggerActions: + PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + offset: const Offset(16, 0), + constraints: BoxConstraints.loose(const Size(460, 440)), + margin: EdgeInsets.zero, + child: selectNumUnitButton, + popupBuilder: (BuildContext popoverContext) { + return NumberFormatList( + selectedFormat: typeOption.format, + onSelected: (format) { + final newTypeOption = _updateNumberFormat(typeOption, format); + onTypeOptionUpdated(newTypeOption.writeToBuffer()); + PopoverContainer.of(popoverContext).close(); + }, + ); + }, + ), + ], + ), + ); + } + + NumberTypeOptionPB _parseTypeOptionData(List data) { + return NumberTypeOptionDataParser().fromBuffer(data); + } + + NumberTypeOptionPB _updateNumberFormat( + NumberTypeOptionPB typeOption, + NumberFormatPB format, + ) { + typeOption.freeze(); + return typeOption.rebuild((typeOption) => typeOption.format = format); + } +} + +typedef SelectNumberFormatCallback = void Function(NumberFormatPB format); + +class NumberFormatList extends StatelessWidget { + const NumberFormatList({ + super.key, + required this.selectedFormat, + required this.onSelected, + }); + + final NumberFormatPB selectedFormat; + final SelectNumberFormatCallback onSelected; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => NumberFormatBloc(), + child: SizedBox( + width: 180, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const _FilterTextField(), + const TypeOptionSeparator(spacing: 0.0), + BlocBuilder( + builder: (context, state) { + final cells = state.formats.map((format) { + return NumberFormatCell( + isSelected: format == selectedFormat, + format: format, + onSelected: (format) { + onSelected(format); + }, + ); + }).toList(); + + final list = ListView.separated( + shrinkWrap: true, + separatorBuilder: (context, index) { + return VSpace(GridSize.typeOptionSeparatorHeight); + }, + itemCount: cells.length, + itemBuilder: (BuildContext context, int index) { + return cells[index]; + }, + padding: const EdgeInsets.all(6.0), + ); + return Flexible(child: list); + }, + ), + ], + ), + ), + ); + } +} + +class NumberFormatCell extends StatelessWidget { + const NumberFormatCell({ + super.key, + required this.format, + required this.isSelected, + required this.onSelected, + }); + + final NumberFormatPB format; + final bool isSelected; + final SelectNumberFormatCallback onSelected; + + @override + Widget build(BuildContext context) { + final checkmark = isSelected ? const FlowySvg(FlowySvgs.check_s) : null; + + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText( + format.title(), + lineHeight: 1.0, + ), + onTap: () => onSelected(format), + rightIcon: checkmark, + ), + ); + } +} + +class _FilterTextField extends StatelessWidget { + const _FilterTextField(); + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(6.0), + child: FlowyTextField( + onChanged: (text) => context + .read() + .add(NumberFormatEvent.setFilter(text)), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/relation.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/relation.dart new file mode 100644 index 0000000000000..2ee3222b2311f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/relation.dart @@ -0,0 +1,161 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/relation_type_option_cubit.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:protobuf/protobuf.dart'; + +import 'builder.dart'; + +class RelationTypeOptionEditorFactory implements TypeOptionEditorFactory { + const RelationTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) { + final typeOption = _parseTypeOptionData(field.typeOptionData); + + return BlocProvider( + create: (_) => RelationDatabaseListCubit(), + child: Builder( + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.only(left: 14, right: 8), + height: GridSize.popoverItemHeight, + alignment: Alignment.centerLeft, + child: FlowyText.regular( + LocaleKeys.grid_relation_relatedDatabasePlaceLabel.tr(), + color: Theme.of(context).hintColor, + fontSize: 11, + ), + ), + AppFlowyPopover( + mutex: popoverMutex, + triggerActions: + PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + offset: const Offset(6, 0), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: BlocBuilder( + builder: (context, state) { + final databaseMeta = + state.databaseMetas.firstWhereOrNull( + (meta) => meta.databaseId == typeOption.databaseId, + ); + return FlowyText( + lineHeight: 1.0, + databaseMeta == null + ? LocaleKeys + .grid_relation_relatedDatabasePlaceholder + .tr() + : databaseMeta.databaseName, + color: databaseMeta == null + ? Theme.of(context).hintColor + : null, + overflow: TextOverflow.ellipsis, + ); + }, + ), + rightIcon: const FlowySvg(FlowySvgs.more_s), + ), + ), + popupBuilder: (popoverContext) { + return BlocProvider.value( + value: context.read(), + child: _DatabaseList( + onSelectDatabase: (newDatabaseId) { + final newTypeOption = _updateTypeOption( + typeOption: typeOption, + databaseId: newDatabaseId, + ); + onTypeOptionUpdated(newTypeOption.writeToBuffer()); + PopoverContainer.of(context).close(); + }, + currentDatabaseId: typeOption.databaseId, + ), + ); + }, + ), + ], + ); + }, + ), + ); + } + + RelationTypeOptionPB _parseTypeOptionData(List data) { + return RelationTypeOptionDataParser().fromBuffer(data); + } + + RelationTypeOptionPB _updateTypeOption({ + required RelationTypeOptionPB typeOption, + required String databaseId, + }) { + typeOption.freeze(); + return typeOption.rebuild((typeOption) { + typeOption.databaseId = databaseId; + }); + } +} + +class _DatabaseList extends StatelessWidget { + const _DatabaseList({ + required this.onSelectDatabase, + required this.currentDatabaseId, + }); + + final String currentDatabaseId; + final void Function(String databaseId) onSelectDatabase; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final children = state.databaseMetas.map((meta) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + onTap: () => onSelectDatabase(meta.databaseId), + text: FlowyText( + lineHeight: 1.0, + meta.databaseName, + overflow: TextOverflow.ellipsis, + ), + rightIcon: meta.databaseId == currentDatabaseId + ? const FlowySvg( + FlowySvgs.check_s, + ) + : null, + ), + ); + }).toList(); + + return ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), + itemCount: children.length, + itemBuilder: (context, index) => children[index], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/rich_text.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/rich_text.dart new file mode 100644 index 0000000000000..f13a4515fb763 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/rich_text.dart @@ -0,0 +1,18 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; +import 'builder.dart'; + +class RichTextTypeOptionEditorFactory implements TypeOptionEditorFactory { + const RichTextTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) => + null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option.dart new file mode 100644 index 0000000000000..5201630cc7f6a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option.dart @@ -0,0 +1,324 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/select_option_type_option_bloc.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/select_type_option_actions.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'select_option_editor.dart'; + +class SelectOptionTypeOptionWidget extends StatelessWidget { + const SelectOptionTypeOptionWidget({ + super.key, + required this.options, + required this.beginEdit, + required this.typeOptionAction, + this.popoverMutex, + }); + + final List options; + final VoidCallback beginEdit; + final ISelectOptionAction typeOptionAction; + final PopoverMutex? popoverMutex; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SelectOptionTypeOptionBloc( + options: options, + typeOptionAction: typeOptionAction, + ), + child: + BlocBuilder( + builder: (context, state) { + final List children = [ + const _OptionTitle(), + const VSpace(4), + if (state.isEditingOption) ...[ + CreateOptionTextField(popoverMutex: popoverMutex), + const VSpace(4), + ] else + const _AddOptionButton(), + const VSpace(4), + Flexible( + child: _OptionList( + popoverMutex: popoverMutex, + ), + ), + ]; + + return Column( + mainAxisSize: MainAxisSize.min, + children: children, + ); + }, + ), + ); + } +} + +class _OptionTitle extends StatelessWidget { + const _OptionTitle(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: FlowyText.regular( + LocaleKeys.grid_field_optionTitle.tr(), + fontSize: 11, + color: Theme.of(context).hintColor, + ), + ), + ); + }, + ); + } +} + +class _OptionCell extends StatefulWidget { + const _OptionCell({ + super.key, + required this.option, + required this.index, + this.popoverMutex, + }); + + final SelectOptionPB option; + final int index; + final PopoverMutex? popoverMutex; + + @override + State<_OptionCell> createState() => _OptionCellState(); +} + +class _OptionCellState extends State<_OptionCell> { + final PopoverController _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + final child = SizedBox( + height: 28, + child: SelectOptionTagCell( + option: widget.option, + index: widget.index, + onSelected: () => _popoverController.show(), + children: [ + FlowyIconButton( + onPressed: () => _popoverController.show(), + iconPadding: const EdgeInsets.symmetric(horizontal: 6.0), + hoverColor: Colors.transparent, + icon: FlowySvg( + FlowySvgs.three_dots_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(16), + ), + ), + ], + ), + ); + return AppFlowyPopover( + controller: _popoverController, + mutex: widget.popoverMutex, + offset: const Offset(8, 0), + margin: EdgeInsets.zero, + asBarrier: true, + constraints: BoxConstraints.loose(const Size(460, 470)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: FlowyHover( + resetHoverOnRebuild: false, + style: HoverStyle( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + child: child, + ), + ), + popupBuilder: (BuildContext popoverContext) { + return SelectOptionEditor( + option: widget.option, + onDeleted: () { + context + .read() + .add(SelectOptionTypeOptionEvent.deleteOption(widget.option)); + PopoverContainer.of(popoverContext).close(); + }, + onUpdated: (updatedOption) { + context + .read() + .add(SelectOptionTypeOptionEvent.updateOption(updatedOption)); + PopoverContainer.of(popoverContext).close(); + }, + key: ValueKey(widget.option.id), + ); + }, + ); + } +} + +class _AddOptionButton extends StatelessWidget { + const _AddOptionButton(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText( + lineHeight: 1.0, + LocaleKeys.grid_field_addSelectOption.tr(), + ), + onTap: () { + context + .read() + .add(const SelectOptionTypeOptionEvent.addingOption()); + }, + leftIcon: const FlowySvg(FlowySvgs.add_s), + ), + ), + ); + } +} + +class CreateOptionTextField extends StatefulWidget { + const CreateOptionTextField({super.key, this.popoverMutex}); + + final PopoverMutex? popoverMutex; + + @override + State createState() => _CreateOptionTextFieldState(); +} + +class _CreateOptionTextFieldState extends State { + final focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + + focusNode.addListener(_onFocusChanged); + widget.popoverMutex?.addPopoverListener(_onPopoverChanged); + } + + @override + void dispose() { + widget.popoverMutex?.removePopoverListener(_onPopoverChanged); + focusNode.removeListener(_onFocusChanged); + focusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final text = state.newOptionName ?? ''; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14.0), + child: FlowyTextField( + autoClearWhenDone: true, + text: text, + focusNode: focusNode, + onCanceled: () { + context + .read() + .add(const SelectOptionTypeOptionEvent.endAddingOption()); + }, + onEditingComplete: () {}, + onSubmitted: (optionName) { + context + .read() + .add(SelectOptionTypeOptionEvent.createOption(optionName)); + }, + ), + ); + }, + ); + } + + void _onFocusChanged() { + if (focusNode.hasFocus) { + widget.popoverMutex?.close(); + } + } + + void _onPopoverChanged() { + if (focusNode.hasFocus) { + focusNode.unfocus(); + } + } +} + +class _OptionList extends StatelessWidget { + const _OptionList({ + this.popoverMutex, + }); + + final PopoverMutex? popoverMutex; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return ReorderableListView.builder( + shrinkWrap: true, + onReorderStart: (_) => popoverMutex?.close(), + proxyDecorator: (child, index, _) => Material( + color: Colors.transparent, + child: Stack( + children: [ + BlocProvider.value( + value: context.read(), + child: child, + ), + MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: const SizedBox.expand(), + ), + ], + ), + ), + buildDefaultDragHandles: false, + itemBuilder: (context, index) => _OptionCell( + key: ValueKey("select_type_option_list_${state.options[index].id}"), + index: index, + option: state.options[index], + popoverMutex: popoverMutex, + ), + itemCount: state.options.length, + onReorder: (oldIndex, newIndex) { + if (oldIndex < newIndex) { + newIndex--; + } + final fromOptionId = state.options[oldIndex].id; + final toOptionId = state.options[newIndex].id; + context.read().add( + SelectOptionTypeOptionEvent.reorderOption( + fromOptionId, + toOptionId, + ), + ); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option_editor.dart new file mode 100644 index 0000000000000..9946a6ab75537 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/select/select_option_editor.dart @@ -0,0 +1,244 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/edit_select_option_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../grid/presentation/layout/sizes.dart'; +import '../../../../grid/presentation/widgets/common/type_option_separator.dart'; + +class SelectOptionEditor extends StatelessWidget { + const SelectOptionEditor({ + super.key, + required this.option, + required this.onDeleted, + required this.onUpdated, + this.showOptions = true, + this.autoFocus = true, + }); + + final SelectOptionPB option; + final VoidCallback onDeleted; + final Function(SelectOptionPB) onUpdated; + final bool showOptions; + final bool autoFocus; + + static String get identifier => (SelectOptionEditor).toString(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => EditSelectOptionBloc(option: option), + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (p, c) => p.deleted != c.deleted, + listener: (context, state) { + if (state.deleted) { + onDeleted(); + } + }, + ), + BlocListener( + listenWhen: (p, c) => p.option != c.option, + listener: (context, state) { + onUpdated(state.option); + }, + ), + ], + child: BlocBuilder( + builder: (context, state) { + final List cells = [ + _OptionNameTextField( + name: state.option.name, + autoFocus: autoFocus, + ), + const VSpace(10), + const _DeleteTag(), + const TypeOptionSeparator(), + SelectOptionColorList( + selectedColor: state.option.color, + onSelectedColor: (color) => context + .read() + .add(EditSelectOptionEvent.updateColor(color)), + ), + ]; + return SizedBox( + width: 180, + child: ListView.builder( + shrinkWrap: true, + physics: StyledScrollPhysics(), + itemCount: cells.length, + itemBuilder: (context, index) { + if (cells[index] is TypeOptionSeparator) { + return cells[index]; + } else { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: cells[index], + ); + } + }, + padding: const EdgeInsets.symmetric(vertical: 6.0), + ), + ); + }, + ), + ), + ); + } +} + +class _DeleteTag extends StatelessWidget { + const _DeleteTag(); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText( + lineHeight: 1.0, + LocaleKeys.grid_selectOption_deleteTag.tr(), + ), + leftIcon: const FlowySvg(FlowySvgs.delete_s), + onTap: () { + context + .read() + .add(const EditSelectOptionEvent.delete()); + }, + ), + ); + } +} + +class _OptionNameTextField extends StatelessWidget { + const _OptionNameTextField({ + required this.name, + required this.autoFocus, + }); + + final String name; + final bool autoFocus; + + @override + Widget build(BuildContext context) { + return FlowyTextField( + autoFocus: autoFocus, + text: name, + submitOnLeave: true, + onSubmitted: (newName) { + if (name != newName) { + context + .read() + .add(EditSelectOptionEvent.updateName(newName)); + } + }, + ); + } +} + +class SelectOptionColorList extends StatelessWidget { + const SelectOptionColorList({ + super.key, + this.selectedColor, + required this.onSelectedColor, + }); + + final SelectOptionColorPB? selectedColor; + final void Function(SelectOptionColorPB color) onSelectedColor; + + @override + Widget build(BuildContext context) { + final cells = SelectOptionColorPB.values.map((color) { + return _SelectOptionColorCell( + color: color, + isSelected: selectedColor != null ? selectedColor == color : false, + onSelectedColor: onSelectedColor, + ); + }).toList(); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: GridSize.typeOptionContentInsets, + child: SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyText( + LocaleKeys.grid_selectOption_colorPanelTitle.tr(), + textAlign: TextAlign.left, + color: Theme.of(context).hintColor, + ), + ), + ), + ListView.separated( + shrinkWrap: true, + separatorBuilder: (context, index) { + return VSpace(GridSize.typeOptionSeparatorHeight); + }, + itemCount: cells.length, + physics: StyledScrollPhysics(), + itemBuilder: (BuildContext context, int index) { + return cells[index]; + }, + ), + ], + ); + } +} + +class _SelectOptionColorCell extends StatelessWidget { + const _SelectOptionColorCell({ + required this.color, + required this.isSelected, + required this.onSelectedColor, + }); + + final SelectOptionColorPB color; + final bool isSelected; + final void Function(SelectOptionColorPB color) onSelectedColor; + + @override + Widget build(BuildContext context) { + Widget? checkmark; + if (isSelected) { + checkmark = const FlowySvg(FlowySvgs.check_s); + } + + final colorIcon = SizedBox.square( + dimension: 16, + child: DecoratedBox( + decoration: BoxDecoration( + color: color.toColor(context), + shape: BoxShape.circle, + ), + ), + ); + + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + text: FlowyText( + lineHeight: 1.0, + color.colorName(), + color: AFThemeExtension.of(context).textColor, + ), + leftIcon: colorIcon, + rightIcon: checkmark, + onTap: () => onSelectedColor(color), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/single_select.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/single_select.dart new file mode 100644 index 0000000000000..a0ea917b2125c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/single_select.dart @@ -0,0 +1,38 @@ +import 'package:appflowy/plugins/database/application/field/type_option/select_type_option_actions.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter/material.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; + +import 'builder.dart'; +import 'select/select_option.dart'; + +class SingleSelectTypeOptionEditorFactory implements TypeOptionEditorFactory { + const SingleSelectTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) { + final typeOption = _parseTypeOptionData(field.typeOptionData); + + return SelectOptionTypeOptionWidget( + options: typeOption.options, + beginEdit: () => PopoverContainer.of(context).closeAll(), + popoverMutex: popoverMutex, + typeOptionAction: SingleSelectAction( + viewId: viewId, + fieldId: field.id, + onTypeOptionUpdated: onTypeOptionUpdated, + ), + ); + } + + SingleSelectTypeOptionPB _parseTypeOptionData(List data) { + return SingleSelectTypeOptionDataParser().fromBuffer(data); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/summary.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/summary.dart new file mode 100644 index 0000000000000..76a78aa22a73a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/summary.dart @@ -0,0 +1,19 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; + +import 'builder.dart'; + +class SummaryTypeOptionEditorFactory implements TypeOptionEditorFactory { + const SummaryTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) => + null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/time.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/time.dart new file mode 100644 index 0000000000000..01a8c519c2f6f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/time.dart @@ -0,0 +1,19 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; + +import 'builder.dart'; + +class TimeTypeOptionEditorFactory implements TypeOptionEditorFactory { + const TimeTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) => + null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/timestamp.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/timestamp.dart new file mode 100644 index 0000000000000..e67929d2dcf36 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/timestamp.dart @@ -0,0 +1,130 @@ +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:protobuf/protobuf.dart'; + +import 'builder.dart'; +import 'date/date_time_format.dart'; + +class TimestampTypeOptionEditorFactory implements TypeOptionEditorFactory { + const TimestampTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) { + final typeOption = _parseTypeOptionData(field.typeOptionData); + + return SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => VSpace(GridSize.typeOptionSeparatorHeight), + children: [ + _renderDateFormatButton(typeOption, popoverMutex, onTypeOptionUpdated), + _renderTimeFormatButton(typeOption, popoverMutex, onTypeOptionUpdated), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: IncludeTimeButton( + onChanged: (value) { + final newTypeOption = _updateTypeOption( + typeOption: typeOption, + includeTime: value, + ); + onTypeOptionUpdated(newTypeOption.writeToBuffer()); + }, + includeTime: typeOption.includeTime, + ), + ), + ], + ); + } + + Widget _renderDateFormatButton( + TimestampTypeOptionPB typeOption, + PopoverMutex popoverMutex, + TypeOptionDataCallback onTypeOptionUpdated, + ) { + return AppFlowyPopover( + mutex: popoverMutex, + asBarrier: true, + triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + offset: const Offset(8, 0), + constraints: BoxConstraints.loose(const Size(460, 440)), + popupBuilder: (popoverContext) { + return DateFormatList( + selectedFormat: typeOption.dateFormat, + onSelected: (format) { + final newTypeOption = + _updateTypeOption(typeOption: typeOption, dateFormat: format); + onTypeOptionUpdated(newTypeOption.writeToBuffer()); + PopoverContainer.of(popoverContext).close(); + }, + ); + }, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 12.0), + child: DateFormatButton(), + ), + ); + } + + Widget _renderTimeFormatButton( + TimestampTypeOptionPB typeOption, + PopoverMutex popoverMutex, + TypeOptionDataCallback onTypeOptionUpdated, + ) { + return AppFlowyPopover( + mutex: popoverMutex, + asBarrier: true, + triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + offset: const Offset(8, 0), + constraints: BoxConstraints.loose(const Size(460, 440)), + popupBuilder: (BuildContext popoverContext) { + return TimeFormatList( + selectedFormat: typeOption.timeFormat, + onSelected: (format) { + final newTypeOption = + _updateTypeOption(typeOption: typeOption, timeFormat: format); + onTypeOptionUpdated(newTypeOption.writeToBuffer()); + PopoverContainer.of(popoverContext).close(); + }, + ); + }, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 12.0), + child: TimeFormatButton(), + ), + ); + } + + TimestampTypeOptionPB _parseTypeOptionData(List data) { + return TimestampTypeOptionDataParser().fromBuffer(data); + } + + TimestampTypeOptionPB _updateTypeOption({ + required TimestampTypeOptionPB typeOption, + DateFormatPB? dateFormat, + TimeFormatPB? timeFormat, + bool? includeTime, + }) { + typeOption.freeze(); + return typeOption.rebuild((typeOption) { + if (dateFormat != null) { + typeOption.dateFormat = dateFormat; + } + + if (timeFormat != null) { + typeOption.timeFormat = timeFormat; + } + + if (includeTime != null) { + typeOption.includeTime = includeTime; + } + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/translate.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/translate.dart new file mode 100644 index 0000000000000..70ce6e8049f4a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/translate.dart @@ -0,0 +1,175 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/translate_type_option_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import './builder.dart'; + +class TranslateTypeOptionEditorFactory implements TypeOptionEditorFactory { + const TranslateTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) { + final typeOption = TranslateTypeOptionPB.fromBuffer(field.typeOptionData); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + LocaleKeys.grid_field_translateTo.tr(), + ), + const HSpace(6), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: BlocProvider( + create: (context) => TranslateTypeOptionBloc(option: typeOption), + child: BlocConsumer( + listenWhen: (previous, current) => + previous.option != current.option, + listener: (context, state) { + onTypeOptionUpdated(state.option.writeToBuffer()); + }, + builder: (context, state) { + return _wrapLanguageListPopover( + context, + state, + popoverMutex, + SelectLanguageButton( + language: state.language, + ), + ); + }, + ), + ), + ), + ], + ), + ); + } + + Widget _wrapLanguageListPopover( + BuildContext blocContext, + TranslateTypeOptionState state, + PopoverMutex popoverMutex, + Widget child, + ) { + return AppFlowyPopover( + mutex: popoverMutex, + asBarrier: true, + triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + offset: const Offset(8, 0), + constraints: BoxConstraints.loose(const Size(460, 440)), + popupBuilder: (popoverContext) { + return LanguageList( + onSelected: (language) { + blocContext + .read() + .add(TranslateTypeOptionEvent.selectLanguage(language)); + PopoverContainer.of(popoverContext).close(); + }, + selectedLanguage: state.option.language, + ); + }, + child: child, + ); + } +} + +class SelectLanguageButton extends StatelessWidget { + const SelectLanguageButton({required this.language, super.key}); + final String language; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 30, + child: FlowyButton( + text: FlowyText( + language, + lineHeight: 1.0, + ), + ), + ); + } +} + +class LanguageList extends StatelessWidget { + const LanguageList({ + super.key, + required this.onSelected, + required this.selectedLanguage, + }); + + final Function(TranslateLanguagePB) onSelected; + final TranslateLanguagePB selectedLanguage; + + @override + Widget build(BuildContext context) { + final cells = TranslateLanguagePB.values.map((languageType) { + return LanguageCell( + languageType: languageType, + onSelected: onSelected, + isSelected: languageType == selectedLanguage, + ); + }).toList(); + + return SizedBox( + width: 180, + child: ListView.separated( + shrinkWrap: true, + separatorBuilder: (context, index) { + return VSpace(GridSize.typeOptionSeparatorHeight); + }, + itemCount: cells.length, + itemBuilder: (BuildContext context, int index) { + return cells[index]; + }, + ), + ); + } +} + +class LanguageCell extends StatelessWidget { + const LanguageCell({ + required this.languageType, + required this.onSelected, + required this.isSelected, + super.key, + }); + final Function(TranslateLanguagePB) onSelected; + final TranslateLanguagePB languageType; + final bool isSelected; + + @override + Widget build(BuildContext context) { + Widget? checkmark; + if (isSelected) { + checkmark = const FlowySvg(FlowySvgs.check_s); + } + + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText( + languageTypeToLanguage(languageType), + lineHeight: 1.0, + ), + rightIcon: checkmark, + onTap: () => onSelected(languageType), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/url.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/url.dart new file mode 100644 index 0000000000000..6dc95d882414b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/url.dart @@ -0,0 +1,18 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; +import 'builder.dart'; + +class URLTypeOptionEditorFactory implements TypeOptionEditorFactory { + const URLTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) => + null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/group/database_group.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/group/database_group.dart new file mode 100644 index 0000000000000..7a888b96ed3ab --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/group/database_group.dart @@ -0,0 +1,222 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/setting/group_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:protobuf/protobuf.dart' hide FieldInfo; + +class DatabaseGroupList extends StatelessWidget { + const DatabaseGroupList({ + super.key, + required this.viewId, + required this.databaseController, + required this.onDismissed, + }); + + final String viewId; + final DatabaseController databaseController; + final VoidCallback onDismissed; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DatabaseGroupBloc( + viewId: viewId, + databaseController: databaseController, + )..add(const DatabaseGroupEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final field = state.fieldInfos.firstWhereOrNull( + (field) => field.fieldType.canBeGroup && field.isGroupField, + ); + final showHideUngroupedToggle = + field?.fieldType != FieldType.Checkbox; + + DateGroupConfigurationPB? config; + if (field != null) { + final gs = state.groupSettings + .firstWhereOrNull((gs) => gs.fieldId == field.id); + config = gs != null + ? DateGroupConfigurationPB.fromBuffer(gs.content) + : null; + } + + final children = [ + if (showHideUngroupedToggle) ...[ + SizedBox( + height: GridSize.popoverItemHeight, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + Expanded( + child: FlowyText( + LocaleKeys.board_showUngrouped.tr(), + ), + ), + Toggle( + value: !state.layoutSettings.hideUngroupedColumn, + onChanged: (value) => + _updateLayoutSettings(state.layoutSettings, !value), + padding: EdgeInsets.zero, + ), + ], + ), + ), + ), + const TypeOptionSeparator(spacing: 0), + ], + SizedBox( + height: GridSize.popoverItemHeight, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: FlowyText( + LocaleKeys.board_groupBy.tr(), + textAlign: TextAlign.left, + color: Theme.of(context).hintColor, + ), + ), + ), + ...state.fieldInfos + .where((fieldInfo) => fieldInfo.fieldType.canBeGroup) + .map( + (fieldInfo) => _GridGroupCell( + fieldInfo: fieldInfo, + name: fieldInfo.name, + checked: fieldInfo.isGroupField, + onSelected: onDismissed, + key: ValueKey(fieldInfo.id), + ), + ), + if (field?.fieldType.groupConditions.isNotEmpty ?? false) ...[ + const TypeOptionSeparator(spacing: 0), + SizedBox( + height: GridSize.popoverItemHeight, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: FlowyText( + LocaleKeys.board_groupCondition.tr(), + textAlign: TextAlign.left, + color: Theme.of(context).hintColor, + ), + ), + ), + ...field!.fieldType.groupConditions.map( + (condition) => _GridGroupCell( + fieldInfo: field, + name: condition.name, + condition: condition.value, + onSelected: onDismissed, + checked: config?.condition == condition, + ), + ), + ], + ]; + + return ListView.separated( + shrinkWrap: true, + itemCount: children.length, + itemBuilder: (BuildContext context, int index) => children[index], + separatorBuilder: (BuildContext context, int index) => + VSpace(GridSize.typeOptionSeparatorHeight), + padding: const EdgeInsets.symmetric(vertical: 6.0), + ); + }, + ), + ); + } + + Future _updateLayoutSettings( + BoardLayoutSettingPB layoutSettings, + bool hideUngrouped, + ) { + layoutSettings.freeze(); + final newLayoutSetting = layoutSettings.rebuild((message) { + message.hideUngroupedColumn = hideUngrouped; + }); + return databaseController.updateLayoutSetting( + boardLayoutSetting: newLayoutSetting, + ); + } +} + +class _GridGroupCell extends StatelessWidget { + const _GridGroupCell({ + super.key, + required this.fieldInfo, + required this.onSelected, + required this.checked, + required this.name, + this.condition = 0, + }); + + final FieldInfo fieldInfo; + final VoidCallback onSelected; + final bool checked; + final int condition; + final String name; + + @override + Widget build(BuildContext context) { + Widget? rightIcon; + if (checked) { + rightIcon = const Padding( + padding: EdgeInsets.all(2.0), + child: FlowySvg(FlowySvgs.check_s), + ); + } + + return SizedBox( + height: GridSize.popoverItemHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: FlowyButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + text: FlowyText( + name, + color: AFThemeExtension.of(context).textColor, + lineHeight: 1.0, + ), + leftIcon: FieldIcon(fieldInfo: fieldInfo), + rightIcon: rightIcon, + onTap: () { + List settingContent = []; + switch (fieldInfo.fieldType) { + case FieldType.DateTime: + final config = DateGroupConfigurationPB() + ..condition = DateConditionPB.values[condition]; + settingContent = config.writeToBuffer(); + break; + default: + } + context.read().add( + DatabaseGroupEvent.setGroupByField( + fieldInfo.id, + fieldInfo.fieldType, + settingContent, + ), + ); + onSelected(); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart new file mode 100644 index 0000000000000..9ac6bec394dd9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; + +extension FileTypeDisplay on MediaFileTypePB { + FlowySvgData get icon => switch (this) { + MediaFileTypePB.Image => FlowySvgs.image_s, + MediaFileTypePB.Link => FlowySvgs.ft_link_s, + MediaFileTypePB.Document => FlowySvgs.icon_document_s, + MediaFileTypePB.Archive => FlowySvgs.ft_archive_s, + MediaFileTypePB.Video => FlowySvgs.ft_video_s, + MediaFileTypePB.Audio => FlowySvgs.ft_audio_s, + MediaFileTypePB.Text => FlowySvgs.ft_text_s, + _ => FlowySvgs.icon_document_s, + }; + + Color get color => switch (this) { + MediaFileTypePB.Image => const Color(0xFF5465A1), + MediaFileTypePB.Link => const Color(0xFFEBE4FF), + MediaFileTypePB.Audio => const Color(0xFFE4FFDE), + MediaFileTypePB.Video => const Color(0xFFE0F8FF), + MediaFileTypePB.Archive => const Color(0xFFFFE7EE), + MediaFileTypePB.Text || + MediaFileTypePB.Document || + MediaFileTypePB.Other => + const Color(0xFFF5FFDC), + _ => const Color(0xFF87B3A8), + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart new file mode 100644 index 0000000000000..437d125f53ec6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:styled_widget/styled_widget.dart'; + +import '../../cell/editable_cell_builder.dart'; + +class GridCellAccessoryBuildContext { + GridCellAccessoryBuildContext({ + required this.anchorContext, + required this.isCellEditing, + }); + + final BuildContext anchorContext; + final bool isCellEditing; +} + +class GridCellAccessoryBuilder> { + GridCellAccessoryBuilder({required Widget Function(Key key) builder}) + : _builder = builder; + + final GlobalKey _key = GlobalKey(); + + final Widget Function(Key key) _builder; + + Widget build() => _builder(_key); + + void onTap() { + (_key.currentState as GridCellAccessoryState).onTap(); + } + + bool enable() { + if (_key.currentState == null) { + return true; + } + return (_key.currentState as GridCellAccessoryState).enable(); + } +} + +abstract mixin class GridCellAccessoryState { + void onTap(); + + // The accessory will be hidden if enable() return false; + bool enable() => true; +} + +class PrimaryCellAccessory extends StatefulWidget { + const PrimaryCellAccessory({ + super.key, + required this.onTap, + required this.isCellEditing, + }); + + final VoidCallback onTap; + final bool isCellEditing; + + @override + State createState() => _PrimaryCellAccessoryState(); +} + +class _PrimaryCellAccessoryState extends State + with GridCellAccessoryState { + @override + Widget build(BuildContext context) { + return FlowyHover( + style: HoverStyle( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + backgroundColor: Theme.of(context).cardColor, + ), + builder: (_, onHover) { + return FlowyTooltip( + message: LocaleKeys.tooltip_openAsPage.tr(), + child: Container( + width: 26, + height: 26, + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).dividerColor), + ), + borderRadius: Corners.s6Border, + ), + child: Center( + child: FlowySvg( + FlowySvgs.full_view_s, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ); + }, + ); + } + + @override + void onTap() => widget.onTap(); + + @override + bool enable() => !widget.isCellEditing; +} + +class AccessoryHover extends StatefulWidget { + const AccessoryHover({ + super.key, + required this.child, + required this.fieldType, + }); + + final CellAccessory child; + final FieldType fieldType; + + @override + State createState() => _AccessoryHoverState(); +} + +class _AccessoryHoverState extends State { + bool _isHover = false; + + @override + Widget build(BuildContext context) { + // Some FieldType has built-in handling for more gestures + // and granular control, so we don't need to show the accessory. + if (!widget.fieldType.showRowDetailAccessory) { + return widget.child; + } + + final List children = [ + DecoratedBox( + decoration: BoxDecoration( + color: _isHover && widget.fieldType != FieldType.Checklist + ? AFThemeExtension.of(context).lightGreyHover + : Colors.transparent, + borderRadius: Corners.s6Border, + ), + child: widget.child, + ), + ]; + + final accessoryBuilder = widget.child.accessoryBuilder; + if (accessoryBuilder != null && _isHover) { + final accessories = accessoryBuilder( + GridCellAccessoryBuildContext( + anchorContext: context, + isCellEditing: false, + ), + ); + children.add( + Padding( + padding: const EdgeInsets.only(right: 6), + child: CellAccessoryContainer(accessories: accessories), + ).positioned(right: 0), + ); + } + + return MouseRegion( + cursor: SystemMouseCursors.click, + opaque: false, + onEnter: (p) => setState(() => _isHover = true), + onExit: (p) => setState(() => _isHover = false), + child: Stack( + alignment: AlignmentDirectional.center, + children: children, + ), + ); + } +} + +class CellAccessoryContainer extends StatelessWidget { + const CellAccessoryContainer({required this.accessories, super.key}); + + final List accessories; + + @override + Widget build(BuildContext context) { + final children = + accessories.where((accessory) => accessory.enable()).map((accessory) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => accessory.onTap(), + child: accessory.build(), + ); + }).toList(); + + return Wrap(spacing: 6, children: children); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_shortcuts.dart new file mode 100644 index 0000000000000..3fc2131f24c4d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_shortcuts.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +typedef CellKeyboardAction = dynamic Function(); + +enum CellKeyboardKey { + onEnter, + onCopy, + onInsert, +} + +abstract class CellShortcuts extends Widget { + const CellShortcuts({super.key}); + + Map get shortcutHandlers; +} + +class GridCellShortcuts extends StatelessWidget { + const GridCellShortcuts({required this.child, super.key}); + + final CellShortcuts child; + + @override + Widget build(BuildContext context) { + return Shortcuts( + shortcuts: shortcuts, + child: Actions( + actions: actions, + child: child, + ), + ); + } + + Map get shortcuts => { + if (shouldAddKeyboardKey(CellKeyboardKey.onEnter)) + LogicalKeySet(LogicalKeyboardKey.enter): const GridCellEnterIdent(), + if (shouldAddKeyboardKey(CellKeyboardKey.onCopy)) + LogicalKeySet( + Platform.isMacOS + ? LogicalKeyboardKey.meta + : LogicalKeyboardKey.control, + LogicalKeyboardKey.keyC, + ): const GridCellCopyIntent(), + }; + + Map> get actions => { + if (shouldAddKeyboardKey(CellKeyboardKey.onEnter)) + GridCellEnterIdent: GridCellEnterAction(child: child), + if (shouldAddKeyboardKey(CellKeyboardKey.onCopy)) + GridCellCopyIntent: GridCellCopyAction(child: child), + }; + + bool shouldAddKeyboardKey(CellKeyboardKey key) => + child.shortcutHandlers.containsKey(key); +} + +class GridCellEnterIdent extends Intent { + const GridCellEnterIdent(); +} + +class GridCellEnterAction extends Action { + GridCellEnterAction({required this.child}); + + final CellShortcuts child; + + @override + void invoke(covariant GridCellEnterIdent intent) { + final callback = child.shortcutHandlers[CellKeyboardKey.onEnter]; + if (callback != null) { + callback(); + } + } +} + +class GridCellCopyIntent extends Intent { + const GridCellCopyIntent(); +} + +class GridCellCopyAction extends Action { + GridCellCopyAction({required this.child}); + + final CellShortcuts child; + + @override + void invoke(covariant GridCellCopyIntent intent) { + final callback = child.shortcutHandlers[CellKeyboardKey.onCopy]; + if (callback == null) { + return; + } + + final s = callback(); + if (s is String) { + Clipboard.setData(ClipboardData(text: s)); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart new file mode 100644 index 0000000000000..2e1260fe3d855 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart @@ -0,0 +1,158 @@ +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; + +import '../../../grid/presentation/layout/sizes.dart'; +import '../../../grid/presentation/widgets/row/row.dart'; +import '../../cell/editable_cell_builder.dart'; +import '../accessory/cell_accessory.dart'; +import '../accessory/cell_shortcuts.dart'; + +class CellContainer extends StatelessWidget { + const CellContainer({ + super.key, + required this.child, + required this.width, + required this.isPrimary, + this.accessoryBuilder, + }); + + final EditableCellWidget child; + final AccessoryBuilder? accessoryBuilder; + final double width; + final bool isPrimary; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: child.cellContainerNotifier, + child: Selector( + selector: (context, notifier) => notifier.isFocus, + builder: (providerContext, isFocus, _) { + Widget container = Center(child: GridCellShortcuts(child: child)); + + if (accessoryBuilder != null) { + final accessories = accessoryBuilder!.call( + GridCellAccessoryBuildContext( + anchorContext: context, + isCellEditing: isFocus, + ), + ); + + if (accessories.isNotEmpty) { + container = _GridCellEnterRegion( + accessories: accessories, + isPrimary: isPrimary, + child: container, + ); + } + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (!isFocus) { + child.requestFocus.notify(); + } + }, + child: Container( + constraints: BoxConstraints(maxWidth: width, minHeight: 36), + decoration: _makeBoxDecoration(context, isFocus), + child: container, + ), + ); + }, + ), + ); + } + + BoxDecoration _makeBoxDecoration(BuildContext context, bool isFocus) { + if (isFocus) { + final borderSide = BorderSide( + color: Theme.of(context).colorScheme.primary, + ); + + return BoxDecoration(border: Border.fromBorderSide(borderSide)); + } + + final borderSide = + BorderSide(color: AFThemeExtension.of(context).borderColor); + return BoxDecoration( + border: Border(right: borderSide, bottom: borderSide), + ); + } +} + +class _GridCellEnterRegion extends StatelessWidget { + const _GridCellEnterRegion({ + required this.child, + required this.accessories, + required this.isPrimary, + }); + + final Widget child; + final List accessories; + final bool isPrimary; + + @override + Widget build(BuildContext context) { + return Selector2( + selector: (context, regionNotifier, cellNotifier) => + !cellNotifier.isFocus && + (cellNotifier.isHover || regionNotifier.onEnter && isPrimary), + builder: (context, showAccessory, _) { + final List children = [child]; + + if (showAccessory) { + children.add( + CellAccessoryContainer(accessories: accessories).positioned( + right: GridSize.cellContentInsets.right, + ), + ); + } + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (p) => + CellContainerNotifier.of(context, listen: false).isHover = true, + onExit: (p) => + CellContainerNotifier.of(context, listen: false).isHover = false, + child: Stack( + alignment: Alignment.center, + fit: StackFit.expand, + children: children, + ), + ); + }, + ); + } +} + +class CellContainerNotifier extends ChangeNotifier { + bool _isFocus = false; + bool _onEnter = false; + + set isFocus(bool value) { + if (_isFocus != value) { + _isFocus = value; + notifyListeners(); + } + } + + set isHover(bool value) { + if (_onEnter != value) { + _onEnter = value; + notifyListeners(); + } + } + + bool get isFocus => _isFocus; + + bool get isHover => _onEnter; + + static CellContainerNotifier of(BuildContext context, {bool listen = true}) { + return Provider.of(context, listen: listen); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/mobile_cell_container.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/mobile_cell_container.dart new file mode 100644 index 0000000000000..8dba996d0520f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/mobile_cell_container.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../cell/editable_cell_builder.dart'; +import 'cell_container.dart'; + +class MobileCellContainer extends StatelessWidget { + const MobileCellContainer({ + super.key, + required this.child, + required this.isPrimary, + this.onPrimaryFieldCellTap, + }); + + final EditableCellWidget child; + final bool isPrimary; + final VoidCallback? onPrimaryFieldCellTap; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: child.cellContainerNotifier, + child: Selector( + selector: (context, notifier) => notifier.isFocus, + builder: (providerContext, isFocus, _) { + Widget container = Center(child: child); + + if (isPrimary) { + container = IgnorePointer(child: container); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (isPrimary) { + onPrimaryFieldCellTap?.call(); + return; + } + if (!isFocus) { + child.requestFocus.notify(); + } + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 200, minHeight: 46), + decoration: _makeBoxDecoration(context, isPrimary, isFocus), + child: container, + ), + ); + }, + ), + ); + } + + BoxDecoration _makeBoxDecoration( + BuildContext context, + bool isPrimary, + bool isFocus, + ) { + if (isFocus) { + return BoxDecoration( + border: Border.fromBorderSide( + BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } + + final borderSide = BorderSide(color: Theme.of(context).dividerColor); + return BoxDecoration( + border: Border( + left: isPrimary ? borderSide : BorderSide.none, + right: borderSide, + bottom: borderSide, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/relation_row_detail.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/relation_row_detail.dart new file mode 100644 index 0000000000000..256de6bc3cb32 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/relation_row_detail.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'row_detail.dart'; + +class RelatedRowDetailPage extends StatelessWidget { + const RelatedRowDetailPage({ + super.key, + required this.databaseId, + required this.rowId, + }); + + final String databaseId; + final String rowId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => RelatedRowDetailPageBloc( + databaseId: databaseId, + initialRowId: rowId, + ), + child: BlocBuilder( + builder: (context, state) { + return state.when( + loading: () => const SizedBox.shrink(), + ready: (databaseController, rowController) { + return RowDetailPage( + databaseController: databaseController, + rowController: rowController, + allowOpenAsFullPage: false, + ); + }, + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_action.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_action.dart new file mode 100644 index 0000000000000..c0cf547a06e28 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_action.dart @@ -0,0 +1,94 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class RowActionList extends StatelessWidget { + const RowActionList({super.key, required this.rowController}); + + final RowController rowController; + + @override + Widget build(BuildContext context) { + return IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + RowDetailPageDuplicateButton( + viewId: rowController.viewId, + rowId: rowController.rowId, + ), + const VSpace(4.0), + RowDetailPageDeleteButton( + viewId: rowController.viewId, + rowId: rowController.rowId, + ), + ], + ), + ); + } +} + +class RowDetailPageDeleteButton extends StatelessWidget { + const RowDetailPageDeleteButton({ + super.key, + required this.viewId, + required this.rowId, + }); + + final String viewId; + final String rowId; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText.regular( + LocaleKeys.grid_row_delete.tr(), + lineHeight: 1.0, + ), + leftIcon: const FlowySvg(FlowySvgs.trash_m), + onTap: () { + RowBackendService.deleteRows(viewId, [rowId]); + FlowyOverlay.pop(context); + }, + ), + ); + } +} + +class RowDetailPageDuplicateButton extends StatelessWidget { + const RowDetailPageDuplicateButton({ + super.key, + required this.viewId, + required this.rowId, + }); + + final String viewId; + final String rowId; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText.regular( + LocaleKeys.grid_row_duplicate.tr(), + lineHeight: 1.0, + ), + leftIcon: const FlowySvg(FlowySvgs.copy_s), + onTap: () { + RowBackendService.duplicateRow(viewId, rowId); + FlowyOverlay.pop(context); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart new file mode 100644 index 0000000000000..243eb5f027797 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart @@ -0,0 +1,683 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_banner_bloc.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_action.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; +import 'package:appflowy/plugins/shared/cover_type_ext.dart'; +import 'package:appflowy/shared/af_image.dart'; +import 'package:appflowy/shared/flowy_gradient_colors.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/rounded_button.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../../../shared/icon_emoji_picker/tab.dart'; +import '../../../document/presentation/editor_plugins/plugins.dart'; + +/// We have the cover height as public as it is used in the row_detail.dart file +/// Used to determine the position of the row actions depending on if there is a cover or not. +/// +const rowCoverHeight = 250.0; + +const _iconHeight = 60.0; +const _toolbarHeight = 40.0; + +class RowBanner extends StatefulWidget { + const RowBanner({ + super.key, + required this.databaseController, + required this.rowController, + required this.cellBuilder, + this.allowOpenAsFullPage = true, + this.userProfile, + }); + + final DatabaseController databaseController; + final RowController rowController; + final EditableCellBuilder cellBuilder; + final bool allowOpenAsFullPage; + final UserProfilePB? userProfile; + + @override + State createState() => _RowBannerState(); +} + +class _RowBannerState extends State { + final _isHovering = ValueNotifier(false); + late final isLocalMode = + (widget.userProfile?.authenticator ?? AuthenticatorPB.Local) == + AuthenticatorPB.Local; + + @override + void dispose() { + _isHovering.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => RowBannerBloc( + viewId: widget.rowController.viewId, + fieldController: widget.databaseController.fieldController, + rowMeta: widget.rowController.rowMeta, + )..add(const RowBannerEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final hasCover = state.rowMeta.cover.data.isNotEmpty; + final hasIcon = state.rowMeta.icon.isNotEmpty; + + return Column( + children: [ + LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: [ + SizedBox( + height: _calculateOverallHeight(hasIcon, hasCover), + width: constraints.maxWidth, + child: RowHeaderToolbar( + offset: GridSize.horizontalHeaderPadding + 20, + hasIcon: hasIcon, + hasCover: hasCover, + onIconChanged: (icon) { + if (icon != null) { + context + .read() + .add(RowBannerEvent.setIcon(icon)); + } + }, + onCoverChanged: (cover) { + if (cover != null) { + context + .read() + .add(RowBannerEvent.setCover(cover)); + } + }, + ), + ), + if (hasCover) + RowCover( + rowId: widget.rowController.rowId, + cover: state.rowMeta.cover, + userProfile: widget.userProfile, + onCoverChanged: (type, details, uploadType) { + if (details != null) { + context.read().add( + RowBannerEvent.setCover( + RowCoverPB( + data: details, + uploadType: uploadType, + coverType: type.into(), + ), + ), + ); + } else { + context + .read() + .add(const RowBannerEvent.removeCover()); + } + }, + isLocalMode: isLocalMode, + ), + if (hasIcon) + Positioned( + left: GridSize.horizontalHeaderPadding + 20, + bottom: hasCover + ? _toolbarHeight - _iconHeight / 2 + : _toolbarHeight, + child: RowIcon( + ///TODO: avoid hardcoding for [FlowyIconType] + icon: EmojiIconData( + FlowyIconType.emoji, + state.rowMeta.icon, + ), + onIconChanged: (icon) { + if (icon == null || icon.isEmpty) { + context + .read() + .add(const RowBannerEvent.setIcon("")); + } else { + context + .read() + .add(RowBannerEvent.setIcon(icon)); + } + }, + ), + ), + ], + ); + }, + ), + const VSpace(8), + _BannerTitle( + cellBuilder: widget.cellBuilder, + rowController: widget.rowController, + ), + ], + ); + }, + ), + ); + } + + double _calculateOverallHeight(bool hasIcon, bool hasCover) { + switch ((hasIcon, hasCover)) { + case (true, true): + return rowCoverHeight + _toolbarHeight; + case (true, false): + return 50 + _iconHeight + _toolbarHeight; + case (false, true): + return rowCoverHeight + _toolbarHeight; + case (false, false): + return _toolbarHeight; + } + } +} + +class RowCover extends StatefulWidget { + const RowCover({ + super.key, + required this.rowId, + required this.cover, + this.userProfile, + required this.onCoverChanged, + this.isLocalMode = true, + }); + + final String rowId; + final RowCoverPB cover; + final UserProfilePB? userProfile; + final void Function( + CoverType type, + String? details, + FileUploadTypePB? uploadType, + ) onCoverChanged; + final bool isLocalMode; + + @override + State createState() => _RowCoverState(); +} + +class _RowCoverState extends State { + final popoverController = PopoverController(); + bool isOverlayButtonsHidden = true; + bool isPopoverOpen = false; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: rowCoverHeight, + child: MouseRegion( + onEnter: (_) => setState(() => isOverlayButtonsHidden = false), + onExit: (_) => setState(() => isOverlayButtonsHidden = true), + child: Stack( + children: [ + SizedBox( + width: double.infinity, + child: DesktopRowCover( + cover: widget.cover, + userProfile: widget.userProfile, + ), + ), + if (!isOverlayButtonsHidden || isPopoverOpen) + _buildCoverOverlayButtons(context), + ], + ), + ), + ); + } + + Widget _buildCoverOverlayButtons(BuildContext context) { + return Positioned( + bottom: 20, + right: 50, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppFlowyPopover( + controller: popoverController, + triggerActions: PopoverTriggerFlags.none, + offset: const Offset(0, 8), + direction: PopoverDirection.bottomWithCenterAligned, + constraints: const BoxConstraints( + maxWidth: 540, + maxHeight: 360, + minHeight: 80, + ), + margin: EdgeInsets.zero, + onClose: () => setState(() => isPopoverOpen = false), + child: IntrinsicWidth( + child: RoundedTextButton( + height: 28.0, + onPressed: () => popoverController.show(), + hoverColor: Theme.of(context).colorScheme.surface, + textColor: Theme.of(context).colorScheme.tertiary, + fillColor: + Theme.of(context).colorScheme.surface.withOpacity(0.5), + title: LocaleKeys.document_plugins_cover_changeCover.tr(), + ), + ), + popupBuilder: (BuildContext popoverContext) { + isPopoverOpen = true; + + return UploadImageMenu( + limitMaximumImageSize: !widget.isLocalMode, + supportTypes: const [ + UploadImageType.color, + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + ], + onSelectedAIImage: (_) => throw UnimplementedError(), + onSelectedLocalImages: (files) { + popoverController.close(); + if (files.isEmpty) { + return; + } + + final item = files.map((file) => file.path).first; + onCoverChanged( + CoverType.file, + item, + widget.isLocalMode + ? FileUploadTypePB.LocalFile + : FileUploadTypePB.CloudFile, + ); + }, + onSelectedNetworkImage: (url) { + popoverController.close(); + onCoverChanged( + CoverType.file, + url, + FileUploadTypePB.NetworkFile, + ); + }, + onSelectedColor: (color) { + popoverController.close(); + onCoverChanged( + CoverType.color, + color, + FileUploadTypePB.LocalFile, + ); + }, + ); + }, + ), + const HSpace(10), + DeleteCoverButton( + onTap: () => widget.onCoverChanged(CoverType.none, null, null), + ), + ], + ), + ); + } + + Future onCoverChanged( + CoverType type, + String? details, + FileUploadTypePB? uploadType, + ) async { + if (type == CoverType.file && details != null && !isURL(details)) { + if (widget.isLocalMode) { + details = await saveImageToLocalStorage(details); + } else { + // else we should save the image to cloud storage + (details, _) = await saveImageToCloudStorage(details, widget.rowId); + } + } + widget.onCoverChanged(type, details, uploadType); + } +} + +class DesktopRowCover extends StatefulWidget { + const DesktopRowCover({super.key, required this.cover, this.userProfile}); + + final RowCoverPB cover; + final UserProfilePB? userProfile; + + @override + State createState() => _DesktopRowCoverState(); +} + +class _DesktopRowCoverState extends State { + RowCoverPB get cover => widget.cover; + + @override + Widget build(BuildContext context) { + if (cover.coverType == CoverTypePB.FileCover) { + return SizedBox( + height: rowCoverHeight, + width: double.infinity, + child: AFImage( + url: cover.data, + uploadType: cover.uploadType, + userProfile: widget.userProfile, + ), + ); + } + + if (cover.coverType == CoverTypePB.AssetCover) { + return SizedBox( + height: rowCoverHeight, + width: double.infinity, + child: Image.asset( + PageStyleCoverImageType.builtInImagePath(cover.data), + fit: BoxFit.cover, + ), + ); + } + + if (cover.coverType == CoverTypePB.ColorCover) { + final color = FlowyTint.fromId(cover.data)?.color(context) ?? + cover.data.tryToColor(); + return Container( + height: rowCoverHeight, + width: double.infinity, + color: color, + ); + } + + if (cover.coverType == CoverTypePB.GradientCover) { + return Container( + height: rowCoverHeight, + width: double.infinity, + decoration: BoxDecoration( + gradient: FlowyGradientColor.fromId(cover.data).linear, + ), + ); + } + + return const SizedBox.shrink(); + } +} + +class RowHeaderToolbar extends StatefulWidget { + const RowHeaderToolbar({ + super.key, + required this.offset, + required this.hasIcon, + required this.hasCover, + required this.onIconChanged, + required this.onCoverChanged, + }); + + final double offset; + final bool hasIcon; + final bool hasCover; + + /// Returns null if the icon is removed. + /// + final void Function(String? icon) onIconChanged; + + /// Returns null if the cover is removed. + /// + final void Function(RowCoverPB? cover) onCoverChanged; + + @override + State createState() => _RowHeaderToolbarState(); +} + +class _RowHeaderToolbarState extends State { + final popoverController = PopoverController(); + final bool isDesktop = UniversalPlatform.isDesktopOrWeb; + + bool isHidden = UniversalPlatform.isDesktopOrWeb; + bool isPopoverOpen = false; + + @override + Widget build(BuildContext context) { + if (!isDesktop) { + return const SizedBox.shrink(); + } + + return MouseRegion( + opaque: false, + onEnter: (_) => setState(() => isHidden = false), + onExit: isPopoverOpen ? null : (_) => setState(() => isHidden = true), + child: Container( + alignment: Alignment.bottomLeft, + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: widget.offset), + child: SizedBox( + height: 28, + child: Visibility( + visible: !isHidden || isPopoverOpen, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!widget.hasCover) + FlowyButton( + resetHoverOnRebuild: false, + useIntrinsicWidth: true, + leftIconSize: const Size.square(18), + leftIcon: const FlowySvg(FlowySvgs.add_cover_s), + text: FlowyText.small( + LocaleKeys.document_plugins_cover_addCover.tr(), + ), + onTap: () => widget.onCoverChanged( + RowCoverPB( + data: isDesktop ? '1' : '0xffe8e0ff', + uploadType: FileUploadTypePB.LocalFile, + coverType: isDesktop + ? CoverTypePB.AssetCover + : CoverTypePB.ColorCover, + ), + ), + ), + if (!widget.hasIcon) + AppFlowyPopover( + controller: popoverController, + onClose: () => setState(() => isPopoverOpen = false), + offset: const Offset(0, 8), + direction: PopoverDirection.bottomWithCenterAligned, + constraints: BoxConstraints.loose(const Size(360, 380)), + margin: EdgeInsets.zero, + triggerActions: PopoverTriggerFlags.none, + popupBuilder: (_) { + isPopoverOpen = true; + return FlowyIconEmojiPicker( + tabs: const [PickerTabType.emoji], + onSelectedEmoji: (result) { + widget.onIconChanged(result.emoji); + popoverController.close(); + }, + ); + }, + child: FlowyButton( + useIntrinsicWidth: true, + leftIconSize: const Size.square(18), + leftIcon: const FlowySvg(FlowySvgs.add_icon_s), + text: FlowyText.small( + widget.hasIcon + ? LocaleKeys.document_plugins_cover_removeIcon.tr() + : LocaleKeys.document_plugins_cover_addIcon.tr(), + ), + onTap: () async { + if (!isDesktop) { + final result = await context.push( + MobileEmojiPickerScreen.routeName, + ); + + if (result != null) { + widget.onIconChanged(result.emoji); + } + } else { + popoverController.show(); + } + }, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class RowIcon extends StatefulWidget { + const RowIcon({ + super.key, + required this.icon, + required this.onIconChanged, + }); + + final EmojiIconData icon; + final void Function(String?) onIconChanged; + + @override + State createState() => _RowIconState(); +} + +class _RowIconState extends State { + final controller = PopoverController(); + + @override + Widget build(BuildContext context) { + if (widget.icon.isEmpty) { + return const SizedBox.shrink(); + } + + return AppFlowyPopover( + controller: controller, + offset: const Offset(0, 8), + direction: PopoverDirection.bottomWithCenterAligned, + constraints: BoxConstraints.loose(const Size(360, 380)), + margin: EdgeInsets.zero, + popupBuilder: (_) => FlowyIconEmojiPicker( + tabs: const [PickerTabType.emoji], + onSelectedEmoji: (result) { + controller.close(); + widget.onIconChanged(result.emoji); + }, + ), + child: EmojiIconWidget(emoji: widget.icon), + ); + } +} + +class _BannerTitle extends StatelessWidget { + const _BannerTitle({ + required this.cellBuilder, + required this.rowController, + }); + + final EditableCellBuilder cellBuilder; + final RowController rowController; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final children = [ + if (state.primaryField != null) + Expanded( + child: cellBuilder.buildCustom( + CellContext( + fieldId: state.primaryField!.id, + rowId: rowController.rowId, + ), + skinMap: EditableCellSkinMap(textSkin: _TitleSkin()), + ), + ), + ]; + + return Padding( + padding: const EdgeInsets.only(left: 60), + child: Row(children: children), + ); + }, + ); + } +} + +class _TitleSkin extends IEditableTextCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TextCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => + focusNode.unfocus(), + const SimpleActivator(LogicalKeyboardKey.enter): () => + focusNode.unfocus(), + }, + child: TextField( + controller: textEditingController, + focusNode: focusNode, + autofocus: true, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 28), + maxLines: null, + decoration: InputDecoration( + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + hintText: LocaleKeys.grid_row_titlePlaceholder.tr(), + isDense: true, + isCollapsed: true, + ), + onEditingComplete: () { + bloc.add(TextCellEvent.updateText(textEditingController.text)); + }, + ), + ); + } +} + +class RowActionButton extends StatelessWidget { + const RowActionButton({super.key, required this.rowController}); + + final RowController rowController; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (context) => RowActionList(rowController: rowController), + child: FlowyTooltip( + message: LocaleKeys.grid_rowPage_moreRowActions.tr(), + child: FlowyIconButton( + width: 20, + height: 20, + icon: const FlowySvg(FlowySvgs.details_horizontal_s), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_detail.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_detail.dart new file mode 100644 index 0000000000000..7b39335ec7236 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_detail.dart @@ -0,0 +1,200 @@ +import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/domain/database_view_service.dart'; +import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_document.dart'; +import 'package:appflowy/plugins/database_document/database_document_plugin.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +import '../cell/editable_cell_builder.dart'; + +import 'row_banner.dart'; +import 'row_property.dart'; + +class RowDetailPage extends StatefulWidget with FlowyOverlayDelegate { + const RowDetailPage({ + super.key, + required this.rowController, + required this.databaseController, + this.allowOpenAsFullPage = true, + this.userProfile, + }); + + final RowController rowController; + final DatabaseController databaseController; + final bool allowOpenAsFullPage; + final UserProfilePB? userProfile; + + @override + State createState() => _RowDetailPageState(); +} + +class _RowDetailPageState extends State { + // To allow blocking drop target in RowDocument from Field dialogs + final dropManagerState = EditorDropManagerState(); + + late final cellBuilder = EditableCellBuilder( + databaseController: widget.databaseController, + ); + late final ScrollController scrollController; + + double scrollOffset = 0; + + @override + void initState() { + super.initState(); + scrollController = + ScrollController(onAttach: (_) => attachScrollListener()); + } + + void attachScrollListener() => scrollController.addListener(onScrollChanged); + + @override + void dispose() { + scrollController.removeListener(onScrollChanged); + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FlowyDialog( + child: ChangeNotifierProvider.value( + value: dropManagerState, + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => RowDetailBloc( + fieldController: widget.databaseController.fieldController, + rowController: widget.rowController, + ), + ), + BlocProvider.value(value: getIt()), + ], + child: BlocBuilder( + builder: (context, state) => Stack( + children: [ + ListView( + controller: scrollController, + physics: const ClampingScrollPhysics(), + children: [ + RowBanner( + databaseController: widget.databaseController, + rowController: widget.rowController, + cellBuilder: cellBuilder, + allowOpenAsFullPage: widget.allowOpenAsFullPage, + userProfile: widget.userProfile, + ), + const VSpace(16), + Padding( + padding: const EdgeInsets.only(left: 40, right: 60), + child: RowPropertyList( + cellBuilder: cellBuilder, + viewId: widget.databaseController.viewId, + fieldController: + widget.databaseController.fieldController, + ), + ), + const VSpace(20), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 60), + child: Divider(height: 1.0), + ), + const VSpace(20), + RowDocument( + viewId: widget.rowController.viewId, + rowId: widget.rowController.rowId, + ), + ], + ), + Positioned( + top: calculateActionsOffset( + state.rowMeta.cover.data.isNotEmpty, + ), + right: 12, + child: Row(children: actions(context)), + ), + ], + ), + ), + ), + ), + ); + } + + void onScrollChanged() { + if (scrollOffset != scrollController.offset) { + setState(() => scrollOffset = scrollController.offset); + } + } + + double calculateActionsOffset(bool hasCover) { + if (!hasCover) { + return 12; + } + + final offsetByScroll = clampDouble( + rowCoverHeight - scrollOffset, + 0, + rowCoverHeight, + ); + return 12 + offsetByScroll; + } + + List actions(BuildContext context) { + return [ + if (widget.allowOpenAsFullPage) ...[ + FlowyTooltip( + message: LocaleKeys.grid_rowPage_openAsFullPage.tr(), + child: FlowyIconButton( + width: 20, + height: 20, + icon: const FlowySvg(FlowySvgs.full_view_s), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + onPressed: () async { + Navigator.of(context).pop(); + final databaseId = await DatabaseViewBackendService( + viewId: widget.databaseController.viewId, + ) + .getDatabaseId() + .then((value) => value.fold((s) => s, (f) => null)); + final documentId = widget.rowController.rowMeta.documentId; + if (databaseId != null) { + getIt().add( + TabsEvent.openPlugin( + plugin: DatabaseDocumentPlugin( + data: DatabaseDocumentContext( + view: widget.databaseController.view, + databaseId: databaseId, + rowId: widget.rowController.rowId, + documentId: documentId, + ), + pluginType: PluginType.databaseDocument, + ), + setLatest: false, + ), + ); + } + }, + ), + ), + const HSpace(4), + ], + RowActionButton(rowController: widget.rowController), + ]; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart new file mode 100644 index 0000000000000..e3d30249ac3ad --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart @@ -0,0 +1,146 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/application/row/row_document_bloc.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; +import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +class RowDocument extends StatelessWidget { + const RowDocument({ + super.key, + required this.viewId, + required this.rowId, + }); + + final String viewId; + final String rowId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => RowDocumentBloc(viewId: viewId, rowId: rowId) + ..add(const RowDocumentEvent.initial()), + child: BlocConsumer( + listener: (_, state) => state.loadingState.maybeWhen( + error: (error) => Log.error('RowDocument error: $error'), + orElse: () => null, + ), + builder: (context, state) { + return state.loadingState.when( + loading: () => const Center( + child: CircularProgressIndicator.adaptive(), + ), + error: (error) => Center( + child: AppFlowyErrorPage( + error: error, + ), + ), + finish: () => _RowEditor( + view: state.viewPB!, + onIsEmptyChanged: (isEmpty) => context + .read() + .add(RowDocumentEvent.updateIsEmpty(isEmpty)), + ), + ); + }, + ), + ); + } +} + +class _RowEditor extends StatelessWidget { + const _RowEditor({ + required this.view, + this.onIsEmptyChanged, + }); + + final ViewPB view; + final void Function(bool)? onIsEmptyChanged; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => + DocumentBloc(documentId: view.id)..add(const DocumentEvent.initial()), + child: BlocConsumer( + listenWhen: (previous, current) => + previous.isDocumentEmpty != current.isDocumentEmpty, + listener: (_, state) { + if (state.isDocumentEmpty != null) { + onIsEmptyChanged?.call(state.isDocumentEmpty!); + } + if (state.error != null) { + Log.error('RowEditor error: ${state.error}'); + } + if (state.editorState == null) { + Log.error('RowEditor unable to get editorState'); + } + }, + builder: (context, state) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + + final editorState = state.editorState; + final error = state.error; + if (error != null || editorState == null) { + return Center( + child: AppFlowyErrorPage(error: error), + ); + } + + return BlocProvider( + create: (context) => ViewInfoBloc(view: view), + child: IntrinsicHeight( + child: Container( + constraints: const BoxConstraints(minHeight: 300), + child: Provider( + create: (_) { + final context = SharedEditorContext(); + context.isInDatabaseRowPage = true; + return context; + }, + dispose: (_, editorContext) => editorContext.dispose(), + child: EditorDropHandler( + viewId: view.id, + editorState: editorState, + isLocalMode: context.read().isLocalMode, + dropManagerState: context.read(), + child: EditorTransactionService( + viewId: view.id, + editorState: editorState, + child: AppFlowyEditorPage( + shrinkWrap: true, + autoFocus: false, + editorState: editorState, + styleCustomizer: EditorStyleCustomizer( + context: context, + padding: const EdgeInsets.only(left: 16, right: 54), + ), + showParagraphPlaceholder: (editorState, _) => + editorState.document.isEmpty, + placeholderText: (_) => + LocaleKeys.cardDetails_notesPlaceholder.tr(), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart new file mode 100644 index 0000000000000..240d33f0f20d4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart @@ -0,0 +1,407 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:appflowy/plugins/database/widgets/field/field_editor.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../cell/editable_cell_builder.dart'; +import 'accessory/cell_accessory.dart'; + +/// Display the row properties in a list. Only used in [RowDetailPage]. +class RowPropertyList extends StatelessWidget { + const RowPropertyList({ + super.key, + required this.viewId, + required this.fieldController, + required this.cellBuilder, + }); + + final String viewId; + final FieldController fieldController; + final EditableCellBuilder cellBuilder; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.showHiddenFields != current.showHiddenFields || + !listEquals(previous.visibleCells, current.visibleCells), + builder: (context, state) { + final children = state.visibleCells + .mapIndexed( + (index, cell) => _PropertyCell( + key: ValueKey('row_detail_${cell.fieldId}'), + cellContext: cell, + cellBuilder: cellBuilder, + fieldController: fieldController, + index: index, + ), + ) + .toList(); + + return ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + onReorder: (from, to) => context + .read() + .add(RowDetailEvent.reorderField(from, to)), + buildDefaultDragHandles: false, + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: Stack( + children: [ + BlocProvider.value( + value: context.read(), + child: child, + ), + MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: const SizedBox( + width: 16, + height: 30, + child: FlowySvg(FlowySvgs.drag_element_s), + ), + ), + ], + ), + ), + footer: Padding( + padding: const EdgeInsets.only(left: 20), + child: Column( + children: [ + if (context.watch().state.numHiddenFields != 0) + const Padding( + padding: EdgeInsets.only(bottom: 4.0), + child: ToggleHiddenFieldsVisibilityButton(), + ), + CreateRowFieldButton( + viewId: viewId, + fieldController: fieldController, + ), + ], + ), + ), + children: children, + ); + }, + ); + } +} + +class _PropertyCell extends StatefulWidget { + const _PropertyCell({ + super.key, + required this.cellContext, + required this.cellBuilder, + required this.fieldController, + required this.index, + }); + + final CellContext cellContext; + final EditableCellBuilder cellBuilder; + final FieldController fieldController; + final int index; + + @override + State createState() => _PropertyCellState(); +} + +class _PropertyCellState extends State<_PropertyCell> { + final PopoverController _popoverController = PopoverController(); + + final ValueNotifier _isFieldHover = ValueNotifier(false); + + @override + Widget build(BuildContext context) { + final cell = widget.cellBuilder.buildStyled( + widget.cellContext, + EditableCellStyle.desktopRowDetail, + ); + final gesture = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => cell.requestFocus.notify(), + child: AccessoryHover( + fieldType: widget.fieldController + .getField(widget.cellContext.fieldId)! + .fieldType, + child: cell, + ), + ); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + constraints: const BoxConstraints(minHeight: 30), + child: MouseRegion( + onEnter: (event) { + _isFieldHover.value = true; + cell.cellContainerNotifier.isHover = true; + }, + onExit: (event) { + _isFieldHover.value = false; + cell.cellContainerNotifier.isHover = false; + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ValueListenableBuilder( + valueListenable: _isFieldHover, + builder: (context, value, _) { + return ReorderableDragStartListener( + index: widget.index, + enabled: value, + child: _buildDragHandle(context), + ); + }, + ), + const HSpace(4), + _buildFieldButton(context), + const HSpace(8), + Expanded(child: gesture), + ], + ), + ), + ); + } + + Widget _buildDragHandle(BuildContext context) { + return MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grab, + child: SizedBox( + width: 16, + height: 30, + child: BlocListener( + listenWhen: (previous, current) => + previous.editingFieldId != current.editingFieldId, + listener: (context, state) { + if (state.editingFieldId == widget.cellContext.fieldId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _popoverController.show(); + }); + } + }, + child: ValueListenableBuilder( + valueListenable: _isFieldHover, + builder: (_, isHovering, child) => + isHovering ? child! : const SizedBox.shrink(), + child: BlockActionButton( + onTap: () => context.read().add( + RowDetailEvent.startEditingField( + widget.cellContext.fieldId, + ), + ), + svg: FlowySvgs.drag_element_s, + richMessage: TextSpan( + text: LocaleKeys.grid_rowPage_fieldDragElementTooltip.tr(), + style: context.tooltipTextStyle(), + ), + ), + ), + ), + ), + ); + } + + Widget _buildFieldButton(BuildContext context) { + return BlocSelector( + selector: (state) => state.fields.firstWhereOrNull( + (fieldInfo) => fieldInfo.field.id == widget.cellContext.fieldId, + ), + builder: (context, fieldInfo) { + if (fieldInfo == null) { + return const SizedBox.shrink(); + } + return AppFlowyPopover( + controller: _popoverController, + constraints: BoxConstraints.loose(const Size(240, 600)), + margin: EdgeInsets.zero, + triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.bottomWithLeftAligned, + onClose: () => context + .read() + .add(const RowDetailEvent.endEditingField()), + popupBuilder: (popoverContext) => FieldEditor( + viewId: widget.fieldController.viewId, + fieldInfo: fieldInfo, + fieldController: widget.fieldController, + isNewField: context.watch().state.newFieldId == + widget.cellContext.fieldId, + ), + child: SizedBox( + width: 160, + height: 30, + child: Tooltip( + waitDuration: const Duration(seconds: 1), + preferBelow: false, + verticalOffset: 15, + message: fieldInfo.name, + child: FieldCellButton( + field: fieldInfo.field, + onTap: () => context.read().add( + RowDetailEvent.startEditingField( + widget.cellContext.fieldId, + ), + ), + radius: BorderRadius.circular(6), + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + ), + ), + ), + ); + }, + ); + } +} + +class ToggleHiddenFieldsVisibilityButton extends StatelessWidget { + const ToggleHiddenFieldsVisibilityButton({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.showHiddenFields != current.showHiddenFields || + previous.numHiddenFields != current.numHiddenFields, + builder: (context, state) { + final text = state.showHiddenFields + ? LocaleKeys.grid_rowPage_hideHiddenFields.plural( + state.numHiddenFields, + namedArgs: {'count': '${state.numHiddenFields}'}, + ) + : LocaleKeys.grid_rowPage_showHiddenFields.plural( + state.numHiddenFields, + namedArgs: {'count': '${state.numHiddenFields}'}, + ); + final quarterTurns = state.showHiddenFields ? 1 : 3; + return UniversalPlatform.isDesktopOrWeb + ? _desktop(context, text, quarterTurns) + : _mobile(context, text, quarterTurns); + }, + ); + } + + Widget _desktop(BuildContext context, String text, int quarterTurns) { + return SizedBox( + height: 30, + child: FlowyButton( + text: FlowyText( + text, + lineHeight: 1.0, + color: Theme.of(context).hintColor, + ), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + leftIcon: RotatedBox( + quarterTurns: quarterTurns, + child: FlowySvg( + FlowySvgs.arrow_left_s, + color: Theme.of(context).hintColor, + ), + ), + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + onTap: () => context.read().add( + const RowDetailEvent.toggleHiddenFieldVisibility(), + ), + ), + ); + } + + Widget _mobile(BuildContext context, String text, int quarterTurns) { + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: double.infinity), + child: TextButton.icon( + style: Theme.of(context).textButtonTheme.style?.copyWith( + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + ), + overlayColor: WidgetStateProperty.all( + Theme.of(context).hoverColor, + ), + alignment: AlignmentDirectional.centerStart, + splashFactory: NoSplash.splashFactory, + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(vertical: 14, horizontal: 6), + ), + ), + label: FlowyText( + text, + fontSize: 15, + color: Theme.of(context).hintColor, + ), + onPressed: () => context + .read() + .add(const RowDetailEvent.toggleHiddenFieldVisibility()), + icon: RotatedBox( + quarterTurns: quarterTurns, + child: FlowySvg( + FlowySvgs.arrow_left_s, + color: Theme.of(context).hintColor, + ), + ), + ), + ); + } +} + +class CreateRowFieldButton extends StatelessWidget { + const CreateRowFieldButton({ + super.key, + required this.viewId, + required this.fieldController, + }); + + final String viewId; + final FieldController fieldController; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 30, + child: FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + text: FlowyText( + lineHeight: 1.0, + LocaleKeys.grid_field_newProperty.tr(), + color: Theme.of(context).hintColor, + ), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onTap: () async { + final result = await FieldBackendService.createField( + viewId: viewId, + ); + await Future.delayed(const Duration(milliseconds: 50)); + result.fold( + (field) => context + .read() + .add(RowDetailEvent.startEditingNewField(field.id)), + (err) => Log.error("Failed to create field type option: $err"), + ); + }, + leftIcon: FlowySvg( + FlowySvgs.add_m, + color: Theme.of(context).hintColor, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_layout_selector.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_layout_selector.dart new file mode 100644 index 0000000000000..c8c23365de92f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_layout_selector.dart @@ -0,0 +1,97 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/application/layout/layout_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pb.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../grid/presentation/layout/sizes.dart'; + +class DatabaseLayoutSelector extends StatefulWidget { + const DatabaseLayoutSelector({ + super.key, + required this.viewId, + required this.currentLayout, + }); + + final String viewId; + final DatabaseLayoutPB currentLayout; + + @override + State createState() => _DatabaseLayoutSelectorState(); +} + +class _DatabaseLayoutSelectorState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DatabaseLayoutBloc( + viewId: widget.viewId, + databaseLayout: widget.currentLayout, + )..add(const DatabaseLayoutEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final cells = DatabaseLayoutPB.values + .map( + (layout) => DatabaseViewLayoutCell( + databaseLayout: layout, + isSelected: state.databaseLayout == layout, + onTap: (selectedLayout) => context + .read() + .add(DatabaseLayoutEvent.updateLayout(selectedLayout)), + ), + ) + .toList(); + + return ListView.separated( + shrinkWrap: true, + itemCount: cells.length, + padding: const EdgeInsets.symmetric(vertical: 6.0), + itemBuilder: (_, int index) => cells[index], + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), + ); + }, + ), + ); + } +} + +class DatabaseViewLayoutCell extends StatelessWidget { + const DatabaseViewLayoutCell({ + super.key, + required this.isSelected, + required this.databaseLayout, + required this.onTap, + }); + + final bool isSelected; + final DatabaseLayoutPB databaseLayout; + final void Function(DatabaseLayoutPB) onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + text: FlowyText( + lineHeight: 1.0, + databaseLayout.layoutName, + color: AFThemeExtension.of(context).textColor, + ), + leftIcon: FlowySvg( + databaseLayout.icon, + color: Theme.of(context).iconTheme.color, + ), + rightIcon: isSelected ? const FlowySvg(FlowySvgs.check_s) : null, + onTap: () => onTap(databaseLayout), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_setting_action.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_setting_action.dart new file mode 100644 index 0000000000000..409454848a9dd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_setting_action.dart @@ -0,0 +1,96 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/calendar/presentation/toolbar/calendar_layout_setting.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/setting/database_layout_selector.dart'; +import 'package:appflowy/plugins/database/widgets/group/database_group.dart'; +import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +enum DatabaseSettingAction { + showProperties, + showLayout, + showGroup, + showCalendarLayout, +} + +extension DatabaseSettingActionExtension on DatabaseSettingAction { + FlowySvgData iconData() { + switch (this) { + case DatabaseSettingAction.showProperties: + return FlowySvgs.multiselect_s; + case DatabaseSettingAction.showLayout: + return FlowySvgs.database_layout_m; + case DatabaseSettingAction.showGroup: + return FlowySvgs.group_s; + case DatabaseSettingAction.showCalendarLayout: + return FlowySvgs.calendar_layout_m; + } + } + + String title() { + switch (this) { + case DatabaseSettingAction.showProperties: + return LocaleKeys.grid_settings_properties.tr(); + case DatabaseSettingAction.showLayout: + return LocaleKeys.grid_settings_databaseLayout.tr(); + case DatabaseSettingAction.showGroup: + return LocaleKeys.grid_settings_group.tr(); + case DatabaseSettingAction.showCalendarLayout: + return LocaleKeys.calendar_settings_name.tr(); + } + } + + Widget build( + BuildContext context, + DatabaseController databaseController, + PopoverMutex popoverMutex, + ) { + final popover = switch (this) { + DatabaseSettingAction.showLayout => DatabaseLayoutSelector( + viewId: databaseController.viewId, + currentLayout: databaseController.databaseLayout, + ), + DatabaseSettingAction.showGroup => DatabaseGroupList( + viewId: databaseController.viewId, + databaseController: databaseController, + onDismissed: () {}, + ), + DatabaseSettingAction.showProperties => DatabasePropertyList( + viewId: databaseController.viewId, + fieldController: databaseController.fieldController, + ), + DatabaseSettingAction.showCalendarLayout => CalendarLayoutSetting( + databaseController: databaseController, + ), + }; + + return AppFlowyPopover( + triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + direction: PopoverDirection.leftWithTopAligned, + mutex: popoverMutex, + margin: EdgeInsets.zero, + offset: const Offset(-14, 0), + child: SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + text: FlowyText( + title(), + lineHeight: 1.0, + color: AFThemeExtension.of(context).textColor, + ), + leftIcon: FlowySvg( + iconData(), + color: Theme.of(context).iconTheme.color, + ), + ), + ), + popupBuilder: (context) => popover, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_settings_list.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_settings_list.dart new file mode 100644 index 0000000000000..79d5e2410e7da --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/database_settings_list.dart @@ -0,0 +1,80 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/setting/database_setting_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/widgets.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class DatabaseSettingsList extends StatefulWidget { + const DatabaseSettingsList({ + super.key, + required this.databaseController, + }); + + final DatabaseController databaseController; + + @override + State createState() => _DatabaseSettingsListState(); +} + +class _DatabaseSettingsListState extends State { + final PopoverMutex popoverMutex = PopoverMutex(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cells = + actionsForDatabaseLayout(widget.databaseController.databaseLayout) + .map( + (action) => action.build( + context, + widget.databaseController, + popoverMutex, + ), + ) + .toList(); + + return ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: cells.length, + separatorBuilder: (context, index) => + VSpace(GridSize.typeOptionSeparatorHeight), + physics: StyledScrollPhysics(), + itemBuilder: (BuildContext context, int index) => cells[index], + ); + } +} + +/// Returns the list of actions that should be shown for the given database layout. +List actionsForDatabaseLayout(DatabaseLayoutPB? layout) { + switch (layout) { + case DatabaseLayoutPB.Board: + return [ + DatabaseSettingAction.showProperties, + DatabaseSettingAction.showLayout, + if (!UniversalPlatform.isMobile) DatabaseSettingAction.showGroup, + ]; + case DatabaseLayoutPB.Calendar: + return [ + DatabaseSettingAction.showProperties, + DatabaseSettingAction.showLayout, + DatabaseSettingAction.showCalendarLayout, + ]; + case DatabaseLayoutPB.Grid: + return [ + DatabaseSettingAction.showProperties, + DatabaseSettingAction.showLayout, + ]; + default: + return []; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/field_visibility_extension.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/field_visibility_extension.dart new file mode 100644 index 0000000000000..9fe90612054a2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/field_visibility_extension.dart @@ -0,0 +1,16 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart'; + +extension ToggleVisibility on FieldVisibility { + FieldVisibility toggle() => switch (this) { + FieldVisibility.AlwaysShown => FieldVisibility.AlwaysHidden, + FieldVisibility.AlwaysHidden => FieldVisibility.AlwaysShown, + _ => FieldVisibility.AlwaysHidden, + }; + + bool isVisibleState() => switch (this) { + FieldVisibility.AlwaysShown => true, + FieldVisibility.HideWhenEmpty => true, + FieldVisibility.AlwaysHidden => false, + _ => false, + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/mobile_database_controls.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/mobile_database_controls.dart new file mode 100644 index 0000000000000..6394a2ac1a0b9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/mobile_database_controls.dart @@ -0,0 +1,188 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/database/view/database_field_list.dart'; +import 'package:appflowy/mobile/presentation/database/view/database_filter_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/database/view/database_sort_bottom_sheet.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +enum MobileDatabaseControlFeatures { sort, filter } + +class MobileDatabaseControls extends StatelessWidget { + const MobileDatabaseControls({ + super.key, + required this.controller, + required this.features, + }); + + final DatabaseController controller; + final List features; + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => FilterEditorBloc( + viewId: controller.viewId, + fieldController: controller.fieldController, + ), + ), + BlocProvider( + create: (context) => SortEditorBloc( + viewId: controller.viewId, + fieldController: controller.fieldController, + ), + ), + ], + child: ValueListenableBuilder( + valueListenable: controller.isLoading, + builder: (context, isLoading, child) { + if (isLoading) { + return const SizedBox.shrink(); + } + + return SeparatedRow( + separatorBuilder: () => const HSpace(8.0), + children: [ + if (features.contains(MobileDatabaseControlFeatures.sort)) + _DatabaseControlButton( + icon: FlowySvgs.sort_ascending_s, + count: context.watch().state.sorts.length, + onTap: () => _showEditSortPanelFromToolbar( + context, + controller, + ), + ), + if (features.contains(MobileDatabaseControlFeatures.filter)) + _DatabaseControlButton( + icon: FlowySvgs.filter_s, + count: context.watch().state.filters.length, + onTap: () => _showEditFilterPanelFromToolbar( + context, + controller, + ), + ), + _DatabaseControlButton( + icon: FlowySvgs.m_field_hide_s, + onTap: () => _showDatabaseFieldListFromToolbar( + context, + controller, + ), + ), + ], + ); + }, + ), + ); + } +} + +class _DatabaseControlButton extends StatelessWidget { + const _DatabaseControlButton({ + required this.onTap, + required this.icon, + this.count = 0, + }); + + final VoidCallback onTap; + final FlowySvgData icon; + final int count; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.all(5.0), + child: count == 0 + ? FlowySvg( + icon, + size: const Size.square(20), + ) + : Row( + children: [ + FlowySvg( + icon, + size: const Size.square(20), + color: Theme.of(context).colorScheme.primary, + ), + const HSpace(2.0), + FlowyText.medium( + count.toString(), + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ); + } +} + +void _showDatabaseFieldListFromToolbar( + BuildContext context, + DatabaseController databaseController, +) { + showTransitionMobileBottomSheet( + context, + showHeader: true, + showBackButton: true, + title: LocaleKeys.grid_settings_properties.tr(), + builder: (_) { + return BlocProvider.value( + value: context.read(), + child: MobileDatabaseFieldList( + databaseController: databaseController, + canCreate: false, + ), + ); + }, + ); +} + +void _showEditSortPanelFromToolbar( + BuildContext context, + DatabaseController databaseController, +) { + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + useSafeArea: false, + backgroundColor: AFThemeExtension.of(context).background, + builder: (_) { + return BlocProvider.value( + value: context.read(), + child: const MobileSortEditor(), + ); + }, + ); +} + +void _showEditFilterPanelFromToolbar( + BuildContext context, + DatabaseController databaseController, +) { + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + useSafeArea: false, + backgroundColor: AFThemeExtension.of(context).background, + builder: (_) { + return BlocProvider.value( + value: context.read(), + child: const MobileFilterEditor(), + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_button.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_button.dart new file mode 100644 index 0000000000000..4d31e5a79dc5e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_button.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/widgets/setting/database_settings_list.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class SettingButton extends StatefulWidget { + const SettingButton({super.key, required this.databaseController}); + + final DatabaseController databaseController; + + @override + State createState() => _SettingButtonState(); +} + +class _SettingButtonState extends State { + final PopoverController _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: _popoverController, + constraints: BoxConstraints.loose(const Size(200, 400)), + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 8), + triggerActions: PopoverTriggerFlags.none, + child: FlowyTextButton( + LocaleKeys.settings_title.tr(), + fontColor: Theme.of(context).hintColor, + fontSize: FontSizes.s12, + fillColor: Colors.transparent, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + padding: GridSize.toolbarSettingButtonInsets, + radius: Corners.s4Border, + onPressed: _popoverController.show, + ), + popupBuilder: (_) => + DatabaseSettingsList(databaseController: widget.databaseController), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_property_list.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_property_list.dart new file mode 100644 index 0000000000000..8c7d35b2e4d52 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/setting/setting_property_list.dart @@ -0,0 +1,210 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/setting/property_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart'; +import 'package:appflowy/plugins/database/widgets/field/field_editor.dart'; +import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DatabasePropertyList extends StatefulWidget { + const DatabasePropertyList({ + super.key, + required this.viewId, + required this.fieldController, + }); + + final String viewId; + final FieldController fieldController; + + @override + State createState() => _DatabasePropertyListState(); +} + +class _DatabasePropertyListState extends State { + final PopoverMutex _popoverMutex = PopoverMutex(); + late final DatabasePropertyBloc _bloc; + + @override + void initState() { + super.initState(); + _bloc = DatabasePropertyBloc( + viewId: widget.viewId, + fieldController: widget.fieldController, + )..add(const DatabasePropertyEvent.initial()); + } + + @override + void dispose() { + _popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _bloc, + child: BlocBuilder( + builder: (context, state) { + final cells = state.fieldContexts + .mapIndexed( + (index, field) => DatabasePropertyCell( + key: ValueKey(field.id), + viewId: widget.viewId, + fieldController: widget.fieldController, + fieldInfo: field, + popoverMutex: _popoverMutex, + index: index, + ), + ) + .toList(); + + return ReorderableListView( + proxyDecorator: (child, index, _) => Material( + color: Colors.transparent, + child: Stack( + children: [ + child, + MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grabbing, + child: const SizedBox.expand(), + ), + ], + ), + ), + buildDefaultDragHandles: false, + shrinkWrap: true, + onReorder: (from, to) { + context + .read() + .add(DatabasePropertyEvent.moveField(from, to)); + }, + onReorderStart: (_) => _popoverMutex.close(), + padding: const EdgeInsets.symmetric(vertical: 4.0), + children: cells, + ); + }, + ), + ); + } +} + +@visibleForTesting +class DatabasePropertyCell extends StatefulWidget { + const DatabasePropertyCell({ + super.key, + required this.fieldInfo, + required this.viewId, + required this.popoverMutex, + required this.index, + required this.fieldController, + }); + + final FieldInfo fieldInfo; + final String viewId; + final PopoverMutex popoverMutex; + final int index; + final FieldController fieldController; + + @override + State createState() => _DatabasePropertyCellState(); +} + +class _DatabasePropertyCellState extends State { + final PopoverController _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + final visiblity = widget.fieldInfo.visibility; + final visibleIcon = FlowySvg( + visiblity != null && visiblity != FieldVisibility.AlwaysHidden + ? FlowySvgs.show_m + : FlowySvgs.hide_m, + size: const Size.square(16), + color: Theme.of(context).iconTheme.color, + ); + + return AppFlowyPopover( + mutex: widget.popoverMutex, + controller: _popoverController, + offset: const Offset(-8, 0), + direction: PopoverDirection.leftWithTopAligned, + constraints: BoxConstraints.loose(const Size(240, 400)), + triggerActions: PopoverTriggerFlags.none, + margin: EdgeInsets.zero, + child: Container( + height: GridSize.popoverItemHeight, + margin: const EdgeInsets.symmetric(vertical: 2, horizontal: 6), + child: FlowyButton( + hoverColor: AFThemeExtension.of(context).lightGreyHover, + text: FlowyText( + lineHeight: 1.0, + widget.fieldInfo.name, + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(36, 18), + leftIcon: Row( + children: [ + ReorderableDragStartListener( + index: widget.index, + child: MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grab, + child: SizedBox( + width: 14, + height: 14, + child: FlowySvg( + FlowySvgs.drag_element_s, + color: Theme.of(context).iconTheme.color, + ), + ), + ), + ), + const HSpace(6.0), + FieldIcon( + fieldInfo: widget.fieldInfo, + ), + ], + ), + rightIcon: FlowyIconButton( + hoverColor: Colors.transparent, + onPressed: () { + if (widget.fieldInfo.fieldSettings == null) { + return; + } + + final newVisiblity = widget.fieldInfo.visibility!.toggle(); + context.read().add( + DatabasePropertyEvent.setFieldVisibility( + widget.fieldInfo.id, + newVisiblity, + ), + ); + }, + icon: visibleIcon, + ), + onTap: () => _popoverController.show(), + ), + ), + popupBuilder: (BuildContext context) { + return FieldEditor( + viewId: widget.viewId, + fieldInfo: widget.fieldInfo, + fieldController: widget.fieldController, + isNewField: false, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/share_button.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/share_button.dart new file mode 100644 index 0000000000000..7ede902085e73 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/share_button.dart @@ -0,0 +1,155 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/share_bloc.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra_ui/widget/rounded_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DatabaseShareButton extends StatelessWidget { + const DatabaseShareButton({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DatabaseShareBloc(view: view), + child: BlocListener( + listener: (context, state) { + state.mapOrNull( + finish: (state) { + state.successOrFail.fold( + (data) => _handleExportData(context), + _handleExportError, + ); + }, + ); + }, + child: BlocBuilder( + builder: (context, state) => IntrinsicWidth( + child: DatabaseShareActionList(view: view), + ), + ), + ), + ); + } + + void _handleExportData(BuildContext context) { + showSnackBarMessage( + context, + LocaleKeys.settings_files_exportFileSuccess.tr(), + ); + } + + void _handleExportError(FlowyError error) { + showMessageToast(error.msg); + } +} + +class DatabaseShareActionList extends StatefulWidget { + const DatabaseShareActionList({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + State createState() => + DatabaseShareActionListState(); +} + +@visibleForTesting +class DatabaseShareActionListState extends State { + late String name; + late final ViewListener viewListener = ViewListener(viewId: widget.view.id); + + @override + void initState() { + super.initState(); + listenOnViewUpdated(); + } + + @override + void dispose() { + viewListener.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final databaseShareBloc = context.read(); + return PopoverActionList( + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 8), + actions: ShareAction.values + .map((action) => ShareActionWrapper(action)) + .toList(), + buildChild: (controller) => Listener( + onPointerDown: (_) => controller.show(), + child: RoundedTextButton( + title: LocaleKeys.shareAction_buttonText.tr(), + padding: const EdgeInsets.symmetric(horizontal: 12.0), + fontSize: 14.0, + textColor: Theme.of(context).colorScheme.onPrimary, + onPressed: () {}, + ), + ), + onSelected: (action, controller) async { + switch (action.inner) { + case ShareAction.csv: + final exportPath = await getIt().saveFile( + dialogTitle: '', + fileName: '${name.toFileName()}.csv', + ); + if (exportPath != null) { + databaseShareBloc.add(DatabaseShareEvent.shareCSV(exportPath)); + } + break; + } + controller.close(); + }, + ); + } + + void listenOnViewUpdated() { + name = widget.view.name; + viewListener.start( + onViewUpdated: (view) { + name = view.name; + }, + ); + } +} + +enum ShareAction { + csv, +} + +class ShareActionWrapper extends ActionCell { + ShareActionWrapper(this.inner); + + final ShareAction inner; + + Widget? icon(Color iconColor) => null; + + @override + String get name { + switch (inner) { + case ShareAction.csv: + return LocaleKeys.shareAction_csv.tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart new file mode 100644 index 0000000000000..eaa82b22e9ee4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart @@ -0,0 +1,234 @@ +import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart'; +import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_banner.dart'; +import 'package:appflowy/plugins/database/widgets/row/row_property.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/banner.dart'; +import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +// This widget is largely copied from `plugins/document/document_page.dart` intentionally instead of opting for an abstraction. We can make an abstraction after the view refactor is done and there's more clarity in that department. + +class DatabaseDocumentPage extends StatefulWidget { + const DatabaseDocumentPage({ + super.key, + required this.view, + required this.databaseId, + required this.rowId, + required this.documentId, + this.initialSelection, + }); + + final ViewPB view; + final String databaseId; + final String rowId; + final String documentId; + final Selection? initialSelection; + + @override + State createState() => _DatabaseDocumentPageState(); +} + +class _DatabaseDocumentPageState extends State { + EditorState? editorState; + + @override + void initState() { + super.initState(); + EditorNotification.addListener(_onEditorNotification); + } + + @override + void dispose() { + EditorNotification.removeListener(_onEditorNotification); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider.value( + value: getIt(), + ), + BlocProvider( + create: (_) => DocumentBloc( + databaseViewId: widget.databaseId, + rowId: widget.rowId, + documentId: widget.documentId, + )..add(const DocumentEvent.initial()), + ), + ], + child: BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + + final editorState = state.editorState; + this.editorState = editorState; + final error = state.error; + if (error != null || editorState == null) { + Log.error(error); + return Center( + child: AppFlowyErrorPage( + error: error, + ), + ); + } + + if (state.forceClose) { + return const SizedBox.shrink(); + } + + return BlocListener( + listener: _onNotificationAction, + listenWhen: (_, curr) => curr.action != null, + child: _buildEditorPage(context, state), + ); + }, + ), + ); + } + + Widget _buildEditorPage(BuildContext context, DocumentState state) { + final appflowyEditorPage = EditorDropHandler( + viewId: widget.view.id, + editorState: state.editorState!, + isLocalMode: context.read().isLocalMode, + child: AppFlowyEditorPage( + editorState: state.editorState!, + styleCustomizer: EditorStyleCustomizer( + context: context, + padding: EditorStyleCustomizer.documentPadding, + ), + header: _buildDatabaseDataContent(context, state.editorState!), + initialSelection: widget.initialSelection, + useViewInfoBloc: false, + ), + ); + + return EditorTransactionService( + viewId: widget.view.id, + editorState: state.editorState!, + child: Column( + children: [ + if (state.isDeleted) _buildBanner(context), + Expanded(child: appflowyEditorPage), + ], + ), + ); + } + + Widget _buildDatabaseDataContent( + BuildContext context, + EditorState editorState, + ) { + return BlocProvider( + create: (context) => RelatedRowDetailPageBloc( + databaseId: widget.databaseId, + initialRowId: widget.rowId, + ), + child: BlocBuilder( + builder: (context, state) { + return state.when( + loading: () => const SizedBox.shrink(), + ready: (databaseController, rowController) { + final padding = EditorStyleCustomizer.documentPadding; + return BlocProvider( + create: (context) => RowDetailBloc( + fieldController: databaseController.fieldController, + rowController: rowController, + ), + child: Column( + children: [ + RowBanner( + databaseController: databaseController, + rowController: rowController, + cellBuilder: EditableCellBuilder( + databaseController: databaseController, + ), + userProfile: + context.read().userProfile, + ), + Padding( + padding: EdgeInsets.only( + top: 24, + left: padding.left, + right: padding.right, + ), + child: RowPropertyList( + viewId: databaseController.viewId, + fieldController: databaseController.fieldController, + cellBuilder: EditableCellBuilder( + databaseController: databaseController, + ), + ), + ), + const TypeOptionSeparator(spacing: 24.0), + ], + ), + ); + }, + ); + }, + ), + ); + } + + Widget _buildBanner(BuildContext context) { + return DocumentBanner( + viewName: widget.view.name, + onRestore: () => context.read().add( + const DocumentEvent.restorePage(), + ), + onDelete: () => context.read().add( + const DocumentEvent.deletePermanently(), + ), + ); + } + + void _onEditorNotification(EditorNotificationType type) { + final editorState = this.editorState; + if (editorState == null) { + return; + } + if (type == EditorNotificationType.undo) { + undoCommand.execute(editorState); + } else if (type == EditorNotificationType.redo) { + redoCommand.execute(editorState); + } else if (type == EditorNotificationType.exitEditing) { + editorState.selection = null; + } + } + + void _onNotificationAction( + BuildContext context, + ActionNavigationState state, + ) { + if (state.action != null && state.action!.type == ActionType.jumpToBlock) { + final path = state.action?.arguments?[ActionArgumentKeys.nodePath]; + + final editorState = context.read().state.editorState; + if (editorState != null && widget.documentId == state.action?.objectId) { + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [path])), + ); + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart new file mode 100644 index 0000000000000..07c2a4b5dc1ec --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_plugin.dart @@ -0,0 +1,143 @@ +library document_plugin; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'database_document_page.dart'; +import 'presentation/database_document_title.dart'; + +// This widget is largely copied from `plugins/document/document_plugin.dart` intentionally instead of opting for an abstraction. We can make an abstraction after the view refactor is done and there's more clarity in that department. + +class DatabaseDocumentContext { + DatabaseDocumentContext({ + required this.view, + required this.databaseId, + required this.rowId, + required this.documentId, + }); + + final ViewPB view; + final String databaseId; + final String rowId; + final String documentId; +} + +class DatabaseDocumentPluginBuilder extends PluginBuilder { + @override + Plugin build(dynamic data) { + if (data is DatabaseDocumentContext) { + return DatabaseDocumentPlugin(pluginType: pluginType, data: data); + } + + throw FlowyPluginException.invalidData; + } + + @override + String get menuName => LocaleKeys.document_menuName.tr(); + + @override + FlowySvgData get icon => FlowySvgs.icon_document_s; + + @override + PluginType get pluginType => PluginType.databaseDocument; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Document; +} + +class DatabaseDocumentPlugin extends Plugin { + DatabaseDocumentPlugin({ + required this.data, + required PluginType pluginType, + this.initialSelection, + }) : _pluginType = pluginType; + + final DatabaseDocumentContext data; + final PluginType _pluginType; + + final Selection? initialSelection; + + @override + PluginWidgetBuilder get widgetBuilder => DatabaseDocumentPluginWidgetBuilder( + view: data.view, + databaseId: data.databaseId, + rowId: data.rowId, + documentId: data.documentId, + initialSelection: initialSelection, + ); + + @override + PluginType get pluginType => _pluginType; + + @override + PluginId get id => data.rowId; +} + +class DatabaseDocumentPluginWidgetBuilder extends PluginWidgetBuilder + with NavigationItem { + DatabaseDocumentPluginWidgetBuilder({ + required this.view, + required this.databaseId, + required this.rowId, + required this.documentId, + this.initialSelection, + }); + + final ViewPB view; + final String databaseId; + final String rowId; + final String documentId; + final Selection? initialSelection; + + @override + String? get viewName => view.nameOrDefault; + + @override + EdgeInsets get contentPadding => EdgeInsets.zero; + + @override + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + Map? data, + }) { + return BlocBuilder( + builder: (_, state) => DatabaseDocumentPage( + key: ValueKey(documentId), + view: view, + databaseId: databaseId, + documentId: documentId, + rowId: rowId, + initialSelection: initialSelection, + ), + ); + } + + @override + Widget get leftBarItem => + ViewTitleBarWithRow(view: view, databaseId: databaseId, rowId: rowId); + + @override + Widget tabBarItem(String pluginId, [bool shortForm = false]) => + const SizedBox.shrink(); + + @override + Widget? get rightBarItem => const SizedBox.shrink(); + + @override + List get navigationItems => [this]; +} + +class DatabaseDocumentPluginConfig implements PluginConfig { + @override + bool get creatable => false; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title.dart b/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title.dart new file mode 100644 index 0000000000000..04b8a309056c6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title.dart @@ -0,0 +1,270 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'database_document_title_bloc.dart'; + +// This widget is largely copied from `workspace/presentation/widgets/view_title_bar.dart` intentionally instead of opting for an abstraction. We can make an abstraction after the view refactor is done and there's more clarity in that department. + +// workspaces / ... / database view name / row name +class ViewTitleBarWithRow extends StatelessWidget { + const ViewTitleBarWithRow({ + super.key, + required this.view, + required this.databaseId, + required this.rowId, + }); + + final ViewPB view; + final String databaseId; + final String rowId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DatabaseDocumentTitleBloc( + view: view, + rowId: rowId, + ), + child: BlocBuilder( + builder: (context, state) { + if (state.ancestors.isEmpty) { + return const SizedBox.shrink(); + } + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + height: 24, + child: Row( + // refresh the view title bar when the ancestors changed + key: ValueKey(state.ancestors.hashCode), + children: _buildViewTitles(state.ancestors), + ), + ), + ); + }, + ), + ); + } + + List _buildViewTitles(List views) { + // if the level is too deep, only show the root view, the database view and the row + return views.length > 2 + ? [ + _buildViewButton(views[1]), + const FlowySvg(FlowySvgs.title_bar_divider_s), + const FlowyText.regular(' ... '), + const FlowySvg(FlowySvgs.title_bar_divider_s), + _buildViewButton(views.last), + const FlowySvg(FlowySvgs.title_bar_divider_s), + _buildRowName(), + ] + : [ + ...views + .map( + (e) => [ + _buildViewButton(e), + const FlowySvg(FlowySvgs.title_bar_divider_s), + ], + ) + .flattened, + _buildRowName(), + ]; + } + + Widget _buildViewButton(ViewPB view) { + return FlowyTooltip( + message: view.name, + child: ViewTitle( + view: view, + behavior: ViewTitleBehavior.uneditable, + onUpdated: () {}, + ), + ); + } + + Widget _buildRowName() { + return _RowName( + rowId: rowId, + ); + } +} + +class _RowName extends StatelessWidget { + const _RowName({ + required this.rowId, + }); + + final String rowId; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.databaseController == null) { + return const SizedBox.shrink(); + } + + final cellBuilder = EditableCellBuilder( + databaseController: state.databaseController!, + ); + + return cellBuilder.buildCustom( + CellContext( + fieldId: state.fieldId!, + rowId: rowId, + ), + skinMap: EditableCellSkinMap(textSkin: _TitleSkin()), + ); + }, + ); + } +} + +class _TitleSkin extends IEditableTextCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + TextCellBloc bloc, + FocusNode focusNode, + TextEditingController textEditingController, + ) { + return BlocSelector( + selector: (state) => state.content ?? "", + builder: (context, content) { + final name = content.isEmpty + ? LocaleKeys.grid_row_titlePlaceholder.tr() + : content; + return BlocBuilder( + builder: (context, state) { + return FlowyTooltip( + message: name, + child: AppFlowyPopover( + constraints: const BoxConstraints( + maxWidth: 300, + maxHeight: 44, + ), + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 18), + popupBuilder: (_) { + return RenameRowPopover( + textController: textEditingController, + icon: state.icon ?? EmojiIconData.none(), + onUpdateIcon: (icon) { + context + .read() + .add(DatabaseDocumentTitleEvent.updateIcon(icon)); + }, + onUpdateName: (text) => + bloc.add(TextCellEvent.updateText(text)), + ); + }, + child: FlowyButton( + useIntrinsicWidth: true, + onTap: () {}, + text: Row( + children: [ + if (state.icon != null) ...[ + RawEmojiIconWidget(emoji: state.icon!, emojiSize: 14), + const HSpace(4.0), + ], + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: FlowyText.regular( + name, + overflow: TextOverflow.ellipsis, + fontSize: 14.0, + figmaLineHeight: 18.0, + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + } +} + +class RenameRowPopover extends StatefulWidget { + const RenameRowPopover({ + super.key, + required this.textController, + required this.onUpdateName, + required this.onUpdateIcon, + required this.icon, + }); + + final TextEditingController textController; + final EmojiIconData icon; + + final ValueChanged onUpdateName; + final ValueChanged onUpdateIcon; + + @override + State createState() => _RenameRowPopoverState(); +} + +class _RenameRowPopoverState extends State { + @override + void initState() { + super.initState(); + widget.textController.selection = TextSelection( + baseOffset: 0, + extentOffset: widget.textController.value.text.characters.length, + ); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + EmojiPickerButton( + emoji: widget.icon, + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 18), + defaultIcon: const FlowySvg(FlowySvgs.document_s), + onSubmitted: (emoji, _) { + widget.onUpdateIcon(emoji); + PopoverContainer.of(context).close(); + }, + ), + const HSpace(6), + SizedBox( + height: 36.0, + width: 220, + child: FlowyTextField( + controller: widget.textController, + maxLength: 256, + onSubmitted: (text) { + widget.onUpdateName(text); + PopoverContainer.of(context).close(); + }, + onCanceled: () => widget.onUpdateName(widget.textController.text), + showCounter: false, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title_bloc.dart new file mode 100644 index 0000000000000..2711274cb2610 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title_bloc.dart @@ -0,0 +1,183 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_controller.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/domain/row_meta_listener.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; + +part 'database_document_title_bloc.freezed.dart'; + +class DatabaseDocumentTitleBloc + extends Bloc { + DatabaseDocumentTitleBloc({ + required this.view, + required this.rowId, + }) : _metaListener = RowMetaListener(rowId), + super(DatabaseDocumentTitleState.initial()) { + _dispatch(); + _startListening(); + _init(); + } + + final ViewPB view; + final String rowId; + final RowMetaListener _metaListener; + + void _dispatch() { + on((event, emit) async { + event.when( + didUpdateAncestors: (ancestors) { + emit( + state.copyWith( + ancestors: ancestors, + ), + ); + }, + didUpdateRowTitleInfo: (databaseController, rowController, fieldId) { + emit( + state.copyWith( + databaseController: databaseController, + rowController: rowController, + fieldId: fieldId, + ), + ); + }, + didUpdateRowIcon: (icon) { + emit( + state.copyWith( + icon: icon, + ), + ); + }, + updateIcon: (icon) { + _updateMeta(icon.emoji); + }, + ); + }); + } + + void _startListening() { + _metaListener.start( + callback: (rowMeta) { + if (!isClosed) { + add( + DatabaseDocumentTitleEvent.didUpdateRowIcon( + EmojiIconData.emoji(rowMeta.icon), + ), + ); + } + }, + ); + } + + void _init() async { + // get the database controller, row controller and primary field id + final databaseController = DatabaseController(view: view); + await databaseController.open().fold( + (s) => databaseController.setIsLoading(false), + (f) => null, + ); + final rowInfo = databaseController.rowCache.getRow(rowId); + if (rowInfo == null) { + return; + } + final rowController = RowController( + rowMeta: rowInfo.rowMeta, + viewId: view.id, + rowCache: databaseController.rowCache, + ); + unawaited(rowController.initialize()); + + final primaryFieldId = + await FieldBackendService.getPrimaryField(viewId: view.id).fold( + (primaryField) => primaryField.id, + (r) { + Log.error(r); + return null; + }, + ); + if (primaryFieldId != null) { + add( + DatabaseDocumentTitleEvent.didUpdateRowTitleInfo( + databaseController, + rowController, + primaryFieldId, + ), + ); + } + + // load ancestors + final ancestors = await ViewBackendService.getViewAncestors(view.id) + .fold((s) => s.items, (f) => []); + add(DatabaseDocumentTitleEvent.didUpdateAncestors(ancestors)); + + // initialize icon + if (rowInfo.rowMeta.icon.isNotEmpty) { + add( + DatabaseDocumentTitleEvent.didUpdateRowIcon( + EmojiIconData.emoji(rowInfo.rowMeta.icon), + ), + ); + } + } + + /// Update the meta of the row and the view + void _updateMeta(String iconURL) { + RowBackendService(viewId: view.id) + .updateMeta( + iconURL: iconURL, + rowId: rowId, + ) + .fold((l) => null, (err) => Log.error(err)); + } +} + +@freezed +class DatabaseDocumentTitleEvent with _$DatabaseDocumentTitleEvent { + const factory DatabaseDocumentTitleEvent.didUpdateAncestors( + List ancestors, + ) = _DidUpdateAncestors; + + const factory DatabaseDocumentTitleEvent.didUpdateRowTitleInfo( + DatabaseController databaseController, + RowController rowController, + String fieldId, + ) = _DidUpdateRowTitleInfo; + + const factory DatabaseDocumentTitleEvent.didUpdateRowIcon( + EmojiIconData icon, + ) = _DidUpdateRowIcon; + + const factory DatabaseDocumentTitleEvent.updateIcon( + EmojiIconData icon, + ) = _UpdateIcon; +} + +@freezed +class DatabaseDocumentTitleState with _$DatabaseDocumentTitleState { + const factory DatabaseDocumentTitleState({ + required List ancestors, + required DatabaseController? databaseController, + required RowController? rowController, + required String? fieldId, + required EmojiIconData? icon, + }) = _DatabaseDocumentTitleState; + + factory DatabaseDocumentTitleState.initial() => + const DatabaseDocumentTitleState( + ancestors: [], + databaseController: null, + rowController: null, + fieldId: null, + icon: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_state_listener.dart b/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_state_listener.dart new file mode 100644 index 0000000000000..7f73147d79209 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/doc_sync_state_listener.dart @@ -0,0 +1,61 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/document_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +typedef DocumentSyncStateCallback = void Function( + DocumentSyncStatePB syncState, +); + +class DocumentSyncStateListener { + DocumentSyncStateListener({ + required this.id, + }); + + final String id; + StreamSubscription? _subscription; + DocumentNotificationParser? _parser; + DocumentSyncStateCallback? didReceiveSyncState; + + void start({ + DocumentSyncStateCallback? didReceiveSyncState, + }) { + this.didReceiveSyncState = didReceiveSyncState; + + _parser = DocumentNotificationParser( + id: id, + callback: _callback, + ); + _subscription = RustStreamReceiver.listen( + (observable) => _parser?.parse(observable), + ); + } + + void _callback( + DocumentNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DocumentNotification.DidUpdateDocumentSyncState: + result.map( + (r) { + final value = DocumentSyncStatePB.fromBuffer(r); + didReceiveSyncState?.call(value); + }, + ); + break; + default: + break; + } + } + + Future stop() async { + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart new file mode 100644 index 0000000000000..c65d818351d19 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_appearance_cubit.dart @@ -0,0 +1,217 @@ +import 'dart:async'; + +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/util/color_to_hex_string.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class DocumentAppearance { + const DocumentAppearance({ + required this.fontSize, + required this.fontFamily, + required this.codeFontFamily, + required this.width, + this.cursorColor, + this.selectionColor, + this.defaultTextDirection, + }); + + final double fontSize; + final String fontFamily; + final String codeFontFamily; + final Color? cursorColor; + final Color? selectionColor; + final String? defaultTextDirection; + final double width; + + /// For nullable fields (like `cursorColor`), + /// use the corresponding `isNull` flag (like `cursorColorIsNull`) to explicitly set the field to `null`. + /// + /// This is necessary because simply passing `null` as the value does not distinguish between wanting to + /// set the field to `null` and not wanting to update the field at all. + DocumentAppearance copyWith({ + double? fontSize, + String? fontFamily, + String? codeFontFamily, + Color? cursorColor, + Color? selectionColor, + String? defaultTextDirection, + bool cursorColorIsNull = false, + bool selectionColorIsNull = false, + bool textDirectionIsNull = false, + double? width, + }) { + return DocumentAppearance( + fontSize: fontSize ?? this.fontSize, + fontFamily: fontFamily ?? this.fontFamily, + codeFontFamily: codeFontFamily ?? this.codeFontFamily, + cursorColor: cursorColorIsNull ? null : cursorColor ?? this.cursorColor, + selectionColor: + selectionColorIsNull ? null : selectionColor ?? this.selectionColor, + defaultTextDirection: textDirectionIsNull + ? null + : defaultTextDirection ?? this.defaultTextDirection, + width: width ?? this.width, + ); + } +} + +class DocumentAppearanceCubit extends Cubit { + DocumentAppearanceCubit() + : super( + DocumentAppearance( + fontSize: 16.0, + fontFamily: defaultFontFamily, + codeFontFamily: builtInCodeFontFamily, + width: UniversalPlatform.isMobile + ? double.infinity + : EditorStyleCustomizer.maxDocumentWidth, + ), + ); + + Future fetch() async { + final prefs = await SharedPreferences.getInstance(); + final fontSize = + prefs.getDouble(KVKeys.kDocumentAppearanceFontSize) ?? 16.0; + final fontFamily = prefs.getString(KVKeys.kDocumentAppearanceFontFamily) ?? + defaultFontFamily; + final defaultTextDirection = + prefs.getString(KVKeys.kDocumentAppearanceDefaultTextDirection); + + final cursorColorString = + prefs.getString(KVKeys.kDocumentAppearanceCursorColor); + final selectionColorString = + prefs.getString(KVKeys.kDocumentAppearanceSelectionColor); + final cursorColor = + cursorColorString != null ? Color(int.parse(cursorColorString)) : null; + final selectionColor = selectionColorString != null + ? Color(int.parse(selectionColorString)) + : null; + final double? width = prefs.getDouble(KVKeys.kDocumentAppearanceWidth); + + final textScaleFactor = + double.parse(prefs.getString(KVKeys.textScaleFactor) ?? '1.0'); + + if (isClosed) { + return; + } + + emit( + state.copyWith( + fontSize: fontSize * textScaleFactor, + fontFamily: fontFamily, + cursorColor: cursorColor, + selectionColor: selectionColor, + defaultTextDirection: defaultTextDirection, + cursorColorIsNull: cursorColor == null, + selectionColorIsNull: selectionColor == null, + textDirectionIsNull: defaultTextDirection == null, + width: width, + ), + ); + } + + Future syncFontSize(double fontSize) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(KVKeys.kDocumentAppearanceFontSize, fontSize); + + if (!isClosed) { + emit(state.copyWith(fontSize: fontSize)); + } + } + + Future syncFontFamily(String fontFamily) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(KVKeys.kDocumentAppearanceFontFamily, fontFamily); + + if (!isClosed) { + emit(state.copyWith(fontFamily: fontFamily)); + } + } + + Future syncDefaultTextDirection(String? direction) async { + final prefs = await SharedPreferences.getInstance(); + if (direction == null) { + await prefs.remove(KVKeys.kDocumentAppearanceDefaultTextDirection); + } else { + await prefs.setString( + KVKeys.kDocumentAppearanceDefaultTextDirection, + direction, + ); + } + + if (!isClosed) { + emit( + state.copyWith( + defaultTextDirection: direction, + textDirectionIsNull: direction == null, + ), + ); + } + } + + Future syncCursorColor(Color? cursorColor) async { + final prefs = await SharedPreferences.getInstance(); + + if (cursorColor == null) { + await prefs.remove(KVKeys.kDocumentAppearanceCursorColor); + } else { + await prefs.setString( + KVKeys.kDocumentAppearanceCursorColor, + cursorColor.toHexString(), + ); + } + + if (!isClosed) { + emit( + state.copyWith( + cursorColor: cursorColor, + cursorColorIsNull: cursorColor == null, + ), + ); + } + } + + Future syncSelectionColor(Color? selectionColor) async { + final prefs = await SharedPreferences.getInstance(); + + if (selectionColor == null) { + await prefs.remove(KVKeys.kDocumentAppearanceSelectionColor); + } else { + await prefs.setString( + KVKeys.kDocumentAppearanceSelectionColor, + selectionColor.toHexString(), + ); + } + + if (!isClosed) { + emit( + state.copyWith( + selectionColor: selectionColor, + selectionColorIsNull: selectionColor == null, + ), + ); + } + } + + Future syncWidth(double? width) async { + final prefs = await SharedPreferences.getInstance(); + + width ??= UniversalPlatform.isMobile + ? double.infinity + : EditorStyleCustomizer.maxDocumentWidth; + width = width.clamp( + EditorStyleCustomizer.minDocumentWidth, + EditorStyleCustomizer.maxDocumentWidth, + ); + await prefs.setDouble(KVKeys.kDocumentAppearanceWidth, width); + + if (!isClosed) { + emit(state.copyWith(width: width)); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_awareness_metadata.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_awareness_metadata.dart new file mode 100644 index 0000000000000..6bb19ef8bb5fd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_awareness_metadata.dart @@ -0,0 +1,22 @@ +// This file is "main.dart" +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'document_awareness_metadata.freezed.dart'; +part 'document_awareness_metadata.g.dart'; + +@freezed +class DocumentAwarenessMetadata with _$DocumentAwarenessMetadata { + const factory DocumentAwarenessMetadata({ + // ignore: invalid_annotation_target + @JsonKey(name: 'cursor_color') required String cursorColor, + // ignore: invalid_annotation_target + @JsonKey(name: 'selection_color') required String selectionColor, + // ignore: invalid_annotation_target + @JsonKey(name: 'user_name') required String userName, + // ignore: invalid_annotation_target + @JsonKey(name: 'user_avatar') required String userAvatar, + }) = _DocumentAwarenessMetadata; + + factory DocumentAwarenessMetadata.fromJson(Map json) => + _$DocumentAwarenessMetadataFromJson(json); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart new file mode 100644 index 0000000000000..8a7d9ba47e9b5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart @@ -0,0 +1,492 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/plugins/document/application/doc_sync_state_listener.dart'; +import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart'; +import 'package:appflowy/plugins/document/application/document_collab_adapter.dart'; +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/application/document_listener.dart'; +import 'package:appflowy/plugins/document/application/document_service.dart'; +import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart'; +import 'package:appflowy/plugins/trash/application/trash_service.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/util/color_generator/color_generator.dart'; +import 'package:appflowy/util/color_to_hex_string.dart'; +import 'package:appflowy/util/debounce.dart'; +import 'package:appflowy/util/throttle.dart'; +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + show + EditorState, + AppFlowyEditorLogLevel, + TransactionTime, + Selection, + Position, + paragraphNode; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'document_bloc.freezed.dart'; + +bool enableDocumentInternalLog = false; + +final Map _documentBlocMap = {}; + +class DocumentBloc extends Bloc { + DocumentBloc({ + required this.documentId, + this.databaseViewId, + this.rowId, + bool saveToBlocMap = true, + }) : _saveToBlocMap = saveToBlocMap, + _documentListener = DocumentListener(id: documentId), + _syncStateListener = DocumentSyncStateListener(id: documentId), + super(DocumentState.initial()) { + _viewListener = databaseViewId == null && rowId == null + ? ViewListener(viewId: documentId) + : null; + on(_onDocumentEvent); + } + + static DocumentBloc? findOpen(String documentId) => + _documentBlocMap[documentId]; + + /// For a normal document, the document id is the same as the view id + final String documentId; + + final String? databaseViewId; + final String? rowId; + + final bool _saveToBlocMap; + + final DocumentListener _documentListener; + final DocumentSyncStateListener _syncStateListener; + late final ViewListener? _viewListener; + + final DocumentService _documentService = DocumentService(); + final TrashService _trashService = TrashService(); + + late DocumentCollabAdapter _documentCollabAdapter; + + late final TransactionAdapter _transactionAdapter = TransactionAdapter( + documentId: documentId, + documentService: _documentService, + ); + + StreamSubscription? _transactionSubscription; + + bool isClosing = false; + + static const _syncDuration = Duration(milliseconds: 250); + final _updateSelectionDebounce = Debounce(duration: _syncDuration); + final _syncThrottle = Throttler(duration: _syncDuration); + + // The conflict handle logic is not fully implemented yet + // use the syncTimer to force to reload the document state when the conflict happens. + Timer? _syncTimer; + + bool get isLocalMode { + final userProfilePB = state.userProfilePB; + final type = userProfilePB?.authenticator ?? AuthenticatorPB.Local; + return type == AuthenticatorPB.Local; + } + + @override + Future close() async { + isClosing = true; + if (_saveToBlocMap) { + _documentBlocMap.remove(documentId); + } + await checkDocumentIntegrity(); + await _cancelSubscriptions(); + _clearEditorState(); + return super.close(); + } + + Future _cancelSubscriptions() async { + await _documentService.syncAwarenessStates(documentId: documentId); + await _documentListener.stop(); + await _syncStateListener.stop(); + await _viewListener?.stop(); + await _transactionSubscription?.cancel(); + await _documentService.closeDocument(viewId: documentId); + } + + void _clearEditorState() { + _updateSelectionDebounce.dispose(); + _syncThrottle.dispose(); + + _syncTimer?.cancel(); + _syncTimer = null; + state.editorState?.selectionNotifier + .removeListener(_debounceOnSelectionUpdate); + state.editorState?.service.keyboardService?.closeKeyboard(); + state.editorState?.dispose(); + } + + Future _onDocumentEvent( + DocumentEvent event, + Emitter emit, + ) async { + await event.when( + initial: () async { + if (_saveToBlocMap) { + _documentBlocMap[documentId] = this; + } + final result = await _fetchDocumentState(); + _onViewChanged(); + _onDocumentChanged(); + final newState = await result.fold( + (s) async { + final userProfilePB = + await getIt().getUser().toNullable(); + return state.copyWith( + error: null, + editorState: s, + isLoading: false, + userProfilePB: userProfilePB, + ); + }, + (f) async => state.copyWith( + error: f, + editorState: null, + isLoading: false, + ), + ); + emit(newState); + if (newState.userProfilePB != null) { + await _updateCollaborator(); + } + }, + moveToTrash: () async { + emit(state.copyWith(isDeleted: true)); + }, + restore: () async { + emit(state.copyWith(isDeleted: false)); + }, + deletePermanently: () async { + if (databaseViewId == null && rowId == null) { + final result = await _trashService.deleteViews([documentId]); + final forceClose = result.fold((l) => true, (r) => false); + emit(state.copyWith(forceClose: forceClose)); + } + }, + restorePage: () async { + if (databaseViewId == null && rowId == null) { + final result = await TrashService.putback(documentId); + final isDeleted = result.fold((l) => false, (r) => true); + emit(state.copyWith(isDeleted: isDeleted)); + } + }, + syncStateChanged: (syncState) { + emit(state.copyWith(syncState: syncState.value)); + }, + clearAwarenessStates: () async { + // sync a null selection and a null meta to clear the awareness states + await _documentService.syncAwarenessStates( + documentId: documentId, + ); + }, + syncAwarenessStates: () async { + await _updateCollaborator(); + }, + ); + } + + /// subscribe to the view(document page) change + void _onViewChanged() { + _viewListener?.start( + onViewMoveToTrash: (r) { + r.map((r) => add(const DocumentEvent.moveToTrash())); + }, + onViewDeleted: (r) { + r.map((r) => add(const DocumentEvent.moveToTrash())); + }, + onViewRestored: (r) => r.map((r) => add(const DocumentEvent.restore())), + ); + } + + /// subscribe to the document content change + void _onDocumentChanged() { + _documentListener.start( + onDocEventUpdate: _throttleSyncDoc, + onDocAwarenessUpdate: _onAwarenessStatesUpdate, + ); + + _syncStateListener.start( + didReceiveSyncState: (syncState) { + if (!isClosed) { + add(DocumentEvent.syncStateChanged(syncState)); + } + }, + ); + } + + /// Fetch document + Future> _fetchDocumentState() async { + final result = await _documentService.openDocument(documentId: documentId); + return result.fold( + (s) async => FlowyResult.success(await _initAppFlowyEditorState(s)), + (e) => FlowyResult.failure(e), + ); + } + + Future _initAppFlowyEditorState(DocumentDataPB data) async { + if (enableDocumentInternalLog) { + Log.info('document data: ${data.toProto3Json()}'); + } + + final document = data.toDocument(); + if (document == null) { + assert(false, 'document is null'); + return null; + } + + final editorState = EditorState(document: document); + + _documentCollabAdapter = DocumentCollabAdapter(editorState, documentId); + + // subscribe to the document change from the editor + _transactionSubscription = editorState.transactionStream.listen( + (event) async { + final time = event.$1; + final transaction = event.$2; + final options = event.$3; + if (time != TransactionTime.before) { + return; + } + + if (options.inMemoryUpdate) { + Log.info('skip transaction for in-memory update'); + return; + } + + if (enableDocumentInternalLog) { + Log.debug( + '[TransactionAdapter] 1. transaction before apply: ${transaction.hashCode}', + ); + } + + // apply transaction to backend + await _transactionAdapter.apply(transaction, editorState); + + // check if the document is empty. + await _applyRules(); + + if (enableDocumentInternalLog) { + Log.debug( + '[TransactionAdapter] 4. transaction after apply: ${transaction.hashCode}', + ); + } + + if (!isClosed) { + // ignore: invalid_use_of_visible_for_testing_member + emit(state.copyWith(isDocumentEmpty: editorState.document.isEmpty)); + } + }, + ); + + editorState.selectionNotifier.addListener(_debounceOnSelectionUpdate); + + // output the log from the editor when debug mode + if (kDebugMode) { + editorState.logConfiguration + ..level = AppFlowyEditorLogLevel.all + ..handler = (log) { + if (enableDocumentInternalLog) { + Log.info(log); + } + }; + } + + return editorState; + } + + Future _applyRules() async { + await Future.wait([ + _ensureAtLeastOneParagraphExists(), + ]); + } + + Future _ensureAtLeastOneParagraphExists() async { + final editorState = state.editorState; + if (editorState == null) { + return; + } + final document = editorState.document; + if (document.root.children.isEmpty) { + final transaction = editorState.transaction; + transaction.insertNode([0], paragraphNode()); + transaction.afterSelection = Selection.collapsed( + Position(path: [0]), + ); + await editorState.apply(transaction); + } + } + + Future _onDocumentStateUpdate(DocEventPB docEvent) async { + if (!docEvent.isRemote || !FeatureFlag.syncDocument.isOn) { + return; + } + + unawaited(_documentCollabAdapter.syncV3(docEvent: docEvent)); + } + + Future _onAwarenessStatesUpdate( + DocumentAwarenessStatesPB awarenessStates, + ) async { + if (!FeatureFlag.syncDocument.isOn) { + return; + } + + final userId = state.userProfilePB?.id; + if (userId != null) { + await _documentCollabAdapter.updateRemoteSelection( + userId.toString(), + awarenessStates, + ); + } + } + + void _debounceOnSelectionUpdate() { + _updateSelectionDebounce.call(_onSelectionUpdate); + } + + void _throttleSyncDoc(DocEventPB docEvent) { + _syncThrottle.call(() { + _onDocumentStateUpdate(docEvent); + }); + } + + Future _onSelectionUpdate() async { + if (isClosing) { + return; + } + final user = state.userProfilePB; + final deviceId = ApplicationInfo.deviceId; + if (!FeatureFlag.syncDocument.isOn || user == null) { + return; + } + + final editorState = state.editorState; + if (editorState == null) { + return; + } + final selection = editorState.selection; + + // sync the selection + final id = user.id.toString() + deviceId; + final basicColor = ColorGenerator(id.toString()).toColor(); + final metadata = DocumentAwarenessMetadata( + cursorColor: basicColor.toHexString(), + selectionColor: basicColor.withOpacity(0.6).toHexString(), + userName: user.name, + userAvatar: user.iconUrl, + ); + await _documentService.syncAwarenessStates( + documentId: documentId, + selection: selection, + metadata: jsonEncode(metadata.toJson()), + ); + } + + Future _updateCollaborator() async { + final user = state.userProfilePB; + final deviceId = ApplicationInfo.deviceId; + if (!FeatureFlag.syncDocument.isOn || user == null) { + return; + } + + // sync the selection + final id = user.id.toString() + deviceId; + final basicColor = ColorGenerator(id.toString()).toColor(); + final metadata = DocumentAwarenessMetadata( + cursorColor: basicColor.toHexString(), + selectionColor: basicColor.withOpacity(0.6).toHexString(), + userName: user.name, + userAvatar: user.iconUrl, + ); + await _documentService.syncAwarenessStates( + documentId: documentId, + metadata: jsonEncode(metadata.toJson()), + ); + } + + void forceReloadDocumentState() { + _documentCollabAdapter.syncV3(); + } + + // this is only used for debug mode + Future checkDocumentIntegrity() async { + if (!enableDocumentInternalLog) { + return; + } + + final cloudDocResult = + await _documentService.getDocument(documentId: documentId); + final cloudDoc = cloudDocResult.fold((s) => s, (f) => null)?.toDocument(); + final localDoc = state.editorState?.document; + if (cloudDoc == null || localDoc == null) { + return; + } + final cloudJson = cloudDoc.toJson(); + final localJson = localDoc.toJson(); + final deepEqual = const DeepCollectionEquality().equals( + cloudJson, + localJson, + ); + if (!deepEqual) { + Log.error('document integrity check failed'); + // Enable it to debug the document integrity check failed + // Log.error('cloud doc: $cloudJson'); + // Log.error('local doc: $localJson'); + assert(false, 'document integrity check failed'); + } + } +} + +@freezed +class DocumentEvent with _$DocumentEvent { + const factory DocumentEvent.initial() = Initial; + const factory DocumentEvent.moveToTrash() = MoveToTrash; + const factory DocumentEvent.restore() = Restore; + const factory DocumentEvent.restorePage() = RestorePage; + const factory DocumentEvent.deletePermanently() = DeletePermanently; + const factory DocumentEvent.syncStateChanged( + final DocumentSyncStatePB syncState, + ) = syncStateChanged; + const factory DocumentEvent.syncAwarenessStates() = SyncAwarenessStates; + const factory DocumentEvent.clearAwarenessStates() = ClearAwarenessStates; +} + +@freezed +class DocumentState with _$DocumentState { + const factory DocumentState({ + required final bool isDeleted, + required final bool forceClose, + required final bool isLoading, + required final DocumentSyncState syncState, + bool? isDocumentEmpty, + UserProfilePB? userProfilePB, + EditorState? editorState, + FlowyError? error, + @Default(null) DocumentAwarenessStatesPB? awarenessStates, + }) = _DocumentState; + + factory DocumentState.initial() => const DocumentState( + isDeleted: false, + forceClose: false, + isLoading: true, + syncState: DocumentSyncState.Syncing, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_collab_adapter.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_collab_adapter.dart new file mode 100644 index 0000000000000..a3e62d569cc5b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_collab_adapter.dart @@ -0,0 +1,266 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart'; +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/shared/list_extension.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:appflowy/util/color_generator/color_generator.dart'; +import 'package:appflowy/util/json_print.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class DocumentCollabAdapter { + DocumentCollabAdapter(this.editorState, this.docId); + + final EditorState editorState; + final String docId; + + final _service = DocumentService(); + + /// Sync version 1 + /// + /// Force to reload the document + /// + /// Only use in development + Future syncV1() async { + final result = await _service.getDocument(documentId: docId); + final document = result.fold((s) => s.toDocument(), (f) => null); + if (document == null) { + return null; + } + return EditorState(document: document); + } + + /// Sync version 2 + /// + /// Translate the [docEvent] from yrs to [Operation]s and apply it to the [editorState] + /// + /// Not fully implemented yet + Future syncV2(DocEventPB docEvent) async { + prettyPrintJson(docEvent.toProto3Json()); + + final transaction = editorState.transaction; + + for (final event in docEvent.events) { + for (final blockEvent in event.event) { + switch (blockEvent.command) { + case DeltaTypePB.Inserted: + break; + case DeltaTypePB.Updated: + await _syncUpdated(blockEvent, transaction); + break; + case DeltaTypePB.Removed: + break; + default: + } + } + } + + await editorState.apply(transaction, isRemote: true); + } + + /// Sync version 3 + /// + /// Diff the local document with the remote document and apply the changes + Future syncV3({DocEventPB? docEvent}) async { + final result = await _service.getDocument(documentId: docId); + final document = result.fold((s) => s.toDocument(), (f) => null); + if (document == null) { + return; + } + + final ops = diffNodes(editorState.document.root, document.root); + if (ops.isEmpty) { + return; + } + + // Use for debugging, DO NOT REMOVE + // prettyPrintJson(ops.map((op) => op.toJson()).toList()); + + final transaction = editorState.transaction; + for (final op in ops) { + transaction.add(op); + } + await editorState.apply(transaction, isRemote: true); + + // Use for debugging, DO NOT REMOVE + // assert(() { + // final local = editorState.document.root.toJson(); + // final remote = document.root.toJson(); + // if (!const DeepCollectionEquality().equals(local, remote)) { + // Log.error('Invalid diff status'); + // Log.error('Local: $local'); + // Log.error('Remote: $remote'); + // return false; + // } + // return true; + // }()); + } + + Future forceReload() async { + final result = await _service.getDocument(documentId: docId); + final document = result.fold((s) => s.toDocument(), (f) => null); + if (document == null) { + return; + } + + final beforeSelection = editorState.selection; + + final clear = editorState.transaction; + clear.deleteNodes(editorState.document.root.children); + await editorState.apply(clear, isRemote: true); + + final insert = editorState.transaction; + insert.insertNodes([0], document.root.children); + await editorState.apply(insert, isRemote: true); + + editorState.selection = beforeSelection; + } + + Future _syncUpdated( + BlockEventPayloadPB payload, + Transaction transaction, + ) async { + assert(payload.command == DeltaTypePB.Updated); + + final path = payload.path; + final id = payload.id; + final value = jsonDecode(payload.value); + + final nodes = NodeIterator( + document: editorState.document, + startNode: editorState.document.root, + ).toList(); + + // 1. meta -> text_map = text delta change + if (path.isTextDeltaChangeset) { + // find the 'text' block and apply the delta + // ⚠️ not completed yet. + final target = nodes.singleWhereOrNull((n) => n.id == id); + if (target != null) { + try { + final delta = Delta.fromJson(jsonDecode(value)); + transaction.insertTextDelta(target, 0, delta); + } catch (e) { + Log.error('Failed to apply delta: $value, error: $e'); + } + } + } else if (path.isBlockChangeset) { + final target = nodes.singleWhereOrNull((n) => n.id == id); + if (target != null) { + try { + final delta = jsonDecode(value['data'])['delta']; + transaction.updateNode(target, { + 'delta': Delta.fromJson(delta).toJson(), + }); + } catch (e) { + Log.error('Failed to update $value, error: $e'); + } + } + } + } + + Future updateRemoteSelection( + String userId, + DocumentAwarenessStatesPB states, + ) async { + final List remoteSelections = []; + final deviceId = ApplicationInfo.deviceId; + // the values may be duplicated, sort by the timestamp and then filter the duplicated values + final values = states.value.values + .sorted( + (a, b) => b.timestamp.compareTo(a.timestamp), + ) // in descending order + .unique( + (e) => Object.hashAll([e.user.uid, e.user.deviceId]), + ); + for (final state in values) { + // the following code is only for version 1 + if (state.version != 1 || state.metadata.isEmpty) { + continue; + } + final uid = state.user.uid.toString(); + final did = state.user.deviceId; + + DocumentAwarenessMetadata metadata; + try { + metadata = DocumentAwarenessMetadata.fromJson( + jsonDecode(state.metadata), + ); + } catch (e) { + Log.error('Failed to parse metadata: $e, ${state.metadata}'); + continue; + } + final selectionColor = metadata.selectionColor.tryToColor(); + final cursorColor = metadata.cursorColor.tryToColor(); + if ((uid == userId && did == deviceId) || + (cursorColor == null || selectionColor == null)) { + continue; + } + final start = state.selection.start; + final end = state.selection.end; + final selection = Selection( + start: Position( + path: start.path.toIntList(), + offset: start.offset.toInt(), + ), + end: Position( + path: end.path.toIntList(), + offset: end.offset.toInt(), + ), + ); + final color = ColorGenerator(uid + did).toColor(); + final remoteSelection = RemoteSelection( + id: uid, + selection: selection, + selectionColor: selectionColor, + cursorColor: cursorColor, + builder: (_, __, rect) { + return Positioned( + top: rect.top - 14, + left: selection.isCollapsed ? rect.right : rect.left, + child: ColoredBox( + color: color, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 2.0, + vertical: 1.0, + ), + child: FlowyText( + metadata.userName, + color: Colors.black, + fontSize: 12.0, + ), + ), + ), + ); + }, + ); + remoteSelections.add(remoteSelection); + } + + editorState.remoteSelections.value = remoteSelections; + } +} + +extension on List { + List toIntList() { + return map((e) => e.toInt()).toList(); + } +} + +extension on List { + bool get isTextDeltaChangeset { + return length == 3 && this[0] == 'meta' && this[1] == 'text_map'; + } + + bool get isBlockChangeset { + return length == 2 && this[0] == 'blocks'; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart new file mode 100644 index 0000000000000..b6352b0430222 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_collaborators_bloc.dart @@ -0,0 +1,131 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart'; +import 'package:appflowy/plugins/document/application/document_listener.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'document_collaborators_bloc.freezed.dart'; + +bool _filterCurrentUser = false; + +class DocumentCollaboratorsBloc + extends Bloc { + DocumentCollaboratorsBloc({ + required this.view, + }) : _listener = DocumentListener(id: view.id), + super(DocumentCollaboratorsState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + final result = await getIt().getUser(); + final userProfile = result.fold((s) => s, (f) => null); + emit( + state.copyWith( + shouldShowIndicator: + userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud, + ), + ); + final deviceId = ApplicationInfo.deviceId; + if (userProfile != null) { + _listener.start( + onDocAwarenessUpdate: (states) { + if (isClosed) { + return; + } + + add( + DocumentCollaboratorsEvent.update( + userProfile, + deviceId, + states, + ), + ); + }, + ); + } + }, + update: (userProfile, deviceId, states) { + final collaborators = _buildCollaborators( + userProfile, + deviceId, + states, + ); + emit(state.copyWith(collaborators: collaborators)); + }, + ); + }, + ); + } + + final ViewPB view; + final DocumentListener _listener; + + @override + Future close() async { + await _listener.stop(); + return super.close(); + } + + List _buildCollaborators( + UserProfilePB userProfile, + String deviceId, + DocumentAwarenessStatesPB states, + ) { + final result = []; + final ids = {}; + final sorted = states.value.values.toList() + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)) + ..retainWhere((e) => ids.add(e.user.uid.toString() + e.user.deviceId)); + for (final state in sorted) { + if (state.version != 1) { + continue; + } + // filter current user + if (_filterCurrentUser && + userProfile.id == state.user.uid && + deviceId == state.user.deviceId) { + continue; + } + try { + final metadata = DocumentAwarenessMetadata.fromJson( + jsonDecode(state.metadata), + ); + result.add(metadata); + } catch (e) { + Log.error('Failed to parse metadata: $e'); + } + } + return result; + } +} + +@freezed +class DocumentCollaboratorsEvent with _$DocumentCollaboratorsEvent { + const factory DocumentCollaboratorsEvent.initial() = Initial; + const factory DocumentCollaboratorsEvent.update( + UserProfilePB userProfile, + String deviceId, + DocumentAwarenessStatesPB states, + ) = Update; +} + +@freezed +class DocumentCollaboratorsState with _$DocumentCollaboratorsState { + const factory DocumentCollaboratorsState({ + @Default([]) List collaborators, + @Default(false) bool shouldShowIndicator, + }) = _DocumentCollaboratorsState; + + factory DocumentCollaboratorsState.initial() => + const DocumentCollaboratorsState(); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart new file mode 100644 index 0000000000000..f9a80864f1497 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_data_pb_extension.dart @@ -0,0 +1,222 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + show + Document, + Node, + Attributes, + Delta, + ParagraphBlockKeys, + NodeIterator, + NodeExternalValues, + HeadingBlockKeys, + QuoteBlockKeys, + NumberedListBlockKeys, + BulletedListBlockKeys, + blockComponentDelta; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:collection/collection.dart'; +import 'package:nanoid/nanoid.dart'; + +class ExternalValues extends NodeExternalValues { + const ExternalValues({ + required this.externalId, + required this.externalType, + }); + + final String externalId; + final String externalType; +} + +extension DocumentDataPBFromTo on DocumentDataPB { + static DocumentDataPB? fromDocument(Document document) { + final startNode = document.first; + final endNode = document.last; + if (startNode == null || endNode == null) { + return null; + } + final pageId = document.root.id; + + // generate the block + final blocks = {}; + final nodes = NodeIterator( + document: document, + startNode: startNode, + endNode: endNode, + ).toList(); + for (final node in nodes) { + if (blocks.containsKey(node.id)) { + assert(false, 'duplicate node id: ${node.id}'); + } + final parentId = node.parent?.id; + final childrenId = nanoid(10); + blocks[node.id] = node.toBlock( + parentId: parentId, + childrenId: childrenId, + ); + } + // root + blocks[pageId] = document.root.toBlock( + parentId: '', + childrenId: pageId, + ); + + // generate the meta + final childrenMap = {}; + blocks.values.where((e) => e.parentId.isNotEmpty).forEach((value) { + final childrenId = blocks[value.parentId]?.childrenId; + if (childrenId != null) { + childrenMap[childrenId] ??= ChildrenPB.create(); + childrenMap[childrenId]!.children.add(value.id); + } + }); + final meta = MetaPB(childrenMap: childrenMap); + + return DocumentDataPB( + blocks: blocks, + pageId: pageId, + meta: meta, + ); + } + + Document? toDocument() { + final rootId = pageId; + try { + final root = buildNode(rootId); + + if (root != null) { + return Document(root: root); + } + + return null; + } catch (e) { + Log.error('create document error: $e'); + return null; + } + } + + Node? buildNode(String id) { + final block = blocks[id]; + final childrenId = block?.childrenId; + final childrenIds = meta.childrenMap[childrenId]?.children; + + final children = []; + if (childrenIds != null && childrenIds.isNotEmpty) { + children.addAll(childrenIds.map((e) => buildNode(e)).whereNotNull()); + } + + final node = block?.toNode( + children: children, + meta: meta, + ); + + for (final element in children) { + element.parent = node; + } + + return node; + } +} + +extension BlockToNode on BlockPB { + Node toNode({ + Iterable? children, + required MetaPB meta, + }) { + final node = Node( + id: id, + type: ty, + attributes: _dataAdapter(ty, data, meta), + children: children ?? [], + ); + node.externalValues = ExternalValues( + externalId: externalId, + externalType: externalType, + ); + return node; + } + + Attributes _dataAdapter(String ty, String data, MetaPB meta) { + final map = Attributes.from(jsonDecode(data)); + + // it used in the delta case now. + final externalType = this.externalType; + final externalId = this.externalId; + if (externalType.isNotEmpty && externalId.isNotEmpty) { + // the 'text' type is the only type that is supported now. + if (externalType == 'text') { + final deltaString = meta.textMap[externalId]; + if (deltaString != null) { + final delta = jsonDecode(deltaString); + map[blockComponentDelta] = delta; + } + } + } + + Attributes adapterCallback(Attributes map) => map + ..putIfAbsent( + blockComponentDelta, + () => Delta().toJson(), + ); + + final adapter = { + ParagraphBlockKeys.type: adapterCallback, + HeadingBlockKeys.type: adapterCallback, + CodeBlockKeys.type: adapterCallback, + QuoteBlockKeys.type: adapterCallback, + NumberedListBlockKeys.type: adapterCallback, + BulletedListBlockKeys.type: adapterCallback, + ToggleListBlockKeys.type: adapterCallback, + }; + return adapter[ty]?.call(map) ?? map; + } +} + +extension NodeToBlock on Node { + BlockPB toBlock({ + String? parentId, + String? childrenId, + Attributes? attributes, + String? externalId, + String? externalType, + }) { + assert(id.isNotEmpty); + final block = BlockPB.create() + ..id = id + ..ty = type + ..data = _dataAdapter(type, attributes ?? this.attributes); + if (childrenId != null && childrenId.isNotEmpty) { + block.childrenId = childrenId; + } + if (parentId != null && parentId.isNotEmpty) { + block.parentId = parentId; + } + if (externalId != null && externalId.isNotEmpty) { + block.externalId = externalId; + } + if (externalType != null && externalType.isNotEmpty) { + block.externalType = externalType; + } + return block; + } + + String _dataAdapter(String type, Attributes attributes) { + try { + return jsonEncode( + attributes, + toEncodable: (value) { + if (value is Map) { + return jsonEncode(value); + } + return value; + }, + ); + } catch (e) { + Log.error('encode attributes error: $e'); + return '{}'; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_listener.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_listener.dart new file mode 100644 index 0000000000000..ab102c7ee8194 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_listener.dart @@ -0,0 +1,73 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/document_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +typedef OnDocumentEventUpdate = void Function(DocEventPB docEvent); +typedef OnDocumentAwarenessStateUpdate = void Function( + DocumentAwarenessStatesPB awarenessStates, +); + +class DocumentListener { + DocumentListener({ + required this.id, + }); + + final String id; + + StreamSubscription? _subscription; + DocumentNotificationParser? _parser; + + OnDocumentEventUpdate? _onDocEventUpdate; + OnDocumentAwarenessStateUpdate? _onDocAwarenessUpdate; + + void start({ + OnDocumentEventUpdate? onDocEventUpdate, + OnDocumentAwarenessStateUpdate? onDocAwarenessUpdate, + }) { + _onDocEventUpdate = onDocEventUpdate; + _onDocAwarenessUpdate = onDocAwarenessUpdate; + + _parser = DocumentNotificationParser( + id: id, + callback: _callback, + ); + _subscription = RustStreamReceiver.listen( + (observable) => _parser?.parse(observable), + ); + } + + void _callback( + DocumentNotification ty, + FlowyResult result, + ) { + switch (ty) { + case DocumentNotification.DidReceiveUpdate: + result.map( + (s) => _onDocEventUpdate?.call(DocEventPB.fromBuffer(s)), + ); + break; + case DocumentNotification.DidUpdateDocumentAwarenessState: + result.map( + (s) => _onDocAwarenessUpdate?.call( + DocumentAwarenessStatesPB.fromBuffer(s), + ), + ); + break; + default: + break; + } + } + + Future stop() async { + _onDocAwarenessUpdate = null; + _onDocEventUpdate = null; + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart new file mode 100644 index 0000000000000..f520e20d02558 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart @@ -0,0 +1,222 @@ +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:fixnum/fixnum.dart'; + +class DocumentService { + // unused now. + Future> createDocument({ + required ViewPB view, + }) async { + final canOpen = await openDocument(documentId: view.id); + if (canOpen.isSuccess) { + return FlowyResult.success(null); + } + final payload = CreateDocumentPayloadPB()..documentId = view.id; + final result = await DocumentEventCreateDocument(payload).send(); + return result; + } + + Future> openDocument({ + required String documentId, + }) async { + final payload = OpenDocumentPayloadPB()..documentId = documentId; + final result = await DocumentEventOpenDocument(payload).send(); + return result; + } + + Future> getDocument({ + required String documentId, + }) async { + final payload = OpenDocumentPayloadPB()..documentId = documentId; + final result = await DocumentEventGetDocumentData(payload).send(); + return result; + } + + Future> + getDocumentNode({ + required String documentId, + required String blockId, + }) async { + final documentResult = await getDocument(documentId: documentId); + final document = documentResult.fold((l) => l, (f) => null); + if (document == null) { + Log.error('unable to get the document for page $documentId'); + return FlowyResult.failure(FlowyError(msg: 'Document not found')); + } + + final blockResult = await getBlockFromDocument( + document: document, + blockId: blockId, + ); + final block = blockResult.fold((l) => l, (f) => null); + if (block == null) { + Log.error( + 'unable to get the block $blockId from the document $documentId', + ); + return FlowyResult.failure(FlowyError(msg: 'Block not found')); + } + + final node = document.buildNode(blockId); + if (node == null) { + Log.error( + 'unable to get the node for block $blockId in document $documentId', + ); + return FlowyResult.failure(FlowyError(msg: 'Node not found')); + } + + return FlowyResult.success((document, block, node)); + } + + Future> getBlockFromDocument({ + required DocumentDataPB document, + required String blockId, + }) async { + final block = document.blocks[blockId]; + + if (block != null) { + return FlowyResult.success(block); + } + + return FlowyResult.failure( + FlowyError( + msg: 'Block($blockId) not found in Document(${document.pageId})', + ), + ); + } + + Future> closeDocument({ + required String viewId, + }) async { + final payload = ViewIdPB()..value = viewId; + final result = await FolderEventCloseView(payload).send(); + return result; + } + + Future> applyAction({ + required String documentId, + required Iterable actions, + }) async { + final payload = ApplyActionPayloadPB( + documentId: documentId, + actions: actions, + ); + final result = await DocumentEventApplyAction(payload).send(); + return result; + } + + /// Creates a new external text. + /// + /// Normally, it's used to the block that needs sync long text. + /// + /// the delta parameter is the json representation of the delta. + Future> createExternalText({ + required String documentId, + required String textId, + String? delta, + }) async { + final payload = TextDeltaPayloadPB( + documentId: documentId, + textId: textId, + delta: delta, + ); + final result = await DocumentEventCreateText(payload).send(); + return result; + } + + /// Updates the external text. + /// + /// this function is compatible with the [createExternalText] function. + /// + /// the delta parameter is the json representation of the delta too. + Future> updateExternalText({ + required String documentId, + required String textId, + String? delta, + }) async { + final payload = TextDeltaPayloadPB( + documentId: documentId, + textId: textId, + delta: delta, + ); + final result = await DocumentEventApplyTextDeltaEvent(payload).send(); + return result; + } + + /// Upload a file to the cloud storage. + Future> uploadFile({ + required String localFilePath, + required String documentId, + }) async { + final workspace = await FolderEventReadCurrentWorkspace().send(); + return workspace.fold( + (l) async { + final payload = UploadFileParamsPB( + workspaceId: l.id, + localFilePath: localFilePath, + documentId: documentId, + ); + return DocumentEventUploadFile(payload).send(); + }, + (r) async { + return FlowyResult.failure(FlowyError(msg: 'Workspace not found')); + }, + ); + } + + /// Download a file from the cloud storage. + Future> downloadFile({ + required String url, + }) async { + final workspace = await FolderEventReadCurrentWorkspace().send(); + return workspace.fold((l) async { + final payload = DownloadFilePB( + url: url, + ); + final result = await DocumentEventDownloadFile(payload).send(); + return result; + }, (r) async { + return FlowyResult.failure(FlowyError(msg: 'Workspace not found')); + }); + } + + /// Sync the awareness states + /// For example, the cursor position, selection, who is viewing the document. + Future> syncAwarenessStates({ + required String documentId, + Selection? selection, + String? metadata, + }) async { + final payload = UpdateDocumentAwarenessStatePB( + documentId: documentId, + selection: convertSelectionToAwarenessSelection(selection), + metadata: metadata, + ); + + final result = await DocumentEventSetAwarenessState(payload).send(); + return result; + } + + DocumentAwarenessSelectionPB? convertSelectionToAwarenessSelection( + Selection? selection, + ) { + if (selection == null) { + return null; + } + return DocumentAwarenessSelectionPB( + start: DocumentAwarenessPositionPB( + offset: Int64(selection.startIndex), + path: selection.start.path.map((e) => Int64(e)), + ), + end: DocumentAwarenessPositionPB( + offset: Int64(selection.endIndex), + path: selection.end.path.map((e) => Int64(e)), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart new file mode 100644 index 0000000000000..0fae90920dec7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_sync_bloc.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/document/application/doc_sync_state_listener.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'document_sync_bloc.freezed.dart'; + +class DocumentSyncBloc extends Bloc { + DocumentSyncBloc({ + required this.view, + }) : _syncStateListener = DocumentSyncStateListener(id: view.id), + super(DocumentSyncBlocState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + final userProfile = await getIt().getUser().then( + (result) => result.fold( + (l) => l, + (r) => null, + ), + ); + emit( + state.copyWith( + shouldShowIndicator: + userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud, + ), + ); + _syncStateListener.start( + didReceiveSyncState: (syncState) { + add(DocumentSyncEvent.syncStateChanged(syncState)); + }, + ); + + final isNetworkConnected = await _connectivity + .checkConnectivity() + .then((value) => value != ConnectivityResult.none); + emit(state.copyWith(isNetworkConnected: isNetworkConnected)); + + connectivityStream = + _connectivity.onConnectivityChanged.listen((result) { + add(DocumentSyncEvent.networkStateChanged(result)); + }); + }, + syncStateChanged: (syncState) { + emit(state.copyWith(syncState: syncState.value)); + }, + networkStateChanged: (result) { + emit( + state.copyWith( + isNetworkConnected: result != ConnectivityResult.none, + ), + ); + }, + ); + }, + ); + } + + final ViewPB view; + final DocumentSyncStateListener _syncStateListener; + final _connectivity = Connectivity(); + + StreamSubscription? connectivityStream; + + @override + Future close() async { + await connectivityStream?.cancel(); + await _syncStateListener.stop(); + return super.close(); + } +} + +@freezed +class DocumentSyncEvent with _$DocumentSyncEvent { + const factory DocumentSyncEvent.initial() = Initial; + const factory DocumentSyncEvent.syncStateChanged( + DocumentSyncStatePB syncState, + ) = syncStateChanged; + const factory DocumentSyncEvent.networkStateChanged( + ConnectivityResult result, + ) = NetworkStateChanged; +} + +@freezed +class DocumentSyncBlocState with _$DocumentSyncBlocState { + const factory DocumentSyncBlocState({ + required DocumentSyncState syncState, + @Default(true) bool isNetworkConnected, + @Default(false) bool shouldShowIndicator, + }) = _DocumentSyncState; + + factory DocumentSyncBlocState.initial() => const DocumentSyncBlocState( + syncState: DocumentSyncState.Syncing, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_validator.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_validator.dart new file mode 100644 index 0000000000000..8fb2e8008d3ca --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_validator.dart @@ -0,0 +1,77 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +class DocumentValidator { + const DocumentValidator({ + required this.editorState, + required this.rules, + }); + + final EditorState editorState; + final List rules; + + Future validate(Transaction transaction) async { + // deep copy the document + final root = this.editorState.document.root.deepCopy(); + final dummyDocument = Document(root: root); + if (dummyDocument.isEmpty) { + return true; + } + + final editorState = EditorState(document: dummyDocument); + await editorState.apply(transaction); + + final iterator = NodeIterator( + document: editorState.document, + startNode: editorState.document.root, + ); + + for (final rule in rules) { + while (iterator.moveNext()) { + if (!rule.validate(iterator.current)) { + return false; + } + } + } + + return true; + } + + Future applyTransactionInDummyDocument(Transaction transaction) async { + // deep copy the document + final root = this.editorState.document.root.deepCopy(); + final dummyDocument = Document(root: root); + if (dummyDocument.isEmpty) { + return true; + } + + final editorState = EditorState(document: dummyDocument); + await editorState.apply(transaction); + + final iterator = NodeIterator( + document: editorState.document, + startNode: editorState.document.root, + ); + + for (final rule in rules) { + while (iterator.moveNext()) { + if (!rule.validate(iterator.current)) { + return false; + } + } + } + + return true; + } +} + +class DocumentRule { + const DocumentRule({ + required this.type, + }); + + final String type; + + bool validate(Node node) { + return true; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart b/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart new file mode 100644 index 0000000000000..d66110d95042b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/editor_transaction_adapter.dart @@ -0,0 +1,415 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/application/document_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_component.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + show + EditorState, + Transaction, + Operation, + InsertOperation, + UpdateOperation, + DeleteOperation, + PathExtensions, + Node, + Path, + Delta, + composeAttributes, + blockComponentDelta; +import 'package:collection/collection.dart'; +import 'package:nanoid/nanoid.dart'; + +const kExternalTextType = 'text'; + +/// Uses to adjust the data structure between the editor and the backend. +/// +/// The editor uses a tree structure to represent the document, while the backend uses a flat structure. +/// This adapter is used to convert the editor's transaction to the backend's transaction. +class TransactionAdapter { + TransactionAdapter({ + required this.documentId, + required this.documentService, + }); + + final DocumentService documentService; + final String documentId; + + Future apply(Transaction transaction, EditorState editorState) async { + if (enableDocumentInternalLog) { + Log.info( + '[TransactionAdapter] 2. apply transaction begin ${transaction.hashCode} in $hashCode', + ); + } + + await _applyInternal(transaction, editorState); + + if (enableDocumentInternalLog) { + Log.info( + '[TransactionAdapter] 3. apply transaction end ${transaction.hashCode} in $hashCode', + ); + } + } + + Future _applyInternal( + Transaction transaction, + EditorState editorState, + ) async { + final stopwatch = Stopwatch()..start(); + if (enableDocumentInternalLog) { + Log.info('transaction => ${transaction.toJson()}'); + } + + final actions = transactionToBlockActions(transaction, editorState); + final textActions = filterTextDeltaActions(actions); + + final actionCostTime = stopwatch.elapsedMilliseconds; + for (final textAction in textActions) { + final payload = textAction.textDeltaPayloadPB!; + final type = textAction.textDeltaType; + if (type == TextDeltaType.create) { + await documentService.createExternalText( + documentId: payload.documentId, + textId: payload.textId, + delta: payload.delta, + ); + if (enableDocumentInternalLog) { + Log.info( + '[editor_transaction_adapter] create external text: id: ${payload.textId} delta: ${payload.delta}', + ); + } + } else if (type == TextDeltaType.update) { + await documentService.updateExternalText( + documentId: payload.documentId, + textId: payload.textId, + delta: payload.delta, + ); + if (enableDocumentInternalLog) { + Log.info( + '[editor_transaction_adapter] update external text: id: ${payload.textId} delta: ${payload.delta}', + ); + } + } + } + + final blockActions = filterBlockActions(actions); + + for (final action in blockActions) { + if (enableDocumentInternalLog) { + Log.info( + '[editor_transaction_adapter] action => ${action.toProto3Json()}', + ); + } + } + + await documentService.applyAction( + documentId: documentId, + actions: blockActions, + ); + + final elapsed = stopwatch.elapsedMilliseconds; + stopwatch.stop(); + if (enableDocumentInternalLog) { + Log.info( + '[editor_transaction_adapter] apply transaction cost: total $elapsed ms, converter action $actionCostTime ms, apply action ${elapsed - actionCostTime} ms', + ); + } + } + + List transactionToBlockActions( + Transaction transaction, + EditorState editorState, + ) { + return transaction.operations + .map((op) => op.toBlockAction(editorState, documentId)) + .whereNotNull() + .expand((element) => element) + .toList(growable: false); // avoid lazy evaluation + } + + List filterTextDeltaActions( + List actions, + ) { + return actions + .where( + (e) => + e.textDeltaType != TextDeltaType.none && + e.textDeltaPayloadPB != null, + ) + .toList(growable: false); + } + + List filterBlockActions( + List actions, + ) { + return actions.map((e) => e.blockActionPB).toList(growable: false); + } +} + +extension BlockAction on Operation { + List toBlockAction( + EditorState editorState, + String documentId, + ) { + final op = this; + if (op is InsertOperation) { + return op.toBlockAction(editorState, documentId); + } else if (op is UpdateOperation) { + return op.toBlockAction(editorState, documentId); + } else if (op is DeleteOperation) { + return op.toBlockAction(editorState); + } + throw UnimplementedError(); + } +} + +extension on InsertOperation { + List toBlockAction( + EditorState editorState, + String documentId, { + Node? previousNode, + }) { + Path currentPath = path; + final List actions = []; + for (final node in nodes) { + if (node.type == AskAIBlockKeys.type) { + continue; + } + + final parentId = node.parent?.id ?? + editorState.getNodeAtPath(currentPath.parent)?.id ?? + ''; + assert(parentId.isNotEmpty); + + String prevId = ''; + // if the node is the first child of the parent, then its prevId should be empty. + final isFirstChild = currentPath.previous.equals(currentPath); + + if (!isFirstChild) { + prevId = previousNode?.id ?? + editorState.getNodeAtPath(currentPath.previous)?.id ?? + ''; + assert(prevId.isNotEmpty && prevId != node.id); + } + + // create the external text if the node contains the delta in its data. + final delta = node.delta; + TextDeltaPayloadPB? textDeltaPayloadPB; + String? textId; + if (delta != null) { + textId = nanoid(6); + + textDeltaPayloadPB = TextDeltaPayloadPB( + documentId: documentId, + textId: textId, + delta: jsonEncode(node.delta!.toJson()), + ); + + // sync the text id to the node + node.externalValues = ExternalValues( + externalId: textId, + externalType: kExternalTextType, + ); + } + + // remove the delta from the data when the incremental update is stable. + final payload = BlockActionPayloadPB() + ..block = node.toBlock( + childrenId: nanoid(6), + externalId: textId, + externalType: textId != null ? kExternalTextType : null, + attributes: {...node.attributes}..remove(blockComponentDelta), + ) + ..parentId = parentId + ..prevId = prevId; + + // pass the external text id to the payload. + if (textDeltaPayloadPB != null) { + payload.textId = textDeltaPayloadPB.textId; + } + + assert(payload.block.childrenId.isNotEmpty); + final blockActionPB = BlockActionPB() + ..action = BlockActionTypePB.Insert + ..payload = payload; + + actions.add( + BlockActionWrapper( + blockActionPB: blockActionPB, + textDeltaPayloadPB: textDeltaPayloadPB, + textDeltaType: TextDeltaType.create, + ), + ); + if (node.children.isNotEmpty) { + Node? prevChild; + for (final child in node.children) { + actions.addAll( + InsertOperation(currentPath + child.path, [child]).toBlockAction( + editorState, + documentId, + previousNode: prevChild, + ), + ); + prevChild = child; + } + } + previousNode = node; + currentPath = currentPath.next; + } + return actions; + } +} + +extension on UpdateOperation { + List toBlockAction( + EditorState editorState, + String documentId, + ) { + final List actions = []; + + // if the attributes are both empty, we don't need to update + if (const DeepCollectionEquality().equals(attributes, oldAttributes)) { + return actions; + } + final node = editorState.getNodeAtPath(path); + if (node == null) { + assert(false, 'node not found at path: $path'); + return actions; + } + final parentId = + node.parent?.id ?? editorState.getNodeAtPath(path.parent)?.id ?? ''; + assert(parentId.isNotEmpty); + + // create the external text if the node contains the delta in its data. + final prevDelta = oldAttributes[blockComponentDelta]; + final delta = attributes[blockComponentDelta]; + final diff = prevDelta != null && delta != null + ? Delta.fromJson(prevDelta).diff( + Delta.fromJson(delta), + ) + : null; + + final composedAttributes = composeAttributes(oldAttributes, attributes); + final composedDelta = composedAttributes?[blockComponentDelta]; + composedAttributes?.remove(blockComponentDelta); + + final payload = BlockActionPayloadPB() + ..block = node.toBlock( + parentId: parentId, + attributes: composedAttributes, + ) + ..parentId = parentId; + final blockActionPB = BlockActionPB() + ..action = BlockActionTypePB.Update + ..payload = payload; + + final textId = (node.externalValues as ExternalValues?)?.externalId; + if (textId == null || textId.isEmpty) { + // to be compatible with the old version, we create a new text id if the text id is empty. + final textId = nanoid(6); + final textDelta = composedDelta ?? delta ?? prevDelta; + final textDeltaPayloadPB = textDelta == null + ? null + : TextDeltaPayloadPB( + documentId: documentId, + textId: textId, + delta: jsonEncode(textDelta), + ); + + node.externalValues = ExternalValues( + externalId: textId, + externalType: kExternalTextType, + ); + + if (enableDocumentInternalLog) { + Log.info('create text delta: $textDeltaPayloadPB'); + } + + // update the external text id and external type to the block + blockActionPB.payload.block + ..externalId = textId + ..externalType = kExternalTextType; + + actions.add( + BlockActionWrapper( + blockActionPB: blockActionPB, + textDeltaPayloadPB: textDeltaPayloadPB, + textDeltaType: TextDeltaType.create, + ), + ); + } else { + final textDeltaPayloadPB = delta == null + ? null + : TextDeltaPayloadPB( + documentId: documentId, + textId: textId, + delta: jsonEncode(diff), + ); + + if (enableDocumentInternalLog) { + Log.info('update text delta: $textDeltaPayloadPB'); + } + + // update the external text id and external type to the block + blockActionPB.payload.block + ..externalId = textId + ..externalType = kExternalTextType; + + actions.add( + BlockActionWrapper( + blockActionPB: blockActionPB, + textDeltaPayloadPB: textDeltaPayloadPB, + textDeltaType: TextDeltaType.update, + ), + ); + } + + return actions; + } +} + +extension on DeleteOperation { + List toBlockAction(EditorState editorState) { + final List actions = []; + for (final node in nodes) { + final parentId = + node.parent?.id ?? editorState.getNodeAtPath(path.parent)?.id ?? ''; + final payload = BlockActionPayloadPB() + ..block = node.toBlock( + parentId: parentId, + ) + ..parentId = parentId; + assert(parentId.isNotEmpty); + actions.add( + BlockActionPB() + ..action = BlockActionTypePB.Delete + ..payload = payload, + ); + } + return actions + .map((e) => BlockActionWrapper(blockActionPB: e)) + .toList(growable: false); + } +} + +enum TextDeltaType { + none, + create, + update, +} + +class BlockActionWrapper { + BlockActionWrapper({ + required this.blockActionPB, + this.textDeltaType = TextDeltaType.none, + this.textDeltaPayloadPB, + }); + + final BlockActionPB blockActionPB; + final TextDeltaPayloadPB? textDeltaPayloadPB; + final TextDeltaType textDeltaType; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/prelude.dart b/frontend/appflowy_flutter/lib/plugins/document/application/prelude.dart new file mode 100644 index 0000000000000..a6497bf6de48f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/application/prelude.dart @@ -0,0 +1,3 @@ +export '../../shared/share/share_bloc.dart'; +export 'document_bloc.dart'; +export 'document_service.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/document.dart b/frontend/appflowy_flutter/lib/plugins/document/document.dart new file mode 100644 index 0000000000000..dcfc1906a373e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/document.dart @@ -0,0 +1,195 @@ +library document_plugin; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/document_page.dart'; +import 'package:appflowy/plugins/document/presentation/document_collaborators.dart'; +import 'package:appflowy/plugins/shared/share/share_button.dart'; +import 'package:appflowy/plugins/util.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.dart'; +import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart'; +import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DocumentPluginBuilder extends PluginBuilder { + @override + Plugin build(dynamic data) { + if (data is ViewPB) { + return DocumentPlugin(pluginType: pluginType, view: data); + } + + throw FlowyPluginException.invalidData; + } + + @override + String get menuName => LocaleKeys.document_menuName.tr(); + + @override + FlowySvgData get icon => FlowySvgs.icon_document_s; + + @override + PluginType get pluginType => PluginType.document; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Document; +} + +class DocumentPlugin extends Plugin { + DocumentPlugin({ + required ViewPB view, + required PluginType pluginType, + this.initialSelection, + this.initialBlockId, + }) : notifier = ViewPluginNotifier(view: view) { + _pluginType = pluginType; + } + + late PluginType _pluginType; + late final ViewInfoBloc _viewInfoBloc; + + @override + final ViewPluginNotifier notifier; + + // the initial selection of the document + final Selection? initialSelection; + + // the initial block id of the document + final String? initialBlockId; + + @override + PluginWidgetBuilder get widgetBuilder => DocumentPluginWidgetBuilder( + bloc: _viewInfoBloc, + notifier: notifier, + initialSelection: initialSelection, + initialBlockId: initialBlockId, + ); + + @override + PluginType get pluginType => _pluginType; + + @override + PluginId get id => notifier.view.id; + + @override + void init() { + _viewInfoBloc = ViewInfoBloc(view: notifier.view) + ..add(const ViewInfoEvent.started()); + } + + @override + void dispose() { + _viewInfoBloc.close(); + notifier.dispose(); + } +} + +class DocumentPluginWidgetBuilder extends PluginWidgetBuilder + with NavigationItem { + DocumentPluginWidgetBuilder({ + required this.bloc, + required this.notifier, + this.initialSelection, + this.initialBlockId, + }); + + final ViewInfoBloc bloc; + final ViewPluginNotifier notifier; + ViewPB get view => notifier.view; + int? deletedViewIndex; + final Selection? initialSelection; + final String? initialBlockId; + + @override + EdgeInsets get contentPadding => EdgeInsets.zero; + + @override + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + Map? data, + }) { + notifier.isDeleted.addListener(() { + final deletedView = notifier.isDeleted.value; + if (deletedView != null && deletedView.hasIndex()) { + deletedViewIndex = deletedView.index; + } + }); + + final fixedTitle = data?[MobileDocumentScreen.viewFixedTitle]; + final blockId = initialBlockId ?? data?[MobileDocumentScreen.viewBlockId]; + + return BlocProvider.value( + value: bloc, + child: BlocBuilder( + builder: (_, state) => DocumentPage( + key: ValueKey(view.id), + view: view, + onDeleted: () => context.onDeleted?.call(view, deletedViewIndex), + initialSelection: initialSelection, + initialBlockId: blockId, + fixedTitle: fixedTitle, + ), + ), + ); + } + + @override + String? get viewName => notifier.view.nameOrDefault; + + @override + Widget get leftBarItem => ViewTitleBar(key: ValueKey(view.id), view: view); + + @override + Widget tabBarItem(String pluginId, [bool shortForm = false]) => + ViewTabBarItem(view: notifier.view, shortForm: shortForm); + + @override + Widget? get rightBarItem { + return BlocProvider.value( + value: bloc, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...FeatureFlag.syncDocument.isOn + ? [ + DocumentCollaborators( + key: ValueKey('collaborators_${view.id}'), + width: 120, + height: 32, + view: view, + ), + const HSpace(16), + ] + : [const HSpace(8)], + ShareButton( + key: ValueKey('share_button_${view.id}'), + view: view, + ), + const HSpace(10), + ViewFavoriteButton( + key: ValueKey('favorite_button_${view.id}'), + view: view, + ), + const HSpace(4), + MoreViewActions(view: view), + ], + ), + ); + } + + @override + List get navigationItems => [this]; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart new file mode 100644 index 0000000000000..22080a94fc20b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -0,0 +1,329 @@ +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/banner.dart'; +import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class DocumentPage extends StatefulWidget { + const DocumentPage({ + super.key, + required this.view, + required this.onDeleted, + this.initialSelection, + this.initialBlockId, + this.fixedTitle, + }); + + final ViewPB view; + final VoidCallback onDeleted; + final Selection? initialSelection; + final String? initialBlockId; + final String? fixedTitle; + + @override + State createState() => _DocumentPageState(); +} + +class _DocumentPageState extends State + with WidgetsBindingObserver { + EditorState? editorState; + Selection? initialSelection; + late final documentBloc = DocumentBloc(documentId: widget.view.id) + ..add(const DocumentEvent.initial()); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + documentBloc.close(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + documentBloc.add(const DocumentEvent.clearAwarenessStates()); + } else if (state == AppLifecycleState.resumed) { + documentBloc.add(const DocumentEvent.syncAwarenessStates()); + } + } + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + BlocProvider.value(value: documentBloc), + ], + child: BlocBuilder( + buildWhen: shouldRebuildDocument, + builder: (context, state) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + + final editorState = state.editorState; + this.editorState = editorState; + final error = state.error; + if (error != null || editorState == null) { + Log.error(error); + return Center(child: AppFlowyErrorPage(error: error)); + } + + if (state.forceClose) { + widget.onDeleted(); + return const SizedBox.shrink(); + } + + return BlocListener( + listenWhen: (_, curr) => curr.action != null, + listener: onNotificationAction, + child: buildEditorPage(context, state), + ); + }, + ), + ); + } + + Widget buildEditorPage( + BuildContext context, + DocumentState state, + ) { + final editorState = state.editorState; + if (editorState == null) { + return const SizedBox.shrink(); + } + + final width = context.read().state.width; + + // avoid the initial selection calculation change when the editorState is not changed + initialSelection ??= _calculateInitialSelection(editorState); + + final Widget child; + if (UniversalPlatform.isMobile) { + child = BlocBuilder( + builder: (context, styleState) => AppFlowyEditorPage( + editorState: editorState, + // if the view's name is empty, focus on the title + autoFocus: widget.view.name.isEmpty ? false : null, + styleCustomizer: EditorStyleCustomizer( + context: context, + width: width, + padding: EditorStyleCustomizer.documentPadding, + ), + header: buildCoverAndIcon(context, state), + initialSelection: initialSelection, + ), + ); + } else { + child = EditorDropHandler( + viewId: widget.view.id, + editorState: editorState, + isLocalMode: context.read().isLocalMode, + child: AppFlowyEditorPage( + editorState: editorState, + // if the view's name is empty, focus on the title + autoFocus: widget.view.name.isEmpty ? false : null, + styleCustomizer: EditorStyleCustomizer( + context: context, + width: width, + padding: EditorStyleCustomizer.documentPadding, + ), + header: buildCoverAndIcon(context, state), + initialSelection: initialSelection, + ), + ); + } + + return Provider( + create: (_) { + final context = SharedEditorContext(); + final children = editorState.document.root.children; + final firstDelta = children.firstOrNull?.delta; + final isEmptyDocument = + children.length == 1 && (firstDelta == null || firstDelta.isEmpty); + if (widget.view.name.isEmpty && isEmptyDocument) { + context.requestCoverTitleFocus = true; + } + return context; + }, + dispose: (buildContext, editorContext) => editorContext.dispose(), + child: EditorTransactionService( + viewId: widget.view.id, + editorState: state.editorState!, + child: Column( + children: [ + if (state.isDeleted) buildBanner(context), + Expanded(child: child), + ], + ), + ), + ); + } + + Widget buildBanner(BuildContext context) { + return DocumentBanner( + viewName: widget.view.nameOrDefault, + onRestore: () => + context.read().add(const DocumentEvent.restorePage()), + onDelete: () => context + .read() + .add(const DocumentEvent.deletePermanently()), + ); + } + + Widget buildCoverAndIcon(BuildContext context, DocumentState state) { + final editorState = state.editorState; + final userProfilePB = state.userProfilePB; + if (editorState == null || userProfilePB == null) { + return const SizedBox.shrink(); + } + + if (UniversalPlatform.isMobile) { + return DocumentImmersiveCover( + fixedTitle: widget.fixedTitle, + view: widget.view, + userProfilePB: userProfilePB, + ); + } + + final page = editorState.document.root; + return DocumentCoverWidget( + node: page, + editorState: editorState, + view: widget.view, + onIconChanged: (icon) async => ViewBackendService.updateViewIcon( + viewId: widget.view.id, + viewIcon: icon, + ), + ); + } + + void onNotificationAction( + BuildContext context, + ActionNavigationState state, + ) { + final action = state.action; + if (action == null || + action.type != ActionType.jumpToBlock || + action.objectId != widget.view.id) { + return; + } + + final editorState = context.read().state.editorState; + if (editorState == null) { + return; + } + + final Path? path = _getPathFromAction(action, editorState); + if (path != null) { + editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: path)), + ); + } + } + + Path? _getPathFromAction(NavigationAction action, EditorState editorState) { + Path? path = action.arguments?[ActionArgumentKeys.nodePath]; + if (path == null || path.isEmpty) { + final blockId = action.arguments?[ActionArgumentKeys.blockId]; + if (blockId != null) { + path = _findNodePathByBlockId(editorState, blockId); + } + } + return path; + } + + Path? _findNodePathByBlockId(EditorState editorState, String blockId) { + final document = editorState.document; + final startNode = document.root.children.firstOrNull; + if (startNode == null) { + return null; + } + + final nodeIterator = NodeIterator(document: document, startNode: startNode); + while (nodeIterator.moveNext()) { + final node = nodeIterator.current; + if (node.id == blockId) { + return node.path; + } + } + + return null; + } + + bool shouldRebuildDocument(DocumentState previous, DocumentState current) { + // only rebuild the document page when the below fields are changed + // this is to prevent unnecessary rebuilds + // + // If you confirm the newly added fields should be rebuilt, please update + // this function. + if (previous.editorState != current.editorState) { + return true; + } + + if (previous.forceClose != current.forceClose || + previous.isDeleted != current.isDeleted) { + return true; + } + + if (previous.userProfilePB != current.userProfilePB) { + return true; + } + + if (previous.isLoading != current.isLoading || + previous.error != current.error) { + return true; + } + + return false; + } + + Selection? _calculateInitialSelection(EditorState editorState) { + if (widget.initialSelection != null) { + return widget.initialSelection; + } + + if (widget.initialBlockId != null) { + final path = _findNodePathByBlockId(editorState, widget.initialBlockId!); + if (path != null) { + editorState.selectionType = SelectionType.block; + editorState.selectionExtraInfo = { + selectionExtraInfoDoNotAttachTextService: true, + }; + return Selection.collapsed( + Position( + path: path, + ), + ); + } + } + + return null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/banner.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/banner.dart new file mode 100644 index 0000000000000..856763e9b95e5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/banner.dart @@ -0,0 +1,86 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/buttons/base_styled_button.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; + +class DocumentBanner extends StatelessWidget { + const DocumentBanner({ + super.key, + required this.viewName, + required this.onRestore, + required this.onDelete, + }); + + final String viewName; + final void Function() onRestore; + final void Function() onDelete; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 60), + child: Container( + width: double.infinity, + color: colorScheme.surfaceContainerHighest, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + children: [ + FlowyText.medium( + LocaleKeys.deletePagePrompt_text.tr(), + color: colorScheme.tertiary, + fontSize: 14, + ), + const HSpace(20), + BaseStyledButton( + minWidth: 160, + minHeight: 40, + contentPadding: EdgeInsets.zero, + bgColor: Colors.transparent, + highlightColor: Theme.of(context).colorScheme.onErrorContainer, + outlineColor: colorScheme.tertiaryContainer, + borderRadius: Corners.s8Border, + onPressed: onRestore, + child: FlowyText.medium( + LocaleKeys.deletePagePrompt_restore.tr(), + color: colorScheme.tertiary, + fontSize: 13, + ), + ), + const HSpace(20), + BaseStyledButton( + minWidth: 220, + minHeight: 40, + contentPadding: EdgeInsets.zero, + bgColor: Colors.transparent, + highlightColor: Theme.of(context).colorScheme.error, + outlineColor: colorScheme.tertiaryContainer, + borderRadius: Corners.s8Border, + onPressed: () => showConfirmDeletionDialog( + context: context, + name: viewName.trim().isEmpty + ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() + : viewName, + description: LocaleKeys + .deletePagePrompt_deletePermanentDescription + .tr(), + onConfirm: onDelete, + ), + child: FlowyText.medium( + LocaleKeys.deletePagePrompt_deletePermanent.tr(), + color: colorScheme.tertiary, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avater_stack.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avater_stack.dart new file mode 100644 index 0000000000000..8fa15af8b23ab --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/collaborator_avater_stack.dart @@ -0,0 +1,64 @@ +import 'package:avatar_stack/avatar_stack.dart'; +import 'package:avatar_stack/positions.dart'; +import 'package:flutter/material.dart'; + +class CollaboratorAvatarStack extends StatelessWidget { + const CollaboratorAvatarStack({ + super.key, + required this.avatars, + this.settings, + this.infoWidgetBuilder, + this.width, + this.height, + this.borderWidth, + this.borderColor, + this.backgroundColor, + required this.plusWidgetBuilder, + }); + + final List avatars; + final Positions? settings; + final InfoWidgetBuilder? infoWidgetBuilder; + final double? width; + final double? height; + final double? borderWidth; + final Color? borderColor; + final Color? backgroundColor; + final Widget Function(int value, BorderSide border) plusWidgetBuilder; + + @override + Widget build(BuildContext context) { + final settings = this.settings ?? + RestrictedPositions( + maxCoverage: 0.4, + minCoverage: 0.3, + align: StackAlign.right, + laying: StackLaying.first, + ); + + final border = BorderSide( + color: borderColor ?? Theme.of(context).dividerColor, + width: borderWidth ?? 2.0, + ); + + return SizedBox( + height: height, + width: width, + child: WidgetStack( + positions: settings, + buildInfoWidget: (value) => plusWidgetBuilder(value, border), + stackedWidgets: avatars + .map( + (avatar) => CircleAvatar( + backgroundColor: border.color, + child: Padding( + padding: EdgeInsets.all(border.width), + child: avatar, + ), + ), + ) + .toList(), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart new file mode 100644 index 0000000000000..42d05c0f9ea96 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/document_collaborators.dart @@ -0,0 +1,133 @@ +import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart'; +import 'package:appflowy/plugins/document/application/document_collaborators_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/collaborator_avater_stack.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:avatar_stack/avatar_stack.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:string_validator/string_validator.dart'; + +class DocumentCollaborators extends StatelessWidget { + const DocumentCollaborators({ + super.key, + required this.height, + required this.width, + required this.view, + this.padding, + this.fontSize, + }); + + final ViewPB view; + final double height; + final double width; + final EdgeInsets? padding; + final double? fontSize; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DocumentCollaboratorsBloc(view: view) + ..add(const DocumentCollaboratorsEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final collaborators = state.collaborators; + if (!state.shouldShowIndicator || collaborators.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: padding ?? EdgeInsets.zero, + child: CollaboratorAvatarStack( + height: height, + width: width, + borderWidth: 1.0, + plusWidgetBuilder: (value, border) { + final lastXCollaborators = collaborators.sublist( + collaborators.length - value, + ); + return BorderedCircleAvatar( + border: border, + backgroundColor: Theme.of(context).hoverColor, + child: FittedBox( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: FlowyTooltip( + message: lastXCollaborators + .map((e) => e.userName) + .join('\n'), + child: FlowyText( + '+$value', + fontSize: fontSize, + color: Colors.black, + ), + ), + ), + ), + ); + }, + avatars: [ + ...collaborators.map( + (c) => _UserAvatar(fontSize: fontSize, user: c, width: width), + ), + ], + ), + ); + }, + ), + ); + } +} + +class _UserAvatar extends StatelessWidget { + const _UserAvatar({ + this.fontSize, + required this.user, + required this.width, + }); + + final DocumentAwarenessMetadata user; + final double? fontSize; + final double width; + + @override + Widget build(BuildContext context) { + final Widget child; + if (isURL(user.userAvatar)) { + child = _buildUrlAvatar(context); + } else { + child = _buildNameAvatar(context); + } + return FlowyTooltip( + message: user.userName, + child: child, + ); + } + + Widget _buildNameAvatar(BuildContext context) { + return CircleAvatar( + backgroundColor: user.cursorColor.tryToColor(), + child: FlowyText( + user.userName.characters.firstOrNull ?? ' ', + fontSize: fontSize, + color: Colors.black, + ), + ); + } + + Widget _buildUrlAvatar(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(width), + child: CircleAvatar( + backgroundColor: user.cursorColor.tryToColor(), + child: Image.network( + user.userAvatar, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _buildNameAvatar(context), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart new file mode 100644 index 0000000000000..c1fa69913cc97 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -0,0 +1,915 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart' hide TextDirection; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +/// The node types that support slash menu. +final Set supportSlashMenuNodeTypes = { + ParagraphBlockKeys.type, + HeadingBlockKeys.type, + + // Lists + TodoListBlockKeys.type, + BulletedListBlockKeys.type, + NumberedListBlockKeys.type, + QuoteBlockKeys.type, + ToggleListBlockKeys.type, + + // Simple table + SimpleTableBlockKeys.type, + SimpleTableRowBlockKeys.type, + SimpleTableCellBlockKeys.type, +}; + +/// Build the block component builders. +/// +/// Every block type should have a corresponding builder in the map. +/// Otherwise, the errorBlockComponentBuilder will be rendered. +/// +/// Additional, you can define the block render options in the builder +/// - customize the block option actions. (... button and + button) +/// - customize the block component configuration. (padding, placeholder, etc.) +/// - customize the block icon. (bulleted list, numbered list, todo list) +/// - customize the hover menu. (show the menu at the top-right corner of the block) +Map buildBlockComponentBuilders({ + required BuildContext context, + required EditorState editorState, + required EditorStyleCustomizer styleCustomizer, + SlashMenuItemsBuilder? slashMenuItemsBuilder, + bool editable = true, + ShowPlaceholder? showParagraphPlaceholder, + String Function(Node)? placeholderText, + EdgeInsets? customHeadingPadding, +}) { + final configuration = _buildDefaultConfiguration(context); + final builders = _buildBlockComponentBuilderMap( + context, + configuration: configuration, + editorState: editorState, + styleCustomizer: styleCustomizer, + showParagraphPlaceholder: showParagraphPlaceholder, + placeholderText: placeholderText, + ); + + // customize the action builder. actually, we can customize them in their own builder. Put them here just for convenience. + if (editable) { + _customBlockOptionActions( + context, + builders: builders, + editorState: editorState, + styleCustomizer: styleCustomizer, + slashMenuItemsBuilder: slashMenuItemsBuilder, + ); + } + + return builders; +} + +BlockComponentConfiguration _buildDefaultConfiguration(BuildContext context) { + final configuration = BlockComponentConfiguration( + padding: (node) { + if (UniversalPlatform.isMobile) { + final pageStyle = context.read().state; + final factor = pageStyle.fontLayout.factor; + final top = pageStyle.lineHeightLayout.padding * factor; + EdgeInsets edgeInsets = EdgeInsets.only(top: top); + // only add padding for the top level node, otherwise the nested node will have extra padding + if (node.path.length == 1) { + if (node.type != SimpleTableBlockKeys.type) { + // do not add padding for the simple table to allow it overflow + edgeInsets = edgeInsets.copyWith( + left: EditorStyleCustomizer.nodeHorizontalPadding, + ); + } + edgeInsets = edgeInsets.copyWith( + right: EditorStyleCustomizer.nodeHorizontalPadding, + ); + } + return edgeInsets; + } + + return const EdgeInsets.symmetric(vertical: 5.0); + }, + indentPadding: (node, textDirection) => textDirection == TextDirection.ltr + ? const EdgeInsets.only(left: 26.0) + : const EdgeInsets.only(right: 26.0), + ); + return configuration; +} + +/// Build the option actions for the block component. +/// +/// Notes: different block type may have different option actions. +/// All the block types have the delete and duplicate options. +List _buildOptionActions(BuildContext context, String type) { + final standardActions = [ + OptionAction.delete, + OptionAction.duplicate, + ]; + + // filter out the copy link to block option if in local mode + if (context.read()?.isLocalMode != true) { + standardActions.add(OptionAction.copyLinkToBlock); + } + + standardActions.add(OptionAction.turnInto); + + if (SimpleTableBlockKeys.type == type) { + standardActions.addAll([ + OptionAction.divider, + OptionAction.setToPageWidth, + OptionAction.distributeColumnsEvenly, + ]); + } + + if (EditorOptionActionType.color.supportTypes.contains(type)) { + standardActions.addAll([OptionAction.divider, OptionAction.color]); + } + + if (EditorOptionActionType.align.supportTypes.contains(type)) { + standardActions.addAll([OptionAction.divider, OptionAction.align]); + } + + if (EditorOptionActionType.depth.supportTypes.contains(type)) { + standardActions.addAll([OptionAction.divider, OptionAction.depth]); + } + + return standardActions; +} + +void _customBlockOptionActions( + BuildContext context, { + required Map builders, + required EditorState editorState, + required EditorStyleCustomizer styleCustomizer, + SlashMenuItemsBuilder? slashMenuItemsBuilder, +}) { + for (final entry in builders.entries) { + if (entry.key == PageBlockKeys.type) { + continue; + } + final builder = entry.value; + final actions = _buildOptionActions(context, entry.key); + + if (UniversalPlatform.isDesktop) { + builder.showActions = (node) { + final parentTableNode = node.parentTableNode; + // disable the option action button in table cell to avoid the misalignment issue + if (node.type != SimpleTableBlockKeys.type && parentTableNode != null) { + return false; + } + return true; + }; + + builder.configuration = builder.configuration.copyWith( + blockSelectionAreaMargin: (_) => const EdgeInsets.symmetric( + vertical: 1, + ), + ); + + builder.actionBuilder = (context, state) { + double top = builder.configuration.padding(context.node).top; + final type = context.node.type; + final level = context.node.attributes[HeadingBlockKeys.level] ?? 0; + if ((type == HeadingBlockKeys.type || + type == ToggleListBlockKeys.type) && + level > 0) { + final offset = [13.0, 11.0, 8.0, 6.0, 4.0, 2.0]; + top += offset[level - 1]; + } else if (type == SimpleTableBlockKeys.type) { + top += 8.0; + } else { + top += 2.0; + } + return Padding( + padding: EdgeInsets.only(top: top), + child: BlockActionList( + blockComponentContext: context, + blockComponentState: state, + editorState: editorState, + blockComponentBuilder: builders, + actions: actions, + showSlashMenu: slashMenuItemsBuilder != null + ? () => customAppFlowySlashCommand( + itemsBuilder: slashMenuItemsBuilder, + shouldInsertSlash: false, + deleteKeywordsByDefault: true, + style: styleCustomizer.selectionMenuStyleBuilder(), + supportSlashMenuNodeTypes: supportSlashMenuNodeTypes, + ).handler.call(editorState) + : () {}, + ), + ); + }; + } + } +} + +Map _buildBlockComponentBuilderMap( + BuildContext context, { + required BlockComponentConfiguration configuration, + required EditorState editorState, + required EditorStyleCustomizer styleCustomizer, + ShowPlaceholder? showParagraphPlaceholder, + String Function(Node)? placeholderText, + EdgeInsets? customHeadingPadding, +}) { + final customBlockComponentBuilderMap = { + PageBlockKeys.type: PageBlockComponentBuilder(), + ParagraphBlockKeys.type: _buildParagraphBlockComponentBuilder( + context, + configuration, + showParagraphPlaceholder, + placeholderText, + ), + TodoListBlockKeys.type: _buildTodoListBlockComponentBuilder( + context, + configuration, + ), + BulletedListBlockKeys.type: _buildBulletedListBlockComponentBuilder( + context, + configuration, + ), + NumberedListBlockKeys.type: _buildNumberedListBlockComponentBuilder( + context, + configuration, + ), + QuoteBlockKeys.type: _buildQuoteBlockComponentBuilder( + context, + configuration, + ), + HeadingBlockKeys.type: _buildHeadingBlockComponentBuilder( + context, + configuration, + styleCustomizer, + customHeadingPadding, + ), + ImageBlockKeys.type: _buildCustomImageBlockComponentBuilder( + context, + configuration, + ), + MultiImageBlockKeys.type: _buildMultiImageBlockComponentBuilder( + context, + configuration, + ), + TableBlockKeys.type: _buildTableBlockComponentBuilder( + context, + configuration, + ), + TableCellBlockKeys.type: _buildTableCellBlockComponentBuilder( + context, + configuration, + ), + DatabaseBlockKeys.gridType: _buildDatabaseViewBlockComponentBuilder( + context, + configuration, + ), + DatabaseBlockKeys.boardType: _buildDatabaseViewBlockComponentBuilder( + context, + configuration, + ), + DatabaseBlockKeys.calendarType: _buildDatabaseViewBlockComponentBuilder( + context, + configuration, + ), + CalloutBlockKeys.type: _buildCalloutBlockComponentBuilder( + context, + configuration, + ), + DividerBlockKeys.type: _buildDividerBlockComponentBuilder( + context, + configuration, + editorState, + ), + MathEquationBlockKeys.type: _buildMathEquationBlockComponentBuilder( + context, + configuration, + ), + CodeBlockKeys.type: _buildCodeBlockComponentBuilder( + context, + configuration, + styleCustomizer, + ), + AIWriterBlockKeys.type: _buildAIWriterBlockComponentBuilder( + context, + configuration, + ), + AskAIBlockKeys.type: _buildAskAIBlockComponentBuilder( + context, + configuration, + ), + ToggleListBlockKeys.type: _buildToggleListBlockComponentBuilder( + context, + configuration, + styleCustomizer, + customHeadingPadding, + ), + OutlineBlockKeys.type: _buildOutlineBlockComponentBuilder( + context, + configuration, + styleCustomizer, + ), + LinkPreviewBlockKeys.type: _buildLinkPreviewBlockComponentBuilder( + context, + configuration, + ), + FileBlockKeys.type: _buildFileBlockComponentBuilder( + context, + configuration, + ), + SubPageBlockKeys.type: _buildSubPageBlockComponentBuilder( + context, + configuration, + styleCustomizer: styleCustomizer, + ), + errorBlockComponentBuilderKey: ErrorBlockComponentBuilder( + configuration: configuration, + ), + SimpleTableBlockKeys.type: _buildSimpleTableBlockComponentBuilder( + context, + configuration, + ), + SimpleTableRowBlockKeys.type: _buildSimpleTableRowBlockComponentBuilder( + context, + configuration, + ), + SimpleTableCellBlockKeys.type: _buildSimpleTableCellBlockComponentBuilder( + context, + configuration, + ), + }; + + final builders = { + ...standardBlockComponentBuilderMap, + ...customBlockComponentBuilderMap, + }; + + return builders; +} + +SimpleTableBlockComponentBuilder _buildSimpleTableBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + final copiedConfiguration = configuration.copyWith( + padding: (node) { + final padding = configuration.padding(node); + if (UniversalPlatform.isDesktop) { + return padding; + } else { + return padding; + } + }, + ); + return SimpleTableBlockComponentBuilder(configuration: copiedConfiguration); +} + +SimpleTableRowBlockComponentBuilder _buildSimpleTableRowBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return SimpleTableRowBlockComponentBuilder(configuration: configuration); +} + +SimpleTableCellBlockComponentBuilder _buildSimpleTableCellBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return SimpleTableCellBlockComponentBuilder(configuration: configuration); +} + +ParagraphBlockComponentBuilder _buildParagraphBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, + ShowPlaceholder? showParagraphPlaceholder, + String Function(Node)? placeholderText, +) { + return ParagraphBlockComponentBuilder( + configuration: configuration.copyWith( + placeholderText: placeholderText, + textStyle: (node) => _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + ), + textAlign: (node) => _buildTextAlignInTableCell( + context, + node: node, + configuration: configuration, + ), + ), + showPlaceholder: showParagraphPlaceholder, + ); +} + +TodoListBlockComponentBuilder _buildTodoListBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return TodoListBlockComponentBuilder( + configuration: configuration.copyWith( + placeholderText: (_) => LocaleKeys.blockPlaceholders_todoList.tr(), + textStyle: (node) => _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + ), + textAlign: (node) => _buildTextAlignInTableCell( + context, + node: node, + configuration: configuration, + ), + ), + iconBuilder: (_, node, onCheck) => TodoListIcon( + node: node, + onCheck: onCheck, + ), + toggleChildrenTriggers: [ + LogicalKeyboardKey.shift, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + ], + ); +} + +BulletedListBlockComponentBuilder _buildBulletedListBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return BulletedListBlockComponentBuilder( + configuration: configuration.copyWith( + placeholderText: (_) => LocaleKeys.blockPlaceholders_bulletList.tr(), + textStyle: (node) => _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + ), + textAlign: (node) => _buildTextAlignInTableCell( + context, + node: node, + configuration: configuration, + ), + ), + iconBuilder: (_, node) => BulletedListIcon(node: node), + ); +} + +NumberedListBlockComponentBuilder _buildNumberedListBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return NumberedListBlockComponentBuilder( + configuration: configuration.copyWith( + placeholderText: (_) => LocaleKeys.blockPlaceholders_numberList.tr(), + textStyle: (node) => _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + ), + textAlign: (node) => _buildTextAlignInTableCell( + context, + node: node, + configuration: configuration, + ), + ), + iconBuilder: (_, node, textDirection) { + TextStyle? textStyle; + if (node.isInHeaderColumn || node.isInHeaderRow) { + textStyle = configuration.textStyle(node).copyWith( + fontWeight: FontWeight.bold, + ); + } + return NumberedListIcon( + node: node, + textDirection: textDirection, + textStyle: textStyle, + ); + }, + ); +} + +QuoteBlockComponentBuilder _buildQuoteBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return QuoteBlockComponentBuilder( + configuration: configuration.copyWith( + placeholderText: (_) => LocaleKeys.blockPlaceholders_quote.tr(), + textStyle: (node) => _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + ), + textAlign: (node) => _buildTextAlignInTableCell( + context, + node: node, + configuration: configuration, + ), + ), + ); +} + +HeadingBlockComponentBuilder _buildHeadingBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, + EditorStyleCustomizer styleCustomizer, + EdgeInsets? customHeadingPadding, +) { + return HeadingBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (node) { + if (customHeadingPadding != null) { + return customHeadingPadding; + } + + if (UniversalPlatform.isMobile) { + final pageStyle = context.read().state; + final factor = pageStyle.fontLayout.factor; + final headingPaddings = + pageStyle.lineHeightLayout.headingPaddings.map((e) => e * factor); + final level = + (node.attributes[HeadingBlockKeys.level] ?? 6).clamp(1, 6); + final top = headingPaddings.elementAt(level - 1); + EdgeInsets edgeInsets = EdgeInsets.only(top: top); + if (node.path.length == 1) { + edgeInsets = edgeInsets.copyWith( + left: EditorStyleCustomizer.nodeHorizontalPadding, + right: EditorStyleCustomizer.nodeHorizontalPadding, + ); + } + return edgeInsets; + } + + return const EdgeInsets.only(top: 12.0, bottom: 4.0); + }, + placeholderText: (node) { + int level = node.attributes[HeadingBlockKeys.level] ?? 6; + level = level.clamp(1, 6); + return LocaleKeys.blockPlaceholders_heading.tr( + args: [level.toString()], + ); + }, + textAlign: (node) => _buildTextAlignInTableCell( + context, + node: node, + configuration: configuration, + ), + ), + textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level), + ); +} + +CustomImageBlockComponentBuilder _buildCustomImageBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return CustomImageBlockComponentBuilder( + configuration: configuration, + showMenu: true, + menuBuilder: (node, state) => Positioned( + top: 10, + right: 10, + child: ImageMenu(node: node, state: state), + ), + ); +} + +MultiImageBlockComponentBuilder _buildMultiImageBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return MultiImageBlockComponentBuilder( + configuration: configuration, + showMenu: true, + menuBuilder: ( + Node node, + MultiImageBlockComponentState state, + ValueNotifier indexNotifier, + VoidCallback onImageDeleted, + ) => + Positioned( + top: 10, + right: 10, + child: MultiImageMenu( + node: node, + state: state, + indexNotifier: indexNotifier, + isLocalMode: context.read().isLocalMode, + onImageDeleted: onImageDeleted, + ), + ), + ); +} + +TableBlockComponentBuilder _buildTableBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return TableBlockComponentBuilder( + menuBuilder: (node, editorState, position, dir, onBuild, onClose) => + TableMenu( + node: node, + editorState: editorState, + position: position, + dir: dir, + onBuild: onBuild, + onClose: onClose, + ), + ); +} + +TableCellBlockComponentBuilder _buildTableCellBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return TableCellBlockComponentBuilder( + colorBuilder: (context, node) { + final String colorString = + node.attributes[TableCellBlockKeys.colBackgroundColor] ?? + node.attributes[TableCellBlockKeys.rowBackgroundColor] ?? + ''; + if (colorString.isEmpty) { + return null; + } + return buildEditorCustomizedColor(context, node, colorString); + }, + menuBuilder: (node, editorState, position, dir, onBuild, onClose) => + TableMenu( + node: node, + editorState: editorState, + position: position, + dir: dir, + onBuild: onBuild, + onClose: onClose, + ), + ); +} + +DatabaseViewBlockComponentBuilder _buildDatabaseViewBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return DatabaseViewBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (_) => const EdgeInsets.symmetric(vertical: 10), + ), + ); +} + +CalloutBlockComponentBuilder _buildCalloutBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + final calloutBGColor = AFThemeExtension.of(context).calloutBGColor; + return CalloutBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (node) { + if (UniversalPlatform.isMobile) { + return configuration.padding(node); + } + return const EdgeInsets.symmetric(vertical: 10); + }, + textAlign: (node) => _buildTextAlignInTableCell( + context, + node: node, + configuration: configuration, + ), + textStyle: (node) => _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + ), + ), + inlinePadding: const EdgeInsets.symmetric(vertical: 8.0), + defaultColor: calloutBGColor, + ); +} + +DividerBlockComponentBuilder _buildDividerBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, + EditorState editorState, +) { + return DividerBlockComponentBuilder( + configuration: configuration, + height: 28.0, + wrapper: (_, node, child) => MobileBlockActionButtons( + showThreeDots: false, + node: node, + editorState: editorState, + child: child, + ), + ); +} + +MathEquationBlockComponentBuilder _buildMathEquationBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return MathEquationBlockComponentBuilder( + configuration: configuration, + ); +} + +CodeBlockComponentBuilder _buildCodeBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, + EditorStyleCustomizer styleCustomizer, +) { + return CodeBlockComponentBuilder( + styleBuilder: styleCustomizer.codeBlockStyleBuilder, + configuration: configuration, + padding: const EdgeInsets.only(left: 20, right: 30, bottom: 34), + languagePickerBuilder: codeBlockLanguagePickerBuilder, + copyButtonBuilder: codeBlockCopyBuilder, + ); +} + +AIWriterBlockComponentBuilder _buildAIWriterBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return AIWriterBlockComponentBuilder(); +} + +AskAIBlockComponentBuilder _buildAskAIBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return AskAIBlockComponentBuilder(); +} + +ToggleListBlockComponentBuilder _buildToggleListBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, + EditorStyleCustomizer styleCustomizer, + EdgeInsets? customHeadingPadding, +) { + return ToggleListBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (node) { + if (customHeadingPadding != null) { + return customHeadingPadding; + } + + if (UniversalPlatform.isMobile) { + final pageStyle = context.read().state; + final factor = pageStyle.fontLayout.factor; + final headingPaddings = + pageStyle.lineHeightLayout.headingPaddings.map((e) => e * factor); + final level = + (node.attributes[HeadingBlockKeys.level] ?? 6).clamp(1, 6); + final top = headingPaddings.elementAt(level - 1); + return configuration.padding(node).copyWith(top: top); + } + + return const EdgeInsets.only(top: 12.0, bottom: 4.0); + }, + textStyle: (node) { + final textStyle = _buildTextStyleInTableCell( + context, + node: node, + configuration: configuration, + ); + final level = node.attributes[ToggleListBlockKeys.level] as int?; + if (level == null) { + return textStyle; + } + return textStyle.merge(styleCustomizer.headingStyleBuilder(level)); + }, + textAlign: (node) => _buildTextAlignInTableCell( + context, + node: node, + configuration: configuration, + ), + placeholderText: (node) { + int? level = node.attributes[ToggleListBlockKeys.level]; + if (level == null) { + return configuration.placeholderText(node); + } + level = level.clamp(1, 6); + return LocaleKeys.blockPlaceholders_heading.tr( + args: [level.toString()], + ); + }, + ), + textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level), + ); +} + +OutlineBlockComponentBuilder _buildOutlineBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, + EditorStyleCustomizer styleCustomizer, +) { + return OutlineBlockComponentBuilder( + configuration: configuration.copyWith( + placeholderTextStyle: (_) => + styleCustomizer.outlineBlockPlaceholderStyleBuilder(), + padding: (_) => const EdgeInsets.only(top: 12.0, bottom: 4.0), + ), + ); +} + +LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return LinkPreviewBlockComponentBuilder( + configuration: configuration.copyWith( + padding: (_) => const EdgeInsets.symmetric(vertical: 10), + ), + cache: LinkPreviewDataCache(), + showMenu: true, + menuBuilder: (context, node, state) => Positioned( + top: 10, + right: 0, + child: LinkPreviewMenu(node: node, state: state), + ), + builder: (_, node, url, title, description, imageUrl) => + CustomLinkPreviewWidget( + node: node, + url: url, + title: title, + description: description, + imageUrl: imageUrl, + ), + ); +} + +FileBlockComponentBuilder _buildFileBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, +) { + return FileBlockComponentBuilder( + configuration: configuration, + ); +} + +SubPageBlockComponentBuilder _buildSubPageBlockComponentBuilder( + BuildContext context, + BlockComponentConfiguration configuration, { + required EditorStyleCustomizer styleCustomizer, +}) { + return SubPageBlockComponentBuilder( + configuration: configuration.copyWith( + textStyle: (node) => styleCustomizer.subPageBlockTextStyleBuilder(), + ), + ); +} + +TextStyle _buildTextStyleInTableCell( + BuildContext context, { + required Node node, + required BlockComponentConfiguration configuration, +}) { + TextStyle textStyle = configuration.textStyle(node); + + if (node.isInHeaderColumn || + node.isInHeaderRow || + node.isInBoldColumn || + node.isInBoldRow) { + textStyle = textStyle.copyWith( + fontWeight: FontWeight.bold, + ); + } + + final cellTextColor = node.textColorInColumn ?? node.textColorInRow; + + if (cellTextColor != null) { + textStyle = textStyle.copyWith( + color: buildEditorCustomizedColor( + context, + node, + cellTextColor, + ), + ); + } + + return textStyle; +} + +TextAlign _buildTextAlignInTableCell( + BuildContext context, { + required Node node, + required BlockComponentConfiguration configuration, +}) { + final isInTable = node.isInTable; + if (!isInTable) { + return configuration.textAlign(node); + } + + return node.tableAlign.textAlign; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_handler.dart new file mode 100644 index 0000000000000..443db069d177f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_handler.dart @@ -0,0 +1,158 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:provider/provider.dart'; + +const _excludeFromDropTarget = [ + ImageBlockKeys.type, + CustomImageBlockKeys.type, + MultiImageBlockKeys.type, + FileBlockKeys.type, +]; + +class EditorDropHandler extends StatelessWidget { + const EditorDropHandler({ + super.key, + required this.viewId, + required this.editorState, + required this.isLocalMode, + required this.child, + this.dropManagerState, + }); + + final String viewId; + final EditorState editorState; + final bool isLocalMode; + final Widget child; + final EditorDropManagerState? dropManagerState; + + @override + Widget build(BuildContext context) { + final childWidget = Consumer( + builder: (context, dropState, _) => DragTarget( + onLeave: (_) => editorState.selectionService.removeDropTarget(), + onMove: (details) { + if (details.data.id == viewId) { + return; + } + + _onDragUpdated(details.offset); + }, + onWillAcceptWithDetails: (details) { + if (!dropState.isDropEnabled) { + return false; + } + + if (details.data.id == viewId) { + return false; + } + + return true; + }, + onAcceptWithDetails: _onDragViewDone, + builder: (context, _, __) => DropTarget( + enable: dropState.isDropEnabled, + onDragExited: (_) => editorState.selectionService.removeDropTarget(), + onDragUpdated: (details) => _onDragUpdated(details.globalPosition), + onDragDone: _onDragDone, + child: child, + ), + ), + ); + + // Due to how DropTarget works, there is no way to differentiate if an overlay is + // blocking the target visibly, so when we have an overlay with a drop target, + // we should disable the drop target for the Editor, until it is closed. + // + // See FileBlockComponent for sample use. + // + // Relates to: + // - https://github.com/MixinNetwork/flutter-plugins/issues/2 + // - https://github.com/MixinNetwork/flutter-plugins/issues/331 + if (dropManagerState != null) { + return ChangeNotifierProvider.value( + value: dropManagerState!, + child: childWidget, + ); + } + + return ChangeNotifierProvider( + create: (_) => EditorDropManagerState(), + child: childWidget, + ); + } + + void _onDragUpdated(Offset position) { + final data = editorState.selectionService.getDropTargetRenderData(position); + + if (data != null && + data.dropPath != null && + + // We implement custom Drop logic for image blocks, this is + // how we can exclude them from the Drop Target + !_excludeFromDropTarget.contains(data.cursorNode?.type)) { + // Render the drop target + editorState.selectionService.renderDropTargetForOffset(position); + } else { + editorState.selectionService.removeDropTarget(); + } + } + + Future _onDragDone(DropDoneDetails details) async { + editorState.selectionService.removeDropTarget(); + + final data = editorState.selectionService + .getDropTargetRenderData(details.globalPosition); + + if (data != null) { + final cursorNode = data.cursorNode; + final dropPath = data.dropPath; + + if (cursorNode != null && dropPath != null) { + if (_excludeFromDropTarget.contains(cursorNode.type)) { + return; + } + + for (final file in details.files) { + final fileName = file.name.toLowerCase(); + if (file.mimeType?.startsWith('image/') ?? + false || imgExtensionRegex.hasMatch(fileName)) { + await editorState.dropImages(dropPath, [file], viewId, isLocalMode); + } else { + await editorState.dropFiles(dropPath, [file], viewId, isLocalMode); + } + } + } + } + } + + void _onDragViewDone(DragTargetDetails details) { + editorState.selectionService.removeDropTarget(); + + final data = + editorState.selectionService.getDropTargetRenderData(details.offset); + if (data != null) { + final cursorNode = data.cursorNode; + final dropPath = data.dropPath; + + if (cursorNode != null && dropPath != null) { + if (_excludeFromDropTarget.contains(cursorNode.type)) { + return; + } + + final view = details.data; + final node = pageMentionNode(view.id); + final t = editorState.transaction..insertNode(dropPath, node); + editorState.apply(t); + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart new file mode 100644 index 0000000000000..728dee766d4e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_drop_manager.dart @@ -0,0 +1,17 @@ +import 'package:flutter/widgets.dart'; + +class EditorDropManagerState extends ChangeNotifier { + final Set _draggedTypes = {}; + + void add(String type) { + _draggedTypes.add(type); + notifyListeners(); + } + + void remove(String type) { + _draggedTypes.remove(type); + notifyListeners(); + } + + bool get isDropEnabled => _draggedTypes.isEmpty; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_notification.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_notification.dart new file mode 100644 index 0000000000000..fce8b4c16e321 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_notification.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy_editor/appflowy_editor.dart'; + +enum EditorNotificationType { + none, + undo, + redo, + exitEditing, + paste, + dragStart, + dragEnd, + turnInto, +} + +class EditorNotification { + const EditorNotification({required this.type}); + + EditorNotification.undo() : type = EditorNotificationType.undo; + EditorNotification.redo() : type = EditorNotificationType.redo; + EditorNotification.exitEditing() : type = EditorNotificationType.exitEditing; + EditorNotification.paste() : type = EditorNotificationType.paste; + EditorNotification.dragStart() : type = EditorNotificationType.dragStart; + EditorNotification.dragEnd() : type = EditorNotificationType.dragEnd; + EditorNotification.turnInto() : type = EditorNotificationType.turnInto; + + static final PropertyValueNotifier _notifier = + PropertyValueNotifier(EditorNotificationType.none); + + final EditorNotificationType type; + + void post() => _notifier.value = type; + + static void addListener(ValueChanged listener) { + _notifier.addListener(() => listener(_notifier.value)); + } + + static void removeListener(ValueChanged listener) { + _notifier.removeListener(() => listener(_notifier.value)); + } + + static void dispose() => _notifier.dispose(); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart new file mode 100644 index 0000000000000..0cf39e8bcc9b6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -0,0 +1,566 @@ +import 'dart:ui' as ui; + +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/background_color/theme_background_color.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/i18n/editor_i18n.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/plugins/inline_actions/handlers/child_page.dart'; +import 'package:appflowy/plugins/inline_actions/handlers/date_reference.dart'; +import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart'; +import 'package:appflowy/plugins/inline_actions/handlers/reminder_reference.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; +import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +/// Wrapper for the appflowy editor. +class AppFlowyEditorPage extends StatefulWidget { + const AppFlowyEditorPage({ + super.key, + required this.editorState, + this.header, + this.shrinkWrap = false, + this.scrollController, + this.autoFocus, + required this.styleCustomizer, + this.showParagraphPlaceholder, + this.placeholderText, + this.initialSelection, + this.useViewInfoBloc = true, + }); + + final Widget? header; + final EditorState editorState; + final ScrollController? scrollController; + final bool shrinkWrap; + final bool? autoFocus; + final EditorStyleCustomizer styleCustomizer; + final ShowPlaceholder? showParagraphPlaceholder; + final String Function(Node)? placeholderText; + + /// Used to provide an initial selection on Page-load + final Selection? initialSelection; + + final bool useViewInfoBloc; + + @override + State createState() => _AppFlowyEditorPageState(); +} + +class _AppFlowyEditorPageState extends State + with WidgetsBindingObserver { + late final ScrollController effectiveScrollController; + + late final InlineActionsService inlineActionsService = InlineActionsService( + context: context, + handlers: [ + if (FeatureFlag.inlineSubPageMention.isOn) + InlineChildPageService(currentViewId: documentBloc.documentId), + InlinePageReferenceService(currentViewId: documentBloc.documentId), + DateReferenceService(context), + ReminderReferenceService(context), + ], + ); + + late final List commandShortcuts = [ + ...commandShortcutEvents, + ..._buildFindAndReplaceCommands(), + ]; + + final List toolbarItems = [ + askAIItem..isActive = onlyShowInTextType, + paragraphItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, + headingsToolbarItem + ..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, + ...markdownFormatItems..forEach((e) => e.isActive = showInAnyTextType), + quoteItem..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, + bulletedListItem + ..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, + numberedListItem + ..isActive = onlyShowInSingleTextTypeSelectionAndExcludeTable, + inlineMathEquationItem, + linkItem, + alignToolbarItem, + buildTextColorItem()..isActive = showInAnyTextType, + buildHighlightColorItem()..isActive = showInAnyTextType, + customizeFontToolbarItem..isActive = showInAnyTextType, + ]; + + List get characterShortcutEvents { + return buildCharacterShortcutEvents( + context, + documentBloc, + styleCustomizer, + inlineActionsService, + (editorState, node) => _customSlashMenuItems( + editorState: editorState, + node: node, + ), + ); + } + + EditorStyleCustomizer get styleCustomizer => widget.styleCustomizer; + DocumentBloc get documentBloc => context.read(); + + late final EditorScrollController editorScrollController; + + late final ViewInfoBloc viewInfoBloc = context.read(); + + final editorKeyboardInterceptor = EditorKeyboardInterceptor(); + + Future showSlashMenu(editorState) async => customSlashCommand( + _customSlashMenuItems(), + shouldInsertSlash: false, + style: styleCustomizer.selectionMenuStyleBuilder(), + supportSlashMenuNodeTypes: supportSlashMenuNodeTypes, + ).handler(editorState); + + AFFocusManager? focusManager; + + AppLifecycleState? lifecycleState = WidgetsBinding.instance.lifecycleState; + List previousSelections = []; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + if (widget.useViewInfoBloc) { + viewInfoBloc.add( + ViewInfoEvent.registerEditorState(editorState: widget.editorState), + ); + } + + _initEditorL10n(); + _initializeShortcuts(); + + AppFlowyRichTextKeys.partialSliced.addAll([ + MentionBlockKeys.mention, + InlineMathEquationKeys.formula, + ]); + + indentableBlockTypes.add(ToggleListBlockKeys.type); + convertibleBlockTypes.add(ToggleListBlockKeys.type); + + effectiveScrollController = widget.scrollController ?? ScrollController(); + // disable the color parse in the HTML decoder. + DocumentHTMLDecoder.enableColorParse = false; + + editorScrollController = EditorScrollController( + editorState: widget.editorState, + shrinkWrap: widget.shrinkWrap, + scrollController: effectiveScrollController, + ); + + toolbarItemWhiteList.addAll([ + ToggleListBlockKeys.type, + CalloutBlockKeys.type, + TableBlockKeys.type, + SimpleTableBlockKeys.type, + SimpleTableCellBlockKeys.type, + SimpleTableRowBlockKeys.type, + ]); + AppFlowyRichTextKeys.supportSliced.add(AppFlowyRichTextKeys.fontFamily); + + // customize the dynamic theme color + _customizeBlockComponentBackgroundColorDecorator(); + + widget.editorState.selectionNotifier.addListener(onSelectionChanged); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + + focusManager = AFFocusManager.maybeOf(context); + focusManager?.loseFocusNotifier.addListener(_loseFocus); + + _scrollToSelectionIfNeeded(); + + widget.editorState.service.keyboardService?.registerInterceptor( + editorKeyboardInterceptor, + ); + }); + } + + void _scrollToSelectionIfNeeded() { + final initialSelection = widget.initialSelection; + final path = initialSelection?.start.path; + if (path == null) { + return; + } + + // on desktop, using jumpTo to scroll to the selection. + // on mobile, using scrollTo to scroll to the selection, because using jumpTo will break the scroll notification metrics. + if (UniversalPlatform.isDesktop) { + editorScrollController.itemScrollController.jumpTo( + index: path.first, + alignment: 0.5, + ); + widget.editorState.updateSelectionWithReason( + initialSelection, + ); + } else { + const delayDuration = Duration(milliseconds: 250); + const animationDuration = Duration(milliseconds: 400); + Future.delayed(delayDuration, () { + editorScrollController.itemScrollController.scrollTo( + index: path.first, + duration: animationDuration, + curve: Curves.easeInOut, + ); + widget.editorState.updateSelectionWithReason( + initialSelection, + extraInfo: { + selectionExtraInfoDoNotAttachTextService: true, + selectionExtraInfoDisableMobileToolbarKey: true, + }, + ); + }).then((_) { + Future.delayed(animationDuration, () { + widget.editorState.selectionType = SelectionType.inline; + widget.editorState.selectionExtraInfo = null; + }); + }); + } + } + + void onSelectionChanged() { + if (widget.editorState.isDisposed) { + return; + } + + previousSelections.add(widget.editorState.selection); + + if (previousSelections.length > 2) { + previousSelections.removeAt(0); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + lifecycleState = state; + + if (widget.editorState.isDisposed) { + return; + } + + if (previousSelections.length == 2 && + state == AppLifecycleState.resumed && + widget.editorState.selection == null) { + widget.editorState.selection = previousSelections.first; + } + } + + @override + void didChangeDependencies() { + final currFocusManager = AFFocusManager.maybeOf(context); + if (focusManager != currFocusManager) { + focusManager?.loseFocusNotifier.removeListener(_loseFocus); + focusManager = currFocusManager; + focusManager?.loseFocusNotifier.addListener(_loseFocus); + } + + super.didChangeDependencies(); + } + + @override + void dispose() { + widget.editorState.selectionNotifier.removeListener(onSelectionChanged); + widget.editorState.service.keyboardService?.unregisterInterceptor( + editorKeyboardInterceptor, + ); + focusManager?.loseFocusNotifier.removeListener(_loseFocus); + + if (widget.useViewInfoBloc && !viewInfoBloc.isClosed) { + viewInfoBloc.add(const ViewInfoEvent.unregisterEditorState()); + } + + SystemChannels.textInput.invokeMethod('TextInput.hide'); + + if (widget.scrollController == null) { + effectiveScrollController.dispose(); + } + inlineActionsService.dispose(); + editorScrollController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final (bool autoFocus, Selection? selection) = + _computeAutoFocusParameters(); + + final isRTL = + context.read().state.layoutDirection == + LayoutDirection.rtlLayout; + final textDirection = isRTL ? ui.TextDirection.rtl : ui.TextDirection.ltr; + + _setRTLToolbarItems( + context.read().state.enableRtlToolbarItems, + ); + + final isViewDeleted = context.read().state.isDeleted; + final editor = Directionality( + textDirection: textDirection, + child: AppFlowyEditor( + editorState: widget.editorState, + editable: !isViewDeleted, + editorScrollController: editorScrollController, + // setup the auto focus parameters + autoFocus: widget.autoFocus ?? autoFocus, + focusedSelection: selection, + // setup the theme + editorStyle: styleCustomizer.style(), + // customize the block builders + blockComponentBuilders: buildBlockComponentBuilders( + slashMenuItemsBuilder: (editorState, node) => _customSlashMenuItems( + editorState: editorState, + node: node, + ), + context: context, + editorState: widget.editorState, + styleCustomizer: widget.styleCustomizer, + showParagraphPlaceholder: widget.showParagraphPlaceholder, + placeholderText: widget.placeholderText, + ), + // customize the shortcuts + characterShortcutEvents: characterShortcutEvents, + commandShortcutEvents: commandShortcuts, + // customize the context menu items + contextMenuItems: customContextMenuItems, + // customize the header and footer. + header: widget.header, + footer: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () async { + // if the last one isn't a empty node, insert a new empty node. + await _focusOnLastEmptyParagraph(); + }, + child: SizedBox( + width: double.infinity, + height: UniversalPlatform.isDesktopOrWeb ? 200 : 400, + ), + ), + dropTargetStyle: AppFlowyDropTargetStyle( + color: Theme.of(context).colorScheme.primary.withOpacity(0.8), + margin: const EdgeInsets.only(left: 44), + ), + ), + ); + + if (isViewDeleted) { + return editor; + } + + final editorState = widget.editorState; + + if (UniversalPlatform.isMobile) { + return AppFlowyMobileToolbar( + toolbarHeight: 42.0, + editorState: editorState, + toolbarItemsBuilder: (sel) => buildMobileToolbarItems(editorState, sel), + child: MobileFloatingToolbar( + editorState: editorState, + editorScrollController: editorScrollController, + toolbarBuilder: (_, anchor, closeToolbar) => + CustomMobileFloatingToolbar( + editorState: editorState, + anchor: anchor, + closeToolbar: closeToolbar, + ), + child: editor, + ), + ); + } + + return Center( + child: FloatingToolbar( + style: styleCustomizer.floatingToolbarStyleBuilder(), + items: toolbarItems, + editorState: editorState, + editorScrollController: editorScrollController, + textDirection: textDirection, + tooltipBuilder: (context, id, message, child) => + widget.styleCustomizer.buildToolbarItemTooltip( + context, + id, + message, + child, + ), + child: editor, + ), + ); + } + + List _customSlashMenuItems({ + EditorState? editorState, + Node? node, + }) { + final documentBloc = context.read(); + final isLocalMode = documentBloc.isLocalMode; + return slashMenuItemsBuilder( + editorState: editorState, + node: node, + isLocalMode: isLocalMode, + documentBloc: documentBloc, + ); + } + + (bool, Selection?) _computeAutoFocusParameters() { + if (widget.editorState.document.isEmpty) { + return (true, Selection.collapsed(Position(path: [0]))); + } + return const (false, null); + } + + Future _initializeShortcuts() async { + defaultCommandShortcutEvents; + final settingsShortcutService = SettingsShortcutService(); + final customizeShortcuts = + await settingsShortcutService.getCustomizeShortcuts(); + await settingsShortcutService.updateCommandShortcuts( + commandShortcuts, + customizeShortcuts, + ); + } + + void _setRTLToolbarItems(bool enableRtlToolbarItems) { + final textDirectionItemIds = textDirectionItems.map((e) => e.id); + // clear all the text direction items + toolbarItems.removeWhere((item) => textDirectionItemIds.contains(item.id)); + // only show the rtl item when the layout direction is ltr. + if (enableRtlToolbarItems) { + toolbarItems.addAll(textDirectionItems); + } + } + + List _buildFindAndReplaceCommands() { + return findAndReplaceCommands( + context: context, + style: FindReplaceStyle( + findMenuBuilder: ( + context, + editorState, + localizations, + style, + showReplaceMenu, + onDismiss, + ) => + Material( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: FindAndReplaceMenuWidget( + showReplaceMenu: showReplaceMenu, + editorState: editorState, + onDismiss: onDismiss, + ), + ), + ), + ), + ); + } + + void _customizeBlockComponentBackgroundColorDecorator() { + blockComponentBackgroundColorDecorator = (Node node, String colorString) { + if (mounted && context.mounted) { + return buildEditorCustomizedColor(context, node, colorString); + } + return null; + }; + } + + void _initEditorL10n() => AppFlowyEditorL10n.current = EditorI18n(); + + Future _focusOnLastEmptyParagraph() async { + final editorState = widget.editorState; + final root = editorState.document.root; + final lastNode = root.children.lastOrNull; + final transaction = editorState.transaction; + if (lastNode == null || + lastNode.delta?.isEmpty == false || + lastNode.type != ParagraphBlockKeys.type) { + transaction.insertNode([root.children.length], paragraphNode()); + transaction.afterSelection = Selection.collapsed( + Position(path: [root.children.length]), + ); + } else { + transaction.afterSelection = Selection.collapsed( + Position(path: lastNode.path), + ); + } + await editorState.apply(transaction); + } + + void _loseFocus() { + if (!widget.editorState.isDisposed) { + widget.editorState.selection = null; + } + } +} + +Color? buildEditorCustomizedColor( + BuildContext context, + Node node, + String colorString, +) { + if (!context.mounted) { + return null; + } + + // the color string is from FlowyTint. + final tintColor = FlowyTint.values.firstWhereOrNull( + (e) => e.id == colorString, + ); + if (tintColor != null) { + return tintColor.color(context); + } + + final themeColor = themeBackgroundColors[colorString]; + if (themeColor != null) { + return themeColor.color(context); + } + + if (colorString == optionActionColorDefaultColor) { + final defaultColor = node.type == CalloutBlockKeys.type + ? AFThemeExtension.of(context).calloutBGColor + : Colors.transparent; + return defaultColor; + } + + if (colorString == tableCellDefaultColor) { + return AFThemeExtension.of(context).tableCellBGColor; + } + + try { + return colorString.tryToColor(); + } catch (e) { + return null; + } +} + +bool showInAnyTextType(EditorState editorState) { + final selection = editorState.selection; + if (selection == null) { + return false; + } + + final nodes = editorState.getNodesInSelection(selection); + return nodes.any((node) => toolbarItemWhiteList.contains(node.type)); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart new file mode 100644 index 0000000000000..6d01ed5f1b5e9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart @@ -0,0 +1,82 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class BlockAddButton extends StatelessWidget { + const BlockAddButton({ + super.key, + required this.blockComponentContext, + required this.blockComponentState, + required this.editorState, + required this.showSlashMenu, + }); + + final BlockComponentContext blockComponentContext; + final BlockComponentActionState blockComponentState; + + final EditorState editorState; + final VoidCallback showSlashMenu; + + @override + Widget build(BuildContext context) { + return BlockActionButton( + svg: FlowySvgs.add_s, + richMessage: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.blockActions_addBelowTooltip.tr(), + style: context.tooltipTextStyle(), + ), + const TextSpan(text: '\n'), + TextSpan( + text: Platform.isMacOS + ? LocaleKeys.blockActions_addAboveMacCmd.tr() + : LocaleKeys.blockActions_addAboveCmd.tr(), + style: context.tooltipTextStyle(), + ), + const TextSpan(text: ' '), + TextSpan( + text: LocaleKeys.blockActions_addAboveTooltip.tr(), + style: context.tooltipTextStyle(), + ), + ], + ), + onTap: () { + final isAltPressed = HardwareKeyboard.instance.isAltPressed; + + final transaction = editorState.transaction; + + // If the current block is not an empty paragraph block, + // then insert a new block above/below the current block. + final node = blockComponentContext.node; + if (node.type != ParagraphBlockKeys.type || + (node.delta?.isNotEmpty ?? true)) { + final path = isAltPressed ? node.path : node.path.next; + + transaction.insertNode(path, paragraphNode()); + transaction.afterSelection = Selection.collapsed( + Position(path: path), + ); + } else { + transaction.afterSelection = Selection.collapsed( + Position(path: node.path), + ); + } + + // show the slash menu. + editorState.apply(transaction).then( + (_) => WidgetsBinding.instance.addPostFrameCallback( + (_) => showSlashMenu(), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_button.dart new file mode 100644 index 0000000000000..aed2de9abfecc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_button.dart @@ -0,0 +1,55 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart'; +import 'package:flutter/material.dart'; + +class BlockActionButton extends StatelessWidget { + const BlockActionButton({ + super.key, + required this.svg, + required this.richMessage, + required this.onTap, + this.showTooltip = true, + this.onPointerDown, + }); + + final FlowySvgData svg; + final bool showTooltip; + final InlineSpan richMessage; + final VoidCallback onTap; + final VoidCallback? onPointerDown; + + @override + Widget build(BuildContext context) { + Widget child = MouseRegion( + cursor: Platform.isWindows + ? SystemMouseCursors.click + : SystemMouseCursors.grab, + child: IgnoreParentGestureWidget( + onPress: onPointerDown, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.deferToChild, + child: FlowySvg( + svg, + size: const Size.square(18.0), + color: Theme.of(context).iconTheme.color, + ), + ), + ), + ); + + if (showTooltip) { + child = FlowyTooltip( + richMessage: richMessage, + child: child, + ); + } + + return Align( + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_list.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_list.dart new file mode 100644 index 0000000000000..fcbd7ea6caa9b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_list.dart @@ -0,0 +1,49 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class BlockActionList extends StatelessWidget { + const BlockActionList({ + super.key, + required this.blockComponentContext, + required this.blockComponentState, + required this.editorState, + required this.actions, + required this.showSlashMenu, + required this.blockComponentBuilder, + }); + + final BlockComponentContext blockComponentContext; + final BlockComponentActionState blockComponentState; + final List actions; + final VoidCallback showSlashMenu; + final EditorState editorState; + final Map blockComponentBuilder; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + BlockAddButton( + blockComponentContext: blockComponentContext, + blockComponentState: blockComponentState, + editorState: editorState, + showSlashMenu: showSlashMenu, + ), + const HSpace(2.0), + BlockOptionButton( + blockComponentContext: blockComponentContext, + blockComponentState: blockComponentState, + actions: actions, + editorState: editorState, + blockComponentBuilder: blockComponentBuilder, + ), + const HSpace(8.0), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart new file mode 100644 index 0000000000000..29dabd0da14b0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart @@ -0,0 +1,140 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'drag_to_reorder/draggable_option_button.dart'; + +class BlockOptionButton extends StatefulWidget { + const BlockOptionButton({ + super.key, + required this.blockComponentContext, + required this.blockComponentState, + required this.actions, + required this.editorState, + required this.blockComponentBuilder, + }); + + final BlockComponentContext blockComponentContext; + final BlockComponentActionState blockComponentState; + final List actions; + final EditorState editorState; + final Map blockComponentBuilder; + + @override + State createState() => _BlockOptionButtonState(); +} + +class _BlockOptionButtonState extends State { + // the mutex is used to ensure that only one popover is open at a time + // for example, when the user is selecting the color, the turn into option + // should not be shown. + final mutex = PopoverMutex(); + + @override + Widget build(BuildContext context) { + final direction = + context.read().state.layoutDirection == + LayoutDirection.rtlLayout + ? PopoverDirection.rightWithCenterAligned + : PopoverDirection.leftWithCenterAligned; + return BlocProvider( + create: (context) => BlockActionOptionCubit( + editorState: widget.editorState, + blockComponentBuilder: widget.blockComponentBuilder, + ), + child: BlocBuilder( + builder: (context, _) => PopoverActionList( + actions: _buildPopoverActions(context), + popoverMutex: PopoverMutex(), + animationDuration: Durations.short3, + slideDistance: 5, + beginScaleFactor: 1.0, + beginOpacity: 0.8, + direction: direction, + onPopupBuilder: _onPopoverBuilder, + onClosed: () => _onPopoverClosed(context), + onSelected: (action, controller) => _onActionSelected( + context, + action, + controller, + ), + buildChild: (controller) => DraggableOptionButton( + controller: controller, + editorState: widget.editorState, + blockComponentContext: widget.blockComponentContext, + blockComponentBuilder: widget.blockComponentBuilder, + ), + ), + ), + ); + } + + @override + void dispose() { + mutex.dispose(); + + super.dispose(); + } + + List _buildPopoverActions(BuildContext context) { + return widget.actions.map((e) { + switch (e) { + case OptionAction.divider: + return DividerOptionAction(); + case OptionAction.color: + return ColorOptionAction( + editorState: widget.editorState, + mutex: mutex, + ); + case OptionAction.align: + return AlignOptionAction(editorState: widget.editorState); + case OptionAction.depth: + return DepthOptionAction(editorState: widget.editorState); + case OptionAction.turnInto: + return TurnIntoOptionAction( + editorState: widget.editorState, + blockComponentBuilder: widget.blockComponentBuilder, + mutex: mutex, + ); + default: + return OptionActionWrapper(e); + } + }).toList(); + } + + void _onPopoverBuilder() { + keepEditorFocusNotifier.increase(); + widget.blockComponentState.alwaysShowActions = true; + } + + void _onPopoverClosed(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + widget.editorState.selectionType = null; + widget.editorState.selection = null; + widget.blockComponentState.alwaysShowActions = false; + }); + + PopoverContainer.maybeOf(context)?.closeAll(); + } + + void _onActionSelected( + BuildContext context, + PopoverAction action, + PopoverController controller, + ) { + if (action is! OptionActionWrapper) { + return; + } + + context.read().handleAction( + action.inner, + widget.blockComponentContext.node, + ); + controller.close(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart new file mode 100644 index 0000000000000..52b518a718463 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart @@ -0,0 +1,682 @@ +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/plugins/trash/application/prelude.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BlockActionOptionState {} + +class BlockActionOptionCubit extends Cubit { + BlockActionOptionCubit({ + required this.editorState, + required this.blockComponentBuilder, + }) : super(BlockActionOptionState()); + + final EditorState editorState; + final Map blockComponentBuilder; + + Future handleAction(OptionAction action, Node node) async { + final transaction = editorState.transaction; + switch (action) { + case OptionAction.delete: + _deleteBlocks(transaction, node); + break; + case OptionAction.duplicate: + await _duplicateBlock(transaction, node); + EditorNotification.paste().post(); + break; + case OptionAction.moveUp: + transaction.moveNode(node.path.previous, node); + break; + case OptionAction.moveDown: + transaction.moveNode(node.path.next.next, node); + break; + case OptionAction.copyLinkToBlock: + await _copyLinkToBlock(node); + break; + case OptionAction.setToPageWidth: + await _setToPageWidth(node); + break; + case OptionAction.distributeColumnsEvenly: + await _distributeColumnsEvenly(node); + break; + case OptionAction.align: + case OptionAction.color: + case OptionAction.divider: + case OptionAction.depth: + case OptionAction.turnInto: + throw UnimplementedError(); + } + + await editorState.apply(transaction); + } + + /// If the selection is a block selection, delete the selected blocks. + /// Otherwise, delete the selected block. + void _deleteBlocks(Transaction transaction, Node selectedNode) { + final selection = editorState.selection; + final selectionType = editorState.selectionType; + if (selectionType == SelectionType.block && selection != null) { + final nodes = editorState.getNodesInSelection(selection.normalized); + transaction.deleteNodes(nodes); + } else { + transaction.deleteNode(selectedNode); + } + } + + Future _duplicateBlock(Transaction transaction, Node node) async { + final selection = editorState.selection; + final selectionType = editorState.selectionType; + if (selectionType == SelectionType.block && selection != null) { + final nodes = editorState.getNodesInSelection(selection.normalized); + for (final node in nodes) { + _validateNode(node); + } + transaction.insertNodes( + selection.normalized.end.path.next, + nodes.map((e) => _copyBlock(e)).toList(), + ); + } else { + _validateNode(node); + transaction.insertNode(node.path.next, _copyBlock(node)); + } + } + + void _validateNode(Node node) { + final type = node.type; + final builder = blockComponentBuilder[type]; + + if (builder == null) { + Log.error('Block type $type is not supported'); + return; + } + + final valid = builder.validate(node); + if (!valid) { + Log.error('Block type $type is not valid'); + } + } + + Node _copyBlock(Node node) { + Node copiedNode = node.deepCopy(); + + final type = node.type; + final builder = blockComponentBuilder[type]; + + if (builder == null) { + Log.error('Block type $type is not supported'); + } else { + final valid = builder.validate(node); + if (!valid) { + Log.error('Block type $type is not valid'); + if (node.type == TableBlockKeys.type) { + copiedNode = _fixTableBlock(node); + copiedNode = _convertTableToSimpleTable(copiedNode); + } + } else { + if (node.type == TableBlockKeys.type) { + copiedNode = _convertTableToSimpleTable(node); + } + } + } + + return copiedNode; + } + + Node _fixTableBlock(Node node) { + if (node.type != TableBlockKeys.type) { + return node; + } + + // the table node should contains colsLen and rowsLen + final colsLen = node.attributes[TableBlockKeys.colsLen]; + final rowsLen = node.attributes[TableBlockKeys.rowsLen]; + if (colsLen == null || rowsLen == null) { + return node; + } + + final newChildren = []; + final children = node.children; + + // based on the colsLen and rowsLen, iterate the children and fix the data + for (var i = 0; i < rowsLen; i++) { + for (var j = 0; j < colsLen; j++) { + final cell = children + .where( + (n) => + n.attributes[TableCellBlockKeys.rowPosition] == i && + n.attributes[TableCellBlockKeys.colPosition] == j, + ) + .firstOrNull; + if (cell != null) { + newChildren.add(cell.deepCopy()); + } else { + newChildren.add( + tableCellNode('', i, j), + ); + } + } + } + + return node.copyWith( + children: newChildren, + attributes: { + ...node.attributes, + TableBlockKeys.colsLen: colsLen, + TableBlockKeys.rowsLen: rowsLen, + }, + ); + } + + Node _convertTableToSimpleTable(Node node) { + if (node.type != TableBlockKeys.type) { + return node; + } + + // the table node should contains colsLen and rowsLen + final colsLen = node.attributes[TableBlockKeys.colsLen]; + final rowsLen = node.attributes[TableBlockKeys.rowsLen]; + if (colsLen == null || rowsLen == null) { + return node; + } + + final rows = >[]; + final children = node.children; + for (var i = 0; i < rowsLen; i++) { + final row = []; + for (var j = 0; j < colsLen; j++) { + final cell = children + .where( + (n) => + n.attributes[TableCellBlockKeys.rowPosition] == i && + n.attributes[TableCellBlockKeys.colPosition] == j, + ) + .firstOrNull; + row.add( + simpleTableCellBlockNode( + children: [cell?.children.first.deepCopy() ?? paragraphNode()], + ), + ); + } + rows.add(row); + } + + return simpleTableBlockNode( + children: rows.map((e) => simpleTableRowBlockNode(children: e)).toList(), + ); + } + + Future _copyLinkToBlock(Node node) async { + List nodes = [node]; + + final selection = editorState.selection; + final selectionType = editorState.selectionType; + if (selectionType == SelectionType.block && selection != null) { + nodes = editorState.getNodesInSelection(selection.normalized); + } + + final context = editorState.document.root.context; + final viewId = context?.read().documentId; + if (viewId == null) { + return; + } + + final workspace = await FolderEventReadCurrentWorkspace().send(); + final workspaceId = workspace.fold( + (l) => l.id, + (r) => '', + ); + + if (workspaceId.isEmpty || viewId.isEmpty) { + Log.error('Failed to get workspace id: $workspaceId or view id: $viewId'); + emit(BlockActionOptionState()); // Emit a new state to trigger UI update + return; + } + + final blockIds = nodes.map((e) => e.id); + final links = blockIds.map( + (e) => ShareConstants.buildShareUrl( + workspaceId: workspaceId, + viewId: viewId, + blockId: e, + ), + ); + + await getIt().setData( + ClipboardServiceData(plainText: links.join('\n')), + ); + + emit(BlockActionOptionState()); // Emit a new state to trigger UI update + } + + Future turnIntoBlock( + String type, + Node node, { + int? level, + String? currentViewId, + }) async { + final selection = editorState.selection; + if (selection == null) { + return false; + } + + // Notify the transaction service that the next apply is from turn into action + EditorNotification.turnInto().post(); + + final toType = type; + + // only handle the node in the same depth + final selectedNodes = editorState + .getNodesInSelection(selection.normalized) + .where((e) => e.path.length == node.path.length) + .toList(); + Log.info('turnIntoBlock selectedNodes $selectedNodes'); + + // try to turn into a single toggle heading block + if (await turnIntoSingleToggleHeading( + type: toType, + selectedNodes: selectedNodes, + level: level, + )) { + return true; + } + + // try to turn into a page block + if (currentViewId != null && + await turnIntoPage( + type: toType, + selectedNodes: selectedNodes, + selection: selection, + currentViewId: currentViewId, + )) { + return true; + } + + final insertedNode = []; + for (final node in selectedNodes) { + Log.info('Turn into block: from ${node.type} to $type'); + + Node afterNode = node.copyWith( + type: type, + attributes: { + if (toType == HeadingBlockKeys.type) HeadingBlockKeys.level: level, + if (toType == ToggleListBlockKeys.type) + ToggleListBlockKeys.level: level, + if (toType == TodoListBlockKeys.type) + TodoListBlockKeys.checked: false, + blockComponentBackgroundColor: + node.attributes[blockComponentBackgroundColor], + blockComponentTextDirection: + node.attributes[blockComponentTextDirection], + blockComponentDelta: (node.delta ?? Delta()).toJson(), + }, + ); + + // heading block and callout block should not have children + if ([HeadingBlockKeys.type, CalloutBlockKeys.type, QuoteBlockKeys.type] + .contains(toType)) { + afterNode = afterNode.copyWith(children: []); + afterNode = await _handleSubPageNode(afterNode, node); + insertedNode.add(afterNode); + insertedNode.addAll(node.children.map((e) => e.deepCopy())); + } else if (!EditorOptionActionType.turnInto.supportTypes + .contains(node.type)) { + afterNode = node.deepCopy(); + insertedNode.add(afterNode); + } else { + afterNode = await _handleSubPageNode(afterNode, node); + insertedNode.add(afterNode); + } + } + + final transaction = editorState.transaction; + transaction.insertNodes( + node.path, + insertedNode, + ); + transaction.deleteNodes(selectedNodes); + await editorState.apply(transaction); + + return true; + } + + /// Takes the new [Node] and the Node which is a SubPageBlock. + /// + /// Returns the altered [Node] with the delta as the Views' name. + /// + Future _handleSubPageNode(Node node, Node subPageNode) async { + if (subPageNode.type != SubPageBlockKeys.type) { + return node; + } + + final delta = await _deltaFromSubPageNode(subPageNode); + return node.copyWith( + attributes: { + ...node.attributes, + blockComponentDelta: (delta ?? Delta()).toJson(), + }, + ); + } + + /// Returns the [Delta] from a SubPage [Node], where the + /// [Delta] is the views' name. + /// + Future _deltaFromSubPageNode(Node node) async { + if (node.type != SubPageBlockKeys.type) { + return null; + } + + final viewId = node.attributes[SubPageBlockKeys.viewId]; + final viewOrFailure = await ViewBackendService.getView(viewId); + final view = viewOrFailure.toNullable(); + if (view != null) { + return Delta(operations: [TextInsert(view.name)]); + } + + Log.error("Failed to get view by id($viewId)"); + return null; + } + + // turn a single node into toggle heading block + // 1. find the sibling nodes after the selected node until + // meet the first node that contains level and its value is greater or equal to the level + // 2. move the found nodes in the selected node + // + // example: + // Toggle Heading 1 <- selected node + // - bulleted item 1 + // - bulleted item 2 + // - bulleted item 3 + // Heading 1 + // - paragraph 1 + // - paragraph 2 + // when turning "Toggle Heading 1" into toggle heading, the bulleted items will be moved into the toggle heading + Future turnIntoSingleToggleHeading({ + required String type, + required List selectedNodes, + int? level, + Delta? delta, + Selection? afterSelection, + }) async { + // only support turn a single node into toggle heading block + if (type != ToggleListBlockKeys.type || + selectedNodes.length != 1 || + level == null) { + return false; + } + + // find the sibling nodes after the selected node until + final insertedNodes = []; + final node = selectedNodes.first; + Path path = node.path.next; + Node? nextNode = editorState.getNodeAtPath(path); + while (nextNode != null) { + if (nextNode.type == HeadingBlockKeys.type && + nextNode.attributes[HeadingBlockKeys.level] != null && + nextNode.attributes[HeadingBlockKeys.level]! <= level) { + break; + } + + if (nextNode.type == ToggleListBlockKeys.type && + nextNode.attributes[ToggleListBlockKeys.level] != null && + nextNode.attributes[ToggleListBlockKeys.level]! <= level) { + break; + } + + insertedNodes.add(nextNode); + + path = path.next; + nextNode = editorState.getNodeAtPath(path); + } + + Log.info('insertedNodes $insertedNodes'); + + Log.info( + 'Turn into block: from ${node.type} to $type', + ); + + Delta newDelta = delta ?? (node.delta ?? Delta()); + if (delta == null && node.type == SubPageBlockKeys.type) { + newDelta = await _deltaFromSubPageNode(node) ?? Delta(); + } + + final afterNode = node.copyWith( + type: type, + attributes: { + ToggleListBlockKeys.level: level, + ToggleListBlockKeys.collapsed: + node.attributes[ToggleListBlockKeys.collapsed] ?? false, + blockComponentBackgroundColor: + node.attributes[blockComponentBackgroundColor], + blockComponentTextDirection: + node.attributes[blockComponentTextDirection], + blockComponentDelta: newDelta.toJson(), + }, + children: [ + ...node.children, + ...insertedNodes.map((e) => e.deepCopy()), + ], + ); + + final transaction = editorState.transaction; + transaction.insertNode( + node.path, + afterNode, + ); + transaction.deleteNodes([ + node, + ...insertedNodes, + ]); + if (afterSelection != null) { + transaction.afterSelection = afterSelection; + } else if (insertedNodes.isNotEmpty) { + // select the blocks + transaction.afterSelection = Selection( + start: Position(path: node.path.child(0)), + end: Position(path: node.path.child(insertedNodes.length - 1)), + ); + } else { + transaction.afterSelection = transaction.beforeSelection; + } + await editorState.apply(transaction); + + return true; + } + + Future turnIntoPage({ + required String type, + required List selectedNodes, + required Selection selection, + required String currentViewId, + }) async { + if (type != SubPageBlockKeys.type || selectedNodes.isEmpty) { + return false; + } + + if (selectedNodes.length == 1 && + selectedNodes.first.type == SubPageBlockKeys.type) { + return true; + } + + Log.info('Turn into page'); + + final insertedNodes = selectedNodes.map((n) => n.deepCopy()).toList(); + final document = Document.blank()..insert([0], insertedNodes); + final name = await _extractNameFromNodes(selectedNodes); + + final viewResult = await ViewBackendService.createView( + layoutType: ViewLayoutPB.Document, + name: name, + parentViewId: currentViewId, + initialDataBytes: + DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer(), + ); + + await viewResult.fold( + (view) async { + final node = subPageNode(viewId: view.id); + final transaction = editorState.transaction; + transaction + ..insertNode(selection.normalized.start.path.next, node) + ..deleteNodes(selectedNodes) + ..afterSelection = Selection.collapsed(selection.normalized.start); + editorState.selectionType = SelectionType.inline; + + await editorState.apply(transaction); + + // We move views after applying transaction to avoid performing side-effects on the views + final viewIdsToMove = _extractChildViewIds(selectedNodes); + for (final viewId in viewIdsToMove) { + // Attempt to put back from trash if necessary + await TrashService.putback(viewId); + + await ViewBackendService.moveViewV2( + viewId: viewId, + newParentId: view.id, + prevViewId: null, + ); + } + }, + (err) async => Log.error(err), + ); + + return true; + } + + Future _extractNameFromNodes(List? nodes) async { + if (nodes == null || nodes.isEmpty) { + return ''; + } + + String name = ''; + for (final node in nodes) { + if (name.length > 30) { + return name.substring(0, name.length > 30 ? 30 : name.length); + } + + if (node.delta != null) { + // "ABC [Hello world]" -> ABC Hello world + final textInserts = node.delta!.whereType(); + for (final ti in textInserts) { + if (ti.attributes?[MentionBlockKeys.mention] != null) { + // fetch the view name + final pageId = ti.attributes![MentionBlockKeys.mention] + [MentionBlockKeys.pageId]; + final viewOrFailure = await ViewBackendService.getView(pageId); + + final view = viewOrFailure.toNullable(); + if (view == null) { + Log.error('Failed to fetch view with id: $pageId'); + continue; + } + + name += view.name; + } else { + name += ti.data!.toString(); + } + } + + if (name.isNotEmpty) { + break; + } + } + + if (node.children.isNotEmpty) { + final n = await _extractNameFromNodes(node.children); + if (n.isNotEmpty) { + name = n; + break; + } + } + } + + return name.substring(0, name.length > 30 ? 30 : name.length); + } + + List _extractChildViewIds(List nodes) { + final List viewIds = []; + for (final node in nodes) { + if (node.type == SubPageBlockKeys.type) { + final viewId = node.attributes[SubPageBlockKeys.viewId]; + viewIds.add(viewId); + } + + if (node.children.isNotEmpty) { + viewIds.addAll(_extractChildViewIds(node.children)); + } + + if (node.delta == null || node.delta!.isEmpty) { + continue; + } + + final textInserts = node.delta!.whereType(); + for (final ti in textInserts) { + final Map? mention = + ti.attributes?[MentionBlockKeys.mention]; + if (mention != null && + mention[MentionBlockKeys.type] == MentionType.childPage.name) { + final String? viewId = mention[MentionBlockKeys.pageId]; + if (viewId != null) { + viewIds.add(viewId); + } + } + } + } + + return viewIds; + } + + Selection? calculateTurnIntoSelection( + Node selectedNode, + Selection? beforeSelection, + ) { + final path = selectedNode.path; + final selection = Selection.collapsed(Position(path: path)); + + // if the previous selection is null or the start path is not in the same level as the current block path, + // then update the selection with the current block path + // for example,'|' means the selection, + // case 1: collapsed selection + // - bulleted item 1 + // - bulleted |item 2 + // when clicking the bulleted item 1, the bulleted item 1 path should be selected + // case 2: not collapsed selection + // - bulleted item 1 + // - bulleted |item 2 + // - bulleted |item 3 + // when clicking the bulleted item 1, the bulleted item 1 path should be selected + if (beforeSelection == null || + beforeSelection.start.path.length != path.length || + !path.inSelection(beforeSelection)) { + return selection; + } + // if the beforeSelection start with the current block, + // then updating the selection with the beforeSelection that may contains multiple blocks + return beforeSelection; + } + + Future _setToPageWidth(Node node) async { + if (node.type != SimpleTableBlockKeys.type) { + return; + } + + await editorState.setColumnWidthToPageWidth(tableNode: node); + } + + Future _distributeColumnsEvenly(Node node) async { + if (node.type != SimpleTableBlockKeys.type) { + return; + } + + await editorState.distributeColumnWidthToPageWidth(tableNode: node); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart new file mode 100644 index 0000000000000..8ab16aea4ce1c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart @@ -0,0 +1,125 @@ +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; + +import 'draggable_option_button_feedback.dart'; +import 'option_button.dart'; + +// this flag is used to disable the tooltip of the block when it is dragged +@visibleForTesting +ValueNotifier isDraggingAppFlowyEditorBlock = ValueNotifier(false); + +class DraggableOptionButton extends StatefulWidget { + const DraggableOptionButton({ + super.key, + required this.controller, + required this.editorState, + required this.blockComponentContext, + required this.blockComponentBuilder, + }); + + final PopoverController controller; + final EditorState editorState; + final BlockComponentContext blockComponentContext; + final Map blockComponentBuilder; + + @override + State createState() => _DraggableOptionButtonState(); +} + +class _DraggableOptionButtonState extends State { + late Node node; + late BlockComponentContext blockComponentContext; + + Offset? globalPosition; + + @override + void initState() { + super.initState(); + + // copy the node to avoid the node in document being updated + node = widget.blockComponentContext.node.deepCopy(); + } + + @override + void dispose() { + node.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Draggable( + data: node, + onDragStarted: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + feedback: DraggleOptionButtonFeedback( + controller: widget.controller, + editorState: widget.editorState, + blockComponentContext: widget.blockComponentContext, + blockComponentBuilder: widget.blockComponentBuilder, + ), + child: OptionButton( + isDragging: isDraggingAppFlowyEditorBlock, + controller: widget.controller, + editorState: widget.editorState, + blockComponentContext: widget.blockComponentContext, + ), + ); + } + + void _onDragStart() { + EditorNotification.dragStart().post(); + isDraggingAppFlowyEditorBlock.value = true; + widget.editorState.selectionService.removeDropTarget(); + } + + void _onDragUpdate(DragUpdateDetails details) { + isDraggingAppFlowyEditorBlock.value = true; + + widget.editorState.selectionService.renderDropTargetForOffset( + details.globalPosition, + builder: (context, data) { + return VisualDragArea( + editorState: widget.editorState, + data: data, + dragNode: widget.blockComponentContext.node, + ); + }, + ); + + globalPosition = details.globalPosition; + + // auto scroll the page when the drag position is at the edge of the screen + widget.editorState.scrollService?.startAutoScroll( + details.localPosition, + ); + } + + void _onDragEnd(DraggableDetails details) { + isDraggingAppFlowyEditorBlock.value = false; + + widget.editorState.selectionService.removeDropTarget(); + + if (globalPosition == null) { + return; + } + + final data = widget.editorState.selectionService.getDropTargetRenderData( + globalPosition!, + ); + dragToMoveNode( + context, + node: widget.blockComponentContext.node, + acceptedPath: data?.cursorNode?.path, + dragOffset: globalPosition!, + ).then((_) { + EditorNotification.dragEnd().post(); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button_feedback.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button_feedback.dart new file mode 100644 index 0000000000000..0b6c89599a589 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button_feedback.dart @@ -0,0 +1,263 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class DraggleOptionButtonFeedback extends StatefulWidget { + const DraggleOptionButtonFeedback({ + super.key, + required this.controller, + required this.editorState, + required this.blockComponentContext, + required this.blockComponentBuilder, + }); + + final PopoverController controller; + final EditorState editorState; + final BlockComponentContext blockComponentContext; + final Map blockComponentBuilder; + + @override + State createState() => + _DraggleOptionButtonFeedbackState(); +} + +class _DraggleOptionButtonFeedbackState + extends State { + late Node node; + late BlockComponentContext blockComponentContext; + + @override + void initState() { + super.initState(); + + _setupLockComponentContext(); + widget.blockComponentContext.node.addListener(_updateBlockComponentContext); + } + + @override + void dispose() { + widget.blockComponentContext.node + .removeListener(_updateBlockComponentContext); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final maxWidth = (widget.editorState.renderBox?.size.width ?? + MediaQuery.of(context).size.width) * + 0.8; + + return Opacity( + opacity: 0.7, + child: Material( + color: Colors.transparent, + child: Container( + constraints: BoxConstraints( + maxWidth: maxWidth, + ), + child: IntrinsicHeight( + child: Provider.value( + value: widget.editorState, + child: _buildBlock(), + ), + ), + ), + ), + ); + } + + Widget _buildBlock() { + final node = widget.blockComponentContext.node; + final builder = widget.blockComponentBuilder[node.type]; + if (builder == null) { + return const SizedBox.shrink(); + } + + const unsupportedRenderBlockTypes = [ + TableBlockKeys.type, + CustomImageBlockKeys.type, + MultiImageBlockKeys.type, + FileBlockKeys.type, + DatabaseBlockKeys.boardType, + DatabaseBlockKeys.calendarType, + DatabaseBlockKeys.gridType, + ]; + + if (unsupportedRenderBlockTypes.contains(node.type)) { + // unable to render table block without provider/context + // render a placeholder instead + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(8), + ), + child: FlowyText(node.type.replaceAll('_', ' ').capitalize()), + ); + } + + return IntrinsicHeight( + child: MultiProvider( + providers: [ + Provider.value(value: widget.editorState), + Provider.value(value: getIt()), + ], + child: builder.build(blockComponentContext), + ), + ); + } + + void _updateBlockComponentContext() { + setState(() => _setupLockComponentContext()); + } + + void _setupLockComponentContext() { + node = widget.blockComponentContext.node.deepCopy(); + blockComponentContext = BlockComponentContext( + widget.blockComponentContext.buildContext, + node, + ); + } +} + +class _OptionButton extends StatefulWidget { + const _OptionButton({ + required this.controller, + required this.editorState, + required this.blockComponentContext, + required this.isDragging, + }); + + final PopoverController controller; + final EditorState editorState; + final BlockComponentContext blockComponentContext; + final ValueNotifier isDragging; + + @override + State<_OptionButton> createState() => _OptionButtonState(); +} + +const _interceptorKey = 'document_option_button_interceptor'; + +class _OptionButtonState extends State<_OptionButton> { + late final gestureInterceptor = SelectionGestureInterceptor( + key: _interceptorKey, + canTap: (details) => !_isTapInBounds(details.globalPosition), + ); + + // the selection will be cleared when tap the option button + // so we need to restore the selection after tap the option button + Selection? beforeSelection; + RenderBox? get renderBox => context.findRenderObject() as RenderBox?; + + @override + void initState() { + super.initState(); + + widget.editorState.service.selectionService.registerGestureInterceptor( + gestureInterceptor, + ); + } + + @override + void dispose() { + widget.editorState.service.selectionService.unregisterGestureInterceptor( + _interceptorKey, + ); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.isDragging, + builder: (context, isDragging, child) { + return BlockActionButton( + svg: FlowySvgs.drag_element_s, + showTooltip: !isDragging, + richMessage: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.document_plugins_optionAction_drag.tr(), + style: context.tooltipTextStyle(), + ), + TextSpan( + text: LocaleKeys.document_plugins_optionAction_toMove.tr(), + style: context.tooltipTextStyle(), + ), + const TextSpan(text: '\n'), + TextSpan( + text: LocaleKeys.document_plugins_optionAction_click.tr(), + style: context.tooltipTextStyle(), + ), + TextSpan( + text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(), + style: context.tooltipTextStyle(), + ), + ], + ), + onPointerDown: () { + if (widget.editorState.selection != null) { + beforeSelection = widget.editorState.selection; + } + }, + onTap: () { + if (widget.editorState.selection != null) { + beforeSelection = widget.editorState.selection; + } + + widget.controller.show(); + + // update selection + _updateBlockSelection(); + }, + ); + }, + ); + } + + void _updateBlockSelection() { + if (beforeSelection == null) { + final path = widget.blockComponentContext.node.path; + final selection = Selection.collapsed( + Position(path: path), + ); + widget.editorState.updateSelectionWithReason( + selection, + customSelectionType: SelectionType.block, + ); + } else { + widget.editorState.updateSelectionWithReason( + beforeSelection!, + customSelectionType: SelectionType.block, + ); + } + } + + bool _isTapInBounds(Offset offset) { + if (renderBox == null) { + return false; + } + + final localPosition = renderBox!.globalToLocal(offset); + final result = renderBox!.paintBounds.contains(localPosition); + if (result) { + beforeSelection = widget.editorState.selection; + } else { + beforeSelection = null; + } + + return result; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/option_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/option_button.dart new file mode 100644 index 0000000000000..fa6b771b74905 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/option_button.dart @@ -0,0 +1,136 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +const _interceptorKey = 'document_option_button_interceptor'; + +class OptionButton extends StatefulWidget { + const OptionButton({ + super.key, + required this.controller, + required this.editorState, + required this.blockComponentContext, + required this.isDragging, + }); + + final PopoverController controller; + final EditorState editorState; + final BlockComponentContext blockComponentContext; + final ValueNotifier isDragging; + + @override + State createState() => _OptionButtonState(); +} + +class _OptionButtonState extends State { + late final registerKey = + _interceptorKey + widget.blockComponentContext.node.id; + late final gestureInterceptor = SelectionGestureInterceptor( + key: registerKey, + canTap: (details) => !_isTapInBounds(details.globalPosition), + ); + + // the selection will be cleared when tap the option button + // so we need to restore the selection after tap the option button + Selection? beforeSelection; + RenderBox? get renderBox => context.findRenderObject() as RenderBox?; + + @override + void initState() { + super.initState(); + + widget.editorState.service.selectionService.registerGestureInterceptor( + gestureInterceptor, + ); + } + + @override + void dispose() { + widget.editorState.service.selectionService.unregisterGestureInterceptor( + registerKey, + ); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.isDragging, + builder: (context, isDragging, child) { + return BlockActionButton( + svg: FlowySvgs.drag_element_s, + showTooltip: !isDragging, + richMessage: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.document_plugins_optionAction_drag.tr(), + style: context.tooltipTextStyle(), + ), + TextSpan( + text: LocaleKeys.document_plugins_optionAction_toMove.tr(), + style: context.tooltipTextStyle(), + ), + const TextSpan(text: '\n'), + TextSpan( + text: LocaleKeys.document_plugins_optionAction_click.tr(), + style: context.tooltipTextStyle(), + ), + TextSpan( + text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(), + style: context.tooltipTextStyle(), + ), + ], + ), + onTap: () { + final selection = widget.editorState.selection; + if (selection != null) { + beforeSelection = selection.normalized; + } + + widget.controller.show(); + + // update selection + _updateBlockSelection(context); + }, + ); + }, + ); + } + + void _updateBlockSelection(BuildContext context) { + final cubit = context.read(); + final selection = cubit.calculateTurnIntoSelection( + widget.blockComponentContext.node, + beforeSelection, + ); + + widget.editorState.updateSelectionWithReason( + selection, + customSelectionType: SelectionType.block, + ); + } + + bool _isTapInBounds(Offset offset) { + final renderBox = this.renderBox; + if (renderBox == null) { + return false; + } + + final localPosition = renderBox.globalToLocal(offset); + final result = renderBox.paintBounds.contains(localPosition); + if (result) { + beforeSelection = widget.editorState.selection?.normalized; + } else { + beforeSelection = null; + } + + return result; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart new file mode 100644 index 0000000000000..ddfb6f3a42a83 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart @@ -0,0 +1,148 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +enum HorizontalPosition { left, center, right } + +enum VerticalPosition { top, middle, bottom } + +Future dragToMoveNode( + BuildContext context, { + required Node node, + required Offset dragOffset, + Path? acceptedPath, +}) async { + if (acceptedPath == null) { + Log.info('acceptedPath is null'); + return; + } + + final editorState = context.read(); + final targetNode = editorState.getNodeAtPath(acceptedPath); + if (targetNode == null) { + Log.info('targetNode is null'); + return; + } + + final position = getDragAreaPosition(context, targetNode, dragOffset); + if (position == null) { + Log.info('position is null'); + return; + } + + final (verticalPosition, horizontalPosition, _) = position; + Path newPath = targetNode.path; + + // Determine the new path based on drop position + // For VerticalPosition.top, we keep the target node's path + if (verticalPosition == VerticalPosition.bottom) { + if (horizontalPosition == HorizontalPosition.left) { + newPath = newPath.next; + final node = editorState.document.nodeAtPath(newPath); + if (node == null) { + // if node is null, it means the node is the last one of the document. + newPath = targetNode.path; + } + } else { + newPath = newPath.child(0); + } + } + + // Check if the drop should be ignored + if (shouldIgnoreDragTarget( + editorState: editorState, + dragNode: node, + targetPath: newPath, + )) { + Log.info( + 'Drop ignored: node($node, ${node.path}), path($acceptedPath)', + ); + return; + } + + Log.info('Moving node($node, ${node.path}) to path($newPath)'); + + final transaction = editorState.transaction; + transaction.insertNode(newPath, node.deepCopy()); + transaction.deleteNode(node); + await editorState.apply(transaction); +} + +(VerticalPosition, HorizontalPosition, Rect)? getDragAreaPosition( + BuildContext context, + Node dragTargetNode, + Offset dragOffset, +) { + final selectable = dragTargetNode.selectable; + final renderBox = selectable?.context.findRenderObject() as RenderBox?; + if (selectable == null || renderBox == null) { + return null; + } + + // disable the table cell block + if (dragTargetNode.parent?.type == TableCellBlockKeys.type) { + return null; + } + + final globalBlockOffset = renderBox.localToGlobal(Offset.zero); + final globalBlockRect = globalBlockOffset & renderBox.size; + + // Check if the dragOffset is within the globalBlockRect + final isInside = globalBlockRect.contains(dragOffset); + + if (!isInside) { + Log.info( + 'the drag offset is not inside the block, dragOffset($dragOffset), globalBlockRect($globalBlockRect)', + ); + return null; + } + + // Determine the relative position + HorizontalPosition horizontalPosition = HorizontalPosition.left; + VerticalPosition verticalPosition; + + // Horizontal position + if (dragOffset.dx < globalBlockRect.left + 88) { + horizontalPosition = HorizontalPosition.left; + } else if (indentableBlockTypes.contains(dragTargetNode.type)) { + // For indentable blocks, it means the block can contain a child block. + // ignore the middle here, it's not used in this example + horizontalPosition = HorizontalPosition.right; + } + + // Vertical position + if (dragOffset.dy < globalBlockRect.top + globalBlockRect.height / 2) { + verticalPosition = VerticalPosition.top; + } else { + verticalPosition = VerticalPosition.bottom; + } + + return (verticalPosition, horizontalPosition, globalBlockRect); +} + +bool shouldIgnoreDragTarget({ + required EditorState editorState, + required Node dragNode, + required Path? targetPath, +}) { + if (targetPath == null) { + return true; + } + + if (dragNode.path.equals(targetPath)) { + return true; + } + + if (dragNode.path.isAncestorOf(targetPath)) { + return true; + } + + final targetNode = editorState.getNodeAtPath(targetPath); + if (targetNode != null && targetNode.isInTable) { + return true; + } + + return false; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart new file mode 100644 index 0000000000000..f3499a9ea5fc4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart @@ -0,0 +1,90 @@ +import 'dart:math'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'util.dart'; + +class VisualDragArea extends StatelessWidget { + const VisualDragArea({ + super.key, + required this.data, + required this.dragNode, + required this.editorState, + }); + + final DragAreaBuilderData data; + final Node dragNode; + final EditorState editorState; + @override + Widget build(BuildContext context) { + final targetNode = data.targetNode; + + final ignore = shouldIgnoreDragTarget( + editorState: editorState, + dragNode: dragNode, + targetPath: targetNode.path, + ); + if (ignore) { + return const SizedBox.shrink(); + } + + final selectable = targetNode.selectable; + final renderBox = selectable?.context.findRenderObject() as RenderBox?; + if (selectable == null || renderBox == null) { + return const SizedBox.shrink(); + } + + final position = getDragAreaPosition( + context, + targetNode, + data.dragOffset, + ); + + if (position == null) { + return const SizedBox.shrink(); + } + + final (verticalPosition, horizontalPosition, globalBlockRect) = position; + + // 44 is the width of the drag indicator + const indicatorWidth = 44.0; + final width = globalBlockRect.width - indicatorWidth; + + Widget child = Container( + height: 2, + width: max(width, 0.0), + color: Theme.of(context).colorScheme.primary, + ); + + if (horizontalPosition == HorizontalPosition.right) { + const breakWidth = 22.0; + const padding = 8.0; + child = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 2, + width: breakWidth, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: padding), + Container( + height: 2, + width: width - breakWidth - padding, + color: Theme.of(context).colorScheme.primary, + ), + ], + ); + } + + return Positioned( + top: verticalPosition == VerticalPosition.top + ? globalBlockRect.top + : globalBlockRect.bottom, + // 44 is the width of the drag indicator + left: globalBlockRect.left + 44, + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart new file mode 100644 index 0000000000000..539dd313b149f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart @@ -0,0 +1,124 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:universal_platform/universal_platform.dart'; + +/// The ... button shows on the top right corner of a block. +/// +/// Default actions are: +/// - delete +/// - duplicate +/// - insert above +/// - insert below +/// +/// Only works on mobile. +class MobileBlockActionButtons extends StatelessWidget { + const MobileBlockActionButtons({ + super.key, + this.extendActionWidgets = const [], + this.showThreeDots = true, + required this.node, + required this.editorState, + required this.child, + }); + + final Node node; + final EditorState editorState; + final List extendActionWidgets; + final Widget child; + final bool showThreeDots; + + @override + Widget build(BuildContext context) { + if (!UniversalPlatform.isMobile) { + return child; + } + + if (!showThreeDots) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _showBottomSheet(context), + child: child, + ); + } + + const padding = 10.0; + return Stack( + children: [ + child, + Positioned( + top: padding, + right: padding, + child: FlowyIconButton( + icon: const FlowySvg( + FlowySvgs.three_dots_s, + ), + width: 20.0, + onPressed: () => _showBottomSheet(context), + ), + ), + ], + ); + } + + void _showBottomSheet(BuildContext context) { + // close the keyboard + editorState.updateSelectionWithReason(null, extraInfo: {}); + + showMobileBottomSheet( + context, + showHeader: true, + showCloseButton: true, + showDragHandle: true, + title: LocaleKeys.document_plugins_action.tr(), + builder: (context) { + return BlockActionBottomSheet( + extendActionWidgets: extendActionWidgets, + onAction: (action) async { + context.pop(); + + final transaction = editorState.transaction; + switch (action) { + case BlockActionBottomSheetType.delete: + transaction.deleteNode(node); + break; + case BlockActionBottomSheetType.duplicate: + transaction.insertNode( + node.path.next, + node.deepCopy(), + ); + break; + case BlockActionBottomSheetType.insertAbove: + case BlockActionBottomSheetType.insertBelow: + final path = action == BlockActionBottomSheetType.insertAbove + ? node.path + : node.path.next; + transaction + ..insertNode( + path, + paragraphNode(), + ) + ..afterSelection = Selection.collapsed( + Position( + path: path, + ), + ); + break; + default: + } + + if (transaction.operations.isNotEmpty) { + await editorState.apply(transaction); + } + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/align_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/align_option_action.dart new file mode 100644 index 0000000000000..7f91f7e2b031f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/align_option_action.dart @@ -0,0 +1,167 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +enum OptionAlignType { + left, + center, + right; + + static OptionAlignType fromString(String? value) { + switch (value) { + case 'left': + return OptionAlignType.left; + case 'center': + return OptionAlignType.center; + case 'right': + return OptionAlignType.right; + default: + return OptionAlignType.center; + } + } + + FlowySvgData get svg { + switch (this) { + case OptionAlignType.left: + return FlowySvgs.align_left_s; + case OptionAlignType.center: + return FlowySvgs.align_center_s; + case OptionAlignType.right: + return FlowySvgs.align_right_s; + } + } + + String get description { + switch (this) { + case OptionAlignType.left: + return LocaleKeys.document_plugins_optionAction_left.tr(); + case OptionAlignType.center: + return LocaleKeys.document_plugins_optionAction_center.tr(); + case OptionAlignType.right: + return LocaleKeys.document_plugins_optionAction_right.tr(); + } + } +} + +class AlignOptionAction extends PopoverActionCell { + AlignOptionAction({ + required this.editorState, + }); + + final EditorState editorState; + + @override + Widget? leftIcon(Color iconColor) { + return FlowySvg( + align.svg, + size: const Size.square(18), + ); + } + + @override + String get name { + return LocaleKeys.document_plugins_optionAction_align.tr(); + } + + @override + PopoverActionCellBuilder get builder => + (context, parentController, controller) { + final selection = editorState.selection?.normalized; + if (selection == null) { + return const SizedBox.shrink(); + } + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return const SizedBox.shrink(); + } + final children = buildAlignOptions(context, (align) async { + await onAlignChanged(align); + controller.close(); + parentController.close(); + }); + return IntrinsicHeight( + child: IntrinsicWidth( + child: Column( + children: children, + ), + ), + ); + }; + + List buildAlignOptions( + BuildContext context, + void Function(OptionAlignType) onTap, + ) { + return OptionAlignType.values.map((e) => OptionAlignWrapper(e)).map((e) { + final leftIcon = e.leftIcon(Theme.of(context).colorScheme.onSurface); + final rightIcon = e.rightIcon(Theme.of(context).colorScheme.onSurface); + return HoverButton( + onTap: () => onTap(e.inner), + itemHeight: ActionListSizes.itemHeight, + leftIcon: SizedBox( + width: 16, + height: 16, + child: leftIcon, + ), + name: e.name, + rightIcon: rightIcon, + ); + }).toList(); + } + + OptionAlignType get align { + final selection = editorState.selection; + if (selection == null) { + return OptionAlignType.center; + } + final node = editorState.getNodeAtPath(selection.start.path); + final align = node?.type == SimpleTableBlockKeys.type + ? node?.tableAlign.key + : node?.attributes[blockComponentAlign]; + return OptionAlignType.fromString(align); + } + + Future onAlignChanged(OptionAlignType align) async { + if (align == this.align) { + return; + } + final selection = editorState.selection; + if (selection == null) { + return; + } + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return; + } + // the align attribute for simple table is not same as the align type, + // so we need to convert the align type to the align attribute + if (node.type == SimpleTableBlockKeys.type) { + await editorState.updateTableAlign( + tableNode: node, + align: TableAlign.fromString(align.name), + ); + } else { + final transaction = editorState.transaction; + transaction.updateNode(node, { + blockComponentAlign: align.name, + }); + await editorState.apply(transaction); + } + } +} + +class OptionAlignWrapper extends ActionCell { + OptionAlignWrapper(this.inner); + + final OptionAlignType inner; + + @override + Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg); + + @override + String get name => inner.description; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/color_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/color_option_action.dart new file mode 100644 index 0000000000000..cb1ec36b56293 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/color_option_action.dart @@ -0,0 +1,176 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +const optionActionColorDefaultColor = 'appflowy_theme_default_color'; + +class ColorOptionAction extends CustomActionCell { + ColorOptionAction({ + required this.editorState, + required this.mutex, + }); + + final EditorState editorState; + final PopoverController innerController = PopoverController(); + final PopoverMutex mutex; + + @override + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { + return ColorOptionButton( + editorState: editorState, + mutex: this.mutex, + controller: controller, + ); + } +} + +class ColorOptionButton extends StatefulWidget { + const ColorOptionButton({ + super.key, + required this.editorState, + required this.mutex, + required this.controller, + }); + + final EditorState editorState; + final PopoverMutex mutex; + final PopoverController controller; + + @override + State createState() => _ColorOptionButtonState(); +} + +class _ColorOptionButtonState extends State { + final PopoverController innerController = PopoverController(); + bool isOpen = false; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + asBarrier: true, + controller: innerController, + mutex: widget.mutex, + popupBuilder: (context) { + isOpen = true; + return _buildColorOptionMenu( + context, + widget.controller, + ); + }, + onClose: () => isOpen = false, + direction: PopoverDirection.rightWithCenterAligned, + animationDuration: Durations.short3, + beginScaleFactor: 1.0, + beginOpacity: 0.8, + child: HoverButton( + itemHeight: ActionListSizes.itemHeight, + leftIcon: const FlowySvg( + FlowySvgs.color_format_m, + size: Size.square(15), + ), + name: LocaleKeys.document_plugins_optionAction_color.tr(), + onTap: () { + if (!isOpen) { + innerController.show(); + } + }, + ), + ); + } + + Widget _buildColorOptionMenu( + BuildContext context, + PopoverController controller, + ) { + final selection = widget.editorState.selection?.normalized; + if (selection == null) { + return const SizedBox.shrink(); + } + + final node = widget.editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return const SizedBox.shrink(); + } + + return _buildColorOptions(context, node, controller); + } + + Widget _buildColorOptions( + BuildContext context, + Node node, + PopoverController controller, + ) { + final selection = widget.editorState.selection?.normalized; + if (selection == null) { + return const SizedBox.shrink(); + } + final node = widget.editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return const SizedBox.shrink(); + } + final bgColor = node.attributes[blockComponentBackgroundColor] as String?; + final selectedColor = bgColor?.tryToColor(); + // get default background color for callout block from themeExtension + final defaultColor = node.type == CalloutBlockKeys.type + ? AFThemeExtension.of(context).calloutBGColor + : Colors.transparent; + final colors = [ + // reset to default background color + FlowyColorOption( + color: defaultColor, + i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(), + id: optionActionColorDefaultColor, + ), + ...FlowyTint.values.map( + (e) => FlowyColorOption( + color: e.color(context), + i18n: e.tintName(AppFlowyEditorL10n.current), + id: e.id, + ), + ), + ]; + + return FlowyColorPicker( + colors: colors, + selected: selectedColor, + border: Border.all( + color: AFThemeExtension.of(context).onBackground, + ), + onTap: (option, index) async { + final editorState = widget.editorState; + final transaction = editorState.transaction; + final selectionType = editorState.selectionType; + final selection = editorState.selection; + + // In multiple selection, we need to update all the nodes in the selection + if (selectionType == SelectionType.block && selection != null) { + final nodes = editorState.getNodesInSelection(selection.normalized); + for (final node in nodes) { + transaction.updateNode(node, { + blockComponentBackgroundColor: option.id, + }); + } + } else { + transaction.updateNode(node, { + blockComponentBackgroundColor: option.id, + }); + } + + await widget.editorState.apply(transaction); + + innerController.close(); + controller.close(); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/depth_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/depth_option_action.dart new file mode 100644 index 0000000000000..bc083cd61763c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/depth_option_action.dart @@ -0,0 +1,142 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +enum OptionDepthType { + h1(1, 'H1'), + h2(2, 'H2'), + h3(3, 'H3'), + h4(4, 'H4'), + h5(5, 'H5'), + h6(6, 'H6'); + + const OptionDepthType(this.level, this.description); + + final String description; + final int level; + + static OptionDepthType fromLevel(int? level) { + switch (level) { + case 1: + return OptionDepthType.h1; + case 2: + return OptionDepthType.h2; + case 3: + default: + return OptionDepthType.h3; + } + } +} + +class DepthOptionAction extends PopoverActionCell { + DepthOptionAction({ + required this.editorState, + }); + + final EditorState editorState; + + @override + Widget? leftIcon(Color iconColor) { + return FlowySvg( + OptionAction.depth.svg, + size: const Size.square(16), + ); + } + + @override + String get name => LocaleKeys.document_plugins_optionAction_depth.tr(); + + @override + PopoverActionCellBuilder get builder => + (context, parentController, controller) { + return DepthOptionMenu( + onTap: (depth) async { + await onDepthChanged(depth); + parentController.close(); + parentController.close(); + }, + ); + }; + + OptionDepthType depth(Node node) { + final level = node.attributes[OutlineBlockKeys.depth]; + return OptionDepthType.fromLevel(level); + } + + Future onDepthChanged(OptionDepthType depth) async { + final selection = editorState.selection; + final node = selection != null + ? editorState.getNodeAtPath(selection.start.path) + : null; + + if (node == null || depth == this.depth(node)) return; + + final transaction = editorState.transaction; + transaction.updateNode( + node, + {OutlineBlockKeys.depth: depth.level}, + ); + await editorState.apply(transaction); + } +} + +class DepthOptionMenu extends StatelessWidget { + const DepthOptionMenu({ + super.key, + required this.onTap, + }); + + final Future Function(OptionDepthType) onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 42, + child: Column( + mainAxisSize: MainAxisSize.min, + children: buildDepthOptions(context, onTap), + ), + ); + } + + List buildDepthOptions( + BuildContext context, + Future Function(OptionDepthType) onTap, + ) { + return OptionDepthType.values + .map((e) => OptionDepthWrapper(e)) + .map( + (e) => HoverButton( + onTap: () => onTap(e.inner), + itemHeight: ActionListSizes.itemHeight, + name: e.name, + ), + ) + .toList(); + } +} + +class OptionDepthWrapper extends ActionCell { + OptionDepthWrapper(this.inner); + + final OptionDepthType inner; + + @override + String get name => inner.description; +} + +class OptionActionWrapper extends ActionCell { + OptionActionWrapper(this.inner); + + final OptionAction inner; + + @override + Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg); + + @override + String get name => inner.description; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/divider_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/divider_option_action.dart new file mode 100644 index 0000000000000..75e4339b5e773 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/divider_option_action.dart @@ -0,0 +1,20 @@ +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; + +class DividerOptionAction extends CustomActionCell { + @override + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 4.0), + child: Divider( + height: 1.0, + thickness: 1.0, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart new file mode 100644 index 0000000000000..14ec6773a6081 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart @@ -0,0 +1,140 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart' hide TextDirection; +import 'package:easy_localization/easy_localization.dart'; + +export 'align_option_action.dart'; +export 'color_option_action.dart'; +export 'depth_option_action.dart'; +export 'divider_option_action.dart'; +export 'turn_into_option_action.dart'; + +enum EditorOptionActionType { + turnInto, + color, + align, + depth; + + Set get supportTypes { + switch (this) { + case EditorOptionActionType.turnInto: + return { + ParagraphBlockKeys.type, + HeadingBlockKeys.type, + QuoteBlockKeys.type, + CalloutBlockKeys.type, + BulletedListBlockKeys.type, + NumberedListBlockKeys.type, + TodoListBlockKeys.type, + ToggleListBlockKeys.type, + SubPageBlockKeys.type, + }; + case EditorOptionActionType.color: + return { + ParagraphBlockKeys.type, + HeadingBlockKeys.type, + BulletedListBlockKeys.type, + NumberedListBlockKeys.type, + QuoteBlockKeys.type, + TodoListBlockKeys.type, + CalloutBlockKeys.type, + OutlineBlockKeys.type, + ToggleListBlockKeys.type, + }; + case EditorOptionActionType.align: + return { + ImageBlockKeys.type, + SimpleTableBlockKeys.type, + }; + case EditorOptionActionType.depth: + return { + OutlineBlockKeys.type, + }; + } + } +} + +enum OptionAction { + delete, + duplicate, + turnInto, + moveUp, + moveDown, + copyLinkToBlock, + + /// callout background color + color, + divider, + align, + + // Outline block + depth, + + // Simple table + setToPageWidth, + distributeColumnsEvenly; + + FlowySvgData get svg { + switch (this) { + case OptionAction.delete: + return FlowySvgs.trash_s; + case OptionAction.duplicate: + return FlowySvgs.copy_s; + case OptionAction.turnInto: + return FlowySvgs.turninto_s; + case OptionAction.moveUp: + return const FlowySvgData('editor/move_up'); + case OptionAction.moveDown: + return const FlowySvgData('editor/move_down'); + case OptionAction.color: + return const FlowySvgData('editor/color'); + case OptionAction.divider: + return const FlowySvgData('editor/divider'); + case OptionAction.align: + return FlowySvgs.m_aa_bulleted_list_s; + case OptionAction.depth: + return FlowySvgs.tag_s; + case OptionAction.copyLinkToBlock: + return FlowySvgs.share_tab_copy_s; + case OptionAction.setToPageWidth: + return FlowySvgs.table_set_to_page_width_s; + case OptionAction.distributeColumnsEvenly: + return FlowySvgs.table_distribute_columns_evenly_s; + } + } + + String get description { + switch (this) { + case OptionAction.delete: + return LocaleKeys.document_plugins_optionAction_delete.tr(); + case OptionAction.duplicate: + return LocaleKeys.document_plugins_optionAction_duplicate.tr(); + case OptionAction.turnInto: + return LocaleKeys.document_plugins_optionAction_turnInto.tr(); + case OptionAction.moveUp: + return LocaleKeys.document_plugins_optionAction_moveUp.tr(); + case OptionAction.moveDown: + return LocaleKeys.document_plugins_optionAction_moveDown.tr(); + case OptionAction.color: + return LocaleKeys.document_plugins_optionAction_color.tr(); + case OptionAction.align: + return LocaleKeys.document_plugins_optionAction_align.tr(); + case OptionAction.depth: + return LocaleKeys.document_plugins_optionAction_depth.tr(); + case OptionAction.copyLinkToBlock: + return LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr(); + case OptionAction.divider: + throw UnsupportedError('Divider does not have description'); + case OptionAction.setToPageWidth: + return LocaleKeys + .document_plugins_simpleTable_moreActions_setToPageWidth + .tr(); + case OptionAction.distributeColumnsEvenly: + return LocaleKeys + .document_plugins_simpleTable_moreActions_distributeColumnsWidth + .tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart new file mode 100644 index 0000000000000..ac3774a511c97 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart @@ -0,0 +1,360 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class TurnIntoOptionAction extends CustomActionCell { + TurnIntoOptionAction({ + required this.editorState, + required this.blockComponentBuilder, + required this.mutex, + }); + + final EditorState editorState; + final Map blockComponentBuilder; + final PopoverController innerController = PopoverController(); + final PopoverMutex mutex; + + @override + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { + return TurnInfoButton( + editorState: editorState, + blockComponentBuilder: blockComponentBuilder, + mutex: this.mutex, + ); + } +} + +class TurnInfoButton extends StatefulWidget { + const TurnInfoButton({ + super.key, + required this.editorState, + required this.blockComponentBuilder, + required this.mutex, + }); + + final EditorState editorState; + final Map blockComponentBuilder; + final PopoverMutex mutex; + + @override + State createState() => _TurnInfoButtonState(); +} + +class _TurnInfoButtonState extends State { + final PopoverController innerController = PopoverController(); + bool isOpen = false; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + asBarrier: true, + controller: innerController, + mutex: widget.mutex, + popupBuilder: (context) { + isOpen = true; + return BlocProvider( + create: (context) => BlockActionOptionCubit( + editorState: widget.editorState, + blockComponentBuilder: widget.blockComponentBuilder, + ), + child: BlocBuilder( + builder: (context, _) => _buildTurnIntoOptionMenu(context), + ), + ); + }, + onClose: () => isOpen = false, + direction: PopoverDirection.rightWithCenterAligned, + animationDuration: Durations.short3, + beginScaleFactor: 1.0, + beginOpacity: 0.8, + child: HoverButton( + itemHeight: ActionListSizes.itemHeight, + // todo(lucas): replace the svg with the correct one + leftIcon: const FlowySvg(FlowySvgs.turninto_s), + name: LocaleKeys.document_plugins_optionAction_turnInto.tr(), + onTap: () { + if (!isOpen) { + innerController.show(); + } + }, + ), + ); + } + + Widget _buildTurnIntoOptionMenu(BuildContext context) { + final selection = widget.editorState.selection?.normalized; + // the selection may not be collapsed, for example, if a block contains some children, + // the selection will be the start from the current block and end at the last child block. + // we should take care of this case: + // converting a block that contains children to a heading block, + // we should move all the children under the heading block. + if (selection == null) { + return const SizedBox.shrink(); + } + + final node = widget.editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return const SizedBox.shrink(); + } + + return TurnIntoOptionMenu( + node: node, + hasNonSupportedTypes: _hasNonSupportedTypes(selection), + ); + } + + bool _hasNonSupportedTypes(Selection selection) { + final nodes = widget.editorState.getNodesInSelection(selection); + if (nodes.isEmpty) { + return false; + } + + for (final node in nodes) { + if (!EditorOptionActionType.turnInto.supportTypes.contains(node.type)) { + return true; + } + } + + return false; + } +} + +class TurnIntoOptionMenu extends StatelessWidget { + const TurnIntoOptionMenu({ + super.key, + required this.node, + required this.hasNonSupportedTypes, + }); + + final Node node; + + /// Signifies whether the selection contains [Node]s that are not supported, + /// these often do not have a [Delta], example could be [FileBlockComponent]. + /// + final bool hasNonSupportedTypes; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: _buildTurnIntoOptions(context, node), + ); + } + + List _buildTurnIntoOptions(BuildContext context, Node node) { + final children = []; + + if (hasNonSupportedTypes) { + return children + ..add( + _TurnInfoButton( + type: SubPageBlockKeys.type, + node: node, + ), + ); + } + + for (final type in EditorOptionActionType.turnInto.supportTypes) { + if (type == ToggleListBlockKeys.type) { + // toggle list block and toggle heading block are the same type, + // but they have different attributes. + + // toggle list block + children.add( + _TurnInfoButton( + type: type, + node: node, + ), + ); + + // toggle heading block + for (final i in [1, 2, 3]) { + children.add( + _TurnInfoButton( + type: type, + node: node, + level: i, + ), + ); + } + } else if (type != HeadingBlockKeys.type) { + children.add( + _TurnInfoButton( + type: type, + node: node, + ), + ); + } else { + for (final i in [1, 2, 3]) { + children.add( + _TurnInfoButton( + type: type, + node: node, + level: i, + ), + ); + } + } + } + + return children; + } +} + +class _TurnInfoButton extends StatelessWidget { + const _TurnInfoButton({ + required this.type, + required this.node, + this.level, + }); + + final String type; + final Node node; + final int? level; + + @override + Widget build(BuildContext context) { + final name = _buildLocalization(type, level: level); + final leftIcon = _buildLeftIcon(type, level: level); + final rightIcon = _buildRightIcon(type, node, level: level); + + return HoverButton( + name: name, + leftIcon: FlowySvg(leftIcon), + rightIcon: rightIcon, + itemHeight: ActionListSizes.itemHeight, + onTap: () => context.read().turnIntoBlock( + type, + node, + level: level, + currentViewId: getIt().latestOpenView?.id, + ), + ); + } + + Widget? _buildRightIcon(String type, Node node, {int? level}) { + if (type != node.type) { + return null; + } + + if (node.type == HeadingBlockKeys.type) { + final nodeLevel = node.attributes[HeadingBlockKeys.level] ?? 1; + if (level != nodeLevel) { + return null; + } + } + + if (node.type == ToggleListBlockKeys.type) { + final nodeLevel = node.attributes[ToggleListBlockKeys.level]; + if (level != nodeLevel) { + return null; + } + } + + return const FlowySvg( + FlowySvgs.workspace_selected_s, + blendMode: null, + ); + } + + FlowySvgData _buildLeftIcon(String type, {int? level}) { + if (type == ParagraphBlockKeys.type) { + return FlowySvgs.slash_menu_icon_text_s; + } else if (type == HeadingBlockKeys.type) { + switch (level) { + case 1: + return FlowySvgs.slash_menu_icon_h1_s; + case 2: + return FlowySvgs.slash_menu_icon_h2_s; + case 3: + return FlowySvgs.slash_menu_icon_h3_s; + default: + return FlowySvgs.slash_menu_icon_text_s; + } + } else if (type == QuoteBlockKeys.type) { + return FlowySvgs.slash_menu_icon_quote_s; + } else if (type == BulletedListBlockKeys.type) { + return FlowySvgs.slash_menu_icon_bulleted_list_s; + } else if (type == NumberedListBlockKeys.type) { + return FlowySvgs.slash_menu_icon_numbered_list_s; + } else if (type == TodoListBlockKeys.type) { + return FlowySvgs.slash_menu_icon_checkbox_s; + } else if (type == CalloutBlockKeys.type) { + return FlowySvgs.slash_menu_icon_callout_s; + } else if (type == SubPageBlockKeys.type) { + return FlowySvgs.icon_document_s; + } else if (type == ToggleListBlockKeys.type) { + switch (level) { + case 1: + return FlowySvgs.toggle_heading1_s; + case 2: + return FlowySvgs.toggle_heading2_s; + case 3: + return FlowySvgs.toggle_heading3_s; + default: + return FlowySvgs.slash_menu_icon_toggle_s; + } + } + + throw UnimplementedError('Unsupported block type: $type'); + } + + String _buildLocalization( + String type, { + int? level, + }) { + switch (type) { + case ParagraphBlockKeys.type: + return LocaleKeys.document_slashMenu_name_text.tr(); + case HeadingBlockKeys.type: + switch (level) { + case 1: + return LocaleKeys.document_slashMenu_name_heading1.tr(); + case 2: + return LocaleKeys.document_slashMenu_name_heading2.tr(); + case 3: + return LocaleKeys.document_slashMenu_name_heading3.tr(); + default: + return LocaleKeys.document_slashMenu_name_text.tr(); + } + case QuoteBlockKeys.type: + return LocaleKeys.document_slashMenu_name_quote.tr(); + case BulletedListBlockKeys.type: + return LocaleKeys.document_slashMenu_name_bulletedList.tr(); + case NumberedListBlockKeys.type: + return LocaleKeys.document_slashMenu_name_numberedList.tr(); + case TodoListBlockKeys.type: + return LocaleKeys.document_slashMenu_name_todoList.tr(); + case CalloutBlockKeys.type: + return LocaleKeys.document_slashMenu_name_callout.tr(); + case SubPageBlockKeys.type: + return LocaleKeys.editor_page.tr(); + case ToggleListBlockKeys.type: + switch (level) { + case 1: + return LocaleKeys.document_slashMenu_name_toggleHeading1.tr(); + case 2: + return LocaleKeys.document_slashMenu_name_toggleHeading2.tr(); + case 3: + return LocaleKeys.document_slashMenu_name_toggleHeading3.tr(); + default: + return LocaleKeys.document_slashMenu_name_toggleList.tr(); + } + } + + throw UnimplementedError('Unsupported block type: $type'); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart new file mode 100644 index 0000000000000..09bdc06057882 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/align_toolbar_item.dart @@ -0,0 +1,197 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +const String leftAlignmentKey = 'left'; +const String centerAlignmentKey = 'center'; +const String rightAlignmentKey = 'right'; +const String kAlignToolbarItemId = 'editor.align'; + +final alignToolbarItem = ToolbarItem( + id: kAlignToolbarItemId, + group: 4, + isActive: onlyShowInTextType, + builder: (context, editorState, highlightColor, _, tooltipBuilder) { + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + + bool isSatisfyCondition(bool Function(Object? value) test) { + return nodes.every( + (n) => test(n.attributes[blockComponentAlign]), + ); + } + + bool isHighlight = false; + FlowySvgData data = FlowySvgs.toolbar_align_left_s; + if (isSatisfyCondition((value) => value == leftAlignmentKey)) { + isHighlight = true; + data = FlowySvgs.toolbar_align_left_s; + } else if (isSatisfyCondition((value) => value == centerAlignmentKey)) { + isHighlight = true; + data = FlowySvgs.toolbar_align_center_s; + } else if (isSatisfyCondition((value) => value == rightAlignmentKey)) { + isHighlight = true; + data = FlowySvgs.toolbar_align_right_s; + } + + Widget child = FlowySvg( + data, + size: const Size.square(16), + color: isHighlight ? highlightColor : Colors.white, + ); + + child = _AlignmentButtons( + child: child, + onAlignChanged: (align) async { + await editorState.updateNode( + selection, + (node) => node.copyWith( + attributes: { + ...node.attributes, + blockComponentAlign: align, + }, + ), + ); + }, + ); + + if (tooltipBuilder != null) { + child = tooltipBuilder( + context, + kAlignToolbarItemId, + LocaleKeys.document_plugins_optionAction_align.tr(), + child, + ); + } + + return child; + }, +); + +class _AlignmentButtons extends StatefulWidget { + const _AlignmentButtons({ + required this.child, + required this.onAlignChanged, + }); + + final Widget child; + final Function(String align) onAlignChanged; + + @override + State<_AlignmentButtons> createState() => _AlignmentButtonsState(); +} + +class _AlignmentButtonsState extends State<_AlignmentButtons> { + final controller = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + windowPadding: const EdgeInsets.all(0), + margin: const EdgeInsets.symmetric(vertical: 2.0), + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 10), + decorationColor: Theme.of(context).colorScheme.onTertiary, + borderRadius: BorderRadius.circular(6.0), + popupBuilder: (_) { + keepEditorFocusNotifier.increase(); + return _AlignButtons(onAlignChanged: widget.onAlignChanged); + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + }, + child: FlowyButton( + useIntrinsicWidth: true, + text: widget.child, + hoverColor: Colors.grey.withOpacity(0.3), + onTap: () => controller.show(), + ), + ); + } +} + +class _AlignButtons extends StatelessWidget { + const _AlignButtons({ + required this.onAlignChanged, + }); + + final Function(String align) onAlignChanged; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 28, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(4), + _AlignButton( + icon: FlowySvgs.toolbar_align_left_s, + tooltips: LocaleKeys.document_plugins_optionAction_left.tr(), + onTap: () => onAlignChanged(leftAlignmentKey), + ), + const _Divider(), + _AlignButton( + icon: FlowySvgs.toolbar_align_center_s, + tooltips: LocaleKeys.document_plugins_optionAction_center.tr(), + onTap: () => onAlignChanged(centerAlignmentKey), + ), + const _Divider(), + _AlignButton( + icon: FlowySvgs.toolbar_align_right_s, + tooltips: LocaleKeys.document_plugins_optionAction_right.tr(), + onTap: () => onAlignChanged(rightAlignmentKey), + ), + const HSpace(4), + ], + ), + ); + } +} + +class _AlignButton extends StatelessWidget { + const _AlignButton({ + required this.icon, + required this.tooltips, + required this.onTap, + }); + + final FlowySvgData icon; + final String tooltips; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyButton( + useIntrinsicWidth: true, + hoverColor: Colors.grey.withOpacity(0.3), + onTap: onTap, + text: FlowyTooltip( + message: tooltips, + child: FlowySvg( + icon, + size: const Size.square(16), + color: Colors.white, + ), + ), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4), + child: Container( + width: 1, + color: Colors.grey, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart new file mode 100644 index 0000000000000..9fe78591c368c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +final List customTextAlignCommands = [ + customTextLeftAlignCommand, + customTextCenterAlignCommand, + customTextRightAlignCommand, +]; + +/// Windows / Linux : ctrl + shift + l +/// macOS : ctrl + shift + l +/// Allows the user to align text to the left +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent customTextLeftAlignCommand = CommandShortcutEvent( + key: 'Align text to the left', + command: 'ctrl+shift+l', + getDescription: LocaleKeys.settings_shortcutsPage_commands_textAlignLeft.tr, + handler: (editorState) => _textAlignHandler(editorState, leftAlignmentKey), +); + +/// Windows / Linux : ctrl + shift + e +/// macOS : ctrl + shift + e +/// Allows the user to align text to the center +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent customTextCenterAlignCommand = CommandShortcutEvent( + key: 'Align text to the center', + command: 'ctrl+shift+e', + getDescription: LocaleKeys.settings_shortcutsPage_commands_textAlignCenter.tr, + handler: (editorState) => _textAlignHandler(editorState, centerAlignmentKey), +); + +/// Windows / Linux : ctrl + shift + r +/// macOS : ctrl + shift + r +/// Allows the user to align text to the right +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent customTextRightAlignCommand = CommandShortcutEvent( + key: 'Align text to the right', + command: 'ctrl+shift+r', + getDescription: LocaleKeys.settings_shortcutsPage_commands_textAlignRight.tr, + handler: (editorState) => _textAlignHandler(editorState, rightAlignmentKey), +); + +KeyEventResult _textAlignHandler(EditorState editorState, String align) { + final Selection? selection = editorState.selection; + + if (selection == null) { + return KeyEventResult.ignored; + } + + editorState.updateNode( + selection, + (node) => node.copyWith( + attributes: { + ...node.attributes, + blockComponentAlign: align, + }, + ), + ); + + return KeyEventResult.handled; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/background_color/theme_background_color.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/background_color/theme_background_color.dart new file mode 100644 index 0000000000000..25bc82283dd0c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/background_color/theme_background_color.dart @@ -0,0 +1,15 @@ +import 'package:flowy_infra/theme_extension.dart'; + +// DON'T MODIFY THIS KEY BECAUSE IT'S SAVED IN THE DATABASE! +// Used for the block component background color +const themeBackgroundColors = { + 'appflowy_them_color_tint1': FlowyTint.tint1, + 'appflowy_them_color_tint2': FlowyTint.tint2, + 'appflowy_them_color_tint3': FlowyTint.tint3, + 'appflowy_them_color_tint4': FlowyTint.tint4, + 'appflowy_them_color_tint5': FlowyTint.tint5, + 'appflowy_them_color_tint6': FlowyTint.tint6, + 'appflowy_them_color_tint7': FlowyTint.tint7, + 'appflowy_them_color_tint8': FlowyTint.tint8, + 'appflowy_them_color_tint9': FlowyTint.tint9, +}; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/backtick_character_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/backtick_character_command.dart new file mode 100644 index 0000000000000..ca26c5a263d2f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/backtick_character_command.dart @@ -0,0 +1,51 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; + +/// ``` to code block +/// +/// - support +/// - desktop +/// - mobile +/// - web +/// +final CharacterShortcutEvent formatBacktickToCodeBlock = CharacterShortcutEvent( + key: '``` to code block', + character: '`', + handler: (editorState) async => _convertBacktickToCodeBlock( + editorState: editorState, + ), +); + +Future _convertBacktickToCodeBlock({ + required EditorState editorState, +}) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return false; + } + + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null || delta.isEmpty) { + return false; + } + + // only active when the backtick is at the beginning of the line + final plainText = delta.toPlainText(); + if (plainText != '``') { + return false; + } + + final transaction = editorState.transaction; + transaction.insertNode( + selection.end.path, + codeBlockNode(), + ); + transaction.deleteNode(node); + transaction.afterSelection = Selection.collapsed( + Position(path: selection.start.path), + ); + await editorState.apply(transaction); + + return true; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/build_context_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/build_context_extension.dart new file mode 100644 index 0000000000000..06d51094c3467 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/build_context_extension.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +extension BuildContextExtension on BuildContext { + /// returns a boolean value indicating whether the given offset is contained within the bounds of the specified RenderBox or not. + bool isOffsetInside(Offset offset) { + final box = findRenderObject() as RenderBox?; + if (box == null) { + return false; + } + final result = BoxHitTestResult(); + box.hitTest(result, position: box.globalToLocal(offset)); + return result.path.any((entry) => entry.target == box); + } + + double get appBarHeight => + AppBarTheme.of(this).toolbarHeight ?? kToolbarHeight; + double get statusBarHeight => statusBarAndAppBarHeight - appBarHeight; + double get statusBarAndAppBarHeight => MediaQuery.of(this).padding.top; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart new file mode 100644 index 0000000000000..9f25e4745d316 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart @@ -0,0 +1,187 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class BuiltInPageWidget extends StatefulWidget { + const BuiltInPageWidget({ + super.key, + required this.node, + required this.editorState, + required this.builder, + }); + + final Node node; + final EditorState editorState; + final Widget Function(ViewPB viewPB) builder; + + @override + State createState() => _BuiltInPageWidgetState(); +} + +class _BuiltInPageWidgetState extends State { + late Future> future; + + final focusNode = FocusNode(); + + String get parentViewId => widget.node.attributes[DatabaseBlockKeys.parentID]; + String get childViewId => widget.node.attributes[DatabaseBlockKeys.viewID]; + + @override + void initState() { + super.initState(); + future = ViewBackendService().getChildView( + parentViewId: parentViewId, + childViewId: childViewId, + ); + } + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + builder: (context, snapshot) { + final page = snapshot.data?.toNullable(); + if (snapshot.hasData && page != null) { + return _build(context, page); + } + + if (snapshot.connectionState == ConnectionState.done) { + // Delete the page if not found + WidgetsBinding.instance.addPostFrameCallback((_) => _deletePage()); + + return const Center(child: FlowyText('Cannot load the page')); + } + + return const Center(child: CircularProgressIndicator()); + }, + future: future, + ); + } + + Widget _build(BuildContext context, ViewPB viewPB) { + return MouseRegion( + onEnter: (_) => widget.editorState.service.scrollService?.disable(), + onExit: (_) => widget.editorState.service.scrollService?.enable(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildMenu(context, viewPB), + Flexible(child: _buildPage(context, viewPB)), + ], + ), + ); + } + + Widget _buildPage(BuildContext context, ViewPB view) { + return Focus( + focusNode: focusNode, + onFocusChange: (value) { + if (value) { + widget.editorState.service.selectionService.clearSelection(); + } + }, + child: widget.builder(view), + ); + } + + Widget _buildMenu(BuildContext context, ViewPB view) { + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // information + FlowyIconButton( + tooltipText: LocaleKeys.tooltip_referencePage.tr( + namedArgs: {'name': view.layout.name}, + ), + width: 24, + height: 24, + iconPadding: const EdgeInsets.all(3), + icon: const FlowySvg( + FlowySvgs.information_s, + ), + ), + // setting + const Space(7, 0), + PopoverActionList<_ActionWrapper>( + direction: PopoverDirection.bottomWithCenterAligned, + actions: _ActionType.values + .map((action) => _ActionWrapper(action)) + .toList(), + buildChild: (controller) => FlowyIconButton( + tooltipText: LocaleKeys.tooltip_openMenu.tr(), + width: 24, + height: 24, + iconPadding: const EdgeInsets.all(3), + icon: const FlowySvg( + FlowySvgs.settings_s, + ), + onPressed: () => controller.show(), + ), + onSelected: (action, controller) async { + switch (action.inner) { + case _ActionType.viewDatabase: + getIt().add( + TabsEvent.openPlugin( + plugin: view.plugin(), + view: view, + ), + ); + break; + case _ActionType.delete: + final transaction = widget.editorState.transaction; + transaction.deleteNode(widget.node); + await widget.editorState.apply(transaction); + break; + } + controller.close(); + }, + ), + ], + ); + } + + Future _deletePage() async { + final transaction = widget.editorState.transaction; + transaction.deleteNode(widget.node); + await widget.editorState.apply(transaction); + } +} + +enum _ActionType { viewDatabase, delete } + +class _ActionWrapper extends ActionCell { + _ActionWrapper(this.inner); + + final _ActionType inner; + + Widget? icon(Color iconColor) => null; + + @override + String get name { + switch (inner) { + case _ActionType.viewDatabase: + return LocaleKeys.tooltip_viewDataBase.tr(); + case _ActionType.delete: + return LocaleKeys.disclosureAction_delete.tr(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/cover_title_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/cover_title_command.dart new file mode 100644 index 0000000000000..20b4b7901ee9f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/cover_title_command.dart @@ -0,0 +1,132 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Press the backspace at the first position of first line to go to the title +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent backspaceToTitle = CommandShortcutEvent( + key: 'backspace to title', + command: 'backspace', + getDescription: () => 'backspace to title', + handler: (editorState) => _backspaceToTitle( + editorState: editorState, + ), +); + +KeyEventResult _backspaceToTitle({ + required EditorState editorState, +}) { + final coverTitleFocusNode = editorState.document.root.context + ?.read() + ?.coverTitleFocusNode; + if (coverTitleFocusNode == null) { + return KeyEventResult.ignored; + } + + final selection = editorState.selection; + // only active when the backspace is at the first position of first line + if (selection == null || + !selection.isCollapsed || + !selection.start.path.equals([0]) || + selection.start.offset != 0) { + return KeyEventResult.ignored; + } + + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null || node.type != ParagraphBlockKeys.type) { + return KeyEventResult.ignored; + } + + // delete the first line + () async { + // only delete the first line if it is empty + if (node.delta == null || node.delta!.isEmpty) { + final transaction = editorState.transaction; + transaction.deleteNode(node); + transaction.afterSelection = null; + await editorState.apply(transaction); + } + + editorState.selection = null; + coverTitleFocusNode.requestFocus(); + }(); + + return KeyEventResult.handled; +} + +/// Press the arrow left at the first position of first line to go to the title +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent arrowLeftToTitle = CommandShortcutEvent( + key: 'arrow left to title', + command: 'arrow left', + getDescription: () => 'arrow left to title', + handler: (editorState) => _arrowKeyToTitle( + editorState: editorState, + checkSelection: (selection) { + if (!selection.isCollapsed || + !selection.start.path.equals([0]) || + selection.start.offset != 0) { + return false; + } + return true; + }, + ), +); + +/// Press the arrow up at the first line to go to the title +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent arrowUpToTitle = CommandShortcutEvent( + key: 'arrow up to title', + command: 'arrow up', + getDescription: () => 'arrow up to title', + handler: (editorState) => _arrowKeyToTitle( + editorState: editorState, + checkSelection: (selection) { + if (!selection.isCollapsed || !selection.start.path.equals([0])) { + return false; + } + return true; + }, + ), +); + +KeyEventResult _arrowKeyToTitle({ + required EditorState editorState, + required bool Function(Selection selection) checkSelection, +}) { + final coverTitleFocusNode = editorState.document.root.context + ?.read() + ?.coverTitleFocusNode; + if (coverTitleFocusNode == null) { + return KeyEventResult.ignored; + } + + final selection = editorState.selection; + // only active when the arrow up is at the first line + if (selection == null || !checkSelection(selection)) { + return KeyEventResult.ignored; + } + + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return KeyEventResult.ignored; + } + + editorState.selection = null; + coverTitleFocusNode.requestFocus(); + + return KeyEventResult.handled; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart new file mode 100644 index 0000000000000..e2113cc1fb89b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart @@ -0,0 +1,190 @@ +import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class EmojiPickerButton extends StatelessWidget { + EmojiPickerButton({ + super.key, + required this.emoji, + required this.onSubmitted, + this.emojiPickerSize = const Size(360, 380), + this.emojiSize = 18.0, + this.defaultIcon, + this.offset, + this.direction, + this.title, + this.showBorder = true, + this.enable = true, + this.margin, + this.buttonSize, + }); + + final EmojiIconData emoji; + final double emojiSize; + final Size emojiPickerSize; + final void Function(EmojiIconData emoji, PopoverController? controller) + onSubmitted; + final PopoverController popoverController = PopoverController(); + final Widget? defaultIcon; + final Offset? offset; + final PopoverDirection? direction; + final String? title; + final bool showBorder; + final bool enable; + final EdgeInsets? margin; + final Size? buttonSize; + + @override + Widget build(BuildContext context) { + if (UniversalPlatform.isDesktopOrWeb) { + return _DesktopEmojiPickerButton( + emoji: emoji, + onSubmitted: onSubmitted, + emojiPickerSize: emojiPickerSize, + emojiSize: emojiSize, + defaultIcon: defaultIcon, + offset: offset, + direction: direction, + title: title, + showBorder: showBorder, + enable: enable, + buttonSize: buttonSize, + ); + } + + return _MobileEmojiPickerButton( + emoji: emoji, + onSubmitted: onSubmitted, + emojiSize: emojiSize, + enable: enable, + title: title, + margin: margin, + ); + } +} + +class _DesktopEmojiPickerButton extends StatelessWidget { + _DesktopEmojiPickerButton({ + required this.emoji, + required this.onSubmitted, + this.emojiPickerSize = const Size(360, 380), + this.emojiSize = 18.0, + this.defaultIcon, + this.offset, + this.direction, + this.title, + this.showBorder = true, + this.enable = true, + this.buttonSize, + }); + + final EmojiIconData emoji; + final double emojiSize; + final Size emojiPickerSize; + final void Function(EmojiIconData emoji, PopoverController? controller) + onSubmitted; + final PopoverController popoverController = PopoverController(); + final Widget? defaultIcon; + final Offset? offset; + final PopoverDirection? direction; + final String? title; + final bool showBorder; + final bool enable; + final Size? buttonSize; + + @override + Widget build(BuildContext context) { + final showDefault = emoji.isEmpty && defaultIcon != null; + return AppFlowyPopover( + controller: popoverController, + constraints: BoxConstraints.expand( + width: emojiPickerSize.width, + height: emojiPickerSize.height, + ), + offset: offset, + margin: EdgeInsets.zero, + direction: direction ?? PopoverDirection.rightWithTopAligned, + popupBuilder: (_) => Container( + width: emojiPickerSize.width, + height: emojiPickerSize.height, + padding: const EdgeInsets.all(4.0), + child: FlowyIconEmojiPicker( + onSelectedEmoji: (r) { + onSubmitted(r, popoverController); + }, + ), + ), + child: Container( + width: buttonSize?.width ?? 30.0, + height: buttonSize?.height ?? 30.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: showBorder + ? Border.all( + color: Theme.of(context).dividerColor, + ) + : null, + ), + child: FlowyButton( + margin: emoji.isEmpty && defaultIcon != null + ? EdgeInsets.zero + : const EdgeInsets.only(left: 2.0), + expandText: false, + text: showDefault + ? defaultIcon! + : RawEmojiIconWidget(emoji: emoji, emojiSize: emojiSize), + onTap: enable ? popoverController.show : null, + ), + ), + ); + } +} + +class _MobileEmojiPickerButton extends StatelessWidget { + const _MobileEmojiPickerButton({ + required this.emoji, + required this.onSubmitted, + this.emojiSize = 18.0, + this.enable = true, + this.title, + this.margin, + }); + + final EmojiIconData emoji; + final double emojiSize; + final void Function(EmojiIconData emoji, PopoverController? controller) + onSubmitted; + final String? title; + final bool enable; + final EdgeInsets? margin; + + @override + Widget build(BuildContext context) { + return FlowyButton( + useIntrinsicWidth: true, + margin: + margin ?? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + text: RawEmojiIconWidget( + emoji: emoji, + emojiSize: emojiSize, + ), + onTap: enable + ? () async { + final result = await context.push( + Uri( + path: MobileEmojiPickerScreen.routeName, + queryParameters: {MobileEmojiPickerScreen.pageTitle: title}, + ).toString(), + ); + if (result != null) { + onSubmitted(result, null); + } + } + : null, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/font_colors.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/font_colors.dart new file mode 100644 index 0000000000000..e386be709a910 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/font_colors.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +class EditorFontColors { + static final lightColors = [ + const Color(0x00FFFFFF), + const Color(0xFFE8E0FF), + const Color(0xFFFFE6FD), + const Color(0xFFFFDAE6), + const Color(0xFFFFEFE3), + const Color(0xFFF5FFDC), + const Color(0xFFDDFFD6), + const Color(0xFFDEFFF1), + const Color(0xFFE1FBFF), + const Color(0xFFFFADAD), + const Color(0xFFFFE088), + const Color(0xFFA7DF4A), + const Color(0xFFD4C0FF), + const Color(0xFFFDB2FE), + const Color(0xFFFFD18B), + const Color(0xFF65E7F0), + const Color(0xFF71E6B4), + const Color(0xFF80F1FF), + ]; + + static final darkColors = [ + const Color(0x00FFFFFF), + const Color(0xFF8B80AD), + const Color(0xFF987195), + const Color(0xFF906D78), + const Color(0xFFA68B77), + const Color(0xFF88936D), + const Color(0xFF72936B), + const Color(0xFF6B9483), + const Color(0xFF658B90), + const Color(0xFF95405A), + const Color(0xFFA6784D), + const Color(0xFF6E9234), + const Color(0xFF6455A2), + const Color(0xFF924F83), + const Color(0xFFA48F34), + const Color(0xFF29A3AC), + const Color(0xFF2E9F84), + const Color(0xFF405EA6), + ]; + + // if the input color doesn't exist in the list, return the input color itself. + static Color? fromBuiltInColors(BuildContext context, Color? color) { + if (color == null) { + return null; + } + + final brightness = Theme.of(context).brightness; + + // if the dark mode color using light mode, return it's corresponding light color. Same for light mode. + if (brightness == Brightness.light) { + if (darkColors.contains(color)) { + return lightColors[darkColors.indexOf(color)]; + } + } else { + if (lightColors.contains(color)) { + return darkColors[lightColors.indexOf(color)]; + } + } + return color; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart new file mode 100644 index 0000000000000..6e6e111df1230 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart @@ -0,0 +1,78 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; + +const _greater = '>'; +const _equals = '='; +const _arrow = '⇒'; + +/// format '=' + '>' into an ⇒ +/// +/// - support +/// - desktop +/// - mobile +/// - web +/// +final CharacterShortcutEvent customFormatGreaterEqual = CharacterShortcutEvent( + key: 'format = + > into ⇒', + character: _greater, + handler: (editorState) async => _handleDoubleCharacterReplacement( + editorState: editorState, + character: _greater, + replacement: _arrow, + prefixCharacter: _equals, + ), +); + +/// If [prefixCharacter] is null or empty, [character] is used +Future _handleDoubleCharacterReplacement({ + required EditorState editorState, + required String character, + required String replacement, + String? prefixCharacter, +}) async { + assert(character.length == 1); + + final selection = editorState.selection; + if (selection == null) { + return false; + } + + if (!selection.isCollapsed) { + await editorState.deleteSelection(selection); + } + + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || + delta == null || + delta.isEmpty || + node.type == CodeBlockKeys.type) { + return false; + } + + if (selection.end.offset > 0) { + final plain = delta.toPlainText(); + + final expectedPrevious = + prefixCharacter?.isEmpty ?? true ? character : prefixCharacter; + + final previousCharacter = plain[selection.end.offset - 1]; + if (previousCharacter != expectedPrevious) { + return false; + } + + final replace = editorState.transaction + ..replaceText( + node, + selection.end.offset - 1, + 1, + replacement, + ); + + await editorState.apply(replace); + + return true; + } + + return false; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart new file mode 100644 index 0000000000000..af605972def76 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/insert_page_command.dart @@ -0,0 +1,151 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/domain/database_view_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +extension InsertDatabase on EditorState { + Future insertInlinePage(String parentViewId, ViewPB childView) async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + + final transaction = this.transaction; + transaction.insertNode( + selection.end.path, + Node( + type: _convertPageType(childView), + attributes: { + DatabaseBlockKeys.parentID: parentViewId, + DatabaseBlockKeys.viewID: childView.id, + }, + ), + ); + await apply(transaction); + } + + Future insertReferencePage( + ViewPB childView, + ViewLayoutPB viewType, + ) async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + throw FlowyError( + msg: + "Could not insert the reference page because the current selection was null or collapsed.", + ); + } + final node = getNodeAtPath(selection.end.path); + if (node == null) { + throw FlowyError( + msg: + "Could not insert the reference page because the current node at the selection does not exist.", + ); + } + + final Transaction transaction = viewType == ViewLayoutPB.Document + ? await _insertDocumentReference(childView, selection, node) + : await _insertDatabaseReference(childView, selection.end.path); + + await apply(transaction); + } + + Future _insertDocumentReference( + ViewPB view, + Selection selection, + Node node, + ) async { + return transaction + ..replaceText( + node, + selection.end.offset, + 0, + r'$', + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.page.name, + MentionBlockKeys.pageId: view.id, + }, + }, + ); + } + + Future _insertDatabaseReference( + ViewPB view, + List path, + ) async { + // get the database id that the view is associated with + final databaseId = await DatabaseViewBackendService(viewId: view.id) + .getDatabaseId() + .then((value) => value.toNullable()); + + if (databaseId == null) { + throw StateError( + 'The database associated with ${view.id} could not be found while attempting to create a referenced ${view.layout.name}.', + ); + } + + final prefix = _referencedDatabasePrefix(view.layout); + final ref = await ViewBackendService.createDatabaseLinkedView( + parentViewId: view.id, + name: "$prefix ${view.nameOrDefault}", + layoutType: view.layout, + databaseId: databaseId, + ).then((value) => value.toNullable()); + + if (ref == null) { + throw FlowyError( + msg: + "The `ViewBackendService` failed to create a database reference view", + ); + } + + return transaction + ..insertNode( + path, + Node( + type: _convertPageType(view), + attributes: { + DatabaseBlockKeys.parentID: view.id, + DatabaseBlockKeys.viewID: ref.id, + }, + ), + ); + } + + String _referencedDatabasePrefix(ViewLayoutPB layout) { + switch (layout) { + case ViewLayoutPB.Grid: + return LocaleKeys.grid_referencedGridPrefix.tr(); + case ViewLayoutPB.Board: + return LocaleKeys.board_referencedBoardPrefix.tr(); + case ViewLayoutPB.Calendar: + return LocaleKeys.calendar_referencedCalendarPrefix.tr(); + default: + throw UnimplementedError(); + } + } + + String _convertPageType(ViewPB viewPB) { + switch (viewPB.layout) { + case ViewLayoutPB.Grid: + return DatabaseBlockKeys.gridType; + case ViewLayoutPB.Board: + return DatabaseBlockKeys.boardType; + case ViewLayoutPB.Calendar: + return DatabaseBlockKeys.calendarType; + default: + throw Exception('Unknown layout type'); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart new file mode 100644 index 0000000000000..ce4f44e72be40 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart @@ -0,0 +1,73 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +InlineActionsMenuService? _actionsMenuService; +Future showLinkToPageMenu( + EditorState editorState, + SelectionMenuService menuService, { + ViewLayoutPB? pageType, + bool? insertPage, +}) async { + keepEditorFocusNotifier.increase(); + + menuService.dismiss(); + _actionsMenuService?.dismiss(); + + final rootContext = editorState.document.root.context; + if (rootContext == null) { + return; + } + + final service = InlineActionsService( + context: rootContext, + handlers: [ + InlinePageReferenceService( + currentViewId: '', + viewLayout: pageType, + customTitle: titleFromPageType(pageType), + insertPage: insertPage ?? pageType != ViewLayoutPB.Document, + limitResults: 15, + ), + ], + ); + + final List initialResults = []; + for (final handler in service.handlers) { + final group = await handler.search(null); + + if (group.results.isNotEmpty) { + initialResults.add(group); + } + } + + if (rootContext.mounted) { + _actionsMenuService = InlineActionsMenu( + context: rootContext, + editorState: editorState, + service: service, + initialResults: initialResults, + style: Theme.of(editorState.document.root.context!).brightness == + Brightness.light + ? const InlineActionsMenuStyle.light() + : const InlineActionsMenuStyle.dark(), + startCharAmount: 0, + ); + + _actionsMenuService?.show(); + } +} + +String titleFromPageType(ViewLayoutPB? layout) => switch (layout) { + ViewLayoutPB.Grid => LocaleKeys.inlineActions_gridReference.tr(), + ViewLayoutPB.Document => LocaleKeys.inlineActions_docReference.tr(), + ViewLayoutPB.Board => LocaleKeys.inlineActions_boardReference.tr(), + ViewLayoutPB.Calendar => LocaleKeys.inlineActions_calReference.tr(), + _ => LocaleKeys.inlineActions_pageReference.tr(), + }; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart new file mode 100644 index 0000000000000..0cc9a50e5cb1c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart @@ -0,0 +1,137 @@ +import 'dart:convert'; + +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/foundation.dart'; +import 'package:synchronized/synchronized.dart'; + +class MarkdownTextRobot { + MarkdownTextRobot({ + required this.editorState, + this.enableDebug = true, + }); + + final EditorState editorState; + final bool enableDebug; + + final Lock lock = Lock(); + + // The selection before the text robot is ready. + Selection? _startSelection; + + // The markdown text to be inserted. + String _markdownText = ''; + + // Only for debug. Enable by [enableDebug]. + @visibleForTesting + final List debugMarkdownTexts = []; + + // The nodes inserted in the previous refresh. + Iterable _previousInsertedNodes = []; + + /// Start the text robot. + /// + /// Must call this function before using the text robot. + void start() { + _startSelection = editorState.selection; + + if (enableDebug) { + Log.info( + 'MarkdownTextRobot prepare, current selection: $_startSelection', + ); + } + } + + /// Append the markdown text to the text robot. + /// + /// The text will be inserted into document but not persisted until the text + /// robot is stopped. + Future appendMarkdownText(String text) async { + _markdownText += text; + + await lock.synchronized(() async { + await _refresh(); + }); + + if (enableDebug) { + debugMarkdownTexts.add(text); + Log.info('debug markdown texts: ${jsonEncode(debugMarkdownTexts)}'); + } + } + + /// Stop the text robot. + /// + /// The text will be persisted into document. + Future stop() async { + // persist the markdown text + await lock.synchronized(() async { + await _refresh(inMemoryUpdate: false); + }); + + _markdownText = ''; + + if (enableDebug) { + Log.info( + 'debug markdown texts: ${jsonEncode(debugMarkdownTexts)}', + ); + debugMarkdownTexts.clear(); + } + } + + /// Refreshes the editor state with the current markdown text by: + /// + /// 1. Converting markdown to document nodes + /// 2. Replacing previously inserted nodes with new nodes + /// 3. Updating selection position + Future _refresh({bool inMemoryUpdate = true}) async { + final start = _startSelection?.start; + if (start == null) { + return; + } + + final transaction = editorState.transaction; + + // Convert markdown and deep copy nodes + final nodes = customMarkdownToDocument(_markdownText).root.children.map( + (node) => node.deepCopy(), + ); // deep copy the nodes to avoid the linked entities being changed. + + // Insert new nodes at selection start + transaction.insertNodes(start.path, nodes); + + // Remove previously inserted nodes if they exist + if (_previousInsertedNodes.isNotEmpty) { + // fallback to the calculated position if the selection is null. + final end = editorState.selection?.end ?? + Position( + path: start.path.nextNPath(_previousInsertedNodes.length - 1), + ); + final deletedNodes = editorState.getNodesInSelection( + Selection(start: start, end: end), + ); + transaction.deleteNodes(deletedNodes); + } + + // Update selection to end of inserted content if it contains text + final lastDelta = nodes.lastOrNull?.delta; + if (lastDelta != null) { + transaction.afterSelection = Selection.collapsed( + Position( + path: start.path.nextNPath(nodes.length - 1), + offset: lastDelta.length, + ), + ); + } + + await editorState.apply( + transaction, + options: ApplyOptions( + inMemoryUpdate: inMemoryUpdate, + recordUndo: false, + ), + ); + + _previousInsertedNodes = nodes; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart new file mode 100644 index 0000000000000..079a062837c33 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart @@ -0,0 +1,156 @@ +import 'package:appflowy/plugins/inline_actions/handlers/child_page.dart'; +import 'package:appflowy/plugins/inline_actions/handlers/inline_page_reference.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +const _bracketChar = '['; +const _plusChar = '+'; + +CharacterShortcutEvent pageReferenceShortcutBrackets( + BuildContext context, + String viewId, + InlineActionsMenuStyle style, +) => + CharacterShortcutEvent( + key: 'show the inline page reference menu by [', + character: _bracketChar, + handler: (editorState) => inlinePageReferenceCommandHandler( + _bracketChar, + context, + viewId, + editorState, + style, + previousChar: _bracketChar, + ), + ); + +CharacterShortcutEvent pageReferenceShortcutPlusSign( + BuildContext context, + String viewId, + InlineActionsMenuStyle style, +) => + CharacterShortcutEvent( + key: 'show the inline page reference menu by +', + character: _plusChar, + handler: (editorState) => inlinePageReferenceCommandHandler( + _plusChar, + context, + viewId, + editorState, + style, + ), + ); + +InlineActionsMenuService? selectionMenuService; +Future inlinePageReferenceCommandHandler( + String character, + BuildContext context, + String currentViewId, + EditorState editorState, + InlineActionsMenuStyle style, { + String? previousChar, +}) async { + final selection = editorState.selection; + if (UniversalPlatform.isMobile || selection == null) { + return false; + } + + if (!selection.isCollapsed) { + await editorState.deleteSelection(selection); + } + + // Check for previous character + if (previousChar != null) { + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null || delta.isEmpty) { + return false; + } + + if (selection.end.offset > 0) { + final plain = delta.toPlainText(); + + final previousCharacter = plain[selection.end.offset - 1]; + if (previousCharacter != _bracketChar) { + return false; + } + } + } + + if (!context.mounted) { + return false; + } + + final service = InlineActionsService( + context: context, + handlers: [ + if (FeatureFlag.inlineSubPageMention.isOn) + InlineChildPageService(currentViewId: currentViewId), + InlinePageReferenceService( + currentViewId: currentViewId, + limitResults: 10, + ), + ], + ); + + await editorState.insertTextAtPosition(character, position: selection.start); + + final List initialResults = []; + for (final handler in service.handlers) { + final group = await handler.search(null); + + if (group.results.isNotEmpty) { + initialResults.add(group); + } + } + + if (context.mounted) { + selectionMenuService = InlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + startCharAmount: previousChar != null ? 2 : 1, + cancelBySpaceHandler: () { + if (character == _plusChar) { + final currentSelection = editorState.selection; + if (currentSelection == null) { + return false; + } + // check if the space is after the character + if (currentSelection.isCollapsed && + currentSelection.start.offset == + selection.start.offset + character.length) { + _cancelInlinePageReferenceMenu(editorState); + return true; + } + } + return false; + }, + ); + + selectionMenuService?.show(); + } + + return true; +} + +void _cancelInlinePageReferenceMenu(EditorState editorState) { + selectionMenuService?.dismiss(); + selectionMenuService = null; + + // re-focus the selection + final selection = editorState.selection; + if (selection != null) { + editorState.updateSelectionWithReason( + selection, + reason: SelectionUpdateReason.uiEvent, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart new file mode 100644 index 0000000000000..0b6382fc53918 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart @@ -0,0 +1,72 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +class SelectableItemListMenu extends StatelessWidget { + const SelectableItemListMenu({ + super.key, + required this.items, + required this.selectedIndex, + required this.onSelected, + this.shrinkWrap = false, + this.controller, + }); + + final List items; + final int selectedIndex; + final void Function(int) onSelected; + final ItemScrollController? controller; + + /// shrinkWrapping is useful in cases where you have a list of + /// limited amount of items. It will make the list take the minimum + /// amount of space required to show all the items. + /// + final bool shrinkWrap; + + @override + Widget build(BuildContext context) { + return ScrollablePositionedList.builder( + physics: const ClampingScrollPhysics(), + shrinkWrap: shrinkWrap, + itemCount: items.length, + itemScrollController: controller, + initialScrollIndex: max(0, selectedIndex), + itemBuilder: (context, index) => SelectableItem( + isSelected: index == selectedIndex, + item: items[index], + onTap: () => onSelected(index), + ), + ); + } +} + +class SelectableItem extends StatelessWidget { + const SelectableItem({ + super.key, + required this.isSelected, + required this.item, + required this.onTap, + }); + + final bool isSelected; + final String item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: FlowyButton( + text: FlowyText.medium( + item, + lineHeight: 1.0, + ), + isSelected: isSelected, + onTap: onTap, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart new file mode 100644 index 0000000000000..f1b082e0a7705 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart @@ -0,0 +1,61 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class SelectableSvgWidget extends StatelessWidget { + const SelectableSvgWidget({ + super.key, + required this.data, + required this.isSelected, + required this.style, + this.size, + this.padding, + }); + + final FlowySvgData data; + final bool isSelected; + final SelectionMenuStyle style; + final Size? size; + final EdgeInsets? padding; + + @override + Widget build(BuildContext context) { + final child = FlowySvg( + data, + size: size ?? const Size.square(16.0), + color: isSelected + ? style.selectionMenuItemSelectedIconColor + : style.selectionMenuItemIconColor, + ); + + if (padding != null) { + return Padding(padding: padding!, child: child); + } else { + return child; + } + } +} + +class SelectableIconWidget extends StatelessWidget { + const SelectableIconWidget({ + super.key, + required this.icon, + required this.isSelected, + required this.style, + }); + + final IconData icon; + final bool isSelected; + final SelectionMenuStyle style; + + @override + Widget build(BuildContext context) { + return Icon( + icon, + size: 18.0, + color: isSelected + ? style.selectionMenuItemSelectedIconColor + : style.selectionMenuItemIconColor, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/string_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/string_extension.dart new file mode 100644 index 0000000000000..254c3d53bfe1b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/string_extension.dart @@ -0,0 +1,6 @@ +extension Capitalize on String { + String capitalize() { + if (isEmpty) return this; + return "${this[0].toUpperCase()}${substring(1)}"; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/text_robot.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/text_robot.dart new file mode 100644 index 0000000000000..a20ea9aec5201 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/text_robot.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:synchronized/synchronized.dart'; + +enum TextRobotInputType { + character, + word, + sentence, +} + +class TextRobot { + TextRobot({ + required this.editorState, + }); + + final EditorState editorState; + final Lock lock = Lock(); + + /// This function is used to insert text in a synchronized way + /// + /// It is suitable for inserting text in a loop. + Future autoInsertTextSync( + String text, { + TextRobotInputType inputType = TextRobotInputType.word, + Duration delay = const Duration(milliseconds: 10), + String separator = '\n', + }) async { + await lock.synchronized(() async { + await autoInsertText( + text, + inputType: inputType, + delay: delay, + separator: separator, + ); + }); + } + + /// This function is used to insert text in an asynchronous way + /// + /// It is suitable for inserting a long paragraph or a long sentence. + Future autoInsertText( + String text, { + TextRobotInputType inputType = TextRobotInputType.word, + Duration delay = const Duration(milliseconds: 10), + String separator = '\n', + }) async { + if (text == separator) { + await insertNewParagraph(delay); + return; + } + final lines = _splitText(text, separator); + for (final line in lines) { + if (line.isEmpty) { + await insertNewParagraph(delay); + continue; + } + switch (inputType) { + case TextRobotInputType.character: + await insertCharacter(line, delay); + break; + case TextRobotInputType.word: + await insertWord(line, delay); + break; + case TextRobotInputType.sentence: + await insertSentence(line, delay); + break; + } + } + } + + Future insertCharacter(String line, Duration delay) async { + final iterator = line.runes.iterator; + while (iterator.moveNext()) { + await insertText(iterator.currentAsString, delay); + } + } + + Future insertWord(String line, Duration delay) async { + final words = line.split(' '); + if (words.length == 1 || + (words.length == 2 && (words.first.isEmpty || words.last.isEmpty))) { + await insertText(line, delay); + } else { + for (final word in words.map((e) => '$e ')) { + await insertText(word, delay); + } + } + await Future.delayed(delay); + } + + Future insertSentence(String line, Duration delay) async { + await insertText(line, delay); + } + + Future insertNewParagraph(Duration delay) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final next = selection.end.path.next; + final transaction = editorState.transaction; + transaction.insertNode( + next, + paragraphNode(), + ); + transaction.afterSelection = Selection.collapsed( + Position(path: next), + ); + await editorState.apply(transaction); + await Future.delayed(const Duration(milliseconds: 10)); + } + + Future insertText(String text, Duration delay) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final transaction = editorState.transaction; + transaction.insertText(node, selection.endIndex, text); + await editorState.apply(transaction); + await Future.delayed(delay); + } +} + +List _splitText(String text, String separator) { + final parts = text.split(RegExp(separator)); + final result = []; + + for (int i = 0; i < parts.length; i++) { + result.add(parts[i]); + // Only add empty string if it's not the last part and the next part is not empty + if (i < parts.length - 1 && parts[i + 1].isNotEmpty) { + result.add(''); + } + } + + return result; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart new file mode 100644 index 0000000000000..ebbfb27db69b7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart @@ -0,0 +1,29 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +bool notShowInTable(EditorState editorState) { + final selection = editorState.selection; + if (selection == null) { + return false; + } + final nodes = editorState.getNodesInSelection(selection); + return nodes.every((element) { + if (element.type == TableBlockKeys.type) { + return false; + } + var parent = element.parent; + while (parent != null) { + if (parent.type == TableBlockKeys.type) { + return false; + } + parent = parent.parent; + } + return true; + }); +} + +bool onlyShowInSingleTextTypeSelectionAndExcludeTable( + EditorState editorState, +) { + return onlyShowInSingleSelectionAndTextType(editorState) && + notShowInTable(editorState); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart new file mode 100644 index 0000000000000..735b6b15df8fe --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart @@ -0,0 +1,31 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MenuBlockButton extends StatelessWidget { + const MenuBlockButton({ + super.key, + required this.tooltip, + required this.iconData, + this.onTap, + }); + + final VoidCallback? onTap; + final String tooltip; + final FlowySvgData iconData; + + @override + Widget build(BuildContext context) { + return FlowyButton( + useIntrinsicWidth: true, + onTap: onTap, + text: FlowyTooltip( + message: tooltip, + child: FlowySvg( + iconData, + size: const Size.square(16), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/block_transaction_handler/block_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/block_transaction_handler/block_transaction_handler.dart new file mode 100644 index 0000000000000..80ac61a4069ee --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/block_transaction_handler/block_transaction_handler.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy_editor/appflowy_editor.dart'; + +/// A handler for transactions that involve a Block Component. +/// +abstract class BlockTransactionHandler { + const BlockTransactionHandler({required this.blockType}); + + /// The type of the block that this handler is built for. + /// It's used to determine whether to call any of the handlers on certain transactions. + /// + final String blockType; + + Future onTransaction( + BuildContext context, + EditorState editorState, + List added, + List removed, { + bool isUndoRedo = false, + bool isPaste = false, + bool isDraggingNode = false, + String? parentViewId, + }); + + void onUndo( + BuildContext context, + EditorState editorState, + List before, + List after, + ); + + void onRedo( + BuildContext context, + EditorState editorState, + List before, + List after, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/bulleted_list/bulleted_list_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/bulleted_list/bulleted_list_icon.dart new file mode 100644 index 0000000000000..43b84ddc1c78b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/bulleted_list/bulleted_list_icon.dart @@ -0,0 +1,54 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BulletedListIcon extends StatelessWidget { + const BulletedListIcon({ + super.key, + required this.node, + }); + + final Node node; + + static final bulletedListIcons = [ + FlowySvgs.bulleted_list_icon_1_s, + FlowySvgs.bulleted_list_icon_2_s, + FlowySvgs.bulleted_list_icon_3_s, + ]; + + int get level { + var level = 0; + var parent = node.parent; + while (parent != null) { + if (parent.type == BulletedListBlockKeys.type) { + level++; + } + parent = parent.parent; + } + return level; + } + + @override + Widget build(BuildContext context) { + final textStyle = + context.read().editorStyle.textStyleConfiguration; + final fontSize = textStyle.text.fontSize ?? 16.0; + final height = textStyle.text.height ?? textStyle.lineHeight; + final size = fontSize * height; + final index = level % bulletedListIcons.length; + final icon = FlowySvg( + bulletedListIcons[index], + size: Size.square(size * 0.8), + ); + return Container( + constraints: BoxConstraints( + minWidth: size, + minHeight: size, + ), + margin: const EdgeInsets.only(right: 8.0), + alignment: Alignment.center, + child: icon, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart new file mode 100644 index 0000000000000..20cf6b590274a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart @@ -0,0 +1,294 @@ +import 'package:appflowy/generated/locale_keys.g.dart' show LocaleKeys; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart' + show StringTranslateExtension; +import 'package:easy_localization/easy_localization.dart' hide TextDirection; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../base/emoji_picker_button.dart'; + +// defining the keys of the callout block's attributes for easy access +class CalloutBlockKeys { + const CalloutBlockKeys._(); + + static const String type = 'callout'; + + /// The content of a code block. + /// + /// The value is a String. + static const String delta = 'delta'; + + /// The background color of a callout block. + /// + /// The value is a String. + static const String backgroundColor = blockComponentBackgroundColor; + + /// The emoji icon of a callout block. + /// + /// The value is a String. + static const String icon = 'icon'; +} + +// The one is inserted through selection menu +Node calloutNode({ + Delta? delta, + String emoji = '📌', + Color? defaultColor, +}) { + final attributes = { + CalloutBlockKeys.delta: (delta ?? Delta()).toJson(), + CalloutBlockKeys.icon: emoji, + CalloutBlockKeys.backgroundColor: defaultColor?.toHex(), + }; + return Node( + type: CalloutBlockKeys.type, + attributes: attributes, + ); +} + +// defining the callout block menu item in selection menu +SelectionMenuItem calloutItem = SelectionMenuItem.node( + getName: LocaleKeys.document_plugins_callout.tr, + iconData: Icons.note, + keywords: [CalloutBlockKeys.type], + nodeBuilder: (editorState, context) => + calloutNode(defaultColor: Colors.transparent), + replace: (_, node) => node.delta?.isEmpty ?? false, + updateSelection: (_, path, __, ___) { + return Selection.single(path: path, startOffset: 0); + }, +); + +// building the callout block widget +class CalloutBlockComponentBuilder extends BlockComponentBuilder { + CalloutBlockComponentBuilder({ + super.configuration, + required this.defaultColor, + required this.inlinePadding, + }); + + final Color defaultColor; + final EdgeInsets inlinePadding; + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return CalloutBlockComponentWidget( + key: node.key, + node: node, + defaultColor: defaultColor, + inlinePadding: inlinePadding, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => + (node) => node.delta != null && node.children.isEmpty; +} + +// the main widget for rendering the callout block +class CalloutBlockComponentWidget extends BlockComponentStatefulWidget { + const CalloutBlockComponentWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + required this.defaultColor, + required this.inlinePadding, + }); + + final Color defaultColor; + final EdgeInsets inlinePadding; + + @override + State createState() => + _CalloutBlockComponentWidgetState(); +} + +class _CalloutBlockComponentWidgetState + extends State + with + SelectableMixin, + DefaultSelectableMixin, + BlockComponentConfigurable, + BlockComponentTextDirectionMixin, + BlockComponentAlignMixin, + BlockComponentBackgroundColorMixin { + // the key used to forward focus to the richtext child + @override + final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text'); + + // the key used to identify this component + @override + GlobalKey> get containerKey => widget.node.key; + + @override + GlobalKey> blockComponentKey = GlobalKey( + debugLabel: CalloutBlockKeys.type, + ); + + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + @override + Color get backgroundColor { + final color = super.backgroundColor; + if (color == Colors.transparent) { + return AFThemeExtension.of(context).calloutBGColor; + } + return color; + } + + // get the emoji of the note block from the node's attributes or default to '📌' + String get emoji { + final icon = node.attributes[CalloutBlockKeys.icon]; + if (icon == null || icon.isEmpty) { + return '📌'; + } + return icon; + } + + // get access to the editor state via provider + @override + late final editorState = Provider.of(context, listen: false); + + // build the callout block widget + @override + Widget build(BuildContext context) { + final textDirection = calculateTextDirection( + layoutDirection: Directionality.maybeOf(context), + ); + final (emojiSize, emojiButtonSize) = calculateEmojiSize(); + + Widget child = Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + color: backgroundColor, + ), + padding: widget.inlinePadding, + width: double.infinity, + alignment: alignment, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + textDirection: textDirection, + children: [ + if (UniversalPlatform.isDesktopOrWeb) const HSpace(4.0), + // the emoji picker button for the note + EmojiPickerButton( + // force to refresh the popover state + key: ValueKey(widget.node.id + emoji), + enable: editorState.editable, + title: '', + emoji: EmojiIconData.emoji(emoji), + emojiSize: emojiSize, + showBorder: false, + buttonSize: emojiButtonSize, + onSubmitted: (emoji, controller) { + setEmoji(emoji.emoji); + controller?.close(); + }, + ), + if (UniversalPlatform.isDesktopOrWeb) const HSpace(4.0), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: buildCalloutBlockComponent(context, textDirection), + ), + ), + const HSpace(8.0), + ], + ), + ); + + child = Padding( + key: blockComponentKey, + padding: padding, + child: child, + ); + + child = BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + blockColor: editorState.editorStyle.selectionColor, + supportTypes: const [ + BlockSelectionType.block, + ], + child: child, + ); + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: widget.node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + return child; + } + + // build the richtext child + Widget buildCalloutBlockComponent( + BuildContext context, + TextDirection textDirection, + ) { + return AppFlowyRichText( + key: forwardKey, + delegate: this, + node: widget.node, + editorState: editorState, + placeholderText: placeholderText, + textAlign: alignment?.toTextAlign ?? textAlign, + textSpanDecorator: (textSpan) => textSpan.updateTextStyle( + textStyle, + ), + placeholderTextSpanDecorator: (textSpan) => textSpan.updateTextStyle( + placeholderTextStyle, + ), + textDirection: textDirection, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: editorState.editorStyle.selectionColor, + ); + } + + // set the emoji of the note block + Future setEmoji(String emoji) async { + final transaction = editorState.transaction + ..updateNode(node, { + CalloutBlockKeys.icon: emoji, + }) + ..afterSelection = Selection.collapsed( + Position(path: node.path, offset: node.delta?.length ?? 0), + ); + await editorState.apply(transaction); + } + + (double, Size) calculateEmojiSize() { + const double defaultEmojiSize = 16.0; + const Size defaultEmojiButtonSize = Size(30.0, 30.0); + final double emojiSize = + editorState.editorStyle.textStyleConfiguration.text.fontSize ?? + defaultEmojiSize; + final emojiButtonSize = + defaultEmojiButtonSize * emojiSize / defaultEmojiSize; + return (emojiSize, emojiButtonSize); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_shortcuts.dart new file mode 100644 index 0000000000000..3c50661071420 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_shortcuts.dart @@ -0,0 +1,41 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; + +/// Pressing Enter in a callout block will insert a newline (\n) within the callout, +/// while pressing Shift+Enter in a callout will insert a new paragraph next to the callout. +/// +/// - support +/// - desktop +/// - mobile +/// - web +/// +final CharacterShortcutEvent insertNewLineInCalloutBlock = + CharacterShortcutEvent( + key: 'insert a new line in callout block', + character: '\n', + handler: _insertNewLineHandler, +); + +CharacterShortcutEventHandler _insertNewLineHandler = (editorState) async { + final selection = editorState.selection?.normalized; + if (selection == null) { + return false; + } + + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.type != CalloutBlockKeys.type) { + return false; + } + + // delete the selection + await editorState.deleteSelection(selection); + + if (HardwareKeyboard.instance.isShiftPressed) { + await editorState.insertNewLine(); + } else { + await editorState.insertTextAtCurrentSelection('\n'); + } + + return true; +}; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart new file mode 100644 index 0000000000000..cc80119cb90c8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_copy_button.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; + +CodeBlockCopyBuilder codeBlockCopyBuilder = + (_, node) => _CopyButton(node: node); + +class _CopyButton extends StatelessWidget { + const _CopyButton({required this.node}); + + final Node node; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4), + child: FlowyTooltip( + message: LocaleKeys.document_codeBlock_copyTooltip.tr(), + child: FlowyIconButton( + onPressed: () async { + final delta = node.delta; + if (delta == null) { + return; + } + + final document = Document.blank() + ..insert([0], [node.deepCopy()]) + ..toJson(); + + await getIt().setData( + ClipboardServiceData( + plainText: delta.toPlainText(), + inAppJson: jsonEncode(document.toJson()), + ), + ); + + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(), + ); + } + }, + hoverColor: Theme.of(context).colorScheme.secondaryContainer, + icon: FlowySvg( + FlowySvgs.copy_s, + color: AFThemeExtension.of(context).textColor, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_language_selector.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_language_selector.dart new file mode 100644 index 0000000000000..14b19a6927977 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_language_selector.dart @@ -0,0 +1,253 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_item_list_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:universal_platform/universal_platform.dart'; + +CodeBlockLanguagePickerBuilder codeBlockLanguagePickerBuilder = ( + editorState, + supportedLanguages, + onLanguageSelected, { + selectedLanguage, + onMenuClose, + onMenuOpen, +}) => + CodeBlockLanguageSelector( + editorState: editorState, + language: selectedLanguage, + supportedLanguages: supportedLanguages, + onLanguageSelected: onLanguageSelected, + onMenuClose: onMenuClose, + onMenuOpen: onMenuOpen, + ); + +class CodeBlockLanguageSelector extends StatefulWidget { + const CodeBlockLanguageSelector({ + super.key, + required this.editorState, + required this.supportedLanguages, + this.language, + required this.onLanguageSelected, + this.onMenuOpen, + this.onMenuClose, + }); + + final EditorState editorState; + final List supportedLanguages; + final String? language; + final void Function(String) onLanguageSelected; + final VoidCallback? onMenuOpen; + final VoidCallback? onMenuClose; + + @override + State createState() => + _CodeBlockLanguageSelectorState(); +} + +class _CodeBlockLanguageSelectorState extends State { + final controller = PopoverController(); + + @override + void dispose() { + controller.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child = Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0), + child: FlowyTextButton( + widget.language?.capitalize() ?? + LocaleKeys.document_codeBlock_language_auto.tr(), + constraints: const BoxConstraints(minWidth: 50), + fontColor: AFThemeExtension.of(context).onBackground, + padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4), + fillColor: Colors.transparent, + hoverColor: Theme.of(context).colorScheme.secondaryContainer, + onPressed: () async { + if (UniversalPlatform.isMobile) { + final language = await context + .push(MobileCodeLanguagePickerScreen.routeName); + if (language != null) { + widget.onLanguageSelected(language); + } + } + }, + ), + ), + ], + ); + + if (UniversalPlatform.isDesktopOrWeb) { + child = AppFlowyPopover( + controller: controller, + direction: PopoverDirection.bottomWithLeftAligned, + onOpen: widget.onMenuOpen, + constraints: const BoxConstraints(maxHeight: 300, maxWidth: 200), + onClose: widget.onMenuClose, + popupBuilder: (_) => _LanguageSelectionPopover( + editorState: widget.editorState, + language: widget.language, + supportedLanguages: widget.supportedLanguages, + onLanguageSelected: (language) { + widget.onLanguageSelected(language); + controller.close(); + }, + ), + child: child, + ); + } + + return child; + } +} + +class _LanguageSelectionPopover extends StatefulWidget { + const _LanguageSelectionPopover({ + required this.editorState, + required this.language, + required this.supportedLanguages, + required this.onLanguageSelected, + }); + + final EditorState editorState; + final String? language; + final List supportedLanguages; + final void Function(String) onLanguageSelected; + + @override + State<_LanguageSelectionPopover> createState() => + _LanguageSelectionPopoverState(); +} + +class _LanguageSelectionPopoverState extends State<_LanguageSelectionPopover> { + final searchController = TextEditingController(); + final focusNode = FocusNode(); + late List filteredLanguages = + widget.supportedLanguages.map((e) => e.capitalize()).toList(); + late int selectedIndex = + widget.supportedLanguages.indexOf(widget.language?.toLowerCase() ?? ''); + final ItemScrollController languageListController = ItemScrollController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + // This is a workaround because longer taps might break the + // focus, this might be an issue with the Flutter framework. + (_) => Future.delayed( + const Duration(milliseconds: 100), + () => focusNode.requestFocus(), + ), + ); + } + + @override + void dispose() { + focusNode.dispose(); + searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.arrowUp): + _DirectionIntent(AxisDirection.up), + SingleActivator(LogicalKeyboardKey.arrowDown): + _DirectionIntent(AxisDirection.down), + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + }, + child: Actions( + actions: { + _DirectionIntent: CallbackAction<_DirectionIntent>( + onInvoke: (intent) => onArrowKey(intent.direction), + ), + ActivateIntent: CallbackAction( + onInvoke: (intent) { + if (selectedIndex < 0) return; + selectLanguage(selectedIndex); + return null; + }, + ), + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyTextField( + focusNode: focusNode, + autoFocus: false, + controller: searchController, + hintText: LocaleKeys.document_codeBlock_searchLanguageHint.tr(), + onChanged: (_) => setState(() { + filteredLanguages = widget.supportedLanguages + .where( + (e) => e.contains(searchController.text.toLowerCase()), + ) + .map((e) => e.capitalize()) + .toList(); + selectedIndex = + widget.supportedLanguages.indexOf(widget.language ?? ''); + }), + ), + const VSpace(8), + Flexible( + child: SelectableItemListMenu( + controller: languageListController, + shrinkWrap: true, + items: filteredLanguages, + selectedIndex: selectedIndex, + onSelected: selectLanguage, + ), + ), + ], + ), + ), + ); + } + + void onArrowKey(AxisDirection direction) { + if (filteredLanguages.isEmpty) return; + final isUp = direction == AxisDirection.up; + if (selectedIndex < 0) { + selectedIndex = isUp ? 0 : -1; + } + final length = filteredLanguages.length; + setState(() { + if (isUp) { + selectedIndex = selectedIndex == 0 ? length - 1 : selectedIndex - 1; + } else { + selectedIndex = selectedIndex == length - 1 ? 0 : selectedIndex + 1; + } + }); + languageListController.scrollTo( + index: selectedIndex, + alignment: 0.5, + duration: const Duration(milliseconds: 300), + ); + } + + void selectLanguage(int index) { + widget.onLanguageSelected(filteredLanguages[index]); + } +} + +/// [ScrollIntent] is not working, so using this custom Intent +class _DirectionIntent extends Intent { + const _DirectionIntent(this.direction); + + final AxisDirection direction; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_menu_item.dart new file mode 100644 index 0000000000000..c4e395f22f5ab --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_block_menu_item.dart @@ -0,0 +1,18 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; + +final codeBlockSelectionMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_selectionMenu_codeBlock.tr(), + iconBuilder: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.icon_code_block_s, + isSelected: onSelected, + style: style, + ), + keywords: ['code', 'codeblock'], + nodeBuilder: (_, __) => codeBlockNode(), + replace: (_, node) => node.delta?.isEmpty ?? false, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart new file mode 100644 index 0000000000000..8c4a239e20e2b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:go_router/go_router.dart'; + +class MobileCodeLanguagePickerScreen extends StatelessWidget { + const MobileCodeLanguagePickerScreen({super.key}); + + static const routeName = '/code_language_picker'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: FlowyAppBar(titleText: LocaleKeys.titleBar_language.tr()), + body: SafeArea( + child: ListView.separated( + separatorBuilder: (_, __) => const Divider(), + itemCount: defaultCodeBlockSupportedLanguages.length, + itemBuilder: (context, index) { + final language = defaultCodeBlockSupportedLanguages[index]; + return SizedBox( + height: 48, + child: FlowyTextButton( + language.capitalize(), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 4.0, + ), + onPressed: () => context.pop(language), + ), + ); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/context_menu/custom_context_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/context_menu/custom_context_menu.dart new file mode 100644 index 0000000000000..cc496ff9d46d7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/context_menu/custom_context_menu.dart @@ -0,0 +1,26 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +final List> customContextMenuItems = [ + [ + ContextMenuItem( + getName: LocaleKeys.document_plugins_contextMenu_copy.tr, + onPressed: (editorState) => customCopyCommand.execute(editorState), + ), + ContextMenuItem( + getName: LocaleKeys.document_plugins_contextMenu_paste.tr, + onPressed: (editorState) => customPasteCommand.execute(editorState), + ), + ContextMenuItem( + getName: LocaleKeys.document_plugins_contextMenu_pasteAsPlainText.tr, + onPressed: (editorState) => + customPastePlainTextCommand.execute(editorState), + ), + ContextMenuItem( + getName: LocaleKeys.document_plugins_contextMenu_cut.tr, + onPressed: (editorState) => customCutCommand.execute(editorState), + ), + ], +]; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart new file mode 100644 index 0000000000000..f4aac4fe2ce84 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart @@ -0,0 +1,197 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy_backend/log.dart'; +import 'package:flutter/foundation.dart'; +import 'package:super_clipboard/super_clipboard.dart'; + +/// Used for in-app copy and paste without losing the format. +/// +/// It's a Json string representing the copied editor nodes. +const inAppJsonFormat = CustomValueFormat( + applicationId: 'io.appflowy.InAppJsonType', + onDecode: _defaultDecode, + onEncode: _defaultEncode, +); + +/// Used for table nodes when coping a row or a column. +const tableJsonFormat = CustomValueFormat( + applicationId: 'io.appflowy.TableJsonType', + onDecode: _defaultDecode, + onEncode: _defaultEncode, +); + +class ClipboardServiceData { + const ClipboardServiceData({ + this.plainText, + this.html, + this.image, + this.inAppJson, + this.tableJson, + }); + + /// The [plainText] is the plain text string. + /// + /// It should be used for pasting the plain text from the clipboard. + final String? plainText; + + /// The [html] is the html string. + /// + /// It should be used for pasting the html from the clipboard. + /// For example, copy the content in the browser, and paste it in the editor. + final String? html; + + /// The [image] is the image data. + /// + /// It should be used for pasting the image from the clipboard. + /// For example, copy the image in the browser or other apps, and paste it in the editor. + final (String, Uint8List?)? image; + + /// The [inAppJson] is the json string of the editor nodes. + /// + /// It should be used for pasting the content in-app. + /// For example, pasting the content from document A to document B. + final String? inAppJson; + + /// The [tableJson] is the json string of the table nodes. + /// + /// It only works for the table nodes when coping a row or a column. + /// Don't use it for another scenario. + final String? tableJson; +} + +class ClipboardService { + static ClipboardServiceData? _mockData; + + @visibleForTesting + static void mockSetData(ClipboardServiceData? data) { + _mockData = data; + } + + Future setData(ClipboardServiceData data) async { + final plainText = data.plainText; + final html = data.html; + final inAppJson = data.inAppJson; + final image = data.image; + final tableJson = data.tableJson; + + final item = DataWriterItem(); + if (plainText != null) { + item.add(Formats.plainText(plainText)); + } + if (html != null) { + item.add(Formats.htmlText(html)); + } + if (inAppJson != null) { + item.add(inAppJsonFormat(inAppJson)); + } + if (tableJson != null) { + item.add(tableJsonFormat(tableJson)); + } + if (image != null && image.$2?.isNotEmpty == true) { + switch (image.$1) { + case 'png': + item.add(Formats.png(image.$2!)); + break; + case 'jpeg': + item.add(Formats.jpeg(image.$2!)); + break; + case 'gif': + item.add(Formats.gif(image.$2!)); + break; + default: + throw Exception('unsupported image format: ${image.$1}'); + } + } + await SystemClipboard.instance?.write([item]); + } + + Future setPlainText(String text) async { + await SystemClipboard.instance?.write([ + DataWriterItem()..add(Formats.plainText(text)), + ]); + } + + Future getData() async { + if (_mockData != null) { + return _mockData!; + } + + final reader = await SystemClipboard.instance?.read(); + + if (reader == null) { + return const ClipboardServiceData(); + } + + for (final item in reader.items) { + final availableFormats = await item.rawReader!.getAvailableFormats(); + Log.info('availableFormats: $availableFormats'); + } + + final plainText = await reader.readValue(Formats.plainText); + final html = await reader.readValue(Formats.htmlText); + final inAppJson = await reader.readValue(inAppJsonFormat); + final tableJson = await reader.readValue(tableJsonFormat); + (String, Uint8List?)? image; + if (reader.canProvide(Formats.png)) { + image = ('png', await reader.readFile(Formats.png)); + } else if (reader.canProvide(Formats.jpeg)) { + image = ('jpeg', await reader.readFile(Formats.jpeg)); + } else if (reader.canProvide(Formats.gif)) { + image = ('gif', await reader.readFile(Formats.gif)); + } else if (reader.canProvide(Formats.webp)) { + image = ('webp', await reader.readFile(Formats.webp)); + } + + return ClipboardServiceData( + plainText: plainText, + html: html, + image: image, + inAppJson: inAppJson, + tableJson: tableJson, + ); + } +} + +extension on DataReader { + Future? readFile(FileFormat format) { + final c = Completer(); + final progress = getFile( + format, + (file) async { + try { + final all = await file.readAll(); + c.complete(all); + } catch (e) { + c.completeError(e); + } + }, + onError: (e) { + c.completeError(e); + }, + ); + if (progress == null) { + c.complete(null); + } + return c.future; + } +} + +/// The default decode function for the clipboard service. +Future _defaultDecode(Object value, String platformType) async { + if (value is PlatformDataProvider) { + final data = await value.getData(platformType); + if (data is List) { + return utf8.decode(data, allowMalformed: true); + } + if (data is String) { + return Uri.decodeFull(data); + } + } + return null; +} + +/// The default encode function for the clipboard service. +Future _defaultEncode(String value, String platformType) async { + return utf8.encode(value); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart new file mode 100644 index 0000000000000..99211920fa6eb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +/// Copy. +/// +/// - support +/// - desktop +/// - web +/// - mobile +/// +final CommandShortcutEvent customCopyCommand = CommandShortcutEvent( + key: 'copy the selected content', + getDescription: () => AppFlowyEditorL10n.current.cmdCopySelection, + command: 'ctrl+c', + macOSCommand: 'cmd+c', + handler: _copyCommandHandler, +); + +CommandShortcutEventHandler _copyCommandHandler = + (editorState) => handleCopyCommand(editorState); + +KeyEventResult handleCopyCommand( + EditorState editorState, { + bool isCut = false, +}) { + final selection = editorState.selection?.normalized; + if (selection == null) { + return KeyEventResult.ignored; + } + + String? text; + String? html; + String? inAppJson; + + if (selection.isCollapsed) { + // if the selection is collapsed, we will copy the text of the current line. + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return KeyEventResult.ignored; + } + + // plain text. + text = node.delta?.toPlainText(); + + // in app json + final document = Document.blank() + ..insert([0], [_handleNode(node.deepCopy(), isCut)]); + inAppJson = jsonEncode(document.toJson()); + + // html + html = documentToHTML(document); + } else { + // plain text. + text = editorState.getTextInSelection(selection).join('\n'); + + final document = _buildCopiedDocument( + editorState, + selection, + isCut: isCut, + ); + + inAppJson = jsonEncode(document.toJson()); + + // html + html = documentToHTML(document); + } + + () async { + await getIt().setData( + ClipboardServiceData( + plainText: text, + html: html, + inAppJson: inAppJson, + ), + ); + }(); + + return KeyEventResult.handled; +} + +Document _buildCopiedDocument( + EditorState editorState, + Selection selection, { + bool isCut = false, +}) { + // filter the table nodes + final filteredNodes = []; + final selectedNodes = editorState.getSelectedNodes(selection: selection); + final nodes = _handleSubPageNodes(selectedNodes, isCut); + for (final node in nodes) { + if (node.type == SimpleTableCellBlockKeys.type) { + // if the node is a table cell, we will fetch its children instead. + filteredNodes.addAll(node.children); + } else if (node.type == SimpleTableRowBlockKeys.type) { + // if the node is a table row, we will fetch its children instead. + filteredNodes.addAll(node.children.expand((e) => e.children)); + } else { + filteredNodes.add(node); + } + } + final document = Document.blank() + ..insert( + [0], + filteredNodes.map((e) => e.deepCopy()), + ); + return document; +} + +List _handleSubPageNodes(List nodes, [bool isCut = false]) { + final handled = []; + for (final node in nodes) { + handled.add(_handleNode(node, isCut)); + } + + return handled; +} + +Node _handleNode(Node node, [bool isCut = false]) { + if (!isCut) { + return node.deepCopy(); + } + + final newChildren = node.children.map(_handleNode).toList(); + + if (node.type == SubPageBlockKeys.type) { + return node.copyWith( + attributes: { + ...node.attributes, + SubPageBlockKeys.wasCopied: !isCut, + SubPageBlockKeys.wasCut: isCut, + }, + children: newChildren, + ); + } + + return node.copyWith(children: newChildren); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_cut_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_cut_command.dart new file mode 100644 index 0000000000000..9fea34edbf22c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_cut_command.dart @@ -0,0 +1,61 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/clipboard_state.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// cut. +/// +/// - support +/// - desktop +/// - web +/// - mobile +/// +final CommandShortcutEvent customCutCommand = CommandShortcutEvent( + key: 'cut the selected content', + getDescription: () => AppFlowyEditorL10n.current.cmdCutSelection, + command: 'ctrl+x', + macOSCommand: 'cmd+x', + handler: _cutCommandHandler, +); + +CommandShortcutEventHandler _cutCommandHandler = (editorState) { + final selection = editorState.selection; + if (selection == null) { + return KeyEventResult.ignored; + } + + final context = editorState.document.root.context; + if (context == null || !context.mounted) { + return KeyEventResult.ignored; + } + + context.read().didCut(); + + handleCopyCommand(editorState, isCut: true); + + if (!selection.isCollapsed) { + editorState.deleteSelectionIfNeeded(); + } else { + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) { + return KeyEventResult.handled; + } + // prevent to cut the node that is selecting the table. + if (node.parentTableNode != null) { + return KeyEventResult.skipRemainingHandlers; + } + + final transaction = editorState.transaction; + transaction.deleteNode(node); + final nextNode = node.next; + if (nextNode != null && nextNode.delta != null) { + transaction.afterSelection = Selection.collapsed( + Position(path: node.path, offset: nextNode.delta?.length ?? 0), + ); + } + editorState.apply(transaction); + } + + return KeyEventResult.handled; +}; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart new file mode 100644 index 0000000000000..ed353103fb7e7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart @@ -0,0 +1,237 @@ +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_in_app_json.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart'; +import 'package:appflowy/shared/clipboard_state.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/default_extensions.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:string_validator/string_validator.dart'; + +/// - support +/// - desktop +/// - web +/// - mobile +/// +final CommandShortcutEvent customPasteCommand = CommandShortcutEvent( + key: 'paste the content', + getDescription: () => AppFlowyEditorL10n.current.cmdPasteContent, + command: 'ctrl+v', + macOSCommand: 'cmd+v', + handler: _pasteCommandHandler, +); + +final CommandShortcutEvent customPastePlainTextCommand = CommandShortcutEvent( + key: 'paste the plain content', + getDescription: () => AppFlowyEditorL10n.current.cmdPasteContent, + command: 'ctrl+shift+v', + macOSCommand: 'cmd+shift+v', + handler: _pastePlainCommandHandler, +); + +CommandShortcutEventHandler _pasteCommandHandler = (editorState) { + final selection = editorState.selection; + if (selection == null) { + return KeyEventResult.ignored; + } + + doPaste(editorState).then((_) { + final context = editorState.document.root.context; + if (context != null && context.mounted) { + context.read().didPaste(); + } + }); + + return KeyEventResult.handled; +}; + +CommandShortcutEventHandler _pastePlainCommandHandler = (editorState) { + final selection = editorState.selection; + if (selection == null) { + return KeyEventResult.ignored; + } + + doPlainPaste(editorState).then((_) { + final context = editorState.document.root.context; + if (context != null && context.mounted) { + context.read().didPaste(); + } + }); + + return KeyEventResult.handled; +}; + +Future doPaste(EditorState editorState) async { + final selection = editorState.selection; + if (selection == null) { + return; + } + + EditorNotification.paste().post(); + + // dispatch the paste event + final data = await getIt().getData(); + final inAppJson = data.inAppJson; + final html = data.html; + final plainText = data.plainText; + final image = data.image; + + // dump the length of the data here, don't log the data itself for privacy concerns + Log.info('paste command: inAppJson: ${inAppJson?.length}'); + Log.info('paste command: html: ${html?.length}'); + Log.info('paste command: plainText: ${plainText?.length}'); + Log.info('paste command: image: ${image?.$2?.length}'); + + if (await editorState.pasteAppFlowySharePageLink(plainText)) { + return Log.info('Pasted block link'); + } + + // paste as link preview + if (await _pasteAsLinkPreview(editorState, plainText)) { + return Log.info('Pasted as link preview'); + } + + // Order: + // 1. in app json format + // 2. html + // 3. image + // 4. plain text + + // try to paste the content in order, if any of them is failed, then try the next one + if (inAppJson != null && inAppJson.isNotEmpty) { + if (await editorState.pasteInAppJson(inAppJson)) { + return Log.info('Pasted in app json'); + } + } + + // if the image data is not null, we should handle it first + // because the image URL in the HTML may not be reachable due to permission issues + // For example, when pasting an image from Slack, the image URL provided is not public. + if (image != null && image.$2?.isNotEmpty == true) { + final documentBloc = + editorState.document.root.context?.read(); + final documentId = documentBloc?.documentId; + if (documentId == null || documentId.isEmpty) { + return; + } + + await editorState.deleteSelectionIfNeeded(); + final result = await editorState.pasteImage( + image.$1, + image.$2!, + documentId, + selection: selection, + ); + if (result) { + return Log.info('Pasted image'); + } + } + + if (html != null && html.isNotEmpty) { + await editorState.deleteSelectionIfNeeded(); + if (await editorState.pasteHtml(html)) { + return Log.info('Pasted html'); + } + } + + if (plainText != null && plainText.isNotEmpty) { + await editorState.pasteText(plainText); + return Log.info('Pasted plain text'); + } + + return Log.info('unable to parse the clipboard content'); +} + +Future _pasteAsLinkPreview( + EditorState editorState, + String? text, +) async { + // 1. the url should contains a protocol + // 2. the url should not be an image url + if (text == null || + text.isImageUrl() || + !isURL(text, {'require_protocol': true})) { + return false; + } + + final selection = editorState.selection; + // Apply the update only when the selection is collapsed + // and at the start of the current line + if (selection == null || + !selection.isCollapsed || + selection.startIndex != 0) { + return false; + } + + final node = editorState.getNodeAtPath(selection.start.path); + // Apply the update only when the current node is a paragraph + // and the paragraph is empty + if (node == null || + node.type != ParagraphBlockKeys.type || + node.delta?.toPlainText().isNotEmpty == true) { + return false; + } + + // 1. insert the text with link format + // 2. convert it the link preview node + final textTransaction = editorState.transaction; + textTransaction.insertText( + node, + 0, + text, + attributes: {AppFlowyRichTextKeys.href: text}, + ); + await editorState.apply( + textTransaction, + skipHistoryDebounce: true, + ); + + final linkPreviewTransaction = editorState.transaction; + final insertedNodes = [ + linkPreviewNode(url: text), + // if the next node is null, insert a empty paragraph node + if (node.next == null) paragraphNode(), + ]; + linkPreviewTransaction.insertNodes( + selection.start.path, + insertedNodes, + ); + linkPreviewTransaction.deleteNode(node); + linkPreviewTransaction.afterSelection = Selection.collapsed( + Position( + path: node.path.next, + ), + ); + await editorState.apply(linkPreviewTransaction); + + return true; +} + +Future doPlainPaste(EditorState editorState) async { + final selection = editorState.selection; + if (selection == null) { + return; + } + + EditorNotification.paste().post(); + + // dispatch the paste event + final data = await getIt().getData(); + final plainText = data.plainText; + if (plainText != null && plainText.isNotEmpty) { + await editorState.pastePlainText(plainText); + Log.info('Pasted plain text'); + return; + } + + Log.info('unable to parse the clipboard content'); + return; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart new file mode 100644 index 0000000000000..fbd9914c1d616 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_block_link.dart @@ -0,0 +1,58 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension PasteFromBlockLink on EditorState { + Future pasteAppFlowySharePageLink(String? sharePageLink) async { + if (sharePageLink == null || sharePageLink.isEmpty) { + return false; + } + + // Check if the link matches the appflowy block link format + final match = appflowySharePageLinkRegex.firstMatch(sharePageLink); + + if (match == null) { + return false; + } + + final workspaceId = match.group(1); + final pageId = match.group(2); + final blockId = match.group(3); + + if (workspaceId == null || pageId == null) { + Log.error( + 'Failed to extract information from block link: $sharePageLink', + ); + return false; + } + + final selection = this.selection; + if (selection == null) { + return false; + } + + final node = getNodesInSelection(selection).firstOrNull; + if (node == null) { + return false; + } + + // todo: if the current link is not from current workspace. + final transaction = this.transaction; + transaction.insertText( + node, + selection.startIndex, + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.page.name, + MentionBlockKeys.blockId: blockId, + MentionBlockKeys.pageId: pageId, + }, + }, + ); + await apply(transaction); + + return true; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart new file mode 100644 index 0000000000000..dc05e852c94e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:cross_file/cross_file.dart'; + +extension PasteFromFile on EditorState { + Future dropFiles( + List dropPath, + List files, + String documentId, + bool isLocalMode, + ) async { + for (final file in files) { + String? path; + FileUrlType? type; + if (isLocalMode) { + path = await saveFileToLocalStorage(file.path); + type = FileUrlType.local; + } else { + (path, _) = await saveFileToCloudStorage(file.path, documentId); + type = FileUrlType.cloud; + } + + if (path == null) { + continue; + } + + final t = transaction + ..insertNode( + dropPath, + fileNode( + url: path, + type: type, + name: file.name, + ), + ); + await apply(t); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart new file mode 100644 index 0000000000000..87259d598132b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart @@ -0,0 +1,24 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension PasteFromHtml on EditorState { + Future pasteHtml(String html) async { + final nodes = htmlToDocument(html).root.children.toList(); + // remove the front and back empty line + while (nodes.isNotEmpty && nodes.first.delta?.isEmpty == true) { + nodes.removeAt(0); + } + while (nodes.isNotEmpty && nodes.last.delta?.isEmpty == true) { + nodes.removeLast(); + } + // if there's no nodes being converted successfully, return false + if (nodes.isEmpty) { + return false; + } + if (nodes.length == 1) { + await pasteSingleLineNode(nodes.first); + } else { + await pasteMultiLineNodes(nodes.toList()); + } + return true; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart new file mode 100644 index 0000000000000..6e6c9b1772b96 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart @@ -0,0 +1,187 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/default_extensions.dart'; +import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +extension PasteFromImage on EditorState { + Future dropImages( + List dropPath, + List files, + String documentId, + bool isLocalMode, + ) async { + final imageFiles = files.where( + (file) => + file.mimeType?.startsWith('image/') ?? + false || imgExtensionRegex.hasMatch(file.name.toLowerCase()), + ); + + for (final file in imageFiles) { + String? path; + CustomImageType? type; + if (isLocalMode) { + path = await saveImageToLocalStorage(file.path); + type = CustomImageType.local; + } else { + (path, _) = await saveImageToCloudStorage(file.path, documentId); + type = CustomImageType.internal; + } + + if (path == null) { + continue; + } + + final t = transaction + ..insertNode( + dropPath, + customImageNode(url: path, type: type), + ); + await apply(t); + } + } + + Future pasteImage( + String format, + Uint8List imageBytes, + String documentId, { + Selection? selection, + }) async { + final context = document.root.context; + + if (context == null) { + return false; + } + + if (!defaultImageExtensions.contains(format)) { + Log.info('unsupported format: $format'); + if (UniversalPlatform.isMobile) { + showToastNotification( + context, + message: LocaleKeys.document_imageBlock_error_invalidImageFormat.tr(), + ); + } + return false; + } + + final isLocalMode = context.read().isLocalMode; + + final path = await getIt().getPath(); + final imagePath = p.join( + path, + 'images', + ); + + try { + // create the directory if not exists + final directory = Directory(imagePath); + if (!directory.existsSync()) { + await directory.create(recursive: true); + } + final copyToPath = p.join( + imagePath, + 'tmp_${uuid()}.$format', + ); + await File(copyToPath).writeAsBytes(imageBytes); + final String? path; + + CustomImageType type; + if (isLocalMode) { + path = await saveImageToLocalStorage(copyToPath); + type = CustomImageType.local; + } else { + final result = await saveImageToCloudStorage(copyToPath, documentId); + + final errorMessage = result.$2; + + if (errorMessage != null && context.mounted) { + showToastNotification( + context, + message: errorMessage, + ); + return false; + } + + path = result.$1; + type = CustomImageType.internal; + } + + if (path != null) { + await insertImageNode(path, selection: selection, type: type); + } + + return true; + } catch (e) { + Log.error('cannot copy image file', e); + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + } + } + + return false; + } + + Future insertImageNode( + String src, { + Selection? selection, + required CustomImageType type, + }) async { + selection ??= this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final node = getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final transaction = this.transaction; + // if the current node is empty paragraph, replace it with image node + if (node.type == ParagraphBlockKeys.type && + (node.delta?.isEmpty ?? false)) { + transaction + ..insertNode( + node.path, + customImageNode( + url: src, + type: type, + ), + ) + ..deleteNode(node); + } else { + transaction.insertNode( + node.path.next, + customImageNode( + url: src, + type: type, + ), + ); + } + + transaction.afterSelection = Selection.collapsed( + Position( + path: node.path.next, + ), + ); + + return apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_in_app_json.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_in_app_json.dart new file mode 100644 index 0000000000000..70948cd962b7d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_in_app_json.dart @@ -0,0 +1,29 @@ +import 'dart:convert'; + +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension PasteFromInAppJson on EditorState { + Future pasteInAppJson(String inAppJson) async { + try { + final nodes = Document.fromJson(jsonDecode(inAppJson)).root.children; + if (nodes.isEmpty) { + Log.info('pasteInAppJson: nodes is empty'); + return false; + } + if (nodes.length == 1) { + Log.info('pasteInAppJson: single line node'); + await pasteSingleLineNode(nodes.first); + } else { + Log.info('pasteInAppJson: multi line nodes'); + await pasteMultiLineNodes(nodes.toList()); + } + return true; + } catch (e) { + Log.error( + 'Failed to paste in app json: $inAppJson, error: $e', + ); + } + return false; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart new file mode 100644 index 0000000000000..90ed4511283f5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart @@ -0,0 +1,67 @@ +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension PasteFromPlainText on EditorState { + Future pastePlainText(String plainText) async { + await deleteSelectionIfNeeded(); + final nodes = plainText + .split('\n') + .map( + (e) => e + ..replaceAll(r'\r', '') + ..trimRight(), + ) + .map((e) => Delta()..insert(e)) + .map((e) => paragraphNode(delta: e)) + .toList(); + if (nodes.isEmpty) { + return; + } + if (nodes.length == 1) { + await pasteSingleLineNode(nodes.first); + } else { + await pasteMultiLineNodes(nodes.toList()); + } + } + + Future pasteText(String plainText) async { + if (await pasteHtmlIfAvailable(plainText)) { + return; + } + + await deleteSelectionIfNeeded(); + + final nodes = customMarkdownToDocument(plainText).root.children; + if (nodes.isEmpty) { + return; + } + if (nodes.length == 1) { + await pasteSingleLineNode(nodes.first); + } else { + await pasteMultiLineNodes(nodes.toList()); + } + } + + Future pasteHtmlIfAvailable(String plainText) async { + final selection = this.selection; + if (selection == null || + !selection.isSingle || + selection.isCollapsed || + !hrefRegex.hasMatch(plainText)) { + return false; + } + + final node = getNodeAtPath(selection.start.path); + if (node == null) { + return false; + } + + final transaction = this.transaction; + transaction.formatText(node, selection.startIndex, selection.length, { + AppFlowyRichTextKeys.href: plainText, + }); + await apply(transaction); + return true; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart new file mode 100644 index 0000000000000..05c891bd1ef16 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart @@ -0,0 +1,352 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/flowy_gradient_colors.dart'; +import 'package:appflowy/shared/google_fonts_extension.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:auto_size_text_field/auto_size_text_field.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; + +double kDocumentCoverHeight = 98.0; +double kDocumentTitlePadding = 20.0; + +class DocumentImmersiveCover extends StatefulWidget { + const DocumentImmersiveCover({ + super.key, + required this.view, + required this.userProfilePB, + this.fixedTitle, + }); + + final ViewPB view; + final UserProfilePB userProfilePB; + final String? fixedTitle; + + @override + State createState() => _DocumentImmersiveCoverState(); +} + +class _DocumentImmersiveCoverState extends State { + final textEditingController = TextEditingController(); + final scrollController = ScrollController(); + final focusNode = FocusNode(); + + late PropertyValueNotifier? selectionNotifier = + context.read().state.editorState?.selectionNotifier; + + @override + void initState() { + super.initState(); + selectionNotifier?.addListener(_unfocus); + if (widget.view.name.isEmpty) { + focusNode.requestFocus(); + } + } + + @override + void dispose() { + textEditingController.dispose(); + scrollController.dispose(); + selectionNotifier?.removeListener(_unfocus); + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnoreParentGestureWidget( + child: BlocProvider( + create: (context) => DocumentImmersiveCoverBloc(view: widget.view) + ..add(const DocumentImmersiveCoverEvent.initial()), + child: BlocConsumer( + listener: (context, state) { + if (textEditingController.text != state.name) { + textEditingController.text = state.name; + } + }, + builder: (_, state) { + final iconAndTitle = _buildIconAndTitle(context, state); + if (state.cover.type == PageStyleCoverImageType.none) { + return Padding( + padding: EdgeInsets.only( + top: context.statusBarAndAppBarHeight + kDocumentTitlePadding, + ), + child: iconAndTitle, + ); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Stack( + children: [ + _buildCover(context, state), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: iconAndTitle, + ), + ), + ], + ), + ); + }, + ), + ), + ); + } + + Widget _buildIconAndTitle( + BuildContext context, + DocumentImmersiveCoverState state, + ) { + final icon = state.icon; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Row( + children: [ + if (icon != null && icon.isNotEmpty) ...[ + _buildIcon(context, icon), + const HSpace(8.0), + ], + Expanded(child: _buildTitle(context, state)), + ], + ), + ); + } + + Widget _buildTitle( + BuildContext context, + DocumentImmersiveCoverState state, + ) { + String? fontFamily = defaultFontFamily; + final documentFontFamily = + context.read().state.fontFamily; + if (documentFontFamily != null && fontFamily != documentFontFamily) { + fontFamily = getGoogleFontSafely(documentFontFamily).fontFamily; + } + + if (widget.fixedTitle != null) { + return FlowyText( + widget.fixedTitle!, + fontSize: 28.0, + fontWeight: FontWeight.w700, + fontFamily: fontFamily, + color: + state.cover.isNone || state.cover.isPresets ? null : Colors.white, + overflow: TextOverflow.ellipsis, + ); + } + + return AutoSizeTextField( + controller: textEditingController, + focusNode: focusNode, + minFontSize: 18.0, + decoration: InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + disabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + hintText: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + contentPadding: EdgeInsets.zero, + ), + scrollController: scrollController, + style: TextStyle( + fontSize: 28.0, + fontWeight: FontWeight.w700, + fontFamily: fontFamily, + color: + state.cover.isNone || state.cover.isPresets ? null : Colors.white, + overflow: TextOverflow.ellipsis, + ), + onChanged: (name) => Debounce.debounce( + 'rename', + const Duration(milliseconds: 300), + () => _rename(name), + ), + onSubmitted: (name) { + // focus on the document + _createNewLine(); + Debounce.debounce( + 'rename', + const Duration(milliseconds: 300), + () => _rename(name), + ); + }, + ); + } + + Widget _buildIcon(BuildContext context, EmojiIconData icon) { + return GestureDetector( + child: ConstrainedBox( + constraints: const BoxConstraints.tightFor(width: 34.0), + child: EmojiIconWidget( + emoji: icon, + emojiSize: 26, + ), + ), + onTap: () async { + final pageStyleIconBloc = PageStyleIconBloc(view: widget.view) + ..add(const PageStyleIconEvent.initial()); + await showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + showHeader: true, + title: LocaleKeys.titleBar_pageIcon.tr(), + backgroundColor: AFThemeExtension.of(context).background, + enableDraggableScrollable: true, + minChildSize: 0.6, + initialChildSize: 0.61, + scrollableWidgetBuilder: (_, controller) { + return BlocProvider.value( + value: pageStyleIconBloc, + child: Expanded( + child: FlowyIconEmojiPicker( + onSelectedEmoji: (r) { + pageStyleIconBloc.add( + PageStyleIconEvent.updateIcon(r, true), + ); + Navigator.pop(context); + }, + ), + ), + ); + }, + builder: (_) => const SizedBox.shrink(), + ); + }, + ); + } + + Widget _buildCover(BuildContext context, DocumentImmersiveCoverState state) { + final cover = state.cover; + final type = cover.type; + final naviBarHeight = MediaQuery.of(context).padding.top; + final height = naviBarHeight + kDocumentCoverHeight; + + if (type == PageStyleCoverImageType.customImage || + type == PageStyleCoverImageType.unsplashImage) { + return SizedBox( + height: height, + width: double.infinity, + child: FlowyNetworkImage( + url: cover.value, + userProfilePB: widget.userProfilePB, + ), + ); + } + + if (type == PageStyleCoverImageType.builtInImage) { + return SizedBox( + height: height, + width: double.infinity, + child: Image.asset( + PageStyleCoverImageType.builtInImagePath(cover.value), + fit: BoxFit.cover, + ), + ); + } + + if (type == PageStyleCoverImageType.pureColor) { + return Container( + height: height, + width: double.infinity, + color: cover.value.coverColor(context), + ); + } + + if (type == PageStyleCoverImageType.gradientColor) { + return Container( + height: height, + width: double.infinity, + decoration: BoxDecoration( + gradient: FlowyGradientColor.fromId(cover.value).linear, + ), + ); + } + + if (type == PageStyleCoverImageType.localImage) { + return SizedBox( + height: height, + width: double.infinity, + child: Image.file( + File(cover.value), + fit: BoxFit.cover, + ), + ); + } + + return SizedBox( + height: naviBarHeight, + width: double.infinity, + ); + } + + void _unfocus() { + final selection = selectionNotifier?.value; + if (selection != null) { + focusNode.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); + } + } + + void _rename(String name) { + scrollController.position.jumpTo(0); + context.read().add(ViewEvent.rename(name)); + } + + Future _createNewLine() async { + focusNode.unfocus(); + + final selection = textEditingController.selection; + final text = textEditingController.text; + // split the text into two lines based on the cursor position + final parts = [ + text.substring(0, selection.baseOffset), + text.substring(selection.baseOffset), + ]; + textEditingController.text = parts[0]; + + final editorState = context.read().state.editorState; + if (editorState == null) { + Log.info('editorState is null when creating new line'); + return; + } + + final transaction = editorState.transaction; + transaction.insertNode([0], paragraphNode(text: parts[1])); + await editorState.apply(transaction); + + // update selection instead of using afterSelection in transaction, + // because it will cause the cursor to jump + await editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [0])), + // trigger the keyboard service. + reason: SelectionUpdateReason.uiEvent, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart new file mode 100644 index 0000000000000..ee8952dc11f61 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; + +part 'document_immersive_cover_bloc.freezed.dart'; + +class DocumentImmersiveCoverBloc + extends Bloc { + DocumentImmersiveCoverBloc({ + required this.view, + }) : _viewListener = ViewListener(viewId: view.id), + super(DocumentImmersiveCoverState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + add( + DocumentImmersiveCoverEvent.updateCoverAndIcon( + view.cover, + EmojiIconData.fromViewIconPB(view.icon), + view.name, + ), + ); + _viewListener?.start( + onViewUpdated: (view) { + add( + DocumentImmersiveCoverEvent.updateCoverAndIcon( + view.cover, + EmojiIconData.fromViewIconPB(view.icon), + view.name, + ), + ); + }, + ); + }, + updateCoverAndIcon: (cover, icon, name) { + emit( + state.copyWith( + icon: icon, + cover: cover ?? state.cover, + name: name ?? state.name, + ), + ); + }, + ); + }, + ); + } + + final ViewPB view; + final ViewListener? _viewListener; + + @override + Future close() { + _viewListener?.stop(); + return super.close(); + } +} + +@freezed +class DocumentImmersiveCoverEvent with _$DocumentImmersiveCoverEvent { + const factory DocumentImmersiveCoverEvent.initial() = Initial; + + const factory DocumentImmersiveCoverEvent.updateCoverAndIcon( + PageStyleCover? cover, + EmojiIconData? icon, + String? name, + ) = UpdateCoverAndIcon; +} + +@freezed +class DocumentImmersiveCoverState with _$DocumentImmersiveCoverState { + const factory DocumentImmersiveCoverState({ + @Default(null) EmojiIconData? icon, + required PageStyleCover cover, + @Default('') String name, + }) = _DocumentImmersiveCoverState; + + factory DocumentImmersiveCoverState.initial() => DocumentImmersiveCoverState( + cover: PageStyleCover.none(), + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart new file mode 100644 index 0000000000000..cd150ff1c10cf --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart @@ -0,0 +1,100 @@ +import 'package:appflowy/plugins/database/widgets/database_view_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/built_in_page_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class DatabaseBlockKeys { + const DatabaseBlockKeys._(); + + static const String gridType = 'grid'; + static const String boardType = 'board'; + static const String calendarType = 'calendar'; + + static const String parentID = 'parent_id'; + static const String viewID = 'view_id'; +} + +class DatabaseViewBlockComponentBuilder extends BlockComponentBuilder { + DatabaseViewBlockComponentBuilder({ + super.configuration, + }); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return DatabaseBlockComponentWidget( + key: node.key, + node: node, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (node) => + node.children.isEmpty && + node.attributes[DatabaseBlockKeys.parentID] is String && + node.attributes[DatabaseBlockKeys.viewID] is String; +} + +class DatabaseBlockComponentWidget extends BlockComponentStatefulWidget { + const DatabaseBlockComponentWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => + _DatabaseBlockComponentWidgetState(); +} + +class _DatabaseBlockComponentWidgetState + extends State + with BlockComponentConfigurable { + @override + Node get node => widget.node; + + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Widget build(BuildContext context) { + final editorState = Provider.of(context, listen: false); + Widget child = BuiltInPageWidget( + node: widget.node, + editorState: editorState, + builder: (view) => DatabaseViewWidget(key: ValueKey(view.id), view: view), + ); + + child = Padding( + padding: padding, + child: FocusScope( + skipTraversal: true, + onFocusChange: (value) { + if (value && keepEditorFocusNotifier.value == 0) { + context.read().selection = null; + } + }, + child: child, + ), + ); + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: widget.node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + return child; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/inline_database_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/inline_database_menu_item.dart new file mode 100644 index 0000000000000..e31bd172c6c88 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/inline_database_menu_item.dart @@ -0,0 +1,72 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +SelectionMenuItem inlineGridMenuItem(DocumentBloc documentBloc) => + SelectionMenuItem( + getName: LocaleKeys.document_slashMenu_grid_createANewGrid.tr, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.grid_s, + isSelected: onSelected, + style: style, + ), + keywords: ['grid', 'database'], + handler: (editorState, menuService, context) async { + // create the view inside current page + final parentViewId = documentBloc.documentId; + final value = await ViewBackendService.createView( + parentViewId: parentViewId, + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layoutType: ViewLayoutPB.Grid, + ); + value.map((r) => editorState.insertInlinePage(parentViewId, r)); + }, + ); + +SelectionMenuItem inlineBoardMenuItem(DocumentBloc documentBloc) => + SelectionMenuItem( + getName: LocaleKeys.document_slashMenu_board_createANewBoard.tr, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.board_s, + isSelected: onSelected, + style: style, + ), + keywords: ['board', 'kanban', 'database'], + handler: (editorState, menuService, context) async { + // create the view inside current page + final parentViewId = documentBloc.documentId; + final value = await ViewBackendService.createView( + parentViewId: parentViewId, + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layoutType: ViewLayoutPB.Board, + ); + value.map((r) => editorState.insertInlinePage(parentViewId, r)); + }, + ); + +SelectionMenuItem inlineCalendarMenuItem(DocumentBloc documentBloc) => + SelectionMenuItem( + getName: LocaleKeys.document_slashMenu_calendar_createANewCalendar.tr, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.date_s, + isSelected: onSelected, + style: style, + ), + keywords: ['calendar', 'database'], + handler: (editorState, menuService, context) async { + // create the view inside current page + final parentViewId = documentBloc.documentId; + final value = await ViewBackendService.createView( + parentViewId: parentViewId, + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layoutType: ViewLayoutPB.Calendar, + ); + value.map((r) => editorState.insertInlinePage(parentViewId, r)); + }, + ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_item.dart new file mode 100644 index 0000000000000..8f6c117833e33 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_item.dart @@ -0,0 +1,71 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +// Document Reference + +SelectionMenuItem referencedDocumentMenuItem = SelectionMenuItem( + getName: LocaleKeys.document_plugins_referencedDocument.tr, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.icon_document_s, + isSelected: onSelected, + style: style, + ), + keywords: ['page', 'notes', 'referenced page', 'referenced document'], + handler: (editorState, menuService, context) => showLinkToPageMenu( + editorState, + menuService, + pageType: ViewLayoutPB.Document, + ), +); + +// Database References + +SelectionMenuItem referencedGridMenuItem = SelectionMenuItem( + getName: LocaleKeys.document_plugins_referencedGrid.tr, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.grid_s, + isSelected: onSelected, + style: style, + ), + keywords: ['referenced', 'grid', 'database'], + handler: (editorState, menuService, context) => showLinkToPageMenu( + editorState, + menuService, + pageType: ViewLayoutPB.Grid, + ), +); + +SelectionMenuItem referencedBoardMenuItem = SelectionMenuItem( + getName: LocaleKeys.document_plugins_referencedBoard.tr, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.board_s, + isSelected: onSelected, + style: style, + ), + keywords: ['referenced', 'board', 'kanban'], + handler: (editorState, menuService, context) => showLinkToPageMenu( + editorState, + menuService, + pageType: ViewLayoutPB.Board, + ), +); + +SelectionMenuItem referencedCalendarMenuItem = SelectionMenuItem( + getName: LocaleKeys.document_plugins_referencedCalendar.tr, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.date_s, + isSelected: onSelected, + style: style, + ), + keywords: ['referenced', 'calendar', 'database'], + handler: (editorState, menuService, context) => showLinkToPageMenu( + editorState, + menuService, + pageType: ViewLayoutPB.Calendar, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart new file mode 100644 index 0000000000000..5beba66c3256f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart @@ -0,0 +1,46 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +typedef MentionPageNameGetter = Future Function(String pageId); + +extension TextDeltaExtension on Delta { + /// Convert the delta to a text string. + /// + /// Unlike the [toPlainText], this method will keep the mention text + /// such as mentioned page name, mentioned block content. + /// + /// If the mentioned page or mentioned block not found, it will downgrade to + /// the default plain text. + Future toText({ + required MentionPageNameGetter getMentionPageName, + }) async { + final defaultPlainText = toPlainText(); + + String text = ''; + final ops = iterator; + while (ops.moveNext()) { + final op = ops.current; + final attributes = op.attributes; + if (op is TextInsert) { + // if the text is '\$', it means the block text is empty, + // the real data is in the attributes + if (op.text == MentionBlockKeys.mentionChar) { + final mention = attributes?[MentionBlockKeys.mention]; + final mentionPageId = mention?[MentionBlockKeys.pageId]; + if (mentionPageId != null) { + text += await getMentionPageName(mentionPageId); + continue; + } + } + + text += op.text; + } else { + // if the delta contains other types of operations, + // return the default plain text + return defaultPlainText; + } + } + + return text; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart new file mode 100644 index 0000000000000..a37ed29150e1d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/error/error_block_component_builder.dart @@ -0,0 +1,159 @@ +import 'dart:convert'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class ErrorBlockComponentBuilder extends BlockComponentBuilder { + ErrorBlockComponentBuilder({ + super.configuration, + }); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return ErrorBlockComponentWidget( + key: node.key, + node: node, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (_) => true; +} + +class ErrorBlockComponentWidget extends BlockComponentStatefulWidget { + const ErrorBlockComponentWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => + _ErrorBlockComponentWidgetState(); +} + +class _ErrorBlockComponentWidgetState extends State + with BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + @override + Widget build(BuildContext context) { + Widget child = Container( + width: double.infinity, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: UniversalPlatform.isDesktopOrWeb + ? _buildDesktopErrorBlock(context) + : _buildMobileErrorBlock(context), + ); + + child = Padding( + padding: padding, + child: child, + ); + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + if (UniversalPlatform.isMobile) { + child = MobileBlockActionButtons( + node: node, + editorState: context.read(), + child: child, + ); + } + + return child; + } + + Widget _buildDesktopErrorBlock(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + const HSpace(12), + FlowyText.regular( + LocaleKeys.document_errorBlock_parseError.tr(args: [node.type]), + ), + const Spacer(), + OutlinedRoundedButton( + text: LocaleKeys.document_errorBlock_copyBlockContent.tr(), + onTap: _copyBlockContent, + ), + const HSpace(12), + ], + ), + ); + } + + Widget _buildMobileErrorBlock(BuildContext context) { + return AnimatedGestureDetector( + onTapUp: _copyBlockContent, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4.0, right: 24.0), + child: FlowyText.regular( + LocaleKeys.document_errorBlock_parseError.tr(args: [node.type]), + maxLines: 3, + ), + ), + const VSpace(6), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: FlowyText.regular( + '(${LocaleKeys.document_errorBlock_clickToCopyTheBlockContent.tr()})', + color: Theme.of(context).hintColor, + fontSize: 12.0, + ), + ), + ], + ), + ), + ); + } + + void _copyBlockContent() { + showToastNotification( + context, + message: LocaleKeys.document_errorBlock_blockContentHasBeenCopied.tr(), + ); + + getIt().setData( + ClipboardServiceData(plainText: jsonEncode(node.toJson())), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/extensions/flowy_tint_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/extensions/flowy_tint_extension.dart new file mode 100644 index 0000000000000..65ade628f115b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/extensions/flowy_tint_extension.dart @@ -0,0 +1,32 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +extension FlowyTintExtension on FlowyTint { + String tintName( + AppFlowyEditorLocalizations l10n, { + ThemeMode? themeMode, + String? theme, + }) { + switch (this) { + case FlowyTint.tint1: + return l10n.lightLightTint1; + case FlowyTint.tint2: + return l10n.lightLightTint2; + case FlowyTint.tint3: + return l10n.lightLightTint3; + case FlowyTint.tint4: + return l10n.lightLightTint4; + case FlowyTint.tint5: + return l10n.lightLightTint5; + case FlowyTint.tint6: + return l10n.lightLightTint6; + case FlowyTint.tint7: + return l10n.lightLightTint7; + case FlowyTint.tint8: + return l10n.lightLightTint8; + case FlowyTint.tint9: + return l10n.lightLightTint9; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block.dart new file mode 100644 index 0000000000000..31ead6370c946 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block.dart @@ -0,0 +1,2 @@ +export './file_block_component.dart'; +export './file_selection_menu.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart new file mode 100644 index 0000000000000..52fc2e717fcf5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart @@ -0,0 +1,627 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'file_block_menu.dart'; +import 'file_upload_menu.dart'; + +class FileBlockKeys { + const FileBlockKeys._(); + + static const String type = 'file'; + + /// The src of the file. + /// + /// The value is a String. + /// It can be a url for a network file or a local file path. + /// + static const String url = 'url'; + + /// The name of the file. + /// + /// The value is a String. + /// + static const String name = 'name'; + + /// The type of the url. + /// + /// The value is a FileUrlType enum. + /// + static const String urlType = 'url_type'; + + /// The date of the file upload. + /// + /// The value is a timestamp in ms. + /// + static const String uploadedAt = 'uploaded_at'; + + /// The user who uploaded the file. + /// + /// The value is a String, in form of user id. + /// + static const String uploadedBy = 'uploaded_by'; + + /// The GlobalKey of the FileBlockComponentState. + /// + /// **Note: This value is used in extraInfos of the Node, not in the attributes.** + static const String globalKey = 'global_key'; +} + +enum FileUrlType { + local, + network, + cloud; + + static FileUrlType fromIntValue(int value) { + switch (value) { + case 0: + return FileUrlType.local; + case 1: + return FileUrlType.network; + case 2: + return FileUrlType.cloud; + default: + throw UnimplementedError(); + } + } + + int toIntValue() { + switch (this) { + case FileUrlType.local: + return 0; + case FileUrlType.network: + return 1; + case FileUrlType.cloud: + return 2; + } + } +} + +Node fileNode({ + required String url, + FileUrlType type = FileUrlType.local, + String? name, +}) { + return Node( + type: FileBlockKeys.type, + attributes: { + FileBlockKeys.url: url, + FileBlockKeys.urlType: type.toIntValue(), + FileBlockKeys.name: name, + FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, + }, + ); +} + +class FileBlockComponentBuilder extends BlockComponentBuilder { + FileBlockComponentBuilder({super.configuration}); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + final extraInfos = node.extraInfos; + final key = extraInfos?[FileBlockKeys.globalKey] as GlobalKey?; + + return FileBlockComponent( + key: key ?? node.key, + node: node, + showActions: showActions(node), + configuration: configuration, + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + ); + } + + @override + BlockComponentValidate get validate => (node) => node.children.isEmpty; +} + +class FileBlockComponent extends BlockComponentStatefulWidget { + const FileBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => FileBlockComponentState(); +} + +class FileBlockComponentState extends State + with SelectableMixin, BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; + + late EditorDropManagerState? dropManagerState = UniversalPlatform.isMobile + ? null + : context.read(); + + final fileKey = GlobalKey(); + final showActionsNotifier = ValueNotifier(false); + final controller = PopoverController(); + final menuController = PopoverController(); + + late final editorState = Provider.of(context, listen: false); + + bool alwaysShowMenu = false; + bool isDragging = false; + bool isHovering = false; + + @override + void didChangeDependencies() { + if (!UniversalPlatform.isMobile) { + dropManagerState = context.read(); + } + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + final url = node.attributes[FileBlockKeys.url]; + final FileUrlType urlType = + FileUrlType.fromIntValue(node.attributes[FileBlockKeys.urlType] ?? 0); + + Widget child = MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) { + setState(() => isHovering = true); + showActionsNotifier.value = true; + }, + onExit: (_) { + setState(() => isHovering = false); + if (!alwaysShowMenu) { + showActionsNotifier.value = false; + } + }, + opaque: false, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: url != null && url.isNotEmpty + ? () async => _openFile(context, urlType, url) + : _openMenu, + child: DecoratedBox( + decoration: BoxDecoration( + color: isHovering + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + border: isDragging + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, + ), + child: SizedBox( + height: 52, + child: Row( + children: [ + const HSpace(10), + FlowySvg( + FlowySvgs.slash_menu_icon_file_s, + color: Theme.of(context).hintColor, + size: const Size.square(24), + ), + const HSpace(10), + ..._buildTrailing(context), + ], + ), + ), + ), + ), + ); + + if (UniversalPlatform.isDesktopOrWeb) { + if (url == null || url.isEmpty) { + child = DropTarget( + onDragEntered: (_) { + if (dropManagerState?.isDropEnabled == true) { + setState(() => isDragging = true); + } + }, + onDragExited: (_) { + if (dropManagerState?.isDropEnabled == true) { + setState(() => isDragging = false); + } + }, + onDragDone: (details) { + if (dropManagerState?.isDropEnabled == true) { + insertFileFromLocal(details.files); + } + }, + child: AppFlowyPopover( + controller: controller, + direction: PopoverDirection.bottomWithCenterAligned, + constraints: const BoxConstraints( + maxWidth: 480, + maxHeight: 340, + minHeight: 80, + ), + clickHandler: PopoverClickHandler.gestureDetector, + onOpen: () => dropManagerState?.add(FileBlockKeys.type), + onClose: () => dropManagerState?.remove(FileBlockKeys.type), + popupBuilder: (_) => FileUploadMenu( + onInsertLocalFile: insertFileFromLocal, + onInsertNetworkFile: insertNetworkFile, + ), + child: child, + ), + ); + } + + child = BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + blockColor: editorState.editorStyle.selectionColor, + supportTypes: const [BlockSelectionType.block], + child: Padding( + key: fileKey, + padding: padding, + child: child, + ), + ); + } else { + return Padding( + key: fileKey, + padding: padding, + child: MobileBlockActionButtons( + node: widget.node, + editorState: editorState, + child: child, + ), + ); + } + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + if (!UniversalPlatform.isDesktopOrWeb) { + // show a fixed menu on mobile + child = MobileBlockActionButtons( + node: node, + editorState: editorState, + extendActionWidgets: _buildExtendActionWidgets(context), + child: child, + ); + } + + return child; + } + + Future _openFile( + BuildContext context, + FileUrlType urlType, + String url, + ) async { + await afLaunchUrlString(url, context: context); + } + + void _openMenu() { + if (UniversalPlatform.isDesktopOrWeb) { + controller.show(); + dropManagerState?.add(FileBlockKeys.type); + } else { + editorState.updateSelectionWithReason(null, extraInfo: {}); + showUploadFileMobileMenu(); + } + } + + List _buildTrailing(BuildContext context) { + if (node.attributes[FileBlockKeys.url]?.isNotEmpty == true) { + final name = node.attributes[FileBlockKeys.name] as String; + return [ + Expanded( + child: FlowyText( + name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(8), + if (UniversalPlatform.isDesktopOrWeb) ...[ + ValueListenableBuilder( + valueListenable: showActionsNotifier, + builder: (_, value, __) { + final url = node.attributes[FileBlockKeys.url]; + if (!value || url == null || url.isEmpty) { + return const SizedBox.shrink(); + } + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: menuController.show, + child: AppFlowyPopover( + controller: menuController, + triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.bottomWithRightAligned, + onClose: () { + setState( + () { + alwaysShowMenu = false; + showActionsNotifier.value = false; + }, + ); + }, + popupBuilder: (_) { + alwaysShowMenu = true; + return FileBlockMenu( + controller: menuController, + node: node, + editorState: editorState, + ); + }, + child: const FileMenuTrigger(), + ), + ); + }, + ), + const HSpace(8), + ], + if (UniversalPlatform.isMobile) ...[ + const HSpace(36), + ], + ]; + } else { + return [ + Flexible( + child: FlowyText( + isDragging + ? LocaleKeys.document_plugins_file_placeholderDragging.tr() + : LocaleKeys.document_plugins_file_placeholderText.tr(), + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, + ), + ), + ]; + } + } + + // only used on mobile platform + List _buildExtendActionWidgets(BuildContext context) { + final String? url = widget.node.attributes[FileBlockKeys.url]; + if (url == null || url.isEmpty) { + return []; + } + + final urlType = FileUrlType.fromIntValue( + widget.node.attributes[FileBlockKeys.urlType] ?? 0, + ); + + if (urlType != FileUrlType.network) { + return []; + } + + return [ + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.editor_copyLink.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_field_copy_s, + ), + onTap: () async { + context.pop(); + showSnackBarMessage( + context, + LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), + ); + await getIt().setPlainText(url); + }, + ), + ]; + } + + void showUploadFileMobileMenu() { + showMobileBottomSheet( + context, + title: LocaleKeys.document_plugins_file_name.tr(), + showHeader: true, + showCloseButton: true, + showDragHandle: true, + builder: (context) { + return Container( + margin: const EdgeInsets.only(top: 12.0), + constraints: const BoxConstraints( + maxHeight: 340, + minHeight: 80, + ), + child: FileUploadMenu( + onInsertLocalFile: (file) async { + context.pop(); + await insertFileFromLocal(file); + }, + onInsertNetworkFile: (url) async { + context.pop(); + await insertNetworkFile(url); + }, + ), + ); + }, + ); + } + + Future insertFileFromLocal(List files) async { + if (files.isEmpty) return; + + final file = files.first; + final path = file.path; + final documentBloc = context.read(); + final isLocalMode = documentBloc.isLocalMode; + final urlType = isLocalMode ? FileUrlType.local : FileUrlType.cloud; + + String? url; + String? errorMsg; + if (isLocalMode) { + url = await saveFileToLocalStorage(path); + } else { + final result = + await saveFileToCloudStorage(path, documentBloc.documentId); + url = result.$1; + errorMsg = result.$2; + } + + if (errorMsg != null && mounted) { + return showSnackBarMessage(context, errorMsg); + } + + // Remove the file block from the drop state manager + dropManagerState?.remove(FileBlockKeys.type); + + final transaction = editorState.transaction; + transaction.updateNode(widget.node, { + FileBlockKeys.url: url, + FileBlockKeys.urlType: urlType.toIntValue(), + FileBlockKeys.name: file.name, + FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, + }); + await editorState.apply(transaction); + } + + Future insertNetworkFile(String url) async { + if (url.isEmpty || !isURL(url)) { + // show error + return showSnackBarMessage( + context, + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + ); + } + + // Remove the file block from the drop state manager + dropManagerState?.remove(FileBlockKeys.type); + + final uri = Uri.tryParse(url); + if (uri == null) { + return; + } + + String name = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : ""; + if (name.isEmpty && uri.pathSegments.length > 1) { + name = uri.pathSegments[uri.pathSegments.length - 2]; + } else if (name.isEmpty) { + name = uri.host; + } + + final transaction = editorState.transaction; + transaction.updateNode(widget.node, { + FileBlockKeys.url: url, + FileBlockKeys.urlType: FileUrlType.network.toIntValue(), + FileBlockKeys.name: name, + FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, + }); + await editorState.apply(transaction); + } + + @override + Position start() => Position(path: widget.node.path); + + @override + Position end() => Position(path: widget.node.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + bool get shouldCursorBlink => false; + + @override + CursorStyle get cursorStyle => CursorStyle.cover; + + @override + Rect getBlockRect({bool shiftWithBaseOffset = false}) { + final renderBox = fileKey.currentContext?.findRenderObject(); + if (renderBox is RenderBox) { + return Offset.zero & renderBox.size; + } + return Rect.zero; + } + + @override + Rect? getCursorRectInPosition( + Position position, { + bool shiftWithBaseOffset = false, + }) { + final rects = getRectsInSelection(Selection.collapsed(position)); + return rects.firstOrNull; + } + + @override + List getRectsInSelection( + Selection selection, { + bool shiftWithBaseOffset = false, + }) { + if (_renderBox == null) { + return []; + } + final parentBox = context.findRenderObject(); + final renderBox = fileKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && renderBox is RenderBox) { + return [ + renderBox.localToGlobal(Offset.zero, ancestor: parentBox) & + renderBox.size, + ]; + } + return [Offset.zero & _renderBox!.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) => Selection.single( + path: widget.node.path, + startOffset: 0, + endOffset: 1, + ); + + @override + Offset localToGlobal( + Offset offset, { + bool shiftWithBaseOffset = false, + }) => + _renderBox!.localToGlobal(offset); +} + +@visibleForTesting +class FileMenuTrigger extends StatelessWidget { + const FileMenuTrigger({super.key}); + + @override + Widget build(BuildContext context) { + return const FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: EdgeInsets.all(4), + child: FlowySvg( + FlowySvgs.three_dots_s, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart new file mode 100644 index 0000000000000..d79d5a1994e88 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class FileBlockMenu extends StatefulWidget { + const FileBlockMenu({ + super.key, + required this.controller, + required this.node, + required this.editorState, + }); + + final PopoverController controller; + final Node node; + final EditorState editorState; + + @override + State createState() => _FileBlockMenuState(); +} + +class _FileBlockMenuState extends State { + final nameController = TextEditingController(); + final errorMessage = ValueNotifier(null); + BuildContext? renameContext; + + @override + void initState() { + super.initState(); + nameController.text = widget.node.attributes[FileBlockKeys.name] ?? ''; + nameController.selection = TextSelection( + baseOffset: 0, + extentOffset: nameController.text.length, + ); + } + + @override + void dispose() { + errorMessage.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final uploadedAtInMS = + widget.node.attributes[FileBlockKeys.uploadedAt] as int?; + final uploadedAt = uploadedAtInMS != null + ? DateTime.fromMillisecondsSinceEpoch(uploadedAtInMS) + : null; + final dateFormat = context.read().state.dateFormat; + final urlType = + FileUrlType.fromIntValue(widget.node.attributes[FileBlockKeys.urlType]); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + HoverButton( + itemHeight: 20, + leftIcon: const FlowySvg(FlowySvgs.edit_s), + name: LocaleKeys.document_plugins_file_renameFile_title.tr(), + onTap: () { + widget.controller.close(); + showCustomConfirmDialog( + context: context, + title: LocaleKeys.document_plugins_file_renameFile_title.tr(), + description: + LocaleKeys.document_plugins_file_renameFile_description.tr(), + closeOnConfirm: false, + builder: (context) { + renameContext = context; + return FileRenameTextField( + nameController: nameController, + errorMessage: errorMessage, + onSubmitted: _saveName, + ); + }, + confirmLabel: LocaleKeys.button_save.tr(), + onConfirm: _saveName, + ); + }, + ), + const VSpace(4), + HoverButton( + itemHeight: 20, + leftIcon: const FlowySvg(FlowySvgs.delete_s), + name: LocaleKeys.button_delete.tr(), + onTap: () { + final transaction = widget.editorState.transaction + ..deleteNode(widget.node); + widget.editorState.apply(transaction); + widget.controller.close(); + }, + ), + if (uploadedAt != null) ...[ + const Divider(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: FlowyText.regular( + [FileUrlType.cloud, FileUrlType.local].contains(urlType) + ? LocaleKeys.document_plugins_file_uploadedAt.tr( + args: [dateFormat.formatDate(uploadedAt, false)], + ) + : LocaleKeys.document_plugins_file_linkedAt.tr( + args: [dateFormat.formatDate(uploadedAt, false)], + ), + fontSize: 14, + maxLines: 2, + color: Theme.of(context).hintColor, + ), + ), + const VSpace(2), + ], + ], + ); + } + + void _saveName() { + if (nameController.text.isEmpty) { + errorMessage.value = + LocaleKeys.document_plugins_file_renameFile_nameEmptyError.tr(); + return; + } + + final attributes = widget.node.attributes; + attributes[FileBlockKeys.name] = nameController.text; + + final transaction = widget.editorState.transaction + ..updateNode(widget.node, attributes); + widget.editorState.apply(transaction); + + if (renameContext != null) { + Navigator.of(renameContext!).pop(); + } + } +} + +class FileRenameTextField extends StatefulWidget { + const FileRenameTextField({ + super.key, + required this.nameController, + required this.errorMessage, + required this.onSubmitted, + this.disposeController = true, + }); + + final TextEditingController nameController; + final ValueNotifier errorMessage; + final VoidCallback onSubmitted; + + final bool disposeController; + + @override + State createState() => _FileRenameTextFieldState(); +} + +class _FileRenameTextFieldState extends State { + @override + void initState() { + super.initState(); + widget.errorMessage.addListener(_setState); + } + + @override + void dispose() { + widget.errorMessage.removeListener(_setState); + if (widget.disposeController) { + widget.nameController.dispose(); + } + super.dispose(); + } + + void _setState() { + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyTextField( + controller: widget.nameController, + onSubmitted: (_) => widget.onSubmitted(), + ), + if (widget.errorMessage.value != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: FlowyText( + widget.errorMessage.value!, + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_selection_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_selection_menu.dart new file mode 100644 index 0000000000000..8a5d7047a9baa --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_selection_menu.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension InsertFile on EditorState { + Future insertEmptyFileBlock(GlobalKey key) async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final node = getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final file = fileNode(url: '')..extraInfos = {'global_key': key}; + final transaction = this.transaction; + + // if the current node is empty paragraph, replace it with the file node + if (node.type == ParagraphBlockKeys.type && + (node.delta?.isEmpty ?? false)) { + transaction + ..insertNode(node.path, file) + ..deleteNode(node); + } else { + transaction.insertNode(node.path.next, file); + } + + transaction.afterSelection = + Selection.collapsed(Position(path: node.path.next)); + transaction.selectionExtraInfo = {}; + + return apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart new file mode 100644 index 0000000000000..75f02f5079c4c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart @@ -0,0 +1,323 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:dotted_border/dotted_border.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class FileUploadMenu extends StatefulWidget { + const FileUploadMenu({ + super.key, + required this.onInsertLocalFile, + required this.onInsertNetworkFile, + this.allowMultipleFiles = false, + }); + + final void Function(List files) onInsertLocalFile; + final void Function(String url) onInsertNetworkFile; + final bool allowMultipleFiles; + + @override + State createState() => _FileUploadMenuState(); +} + +class _FileUploadMenuState extends State { + int currentTab = 0; + + @override + Widget build(BuildContext context) { + // ClipRRect is used to clip the tab indicator, so the animation doesn't overflow the dialog + return ClipRRect( + child: DefaultTabController( + length: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + TabBar( + onTap: (value) => setState(() => currentTab = value), + isScrollable: true, + indicatorWeight: 3, + tabAlignment: TabAlignment.start, + indicatorSize: TabBarIndicatorSize.label, + labelPadding: EdgeInsets.zero, + padding: EdgeInsets.zero, + overlayColor: WidgetStatePropertyAll( + UniversalPlatform.isDesktop + ? Theme.of(context).colorScheme.secondary + : Colors.transparent, + ), + tabs: [ + _Tab( + title: LocaleKeys.document_plugins_file_uploadTab.tr(), + isSelected: currentTab == 0, + ), + _Tab( + title: LocaleKeys.document_plugins_file_networkTab.tr(), + isSelected: currentTab == 1, + ), + ], + ), + const Divider(height: 0), + if (currentTab == 0) ...[ + _FileUploadLocal( + allowMultipleFiles: widget.allowMultipleFiles, + onFilesPicked: (files) { + if (files.isNotEmpty) { + widget.onInsertLocalFile(files); + } + }, + ), + ] else ...[ + _FileUploadNetwork(onSubmit: widget.onInsertNetworkFile), + ], + ], + ), + ), + ); + } +} + +class _Tab extends StatelessWidget { + const _Tab({required this.title, this.isSelected = false}); + + final String title; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 12.0, + right: 12.0, + bottom: 8.0, + top: UniversalPlatform.isMobile ? 0 : 8.0, + ), + child: FlowyText.semibold( + title, + color: isSelected + ? AFThemeExtension.of(context).strongText + : Theme.of(context).hintColor, + ), + ); + } +} + +class _FileUploadLocal extends StatefulWidget { + const _FileUploadLocal({ + required this.onFilesPicked, + this.allowMultipleFiles = false, + }); + + final void Function(List) onFilesPicked; + final bool allowMultipleFiles; + + @override + State<_FileUploadLocal> createState() => _FileUploadLocalState(); +} + +class _FileUploadLocalState extends State<_FileUploadLocal> { + bool isDragging = false; + + @override + Widget build(BuildContext context) { + final constraints = + UniversalPlatform.isMobile ? const BoxConstraints(minHeight: 92) : null; + + if (UniversalPlatform.isMobile) { + return Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + height: 32, + child: FlowyButton( + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.9), + showDefaultBoxDecorationOnMobile: true, + margin: const EdgeInsets.all(5), + text: FlowyText( + LocaleKeys.document_plugins_file_uploadMobile.tr(), + textAlign: TextAlign.center, + color: Theme.of(context).colorScheme.onPrimary, + ), + onTap: () => _uploadFile(context), + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(16), + child: DropTarget( + onDragEntered: (_) => setState(() => isDragging = true), + onDragExited: (_) => setState(() => isDragging = false), + onDragDone: (details) => widget.onFilesPicked(details.files), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => _uploadFile(context), + child: FlowyHover( + resetHoverOnRebuild: false, + isSelected: () => isDragging, + style: HoverStyle( + borderRadius: BorderRadius.circular(10), + hoverColor: + isDragging ? AFThemeExtension.of(context).tint9 : null, + ), + child: Container( + height: 172, + constraints: constraints, + child: DottedBorder( + dashPattern: const [3, 3], + radius: const Radius.circular(8), + borderType: BorderType.RRect, + color: isDragging + ? Theme.of(context).colorScheme.primary + : Theme.of(context).hintColor, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isDragging) ...[ + FlowyText( + LocaleKeys.document_plugins_file_dropFileToUpload + .tr(), + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context).hintColor, + ), + ] else ...[ + RichText( + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys + .document_plugins_file_fileUploadHint + .tr(), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context).hintColor, + ), + ), + TextSpan( + text: LocaleKeys + .document_plugins_file_fileUploadHintSuffix + .tr(), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: + Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Future _uploadFile(BuildContext context) async { + final result = await getIt().pickFiles( + dialogTitle: '', + allowMultiple: widget.allowMultipleFiles, + ); + + final List files = result?.files.isNotEmpty ?? false + ? result!.files.map((f) => f.xFile).toList() + : const []; + + widget.onFilesPicked(files); + } +} + +class _FileUploadNetwork extends StatefulWidget { + const _FileUploadNetwork({required this.onSubmit}); + + final void Function(String url) onSubmit; + + @override + State<_FileUploadNetwork> createState() => _FileUploadNetworkState(); +} + +class _FileUploadNetworkState extends State<_FileUploadNetwork> { + bool isUrlValid = true; + String inputText = ''; + + @override + Widget build(BuildContext context) { + final constraints = + UniversalPlatform.isMobile ? const BoxConstraints(minHeight: 92) : null; + + return Container( + padding: const EdgeInsets.all(16), + constraints: constraints, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyTextField( + hintText: LocaleKeys.document_plugins_file_networkHint.tr(), + onChanged: (value) => inputText = value, + onEditingComplete: submit, + ), + if (!isUrlValid) ...[ + const VSpace(4), + FlowyText( + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + color: Theme.of(context).colorScheme.error, + maxLines: 3, + textAlign: TextAlign.start, + ), + ], + const VSpace(16), + SizedBox( + height: 32, + child: FlowyButton( + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: + Theme.of(context).colorScheme.primary.withOpacity(0.9), + showDefaultBoxDecorationOnMobile: true, + margin: const EdgeInsets.all(5), + text: FlowyText( + LocaleKeys.document_plugins_file_networkAction.tr(), + textAlign: TextAlign.center, + color: Theme.of(context).colorScheme.onPrimary, + ), + onTap: submit, + ), + ), + ], + ), + ); + } + + void submit() { + if (checkUrlValidity(inputText)) { + return widget.onSubmit(inputText); + } + + setState(() => isUrlValid = false); + } + + bool checkUrlValidity(String url) => hrefRegex.hasMatch(url); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart new file mode 100644 index 0000000000000..3ab93b4c95f9e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart @@ -0,0 +1,265 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_service.dart'; +import 'package:appflowy/shared/custom_image_cache_manager.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/xfile_ext.dart'; +import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/dispatch/error.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_impl.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:universal_platform/universal_platform.dart'; + +Future saveFileToLocalStorage(String localFilePath) async { + final path = await getIt().getPath(); + final filePath = p.join(path, 'files'); + + try { + // create the directory if not exists + final directory = Directory(filePath); + if (!directory.existsSync()) { + await directory.create(recursive: true); + } + final copyToPath = p.join( + filePath, + '${uuid()}${p.extension(localFilePath)}', + ); + await File(localFilePath).copy( + copyToPath, + ); + return copyToPath; + } catch (e) { + Log.error('cannot save file', e); + return null; + } +} + +Future<(String? path, String? errorMessage)> saveFileToCloudStorage( + String localFilePath, + String documentId, [ + bool isImage = false, +]) async { + final documentService = DocumentService(); + Log.debug("Uploading file from local path: $localFilePath"); + final result = await documentService.uploadFile( + localFilePath: localFilePath, + documentId: documentId, + ); + + return result.fold( + (s) async { + if (isImage) { + await CustomImageCacheManager().putFile( + s.url, + File(localFilePath).readAsBytesSync(), + ); + } + + return (s.url, null); + }, + (err) { + final message = Platform.isIOS + ? LocaleKeys.sideBar_storageLimitDialogTitleIOS.tr() + : LocaleKeys.sideBar_storageLimitDialogTitle.tr(); + if (err.isStorageLimitExceeded) { + return (null, message); + } + return (null, err.msg); + }, + ); +} + +/// Downloads a MediaFilePB +/// +/// On Mobile the file is fetched first using HTTP, and then saved using FilePicker. +/// On Desktop the files location is picked first using FilePicker, and then the file is saved. +/// +Future downloadMediaFile( + BuildContext context, + MediaFilePB file, { + VoidCallback? onDownloadBegin, + VoidCallback? onDownloadEnd, + UserProfilePB? userProfile, +}) async { + if ([ + FileUploadTypePB.NetworkFile, + FileUploadTypePB.LocalFile, + ].contains(file.uploadType)) { + /// When the file is a network file or a local file, we can directly open the file. + await afLaunchUrlString(file.url); + } else { + if (userProfile == null) { + return showToastNotification( + context, + message: LocaleKeys.grid_media_downloadFailedToken.tr(), + ); + } + + final uri = Uri.parse(file.url); + final token = jsonDecode(userProfile.token)['access_token']; + + if (UniversalPlatform.isMobile) { + onDownloadBegin?.call(); + + final response = + await http.get(uri, headers: {'Authorization': 'Bearer $token'}); + + if (response.statusCode == 200) { + final tempFile = File(uri.pathSegments.last); + final result = await FilePicker().saveFile( + fileName: p.basename(tempFile.path), + bytes: response.bodyBytes, + ); + + if (result != null && context.mounted) { + showToastNotification( + context, + type: ToastificationType.error, + message: LocaleKeys.grid_media_downloadSuccess.tr(), + ); + } + } else if (context.mounted) { + showToastNotification( + context, + type: ToastificationType.error, + message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), + ); + } + + onDownloadEnd?.call(); + } else { + final savePath = await FilePicker().saveFile(fileName: file.name); + if (savePath == null) { + return; + } + + onDownloadBegin?.call(); + + final response = + await http.get(uri, headers: {'Authorization': 'Bearer $token'}); + + if (response.statusCode == 200) { + final imgFile = File(savePath); + await imgFile.writeAsBytes(response.bodyBytes); + + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys.grid_media_downloadSuccess.tr(), + ); + } + } else if (context.mounted) { + showToastNotification( + context, + type: ToastificationType.error, + message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), + ); + } + + onDownloadEnd?.call(); + } + } +} + +Future insertLocalFile( + BuildContext context, + XFile file, { + required String documentId, + UserProfilePB? userProfile, + void Function(String, bool)? onUploadSuccess, +}) async { + if (file.path.isEmpty) return; + + final fileType = file.fileType.toMediaFileTypePB(); + + // Check upload type + final isLocalMode = (userProfile?.authenticator ?? AuthenticatorPB.Local) == + AuthenticatorPB.Local; + + String? path; + String? errorMsg; + if (isLocalMode) { + path = await saveFileToLocalStorage(file.path); + } else { + (path, errorMsg) = await saveFileToCloudStorage( + file.path, + documentId, + fileType == MediaFileTypePB.Image, + ); + } + + if (errorMsg != null) { + return showSnackBarMessage(context, errorMsg); + } + + if (path == null) { + return; + } + + onUploadSuccess?.call(path, isLocalMode); +} + +/// [onUploadSuccess] Callback to be called when the upload is successful. +/// +/// The callback is called for each file that is successfully uploaded. +/// In case of an error, the error message will be shown on a per-file basis. +/// +Future insertLocalFiles( + BuildContext context, + List files, { + required String documentId, + UserProfilePB? userProfile, + void Function( + XFile file, + String path, + bool isLocalMode, + )? onUploadSuccess, +}) async { + if (files.every((f) => f.path.isEmpty)) return; + + // Check upload type + final isLocalMode = (userProfile?.authenticator ?? AuthenticatorPB.Local) == + AuthenticatorPB.Local; + + for (final file in files) { + final fileType = file.fileType.toMediaFileTypePB(); + + String? path; + String? errorMsg; + + if (isLocalMode) { + path = await saveFileToLocalStorage(file.path); + } else { + (path, errorMsg) = await saveFileToCloudStorage( + file.path, + documentId, + fileType == MediaFileTypePB.Image, + ); + } + + if (errorMsg != null) { + showSnackBarMessage(context, errorMsg); + continue; + } + + if (path == null) { + continue; + } + onUploadSuccess?.call(file, path, isLocalMode); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/mobile_file_upload_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/mobile_file_upload_menu.dart new file mode 100644 index 0000000000000..f716c107df636 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/mobile_file_upload_menu.dart @@ -0,0 +1,269 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/shared/permission/permission_checker.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class MobileFileUploadMenu extends StatefulWidget { + const MobileFileUploadMenu({ + super.key, + required this.onInsertLocalFile, + required this.onInsertNetworkFile, + this.allowMultipleFiles = false, + }); + + final void Function(List files) onInsertLocalFile; + final void Function(String url) onInsertNetworkFile; + final bool allowMultipleFiles; + + @override + State createState() => _MobileFileUploadMenuState(); +} + +class _MobileFileUploadMenuState extends State { + int currentTab = 0; + + @override + Widget build(BuildContext context) { + // ClipRRect is used to clip the tab indicator, so the animation doesn't overflow the dialog + return ClipRRect( + child: DefaultTabController( + length: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + TabBar( + onTap: (value) => setState(() => currentTab = value), + indicatorWeight: 3, + labelPadding: EdgeInsets.zero, + padding: EdgeInsets.zero, + overlayColor: WidgetStatePropertyAll( + UniversalPlatform.isDesktop + ? Theme.of(context).colorScheme.secondary + : Colors.transparent, + ), + tabs: [ + _Tab( + title: LocaleKeys.document_plugins_file_uploadTab.tr(), + isSelected: currentTab == 0, + ), + _Tab( + title: LocaleKeys.document_plugins_file_networkTab.tr(), + isSelected: currentTab == 1, + ), + ], + ), + const Divider(height: 0), + if (currentTab == 0) ...[ + _FileUploadLocal( + allowMultipleFiles: widget.allowMultipleFiles, + onFilesPicked: (files) { + if (files.isNotEmpty) { + widget.onInsertLocalFile(files); + } + }, + ), + ] else ...[ + _FileUploadNetwork(onSubmit: widget.onInsertNetworkFile), + ], + ], + ), + ), + ); + } +} + +class _Tab extends StatelessWidget { + const _Tab({required this.title, this.isSelected = false}); + + final String title; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 12.0, + right: 12.0, + bottom: 8.0, + top: UniversalPlatform.isMobile ? 0 : 8.0, + ), + child: FlowyText.semibold( + title, + color: isSelected + ? AFThemeExtension.of(context).strongText + : Theme.of(context).hintColor, + ), + ); + } +} + +class _FileUploadLocal extends StatefulWidget { + const _FileUploadLocal({ + required this.onFilesPicked, + this.allowMultipleFiles = false, + }); + + final void Function(List) onFilesPicked; + final bool allowMultipleFiles; + + @override + State<_FileUploadLocal> createState() => _FileUploadLocalState(); +} + +class _FileUploadLocalState extends State<_FileUploadLocal> { + bool isDragging = false; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + SizedBox( + height: 36, + child: FlowyButton( + radius: Corners.s8Border, + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: + Theme.of(context).colorScheme.primary.withOpacity(0.9), + margin: const EdgeInsets.all(5), + text: FlowyText( + LocaleKeys.document_plugins_file_uploadMobileGallery.tr(), + textAlign: TextAlign.center, + color: Theme.of(context).colorScheme.onPrimary, + ), + onTap: () => _uploadFileFromGallery(context), + ), + ), + const VSpace(16), + SizedBox( + height: 36, + child: FlowyButton( + radius: Corners.s8Border, + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: + Theme.of(context).colorScheme.primary.withOpacity(0.9), + margin: const EdgeInsets.all(5), + text: FlowyText( + LocaleKeys.document_plugins_file_uploadMobile.tr(), + textAlign: TextAlign.center, + color: Theme.of(context).colorScheme.onPrimary, + ), + onTap: () => _uploadFile(context), + ), + ), + ], + ), + ); + } + + Future _uploadFileFromGallery(BuildContext context) async { + final photoPermission = + await PermissionChecker.checkPhotoPermission(context); + if (!photoPermission) { + Log.error('Has no permission to access the photo library'); + return; + } + // on mobile, the users can pick a image file from camera or image library + final files = await ImagePicker().pickMultiImage(); + + widget.onFilesPicked(files); + } + + Future _uploadFile(BuildContext context) async { + final result = await getIt().pickFiles( + dialogTitle: '', + allowMultiple: widget.allowMultipleFiles, + ); + + final List files = result?.files.isNotEmpty ?? false + ? result!.files.map((f) => f.xFile).toList() + : const []; + + widget.onFilesPicked(files); + } +} + +class _FileUploadNetwork extends StatefulWidget { + const _FileUploadNetwork({required this.onSubmit}); + + final void Function(String url) onSubmit; + + @override + State<_FileUploadNetwork> createState() => _FileUploadNetworkState(); +} + +class _FileUploadNetworkState extends State<_FileUploadNetwork> { + bool isUrlValid = true; + String inputText = ''; + + @override + Widget build(BuildContext context) { + final constraints = + UniversalPlatform.isMobile ? const BoxConstraints(minHeight: 92) : null; + + return Container( + padding: const EdgeInsets.all(16), + constraints: constraints, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyTextField( + hintText: LocaleKeys.document_plugins_file_networkHint.tr(), + onChanged: (value) => inputText = value, + onEditingComplete: submit, + ), + if (!isUrlValid) ...[ + const VSpace(4), + FlowyText( + LocaleKeys.document_plugins_file_networkUrlInvalid.tr(), + color: Theme.of(context).colorScheme.error, + maxLines: 3, + textAlign: TextAlign.start, + ), + ], + const VSpace(16), + SizedBox( + height: 36, + child: FlowyButton( + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: + Theme.of(context).colorScheme.primary.withOpacity(0.9), + radius: Corners.s8Border, + margin: const EdgeInsets.all(5), + text: FlowyText( + LocaleKeys.grid_media_embedLink.tr(), + textAlign: TextAlign.center, + color: Theme.of(context).colorScheme.onPrimary, + ), + onTap: submit, + ), + ), + ], + ), + ); + } + + void submit() { + if (checkUrlValidity(inputText)) { + return widget.onSubmit(inputText); + } + + setState(() => isUrlValid = false); + } + + bool checkUrlValidity(String url) => hrefRegex.hasMatch(url); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart new file mode 100644 index 0000000000000..2d65c602f5c54 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/find_and_replace/find_and_replace_menu.dart @@ -0,0 +1,371 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class FindAndReplaceMenuWidget extends StatefulWidget { + const FindAndReplaceMenuWidget({ + super.key, + required this.onDismiss, + required this.editorState, + required this.showReplaceMenu, + }); + + final EditorState editorState; + final VoidCallback onDismiss; + + /// Whether to show the replace menu initially + final bool showReplaceMenu; + + @override + State createState() => + _FindAndReplaceMenuWidgetState(); +} + +class _FindAndReplaceMenuWidgetState extends State { + late bool showReplaceMenu = widget.showReplaceMenu; + + final findFocusNode = FocusNode(); + final replaceFocusNode = FocusNode(); + + late SearchServiceV3 searchService = SearchServiceV3( + editorState: widget.editorState, + ); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.showReplaceMenu) { + replaceFocusNode.requestFocus(); + } else { + findFocusNode.requestFocus(); + } + }); + } + + @override + void dispose() { + findFocusNode.dispose(); + replaceFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + }, + child: Actions( + actions: { + DismissIntent: CallbackAction( + onInvoke: (t) => widget.onDismiss.call(), + ), + }, + child: TextFieldTapRegion( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: FindMenu( + onDismiss: widget.onDismiss, + editorState: widget.editorState, + searchService: searchService, + focusNode: findFocusNode, + showReplaceMenu: showReplaceMenu, + onToggleShowReplace: () => setState(() { + showReplaceMenu = !showReplaceMenu; + }), + ), + ), + if (showReplaceMenu) + Padding( + padding: const EdgeInsets.only( + bottom: 8.0, + ), + child: ReplaceMenu( + editorState: widget.editorState, + searchService: searchService, + focusNode: replaceFocusNode, + ), + ), + ], + ), + ), + ), + ); + } +} + +class FindMenu extends StatefulWidget { + const FindMenu({ + super.key, + required this.editorState, + required this.searchService, + required this.showReplaceMenu, + required this.focusNode, + required this.onDismiss, + required this.onToggleShowReplace, + }); + + final EditorState editorState; + final SearchServiceV3 searchService; + + final bool showReplaceMenu; + final FocusNode focusNode; + + final VoidCallback onDismiss; + final void Function() onToggleShowReplace; + + @override + State createState() => _FindMenuState(); +} + +class _FindMenuState extends State { + final textController = TextEditingController(); + + bool caseSensitive = false; + + @override + void initState() { + super.initState(); + + widget.searchService.matchWrappers.addListener(_setState); + widget.searchService.currentSelectedIndex.addListener(_setState); + + textController.addListener(_searchPattern); + } + + @override + void dispose() { + widget.searchService.matchWrappers.removeListener(_setState); + widget.searchService.currentSelectedIndex.removeListener(_setState); + widget.searchService.dispose(); + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // the selectedIndex from searchService is 0-based + final selectedIndex = widget.searchService.selectedIndex + 1; + final matches = widget.searchService.matchWrappers.value; + return Row( + children: [ + const HSpace(4.0), + // expand/collapse button + _FindAndReplaceIcon( + icon: widget.showReplaceMenu + ? FlowySvgs.drop_menu_show_s + : FlowySvgs.drop_menu_hide_s, + tooltipText: '', + onPressed: widget.onToggleShowReplace, + ), + const HSpace(4.0), + // find text input + SizedBox( + width: 200, + height: 30, + child: TextField( + key: const Key('findTextField'), + focusNode: widget.focusNode, + controller: textController, + style: Theme.of(context).textTheme.bodyMedium, + onSubmitted: (_) { + widget.searchService.navigateToMatch(); + + // after update selection or navigate to match, the editor + // will request focus, here's a workaround to request the + // focus back to the text field + Future.delayed( + const Duration(milliseconds: 50), + () => widget.focusNode.requestFocus(), + ); + }, + decoration: _buildInputDecoration( + LocaleKeys.findAndReplace_find.tr(), + ), + ), + ), + // the count of matches + Container( + constraints: const BoxConstraints(minWidth: 80), + padding: const EdgeInsets.symmetric(horizontal: 8.0), + alignment: Alignment.centerLeft, + child: FlowyText( + matches.isEmpty + ? LocaleKeys.findAndReplace_noResult.tr() + : '$selectedIndex of ${matches.length}', + ), + ), + const HSpace(4.0), + // case sensitive button + _FindAndReplaceIcon( + icon: FlowySvgs.text_s, + tooltipText: LocaleKeys.findAndReplace_caseSensitive.tr(), + onPressed: () => setState(() { + caseSensitive = !caseSensitive; + widget.searchService.caseSensitive = caseSensitive; + }), + isSelected: caseSensitive, + ), + const HSpace(4.0), + // previous match button + _FindAndReplaceIcon( + onPressed: () => widget.searchService.navigateToMatch(moveUp: true), + icon: FlowySvgs.arrow_up_s, + tooltipText: LocaleKeys.findAndReplace_previousMatch.tr(), + ), + const HSpace(4.0), + // next match button + _FindAndReplaceIcon( + onPressed: () => widget.searchService.navigateToMatch(), + icon: FlowySvgs.arrow_down_s, + tooltipText: LocaleKeys.findAndReplace_nextMatch.tr(), + ), + const HSpace(4.0), + _FindAndReplaceIcon( + onPressed: widget.onDismiss, + icon: FlowySvgs.close_s, + tooltipText: LocaleKeys.findAndReplace_close.tr(), + ), + const HSpace(4.0), + ], + ); + } + + void _searchPattern() { + widget.searchService.findAndHighlight(textController.text); + _setState(); + } + + void _setState() { + setState(() {}); + } +} + +class ReplaceMenu extends StatefulWidget { + const ReplaceMenu({ + super.key, + required this.editorState, + required this.searchService, + required this.focusNode, + }); + + final EditorState editorState; + final SearchServiceV3 searchService; + + final FocusNode focusNode; + + @override + State createState() => _ReplaceMenuState(); +} + +class _ReplaceMenuState extends State { + final textController = TextEditingController(); + + @override + void dispose() { + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + // placeholder for aligning the replace menu + const HSpace(30), + SizedBox( + width: 200, + height: 30, + child: TextField( + key: const Key('replaceTextField'), + focusNode: widget.focusNode, + controller: textController, + style: Theme.of(context).textTheme.bodyMedium, + onSubmitted: (_) { + _replaceSelectedWord(); + + Future.delayed( + const Duration(milliseconds: 50), + () => widget.focusNode.requestFocus(), + ); + }, + decoration: _buildInputDecoration( + LocaleKeys.findAndReplace_replace.tr(), + ), + ), + ), + _FindAndReplaceIcon( + onPressed: _replaceSelectedWord, + iconBuilder: (_) => const Icon( + Icons.find_replace_outlined, + size: 16, + ), + tooltipText: LocaleKeys.findAndReplace_replace.tr(), + ), + const HSpace(4.0), + _FindAndReplaceIcon( + iconBuilder: (_) => const Icon( + Icons.change_circle_outlined, + size: 16, + ), + tooltipText: LocaleKeys.findAndReplace_replaceAll.tr(), + onPressed: () => widget.searchService.replaceAllMatches( + textController.text, + ), + ), + ], + ); + } + + void _replaceSelectedWord() { + widget.searchService.replaceSelectedWord(textController.text); + } +} + +class _FindAndReplaceIcon extends StatelessWidget { + const _FindAndReplaceIcon({ + required this.onPressed, + required this.tooltipText, + this.icon, + this.iconBuilder, + this.isSelected, + }); + + final VoidCallback onPressed; + final FlowySvgData? icon; + final WidgetBuilder? iconBuilder; + final String tooltipText; + final bool? isSelected; + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + width: 24, + height: 24, + onPressed: onPressed, + icon: iconBuilder?.call(context) ?? + (icon != null + ? FlowySvg(icon!, color: Theme.of(context).iconTheme.color) + : const Placeholder()), + tooltipText: tooltipText, + isSelected: isSelected, + iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + ); + } +} + +InputDecoration _buildInputDecoration(String hintText) { + return InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + border: const UnderlineInputBorder(), + hintText: hintText, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart new file mode 100644 index 0000000000000..ef943a7ef7b92 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart @@ -0,0 +1,267 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/shared/google_fonts_extension.dart'; +import 'package:appflowy/util/font_family_extension.dart'; +import 'package:appflowy/util/levenshtein.dart'; +import 'package:appflowy/workspace/application/appearance_defaults.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/setting_value_dropdown.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; + +const kFontToolbarItemId = 'editor.font'; + +@visibleForTesting +const kFontFamilyToolbarItemKey = ValueKey('FontFamilyToolbarItem'); + +final customizeFontToolbarItem = ToolbarItem( + id: kFontToolbarItemId, + group: 4, + isActive: onlyShowInTextType, + builder: (context, editorState, highlightColor, _, tooltipBuilder) { + final selection = editorState.selection!; + final popoverController = PopoverController(); + final String? currentFontFamily = editorState + .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.fontFamily); + + Widget child = FontFamilyDropDown( + currentFontFamily: currentFontFamily ?? '', + offset: const Offset(0, 12), + popoverController: popoverController, + onOpen: () => keepEditorFocusNotifier.increase(), + onClose: () => keepEditorFocusNotifier.decrease(), + onFontFamilyChanged: (fontFamily) async { + popoverController.close(); + try { + await editorState.formatDelta(selection, { + AppFlowyRichTextKeys.fontFamily: fontFamily, + }); + } catch (e) { + Log.error('Failed to set font family: $e'); + } + }, + onResetFont: () async { + popoverController.close(); + await editorState + .formatDelta(selection, {AppFlowyRichTextKeys.fontFamily: null}); + }, + child: FlowyButton( + key: kFontFamilyToolbarItemKey, + useIntrinsicWidth: true, + hoverColor: Colors.grey.withOpacity(0.3), + onTap: () => popoverController.show(), + text: const FlowySvg( + FlowySvgs.font_family_s, + size: Size.square(16.0), + color: Colors.white, + ), + ), + ); + + if (tooltipBuilder != null) { + child = tooltipBuilder( + context, + kFontToolbarItemId, + LocaleKeys.document_plugins_fonts.tr(), + child, + ); + } + + return child; + }, +); + +class ThemeFontFamilySetting extends StatefulWidget { + const ThemeFontFamilySetting({ + super.key, + required this.currentFontFamily, + }); + + final String currentFontFamily; + static Key textFieldKey = const Key('FontFamilyTextField'); + static Key resetButtonKey = const Key('FontFamilyResetButton'); + static Key popoverKey = const Key('FontFamilyPopover'); + + @override + State createState() => _ThemeFontFamilySettingState(); +} + +class _ThemeFontFamilySettingState extends State { + @override + Widget build(BuildContext context) { + return SettingListTile( + label: LocaleKeys.settings_appearance_fontFamily_label.tr(), + resetButtonKey: ThemeFontFamilySetting.resetButtonKey, + onResetRequested: () { + context.read().resetFontFamily(); + context + .read() + .syncFontFamily(DefaultAppearanceSettings.kDefaultFontFamily); + }, + trailing: [ + FontFamilyDropDown(currentFontFamily: widget.currentFontFamily), + ], + ); + } +} + +class FontFamilyDropDown extends StatefulWidget { + const FontFamilyDropDown({ + super.key, + required this.currentFontFamily, + this.onOpen, + this.onClose, + this.onFontFamilyChanged, + this.child, + this.popoverController, + this.offset, + this.onResetFont, + }); + + final String currentFontFamily; + final VoidCallback? onOpen; + final VoidCallback? onClose; + final void Function(String fontFamily)? onFontFamilyChanged; + final Widget? child; + final PopoverController? popoverController; + final Offset? offset; + final VoidCallback? onResetFont; + + @override + State createState() => _FontFamilyDropDownState(); +} + +class _FontFamilyDropDownState extends State { + final List availableFonts = [ + defaultFontFamily, + ...GoogleFonts.asMap().keys, + ]; + final ValueNotifier query = ValueNotifier(''); + + @override + void dispose() { + query.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final currentValue = widget.currentFontFamily.fontFamilyDisplayName; + return SettingValueDropDown( + popoverKey: ThemeFontFamilySetting.popoverKey, + popoverController: widget.popoverController, + currentValue: currentValue, + onClose: () { + query.value = ''; + widget.onClose?.call(); + }, + offset: widget.offset, + child: widget.child, + popupBuilder: (_) { + widget.onOpen?.call(); + return CustomScrollView( + shrinkWrap: true, + slivers: [ + SliverPadding( + padding: const EdgeInsets.only(right: 8), + sliver: SliverToBoxAdapter( + child: FlowyTextField( + key: ThemeFontFamilySetting.textFieldKey, + hintText: + LocaleKeys.settings_appearance_fontFamily_search.tr(), + autoFocus: false, + debounceDuration: const Duration(milliseconds: 300), + onChanged: (value) { + query.value = value; + }, + ), + ), + ), + const SliverToBoxAdapter( + child: SizedBox(height: 4), + ), + ValueListenableBuilder( + valueListenable: query, + builder: (context, value, child) { + var displayed = availableFonts; + if (value.isNotEmpty) { + displayed = availableFonts + .where( + (font) => font + .toLowerCase() + .contains(value.toLowerCase().toString()), + ) + .sorted((a, b) => levenshtein(a, b)) + .toList(); + } + return SliverFixedExtentList.builder( + itemBuilder: (context, index) => _fontFamilyItemButton( + context, + getGoogleFontSafely(displayed[index]), + ), + itemCount: displayed.length, + itemExtent: 32, + ); + }, + ), + ], + ); + }, + ); + } + + Widget _fontFamilyItemButton( + BuildContext context, + TextStyle style, + ) { + final buttonFontFamily = + style.fontFamily?.parseFontFamilyName() ?? defaultFontFamily; + return Tooltip( + message: buttonFontFamily, + waitDuration: const Duration(milliseconds: 150), + child: SizedBox( + key: ValueKey(buttonFontFamily), + height: 32, + child: FlowyButton( + onHover: (_) => FocusScope.of(context).unfocus(), + text: FlowyText.medium( + buttonFontFamily.fontFamilyDisplayName, + fontFamily: buttonFontFamily, + ), + rightIcon: + buttonFontFamily == widget.currentFontFamily.parseFontFamilyName() + ? const FlowySvg(FlowySvgs.check_s) + : null, + onTap: () { + if (widget.onFontFamilyChanged != null) { + widget.onFontFamilyChanged!(buttonFontFamily); + } else { + if (widget.currentFontFamily.parseFontFamilyName() != + buttonFontFamily) { + context + .read() + .setFontFamily(buttonFontFamily); + context + .read() + .syncFontFamily(buttonFontFamily); + } + } + PopoverContainer.of(context).close(); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart new file mode 100644 index 0000000000000..60211b9024092 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor.dart @@ -0,0 +1,147 @@ +import 'dart:ui'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +const String kLocalImagesKey = 'local_images'; + +List get builtInAssetImages => [ + 'assets/images/built_in_cover_images/m_cover_image_1.jpg', + 'assets/images/built_in_cover_images/m_cover_image_2.jpg', + 'assets/images/built_in_cover_images/m_cover_image_3.jpg', + 'assets/images/built_in_cover_images/m_cover_image_4.jpg', + 'assets/images/built_in_cover_images/m_cover_image_5.jpg', + 'assets/images/built_in_cover_images/m_cover_image_6.jpg', + ]; + +class ColorOption { + const ColorOption({ + required this.colorHex, + required this.name, + }); + + final String colorHex; + final String name; +} + +class CoverColorPicker extends StatefulWidget { + const CoverColorPicker({ + super.key, + this.selectedBackgroundColorHex, + required this.pickerBackgroundColor, + required this.backgroundColorOptions, + required this.pickerItemHoverColor, + required this.onSubmittedBackgroundColorHex, + }); + + final String? selectedBackgroundColorHex; + final Color pickerBackgroundColor; + final List backgroundColorOptions; + final Color pickerItemHoverColor; + final void Function(String color) onSubmittedBackgroundColorHex; + + @override + State createState() => _CoverColorPickerState(); +} + +class _CoverColorPickerState extends State { + final scrollController = ScrollController(); + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: 30, + alignment: Alignment.center, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + platform: TargetPlatform.windows, + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: _buildColorItems( + widget.backgroundColorOptions, + widget.selectedBackgroundColorHex, + ), + ), + ), + ); + } + + Widget _buildColorItems(List options, String? selectedColor) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: options + .map( + (e) => ColorItem( + option: e, + isChecked: e.colorHex == selectedColor, + hoverColor: widget.pickerItemHoverColor, + onTap: widget.onSubmittedBackgroundColorHex, + ), + ) + .toList(), + ); + } +} + +@visibleForTesting +class ColorItem extends StatelessWidget { + const ColorItem({ + super.key, + required this.option, + required this.isChecked, + required this.hoverColor, + required this.onTap, + }); + + final ColorOption option; + final bool isChecked; + final Color hoverColor; + final void Function(String) onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 10.0), + child: InkWell( + customBorder: const CircleBorder(), + hoverColor: hoverColor, + onTap: () => onTap(option.colorHex), + child: SizedBox.square( + dimension: 25, + child: DecoratedBox( + decoration: BoxDecoration( + color: option.colorHex.tryToColor(), + shape: BoxShape.circle, + ), + child: isChecked + ? SizedBox.square( + child: Container( + margin: const EdgeInsets.all(1), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).cardColor, + width: 3.0, + ), + color: option.colorHex.tryToColor(), + shape: BoxShape.circle, + ), + ), + ) + : null, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor_bloc.dart new file mode 100644 index 0000000000000..112cfda026a3a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_editor_bloc.dart @@ -0,0 +1,130 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'cover_editor_bloc.freezed.dart'; + +class ChangeCoverPopoverBloc + extends Bloc { + ChangeCoverPopoverBloc({required this.editorState, required this.node}) + : super(const ChangeCoverPopoverState.initial()) { + SharedPreferences.getInstance().then((prefs) { + _prefs = prefs; + _initCompleter.complete(); + }); + + _dispatch(); + } + + final EditorState editorState; + final Node node; + final _initCompleter = Completer(); + late final SharedPreferences _prefs; + + void _dispatch() { + on((event, emit) async { + await event.map( + fetchPickedImagePaths: + (FetchPickedImagePaths fetchPickedImagePaths) async { + final imageNames = await _getPreviouslyPickedImagePaths(); + + emit( + ChangeCoverPopoverState.loaded( + imageNames, + selectLatestImage: fetchPickedImagePaths.selectLatestImage, + ), + ); + }, + deleteImage: (DeleteImage deleteImage) async { + final currentState = state; + final currentlySelectedImage = + node.attributes[DocumentHeaderBlockKeys.coverDetails]; + if (currentState is Loaded) { + await _deleteImageInStorage(deleteImage.path); + if (currentlySelectedImage == deleteImage.path) { + _removeCoverImageFromNode(); + } + final updateImageList = currentState.imageNames + .where((path) => path != deleteImage.path) + .toList(); + _updateImagePathsInStorage(updateImageList); + emit(Loaded(updateImageList)); + } + }, + clearAllImages: (ClearAllImages clearAllImages) async { + final currentState = state; + final currentlySelectedImage = + node.attributes[DocumentHeaderBlockKeys.coverDetails]; + + if (currentState is Loaded) { + for (final image in currentState.imageNames) { + await _deleteImageInStorage(image); + if (currentlySelectedImage == image) { + _removeCoverImageFromNode(); + } + } + _updateImagePathsInStorage([]); + emit(const Loaded([])); + } + }, + ); + }); + } + + Future> _getPreviouslyPickedImagePaths() async { + await _initCompleter.future; + final imageNames = _prefs.getStringList(kLocalImagesKey) ?? []; + if (imageNames.isEmpty) { + return imageNames; + } + imageNames.removeWhere((name) => !File(name).existsSync()); + unawaited(_prefs.setStringList(kLocalImagesKey, imageNames)); + return imageNames; + } + + void _updateImagePathsInStorage(List imagePaths) async { + await _initCompleter.future; + await _prefs.setStringList(kLocalImagesKey, imagePaths); + } + + Future _deleteImageInStorage(String path) async { + final imageFile = File(path); + await imageFile.delete(); + } + + void _removeCoverImageFromNode() { + final transaction = editorState.transaction; + transaction.updateNode(node, { + DocumentHeaderBlockKeys.coverType: CoverType.none.toString(), + DocumentHeaderBlockKeys.icon: + node.attributes[DocumentHeaderBlockKeys.icon], + }); + editorState.apply(transaction); + } +} + +@freezed +class ChangeCoverPopoverEvent with _$ChangeCoverPopoverEvent { + const factory ChangeCoverPopoverEvent.fetchPickedImagePaths({ + @Default(false) bool selectLatestImage, + }) = FetchPickedImagePaths; + + const factory ChangeCoverPopoverEvent.deleteImage(String path) = DeleteImage; + const factory ChangeCoverPopoverEvent.clearAllImages() = ClearAllImages; +} + +@freezed +class ChangeCoverPopoverState with _$ChangeCoverPopoverState { + const factory ChangeCoverPopoverState.initial() = Initial; + const factory ChangeCoverPopoverState.loading() = Loading; + const factory ChangeCoverPopoverState.loaded( + List imageNames, { + @Default(false) selectLatestImage, + }) = Loaded; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart new file mode 100644 index 0000000000000..8111797b8042d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart @@ -0,0 +1,314 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart'; +import 'package:appflowy/workspace/application/appearance_defaults.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class CoverTitle extends StatelessWidget { + const CoverTitle({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => ViewBloc(view: view)..add(const ViewEvent.initial()), + child: _InnerCoverTitle( + view: view, + ), + ); + } +} + +class _InnerCoverTitle extends StatefulWidget { + const _InnerCoverTitle({ + required this.view, + }); + + final ViewPB view; + + @override + State<_InnerCoverTitle> createState() => _InnerCoverTitleState(); +} + +class _InnerCoverTitleState extends State<_InnerCoverTitle> { + final titleTextController = TextEditingController(); + + late final editorContext = context.read(); + late final editorState = context.read(); + late final titleFocusNode = editorContext.coverTitleFocusNode; + int lineCount = 1; + + @override + void initState() { + super.initState(); + + titleTextController.text = widget.view.name; + titleTextController.addListener(_onViewNameChanged); + + titleFocusNode + ..onKeyEvent = _onKeyEvent + ..addListener(_onFocusChanged); + + editorState.selectionNotifier.addListener(_onSelectionChanged); + + _requestInitialFocus(); + } + + @override + void dispose() { + titleFocusNode + ..onKeyEvent = null + ..removeListener(_onFocusChanged); + titleTextController.dispose(); + editorState.selectionNotifier.removeListener(_onSelectionChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final fontStyle = Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(fontSize: 40.0, fontWeight: FontWeight.w700); + final width = context.read().state.width; + return BlocConsumer( + listenWhen: (previous, current) => + previous.view.name != current.view.name, + listener: _onListen, + builder: (context, state) { + final appearance = context.read().state; + return Container( + padding: EditorStyleCustomizer.documentPaddingWithOptionMenu, + constraints: BoxConstraints(maxWidth: width), + child: Theme( + data: Theme.of(context).copyWith( + textSelectionTheme: TextSelectionThemeData( + cursorColor: appearance.selectionColor, + selectionColor: appearance.selectionColor ?? + DefaultAppearanceSettings.getDefaultSelectionColor(context), + ), + ), + child: TextFieldWithMetricLines( + controller: titleTextController, + enabled: editorState.editable, + focusNode: titleFocusNode, + style: fontStyle, + onLineCountChange: (count) => lineCount = count, + decoration: InputDecoration( + border: InputBorder.none, + hintText: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + hintStyle: fontStyle.copyWith( + color: Theme.of(context).hintColor, + ), + ), + ), + ), + ); + }, + ); + } + + void _requestInitialFocus() { + if (editorContext.requestCoverTitleFocus) { + void requestFocus() { + titleFocusNode.canRequestFocus = true; + titleFocusNode.requestFocus(); + editorContext.requestCoverTitleFocus = false; + } + + // on macOS, if we gain focus immediately, the focus won't work. + // It's a workaround to delay the focus request. + if (UniversalPlatform.isMacOS) { + Future.delayed(Durations.short4, () { + requestFocus(); + }); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + requestFocus(); + }); + } + } + } + + void _onSelectionChanged() { + // if title is focused and the selection is not null, clear the selection + if (editorState.selection != null && titleFocusNode.hasFocus) { + Log.info('title is focused, clear the editor selection'); + editorState.selection = null; + } + } + + void _onListen(BuildContext context, ViewState state) { + _requestFocusIfNeeded(widget.view, state); + + if (state.view.name != titleTextController.text) { + titleTextController.text = state.view.name; + } + } + + bool _shouldFocus(ViewPB view, ViewState? state) { + final name = state?.view.name ?? view.name; + + if (editorState.document.root.children.isNotEmpty) { + return false; + } + + // if the view's name is empty, focus on the title + if (name.isEmpty) { + return true; + } + + return false; + } + + void _requestFocusIfNeeded(ViewPB view, ViewState? state) { + final shouldFocus = _shouldFocus(view, state); + if (shouldFocus) { + titleFocusNode.requestFocus(); + } + } + + void _onFocusChanged() { + if (titleFocusNode.hasFocus) { + // if the document is empty, disable the keyboard service + final children = editorState.document.root.children; + final firstDelta = children.firstOrNull?.delta; + final isEmptyDocument = + children.length == 1 && (firstDelta == null || firstDelta.isEmpty); + if (!isEmptyDocument) { + return; + } + + if (editorState.selection != null) { + Log.info('cover title got focus, clear the editor selection'); + editorState.selection = null; + } + + Log.info('cover title got focus, disable keyboard service'); + editorState.service.keyboardService?.disable(); + } else { + Log.info('cover title lost focus, enable keyboard service'); + editorState.service.keyboardService?.enable(); + } + } + + void _onViewNameChanged() { + Debounce.debounce( + 'update view name', + const Duration(milliseconds: 250), + () { + if (!mounted) { + return; + } + if (context.read().state.view.name != + titleTextController.text) { + context + .read() + .add(ViewEvent.rename(titleTextController.text)); + } + }, + ); + } + + KeyEventResult _onKeyEvent(FocusNode focusNode, KeyEvent event) { + if (event is KeyUpEvent) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.enter) { + // if enter is pressed, jump the first line of editor. + _createNewLine(); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + return _moveCursorToNextLine(event.logicalKey); + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + return _moveCursorToNextLine(event.logicalKey); + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + return _exitEditing(); + } + + return KeyEventResult.ignored; + } + + KeyEventResult _exitEditing() { + titleFocusNode.unfocus(); + return KeyEventResult.handled; + } + + Future _createNewLine() async { + titleFocusNode.unfocus(); + + final selection = titleTextController.selection; + final text = titleTextController.text; + // split the text into two lines based on the cursor position + final parts = [ + text.substring(0, selection.baseOffset), + text.substring(selection.baseOffset), + ]; + titleTextController.text = parts[0]; + + final transaction = editorState.transaction; + transaction.insertNode([0], paragraphNode(text: parts[1])); + await editorState.apply(transaction); + + // update selection instead of using afterSelection in transaction, + // because it will cause the cursor to jump + await editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: [0])), + // trigger the keyboard service. + reason: SelectionUpdateReason.uiEvent, + ); + } + + KeyEventResult _moveCursorToNextLine(LogicalKeyboardKey key) { + final selection = titleTextController.selection; + final text = titleTextController.text; + + // if the cursor is not at the end of the text, ignore the event + if ((key == LogicalKeyboardKey.arrowRight || lineCount != 1) && + (!selection.isCollapsed || text.length != selection.extentOffset)) { + return KeyEventResult.ignored; + } + + final node = editorState.getNodeAtPath([0]); + if (node == null) { + _createNewLine(); + return KeyEventResult.handled; + } + + titleFocusNode.unfocus(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // delay the update selection to wait for the title to unfocus + int offset = 0; + if (key == LogicalKeyboardKey.arrowDown) { + offset = node.delta?.length ?? 0; + } else if (key == LogicalKeyboardKey.arrowRight) { + offset = 0; + } + editorState.updateSelectionWithReason( + Selection.collapsed( + Position(path: [0], offset: offset), + ), + // trigger the keyboard service. + reason: SelectionUpdateReason.uiEvent, + ); + }); + + return KeyEventResult.handled; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart new file mode 100644 index 0000000000000..3f84d04283e48 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart @@ -0,0 +1,333 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/rounded_button.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class CoverImagePicker extends StatefulWidget { + const CoverImagePicker({ + super.key, + required this.onBackPressed, + required this.onFileSubmit, + }); + + final VoidCallback onBackPressed; + final Function(List paths) onFileSubmit; + + @override + State createState() => _CoverImagePickerState(); +} + +class _CoverImagePickerState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => CoverImagePickerBloc() + ..add(const CoverImagePickerEvent.initialEvent()), + child: BlocListener( + listener: (context, state) { + if (state is NetworkImagePicked) { + state.successOrFail.fold( + (s) {}, + (e) => showSnapBar( + context, + LocaleKeys.document_plugins_cover_invalidImageUrl.tr(), + ), + ); + } + if (state is Done) { + state.successOrFail.fold( + (l) => widget.onFileSubmit(l), + (r) => showSnapBar( + context, + LocaleKeys.document_plugins_cover_failedToAddImageToGallery + .tr(), + ), + ); + } + }, + child: BlocBuilder( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + state is Loading + ? const SizedBox( + height: 180, + child: Center( + child: CircularProgressIndicator(), + ), + ) + : CoverImagePreviewWidget(state: state), + const VSpace(10), + NetworkImageUrlInput( + onAdd: (url) { + context.read().add(UrlSubmit(url)); + }, + ), + const VSpace(10), + ImagePickerActionButtons( + onBackPressed: () { + widget.onBackPressed(); + }, + onSave: () { + context.read().add( + SaveToGallery(state), + ); + }, + ), + ], + ); + }, + ), + ), + ); + } +} + +class NetworkImageUrlInput extends StatefulWidget { + const NetworkImageUrlInput({super.key, required this.onAdd}); + + final void Function(String color) onAdd; + + @override + State createState() => _NetworkImageUrlInputState(); +} + +class _NetworkImageUrlInputState extends State { + TextEditingController urlController = TextEditingController(); + bool get buttonDisabled => urlController.text.isEmpty; + + @override + void initState() { + super.initState(); + urlController.addListener(_updateState); + } + + void _updateState() => setState(() {}); + + @override + void dispose() { + urlController.removeListener(_updateState); + urlController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + flex: 4, + child: FlowyTextField( + controller: urlController, + hintText: LocaleKeys.document_plugins_cover_enterImageUrl.tr(), + ), + ), + const SizedBox( + width: 5, + ), + Expanded( + child: RoundedTextButton( + onPressed: () { + urlController.text.isNotEmpty + ? widget.onAdd(urlController.text) + : null; + }, + hoverColor: Colors.transparent, + fillColor: buttonDisabled + ? Theme.of(context).disabledColor + : Theme.of(context).colorScheme.primary, + height: 36, + title: LocaleKeys.document_plugins_cover_add.tr(), + borderRadius: Corners.s8Border, + ), + ), + ], + ); + } +} + +class ImagePickerActionButtons extends StatelessWidget { + const ImagePickerActionButtons({ + super.key, + required this.onBackPressed, + required this.onSave, + }); + + final VoidCallback onBackPressed; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlowyTextButton( + LocaleKeys.document_plugins_cover_back.tr(), + hoverColor: Theme.of(context).colorScheme.secondaryContainer, + fillColor: Colors.transparent, + mainAxisAlignment: MainAxisAlignment.end, + onPressed: () => onBackPressed(), + ), + FlowyTextButton( + LocaleKeys.document_plugins_cover_saveToGallery.tr(), + onPressed: () => onSave(), + hoverColor: Theme.of(context).colorScheme.secondaryContainer, + fillColor: Colors.transparent, + mainAxisAlignment: MainAxisAlignment.end, + fontColor: Theme.of(context).colorScheme.primary, + ), + ], + ); + } +} + +class CoverImagePreviewWidget extends StatefulWidget { + const CoverImagePreviewWidget({super.key, required this.state}); + + final dynamic state; + + @override + State createState() => + _CoverImagePreviewWidgetState(); +} + +class _CoverImagePreviewWidgetState extends State { + DecoratedBox _buildFilePickerWidget(BuildContext ctx) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: Corners.s6Border, + border: Border.fromBorderSide( + BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.add_s, + size: Size(20, 20), + ), + const SizedBox( + width: 3, + ), + FlowyText( + LocaleKeys.document_plugins_cover_pasteImageUrl.tr(), + ), + ], + ), + const VSpace(10), + FlowyText( + LocaleKeys.document_plugins_cover_or.tr(), + fontWeight: FontWeight.w300, + ), + const VSpace(10), + FlowyButton( + hoverColor: Theme.of(context).hoverColor, + onTap: () { + ctx.read().add(const PickFileImage()); + }, + useIntrinsicWidth: true, + leftIcon: const FlowySvg( + FlowySvgs.document_s, + size: Size(20, 20), + ), + text: FlowyText( + lineHeight: 1.0, + LocaleKeys.document_plugins_cover_pickFromFiles.tr(), + ), + ), + ], + ), + ); + } + + Positioned _buildImageDeleteButton(BuildContext ctx) { + return Positioned( + right: 10, + top: 10, + child: InkWell( + onTap: () { + ctx.read().add(const DeleteImage()); + }, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.onPrimary, + ), + child: const FlowySvg( + FlowySvgs.close_s, + size: Size(20, 20), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Container( + height: 180, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + borderRadius: Corners.s6Border, + image: widget.state is Initial + ? null + : widget.state is NetworkImagePicked + ? widget.state.successOrFail.fold( + (path) => DecorationImage( + image: NetworkImage(path), + fit: BoxFit.cover, + ), + (r) => null, + ) + : widget.state is FileImagePicked + ? DecorationImage( + image: FileImage(File(widget.state.path)), + fit: BoxFit.cover, + ) + : null, + ), + child: (widget.state is Initial) + ? _buildFilePickerWidget(context) + : (widget.state is NetworkImagePicked) + ? widget.state.successOrFail.fold( + (l) => null, + (r) => _buildFilePickerWidget( + context, + ), + ) + : null, + ), + (widget.state is FileImagePicked) + ? _buildImageDeleteButton(context) + : (widget.state is NetworkImagePicked) + ? widget.state.successOrFail.fold( + (l) => _buildImageDeleteButton(context), + (r) => const SizedBox.shrink(), + ) + : const SizedBox.shrink(), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart new file mode 100644 index 0000000000000..9ead1ff3f4e76 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/custom_cover_picker_bloc.dart @@ -0,0 +1,221 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/default_extensions.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'cover_editor.dart'; + +part 'custom_cover_picker_bloc.freezed.dart'; + +class CoverImagePickerBloc + extends Bloc { + CoverImagePickerBloc() : super(const CoverImagePickerState.initial()) { + _dispatch(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.map( + initialEvent: (InitialEvent initialEvent) { + emit(const CoverImagePickerState.initial()); + }, + urlSubmit: (UrlSubmit urlSubmit) async { + emit(const CoverImagePickerState.loading()); + final validateImage = await _validateURL(urlSubmit.path); + if (validateImage) { + emit( + CoverImagePickerState.networkImage( + FlowyResult.success(urlSubmit.path), + ), + ); + } else { + emit( + CoverImagePickerState.networkImage( + FlowyResult.failure( + FlowyError( + msg: LocaleKeys.document_plugins_cover_couldNotFetchImage + .tr(), + ), + ), + ), + ); + } + }, + pickFileImage: (PickFileImage pickFileImage) async { + final imagePickerResults = await _pickImages(); + if (imagePickerResults != null) { + emit(CoverImagePickerState.fileImage(imagePickerResults)); + } else { + emit(const CoverImagePickerState.initial()); + } + }, + deleteImage: (DeleteImage deleteImage) { + emit(const CoverImagePickerState.initial()); + }, + saveToGallery: (SaveToGallery saveToGallery) async { + emit(const CoverImagePickerState.loading()); + final saveImage = await _saveToGallery(saveToGallery.previousState); + if (saveImage != null) { + emit(CoverImagePickerState.done(FlowyResult.success(saveImage))); + } else { + emit( + CoverImagePickerState.done( + FlowyResult.failure( + FlowyError( + msg: LocaleKeys.document_plugins_cover_imageSavingFailed + .tr(), + ), + ), + ), + ); + emit(const CoverImagePickerState.initial()); + } + }, + ); + }, + ); + } + + Future?>? _saveToGallery(CoverImagePickerState state) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final List imagePaths = prefs.getStringList(kLocalImagesKey) ?? []; + final directory = await _coverPath(); + + if (state is FileImagePicked) { + try { + final path = state.path; + final newPath = p.join(directory, p.split(path).last); + final newFile = await File(path).copy(newPath); + imagePaths.add(newFile.path); + } catch (e) { + return null; + } + } else if (state is NetworkImagePicked) { + try { + final url = state.successOrFail.fold((path) => path, (r) => null); + if (url != null) { + final response = await http.get(Uri.parse(url)); + final newPath = p.join(directory, _networkImageName(url)); + final imageFile = File(newPath); + await imageFile.create(); + await imageFile.writeAsBytes(response.bodyBytes); + imagePaths.add(imageFile.absolute.path); + } else { + return null; + } + } catch (e) { + return null; + } + } + await prefs.setStringList(kLocalImagesKey, imagePaths); + return imagePaths; + } + + Future _pickImages() async { + final result = await getIt().pickFiles( + dialogTitle: LocaleKeys.document_plugins_cover_addLocalImage.tr(), + type: FileType.image, + allowedExtensions: defaultImageExtensions, + ); + if (result != null && result.files.isNotEmpty) { + return result.files.first.path; + } + return null; + } + + Future _coverPath() async { + final directory = await getIt().getPath(); + return Directory(p.join(directory, 'covers')) + .create(recursive: true) + .then((value) => value.path); + } + + String _networkImageName(String url) { + return 'IMG_${DateTime.now().millisecondsSinceEpoch.toString()}.${_getExtension( + url, + fromNetwork: true, + )}'; + } + + String? _getExtension( + String path, { + bool fromNetwork = false, + }) { + String? ext; + if (!fromNetwork) { + final extension = p.extension(path); + if (extension.isEmpty) { + return null; + } + ext = extension; + } else { + final uri = Uri.parse(path); + final parameters = uri.queryParameters; + if (path.contains('unsplash')) { + final dl = parameters['dl']; + if (dl != null) { + ext = p.extension(dl); + } + } else { + ext = p.extension(path); + } + } + if (ext != null && ext.isNotEmpty) { + ext = ext.substring(1); + } + if (defaultImageExtensions.contains(ext)) { + return ext; + } + return null; + } + + Future _validateURL(String path) async { + final extension = _getExtension(path, fromNetwork: true); + if (extension == null) { + return false; + } + try { + final response = await http.head(Uri.parse(path)); + return response.statusCode == 200; + } catch (e) { + return false; + } + } +} + +@freezed +class CoverImagePickerEvent with _$CoverImagePickerEvent { + const factory CoverImagePickerEvent.urlSubmit(String path) = UrlSubmit; + const factory CoverImagePickerEvent.pickFileImage() = PickFileImage; + const factory CoverImagePickerEvent.deleteImage() = DeleteImage; + const factory CoverImagePickerEvent.saveToGallery( + CoverImagePickerState previousState, + ) = SaveToGallery; + const factory CoverImagePickerEvent.initialEvent() = InitialEvent; +} + +@freezed +class CoverImagePickerState with _$CoverImagePickerState { + const factory CoverImagePickerState.initial() = Initial; + const factory CoverImagePickerState.loading() = Loading; + const factory CoverImagePickerState.networkImage( + FlowyResult successOrFail, + ) = NetworkImagePicked; + const factory CoverImagePickerState.fileImage(String path) = FileImagePicked; + + const factory CoverImagePickerState.done( + FlowyResult, FlowyError> successOrFail, + ) = Done; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover.dart new file mode 100644 index 0000000000000..7265ef6f82be5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/desktop_cover.dart @@ -0,0 +1,169 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/flowy_gradient_colors.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:string_validator/string_validator.dart'; + +/// This is a transitional component that can be removed once the desktop +/// supports immersive widgets, allowing for the exclusive use of the DocumentImmersiveCover component. +class DesktopCover extends StatefulWidget { + const DesktopCover({ + super.key, + required this.view, + required this.editorState, + required this.node, + required this.coverType, + this.coverDetails, + }); + + final ViewPB view; + final Node node; + final EditorState editorState; + final CoverType coverType; + final String? coverDetails; + + @override + State createState() => _DesktopCoverState(); +} + +class _DesktopCoverState extends State { + CoverType get coverType => CoverType.fromString( + widget.node.attributes[DocumentHeaderBlockKeys.coverType], + ); + String? get coverDetails => + widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]; + + @override + Widget build(BuildContext context) { + if (widget.view.extra.isEmpty) { + return _buildCoverImageV1(); + } + + return _buildCoverImageV2(); + } + + // version > 0.5.5 + Widget _buildCoverImageV2() { + return BlocProvider( + create: (context) => DocumentImmersiveCoverBloc(view: widget.view) + ..add(const DocumentImmersiveCoverEvent.initial()), + child: + BlocBuilder( + builder: (context, state) { + final cover = state.cover; + final type = state.cover.type; + const height = kCoverHeight; + + if (type == PageStyleCoverImageType.customImage || + type == PageStyleCoverImageType.unsplashImage) { + final userProfilePB = + context.read().state.userProfilePB; + return SizedBox( + height: height, + width: double.infinity, + child: FlowyNetworkImage( + url: cover.value, + userProfilePB: userProfilePB, + ), + ); + } + + if (type == PageStyleCoverImageType.builtInImage) { + return SizedBox( + height: height, + width: double.infinity, + child: Image.asset( + PageStyleCoverImageType.builtInImagePath(cover.value), + fit: BoxFit.cover, + ), + ); + } + + if (type == PageStyleCoverImageType.pureColor) { + // try to parse the color from the tint id, + // if it fails, try to parse the color as a hex string + final color = FlowyTint.fromId(cover.value)?.color(context) ?? + cover.value.tryToColor(); + return Container( + height: height, + width: double.infinity, + color: color, + ); + } + + if (type == PageStyleCoverImageType.gradientColor) { + return Container( + height: height, + width: double.infinity, + decoration: BoxDecoration( + gradient: FlowyGradientColor.fromId(cover.value).linear, + ), + ); + } + + if (type == PageStyleCoverImageType.localImage) { + return SizedBox( + height: height, + width: double.infinity, + child: Image.file( + File(cover.value), + fit: BoxFit.cover, + ), + ); + } + + return const SizedBox.shrink(); + }, + ), + ); + } + + // version <= 0.5.5 + Widget _buildCoverImageV1() { + final detail = coverDetails; + if (detail == null) { + return const SizedBox.shrink(); + } + switch (widget.coverType) { + case CoverType.file: + if (isURL(detail)) { + final userProfilePB = + context.read().state.userProfilePB; + return FlowyNetworkImage( + url: detail, + userProfilePB: userProfilePB, + errorWidgetBuilder: (context, url, error) => + const SizedBox.shrink(), + ); + } + final imageFile = File(detail); + if (!imageFile.existsSync()) { + return const SizedBox.shrink(); + } + return Image.file( + imageFile, + fit: BoxFit.cover, + ); + case CoverType.asset: + return Image.asset( + PageStyleCoverImageType.builtInImagePath(detail), + fit: BoxFit.cover, + ); + case CoverType.color: + final color = widget.coverDetails?.tryToColor() ?? Colors.white; + return Container(color: color); + case CoverType.none: + return const SizedBox.shrink(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart new file mode 100644 index 0000000000000..54feee3d904bf --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart @@ -0,0 +1,878 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/desktop_cover.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/rounded_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'cover_title.dart'; + +const double kCoverHeight = 280.0; +const double kIconHeight = 60.0; +const double kToolbarHeight = 40.0; // with padding to the top + +// Remove this widget if the desktop support immersive cover. +class DocumentHeaderBlockKeys { + const DocumentHeaderBlockKeys._(); + + static const String coverType = 'cover_selection_type'; + static const String coverDetails = 'cover_selection'; + static const String icon = 'selected_icon'; +} + +// for the version under 0.5.5, including 0.5.5 +enum CoverType { + none, + color, + file, + asset; + + static CoverType fromString(String? value) { + if (value == null) { + return CoverType.none; + } + return CoverType.values.firstWhere( + (e) => e.toString() == value, + orElse: () => CoverType.none, + ); + } +} + +// This key is used to intercept the selection event in the document cover widget. +const _interceptorKey = 'document_cover_widget_interceptor'; + +class DocumentCoverWidget extends StatefulWidget { + const DocumentCoverWidget({ + super.key, + required this.node, + required this.editorState, + required this.onIconChanged, + required this.view, + }); + + final Node node; + final EditorState editorState; + final ValueChanged onIconChanged; + final ViewPB view; + + @override + State createState() => _DocumentCoverWidgetState(); +} + +class _DocumentCoverWidgetState extends State { + CoverType get coverType => CoverType.fromString( + widget.node.attributes[DocumentHeaderBlockKeys.coverType], + ); + + String? get coverDetails => + widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]; + + String? get icon => widget.node.attributes[DocumentHeaderBlockKeys.icon]; + + bool get hasIcon => viewIcon.emoji.isNotEmpty; + + bool get hasCover => + coverType != CoverType.none || + (cover != null && cover?.type != PageStyleCoverImageType.none); + + RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; + + EmojiIconData viewIcon = EmojiIconData.none(); + + PageStyleCover? cover; + late ViewPB view; + late final ViewListener viewListener; + int retryCount = 0; + + final isCoverTitleHovered = ValueNotifier(false); + + late final gestureInterceptor = SelectionGestureInterceptor( + key: _interceptorKey, + canTap: (details) => !_isTapInBounds(details.globalPosition), + canPanStart: (details) => !_isDragInBounds(details.globalPosition), + ); + + @override + void initState() { + super.initState(); + final icon = widget.view.icon; + viewIcon = EmojiIconData.fromViewIconPB(icon); + cover = widget.view.cover; + view = widget.view; + widget.node.addListener(_reload); + widget.editorState.service.selectionService + .registerGestureInterceptor(gestureInterceptor); + + viewListener = ViewListener(viewId: widget.view.id) + ..start( + onViewUpdated: (view) { + setState(() { + viewIcon = EmojiIconData.fromViewIconPB(view.icon); + cover = view.cover; + view = view; + }); + }, + ); + } + + @override + void dispose() { + viewListener.stop(); + widget.node.removeListener(_reload); + isCoverTitleHovered.dispose(); + widget.editorState.service.selectionService + .unregisterGestureInterceptor(_interceptorKey); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final offset = _calculateIconLeft(context, constraints); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + children: [ + SizedBox( + height: _calculateOverallHeight(), + child: DocumentHeaderToolbar( + onIconOrCoverChanged: _saveIconOrCover, + node: widget.node, + editorState: widget.editorState, + hasCover: hasCover, + hasIcon: hasIcon, + offset: offset, + isCoverTitleHovered: isCoverTitleHovered, + ), + ), + if (hasCover) + DocumentCover( + view: view, + editorState: widget.editorState, + node: widget.node, + coverType: coverType, + coverDetails: coverDetails, + onChangeCover: (type, details) => + _saveIconOrCover(cover: (type, details)), + ), + _buildCoverIcon( + context, + constraints, + offset, + ), + ], + ), + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: MouseRegion( + onEnter: (event) => isCoverTitleHovered.value = true, + onExit: (event) => isCoverTitleHovered.value = false, + child: CoverTitle( + view: widget.view, + ), + ), + ), + ], + ); + }, + ); + } + + Widget _buildCoverIcon( + BuildContext context, + BoxConstraints constraints, + double offset, + ) { + if (!hasIcon || offset == 0) { + return const SizedBox.shrink(); + } + + return Positioned( + // if hasCover, there shouldn't be icons present so the icon can + // be closer to the bottom. + left: offset, + bottom: hasCover ? kToolbarHeight - kIconHeight / 2 : kToolbarHeight, + child: DocumentIcon( + editorState: widget.editorState, + node: widget.node, + icon: viewIcon, + onChangeIcon: (icon) => _saveIconOrCover(icon: icon), + ), + ); + } + + void _reload() => setState(() {}); + + double _calculateIconLeft(BuildContext context, BoxConstraints constraints) { + final editorState = context.read(); + final appearanceCubit = context.read(); + + final renderBox = editorState.renderBox; + + if (renderBox == null || !renderBox.hasSize) {} + + var renderBoxWidth = 0.0; + if (renderBox != null && renderBox.hasSize) { + renderBoxWidth = renderBox.size.width; + } else if (retryCount <= 3) { + retryCount++; + // this is a workaround for the issue that the renderBox is not initialized + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _reload(); + }); + return 0; + } + + // if the renderBox width equals to 0, it means the editor is not initialized + final editorWidth = renderBoxWidth != 0 + ? min(renderBoxWidth, appearanceCubit.state.width) + : appearanceCubit.state.width; + + // left padding + editor width + right padding = the width of the editor + final leftOffset = (constraints.maxWidth - editorWidth) / 2.0 + + EditorStyleCustomizer.documentPadding.right; + + // ensure the offset is not negative + return max(0, leftOffset); + } + + double _calculateOverallHeight() { + final height = switch ((hasIcon, hasCover)) { + (true, true) => kCoverHeight + kToolbarHeight, + (true, false) => 50 + kIconHeight + kToolbarHeight, + (false, true) => kCoverHeight + kToolbarHeight, + (false, false) => kToolbarHeight, + }; + + return height; + } + + void _saveIconOrCover({ + (CoverType, String?)? cover, + EmojiIconData? icon, + }) async { + final transaction = widget.editorState.transaction; + final coverType = widget.node.attributes[DocumentHeaderBlockKeys.coverType]; + final coverDetails = + widget.node.attributes[DocumentHeaderBlockKeys.coverDetails]; + final Map attributes = { + DocumentHeaderBlockKeys.coverType: coverType, + DocumentHeaderBlockKeys.coverDetails: coverDetails, + DocumentHeaderBlockKeys.icon: + widget.node.attributes[DocumentHeaderBlockKeys.icon], + CustomImageBlockKeys.imageType: '1', + }; + if (cover != null) { + attributes[DocumentHeaderBlockKeys.coverType] = cover.$1.toString(); + attributes[DocumentHeaderBlockKeys.coverDetails] = cover.$2; + } + if (icon != null) { + attributes[DocumentHeaderBlockKeys.icon] = icon.emoji; + widget.onIconChanged(icon); + } + + // compatible with version <= 0.5.5. + transaction.updateNode(widget.node, attributes); + await widget.editorState.apply(transaction); + + // compatible with version > 0.5.5. + EditorMigration.migrateCoverIfNeeded( + widget.view, + attributes, + overwrite: true, + ); + } + + bool _isTapInBounds(Offset offset) { + if (_renderBox == null) { + return false; + } + + final localPosition = _renderBox!.globalToLocal(offset); + return _renderBox!.paintBounds.contains(localPosition); + } + + bool _isDragInBounds(Offset offset) { + if (_renderBox == null) { + return false; + } + + final localPosition = _renderBox!.globalToLocal(offset); + return _renderBox!.paintBounds.contains(localPosition); + } +} + +@visibleForTesting +class DocumentHeaderToolbar extends StatefulWidget { + const DocumentHeaderToolbar({ + super.key, + required this.node, + required this.editorState, + required this.hasCover, + required this.hasIcon, + required this.onIconOrCoverChanged, + required this.offset, + required this.isCoverTitleHovered, + }); + + final Node node; + final EditorState editorState; + final bool hasCover; + final bool hasIcon; + final void Function({(CoverType, String?)? cover, EmojiIconData? icon}) + onIconOrCoverChanged; + final double offset; + final ValueNotifier isCoverTitleHovered; + + @override + State createState() => _DocumentHeaderToolbarState(); +} + +class _DocumentHeaderToolbarState extends State { + final _popoverController = PopoverController(); + + bool isHidden = UniversalPlatform.isDesktopOrWeb; + bool isPopoverOpen = false; + + @override + Widget build(BuildContext context) { + Widget child = Container( + alignment: Alignment.bottomLeft, + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: widget.offset), + child: SizedBox( + height: 28, + child: ValueListenableBuilder( + valueListenable: widget.isCoverTitleHovered, + builder: (context, isHovered, child) { + return Visibility( + visible: !isHidden || isPopoverOpen || isHovered, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: buildRowChildren(), + ), + ); + }, + ), + ), + ); + + if (UniversalPlatform.isDesktopOrWeb) { + child = MouseRegion( + opaque: false, + onEnter: (event) => setHidden(false), + onExit: isPopoverOpen ? null : (_) => setHidden(true), + child: child, + ); + } + + return child; + } + + List buildRowChildren() { + if (widget.hasCover && widget.hasIcon) { + return []; + } + + final List children = []; + + if (!widget.hasCover) { + children.add( + FlowyButton( + leftIconSize: const Size.square(18), + onTap: () => widget.onIconOrCoverChanged( + cover: UniversalPlatform.isDesktopOrWeb + ? (CoverType.asset, '1') + : (CoverType.color, '0xffe8e0ff'), + ), + useIntrinsicWidth: true, + leftIcon: const FlowySvg(FlowySvgs.add_cover_s), + text: FlowyText.small( + LocaleKeys.document_plugins_cover_addCover.tr(), + color: Theme.of(context).hintColor, + ), + ), + ); + } + + if (widget.hasIcon) { + children.add( + FlowyButton( + onTap: () => widget.onIconOrCoverChanged(icon: EmojiIconData.none()), + useIntrinsicWidth: true, + leftIcon: const FlowySvg(FlowySvgs.add_icon_s), + iconPadding: 4.0, + text: FlowyText.small( + LocaleKeys.document_plugins_cover_removeIcon.tr(), + color: Theme.of(context).hintColor, + ), + ), + ); + } else { + Widget child = FlowyButton( + useIntrinsicWidth: true, + leftIcon: const FlowySvg(FlowySvgs.add_icon_s), + iconPadding: 4.0, + text: FlowyText.small( + LocaleKeys.document_plugins_cover_addIcon.tr(), + color: Theme.of(context).hintColor, + ), + onTap: UniversalPlatform.isDesktop + ? null + : () async { + final result = await context.push( + MobileEmojiPickerScreen.routeName, + ); + if (result != null) { + widget.onIconOrCoverChanged(icon: result); + } + }, + ); + + if (UniversalPlatform.isDesktop) { + child = AppFlowyPopover( + onClose: () => setState(() => isPopoverOpen = false), + controller: _popoverController, + offset: const Offset(0, 8), + direction: PopoverDirection.bottomWithCenterAligned, + constraints: BoxConstraints.loose(const Size(360, 380)), + margin: EdgeInsets.zero, + child: child, + popupBuilder: (BuildContext popoverContext) { + isPopoverOpen = true; + return FlowyIconEmojiPicker( + onSelectedEmoji: (result) { + widget.onIconOrCoverChanged(icon: result); + _popoverController.close(); + }, + ); + }, + ); + } + + children.add(child); + } + + return children; + } + + void setHidden(bool value) { + if (isHidden == value) return; + setState(() { + isHidden = value; + }); + } +} + +@visibleForTesting +class DocumentCover extends StatefulWidget { + const DocumentCover({ + super.key, + required this.view, + required this.node, + required this.editorState, + required this.coverType, + this.coverDetails, + required this.onChangeCover, + }); + + final ViewPB view; + final Node node; + final EditorState editorState; + final CoverType coverType; + final String? coverDetails; + final void Function(CoverType type, String? details) onChangeCover; + + @override + State createState() => DocumentCoverState(); +} + +class DocumentCoverState extends State { + final popoverController = PopoverController(); + + bool isOverlayButtonsHidden = true; + bool isPopoverOpen = false; + + @override + Widget build(BuildContext context) { + return UniversalPlatform.isDesktopOrWeb + ? _buildDesktopCover() + : _buildMobileCover(); + } + + Widget _buildDesktopCover() { + return SizedBox( + height: kCoverHeight, + child: MouseRegion( + onEnter: (event) => setOverlayButtonsHidden(false), + onExit: (event) => + setOverlayButtonsHidden(isPopoverOpen ? false : true), + child: Stack( + children: [ + SizedBox( + height: double.infinity, + width: double.infinity, + child: DesktopCover( + view: widget.view, + editorState: widget.editorState, + node: widget.node, + coverType: widget.coverType, + coverDetails: widget.coverDetails, + ), + ), + if (!isOverlayButtonsHidden) _buildCoverOverlayButtons(context), + ], + ), + ), + ); + } + + Widget _buildMobileCover() { + return SizedBox( + height: kCoverHeight, + child: Stack( + children: [ + SizedBox( + height: double.infinity, + width: double.infinity, + child: _buildCoverImage(), + ), + Positioned( + bottom: 8, + right: 12, + child: Row( + children: [ + IntrinsicWidth( + child: RoundedTextButton( + fontSize: 14, + onPressed: () { + showMobileBottomSheet( + context, + showHeader: true, + showDragHandle: true, + showCloseButton: true, + title: + LocaleKeys.document_plugins_cover_changeCover.tr(), + builder: (context) { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 340, + minHeight: 80, + ), + child: UploadImageMenu( + limitMaximumImageSize: !_isLocalMode(), + supportTypes: const [ + UploadImageType.color, + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + ], + onSelectedLocalImages: (files) async { + context.pop(); + + if (files.isEmpty) { + return; + } + + widget.onChangeCover( + CoverType.file, + files.first.path, + ); + }, + onSelectedAIImage: (_) { + throw UnimplementedError(); + }, + onSelectedNetworkImage: (url) async { + context.pop(); + widget.onChangeCover(CoverType.file, url); + }, + onSelectedColor: (color) { + context.pop(); + widget.onChangeCover(CoverType.color, color); + }, + ), + ), + ); + }, + ); + }, + fillColor: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withOpacity(0.5), + height: 32, + title: LocaleKeys.document_plugins_cover_changeCover.tr(), + ), + ), + const HSpace(8.0), + SizedBox.square( + dimension: 32.0, + child: DeleteCoverButton( + onTap: () => widget.onChangeCover(CoverType.none, null), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildCoverImage() { + final detail = widget.coverDetails; + if (detail == null) { + return const SizedBox.shrink(); + } + switch (widget.coverType) { + case CoverType.file: + if (isURL(detail)) { + final userProfilePB = + context.read().state.userProfilePB; + return FlowyNetworkImage( + url: detail, + userProfilePB: userProfilePB, + errorWidgetBuilder: (context, url, error) => + const SizedBox.shrink(), + ); + } + final imageFile = File(detail); + if (!imageFile.existsSync()) { + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onChangeCover(CoverType.none, null); + }); + return const SizedBox.shrink(); + } + return Image.file( + imageFile, + fit: BoxFit.cover, + ); + case CoverType.asset: + return Image.asset( + widget.coverDetails!, + fit: BoxFit.cover, + ); + case CoverType.color: + final color = widget.coverDetails?.tryToColor() ?? Colors.white; + return Container(color: color); + case CoverType.none: + return const SizedBox.shrink(); + } + } + + Widget _buildCoverOverlayButtons(BuildContext context) { + return Positioned( + bottom: 20, + right: 50, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppFlowyPopover( + controller: popoverController, + triggerActions: PopoverTriggerFlags.none, + offset: const Offset(0, 8), + direction: PopoverDirection.bottomWithCenterAligned, + constraints: const BoxConstraints( + maxWidth: 540, + maxHeight: 360, + minHeight: 80, + ), + margin: EdgeInsets.zero, + onClose: () => isPopoverOpen = false, + child: IntrinsicWidth( + child: RoundedTextButton( + height: 28.0, + onPressed: () => popoverController.show(), + hoverColor: Theme.of(context).colorScheme.surface, + textColor: Theme.of(context).colorScheme.tertiary, + fillColor: + Theme.of(context).colorScheme.surface.withOpacity(0.5), + title: LocaleKeys.document_plugins_cover_changeCover.tr(), + ), + ), + popupBuilder: (BuildContext popoverContext) { + isPopoverOpen = true; + + return UploadImageMenu( + limitMaximumImageSize: !_isLocalMode(), + supportTypes: const [ + UploadImageType.color, + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + ], + onSelectedLocalImages: (files) { + popoverController.close(); + if (files.isEmpty) { + return; + } + + final item = files.map((file) => file.path).first; + onCoverChanged(CoverType.file, item); + }, + onSelectedAIImage: (_) { + throw UnimplementedError(); + }, + onSelectedNetworkImage: (url) { + popoverController.close(); + onCoverChanged(CoverType.file, url); + }, + onSelectedColor: (color) { + popoverController.close(); + onCoverChanged(CoverType.color, color); + }, + ); + }, + ), + const HSpace(10), + DeleteCoverButton( + onTap: () => onCoverChanged(CoverType.none, null), + ), + ], + ), + ); + } + + Future onCoverChanged(CoverType type, String? details) async { + if (type == CoverType.file && details != null && !isURL(details)) { + if (_isLocalMode()) { + details = await saveImageToLocalStorage(details); + } else { + // else we should save the image to cloud storage + (details, _) = await saveImageToCloudStorage(details, widget.view.id); + } + } + widget.onChangeCover(type, details); + } + + void setOverlayButtonsHidden(bool value) { + if (isOverlayButtonsHidden == value) return; + setState(() { + isOverlayButtonsHidden = value; + }); + } + + bool _isLocalMode() { + return context.read().isLocalMode; + } +} + +@visibleForTesting +class DeleteCoverButton extends StatelessWidget { + const DeleteCoverButton({required this.onTap, super.key}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final fillColor = UniversalPlatform.isDesktopOrWeb + ? Theme.of(context).colorScheme.surface.withOpacity(0.5) + : Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.5); + final svgColor = UniversalPlatform.isDesktopOrWeb + ? Theme.of(context).colorScheme.tertiary + : Theme.of(context).colorScheme.onPrimary; + return FlowyIconButton( + hoverColor: Theme.of(context).colorScheme.surface, + fillColor: fillColor, + iconPadding: const EdgeInsets.all(5), + width: 28, + icon: FlowySvg( + FlowySvgs.delete_s, + color: svgColor, + ), + onPressed: onTap, + ); + } +} + +@visibleForTesting +class DocumentIcon extends StatefulWidget { + const DocumentIcon({ + super.key, + required this.node, + required this.editorState, + required this.icon, + required this.onChangeIcon, + }); + + final Node node; + final EditorState editorState; + final EmojiIconData icon; + final ValueChanged onChangeIcon; + + @override + State createState() => _DocumentIconState(); +} + +class _DocumentIconState extends State { + final PopoverController _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + Widget child = EmojiIconWidget( + emoji: widget.icon, + ); + + if (UniversalPlatform.isDesktopOrWeb) { + child = AppFlowyPopover( + direction: PopoverDirection.bottomWithCenterAligned, + controller: _popoverController, + offset: const Offset(0, 8), + constraints: BoxConstraints.loose(const Size(360, 380)), + margin: EdgeInsets.zero, + child: child, + popupBuilder: (BuildContext popoverContext) { + return FlowyIconEmojiPicker( + onSelectedEmoji: (result) { + widget.onChangeIcon(result); + _popoverController.close(); + }, + ); + }, + ); + } else { + child = GestureDetector( + child: child, + onTap: () async { + final result = await context.push( + MobileEmojiPickerScreen.routeName, + ); + if (result != null) { + widget.onChangeIcon(result); + } + }, + ); + } + + return child; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart new file mode 100644 index 0000000000000..77b0c158f5b1d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/base/emoji/emoji_text.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter/material.dart'; + +import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import '../../../../base/icon/icon_widget.dart'; + +class EmojiIconWidget extends StatefulWidget { + const EmojiIconWidget({ + super.key, + required this.emoji, + this.emojiSize = 60, + }); + + final EmojiIconData emoji; + final double emojiSize; + + @override + State createState() => _EmojiIconWidgetState(); +} + +class _EmojiIconWidgetState extends State { + bool hover = true; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setHidden(false), + onExit: (_) => setHidden(true), + cursor: SystemMouseCursors.click, + child: Container( + decoration: BoxDecoration( + color: !hover + ? Theme.of(context).colorScheme.inverseSurface.withOpacity(0.5) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: RawEmojiIconWidget( + emoji: widget.emoji, + emojiSize: widget.emojiSize, + ), + ), + ); + } + + void setHidden(bool value) { + if (hover == value) return; + setState(() { + hover = value; + }); + } +} + +class RawEmojiIconWidget extends StatelessWidget { + const RawEmojiIconWidget({ + super.key, + required this.emoji, + required this.emojiSize, + }); + + final EmojiIconData emoji; + final double emojiSize; + + @override + Widget build(BuildContext context) { + final defaultEmoji = EmojiText( + emoji: '❓', + fontSize: emojiSize, + textAlign: TextAlign.center, + ); + try { + switch (emoji.type) { + case FlowyIconType.emoji: + return EmojiText( + emoji: emoji.emoji, + fontSize: emojiSize, + textAlign: TextAlign.center, + ); + case FlowyIconType.icon: + final iconData = IconsData.fromJson(jsonDecode(emoji.emoji)); + return IconWidget( + data: iconData, + size: emojiSize, + ); + default: + return defaultEmoji; + } + } catch (e) { + Log.error("Display widget error: $e"); + return defaultEmoji; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/heading/heading_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/heading/heading_toolbar_item.dart new file mode 100644 index 0000000000000..c5e1758435c61 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/heading/heading_toolbar_item.dart @@ -0,0 +1,240 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +final _headingData = [ + (FlowySvgs.h1_s, LocaleKeys.editor_heading1.tr()), + (FlowySvgs.h2_s, LocaleKeys.editor_heading2.tr()), + (FlowySvgs.h3_s, LocaleKeys.editor_heading3.tr()), +]; + +final headingsToolbarItem = ToolbarItem( + id: 'editor.headings', + group: 1, + isActive: onlyShowInTextType, + builder: (context, editorState, highlightColor, _, __) { + final selection = editorState.selection!; + final node = editorState.getNodeAtPath(selection.start.path)!; + final delta = (node.delta ?? Delta()).toJson(); + int level = node.attributes[HeadingBlockKeys.level] ?? 1; + final originLevel = level; + final isHighlight = + node.type == HeadingBlockKeys.type && (level >= 1 && level <= 3); + // only supports the level 1 - 3 in the toolbar, ignore the other levels + level = level.clamp(1, 3); + + final svg = _headingData[level - 1].$1; + final message = _headingData[level - 1].$2; + + final child = FlowyTooltip( + message: message, + preferBelow: false, + child: Row( + children: [ + FlowySvg( + svg, + size: const Size.square(18), + color: isHighlight ? highlightColor : Colors.white, + ), + const HSpace(2.0), + const FlowySvg( + FlowySvgs.arrow_down_s, + size: Size.square(12), + color: Colors.grey, + ), + ], + ), + ); + return HeadingPopup( + currentLevel: isHighlight ? level : -1, + highlightColor: highlightColor, + child: child, + onLevelChanged: (newLevel) async { + // same level means cancel the heading + final type = + newLevel == originLevel && node.type == HeadingBlockKeys.type + ? ParagraphBlockKeys.type + : HeadingBlockKeys.type; + + if (type == HeadingBlockKeys.type) { + // from paragraph to heading + final newNode = node.copyWith( + type: type, + attributes: { + HeadingBlockKeys.level: newLevel, + blockComponentBackgroundColor: + node.attributes[blockComponentBackgroundColor], + blockComponentTextDirection: + node.attributes[blockComponentTextDirection], + blockComponentDelta: delta, + }, + ); + final children = node.children.map((child) => child.deepCopy()); + + final transaction = editorState.transaction; + transaction.insertNodes( + selection.start.path.next, + [newNode, ...children], + ); + transaction.deleteNode(node); + await editorState.apply(transaction); + } else { + // from heading to paragraph + await editorState.formatNode( + selection, + (node) => node.copyWith( + type: type, + attributes: { + HeadingBlockKeys.level: newLevel, + blockComponentBackgroundColor: + node.attributes[blockComponentBackgroundColor], + blockComponentTextDirection: + node.attributes[blockComponentTextDirection], + blockComponentDelta: delta, + }, + ), + ); + } + }, + ); + }, +); + +class HeadingPopup extends StatelessWidget { + const HeadingPopup({ + super.key, + required this.currentLevel, + required this.highlightColor, + required this.onLevelChanged, + required this.child, + }); + + final int currentLevel; + final Color highlightColor; + final Function(int level) onLevelChanged; + final Widget child; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + windowPadding: const EdgeInsets.all(0), + margin: const EdgeInsets.symmetric(vertical: 2.0), + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 10), + decorationColor: Theme.of(context).colorScheme.onTertiary, + borderRadius: BorderRadius.circular(6.0), + popupBuilder: (_) { + keepEditorFocusNotifier.increase(); + return _HeadingButtons( + currentLevel: currentLevel, + highlightColor: highlightColor, + onLevelChanged: onLevelChanged, + ); + }, + onClose: () { + keepEditorFocusNotifier.decrease(); + }, + child: FlowyButton( + useIntrinsicWidth: true, + hoverColor: Colors.grey.withOpacity(0.3), + text: child, + ), + ); + } +} + +class _HeadingButtons extends StatelessWidget { + const _HeadingButtons({ + required this.highlightColor, + required this.currentLevel, + required this.onLevelChanged, + }); + + final int currentLevel; + final Color highlightColor; + final Function(int level) onLevelChanged; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 28, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(4), + ..._headingData.mapIndexed((index, data) { + final svg = data.$1; + final message = data.$2; + return [ + HeadingButton( + icon: svg, + tooltip: message, + onTap: () => onLevelChanged(index + 1), + isHighlight: index + 1 == currentLevel, + highlightColor: highlightColor, + ), + index != _headingData.length - 1 + ? const _Divider() + : const SizedBox.shrink(), + ]; + }).flattened, + const HSpace(4), + ], + ), + ); + } +} + +class HeadingButton extends StatelessWidget { + const HeadingButton({ + super.key, + required this.icon, + required this.tooltip, + required this.onTap, + required this.highlightColor, + required this.isHighlight, + }); + + final Color highlightColor; + final FlowySvgData icon; + final String tooltip; + final VoidCallback onTap; + final bool isHighlight; + + @override + Widget build(BuildContext context) { + return FlowyButton( + useIntrinsicWidth: true, + hoverColor: Colors.grey.withOpacity(0.3), + onTap: onTap, + text: FlowyTooltip( + message: tooltip, + preferBelow: true, + child: FlowySvg( + icon, + size: const Size.square(18), + color: isHighlight ? highlightColor : Colors.white, + ), + ), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4), + child: Container( + width: 1, + color: Colors.grey, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/i18n/editor_i18n.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/i18n/editor_i18n.dart new file mode 100644 index 0000000000000..8029e2a8705be --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/i18n/editor_i18n.dart @@ -0,0 +1,673 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class EditorI18n extends AppFlowyEditorL10n { + // static AppFlowyEditorLocalizations current = EditorI18n(); + EditorI18n(); + + @override + String get bold { + return LocaleKeys.editor_bold.tr(); + } + + /// `Bulleted List` + @override + String get bulletedList { + return LocaleKeys.editor_bulletedList.tr(); + } + + /// `Checkbox` + @override + String get checkbox { + return LocaleKeys.editor_checkbox.tr(); + } + + /// `Embed Code` + @override + String get embedCode { + return LocaleKeys.editor_embedCode.tr(); + } + + /// `H1` + @override + String get heading1 { + return LocaleKeys.editor_heading1.tr(); + } + + /// `H2` + @override + String get heading2 { + return LocaleKeys.editor_heading2.tr(); + } + + /// `H3` + @override + String get heading3 { + return LocaleKeys.editor_heading3.tr(); + } + + /// `Highlight` + @override + String get highlight { + return LocaleKeys.editor_highlight.tr(); + } + + /// `Color` + @override + String get color { + return LocaleKeys.editor_color.tr(); + } + + /// `Image` + @override + String get image { + return LocaleKeys.editor_image.tr(); + } + + /// `Italic` + @override + String get italic { + return LocaleKeys.editor_italic.tr(); + } + + /// `Link` + @override + String get link { + return LocaleKeys.editor_link.tr(); + } + + /// `Numbered List` + @override + String get numberedList { + return LocaleKeys.editor_numberedList.tr(); + } + + /// `Quote` + @override + String get quote { + return LocaleKeys.editor_quote.tr(); + } + + /// `Strikethrough` + @override + String get strikethrough { + return LocaleKeys.editor_strikethrough.tr(); + } + + /// `Text` + @override + String get text { + return LocaleKeys.editor_text.tr(); + } + + /// `Underline` + @override + String get underline { + return LocaleKeys.editor_underline.tr(); + } + + /// `Default` + @override + String get fontColorDefault { + return LocaleKeys.editor_fontColorDefault.tr(); + } + + /// `Gray` + @override + String get fontColorGray { + return LocaleKeys.editor_fontColorGray.tr(); + } + + /// `Brown` + @override + String get fontColorBrown { + return LocaleKeys.editor_fontColorBrown.tr(); + } + + /// `Orange` + @override + String get fontColorOrange { + return LocaleKeys.editor_fontColorOrange.tr(); + } + + /// `Yellow` + @override + String get fontColorYellow { + return LocaleKeys.editor_fontColorYellow.tr(); + } + + /// `Green` + @override + String get fontColorGreen { + return LocaleKeys.editor_fontColorGreen.tr(); + } + + /// `Blue` + @override + String get fontColorBlue { + return LocaleKeys.editor_fontColorBlue.tr(); + } + + /// `Purple` + @override + String get fontColorPurple { + return LocaleKeys.editor_fontColorPurple.tr(); + } + + /// `Pink` + @override + String get fontColorPink { + return LocaleKeys.editor_fontColorPink.tr(); + } + + /// `Red` + @override + String get fontColorRed { + return LocaleKeys.editor_fontColorRed.tr(); + } + + /// `Default background` + @override + String get backgroundColorDefault { + return LocaleKeys.editor_backgroundColorDefault.tr(); + } + + /// `Gray background` + @override + String get backgroundColorGray { + return LocaleKeys.editor_backgroundColorGray.tr(); + } + + /// `Brown background` + @override + String get backgroundColorBrown { + return LocaleKeys.editor_backgroundColorBrown.tr(); + } + + /// `Orange background` + @override + String get backgroundColorOrange { + return LocaleKeys.editor_backgroundColorOrange.tr(); + } + + /// `Yellow background` + @override + String get backgroundColorYellow { + return LocaleKeys.editor_backgroundColorYellow.tr(); + } + + /// `Green background` + @override + String get backgroundColorGreen { + return LocaleKeys.editor_backgroundColorGreen.tr(); + } + + /// `Blue background` + @override + String get backgroundColorBlue { + return LocaleKeys.editor_backgroundColorBlue.tr(); + } + + /// `Purple background` + @override + String get backgroundColorPurple { + return LocaleKeys.editor_backgroundColorPurple.tr(); + } + + /// `Pink background` + @override + String get backgroundColorPink { + return LocaleKeys.editor_backgroundColorPink.tr(); + } + + /// `Red background` + @override + String get backgroundColorRed { + return LocaleKeys.editor_backgroundColorRed.tr(); + } + + /// `Done` + @override + String get done { + return LocaleKeys.editor_done.tr(); + } + + /// `Cancel` + @override + String get cancel { + return LocaleKeys.editor_cancel.tr(); + } + + /// `Tint 1` + @override + String get tint1 { + return LocaleKeys.editor_tint1.tr(); + } + + /// `Tint 2` + @override + String get tint2 { + return LocaleKeys.editor_tint2.tr(); + } + + /// `Tint 3` + @override + String get tint3 { + return LocaleKeys.editor_tint3.tr(); + } + + /// `Tint 4` + @override + String get tint4 { + return LocaleKeys.editor_tint4.tr(); + } + + /// `Tint 5` + @override + String get tint5 { + return LocaleKeys.editor_tint5.tr(); + } + + /// `Tint 6` + @override + String get tint6 { + return LocaleKeys.editor_tint6.tr(); + } + + /// `Tint 7` + @override + String get tint7 { + return LocaleKeys.editor_tint7.tr(); + } + + /// `Tint 8` + @override + String get tint8 { + return LocaleKeys.editor_tint8.tr(); + } + + /// `Tint 9` + @override + String get tint9 { + return LocaleKeys.editor_tint9.tr(); + } + + /// `Purple` + @override + String get lightLightTint1 { + return LocaleKeys.editor_lightLightTint1.tr(); + } + + /// `Pink` + @override + String get lightLightTint2 { + return LocaleKeys.editor_lightLightTint2.tr(); + } + + /// `Light Pink` + @override + String get lightLightTint3 { + return LocaleKeys.editor_lightLightTint3.tr(); + } + + /// `Orange` + @override + String get lightLightTint4 { + return LocaleKeys.editor_lightLightTint4.tr(); + } + + /// `Yellow` + @override + String get lightLightTint5 { + return LocaleKeys.editor_lightLightTint5.tr(); + } + + /// `Lime` + @override + String get lightLightTint6 { + return LocaleKeys.editor_lightLightTint6.tr(); + } + + /// `Green` + @override + String get lightLightTint7 { + return LocaleKeys.editor_lightLightTint7.tr(); + } + + /// `Aqua` + @override + String get lightLightTint8 { + return LocaleKeys.editor_lightLightTint8.tr(); + } + + /// `Blue` + @override + String get lightLightTint9 { + return LocaleKeys.editor_lightLightTint9.tr(); + } + + /// `URL` + @override + String get urlHint { + return LocaleKeys.editor_urlHint.tr(); + } + + /// `Heading 1` + @override + String get mobileHeading1 { + return LocaleKeys.editor_mobileHeading1.tr(); + } + + /// `Heading 2` + @override + String get mobileHeading2 { + return LocaleKeys.editor_mobileHeading2.tr(); + } + + /// `Heading 3` + @override + String get mobileHeading3 { + return LocaleKeys.editor_mobileHeading3.tr(); + } + + /// `Text Color` + @override + String get textColor { + return LocaleKeys.editor_textColor.tr(); + } + + /// `Background Color` + @override + String get backgroundColor { + return LocaleKeys.editor_backgroundColor.tr(); + } + + /// `Add your link` + @override + String get addYourLink { + return LocaleKeys.editor_addYourLink.tr(); + } + + /// `Open link` + @override + String get openLink { + return LocaleKeys.editor_openLink.tr(); + } + + /// `Copy link` + @override + String get copyLink { + return LocaleKeys.editor_copyLink.tr(); + } + + /// `Remove link` + @override + String get removeLink { + return LocaleKeys.editor_removeLink.tr(); + } + + /// `Edit link` + @override + String get editLink { + return LocaleKeys.editor_editLink.tr(); + } + + /// `Text` + @override + String get linkText { + return LocaleKeys.editor_linkText.tr(); + } + + /// `Please enter text` + @override + String get linkTextHint { + return LocaleKeys.editor_linkTextHint.tr(); + } + + /// `Please enter URL` + @override + String get linkAddressHint { + return LocaleKeys.editor_linkAddressHint.tr(); + } + + /// `Highlight color` + @override + String get highlightColor { + return LocaleKeys.editor_highlightColor.tr(); + } + + /// `Clear highlight color` + @override + String get clearHighlightColor { + return LocaleKeys.editor_clearHighlightColor.tr(); + } + + /// `Custom color` + @override + String get customColor { + return LocaleKeys.editor_customColor.tr(); + } + + /// `Hex value` + @override + String get hexValue { + return LocaleKeys.editor_hexValue.tr(); + } + + /// `Opacity` + @override + String get opacity { + return LocaleKeys.editor_opacity.tr(); + } + + /// `Reset to default color` + @override + String get resetToDefaultColor { + return LocaleKeys.editor_resetToDefaultColor.tr(); + } + + /// `LTR` + @override + String get ltr { + return LocaleKeys.editor_ltr.tr(); + } + + /// `RTL` + @override + String get rtl { + return LocaleKeys.editor_rtl.tr(); + } + + /// `Auto` + @override + String get auto { + return LocaleKeys.editor_auto.tr(); + } + + /// `Cut` + @override + String get cut { + return LocaleKeys.editor_cut.tr(); + } + + /// `Copy` + @override + String get copy { + return LocaleKeys.editor_copy.tr(); + } + + /// `Paste` + @override + String get paste { + return LocaleKeys.editor_paste.tr(); + } + + /// `Find` + @override + String get find { + return LocaleKeys.editor_find.tr(); + } + + /// `Previous match` + @override + String get previousMatch { + return LocaleKeys.editor_previousMatch.tr(); + } + + /// `Next match` + @override + String get nextMatch { + return LocaleKeys.editor_nextMatch.tr(); + } + + /// `Close` + @override + String get closeFind { + return LocaleKeys.editor_closeFind.tr(); + } + + /// `Replace` + @override + String get replace { + return LocaleKeys.editor_replace.tr(); + } + + /// `Replace all` + @override + String get replaceAll { + return LocaleKeys.editor_replaceAll.tr(); + } + + /// `Regex` + @override + String get regex { + return LocaleKeys.editor_regex.tr(); + } + + /// `Case sensitive` + @override + String get caseSensitive { + return LocaleKeys.editor_caseSensitive.tr(); + } + + /// `Upload Image` + @override + String get uploadImage { + return LocaleKeys.editor_uploadImage.tr(); + } + + /// `URL Image` + @override + String get urlImage { + return LocaleKeys.editor_urlImage.tr(); + } + + /// `Incorrect Link` + @override + String get incorrectLink { + return LocaleKeys.editor_incorrectLink.tr(); + } + + /// `Upload` + @override + String get upload { + return LocaleKeys.editor_upload.tr(); + } + + /// `Choose an image` + @override + String get chooseImage { + return LocaleKeys.editor_chooseImage.tr(); + } + + /// `Loading` + @override + String get loading { + return LocaleKeys.editor_loading.tr(); + } + + /// `Could not load the image` + @override + String get imageLoadFailed { + return LocaleKeys.editor_imageLoadFailed.tr(); + } + + /// `Divider` + @override + String get divider { + return LocaleKeys.editor_divider.tr(); + } + + /// `Table` + @override + String get table { + return LocaleKeys.editor_table.tr(); + } + + /// `Add before` + @override + String get colAddBefore { + return LocaleKeys.editor_colAddBefore.tr(); + } + + /// `Add before` + @override + String get rowAddBefore { + return LocaleKeys.editor_rowAddBefore.tr(); + } + + /// `Add after` + @override + String get colAddAfter { + return LocaleKeys.editor_colAddAfter.tr(); + } + + /// `Add after` + @override + String get rowAddAfter { + return LocaleKeys.editor_rowAddAfter.tr(); + } + + /// `Remove` + @override + String get colRemove { + return LocaleKeys.editor_colRemove.tr(); + } + + /// `Remove` + @override + String get rowRemove { + return LocaleKeys.editor_rowRemove.tr(); + } + + /// `Duplicate` + @override + String get colDuplicate { + return LocaleKeys.editor_colDuplicate.tr(); + } + + /// `Duplicate` + @override + String get rowDuplicate { + return LocaleKeys.editor_rowDuplicate.tr(); + } + + /// `Clear Content` + @override + String get colClear { + return LocaleKeys.editor_colClear.tr(); + } + + /// `Clear Content` + @override + String get rowClear { + return LocaleKeys.editor_rowClear.tr(); + } + + /// `Enter a / to insert a block, or start typing` + @override + String get slashPlaceHolder { + return LocaleKeys.editor_slashPlaceHolder.tr(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/common.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/common.dart new file mode 100644 index 0000000000000..24e10f229c2e4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/common.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; + +enum CustomImageType { + local, + internal, // the images saved in self-host cloud + external; // the images linked from network, like unsplash, https://xxx/yyy/zzz.jpg + + static CustomImageType fromIntValue(int value) { + switch (value) { + case 0: + return CustomImageType.local; + case 1: + return CustomImageType.internal; + case 2: + return CustomImageType.external; + default: + throw UnimplementedError(); + } + } + + int toIntValue() { + switch (this) { + case CustomImageType.local: + return 0; + case CustomImageType.internal: + return 1; + case CustomImageType.external: + return 2; + } + } +} + +class ImageBlockData { + factory ImageBlockData.fromJson(Map json) { + return ImageBlockData( + url: json['url'] as String? ?? '', + type: CustomImageType.fromIntValue(json['type'] as int), + ); + } + + ImageBlockData({required this.url, required this.type}); + + final String url; + final CustomImageType type; + + bool get isLocal => type == CustomImageType.local; + bool get isNotInternal => type != CustomImageType.internal; + + Map toJson() { + return {'url': url, 'type': type.toIntValue()}; + } + + ImageProvider toImageProvider() { + switch (type) { + case CustomImageType.internal: + case CustomImageType.external: + return NetworkImage(url); + case CustomImageType.local: + return FileImage(File(url)); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart new file mode 100644 index 0000000000000..7a68ad99b98df --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart @@ -0,0 +1,410 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../common.dart'; + +const kImagePlaceholderKey = 'imagePlaceholderKey'; + +class CustomImageBlockKeys { + const CustomImageBlockKeys._(); + + static const String type = 'image'; + + /// The align data of a image block. + /// + /// The value is a String. + /// left, center, right + static const String align = 'align'; + + /// The image src of a image block. + /// + /// The value is a String. + /// It can be a url or a base64 string(web). + static const String url = 'url'; + + /// The height of a image block. + /// + /// The value is a double. + static const String width = 'width'; + + /// The width of a image block. + /// + /// The value is a double. + static const String height = 'height'; + + /// The image type of a image block. + /// + /// The value is a CustomImageType enum. + static const String imageType = 'image_type'; +} + +Node customImageNode({ + required String url, + String align = 'center', + double? height, + double? width, + CustomImageType type = CustomImageType.local, +}) { + return Node( + type: CustomImageBlockKeys.type, + attributes: { + CustomImageBlockKeys.url: url, + CustomImageBlockKeys.align: align, + CustomImageBlockKeys.height: height, + CustomImageBlockKeys.width: width, + CustomImageBlockKeys.imageType: type.toIntValue(), + }, + ); +} + +typedef CustomImageBlockComponentMenuBuilder = Widget Function( + Node node, + CustomImageBlockComponentState state, +); + +class CustomImageBlockComponentBuilder extends BlockComponentBuilder { + CustomImageBlockComponentBuilder({ + super.configuration, + this.showMenu = false, + this.menuBuilder, + }); + + /// Whether to show the menu of this block component. + final bool showMenu; + + /// + final CustomImageBlockComponentMenuBuilder? menuBuilder; + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return CustomImageBlockComponent( + key: node.key, + node: node, + showActions: showActions(node), + configuration: configuration, + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + showMenu: showMenu, + menuBuilder: menuBuilder, + ); + } + + @override + BlockComponentValidate get validate => (node) => node.children.isEmpty; +} + +class CustomImageBlockComponent extends BlockComponentStatefulWidget { + const CustomImageBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + this.showMenu = false, + this.menuBuilder, + }); + + /// Whether to show the menu of this block component. + final bool showMenu; + + final CustomImageBlockComponentMenuBuilder? menuBuilder; + + @override + State createState() => + CustomImageBlockComponentState(); +} + +class CustomImageBlockComponentState extends State + with SelectableMixin, BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + final imageKey = GlobalKey(); + RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; + + late final editorState = Provider.of(context, listen: false); + + final showActionsNotifier = ValueNotifier(false); + + bool alwaysShowMenu = false; + + @override + Widget build(BuildContext context) { + final node = widget.node; + final attributes = node.attributes; + final src = attributes[CustomImageBlockKeys.url]; + + final alignment = AlignmentExtension.fromString( + attributes[CustomImageBlockKeys.align] ?? 'center', + ); + final width = attributes[CustomImageBlockKeys.width]?.toDouble() ?? + MediaQuery.of(context).size.width; + final height = attributes[CustomImageBlockKeys.height]?.toDouble(); + final rawImageType = attributes[CustomImageBlockKeys.imageType] ?? 0; + final imageType = CustomImageType.fromIntValue(rawImageType); + + final imagePlaceholderKey = node.extraInfos?[kImagePlaceholderKey]; + Widget child; + if (src.isEmpty) { + child = ImagePlaceholder( + key: imagePlaceholderKey is GlobalKey ? imagePlaceholderKey : null, + node: node, + ); + } else if (imageType != CustomImageType.internal && + !_checkIfURLIsValid(src)) { + child = const UnsupportedImageWidget(); + } else { + child = ResizableImage( + src: src, + width: width, + height: height, + editable: editorState.editable, + alignment: alignment, + type: imageType, + onDoubleTap: () => showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfilePB, + imageProvider: AFBlockImageProvider( + images: [ImageBlockData(url: src, type: imageType)], + onDeleteImage: (_) async { + final transaction = editorState.transaction..deleteNode(node); + await editorState.apply(transaction); + }, + ), + ), + ), + onResize: (width) { + final transaction = editorState.transaction + ..updateNode(node, {CustomImageBlockKeys.width: width}); + editorState.apply(transaction); + }, + ); + } + + child = Padding( + padding: padding, + child: RepaintBoundary( + key: imageKey, + child: child, + ), + ); + + if (UniversalPlatform.isDesktopOrWeb) { + child = BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + blockColor: editorState.editorStyle.selectionColor, + supportTypes: const [BlockSelectionType.block], + child: child, + ); + } + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + // show a hover menu on desktop or web + if (UniversalPlatform.isDesktopOrWeb) { + if (widget.showMenu && widget.menuBuilder != null) { + child = MouseRegion( + onEnter: (_) => showActionsNotifier.value = true, + onExit: (_) { + if (!alwaysShowMenu) { + showActionsNotifier.value = false; + } + }, + hitTestBehavior: HitTestBehavior.opaque, + opaque: false, + child: ValueListenableBuilder( + valueListenable: showActionsNotifier, + builder: (_, value, child) { + final url = node.attributes[CustomImageBlockKeys.url]; + return Stack( + children: [ + BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: editorState.editorStyle.selectionColor, + child: child!, + ), + if (value && url.isNotEmpty == true) + widget.menuBuilder!(widget.node, this), + ], + ); + }, + child: child, + ), + ); + } + } else { + // show a fixed menu on mobile + child = MobileBlockActionButtons( + showThreeDots: false, + node: node, + editorState: editorState, + extendActionWidgets: _buildExtendActionWidgets(context), + child: child, + ); + } + + return child; + } + + @override + Position start() => Position(path: widget.node.path); + + @override + Position end() => Position(path: widget.node.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + bool get shouldCursorBlink => false; + + @override + CursorStyle get cursorStyle => CursorStyle.cover; + + @override + Rect getBlockRect({ + bool shiftWithBaseOffset = false, + }) { + final imageBox = imageKey.currentContext?.findRenderObject(); + if (imageBox is RenderBox) { + return Offset.zero & imageBox.size; + } + return Rect.zero; + } + + @override + Rect? getCursorRectInPosition( + Position position, { + bool shiftWithBaseOffset = false, + }) { + final rects = getRectsInSelection(Selection.collapsed(position)); + return rects.firstOrNull; + } + + @override + List getRectsInSelection( + Selection selection, { + bool shiftWithBaseOffset = false, + }) { + if (_renderBox == null) { + return []; + } + final parentBox = context.findRenderObject(); + final imageBox = imageKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && imageBox is RenderBox) { + return [ + imageBox.localToGlobal(Offset.zero, ancestor: parentBox) & + imageBox.size, + ]; + } + return [Offset.zero & _renderBox!.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) => Selection.single( + path: widget.node.path, + startOffset: 0, + endOffset: 1, + ); + + @override + Offset localToGlobal( + Offset offset, { + bool shiftWithBaseOffset = false, + }) => + _renderBox!.localToGlobal(offset); + + // only used on mobile platform + List _buildExtendActionWidgets(BuildContext context) { + final String url = widget.node.attributes[CustomImageBlockKeys.url]; + if (!_checkIfURLIsValid(url)) { + return []; + } + + return [ + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.editor_copy.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_field_copy_s, + ), + onTap: () async { + context.pop(); + showToastNotification( + context, + message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), + ); + await getIt().setPlainText(url); + }, + ), + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.document_imageBlock_saveImageToGallery.tr(), + leftIcon: const FlowySvg( + FlowySvgs.image_placeholder_s, + size: Size.square(20), + ), + onTap: () async { + context.pop(); + showSnackBarMessage( + context, + LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), + ); + await getIt().setPlainText(url); + }, + ), + ]; + } + + bool _checkIfURLIsValid(dynamic url) { + if (url is! String) { + return false; + } + + if (url.isEmpty) { + return false; + } + + if (!isURL(url) && !File(url).existsSync()) { + return false; + } + + return true; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart new file mode 100644 index 0000000000000..bffb651d29c45 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/image_menu.dart @@ -0,0 +1,302 @@ +import 'dart:ui'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class ImageMenu extends StatefulWidget { + const ImageMenu({ + super.key, + required this.node, + required this.state, + }); + + final Node node; + final CustomImageBlockComponentState state; + + @override + State createState() => _ImageMenuState(); +} + +class _ImageMenuState extends State { + late final String? url = widget.node.attributes[CustomImageBlockKeys.url]; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + height: 32, + decoration: BoxDecoration( + color: theme.cardColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + borderRadius: BorderRadius.circular(4.0), + ), + child: Row( + children: [ + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(), + iconData: FlowySvgs.full_view_s, + onTap: openFullScreen, + ), + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.editor_copy.tr(), + iconData: FlowySvgs.copy_s, + onTap: copyImageLink, + ), + const HSpace(4), + _ImageAlignButton(node: widget.node, state: widget.state), + const _Divider(), + MenuBlockButton( + tooltip: LocaleKeys.button_delete.tr(), + iconData: FlowySvgs.trash_s, + onTap: deleteImage, + ), + const HSpace(4), + ], + ), + ); + } + + Future copyImageLink() async { + if (url != null) { + // paste the image url and the image data + final imageData = await captureImage(); + + try { + // /image + await getIt().setData( + ClipboardServiceData( + plainText: url!, + image: ('png', imageData), + ), + ); + + if (mounted) { + showToastNotification( + context, + message: LocaleKeys.message_copy_success.tr(), + ); + } + } catch (e) { + if (mounted) { + showToastNotification( + context, + message: LocaleKeys.message_copy_fail.tr(), + type: ToastificationType.error, + ); + } + } + } + } + + Future deleteImage() async { + final node = widget.node; + final editorState = context.read(); + final transaction = editorState.transaction; + transaction.deleteNode(node); + transaction.afterSelection = null; + await editorState.apply(transaction); + } + + void openFullScreen() { + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfilePB, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: url!, + type: CustomImageType.fromIntValue( + widget.node.attributes[CustomImageBlockKeys.imageType] ?? 2, + ), + ), + ], + onDeleteImage: (_) async { + final transaction = widget.state.editorState.transaction; + transaction.deleteNode(widget.node); + await widget.state.editorState.apply(transaction); + }, + ), + ), + ); + } + + Future captureImage() async { + final boundary = widget.state.imageKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + final image = await boundary?.toImage(); + final byteData = await image?.toByteData(format: ImageByteFormat.png); + if (byteData == null) { + return Uint8List(0); + } + return byteData.buffer.asUint8List(); + } +} + +class _ImageAlignButton extends StatefulWidget { + const _ImageAlignButton({required this.node, required this.state}); + + final Node node; + final CustomImageBlockComponentState state; + + @override + State<_ImageAlignButton> createState() => _ImageAlignButtonState(); +} + +const _interceptorKey = 'image-align'; + +class _ImageAlignButtonState extends State<_ImageAlignButton> { + final gestureInterceptor = SelectionGestureInterceptor( + key: _interceptorKey, + canTap: (details) => false, + ); + + String get align => + widget.node.attributes[CustomImageBlockKeys.align] ?? centerAlignmentKey; + final popoverController = PopoverController(); + late final EditorState editorState; + + @override + void initState() { + super.initState(); + editorState = context.read(); + } + + @override + void dispose() { + allowMenuClose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnoreParentGestureWidget( + child: AppFlowyPopover( + onClose: allowMenuClose, + controller: popoverController, + windowPadding: const EdgeInsets.all(0), + margin: const EdgeInsets.all(0), + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 10), + child: MenuBlockButton( + tooltip: LocaleKeys.document_plugins_optionAction_align.tr(), + iconData: iconFor(align), + ), + popupBuilder: (_) { + preventMenuClose(); + return _AlignButtons(onAlignChanged: onAlignChanged); + }, + ), + ); + } + + void onAlignChanged(String align) { + popoverController.close(); + + final transaction = editorState.transaction; + transaction.updateNode(widget.node, {CustomImageBlockKeys.align: align}); + editorState.apply(transaction); + + allowMenuClose(); + } + + void preventMenuClose() { + widget.state.alwaysShowMenu = true; + editorState.service.selectionService.registerGestureInterceptor( + gestureInterceptor, + ); + } + + void allowMenuClose() { + widget.state.alwaysShowMenu = false; + editorState.service.selectionService.unregisterGestureInterceptor( + _interceptorKey, + ); + } + + FlowySvgData iconFor(String alignment) { + switch (alignment) { + case rightAlignmentKey: + return FlowySvgs.align_right_s; + case centerAlignmentKey: + return FlowySvgs.align_center_s; + case leftAlignmentKey: + default: + return FlowySvgs.align_left_s; + } + } +} + +class _AlignButtons extends StatelessWidget { + const _AlignButtons({required this.onAlignChanged}); + + final Function(String align) onAlignChanged; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.document_plugins_optionAction_left, + iconData: FlowySvgs.align_left_s, + onTap: () => onAlignChanged(leftAlignmentKey), + ), + const _Divider(), + MenuBlockButton( + tooltip: LocaleKeys.document_plugins_optionAction_center, + iconData: FlowySvgs.align_center_s, + onTap: () => onAlignChanged(centerAlignmentKey), + ), + const _Divider(), + MenuBlockButton( + tooltip: LocaleKeys.document_plugins_optionAction_right, + iconData: FlowySvgs.align_right_s, + onTap: () => onAlignChanged(rightAlignmentKey), + ), + const HSpace(4), + ], + ), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Container(width: 1, color: Colors.grey), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart new file mode 100644 index 0000000000000..f0310a4aa57b0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/custom_image_block_component/unsupport_image_widget.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; + +class UnsupportedImageWidget extends StatelessWidget { + const UnsupportedImageWidget({super.key}); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: FlowyHover( + style: HoverStyle(borderRadius: BorderRadius.circular(4)), + child: SizedBox( + height: 52, + child: Row( + children: [ + const HSpace(10), + const FlowySvg( + FlowySvgs.image_placeholder_s, + size: Size.square(24), + ), + const HSpace(10), + FlowyText(LocaleKeys.document_imageBlock_unableToLoadImage.tr()), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart new file mode 100644 index 0000000000000..b1c6c94213557 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; + +class MobileImagePickerScreen extends StatelessWidget { + const MobileImagePickerScreen({super.key}); + + static const routeName = '/image_picker'; + + @override + Widget build(BuildContext context) => const ImagePickerPage(); +} + +class ImagePickerPage extends StatelessWidget { + const ImagePickerPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + title: FlowyText.semibold( + LocaleKeys.titleBar_pageIcon.tr(), + fontSize: 14.0, + ), + leading: const AppBarBackButton(), + ), + body: SafeArea( + child: UploadImageMenu( + onSubmitted: (_) {}, + onUpload: (_) {}, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart new file mode 100644 index 0000000000000..df975de731bc4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart @@ -0,0 +1,395 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart'; +import 'package:path/path.dart' as p; +import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class ImagePlaceholder extends StatefulWidget { + const ImagePlaceholder({super.key, required this.node}); + + final Node node; + + @override + State createState() => ImagePlaceholderState(); +} + +class ImagePlaceholderState extends State { + final controller = PopoverController(); + final documentService = DocumentService(); + late final editorState = context.read(); + + bool showLoading = false; + String? errorMessage; + + bool isDraggingFiles = false; + + @override + Widget build(BuildContext context) { + final Widget child = DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + border: isDraggingFiles + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, + ), + child: FlowyHover( + style: HoverStyle( + borderRadius: BorderRadius.circular(4), + ), + child: SizedBox( + height: 52, + child: Row( + children: [ + const HSpace(10), + FlowySvg( + FlowySvgs.slash_menu_icon_image_s, + size: const Size.square(24), + color: Theme.of(context).hintColor, + ), + const HSpace(10), + ..._buildTrailing(context), + ], + ), + ), + ), + ); + + if (UniversalPlatform.isDesktopOrWeb) { + return AppFlowyPopover( + controller: controller, + direction: PopoverDirection.bottomWithCenterAligned, + constraints: const BoxConstraints( + maxWidth: 540, + maxHeight: 360, + minHeight: 80, + ), + clickHandler: PopoverClickHandler.gestureDetector, + popupBuilder: (context) { + return UploadImageMenu( + allowMultipleImages: true, + limitMaximumImageSize: !_isLocalMode(), + supportTypes: const [ + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + ], + onSelectedLocalImages: (files) { + controller.close(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + final List items = List.from( + files + .where((file) => file.path.isNotEmpty) + .map((file) => file.path), + ); + if (items.isNotEmpty) { + await insertMultipleLocalImages(items); + } + }); + }, + onSelectedAIImage: (url) { + controller.close(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + await insertAIImage(url); + }); + }, + onSelectedNetworkImage: (url) { + controller.close(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + await insertNetworkImage(url); + }); + }, + ); + }, + child: DropTarget( + onDragEntered: (_) => setState(() => isDraggingFiles = true), + onDragExited: (_) => setState(() => isDraggingFiles = false), + onDragDone: (details) { + // Only accept files where the mimetype is an image, + // otherwise we assume it's a file we cannot display. + final imageFiles = details.files + .where( + (file) => + file.mimeType?.startsWith('image/') ?? + false || imgExtensionRegex.hasMatch(file.name), + ) + .toList(); + final paths = imageFiles.map((file) => file.path).toList(); + + WidgetsBinding.instance.addPostFrameCallback( + (_) async => insertMultipleLocalImages(paths), + ); + }, + child: child, + ), + ); + } else { + return MobileBlockActionButtons( + node: widget.node, + editorState: editorState, + child: GestureDetector( + onTap: () { + editorState.updateSelectionWithReason(null, extraInfo: {}); + showUploadImageMenu(); + }, + child: child, + ), + ); + } + } + + List _buildTrailing(BuildContext context) { + if (errorMessage != null) { + return [ + Flexible( + child: FlowyText( + '${LocaleKeys.document_plugins_image_imageUploadFailed.tr()}: ${errorMessage!}', + maxLines: 3, + ), + ), + ]; + } else if (showLoading) { + return [ + FlowyText( + LocaleKeys.document_imageBlock_imageIsUploading.tr(), + ), + const HSpace(8), + const CircularProgressIndicator.adaptive(), + ]; + } else { + return [ + Flexible( + child: FlowyText( + UniversalPlatform.isDesktop + ? isDraggingFiles + ? LocaleKeys.document_plugins_image_dropImageToInsert.tr() + : LocaleKeys.document_plugins_image_addAnImageDesktop.tr() + : LocaleKeys.document_plugins_image_addAnImageMobile.tr(), + color: Theme.of(context).hintColor, + ), + ), + ]; + } + } + + void showUploadImageMenu() { + if (UniversalPlatform.isDesktopOrWeb) { + controller.show(); + } else { + final isLocalMode = _isLocalMode(); + showMobileBottomSheet( + context, + title: LocaleKeys.editor_image.tr(), + showHeader: true, + showCloseButton: true, + showDragHandle: true, + builder: (context) { + return Container( + margin: const EdgeInsets.only(top: 12.0), + constraints: const BoxConstraints( + maxHeight: 340, + minHeight: 80, + ), + child: UploadImageMenu( + limitMaximumImageSize: !isLocalMode, + supportTypes: const [ + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + ], + onSelectedLocalImages: (files) async { + context.pop(); + + final items = files + .where((file) => file.path.isNotEmpty) + .map((file) => file.path) + .toList(); + + await insertMultipleLocalImages(items); + }, + onSelectedAIImage: (url) async { + context.pop(); + await insertAIImage(url); + }, + onSelectedNetworkImage: (url) async { + context.pop(); + await insertNetworkImage(url); + }, + ), + ); + }, + ); + } + } + + Future insertMultipleLocalImages(List urls) async { + controller.close(); + + if (urls.isEmpty) { + return; + } + + setState(() { + showLoading = true; + errorMessage = null; + }); + + bool hasError = false; + + if (_isLocalMode()) { + final first = urls.removeAt(0); + final firstPath = await saveImageToLocalStorage(first); + final transaction = editorState.transaction; + transaction.updateNode(widget.node, { + CustomImageBlockKeys.url: firstPath, + CustomImageBlockKeys.imageType: CustomImageType.local.toIntValue(), + }); + + if (urls.isNotEmpty) { + // Create new nodes for the rest of the images: + final paths = await Future.wait(urls.map(saveImageToLocalStorage)); + paths.removeWhere((url) => url == null || url.isEmpty); + + transaction.insertNodes( + widget.node.path.next, + paths.map((url) => customImageNode(url: url!)).toList(), + ); + } + + await editorState.apply(transaction); + } else { + final transaction = editorState.transaction; + + bool isFirst = true; + for (final url in urls) { + // Upload to cloud + final (path, error) = await saveImageToCloudStorage( + url, + context.read().documentId, + ); + + if (error != null) { + hasError = true; + + if (isFirst) { + setState(() => errorMessage = error); + } + + continue; + } + + if (path != null) { + if (isFirst) { + isFirst = false; + transaction.updateNode(widget.node, { + CustomImageBlockKeys.url: path, + CustomImageBlockKeys.imageType: + CustomImageType.internal.toIntValue(), + }); + } else { + transaction.insertNode( + widget.node.path.next, + customImageNode( + url: path, + type: CustomImageType.internal, + ), + ); + } + } + } + + await editorState.apply(transaction); + } + + setState(() => showLoading = false); + + if (hasError && mounted) { + showSnapBar( + context, + LocaleKeys.document_imageBlock_error_multipleImagesFailed.tr(), + ); + } + } + + Future insertAIImage(String url) async { + if (url.isEmpty || !isURL(url)) { + // show error + return showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + } + + final path = await getIt().getPath(); + final imagePath = p.join(path, 'images'); + try { + // create the directory if not exists + final directory = Directory(imagePath); + if (!directory.existsSync()) { + await directory.create(recursive: true); + } + final uri = Uri.parse(url); + final copyToPath = p.join( + imagePath, + '${uuid()}${p.extension(uri.path)}', + ); + + final response = await get(uri); + await File(copyToPath).writeAsBytes(response.bodyBytes); + await insertMultipleLocalImages([copyToPath]); + await File(copyToPath).delete(); + } catch (e) { + Log.error('cannot save image file', e); + } + } + + Future insertNetworkImage(String url) async { + if (url.isEmpty || !isURL(url)) { + // show error + return showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + } + + final transaction = editorState.transaction; + transaction.updateNode(widget.node, { + CustomImageBlockKeys.url: url, + CustomImageBlockKeys.imageType: CustomImageType.external.toIntValue(), + }); + await editorState.apply(transaction); + } + + bool _isLocalMode() { + return context.read().isLocalMode; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart new file mode 100644 index 0000000000000..f41127b78d7fc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_selection_menu.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +final customImageMenuItem = SelectionMenuItem( + getName: () => AppFlowyEditorL10n.current.image, + icon: (_, isSelected, style) => SelectionMenuIconWidget( + name: 'image', + isSelected: isSelected, + style: style, + ), + keywords: ['image', 'picture', 'img', 'photo'], + handler: (editorState, _, __) async { + // use the key to retrieve the state of the image block to show the popover automatically + final imagePlaceholderKey = GlobalKey(); + await editorState.insertEmptyImageBlock(imagePlaceholderKey); + + WidgetsBinding.instance.addPostFrameCallback((_) { + imagePlaceholderKey.currentState?.controller.show(); + }); + }, +); + +final multiImageMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_plugins_photoGallery_name.tr(), + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.image_s, + size: const Size.square(16.0), + isSelected: isSelected, + style: style, + ), + keywords: [ + LocaleKeys.document_plugins_photoGallery_imageKeyword.tr(), + LocaleKeys.document_plugins_photoGallery_imageGalleryKeyword.tr(), + LocaleKeys.document_plugins_photoGallery_photoKeyword.tr(), + LocaleKeys.document_plugins_photoGallery_photoBrowserKeyword.tr(), + LocaleKeys.document_plugins_photoGallery_galleryKeyword.tr(), + ], + handler: (editorState, _, __) async { + final imagePlaceholderKey = GlobalKey(); + await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey); + WidgetsBinding.instance.addPostFrameCallback( + (_) => imagePlaceholderKey.currentState?.controller.show(), + ); + }, +); + +extension InsertImage on EditorState { + Future insertEmptyImageBlock(GlobalKey key) async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final node = getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final emptyImage = imageNode(url: '') + ..extraInfos = {kImagePlaceholderKey: key}; + final transaction = this.transaction; + // if the current node is empty paragraph, replace it with image node + if (node.type == ParagraphBlockKeys.type && + (node.delta?.isEmpty ?? false)) { + transaction + ..insertNode(node.path, emptyImage) + ..deleteNode(node); + } else { + transaction.insertNode(node.path.next, emptyImage); + } + + transaction.afterSelection = + Selection.collapsed(Position(path: node.path.next)); + transaction.selectionExtraInfo = {}; + + return apply(transaction); + } + + Future insertEmptyMultiImageBlock(GlobalKey key) async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final node = getNodeAtPath(selection.end.path); + if (node == null) { + return; + } + final emptyBlock = multiImageNode() + ..extraInfos = {kMultiImagePlaceholderKey: key}; + final transaction = this.transaction; + // if the current node is empty paragraph, replace it with image node + if (node.type == ParagraphBlockKeys.type && + (node.delta?.isEmpty ?? false)) { + transaction + ..insertNode(node.path, emptyBlock) + ..deleteNode(node); + } else { + transaction.insertNode(node.path.next, emptyBlock); + } + + transaction.afterSelection = + Selection.collapsed(Position(path: node.path.next)); + transaction.selectionExtraInfo = {}; + + return apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart new file mode 100644 index 0000000000000..efa5721382fc3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_util.dart @@ -0,0 +1,119 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/shared/custom_image_cache_manager.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/dispatch/error.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:path/path.dart' as p; + +Future saveImageToLocalStorage(String localImagePath) async { + final path = await getIt().getPath(); + final imagePath = p.join( + path, + 'images', + ); + try { + // create the directory if not exists + final directory = Directory(imagePath); + if (!directory.existsSync()) { + await directory.create(recursive: true); + } + final copyToPath = p.join( + imagePath, + '${uuid()}${p.extension(localImagePath)}', + ); + await File(localImagePath).copy( + copyToPath, + ); + return copyToPath; + } catch (e) { + Log.error('cannot save image file', e); + return null; + } +} + +Future<(String? path, String? errorMessage)> saveImageToCloudStorage( + String localImagePath, + String documentId, +) async { + final documentService = DocumentService(); + Log.debug("Uploading image local path: $localImagePath"); + final result = await documentService.uploadFile( + localFilePath: localImagePath, + documentId: documentId, + ); + return result.fold( + (s) async { + await CustomImageCacheManager().putFile( + s.url, + File(localImagePath).readAsBytesSync(), + ); + return (s.url, null); + }, + (err) { + final message = Platform.isIOS + ? LocaleKeys.sideBar_storageLimitDialogTitleIOS.tr() + : LocaleKeys.sideBar_storageLimitDialogTitle.tr(); + if (err.isStorageLimitExceeded) { + return (null, message); + } else { + return (null, err.msg); + } + }, + ); +} + +Future> extractAndUploadImages( + BuildContext context, + List urls, + bool isLocalMode, +) async { + final List images = []; + + bool hasError = false; + for (final url in urls) { + if (url == null || url.isEmpty) { + continue; + } + + String? path; + String? errorMsg; + CustomImageType imageType = CustomImageType.local; + + // If the user is using local authenticator, we save the image to local storage + if (isLocalMode) { + path = await saveImageToLocalStorage(url); + } else { + // Else we save the image to cloud storage + (path, errorMsg) = await saveImageToCloudStorage( + url, + context.read().documentId, + ); + imageType = CustomImageType.internal; + } + + if (path != null && errorMsg == null) { + images.add(ImageBlockData(url: path, type: imageType)); + } else { + hasError = true; + } + } + + if (context.mounted && hasError) { + showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_multipleImagesFailed.tr(), + ); + } + + return images; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/mobile_image_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/mobile_image_toolbar_item.dart new file mode 100644 index 0000000000000..cb7fd457e0456 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/mobile_image_toolbar_item.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +final imageMobileToolbarItem = MobileToolbarItem.action( + itemIconBuilder: (_, __, ___) => const FlowySvg(FlowySvgs.m_toolbar_imae_lg), + actionHandler: (_, editorState) async { + final imagePlaceholderKey = GlobalKey(); + await editorState.insertEmptyImageBlock(imagePlaceholderKey); + + WidgetsBinding.instance.addPostFrameCallback((_) { + imagePlaceholderKey.currentState?.showUploadImageMenu(); + }); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart new file mode 100644 index 0000000000000..66a14d2c4ae41 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flowy_infra/size.dart'; + +@visibleForTesting +class ImageRender extends StatelessWidget { + const ImageRender({ + super.key, + required this.image, + this.userProfile, + this.fit = BoxFit.cover, + this.borderRadius = Corners.s6Border, + }); + + final ImageBlockData image; + final UserProfilePB? userProfile; + final BoxFit fit; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + final child = switch (image.type) { + CustomImageType.internal || CustomImageType.external => FlowyNetworkImage( + url: image.url, + userProfilePB: userProfile, + fit: fit, + ), + CustomImageType.local => Image.file(File(image.url), fit: fit), + }; + + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(borderRadius: borderRadius), + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart new file mode 100644 index 0000000000000..9f6b10cf3c725 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart @@ -0,0 +1,408 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:collection/collection.dart'; +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:provider/provider.dart'; + +import '../image_render.dart'; + +const _thumbnailItemSize = 100.0; + +class ImageBrowserLayout extends ImageBlockMultiLayout { + const ImageBrowserLayout({ + super.key, + required super.node, + required super.editorState, + required super.images, + required super.indexNotifier, + required super.isLocalMode, + required this.onIndexChanged, + }); + + final void Function(int) onIndexChanged; + + @override + State createState() => _ImageBrowserLayoutState(); +} + +class _ImageBrowserLayoutState extends State { + UserProfilePB? _userProfile; + bool isDraggingFiles = false; + + @override + void initState() { + super.initState(); + _userProfile = context.read().state.userProfilePB; + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 400, + width: MediaQuery.of(context).size.width, + child: GestureDetector( + onDoubleTap: () => _openInteractiveViewer(context), + child: ImageRender( + image: widget.images[widget.indexNotifier.value], + userProfile: _userProfile, + fit: BoxFit.contain, + ), + ), + ), + const VSpace(8), + LayoutBuilder( + builder: (context, constraints) { + final maxItems = + (constraints.maxWidth / (_thumbnailItemSize + 4)).floor(); + final items = widget.images.take(maxItems).toList(); + + return Center( + child: Wrap( + children: items.mapIndexed((index, image) { + final isLast = items.last == image; + final amountLeft = widget.images.length - items.length; + if (isLast && amountLeft > 0) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => _openInteractiveViewer( + context, + maxItems - 1, + ), + child: Container( + width: _thumbnailItemSize, + height: _thumbnailItemSize, + padding: const EdgeInsets.all(2), + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + border: Border.all( + width: 2, + color: Theme.of(context).dividerColor, + ), + ), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: Corners.s6Border, + image: image.type == CustomImageType.local + ? DecorationImage( + image: FileImage(File(image.url)), + fit: BoxFit.cover, + opacity: 0.5, + ) + : null, + ), + child: Stack( + children: [ + if (image.type != CustomImageType.local) + Positioned.fill( + child: Container( + clipBehavior: Clip.antiAlias, + decoration: const BoxDecoration( + borderRadius: Corners.s6Border, + ), + child: FlowyNetworkImage( + url: image.url, + userProfilePB: _userProfile, + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.5), + ), + child: Center( + child: FlowyText( + '+$amountLeft', + color: AFThemeExtension.of(context) + .strongText, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => widget.onIndexChanged(index), + child: ThumbnailItem( + images: widget.images, + index: index, + selectedIndex: widget.indexNotifier.value, + userProfile: _userProfile, + onDeleted: () async { + final transaction = + widget.editorState.transaction; + + final images = widget.images.toList(); + images.removeAt(index); + + transaction.updateNode( + widget.node, + { + MultiImageBlockKeys.images: + images.map((e) => e.toJson()).toList(), + MultiImageBlockKeys.layout: widget.node + .attributes[MultiImageBlockKeys.layout], + }, + ); + + await widget.editorState.apply(transaction); + + widget.onIndexChanged( + widget.indexNotifier.value > 0 + ? widget.indexNotifier.value - 1 + : 0, + ); + }, + ), + ), + ); + }).toList(), + ), + ); + }, + ), + ], + ), + Positioned.fill( + child: DropTarget( + onDragEntered: (_) => setState(() => isDraggingFiles = true), + onDragExited: (_) => setState(() => isDraggingFiles = false), + onDragDone: (details) { + setState(() => isDraggingFiles = false); + // Only accept files where the mimetype is an image, + // or the file extension is a known image format, + // otherwise we assume it's a file we cannot display. + final imageFiles = details.files + .where( + (file) => + file.mimeType?.startsWith('image/') ?? + false || imgExtensionRegex.hasMatch(file.name), + ) + .toList(); + final paths = imageFiles.map((file) => file.path).toList(); + WidgetsBinding.instance.addPostFrameCallback( + (_) async => insertLocalImages(paths), + ); + }, + child: !isDraggingFiles + ? const SizedBox.shrink() + : SizedBox.expand( + child: DecoratedBox( + decoration: + BoxDecoration(color: Colors.white.withOpacity(0.5)), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.download_s, + size: Size.square(28), + ), + const HSpace(12), + Flexible( + child: FlowyText( + LocaleKeys + .document_plugins_image_dropImageToInsert + .tr(), + color: AFThemeExtension.of(context).strongText, + fontSize: 22, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + } + + void _openInteractiveViewer(BuildContext context, [int? index]) => showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: _userProfile, + imageProvider: AFBlockImageProvider( + images: widget.images, + initialIndex: index ?? widget.indexNotifier.value, + onDeleteImage: (index) async { + final transaction = widget.editorState.transaction; + final newImages = widget.images.toList(); + newImages.removeAt(index); + + widget.onIndexChanged( + widget.indexNotifier.value > 0 + ? widget.indexNotifier.value - 1 + : 0, + ); + + if (newImages.isNotEmpty) { + transaction.updateNode( + widget.node, + { + MultiImageBlockKeys.images: + newImages.map((e) => e.toJson()).toList(), + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], + }, + ); + } else { + transaction.deleteNode(widget.node); + } + + await widget.editorState.apply(transaction); + }, + ), + ), + ); + + Future insertLocalImages(List urls) async { + if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) { + return; + } + + final isLocalMode = context.read().isLocalMode; + final transaction = widget.editorState.transaction; + final images = await extractAndUploadImages(context, urls, isLocalMode); + if (images.isEmpty) { + return; + } + + final newImages = [...widget.images, ...images]; + final imagesJson = newImages.map((image) => image.toJson()).toList(); + + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: imagesJson, + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], + }); + + await widget.editorState.apply(transaction); + } +} + +@visibleForTesting +class ThumbnailItem extends StatefulWidget { + const ThumbnailItem({ + super.key, + required this.images, + required this.index, + required this.selectedIndex, + required this.onDeleted, + this.userProfile, + }); + + final List images; + final int index; + final int selectedIndex; + final VoidCallback onDeleted; + final UserProfilePB? userProfile; + + @override + State createState() => _ThumbnailItemState(); +} + +class _ThumbnailItemState extends State { + bool isHovering = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + child: Container( + width: _thumbnailItemSize, + height: _thumbnailItemSize, + padding: const EdgeInsets.all(2), + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + border: Border.all( + width: 2, + color: widget.index == widget.selectedIndex + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + ), + ), + child: Stack( + children: [ + Positioned.fill( + child: ImageRender( + image: widget.images[widget.index], + userProfile: widget.userProfile, + ), + ), + Positioned( + top: 4, + right: 4, + child: AnimatedOpacity( + opacity: isHovering ? 1 : 0, + duration: const Duration(milliseconds: 100), + child: FlowyTooltip( + message: LocaleKeys.button_delete.tr(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onDeleted, + child: FlowyHover( + resetHoverOnRebuild: false, + style: HoverStyle( + backgroundColor: Colors.black.withOpacity(0.6), + hoverColor: Colors.black.withOpacity(0.9), + ), + child: const Padding( + padding: EdgeInsets.all(4), + child: FlowySvg( + FlowySvgs.delete_s, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart new file mode 100644 index 0000000000000..1abe57146ebc0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart @@ -0,0 +1,323 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:provider/provider.dart'; + +class ImageGridLayout extends ImageBlockMultiLayout { + const ImageGridLayout({ + super.key, + required super.node, + required super.editorState, + required super.images, + required super.indexNotifier, + required super.isLocalMode, + }); + + @override + State createState() => _ImageGridLayoutState(); +} + +class _ImageGridLayoutState extends State { + @override + Widget build(BuildContext context) { + return StaggeredGridBuilder( + images: widget.images, + onImageDoubleTapped: (index) { + _openInteractiveViewer(context, index); + }, + ); + } + + void _openInteractiveViewer(BuildContext context, int index) => showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfilePB, + imageProvider: AFBlockImageProvider( + images: widget.images, + initialIndex: index, + onDeleteImage: (index) async { + final transaction = widget.editorState.transaction; + final newImages = widget.images.toList(); + newImages.removeAt(index); + + if (newImages.isNotEmpty) { + transaction.updateNode( + widget.node, + { + MultiImageBlockKeys.images: + newImages.map((e) => e.toJson()).toList(), + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], + }, + ); + } else { + transaction.deleteNode(widget.node); + } + + await widget.editorState.apply(transaction); + }, + ), + ), + ); +} + +/// Draws a staggered grid of images, where the pattern is based +/// on the amount of images to fill the grid at all times. +/// +/// They will be alternating depending on the current index of the images, such that +/// the layout is reversed in odd segments. +/// +/// If there are 4 images in the last segment, this layout will be used: +/// ┌─────┐┌─┐┌─┐ +/// │ │└─┘└─┘ +/// │ │┌────┐ +/// └─────┘└────┘ +/// +/// If there are 3 images in the last segment, this layout will be used: +/// ┌─────┐┌────┐ +/// │ │└────┘ +/// │ │┌────┐ +/// └─────┘└────┘ +/// +/// If there are 2 images in the last segment, this layout will be used: +/// ┌─────┐┌─────┐ +/// │ ││ │ +/// └─────┘└─────┘ +/// +/// If there is 1 image in the last segment, this layout will be used: +/// ┌──────────┐ +/// │ │ +/// └──────────┘ +class StaggeredGridBuilder extends StatefulWidget { + const StaggeredGridBuilder({ + super.key, + required this.images, + required this.onImageDoubleTapped, + }); + + final List images; + final void Function(int) onImageDoubleTapped; + + @override + State createState() => _StaggeredGridBuilderState(); +} + +class _StaggeredGridBuilderState extends State { + late final UserProfilePB? _userProfile; + final List> _splitImages = []; + + @override + void initState() { + super.initState(); + _userProfile = context.read().state.userProfilePB; + + for (int i = 0; i < widget.images.length; i += 4) { + final end = (i + 4 < widget.images.length) ? i + 4 : widget.images.length; + _splitImages.add(widget.images.sublist(i, end)); + } + } + + @override + void didUpdateWidget(covariant StaggeredGridBuilder oldWidget) { + if (widget.images.length != oldWidget.images.length) { + _splitImages.clear(); + for (int i = 0; i < widget.images.length; i += 4) { + final end = + (i + 4 < widget.images.length) ? i + 4 : widget.images.length; + _splitImages.add(widget.images.sublist(i, end)); + } + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return StaggeredGrid.count( + crossAxisCount: 4, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + children: + _splitImages.indexed.map(_buildTilesForImages).flattened.toList(), + ); + } + + List _buildTilesForImages((int, List) data) { + final index = data.$1; + final images = data.$2; + + final isReversed = index.isOdd; + + if (images.length == 4) { + return [ + StaggeredGridTile.count( + crossAxisCellCount: isReversed ? 1 : 2, + mainAxisCellCount: isReversed ? 1 : 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[0], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 1, + mainAxisCellCount: 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 1; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[1], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: isReversed ? 2 : 1, + mainAxisCellCount: isReversed ? 2 : 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 2; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[2], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 3; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[3], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + ]; + } else if (images.length == 3) { + return [ + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: isReversed ? 1 : 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[0], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: isReversed ? 2 : 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 1; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[1], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: 1, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 2; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[2], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + ]; + } else if (images.length == 2) { + return [ + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[0], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + StaggeredGridTile.count( + crossAxisCellCount: 2, + mainAxisCellCount: 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4 + 1; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[1], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + ]; + } else { + return [ + StaggeredGridTile.count( + crossAxisCellCount: 4, + mainAxisCellCount: 2, + child: GestureDetector( + onDoubleTap: () { + final imageIndex = index * 4; + widget.onImageDoubleTapped(imageIndex); + }, + child: ImageRender( + image: images[0], + userProfile: _userProfile, + borderRadius: BorderRadius.zero, + ), + ), + ), + ]; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart new file mode 100644 index 0000000000000..00919a20cc0ad --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_grid_layout.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide ResizableImage; + +abstract class ImageBlockMultiLayout extends StatefulWidget { + const ImageBlockMultiLayout({ + super.key, + required this.node, + required this.editorState, + required this.images, + required this.indexNotifier, + required this.isLocalMode, + }); + + final Node node; + final EditorState editorState; + final List images; + final ValueNotifier indexNotifier; + final bool isLocalMode; +} + +class ImageLayoutRender extends StatelessWidget { + const ImageLayoutRender({ + super.key, + required this.node, + required this.editorState, + required this.images, + required this.indexNotifier, + required this.isLocalMode, + required this.onIndexChanged, + }); + + final Node node; + final EditorState editorState; + final List images; + final ValueNotifier indexNotifier; + final bool isLocalMode; + final void Function(int) onIndexChanged; + + @override + Widget build(BuildContext context) { + final layout = _getLayout(); + + return _buildLayout(layout); + } + + MultiImageLayout _getLayout() { + return MultiImageLayout.fromIntValue( + node.attributes[MultiImageBlockKeys.layout] ?? 0, + ); + } + + Widget _buildLayout(MultiImageLayout layout) { + switch (layout) { + case MultiImageLayout.grid: + return ImageGridLayout( + node: node, + editorState: editorState, + images: images, + indexNotifier: indexNotifier, + isLocalMode: isLocalMode, + ); + case MultiImageLayout.browser: + default: + return ImageBrowserLayout( + node: node, + editorState: editorState, + images: images, + indexNotifier: indexNotifier, + isLocalMode: isLocalMode, + onIndexChanged: onIndexChanged, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart new file mode 100644 index 0000000000000..272b492835ec0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart @@ -0,0 +1,371 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +const kMultiImagePlaceholderKey = 'multiImagePlaceholderKey'; + +Node multiImageNode() => Node( + type: MultiImageBlockKeys.type, + attributes: { + MultiImageBlockKeys.images: MultiImageData(images: []).toJson(), + MultiImageBlockKeys.layout: MultiImageLayout.browser.toIntValue(), + }, + ); + +class MultiImageBlockKeys { + const MultiImageBlockKeys._(); + + static const String type = 'multi_image'; + + /// The image data for the block, stored as a JSON encoded list of [ImageBlockData]. + /// + static const String images = 'images'; + + /// The layout of the images. + /// + /// The value is a MultiImageLayout enum. + /// + static const String layout = 'layout'; +} + +typedef MultiImageBlockComponentMenuBuilder = Widget Function( + Node node, + MultiImageBlockComponentState state, + ValueNotifier indexNotifier, + VoidCallback onImageDeleted, +); + +class MultiImageBlockComponentBuilder extends BlockComponentBuilder { + MultiImageBlockComponentBuilder({ + super.configuration, + this.showMenu = false, + this.menuBuilder, + }); + + final bool showMenu; + final MultiImageBlockComponentMenuBuilder? menuBuilder; + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return MultiImageBlockComponent( + key: node.key, + node: node, + showActions: showActions(node), + configuration: configuration, + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + showMenu: showMenu, + menuBuilder: menuBuilder, + ); + } + + @override + BlockComponentValidate get validate => (node) => node.children.isEmpty; +} + +class MultiImageBlockComponent extends BlockComponentStatefulWidget { + const MultiImageBlockComponent({ + super.key, + required super.node, + super.showActions, + this.showMenu = false, + this.menuBuilder, + super.configuration = const BlockComponentConfiguration(), + super.actionBuilder, + }); + + final bool showMenu; + + final MultiImageBlockComponentMenuBuilder? menuBuilder; + + @override + State createState() => + MultiImageBlockComponentState(); +} + +class MultiImageBlockComponentState extends State + with SelectableMixin, BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + final multiImageKey = GlobalKey(); + + RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; + + late final editorState = Provider.of(context, listen: false); + + final showActionsNotifier = ValueNotifier(false); + + ValueNotifier indexNotifier = ValueNotifier(0); + + bool alwaysShowMenu = false; + + static const _interceptorKey = 'multi-image-block-interceptor'; + + late final interceptor = SelectionGestureInterceptor( + key: _interceptorKey, + canTap: (details) => _isTapInBounds(details.globalPosition), + canPanStart: (details) => _isTapInBounds(details.globalPosition), + ); + + @override + void initState() { + super.initState(); + editorState.selectionService.registerGestureInterceptor(interceptor); + } + + @override + void dispose() { + editorState.selectionService.unregisterGestureInterceptor(_interceptorKey); + super.dispose(); + } + + bool _isTapInBounds(Offset offset) { + if (_renderBox == null) { + // We shouldn't block any actions if the render box is not available. + // This has the potential to break taps on the editor completely if we + // accidentally return false here. + return true; + } + + final localPosition = _renderBox!.globalToLocal(offset); + return !_renderBox!.paintBounds.contains(localPosition); + } + + @override + Widget build(BuildContext context) { + final data = MultiImageData.fromJson( + node.attributes[MultiImageBlockKeys.images], + ); + + Widget child; + if (data.images.isEmpty) { + final multiImagePlaceholderKey = + node.extraInfos?[kMultiImagePlaceholderKey]; + + child = MultiImagePlaceholder( + key: multiImagePlaceholderKey is GlobalKey + ? multiImagePlaceholderKey + : null, + node: node, + ); + } else { + child = ImageLayoutRender( + node: node, + images: data.images, + editorState: editorState, + indexNotifier: indexNotifier, + isLocalMode: context.read().isLocalMode, + onIndexChanged: (index) => setState(() => indexNotifier.value = index), + ); + } + + if (UniversalPlatform.isDesktopOrWeb) { + child = BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + blockColor: editorState.editorStyle.selectionColor, + supportTypes: const [BlockSelectionType.block], + child: Padding(key: multiImageKey, padding: padding, child: child), + ); + } else { + child = Padding(key: multiImageKey, padding: padding, child: child); + } + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + if (UniversalPlatform.isDesktopOrWeb) { + if (widget.showMenu && widget.menuBuilder != null) { + child = MouseRegion( + onEnter: (_) => showActionsNotifier.value = true, + onExit: (_) { + if (!alwaysShowMenu) { + showActionsNotifier.value = false; + } + }, + hitTestBehavior: HitTestBehavior.opaque, + opaque: false, + child: ValueListenableBuilder( + valueListenable: showActionsNotifier, + builder: (context, value, child) { + return Stack( + children: [ + BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: editorState.editorStyle.selectionColor, + child: child!, + ), + if (value && data.images.isNotEmpty) + widget.menuBuilder!( + widget.node, + this, + indexNotifier, + () => setState( + () => indexNotifier.value = indexNotifier.value > 0 + ? indexNotifier.value - 1 + : 0, + ), + ), + ], + ); + }, + child: child, + ), + ); + } + } else { + // show a fixed menu on mobile + child = MobileBlockActionButtons( + showThreeDots: false, + node: node, + editorState: editorState, + child: child, + ); + } + + return child; + } + + @override + Position start() => Position(path: widget.node.path); + + @override + Position end() => Position(path: widget.node.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + bool get shouldCursorBlink => false; + + @override + CursorStyle get cursorStyle => CursorStyle.cover; + + @override + Rect getBlockRect({ + bool shiftWithBaseOffset = false, + }) { + final imageBox = multiImageKey.currentContext?.findRenderObject(); + if (imageBox is RenderBox) { + return Offset.zero & imageBox.size; + } + return Rect.zero; + } + + @override + Rect? getCursorRectInPosition( + Position position, { + bool shiftWithBaseOffset = false, + }) { + final rects = getRectsInSelection(Selection.collapsed(position)); + return rects.firstOrNull; + } + + @override + List getRectsInSelection( + Selection selection, { + bool shiftWithBaseOffset = false, + }) { + if (_renderBox == null) { + return []; + } + final parentBox = context.findRenderObject(); + final imageBox = multiImageKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && imageBox is RenderBox) { + return [ + imageBox.localToGlobal(Offset.zero, ancestor: parentBox) & + imageBox.size, + ]; + } + return [Offset.zero & _renderBox!.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) => Selection.single( + path: widget.node.path, + startOffset: 0, + endOffset: 1, + ); + + @override + Offset localToGlobal( + Offset offset, { + bool shiftWithBaseOffset = false, + }) => + _renderBox!.localToGlobal(offset); +} + +/// The data for a multi-image block, primarily used for +/// serializing and deserializing the block's images. +/// +class MultiImageData { + factory MultiImageData.fromJson(List json) { + final images = json + .map((e) => ImageBlockData.fromJson(e as Map)) + .toList(); + return MultiImageData(images: images); + } + + MultiImageData({required this.images}); + + final List images; + + List toJson() => images.map((e) => e.toJson()).toList(); +} + +enum MultiImageLayout { + browser, + grid; + + int toIntValue() { + switch (this) { + case MultiImageLayout.browser: + return 0; + case MultiImageLayout.grid: + return 1; + } + } + + static MultiImageLayout fromIntValue(int value) { + switch (value) { + case 0: + return MultiImageLayout.browser; + case 1: + return MultiImageLayout.grid; + default: + throw UnimplementedError(); + } + } + + String get label => switch (this) { + browser => LocaleKeys.document_plugins_photoGallery_browserLayout.tr(), + grid => LocaleKeys.document_plugins_photoGallery_gridLayout.tr(), + }; + + FlowySvgData get icon => switch (this) { + browser => FlowySvgs.photo_layout_browser_s, + grid => FlowySvgs.photo_layout_grid_s, + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart new file mode 100644 index 0000000000000..8abeaf9e99de3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_menu.dart @@ -0,0 +1,442 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; +import 'package:cross_file/cross_file.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:http/http.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:string_validator/string_validator.dart'; + +const _interceptorKey = 'add-image'; + +class MultiImageMenu extends StatefulWidget { + const MultiImageMenu({ + super.key, + required this.node, + required this.state, + required this.indexNotifier, + this.isLocalMode = true, + required this.onImageDeleted, + }); + + final Node node; + final MultiImageBlockComponentState state; + final ValueNotifier indexNotifier; + final bool isLocalMode; + final VoidCallback onImageDeleted; + + @override + State createState() => _MultiImageMenuState(); +} + +class _MultiImageMenuState extends State { + final gestureInterceptor = SelectionGestureInterceptor( + key: _interceptorKey, + canTap: (details) => false, + ); + + final PopoverController controller = PopoverController(); + final PopoverController layoutController = PopoverController(); + late List images; + late final EditorState editorState; + + @override + void initState() { + super.initState(); + editorState = context.read(); + images = MultiImageData.fromJson( + widget.node.attributes[MultiImageBlockKeys.images] ?? {}, + ).images; + } + + @override + void dispose() { + allowMenuClose(); + controller.close(); + layoutController.close(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant MultiImageMenu oldWidget) { + images = MultiImageData.fromJson( + widget.node.attributes[MultiImageBlockKeys.images] ?? {}, + ).images; + + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final layout = MultiImageLayout.fromIntValue( + widget.node.attributes[MultiImageBlockKeys.layout] ?? 0, + ); + return Container( + height: 32, + decoration: BoxDecoration( + color: theme.cardColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + borderRadius: BorderRadius.circular(4.0), + ), + child: Row( + children: [ + const HSpace(4), + AppFlowyPopover( + controller: controller, + direction: PopoverDirection.bottomWithRightAligned, + onClose: allowMenuClose, + constraints: const BoxConstraints( + maxWidth: 540, + maxHeight: 360, + minHeight: 80, + ), + offset: const Offset(0, 10), + popupBuilder: (context) { + preventMenuClose(); + return UploadImageMenu( + allowMultipleImages: true, + supportTypes: const [ + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + ], + onSelectedLocalImages: insertLocalImages, + onSelectedAIImage: insertAIImage, + onSelectedNetworkImage: insertNetworkImage, + ); + }, + child: MenuBlockButton( + tooltip: + LocaleKeys.document_plugins_photoGallery_addImageTooltip.tr(), + iconData: FlowySvgs.add_s, + onTap: () {}, + ), + ), + const HSpace(4), + AppFlowyPopover( + controller: layoutController, + onClose: allowMenuClose, + direction: PopoverDirection.bottomWithRightAligned, + offset: const Offset(0, 10), + constraints: const BoxConstraints( + maxHeight: 300, + maxWidth: 300, + ), + popupBuilder: (context) { + preventMenuClose(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _LayoutSelector( + selectedLayout: layout, + onSelected: (layout) { + allowMenuClose(); + layoutController.close(); + final transaction = editorState.transaction; + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: + widget.node.attributes[MultiImageBlockKeys.images], + MultiImageBlockKeys.layout: layout.toIntValue(), + }); + editorState.apply(transaction); + }, + ), + ], + ); + }, + child: MenuBlockButton( + tooltip: LocaleKeys + .document_plugins_photoGallery_changeLayoutTooltip + .tr(), + iconData: FlowySvgs.edit_layout_s, + onTap: () {}, + ), + ), + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(), + iconData: FlowySvgs.full_view_s, + onTap: openFullScreen, + ), + + // disable the copy link button if the image is hosted on appflowy cloud + // because the url needs the verification token to be accessible + if (layout == MultiImageLayout.browser && + !images[widget.indexNotifier.value].url.isAppFlowyCloudUrl) ...[ + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.editor_copyLink.tr(), + iconData: FlowySvgs.copy_s, + onTap: copyImageLink, + ), + ], + const _Divider(), + MenuBlockButton( + tooltip: LocaleKeys.document_plugins_photoGallery_deleteBlockTooltip + .tr(), + iconData: FlowySvgs.delete_s, + onTap: deleteImage, + ), + const HSpace(4), + ], + ), + ); + } + + void copyImageLink() { + Clipboard.setData( + ClipboardData(text: images[widget.indexNotifier.value].url), + ); + showSnackBarMessage( + context, + LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(), + ); + } + + Future deleteImage() async { + final node = widget.node; + final editorState = context.read(); + final transaction = editorState.transaction; + transaction.deleteNode(node); + transaction.afterSelection = null; + await editorState.apply(transaction); + } + + void openFullScreen() { + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfilePB, + imageProvider: AFBlockImageProvider( + images: images, + initialIndex: widget.indexNotifier.value, + onDeleteImage: (index) async { + final transaction = editorState.transaction; + final newImages = List.from(images); + newImages.removeAt(index); + + images = newImages; + widget.onImageDeleted(); + + final imagesJson = + newImages.map((image) => image.toJson()).toList(); + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: imagesJson, + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], + }); + + await editorState.apply(transaction); + }, + ), + ), + ); + } + + void preventMenuClose() { + widget.state.alwaysShowMenu = true; + editorState.service.selectionService.registerGestureInterceptor( + gestureInterceptor, + ); + } + + void allowMenuClose() { + widget.state.alwaysShowMenu = false; + editorState.service.selectionService.unregisterGestureInterceptor( + _interceptorKey, + ); + } + + Future insertLocalImages(List files) async { + controller.close(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + final urls = files + .map((file) => file.path) + .where((path) => path.isNotEmpty) + .toList(); + + if (urls.isEmpty || urls.every((url) => url.isEmpty)) { + return; + } + + final transaction = editorState.transaction; + final newImages = + await extractAndUploadImages(context, urls, widget.isLocalMode); + if (newImages.isEmpty) { + return; + } + + final imagesJson = + [...images, ...newImages].map((i) => i.toJson()).toList(); + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: imagesJson, + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], + }); + + await editorState.apply(transaction); + setState(() => images = newImages); + }); + } + + Future insertAIImage(String url) async { + controller.close(); + + if (url.isEmpty || !isURL(url)) { + // show error + return showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + } + + final path = await getIt().getPath(); + final imagePath = p.join(path, 'images'); + try { + // create the directory if not exists + final directory = Directory(imagePath); + if (!directory.existsSync()) { + await directory.create(recursive: true); + } + final uri = Uri.parse(url); + final copyToPath = p.join( + imagePath, + '${uuid()}${p.extension(uri.path)}', + ); + + final response = await get(uri); + await File(copyToPath).writeAsBytes(response.bodyBytes); + await insertLocalImages([XFile(copyToPath)]); + await File(copyToPath).delete(); + } catch (e) { + Log.error('cannot save image file', e); + } + } + + Future insertNetworkImage(String url) async { + controller.close(); + + if (url.isEmpty || !isURL(url)) { + // show error + return showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + } + + final transaction = editorState.transaction; + + final newImages = [ + ...images, + ImageBlockData(url: url, type: CustomImageType.external), + ]; + + final imagesJson = newImages.map((image) => image.toJson()).toList(); + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: imagesJson, + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout], + }); + + await editorState.apply(transaction); + setState(() => images = newImages); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Container(width: 1, color: Colors.grey), + ); + } +} + +class _LayoutSelector extends StatelessWidget { + const _LayoutSelector({ + required this.selectedLayout, + required this.onSelected, + }); + + final MultiImageLayout selectedLayout; + final Function(MultiImageLayout) onSelected; + + @override + Widget build(BuildContext context) { + return SeparatedRow( + separatorBuilder: () => const HSpace(6), + mainAxisSize: MainAxisSize.min, + children: MultiImageLayout.values + .map( + (layout) => MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => onSelected(layout), + child: Container( + height: 80, + width: 80, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all( + width: 2, + color: selectedLayout == layout + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + ), + borderRadius: Corners.s8Border, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlowySvg( + layout.icon, + color: AFThemeExtension.of(context).strongText, + size: const Size.square(24), + ), + const VSpace(6), + FlowyText(layout.label), + ], + ), + ), + ), + ), + ) + .toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart new file mode 100644 index 0000000000000..313022bfaba6f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart @@ -0,0 +1,302 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/application/document_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu; +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class MultiImagePlaceholder extends StatefulWidget { + const MultiImagePlaceholder({super.key, required this.node}); + + final Node node; + + @override + State createState() => MultiImagePlaceholderState(); +} + +class MultiImagePlaceholderState extends State { + final controller = PopoverController(); + final documentService = DocumentService(); + late final editorState = context.read(); + + bool isDraggingFiles = false; + + @override + Widget build(BuildContext context) { + final child = DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + border: isDraggingFiles + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, + ), + child: FlowyHover( + style: HoverStyle( + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + child: Row( + children: [ + FlowySvg( + FlowySvgs.slash_menu_icon_photo_gallery_s, + color: Theme.of(context).hintColor, + size: const Size.square(24), + ), + const HSpace(10), + FlowyText( + UniversalPlatform.isDesktop + ? isDraggingFiles + ? LocaleKeys.document_plugins_image_dropImageToInsert + .tr() + : LocaleKeys.document_plugins_image_addAnImageDesktop + .tr() + : LocaleKeys.document_plugins_image_addAnImageMobile.tr(), + color: Theme.of(context).hintColor, + ), + ], + ), + ), + ), + ); + + if (UniversalPlatform.isDesktopOrWeb) { + return AppFlowyPopover( + controller: controller, + direction: PopoverDirection.bottomWithCenterAligned, + constraints: const BoxConstraints( + maxWidth: 540, + maxHeight: 360, + minHeight: 80, + ), + clickHandler: PopoverClickHandler.gestureDetector, + popupBuilder: (_) { + return UploadImageMenu( + allowMultipleImages: true, + limitMaximumImageSize: !_isLocalMode(), + supportTypes: const [ + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + ], + onSelectedLocalImages: (files) { + controller.close(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + final paths = files.map((file) => file.path).toList(); + await insertLocalImages(paths); + }); + }, + onSelectedAIImage: (url) { + controller.close(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + await insertAIImage(url); + }); + }, + onSelectedNetworkImage: (url) { + controller.close(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + await insertNetworkImage(url); + }); + }, + ); + }, + child: DropTarget( + onDragEntered: (_) => setState(() => isDraggingFiles = true), + onDragExited: (_) => setState(() => isDraggingFiles = false), + onDragDone: (details) { + // Only accept files where the mimetype is an image, + // or the file extension is a known image format, + // otherwise we assume it's a file we cannot display. + final imageFiles = details.files + .where( + (file) => + file.mimeType?.startsWith('image/') ?? + false || imgExtensionRegex.hasMatch(file.name), + ) + .toList(); + final paths = imageFiles.map((file) => file.path).toList(); + WidgetsBinding.instance.addPostFrameCallback( + (_) async => insertLocalImages(paths), + ); + }, + child: child, + ), + ); + } else { + return MobileBlockActionButtons( + node: widget.node, + editorState: editorState, + child: GestureDetector( + onTap: () { + editorState.updateSelectionWithReason(null, extraInfo: {}); + showUploadImageMenu(); + }, + child: child, + ), + ); + } + } + + void showUploadImageMenu() { + if (UniversalPlatform.isDesktopOrWeb) { + controller.show(); + } else { + final isLocalMode = _isLocalMode(); + showMobileBottomSheet( + context, + title: LocaleKeys.editor_image.tr(), + showHeader: true, + showCloseButton: true, + showDragHandle: true, + builder: (context) { + return Container( + margin: const EdgeInsets.only(top: 12.0), + constraints: const BoxConstraints( + maxHeight: 340, + minHeight: 80, + ), + child: UploadImageMenu( + limitMaximumImageSize: !isLocalMode, + allowMultipleImages: true, + supportTypes: const [ + UploadImageType.local, + UploadImageType.url, + UploadImageType.unsplash, + ], + onSelectedLocalImages: (files) async { + context.pop(); + final items = files.map((file) => file.path).toList(); + await insertLocalImages(items); + }, + onSelectedAIImage: (url) async { + context.pop(); + await insertAIImage(url); + }, + onSelectedNetworkImage: (url) async { + context.pop(); + await insertNetworkImage(url); + }, + ), + ); + }, + ); + } + } + + Future insertLocalImages(List urls) async { + controller.close(); + + if (urls.isEmpty || urls.every((path) => path?.isEmpty ?? true)) { + return; + } + + final transaction = editorState.transaction; + final images = await extractAndUploadImages(context, urls, _isLocalMode()); + if (images.isEmpty) { + return; + } + + final imagesJson = images.map((image) => image.toJson()).toList(); + + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: imagesJson, + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout] ?? + MultiImageLayout.browser.toIntValue(), + }); + + await editorState.apply(transaction); + } + + Future insertAIImage(String url) async { + if (url.isEmpty || !isURL(url)) { + // show error + return showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + } + + final path = await getIt().getPath(); + final imagePath = p.join(path, 'images'); + try { + // create the directory if not exists + final directory = Directory(imagePath); + if (!directory.existsSync()) { + await directory.create(recursive: true); + } + final uri = Uri.parse(url); + final copyToPath = p.join( + imagePath, + '${uuid()}${p.extension(uri.path)}', + ); + + final response = await get(uri); + await File(copyToPath).writeAsBytes(response.bodyBytes); + await insertLocalImages([copyToPath]); + await File(copyToPath).delete(); + } catch (e) { + Log.error('cannot save image file', e); + } + } + + Future insertNetworkImage(String url) async { + if (url.isEmpty || !isURL(url)) { + // show error + return showSnackBarMessage( + context, + LocaleKeys.document_imageBlock_error_invalidImage.tr(), + ); + } + + final transaction = editorState.transaction; + + final images = [ + ImageBlockData( + url: url, + type: CustomImageType.external, + ), + ]; + + transaction.updateNode(widget.node, { + MultiImageBlockKeys.images: + images.map((image) => image.toJson()).toList(), + MultiImageBlockKeys.layout: + widget.node.attributes[MultiImageBlockKeys.layout] ?? + MultiImageLayout.browser.toIntValue(), + }); + await editorState.apply(transaction); + } + + bool _isLocalMode() { + return context.read().isLocalMode; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart new file mode 100644 index 0000000000000..607e1d4d06653 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/resizeable_image.dart @@ -0,0 +1,258 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:string_validator/string_validator.dart'; + +class ResizableImage extends StatefulWidget { + const ResizableImage({ + super.key, + required this.type, + required this.alignment, + required this.editable, + required this.onResize, + required this.width, + required this.src, + this.height, + this.onDoubleTap, + }); + + final String src; + final CustomImageType type; + final double width; + final double? height; + final Alignment alignment; + final bool editable; + final VoidCallback? onDoubleTap; + + final void Function(double width) onResize; + + @override + State createState() => _ResizableImageState(); +} + +const _kImageBlockComponentMinWidth = 30.0; + +class _ResizableImageState extends State { + final documentService = DocumentService(); + + double initialOffset = 0; + double moveDistance = 0; + Widget? _cacheImage; + + late double imageWidth; + + @visibleForTesting + bool onFocus = false; + + UserProfilePB? _userProfilePB; + + @override + void initState() { + super.initState(); + imageWidth = widget.width; + _userProfilePB = context.read()?.state.userProfilePB; + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: widget.alignment, + child: SizedBox( + width: max(_kImageBlockComponentMinWidth, imageWidth - moveDistance), + height: widget.height, + child: MouseRegion( + onEnter: (_) => setState(() => onFocus = true), + onExit: (_) => setState(() => onFocus = false), + child: GestureDetector( + onDoubleTap: widget.onDoubleTap, + child: _buildResizableImage(context), + ), + ), + ), + ); + } + + Widget _buildResizableImage(BuildContext context) { + Widget child; + final src = widget.src; + if (isURL(src)) { + // load network image + if (widget.type == CustomImageType.internal && _userProfilePB == null) { + return _buildLoading(context); + } + + _cacheImage = FlowyNetworkImage( + url: widget.src, + width: imageWidth - moveDistance, + userProfilePB: _userProfilePB, + progressIndicatorBuilder: (context, _, __) => _buildLoading(context), + errorWidgetBuilder: (_, __, error) => _ImageLoadFailedWidget( + width: imageWidth, + error: error, + ), + ); + + child = _cacheImage!; + } else { + // load local file + _cacheImage ??= Image.file(File(src)); + child = _cacheImage!; + } + return Stack( + children: [ + child, + if (widget.editable) ...[ + _buildEdgeGesture( + context, + top: 0, + left: 5, + bottom: 0, + width: 5, + onUpdate: (distance) => setState(() => moveDistance = distance), + ), + _buildEdgeGesture( + context, + top: 0, + right: 5, + bottom: 0, + width: 5, + onUpdate: (distance) => setState(() => moveDistance = -distance), + ), + ], + ], + ); + } + + Widget _buildLoading(BuildContext context) { + return SizedBox( + height: 150, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.fromSize( + size: const Size(18, 18), + child: const CircularProgressIndicator(), + ), + SizedBox.fromSize(size: const Size(10, 10)), + Text(AppFlowyEditorL10n.current.loading), + ], + ), + ); + } + + Widget _buildEdgeGesture( + BuildContext context, { + double? top, + double? left, + double? right, + double? bottom, + double? width, + void Function(double distance)? onUpdate, + }) { + return Positioned( + top: top, + left: left, + right: right, + bottom: bottom, + width: width, + child: GestureDetector( + onHorizontalDragStart: (details) { + initialOffset = details.globalPosition.dx; + }, + onHorizontalDragUpdate: (details) { + if (onUpdate != null) { + double offset = details.globalPosition.dx - initialOffset; + if (widget.alignment == Alignment.center) { + offset *= 2.0; + } + onUpdate(offset); + } + }, + onHorizontalDragEnd: (details) { + imageWidth = + max(_kImageBlockComponentMinWidth, imageWidth - moveDistance); + initialOffset = 0; + moveDistance = 0; + + widget.onResize(imageWidth); + }, + child: MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: onFocus + ? Center( + child: Container( + height: 40, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: const BorderRadius.all( + Radius.circular(5.0), + ), + border: Border.all(color: Colors.white), + ), + ), + ) + : null, + ), + ), + ); + } +} + +class _ImageLoadFailedWidget extends StatelessWidget { + const _ImageLoadFailedWidget({required this.width, required this.error}); + + final double width; + final Object error; + + @override + Widget build(BuildContext context) { + final error = _getErrorMessage(); + return Container( + height: 140, + width: width, + alignment: Alignment.center, + padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4.0)), + border: Border.all(color: Colors.grey.withOpacity(0.6)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.broken_image_xl, + size: Size.square(48), + ), + FlowyText(AppFlowyEditorL10n.current.imageLoadFailed), + const VSpace(6), + if (error != null) + FlowyText( + error, + textAlign: TextAlign.center, + color: Theme.of(context).hintColor.withOpacity(0.6), + fontSize: 10, + maxLines: 2, + ), + ], + ), + ); + } + + String? _getErrorMessage() { + if (error is HttpExceptionWithStatus) { + return 'Error ${(error as HttpExceptionWithStatus).statusCode}'; + } + + return null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart new file mode 100644 index 0000000000000..949e946188e09 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart @@ -0,0 +1,259 @@ +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:unsplash_client/unsplash_client.dart'; + +const _accessKeyA = 'YyD-LbW5bVolHWZBq5fWRM_'; +const _accessKeyB = '3ezkG2XchRFjhNTnK9TE'; +const _secretKeyA = '5z4EnxaXjWjWMnuBhc0Ku0u'; +const _secretKeyB = 'YW2bsYCZlO-REZaqmV6A'; + +enum UnsplashImageType { + // the creator name is under the image + halfScreen, + // the creator name is on the image + fullScreen, +} + +typedef OnSelectUnsplashImage = void Function(String url); + +class UnsplashImageWidget extends StatefulWidget { + const UnsplashImageWidget({ + super.key, + this.type = UnsplashImageType.halfScreen, + required this.onSelectUnsplashImage, + }); + + final UnsplashImageType type; + final OnSelectUnsplashImage onSelectUnsplashImage; + + @override + State createState() => _UnsplashImageWidgetState(); +} + +class _UnsplashImageWidgetState extends State { + final unsplash = UnsplashClient( + settings: const ClientSettings( + credentials: AppCredentials( + accessKey: _accessKeyA + _accessKeyB, + secretKey: _secretKeyA + _secretKeyB, + ), + ), + ); + + late Future> randomPhotos; + + String query = ''; + + @override + void initState() { + super.initState(); + randomPhotos = unsplash.photos + .random(count: 18, orientation: PhotoOrientation.landscape) + .goAndGet(); + } + + @override + void dispose() { + unsplash.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 44, + child: FlowyMobileSearchTextField( + onChanged: (keyword) => query = keyword, + onSubmitted: (_) => _search(), + ), + ), + const VSpace(12.0), + Expanded( + child: FutureBuilder( + future: randomPhotos, + builder: (context, value) { + final data = value.data; + if (!value.hasData || + value.connectionState != ConnectionState.done || + data == null || + data.isEmpty) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + return _UnsplashImages( + type: widget.type, + photos: data, + onSelectUnsplashImage: widget.onSelectUnsplashImage, + ); + }, + ), + ), + ], + ); + } + + void _search() { + setState(() { + randomPhotos = unsplash.photos + .random( + count: 18, + orientation: PhotoOrientation.landscape, + query: query, + ) + .goAndGet(); + }); + } +} + +class _UnsplashImages extends StatefulWidget { + const _UnsplashImages({ + required this.type, + required this.photos, + required this.onSelectUnsplashImage, + }); + + final UnsplashImageType type; + final List photos; + final OnSelectUnsplashImage onSelectUnsplashImage; + + @override + State<_UnsplashImages> createState() => _UnsplashImagesState(); +} + +class _UnsplashImagesState extends State<_UnsplashImages> { + int _selectedPhotoIndex = -1; + + @override + Widget build(BuildContext context) { + const mainAxisSpacing = 16.0; + final crossAxisCount = switch (widget.type) { + UnsplashImageType.halfScreen => 3, + UnsplashImageType.fullScreen => 2, + }; + final crossAxisSpacing = switch (widget.type) { + UnsplashImageType.halfScreen => 10.0, + UnsplashImageType.fullScreen => 16.0, + }; + + return GridView.count( + crossAxisCount: crossAxisCount, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + childAspectRatio: 4 / 3, + children: widget.photos.asMap().entries.map((entry) { + final index = entry.key; + final photo = entry.value; + return _UnsplashImage( + type: widget.type, + photo: photo, + isSelected: index == _selectedPhotoIndex, + onTap: () { + widget.onSelectUnsplashImage(photo.urls.full.toString()); + setState(() => _selectedPhotoIndex = index); + }, + ); + }).toList(), + ); + } +} + +class _UnsplashImage extends StatelessWidget { + const _UnsplashImage({ + required this.type, + required this.photo, + required this.onTap, + required this.isSelected, + }); + + final UnsplashImageType type; + final Photo photo; + final VoidCallback onTap; + final bool isSelected; + + @override + Widget build(BuildContext context) { + final child = switch (type) { + UnsplashImageType.halfScreen => _buildHalfScreenImage(context), + UnsplashImageType.fullScreen => _buildFullScreenImage(context), + }; + + return GestureDetector( + onTap: onTap, + child: isSelected + ? Container( + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide(width: 1.50, color: Color(0xFF00BCF0)), + borderRadius: BorderRadius.circular(8.0), + ), + ), + padding: const EdgeInsets.all(2.0), + child: child, + ) + : child, + ); + } + + Widget _buildHalfScreenImage(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Image.network( + photo.urls.thumb.toString(), + fit: BoxFit.cover, + ), + ), + const HSpace(2.0), + FlowyText('by ${photo.name}', fontSize: 10.0), + ], + ); + } + + Widget _buildFullScreenImage(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Stack( + children: [ + LayoutBuilder( + builder: (_, constraints) => Image.network( + photo.urls.thumb.toString(), + fit: BoxFit.cover, + width: constraints.maxWidth, + height: constraints.maxHeight, + ), + ), + Positioned( + bottom: 9, + left: 10, + child: FlowyText.medium( + photo.name, + fontSize: 13.0, + color: Colors.white, + ), + ), + ], + ), + ); + } +} + +extension on Photo { + String get name { + if (user.username.isNotEmpty) { + return user.username; + } else if (user.name.isNotEmpty) { + return user.name; + } else if (user.email?.isNotEmpty == true) { + return user.email!; + } + + return user.id; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart new file mode 100644 index 0000000000000..836f087797dd1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart @@ -0,0 +1,195 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/upload_image_file_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' hide ColorOption; +import 'package:cross_file/cross_file.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'widgets/embed_image_url_widget.dart'; + +enum UploadImageType { + local, + url, + unsplash, + color; + + String get description => switch (this) { + UploadImageType.local => + LocaleKeys.document_imageBlock_upload_label.tr(), + UploadImageType.url => + LocaleKeys.document_imageBlock_embedLink_label.tr(), + UploadImageType.unsplash => + LocaleKeys.document_imageBlock_unsplash_label.tr(), + UploadImageType.color => LocaleKeys.document_plugins_cover_colors.tr(), + }; +} + +class UploadImageMenu extends StatefulWidget { + const UploadImageMenu({ + super.key, + required this.onSelectedLocalImages, + required this.onSelectedAIImage, + required this.onSelectedNetworkImage, + this.onSelectedColor, + this.supportTypes = UploadImageType.values, + this.limitMaximumImageSize = false, + this.allowMultipleImages = false, + }); + + final void Function(List) onSelectedLocalImages; + final void Function(String url) onSelectedAIImage; + final void Function(String url) onSelectedNetworkImage; + final void Function(String color)? onSelectedColor; + final List supportTypes; + final bool limitMaximumImageSize; + final bool allowMultipleImages; + + @override + State createState() => _UploadImageMenuState(); +} + +class _UploadImageMenuState extends State { + late final List values; + int currentTabIndex = 0; + + @override + void initState() { + super.initState(); + values = widget.supportTypes; + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: values.length, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TabBar( + onTap: (value) => setState(() { + currentTabIndex = value; + }), + indicatorSize: TabBarIndicatorSize.label, + isScrollable: true, + overlayColor: WidgetStatePropertyAll( + UniversalPlatform.isDesktop + ? Theme.of(context).colorScheme.secondary + : Colors.transparent, + ), + padding: EdgeInsets.zero, + tabs: values.map( + (e) { + final child = Padding( + padding: EdgeInsets.only( + left: 12.0, + right: 12.0, + bottom: 8.0, + top: UniversalPlatform.isMobile ? 0 : 8.0, + ), + child: FlowyText(e.description), + ); + if (UniversalPlatform.isDesktop) { + return FlowyHover( + style: const HoverStyle(borderRadius: BorderRadius.zero), + child: child, + ); + } + return child; + }, + ).toList(), + ), + const Divider(height: 2), + _buildTab(), + ], + ), + ); + } + + Widget _buildTab() { + final constraints = + UniversalPlatform.isMobile ? const BoxConstraints(minHeight: 92) : null; + final type = values[currentTabIndex]; + switch (type) { + case UploadImageType.local: + Widget child = UploadImageFileWidget( + allowMultipleImages: widget.allowMultipleImages, + onPickFiles: widget.onSelectedLocalImages, + ); + if (UniversalPlatform.isDesktop) { + child = Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline, + ), + ), + constraints: constraints, + child: child, + ), + ); + } else { + child = Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 12.0, + ), + child: child, + ); + } + return child; + + case UploadImageType.url: + return Container( + padding: const EdgeInsets.all(8.0), + constraints: constraints, + child: EmbedImageUrlWidget( + onSubmit: widget.onSelectedNetworkImage, + ), + ); + case UploadImageType.unsplash: + return Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: UnsplashImageWidget( + onSelectUnsplashImage: widget.onSelectedNetworkImage, + ), + ), + ); + case UploadImageType.color: + final theme = Theme.of(context); + final padding = UniversalPlatform.isMobile + ? const EdgeInsets.all(16.0) + : const EdgeInsets.all(8.0); + return Container( + constraints: constraints, + padding: padding, + alignment: Alignment.center, + child: CoverColorPicker( + pickerBackgroundColor: theme.cardColor, + pickerItemHoverColor: theme.hoverColor, + backgroundColorOptions: FlowyTint.values + .map( + (t) => ColorOption( + colorHex: t.color(context).toHex(), + name: t.tintName(AppFlowyEditorL10n.current), + ), + ) + .toList(), + onSubmittedBackgroundColorHex: (color) { + widget.onSelectedColor?.call(color); + }, + ), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart new file mode 100644 index 0000000000000..caddbf464af6d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart @@ -0,0 +1,91 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class EmbedImageUrlWidget extends StatefulWidget { + const EmbedImageUrlWidget({ + super.key, + required this.onSubmit, + }); + + final void Function(String url) onSubmit; + + @override + State createState() => _EmbedImageUrlWidgetState(); +} + +class _EmbedImageUrlWidgetState extends State { + bool isUrlValid = true; + String inputText = ''; + + @override + Widget build(BuildContext context) { + final textField = FlowyTextField( + hintText: LocaleKeys.document_imageBlock_embedLink_placeholder.tr(), + onChanged: (value) => inputText = value, + onEditingComplete: submit, + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14, + ), + hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + fontSize: 14, + ), + ); + return Column( + children: [ + const VSpace(12), + UniversalPlatform.isDesktop + ? textField + : SizedBox( + height: 42, + child: textField, + ), + if (!isUrlValid) ...[ + const VSpace(12), + FlowyText( + LocaleKeys.document_plugins_cover_invalidImageUrl.tr(), + color: Theme.of(context).colorScheme.error, + ), + ], + const VSpace(20), + SizedBox( + height: UniversalPlatform.isMobile ? 36 : 32, + width: 300, + child: FlowyButton( + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.9), + showDefaultBoxDecorationOnMobile: true, + radius: + UniversalPlatform.isMobile ? BorderRadius.circular(8) : null, + margin: const EdgeInsets.all(5), + text: FlowyText( + LocaleKeys.document_imageBlock_embedLink_label.tr(), + lineHeight: 1, + textAlign: TextAlign.center, + color: UniversalPlatform.isMobile + ? null + : Theme.of(context).colorScheme.onPrimary, + fontSize: UniversalPlatform.isMobile ? 14 : null, + ), + onTap: submit, + ), + ), + const VSpace(8), + ], + ); + } + + void submit() { + if (checkUrlValidity(inputText)) { + return widget.onSubmit(inputText); + } + + setState(() => isUrlValid = false); + } + + bool checkUrlValidity(String url) => imgUrlRegex.hasMatch(url); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/upload_image_file_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/upload_image_file_widget.dart new file mode 100644 index 0000000000000..f08c74b84eb7f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/upload_image_file_widget.dart @@ -0,0 +1,75 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/permission/permission_checker.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/default_extensions.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class UploadImageFileWidget extends StatelessWidget { + const UploadImageFileWidget({ + super.key, + required this.onPickFiles, + this.allowedExtensions = defaultImageExtensions, + this.allowMultipleImages = false, + }); + + final void Function(List) onPickFiles; + final List allowedExtensions; + final bool allowMultipleImages; + + @override + Widget build(BuildContext context) { + Widget child = FlowyButton( + showDefaultBoxDecorationOnMobile: true, + radius: UniversalPlatform.isMobile ? BorderRadius.circular(8.0) : null, + text: Container( + margin: const EdgeInsets.all(4.0), + alignment: Alignment.center, + child: FlowyText( + LocaleKeys.document_imageBlock_upload_placeholder.tr(), + ), + ), + onTap: () => _uploadImage(context), + ); + + if (UniversalPlatform.isDesktopOrWeb) { + child = FlowyHover(child: child); + } else { + child = Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: child, + ); + } + + return child; + } + + Future _uploadImage(BuildContext context) async { + if (UniversalPlatform.isDesktopOrWeb) { + // on desktop, the users can pick a image file from folder + final result = await getIt().pickFiles( + dialogTitle: '', + type: FileType.custom, + allowedExtensions: allowedExtensions, + allowMultiple: allowMultipleImages, + ); + onPickFiles(result?.files.map((f) => f.xFile).toList() ?? const []); + } else { + final photoPermission = + await PermissionChecker.checkPhotoPermission(context); + if (!photoPermission) { + Log.error('Has no permission to access the photo library'); + return; + } + // on mobile, the users can pick a image file from camera or image library + final result = await ImagePicker().pickMultiImage(); + onPickFiles(result); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation.dart new file mode 100644 index 0000000000000..e41bdc1114a57 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation.dart @@ -0,0 +1,184 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/text_input.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_math_fork/flutter_math.dart'; +import 'package:provider/provider.dart'; + +class InlineMathEquationKeys { + const InlineMathEquationKeys._(); + + static const formula = 'formula'; +} + +class InlineMathEquation extends StatefulWidget { + const InlineMathEquation({ + super.key, + required this.formula, + required this.node, + required this.index, + this.textStyle, + }); + + final Node node; + final int index; + final String formula; + final TextStyle? textStyle; + + @override + State createState() => _InlineMathEquationState(); +} + +class _InlineMathEquationState extends State { + final popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return _IgnoreParentPointer( + child: AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (_) { + return MathInputTextField( + initialText: widget.formula, + onSubmit: (value) async { + popoverController.close(); + if (value == widget.formula) { + return; + } + final editorState = context.read(); + final transaction = editorState.transaction + ..formatText(widget.node, widget.index, 1, { + InlineMathEquationKeys.formula: value, + }); + await editorState.apply(transaction); + }, + ); + }, + offset: const Offset(0, 10), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: _buildMathEquation(context), + ), + ), + ), + ); + } + + Widget _buildMathEquation(BuildContext context) { + final theme = Theme.of(context); + final longEq = Math.tex( + widget.formula, + textStyle: widget.textStyle, + mathStyle: MathStyle.text, + options: MathOptions( + style: MathStyle.text, + mathFontOptions: const FontOptions( + fontShape: FontStyle.italic, + ), + fontSize: widget.textStyle?.fontSize ?? 14.0, + color: widget.textStyle?.color ?? theme.colorScheme.onSurface, + ), + onErrorFallback: (errmsg) { + return FlowyText( + errmsg.message, + fontSize: widget.textStyle?.fontSize ?? 14.0, + color: widget.textStyle?.color ?? theme.colorScheme.onSurface, + ); + }, + ); + return longEq; + } +} + +class MathInputTextField extends StatefulWidget { + const MathInputTextField({ + super.key, + required this.initialText, + required this.onSubmit, + }); + + final String initialText; + final void Function(String value) onSubmit; + + @override + State createState() => _MathInputTextFieldState(); +} + +class _MathInputTextFieldState extends State { + late final TextEditingController textEditingController; + + @override + void initState() { + super.initState(); + + textEditingController = TextEditingController( + text: widget.initialText, + ); + textEditingController.selection = TextSelection( + baseOffset: 0, + extentOffset: widget.initialText.length, + ); + } + + @override + void dispose() { + textEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 240, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: FlowyFormTextInput( + autoFocus: true, + textAlign: TextAlign.left, + controller: textEditingController, + contentPadding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 4.0, + ), + onEditingComplete: () => + widget.onSubmit(textEditingController.text), + ), + ), + const HSpace(4.0), + FlowyButton( + text: FlowyText(LocaleKeys.button_done.tr()), + useIntrinsicWidth: true, + onTap: () => widget.onSubmit(textEditingController.text), + ), + ], + ), + ); + } +} + +class _IgnoreParentPointer extends StatelessWidget { + const _IgnoreParentPointer({ + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, + onTapDown: (_) {}, + onDoubleTap: () {}, + onLongPress: () {}, + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation_toolbar_item.dart new file mode 100644 index 0000000000000..061a6fe320d07 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_math_equation/inline_math_equation_toolbar_item.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +const _kInlineMathEquationToolbarItemId = 'editor.inline_math_equation'; + +final ToolbarItem inlineMathEquationItem = ToolbarItem( + id: _kInlineMathEquationToolbarItemId, + group: 2, + isActive: onlyShowInSingleSelectionAndTextType, + builder: (context, editorState, highlightColor, _, tooltipBuilder) { + final selection = editorState.selection!; + final nodes = editorState.getNodesInSelection(selection); + final isHighlight = nodes.allSatisfyInSelection(selection, (delta) { + return delta.everyAttributes( + (attributes) => attributes[InlineMathEquationKeys.formula] != null, + ); + }); + final child = SVGIconItemWidget( + iconBuilder: (_) => FlowySvg( + FlowySvgs.math_lg, + size: const Size.square(16), + color: isHighlight ? highlightColor : Colors.white, + ), + isHighlight: isHighlight, + highlightColor: highlightColor, + onPressed: () async { + final selection = editorState.selection; + if (selection == null || selection.isCollapsed) { + return; + } + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + final transaction = editorState.transaction; + if (isHighlight) { + final formula = delta + .slice(selection.startIndex, selection.endIndex) + .whereType() + .firstOrNull + ?.attributes?[InlineMathEquationKeys.formula]; + assert(formula != null); + if (formula == null) { + return; + } + // clear the format + transaction.replaceText( + node, + selection.startIndex, + selection.length, + formula, + attributes: {}, + ); + } else { + final text = editorState.getTextInSelection(selection).join(); + transaction.replaceText( + node, + selection.startIndex, + selection.length, + MentionBlockKeys.mentionChar, + attributes: { + InlineMathEquationKeys.formula: text, + }, + ); + } + await editorState.apply(transaction); + }, + ); + + if (tooltipBuilder != null) { + return tooltipBuilder( + context, + _kInlineMathEquationToolbarItemId, + LocaleKeys.document_plugins_createInlineMathEquation.tr(), + child, + ); + } + + return child; + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/keyboard_interceptor/keyboard_interceptor.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/keyboard_interceptor/keyboard_interceptor.dart new file mode 100644 index 0000000000000..1d0bb5e49fc29 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/keyboard_interceptor/keyboard_interceptor.dart @@ -0,0 +1,91 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter/services.dart'; + +class EditorKeyboardInterceptor extends AppFlowyKeyboardServiceInterceptor { + @override + Future interceptNonTextUpdate( + TextEditingDeltaNonTextUpdate nonTextUpdate, + EditorState editorState, + List characterShortcutEvents, + ) async { + return _checkIfBacktickPressed( + editorState, + nonTextUpdate, + ); + } + + @override + Future interceptDelete( + TextEditingDeltaDeletion deletion, + EditorState editorState, + ) async { + // check if the current selection is in a code block + final (isInTableCell, selection, tableCellNode, node) = + editorState.isCurrentSelectionInTableCell(); + if (!isInTableCell || + selection == null || + tableCellNode == null || + node == null) { + return false; + } + + final onlyContainsOneChild = tableCellNode.children.length == 1; + final isParagraphNode = + tableCellNode.children.first.type == ParagraphBlockKeys.type; + if (onlyContainsOneChild && + selection.isCollapsed && + selection.end.offset == 0 && + isParagraphNode) { + return true; + } + + return false; + } + + /// Check if the backtick pressed event should be handled + Future _checkIfBacktickPressed( + EditorState editorState, + TextEditingDeltaNonTextUpdate nonTextUpdate, + ) async { + // if the composing range is not empty, it means the user is typing a text, + // so we don't need to handle the backtick pressed event + if (!nonTextUpdate.composing.isCollapsed || + !nonTextUpdate.selection.isCollapsed) { + return false; + } + + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + AppFlowyEditorLog.input.debug('selection is null or not collapsed'); + return false; + } + + final node = editorState.getNodesInSelection(selection).firstOrNull; + if (node == null) { + AppFlowyEditorLog.input.debug('node is null'); + return false; + } + + // get last character of the node + final plainText = node.delta?.toPlainText(); + // three backticks to code block + if (plainText != '```') { + return false; + } + + final transaction = editorState.transaction; + transaction.insertNode( + selection.end.path, + codeBlockNode(), + ); + transaction.deleteNode(node); + transaction.afterSelection = Selection.collapsed( + Position(path: selection.start.path), + ); + await editorState.apply(transaction); + + return true; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart new file mode 100644 index 0000000000000..879a71f008108 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart @@ -0,0 +1,153 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class CustomLinkPreviewWidget extends StatelessWidget { + const CustomLinkPreviewWidget({ + super.key, + required this.node, + required this.url, + this.title, + this.description, + this.imageUrl, + }); + + final Node node; + final String? title; + final String? description; + final String? imageUrl; + final String url; + + @override + Widget build(BuildContext context) { + final documentFontSize = context + .read() + .editorStyle + .textStyleConfiguration + .text + .fontSize ?? + 16.0; + final (fontSize, width) = UniversalPlatform.isDesktopOrWeb + ? (documentFontSize, 180.0) + : (documentFontSize - 2, 120.0); + final Widget child = Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.onSurface, + ), + borderRadius: BorderRadius.circular( + 6.0, + ), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (imageUrl != null) + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(6.0), + bottomLeft: Radius.circular(6.0), + ), + child: FlowyNetworkImage( + url: imageUrl!, + width: width, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.only( + bottom: 4.0, + right: 10.0, + ), + child: FlowyText.medium( + title!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + fontSize: fontSize, + ), + ), + if (description != null) + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: FlowyText( + description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + fontSize: fontSize - 4, + ), + ), + FlowyText( + url.toString(), + overflow: TextOverflow.ellipsis, + maxLines: 2, + color: Theme.of(context).hintColor, + fontSize: fontSize - 4, + ), + ], + ), + ), + ), + ], + ), + ), + ); + + if (UniversalPlatform.isDesktopOrWeb) { + return InkWell( + onTap: () => afLaunchUrlString(url), + child: child, + ); + } + + return MobileBlockActionButtons( + node: node, + editorState: context.read(), + extendActionWidgets: _buildExtendActionWidgets(context), + child: GestureDetector( + onTap: () => afLaunchUrlString(url), + child: child, + ), + ); + } + + // only used on mobile platform + List _buildExtendActionWidgets(BuildContext context) { + return [ + FlowyOptionTile.text( + showTopBorder: false, + text: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(), + leftIcon: const FlowySvg( + FlowySvgs.m_toolbar_link_m, + size: Size.square(18), + ), + onTap: () { + context.pop(); + convertUrlPreviewNodeToLink( + context.read(), + node, + ); + }, + ), + ]; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart new file mode 100644 index 0000000000000..6688cfe304ef3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_cache.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; + +class LinkPreviewDataCache implements LinkPreviewDataCacheInterface { + @override + Future get(String url) async { + final option = + await getIt().getWithFormat( + url, + (value) => LinkPreviewData.fromJson(jsonDecode(value)), + ); + return option; + } + + @override + Future set(String url, LinkPreviewData data) async { + await getIt().set( + url, + jsonEncode(data.toJson()), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart new file mode 100644 index 0000000000000..61e5156060b45 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart @@ -0,0 +1,110 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../image/custom_image_block_component/custom_image_block_component.dart'; + +class LinkPreviewMenu extends StatefulWidget { + const LinkPreviewMenu({ + super.key, + required this.node, + required this.state, + }); + + final Node node; + final LinkPreviewBlockComponentState state; + + @override + State createState() => _LinkPreviewMenuState(); +} + +class _LinkPreviewMenuState extends State { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + height: 32, + decoration: BoxDecoration( + color: theme.cardColor, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + borderRadius: BorderRadius.circular(4.0), + ), + child: Row( + children: [ + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(), + iconData: FlowySvgs.m_toolbar_link_m, + onTap: () async => convertUrlPreviewNodeToLink( + context.read(), + widget.node, + ), + ), + const HSpace(4), + MenuBlockButton( + tooltip: LocaleKeys.editor_copyLink.tr(), + iconData: FlowySvgs.copy_s, + onTap: copyImageLink, + ), + const _Divider(), + MenuBlockButton( + tooltip: LocaleKeys.button_delete.tr(), + iconData: FlowySvgs.trash_s, + onTap: deleteLinkPreviewNode, + ), + const HSpace(4), + ], + ), + ); + } + + void copyImageLink() { + final url = widget.node.attributes[CustomImageBlockKeys.url]; + if (url != null) { + Clipboard.setData(ClipboardData(text: url)); + showToastNotification( + context, + message: LocaleKeys.document_plugins_urlPreview_copiedToPasteBoard.tr(), + ); + } + } + + Future deleteLinkPreviewNode() async { + final node = widget.node; + final editorState = context.read(); + final transaction = editorState.transaction; + transaction.deleteNode(node); + transaction.afterSelection = null; + await editorState.apply(transaction); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Container( + width: 1, + color: Colors.grey, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart new file mode 100644 index 0000000000000..57564c472203c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart @@ -0,0 +1,31 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; + +Future convertUrlPreviewNodeToLink( + EditorState editorState, + Node node, +) async { + if (node.type != LinkPreviewBlockKeys.type) { + return; + } + + final url = node.attributes[ImageBlockKeys.url]; + final delta = Delta() + ..insert( + url, + attributes: { + AppFlowyRichTextKeys.href: url, + }, + ); + final transaction = editorState.transaction; + transaction + ..insertNode(node.path, paragraphNode(delta: delta)) + ..deleteNode(node); + transaction.afterSelection = Selection.collapsed( + Position( + path: node.path, + offset: url.length, + ), + ); + return editorState.apply(transaction); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_block_component.dart new file mode 100644 index 0000000000000..286eac3d75272 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/math_equation_block_component.dart @@ -0,0 +1,307 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; +import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_math_fork/flutter_math.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class MathEquationBlockKeys { + const MathEquationBlockKeys._(); + + static const String type = 'math_equation'; + + /// The content of a math equation block. + /// + /// The value is a String. + static const String formula = 'formula'; +} + +Node mathEquationNode({ + String formula = '', +}) { + final attributes = { + MathEquationBlockKeys.formula: formula, + }; + return Node( + type: MathEquationBlockKeys.type, + attributes: attributes, + ); +} + +// defining the callout block menu item for selection +SelectionMenuItem mathEquationItem = SelectionMenuItem.node( + getName: LocaleKeys.document_plugins_mathEquation_name.tr, + iconBuilder: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.icon_math_eq_s, + isSelected: onSelected, + style: style, + ), + keywords: ['tex, latex, katex', 'math equation', 'formula'], + nodeBuilder: (editorState, _) => mathEquationNode(), + replace: (_, node) => node.delta?.isEmpty ?? false, + updateSelection: (editorState, path, __, ___) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + final mathEquationState = + editorState.getNodeAtPath(path)?.key.currentState; + if (mathEquationState != null && + mathEquationState is MathEquationBlockComponentWidgetState) { + mathEquationState.showEditingDialog(); + } + }); + return null; + }, +); + +class MathEquationBlockComponentBuilder extends BlockComponentBuilder { + MathEquationBlockComponentBuilder({ + super.configuration, + }); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return MathEquationBlockComponentWidget( + key: node.key, + node: node, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (node) => + node.children.isEmpty && + node.attributes[MathEquationBlockKeys.formula] is String; +} + +class MathEquationBlockComponentWidget extends BlockComponentStatefulWidget { + const MathEquationBlockComponentWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => + MathEquationBlockComponentWidgetState(); +} + +class MathEquationBlockComponentWidgetState + extends State + with BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + String get formula => + widget.node.attributes[MathEquationBlockKeys.formula] as String; + + late final editorState = context.read(); + final ValueNotifier isHover = ValueNotifier(false); + + late final controller = TextEditingController(text: formula); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InkWell( + onHover: (value) => isHover.value = value, + onTap: showEditingDialog, + child: _build(context), + ); + } + + Widget _build(BuildContext context) { + Widget child = Container( + constraints: const BoxConstraints(minHeight: 52), + decoration: BoxDecoration( + color: formula.isNotEmpty + ? Colors.transparent + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: FlowyHover( + style: HoverStyle( + borderRadius: BorderRadius.circular(4), + ), + child: formula.isEmpty + ? _buildPlaceholderWidget(context) + : _buildMathEquation(context), + ), + ); + + child = Padding( + padding: padding, + child: child, + ); + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + if (UniversalPlatform.isMobile) { + child = MobileBlockActionButtons( + node: node, + editorState: editorState, + child: child, + ); + } + + if (UniversalPlatform.isDesktopOrWeb) { + child = Stack( + children: [ + child, + Positioned( + right: 6, + top: 12, + child: ValueListenableBuilder( + valueListenable: isHover, + builder: (_, value, __) => + value ? _buildDeleteButton(context) : const SizedBox.shrink(), + ), + ), + ], + ); + } + + return child; + } + + Widget _buildPlaceholderWidget(BuildContext context) { + return SizedBox( + height: 52, + child: Row( + children: [ + const HSpace(10), + FlowySvg( + FlowySvgs.slash_menu_icon_math_equation_s, + color: Theme.of(context).hintColor, + size: const Size.square(24), + ), + const HSpace(10), + FlowyText( + LocaleKeys.document_plugins_mathEquation_addMathEquation.tr(), + color: Theme.of(context).hintColor, + ), + ], + ), + ); + } + + Widget _buildMathEquation(BuildContext context) { + return Center( + child: Math.tex( + formula, + textStyle: const TextStyle(fontSize: 20), + ), + ); + } + + Widget _buildDeleteButton(BuildContext context) { + return MenuBlockButton( + tooltip: LocaleKeys.button_delete.tr(), + iconData: FlowySvgs.trash_s, + onTap: () { + final transaction = editorState.transaction..deleteNode(widget.node); + editorState.apply(transaction); + }, + ); + } + + void showEditingDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: Theme.of(context).canvasColor, + title: Text( + LocaleKeys.document_plugins_mathEquation_editMathEquation.tr(), + ), + content: KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (key) { + if (key.logicalKey == LogicalKeyboardKey.enter && + !HardwareKeyboard.instance.isShiftPressed) { + updateMathEquation(controller.text, context); + } else if (key.logicalKey == LogicalKeyboardKey.escape) { + dismiss(context); + } + }, + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.3, + child: TextField( + autofocus: true, + controller: controller, + maxLines: null, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'E = MC^2', + ), + ), + ), + ), + actions: [ + SecondaryTextButton( + LocaleKeys.button_cancel.tr(), + mode: TextButtonMode.big, + onPressed: () => dismiss(context), + ), + PrimaryTextButton( + LocaleKeys.button_done.tr(), + onPressed: () => updateMathEquation(controller.text, context), + ), + ], + actionsPadding: const EdgeInsets.only(bottom: 20), + actionsAlignment: MainAxisAlignment.spaceAround, + ); + }, + ); + } + + void updateMathEquation(String mathEquation, BuildContext context) { + if (mathEquation == formula) { + dismiss(context); + return; + } + final transaction = editorState.transaction + ..updateNode( + widget.node, + { + MathEquationBlockKeys.formula: mathEquation, + }, + ); + editorState.apply(transaction); + dismiss(context); + } + + void dismiss(BuildContext context) { + Navigator.of(context).pop(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/mobile_math_equation_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/mobile_math_equation_toolbar_item.dart new file mode 100644 index 0000000000000..62714ae93dbfc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/math_equation/mobile_math_equation_toolbar_item.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +final mathEquationMobileToolbarItem = MobileToolbarItem.action( + itemIconBuilder: (_, __, ___) => const SizedBox( + width: 22, + child: FlowySvg(FlowySvgs.math_lg), + ), + actionHandler: (_, editorState) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final path = selection.start.path; + final node = editorState.getNodeAtPath(path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + final transaction = editorState.transaction; + final insertedNode = mathEquationNode(); + + if (delta.isEmpty) { + transaction + ..insertNode(path, insertedNode) + ..deleteNode(node); + } else { + transaction.insertNode( + path.next, + insertedNode, + ); + } + + await editorState.apply(transaction); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + final mathEquationState = + editorState.getNodeAtPath(path)?.key.currentState; + if (mathEquationState != null && + mathEquationState is MathEquationBlockComponentWidgetState) { + mathEquationState.showEditingDialog(); + } + }); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart new file mode 100644 index 0000000000000..ef32ad1098df1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart @@ -0,0 +1,223 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/plugins/trash/application/trash_service.dart'; +import 'package:appflowy/shared/clipboard_state.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../transaction_handler/mention_transaction_handler.dart'; + +const _pasteIdentifier = 'child_page_transaction'; + +class ChildPageTransactionHandler extends MentionTransactionHandler { + ChildPageTransactionHandler(); + + @override + Future onTransaction( + BuildContext context, + String viewId, + EditorState editorState, + List added, + List removed, { + bool isCut = false, + bool isUndoRedo = false, + bool isPaste = false, + bool isDraggingNode = false, + bool isTurnInto = false, + String? parentViewId, + }) async { + if (isDraggingNode || isTurnInto) { + return; + } + + // Remove the mentions that were both added and removed in the same transaction. + // These were just moved around. + final moved = []; + for (final mention in added) { + if (removed.any((r) => r.$2 == mention.$2)) { + moved.add(mention); + } + } + + for (final mention in removed) { + if (!context.mounted || moved.any((m) => m.$2 == mention.$2)) { + return; + } + + if (mention.$2[MentionBlockKeys.type] != MentionType.childPage.name) { + continue; + } + + await _handleDeletion(context, mention); + } + + if (isPaste || isUndoRedo) { + if (context.mounted) { + context.read().startHandlingPaste(_pasteIdentifier); + } + + for (final mention in added) { + if (!context.mounted || moved.any((m) => m.$2 == mention.$2)) { + return; + } + + if (mention.$2[MentionBlockKeys.type] != MentionType.childPage.name) { + continue; + } + + await _handleAddition( + context, + editorState, + mention, + isPaste, + parentViewId, + isCut, + ); + } + + if (context.mounted) { + context.read().endHandlingPaste(_pasteIdentifier); + } + } + } + + Future _handleDeletion( + BuildContext context, + MentionBlockData data, + ) async { + final viewId = data.$2[MentionBlockKeys.pageId]; + + final result = await ViewBackendService.deleteView(viewId: viewId); + result.fold( + (_) {}, + (error) { + Log.error(error); + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys.document_plugins_subPage_errors_failedDeletePage + .tr(), + ); + } + }, + ); + } + + Future _handleAddition( + BuildContext context, + EditorState editorState, + MentionBlockData data, + bool isPaste, + String? parentViewId, + bool isCut, + ) async { + if (parentViewId == null) { + return; + } + + final viewId = data.$2[MentionBlockKeys.pageId]; + if (isPaste && !isCut) { + _handlePasteFromCopy( + context, + editorState, + data.$1, + data.$3, + viewId, + parentViewId, + ); + } else { + _handlePasteFromCut(viewId, parentViewId); + } + } + + void _handlePasteFromCut(String viewId, String parentViewId) async { + // Attempt to restore from Trash just in case + await TrashService.putback(viewId); + + final view = (await ViewBackendService.getView(viewId)).toNullable(); + if (view == null) { + return Log.error('View not found: $viewId'); + } + + if (view.parentViewId == parentViewId) { + return; + } + + await ViewBackendService.moveViewV2( + viewId: viewId, + newParentId: parentViewId, + prevViewId: null, + ); + } + + void _handlePasteFromCopy( + BuildContext context, + EditorState editorState, + Node node, + int index, + String viewId, + String parentViewId, + ) async { + final view = (await ViewBackendService.getView(viewId)).toNullable(); + if (view == null) { + return Log.error('View not found: $viewId'); + } + + final duplicatedViewOrFailure = await ViewBackendService.duplicate( + view: view, + openAfterDuplicate: false, + includeChildren: true, + syncAfterDuplicate: true, + parentViewId: parentViewId, + ); + + await duplicatedViewOrFailure.fold( + (newView) async { + final newMentionAttributes = { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.childPage.name, + MentionBlockKeys.pageId: newView.id, + }, + }; + + // The index is the index of the delta, to get the index of the mention character + // in all the text, we need to calculate it based on the deltas before the current delta. + int mentionIndex = 0; + for (final (i, delta) in node.delta!.indexed) { + if (i >= index) { + break; + } + + mentionIndex += delta.length; + } + + final transaction = editorState.transaction; + transaction.formatText( + node, + mentionIndex, + MentionBlockKeys.mentionChar.length, + newMentionAttributes, + ); + await editorState.apply( + transaction, + options: const ApplyOptions(recordUndo: false), + ); + }, + (error) { + Log.error(error); + if (context.mounted) { + showSnapBar( + context, + LocaleKeys.document_plugins_subPage_errors_failedDuplicatePage.tr(), + ); + } + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart new file mode 100644 index 0000000000000..972ed229ddf9d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart @@ -0,0 +1,263 @@ +import 'package:appflowy/shared/clipboard_state.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:nanoid/nanoid.dart'; +import 'package:provider/provider.dart'; + +import '../plugins.dart'; +import '../transaction_handler/mention_transaction_handler.dart'; + +const _pasteIdentifier = 'date_transaction'; + +class DateTransactionHandler extends MentionTransactionHandler { + DateTransactionHandler(); + + @override + Future onTransaction( + BuildContext context, + String viewId, + EditorState editorState, + List added, + List removed, { + bool isCut = false, + bool isUndoRedo = false, + bool isPaste = false, + bool isDraggingNode = false, + bool isTurnInto = false, + String? parentViewId, + }) async { + if (isDraggingNode || isTurnInto) { + return; + } + + // Remove the mentions that were both added and removed in the same transaction. + // These were just moved around. + final moved = []; + for (final mention in added) { + if (removed.any((r) => r.$2 == mention.$2)) { + moved.add(mention); + } + } + + for (final mention in removed) { + if (!context.mounted || moved.any((m) => m.$2 == mention.$2)) { + return; + } + + if (mention.$2[MentionBlockKeys.type] != MentionType.date.name) { + continue; + } + + _handleDeletion(context, mention); + } + + if (isPaste || isUndoRedo) { + if (context.mounted) { + context.read().startHandlingPaste(_pasteIdentifier); + } + + for (final mention in added) { + if (!context.mounted || moved.any((m) => m.$2 == mention.$2)) { + return; + } + + if (mention.$2[MentionBlockKeys.type] != MentionType.date.name) { + continue; + } + + _handleAddition( + context, + viewId, + editorState, + mention, + isPaste, + isCut, + ); + } + + if (context.mounted) { + context.read().endHandlingPaste(_pasteIdentifier); + } + } + } + + void _handleDeletion( + BuildContext context, + MentionBlockData data, + ) { + final reminderId = data.$2[MentionBlockKeys.reminderId]; + + if (reminderId case String _ when reminderId.isNotEmpty) { + getIt().add(ReminderEvent.remove(reminderId: reminderId)); + } + } + + void _handleAddition( + BuildContext context, + String viewId, + EditorState editorState, + MentionBlockData data, + bool isPaste, + bool isCut, + ) { + final dateData = _MentionDateBlockData.fromData(data.$2); + if (dateData.dateString.isEmpty) { + Log.error("mention date block doesn't have a valid date string"); + return; + } + + if (isPaste && !isCut) { + _handlePasteFromCopy( + context, + viewId, + editorState, + data.$1, + data.$3, + dateData, + ); + } else { + _handlePasteFromCut(viewId, data.$1, dateData); + } + } + + void _handlePasteFromCut( + String viewId, + Node node, + _MentionDateBlockData data, + ) { + final dateTime = DateTime.tryParse(data.dateString); + + if (data.reminderId == null || dateTime == null) { + return; + } + + getIt().add( + ReminderEvent.addById( + reminderId: data.reminderId!, + objectId: viewId, + scheduledAt: Int64( + data.reminderOption + .getNotificationDateTime(dateTime) + .millisecondsSinceEpoch ~/ + 1000, + ), + meta: { + ReminderMetaKeys.includeTime: data.includeTime.toString(), + ReminderMetaKeys.blockId: node.id, + }, + ), + ); + } + + void _handlePasteFromCopy( + BuildContext context, + String viewId, + EditorState editorState, + Node node, + int index, + _MentionDateBlockData data, + ) async { + final dateTime = DateTime.tryParse(data.dateString); + + if (node.delta == null) { + return; + } + + if (data.reminderId == null || dateTime == null) { + return; + } + + final reminderId = nanoid(); + getIt().add( + ReminderEvent.addById( + reminderId: reminderId, + objectId: viewId, + scheduledAt: Int64( + data.reminderOption + .getNotificationDateTime(dateTime) + .millisecondsSinceEpoch ~/ + 1000, + ), + meta: { + ReminderMetaKeys.includeTime: data.includeTime.toString(), + ReminderMetaKeys.blockId: node.id, + }, + ), + ); + + final newMentionAttributes = { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.date.name, + MentionBlockKeys.date: dateTime.toIso8601String(), + MentionBlockKeys.reminderId: reminderId, + MentionBlockKeys.includeTime: data.includeTime, + MentionBlockKeys.reminderOption: data.reminderOption.name, + }, + }; + + // The index is the index of the delta, to get the index of the mention character + // in all the text, we need to calculate it based on the deltas before the current delta. + int mentionIndex = 0; + for (final (i, delta) in node.delta!.indexed) { + if (i >= index) { + break; + } + + mentionIndex += delta.length; + } + + // Required to prevent editing the same spot at the same time + await Future.delayed(const Duration(milliseconds: 100)); + + final transaction = editorState.transaction + ..formatText( + node, + mentionIndex, + MentionBlockKeys.mentionChar.length, + newMentionAttributes, + ); + + await editorState.apply( + transaction, + options: const ApplyOptions(recordUndo: false), + ); + } +} + +/// A helper class to parse and store the mention date block data +class _MentionDateBlockData { + _MentionDateBlockData.fromData(Map data) { + dateString = switch (data[MentionBlockKeys.date]) { + final String string when DateTime.tryParse(string) != null => string, + _ => "", + }; + includeTime = switch (data[MentionBlockKeys.includeTime]) { + final bool flag => flag, + _ => false, + }; + reminderOption = switch (data[MentionBlockKeys.reminderOption]) { + final String name => + ReminderOption.values.firstWhereOrNull((o) => o.name == name) ?? + ReminderOption.none, + _ => ReminderOption.none, + }; + reminderId = switch (data[MentionBlockKeys.reminderId]) { + final String id + when id.isNotEmpty && reminderOption != ReminderOption.none => + id, + _ => null, + }; + } + + late final String dateString; + late final bool includeTime; + late final String? reminderId; + late final ReminderOption reminderOption; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart new file mode 100644 index 0000000000000..a5bc340f713e5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart @@ -0,0 +1,131 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +enum MentionType { + page, + date, + childPage; + + static MentionType fromString(String value) => switch (value) { + 'page' => page, + 'date' => date, + 'childPage' => childPage, + // Backwards compatibility + 'reminder' => date, + _ => throw UnimplementedError(), + }; +} + +Node dateMentionNode() { + return paragraphNode( + delta: Delta( + operations: [ + TextInsert( + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.date.name, + MentionBlockKeys.date: DateTime.now().toIso8601String(), + }, + }, + ), + ], + ), + ); +} + +class MentionBlockKeys { + const MentionBlockKeys._(); + + static const reminderId = 'reminder_id'; // ReminderID + static const mention = 'mention'; + static const type = 'type'; // MentionType, String + static const pageId = 'page_id'; + static const blockId = 'block_id'; + + // Related to Reminder and Date blocks + static const date = 'date'; // Start Date + static const includeTime = 'include_time'; + static const reminderOption = 'reminder_option'; + + static const mentionChar = '\$'; +} + +class MentionBlock extends StatelessWidget { + const MentionBlock({ + super.key, + required this.mention, + required this.node, + required this.index, + required this.textStyle, + }); + + final Map mention; + final Node node; + final int index; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final type = MentionType.fromString(mention[MentionBlockKeys.type]); + final editorState = context.read(); + + switch (type) { + case MentionType.page: + final String? pageId = mention[MentionBlockKeys.pageId] as String?; + if (pageId == null) { + return const SizedBox.shrink(); + } + final String? blockId = mention[MentionBlockKeys.blockId] as String?; + + return MentionPageBlock( + key: ValueKey(pageId), + editorState: editorState, + pageId: pageId, + blockId: blockId, + node: node, + textStyle: textStyle, + index: index, + ); + case MentionType.childPage: + final String? pageId = mention[MentionBlockKeys.pageId] as String?; + if (pageId == null) { + return const SizedBox.shrink(); + } + + return MentionSubPageBlock( + key: ValueKey(pageId), + editorState: editorState, + pageId: pageId, + node: node, + textStyle: textStyle, + index: index, + ); + + case MentionType.date: + final String date = mention[MentionBlockKeys.date]; + final reminderOption = ReminderOption.values.firstWhereOrNull( + (o) => o.name == mention[MentionBlockKeys.reminderOption], + ); + + return MentionDateBlock( + key: ValueKey(date), + editorState: editorState, + date: date, + node: node, + textStyle: textStyle, + index: index, + reminderId: mention[MentionBlockKeys.reminderId], + reminderOption: reminderOption ?? ReminderOption.none, + includeTime: mention[MentionBlockKeys.includeTime] ?? false, + ); + default: + return const SizedBox.shrink(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart new file mode 100644 index 0000000000000..b2190d67fa38d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart @@ -0,0 +1,386 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/mobile_date_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/date_time_format_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/user_time_format_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nanoid/non_secure.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class MentionDateBlock extends StatefulWidget { + const MentionDateBlock({ + super.key, + required this.editorState, + required this.date, + required this.index, + required this.node, + this.textStyle, + this.reminderId, + this.reminderOption = ReminderOption.none, + this.includeTime = false, + }); + + final EditorState editorState; + final String date; + final int index; + final Node node; + + /// If [isReminder] is true, then this must not be + /// null or empty + final String? reminderId; + + final ReminderOption reminderOption; + + final bool includeTime; + + final TextStyle? textStyle; + + @override + State createState() => _MentionDateBlockState(); +} + +class _MentionDateBlockState extends State { + final PopoverMutex mutex = PopoverMutex(); + + late bool _includeTime = widget.includeTime; + late DateTime? parsedDate = DateTime.tryParse(widget.date); + + @override + void didUpdateWidget(covariant oldWidget) { + parsedDate = DateTime.tryParse(widget.date); + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + mutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (parsedDate == null) { + return const SizedBox.shrink(); + } + + final appearance = context.read(); + final reminder = context.read(); + + if (appearance == null || reminder == null) { + return const SizedBox.shrink(); + } + + return BlocBuilder( + buildWhen: (previous, current) => + previous.dateFormat != current.dateFormat || + previous.timeFormat != current.timeFormat, + builder: (context, appearance) => + BlocBuilder( + builder: (context, state) { + final reminder = state.reminders + .firstWhereOrNull((r) => r.id == widget.reminderId); + + final formattedDate = appearance.dateFormat + .formatDate(parsedDate!, _includeTime, appearance.timeFormat); + + final options = DatePickerOptions( + focusedDay: parsedDate, + popoverMutex: mutex, + selectedDay: parsedDate, + includeTime: _includeTime, + dateFormat: appearance.dateFormat, + timeFormat: appearance.timeFormat, + selectedReminderOption: widget.reminderOption, + onIncludeTimeChanged: (includeTime, dateTime, _) { + _includeTime = includeTime; + + if (widget.reminderOption != ReminderOption.none) { + _updateReminder( + widget.reminderOption, + reminder, + includeTime, + ); + } else if (dateTime != null) { + parsedDate = dateTime; + _updateBlock( + dateTime, + includeTime: includeTime, + ); + } + }, + onDaySelected: (selectedDay) { + parsedDate = selectedDay; + + if (widget.reminderOption != ReminderOption.none) { + _updateReminder( + widget.reminderOption, + reminder, + _includeTime, + ); + } else { + _updateBlock(selectedDay, includeTime: _includeTime); + } + }, + onReminderSelected: (reminderOption) => + _updateReminder(reminderOption, reminder), + ); + + Color? color; + if (reminder != null) { + if (reminder.type == ReminderType.today) { + color = Theme.of(context).isLightMode + ? const Color(0xFFFE0299) + : Theme.of(context).colorScheme.error; + } + } + final textStyle = widget.textStyle?.copyWith( + color: color, + leadingDistribution: TextLeadingDistribution.even, + ); + + // when font size equals 14, the icon size is 16.0. + // scale the icon size based on the font size. + final iconSize = (widget.textStyle?.fontSize ?? 14.0) / 14.0 * 16.0; + + return GestureDetector( + onTapDown: (details) { + _showDatePicker( + context: context, + offset: details.globalPosition, + reminder: reminder, + options: options, + ); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '@$formattedDate', + style: textStyle, + strutStyle: textStyle != null + ? StrutStyle.fromTextStyle(textStyle) + : null, + ), + const HSpace(4), + FlowySvg( + widget.reminderId != null + ? FlowySvgs.reminder_clock_s + : FlowySvgs.date_s, + size: Size.square(iconSize), + color: textStyle?.color, + ), + ], + ), + ), + ); + }, + ), + ); + } + + void _updateBlock( + DateTime date, { + required bool includeTime, + String? reminderId, + ReminderOption? reminderOption, + }) { + final rId = reminderId ?? + (reminderOption == ReminderOption.none ? null : widget.reminderId); + + final transaction = widget.editorState.transaction + ..formatText(widget.node, widget.index, 1, { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.date.name, + MentionBlockKeys.date: date.toIso8601String(), + MentionBlockKeys.reminderId: rId, + MentionBlockKeys.includeTime: includeTime, + MentionBlockKeys.reminderOption: + reminderOption?.name ?? widget.reminderOption.name, + }, + }); + + widget.editorState.apply(transaction, withUpdateSelection: false); + + // Length of rendered block changes, this synchronizes + // the cursor with the new block render + widget.editorState.updateSelectionWithReason( + widget.editorState.selection, + ); + } + + void _updateReminder( + ReminderOption reminderOption, + ReminderPB? reminder, [ + bool includeTime = false, + ]) { + final rootContext = widget.editorState.document.root.context; + if (parsedDate == null || rootContext == null) { + return; + } + + if (widget.reminderId != null) { + _updateBlock( + parsedDate!, + includeTime: includeTime, + reminderOption: reminderOption, + ); + + if (ReminderOption.none == reminderOption && reminder != null) { + // Delete existing reminder + return rootContext + .read() + .add(ReminderEvent.remove(reminderId: reminder.id)); + } + + // Update existing reminder + return rootContext.read().add( + ReminderEvent.update( + ReminderUpdate( + id: widget.reminderId!, + scheduledAt: + reminderOption.getNotificationDateTime(parsedDate!), + date: parsedDate!, + ), + ), + ); + } + + final reminderId = nanoid(); + _updateBlock( + parsedDate!, + includeTime: includeTime, + reminderId: reminderId, + reminderOption: reminderOption, + ); + + // Add new reminder + final viewId = rootContext.read().documentId; + return rootContext.read().add( + ReminderEvent.add( + reminder: ReminderPB( + id: reminderId, + objectId: viewId, + title: LocaleKeys.reminderNotification_title.tr(), + message: LocaleKeys.reminderNotification_message.tr(), + meta: { + ReminderMetaKeys.includeTime: false.toString(), + ReminderMetaKeys.blockId: widget.node.id, + ReminderMetaKeys.createdAt: + DateTime.now().millisecondsSinceEpoch.toString(), + }, + scheduledAt: Int64(parsedDate!.millisecondsSinceEpoch ~/ 1000), + isAck: parsedDate!.isBefore(DateTime.now()), + ), + ), + ); + } + + void _showDatePicker({ + required BuildContext context, + required DatePickerOptions options, + required Offset offset, + ReminderPB? reminder, + }) { + if (!widget.editorState.editable) { + return; + } + if (UniversalPlatform.isMobile) { + SystemChannels.textInput.invokeMethod('TextInput.hide'); + + showMobileBottomSheet( + context, + builder: (_) => DraggableScrollableSheet( + expand: false, + snap: true, + initialChildSize: 0.7, + minChildSize: 0.4, + snapSizes: const [0.4, 0.7, 1.0], + builder: (_, controller) => _DatePickerBottomSheet( + controller: controller, + parsedDate: parsedDate, + options: options, + includeTime: _includeTime, + reminderOption: widget.reminderOption, + onReminderSelected: (option) => _updateReminder( + option, + reminder, + ), + ), + ), + ); + } else { + DatePickerMenu( + context: context, + editorState: widget.editorState, + ).show(offset, options: options); + } + } +} + +class _DatePickerBottomSheet extends StatelessWidget { + const _DatePickerBottomSheet({ + required this.controller, + required this.parsedDate, + required this.options, + required this.includeTime, + required this.reminderOption, + required this.onReminderSelected, + }); + + final ScrollController controller; + final DateTime? parsedDate; + final DatePickerOptions options; + final bool includeTime; + final ReminderOption reminderOption; + final void Function(ReminderOption) onReminderSelected; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.secondaryContainer, + child: ListView( + controller: controller, + children: [ + ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: const Center(child: DragHandle()), + ), + const MobileDateHeader(), + MobileAppFlowyDatePicker( + dateTime: parsedDate, + includeTime: includeTime, + isRange: options.isRange, + dateFormat: options.dateFormat.simplified, + timeFormat: options.timeFormat.simplified, + reminderOption: reminderOption, + onDaySelected: options.onDaySelected, + onIncludeTimeChanged: options.onIncludeTimeChanged, + onReminderSelected: onReminderSelected, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_bloc.dart new file mode 100644 index 0000000000000..28a698dde292c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_bloc.dart @@ -0,0 +1,258 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/application/document_listener.dart'; +import 'package:appflowy/plugins/document/application/document_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart'; +import 'package:appflowy/plugins/trash/application/prelude.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'mention_page_bloc.freezed.dart'; + +typedef MentionPageStatus = (ViewPB? view, bool isInTrash, bool isDeleted); + +class MentionPageBloc extends Bloc { + MentionPageBloc({ + required this.pageId, + this.blockId, + bool isSubPage = false, + }) : _isSubPage = isSubPage, + super(MentionPageState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + final (view, isInTrash, isDeleted) = + await ViewBackendService.getMentionPageStatus(pageId); + + String? blockContent; + if (!_isSubPage) { + blockContent = await _getBlockContent(); + } + + emit( + state.copyWith( + view: view, + isLoading: false, + isInTrash: isInTrash, + isDeleted: isDeleted, + blockContent: blockContent ?? '', + ), + ); + + if (view != null) { + _startListeningView(); + _startListeningTrash(); + + if (!_isSubPage) { + _startListeningDocument(); + } + } + }, + didUpdateViewStatus: (view, isDeleted) async { + emit( + state.copyWith( + view: view, + isDeleted: isDeleted ?? state.isDeleted, + ), + ); + }, + didUpdateTrashStatus: (isInTrash) async => + emit(state.copyWith(isInTrash: isInTrash)), + didUpdateBlockContent: (content) { + emit( + state.copyWith( + blockContent: content, + ), + ); + }, + ); + }, + ); + } + + @override + Future close() { + _viewListener?.stop(); + _trashListener?.close(); + _documentListener?.stop(); + return super.close(); + } + + final _documentService = DocumentService(); + + final String pageId; + final String? blockId; + final bool _isSubPage; + + ViewListener? _viewListener; + TrashListener? _trashListener; + + DocumentListener? _documentListener; + BlockPB? _block; + String? _blockTextId; + Delta? _initialDelta; + + void _startListeningView() { + _viewListener = ViewListener(viewId: pageId) + ..start( + onViewUpdated: (view) => add( + MentionPageEvent.didUpdateViewStatus(view: view, isDeleted: false), + ), + onViewDeleted: (_) => + add(const MentionPageEvent.didUpdateViewStatus(isDeleted: true)), + ); + } + + void _startListeningTrash() { + _trashListener = TrashListener() + ..start( + trashUpdated: (trashOrFailed) { + final trash = trashOrFailed.toNullable(); + if (trash != null) { + final isInTrash = trash.any((t) => t.id == pageId); + add(MentionPageEvent.didUpdateTrashStatus(isInTrash: isInTrash)); + } + }, + ); + } + + Future _convertDeltaToText(Delta? delta) async { + if (delta == null) { + return _initialDelta?.toPlainText() ?? ''; + } + + return delta.toText( + getMentionPageName: (mentionedPageId) async { + if (mentionedPageId == pageId) { + // if the mention page is the current page, return the view name + return state.view?.name ?? ''; + } else { + // if the mention page is not the current page, return the mention page name + final viewResult = await ViewBackendService.getView(mentionedPageId); + final name = viewResult.fold((l) => l.name, (f) => ''); + return name; + } + }, + ); + } + + Future _getBlockContent() async { + if (blockId == null) { + return null; + } + + final documentNodeResult = await _documentService.getDocumentNode( + documentId: pageId, + blockId: blockId!, + ); + final documentNode = documentNodeResult.fold((l) => l, (f) => null); + if (documentNode == null) { + Log.error( + 'unable to get the document node for block $blockId in page $pageId', + ); + return null; + } + + final block = documentNode.$2; + final node = documentNode.$3; + + _blockTextId = (node.externalValues as ExternalValues?)?.externalId; + _initialDelta = node.delta; + _block = block; + + return _convertDeltaToText(_initialDelta); + } + + void _startListeningDocument() { + // only observe the block content if the block id is not null + if (blockId == null || + _blockTextId == null || + _initialDelta == null || + _block == null) { + return; + } + + _documentListener = DocumentListener(id: pageId) + ..start( + onDocEventUpdate: (docEvent) { + for (final block in docEvent.events) { + for (final event in block.event) { + if (event.id == _blockTextId) { + if (event.command == DeltaTypePB.Updated) { + _updateBlockContent(event.value); + } else if (event.command == DeltaTypePB.Removed) { + add(const MentionPageEvent.didUpdateBlockContent('')); + } + } + } + } + }, + ); + } + + Future _updateBlockContent(String deltaJson) async { + if (_initialDelta == null || _block == null) { + return; + } + + try { + final incremental = Delta.fromJson(jsonDecode(deltaJson)); + final delta = _initialDelta!.compose(incremental); + final content = await _convertDeltaToText(delta); + add(MentionPageEvent.didUpdateBlockContent(content)); + _initialDelta = delta; + } catch (e) { + Log.error('failed to update block content: $e'); + } + } +} + +@freezed +class MentionPageEvent with _$MentionPageEvent { + const factory MentionPageEvent.initial() = _Initial; + const factory MentionPageEvent.didUpdateViewStatus({ + @Default(null) ViewPB? view, + @Default(null) bool? isDeleted, + }) = _DidUpdateViewStatus; + const factory MentionPageEvent.didUpdateTrashStatus({ + required bool isInTrash, + }) = _DidUpdateTrashStatus; + const factory MentionPageEvent.didUpdateBlockContent( + String content, + ) = _DidUpdateBlockContent; +} + +@freezed +class MentionPageState with _$MentionPageState { + const factory MentionPageState({ + required bool isLoading, + required bool isInTrash, + required bool isDeleted, + // non-null case: + // - page is found + // - page is in trash + // null case: + // - page is deleted + required ViewPB? view, + // the plain text content of the block + // it doesn't contain any formatting + required String blockContent, + }) = _MentionSubPageState; + + factory MentionPageState.initial() => const MentionPageState( + isLoading: true, + isInTrash: false, + isDeleted: false, + view: null, + blockContent: '', + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart new file mode 100644 index 0000000000000..f102c10daf498 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart @@ -0,0 +1,659 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart'; +import 'package:appflowy/plugins/trash/application/trash_service.dart'; +import 'package:appflowy/shared/clipboard_state.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + show + ApplyOptions, + Delta, + EditorState, + Node, + NodeIterator, + Path, + Position, + Selection, + SelectionType, + TextInsert, + TextTransaction, + paragraphNode; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +final pageMemorizer = {}; + +Node pageMentionNode(String viewId) { + return paragraphNode( + delta: Delta( + operations: [ + TextInsert( + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.page.name, + MentionBlockKeys.pageId: viewId, + }, + }, + ), + ], + ), + ); +} + +class MentionPageBlock extends StatefulWidget { + const MentionPageBlock({ + super.key, + required this.editorState, + required this.pageId, + required this.blockId, + required this.node, + required this.textStyle, + required this.index, + }); + + final EditorState editorState; + final String pageId; + final String? blockId; + final Node node; + final TextStyle? textStyle; + + // Used to update the block + final int index; + + @override + State createState() => _MentionPageBlockState(); +} + +class _MentionPageBlockState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => MentionPageBloc( + pageId: widget.pageId, + blockId: widget.blockId, + )..add(const MentionPageEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final view = state.view; + if (state.isLoading) { + return const SizedBox.shrink(); + } + + if (state.isDeleted || view == null) { + return _NoAccessMentionPageBlock( + textStyle: widget.textStyle, + ); + } + + if (UniversalPlatform.isMobile) { + return _MobileMentionPageBlock( + view: view, + content: state.blockContent, + textStyle: widget.textStyle, + handleTap: () => _handleTap( + context, + widget.editorState, + view, + blockId: widget.blockId, + ), + handleDoubleTap: () => _handleDoubleTap( + context, + widget.editorState, + view.id, + widget.node, + widget.index, + ), + ); + } else { + return _DesktopMentionPageBlock( + view: view, + content: state.blockContent, + textStyle: widget.textStyle, + showTrashHint: state.isInTrash, + handleTap: () => _handleTap( + context, + widget.editorState, + view, + blockId: widget.blockId, + ), + ); + } + }, + ), + ); + } + + void updateSelection() { + WidgetsBinding.instance.addPostFrameCallback( + (_) => widget.editorState + .updateSelectionWithReason(widget.editorState.selection), + ); + } +} + +class MentionSubPageBlock extends StatefulWidget { + const MentionSubPageBlock({ + super.key, + required this.editorState, + required this.pageId, + required this.node, + required this.textStyle, + required this.index, + }); + + final EditorState editorState; + final String pageId; + final Node node; + final TextStyle? textStyle; + + // Used to update the block + final int index; + + @override + State createState() => _MentionSubPageBlockState(); +} + +class _MentionSubPageBlockState extends State { + late bool isHandlingPaste = context.read().isHandlingPaste; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => MentionPageBloc(pageId: widget.pageId, isSubPage: true) + ..add(const MentionPageEvent.initial()), + child: BlocConsumer( + listener: (context, state) async { + if (state.view != null) { + final currentViewId = getIt().latestOpenView?.id; + if (currentViewId == null) { + return; + } + + if (state.view!.parentViewId != currentViewId) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + turnIntoPageRef(); + } + }); + } + } + }, + builder: (context, state) { + final view = state.view; + if (state.isLoading || isHandlingPaste) { + return const SizedBox.shrink(); + } + + if (state.isDeleted || view == null) { + return _DeletedPageBlock(textStyle: widget.textStyle); + } + + if (UniversalPlatform.isMobile) { + return _MobileMentionPageBlock( + view: view, + showTrashHint: state.isInTrash, + textStyle: widget.textStyle, + handleTap: () => _handleTap(context, widget.editorState, view), + isChildPage: true, + content: '', + handleDoubleTap: () => _handleDoubleTap( + context, + widget.editorState, + view.id, + widget.node, + widget.index, + ), + ); + } else { + return _DesktopMentionPageBlock( + view: view, + showTrashHint: state.isInTrash, + content: null, + textStyle: widget.textStyle, + isChildPage: true, + handleTap: () => _handleTap(context, widget.editorState, view), + ); + } + }, + ), + ); + } + + Future fetchView(String pageId) async { + final view = await ViewBackendService.getView(pageId).then( + (value) => value.toNullable(), + ); + + if (view == null) { + // try to fetch from trash + final trashViews = await TrashService().readTrash(); + final trash = trashViews.fold( + (l) => l.items.firstWhereOrNull((element) => element.id == pageId), + (r) => null, + ); + if (trash != null) { + return ViewPB() + ..id = trash.id + ..name = trash.name; + } + } + + return view; + } + + void updateSelection() { + WidgetsBinding.instance.addPostFrameCallback( + (_) => widget.editorState + .updateSelectionWithReason(widget.editorState.selection), + ); + } + + void turnIntoPageRef() { + final transaction = widget.editorState.transaction + ..formatText( + widget.node, + widget.index, + MentionBlockKeys.mentionChar.length, + { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.page.name, + MentionBlockKeys.pageId: widget.pageId, + }, + }, + ); + + widget.editorState.apply( + transaction, + withUpdateSelection: false, + options: const ApplyOptions( + recordUndo: false, + ), + ); + } +} + +Path? _findNodePathByBlockId(EditorState editorState, String blockId) { + final document = editorState.document; + final startNode = document.root.children.firstOrNull; + if (startNode == null) { + return null; + } + + final nodeIterator = NodeIterator( + document: document, + startNode: startNode, + ); + while (nodeIterator.moveNext()) { + final node = nodeIterator.current; + if (node.id == blockId) { + return node.path; + } + } + + return null; +} + +Future _handleTap( + BuildContext context, + EditorState editorState, + ViewPB view, { + String? blockId, +}) async { + final currentViewId = context.read().documentId; + if (currentViewId == view.id && blockId != null) { + // same page + final path = _findNodePathByBlockId(editorState, blockId); + if (path != null) { + editorState.scrollService?.jumpTo(path.first); + await editorState.updateSelectionWithReason( + Selection.collapsed(Position(path: path)), + customSelectionType: SelectionType.block, + ); + } + return; + } + + if (UniversalPlatform.isMobile) { + if (context.mounted && currentViewId != view.id) { + await context.pushView( + view, + blockId: blockId, + ); + } + } else { + final action = NavigationAction( + objectId: view.id, + arguments: { + ActionArgumentKeys.view: view, + ActionArgumentKeys.blockId: blockId, + }, + ); + getIt().add( + ActionNavigationEvent.performAction( + action: action, + ), + ); + } +} + +Future _handleDoubleTap( + BuildContext context, + EditorState editorState, + String viewId, + Node node, + int index, +) async { + if (!UniversalPlatform.isMobile) { + return; + } + + final currentViewId = context.read().documentId; + final newViewId = await showPageSelectorSheet( + context, + currentViewId: currentViewId, + selectedViewId: viewId, + ); + + if (newViewId != null) { + // Update this nodes pageId + final transaction = editorState.transaction + ..formatText( + node, + index, + 1, + { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.page.name, + MentionBlockKeys.pageId: newViewId, + }, + }, + ); + + await editorState.apply(transaction, withUpdateSelection: false); + } +} + +class _MentionPageBlockContent extends StatelessWidget { + const _MentionPageBlockContent({ + required this.view, + required this.textStyle, + this.content, + this.showTrashHint = false, + this.isChildPage = false, + }); + + final ViewPB view; + final TextStyle? textStyle; + final String? content; + final bool showTrashHint; + final bool isChildPage; + + @override + Widget build(BuildContext context) { + final text = _getDisplayText(context, view, content); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ..._buildPrefixIcons(context, view, content, isChildPage), + const HSpace(4), + Flexible( + child: FlowyText( + text, + decoration: TextDecoration.underline, + fontSize: textStyle?.fontSize, + fontWeight: textStyle?.fontWeight, + lineHeight: textStyle?.height, + overflow: TextOverflow.ellipsis, + ), + ), + if (showTrashHint) ...[ + FlowyText( + LocaleKeys.document_mention_trashHint.tr(), + fontSize: textStyle?.fontSize, + fontWeight: textStyle?.fontWeight, + lineHeight: textStyle?.height, + color: Theme.of(context).disabledColor, + decoration: TextDecoration.underline, + decorationColor: AFThemeExtension.of(context).textColor, + ), + ], + const HSpace(4), + ], + ); + } + + List _buildPrefixIcons( + BuildContext context, + ViewPB view, + String? content, + bool isChildPage, + ) { + final isSameDocument = _isSameDocument(context, view.id); + final shouldDisplayViewName = _shouldDisplayViewName( + context, + view.id, + content, + ); + final isBlockContentEmpty = content == null || content.isEmpty; + final emojiSize = textStyle?.fontSize ?? 12.0; + final iconSize = textStyle?.fontSize ?? 16.0; + + // if the block is from the same doc, display the paragraph mark icon '¶' + if (isSameDocument && !isBlockContentEmpty) { + return [ + const HSpace(2), + FlowySvg( + FlowySvgs.paragraph_mark_s, + size: Size.square(iconSize - 2.0), + color: Theme.of(context).hintColor, + ), + ]; + } else if (shouldDisplayViewName) { + return [ + const HSpace(4), + Stack( + children: [ + view.icon.value.isNotEmpty + ? EmojiIconWidget( + emoji: view.icon.toEmojiIconData(), + emojiSize: emojiSize, + ) + : view.defaultIcon(size: Size.square(iconSize + 2.0)), + if (!isChildPage) ...[ + const Positioned( + right: 0, + bottom: 0, + child: FlowySvg( + FlowySvgs.referenced_page_s, + blendMode: BlendMode.dstIn, + ), + ), + ], + ], + ), + ]; + } + + return []; + } + + String _getDisplayText( + BuildContext context, + ViewPB view, + String? blockContent, + ) { + final shouldDisplayViewName = _shouldDisplayViewName( + context, + view.id, + blockContent, + ); + + if (blockContent == null || blockContent.isEmpty) { + return shouldDisplayViewName ? view.name : ''; + } + + return shouldDisplayViewName + ? '${view.name} - $blockContent' + : blockContent; + } + + // display the view name or not + // if the block is from the same doc, + // 1. block content is not empty, display the **block content only**. + // 2. block content is empty, display the **view name**. + // if the block is from another doc, + // 1. block content is not empty, display the **view name and block content**. + // 2. block content is empty, display the **view name**. + bool _shouldDisplayViewName( + BuildContext context, + String viewId, + String? blockContent, + ) { + if (_isSameDocument(context, viewId)) { + return blockContent == null || blockContent.isEmpty; + } + return true; + } + + bool _isSameDocument(BuildContext context, String viewId) { + final currentViewId = context.read()?.documentId; + return viewId == currentViewId; + } +} + +class _NoAccessMentionPageBlock extends StatelessWidget { + const _NoAccessMentionPageBlock({required this.textStyle}); + + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + return FlowyHover( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: FlowyText( + LocaleKeys.document_mention_noAccess.tr(), + color: Theme.of(context).disabledColor, + decoration: TextDecoration.underline, + fontSize: textStyle?.fontSize, + fontWeight: textStyle?.fontWeight, + ), + ), + ); + } +} + +class _DeletedPageBlock extends StatelessWidget { + const _DeletedPageBlock({required this.textStyle}); + + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + return FlowyHover( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: FlowyText( + LocaleKeys.document_mention_deletedPage.tr(), + color: Theme.of(context).disabledColor, + decoration: TextDecoration.underline, + fontSize: textStyle?.fontSize, + fontWeight: textStyle?.fontWeight, + ), + ), + ); + } +} + +class _MobileMentionPageBlock extends StatelessWidget { + const _MobileMentionPageBlock({ + required this.view, + required this.content, + required this.textStyle, + required this.handleTap, + required this.handleDoubleTap, + this.showTrashHint = false, + this.isChildPage = false, + }); + + final TextStyle? textStyle; + final ViewPB view; + final String content; + final VoidCallback handleTap; + final VoidCallback handleDoubleTap; + final bool showTrashHint; + final bool isChildPage; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: handleTap, + onDoubleTap: handleDoubleTap, + behavior: HitTestBehavior.opaque, + child: _MentionPageBlockContent( + view: view, + content: content, + textStyle: textStyle, + showTrashHint: showTrashHint, + isChildPage: isChildPage, + ), + ); + } +} + +class _DesktopMentionPageBlock extends StatelessWidget { + const _DesktopMentionPageBlock({ + required this.view, + required this.textStyle, + required this.handleTap, + required this.content, + this.showTrashHint = false, + this.isChildPage = false, + }); + + final TextStyle? textStyle; + final ViewPB view; + final String? content; + final VoidCallback handleTap; + final bool showTrashHint; + final bool isChildPage; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: handleTap, + behavior: HitTestBehavior.opaque, + child: FlowyHover( + cursor: SystemMouseCursors.click, + child: _MentionPageBlockContent( + view: view, + content: content, + textStyle: textStyle, + showTrashHint: showTrashHint, + isChildPage: isChildPage, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart new file mode 100644 index 0000000000000..f3578f185ea21 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart @@ -0,0 +1,148 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +Future showPageSelectorSheet( + BuildContext context, { + String? currentViewId, + String? selectedViewId, + bool Function(ViewPB view)? filter, +}) async { + filter ??= (v) => !v.isSpace && v.parentViewId.isNotEmpty; + + return showMobileBottomSheet( + context, + title: LocaleKeys.document_mobilePageSelector_title.tr(), + showHeader: true, + showCloseButton: true, + showDragHandle: true, + useSafeArea: false, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (context) => ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 340, + minHeight: 80, + ), + child: _MobilePageSelectorBody( + currentViewId: currentViewId, + selectedViewId: selectedViewId, + filter: filter, + ), + ), + ); +} + +class _MobilePageSelectorBody extends StatefulWidget { + const _MobilePageSelectorBody({ + this.currentViewId, + this.selectedViewId, + this.filter, + }); + + final String? currentViewId; + final String? selectedViewId; + final bool Function(ViewPB view)? filter; + + @override + State<_MobilePageSelectorBody> createState() => + _MobilePageSelectorBodyState(); +} + +class _MobilePageSelectorBodyState extends State<_MobilePageSelectorBody> { + final searchController = TextEditingController(); + late final Future> _viewsFuture = _fetchViews(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + height: 44.0, + child: FlowySearchTextField( + controller: searchController, + onChanged: (_) => setState(() {}), + ), + ), + FutureBuilder( + future: _viewsFuture, + builder: (_, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + + if (snapshot.hasError || snapshot.data == null) { + return Center( + child: FlowyText( + LocaleKeys.document_mobilePageSelector_failedToLoad.tr(), + ), + ); + } + + final views = snapshot.data! + .where((v) => widget.filter?.call(v) ?? true) + .toList(); + + if (widget.currentViewId != null) { + views.removeWhere((v) => v.id == widget.currentViewId); + } + + final filtered = views.where( + (v) => + searchController.text.isEmpty || + v.name + .toLowerCase() + .contains(searchController.text.toLowerCase()), + ); + + if (filtered.isEmpty) { + return Center( + child: FlowyText( + LocaleKeys.document_mobilePageSelector_noPagesFound.tr(), + ), + ); + } + + return Flexible( + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (context, index) { + final view = filtered.elementAt(index); + return FlowyOptionTile.checkbox( + leftIcon: view.icon.value.isNotEmpty + ? RawEmojiIconWidget( + emoji: view.icon.toEmojiIconData(), + emojiSize: 18, + ) + : FlowySvg( + view.layout.icon, + size: const Size.square(20), + ), + text: view.name, + showTopBorder: index != 0, + showBottomBorder: false, + isSelected: view.id == widget.selectedViewId, + onTap: () => Navigator.of(context).pop(view), + ); + }, + ), + ); + }, + ), + ], + ); + } + + Future> _fetchViews() async => + (await ViewBackendService.getAllViews()).toNullable()?.items ?? []; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart new file mode 100644 index 0000000000000..8463030667f40 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/migration/editor_migration.dart @@ -0,0 +1,250 @@ +import 'dart:convert'; + +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:collection/collection.dart'; +import 'package:string_validator/string_validator.dart'; + +class EditorMigration { + // AppFlowy 0.1.x -> 0.2 + // + // The cover node has been deprecated, and use page/attributes/cover instead. + // cover node -> page/attributes/cover + // + // mark the textNode deprecated. use paragraph node instead. + // text node -> paragraph node + // delta -> attributes/delta + // + // mark the subtype deprecated. use type instead. + // for example, text/checkbox -> checkbox_list + // + // some attribute keys. + // ... + static Document migrateDocument(String json) { + final map = jsonDecode(json); + assert(map['document'] != null); + final documentV0 = Map.from(map['document'] as Map); + final rootV0 = NodeV0.fromJson(documentV0); + final root = migrateNode(rootV0); + return Document(root: root); + } + + static Node migrateNode(NodeV0 nodeV0) { + Node? node; + final children = nodeV0.children.map((e) => migrateNode(e)).toList(); + final id = nodeV0.id; + if (id == 'editor') { + final coverNode = children.firstWhereOrNull( + (element) => element.id == 'cover', + ); + if (coverNode != null) { + node = pageNode( + children: children, + attributes: coverNode.attributes, + ); + } else { + node = pageNode(children: children); + } + } else if (id == 'callout') { + final emoji = nodeV0.attributes['emoji'] ?? '📌'; + final delta = + nodeV0.children.whereType().fold(Delta(), (p, e) { + final delta = migrateDelta(e.delta); + final textInserts = delta.whereType(); + for (final element in textInserts) { + p.add(element); + } + return p..insert('\n'); + }); + node = calloutNode( + emoji: emoji, + delta: delta, + ); + } else if (id == 'divider') { + // divider -> divider + node = dividerNode(); + } else if (id == 'math_equation') { + // math_equation -> math_equation + final formula = nodeV0.attributes['math_equation'] ?? ''; + node = mathEquationNode(formula: formula); + } else if (nodeV0 is TextNodeV0) { + final delta = migrateDelta(nodeV0.delta); + final deltaJson = delta.toJson(); + final attributes = {'delta': deltaJson}; + if (id == 'text') { + // text -> paragraph + node = paragraphNode( + attributes: attributes, + children: children, + ); + } else if (nodeV0.id == 'text/heading') { + // text/heading -> heading + final heading = nodeV0.attributes.heading?.replaceAll('h', ''); + final level = int.tryParse(heading ?? '') ?? 1; + node = headingNode( + level: level, + attributes: attributes, + ); + } else if (id == 'text/checkbox') { + // text/checkbox -> todo_list + final checked = nodeV0.attributes.check; + node = todoListNode( + checked: checked, + attributes: attributes, + children: children, + ); + } else if (id == 'text/quote') { + // text/quote -> quote + node = quoteNode(attributes: attributes); + } else if (id == 'text/number-list') { + // text/number-list -> numbered_list + node = numberedListNode( + attributes: attributes, + children: children, + ); + } else if (id == 'text/bulleted-list') { + // text/bulleted-list -> bulleted_list + node = bulletedListNode( + attributes: attributes, + children: children, + ); + } else if (id == 'text/code_block') { + // text/code_block -> code + final language = nodeV0.attributes['language']; + node = codeBlockNode(delta: delta, language: language); + } + } else if (id == 'cover') { + node = paragraphNode(); + } + + return node ?? paragraphNode(text: jsonEncode(nodeV0.toJson())); + } + + // migrate the attributes. + // backgroundColor -> highlightColor + // color -> textColor + static Delta migrateDelta(Delta delta) { + final textInserts = delta + .whereType() + .map( + (e) => TextInsert( + e.text, + attributes: migrateAttributes(e.attributes), + ), + ) + .toList(growable: false); + return Delta(operations: textInserts.toList()); + } + + static Attributes? migrateAttributes(Attributes? attributes) { + if (attributes == null) { + return null; + } + const backgroundColor = 'backgroundColor'; + if (attributes.containsKey(backgroundColor)) { + attributes[AppFlowyRichTextKeys.backgroundColor] = + attributes[backgroundColor]; + attributes.remove(backgroundColor); + } + const color = 'color'; + if (attributes.containsKey(color)) { + attributes[AppFlowyRichTextKeys.textColor] = attributes[color]; + attributes.remove(color); + } + return attributes; + } + + // Before version 0.5.5, the cover is stored in the document root. + // Now, the cover is stored in the view.ext. + static void migrateCoverIfNeeded( + ViewPB view, + Attributes attributes, { + bool overwrite = false, + }) async { + if (view.extra.isNotEmpty && !overwrite) { + return; + } + + final coverType = CoverType.fromString( + attributes[DocumentHeaderBlockKeys.coverType], + ); + final coverDetails = attributes[DocumentHeaderBlockKeys.coverDetails]; + + Map extra = {}; + + if (coverType == CoverType.none || + coverDetails == null || + coverDetails is! String) { + extra = { + ViewExtKeys.coverKey: { + ViewExtKeys.coverTypeKey: PageStyleCoverImageType.none.toString(), + ViewExtKeys.coverValueKey: '', + }, + }; + } else { + switch (coverType) { + case CoverType.asset: + extra = { + ViewExtKeys.coverKey: { + ViewExtKeys.coverTypeKey: + PageStyleCoverImageType.builtInImage.toString(), + ViewExtKeys.coverValueKey: coverDetails, + }, + }; + break; + case CoverType.color: + extra = { + ViewExtKeys.coverKey: { + ViewExtKeys.coverTypeKey: + PageStyleCoverImageType.pureColor.toString(), + ViewExtKeys.coverValueKey: coverDetails, + }, + }; + break; + case CoverType.file: + if (isURL(coverDetails)) { + if (coverDetails.contains('unsplash')) { + extra = { + ViewExtKeys.coverKey: { + ViewExtKeys.coverTypeKey: + PageStyleCoverImageType.unsplashImage.toString(), + ViewExtKeys.coverValueKey: coverDetails, + }, + }; + } else { + extra = { + ViewExtKeys.coverKey: { + ViewExtKeys.coverTypeKey: + PageStyleCoverImageType.customImage.toString(), + ViewExtKeys.coverValueKey: coverDetails, + }, + }; + } + } + break; + default: + } + } + + if (extra.isEmpty) { + return; + } + + try { + final current = view.extra.isNotEmpty ? jsonDecode(view.extra) : {}; + final merged = mergeMaps(current, extra); + await ViewBackendService.updateView( + viewId: view.id, + extra: jsonEncode(merged), + ); + } catch (e) { + Log.error('Failed to migrating cover: $e'); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_floating_toolbar/custom_mobile_floating_toolbar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_floating_toolbar/custom_mobile_floating_toolbar.dart new file mode 100644 index 0000000000000..ba170e8d24c07 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_floating_toolbar/custom_mobile_floating_toolbar.dart @@ -0,0 +1,147 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; + +List buildMobileFloatingToolbarItems( + EditorState editorState, + Offset offset, + Function closeToolbar, +) { + // copy, paste, select, select all, cut + final selection = editorState.selection; + if (selection == null) { + return []; + } + final toolbarItems = []; + + if (!selection.isCollapsed) { + toolbarItems.add( + ContextMenuButtonItem( + label: LocaleKeys.editor_copy.tr(), + onPressed: () { + customCopyCommand.execute(editorState); + closeToolbar(); + }, + ), + ); + } + + toolbarItems.add( + ContextMenuButtonItem( + label: LocaleKeys.editor_paste.tr(), + onPressed: () { + customPasteCommand.execute(editorState); + closeToolbar(); + }, + ), + ); + + if (!selection.isCollapsed) { + toolbarItems.add( + ContextMenuButtonItem( + label: LocaleKeys.editor_cut.tr(), + onPressed: () { + cutCommand.execute(editorState); + closeToolbar(); + }, + ), + ); + } + + toolbarItems.add( + ContextMenuButtonItem( + label: LocaleKeys.editor_select.tr(), + onPressed: () { + editorState.selectWord(offset); + closeToolbar(); + }, + ), + ); + + toolbarItems.add( + ContextMenuButtonItem( + label: LocaleKeys.editor_selectAll.tr(), + onPressed: () { + selectAllCommand.execute(editorState); + closeToolbar(); + }, + ), + ); + + return toolbarItems; +} + +extension on EditorState { + void selectWord(Offset offset) { + final node = service.selectionService.getNodeInOffset(offset); + final selection = node?.selectable?.getWordBoundaryInOffset(offset); + if (selection == null) { + return; + } + updateSelectionWithReason(selection); + } +} + +class CustomMobileFloatingToolbar extends StatelessWidget { + const CustomMobileFloatingToolbar({ + super.key, + required this.editorState, + required this.anchor, + required this.closeToolbar, + }); + + final EditorState editorState; + final Offset anchor; + final VoidCallback closeToolbar; + + @override + Widget build(BuildContext context) { + return Animate( + autoPlay: true, + effects: _getEffects(context), + child: AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: buildMobileFloatingToolbarItems( + editorState, + anchor, + closeToolbar, + ), + anchors: TextSelectionToolbarAnchors( + primaryAnchor: anchor, + ), + ), + ); + } + + List _getEffects(BuildContext context) { + if (Platform.isIOS) { + final Size(:width, :height) = MediaQuery.of(context).size; + final alignmentX = (anchor.dx - width / 2) / (width / 2); + final alignmentY = (anchor.dy - height / 2) / (height / 2); + return [ + ScaleEffect( + curve: Curves.easeInOut, + alignment: Alignment(alignmentX, alignmentY), + duration: 250.milliseconds, + ), + ]; + } else if (Platform.isAndroid) { + return [ + const FadeEffect( + duration: SelectionOverlay.fadeDuration, + ), + MoveEffect( + curve: Curves.easeOutCubic, + begin: const Offset(0, 16), + duration: 100.milliseconds, + ), + ]; + } else { + return []; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_add_block_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_add_block_toolbar_item.dart new file mode 100644 index 0000000000000..f7a77a9816cae --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_add_block_toolbar_item.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension EditorStateAddBlock on EditorState { + Future insertMathEquation( + Selection selection, + ) async { + final path = selection.start.path; + final node = getNodeAtPath(path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + final transaction = this.transaction; + final insertedNode = mathEquationNode(); + if (delta.isEmpty) { + transaction + ..insertNode(path, insertedNode) + ..deleteNode(node); + } else { + transaction.insertNode( + path.next, + insertedNode, + ); + } + + await apply(transaction); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + final mathEquationState = getNodeAtPath(path)?.key.currentState; + if (mathEquationState != null && + mathEquationState is MathEquationBlockComponentWidgetState) { + mathEquationState.showEditingDialog(); + } + }); + } + + Future insertDivider(Selection selection) async { + // same as the [handler] of [dividerMenuItem] in Desktop + + final path = selection.end.path; + final node = getNodeAtPath(path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + final insertedPath = delta.isEmpty ? path : path.next; + final transaction = this.transaction; + transaction.insertNode(insertedPath, dividerNode()); + // only insert a new paragraph node when the next node is not a paragraph node + // and its delta is not empty. + final next = node.next; + if (next == null || + next.type != ParagraphBlockKeys.type || + next.delta?.isNotEmpty == true) { + transaction.insertNode( + insertedPath, + paragraphNode(), + ); + } + transaction.selectionExtraInfo = {}; + transaction.afterSelection = Selection.collapsed( + Position(path: insertedPath.next), + ); + await apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_block_settings_screen.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_block_settings_screen.dart new file mode 100644 index 0000000000000..faa5795d0a0c1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_block_settings_screen.dart @@ -0,0 +1,100 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +enum MobileBlockActionType { + delete, + duplicate, + insertAbove, + insertBelow, + color; + + static List get standard => [ + MobileBlockActionType.delete, + MobileBlockActionType.duplicate, + MobileBlockActionType.insertAbove, + MobileBlockActionType.insertBelow, + ]; + + static MobileBlockActionType fromActionString(String actionString) { + return MobileBlockActionType.values.firstWhere( + (e) => e.actionString == actionString, + orElse: () => throw Exception('Unknown action string: $actionString'), + ); + } + + String get actionString => toString(); + + FlowySvgData get icon { + return switch (this) { + MobileBlockActionType.delete => FlowySvgs.m_delete_m, + MobileBlockActionType.duplicate => FlowySvgs.m_duplicate_m, + MobileBlockActionType.insertAbove => FlowySvgs.arrow_up_s, + MobileBlockActionType.insertBelow => FlowySvgs.arrow_down_s, + MobileBlockActionType.color => FlowySvgs.m_color_m, + }; + } + + String get i18n { + return switch (this) { + MobileBlockActionType.delete => LocaleKeys.button_delete.tr(), + MobileBlockActionType.duplicate => LocaleKeys.button_duplicate.tr(), + MobileBlockActionType.insertAbove => LocaleKeys.button_insertAbove.tr(), + MobileBlockActionType.insertBelow => LocaleKeys.button_insertBelow.tr(), + MobileBlockActionType.color => + LocaleKeys.document_plugins_optionAction_color.tr(), + }; + } +} + +class MobileBlockSettingsScreen extends StatelessWidget { + const MobileBlockSettingsScreen({super.key, required this.actions}); + + final List actions; + + static const routeName = '/block_settings'; + + // the action string comes from the enum MobileBlockActionType + // example: MobileBlockActionType.delete.actionString, MobileBlockActionType.duplicate.actionString, etc. + static const supportedActions = 'actions'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + title: FlowyText.semibold( + LocaleKeys.titleBar_actions.tr(), + fontSize: 14.0, + ), + leading: const AppBarBackButton(), + ), + body: SafeArea( + child: ListView.separated( + itemCount: actions.length, + itemBuilder: (context, index) { + final action = actions[index]; + return FlowyButton( + text: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 18.0, + ), + child: FlowyText(action.i18n), + ), + leftIcon: FlowySvg(action.icon), + leftIconSize: const Size.square(24), + onTap: () {}, + ); + }, + separatorBuilder: (context, index) => const Divider( + height: 1.0, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart new file mode 100644 index 0000000000000..ad4d52381287b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart @@ -0,0 +1,23 @@ +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_edit_link_widget.dart'; +import 'package:flutter/material.dart'; + +Future showEditLinkBottomSheet( + BuildContext context, + String text, + String? href, + void Function(BuildContext context, String text, String href) onEdit, +) { + return showMobileBottomSheet( + context, + showDragHandle: true, + padding: const EdgeInsets.symmetric(horizontal: 16), + builder: (context) { + return MobileBottomSheetEditLinkWidget( + text: text, + href: href, + onEdit: (text, href) => onEdit(context, text, href), + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_get_selection_color.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_get_selection_color.dart new file mode 100644 index 0000000000000..b60eae3006895 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_get_selection_color.dart @@ -0,0 +1,28 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension SelectionColor on EditorState { + String? getSelectionColor(String key) { + final selection = this.selection; + if (selection == null) { + return null; + } + String? color = toggledStyle[key]; + if (color == null) { + if (selection.isCollapsed && selection.startIndex != 0) { + color = getDeltaAttributeValueInSelection( + key, + selection.copyWith( + start: selection.start.copyWith( + offset: selection.startIndex - 1, + ), + ), + ); + } else { + color = getDeltaAttributeValueInSelection( + key, + ); + } + } + return color; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_align_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_align_items.dart new file mode 100644 index 0000000000000..dccff22664967 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_align_items.dart @@ -0,0 +1,116 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_menu_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +const _left = 'left'; +const _center = 'center'; +const _right = 'right'; + +class AlignItems extends StatelessWidget { + AlignItems({ + super.key, + required this.editorState, + }); + + final EditorState editorState; + final List<(String, FlowySvgData)> _alignMenuItems = [ + (_left, FlowySvgs.m_aa_align_left_m), + (_center, FlowySvgs.m_aa_align_center_m), + (_right, FlowySvgs.m_aa_align_right_m), + ]; + + @override + Widget build(BuildContext context) { + final currentAlignItem = _getCurrentAlignItem(); + final theme = ToolbarColorExtension.of(context); + return PopupMenu( + itemLength: _alignMenuItems.length, + onSelected: (index) { + editorState.alignBlock( + _alignMenuItems[index].$1, + selectionExtraInfo: { + selectionExtraInfoDoNotAttachTextService: true, + selectionExtraInfoDisableFloatingToolbar: true, + }, + ); + }, + menuBuilder: (context, keys, currentIndex) { + final children = _alignMenuItems + .mapIndexed( + (index, e) => [ + PopupMenuItemWrapper( + key: keys[index], + isSelected: currentIndex == index, + icon: e.$2, + ), + if (index != 0 && index != _alignMenuItems.length - 1) + const HSpace(12), + ], + ) + .flattened + .toList(); + return PopupMenuWrapper( + child: Row( + mainAxisSize: MainAxisSize.min, + children: children, + ), + ); + }, + builder: (context, key) => MobileToolbarMenuItemWrapper( + key: key, + size: const Size(82, 52), + onTap: () async { + await editorState.alignBlock( + currentAlignItem.$1, + selectionExtraInfo: { + selectionExtraInfoDoNotAttachTextService: true, + selectionExtraInfoDisableFloatingToolbar: true, + }, + ); + }, + icon: currentAlignItem.$2, + isSelected: false, + iconPadding: const EdgeInsets.symmetric( + vertical: 14.0, + ), + showDownArrow: true, + backgroundColor: theme.toolbarMenuItemBackgroundColor, + ), + ); + } + + (String, FlowySvgData) _getCurrentAlignItem() { + final align = _getCurrentBlockAlign(); + if (align == _center) { + return (_right, FlowySvgs.m_aa_align_right_s); + } else if (align == _right) { + return (_left, FlowySvgs.m_aa_align_left_s); + } else { + return (_center, FlowySvgs.m_aa_align_center_s); + } + } + + String _getCurrentBlockAlign() { + final selection = editorState.selection; + if (selection == null) { + return _left; + } + final nodes = editorState.getNodesInSelection(selection); + String? alignString; + for (final node in nodes) { + final align = node.attributes[blockComponentAlign]; + if (alignString == null) { + alignString = align; + } else if (alignString != align) { + return _left; + } + } + return alignString ?? _left; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_bius_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_bius_items.dart new file mode 100644 index 0000000000000..0de86ffd6c026 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_bius_items.dart @@ -0,0 +1,82 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class BIUSItems extends StatelessWidget { + BIUSItems({ + super.key, + required this.editorState, + }); + + final EditorState editorState; + + final List<(FlowySvgData, String)> _bius = [ + (FlowySvgs.m_toolbar_bold_m, AppFlowyRichTextKeys.bold), + (FlowySvgs.m_toolbar_italic_m, AppFlowyRichTextKeys.italic), + (FlowySvgs.m_toolbar_underline_m, AppFlowyRichTextKeys.underline), + (FlowySvgs.m_toolbar_strike_m, AppFlowyRichTextKeys.strikethrough), + ]; + + @override + Widget build(BuildContext context) { + return IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + children: _bius + .mapIndexed( + (index, e) => [ + _buildBIUSItem( + context, + index, + e.$1, + e.$2, + ), + if (index != 0 || index != _bius.length - 1) + const ScaledVerticalDivider(), + ], + ) + .flattened + .toList(), + ), + ); + } + + Widget _buildBIUSItem( + BuildContext context, + int index, + FlowySvgData icon, + String richTextKey, + ) { + final theme = ToolbarColorExtension.of(context); + return StatefulBuilder( + builder: (_, setState) => MobileToolbarMenuItemWrapper( + size: const Size(62, 52), + enableTopLeftRadius: index == 0, + enableBottomLeftRadius: index == 0, + enableTopRightRadius: index == _bius.length - 1, + enableBottomRightRadius: index == _bius.length - 1, + backgroundColor: theme.toolbarMenuItemBackgroundColor, + onTap: () async { + await editorState.toggleAttribute( + richTextKey, + selectionExtraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ); + // refresh the status + setState(() {}); + }, + icon: icon, + isSelected: editorState.isTextDecorationSelected(richTextKey) && + editorState.toggledStyle[richTextKey] != false, + iconPadding: const EdgeInsets.symmetric( + vertical: 14.0, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart new file mode 100644 index 0000000000000..57670afadd768 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart @@ -0,0 +1,218 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_menu_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class BlockItems extends StatelessWidget { + BlockItems({ + super.key, + required this.service, + required this.editorState, + }); + + final EditorState editorState; + final AppFlowyMobileToolbarWidgetService service; + + final List<(FlowySvgData, String)> _blockItems = [ + (FlowySvgs.m_toolbar_bulleted_list_m, BulletedListBlockKeys.type), + (FlowySvgs.m_toolbar_numbered_list_m, NumberedListBlockKeys.type), + (FlowySvgs.m_aa_quote_m, QuoteBlockKeys.type), + ]; + + @override + Widget build(BuildContext context) { + return IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ..._blockItems + .mapIndexed( + (index, e) => [ + _buildBlockItem( + context, + index, + e.$1, + e.$2, + ), + if (index != 0) const ScaledVerticalDivider(), + ], + ) + .flattened, + // this item is a special case, use link item here instead of block item + _buildLinkItem(context), + ], + ), + ); + } + + Widget _buildBlockItem( + BuildContext context, + int index, + FlowySvgData icon, + String blockType, + ) { + final theme = ToolbarColorExtension.of(context); + return MobileToolbarMenuItemWrapper( + size: const Size(62, 54), + enableTopLeftRadius: index == 0, + enableBottomLeftRadius: index == 0, + enableTopRightRadius: false, + enableBottomRightRadius: false, + onTap: () async { + await _convert(blockType); + }, + backgroundColor: theme.toolbarMenuItemBackgroundColor, + icon: icon, + isSelected: editorState.isBlockTypeSelected(blockType), + iconPadding: const EdgeInsets.symmetric( + vertical: 14.0, + ), + ); + } + + Widget _buildLinkItem(BuildContext context) { + final theme = ToolbarColorExtension.of(context); + final items = [ + (AppFlowyRichTextKeys.code, FlowySvgs.m_aa_code_m), + // (InlineMathEquationKeys.formula, FlowySvgs.m_aa_math_s), + ]; + return PopupMenu( + itemLength: items.length, + onSelected: (index) async { + await editorState.toggleAttribute(items[index].$1); + }, + menuBuilder: (context, keys, currentIndex) { + final children = items + .mapIndexed( + (index, e) => [ + PopupMenuItemWrapper( + key: keys[index], + isSelected: currentIndex == index, + icon: e.$2, + ), + if (index != 0 || index != items.length - 1) const HSpace(12), + ], + ) + .flattened + .toList(); + return PopupMenuWrapper( + child: Row( + mainAxisSize: MainAxisSize.min, + children: children, + ), + ); + }, + builder: (context, key) => MobileToolbarMenuItemWrapper( + key: key, + size: const Size(62, 54), + enableTopLeftRadius: false, + enableBottomLeftRadius: false, + showDownArrow: true, + onTap: _onLinkItemTap, + backgroundColor: theme.toolbarMenuItemBackgroundColor, + icon: FlowySvgs.m_toolbar_link_m, + isSelected: false, + iconPadding: const EdgeInsets.symmetric( + vertical: 14.0, + ), + ), + ); + } + + void _onLinkItemTap() async { + final selection = editorState.selection; + if (selection == null) { + return; + } + final nodes = editorState.getNodesInSelection(selection); + // show edit link bottom sheet + final context = nodes.firstOrNull?.context; + if (context != null) { + _closeKeyboard(selection); + + // keep the selection + unawaited( + editorState.updateSelectionWithReason( + selection, + extraInfo: { + selectionExtraInfoDisableMobileToolbarKey: true, + selectionExtraInfoDoNotAttachTextService: true, + selectionExtraInfoDisableFloatingToolbar: true, + }, + ), + ); + keepEditorFocusNotifier.increase(); + + final text = editorState + .getTextInSelection( + selection, + ) + .join(); + final href = editorState.getDeltaAttributeValueInSelection( + AppFlowyRichTextKeys.href, + selection, + ); + await showEditLinkBottomSheet( + context, + text, + href, + (context, newText, newHref) { + editorState.updateTextAndHref( + text, + href, + newText, + newHref, + selection: selection, + ); + context.pop(true); + }, + ); + // re-open the keyboard again + unawaited( + editorState.updateSelectionWithReason( + selection, + extraInfo: {}, + ), + ); + } + } + + void _closeKeyboard(Selection selection) { + editorState.updateSelectionWithReason( + selection, + extraInfo: { + selectionExtraInfoDisableMobileToolbarKey: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ); + editorState.service.keyboardService?.closeKeyboard(); + } + + Future _convert(String blockType) async { + await editorState.convertBlockType( + blockType, + selectionExtraInfo: { + selectionExtraInfoDoNotAttachTextService: true, + selectionExtraInfoDisableFloatingToolbar: true, + }, + ); + unawaited( + editorState.updateSelectionWithReason( + editorState.selection, + extraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_close_keyboard_or_menu_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_close_keyboard_or_menu_button.dart new file mode 100644 index 0000000000000..4c91a00bc78dd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_close_keyboard_or_menu_button.dart @@ -0,0 +1,26 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class CloseKeyboardOrMenuButton extends StatelessWidget { + const CloseKeyboardOrMenuButton({ + super.key, + required this.onPressed, + }); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 62, + height: 42, + child: FlowyButton( + text: const FlowySvg( + FlowySvgs.m_toolbar_keyboard_m, + ), + onTap: onPressed, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_item.dart new file mode 100644 index 0000000000000..997faaf2a94d7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_item.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/font_colors.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_get_selection_color.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class ColorItem extends StatelessWidget { + const ColorItem({ + super.key, + required this.editorState, + required this.service, + }); + + final EditorState editorState; + final AppFlowyMobileToolbarWidgetService service; + + @override + Widget build(BuildContext context) { + final theme = ToolbarColorExtension.of(context); + final String? selectedTextColor = + editorState.getSelectionColor(AppFlowyRichTextKeys.textColor); + final String? selectedBackgroundColor = + editorState.getSelectionColor(AppFlowyRichTextKeys.backgroundColor); + final backgroundColor = EditorFontColors.fromBuiltInColors( + context, + selectedBackgroundColor?.tryToColor(), + ); + return MobileToolbarMenuItemWrapper( + size: const Size(82, 52), + onTap: () async { + service.closeKeyboard(); + unawaited( + editorState.updateSelectionWithReason( + editorState.selection, + extraInfo: { + selectionExtraInfoDisableMobileToolbarKey: true, + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ), + ); + keepEditorFocusNotifier.increase(); + await showTextColorAndBackgroundColorPicker( + context, + editorState: editorState, + selection: editorState.selection!, + ); + }, + icon: FlowySvgs.m_aa_font_color_m, + iconColor: EditorFontColors.fromBuiltInColors( + context, + selectedTextColor?.tryToColor(), + ), + backgroundColor: backgroundColor ?? theme.toolbarMenuItemBackgroundColor, + selectedBackgroundColor: backgroundColor, + isSelected: selectedBackgroundColor != null, + showRightArrow: true, + iconPadding: const EdgeInsets.only( + top: 14.0, + bottom: 14.0, + right: 28.0, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart new file mode 100644 index 0000000000000..d3d4c5daa9624 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart @@ -0,0 +1,304 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/font_colors.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/_get_selection_color.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +const _count = 6; + +Future showTextColorAndBackgroundColorPicker( + BuildContext context, { + required EditorState editorState, + required Selection selection, +}) async { + final theme = ToolbarColorExtension.of(context); + await showMobileBottomSheet( + context, + showHeader: true, + showDragHandle: true, + showDoneButton: true, + barrierColor: Colors.transparent, + backgroundColor: theme.toolbarMenuBackgroundColor, + elevation: 20, + title: LocaleKeys.grid_selectOption_colorPanelTitle.tr(), + padding: const EdgeInsets.fromLTRB(10, 4, 10, 8), + builder: (context) { + return _TextColorAndBackgroundColor( + editorState: editorState, + selection: selection, + ); + }, + ); + Future.delayed(const Duration(milliseconds: 100), () { + // highlight the selected text again. + editorState.updateSelectionWithReason( + selection, + extraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + }, + ); + }); +} + +class _TextColorAndBackgroundColor extends StatefulWidget { + const _TextColorAndBackgroundColor({ + required this.editorState, + required this.selection, + }); + + final EditorState editorState; + final Selection selection; + + @override + State<_TextColorAndBackgroundColor> createState() => + _TextColorAndBackgroundColorState(); +} + +class _TextColorAndBackgroundColorState + extends State<_TextColorAndBackgroundColor> { + @override + Widget build(BuildContext context) { + final String? selectedTextColor = + widget.editorState.getSelectionColor(AppFlowyRichTextKeys.textColor); + final String? selectedBackgroundColor = widget.editorState + .getSelectionColor(AppFlowyRichTextKeys.backgroundColor); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + top: 20, + left: 6.0, + ), + child: FlowyText( + LocaleKeys.editor_textColor.tr(), + fontSize: 14.0, + ), + ), + const VSpace(6.0), + EditorTextColorWidget( + selectedColor: selectedTextColor?.tryToColor(), + onSelectedColor: (textColor) async { + final hex = textColor.alpha == 0 ? null : textColor.toHex(); + final selection = widget.selection; + if (selection.isCollapsed) { + widget.editorState.updateToggledStyle( + AppFlowyRichTextKeys.textColor, + hex ?? '', + ); + } else { + await widget.editorState.formatDelta( + widget.selection, + { + AppFlowyRichTextKeys.textColor: hex, + }, + selectionExtraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDisableMobileToolbarKey: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ); + } + setState(() {}); + }, + ), + Padding( + padding: const EdgeInsets.only( + top: 18.0, + left: 6.0, + ), + child: FlowyText( + LocaleKeys.editor_backgroundColor.tr(), + fontSize: 14.0, + ), + ), + const VSpace(6.0), + EditorBackgroundColors( + selectedColor: selectedBackgroundColor?.tryToColor(), + onSelectedColor: (backgroundColor) async { + final hex = + backgroundColor.alpha == 0 ? null : backgroundColor.toHex(); + final selection = widget.selection; + if (selection.isCollapsed) { + widget.editorState.updateToggledStyle( + AppFlowyRichTextKeys.backgroundColor, + hex ?? '', + ); + } else { + await widget.editorState.formatDelta( + widget.selection, + { + AppFlowyRichTextKeys.backgroundColor: hex, + }, + selectionExtraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDisableMobileToolbarKey: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ); + } + setState(() {}); + }, + ), + ], + ); + } +} + +class EditorBackgroundColors extends StatelessWidget { + const EditorBackgroundColors({ + super.key, + this.selectedColor, + required this.onSelectedColor, + }); + + final Color? selectedColor; + final void Function(Color color) onSelectedColor; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).brightness == Brightness.light + ? EditorFontColors.lightColors + : EditorFontColors.darkColors; + return GridView.count( + crossAxisCount: _count, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: colors.mapIndexed( + (index, color) { + return _BackgroundColorItem( + color: color, + isSelected: + selectedColor == null ? index == 0 : selectedColor == color, + onTap: () => onSelectedColor(color), + ); + }, + ).toList(), + ); + } +} + +class _BackgroundColorItem extends StatelessWidget { + const _BackgroundColorItem({ + required this.color, + required this.isSelected, + required this.onTap, + }); + + final VoidCallback onTap; + final Color color; + final bool isSelected; + + @override + Widget build(BuildContext context) { + final theme = ToolbarColorExtension.of(context); + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.all(6.0), + decoration: BoxDecoration( + color: color, + borderRadius: Corners.s12Border, + border: Border.all( + width: isSelected ? 2.0 : 1.0, + color: isSelected + ? theme.toolbarMenuItemSelectedBackgroundColor + : Theme.of(context).dividerColor, + ), + ), + alignment: Alignment.center, + child: isSelected + ? const FlowySvg( + FlowySvgs.m_blue_check_s, + size: Size.square(28.0), + blendMode: null, + ) + : null, + ), + ); + } +} + +class EditorTextColorWidget extends StatelessWidget { + EditorTextColorWidget({ + super.key, + this.selectedColor, + required this.onSelectedColor, + }); + + final Color? selectedColor; + final void Function(Color color) onSelectedColor; + + final colors = [ + const Color(0x00FFFFFF), + const Color(0xFFDB3636), + const Color(0xFFEA8F06), + const Color(0xFF18A166), + const Color(0xFF205EEE), + const Color(0xFFC619C9), + ]; + + @override + Widget build(BuildContext context) { + return GridView.count( + crossAxisCount: _count, + shrinkWrap: true, + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + children: colors.mapIndexed( + (index, color) { + return _TextColorItem( + color: color, + isSelected: + selectedColor == null ? index == 0 : selectedColor == color, + onTap: () => onSelectedColor(color), + ); + }, + ).toList(), + ); + } +} + +class _TextColorItem extends StatelessWidget { + const _TextColorItem({ + required this.color, + required this.isSelected, + required this.onTap, + }); + + final VoidCallback onTap; + final Color color; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.all(6.0), + decoration: BoxDecoration( + borderRadius: Corners.s12Border, + border: Border.all( + width: isSelected ? 2.0 : 1.0, + color: isSelected + ? const Color(0xff00C6F1) + : Theme.of(context).dividerColor, + ), + ), + alignment: Alignment.center, + child: FlowyText( + 'A', + fontSize: 24, + color: color.alpha == 0 ? null : color, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_font_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_font_item.dart new file mode 100644 index 0000000000000..96431996f5a4d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_font_item.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/google_fonts_extension.dart'; +import 'package:appflowy/util/font_family_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +class FontFamilyItem extends StatelessWidget { + const FontFamilyItem({ + super.key, + required this.editorState, + }); + + final EditorState editorState; + + @override + Widget build(BuildContext context) { + final theme = ToolbarColorExtension.of(context); + final fontFamily = _getCurrentSelectedFontFamilyName(); + final systemFonFamily = + context.read().state.fontFamily; + return MobileToolbarMenuItemWrapper( + size: const Size(144, 52), + onTap: () async { + final selection = editorState.selection; + if (selection == null) { + return; + } + // disable the floating toolbar + unawaited( + editorState.updateSelectionWithReason( + selection, + extraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDisableMobileToolbarKey: true, + }, + ), + ); + + final newFont = await context + .read() + .push(FontPickerScreen.routeName); + + // if the selection is not collapsed, apply the font to the selection. + if (newFont != null && !selection.isCollapsed) { + if (newFont != fontFamily) { + await editorState.formatDelta(selection, { + AppFlowyRichTextKeys.fontFamily: newFont, + }); + } + } + + // wait for the font picker screen to be dismissed. + Future.delayed(const Duration(milliseconds: 250), () { + // highlight the selected text again. + editorState.updateSelectionWithReason( + selection, + extraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDisableMobileToolbarKey: false, + }, + ); + // if the selection is collapsed, save the font for the next typing. + if (newFont != null && selection.isCollapsed) { + editorState.updateToggledStyle( + AppFlowyRichTextKeys.fontFamily, + getGoogleFontSafely(newFont).fontFamily, + ); + } + }); + }, + text: (fontFamily ?? systemFonFamily).fontFamilyDisplayName, + fontFamily: fontFamily ?? systemFonFamily, + backgroundColor: theme.toolbarMenuItemBackgroundColor, + isSelected: false, + enable: true, + showRightArrow: true, + iconPadding: const EdgeInsets.only( + top: 14.0, + bottom: 14.0, + left: 14.0, + right: 12.0, + ), + textPadding: const EdgeInsets.only( + right: 16.0, + ), + ); + } + + String? _getCurrentSelectedFontFamilyName() { + final toggleFontFamily = + editorState.toggledStyle[AppFlowyRichTextKeys.fontFamily]; + if (toggleFontFamily is String && toggleFontFamily.isNotEmpty) { + return toggleFontFamily; + } + final selection = editorState.selection; + if (selection != null && + selection.isCollapsed && + selection.startIndex != 0) { + return editorState.getDeltaAttributeValueInSelection( + AppFlowyRichTextKeys.fontFamily, + selection.copyWith( + start: selection.start.copyWith( + offset: selection.startIndex - 1, + ), + ), + ); + } + return editorState.getDeltaAttributeValueInSelection( + AppFlowyRichTextKeys.fontFamily, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_heading_and_text_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_heading_and_text_items.dart new file mode 100644 index 0000000000000..b98a6fddffb3e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_heading_and_text_items.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class HeadingsAndTextItems extends StatelessWidget { + const HeadingsAndTextItems({ + super.key, + required this.editorState, + }); + + final EditorState editorState; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _HeadingOrTextItem( + icon: FlowySvgs.m_aa_h1_m, + blockType: HeadingBlockKeys.type, + editorState: editorState, + level: 1, + ), + _HeadingOrTextItem( + icon: FlowySvgs.m_aa_h2_m, + blockType: HeadingBlockKeys.type, + editorState: editorState, + level: 2, + ), + _HeadingOrTextItem( + icon: FlowySvgs.m_aa_h3_m, + blockType: HeadingBlockKeys.type, + editorState: editorState, + level: 3, + ), + _HeadingOrTextItem( + icon: FlowySvgs.m_aa_paragraph_m, + blockType: ParagraphBlockKeys.type, + editorState: editorState, + ), + ], + ); + } +} + +class _HeadingOrTextItem extends StatelessWidget { + const _HeadingOrTextItem({ + required this.icon, + required this.blockType, + required this.editorState, + this.level, + }); + + final FlowySvgData icon; + final String blockType; + final EditorState editorState; + final int? level; + + @override + Widget build(BuildContext context) { + final isSelected = editorState.isBlockTypeSelected( + blockType, + level: level, + ); + final padding = level != null + ? EdgeInsets.symmetric( + vertical: 14.0 - (3 - level!) * 3.0, + ) + : const EdgeInsets.symmetric( + vertical: 16.0, + ); + return MobileToolbarMenuItemWrapper( + size: const Size(76, 52), + onTap: () async => _convert(isSelected), + icon: icon, + isSelected: isSelected, + iconPadding: padding, + ); + } + + Future _convert(bool isSelected) async { + await editorState.convertBlockType( + blockType, + isSelected: isSelected, + extraAttributes: level != null + ? { + HeadingBlockKeys.level: level!, + } + : null, + selectionExtraInfo: { + selectionExtraInfoDoNotAttachTextService: true, + selectionExtraInfoDisableFloatingToolbar: true, + }, + ); + unawaited( + editorState.updateSelectionWithReason( + editorState.selection, + extraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_indent_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_indent_items.dart new file mode 100644 index 0000000000000..2ddcd4dacbfb7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_indent_items.dart @@ -0,0 +1,56 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class IndentAndOutdentItems extends StatelessWidget { + const IndentAndOutdentItems({ + super.key, + required this.service, + required this.editorState, + }); + + final EditorState editorState; + final AppFlowyMobileToolbarWidgetService service; + + @override + Widget build(BuildContext context) { + final theme = ToolbarColorExtension.of(context); + return IntrinsicHeight( + child: Row( + children: [ + MobileToolbarMenuItemWrapper( + size: const Size(95, 52), + icon: FlowySvgs.m_aa_outdent_m, + enable: isOutdentable(editorState), + isSelected: false, + enableTopRightRadius: false, + enableBottomRightRadius: false, + iconPadding: const EdgeInsets.symmetric(vertical: 14.0), + backgroundColor: theme.toolbarMenuItemBackgroundColor, + onTap: () { + service.closeItemMenu(); + outdentCommand.execute(editorState); + }, + ), + const ScaledVerticalDivider(), + MobileToolbarMenuItemWrapper( + size: const Size(95, 52), + icon: FlowySvgs.m_aa_indent_m, + enable: isIndentable(editorState), + isSelected: false, + enableTopLeftRadius: false, + enableBottomLeftRadius: false, + iconPadding: const EdgeInsets.symmetric(vertical: 14.0), + backgroundColor: theme.toolbarMenuItemBackgroundColor, + onTap: () { + service.closeItemMenu(); + indentCommand.execute(editorState); + }, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_menu_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_menu_item.dart new file mode 100644 index 0000000000000..7464514f9345e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_menu_item.dart @@ -0,0 +1,71 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:flowy_svg/flowy_svg.dart'; +import 'package:flutter/material.dart'; + +class PopupMenuItemWrapper extends StatelessWidget { + const PopupMenuItemWrapper({ + super.key, + required this.isSelected, + required this.icon, + }); + + final bool isSelected; + final FlowySvgData icon; + + @override + Widget build(BuildContext context) { + final theme = ToolbarColorExtension.of(context); + return Container( + width: 62, + height: 44, + decoration: ShapeDecoration( + color: isSelected ? theme.toolbarMenuItemSelectedBackgroundColor : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9), + child: FlowySvg( + icon, + color: isSelected + ? theme.toolbarMenuIconSelectedColor + : theme.toolbarMenuIconColor, + ), + ); + } +} + +class PopupMenuWrapper extends StatelessWidget { + const PopupMenuWrapper({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ToolbarColorExtension.of(context); + return Container( + height: 64, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: ShapeDecoration( + color: theme.toolbarMenuBackgroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + shadows: [ + BoxShadow( + color: theme.toolbarShadowColor, + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart new file mode 100644 index 0000000000000..d678d7c0bacb8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_popup_menu.dart @@ -0,0 +1,144 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class PopupMenu extends StatefulWidget { + const PopupMenu({ + super.key, + required this.onSelected, + required this.itemLength, + required this.menuBuilder, + required this.builder, + }); + + final Widget Function(BuildContext context, Key key) builder; + final int itemLength; + final Widget Function( + BuildContext context, + List keys, + int currentIndex, + ) menuBuilder; + final void Function(int index) onSelected; + + @override + State createState() => _PopupMenuState(); +} + +class _PopupMenuState extends State { + final key = GlobalKey(); + final indexNotifier = ValueNotifier(-1); + late List itemKeys; + + OverlayEntry? popupMenuOverlayEntry; + + Rect get rect { + final RenderBox renderBox = + key.currentContext!.findRenderObject() as RenderBox; + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + return Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height); + } + + @override + void initState() { + super.initState(); + + indexNotifier.value = widget.itemLength - 1; + itemKeys = List.generate( + widget.itemLength, + (_) => GlobalKey(), + ); + + indexNotifier.addListener(HapticFeedback.mediumImpact); + } + + @override + void dispose() { + indexNotifier.removeListener(HapticFeedback.mediumImpact); + indexNotifier.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPressStart: (details) { + _showMenu(context); + }, + onLongPressMoveUpdate: (details) { + _updateSelection(details); + }, + onLongPressCancel: () { + _hideMenu(); + }, + onLongPressUp: () { + if (indexNotifier.value != -1) { + widget.onSelected(indexNotifier.value); + } + _hideMenu(); + }, + child: widget.builder(context, key), + ); + } + + void _showMenu(BuildContext context) { + final theme = ToolbarColorExtension.of(context); + _hideMenu(); + + indexNotifier.value = widget.itemLength - 1; + popupMenuOverlayEntry ??= OverlayEntry( + builder: (context) { + final screenSize = MediaQuery.of(context).size; + final right = screenSize.width - rect.right; + final bottom = screenSize.height - rect.top + 16; + return Positioned( + right: right, + bottom: bottom, + child: ColoredBox( + color: theme.toolbarMenuBackgroundColor, + child: ValueListenableBuilder( + valueListenable: indexNotifier, + builder: (context, value, _) => widget.menuBuilder( + context, + itemKeys, + value, + ), + ), + ), + ); + }, + ); + Overlay.of(context).insert(popupMenuOverlayEntry!); + } + + void _hideMenu() { + indexNotifier.value = -1; + + popupMenuOverlayEntry?.remove(); + popupMenuOverlayEntry = null; + } + + void _updateSelection(LongPressMoveUpdateDetails details) { + final dx = details.globalPosition.dx; + for (var i = 0; i < itemKeys.length; i++) { + final key = itemKeys[i]; + final RenderBox renderBox = + key.currentContext!.findRenderObject() as RenderBox; + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + final rect = Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height); + // ignore the position overflow + if ((i == 0 && dx < rect.left) || + (i == itemKeys.length - 1 && dx > rect.right)) { + indexNotifier.value = -1; + break; + } + if (rect.left <= dx && dx <= rect.right) { + indexNotifier.value = itemKeys.indexOf(key); + break; + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart new file mode 100644 index 0000000000000..d35b8d56dffba --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart @@ -0,0 +1,173 @@ +// workaround for toolbar theme color. + +import 'package:flutter/material.dart'; + +class ToolbarColorExtension extends ThemeExtension { + factory ToolbarColorExtension.light() => const ToolbarColorExtension( + toolbarBackgroundColor: Color(0xFFFFFFFF), + toolbarItemIconColor: Color(0xFF1F2329), + toolbarItemIconDisabledColor: Color(0xFF999BA0), + toolbarItemIconSelectedColor: Color(0x1F232914), + toolbarItemSelectedBackgroundColor: Color(0xFFF2F2F2), + toolbarMenuBackgroundColor: Color(0xFFFFFFFF), + toolbarMenuItemBackgroundColor: Color(0xFFF2F2F7), + toolbarMenuItemSelectedBackgroundColor: Color(0xFF00BCF0), + toolbarMenuIconColor: Color(0xFF1F2329), + toolbarMenuIconDisabledColor: Color(0xFF999BA0), + toolbarMenuIconSelectedColor: Color(0xFFFFFFFF), + toolbarShadowColor: Color(0x2D000000), + ); + + factory ToolbarColorExtension.dark() => const ToolbarColorExtension( + toolbarBackgroundColor: Color(0xFF1F2329), + toolbarItemIconColor: Color(0xFFF3F3F8), + toolbarItemIconDisabledColor: Color(0xFF55565B), + toolbarItemIconSelectedColor: Color(0xFF00BCF0), + toolbarItemSelectedBackgroundColor: Color(0xFF3A3D43), + toolbarMenuBackgroundColor: Color(0xFF23262B), + toolbarMenuItemBackgroundColor: Color(0xFF2D3036), + toolbarMenuItemSelectedBackgroundColor: Color(0xFF00BCF0), + toolbarMenuIconColor: Color(0xFFF3F3F8), + toolbarMenuIconDisabledColor: Color(0xFF55565B), + toolbarMenuIconSelectedColor: Color(0xFF1F2329), + toolbarShadowColor: Color.fromARGB(80, 112, 112, 112), + ); + + factory ToolbarColorExtension.fromBrightness(Brightness brightness) => + brightness == Brightness.light + ? ToolbarColorExtension.light() + : ToolbarColorExtension.dark(); + + const ToolbarColorExtension({ + required this.toolbarBackgroundColor, + required this.toolbarItemIconColor, + required this.toolbarItemIconDisabledColor, + required this.toolbarItemIconSelectedColor, + required this.toolbarMenuBackgroundColor, + required this.toolbarMenuItemBackgroundColor, + required this.toolbarMenuItemSelectedBackgroundColor, + required this.toolbarItemSelectedBackgroundColor, + required this.toolbarMenuIconColor, + required this.toolbarMenuIconDisabledColor, + required this.toolbarMenuIconSelectedColor, + required this.toolbarShadowColor, + }); + + final Color toolbarBackgroundColor; + + final Color toolbarItemIconColor; + final Color toolbarItemIconDisabledColor; + final Color toolbarItemIconSelectedColor; + final Color toolbarItemSelectedBackgroundColor; + + final Color toolbarMenuBackgroundColor; + final Color toolbarMenuItemBackgroundColor; + final Color toolbarMenuItemSelectedBackgroundColor; + final Color toolbarMenuIconColor; + final Color toolbarMenuIconDisabledColor; + final Color toolbarMenuIconSelectedColor; + + final Color toolbarShadowColor; + + static ToolbarColorExtension of(BuildContext context) { + return Theme.of(context).extension()!; + } + + @override + ToolbarColorExtension copyWith({ + Color? toolbarBackgroundColor, + Color? toolbarItemIconColor, + Color? toolbarItemIconDisabledColor, + Color? toolbarItemIconSelectedColor, + Color? toolbarMenuBackgroundColor, + Color? toolbarItemSelectedBackgroundColor, + Color? toolbarMenuItemBackgroundColor, + Color? toolbarMenuItemSelectedBackgroundColor, + Color? toolbarMenuIconColor, + Color? toolbarMenuIconDisabledColor, + Color? toolbarMenuIconSelectedColor, + Color? toolbarShadowColor, + }) { + return ToolbarColorExtension( + toolbarBackgroundColor: + toolbarBackgroundColor ?? this.toolbarBackgroundColor, + toolbarItemIconColor: toolbarItemIconColor ?? this.toolbarItemIconColor, + toolbarItemIconDisabledColor: + toolbarItemIconDisabledColor ?? this.toolbarItemIconDisabledColor, + toolbarItemIconSelectedColor: + toolbarItemIconSelectedColor ?? this.toolbarItemIconSelectedColor, + toolbarItemSelectedBackgroundColor: toolbarItemSelectedBackgroundColor ?? + this.toolbarItemSelectedBackgroundColor, + toolbarMenuBackgroundColor: + toolbarMenuBackgroundColor ?? this.toolbarMenuBackgroundColor, + toolbarMenuItemBackgroundColor: + toolbarMenuItemBackgroundColor ?? this.toolbarMenuItemBackgroundColor, + toolbarMenuItemSelectedBackgroundColor: + toolbarMenuItemSelectedBackgroundColor ?? + this.toolbarMenuItemSelectedBackgroundColor, + toolbarMenuIconColor: toolbarMenuIconColor ?? this.toolbarMenuIconColor, + toolbarMenuIconDisabledColor: + toolbarMenuIconDisabledColor ?? this.toolbarMenuIconDisabledColor, + toolbarMenuIconSelectedColor: + toolbarMenuIconSelectedColor ?? this.toolbarMenuIconSelectedColor, + toolbarShadowColor: toolbarShadowColor ?? this.toolbarShadowColor, + ); + } + + @override + ToolbarColorExtension lerp(ToolbarColorExtension? other, double t) { + if (other is! ToolbarColorExtension) { + return this; + } + return ToolbarColorExtension( + toolbarBackgroundColor: + Color.lerp(toolbarBackgroundColor, other.toolbarBackgroundColor, t)!, + toolbarItemIconColor: + Color.lerp(toolbarItemIconColor, other.toolbarItemIconColor, t)!, + toolbarItemIconDisabledColor: Color.lerp( + toolbarItemIconDisabledColor, + other.toolbarItemIconDisabledColor, + t, + )!, + toolbarItemIconSelectedColor: Color.lerp( + toolbarItemIconSelectedColor, + other.toolbarItemIconSelectedColor, + t, + )!, + toolbarItemSelectedBackgroundColor: Color.lerp( + toolbarItemSelectedBackgroundColor, + other.toolbarItemSelectedBackgroundColor, + t, + )!, + toolbarMenuBackgroundColor: Color.lerp( + toolbarMenuBackgroundColor, + other.toolbarMenuBackgroundColor, + t, + )!, + toolbarMenuItemBackgroundColor: Color.lerp( + toolbarMenuItemBackgroundColor, + other.toolbarMenuItemBackgroundColor, + t, + )!, + toolbarMenuItemSelectedBackgroundColor: Color.lerp( + toolbarMenuItemSelectedBackgroundColor, + other.toolbarMenuItemSelectedBackgroundColor, + t, + )!, + toolbarMenuIconColor: + Color.lerp(toolbarMenuIconColor, other.toolbarMenuIconColor, t)!, + toolbarMenuIconDisabledColor: Color.lerp( + toolbarMenuIconDisabledColor, + other.toolbarMenuIconDisabledColor, + t, + )!, + toolbarMenuIconSelectedColor: Color.lerp( + toolbarMenuIconSelectedColor, + other.toolbarMenuIconSelectedColor, + t, + )!, + toolbarShadowColor: + Color.lerp(toolbarShadowColor, other.toolbarShadowColor, t)!, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_toolbar_item.dart new file mode 100644 index 0000000000000..7489911fb79b6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_toolbar_item.dart @@ -0,0 +1,120 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_align_items.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_bius_items.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_block_items.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_font_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_heading_and_text_items.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_indent_items.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +final aaToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, service, onMenu, _) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + isSelected: () => service.showMenuNotifier.value, + keepSelectedStatus: true, + icon: FlowySvgs.m_toolbar_aa_m, + onTap: () => onMenu?.call(), + ); + }, + menuBuilder: (context, editorState, service) { + final selection = editorState.selection; + if (selection == null) { + return const SizedBox.shrink(); + } + return _TextDecorationMenu( + editorState, + selection, + service, + ); + }, +); + +class _TextDecorationMenu extends StatefulWidget { + const _TextDecorationMenu( + this.editorState, + this.selection, + this.service, + ); + + final EditorState editorState; + final Selection selection; + final AppFlowyMobileToolbarWidgetService service; + + @override + State<_TextDecorationMenu> createState() => _TextDecorationMenuState(); +} + +class _TextDecorationMenuState extends State<_TextDecorationMenu> { + EditorState get editorState => widget.editorState; + + @override + Widget build(BuildContext context) { + final theme = ToolbarColorExtension.of(context); + return ColoredBox( + color: theme.toolbarMenuBackgroundColor, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only( + top: 16, + bottom: 20, + left: 12, + right: 12, + ) * + context.scale, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HeadingsAndTextItems( + editorState: editorState, + ), + const ScaledVSpace(), + Row( + children: [ + BIUSItems( + editorState: editorState, + ), + const Spacer(), + ColorItem( + editorState: editorState, + service: widget.service, + ), + ], + ), + const ScaledVSpace(), + Row( + children: [ + BlockItems( + service: widget.service, + editorState: editorState, + ), + const Spacer(), + AlignItems( + editorState: editorState, + ), + ], + ), + const ScaledVSpace(), + Row( + children: [ + FontFamilyItem( + editorState: editorState, + ), + const Spacer(), + IndentAndOutdentItems( + service: widget.service, + editorState: editorState, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_attachment_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_attachment_item.dart new file mode 100644 index 0000000000000..8aa9ca7aaced3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_attachment_item.dart @@ -0,0 +1,238 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/permission/permission_checker.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/app_widget.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:image_picker/image_picker.dart'; + +@visibleForTesting +const addAttachmentToolbarItemKey = ValueKey('add_attachment_toolbar_item'); + +final addAttachmentItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, service, _, onAction) { + return AppFlowyMobileToolbarIconItem( + key: addAttachmentToolbarItemKey, + editorState: editorState, + icon: FlowySvgs.media_s, + onTap: () { + final documentId = context.read().documentId; + final isLocalMode = context.read().isLocalMode; + + final selection = editorState.selection; + service.closeKeyboard(); + + // delay to wait the keyboard closed. + Future.delayed(const Duration(milliseconds: 100), () async { + unawaited( + editorState.updateSelectionWithReason( + selection, + extraInfo: { + selectionExtraInfoDisableMobileToolbarKey: true, + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ), + ); + + keepEditorFocusNotifier.increase(); + final didAddAttachment = await showAddAttachmentMenu( + AppGlobals.rootNavKey.currentContext!, + documentId: documentId, + isLocalMode: isLocalMode, + editorState: editorState, + selection: selection!, + ); + + if (didAddAttachment != true) { + unawaited(editorState.updateSelectionWithReason(selection)); + } + }); + }, + ); + }, +); + +Future showAddAttachmentMenu( + BuildContext context, { + required String documentId, + required bool isLocalMode, + required EditorState editorState, + required Selection selection, +}) async => + showMobileBottomSheet( + context, + showDragHandle: true, + barrierColor: Colors.transparent, + backgroundColor: + ToolbarColorExtension.of(context).toolbarMenuBackgroundColor, + elevation: 20, + isScrollControlled: false, + enableDraggableScrollable: true, + builder: (_) => _AddAttachmentMenu( + documentId: documentId, + isLocalMode: isLocalMode, + editorState: editorState, + selection: selection, + ), + ); + +class _AddAttachmentMenu extends StatelessWidget { + const _AddAttachmentMenu({ + required this.documentId, + required this.isLocalMode, + required this.editorState, + required this.selection, + }); + + final String documentId; + final bool isLocalMode; + final EditorState editorState; + final Selection selection; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MobileQuickActionButton( + text: LocaleKeys.document_attachmentMenu_choosePhoto.tr(), + icon: FlowySvgs.image_rounded_s, + iconSize: const Size.square(20), + onTap: () async => selectPhoto(context), + ), + const Divider(height: 8.5, thickness: 0.5), + MobileQuickActionButton( + text: LocaleKeys.document_attachmentMenu_takePicture.tr(), + icon: FlowySvgs.camera_s, + iconSize: const Size.square(20), + onTap: () async => selectCamera(context), + ), + const Divider(height: 8.5, thickness: 0.5), + MobileQuickActionButton( + text: LocaleKeys.document_attachmentMenu_chooseFile.tr(), + icon: FlowySvgs.file_s, + iconSize: const Size.square(20), + onTap: () async => selectFile(context), + ), + const Divider(height: 8.5, thickness: 0.5), + ], + ), + ); + } + + Future _insertNode(Node node) async { + Future.delayed( + const Duration(milliseconds: 100), + () async { + // if current selected block is a empty paragraph block, replace it with the new block. + if (selection.isCollapsed) { + final path = selection.end.path; + final currentNode = editorState.getNodeAtPath(path); + final text = currentNode?.delta?.toPlainText(); + if (currentNode != null && + currentNode.type == ParagraphBlockKeys.type && + text != null && + text.isEmpty) { + final transaction = editorState.transaction; + transaction.insertNode(path.next, node); + transaction.deleteNode(currentNode); + transaction.afterSelection = + Selection.collapsed(Position(path: path)); + transaction.selectionExtraInfo = {}; + return editorState.apply(transaction); + } + } + + await editorState.insertBlockAfterCurrentSelection(selection, node); + }, + ); + } + + Future insertImage(BuildContext context, XFile image) async { + CustomImageType type = CustomImageType.local; + String? path; + if (isLocalMode) { + path = await saveImageToLocalStorage(image.path); + } else { + (path, _) = await saveImageToCloudStorage(image.path, documentId); + type = CustomImageType.internal; + } + + if (path != null) { + final node = customImageNode(url: path, type: type); + await _insertNode(node); + } + } + + Future selectPhoto(BuildContext context) async { + final image = await ImagePicker().pickImage(source: ImageSource.gallery); + + if (image != null && context.mounted) { + await insertImage(context, image); + } + + if (context.mounted) { + Navigator.pop(context); + } + } + + Future selectCamera(BuildContext context) async { + final cameraPermission = + await PermissionChecker.checkCameraPermission(context); + if (!cameraPermission) { + Log.error('Has no permission to access the camera'); + return; + } + + final image = await ImagePicker().pickImage(source: ImageSource.camera); + + if (image != null && context.mounted) { + await insertImage(context, image); + } + + if (context.mounted) { + Navigator.pop(context); + } + } + + Future selectFile(BuildContext context) async { + final result = await getIt().pickFiles(); + final file = result?.files.first.xFile; + if (file != null) { + FileUrlType type = FileUrlType.local; + String? path; + if (isLocalMode) { + path = await saveFileToLocalStorage(file.path); + } else { + (path, _) = await saveFileToCloudStorage(file.path, documentId); + type = FileUrlType.cloud; + } + + if (path != null) { + final node = fileNode(url: path, type: type, name: file.name); + await _insertNode(node); + } + } + + if (context.mounted) { + Navigator.pop(context); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_menu_item_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_menu_item_builder.dart new file mode 100644 index 0000000000000..4ae243575ceec --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_menu_item_builder.dart @@ -0,0 +1,480 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_add_block_toolbar_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/app_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class AddBlockMenuItemBuilder { + AddBlockMenuItemBuilder({ + required this.editorState, + required this.selection, + }); + + final EditorState editorState; + final Selection selection; + + List> buildTypeOptionMenuItemValues( + BuildContext context, + ) { + if (selection.isCollapsed) { + final node = editorState.getNodeAtPath(selection.end.path); + if (node?.parentTableCellNode != null) { + return _buildTableTypeOptionMenuItemValues(context); + } + } + return _buildDefaultTypeOptionMenuItemValues(context); + } + + /// Build the default type option menu item values. + + List> _buildDefaultTypeOptionMenuItemValues( + BuildContext context, + ) { + final colorMap = _colorMap(context); + return [ + ..._buildHeadingMenuItems(colorMap), + ..._buildParagraphMenuItems(colorMap), + ..._buildTodoListMenuItems(colorMap), + ..._buildTableMenuItems(colorMap), + ..._buildQuoteMenuItems(colorMap), + ..._buildListMenuItems(colorMap), + ..._buildToggleHeadingMenuItems(colorMap), + ..._buildImageMenuItems(colorMap), + ..._buildPhotoGalleryMenuItems(colorMap), + ..._buildFileMenuItems(colorMap), + ..._buildMentionMenuItems(context, colorMap), + ..._buildDividerMenuItems(colorMap), + ..._buildCalloutMenuItems(colorMap), + ..._buildCodeMenuItems(colorMap), + ..._buildMathEquationMenuItems(colorMap), + ]; + } + + /// Build the table type option menu item values. + List> _buildTableTypeOptionMenuItemValues( + BuildContext context, + ) { + final colorMap = _colorMap(context); + return [ + ..._buildHeadingMenuItems(colorMap), + ..._buildParagraphMenuItems(colorMap), + ..._buildTodoListMenuItems(colorMap), + ..._buildQuoteMenuItems(colorMap), + ..._buildListMenuItems(colorMap), + ..._buildToggleHeadingMenuItems(colorMap), + ..._buildImageMenuItems(colorMap), + ..._buildFileMenuItems(colorMap), + ..._buildMentionMenuItems(context, colorMap), + ..._buildDividerMenuItems(colorMap), + ..._buildCalloutMenuItems(colorMap), + ..._buildCodeMenuItems(colorMap), + ..._buildMathEquationMenuItems(colorMap), + ]; + } + + List> _buildHeadingMenuItems( + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: HeadingBlockKeys.type, + backgroundColor: colorMap[HeadingBlockKeys.type]!, + text: LocaleKeys.editor_heading1.tr(), + icon: FlowySvgs.m_add_block_h1_s, + onTap: (_, __) => _insertBlock(headingNode(level: 1)), + ), + TypeOptionMenuItemValue( + value: HeadingBlockKeys.type, + backgroundColor: colorMap[HeadingBlockKeys.type]!, + text: LocaleKeys.editor_heading2.tr(), + icon: FlowySvgs.m_add_block_h2_s, + onTap: (_, __) => _insertBlock(headingNode(level: 2)), + ), + TypeOptionMenuItemValue( + value: HeadingBlockKeys.type, + backgroundColor: colorMap[HeadingBlockKeys.type]!, + text: LocaleKeys.editor_heading3.tr(), + icon: FlowySvgs.m_add_block_h3_s, + onTap: (_, __) => _insertBlock(headingNode(level: 3)), + ), + ]; + } + + List> _buildParagraphMenuItems( + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: ParagraphBlockKeys.type, + backgroundColor: colorMap[ParagraphBlockKeys.type]!, + text: LocaleKeys.editor_text.tr(), + icon: FlowySvgs.m_add_block_paragraph_s, + onTap: (_, __) => _insertBlock(paragraphNode()), + ), + ]; + } + + List> _buildTodoListMenuItems( + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: TodoListBlockKeys.type, + backgroundColor: colorMap[TodoListBlockKeys.type]!, + text: LocaleKeys.editor_checkbox.tr(), + icon: FlowySvgs.m_add_block_checkbox_s, + onTap: (_, __) => _insertBlock(todoListNode(checked: false)), + ), + ]; + } + + List> _buildTableMenuItems( + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: SimpleTableBlockKeys.type, + backgroundColor: colorMap[SimpleTableBlockKeys.type]!, + text: LocaleKeys.editor_table.tr(), + icon: FlowySvgs.slash_menu_icon_simple_table_s, + onTap: (_, __) => _insertBlock( + createSimpleTableBlockNode(columnCount: 2, rowCount: 2), + ), + ), + ]; + } + + List> _buildQuoteMenuItems( + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: QuoteBlockKeys.type, + backgroundColor: colorMap[QuoteBlockKeys.type]!, + text: LocaleKeys.editor_quote.tr(), + icon: FlowySvgs.m_add_block_quote_s, + onTap: (_, __) => _insertBlock(quoteNode()), + ), + ]; + } + + List> _buildListMenuItems( + Map colorMap, + ) { + return [ + // bulleted list, numbered list, toggle list + TypeOptionMenuItemValue( + value: BulletedListBlockKeys.type, + backgroundColor: colorMap[BulletedListBlockKeys.type]!, + text: LocaleKeys.editor_bulletedListShortForm.tr(), + icon: FlowySvgs.m_add_block_bulleted_list_s, + onTap: (_, __) => _insertBlock(bulletedListNode()), + ), + TypeOptionMenuItemValue( + value: NumberedListBlockKeys.type, + backgroundColor: colorMap[NumberedListBlockKeys.type]!, + text: LocaleKeys.editor_numberedListShortForm.tr(), + icon: FlowySvgs.m_add_block_numbered_list_s, + onTap: (_, __) => _insertBlock(numberedListNode()), + ), + TypeOptionMenuItemValue( + value: ToggleListBlockKeys.type, + backgroundColor: colorMap[ToggleListBlockKeys.type]!, + text: LocaleKeys.editor_toggleListShortForm.tr(), + icon: FlowySvgs.m_add_block_toggle_s, + onTap: (_, __) => _insertBlock(toggleListBlockNode()), + ), + ]; + } + + List> _buildToggleHeadingMenuItems( + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: ToggleListBlockKeys.type, + backgroundColor: colorMap[ToggleListBlockKeys.type]!, + text: LocaleKeys.editor_toggleHeading1ShortForm.tr(), + icon: FlowySvgs.toggle_heading1_s, + iconPadding: const EdgeInsets.all(3), + onTap: (_, __) => _insertBlock(toggleHeadingNode()), + ), + TypeOptionMenuItemValue( + value: ToggleListBlockKeys.type, + backgroundColor: colorMap[ToggleListBlockKeys.type]!, + text: LocaleKeys.editor_toggleHeading2ShortForm.tr(), + icon: FlowySvgs.toggle_heading2_s, + iconPadding: const EdgeInsets.all(3), + onTap: (_, __) => _insertBlock(toggleHeadingNode(level: 2)), + ), + TypeOptionMenuItemValue( + value: ToggleListBlockKeys.type, + backgroundColor: colorMap[ToggleListBlockKeys.type]!, + text: LocaleKeys.editor_toggleHeading3ShortForm.tr(), + icon: FlowySvgs.toggle_heading3_s, + iconPadding: const EdgeInsets.all(3), + onTap: (_, __) => _insertBlock(toggleHeadingNode(level: 3)), + ), + ]; + } + + List> _buildImageMenuItems( + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: ImageBlockKeys.type, + backgroundColor: colorMap[ImageBlockKeys.type]!, + text: LocaleKeys.editor_image.tr(), + icon: FlowySvgs.m_add_block_image_s, + onTap: (_, __) async { + AppGlobals.rootNavKey.currentContext?.pop(true); + Future.delayed(const Duration(milliseconds: 400), () async { + final imagePlaceholderKey = GlobalKey(); + await editorState.insertEmptyImageBlock(imagePlaceholderKey); + }); + }, + ), + ]; + } + + List> _buildPhotoGalleryMenuItems( + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: MultiImageBlockKeys.type, + backgroundColor: colorMap[ImageBlockKeys.type]!, + text: LocaleKeys.document_plugins_photoGallery_name.tr(), + icon: FlowySvgs.m_add_block_photo_gallery_s, + onTap: (_, __) async { + AppGlobals.rootNavKey.currentContext?.pop(true); + Future.delayed(const Duration(milliseconds: 400), () async { + final imagePlaceholderKey = GlobalKey(); + await editorState.insertEmptyMultiImageBlock(imagePlaceholderKey); + }); + }, + ), + ]; + } + + List> _buildFileMenuItems( + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: FileBlockKeys.type, + backgroundColor: colorMap[ImageBlockKeys.type]!, + text: LocaleKeys.document_plugins_file_name.tr(), + icon: FlowySvgs.media_s, + onTap: (_, __) async { + AppGlobals.rootNavKey.currentContext?.pop(true); + Future.delayed(const Duration(milliseconds: 400), () async { + final fileGlobalKey = GlobalKey(); + await editorState.insertEmptyFileBlock(fileGlobalKey); + }); + }, + ), + ]; + } + + List> _buildMentionMenuItems( + BuildContext context, + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: ParagraphBlockKeys.type, + backgroundColor: colorMap[MentionBlockKeys.type]!, + text: LocaleKeys.editor_date.tr(), + icon: FlowySvgs.m_add_block_date_s, + onTap: (_, __) => _insertBlock(dateMentionNode()), + ), + TypeOptionMenuItemValue( + value: ParagraphBlockKeys.type, + backgroundColor: colorMap[MentionBlockKeys.type]!, + text: LocaleKeys.editor_page.tr(), + icon: FlowySvgs.icon_document_s, + onTap: (_, __) async { + AppGlobals.rootNavKey.currentContext?.pop(true); + + final currentViewId = getIt().latestOpenView?.id; + final view = await showPageSelectorSheet( + context, + currentViewId: currentViewId, + ); + + if (view != null) { + Future.delayed(const Duration(milliseconds: 100), () { + editorState.insertBlockAfterCurrentSelection( + selection, + pageMentionNode(view.id), + ); + }); + } + }, + ), + ]; + } + + List> _buildDividerMenuItems( + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: DividerBlockKeys.type, + backgroundColor: colorMap[DividerBlockKeys.type]!, + text: LocaleKeys.editor_divider.tr(), + icon: FlowySvgs.m_add_block_divider_s, + onTap: (_, __) { + AppGlobals.rootNavKey.currentContext?.pop(true); + Future.delayed(const Duration(milliseconds: 100), () { + editorState.insertDivider(selection); + }); + }, + ), + ]; + } + + // callout, code, math equation + List> _buildCalloutMenuItems( + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: CalloutBlockKeys.type, + backgroundColor: colorMap[CalloutBlockKeys.type]!, + text: LocaleKeys.document_plugins_callout.tr(), + icon: FlowySvgs.m_add_block_callout_s, + onTap: (_, __) => _insertBlock(calloutNode()), + ), + ]; + } + + List> _buildCodeMenuItems( + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: CodeBlockKeys.type, + backgroundColor: colorMap[CodeBlockKeys.type]!, + text: LocaleKeys.editor_codeBlockShortForm.tr(), + icon: FlowySvgs.m_add_block_code_s, + onTap: (_, __) => _insertBlock(codeBlockNode()), + ), + ]; + } + + List> _buildMathEquationMenuItems( + Map colorMap, + ) { + return [ + TypeOptionMenuItemValue( + value: MathEquationBlockKeys.type, + backgroundColor: colorMap[MathEquationBlockKeys.type]!, + text: LocaleKeys.editor_mathEquationShortForm.tr(), + icon: FlowySvgs.m_add_block_formula_s, + onTap: (_, __) { + AppGlobals.rootNavKey.currentContext?.pop(true); + Future.delayed(const Duration(milliseconds: 100), () { + editorState.insertMathEquation(selection); + }); + }, + ), + ]; + } + + Map _colorMap(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + if (isDarkMode) { + return { + HeadingBlockKeys.type: const Color(0xFF5465A1), + ParagraphBlockKeys.type: const Color(0xFF5465A1), + TodoListBlockKeys.type: const Color(0xFF4BB299), + SimpleTableBlockKeys.type: const Color(0xFF4BB299), + QuoteBlockKeys.type: const Color(0xFFBAAC74), + BulletedListBlockKeys.type: const Color(0xFFA35F94), + NumberedListBlockKeys.type: const Color(0xFFA35F94), + ToggleListBlockKeys.type: const Color(0xFFA35F94), + ImageBlockKeys.type: const Color(0xFFBAAC74), + MentionBlockKeys.type: const Color(0xFF40AAB8), + DividerBlockKeys.type: const Color(0xFF4BB299), + CalloutBlockKeys.type: const Color(0xFF66599B), + CodeBlockKeys.type: const Color(0xFF66599B), + MathEquationBlockKeys.type: const Color(0xFF66599B), + }; + } + return { + HeadingBlockKeys.type: const Color(0xFFBECCFF), + ParagraphBlockKeys.type: const Color(0xFFBECCFF), + TodoListBlockKeys.type: const Color(0xFF98F4CD), + SimpleTableBlockKeys.type: const Color(0xFF98F4CD), + QuoteBlockKeys.type: const Color(0xFFFDEDA7), + BulletedListBlockKeys.type: const Color(0xFFFFB9EF), + NumberedListBlockKeys.type: const Color(0xFFFFB9EF), + ToggleListBlockKeys.type: const Color(0xFFFFB9EF), + ImageBlockKeys.type: const Color(0xFFFDEDA7), + MentionBlockKeys.type: const Color(0xFF91EAF5), + DividerBlockKeys.type: const Color(0xFF98F4CD), + CalloutBlockKeys.type: const Color(0xFFCABDFF), + CodeBlockKeys.type: const Color(0xFFCABDFF), + MathEquationBlockKeys.type: const Color(0xFFCABDFF), + }; + } + + Future _insertBlock(Node node) async { + AppGlobals.rootNavKey.currentContext?.pop(true); + Future.delayed( + const Duration(milliseconds: 100), + () async { + // if current selected block is a empty paragraph block, replace it with the new block. + if (selection.isCollapsed) { + final currentNode = editorState.getNodeAtPath(selection.end.path); + final text = currentNode?.delta?.toPlainText(); + if (currentNode != null && + currentNode.type == ParagraphBlockKeys.type && + text != null && + text.isEmpty) { + final transaction = editorState.transaction; + transaction.insertNode( + selection.end.path.next, + node, + ); + transaction.deleteNode(currentNode); + if (node.type == SimpleTableBlockKeys.type) { + transaction.afterSelection = Selection.collapsed( + Position( + // table -> row -> cell -> paragraph + path: selection.end.path + [0, 0, 0], + ), + ); + } else { + transaction.afterSelection = Selection.collapsed( + Position(path: selection.end.path), + ); + } + transaction.selectionExtraInfo = {}; + await editorState.apply(transaction); + return; + } + } + + await editorState.insertBlockAfterCurrentSelection(selection, node); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart new file mode 100644 index 0000000000000..c09368ff954c2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/tasks/app_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'add_block_menu_item_builder.dart'; + +@visibleForTesting +const addBlockToolbarItemKey = ValueKey('add_block_toolbar_item'); + +final addBlockToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, service, __, onAction) { + return AppFlowyMobileToolbarIconItem( + key: addBlockToolbarItemKey, + editorState: editorState, + icon: FlowySvgs.m_toolbar_add_m, + onTap: () { + final selection = editorState.selection; + service.closeKeyboard(); + + // delay to wait the keyboard closed. + Future.delayed(const Duration(milliseconds: 100), () async { + unawaited( + editorState.updateSelectionWithReason( + selection, + extraInfo: { + selectionExtraInfoDisableMobileToolbarKey: true, + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ), + ); + keepEditorFocusNotifier.increase(); + final didAddBlock = await showAddBlockMenu( + AppGlobals.rootNavKey.currentContext!, + editorState: editorState, + selection: selection!, + ); + if (didAddBlock != true) { + unawaited(editorState.updateSelectionWithReason(selection)); + } + }); + }, + ); + }, +); + +Future showAddBlockMenu( + BuildContext context, { + required EditorState editorState, + required Selection selection, +}) async => + showMobileBottomSheet( + context, + showHeader: true, + showDragHandle: true, + showCloseButton: true, + title: LocaleKeys.button_add.tr(), + barrierColor: Colors.transparent, + backgroundColor: + ToolbarColorExtension.of(context).toolbarMenuBackgroundColor, + elevation: 20, + enableDraggableScrollable: true, + builder: (_) => Padding( + padding: EdgeInsets.all(16 * context.scale), + child: AddBlockMenu(selection: selection, editorState: editorState), + ), + ); + +class AddBlockMenu extends StatelessWidget { + const AddBlockMenu({ + super.key, + required this.selection, + required this.editorState, + }); + + final Selection selection; + final EditorState editorState; + + @override + Widget build(BuildContext context) { + final builder = AddBlockMenuItemBuilder( + editorState: editorState, + selection: selection, + ); + return TypeOptionMenu( + values: builder.buildTypeOptionMenuItemValues(context), + scaleFactor: context.scale, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart new file mode 100644 index 0000000000000..cd806f6c56408 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart @@ -0,0 +1,582 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_close_keyboard_or_menu_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/keyboard_height_observer.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +abstract class AppFlowyMobileToolbarWidgetService { + void closeItemMenu(); + void closeKeyboard(); + + PropertyValueNotifier get showMenuNotifier; +} + +class AppFlowyMobileToolbar extends StatefulWidget { + const AppFlowyMobileToolbar({ + super.key, + this.toolbarHeight = 50.0, + required this.editorState, + required this.toolbarItemsBuilder, + required this.child, + }); + + final EditorState editorState; + final double toolbarHeight; + final List Function( + Selection? selection, + ) toolbarItemsBuilder; + final Widget child; + + @override + State createState() => _AppFlowyMobileToolbarState(); +} + +class _AppFlowyMobileToolbarState extends State { + OverlayEntry? toolbarOverlay; + + final isKeyboardShow = ValueNotifier(false); + + @override + void initState() { + super.initState(); + + _insertKeyboardToolbar(); + KeyboardHeightObserver.instance.addListener(_onKeyboardHeightChanged); + } + + @override + void dispose() { + _removeKeyboardToolbar(); + KeyboardHeightObserver.instance.removeListener(_onKeyboardHeightChanged); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded(child: widget.child), + // add a bottom offset to make sure the toolbar is above the keyboard + ValueListenableBuilder( + valueListenable: isKeyboardShow, + builder: (context, isKeyboardShow, __) { + return SizedBox( + // only adding padding when the keyboard is triggered by editor + height: isKeyboardShow && widget.editorState.selection != null + ? widget.toolbarHeight + : 0, + ); + }, + ), + ], + ); + } + + void _onKeyboardHeightChanged(double height) { + isKeyboardShow.value = height > 0; + } + + void _removeKeyboardToolbar() { + toolbarOverlay?.remove(); + toolbarOverlay?.dispose(); + toolbarOverlay = null; + } + + void _insertKeyboardToolbar() { + _removeKeyboardToolbar(); + + Widget child = ValueListenableBuilder( + valueListenable: widget.editorState.selectionNotifier, + builder: (_, Selection? selection, __) { + // if the selection is null, hide the toolbar + if (selection == null || + widget.editorState.selectionExtraInfo?[ + selectionExtraInfoDisableMobileToolbarKey] == + true) { + return const SizedBox.shrink(); + } + + return RepaintBoundary( + child: BlocProvider.value( + value: context.read(), + child: _MobileToolbar( + editorState: widget.editorState, + toolbarItems: widget.toolbarItemsBuilder(selection), + toolbarHeight: widget.toolbarHeight, + ), + ), + ); + }, + ); + + child = Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Material( + child: child, + ), + ), + ], + ); + + final router = GoRouter.of(context); + + toolbarOverlay = OverlayEntry( + builder: (context) { + return Provider.value( + value: router, + child: child, + ); + }, + ); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + Overlay.of(context, rootOverlay: true).insert(toolbarOverlay!); + }); + } +} + +class _MobileToolbar extends StatefulWidget { + const _MobileToolbar({ + required this.editorState, + required this.toolbarItems, + required this.toolbarHeight, + }); + + final EditorState editorState; + final List toolbarItems; + final double toolbarHeight; + + @override + State<_MobileToolbar> createState() => _MobileToolbarState(); +} + +class _MobileToolbarState extends State<_MobileToolbar> + implements AppFlowyMobileToolbarWidgetService { + // used to control the toolbar menu items + @override + PropertyValueNotifier showMenuNotifier = PropertyValueNotifier(false); + + // when the users click the menu item, the keyboard will be hidden, + // but in this case, we don't want to update the cached keyboard height. + // This is because we want to keep the same height when the menu is shown. + bool canUpdateCachedKeyboardHeight = true; + ValueNotifier cachedKeyboardHeight = ValueNotifier(0.0); + + // used to check if click the same item again + int? selectedMenuIndex; + + Selection? currentSelection; + + bool closeKeyboardInitiative = false; + + final ScrollOffsetListener offsetListener = ScrollOffsetListener.create(); + late final StreamSubscription offsetSubscription; + ValueNotifier toolbarOffset = ValueNotifier(0.0); + + @override + void initState() { + super.initState(); + + currentSelection = widget.editorState.selection; + KeyboardHeightObserver.instance.addListener(_onKeyboardHeightChanged); + offsetSubscription = offsetListener.changes.listen((event) { + toolbarOffset.value += event; + }); + } + + @override + void didUpdateWidget(covariant _MobileToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + + if (currentSelection != widget.editorState.selection) { + currentSelection = widget.editorState.selection; + closeItemMenu(); + if (currentSelection != null) { + _showKeyboard(); + } + } + } + + @override + void dispose() { + showMenuNotifier.dispose(); + cachedKeyboardHeight.dispose(); + KeyboardHeightObserver.instance.removeListener(_onKeyboardHeightChanged); + offsetSubscription.cancel(); + toolbarOffset.dispose(); + + super.dispose(); + } + + @override + void reassemble() { + super.reassemble(); + + canUpdateCachedKeyboardHeight = true; + closeItemMenu(); + _closeKeyboard(); + } + + @override + Widget build(BuildContext context) { + // toolbar + // - if the menu is shown, the toolbar will be pushed up by the height of the menu + // - otherwise, add a spacer to push the toolbar up when the keyboard is shown + return Column( + children: [ + const Divider( + height: 0.5, + color: Color(0x7FEDEDED), + ), + _buildToolbar(context), + const Divider( + height: 0.5, + color: Color(0x7FEDEDED), + ), + _buildMenuOrSpacer(context), + ], + ); + } + + @override + void closeItemMenu() { + showMenuNotifier.value = false; + } + + @override + void closeKeyboard() { + _closeKeyboard(); + } + + void showItemMenu() { + showMenuNotifier.value = true; + } + + void _onKeyboardHeightChanged(double height) { + // if the keyboard is not closed initiative, we need to close the menu at same time + if (!closeKeyboardInitiative && + cachedKeyboardHeight.value != 0 && + height == 0) { + if (!widget.editorState.isDisposed) { + widget.editorState.selection = null; + } + } + + // if the menu is shown and the height is not 0, we need to close the menu + if (showMenuNotifier.value && height != 0) { + closeItemMenu(); + } + + if (canUpdateCachedKeyboardHeight) { + cachedKeyboardHeight.value = height; + + if (defaultTargetPlatform == TargetPlatform.android) { + // cache the keyboard height with the view padding in Android + if (cachedKeyboardHeight.value != 0) { + cachedKeyboardHeight.value += + MediaQuery.of(context).viewPadding.bottom; + } + } + } + + if (height == 0) { + closeKeyboardInitiative = false; + } + } + + // toolbar list view and close keyboard/menu button + Widget _buildToolbar(BuildContext context) { + final theme = ToolbarColorExtension.of(context); + return Container( + height: widget.toolbarHeight, + width: MediaQuery.of(context).size.width, + decoration: BoxDecoration( + color: theme.toolbarBackgroundColor, + boxShadow: const [ + BoxShadow( + color: Color(0x0F181818), + blurRadius: 40, + offset: Offset(0, -4), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // toolbar list view + Expanded( + child: _ToolbarItemListView( + offsetListener: offsetListener, + toolbarItems: widget.toolbarItems, + editorState: widget.editorState, + toolbarWidgetService: this, + itemWithActionOnPressed: (_) { + if (showMenuNotifier.value) { + closeItemMenu(); + _showKeyboard(); + // update the cached keyboard height after the keyboard is shown + Debounce.debounce('canUpdateCachedKeyboardHeight', + const Duration(milliseconds: 500), () { + canUpdateCachedKeyboardHeight = true; + }); + } + }, + itemWithMenuOnPressed: (index) { + // click the same one + if (selectedMenuIndex == index && showMenuNotifier.value) { + // if the menu is shown, close it and show the keyboard + closeItemMenu(); + _showKeyboard(); + // update the cached keyboard height after the keyboard is shown + Debounce.debounce('canUpdateCachedKeyboardHeight', + const Duration(milliseconds: 500), () { + canUpdateCachedKeyboardHeight = true; + }); + } else { + canUpdateCachedKeyboardHeight = false; + selectedMenuIndex = index; + showItemMenu(); + closeKeyboardInitiative = true; + _closeKeyboard(); + } + }, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 13.0), + child: VerticalDivider( + width: 1.0, + thickness: 1.0, + color: Color(0xFFD9D9D9), + ), + ), + // close menu or close keyboard button + CloseKeyboardOrMenuButton( + onPressed: () { + closeKeyboardInitiative = true; + // close the keyboard and clear the selection + // if the selection is null, the keyboard and the toolbar will be hidden automatically + widget.editorState.selection = null; + + // sometimes, the keyboard is not closed after the selection is cleared + if (Platform.isAndroid) { + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } + }, + ), + const HSpace(4.0), + ], + ), + ); + } + + // if there's no menu, we need to add a spacer to push the toolbar up when the keyboard is shown + Widget _buildMenuOrSpacer(BuildContext context) { + return ValueListenableBuilder( + valueListenable: cachedKeyboardHeight, + builder: (_, height, ___) { + return ValueListenableBuilder( + valueListenable: showMenuNotifier, + builder: (_, showingMenu, __) { + var keyboardHeight = height; + if (defaultTargetPlatform == TargetPlatform.android) { + if (!showingMenu) { + // take the max value of the keyboard height and the view padding + // to make sure the toolbar is above the keyboard + keyboardHeight = max( + keyboardHeight, + MediaQuery.of(context).viewInsets.bottom, + ); + } + } + return SizedBox( + height: keyboardHeight, + child: (showingMenu && selectedMenuIndex != null) + ? widget.toolbarItems[selectedMenuIndex!].menuBuilder?.call( + context, + widget.editorState, + this, + ) ?? + const SizedBox.shrink() + : const SizedBox.shrink(), + ); + }, + ); + }, + ); + } + + void _showKeyboard() { + final selection = widget.editorState.selection; + if (selection != null) { + widget.editorState.service.keyboardService?.enableKeyBoard(selection); + } + } + + void _closeKeyboard() { + widget.editorState.service.keyboardService?.closeKeyboard(); + } +} + +class _ToolbarItemListView extends StatefulWidget { + const _ToolbarItemListView({ + required this.offsetListener, + required this.toolbarItems, + required this.editorState, + required this.toolbarWidgetService, + required this.itemWithMenuOnPressed, + required this.itemWithActionOnPressed, + }); + + final Function(int index) itemWithMenuOnPressed; + final Function(int index) itemWithActionOnPressed; + final List toolbarItems; + final EditorState editorState; + final AppFlowyMobileToolbarWidgetService toolbarWidgetService; + final ScrollOffsetListener offsetListener; + + @override + State<_ToolbarItemListView> createState() => _ToolbarItemListViewState(); +} + +class _ToolbarItemListViewState extends State<_ToolbarItemListView> { + final scrollController = ItemScrollController(); + Selection? previousSelection; + + @override + void initState() { + super.initState(); + + widget.editorState.selectionNotifier + .addListener(_debounceUpdatePilotPosition); + previousSelection = widget.editorState.selection; + } + + @override + void dispose() { + widget.editorState.selectionNotifier + .removeListener(_debounceUpdatePilotPosition); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const left = 8.0; + const right = 4.0; + // 68.0 is the width of the close keyboard/menu button + final padding = _calculatePadding(left + right + 68.0); + + final children = [ + const HSpace(left), + ...widget.toolbarItems + .mapIndexed( + (index, element) => element.itemBuilder.call( + context, + widget.editorState, + widget.toolbarWidgetService, + element.menuBuilder != null + ? () { + widget.itemWithMenuOnPressed(index); + } + : null, + element.menuBuilder == null + ? () { + widget.itemWithActionOnPressed(index); + } + : null, + ), + ) + .map((e) => [e, HSpace(padding)]) + .flattened, + const HSpace(right), + ]; + + return PageStorage( + bucket: PageStorageBucket(), + child: ScrollablePositionedList.builder( + physics: const ClampingScrollPhysics(), + scrollOffsetListener: widget.offsetListener, + itemScrollController: scrollController, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) => children[index], + itemCount: children.length, + ), + ); + } + + double _calculatePadding(double extent) { + final screenWidth = MediaQuery.of(context).size.width; + final width = screenWidth - extent; + final int count; + if (screenWidth <= 340) { + count = 5; + } else if (screenWidth <= 384) { + count = 6; + } else if (screenWidth <= 430) { + count = 7; + } else { + count = 8; + } + // left + item count * width + item count * padding + right + close button width = screenWidth + return (width - count * 40.0) / count; + } + + void _debounceUpdatePilotPosition() { + Debounce.debounce( + 'updatePilotPosition', + const Duration(milliseconds: 250), + _updatePilotPosition, + ); + } + + void _updatePilotPosition() { + final selection = widget.editorState.selection; + if (selection == null) { + return; + } + + if (previousSelection != null && + previousSelection!.isCollapsed == selection.isCollapsed) { + return; + } + + final toolbarItems = widget.toolbarItems; + // use -0.4 to make sure the pilot is in the front of the toolbar item + final alignment = selection.isCollapsed ? 0.0 : -0.4; + final index = toolbarItems.indexWhere( + (element) => selection.isCollapsed + ? element.pilotAtCollapsedSelection + : element.pilotAtExpandedSelection, + ); + if (index != -1) { + scrollController.scrollTo( + alignment: alignment, + index: index, + duration: const Duration( + milliseconds: 250, + ), + ); + } + + previousSelection = selection; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart new file mode 100644 index 0000000000000..12e7d1bef7a9c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +// build the toolbar item, like Aa, +, image ... +typedef AppFlowyMobileToolbarItemBuilder = Widget Function( + BuildContext context, + EditorState editorState, + AppFlowyMobileToolbarWidgetService service, + VoidCallback? onMenuCallback, + VoidCallback? onActionCallback, +); + +// build the menu after clicking the toolbar item +typedef AppFlowyMobileToolbarItemMenuBuilder = Widget Function( + BuildContext context, + EditorState editorState, + AppFlowyMobileToolbarWidgetService service, +); + +class AppFlowyMobileToolbarItem { + /// Tool bar item that implements attribute directly(without opening menu) + const AppFlowyMobileToolbarItem({ + required this.itemBuilder, + this.menuBuilder, + this.pilotAtCollapsedSelection = false, + this.pilotAtExpandedSelection = false, + }); + + final AppFlowyMobileToolbarItemBuilder itemBuilder; + final AppFlowyMobileToolbarItemMenuBuilder? menuBuilder; + final bool pilotAtCollapsedSelection; + final bool pilotAtExpandedSelection; +} + +class AppFlowyMobileToolbarIconItem extends StatefulWidget { + const AppFlowyMobileToolbarIconItem({ + super.key, + this.icon, + this.keepSelectedStatus = false, + this.iconBuilder, + this.isSelected, + this.shouldListenToToggledStyle = false, + this.enable, + required this.onTap, + required this.editorState, + }); + + final FlowySvgData? icon; + final bool keepSelectedStatus; + final VoidCallback onTap; + final WidgetBuilder? iconBuilder; + final bool Function()? isSelected; + final bool shouldListenToToggledStyle; + final EditorState editorState; + final bool Function()? enable; + + @override + State createState() => + _AppFlowyMobileToolbarIconItemState(); +} + +class _AppFlowyMobileToolbarIconItemState + extends State { + bool isSelected = false; + StreamSubscription? _subscription; + + @override + void initState() { + super.initState(); + + isSelected = widget.isSelected?.call() ?? false; + if (widget.shouldListenToToggledStyle) { + widget.editorState.toggledStyleNotifier.addListener(_rebuild); + _subscription = widget.editorState.transactionStream.listen((_) { + _rebuild(); + }); + } + } + + @override + void dispose() { + if (widget.shouldListenToToggledStyle) { + widget.editorState.toggledStyleNotifier.removeListener(_rebuild); + _subscription?.cancel(); + } + super.dispose(); + } + + @override + void didUpdateWidget(covariant AppFlowyMobileToolbarIconItem oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.isSelected != null) { + isSelected = widget.isSelected!.call(); + } + } + + @override + Widget build(BuildContext context) { + final theme = ToolbarColorExtension.of(context); + final enable = widget.enable?.call() ?? true; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: AnimatedGestureDetector( + scaleFactor: 0.95, + onTapUp: () { + widget.onTap(); + _rebuild(); + }, + child: widget.iconBuilder?.call(context) ?? + Container( + width: 40, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + color: isSelected + ? theme.toolbarItemSelectedBackgroundColor + : null, + ), + child: FlowySvg( + widget.icon!, + color: enable + ? theme.toolbarItemIconColor + : theme.toolbarItemIconDisabledColor, + ), + ), + ), + ); + } + + void _rebuild() { + if (!mounted) { + return; + } + setState(() { + isSelected = (widget.keepSelectedStatus && widget.isSelected == null) + ? !isSelected + : widget.isSelected?.call() ?? false; + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/basic_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/basic_toolbar_item.dart new file mode 100644 index 0000000000000..518060ccf55b0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/basic_toolbar_item.dart @@ -0,0 +1,159 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/font_colors.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/widgets.dart'; + +final boldToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + isSelected: () => + editorState.isTextDecorationSelected( + AppFlowyRichTextKeys.bold, + ) && + editorState.toggledStyle[AppFlowyRichTextKeys.bold] != false, + icon: FlowySvgs.m_toolbar_bold_m, + onTap: () async => editorState.toggleAttribute( + AppFlowyRichTextKeys.bold, + selectionExtraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + }, + ), + ); + }, +); + +final italicToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + isSelected: () => editorState.isTextDecorationSelected( + AppFlowyRichTextKeys.italic, + ), + icon: FlowySvgs.m_toolbar_italic_m, + onTap: () async => editorState.toggleAttribute( + AppFlowyRichTextKeys.italic, + selectionExtraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + }, + ), + ); + }, +); + +final underlineToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + isSelected: () => editorState.isTextDecorationSelected( + AppFlowyRichTextKeys.underline, + ), + icon: FlowySvgs.m_toolbar_underline_m, + onTap: () async => editorState.toggleAttribute( + AppFlowyRichTextKeys.underline, + selectionExtraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + }, + ), + ); + }, +); + +final strikethroughToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + isSelected: () => editorState.isTextDecorationSelected( + AppFlowyRichTextKeys.strikethrough, + ), + icon: FlowySvgs.m_toolbar_strike_m, + onTap: () async => editorState.toggleAttribute( + AppFlowyRichTextKeys.strikethrough, + selectionExtraInfo: { + selectionExtraInfoDisableFloatingToolbar: true, + }, + ), + ); + }, +); + +final colorToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, service, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + icon: FlowySvgs.m_aa_font_color_m, + iconBuilder: (context) { + String? getColor(String key) { + final selection = editorState.selection; + if (selection == null) { + return null; + } + String? color = editorState.toggledStyle[key]; + if (color == null) { + if (selection.isCollapsed && selection.startIndex != 0) { + color = editorState.getDeltaAttributeValueInSelection( + key, + selection.copyWith( + start: selection.start.copyWith( + offset: selection.startIndex - 1, + ), + ), + ); + } else { + color = editorState.getDeltaAttributeValueInSelection( + key, + ); + } + } + return color; + } + + final textColor = getColor(AppFlowyRichTextKeys.textColor); + final backgroundColor = getColor(AppFlowyRichTextKeys.backgroundColor); + + return Container( + width: 40, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + color: EditorFontColors.fromBuiltInColors( + context, + backgroundColor?.tryToColor(), + ), + ), + child: FlowySvg( + FlowySvgs.m_aa_font_color_m, + color: EditorFontColors.fromBuiltInColors( + context, + textColor?.tryToColor(), + ), + ), + ); + }, + onTap: () { + service.closeKeyboard(); + editorState.updateSelectionWithReason( + editorState.selection, + extraInfo: { + selectionExtraInfoDisableMobileToolbarKey: true, + selectionExtraInfoDisableFloatingToolbar: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ); + keepEditorFocusNotifier.increase(); + showTextColorAndBackgroundColorPicker( + context, + editorState: editorState, + selection: editorState.selection!, + ); + }, + ); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/indent_outdent_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/indent_outdent_toolbar_item.dart new file mode 100644 index 0000000000000..290fa2d3e0db7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/indent_outdent_toolbar_item.dart @@ -0,0 +1,35 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +final indentToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + keepSelectedStatus: true, + isSelected: () => false, + enable: () => isIndentable(editorState), + icon: FlowySvgs.m_aa_indent_m, + onTap: () async { + indentCommand.execute(editorState); + }, + ); + }, +); + +final outdentToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + keepSelectedStatus: true, + isSelected: () => false, + enable: () => isOutdentable(editorState), + icon: FlowySvgs.m_aa_outdent_m, + onTap: () async { + outdentCommand.execute(editorState); + }, + ); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/keyboard_height_observer.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/keyboard_height_observer.dart new file mode 100644 index 0000000000000..d72f722eb6c54 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/keyboard_height_observer.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:keyboard_height_plugin/keyboard_height_plugin.dart'; + +typedef KeyboardHeightCallback = void Function(double height); + +// the KeyboardHeightPlugin only accepts one listener, so we need to create a +// singleton class to manage the multiple listeners. +class KeyboardHeightObserver { + KeyboardHeightObserver._() { + _keyboardHeightPlugin.onKeyboardHeightChanged((height) { + notify(height); + }); + } + + static final KeyboardHeightObserver instance = KeyboardHeightObserver._(); + static double currentKeyboardHeight = 0; + + final List _listeners = []; + final KeyboardHeightPlugin _keyboardHeightPlugin = KeyboardHeightPlugin(); + + void addListener(KeyboardHeightCallback listener) { + _listeners.add(listener); + } + + void removeListener(KeyboardHeightCallback listener) { + _listeners.remove(listener); + } + + void dispose() { + _listeners.clear(); + _keyboardHeightPlugin.dispose(); + } + + void notify(double height) { + // the keyboard height will notify twice with the same value on Android + if (Platform.isAndroid && height == currentKeyboardHeight) { + return; + } + for (final listener in _listeners) { + listener(height); + } + currentKeyboardHeight = height; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/list_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/list_toolbar_item.dart new file mode 100644 index 0000000000000..240ea7072e7fe --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/list_toolbar_item.dart @@ -0,0 +1,61 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +final todoListToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + keepSelectedStatus: true, + isSelected: () => false, + icon: FlowySvgs.m_toolbar_checkbox_m, + onTap: () async { + await editorState.convertBlockType( + TodoListBlockKeys.type, + extraAttributes: { + TodoListBlockKeys.checked: false, + }, + ); + }, + ); + }, +); + +final numberedListToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + final isSelected = + editorState.isBlockTypeSelected(NumberedListBlockKeys.type); + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + keepSelectedStatus: true, + isSelected: () => isSelected, + icon: FlowySvgs.m_toolbar_numbered_list_m, + onTap: () async { + await editorState.convertBlockType( + NumberedListBlockKeys.type, + ); + }, + ); + }, +); + +final bulletedListToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + final isSelected = + editorState.isBlockTypeSelected(BulletedListBlockKeys.type); + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + shouldListenToToggledStyle: true, + keepSelectedStatus: true, + isSelected: () => isSelected, + icon: FlowySvgs.m_toolbar_bulleted_list_m, + onTap: () async { + await editorState.convertBlockType( + BulletedListBlockKeys.type, + ); + }, + ); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/more_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/more_toolbar_item.dart new file mode 100644 index 0000000000000..1488847ea5cf9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/more_toolbar_item.dart @@ -0,0 +1,12 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; + +final moreToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + icon: FlowySvgs.m_toolbar_more_s, + onTap: () {}, + ); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/toolbar_item_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/toolbar_item_builder.dart new file mode 100644 index 0000000000000..35a3c37e74328 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/toolbar_item_builder.dart @@ -0,0 +1,83 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +final _listBlockTypes = [ + BulletedListBlockKeys.type, + NumberedListBlockKeys.type, + TodoListBlockKeys.type, +]; + +final _defaultToolbarItems = [ + addBlockToolbarItem, + aaToolbarItem, + todoListToolbarItem, + bulletedListToolbarItem, + addAttachmentItem, + numberedListToolbarItem, + boldToolbarItem, + italicToolbarItem, + underlineToolbarItem, + strikethroughToolbarItem, + colorToolbarItem, + undoToolbarItem, + redoToolbarItem, +]; + +final _listToolbarItems = [ + addBlockToolbarItem, + aaToolbarItem, + outdentToolbarItem, + indentToolbarItem, + todoListToolbarItem, + bulletedListToolbarItem, + numberedListToolbarItem, + boldToolbarItem, + italicToolbarItem, + underlineToolbarItem, + strikethroughToolbarItem, + colorToolbarItem, + addAttachmentItem, + undoToolbarItem, + redoToolbarItem, +]; + +final _textToolbarItems = [ + aaToolbarItem, + boldToolbarItem, + italicToolbarItem, + underlineToolbarItem, + strikethroughToolbarItem, + colorToolbarItem, +]; + +/// Calculate the toolbar items based on the current selection. +/// +/// Default: +/// Add, Aa, Todo List, Image, Bulleted List, Numbered List, B, I, U, S, Color, Undo, Redo +/// +/// Selecting text: +/// Aa, B, I, U, S, Color +/// +/// Selecting a list: +/// Add, Aa, Indent, Outdent, Bulleted List, Numbered List, Todo List B, I, U, S +List buildMobileToolbarItems( + EditorState editorState, + Selection? selection, +) { + if (selection == null) { + return []; + } + + if (!selection.isCollapsed) { + return _textToolbarItems; + } + + final allSelectedAreListType = editorState + .getSelectedNodes(selection: selection) + .every((node) => _listBlockTypes.contains(node.type)); + if (allSelectedAreListType) { + return _listToolbarItems; + } + + return _defaultToolbarItems; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/undo_redo_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/undo_redo_toolbar_item.dart new file mode 100644 index 0000000000000..5578d8a33cf28 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/undo_redo_toolbar_item.dart @@ -0,0 +1,57 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/widgets.dart'; + +final undoToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + final theme = ToolbarColorExtension.of(context); + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + iconBuilder: (context) { + final canUndo = editorState.undoManager.undoStack.isNonEmpty; + return Container( + width: 40, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + ), + child: FlowySvg( + FlowySvgs.m_toolbar_undo_m, + color: canUndo + ? theme.toolbarItemIconColor + : theme.toolbarItemIconDisabledColor, + ), + ); + }, + onTap: () => undoCommand.execute(editorState), + ); + }, +); + +final redoToolbarItem = AppFlowyMobileToolbarItem( + itemBuilder: (context, editorState, _, __, onAction) { + final theme = ToolbarColorExtension.of(context); + return AppFlowyMobileToolbarIconItem( + editorState: editorState, + iconBuilder: (context) { + final canRedo = editorState.undoManager.redoStack.isNonEmpty; + return Container( + width: 40, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + ), + child: FlowySvg( + FlowySvgs.m_toolbar_redo_m, + color: canRedo + ? theme.toolbarItemIconColor + : theme.toolbarItemIconDisabledColor, + ), + ); + }, + onTap: () => redoCommand.execute(editorState), + ); + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart new file mode 100644 index 0000000000000..37444cd6e1f59 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart @@ -0,0 +1,358 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileToolbarMenuItemWrapper extends StatelessWidget { + const MobileToolbarMenuItemWrapper({ + super.key, + required this.size, + this.icon, + this.text, + this.backgroundColor, + this.selectedBackgroundColor, + this.enable, + this.fontFamily, + required this.isSelected, + required this.iconPadding, + this.enableBottomLeftRadius = true, + this.enableBottomRightRadius = true, + this.enableTopLeftRadius = true, + this.enableTopRightRadius = true, + this.showDownArrow = false, + this.showRightArrow = false, + this.textPadding = EdgeInsets.zero, + required this.onTap, + this.iconColor, + }); + + final Size size; + final VoidCallback onTap; + final FlowySvgData? icon; + final String? text; + final bool? enable; + final String? fontFamily; + final bool isSelected; + final EdgeInsets iconPadding; + final bool enableTopLeftRadius; + final bool enableTopRightRadius; + final bool enableBottomRightRadius; + final bool enableBottomLeftRadius; + final bool showDownArrow; + final bool showRightArrow; + final Color? backgroundColor; + final Color? selectedBackgroundColor; + final EdgeInsets textPadding; + final Color? iconColor; + + @override + Widget build(BuildContext context) { + final theme = ToolbarColorExtension.of(context); + Color? iconColor = this.iconColor; + if (iconColor == null) { + if (enable != null) { + iconColor = enable! ? null : theme.toolbarMenuIconDisabledColor; + } else { + iconColor = isSelected + ? theme.toolbarMenuIconSelectedColor + : theme.toolbarMenuIconColor; + } + } + final textColor = + enable == false ? theme.toolbarMenuIconDisabledColor : null; + // the ui design is based on 375.0 width + final scale = context.scale; + final radius = Radius.circular(12 * scale); + final Widget child; + if (icon != null) { + child = FlowySvg(icon!, color: iconColor); + } else if (text != null) { + child = Padding( + padding: textPadding * scale, + child: FlowyText( + text!, + fontSize: 16.0, + color: textColor, + fontFamily: fontFamily, + overflow: TextOverflow.ellipsis, + ), + ); + } else { + throw ArgumentError('icon and text cannot be null at the same time'); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enable == false ? null : onTap, + child: Stack( + children: [ + Container( + height: size.height * scale, + width: size.width * scale, + alignment: text != null ? Alignment.centerLeft : Alignment.center, + decoration: BoxDecoration( + color: isSelected + ? (selectedBackgroundColor ?? + theme.toolbarMenuItemSelectedBackgroundColor) + : backgroundColor, + borderRadius: BorderRadius.only( + topLeft: enableTopLeftRadius ? radius : Radius.zero, + topRight: enableTopRightRadius ? radius : Radius.zero, + bottomRight: enableBottomRightRadius ? radius : Radius.zero, + bottomLeft: enableBottomLeftRadius ? radius : Radius.zero, + ), + ), + padding: iconPadding * scale, + child: child, + ), + if (showDownArrow) + Positioned( + right: 9.0 * scale, + bottom: 9.0 * scale, + child: const FlowySvg(FlowySvgs.m_aa_down_arrow_s), + ), + if (showRightArrow) + Positioned.fill( + right: 12.0 * scale, + child: Align( + alignment: Alignment.centerRight, + child: FlowySvg( + FlowySvgs.m_aa_arrow_right_s, + color: iconColor, + ), + ), + ), + ], + ), + ); + } +} + +class ScaledVerticalDivider extends StatelessWidget { + const ScaledVerticalDivider({super.key}); + + @override + Widget build(BuildContext context) { + return HSpace(1.5 * context.scale); + } +} + +class ScaledVSpace extends StatelessWidget { + const ScaledVSpace({super.key}); + + @override + Widget build(BuildContext context) { + return VSpace(12.0 * context.scale); + } +} + +extension MobileToolbarBuildContext on BuildContext { + double get scale => MediaQuery.of(this).size.width / 375.0; +} + +final _blocksCanContainChildren = [ + ParagraphBlockKeys.type, + BulletedListBlockKeys.type, + NumberedListBlockKeys.type, + TodoListBlockKeys.type, +]; + +extension MobileToolbarEditorState on EditorState { + bool isBlockTypeSelected(String blockType, {int? level}) { + final selection = this.selection; + if (selection == null) { + return false; + } + final node = getNodeAtPath(selection.start.path); + final type = node?.type; + if (node == null || type == null) { + return false; + } + if (level != null && blockType == HeadingBlockKeys.type) { + return type == blockType && + node.attributes[HeadingBlockKeys.level] == level; + } + return type == blockType; + } + + bool isTextDecorationSelected(String richTextKey) { + final selection = this.selection; + if (selection == null) { + return false; + } + + final nodes = getNodesInSelection(selection); + bool isSelected = false; + if (selection.isCollapsed) { + if (toggledStyle.containsKey(richTextKey)) { + isSelected = toggledStyle[richTextKey] as bool; + } else { + if (selection.startIndex != 0) { + // get previous index text style + isSelected = nodes.allSatisfyInSelection( + selection.copyWith( + start: selection.start.copyWith( + offset: selection.startIndex - 1, + ), + ), + (delta) => delta.everyAttributes( + (attributes) => attributes[richTextKey] == true, + ), + ); + } + } + } else { + isSelected = nodes.allSatisfyInSelection(selection, (delta) { + return delta.everyAttributes((attr) => attr[richTextKey] == true); + }); + } + return isSelected; + } + + Future convertBlockType( + String newBlockType, { + Selection? selection, + Attributes? extraAttributes, + bool? isSelected, + Map? selectionExtraInfo, + }) async { + selection = selection ?? this.selection; + if (selection == null) { + return; + } + final node = getNodeAtPath(selection.start.path); + final type = node?.type; + if (node == null || type == null) { + assert(false, 'node or type is null'); + return; + } + final selected = isSelected ?? type == newBlockType; + + // if the new block type can't contain children, we need to move all the children to the parent + bool needToDeleteChildren = false; + if (!selected && + node.children.isNotEmpty && + !_blocksCanContainChildren.contains(newBlockType)) { + final transaction = this.transaction; + needToDeleteChildren = true; + transaction.insertNodes( + selection.end.path.next, + node.children.map((e) => e.deepCopy()), + ); + await apply(transaction); + } + await formatNode( + selection, + (node) { + final attributes = { + ParagraphBlockKeys.delta: (node.delta ?? Delta()).toJson(), + // for some block types, they have extra attributes, like todo list has checked attribute, callout has icon attribute, etc. + if (!selected && extraAttributes != null) ...extraAttributes, + }; + return node.copyWith( + type: selected ? ParagraphBlockKeys.type : newBlockType, + attributes: attributes, + children: needToDeleteChildren ? [] : null, + ); + }, + selectionExtraInfo: selectionExtraInfo, + ); + } + + Future alignBlock( + String alignment, { + Selection? selection, + Map? selectionExtraInfo, + }) async { + await updateNode( + selection, + (node) => node.copyWith( + attributes: { + ...node.attributes, + blockComponentAlign: alignment, + }, + ), + selectionExtraInfo: selectionExtraInfo, + ); + } + + Future updateTextAndHref( + String? prevText, + String? prevHref, + String? text, + String? href, { + Selection? selection, + }) async { + if (prevText == null && text == null) { + return; + } + + selection ??= this.selection; + // doesn't support multiple selection now + if (selection == null || !selection.isSingle) { + return; + } + + final node = getNodeAtPath(selection.start.path); + if (node == null) { + return; + } + + final transaction = this.transaction; + + // insert a new link + if (prevText == null && + text != null && + text.isNotEmpty && + selection.isCollapsed) { + final attributes = href != null && href.isNotEmpty + ? {AppFlowyRichTextKeys.href: href} + : null; + transaction.insertText( + node, + selection.startIndex, + text, + attributes: attributes, + ); + } else if (text != null && prevText != text) { + // update text + transaction.replaceText( + node, + selection.startIndex, + selection.length, + text, + ); + } + + // if the text is empty, it means the user wants to remove the text + if (text != null && text.isNotEmpty && prevHref != href) { + // update href + transaction.formatText( + node, + selection.startIndex, + text.length, + {AppFlowyRichTextKeys.href: href?.isEmpty == true ? null : href}, + ); + } + + await apply(transaction); + } + + Future insertBlockAfterCurrentSelection( + Selection selection, + Node node, + ) async { + final path = selection.end.path.next; + final transaction = this.transaction; + transaction.insertNode( + path, + node, + ); + transaction.afterSelection = Selection.collapsed( + Position(path: path), + ); + transaction.selectionExtraInfo = {}; + await apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart new file mode 100644 index 0000000000000..2f31a68a738b0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart @@ -0,0 +1,123 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:numerus/roman/roman.dart'; + +class NumberedListIcon extends StatelessWidget { + const NumberedListIcon({ + super.key, + required this.node, + required this.textDirection, + this.textStyle, + }); + + final Node node; + final TextDirection textDirection; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final textStyleConfiguration = + context.read().editorStyle.textStyleConfiguration; + final height = + textStyleConfiguration.text.height ?? textStyleConfiguration.lineHeight; + final combinedTextStyle = textStyle?.combine(textStyleConfiguration.text) ?? + textStyleConfiguration.text; + final adjustedTextStyle = combinedTextStyle.copyWith( + height: height, + fontFeatures: [const FontFeature.tabularFigures()], + ); + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Text( + node.levelString, + style: adjustedTextStyle, + strutStyle: StrutStyle.fromTextStyle(combinedTextStyle), + textHeightBehavior: TextHeightBehavior( + applyHeightToFirstAscent: + textStyleConfiguration.applyHeightToFirstAscent, + applyHeightToLastDescent: + textStyleConfiguration.applyHeightToLastDescent, + leadingDistribution: textStyleConfiguration.leadingDistribution, + ), + textDirection: textDirection, + ), + ); + } +} + +extension on Node { + String get levelString { + final builder = _NumberedListIconBuilder(node: this); + final indexInRootLevel = builder.indexInRootLevel; + final indexInSameLevel = builder.indexInSameLevel; + final level = indexInRootLevel % 3; + final levelString = switch (level) { + 1 => indexInSameLevel.latin, + 2 => indexInSameLevel.roman, + _ => '$indexInSameLevel', + }; + return '$levelString.'; + } +} + +class _NumberedListIconBuilder { + _NumberedListIconBuilder({ + required this.node, + }); + + final Node node; + + // the level of the current node + int get indexInRootLevel { + var level = 0; + var parent = node.parent; + while (parent != null) { + if (parent.type == NumberedListBlockKeys.type) { + level++; + } + parent = parent.parent; + } + return level; + } + + // the index of the current level + int get indexInSameLevel { + int level = 1; + Node? previous = node.previous; + + // if the previous one is not a numbered list, then it is the first one + if (previous == null || previous.type != NumberedListBlockKeys.type) { + return node.attributes[NumberedListBlockKeys.number] ?? level; + } + + int? startNumber; + while (previous != null && previous.type == NumberedListBlockKeys.type) { + startNumber = previous.attributes[NumberedListBlockKeys.number] as int?; + level++; + previous = previous.previous; + } + if (startNumber != null) { + return startNumber + level - 1; + } + return level; + } +} + +extension on int { + String get latin { + String result = ''; + int number = this; + while (number > 0) { + final int remainder = (number - 1) % 26; + result = String.fromCharCode(remainder + 65) + result; + number = (number - 1) ~/ 26; + } + return result.toLowerCase(); + } + + String get roman { + return toRomanNumeralString() ?? '$this'; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart new file mode 100644 index 0000000000000..801f15a77f658 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart @@ -0,0 +1,32 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/text_completion.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +abstract class AIRepository { + Future getStreamedCompletions({ + required String prompt, + required Future Function() onStart, + required Future Function(TextCompletionResponse response) onProcess, + required Future Function() onEnd, + required void Function(AIError error) onError, + String? suffix, + int maxTokens = 2048, + double temperature = 0.3, + bool useAction = false, + }); + + Future streamCompletion({ + required String text, + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) onProcess, + required Future Function() onEnd, + required void Function(AIError error) onError, + }); + + Future, AIError>> generateImage({ + required String prompt, + int n = 1, + }); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/error.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/error.dart new file mode 100644 index 0000000000000..0912f9bdcf62f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/error.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'error.freezed.dart'; +part 'error.g.dart'; + +@freezed +class AIError with _$AIError { + const factory AIError({ + required String message, + @Default(AIErrorCode.other) AIErrorCode code, + }) = _AIError; + + factory AIError.fromJson(Map json) => + _$AIErrorFromJson(json); +} + +enum AIErrorCode { + @JsonValue('AIResponseLimitExceeded') + aiResponseLimitExceeded, + @JsonValue('Other') + other, +} + +extension AIErrorExtension on AIError { + bool get isLimitExceeded => code == AIErrorCode.aiResponseLimitExceeded; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart new file mode 100644 index 0000000000000..3b52963125324 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart @@ -0,0 +1,172 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:http/http.dart' as http; + +import 'error.dart'; +import 'text_completion.dart'; + +enum OpenAIRequestType { + textCompletion, + textEdit, + imageGenerations; + + Uri get uri { + switch (this) { + case OpenAIRequestType.textCompletion: + return Uri.parse('https://api.openai.com/v1/completions'); + case OpenAIRequestType.textEdit: + return Uri.parse('https://api.openai.com/v1/chat/completions'); + case OpenAIRequestType.imageGenerations: + return Uri.parse('https://api.openai.com/v1/images/generations'); + } + } +} + +class HttpOpenAIRepository implements AIRepository { + const HttpOpenAIRepository({ + required this.client, + required this.apiKey, + }); + + final http.Client client; + final String apiKey; + + Map get headers => { + 'Authorization': 'Bearer $apiKey', + 'Content-Type': 'application/json', + }; + + @override + Future getStreamedCompletions({ + required String prompt, + required Future Function() onStart, + required Future Function(TextCompletionResponse response) onProcess, + required Future Function() onEnd, + required void Function(AIError error) onError, + String? suffix, + int maxTokens = 2048, + double temperature = 0.3, + bool useAction = false, + }) async { + final parameters = { + 'model': 'gpt-3.5-turbo-instruct', + 'prompt': prompt, + 'suffix': suffix, + 'max_tokens': maxTokens, + 'temperature': temperature, + 'stream': true, + }; + + final request = http.Request('POST', OpenAIRequestType.textCompletion.uri); + request.headers.addAll(headers); + request.body = jsonEncode(parameters); + + final response = await client.send(request); + + // NEED TO REFACTOR. + // WHY OPENAI USE TWO LINES TO INDICATE THE START OF THE STREAMING RESPONSE? + // AND WHY OPENAI USE [DONE] TO INDICATE THE END OF THE STREAMING RESPONSE? + int syntax = 0; + var previousSyntax = ''; + if (response.statusCode == 200) { + await for (final chunk in response.stream + .transform(const Utf8Decoder()) + .transform(const LineSplitter())) { + syntax += 1; + if (!useAction) { + if (syntax == 3) { + await onStart(); + continue; + } else if (syntax < 3) { + continue; + } + } else { + if (syntax == 2) { + await onStart(); + continue; + } else if (syntax < 2) { + continue; + } + } + final data = chunk.trim().split('data: '); + if (data.length > 1) { + if (data[1] != '[DONE]') { + final response = TextCompletionResponse.fromJson( + json.decode(data[1]), + ); + if (response.choices.isNotEmpty) { + final text = response.choices.first.text; + if (text == previousSyntax && text == '\n') { + continue; + } + await onProcess(response); + previousSyntax = response.choices.first.text; + } + } else { + await onEnd(); + } + } + } + } else { + final body = await response.stream.bytesToString(); + onError( + AIError.fromJson(json.decode(body)['error']), + ); + } + return; + } + + @override + Future, AIError>> generateImage({ + required String prompt, + int n = 1, + }) async { + final parameters = { + 'prompt': prompt, + 'n': n, + 'size': '512x512', + }; + + try { + final response = await client.post( + OpenAIRequestType.imageGenerations.uri, + headers: headers, + body: json.encode(parameters), + ); + + if (response.statusCode == 200) { + final data = json.decode( + utf8.decode(response.bodyBytes), + )['data'] as List; + final urls = data + .map((e) => e.values) + .expand((e) => e) + .map((e) => e.toString()) + .toList(); + return FlowyResult.success(urls); + } else { + return FlowyResult.failure( + AIError.fromJson(json.decode(response.body)['error']), + ); + } + } catch (error) { + return FlowyResult.failure(AIError(message: error.toString())); + } + } + + @override + Future streamCompletion({ + required String text, + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) onProcess, + required Future Function() onEnd, + required void Function(AIError error) onError, + }) { + throw UnimplementedError(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/text_completion.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/text_completion.dart new file mode 100644 index 0000000000000..067049adbfbfd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/text_completion.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +part 'text_completion.freezed.dart'; +part 'text_completion.g.dart'; + +@freezed +class TextCompletionChoice with _$TextCompletionChoice { + factory TextCompletionChoice({ + required String text, + required int index, + // ignore: invalid_annotation_target + @JsonKey(name: 'finish_reason') String? finishReason, + }) = _TextCompletionChoice; + + factory TextCompletionChoice.fromJson(Map json) => + _$TextCompletionChoiceFromJson(json); +} + +@freezed +class TextCompletionResponse with _$TextCompletionResponse { + const factory TextCompletionResponse({ + required List choices, + }) = _TextCompletionResponse; + + factory TextCompletionResponse.fromJson(Map json) => + _$TextCompletionResponseFromJson(json); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/text_edit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/text_edit.dart new file mode 100644 index 0000000000000..52cce9da4fcf8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/service/text_edit.dart @@ -0,0 +1,24 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +part 'text_edit.freezed.dart'; +part 'text_edit.g.dart'; + +@freezed +class TextEditChoice with _$TextEditChoice { + factory TextEditChoice({ + required String text, + required int index, + }) = _TextEditChoice; + + factory TextEditChoice.fromJson(Map json) => + _$TextEditChoiceFromJson(json); +} + +@freezed +class TextEditResponse with _$TextEditResponse { + const factory TextEditResponse({ + required List choices, + }) = _TextEditResponse; + + factory TextEditResponse.fromJson(Map json) => + _$TextEditResponseFromJson(json); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/util/ask_ai_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/util/ask_ai_node_extension.dart new file mode 100644 index 0000000000000..933712217f3e6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/util/ask_ai_node_extension.dart @@ -0,0 +1,68 @@ +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension AskAINodeExtension on EditorState { + String getMarkdownInSelection(Selection? selection) { + selection ??= this.selection?.normalized; + if (selection == null || selection.isCollapsed) { + return ''; + } + + // if the selected nodes are not entirely selected, slice the nodes + final slicedNodes = []; + final nodes = getNodesInSelection(selection); + + for (final node in nodes) { + final delta = node.delta; + if (delta == null) { + continue; + } + + final slicedDelta = delta.slice( + node == nodes.first ? selection.startIndex : 0, + node == nodes.last ? selection.endIndex : delta.length, + ); + + final copiedNode = node.copyWith( + attributes: { + ...node.attributes, + blockComponentDelta: slicedDelta.toJson(), + }, + ); + + slicedNodes.add(copiedNode); + } + + final markdown = customDocumentToMarkdown( + Document.blank()..insert([0], slicedNodes), + ); + + return markdown; + } + + List getPlainTextInSelection(Selection? selection) { + selection ??= this.selection?.normalized; + if (selection == null || selection.isCollapsed) { + return []; + } + + final res = []; + if (selection.isCollapsed) { + return res; + } + + final nodes = getNodesInSelection(selection); + + for (final node in nodes) { + final delta = node.delta; + if (delta == null) { + continue; + } + final startIndex = node == nodes.first ? selection.startIndex : 0; + final endIndex = node == nodes.last ? selection.endIndex : delta.length; + res.add(delta.slice(startIndex, endIndex).toPlainText()); + } + + return res; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/util/learn_more_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/util/learn_more_action.dart new file mode 100644 index 0000000000000..17e89b1bcac37 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/util/learn_more_action.dart @@ -0,0 +1,8 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; + +const String learnMoreUrl = + 'https://docs.appflowy.io/docs/appflowy/product/appflowy-x-openai'; + +Future openLearnMorePage() async { + await afLaunchUrlString(learnMoreUrl); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart new file mode 100644 index 0000000000000..3a57a7f49c789 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; + +void showAILimitDialog(BuildContext context, String message) { + showConfirmDialog( + context: context, + title: LocaleKeys.sideBar_aiResponseLimitDialogTitle.tr(), + description: message, + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_component.dart new file mode 100644 index 0000000000000..2938e87516481 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_component.dart @@ -0,0 +1,404 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_operations.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_widgets.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; +import 'package:appflowy/user/application/ai_service.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'ai_limit_dialog.dart'; + +class AIWriterBlockKeys { + const AIWriterBlockKeys._(); + + static const String type = 'ai_writer'; + static const String prompt = 'prompt'; + static const String startSelection = 'start_selection'; + static const String generationCount = 'generation_count'; + + static String getRewritePrompt(String previousOutput, String prompt) { + return 'I am not satisfied with your previous response ($previousOutput) to the query ($prompt). Please provide an alternative response.'; + } +} + +Node aiWriterNode({ + String prompt = '', + required Selection start, +}) { + return Node( + type: AIWriterBlockKeys.type, + attributes: { + AIWriterBlockKeys.prompt: prompt, + AIWriterBlockKeys.startSelection: start.toJson(), + AIWriterBlockKeys.generationCount: 0, + }, + ); +} + +class AIWriterBlockComponentBuilder extends BlockComponentBuilder { + AIWriterBlockComponentBuilder(); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return AIWriterBlockComponent( + key: node.key, + node: node, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (node) => + node.children.isEmpty && + node.attributes[AIWriterBlockKeys.prompt] is String && + node.attributes[AIWriterBlockKeys.startSelection] is Map; +} + +class AIWriterBlockComponent extends BlockComponentStatefulWidget { + const AIWriterBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => _AIWriterBlockComponentState(); +} + +class _AIWriterBlockComponentState extends State { + final controller = TextEditingController(); + final textFieldFocusNode = FocusNode(); + + late final editorState = context.read(); + late final SelectionGestureInterceptor interceptor; + late final AIWriterBlockOperations aiWriterOperations = + AIWriterBlockOperations( + editorState: editorState, + aiWriterNode: widget.node, + ); + + String get prompt => widget.node.attributes[AIWriterBlockKeys.prompt]; + int get generationCount => + widget.node.attributes[AIWriterBlockKeys.generationCount] ?? 0; + Selection? get startSelection { + final selection = widget.node.attributes[AIWriterBlockKeys.startSelection]; + if (selection != null) { + return Selection.fromJson(selection); + } + return null; + } + + bool isGenerating = false; + + @override + void initState() { + super.initState(); + + _subscribeSelectionGesture(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + editorState.selection = null; + textFieldFocusNode.requestFocus(); + }); + } + + @override + void dispose() { + _onExit(); + _unsubscribeSelectionGesture(); + controller.dispose(); + textFieldFocusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (UniversalPlatform.isMobile) { + return const SizedBox.shrink(); + } + + final child = Focus( + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.enter) { + if (!isGenerating) { + _onGenerate(); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Card( + elevation: 5, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + color: Theme.of(context).colorScheme.surface, + child: Container( + margin: const EdgeInsets.all(10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AIWriterBlockHeader(), + const Space(0, 10), + if (prompt.isEmpty && generationCount < 1) ...[ + _buildInputWidget(context), + const Space(0, 10), + AIWriterBlockInputField( + onGenerate: _onGenerate, + onExit: _onExit, + ), + ] else ...[ + AIWriterBlockFooter( + onKeep: _onExit, + onRewrite: _onRewrite, + onDiscard: _onDiscard, + ), + ], + ], + ), + ), + ), + ); + + return Padding( + padding: const EdgeInsets.only(left: 40), + child: child, + ); + } + + Widget _buildInputWidget(BuildContext context) { + return FlowyTextField( + hintText: LocaleKeys.document_plugins_autoGeneratorHintText.tr(), + controller: controller, + maxLines: 5, + focusNode: textFieldFocusNode, + autoFocus: false, + hintTextConstraints: const BoxConstraints(), + ); + } + + Future _onExit() async { + await aiWriterOperations.removeAIWriterNode(widget.node); + } + + Future _onGenerate() async { + if (isGenerating) { + return; + } + + isGenerating = true; + + await aiWriterOperations.updatePromptText(controller.text); + + if (!_isAIWriterEnabled) { + Log.error('AI Writer is not enabled'); + return; + } + + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + + BarrierDialog? barrierDialog; + + final aiRepository = AppFlowyAIService(); + await aiRepository.streamCompletion( + text: controller.text, + completionType: CompletionTypePB.ContinueWriting, + onStart: () async { + if (mounted) { + barrierDialog = BarrierDialog(context); + barrierDialog?.show(); + await aiWriterOperations.ensurePreviousNodeIsEmptyParagraphNode(); + markdownTextRobot.start(); + } + }, + onProcess: (text) async { + await markdownTextRobot.appendMarkdownText(text); + }, + onEnd: () async { + barrierDialog?.dismiss(); + await markdownTextRobot.stop(); + editorState.service.keyboardService?.enable(); + }, + onError: (error) async { + barrierDialog?.dismiss(); + _showAIWriterError(error); + }, + ); + + await aiWriterOperations.updateGenerationCount(generationCount + 1); + + isGenerating = false; + } + + Future _onDiscard() async { + await aiWriterOperations.discardCurrentResponse( + aiWriterNode: widget.node, + selection: startSelection, + ); + return _onExit(); + } + + Future _onRewrite() async { + if (isGenerating) { + return; + } + + isGenerating = true; + + final previousOutput = _getPreviousOutput(); + if (previousOutput == null) { + return; + } + + // discard the current response + await aiWriterOperations.discardCurrentResponse( + aiWriterNode: widget.node, + selection: startSelection, + ); + + if (!_isAIWriterEnabled) { + return; + } + + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + final aiService = AppFlowyAIService(); + await aiService.streamCompletion( + text: AIWriterBlockKeys.getRewritePrompt(previousOutput, prompt), + completionType: CompletionTypePB.ContinueWriting, + onStart: () async { + await aiWriterOperations.ensurePreviousNodeIsEmptyParagraphNode(); + + markdownTextRobot.start(); + }, + onProcess: (text) async { + await markdownTextRobot.appendMarkdownText(text); + }, + onEnd: () async { + await markdownTextRobot.stop(); + }, + onError: (error) { + _showAIWriterError(error); + }, + ); + + await aiWriterOperations.updateGenerationCount(generationCount + 1); + + isGenerating = false; + } + + String? _getPreviousOutput() { + final startSelection = this.startSelection; + if (startSelection != null) { + final end = widget.node.previous?.path; + + if (end != null) { + final result = editorState + .getNodesInSelection( + startSelection.copyWith(end: Position(path: end)), + ) + .fold( + '', + (previousValue, element) { + final delta = element.delta; + if (delta != null) { + return "$previousValue\n${delta.toPlainText()}"; + } else { + return previousValue; + } + }, + ); + return result.trim(); + } + } + return null; + } + + void _subscribeSelectionGesture() { + interceptor = SelectionGestureInterceptor( + key: AIWriterBlockKeys.type, + canTap: (details) { + if (!context.isOffsetInside(details.globalPosition)) { + if (prompt.isNotEmpty || controller.text.isNotEmpty) { + // show dialog + showDialog( + context: context, + builder: (_) => DiscardDialog( + onConfirm: _onDiscard, + onCancel: () {}, + ), + ); + } else if (controller.text.isEmpty) { + _onExit(); + } + } + editorState.service.keyboardService?.disable(); + return false; + }, + ); + editorState.service.selectionService.registerGestureInterceptor( + interceptor, + ); + } + + void _unsubscribeSelectionGesture() { + editorState.service.selectionService.unregisterGestureInterceptor( + AIWriterBlockKeys.type, + ); + } + + void _showAIWriterError(AIError error) { + if (mounted) { + if (error.isLimitExceeded) { + showAILimitDialog(context, error.message); + } else { + showToastNotification( + context, + message: error.message, + type: ToastificationType.error, + ); + } + } + } + + bool get _isAIWriterEnabled { + final userProfile = context.read().state.userProfilePB; + final isAIWriterEnabled = userProfile != null; + + if (!isAIWriterEnabled) { + showToastNotification( + context, + message: LocaleKeys.document_plugins_autoGeneratorCantGetOpenAIKey.tr(), + type: ToastificationType.error, + ); + } + + return isAIWriterEnabled; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_operations.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_operations.dart new file mode 100644 index 0000000000000..08e242574f44e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_operations.dart @@ -0,0 +1,112 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +/// Notes: All the operation related to the AI writer block will be applied +/// in memory. +class AIWriterBlockOperations { + AIWriterBlockOperations({ + required this.editorState, + required this.aiWriterNode, + }) : assert(aiWriterNode.type == AIWriterBlockKeys.type); + + final EditorState editorState; + final Node aiWriterNode; + + /// Update the prompt text in the node. + Future updatePromptText(String prompt) async { + final transaction = editorState.transaction; + transaction.updateNode( + aiWriterNode, + {AIWriterBlockKeys.prompt: prompt}, + ); + await editorState.apply( + transaction, + options: const ApplyOptions( + inMemoryUpdate: true, + recordUndo: false, + ), + ); + } + + /// Update the generation count in the node. + Future updateGenerationCount(int count) async { + final transaction = editorState.transaction; + transaction.updateNode( + aiWriterNode, + {AIWriterBlockKeys.generationCount: count}, + ); + await editorState.apply( + transaction, + options: const ApplyOptions(inMemoryUpdate: true), + ); + } + + /// Ensure the previous node is a empty paragraph node without any styles. + Future ensurePreviousNodeIsEmptyParagraphNode() async { + final previous = aiWriterNode.previous; + final Selection selection; + + // 1. previous node is null or + // 2. previous node is not a paragraph node or + // 3. previous node is a paragraph node but not empty + final isNotEmptyParagraphNode = previous == null || + previous.type != ParagraphBlockKeys.type || + (previous.delta?.toPlainText().isNotEmpty ?? false); + + if (isNotEmptyParagraphNode) { + final path = aiWriterNode.path; + final transaction = editorState.transaction; + selection = Selection.collapsed(Position(path: path)); + transaction + ..insertNode( + path, + paragraphNode(), + ) + ..afterSelection = selection; + await editorState.apply(transaction); + } else { + selection = Selection.collapsed(Position(path: previous.path)); + } + + final transaction = editorState.transaction; + transaction.updateNode(aiWriterNode, { + AIWriterBlockKeys.startSelection: selection.toJson(), + }); + transaction.afterSelection = selection; + await editorState.apply( + transaction, + options: const ApplyOptions(inMemoryUpdate: true), + ); + } + + /// Discard the current response and delete the previous node. + Future discardCurrentResponse({ + required Node aiWriterNode, + Selection? selection, + }) async { + if (selection != null) { + final start = selection.start.path; + final end = aiWriterNode.previous?.path; + if (end != null) { + final transaction = editorState.transaction; + transaction.deleteNodesAtPath( + start, + end.last - start.last + 1, + ); + await editorState.apply(transaction); + await ensurePreviousNodeIsEmptyParagraphNode(); + } + } + } + + /// Remove the ai writer node from the editor. + Future removeAIWriterNode(Node aiWriterNode) async { + final transaction = editorState.transaction; + transaction.deleteNode(aiWriterNode); + await editorState.apply( + transaction, + options: const ApplyOptions(inMemoryUpdate: true, recordUndo: false), + withUpdateSelection: false, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_widgets.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_widgets.dart new file mode 100644 index 0000000000000..b6681ed7fda7b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ai_writer_block_widgets.dart @@ -0,0 +1,104 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class AIWriterBlockHeader extends StatelessWidget { + const AIWriterBlockHeader({super.key}); + + @override + Widget build(BuildContext context) { + return FlowyText.medium( + LocaleKeys.document_plugins_autoGeneratorTitleName.tr(), + fontSize: 14, + ); + } +} + +class AIWriterBlockInputField extends StatelessWidget { + const AIWriterBlockInputField({ + super.key, + required this.onGenerate, + required this.onExit, + }); + + final VoidCallback onGenerate; + final VoidCallback onExit; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + PrimaryRoundedButton( + text: LocaleKeys.button_generate.tr(), + margin: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 10.0, + ), + radius: 8.0, + onTap: onGenerate, + ), + const Space(10, 0), + OutlinedRoundedButton( + text: LocaleKeys.button_cancel.tr(), + margin: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 10.0, + ), + onTap: onExit, + ), + Flexible( + child: Container( + alignment: Alignment.centerRight, + child: FlowyText.regular( + LocaleKeys.document_plugins_warning.tr(), + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + fontSize: 12, + ), + ), + ), + ], + ); + } +} + +class AIWriterBlockFooter extends StatelessWidget { + const AIWriterBlockFooter({ + super.key, + required this.onKeep, + required this.onRewrite, + required this.onDiscard, + }); + + final VoidCallback onKeep; + final VoidCallback onRewrite; + final VoidCallback onDiscard; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + PrimaryRoundedButton( + text: LocaleKeys.button_keep.tr(), + margin: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 9.0, + ), + onTap: onKeep, + ), + const HSpace(10), + OutlinedRoundedButton( + text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(), + onTap: onRewrite, + ), + const HSpace(10), + OutlinedRoundedButton( + text: LocaleKeys.button_discard.tr(), + onTap: onDiscard, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart new file mode 100644 index 0000000000000..d47c26d655b0f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart @@ -0,0 +1,77 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +enum AskAIAction { + summarize, + fixSpelling, + improveWriting, + makeItLonger; + + String get toInstruction { + switch (this) { + case AskAIAction.summarize: + return 'Tl;dr'; + case AskAIAction.fixSpelling: + return 'Correct this to standard English:'; + case AskAIAction.improveWriting: + return 'Rewrite this in your own words:'; + case AskAIAction.makeItLonger: + return 'Make this text longer:'; + } + } + + String prompt(String input) { + switch (this) { + case AskAIAction.summarize: + return '$input\n\nTl;dr'; + case AskAIAction.fixSpelling: + return 'Correct this to standard English:\n\n$input'; + case AskAIAction.improveWriting: + return 'Rewrite this:\n\n$input'; + case AskAIAction.makeItLonger: + return 'Make this text longer:\n\n$input'; + } + } + + static AskAIAction from(int index) { + switch (index) { + case 0: + return AskAIAction.summarize; + case 1: + return AskAIAction.fixSpelling; + case 2: + return AskAIAction.improveWriting; + case 3: + return AskAIAction.makeItLonger; + } + return AskAIAction.fixSpelling; + } + + String get name { + switch (this) { + case AskAIAction.summarize: + return LocaleKeys.document_plugins_smartEditSummarize.tr(); + case AskAIAction.fixSpelling: + return LocaleKeys.document_plugins_smartEditFixSpelling.tr(); + case AskAIAction.improveWriting: + return LocaleKeys.document_plugins_smartEditImproveWriting.tr(); + case AskAIAction.makeItLonger: + return LocaleKeys.document_plugins_smartEditMakeLonger.tr(); + } + } +} + +class AskAIActionWrapper extends ActionCell { + AskAIActionWrapper(this.inner); + + final AskAIAction inner; + + Widget? icon(Color iconColor) => null; + + @override + String get name { + return inner.name; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action_bloc.dart new file mode 100644 index 0000000000000..82cd627180070 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action_bloc.dart @@ -0,0 +1,297 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy/user/application/ai_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'ask_ai_action_bloc.freezed.dart'; + +enum AskAIReplacementType { + markdown, + plainText, +} + +const _defaultReplacementType = AskAIReplacementType.markdown; + +class AskAIActionBloc extends Bloc { + AskAIActionBloc({ + required this.node, + required this.editorState, + required this.action, + this.enableLogging = true, + }) : super( + AskAIState.initial(action), + ) { + on((event, emit) async { + await event.when( + initial: (aiRepositoryProvider) async { + aiRepository = await aiRepositoryProvider; + aiRepositoryCompleter.complete(); + }, + started: () async { + await _requestCompletions(); + }, + rewrite: () async { + await _requestCompletions(rewrite: true); + }, + replace: () async { + await _replace(); + await _exit(); + }, + insertBelow: () async { + await _insertBelow(); + await _exit(); + }, + cancel: () async { + isCanceled = true; + await _exit(); + }, + update: (result, isLoading, aiError) { + emit( + state.copyWith( + result: result, + loading: isLoading, + requestError: aiError, + ), + ); + }, + ); + }); + } + + final Node node; + final EditorState editorState; + final AskAIAction action; + final bool enableLogging; + // used to wait for the aiRepository to be initialized + final aiRepositoryCompleter = Completer(); + late final AIRepository aiRepository; + + bool isCanceled = false; + + Future _requestCompletions({ + bool rewrite = false, + }) async { + await aiRepositoryCompleter.future; + + if (rewrite) { + add(const AskAIEvent.update('', true, null)); + } + + if (enableLogging) { + Log.info('[smart_edit] request completions'); + } + + final content = node.attributes[AskAIBlockKeys.content] as String; + await aiRepository.streamCompletion( + text: content, + completionType: completionTypeFromInt(state.action), + onStart: () async { + if (isCanceled) { + return; + } + if (enableLogging) { + Log.info('[smart_edit] start generating'); + } + add(const AskAIEvent.update('', true, null)); + }, + onProcess: (text) async { + if (isCanceled) { + return; + } + // only display the log in debug mode + if (enableLogging) { + Log.debug('[smart_edit] onProcess: $text'); + } + final newResult = state.result + text; + add(AskAIEvent.update(newResult, false, null)); + }, + onEnd: () async { + if (isCanceled) { + return; + } + if (enableLogging) { + Log.info('[smart_edit] end generating'); + } + add(AskAIEvent.update('${state.result}\n', false, null)); + }, + onError: (error) async { + if (isCanceled) { + return; + } + if (enableLogging) { + Log.info('[smart_edit] onError: $error'); + } + add(AskAIEvent.update('', false, error)); + await _exit(); + await _clearSelection(); + }, + ); + } + + Future _insertBelow() async { + // check the selection is not empty + final selection = editorState.selection?.normalized; + if (selection == null) { + return; + } + final nodes = customMarkdownToDocument(state.result) + .root + .children + .map((e) => e.deepCopy()) + .toList(); + final insertedPath = selection.end.path.next; + final transaction = editorState.transaction; + transaction.insertNodes( + insertedPath, + nodes, + ); + final lastDeltaLength = nodes.lastOrNull?.delta?.length ?? 0; + transaction.afterSelection = Selection( + start: Position(path: insertedPath), + end: Position( + path: insertedPath.nextNPath(nodes.length - 1), + offset: lastDeltaLength, + ), + ); + await editorState.apply(transaction); + } + + Future _replace() async { + switch (_defaultReplacementType) { + case AskAIReplacementType.markdown: + await _replaceWithMarkdown(); + case AskAIReplacementType.plainText: + await _replaceWithPlainText(); + } + } + + Future _replaceWithMarkdown() async { + final selection = editorState.selection?.normalized; + if (selection == null) { + return; + } + + final nodes = customMarkdownToDocument(state.result) + .root + .children + .map((e) => e.deepCopy()) + .toList(); + if (nodes.isEmpty) { + return; + } + + final nodesInSelection = editorState.getNodesInSelection(selection); + final transaction = editorState.transaction; + transaction.insertNodes( + selection.start.path, + nodes, + ); + transaction.deleteNodes(nodesInSelection); + transaction.afterSelection = Selection( + start: selection.start, + end: Position( + path: selection.start.path.nextNPath(nodes.length - 1), + offset: nodes.lastOrNull?.delta?.length ?? 0, + ), + ); + await editorState.apply(transaction); + } + + Future _replaceWithPlainText() async { + final result = state.result.trim(); + if (result.isEmpty) { + return; + } + + final selection = editorState.selection?.normalized; + if (selection == null) { + return; + } + final nodes = editorState.getNodesInSelection(selection); + if (nodes.isEmpty || !nodes.every((element) => element.delta != null)) { + return; + } + + final replaceTexts = result.split('\n') + ..removeWhere((element) => element.isEmpty); + final transaction = editorState.transaction; + transaction.replaceTexts( + nodes, + selection, + replaceTexts, + ); + await editorState.apply(transaction); + + int endOffset = replaceTexts.last.length; + if (replaceTexts.length == 1) { + endOffset += selection.start.offset; + } + final end = Position( + path: [selection.start.path.first + replaceTexts.length - 1], + offset: endOffset, + ); + editorState.selection = Selection( + start: selection.start, + end: end, + ); + } + + Future _exit() async { + final transaction = editorState.transaction..deleteNode(node); + await editorState.apply( + transaction, + options: const ApplyOptions( + recordUndo: false, + ), + ); + } + + Future _clearSelection() async { + final selection = editorState.selection; + if (selection == null) { + return; + } + editorState.selection = null; + } +} + +@freezed +class AskAIEvent with _$AskAIEvent { + const factory AskAIEvent.initial( + Future aiRepositoryProvider, + ) = _Initial; + const factory AskAIEvent.started() = _Started; + const factory AskAIEvent.rewrite() = _Rewrite; + const factory AskAIEvent.replace() = _Replace; + const factory AskAIEvent.insertBelow() = _InsertBelow; + const factory AskAIEvent.cancel() = _Cancel; + const factory AskAIEvent.update( + String result, + bool isLoading, + AIError? error, + ) = _Update; +} + +@freezed +class AskAIState with _$AskAIState { + const factory AskAIState({ + required bool loading, + required String result, + required AskAIAction action, + @Default(null) AIError? requestError, + }) = _AskAIState; + + factory AskAIState.initial(AskAIAction action) => AskAIState( + loading: true, + action: action, + result: '', + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_component.dart new file mode 100644 index 0000000000000..7babb48ba556d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_component.dart @@ -0,0 +1,208 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ai_limit_dialog.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_widgets.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class AskAIBlockKeys { + const AskAIBlockKeys._(); + + static const type = 'ask_ai'; + + /// The instruction of the smart edit. + /// + /// It is a [AskAIAction] value. + static const action = 'action'; + + /// The input of the smart edit. + /// + /// The content is a string that using '\n\n' as separator. + static const content = 'content'; +} + +Node askAINode({ + required AskAIAction action, + required String content, +}) { + return Node( + type: AskAIBlockKeys.type, + attributes: { + AskAIBlockKeys.action: action.index, + AskAIBlockKeys.content: content, + }, + ); +} + +class AskAIBlockComponentBuilder extends BlockComponentBuilder { + AskAIBlockComponentBuilder(); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return AskAIBlockComponentWidget( + key: node.key, + node: node, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (node) => + node.attributes[AskAIBlockKeys.action] is int && + node.attributes[AskAIBlockKeys.content] is String; +} + +class AskAIBlockComponentWidget extends BlockComponentStatefulWidget { + const AskAIBlockComponentWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => + _AskAIBlockComponentWidgetState(); +} + +class _AskAIBlockComponentWidgetState extends State { + final popoverController = PopoverController(); + + late final editorState = context.read(); + late final action = + AskAIAction.values[widget.node.attributes[AskAIBlockKeys.action] as int]; + late AskAIActionBloc askAIBloc; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + popoverController.show(); + }); + + askAIBloc = AskAIActionBloc( + node: widget.node, + editorState: editorState, + action: action, + )..add(AskAIEvent.initial(getIt.getAsync())); + } + + @override + void dispose() { + askAIBloc.close(); + + super.dispose(); + } + + @override + void reassemble() { + super.reassemble(); + + _removeNode(); + } + + @override + Widget build(BuildContext context) { + if (UniversalPlatform.isMobile) { + return const SizedBox.shrink(); + } + + final width = _getEditorWidth(); + + return BlocProvider.value( + value: askAIBloc, + child: BlocListener( + listener: _onListen, + child: AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + triggerActions: PopoverTriggerFlags.none, + margin: EdgeInsets.zero, + offset: const Offset(40, 0), // align the editor block + windowPadding: EdgeInsets.zero, + constraints: BoxConstraints(maxWidth: width), + canClose: () async { + final completer = Completer(); + final state = askAIBloc.state; + if (state.result.isEmpty) { + completer.complete(true); + } else { + await showCancelAndConfirmDialog( + context: context, + title: LocaleKeys.document_plugins_discardResponse.tr(), + description: '', + confirmLabel: LocaleKeys.button_discard.tr(), + onConfirm: () => completer.complete(true), + onCancel: () => completer.complete(false), + ); + } + return completer.future; + }, + onClose: _removeNode, + popupBuilder: (BuildContext popoverContext) { + return BlocProvider.value( + // request the result when opening the popover + value: askAIBloc..add(const AskAIEvent.started()), + child: const AskAiInputContent(), + ); + }, + child: const SizedBox( + width: double.infinity, + ), + ), + ), + ); + } + + double _getEditorWidth() { + var width = double.infinity; + try { + final editorSize = editorState.renderBox?.size; + final editorWidth = + editorSize?.width.clamp(0, editorState.editorStyle.maxWidth ?? width); + final padding = editorState.editorStyle.padding; + if (editorWidth != null) { + width = editorWidth - padding.left - padding.right; + } + } catch (_) {} + return width; + } + + void _removeNode() { + final transaction = editorState.transaction..deleteNode(widget.node); + editorState.apply(transaction); + } + + void _onListen(BuildContext context, AskAIState state) { + final error = state.requestError; + if (error != null) { + if (error.isLimitExceeded) { + showAILimitDialog(context, error.message); + } else { + showToastNotification( + context, + message: error.message, + type: ToastificationType.error, + ); + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_widgets.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_widgets.dart new file mode 100644 index 0000000000000..5c70f774ed753 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_widgets.dart @@ -0,0 +1,112 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class AskAiInputContent extends StatelessWidget { + const AskAiInputContent({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Card( + elevation: 5, + color: Theme.of(context).colorScheme.surface, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + child: Container( + margin: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.medium( + state.action.name, + fontSize: 14, + ), + const VSpace(16), + state.loading + ? _buildLoadingWidget(context) + : _buildResultWidget(context, state), + const VSpace(16), + const AskAIFooter(), + ], + ), + ), + ); + }, + ); + } + + Widget _buildResultWidget(BuildContext context, AskAIState state) { + return Flexible( + child: AIMarkdownText( + markdown: state.result, + ), + ); + } + + Widget _buildLoadingWidget(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 4.0), + child: SizedBox.square( + dimension: 14, + child: CircularProgressIndicator( + strokeWidth: 2.0, + ), + ), + ); + } +} + +class AskAIFooter extends StatelessWidget { + const AskAIFooter({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + OutlinedRoundedButton( + text: LocaleKeys.document_plugins_autoGeneratorRewrite.tr(), + onTap: () => + context.read().add(const AskAIEvent.rewrite()), + ), + const HSpace(10), + OutlinedRoundedButton( + text: LocaleKeys.button_replace.tr(), + onTap: () => + context.read().add(const AskAIEvent.replace()), + ), + const HSpace(10), + OutlinedRoundedButton( + text: LocaleKeys.button_insertBelow.tr(), + onTap: () => context + .read() + .add(const AskAIEvent.insertBelow()), + ), + const HSpace(10), + OutlinedRoundedButton( + text: LocaleKeys.button_cancel.tr(), + onTap: () => + context.read().add(const AskAIEvent.cancel()), + ), + Expanded( + child: Container( + alignment: Alignment.centerRight, + child: Text( + LocaleKeys.document_plugins_warning.tr(), + style: TextStyle(color: Theme.of(context).hintColor), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_toolbar_item.dart new file mode 100644 index 0000000000000..232b5872a6df7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_toolbar_item.dart @@ -0,0 +1,147 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/util/ask_ai_node_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_component.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +const _kAskAIToolbarItemId = 'appflowy.editor.ask_ai'; + +final ToolbarItem askAIItem = ToolbarItem( + id: _kAskAIToolbarItemId, + group: 0, + isActive: onlyShowInSingleSelectionAndTextType, + builder: (context, editorState, _, __, tooltipBuilder) => AskAIActionList( + editorState: editorState, + tooltipBuilder: tooltipBuilder, + ), +); + +class AskAIActionList extends StatefulWidget { + const AskAIActionList({ + super.key, + required this.editorState, + this.tooltipBuilder, + }); + + final EditorState editorState; + final ToolbarTooltipBuilder? tooltipBuilder; + + @override + State createState() => _AskAIActionListState(); +} + +class _AskAIActionListState extends State { + bool isAIEnabled = true; + + EditorState get editorState => widget.editorState; + + @override + void initState() { + super.initState(); + + isAIEnabled = _isAIEnabled(); + } + + @override + Widget build(BuildContext context) { + return PopoverActionList( + offset: const Offset(-5, 5), + direction: PopoverDirection.bottomWithLeftAligned, + actions: AskAIAction.values + .map((action) => AskAIActionWrapper(action)) + .toList(), + onClosed: () => keepEditorFocusNotifier.decrease(), + buildChild: (controller) { + keepEditorFocusNotifier.increase(); + final child = FlowyButton( + text: FlowyText.regular( + LocaleKeys.document_plugins_smartEdit.tr(), + fontSize: 13.0, + figmaLineHeight: 16.0, + color: Colors.white, + ), + hoverColor: Colors.transparent, + useIntrinsicWidth: true, + leftIcon: const FlowySvg( + FlowySvgs.toolbar_item_ai_s, + size: Size.square(16.0), + color: Colors.white, + ), + onTap: () { + if (isAIEnabled) { + keepEditorFocusNotifier.increase(); + controller.show(); + } else { + showToastNotification( + context, + message: + LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), + ); + } + }, + ); + + if (widget.tooltipBuilder != null) { + return widget.tooltipBuilder!( + context, + _kAskAIToolbarItemId, + isAIEnabled + ? LocaleKeys.document_plugins_smartEdit.tr() + : LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), + child, + ); + } + + return child; + }, + onSelected: (action, controller) { + controller.close(); + _insertAskAINode(action); + }, + ); + } + + Future _insertAskAINode( + AskAIActionWrapper actionWrapper, + ) async { + final selection = editorState.selection?.normalized; + if (selection == null) { + return; + } + + final markdown = editorState.getMarkdownInSelection(selection); + + final transaction = editorState.transaction; + transaction.insertNode( + selection.normalized.end.path.next, + askAINode( + action: actionWrapper.inner, + content: markdown, + ), + ); + await editorState.apply( + transaction, + options: const ApplyOptions( + recordUndo: false, + inMemoryUpdate: true, + ), + withUpdateSelection: false, + ); + } + + bool _isAIEnabled() { + final documentContext = widget.editorState.document.root.context; + if (documentContext == null) { + return true; + } + return !documentContext.read().isLocalMode; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart new file mode 100644 index 0000000000000..4c0c2bc91ab31 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/discard_dialog.dart @@ -0,0 +1,28 @@ +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; + +import 'package:flutter/material.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; + +import 'package:easy_localization/easy_localization.dart'; + +class DiscardDialog extends StatelessWidget { + const DiscardDialog({ + super.key, + required this.onConfirm, + required this.onCancel, + }); + + final VoidCallback onConfirm; + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + return NavigatorOkCancelDialog( + message: LocaleKeys.document_plugins_discardResponse.tr(), + okTitle: LocaleKeys.button_discard.tr(), + cancelTitle: LocaleKeys.button_cancel.tr(), + onOkPressed: onConfirm, + onCancelPressed: onCancel, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart new file mode 100644 index 0000000000000..6f475bc627d5e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class Loading { + Loading(this.context); + + BuildContext? loadingContext; + final BuildContext context; + + bool hasStopped = false; + + void start() => unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + loadingContext = context; + + if (hasStopped) { + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.of(loadingContext!).maybePop(); + loadingContext = null; + }); + } + + return const SimpleDialog( + elevation: 0.0, + backgroundColor: + Colors.transparent, // can change this to your preferred color + children: [ + Center( + child: CircularProgressIndicator(), + ), + ], + ); + }, + ), + ); + + void stop() { + if (loadingContext != null) { + Navigator.of(loadingContext!).pop(); + loadingContext = null; + } + + hasStopped = true; + } +} + +class BarrierDialog { + BarrierDialog(this.context); + + late BuildContext loadingContext; + final BuildContext context; + + void show() => unawaited( + showDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.transparent, + builder: (BuildContext context) { + loadingContext = context; + return const SizedBox.shrink(); + }, + ), + ); + + void dismiss() => Navigator.of(loadingContext).pop(); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart new file mode 100644 index 0000000000000..8fc3fd9145eca --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart @@ -0,0 +1,307 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart' hide TextDirection; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class OutlineBlockKeys { + const OutlineBlockKeys._(); + + static const String type = 'outline'; + static const String backgroundColor = blockComponentBackgroundColor; + static const String depth = 'depth'; +} + +Node outlineBlockNode() { + return Node( + type: OutlineBlockKeys.type, + ); +} + +enum _OutlineBlockStatus { + noHeadings, + noMatchHeadings, + success; +} + +final _availableBlockTypes = [ + HeadingBlockKeys.type, + ToggleListBlockKeys.type, +]; + +class OutlineBlockComponentBuilder extends BlockComponentBuilder { + OutlineBlockComponentBuilder({ + super.configuration, + }); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return OutlineBlockWidget( + key: node.key, + node: node, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (node) => node.children.isEmpty; +} + +class OutlineBlockWidget extends BlockComponentStatefulWidget { + const OutlineBlockWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => _OutlineBlockWidgetState(); +} + +class _OutlineBlockWidgetState extends State + with + BlockComponentConfigurable, + BlockComponentTextDirectionMixin, + BlockComponentBackgroundColorMixin { + // Change the value if the heading block type supports heading levels greater than '3' + static const maxVisibleDepth = 6; + + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + @override + late EditorState editorState = context.read(); + late Stream stream = editorState.transactionStream; + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: stream, + builder: (context, snapshot) { + Widget child = _buildOutlineBlock(); + + if (UniversalPlatform.isDesktopOrWeb) { + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: widget.node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + } else { + child = MobileBlockActionButtons( + node: node, + editorState: editorState, + child: child, + ); + } + + return child; + }, + ); + } + + Widget _buildOutlineBlock() { + final textDirection = calculateTextDirection( + layoutDirection: Directionality.maybeOf(context), + ); + final (status, headings) = getHeadingNodes(); + + Widget child; + + switch (status) { + case _OutlineBlockStatus.noHeadings: + child = Align( + alignment: Alignment.centerLeft, + child: Text( + LocaleKeys.document_plugins_outline_addHeadingToCreateOutline.tr(), + style: configuration.placeholderTextStyle(node), + ), + ); + case _OutlineBlockStatus.noMatchHeadings: + child = Align( + alignment: Alignment.centerLeft, + child: Text( + LocaleKeys.document_plugins_outline_noMatchHeadings.tr(), + style: configuration.placeholderTextStyle(node), + ), + ); + case _OutlineBlockStatus.success: + final children = headings + .map( + (e) => Container( + padding: const EdgeInsets.only( + bottom: 4.0, + ), + width: double.infinity, + child: OutlineItemWidget( + node: e, + textDirection: textDirection, + ), + ), + ) + .toList(); + child = Padding( + padding: const EdgeInsets.only(left: 15.0), + child: Column( + children: children, + ), + ); + } + + return Container( + constraints: const BoxConstraints( + minHeight: 40.0, + ), + padding: padding, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 2.0, + horizontal: 5.0, + ), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + color: backgroundColor, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + textDirection: textDirection, + children: [ + Text( + LocaleKeys.document_outlineBlock_placeholder.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + const VSpace(8.0), + child, + ], + ), + ), + ); + } + + (_OutlineBlockStatus, Iterable) getHeadingNodes() { + final nodes = NodeIterator( + document: editorState.document, + startNode: editorState.document.root, + ).toList(); + final level = node.attributes[OutlineBlockKeys.depth] ?? maxVisibleDepth; + var headings = nodes.where( + (e) => _isHeadingNode(e), + ); + if (headings.isEmpty) { + return (_OutlineBlockStatus.noHeadings, []); + } + headings = headings.where( + (e) => + (e.type == HeadingBlockKeys.type && + e.attributes[HeadingBlockKeys.level] <= level) || + (e.type == ToggleListBlockKeys.type && + e.attributes[ToggleListBlockKeys.level] <= level), + ); + if (headings.isEmpty) { + return (_OutlineBlockStatus.noMatchHeadings, []); + } + return (_OutlineBlockStatus.success, headings); + } + + bool _isHeadingNode(Node node) { + if (node.type == HeadingBlockKeys.type && node.delta?.isNotEmpty == true) { + return true; + } + + if (node.type == ToggleListBlockKeys.type && + node.delta?.isNotEmpty == true && + node.attributes[ToggleListBlockKeys.level] != null) { + return true; + } + + return false; + } +} + +class OutlineItemWidget extends StatelessWidget { + OutlineItemWidget({ + super.key, + required this.node, + required this.textDirection, + }) { + assert(_availableBlockTypes.contains(node.type)); + } + + final Node node; + final TextDirection textDirection; + + @override + Widget build(BuildContext context) { + final editorState = context.read(); + final textStyle = editorState.editorStyle.textStyleConfiguration; + final style = textStyle.href.combine(textStyle.text); + return FlowyButton( + onTap: () => scrollToBlock(context), + text: Row( + textDirection: textDirection, + children: [ + HSpace(node.leftIndent), + Text( + node.outlineItemText, + textDirection: textDirection, + style: style, + ), + ], + ), + ); + } + + void scrollToBlock(BuildContext context) { + final editorState = context.read(); + final editorScrollController = context.read(); + editorScrollController.itemScrollController.jumpTo( + index: node.path.first, + alignment: 0.5, + ); + editorState.selection = Selection.collapsed( + Position(path: node.path, offset: node.delta?.length ?? 0), + ); + } +} + +extension on Node { + double get leftIndent { + assert(_availableBlockTypes.contains(type)); + + if (!_availableBlockTypes.contains(type)) { + return 0.0; + } + + final level = attributes[HeadingBlockKeys.level] ?? + attributes[ToggleListBlockKeys.level]; + if (level != null) { + final indent = (level - 1) * 15.0; + return indent; + } + + return 0.0; + } + + String get outlineItemText { + return delta?.toPlainText() ?? ''; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_cover_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_cover_bottom_sheet.dart new file mode 100644 index 0000000000000..9b26daa3e326e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_cover_bottom_sheet.dart @@ -0,0 +1,287 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart'; +import 'package:appflowy/shared/feedback_gesture_detector.dart'; +import 'package:appflowy/shared/flowy_gradient_colors.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class PageCoverBottomSheet extends StatelessWidget { + const PageCoverBottomSheet({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const VSpace(8.0), + + // pure colors + FlowyText( + LocaleKeys.pageStyle_colors.tr(), + color: context.pageStyleTextColor, + fontSize: 14.0, + ), + const VSpace(8.0), + _buildPureColors(context, state), + const VSpace(20.0), + + // gradient colors + FlowyText( + LocaleKeys.pageStyle_gradient.tr(), + color: context.pageStyleTextColor, + fontSize: 14.0, + ), + const VSpace(8.0), + _buildGradientColors(context, state), + const VSpace(20.0), + + // built-in images + FlowyText( + LocaleKeys.pageStyle_backgroundImage.tr(), + color: context.pageStyleTextColor, + fontSize: 14.0, + ), + const VSpace(8.0), + _buildBuiltImages(context, state), + ], + ), + ); + }, + ); + } + + Widget _buildPureColors( + BuildContext context, + DocumentPageStyleState state, + ) { + return SizedBox( + height: 42.0, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: FlowyTint.values.length, + separatorBuilder: (context, index) => const HSpace(12.0), + itemBuilder: (context, index) => _buildColorButton( + context, + state, + FlowyTint.values[index], + ), + ), + ); + } + + Widget _buildGradientColors( + BuildContext context, + DocumentPageStyleState state, + ) { + return SizedBox( + height: 42.0, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: FlowyGradientColor.values.length, + separatorBuilder: (context, index) => const HSpace(12.0), + itemBuilder: (context, index) => _buildGradientButton( + context, + state, + FlowyGradientColor.values[index], + ), + ), + ); + } + + Widget _buildColorButton( + BuildContext context, + DocumentPageStyleState state, + FlowyTint tint, + ) { + final isSelected = + state.coverImage.isPureColor && state.coverImage.value == tint.id; + + final child = !isSelected + ? Container( + width: 42, + height: 42, + decoration: ShapeDecoration( + color: tint.color(context), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(21), + ), + ), + ) + : Container( + width: 42, + height: 42, + decoration: ShapeDecoration( + color: Colors.transparent, + shape: RoundedRectangleBorder( + side: BorderSide( + width: 1.50, + color: Theme.of(context).colorScheme.primary, + ), + borderRadius: BorderRadius.circular(21), + ), + ), + alignment: Alignment.center, + child: Container( + width: 34, + height: 34, + decoration: ShapeDecoration( + color: tint.color(context), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(17), + ), + ), + ), + ); + + return FeedbackGestureDetector( + onTap: () { + context.read().add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover( + type: PageStyleCoverImageType.pureColor, + value: tint.id, + ), + ), + ); + }, + child: child, + ); + } + + Widget _buildGradientButton( + BuildContext context, + DocumentPageStyleState state, + FlowyGradientColor gradientColor, + ) { + final isSelected = state.coverImage.isGradient && + state.coverImage.value == gradientColor.id; + + final child = !isSelected + ? Container( + width: 42, + height: 42, + decoration: ShapeDecoration( + gradient: gradientColor.linear, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(21), + ), + ), + ) + : Container( + width: 42, + height: 42, + decoration: ShapeDecoration( + color: Colors.transparent, + shape: RoundedRectangleBorder( + side: BorderSide( + width: 1.50, + color: Theme.of(context).colorScheme.primary, + ), + borderRadius: BorderRadius.circular(21), + ), + ), + alignment: Alignment.center, + child: Container( + width: 34, + height: 34, + decoration: ShapeDecoration( + gradient: gradientColor.linear, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(17), + ), + ), + ), + ); + + return FeedbackGestureDetector( + onTap: () { + context.read().add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover( + type: PageStyleCoverImageType.gradientColor, + value: gradientColor.id, + ), + ), + ); + }, + child: child, + ); + } + + Widget _buildBuiltImages( + BuildContext context, + DocumentPageStyleState state, + ) { + final imageNames = ['1', '2', '3', '4', '5', '6']; + return GridView.builder( + shrinkWrap: true, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 16.0 / 9.0, + ), + itemCount: imageNames.length, + itemBuilder: (context, index) => _buildBuiltInImage( + context, + state, + imageNames[index], + ), + ); + } + + Widget _buildBuiltInImage( + BuildContext context, + DocumentPageStyleState state, + String imageName, + ) { + final asset = PageStyleCoverImageType.builtInImagePath(imageName); + final isSelected = + state.coverImage.isBuiltInImage && state.coverImage.value == imageName; + final image = ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.asset( + asset, + fit: BoxFit.cover, + ), + ); + final child = !isSelected + ? image + : Container( + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide(width: 1.50, color: Color(0xFF00BCF0)), + borderRadius: BorderRadius.circular(6), + ), + ), + padding: const EdgeInsets.all(2.0), + child: image, + ); + + return FeedbackGestureDetector( + onTap: () { + context.read().add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover( + type: PageStyleCoverImageType.builtInImage, + value: imageName, + ), + ), + ); + }, + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart new file mode 100644 index 0000000000000..27498cc65e8e9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart @@ -0,0 +1,410 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_cover_bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/feedback_gesture_detector.dart'; +import 'package:appflowy/shared/flowy_gradient_colors.dart'; +import 'package:appflowy/shared/permission/permission_checker.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; + +class PageStyleCoverImage extends StatelessWidget { + PageStyleCoverImage({ + super.key, + required this.documentId, + }); + + final String documentId; + late final ImagePicker _imagePicker = ImagePicker(); + + @override + Widget build(BuildContext context) { + final backgroundColor = context.pageStyleBackgroundColor; + return BlocBuilder( + builder: (context, state) { + return Column( + children: [ + _buildOptionGroup(context, backgroundColor, state), + const VSpace(16.0), + _buildPreview(context, state), + ], + ); + }, + ); + } + + Widget _buildOptionGroup( + BuildContext context, + Color backgroundColor, + DocumentPageStyleState state, + ) { + return Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(12), + right: Radius.circular(12), + ), + ), + padding: const EdgeInsets.all(4.0), + child: Row( + children: [ + _CoverOptionButton( + showLeftCorner: true, + showRightCorner: false, + selected: state.coverImage.isPresets, + onTap: () => _showPresets(context), + child: const _PresetCover(), + ), + _CoverOptionButton( + showLeftCorner: false, + showRightCorner: false, + selected: state.coverImage.isPhoto, + onTap: () => _pickImage(context), + child: const _PhotoCover(), + ), + _CoverOptionButton( + showLeftCorner: false, + showRightCorner: true, + selected: state.coverImage.isUnsplashImage, + onTap: () => _showUnsplash(context), + child: const _UnsplashCover(), + ), + ], + ), + ); + } + + Widget _buildPreview( + BuildContext context, + DocumentPageStyleState state, + ) { + final cover = state.coverImage; + if (cover.isNone) { + return const SizedBox.shrink(); + } + + final value = cover.value; + final type = cover.type; + + Widget preview = const SizedBox.shrink(); + + if (type == PageStyleCoverImageType.customImage || + type == PageStyleCoverImageType.unsplashImage) { + final userProfilePB = + context.read().state.userProfilePB; + preview = FlowyNetworkImage( + url: value, + userProfilePB: userProfilePB, + ); + } + + if (type == PageStyleCoverImageType.builtInImage) { + preview = Image.asset( + PageStyleCoverImageType.builtInImagePath(value), + fit: BoxFit.cover, + ); + } + + if (type == PageStyleCoverImageType.pureColor) { + final color = value.coverColor(context); + if (color != null) { + preview = ColoredBox( + color: color, + ); + } + } + + if (type == PageStyleCoverImageType.gradientColor) { + preview = Container( + decoration: BoxDecoration( + gradient: FlowyGradientColor.fromId(value).linear, + ), + ); + } + + if (type == PageStyleCoverImageType.localImage) { + preview = Image.file( + File(value), + fit: BoxFit.cover, + ); + } + + return Row( + children: [ + FlowyText(LocaleKeys.pageStyle_image.tr()), + const Spacer(), + Container( + width: 40, + height: 28, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(6.0)), + border: Border.all(color: const Color(0x1F222533)), + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(5.0)), + child: preview, + ), + ), + ], + ); + } + + void _showPresets(BuildContext context) { + final pageStyleBloc = context.read(); + + context.pop(); + + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + showDoneButton: true, + showHeader: true, + showRemoveButton: true, + onRemove: () { + pageStyleBloc.add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover.none(), + ), + ); + }, + title: LocaleKeys.pageStyle_presets.tr(), + backgroundColor: AFThemeExtension.of(context).background, + builder: (_) { + return BlocProvider.value( + value: pageStyleBloc, + child: const PageCoverBottomSheet(), + ); + }, + ); + } + + Future _pickImage(BuildContext context) async { + final photoPermission = + await PermissionChecker.checkPhotoPermission(context); + if (!photoPermission) { + Log.error('Has no permission to access the photo library'); + return; + } + + XFile? result; + try { + result = await _imagePicker.pickImage(source: ImageSource.gallery); + } catch (e) { + Log.error('Error while picking image: $e'); + return; + } + + final path = result?.path; + if (path != null && context.mounted) { + final String? result; + final userProfile = await UserBackendService.getCurrentUserProfile().fold( + (s) => s, + (f) => null, + ); + final isAppFlowyCloud = + userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud; + final PageStyleCoverImageType type; + if (!isAppFlowyCloud) { + result = await saveImageToLocalStorage(path); + type = PageStyleCoverImageType.localImage; + } else { + // else we should save the image to cloud storage + (result, _) = await saveImageToCloudStorage(path, documentId); + type = PageStyleCoverImageType.customImage; + } + if (!context.mounted) { + return; + } + if (result == null) { + return showSnapBar( + context, + LocaleKeys.document_plugins_image_imageUploadFailed.tr(), + ); + } + + context.read().add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover(type: type, value: result), + ), + ); + } + } + + void _showUnsplash(BuildContext context) { + final pageStyleBloc = context.read(); + final backgroundColor = AFThemeExtension.of(context).background; + final maxHeight = MediaQuery.of(context).size.height * 0.6; + + context.pop(); + + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + showDoneButton: true, + showHeader: true, + showRemoveButton: true, + title: LocaleKeys.pageStyle_unsplash.tr(), + backgroundColor: backgroundColor, + onRemove: () { + pageStyleBloc.add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover.none(), + ), + ); + }, + builder: (_) { + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight, minHeight: 80), + child: BlocProvider.value( + value: pageStyleBloc, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: UnsplashImageWidget( + type: UnsplashImageType.fullScreen, + onSelectUnsplashImage: (url) { + pageStyleBloc.add( + DocumentPageStyleEvent.updateCoverImage( + PageStyleCover( + type: PageStyleCoverImageType.unsplashImage, + value: url, + ), + ), + ); + }, + ), + ), + ), + ); + }, + ); + } +} + +class _UnsplashCover extends StatelessWidget { + const _UnsplashCover(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg(FlowySvgs.m_page_style_unsplash_m), + const VSpace(4.0), + FlowyText( + LocaleKeys.pageStyle_unsplash.tr(), + fontSize: 12.0, + ), + ], + ); + } +} + +class _PhotoCover extends StatelessWidget { + const _PhotoCover(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg(FlowySvgs.m_page_style_photo_m), + const VSpace(4.0), + FlowyText( + LocaleKeys.pageStyle_photo.tr(), + fontSize: 12.0, + ), + ], + ); + } +} + +class _PresetCover extends StatelessWidget { + const _PresetCover(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.m_page_style_presets_m, + blendMode: null, + ), + const VSpace(4.0), + FlowyText( + LocaleKeys.pageStyle_presets.tr(), + fontSize: 12.0, + ), + ], + ); + } +} + +class _CoverOptionButton extends StatelessWidget { + const _CoverOptionButton({ + required this.showLeftCorner, + required this.showRightCorner, + required this.child, + required this.onTap, + required this.selected, + }); + + final Widget child; + final bool showLeftCorner; + final bool showRightCorner; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: FeedbackGestureDetector( + feedbackType: HapticFeedbackType.medium, + onTap: onTap, + child: AnimatedContainer( + height: 64, + duration: Durations.medium1, + decoration: selected + ? ShapeDecoration( + color: const Color(0x141AC3F2), + shape: RoundedRectangleBorder( + side: const BorderSide( + width: 1.50, + color: Color(0xFF1AC3F2), + ), + borderRadius: BorderRadius.circular(12), + ), + ) + : null, + alignment: Alignment.center, + child: child, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart new file mode 100644 index 0000000000000..6b640be1c4a3f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart @@ -0,0 +1,101 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; + +class PageStyleIcon extends StatefulWidget { + const PageStyleIcon({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + State createState() => _PageStyleIconState(); +} + +class _PageStyleIconState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => PageStyleIconBloc(view: widget.view) + ..add(const PageStyleIconEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final icon = state.icon ?? EmojiIconData.none(); + return GestureDetector( + onTap: () => _showIconSelector(context), + behavior: HitTestBehavior.opaque, + child: Container( + height: 52, + decoration: BoxDecoration( + color: context.pageStyleBackgroundColor, + borderRadius: BorderRadius.circular(12.0), + ), + child: Row( + children: [ + const HSpace(16.0), + FlowyText(LocaleKeys.document_plugins_emoji.tr()), + const Spacer(), + RawEmojiIconWidget( + emoji: icon.isNotEmpty + ? icon + : EmojiIconData.emoji(LocaleKeys.pageStyle_none.tr()), + emojiSize: icon.isNotEmpty ? 22.0 : 16.0, + ), + const HSpace(6.0), + const FlowySvg(FlowySvgs.m_page_style_arrow_right_s), + const HSpace(12.0), + ], + ), + ), + ); + }, + ), + ); + } + + void _showIconSelector(BuildContext context) { + Navigator.pop(context); + final pageStyleIconBloc = PageStyleIconBloc(view: widget.view) + ..add(const PageStyleIconEvent.initial()); + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + showHeader: true, + title: LocaleKeys.titleBar_pageIcon.tr(), + backgroundColor: AFThemeExtension.of(context).background, + enableDraggableScrollable: true, + minChildSize: 0.6, + initialChildSize: 0.61, + scrollableWidgetBuilder: (ctx, controller) { + return BlocProvider.value( + value: pageStyleIconBloc, + child: Expanded( + child: FlowyIconEmojiPicker( + onSelectedEmoji: (r) { + pageStyleIconBloc.add( + PageStyleIconEvent.updateIcon(r, true), + ); + Navigator.pop(ctx); + }, + ), + ), + ); + }, + builder: (_) => const SizedBox.shrink(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart new file mode 100644 index 0000000000000..a930c8d87e2bf --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart @@ -0,0 +1,81 @@ +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part '_page_style_icon_bloc.freezed.dart'; + +class PageStyleIconBloc extends Bloc { + PageStyleIconBloc({ + required this.view, + }) : _viewListener = ViewListener(viewId: view.id), + super(PageStyleIconState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + add( + PageStyleIconEvent.updateIcon( + view.icon.toEmojiIconData(), + false, + ), + ); + _viewListener?.start( + onViewUpdated: (view) { + add( + PageStyleIconEvent.updateIcon( + view.icon.toEmojiIconData(), + false, + ), + ); + }, + ); + }, + updateIcon: (icon, shouldUpdateRemote) async { + emit( + state.copyWith( + icon: icon, + ), + ); + if (shouldUpdateRemote && icon != null) { + await ViewBackendService.updateViewIcon( + viewId: view.id, + viewIcon: icon, + ); + } + }, + ); + }, + ); + } + + final ViewPB view; + final ViewListener? _viewListener; + + @override + Future close() { + _viewListener?.stop(); + return super.close(); + } +} + +@freezed +class PageStyleIconEvent with _$PageStyleIconEvent { + const factory PageStyleIconEvent.initial() = Initial; + + const factory PageStyleIconEvent.updateIcon( + EmojiIconData? icon, + bool shouldUpdateRemote, + ) = UpdateIconInner; +} + +@freezed +class PageStyleIconState with _$PageStyleIconState { + const factory PageStyleIconState({ + @Default(null) EmojiIconData? icon, + }) = _PageStyleIconState; + + factory PageStyleIconState.initial() => const PageStyleIconState(); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart new file mode 100644 index 0000000000000..211e287d159c2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart @@ -0,0 +1,245 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart'; +import 'package:appflowy/shared/feedback_gesture_detector.dart'; +import 'package:appflowy/util/font_family_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +const kPageStyleLayoutHeight = 52.0; + +class PageStyleLayout extends StatelessWidget { + const PageStyleLayout({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + children: [ + Row( + children: [ + _OptionGroup( + options: const [ + PageStyleFontLayout.small, + PageStyleFontLayout.normal, + PageStyleFontLayout.large, + ], + selectedOption: state.fontLayout, + onTap: (option) => context + .read() + .add(DocumentPageStyleEvent.updateFont(option)), + ), + const HSpace(14), + _OptionGroup( + options: const [ + PageStyleLineHeightLayout.small, + PageStyleLineHeightLayout.normal, + PageStyleLineHeightLayout.large, + ], + selectedOption: state.lineHeightLayout, + onTap: (option) => context + .read() + .add(DocumentPageStyleEvent.updateLineHeight(option)), + ), + ], + ), + const VSpace(12.0), + const _FontButton(), + ], + ); + }, + ); + } +} + +class _OptionGroup extends StatelessWidget { + const _OptionGroup({ + required this.options, + required this.selectedOption, + required this.onTap, + }); + + final List options; + final T selectedOption; + final void Function(T option) onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: context.pageStyleBackgroundColor, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(12), + right: Radius.circular(12), + ), + ), + child: Row( + children: options.map((option) { + final child = _buildSvg(option); + final showLeftCorner = option == options.first; + final showRightCorner = option == options.last; + return _buildOptionButton( + child, + showLeftCorner, + showRightCorner, + selectedOption == option, + () => onTap(option), + ); + }).toList(), + ), + ), + ); + } + + Widget _buildOptionButton( + Widget child, + bool showLeftCorner, + bool showRightCorner, + bool selected, + VoidCallback onTap, + ) { + return Expanded( + child: FeedbackGestureDetector( + feedbackType: HapticFeedbackType.medium, + onTap: onTap, + child: AnimatedContainer( + height: kPageStyleLayoutHeight, + duration: Durations.medium1, + decoration: selected + ? ShapeDecoration( + color: const Color(0x141AC3F2), + shape: RoundedRectangleBorder( + side: const BorderSide( + width: 1.50, + color: Color(0xFF1AC3F2), + ), + borderRadius: BorderRadius.circular(12), + ), + ) + : null, + alignment: Alignment.center, + child: child, + ), + ), + ); + } + + Widget _buildSvg(dynamic option) { + if (option is PageStyleFontLayout) { + return switch (option) { + PageStyleFontLayout.small => + const FlowySvg(FlowySvgs.m_font_size_small_s), + PageStyleFontLayout.normal => + const FlowySvg(FlowySvgs.m_font_size_normal_s), + PageStyleFontLayout.large => + const FlowySvg(FlowySvgs.m_font_size_large_s), + }; + } else if (option is PageStyleLineHeightLayout) { + return switch (option) { + PageStyleLineHeightLayout.small => + const FlowySvg(FlowySvgs.m_layout_small_s), + PageStyleLineHeightLayout.normal => + const FlowySvg(FlowySvgs.m_layout_normal_s), + PageStyleLineHeightLayout.large => + const FlowySvg(FlowySvgs.m_layout_large_s), + }; + } + throw ArgumentError('Invalid option type'); + } +} + +class _FontButton extends StatelessWidget { + const _FontButton(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final fontFamilyDisplayName = + (state.fontFamily ?? defaultFontFamily).fontFamilyDisplayName; + return GestureDetector( + onTap: () => _showFontSelector(context), + behavior: HitTestBehavior.opaque, + child: Container( + height: kPageStyleLayoutHeight, + decoration: BoxDecoration( + color: context.pageStyleBackgroundColor, + borderRadius: BorderRadius.circular(12.0), + ), + child: Row( + children: [ + const HSpace(16.0), + FlowyText(LocaleKeys.titleBar_font.tr()), + const Spacer(), + FlowyText( + fontFamilyDisplayName, + color: context.pageStyleTextColor, + ), + const HSpace(6.0), + const FlowySvg(FlowySvgs.m_page_style_arrow_right_s), + const HSpace(12.0), + ], + ), + ), + ); + }, + ); + } + + void _showFontSelector(BuildContext context) { + final pageStyleBloc = context.read(); + context.pop(); + + showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + showDoneButton: true, + showHeader: true, + title: LocaleKeys.titleBar_font.tr(), + backgroundColor: AFThemeExtension.of(context).background, + enableDraggableScrollable: true, + minChildSize: 0.6, + initialChildSize: 0.61, + scrollableWidgetBuilder: (_, controller) { + return BlocProvider.value( + value: pageStyleBloc, + child: BlocBuilder( + builder: (context, state) { + return Expanded( + child: Scrollbar( + controller: controller, + child: FontSelector( + scrollController: controller, + selectedFontFamilyName: + state.fontFamily ?? defaultFontFamily, + onFontFamilySelected: (fontFamilyName) { + pageStyleBloc.add( + DocumentPageStyleEvent.updateFontFamily( + fontFamilyName, + ), + ); + }, + ), + ), + ); + }, + ), + ); + }, + builder: (_) => const SizedBox.shrink(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart new file mode 100644 index 0000000000000..101046fb933f0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +extension PageStyleUtil on BuildContext { + Color get pageStyleBackgroundColor { + final themeMode = Theme.of(this).brightness; + return themeMode == Brightness.light + ? const Color(0xFFF5F5F8) + : const Color(0xFF303030); + } + + Color get pageStyleTextColor { + final themeMode = Theme.of(this).brightness; + return themeMode == Brightness.light + ? const Color(0x7F1F2225) + : Colors.white54; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart new file mode 100644 index 0000000000000..555881a38f1d2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart @@ -0,0 +1,58 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_cover_image.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_util.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class PageStyleBottomSheet extends StatelessWidget { + const PageStyleBottomSheet({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // cover image + FlowyText( + LocaleKeys.pageStyle_coverImage.tr(), + color: context.pageStyleTextColor, + fontSize: 14.0, + ), + const VSpace(8.0), + PageStyleCoverImage(documentId: view.id), + const VSpace(20.0), + // layout: font size, line height and font family. + FlowyText( + LocaleKeys.pageStyle_layout.tr(), + color: context.pageStyleTextColor, + fontSize: 14.0, + ), + const VSpace(8.0), + const PageStyleLayout(), + const VSpace(20.0), + // icon + FlowyText( + LocaleKeys.pageStyle_pageIcon.tr(), + color: context.pageStyleTextColor, + fontSize: 14.0, + ), + const VSpace(8.0), + PageStyleIcon( + view: view, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/callout_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/callout_node_parser.dart new file mode 100644 index 0000000000000..867fcf236fda5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/callout_node_parser.dart @@ -0,0 +1,27 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +class CalloutNodeParser extends NodeParser { + const CalloutNodeParser(); + + @override + String get id => CalloutBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + assert(node.children.isEmpty); + final icon = node.attributes[CalloutBlockKeys.icon]; + final delta = node.delta ?? Delta() + ..insert(''); + final String markdown = DeltaMarkdownEncoder() + .convert(delta) + .split('\n') + .map((e) => '> $e') + .join('\n'); + return ''' +> $icon +$markdown + +'''; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_image_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_image_node_parser.dart new file mode 100644 index 0000000000000..91398302ed8c0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_image_node_parser.dart @@ -0,0 +1,18 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +import '../image/custom_image_block_component/custom_image_block_component.dart'; + +class CustomImageNodeParser extends NodeParser { + const CustomImageNodeParser(); + + @override + String get id => ImageBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + assert(node.children.isEmpty); + final url = node.attributes[CustomImageBlockKeys.url]; + assert(url != null); + return '![]($url)\n'; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart new file mode 100644 index 0000000000000..3f2895d57e35b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart @@ -0,0 +1,7 @@ +export 'callout_node_parser.dart'; +export 'custom_image_node_parser.dart'; +export 'file_block_node_parser.dart'; +export 'link_preview_node_parser.dart'; +export 'math_equation_node_parser.dart'; +export 'simple_table_node_parser.dart'; +export 'toggle_list_node_parser.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/file_block_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/file_block_node_parser.dart new file mode 100644 index 0000000000000..e57ededcec447 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/file_block_node_parser.dart @@ -0,0 +1,19 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +class FileBlockNodeParser extends NodeParser { + const FileBlockNodeParser(); + + @override + String get id => FileBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + final name = node.attributes[FileBlockKeys.name]; + final url = node.attributes[FileBlockKeys.url]; + if (name == null || url == null) { + return ''; + } + return '[$name]($url)\n'; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/link_preview_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/link_preview_node_parser.dart new file mode 100644 index 0000000000000..c7ce69d221be5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/link_preview_node_parser.dart @@ -0,0 +1,18 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; + +class LinkPreviewNodeParser extends NodeParser { + const LinkPreviewNodeParser(); + + @override + String get id => LinkPreviewBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + final href = node.attributes[LinkPreviewBlockKeys.url]; + if (href == null) { + return ''; + } + return '[$href]($href)\n'; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_code_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_code_parser.dart new file mode 100644 index 0000000000000..d756c25d6b6b2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_code_parser.dart @@ -0,0 +1,50 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:markdown/markdown.dart' as md; + +class MarkdownCodeBlockParser extends CustomMarkdownParser { + const MarkdownCodeBlockParser(); + + @override + List transform( + md.Node element, + List parsers, { + MarkdownListType listType = MarkdownListType.unknown, + int? startNumber, + }) { + if (element is! md.Element) { + return []; + } + + if (element.tag != 'pre') { + return []; + } + + final ec = element.children; + if (ec == null || ec.isEmpty) { + return []; + } + + final code = ec.first; + if (code is! md.Element || code.tag != 'code') { + return []; + } + + String? language; + if (code.attributes.containsKey('class')) { + final classes = code.attributes['class']!.split(' '); + final languageClass = classes.firstWhere( + (c) => c.startsWith('language-'), + orElse: () => '', + ); + language = languageClass.substring('language-'.length); + } + + return [ + codeBlockNode( + language: language, + delta: Delta()..insert(code.textContent.trimRight()), + ), + ]; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_parsers.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_parsers.dart new file mode 100644 index 0000000000000..4ad773464342d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_parsers.dart @@ -0,0 +1,2 @@ +export 'markdown_code_parser.dart'; +export 'markdown_simple_table_parser.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_simple_table_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_simple_table_parser.dart new file mode 100644 index 0000000000000..47668ec0ff4da --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_simple_table_parser.dart @@ -0,0 +1,103 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:markdown/markdown.dart' as md; + +class MarkdownSimpleTableParser extends CustomMarkdownParser { + const MarkdownSimpleTableParser(); + + @override + List transform( + md.Node element, + List parsers, { + MarkdownListType listType = MarkdownListType.unknown, + int? startNumber, + }) { + if (element is! md.Element) { + return []; + } + + if (element.tag != 'table') { + return []; + } + + final ec = element.children; + if (ec == null || ec.isEmpty) { + return []; + } + + final th = ec + .whereType() + .where((e) => e.tag == 'thead') + .firstOrNull + ?.children + ?.whereType() + .where((e) => e.tag == 'tr') + .expand((e) => e.children?.whereType().toList() ?? []) + .where((e) => e.tag == 'th') + .toList(); + + final tr = ec + .whereType() + .where((e) => e.tag == 'tbody') + .firstOrNull + ?.children + ?.whereType() + .where((e) => e.tag == 'tr') + .toList(); + + if (th == null || tr == null || th.isEmpty || tr.isEmpty) { + return []; + } + + final rows = []; + + // Add header cells + + rows.add( + simpleTableRowBlockNode( + children: th + .map( + (e) => simpleTableCellBlockNode( + children: [ + paragraphNode( + delta: DeltaMarkdownDecoder().convertNodes(e.children), + ), + ], + ), + ) + .toList(), + ), + ); + + // Add body cells + for (var i = 0; i < tr.length; i++) { + final td = tr[i] + .children + ?.whereType() + .where((e) => e.tag == 'td') + .toList(); + + if (td == null || td.isEmpty) { + continue; + } + + rows.add( + simpleTableRowBlockNode( + children: td + .map( + (e) => simpleTableCellBlockNode( + children: [ + paragraphNode( + delta: DeltaMarkdownDecoder().convertNodes(e.children), + ), + ], + ), + ) + .toList(), + ), + ); + } + + return [simpleTableBlockNode(children: rows)]; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/math_equation_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/math_equation_node_parser.dart new file mode 100644 index 0000000000000..fed895c97814f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/math_equation_node_parser.dart @@ -0,0 +1,14 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +class MathEquationNodeParser extends NodeParser { + const MathEquationNodeParser(); + + @override + String get id => MathEquationBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + return '\$\$${node.attributes[id]}\$\$'; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/simple_table_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/simple_table_node_parser.dart new file mode 100644 index 0000000000000..b01e797595bde --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/simple_table_node_parser.dart @@ -0,0 +1,92 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +/// Parser for converting SimpleTable nodes to markdown format +class SimpleTableNodeParser extends NodeParser { + const SimpleTableNodeParser(); + + @override + String get id => SimpleTableBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + try { + final tableData = _extractTableData(node, encoder); + if (tableData.isEmpty) { + return ''; + } + return _buildMarkdownTable(tableData); + } catch (e) { + return ''; + } + } + + /// Extracts table data from the node structure into a 2D list of strings + /// Each inner list represents a row, and each string represents a cell's content + List> _extractTableData( + Node node, + DocumentMarkdownEncoder? encoder, + ) { + final tableData = >[]; + final rows = node.children; + + for (final row in rows) { + final rowData = _extractRowData(row, encoder); + tableData.add(rowData); + } + + return tableData; + } + + /// Extracts data from a single table row + List _extractRowData(Node row, DocumentMarkdownEncoder? encoder) { + final rowData = []; + final cells = row.children; + + for (final cell in cells) { + final content = _extractCellContent(cell, encoder); + rowData.add(content); + } + + return rowData; + } + + /// Extracts and formats content from a single table cell + String _extractCellContent(Node cell, DocumentMarkdownEncoder? encoder) { + final contentBuffer = StringBuffer(); + + for (final child in cell.children) { + final delta = child.delta; + + // if the node doesn't contain delta, fallback to the encoder + final content = delta != null + ? DeltaMarkdownEncoder().convert(delta) + : encoder?.convertNodes([child]).trim() ?? ''; + + // Escape pipe characters to prevent breaking markdown table structure + contentBuffer.write(content.replaceAll('|', '\\|')); + } + + return contentBuffer.toString(); + } + + /// Builds a markdown table string from the extracted table data + /// First row is treated as header, followed by separator row and data rows + String _buildMarkdownTable(List> tableData) { + final markdown = StringBuffer(); + final columnCount = tableData[0].length; + + // Add header row + markdown.writeln('|${tableData[0].join('|')}|'); + + // Add separator row + markdown.writeln('|${List.filled(columnCount, '---').join('|')}|'); + + // Add data rows (skip header row) + for (int i = 1; i < tableData.length; i++) { + markdown.writeln('|${tableData[i].join('|')}|'); + } + + return markdown.toString(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/toggle_list_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/toggle_list_node_parser.dart new file mode 100644 index 0000000000000..3cf79a671a5ea --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/toggle_list_node_parser.dart @@ -0,0 +1,44 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +enum ToggleListExportStyle { + github, + markdown, +} + +class ToggleListNodeParser extends NodeParser { + const ToggleListNodeParser({ + this.exportStyle = ToggleListExportStyle.markdown, + }); + + final ToggleListExportStyle exportStyle; + + @override + String get id => ToggleListBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + final delta = node.delta ?? Delta() + ..insert(''); + String markdown = DeltaMarkdownEncoder().convert(delta); + final details = encoder?.convertNodes( + node.children, + withIndent: true, + ); + switch (exportStyle) { + case ToggleListExportStyle.github: + return '''
+$markdown + +$details +
+'''; + case ToggleListExportStyle.markdown: + markdown = '- $markdown\n'; + if (details != null && details.isNotEmpty) { + markdown += details; + } + return markdown; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart new file mode 100644 index 0000000000000..571ac53bc09f4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart @@ -0,0 +1,74 @@ +export 'actions/block_action_list.dart'; +export 'actions/option/option_actions.dart'; +export 'align_toolbar_item/align_toolbar_item.dart'; +export 'base/backtick_character_command.dart'; +export 'base/cover_title_command.dart'; +export 'base/toolbar_extension.dart'; +export 'bulleted_list/bulleted_list_icon.dart'; +export 'callout/callout_block_component.dart'; +export 'code_block/code_block_language_selector.dart'; +export 'code_block/code_block_menu_item.dart'; +export 'context_menu/custom_context_menu.dart'; +export 'copy_and_paste/custom_copy_command.dart'; +export 'copy_and_paste/custom_cut_command.dart'; +export 'copy_and_paste/custom_paste_command.dart'; +export 'database/database_view_block_component.dart'; +export 'database/inline_database_menu_item.dart'; +export 'database/referenced_database_menu_item.dart'; +export 'error/error_block_component_builder.dart'; +export 'extensions/flowy_tint_extension.dart'; +export 'file/file_block.dart'; +export 'find_and_replace/find_and_replace_menu.dart'; +export 'font/customize_font_toolbar_item.dart'; +export 'header/cover_editor_bloc.dart'; +export 'header/custom_cover_picker.dart'; +export 'header/document_cover_widget.dart'; +export 'heading/heading_toolbar_item.dart'; +export 'image/custom_image_block_component/custom_image_block_component.dart'; +export 'image/custom_image_block_component/image_menu.dart'; +export 'image/image_selection_menu.dart'; +export 'image/mobile_image_toolbar_item.dart'; +export 'image/multi_image_block_component/multi_image_block_component.dart'; +export 'image/multi_image_block_component/multi_image_menu.dart'; +export 'inline_math_equation/inline_math_equation.dart'; +export 'inline_math_equation/inline_math_equation_toolbar_item.dart'; +export 'keyboard_interceptor/keyboard_interceptor.dart'; +export 'link_preview/custom_link_preview.dart'; +export 'link_preview/link_preview_cache.dart'; +export 'link_preview/link_preview_menu.dart'; +export 'math_equation/math_equation_block_component.dart'; +export 'math_equation/mobile_math_equation_toolbar_item.dart'; +export 'mention/mention_block.dart'; +export 'mobile_floating_toolbar/custom_mobile_floating_toolbar.dart'; +export 'mobile_toolbar_v3/aa_toolbar_item.dart'; +export 'mobile_toolbar_v3/add_attachment_item.dart'; +export 'mobile_toolbar_v3/add_block_toolbar_item.dart'; +export 'mobile_toolbar_v3/appflowy_mobile_toolbar.dart'; +export 'mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart'; +export 'mobile_toolbar_v3/basic_toolbar_item.dart'; +export 'mobile_toolbar_v3/indent_outdent_toolbar_item.dart'; +export 'mobile_toolbar_v3/list_toolbar_item.dart'; +export 'mobile_toolbar_v3/more_toolbar_item.dart'; +export 'mobile_toolbar_v3/toolbar_item_builder.dart'; +export 'mobile_toolbar_v3/undo_redo_toolbar_item.dart'; +export 'mobile_toolbar_v3/util.dart'; +export 'numbered_list/numbered_list_icon.dart'; +export 'openai/widgets/ai_writer_block_component.dart'; +export 'openai/widgets/ask_ai_block_component.dart'; +export 'openai/widgets/ask_ai_toolbar_item.dart'; +export 'outline/outline_block_component.dart'; +export 'parsers/document_markdown_parsers.dart'; +export 'parsers/markdown_parsers.dart'; +export 'parsers/markdown_simple_table_parser.dart'; +export 'quote/quote_block_shortcuts.dart'; +export 'shortcuts/character_shortcuts.dart'; +export 'shortcuts/command_shortcuts.dart'; +export 'simple_table/simple_table.dart'; +export 'slash_menu/slash_command.dart'; +export 'slash_menu/slash_menu_items_builder.dart'; +export 'sub_page/sub_page_block_component.dart'; +export 'table/table_menu.dart'; +export 'table/table_option_action.dart'; +export 'todo_list/todo_list_icon.dart'; +export 'toggle/toggle_block_component.dart'; +export 'toggle/toggle_block_shortcuts.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_shortcuts.dart new file mode 100644 index 0000000000000..ba3ad6e7dfd68 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/quote/quote_block_shortcuts.dart @@ -0,0 +1,39 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; + +/// Pressing Enter in a quote block will insert a newline (\n) within the quote, +/// while pressing Shift+Enter in a quote will insert a new paragraph next to the quote. +/// +/// - support +/// - desktop +/// - mobile +/// - web +/// +final CharacterShortcutEvent insertNewLineInQuoteBlock = CharacterShortcutEvent( + key: 'insert a new line in quote block', + character: '\n', + handler: _insertNewLineHandler, +); + +CharacterShortcutEventHandler _insertNewLineHandler = (editorState) async { + final selection = editorState.selection?.normalized; + if (selection == null) { + return false; + } + + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.type != QuoteBlockKeys.type) { + return false; + } + + // delete the selection + await editorState.deleteSelection(selection); + + if (HardwareKeyboard.instance.isShiftPressed) { + await editorState.insertNewLine(); + } else { + await editorState.insertTextAtCurrentSelection('\n'); + } + + return true; +}; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart new file mode 100644 index 0000000000000..40d4c5416345e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart @@ -0,0 +1,23 @@ +import 'package:flutter/widgets.dart'; + +/// Shared context for the editor plugins. +/// +/// For example, the backspace command requires the focus node of the cover title. +/// so we need to use the shared context to get the focus node. +/// +class SharedEditorContext { + SharedEditorContext() : _coverTitleFocusNode = FocusNode(); + + // The focus node of the cover title. + final FocusNode _coverTitleFocusNode; + + bool requestCoverTitleFocus = false; + + bool isInDatabaseRowPage = false; + + FocusNode get coverTitleFocusNode => _coverTitleFocusNode; + + void dispose() { + _coverTitleFocusNode.dispose(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart new file mode 100644 index 0000000000000..080027d179bce --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart @@ -0,0 +1,83 @@ +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/format_arrow_character.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_shortcuts.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/heading_block_shortcuts.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/numbered_list_block_shortcuts.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_command.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter/material.dart'; + +List buildCharacterShortcutEvents( + BuildContext context, + DocumentBloc documentBloc, + EditorStyleCustomizer styleCustomizer, + InlineActionsService inlineActionsService, + SlashMenuItemsBuilder slashMenuItemsBuilder, +) { + return [ + // code block + formatBacktickToCodeBlock, + ...codeBlockCharacterEvents, + + // callout block + insertNewLineInCalloutBlock, + + // quote block + insertNewLineInQuoteBlock, + + // toggle list + formatGreaterToToggleList, + insertChildNodeInsideToggleList, + + // customize the slash menu command + customAppFlowySlashCommand( + itemsBuilder: slashMenuItemsBuilder, + style: styleCustomizer.selectionMenuStyleBuilder(), + supportSlashMenuNodeTypes: supportSlashMenuNodeTypes, + ), + + customFormatGreaterEqual, + + customFormatNumberToNumberedList, + customFormatSignToHeading, + + ...standardCharacterShortcutEvents + ..removeWhere( + (shortcut) => [ + slashCommand, // Remove default slash command + formatGreaterEqual, // Overridden by customFormatGreaterEqual + formatNumberToNumberedList, // Overridden by customFormatNumberToNumberedList + formatSignToHeading, // Overridden by customFormatSignToHeading + ].contains(shortcut), + ), + + /// Inline Actions + /// - Reminder + /// - Inline-page reference + inlineActionsCommand( + inlineActionsService, + style: styleCustomizer.inlineActionsMenuStyleBuilder(), + ), + + /// Inline page menu + /// - Using `[[` + pageReferenceShortcutBrackets( + context, + documentBloc.documentId, + styleCustomizer.inlineActionsMenuStyleBuilder(), + ), + + /// - Using `+` + pageReferenceShortcutPlusSign( + context, + documentBloc.documentId, + styleCustomizer.inlineActionsMenuStyleBuilder(), + ), + ]; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart new file mode 100644 index 0000000000000..8b168bcd30160 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart @@ -0,0 +1,76 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'exit_edit_mode_command.dart'; + +final List defaultCommandShortcutEvents = [ + ...commandShortcutEvents.map((e) => e.copyWith()), +]; + +// Command shortcuts are order-sensitive. Verify order when modifying. +List commandShortcutEvents = [ + ...simpleTableCommands, + + customExitEditingCommand, + backspaceToTitle, + removeToggleHeadingStyle, + + arrowUpToTitle, + arrowLeftToTitle, + + toggleToggleListCommand, + + ...localizedCodeBlockCommands, + + customCopyCommand, + customPasteCommand, + customPastePlainTextCommand, + customCutCommand, + customUndoCommand, + customRedoCommand, + + ...customTextAlignCommands, + + // remove standard shortcuts for copy, cut, paste, todo + ...standardCommandShortcutEvents + ..removeWhere( + (shortcut) => [ + copyCommand, + cutCommand, + pasteCommand, + pasteTextWithoutFormattingCommand, + toggleTodoListCommand, + undoCommand, + redoCommand, + exitEditingCommand, + ...tableCommands, + ].contains(shortcut), + ), + + emojiShortcutEvent, +]; + +final _codeBlockLocalization = CodeBlockLocalizations( + codeBlockNewParagraph: + LocaleKeys.settings_shortcutsPage_commands_codeBlockNewParagraph.tr(), + codeBlockIndentLines: + LocaleKeys.settings_shortcutsPage_commands_codeBlockIndentLines.tr(), + codeBlockOutdentLines: + LocaleKeys.settings_shortcutsPage_commands_codeBlockOutdentLines.tr(), + codeBlockSelectAll: + LocaleKeys.settings_shortcutsPage_commands_codeBlockSelectAll.tr(), + codeBlockPasteText: + LocaleKeys.settings_shortcutsPage_commands_codeBlockPasteText.tr(), + codeBlockAddTwoSpaces: + LocaleKeys.settings_shortcutsPage_commands_codeBlockAddTwoSpaces.tr(), +); + +final localizedCodeBlockCommands = codeBlockCommands( + localizations: _codeBlockLocalization, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/exit_edit_mode_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/exit_edit_mode_command.dart new file mode 100644 index 0000000000000..4eaa1313900df --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/exit_edit_mode_command.dart @@ -0,0 +1,24 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +/// End key event. +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent customExitEditingCommand = CommandShortcutEvent( + key: 'exit the editing mode', + getDescription: () => AppFlowyEditorL10n.current.cmdExitEditing, + command: 'escape', + handler: _exitEditingCommandHandler, +); + +CommandShortcutEventHandler _exitEditingCommandHandler = (editorState) { + if (editorState.selection == null) { + return KeyEventResult.ignored; + } + editorState.selection = null; + editorState.service.keyboardService?.closeKeyboard(); + return KeyEventResult.handled; +}; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/heading_block_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/heading_block_shortcuts.dart new file mode 100644 index 0000000000000..a65cd61c83552 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/heading_block_shortcuts.dart @@ -0,0 +1,52 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +/// Convert '# ' to bulleted list +/// +/// - support +/// - desktop +/// - mobile +/// - web +/// +CharacterShortcutEvent customFormatSignToHeading = CharacterShortcutEvent( + key: 'format sign to heading list', + character: ' ', + handler: (editorState) async => formatMarkdownSymbol( + editorState, + (node) => true, + (_, text, selection) { + final characters = text.split(''); + // only supports h1 to h6 levels + // if the characters is empty, the every function will return true directly + return characters.isNotEmpty && + characters.every((element) => element == '#') && + characters.length < 7; + }, + (text, node, delta) { + final numberOfSign = text.split('').length; + final type = node.type; + + // if current node is toggle block, try to convert it to toggle heading block. + if (type == ToggleListBlockKeys.type) { + final collapsed = + node.attributes[ToggleListBlockKeys.collapsed] as bool?; + return [ + toggleHeadingNode( + level: numberOfSign, + delta: delta.compose(Delta()..delete(numberOfSign)), + collapsed: collapsed ?? false, + children: node.children.map((child) => child.deepCopy()), + ), + ]; + } + return [ + headingNode( + level: numberOfSign, + delta: delta.compose(Delta()..delete(numberOfSign)), + ), + if (node.children.isNotEmpty) ...node.children, + ]; + }, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/numbered_list_block_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/numbered_list_block_shortcuts.dart new file mode 100644 index 0000000000000..b0e271fbe820d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/numbered_list_block_shortcuts.dart @@ -0,0 +1,95 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +/// Convert 'num. ' to bulleted list +/// +/// - support +/// - desktop +/// - mobile +/// +/// In heading block and toggle heading block, this shortcut will be ignored. +CharacterShortcutEvent customFormatNumberToNumberedList = + CharacterShortcutEvent( + key: 'format number to numbered list', + character: ' ', + handler: (editorState) async => formatMarkdownSymbol( + editorState, + (node) => node.type != NumberedListBlockKeys.type, + (node, text, selection) { + final shouldBeIgnored = _shouldBeIgnored(node); + if (shouldBeIgnored) { + return false; + } + + final match = numberedListRegex.firstMatch(text); + if (match == null) { + return false; + } + + final matchText = match.group(0); + final numberText = match.group(1); + + if (matchText == null || numberText == null) { + return false; + } + + // if the previous one is numbered list, + // we should check the current number is the next number of the previous one + Node? previous = node.previous; + int level = 0; + int? startNumber; + while (previous != null && previous.type == NumberedListBlockKeys.type) { + startNumber = previous.attributes[NumberedListBlockKeys.number] as int?; + level++; + previous = previous.previous; + } + if (startNumber != null) { + final currentNumber = int.tryParse(numberText); + if (currentNumber == null || currentNumber != startNumber + level) { + return false; + } + } + + return selection.endIndex == matchText.length; + }, + (text, node, delta) { + final match = numberedListRegex.firstMatch(text); + final matchText = match?.group(0); + if (matchText == null) { + return [node]; + } + + final number = matchText.substring(0, matchText.length - 1); + final composedDelta = delta.compose( + Delta()..delete(matchText.length), + ); + return [ + node.copyWith( + type: NumberedListBlockKeys.type, + attributes: { + NumberedListBlockKeys.delta: composedDelta.toJson(), + NumberedListBlockKeys.number: int.tryParse(number), + }, + ), + ]; + }, + ), +); + +bool _shouldBeIgnored(Node node) { + final type = node.type; + + // ignore heading block + if (type == HeadingBlockKeys.type) { + return true; + } + + // ignore toggle heading block + final level = node.attributes[ToggleListBlockKeys.level] as int?; + if (type == ToggleListBlockKeys.type && level != null) { + return true; + } + + return false; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart new file mode 100644 index 0000000000000..4454e9efaf093 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart @@ -0,0 +1,8 @@ +export 'simple_table_block_component.dart'; +export 'simple_table_cell_block_component.dart'; +export 'simple_table_constants.dart'; +export 'simple_table_more_action.dart'; +export 'simple_table_operations/simple_table_operations.dart'; +export 'simple_table_row_block_component.dart'; +export 'simple_table_shortcuts/simple_table_commands.dart'; +export 'simple_table_widgets/widgets.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart new file mode 100644 index 0000000000000..495fb7c92211e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart @@ -0,0 +1,339 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +typedef SimpleTableColumnWidthMap = Map; +typedef SimpleTableRowAlignMap = Map; +typedef SimpleTableColumnAlignMap = Map; +typedef SimpleTableColorMap = Map; +typedef SimpleTableAttributeMap = Map; + +class SimpleTableBlockKeys { + const SimpleTableBlockKeys._(); + + static const String type = 'simple_table'; + + /// enable header row + /// it's a bool value, default is false + static const String enableHeaderRow = 'enable_header_row'; + + /// enable column header + /// it's a bool value, default is false + static const String enableHeaderColumn = 'enable_header_column'; + + /// column colors + /// it's a [SimpleTableColorMap] value, {column_index: color, ...} + /// the number of colors should be the same as the number of columns + static const String columnColors = 'column_colors'; + + /// row colors + /// it's a [SimpleTableColorMap] value, {row_index: color, ...} + /// the number of colors should be the same as the number of rows + static const String rowColors = 'row_colors'; + + /// column alignments + /// it's a [SimpleTableColumnAlignMap] value, {column_index: align, ...} + /// the value should be one of the following: 'left', 'center', 'right' + static const String columnAligns = 'column_aligns'; + + /// row alignments + /// it's a [SimpleTableRowAlignMap] value, {row_index: align, ...} + /// the value should be one of the following: 'top', 'center', 'bottom' + static const String rowAligns = 'row_aligns'; + + /// column bold attributes + /// it's a [SimpleTableAttributeMap] value, {column_index: attribute, ...} + /// the attribute should be one of the following: true, false + static const String columnBoldAttributes = 'column_bold_attributes'; + + /// row bold attributes + /// it's a [SimpleTableAttributeMap] value, {row_index: true, ...} + /// the attribute should be one of the following: true, false + static const String rowBoldAttributes = 'row_bold_attributes'; + + /// column text color attributes + /// it's a [SimpleTableColorMap] value, {column_index: color_hex_code, ...} + /// the attribute should be the color hex color or appflowy_theme_color + static const String columnTextColors = 'column_text_colors'; + + /// row text color attributes + /// it's a [SimpleTableColorMap] value, {row_index: color_hex_code, ...} + /// the attribute should be the color hex color or appflowy_theme_color + static const String rowTextColors = 'row_text_colors'; + + /// column widths + /// it's a [SimpleTableColumnWidthMap] value, {column_index: width, ...} + static const String columnWidths = 'column_widths'; + + /// distribute column widths evenly + /// if the user distributed the column widths evenly before, the value should be true, + /// and for the newly added column, using the width of the previous column. + /// it's a bool value, default is false + static const String distributeColumnWidthsEvenly = + 'distribute_column_widths_evenly'; +} + +Node simpleTableBlockNode({ + bool enableHeaderRow = false, + bool enableHeaderColumn = false, + SimpleTableColorMap? columnColors, + SimpleTableColorMap? rowColors, + SimpleTableColumnAlignMap? columnAligns, + SimpleTableRowAlignMap? rowAligns, + SimpleTableColumnWidthMap? columnWidths, + required List children, +}) { + assert(children.every((e) => e.type == SimpleTableRowBlockKeys.type)); + + return Node( + type: SimpleTableBlockKeys.type, + attributes: { + SimpleTableBlockKeys.enableHeaderRow: enableHeaderRow, + SimpleTableBlockKeys.enableHeaderColumn: enableHeaderColumn, + SimpleTableBlockKeys.columnColors: columnColors, + SimpleTableBlockKeys.rowColors: rowColors, + SimpleTableBlockKeys.columnAligns: columnAligns, + SimpleTableBlockKeys.rowAligns: rowAligns, + SimpleTableBlockKeys.columnWidths: columnWidths, + }, + children: children, + ); +} + +/// Create a simple table block node with the given column and row count. +/// +/// The table will have cells filled with paragraph nodes. +/// +/// For example, if you want to create a table with 2 columns and 3 rows, you can use: +/// ```dart +/// final table = createSimpleTableBlockNode(columnCount: 2, rowCount: 3); +/// ``` +/// +/// | cell 1 | cell 2 | +/// | cell 3 | cell 4 | +/// | cell 5 | cell 6 | +Node createSimpleTableBlockNode({ + required int columnCount, + required int rowCount, + String? defaultContent, + String Function(int rowIndex, int columnIndex)? contentBuilder, +}) { + final rows = List.generate(rowCount, (rowIndex) { + final cells = List.generate( + columnCount, + (columnIndex) => simpleTableCellBlockNode( + children: [ + paragraphNode( + text: defaultContent ?? contentBuilder?.call(rowIndex, columnIndex), + ), + ], + ), + ); + return simpleTableRowBlockNode(children: cells); + }); + + return simpleTableBlockNode(children: rows); +} + +class SimpleTableBlockComponentBuilder extends BlockComponentBuilder { + SimpleTableBlockComponentBuilder({ + super.configuration, + }); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return SimpleTableBlockWidget( + key: node.key, + node: node, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (node) => node.children.isNotEmpty; +} + +class SimpleTableBlockWidget extends BlockComponentStatefulWidget { + const SimpleTableBlockWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => _SimpleTableBlockWidgetState(); +} + +class _SimpleTableBlockWidgetState extends State + with + SelectableMixin, + BlockComponentConfigurable, + BlockComponentTextDirectionMixin, + BlockComponentBackgroundColorMixin { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + @override + late EditorState editorState = context.read(); + + final tableKey = GlobalKey(); + + final simpleTableContext = SimpleTableContext(); + final scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + + editorState.selectionNotifier.addListener(_onSelectionChanged); + } + + @override + void dispose() { + simpleTableContext.dispose(); + editorState.selectionNotifier.removeListener(_onSelectionChanged); + scrollController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child = SimpleTableWidget( + node: node, + simpleTableContext: simpleTableContext, + ); + + if (UniversalPlatform.isDesktop) { + child = Transform.translate( + offset: Offset( + -SimpleTableConstants.tableLeftPadding, + 0, + ), + child: child, + ); + } + + child = Container( + alignment: Alignment.topLeft, + padding: padding, + child: child, + ); + + if (UniversalPlatform.isDesktop) { + child = Provider.value( + value: simpleTableContext, + child: MouseRegion( + onEnter: (event) => + simpleTableContext.isHoveringOnTableBlock.value = true, + onExit: (event) { + simpleTableContext.isHoveringOnTableBlock.value = false; + }, + child: child, + ), + ); + } + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + return child; + } + + void _onSelectionChanged() { + final selection = editorState.selectionNotifier.value; + final selectionType = editorState.selectionType; + if (selectionType == SelectionType.block && + widget.node.path.inSelection(selection)) { + simpleTableContext.isSelectingTable.value = true; + } else { + simpleTableContext.isSelectingTable.value = false; + } + } + + RenderBox get _renderBox => context.findRenderObject() as RenderBox; + + @override + Position start() => Position(path: widget.node.path); + + @override + Position end() => Position(path: widget.node.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + List getRectsInSelection( + Selection selection, { + bool shiftWithBaseOffset = false, + }) { + final parentBox = context.findRenderObject(); + final tableBox = tableKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && tableBox is RenderBox) { + return [ + (shiftWithBaseOffset + ? tableBox.localToGlobal(Offset.zero, ancestor: parentBox) + : Offset.zero) & + tableBox.size, + ]; + } + return [Offset.zero & _renderBox.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) => Selection.single( + path: widget.node.path, + startOffset: 0, + endOffset: 1, + ); + + @override + bool get shouldCursorBlink => false; + + @override + CursorStyle get cursorStyle => CursorStyle.cover; + + @override + Offset localToGlobal( + Offset offset, { + bool shiftWithBaseOffset = false, + }) => + _renderBox.localToGlobal(offset); + + @override + Rect getBlockRect({ + bool shiftWithBaseOffset = false, + }) { + return getRectsInSelection(Selection.invalid()).first; + } + + @override + Rect? getCursorRectInPosition( + Position position, { + bool shiftWithBaseOffset = false, + }) { + final size = _renderBox.size; + return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart new file mode 100644 index 0000000000000..fa35c6460fd06 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart @@ -0,0 +1,564 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class SimpleTableCellBlockKeys { + const SimpleTableCellBlockKeys._(); + + static const String type = 'simple_table_cell'; +} + +Node simpleTableCellBlockNode({ + List? children, +}) { + // Default children is a paragraph node. + children ??= [ + paragraphNode(), + ]; + + return Node( + type: SimpleTableCellBlockKeys.type, + children: children, + ); +} + +class SimpleTableCellBlockComponentBuilder extends BlockComponentBuilder { + SimpleTableCellBlockComponentBuilder({ + super.configuration, + }); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return SimpleTableCellBlockWidget( + key: node.key, + node: node, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (node) => true; +} + +class SimpleTableCellBlockWidget extends BlockComponentStatefulWidget { + const SimpleTableCellBlockWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => + SimpleTableCellBlockWidgetState(); +} + +@visibleForTesting +class SimpleTableCellBlockWidgetState extends State + with + BlockComponentConfigurable, + BlockComponentTextDirectionMixin, + BlockComponentBackgroundColorMixin { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + @override + late EditorState editorState = context.read(); + + late SimpleTableContext? simpleTableContext = + context.read(); + late final borderBuilder = SimpleTableBorderBuilder( + context: context, + simpleTableContext: simpleTableContext!, + node: node, + ); + + /// Notify if the cell is editing. + ValueNotifier isEditingCellNotifier = ValueNotifier(false); + + /// Notify if the cell is hit by the reordering offset. + /// + /// This value is only available on mobile. + ValueNotifier isReorderingHitCellNotifier = ValueNotifier(false); + + @override + void initState() { + super.initState(); + + simpleTableContext?.isSelectingTable.addListener(_onSelectingTableChanged); + simpleTableContext?.reorderingOffset + .addListener(_onReorderingOffsetChanged); + node.parentTableNode?.addListener(_onSelectingTableChanged); + editorState.selectionNotifier.addListener(_onSelectionChanged); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _onSelectionChanged(); + }); + } + + @override + void dispose() { + simpleTableContext?.isSelectingTable.removeListener( + _onSelectingTableChanged, + ); + simpleTableContext?.reorderingOffset.removeListener( + _onReorderingOffsetChanged, + ); + node.parentTableNode?.removeListener(_onSelectingTableChanged); + editorState.selectionNotifier.removeListener(_onSelectionChanged); + isEditingCellNotifier.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (simpleTableContext == null) { + return const SizedBox.shrink(); + } + + Widget child = Stack( + clipBehavior: Clip.none, + children: [ + _buildCell(), + if (editorState.editable) ...[ + if (node.columnIndex == 0) + Positioned( + // if the cell is in the first row, add padding to the top of the cell + // to make the row action button clickable. + top: node.rowIndex == 0 + ? SimpleTableConstants.tableHitTestTopPadding + : 0, + bottom: 0, + left: -SimpleTableConstants.tableLeftPadding, + child: _buildRowMoreActionButton(), + ), + if (node.rowIndex == 0) + Positioned( + left: node.columnIndex == 0 + ? SimpleTableConstants.tableHitTestLeftPadding + : 0, + right: 0, + child: _buildColumnMoreActionButton(), + ), + if (node.columnIndex == 0 && node.rowIndex == 0) + Positioned( + left: 2, + top: 2, + child: _buildTableActionMenu(), + ), + Positioned( + right: 0, + top: node.rowIndex == 0 + ? SimpleTableConstants.tableHitTestTopPadding + : 0, + bottom: 0, + child: SimpleTableColumnResizeHandle( + node: node, + ), + ), + ], + ], + ); + + if (UniversalPlatform.isDesktop) { + child = MouseRegion( + hitTestBehavior: HitTestBehavior.opaque, + onEnter: (event) => simpleTableContext!.hoveringTableCell.value = node, + child: child, + ); + } + + return child; + } + + Widget _buildCell() { + if (simpleTableContext == null) { + return const SizedBox.shrink(); + } + + return UniversalPlatform.isDesktop + ? _buildDesktopCell() + : _buildMobileCell(); + } + + Widget _buildDesktopCell() { + return Padding( + // add padding to the top of the cell if it is the first row, otherwise the + // column action button is not clickable. + // issue: https://github.com/flutter/flutter/issues/75747 + padding: EdgeInsets.only( + top: node.rowIndex == 0 + ? SimpleTableConstants.tableHitTestTopPadding + : 0, + left: node.columnIndex == 0 + ? SimpleTableConstants.tableHitTestLeftPadding + : 0, + ), + // TODO(Lucas): find a better way to handle the multiple value listenable builder + // There's flutter pub can do that. + child: ValueListenableBuilder( + valueListenable: isEditingCellNotifier, + builder: (context, isEditingCell, child) { + return ValueListenableBuilder( + valueListenable: simpleTableContext!.selectingColumn, + builder: (context, selectingColumn, _) { + return ValueListenableBuilder( + valueListenable: simpleTableContext!.selectingRow, + builder: (context, selectingRow, _) { + return ValueListenableBuilder( + valueListenable: simpleTableContext!.hoveringTableCell, + builder: (context, hoveringTableCell, _) { + return DecoratedBox( + decoration: _buildDecoration(), + child: child!, + ); + }, + ); + }, + ); + }, + ); + }, + child: Container( + padding: SimpleTableConstants.cellEdgePadding, + constraints: const BoxConstraints( + minWidth: SimpleTableConstants.minimumColumnWidth, + ), + width: node.columnWidth, + child: node.children.isEmpty + ? Column( + children: [ + // expand the cell to make the empty cell content clickable + Expanded( + child: _buildEmptyCellContent(), + ), + ], + ) + : Column( + children: [ + ...node.children.map(_buildCellContent), + _buildEmptyCellContent(height: 12), + ], + ), + ), + ), + ); + } + + Widget _buildMobileCell() { + return Padding( + padding: EdgeInsets.only( + top: node.rowIndex == 0 + ? SimpleTableConstants.tableHitTestTopPadding + : 0, + left: node.columnIndex == 0 + ? SimpleTableConstants.tableHitTestLeftPadding + : 0, + ), + child: ValueListenableBuilder( + valueListenable: isEditingCellNotifier, + builder: (context, isEditingCell, child) { + return ValueListenableBuilder( + valueListenable: simpleTableContext!.selectingColumn, + builder: (context, selectingColumn, _) { + return ValueListenableBuilder( + valueListenable: simpleTableContext!.selectingRow, + builder: (context, selectingRow, _) { + return ValueListenableBuilder( + valueListenable: isReorderingHitCellNotifier, + builder: (context, isReorderingHitCellNotifier, _) { + return DecoratedBox( + decoration: _buildDecoration(), + child: child!, + ); + }, + ); + }, + ); + }, + ); + }, + child: Container( + padding: SimpleTableConstants.cellEdgePadding, + constraints: const BoxConstraints( + minWidth: SimpleTableConstants.minimumColumnWidth, + ), + width: node.columnWidth, + child: node.children.isEmpty + ? _buildEmptyCellContent() + : Column( + children: node.children.map(_buildCellContent).toList(), + ), + ), + ), + ); + } + + Widget _buildCellContent(Node childNode) { + final alignment = _buildAlignment(); + + Widget child = IntrinsicWidth( + child: editorState.renderer.build(context, childNode), + ); + + final notSupportAlignmentBlocks = [ + DividerBlockKeys.type, + CalloutBlockKeys.type, + MathEquationBlockKeys.type, + CodeBlockKeys.type, + SubPageBlockKeys.type, + FileBlockKeys.type, + CustomImageBlockKeys.type, + ]; + if (notSupportAlignmentBlocks.contains(childNode.type)) { + child = SizedBox( + width: double.infinity, + child: child, + ); + } else { + child = Align( + alignment: alignment, + child: child, + ); + } + + return child; + } + + Widget _buildEmptyCellContent({ + double? height, + }) { + // if the table cell is empty, we should allow the user to tap on it to create a new paragraph. + final lastChild = node.children.lastOrNull; + if (lastChild != null && lastChild.delta?.isEmpty != null) { + return const SizedBox.shrink(); + } + + Widget child = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + final transaction = editorState.transaction; + final length = node.children.length; + final path = node.path.child(length); + transaction + ..insertNode( + path, + paragraphNode(), + ) + ..afterSelection = Selection.collapsed(Position(path: path)); + editorState.apply(transaction); + }, + ); + + if (height != null) { + child = SizedBox( + height: height, + child: child, + ); + } + + return child; + } + + Widget _buildRowMoreActionButton() { + final rowIndex = node.rowIndex; + + return SimpleTableMoreActionMenu( + tableCellNode: node, + index: rowIndex, + type: SimpleTableMoreActionType.row, + ); + } + + Widget _buildColumnMoreActionButton() { + final columnIndex = node.columnIndex; + + return SimpleTableMoreActionMenu( + tableCellNode: node, + index: columnIndex, + type: SimpleTableMoreActionType.column, + ); + } + + Widget _buildTableActionMenu() { + final tableNode = node.parentTableNode; + + // the table action menu is only available on mobile platform. + if (tableNode == null || UniversalPlatform.isDesktop) { + return const SizedBox.shrink(); + } + + return SimpleTableActionMenu( + tableNode: tableNode, + editorState: editorState, + ); + } + + Alignment _buildAlignment() { + Alignment alignment = Alignment.topLeft; + if (node.columnAlign != TableAlign.left) { + alignment = node.columnAlign.alignment; + } else if (node.rowAlign != TableAlign.left) { + alignment = node.rowAlign.alignment; + } + return alignment; + } + + Decoration _buildDecoration() { + final backgroundColor = _buildBackgroundColor(); + final border = borderBuilder.buildBorder( + isEditingCell: isEditingCellNotifier.value, + ); + + return BoxDecoration( + border: border, + color: backgroundColor, + ); + } + + Color? _buildBackgroundColor() { + // Priority: highlight color > column color > row color > header color > default color + final isSelectingTable = + simpleTableContext?.isSelectingTable.value ?? false; + if (isSelectingTable) { + return Theme.of(context).colorScheme.primary.withOpacity(0.1); + } + + final columnColor = node.buildColumnColor(context); + if (columnColor != null && columnColor != Colors.transparent) { + return columnColor; + } + + final rowColor = node.buildRowColor(context); + if (rowColor != null && rowColor != Colors.transparent) { + return rowColor; + } + + // Check if the cell is in the header. + // If the cell is in the header, set the background color to the default header color. + // Otherwise, set the background color to null. + if (_isInHeader()) { + return context.simpleTableDefaultHeaderColor; + } + + return Theme.of(context).colorScheme.surface; + } + + bool _isInHeader() { + final isHeaderColumnEnabled = node.isHeaderColumnEnabled; + final isHeaderRowEnabled = node.isHeaderRowEnabled; + final cellPosition = node.cellPosition; + final isFirstColumn = cellPosition.$1 == 0; + final isFirstRow = cellPosition.$2 == 0; + + return isHeaderColumnEnabled && isFirstRow || + isHeaderRowEnabled && isFirstColumn; + } + + void _onSelectingTableChanged() { + if (mounted) { + setState(() {}); + } + } + + void _onSelectionChanged() { + final selection = editorState.selection; + + // check if the selection is in the cell + if (selection != null && + node.path.isAncestorOf(selection.start.path) && + node.path.isAncestorOf(selection.end.path)) { + isEditingCellNotifier.value = true; + simpleTableContext?.isEditingCell.value = node; + } else { + isEditingCellNotifier.value = false; + } + + // if the selection is null or the selection is collapsed, set the isEditingCell to null. + if (selection == null) { + simpleTableContext?.isEditingCell.value = null; + } else if (selection.isCollapsed) { + // if the selection is collapsed, check if the selection is in the cell. + final selectedNode = + editorState.getNodesInSelection(selection).firstOrNull; + if (selectedNode != null) { + final tableNode = selectedNode.parentTableNode; + if (tableNode == null || tableNode.id != node.parentTableNode?.id) { + simpleTableContext?.isEditingCell.value = null; + } + } else { + simpleTableContext?.isEditingCell.value = null; + } + } + } + + /// Calculate if the cell is hit by the reordering offset. + /// If the cell is hit, set the isReorderingCell to true. + void _onReorderingOffsetChanged() { + final simpleTableContext = this.simpleTableContext; + if (UniversalPlatform.isDesktop || simpleTableContext == null) { + return; + } + + final isReordering = simpleTableContext.isReordering; + if (!isReordering) { + return; + } + + final isReorderingColumn = simpleTableContext.isReorderingColumn.value.$1; + final isReorderingRow = simpleTableContext.isReorderingRow.value.$1; + if (!isReorderingColumn && !isReorderingRow) { + return; + } + + final reorderingOffset = simpleTableContext.reorderingOffset.value; + + final renderBox = node.renderBox; + if (renderBox == null) { + return; + } + + final cellRect = renderBox.localToGlobal(Offset.zero) & renderBox.size; + + bool isHitCurrentCell = false; + if (isReorderingColumn) { + isHitCurrentCell = cellRect.left < reorderingOffset.dx && + cellRect.right > reorderingOffset.dx; + } else if (isReorderingRow) { + isHitCurrentCell = cellRect.top < reorderingOffset.dy && + cellRect.bottom > reorderingOffset.dy; + } + + isReorderingHitCellNotifier.value = isHitCurrentCell; + if (isHitCurrentCell) { + if (isReorderingColumn) { + if (simpleTableContext.isReorderingHitIndex.value != node.columnIndex) { + HapticFeedback.lightImpact(); + + simpleTableContext.isReorderingHitIndex.value = node.columnIndex; + } + } else if (isReorderingRow) { + if (simpleTableContext.isReorderingHitIndex.value != node.rowIndex) { + HapticFeedback.lightImpact(); + + simpleTableContext.isReorderingHitIndex.value = node.rowIndex; + } + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart new file mode 100644 index 0000000000000..0f531b1c91c1d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart @@ -0,0 +1,311 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +const _enableTableDebugLog = false; + +class SimpleTableContext { + SimpleTableContext() { + if (_enableTableDebugLog) { + isHoveringOnColumnsAndRows.addListener( + _onHoveringOnColumnsAndRowsChanged, + ); + isHoveringOnTableArea.addListener( + _onHoveringOnTableAreaChanged, + ); + hoveringTableCell.addListener(_onHoveringTableNodeChanged); + selectingColumn.addListener(_onSelectingColumnChanged); + selectingRow.addListener(_onSelectingRowChanged); + isSelectingTable.addListener(_onSelectingTableChanged); + isHoveringOnTableBlock.addListener(_onHoveringOnTableBlockChanged); + isReorderingColumn.addListener(_onDraggingColumnChanged); + isReorderingRow.addListener(_onDraggingRowChanged); + } + } + + /// the area only contains the columns and rows, + /// the add row button, add column button, and add column and row button are not part of the table area + final ValueNotifier isHoveringOnColumnsAndRows = ValueNotifier(false); + + /// the table area contains the columns and rows, + /// the add row button, add column button, and add column and row button are not part of the table area, + /// not including the selection area and padding + final ValueNotifier isHoveringOnTableArea = ValueNotifier(false); + + /// the table block area contains the table area and the add row button, add column button, and add column and row button + /// also, the table block area contains the selection area and padding + final ValueNotifier isHoveringOnTableBlock = ValueNotifier(false); + + /// the hovering table cell is the cell that the mouse is hovering on + final ValueNotifier hoveringTableCell = ValueNotifier(null); + + /// the hovering on resize handle is the resize handle that the mouse is hovering on + final ValueNotifier hoveringOnResizeHandle = ValueNotifier(null); + + /// the selecting column is the column that the user is selecting + final ValueNotifier selectingColumn = ValueNotifier(null); + + /// the selecting row is the row that the user is selecting + final ValueNotifier selectingRow = ValueNotifier(null); + + /// the is selecting table is the table that the user is selecting + final ValueNotifier isSelectingTable = ValueNotifier(false); + + /// isReorderingColumn is a tuple of (isReordering, columnIndex) + final ValueNotifier<(bool, int)> isReorderingColumn = + ValueNotifier((false, -1)); + + /// isReorderingRow is a tuple of (isReordering, rowIndex) + final ValueNotifier<(bool, int)> isReorderingRow = ValueNotifier((false, -1)); + + /// reorderingOffset is the offset of the reordering + // + /// This value is only available when isReordering is true + final ValueNotifier reorderingOffset = ValueNotifier(Offset.zero); + + /// isDraggingRow to expand the rows of the table + bool isDraggingRow = false; + + /// isDraggingColumn to expand the columns of the table + bool isDraggingColumn = false; + + bool get isReordering => + isReorderingColumn.value.$1 || isReorderingRow.value.$1; + + /// isEditingCell is the cell that the user is editing + /// + /// This value is available on mobile only + final ValueNotifier isEditingCell = ValueNotifier(null); + + /// isReorderingHitCell is the cell that the user is reordering + /// + /// This value is available on mobile only + final ValueNotifier isReorderingHitIndex = ValueNotifier(null); + + void _onHoveringOnColumnsAndRowsChanged() { + if (!_enableTableDebugLog) { + return; + } + + Log.debug('isHoveringOnTable: ${isHoveringOnColumnsAndRows.value}'); + } + + void _onHoveringTableNodeChanged() { + if (!_enableTableDebugLog) { + return; + } + + final node = hoveringTableCell.value; + if (node == null) { + return; + } + + Log.debug('hoveringTableNode: $node, ${node.cellPosition}'); + } + + void _onSelectingColumnChanged() { + if (!_enableTableDebugLog) { + return; + } + + Log.debug('selectingColumn: ${selectingColumn.value}'); + } + + void _onSelectingRowChanged() { + if (!_enableTableDebugLog) { + return; + } + + Log.debug('selectingRow: ${selectingRow.value}'); + } + + void _onSelectingTableChanged() { + if (!_enableTableDebugLog) { + return; + } + + Log.debug('isSelectingTable: ${isSelectingTable.value}'); + } + + void _onHoveringOnTableBlockChanged() { + if (!_enableTableDebugLog) { + return; + } + + Log.debug('isHoveringOnTableBlock: ${isHoveringOnTableBlock.value}'); + } + + void _onHoveringOnTableAreaChanged() { + if (!_enableTableDebugLog) { + return; + } + + Log.debug('isHoveringOnTableArea: ${isHoveringOnTableArea.value}'); + } + + void _onDraggingColumnChanged() { + if (!_enableTableDebugLog) { + return; + } + + Log.debug('isDraggingColumn: ${isReorderingColumn.value}'); + } + + void _onDraggingRowChanged() { + if (!_enableTableDebugLog) { + return; + } + + Log.debug('isDraggingRow: ${isReorderingRow.value}'); + } + + void dispose() { + isHoveringOnColumnsAndRows.dispose(); + isHoveringOnTableBlock.dispose(); + isHoveringOnTableArea.dispose(); + hoveringTableCell.dispose(); + hoveringOnResizeHandle.dispose(); + selectingColumn.dispose(); + selectingRow.dispose(); + isSelectingTable.dispose(); + isReorderingColumn.dispose(); + isReorderingRow.dispose(); + reorderingOffset.dispose(); + isEditingCell.dispose(); + isReorderingHitIndex.dispose(); + } +} + +class SimpleTableConstants { + /// Table + static const defaultColumnWidth = 120.0; + static const minimumColumnWidth = 36.0; + + static const defaultRowHeight = 36.0; + + static double get tableHitTestTopPadding => + UniversalPlatform.isDesktop ? 8.0 : 24.0; + static double get tableHitTestLeftPadding => + UniversalPlatform.isDesktop ? 0.0 : 24.0; + static double get tableLeftPadding => UniversalPlatform.isDesktop ? 8.0 : 0.0; + + static const tableBottomPadding = + addRowButtonHeight + 3 * addRowButtonPadding; + static const tableRightPadding = + addColumnButtonWidth + 2 * SimpleTableConstants.addColumnButtonPadding; + + static EdgeInsets get tablePadding => EdgeInsets.only( + // don't add padding to the top of the table, the first row will have padding + // to make the column action button clickable. + bottom: tableBottomPadding, + left: tableLeftPadding, + right: tableRightPadding, + ); + + static double get tablePageOffset => UniversalPlatform.isMobile + ? EditorStyleCustomizer.optionMenuWidth + + EditorStyleCustomizer.nodeHorizontalPadding * 2 + : EditorStyleCustomizer.optionMenuWidth + 12; + + // Add row button + static const addRowButtonHeight = 16.0; + static const addRowButtonPadding = 4.0; + static const addRowButtonRadius = 4.0; + static const addRowButtonRightPadding = + addColumnButtonWidth + addColumnButtonPadding * 2; + + // Add column button + static const addColumnButtonWidth = 16.0; + static const addColumnButtonPadding = 2.0; + static const addColumnButtonRadius = 4.0; + static const addColumnButtonBottomPadding = + addRowButtonHeight + 3 * addRowButtonPadding; + + // Add column and row button + static const addColumnAndRowButtonWidth = addColumnButtonWidth; + static const addColumnAndRowButtonHeight = addRowButtonHeight; + static const addColumnAndRowButtonCornerRadius = addColumnButtonWidth / 2.0; + static const addColumnAndRowButtonBottomPadding = 2.5 * addRowButtonPadding; + + // Table cell + static EdgeInsets get cellEdgePadding => UniversalPlatform.isDesktop + ? const EdgeInsets.symmetric( + horizontal: 9.0, + vertical: 2.0, + ) + : const EdgeInsets.only( + left: 8.0, + right: 8.0, + bottom: 6.0, + ); + static const cellBorderWidth = 1.0; + static const resizeHandleWidth = 3.0; + + static const borderType = SimpleTableBorderRenderType.cell; + + // Table more action + static const moreActionHeight = 34.0; + static const moreActionPadding = EdgeInsets.symmetric(vertical: 2.0); + static const moreActionHorizontalMargin = + EdgeInsets.symmetric(horizontal: 6.0); + + /// Only displaying the add row / add column / add column and row button + /// when hovering on the last row / last column / last cell. + static const enableHoveringLogicV2 = true; + + /// Enable the drag to expand the table + static const enableDragToExpandTable = false; + + /// Action sheet hit test area on Mobile + static const rowActionSheetHitTestAreaWidth = 24.0; + static const columnActionSheetHitTestAreaHeight = 24.0; + + static const actionSheetQuickActionSectionHeight = 44.0; + static const actionSheetInsertSectionHeight = 52.0; + static const actionSheetContentSectionHeight = 44.0; + static const actionSheetNormalActionSectionHeight = 48.0; + static const actionSheetButtonRadius = 12.0; +} + +enum SimpleTableBorderRenderType { + cell, + table, +} + +extension SimpleTableColors on BuildContext { + Color get simpleTableBorderColor => Theme.of(this).isLightMode + ? const Color(0xFFE4E5E5) + : const Color(0xFF3A3F49); + + Color get simpleTableDividerColor => Theme.of(this).isLightMode + ? const Color(0x141F2329) + : const Color(0xFF23262B).withOpacity(0.5); + + Color get simpleTableMoreActionBackgroundColor => Theme.of(this).isLightMode + ? const Color(0xFFF2F3F5) + : const Color(0xFF2D3036); + + Color get simpleTableMoreActionBorderColor => Theme.of(this).isLightMode + ? const Color(0xFFCFD3D9) + : const Color(0xFF44484E); + + Color get simpleTableMoreActionHoverColor => Theme.of(this).isLightMode + ? const Color(0xFF00C8FF) + : const Color(0xFF00C8FF); + + Color get simpleTableDefaultHeaderColor => Theme.of(this).isLightMode + ? const Color(0xFFF2F2F2) + : const Color(0x08FFFFFF); + + Color get simpleTableActionButtonBackgroundColor => Theme.of(this).isLightMode + ? const Color(0xFFFFFFFF) + : const Color(0xFF2D3036); + + Color get simpleTableInsertActionBackgroundColor => Theme.of(this).isLightMode + ? const Color(0xFFF2F2F7) + : const Color(0xFF2D3036); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_more_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_more_action.dart new file mode 100644 index 0000000000000..8b4e2f5153a67 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_more_action.dart @@ -0,0 +1,488 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +enum SimpleTableMoreActionType { + column, + row; + + List buildDesktopActions({ + required int index, + required int columnLength, + required int rowLength, + }) { + // there're two special cases: + // 1. if the table only contains one row or one column, remove the delete action + // 2. if the index is 0, add the enable header action + switch (this) { + case SimpleTableMoreActionType.row: + return [ + SimpleTableMoreAction.insertAbove, + SimpleTableMoreAction.insertBelow, + SimpleTableMoreAction.divider, + if (index == 0) SimpleTableMoreAction.enableHeaderRow, + SimpleTableMoreAction.backgroundColor, + SimpleTableMoreAction.align, + SimpleTableMoreAction.divider, + SimpleTableMoreAction.setToPageWidth, + SimpleTableMoreAction.distributeColumnsEvenly, + SimpleTableMoreAction.divider, + SimpleTableMoreAction.duplicate, + SimpleTableMoreAction.clearContents, + if (rowLength > 1) SimpleTableMoreAction.delete, + ]; + case SimpleTableMoreActionType.column: + return [ + SimpleTableMoreAction.insertLeft, + SimpleTableMoreAction.insertRight, + SimpleTableMoreAction.divider, + if (index == 0) SimpleTableMoreAction.enableHeaderColumn, + SimpleTableMoreAction.backgroundColor, + SimpleTableMoreAction.align, + SimpleTableMoreAction.divider, + SimpleTableMoreAction.setToPageWidth, + SimpleTableMoreAction.distributeColumnsEvenly, + SimpleTableMoreAction.divider, + SimpleTableMoreAction.duplicate, + SimpleTableMoreAction.clearContents, + if (columnLength > 1) SimpleTableMoreAction.delete, + ]; + } + } + + List> buildMobileActions({ + required int index, + required int columnLength, + required int rowLength, + }) { + // the actions on mobile are not the same as the desktop ones + // the mobile actions are grouped into different sections + switch (this) { + case SimpleTableMoreActionType.row: + return [ + if (index == 0) [SimpleTableMoreAction.enableHeaderRow], + [ + SimpleTableMoreAction.setToPageWidth, + SimpleTableMoreAction.distributeColumnsEvenly, + ], + [ + SimpleTableMoreAction.duplicateRow, + SimpleTableMoreAction.clearContents, + ], + ]; + case SimpleTableMoreActionType.column: + return [ + if (index == 0) [SimpleTableMoreAction.enableHeaderColumn], + [ + SimpleTableMoreAction.setToPageWidth, + SimpleTableMoreAction.distributeColumnsEvenly, + ], + [ + SimpleTableMoreAction.duplicateColumn, + SimpleTableMoreAction.clearContents, + ], + ]; + } + } + + FlowySvgData get reorderIconSvg { + switch (this) { + case SimpleTableMoreActionType.column: + return FlowySvgs.table_reorder_column_s; + case SimpleTableMoreActionType.row: + return FlowySvgs.table_reorder_row_s; + } + } + + @override + String toString() { + return switch (this) { + SimpleTableMoreActionType.column => 'column', + SimpleTableMoreActionType.row => 'row', + }; + } +} + +enum SimpleTableMoreAction { + insertLeft, + insertRight, + insertAbove, + insertBelow, + duplicate, + clearContents, + delete, + align, + backgroundColor, + enableHeaderColumn, + enableHeaderRow, + setToPageWidth, + distributeColumnsEvenly, + divider, + + // these actions are only available on mobile + duplicateRow, + duplicateColumn, + cut, + copy, + paste, + bold, + textColor, + textBackgroundColor, + duplicateTable, + copyLinkToBlock; + + String get name { + return switch (this) { + SimpleTableMoreAction.align => + LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), + SimpleTableMoreAction.backgroundColor => + LocaleKeys.document_plugins_simpleTable_moreActions_color.tr(), + SimpleTableMoreAction.enableHeaderColumn => + LocaleKeys.document_plugins_simpleTable_moreActions_headerColumn.tr(), + SimpleTableMoreAction.enableHeaderRow => + LocaleKeys.document_plugins_simpleTable_moreActions_headerRow.tr(), + SimpleTableMoreAction.insertLeft => + LocaleKeys.document_plugins_simpleTable_moreActions_insertLeft.tr(), + SimpleTableMoreAction.insertRight => + LocaleKeys.document_plugins_simpleTable_moreActions_insertRight.tr(), + SimpleTableMoreAction.insertBelow => + LocaleKeys.document_plugins_simpleTable_moreActions_insertBelow.tr(), + SimpleTableMoreAction.insertAbove => + LocaleKeys.document_plugins_simpleTable_moreActions_insertAbove.tr(), + SimpleTableMoreAction.clearContents => + LocaleKeys.document_plugins_simpleTable_moreActions_clearContents.tr(), + SimpleTableMoreAction.delete => + LocaleKeys.document_plugins_simpleTable_moreActions_delete.tr(), + SimpleTableMoreAction.duplicate => + LocaleKeys.document_plugins_simpleTable_moreActions_duplicate.tr(), + SimpleTableMoreAction.setToPageWidth => + LocaleKeys.document_plugins_simpleTable_moreActions_setToPageWidth.tr(), + SimpleTableMoreAction.distributeColumnsEvenly => LocaleKeys + .document_plugins_simpleTable_moreActions_distributeColumnsWidth + .tr(), + SimpleTableMoreAction.duplicateRow => + LocaleKeys.document_plugins_simpleTable_moreActions_duplicateRow.tr(), + SimpleTableMoreAction.duplicateColumn => LocaleKeys + .document_plugins_simpleTable_moreActions_duplicateColumn + .tr(), + SimpleTableMoreAction.duplicateTable => + LocaleKeys.document_plugins_simpleTable_moreActions_duplicateTable.tr(), + SimpleTableMoreAction.copyLinkToBlock => + LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr(), + SimpleTableMoreAction.bold || + SimpleTableMoreAction.textColor || + SimpleTableMoreAction.textBackgroundColor || + SimpleTableMoreAction.cut || + SimpleTableMoreAction.copy || + SimpleTableMoreAction.paste => + throw UnimplementedError(), + SimpleTableMoreAction.divider => throw UnimplementedError(), + }; + } + + FlowySvgData get leftIconSvg { + return switch (this) { + SimpleTableMoreAction.insertLeft => FlowySvgs.table_insert_left_s, + SimpleTableMoreAction.insertRight => FlowySvgs.table_insert_right_s, + SimpleTableMoreAction.insertAbove => FlowySvgs.table_insert_above_s, + SimpleTableMoreAction.insertBelow => FlowySvgs.table_insert_below_s, + SimpleTableMoreAction.duplicate => FlowySvgs.duplicate_s, + SimpleTableMoreAction.clearContents => FlowySvgs.table_clear_content_s, + SimpleTableMoreAction.delete => FlowySvgs.trash_s, + SimpleTableMoreAction.setToPageWidth => + FlowySvgs.table_set_to_page_width_s, + SimpleTableMoreAction.distributeColumnsEvenly => + FlowySvgs.table_distribute_columns_evenly_s, + SimpleTableMoreAction.enableHeaderColumn => + FlowySvgs.table_header_column_s, + SimpleTableMoreAction.enableHeaderRow => FlowySvgs.table_header_row_s, + SimpleTableMoreAction.duplicateRow => FlowySvgs.m_table_duplicate_s, + SimpleTableMoreAction.duplicateColumn => FlowySvgs.m_table_duplicate_s, + SimpleTableMoreAction.cut => FlowySvgs.m_table_quick_action_cut_s, + SimpleTableMoreAction.copy => FlowySvgs.m_table_quick_action_copy_s, + SimpleTableMoreAction.paste => FlowySvgs.m_table_quick_action_paste_s, + SimpleTableMoreAction.bold => FlowySvgs.m_aa_bold_s, + SimpleTableMoreAction.duplicateTable => FlowySvgs.m_table_duplicate_s, + SimpleTableMoreAction.copyLinkToBlock => FlowySvgs.m_copy_link_s, + SimpleTableMoreAction.align => FlowySvgs.m_aa_align_left_s, + SimpleTableMoreAction.textColor => + throw UnsupportedError('text color icon is not supported'), + SimpleTableMoreAction.textBackgroundColor => + throw UnsupportedError('text background color icon is not supported'), + SimpleTableMoreAction.divider => + throw UnsupportedError('divider icon is not supported'), + SimpleTableMoreAction.backgroundColor => + throw UnsupportedError('background color icon is not supported'), + }; + } +} + +class SimpleTableMoreActionMenu extends StatefulWidget { + const SimpleTableMoreActionMenu({ + super.key, + required this.index, + required this.type, + required this.tableCellNode, + }); + + final int index; + final SimpleTableMoreActionType type; + final Node tableCellNode; + + @override + State createState() => + _SimpleTableMoreActionMenuState(); +} + +class _SimpleTableMoreActionMenuState extends State { + ValueNotifier isShowingMenu = ValueNotifier(false); + ValueNotifier isEditingCellNotifier = ValueNotifier(false); + + late final editorState = context.read(); + late final simpleTableContext = context.read(); + + @override + void initState() { + super.initState(); + + editorState.selectionNotifier.addListener(_onSelectionChanged); + } + + @override + void dispose() { + isShowingMenu.dispose(); + isEditingCellNotifier.dispose(); + + editorState.selectionNotifier.removeListener(_onSelectionChanged); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: widget.type == SimpleTableMoreActionType.row + ? UniversalPlatform.isDesktop + ? Alignment.centerLeft + : Alignment.centerRight + : Alignment.topCenter, + child: UniversalPlatform.isDesktop + ? _buildDesktopMenu() + : _buildMobileMenu(), + ); + } + + // On desktop, the menu is a popup and only shows when hovering. + Widget _buildDesktopMenu() { + return ValueListenableBuilder( + valueListenable: isShowingMenu, + builder: (context, isShowingMenu, child) { + return ValueListenableBuilder( + valueListenable: simpleTableContext.hoveringTableCell, + builder: (context, hoveringTableNode, child) { + final reorderingIndex = switch (widget.type) { + SimpleTableMoreActionType.column => + simpleTableContext.isReorderingColumn.value.$2, + SimpleTableMoreActionType.row => + simpleTableContext.isReorderingRow.value.$2, + }; + final isReordering = simpleTableContext.isReordering; + if (isReordering) { + // when reordering, hide the menu for another column or row that is not the current dragging one. + if (reorderingIndex != widget.index) { + return const SizedBox.shrink(); + } else { + return child!; + } + } + + final hoveringIndex = + widget.type == SimpleTableMoreActionType.column + ? hoveringTableNode?.columnIndex + : hoveringTableNode?.rowIndex; + + if (hoveringIndex != widget.index && !isShowingMenu) { + return const SizedBox.shrink(); + } + + return child!; + }, + child: SimpleTableMoreActionPopup( + index: widget.index, + isShowingMenu: this.isShowingMenu, + type: widget.type, + ), + ); + }, + ); + } + + // On mobile, the menu is a action sheet and always shows. + Widget _buildMobileMenu() { + return ValueListenableBuilder( + valueListenable: isShowingMenu, + builder: (context, isShowingMenu, child) { + return ValueListenableBuilder( + valueListenable: simpleTableContext.isEditingCell, + builder: (context, isEditingCell, child) { + if (isShowingMenu) { + return child!; + } + + if (isEditingCell == null) { + return const SizedBox.shrink(); + } + + final columnIndex = isEditingCell.columnIndex; + final rowIndex = isEditingCell.rowIndex; + + switch (widget.type) { + case SimpleTableMoreActionType.column: + if (columnIndex != widget.index) { + return const SizedBox.shrink(); + } + case SimpleTableMoreActionType.row: + if (rowIndex != widget.index) { + return const SizedBox.shrink(); + } + } + + return child!; + }, + child: SimpleTableMobileDraggableReorderButton( + index: widget.index, + type: widget.type, + cellNode: widget.tableCellNode, + isShowingMenu: this.isShowingMenu, + editorState: editorState, + simpleTableContext: simpleTableContext, + ), + ); + }, + ); + } + + void _onSelectionChanged() { + final selection = editorState.selection; + + // check if the selection is in the cell + if (selection != null && + widget.tableCellNode.path.isAncestorOf(selection.start.path) && + widget.tableCellNode.path.isAncestorOf(selection.end.path)) { + isEditingCellNotifier.value = true; + } else { + isEditingCellNotifier.value = false; + } + } +} + +/// This widget is only used on mobile +class SimpleTableActionMenu extends StatelessWidget { + const SimpleTableActionMenu({ + super.key, + required this.tableNode, + required this.editorState, + }); + + final Node tableNode; + final EditorState editorState; + + @override + Widget build(BuildContext context) { + final simpleTableContext = context.read(); + return ValueListenableBuilder( + valueListenable: simpleTableContext.isEditingCell, + builder: (context, isEditingCell, child) { + if (isEditingCell == null) { + return const SizedBox.shrink(); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + editorState.service.keyboardService?.closeKeyboard(); + // delay the bottom sheet show to make sure the keyboard is closed + Future.delayed(Durations.short3, () { + _showTableActionBottomSheet(context); + }); + }, + child: Container( + width: 20, + height: 20, + alignment: Alignment.center, + child: const FlowySvg( + FlowySvgs.drag_element_s, + size: Size.square(18.0), + ), + ), + ); + }, + ); + } + + Future _showTableActionBottomSheet(BuildContext context) async { + // check if the table node is a simple table + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + Log.error('The table node is not a simple table'); + return; + } + + // increase the keep editor focus notifier to prevent the editor from losing focus + keepEditorFocusNotifier.increase(); + + unawaited( + editorState.updateSelectionWithReason( + Selection.collapsed( + Position( + path: tableNode.path, + ), + ), + customSelectionType: SelectionType.block, + extraInfo: { + selectionExtraInfoDisableMobileToolbarKey: true, + selectionExtraInfoDoNotAttachTextService: true, + }, + ), + ); + + if (!context.mounted) { + return; + } + + final simpleTableContext = context.read(); + + simpleTableContext.isSelectingTable.value = true; + + // show the bottom sheet + await showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + builder: (context) => Provider.value( + value: simpleTableContext, + child: SimpleTableBottomSheet( + tableNode: tableNode, + editorState: editorState, + ), + ), + ); + + simpleTableContext.isSelectingTable.value = false; + keepEditorFocusNotifier.decrease(); + + // remove the extra info + editorState.selectionType = null; + editorState.selectionExtraInfo = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_content_operation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_content_operation.dart new file mode 100644 index 0000000000000..9691a57a0d5dc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_content_operation.dart @@ -0,0 +1,419 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension TableContentOperation on EditorState { + /// Clear the content of the column at the given index. + /// + /// Before: + /// Given column index: 0 + /// Row 1: | 0 | 1 | ← The content of these cells will be cleared + /// Row 2: | 2 | 3 | + /// + /// Call this function with column index 0 will clear the first column of the table. + /// + /// After: + /// Row 1: | | | + /// Row 2: | 2 | 3 | + Future clearContentAtRowIndex({ + required Node tableNode, + required int rowIndex, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return; + } + + if (rowIndex < 0 || rowIndex >= tableNode.rowLength) { + Log.warn('clear content in row: index out of range: $rowIndex'); + return; + } + + Log.info('clear content in row: $rowIndex in table ${tableNode.id}'); + + final transaction = this.transaction; + + final row = tableNode.children[rowIndex]; + for (var i = 0; i < row.children.length; i++) { + final cell = row.children[i]; + transaction.insertNode(cell.path.next, simpleTableCellBlockNode()); + transaction.deleteNode(cell); + } + await apply(transaction); + } + + /// Clear the content of the row at the given index. + /// + /// Before: + /// Given row index: 1 + /// ↓ The content of these cells will be cleared + /// Row 1: | 0 | 1 | + /// Row 2: | 2 | 3 | + /// + /// Call this function with row index 1 will clear the second row of the table. + /// + /// After: + /// Row 1: | 0 | | + /// Row 2: | 2 | | + Future clearContentAtColumnIndex({ + required Node tableNode, + required int columnIndex, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return; + } + + if (columnIndex < 0 || columnIndex >= tableNode.columnLength) { + Log.warn('clear content in column: index out of range: $columnIndex'); + return; + } + + Log.info('clear content in column: $columnIndex in table ${tableNode.id}'); + + final transaction = this.transaction; + for (var i = 0; i < tableNode.rowLength; i++) { + final row = tableNode.children[i]; + final cell = columnIndex >= row.children.length + ? row.children.last + : row.children[columnIndex]; + transaction.insertNode(cell.path.next, simpleTableCellBlockNode()); + transaction.deleteNode(cell); + } + await apply(transaction); + } + + /// Clear the content of the table. + Future clearAllContent({ + required Node tableNode, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return; + } + + for (var i = 0; i < tableNode.rowLength; i++) { + await clearContentAtRowIndex(tableNode: tableNode, rowIndex: i); + } + } + + /// Copy the selected column to the clipboard. + /// + /// If the [clearContent] is true, the content of the column will be cleared after + /// copying. + Future copyColumn({ + required Node tableNode, + required int columnIndex, + bool clearContent = false, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return null; + } + + if (columnIndex < 0 || columnIndex >= tableNode.columnLength) { + Log.warn('copy column: index out of range: $columnIndex'); + return null; + } + + // the plain text content of the column + final List content = []; + + // the cells of the column + final List cells = []; + + for (var i = 0; i < tableNode.rowLength; i++) { + final row = tableNode.children[i]; + final cell = columnIndex >= row.children.length + ? row.children.last + : row.children[columnIndex]; + final startNode = cell.getFirstChildIndex(); + final endNode = cell.getLastChildIndex(); + if (startNode == null || endNode == null) { + continue; + } + final plainText = getTextInSelection( + Selection( + start: Position(path: startNode.path), + end: Position( + path: endNode.path, + offset: endNode.delta?.length ?? 0, + ), + ), + ); + content.add(plainText.join('\n')); + cells.add(cell.deepCopy()); + } + + final plainText = content.join('\n'); + final document = Document.blank()..insert([0], cells); + + if (clearContent) { + await clearContentAtColumnIndex( + tableNode: tableNode, + columnIndex: columnIndex, + ); + } + + return ClipboardServiceData( + plainText: plainText, + tableJson: jsonEncode(document.toJson()), + ); + } + + /// Copy the selected row to the clipboard. + /// + /// If the [clearContent] is true, the content of the row will be cleared after + /// copying. + Future copyRow({ + required Node tableNode, + required int rowIndex, + bool clearContent = false, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return null; + } + + if (rowIndex < 0 || rowIndex >= tableNode.rowLength) { + Log.warn('copy row: index out of range: $rowIndex'); + return null; + } + + // the plain text content of the row + final List content = []; + + // the cells of the row + final List cells = []; + + final row = tableNode.children[rowIndex]; + for (var i = 0; i < row.children.length; i++) { + final cell = row.children[i]; + final startNode = cell.getFirstChildIndex(); + final endNode = cell.getLastChildIndex(); + if (startNode == null || endNode == null) { + continue; + } + final plainText = getTextInSelection( + Selection( + start: Position(path: startNode.path), + end: Position( + path: endNode.path, + offset: endNode.delta?.length ?? 0, + ), + ), + ); + content.add(plainText.join('\n')); + cells.add(cell.deepCopy()); + } + + final plainText = content.join('\n'); + final document = Document.blank()..insert([0], cells); + + if (clearContent) { + await clearContentAtRowIndex( + tableNode: tableNode, + rowIndex: rowIndex, + ); + } + + return ClipboardServiceData( + plainText: plainText, + tableJson: jsonEncode(document.toJson()), + ); + } + + /// Copy the selected table to the clipboard. + Future copyTable({ + required Node tableNode, + bool clearContent = false, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return null; + } + + // the plain text content of the table + final List content = []; + + // the cells of the table + final List cells = []; + + for (var i = 0; i < tableNode.rowLength; i++) { + final row = tableNode.children[i]; + for (var j = 0; j < row.children.length; j++) { + final cell = row.children[j]; + final startNode = cell.getFirstChildIndex(); + final endNode = cell.getLastChildIndex(); + if (startNode == null || endNode == null) { + continue; + } + final plainText = getTextInSelection( + Selection( + start: Position(path: startNode.path), + end: Position( + path: endNode.path, + offset: endNode.delta?.length ?? 0, + ), + ), + ); + content.add(plainText.join('\n')); + cells.add(cell.deepCopy()); + } + } + + final plainText = content.join('\n'); + final document = Document.blank()..insert([0], cells); + + if (clearContent) { + await clearAllContent(tableNode: tableNode); + } + + return ClipboardServiceData( + plainText: plainText, + tableJson: jsonEncode(document.toJson()), + ); + } + + /// Paste the clipboard content to the table column. + Future pasteColumn({ + required Node tableNode, + required int columnIndex, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return; + } + + if (columnIndex < 0 || columnIndex >= tableNode.columnLength) { + Log.warn('paste column: index out of range: $columnIndex'); + return; + } + + final clipboardData = await getIt().getData(); + final tableJson = clipboardData.tableJson; + if (tableJson == null) { + return; + } + + try { + final document = Document.fromJson(jsonDecode(tableJson)); + final cells = document.root.children; + final transaction = this.transaction; + for (var i = 0; i < tableNode.rowLength; i++) { + final nodes = i < cells.length ? cells[i].children : []; + final row = tableNode.children[i]; + final cell = columnIndex >= row.children.length + ? row.children.last + : row.children[columnIndex]; + if (nodes.isNotEmpty) { + transaction.insertNodes( + cell.path.child(0), + nodes, + ); + transaction.deleteNodes(cell.children); + } + } + await apply(transaction); + } catch (e) { + Log.error('paste column: failed to paste: $e'); + } + } + + /// Paste the clipboard content to the table row. + Future pasteRow({ + required Node tableNode, + required int rowIndex, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return; + } + + if (rowIndex < 0 || rowIndex >= tableNode.rowLength) { + Log.warn('paste row: index out of range: $rowIndex'); + return; + } + + final clipboardData = await getIt().getData(); + final tableJson = clipboardData.tableJson; + if (tableJson == null) { + return; + } + + try { + final document = Document.fromJson(jsonDecode(tableJson)); + final cells = document.root.children; + final transaction = this.transaction; + final row = tableNode.children[rowIndex]; + for (var i = 0; i < row.children.length; i++) { + final nodes = i < cells.length ? cells[i].children : []; + final cell = row.children[i]; + if (nodes.isNotEmpty) { + transaction.insertNodes( + cell.path.child(0), + nodes, + ); + transaction.deleteNodes(cell.children); + } + } + await apply(transaction); + } catch (e) { + Log.error('paste row: failed to paste: $e'); + } + } + + /// Paste the clipboard content to the table. + Future pasteTable({ + required Node tableNode, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return; + } + + final clipboardData = await getIt().getData(); + final tableJson = clipboardData.tableJson; + if (tableJson == null) { + return; + } + + try { + final document = Document.fromJson(jsonDecode(tableJson)); + final cells = document.root.children; + final transaction = this.transaction; + for (var i = 0; i < tableNode.rowLength; i++) { + final row = tableNode.children[i]; + for (var j = 0; j < row.children.length; j++) { + final cell = row.children[j]; + final node = i + j < cells.length ? cells[i + j] : null; + if (node != null && node.children.isNotEmpty) { + transaction.insertNodes( + cell.path.child(0), + node.children, + ); + transaction.deleteNodes(cell.children); + } + } + } + await apply(transaction); + } catch (e) { + Log.error('paste row: failed to paste: $e'); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_delete_operation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_delete_operation.dart new file mode 100644 index 0000000000000..d5dfc0247420f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_delete_operation.dart @@ -0,0 +1,116 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension TableDeletionOperations on EditorState { + /// Delete a row at the given index. + /// + /// Before: + /// Given index: 0 + /// Row 1: | | | | ← This row will be deleted + /// Row 2: | | | | + /// + /// Call this function with index 0 will delete the first row of the table. + /// + /// After: + /// Row 1: | | | | + Future deleteRowInTable( + Node tableNode, + int rowIndex, { + bool inMemoryUpdate = false, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return; + } + + final rowLength = tableNode.rowLength; + if (rowIndex < 0 || rowIndex >= rowLength) { + Log.warn( + 'delete row: index out of range: $rowIndex, row length: $rowLength', + ); + return; + } + + Log.info('delete row: $rowIndex in table ${tableNode.id}'); + + final attributes = tableNode.mapTableAttributes( + tableNode, + type: TableMapOperationType.deleteRow, + index: rowIndex, + ); + + final row = tableNode.children[rowIndex]; + final transaction = this.transaction; + transaction.deleteNode(row); + if (attributes != null) { + transaction.updateNode(tableNode, attributes); + } + await apply( + transaction, + options: ApplyOptions( + inMemoryUpdate: inMemoryUpdate, + ), + ); + } + + /// Delete a column at the given index. + /// + /// Before: + /// Given index: 2 + /// ↓ This column will be deleted + /// Row 1: | 0 | 1 | 2 | + /// Row 2: | | | | + /// + /// Call this function with index 2 will delete the third column of the table. + /// + /// After: + /// Row 1: | 0 | 1 | + /// Row 2: | | | + Future deleteColumnInTable( + Node tableNode, + int columnIndex, { + bool inMemoryUpdate = false, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return; + } + + final rowLength = tableNode.rowLength; + final columnLength = tableNode.columnLength; + if (columnIndex < 0 || columnIndex >= columnLength) { + Log.warn( + 'delete column: index out of range: $columnIndex, column length: $columnLength', + ); + return; + } + + Log.info('delete column: $columnIndex in table ${tableNode.id}'); + + final attributes = tableNode.mapTableAttributes( + tableNode, + type: TableMapOperationType.deleteColumn, + index: columnIndex, + ); + + final transaction = this.transaction; + for (var i = 0; i < rowLength; i++) { + final row = tableNode.children[i]; + transaction.deleteNode(row.children[columnIndex]); + } + if (attributes != null) { + transaction.updateNode(tableNode, attributes); + } + await apply( + transaction, + options: ApplyOptions( + inMemoryUpdate: inMemoryUpdate, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_duplicate_operation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_duplicate_operation.dart new file mode 100644 index 0000000000000..a2fdac4ca271a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_duplicate_operation.dart @@ -0,0 +1,121 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension TableDuplicationOperations on EditorState { + /// Duplicate a row at the given index. + /// + /// Before: + /// | 0 | 1 | 2 | + /// | 3 | 4 | 5 | ← This row will be duplicated + /// + /// Call this function with index 1 will duplicate the second row of the table. + /// + /// After: + /// | 0 | 1 | 2 | + /// | 3 | 4 | 5 | + /// | 3 | 4 | 5 | ← New row + Future duplicateRowInTable(Node node, int index) async { + assert(node.type == SimpleTableBlockKeys.type); + + if (node.type != SimpleTableBlockKeys.type) { + return; + } + + final columnLength = node.columnLength; + final rowLength = node.rowLength; + + if (index < 0 || index >= rowLength) { + Log.warn( + 'duplicate row: index out of range: $index, row length: $rowLength', + ); + return; + } + + Log.info( + 'duplicate row in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength', + ); + + final attributes = node.mapTableAttributes( + node, + type: TableMapOperationType.duplicateRow, + index: index, + ); + + final newRow = node.children[index].deepCopy(); + final transaction = this.transaction; + final path = index >= columnLength + ? node.children.last.path.next + : node.children[index].path; + transaction.insertNode(path, newRow); + if (attributes != null) { + transaction.updateNode(node, attributes); + } + await apply(transaction); + } + + Future duplicateColumnInTable(Node node, int index) async { + assert(node.type == SimpleTableBlockKeys.type); + + if (node.type != SimpleTableBlockKeys.type) { + return; + } + + final columnLength = node.columnLength; + final rowLength = node.rowLength; + + if (index < 0 || index >= columnLength) { + Log.warn( + 'duplicate column: index out of range: $index, column length: $columnLength', + ); + return; + } + + Log.info( + 'duplicate column in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength', + ); + + final attributes = node.mapTableAttributes( + node, + type: TableMapOperationType.duplicateColumn, + index: index, + ); + + final transaction = this.transaction; + for (var i = 0; i < rowLength; i++) { + final row = node.children[i]; + final path = index >= rowLength + ? row.children.last.path.next + : row.children[index].path; + final newCell = row.children[index].deepCopy(); + transaction.insertNode( + path, + newCell, + ); + } + if (attributes != null) { + transaction.updateNode(node, attributes); + } + await apply(transaction); + } + + /// Duplicate the table. + /// + /// This function will duplicate the table and insert it after the original table. + Future duplicateTable({ + required Node tableNode, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return; + } + + final transaction = this.transaction; + final newTable = tableNode.deepCopy(); + transaction.insertNode(tableNode.path.next, newTable); + await apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_header_operation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_header_operation.dart new file mode 100644 index 0000000000000..021181591f9b3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_header_operation.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension TableHeaderOperation on EditorState { + /// Toggle the enable header column of the table. + Future toggleEnableHeaderColumn({ + required Node tableNode, + required bool enable, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return; + } + + Log.info( + 'toggle enable header column: $enable in table ${tableNode.id}', + ); + + final transaction = this.transaction; + transaction.updateNode(tableNode, { + SimpleTableBlockKeys.enableHeaderColumn: enable, + }); + await apply(transaction); + } + + /// Toggle the enable header row of the table. + Future toggleEnableHeaderRow({ + required Node tableNode, + required bool enable, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return; + } + + Log.info('toggle enable header row: $enable in table ${tableNode.id}'); + + final transaction = this.transaction; + transaction.updateNode(tableNode, { + SimpleTableBlockKeys.enableHeaderRow: enable, + }); + await apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_insert_operation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_insert_operation.dart new file mode 100644 index 0000000000000..7a92aa3c7ed66 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_insert_operation.dart @@ -0,0 +1,213 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension TableInsertionOperations on EditorState { + /// Add a row at the end of the table. + /// + /// Before: + /// Row 1: | | | | + /// Row 2: | | | | + /// + /// Call this function will add a row at the end of the table. + /// + /// After: + /// Row 1: | | | | + /// Row 2: | | | | + /// Row 3: | | | | ← New row + /// + Future addRowInTable(Node tableNode) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + Log.warn('node is not a table node: ${tableNode.type}'); + return; + } + + await insertRowInTable(tableNode, tableNode.rowLength); + } + + /// Add a column at the end of the table. + /// + /// Before: + /// Row 1: | | | | + /// Row 2: | | | | + /// + /// Call this function will add a column at the end of the table. + /// + /// After: + /// ↓ New column + /// Row 1: | | | | | + /// Row 2: | | | | | + Future addColumnInTable(Node node) async { + assert(node.type == SimpleTableBlockKeys.type); + + if (node.type != SimpleTableBlockKeys.type) { + Log.warn('node is not a table node: ${node.type}'); + return; + } + + await insertColumnInTable(node, node.columnLength); + } + + /// Add a column and a row at the end of the table. + /// + /// Before: + /// Row 1: | | | | + /// Row 2: | | | | + /// + /// Call this function will add a column and a row at the end of the table. + /// + /// After: + /// ↓ New column + /// Row 1: | | | | | + /// Row 2: | | | | | + /// Row 3: | | | | | ← New row + Future addColumnAndRowInTable(Node node) async { + assert(node.type == SimpleTableBlockKeys.type); + + if (node.type != SimpleTableBlockKeys.type) { + return; + } + + await addColumnInTable(node); + await addRowInTable(node); + } + + /// Add a column at the given index. + /// + /// Before: + /// Given index: 1 + /// Row 1: | 0 | 1 | + /// Row 2: | | | + /// + /// Call this function with index 1 will add a column at the second position of the table. + /// + /// After: ↓ New column + /// Row 1: | 0 | | 1 | + /// Row 2: | | | | + Future insertColumnInTable( + Node node, + int index, { + bool inMemoryUpdate = false, + }) async { + assert(node.type == SimpleTableBlockKeys.type); + + if (node.type != SimpleTableBlockKeys.type) { + Log.warn('node is not a table node: ${node.type}'); + return; + } + + final columnLength = node.rowLength; + final rowLength = node.columnLength; + + Log.info( + 'add column in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength', + ); + + if (index < 0) { + Log.warn( + 'insert column: index out of range: $index, column length: $columnLength', + ); + return; + } + + final attributes = node.mapTableAttributes( + node, + type: TableMapOperationType.insertColumn, + index: index, + ); + + final transaction = this.transaction; + for (var i = 0; i < columnLength; i++) { + final row = node.children[i]; + // if the index is greater than the row length, we add the new column at the end of the row. + final path = index >= rowLength + ? row.children.last.path.next + : row.children[index].path; + transaction.insertNode( + path, + simpleTableCellBlockNode(), + ); + } + if (attributes != null) { + transaction.updateNode(node, attributes); + } + await apply( + transaction, + options: ApplyOptions( + inMemoryUpdate: inMemoryUpdate, + ), + ); + } + + /// Add a row at the given index. + /// + /// Before: + /// Given index: 1 + /// Row 1: | | | + /// Row 2: | | | + /// + /// Call this function with index 1 will add a row at the second position of the table. + /// + /// After: + /// Row 1: | | | + /// Row 2: | | | + /// Row 3: | | | ← New row + Future insertRowInTable( + Node node, + int index, { + bool inMemoryUpdate = false, + }) async { + assert(node.type == SimpleTableBlockKeys.type); + + if (node.type != SimpleTableBlockKeys.type) { + return; + } + + if (index < 0) { + Log.warn( + 'insert row: index out of range: $index', + ); + return; + } + + final columnLength = node.rowLength; + final rowLength = node.columnLength; + + Log.info( + 'insert row in table ${node.id} at index: $index, column length: $columnLength, row length: $rowLength', + ); + + final newRow = simpleTableRowBlockNode( + children: [ + for (var i = 0; i < rowLength; i++) simpleTableCellBlockNode(), + ], + ); + + final attributes = node.mapTableAttributes( + node, + type: TableMapOperationType.insertRow, + index: index, + ); + + final transaction = this.transaction; + final path = index >= columnLength + ? node.children.last.path.next + : node.children[index].path; + transaction.insertNode(path, newRow); + if (attributes != null) { + transaction.updateNode(node, attributes); + } + await apply( + transaction, + options: ApplyOptions( + inMemoryUpdate: inMemoryUpdate, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart new file mode 100644 index 0000000000000..cb42571704261 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart @@ -0,0 +1,844 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +enum TableMapOperationType { + insertRow, + deleteRow, + insertColumn, + deleteColumn, + duplicateRow, + duplicateColumn, + reorderColumn, + reorderRow, +} + +extension TableMapOperation on Node { + Attributes? mapTableAttributes( + Node node, { + required TableMapOperationType type, + required int index, + // Only used for reorder column operation + int? toIndex, + }) { + assert(this.type == SimpleTableBlockKeys.type); + + if (this.type != SimpleTableBlockKeys.type) { + return null; + } + + Attributes? attributes; + + switch (type) { + case TableMapOperationType.insertRow: + attributes = _mapRowInsertionAttributes(index); + case TableMapOperationType.insertColumn: + attributes = _mapColumnInsertionAttributes(index); + case TableMapOperationType.duplicateRow: + attributes = _mapRowDuplicationAttributes(index); + case TableMapOperationType.duplicateColumn: + attributes = _mapColumnDuplicationAttributes(index); + case TableMapOperationType.deleteRow: + attributes = _mapRowDeletionAttributes(index); + case TableMapOperationType.deleteColumn: + attributes = _mapColumnDeletionAttributes(index); + case TableMapOperationType.reorderColumn: + if (toIndex != null) { + attributes = _mapColumnReorderingAttributes(index, toIndex); + } + case TableMapOperationType.reorderRow: + if (toIndex != null) { + attributes = _mapRowReorderingAttributes(index, toIndex); + } + } + + // clear the attributes that are null + attributes?.removeWhere( + (key, value) => value == null, + ); + + return attributes; + } + + /// Map the attributes of a row insertion operation. + /// + /// When inserting a row, the attributes of the table after the index should be updated + /// For example: + /// Before: + /// | 0 | 1 | 2 | + /// | 3 | 4 | 5 | ← insert a new row here + /// + /// The original attributes of the table: + /// { + /// "rowColors": { + /// 0: "#FF0000", + /// 1: "#00FF00", + /// } + /// } + /// + /// Insert a row at index 1: + /// | 0 | 1 | 2 | + /// | | | | ← new row + /// | 3 | 4 | 5 | + /// + /// The new attributes of the table: + /// { + /// "rowColors": { + /// 0: "#FF0000", + /// 2: "#00FF00", ← The attributes of the original second row + /// } + /// } + Attributes? _mapRowInsertionAttributes(int index) { + final attributes = this.attributes; + try { + final rowColors = _remapSource( + this.rowColors, + index, + comparator: (iKey, index) => iKey >= index, + ); + + final rowAligns = _remapSource( + this.rowAligns, + index, + comparator: (iKey, index) => iKey >= index, + ); + + return attributes + .mergeValues( + SimpleTableBlockKeys.rowColors, + rowColors, + ) + .mergeValues( + SimpleTableBlockKeys.rowAligns, + rowAligns, + ); + } catch (e) { + Log.warn('Failed to map row insertion attributes: $e'); + return attributes; + } + } + + /// Map the attributes of a column insertion operation. + /// + /// When inserting a column, the attributes of the table after the index should be updated + /// For example: + /// Before: + /// | 0 | 1 | + /// | 2 | 3 | + /// + /// The original attributes of the table: + /// { + /// "columnColors": { + /// 0: "#FF0000", + /// 1: "#00FF00", + /// } + /// } + /// + /// Insert a column at index 1: + /// | 0 | | 1 | + /// | 2 | | 3 | + /// + /// The new attributes of the table: + /// { + /// "columnColors": { + /// 0: "#FF0000", + /// 2: "#00FF00", ← The attributes of the original second column + /// } + /// } + Attributes? _mapColumnInsertionAttributes(int index) { + final attributes = this.attributes; + try { + final columnColors = _remapSource( + this.columnColors, + index, + comparator: (iKey, index) => iKey >= index, + ); + + final columnAligns = _remapSource( + this.columnAligns, + index, + comparator: (iKey, index) => iKey >= index, + ); + + final columnWidths = _remapSource( + this.columnWidths, + index, + comparator: (iKey, index) => iKey >= index, + ); + + final bool distributeColumnWidthsEvenly = + attributes[SimpleTableBlockKeys.distributeColumnWidthsEvenly] ?? + false; + + if (distributeColumnWidthsEvenly) { + // if the distribute column widths evenly flag is true, + // we should distribute the column widths evenly + columnWidths[index.toString()] = columnWidths.values.firstOrNull; + } + + return attributes + .mergeValues( + SimpleTableBlockKeys.columnColors, + columnColors, + ) + .mergeValues( + SimpleTableBlockKeys.columnAligns, + columnAligns, + ) + .mergeValues( + SimpleTableBlockKeys.columnWidths, + columnWidths, + ); + } catch (e) { + Log.warn('Failed to map row insertion attributes: $e'); + return attributes; + } + } + + /// Map the attributes of a row duplication operation. + /// + /// When duplicating a row, the attributes of the table after the index should be updated + /// For example: + /// Before: + /// | 0 | 1 | 2 | + /// | 3 | 4 | 5 | + /// + /// The original attributes of the table: + /// { + /// "rowColors": { + /// 0: "#FF0000", + /// 1: "#00FF00", + /// } + /// } + /// + /// Duplicate the row at index 1: + /// | 0 | 1 | 2 | + /// | 3 | 4 | 5 | + /// | 3 | 4 | 5 | ← duplicated row + /// + /// The new attributes of the table: + /// { + /// "rowColors": { + /// 0: "#FF0000", + /// 1: "#00FF00", + /// 2: "#00FF00", ← The attributes of the original second row + /// } + /// } + Attributes? _mapRowDuplicationAttributes(int index) { + final attributes = this.attributes; + try { + final (rowColors, duplicatedRowColor) = _findDuplicatedEntryAndRemap( + this.rowColors, + index, + ); + + final (rowAligns, duplicatedRowAlign) = _findDuplicatedEntryAndRemap( + this.rowAligns, + index, + ); + + return attributes + .mergeValues( + SimpleTableBlockKeys.rowColors, + rowColors, + duplicatedEntry: duplicatedRowColor, + ) + .mergeValues( + SimpleTableBlockKeys.rowAligns, + rowAligns, + duplicatedEntry: duplicatedRowAlign, + ); + } catch (e) { + Log.warn('Failed to map row insertion attributes: $e'); + return attributes; + } + } + + /// Map the attributes of a column duplication operation. + /// + /// When duplicating a column, the attributes of the table after the index should be updated + /// For example: + /// Before: + /// | 0 | 1 | + /// | 2 | 3 | + /// + /// The original attributes of the table: + /// { + /// "columnColors": { + /// 0: "#FF0000", + /// 1: "#00FF00", + /// } + /// } + /// + /// Duplicate the column at index 1: + /// | 0 | 1 | 1 | ← duplicated column + /// | 2 | 3 | 2 | ← duplicated column + /// + /// The new attributes of the table: + /// { + /// "columnColors": { + /// 0: "#FF0000", + /// 1: "#00FF00", + /// 2: "#00FF00", ← The attributes of the original second column + /// } + /// } + Attributes? _mapColumnDuplicationAttributes(int index) { + final attributes = this.attributes; + try { + final (columnColors, duplicatedColumnColor) = + _findDuplicatedEntryAndRemap( + this.columnColors, + index, + ); + + final (columnAligns, duplicatedColumnAlign) = + _findDuplicatedEntryAndRemap( + this.columnAligns, + index, + ); + + final (columnWidths, duplicatedColumnWidth) = + _findDuplicatedEntryAndRemap( + this.columnWidths, + index, + ); + + return attributes + .mergeValues( + SimpleTableBlockKeys.columnColors, + columnColors, + duplicatedEntry: duplicatedColumnColor, + ) + .mergeValues( + SimpleTableBlockKeys.columnAligns, + columnAligns, + duplicatedEntry: duplicatedColumnAlign, + ) + .mergeValues( + SimpleTableBlockKeys.columnWidths, + columnWidths, + duplicatedEntry: duplicatedColumnWidth, + ); + } catch (e) { + Log.warn('Failed to map column duplication attributes: $e'); + return attributes; + } + } + + /// Map the attributes of a column deletion operation. + /// + /// When deleting a column, the attributes of the table after the index should be updated + /// + /// For example: + /// Before: + /// | 0 | 1 | 2 | + /// | 3 | 4 | 5 | + /// + /// The original attributes of the table: + /// { + /// "columnColors": { + /// 0: "#FF0000", + /// 2: "#00FF00", + /// } + /// } + /// + /// Delete the column at index 1: + /// | 0 | 2 | + /// | 3 | 5 | + /// + /// The new attributes of the table: + /// { + /// "columnColors": { + /// 0: "#FF0000", + /// 1: "#00FF00", ← The attributes of the original second column + /// } + /// } + Attributes? _mapColumnDeletionAttributes(int index) { + final attributes = this.attributes; + try { + final columnColors = _remapSource( + this.columnColors, + index, + increment: false, + comparator: (iKey, index) => iKey > index, + filterIndex: index, + ); + final columnAligns = _remapSource( + this.columnAligns, + index, + increment: false, + comparator: (iKey, index) => iKey > index, + filterIndex: index, + ); + final columnWidths = _remapSource( + this.columnWidths, + index, + increment: false, + comparator: (iKey, index) => iKey > index, + filterIndex: index, + ); + + return attributes + .mergeValues( + SimpleTableBlockKeys.columnColors, + columnColors, + ) + .mergeValues( + SimpleTableBlockKeys.columnAligns, + columnAligns, + ) + .mergeValues( + SimpleTableBlockKeys.columnWidths, + columnWidths, + ); + } catch (e) { + Log.warn('Failed to map column deletion attributes: $e'); + return attributes; + } + } + + /// Map the attributes of a row deletion operation. + /// + /// When deleting a row, the attributes of the table after the index should be updated + /// + /// For example: + /// Before: + /// | 0 | 1 | 2 | ← delete this row + /// | 3 | 4 | 5 | + /// + /// The original attributes of the table: + /// { + /// "rowColors": { + /// 0: "#FF0000", + /// 1: "#00FF00", + /// } + /// } + /// + /// Delete the row at index 0: + /// | 3 | 4 | 5 | + /// + /// The new attributes of the table: + /// { + /// "rowColors": { + /// 0: "#00FF00", + /// } + /// } + Attributes? _mapRowDeletionAttributes(int index) { + final attributes = this.attributes; + try { + final rowColors = _remapSource( + this.rowColors, + index, + increment: false, + comparator: (iKey, index) => iKey > index, + filterIndex: index, + ); + + final rowAligns = _remapSource( + this.rowAligns, + index, + increment: false, + comparator: (iKey, index) => iKey > index, + filterIndex: index, + ); + + return attributes + .mergeValues( + SimpleTableBlockKeys.rowColors, + rowColors, + ) + .mergeValues( + SimpleTableBlockKeys.rowAligns, + rowAligns, + ); + } catch (e) { + Log.warn('Failed to map row deletion attributes: $e'); + return attributes; + } + } + + /// Map the attributes of a column reordering operation. + /// + /// + /// Examples: + /// Case 1: + /// + /// When reordering a column, if the from index is greater than the to index, + /// the attributes of the table before the from index should be updated. + /// + /// Before: + /// ↓ reorder this column from index 1 to index 0 + /// | 0 | 1 | 2 | + /// | 3 | 4 | 5 | + /// + /// The original attributes of the table: + /// { + /// "rowColors": { + /// 0: "#FF0000", + /// 1: "#00FF00", + /// 2: "#0000FF", + /// } + /// } + /// + /// After reordering: + /// | 1 | 0 | 2 | + /// | 4 | 3 | 5 | + /// + /// The new attributes of the table: + /// { + /// "rowColors": { + /// 0: "#00FF00", ← The attributes of the original second column + /// 1: "#FF0000", ← The attributes of the original first column + /// 2: "#0000FF", + /// } + /// } + /// + /// Case 2: + /// + /// When reordering a column, if the from index is less than the to index, + /// the attributes of the table after the from index should be updated. + /// + /// Before: + /// ↓ reorder this column from index 1 to index 2 + /// | 0 | 1 | 2 | + /// | 3 | 4 | 5 | + /// + /// The original attributes of the table: + /// { + /// "columnColors": { + /// 0: "#FF0000", + /// 1: "#00FF00", + /// 2: "#0000FF", + /// } + /// } + /// + /// After reordering: + /// | 0 | 2 | 1 | + /// | 3 | 5 | 4 | + /// + /// The new attributes of the table: + /// { + /// "columnColors": { + /// 0: "#FF0000", + /// 1: "#0000FF", ← The attributes of the original third column + /// 2: "#00FF00", ← The attributes of the original second column + /// } + /// } + Attributes? _mapColumnReorderingAttributes(int fromIndex, int toIndex) { + final attributes = this.attributes; + try { + final duplicatedColumnColor = this.columnColors[fromIndex.toString()]; + final duplicatedColumnAlign = this.columnAligns[fromIndex.toString()]; + final duplicatedColumnWidth = this.columnWidths[fromIndex.toString()]; + + /// Case 1: fromIndex > toIndex + /// Before: + /// Row 0: | 0 | 1 | 2 | + /// Row 1: | 3 | 4 | 5 | + /// Row 2: | 6 | 7 | 8 | + /// + /// columnColors = { + /// "0": "#FF0000", + /// "1": "#00FF00", + /// "2": "#0000FF" ← Move this column (index 2) + /// } + /// + /// Move column 2 to index 0: + /// Row 0: | 2 | 0 | 1 | + /// Row 1: | 5 | 3 | 4 | + /// Row 2: | 8 | 6 | 7 | + /// + /// columnColors = { + /// "0": "#0000FF", ← Moved here + /// "1": "#FF0000", + /// "2": "#00FF00" + /// } + /// + /// Case 2: fromIndex < toIndex + /// Before: + /// Row 0: | 0 | 1 | 2 | + /// Row 1: | 3 | 4 | 5 | + /// Row 2: | 6 | 7 | 8 | + /// + /// columnColors = { + /// "0": "#FF0000" ← Move this column (index 0) + /// "1": "#00FF00", + /// "2": "#0000FF" + /// } + /// + /// Move column 0 to index 2: + /// Row 0: | 1 | 2 | 0 | + /// Row 1: | 4 | 5 | 3 | + /// Row 2: | 7 | 8 | 6 | + /// + /// columnColors = { + /// "0": "#00FF00", + /// "1": "#0000FF", + /// "2": "#FF0000" ← Moved here + /// } + final columnColors = _remapSource( + this.columnColors, + fromIndex, + increment: fromIndex > toIndex, + comparator: (iKey, index) { + if (fromIndex > toIndex) { + return iKey < fromIndex && iKey >= toIndex; + } else { + return iKey > fromIndex && iKey <= toIndex; + } + }, + filterIndex: fromIndex, + ); + + final columnAligns = _remapSource( + this.columnAligns, + fromIndex, + increment: fromIndex > toIndex, + comparator: (iKey, index) { + if (fromIndex > toIndex) { + return iKey < fromIndex && iKey >= toIndex; + } else { + return iKey > fromIndex && iKey <= toIndex; + } + }, + filterIndex: fromIndex, + ); + + final columnWidths = _remapSource( + this.columnWidths, + fromIndex, + increment: fromIndex > toIndex, + comparator: (iKey, index) { + if (fromIndex > toIndex) { + return iKey < fromIndex && iKey >= toIndex; + } else { + return iKey > fromIndex && iKey <= toIndex; + } + }, + filterIndex: fromIndex, + ); + + return attributes + .mergeValues( + SimpleTableBlockKeys.columnColors, + columnColors, + duplicatedEntry: duplicatedColumnColor != null + ? MapEntry( + toIndex.toString(), + duplicatedColumnColor, + ) + : null, + removeNullValue: true, + ) + .mergeValues( + SimpleTableBlockKeys.columnAligns, + columnAligns, + duplicatedEntry: duplicatedColumnAlign != null + ? MapEntry( + toIndex.toString(), + duplicatedColumnAlign, + ) + : null, + removeNullValue: true, + ) + .mergeValues( + SimpleTableBlockKeys.columnWidths, + columnWidths, + duplicatedEntry: duplicatedColumnWidth != null + ? MapEntry( + toIndex.toString(), + duplicatedColumnWidth, + ) + : null, + removeNullValue: true, + ); + } catch (e) { + Log.warn('Failed to map column deletion attributes: $e'); + return attributes; + } + } + + /// Map the attributes of a row reordering operation. + /// + /// See [_mapColumnReorderingAttributes] for more details. + Attributes? _mapRowReorderingAttributes(int fromIndex, int toIndex) { + final attributes = this.attributes; + try { + final duplicatedRowColor = this.rowColors[fromIndex.toString()]; + final duplicatedRowAlign = this.rowAligns[fromIndex.toString()]; + + /// Example: + /// Case 1: fromIndex > toIndex + /// Before: + /// Row 0: | 0 | 1 | 2 | + /// Row 1: | 3 | 4 | 5 | ← Move this row (index 1) + /// Row 2: | 6 | 7 | 8 | + /// + /// rowColors = { + /// "0": "#FF0000", + /// "1": "#00FF00", ← This will be moved + /// "2": "#0000FF" + /// } + /// + /// Move row 1 to index 0: + /// Row 0: | 3 | 4 | 5 | ← Moved here + /// Row 1: | 0 | 1 | 2 | + /// Row 2: | 6 | 7 | 8 | + /// + /// rowColors = { + /// "0": "#00FF00", ← Moved here + /// "1": "#FF0000", + /// "2": "#0000FF" + /// } + /// + /// Case 2: fromIndex < toIndex + /// Before: + /// Row 0: | 0 | 1 | 2 | + /// Row 1: | 3 | 4 | 5 | ← Move this row (index 1) + /// Row 2: | 6 | 7 | 8 | + /// + /// rowColors = { + /// "0": "#FF0000", + /// "1": "#00FF00", ← This will be moved + /// "2": "#0000FF" + /// } + /// + /// Move row 1 to index 2: + /// Row 0: | 0 | 1 | 2 | + /// Row 1: | 3 | 4 | 5 | + /// Row 2: | 6 | 7 | 8 | ← Moved here + /// + /// rowColors = { + /// "0": "#FF0000", + /// "1": "#0000FF", + /// "2": "#00FF00" ← Moved here + /// } + final rowColors = _remapSource( + this.rowColors, + fromIndex, + increment: fromIndex > toIndex, + comparator: (iKey, index) { + if (fromIndex > toIndex) { + return iKey < fromIndex && iKey >= toIndex; + } else { + return iKey > fromIndex && iKey <= toIndex; + } + }, + filterIndex: fromIndex, + ); + + final rowAligns = _remapSource( + this.rowAligns, + fromIndex, + increment: fromIndex > toIndex, + comparator: (iKey, index) { + if (fromIndex > toIndex) { + return iKey < fromIndex && iKey >= toIndex; + } else { + return iKey > fromIndex && iKey <= toIndex; + } + }, + filterIndex: fromIndex, + ); + + return attributes + .mergeValues( + SimpleTableBlockKeys.rowColors, + rowColors, + duplicatedEntry: duplicatedRowColor != null + ? MapEntry( + toIndex.toString(), + duplicatedRowColor, + ) + : null, + removeNullValue: true, + ) + .mergeValues( + SimpleTableBlockKeys.rowAligns, + rowAligns, + duplicatedEntry: duplicatedRowAlign != null + ? MapEntry( + toIndex.toString(), + duplicatedRowAlign, + ) + : null, + removeNullValue: true, + ); + } catch (e) { + Log.warn('Failed to map row reordering attributes: $e'); + return attributes; + } + } +} + +/// Find the duplicated entry and remap the source. +/// +/// All the entries after the index will be remapped to the new index. +(Map newSource, MapEntry? duplicatedEntry) + _findDuplicatedEntryAndRemap( + Map source, + int index, { + bool increment = true, +}) { + MapEntry? duplicatedEntry; + final newSource = source.map((key, value) { + final iKey = int.parse(key); + if (iKey == index) { + duplicatedEntry = MapEntry(key, value); + } + if (iKey >= index) { + return MapEntry((iKey + (increment ? 1 : -1)).toString(), value); + } + return MapEntry(key, value); + }); + return (newSource, duplicatedEntry); +} + +/// Remap the source to the new index. +/// +/// All the entries after the index will be remapped to the new index. +Map _remapSource( + Map source, + int index, { + bool increment = true, + required bool Function(int iKey, int index) comparator, + int? filterIndex, +}) { + var newSource = {...source}; + if (filterIndex != null) { + newSource.remove(filterIndex.toString()); + } + newSource = newSource.map((key, value) { + final iKey = int.parse(key); + if (comparator(iKey, index)) { + return MapEntry((iKey + (increment ? 1 : -1)).toString(), value); + } + return MapEntry(key, value); + }); + return newSource; +} + +extension TableMapOperationAttributes on Attributes { + Attributes mergeValues( + String key, + Map newSource, { + MapEntry? duplicatedEntry, + bool removeNullValue = false, + }) { + final result = {...this}; + + if (duplicatedEntry != null) { + newSource[duplicatedEntry.key] = duplicatedEntry.value; + } + + if (removeNullValue) { + // remove the null value + newSource.removeWhere((key, value) => value == null); + } + + result[key] = newSource; + + return result; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart new file mode 100644 index 0000000000000..ff2cd3e6748b9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart @@ -0,0 +1,858 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_page.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +typedef TableCellPosition = (int, int); + +enum TableAlign { + left, + center, + right; + + static TableAlign fromString(String align) { + return TableAlign.values.firstWhere( + (e) => e.key.toLowerCase() == align.toLowerCase(), + orElse: () => TableAlign.left, + ); + } + + String get name => switch (this) { + TableAlign.left => 'Left', + TableAlign.center => 'Center', + TableAlign.right => 'Right', + }; + + // The key used in the attributes of the table node. + // + // Example: + // + // attributes[SimpleTableBlockKeys.columnAligns] = {0: 'left', 1: 'center', 2: 'right'} + String get key => switch (this) { + TableAlign.left => 'left', + TableAlign.center => 'center', + TableAlign.right => 'right', + }; + + FlowySvgData get leftIconSvg => switch (this) { + TableAlign.left => FlowySvgs.table_align_left_s, + TableAlign.center => FlowySvgs.table_align_center_s, + TableAlign.right => FlowySvgs.table_align_right_s, + }; + + Alignment get alignment => switch (this) { + TableAlign.left => Alignment.topLeft, + TableAlign.center => Alignment.topCenter, + TableAlign.right => Alignment.topRight, + }; + + TextAlign get textAlign => switch (this) { + TableAlign.left => TextAlign.left, + TableAlign.center => TextAlign.center, + TableAlign.right => TextAlign.right, + }; +} + +extension TableNodeExtension on Node { + /// The number of rows in the table. + /// + /// The acceptable node is a table node, table row node or table cell node. + /// + /// Example: + /// + /// Row 1: | | | | + /// Row 2: | | | | + /// + /// The row length is 2. + int get rowLength { + final parentTableNode = this.parentTableNode; + + if (parentTableNode == null || + parentTableNode.type != SimpleTableBlockKeys.type) { + return -1; + } + + return parentTableNode.children.length; + } + + /// The number of rows in the table. + /// + /// The acceptable node is a table node, table row node or table cell node. + /// + /// Example: + /// + /// Row 1: | | | | + /// Row 2: | | | | + /// + /// The column length is 3. + int get columnLength { + final parentTableNode = this.parentTableNode; + + if (parentTableNode == null || + parentTableNode.type != SimpleTableBlockKeys.type) { + return -1; + } + + return parentTableNode.children.firstOrNull?.children.length ?? 0; + } + + TableCellPosition get cellPosition { + assert(type == SimpleTableCellBlockKeys.type); + return (rowIndex, columnIndex); + } + + int get rowIndex { + if (type == SimpleTableCellBlockKeys.type) { + if (path.parent.isEmpty) { + return -1; + } + return path.parent.last; + } else if (type == SimpleTableRowBlockKeys.type) { + return path.last; + } + return -1; + } + + int get columnIndex { + assert(type == SimpleTableCellBlockKeys.type); + if (path.isEmpty) { + return -1; + } + return path.last; + } + + bool get isHeaderColumnEnabled { + try { + return parentTableNode + ?.attributes[SimpleTableBlockKeys.enableHeaderColumn] ?? + false; + } catch (e) { + Log.warn('get is header column enabled: $e'); + return false; + } + } + + bool get isHeaderRowEnabled { + try { + return parentTableNode + ?.attributes[SimpleTableBlockKeys.enableHeaderRow] ?? + false; + } catch (e) { + Log.warn('get is header row enabled: $e'); + return false; + } + } + + TableAlign get rowAlign { + final parentTableNode = this.parentTableNode; + + if (parentTableNode == null) { + return TableAlign.left; + } + + try { + final rowAligns = + parentTableNode.attributes[SimpleTableBlockKeys.rowAligns]; + final align = rowAligns?[rowIndex.toString()]; + return TableAlign.values.firstWhere( + (e) => e.key == align, + orElse: () => TableAlign.left, + ); + } catch (e) { + Log.warn('get row align: $e'); + return TableAlign.left; + } + } + + TableAlign get columnAlign { + final parentTableNode = this.parentTableNode; + + if (parentTableNode == null) { + return TableAlign.left; + } + + try { + final columnAligns = + parentTableNode.attributes[SimpleTableBlockKeys.columnAligns]; + final align = columnAligns?[columnIndex.toString()]; + return TableAlign.values.firstWhere( + (e) => e.key == align, + orElse: () => TableAlign.left, + ); + } catch (e) { + Log.warn('get column align: $e'); + return TableAlign.left; + } + } + + Node? get parentTableNode { + Node? tableNode; + + if (type == SimpleTableBlockKeys.type) { + tableNode = this; + } else if (type == SimpleTableRowBlockKeys.type) { + tableNode = parent; + } else if (type == SimpleTableCellBlockKeys.type) { + tableNode = parent?.parent; + } else { + return parent?.parentTableNode; + } + + if (tableNode == null || tableNode.type != SimpleTableBlockKeys.type) { + return null; + } + + return tableNode; + } + + Node? get parentTableCellNode { + Node? tableCellNode; + + if (type == SimpleTableCellBlockKeys.type) { + tableCellNode = this; + } else { + return parent?.parentTableCellNode; + } + + return tableCellNode; + } + + /// Whether the current node is in a table. + bool get isInTable { + return parentTableNode != null; + } + + double get columnWidth { + final parentTableNode = this.parentTableNode; + + if (parentTableNode == null) { + return SimpleTableConstants.defaultColumnWidth; + } + + try { + final columnWidths = + parentTableNode.attributes[SimpleTableBlockKeys.columnWidths]; + final width = columnWidths?[columnIndex.toString()]; + return width ?? SimpleTableConstants.defaultColumnWidth; + } catch (e) { + Log.warn('get column width: $e'); + return SimpleTableConstants.defaultColumnWidth; + } + } + + /// Build the row color. + /// + /// Default is null. + Color? buildRowColor(BuildContext context) { + try { + final rawRowColors = + parentTableNode?.attributes[SimpleTableBlockKeys.rowColors]; + if (rawRowColors == null) { + return null; + } + final color = rawRowColors[rowIndex.toString()]; + if (color == null) { + return null; + } + return buildEditorCustomizedColor(context, this, color); + } catch (e) { + Log.warn('get row color: $e'); + return null; + } + } + + /// Build the column color. + /// + /// Default is null. + Color? buildColumnColor(BuildContext context) { + try { + final columnColors = + parentTableNode?.attributes[SimpleTableBlockKeys.columnColors]; + if (columnColors == null) { + return null; + } + final color = columnColors[columnIndex.toString()]; + if (color == null) { + return null; + } + return buildEditorCustomizedColor(context, this, color); + } catch (e) { + Log.warn('get column color: $e'); + return null; + } + } + + /// Whether the current node is in the header column. + /// + /// Default is false. + bool get isInHeaderColumn { + final parentTableNode = parent?.parentTableNode; + if (parentTableNode == null || + parentTableNode.type != SimpleTableBlockKeys.type) { + return false; + } + return parentTableNode.isHeaderColumnEnabled && + parentTableCellNode?.columnIndex == 0; + } + + /// Whether the current cell is bold in the column. + /// + /// Default is false. + bool get isInBoldColumn { + final parentTableCellNode = this.parentTableCellNode; + final parentTableNode = this.parentTableNode; + if (parentTableCellNode == null || + parentTableNode == null || + parentTableNode.type != SimpleTableBlockKeys.type) { + return false; + } + + final columnIndex = parentTableCellNode.columnIndex; + final columnBoldAttributes = parentTableNode.columnBoldAttributes; + return columnBoldAttributes[columnIndex.toString()] ?? false; + } + + /// Whether the current cell is bold in the row. + /// + /// Default is false. + bool get isInBoldRow { + final parentTableCellNode = this.parentTableCellNode; + final parentTableNode = this.parentTableNode; + if (parentTableCellNode == null || + parentTableNode == null || + parentTableNode.type != SimpleTableBlockKeys.type) { + return false; + } + + final rowIndex = parentTableCellNode.rowIndex; + final rowBoldAttributes = parentTableNode.rowBoldAttributes; + return rowBoldAttributes[rowIndex.toString()] ?? false; + } + + /// Get the text color of the current cell in the column. + /// + /// Default is null. + String? get textColorInColumn { + final parentTableCellNode = this.parentTableCellNode; + final parentTableNode = this.parentTableNode; + if (parentTableCellNode == null || + parentTableNode == null || + parentTableNode.type != SimpleTableBlockKeys.type) { + return null; + } + + final columnIndex = parentTableCellNode.columnIndex; + return parentTableNode.columnTextColors[columnIndex.toString()]; + } + + /// Get the text color of the current cell in the row. + /// + /// Default is null. + String? get textColorInRow { + final parentTableCellNode = this.parentTableCellNode; + final parentTableNode = this.parentTableNode; + if (parentTableCellNode == null || + parentTableNode == null || + parentTableNode.type != SimpleTableBlockKeys.type) { + return null; + } + + final rowIndex = parentTableCellNode.rowIndex; + return parentTableNode.rowTextColors[rowIndex.toString()]; + } + + /// Whether the current node is in the header row. + /// + /// Default is false. + bool get isInHeaderRow { + final parentTableNode = parent?.parentTableNode; + if (parentTableNode == null || + parentTableNode.type != SimpleTableBlockKeys.type) { + return false; + } + return parentTableNode.isHeaderRowEnabled && + parentTableCellNode?.rowIndex == 0; + } + + /// Get the row aligns. + SimpleTableRowAlignMap get rowAligns { + final rawRowAligns = + parentTableNode?.attributes[SimpleTableBlockKeys.rowAligns]; + if (rawRowAligns == null) { + return SimpleTableRowAlignMap(); + } + try { + return SimpleTableRowAlignMap.from(rawRowAligns); + } catch (e) { + Log.warn('get row aligns: $e'); + return SimpleTableRowAlignMap(); + } + } + + /// Get the row colors. + SimpleTableColorMap get rowColors { + final rawRowColors = + parentTableNode?.attributes[SimpleTableBlockKeys.rowColors]; + if (rawRowColors == null) { + return SimpleTableColorMap(); + } + try { + return SimpleTableColorMap.from(rawRowColors); + } catch (e) { + Log.warn('get row colors: $e'); + return SimpleTableColorMap(); + } + } + + /// Get the column colors. + SimpleTableColorMap get columnColors { + final rawColumnColors = + parentTableNode?.attributes[SimpleTableBlockKeys.columnColors]; + if (rawColumnColors == null) { + return SimpleTableColorMap(); + } + try { + return SimpleTableColorMap.from(rawColumnColors); + } catch (e) { + Log.warn('get column colors: $e'); + return SimpleTableColorMap(); + } + } + + /// Get the column aligns. + SimpleTableColumnAlignMap get columnAligns { + final rawColumnAligns = + parentTableNode?.attributes[SimpleTableBlockKeys.columnAligns]; + if (rawColumnAligns == null) { + return SimpleTableRowAlignMap(); + } + try { + return SimpleTableRowAlignMap.from(rawColumnAligns); + } catch (e) { + Log.warn('get column aligns: $e'); + return SimpleTableRowAlignMap(); + } + } + + /// Get the column widths. + SimpleTableColumnWidthMap get columnWidths { + final rawColumnWidths = + parentTableNode?.attributes[SimpleTableBlockKeys.columnWidths]; + if (rawColumnWidths == null) { + return SimpleTableColumnWidthMap(); + } + try { + return SimpleTableColumnWidthMap.from(rawColumnWidths); + } catch (e) { + Log.warn('get column widths: $e'); + return SimpleTableColumnWidthMap(); + } + } + + /// Get the column text colors + SimpleTableColorMap get columnTextColors { + final rawColumnTextColors = + parentTableNode?.attributes[SimpleTableBlockKeys.columnTextColors]; + if (rawColumnTextColors == null) { + return SimpleTableColorMap(); + } + try { + return SimpleTableColorMap.from(rawColumnTextColors); + } catch (e) { + Log.warn('get column text colors: $e'); + return SimpleTableColorMap(); + } + } + + /// Get the row text colors + SimpleTableColorMap get rowTextColors { + final rawRowTextColors = + parentTableNode?.attributes[SimpleTableBlockKeys.rowTextColors]; + if (rawRowTextColors == null) { + return SimpleTableColorMap(); + } + try { + return SimpleTableColorMap.from(rawRowTextColors); + } catch (e) { + Log.warn('get row text colors: $e'); + return SimpleTableColorMap(); + } + } + + /// Get the column bold attributes + SimpleTableAttributeMap get columnBoldAttributes { + final rawColumnBoldAttributes = + parentTableNode?.attributes[SimpleTableBlockKeys.columnBoldAttributes]; + if (rawColumnBoldAttributes == null) { + return SimpleTableAttributeMap(); + } + try { + return SimpleTableAttributeMap.from(rawColumnBoldAttributes); + } catch (e) { + Log.warn('get column bold attributes: $e'); + return SimpleTableAttributeMap(); + } + } + + /// Get the row bold attributes + SimpleTableAttributeMap get rowBoldAttributes { + final rawRowBoldAttributes = + parentTableNode?.attributes[SimpleTableBlockKeys.rowBoldAttributes]; + if (rawRowBoldAttributes == null) { + return SimpleTableAttributeMap(); + } + try { + return SimpleTableAttributeMap.from(rawRowBoldAttributes); + } catch (e) { + Log.warn('get row bold attributes: $e'); + return SimpleTableAttributeMap(); + } + } + + /// Get the width of the table. + double get width { + double currentColumnWidth = 0; + for (var i = 0; i < columnLength; i++) { + final columnWidth = + columnWidths[i.toString()] ?? SimpleTableConstants.defaultColumnWidth; + currentColumnWidth += columnWidth; + } + return currentColumnWidth; + } + + /// Get the previous cell in the same column. If the row index is 0, it will return the same cell. + Node? getPreviousCellInSameColumn() { + assert(type == SimpleTableCellBlockKeys.type); + final parentTableNode = this.parentTableNode; + if (parentTableNode == null) { + return null; + } + + final columnIndex = this.columnIndex; + final rowIndex = this.rowIndex; + + if (rowIndex == 0) { + return this; + } + + final previousColumn = parentTableNode.children[rowIndex - 1]; + final previousCell = previousColumn.children[columnIndex]; + return previousCell; + } + + /// Get the next cell in the same column. If the row index is the last row, it will return the same cell. + Node? getNextCellInSameColumn() { + assert(type == SimpleTableCellBlockKeys.type); + final parentTableNode = this.parentTableNode; + if (parentTableNode == null) { + return null; + } + + final columnIndex = this.columnIndex; + final rowIndex = this.rowIndex; + + if (rowIndex == parentTableNode.rowLength - 1) { + return this; + } + + final nextColumn = parentTableNode.children[rowIndex + 1]; + final nextCell = nextColumn.children[columnIndex]; + return nextCell; + } + + /// Get the right cell in the same row. If the column index is the last column, it will return the same cell. + Node? getNextCellInSameRow() { + assert(type == SimpleTableCellBlockKeys.type); + final parentTableNode = this.parentTableNode; + if (parentTableNode == null) { + return null; + } + + final columnIndex = this.columnIndex; + final rowIndex = this.rowIndex; + + // the last cell + if (columnIndex == parentTableNode.columnLength - 1 && + rowIndex == parentTableNode.rowLength - 1) { + return this; + } + + if (columnIndex == parentTableNode.columnLength - 1) { + final nextRow = parentTableNode.children[rowIndex + 1]; + final nextCell = nextRow.children.first; + return nextCell; + } + + final nextColumn = parentTableNode.children[rowIndex]; + final nextCell = nextColumn.children[columnIndex + 1]; + return nextCell; + } + + /// Get the previous cell in the same row. If the column index is 0, it will return the same cell. + Node? getPreviousCellInSameRow() { + assert(type == SimpleTableCellBlockKeys.type); + final parentTableNode = this.parentTableNode; + if (parentTableNode == null) { + return null; + } + + final columnIndex = this.columnIndex; + final rowIndex = this.rowIndex; + + if (columnIndex == 0 && rowIndex == 0) { + return this; + } + + if (columnIndex == 0) { + final previousRow = parentTableNode.children[rowIndex - 1]; + final previousCell = previousRow.children.last; + return previousCell; + } + + final previousColumn = parentTableNode.children[rowIndex]; + final previousCell = previousColumn.children[columnIndex - 1]; + return previousCell; + } + + /// Get the previous focusable sibling. + /// + /// If the current node is the first child of its parent, it will return itself. + Node? getPreviousFocusableSibling() { + final parent = this.parent; + if (parent == null) { + return null; + } + final parentTableNode = this.parentTableNode; + if (parentTableNode == null) { + return null; + } + if (parentTableNode.path == [0]) { + return this; + } + final previous = parentTableNode.previous; + if (previous == null) { + return null; + } + var children = previous.children; + if (children.isEmpty) { + return previous; + } + while (children.isNotEmpty) { + children = children.last.children; + } + return children.lastWhere((c) => c.delta != null); + } + + /// Get the next focusable sibling. + /// + /// If the current node is the last child of its parent, it will return itself. + Node? getNextFocusableSibling() { + final next = this.next; + if (next == null) { + return null; + } + return next; + } + + /// Is the last cell in the table. + bool get isLastCellInTable { + return columnIndex + 1 == parentTableNode?.columnLength && + rowIndex + 1 == parentTableNode?.rowLength; + } + + /// Is the first cell in the table. + bool get isFirstCellInTable { + return columnIndex == 0 && rowIndex == 0; + } + + /// Get the table cell node by the row index and column index. + /// + /// If the current node is not a table cell node, it will return null. + /// Or if the row index or column index is out of range, it will return null. + Node? getTableCellNode({ + required int rowIndex, + required int columnIndex, + }) { + assert(type == SimpleTableBlockKeys.type); + + if (type != SimpleTableBlockKeys.type) { + return null; + } + + if (rowIndex < 0 || rowIndex >= rowLength) { + return null; + } + + if (columnIndex < 0 || columnIndex >= columnLength) { + return null; + } + + return children[rowIndex].children[columnIndex]; + } + + String? getTableCellContent({ + required int rowIndex, + required int columnIndex, + }) { + final cell = getTableCellNode(rowIndex: rowIndex, columnIndex: columnIndex); + if (cell == null) { + return null; + } + final content = cell.children + .map((e) => e.delta?.toPlainText()) + .where((e) => e != null) + .join('\n'); + return content; + } + + /// Return the first empty row in the table from bottom to top. + /// + /// Example: + /// + /// | A | B | C | + /// | | | | + /// | E | F | G | + /// | H | I | J | + /// | | | | <--- The first empty row is the row at index 3. + /// | | | | + /// + /// The first empty row is the row at index 3. + (int, Node)? getFirstEmptyRowFromBottom() { + assert(type == SimpleTableBlockKeys.type); + + if (type != SimpleTableBlockKeys.type) { + return null; + } + + (int, Node)? result; + + for (var i = children.length - 1; i >= 0; i--) { + final row = children[i]; + + // Check if all cells in this row are empty + final hasContent = row.children.any((cell) { + final content = getTableCellContent( + rowIndex: i, + columnIndex: row.children.indexOf(cell), + ); + return content != null && content.isNotEmpty; + }); + + if (!hasContent) { + if (result != null) { + final (index, _) = result; + if (i <= index) { + result = (i, row); + } + } else { + result = (i, row); + } + } + } + + return result; + } + + /// Return the first empty column in the table from right to left. + /// + /// Example: + /// ↓ The first empty column is the column at index 3. + /// | A | C | | E | | | + /// | B | D | | F | | | + /// + /// The first empty column is the column at index 3. + int? getFirstEmptyColumnFromRight() { + assert(type == SimpleTableBlockKeys.type); + + if (type != SimpleTableBlockKeys.type) { + return null; + } + + int? result; + + for (var i = columnLength - 1; i >= 0; i--) { + bool hasContent = false; + for (var j = 0; j < rowLength; j++) { + final content = getTableCellContent( + rowIndex: j, + columnIndex: i, + ); + if (content != null && content.isNotEmpty) { + hasContent = true; + } + } + if (!hasContent) { + if (result != null) { + final index = result; + if (i <= index) { + result = i; + } + } else { + result = i; + } + } + } + + return result; + } + + /// Get first focusable child in the table cell. + /// + /// If the current node is not a table cell node, it will return null. + Node? getFirstChildIndex() { + if (children.isEmpty) { + return this; + } + return children.first.getFirstChildIndex(); + } + + /// Get last focusable child in the table cell. + /// + /// If the current node is not a table cell node, it will return null. + Node? getLastChildIndex() { + if (children.isEmpty) { + return this; + } + return children.last.getLastChildIndex(); + } + + /// Get table align of column + /// + /// If one of the align is not same as the others, it will return TableAlign.left. + TableAlign get allColumnAlign { + final alignSet = columnAligns.values.toSet(); + if (alignSet.length == 1) { + return TableAlign.fromString(alignSet.first); + } + return TableAlign.left; + } + + /// Get table align of row + /// + /// If one of the align is not same as the others, it will return TableAlign.left. + TableAlign get allRowAlign { + final alignSet = rowAligns.values.toSet(); + if (alignSet.length == 1) { + return TableAlign.fromString(alignSet.first); + } + return TableAlign.left; + } + + /// Get table align of the table. + /// + /// If one of the align is not same as the others, it will return TableAlign.left. + TableAlign get tableAlign { + if (allColumnAlign != TableAlign.left) { + return allColumnAlign; + } else if (allRowAlign != TableAlign.left) { + return allRowAlign; + } + return TableAlign.left; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart new file mode 100644 index 0000000000000..c5bb8bac83fe1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart @@ -0,0 +1,9 @@ +export 'simple_table_content_operation.dart'; +export 'simple_table_delete_operation.dart'; +export 'simple_table_duplicate_operation.dart'; +export 'simple_table_header_operation.dart'; +export 'simple_table_insert_operation.dart'; +export 'simple_table_map_operation.dart'; +export 'simple_table_node_extension.dart'; +export 'simple_table_reorder_operation.dart'; +export 'simple_table_style_operation.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_reorder_operation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_reorder_operation.dart new file mode 100644 index 0000000000000..02e384ac0266b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_reorder_operation.dart @@ -0,0 +1,120 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension SimpleTableReorderOperation on EditorState { + /// Reorder the column of the table. + /// + /// If the from index is equal to the to index, do nothing. + /// The node's type can be [SimpleTableCellBlockKeys.type] or [SimpleTableRowBlockKeys.type] or [SimpleTableBlockKeys.type]. + Future reorderColumn( + Node node, { + required int fromIndex, + required int toIndex, + }) async { + if (fromIndex == toIndex) { + return; + } + + final tableNode = node.parentTableNode; + + if (tableNode == null) { + assert(tableNode == null); + return; + } + + final columnLength = tableNode.columnLength; + final rowLength = tableNode.rowLength; + + if (fromIndex < 0 || + fromIndex >= columnLength || + toIndex < 0 || + toIndex >= columnLength) { + Log.warn( + 'reorder column: index out of range: fromIndex: $fromIndex, toIndex: $toIndex, column length: $columnLength', + ); + return; + } + + Log.info( + 'reorder column in table ${node.id} at fromIndex: $fromIndex, toIndex: $toIndex, column length: $columnLength, row length: $rowLength', + ); + + final attributes = tableNode.mapTableAttributes( + tableNode, + type: TableMapOperationType.reorderColumn, + index: fromIndex, + toIndex: toIndex, + ); + + final transaction = this.transaction; + for (var i = 0; i < rowLength; i++) { + final row = tableNode.children[i]; + final from = row.children[fromIndex]; + final to = row.children[toIndex]; + final path = fromIndex < toIndex ? to.path.next : to.path; + transaction.insertNode(path, from.deepCopy()); + transaction.deleteNode(from); + } + if (attributes != null) { + transaction.updateNode(tableNode, attributes); + } + await apply(transaction); + } + + /// Reorder the row of the table. + /// + /// If the from index is equal to the to index, do nothing. + /// The node's type can be [SimpleTableCellBlockKeys.type] or [SimpleTableRowBlockKeys.type] or [SimpleTableBlockKeys.type]. + Future reorderRow( + Node node, { + required int fromIndex, + required int toIndex, + }) async { + if (fromIndex == toIndex) { + return; + } + + final tableNode = node.parentTableNode; + + if (tableNode == null) { + assert(tableNode == null); + return; + } + + final columnLength = tableNode.columnLength; + final rowLength = tableNode.rowLength; + + if (fromIndex < 0 || + fromIndex >= rowLength || + toIndex < 0 || + toIndex >= rowLength) { + Log.warn( + 'reorder row: index out of range: fromIndex: $fromIndex, toIndex: $toIndex, row length: $rowLength', + ); + return; + } + + Log.info( + 'reorder row in table ${node.id} at fromIndex: $fromIndex, toIndex: $toIndex, column length: $columnLength, row length: $rowLength', + ); + + final attributes = tableNode.mapTableAttributes( + tableNode, + type: TableMapOperationType.reorderRow, + index: fromIndex, + toIndex: toIndex, + ); + + final transaction = this.transaction; + final from = tableNode.children[fromIndex]; + final to = tableNode.children[toIndex]; + final path = fromIndex < toIndex ? to.path.next : to.path; + transaction.insertNode(path, from.deepCopy()); + transaction.deleteNode(from); + if (attributes != null) { + transaction.updateNode(tableNode, attributes); + } + await apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_style_operation.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_style_operation.dart new file mode 100644 index 0000000000000..48ffaae3cd143 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_style_operation.dart @@ -0,0 +1,388 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_constants.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_map_operation.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_node_extension.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:universal_platform/universal_platform.dart'; + +extension TableOptionOperation on EditorState { + /// Update the column width of the table in memory. Call this function when dragging the table column. + /// + /// The deltaX is the change of the column width. + Future updateColumnWidthInMemory({ + required Node tableCellNode, + required double deltaX, + }) async { + // Disable in mobile + if (UniversalPlatform.isMobile) { + return; + } + + assert(tableCellNode.type == SimpleTableCellBlockKeys.type); + + if (tableCellNode.type != SimpleTableCellBlockKeys.type) { + return; + } + + // when dragging the table column, we need to update the column width in memory. + // so that the table can render the column with the new width. + // but don't need to persist to the database immediately. + // only persist to the database when the drag is completed. + final columnIndex = tableCellNode.columnIndex; + final parentTableNode = tableCellNode.parentTableNode; + if (parentTableNode == null) { + Log.warn('parent table node is null'); + return; + } + + final width = tableCellNode.columnWidth + deltaX; + + try { + final columnWidths = + parentTableNode.attributes[SimpleTableBlockKeys.columnWidths] ?? + SimpleTableColumnWidthMap(); + final newAttributes = { + ...parentTableNode.attributes, + SimpleTableBlockKeys.columnWidths: { + ...columnWidths, + columnIndex.toString(): width.clamp( + SimpleTableConstants.minimumColumnWidth, + double.infinity, + ), + }, + }; + + parentTableNode.updateAttributes(newAttributes); + } catch (e) { + Log.warn('update column width in memory: $e'); + } + } + + /// Update the column width of the table. Call this function after the drag is completed. + Future updateColumnWidth({ + required Node tableCellNode, + required double width, + }) async { + // Disable in mobile + if (UniversalPlatform.isMobile) { + return; + } + + assert(tableCellNode.type == SimpleTableCellBlockKeys.type); + + if (tableCellNode.type != SimpleTableCellBlockKeys.type) { + return; + } + + final columnIndex = tableCellNode.columnIndex; + final parentTableNode = tableCellNode.parentTableNode; + if (parentTableNode == null) { + Log.warn('parent table node is null'); + return; + } + + final transaction = this.transaction; + final columnWidths = + parentTableNode.attributes[SimpleTableBlockKeys.columnWidths] ?? + SimpleTableColumnWidthMap(); + transaction.updateNode(parentTableNode, { + SimpleTableBlockKeys.columnWidths: { + ...columnWidths, + columnIndex.toString(): width.clamp( + SimpleTableConstants.minimumColumnWidth, + double.infinity, + ), + }, + // reset the distribute column widths evenly flag + SimpleTableBlockKeys.distributeColumnWidthsEvenly: false, + }); + await apply(transaction); + } + + /// Update the align of the column at the index where the table cell node is located. + /// + /// Before: + /// Given table cell node: + /// Row 1: | 0 | 1 | + /// Row 2: |2 |3 | ← This column will be updated + /// + /// Call this function will update the align of the column where the table cell node is located. + /// + /// After: + /// Row 1: | 0 | 1 | + /// Row 2: | 2 | 3 | ← This column is updated, texts are aligned to the center + Future updateColumnAlign({ + required Node tableCellNode, + required TableAlign align, + }) async { + final columnIndex = tableCellNode.columnIndex; + await _updateTableAttributes( + tableCellNode: tableCellNode, + attributeKey: SimpleTableBlockKeys.columnAligns, + source: tableCellNode.columnAligns, + duplicatedEntry: MapEntry(columnIndex.toString(), align.key), + ); + } + + /// Update the align of the row at the index where the table cell node is located. + /// + /// Before: + /// Given table cell node: + /// ↓ This row will be updated + /// Row 1: | 0 |1 | + /// Row 2: | 2 |3 | + /// + /// Call this function will update the align of the row where the table cell node is located. + /// + /// After: + /// ↓ This row is updated, texts are aligned to the center + /// Row 1: | 0 | 1 | + /// Row 2: | 2 | 3 | + Future updateRowAlign({ + required Node tableCellNode, + required TableAlign align, + }) async { + final rowIndex = tableCellNode.rowIndex; + await _updateTableAttributes( + tableCellNode: tableCellNode, + attributeKey: SimpleTableBlockKeys.rowAligns, + source: tableCellNode.rowAligns, + duplicatedEntry: MapEntry(rowIndex.toString(), align.key), + ); + } + + /// Update the align of the table. + /// + /// This function will update the align of the table. + /// + /// The align is the align to be updated. + Future updateTableAlign({ + required Node tableNode, + required TableAlign align, + }) async { + assert(tableNode.type == SimpleTableBlockKeys.type); + + if (tableNode.type != SimpleTableBlockKeys.type) { + return; + } + + final transaction = this.transaction; + Attributes attributes = tableNode.attributes; + for (var i = 0; i < tableNode.columnLength; i++) { + attributes = attributes.mergeValues( + SimpleTableBlockKeys.columnAligns, + attributes[SimpleTableBlockKeys.columnAligns] ?? + SimpleTableColumnAlignMap(), + duplicatedEntry: MapEntry(i.toString(), align.key), + ); + } + transaction.updateNode(tableNode, attributes); + await apply(transaction); + } + + /// Update the background color of the column at the index where the table cell node is located. + Future updateColumnBackgroundColor({ + required Node tableCellNode, + required String color, + }) async { + final columnIndex = tableCellNode.columnIndex; + await _updateTableAttributes( + tableCellNode: tableCellNode, + attributeKey: SimpleTableBlockKeys.columnColors, + source: tableCellNode.columnColors, + duplicatedEntry: MapEntry(columnIndex.toString(), color), + ); + } + + /// Update the background color of the row at the index where the table cell node is located. + Future updateRowBackgroundColor({ + required Node tableCellNode, + required String color, + }) async { + final rowIndex = tableCellNode.rowIndex; + await _updateTableAttributes( + tableCellNode: tableCellNode, + attributeKey: SimpleTableBlockKeys.rowColors, + source: tableCellNode.rowColors, + duplicatedEntry: MapEntry(rowIndex.toString(), color), + ); + } + + /// Set the column width of the table to the page width. + /// + /// Example: + /// + /// Before: + /// | 0 | 1 | + /// | 3 | 4 | + /// + /// After: + /// | 0 | 1 | <- the column's width will be expanded based on the percentage of the page width + /// | 3 | 4 | + /// + /// This function will update the table width. + Future setColumnWidthToPageWidth({ + required Node tableNode, + }) async { + final columnLength = tableNode.columnLength; + double? pageWidth = tableNode.renderBox?.size.width; + if (pageWidth == null) { + Log.warn('table node render box is null'); + return; + } + pageWidth -= SimpleTableConstants.tablePageOffset; + + final transaction = this.transaction; + final columnWidths = tableNode.columnWidths; + final ratio = pageWidth / tableNode.width; + for (var i = 0; i < columnLength; i++) { + final columnWidth = + columnWidths[i.toString()] ?? SimpleTableConstants.defaultColumnWidth; + columnWidths[i.toString()] = (columnWidth * ratio).clamp( + SimpleTableConstants.minimumColumnWidth, + double.infinity, + ); + } + transaction.updateNode(tableNode, { + SimpleTableBlockKeys.columnWidths: columnWidths, + SimpleTableBlockKeys.distributeColumnWidthsEvenly: false, + }); + await apply(transaction); + } + + /// Distribute the column width of the table to the page width. + /// + /// Example: + /// + /// Before: + /// Before: + /// | 0 | 1 | + /// | 3 | 4 | + /// + /// After: + /// | 0 | 1 | <- the column's width will be expanded based on the percentage of the page width + /// | 3 | 4 | + /// + /// This function will not update table width. + Future distributeColumnWidthToPageWidth({ + required Node tableNode, + }) async { + // Disable in mobile + if (UniversalPlatform.isMobile) { + return; + } + + final columnLength = tableNode.columnLength; + final tableWidth = tableNode.width; + final columnWidth = (tableWidth / columnLength).clamp( + SimpleTableConstants.minimumColumnWidth, + double.infinity, + ); + final transaction = this.transaction; + final columnWidths = tableNode.columnWidths; + for (var i = 0; i < columnLength; i++) { + columnWidths[i.toString()] = columnWidth; + } + transaction.updateNode(tableNode, { + SimpleTableBlockKeys.columnWidths: columnWidths, + SimpleTableBlockKeys.distributeColumnWidthsEvenly: true, + }); + await apply(transaction); + } + + /// Update the bold attribute of the column + Future toggleColumnBoldAttribute({ + required Node tableCellNode, + required bool isBold, + }) async { + final columnIndex = tableCellNode.columnIndex; + await _updateTableAttributes( + tableCellNode: tableCellNode, + attributeKey: SimpleTableBlockKeys.columnBoldAttributes, + source: tableCellNode.columnBoldAttributes, + duplicatedEntry: MapEntry(columnIndex.toString(), isBold), + ); + } + + /// Update the bold attribute of the row + Future toggleRowBoldAttribute({ + required Node tableCellNode, + required bool isBold, + }) async { + final rowIndex = tableCellNode.rowIndex; + await _updateTableAttributes( + tableCellNode: tableCellNode, + attributeKey: SimpleTableBlockKeys.rowBoldAttributes, + source: tableCellNode.rowBoldAttributes, + duplicatedEntry: MapEntry(rowIndex.toString(), isBold), + ); + } + + /// Update the text color of the column + Future updateColumnTextColor({ + required Node tableCellNode, + required String color, + }) async { + final columnIndex = tableCellNode.columnIndex; + await _updateTableAttributes( + tableCellNode: tableCellNode, + attributeKey: SimpleTableBlockKeys.columnTextColors, + source: tableCellNode.columnTextColors, + duplicatedEntry: MapEntry(columnIndex.toString(), color), + ); + } + + /// Update the text color of the row + Future updateRowTextColor({ + required Node tableCellNode, + required String color, + }) async { + final rowIndex = tableCellNode.rowIndex; + await _updateTableAttributes( + tableCellNode: tableCellNode, + attributeKey: SimpleTableBlockKeys.rowTextColors, + source: tableCellNode.rowTextColors, + duplicatedEntry: MapEntry(rowIndex.toString(), color), + ); + } + + /// Update the attributes of the table. + /// + /// This function is used to update the attributes of the table. + /// + /// The attribute key is the key of the attribute to be updated. + /// The source is the original value of the attribute. + /// The duplicatedEntry is the entry of the attribute to be updated. + Future _updateTableAttributes({ + required Node tableCellNode, + required String attributeKey, + required Map source, + MapEntry? duplicatedEntry, + }) async { + assert(tableCellNode.type == SimpleTableCellBlockKeys.type); + + final parentTableNode = tableCellNode.parentTableNode; + + if (parentTableNode == null) { + Log.warn('parent table node is null'); + return; + } + + final columnIndex = tableCellNode.columnIndex; + + Log.info( + 'update $attributeKey: $source at column $columnIndex in table ${parentTableNode.id}', + ); + + final transaction = this.transaction; + final attributes = parentTableNode.attributes.mergeValues( + attributeKey, + source, + duplicatedEntry: duplicatedEntry, + ); + transaction.updateNode(parentTableNode, attributes); + await apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart new file mode 100644 index 0000000000000..712df59b82cb1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_row_block_component.dart @@ -0,0 +1,109 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SimpleTableRowBlockKeys { + const SimpleTableRowBlockKeys._(); + + static const String type = 'simple_table_row'; +} + +Node simpleTableRowBlockNode({ + List children = const [], +}) { + return Node( + type: SimpleTableRowBlockKeys.type, + children: children, + ); +} + +class SimpleTableRowBlockComponentBuilder extends BlockComponentBuilder { + SimpleTableRowBlockComponentBuilder({ + super.configuration, + }); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return SimpleTableRowBlockWidget( + key: node.key, + node: node, + configuration: configuration, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (_) => true; +} + +class SimpleTableRowBlockWidget extends BlockComponentStatefulWidget { + const SimpleTableRowBlockWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => + _SimpleTableRowBlockWidgetState(); +} + +class _SimpleTableRowBlockWidgetState extends State + with + BlockComponentConfigurable, + BlockComponentTextDirectionMixin, + BlockComponentBackgroundColorMixin { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + @override + late EditorState editorState = context.read(); + + @override + Widget build(BuildContext context) { + if (node.children.isEmpty) { + return const SizedBox.shrink(); + } + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _buildCells(), + ), + ); + } + + List _buildCells() { + final List cells = []; + + for (var i = 0; i < node.children.length; i++) { + // border + if (i == 0 && + SimpleTableConstants.borderType == + SimpleTableBorderRenderType.table) { + cells.add(const SimpleTableRowDivider()); + } + + cells.add(editorState.renderer.build(context, node.children[i])); + + // border + if (SimpleTableConstants.borderType == + SimpleTableBorderRenderType.table) { + cells.add(const SimpleTableRowDivider()); + } + } + + return cells; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_down_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_down_command.dart new file mode 100644 index 0000000000000..68e2a1f879782 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_down_command.dart @@ -0,0 +1,81 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +final CommandShortcutEvent arrowDownInTableCell = CommandShortcutEvent( + key: 'Press arrow down in table cell', + getDescription: () => + AppFlowyEditorL10n.current.cmdTableMoveToDownCellAtSameOffset, + command: 'arrow down', + handler: _arrowDownInTableCellHandler, +); + +/// Move the selection to the next cell in the same column. +/// +/// Only handle the case when the selection is in the first line of the cell. +KeyEventResult _arrowDownInTableCellHandler(EditorState editorState) { + final (isInTableCell, selection, tableCellNode, node) = + editorState.isCurrentSelectionInTableCell(); + if (!isInTableCell || + selection == null || + tableCellNode == null || + node == null) { + return KeyEventResult.ignored; + } + + final isInLastLine = node.path.last + 1 == node.parent?.children.length; + if (!isInLastLine) { + return KeyEventResult.ignored; + } + + Selection? newSelection = editorState.selection; + final rowIndex = tableCellNode.rowIndex; + final parentTableNode = tableCellNode.parentTableNode; + if (parentTableNode == null) { + return KeyEventResult.ignored; + } + + if (rowIndex == parentTableNode.rowLength - 1) { + // focus on the next block + final nextNode = tableCellNode.next; + if (nextNode != null) { + final nextFocusableSibling = parentTableNode.getNextFocusableSibling(); + if (nextFocusableSibling != null) { + final length = nextFocusableSibling.delta?.length ?? 0; + newSelection = Selection.collapsed( + Position( + path: nextFocusableSibling.path, + offset: length, + ), + ); + } + } + } else { + // focus on next cell in the same column + final nextCell = tableCellNode.getNextCellInSameColumn(); + if (nextCell != null) { + final offset = selection.end.offset; + // get the first children of the next cell + final firstChild = nextCell.children.firstWhereOrNull( + (c) => c.delta != null, + ); + if (firstChild != null) { + final length = firstChild.delta?.length ?? 0; + newSelection = Selection.collapsed( + Position( + path: firstChild.path, + offset: offset.clamp(0, length), + ), + ); + } + } + } + + if (newSelection != null) { + editorState.updateSelectionWithReason(newSelection); + } + + return KeyEventResult.handled; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_left_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_left_command.dart new file mode 100644 index 0000000000000..f9a80ced5eb09 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_left_command.dart @@ -0,0 +1,19 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +final CommandShortcutEvent arrowLeftInTableCell = CommandShortcutEvent( + key: 'Press arrow left in table cell', + getDescription: () => AppFlowyEditorL10n + .current.cmdTableMoveToRightCellIfItsAtTheEndOfCurrentCell, + command: 'arrow left', + handler: (editorState) => editorState.moveToPreviousCell( + editorState, + (result) { + // only handle the case when the selection is at the beginning of the cell + if (0 != result.$2?.end.offset) { + return false; + } + return true; + }, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_right_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_right_command.dart new file mode 100644 index 0000000000000..196357b5b0356 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_right_command.dart @@ -0,0 +1,19 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +final CommandShortcutEvent arrowRightInTableCell = CommandShortcutEvent( + key: 'Press arrow right in table cell', + getDescription: () => AppFlowyEditorL10n + .current.cmdTableMoveToRightCellIfItsAtTheEndOfCurrentCell, + command: 'arrow right', + handler: (editorState) => editorState.moveToNextCell( + editorState, + (result) { + // only handle the case when the selection is at the end of the cell + final node = result.$4; + final length = node?.delta?.length ?? 0; + final selection = result.$2; + return selection?.end.offset == length; + }, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_up_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_up_command.dart new file mode 100644 index 0000000000000..f6919f3b049b8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_up_command.dart @@ -0,0 +1,77 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +final CommandShortcutEvent arrowUpInTableCell = CommandShortcutEvent( + key: 'Press arrow up in table cell', + getDescription: () => + AppFlowyEditorL10n.current.cmdTableMoveToUpCellAtSameOffset, + command: 'arrow up', + handler: _arrowUpInTableCellHandler, +); + +/// Move the selection to the previous cell in the same column. +/// +/// Only handle the case when the selection is in the first line of the cell. +KeyEventResult _arrowUpInTableCellHandler(EditorState editorState) { + final (isInTableCell, selection, tableCellNode, node) = + editorState.isCurrentSelectionInTableCell(); + if (!isInTableCell || + selection == null || + tableCellNode == null || + node == null) { + return KeyEventResult.ignored; + } + + final isInFirstLine = node.path.last == 0; + if (!isInFirstLine) { + return KeyEventResult.ignored; + } + + Selection? newSelection = editorState.selection; + final rowIndex = tableCellNode.rowIndex; + if (rowIndex == 0) { + // focus on the previous block + final previousNode = tableCellNode.parentTableNode; + if (previousNode != null) { + final previousFocusableSibling = + previousNode.getPreviousFocusableSibling(); + if (previousFocusableSibling != null) { + final length = previousFocusableSibling.delta?.length ?? 0; + newSelection = Selection.collapsed( + Position( + path: previousFocusableSibling.path, + offset: length, + ), + ); + } + } + } else { + // focus on previous cell in the same column + final previousCell = tableCellNode.getPreviousCellInSameColumn(); + if (previousCell != null) { + final offset = selection.end.offset; + // get the last children of the previous cell + final lastChild = previousCell.children.lastWhereOrNull( + (c) => c.delta != null, + ); + if (lastChild != null) { + final length = lastChild.delta?.length ?? 0; + newSelection = Selection.collapsed( + Position( + path: lastChild.path, + offset: offset.clamp(0, length), + ), + ); + } + } + } + + if (newSelection != null) { + editorState.updateSelectionWithReason(newSelection); + } + + return KeyEventResult.handled; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_backspace_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_backspace_command.dart new file mode 100644 index 0000000000000..f4a9bd9946114 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_backspace_command.dart @@ -0,0 +1,48 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter/material.dart'; + +final CommandShortcutEvent backspaceInTableCell = CommandShortcutEvent( + key: 'Press backspace in table cell', + getDescription: () => 'Ignore the backspace key in table cell', + command: 'backspace', + handler: _backspaceInTableCellHandler, +); + +KeyEventResult _backspaceInTableCellHandler(EditorState editorState) { + final (isInTableCell, selection, tableCellNode, node) = + editorState.isCurrentSelectionInTableCell(); + if (!isInTableCell || + selection == null || + tableCellNode == null || + node == null) { + return KeyEventResult.ignored; + } + + final onlyContainsOneChild = tableCellNode.children.length == 1; + final firstChild = tableCellNode.children.first; + final isParagraphNode = firstChild.type == ParagraphBlockKeys.type; + final isCodeBlock = firstChild.type == CodeBlockKeys.type; + if (onlyContainsOneChild && + selection.isCollapsed && + selection.end.offset == 0) { + if (isParagraphNode && firstChild.children.isEmpty) { + return KeyEventResult.skipRemainingHandlers; + } else if (isCodeBlock) { + // replace the codeblock with a paragraph + final transaction = editorState.transaction; + transaction.insertNode(node.path, paragraphNode()); + transaction.deleteNode(node); + transaction.afterSelection = Selection.collapsed( + Position( + path: node.path, + ), + ); + editorState.apply(transaction); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart new file mode 100644 index 0000000000000..1fb8e07603271 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart @@ -0,0 +1,172 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +typedef IsInTableCellResult = ( + bool isInTableCell, + Selection? selection, + Node? tableCellNode, + Node? node, +); + +extension TableCommandExtension on EditorState { + /// Return a tuple, the first element is a boolean indicating whether the current selection is in a table cell, + /// the second element is the node that is the parent of the table cell if the current selection is in a table cell, + /// otherwise it is null. + /// The third element is the node that is the current selection. + IsInTableCellResult isCurrentSelectionInTableCell() { + final selection = this.selection; + if (selection == null) { + return (false, null, null, null); + } + + if (selection.isCollapsed) { + // if the selection is collapsed, check if the node is in a table cell + final node = document.nodeAtPath(selection.end.path); + final tableCellParent = node?.findParent( + (node) => node.type == SimpleTableCellBlockKeys.type, + ); + final isInTableCell = tableCellParent != null; + return (isInTableCell, selection, tableCellParent, node); + } else { + // if the selection is not collapsed, check if the start and end nodes are in a table cell + final startNode = document.nodeAtPath(selection.start.path); + final endNode = document.nodeAtPath(selection.end.path); + final startNodeInTableCell = startNode?.findParent( + (node) => node.type == SimpleTableCellBlockKeys.type, + ); + final endNodeInTableCell = endNode?.findParent( + (node) => node.type == SimpleTableCellBlockKeys.type, + ); + final isInSameTableCell = startNodeInTableCell != null && + endNodeInTableCell != null && + startNodeInTableCell.path.equals(endNodeInTableCell.path); + return (isInSameTableCell, selection, startNodeInTableCell, endNode); + } + } + + /// Move the selection to the previous cell + KeyEventResult moveToPreviousCell( + EditorState editorState, + bool Function(IsInTableCellResult result) shouldHandle, + ) { + final (isInTableCell, selection, tableCellNode, node) = + editorState.isCurrentSelectionInTableCell(); + if (!isInTableCell || + selection == null || + tableCellNode == null || + node == null) { + return KeyEventResult.ignored; + } + + if (!shouldHandle((isInTableCell, selection, tableCellNode, node))) { + return KeyEventResult.ignored; + } + + if (isOutdentable(editorState)) { + return outdentCommand.execute(editorState); + } + + Selection? newSelection; + + final previousCell = tableCellNode.getPreviousCellInSameRow(); + if (previousCell != null && !previousCell.path.equals(tableCellNode.path)) { + // get the last children of the previous cell + final lastChild = previousCell.children.lastWhereOrNull( + (c) => c.delta != null, + ); + if (lastChild != null) { + newSelection = Selection.collapsed( + Position( + path: lastChild.path, + offset: lastChild.delta?.length ?? 0, + ), + ); + } + } else { + // focus on the previous block + final previousNode = tableCellNode.parentTableNode; + if (previousNode != null) { + final previousFocusableSibling = + previousNode.getPreviousFocusableSibling(); + if (previousFocusableSibling != null) { + final length = previousFocusableSibling.delta?.length ?? 0; + newSelection = Selection.collapsed( + Position( + path: previousFocusableSibling.path, + offset: length, + ), + ); + } + } + } + + if (newSelection != null) { + editorState.updateSelectionWithReason(newSelection); + } + + return KeyEventResult.handled; + } + + /// Move the selection to the next cell + KeyEventResult moveToNextCell( + EditorState editorState, + bool Function(IsInTableCellResult result) shouldHandle, + ) { + final (isInTableCell, selection, tableCellNode, node) = + editorState.isCurrentSelectionInTableCell(); + if (!isInTableCell || + selection == null || + tableCellNode == null || + node == null) { + return KeyEventResult.ignored; + } + + if (!shouldHandle((isInTableCell, selection, tableCellNode, node))) { + return KeyEventResult.ignored; + } + + Selection? newSelection; + + if (isIndentable(editorState)) { + return indentCommand.execute(editorState); + } + + final nextCell = tableCellNode.getNextCellInSameRow(); + if (nextCell != null && !nextCell.path.equals(tableCellNode.path)) { + // get the first children of the next cell + final firstChild = nextCell.children.firstWhereOrNull( + (c) => c.delta != null, + ); + if (firstChild != null) { + newSelection = Selection.collapsed( + Position( + path: firstChild.path, + ), + ); + } + } else { + // focus on the previous block + final nextNode = tableCellNode.parentTableNode; + if (nextNode != null) { + final nextFocusableSibling = nextNode.getNextFocusableSibling(); + nextNode.getNextFocusableSibling(); + if (nextFocusableSibling != null) { + newSelection = Selection.collapsed( + Position( + path: nextFocusableSibling.path, + ), + ); + } + } + } + + if (newSelection != null) { + editorState.updateSelectionWithReason(newSelection); + } + + return KeyEventResult.handled; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_commands.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_commands.dart new file mode 100644 index 0000000000000..6fca0a527504c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_commands.dart @@ -0,0 +1,20 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_down_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_left_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_right_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_arrow_up_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_backspace_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_enter_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_select_all_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_tab_command.dart'; + +final simpleTableCommands = [ + arrowUpInTableCell, + arrowDownInTableCell, + arrowLeftInTableCell, + arrowRightInTableCell, + tabInTableCell, + shiftTabInTableCell, + backspaceInTableCell, + selectAllInTableCellCommand, + enterInTableCell, +]; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_enter_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_enter_command.dart new file mode 100644 index 0000000000000..74bdc3fc62084 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_enter_command.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_cell_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +final CommandShortcutEvent enterInTableCell = CommandShortcutEvent( + key: 'Press enter in table cell', + getDescription: () => 'Press the enter key in table cell', + command: 'enter', + handler: _enterInTableCellHandler, +); + +KeyEventResult _enterInTableCellHandler(EditorState editorState) { + final (isInTableCell, selection, tableCellNode, node) = + editorState.isCurrentSelectionInTableCell(); + if (!isInTableCell || + selection == null || + tableCellNode == null || + node == null || + !selection.isCollapsed) { + return KeyEventResult.ignored; + } + + // check if the shift key is pressed, if so, we should return false to let the system handle it. + final isShiftPressed = HardwareKeyboard.instance.isShiftPressed; + if (isShiftPressed) { + return KeyEventResult.ignored; + } + + final delta = node.delta; + if (!indentableBlockTypes.contains(node.type) || delta == null) { + return KeyEventResult.ignored; + } + + if (selection.startIndex == 0 && delta.isEmpty) { + // clear the style + if (node.parent?.type != SimpleTableCellBlockKeys.type) { + if (outdentCommand.execute(editorState) == KeyEventResult.handled) { + return KeyEventResult.handled; + } + } + return convertToParagraphCommand.execute(editorState); + } + + return KeyEventResult.ignored; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_select_all_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_select_all_command.dart new file mode 100644 index 0000000000000..a33b08f66e972 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_select_all_command.dart @@ -0,0 +1,46 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +final CommandShortcutEvent selectAllInTableCellCommand = CommandShortcutEvent( + key: 'Select all contents in table cell', + getDescription: () => 'Select all contents in table cell', + command: 'ctrl+a', + macOSCommand: 'cmd+a', + handler: _selectAllInTableCellHandler, +); + +KeyEventResult _selectAllInTableCellHandler(EditorState editorState) { + final (isInTableCell, selection, tableCellNode, _) = + editorState.isCurrentSelectionInTableCell(); + if (!isInTableCell || selection == null || tableCellNode == null) { + return KeyEventResult.ignored; + } + + final firstFocusableChild = tableCellNode.children.firstWhereOrNull( + (e) => e.delta != null, + ); + final lastFocusableChild = tableCellNode.lastChildWhere( + (e) => e.delta != null, + ); + if (firstFocusableChild == null || lastFocusableChild == null) { + return KeyEventResult.ignored; + } + + final afterSelection = Selection( + start: Position(path: firstFocusableChild.path), + end: Position( + path: lastFocusableChild.path, + offset: lastFocusableChild.delta?.length ?? 0, + ), + ); + + if (afterSelection == editorState.selection) { + // Focus on the cell already + return KeyEventResult.ignored; + } else { + editorState.selection = afterSelection; + return KeyEventResult.handled; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_tab_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_tab_command.dart new file mode 100644 index 0000000000000..93b072fa880e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_tab_command.dart @@ -0,0 +1,35 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_shortcuts/simple_table_command_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_operations/simple_table_operations.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +final CommandShortcutEvent tabInTableCell = CommandShortcutEvent( + key: 'Press tab in table cell', + getDescription: () => 'Move the selection to the next cell', + command: 'tab', + handler: (editorState) => editorState.moveToNextCell( + editorState, + (result) { + final tableCellNode = result.$3; + if (tableCellNode?.isLastCellInTable ?? false) { + return false; + } + return true; + }, + ), +); + +final CommandShortcutEvent shiftTabInTableCell = CommandShortcutEvent( + key: 'Press shift + tab in table cell', + getDescription: () => 'Move the selection to the previous cell', + command: 'shift+tab', + handler: (editorState) => editorState.moveToPreviousCell( + editorState, + (result) { + final tableCellNode = result.$3; + if (tableCellNode?.isFirstCellInTable ?? false) { + return false; + } + return true; + }, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_desktop_simple_table_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_desktop_simple_table_widget.dart new file mode 100644 index 0000000000000..6e640e4561d99 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_desktop_simple_table_widget.dart @@ -0,0 +1,165 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class DesktopSimpleTableWidget extends StatefulWidget { + const DesktopSimpleTableWidget({ + super.key, + required this.simpleTableContext, + required this.node, + this.enableAddColumnButton = true, + this.enableAddRowButton = true, + this.enableAddColumnAndRowButton = true, + this.enableHoverEffect = true, + this.isFeedback = false, + }); + + /// Refer to [SimpleTableWidget.node]. + final Node node; + + /// Refer to [SimpleTableWidget.simpleTableContext]. + final SimpleTableContext simpleTableContext; + + /// Refer to [SimpleTableWidget.enableAddColumnButton]. + final bool enableAddColumnButton; + + /// Refer to [SimpleTableWidget.enableAddRowButton]. + final bool enableAddRowButton; + + /// Refer to [SimpleTableWidget.enableAddColumnAndRowButton]. + final bool enableAddColumnAndRowButton; + + /// Refer to [SimpleTableWidget.enableHoverEffect]. + final bool enableHoverEffect; + + /// Refer to [SimpleTableWidget.isFeedback]. + final bool isFeedback; + + @override + State createState() => + _DesktopSimpleTableWidgetState(); +} + +class _DesktopSimpleTableWidgetState extends State { + SimpleTableContext get simpleTableContext => widget.simpleTableContext; + + final scrollController = ScrollController(); + late final editorState = context.read(); + + @override + void dispose() { + scrollController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.isFeedback ? _buildFeedbackTable() : _buildDesktopTable(); + } + + Widget _buildFeedbackTable() { + return Provider.value( + value: simpleTableContext, + child: IntrinsicWidth( + child: IntrinsicHeight( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildRows(), + ), + ), + ), + ); + } + + Widget _buildDesktopTable() { + // table content + Widget child = Scrollbar( + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + child: Padding( + padding: SimpleTableConstants.tablePadding, + // IntrinsicWidth and IntrinsicHeight are used to make the table size fit the content. + child: IntrinsicWidth( + child: IntrinsicHeight( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildRows(), + ), + ), + ), + ), + ), + ); + + if (widget.enableHoverEffect) { + child = MouseRegion( + onEnter: (event) => + simpleTableContext.isHoveringOnTableArea.value = true, + onExit: (event) { + simpleTableContext.isHoveringOnTableArea.value = false; + }, + child: Provider.value( + value: simpleTableContext, + child: Stack( + children: [ + MouseRegion( + hitTestBehavior: HitTestBehavior.opaque, + onEnter: (event) => + simpleTableContext.isHoveringOnColumnsAndRows.value = true, + onExit: (event) { + simpleTableContext.isHoveringOnColumnsAndRows.value = false; + simpleTableContext.hoveringTableCell.value = null; + }, + child: child, + ), + if (editorState.editable) ...[ + if (widget.enableAddColumnButton) + SimpleTableAddColumnHoverButton( + editorState: editorState, + tableNode: widget.node, + ), + if (widget.enableAddRowButton) + SimpleTableAddRowHoverButton( + editorState: editorState, + tableNode: widget.node, + ), + if (widget.enableAddColumnAndRowButton) + SimpleTableAddColumnAndRowHoverButton( + editorState: editorState, + node: widget.node, + ), + ], + ], + ), + ), + ); + } + + return child; + } + + List _buildRows() { + final List rows = []; + + if (SimpleTableConstants.borderType == SimpleTableBorderRenderType.table) { + rows.add(const SimpleTableColumnDivider()); + } + + for (final child in widget.node.children) { + rows.add(editorState.renderer.build(context, child)); + + if (SimpleTableConstants.borderType == + SimpleTableBorderRenderType.table) { + rows.add(const SimpleTableColumnDivider()); + } + } + + return rows; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_mobile_simple_table_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_mobile_simple_table_widget.dart new file mode 100644 index 0000000000000..41ae29c61c0fc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_mobile_simple_table_widget.dart @@ -0,0 +1,114 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class MobileSimpleTableWidget extends StatefulWidget { + const MobileSimpleTableWidget({ + super.key, + required this.simpleTableContext, + required this.node, + this.enableAddColumnButton = true, + this.enableAddRowButton = true, + this.enableAddColumnAndRowButton = true, + this.enableHoverEffect = true, + this.isFeedback = false, + }); + + /// Refer to [SimpleTableWidget.node]. + final Node node; + + /// Refer to [SimpleTableWidget.simpleTableContext]. + final SimpleTableContext simpleTableContext; + + /// Refer to [SimpleTableWidget.enableAddColumnButton]. + final bool enableAddColumnButton; + + /// Refer to [SimpleTableWidget.enableAddRowButton]. + final bool enableAddRowButton; + + /// Refer to [SimpleTableWidget.enableAddColumnAndRowButton]. + final bool enableAddColumnAndRowButton; + + /// Refer to [SimpleTableWidget.enableHoverEffect]. + final bool enableHoverEffect; + + /// Refer to [SimpleTableWidget.isFeedback]. + final bool isFeedback; + + @override + State createState() => + _MobileSimpleTableWidgetState(); +} + +class _MobileSimpleTableWidgetState extends State { + SimpleTableContext get simpleTableContext => widget.simpleTableContext; + + final scrollController = ScrollController(); + late final editorState = context.read(); + + @override + void dispose() { + scrollController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.isFeedback ? _buildFeedbackTable() : _buildMobileTable(); + } + + Widget _buildFeedbackTable() { + return Provider.value( + value: simpleTableContext, + child: IntrinsicWidth( + child: IntrinsicHeight( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildRows(), + ), + ), + ), + ); + } + + Widget _buildMobileTable() { + return Provider.value( + value: simpleTableContext, + child: SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + child: IntrinsicWidth( + child: IntrinsicHeight( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildRows(), + ), + ), + ), + ), + ); + } + + List _buildRows() { + final List rows = []; + + if (SimpleTableConstants.borderType == SimpleTableBorderRenderType.table) { + rows.add(const SimpleTableColumnDivider()); + } + + for (final child in widget.node.children) { + rows.add(editorState.renderer.build(context, child)); + + if (SimpleTableConstants.borderType == + SimpleTableBorderRenderType.table) { + rows.add(const SimpleTableColumnDivider()); + } + } + + return rows; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart new file mode 100644 index 0000000000000..80fa91e658d25 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart @@ -0,0 +1,1274 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// Base class for all simple table bottom sheet actions +abstract class ISimpleTableBottomSheetActions extends StatelessWidget { + const ISimpleTableBottomSheetActions({ + super.key, + required this.type, + required this.cellNode, + required this.editorState, + }); + + final SimpleTableMoreActionType type; + final Node cellNode; + final EditorState editorState; +} + +/// Quick actions for the table cell +/// +/// - Copy +/// - Paste +/// - Cut +/// - Delete +class SimpleTableCellQuickActions extends ISimpleTableBottomSheetActions { + const SimpleTableCellQuickActions({ + super.key, + required super.type, + required super.cellNode, + required super.editorState, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: SimpleTableConstants.actionSheetQuickActionSectionHeight, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + SimpleTableQuickAction( + type: SimpleTableMoreAction.cut, + onTap: () => _onActionTap( + context, + SimpleTableMoreAction.cut, + ), + ), + SimpleTableQuickAction( + type: SimpleTableMoreAction.copy, + onTap: () => _onActionTap( + context, + SimpleTableMoreAction.copy, + ), + ), + FutureBuilder( + future: getIt().getData(), + builder: (context, snapshot) { + final hasContent = snapshot.data?.tableJson != null; + return SimpleTableQuickAction( + type: SimpleTableMoreAction.paste, + isEnabled: hasContent, + onTap: () => _onActionTap( + context, + SimpleTableMoreAction.paste, + ), + ); + }, + ), + SimpleTableQuickAction( + type: SimpleTableMoreAction.delete, + onTap: () => _onActionTap( + context, + SimpleTableMoreAction.delete, + ), + ), + ], + ), + ); + } + + void _onActionTap(BuildContext context, SimpleTableMoreAction action) { + final tableNode = cellNode.parentTableNode; + if (tableNode == null) { + Log.error('unable to find table node when performing action: $action'); + return; + } + + switch (action) { + case SimpleTableMoreAction.cut: + _onCut(tableNode); + case SimpleTableMoreAction.copy: + _onCopy(tableNode); + case SimpleTableMoreAction.paste: + _onPaste(tableNode); + case SimpleTableMoreAction.delete: + _onDelete(tableNode); + default: + assert(false, 'Unsupported action: $type'); + } + + // close the action menu + Navigator.of(context).pop(); + } + + Future _onCut(Node tableNode) async { + ClipboardServiceData? data; + + switch (type) { + case SimpleTableMoreActionType.column: + data = await editorState.copyColumn( + tableNode: tableNode, + columnIndex: cellNode.columnIndex, + clearContent: true, + ); + case SimpleTableMoreActionType.row: + data = await editorState.copyRow( + tableNode: tableNode, + rowIndex: cellNode.rowIndex, + clearContent: true, + ); + } + + if (data != null) { + await getIt().setData(data); + } + } + + Future _onCopy( + Node tableNode, + ) async { + ClipboardServiceData? data; + + switch (type) { + case SimpleTableMoreActionType.column: + data = await editorState.copyColumn( + tableNode: tableNode, + columnIndex: cellNode.columnIndex, + ); + case SimpleTableMoreActionType.row: + data = await editorState.copyRow( + tableNode: tableNode, + rowIndex: cellNode.rowIndex, + ); + } + + if (data != null) { + await getIt().setData(data); + } + } + + void _onPaste(Node tableNode) { + switch (type) { + case SimpleTableMoreActionType.column: + editorState.pasteColumn( + tableNode: tableNode, + columnIndex: cellNode.columnIndex, + ); + case SimpleTableMoreActionType.row: + editorState.pasteRow( + tableNode: tableNode, + rowIndex: cellNode.rowIndex, + ); + } + } + + void _onDelete(Node tableNode) { + switch (type) { + case SimpleTableMoreActionType.column: + editorState.deleteColumnInTable( + tableNode, + cellNode.columnIndex, + ); + case SimpleTableMoreActionType.row: + editorState.deleteRowInTable( + tableNode, + cellNode.rowIndex, + ); + } + } +} + +class SimpleTableQuickAction extends StatelessWidget { + const SimpleTableQuickAction({ + super.key, + required this.type, + required this.onTap, + this.isEnabled = true, + }); + + final SimpleTableMoreAction type; + final VoidCallback onTap; + final bool isEnabled; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: isEnabled ? 1.0 : 0.5, + child: AnimatedGestureDetector( + onTapUp: isEnabled ? onTap : null, + child: FlowySvg( + type.leftIconSvg, + blendMode: null, + size: const Size.square(24), + ), + ), + ); + } +} + +/// Insert actions +/// +/// - Column: Insert left or insert right +/// - Row: Insert above or insert below +class SimpleTableInsertActions extends ISimpleTableBottomSheetActions { + const SimpleTableInsertActions({ + super.key, + required super.type, + required super.cellNode, + required super.editorState, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + height: SimpleTableConstants.actionSheetInsertSectionHeight, + child: _buildAction(context), + ); + } + + Widget _buildAction(BuildContext context) { + return switch (type) { + SimpleTableMoreActionType.row => Row( + children: [ + SimpleTableInsertAction( + type: SimpleTableMoreAction.insertAbove, + enableLeftBorder: true, + onTap: () => _onActionTap( + context, + SimpleTableMoreAction.insertAbove, + ), + ), + const HSpace(2), + SimpleTableInsertAction( + type: SimpleTableMoreAction.insertBelow, + enableRightBorder: true, + onTap: () => _onActionTap( + context, + SimpleTableMoreAction.insertBelow, + ), + ), + ], + ), + SimpleTableMoreActionType.column => Row( + children: [ + SimpleTableInsertAction( + type: SimpleTableMoreAction.insertLeft, + enableLeftBorder: true, + onTap: () => _onActionTap( + context, + SimpleTableMoreAction.insertLeft, + ), + ), + const HSpace(2), + SimpleTableInsertAction( + type: SimpleTableMoreAction.insertRight, + enableRightBorder: true, + onTap: () => _onActionTap( + context, + SimpleTableMoreAction.insertRight, + ), + ), + ], + ), + }; + } + + void _onActionTap(BuildContext context, SimpleTableMoreAction type) { + final simpleTableContext = context.read(); + final tableNode = cellNode.parentTableNode; + if (tableNode == null) { + Log.error('unable to find table node when performing action: $type'); + return; + } + + switch (type) { + case SimpleTableMoreAction.insertAbove: + // update the highlight status for the selecting row + simpleTableContext.selectingRow.value = cellNode.rowIndex + 1; + editorState.insertRowInTable( + tableNode, + cellNode.rowIndex, + ); + case SimpleTableMoreAction.insertBelow: + editorState.insertRowInTable( + tableNode, + cellNode.rowIndex + 1, + ); + case SimpleTableMoreAction.insertLeft: + // update the highlight status for the selecting column + simpleTableContext.selectingColumn.value = cellNode.columnIndex + 1; + editorState.insertColumnInTable( + tableNode, + cellNode.columnIndex, + ); + case SimpleTableMoreAction.insertRight: + editorState.insertColumnInTable( + tableNode, + cellNode.columnIndex + 1, + ); + default: + assert(false, 'Unsupported action: $type'); + } + } +} + +class SimpleTableInsertAction extends StatelessWidget { + const SimpleTableInsertAction({ + super.key, + required this.type, + this.enableLeftBorder = false, + this.enableRightBorder = false, + required this.onTap, + }); + + final SimpleTableMoreAction type; + final bool enableLeftBorder; + final bool enableRightBorder; + final void Function() onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: DecoratedBox( + decoration: ShapeDecoration( + color: context.simpleTableInsertActionBackgroundColor, + shape: _buildBorder(), + ), + child: AnimatedGestureDetector( + onTapUp: onTap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(1), + child: FlowySvg( + type.leftIconSvg, + size: const Size.square(22), + ), + ), + FlowyText( + type.name, + fontSize: 12, + figmaLineHeight: 16, + ), + ], + ), + ), + ), + ); + } + + RoundedRectangleBorder _buildBorder() { + const radius = Radius.circular( + SimpleTableConstants.actionSheetButtonRadius, + ); + return RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: enableLeftBorder ? radius : Radius.zero, + bottomLeft: enableLeftBorder ? radius : Radius.zero, + topRight: enableRightBorder ? radius : Radius.zero, + bottomRight: enableRightBorder ? radius : Radius.zero, + ), + ); + } +} + +/// Cell Action buttons +/// +/// - Distribute columns evenly +/// - Set to page width +/// - Duplicate row +/// - Duplicate column +/// - Clear contents +class SimpleTableCellActionButtons extends ISimpleTableBottomSheetActions { + const SimpleTableCellActionButtons({ + super.key, + required super.type, + required super.cellNode, + required super.editorState, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: _buildActions(context), + ), + ); + } + + List _buildActions(BuildContext context) { + // the actions are grouped into different sections + // we need to get the index of the table cell node + // and the length of the columns and rows + final (index, columnLength, rowLength) = switch (type) { + SimpleTableMoreActionType.row => ( + cellNode.rowIndex, + cellNode.columnLength, + cellNode.rowLength, + ), + SimpleTableMoreActionType.column => ( + cellNode.columnIndex, + cellNode.rowLength, + cellNode.columnLength, + ), + }; + final actionGroups = type.buildMobileActions( + index: index, + columnLength: columnLength, + rowLength: rowLength, + ); + final List widgets = []; + + for (final (actionGroupIndex, actionGroup) in actionGroups.indexed) { + for (final (index, action) in actionGroup.indexed) { + widgets.add( + // enable the corner border if the cell is the first or last in the group + switch (action) { + SimpleTableMoreAction.enableHeaderColumn => + SimpleTableHeaderActionButton( + type: action, + isEnabled: cellNode.isHeaderColumnEnabled, + onTap: (value) => _onActionTap( + context, + action: action, + toggleHeaderValue: value, + ), + ), + SimpleTableMoreAction.enableHeaderRow => + SimpleTableHeaderActionButton( + type: action, + isEnabled: cellNode.isHeaderRowEnabled, + onTap: (value) => _onActionTap( + context, + action: action, + toggleHeaderValue: value, + ), + ), + _ => SimpleTableActionButton( + type: action, + enableTopBorder: index == 0, + enableBottomBorder: index == actionGroup.length - 1, + onTap: () => _onActionTap( + context, + action: action, + ), + ), + }, + ); + // if the action is not the first or last in the group, add a divider + if (index != actionGroup.length - 1) { + widgets.add(const FlowyDivider()); + } + } + + // add padding to separate the action groups + if (actionGroupIndex != actionGroups.length - 1) { + widgets.add(const VSpace(16)); + } + } + + return widgets; + } + + void _onActionTap( + BuildContext context, { + required SimpleTableMoreAction action, + bool toggleHeaderValue = false, + }) { + final tableNode = cellNode.parentTableNode; + if (tableNode == null) { + Log.error('unable to find table node when performing action: $action'); + return; + } + + switch (action) { + case SimpleTableMoreAction.enableHeaderColumn: + editorState.toggleEnableHeaderColumn( + tableNode: tableNode, + enable: toggleHeaderValue, + ); + case SimpleTableMoreAction.enableHeaderRow: + editorState.toggleEnableHeaderRow( + tableNode: tableNode, + enable: toggleHeaderValue, + ); + case SimpleTableMoreAction.distributeColumnsEvenly: + editorState.distributeColumnWidthToPageWidth(tableNode: tableNode); + case SimpleTableMoreAction.setToPageWidth: + editorState.setColumnWidthToPageWidth(tableNode: tableNode); + case SimpleTableMoreAction.duplicateRow: + editorState.duplicateRowInTable( + tableNode, + cellNode.rowIndex, + ); + case SimpleTableMoreAction.duplicateColumn: + editorState.duplicateColumnInTable( + tableNode, + cellNode.columnIndex, + ); + case SimpleTableMoreAction.clearContents: + switch (type) { + case SimpleTableMoreActionType.column: + editorState.clearContentAtColumnIndex( + tableNode: tableNode, + columnIndex: cellNode.columnIndex, + ); + case SimpleTableMoreActionType.row: + editorState.clearContentAtRowIndex( + tableNode: tableNode, + rowIndex: cellNode.rowIndex, + ); + } + default: + assert(false, 'Unsupported action: $action'); + break; + } + + // close the action menu + Navigator.of(context).pop(); + } +} + +/// Header action button +/// +/// - Enable header column +/// - Enable header row +/// +/// Notes: These actions are only available for the first column or first row +class SimpleTableHeaderActionButton extends StatefulWidget { + const SimpleTableHeaderActionButton({ + super.key, + required this.isEnabled, + required this.type, + this.onTap, + }); + + final bool isEnabled; + final SimpleTableMoreAction type; + final void Function(bool value)? onTap; + + @override + State createState() => + _SimpleTableHeaderActionButtonState(); +} + +class _SimpleTableHeaderActionButtonState + extends State { + bool value = false; + + @override + void initState() { + super.initState(); + + value = widget.isEnabled; + } + + @override + Widget build(BuildContext context) { + return SimpleTableActionButton( + type: widget.type, + enableTopBorder: true, + enableBottomBorder: true, + onTap: _toggle, + rightIconBuilder: (context) { + return Container( + width: 36, + height: 24, + margin: const EdgeInsets.only(right: 16), + child: FittedBox( + fit: BoxFit.fill, + child: CupertinoSwitch( + value: value, + activeColor: Theme.of(context).colorScheme.primary, + onChanged: (_) {}, + ), + ), + ); + }, + ); + } + + void _toggle() { + setState(() { + value = !value; + }); + + widget.onTap?.call(value); + } +} + +/// Align text action button +/// +/// - Align text to left +/// - Align text to center +/// - Align text to right +/// +/// Notes: These actions are only available for the table +class SimpleTableAlignActionButton extends StatefulWidget { + const SimpleTableAlignActionButton({ + super.key, + required this.onTap, + }); + + final VoidCallback onTap; + + @override + State createState() => + _SimpleTableAlignActionButtonState(); +} + +class _SimpleTableAlignActionButtonState + extends State { + @override + Widget build(BuildContext context) { + return SimpleTableActionButton( + type: SimpleTableMoreAction.align, + enableTopBorder: true, + enableBottomBorder: true, + onTap: widget.onTap, + rightIconBuilder: (context) { + return const Padding( + padding: EdgeInsets.only(right: 16), + child: FlowySvg(FlowySvgs.m_aa_arrow_right_s), + ); + }, + ); + } +} + +class SimpleTableActionButton extends StatelessWidget { + const SimpleTableActionButton({ + super.key, + required this.type, + this.enableTopBorder = false, + this.enableBottomBorder = false, + this.rightIconBuilder, + this.onTap, + }); + + final SimpleTableMoreAction type; + final bool enableTopBorder; + final bool enableBottomBorder; + final WidgetBuilder? rightIconBuilder; + final void Function()? onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + height: SimpleTableConstants.actionSheetNormalActionSectionHeight, + decoration: ShapeDecoration( + color: context.simpleTableActionButtonBackgroundColor, + shape: _buildBorder(), + ), + child: Row( + children: [ + const HSpace(16), + Padding( + padding: const EdgeInsets.all(1.0), + child: FlowySvg( + type.leftIconSvg, + size: const Size.square(20), + ), + ), + const HSpace(12), + FlowyText( + type.name, + fontSize: 14, + figmaLineHeight: 20, + ), + const Spacer(), + rightIconBuilder?.call(context) ?? const SizedBox.shrink(), + ], + ), + ), + ); + } + + RoundedRectangleBorder _buildBorder() { + const radius = Radius.circular( + SimpleTableConstants.actionSheetButtonRadius, + ); + return RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: enableTopBorder ? radius : Radius.zero, + topRight: enableTopBorder ? radius : Radius.zero, + bottomLeft: enableBottomBorder ? radius : Radius.zero, + bottomRight: enableBottomBorder ? radius : Radius.zero, + ), + ); + } +} + +class SimpleTableContentActions extends ISimpleTableBottomSheetActions { + const SimpleTableContentActions({ + super.key, + required super.type, + required super.cellNode, + required super.editorState, + required this.onTextColorSelected, + required this.onCellBackgroundColorSelected, + required this.onAlignTap, + this.selectedTextColor, + this.selectedCellBackgroundColor, + this.selectedAlign, + }); + + final VoidCallback onTextColorSelected; + final VoidCallback onCellBackgroundColorSelected; + final ValueChanged onAlignTap; + + final Color? selectedTextColor; + final Color? selectedCellBackgroundColor; + final TableAlign? selectedAlign; + + @override + Widget build(BuildContext context) { + return Container( + height: SimpleTableConstants.actionSheetContentSectionHeight, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + SimpleTableContentBoldAction( + isBold: type == SimpleTableMoreActionType.column + ? cellNode.isInBoldColumn + : cellNode.isInBoldRow, + toggleBold: _toggleBold, + ), + const HSpace(2), + SimpleTableContentTextColorAction( + onTap: onTextColorSelected, + selectedTextColor: selectedTextColor, + ), + const HSpace(2), + SimpleTableContentCellBackgroundColorAction( + onTap: onCellBackgroundColorSelected, + selectedCellBackgroundColor: selectedCellBackgroundColor, + ), + const HSpace(16), + SimpleTableContentAlignmentAction( + align: selectedAlign ?? TableAlign.left, + onTap: onAlignTap, + ), + ], + ), + ); + } + + void _toggleBold(bool isBold) { + switch (type) { + case SimpleTableMoreActionType.column: + editorState.toggleColumnBoldAttribute( + tableCellNode: cellNode, + isBold: isBold, + ); + case SimpleTableMoreActionType.row: + editorState.toggleRowBoldAttribute( + tableCellNode: cellNode, + isBold: isBold, + ); + } + } +} + +class SimpleTableContentBoldAction extends StatefulWidget { + const SimpleTableContentBoldAction({ + super.key, + required this.toggleBold, + required this.isBold, + }); + + final ValueChanged toggleBold; + final bool isBold; + + @override + State createState() => + _SimpleTableContentBoldActionState(); +} + +class _SimpleTableContentBoldActionState + extends State { + bool isBold = false; + + @override + void initState() { + super.initState(); + + isBold = widget.isBold; + } + + @override + Widget build(BuildContext context) { + return Expanded( + child: SimpleTableContentActionDecorator( + backgroundColor: isBold ? Theme.of(context).colorScheme.primary : null, + enableLeftBorder: true, + child: AnimatedGestureDetector( + onTapUp: () { + setState(() { + isBold = !isBold; + }); + widget.toggleBold.call(isBold); + }, + child: FlowySvg( + FlowySvgs.m_aa_bold_s, + size: const Size.square(24), + color: isBold ? Theme.of(context).colorScheme.onPrimary : null, + ), + ), + ), + ); + } +} + +class SimpleTableContentTextColorAction extends StatelessWidget { + const SimpleTableContentTextColorAction({ + super.key, + required this.onTap, + this.selectedTextColor, + }); + + final VoidCallback onTap; + final Color? selectedTextColor; + + @override + Widget build(BuildContext context) { + return Expanded( + child: SimpleTableContentActionDecorator( + child: AnimatedGestureDetector( + onTapUp: onTap, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlowySvg( + FlowySvgs.m_table_text_color_m, + color: selectedTextColor, + ), + const HSpace(10), + const FlowySvg( + FlowySvgs.m_aa_arrow_right_s, + size: Size.square(12), + ), + ], + ), + ), + ), + ); + } +} + +class SimpleTableContentCellBackgroundColorAction extends StatelessWidget { + const SimpleTableContentCellBackgroundColorAction({ + super.key, + required this.onTap, + this.selectedCellBackgroundColor, + }); + + final VoidCallback onTap; + final Color? selectedCellBackgroundColor; + + @override + Widget build(BuildContext context) { + return Expanded( + child: SimpleTableContentActionDecorator( + enableRightBorder: true, + child: AnimatedGestureDetector( + onTapUp: onTap, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildTextBackgroundColorPreview(), + const HSpace(10), + FlowySvg( + FlowySvgs.m_aa_arrow_right_s, + size: const Size.square(12), + color: selectedCellBackgroundColor, + ), + ], + ), + ), + ), + ); + } + + Widget _buildTextBackgroundColorPreview() { + return Container( + width: 24, + height: 24, + decoration: ShapeDecoration( + color: selectedCellBackgroundColor ?? const Color(0xFFFFE6FD), + shape: RoundedRectangleBorder( + side: const BorderSide( + color: Color(0xFFCFD3D9), + ), + borderRadius: BorderRadius.circular(100), + ), + ), + ); + } +} + +class SimpleTableContentAlignmentAction extends StatelessWidget { + const SimpleTableContentAlignmentAction({ + super.key, + this.align = TableAlign.left, + required this.onTap, + }); + + final TableAlign align; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: SimpleTableContentActionDecorator( + enableLeftBorder: true, + enableRightBorder: true, + child: AnimatedGestureDetector( + onTapUp: _onTap, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlowySvg( + align.leftIconSvg, + size: const Size.square(24), + ), + const HSpace(10), + const FlowySvg( + FlowySvgs.m_aa_arrow_right_s, + size: Size.square(12), + ), + ], + ), + ), + ), + ); + } + + void _onTap() { + final nextAlign = switch (align) { + TableAlign.left => TableAlign.center, + TableAlign.center => TableAlign.right, + TableAlign.right => TableAlign.left, + }; + + onTap(nextAlign); + } +} + +class SimpleTableContentActionDecorator extends StatelessWidget { + const SimpleTableContentActionDecorator({ + super.key, + this.enableLeftBorder = false, + this.enableRightBorder = false, + this.backgroundColor, + required this.child, + }); + + final bool enableLeftBorder; + final bool enableRightBorder; + final Color? backgroundColor; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + height: SimpleTableConstants.actionSheetNormalActionSectionHeight, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: ShapeDecoration( + color: + backgroundColor ?? context.simpleTableInsertActionBackgroundColor, + shape: _buildBorder(), + ), + child: child, + ); + } + + RoundedRectangleBorder _buildBorder() { + const radius = Radius.circular( + SimpleTableConstants.actionSheetButtonRadius, + ); + return RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: enableLeftBorder ? radius : Radius.zero, + topRight: enableRightBorder ? radius : Radius.zero, + bottomLeft: enableLeftBorder ? radius : Radius.zero, + bottomRight: enableRightBorder ? radius : Radius.zero, + ), + ); + } +} + +class SimpleTableActionButtons extends StatelessWidget { + const SimpleTableActionButtons({ + super.key, + required this.tableNode, + required this.editorState, + required this.onAlignTap, + }); + + final Node tableNode; + final EditorState editorState; + final VoidCallback onAlignTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: _buildActions(context), + ), + ); + } + + List _buildActions(BuildContext context) { + final actionGroups = [ + [ + SimpleTableMoreAction.setToPageWidth, + SimpleTableMoreAction.distributeColumnsEvenly, + ], + [ + SimpleTableMoreAction.align, + ], + [ + SimpleTableMoreAction.duplicateTable, + SimpleTableMoreAction.copyLinkToBlock, + ] + ]; + final List widgets = []; + + for (final (actionGroupIndex, actionGroup) in actionGroups.indexed) { + for (final (index, action) in actionGroup.indexed) { + widgets.add( + // enable the corner border if the cell is the first or last in the group + switch (action) { + SimpleTableMoreAction.align => SimpleTableAlignActionButton( + onTap: () => _onActionTap( + context, + action: action, + ), + ), + _ => SimpleTableActionButton( + type: action, + enableTopBorder: index == 0, + enableBottomBorder: index == actionGroup.length - 1, + onTap: () => _onActionTap( + context, + action: action, + ), + ), + }, + ); + // if the action is not the first or last in the group, add a divider + if (index != actionGroup.length - 1) { + widgets.add(const FlowyDivider()); + } + } + + // add padding to separate the action groups + if (actionGroupIndex != actionGroups.length - 1) { + widgets.add(const VSpace(16)); + } + } + + return widgets; + } + + void _onActionTap( + BuildContext context, { + required SimpleTableMoreAction action, + }) { + final optionCubit = BlockActionOptionCubit( + editorState: editorState, + blockComponentBuilder: {}, + ); + switch (action) { + case SimpleTableMoreAction.setToPageWidth: + editorState.setColumnWidthToPageWidth(tableNode: tableNode); + case SimpleTableMoreAction.distributeColumnsEvenly: + editorState.distributeColumnWidthToPageWidth(tableNode: tableNode); + case SimpleTableMoreAction.duplicateTable: + optionCubit.handleAction(OptionAction.duplicate, tableNode); + case SimpleTableMoreAction.copyLinkToBlock: + optionCubit.handleAction(OptionAction.copyLinkToBlock, tableNode); + case SimpleTableMoreAction.align: + onAlignTap(); + default: + assert(false, 'Unsupported action: $action'); + break; + } + + // close the action menu + if (action != SimpleTableMoreAction.align) { + Navigator.of(context).pop(); + } + } +} + +class SimpleTableContentAlignAction extends StatefulWidget { + const SimpleTableContentAlignAction({ + super.key, + required this.isSelected, + required this.align, + required this.onTap, + }); + + final bool isSelected; + final VoidCallback onTap; + final TableAlign align; + + @override + State createState() => + _SimpleTableContentAlignActionState(); +} + +class _SimpleTableContentAlignActionState + extends State { + @override + Widget build(BuildContext context) { + return Expanded( + child: SimpleTableContentActionDecorator( + backgroundColor: + widget.isSelected ? Theme.of(context).colorScheme.primary : null, + enableLeftBorder: widget.align == TableAlign.left, + enableRightBorder: widget.align == TableAlign.right, + child: AnimatedGestureDetector( + onTapUp: widget.onTap, + child: FlowySvg( + widget.align.leftIconSvg, + size: const Size.square(24), + color: widget.isSelected + ? Theme.of(context).colorScheme.onPrimary + : null, + ), + ), + ), + ); + } +} + +/// Quick actions for the table +/// +/// - Copy +/// - Paste +/// - Cut +/// - Delete +class SimpleTableQuickActions extends StatelessWidget { + const SimpleTableQuickActions({ + super.key, + required this.tableNode, + required this.editorState, + }); + + final Node tableNode; + final EditorState editorState; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: SimpleTableConstants.actionSheetQuickActionSectionHeight, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + SimpleTableQuickAction( + type: SimpleTableMoreAction.cut, + onTap: () => _onActionTap( + context, + SimpleTableMoreAction.cut, + ), + ), + SimpleTableQuickAction( + type: SimpleTableMoreAction.copy, + onTap: () => _onActionTap( + context, + SimpleTableMoreAction.copy, + ), + ), + FutureBuilder( + future: getIt().getData(), + builder: (context, snapshot) { + final hasContent = snapshot.data?.tableJson != null; + return SimpleTableQuickAction( + type: SimpleTableMoreAction.paste, + isEnabled: hasContent, + onTap: () => _onActionTap( + context, + SimpleTableMoreAction.paste, + ), + ); + }, + ), + SimpleTableQuickAction( + type: SimpleTableMoreAction.delete, + onTap: () => _onActionTap( + context, + SimpleTableMoreAction.delete, + ), + ), + ], + ), + ); + } + + void _onActionTap(BuildContext context, SimpleTableMoreAction action) { + switch (action) { + case SimpleTableMoreAction.cut: + _onCut(tableNode); + case SimpleTableMoreAction.copy: + _onCopy(tableNode); + case SimpleTableMoreAction.paste: + _onPaste(tableNode); + case SimpleTableMoreAction.delete: + _onDelete(tableNode); + default: + assert(false, 'Unsupported action: $action'); + } + + // close the action menu + Navigator.of(context).pop(); + } + + Future _onCut(Node tableNode) async { + final data = await editorState.copyTable( + tableNode: tableNode, + clearContent: true, + ); + if (data != null) { + await getIt().setData(data); + } + } + + Future _onCopy(Node tableNode) async { + final data = await editorState.copyTable( + tableNode: tableNode, + ); + if (data != null) { + await getIt().setData(data); + } + } + + void _onPaste(Node tableNode) => editorState.pasteTable( + tableNode: tableNode, + ); + + void _onDelete(Node tableNode) { + final transaction = editorState.transaction; + transaction.deleteNode(tableNode); + editorState.apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_action_sheet.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_action_sheet.dart new file mode 100644 index 0000000000000..46329e643f7c0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_action_sheet.dart @@ -0,0 +1,236 @@ +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_feedback.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class SimpleTableMobileDraggableReorderButton extends StatelessWidget { + const SimpleTableMobileDraggableReorderButton({ + super.key, + required this.cellNode, + required this.index, + required this.isShowingMenu, + required this.type, + required this.editorState, + required this.simpleTableContext, + }); + + final Node cellNode; + final int index; + final ValueNotifier isShowingMenu; + final SimpleTableMoreActionType type; + final EditorState editorState; + final SimpleTableContext simpleTableContext; + + @override + Widget build(BuildContext context) { + return Draggable( + data: index, + onDragStarted: () => _startDragging(), + onDragUpdate: (details) => _onDragUpdate(details), + onDragEnd: (_) => _stopDragging(), + feedback: SimpleTableFeedback( + editorState: editorState, + node: cellNode, + type: type, + index: index, + ), + child: SimpleTableMobileReorderButton( + index: index, + type: type, + node: cellNode, + isShowingMenu: isShowingMenu, + ), + ); + } + + void _startDragging() { + HapticFeedback.lightImpact(); + + isShowingMenu.value = true; + editorState.selection = null; + + switch (type) { + case SimpleTableMoreActionType.column: + simpleTableContext.isReorderingColumn.value = (true, index); + + case SimpleTableMoreActionType.row: + simpleTableContext.isReorderingRow.value = (true, index); + } + } + + void _onDragUpdate(DragUpdateDetails details) { + simpleTableContext.reorderingOffset.value = details.globalPosition; + } + + void _stopDragging() { + isShowingMenu.value = false; + + switch (type) { + case SimpleTableMoreActionType.column: + _reorderColumn(); + case SimpleTableMoreActionType.row: + _reorderRow(); + } + + simpleTableContext.reorderingOffset.value = Offset.zero; + simpleTableContext.isReorderingHitIndex.value = null; + + switch (type) { + case SimpleTableMoreActionType.column: + simpleTableContext.isReorderingColumn.value = (false, -1); + break; + case SimpleTableMoreActionType.row: + simpleTableContext.isReorderingRow.value = (false, -1); + break; + } + } + + void _reorderColumn() { + final fromIndex = simpleTableContext.isReorderingColumn.value.$2; + final toIndex = simpleTableContext.isReorderingHitIndex.value; + if (toIndex == null) { + return; + } + + editorState.reorderColumn( + cellNode, + fromIndex: fromIndex, + toIndex: toIndex, + ); + } + + void _reorderRow() { + final fromIndex = simpleTableContext.isReorderingRow.value.$2; + final toIndex = simpleTableContext.isReorderingHitIndex.value; + if (toIndex == null) { + return; + } + + editorState.reorderRow( + cellNode, + fromIndex: fromIndex, + toIndex: toIndex, + ); + } +} + +class SimpleTableMobileReorderButton extends StatefulWidget { + const SimpleTableMobileReorderButton({ + super.key, + required this.index, + required this.type, + required this.node, + required this.isShowingMenu, + }); + + final int index; + final SimpleTableMoreActionType type; + final Node node; + final ValueNotifier isShowingMenu; + + @override + State createState() => + _SimpleTableMobileReorderButtonState(); +} + +class _SimpleTableMobileReorderButtonState + extends State { + late final EditorState editorState = context.read(); + late final SimpleTableContext simpleTableContext = + context.read(); + + @override + void initState() { + super.initState(); + + simpleTableContext.selectingRow.addListener(_onUpdateShowingMenu); + simpleTableContext.selectingColumn.addListener(_onUpdateShowingMenu); + } + + @override + void dispose() { + simpleTableContext.selectingRow.removeListener(_onUpdateShowingMenu); + simpleTableContext.selectingColumn.removeListener(_onUpdateShowingMenu); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () async => _onSelecting(), + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: widget.type == SimpleTableMoreActionType.column + ? SimpleTableConstants.columnActionSheetHitTestAreaHeight + : null, + width: widget.type == SimpleTableMoreActionType.row + ? SimpleTableConstants.rowActionSheetHitTestAreaWidth + : null, + child: Align( + child: SimpleTableReorderButton( + isShowingMenu: widget.isShowingMenu, + type: widget.type, + ), + ), + ), + ); + } + + Future _onSelecting() async { + widget.isShowingMenu.value = true; + + // update the selecting row or column + switch (widget.type) { + case SimpleTableMoreActionType.column: + simpleTableContext.selectingColumn.value = widget.index; + simpleTableContext.selectingRow.value = null; + break; + case SimpleTableMoreActionType.row: + simpleTableContext.selectingRow.value = widget.index; + simpleTableContext.selectingColumn.value = null; + } + + editorState.selection = null; + + // show the bottom sheet + await showMobileBottomSheet( + context, + showDragHandle: true, + showDivider: false, + builder: (context) => Provider.value( + value: simpleTableContext, + child: SimpleTableCellBottomSheet( + type: widget.type, + cellNode: widget.node, + editorState: editorState, + ), + ), + ); + + // reset the selecting row or column + simpleTableContext.selectingRow.value = null; + simpleTableContext.selectingColumn.value = null; + + widget.isShowingMenu.value = false; + } + + void _onUpdateShowingMenu() { + // highlight the reorder button when the row or column is selected + final selectingRow = simpleTableContext.selectingRow.value; + final selectingColumn = simpleTableContext.selectingColumn.value; + + if (selectingRow == widget.index && + widget.type == SimpleTableMoreActionType.row) { + widget.isShowingMenu.value = true; + } else if (selectingColumn == widget.index && + widget.type == SimpleTableMoreActionType.column) { + widget.isShowingMenu.value = true; + } else { + widget.isShowingMenu.value = false; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_add_column_and_row_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_add_column_and_row_button.dart new file mode 100644 index 0000000000000..b859895c6dbf5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_add_column_and_row_button.dart @@ -0,0 +1,91 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SimpleTableAddColumnAndRowHoverButton extends StatelessWidget { + const SimpleTableAddColumnAndRowHoverButton({ + super.key, + required this.editorState, + required this.node, + }); + + final EditorState editorState; + final Node node; + + @override + Widget build(BuildContext context) { + assert(node.type == SimpleTableBlockKeys.type); + + if (node.type != SimpleTableBlockKeys.type) { + return const SizedBox.shrink(); + } + + return ValueListenableBuilder( + valueListenable: context.read().isHoveringOnTableArea, + builder: (context, isHoveringOnTableArea, child) { + return ValueListenableBuilder( + valueListenable: context.read().hoveringTableCell, + builder: (context, hoveringTableCell, child) { + bool shouldShow = isHoveringOnTableArea; + if (hoveringTableCell != null && + SimpleTableConstants.enableHoveringLogicV2) { + shouldShow = hoveringTableCell.isLastCellInTable; + } + return shouldShow + ? Positioned( + bottom: + SimpleTableConstants.addColumnAndRowButtonBottomPadding, + right: SimpleTableConstants.addColumnButtonPadding, + child: SimpleTableAddColumnAndRowButton( + onTap: () => editorState.addColumnAndRowInTable(node), + ), + ) + : const SizedBox.shrink(); + }, + ); + }, + ); + } +} + +class SimpleTableAddColumnAndRowButton extends StatelessWidget { + const SimpleTableAddColumnAndRowButton({ + super.key, + this.onTap, + }); + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.document_plugins_simpleTable_clickToAddNewRowAndColumn + .tr(), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + width: SimpleTableConstants.addColumnAndRowButtonWidth, + height: SimpleTableConstants.addColumnAndRowButtonHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + SimpleTableConstants.addColumnAndRowButtonCornerRadius, + ), + color: context.simpleTableMoreActionBackgroundColor, + ), + child: const FlowySvg( + FlowySvgs.add_s, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_add_column_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_add_column_button.dart new file mode 100644 index 0000000000000..5747125b22068 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_add_column_button.dart @@ -0,0 +1,219 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SimpleTableAddColumnHoverButton extends StatefulWidget { + const SimpleTableAddColumnHoverButton({ + super.key, + required this.editorState, + required this.tableNode, + }); + + final EditorState editorState; + final Node tableNode; + + @override + State createState() => + _SimpleTableAddColumnHoverButtonState(); +} + +class _SimpleTableAddColumnHoverButtonState + extends State { + late final interceptorKey = + 'simple_table_add_column_hover_button_${widget.tableNode.id}'; + + SelectionGestureInterceptor? interceptor; + + Offset? startDraggingOffset; + int? initialColumnCount; + + @override + void initState() { + super.initState(); + + interceptor = SelectionGestureInterceptor( + key: interceptorKey, + canTap: (details) => !_isTapInBounds(details.globalPosition), + ); + widget.editorState.service.selectionService + .registerGestureInterceptor(interceptor!); + } + + @override + void dispose() { + widget.editorState.service.selectionService.unregisterGestureInterceptor( + interceptorKey, + ); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert(widget.tableNode.type == SimpleTableBlockKeys.type); + + if (widget.tableNode.type != SimpleTableBlockKeys.type) { + return const SizedBox.shrink(); + } + + return ValueListenableBuilder( + valueListenable: context.read().isHoveringOnTableArea, + builder: (context, isHoveringOnTableArea, _) { + return ValueListenableBuilder( + valueListenable: context.read().hoveringTableCell, + builder: (context, hoveringTableCell, _) { + bool shouldShow = isHoveringOnTableArea; + if (hoveringTableCell != null && + SimpleTableConstants.enableHoveringLogicV2) { + shouldShow = hoveringTableCell.columnIndex + 1 == + hoveringTableCell.columnLength; + } + return Positioned( + top: SimpleTableConstants.tableHitTestTopPadding - + SimpleTableConstants.cellBorderWidth, + bottom: SimpleTableConstants.addColumnButtonBottomPadding, + right: 0, + child: Opacity( + opacity: shouldShow ? 1.0 : 0.0, + child: SimpleTableAddColumnButton( + onTap: () => + widget.editorState.addColumnInTable(widget.tableNode), + onHorizontalDragStart: (details) { + context.read().isDraggingColumn = true; + startDraggingOffset = details.globalPosition; + initialColumnCount = widget.tableNode.columnLength; + }, + onHorizontalDragEnd: (details) { + context.read().isDraggingColumn = false; + }, + onHorizontalDragUpdate: (details) { + _insertColumnInMemory(details); + }, + ), + ), + ); + }, + ); + }, + ); + } + + bool _isTapInBounds(Offset offset) { + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null) { + return false; + } + + final localPosition = renderBox.globalToLocal(offset); + final result = renderBox.paintBounds.contains(localPosition); + + return result; + } + + void _insertColumnInMemory(DragUpdateDetails details) { + if (!SimpleTableConstants.enableDragToExpandTable) { + return; + } + + if (startDraggingOffset == null || initialColumnCount == null) { + return; + } + + // calculate the horizontal offset from the start dragging offset + final horizontalOffset = + details.globalPosition.dx - startDraggingOffset!.dx; + + const columnWidth = SimpleTableConstants.defaultColumnWidth; + final columnDelta = (horizontalOffset / columnWidth).round(); + + // if the change is less than 1 column, skip the operation + if (columnDelta.abs() < 1) { + return; + } + + final firstEmptyColumnFromRight = + widget.tableNode.getFirstEmptyColumnFromRight(); + if (firstEmptyColumnFromRight == null) { + return; + } + + final currentColumnCount = widget.tableNode.columnLength; + final targetColumnCount = initialColumnCount! + columnDelta; + + // There're 3 cases that we don't want to proceed: + // 1. targetColumnCount < 0: the table at least has 1 column + // 2. targetColumnCount == currentColumnCount: the table has no change + // 3. targetColumnCount <= initialColumnCount: the table has less columns than the initial column count + if (targetColumnCount <= 0 || + targetColumnCount == currentColumnCount || + targetColumnCount <= firstEmptyColumnFromRight) { + return; + } + + if (targetColumnCount > currentColumnCount) { + widget.editorState.insertColumnInTable( + widget.tableNode, + targetColumnCount, + inMemoryUpdate: true, + ); + } else { + widget.editorState.deleteColumnInTable( + widget.tableNode, + targetColumnCount, + inMemoryUpdate: true, + ); + } + } +} + +class SimpleTableAddColumnButton extends StatelessWidget { + const SimpleTableAddColumnButton({ + super.key, + this.onTap, + required this.onHorizontalDragStart, + required this.onHorizontalDragEnd, + required this.onHorizontalDragUpdate, + }); + + final VoidCallback? onTap; + final void Function(DragStartDetails) onHorizontalDragStart; + final void Function(DragEndDetails) onHorizontalDragEnd; + final void Function(DragUpdateDetails) onHorizontalDragUpdate; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.document_plugins_simpleTable_clickToAddNewColumn.tr(), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onTap, + onHorizontalDragStart: onHorizontalDragStart, + onHorizontalDragEnd: onHorizontalDragEnd, + onHorizontalDragUpdate: onHorizontalDragUpdate, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + width: SimpleTableConstants.addColumnButtonWidth, + margin: const EdgeInsets.symmetric( + horizontal: SimpleTableConstants.addColumnButtonPadding, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + SimpleTableConstants.addColumnButtonRadius, + ), + color: context.simpleTableMoreActionBackgroundColor, + ), + child: const FlowySvg( + FlowySvgs.add_s, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_add_row_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_add_row_button.dart new file mode 100644 index 0000000000000..f0037bd0af6de --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_add_row_button.dart @@ -0,0 +1,221 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SimpleTableAddRowHoverButton extends StatefulWidget { + const SimpleTableAddRowHoverButton({ + super.key, + required this.editorState, + required this.tableNode, + }); + + final EditorState editorState; + final Node tableNode; + + @override + State createState() => + _SimpleTableAddRowHoverButtonState(); +} + +class _SimpleTableAddRowHoverButtonState + extends State { + late final interceptorKey = + 'simple_table_add_row_hover_button_${widget.tableNode.id}'; + + SelectionGestureInterceptor? interceptor; + + Offset? startDraggingOffset; + int? initialRowCount; + + @override + void initState() { + super.initState(); + + interceptor = SelectionGestureInterceptor( + key: interceptorKey, + canTap: (details) => !_isTapInBounds(details.globalPosition), + ); + widget.editorState.service.selectionService + .registerGestureInterceptor(interceptor!); + } + + @override + void dispose() { + widget.editorState.service.selectionService.unregisterGestureInterceptor( + interceptorKey, + ); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert(widget.tableNode.type == SimpleTableBlockKeys.type); + + if (widget.tableNode.type != SimpleTableBlockKeys.type) { + return const SizedBox.shrink(); + } + + final simpleTableContext = context.read(); + return ValueListenableBuilder( + valueListenable: simpleTableContext.isHoveringOnTableArea, + builder: (context, isHoveringOnTableArea, child) { + return ValueListenableBuilder( + valueListenable: simpleTableContext.hoveringTableCell, + builder: (context, hoveringTableCell, _) { + bool shouldShow = isHoveringOnTableArea; + if (hoveringTableCell != null && + SimpleTableConstants.enableHoveringLogicV2) { + shouldShow = + hoveringTableCell.rowIndex + 1 == hoveringTableCell.rowLength; + } + if (simpleTableContext.isDraggingRow) { + shouldShow = true; + } + return shouldShow ? child! : const SizedBox.shrink(); + }, + ); + }, + child: Positioned( + bottom: 2 * SimpleTableConstants.addRowButtonPadding, + left: SimpleTableConstants.tableLeftPadding - + SimpleTableConstants.cellBorderWidth, + right: SimpleTableConstants.addRowButtonRightPadding, + child: SimpleTableAddRowButton( + onTap: () => widget.editorState.addRowInTable( + widget.tableNode, + ), + onVerticalDragStart: (details) { + context.read().isDraggingRow = true; + startDraggingOffset = details.globalPosition; + initialRowCount = widget.tableNode.children.length; + }, + onVerticalDragEnd: (details) { + context.read().isDraggingRow = false; + }, + onVerticalDragUpdate: (details) { + _insertRowInMemory(details); + }, + ), + ), + ); + } + + bool _isTapInBounds(Offset offset) { + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null) { + return false; + } + + final localPosition = renderBox.globalToLocal(offset); + final result = renderBox.paintBounds.contains(localPosition); + + return result; + } + + void _insertRowInMemory(DragUpdateDetails details) { + if (!SimpleTableConstants.enableDragToExpandTable) { + return; + } + + if (startDraggingOffset == null || initialRowCount == null) { + return; + } + + // calculate the vertical offset from the start dragging offset + final verticalOffset = details.globalPosition.dy - startDraggingOffset!.dy; + + const rowHeight = SimpleTableConstants.defaultRowHeight; + final rowDelta = (verticalOffset / rowHeight).round(); + + // if the change is less than 1 row, skip the operation + if (rowDelta.abs() < 1) { + return; + } + + final firstEmptyRowFromBottom = + widget.tableNode.getFirstEmptyRowFromBottom(); + if (firstEmptyRowFromBottom == null) { + return; + } + + final currentRowCount = widget.tableNode.children.length; + final targetRowCount = initialRowCount! + rowDelta; + + // There're 3 cases that we don't want to proceed: + // 1. targetRowCount < 0: the table at least has 1 row + // 2. targetRowCount == currentRowCount: the table has no change + // 3. targetRowCount <= initialRowCount: the table has less rows than the initial row count + if (targetRowCount <= 0 || + targetRowCount == currentRowCount || + targetRowCount <= firstEmptyRowFromBottom.$1) { + return; + } + + if (targetRowCount > currentRowCount) { + widget.editorState.insertRowInTable( + widget.tableNode, + targetRowCount, + inMemoryUpdate: true, + ); + } else { + widget.editorState.deleteRowInTable( + widget.tableNode, + targetRowCount, + inMemoryUpdate: true, + ); + } + } +} + +class SimpleTableAddRowButton extends StatelessWidget { + const SimpleTableAddRowButton({ + super.key, + this.onTap, + required this.onVerticalDragStart, + required this.onVerticalDragEnd, + required this.onVerticalDragUpdate, + }); + + final VoidCallback? onTap; + final void Function(DragStartDetails) onVerticalDragStart; + final void Function(DragEndDetails) onVerticalDragEnd; + final void Function(DragUpdateDetails) onVerticalDragUpdate; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.document_plugins_simpleTable_clickToAddNewRow.tr(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + onVerticalDragStart: onVerticalDragStart, + onVerticalDragEnd: onVerticalDragEnd, + onVerticalDragUpdate: onVerticalDragUpdate, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + height: SimpleTableConstants.addRowButtonHeight, + margin: const EdgeInsets.symmetric( + vertical: SimpleTableConstants.addColumnButtonPadding, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + SimpleTableConstants.addRowButtonRadius, + ), + color: context.simpleTableMoreActionBackgroundColor, + ), + child: const FlowySvg( + FlowySvgs.add_s, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_align_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_align_button.dart new file mode 100644 index 0000000000000..a042e632ea943 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_align_button.dart @@ -0,0 +1,81 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SimpleTableAlignMenu extends StatefulWidget { + const SimpleTableAlignMenu({ + super.key, + required this.type, + required this.tableCellNode, + this.mutex, + }); + + final SimpleTableMoreActionType type; + final Node tableCellNode; + final PopoverMutex? mutex; + + @override + State createState() => _SimpleTableAlignMenuState(); +} + +class _SimpleTableAlignMenuState extends State { + @override + Widget build(BuildContext context) { + final align = switch (widget.type) { + SimpleTableMoreActionType.column => widget.tableCellNode.columnAlign, + SimpleTableMoreActionType.row => widget.tableCellNode.rowAlign, + }; + return AppFlowyPopover( + mutex: widget.mutex, + child: SimpleTableBasicButton( + leftIconSvg: align.leftIconSvg, + text: LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(), + onTap: () {}, + ), + popupBuilder: (popoverContext) { + void onClose() => PopoverContainer.of(popoverContext).closeAll(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildAlignButton(context, TableAlign.left, onClose), + _buildAlignButton(context, TableAlign.center, onClose), + _buildAlignButton(context, TableAlign.right, onClose), + ], + ); + }, + ); + } + + Widget _buildAlignButton( + BuildContext context, + TableAlign align, + VoidCallback onClose, + ) { + return SimpleTableBasicButton( + leftIconSvg: align.leftIconSvg, + text: align.name, + onTap: () { + switch (widget.type) { + case SimpleTableMoreActionType.column: + context.read().updateColumnAlign( + tableCellNode: widget.tableCellNode, + align: align, + ); + break; + case SimpleTableMoreActionType.row: + context.read().updateRowAlign( + tableCellNode: widget.tableCellNode, + align: align, + ); + break; + } + + onClose(); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_background_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_background_menu.dart new file mode 100644 index 0000000000000..ef55081a14ead --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_background_menu.dart @@ -0,0 +1,104 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SimpleTableBackgroundColorMenu extends StatefulWidget { + const SimpleTableBackgroundColorMenu({ + super.key, + required this.type, + required this.tableCellNode, + this.mutex, + }); + + final SimpleTableMoreActionType type; + final Node tableCellNode; + final PopoverMutex? mutex; + + @override + State createState() => + _SimpleTableBackgroundColorMenuState(); +} + +class _SimpleTableBackgroundColorMenuState + extends State { + @override + Widget build(BuildContext context) { + final theme = AFThemeExtension.of(context); + final backgroundColor = switch (widget.type) { + SimpleTableMoreActionType.row => + widget.tableCellNode.buildRowColor(context), + SimpleTableMoreActionType.column => + widget.tableCellNode.buildColumnColor(context), + }; + return AppFlowyPopover( + mutex: widget.mutex, + popupBuilder: (popoverContext) { + return _buildColorOptionMenu( + context, + theme: theme, + onClose: () => PopoverContainer.of(popoverContext).closeAll(), + ); + }, + direction: PopoverDirection.rightWithCenterAligned, + child: SimpleTableBasicButton( + leftIconBuilder: (onHover) => ColorOptionIcon( + color: backgroundColor ?? Colors.transparent, + ), + text: LocaleKeys.document_plugins_simpleTable_moreActions_color.tr(), + onTap: () {}, + ), + ); + } + + Widget _buildColorOptionMenu( + BuildContext context, { + required AFThemeExtension theme, + required VoidCallback onClose, + }) { + final colors = [ + // reset to default background color + FlowyColorOption( + color: Colors.transparent, + i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(), + id: optionActionColorDefaultColor, + ), + ...FlowyTint.values.map( + (e) => FlowyColorOption( + color: e.color(context, theme: theme), + i18n: e.tintName(AppFlowyEditorL10n.current), + id: e.id, + ), + ), + ]; + + return FlowyColorPicker( + colors: colors, + border: Border.all( + color: theme.onBackground, + ), + onTap: (option, index) { + switch (widget.type) { + case SimpleTableMoreActionType.column: + context.read().updateColumnBackgroundColor( + tableCellNode: widget.tableCellNode, + color: option.id, + ); + break; + case SimpleTableMoreActionType.row: + context.read().updateRowBackgroundColor( + tableCellNode: widget.tableCellNode, + color: option.id, + ); + break; + } + + onClose(); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_basic_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_basic_button.dart new file mode 100644 index 0000000000000..f9df88ccf0f78 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_basic_button.dart @@ -0,0 +1,50 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class SimpleTableBasicButton extends StatelessWidget { + const SimpleTableBasicButton({ + super.key, + required this.text, + required this.onTap, + this.leftIconSvg, + this.leftIconBuilder, + this.rightIcon, + }); + + final FlowySvgData? leftIconSvg; + final String text; + final VoidCallback onTap; + final Widget Function(bool onHover)? leftIconBuilder; + final Widget? rightIcon; + + @override + Widget build(BuildContext context) { + return Container( + height: SimpleTableConstants.moreActionHeight, + padding: SimpleTableConstants.moreActionPadding, + child: FlowyIconTextButton( + margin: SimpleTableConstants.moreActionHorizontalMargin, + leftIconBuilder: _buildLeftIcon, + iconPadding: 10.0, + textBuilder: (onHover) => FlowyText.regular( + text, + fontSize: 14.0, + figmaLineHeight: 18.0, + ), + onTap: onTap, + rightIconBuilder: (onHover) => rightIcon ?? const SizedBox.shrink(), + ), + ); + } + + Widget _buildLeftIcon(bool onHover) { + if (leftIconBuilder != null) { + return leftIconBuilder!(onHover); + } + return leftIconSvg != null + ? FlowySvg(leftIconSvg!) + : const SizedBox.shrink(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_border_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_border_builder.dart new file mode 100644 index 0000000000000..e241f8bf7222f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_border_builder.dart @@ -0,0 +1,279 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class SimpleTableBorderBuilder { + SimpleTableBorderBuilder({ + required this.context, + required this.simpleTableContext, + required this.node, + }); + + final BuildContext context; + final SimpleTableContext simpleTableContext; + final Node node; + + /// Build the border for the cell. + Border? buildBorder({ + bool isEditingCell = false, + }) { + if (SimpleTableConstants.borderType != SimpleTableBorderRenderType.cell) { + return null; + } + + // check if the cell is in the selected column + final isCellInSelectedColumn = + node.columnIndex == simpleTableContext.selectingColumn.value; + + // check if the cell is in the selected row + final isCellInSelectedRow = + node.rowIndex == simpleTableContext.selectingRow.value; + + final isReordering = simpleTableContext.isReordering && + (simpleTableContext.isReorderingColumn.value.$1 || + simpleTableContext.isReorderingRow.value.$1); + + final editorState = context.read(); + final editable = editorState.editable; + + if (!editable) { + return buildCellBorder(); + } else if (isReordering) { + return buildReorderingBorder(); + } else if (simpleTableContext.isSelectingTable.value) { + return buildSelectingTableBorder(); + } else if (isCellInSelectedColumn) { + return buildColumnHighlightBorder(); + } else if (isCellInSelectedRow) { + return buildRowHighlightBorder(); + } else if (isEditingCell) { + return buildEditingBorder(); + } else { + return buildCellBorder(); + } + } + + /// the column border means the `VERTICAL` border of the cell + /// + /// ____ + /// | 1 | 2 | + /// | 3 | 4 | + /// |___| + /// + /// the border wrapping the cell 2 and cell 4 is the column border + Border buildColumnHighlightBorder() { + return Border( + left: _buildHighlightBorderSide(), + right: _buildHighlightBorderSide(), + top: node.rowIndex == 0 + ? _buildHighlightBorderSide() + : _buildLightBorderSide(), + bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength + ? _buildHighlightBorderSide() + : _buildLightBorderSide(), + ); + } + + /// the row border means the `HORIZONTAL` border of the cell + /// + /// ________ + /// | 1 | 2 | + /// |_______| + /// | 3 | 4 | + /// + /// the border wrapping the cell 1 and cell 2 is the row border + Border buildRowHighlightBorder() { + return Border( + top: _buildHighlightBorderSide(), + bottom: _buildHighlightBorderSide(), + left: node.columnIndex == 0 + ? _buildHighlightBorderSide() + : _buildLightBorderSide(), + right: node.columnIndex + 1 == node.parentTableNode?.columnLength + ? _buildHighlightBorderSide() + : _buildLightBorderSide(), + ); + } + + /// Build the border for the reordering state. + /// + /// For example, when reordering a column, we should highlight the border of the + /// current column we're hovering. + Border buildReorderingBorder() { + final isReorderingColumn = simpleTableContext.isReorderingColumn.value.$1; + final isReorderingRow = simpleTableContext.isReorderingRow.value.$1; + + if (isReorderingColumn) { + return _buildColumnReorderingBorder(); + } else if (isReorderingRow) { + return _buildRowReorderingBorder(); + } + + return buildCellBorder(); + } + + /// Build the border for the cell without any state. + Border buildCellBorder() { + return Border( + top: node.rowIndex == 0 + ? _buildDefaultBorderSide() + : _buildLightBorderSide(), + bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength + ? _buildDefaultBorderSide() + : _buildLightBorderSide(), + left: node.columnIndex == 0 + ? _buildDefaultBorderSide() + : _buildLightBorderSide(), + right: node.columnIndex + 1 == node.parentTableNode?.columnLength + ? _buildDefaultBorderSide() + : _buildLightBorderSide(), + ); + } + + /// Build the border for the editing state. + Border buildEditingBorder() { + return Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ); + } + + /// Build the border for the selecting table state. + Border buildSelectingTableBorder() { + final rowIndex = node.rowIndex; + final columnIndex = node.columnIndex; + + return Border( + top: + rowIndex == 0 ? _buildHighlightBorderSide() : _buildLightBorderSide(), + bottom: rowIndex + 1 == node.parentTableNode?.rowLength + ? _buildHighlightBorderSide() + : _buildLightBorderSide(), + left: columnIndex == 0 + ? _buildHighlightBorderSide() + : _buildLightBorderSide(), + right: columnIndex + 1 == node.parentTableNode?.columnLength + ? _buildHighlightBorderSide() + : _buildLightBorderSide(), + ); + } + + Border _buildColumnReorderingBorder() { + assert(simpleTableContext.isReordering); + + final isDraggingInCurrentColumn = + simpleTableContext.isReorderingColumn.value.$2 == node.columnIndex; + // if the dragging column is the current column, don't show the highlight border + if (isDraggingInCurrentColumn) { + return buildCellBorder(); + } + + bool isHitCurrentCell = false; + + if (UniversalPlatform.isDesktop) { + // On desktop, we use the dragging column index to determine the highlight border + // Check if the hovering table cell column index hit the current node column index + isHitCurrentCell = + simpleTableContext.hoveringTableCell.value?.columnIndex == + node.columnIndex; + } else if (UniversalPlatform.isMobile) { + // On mobile, we use the isReorderingHitIndex to determine the highlight border + isHitCurrentCell = + simpleTableContext.isReorderingHitIndex.value == node.columnIndex; + } + + // if the hovering column is not the current column, don't show the highlight border + if (!isHitCurrentCell) { + return buildCellBorder(); + } + + // if the dragging column index is less than the current column index, show the + // highlight border on the left side + final isLeftSide = + simpleTableContext.isReorderingColumn.value.$2 > node.columnIndex; + // if the dragging column index is greater than the current column index, show + // the highlight border on the right side + final isRightSide = + simpleTableContext.isReorderingColumn.value.$2 < node.columnIndex; + + return Border( + top: node.rowIndex == 0 + ? _buildDefaultBorderSide() + : _buildLightBorderSide(), + bottom: node.rowIndex + 1 == node.parentTableNode?.rowLength + ? _buildDefaultBorderSide() + : _buildLightBorderSide(), + left: isLeftSide ? _buildHighlightBorderSide() : _buildLightBorderSide(), + right: + isRightSide ? _buildHighlightBorderSide() : _buildLightBorderSide(), + ); + } + + Border _buildRowReorderingBorder() { + assert(simpleTableContext.isReordering); + + final isDraggingInCurrentRow = + simpleTableContext.isReorderingRow.value.$2 == node.rowIndex; + // if the dragging row is the current row, don't show the highlight border + if (isDraggingInCurrentRow) { + return buildCellBorder(); + } + + bool isHitCurrentCell = false; + + if (UniversalPlatform.isDesktop) { + // On desktop, we use the dragging row index to determine the highlight border + // Check if the hovering table cell row index hit the current node row index + isHitCurrentCell = + simpleTableContext.hoveringTableCell.value?.rowIndex == node.rowIndex; + } else if (UniversalPlatform.isMobile) { + // On mobile, we use the isReorderingHitIndex to determine the highlight border + isHitCurrentCell = + simpleTableContext.isReorderingHitIndex.value == node.rowIndex; + } + + if (!isHitCurrentCell) { + return buildCellBorder(); + } + + // For the row reordering, we only need to update the top and bottom border + final isTopSide = + simpleTableContext.isReorderingRow.value.$2 > node.rowIndex; + final isBottomSide = + simpleTableContext.isReorderingRow.value.$2 < node.rowIndex; + + return Border( + top: isTopSide ? _buildHighlightBorderSide() : _buildLightBorderSide(), + bottom: + isBottomSide ? _buildHighlightBorderSide() : _buildLightBorderSide(), + left: node.columnIndex == 0 + ? _buildDefaultBorderSide() + : _buildLightBorderSide(), + right: node.columnIndex + 1 == node.parentTableNode?.columnLength + ? _buildDefaultBorderSide() + : _buildLightBorderSide(), + ); + } + + BorderSide _buildHighlightBorderSide() { + return BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ); + } + + BorderSide _buildLightBorderSide() { + return BorderSide( + color: context.simpleTableBorderColor, + width: 0.5, + ); + } + + BorderSide _buildDefaultBorderSide() { + return BorderSide( + color: context.simpleTableBorderColor, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_bottom_sheet.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_bottom_sheet.dart new file mode 100644 index 0000000000000..5343e73342e7a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_bottom_sheet.dart @@ -0,0 +1,430 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_color_list.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +enum _SimpleTableBottomSheetMenuState { + cellActionMenu, + textColor, + textBackgroundColor, + tableActionMenu, + align, +} + +/// This bottom sheet is used for the column or row action menu. +/// When selecting a cell and tapping the action menu button around the cell, +/// this bottom sheet will be shown. +/// +/// Note: This widget is only used for mobile. +class SimpleTableCellBottomSheet extends StatefulWidget { + const SimpleTableCellBottomSheet({ + super.key, + required this.type, + required this.cellNode, + required this.editorState, + }); + + final SimpleTableMoreActionType type; + final Node cellNode; + final EditorState editorState; + + @override + State createState() => + _SimpleTableCellBottomSheetState(); +} + +class _SimpleTableCellBottomSheetState + extends State { + _SimpleTableBottomSheetMenuState menuState = + _SimpleTableBottomSheetMenuState.cellActionMenu; + + Color? selectedTextColor; + Color? selectedCellBackgroundColor; + TableAlign? selectedAlign; + + @override + void initState() { + super.initState(); + + selectedTextColor = switch (widget.type) { + SimpleTableMoreActionType.column => + widget.cellNode.textColorInColumn?.tryToColor(), + SimpleTableMoreActionType.row => + widget.cellNode.textColorInRow?.tryToColor(), + }; + + selectedCellBackgroundColor = switch (widget.type) { + SimpleTableMoreActionType.column => + widget.cellNode.buildColumnColor(context), + SimpleTableMoreActionType.row => widget.cellNode.buildRowColor(context), + }; + + selectedAlign = switch (widget.type) { + SimpleTableMoreActionType.column => widget.cellNode.columnAlign, + SimpleTableMoreActionType.row => widget.cellNode.rowAlign, + }; + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // header + _buildHeader(), + + // content + ..._buildContent(), + ], + ); + } + + Widget _buildHeader() { + switch (menuState) { + case _SimpleTableBottomSheetMenuState.cellActionMenu: + return BottomSheetHeader( + showBackButton: false, + showCloseButton: true, + showDoneButton: false, + showRemoveButton: false, + title: widget.type.name.capitalize(), + onClose: () => Navigator.pop(context), + ); + case _SimpleTableBottomSheetMenuState.textColor || + _SimpleTableBottomSheetMenuState.textBackgroundColor: + return BottomSheetHeader( + showBackButton: false, + showCloseButton: true, + showDoneButton: false, + showRemoveButton: false, + title: widget.type.name.capitalize(), + onClose: () => setState(() { + menuState = _SimpleTableBottomSheetMenuState.cellActionMenu; + }), + ); + default: + throw UnimplementedError('Unsupported menu state: $menuState'); + } + } + + List _buildContent() { + switch (menuState) { + case _SimpleTableBottomSheetMenuState.cellActionMenu: + return _buildActionButtons(); + case _SimpleTableBottomSheetMenuState.textColor: + return _buildTextColor(); + case _SimpleTableBottomSheetMenuState.textBackgroundColor: + return _buildTextBackgroundColor(); + default: + throw UnimplementedError('Unsupported menu state: $menuState'); + } + } + + List _buildActionButtons() { + return [ + // copy, cut, paste, delete + SimpleTableCellQuickActions( + type: widget.type, + cellNode: widget.cellNode, + editorState: widget.editorState, + ), + const VSpace(12), + + // insert row, insert column + SimpleTableInsertActions( + type: widget.type, + cellNode: widget.cellNode, + editorState: widget.editorState, + ), + const VSpace(12), + + // content actions + SimpleTableContentActions( + type: widget.type, + cellNode: widget.cellNode, + editorState: widget.editorState, + selectedAlign: selectedAlign, + selectedTextColor: selectedTextColor, + selectedCellBackgroundColor: selectedCellBackgroundColor, + onTextColorSelected: () { + setState(() { + menuState = _SimpleTableBottomSheetMenuState.textColor; + }); + }, + onCellBackgroundColorSelected: () { + setState(() { + menuState = _SimpleTableBottomSheetMenuState.textBackgroundColor; + }); + }, + onAlignTap: _onAlignTap, + ), + const VSpace(16), + + // action buttons + SimpleTableCellActionButtons( + type: widget.type, + cellNode: widget.cellNode, + editorState: widget.editorState, + ), + ]; + } + + List _buildTextColor() { + return [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: FlowyText( + LocaleKeys.document_plugins_simpleTable_moreActions_textColor.tr(), + fontSize: 14.0, + ), + ), + const VSpace(12.0), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: EditorTextColorWidget( + onSelectedColor: _onTextColorSelected, + selectedColor: selectedTextColor, + ), + ), + ]; + } + + List _buildTextBackgroundColor() { + return [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: FlowyText( + LocaleKeys + .document_plugins_simpleTable_moreActions_cellBackgroundColor + .tr(), + fontSize: 14.0, + ), + ), + const VSpace(12.0), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: EditorBackgroundColors( + onSelectedColor: _onCellBackgroundColorSelected, + selectedColor: selectedCellBackgroundColor, + ), + ), + ]; + } + + void _onTextColorSelected(Color color) { + final hex = color.alpha == 0 ? null : color.toHex(); + switch (widget.type) { + case SimpleTableMoreActionType.column: + widget.editorState.updateColumnTextColor( + tableCellNode: widget.cellNode, + color: hex ?? '', + ); + case SimpleTableMoreActionType.row: + widget.editorState.updateRowTextColor( + tableCellNode: widget.cellNode, + color: hex ?? '', + ); + } + + setState(() { + selectedTextColor = color; + }); + } + + void _onCellBackgroundColorSelected(Color color) { + final hex = color.alpha == 0 ? null : color.toHex(); + switch (widget.type) { + case SimpleTableMoreActionType.column: + widget.editorState.updateColumnBackgroundColor( + tableCellNode: widget.cellNode, + color: hex ?? '', + ); + case SimpleTableMoreActionType.row: + widget.editorState.updateRowBackgroundColor( + tableCellNode: widget.cellNode, + color: hex ?? '', + ); + } + + setState(() { + selectedCellBackgroundColor = color; + }); + } + + void _onAlignTap(TableAlign align) { + switch (widget.type) { + case SimpleTableMoreActionType.column: + widget.editorState.updateColumnAlign( + tableCellNode: widget.cellNode, + align: align, + ); + case SimpleTableMoreActionType.row: + widget.editorState.updateRowAlign( + tableCellNode: widget.cellNode, + align: align, + ); + } + + setState(() { + selectedAlign = align; + }); + } +} + +/// This bottom sheet is used for the table action menu. +/// When selecting a table and tapping the action menu button on the top-left corner of the table, +/// this bottom sheet will be shown. +/// +/// Note: This widget is only used for mobile. +class SimpleTableBottomSheet extends StatefulWidget { + const SimpleTableBottomSheet({ + super.key, + required this.tableNode, + required this.editorState, + }); + + final Node tableNode; + final EditorState editorState; + + @override + State createState() => _SimpleTableBottomSheetState(); +} + +class _SimpleTableBottomSheetState extends State { + _SimpleTableBottomSheetMenuState menuState = + _SimpleTableBottomSheetMenuState.tableActionMenu; + + TableAlign? selectedAlign; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // header + _buildHeader(), + + // content + ..._buildContent(), + ], + ); + } + + Widget _buildHeader() { + switch (menuState) { + case _SimpleTableBottomSheetMenuState.tableActionMenu: + return BottomSheetHeader( + showBackButton: false, + showCloseButton: true, + showDoneButton: false, + showRemoveButton: false, + title: LocaleKeys.document_plugins_simpleTable_headerName_table.tr(), + onClose: () => Navigator.pop(context), + ); + case _SimpleTableBottomSheetMenuState.align: + return BottomSheetHeader( + showBackButton: true, + showCloseButton: false, + showDoneButton: true, + showRemoveButton: false, + title: LocaleKeys.document_plugins_simpleTable_headerName_table.tr(), + onBack: () => setState(() { + menuState = _SimpleTableBottomSheetMenuState.tableActionMenu; + }), + onDone: (_) => Navigator.pop(context), + ); + default: + throw UnimplementedError('Unsupported menu state: $menuState'); + } + } + + List _buildContent() { + switch (menuState) { + case _SimpleTableBottomSheetMenuState.tableActionMenu: + return _buildActionButtons(); + case _SimpleTableBottomSheetMenuState.align: + return _buildAlign(); + default: + throw UnimplementedError('Unsupported menu state: $menuState'); + } + } + + List _buildActionButtons() { + return [ + // quick actions + // copy, cut, paste, delete + SimpleTableQuickActions( + tableNode: widget.tableNode, + editorState: widget.editorState, + ), + const VSpace(24), + + // action buttons + SimpleTableActionButtons( + tableNode: widget.tableNode, + editorState: widget.editorState, + onAlignTap: _onTapAlignButton, + ), + ]; + } + + List _buildAlign() { + return [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: Row( + children: [ + _buildAlignButton(TableAlign.left), + const HSpace(2), + _buildAlignButton(TableAlign.center), + const HSpace(2), + _buildAlignButton(TableAlign.right), + ], + ), + ), + ]; + } + + Widget _buildAlignButton(TableAlign align) { + return SimpleTableContentAlignAction( + onTap: () => _onTapAlign(align), + align: align, + isSelected: selectedAlign == align, + ); + } + + void _onTapAlignButton() { + setState(() { + menuState = _SimpleTableBottomSheetMenuState.align; + }); + } + + void _onTapAlign(TableAlign align) { + setState(() { + selectedAlign = align; + }); + + widget.editorState.updateTableAlign( + tableNode: widget.tableNode, + align: align, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_column_resize_handle.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_column_resize_handle.dart new file mode 100644 index 0000000000000..bfa1f316d70ea --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_column_resize_handle.dart @@ -0,0 +1,109 @@ +import 'dart:ui'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SimpleTableColumnResizeHandle extends StatefulWidget { + const SimpleTableColumnResizeHandle({ + super.key, + required this.node, + }); + + final Node node; + + @override + State createState() => + _SimpleTableColumnResizeHandleState(); +} + +class _SimpleTableColumnResizeHandleState + extends State { + late final simpleTableContext = context.read(); + + bool isStartDragging = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + onEnter: (_) => _onEnterHoverArea(), + onExit: (event) => _onExitHoverArea(), + child: GestureDetector( + onHorizontalDragStart: _onHorizontalDragStart, + onHorizontalDragUpdate: _onHorizontalDragUpdate, + onHorizontalDragEnd: _onHorizontalDragEnd, + child: ValueListenableBuilder( + valueListenable: simpleTableContext.hoveringOnResizeHandle, + builder: (context, hoveringOnResizeHandle, child) { + // when reordering a column, the resize handle should not be shown + final isSameRowIndex = hoveringOnResizeHandle?.columnIndex == + widget.node.columnIndex && + !simpleTableContext.isReordering; + return Opacity( + opacity: isSameRowIndex ? 1.0 : 0.0, + child: child, + ); + }, + child: Container( + height: double.infinity, + width: SimpleTableConstants.resizeHandleWidth, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ); + } + + void _onEnterHoverArea() { + simpleTableContext.hoveringOnResizeHandle.value = widget.node; + } + + void _onExitHoverArea() { + Future.delayed(const Duration(milliseconds: 100), () { + // the onExit event will be triggered before dragging started. + // delay the hiding of the resize handle to avoid flickering. + if (!isStartDragging) { + simpleTableContext.hoveringOnResizeHandle.value = null; + } + }); + } + + void _onHorizontalDragStart(DragStartDetails details) { + // disable the two-finger drag on trackpad + if (details.kind == PointerDeviceKind.trackpad) { + return; + } + + isStartDragging = true; + } + + void _onHorizontalDragUpdate(DragUpdateDetails details) { + if (!isStartDragging) { + return; + } + + // only update the column width in memory, + // the actual update will be applied in _onHorizontalDragEnd + context.read().updateColumnWidthInMemory( + tableCellNode: widget.node, + deltaX: details.delta.dx, + ); + } + + void _onHorizontalDragEnd(DragEndDetails details) { + if (!isStartDragging) { + return; + } + + isStartDragging = false; + context.read().hoveringOnResizeHandle.value = null; + + // apply the updated column width + context.read().updateColumnWidth( + tableCellNode: widget.node, + width: widget.node.columnWidth, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_divider.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_divider.dart new file mode 100644 index 0000000000000..0de08d6e75c3f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_divider.dart @@ -0,0 +1,28 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:flutter/material.dart'; + +class SimpleTableRowDivider extends StatelessWidget { + const SimpleTableRowDivider({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return VerticalDivider( + color: context.simpleTableBorderColor, + width: 1.0, + ); + } +} + +class SimpleTableColumnDivider extends StatelessWidget { + const SimpleTableColumnDivider({super.key}); + + @override + Widget build(BuildContext context) { + return Divider( + color: context.simpleTableBorderColor, + height: 1.0, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_feedback.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_feedback.dart new file mode 100644 index 0000000000000..2467d4539508a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_feedback.dart @@ -0,0 +1,137 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SimpleTableFeedback extends StatefulWidget { + const SimpleTableFeedback({ + super.key, + required this.editorState, + required this.node, + required this.type, + required this.index, + }); + + /// The node of the table. + /// Its type must be one of the following: + /// [SimpleTableBlockKeys.type], [SimpleTableRowBlockKeys.type], [SimpleTableCellBlockKeys.type]. + final Node node; + + /// The type of the more action. + /// + /// If the type is [SimpleTableMoreActionType.column], the feedback will use index as column index. + /// If the type is [SimpleTableMoreActionType.row], the feedback will use index as row index. + final SimpleTableMoreActionType type; + + /// The index of the column or row. + final int index; + + final EditorState editorState; + + @override + State createState() => _SimpleTableFeedbackState(); +} + +class _SimpleTableFeedbackState extends State { + final simpleTableContext = SimpleTableContext(); + late final Node dummyNode; + + @override + void initState() { + super.initState(); + + assert( + [ + SimpleTableBlockKeys.type, + SimpleTableRowBlockKeys.type, + SimpleTableCellBlockKeys.type, + ].contains(widget.node.type), + 'The node type must be one of the following: ' + '[SimpleTableBlockKeys.type], [SimpleTableRowBlockKeys.type], [SimpleTableCellBlockKeys.type].', + ); + + simpleTableContext.isSelectingTable.value = true; + dummyNode = _buildDummyNode(); + } + + @override + void dispose() { + simpleTableContext.dispose(); + dummyNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Provider.value( + value: widget.editorState, + child: SimpleTableWidget( + node: dummyNode, + simpleTableContext: simpleTableContext, + enableAddColumnButton: false, + enableAddRowButton: false, + enableAddColumnAndRowButton: false, + enableHoverEffect: false, + isFeedback: true, + ), + ), + ); + } + + /// Build the dummy node for the feedback. + /// + /// For example, + /// + /// If the type is [SimpleTableMoreActionType.row], we should build the dummy table node using the data from the first row of the table node. + /// If the type is [SimpleTableMoreActionType.column], we should build the dummy table node using the data from the first column of the table node. + Node _buildDummyNode() { + // deep copy the table node to avoid mutating the original node + final tableNode = widget.node.parentTableNode?.deepCopy(); + if (tableNode == null) { + return simpleTableBlockNode(children: []); + } + + switch (widget.type) { + case SimpleTableMoreActionType.row: + if (widget.index >= tableNode.rowLength || widget.index < 0) { + return simpleTableBlockNode(children: []); + } + + final row = tableNode.children[widget.index]; + return tableNode.copyWith( + children: [row], + attributes: { + ...tableNode.attributes, + if (widget.index != 0) SimpleTableBlockKeys.enableHeaderRow: false, + }, + ); + case SimpleTableMoreActionType.column: + if (widget.index >= tableNode.columnLength || widget.index < 0) { + return simpleTableBlockNode(children: []); + } + + final rows = tableNode.children.map((row) { + final cell = row.children[widget.index]; + return simpleTableRowBlockNode(children: [cell]); + }).toList(); + + final columnWidth = tableNode.columnWidths[widget.index.toString()] ?? + SimpleTableConstants.defaultColumnWidth; + + return tableNode.copyWith( + children: rows, + attributes: { + ...tableNode.attributes, + SimpleTableBlockKeys.columnWidths: { + '0': columnWidth, + }, + if (widget.index != 0) + SimpleTableBlockKeys.enableHeaderColumn: false, + }, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_more_action_popup.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_more_action_popup.dart new file mode 100644 index 0000000000000..d2e7340790d95 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_more_action_popup.dart @@ -0,0 +1,539 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SimpleTableMoreActionPopup extends StatefulWidget { + const SimpleTableMoreActionPopup({ + super.key, + required this.index, + required this.isShowingMenu, + required this.type, + }); + + final int index; + final ValueNotifier isShowingMenu; + final SimpleTableMoreActionType type; + + @override + State createState() => + _SimpleTableMoreActionPopupState(); +} + +class _SimpleTableMoreActionPopupState + extends State { + late final editorState = context.read(); + + SelectionGestureInterceptor? gestureInterceptor; + + RenderBox? get renderBox => context.findRenderObject() as RenderBox?; + + @override + void initState() { + super.initState(); + + final tableCellNode = + context.read().hoveringTableCell.value; + gestureInterceptor = SelectionGestureInterceptor( + key: 'simple_table_more_action_popup_interceptor_${tableCellNode?.id}', + canTap: (details) => !_isTapInBounds(details.globalPosition), + ); + editorState.service.selectionService.registerGestureInterceptor( + gestureInterceptor!, + ); + } + + @override + void dispose() { + if (gestureInterceptor != null) { + editorState.service.selectionService.unregisterGestureInterceptor( + gestureInterceptor!.key, + ); + } + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final simpleTableContext = context.read(); + final tableCellNode = simpleTableContext.hoveringTableCell.value; + final tableNode = tableCellNode?.parentTableNode; + + if (tableNode == null) { + return const SizedBox.shrink(); + } + + return AppFlowyPopover( + onOpen: () => _onOpen(tableCellNode: tableCellNode), + onClose: () => _onClose(), + direction: widget.type == SimpleTableMoreActionType.row + ? PopoverDirection.bottomWithCenterAligned + : PopoverDirection.bottomWithLeftAligned, + offset: widget.type == SimpleTableMoreActionType.row + ? const Offset(24, 14) + : const Offset(-14, 8), + clickHandler: PopoverClickHandler.gestureDetector, + popupBuilder: (_) => _buildPopup(tableCellNode: tableCellNode), + child: SimpleTableDraggableReorderButton( + editorState: editorState, + simpleTableContext: simpleTableContext, + node: tableNode, + index: widget.index, + isShowingMenu: widget.isShowingMenu, + type: widget.type, + ), + ); + } + + Widget _buildPopup({Node? tableCellNode}) { + if (tableCellNode == null) { + return const SizedBox.shrink(); + } + return MultiProvider( + providers: [ + Provider.value( + value: context.read(), + ), + Provider.value( + value: context.read(), + ), + ], + child: SimpleTableMoreActionList( + type: widget.type, + index: widget.index, + tableCellNode: tableCellNode, + ), + ); + } + + void _onOpen({Node? tableCellNode}) { + widget.isShowingMenu.value = true; + + switch (widget.type) { + case SimpleTableMoreActionType.column: + context.read().selectingColumn.value = + tableCellNode?.columnIndex; + case SimpleTableMoreActionType.row: + context.read().selectingRow.value = + tableCellNode?.rowIndex; + } + + // Workaround to clear the selection after the menu is opened. + Future.delayed(Durations.short3, () { + if (!editorState.isDisposed) { + editorState.selection = null; + } + }); + } + + void _onClose() { + widget.isShowingMenu.value = false; + + // clear the selecting index + context.read().selectingColumn.value = null; + context.read().selectingRow.value = null; + } + + bool _isTapInBounds(Offset offset) { + if (renderBox == null) { + return false; + } + + final localPosition = renderBox!.globalToLocal(offset); + final result = renderBox!.paintBounds.contains(localPosition); + if (result) { + editorState.selection = null; + } + return result; + } +} + +class SimpleTableMoreActionList extends StatefulWidget { + const SimpleTableMoreActionList({ + super.key, + required this.type, + required this.index, + required this.tableCellNode, + this.mutex, + }); + + final SimpleTableMoreActionType type; + final int index; + final Node tableCellNode; + final PopoverMutex? mutex; + + @override + State createState() => + _SimpleTableMoreActionListState(); +} + +class _SimpleTableMoreActionListState extends State { + // ensure the background color menu and align menu exclusive + final mutex = PopoverMutex(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: widget.type + .buildDesktopActions( + index: widget.index, + columnLength: widget.tableCellNode.columnLength, + rowLength: widget.tableCellNode.rowLength, + ) + .map( + (action) => SimpleTableMoreActionItem( + type: widget.type, + action: action, + tableCellNode: widget.tableCellNode, + popoverMutex: mutex, + ), + ) + .toList(), + ); + } +} + +class SimpleTableMoreActionItem extends StatefulWidget { + const SimpleTableMoreActionItem({ + super.key, + required this.type, + required this.action, + required this.tableCellNode, + required this.popoverMutex, + }); + + final SimpleTableMoreActionType type; + final SimpleTableMoreAction action; + final Node tableCellNode; + final PopoverMutex popoverMutex; + + @override + State createState() => + _SimpleTableMoreActionItemState(); +} + +class _SimpleTableMoreActionItemState extends State { + final isEnableHeader = ValueNotifier(false); + + @override + void initState() { + super.initState(); + + _initEnableHeader(); + } + + @override + void dispose() { + isEnableHeader.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.action == SimpleTableMoreAction.divider) { + return _buildDivider(context); + } else if (widget.action == SimpleTableMoreAction.align) { + return _buildAlignMenu(context); + } else if (widget.action == SimpleTableMoreAction.backgroundColor) { + return _buildBackgroundColorMenu(context); + } else if (widget.action == SimpleTableMoreAction.enableHeaderColumn) { + return _buildEnableHeaderButton(context); + } else if (widget.action == SimpleTableMoreAction.enableHeaderRow) { + return _buildEnableHeaderButton(context); + } + + return _buildActionButton(context); + } + + Widget _buildDivider(BuildContext context) { + return const FlowyDivider( + padding: EdgeInsets.symmetric( + vertical: 4.0, + ), + ); + } + + Widget _buildAlignMenu(BuildContext context) { + return SimpleTableAlignMenu( + type: widget.type, + tableCellNode: widget.tableCellNode, + mutex: widget.popoverMutex, + ); + } + + Widget _buildBackgroundColorMenu(BuildContext context) { + return SimpleTableBackgroundColorMenu( + type: widget.type, + tableCellNode: widget.tableCellNode, + mutex: widget.popoverMutex, + ); + } + + Widget _buildEnableHeaderButton(BuildContext context) { + return SimpleTableBasicButton( + text: widget.action.name, + leftIconSvg: widget.action.leftIconSvg, + rightIcon: ValueListenableBuilder( + valueListenable: isEnableHeader, + builder: (context, isEnableHeader, child) { + return Toggle( + value: isEnableHeader, + onChanged: (value) => _toggleEnableHeader(), + padding: EdgeInsets.zero, + ); + }, + ), + onTap: _toggleEnableHeader, + ); + } + + Widget _buildActionButton(BuildContext context) { + return Container( + height: SimpleTableConstants.moreActionHeight, + padding: SimpleTableConstants.moreActionPadding, + child: FlowyIconTextButton( + margin: SimpleTableConstants.moreActionHorizontalMargin, + leftIconBuilder: (onHover) => FlowySvg( + widget.action.leftIconSvg, + color: widget.action == SimpleTableMoreAction.delete && onHover + ? Theme.of(context).colorScheme.error + : null, + ), + iconPadding: 10.0, + textBuilder: (onHover) => FlowyText.regular( + widget.action.name, + fontSize: 14.0, + figmaLineHeight: 18.0, + color: widget.action == SimpleTableMoreAction.delete && onHover + ? Theme.of(context).colorScheme.error + : null, + ), + onTap: _onAction, + ), + ); + } + + void _onAction() { + switch (widget.action) { + case SimpleTableMoreAction.delete: + switch (widget.type) { + case SimpleTableMoreActionType.column: + _deleteColumn(); + break; + case SimpleTableMoreActionType.row: + _deleteRow(); + break; + } + case SimpleTableMoreAction.insertLeft: + _insertColumnLeft(); + case SimpleTableMoreAction.insertRight: + _insertColumnRight(); + case SimpleTableMoreAction.insertAbove: + _insertRowAbove(); + case SimpleTableMoreAction.insertBelow: + _insertRowBelow(); + case SimpleTableMoreAction.clearContents: + _clearContent(); + case SimpleTableMoreAction.duplicate: + switch (widget.type) { + case SimpleTableMoreActionType.column: + _duplicateColumn(); + break; + case SimpleTableMoreActionType.row: + _duplicateRow(); + break; + } + case SimpleTableMoreAction.setToPageWidth: + _setToPageWidth(); + case SimpleTableMoreAction.distributeColumnsEvenly: + _distributeColumnsEvenly(); + default: + break; + } + + PopoverContainer.of(context).close(); + } + + void _setToPageWidth() { + final value = _getTableAndTableCellAndCellPosition(); + if (value == null) { + return; + } + final (table, _, _) = value; + final editorState = context.read(); + editorState.setColumnWidthToPageWidth(tableNode: table); + } + + void _distributeColumnsEvenly() { + final value = _getTableAndTableCellAndCellPosition(); + if (value == null) { + return; + } + final (table, _, _) = value; + final editorState = context.read(); + editorState.distributeColumnWidthToPageWidth(tableNode: table); + } + + void _duplicateRow() { + final value = _getTableAndTableCellAndCellPosition(); + if (value == null) { + return; + } + final (table, node, _) = value; + final editorState = context.read(); + editorState.duplicateRowInTable(table, node.rowIndex); + } + + void _duplicateColumn() { + final value = _getTableAndTableCellAndCellPosition(); + if (value == null) { + return; + } + final (table, node, _) = value; + final editorState = context.read(); + editorState.duplicateColumnInTable(table, node.columnIndex); + } + + void _toggleEnableHeader() { + final value = _getTableAndTableCellAndCellPosition(); + if (value == null) { + return; + } + + isEnableHeader.value = !isEnableHeader.value; + + final (table, _, _) = value; + final editorState = context.read(); + switch (widget.type) { + case SimpleTableMoreActionType.column: + editorState.toggleEnableHeaderColumn( + tableNode: table, + enable: isEnableHeader.value, + ); + case SimpleTableMoreActionType.row: + editorState.toggleEnableHeaderRow( + tableNode: table, + enable: isEnableHeader.value, + ); + } + + PopoverContainer.of(context).close(); + } + + void _clearContent() { + final value = _getTableAndTableCellAndCellPosition(); + if (value == null) { + return; + } + final (table, node, _) = value; + final editorState = context.read(); + if (widget.type == SimpleTableMoreActionType.column) { + editorState.clearContentAtColumnIndex( + tableNode: table, + columnIndex: node.columnIndex, + ); + } else if (widget.type == SimpleTableMoreActionType.row) { + editorState.clearContentAtRowIndex( + tableNode: table, + rowIndex: node.rowIndex, + ); + } + } + + void _insertColumnLeft() { + final value = _getTableAndTableCellAndCellPosition(); + if (value == null) { + return; + } + final (table, node, _) = value; + final columnIndex = node.columnIndex; + final editorState = context.read(); + editorState.insertColumnInTable(table, columnIndex); + } + + void _insertColumnRight() { + final value = _getTableAndTableCellAndCellPosition(); + if (value == null) { + return; + } + final (table, node, _) = value; + final columnIndex = node.columnIndex; + final editorState = context.read(); + editorState.insertColumnInTable(table, columnIndex + 1); + } + + void _insertRowAbove() { + final value = _getTableAndTableCellAndCellPosition(); + if (value == null) { + return; + } + final (table, node, _) = value; + final rowIndex = node.rowIndex; + final editorState = context.read(); + editorState.insertRowInTable(table, rowIndex); + } + + void _insertRowBelow() { + final value = _getTableAndTableCellAndCellPosition(); + if (value == null) { + return; + } + final (table, node, _) = value; + final rowIndex = node.rowIndex; + final editorState = context.read(); + editorState.insertRowInTable(table, rowIndex + 1); + } + + void _deleteRow() { + final value = _getTableAndTableCellAndCellPosition(); + if (value == null) { + return; + } + final (table, node, _) = value; + final rowIndex = node.rowIndex; + final editorState = context.read(); + editorState.deleteRowInTable(table, rowIndex); + } + + void _deleteColumn() { + final value = _getTableAndTableCellAndCellPosition(); + if (value == null) { + return; + } + final (table, node, _) = value; + final columnIndex = node.columnIndex; + final editorState = context.read(); + editorState.deleteColumnInTable(table, columnIndex); + } + + (Node, Node, TableCellPosition)? _getTableAndTableCellAndCellPosition() { + final cell = widget.tableCellNode; + final table = cell.parent?.parent; + if (table == null || table.type != SimpleTableBlockKeys.type) { + return null; + } + return (table, cell, cell.cellPosition); + } + + void _initEnableHeader() { + final value = _getTableAndTableCellAndCellPosition(); + if (value != null) { + final (table, _, _) = value; + if (widget.type == SimpleTableMoreActionType.column) { + isEnableHeader.value = table + .attributes[SimpleTableBlockKeys.enableHeaderColumn] as bool? ?? + false; + } else if (widget.type == SimpleTableMoreActionType.row) { + isEnableHeader.value = + table.attributes[SimpleTableBlockKeys.enableHeaderRow] as bool? ?? + false; + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_reorder_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_reorder_button.dart new file mode 100644 index 0000000000000..bb8719605120a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_reorder_button.dart @@ -0,0 +1,148 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_feedback.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class SimpleTableDraggableReorderButton extends StatelessWidget { + const SimpleTableDraggableReorderButton({ + super.key, + required this.node, + required this.index, + required this.isShowingMenu, + required this.type, + required this.editorState, + required this.simpleTableContext, + }); + + final Node node; + final int index; + final ValueNotifier isShowingMenu; + final SimpleTableMoreActionType type; + final EditorState editorState; + final SimpleTableContext simpleTableContext; + + @override + Widget build(BuildContext context) { + return Draggable( + data: index, + onDragStarted: () => _startDragging(), + onDragUpdate: (details) => _onDragUpdate(details), + onDragEnd: (_) => _stopDragging(), + feedback: SimpleTableFeedback( + editorState: editorState, + node: node, + type: type, + index: index, + ), + child: SimpleTableReorderButton( + isShowingMenu: isShowingMenu, + type: type, + ), + ); + } + + void _startDragging() { + switch (type) { + case SimpleTableMoreActionType.column: + simpleTableContext.isReorderingColumn.value = (true, index); + break; + case SimpleTableMoreActionType.row: + simpleTableContext.isReorderingRow.value = (true, index); + break; + } + } + + void _onDragUpdate(DragUpdateDetails details) { + simpleTableContext.reorderingOffset.value = details.globalPosition; + } + + void _stopDragging() { + switch (type) { + case SimpleTableMoreActionType.column: + _reorderColumn(); + case SimpleTableMoreActionType.row: + _reorderRow(); + } + + simpleTableContext.reorderingOffset.value = Offset.zero; + + switch (type) { + case SimpleTableMoreActionType.column: + simpleTableContext.isReorderingColumn.value = (false, -1); + break; + case SimpleTableMoreActionType.row: + simpleTableContext.isReorderingRow.value = (false, -1); + break; + } + } + + void _reorderColumn() { + final fromIndex = simpleTableContext.isReorderingColumn.value.$2; + final toIndex = simpleTableContext.hoveringTableCell.value?.columnIndex; + if (toIndex == null) { + return; + } + + editorState.reorderColumn( + node, + fromIndex: fromIndex, + toIndex: toIndex, + ); + } + + void _reorderRow() { + final fromIndex = simpleTableContext.isReorderingRow.value.$2; + final toIndex = simpleTableContext.hoveringTableCell.value?.rowIndex; + if (toIndex == null) { + return; + } + + editorState.reorderRow( + node, + fromIndex: fromIndex, + toIndex: toIndex, + ); + } +} + +class SimpleTableReorderButton extends StatelessWidget { + const SimpleTableReorderButton({ + super.key, + required this.isShowingMenu, + required this.type, + }); + + final ValueNotifier isShowingMenu; + final SimpleTableMoreActionType type; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: isShowingMenu, + builder: (context, isShowingMenu, child) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + decoration: BoxDecoration( + color: isShowingMenu + ? context.simpleTableMoreActionHoverColor + : Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: context.simpleTableMoreActionBorderColor, + ), + ), + height: 16.0, + width: 16.0, + child: FlowySvg( + type.reorderIconSvg, + color: isShowingMenu ? Colors.white : null, + size: const Size.square(16.0), + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_widget.dart new file mode 100644 index 0000000000000..7f790825506f4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/simple_table_widget.dart @@ -0,0 +1,79 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '_desktop_simple_table_widget.dart'; +import '_mobile_simple_table_widget.dart'; + +class SimpleTableWidget extends StatefulWidget { + const SimpleTableWidget({ + super.key, + required this.simpleTableContext, + required this.node, + this.enableAddColumnButton = true, + this.enableAddRowButton = true, + this.enableAddColumnAndRowButton = true, + this.enableHoverEffect = true, + this.isFeedback = false, + }); + + /// The node of the table. + /// + /// Its type must be [SimpleTableBlockKeys.type]. + final Node node; + + /// The context of the simple table. + final SimpleTableContext simpleTableContext; + + /// Whether to show the add column button. + /// + /// For the feedback widget builder, it should be false. + final bool enableAddColumnButton; + + /// Whether to show the add row button. + /// + /// For the feedback widget builder, it should be false. + final bool enableAddRowButton; + + /// Whether to show the add column and row button. + /// + /// For the feedback widget builder, it should be false. + final bool enableAddColumnAndRowButton; + + /// Whether to enable the hover effect. + /// + /// For the feedback widget builder, it should be false. + final bool enableHoverEffect; + + /// Whether the widget is a feedback widget. + final bool isFeedback; + + @override + State createState() => _SimpleTableWidgetState(); +} + +class _SimpleTableWidgetState extends State { + @override + Widget build(BuildContext context) { + return UniversalPlatform.isDesktop + ? DesktopSimpleTableWidget( + simpleTableContext: widget.simpleTableContext, + node: widget.node, + enableAddColumnButton: widget.enableAddColumnButton, + enableAddRowButton: widget.enableAddRowButton, + enableAddColumnAndRowButton: widget.enableAddColumnAndRowButton, + enableHoverEffect: widget.enableHoverEffect, + isFeedback: widget.isFeedback, + ) + : MobileSimpleTableWidget( + simpleTableContext: widget.simpleTableContext, + node: widget.node, + enableAddColumnButton: widget.enableAddColumnButton, + enableAddRowButton: widget.enableAddRowButton, + enableAddColumnAndRowButton: widget.enableAddColumnAndRowButton, + enableHoverEffect: widget.enableHoverEffect, + isFeedback: widget.isFeedback, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/widgets.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/widgets.dart new file mode 100644 index 0000000000000..322e3e6a898b1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/widgets.dart @@ -0,0 +1,14 @@ +export 'simple_table_action_sheet.dart'; +export 'simple_table_add_column_and_row_button.dart'; +export 'simple_table_add_column_button.dart'; +export 'simple_table_add_row_button.dart'; +export 'simple_table_align_button.dart'; +export 'simple_table_background_menu.dart'; +export 'simple_table_basic_button.dart'; +export 'simple_table_border_builder.dart'; +export 'simple_table_bottom_sheet.dart'; +export 'simple_table_column_resize_handle.dart'; +export 'simple_table_divider.dart'; +export 'simple_table_more_action_popup.dart'; +export 'simple_table_reorder_button.dart'; +export 'simple_table_widget.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_command.dart new file mode 100644 index 0000000000000..c20fec9d7d301 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_command.dart @@ -0,0 +1,145 @@ +import 'dart:io'; + +import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +typedef SlashMenuItemsBuilder = List Function( + EditorState editorState, + Node node, +); + +/// Show the slash menu +/// +/// - support +/// - desktop +/// +final CharacterShortcutEvent appFlowySlashCommand = CharacterShortcutEvent( + key: 'show the slash menu', + character: '/', + handler: (editorState) async => _showSlashMenu( + editorState, + itemsBuilder: (_, __) => standardSelectionMenuItems, + supportSlashMenuNodeTypes: supportSlashMenuNodeTypes, + ), +); + +CharacterShortcutEvent customAppFlowySlashCommand({ + required SlashMenuItemsBuilder itemsBuilder, + bool shouldInsertSlash = true, + bool deleteKeywordsByDefault = false, + bool singleColumn = true, + SelectionMenuStyle style = SelectionMenuStyle.light, + required Set supportSlashMenuNodeTypes, +}) { + return CharacterShortcutEvent( + key: 'show the slash menu', + character: '/', + handler: (editorState) => _showSlashMenu( + editorState, + shouldInsertSlash: shouldInsertSlash, + deleteKeywordsByDefault: deleteKeywordsByDefault, + singleColumn: singleColumn, + style: style, + supportSlashMenuNodeTypes: supportSlashMenuNodeTypes, + itemsBuilder: itemsBuilder, + ), + ); +} + +SelectionMenuService? _selectionMenuService; +Future _showSlashMenu( + EditorState editorState, { + required SlashMenuItemsBuilder itemsBuilder, + bool shouldInsertSlash = true, + bool singleColumn = true, + bool deleteKeywordsByDefault = false, + SelectionMenuStyle style = SelectionMenuStyle.light, + required Set supportSlashMenuNodeTypes, +}) async { + if (UniversalPlatform.isMobile) { + return false; + } + + final selection = editorState.selection; + if (selection == null) { + return false; + } + + // delete the selection + if (!selection.isCollapsed) { + await editorState.deleteSelection(selection); + } + + final afterSelection = editorState.selection; + if (afterSelection == null || !afterSelection.isCollapsed) { + assert(false, 'the selection should be collapsed'); + return true; + } + + final node = editorState.getNodeAtPath(selection.start.path); + + // only enable in white-list nodes + if (node == null || + !_isSupportSlashMenuNode(node, supportSlashMenuNodeTypes)) { + return false; + } + + final items = itemsBuilder(editorState, node); + + // insert the slash character + if (shouldInsertSlash) { + keepEditorFocusNotifier.increase(); + await editorState.insertTextAtPosition('/', position: selection.start); + } + + // show the slash menu + + final context = editorState.getNodeAtPath(selection.start.path)?.context; + if (context != null && context.mounted) { + _selectionMenuService = SelectionMenu( + context: context, + editorState: editorState, + selectionMenuItems: items, + deleteSlashByDefault: shouldInsertSlash, + deleteKeywordsByDefault: deleteKeywordsByDefault, + singleColumn: singleColumn, + style: style, + ); + if (!kIsWeb && Platform.environment.containsKey('FLUTTER_TEST')) { + await _selectionMenuService?.show(); + } else { + await _selectionMenuService?.show(); + } + } + + if (shouldInsertSlash) { + WidgetsBinding.instance.addPostFrameCallback( + (timeStamp) => keepEditorFocusNotifier.decrease(), + ); + } + + return true; +} + +bool _isSupportSlashMenuNode( + Node node, + Set supportSlashMenuNodeWhiteList, +) { + // Check if current node type is supported + if (!supportSlashMenuNodeWhiteList.contains(node.type)) { + return false; + } + + // If node has a parent and level > 1, recursively check parent nodes + if (node.level > 1 && node.parent != null) { + return _isSupportSlashMenuNode( + node.parent!, + supportSlashMenuNodeWhiteList, + ); + } + + return true; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/ai_writer_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/ai_writer_item.dart new file mode 100644 index 0000000000000..4e3f455522e3c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/ai_writer_item.dart @@ -0,0 +1,55 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'ai', + 'openai', + 'writer', + 'ai writer', + 'autogenerator', +]; + +// auto generate menu item +SelectionMenuItem aiWriterSlashMenuItem = SelectionMenuItem( + getName: LocaleKeys.document_slashMenu_name_aiWriter.tr, + keywords: _keywords, + handler: (editorState, _, __) async => editorState.insertAiWriter(), + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_ai_writer_s, + isSelected: isSelected, + style: style, + ), + nameBuilder: slashMenuItemNameBuilder, +); + +extension on EditorState { + Future insertAiWriter() async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final node = getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + final newNode = aiWriterNode(start: selection); + + final transaction = this.transaction; + //default insert after + final path = node.path.next; + transaction + ..insertNode(path, newNode) + ..afterSelection = null; + await apply( + transaction, + options: const ApplyOptions(inMemoryUpdate: true), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/bulleted_list_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/bulleted_list_item.dart new file mode 100644 index 0000000000000..3fee0d0adb7f8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/bulleted_list_item.dart @@ -0,0 +1,29 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'bulleted list', + 'list', + 'unordered list', + 'ul', +]; + +/// Bulleted list menu item +final bulletedListSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_bulletedList.tr(), + keywords: _keywords, + handler: (editorState, _, __) { + insertBulletedListAfterSelection(editorState); + }, + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_bulleted_list_s, + isSelected: isSelected, + style: style, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/callout_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/callout_item.dart new file mode 100644 index 0000000000000..6f553f5216ea7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/callout_item.dart @@ -0,0 +1,31 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'callout', +]; + +/// Callout menu item +SelectionMenuItem calloutSlashMenuItem = SelectionMenuItem.node( + getName: LocaleKeys.document_plugins_callout.tr, + keywords: _keywords, + nodeBuilder: (editorState, context) => + calloutNode(defaultColor: Colors.transparent), + replace: (_, node) => node.delta?.isEmpty ?? false, + updateSelection: (_, path, __, ___) { + return Selection.single(path: path, startOffset: 0); + }, + nameBuilder: slashMenuItemNameBuilder, + iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_callout_s, + isSelected: isSelected, + style: style, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/code_block_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/code_block_item.dart new file mode 100644 index 0000000000000..44c346a302b7e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/code_block_item.dart @@ -0,0 +1,27 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; + +final _keywords = [ + 'code', + 'code block', + 'codeblock', +]; + +// code block menu item +SelectionMenuItem codeBlockSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_code.tr(), + keywords: _keywords, + nodeBuilder: (_, __) => codeBlockNode(), + replace: (_, node) => node.delta?.isEmpty ?? false, + nameBuilder: slashMenuItemNameBuilder, + iconBuilder: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_code_block_s, + isSelected: isSelected, + style: style, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/database_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/database_items.dart new file mode 100644 index 0000000000000..176b67586ad91 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/database_items.dart @@ -0,0 +1,185 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'slash_menu_item_builder.dart'; + +final _gridKeywords = ['grid', 'database']; +final _kanbanKeywords = ['board', 'kanban', 'database']; +final _calendarKeywords = ['calendar', 'database']; + +final _linkedDocKeywords = [ + 'page', + 'notes', + 'referenced page', + 'referenced document', + 'referenced database', + 'link to database', + 'link to document', + 'link to page', + 'link to grid', + 'link to board', + 'link to calendar', +]; +final _linkedGridKeywords = [ + 'referenced', + 'grid', + 'database', + 'linked', +]; +final _linkedKanbanKeywords = [ + 'referenced', + 'board', + 'kanban', + 'linked', +]; +final _linkedCalendarKeywords = [ + 'referenced', + 'calendar', + 'database', + 'linked', +]; + +/// Grid menu item +SelectionMenuItem gridSlashMenuItem(DocumentBloc documentBloc) { + return SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_grid.tr(), + keywords: _gridKeywords, + handler: (editorState, menuService, context) async { + // create the view inside current page + final parentViewId = documentBloc.documentId; + final value = await ViewBackendService.createView( + parentViewId: parentViewId, + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layoutType: ViewLayoutPB.Grid, + ); + value.map((r) => editorState.insertInlinePage(parentViewId, r)); + }, + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_grid_s, + isSelected: onSelected, + style: style, + ), + ); +} + +SelectionMenuItem kanbanSlashMenuItem(DocumentBloc documentBloc) { + return SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_kanban.tr(), + keywords: _kanbanKeywords, + handler: (editorState, menuService, context) async { + // create the view inside current page + final parentViewId = documentBloc.documentId; + final value = await ViewBackendService.createView( + parentViewId: parentViewId, + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layoutType: ViewLayoutPB.Board, + ); + value.map((r) => editorState.insertInlinePage(parentViewId, r)); + }, + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_kanban_s, + isSelected: onSelected, + style: style, + ), + ); +} + +SelectionMenuItem calendarSlashMenuItem(DocumentBloc documentBloc) { + return SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_calendar.tr(), + keywords: _calendarKeywords, + handler: (editorState, menuService, context) async { + // create the view inside current page + final parentViewId = documentBloc.documentId; + final value = await ViewBackendService.createView( + parentViewId: parentViewId, + name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + layoutType: ViewLayoutPB.Calendar, + ); + value.map((r) => editorState.insertInlinePage(parentViewId, r)); + }, + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_calendar_s, + isSelected: onSelected, + style: style, + ), + ); +} + +// linked doc menu item +final linkToPageSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_linkedDoc.tr(), + keywords: _linkedDocKeywords, + handler: (editorState, menuService, context) => showLinkToPageMenu( + editorState, + menuService, + // enable database and document references + insertPage: false, + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_doc_s, + isSelected: isSelected, + style: style, + ), +); + +// linked grid & board & calendar menu item +SelectionMenuItem referencedGridSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_linkedGrid.tr(), + keywords: _linkedGridKeywords, + handler: (editorState, menuService, context) => showLinkToPageMenu( + editorState, + menuService, + pageType: ViewLayoutPB.Grid, + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_grid_s, + isSelected: onSelected, + style: style, + ), +); + +SelectionMenuItem referencedKanbanSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_linkedKanban.tr(), + keywords: _linkedKanbanKeywords, + handler: (editorState, menuService, context) => showLinkToPageMenu( + editorState, + menuService, + pageType: ViewLayoutPB.Board, + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_kanban_s, + isSelected: onSelected, + style: style, + ), +); + +SelectionMenuItem referencedCalendarSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_linkedCalendar.tr(), + keywords: _linkedCalendarKeywords, + handler: (editorState, menuService, context) => showLinkToPageMenu( + editorState, + menuService, + pageType: ViewLayoutPB.Calendar, + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, onSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_calendar_s, + isSelected: onSelected, + style: style, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/date_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/date_item.dart new file mode 100644 index 0000000000000..0bb09d0a7e2f0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/date_item.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +final _keywords = [ + 'insert date', + 'date', + 'time', + 'reminder', + 'schedule', +]; + +// date or reminder menu item +SelectionMenuItem dateOrReminderSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => editorState.insertDateReference(), + nameBuilder: slashMenuItemNameBuilder, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_date_or_reminder_s, + isSelected: isSelected, + style: style, + ), +); + +extension on EditorState { + Future insertDateReference() async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + final transaction = this.transaction + ..replaceText( + node, + selection.start.offset, + 0, + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.date.name, + MentionBlockKeys.date: DateTime.now().toIso8601String(), + }, + }, + ); + + await apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/divider_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/divider_item.dart new file mode 100644 index 0000000000000..a6c4001a68e63 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/divider_item.dart @@ -0,0 +1,49 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'divider', + 'separator', + 'line', + 'break', + 'horizontal line', +]; + +/// Divider menu item +final dividerSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_divider.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => editorState.insertDividerBlock(), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_divider_s, + isSelected: isSelected, + style: style, + ), +); + +extension on EditorState { + Future insertDividerBlock() async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final path = selection.end.path; + final node = getNodeAtPath(path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + final insertedPath = delta.isEmpty ? path : path.next; + final transaction = this.transaction + ..insertNode(insertedPath, dividerNode()) + ..insertNode(insertedPath, paragraphNode()) + ..afterSelection = Selection.collapsed(Position(path: insertedPath.next)); + await apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/emoji_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/emoji_item.dart new file mode 100644 index 0000000000000..df1c457cc2aca --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/emoji_item.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'emoji', + 'reaction', + 'emoticon', +]; + +// emoji menu item +SelectionMenuItem emojiSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_emoji.tr(), + keywords: _keywords, + handler: (editorState, menuService, context) => editorState.showEmojiPicker( + context, + menuService: menuService, + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_emoji_picker_s, + isSelected: isSelected, + style: style, + ), +); + +extension on EditorState { + Future showEmojiPicker( + BuildContext context, { + required SelectionMenuService menuService, + }) async { + final container = Overlay.of(context); + menuService.dismiss(); + showEmojiPickerMenu( + container, + this, + menuService.alignment, + menuService.offset, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/file_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/file_item.dart new file mode 100644 index 0000000000000..64c529bee8293 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/file_item.dart @@ -0,0 +1,42 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'slash_menu_items.dart'; + +final _keywords = [ + 'file upload', + 'pdf', + 'zip', + 'archive', + 'upload', + 'attachment', +]; + +// file menu item +SelectionMenuItem fileSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_file.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => editorState.insertFileBlock(), + nameBuilder: slashMenuItemNameBuilder, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_file_s, + isSelected: isSelected, + style: style, + ), +); + +extension on EditorState { + Future insertFileBlock() async { + final fileGlobalKey = GlobalKey(); + await insertEmptyFileBlock(fileGlobalKey); + + WidgetsBinding.instance.addPostFrameCallback((_) { + fileGlobalKey.currentState?.controller.show(); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/heading_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/heading_items.dart new file mode 100644 index 0000000000000..115ef22abed24 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/heading_items.dart @@ -0,0 +1,139 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'slash_menu_item_builder.dart'; + +final _h1Keywords = [ + 'heading 1', + 'h1', + 'heading1', +]; +final _h2Keywords = [ + 'heading 2', + 'h2', + 'heading2', +]; +final _h3Keywords = [ + 'heading 3', + 'h3', + 'heading3', +]; + +final _toggleH1Keywords = [ + 'toggle heading 1', + 'toggle h1', + 'toggle heading1', + 'toggleheading1', + 'toggleh1', +]; +final _toggleH2Keywords = [ + 'toggle heading 2', + 'toggle h2', + 'toggle heading2', + 'toggleheading2', + 'toggleh2', +]; +final _toggleH3Keywords = [ + 'toggle heading 3', + 'toggle h3', + 'toggle heading3', + 'toggleheading3', + 'toggleh3', +]; + +// heading 1 - 3 menu items +final heading1SlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_heading1.tr(), + keywords: _h1Keywords, + handler: (editorState, _, __) async => insertHeadingAfterSelection( + editorState, + 1, + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_h1_s, + isSelected: isSelected, + style: style, + ), +); + +final heading2SlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_heading2.tr(), + keywords: _h2Keywords, + handler: (editorState, _, __) async => insertHeadingAfterSelection( + editorState, + 2, + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_h2_s, + isSelected: isSelected, + style: style, + ), +); + +final heading3SlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_heading3.tr(), + keywords: _h3Keywords, + handler: (editorState, _, __) async => insertHeadingAfterSelection( + editorState, + 3, + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_h3_s, + isSelected: isSelected, + style: style, + ), +); + +// toggle heading 1 menu item +// heading 1 - 3 menu items +final toggleHeading1SlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_toggleHeading1.tr(), + keywords: _toggleH1Keywords, + handler: (editorState, _, __) async => insertNodeAfterSelection( + editorState, + toggleHeadingNode(), + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.toggle_heading1_s, + isSelected: isSelected, + style: style, + ), +); + +final toggleHeading2SlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_toggleHeading2.tr(), + keywords: _toggleH2Keywords, + handler: (editorState, _, __) async => insertNodeAfterSelection( + editorState, + toggleHeadingNode(level: 2), + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.toggle_heading2_s, + isSelected: isSelected, + style: style, + ), +); + +final toggleHeading3SlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_toggleHeading3.tr(), + keywords: _toggleH3Keywords, + handler: (editorState, _, __) async => insertNodeAfterSelection( + editorState, + toggleHeadingNode(level: 3), + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.toggle_heading3_s, + isSelected: isSelected, + style: style, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/image_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/image_item.dart new file mode 100644 index 0000000000000..f0ce852e41ed5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/image_item.dart @@ -0,0 +1,42 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'image', + 'photo', + 'picture', + 'img', +]; + +/// Image menu item +final imageSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_image.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => editorState.insertImageBlock(), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_image_s, + isSelected: isSelected, + style: style, + ), +); + +extension on EditorState { + Future insertImageBlock() async { + // use the key to retrieve the state of the image block to show the popover automatically + final imagePlaceholderKey = GlobalKey(); + await insertEmptyImageBlock(imagePlaceholderKey); + + WidgetsBinding.instance.addPostFrameCallback((_) { + imagePlaceholderKey.currentState?.controller.show(); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/math_equation_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/math_equation_item.dart new file mode 100644 index 0000000000000..3274e4ab959eb --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/math_equation_item.dart @@ -0,0 +1,42 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'tex', + 'latex', + 'katex', + 'math equation', + 'formula', +]; + +// math equation +SelectionMenuItem mathEquationSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_mathEquation.tr(), + keywords: _keywords, + nodeBuilder: (editorState, _) => mathEquationNode(), + replace: (_, node) => node.delta?.isEmpty ?? false, + updateSelection: (editorState, path, __, ___) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + final mathEquationState = + editorState.getNodeAtPath(path)?.key.currentState; + if (mathEquationState != null && + mathEquationState is MathEquationBlockComponentWidgetState) { + mathEquationState.showEditingDialog(); + } + }); + return null; + }, + nameBuilder: slashMenuItemNameBuilder, + iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_math_equation_s, + isSelected: isSelected, + style: style, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/numbered_list_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/numbered_list_item.dart new file mode 100644 index 0000000000000..2bb04156d67bc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/numbered_list_item.dart @@ -0,0 +1,29 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'numbered list', + 'list', + 'ordered list', + 'ol', +]; + +/// Numbered list menu item +final numberedListSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_numberedList.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => insertNumberedListAfterSelection( + editorState, + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_numbered_list_s, + isSelected: isSelected, + style: style, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/outline_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/outline_item.dart new file mode 100644 index 0000000000000..795f27bc0fbc2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/outline_item.dart @@ -0,0 +1,75 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'outline', + 'table of contents', + 'toc', + 'tableofcontents', +]; + +/// Outline menu item +SelectionMenuItem outlineSlashMenuItem = SelectionMenuItem( + getName: LocaleKeys.document_selectionMenu_outline.tr, + keywords: _keywords, + handler: (editorState, _, __) async => editorState.insertOutline(), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, onSelected, style) { + return Icon( + Icons.list_alt, + color: onSelected + ? style.selectionMenuItemSelectedIconColor + : style.selectionMenuItemIconColor, + size: 16.0, + ); + }, +); + +extension on EditorState { + Future insertOutline() async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + final transaction = this.transaction; + final bReplace = node.delta?.isEmpty ?? false; + + //default insert after + var path = node.path.next; + if (bReplace) { + path = node.path; + } + + final nextNode = getNodeAtPath(path.next); + + transaction + ..insertNodes( + path, + [ + outlineBlockNode(), + if (nextNode == null || nextNode.delta == null) paragraphNode(), + ], + ) + ..afterSelection = Selection.collapsed( + Position(path: path.next), + ); + + if (bReplace) { + transaction.deleteNode(node); + } + + await apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/paragraph_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/paragraph_item.dart new file mode 100644 index 0000000000000..5ad8df64b0a90 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/paragraph_item.dart @@ -0,0 +1,28 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'text', + 'paragraph', +]; + +// paragraph menu item +final paragraphSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_text.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => insertNodeAfterSelection( + editorState, + paragraphNode(), + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_text_s, + isSelected: isSelected, + style: style, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/photo_gallery_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/photo_gallery_item.dart new file mode 100644 index 0000000000000..95ef1a123b993 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/photo_gallery_item.dart @@ -0,0 +1,42 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'slash_menu_items.dart'; + +final _keywords = [ + LocaleKeys.document_plugins_photoGallery_imageKeyword.tr(), + LocaleKeys.document_plugins_photoGallery_imageGalleryKeyword.tr(), + LocaleKeys.document_plugins_photoGallery_photoKeyword.tr(), + LocaleKeys.document_plugins_photoGallery_photoBrowserKeyword.tr(), + LocaleKeys.document_plugins_photoGallery_galleryKeyword.tr(), +]; + +// photo gallery menu item +SelectionMenuItem photoGallerySlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_photoGallery.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => editorState.insertPhotoGalleryBlock(), + nameBuilder: slashMenuItemNameBuilder, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_photo_gallery_s, + isSelected: isSelected, + style: style, + ), +); + +extension on EditorState { + Future insertPhotoGalleryBlock() async { + final imagePlaceholderKey = GlobalKey(); + await insertEmptyMultiImageBlock(imagePlaceholderKey); + + WidgetsBinding.instance.addPostFrameCallback( + (_) => imagePlaceholderKey.currentState?.controller.show(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/quote_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/quote_item.dart new file mode 100644 index 0000000000000..7c7f887a67917 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/quote_item.dart @@ -0,0 +1,27 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'quote', + 'refer', + 'blockquote', + 'citation', +]; + +/// Quote menu item +final quoteSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_quote.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => insertQuoteAfterSelection(editorState), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_quote_s, + isSelected: isSelected, + style: style, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_table_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_table_item.dart new file mode 100644 index 0000000000000..1b05d70091817 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/simple_table_item.dart @@ -0,0 +1,74 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'table', + 'rows', + 'columns', + 'data', +]; + +// table menu item +SelectionMenuItem tableSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_table.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => editorState.insertSimpleTable(), + nameBuilder: slashMenuItemNameBuilder, + icon: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_simple_table_s, + isSelected: isSelected, + style: style, + ), +); + +extension on EditorState { + Future insertSimpleTable() async { + final selection = this.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final currentNode = getNodeAtPath(selection.end.path); + if (currentNode == null) { + return; + } + + // create a simple table with 2 columns and 2 rows + final tableNode = createSimpleTableBlockNode( + columnCount: 2, + rowCount: 2, + ); + + final transaction = this.transaction; + final delta = currentNode.delta; + if (delta != null && delta.isEmpty) { + final path = selection.end.path; + transaction + ..insertNode(path, tableNode) + ..deleteNode(currentNode); + transaction.afterSelection = Selection.collapsed( + Position( + // table -> row -> cell -> paragraph + path: path + [0, 0, 0], + ), + ); + } else { + final path = selection.end.path.next; + transaction.insertNode(path, tableNode); + transaction.afterSelection = Selection.collapsed( + Position( + // table -> row -> cell -> paragraph + path: path + [0, 0, 0], + ), + ); + } + + await apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart new file mode 100644 index 0000000000000..85f8a6895bcd0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_item_builder.dart @@ -0,0 +1,89 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +/// Builder function for the slash menu item. +Widget slashMenuItemNameBuilder( + String name, + SelectionMenuStyle style, + bool isSelected, +) { + return SlashMenuItemNameBuilder( + name: name, + style: style, + isSelected: isSelected, + ); +} + +Widget slashMenuItemIconBuilder( + FlowySvgData data, + bool isSelected, + SelectionMenuStyle style, +) { + return SelectableSvgWidget( + data: data, + isSelected: isSelected, + style: style, + ); +} + +/// Build the name of the slash menu item. +class SlashMenuItemNameBuilder extends StatelessWidget { + const SlashMenuItemNameBuilder({ + super.key, + required this.name, + required this.style, + required this.isSelected, + }); + + /// The name of the slash menu item. + final String name; + + /// The style of the slash menu item. + final SelectionMenuStyle style; + + /// Whether the slash menu item is selected. + final bool isSelected; + + @override + Widget build(BuildContext context) { + return FlowyText.regular( + name, + fontSize: 12.0, + figmaLineHeight: 15.0, + color: isSelected + ? style.selectionMenuItemSelectedTextColor + : style.selectionMenuItemTextColor, + ); + } +} + +/// Build the icon of the slash menu item. +class SlashMenuIconBuilder extends StatelessWidget { + const SlashMenuIconBuilder({ + super.key, + required this.data, + required this.isSelected, + required this.style, + }); + + /// The data of the icon. + final FlowySvgData data; + + /// Whether the slash menu item is selected. + final bool isSelected; + + /// The style of the slash menu item. + final SelectionMenuStyle style; + + @override + Widget build(BuildContext context) { + return SelectableSvgWidget( + data: data, + isSelected: isSelected, + style: style, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_items.dart new file mode 100644 index 0000000000000..ee76bc0ea3fc6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/slash_menu_items.dart @@ -0,0 +1,22 @@ +export 'ai_writer_item.dart'; +export 'bulleted_list_item.dart'; +export 'callout_item.dart'; +export 'code_block_item.dart'; +export 'database_items.dart'; +export 'date_item.dart'; +export 'divider_item.dart'; +export 'emoji_item.dart'; +export 'file_item.dart'; +export 'heading_items.dart'; +export 'image_item.dart'; +export 'math_equation_item.dart'; +export 'numbered_list_item.dart'; +export 'outline_item.dart'; +export 'paragraph_item.dart'; +export 'photo_gallery_item.dart'; +export 'quote_item.dart'; +export 'simple_table_item.dart'; +export 'slash_menu_item_builder.dart'; +export 'sub_page_item.dart'; +export 'todo_list_item.dart'; +export 'toggle_list_item.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/sub_page_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/sub_page_item.dart new file mode 100644 index 0000000000000..bc7f7e46b49b9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/sub_page_item.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'slash_menu_items.dart'; + +final _keywords = [ + LocaleKeys.document_slashMenu_subPage_keyword1.tr(), + LocaleKeys.document_slashMenu_subPage_keyword2.tr(), + LocaleKeys.document_slashMenu_subPage_keyword3.tr(), + LocaleKeys.document_slashMenu_subPage_keyword4.tr(), + LocaleKeys.document_slashMenu_subPage_keyword5.tr(), + LocaleKeys.document_slashMenu_subPage_keyword6.tr(), + LocaleKeys.document_slashMenu_subPage_keyword7.tr(), + LocaleKeys.document_slashMenu_subPage_keyword8.tr(), +]; + +// Sub-page menu item +SelectionMenuItem subPageSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_subPage_name.tr(), + keywords: _keywords, + updateSelection: (editorState, path, __, ___) { + final context = editorState.document.root.context; + if (context != null) { + final isInDatabase = + context.read().isInDatabaseRowPage; + if (isInDatabase) { + Navigator.of(context).pop(); + } + } + return Selection.collapsed(Position(path: path)); + }, + replace: (_, node) => node.delta?.isEmpty ?? false, + nodeBuilder: (_, __) => subPageNode(), + nameBuilder: slashMenuItemNameBuilder, + iconBuilder: (_, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.insert_document_s, + isSelected: isSelected, + style: style, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/todo_list_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/todo_list_item.dart new file mode 100644 index 0000000000000..74ffa7e0759f9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/todo_list_item.dart @@ -0,0 +1,29 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'checkbox', + 'todo', + 'list', + 'to-do', + 'task', +]; + +final todoListSlashMenuItem = SelectionMenuItem( + getName: () => LocaleKeys.document_slashMenu_name_todoList.tr(), + keywords: _keywords, + handler: (editorState, _, __) async => insertCheckboxAfterSelection( + editorState, + ), + nameBuilder: slashMenuItemNameBuilder, + icon: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_checkbox_s, + isSelected: isSelected, + style: style, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/toggle_list_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/toggle_list_item.dart new file mode 100644 index 0000000000000..d93dd3c7380a9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/toggle_list_item.dart @@ -0,0 +1,29 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'slash_menu_item_builder.dart'; + +final _keywords = [ + 'collapsed list', + 'toggle list', + 'list', + 'dropdown', +]; + +// toggle menu item +SelectionMenuItem toggleListSlashMenuItem = SelectionMenuItem.node( + getName: () => LocaleKeys.document_slashMenu_name_toggleList.tr(), + keywords: _keywords, + nodeBuilder: (editorState, _) => toggleListBlockNode(), + replace: (_, node) => node.delta?.isEmpty ?? false, + nameBuilder: slashMenuItemNameBuilder, + iconBuilder: (editorState, isSelected, style) => SelectableSvgWidget( + data: FlowySvgs.slash_menu_icon_toggle_s, + isSelected: isSelected, + style: style, + ), +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart new file mode 100644 index 0000000000000..3e9bdef3556d7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart @@ -0,0 +1,179 @@ +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +import 'slash_menu_items/slash_menu_items.dart'; + +/// Build slash menu items +/// +List slashMenuItemsBuilder({ + bool isLocalMode = false, + DocumentBloc? documentBloc, + EditorState? editorState, + Node? node, +}) { + final isInTable = node != null && node.parentTableCellNode != null; + + if (isInTable) { + return _simpleTableSlashMenuItems(); + } else { + return _defaultSlashMenuItems( + isLocalMode: isLocalMode, + documentBloc: documentBloc, + ); + } +} + +/// The default slash menu items are used in the text-based block. +/// +/// Except for the simple table block, the slash menu items in the table block are +/// built by the `tableSlashMenuItem` function. +/// If in local mode, disable the ai writer feature +/// +/// The linked database relies on the documentBloc, so it's required to pass in +/// the documentBloc when building the slash menu items. If the documentBloc is +/// not provided, the linked database items will be disabled. +/// +/// +List _defaultSlashMenuItems({ + bool isLocalMode = false, + DocumentBloc? documentBloc, +}) { + return [ + // disable ai writer in local mode + if (!isLocalMode) aiWriterSlashMenuItem, + + paragraphSlashMenuItem, + + // heading 1-3 + heading1SlashMenuItem, + heading2SlashMenuItem, + heading3SlashMenuItem, + + // image + imageSlashMenuItem, + + // list + bulletedListSlashMenuItem, + numberedListSlashMenuItem, + todoListSlashMenuItem, + + // divider + dividerSlashMenuItem, + + // quote + quoteSlashMenuItem, + + // simple table + tableSlashMenuItem, + + // link to page + linkToPageSlashMenuItem, + + // grid + if (documentBloc != null) gridSlashMenuItem(documentBloc), + referencedGridSlashMenuItem, + + // kanban + if (documentBloc != null) kanbanSlashMenuItem(documentBloc), + referencedKanbanSlashMenuItem, + + // calendar + if (documentBloc != null) calendarSlashMenuItem(documentBloc), + referencedCalendarSlashMenuItem, + + // callout + calloutSlashMenuItem, + + // outline + outlineSlashMenuItem, + + // math equation + mathEquationSlashMenuItem, + + // code block + codeBlockSlashMenuItem, + + // toggle list - toggle headings + toggleListSlashMenuItem, + toggleHeading1SlashMenuItem, + toggleHeading2SlashMenuItem, + toggleHeading3SlashMenuItem, + + // emoji + emojiSlashMenuItem, + + // date or reminder + dateOrReminderSlashMenuItem, + + // photo gallery + photoGallerySlashMenuItem, + + // file + fileSlashMenuItem, + + // sub page + subPageSlashMenuItem, + ]; +} + +/// The slash menu items in the simple table block. +/// +/// There're some blocks should be excluded in the slash menu items. +/// +/// - Database Items +/// - Image Gallery +List _simpleTableSlashMenuItems() { + return [ + paragraphSlashMenuItem, + + // heading 1-3 + heading1SlashMenuItem, + heading2SlashMenuItem, + heading3SlashMenuItem, + + // image + imageSlashMenuItem, + + // list + bulletedListSlashMenuItem, + numberedListSlashMenuItem, + todoListSlashMenuItem, + + // divider + dividerSlashMenuItem, + + // quote + quoteSlashMenuItem, + + // link to page + linkToPageSlashMenuItem, + + // callout + calloutSlashMenuItem, + + // math equation + mathEquationSlashMenuItem, + + // code block + codeBlockSlashMenuItem, + + // toggle list - toggle headings + toggleListSlashMenuItem, + toggleHeading1SlashMenuItem, + toggleHeading2SlashMenuItem, + toggleHeading3SlashMenuItem, + + // emoji + emojiSlashMenuItem, + + // date or reminder + dateOrReminderSlashMenuItem, + + // file + fileSlashMenuItem, + + // sub page + subPageSlashMenuItem, + ]; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/block_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/block_transaction_handler.dart new file mode 100644 index 0000000000000..35ff1f219bf04 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/block_transaction_handler.dart @@ -0,0 +1,299 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/block_transaction_handler/block_transaction_handler.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart'; +import 'package:appflowy/plugins/trash/application/trash_service.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class SubPageBlockTransactionHandler extends BlockTransactionHandler { + SubPageBlockTransactionHandler() : super(blockType: SubPageBlockKeys.type); + + final List _beingCreated = []; + + @override + void onRedo( + BuildContext context, + EditorState editorState, + List before, + List after, + ) { + _handleUndoRedo(context, editorState, before, after); + } + + @override + void onUndo( + BuildContext context, + EditorState editorState, + List before, + List after, + ) { + _handleUndoRedo(context, editorState, before, after); + } + + void _handleUndoRedo( + BuildContext context, + EditorState editorState, + List before, + List after, + ) { + final additions = after.where((e) => !before.contains(e)).toList(); + final removals = before.where((e) => !after.contains(e)).toList(); + + // Removals goes to trash + for (final node in removals) { + _subPageDeleted(context, editorState, node); + } + + // Additions are moved to this view + for (final node in additions) { + _subPageAdded(context, editorState, node); + } + } + + @override + Future onTransaction( + BuildContext context, + EditorState editorState, + List added, + List removed, { + bool isUndoRedo = false, + bool isPaste = false, + bool isDraggingNode = false, + String? parentViewId, + }) async { + if (isDraggingNode) { + return; + } + + for (final node in removed) { + if (!context.mounted) return; + await _subPageDeleted(context, editorState, node); + } + + for (final node in added) { + if (!context.mounted) return; + await _subPageAdded( + context, + editorState, + node, + isPaste: isPaste, + parentViewId: parentViewId, + ); + } + } + + Future _subPageDeleted( + BuildContext context, + EditorState editorState, + Node node, + ) async { + if (node.type != blockType) { + return; + } + + final view = node.attributes[SubPageBlockKeys.viewId]; + if (view == null) { + return; + } + + // We move the view to Trash + final result = await ViewBackendService.deleteView(viewId: view); + result.fold( + (_) {}, + (error) { + Log.error(error); + if (context.mounted) { + showSnapBar( + context, + LocaleKeys.document_plugins_subPage_errors_failedDeletePage.tr(), + ); + } + }, + ); + } + + Future _subPageAdded( + BuildContext context, + EditorState editorState, + Node node, { + bool isPaste = false, + String? parentViewId, + }) async { + if (node.type != blockType || _beingCreated.contains(node.id)) { + return; + } + + final viewId = node.attributes[SubPageBlockKeys.viewId]; + if (viewId == null && parentViewId != null) { + _beingCreated.add(node.id); + + // This is a new Node, we need to create the view + final viewOrResult = await ViewBackendService.createView( + layoutType: ViewLayoutPB.Document, + parentViewId: parentViewId, + name: '', + ); + + await viewOrResult.fold( + (view) async { + final transaction = editorState.transaction + ..updateNode(node, {SubPageBlockKeys.viewId: view.id}); + await editorState + .apply( + transaction, + withUpdateSelection: false, + options: const ApplyOptions(recordUndo: false), + ) + .then((_) async { + editorState.reload(); + + // Open the new page + if (UniversalPlatform.isDesktop) { + getIt().openPlugin(view); + } else { + await context.pushView(view); + } + }); + }, + (error) async { + Log.error(error); + showSnapBar( + context, + LocaleKeys.document_plugins_subPage_errors_failedCreatePage.tr(), + ); + + // Remove the node because it failed + final transaction = editorState.transaction..deleteNode(node); + await editorState.apply( + transaction, + withUpdateSelection: false, + options: const ApplyOptions(recordUndo: false), + ); + }, + ); + + _beingCreated.remove(node.id); + } else if (isPaste) { + final wasCut = node.attributes[SubPageBlockKeys.wasCut]; + if (wasCut == true && parentViewId != null) { + // Just in case, we try to put back from trash before moving + await TrashService.putback(viewId); + + final viewOrResult = await ViewBackendService.moveViewV2( + viewId: viewId, + newParentId: parentViewId, + prevViewId: null, + ); + + viewOrResult.fold( + (_) {}, + (error) { + Log.error(error); + showSnapBar( + context, + LocaleKeys.document_plugins_subPage_errors_failedMovePage.tr(), + ); + }, + ); + } else { + final viewId = node.attributes[SubPageBlockKeys.viewId]; + if (viewId == null) { + return; + } + + final viewOrResult = await ViewBackendService.getView(viewId); + return viewOrResult.fold( + (view) async { + final duplicatedViewOrResult = await ViewBackendService.duplicate( + view: view, + openAfterDuplicate: false, + includeChildren: true, + syncAfterDuplicate: true, + parentViewId: parentViewId, + ); + + return duplicatedViewOrResult.fold( + (view) async { + final transaction = editorState.transaction + ..updateNode(node, { + SubPageBlockKeys.viewId: view.id, + SubPageBlockKeys.wasCut: false, + SubPageBlockKeys.wasCopied: false, + }); + await editorState.apply( + transaction, + withUpdateSelection: false, + options: const ApplyOptions(recordUndo: false), + ); + editorState.reload(); + }, + (error) { + Log.error(error); + if (context.mounted) { + showSnapBar( + context, + LocaleKeys + .document_plugins_subPage_errors_failedDuplicatePage + .tr(), + ); + } + }, + ); + }, + (error) async { + Log.error(error); + + final transaction = editorState.transaction..deleteNode(node); + await editorState.apply( + transaction, + withUpdateSelection: false, + options: const ApplyOptions(recordUndo: false), + ); + editorState.reload(); + if (context.mounted) { + showSnapBar( + context, + LocaleKeys + .document_plugins_subPage_errors_failedDuplicateFindView + .tr(), + ); + } + }, + ); + } + } else { + // Try to restore from trash, and move to parent view + await TrashService.putback(viewId); + + // Check if View needs to be moved + if (parentViewId != null) { + final view = pageMemorizer[viewId] ?? + (await ViewBackendService.getView(viewId)).toNullable(); + if (view == null) { + return Log.error('View not found: $viewId'); + } + + if (view.parentViewId == parentViewId) { + return; + } + + await ViewBackendService.moveViewV2( + viewId: viewId, + newParentId: parentViewId, + prevViewId: null, + ); + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart new file mode 100644 index 0000000000000..4b8550570aabc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart @@ -0,0 +1,410 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart'; +import 'package:appflowy/plugins/trash/application/trash_listener.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +Node subPageNode({String? viewId}) { + return Node( + type: SubPageBlockKeys.type, + attributes: {SubPageBlockKeys.viewId: viewId}, + ); +} + +class SubPageBlockKeys { + const SubPageBlockKeys._(); + + static const String type = 'sub_page'; + + /// The ID of the View which is being linked to. + /// + static const String viewId = "view_id"; + + /// Signifies whether the block was inserted after a Copy operation. + /// + static const String wasCopied = "was_copied"; + + /// Signifies whether the block was inserted after a Cut operation. + /// + static const String wasCut = "was_cut"; +} + +class SubPageBlockComponentBuilder extends BlockComponentBuilder { + SubPageBlockComponentBuilder({super.configuration}); + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return SubPageBlockComponent( + key: node.key, + node: node, + showActions: showActions(node), + configuration: configuration, + actionBuilder: (_, state) => actionBuilder(blockComponentContext, state), + ); + } + + @override + BlockComponentValidate get validate => (node) => node.children.isEmpty; +} + +class SubPageBlockComponent extends BlockComponentStatefulWidget { + const SubPageBlockComponent({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + }); + + @override + State createState() => SubPageBlockComponentState(); +} + +class SubPageBlockComponentState extends State + with SelectableMixin, BlockComponentConfigurable { + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + Node get node => widget.node; + + RenderBox? get _renderBox => context.findRenderObject() as RenderBox?; + + final subPageKey = GlobalKey(); + + ViewListener? viewListener; + TrashListener? trashListener; + Future? viewFuture; + + bool isHovering = false; + bool isHandlingPaste = false; + + EditorState get editorState => context.read(); + + String? parentId; + + @override + void initState() { + super.initState(); + final viewId = node.attributes[SubPageBlockKeys.viewId]; + if (viewId != null) { + viewFuture = fetchView(viewId); + viewListener = ViewListener(viewId: viewId) + ..start(onViewUpdated: onViewUpdated); + trashListener = TrashListener()..start(trashUpdated: didUpdateTrash); + } + } + + @override + void didUpdateWidget(SubPageBlockComponent oldWidget) { + final viewId = node.attributes[SubPageBlockKeys.viewId]; + final oldViewId = viewListener?.viewId ?? + oldWidget.node.attributes[SubPageBlockKeys.viewId]; + if (viewId != null && (viewId != oldViewId || viewListener == null)) { + viewFuture = fetchView(viewId); + viewListener?.stop(); + viewListener = ViewListener(viewId: viewId) + ..start(onViewUpdated: onViewUpdated); + } + super.didUpdateWidget(oldWidget); + } + + void didUpdateTrash(FlowyResult, FlowyError> trashOrFailed) { + final trashList = trashOrFailed.toNullable(); + if (trashList == null) { + return; + } + + final viewId = node.attributes[SubPageBlockKeys.viewId]; + if (trashList.any((t) => t.id == viewId)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final transaction = editorState.transaction..deleteNode(node); + editorState.apply( + transaction, + withUpdateSelection: false, + options: const ApplyOptions(recordUndo: false), + ); + } + }); + } + } + + void onViewUpdated(ViewPB view) { + pageMemorizer[view.id] = view; + viewFuture = Future.value(view); + editorState.reload(); + + if (parentId != view.parentViewId && parentId != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final transaction = editorState.transaction..deleteNode(node); + editorState.apply( + transaction, + withUpdateSelection: false, + options: const ApplyOptions(recordUndo: false), + ); + } + }); + } + } + + @override + void dispose() { + viewListener?.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + initialData: pageMemorizer[node.attributes[SubPageBlockKeys.viewId]], + future: viewFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const SizedBox.shrink(); + } + + final view = snapshot.data; + if (view == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final transaction = editorState.transaction..deleteNode(node); + editorState.apply( + transaction, + withUpdateSelection: false, + options: const ApplyOptions(recordUndo: false), + ); + } + }); + + return const SizedBox.shrink(); + } + + Widget child = Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + opaque: false, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + ), + child: BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + remoteSelection: editorState.remoteSelections, + blockColor: editorState.editorStyle.selectionColor, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: editorState.editorStyle.selectionColor, + supportTypes: const [ + BlockSelectionType.block, + BlockSelectionType.cursor, + BlockSelectionType.selection, + ], + child: GestureDetector( + // TODO(Mathias): Handle mobile tap + onTap: + isHandlingPaste ? null : () => _openSubPage(view: view), + child: DecoratedBox( + decoration: BoxDecoration( + color: isHovering + ? Theme.of(context).colorScheme.secondary + : null, + borderRadius: BorderRadius.circular(4), + ), + child: SizedBox( + height: 32, + child: Row( + children: [ + const HSpace(10), + view.icon.value.isNotEmpty + ? FlowyText.emoji( + view.icon.value, + fontSize: textStyle.fontSize, + lineHeight: textStyle.height, + color: + AFThemeExtension.of(context).strongText, + ) + : view.defaultIcon(), + const HSpace(6), + Flexible( + child: FlowyText( + view.nameOrDefault, + fontSize: textStyle.fontSize, + fontWeight: textStyle.fontWeight, + decoration: TextDecoration.underline, + lineHeight: textStyle.height, + overflow: TextOverflow.ellipsis, + ), + ), + if (isHandlingPaste) ...[ + FlowyText( + LocaleKeys + .document_plugins_subPage_handlingPasteHint + .tr(), + fontSize: textStyle.fontSize, + fontWeight: textStyle.fontWeight, + lineHeight: textStyle.height, + color: Theme.of(context).hintColor, + ), + const HSpace(10), + const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator( + strokeWidth: 1.5, + ), + ), + ], + const HSpace(10), + ], + ), + ), + ), + ), + ), + ), + ), + ); + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + return child; + }, + ); + } + + Future fetchView(String pageId) async { + final view = await ViewBackendService.getView(pageId).then( + (res) => res.toNullable(), + ); + + if (view == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final transaction = editorState.transaction..deleteNode(node); + editorState.apply( + transaction, + withUpdateSelection: false, + options: const ApplyOptions(recordUndo: false), + ); + } + }); + } + + parentId = view?.parentViewId; + return view; + } + + @override + Position start() => Position(path: widget.node.path); + + @override + Position end() => Position(path: widget.node.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + bool get shouldCursorBlink => false; + + @override + CursorStyle get cursorStyle => CursorStyle.cover; + + @override + Rect getBlockRect({ + bool shiftWithBaseOffset = false, + }) { + return getRectsInSelection(Selection.invalid()).first; + } + + @override + Rect? getCursorRectInPosition( + Position position, { + bool shiftWithBaseOffset = false, + }) { + final rects = getRectsInSelection( + Selection.collapsed(position), + shiftWithBaseOffset: shiftWithBaseOffset, + ); + return rects.firstOrNull; + } + + @override + List getRectsInSelection( + Selection selection, { + bool shiftWithBaseOffset = false, + }) { + if (_renderBox == null) { + return []; + } + final parentBox = context.findRenderObject(); + final renderBox = subPageKey.currentContext?.findRenderObject(); + if (parentBox is RenderBox && renderBox is RenderBox) { + return [ + renderBox.localToGlobal(Offset.zero, ancestor: parentBox) & + renderBox.size, + ]; + } + return [Offset.zero & _renderBox!.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) => + Selection.single(path: widget.node.path, startOffset: 0, endOffset: 1); + + @override + Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) => + _renderBox!.localToGlobal(offset); + + void _openSubPage({ + required ViewPB view, + }) { + if (UniversalPlatform.isDesktop) { + final isInDatabase = + context.read().isInDatabaseRowPage; + if (isInDatabase) { + Navigator.of(context).pop(); + } + + getIt().add( + TabsEvent.openPlugin( + plugin: view.plugin(), + view: view, + ), + ); + } else if (UniversalPlatform.isMobile) { + context.pushView(view); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_transaction_handler.dart new file mode 100644 index 0000000000000..c5c7398bdb056 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_transaction_handler.dart @@ -0,0 +1,249 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/block_transaction_handler.dart'; +import 'package:appflowy/plugins/trash/application/trash_service.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; + +class SubPageTransactionHandler extends BlockTransactionHandler { + SubPageTransactionHandler() : super(type: SubPageBlockKeys.type); + + final List _beingCreated = []; + + @override + Future onTransaction( + BuildContext context, + String viewId, + EditorState editorState, + List added, + List removed, { + bool isCut = false, + bool isUndoRedo = false, + bool isPaste = false, + bool isDraggingNode = false, + bool isTurnInto = false, + String? parentViewId, + }) async { + if (isDraggingNode || isTurnInto) { + return; + } + + for (final node in removed) { + if (!context.mounted) return; + await _subPageDeleted(context, node); + } + + for (final node in added) { + if (!context.mounted) return; + await _subPageAdded( + context, + editorState, + node, + isCut: isCut, + isPaste: isPaste, + parentViewId: parentViewId, + ); + } + } + + Future _subPageDeleted( + BuildContext context, + Node node, + ) async { + if (node.type != type) { + return; + } + + final view = node.attributes[SubPageBlockKeys.viewId]; + if (view == null) { + return; + } + + final result = await ViewBackendService.deleteView(viewId: view); + result.fold( + (_) {}, + (error) { + Log.error(error); + if (context.mounted) { + showSnapBar( + context, + LocaleKeys.document_plugins_subPage_errors_failedDeletePage.tr(), + ); + } + }, + ); + } + + Future _subPageAdded( + BuildContext context, + EditorState editorState, + Node node, { + bool isCut = false, + bool isPaste = false, + String? parentViewId, + }) async { + if (node.type != type || _beingCreated.contains(node.id)) { + return; + } + + final viewId = node.attributes[SubPageBlockKeys.viewId]; + if (viewId == null && parentViewId != null) { + _beingCreated.add(node.id); + + // This is a new Node, we need to create the view + final viewOrResult = await ViewBackendService.createView( + name: '', + layoutType: ViewLayoutPB.Document, + parentViewId: parentViewId, + ); + + await viewOrResult.fold( + (view) async { + final transaction = editorState.transaction + ..updateNode(node, {SubPageBlockKeys.viewId: view.id}); + await editorState.apply( + transaction, + withUpdateSelection: false, + options: const ApplyOptions(recordUndo: false), + ); + editorState.reload(); + + // Open view + getIt().openPlugin(view); + }, + (error) async { + Log.error(error); + showSnapBar( + context, + LocaleKeys.document_plugins_subPage_errors_failedCreatePage.tr(), + ); + + // Remove the node because it failed + final transaction = editorState.transaction..deleteNode(node); + await editorState.apply( + transaction, + withUpdateSelection: false, + options: const ApplyOptions(recordUndo: false), + ); + }, + ); + + _beingCreated.remove(node.id); + } else if (isPaste) { + if (isCut && parentViewId != null) { + await TrashService.putback(viewId); + + final viewOrResult = await ViewBackendService.moveViewV2( + viewId: viewId, + newParentId: parentViewId, + prevViewId: null, + ); + + viewOrResult.fold( + (_) {}, + (error) { + Log.error(error); + showSnapBar( + context, + LocaleKeys.document_plugins_subPage_errors_failedMovePage.tr(), + ); + }, + ); + } else { + final viewId = node.attributes[SubPageBlockKeys.viewId]; + if (viewId == null) { + return; + } + + final viewOrResult = await ViewBackendService.getView(viewId); + return viewOrResult.fold( + (view) async { + final duplicatedViewOrResult = await ViewBackendService.duplicate( + view: view, + openAfterDuplicate: false, + includeChildren: true, + syncAfterDuplicate: true, + parentViewId: parentViewId, + ); + + return duplicatedViewOrResult.fold( + (view) async { + final transaction = editorState.transaction + ..updateNode(node, { + SubPageBlockKeys.viewId: view.id, + SubPageBlockKeys.wasCut: false, + SubPageBlockKeys.wasCopied: false, + }); + await editorState.apply( + transaction, + withUpdateSelection: false, + options: const ApplyOptions(recordUndo: false), + ); + editorState.reload(); + }, + (error) { + Log.error(error); + if (context.mounted) { + showSnapBar( + context, + LocaleKeys + .document_plugins_subPage_errors_failedDuplicatePage + .tr(), + ); + } + }, + ); + }, + (error) async { + Log.error(error); + + final transaction = editorState.transaction..deleteNode(node); + await editorState.apply( + transaction, + withUpdateSelection: false, + options: const ApplyOptions(recordUndo: false), + ); + editorState.reload(); + if (context.mounted) { + showSnapBar( + context, + LocaleKeys + .document_plugins_subPage_errors_failedDuplicateFindView + .tr(), + ); + } + }, + ); + } + } else { + // Try to restore from trash, and move to parent view + await TrashService.putback(viewId); + + // Check if View needs to be moved + if (parentViewId != null) { + final view = (await ViewBackendService.getView(viewId)).toNullable(); + if (view == null) { + return Log.error('View not found: $viewId'); + } + + if (view.parentViewId == parentViewId) { + return; + } + + await ViewBackendService.moveViewV2( + viewId: viewId, + newParentId: parentViewId, + prevViewId: null, + ); + } + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/table/table_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/table/table_menu.dart new file mode 100644 index 0000000000000..0abba733fbe70 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/table/table_menu.dart @@ -0,0 +1,112 @@ +import 'dart:math' as math; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/table/table_option_action.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; + +const tableActions = [ + TableOptionAction.addAfter, + TableOptionAction.addBefore, + TableOptionAction.delete, + TableOptionAction.duplicate, + TableOptionAction.clear, + TableOptionAction.bgColor, +]; + +class TableMenu extends StatelessWidget { + const TableMenu({ + super.key, + required this.node, + required this.editorState, + required this.position, + required this.dir, + this.onBuild, + this.onClose, + }); + + final Node node; + final EditorState editorState; + final int position; + final TableDirection dir; + final VoidCallback? onBuild; + final VoidCallback? onClose; + + @override + Widget build(BuildContext context) { + final actions = tableActions.map((action) { + switch (action) { + case TableOptionAction.bgColor: + return TableColorOptionAction( + node: node, + editorState: editorState, + position: position, + dir: dir, + ); + default: + return TableOptionActionWrapper(action); + } + }).toList(); + + return PopoverActionList( + direction: dir == TableDirection.col + ? PopoverDirection.bottomWithCenterAligned + : PopoverDirection.rightWithTopAligned, + actions: actions, + onPopupBuilder: onBuild, + onClosed: onClose, + onSelected: (action, controller) { + if (action is TableOptionActionWrapper) { + _onSelectAction(action.inner); + controller.close(); + } + }, + buildChild: (controller) => _buildOptionButton(controller, context), + ); + } + + Widget _buildOptionButton( + PopoverController controller, + BuildContext context, + ) { + return Card( + elevation: 1.0, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => controller.show(), + child: Transform.rotate( + angle: dir == TableDirection.col ? math.pi / 2 : 0, + child: const FlowySvg( + FlowySvgs.drag_element_s, + size: Size.square(18.0), + ), + ), + ), + ), + ); + } + + void _onSelectAction(TableOptionAction action) { + switch (action) { + case TableOptionAction.addAfter: + TableActions.add(node, position + 1, editorState, dir); + break; + case TableOptionAction.addBefore: + TableActions.add(node, position, editorState, dir); + break; + case TableOptionAction.delete: + TableActions.delete(node, position, editorState, dir); + break; + case TableOptionAction.clear: + TableActions.clear(node, position, editorState, dir); + break; + case TableOptionAction.duplicate: + TableActions.duplicate(node, position, editorState, dir); + break; + default: + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/table/table_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/table/table_option_action.dart new file mode 100644 index 0000000000000..993ee9b5a7700 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/table/table_option_action.dart @@ -0,0 +1,157 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/extensions/flowy_tint_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/extension.dart'; +import 'package:flutter/material.dart'; + +const tableCellDefaultColor = 'appflowy_table_cell_default_color'; + +enum TableOptionAction { + addAfter, + addBefore, + delete, + duplicate, + clear, + + /// row|cell background color + bgColor; + + Widget icon(Color? color) { + switch (this) { + case TableOptionAction.addAfter: + return const FlowySvg(FlowySvgs.add_s); + case TableOptionAction.addBefore: + return const FlowySvg(FlowySvgs.add_s); + case TableOptionAction.delete: + return const FlowySvg(FlowySvgs.delete_s); + case TableOptionAction.duplicate: + return const FlowySvg(FlowySvgs.copy_s); + case TableOptionAction.clear: + return const FlowySvg(FlowySvgs.close_s); + case TableOptionAction.bgColor: + return const FlowySvg( + FlowySvgs.color_format_m, + size: Size.square(12), + ).padding(all: 2.0); + } + } + + String get description { + switch (this) { + case TableOptionAction.addAfter: + return LocaleKeys.document_plugins_table_addAfter.tr(); + case TableOptionAction.addBefore: + return LocaleKeys.document_plugins_table_addBefore.tr(); + case TableOptionAction.delete: + return LocaleKeys.document_plugins_table_delete.tr(); + case TableOptionAction.duplicate: + return LocaleKeys.document_plugins_table_duplicate.tr(); + case TableOptionAction.clear: + return LocaleKeys.document_plugins_table_clear.tr(); + case TableOptionAction.bgColor: + return LocaleKeys.document_plugins_table_bgColor.tr(); + } + } +} + +class TableOptionActionWrapper extends ActionCell { + TableOptionActionWrapper(this.inner); + + final TableOptionAction inner; + + @override + Widget? leftIcon(Color iconColor) => inner.icon(iconColor); + + @override + String get name => inner.description; +} + +class TableColorOptionAction extends PopoverActionCell { + TableColorOptionAction({ + required this.node, + required this.editorState, + required this.position, + required this.dir, + }); + + final Node node; + final EditorState editorState; + final int position; + final TableDirection dir; + + @override + Widget? leftIcon(Color iconColor) => + TableOptionAction.bgColor.icon(iconColor); + + @override + String get name => TableOptionAction.bgColor.description; + + @override + Widget Function( + BuildContext context, + PopoverController parentController, + PopoverController controller, + ) get builder => (context, parentController, controller) { + int row = 0, col = position; + if (dir == TableDirection.row) { + col = 0; + row = position; + } + + final cell = node.children.firstWhereOrNull( + (n) => + n.attributes[TableCellBlockKeys.colPosition] == col && + n.attributes[TableCellBlockKeys.rowPosition] == row, + ); + final key = dir == TableDirection.col + ? TableCellBlockKeys.colBackgroundColor + : TableCellBlockKeys.rowBackgroundColor; + final bgColor = cell?.attributes[key] as String?; + final selectedColor = bgColor?.tryToColor(); + // get default background color from themeExtension + final defaultColor = AFThemeExtension.of(context).tableCellBGColor; + final colors = [ + // reset to default background color + FlowyColorOption( + color: defaultColor, + i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(), + id: tableCellDefaultColor, + ), + ...FlowyTint.values.map( + (e) => FlowyColorOption( + color: e.color(context), + i18n: e.tintName(AppFlowyEditorL10n.current), + id: e.id, + ), + ), + ]; + + return FlowyColorPicker( + colors: colors, + selected: selectedColor, + border: Border.all( + color: AFThemeExtension.of(context).onBackground, + ), + onTap: (option, index) async { + final backgroundColor = + selectedColor != option.color ? option.id : ''; + TableActions.setBgColor( + node, + position, + editorState, + backgroundColor, + dir, + ); + + controller.close(); + parentController.close(); + }, + ); + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/todo_list/todo_list_icon.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/todo_list/todo_list_icon.dart new file mode 100644 index 0000000000000..95841051d7397 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/todo_list/todo_list_icon.dart @@ -0,0 +1,50 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class TodoListIcon extends StatelessWidget { + const TodoListIcon({ + super.key, + required this.node, + required this.onCheck, + }); + + final Node node; + final VoidCallback onCheck; + + @override + Widget build(BuildContext context) { + // the icon height should be equal to the text height * text font size + final textStyle = + context.read().editorStyle.textStyleConfiguration; + final fontSize = textStyle.text.fontSize ?? 16.0; + final height = textStyle.text.height ?? textStyle.lineHeight; + final iconSize = fontSize * height; + + final checked = node.attributes[TodoListBlockKeys.checked] ?? false; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + HapticFeedback.lightImpact(); + onCheck(); + }, + child: Container( + constraints: BoxConstraints( + minWidth: iconSize, + minHeight: iconSize, + ), + margin: const EdgeInsets.only(right: 8.0), + alignment: Alignment.center, + child: FlowySvg( + checked + ? FlowySvgs.m_todo_list_checked_s + : FlowySvgs.m_todo_list_unchecked_s, + blendMode: checked ? null : BlendMode.srcIn, + size: Size.square(iconSize * 0.9), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart new file mode 100644 index 0000000000000..9f26f7037fde9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart @@ -0,0 +1,411 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart' hide TextDirection; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class ToggleListBlockKeys { + const ToggleListBlockKeys._(); + + static const String type = 'toggle_list'; + + /// The content of a code block. + /// + /// The value is a String. + static const String delta = blockComponentDelta; + + static const String backgroundColor = blockComponentBackgroundColor; + + static const String textDirection = blockComponentTextDirection; + + /// The value is a bool. + static const String collapsed = 'collapsed'; + + /// The value is a int. + /// + /// If this value is not null, the block represent a toggle heading. + static const String level = 'level'; +} + +Node toggleListBlockNode({ + String? text, + Delta? delta, + bool collapsed = false, + String? textDirection, + Attributes? attributes, + Iterable? children, +}) { + delta ??= Delta()..insert(text ?? ''); + return Node( + type: ToggleListBlockKeys.type, + children: children ?? [], + attributes: { + if (textDirection != null) + ToggleListBlockKeys.textDirection: textDirection, + ToggleListBlockKeys.collapsed: collapsed, + ToggleListBlockKeys.delta: delta.toJson(), + if (attributes != null) ...attributes, + }, + ); +} + +Node toggleHeadingNode({ + int level = 1, + String? text, + Delta? delta, + bool collapsed = false, + String? textDirection, + Attributes? attributes, + Iterable? children, +}) { + // only support level 1 - 6 + level = level.clamp(1, 6); + return toggleListBlockNode( + text: text, + delta: delta, + collapsed: collapsed, + textDirection: textDirection, + children: children, + attributes: { + if (attributes != null) ...attributes, + ToggleListBlockKeys.level: level, + }, + ); +} + +// defining the toggle list block menu item +SelectionMenuItem toggleListBlockItem = SelectionMenuItem.node( + getName: LocaleKeys.document_plugins_toggleList.tr, + iconData: Icons.arrow_right, + keywords: ['collapsed list', 'toggle list', 'list'], + nodeBuilder: (editorState, _) => toggleListBlockNode(), + replace: (_, node) => node.delta?.isEmpty ?? false, +); + +class ToggleListBlockComponentBuilder extends BlockComponentBuilder { + ToggleListBlockComponentBuilder({ + super.configuration, + this.padding = const EdgeInsets.all(0), + this.textStyleBuilder, + }); + + final EdgeInsets padding; + + /// The text style of the toggle heading block. + final TextStyle Function(int level)? textStyleBuilder; + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return ToggleListBlockComponentWidget( + key: node.key, + node: node, + configuration: configuration, + padding: padding, + textStyleBuilder: textStyleBuilder, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + BlockComponentValidate get validate => (node) => node.delta != null; +} + +class ToggleListBlockComponentWidget extends BlockComponentStatefulWidget { + const ToggleListBlockComponentWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + this.padding = const EdgeInsets.all(0), + this.textStyleBuilder, + }); + + final EdgeInsets padding; + final TextStyle Function(int level)? textStyleBuilder; + + @override + State createState() => + _ToggleListBlockComponentWidgetState(); +} + +class _ToggleListBlockComponentWidgetState + extends State + with + SelectableMixin, + DefaultSelectableMixin, + BlockComponentConfigurable, + BlockComponentBackgroundColorMixin, + NestedBlockComponentStatefulWidgetMixin, + BlockComponentTextDirectionMixin, + BlockComponentAlignMixin { + // the key used to forward focus to the richtext child + @override + final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text'); + + @override + BlockComponentConfiguration get configuration => widget.configuration; + + @override + GlobalKey> get containerKey => node.key; + + @override + GlobalKey> blockComponentKey = GlobalKey( + debugLabel: ToggleListBlockKeys.type, + ); + + @override + Node get node => widget.node; + + @override + EdgeInsets get indentPadding => configuration.indentPadding( + node, + calculateTextDirection( + layoutDirection: Directionality.maybeOf(context), + ), + ); + + bool get collapsed => node.attributes[ToggleListBlockKeys.collapsed] ?? false; + int? get level => node.attributes[ToggleListBlockKeys.level] as int?; + + @override + Widget build(BuildContext context) { + return collapsed + ? buildComponent(context) + : buildComponentWithChildren(context); + } + + @override + Widget buildComponentWithChildren(BuildContext context) { + return Stack( + children: [ + if (backgroundColor != Colors.transparent) + Positioned.fill( + left: cachedLeft, + top: padding.top, + child: Container( + width: double.infinity, + color: backgroundColor, + ), + ), + NestedListWidget( + indentPadding: indentPadding, + child: buildComponent(context), + children: editorState.renderer.buildList( + context, + widget.node.children, + ), + ), + ], + ); + } + + @override + Widget buildComponent( + BuildContext context, { + bool withBackgroundColor = false, + }) { + Widget child = _buildToggleBlock(); + + child = BlockSelectionContainer( + node: node, + delegate: this, + listenable: editorState.selectionNotifier, + blockColor: editorState.editorStyle.selectionColor, + supportTypes: const [ + BlockSelectionType.block, + ], + child: child, + ); + + child = Padding( + padding: padding, + child: Container( + key: blockComponentKey, + color: withBackgroundColor || + (backgroundColor != Colors.transparent && collapsed) + ? backgroundColor + : null, + child: child, + ), + ); + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + return child; + } + + Widget _buildToggleBlock() { + final textDirection = calculateTextDirection( + layoutDirection: Directionality.maybeOf(context), + ); + final crossAxisAlignment = textDirection == TextDirection.ltr + ? CrossAxisAlignment.start + : CrossAxisAlignment.end; + + return Container( + width: double.infinity, + alignment: alignment, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: crossAxisAlignment, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + textDirection: textDirection, + children: [ + _buildExpandIcon(), + Flexible( + child: _buildRichText(), + ), + ], + ), + _buildPlaceholder(), + ], + ), + ); + } + + Widget _buildPlaceholder() { + // if the toggle block is collapsed or it contains children, don't show the + // placeholder. + if (collapsed || node.children.isNotEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: indentPadding, + child: FlowyButton( + text: FlowyText( + buildPlaceholderText(), + color: Theme.of(context).hintColor, + ), + margin: const EdgeInsets.symmetric(horizontal: 3.0, vertical: 8), + onTap: onAddContent, + ), + ); + } + + Widget _buildRichText() { + final textDirection = calculateTextDirection( + layoutDirection: Directionality.maybeOf(context), + ); + final level = node.attributes[ToggleListBlockKeys.level]; + return AppFlowyRichText( + key: forwardKey, + delegate: this, + node: widget.node, + editorState: editorState, + placeholderText: placeholderText, + lineHeight: 1.5, + textSpanDecorator: (textSpan) { + var result = textSpan.updateTextStyle(textStyle); + if (level != null) { + result = result.updateTextStyle( + widget.textStyleBuilder?.call(level), + ); + } + return result; + }, + placeholderTextSpanDecorator: (textSpan) { + var result = textSpan.updateTextStyle(textStyle); + if (level != null && widget.textStyleBuilder != null) { + result = result.updateTextStyle( + widget.textStyleBuilder?.call(level), + ); + } + return result.updateTextStyle(placeholderTextStyle); + }, + textDirection: textDirection, + textAlign: alignment?.toTextAlign ?? textAlign, + cursorColor: editorState.editorStyle.cursorColor, + selectionColor: editorState.editorStyle.selectionColor, + ); + } + + Widget _buildExpandIcon() { + double buttonHeight = UniversalPlatform.isDesktop ? 22.0 : 26.0; + final textDirection = calculateTextDirection( + layoutDirection: Directionality.maybeOf(context), + ); + + if (level != null) { + // top padding * 2 + button height = height of the heading text + final textStyle = widget.textStyleBuilder?.call(level ?? 1); + final fontSize = textStyle?.fontSize; + final lineHeight = textStyle?.height ?? 1.5; + + if (fontSize != null) { + buttonHeight = fontSize * lineHeight; + } + } + + final turns = switch (textDirection) { + TextDirection.ltr => collapsed ? 0.0 : 0.25, + TextDirection.rtl => collapsed ? -0.5 : -0.75, + }; + + return Container( + constraints: BoxConstraints( + minWidth: 26, + minHeight: buttonHeight, + ), + alignment: Alignment.center, + child: FlowyButton( + margin: const EdgeInsets.all(2.0), + useIntrinsicWidth: true, + onTap: onCollapsed, + text: AnimatedRotation( + turns: turns, + duration: const Duration(milliseconds: 200), + child: const Icon( + Icons.arrow_right, + size: 18.0, + ), + ), + ), + ); + } + + Future onCollapsed() async { + final transaction = editorState.transaction + ..updateNode(node, { + ToggleListBlockKeys.collapsed: !collapsed, + }); + transaction.afterSelection = editorState.selection; + await editorState.apply(transaction); + } + + Future onAddContent() async { + final transaction = editorState.transaction; + final path = node.path.child(0); + transaction.insertNode( + path, + paragraphNode(), + ); + transaction.afterSelection = Selection.collapsed(Position(path: path)); + await editorState.apply(transaction); + } + + String buildPlaceholderText() { + if (level != null) { + return LocaleKeys.document_plugins_emptyToggleHeading.tr( + args: [level.toString()], + ); + } + return LocaleKeys.document_plugins_emptyToggleList.tr(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart new file mode 100644 index 0000000000000..212be0f7bfc95 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart @@ -0,0 +1,305 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +const _greater = '>'; + +/// Convert '> ' to toggle list +/// +/// - support +/// - desktop +/// - mobile +/// - web +/// +CharacterShortcutEvent formatGreaterToToggleList = CharacterShortcutEvent( + key: 'format greater to toggle list', + character: ' ', + handler: (editorState) async => _formatGreaterSymbol( + editorState, + (node) => node.type != ToggleListBlockKeys.type, + (_, text, __) => text == _greater, + (text, node, delta, afterSelection) async => _formatGreaterToToggleHeading( + editorState, + text, + node, + delta, + afterSelection, + ), + ), +); + +Future _formatGreaterToToggleHeading( + EditorState editorState, + String text, + Node node, + Delta delta, + Selection afterSelection, +) async { + final type = node.type; + int? level; + if (type == ToggleListBlockKeys.type) { + level = node.attributes[ToggleListBlockKeys.level] as int?; + } else if (type == HeadingBlockKeys.type) { + level = node.attributes[HeadingBlockKeys.level] as int?; + } + delta = delta.compose(Delta()..delete(_greater.length)); + // if the previous block is heading block, convert it to toggle heading block + if (type == HeadingBlockKeys.type && level != null) { + final cubit = BlockActionOptionCubit( + editorState: editorState, + blockComponentBuilder: {}, + ); + await cubit.turnIntoSingleToggleHeading( + type: ToggleListBlockKeys.type, + selectedNodes: [node], + level: level, + delta: delta, + afterSelection: afterSelection, + ); + return; + } + + final transaction = editorState.transaction; + transaction + ..insertNode( + node.path, + toggleListBlockNode( + delta: delta, + children: node.children.map((e) => e.deepCopy()).toList(), + ), + ) + ..deleteNode(node); + transaction.afterSelection = afterSelection; + await editorState.apply(transaction); +} + +/// Press enter key to insert child node inside the toggle list +/// +/// - support +/// - desktop +/// - mobile +/// - web +CharacterShortcutEvent insertChildNodeInsideToggleList = CharacterShortcutEvent( + key: 'insert child node inside toggle list', + character: '\n', + handler: (editorState) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return false; + } + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || + node.type != ToggleListBlockKeys.type || + delta == null) { + return false; + } + final slicedDelta = delta.slice(selection.start.offset); + final transaction = editorState.transaction; + final collapsed = node.attributes[ToggleListBlockKeys.collapsed] as bool; + if (collapsed) { + // if the delta is empty, clear the format + if (delta.isEmpty) { + transaction + ..insertNode( + selection.start.path.next, + paragraphNode(), + ) + ..deleteNode(node) + ..afterSelection = Selection.collapsed( + Position(path: selection.start.path), + ); + } else if (selection.startIndex == 0) { + // insert a paragraph block above the current toggle list block + transaction.insertNode(selection.start.path, paragraphNode()); + transaction.afterSelection = Selection.collapsed( + Position(path: selection.start.path.next), + ); + } else { + // insert a toggle list block below the current toggle list block + transaction + ..deleteText(node, selection.startIndex, slicedDelta.length) + ..insertNodes( + selection.start.path.next, + [ + toggleListBlockNode(collapsed: true, delta: slicedDelta), + ], + ) + ..afterSelection = Selection.collapsed( + Position(path: selection.start.path.next), + ); + } + } else { + // insert a paragraph block inside the current toggle list block + transaction + ..deleteText(node, selection.startIndex, slicedDelta.length) + ..insertNode( + selection.start.path + [0], + paragraphNode(delta: slicedDelta), + ) + ..afterSelection = Selection.collapsed( + Position(path: selection.start.path + [0]), + ); + } + await editorState.apply(transaction); + return true; + }, +); + +/// cmd/ctrl + enter to close or open the toggle list +/// +/// - support +/// - desktop +/// - web +/// + +// toggle the todo list +final CommandShortcutEvent toggleToggleListCommand = CommandShortcutEvent( + key: 'toggle the toggle list', + getDescription: () => AppFlowyEditorL10n.current.cmdToggleTodoList, + command: 'ctrl+enter', + macOSCommand: 'cmd+enter', + handler: _toggleToggleListCommandHandler, +); + +CommandShortcutEventHandler _toggleToggleListCommandHandler = (editorState) { + if (UniversalPlatform.isMobile) { + assert(false, 'enter key is not supported on mobile platform.'); + return KeyEventResult.ignored; + } + + final selection = editorState.selection; + if (selection == null) { + return KeyEventResult.ignored; + } + final nodes = editorState.getNodesInSelection(selection); + if (nodes.isEmpty || nodes.length > 1) { + return KeyEventResult.ignored; + } + + final node = nodes.first; + if (node.type != ToggleListBlockKeys.type) { + return KeyEventResult.ignored; + } + + final collapsed = node.attributes[ToggleListBlockKeys.collapsed] as bool; + final transaction = editorState.transaction; + transaction.updateNode(node, { + ToggleListBlockKeys.collapsed: !collapsed, + }); + transaction.afterSelection = selection; + editorState.apply(transaction); + return KeyEventResult.handled; +}; + +/// Press the backspace at the first position of first line to go to the title +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent removeToggleHeadingStyle = CommandShortcutEvent( + key: 'remove toggle heading style', + command: 'backspace', + getDescription: () => 'remove toggle heading style', + handler: (editorState) => _removeToggleHeadingStyle( + editorState: editorState, + ), +); + +// convert the toggle heading block to heading block +KeyEventResult _removeToggleHeadingStyle({ + required EditorState editorState, +}) { + final selection = editorState.selection; + if (selection == null || + !selection.isCollapsed || + selection.start.offset != 0) { + return KeyEventResult.ignored; + } + + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null || node.type != ToggleListBlockKeys.type) { + return KeyEventResult.ignored; + } + + final level = node.attributes[ToggleListBlockKeys.level] as int?; + if (level == null) { + return KeyEventResult.ignored; + } + + final transaction = editorState.transaction; + transaction.updateNode(node, { + ToggleListBlockKeys.level: null, + }); + transaction.afterSelection = selection; + editorState.apply(transaction); + + return KeyEventResult.handled; +} + +/// Formats the current node to specified markdown style. +/// +/// For example, +/// bulleted list: '- ' +/// numbered list: '1. ' +/// quote: '" ' +/// ... +/// +/// The [nodeBuilder] can return a list of nodes, which will be inserted +/// into the document. +/// For example, when converting a bulleted list to a heading and the heading is +/// not allowed to contain children, then the [nodeBuilder] should return a list +/// of nodes, which contains the heading node and the children nodes. +Future _formatGreaterSymbol( + EditorState editorState, + bool Function(Node node) shouldFormat, + bool Function( + Node node, + String text, + Selection selection, + ) predicate, + Future Function( + String text, + Node node, + Delta delta, + Selection afterSelection, + ) onFormat, +) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return false; + } + + final position = selection.end; + final node = editorState.getNodeAtPath(position.path); + + if (node == null || !shouldFormat(node)) { + return false; + } + + // Get the text from the start of the document until the selection. + final delta = node.delta; + if (delta == null) { + return false; + } + final text = delta.toPlainText().substring(0, selection.end.offset); + + // If the text doesn't match the predicate, then we don't want to + // format it. + if (!predicate(node, text, selection)) { + return false; + } + + final afterSelection = Selection.collapsed( + Position( + path: node.path, + ), + ); + + await onFormat(text, node, delta, afterSelection); + + return true; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/block_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/block_transaction_handler.dart new file mode 100644 index 0000000000000..34b0bdc8f9ee4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/block_transaction_handler.dart @@ -0,0 +1,13 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +/// A handler for transactions that involve a Block Component. +/// +/// This is a subclass of [EditorTransactionHandler] that is used for block components. +/// Specifically this transaction handler only needs to concern itself with changes to +/// a [Node], and doesn't care about text deltas. +/// +abstract class BlockTransactionHandler extends EditorTransactionHandler { + const BlockTransactionHandler({required super.type}) + : super(livesInDelta: false); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart new file mode 100644 index 0000000000000..243532e8ce48e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy_editor/appflowy_editor.dart'; + +/// A handler for transactions that involve a Block Component. +/// The [T] type is the type of data that this transaction handler takes. +/// +/// In case of a block component, the [T] type should be a [Node]. +/// In case of a mention component, the [T] type should be a [Map]. +/// +abstract class EditorTransactionHandler { + const EditorTransactionHandler({ + required this.type, + this.livesInDelta = false, + }); + + /// The type of the block/mention that this handler is built for. + /// It's used to determine whether to call any of the handlers on certain transactions. + /// + final String type; + + /// If the block is a "mention" type, it lives inside the [Delta] of a [Node]. + /// + final bool livesInDelta; + + Future onTransaction( + BuildContext context, + String viewId, + EditorState editorState, + List added, + List removed, { + bool isCut = false, + bool isUndoRedo = false, + bool isPaste = false, + bool isDraggingNode = false, + bool isTurnInto = false, + String? parentViewId, + }); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart new file mode 100644 index 0000000000000..b56066ae8bb3f --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart @@ -0,0 +1,331 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_transaction_handler.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart'; +import 'package:appflowy/shared/clipboard_state.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'mention_transaction_handler.dart'; + +final _transactionHandlers = [ + if (FeatureFlag.inlineSubPageMention.isOn) ...[ + SubPageTransactionHandler(), + ChildPageTransactionHandler(), + ], + DateTransactionHandler(), +]; + +/// Handles delegating transactions to appropriate handlers. +/// +/// Such as the [ChildPageTransactionHandler] for inline child pages. +/// +class EditorTransactionService extends StatefulWidget { + const EditorTransactionService({ + super.key, + required this.viewId, + required this.editorState, + required this.child, + }); + + final String viewId; + final EditorState editorState; + final Widget child; + + @override + State createState() => + _EditorTransactionServiceState(); +} + +class _EditorTransactionServiceState extends State { + StreamSubscription? transactionSubscription; + + bool isUndoRedo = false; + bool isPaste = false; + bool isDraggingNode = false; + bool isTurnInto = false; + + @override + void initState() { + super.initState(); + transactionSubscription = + widget.editorState.transactionStream.listen(onEditorTransaction); + EditorNotification.addListener(onEditorNotification); + } + + @override + void dispose() { + EditorNotification.removeListener(onEditorNotification); + transactionSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } + + void onEditorNotification(EditorNotificationType type) { + if ([EditorNotificationType.undo, EditorNotificationType.redo] + .contains(type)) { + isUndoRedo = true; + } else if (type == EditorNotificationType.paste) { + isPaste = true; + } else if (type == EditorNotificationType.dragStart) { + isDraggingNode = true; + } else if (type == EditorNotificationType.dragEnd) { + isDraggingNode = false; + } else if (type == EditorNotificationType.turnInto) { + isTurnInto = true; + } + + if (type == EditorNotificationType.undo) { + undoCommand.execute(widget.editorState); + } else if (type == EditorNotificationType.redo) { + redoCommand.execute(widget.editorState); + } else if (type == EditorNotificationType.exitEditing && + widget.editorState.selection != null) { + // If the editor is disposed, we don't need to reset the selection. + if (!widget.editorState.isDisposed) { + widget.editorState.selection = null; + } + } + } + + /// Collects all nodes of a certain type, including those that are nested. + /// + List collectMatchingNodes( + Node node, + String type, { + bool livesInDelta = false, + }) { + final List matchingNodes = []; + if (node.type == type) { + matchingNodes.add(node); + } + + if (livesInDelta && node.attributes[blockComponentDelta] != null) { + final deltas = node.attributes[blockComponentDelta]; + if (deltas is List) { + for (final delta in deltas) { + if (delta['attributes'] != null && + delta['attributes'][type] != null) { + matchingNodes.add(node); + } + } + } + } + + for (final child in node.children) { + matchingNodes.addAll( + collectMatchingNodes( + child, + type, + livesInDelta: livesInDelta, + ), + ); + } + + return matchingNodes; + } + + void onEditorTransaction(EditorTransactionValue event) { + final time = event.$1; + final transaction = event.$2; + + if (time == TransactionTime.before) { + return; + } + + final Map added = { + for (final handler in _transactionHandlers) + handler.type: handler.livesInDelta ? [] : [], + }; + final Map removed = { + for (final handler in _transactionHandlers) + handler.type: handler.livesInDelta ? [] : [], + }; + + // based on the type of the transaction handler + final uniqueTransactionHandlers = {}; + for (final handler in _transactionHandlers) { + uniqueTransactionHandlers.putIfAbsent(handler.type, () => handler); + } + + for (final op in transaction.operations) { + if (op is InsertOperation) { + for (final n in op.nodes) { + for (final handler in uniqueTransactionHandlers.values) { + if (handler.livesInDelta) { + added[handler.type]! + .addAll(extractMentionsForType(n, handler.type)); + } else { + added[handler.type]! + .addAll(collectMatchingNodes(n, handler.type)); + } + } + } + } else if (op is DeleteOperation) { + for (final n in op.nodes) { + for (final handler in uniqueTransactionHandlers.values) { + if (handler.livesInDelta) { + removed[handler.type]!.addAll( + extractMentionsForType(n, handler.type, false), + ); + } else { + removed[handler.type]! + .addAll(collectMatchingNodes(n, handler.type)); + } + } + } + } else if (op is UpdateOperation) { + final node = widget.editorState.getNodeAtPath(op.path); + if (node == null) { + continue; + } + + if (op.attributes[blockComponentDelta] is! List || + op.oldAttributes[blockComponentDelta] is! List) { + continue; + } + + final deltaBefore = + Delta.fromJson(op.oldAttributes[blockComponentDelta]); + final deltaAfter = Delta.fromJson(op.attributes[blockComponentDelta]); + + final (add, del) = diffDeltas(deltaBefore, deltaAfter); + + bool fetchedMentions = false; + for (final handler in _transactionHandlers) { + if (!handler.livesInDelta || fetchedMentions) { + continue; + } + + if (add.isNotEmpty) { + final mentionBlockDatas = + getMentionBlockData(handler.type, node, add); + + added[handler.type]!.addAll(mentionBlockDatas); + } + + if (del.isNotEmpty) { + final mentionBlockDatas = getMentionBlockData( + handler.type, + node, + del, + ); + + removed[handler.type]!.addAll(mentionBlockDatas); + } + + fetchedMentions = true; + } + } + } + + for (final handler in _transactionHandlers) { + final additions = added[handler.type] ?? []; + final removals = removed[handler.type] ?? []; + + if (additions.isEmpty && removals.isEmpty) { + continue; + } + + handler.onTransaction( + context, + widget.viewId, + widget.editorState, + additions, + removals, + isCut: context.read().isCut, + isUndoRedo: isUndoRedo, + isPaste: isPaste, + isDraggingNode: isDraggingNode, + isTurnInto: isTurnInto, + parentViewId: widget.viewId, + ); + } + + isUndoRedo = false; + isPaste = false; + isTurnInto = false; + } + + /// Takes an iterable of [TextInsert] and returns a list of [MentionBlockData]. + /// This is used to extract mentions from a list of text inserts, of a certain type. + List getMentionBlockData( + String type, + Node node, + Iterable textInserts, + ) { + // Additions contain all the text inserts that were added in this + // transaction, we only care about the ones that fit the handlers type. + + // Filter out the text inserts where the attribute for the handler type is present. + final relevantTextInserts = + textInserts.where((ti) => ti.attributes?[type] != null); + + // Map it to a list of MentionBlockData. + final mentionBlockDatas = relevantTextInserts.map((ti) { + // For some text inserts (mostly additions), we might need to modify them after the transaction, + // so we pass the index of the delta to the handler. + final index = node.delta?.toList().indexOf(ti) ?? -1; + return (node, ti.attributes![type], index); + }).toList(); + + return mentionBlockDatas; + } + + List extractMentionsForType( + Node node, + String mentionType, [ + bool includeIndex = true, + ]) { + final changes = []; + + final nodesWithDelta = collectMatchingNodes( + node, + mentionType, + livesInDelta: true, + ); + + for (final paragraphNode in nodesWithDelta) { + final textInserts = paragraphNode.attributes[blockComponentDelta]; + if (textInserts == null || textInserts is! List || textInserts.isEmpty) { + continue; + } + + for (final (index, textInsert) in textInserts.indexed) { + if (textInsert['attributes'] != null && + textInsert['attributes'][mentionType] != null) { + changes.add( + ( + paragraphNode, + textInsert['attributes'][mentionType], + includeIndex ? index : -1, + ), + ); + } + } + } + + return changes; + } + + (Iterable, Iterable) diffDeltas( + Delta before, + Delta after, + ) { + final diff = before.diff(after); + final inverted = diff.invert(before); + final del = inverted.whereType(); + final add = diff.whereType(); + + return (add, del); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/mention_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/mention_transaction_handler.dart new file mode 100644 index 0000000000000..d08ce05510594 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/mention_transaction_handler.dart @@ -0,0 +1,17 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +/// The data used to handle transactions for mentions. +/// +/// [Node] is the block node. +/// [Map] is the data of the mention block. +/// [int] is the index of the mention block in the list of deltas (after transaction apply). +/// +typedef MentionBlockData = (Node, Map, int); + +abstract class MentionTransactionHandler + extends EditorTransactionHandler { + const MentionTransactionHandler() + : super(type: MentionBlockKeys.mention, livesInDelta: true); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart new file mode 100644 index 0000000000000..9ea64779696c6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart @@ -0,0 +1,44 @@ +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/widgets.dart'; + +/// Undo +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent customUndoCommand = CommandShortcutEvent( + key: 'undo', + getDescription: () => AppFlowyEditorL10n.current.cmdUndo, + command: 'ctrl+z', + macOSCommand: 'cmd+z', + handler: (editorState) { + // if the selection is null, it means the keyboard service is disabled + if (editorState.selection == null) { + return KeyEventResult.ignored; + } + EditorNotification.undo().post(); + return KeyEventResult.handled; + }, +); + +/// Redo +/// +/// - support +/// - desktop +/// - web +/// +final CommandShortcutEvent customRedoCommand = CommandShortcutEvent( + key: 'redo', + getDescription: () => AppFlowyEditorL10n.current.cmdRedo, + command: 'ctrl+y,ctrl+shift+z', + macOSCommand: 'cmd+shift+z', + handler: (editorState) { + if (editorState.selection == null) { + return KeyEventResult.ignored; + } + EditorNotification.redo().post(); + return KeyEventResult.handled; + }, +); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart new file mode 100644 index 0000000000000..de4d431a0864b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -0,0 +1,525 @@ +import 'dart:io'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/font_colors.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/utils.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/shared/google_fonts_extension.dart'; +import 'package:appflowy/util/font_family_extension.dart'; +import 'package:appflowy/workspace/application/appearance_defaults.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class EditorStyleCustomizer { + EditorStyleCustomizer({ + required this.context, + required this.padding, + this.width, + }); + + final BuildContext context; + final EdgeInsets padding; + final double? width; + + static const double maxDocumentWidth = 480 * 4; + static const double minDocumentWidth = 480; + + static EdgeInsets get documentPadding => UniversalPlatform.isMobile + ? EdgeInsets.zero + : EdgeInsets.only( + left: 40, + right: 40 + EditorStyleCustomizer.optionMenuWidth, + ); + + static double get nodeHorizontalPadding => + UniversalPlatform.isMobile ? 24 : 0; + + static EdgeInsets get documentPaddingWithOptionMenu => + documentPadding + EdgeInsets.only(left: optionMenuWidth); + + static double get optionMenuWidth => UniversalPlatform.isMobile ? 0 : 44; + + EditorStyle style() { + if (UniversalPlatform.isDesktopOrWeb) { + return desktop(); + } else if (UniversalPlatform.isMobile) { + return mobile(); + } + throw UnimplementedError(); + } + + EditorStyle desktop() { + final theme = Theme.of(context); + final afThemeExtension = AFThemeExtension.of(context); + final appearanceFont = context.read().state.font; + final appearance = context.read().state; + final fontSize = appearance.fontSize; + String fontFamily = appearance.fontFamily; + if (fontFamily.isEmpty && appearanceFont.isNotEmpty) { + fontFamily = appearanceFont; + } + + return EditorStyle.desktop( + padding: padding, + maxWidth: width, + cursorColor: appearance.cursorColor ?? + DefaultAppearanceSettings.getDefaultCursorColor(context), + selectionColor: appearance.selectionColor ?? + DefaultAppearanceSettings.getDefaultSelectionColor(context), + defaultTextDirection: appearance.defaultTextDirection, + textStyleConfiguration: TextStyleConfiguration( + lineHeight: 1.4, + applyHeightToFirstAscent: true, + applyHeightToLastDescent: true, + text: baseTextStyle(fontFamily).copyWith( + fontSize: fontSize, + color: afThemeExtension.onBackground, + ), + bold: baseTextStyle(fontFamily, fontWeight: FontWeight.bold).copyWith( + fontWeight: FontWeight.w600, + ), + italic: baseTextStyle(fontFamily).copyWith(fontStyle: FontStyle.italic), + underline: baseTextStyle(fontFamily).copyWith( + decoration: TextDecoration.underline, + ), + strikethrough: baseTextStyle(fontFamily).copyWith( + decoration: TextDecoration.lineThrough, + ), + href: baseTextStyle(fontFamily).copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + ), + code: GoogleFonts.robotoMono( + textStyle: baseTextStyle(fontFamily).copyWith( + fontSize: fontSize, + fontWeight: FontWeight.normal, + color: Colors.red, + backgroundColor: theme.colorScheme.inverseSurface.withOpacity(0.8), + ), + ), + ), + textSpanDecorator: customizeAttributeDecorator, + textScaleFactor: + context.watch().state.textScaleFactor, + ); + } + + EditorStyle mobile() { + final afThemeExtension = AFThemeExtension.of(context); + final pageStyle = context.read().state; + final theme = Theme.of(context); + final fontSize = pageStyle.fontLayout.fontSize; + final lineHeight = pageStyle.lineHeightLayout.lineHeight; + final fontFamily = pageStyle.fontFamily ?? + context.read().state.font; + final defaultTextDirection = + context.read().state.defaultTextDirection; + final textScaleFactor = + context.read().state.textScaleFactor; + final baseTextStyle = this.baseTextStyle(fontFamily); + + return EditorStyle.mobile( + padding: padding, + defaultTextDirection: defaultTextDirection, + textStyleConfiguration: TextStyleConfiguration( + lineHeight: lineHeight, + text: baseTextStyle.copyWith( + fontSize: fontSize, + color: afThemeExtension.onBackground, + ), + bold: baseTextStyle.copyWith(fontWeight: FontWeight.w600), + italic: baseTextStyle.copyWith(fontStyle: FontStyle.italic), + underline: baseTextStyle.copyWith(decoration: TextDecoration.underline), + strikethrough: baseTextStyle.copyWith( + decoration: TextDecoration.lineThrough, + ), + href: baseTextStyle.copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + ), + code: GoogleFonts.robotoMono( + textStyle: baseTextStyle.copyWith( + fontSize: fontSize, + fontWeight: FontWeight.normal, + color: Colors.red, + backgroundColor: Colors.grey.withOpacity(0.3), + ), + ), + applyHeightToFirstAscent: true, + applyHeightToLastDescent: true, + ), + textSpanDecorator: customizeAttributeDecorator, + mobileDragHandleBallSize: const Size.square(12.0), + magnifierSize: const Size(144, 96), + textScaleFactor: textScaleFactor, + ); + } + + TextStyle headingStyleBuilder(int level) { + final String? fontFamily; + final List fontSizes; + final double fontSize; + if (UniversalPlatform.isMobile) { + final state = context.read().state; + fontFamily = state.fontFamily; + fontSize = state.fontLayout.fontSize; + fontSizes = state.fontLayout.headingFontSizes; + } else { + fontFamily = context.read().state.fontFamily; + fontSize = context.read().state.fontSize; + fontSizes = [ + fontSize + 16, + fontSize + 12, + fontSize + 8, + fontSize + 4, + fontSize + 2, + fontSize, + ]; + } + return baseTextStyle(fontFamily, fontWeight: FontWeight.w600).copyWith( + fontSize: fontSizes.elementAtOrNull(level - 1) ?? fontSize, + ); + } + + CodeBlockStyle codeBlockStyleBuilder() { + final fontSize = context.read().state.fontSize; + final fontFamily = + context.read().state.codeFontFamily; + + return CodeBlockStyle( + textStyle: baseTextStyle(fontFamily).copyWith( + fontSize: fontSize, + height: 1.5, + color: AFThemeExtension.of(context).onBackground, + ), + backgroundColor: AFThemeExtension.of(context).calloutBGColor, + foregroundColor: AFThemeExtension.of(context).textColor.withAlpha(155), + ); + } + + TextStyle calloutBlockStyleBuilder() { + if (UniversalPlatform.isMobile) { + final afThemeExtension = AFThemeExtension.of(context); + final pageStyle = context.read().state; + final fontSize = pageStyle.fontLayout.fontSize; + final fontFamily = pageStyle.fontFamily ?? defaultFontFamily; + final baseTextStyle = this.baseTextStyle(fontFamily); + return baseTextStyle.copyWith( + fontSize: fontSize, + color: afThemeExtension.onBackground, + ); + } else { + final fontSize = context.read().state.fontSize; + return baseTextStyle(null).copyWith( + fontSize: fontSize, + height: 1.5, + ); + } + } + + TextStyle outlineBlockPlaceholderStyleBuilder() { + final fontSize = context.read().state.fontSize; + return TextStyle( + fontFamily: defaultFontFamily, + fontSize: fontSize, + height: 1.5, + color: AFThemeExtension.of(context).onBackground.withOpacity(0.6), + ); + } + + TextStyle subPageBlockTextStyleBuilder() { + if (UniversalPlatform.isMobile) { + final pageStyle = context.read().state; + final fontSize = pageStyle.fontLayout.fontSize; + final fontFamily = pageStyle.fontFamily ?? defaultFontFamily; + final baseTextStyle = this.baseTextStyle(fontFamily); + return baseTextStyle.copyWith( + fontSize: fontSize, + ); + } else { + final fontSize = context.read().state.fontSize; + return baseTextStyle(null).copyWith( + fontSize: fontSize, + height: 1.5, + ); + } + } + + SelectionMenuStyle selectionMenuStyleBuilder() { + final theme = Theme.of(context); + final afThemeExtension = AFThemeExtension.of(context); + return SelectionMenuStyle( + selectionMenuBackgroundColor: theme.cardColor, + selectionMenuItemTextColor: afThemeExtension.onBackground, + selectionMenuItemIconColor: afThemeExtension.onBackground, + selectionMenuItemSelectedIconColor: theme.colorScheme.onSurface, + selectionMenuItemSelectedTextColor: theme.colorScheme.onSurface, + selectionMenuItemSelectedColor: afThemeExtension.greyHover, + ); + } + + InlineActionsMenuStyle inlineActionsMenuStyleBuilder() { + final theme = Theme.of(context); + final afThemeExtension = AFThemeExtension.of(context); + return InlineActionsMenuStyle( + backgroundColor: theme.cardColor, + groupTextColor: afThemeExtension.onBackground.withOpacity(.8), + menuItemTextColor: afThemeExtension.onBackground, + menuItemSelectedColor: theme.colorScheme.secondary, + menuItemSelectedTextColor: theme.colorScheme.onSurface, + ); + } + + FloatingToolbarStyle floatingToolbarStyleBuilder() => FloatingToolbarStyle( + backgroundColor: Theme.of(context).colorScheme.onTertiary, + ); + + TextStyle baseTextStyle(String? fontFamily, {FontWeight? fontWeight}) { + if (fontFamily == null || fontFamily == defaultFontFamily) { + return TextStyle(fontWeight: fontWeight); + } + try { + return getGoogleFontSafely(fontFamily, fontWeight: fontWeight); + } on Exception { + if ([defaultFontFamily, builtInCodeFontFamily].contains(fontFamily)) { + return TextStyle(fontFamily: fontFamily, fontWeight: fontWeight); + } + + return TextStyle(fontWeight: fontWeight); + } + } + + InlineSpan customizeAttributeDecorator( + BuildContext context, + Node node, + int index, + TextInsert text, + TextSpan before, + TextSpan after, + ) { + final attributes = text.attributes; + if (attributes == null) { + return before; + } + + if (attributes.backgroundColor != null) { + final color = EditorFontColors.fromBuiltInColors( + context, + attributes.backgroundColor!, + ); + if (color != null) { + return TextSpan( + text: before.text, + style: after.style?.merge( + TextStyle(backgroundColor: color), + ), + ); + } + } + + // try to refresh font here. + if (attributes.fontFamily != null) { + try { + if (before.text?.contains('_regular') == true) { + getGoogleFontSafely(attributes.fontFamily!.parseFontFamilyName()); + } else { + return TextSpan( + text: before.text, + style: after.style?.merge( + getGoogleFontSafely(attributes.fontFamily!), + ), + ); + } + } catch (_) { + // ignore + } + } + + // Inline Mentions (Page Reference, Date, Reminder, etc.) + final mention = + attributes[MentionBlockKeys.mention] as Map?; + if (mention != null) { + final type = mention[MentionBlockKeys.type]; + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + style: after.style, + child: MentionBlock( + key: ValueKey( + switch (type) { + MentionType.page => mention[MentionBlockKeys.pageId], + MentionType.date => mention[MentionBlockKeys.date], + _ => MentionBlockKeys.mention, + }, + ), + node: node, + index: index, + mention: mention, + textStyle: after.style, + ), + ); + } + + // customize the inline math equation block + final formula = attributes[InlineMathEquationKeys.formula]; + if (formula is String) { + return WidgetSpan( + style: after.style, + alignment: PlaceholderAlignment.middle, + child: InlineMathEquation( + node: node, + index: index, + formula: formula, + textStyle: after.style ?? style().textStyleConfiguration.text, + ), + ); + } + + // customize the link on mobile + final href = attributes[AppFlowyRichTextKeys.href] as String?; + if (UniversalPlatform.isMobile && href != null) { + return TextSpan( + style: before.style, + text: text.text, + recognizer: TapGestureRecognizer() + ..onTap = () { + final editorState = context.read(); + if (editorState.selection == null) { + afLaunchUrlString(href, addingHttpSchemeWhenFailed: true); + return; + } + + editorState.updateSelectionWithReason( + editorState.selection, + extraInfo: {selectionExtraInfoDisableMobileToolbarKey: true}, + ); + + showEditLinkBottomSheet( + context, + text.text, + href, + (linkContext, newText, newHref) { + final selection = Selection.single( + path: node.path, + startOffset: index, + endOffset: index + text.text.length, + ); + editorState.updateTextAndHref( + text.text, + href, + newText, + newHref, + selection: selection, + ); + linkContext.pop(); + }, + ); + }, + ); + } + + return defaultTextSpanDecoratorForAttribute( + context, + node, + index, + text, + before, + after, + ); + } + + Widget buildToolbarItemTooltip( + BuildContext context, + String id, + String message, + Widget child, + ) { + final tooltipMessage = _buildTooltipMessage(id, message); + child = FlowyTooltip( + richMessage: tooltipMessage, + preferBelow: false, + verticalOffset: 20, + child: child, + ); + + // the align/font toolbar item doesn't need the hover effect + final toolbarItemsWithoutHover = { + kFontToolbarItemId, + kAlignToolbarItemId, + }; + + if (!toolbarItemsWithoutHover.contains(id)) { + child = Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: FlowyHover( + style: HoverStyle( + hoverColor: Colors.grey.withOpacity(0.3), + ), + child: child, + ), + ); + } + + return child; + } + + TextSpan _buildTooltipMessage(String id, String message) { + final markdownItemTooltips = { + 'underline': (LocaleKeys.toolbar_underline.tr(), 'U'), + 'bold': (LocaleKeys.toolbar_bold.tr(), 'B'), + 'italic': (LocaleKeys.toolbar_italic.tr(), 'I'), + 'strikethrough': (LocaleKeys.toolbar_strike.tr(), 'Shift+S'), + 'code': (LocaleKeys.toolbar_inlineCode.tr(), 'E'), + }; + + final markdownItemIds = markdownItemTooltips.keys.toSet(); + // the items without shortcuts + if (!markdownItemIds.contains(id)) { + return TextSpan( + text: message, + style: context.tooltipTextStyle(), + ); + } + + final tooltip = markdownItemTooltips[id]; + if (tooltip == null) { + return TextSpan( + text: message, + style: context.tooltipTextStyle(), + ); + } + + final textSpan = TextSpan( + children: [ + TextSpan( + text: '${tooltip.$1}\n', + style: context.tooltipTextStyle(), + ), + TextSpan( + text: (Platform.isMacOS ? '⌘+' : 'Ctrl+\\') + tooltip.$2, + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], + ); + + return textSpan; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart new file mode 100644 index 0000000000000..d29a1f86bf698 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/child_page.dart @@ -0,0 +1,84 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/service_handler.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class InlineChildPageService extends InlineActionsDelegate { + InlineChildPageService({required this.currentViewId}); + + final String currentViewId; + + @override + Future search(String? search) async { + final List results = []; + if (search != null && search.isNotEmpty) { + results.add( + InlineActionsMenuItem( + label: LocaleKeys.inlineActions_createPage.tr(args: [search]), + icon: (_) => const FlowySvg(FlowySvgs.add_s), + onSelected: (context, editorState, service, replacement) => + _onSelected(context, editorState, service, replacement, search), + ), + ); + } + + return InlineActionsResult(results: results); + } + + Future _onSelected( + BuildContext context, + EditorState editorState, + InlineActionsMenuService service, + (int, int) replacement, + String? search, + ) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + final view = (await ViewBackendService.createView( + layoutType: ViewLayoutPB.Document, + parentViewId: currentViewId, + name: search!, + )) + .toNullable(); + + if (view == null) { + return Log.error('Failed to create view'); + } + + // preload the page info + pageMemorizer[view.id] = view; + final transaction = editorState.transaction + ..replaceText( + node, + replacement.$1, + replacement.$2, + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.childPage.name, + MentionBlockKeys.pageId: view.id, + }, + }, + ); + + await editorState.apply(transaction); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart new file mode 100644 index 0000000000000..904e10d36219a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/date_reference.dart @@ -0,0 +1,190 @@ +import 'package:appflowy/date/date_service.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/service_handler.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +final _keywords = [ + LocaleKeys.inlineActions_date.tr().toLowerCase(), +]; + +class DateReferenceService extends InlineActionsDelegate { + DateReferenceService(this.context) { + // Initialize locale + _locale = context.locale.toLanguageTag(); + + // Initializes options + _setOptions(); + } + + final BuildContext context; + + late String _locale; + late List _allOptions; + + List options = []; + + @override + Future search([ + String? search, + ]) async { + // Checks if Locale has changed since last + _setLocale(); + + // Filters static options + _filterOptions(search); + + // Searches for date by pattern + _searchDate(search); + + // Searches for date by natural language prompt + await _searchDateNLP(search); + + return InlineActionsResult( + title: LocaleKeys.inlineActions_date.tr(), + results: options, + ); + } + + void _filterOptions(String? search) { + if (search == null || search.isEmpty) { + options = _allOptions; + return; + } + + options = _allOptions + .where( + (option) => + option.keywords != null && + option.keywords!.isNotEmpty && + option.keywords!.any( + (keyword) => keyword.contains(search.toLowerCase()), + ), + ) + .toList(); + + if (options.isEmpty && _keywords.any((k) => search.startsWith(k))) { + _setOptions(); + options = _allOptions; + } + } + + void _searchDate(String? search) { + if (search == null || search.isEmpty) { + return; + } + + try { + final date = DateFormat.yMd(_locale).parse(search); + options.insert(0, _itemFromDate(date)); + } catch (_) { + return; + } + } + + Future _searchDateNLP(String? search) async { + if (search == null || search.isEmpty) { + return; + } + + final result = await DateService.queryDate(search); + + result.fold( + (date) => options.insert(0, _itemFromDate(date)), + (_) {}, + ); + } + + Future _insertDateReference( + EditorState editorState, + DateTime date, + int start, + int end, + ) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + final transaction = editorState.transaction + ..replaceText( + node, + start, + end, + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.date.name, + MentionBlockKeys.date: date.toIso8601String(), + }, + }, + ); + + await editorState.apply(transaction); + } + + void _setOptions() { + final today = DateTime.now(); + final tomorrow = today.add(const Duration(days: 1)); + final yesterday = today.subtract(const Duration(days: 1)); + + _allOptions = [ + _itemFromDate( + today, + LocaleKeys.relativeDates_today.tr(), + [DateFormat.yMd(_locale).format(today)], + ), + _itemFromDate( + tomorrow, + LocaleKeys.relativeDates_tomorrow.tr(), + [DateFormat.yMd(_locale).format(tomorrow)], + ), + _itemFromDate( + yesterday, + LocaleKeys.relativeDates_yesterday.tr(), + [DateFormat.yMd(_locale).format(yesterday)], + ), + ]; + } + + /// Sets Locale on each search to make sure + /// keywords are localized + void _setLocale() { + final locale = context.locale.toLanguageTag(); + + if (locale != _locale) { + _locale = locale; + _setOptions(); + } + } + + InlineActionsMenuItem _itemFromDate( + DateTime date, [ + String? label, + List? keywords, + ]) { + final labelStr = label ?? DateFormat.yMd(_locale).format(date); + + return InlineActionsMenuItem( + label: labelStr.capitalize(), + keywords: [labelStr.toLowerCase(), ...?keywords], + onSelected: (context, editorState, menuService, replace) => + _insertDateReference( + editorState, + date, + replace.$1, + replace.$2, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart new file mode 100644 index 0000000000000..ff9e751e3f2d9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/inline_page_reference.dart @@ -0,0 +1,260 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/insert_page_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/service_handler.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/list_extension.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; +import 'package:flutter/material.dart'; + +// const _channel = "InlinePageReference"; + +// TODO(Mathias): Clean up and use folder search instead +class InlinePageReferenceService extends InlineActionsDelegate { + InlinePageReferenceService({ + required this.currentViewId, + this.viewLayout, + this.customTitle, + this.insertPage = false, + this.limitResults = 5, + }) : assert(limitResults > 0, 'limitResults must be greater than 0') { + init(); + } + + final Completer _initCompleter = Completer(); + + final String currentViewId; + final ViewLayoutPB? viewLayout; + final String? customTitle; + + /// Defaults to false, if set to true the Page + /// will be inserted as a Reference + /// When false, a link to the view will be inserted + /// + final bool insertPage; + + /// Defaults to 5 + /// Will limit the page reference results + /// to [limitResults]. + /// + final int limitResults; + + late final CachedRecentService _recentService; + + bool _recentViewsInitialized = false; + late final List _recentViews; + + Future> _getRecentViews() async { + if (_recentViewsInitialized) { + return _recentViews; + } + + _recentViewsInitialized = true; + + final sectionViews = await _recentService.recentViews(); + final views = + sectionViews.unique((e) => e.item.id).map((e) => e.item).toList(); + + // Filter by viewLayout + views.retainWhere( + (i) => + currentViewId != i.id && + (viewLayout == null || i.layout == viewLayout), + ); + + // Map to InlineActionsMenuItem, then take 5 items + return _recentViews = views.map(_fromView).take(5).toList(); + } + + bool _viewsInitialized = false; + late final List _allViews; + + Future> _getViews() async { + if (_viewsInitialized) { + return _allViews; + } + + _viewsInitialized = true; + + final viewResult = await ViewBackendService.getAllViews(); + return _allViews = viewResult + .toNullable() + ?.items + .where((v) => viewLayout == null || v.layout == viewLayout) + .toList() ?? + const []; + } + + Future init() async { + _recentService = getIt(); + // _searchListener.start(onResultsClosed: _onResults); + } + + @override + Future dispose() async { + if (!_initCompleter.isCompleted) { + _initCompleter.complete(); + } + + await super.dispose(); + } + + @override + Future search([ + String? search, + ]) async { + final isSearching = search != null && search.isNotEmpty; + + late List items; + if (isSearching) { + final allViews = await _getViews(); + + items = allViews + .where( + (view) => + view.id != currentViewId && + view.name.toLowerCase().contains(search.toLowerCase()) || + (view.name.isEmpty && search.isEmpty) || + (view.name.isEmpty && + LocaleKeys.menuAppHeader_defaultNewPageName + .tr() + .toLowerCase() + .contains(search.toLowerCase())), + ) + .take(limitResults) + .map((view) => _fromView(view)) + .toList(); + } else { + items = await _getRecentViews(); + } + + return InlineActionsResult( + title: customTitle?.isNotEmpty == true + ? customTitle! + : isSearching + ? LocaleKeys.inlineActions_pageReference.tr() + : LocaleKeys.inlineActions_recentPages.tr(), + results: items, + ); + } + + Future _onInsertPageRef( + ViewPB view, + BuildContext context, + EditorState editorState, + (int, int) replace, + ) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = editorState.getNodeAtPath(selection.start.path); + + if (node != null) { + // Delete search term + if (replace.$2 > 0) { + final transaction = editorState.transaction + ..deleteText(node, replace.$1, replace.$2); + await editorState.apply(transaction); + } + + // Insert newline before inserting referenced database + if (node.delta?.toPlainText().isNotEmpty == true) { + await editorState.insertNewLine(); + } + } + + try { + await editorState.insertReferencePage(view, view.layout); + } on FlowyError catch (e) { + if (context.mounted) { + return Dialogs.show( + context, + child: AppFlowyErrorPage( + error: e, + ), + ); + } + } + } + + Future _onInsertLinkRef( + ViewPB view, + BuildContext context, + EditorState editorState, + InlineActionsMenuService menuService, + (int, int) replace, + ) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = editorState.getNodeAtPath(selection.start.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + // @page name -> $ + // preload the page infos + pageMemorizer[view.id] = view; + final transaction = editorState.transaction + ..replaceText( + node, + replace.$1, + replace.$2, + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.page.name, + MentionBlockKeys.pageId: view.id, + }, + }, + ); + + await editorState.apply(transaction); + } + + InlineActionsMenuItem _fromView(ViewPB view) => InlineActionsMenuItem( + keywords: [view.nameOrDefault.toLowerCase()], + label: view.nameOrDefault, + icon: (onSelected) => view.icon.value.isNotEmpty + ? EmojiIconWidget( + emoji: view.icon.toEmojiIconData(), + emojiSize: 14, + ) + : view.defaultIcon(), + onSelected: (context, editorState, menu, replace) => insertPage + ? _onInsertPageRef(view, context, editorState, replace) + : _onInsertLinkRef(view, context, editorState, menu, replace), + ); + +// Future _fromSearchResult( +// SearchResultPB result, +// ) async { +// final viewRes = await ViewBackendService.getView(result.viewId); +// final view = viewRes.toNullable(); +// if (view == null) { +// return null; +// } + +// return _fromView(view); +// } +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart new file mode 100644 index 0000000000000..372cc786984a5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/reminder_reference.dart @@ -0,0 +1,229 @@ +import 'package:appflowy/date/date_service.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/service_handler.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nanoid/nanoid.dart'; + +final _keywords = [ + LocaleKeys.inlineActions_reminder_groupTitle.tr().toLowerCase(), + LocaleKeys.inlineActions_reminder_shortKeyword.tr().toLowerCase(), +]; + +class ReminderReferenceService extends InlineActionsDelegate { + ReminderReferenceService(this.context) { + // Initialize locale + _locale = context.locale.toLanguageTag(); + + // Initializes options + _setOptions(); + } + + final BuildContext context; + + late String _locale; + late List _allOptions; + + List options = []; + + @override + Future search([ + String? search, + ]) async { + // Checks if Locale has changed since last + _setLocale(); + + // Filters static options + _filterOptions(search); + + // Searches for date by pattern + _searchDate(search); + + // Searches for date by natural language prompt + await _searchDateNLP(search); + + return _groupFromResults(options); + } + + InlineActionsResult _groupFromResults([ + List? options, + ]) => + InlineActionsResult( + title: LocaleKeys.inlineActions_reminder_groupTitle.tr(), + results: options ?? [], + startsWithKeywords: [ + LocaleKeys.inlineActions_reminder_groupTitle.tr().toLowerCase(), + LocaleKeys.inlineActions_reminder_shortKeyword.tr().toLowerCase(), + ], + ); + + void _filterOptions(String? search) { + if (search == null || search.isEmpty) { + options = _allOptions; + return; + } + + options = _allOptions + .where( + (option) => + option.keywords != null && + option.keywords!.isNotEmpty && + option.keywords!.any( + (keyword) => keyword.contains(search.toLowerCase()), + ), + ) + .toList(); + + if (options.isEmpty && _keywords.any((k) => search.startsWith(k))) { + _setOptions(); + options = _allOptions; + } + } + + void _searchDate(String? search) { + if (search == null || search.isEmpty) { + return; + } + + try { + final date = DateFormat.yMd(_locale).parse(search); + options.insert(0, _itemFromDate(date)); + } catch (_) { + return; + } + } + + Future _searchDateNLP(String? search) async { + if (search == null || search.isEmpty) { + return; + } + + final result = await DateService.queryDate(search); + + result.fold( + (date) { + // Only insert dates in the future + if (DateTime.now().isBefore(date)) { + options.insert(0, _itemFromDate(date)); + } + }, + (_) {}, + ); + } + + Future _insertReminderReference( + EditorState editorState, + DateTime date, + int start, + int end, + ) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + final viewId = context.read().documentId; + final reminder = _reminderFromDate(date, viewId, node); + + final transaction = editorState.transaction + ..replaceText( + node, + start, + end, + MentionBlockKeys.mentionChar, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.date.name, + MentionBlockKeys.date: date.toIso8601String(), + MentionBlockKeys.reminderId: reminder.id, + MentionBlockKeys.reminderOption: ReminderOption.atTimeOfEvent.name, + }, + }, + ); + + await editorState.apply(transaction); + + if (context.mounted) { + context.read().add(ReminderEvent.add(reminder: reminder)); + } + } + + void _setOptions() { + final today = DateTime.now(); + final tomorrow = today.add(const Duration(days: 1)); + final oneWeek = today.add(const Duration(days: 7)); + + _allOptions = [ + _itemFromDate( + tomorrow, + LocaleKeys.relativeDates_tomorrow.tr(), + [DateFormat.yMd(_locale).format(tomorrow)], + ), + _itemFromDate( + oneWeek, + LocaleKeys.relativeDates_oneWeek.tr(), + [DateFormat.yMd(_locale).format(oneWeek)], + ), + ]; + } + + /// Sets Locale on each search to make sure + /// keywords are localized + void _setLocale() { + final locale = context.locale.toLanguageTag(); + + if (locale != _locale) { + _locale = locale; + _setOptions(); + } + } + + InlineActionsMenuItem _itemFromDate( + DateTime date, [ + String? label, + List? keywords, + ]) { + final labelStr = label ?? DateFormat.yMd(_locale).format(date); + + return InlineActionsMenuItem( + label: labelStr.capitalize(), + keywords: [labelStr.toLowerCase(), ...?keywords], + onSelected: (context, editorState, menuService, replace) => + _insertReminderReference(editorState, date, replace.$1, replace.$2), + ); + } + + ReminderPB _reminderFromDate(DateTime date, String viewId, Node node) { + return ReminderPB( + id: nanoid(), + objectId: viewId, + title: LocaleKeys.reminderNotification_title.tr(), + message: LocaleKeys.reminderNotification_message.tr(), + meta: { + ReminderMetaKeys.includeTime: false.toString(), + ReminderMetaKeys.blockId: node.id, + ReminderMetaKeys.createdAt: + DateTime.now().millisecondsSinceEpoch.toString(), + }, + scheduledAt: Int64(date.millisecondsSinceEpoch ~/ 1000), + isAck: date.isBefore(DateTime.now()), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart new file mode 100644 index 0000000000000..0ef4ac7c09cb9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_command.dart @@ -0,0 +1,65 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:universal_platform/universal_platform.dart'; + +const inlineActionCharacter = '@'; + +CharacterShortcutEvent inlineActionsCommand( + InlineActionsService inlineActionsService, { + InlineActionsMenuStyle style = const InlineActionsMenuStyle.light(), +}) => + CharacterShortcutEvent( + key: 'Opens Inline Actions Menu', + character: inlineActionCharacter, + handler: (editorState) => inlineActionsCommandHandler( + editorState, + inlineActionsService, + style, + ), + ); + +InlineActionsMenuService? selectionMenuService; +Future inlineActionsCommandHandler( + EditorState editorState, + InlineActionsService service, + InlineActionsMenuStyle style, +) async { + final selection = editorState.selection; + if (UniversalPlatform.isMobile || selection == null) { + return false; + } + + if (!selection.isCollapsed) { + await editorState.deleteSelection(selection); + } + + await editorState.insertTextAtPosition( + inlineActionCharacter, + position: selection.start, + ); + + final List initialResults = []; + for (final handler in service.handlers) { + final group = await handler.search(null); + + if (group.results.isNotEmpty) { + initialResults.add(group); + } + } + + if (service.context != null) { + selectionMenuService = InlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + ); + + selectionMenuService?.show(); + } + + return true; +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart new file mode 100644 index 0000000000000..dadc4ebf6f428 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart @@ -0,0 +1,247 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +abstract class InlineActionsMenuService { + InlineActionsMenuStyle get style; + + void show(); + void dismiss(); +} + +class InlineActionsMenu extends InlineActionsMenuService { + InlineActionsMenu({ + required this.context, + required this.editorState, + required this.service, + required this.initialResults, + required this.style, + this.startCharAmount = 1, + this.cancelBySpaceHandler, + }); + + final BuildContext context; + final EditorState editorState; + final InlineActionsService service; + final List initialResults; + final bool Function()? cancelBySpaceHandler; + + @override + final InlineActionsMenuStyle style; + + final int startCharAmount; + + OverlayEntry? _menuEntry; + bool selectionChangedByMenu = false; + + @override + void dismiss() { + if (_menuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + keepEditorFocusNotifier.decrease(); + } + + _menuEntry?.remove(); + _menuEntry = null; + + // workaround: SelectionService has been released after hot reload. + final isSelectionDisposed = + editorState.service.selectionServiceKey.currentState == null; + if (!isSelectionDisposed) { + final selectionService = editorState.service.selectionService; + selectionService.currentSelection.removeListener(_onSelectionChange); + } + } + + void _onSelectionUpdate() => selectionChangedByMenu = true; + + @override + void show() { + WidgetsBinding.instance.addPostFrameCallback((_) => _show()); + } + + void _show() { + dismiss(); + + final selectionService = editorState.service.selectionService; + final selectionRects = selectionService.selectionRects; + if (selectionRects.isEmpty) { + return; + } + + const double menuHeight = 300.0; + const double menuWidth = 200.0; + const Offset menuOffset = Offset(0, 10); + final Offset editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final Size editorSize = editorState.renderBox!.size; + + // Default to opening the overlay below + Alignment alignment = Alignment.topLeft; + + final firstRect = selectionRects.first; + Offset offset = firstRect.bottomRight + menuOffset; + + // Show above + if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) { + offset = firstRect.topRight - menuOffset; + alignment = Alignment.bottomLeft; + + offset = Offset( + offset.dx, + MediaQuery.of(context).size.height - offset.dy, + ); + } + + // Show on the left + final windowWidth = MediaQuery.of(context).size.width; + if (offset.dx > (windowWidth - menuWidth)) { + alignment = alignment == Alignment.topLeft + ? Alignment.topRight + : Alignment.bottomRight; + + offset = Offset( + windowWidth - offset.dx, + offset.dy, + ); + } + + final (left, top, right, bottom) = _getPosition(alignment, offset); + + _menuEntry = OverlayEntry( + builder: (context) => SizedBox( + height: editorSize.height, + width: editorSize.width, + + // GestureDetector handles clicks outside of the context menu, + // to dismiss the context menu. + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + Positioned( + top: top, + bottom: bottom, + left: left, + right: right, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: InlineActionsHandler( + service: service, + results: initialResults, + editorState: editorState, + menuService: this, + onDismiss: dismiss, + onSelectionUpdate: _onSelectionUpdate, + style: style, + startCharAmount: startCharAmount, + cancelBySpaceHandler: cancelBySpaceHandler, + ), + ), + ), + ], + ), + ), + ), + ); + + Overlay.of(context).insert(_menuEntry!); + + editorState.service.keyboardService?.disable(showCursor: true); + editorState.service.scrollService?.disable(); + selectionService.currentSelection.addListener(_onSelectionChange); + } + + void _onSelectionChange() { + // workaround: SelectionService has been released after hot reload. + final isSelectionDisposed = + editorState.service.selectionServiceKey.currentState == null; + if (!isSelectionDisposed) { + final selectionService = editorState.service.selectionService; + if (selectionService.currentSelection.value == null) { + return; + } + } + + if (!selectionChangedByMenu) { + return dismiss(); + } + + selectionChangedByMenu = false; + } + + (double? left, double? top, double? right, double? bottom) _getPosition( + Alignment alignment, + Offset offset, + ) { + double? left, top, right, bottom; + switch (alignment) { + case Alignment.topLeft: + left = offset.dx; + top = offset.dy; + break; + case Alignment.bottomLeft: + left = offset.dx; + bottom = offset.dy; + break; + case Alignment.topRight: + right = offset.dx; + top = offset.dy; + break; + case Alignment.bottomRight: + right = offset.dx; + bottom = offset.dy; + break; + } + + return (left, top, right, bottom); + } +} + +class InlineActionsMenuStyle { + InlineActionsMenuStyle({ + required this.backgroundColor, + required this.groupTextColor, + required this.menuItemTextColor, + required this.menuItemSelectedColor, + required this.menuItemSelectedTextColor, + }); + + const InlineActionsMenuStyle.light() + : backgroundColor = Colors.white, + groupTextColor = const Color(0xFF555555), + menuItemTextColor = const Color(0xFF333333), + menuItemSelectedColor = const Color(0xFFE0F8FF), + menuItemSelectedTextColor = const Color.fromARGB(255, 56, 91, 247); + + const InlineActionsMenuStyle.dark() + : backgroundColor = const Color(0xFF282E3A), + groupTextColor = const Color(0xFFBBC3CD), + menuItemTextColor = const Color(0xFFBBC3CD), + menuItemSelectedColor = const Color(0xFF00BCF0), + menuItemSelectedTextColor = const Color(0xFF131720); + + /// The background color of the context menu itself + /// + final Color backgroundColor; + + /// The color of the [InlineActionsGroup]'s title text + /// + final Color groupTextColor; + + /// The text color of an [InlineActionsMenuItem] + /// + final Color menuItemTextColor; + + /// The background of the currently selected [InlineActionsMenuItem] + /// + final Color menuItemSelectedColor; + + /// The text color of the currently selected [InlineActionsMenuItem] + /// + final Color menuItemSelectedTextColor; +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart new file mode 100644 index 0000000000000..8da964708408e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_result.dart @@ -0,0 +1,50 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +typedef SelectItemHandler = void Function( + BuildContext context, + EditorState editorState, + InlineActionsMenuService menuService, + (int start, int end) replacement, +); + +class InlineActionsMenuItem { + InlineActionsMenuItem({ + required this.label, + this.icon, + this.keywords, + this.onSelected, + }); + + final String label; + final Widget Function(bool onSelected)? icon; + final List? keywords; + final SelectItemHandler? onSelected; +} + +class InlineActionsResult { + InlineActionsResult({ + this.title, + required this.results, + this.startsWithKeywords, + }); + + /// Localized title to be displayed above the results + /// of the current group. + /// + /// If null, no title will be displayed. + /// + final String? title; + + /// List of results that will be displayed for this group + /// made up of [SelectionMenuItem]s. + /// + final List results; + + /// If the search term start with one of these keyword, + /// the results will be reordered such that these results + /// will be above. + /// + final List? startsWithKeywords; +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart new file mode 100644 index 0000000000000..3bdd5bf61a4d9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_service.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/inline_actions/service_handler.dart'; + +abstract class _InlineActionsProvider { + void dispose(); +} + +class InlineActionsService extends _InlineActionsProvider { + InlineActionsService({ + required this.context, + required this.handlers, + }); + + /// The [BuildContext] in which to show the [InlineActionsMenu] + /// + BuildContext? context; + + final List handlers; + + /// This is a workaround for not having a mounted check. + /// Thus when the widget that uses the service is disposed, + /// we set the [BuildContext] to null. + /// + @override + Future dispose() async { + for (final handler in handlers) { + await handler.dispose(); + } + context = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/service_handler.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/service_handler.dart new file mode 100644 index 0000000000000..de537fb964a92 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/service_handler.dart @@ -0,0 +1,7 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; + +abstract class InlineActionsDelegate { + Future search(String? search); + + Future dispose() async {} +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart new file mode 100644 index 0000000000000..be9a6c2f5fc6b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart @@ -0,0 +1,468 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_menu_group.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// All heights are in physical pixels +const double _groupTextHeight = 14; // 12 height + 2 bottom spacing +const double _groupBottomSpacing = 6; +const double _itemHeight = 30; // 26 height + 4 vertical spacing (2*2) + +const double kInlineMenuHeight = 300; +const double kInlineMenuWidth = 400; +const double _contentHeight = 260; + +extension _StartWithsSort on List { + void sortByStartsWithKeyword(String search) => sort( + (a, b) { + final aCount = a.startsWithKeywords + ?.where( + (key) => search.toLowerCase().startsWith(key), + ) + .length ?? + 0; + + final bCount = b.startsWithKeywords + ?.where( + (key) => search.toLowerCase().startsWith(key), + ) + .length ?? + 0; + + if (aCount > bCount) { + return -1; + } else if (bCount > aCount) { + return 1; + } + + return 0; + }, + ); +} + +const _invalidSearchesAmount = 10; + +class InlineActionsHandler extends StatefulWidget { + const InlineActionsHandler({ + super.key, + required this.service, + required this.results, + required this.editorState, + required this.menuService, + required this.onDismiss, + required this.onSelectionUpdate, + required this.style, + this.startCharAmount = 1, + this.cancelBySpaceHandler, + }); + + final InlineActionsService service; + final List results; + final EditorState editorState; + final InlineActionsMenuService menuService; + final VoidCallback onDismiss; + final VoidCallback onSelectionUpdate; + final InlineActionsMenuStyle style; + final int startCharAmount; + final bool Function()? cancelBySpaceHandler; + + @override + State createState() => _InlineActionsHandlerState(); +} + +class _InlineActionsHandlerState extends State { + final _focusNode = FocusNode(debugLabel: 'inline_actions_menu_handler'); + final _scrollController = ScrollController(); + + late List results = widget.results; + int invalidCounter = 0; + late int startOffset; + + String _search = ''; + set search(String search) { + _search = search; + _doSearch(); + } + + Future _doSearch() async { + final List newResults = []; + for (final handler in widget.service.handlers) { + final group = await handler.search(_search); + + if (group.results.isNotEmpty) { + newResults.add(group); + } + } + + invalidCounter = results.every((group) => group.results.isEmpty) + ? invalidCounter + 1 + : 0; + + if (invalidCounter >= _invalidSearchesAmount) { + widget.onDismiss(); + + // Workaround to bring focus back to editor + await widget.editorState + .updateSelectionWithReason(widget.editorState.selection); + + return; + } + + _resetSelection(); + + newResults.sortByStartsWithKeyword(_search); + setState(() => results = newResults); + } + + void _resetSelection() { + _selectedGroup = 0; + _selectedIndex = 0; + } + + int _selectedGroup = 0; + int _selectedIndex = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _focusNode.requestFocus(), + ); + + startOffset = widget.editorState.selection?.endIndex ?? 0; + } + + @override + void dispose() { + _scrollController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: _focusNode, + onKeyEvent: onKeyEvent, + child: Container( + constraints: const BoxConstraints( + maxHeight: kInlineMenuHeight, + minWidth: kInlineMenuWidth, + ), + decoration: BoxDecoration( + color: widget.style.backgroundColor, + borderRadius: BorderRadius.circular(6.0), + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: noResults + ? SizedBox( + width: 150, + child: FlowyText.regular( + LocaleKeys.inlineActions_noResults.tr(), + ), + ) + : SingleChildScrollView( + controller: _scrollController, + physics: const ClampingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: results + .where((g) => g.results.isNotEmpty) + .mapIndexed( + (index, group) => InlineActionsGroup( + result: group, + editorState: widget.editorState, + menuService: widget.menuService, + style: widget.style, + onSelected: widget.onDismiss, + startOffset: startOffset - widget.startCharAmount, + endOffset: _search.length + widget.startCharAmount, + isLastGroup: index == results.length - 1, + isGroupSelected: _selectedGroup == index, + selectedIndex: _selectedIndex, + ), + ) + .toList(), + ), + ), + ), + ), + ); + } + + bool get noResults => + results.isEmpty || results.every((e) => e.results.isEmpty); + + int get groupLength => results.length; + + int lengthOfGroup(int index) => + results.length > index ? results[index].results.length : -1; + + InlineActionsMenuItem handlerOf(int groupIndex, int handlerIndex) => + results[groupIndex].results[handlerIndex]; + + KeyEventResult onKeyEvent(focus, KeyEvent event) { + if (event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + + const moveKeys = [ + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.tab, + ]; + + if (event.logicalKey == LogicalKeyboardKey.enter) { + if (_selectedGroup <= groupLength && + _selectedIndex <= lengthOfGroup(_selectedGroup)) { + handlerOf(_selectedGroup, _selectedIndex).onSelected?.call( + context, + widget.editorState, + widget.menuService, + ( + startOffset - widget.startCharAmount, + _search.length + widget.startCharAmount + ), + ); + + widget.onDismiss(); + return KeyEventResult.handled; + } + + if (noResults) { + // Workaround to bring focus back to editor + widget.editorState + .updateSelectionWithReason(widget.editorState.selection); + widget.editorState.insertNewLine(); + + widget.onDismiss(); + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + // Workaround to bring focus back to editor + widget.editorState + .updateSelectionWithReason(widget.editorState.selection); + + widget.onDismiss(); + } else if (event.logicalKey == LogicalKeyboardKey.backspace) { + if (_search.isEmpty) { + if (_canDeleteLastCharacter()) { + widget.editorState.deleteBackward(); + } else { + // Workaround for editor regaining focus + widget.editorState.apply( + widget.editorState.transaction + ..afterSelection = widget.editorState.selection, + ); + } + widget.onDismiss(); + } else { + widget.onSelectionUpdate(); + widget.editorState.deleteBackward(); + _deleteCharacterAtSelection(); + } + + return KeyEventResult.handled; + } else if (event.character != null && + ![ + ...moveKeys, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + ].contains(event.logicalKey)) { + /// Prevents dismissal of context menu by notifying the parent + /// that the selection change occurred from the handler. + widget.onSelectionUpdate(); + + if (event.logicalKey == LogicalKeyboardKey.space) { + final cancelBySpaceHandler = widget.cancelBySpaceHandler; + if (cancelBySpaceHandler != null && cancelBySpaceHandler()) { + return KeyEventResult.handled; + } + } + + // Interpolation to avoid having a getter for private variable + _insertCharacter(event.character!); + return KeyEventResult.handled; + } else if (moveKeys.contains(event.logicalKey)) { + _moveSelection(event.logicalKey); + return KeyEventResult.handled; + } + + if ([LogicalKeyboardKey.arrowLeft, LogicalKeyboardKey.arrowRight] + .contains(event.logicalKey)) { + widget.onSelectionUpdate(); + + event.logicalKey == LogicalKeyboardKey.arrowLeft + ? widget.editorState.moveCursorForward() + : widget.editorState.moveCursorBackward(SelectionMoveRange.character); + + /// If cursor moves before @ then dismiss menu + /// If cursor moves after @search.length then dismiss menu + final selection = widget.editorState.selection; + if (selection != null && + (selection.endIndex < startOffset || + selection.endIndex > (startOffset + _search.length))) { + widget.onDismiss(); + } + + /// Workaround: When using the move cursor methods, it seems the + /// focus goes back to the editor, this makes sure this handler + /// receives the next keypress. + /// + _focusNode.requestFocus(); + + return KeyEventResult.handled; + } + + return KeyEventResult.handled; + } + + void _insertCharacter(String character) { + widget.editorState.insertTextAtCurrentSelection(character); + + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final delta = widget.editorState.getNodeAtPath(selection.end.path)?.delta; + if (delta == null) { + return; + } + + search = widget.editorState + .getTextInSelection( + selection.copyWith( + start: selection.start.copyWith(offset: startOffset), + end: selection.start + .copyWith(offset: startOffset + _search.length + 1), + ), + ) + .join(); + } + + void _moveSelection(LogicalKeyboardKey key) { + bool didChange = false; + + if (key == LogicalKeyboardKey.arrowUp || + (key == LogicalKeyboardKey.tab && + HardwareKeyboard.instance.isShiftPressed)) { + if (_selectedIndex == 0 && _selectedGroup > 0) { + _selectedGroup -= 1; + _selectedIndex = lengthOfGroup(_selectedGroup) - 1; + didChange = true; + } else if (_selectedIndex > 0) { + _selectedIndex -= 1; + didChange = true; + } + } else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.tab] + .contains(key)) { + if (_selectedIndex < lengthOfGroup(_selectedGroup) - 1) { + _selectedIndex += 1; + didChange = true; + } else if (_selectedGroup < groupLength - 1) { + _selectedGroup += 1; + _selectedIndex = 0; + didChange = true; + } + } + + if (mounted && didChange) { + setState(() {}); + _scrollToItem(); + } + } + + void _scrollToItem() { + final groups = _selectedGroup + 1; + + int items = 0; + for (int i = 0; i <= _selectedGroup; i++) { + items += lengthOfGroup(i); + } + + // Remove the leftover items + items -= lengthOfGroup(_selectedGroup) - (_selectedIndex + 1); + + /// The offset is roughly calculated by: + /// - Amount of Groups passed + /// - Amount of Items passed + final double offset = + (_groupTextHeight + _groupBottomSpacing) * groups + _itemHeight * items; + + // We have a buffer so that when moving up, we show items above the currently + // selected item. The buffer is the height of 2 items + if (offset <= _scrollController.offset + _itemHeight * 2) { + // We want to show the user some options above the newly + // focused one, therefore we take the offset and subtract + // the height of three items (current + 2) + _scrollController.animateTo( + offset - _itemHeight * 3, + duration: const Duration(milliseconds: 200), + curve: Curves.easeIn, + ); + } else if (offset > + _scrollController.offset + + _contentHeight - + _itemHeight - + _groupTextHeight) { + // The same here, we want to show the options below the + // newly focused item when moving downwards, therefore we add + // 2 times the item height to the offset + _scrollController.animateTo( + offset - _contentHeight + _itemHeight * 2, + duration: const Duration(milliseconds: 200), + curve: Curves.easeIn, + ); + } + } + + void _deleteCharacterAtSelection() { + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = widget.editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + search = delta.toPlainText().substring( + startOffset, + startOffset - 1 + _search.length, + ); + } + + bool _canDeleteLastCharacter() { + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return false; + } + + final delta = widget.editorState.getNodeAtPath(selection.start.path)?.delta; + if (delta == null) { + return false; + } + + return delta.isNotEmpty; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart new file mode 100644 index 0000000000000..6179d3d18b9d4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_menu_group.dart @@ -0,0 +1,123 @@ +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class InlineActionsGroup extends StatelessWidget { + const InlineActionsGroup({ + super.key, + required this.result, + required this.editorState, + required this.menuService, + required this.style, + required this.onSelected, + required this.startOffset, + required this.endOffset, + this.isLastGroup = false, + this.isGroupSelected = false, + this.selectedIndex = 0, + }); + + final InlineActionsResult result; + final EditorState editorState; + final InlineActionsMenuService menuService; + final InlineActionsMenuStyle style; + final VoidCallback onSelected; + final int startOffset; + final int endOffset; + + final bool isLastGroup; + final bool isGroupSelected; + final int selectedIndex; + + @override + Widget build(BuildContext context) { + return Padding( + padding: isLastGroup ? EdgeInsets.zero : const EdgeInsets.only(bottom: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (result.title != null) ...[ + FlowyText.medium(result.title!, color: style.groupTextColor), + const SizedBox(height: 4), + ], + ...result.results.mapIndexed( + (index, item) => InlineActionsWidget( + item: item, + editorState: editorState, + menuService: menuService, + isSelected: isGroupSelected && index == selectedIndex, + style: style, + onSelected: onSelected, + startOffset: startOffset, + endOffset: endOffset, + ), + ), + ], + ), + ); + } +} + +class InlineActionsWidget extends StatefulWidget { + const InlineActionsWidget({ + super.key, + required this.item, + required this.editorState, + required this.menuService, + required this.isSelected, + required this.style, + required this.onSelected, + required this.startOffset, + required this.endOffset, + }); + + final InlineActionsMenuItem item; + final EditorState editorState; + final InlineActionsMenuService menuService; + final bool isSelected; + final InlineActionsMenuStyle style; + final VoidCallback onSelected; + final int startOffset; + final int endOffset; + + @override + State createState() => _InlineActionsWidgetState(); +} + +class _InlineActionsWidgetState extends State { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: SizedBox( + width: kInlineMenuWidth, + child: FlowyButton( + expand: true, + isSelected: widget.isSelected, + leftIcon: widget.item.icon?.call(widget.isSelected), + text: FlowyText.regular( + widget.item.label, + figmaLineHeight: 18, + overflow: TextOverflow.ellipsis, + ), + onTap: _onPressed, + ), + ), + ); + } + + void _onPressed() { + widget.onSelected(); + widget.item.onSelected?.call( + context, + widget.editorState, + widget.menuService, + (widget.startOffset, widget.endOffset), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/callback_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/shared/callback_shortcuts.dart new file mode 100644 index 0000000000000..238b6bd85d0a8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/callback_shortcuts.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +typedef AFBindingCallback = bool Function(); + +class AFCallbackShortcuts extends StatelessWidget { + const AFCallbackShortcuts({ + super.key, + required this.bindings, + required this.child, + }); + + // The bindings for the shortcuts + // + // The result of the callback will be used to determine if the event is handled + final Map bindings; + final Widget child; + + bool _applyKeyEventBinding(ShortcutActivator activator, KeyEvent event) { + if (activator.accepts(event, HardwareKeyboard.instance)) { + return bindings[activator]?.call() ?? false; + } + return false; + } + + @override + Widget build(BuildContext context) { + return Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (FocusNode node, KeyEvent event) { + KeyEventResult result = KeyEventResult.ignored; + for (final ShortcutActivator activator in bindings.keys) { + result = _applyKeyEventBinding(activator, event) + ? KeyEventResult.handled + : result; + } + return result; + }, + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/cover_type_ext.dart b/frontend/appflowy_flutter/lib/plugins/shared/cover_type_ext.dart new file mode 100644 index 0000000000000..a826ae025333d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/cover_type_ext.dart @@ -0,0 +1,11 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; + +extension IntoCoverTypePB on CoverType { + CoverTypePB into() => switch (this) { + CoverType.color => CoverTypePB.ColorCover, + CoverType.asset => CoverTypePB.AssetCover, + CoverType.file => CoverTypePB.FileCover, + _ => CoverTypePB.FileCover, + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart new file mode 100644 index 0000000000000..803c9867c9e0b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/_shared.dart @@ -0,0 +1,66 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; +import 'package:appflowy/plugins/shared/share/share_bloc.dart'; +import 'package:appflowy/plugins/shared/share/share_menu.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ShareMenuButton extends StatelessWidget { + const ShareMenuButton({ + super.key, + required this.tabs, + }); + + final List tabs; + + @override + Widget build(BuildContext context) { + final shareBloc = context.read(); + final databaseBloc = context.read(); + final userWorkspaceBloc = context.read(); + return BlocBuilder( + builder: (context, state) { + return SizedBox( + height: 32.0, + child: IntrinsicWidth( + child: AppFlowyPopover( + direction: PopoverDirection.bottomWithRightAligned, + constraints: const BoxConstraints( + maxWidth: 500, + ), + offset: const Offset(0, 8), + onOpen: () { + context + .read() + .add(const ShareEvent.updatePublishStatus()); + }, + popupBuilder: (_) { + return MultiBlocProvider( + providers: [ + if (databaseBloc != null) + BlocProvider.value( + value: databaseBloc, + ), + BlocProvider.value(value: shareBloc), + BlocProvider.value(value: userWorkspaceBloc), + ], + child: ShareMenu( + tabs: tabs, + viewName: state.viewName, + ), + ); + }, + child: PrimaryRoundedButton( + text: LocaleKeys.shareAction_buttonText.tr(), + figmaLineHeight: 16, + ), + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/constants.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/constants.dart new file mode 100644 index 0000000000000..2be63c5879807 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/constants.dart @@ -0,0 +1,31 @@ +class ShareConstants { + static const String publishBaseUrl = 'appflowy.com'; + static const String shareBaseUrl = 'appflowy.com/app'; + + static String buildPublishUrl({ + required String nameSpace, + required String publishName, + }) { + return 'https://$publishBaseUrl/$nameSpace/$publishName'; + } + + static String buildNamespaceUrl({ + required String nameSpace, + bool withHttps = false, + }) { + final url = withHttps ? 'https://$publishBaseUrl' : publishBaseUrl; + return '$url/$nameSpace'; + } + + static String buildShareUrl({ + required String workspaceId, + required String viewId, + String? blockId, + }) { + final url = 'https://$shareBaseUrl/$workspaceId/$viewId'; + if (blockId == null || blockId.isEmpty) { + return url; + } + return '$url?blockId=$blockId'; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart new file mode 100644 index 0000000000000..ff95fe6acc90a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/export_tab.dart @@ -0,0 +1,221 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/shared/share/share_bloc.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/export/document_exporter.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ExportTab extends StatelessWidget { + const ExportTab({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final view = context.read().view; + + if (view.layout == ViewLayoutPB.Document) { + return _buildDocumentExportTab(context); + } + + return _buildDatabaseExportTab(context); + } + + Widget _buildDocumentExportTab(BuildContext context) { + return Column( + children: [ + const VSpace(10), + _ExportButton( + title: LocaleKeys.shareAction_html.tr(), + svg: FlowySvgs.export_html_s, + onTap: () => _exportHTML(context), + ), + const VSpace(10), + _ExportButton( + title: LocaleKeys.shareAction_markdown.tr(), + svg: FlowySvgs.export_markdown_s, + onTap: () => _exportMarkdown(context), + ), + const VSpace(10), + _ExportButton( + title: LocaleKeys.shareAction_clipboard.tr(), + svg: FlowySvgs.duplicate_s, + onTap: () => _exportToClipboard(context), + ), + if (kDebugMode) ...[ + const VSpace(10), + _ExportButton( + title: 'JSON (Debug Mode)', + svg: FlowySvgs.duplicate_s, + onTap: () => _exportJSON(context), + ), + ], + ], + ); + } + + Widget _buildDatabaseExportTab(BuildContext context) { + return Column( + children: [ + const VSpace(10), + _ExportButton( + title: LocaleKeys.shareAction_csv.tr(), + svg: FlowySvgs.database_layout_m, + onTap: () => _exportCSV(context), + ), + if (kDebugMode) ...[ + const VSpace(10), + _ExportButton( + title: 'Raw Database Data (Debug Mode)', + svg: FlowySvgs.duplicate_s, + onTap: () => _exportRawDatabaseData(context), + ), + ], + ], + ); + } + + Future _exportHTML(BuildContext context) async { + final viewName = context.read().state.viewName; + final exportPath = await getIt().saveFile( + dialogTitle: '', + fileName: '${viewName.toFileName()}.html', + ); + if (context.mounted && exportPath != null) { + context.read().add( + ShareEvent.share( + ShareType.html, + exportPath, + ), + ); + } + } + + Future _exportMarkdown(BuildContext context) async { + final viewName = context.read().state.viewName; + final exportPath = await getIt().saveFile( + dialogTitle: '', + fileName: '${viewName.toFileName()}.md', + ); + if (context.mounted && exportPath != null) { + context.read().add( + ShareEvent.share( + ShareType.markdown, + exportPath, + ), + ); + } + } + + Future _exportJSON(BuildContext context) async { + final viewName = context.read().state.viewName; + final exportPath = await getIt().saveFile( + dialogTitle: '', + fileName: '${viewName.toFileName()}.json', + ); + if (context.mounted && exportPath != null) { + context.read().add( + ShareEvent.share( + ShareType.json, + exportPath, + ), + ); + } + } + + Future _exportCSV(BuildContext context) async { + final viewName = context.read().state.viewName; + final exportPath = await getIt().saveFile( + dialogTitle: '', + fileName: '${viewName.toFileName()}.csv', + ); + if (context.mounted && exportPath != null) { + context.read().add( + ShareEvent.share( + ShareType.csv, + exportPath, + ), + ); + } + } + + Future _exportRawDatabaseData(BuildContext context) async { + final viewName = context.read().state.viewName; + final exportPath = await getIt().saveFile( + dialogTitle: '', + fileName: '${viewName.toFileName()}.json', + ); + if (context.mounted && exportPath != null) { + context.read().add( + ShareEvent.share( + ShareType.rawDatabaseData, + exportPath, + ), + ); + } + } + + Future _exportToClipboard(BuildContext context) async { + final documentExporter = DocumentExporter(context.read().view); + final result = await documentExporter.export(DocumentExportType.markdown); + result.fold( + (markdown) { + getIt().setData( + ClipboardServiceData(plainText: markdown), + ); + showToastNotification( + context, + message: LocaleKeys.grid_url_copiedNotification.tr(), + ); + }, + (error) => showToastNotification(context, message: error.msg), + ); + } +} + +class _ExportButton extends StatelessWidget { + const _ExportButton({ + required this.title, + required this.svg, + required this.onTap, + }); + + final String title; + final FlowySvgData svg; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).isLightMode + ? const Color(0x1E14171B) + : Colors.white.withOpacity(0.1); + final radius = BorderRadius.circular(10.0); + return FlowyButton( + margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), + iconPadding: 12, + decoration: BoxDecoration( + border: Border.all( + color: color, + ), + borderRadius: radius, + ), + radius: radius, + text: FlowyText( + title, + lineHeight: 1.0, + ), + leftIcon: FlowySvg(svg), + onTap: onTap, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_color_extension.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_color_extension.dart new file mode 100644 index 0000000000000..960f59b07de6e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_color_extension.dart @@ -0,0 +1,11 @@ +import 'package:appflowy/util/theme_extension.dart'; +import 'package:flutter/material.dart'; + +class ShareMenuColors { + static Color borderColor(BuildContext context) { + final borderColor = Theme.of(context).isLightMode + ? const Color(0x1E14171B) + : Colors.white.withOpacity(0.1); + return borderColor; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_name_generator.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_name_generator.dart new file mode 100644 index 0000000000000..eae1d56a18a01 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_name_generator.dart @@ -0,0 +1,19 @@ +String replaceInvalidChars(String input) { + final RegExp invalidCharsRegex = RegExp('[^a-zA-Z0-9-]'); + return input.replaceAll(invalidCharsRegex, '-'); +} + +Future generateNameSpace() async { + return ''; +} + +// The backend limits the publish name to a maximum of 120 characters. +// If the combined length of the ID and the name exceeds 120 characters, +// we will truncate the name to ensure the final result is within the limit. +// The name should only contain alphanumeric characters and hyphens. +Future generatePublishName(String id, String name) async { + if (name.length >= 120 - id.length) { + name = name.substring(0, 120 - id.length); + } + return replaceInvalidChars('$name-$id'); +} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart new file mode 100644 index 0000000000000..68d1918c7f319 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/publish_tab.dart @@ -0,0 +1,708 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/plugins/shared/share/publish_color_extension.dart'; +import 'package:appflowy/plugins/shared/share/publish_name_generator.dart'; +import 'package:appflowy/plugins/shared/share/share_bloc.dart'; +import 'package:appflowy/shared/error_code/error_code_map.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/rounded_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class PublishTab extends StatelessWidget { + const PublishTab({ + super.key, + required this.viewName, + }); + + final String viewName; + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + _showToast(context, state); + }, + builder: (context, state) { + if (state.isPublished) { + return _PublishedWidget( + url: state.url, + pathName: state.pathName, + namespace: state.namespace, + onVisitSite: (url) => afLaunchUrlString(url), + onUnPublish: () { + context.read().add(const ShareEvent.unPublish()); + }, + ); + } else { + return _PublishWidget( + onPublish: (selectedViews) async { + final id = context.read().view.id; + final lastPublishName = context.read().state.pathName; + final publishName = lastPublishName.orDefault( + await generatePublishName( + id, + viewName, + ), + ); + + if (selectedViews.isNotEmpty) { + Log.info( + 'Publishing views: ${selectedViews.map((e) => e.name)}', + ); + } + + if (context.mounted) { + context.read().add( + ShareEvent.publish( + '', + publishName, + selectedViews.map((e) => e.id).toList(), + ), + ); + } + }, + ); + } + }, + ); + } + + void _showToast(BuildContext context, ShareState state) { + if (state.publishResult != null) { + state.publishResult!.fold( + (value) => showToastNotification( + context, + message: LocaleKeys.publish_publishSuccessfully.tr(), + ), + (error) => showToastNotification( + context, + message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}', + type: ToastificationType.error, + ), + ); + } else if (state.unpublishResult != null) { + state.unpublishResult!.fold( + (value) => showToastNotification( + context, + message: LocaleKeys.publish_unpublishSuccessfully.tr(), + ), + (error) => showToastNotification( + context, + message: LocaleKeys.publish_unpublishFailed.tr(), + description: error.msg, + type: ToastificationType.error, + ), + ); + } else if (state.updatePathNameResult != null) { + state.updatePathNameResult!.fold( + (value) => showToastNotification( + context, + message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), + ), + (error) { + Log.error('update path name failed: $error'); + + showToastNotification( + context, + message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(), + type: ToastificationType.error, + description: error.code.publishErrorMessage, + ); + }, + ); + } + } +} + +class _PublishedWidget extends StatefulWidget { + const _PublishedWidget({ + required this.url, + required this.pathName, + required this.namespace, + required this.onVisitSite, + required this.onUnPublish, + }); + + final String url; + final String pathName; + final String namespace; + final void Function(String url) onVisitSite; + final VoidCallback onUnPublish; + + @override + State<_PublishedWidget> createState() => _PublishedWidgetState(); +} + +class _PublishedWidgetState extends State<_PublishedWidget> { + final controller = TextEditingController(); + + @override + void initState() { + super.initState(); + controller.text = widget.pathName; + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(16), + const _PublishTabHeader(), + const VSpace(16), + _PublishUrl( + namespace: widget.namespace, + controller: controller, + onCopy: (_) { + final url = context.read().state.url; + + getIt().setData( + ClipboardServiceData(plainText: url), + ); + + showToastNotification( + context, + message: LocaleKeys.grid_url_copy.tr(), + ); + }, + onSubmitted: (pathName) { + context.read().add(ShareEvent.updatePathName(pathName)); + }, + ), + const VSpace(16), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Spacer(), + UnPublishButton( + onUnPublish: widget.onUnPublish, + ), + const HSpace(6), + _buildVisitSiteButton(), + ], + ), + ], + ); + } + + Widget _buildVisitSiteButton() { + return RoundedTextButton( + width: 108, + height: 36, + onPressed: () { + final url = context.read().state.url; + widget.onVisitSite(url); + }, + title: LocaleKeys.shareAction_visitSite.tr(), + borderRadius: const BorderRadius.all(Radius.circular(10)), + fillColor: Theme.of(context).colorScheme.primary, + hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.9), + textColor: Theme.of(context).colorScheme.onPrimary, + ); + } +} + +class UnPublishButton extends StatelessWidget { + const UnPublishButton({ + super.key, + required this.onUnPublish, + }); + + final VoidCallback onUnPublish; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 108, + height: 36, + child: FlowyButton( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: ShareMenuColors.borderColor(context)), + ), + radius: BorderRadius.circular(10), + text: FlowyText.regular( + lineHeight: 1.0, + LocaleKeys.shareAction_unPublish.tr(), + textAlign: TextAlign.center, + ), + onTap: onUnPublish, + ), + ); + } +} + +class _PublishWidget extends StatefulWidget { + const _PublishWidget({ + required this.onPublish, + }); + + final void Function(List selectedViews) onPublish; + + @override + State<_PublishWidget> createState() => _PublishWidgetState(); +} + +class _PublishWidgetState extends State<_PublishWidget> { + List _selectedViews = []; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(16), + const _PublishTabHeader(), + const VSpace(16), + // if current view is a database, show the database selector + if (context.read().view.layout.isDatabaseView) ...[ + _PublishDatabaseSelector( + view: context.read().view, + onSelected: (selectedDatabases) { + _selectedViews = selectedDatabases; + }, + ), + const VSpace(16), + ], + PublishButton( + onPublish: () { + if (context.read().view.layout.isDatabaseView) { + // check if any database is selected + if (_selectedViews.isEmpty) { + showToastNotification( + context, + message: LocaleKeys.publish_noDatabaseSelected.tr(), + ); + return; + } + } + + widget.onPublish(_selectedViews); + }, + ), + ], + ); + } +} + +class PublishButton extends StatelessWidget { + const PublishButton({ + super.key, + required this.onPublish, + }); + + final VoidCallback onPublish; + + @override + Widget build(BuildContext context) { + return PrimaryRoundedButton( + text: LocaleKeys.shareAction_publish.tr(), + useIntrinsicWidth: false, + margin: const EdgeInsets.symmetric(vertical: 9.0), + fontSize: 14.0, + figmaLineHeight: 18.0, + onTap: onPublish, + ); + } +} + +class _PublishTabHeader extends StatelessWidget { + const _PublishTabHeader(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const FlowySvg(FlowySvgs.share_publish_s), + const HSpace(6), + FlowyText(LocaleKeys.shareAction_publishToTheWeb.tr()), + ], + ), + const VSpace(4), + FlowyText.regular( + LocaleKeys.shareAction_publishToTheWebHint.tr(), + fontSize: 12, + maxLines: 3, + color: Theme.of(context).hintColor, + ), + ], + ); + } +} + +class _PublishUrl extends StatefulWidget { + const _PublishUrl({ + required this.namespace, + required this.controller, + required this.onCopy, + required this.onSubmitted, + }); + + final String namespace; + final TextEditingController controller; + final void Function(String url) onCopy; + final void Function(String url) onSubmitted; + + @override + State<_PublishUrl> createState() => _PublishUrlState(); +} + +class _PublishUrlState extends State<_PublishUrl> { + final focusNode = FocusNode(); + bool showSaveButton = false; + + @override + void initState() { + super.initState(); + focusNode.addListener(_onFocusChanged); + } + + void _onFocusChanged() => setState(() => showSaveButton = focusNode.hasFocus); + + @override + void dispose() { + focusNode.removeListener(_onFocusChanged); + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 36, + child: FlowyTextField( + autoFocus: false, + controller: widget.controller, + focusNode: focusNode, + enableBorderColor: ShareMenuColors.borderColor(context), + prefixIcon: _buildPrefixIcon(context), + suffixIcon: _buildSuffixIcon(context), + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14, + height: 18.0 / 14.0, + ), + ), + ); + } + + Widget _buildPrefixIcon(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 230), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(8.0), + Flexible( + child: FlowyText.regular( + ShareConstants.buildNamespaceUrl( + nameSpace: '${widget.namespace}/', + ), + fontSize: 14, + figmaLineHeight: 18.0, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(6.0), + const Padding( + padding: EdgeInsets.symmetric(vertical: 2.0), + child: VerticalDivider( + thickness: 1.0, + width: 1.0, + ), + ), + const HSpace(6.0), + ], + ), + ); + } + + Widget _buildSuffixIcon(BuildContext context) { + return showSaveButton + ? _buildSaveButton(context) + : _buildCopyLinkIcon(context); + } + + Widget _buildSaveButton(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + child: FlowyButton( + useIntrinsicWidth: true, + text: FlowyText.regular( + LocaleKeys.button_save.tr(), + figmaLineHeight: 18.0, + ), + onTap: () { + widget.onSubmitted(widget.controller.text); + focusNode.unfocus(); + }, + ), + ); + } + + Widget _buildCopyLinkIcon(BuildContext context) { + return FlowyHover( + style: const HoverStyle( + contentMargin: EdgeInsets.all(4), + ), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => widget.onCopy(widget.controller.text), + child: Container( + width: 32, + height: 32, + alignment: Alignment.center, + padding: const EdgeInsets.all(6), + decoration: const BoxDecoration( + border: Border(left: BorderSide(color: Color(0x141F2329))), + ), + child: const FlowySvg( + FlowySvgs.m_toolbar_link_m, + ), + ), + ), + ); + } +} + +// used to select which database view should be published +class _PublishDatabaseSelector extends StatefulWidget { + const _PublishDatabaseSelector({ + required this.view, + required this.onSelected, + }); + + final ViewPB view; + final void Function(List selectedDatabases) onSelected; + + @override + State<_PublishDatabaseSelector> createState() => + _PublishDatabaseSelectorState(); +} + +class _PublishDatabaseSelectorState extends State<_PublishDatabaseSelector> { + final PropertyValueNotifier> _databaseStatus = + PropertyValueNotifier>([]); + late final _borderColor = Theme.of(context).hintColor.withOpacity(0.3); + + @override + void initState() { + super.initState(); + + _databaseStatus.addListener(_onDatabaseStatusChanged); + _databaseStatus.value = context + .read() + .state + .tabBars + .map((e) => (e.view, true)) + .toList(); + } + + void _onDatabaseStatusChanged() { + final selectedDatabases = + _databaseStatus.value.where((e) => e.$2).map((e) => e.$1).toList(); + widget.onSelected(selectedDatabases); + } + + @override + void dispose() { + _databaseStatus.removeListener(_onDatabaseStatusChanged); + _databaseStatus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Container( + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide(color: _borderColor), + borderRadius: BorderRadius.circular(8), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(10), + _buildSelectedDatabaseCount(context), + const VSpace(10), + _buildDivider(context), + const VSpace(10), + ...state.tabBars.map( + (e) => _buildDatabaseSelector(context, e), + ), + ], + ), + ); + }, + ); + } + + Widget _buildDivider(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Divider( + color: _borderColor, + thickness: 1, + height: 1, + ), + ); + } + + Widget _buildSelectedDatabaseCount(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _databaseStatus, + builder: (context, selectedDatabases, child) { + final count = selectedDatabases.where((e) => e.$2).length; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: FlowyText( + LocaleKeys.publish_database.plural(count).tr(), + color: Theme.of(context).hintColor, + fontSize: 13, + ), + ); + }, + ); + } + + Widget _buildDatabaseSelector(BuildContext context, DatabaseTabBar tabBar) { + final isPrimaryDatabase = tabBar.view.id == widget.view.id; + return ValueListenableBuilder( + valueListenable: _databaseStatus, + builder: (context, selectedDatabases, child) { + final isSelected = selectedDatabases.any( + (e) => e.$1.id == tabBar.view.id && e.$2, + ); + return _DatabaseSelectorItem( + tabBar: tabBar, + isSelected: isSelected, + isPrimaryDatabase: isPrimaryDatabase, + onTap: () { + // unable to deselect the primary database + if (isPrimaryDatabase) { + showToastNotification( + context, + message: + LocaleKeys.publish_unableToDeselectPrimaryDatabase.tr(), + ); + return; + } + + // toggle the selection status + _databaseStatus.value = _databaseStatus.value + .map( + (e) => + e.$1.id == tabBar.view.id ? (e.$1, !e.$2) : (e.$1, e.$2), + ) + .toList(); + }, + ); + }, + ); + } +} + +class _DatabaseSelectorItem extends StatelessWidget { + const _DatabaseSelectorItem({ + required this.tabBar, + required this.isSelected, + required this.onTap, + required this.isPrimaryDatabase, + }); + + final DatabaseTabBar tabBar; + final bool isSelected; + final VoidCallback onTap; + final bool isPrimaryDatabase; + + @override + Widget build(BuildContext context) { + Widget child = _buildItem(context); + + if (!isPrimaryDatabase) { + child = FlowyHover( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: child, + ), + ); + } else { + child = FlowyTooltip( + message: LocaleKeys.publish_mustSelectPrimaryDatabase.tr(), + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: child, + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: child, + ); + } + + Widget _buildItem(BuildContext context) { + final svg = isPrimaryDatabase + ? FlowySvgs.unable_select_s + : isSelected + ? FlowySvgs.check_filled_s + : FlowySvgs.uncheck_s; + final blendMode = isPrimaryDatabase ? BlendMode.srcIn : null; + return Container( + height: 30, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + FlowySvg( + svg, + blendMode: blendMode, + size: const Size.square(18), + ), + const HSpace(9.0), + FlowySvg( + tabBar.view.layout.icon, + size: const Size.square(16), + ), + const HSpace(6.0), + FlowyText.regular( + tabBar.view.name, + fontSize: 14, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart new file mode 100644 index 0000000000000..c42bbda5a0209 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart @@ -0,0 +1,438 @@ +import 'dart:io'; + +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/export/document_exporter.dart'; +import 'package:appflowy/workspace/application/settings/share/export_service.dart'; +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'constants.dart'; + +part 'share_bloc.freezed.dart'; + +class ShareBloc extends Bloc { + ShareBloc({ + required this.view, + }) : super(ShareState.initial()) { + on((event, emit) async { + await event.when( + initial: () async { + viewListener = ViewListener(viewId: view.id) + ..start( + onViewUpdated: (value) { + add(ShareEvent.updateViewName(value.name, value.id)); + }, + onViewMoveToTrash: (p0) { + add(const ShareEvent.setPublishStatus(false)); + }, + ); + + add(const ShareEvent.updatePublishStatus()); + }, + share: (type, path) async => _share( + type, + path, + emit, + ), + publish: (nameSpace, publishName, selectedViewIds) => _publish( + nameSpace, + publishName, + selectedViewIds, + emit, + ), + unPublish: () async => _unpublish(emit), + updatePublishStatus: () async => _updatePublishStatus(emit), + updateViewName: (viewName, viewId) async { + emit( + state.copyWith( + viewName: viewName, + viewId: viewId, + updatePathNameResult: null, + publishResult: null, + unpublishResult: null, + ), + ); + }, + setPublishStatus: (isPublished) { + emit( + state.copyWith( + isPublished: isPublished, + url: isPublished ? state.url : '', + ), + ); + }, + updatePathName: (pathName) async => _updatePathName( + pathName, + emit, + ), + clearPathNameResult: () async { + emit( + state.copyWith( + updatePathNameResult: null, + ), + ); + }, + ); + }); + } + + final ViewPB view; + late final ViewListener viewListener; + + late final documentExporter = DocumentExporter(view); + + @override + Future close() async { + await viewListener.stop(); + return super.close(); + } + + Future _share( + ShareType type, + String? path, + Emitter emit, + ) async { + if (ShareType.unimplemented.contains(type)) { + Log.error('DocumentShareType $type is not implemented'); + return; + } + + emit(state.copyWith(isLoading: true)); + + final result = await _export(type, path); + + emit( + state.copyWith( + isLoading: false, + exportResult: result, + ), + ); + } + + Future _publish( + String nameSpace, + String publishName, + List selectedViewIds, + Emitter emit, + ) async { + // set space name + try { + final result = + await ViewBackendService.getPublishNameSpace().getOrThrow(); + + await ViewBackendService.publish( + view, + name: publishName, + selectedViewIds: selectedViewIds, + ).getOrThrow(); + + emit( + state.copyWith( + isPublished: true, + publishResult: FlowySuccess(null), + unpublishResult: null, + namespace: result.namespace, + pathName: publishName, + url: ShareConstants.buildPublishUrl( + nameSpace: result.namespace, + publishName: publishName, + ), + ), + ); + + Log.info('publish success: ${result.namespace}/$publishName'); + } catch (e) { + Log.error('publish error: $e'); + + emit( + state.copyWith( + isPublished: false, + publishResult: FlowyResult.failure( + FlowyError(msg: 'publish error: $e'), + ), + unpublishResult: null, + url: '', + ), + ); + } + } + + Future _unpublish(Emitter emit) async { + emit( + state.copyWith( + publishResult: null, + unpublishResult: null, + ), + ); + + final result = await ViewBackendService.unpublish(view); + final isPublished = !result.isSuccess; + result.onFailure((f) { + Log.error('unpublish error: $f'); + }); + + emit( + state.copyWith( + isPublished: isPublished, + publishResult: null, + unpublishResult: result, + url: result.fold((_) => '', (_) => state.url), + ), + ); + } + + Future _updatePublishStatus(Emitter emit) async { + final publishInfo = await ViewBackendService.getPublishInfo(view); + final enablePublish = await UserBackendService.getCurrentUserProfile().fold( + (v) => v.authenticator == AuthenticatorPB.AppFlowyCloud, + (p) => false, + ); + + // skip the "Record not found" error, it's because the view is not published yet + publishInfo.fold( + (s) { + Log.info( + 'get publish info success: $publishInfo for view: ${view.name}(${view.id})', + ); + }, + (f) { + if (![ + ErrorCode.RecordNotFound, + ErrorCode.LocalVersionNotSupport, + ].contains(f.code)) { + Log.info( + 'get publish info failed: $f for view: ${view.name}(${view.id})', + ); + } + }, + ); + + String workspaceId = state.workspaceId; + if (workspaceId.isEmpty) { + workspaceId = await UserBackendService.getCurrentWorkspace().fold( + (s) => s.id, + (f) => '', + ); + } + + final (isPublished, namespace, pathName, url) = publishInfo.fold( + (s) { + return ( + // if the unpublishedAtTimestampSec is not set, it means the view is not unpublished. + !s.hasUnpublishedAtTimestampSec(), + s.namespace, + s.publishName, + ShareConstants.buildPublishUrl( + nameSpace: s.namespace, + publishName: s.publishName, + ), + ); + }, + (f) => (false, '', '', ''), + ); + + emit( + state.copyWith( + isPublished: isPublished, + namespace: namespace, + pathName: pathName, + url: url, + viewName: view.name, + enablePublish: enablePublish, + workspaceId: workspaceId, + viewId: view.id, + ), + ); + } + + Future _updatePathName( + String pathName, + Emitter emit, + ) async { + emit( + state.copyWith( + updatePathNameResult: null, + ), + ); + + if (pathName.isEmpty) { + emit( + state.copyWith( + updatePathNameResult: FlowyResult.failure( + FlowyError( + code: ErrorCode.ViewNameInvalid, + msg: 'Path name is invalid', + ), + ), + ), + ); + return; + } + + final request = SetPublishNamePB() + ..viewId = view.id + ..newName = pathName; + final result = await FolderEventSetPublishName(request).send(); + emit( + state.copyWith( + updatePathNameResult: result, + publishResult: null, + unpublishResult: null, + pathName: result.fold( + (_) => pathName, + (f) => state.pathName, + ), + url: result.fold( + (s) => ShareConstants.buildPublishUrl( + nameSpace: state.namespace, + publishName: pathName, + ), + (f) => state.url, + ), + ), + ); + } + + Future> _export( + ShareType type, + String? path, + ) async { + final FlowyResult result; + if (type == ShareType.csv) { + final exportResult = await BackendExportService.exportDatabaseAsCSV( + view.id, + ); + result = exportResult.fold( + (s) => FlowyResult.success(s.data), + (f) => FlowyResult.failure(f), + ); + } else if (type == ShareType.rawDatabaseData) { + final exportResult = await BackendExportService.exportDatabaseAsRawData( + view.id, + ); + result = exportResult.fold( + (s) => FlowyResult.success(s.data), + (f) => FlowyResult.failure(f), + ); + } else { + result = await documentExporter.export(type.documentExportType); + } + return result.fold( + (s) { + if (path != null) { + switch (type) { + case ShareType.markdown: + case ShareType.html: + case ShareType.csv: + case ShareType.json: + case ShareType.rawDatabaseData: + File(path).writeAsStringSync(s); + return FlowyResult.success(type); + default: + break; + } + } + return FlowyResult.failure(FlowyError()); + }, + (f) => FlowyResult.failure(f), + ); + } +} + +enum ShareType { + // available in document + markdown, + html, + text, + link, + json, + + // only available in database + csv, + rawDatabaseData; + + static List get unimplemented => [link]; + + DocumentExportType get documentExportType { + switch (this) { + case ShareType.markdown: + return DocumentExportType.markdown; + case ShareType.html: + return DocumentExportType.html; + case ShareType.text: + return DocumentExportType.text; + case ShareType.json: + return DocumentExportType.json; + case ShareType.csv: + throw UnsupportedError('DocumentShareType.csv is not supported'); + case ShareType.link: + throw UnsupportedError('DocumentShareType.link is not supported'); + case ShareType.rawDatabaseData: + throw UnsupportedError( + 'DocumentShareType.rawDatabaseData is not supported', + ); + } + } +} + +@freezed +class ShareEvent with _$ShareEvent { + const factory ShareEvent.initial() = _Initial; + const factory ShareEvent.share( + ShareType type, + String? path, + ) = _Share; + const factory ShareEvent.publish( + String nameSpace, + String pageId, + List selectedViewIds, + ) = _Publish; + const factory ShareEvent.unPublish() = _UnPublish; + const factory ShareEvent.updateViewName(String name, String viewId) = + _UpdateViewName; + const factory ShareEvent.updatePublishStatus() = _UpdatePublishStatus; + const factory ShareEvent.setPublishStatus(bool isPublished) = + _SetPublishStatus; + const factory ShareEvent.updatePathName(String pathName) = _UpdatePathName; + const factory ShareEvent.clearPathNameResult() = _ClearPathNameResult; +} + +@freezed +class ShareState with _$ShareState { + const factory ShareState({ + required bool isPublished, + required bool isLoading, + required String url, + required String viewName, + required bool enablePublish, + FlowyResult? exportResult, + FlowyResult? publishResult, + FlowyResult? unpublishResult, + FlowyResult? updatePathNameResult, + required String viewId, + required String workspaceId, + required String namespace, + required String pathName, + }) = _ShareState; + + factory ShareState.initial() => const ShareState( + isLoading: false, + isPublished: false, + enablePublish: true, + url: '', + viewName: '', + viewId: '', + workspaceId: '', + namespace: '', + pathName: '', + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart new file mode 100644 index 0000000000000..7f3eff9d34062 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_button.dart @@ -0,0 +1,86 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart'; +import 'package:appflowy/plugins/shared/share/_shared.dart'; +import 'package:appflowy/plugins/shared/share/share_bloc.dart'; +import 'package:appflowy/plugins/shared/share/share_menu.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ShareButton extends StatelessWidget { + const ShareButton({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => + getIt(param1: view)..add(const ShareEvent.initial()), + ), + if (view.layout.isDatabaseView) + BlocProvider( + create: (context) => DatabaseTabBarBloc(view: view) + ..add(const DatabaseTabBarEvent.initial()), + ), + ], + child: BlocListener( + listener: (context, state) { + if (!state.isLoading && state.exportResult != null) { + state.exportResult!.fold( + (data) => _handleExportSuccess(context, data), + (error) => _handleExportError(context, error), + ); + } + }, + child: BlocBuilder( + builder: (context, state) { + final tabs = [ + if (state.enablePublish) ...[ + // share the same permission with publish + ShareMenuTab.share, + ShareMenuTab.publish, + ], + ShareMenuTab.exportAs, + ]; + + return ShareMenuButton(tabs: tabs); + }, + ), + ), + ); + } + + void _handleExportSuccess(BuildContext context, ShareType shareType) { + switch (shareType) { + case ShareType.markdown: + case ShareType.html: + case ShareType.csv: + showToastNotification( + context, + message: LocaleKeys.settings_files_exportFileSuccess.tr(), + ); + break; + default: + break; + } + } + + void _handleExportError(BuildContext context, FlowyError error) { + showToastNotification( + context, + message: + '${LocaleKeys.settings_files_exportFileFail.tr()}: ${error.code}', + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_menu.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_menu.dart new file mode 100644 index 0000000000000..4decb1c092769 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_menu.dart @@ -0,0 +1,189 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart'; +import 'package:appflowy/plugins/shared/share/export_tab.dart'; +import 'package:appflowy/plugins/shared/share/share_bloc.dart'; +import 'package:appflowy/plugins/shared/share/share_tab.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'publish_tab.dart'; + +enum ShareMenuTab { + share, + publish, + exportAs; + + String get i18n { + switch (this) { + case ShareMenuTab.share: + return LocaleKeys.shareAction_shareTab.tr(); + case ShareMenuTab.publish: + return LocaleKeys.shareAction_publishTab.tr(); + case ShareMenuTab.exportAs: + return LocaleKeys.shareAction_exportAsTab.tr(); + } + } +} + +class ShareMenu extends StatefulWidget { + const ShareMenu({ + super.key, + required this.tabs, + required this.viewName, + }); + + final List tabs; + final String viewName; + + @override + State createState() => _ShareMenuState(); +} + +class _ShareMenuState extends State + with SingleTickerProviderStateMixin { + late ShareMenuTab selectedTab = widget.tabs.first; + late final tabController = TabController( + length: widget.tabs.length, + vsync: this, + initialIndex: widget.tabs.indexOf(selectedTab), + ); + + @override + Widget build(BuildContext context) { + if (widget.tabs.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const VSpace(10), + Container( + alignment: Alignment.centerLeft, + height: 30, + child: _buildTabBar(context), + ), + Divider( + color: Theme.of(context).dividerColor, + height: 1, + thickness: 1, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14.0), + child: _buildTab(context), + ), + const VSpace(20), + ], + ); + } + + @override + void dispose() { + tabController.dispose(); + super.dispose(); + } + + Widget _buildTabBar(BuildContext context) { + final children = [ + for (final tab in widget.tabs) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _Segment( + tab: tab, + isSelected: selectedTab == tab, + ), + ), + ]; + return TabBar( + indicatorSize: TabBarIndicatorSize.label, + indicator: RoundUnderlineTabIndicator( + width: 68.0, + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + insets: const EdgeInsets.only(bottom: -2), + ), + isScrollable: true, + controller: tabController, + tabs: children, + onTap: (index) { + setState(() { + selectedTab = widget.tabs[index]; + }); + }, + ); + } + + Widget _buildTab(BuildContext context) { + switch (selectedTab) { + case ShareMenuTab.publish: + return PublishTab( + viewName: widget.viewName, + ); + case ShareMenuTab.exportAs: + return const ExportTab(); + case ShareMenuTab.share: + return const ShareTab(); + } + } +} + +class _Segment extends StatefulWidget { + const _Segment({ + required this.tab, + required this.isSelected, + }); + + final bool isSelected; + final ShareMenuTab tab; + + @override + State<_Segment> createState() => _SegmentState(); +} + +class _SegmentState extends State<_Segment> { + bool isHovered = false; + + @override + Widget build(BuildContext context) { + Color? textColor = Theme.of(context).hintColor; + if (isHovered) { + textColor = const Color(0xFF00BCF0); + } else if (widget.isSelected) { + textColor = null; + } + + Widget child = MouseRegion( + onEnter: (_) => setState(() => isHovered = true), + onExit: (_) => setState(() => isHovered = false), + child: FlowyText( + widget.tab.i18n, + textAlign: TextAlign.center, + color: textColor, + ), + ); + + if (widget.tab == ShareMenuTab.publish) { + final isPublished = context.watch().state.isPublished; + // show checkmark icon if published + if (isPublished) { + child = Row( + children: [ + const FlowySvg( + FlowySvgs.published_checkmark_s, + blendMode: null, + ), + const HSpace(6), + child, + ], + ); + } + } + + return child; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart new file mode 100644 index 0000000000000..6980edce4684d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_tab.dart @@ -0,0 +1,124 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/shared/share/share_bloc.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'constants.dart'; + +class ShareTab extends StatelessWidget { + const ShareTab({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + VSpace(18), + _ShareTabHeader(), + VSpace(2), + _ShareTabDescription(), + VSpace(14), + _ShareTabContent(), + ], + ); + } +} + +class _ShareTabHeader extends StatelessWidget { + const _ShareTabHeader(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const FlowySvg(FlowySvgs.share_tab_icon_s), + const HSpace(6), + FlowyText.medium( + LocaleKeys.shareAction_shareTabTitle.tr(), + figmaLineHeight: 18.0, + ), + ], + ); + } +} + +class _ShareTabDescription extends StatelessWidget { + const _ShareTabDescription(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2.0), + child: FlowyText.regular( + LocaleKeys.shareAction_shareTabDescription.tr(), + fontSize: 13.0, + figmaLineHeight: 18.0, + color: Theme.of(context).hintColor, + ), + ); + } +} + +class _ShareTabContent extends StatelessWidget { + const _ShareTabContent(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final shareUrl = ShareConstants.buildShareUrl( + workspaceId: state.workspaceId, + viewId: state.viewId, + ); + return Row( + children: [ + Expanded( + child: SizedBox( + height: 36, + child: FlowyTextField( + text: shareUrl, // todo: add workspace id + view id + readOnly: true, + borderRadius: BorderRadius.circular(10), + ), + ), + ), + const HSpace(8.0), + PrimaryRoundedButton( + margin: const EdgeInsets.symmetric( + vertical: 9.0, + horizontal: 14.0, + ), + text: LocaleKeys.button_copyLink.tr(), + figmaLineHeight: 18.0, + leftIcon: FlowySvg( + FlowySvgs.share_tab_copy_s, + color: Theme.of(context).colorScheme.onPrimary, + ), + onTap: () => _copy(context, shareUrl), + ), + ], + ); + }, + ); + } + + void _copy(BuildContext context, String url) { + getIt().setData( + ClipboardServiceData(plainText: url), + ); + + showToastNotification( + context, + message: LocaleKeys.grid_url_copy.tr(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/shared/sync_indicator.dart b/frontend/appflowy_flutter/lib/plugins/shared/sync_indicator.dart new file mode 100644 index 0000000000000..7932c8fc20bac --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/shared/sync_indicator.dart @@ -0,0 +1,126 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/sync/database_sync_bloc.dart'; +import 'package:appflowy/plugins/document/application/document_sync_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DocumentSyncIndicator extends StatelessWidget { + const DocumentSyncIndicator({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + DocumentSyncBloc(view: view)..add(const DocumentSyncEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + // don't show indicator if user is local + if (!state.shouldShowIndicator) { + return const SizedBox.shrink(); + } + final Color color; + final String hintText; + + if (!state.isNetworkConnected) { + color = Colors.grey; + hintText = LocaleKeys.newSettings_syncState_noNetworkConnected.tr(); + } else { + switch (state.syncState) { + case DocumentSyncState.SyncFinished: + color = Colors.green; + hintText = LocaleKeys.newSettings_syncState_synced.tr(); + break; + case DocumentSyncState.Syncing: + case DocumentSyncState.InitSyncBegin: + color = Colors.yellow; + hintText = LocaleKeys.newSettings_syncState_syncing.tr(); + break; + default: + return const SizedBox.shrink(); + } + } + + return FlowyTooltip( + message: hintText, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + width: 8, + height: 8, + ), + ); + }, + ), + ); + } +} + +class DatabaseSyncIndicator extends StatelessWidget { + const DatabaseSyncIndicator({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + DatabaseSyncBloc(view: view)..add(const DatabaseSyncEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + // don't show indicator if user is local + if (!state.shouldShowIndicator) { + return const SizedBox.shrink(); + } + final Color color; + final String hintText; + + if (!state.isNetworkConnected) { + color = Colors.grey; + hintText = LocaleKeys.newSettings_syncState_noNetworkConnected.tr(); + } else { + switch (state.syncState) { + case DatabaseSyncState.SyncFinished: + color = Colors.green; + hintText = LocaleKeys.newSettings_syncState_synced.tr(); + break; + case DatabaseSyncState.Syncing: + case DatabaseSyncState.InitSyncBegin: + color = Colors.yellow; + hintText = LocaleKeys.newSettings_syncState_syncing.tr(); + break; + default: + return const SizedBox.shrink(); + } + } + + return FlowyTooltip( + message: hintText, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + width: 8, + height: 8, + ), + ); + }, + ), + ); + } +} diff --git a/frontend/app_flowy/lib/plugins/trash/application/prelude.dart b/frontend/appflowy_flutter/lib/plugins/trash/application/prelude.dart similarity index 100% rename from frontend/app_flowy/lib/plugins/trash/application/prelude.dart rename to frontend/appflowy_flutter/lib/plugins/trash/application/prelude.dart diff --git a/frontend/appflowy_flutter/lib/plugins/trash/application/trash_bloc.dart b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_bloc.dart new file mode 100644 index 0000000000000..24755b4aa7c59 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_bloc.dart @@ -0,0 +1,117 @@ +import 'package:appflowy/plugins/trash/application/trash_listener.dart'; +import 'package:appflowy/plugins/trash/application/trash_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'trash_bloc.freezed.dart'; + +class TrashBloc extends Bloc { + TrashBloc() + : _service = TrashService(), + _listener = TrashListener(), + super(TrashState.init()) { + _dispatch(); + } + + final TrashService _service; + final TrashListener _listener; + + void _dispatch() { + on((event, emit) async { + await event.map( + initial: (e) async { + _listener.start(trashUpdated: _listenTrashUpdated); + final result = await _service.readTrash(); + + emit( + result.fold( + (object) => state.copyWith( + objects: object.items, + successOrFailure: FlowyResult.success(null), + ), + (error) => + state.copyWith(successOrFailure: FlowyResult.failure(error)), + ), + ); + }, + didReceiveTrash: (e) async { + emit(state.copyWith(objects: e.trash)); + }, + putback: (e) async { + final result = await TrashService.putback(e.trashId); + await _handleResult(result, emit); + }, + delete: (e) async { + final result = await _service.deleteViews([e.trash.id]); + await _handleResult(result, emit); + }, + deleteAll: (e) async { + final result = await _service.deleteAll(); + await _handleResult(result, emit); + }, + restoreAll: (e) async { + final result = await _service.restoreAll(); + await _handleResult(result, emit); + }, + ); + }); + } + + Future _handleResult( + FlowyResult result, + Emitter emit, + ) async { + emit( + result.fold( + (l) => state.copyWith(successOrFailure: FlowyResult.success(null)), + (error) => state.copyWith(successOrFailure: FlowyResult.failure(error)), + ), + ); + } + + void _listenTrashUpdated( + FlowyResult, FlowyError> trashOrFailed, + ) { + trashOrFailed.fold( + (trash) { + add(TrashEvent.didReceiveTrash(trash)); + }, + (error) { + Log.error(error); + }, + ); + } + + @override + Future close() async { + await _listener.close(); + return super.close(); + } +} + +@freezed +class TrashEvent with _$TrashEvent { + const factory TrashEvent.initial() = Initial; + const factory TrashEvent.didReceiveTrash(List trash) = ReceiveTrash; + const factory TrashEvent.putback(String trashId) = Putback; + const factory TrashEvent.delete(TrashPB trash) = Delete; + const factory TrashEvent.restoreAll() = RestoreAll; + const factory TrashEvent.deleteAll() = DeleteAll; +} + +@freezed +class TrashState with _$TrashState { + const factory TrashState({ + required List objects, + required FlowyResult successOrFailure, + }) = _TrashState; + + factory TrashState.init() => TrashState( + objects: [], + successOrFailure: FlowyResult.success(null), + ); +} diff --git a/frontend/appflowy_flutter/lib/plugins/trash/application/trash_listener.dart b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_listener.dart new file mode 100644 index 0000000000000..1d1a8f1ed8b66 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_listener.dart @@ -0,0 +1,57 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/folder_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +typedef TrashUpdatedCallback = void Function( + FlowyResult, FlowyError> trashOrFailed, +); + +class TrashListener { + StreamSubscription? _subscription; + TrashUpdatedCallback? _trashUpdated; + FolderNotificationParser? _parser; + + void start({TrashUpdatedCallback? trashUpdated}) { + _trashUpdated = trashUpdated; + _parser = FolderNotificationParser( + id: "trash", + callback: _observableCallback, + ); + _subscription = + RustStreamReceiver.listen((observable) => _parser?.parse(observable)); + } + + void _observableCallback( + FolderNotification ty, + FlowyResult result, + ) { + switch (ty) { + case FolderNotification.DidUpdateTrash: + if (_trashUpdated != null) { + result.fold( + (payload) { + final repeatedTrash = RepeatedTrashPB.fromBuffer(payload); + _trashUpdated!(FlowyResult.success(repeatedTrash.items)); + }, + (error) => _trashUpdated!(FlowyResult.failure(error)), + ); + } + break; + default: + break; + } + } + + Future close() async { + _parser = null; + await _subscription?.cancel(); + _trashUpdated = null; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/trash/application/trash_service.dart b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_service.dart new file mode 100644 index 0000000000000..69fe613d8208b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/trash/application/trash_service.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class TrashService { + Future> readTrash() { + return FolderEventListTrashItems().send(); + } + + static Future> putback(String trashId) { + final id = TrashIdPB.create()..id = trashId; + + return FolderEventRestoreTrashItem(id).send(); + } + + Future> deleteViews(List trash) { + final items = trash.map((trash) { + return TrashIdPB.create()..id = trash; + }); + + final ids = RepeatedTrashIdPB(items: items); + return FolderEventPermanentlyDeleteTrashItem(ids).send(); + } + + Future> restoreAll() { + return FolderEventRecoverAllTrashItems().send(); + } + + Future> deleteAll() { + return FolderEventPermanentlyDeleteAllTrashItem().send(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/trash/src/sizes.dart b/frontend/appflowy_flutter/lib/plugins/trash/src/sizes.dart new file mode 100644 index 0000000000000..830444ba11c03 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/trash/src/sizes.dart @@ -0,0 +1,17 @@ +class TrashSizes { + static double scale = 0.8; + static double get headerHeight => 60 * scale; + static double get fileNameWidth => 320 * scale; + static double get lashModifyWidth => 230 * scale; + static double get createTimeWidth => 230 * scale; + // padding between createTime and action icon + static double get padding => 40 * scale; + static double get actionIconWidth => 40 * scale; + static double get totalWidth => + TrashSizes.fileNameWidth + + TrashSizes.lashModifyWidth + + TrashSizes.createTimeWidth + + TrashSizes.padding + + // restore and delete icon + 2 * TrashSizes.actionIconWidth; +} diff --git a/frontend/appflowy_flutter/lib/plugins/trash/src/trash_cell.dart b/frontend/appflowy_flutter/lib/plugins/trash/src/trash_cell.dart new file mode 100644 index 0000000000000..e1cfefeffaea6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/trash/src/trash_cell.dart @@ -0,0 +1,67 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:fixnum/fixnum.dart' as $fixnum; + +import 'sizes.dart'; + +class TrashCell extends StatelessWidget { + const TrashCell({ + super.key, + required this.object, + required this.onRestore, + required this.onDelete, + }); + + final VoidCallback onRestore; + final VoidCallback onDelete; + final TrashPB object; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + width: TrashSizes.fileNameWidth, + child: FlowyText(object.name), + ), + SizedBox( + width: TrashSizes.lashModifyWidth, + child: FlowyText(dateFormatter(object.modifiedTime)), + ), + SizedBox( + width: TrashSizes.createTimeWidth, + child: FlowyText(dateFormatter(object.createTime)), + ), + const Spacer(), + FlowyIconButton( + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + width: TrashSizes.actionIconWidth, + onPressed: onRestore, + iconPadding: const EdgeInsets.all(5), + icon: const FlowySvg(FlowySvgs.restore_s), + ), + const HSpace(20), + FlowyIconButton( + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + width: TrashSizes.actionIconWidth, + onPressed: onDelete, + iconPadding: const EdgeInsets.all(5), + icon: const FlowySvg(FlowySvgs.delete_s), + ), + ], + ); + } + + String dateFormatter($fixnum.Int64 inputTimestamps) { + final outputFormat = DateFormat('MM/dd/yyyy hh:mm a'); + final date = + DateTime.fromMillisecondsSinceEpoch(inputTimestamps.toInt() * 1000); + final outputDate = outputFormat.format(date); + return outputDate; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/trash/src/trash_header.dart b/frontend/appflowy_flutter/lib/plugins/trash/src/trash_header.dart new file mode 100644 index 0000000000000..34051428a93b8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/trash/src/trash_header.dart @@ -0,0 +1,82 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; + +import 'sizes.dart'; + +class TrashHeaderDelegate extends SliverPersistentHeaderDelegate { + TrashHeaderDelegate(); + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + return TrashHeader(); + } + + @override + double get maxExtent => TrashSizes.headerHeight; + + @override + double get minExtent => TrashSizes.headerHeight; + + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { + return false; + } +} + +class TrashHeaderItem { + TrashHeaderItem({required this.width, required this.title}); + + double width; + String title; +} + +class TrashHeader extends StatelessWidget { + TrashHeader({super.key}); + + final List items = [ + TrashHeaderItem( + title: LocaleKeys.trash_pageHeader_fileName.tr(), + width: TrashSizes.fileNameWidth, + ), + TrashHeaderItem( + title: LocaleKeys.trash_pageHeader_lastModified.tr(), + width: TrashSizes.lashModifyWidth, + ), + TrashHeaderItem( + title: LocaleKeys.trash_pageHeader_created.tr(), + width: TrashSizes.createTimeWidth, + ), + ]; + + @override + Widget build(BuildContext context) { + final headerItems = List.empty(growable: true); + items.asMap().forEach((index, item) { + headerItems.add( + SizedBox( + width: item.width, + child: FlowyText( + item.title, + color: Theme.of(context).disabledColor, + ), + ), + ); + }); + + return Container( + color: Theme.of(context).colorScheme.surface, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...headerItems, + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/trash/trash.dart b/frontend/appflowy_flutter/lib/plugins/trash/trash.dart new file mode 100644 index 0000000000000..f3fb4a8bbe419 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/trash/trash.dart @@ -0,0 +1,78 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +import 'trash_page.dart'; + +export "./src/sizes.dart"; +export "./src/trash_cell.dart"; +export "./src/trash_header.dart"; + +class TrashPluginBuilder extends PluginBuilder { + @override + Plugin build(dynamic data) { + return TrashPlugin(pluginType: pluginType); + } + + @override + String get menuName => "TrashPB"; + + @override + FlowySvgData get icon => FlowySvgs.trash_m; + + @override + PluginType get pluginType => PluginType.trash; + + @override + ViewLayoutPB get layoutType => ViewLayoutPB.Document; +} + +class TrashPluginConfig implements PluginConfig { + @override + bool get creatable => false; +} + +class TrashPlugin extends Plugin { + TrashPlugin({required PluginType pluginType}) : _pluginType = pluginType; + + final PluginType _pluginType; + + @override + PluginWidgetBuilder get widgetBuilder => TrashPluginDisplay(); + + @override + PluginId get id => "TrashStack"; + + @override + PluginType get pluginType => _pluginType; +} + +class TrashPluginDisplay extends PluginWidgetBuilder { + @override + String? get viewName => LocaleKeys.trash_text.tr(); + + @override + Widget get leftBarItem => FlowyText.medium(LocaleKeys.trash_text.tr()); + + @override + Widget tabBarItem(String pluginId, [bool shortForm = false]) => leftBarItem; + + @override + Widget? get rightBarItem => null; + + @override + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + Map? data, + }) => + const TrashPage(key: ValueKey('TrashPage')); + + @override + List get navigationItems => [this]; +} diff --git a/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart b/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart new file mode 100644 index 0000000000000..16e82a308931a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/trash/trash_page.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/trash/src/sizes.dart'; +import 'package:appflowy/plugins/trash/src/trash_header.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; +import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart'; +import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:styled_widget/styled_widget.dart'; + +import 'application/trash_bloc.dart'; +import 'src/trash_cell.dart'; + +class TrashPage extends StatefulWidget { + const TrashPage({super.key}); + + @override + State createState() => _TrashPageState(); +} + +class _TrashPageState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const horizontalPadding = 80.0; + return BlocProvider( + create: (context) => getIt()..add(const TrashEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + return SizedBox.expand( + child: Column( + children: [ + _renderTopBar(context, state), + const VSpace(32), + _renderTrashList(context, state), + ], + ).padding(horizontal: horizontalPadding, vertical: 48), + ); + }, + ), + ); + } + + Widget _renderTrashList(BuildContext context, TrashState state) { + const barSize = 6.0; + return Expanded( + child: ScrollbarListStack( + axis: Axis.vertical, + controller: _scrollController, + scrollbarPadding: EdgeInsets.only(top: TrashSizes.headerHeight), + barSize: barSize, + child: StyledSingleChildScrollView( + barSize: barSize, + axis: Axis.horizontal, + child: SizedBox( + width: TrashSizes.totalWidth, + child: ScrollConfiguration( + behavior: const ScrollBehavior().copyWith(scrollbars: false), + child: CustomScrollView( + shrinkWrap: true, + physics: StyledScrollPhysics(), + controller: _scrollController, + slivers: [ + _renderListHeader(context, state), + _renderListBody(context, state), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _renderTopBar(BuildContext context, TrashState state) { + return SizedBox( + height: 36, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FlowyText.semibold( + LocaleKeys.trash_text.tr(), + fontSize: FontSizes.s16, + color: Theme.of(context).colorScheme.tertiary, + ), + const Spacer(), + IntrinsicWidth( + child: FlowyButton( + text: FlowyText.medium( + LocaleKeys.trash_restoreAll.tr(), + lineHeight: 1.0, + ), + leftIcon: const FlowySvg(FlowySvgs.restore_s), + onTap: () => showCancelAndConfirmDialog( + context: context, + confirmLabel: LocaleKeys.trash_restore.tr(), + title: LocaleKeys.trash_confirmRestoreAll_title.tr(), + description: LocaleKeys.trash_confirmRestoreAll_caption.tr(), + onConfirm: () => context + .read() + .add(const TrashEvent.restoreAll()), + ), + ), + ), + const HSpace(6), + IntrinsicWidth( + child: FlowyButton( + text: FlowyText.medium( + LocaleKeys.trash_deleteAll.tr(), + lineHeight: 1.0, + ), + leftIcon: const FlowySvg(FlowySvgs.delete_s), + onTap: () => showConfirmDeletionDialog( + context: context, + name: LocaleKeys.trash_confirmDeleteAll_title.tr(), + description: LocaleKeys.trash_confirmDeleteAll_caption.tr(), + onConfirm: () => + context.read().add(const TrashEvent.deleteAll()), + ), + ), + ), + ], + ), + ); + } + + Widget _renderListHeader(BuildContext context, TrashState state) { + return SliverPersistentHeader( + delegate: TrashHeaderDelegate(), + floating: true, + pinned: true, + ); + } + + Widget _renderListBody(BuildContext context, TrashState state) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final object = state.objects[index]; + return SizedBox( + height: 42, + child: TrashCell( + object: object, + onRestore: () => showCancelAndConfirmDialog( + context: context, + title: + LocaleKeys.trash_restorePage_title.tr(args: [object.name]), + description: LocaleKeys.trash_restorePage_caption.tr(), + confirmLabel: LocaleKeys.trash_restore.tr(), + onConfirm: () => context + .read() + .add(TrashEvent.putback(object.id)), + ), + onDelete: () => showConfirmDeletionDialog( + context: context, + name: object.name.trim().isEmpty + ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() + : object.name, + description: + LocaleKeys.deletePagePrompt_deletePermanentDescription.tr(), + onConfirm: () => + context.read().add(TrashEvent.delete(object)), + ), + ), + ); + }, + childCount: state.objects.length, + addAutomaticKeepAlives: false, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/util.dart b/frontend/appflowy_flutter/lib/plugins/util.dart new file mode 100644 index 0000000000000..d7ec567abe097 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/util.dart @@ -0,0 +1,31 @@ +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; + +class ViewPluginNotifier extends PluginNotifier { + ViewPluginNotifier({ + required this.view, + }) : _viewListener = ViewListener(viewId: view.id) { + _viewListener?.start( + onViewUpdated: (updatedView) => view = updatedView, + onViewMoveToTrash: (result) => result.fold( + (deletedView) => isDeleted.value = deletedView, + (err) => Log.error(err), + ), + ); + } + + ViewPB view; + final ViewListener? _viewListener; + + @override + final ValueNotifier isDeleted = ValueNotifier(null); + + @override + void dispose() { + isDeleted.dispose(); + _viewListener?.stop(); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/af_image.dart b/frontend/appflowy_flutter/lib/shared/af_image.dart new file mode 100644 index 0000000000000..702c0f77640f6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/af_image.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/file_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; + +class AFImage extends StatelessWidget { + const AFImage({ + super.key, + required this.url, + required this.uploadType, + this.height, + this.width, + this.fit = BoxFit.cover, + this.userProfile, + this.borderRadius, + }) : assert( + uploadType != FileUploadTypePB.CloudFile || userProfile != null, + 'userProfile must be provided for accessing files from AF Cloud', + ); + + final String url; + final FileUploadTypePB uploadType; + final double? height; + final double? width; + final BoxFit fit; + final UserProfilePB? userProfile; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + if (uploadType == FileUploadTypePB.CloudFile && userProfile == null) { + return const SizedBox.shrink(); + } + + Widget child; + if (uploadType == FileUploadTypePB.NetworkFile) { + child = Image.network( + url, + height: height, + width: width, + fit: fit, + isAntiAlias: true, + errorBuilder: (context, error, stackTrace) { + return const SizedBox.shrink(); + }, + ); + } else if (uploadType == FileUploadTypePB.LocalFile) { + child = Image.file( + File(url), + height: height, + width: width, + fit: fit, + isAntiAlias: true, + errorBuilder: (context, error, stackTrace) { + return const SizedBox.shrink(); + }, + ); + } else { + child = FlowyNetworkImage( + url: url, + userProfilePB: userProfile, + height: height, + width: width, + errorWidgetBuilder: (context, url, error) { + return const SizedBox.shrink(); + }, + ); + } + + if (borderRadius != null) { + child = ClipRRect( + clipBehavior: Clip.antiAliasWithSaveLayer, + borderRadius: borderRadius!, + child: child, + ); + } + + return child; + } +} diff --git a/frontend/appflowy_flutter/lib/shared/af_role_pb_extension.dart b/frontend/appflowy_flutter/lib/shared/af_role_pb_extension.dart new file mode 100644 index 0000000000000..c20ba0db106f2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/af_role_pb_extension.dart @@ -0,0 +1,29 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; + +extension AFRolePBExtension on AFRolePB { + bool get isOwner => this == AFRolePB.Owner; + + bool get isMember => this == AFRolePB.Member; + + bool get canInvite => isOwner; + + bool get canDelete => isOwner; + + bool get canUpdate => isOwner; + + bool get canLeave => this != AFRolePB.Owner; + + String get description { + switch (this) { + case AFRolePB.Owner: + return LocaleKeys.settings_appearance_members_owner.tr(); + case AFRolePB.Member: + return LocaleKeys.settings_appearance_members_member.tr(); + case AFRolePB.Guest: + return LocaleKeys.settings_appearance_members_guest.tr(); + } + throw UnimplementedError('Unknown role: $this'); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/appflowy_cache_manager.dart b/frontend/appflowy_flutter/lib/shared/appflowy_cache_manager.dart new file mode 100644 index 0000000000000..4036d37b77edf --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/appflowy_cache_manager.dart @@ -0,0 +1,74 @@ +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:path_provider/path_provider.dart'; + +class FlowyCacheManager { + final _caches = []; + + // if you add a new cache, you should register it here. + void registerCache(ICache cache) { + _caches.add(cache); + } + + void unregisterAllCache(ICache cache) { + _caches.clear(); + } + + Future clearAllCache() async { + try { + for (final cache in _caches) { + await cache.clearAll(); + } + + Log.info('Cache cleared'); + } catch (e) { + Log.error(e); + } + } + + Future getCacheSize() async { + try { + int tmpDirSize = 0; + for (final cache in _caches) { + tmpDirSize += await cache.cacheSize(); + } + Log.info('Cache size: $tmpDirSize'); + return tmpDirSize; + } catch (e) { + Log.error(e); + return 0; + } + } +} + +abstract class ICache { + Future cacheSize(); + Future clearAll(); +} + +class TemporaryDirectoryCache implements ICache { + @override + Future cacheSize() async { + final tmpDir = await getTemporaryDirectory(); + final tmpDirStat = await tmpDir.stat(); + return tmpDirStat.size; + } + + @override + Future clearAll() async { + final tmpDir = await getTemporaryDirectory(); + await tmpDir.delete(recursive: true); + } +} + +class FeatureFlagCache implements ICache { + @override + Future cacheSize() async { + return 0; + } + + @override + Future clearAll() async { + await FeatureFlag.clear(); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart new file mode 100644 index 0000000000000..d37b2e0838a00 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/appflowy_network_image.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:appflowy/shared/custom_image_cache_manager.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:string_validator/string_validator.dart'; + +/// This widget handles the downloading and caching of either internal or network images. +/// +/// It will append the access token to the URL if the URL is internal. +class FlowyNetworkImage extends StatelessWidget { + const FlowyNetworkImage({ + super.key, + this.userProfilePB, + this.width, + this.height, + this.fit = BoxFit.cover, + this.progressIndicatorBuilder, + this.errorWidgetBuilder, + required this.url, + }); + + final UserProfilePB? userProfilePB; + final String url; + final double? width; + final double? height; + final BoxFit fit; + final ProgressIndicatorBuilder? progressIndicatorBuilder; + final LoadingErrorWidgetBuilder? errorWidgetBuilder; + + @override + Widget build(BuildContext context) { + assert(isURL(url)); + + if (url.isAppFlowyCloudUrl) { + assert(userProfilePB != null && userProfilePB!.token.isNotEmpty); + } + + final manager = CustomImageCacheManager(); + + return CachedNetworkImage( + cacheManager: manager, + httpHeaders: _header(), + imageUrl: url, + fit: fit, + width: width, + height: height, + progressIndicatorBuilder: progressIndicatorBuilder, + errorWidget: (context, url, error) => + errorWidgetBuilder?.call(context, url, error) ?? + const SizedBox.shrink(), + errorListener: (value) { + // try to clear the image cache. + manager.removeFile(url); + + Log.error(value.toString()); + }, + ); + } + + Map _header() { + final header = {}; + final token = userProfilePB?.token; + if (token != null) { + try { + final decodedToken = jsonDecode(token); + header['Authorization'] = 'Bearer ${decodedToken['access_token']}'; + } catch (e) { + Log.error('unable to decode token: $e'); + } + } + return header; + } +} diff --git a/frontend/appflowy_flutter/lib/shared/clipboard_state.dart b/frontend/appflowy_flutter/lib/shared/clipboard_state.dart new file mode 100644 index 0000000000000..dfee65e4206c6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/clipboard_state.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; + +/// Class to hold the state of the Clipboard. +/// +/// Essentially for document in-app json paste, we need to be able +/// to differentiate between a cut-paste and a copy-paste. +/// +/// When a cut-pase has occurred, the next paste operation should be +/// seen as a copy-paste. +/// +class ClipboardState { + ClipboardState(); + + bool _isCut = false; + + bool get isCut => _isCut; + + final ValueNotifier isHandlingPasteNotifier = ValueNotifier(false); + bool get isHandlingPaste => isHandlingPasteNotifier.value; + + final Set _handlingPasteIds = {}; + + void dispose() { + isHandlingPasteNotifier.dispose(); + } + + void didCut() { + _isCut = true; + } + + void didPaste() { + _isCut = false; + } + + void startHandlingPaste(String id) { + _handlingPasteIds.add(id); + isHandlingPasteNotifier.value = true; + } + + void endHandlingPaste(String id) { + _handlingPasteIds.remove(id); + if (_handlingPasteIds.isEmpty) { + isHandlingPasteNotifier.value = false; + } + } +} diff --git a/frontend/appflowy_flutter/lib/shared/colors.dart b/frontend/appflowy_flutter/lib/shared/colors.dart new file mode 100644 index 0000000000000..4f6e1ecfebee7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/colors.dart @@ -0,0 +1,16 @@ +import 'package:appflowy/util/theme_extension.dart'; +import 'package:flutter/material.dart'; + +extension SharedColors on BuildContext { + Color get proPrimaryColor { + return Theme.of(this).isLightMode + ? const Color(0xFF653E8C) + : const Color(0xFFE8E2EE); + } + + Color get proSecondaryColor { + return Theme.of(this).isLightMode + ? const Color(0xFFE8E2EE) + : const Color(0xFF653E8C); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/conditional_listenable_builder.dart b/frontend/appflowy_flutter/lib/shared/conditional_listenable_builder.dart new file mode 100644 index 0000000000000..661e86dcb7136 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/conditional_listenable_builder.dart @@ -0,0 +1,87 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class ConditionalListenableBuilder extends StatefulWidget { + const ConditionalListenableBuilder({ + super.key, + required this.valueListenable, + required this.buildWhen, + required this.builder, + this.child, + }); + + /// The [ValueListenable] whose value you depend on in order to build. + /// + /// This widget does not ensure that the [ValueListenable]'s value is not + /// null, therefore your [builder] may need to handle null values. + final ValueListenable valueListenable; + + /// The [buildWhen] function will be called on each value change of the + /// [valueListenable]. If the [buildWhen] function returns true, the [builder] + /// will be called with the new value of the [valueListenable]. + /// + final bool Function(T previous, T current) buildWhen; + + /// A [ValueWidgetBuilder] which builds a widget depending on the + /// [valueListenable]'s value. + /// + /// Can incorporate a [valueListenable] value-independent widget subtree + /// from the [child] parameter into the returned widget tree. + final ValueWidgetBuilder builder; + + /// A [valueListenable]-independent widget which is passed back to the [builder]. + /// + /// This argument is optional and can be null if the entire widget subtree the + /// [builder] builds depends on the value of the [valueListenable]. For + /// example, in the case where the [valueListenable] is a [String] and the + /// [builder] returns a [Text] widget with the current [String] value, there + /// would be no useful [child]. + final Widget? child; + + @override + State createState() => + _ConditionalListenableBuilderState(); +} + +class _ConditionalListenableBuilderState + extends State> { + late T value; + + @override + void initState() { + super.initState(); + value = widget.valueListenable.value; + widget.valueListenable.addListener(_valueChanged); + } + + @override + void didUpdateWidget(ConditionalListenableBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.valueListenable != widget.valueListenable) { + oldWidget.valueListenable.removeListener(_valueChanged); + value = widget.valueListenable.value; + widget.valueListenable.addListener(_valueChanged); + } + } + + @override + void dispose() { + widget.valueListenable.removeListener(_valueChanged); + super.dispose(); + } + + void _valueChanged() { + if (widget.buildWhen(value, widget.valueListenable.value)) { + setState(() { + value = widget.valueListenable.value; + }); + } else { + value = widget.valueListenable.value; + } + } + + @override + Widget build(BuildContext context) { + return widget.builder(context, value, widget.child); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/custom_image_cache_manager.dart b/frontend/appflowy_flutter/lib/shared/custom_image_cache_manager.dart new file mode 100644 index 0000000000000..f2e6d9cc0abf3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/custom_image_cache_manager.dart @@ -0,0 +1,61 @@ +import 'package:appflowy/shared/appflowy_cache_manager.dart'; +import 'package:appflowy/startup/tasks/prelude.dart'; +import 'package:file/file.dart' hide FileSystem; +import 'package:file/local.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:path/path.dart' as p; + +class CustomImageCacheManager extends CacheManager + with ImageCacheManager + implements ICache { + CustomImageCacheManager._() + : super( + Config( + key, + fileSystem: CustomIOFileSystem(key), + ), + ); + + factory CustomImageCacheManager() => _instance; + + static final CustomImageCacheManager _instance = CustomImageCacheManager._(); + + static const key = 'image_cache'; + + @override + Future cacheSize() async { + // https://github.com/Baseflow/flutter_cache_manager/issues/239#issuecomment-719475429 + // this package does not provide a way to get the cache size + return 0; + } + + @override + Future clearAll() async { + await emptyCache(); + } +} + +class CustomIOFileSystem implements FileSystem { + CustomIOFileSystem(this._cacheKey) : _fileDir = createDirectory(_cacheKey); + final Future _fileDir; + final String _cacheKey; + + static Future createDirectory(String key) async { + final baseDir = await appFlowyApplicationDataDirectory(); + final path = p.join(baseDir.path, key); + + const fs = LocalFileSystem(); + final directory = fs.directory(path); + await directory.create(recursive: true); + return directory; + } + + @override + Future createFile(String name) async { + final directory = await _fileDir; + if (!(await directory.exists())) { + await createDirectory(_cacheKey); + } + return directory.childFile(name); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/error_code/error_code_map.dart b/frontend/appflowy_flutter/lib/shared/error_code/error_code_map.dart new file mode 100644 index 0000000000000..2d54c93cbebf9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/error_code/error_code_map.dart @@ -0,0 +1,45 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; + +extension PublishNameErrorCodeMap on ErrorCode { + String? get publishErrorMessage { + return switch (this) { + ErrorCode.PublishNameAlreadyExists => + LocaleKeys.settings_sites_error_publishNameAlreadyInUse.tr(), + ErrorCode.PublishNameInvalidCharacter => LocaleKeys + .settings_sites_error_publishNameContainsInvalidCharacters + .tr(), + ErrorCode.PublishNameTooLong => + LocaleKeys.settings_sites_error_publishNameTooLong.tr(), + ErrorCode.UserUnauthorized => + LocaleKeys.settings_sites_error_publishPermissionDenied.tr(), + ErrorCode.ViewNameInvalid => + LocaleKeys.settings_sites_error_publishNameCannotBeEmpty.tr(), + _ => null, + }; + } +} + +extension DomainErrorCodeMap on ErrorCode { + String? get namespaceErrorMessage { + return switch (this) { + ErrorCode.CustomNamespaceRequirePlanUpgrade => + LocaleKeys.settings_sites_error_proPlanLimitation.tr(), + ErrorCode.CustomNamespaceAlreadyTaken => + LocaleKeys.settings_sites_error_namespaceAlreadyInUse.tr(), + ErrorCode.InvalidNamespace || + ErrorCode.InvalidRequest => + LocaleKeys.settings_sites_error_invalidNamespace.tr(), + ErrorCode.CustomNamespaceTooLong => + LocaleKeys.settings_sites_error_namespaceTooLong.tr(), + ErrorCode.CustomNamespaceTooShort => + LocaleKeys.settings_sites_error_namespaceTooShort.tr(), + ErrorCode.CustomNamespaceReserved => + LocaleKeys.settings_sites_error_namespaceIsReserved.tr(), + ErrorCode.CustomNamespaceInvalidCharacter => + LocaleKeys.settings_sites_error_namespaceContainsInvalidCharacters.tr(), + _ => null, + }; + } +} diff --git a/frontend/appflowy_flutter/lib/shared/feature_flags.dart b/frontend/appflowy_flutter/lib/shared/feature_flags.dart new file mode 100644 index 0000000000000..7ea66076df4fc --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/feature_flags.dart @@ -0,0 +1,158 @@ +import 'dart:convert'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:collection/collection.dart'; + +typedef FeatureFlagMap = Map; + +/// The [FeatureFlag] is used to control the front-end features of the app. +/// +/// For example, if your feature is still under development, +/// you can set the value to `false` to hide the feature. +enum FeatureFlag { + // used to control the visibility of the collaborative workspace feature + // if it's on, you can see the workspace list and the workspace settings + // in the top-left corner of the app + collaborativeWorkspace, + + // used to control the visibility of the members settings + // if it's on, you can see the members settings in the settings page + membersSettings, + + // used to control the sync feature of the document + // if it's on, the document will be synced the events from server in real-time + syncDocument, + + // used to control the sync feature of the database + // if it's on, the collaborators will show in the database + syncDatabase, + + // used for the search feature + search, + + // used for controlling whether to show plan+billing options in settings + planBilling, + + // used for space design + spaceDesign, + + // used for the inline sub-page mention + inlineSubPageMention, + + // used for ignore the conflicted feature flag + unknown; + + static Future initialize() async { + final values = await getIt().getWithFormat( + KVKeys.featureFlag, + (value) => Map.from(jsonDecode(value)).map( + (key, value) { + final k = FeatureFlag.values.firstWhereOrNull( + (e) => e.name == key, + ) ?? + FeatureFlag.unknown; + return MapEntry(k, value as bool); + }, + ), + ) ?? + {}; + + _values = { + ...{for (final flag in FeatureFlag.values) flag: false}, + ...values, + }; + } + + static UnmodifiableMapView get data => + UnmodifiableMapView(_values); + + Future turnOn() async { + await update(true); + } + + Future turnOff() async { + await update(false); + } + + Future update(bool value) async { + _values[this] = value; + + await getIt().set( + KVKeys.featureFlag, + jsonEncode( + _values.map((key, value) => MapEntry(key.name, value)), + ), + ); + } + + static Future clear() async { + _values = {}; + await getIt().remove(KVKeys.featureFlag); + } + + bool get isOn { + if ([ + FeatureFlag.planBilling, + // release this feature in version 0.6.1 + FeatureFlag.spaceDesign, + // release this feature in version 0.5.9 + FeatureFlag.search, + // release this feature in version 0.5.6 + FeatureFlag.collaborativeWorkspace, + FeatureFlag.membersSettings, + // release this feature in version 0.5.4 + FeatureFlag.syncDatabase, + FeatureFlag.syncDocument, + FeatureFlag.inlineSubPageMention, + ].contains(this)) { + return true; + } + + if (_values.containsKey(this)) { + return _values[this]!; + } + + switch (this) { + case FeatureFlag.planBilling: + case FeatureFlag.search: + case FeatureFlag.syncDocument: + case FeatureFlag.syncDatabase: + case FeatureFlag.spaceDesign: + case FeatureFlag.inlineSubPageMention: + return true; + case FeatureFlag.collaborativeWorkspace: + case FeatureFlag.membersSettings: + case FeatureFlag.unknown: + return false; + } + } + + String get description { + switch (this) { + case FeatureFlag.collaborativeWorkspace: + return 'if it\'s on, you can see the workspace list and the workspace settings in the top-left corner of the app'; + case FeatureFlag.membersSettings: + return 'if it\'s on, you can see the members settings in the settings page'; + case FeatureFlag.syncDocument: + return 'if it\'s on, the document will be synced in real-time'; + case FeatureFlag.syncDatabase: + return 'if it\'s on, the collaborators will show in the database'; + case FeatureFlag.search: + return 'if it\'s on, the command palette and search button will be available'; + case FeatureFlag.planBilling: + return 'if it\'s on, plan and billing pages will be available in Settings'; + case FeatureFlag.spaceDesign: + return 'if it\'s on, the space design feature will be available'; + case FeatureFlag.inlineSubPageMention: + return 'if it\'s on, the inline sub-page mention feature will be available'; + case FeatureFlag.unknown: + return ''; + } + } + + String get key => 'appflowy_feature_flag_${toString()}'; +} + +FeatureFlagMap _values = {}; diff --git a/frontend/appflowy_flutter/lib/shared/feedback_gesture_detector.dart b/frontend/appflowy_flutter/lib/shared/feedback_gesture_detector.dart new file mode 100644 index 0000000000000..0d482fb98549b --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/feedback_gesture_detector.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +enum HapticFeedbackType { + light, + medium, + heavy, + selection, + vibrate; + + void call() { + switch (this) { + case HapticFeedbackType.light: + HapticFeedback.lightImpact(); + break; + case HapticFeedbackType.medium: + HapticFeedback.mediumImpact(); + break; + case HapticFeedbackType.heavy: + HapticFeedback.heavyImpact(); + break; + case HapticFeedbackType.selection: + HapticFeedback.selectionClick(); + break; + case HapticFeedbackType.vibrate: + HapticFeedback.vibrate(); + break; + } + } +} + +class FeedbackGestureDetector extends GestureDetector { + FeedbackGestureDetector({ + super.key, + HitTestBehavior behavior = HitTestBehavior.opaque, + HapticFeedbackType feedbackType = HapticFeedbackType.light, + required Widget child, + required VoidCallback onTap, + }) : super( + behavior: behavior, + onTap: () { + feedbackType.call(); + onTap(); + }, + child: child, + ); +} diff --git a/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart b/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart new file mode 100644 index 0000000000000..da9f679f56b39 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/flowy_error_page.dart @@ -0,0 +1,168 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class AppFlowyErrorPage extends StatelessWidget { + const AppFlowyErrorPage({ + super.key, + this.error, + }); + + final FlowyError? error; + + @override + Widget build(BuildContext context) { + if (UniversalPlatform.isMobile) { + return _MobileSyncErrorPage(error: error); + } else { + return _DesktopSyncErrorPage(error: error); + } + } +} + +class _MobileSyncErrorPage extends StatelessWidget { + const _MobileSyncErrorPage({ + this.error, + }); + + final FlowyError? error; + + @override + Widget build(BuildContext context) { + return AnimatedGestureDetector( + scaleFactor: 0.99, + onTapUp: () { + getIt().setPlainText(error.toString()); + showToastNotification( + context, + message: LocaleKeys.message_copy_success.tr(), + bottomPadding: 0, + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.icon_warning_xl, + blendMode: null, + ), + const VSpace(16.0), + FlowyText.medium( + LocaleKeys.error_syncError.tr(), + fontSize: 15, + ), + const VSpace(8.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: FlowyText.regular( + LocaleKeys.error_syncErrorHint.tr(), + fontSize: 13, + color: Theme.of(context).hintColor, + textAlign: TextAlign.center, + maxLines: 10, + ), + ), + const VSpace(2.0), + FlowyText.regular( + '(${LocaleKeys.error_clickToCopy.tr()})', + fontSize: 13, + color: Theme.of(context).hintColor, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +class _DesktopSyncErrorPage extends StatelessWidget { + const _DesktopSyncErrorPage({ + this.error, + }); + + final FlowyError? error; + + @override + Widget build(BuildContext context) { + return AnimatedGestureDetector( + scaleFactor: 0.995, + onTapUp: () { + getIt().setPlainText(error.toString()); + showToastNotification( + context, + message: LocaleKeys.message_copy_success.tr(), + bottomPadding: 0, + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FlowySvg( + FlowySvgs.icon_warning_xl, + blendMode: null, + ), + const VSpace(16.0), + FlowyText.medium( + error?.code.toString() ?? '', + fontSize: 16, + ), + const VSpace(8.0), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.errorDialog_howToFixFallbackHint1.tr(), + style: TextStyle( + fontSize: 14, + color: Theme.of(context).hintColor, + ), + ), + TextSpan( + text: 'Github', + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + afLaunchUrlString( + 'https://github.com/AppFlowy-IO/AppFlowy/issues/new?template=bug_report.yaml', + ); + }, + ), + TextSpan( + text: LocaleKeys.errorDialog_howToFixFallbackHint2.tr(), + style: TextStyle( + fontSize: 14, + color: Theme.of(context).hintColor, + ), + ), + ], + ), + ), + const VSpace(8.0), + FlowyText.regular( + '(${LocaleKeys.error_clickToCopy.tr()})', + fontSize: 14, + color: Theme.of(context).hintColor, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/flowy_gradient_colors.dart b/frontend/appflowy_flutter/lib/shared/flowy_gradient_colors.dart new file mode 100644 index 0000000000000..836f3b37cbc84 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/flowy_gradient_colors.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +enum FlowyGradientColor { + gradient1, + gradient2, + gradient3, + gradient4, + gradient5, + gradient6, + gradient7; + + static FlowyGradientColor fromId(String id) { + return FlowyGradientColor.values.firstWhere( + (element) => element.id == id, + orElse: () => FlowyGradientColor.gradient1, + ); + } + + String get id { + // DON'T change this name because it's saved in the database! + switch (this) { + case FlowyGradientColor.gradient1: + return 'appflowy_them_color_gradient1'; + case FlowyGradientColor.gradient2: + return 'appflowy_them_color_gradient2'; + case FlowyGradientColor.gradient3: + return 'appflowy_them_color_gradient3'; + case FlowyGradientColor.gradient4: + return 'appflowy_them_color_gradient4'; + case FlowyGradientColor.gradient5: + return 'appflowy_them_color_gradient5'; + case FlowyGradientColor.gradient6: + return 'appflowy_them_color_gradient6'; + case FlowyGradientColor.gradient7: + return 'appflowy_them_color_gradient7'; + } + } + + LinearGradient get linear { + switch (this) { + case FlowyGradientColor.gradient1: + return const LinearGradient( + begin: Alignment(-0.35, -0.94), + end: Alignment(0.35, 0.94), + colors: [Color(0xFF34BDAF), Color(0xFFB682D4)], + ); + case FlowyGradientColor.gradient2: + return const LinearGradient( + begin: Alignment(0.00, -1.00), + end: Alignment(0, 1), + colors: [Color(0xFF4CC2CC), Color(0xFFE17570)], + ); + case FlowyGradientColor.gradient3: + return const LinearGradient( + begin: Alignment(0.00, -1.00), + end: Alignment(0, 1), + colors: [Color(0xFFAF70E0), Color(0xFFED7196)], + ); + case FlowyGradientColor.gradient4: + return const LinearGradient( + begin: Alignment(0.00, -1.00), + end: Alignment(0, 1), + colors: [Color(0xFFA348D6), Color(0xFF44A7DE)], + ); + case FlowyGradientColor.gradient5: + return const LinearGradient( + begin: Alignment(0.38, -0.93), + end: Alignment(-0.38, 0.93), + colors: [Color(0xFF5749C9), Color(0xFFBB4997)], + ); + case FlowyGradientColor.gradient6: + return const LinearGradient( + begin: Alignment(0.00, -1.00), + end: Alignment(0, 1), + colors: [Color(0xFF036FFA), Color(0xFF00B8E5)], + ); + case FlowyGradientColor.gradient7: + return const LinearGradient( + begin: Alignment(0.62, -0.79), + end: Alignment(-0.62, 0.79), + colors: [Color(0xFFF0C6CF), Color(0xFFDECCE2), Color(0xFFCAD3F9)], + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/shared/google_fonts_extension.dart b/frontend/appflowy_flutter/lib/shared/google_fonts_extension.dart new file mode 100644 index 0000000000000..3e6a69153afee --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/google_fonts_extension.dart @@ -0,0 +1,50 @@ +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +const _defaultFontFamilies = [ + defaultFontFamily, + builtInCodeFontFamily, +]; + +// if the font family is not available, google fonts packages will throw an exception +// this method will return the system font family if the font family is not available +TextStyle getGoogleFontSafely( + String fontFamily, { + FontWeight? fontWeight, + double? fontSize, + Color? fontColor, + double? letterSpacing, + double? lineHeight, +}) { + // if the font family is the built-in font family, we can use it directly + if (_defaultFontFamilies.contains(fontFamily)) { + return TextStyle( + fontFamily: fontFamily.isEmpty ? null : fontFamily, + fontWeight: fontWeight, + fontSize: fontSize, + color: fontColor, + letterSpacing: letterSpacing, + height: lineHeight, + ); + } else { + try { + return GoogleFonts.getFont( + fontFamily, + fontWeight: fontWeight, + fontSize: fontSize, + color: fontColor, + letterSpacing: letterSpacing, + height: lineHeight, + ); + } catch (_) {} + } + + return TextStyle( + fontWeight: fontWeight, + fontSize: fontSize, + color: fontColor, + letterSpacing: letterSpacing, + height: lineHeight, + ); +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/colors.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/colors.dart new file mode 100644 index 0000000000000..8728c3be4a6b1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/colors.dart @@ -0,0 +1,26 @@ +import 'package:appflowy/util/theme_extension.dart'; +import 'package:flutter/material.dart'; + +extension PickerColors on BuildContext { + Color get pickerTextColor { + return Theme.of(this).isLightMode + ? const Color(0x80171717) + : Colors.white.withOpacity(0.5); + } + + Color get pickerIconColor { + return Theme.of(this).isLightMode ? const Color(0xFF171717) : Colors.white; + } + + Color get pickerSearchBarBorderColor { + return Theme.of(this).isLightMode + ? const Color(0x1E171717) + : Colors.white.withOpacity(0.12); + } + + Color get pickerButtonBoarderColor { + return Theme.of(this).isLightMode + ? const Color(0x1E171717) + : Colors.white.withOpacity(0.12); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_search_bar.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_search_bar.dart new file mode 100644 index 0000000000000..5604da1b33153 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_search_bar.dart @@ -0,0 +1,198 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'colors.dart'; + +typedef EmojiKeywordChangedCallback = void Function(String keyword); +typedef EmojiSkinToneChanged = void Function(EmojiSkinTone skinTone); + +class FlowyEmojiSearchBar extends StatefulWidget { + const FlowyEmojiSearchBar({ + super.key, + this.ensureFocus = false, + required this.emojiData, + required this.onKeywordChanged, + required this.onSkinToneChanged, + required this.onRandomEmojiSelected, + }); + + final bool ensureFocus; + final EmojiData emojiData; + final EmojiKeywordChangedCallback onKeywordChanged; + final EmojiSkinToneChanged onSkinToneChanged; + final EmojiSelectedCallback onRandomEmojiSelected; + + @override + State createState() => _FlowyEmojiSearchBarState(); +} + +class _FlowyEmojiSearchBarState extends State { + final TextEditingController controller = TextEditingController(); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric( + vertical: 12.0, + horizontal: UniversalPlatform.isDesktopOrWeb ? 0.0 : 8.0, + ), + child: Row( + children: [ + Expanded( + child: _SearchTextField( + onKeywordChanged: widget.onKeywordChanged, + ensureFocus: widget.ensureFocus, + ), + ), + const HSpace(8.0), + _RandomEmojiButton( + emojiData: widget.emojiData, + onRandomEmojiSelected: widget.onRandomEmojiSelected, + ), + const HSpace(8.0), + FlowyEmojiSkinToneSelector( + onEmojiSkinToneChanged: widget.onSkinToneChanged, + ), + ], + ), + ); + } +} + +class _RandomEmojiButton extends StatelessWidget { + const _RandomEmojiButton({ + required this.emojiData, + required this.onRandomEmojiSelected, + }); + + final EmojiData emojiData; + final EmojiSelectedCallback onRandomEmojiSelected; + + @override + Widget build(BuildContext context) { + return Container( + width: 36, + height: 36, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide(color: context.pickerButtonBoarderColor), + borderRadius: BorderRadius.circular(8), + ), + ), + child: FlowyTooltip( + message: LocaleKeys.emoji_random.tr(), + child: FlowyButton( + useIntrinsicWidth: true, + text: const FlowySvg( + FlowySvgs.icon_shuffle_s, + ), + onTap: () { + final random = emojiData.random; + onRandomEmojiSelected( + random.$1, + random.$2, + ); + }, + ), + ), + ); + } +} + +class _SearchTextField extends StatefulWidget { + const _SearchTextField({ + required this.onKeywordChanged, + this.ensureFocus = false, + }); + + final EmojiKeywordChangedCallback onKeywordChanged; + final bool ensureFocus; + + @override + State<_SearchTextField> createState() => _SearchTextFieldState(); +} + +class _SearchTextFieldState extends State<_SearchTextField> { + final TextEditingController controller = TextEditingController(); + final FocusNode focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + if (widget.ensureFocus) { + Future.delayed(const Duration(milliseconds: 200), () { + if (!mounted || focusNode.hasFocus) return; + focusNode.requestFocus(); + }); + } + } + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 36.0, + child: FlowyTextField( + focusNode: focusNode, + hintText: LocaleKeys.search_label.tr(), + hintStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w400, + color: Theme.of(context).hintColor, + ), + enableBorderColor: context.pickerSearchBarBorderColor, + controller: controller, + onChanged: widget.onKeywordChanged, + prefixIcon: const Padding( + padding: EdgeInsets.only( + left: 14.0, + right: 8.0, + ), + child: FlowySvg( + FlowySvgs.search_s, + ), + ), + prefixIconConstraints: const BoxConstraints( + maxHeight: 20.0, + ), + suffixIcon: Padding( + padding: const EdgeInsets.all(4.0), + child: FlowyButton( + text: const FlowySvg( + FlowySvgs.m_app_bar_close_s, + ), + margin: EdgeInsets.zero, + useIntrinsicWidth: true, + onTap: () { + if (controller.text.isNotEmpty) { + controller.clear(); + widget.onKeywordChanged(''); + } else { + focusNode.unfocus(); + } + }, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_skin_tone.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_skin_tone.dart new file mode 100644 index 0000000000000..aa979801820dd --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/emoji_skin_tone.dart @@ -0,0 +1,105 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; + +import 'colors.dart'; + +// use a temporary global value to store last selected skin tone +EmojiSkinTone? lastSelectedEmojiSkinTone; + +@visibleForTesting +ValueKey emojiSkinToneKey(String icon) { + return ValueKey('emoji_skin_tone_$icon'); +} + +class FlowyEmojiSkinToneSelector extends StatefulWidget { + const FlowyEmojiSkinToneSelector({ + super.key, + required this.onEmojiSkinToneChanged, + }); + + final EmojiSkinToneChanged onEmojiSkinToneChanged; + + @override + State createState() => + _FlowyEmojiSkinToneSelectorState(); +} + +class _FlowyEmojiSkinToneSelectorState + extends State { + EmojiSkinTone skinTone = EmojiSkinTone.none; + final controller = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithCenterAligned, + controller: controller, + popupBuilder: (context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: EmojiSkinTone.values + .map( + (e) => _buildIconButton( + e.icon, + () { + setState(() => lastSelectedEmojiSkinTone = e); + widget.onEmojiSkinToneChanged(e); + controller.close(); + }, + ), + ) + .toList(), + ); + }, + child: FlowyTooltip( + message: LocaleKeys.emoji_selectSkinTone.tr(), + child: _buildIconButton( + lastSelectedEmojiSkinTone?.icon ?? '👋', + () => controller.show(), + ), + ), + ); + } + + Widget _buildIconButton(String icon, VoidCallback onPressed) { + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + border: Border.all(color: context.pickerButtonBoarderColor), + borderRadius: BorderRadius.circular(8), + ), + child: FlowyButton( + key: emojiSkinToneKey(icon), + margin: EdgeInsets.zero, + text: FlowyText.emoji( + icon, + fontSize: 24.0, + ), + onTap: onPressed, + ), + ); + } +} + +extension EmojiSkinToneIcon on EmojiSkinTone { + String get icon { + switch (this) { + case EmojiSkinTone.none: + return '👋'; + case EmojiSkinTone.light: + return '👋🏻'; + case EmojiSkinTone.mediumLight: + return '👋🏼'; + case EmojiSkinTone.medium: + return '👋🏽'; + case EmojiSkinTone.mediumDark: + return '👋🏾'; + case EmojiSkinTone.dark: + return '👋🏿'; + } + } +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart new file mode 100644 index 0000000000000..727d0b8fba0ec --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart @@ -0,0 +1,204 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/icon.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart' hide Icon; +import 'package:universal_platform/universal_platform.dart'; + +extension ToProto on FlowyIconType { + ViewIconTypePB toProto() { + switch (this) { + case FlowyIconType.emoji: + return ViewIconTypePB.Emoji; + case FlowyIconType.icon: + return ViewIconTypePB.Icon; + case FlowyIconType.custom: + return ViewIconTypePB.Url; + } + } +} + +extension FromProto on ViewIconTypePB { + FlowyIconType fromProto() { + switch (this) { + case ViewIconTypePB.Emoji: + return FlowyIconType.emoji; + case ViewIconTypePB.Icon: + return FlowyIconType.icon; + case ViewIconTypePB.Url: + return FlowyIconType.custom; + default: + return FlowyIconType.custom; + } + } +} + +extension ToEmojiIconData on ViewIconPB { + EmojiIconData toEmojiIconData() => EmojiIconData(ty.fromProto(), value); +} + +enum FlowyIconType { + emoji, + icon, + custom; +} + +class EmojiIconData { + factory EmojiIconData.none() => const EmojiIconData(FlowyIconType.icon, ''); + + factory EmojiIconData.emoji(String emoji) => + EmojiIconData(FlowyIconType.emoji, emoji); + + factory EmojiIconData.icon(IconsData icon) => + EmojiIconData(FlowyIconType.icon, icon.iconString); + + const EmojiIconData( + this.type, + this.emoji, + ); + + final FlowyIconType type; + final String emoji; + + static EmojiIconData fromViewIconPB(ViewIconPB v) { + return EmojiIconData(v.ty.fromProto(), v.value); + } + + ViewIconPB toViewIcon() { + return ViewIconPB() + ..ty = type.toProto() + ..value = emoji; + } + + bool get isEmpty => emoji.isEmpty; + + bool get isNotEmpty => emoji.isNotEmpty; +} + +class FlowyIconEmojiPicker extends StatefulWidget { + const FlowyIconEmojiPicker({ + super.key, + this.onSelectedEmoji, + this.enableBackgroundColorSelection = true, + this.tabs = const [PickerTabType.emoji, PickerTabType.icon], + }); + + final ValueChanged? onSelectedEmoji; + final bool enableBackgroundColorSelection; + final List tabs; + + @override + State createState() => _FlowyIconEmojiPickerState(); +} + +class _FlowyIconEmojiPickerState extends State + with SingleTickerProviderStateMixin { + late final controller = TabController( + length: widget.tabs.length, + vsync: this, + ); + int currentIndex = 0; + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 46, + padding: const EdgeInsets.only(left: 4.0, right: 12.0), + child: Row( + children: [ + Expanded( + child: PickerTab( + controller: controller, + tabs: widget.tabs, + onTap: (index) => currentIndex = index, + ), + ), + _RemoveIconButton( + onTap: () { + widget.onSelectedEmoji?.call(EmojiIconData.none()); + }, + ), + ], + ), + ), + const FlowyDivider(), + Expanded( + child: TabBarView( + controller: controller, + children: widget.tabs.map((tab) { + switch (tab) { + case PickerTabType.emoji: + return _buildEmojiPicker(); + case PickerTabType.icon: + return _buildIconPicker(); + } + }).toList(), + ), + ), + ], + ); + } + + Widget _buildEmojiPicker() { + return FlowyEmojiPicker( + ensureFocus: true, + emojiPerLine: _getEmojiPerLine(context), + onEmojiSelected: (_, emoji) => widget.onSelectedEmoji?.call( + EmojiIconData.emoji(emoji), + ), + ); + } + + int _getEmojiPerLine(BuildContext context) { + if (UniversalPlatform.isDesktopOrWeb) { + return 9; + } + final width = MediaQuery.of(context).size.width; + return width ~/ 40.0; // the size of the emoji + } + + Widget _buildIconPicker() { + return FlowyIconPicker( + enableBackgroundColorSelection: widget.enableBackgroundColorSelection, + onSelectedIcon: (result) { + widget.onSelectedEmoji?.call(result.toEmojiIconData()); + }, + ); + } +} + +class _RemoveIconButton extends StatelessWidget { + const _RemoveIconButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: FlowyButton( + onTap: onTap, + useIntrinsicWidth: true, + text: FlowyText( + fontSize: 14.0, + figmaLineHeight: 16.0, + fontWeight: FontWeight.w500, + LocaleKeys.button_remove.tr(), + color: Theme.of(context).hintColor, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon.dart new file mode 100644 index 0000000000000..7764d73838671 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon.dart @@ -0,0 +1,86 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'icon.g.dart'; + +@JsonSerializable() +class IconGroup { + factory IconGroup.fromJson(Map json) { + final group = _$IconGroupFromJson(json); + // Set the iconGroup reference for each icon + for (final icon in group.icons) { + icon.iconGroup = group; + } + return group; + } + + factory IconGroup.fromMapEntry(MapEntry entry) => + IconGroup.fromJson({ + 'name': entry.key, + 'icons': entry.value, + }); + + IconGroup({ + required this.name, + required this.icons, + }) { + // Set the iconGroup reference for each icon + for (final icon in icons) { + icon.iconGroup = this; + } + } + + final String name; + final List icons; + + String get displayName => name.replaceAll('_', ' '); + + IconGroup filter(String keyword) { + final lowercaseKey = keyword.toLowerCase(); + final filteredIcons = icons + .where( + (icon) => + icon.keywords.any((k) => k.contains(lowercaseKey)) || + icon.name.contains(lowercaseKey), + ) + .toList(); + return IconGroup(name: name, icons: filteredIcons); + } + + String? getSvgContent(String iconName) { + final icon = icons.firstWhere( + (icon) => icon.name == iconName, + ); + return icon.content; + } + + Map toJson() => _$IconGroupToJson(this); +} + +@JsonSerializable() +class Icon { + factory Icon.fromJson(Map json) => _$IconFromJson(json); + + Icon({ + required this.name, + required this.keywords, + required this.content, + }); + + final String name; + final List keywords; + final String content; + + // Add reference to parent IconGroup + IconGroup? iconGroup; + + String get displayName => name.replaceAll('-', ' '); + + Map toJson() => _$IconToJson(this); + + String get iconPath { + if (iconGroup == null) { + return ''; + } + return '${iconGroup!.name}/$name'; + } +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_color_picker.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_color_picker.dart new file mode 100644 index 0000000000000..b4221ff42afe0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_color_picker.dart @@ -0,0 +1,44 @@ +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; + +class IconColorPicker extends StatelessWidget { + const IconColorPicker({ + super.key, + required this.onSelected, + }); + + final void Function(String color) onSelected; + + @override + Widget build(BuildContext context) { + return GridView.count( + shrinkWrap: true, + crossAxisCount: 6, + mainAxisSpacing: 4.0, + children: builtInSpaceColors.map((color) { + return FlowyHover( + style: HoverStyle(borderRadius: BorderRadius.circular(8.0)), + child: GestureDetector( + onTap: () => onSelected(color), + child: Container( + width: 34, + height: 34, + padding: const EdgeInsets.all(5.0), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: Color(int.parse(color)), + shape: RoundedRectangleBorder( + side: const BorderSide(color: Color(0x2D333333)), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ), + ); + }).toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart new file mode 100644 index 0000000000000..75f5633ee5378 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_picker.dart @@ -0,0 +1,436 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_search_bar.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; +import 'package:appflowy/util/debounce.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' hide Icon; +import 'package:flutter/services.dart'; + +import 'colors.dart'; +import 'icon_color_picker.dart'; + +// cache the icon groups to avoid loading them multiple times +List? kIconGroups; +const _kRecentIconGroupName = 'Recent'; + +extension IconGroupFilter on List { + String? findSvgContent(String key) { + final values = key.split('/'); + if (values.length != 2) { + return null; + } + final groupName = values[0]; + final iconName = values[1]; + final svgString = kIconGroups + ?.firstWhereOrNull( + (group) => group.name == groupName, + ) + ?.icons + .firstWhereOrNull( + (icon) => icon.name == iconName, + ) + ?.content; + return svgString; + } + + (IconGroup, Icon) randomIcon() { + final random = Random(); + final group = this[random.nextInt(length)]; + final icon = group.icons[random.nextInt(group.icons.length)]; + return (group, icon); + } +} + +Future> loadIconGroups() async { + if (kIconGroups != null) { + return kIconGroups!; + } + + final stopwatch = Stopwatch()..start(); + final jsonString = await rootBundle.loadString('assets/icons/icons.json'); + try { + final json = jsonDecode(jsonString) as Map; + final iconGroups = json.entries.map(IconGroup.fromMapEntry).toList(); + kIconGroups = iconGroups; + return iconGroups; + } catch (e) { + Log.error('Failed to decode icons.json', e); + return []; + } finally { + stopwatch.stop(); + Log.info('Loaded icon groups in ${stopwatch.elapsedMilliseconds}ms'); + } +} + +class FlowyIconPicker extends StatefulWidget { + const FlowyIconPicker({ + super.key, + required this.onSelectedIcon, + required this.enableBackgroundColorSelection, + this.iconPerLine = 9, + }); + + final bool enableBackgroundColorSelection; + final ValueChanged onSelectedIcon; + final int iconPerLine; + + @override + State createState() => _FlowyIconPickerState(); +} + +class _FlowyIconPickerState extends State { + final List iconGroups = []; + bool loaded = false; + final ValueNotifier keyword = ValueNotifier(''); + final debounce = Debounce(duration: const Duration(milliseconds: 150)); + + Future loadIcons() async { + final localIcons = await loadIconGroups(); + final recentIcons = await RecentIcons.getIcons(); + if (recentIcons.isNotEmpty) { + iconGroups.add( + IconGroup( + name: _kRecentIconGroupName, + icons: recentIcons.sublist( + 0, + min(recentIcons.length, widget.iconPerLine), + ), + ), + ); + } + iconGroups.addAll(localIcons); + if (mounted) { + setState(() { + loaded = true; + }); + } + } + + @override + void initState() { + super.initState(); + loadIcons(); + } + + @override + void dispose() { + keyword.dispose(); + debounce.dispose(); + iconGroups.clear(); + loaded = false; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: IconSearchBar( + onRandomTap: () { + final value = kIconGroups?.randomIcon(); + if (value == null) { + return; + } + final color = generateRandomSpaceColor(); + widget.onSelectedIcon( + IconsData( + value.$1.name, + value.$2.content, + value.$2.name, + color, + ), + ); + }, + onKeywordChanged: (keyword) => { + debounce.call(() { + this.keyword.value = keyword; + }), + }, + ), + ), + Expanded( + child: loaded + ? _buildIcons(iconGroups) + : const Center( + child: SizedBox.square( + dimension: 24.0, + child: CircularProgressIndicator( + strokeWidth: 2.0, + ), + ), + ), + ), + ], + ); + } + + Widget _buildIcons(List iconGroups) { + return ValueListenableBuilder( + valueListenable: keyword, + builder: (_, keyword, __) { + if (keyword.isNotEmpty) { + final filteredIconGroups = iconGroups + .map((iconGroup) => iconGroup.filter(keyword)) + .where((iconGroup) => iconGroup.icons.isNotEmpty) + .toList(); + return IconPicker( + iconGroups: filteredIconGroups, + enableBackgroundColorSelection: + widget.enableBackgroundColorSelection, + onSelectedIcon: widget.onSelectedIcon, + iconPerLine: widget.iconPerLine, + ); + } + return IconPicker( + iconGroups: iconGroups, + enableBackgroundColorSelection: widget.enableBackgroundColorSelection, + onSelectedIcon: widget.onSelectedIcon, + iconPerLine: widget.iconPerLine, + ); + }, + ); + } +} + +class IconsData { + IconsData(this.groupName, this.iconContent, this.iconName, this.color); + + final String groupName; + final String iconContent; + final String iconName; + final String? color; + + String get iconString => jsonEncode({ + 'groupName': groupName, + 'iconContent': iconContent, + 'iconName': iconName, + if (color != null) 'color': color, + }); + + EmojiIconData toEmojiIconData() => EmojiIconData.icon(this); + + static IconsData fromJson(dynamic json) { + return IconsData( + json['groupName'], + json['iconContent'], + json['iconName'], + json['color'], + ); + } +} + +class IconPicker extends StatefulWidget { + const IconPicker({ + super.key, + required this.onSelectedIcon, + required this.enableBackgroundColorSelection, + required this.iconGroups, + required this.iconPerLine, + }); + + final List iconGroups; + final int iconPerLine; + final bool enableBackgroundColorSelection; + final ValueChanged onSelectedIcon; + + @override + State createState() => _IconPickerState(); +} + +class _IconPickerState extends State { + final mutex = PopoverMutex(); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: widget.iconGroups.length, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + itemBuilder: (context, index) { + final iconGroup = widget.iconGroups[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + iconGroup.displayName.capitalize(), + fontSize: 12, + figmaLineHeight: 18.0, + color: context.pickerTextColor, + ), + const VSpace(4.0), + GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.iconPerLine, + ), + itemCount: iconGroup.icons.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final icon = iconGroup.icons[index]; + return widget.enableBackgroundColorSelection + ? _Icon( + icon: icon, + mutex: mutex, + onSelectedColor: (context, color) { + widget.onSelectedIcon( + IconsData( + iconGroup.name, + icon.content, + icon.name, + color, + ), + ); + RecentIcons.putIcon(icon); + PopoverContainer.of(context).close(); + }, + ) + : _IconNoBackground( + icon: icon, + onSelectedIcon: () { + widget.onSelectedIcon( + IconsData( + iconGroup.name, + icon.content, + icon.name, + null, + ), + ); + RecentIcons.putIcon(icon); + }, + ); + }, + ), + const VSpace(12.0), + if (index == widget.iconGroups.length - 1) ...[ + const StreamlinePermit(), + const VSpace(12.0), + ], + ], + ); + }, + ); + } +} + +class _IconNoBackground extends StatelessWidget { + const _IconNoBackground({ + required this.icon, + required this.onSelectedIcon, + }); + + final Icon icon; + final VoidCallback onSelectedIcon; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: icon.displayName, + preferBelow: false, + child: FlowyButton( + useIntrinsicWidth: true, + onTap: () => onSelectedIcon(), + margin: const EdgeInsets.all(8.0), + text: Center( + child: FlowySvg.string( + icon.content, + size: const Size.square(20), + color: context.pickerIconColor, + opacity: 0.7, + ), + ), + ), + ); + } +} + +class _Icon extends StatefulWidget { + const _Icon({ + required this.icon, + required this.mutex, + required this.onSelectedColor, + }); + + final Icon icon; + final PopoverMutex mutex; + final void Function(BuildContext context, String color) onSelectedColor; + + @override + State<_Icon> createState() => _IconState(); +} + +class _IconState extends State<_Icon> { + final PopoverController _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 6), + mutex: widget.mutex, + child: _IconNoBackground( + icon: widget.icon, + onSelectedIcon: () => _popoverController.show(), + ), + popupBuilder: (context) { + return Container( + padding: const EdgeInsets.all(6.0), + child: IconColorPicker( + onSelected: (color) => widget.onSelectedColor(context, color), + ), + ); + }, + ); + } +} + +class StreamlinePermit extends StatelessWidget { + const StreamlinePermit({ + super.key, + }); + + @override + Widget build(BuildContext context) { + // Open source icons from Streamline + final textStyle = TextStyle( + fontSize: 12.0, + height: 18.0 / 12.0, + fontWeight: FontWeight.w500, + color: context.pickerTextColor, + ); + return RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.emoji_openSourceIconsFrom.tr()} ', + style: textStyle, + ), + TextSpan( + text: 'Streamline', + style: textStyle.copyWith( + decoration: TextDecoration.underline, + color: Theme.of(context).colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + afLaunchUrlString('https://www.streamlinehq.com/'); + }, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_search_bar.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_search_bar.dart new file mode 100644 index 0000000000000..dc079bbc4ecd1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/icon_search_bar.dart @@ -0,0 +1,164 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'colors.dart'; + +typedef IconKeywordChangedCallback = void Function(String keyword); +typedef EmojiSkinToneChanged = void Function(EmojiSkinTone skinTone); + +class IconSearchBar extends StatefulWidget { + const IconSearchBar({ + super.key, + required this.onRandomTap, + required this.onKeywordChanged, + }); + + final VoidCallback onRandomTap; + final IconKeywordChangedCallback onKeywordChanged; + + @override + State createState() => _IconSearchBarState(); +} + +class _IconSearchBarState extends State { + final TextEditingController controller = TextEditingController(); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric( + vertical: 12.0, + horizontal: UniversalPlatform.isDesktopOrWeb ? 0.0 : 8.0, + ), + child: Row( + children: [ + Expanded( + child: _SearchTextField( + onKeywordChanged: widget.onKeywordChanged, + ), + ), + const HSpace(8.0), + _RandomIconButton( + onRandomTap: widget.onRandomTap, + ), + ], + ), + ); + } +} + +class _RandomIconButton extends StatelessWidget { + const _RandomIconButton({ + required this.onRandomTap, + }); + + final VoidCallback onRandomTap; + + @override + Widget build(BuildContext context) { + return Container( + width: 36, + height: 36, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide(color: context.pickerButtonBoarderColor), + borderRadius: BorderRadius.circular(8), + ), + ), + child: FlowyTooltip( + message: LocaleKeys.emoji_random.tr(), + child: FlowyButton( + useIntrinsicWidth: true, + text: const FlowySvg( + FlowySvgs.icon_shuffle_s, + ), + onTap: onRandomTap, + ), + ), + ); + } +} + +class _SearchTextField extends StatefulWidget { + const _SearchTextField({ + required this.onKeywordChanged, + }); + + final IconKeywordChangedCallback onKeywordChanged; + + @override + State<_SearchTextField> createState() => _SearchTextFieldState(); +} + +class _SearchTextFieldState extends State<_SearchTextField> { + final TextEditingController controller = TextEditingController(); + final FocusNode focusNode = FocusNode(); + + @override + void dispose() { + controller.dispose(); + focusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 36.0, + child: FlowyTextField( + focusNode: focusNode, + hintText: LocaleKeys.search_label.tr(), + hintStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w400, + color: Theme.of(context).hintColor, + ), + enableBorderColor: context.pickerSearchBarBorderColor, + controller: controller, + onChanged: widget.onKeywordChanged, + prefixIcon: const Padding( + padding: EdgeInsets.only( + left: 14.0, + right: 8.0, + ), + child: FlowySvg( + FlowySvgs.search_s, + ), + ), + prefixIconConstraints: const BoxConstraints( + maxHeight: 20.0, + ), + suffixIcon: Padding( + padding: const EdgeInsets.all(4.0), + child: FlowyButton( + text: const FlowySvg( + FlowySvgs.m_app_bar_close_s, + ), + margin: EdgeInsets.zero, + useIntrinsicWidth: true, + onTap: () { + if (controller.text.isNotEmpty) { + controller.clear(); + widget.onKeywordChanged(''); + } else { + focusNode.unfocus(); + } + }, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/recent_icons.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/recent_icons.dart new file mode 100644 index 0000000000000..aae3937fac7f5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/recent_icons.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; + +import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter/foundation.dart'; + +import '../../core/config/kv.dart'; +import '../../core/config/kv_keys.dart'; +import '../../startup/startup.dart'; +import 'flowy_icon_emoji_picker.dart'; + +class RecentIcons { + static final Map> _dataMap = {}; + static bool _loaded = false; + static const maxLength = 20; + + /// To prevent the Recent Icon feature from affecting the unit tests of the Icon Selector. + @visibleForTesting + static bool enable = true; + + static Future putEmoji(String id) async { + await _put(FlowyIconType.emoji, id); + } + + static Future putIcon(Icon icon) async { + await _put( + FlowyIconType.icon, + jsonEncode( + Icon(name: icon.name, keywords: icon.keywords, content: icon.content) + .toJson(), + ), + ); + } + + static Future> getEmojiIds() async { + await _load(); + return _dataMap[FlowyIconType.emoji.name] ?? []; + } + + static Future> getIcons() async { + await _load(); + final iconList = _dataMap[FlowyIconType.icon.name] ?? []; + try { + return iconList + .map((e) => Icon.fromJson(jsonDecode(e) as Map)) + .toList(); + } catch (e) { + Log.error('RecentIcons getIcons with :$iconList', e); + } + return []; + } + + static Future _save() async { + await getIt().set( + KVKeys.recentIcons, + jsonEncode(_dataMap), + ); + } + + static Future _load() async { + if (_loaded || !enable) { + return; + } + final storage = getIt(); + final value = await storage.get(KVKeys.recentIcons); + if (value == null || value.isEmpty) { + _loaded = true; + return; + } + try { + final data = jsonDecode(value) as Map; + _dataMap + ..clear() + ..addAll( + Map>.from( + data.map((k, v) => MapEntry(k, List.from(v))), + ), + ); + } catch (e) { + Log.error('RecentIcons load failed with: $value', e); + } + _loaded = true; + } + + static Future _put(FlowyIconType key, String value) async { + await _load(); + if (!enable) return; + final list = _dataMap[key.name] ?? []; + list.remove(value); + list.insert(0, value); + if (list.length > maxLength) list.removeLast(); + _dataMap[key.name] = list; + await _save(); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/tab.dart b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/tab.dart new file mode 100644 index 0000000000000..56a363132c4d7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/icon_emoji_picker/tab.dart @@ -0,0 +1,67 @@ +import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart'; +import 'package:flutter/material.dart'; + +enum PickerTabType { + emoji, + icon; + + String get tr { + switch (this) { + case PickerTabType.emoji: + return 'Emojis'; + case PickerTabType.icon: + return 'Icons'; + } + } +} + +class PickerTab extends StatelessWidget { + const PickerTab({ + super.key, + this.onTap, + required this.controller, + required this.tabs, + }); + + final List tabs; + final TabController controller; + final ValueChanged? onTap; + + @override + Widget build(BuildContext context) { + final baseStyle = Theme.of(context).textTheme.bodyMedium; + final style = baseStyle?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 14.0, + height: 16.0 / 14.0, + ); + return TabBar( + controller: controller, + indicatorSize: TabBarIndicatorSize.label, + indicatorColor: Theme.of(context).colorScheme.primary, + isScrollable: true, + labelStyle: style, + labelColor: baseStyle?.color, + labelPadding: const EdgeInsets.symmetric(horizontal: 12.0), + unselectedLabelStyle: style?.copyWith( + color: Theme.of(context).hintColor, + ), + overlayColor: WidgetStateProperty.all(Colors.transparent), + indicator: RoundUnderlineTabIndicator( + width: 34.0, + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + ), + onTap: onTap, + tabs: tabs + .map( + (tab) => Tab( + text: tab.tr, + ), + ) + .toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/list_extension.dart b/frontend/appflowy_flutter/lib/shared/list_extension.dart new file mode 100644 index 0000000000000..e701ec3c5e468 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/list_extension.dart @@ -0,0 +1,8 @@ +extension Unique on List { + List unique([Id Function(E element)? id]) { + final ids = {}; + final list = [...this]; + list.retainWhere((x) => ids.add(id != null ? id(x) : x as Id)); + return list; + } +} diff --git a/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart b/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart new file mode 100644 index 0000000000000..b3e203460265e --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart @@ -0,0 +1,27 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +Document customMarkdownToDocument(String markdown) { + return markdownToDocument( + markdown, + markdownParsers: [ + const MarkdownCodeBlockParser(), + const MarkdownSimpleTableParser(), + ], + ); +} + +String customDocumentToMarkdown(Document document) { + return documentToMarkdown( + document, + customParsers: [ + const MathEquationNodeParser(), + const CalloutNodeParser(), + const ToggleListNodeParser(), + const CustomImageNodeParser(), + const SimpleTableNodeParser(), + const LinkPreviewNodeParser(), + const FileBlockNodeParser(), + ], + ); +} diff --git a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart new file mode 100644 index 0000000000000..ebd310a747741 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart @@ -0,0 +1,47 @@ +const _trailingZerosPattern = r'^(\d+(?:\.\d*?[1-9](?=0|\b))?)\.?0*$'; +final trailingZerosRegex = RegExp(_trailingZerosPattern); + +const _hrefPattern = + r'https?://(?:www\.)?[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}(?:/[^\s]*)?'; +final hrefRegex = RegExp(_hrefPattern); + +/// This pattern allows for both HTTP and HTTPS Scheme +/// It allows for query parameters +/// It only allows the following image extensions: .png, .jpg, .jpeg, .gif, .webm, .webp, .bmp +/// +const _imgUrlPattern = + r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.jpeg|.gif|.webm|.webp|.bmp)(\?[^\s[",><]*)?'; +final imgUrlRegex = RegExp(_imgUrlPattern); + +/// This pattern allows for both HTTP and HTTPS Scheme +/// It allows for query parameters +/// It only allows the following video extensions: +/// .mp4, .mov, .avi, .webm, .flv, .m4v (mpeg), .mpeg, .h264, +/// +const _videoUrlPattern = + r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.mp4|.mov|.avi|.webm|.flv|.m4v|.mpeg|.h264)(\?[^\s[",><]*)?'; +final videoUrlRegex = RegExp(_videoUrlPattern); + +/// This pattern matches both youtube.com and shortened youtu.be urls. +/// +const _youtubeUrlPattern = r'^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/'; +final youtubeUrlRegex = RegExp(_youtubeUrlPattern); + +const _appflowyCloudUrlPattern = r'^(https:\/\/)(.*)(\.appflowy\.cloud\/)(.*)'; +final appflowyCloudUrlRegex = RegExp(_appflowyCloudUrlPattern); + +const _camelCasePattern = '(?<=[a-z])[A-Z]'; +final camelCaseRegex = RegExp(_camelCasePattern); + +const _macOSVolumesPattern = '^/Volumes/[^/]+'; +final macOSVolumesRegex = RegExp(_macOSVolumesPattern); + +const appflowySharePageLinkPattern = + r'^https://appflowy\.com/app/([^/]+)/([^?]+)(?:\?blockId=(.+))?$'; +final appflowySharePageLinkRegex = RegExp(appflowySharePageLinkPattern); + +const _numberedListPattern = r'^(\d+)\.'; +final numberedListRegex = RegExp(_numberedListPattern); + +const _localPathPattern = r'^(file:\/\/|\/|\\|[a-zA-Z]:[/\\]|\.{1,2}[/\\])'; +final localPathRegex = RegExp(_localPathPattern, caseSensitive: false); diff --git a/frontend/appflowy_flutter/lib/shared/patterns/date_time_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/date_time_patterns.dart new file mode 100644 index 0000000000000..530e9d4558515 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/patterns/date_time_patterns.dart @@ -0,0 +1,19 @@ +/// RegExp to match Twelve Hour formats +/// Source: https://stackoverflow.com/a/33906224 +/// +/// Matches eg: "05:05 PM", "5:50 Pm", "10:59 am", etc. +/// +const _twelveHourTimePattern = + r'\b((1[0-2]|0?[1-9]):([0-5][0-9]) ([AaPp][Mm]))'; +final twelveHourTimeRegex = RegExp(_twelveHourTimePattern); +bool isTwelveHourTime(String? time) => twelveHourTimeRegex.hasMatch(time ?? ''); + +/// RegExp to match Twenty Four Hour formats +/// Source: https://stackoverflow.com/a/7536768 +/// +/// Matches eg: "0:01", "04:59", "16:30", etc. +/// +const _twentyFourHourtimePattern = r'^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'; +final tewentyFourHourTimeRegex = RegExp(_twentyFourHourtimePattern); +bool isTwentyFourHourTime(String? time) => + tewentyFourHourTimeRegex.hasMatch(time ?? ''); diff --git a/frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart new file mode 100644 index 0000000000000..418dd47f3d5b4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart @@ -0,0 +1,29 @@ +/// This pattern matches a file extension that is an image. +/// +const _imgExtensionPattern = r'\.(gif|jpe?g|tiff?|png|webp|bmp)$'; +final imgExtensionRegex = RegExp(_imgExtensionPattern); + +/// This pattern matches a file extension that is a video. +/// +const _videoExtensionPattern = r'\.(mp4|mov|avi|webm|flv|m4v|mpeg|h264)$'; +final videoExtensionRegex = RegExp(_videoExtensionPattern); + +/// This pattern matches a file extension that is an audio. +/// +const _audioExtensionPattern = r'\.(mp3|wav|ogg|flac|aac|wma|alac|aiff)$'; +final audioExtensionRegex = RegExp(_audioExtensionPattern); + +/// This pattern matches a file extension that is a document. +/// +const _documentExtensionPattern = r'\.(pdf|doc|docx)$'; +final documentExtensionRegex = RegExp(_documentExtensionPattern); + +/// This pattern matches a file extension that is an archive. +/// +const _archiveExtensionPattern = r'\.(zip|tar|gz|7z|rar)$'; +final archiveExtensionRegex = RegExp(_archiveExtensionPattern); + +/// This pattern matches a file extension that is a text. +/// +const _textExtensionPattern = r'\.(txt|md|html|css|js|json|xml|csv)$'; +final textExtensionRegex = RegExp(_textExtensionPattern); diff --git a/frontend/appflowy_flutter/lib/shared/permission/permission_checker.dart b/frontend/appflowy_flutter/lib/shared/permission/permission_checker.dart new file mode 100644 index 0000000000000..df260bad71cac --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/permission/permission_checker.dart @@ -0,0 +1,103 @@ +// Check if the user has the required permission to access the device's +// - camera +// - storage +// - ... +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart'; +import 'package:appflowy/startup/tasks/device_info_task.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class PermissionChecker { + static Future checkPhotoPermission(BuildContext context) async { + // check the permission first + final status = await Permission.photos.status; + // if the permission is permanently denied, we should open the app settings + if (status.isPermanentlyDenied && context.mounted) { + unawaited( + showFlowyMobileConfirmDialog( + context, + title: FlowyText.semibold( + LocaleKeys.pageStyle_photoPermissionTitle.tr(), + maxLines: 3, + textAlign: TextAlign.center, + ), + content: FlowyText( + LocaleKeys.pageStyle_photoPermissionDescription.tr(), + maxLines: 5, + textAlign: TextAlign.center, + fontSize: 12.0, + ), + actionAlignment: ConfirmDialogActionAlignment.vertical, + actionButtonTitle: LocaleKeys.pageStyle_openSettings.tr(), + actionButtonColor: Colors.blue, + cancelButtonTitle: LocaleKeys.pageStyle_doNotAllow.tr(), + cancelButtonColor: Colors.blue, + onActionButtonPressed: () { + openAppSettings(); + }, + ), + ); + + return false; + } else if (status.isDenied) { + // https://github.com/Baseflow/flutter-permission-handler/issues/1262#issuecomment-2006340937 + Permission permission = Permission.photos; + if (defaultTargetPlatform == TargetPlatform.android && + ApplicationInfo.androidSDKVersion <= 32) { + permission = Permission.storage; + } + // if the permission is denied, we should request the permission + final newStatus = await permission.request(); + if (newStatus.isDenied) { + return false; + } + } + + return true; + } + + static Future checkCameraPermission(BuildContext context) async { + // check the permission first + final status = await Permission.camera.status; + // if the permission is permanently denied, we should open the app settings + if (status.isPermanentlyDenied && context.mounted) { + unawaited( + showFlowyMobileConfirmDialog( + context, + title: FlowyText.semibold( + LocaleKeys.pageStyle_cameraPermissionTitle.tr(), + maxLines: 3, + textAlign: TextAlign.center, + ), + content: FlowyText( + LocaleKeys.pageStyle_cameraPermissionDescription.tr(), + maxLines: 5, + textAlign: TextAlign.center, + fontSize: 12.0, + ), + actionAlignment: ConfirmDialogActionAlignment.vertical, + actionButtonTitle: LocaleKeys.pageStyle_openSettings.tr(), + actionButtonColor: Colors.blue, + cancelButtonTitle: LocaleKeys.pageStyle_doNotAllow.tr(), + cancelButtonColor: Colors.blue, + onActionButtonPressed: openAppSettings, + ), + ); + + return false; + } else if (status.isDenied) { + final newStatus = await Permission.camera.request(); + if (newStatus.isDenied) { + return false; + } + } + + return true; + } +} diff --git a/frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart b/frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart new file mode 100644 index 0000000000000..1e9aa1a3c3d41 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/popup_menu/appflowy_popup_menu.dart @@ -0,0 +1,1667 @@ +// This file is copied from Flutter source code, +// and modified to fit AppFlowy's needs. + +// changes: +// 1. remove the default ink effect +// 2. remove the tooltip +// 3. support customize transition animation + +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; + +// Examples can assume: +// enum Commands { heroAndScholar, hurricaneCame } +// late bool _heroAndScholar; +// late dynamic _selection; +// late BuildContext context; +// void setState(VoidCallback fn) { } +// enum Menu { itemOne, itemTwo, itemThree, itemFour } + +const Duration _kMenuDuration = Duration(milliseconds: 300); +const double _kMenuCloseIntervalEnd = 2.0 / 3.0; +const double _kMenuDividerHeight = 16.0; +const double _kMenuMaxWidth = 5.0 * _kMenuWidthStep; +const double _kMenuMinWidth = 2.0 * _kMenuWidthStep; +const double _kMenuVerticalPadding = 8.0; +const double _kMenuWidthStep = 56.0; +const double _kMenuScreenPadding = 8.0; + +GlobalKey<_PopupMenuState>? _kPopupMenuKey; +void closePopupMenu() { + _kPopupMenuKey?.currentState?.dismiss(); + _kPopupMenuKey = null; +} + +/// A base class for entries in a Material Design popup menu. +/// +/// The popup menu widget uses this interface to interact with the menu items. +/// To show a popup menu, use the [showMenu] function. To create a button that +/// shows a popup menu, consider using [PopupMenuButton]. +/// +/// The type `T` is the type of the value(s) the entry represents. All the +/// entries in a given menu must represent values with consistent types. +/// +/// A [PopupMenuEntry] may represent multiple values, for example a row with +/// several icons, or a single entry, for example a menu item with an icon (see +/// [PopupMenuItem]), or no value at all (for example, [PopupMenuDivider]). +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for a single value. +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [CheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +/// * [PopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +abstract class PopupMenuEntry extends StatefulWidget { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const PopupMenuEntry({super.key}); + + /// The amount of vertical space occupied by this entry. + /// + /// This value is used at the time the [showMenu] method is called, if the + /// `initialValue` argument is provided, to determine the position of this + /// entry when aligning the selected entry over the given `position`. It is + /// otherwise ignored. + double get height; + + /// Whether this entry represents a particular value. + /// + /// This method is used by [showMenu], when it is called, to align the entry + /// representing the `initialValue`, if any, to the given `position`, and then + /// later is called on each entry to determine if it should be highlighted (if + /// the method returns true, the entry will have its background color set to + /// the ambient [ThemeData.highlightColor]). If `initialValue` is null, then + /// this method is not called. + /// + /// If the [PopupMenuEntry] represents a single value, this should return true + /// if the argument matches that value. If it represents multiple values, it + /// should return true if the argument matches any of them. + bool represents(T? value); +} + +/// A horizontal divider in a Material Design popup menu. +/// +/// This widget adapts the [Divider] for use in popup menus. +/// +/// See also: +/// +/// * [PopupMenuItem], for the kinds of items that this widget divides. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +/// * [PopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +class PopupMenuDivider extends PopupMenuEntry { + /// Creates a horizontal divider for a popup menu. + /// + /// By default, the divider has a height of 16 logical pixels. + const PopupMenuDivider({super.key, this.height = _kMenuDividerHeight}); + + /// The height of the divider entry. + /// + /// Defaults to 16 pixels. + @override + final double height; + + @override + bool represents(void value) => false; + + @override + State createState() => _PopupMenuDividerState(); +} + +class _PopupMenuDividerState extends State { + @override + Widget build(BuildContext context) => Divider(height: widget.height); +} + +// This widget only exists to enable _PopupMenuRoute to save the sizes of +// each menu item. The sizes are used by _PopupMenuRouteLayout to compute the +// y coordinate of the menu's origin so that the center of selected menu +// item lines up with the center of its PopupMenuButton. +class _MenuItem extends SingleChildRenderObjectWidget { + const _MenuItem({ + required this.onLayout, + required super.child, + }); + + final ValueChanged onLayout; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderMenuItem(onLayout); + } + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderMenuItem renderObject, + ) { + renderObject.onLayout = onLayout; + } +} + +class _RenderMenuItem extends RenderShiftedBox { + _RenderMenuItem(this.onLayout, [RenderBox? child]) : super(child); + + ValueChanged onLayout; + + @override + Size computeDryLayout(BoxConstraints constraints) { + return child?.getDryLayout(constraints) ?? Size.zero; + } + + @override + void performLayout() { + if (child == null) { + size = Size.zero; + } else { + child!.layout(constraints, parentUsesSize: true); + size = constraints.constrain(child!.size); + final BoxParentData childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Offset.zero; + } + onLayout(size); + } +} + +/// An item in a Material Design popup menu. +/// +/// To show a popup menu, use the [showMenu] function. To create a button that +/// shows a popup menu, consider using [PopupMenuButton]. +/// +/// To show a checkmark next to a popup menu item, consider using +/// [CheckedPopupMenuItem]. +/// +/// Typically the [child] of a [PopupMenuItem] is a [Text] widget. More +/// elaborate menus with icons can use a [ListTile]. By default, a +/// [PopupMenuItem] is [kMinInteractiveDimension] pixels high. If you use a widget +/// with a different height, it must be specified in the [height] property. +/// +/// {@tool snippet} +/// +/// Here, a [Text] widget is used with a popup menu item. The `Menu` type +/// is an enum, not shown here. +/// +/// ```dart +/// const PopupMenuItem( +/// value: Menu.itemOne, +/// child: Text('Item 1'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See the example at [PopupMenuButton] for how this example could be used in a +/// complete menu, and see the example at [CheckedPopupMenuItem] for one way to +/// keep the text of [PopupMenuItem]s that use [Text] widgets in their [child] +/// slot aligned with the text of [CheckedPopupMenuItem]s or of [PopupMenuItem] +/// that use a [ListTile] in their [child] slot. +/// +/// See also: +/// +/// * [PopupMenuDivider], which can be used to divide items from each other. +/// * [CheckedPopupMenuItem], a variant of [PopupMenuItem] with a checkmark. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +/// * [PopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +class PopupMenuItem extends PopupMenuEntry { + /// Creates an item for a popup menu. + /// + /// By default, the item is [enabled]. + const PopupMenuItem({ + super.key, + this.value, + this.onTap, + this.enabled = true, + this.height = kMinInteractiveDimension, + this.padding, + this.textStyle, + this.labelTextStyle, + this.mouseCursor, + required this.child, + }); + + /// The value that will be returned by [showMenu] if this entry is selected. + final T? value; + + /// Called when the menu item is tapped. + final VoidCallback? onTap; + + /// Whether the user is permitted to select this item. + /// + /// Defaults to true. If this is false, then the item will not react to + /// touches. + final bool enabled; + + /// The minimum height of the menu item. + /// + /// Defaults to [kMinInteractiveDimension] pixels. + @override + final double height; + + /// The padding of the menu item. + /// + /// The [height] property may interact with the applied padding. For example, + /// If a [height] greater than the height of the sum of the padding and [child] + /// is provided, then the padding's effect will not be visible. + /// + /// If this is null and [ThemeData.useMaterial3] is true, the horizontal padding + /// defaults to 12.0 on both sides. + /// + /// If this is null and [ThemeData.useMaterial3] is false, the horizontal padding + /// defaults to 16.0 on both sides. + final EdgeInsets? padding; + + /// The text style of the popup menu item. + /// + /// If this property is null, then [PopupMenuThemeData.textStyle] is used. + /// If [PopupMenuThemeData.textStyle] is also null, then [TextTheme.titleMedium] + /// of [ThemeData.textTheme] is used. + final TextStyle? textStyle; + + /// The label style of the popup menu item. + /// + /// When [ThemeData.useMaterial3] is true, this styles the text of the popup menu item. + /// + /// If this property is null, then [PopupMenuThemeData.labelTextStyle] is used. + /// If [PopupMenuThemeData.labelTextStyle] is also null, then [TextTheme.labelLarge] + /// is used with the [ColorScheme.onSurface] color when popup menu item is enabled and + /// the [ColorScheme.onSurface] color with 0.38 opacity when the popup menu item is disabled. + final WidgetStateProperty? labelTextStyle; + + /// {@template flutter.material.popupmenu.mouseCursor} + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [MaterialStateProperty], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// {@endtemplate} + /// + /// If null, then the value of [PopupMenuThemeData.mouseCursor] is used. If + /// that is also null, then [WidgetStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + + /// The widget below this widget in the tree. + /// + /// Typically a single-line [ListTile] (for menus with icons) or a [Text]. An + /// appropriate [DefaultTextStyle] is put in scope for the child. In either + /// case, the text should be short enough that it won't wrap. + final Widget? child; + + @override + bool represents(T? value) => value == this.value; + + @override + PopupMenuItemState> createState() => + PopupMenuItemState>(); +} + +/// The [State] for [PopupMenuItem] subclasses. +/// +/// By default this implements the basic styling and layout of Material Design +/// popup menu items. +/// +/// The [buildChild] method can be overridden to adjust exactly what gets placed +/// in the menu. By default it returns [PopupMenuItem.child]. +/// +/// The [handleTap] method can be overridden to adjust exactly what happens when +/// the item is tapped. By default, it uses [Navigator.pop] to return the +/// [PopupMenuItem.value] from the menu route. +/// +/// This class takes two type arguments. The second, `W`, is the exact type of +/// the [Widget] that is using this [State]. It must be a subclass of +/// [PopupMenuItem]. The first, `T`, must match the type argument of that widget +/// class, and is the type of values returned from this menu. +class PopupMenuItemState> extends State { + /// The menu item contents. + /// + /// Used by the [build] method. + /// + /// By default, this returns [PopupMenuItem.child]. Override this to put + /// something else in the menu entry. + @protected + Widget? buildChild() => widget.child; + + /// The handler for when the user selects the menu item. + /// + /// Used by the [InkWell] inserted by the [build] method. + /// + /// By default, uses [Navigator.pop] to return the [PopupMenuItem.value] from + /// the menu route. + @protected + void handleTap() { + // Need to pop the navigator first in case onTap may push new route onto navigator. + Navigator.pop(context, widget.value); + + widget.onTap?.call(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final PopupMenuThemeData defaults = theme.useMaterial3 + ? _PopupMenuDefaultsM3(context) + : _PopupMenuDefaultsM2(context); + final Set states = { + if (!widget.enabled) WidgetState.disabled, + }; + + TextStyle style = theme.useMaterial3 + ? (widget.labelTextStyle?.resolve(states) ?? + popupMenuTheme.labelTextStyle?.resolve(states)! ?? + defaults.labelTextStyle!.resolve(states)!) + : (widget.textStyle ?? popupMenuTheme.textStyle ?? defaults.textStyle!); + + if (!widget.enabled && !theme.useMaterial3) { + style = style.copyWith(color: theme.disabledColor); + } + + Widget item = AnimatedDefaultTextStyle( + style: style, + duration: kThemeChangeDuration, + child: Container( + alignment: AlignmentDirectional.centerStart, + constraints: BoxConstraints(minHeight: widget.height), + padding: widget.padding ?? + (theme.useMaterial3 + ? _PopupMenuDefaultsM3.menuHorizontalPadding + : _PopupMenuDefaultsM2.menuHorizontalPadding), + child: buildChild(), + ), + ); + + if (!widget.enabled) { + final bool isDark = theme.brightness == Brightness.dark; + item = IconTheme.merge( + data: IconThemeData(opacity: isDark ? 0.5 : 0.38), + child: item, + ); + } + + return MergeSemantics( + child: Semantics( + enabled: widget.enabled, + button: true, + child: GestureDetector( + onTap: widget.enabled ? handleTap : null, + behavior: HitTestBehavior.opaque, + child: ListTileTheme.merge( + contentPadding: EdgeInsets.zero, + titleTextStyle: style, + child: item, + ), + ), + ), + ); + } +} + +/// An item with a checkmark in a Material Design popup menu. +/// +/// To show a popup menu, use the [showMenu] function. To create a button that +/// shows a popup menu, consider using [PopupMenuButton]. +/// +/// A [CheckedPopupMenuItem] is kMinInteractiveDimension pixels high, which +/// matches the default minimum height of a [PopupMenuItem]. The horizontal +/// layout uses [ListTile]; the checkmark is an [Icons.done] icon, shown in the +/// [ListTile.leading] position. +/// +/// {@tool snippet} +/// +/// Suppose a `Commands` enum exists that lists the possible commands from a +/// particular popup menu, including `Commands.heroAndScholar` and +/// `Commands.hurricaneCame`, and further suppose that there is a +/// `_heroAndScholar` member field which is a boolean. The example below shows a +/// menu with one menu item with a checkmark that can toggle the boolean, and +/// one menu item without a checkmark for selecting the second option. (It also +/// shows a divider placed between the two menu items.) +/// +/// ```dart +/// PopupMenuButton( +/// onSelected: (Commands result) { +/// switch (result) { +/// case Commands.heroAndScholar: +/// setState(() { _heroAndScholar = !_heroAndScholar; }); +/// case Commands.hurricaneCame: +/// // ...handle hurricane option +/// break; +/// // ...other items handled here +/// } +/// }, +/// itemBuilder: (BuildContext context) => >[ +/// CheckedPopupMenuItem( +/// checked: _heroAndScholar, +/// value: Commands.heroAndScholar, +/// child: const Text('Hero and scholar'), +/// ), +/// const PopupMenuDivider(), +/// const PopupMenuItem( +/// value: Commands.hurricaneCame, +/// child: ListTile(leading: Icon(null), title: Text('Bring hurricane')), +/// ), +/// // ...other items listed here +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// In particular, observe how the second menu item uses a [ListTile] with a +/// blank [Icon] in the [ListTile.leading] position to get the same alignment as +/// the item with the checkmark. +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for picking a command (as opposed to +/// toggling a value). +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +/// * [PopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +class CheckedPopupMenuItem extends PopupMenuItem { + /// Creates a popup menu item with a checkmark. + /// + /// By default, the menu item is [enabled] but unchecked. To mark the item as + /// checked, set [checked] to true. + const CheckedPopupMenuItem({ + super.key, + super.value, + this.checked = false, + super.enabled, + super.padding, + super.height, + super.labelTextStyle, + super.mouseCursor, + super.child, + super.onTap, + }); + + /// Whether to display a checkmark next to the menu item. + /// + /// Defaults to false. + /// + /// When true, an [Icons.done] checkmark is displayed. + /// + /// When this popup menu item is selected, the checkmark will fade in or out + /// as appropriate to represent the implied new state. + final bool checked; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text]. An appropriate [DefaultTextStyle] is put in scope for + /// the child. The text should be short enough that it won't wrap. + /// + /// This widget is placed in the [ListTile.title] slot of a [ListTile] whose + /// [ListTile.leading] slot is an [Icons.done] icon. + @override + Widget? get child => super.child; + + @override + PopupMenuItemState> createState() => + _CheckedPopupMenuItemState(); +} + +class _CheckedPopupMenuItemState + extends PopupMenuItemState> + with SingleTickerProviderStateMixin { + static const Duration _fadeDuration = Duration(milliseconds: 150); + late AnimationController _controller; + Animation get _opacity => _controller.view; + + @override + void initState() { + super.initState(); + _controller = AnimationController(duration: _fadeDuration, vsync: this) + ..value = widget.checked ? 1.0 : 0.0 + ..addListener(_updateState); + } + + // Called when animation changed + void _updateState() => setState(() {}); + + @override + void dispose() { + _controller.removeListener(_updateState); + _controller.dispose(); + super.dispose(); + } + + @override + void handleTap() { + // This fades the checkmark in or out when tapped. + if (widget.checked) { + _controller.reverse(); + } else { + _controller.forward(); + } + super.handleTap(); + } + + @override + Widget buildChild() { + final ThemeData theme = Theme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final PopupMenuThemeData defaults = theme.useMaterial3 + ? _PopupMenuDefaultsM3(context) + : _PopupMenuDefaultsM2(context); + final Set states = { + if (widget.checked) WidgetState.selected, + }; + final WidgetStateProperty? effectiveLabelTextStyle = + widget.labelTextStyle ?? + popupMenuTheme.labelTextStyle ?? + defaults.labelTextStyle; + return IgnorePointer( + child: ListTileTheme.merge( + contentPadding: EdgeInsets.zero, + child: ListTile( + enabled: widget.enabled, + titleTextStyle: effectiveLabelTextStyle?.resolve(states), + leading: FadeTransition( + opacity: _opacity, + child: Icon(_controller.isDismissed ? null : Icons.done), + ), + title: widget.child, + ), + ), + ); + } +} + +class _PopupMenu extends StatefulWidget { + const _PopupMenu({ + super.key, + required this.itemKeys, + required this.route, + required this.semanticLabel, + this.constraints, + required this.clipBehavior, + }); + + final List itemKeys; + final _PopupMenuRoute route; + final String? semanticLabel; + final BoxConstraints? constraints; + final Clip clipBehavior; + + @override + State<_PopupMenu> createState() => _PopupMenuState(); +} + +class _PopupMenuState extends State<_PopupMenu> { + @override + Widget build(BuildContext context) { + final double unit = 1.0 / + (widget.route.items.length + + 1.5); // 1.0 for the width and 0.5 for the last item's fade. + final List children = []; + final ThemeData theme = Theme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final PopupMenuThemeData defaults = theme.useMaterial3 + ? _PopupMenuDefaultsM3(context) + : _PopupMenuDefaultsM2(context); + + for (int i = 0; i < widget.route.items.length; i += 1) { + final double start = (i + 1) * unit; + final double end = clampDouble(start + 1.5 * unit, 0.0, 1.0); + final CurvedAnimation opacity = CurvedAnimation( + parent: widget.route.animation!, + curve: Interval(start, end), + ); + Widget item = widget.route.items[i]; + if (widget.route.initialValue != null && + widget.route.items[i].represents(widget.route.initialValue)) { + item = ColoredBox( + color: Theme.of(context).highlightColor, + child: item, + ); + } + children.add( + _MenuItem( + onLayout: (Size size) { + widget.route.itemSizes[i] = size; + }, + child: FadeTransition( + key: widget.itemKeys[i], + opacity: opacity, + child: item, + ), + ), + ); + } + + final _CurveTween opacity = + _CurveTween(curve: const Interval(0.0, 1.0 / 3.0)); + final _CurveTween width = _CurveTween(curve: Interval(0.0, unit)); + final _CurveTween height = + _CurveTween(curve: Interval(0.0, unit * widget.route.items.length)); + + final Widget child = ConstrainedBox( + constraints: widget.constraints ?? + const BoxConstraints( + minWidth: _kMenuMinWidth, + maxWidth: _kMenuMaxWidth, + ), + child: IntrinsicWidth( + stepWidth: _kMenuWidthStep, + child: Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + vertical: _kMenuVerticalPadding, + ), + child: ListBody(children: children), + ), + ), + ), + ); + + return AnimatedBuilder( + animation: widget.route.animation!, + builder: (BuildContext context, Widget? child) { + return FadeTransition( + opacity: opacity.animate(widget.route.animation!), + child: Material( + shape: widget.route.shape ?? popupMenuTheme.shape ?? defaults.shape, + color: widget.route.color ?? popupMenuTheme.color ?? defaults.color, + clipBehavior: widget.clipBehavior, + type: MaterialType.card, + elevation: widget.route.elevation ?? + popupMenuTheme.elevation ?? + defaults.elevation!, + shadowColor: widget.route.shadowColor ?? + popupMenuTheme.shadowColor ?? + defaults.shadowColor, + surfaceTintColor: widget.route.surfaceTintColor ?? + popupMenuTheme.surfaceTintColor ?? + defaults.surfaceTintColor, + child: Align( + alignment: AlignmentDirectional.topEnd, + widthFactor: width.evaluate(widget.route.animation!), + heightFactor: height.evaluate(widget.route.animation!), + child: child, + ), + ), + ); + }, + child: child, + ); + } + + @override + void dispose() { + _kPopupMenuKey = null; + super.dispose(); + } + + void dismiss() { + if (_kPopupMenuKey == null) { + return; + } + + Navigator.of(context).pop(); + _kPopupMenuKey = null; + } +} + +// Positioning of the menu on the screen. +class _PopupMenuRouteLayout extends SingleChildLayoutDelegate { + _PopupMenuRouteLayout( + this.position, + this.itemSizes, + this.selectedItemIndex, + this.textDirection, + this.padding, + this.avoidBounds, + ); + + // Rectangle of underlying button, relative to the overlay's dimensions. + final RelativeRect position; + + // The sizes of each item are computed when the menu is laid out, and before + // the route is laid out. + List itemSizes; + + // The index of the selected item, or null if PopupMenuButton.initialValue + // was not specified. + final int? selectedItemIndex; + + // Whether to prefer going to the left or to the right. + final TextDirection textDirection; + + // The padding of unsafe area. + EdgeInsets padding; + + // List of rectangles that we should avoid overlapping. Unusable screen area. + final Set avoidBounds; + + // We put the child wherever position specifies, so long as it will fit within + // the specified parent size padded (inset) by 8. If necessary, we adjust the + // child's position so that it fits. + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + // The menu can be at most the size of the overlay minus 8.0 pixels in each + // direction. + return BoxConstraints.loose(constraints.biggest).deflate( + const EdgeInsets.all(_kMenuScreenPadding) + padding, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final double y = position.top; + + // Find the ideal horizontal position. + // size: The size of the overlay. + // childSize: The size of the menu, when fully open, as determined by + // getConstraintsForChild. + double x; + if (position.left > position.right) { + // Menu button is closer to the right edge, so grow to the left, aligned to the right edge. + x = size.width - position.right - childSize.width; + } else if (position.left < position.right) { + // Menu button is closer to the left edge, so grow to the right, aligned to the left edge. + x = position.left; + } else { + // Menu button is equidistant from both edges, so grow in reading direction. + x = switch (textDirection) { + TextDirection.rtl => size.width - position.right - childSize.width, + TextDirection.ltr => position.left, + }; + } + final Offset wantedPosition = Offset(x, y); + final Offset originCenter = position.toRect(Offset.zero & size).center; + final Iterable subScreens = + DisplayFeatureSubScreen.subScreensInBounds( + Offset.zero & size, + avoidBounds, + ); + final Rect subScreen = _closestScreen(subScreens, originCenter); + return _fitInsideScreen(subScreen, childSize, wantedPosition); + } + + Rect _closestScreen(Iterable screens, Offset point) { + Rect closest = screens.first; + for (final Rect screen in screens) { + if ((screen.center - point).distance < + (closest.center - point).distance) { + closest = screen; + } + } + return closest; + } + + Offset _fitInsideScreen(Rect screen, Size childSize, Offset wantedPosition) { + double x = wantedPosition.dx; + double y = wantedPosition.dy; + // Avoid going outside an area defined as the rectangle 8.0 pixels from the + // edge of the screen in every direction. + if (x < screen.left + _kMenuScreenPadding + padding.left) { + x = screen.left + _kMenuScreenPadding + padding.left; + } else if (x + childSize.width > + screen.right - _kMenuScreenPadding - padding.right) { + x = screen.right - childSize.width - _kMenuScreenPadding - padding.right; + } + if (y < screen.top + _kMenuScreenPadding + padding.top) { + y = _kMenuScreenPadding + padding.top; + } else if (y + childSize.height > + screen.bottom - _kMenuScreenPadding - padding.bottom) { + y = screen.bottom - + childSize.height - + _kMenuScreenPadding - + padding.bottom; + } + + return Offset(x, y); + } + + @override + bool shouldRelayout(_PopupMenuRouteLayout oldDelegate) { + // If called when the old and new itemSizes have been initialized then + // we expect them to have the same length because there's no practical + // way to change length of the items list once the menu has been shown. + assert(itemSizes.length == oldDelegate.itemSizes.length); + + return position != oldDelegate.position || + selectedItemIndex != oldDelegate.selectedItemIndex || + textDirection != oldDelegate.textDirection || + !listEquals(itemSizes, oldDelegate.itemSizes) || + padding != oldDelegate.padding || + !setEquals(avoidBounds, oldDelegate.avoidBounds); + } +} + +class _PopupMenuRoute extends PopupRoute { + _PopupMenuRoute({ + required this.position, + required this.items, + required this.itemKeys, + this.initialValue, + this.elevation, + this.surfaceTintColor, + this.shadowColor, + required this.barrierLabel, + this.semanticLabel, + this.shape, + this.color, + required this.capturedThemes, + this.constraints, + required this.clipBehavior, + super.settings, + this.popUpAnimationStyle, + }) : itemSizes = List.filled(items.length, null), + // Menus always cycle focus through their items irrespective of the + // focus traversal edge behavior set in the Navigator. + super(traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop); + + final RelativeRect position; + final List> items; + final List itemKeys; + final List itemSizes; + final T? initialValue; + final double? elevation; + final Color? surfaceTintColor; + final Color? shadowColor; + final String? semanticLabel; + final ShapeBorder? shape; + final Color? color; + final CapturedThemes capturedThemes; + final BoxConstraints? constraints; + final Clip clipBehavior; + final AnimationStyle? popUpAnimationStyle; + + @override + Animation createAnimation() { + if (popUpAnimationStyle != AnimationStyle.noAnimation) { + return CurvedAnimation( + parent: super.createAnimation(), + curve: popUpAnimationStyle?.curve ?? Curves.easeInBack, + reverseCurve: popUpAnimationStyle?.reverseCurve ?? + const Interval(0.0, _kMenuCloseIntervalEnd), + ); + } + return super.createAnimation(); + } + + void scrollTo(int selectedItemIndex) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (itemKeys[selectedItemIndex].currentContext != null) { + Scrollable.ensureVisible(itemKeys[selectedItemIndex].currentContext!); + } + }); + } + + @override + Duration get transitionDuration => + popUpAnimationStyle?.duration ?? _kMenuDuration; + + @override + bool get barrierDismissible => true; + + @override + Color? get barrierColor => null; + + @override + final String barrierLabel; + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (!animation.isCompleted) { + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + final size = position.toSize(Size(screenWidth, screenHeight)); + final center = size.width / 2.0; + final alignment = FractionalOffset( + (screenWidth - position.right - center) / screenWidth, + (screenHeight - position.bottom - center) / screenHeight, + ); + child = FadeTransition( + opacity: animation, + child: ScaleTransition( + alignment: alignment, + scale: animation, + child: child, + ), + ); + } + return child; + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + int? selectedItemIndex; + if (initialValue != null) { + for (int index = 0; + selectedItemIndex == null && index < items.length; + index += 1) { + if (items[index].represents(initialValue)) { + selectedItemIndex = index; + } + } + } + if (selectedItemIndex != null) { + scrollTo(selectedItemIndex); + } + + _kPopupMenuKey ??= GlobalKey<_PopupMenuState>(); + final Widget menu = _PopupMenu( + key: _kPopupMenuKey, + route: this, + itemKeys: itemKeys, + semanticLabel: semanticLabel, + constraints: constraints, + clipBehavior: clipBehavior, + ); + final MediaQueryData mediaQuery = MediaQuery.of(context); + return MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + removeLeft: true, + removeRight: true, + child: Builder( + builder: (BuildContext context) { + return CustomSingleChildLayout( + delegate: _PopupMenuRouteLayout( + position, + itemSizes, + selectedItemIndex, + Directionality.of(context), + mediaQuery.padding, + _avoidBounds(mediaQuery), + ), + child: capturedThemes.wrap(menu), + ); + }, + ), + ); + } + + Set _avoidBounds(MediaQueryData mediaQuery) { + return DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet(); + } +} + +/// Show a popup menu that contains the `items` at `position`. +/// +/// The `items` parameter must not be empty. +/// +/// If `initialValue` is specified then the first item with a matching value +/// will be highlighted and the value of `position` gives the rectangle whose +/// vertical center will be aligned with the vertical center of the highlighted +/// item (when possible). +/// +/// If `initialValue` is not specified then the top of the menu will be aligned +/// with the top of the `position` rectangle. +/// +/// In both cases, the menu position will be adjusted if necessary to fit on the +/// screen. +/// +/// Horizontally, the menu is positioned so that it grows in the direction that +/// has the most room. For example, if the `position` describes a rectangle on +/// the left edge of the screen, then the left edge of the menu is aligned with +/// the left edge of the `position`, and the menu grows to the right. If both +/// edges of the `position` are equidistant from the opposite edge of the +/// screen, then the ambient [Directionality] is used as a tie-breaker, +/// preferring to grow in the reading direction. +/// +/// The positioning of the `initialValue` at the `position` is implemented by +/// iterating over the `items` to find the first whose +/// [PopupMenuEntry.represents] method returns true for `initialValue`, and then +/// summing the values of [PopupMenuEntry.height] for all the preceding widgets +/// in the list. +/// +/// The `elevation` argument specifies the z-coordinate at which to place the +/// menu. The elevation defaults to 8, the appropriate elevation for popup +/// menus. +/// +/// The `context` argument is used to look up the [Navigator] and [Theme] for +/// the menu. It is only used when the method is called. Its corresponding +/// widget can be safely removed from the tree before the popup menu is closed. +/// +/// The `useRootNavigator` argument is used to determine whether to push the +/// menu to the [Navigator] furthest from or nearest to the given `context`. It +/// is `false` by default. +/// +/// The `semanticLabel` argument is used by accessibility frameworks to +/// announce screen transitions when the menu is opened and closed. If this +/// label is not provided, it will default to +/// [MaterialLocalizations.popupMenuLabel]. +/// +/// The `clipBehavior` argument is used to clip the shape of the menu. Defaults to +/// [Clip.none]. +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for a single value. +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [CheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [PopupMenuButton], which provides an [IconButton] that shows a menu by +/// calling this method automatically. +/// * [SemanticsConfiguration.namesRoute], for a description of edge triggered +/// semantics. +Future showMenu({ + required BuildContext context, + required RelativeRect position, + required List> items, + T? initialValue, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + String? semanticLabel, + ShapeBorder? shape, + Color? color, + bool useRootNavigator = false, + BoxConstraints? constraints, + Clip clipBehavior = Clip.none, + RouteSettings? routeSettings, + AnimationStyle? popUpAnimationStyle, +}) { + assert(items.isNotEmpty); + assert(debugCheckHasMaterialLocalizations(context)); + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + semanticLabel ??= MaterialLocalizations.of(context).popupMenuLabel; + } + + final List menuItemKeys = + List.generate(items.length, (int index) => GlobalKey()); + final NavigatorState navigator = + Navigator.of(context, rootNavigator: useRootNavigator); + return navigator.push( + _PopupMenuRoute( + position: position, + items: items, + itemKeys: menuItemKeys, + initialValue: initialValue, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + semanticLabel: semanticLabel, + barrierLabel: MaterialLocalizations.of(context).menuDismissLabel, + shape: shape, + color: color, + capturedThemes: + InheritedTheme.capture(from: context, to: navigator.context), + constraints: constraints, + clipBehavior: clipBehavior, + settings: routeSettings, + popUpAnimationStyle: popUpAnimationStyle, + ), + ); +} + +/// Signature for the callback invoked when a menu item is selected. The +/// argument is the value of the [PopupMenuItem] that caused its menu to be +/// dismissed. +/// +/// Used by [PopupMenuButton.onSelected]. +typedef PopupMenuItemSelected = void Function(T value); + +/// Signature for the callback invoked when a [PopupMenuButton] is dismissed +/// without selecting an item. +/// +/// Used by [PopupMenuButton.onCanceled]. +typedef PopupMenuCanceled = void Function(); + +/// Signature used by [PopupMenuButton] to lazily construct the items shown when +/// the button is pressed. +/// +/// Used by [PopupMenuButton.itemBuilder]. +typedef PopupMenuItemBuilder = List> Function( + BuildContext context, +); + +/// Displays a menu when pressed and calls [onSelected] when the menu is dismissed +/// because an item was selected. The value passed to [onSelected] is the value of +/// the selected menu item. +/// +/// One of [child] or [icon] may be provided, but not both. If [icon] is provided, +/// then [PopupMenuButton] behaves like an [IconButton]. +/// +/// If both are null, then a standard overflow icon is created (depending on the +/// platform). +/// +/// ## Updating to [MenuAnchor] +/// +/// There is a Material 3 component, +/// [MenuAnchor] that is preferred for applications that are configured +/// for Material 3 (see [ThemeData.useMaterial3]). +/// The [MenuAnchor] widget's visuals +/// are a little bit different, see the Material 3 spec at +/// for +/// more details. +/// +/// The [MenuAnchor] widget's API is also slightly different. +/// [MenuAnchor]'s were built to be lower level interface for +/// creating menus that are displayed from an anchor. +/// +/// There are a few steps you would take to migrate from +/// [PopupMenuButton] to [MenuAnchor]: +/// +/// 1. Instead of using the [PopupMenuButton.itemBuilder] to build +/// a list of [PopupMenuEntry]s, you would use the [MenuAnchor.menuChildren] +/// which takes a list of [Widget]s. Usually, you would use a list of +/// [MenuItemButton]s as shown in the example below. +/// +/// 2. Instead of using the [PopupMenuButton.onSelected] callback, you would +/// set individual callbacks for each of the [MenuItemButton]s using the +/// [MenuItemButton.onPressed] property. +/// +/// 3. To anchor the [MenuAnchor] to a widget, you would use the [MenuAnchor.builder] +/// to return the widget of choice - usually a [TextButton] or an [IconButton]. +/// +/// 4. You may want to style the [MenuItemButton]s, see the [MenuItemButton] +/// documentation for details. +/// +/// Use the sample below for an example of migrating from [PopupMenuButton] to +/// [MenuAnchor]. +/// +/// {@tool dartpad} +/// This example shows a menu with three items, selecting between an enum's +/// values and setting a `selectedMenu` field based on the selection. +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to migrate the above to a [MenuAnchor]. +/// +/// ** See code in examples/api/lib/material/menu_anchor/menu_anchor.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of a popup menu, as described in: +/// https://m3.material.io/components/menus/overview +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample showcases how to override the [PopupMenuButton] animation +/// curves and duration using [AnimationStyle]. +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [PopupMenuItem], a popup menu entry for a single value. +/// * [PopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [CheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [showMenu], a method to dynamically show a popup menu at a given location. +class PopupMenuButton extends StatefulWidget { + /// Creates a button that shows a popup menu. + const PopupMenuButton({ + super.key, + required this.itemBuilder, + this.initialValue, + this.onOpened, + this.onSelected, + this.onCanceled, + this.tooltip, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.padding = const EdgeInsets.all(8.0), + this.child, + this.splashRadius, + this.icon, + this.iconSize, + this.offset = Offset.zero, + this.enabled = true, + this.shape, + this.color, + this.iconColor, + this.enableFeedback, + this.constraints, + this.position, + this.clipBehavior = Clip.none, + this.useRootNavigator = false, + this.popUpAnimationStyle, + this.routeSettings, + this.style, + }) : assert( + !(child != null && icon != null), + 'You can only pass [child] or [icon], not both.', + ); + + /// Called when the button is pressed to create the items to show in the menu. + final PopupMenuItemBuilder itemBuilder; + + /// The value of the menu item, if any, that should be highlighted when the menu opens. + final T? initialValue; + + /// Called when the popup menu is shown. + final VoidCallback? onOpened; + + /// Called when the user selects a value from the popup menu created by this button. + /// + /// If the popup menu is dismissed without selecting a value, [onCanceled] is + /// called instead. + final PopupMenuItemSelected? onSelected; + + /// Called when the user dismisses the popup menu without selecting an item. + /// + /// If the user selects a value, [onSelected] is called instead. + final PopupMenuCanceled? onCanceled; + + /// Text that describes the action that will occur when the button is pressed. + /// + /// This text is displayed when the user long-presses on the button and is + /// used for accessibility. + final String? tooltip; + + /// The z-coordinate at which to place the menu when open. This controls the + /// size of the shadow below the menu. + /// + /// Defaults to 8, the appropriate elevation for popup menus. + final double? elevation; + + /// The color used to paint the shadow below the menu. + /// + /// If null then the ambient [PopupMenuThemeData.shadowColor] is used. + /// If that is null too, then the overall theme's [ThemeData.shadowColor] + /// (default black) is used. + final Color? shadowColor; + + /// The color used as an overlay on [color] to indicate elevation. + /// + /// This is not recommended for use. [Material 3 spec](https://m3.material.io/styles/color/the-color-system/color-roles) + /// introduced a set of tone-based surfaces and surface containers in its [ColorScheme], + /// which provide more flexibility. The intention is to eventually remove surface tint color from + /// the framework. + /// + /// If null, [PopupMenuThemeData.surfaceTintColor] is used. If that + /// is also null, the default value is [Colors.transparent]. + /// + /// See [Material.surfaceTintColor] for more details on how this + /// overlay is applied. + final Color? surfaceTintColor; + + /// Matches IconButton's 8 dps padding by default. In some cases, notably where + /// this button appears as the trailing element of a list item, it's useful to be able + /// to set the padding to zero. + final EdgeInsetsGeometry padding; + + /// The splash radius. + /// + /// If null, default splash radius of [InkWell] or [IconButton] is used. + final double? splashRadius; + + /// If provided, [child] is the widget used for this button + /// and the button will utilize an [InkWell] for taps. + final Widget? child; + + /// If provided, the [icon] is used for this button + /// and the button will behave like an [IconButton]. + final Widget? icon; + + /// The offset is applied relative to the initial position + /// set by the [position]. + /// + /// When not set, the offset defaults to [Offset.zero]. + final Offset offset; + + /// Whether this popup menu button is interactive. + /// + /// Defaults to true. + /// + /// If true, the button will respond to presses by displaying the menu. + /// + /// If false, the button is styled with the disabled color from the + /// current [Theme] and will not respond to presses or show the popup + /// menu and [onSelected], [onCanceled] and [itemBuilder] will not be called. + /// + /// This can be useful in situations where the app needs to show the button, + /// but doesn't currently have anything to show in the menu. + final bool enabled; + + /// If provided, the shape used for the menu. + /// + /// If this property is null, then [PopupMenuThemeData.shape] is used. + /// If [PopupMenuThemeData.shape] is also null, then the default shape for + /// [MaterialType.card] is used. This default shape is a rectangle with + /// rounded edges of BorderRadius.circular(2.0). + final ShapeBorder? shape; + + /// If provided, the background color used for the menu. + /// + /// If this property is null, then [PopupMenuThemeData.color] is used. + /// If [PopupMenuThemeData.color] is also null, then + /// [ThemeData.cardColor] is used in Material 2. In Material3, defaults to + /// [ColorScheme.surfaceContainer]. + final Color? color; + + /// If provided, this color is used for the button icon. + /// + /// If this property is null, then [PopupMenuThemeData.iconColor] is used. + /// If [PopupMenuThemeData.iconColor] is also null then defaults to + /// [IconThemeData.color]. + final Color? iconColor; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// If provided, the size of the [Icon]. + /// + /// If this property is null, then [IconThemeData.size] is used. + /// If [IconThemeData.size] is also null, then + /// default size is 24.0 pixels. + final double? iconSize; + + /// Optional size constraints for the menu. + /// + /// When unspecified, defaults to: + /// ```dart + /// const BoxConstraints( + /// minWidth: 2.0 * 56.0, + /// maxWidth: 5.0 * 56.0, + /// ) + /// ``` + /// + /// The default constraints ensure that the menu width matches maximum width + /// recommended by the Material Design guidelines. + /// Specifying this parameter enables creation of menu wider than + /// the default maximum width. + final BoxConstraints? constraints; + + /// Whether the popup menu is positioned over or under the popup menu button. + /// + /// [offset] is used to change the position of the popup menu relative to the + /// position set by this parameter. + /// + /// If this property is `null`, then [PopupMenuThemeData.position] is used. If + /// [PopupMenuThemeData.position] is also `null`, then the position defaults + /// to [PopupMenuPosition.over] which makes the popup menu appear directly + /// over the button that was used to create it. + final PopupMenuPosition? position; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// The [clipBehavior] argument is used the clip shape of the menu. + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// Used to determine whether to push the menu to the [Navigator] furthest + /// from or nearest to the given `context`. + /// + /// Defaults to false. + final bool useRootNavigator; + + /// Used to override the default animation curves and durations of the popup + /// menu's open and close transitions. + /// + /// If [AnimationStyle.curve] is provided, it will be used to override + /// the default popup animation curve. Otherwise, defaults to [Curves.linear]. + /// + /// If [AnimationStyle.reverseCurve] is provided, it will be used to + /// override the default popup animation reverse curve. Otherwise, defaults to + /// `Interval(0.0, 2.0 / 3.0)`. + /// + /// If [AnimationStyle.duration] is provided, it will be used to override + /// the default popup animation duration. Otherwise, defaults to 300ms. + /// + /// To disable the theme animation, use [AnimationStyle.noAnimation]. + /// + /// If this is null, then the default animation will be used. + final AnimationStyle? popUpAnimationStyle; + + /// Optional route settings for the menu. + /// + /// See [RouteSettings] for details. + final RouteSettings? routeSettings; + + /// Customizes this icon button's appearance. + /// + /// The [style] is only used for Material 3 [IconButton]s. If [ThemeData.useMaterial3] + /// is set to true, [style] is preferred for icon button customization, and any + /// parameters defined in [style] will override the same parameters in [IconButton]. + /// + /// Null by default. + final ButtonStyle? style; + + @override + PopupMenuButtonState createState() => PopupMenuButtonState(); +} + +/// The [State] for a [PopupMenuButton]. +/// +/// See [showButtonMenu] for a way to programmatically open the popup menu +/// of your button state. +class PopupMenuButtonState extends State> { + /// A method to show a popup menu with the items supplied to + /// [PopupMenuButton.itemBuilder] at the position of your [PopupMenuButton]. + /// + /// By default, it is called when the user taps the button and [PopupMenuButton.enabled] + /// is set to `true`. Moreover, you can open the button by calling the method manually. + /// + /// You would access your [PopupMenuButtonState] using a [GlobalKey] and + /// show the menu of the button with `globalKey.currentState.showButtonMenu`. + void showButtonMenu() { + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final RenderBox button = context.findRenderObject()! as RenderBox; + final RenderBox overlay = + Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox; + final PopupMenuPosition popupMenuPosition = + widget.position ?? popupMenuTheme.position ?? PopupMenuPosition.over; + late Offset offset; + switch (popupMenuPosition) { + case PopupMenuPosition.over: + offset = widget.offset; + case PopupMenuPosition.under: + offset = Offset(0.0, button.size.height) + widget.offset; + if (widget.child == null) { + // Remove the padding of the icon button. + offset -= Offset(0.0, widget.padding.vertical / 2); + } + } + final RelativeRect position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(offset, ancestor: overlay), + button.localToGlobal( + button.size.bottomRight(Offset.zero) + offset, + ancestor: overlay, + ), + ), + Offset.zero & overlay.size, + ); + final List> items = widget.itemBuilder(context); + // Only show the menu if there is something to show + if (items.isNotEmpty) { + var popUpAnimationStyle = widget.popUpAnimationStyle; + if (popUpAnimationStyle == null && + defaultTargetPlatform == TargetPlatform.iOS) { + popUpAnimationStyle = AnimationStyle( + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 300), + ); + } + widget.onOpened?.call(); + showMenu( + context: context, + elevation: widget.elevation ?? popupMenuTheme.elevation, + shadowColor: widget.shadowColor ?? popupMenuTheme.shadowColor, + surfaceTintColor: + widget.surfaceTintColor ?? popupMenuTheme.surfaceTintColor, + items: items, + initialValue: widget.initialValue, + position: position, + shape: widget.shape ?? popupMenuTheme.shape, + color: widget.color ?? popupMenuTheme.color, + constraints: widget.constraints, + clipBehavior: widget.clipBehavior, + useRootNavigator: widget.useRootNavigator, + popUpAnimationStyle: popUpAnimationStyle, + routeSettings: widget.routeSettings, + ).then((T? newValue) { + if (!mounted) { + return null; + } + if (newValue == null) { + widget.onCanceled?.call(); + return null; + } + widget.onSelected?.call(newValue); + }); + } + } + + @override + Widget build(BuildContext context) { + final IconThemeData iconTheme = IconTheme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final bool enableFeedback = widget.enableFeedback ?? + PopupMenuTheme.of(context).enableFeedback ?? + true; + + assert(debugCheckHasMaterialLocalizations(context)); + + if (widget.child != null) { + return AnimatedGestureDetector( + scaleFactor: 0.95, + onTapUp: widget.enabled ? showButtonMenu : null, + child: widget.child!, + ); + } + + return IconButton( + icon: widget.icon ?? Icon(Icons.adaptive.more), + padding: widget.padding, + splashRadius: widget.splashRadius, + iconSize: widget.iconSize ?? popupMenuTheme.iconSize ?? iconTheme.size, + color: widget.iconColor ?? popupMenuTheme.iconColor ?? iconTheme.color, + tooltip: + widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, + onPressed: widget.enabled ? showButtonMenu : null, + enableFeedback: enableFeedback, + style: widget.style, + ); + } +} + +class _PopupMenuDefaultsM2 extends PopupMenuThemeData { + _PopupMenuDefaultsM2(this.context) : super(elevation: 8.0); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final TextTheme _textTheme = _theme.textTheme; + + @override + TextStyle? get textStyle => _textTheme.titleMedium; + + static EdgeInsets menuHorizontalPadding = + const EdgeInsets.symmetric(horizontal: 16.0); +} + +// BEGIN GENERATED TOKEN PROPERTIES - PopupMenu + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +class _PopupMenuDefaultsM3 extends PopupMenuThemeData { + _PopupMenuDefaultsM3(this.context) : super(elevation: 3.0); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + late final TextTheme _textTheme = _theme.textTheme; + + @override + WidgetStateProperty? get labelTextStyle { + return WidgetStateProperty.resolveWith((Set states) { + final TextStyle style = _textTheme.labelLarge!; + if (states.contains(WidgetState.disabled)) { + return style.apply(color: _colors.onSurface.withOpacity(0.38)); + } + return style.apply(color: _colors.onSurface); + }); + } + + @override + Color? get color => _colors.surfaceContainer; + + @override + Color? get shadowColor => _colors.shadow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + ShapeBorder? get shape => const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4.0)), + ); + + // TODO(tahatesser): This is taken from https://m3.material.io/components/menus/specs + // Update this when the token is available. + static EdgeInsets menuHorizontalPadding = + const EdgeInsets.symmetric(horizontal: 12.0); +} +// END GENERATED TOKEN PROPERTIES - PopupMenu + +extension PopupMenuColors on BuildContext { + Color get popupMenuBackgroundColor { + if (Theme.of(this).brightness == Brightness.light) { + return Theme.of(this).colorScheme.surface; + } + return const Color(0xFF23262B); + } +} + +class _CurveTween extends Animatable { + /// Creates a curve tween. + _CurveTween({required this.curve}); + + /// The curve to use when transforming the value of the animation. + Curve curve; + + @override + double transform(double t) { + return curve.transform(t.clamp(0, 1)); + } + + @override + String toString() => + '${objectRuntimeType(this, 'CurveTween')}(curve: $curve)'; +} diff --git a/frontend/appflowy_flutter/lib/shared/red_dot.dart b/frontend/appflowy_flutter/lib/shared/red_dot.dart new file mode 100644 index 0000000000000..149cadae04ec6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/red_dot.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; + +class NotificationRedDot extends StatelessWidget { + const NotificationRedDot({ + super.key, + this.size = 6, + }); + + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: const Color(0xFFFF2214), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/settings/show_settings.dart b/frontend/appflowy_flutter/lib/shared/settings/show_settings.dart new file mode 100644 index 0000000000000..81831346e9f23 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/settings/show_settings.dart @@ -0,0 +1,67 @@ +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; +import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +final GlobalKey _settingsDialogKey = GlobalKey(); + +// show settings dialog with user profile for fully customized settings dialog +void showSettingsDialog( + BuildContext context, + UserProfilePB userProfile, [ + UserWorkspaceBloc? bloc, + SettingsPage? initPage, +]) { + AFFocusManager.of(context).notifyLoseFocus(); + showDialog( + context: context, + builder: (dialogContext) => MultiBlocProvider( + key: _settingsDialogKey, + providers: [ + BlocProvider.value( + value: BlocProvider.of(dialogContext), + ), + BlocProvider.value(value: bloc ?? context.read()), + ], + child: SettingsDialog( + userProfile, + initPage: initPage, + didLogout: () async { + // Pop the dialog using the dialog context + Navigator.of(dialogContext).pop(); + await runAppFlowy(); + }, + dismissDialog: () { + if (Navigator.of(dialogContext).canPop()) { + return Navigator.of(dialogContext).pop(); + } + Log.warn("Can't pop dialog context"); + }, + restartApp: () async { + // Pop the dialog using the dialog context + Navigator.of(dialogContext).pop(); + await runAppFlowy(); + }, + ), + ), + ); +} + +// show settings dialog without user profile for simple settings dialog +// only support +// - language +// - self-host +// - support +void showSimpleSettingsDialog(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) => const SimpleSettingsDialog(), + ); +} diff --git a/frontend/appflowy_flutter/lib/shared/text_field/text_filed_with_metric_lines.dart b/frontend/appflowy_flutter/lib/shared/text_field/text_filed_with_metric_lines.dart new file mode 100644 index 0000000000000..b8db272a4bc84 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/text_field/text_filed_with_metric_lines.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +class TextFieldWithMetricLines extends StatefulWidget { + const TextFieldWithMetricLines({ + super.key, + this.controller, + this.focusNode, + this.maxLines, + this.style, + this.decoration, + this.onLineCountChange, + this.enabled = true, + }); + + final TextEditingController? controller; + final FocusNode? focusNode; + final int? maxLines; + final TextStyle? style; + final InputDecoration? decoration; + final void Function(int count)? onLineCountChange; + final bool enabled; + + @override + State createState() => + _TextFieldWithMetricLinesState(); +} + +class _TextFieldWithMetricLinesState extends State { + final key = GlobalKey(); + late final controller = widget.controller ?? TextEditingController(); + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + updateDisplayedLineCount(context); + }); + } + + @override + void dispose() { + if (widget.controller == null) { + // dispose the controller if it was created by this widget + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField( + key: key, + enabled: widget.enabled, + controller: widget.controller, + focusNode: widget.focusNode, + maxLines: widget.maxLines, + style: widget.style, + decoration: widget.decoration, + onChanged: (_) => updateDisplayedLineCount(context), + ); + } + + // calculate the number of lines that would be displayed in the text field + void updateDisplayedLineCount(BuildContext context) { + if (widget.onLineCountChange == null) { + return; + } + + final renderObject = key.currentContext?.findRenderObject(); + if (renderObject == null || renderObject is! RenderBox) { + return; + } + + final size = renderObject.size; + final text = controller.buildTextSpan( + context: context, + style: widget.style, + withComposing: false, + ); + final textPainter = TextPainter( + text: text, + textDirection: Directionality.of(context), + ); + + textPainter.layout(minWidth: size.width, maxWidth: size.width); + + final lines = textPainter.computeLineMetrics().length; + widget.onLineCountChange?.call(lines); + } +} diff --git a/frontend/appflowy_flutter/lib/shared/time_format.dart b/frontend/appflowy_flutter/lib/shared/time_format.dart new file mode 100644 index 0000000000000..ba9ce0fabd323 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/time_format.dart @@ -0,0 +1,42 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import 'package:time/time.dart'; + +String formatTimestampWithContext( + BuildContext context, { + required int timestamp, + String? prefix, +}) { + final now = DateTime.now(); + final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp); + final difference = now.difference(dateTime); + final String date; + + final dateFormat = context.read().state.dateFormat; + final timeFormat = context.read().state.timeFormat; + + if (difference.inMinutes < 1) { + date = LocaleKeys.sideBar_justNow.tr(); + } else if (difference.inHours < 1 && dateTime.isToday) { + // Less than 1 hour + date = LocaleKeys.sideBar_minutesAgo + .tr(namedArgs: {'count': difference.inMinutes.toString()}); + } else if (difference.inHours >= 1 && dateTime.isToday) { + // in same day + date = timeFormat.formatTime(dateTime); + } else { + date = dateFormat.formatDate(dateTime, false); + } + + if (difference.inHours >= 1 && prefix != null) { + return '$prefix $date'; + } + + return date; +} diff --git a/frontend/appflowy_flutter/lib/shared/window_title_bar.dart b/frontend/appflowy_flutter/lib/shared/window_title_bar.dart new file mode 100644 index 0000000000000..4738be78f367c --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/window_title_bar.dart @@ -0,0 +1,109 @@ +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; +import 'package:window_manager/window_manager.dart'; + +class WindowsButtonListener extends WindowListener { + WindowsButtonListener(); + + final ValueNotifier isMaximized = ValueNotifier(false); + + @override + void onWindowMaximize() => isMaximized.value = true; + + @override + void onWindowUnmaximize() => isMaximized.value = false; + + void dispose() => isMaximized.dispose(); +} + +class WindowTitleBar extends StatefulWidget { + const WindowTitleBar({ + super.key, + this.leftChildren = const [], + }); + + final List leftChildren; + + @override + State createState() => _WindowTitleBarState(); +} + +class _WindowTitleBarState extends State { + late final WindowsButtonListener? windowsButtonListener; + bool isMaximized = false; + + @override + void initState() { + super.initState(); + + if (UniversalPlatform.isWindows || UniversalPlatform.isLinux) { + windowsButtonListener = WindowsButtonListener(); + windowManager.addListener(windowsButtonListener!); + windowsButtonListener!.isMaximized.addListener(_isMaximizedChanged); + } else { + windowsButtonListener = null; + } + + windowManager + .isMaximized() + .then((v) => mounted ? setState(() => isMaximized = v) : null); + } + + void _isMaximizedChanged() { + if (mounted) { + setState(() => isMaximized = windowsButtonListener!.isMaximized.value); + } + } + + @override + void dispose() { + if (windowsButtonListener != null) { + windowManager.removeListener(windowsButtonListener!); + windowsButtonListener!.isMaximized.removeListener(_isMaximizedChanged); + windowsButtonListener?.dispose(); + } + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + + return Container( + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + child: DragToMoveArea( + child: Row( + children: [ + const HSpace(4), + ...widget.leftChildren, + const Spacer(), + WindowCaptionButton.minimize( + brightness: brightness, + onPressed: () => windowManager.minimize(), + ), + if (isMaximized) ...[ + WindowCaptionButton.unmaximize( + brightness: brightness, + onPressed: () => windowManager.unmaximize(), + ), + ] else ...[ + WindowCaptionButton.maximize( + brightness: brightness, + onPressed: () => windowManager.maximize(), + ), + ], + WindowCaptionButton.close( + brightness: brightness, + onPressed: () => windowManager.close(), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart new file mode 100644 index 0000000000000..71f8935ee24b3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -0,0 +1,202 @@ +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/network_monitor.dart'; +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart'; +import 'package:appflowy/plugins/trash/application/prelude.dart'; +import 'package:appflowy/shared/appflowy_cache_manager.dart'; +import 'package:appflowy/shared/custom_image_cache_manager.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/appflowy_cloud_task.dart'; +import 'package:appflowy/user/application/ai_service.dart'; +import 'package:appflowy/user/application/auth/af_cloud_auth_service.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/prelude.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/user_listener.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/user/presentation/router.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:appflowy/workspace/application/settings/appearance/desktop_appearance.dart'; +import 'package:appflowy/workspace/application/settings/appearance/mobile_appearance.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart'; +import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/user/prelude.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/workspace/prelude.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra/file_picker/file_picker_impl.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:get_it/get_it.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class DependencyResolver { + static Future resolve( + GetIt getIt, + IntegrationMode mode, + ) async { + // getIt.registerFactory(() => RustKeyValue()); + getIt.registerFactory(() => DartKeyValue()); + + await _resolveCloudDeps(getIt); + _resolveUserDeps(getIt, mode); + _resolveHomeDeps(getIt); + _resolveFolderDeps(getIt); + _resolveCommonService(getIt, mode); + } +} + +Future _resolveCloudDeps(GetIt getIt) async { + final env = await AppFlowyCloudSharedEnv.fromEnv(); + Log.info("cloud setting: $env"); + getIt.registerFactory(() => env); + + if (isAppFlowyCloudEnabled) { + getIt.registerSingleton( + AppFlowyCloudDeepLink(), + dispose: (obj) async { + await obj.dispose(); + }, + ); + } +} + +void _resolveCommonService( + GetIt getIt, + IntegrationMode mode, +) async { + getIt.registerFactory(() => FilePicker()); + + getIt.registerFactory( + () => mode.isTest ? MockApplicationDataStorage() : ApplicationDataStorage(), + ); + + getIt.registerFactoryAsync( + () async { + final result = await UserBackendService.getCurrentUserProfile(); + return result.fold( + (s) { + return AppFlowyAIService(); + }, + (e) { + throw Exception('Failed to get user profile: ${e.msg}'); + }, + ); + }, + ); + + getIt.registerFactory( + () => ClipboardService(), + ); + + // theme + getIt.registerFactory( + () => UniversalPlatform.isMobile ? MobileAppearance() : DesktopAppearance(), + ); + + getIt.registerFactory( + () => FlowyCacheManager() + ..registerCache(TemporaryDirectoryCache()) + ..registerCache(CustomImageCacheManager()) + ..registerCache(FeatureFlagCache()), + ); +} + +void _resolveUserDeps(GetIt getIt, IntegrationMode mode) { + switch (currentCloudType()) { + case AuthenticatorType.local: + getIt.registerFactory( + () => BackendAuthService( + AuthenticatorPB.Local, + ), + ); + break; + case AuthenticatorType.appflowyCloud: + case AuthenticatorType.appflowyCloudSelfHost: + case AuthenticatorType.appflowyCloudDevelop: + getIt.registerFactory(() => AppFlowyCloudAuthService()); + break; + } + + getIt.registerFactory(() => AuthRouter()); + + getIt.registerFactory( + () => SignInBloc(getIt()), + ); + getIt.registerFactory( + () => SignUpBloc(getIt()), + ); + + getIt.registerFactory(() => SplashRouter()); + getIt.registerFactory(() => EditPanelBloc()); + getIt.registerFactory(() => SplashBloc()); + getIt.registerLazySingleton(() => NetworkListener()); + getIt.registerLazySingleton(() => CachedRecentService()); + getIt.registerLazySingleton( + () => SubscriptionSuccessListenable(), + ); +} + +void _resolveHomeDeps(GetIt getIt) { + getIt.registerSingleton(FToast()); + + getIt.registerSingleton(MenuSharedState()); + + getIt.registerFactoryParam( + (user, _) => UserListener(userProfile: user), + ); + + // share + getIt.registerFactoryParam( + (view, _) => ShareBloc(view: view), + ); + + getIt.registerSingleton(ActionNavigationBloc()); + + getIt.registerLazySingleton(() => TabsBloc()); + + getIt.registerSingleton(ReminderBloc()); + + getIt.registerSingleton(RenameViewBloc(PopoverController())); +} + +void _resolveFolderDeps(GetIt getIt) { + // Workspace + getIt.registerFactoryParam( + (user, workspaceId) => + WorkspaceListener(user: user, workspaceId: workspaceId), + ); + + getIt.registerFactoryParam( + (view, _) => ViewBloc( + view: view, + ), + ); + + // User + getIt.registerFactoryParam( + (user, _) => SettingsUserViewBloc(user), + ); + + // Trash + getIt.registerLazySingleton(() => TrashService()); + getIt.registerLazySingleton(() => TrashListener()); + getIt.registerFactory( + () => TrashBloc(), + ); + + // Favorite + getIt.registerFactory(() => FavoriteBloc()); +} diff --git a/frontend/appflowy_flutter/lib/startup/entry_point.dart b/frontend/appflowy_flutter/lib/startup/entry_point.dart new file mode 100644 index 0000000000000..33eb1bf98296f --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/entry_point.dart @@ -0,0 +1,11 @@ +import 'package:appflowy/startup/launch_configuration.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/presentation/screens/splash_screen.dart'; +import 'package:flutter/material.dart'; + +class AppFlowyApplication implements EntryPoint { + @override + Widget create(LaunchConfiguration config) { + return SplashScreen(isAnon: config.isAnon); + } +} diff --git a/frontend/appflowy_flutter/lib/startup/launch_configuration.dart b/frontend/appflowy_flutter/lib/startup/launch_configuration.dart new file mode 100644 index 0000000000000..da1a64db3e253 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/launch_configuration.dart @@ -0,0 +1,13 @@ +class LaunchConfiguration { + const LaunchConfiguration({ + this.isAnon = false, + required this.version, + required this.rustEnvs, + }); + + // APP will automatically register after launching. + final bool isAnon; + final String version; + // + final Map rustEnvs; +} diff --git a/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart b/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart new file mode 100644 index 0000000000000..920a994927baf --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/plugin/plugin.dart @@ -0,0 +1,118 @@ +library flowy_plugin; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flutter/widgets.dart'; + +export "./src/sandbox.dart"; + +enum PluginType { + document, + blank, + trash, + grid, + board, + calendar, + databaseDocument, + chat, +} + +typedef PluginId = String; + +abstract class Plugin { + PluginId get id; + + PluginWidgetBuilder get widgetBuilder; + + PluginNotifier? get notifier => null; + + PluginType get pluginType; + + void init() {} + + void dispose() { + notifier?.dispose(); + } +} + +abstract class PluginNotifier { + /// Notify if the plugin get deleted + ValueNotifier get isDeleted; + + void dispose() {} +} + +abstract class PluginBuilder { + Plugin build(dynamic data); + + String get menuName; + + FlowySvgData get icon; + + /// The type of this [Plugin]. Each [Plugin] should have a unique [PluginType] + PluginType get pluginType; + + /// The layoutType is used in the backend to determine the layout of the view. + /// Currently, AppFlowy supports 4 layout types: Document, Grid, Board, Calendar. + ViewLayoutPB? get layoutType; +} + +abstract class PluginConfig { + // Return false will disable the user to create it. For example, a trash plugin shouldn't be created by the user, + bool get creatable => true; +} + +abstract class PluginWidgetBuilder with NavigationItem { + List get navigationItems; + + EdgeInsets get contentPadding => + const EdgeInsets.symmetric(horizontal: 40, vertical: 28); + + Widget buildWidget({ + required PluginContext context, + required bool shrinkWrap, + Map? data, + }); +} + +class PluginContext { + PluginContext({ + this.userProfile, + this.onDeleted, + }); + + // calls when widget of the plugin get deleted + final Function(ViewPB, int?)? onDeleted; + final UserProfilePB? userProfile; +} + +void registerPlugin({required PluginBuilder builder, PluginConfig? config}) { + getIt() + .registerPlugin(builder.pluginType, builder, config: config); +} + +/// Make the correct plugin from the [pluginType] and [data]. If the plugin +/// is not registered, it will return a blank plugin. +Plugin makePlugin({required PluginType pluginType, dynamic data}) { + final plugin = getIt().buildPlugin(pluginType, data); + return plugin; +} + +List pluginBuilders() { + final pluginBuilders = getIt().builders; + final pluginConfigs = getIt().pluginConfigs; + return pluginBuilders.where( + (builder) { + final config = pluginConfigs[builder.pluginType]?.creatable; + return config ?? true; + }, + ).toList(); +} + +enum FlowyPluginException { + invalidData, +} diff --git a/frontend/app_flowy/lib/startup/plugin/src/runner.dart b/frontend/appflowy_flutter/lib/startup/plugin/src/runner.dart similarity index 100% rename from frontend/app_flowy/lib/startup/plugin/src/runner.dart rename to frontend/appflowy_flutter/lib/startup/plugin/src/runner.dart diff --git a/frontend/appflowy_flutter/lib/startup/plugin/src/sandbox.dart b/frontend/appflowy_flutter/lib/startup/plugin/src/sandbox.dart new file mode 100644 index 0000000000000..efcc5f97070f9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/plugin/src/sandbox.dart @@ -0,0 +1,59 @@ +import 'dart:collection'; + +import 'package:appflowy/plugins/blank/blank.dart'; +import 'package:flutter/services.dart'; + +import '../plugin.dart'; +import 'runner.dart'; + +class PluginSandbox { + PluginSandbox() { + pluginRunner = PluginRunner(); + } + + final LinkedHashMap _pluginBuilders = + LinkedHashMap(); + final Map _pluginConfigs = + {}; + late PluginRunner pluginRunner; + + int indexOf(PluginType pluginType) { + final index = + _pluginBuilders.keys.toList().indexWhere((ty) => ty == pluginType); + if (index == -1) { + throw PlatformException( + code: '-1', + message: "Can't find the flowy plugin type: $pluginType", + ); + } + return index; + } + + /// Build a plugin from [data] with [pluginType] + /// If the [pluginType] is not registered, it will return a blank plugin + Plugin buildPlugin(PluginType pluginType, dynamic data) { + final builder = _pluginBuilders[pluginType] ?? BlankPluginBuilder(); + return builder.build(data); + } + + void registerPlugin( + PluginType pluginType, + PluginBuilder builder, { + PluginConfig? config, + }) { + if (_pluginBuilders.containsKey(pluginType)) { + return; + } + _pluginBuilders[pluginType] = builder; + + if (config != null) { + _pluginConfigs[pluginType] = config; + } + } + + List get supportPluginTypes => _pluginBuilders.keys.toList(); + + List get builders => _pluginBuilders.values.toList(); + + Map get pluginConfigs => _pluginConfigs; +} diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart new file mode 100644 index 0000000000000..ac0dfbf88a18d --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -0,0 +1,277 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/startup/tasks/feature_flag_task.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy_backend/appflowy_backend.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'deps_resolver.dart'; +import 'entry_point.dart'; +import 'launch_configuration.dart'; +import 'plugin/plugin.dart'; +import 'tasks/file_storage_task.dart'; +import 'tasks/prelude.dart'; + +final getIt = GetIt.instance; + +abstract class EntryPoint { + Widget create(LaunchConfiguration config); +} + +class FlowyRunnerContext { + FlowyRunnerContext({required this.applicationDataDirectory}); + + final Directory applicationDataDirectory; +} + +Future runAppFlowy({bool isAnon = false}) async { + Log.info('restart AppFlowy: isAnon: $isAnon'); + + if (kReleaseMode) { + await FlowyRunner.run( + AppFlowyApplication(), + integrationMode(), + isAnon: isAnon, + ); + } else { + // When running the app in integration test mode, we need to + // specify the mode to run the app again. + await FlowyRunner.run( + AppFlowyApplication(), + FlowyRunner.currentMode, + didInitGetItCallback: IntegrationTestHelper.didInitGetItCallback, + rustEnvsBuilder: IntegrationTestHelper.rustEnvsBuilder, + isAnon: isAnon, + ); + } +} + +class FlowyRunner { + // This variable specifies the initial mode of the app when it is launched for the first time. + // The same mode will be automatically applied in subsequent executions when the runAppFlowy() + // method is called. + static var currentMode = integrationMode(); + + static Future run( + EntryPoint f, + IntegrationMode mode, { + // This callback is triggered after the initialization of 'getIt', + // which is used for dependency injection throughout the app. + // If your functionality depends on 'getIt', ensure to register + // your callback here to execute any necessary actions post-initialization. + Future Function()? didInitGetItCallback, + // Passing the envs to the backend + Map Function()? rustEnvsBuilder, + // Indicate whether the app is running in anonymous mode. + // Note: when the app is running in anonymous mode, the user no need to + // sign in, and the app will only save the data in the local storage. + bool isAnon = false, + }) async { + currentMode = mode; + + // Only set the mode when it's not release mode + if (!kReleaseMode) { + IntegrationTestHelper.didInitGetItCallback = didInitGetItCallback; + IntegrationTestHelper.rustEnvsBuilder = rustEnvsBuilder; + } + + // Clear and dispose tasks from previous AppLaunch + if (getIt.isRegistered(instance: AppLauncher)) { + await getIt().dispose(); + } + + // Clear all the states in case of rebuilding. + await getIt.reset(); + + final config = LaunchConfiguration( + isAnon: isAnon, + // Unit test can't use the package_info_plus plugin + version: mode.isUnitTest + ? '1.0.0' + : await PackageInfo.fromPlatform().then((value) => value.version), + rustEnvs: rustEnvsBuilder?.call() ?? {}, + ); + + // Specify the env + await initGetIt(getIt, mode, f, config); + await didInitGetItCallback?.call(); + + final applicationDataDirectory = + await getIt().getPath().then( + (value) => Directory(value), + ); + + // add task + final launcher = getIt(); + launcher.addTasks( + [ + // this task should be first task, for handling platform errors. + // don't catch errors in test mode + if (!mode.isUnitTest && !mode.isIntegrationTest) + const PlatformErrorCatcherTask(), + if (!mode.isUnitTest) const InitSentryTask(), + // this task should be second task, for handling memory leak. + // there's a flag named _enable in memory_leak_detector.dart. If it's false, the task will be ignored. + MemoryLeakDetectorTask(), + const DebugTask(), + const FeatureFlagTask(), + + // localization + const InitLocalizationTask(), + // init the app window + InitAppWindowTask(), + // Init Rust SDK + InitRustSDKTask(customApplicationPath: applicationDataDirectory), + // Load Plugins, like document, grid ... + const PluginLoadTask(), + const FileStorageTask(), + + // init the app widget + // ignore in test mode + if (!mode.isUnitTest) ...[ + // The DeviceOrApplicationInfoTask should be placed before the AppWidgetTask to fetch the app information. + // It is unable to get the device information from the test environment. + const ApplicationInfoTask(), + const HotKeyTask(), + if (isAppFlowyCloudEnabled) InitAppFlowyCloudTask(), + const InitAppWidgetTask(), + const InitPlatformServiceTask(), + const RecentServiceTask(), + ], + ], + ); + await launcher.launch(); // execute the tasks + + return FlowyRunnerContext( + applicationDataDirectory: applicationDataDirectory, + ); + } +} + +Future initGetIt( + GetIt getIt, + IntegrationMode mode, + EntryPoint f, + LaunchConfiguration config, +) async { + getIt.registerFactory(() => f); + getIt.registerLazySingleton( + () { + return FlowySDK(); + }, + dispose: (sdk) async { + await sdk.dispose(); + }, + ); + getIt.registerLazySingleton( + () => AppLauncher( + context: LaunchContext( + getIt, + mode, + config, + ), + ), + dispose: (launcher) async { + await launcher.dispose(); + }, + ); + getIt.registerSingleton(PluginSandbox()); + + await DependencyResolver.resolve(getIt, mode); +} + +class LaunchContext { + LaunchContext(this.getIt, this.env, this.config); + + GetIt getIt; + IntegrationMode env; + LaunchConfiguration config; +} + +enum LaunchTaskType { + dataProcessing, + appLauncher, +} + +/// The interface of an app launch task, which will trigger +/// some nonresident indispensable task in app launching task. +abstract class LaunchTask { + const LaunchTask(); + + LaunchTaskType get type => LaunchTaskType.dataProcessing; + + Future initialize(LaunchContext context); + Future dispose(); +} + +class AppLauncher { + AppLauncher({ + required this.context, + }); + + final LaunchContext context; + final List tasks = []; + + void addTask(LaunchTask task) { + tasks.add(task); + } + + void addTasks(Iterable tasks) { + this.tasks.addAll(tasks); + } + + Future launch() async { + for (final task in tasks) { + await task.initialize(context); + } + } + + Future dispose() async { + for (final task in tasks) { + await task.dispose(); + } + tasks.clear(); + } +} + +enum IntegrationMode { + develop, + release, + unitTest, + integrationTest; + + // test mode + bool get isTest => isUnitTest || isIntegrationTest; + bool get isUnitTest => this == IntegrationMode.unitTest; + bool get isIntegrationTest => this == IntegrationMode.integrationTest; + + // release mode + bool get isRelease => this == IntegrationMode.release; + + // develop mode + bool get isDevelop => this == IntegrationMode.develop; +} + +IntegrationMode integrationMode() { + if (Platform.environment.containsKey('FLUTTER_TEST')) { + return IntegrationMode.unitTest; + } + + if (kReleaseMode) { + return IntegrationMode.release; + } + + return IntegrationMode.develop; +} + +/// Only used for integration test +class IntegrationTestHelper { + static Future Function()? didInitGetItCallback; + static Map Function()? rustEnvsBuilder; +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart new file mode 100644 index 0000000000000..a398db3061365 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_widget.dart @@ -0,0 +1,304 @@ +import 'dart:io'; + +import 'package:appflowy/mobile/application/mobile_router.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/shared/clipboard_state.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/user_settings_service.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; +import 'package:appflowy/workspace/application/notification/notification_service.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart'; +import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:toastification/toastification.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'prelude.dart'; + +class InitAppWidgetTask extends LaunchTask { + const InitAppWidgetTask(); + + @override + LaunchTaskType get type => LaunchTaskType.appLauncher; + + @override + Future initialize(LaunchContext context) async { + WidgetsFlutterBinding.ensureInitialized(); + + await NotificationService.initialize(); + + await loadIconGroups(); + + final widget = context.getIt().create(context.config); + final appearanceSetting = + await UserSettingsBackendService().getAppearanceSetting(); + final dateTimeSettings = + await UserSettingsBackendService().getDateTimeSettings(); + + // If the passed-in context is not the same as the context of the + // application widget, the application widget will be rebuilt. + final app = ApplicationWidget( + key: ValueKey(context), + appearanceSetting: appearanceSetting, + dateTimeSettings: dateTimeSettings, + appTheme: await appTheme(appearanceSetting.theme), + child: widget, + ); + + Bloc.observer = ApplicationBlocObserver(); + runApp( + EasyLocalization( + supportedLocales: const [ + // In alphabetical order + Locale('am', 'ET'), + Locale('ar', 'SA'), + Locale('ca', 'ES'), + Locale('cs', 'CZ'), + Locale('ckb', 'KU'), + Locale('de', 'DE'), + Locale('en'), + Locale('es', 'VE'), + Locale('eu', 'ES'), + Locale('el', 'GR'), + Locale('fr', 'FR'), + Locale('fr', 'CA'), + Locale('he'), + Locale('hu', 'HU'), + Locale('id', 'ID'), + Locale('it', 'IT'), + Locale('ja', 'JP'), + Locale('ko', 'KR'), + Locale('pl', 'PL'), + Locale('pt', 'BR'), + Locale('ru', 'RU'), + Locale('sv', 'SE'), + Locale('th', 'TH'), + Locale('tr', 'TR'), + Locale('uk', 'UA'), + Locale('ur'), + Locale('vi', 'VN'), + Locale('zh', 'CN'), + Locale('zh', 'TW'), + Locale('fa'), + Locale('hin'), + ], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + useFallbackTranslations: true, + child: app, + ), + ); + + return; + } + + @override + Future dispose() async {} +} + +class ApplicationWidget extends StatefulWidget { + const ApplicationWidget({ + super.key, + required this.child, + required this.appTheme, + required this.appearanceSetting, + required this.dateTimeSettings, + }); + + final Widget child; + final AppTheme appTheme; + final AppearanceSettingsPB appearanceSetting; + final DateTimeSettingsPB dateTimeSettings; + + @override + State createState() => _ApplicationWidgetState(); +} + +class _ApplicationWidgetState extends State { + late final GoRouter routerConfig; + + final _commandPaletteNotifier = ValueNotifier(false); + + @override + void initState() { + super.initState(); + // Avoid rebuild routerConfig when the appTheme is changed. + routerConfig = generateRouter(widget.child); + } + + @override + void dispose() { + _commandPaletteNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + if (FeatureFlag.search.isOn) + BlocProvider(create: (_) => CommandPaletteBloc()), + BlocProvider( + create: (_) => AppearanceSettingsCubit( + widget.appearanceSetting, + widget.dateTimeSettings, + widget.appTheme, + )..readLocaleWhenAppLaunch(context), + ), + BlocProvider( + create: (_) => NotificationSettingsCubit(), + ), + BlocProvider( + create: (_) => DocumentAppearanceCubit()..fetch(), + ), + BlocProvider.value(value: getIt()), + BlocProvider.value(value: getIt()), + ], + child: BlocListener( + listenWhen: (_, curr) => curr.action != null, + listener: (context, state) { + final action = state.action; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (action?.type == ActionType.openView && + UniversalPlatform.isDesktop) { + final view = + action!.arguments?[ActionArgumentKeys.view] as ViewPB?; + final nodePath = action.arguments?[ActionArgumentKeys.nodePath]; + final blockId = action.arguments?[ActionArgumentKeys.blockId]; + if (view != null) { + getIt().openPlugin( + view, + arguments: { + PluginArgumentKeys.selection: nodePath, + PluginArgumentKeys.blockId: blockId, + }, + ); + } + } else if (action?.type == ActionType.openRow && + UniversalPlatform.isMobile) { + final view = action!.arguments?[ActionArgumentKeys.view]; + if (view != null) { + final view = action.arguments?[ActionArgumentKeys.view]; + final rowId = action.arguments?[ActionArgumentKeys.rowId]; + AppGlobals.rootNavKey.currentContext?.pushView( + view, + arguments: { + PluginArgumentKeys.rowId: rowId, + }, + ); + } + } + }); + }, + child: BlocBuilder( + builder: (context, state) { + _setSystemOverlayStyle(state); + return Provider( + create: (_) => ClipboardState(), + dispose: (_, state) => state.dispose(), + child: ToastificationWrapper( + child: Listener( + onPointerSignal: (pointerSignal) { + /// This is a workaround to deal with below question: + /// When the mouse hovers over the tooltip, the scroll event is intercepted by it + /// Here, we listen for the scroll event and then remove the tooltip to avoid that situation + if (pointerSignal is PointerScrollEvent) { + Tooltip.dismissAllToolTips(); + } + }, + child: MaterialApp.router( + builder: (context, child) => MediaQuery( + // use the 1.0 as the textScaleFactor to avoid the text size + // affected by the system setting. + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(state.textScaleFactor), + ), + child: overlayManagerBuilder( + context, + !UniversalPlatform.isMobile && FeatureFlag.search.isOn + ? CommandPalette( + notifier: _commandPaletteNotifier, + child: child, + ) + : child, + ), + ), + debugShowCheckedModeBanner: false, + theme: state.lightTheme, + darkTheme: state.darkTheme, + themeMode: state.themeMode, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: state.locale, + routerConfig: routerConfig, + ), + ), + ), + ); + }, + ), + ), + ); + } + + void _setSystemOverlayStyle(AppearanceSettingsState state) { + if (Platform.isAndroid) { + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.edgeToEdge, + overlays: [], + ); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + systemNavigationBarColor: Colors.transparent, + ), + ); + } + } +} + +class AppGlobals { + static GlobalKey rootNavKey = GlobalKey(); + + static NavigatorState get nav => rootNavKey.currentState!; + + static BuildContext get context => rootNavKey.currentContext!; +} + +class ApplicationBlocObserver extends BlocObserver { + @override + void onError(BlocBase bloc, Object error, StackTrace stackTrace) { + Log.debug(error); + super.onError(bloc, error, stackTrace); + } +} + +Future appTheme(String themeName) async { + if (themeName.isEmpty) { + return AppTheme.fallback; + } else { + try { + return await AppTheme.fromName(themeName); + } catch (e) { + return AppTheme.fallback; + } + } +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart new file mode 100644 index 0000000000000..7d41f2dcebcc6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/startup/startup.dart'; + +class WindowSizeManager { + static const double minWindowHeight = 640.0; + static const double minWindowWidth = 640.0; + // Preventing failed assertion due to Texture Descriptor Validation + static const double maxWindowHeight = 8192.0; + static const double maxWindowWidth = 8192.0; + + static const double maxScaleFactor = 2.0; + static const double minScaleFactor = 0.5; + + static const width = 'width'; + static const height = 'height'; + + static const String dx = 'dx'; + static const String dy = 'dy'; + + Future setSize(Size size) async { + final windowSize = { + height: size.height.clamp(minWindowHeight, maxWindowHeight), + width: size.width.clamp(minWindowWidth, maxWindowWidth), + }; + + await getIt().set( + KVKeys.windowSize, + jsonEncode(windowSize), + ); + } + + Future getSize() async { + final defaultWindowSize = jsonEncode( + { + WindowSizeManager.height: minWindowHeight, + WindowSizeManager.width: minWindowWidth, + }, + ); + final windowSize = await getIt().get(KVKeys.windowSize); + final size = json.decode( + windowSize ?? defaultWindowSize, + ); + final double width = size[WindowSizeManager.width] ?? minWindowWidth; + final double height = size[WindowSizeManager.height] ?? minWindowHeight; + return Size( + width.clamp(minWindowWidth, maxWindowWidth), + height.clamp(minWindowHeight, maxWindowHeight), + ); + } + + Future setPosition(Offset offset) async { + await getIt().set( + KVKeys.windowPosition, + jsonEncode({ + dx: offset.dx, + dy: offset.dy, + }), + ); + } + + Future getPosition() async { + final position = await getIt().get(KVKeys.windowPosition); + if (position == null) { + return null; + } + final offset = json.decode(position); + return Offset(offset[dx], offset[dy]); + } + + Future getScaleFactor() async { + final scaleFactor = await getIt().getWithFormat( + KVKeys.scaleFactor, + (value) => double.tryParse(value) ?? 1.0, + ) ?? + 1.0; + return scaleFactor.clamp(minScaleFactor, maxScaleFactor); + } + + Future setScaleFactor(double scaleFactor) async { + await getIt().set( + KVKeys.scaleFactor, + '${scaleFactor.clamp(minScaleFactor, maxScaleFactor)}', + ); + } + + /// Set the window maximized status + Future setWindowMaximized(bool isMaximized) async { + await getIt() + .set(KVKeys.windowMaximized, isMaximized.toString()); + } + + /// Get the window maximized status + Future getWindowMaximized() async { + return await getIt().getWithFormat( + KVKeys.windowMaximized, + (v) => bool.tryParse(v) ?? false, + ) ?? + false; + } +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart new file mode 100644 index 0000000000000..2c22b8a01ee58 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/appflowy_cloud_task.dart @@ -0,0 +1,255 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:app_links/app_links.dart'; +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/app_widget.dart'; +import 'package:appflowy/user/application/auth/auth_error.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/auth/device_id.dart'; +import 'package:appflowy/user/application/user_auth_listener.dart'; +import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/material.dart'; +import 'package:url_protocol/url_protocol.dart'; + +const appflowyDeepLinkSchema = 'appflowy-flutter'; + +class AppFlowyCloudDeepLink { + AppFlowyCloudDeepLink() { + _deepLinkSubscription = _AppLinkWrapper.instance.listen( + (Uri? uri) async { + Log.info('onDeepLink: ${uri.toString()}'); + await _handleUri(uri); + }, + onError: (Object err, StackTrace stackTrace) { + Log.error('on DeepLink stream error: ${err.toString()}', stackTrace); + _deepLinkSubscription.cancel(); + }, + ); + if (Platform.isWindows) { + // register deep link for Windows + registerProtocolHandler(appflowyDeepLinkSchema); + } + } + + ValueNotifier? _stateNotifier = ValueNotifier(null); + + Completer>? _completer; + + set completer(Completer>? value) { + Log.debug('AppFlowyCloudDeepLink: $hashCode completer'); + _completer = value; + } + + late final StreamSubscription _deepLinkSubscription; + + Future dispose() async { + Log.debug('AppFlowyCloudDeepLink: $hashCode dispose'); + await _deepLinkSubscription.cancel(); + + _stateNotifier?.dispose(); + _stateNotifier = null; + completer = null; + } + + void registerCompleter( + Completer> completer, + ) { + this.completer = completer; + } + + VoidCallback subscribeDeepLinkLoadingState( + ValueChanged listener, + ) { + void listenerFn() { + if (_stateNotifier?.value != null) { + listener(_stateNotifier!.value!); + } + } + + _stateNotifier?.addListener(listenerFn); + return listenerFn; + } + + void unsubscribeDeepLinkLoadingState(VoidCallback listener) => + _stateNotifier?.removeListener(listener); + + Future _handleUri( + Uri? uri, + ) async { + _stateNotifier?.value = DeepLinkResult(state: DeepLinkState.none); + + if (uri == null) { + Log.error('onDeepLinkError: Unexpected empty deep link callback'); + _completer?.complete(FlowyResult.failure(AuthError.emptyDeepLink)); + completer = null; + return; + } + + if (_isPaymentSuccessUri(uri)) { + Log.debug("Payment success deep link: ${uri.toString()}"); + final plan = uri.queryParameters['plan']; + return getIt().onPaymentSuccess(plan); + } + + return _isAuthCallbackDeepLink(uri).fold( + (_) async { + final deviceId = await getDeviceId(); + final payload = OauthSignInPB( + authenticator: AuthenticatorPB.AppFlowyCloud, + map: { + AuthServiceMapKeys.signInURL: uri.toString(), + AuthServiceMapKeys.deviceId: deviceId, + }, + ); + _stateNotifier?.value = DeepLinkResult(state: DeepLinkState.loading); + final result = await UserEventOauthSignIn(payload).send(); + + _stateNotifier?.value = DeepLinkResult( + state: DeepLinkState.finish, + result: result, + ); + // If there is no completer, runAppFlowy() will be called. + if (_completer == null) { + await result.fold( + (_) async { + await runAppFlowy(); + }, + (err) { + Log.error(err); + final context = AppGlobals.rootNavKey.currentState?.context; + if (context != null) { + showToastNotification( + context, + message: err.msg, + ); + } + }, + ); + } else { + _completer?.complete(result); + completer = null; + } + }, + (err) { + Log.error('onDeepLinkError: Unexpected deep link: $err'); + if (_completer == null) { + final context = AppGlobals.rootNavKey.currentState?.context; + if (context != null) { + showSnackBarMessage( + context, + err.msg, + ); + } + } else { + _completer?.complete(FlowyResult.failure(err)); + completer = null; + } + }, + ); + } + + FlowyResult _isAuthCallbackDeepLink(Uri uri) { + if (uri.fragment.contains('access_token')) { + return FlowyResult.success(null); + } + + return FlowyResult.failure( + FlowyError.create() + ..code = ErrorCode.MissingAuthField + ..msg = uri.path, + ); + } + + bool _isPaymentSuccessUri(Uri uri) { + return uri.host == 'payment-success'; + } +} + +class InitAppFlowyCloudTask extends LaunchTask { + UserAuthStateListener? _authStateListener; + bool isLoggingOut = false; + + @override + Future initialize(LaunchContext context) async { + if (!isAppFlowyCloudEnabled) { + return; + } + _authStateListener = UserAuthStateListener(); + + _authStateListener?.start( + didSignIn: () { + isLoggingOut = false; + }, + onInvalidAuth: (message) async { + Log.error(message); + if (!isLoggingOut) { + await runAppFlowy(); + } + }, + ); + } + + @override + Future dispose() async { + await _authStateListener?.stop(); + _authStateListener = null; + } +} + +class DeepLinkResult { + DeepLinkResult({ + required this.state, + this.result, + }); + + final DeepLinkState state; + final FlowyResult? result; +} + +enum DeepLinkState { + none, + loading, + finish, +} + +// wrapper for AppLinks to support multiple listeners +class _AppLinkWrapper { + _AppLinkWrapper._() { + _appLinkSubscription = _appLinks.uriLinkStream.listen((event) { + _streamSubscription.sink.add(event); + }); + } + + static final _AppLinkWrapper instance = _AppLinkWrapper._(); + + final AppLinks _appLinks = AppLinks(); + final _streamSubscription = StreamController.broadcast(); + late final StreamSubscription _appLinkSubscription; + + StreamSubscription listen( + void Function(Uri?) listener, { + Function? onError, + bool? cancelOnError, + }) { + return _streamSubscription.stream.listen( + listener, + onError: onError, + cancelOnError: cancelOnError, + ); + } + + void dispose() { + _streamSubscription.close(); + _appLinkSubscription.cancel(); + } +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart new file mode 100644 index 0000000000000..082e25e250586 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/debug_task.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../startup.dart'; + +class DebugTask extends LaunchTask { + const DebugTask(); + + @override + Future initialize(LaunchContext context) async { + // the hotkey manager is not supported on mobile + if (UniversalPlatform.isMobile && kDebugMode) { + await SystemChannels.textInput.invokeMethod('TextInput.hide'); + } + } + + @override + Future dispose() async {} +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart new file mode 100644 index 0000000000000..1558eefa5362c --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/device_info_task.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:appflowy_backend/log.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../startup.dart'; + +class ApplicationInfo { + static int androidSDKVersion = -1; + static String applicationVersion = ''; + static String buildNumber = ''; + static String deviceId = ''; + + // macOS major version + static int? macOSMajorVersion; + static int? macOSMinorVersion; +} + +class ApplicationInfoTask extends LaunchTask { + const ApplicationInfoTask(); + + @override + Future initialize(LaunchContext context) async { + final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); + final PackageInfo packageInfo = await PackageInfo.fromPlatform(); + + if (Platform.isMacOS) { + final macInfo = await deviceInfoPlugin.macOsInfo; + ApplicationInfo.macOSMajorVersion = macInfo.majorVersion; + ApplicationInfo.macOSMinorVersion = macInfo.minorVersion; + } + + if (Platform.isAndroid) { + final androidInfo = await deviceInfoPlugin.androidInfo; + ApplicationInfo.androidSDKVersion = androidInfo.version.sdkInt; + } + + if (Platform.isAndroid || Platform.isIOS) { + ApplicationInfo.applicationVersion = packageInfo.version; + ApplicationInfo.buildNumber = packageInfo.buildNumber; + } + + String? deviceId; + try { + if (Platform.isAndroid) { + final AndroidDeviceInfo androidInfo = + await deviceInfoPlugin.androidInfo; + deviceId = androidInfo.device; + } else if (Platform.isIOS) { + final IosDeviceInfo iosInfo = await deviceInfoPlugin.iosInfo; + deviceId = iosInfo.identifierForVendor; + } else if (Platform.isMacOS) { + final MacOsDeviceInfo macInfo = await deviceInfoPlugin.macOsInfo; + deviceId = macInfo.systemGUID; + } else if (Platform.isWindows) { + final WindowsDeviceInfo windowsInfo = + await deviceInfoPlugin.windowsInfo; + deviceId = windowsInfo.deviceId; + } else if (Platform.isLinux) { + final LinuxDeviceInfo linuxInfo = await deviceInfoPlugin.linuxInfo; + deviceId = linuxInfo.machineId; + } else { + deviceId = null; + } + } catch (e) { + Log.error('Failed to get platform version, $e'); + } + + ApplicationInfo.deviceId = deviceId ?? ''; + } + + @override + Future dispose() async {} +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/feature_flag_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/feature_flag_task.dart new file mode 100644 index 0000000000000..fd2439c89224e --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/feature_flag_task.dart @@ -0,0 +1,21 @@ +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:flutter/foundation.dart'; + +import '../startup.dart'; + +class FeatureFlagTask extends LaunchTask { + const FeatureFlagTask(); + + @override + Future initialize(LaunchContext context) async { + // the hotkey manager is not supported on mobile + if (!kDebugMode) { + return; + } + + await FeatureFlag.initialize(); + } + + @override + Future dispose() async {} +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart new file mode 100644 index 0000000000000..0695ceeab5bf5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart @@ -0,0 +1,151 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:isolate'; + +import 'package:flutter/foundation.dart'; + +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-storage/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:fixnum/fixnum.dart'; + +import '../startup.dart'; + +class FileStorageTask extends LaunchTask { + const FileStorageTask(); + + @override + Future initialize(LaunchContext context) async { + context.getIt.registerSingleton( + FileStorageService(), + dispose: (service) async => service.dispose(), + ); + } + + @override + Future dispose() async {} +} + +class FileStorageService { + FileStorageService() { + _port.handler = _controller.add; + _subscription = _controller.stream.listen( + (event) { + final fileProgress = FileProgress.fromJsonString(event); + if (fileProgress != null) { + Log.debug( + "FileStorageService upload file: ${fileProgress.fileUrl} ${fileProgress.progress}", + ); + final notifier = _notifierList[fileProgress.fileUrl]; + if (notifier != null) { + notifier.value = fileProgress; + } + } + }, + ); + + if (!integrationMode().isTest) { + final payload = RegisterStreamPB() + ..port = Int64(_port.sendPort.nativePort); + FileStorageEventRegisterStream(payload).send(); + } + } + + final Map> _notifierList = {}; + final RawReceivePort _port = RawReceivePort(); + final StreamController _controller = StreamController.broadcast(); + late StreamSubscription _subscription; + + AutoRemoveNotifier onFileProgress({required String fileUrl}) { + _notifierList.remove(fileUrl)?.dispose(); + + final notifier = AutoRemoveNotifier( + FileProgress(fileUrl: fileUrl, progress: 0), + notifierList: _notifierList, + fileId: fileUrl, + ); + _notifierList[fileUrl] = notifier; + + // trigger the initial file state + getFileState(fileUrl); + + return notifier; + } + + Future> getFileState(String url) { + final payload = QueryFilePB()..url = url; + return FileStorageEventQueryFile(payload).send(); + } + + Future dispose() async { + // dispose all notifiers + for (final notifier in _notifierList.values) { + notifier.dispose(); + } + + await _controller.close(); + await _subscription.cancel(); + _port.close(); + } +} + +class FileProgress { + FileProgress({ + required this.fileUrl, + required this.progress, + this.error, + }); + + static FileProgress? fromJson(Map? json) { + if (json == null) { + return null; + } + + try { + if (json.containsKey('file_url') && json.containsKey('progress')) { + return FileProgress( + fileUrl: json['file_url'] as String, + progress: (json['progress'] as num).toDouble(), + error: json['error'] as String?, + ); + } + } catch (e) { + Log.error('unable to parse file progress: $e'); + } + return null; + } + + // Method to parse a JSON string and return a FileProgress object or null + static FileProgress? fromJsonString(String jsonString) { + try { + final Map jsonMap = jsonDecode(jsonString); + return FileProgress.fromJson(jsonMap); + } catch (e) { + return null; + } + } + + final double progress; + final String fileUrl; + final String? error; +} + +class AutoRemoveNotifier extends ValueNotifier { + AutoRemoveNotifier( + super.value, { + required this.fileId, + required Map> notifierList, + }) : _notifierList = notifierList; + + final String fileId; + final Map> _notifierList; + + @override + void dispose() { + _notifierList.remove(fileId); + super.dispose(); + } +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart new file mode 100644 index 0000000000000..27bfdcee5be33 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -0,0 +1,690 @@ +import 'dart:convert'; + +import 'package:appflowy/mobile/presentation/chat/mobile_chat_screen.dart'; +import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart'; +import 'package:appflowy/mobile/presentation/database/card/card.dart'; +import 'package:appflowy/mobile/presentation/database/date_picker/mobile_date_picker_screen.dart'; +import 'package:appflowy/mobile/presentation/database/field/mobile_create_field_screen.dart'; +import 'package:appflowy/mobile/presentation/database/field/mobile_edit_field_screen.dart'; +import 'package:appflowy/mobile/presentation/database/mobile_calendar_events_screen.dart'; +import 'package:appflowy/mobile/presentation/database/mobile_calendar_screen.dart'; +import 'package:appflowy/mobile/presentation/database/mobile_grid_screen.dart'; +import 'package:appflowy/mobile/presentation/favorite/mobile_favorite_page.dart'; +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_multiple_select_page.dart'; +import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart'; +import 'package:appflowy/mobile/presentation/presentation.dart'; +import 'package:appflowy/mobile/presentation/setting/cloud/appflowy_cloud_page.dart'; +import 'package:appflowy/mobile/presentation/setting/font/font_picker_screen.dart'; +import 'package:appflowy/mobile/presentation/setting/language/language_picker_screen.dart'; +import 'package:appflowy/mobile/presentation/setting/launch_settings_page.dart'; +import 'package:appflowy/mobile/presentation/setting/workspace/invite_members_screen.dart'; +import 'package:appflowy/plugins/base/color/color_picker_screen.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_language_screen.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_picker_screen.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_item/mobile_block_settings_screen.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/app_widget.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/presentation/presentation.dart'; +import 'package:appflowy/workspace/presentation/home/desktop_home_screen.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/mobile_feature_flag_screen.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flowy_infra/time/duration.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sheet/route.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../shared/icon_emoji_picker/tab.dart'; + +GoRouter generateRouter(Widget child) { + return GoRouter( + navigatorKey: AppGlobals.rootNavKey, + initialLocation: '/', + routes: [ + // Root route is SplashScreen. + // It needs LaunchConfiguration as a parameter, so we get it from ApplicationWidget's child. + _rootRoute(child), + // Routes in both desktop and mobile + _signInScreenRoute(), + _skipLogInScreenRoute(), + _encryptSecretScreenRoute(), + _workspaceErrorScreenRoute(), + // Desktop only + if (UniversalPlatform.isDesktop) _desktopHomeScreenRoute(), + // Mobile only + if (UniversalPlatform.isMobile) ...[ + // settings + _mobileHomeSettingPageRoute(), + _mobileCloudSettingAppFlowyCloudPageRoute(), + _mobileLaunchSettingsPageRoute(), + _mobileFeatureFlagPageRoute(), + + // view page + _mobileEditorScreenRoute(), + _mobileGridScreenRoute(), + _mobileBoardScreenRoute(), + _mobileCalendarScreenRoute(), + _mobileChatScreenRoute(), + // card detail page + _mobileCardDetailScreenRoute(), + _mobileDateCellEditScreenRoute(), + _mobileNewPropertyPageRoute(), + _mobileEditPropertyPageRoute(), + + // home + // MobileHomeSettingPage is outside the bottom navigation bar, thus it is not in the StatefulShellRoute. + _mobileHomeScreenWithNavigationBarRoute(), + + // trash + _mobileHomeTrashPageRoute(), + + // emoji picker + _mobileEmojiPickerPageRoute(), + _mobileImagePickerPageRoute(), + + // color picker + _mobileColorPickerPageRoute(), + + // code language picker + _mobileCodeLanguagePickerPageRoute(), + _mobileLanguagePickerPageRoute(), + _mobileFontPickerPageRoute(), + + // calendar related + _mobileCalendarEventsPageRoute(), + + _mobileBlockSettingsPageRoute(), + + // notifications + _mobileNotificationMultiSelectPageRoute(), + + // invite members + _mobileInviteMembersPageRoute(), + ], + + // Desktop and Mobile + GoRoute( + path: WorkspaceStartScreen.routeName, + pageBuilder: (context, state) { + final args = state.extra as Map; + return CustomTransitionPage( + child: WorkspaceStartScreen( + userProfile: args[WorkspaceStartScreen.argUserProfile], + ), + transitionsBuilder: _buildFadeTransition, + transitionDuration: _slowDuration, + ); + }, + ), + GoRoute( + path: SignUpScreen.routeName, + pageBuilder: (context, state) { + return CustomTransitionPage( + child: SignUpScreen( + router: getIt(), + ), + transitionsBuilder: _buildFadeTransition, + transitionDuration: _slowDuration, + ); + }, + ), + ], + ); +} + +/// We use StatefulShellRoute to create a StatefulNavigationShell(ScaffoldWithNavBar) to access to multiple pages, and each page retains its own state. +StatefulShellRoute _mobileHomeScreenWithNavigationBarRoute() { + return StatefulShellRoute.indexedStack( + builder: ( + BuildContext context, + GoRouterState state, + StatefulNavigationShell navigationShell, + ) { + // Return the widget that implements the custom shell (in this case + // using a BottomNavigationBar). The StatefulNavigationShell is passed + // to be able access the state of the shell and to navigate to other + // branches in a stateful way. + return MobileBottomNavigationBar(navigationShell: navigationShell); + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: MobileHomeScreen.routeName, + builder: (BuildContext context, GoRouterState state) { + return const MobileHomeScreen(); + }, + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: MobileFavoriteScreen.routeName, + builder: (BuildContext context, GoRouterState state) { + return const MobileFavoriteScreen(); + }, + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: MobileNotificationsScreenV2.routeName, + builder: (_, __) => const MobileNotificationsScreenV2(), + ), + ], + ), + ], + ); +} + +GoRoute _mobileHomeSettingPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileHomeSettingPage.routeName, + pageBuilder: (context, state) { + return const MaterialExtendedPage(child: MobileHomeSettingPage()); + }, + ); +} + +GoRoute _mobileNotificationMultiSelectPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileNotificationsMultiSelectScreen.routeName, + pageBuilder: (context, state) { + return const MaterialExtendedPage( + child: MobileNotificationsMultiSelectScreen(), + ); + }, + ); +} + +GoRoute _mobileInviteMembersPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: InviteMembersScreen.routeName, + pageBuilder: (context, state) { + return const MaterialExtendedPage( + child: InviteMembersScreen(), + ); + }, + ); +} + +GoRoute _mobileCloudSettingAppFlowyCloudPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: AppFlowyCloudPage.routeName, + pageBuilder: (context, state) { + return const MaterialExtendedPage(child: AppFlowyCloudPage()); + }, + ); +} + +GoRoute _mobileLaunchSettingsPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileLaunchSettingsPage.routeName, + pageBuilder: (context, state) { + return const MaterialExtendedPage(child: MobileLaunchSettingsPage()); + }, + ); +} + +GoRoute _mobileFeatureFlagPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: FeatureFlagScreen.routeName, + pageBuilder: (context, state) { + return const MaterialExtendedPage(child: FeatureFlagScreen()); + }, + ); +} + +GoRoute _mobileHomeTrashPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileHomeTrashPage.routeName, + pageBuilder: (context, state) { + return const MaterialExtendedPage(child: MobileHomeTrashPage()); + }, + ); +} + +GoRoute _mobileBlockSettingsPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileBlockSettingsScreen.routeName, + pageBuilder: (context, state) { + final actionsString = + state.uri.queryParameters[MobileBlockSettingsScreen.supportedActions]; + final actions = actionsString + ?.split(',') + .map(MobileBlockActionType.fromActionString) + .toList(); + return MaterialExtendedPage( + child: MobileBlockSettingsScreen( + actions: actions ?? MobileBlockActionType.standard, + ), + ); + }, + ); +} + +GoRoute _mobileEmojiPickerPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileEmojiPickerScreen.routeName, + pageBuilder: (context, state) { + final title = + state.uri.queryParameters[MobileEmojiPickerScreen.pageTitle]; + final selectTabs = + state.uri.queryParameters[MobileEmojiPickerScreen.selectTabs] ?? ''; + final tabs = selectTabs + .split('-') + .map((e) => PickerTabType.values.byName(e)) + .toList(); + return MaterialExtendedPage( + child: tabs.isEmpty + ? MobileEmojiPickerScreen(title: title) + : MobileEmojiPickerScreen(title: title, tabs: tabs), + ); + }, + ); +} + +GoRoute _mobileColorPickerPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileColorPickerScreen.routeName, + pageBuilder: (context, state) { + final title = + state.uri.queryParameters[MobileColorPickerScreen.pageTitle] ?? ''; + return MaterialExtendedPage( + child: MobileColorPickerScreen( + title: title, + ), + ); + }, + ); +} + +GoRoute _mobileImagePickerPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileImagePickerScreen.routeName, + pageBuilder: (context, state) { + return const MaterialExtendedPage( + child: MobileImagePickerScreen(), + ); + }, + ); +} + +GoRoute _mobileCodeLanguagePickerPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileCodeLanguagePickerScreen.routeName, + pageBuilder: (context, state) { + return const MaterialExtendedPage( + child: MobileCodeLanguagePickerScreen(), + ); + }, + ); +} + +GoRoute _mobileLanguagePickerPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: LanguagePickerScreen.routeName, + pageBuilder: (context, state) { + return const MaterialExtendedPage( + child: LanguagePickerScreen(), + ); + }, + ); +} + +GoRoute _mobileFontPickerPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: FontPickerScreen.routeName, + pageBuilder: (context, state) { + return const MaterialExtendedPage( + child: FontPickerScreen(), + ); + }, + ); +} + +GoRoute _mobileNewPropertyPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileNewPropertyScreen.routeName, + pageBuilder: (context, state) { + final viewId = state + .uri.queryParameters[MobileNewPropertyScreen.argViewId] as String; + final fieldTypeId = + state.uri.queryParameters[MobileNewPropertyScreen.argFieldTypeId] ?? + FieldType.RichText.value.toString(); + final value = int.parse(fieldTypeId); + return MaterialExtendedPage( + fullscreenDialog: true, + child: MobileNewPropertyScreen( + viewId: viewId, + fieldType: FieldType.valueOf(value), + ), + ); + }, + ); +} + +GoRoute _mobileEditPropertyPageRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileEditPropertyScreen.routeName, + pageBuilder: (context, state) { + final args = state.extra as Map; + return MaterialExtendedPage( + fullscreenDialog: true, + child: MobileEditPropertyScreen( + viewId: args[MobileEditPropertyScreen.argViewId], + field: args[MobileEditPropertyScreen.argField], + ), + ); + }, + ); +} + +GoRoute _mobileCalendarEventsPageRoute() { + return GoRoute( + path: MobileCalendarEventsScreen.routeName, + parentNavigatorKey: AppGlobals.rootNavKey, + pageBuilder: (context, state) { + final args = state.extra as Map; + + return MaterialExtendedPage( + child: MobileCalendarEventsScreen( + calendarBloc: args[MobileCalendarEventsScreen.calendarBlocKey], + date: args[MobileCalendarEventsScreen.calendarDateKey], + events: args[MobileCalendarEventsScreen.calendarEventsKey], + rowCache: args[MobileCalendarEventsScreen.calendarRowCacheKey], + viewId: args[MobileCalendarEventsScreen.calendarViewIdKey], + ), + ); + }, + ); +} + +GoRoute _desktopHomeScreenRoute() { + return GoRoute( + path: DesktopHomeScreen.routeName, + pageBuilder: (context, state) { + return CustomTransitionPage( + child: const DesktopHomeScreen(), + transitionsBuilder: _buildFadeTransition, + transitionDuration: _slowDuration, + ); + }, + ); +} + +GoRoute _workspaceErrorScreenRoute() { + return GoRoute( + path: WorkspaceErrorScreen.routeName, + pageBuilder: (context, state) { + final args = state.extra as Map; + return CustomTransitionPage( + child: WorkspaceErrorScreen( + error: args[WorkspaceErrorScreen.argError], + userFolder: args[WorkspaceErrorScreen.argUserFolder], + ), + transitionsBuilder: _buildFadeTransition, + transitionDuration: _slowDuration, + ); + }, + ); +} + +GoRoute _encryptSecretScreenRoute() { + return GoRoute( + path: EncryptSecretScreen.routeName, + pageBuilder: (context, state) { + final args = state.extra as Map; + return CustomTransitionPage( + child: EncryptSecretScreen( + user: args[EncryptSecretScreen.argUser], + key: args[EncryptSecretScreen.argKey], + ), + transitionsBuilder: _buildFadeTransition, + transitionDuration: _slowDuration, + ); + }, + ); +} + +GoRoute _skipLogInScreenRoute() { + return GoRoute( + path: SkipLogInScreen.routeName, + pageBuilder: (context, state) { + return CustomTransitionPage( + child: const SkipLogInScreen(), + transitionsBuilder: _buildFadeTransition, + transitionDuration: _slowDuration, + ); + }, + ); +} + +GoRoute _signInScreenRoute() { + return GoRoute( + path: SignInScreen.routeName, + pageBuilder: (context, state) { + return CustomTransitionPage( + child: const SignInScreen(), + transitionsBuilder: _buildFadeTransition, + transitionDuration: _slowDuration, + ); + }, + ); +} + +GoRoute _mobileEditorScreenRoute() { + return GoRoute( + path: MobileDocumentScreen.routeName, + parentNavigatorKey: AppGlobals.rootNavKey, + pageBuilder: (context, state) { + final id = state.uri.queryParameters[MobileDocumentScreen.viewId]!; + final title = state.uri.queryParameters[MobileDocumentScreen.viewTitle]; + final showMoreButton = bool.tryParse( + state.uri.queryParameters[MobileDocumentScreen.viewShowMoreButton] ?? + 'true', + ); + final fixedTitle = + state.uri.queryParameters[MobileDocumentScreen.viewFixedTitle]; + final blockId = + state.uri.queryParameters[MobileDocumentScreen.viewBlockId]; + + return MaterialExtendedPage( + child: MobileDocumentScreen( + id: id, + title: title, + showMoreButton: showMoreButton ?? true, + fixedTitle: fixedTitle, + blockId: blockId, + ), + ); + }, + ); +} + +GoRoute _mobileChatScreenRoute() { + return GoRoute( + path: MobileChatScreen.routeName, + parentNavigatorKey: AppGlobals.rootNavKey, + pageBuilder: (context, state) { + final id = state.uri.queryParameters[MobileChatScreen.viewId]!; + final title = state.uri.queryParameters[MobileChatScreen.viewTitle]; + + return MaterialExtendedPage( + child: MobileChatScreen(id: id, title: title), + ); + }, + ); +} + +GoRoute _mobileGridScreenRoute() { + return GoRoute( + path: MobileGridScreen.routeName, + parentNavigatorKey: AppGlobals.rootNavKey, + pageBuilder: (context, state) { + final id = state.uri.queryParameters[MobileGridScreen.viewId]!; + final title = state.uri.queryParameters[MobileGridScreen.viewTitle]; + final arguments = state.uri.queryParameters[MobileGridScreen.viewArgs]; + + return MaterialExtendedPage( + child: MobileGridScreen( + id: id, + title: title, + arguments: arguments != null ? jsonDecode(arguments) : null, + ), + ); + }, + ); +} + +GoRoute _mobileBoardScreenRoute() { + return GoRoute( + path: MobileBoardScreen.routeName, + parentNavigatorKey: AppGlobals.rootNavKey, + pageBuilder: (context, state) { + final id = state.uri.queryParameters[MobileBoardScreen.viewId]!; + final title = state.uri.queryParameters[MobileBoardScreen.viewTitle]; + return MaterialExtendedPage( + child: MobileBoardScreen( + id: id, + title: title, + ), + ); + }, + ); +} + +GoRoute _mobileCalendarScreenRoute() { + return GoRoute( + path: MobileCalendarScreen.routeName, + parentNavigatorKey: AppGlobals.rootNavKey, + pageBuilder: (context, state) { + final id = state.uri.queryParameters[MobileCalendarScreen.viewId]!; + final title = state.uri.queryParameters[MobileCalendarScreen.viewTitle]!; + return MaterialExtendedPage( + child: MobileCalendarScreen( + id: id, + title: title, + ), + ); + }, + ); +} + +GoRoute _mobileCardDetailScreenRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileRowDetailPage.routeName, + pageBuilder: (context, state) { + var extra = state.extra as Map?; + + if (kDebugMode && extra == null) { + extra = _dynamicValues; + } + + if (extra == null) { + return const MaterialExtendedPage( + child: SizedBox.shrink(), + ); + } + + final databaseController = + extra[MobileRowDetailPage.argDatabaseController]; + final rowId = extra[MobileRowDetailPage.argRowId]!; + + if (kDebugMode) { + _dynamicValues = extra; + } + + return MaterialExtendedPage( + child: MobileRowDetailPage( + databaseController: databaseController, + rowId: rowId, + ), + ); + }, + ); +} + +GoRoute _mobileDateCellEditScreenRoute() { + return GoRoute( + parentNavigatorKey: AppGlobals.rootNavKey, + path: MobileDateCellEditScreen.routeName, + pageBuilder: (context, state) { + final args = state.extra as Map; + final controller = args[MobileDateCellEditScreen.dateCellController]; + final fullScreen = args[MobileDateCellEditScreen.fullScreen]; + return CustomTransitionPage( + transitionsBuilder: (_, __, ___, child) => child, + fullscreenDialog: true, + opaque: false, + barrierDismissible: true, + barrierColor: Theme.of(context).bottomSheetTheme.modalBarrierColor, + child: MobileDateCellEditScreen( + controller: controller, + showAsFullScreen: fullScreen ?? true, + ), + ); + }, + ); +} + +GoRoute _rootRoute(Widget child) { + return GoRoute( + path: '/', + redirect: (context, state) async { + // Every time before navigating to splash screen, we check if user is already logged in desktop. It is used to skip showing splash screen when user just changes appearance settings like theme mode. + final userResponse = await getIt().getUser(); + final routeName = userResponse.fold( + (user) => DesktopHomeScreen.routeName, + (error) => null, + ); + if (routeName != null && !UniversalPlatform.isMobile) return routeName; + + return null; + }, + // Root route is SplashScreen. + // It needs LaunchConfiguration as a parameter, so we get it from ApplicationWidget's child. + pageBuilder: (context, state) => MaterialExtendedPage( + child: child, + ), + ); +} + +Widget _buildFadeTransition( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, +) => + FadeTransition(opacity: animation, child: child); + +Duration _slowDuration = Duration( + milliseconds: RouteDurations.slow.inMilliseconds.round(), +); + +// ONLY USE IN DEBUG MODE +// this is a workaround for the issue of GoRouter not supporting extra with complex types +// https://github.com/flutter/flutter/issues/137248 +Map _dynamicValues = {}; diff --git a/frontend/appflowy_flutter/lib/startup/tasks/hot_key.dart b/frontend/appflowy_flutter/lib/startup/tasks/hot_key.dart new file mode 100644 index 0000000000000..9e23a0017c9ef --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/hot_key.dart @@ -0,0 +1,20 @@ +import 'package:hotkey_manager/hotkey_manager.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../startup.dart'; + +class HotKeyTask extends LaunchTask { + const HotKeyTask(); + + @override + Future initialize(LaunchContext context) async { + // the hotkey manager is not supported on mobile + if (UniversalPlatform.isMobile) { + return; + } + await hotKeyManager.unregisterAll(); + } + + @override + Future dispose() async {} +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/load_plugin.dart b/frontend/appflowy_flutter/lib/startup/tasks/load_plugin.dart new file mode 100644 index 0000000000000..9a75607d74270 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/load_plugin.dart @@ -0,0 +1,45 @@ +import 'package:appflowy/plugins/ai_chat/chat.dart'; +import 'package:appflowy/plugins/database/calendar/calendar.dart'; +import 'package:appflowy/plugins/database/board/board.dart'; +import 'package:appflowy/plugins/database/grid/grid.dart'; +import 'package:appflowy/plugins/database_document/database_document_plugin.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/plugins/blank/blank.dart'; +import 'package:appflowy/plugins/document/document.dart'; +import 'package:appflowy/plugins/trash/trash.dart'; + +class PluginLoadTask extends LaunchTask { + const PluginLoadTask(); + + @override + LaunchTaskType get type => LaunchTaskType.dataProcessing; + + @override + Future initialize(LaunchContext context) async { + registerPlugin(builder: BlankPluginBuilder(), config: BlankPluginConfig()); + registerPlugin(builder: TrashPluginBuilder(), config: TrashPluginConfig()); + registerPlugin(builder: DocumentPluginBuilder()); + registerPlugin(builder: GridPluginBuilder(), config: GridPluginConfig()); + registerPlugin(builder: BoardPluginBuilder(), config: BoardPluginConfig()); + registerPlugin( + builder: CalendarPluginBuilder(), + config: CalendarPluginConfig(), + ); + registerPlugin( + builder: DatabaseDocumentPluginBuilder(), + config: DatabaseDocumentPluginConfig(), + ); + registerPlugin( + builder: DatabaseDocumentPluginBuilder(), + config: DatabaseDocumentPluginConfig(), + ); + registerPlugin( + builder: AIChatPluginBuilder(), + config: AIChatPluginConfig(), + ); + } + + @override + Future dispose() async {} +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/localization.dart b/frontend/appflowy_flutter/lib/startup/tasks/localization.dart new file mode 100644 index 0000000000000..83950ffc1b93a --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/localization.dart @@ -0,0 +1,16 @@ +import 'package:easy_localization/easy_localization.dart'; + +import '../startup.dart'; + +class InitLocalizationTask extends LaunchTask { + const InitLocalizationTask(); + + @override + Future initialize(LaunchContext context) async { + await EasyLocalization.ensureInitialized(); + EasyLocalization.logger.enableBuildModes = []; + } + + @override + Future dispose() async {} +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/memory_leak_detector.dart b/frontend/appflowy_flutter/lib/startup/tasks/memory_leak_detector.dart new file mode 100644 index 0000000000000..f35c955b3da1c --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/memory_leak_detector.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:leak_tracker/leak_tracker.dart'; + +import '../startup.dart'; + +bool enableMemoryLeakDetect = false; +bool dumpMemoryLeakPerSecond = false; + +void dumpMemoryLeak({ + LeakType type = LeakType.notDisposed, +}) async { + final details = await LeakTracking.collectLeaks(); + details.dumpDetails(type); +} + +class MemoryLeakDetectorTask extends LaunchTask { + MemoryLeakDetectorTask(); + + Timer? _timer; + + @override + Future initialize(LaunchContext context) async { + if (!kDebugMode || !enableMemoryLeakDetect) { + return; + } + + LeakTracking.start(); + LeakTracking.phase = const PhaseSettings( + leakDiagnosticConfig: LeakDiagnosticConfig( + collectRetainingPathForNotGCed: true, + collectStackTraceOnStart: true, + ), + ); + + FlutterMemoryAllocations.instance.addListener((p0) { + LeakTracking.dispatchObjectEvent(p0.toMap()); + }); + + // dump memory leak per second if needed + if (dumpMemoryLeakPerSecond) { + _timer = Timer.periodic(const Duration(seconds: 1), (_) async { + final summary = await LeakTracking.checkLeaks(); + if (summary.isEmpty) { + return; + } + + dumpMemoryLeak(); + }); + } + } + + @override + Future dispose() async { + if (!kDebugMode || !enableMemoryLeakDetect) { + return; + } + + if (dumpMemoryLeakPerSecond) { + _timer?.cancel(); + _timer = null; + } + + LeakTracking.stop(); + } +} + +extension on LeakType { + String get desc => switch (this) { + LeakType.notDisposed => 'not disposed', + LeakType.notGCed => 'not GCed', + LeakType.gcedLate => 'GCed late' + }; +} + +final _dumpablePackages = [ + 'package:appflowy/', + 'package:appflowy_editor/', +]; + +extension on Leaks { + void dumpDetails(LeakType type) { + final summary = '${type.desc}: ${switch (type) { + LeakType.notDisposed => '${notDisposed.length}', + LeakType.notGCed => '${notGCed.length}', + LeakType.gcedLate => '${gcedLate.length}' + }}'; + debugPrint(summary); + final details = switch (type) { + LeakType.notDisposed => notDisposed, + LeakType.notGCed => notGCed, + LeakType.gcedLate => gcedLate + }; + + // only dump the code in appflowy + for (final value in details) { + final stack = value.context![ContextKeys.startCallstack]! as StackTrace; + final stackInAppFlowy = stack + .toString() + .split('\n') + .where( + (stack) => + // ignore current file call stack + !stack.contains('memory_leak_detector') && + _dumpablePackages.any((pkg) => stack.contains(pkg)), + ) + .join('\n'); + + // ignore the untreatable leak + if (stackInAppFlowy.isEmpty) { + continue; + } + + final object = value.type; + debugPrint(''' +$object ${type.desc} +$stackInAppFlowy +'''); + } + } +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/platform_error_catcher.dart b/frontend/appflowy_flutter/lib/startup/tasks/platform_error_catcher.dart new file mode 100644 index 0000000000000..c2c64536b2dc4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/platform_error_catcher.dart @@ -0,0 +1,43 @@ +import 'package:appflowy_backend/log.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../startup.dart'; + +class PlatformErrorCatcherTask extends LaunchTask { + const PlatformErrorCatcherTask(); + + @override + Future initialize(LaunchContext context) async { + // Handle platform errors not caught by Flutter. + // Reduces the likelihood of the app crashing, and logs the error. + // only active in non debug mode. + if (!kDebugMode) { + PlatformDispatcher.instance.onError = (error, stack) { + Log.error('Uncaught platform error', error, stack); + return true; + }; + } + + ErrorWidget.builder = (details) { + if (kDebugMode) { + return Container( + width: double.infinity, + height: 30, + color: Colors.red, + child: FlowyText( + 'ERROR: ${details.exceptionAsString()}', + color: Colors.white, + ), + ); + } + + // hide the error widget in release mode + return const SizedBox.shrink(); + }; + } + + @override + Future dispose() async {} +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/platform_service.dart b/frontend/appflowy_flutter/lib/startup/tasks/platform_service.dart new file mode 100644 index 0000000000000..4206d447dbfe3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/platform_service.dart @@ -0,0 +1,19 @@ +import 'package:appflowy/core/network_monitor.dart'; +import '../startup.dart'; + +class InitPlatformServiceTask extends LaunchTask { + const InitPlatformServiceTask(); + + @override + LaunchTaskType get type => LaunchTaskType.dataProcessing; + + @override + Future initialize(LaunchContext context) async { + return getIt().start(); + } + + @override + Future dispose() async { + await getIt().stop(); + } +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/prelude.dart b/frontend/appflowy_flutter/lib/startup/tasks/prelude.dart new file mode 100644 index 0000000000000..4be5f0f6f7acd --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/prelude.dart @@ -0,0 +1,15 @@ +export 'app_widget.dart'; +export 'appflowy_cloud_task.dart'; +export 'debug_task.dart'; +export 'device_info_task.dart'; +export 'generate_router.dart'; +export 'hot_key.dart'; +export 'load_plugin.dart'; +export 'localization.dart'; +export 'memory_leak_detector.dart'; +export 'platform_error_catcher.dart'; +export 'platform_service.dart'; +export 'recent_service_task.dart'; +export 'rust_sdk.dart'; +export 'sentry.dart'; +export 'windows.dart'; diff --git a/frontend/appflowy_flutter/lib/startup/tasks/recent_service_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/recent_service_task.dart new file mode 100644 index 0000000000000..5d632903cc0c7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/recent_service_task.dart @@ -0,0 +1,14 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/recent/prelude.dart'; +import 'package:appflowy_backend/log.dart'; + +class RecentServiceTask extends LaunchTask { + const RecentServiceTask(); + + @override + Future initialize(LaunchContext context) async => + Log.info('[CachedRecentService] Initialized'); + + @override + Future dispose() async => getIt().dispose(); +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart new file mode 100644 index 0000000000000..58d6aacbc31ae --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:appflowy/env/backend_env.dart'; +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/user/application/auth/device_id.dart'; +import 'package:appflowy_backend/appflowy_backend.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; + +import '../startup.dart'; + +class InitRustSDKTask extends LaunchTask { + const InitRustSDKTask({ + this.customApplicationPath, + }); + + // Customize the RustSDK initialization path + final Directory? customApplicationPath; + + @override + LaunchTaskType get type => LaunchTaskType.dataProcessing; + + @override + Future initialize(LaunchContext context) async { + final root = await getApplicationSupportDirectory(); + final applicationPath = await appFlowyApplicationDataDirectory(); + final dir = customApplicationPath ?? applicationPath; + final deviceId = await getDeviceId(); + + debugPrint('application path: ${applicationPath.path}'); + // Pass the environment variables to the Rust SDK + final env = _makeAppFlowyConfiguration( + root.path, + context.config.version, + dir.path, + applicationPath.path, + deviceId, + rustEnvs: context.config.rustEnvs, + ); + await context.getIt().init(jsonEncode(env.toJson())); + } + + @override + Future dispose() async {} +} + +AppFlowyConfiguration _makeAppFlowyConfiguration( + String root, + String appVersion, + String customAppPath, + String originAppPath, + String deviceId, { + required Map rustEnvs, +}) { + final env = getIt(); + return AppFlowyConfiguration( + root: root, + app_version: appVersion, + custom_app_path: customAppPath, + origin_app_path: originAppPath, + device_id: deviceId, + platform: Platform.operatingSystem, + authenticator_type: env.authenticatorType.value, + appflowy_cloud_config: env.appflowyCloudConfig, + envs: rustEnvs, + ); +} + +/// The default directory to store the user data. The directory can be +/// customized by the user via the [ApplicationDataStorage] +Future appFlowyApplicationDataDirectory() async { + switch (integrationMode()) { + case IntegrationMode.develop: + final Directory documentsDir = await getApplicationSupportDirectory() + .then((directory) => directory.create()); + return Directory(path.join(documentsDir.path, 'data_dev')).create(); + case IntegrationMode.release: + final Directory documentsDir = await getApplicationSupportDirectory(); + return Directory(path.join(documentsDir.path, 'data')).create(); + case IntegrationMode.unitTest: + case IntegrationMode.integrationTest: + return Directory(path.join(Directory.current.path, '.sandbox')); + } +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/sentry.dart b/frontend/appflowy_flutter/lib/startup/tasks/sentry.dart new file mode 100644 index 0000000000000..9076569a9c172 --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/sentry.dart @@ -0,0 +1,31 @@ +import 'package:appflowy/env/env.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +import '../startup.dart'; + +class InitSentryTask extends LaunchTask { + const InitSentryTask(); + + @override + Future initialize(LaunchContext context) async { + const dsn = Env.sentryDsn; + if (dsn.isEmpty) { + Log.info('Sentry DSN is not set, skipping initialization'); + return; + } + + Log.info('Initializing Sentry'); + + await SentryFlutter.init( + (options) { + options.dsn = dsn; + options.tracesSampleRate = 0.1; + options.profilesSampleRate = 0.1; + }, + ); + } + + @override + Future dispose() async {} +} diff --git a/frontend/appflowy_flutter/lib/startup/tasks/windows.dart b/frontend/appflowy_flutter/lib/startup/tasks/windows.dart new file mode 100644 index 0000000000000..20b8b0b56e1df --- /dev/null +++ b/frontend/appflowy_flutter/lib/startup/tasks/windows.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:appflowy/core/helpers/helpers.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/app_window_size_manager.dart'; +import 'package:bitsdojo_window/bitsdojo_window.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:scaled_app/scaled_app.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class InitAppWindowTask extends LaunchTask with WindowListener { + InitAppWindowTask({this.title = 'AppFlowy'}); + + final String title; + final windowSizeManager = WindowSizeManager(); + + @override + Future initialize(LaunchContext context) async { + // Don't initialize on mobile or web. + if (!defaultTargetPlatform.isDesktop || context.env.isIntegrationTest) { + return; + } + + await windowManager.ensureInitialized(); + windowManager.addListener(this); + + final windowSize = await windowSizeManager.getSize(); + final windowOptions = WindowOptions( + size: windowSize, + minimumSize: const Size( + WindowSizeManager.minWindowWidth, + WindowSizeManager.minWindowHeight, + ), + maximumSize: const Size( + WindowSizeManager.maxWindowWidth, + WindowSizeManager.maxWindowHeight, + ), + title: title, + ); + + final position = await windowSizeManager.getPosition(); + + if (UniversalPlatform.isWindows) { + doWhenWindowReady(() async { + appWindow.minSize = windowOptions.minimumSize; + appWindow.maxSize = windowOptions.maximumSize; + appWindow.size = windowSize; + + if (position != null) { + appWindow.position = position; + } + + appWindow.show(); + + /// on Windows we maximize the window if it was previously closed + /// from a maximized state. + final isMaximized = await windowSizeManager.getWindowMaximized(); + if (isMaximized) { + appWindow.maximize(); + } + }); + } else { + await windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); + + if (position != null) { + await windowManager.setPosition(position); + } + }); + } + + unawaited( + windowSizeManager.getScaleFactor().then( + (v) => ScaledWidgetsFlutterBinding.instance.scaleFactor = (_) => v, + ), + ); + } + + @override + Future onWindowMaximize() async { + super.onWindowMaximize(); + await windowSizeManager.setWindowMaximized(true); + await windowSizeManager.setPosition(Offset.zero); + } + + @override + Future onWindowUnmaximize() async { + super.onWindowUnmaximize(); + await windowSizeManager.setWindowMaximized(false); + + final position = await windowManager.getPosition(); + return windowSizeManager.setPosition(position); + } + + @override + void onWindowEnterFullScreen() async { + super.onWindowEnterFullScreen(); + await windowSizeManager.setWindowMaximized(true); + await windowSizeManager.setPosition(Offset.zero); + } + + @override + Future onWindowLeaveFullScreen() async { + super.onWindowLeaveFullScreen(); + await windowSizeManager.setWindowMaximized(false); + + final position = await windowManager.getPosition(); + return windowSizeManager.setPosition(position); + } + + @override + Future onWindowResize() async { + super.onWindowResize(); + + final currentWindowSize = await windowManager.getSize(); + return windowSizeManager.setSize(currentWindowSize); + } + + @override + void onWindowMoved() async { + super.onWindowMoved(); + + final position = await windowManager.getPosition(); + return windowSizeManager.setPosition(position); + } + + @override + Future dispose() async { + windowManager.removeListener(this); + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/ai_service.dart b/frontend/appflowy_flutter/lib/user/application/ai_service.dart new file mode 100644 index 0000000000000..e4100cec59b3d --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/ai_service.dart @@ -0,0 +1,134 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:isolate'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/text_completion.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:fixnum/fixnum.dart' as fixnum; + +class AppFlowyAIService implements AIRepository { + @override + Future, AIError>> generateImage({ + required String prompt, + int n = 1, + }) { + throw UnimplementedError(); + } + + @override + Future getStreamedCompletions({ + required String prompt, + required Future Function() onStart, + required Future Function(TextCompletionResponse response) onProcess, + required Future Function() onEnd, + required void Function(AIError error) onError, + String? suffix, + int maxTokens = 2048, + double temperature = 0.3, + bool useAction = false, + }) { + throw UnimplementedError(); + } + + @override + Future streamCompletion({ + required String text, + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) onProcess, + required Future Function() onEnd, + required void Function(AIError error) onError, + }) async { + final stream = CompletionStream( + onStart, + onProcess, + onEnd, + onError, + ); + final payload = CompleteTextPB( + text: text, + completionType: completionType, + streamPort: fixnum.Int64(stream.nativePort), + ); + + // ignore: unawaited_futures + AIEventCompleteText(payload).send(); + return stream; + } +} + +CompletionTypePB completionTypeFromInt(AskAIAction action) { + switch (action) { + case AskAIAction.summarize: + return CompletionTypePB.MakeShorter; + case AskAIAction.fixSpelling: + return CompletionTypePB.SpellingAndGrammar; + case AskAIAction.improveWriting: + return CompletionTypePB.ImproveWriting; + case AskAIAction.makeItLonger: + return CompletionTypePB.MakeLonger; + } +} + +class CompletionStream { + CompletionStream( + Future Function() onStart, + Future Function(String text) onProcess, + Future Function() onEnd, + void Function(AIError error) onError, + ) { + _port.handler = _controller.add; + _subscription = _controller.stream.listen( + (event) async { + if (event == "AI_RESPONSE_LIMIT") { + onError( + AIError( + message: LocaleKeys.sideBar_aiResponseLimit.tr(), + code: AIErrorCode.aiResponseLimitExceeded, + ), + ); + } + + if (event.startsWith("start:")) { + await onStart(); + } + + if (event.startsWith("data:")) { + await onProcess(event.substring(5)); + } + + if (event.startsWith("finish:")) { + await onEnd(); + } + + if (event.startsWith("error:")) { + onError(AIError(message: event.substring(6))); + } + }, + ); + } + + final RawReceivePort _port = RawReceivePort(); + final StreamController _controller = StreamController.broadcast(); + late StreamSubscription _subscription; + int get nativePort => _port.sendPort.nativePort; + + Future dispose() async { + await _controller.close(); + await _subscription.cancel(); + _port.close(); + } + + StreamSubscription listen( + void Function(String event)? onData, + ) { + return _controller.stream.listen(onData); + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/anon_user_bloc.dart b/frontend/appflowy_flutter/lib/user/application/anon_user_bloc.dart new file mode 100644 index 0000000000000..292760ca4ba00 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/anon_user_bloc.dart @@ -0,0 +1,64 @@ +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'anon_user_bloc.freezed.dart'; + +class AnonUserBloc extends Bloc { + AnonUserBloc() : super(AnonUserState.initial()) { + on((event, emit) async { + await event.when( + initial: () async { + await _loadHistoricalUsers(); + }, + didLoadAnonUsers: (List anonUsers) { + emit(state.copyWith(anonUsers: anonUsers)); + }, + openAnonUser: (anonUser) async { + await UserBackendService.openAnonUser(); + emit(state.copyWith(openedAnonUser: anonUser)); + }, + ); + }); + } + + Future _loadHistoricalUsers() async { + final result = await UserBackendService.getAnonUser(); + result.fold( + (anonUser) { + add(AnonUserEvent.didLoadAnonUsers([anonUser])); + }, + (error) { + if (error.code != ErrorCode.RecordNotFound) { + Log.error(error); + } + }, + ); + } +} + +@freezed +class AnonUserEvent with _$AnonUserEvent { + const factory AnonUserEvent.initial() = _Initial; + const factory AnonUserEvent.didLoadAnonUsers( + List historicalUsers, + ) = _DidLoadHistoricalUsers; + const factory AnonUserEvent.openAnonUser(UserProfilePB anonUser) = + _OpenHistoricalUser; +} + +@freezed +class AnonUserState with _$AnonUserState { + const factory AnonUserState({ + required List anonUsers, + required UserProfilePB? openedAnonUser, + }) = _AnonUserState; + + factory AnonUserState.initial() => const AnonUserState( + anonUsers: [], + openedAnonUser: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart new file mode 100644 index 0000000000000..149bddc951125 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/appflowy_cloud_task.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/auth/backend_auth_service.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'auth_error.dart'; + +class AppFlowyCloudAuthService implements AuthService { + AppFlowyCloudAuthService(); + + final BackendAuthService _backendAuthService = BackendAuthService( + AuthenticatorPB.AppFlowyCloud, + ); + + @override + Future> signUp({ + required String name, + required String email, + required String password, + Map params = const {}, + }) async { + throw UnimplementedError(); + } + + @override + Future> signInWithEmailPassword({ + required String email, + required String password, + Map params = const {}, + }) async { + throw UnimplementedError(); + } + + @override + Future> signUpWithOAuth({ + required String platform, + Map params = const {}, + }) async { + final provider = ProviderTypePBExtension.fromPlatform(platform); + + // Get the oauth url from the backend + final result = await UserEventGetOauthURLWithProvider( + OauthProviderPB.create()..provider = provider, + ).send(); + + return result.fold( + (data) async { + // Open the webview with oauth url + final uri = Uri.parse(data.oauthUrl); + final isSuccess = await afLaunchUri( + uri, + mode: LaunchMode.externalApplication, + webOnlyWindowName: '_self', + ); + + final completer = Completer>(); + if (isSuccess) { + // The [AppFlowyCloudDeepLink] must be registered before using the + // [AppFlowyCloudAuthService]. + if (getIt.isRegistered()) { + getIt().registerCompleter(completer); + } else { + throw Exception('AppFlowyCloudDeepLink is not registered'); + } + } else { + completer.complete( + FlowyResult.failure(AuthError.unableToGetDeepLink), + ); + } + + return completer.future; + }, + (r) => FlowyResult.failure(r), + ); + } + + @override + Future signOut() async { + await _backendAuthService.signOut(); + } + + @override + Future> signUpAsGuest({ + Map params = const {}, + }) async { + return _backendAuthService.signUpAsGuest(); + } + + @override + Future> signInWithMagicLink({ + required String email, + Map params = const {}, + }) async { + return _backendAuthService.signInWithMagicLink( + email: email, + params: params, + ); + } + + @override + Future> getUser() async { + return UserBackendService.getCurrentUserProfile(); + } +} + +extension ProviderTypePBExtension on ProviderTypePB { + static ProviderTypePB fromPlatform(String platform) { + switch (platform) { + case 'github': + return ProviderTypePB.Github; + case 'google': + return ProviderTypePB.Google; + case 'discord': + return ProviderTypePB.Discord; + case 'apple': + return ProviderTypePB.Apple; + default: + throw UnimplementedError(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart new file mode 100644 index 0000000000000..5f8ea7cac616f --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_mock_auth_service.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/auth/backend_auth_service.dart'; +import 'package:appflowy/user/application/auth/device_id.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/material.dart'; + +/// Only used for testing. +class AppFlowyCloudMockAuthService implements AuthService { + AppFlowyCloudMockAuthService({String? email}) + : userEmail = email ?? "${uuid()}@appflowy.io"; + + final String userEmail; + + final BackendAuthService _appFlowyAuthService = + BackendAuthService(AuthenticatorPB.AppFlowyCloud); + + @override + Future> signUp({ + required String name, + required String email, + required String password, + Map params = const {}, + }) async { + throw UnimplementedError(); + } + + @override + Future> signInWithEmailPassword({ + required String email, + required String password, + Map params = const {}, + }) async { + throw UnimplementedError(); + } + + @override + Future> signUpWithOAuth({ + required String platform, + Map params = const {}, + }) async { + final payload = SignInUrlPayloadPB.create() + ..authenticator = AuthenticatorPB.AppFlowyCloud + // don't use nanoid here, the gotrue server will transform the email + ..email = userEmail; + + final deviceId = await getDeviceId(); + final getSignInURLResult = await UserEventGenerateSignInURL(payload).send(); + + return getSignInURLResult.fold( + (urlPB) async { + final payload = OauthSignInPB( + authenticator: AuthenticatorPB.AppFlowyCloud, + map: { + AuthServiceMapKeys.signInURL: urlPB.signInUrl, + AuthServiceMapKeys.deviceId: deviceId, + }, + ); + Log.info("UserEventOauthSignIn with payload: $payload"); + return UserEventOauthSignIn(payload).send().then((value) { + value.fold( + (l) => null, + (err) { + debugPrint("mock auth service Error: $err"); + Log.error(err); + }, + ); + return value; + }); + }, + (r) { + debugPrint("mock auth service error: $r"); + return FlowyResult.failure(r); + }, + ); + } + + @override + Future signOut() async { + await _appFlowyAuthService.signOut(); + } + + @override + Future> signUpAsGuest({ + Map params = const {}, + }) async { + return _appFlowyAuthService.signUpAsGuest(); + } + + @override + Future> signInWithMagicLink({ + required String email, + Map params = const {}, + }) async { + throw UnimplementedError(); + } + + @override + Future> getUser() async { + return UserBackendService.getCurrentUserProfile(); + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/auth/auth_error.dart b/frontend/appflowy_flutter/lib/user/application/auth/auth_error.dart new file mode 100644 index 0000000000000..14d1ed42d6fd2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/auth/auth_error.dart @@ -0,0 +1,20 @@ +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; + +class AuthError { + static final signInWithOauthError = FlowyError() + ..msg = 'sign in with oauth error -10003' + ..code = ErrorCode.UserUnauthorized; + + static final emptyDeepLink = FlowyError() + ..msg = 'Unexpected empty DeepLink' + ..code = ErrorCode.UnexpectedCalendarFieldType; + + static final deepLinkError = FlowyError() + ..msg = 'DeepLink error' + ..code = ErrorCode.Internal; + + static final unableToGetDeepLink = FlowyError() + ..msg = 'Unable to get the deep link' + ..code = ErrorCode.Internal; +} diff --git a/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart new file mode 100644 index 0000000000000..90c6954afe048 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/auth/auth_service.dart @@ -0,0 +1,85 @@ +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pbserver.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class AuthServiceMapKeys { + const AuthServiceMapKeys._(); + + static const String email = 'email'; + static const String deviceId = 'device_id'; + static const String signInURL = 'sign_in_url'; +} + +/// `AuthService` is an abstract class that defines methods related to user authentication. +/// +/// This service provides various methods for user sign-in, sign-up, +/// OAuth-based registration, and other related functionalities. +abstract class AuthService { + /// Authenticates a user with their email and password. + /// + /// - `email`: The email address of the user. + /// - `password`: The password of the user. + /// - `params`: Additional parameters for authentication (optional). + /// + /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. + + Future> signInWithEmailPassword({ + required String email, + required String password, + Map params, + }); + + /// Registers a new user with their name, email, and password. + /// + /// - `name`: The name of the user. + /// - `email`: The email address of the user. + /// - `password`: The password of the user. + /// - `params`: Additional parameters for registration (optional). + /// + /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. + Future> signUp({ + required String name, + required String email, + required String password, + Map params, + }); + + /// Registers a new user with an OAuth platform. + /// + /// - `platform`: The OAuth platform name. + /// - `params`: Additional parameters for OAuth registration (optional). + /// + /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. + Future> signUpWithOAuth({ + required String platform, + Map params, + }); + + /// Registers a user as a guest. + /// + /// - `params`: Additional parameters for guest registration (optional). + /// + /// Returns a default [UserProfilePB]. + Future> signUpAsGuest({ + Map params, + }); + + /// Authenticates a user with a magic link sent to their email. + /// + /// - `email`: The email address of the user. + /// - `params`: Additional parameters for authentication with magic link (optional). + /// + /// Returns [UserProfilePB] if the user is authenticated, otherwise returns [FlowyError]. + Future> signInWithMagicLink({ + required String email, + Map params, + }); + + /// Signs out the currently authenticated user. + Future signOut(); + + /// Retrieves the currently authenticated user's profile. + /// + /// Returns [UserProfilePB] if the user has signed in, otherwise returns [FlowyError]. + Future> getUser(); +} diff --git a/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart new file mode 100644 index 0000000000000..9147fb4fb90ea --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/auth/backend_auth_service.dart @@ -0,0 +1,110 @@ +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/auth.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show SignInPayloadPB, SignUpPayloadPB, UserProfilePB; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/uuid.dart'; + +import '../../../generated/locale_keys.g.dart'; +import 'device_id.dart'; + +class BackendAuthService implements AuthService { + BackendAuthService(this.authType); + + final AuthenticatorPB authType; + + @override + Future> signInWithEmailPassword({ + required String email, + required String password, + Map params = const {}, + }) async { + final request = SignInPayloadPB.create() + ..email = email + ..password = password + ..authType = authType + ..deviceId = await getDeviceId(); + final response = UserEventSignInWithEmailPassword(request).send(); + return response.then((value) => value); + } + + @override + Future> signUp({ + required String name, + required String email, + required String password, + Map params = const {}, + }) async { + final request = SignUpPayloadPB.create() + ..name = name + ..email = email + ..password = password + ..authType = authType + ..deviceId = await getDeviceId(); + final response = await UserEventSignUp(request).send().then( + (value) => value, + ); + return response; + } + + @override + Future signOut({ + Map params = const {}, + }) async { + await UserEventSignOut().send(); + return; + } + + @override + Future> signUpAsGuest({ + Map params = const {}, + }) async { + const password = "Guest!@123456"; + final uid = uuid(); + final userEmail = "$uid@appflowy.io"; + + final request = SignUpPayloadPB.create() + ..name = LocaleKeys.defaultUsername.tr() + ..email = userEmail + ..password = password + // When sign up as guest, the auth type is always local. + ..authType = AuthenticatorPB.Local + ..deviceId = await getDeviceId(); + final response = await UserEventSignUp(request).send().then( + (value) => value, + ); + return response; + } + + @override + Future> signUpWithOAuth({ + required String platform, + AuthenticatorPB authType = AuthenticatorPB.Local, + Map params = const {}, + }) async { + return FlowyResult.failure( + FlowyError.create() + ..code = ErrorCode.Internal + ..msg = "Unsupported sign up action", + ); + } + + @override + Future> getUser() async { + return UserBackendService.getCurrentUserProfile(); + } + + @override + Future> signInWithMagicLink({ + required String email, + Map params = const {}, + }) async { + // No need to pass the redirect URL. + return UserBackendService.signInWithMagicLink(email, ''); + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart b/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart new file mode 100644 index 0000000000000..2d7fe580aebf3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/auth/device_id.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/services.dart'; + +final DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + +Future getDeviceId() async { + if (integrationMode().isTest) { + return "test_device_id"; + } + + String? deviceId; + try { + if (Platform.isAndroid) { + final AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + deviceId = androidInfo.device; + } else if (Platform.isIOS) { + final IosDeviceInfo iosInfo = await deviceInfo.iosInfo; + deviceId = iosInfo.identifierForVendor; + } else if (Platform.isMacOS) { + final MacOsDeviceInfo macInfo = await deviceInfo.macOsInfo; + deviceId = macInfo.systemGUID; + } else if (Platform.isWindows) { + final WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo; + deviceId = windowsInfo.deviceId; + } else if (Platform.isLinux) { + final LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo; + deviceId = linuxInfo.machineId; + } + } on PlatformException { + Log.error('Failed to get platform version'); + } + return deviceId ?? ''; +} diff --git a/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart b/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart new file mode 100644 index 0000000000000..19b8101ae8a9e --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/encrypt_secret_bloc.dart @@ -0,0 +1,114 @@ +import 'package:appflowy/plugins/database/application/defines.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'auth/auth_service.dart'; + +part 'encrypt_secret_bloc.freezed.dart'; + +class EncryptSecretBloc extends Bloc { + EncryptSecretBloc({required this.user}) + : super(EncryptSecretState.initial()) { + _dispatch(); + } + + final UserProfilePB user; + + void _dispatch() { + on((event, emit) async { + await event.when( + setEncryptSecret: (secret) async { + if (isLoading()) { + return; + } + + final payload = UserSecretPB.create() + ..encryptionSecret = secret + ..encryptionSign = user.encryptionSign + ..encryptionType = user.encryptionType + ..userId = user.id; + final result = await UserEventSetEncryptionSecret(payload).send(); + if (!isClosed) { + add(EncryptSecretEvent.didFinishCheck(result)); + } + emit( + state.copyWith( + loadingState: const LoadingState.loading(), + successOrFail: null, + ), + ); + }, + cancelInputSecret: () async { + await getIt().signOut(); + emit( + state.copyWith( + successOrFail: null, + isSignOut: true, + ), + ); + }, + didFinishCheck: (result) { + result.fold( + (unit) { + emit( + state.copyWith( + loadingState: const LoadingState.loading(), + successOrFail: result, + ), + ); + }, + (err) { + emit( + state.copyWith( + loadingState: LoadingState.finish(FlowyResult.failure(err)), + successOrFail: result, + ), + ); + }, + ); + }, + ); + }); + } + + bool isLoading() { + final loadingState = state.loadingState; + if (loadingState != null) { + return loadingState.when( + loading: () => true, + finish: (_) => false, + idle: () => false, + ); + } + return false; + } +} + +@freezed +class EncryptSecretEvent with _$EncryptSecretEvent { + const factory EncryptSecretEvent.setEncryptSecret(String secret) = + _SetEncryptSecret; + const factory EncryptSecretEvent.didFinishCheck( + FlowyResult result, + ) = _DidFinishCheck; + const factory EncryptSecretEvent.cancelInputSecret() = _CancelInputSecret; +} + +@freezed +class EncryptSecretState with _$EncryptSecretState { + const factory EncryptSecretState({ + required FlowyResult? successOrFail, + required bool isSignOut, + LoadingState? loadingState, + }) = _EncryptSecretState; + + factory EncryptSecretState.initial() => const EncryptSecretState( + successOrFail: null, + isSignOut: false, + ); +} diff --git a/frontend/appflowy_flutter/lib/user/application/notification_filter/notification_filter_bloc.dart b/frontend/appflowy_flutter/lib/user/application/notification_filter/notification_filter_bloc.dart new file mode 100644 index 0000000000000..53ba478b4109d --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/notification_filter/notification_filter_bloc.dart @@ -0,0 +1,38 @@ +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'notification_filter_bloc.freezed.dart'; + +class NotificationFilterBloc + extends Bloc { + NotificationFilterBloc() : super(const NotificationFilterState()) { + on((event, emit) async { + event.when( + reset: () => emit(const NotificationFilterState()), + toggleShowUnreadsOnly: () => emit( + state.copyWith(showUnreadsOnly: !state.showUnreadsOnly), + ), + ); + }); + } +} + +@freezed +class NotificationFilterEvent with _$NotificationFilterEvent { + const factory NotificationFilterEvent.toggleShowUnreadsOnly() = + _ToggleShowUnreadsOnly; + + const factory NotificationFilterEvent.reset() = _Reset; +} + +@freezed +class NotificationFilterState with _$NotificationFilterState { + const NotificationFilterState._(); + + const factory NotificationFilterState({ + @Default(false) bool showUnreadsOnly, + }) = _NotificationFilterState; + + // If state is not default values, then there are custom changes + bool get hasFilters => showUnreadsOnly != false; +} diff --git a/frontend/appflowy_flutter/lib/user/application/prelude.dart b/frontend/appflowy_flutter/lib/user/application/prelude.dart new file mode 100644 index 0000000000000..2bd1c18cb7821 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/prelude.dart @@ -0,0 +1,4 @@ +export 'auth/backend_auth_service.dart'; +export './sign_in_bloc.dart'; +export './sign_up_bloc.dart'; +export './splash_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart new file mode 100644 index 0000000000000..24f53e48e80d9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_bloc.dart @@ -0,0 +1,537 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/list_extension.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/user/application/reminder/reminder_service.dart'; +import 'package:appflowy/user/application/user_settings_service.dart'; +import 'package:appflowy/util/int64_extension.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/application/notification/notification_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'reminder_bloc.freezed.dart'; + +class ReminderBloc extends Bloc { + ReminderBloc() : super(ReminderState()) { + Log.info('ReminderBloc created'); + + _actionBloc = getIt(); + _reminderService = const ReminderService(); + timer = _periodicCheck(); + + _dispatch(); + } + + late final ActionNavigationBloc _actionBloc; + late final ReminderService _reminderService; + late final Timer timer; + + void _dispatch() { + on( + (event, emit) async { + await event.when( + started: () async { + Log.info('Start fetching reminders'); + + final result = await _reminderService.fetchReminders(); + + result.fold( + (reminders) { + Log.info('Fetched reminders on startup: ${reminders.length}'); + emit(state.copyWith(reminders: reminders)); + }, + (error) => Log.error('Failed to fetch reminders: $error'), + ); + }, + remove: (reminderId) async { + final result = await _reminderService.removeReminder( + reminderId: reminderId, + ); + + result.fold( + (_) { + Log.info('Removed reminder: $reminderId'); + final reminders = [...state.reminders]; + reminders.removeWhere((e) => e.id == reminderId); + emit(state.copyWith(reminders: reminders)); + }, + (error) => Log.error( + 'Failed to remove reminder($reminderId): $error', + ), + ); + }, + add: (reminder) async { + // check the timestamp in the reminder + if (reminder.createdAt == null) { + reminder.freeze(); + reminder = reminder.rebuild((update) { + update.meta[ReminderMetaKeys.createdAt] = + DateTime.now().millisecondsSinceEpoch.toString(); + }); + } + + final result = await _reminderService.addReminder( + reminder: reminder, + ); + + return result.fold( + (_) { + Log.info('Added reminder: ${reminder.id}'); + Log.info('Before adding reminder: ${state.reminders.length}'); + final reminders = [...state.reminders, reminder]; + Log.info('After adding reminder: ${reminders.length}'); + emit(state.copyWith(reminders: reminders)); + }, + (error) => Log.error('Failed to add reminder: $error'), + ); + }, + addById: (reminderId, objectId, scheduledAt, meta) async => add( + ReminderEvent.add( + reminder: ReminderPB( + id: reminderId, + objectId: objectId, + title: LocaleKeys.reminderNotification_title.tr(), + message: LocaleKeys.reminderNotification_message.tr(), + scheduledAt: scheduledAt, + isAck: scheduledAt.toDateTime().isBefore(DateTime.now()), + meta: meta, + ), + ), + ), + update: (updateObject) async { + final reminder = state.reminders.firstWhereOrNull( + (r) => r.id == updateObject.id, + ); + + if (reminder == null) { + return; + } + + final newReminder = updateObject.merge(a: reminder); + final failureOrUnit = await _reminderService.updateReminder( + reminder: newReminder, + ); + + Log.info('Updating reminder: ${reminder.id}'); + + failureOrUnit.fold( + (_) { + Log.info('Updated reminder: ${reminder.id}'); + final index = + state.reminders.indexWhere((r) => r.id == reminder.id); + final reminders = [...state.reminders]; + reminders.replaceRange(index, index + 1, [newReminder]); + emit(state.copyWith(reminders: reminders)); + }, + (error) => Log.error( + 'Failed to update reminder(${reminder.id}): $error', + ), + ); + }, + pressReminder: (reminderId, path, view) { + final reminder = + state.reminders.firstWhereOrNull((r) => r.id == reminderId); + + if (reminder == null) { + return; + } + + add( + ReminderEvent.update( + ReminderUpdate( + id: reminderId, + isRead: state.pastReminders.contains(reminder), + ), + ), + ); + + String? rowId; + if (view?.layout != ViewLayoutPB.Document) { + rowId = reminder.meta[ReminderMetaKeys.rowId]; + } + + final action = NavigationAction( + objectId: reminder.objectId, + arguments: { + ActionArgumentKeys.view: view, + ActionArgumentKeys.nodePath: path, + ActionArgumentKeys.rowId: rowId, + }, + ); + + if (!isClosed) { + _actionBloc.add( + ActionNavigationEvent.performAction( + action: action, + nextActions: [ + action.copyWith( + type: rowId != null + ? ActionType.openRow + : ActionType.jumpToBlock, + ), + ], + ), + ); + } + }, + markAsRead: (reminderIds) async { + final reminders = await _onMarkAsRead(reminderIds: reminderIds); + + Log.info('Marked reminders as read: $reminderIds'); + + emit( + state.copyWith( + reminders: reminders, + ), + ); + }, + archive: (reminderIds) async { + final reminders = await _onArchived( + isArchived: true, + reminderIds: reminderIds, + ); + + Log.info('Archived reminders: $reminderIds'); + + emit( + state.copyWith( + reminders: reminders, + ), + ); + }, + markAllRead: () async { + final reminders = await _onMarkAsRead(); + + Log.info('Marked all reminders as read'); + + emit( + state.copyWith( + reminders: reminders, + ), + ); + }, + archiveAll: () async { + final reminders = await _onArchived(isArchived: true); + + Log.info('Archived all reminders'); + + emit( + state.copyWith( + reminders: reminders, + ), + ); + }, + unarchiveAll: () async { + final reminders = await _onArchived(isArchived: false); + emit( + state.copyWith( + reminders: reminders, + ), + ); + }, + refresh: () async { + final result = await _reminderService.fetchReminders(); + + result.fold( + (reminders) { + Log.info('Fetched reminders on refresh: ${reminders.length}'); + emit(state.copyWith(reminders: reminders)); + }, + (error) => Log.error('Failed to fetch reminders: $error'), + ); + }, + ); + }, + ); + } + + /// Mark the reminder as read + /// + /// If the [reminderIds] is null, all unread reminders will be marked as read + /// Otherwise, only the reminders with the given IDs will be marked as read + Future> _onMarkAsRead({ + List? reminderIds, + }) async { + final Iterable remindersToUpdate; + + if (reminderIds != null) { + remindersToUpdate = state.reminders.where( + (reminder) => reminderIds.contains(reminder.id) && !reminder.isRead, + ); + } else { + // Get all reminders that are not matching the isArchived flag + remindersToUpdate = state.reminders.where( + (reminder) => !reminder.isRead, + ); + } + + for (final reminder in remindersToUpdate) { + reminder.isRead = true; + + await _reminderService.updateReminder(reminder: reminder); + Log.info('Mark reminder ${reminder.id} as read'); + } + + return state.reminders.map((e) { + if (reminderIds != null && !reminderIds.contains(e.id)) { + return e; + } + + if (e.isRead) { + return e; + } + + e.freeze(); + return e.rebuild((update) { + update.isRead = true; + }); + }).toList(); + } + + /// Archive or unarchive reminders + /// + /// If the [reminderIds] is null, all reminders will be archived + /// Otherwise, only the reminders with the given IDs will be archived or unarchived + Future> _onArchived({ + required bool isArchived, + List? reminderIds, + }) async { + final Iterable remindersToUpdate; + + if (reminderIds != null) { + remindersToUpdate = state.reminders.where( + (reminder) => + reminderIds.contains(reminder.id) && + reminder.isArchived != isArchived, + ); + } else { + // Get all reminders that are not matching the isArchived flag + remindersToUpdate = state.reminders.where( + (reminder) => reminder.isArchived != isArchived, + ); + } + + for (final reminder in remindersToUpdate) { + reminder.isRead = isArchived; + reminder.meta[ReminderMetaKeys.isArchived] = isArchived.toString(); + await _reminderService.updateReminder(reminder: reminder); + Log.info('Reminder ${reminder.id} is archived: $isArchived'); + } + + return state.reminders.map((e) { + if (reminderIds != null && !reminderIds.contains(e.id)) { + return e; + } + + if (e.isArchived == isArchived) { + return e; + } + + e.freeze(); + return e.rebuild((update) { + update.isRead = isArchived; + update.meta[ReminderMetaKeys.isArchived] = isArchived.toString(); + }); + }).toList(); + } + + Timer _periodicCheck() { + return Timer.periodic( + const Duration(minutes: 1), + (_) async { + final now = DateTime.now(); + + for (final reminder in state.upcomingReminders) { + if (reminder.isAck) { + continue; + } + + final scheduledAt = reminder.scheduledAt.toDateTime(); + + if (scheduledAt.isBefore(now)) { + final notificationSettings = + await UserSettingsBackendService().getNotificationSettings(); + if (notificationSettings.notificationsEnabled) { + NotificationMessage( + identifier: reminder.id, + title: LocaleKeys.reminderNotification_title.tr(), + body: LocaleKeys.reminderNotification_message.tr(), + onClick: () => _actionBloc.add( + ActionNavigationEvent.performAction( + action: NavigationAction(objectId: reminder.objectId), + ), + ), + ); + } + + add( + ReminderEvent.update( + ReminderUpdate(id: reminder.id, isAck: true), + ), + ); + } + } + }, + ); + } +} + +@freezed +class ReminderEvent with _$ReminderEvent { + // On startup we fetch all reminders and upcoming ones + const factory ReminderEvent.started() = _Started; + + // Remove a reminder + const factory ReminderEvent.remove({required String reminderId}) = _Remove; + + // Add a reminder + const factory ReminderEvent.add({required ReminderPB reminder}) = _Add; + + // Add a reminder + const factory ReminderEvent.addById({ + required String reminderId, + required String objectId, + required Int64 scheduledAt, + @Default(null) Map? meta, + }) = _AddById; + + // Update a reminder (eg. isAck, isRead, etc.) + const factory ReminderEvent.update(ReminderUpdate update) = _Update; + + // Event to mark specific reminders as read, takes a list of reminder IDs + const factory ReminderEvent.markAsRead(List reminderIds) = + _MarkAsRead; + + // Event to mark all unread reminders as read + const factory ReminderEvent.markAllRead() = _MarkAllRead; + + // Event to archive specific reminders, takes a list of reminder IDs + const factory ReminderEvent.archive(List reminderIds) = _Archive; + + // Event to archive all reminders + const factory ReminderEvent.archiveAll() = _ArchiveAll; + + // Event to unarchive all reminders + const factory ReminderEvent.unarchiveAll() = _UnarchiveAll; + + // Event to handle reminder press action + const factory ReminderEvent.pressReminder({ + required String reminderId, + @Default(null) int? path, + @Default(null) ViewPB? view, + }) = _PressReminder; + + // Event to refresh reminders + const factory ReminderEvent.refresh() = _Refresh; +} + +/// Object used to merge updates with +/// a [ReminderPB] +/// +class ReminderUpdate { + ReminderUpdate({ + required this.id, + this.isAck, + this.isRead, + this.scheduledAt, + this.includeTime, + this.isArchived, + this.date, + }); + + final String id; + final bool? isAck; + final bool? isRead; + final DateTime? scheduledAt; + final bool? includeTime; + final bool? isArchived; + final DateTime? date; + + ReminderPB merge({required ReminderPB a}) { + final isAcknowledged = isAck == null && scheduledAt != null + ? scheduledAt!.isBefore(DateTime.now()) + : a.isAck; + + final meta = {...a.meta}; + if (includeTime != a.includeTime) { + meta[ReminderMetaKeys.includeTime] = includeTime.toString(); + } + + if (isArchived != a.isArchived) { + meta[ReminderMetaKeys.isArchived] = isArchived.toString(); + } + + if (date != a.date && date != null) { + meta[ReminderMetaKeys.date] = date!.millisecondsSinceEpoch.toString(); + } + + return ReminderPB( + id: a.id, + objectId: a.objectId, + scheduledAt: scheduledAt != null + ? Int64(scheduledAt!.millisecondsSinceEpoch ~/ 1000) + : a.scheduledAt, + isAck: isAcknowledged, + isRead: isRead ?? a.isRead, + title: a.title, + message: a.message, + meta: meta, + ); + } +} + +class ReminderState { + ReminderState({List? reminders}) { + _reminders = reminders ?? []; + + pastReminders = []; + upcomingReminders = []; + + if (_reminders.isEmpty) { + hasUnreads = false; + return; + } + + final now = DateTime.now(); + + bool hasUnreadReminders = false; + for (final reminder in _reminders) { + final scheduledDate = DateTime.fromMillisecondsSinceEpoch( + reminder.scheduledAt.toInt() * 1000, + ); + + if (scheduledDate.isBefore(now)) { + pastReminders.add(reminder); + + if (!hasUnreadReminders && !reminder.isRead) { + hasUnreadReminders = true; + } + } else { + upcomingReminders.add(reminder); + } + } + + hasUnreads = hasUnreadReminders; + } + + late final List _reminders; + List get reminders => _reminders.unique((e) => e.id); + + late final List pastReminders; + late final List upcomingReminders; + late final bool hasUnreads; + + ReminderState copyWith({List? reminders}) => + ReminderState(reminders: reminders ?? _reminders); +} diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart new file mode 100644 index 0000000000000..1b5aeaeb43efd --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_extension.dart @@ -0,0 +1,66 @@ +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; + +class ReminderMetaKeys { + static String includeTime = "include_time"; + static String blockId = "block_id"; + static String rowId = "row_id"; + static String createdAt = "created_at"; + static String isArchived = "is_archived"; + static String date = "date"; +} + +enum ReminderType { + past, + today, + other, +} + +extension ReminderExtension on ReminderPB { + bool? get includeTime { + final String? includeTimeStr = meta[ReminderMetaKeys.includeTime]; + + return includeTimeStr != null ? includeTimeStr == true.toString() : null; + } + + String? get blockId => meta[ReminderMetaKeys.blockId]; + + String? get rowId => meta[ReminderMetaKeys.rowId]; + + int? get createdAt { + final t = meta[ReminderMetaKeys.createdAt]; + return t != null ? int.tryParse(t) : null; + } + + bool get isArchived { + final t = meta[ReminderMetaKeys.isArchived]; + return t != null ? t == true.toString() : false; + } + + DateTime? get date { + final t = meta[ReminderMetaKeys.date]; + return t != null ? DateTime.fromMillisecondsSinceEpoch(int.parse(t)) : null; + } + + ReminderType get type { + final date = this.date?.millisecondsSinceEpoch; + + if (date == null) { + return ReminderType.other; + } + + final now = DateTime.now().millisecondsSinceEpoch; + + if (date < now) { + return ReminderType.past; + } + + final difference = date - now; + const oneDayInMilliseconds = 24 * 60 * 60 * 1000; + + if (difference < oneDayInMilliseconds) { + return ReminderType.today; + } + + return ReminderType.other; + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart new file mode 100644 index 0000000000000..eed44f9cedafc --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/reminder/reminder_service.dart @@ -0,0 +1,65 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +/// Interface for a Reminder Service that handles +/// communication to the backend +/// +abstract class IReminderService { + Future, FlowyError>> fetchReminders(); + + Future> removeReminder({ + required String reminderId, + }); + + Future> addReminder({ + required ReminderPB reminder, + }); + + Future> updateReminder({ + required ReminderPB reminder, + }); +} + +class ReminderService implements IReminderService { + const ReminderService(); + + @override + Future> addReminder({ + required ReminderPB reminder, + }) async { + final unitOrFailure = await UserEventCreateReminder(reminder).send(); + + return unitOrFailure; + } + + @override + Future> updateReminder({ + required ReminderPB reminder, + }) async { + final unitOrFailure = await UserEventUpdateReminder(reminder).send(); + + return unitOrFailure; + } + + @override + Future, FlowyError>> fetchReminders() async { + final resultOrFailure = await UserEventGetAllReminders().send(); + + return resultOrFailure.fold( + (s) => FlowyResult.success(s.items), + (e) => FlowyResult.failure(e), + ); + } + + @override + Future> removeReminder({ + required String reminderId, + }) async { + final request = ReminderIdentifierPB(id: reminderId); + final unitOrFailure = await UserEventRemoveReminder(request).send(); + + return unitOrFailure; + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart new file mode 100644 index 0000000000000..6fda156567956 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/sign_in_bloc.dart @@ -0,0 +1,286 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/appflowy_cloud_task.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_in_bloc.freezed.dart'; + +class SignInBloc extends Bloc { + SignInBloc(this.authService) : super(SignInState.initial()) { + if (isAppFlowyCloudEnabled) { + deepLinkStateListener = + getIt().subscribeDeepLinkLoadingState((value) { + if (isClosed) return; + + add(SignInEvent.deepLinkStateChange(value)); + }); + } + + on( + (event, emit) async { + await event.when( + signedInWithUserEmailAndPassword: () async => _onSignIn(emit), + signedInWithOAuth: (platform) async => + _onSignInWithOAuth(emit, platform), + signedInAsGuest: () async => _onSignInAsGuest(emit), + signedWithMagicLink: (email) async => + _onSignInWithMagicLink(emit, email), + deepLinkStateChange: (result) => _onDeepLinkStateChange(emit, result), + cancel: () { + emit( + state.copyWith( + isSubmitting: false, + emailError: null, + passwordError: null, + successOrFail: null, + ), + ); + }, + emailChanged: (email) async { + emit( + state.copyWith( + email: email, + emailError: null, + successOrFail: null, + ), + ); + }, + passwordChanged: (password) async { + emit( + state.copyWith( + password: password, + passwordError: null, + successOrFail: null, + ), + ); + }, + switchLoginType: (type) { + emit(state.copyWith(loginType: type)); + }, + ); + }, + ); + } + + final AuthService authService; + VoidCallback? deepLinkStateListener; + + @override + Future close() { + deepLinkStateListener?.call(); + if (isAppFlowyCloudEnabled && deepLinkStateListener != null) { + getIt().unsubscribeDeepLinkLoadingState( + deepLinkStateListener!, + ); + } + return super.close(); + } + + Future _onDeepLinkStateChange( + Emitter emit, + DeepLinkResult result, + ) async { + final deepLinkState = result.state; + + switch (deepLinkState) { + case DeepLinkState.none: + break; + case DeepLinkState.loading: + emit( + state.copyWith( + isSubmitting: true, + emailError: null, + passwordError: null, + successOrFail: null, + ), + ); + case DeepLinkState.finish: + final newState = result.result?.fold( + (s) => state.copyWith( + isSubmitting: false, + successOrFail: FlowyResult.success(s), + ), + (f) => _stateFromCode(f), + ); + if (newState != null) { + emit(newState); + } + } + } + + Future _onSignIn(Emitter emit) async { + final result = await authService.signInWithEmailPassword( + email: state.email ?? '', + password: state.password ?? '', + ); + emit( + result.fold( + (userProfile) => state.copyWith( + isSubmitting: false, + successOrFail: FlowyResult.success(userProfile), + ), + (error) => _stateFromCode(error), + ), + ); + } + + Future _onSignInWithOAuth( + Emitter emit, + String platform, + ) async { + emit( + state.copyWith( + isSubmitting: true, + emailError: null, + passwordError: null, + successOrFail: null, + ), + ); + + final result = await authService.signUpWithOAuth(platform: platform); + emit( + result.fold( + (userProfile) => state.copyWith( + isSubmitting: false, + successOrFail: FlowyResult.success(userProfile), + ), + (error) => _stateFromCode(error), + ), + ); + } + + Future _onSignInWithMagicLink( + Emitter emit, + String email, + ) async { + emit( + state.copyWith( + isSubmitting: true, + emailError: null, + passwordError: null, + successOrFail: null, + ), + ); + + final result = await authService.signInWithMagicLink(email: email); + + emit( + result.fold( + (userProfile) => state.copyWith(isSubmitting: true), + (error) => _stateFromCode(error), + ), + ); + } + + Future _onSignInAsGuest( + Emitter emit, + ) async { + emit( + state.copyWith( + isSubmitting: true, + emailError: null, + passwordError: null, + successOrFail: null, + ), + ); + + final result = await authService.signUpAsGuest(); + emit( + result.fold( + (userProfile) => state.copyWith( + isSubmitting: false, + successOrFail: FlowyResult.success(userProfile), + ), + (error) => _stateFromCode(error), + ), + ); + } + + SignInState _stateFromCode(FlowyError error) { + Log.error('SignInState _stateFromCode: ${error.msg}'); + + switch (error.code) { + case ErrorCode.EmailFormatInvalid: + return state.copyWith( + isSubmitting: false, + emailError: error.msg, + passwordError: null, + ); + case ErrorCode.PasswordFormatInvalid: + return state.copyWith( + isSubmitting: false, + passwordError: error.msg, + emailError: null, + ); + case ErrorCode.UserUnauthorized: + return state.copyWith( + isSubmitting: false, + successOrFail: FlowyResult.failure( + FlowyError(msg: LocaleKeys.signIn_limitRateError.tr()), + ), + ); + default: + return state.copyWith( + isSubmitting: false, + successOrFail: FlowyResult.failure( + FlowyError(msg: LocaleKeys.signIn_generalError.tr()), + ), + ); + } + } +} + +@freezed +class SignInEvent with _$SignInEvent { + const factory SignInEvent.signedInWithUserEmailAndPassword() = + SignedInWithUserEmailAndPassword; + const factory SignInEvent.signedInWithOAuth(String platform) = + SignedInWithOAuth; + const factory SignInEvent.signedInAsGuest() = SignedInAsGuest; + const factory SignInEvent.signedWithMagicLink(String email) = + SignedWithMagicLink; + const factory SignInEvent.emailChanged(String email) = EmailChanged; + const factory SignInEvent.passwordChanged(String password) = PasswordChanged; + const factory SignInEvent.deepLinkStateChange(DeepLinkResult result) = + DeepLinkStateChange; + const factory SignInEvent.cancel() = _Cancel; + const factory SignInEvent.switchLoginType(LoginType type) = _SwitchLoginType; +} + +// we support sign in directly without sign up, but we want to allow the users to sign up if they want to +// this type is only for the UI to know which form to show +enum LoginType { + signIn, + signUp, +} + +@freezed +class SignInState with _$SignInState { + const factory SignInState({ + String? email, + String? password, + required bool isSubmitting, + required String? passwordError, + required String? emailError, + required FlowyResult? successOrFail, + @Default(LoginType.signIn) LoginType loginType, + }) = _SignInState; + + factory SignInState.initial() => const SignInState( + isSubmitting: false, + passwordError: null, + emailError: null, + successOrFail: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/user/application/sign_up_bloc.dart b/frontend/appflowy_flutter/lib/user/application/sign_up_bloc.dart new file mode 100644 index 0000000000000..1935d00c6d8e2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/sign_up_bloc.dart @@ -0,0 +1,181 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_up_bloc.freezed.dart'; + +class SignUpBloc extends Bloc { + SignUpBloc(this.authService) : super(SignUpState.initial()) { + _dispatch(); + } + + final AuthService authService; + + void _dispatch() { + on( + (event, emit) async { + await event.map( + signUpWithUserEmailAndPassword: (e) async { + await _performActionOnSignUp(emit); + }, + emailChanged: (_EmailChanged value) async { + emit( + state.copyWith( + email: value.email, + emailError: null, + successOrFail: null, + ), + ); + }, + passwordChanged: (_PasswordChanged value) async { + emit( + state.copyWith( + password: value.password, + passwordError: null, + successOrFail: null, + ), + ); + }, + repeatPasswordChanged: (_RepeatPasswordChanged value) async { + emit( + state.copyWith( + repeatedPassword: value.password, + repeatPasswordError: null, + successOrFail: null, + ), + ); + }, + ); + }, + ); + } + + Future _performActionOnSignUp(Emitter emit) async { + emit( + state.copyWith( + isSubmitting: true, + successOrFail: null, + ), + ); + + final password = state.password; + final repeatedPassword = state.repeatedPassword; + if (password == null) { + emit( + state.copyWith( + isSubmitting: false, + passwordError: LocaleKeys.signUp_emptyPasswordError.tr(), + ), + ); + return; + } + + if (repeatedPassword == null) { + emit( + state.copyWith( + isSubmitting: false, + repeatPasswordError: LocaleKeys.signUp_repeatPasswordEmptyError.tr(), + ), + ); + return; + } + + if (password != repeatedPassword) { + emit( + state.copyWith( + isSubmitting: false, + repeatPasswordError: LocaleKeys.signUp_unmatchedPasswordError.tr(), + ), + ); + return; + } + + emit( + state.copyWith( + passwordError: null, + repeatPasswordError: null, + ), + ); + + final result = await authService.signUp( + name: state.email ?? '', + password: state.password ?? '', + email: state.email ?? '', + ); + emit( + result.fold( + (profile) => state.copyWith( + isSubmitting: false, + successOrFail: FlowyResult.success(profile), + emailError: null, + passwordError: null, + repeatPasswordError: null, + ), + (error) => stateFromCode(error), + ), + ); + } + + SignUpState stateFromCode(FlowyError error) { + switch (error.code) { + case ErrorCode.EmailFormatInvalid: + return state.copyWith( + isSubmitting: false, + emailError: error.msg, + passwordError: null, + successOrFail: null, + ); + case ErrorCode.PasswordFormatInvalid: + return state.copyWith( + isSubmitting: false, + passwordError: error.msg, + emailError: null, + successOrFail: null, + ); + default: + return state.copyWith( + isSubmitting: false, + successOrFail: FlowyResult.failure(error), + ); + } + } +} + +@freezed +class SignUpEvent with _$SignUpEvent { + const factory SignUpEvent.signUpWithUserEmailAndPassword() = + SignUpWithUserEmailAndPassword; + const factory SignUpEvent.emailChanged(String email) = _EmailChanged; + const factory SignUpEvent.passwordChanged(String password) = _PasswordChanged; + const factory SignUpEvent.repeatPasswordChanged(String password) = + _RepeatPasswordChanged; +} + +@freezed +class SignUpState with _$SignUpState { + const factory SignUpState({ + String? email, + String? password, + String? repeatedPassword, + required bool isSubmitting, + required String? passwordError, + required String? repeatPasswordError, + required String? emailError, + required FlowyResult? successOrFail, + }) = _SignUpState; + + factory SignUpState.initial() => const SignUpState( + isSubmitting: false, + passwordError: null, + repeatPasswordError: null, + emailError: null, + successOrFail: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/user/application/splash_bloc.dart b/frontend/appflowy_flutter/lib/user/application/splash_bloc.dart new file mode 100644 index 0000000000000..584c730fba238 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/splash_bloc.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/domain/auth_state.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'splash_bloc.freezed.dart'; + +class SplashBloc extends Bloc { + SplashBloc() : super(SplashState.initial()) { + on((event, emit) async { + await event.map( + getUser: (val) async { + final response = await getIt().getUser(); + final authState = response.fold( + (user) => AuthState.authenticated(user), + (error) => AuthState.unauthenticated(error), + ); + emit(state.copyWith(auth: authState)); + }, + ); + }); + } +} + +@freezed +class SplashEvent with _$SplashEvent { + const factory SplashEvent.getUser() = _GetUser; +} + +@freezed +class SplashState with _$SplashState { + const factory SplashState({ + required AuthState auth, + }) = _SplashState; + + factory SplashState.initial() => const SplashState( + auth: AuthState.initial(), + ); +} diff --git a/frontend/appflowy_flutter/lib/user/application/user_auth_listener.dart b/frontend/appflowy_flutter/lib/user/application/user_auth_listener.dart new file mode 100644 index 0000000000000..612c1eacd5e49 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/user_auth_listener.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/user_notification.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/auth.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/notification.pb.dart' + as user; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class UserAuthStateListener { + void Function(String)? _onInvalidAuth; + void Function()? _didSignIn; + StreamSubscription? _subscription; + UserNotificationParser? _userParser; + + void start({ + void Function(String)? onInvalidAuth, + void Function()? didSignIn, + }) { + _onInvalidAuth = onInvalidAuth; + _didSignIn = didSignIn; + + _userParser = UserNotificationParser( + id: "auth_state_change_notification", + callback: _userNotificationCallback, + ); + _subscription = RustStreamReceiver.listen((observable) { + _userParser?.parse(observable); + }); + } + + Future stop() async { + _userParser = null; + await _subscription?.cancel(); + _onInvalidAuth = null; + } + + void _userNotificationCallback( + user.UserNotification ty, + FlowyResult result, + ) { + switch (ty) { + case user.UserNotification.UserAuthStateChanged: + result.fold( + (payload) { + final pb = AuthStateChangedPB.fromBuffer(payload); + switch (pb.state) { + case AuthStatePB.AuthStateSignIn: + _didSignIn?.call(); + break; + case AuthStatePB.InvalidAuth: + _onInvalidAuth?.call(pb.message); + break; + default: + break; + } + }, + (r) => Log.error(r), + ); + break; + default: + break; + } + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/user_listener.dart b/frontend/appflowy_flutter/lib/user/application/user_listener.dart new file mode 100644 index 0000000000000..36d6039d405f9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/user_listener.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:appflowy/core/notification/folder_notification.dart'; +import 'package:appflowy/core/notification/user_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/notification.pb.dart' + as user; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +typedef DidUpdateUserWorkspaceCallback = void Function( + UserWorkspacePB workspace, +); +typedef DidUpdateUserWorkspacesCallback = void Function( + RepeatedUserWorkspacePB workspaces, +); +typedef UserProfileNotifyValue = FlowyResult; +typedef DidUpdateUserWorkspaceSetting = void Function( + UseAISettingPB settings, +); + +class UserListener { + UserListener({ + required UserProfilePB userProfile, + }) : _userProfile = userProfile; + + final UserProfilePB _userProfile; + + UserNotificationParser? _userParser; + StreamSubscription? _subscription; + PublishNotifier? _profileNotifier = PublishNotifier(); + + /// Update notification about _all_ of the users workspaces + /// + DidUpdateUserWorkspacesCallback? onUserWorkspaceListUpdated; + + /// Update notification about _one_ workspace + /// + DidUpdateUserWorkspaceCallback? onUserWorkspaceUpdated; + DidUpdateUserWorkspaceSetting? onUserWorkspaceSettingUpdated; + + void start({ + void Function(UserProfileNotifyValue)? onProfileUpdated, + DidUpdateUserWorkspacesCallback? onUserWorkspaceListUpdated, + void Function(UserWorkspacePB)? onUserWorkspaceUpdated, + DidUpdateUserWorkspaceSetting? onUserWorkspaceSettingUpdated, + }) { + if (onProfileUpdated != null) { + _profileNotifier?.addPublishListener(onProfileUpdated); + } + + this.onUserWorkspaceListUpdated = onUserWorkspaceListUpdated; + this.onUserWorkspaceUpdated = onUserWorkspaceUpdated; + this.onUserWorkspaceSettingUpdated = onUserWorkspaceSettingUpdated; + + _userParser = UserNotificationParser( + id: _userProfile.id.toString(), + callback: _userNotificationCallback, + ); + _subscription = RustStreamReceiver.listen((observable) { + _userParser?.parse(observable); + }); + } + + Future stop() async { + _userParser = null; + await _subscription?.cancel(); + _profileNotifier?.dispose(); + _profileNotifier = null; + } + + void _userNotificationCallback( + user.UserNotification ty, + FlowyResult result, + ) { + switch (ty) { + case user.UserNotification.DidUpdateUserProfile: + result.fold( + (payload) => _profileNotifier?.value = + FlowyResult.success(UserProfilePB.fromBuffer(payload)), + (error) => _profileNotifier?.value = FlowyResult.failure(error), + ); + break; + case user.UserNotification.DidUpdateUserWorkspaces: + result.map( + (r) { + final value = RepeatedUserWorkspacePB.fromBuffer(r); + onUserWorkspaceListUpdated?.call(value); + }, + ); + break; + case user.UserNotification.DidUpdateUserWorkspace: + result.map( + (r) => onUserWorkspaceUpdated?.call(UserWorkspacePB.fromBuffer(r)), + ); + case user.UserNotification.DidUpdateAISetting: + result.map( + (r) => + onUserWorkspaceSettingUpdated?.call(UseAISettingPB.fromBuffer(r)), + ); + break; + default: + break; + } + } +} + +typedef WorkspaceSettingNotifyValue + = FlowyResult; + +class FolderListener { + FolderListener(); + + final PublishNotifier _settingChangedNotifier = + PublishNotifier(); + + FolderNotificationListener? _listener; + + void start({ + void Function(WorkspaceSettingNotifyValue)? onSettingUpdated, + }) { + if (onSettingUpdated != null) { + _settingChangedNotifier.addPublishListener(onSettingUpdated); + } + + // The "current-workspace" is predefined in the backend. Do not try to + // modify it + _listener = FolderNotificationListener( + objectId: "current-workspace", + handler: _handleObservableType, + ); + } + + void _handleObservableType( + FolderNotification ty, + FlowyResult result, + ) { + switch (ty) { + case FolderNotification.DidUpdateWorkspaceSetting: + result.fold( + (payload) => _settingChangedNotifier.value = + FlowyResult.success(WorkspaceSettingPB.fromBuffer(payload)), + (error) => _settingChangedNotifier.value = FlowyResult.failure(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _settingChangedNotifier.dispose(); + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart new file mode 100644 index 0000000000000..5a75a4df3edf4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -0,0 +1,299 @@ +import 'dart:async'; + +import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; + +abstract class IUserBackendService { + Future> cancelSubscription( + String workspaceId, + SubscriptionPlanPB plan, + String? reason, + ); + Future> createSubscription( + String workspaceId, + SubscriptionPlanPB plan, + ); +} + +const _baseBetaUrl = 'https://beta.appflowy.com'; +const _baseProdUrl = 'https://appflowy.com'; + +class UserBackendService implements IUserBackendService { + UserBackendService({required this.userId}); + + final Int64 userId; + + static Future> + getCurrentUserProfile() async { + final result = await UserEventGetUserProfile().send(); + return result; + } + + Future> updateUserProfile({ + String? name, + String? password, + String? email, + String? iconUrl, + String? openAIKey, + String? stabilityAiKey, + }) { + final payload = UpdateUserProfilePayloadPB.create()..id = userId; + + if (name != null) { + payload.name = name; + } + + if (password != null) { + payload.password = password; + } + + if (email != null) { + payload.email = email; + } + + if (iconUrl != null) { + payload.iconUrl = iconUrl; + } + + if (openAIKey != null) { + payload.openaiKey = openAIKey; + } + + if (stabilityAiKey != null) { + payload.stabilityAiKey = stabilityAiKey; + } + + return UserEventUpdateUserProfile(payload).send(); + } + + Future> deleteWorkspace({ + required String workspaceId, + }) { + throw UnimplementedError(); + } + + static Future> signInWithMagicLink( + String email, + String redirectTo, + ) async { + final payload = MagicLinkSignInPB(email: email, redirectTo: redirectTo); + return UserEventMagicLinkSignIn(payload).send(); + } + + static Future> signOut() { + return UserEventSignOut().send(); + } + + Future> initUser() async { + return UserEventInitUser().send(); + } + + static Future> getAnonUser() async { + return UserEventGetAnonUser().send(); + } + + static Future> openAnonUser() async { + return UserEventOpenAnonUser().send(); + } + + Future, FlowyError>> getWorkspaces() { + return UserEventGetAllWorkspace().send().then((value) { + return value.fold( + (workspaces) => FlowyResult.success(workspaces.items), + (error) => FlowyResult.failure(error), + ); + }); + } + + Future> openWorkspace(String workspaceId) { + final payload = UserWorkspaceIdPB.create()..workspaceId = workspaceId; + return UserEventOpenWorkspace(payload).send(); + } + + static Future> getCurrentWorkspace() { + return FolderEventReadCurrentWorkspace().send().then((result) { + return result.fold( + (workspace) => FlowyResult.success(workspace), + (error) => FlowyResult.failure(error), + ); + }); + } + + Future> createWorkspace( + String name, + String desc, + ) { + final request = CreateWorkspacePayloadPB.create() + ..name = name + ..desc = desc; + return FolderEventCreateFolderWorkspace(request).send().then((result) { + return result.fold( + (workspace) => FlowyResult.success(workspace), + (error) => FlowyResult.failure(error), + ); + }); + } + + Future> createUserWorkspace( + String name, + ) { + final request = CreateWorkspacePB.create()..name = name; + return UserEventCreateWorkspace(request).send(); + } + + Future> deleteWorkspaceById( + String workspaceId, + ) { + final request = UserWorkspaceIdPB.create()..workspaceId = workspaceId; + return UserEventDeleteWorkspace(request).send(); + } + + Future> renameWorkspace( + String workspaceId, + String name, + ) { + final request = RenameWorkspacePB() + ..workspaceId = workspaceId + ..newName = name; + return UserEventRenameWorkspace(request).send(); + } + + Future> updateWorkspaceIcon( + String workspaceId, + String icon, + ) { + final request = ChangeWorkspaceIconPB() + ..workspaceId = workspaceId + ..newIcon = icon; + return UserEventChangeWorkspaceIcon(request).send(); + } + + Future> + getWorkspaceMembers( + String workspaceId, + ) async { + final data = QueryWorkspacePB()..workspaceId = workspaceId; + return UserEventGetWorkspaceMembers(data).send(); + } + + Future> addWorkspaceMember( + String workspaceId, + String email, + ) async { + final data = AddWorkspaceMemberPB() + ..workspaceId = workspaceId + ..email = email; + return UserEventAddWorkspaceMember(data).send(); + } + + Future> inviteWorkspaceMember( + String workspaceId, + String email, { + AFRolePB? role, + }) async { + final data = WorkspaceMemberInvitationPB() + ..workspaceId = workspaceId + ..inviteeEmail = email; + if (role != null) { + data.role = role; + } + return UserEventInviteWorkspaceMember(data).send(); + } + + Future> removeWorkspaceMember( + String workspaceId, + String email, + ) async { + final data = RemoveWorkspaceMemberPB() + ..workspaceId = workspaceId + ..email = email; + return UserEventRemoveWorkspaceMember(data).send(); + } + + Future> updateWorkspaceMember( + String workspaceId, + String email, + AFRolePB role, + ) async { + final data = UpdateWorkspaceMemberPB() + ..workspaceId = workspaceId + ..email = email + ..role = role; + return UserEventUpdateWorkspaceMember(data).send(); + } + + Future> leaveWorkspace( + String workspaceId, + ) async { + final data = UserWorkspaceIdPB.create()..workspaceId = workspaceId; + return UserEventLeaveWorkspace(data).send(); + } + + static Future> + getWorkspaceSubscriptionInfo(String workspaceId) { + final params = UserWorkspaceIdPB.create()..workspaceId = workspaceId; + return UserEventGetWorkspaceSubscriptionInfo(params).send(); + } + + Future> + getWorkspaceMember() async { + final data = WorkspaceMemberIdPB.create()..uid = userId; + + return UserEventGetMemberInfo(data).send(); + } + + @override + Future> createSubscription( + String workspaceId, + SubscriptionPlanPB plan, + ) { + final request = SubscribeWorkspacePB() + ..workspaceId = workspaceId + ..recurringInterval = RecurringIntervalPB.Year + ..workspaceSubscriptionPlan = plan + ..successUrl = + '${kDebugMode ? _baseBetaUrl : _baseProdUrl}/after-payment?plan=${plan.toRecognizable()}'; + return UserEventSubscribeWorkspace(request).send(); + } + + @override + Future> cancelSubscription( + String workspaceId, + SubscriptionPlanPB plan, [ + String? reason, + ]) { + final request = CancelWorkspaceSubscriptionPB() + ..workspaceId = workspaceId + ..plan = plan; + + if (reason != null) { + request.reason = reason; + } + + return UserEventCancelWorkspaceSubscription(request).send(); + } + + Future> updateSubscriptionPeriod( + String workspaceId, + SubscriptionPlanPB plan, + RecurringIntervalPB interval, + ) { + final request = UpdateWorkspaceSubscriptionPaymentPeriodPB() + ..workspaceId = workspaceId + ..plan = plan + ..recurringInterval = interval; + + return UserEventUpdateWorkspaceSubscriptionPaymentPeriod(request).send(); + } + + // NOTE: This function is irreversible and will delete the current user's account. + static Future> deleteCurrentAccount() { + return UserEventDeleteAccount().send(); + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/user_settings_service.dart b/frontend/appflowy_flutter/lib/user/application/user_settings_service.dart new file mode 100644 index 0000000000000..f9718a9134d7c --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/user_settings_service.dart @@ -0,0 +1,60 @@ +import 'package:appflowy_backend/appflowy_backend.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class UserSettingsBackendService { + Future getAppearanceSetting() async { + final result = await UserEventGetAppearanceSetting().send(); + + return result.fold( + (AppearanceSettingsPB setting) => setting, + (error) => + throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty), + ); + } + + Future> getUserSetting() { + return UserEventGetUserSetting().send(); + } + + Future> setAppearanceSetting( + AppearanceSettingsPB setting, + ) { + return UserEventSetAppearanceSetting(setting).send(); + } + + Future getDateTimeSettings() async { + final result = await UserEventGetDateTimeSettings().send(); + + return result.fold( + (DateTimeSettingsPB setting) => setting, + (error) => + throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty), + ); + } + + Future> setDateTimeSettings( + DateTimeSettingsPB settings, + ) async { + return UserEventSetDateTimeSettings(settings).send(); + } + + Future> setNotificationSettings( + NotificationSettingsPB settings, + ) async { + return UserEventSetNotificationSettings(settings).send(); + } + + Future getNotificationSettings() async { + final result = await UserEventGetNotificationSettings().send(); + + return result.fold( + (NotificationSettingsPB setting) => setting, + (error) => + throw FlowySDKException(ExceptionType.AppearanceSettingsIsEmpty), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart b/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart new file mode 100644 index 0000000000000..ce51fdd10b15f --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/application/workspace_error_bloc.dart @@ -0,0 +1,98 @@ +import 'package:appflowy/plugins/database/application/defines.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'workspace_error_bloc.freezed.dart'; + +class WorkspaceErrorBloc + extends Bloc { + WorkspaceErrorBloc({required this.userFolder, required FlowyError error}) + : super(WorkspaceErrorState.initial(error)) { + _dispatch(); + } + + final UserFolderPB userFolder; + + void _dispatch() { + on( + (event, emit) async { + await event.when( + init: () { + // _loadSnapshots(); + }, + resetWorkspace: () async { + emit(state.copyWith(loadingState: const LoadingState.loading())); + final payload = ResetWorkspacePB.create() + ..workspaceId = userFolder.workspaceId + ..uid = userFolder.uid; + final result = await UserEventResetWorkspace(payload).send(); + if (!isClosed) { + add(WorkspaceErrorEvent.didResetWorkspace(result)); + } + }, + didResetWorkspace: (result) { + result.fold( + (_) { + emit( + state.copyWith( + loadingState: LoadingState.finish(result), + workspaceState: const WorkspaceState.reset(), + ), + ); + }, + (err) { + emit(state.copyWith(loadingState: LoadingState.finish(result))); + }, + ); + }, + logout: () { + emit( + state.copyWith( + workspaceState: const WorkspaceState.logout(), + ), + ); + }, + ); + }, + ); + } +} + +@freezed +class WorkspaceErrorEvent with _$WorkspaceErrorEvent { + const factory WorkspaceErrorEvent.init() = _Init; + const factory WorkspaceErrorEvent.logout() = _DidLogout; + const factory WorkspaceErrorEvent.resetWorkspace() = _ResetWorkspace; + const factory WorkspaceErrorEvent.didResetWorkspace( + FlowyResult result, + ) = _DidResetWorkspace; +} + +@freezed +class WorkspaceErrorState with _$WorkspaceErrorState { + const factory WorkspaceErrorState({ + required FlowyError initialError, + LoadingState? loadingState, + required WorkspaceState workspaceState, + }) = _WorkspaceErrorState; + + factory WorkspaceErrorState.initial(FlowyError error) => WorkspaceErrorState( + initialError: error, + workspaceState: const WorkspaceState.initial(), + ); +} + +@freezed +class WorkspaceState with _$WorkspaceState { + const factory WorkspaceState.initial() = _Initial; + const factory WorkspaceState.logout() = _Logout; + const factory WorkspaceState.reset() = _Reset; + const factory WorkspaceState.createNewWorkspace() = _NewWorkspace; + const factory WorkspaceState.restoreFromSnapshot() = _RestoreFromSnapshot; +} diff --git a/frontend/appflowy_flutter/lib/user/domain/auth_state.dart b/frontend/appflowy_flutter/lib/user/domain/auth_state.dart new file mode 100644 index 0000000000000..61499d0107f41 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/domain/auth_state.dart @@ -0,0 +1,13 @@ +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +part 'auth_state.freezed.dart'; + +@freezed +class AuthState with _$AuthState { + const factory AuthState.authenticated(UserProfilePB userProfile) = + Authenticated; + const factory AuthState.unauthenticated(FlowyError error) = Unauthenticated; + const factory AuthState.initial() = _Initial; +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart b/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart new file mode 100644 index 0000000000000..a9b11cb42e8bf --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/anon_user.dart @@ -0,0 +1,97 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/anon_user_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class AnonUserList extends StatelessWidget { + const AnonUserList({required this.didOpenUser, super.key}); + + final VoidCallback didOpenUser; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => AnonUserBloc() + ..add( + const AnonUserEvent.initial(), + ), + child: BlocBuilder( + builder: (context, state) { + if (state.anonUsers.isEmpty) { + return const SizedBox.shrink(); + } else { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Opacity( + opacity: 0.6, + child: FlowyText.regular( + LocaleKeys.settings_menu_historicalUserListTooltip.tr(), + fontSize: 13, + maxLines: null, + ), + ), + const VSpace(6), + Expanded( + child: ListView.builder( + itemBuilder: (context, index) { + final user = state.anonUsers[index]; + return AnonUserItem( + key: ValueKey(user.id), + user: user, + isSelected: false, + didOpenUser: didOpenUser, + ); + }, + itemCount: state.anonUsers.length, + ), + ), + ], + ); + } + }, + ), + ); + } +} + +class AnonUserItem extends StatelessWidget { + const AnonUserItem({ + super.key, + required this.user, + required this.isSelected, + required this.didOpenUser, + }); + + final UserProfilePB user; + final bool isSelected; + final VoidCallback didOpenUser; + + @override + Widget build(BuildContext context) { + final icon = isSelected ? const FlowySvg(FlowySvgs.check_s) : null; + final isDisabled = + isSelected || user.authenticator != AuthenticatorPB.Local; + final desc = "${user.name}\t ${user.authenticator}\t"; + final child = SizedBox( + height: 30, + child: FlowyButton( + disable: isDisabled, + text: FlowyText.medium( + desc, + fontSize: 12, + ), + rightIcon: icon, + onTap: () { + context.read().add(AnonUserEvent.openAnonUser(user)); + didOpenUser(); + }, + ), + ); + return child; + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart new file mode 100644 index 0000000000000..0aeb92fc18e86 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_open_workspace_error.dart @@ -0,0 +1,38 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/presentation/router.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:flutter/material.dart'; + +void handleOpenWorkspaceError(BuildContext context, FlowyError error) { + Log.error(error); + switch (error.code) { + case ErrorCode.WorkspaceDataNotSync: + final userFolder = UserFolderPB.fromBuffer(error.payload); + getIt().pushWorkspaceErrorScreen(context, userFolder, error); + break; + case ErrorCode.InvalidEncryptSecret: + case ErrorCode.HttpError: + showToastNotification( + context, + message: error.msg, + type: ToastificationType.error, + ); + break; + default: + showToastNotification( + context, + message: error.msg, + type: ToastificationType.error, + callbacks: ToastificationCallbacks( + onDismissed: (_) { + getIt().signOut(); + runAppFlowy(); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart new file mode 100644 index 0000000000000..9abd417df3a91 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/helpers/handle_user_profile_result.dart @@ -0,0 +1,25 @@ +import 'package:appflowy/user/presentation/helpers/helpers.dart'; +import 'package:appflowy/user/presentation/presentation.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/material.dart'; + +void handleUserProfileResult( + FlowyResult userProfileResult, + BuildContext context, + AuthRouter authRouter, +) { + userProfileResult.fold( + (userProfile) { + if (userProfile.encryptionType == EncryptionTypePB.Symmetric) { + authRouter.pushEncryptionScreen(context, userProfile); + } else { + authRouter.goHomeScreen(context, userProfile); + } + }, + (error) { + handleOpenWorkspaceError(context, error); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart b/frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart new file mode 100644 index 0000000000000..084a36066618e --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/helpers/helpers.dart @@ -0,0 +1,2 @@ +export 'handle_open_workspace_error.dart'; +export 'handle_user_profile_result.dart'; diff --git a/frontend/appflowy_flutter/lib/user/presentation/presentation.dart b/frontend/appflowy_flutter/lib/user/presentation/presentation.dart new file mode 100644 index 0000000000000..baa67531634ce --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/presentation.dart @@ -0,0 +1,4 @@ +export 'screens/screens.dart'; +export 'widgets/widgets.dart'; +export 'anon_user.dart'; +export 'router.dart'; diff --git a/frontend/appflowy_flutter/lib/user/presentation/router.dart b/frontend/appflowy_flutter/lib/user/presentation/router.dart new file mode 100644 index 0000000000000..370d9c2062d0e --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/router.dart @@ -0,0 +1,141 @@ +import 'package:appflowy/mobile/presentation/home/mobile_home_page.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/presentation/screens/screens.dart'; +import 'package:appflowy/workspace/presentation/home/desktop_home_screen.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class AuthRouter { + void pushForgetPasswordScreen(BuildContext context) {} + + void pushWorkspaceStartScreen( + BuildContext context, + UserProfilePB userProfile, + ) { + getIt().pushWorkspaceStartScreen(context, userProfile); + } + + void pushSignUpScreen(BuildContext context) { + context.push(SignUpScreen.routeName); + } + + /// Navigates to the home screen based on the current workspace and platform. + /// + /// This function takes in a [BuildContext] and a [UserProfilePB] object to + /// determine the user's settings and then navigate to the appropriate home screen + /// (`MobileHomeScreen` for mobile platforms, `DesktopHomeScreen` for others). + /// + /// It first fetches the current workspace settings using [FolderEventGetCurrentWorkspace]. + /// If the workspace settings are successfully fetched, it navigates to the home screen. + /// If there's an error, it defaults to the workspace start screen. + /// + /// @param [context] BuildContext for navigating to the appropriate screen. + /// @param [userProfile] UserProfilePB object containing the details of the current user. + /// + Future goHomeScreen( + BuildContext context, + UserProfilePB userProfile, + ) async { + final result = await FolderEventGetCurrentWorkspaceSetting().send(); + result.fold( + (workspaceSetting) { + // Replace SignInScreen or SkipLogInScreen as root page. + // If user click back button, it will exit app rather than go back to SignInScreen or SkipLogInScreen + if (UniversalPlatform.isMobile) { + context.go( + MobileHomeScreen.routeName, + ); + } else { + context.go( + DesktopHomeScreen.routeName, + ); + } + }, + (error) => pushWorkspaceStartScreen(context, userProfile), + ); + } + + void pushEncryptionScreen( + BuildContext context, + UserProfilePB userProfile, + ) { + // After log in,push EncryptionScreen on the top SignInScreen + context.push( + EncryptSecretScreen.routeName, + extra: { + EncryptSecretScreen.argUser: userProfile, + EncryptSecretScreen.argKey: ValueKey(userProfile.id), + }, + ); + } + + Future pushWorkspaceErrorScreen( + BuildContext context, + UserFolderPB userFolder, + FlowyError error, + ) async { + await context.push( + WorkspaceErrorScreen.routeName, + extra: { + WorkspaceErrorScreen.argUserFolder: userFolder, + WorkspaceErrorScreen.argError: error, + }, + ); + } +} + +class SplashRouter { + // Unused for now, it was planed to be used in SignUpScreen. + // To let user choose workspace than navigate to corresponding home screen. + Future pushWorkspaceStartScreen( + BuildContext context, + UserProfilePB userProfile, + ) async { + await context.push( + WorkspaceStartScreen.routeName, + extra: { + WorkspaceStartScreen.argUserProfile: userProfile, + }, + ); + + final result = await FolderEventGetCurrentWorkspaceSetting().send(); + result.fold( + (workspaceSettingPB) => pushHomeScreen(context), + (r) => null, + ); + } + + void pushHomeScreen( + BuildContext context, + ) { + if (UniversalPlatform.isMobile) { + context.push( + MobileHomeScreen.routeName, + ); + } else { + context.push( + DesktopHomeScreen.routeName, + ); + } + } + + void goHomeScreen( + BuildContext context, + ) { + if (UniversalPlatform.isMobile) { + context.go( + MobileHomeScreen.routeName, + ); + } else { + context.go( + DesktopHomeScreen.routeName, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart new file mode 100644 index 0000000000000..f0b79ed9d2785 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/encrypt_secret_screen.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/presentation/helpers/helpers.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../application/encrypt_secret_bloc.dart'; + +class EncryptSecretScreen extends StatefulWidget { + const EncryptSecretScreen({required this.user, super.key}); + + final UserProfilePB user; + + static const routeName = '/EncryptSecretScreen'; + + // arguments used in GoRouter + static const argUser = 'user'; + static const argKey = 'key'; + + @override + State createState() => _EncryptSecretScreenState(); +} + +class _EncryptSecretScreenState extends State { + final TextEditingController _textEditingController = TextEditingController(); + + @override + void dispose() { + _textEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: BlocProvider( + create: (context) => EncryptSecretBloc(user: widget.user), + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (previous, current) => + previous.isSignOut != current.isSignOut, + listener: (context, state) async { + if (state.isSignOut) { + await runAppFlowy(); + } + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous.successOrFail != current.successOrFail, + listener: (context, state) async { + await state.successOrFail?.fold( + (unit) async { + await runAppFlowy(); + }, + (error) { + handleOpenWorkspaceError(context, error); + }, + ); + }, + ), + ], + child: BlocBuilder( + builder: (context, state) { + final indicator = state.loadingState?.when( + loading: () => const Center( + child: CircularProgressIndicator.adaptive(), + ), + finish: (result) => const SizedBox.shrink(), + idle: () => const SizedBox.shrink(), + ) ?? + const SizedBox.shrink(); + return Center( + child: SizedBox( + width: 300, + height: 160, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Opacity( + opacity: 0.6, + child: FlowyText.medium( + "${LocaleKeys.settings_menu_inputEncryptPrompt.tr()} ${widget.user.email}", + fontSize: 14, + maxLines: 10, + ), + ), + const VSpace(6), + SizedBox( + width: 300, + child: FlowyTextField( + controller: _textEditingController, + hintText: + LocaleKeys.settings_menu_inputTextFieldHint.tr(), + onChanged: (_) {}, + ), + ), + OkCancelButton( + alignment: MainAxisAlignment.end, + onOkPressed: () => + context.read().add( + EncryptSecretEvent.setEncryptSecret( + _textEditingController.text, + ), + ), + onCancelPressed: () => context + .read() + .add(const EncryptSecretEvent.cancelInputSecret()), + mode: TextButtonMode.normal, + ), + const VSpace(6), + indicator, + ], + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart new file mode 100644 index 0000000000000..088da389789d6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/screens.dart @@ -0,0 +1,7 @@ +export 'sign_in_screen/sign_in_screen.dart'; +export 'skip_log_in_screen.dart'; +export 'splash_screen.dart'; +export 'sign_up_screen.dart'; +export 'encrypt_secret_screen.dart'; +export 'workspace_error_screen.dart'; +export 'workspace_start_screen/workspace_start_screen.dart'; diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart new file mode 100644 index 0000000000000..94b43478693c5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart @@ -0,0 +1,135 @@ +import 'package:appflowy/core/frameless_window.dart'; +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/settings/show_settings.dart'; +import 'package:appflowy/shared/window_title_bar.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy/user/presentation/widgets/widgets.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class DesktopSignInScreen extends StatelessWidget { + const DesktopSignInScreen({ + super.key, + }); + + @override + Widget build(BuildContext context) { + const indicatorMinHeight = 4.0; + return BlocBuilder( + builder: (context, state) { + return Scaffold( + appBar: _buildAppBar(), + body: Center( + child: AuthFormContainer( + children: [ + const Spacer(), + + const VSpace(20), + + // logo and title + FlowyLogoTitle( + title: LocaleKeys.welcomeText.tr(), + logoSize: const Size(60, 60), + ), + const VSpace(20), + + // magic link sign in + const SignInWithMagicLinkButtons(), + const VSpace(20), + + // third-party sign in. + if (isAuthEnabled) ...[ + const _OrDivider(), + const VSpace(20), + const ThirdPartySignInButtons(), + const VSpace(20), + ], + + // sign in agreement + const SignInAgreement(), + + // loading status + const VSpace(indicatorMinHeight), + state.isSubmitting + ? const LinearProgressIndicator( + minHeight: indicatorMinHeight, + ) + : const VSpace(indicatorMinHeight), + const VSpace(20), + + const Spacer(), + + // anonymous sign in and settings + const Row( + mainAxisSize: MainAxisSize.min, + children: [ + DesktopSignInSettingsButton(), + HSpace(42), + SignInAnonymousButtonV2(), + ], + ), + const VSpace(16), + ], + ), + ), + ); + }, + ); + } + + PreferredSize _buildAppBar() { + return PreferredSize( + preferredSize: Size.fromHeight(UniversalPlatform.isWindows ? 40 : 60), + child: UniversalPlatform.isWindows + ? const WindowTitleBar() + : const MoveWindowDetector(), + ); + } +} + +class DesktopSignInSettingsButton extends StatelessWidget { + const DesktopSignInSettingsButton({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return FlowyButton( + useIntrinsicWidth: true, + text: FlowyText( + LocaleKeys.signIn_settings.tr(), + textAlign: TextAlign.center, + fontSize: 12.0, + // fontWeight: FontWeight.w500, + color: Colors.grey, + decoration: TextDecoration.underline, + ), + onTap: () { + showSimpleSettingsDialog(context); + }, + ); + } +} + +class _OrDivider extends StatelessWidget { + const _OrDivider(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Flexible(child: Divider(thickness: 1)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: FlowyText.regular(LocaleKeys.signIn_or.tr()), + ), + const Flexible(child: Divider(thickness: 1)), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart new file mode 100644 index 0000000000000..0d749586064e5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart @@ -0,0 +1,37 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MobileLoadingScreen extends StatelessWidget { + const MobileLoadingScreen({ + super.key, + }); + + @override + Widget build(BuildContext context) { + const double spacing = 16; + + return Scaffold( + appBar: FlowyAppBar( + showDivider: false, + onTapLeading: () => context.read().add( + const SignInEvent.cancel(), + ), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlowyText(LocaleKeys.signIn_signingInText.tr()), + const VSpace(spacing), + const CircularProgressIndicator(), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart new file mode 100644 index 0000000000000..863aadc49cf43 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart @@ -0,0 +1,125 @@ +import 'dart:io'; + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/setting/launch_settings_page.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class MobileSignInScreen extends StatelessWidget { + const MobileSignInScreen({ + super.key, + }); + + @override + Widget build(BuildContext context) { + const double spacing = 16; + final colorScheme = Theme.of(context).colorScheme; + return BlocBuilder( + builder: (context, state) { + return Scaffold( + resizeToAvoidBottomInset: false, + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 38, horizontal: 40), + child: Column( + children: [ + const Spacer(flex: 4), + _buildLogo(), + const VSpace(spacing), + _buildAppNameText(colorScheme), + const VSpace(spacing * 2), + const SignInWithMagicLinkButtons(), + const VSpace(spacing), + if (isAuthEnabled) _buildThirdPartySignInButtons(colorScheme), + const VSpace(spacing * 1.5), + const SignInAgreement(), + const VSpace(spacing), + if (!isAuthEnabled) const Spacer(flex: 2), + const Spacer(flex: 2), + const Spacer(), + Expanded(child: _buildSettingsButton(context)), + if (Platform.isAndroid) const Spacer(), + ], + ), + ), + ); + }, + ); + } + + Widget _buildLogo() { + return const FlowySvg( + FlowySvgs.flowy_logo_xl, + size: Size.square(56), + blendMode: null, + ); + } + + Widget _buildAppNameText(ColorScheme colorScheme) { + return FlowyText( + LocaleKeys.appName.tr(), + textAlign: TextAlign.center, + fontSize: 28, + color: const Color(0xFF00BCF0), + fontWeight: FontWeight.w700, + ); + } + + Widget _buildThirdPartySignInButtons(ColorScheme colorScheme) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: FlowyText( + LocaleKeys.signIn_or.tr(), + fontSize: 12, + color: colorScheme.onSecondary, + ), + ), + const Expanded(child: Divider()), + ], + ), + const VSpace(16), + // expand third-party sign in buttons on Android by default. + // on iOS, the github and discord buttons are collapsed by default. + ThirdPartySignInButtons( + expanded: Platform.isAndroid, + ), + ], + ); + } + + Widget _buildSettingsButton(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyButton( + useIntrinsicWidth: true, + text: FlowyText( + LocaleKeys.signIn_settings.tr(), + textAlign: TextAlign.center, + fontSize: 12.0, + // fontWeight: FontWeight.w500, + color: Colors.grey, + decoration: TextDecoration.underline, + ), + onTap: () { + context.push(MobileLaunchSettingsPage.routeName); + }, + ), + const HSpace(24), + const SignInAnonymousButtonV2(), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart new file mode 100644 index 0000000000000..5b99ad83f3335 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/sign_in_screen.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/router.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_loading_screen.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../helpers/helpers.dart'; + +class SignInScreen extends StatelessWidget { + const SignInScreen({super.key}); + + static const routeName = '/SignInScreen'; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => getIt(), + child: BlocConsumer( + listener: _showSignInError, + builder: (context, state) { + final isLoading = context.read().state.isSubmitting; + if (UniversalPlatform.isMobile) { + return isLoading + ? const MobileLoadingScreen() + : const MobileSignInScreen(); + } + return const DesktopSignInScreen(); + }, + ), + ); + } + + void _showSignInError(BuildContext context, SignInState state) { + final successOrFail = state.successOrFail; + if (successOrFail != null) { + handleUserProfileResult( + successOrFail, + context, + getIt(), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart new file mode 100644 index 0000000000000..0486d67838712 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart @@ -0,0 +1,131 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:string_validator/string_validator.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class SignInWithMagicLinkButtons extends StatefulWidget { + const SignInWithMagicLinkButtons({super.key}); + + @override + State createState() => + _SignInWithMagicLinkButtonsState(); +} + +class _SignInWithMagicLinkButtonsState + extends State { + final controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + + @override + void dispose() { + controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: UniversalPlatform.isMobile ? 38.0 : 48.0, + child: FlowyTextField( + autoFocus: false, + focusNode: _focusNode, + controller: controller, + borderRadius: BorderRadius.circular(4.0), + hintText: LocaleKeys.signIn_pleaseInputYourEmail.tr(), + hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14.0, + color: Theme.of(context).hintColor, + ), + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14.0, + ), + keyboardType: TextInputType.emailAddress, + onSubmitted: (_) => _sendMagicLink(context, controller.text), + onTapOutside: (_) => _focusNode.unfocus(), + ), + ), + const VSpace(12), + _ConfirmButton( + onTap: () => _sendMagicLink(context, controller.text), + ), + ], + ); + } + + void _sendMagicLink(BuildContext context, String email) { + if (!isEmail(email)) { + return showToastNotification( + context, + message: LocaleKeys.signIn_invalidEmail.tr(), + type: ToastificationType.error, + ); + } + + context.read().add(SignInEvent.signedWithMagicLink(email)); + + showConfirmDialog( + context: context, + title: LocaleKeys.signIn_magicLinkSent.tr(), + description: LocaleKeys.signIn_magicLinkSentDescription.tr(), + ); + } +} + +class _ConfirmButton extends StatelessWidget { + const _ConfirmButton({ + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final name = switch (state.loginType) { + LoginType.signIn => LocaleKeys.signIn_signInWithMagicLink.tr(), + LoginType.signUp => LocaleKeys.signIn_signUpWithMagicLink.tr(), + }; + if (UniversalPlatform.isMobile) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 32), + maximumSize: const Size(double.infinity, 38), + ), + onPressed: onTap, + child: FlowyText( + name, + fontSize: 14, + color: Theme.of(context).colorScheme.onPrimary, + ), + ); + } else { + return SizedBox( + height: 48, + child: FlowyButton( + isSelected: true, + onTap: onTap, + hoverColor: Theme.of(context).colorScheme.primary, + text: FlowyText.medium( + name, + textAlign: TextAlign.center, + color: Theme.of(context).colorScheme.onPrimary, + ), + radius: Corners.s6Border, + ), + ); + } + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart new file mode 100644 index 0000000000000..7351871b6a314 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_agreement.dart @@ -0,0 +1,52 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class SignInAgreement extends StatelessWidget { + const SignInAgreement({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.web_signInAgreement.tr()} ', + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + TextSpan( + text: '${LocaleKeys.web_termOfUse.tr()} ', + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + decoration: TextDecoration.underline, + ), + mouseCursor: SystemMouseCursors.click, + recognizer: TapGestureRecognizer() + ..onTap = () => afLaunchUrlString('https://appflowy.io/terms'), + ), + TextSpan( + text: '${LocaleKeys.web_and.tr()} ', + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + TextSpan( + text: LocaleKeys.web_privacyPolicy.tr(), + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + decoration: TextDecoration.underline, + ), + mouseCursor: SystemMouseCursors.click, + recognizer: TapGestureRecognizer() + ..onTap = () => afLaunchUrlString('https://appflowy.io/privacy'), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart new file mode 100644 index 0000000000000..bce22a714d0f3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_anonymous_button.dart @@ -0,0 +1,140 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/anon_user_bloc.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +/// Used in DesktopSignInScreen and MobileSignInScreen +class SignInAnonymousButton extends StatelessWidget { + const SignInAnonymousButton({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final isMobile = UniversalPlatform.isMobile; + + return BlocBuilder( + builder: (context, signInState) { + return BlocProvider( + create: (context) => AnonUserBloc() + ..add( + const AnonUserEvent.initial(), + ), + child: BlocListener( + listener: (context, state) async { + if (state.openedAnonUser != null) { + await runAppFlowy(); + } + }, + child: BlocBuilder( + builder: (context, state) { + final text = state.anonUsers.isEmpty + ? LocaleKeys.signIn_loginStartWithAnonymous.tr() + : LocaleKeys.signIn_continueAnonymousUser.tr(); + final onTap = state.anonUsers.isEmpty + ? () { + context + .read() + .add(const SignInEvent.signedInAsGuest()); + } + : () { + final bloc = context.read(); + final user = bloc.state.anonUsers.first; + bloc.add(AnonUserEvent.openAnonUser(user)); + }; + // SignInAnonymousButton in mobile + if (isMobile) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 56), + ), + onPressed: onTap, + child: FlowyText( + LocaleKeys.signIn_loginStartWithAnonymous.tr(), + fontSize: 14, + color: Theme.of(context).colorScheme.onPrimary, + fontWeight: FontWeight.w500, + ), + ); + } + // SignInAnonymousButton in desktop + return SizedBox( + height: 48, + child: FlowyButton( + isSelected: true, + disable: signInState.isSubmitting, + text: FlowyText.medium( + text, + textAlign: TextAlign.center, + ), + radius: Corners.s6Border, + onTap: onTap, + ), + ); + }, + ), + ), + ); + }, + ); + } +} + +class SignInAnonymousButtonV2 extends StatelessWidget { + const SignInAnonymousButtonV2({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, signInState) { + return BlocProvider( + create: (context) => AnonUserBloc() + ..add( + const AnonUserEvent.initial(), + ), + child: BlocListener( + listener: (context, state) async { + if (state.openedAnonUser != null) { + await runAppFlowy(); + } + }, + child: BlocBuilder( + builder: (context, state) { + final text = LocaleKeys.signIn_anonymous.tr(); + final onTap = state.anonUsers.isEmpty + ? () { + context + .read() + .add(const SignInEvent.signedInAsGuest()); + } + : () { + final bloc = context.read(); + final user = bloc.state.anonUsers.first; + bloc.add(AnonUserEvent.openAnonUser(user)); + }; + return FlowyButton( + useIntrinsicWidth: true, + onTap: onTap, + text: FlowyText( + text, + color: Colors.grey, + decoration: TextDecoration.underline, + fontSize: 12, + ), + ); + }, + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart new file mode 100644 index 0000000000000..5146e29962740 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart @@ -0,0 +1,67 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class MobileLogoutButton extends StatelessWidget { + const MobileLogoutButton({ + super.key, + this.icon, + required this.text, + this.textColor, + required this.onPressed, + }); + + final FlowySvgData? icon; + final String text; + final Color? textColor; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context); + return GestureDetector( + onTap: onPressed, + child: Container( + height: 38, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(4), + ), + border: Border.all( + color: textColor ?? style.colorScheme.outline, + width: 0.5, + ), + ), + alignment: Alignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + SizedBox( + // The icon could be in different height as original aspect ratio, we use a fixed sizebox to wrap it to make sure they all occupy the same space. + width: 30, + height: 30, + child: Center( + child: SizedBox( + width: 24, + child: FlowySvg( + icon!, + blendMode: null, + ), + ), + ), + ), + const HSpace(8), + ], + FlowyText( + text, + fontSize: 14.0, + fontWeight: FontWeight.w400, + color: textColor, + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/switch_sign_in_sign_up_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/switch_sign_in_sign_up_button.dart new file mode 100644 index 0000000000000..662c1130fae82 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/switch_sign_in_sign_up_button.dart @@ -0,0 +1,52 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SwitchSignInSignUpButton extends StatelessWidget { + const SwitchSignInSignUpButton({ + super.key, + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlowyText( + switch (state.loginType) { + LoginType.signIn => + LocaleKeys.signIn_dontHaveAnAccount.tr(), + LoginType.signUp => + LocaleKeys.signIn_alreadyHaveAnAccount.tr(), + }, + fontSize: 12, + ), + const HSpace(4), + FlowyText( + switch (state.loginType) { + LoginType.signIn => LocaleKeys.signIn_createAccount.tr(), + LoginType.signUp => LocaleKeys.signIn_logIn.tr(), + }, + color: Colors.blue, + fontSize: 12, + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart new file mode 100644 index 0000000000000..35d16b031fcb6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_button.dart @@ -0,0 +1,219 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/animated_gesture.dart'; +import 'package:appflowy/user/presentation/widgets/widgets.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +enum ThirdPartySignInButtonType { + apple, + google, + github, + discord, + anonymous; + + String get provider { + switch (this) { + case ThirdPartySignInButtonType.apple: + return 'apple'; + case ThirdPartySignInButtonType.google: + return 'google'; + case ThirdPartySignInButtonType.github: + return 'github'; + case ThirdPartySignInButtonType.discord: + return 'discord'; + case ThirdPartySignInButtonType.anonymous: + throw UnsupportedError('Anonymous session does not have a provider'); + } + } + + FlowySvgData get icon { + switch (this) { + case ThirdPartySignInButtonType.apple: + return FlowySvgs.m_apple_icon_xl; + case ThirdPartySignInButtonType.google: + return FlowySvgs.m_google_icon_xl; + case ThirdPartySignInButtonType.github: + return FlowySvgs.m_github_icon_xl; + case ThirdPartySignInButtonType.discord: + return FlowySvgs.m_discord_icon_xl; + case ThirdPartySignInButtonType.anonymous: + return FlowySvgs.m_discord_icon_xl; + } + } + + String get labelText { + switch (this) { + case ThirdPartySignInButtonType.apple: + return LocaleKeys.signIn_signInWithApple.tr(); + case ThirdPartySignInButtonType.google: + return LocaleKeys.signIn_signInWithGoogle.tr(); + case ThirdPartySignInButtonType.github: + return LocaleKeys.signIn_signInWithGithub.tr(); + case ThirdPartySignInButtonType.discord: + return LocaleKeys.signIn_signInWithDiscord.tr(); + case ThirdPartySignInButtonType.anonymous: + return 'Anonymous session'; + } + } + + // https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple + Color backgroundColor(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + switch (this) { + case ThirdPartySignInButtonType.apple: + return isDarkMode ? Colors.white : Colors.black; + case ThirdPartySignInButtonType.google: + case ThirdPartySignInButtonType.github: + case ThirdPartySignInButtonType.discord: + case ThirdPartySignInButtonType.anonymous: + return isDarkMode ? Colors.black : Colors.grey.shade100; + } + } + + Color textColor(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + switch (this) { + case ThirdPartySignInButtonType.apple: + return isDarkMode ? Colors.black : Colors.white; + case ThirdPartySignInButtonType.google: + case ThirdPartySignInButtonType.github: + case ThirdPartySignInButtonType.discord: + case ThirdPartySignInButtonType.anonymous: + return isDarkMode ? Colors.white : Colors.black; + } + } + + BlendMode? get blendMode { + switch (this) { + case ThirdPartySignInButtonType.apple: + case ThirdPartySignInButtonType.github: + return BlendMode.srcIn; + default: + return null; + } + } +} + +class MobileThirdPartySignInButton extends StatelessWidget { + const MobileThirdPartySignInButton({ + super.key, + this.height = 38, + this.fontSize = 14.0, + required this.onPressed, + required this.type, + }); + + final VoidCallback onPressed; + final double height; + final double fontSize; + final ThirdPartySignInButtonType type; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context); + + return AnimatedGestureDetector( + scaleFactor: 1.0, + onTapUp: onPressed, + child: Container( + height: height, + decoration: BoxDecoration( + color: type.backgroundColor(context), + borderRadius: const BorderRadius.all( + Radius.circular(4), + ), + border: Border.all( + color: style.colorScheme.outline, + width: 0.5, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (type != ThirdPartySignInButtonType.anonymous) + FlowySvg( + type.icon, + size: Size.square(fontSize), + blendMode: type.blendMode, + color: type.textColor(context), + ), + const HSpace(8.0), + FlowyText( + type.labelText, + fontSize: fontSize, + color: type.textColor(context), + ), + ], + ), + ), + ); + } +} + +class DesktopSignInButton extends StatelessWidget { + const DesktopSignInButton({ + super.key, + required this.type, + required this.onPressed, + }); + + final ThirdPartySignInButtonType type; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context); + // In desktop, the width of button is limited by [AuthFormContainer] + return SizedBox( + height: 48, + width: AuthFormContainer.width, + child: OutlinedButton.icon( + // In order to align all the labels vertically in a relatively centered position to the button, we use a fixed width container to wrap the icon(align to the right), then use another container to align the label to left. + icon: Container( + width: AuthFormContainer.width / 4, + alignment: Alignment.centerRight, + child: SizedBox( + // Some icons are not square, so we just use a fixed width here. + width: 24, + child: FlowySvg( + type.icon, + blendMode: type.blendMode, + ), + ), + ), + label: Container( + padding: const EdgeInsets.only(left: 8), + alignment: Alignment.centerLeft, + child: FlowyText( + type.labelText, + fontSize: 14, + ), + ), + style: ButtonStyle( + overlayColor: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.hovered)) { + return style.colorScheme.onSecondaryContainer; + } + return null; + }, + ), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder( + borderRadius: Corners.s6Border, + ), + ), + side: WidgetStateProperty.all( + BorderSide( + color: style.dividerColor, + ), + ), + ), + onPressed: onPressed, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart new file mode 100644 index 0000000000000..7baa243e5ff3a --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/third_party_sign_in_buttons.dart @@ -0,0 +1,203 @@ +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'third_party_sign_in_button.dart'; + +typedef _SignInCallback = void Function(ThirdPartySignInButtonType signInType); + +@visibleForTesting +const Key signInWithGoogleButtonKey = Key('signInWithGoogleButton'); + +class ThirdPartySignInButtons extends StatelessWidget { + /// Used in DesktopSignInScreen, MobileSignInScreen and SettingThirdPartyLogin + const ThirdPartySignInButtons({ + super.key, + this.expanded = false, + }); + + final bool expanded; + + @override + Widget build(BuildContext context) { + if (UniversalPlatform.isDesktopOrWeb) { + return _DesktopThirdPartySignIn( + onSignIn: (type) => _signIn(context, type.provider), + ); + } else { + return _MobileThirdPartySignIn( + isExpanded: expanded, + onSignIn: (type) => _signIn(context, type.provider), + ); + } + } + + void _signIn(BuildContext context, String provider) { + context.read().add( + SignInEvent.signedInWithOAuth(provider), + ); + } +} + +class _DesktopThirdPartySignIn extends StatefulWidget { + const _DesktopThirdPartySignIn({ + required this.onSignIn, + }); + + final _SignInCallback onSignIn; + + @override + State<_DesktopThirdPartySignIn> createState() => + _DesktopThirdPartySignInState(); +} + +class _DesktopThirdPartySignInState extends State<_DesktopThirdPartySignIn> { + static const padding = 12.0; + + bool isExpanded = false; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + DesktopSignInButton( + key: signInWithGoogleButtonKey, + type: ThirdPartySignInButtonType.google, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google), + ), + const VSpace(padding), + DesktopSignInButton( + type: ThirdPartySignInButtonType.apple, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple), + ), + ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), + ], + ); + } + + List _buildExpandedButtons() { + return [ + const VSpace(padding * 1.5), + DesktopSignInButton( + type: ThirdPartySignInButtonType.github, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github), + ), + const VSpace(padding), + DesktopSignInButton( + type: ThirdPartySignInButtonType.discord, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord), + ), + ]; + } + + List _buildCollapsedButtons() { + return [ + const VSpace(padding), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + setState(() { + isExpanded = !isExpanded; + }); + }, + child: FlowyText( + LocaleKeys.signIn_continueAnotherWay.tr(), + color: Theme.of(context).colorScheme.onSurface, + decoration: TextDecoration.underline, + fontSize: 14, + ), + ), + ), + ]; + } +} + +class _MobileThirdPartySignIn extends StatefulWidget { + const _MobileThirdPartySignIn({ + required this.isExpanded, + required this.onSignIn, + }); + + final bool isExpanded; + final _SignInCallback onSignIn; + + @override + State<_MobileThirdPartySignIn> createState() => + _MobileThirdPartySignInState(); +} + +class _MobileThirdPartySignInState extends State<_MobileThirdPartySignIn> { + static const padding = 8.0; + + bool isExpanded = false; + + @override + void initState() { + super.initState(); + + isExpanded = widget.isExpanded; + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // only display apple sign in button on iOS + if (Platform.isIOS) ...[ + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.apple, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.apple), + ), + const VSpace(padding), + ], + MobileThirdPartySignInButton( + key: signInWithGoogleButtonKey, + type: ThirdPartySignInButtonType.google, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.google), + ), + ...isExpanded ? _buildExpandedButtons() : _buildCollapsedButtons(), + ], + ); + } + + List _buildExpandedButtons() { + return [ + const VSpace(padding), + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.github, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.github), + ), + const VSpace(padding), + MobileThirdPartySignInButton( + type: ThirdPartySignInButtonType.discord, + onPressed: () => widget.onSignIn(ThirdPartySignInButtonType.discord), + ), + ]; + } + + List _buildCollapsedButtons() { + return [ + const VSpace(padding * 2), + GestureDetector( + onTap: () { + setState(() { + isExpanded = !isExpanded; + }); + }, + child: FlowyText( + LocaleKeys.signIn_continueAnotherWay.tr(), + color: Theme.of(context).colorScheme.onSurface, + decoration: TextDecoration.underline, + fontSize: 14, + ), + ), + ]; + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart new file mode 100644 index 0000000000000..18e260a472c68 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/widgets.dart @@ -0,0 +1,7 @@ +export 'magic_link_sign_in_buttons.dart'; +export 'sign_in_anonymous_button.dart'; +export 'sign_in_or_logout_button.dart'; +export 'third_party_sign_in_button.dart'; +// export 'switch_sign_in_sign_up_button.dart'; +export 'third_party_sign_in_buttons.dart'; +export 'sign_in_agreement.dart'; diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart new file mode 100644 index 0000000000000..8aea8dde5579b --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_up_screen.dart @@ -0,0 +1,220 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/sign_up_bloc.dart'; +import 'package:appflowy/user/presentation/router.dart'; +import 'package:appflowy/user/presentation/widgets/widgets.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/rounded_button.dart'; +import 'package:flowy_infra_ui/widget/rounded_input_field.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SignUpScreen extends StatelessWidget { + const SignUpScreen({ + super.key, + required this.router, + }); + + static const routeName = '/SignUpScreen'; + final AuthRouter router; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => getIt(), + child: BlocListener( + listener: (context, state) { + final successOrFail = state.successOrFail; + if (successOrFail != null) { + _handleSuccessOrFail(context, successOrFail); + } + }, + child: const Scaffold(body: SignUpForm()), + ), + ); + } + + void _handleSuccessOrFail( + BuildContext context, + FlowyResult result, + ) { + result.fold( + (user) => router.pushWorkspaceStartScreen(context, user), + (error) => showSnapBar(context, error.msg), + ); + } +} + +class SignUpForm extends StatelessWidget { + const SignUpForm({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Align( + child: AuthFormContainer( + children: [ + FlowyLogoTitle( + title: LocaleKeys.signUp_title.tr(), + logoSize: const Size(60, 60), + ), + const VSpace(30), + const EmailTextField(), + const VSpace(5), + const PasswordTextField(), + const VSpace(5), + const RepeatPasswordTextField(), + const VSpace(30), + const SignUpButton(), + const VSpace(10), + const SignUpPrompt(), + if (context.read().state.isSubmitting) ...[ + const SizedBox(height: 8), + const LinearProgressIndicator(), + ], + ], + ), + ); + } +} + +class SignUpPrompt extends StatelessWidget { + const SignUpPrompt({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlowyText.medium( + LocaleKeys.signUp_alreadyHaveAnAccount.tr(), + color: Theme.of(context).hintColor, + ), + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.bodyMedium, + ), + onPressed: () => Navigator.pop(context), + child: FlowyText.medium( + LocaleKeys.signIn_buttonText.tr(), + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ); + } +} + +class SignUpButton extends StatelessWidget { + const SignUpButton({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return RoundedTextButton( + title: LocaleKeys.signUp_getStartedText.tr(), + height: 48, + onPressed: () { + context + .read() + .add(const SignUpEvent.signUpWithUserEmailAndPassword()); + }, + ); + } +} + +class PasswordTextField extends StatelessWidget { + const PasswordTextField({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.passwordError != current.passwordError, + builder: (context, state) { + return RoundedInputField( + obscureText: true, + obscureIcon: const FlowySvg(FlowySvgs.hide_m), + obscureHideIcon: const FlowySvg(FlowySvgs.show_m), + hintText: LocaleKeys.signUp_passwordHint.tr(), + normalBorderColor: Theme.of(context).colorScheme.outline, + errorBorderColor: Theme.of(context).colorScheme.error, + cursorColor: Theme.of(context).colorScheme.primary, + errorText: context.read().state.passwordError ?? '', + onChanged: (value) => context + .read() + .add(SignUpEvent.passwordChanged(value)), + ); + }, + ); + } +} + +class RepeatPasswordTextField extends StatelessWidget { + const RepeatPasswordTextField({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.repeatPasswordError != current.repeatPasswordError, + builder: (context, state) { + return RoundedInputField( + obscureText: true, + obscureIcon: const FlowySvg(FlowySvgs.hide_m), + obscureHideIcon: const FlowySvg(FlowySvgs.show_m), + hintText: LocaleKeys.signUp_repeatPasswordHint.tr(), + normalBorderColor: Theme.of(context).colorScheme.outline, + errorBorderColor: Theme.of(context).colorScheme.error, + cursorColor: Theme.of(context).colorScheme.primary, + errorText: context.read().state.repeatPasswordError ?? '', + onChanged: (value) => context + .read() + .add(SignUpEvent.repeatPasswordChanged(value)), + ); + }, + ); + } +} + +class EmailTextField extends StatelessWidget { + const EmailTextField({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.emailError != current.emailError, + builder: (context, state) { + return RoundedInputField( + hintText: LocaleKeys.signUp_emailHint.tr(), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + normalBorderColor: Theme.of(context).colorScheme.outline, + errorBorderColor: Theme.of(context).colorScheme.error, + cursorColor: Theme.of(context).colorScheme.primary, + errorText: context.read().state.emailError ?? '', + onChanged: (value) => + context.read().add(SignUpEvent.emailChanged(value)), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart new file mode 100644 index 0000000000000..ee089dfce0b99 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart @@ -0,0 +1,336 @@ +import 'package:appflowy/core/frameless_window.dart'; +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/anon_user_bloc.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/presentation/router.dart'; +import 'package:appflowy/user/presentation/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/language.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class SkipLogInScreen extends StatefulWidget { + const SkipLogInScreen({super.key}); + + static const routeName = '/SkipLogInScreen'; + + @override + State createState() => _SkipLogInScreenState(); +} + +class _SkipLogInScreenState extends State { + var _didCustomizeFolder = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const _SkipLoginMoveWindow(), + body: Center(child: _renderBody(context)), + ); + } + + Widget _renderBody(BuildContext context) { + final size = MediaQuery.of(context).size; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(), + FlowyLogoTitle( + title: LocaleKeys.welcomeText.tr(), + logoSize: Size.square(UniversalPlatform.isMobile ? 80 : 40), + ), + const VSpace(32), + GoButton( + onPressed: () { + if (_didCustomizeFolder) { + _relaunchAppAndAutoRegister(); + } else { + _autoRegister(context); + } + }, + ), + // if (Env.enableCustomCloud) ...[ + // const VSpace(10), + // const SizedBox( + // width: 340, + // child: _SetupYourServer(), + // ), + // ], + const VSpace(32), + SizedBox( + width: size.width * 0.7, + child: FolderWidget( + createFolderCallback: () async => _didCustomizeFolder = true, + ), + ), + const Spacer(), + const SkipLoginPageFooter(), + const VSpace(20), + ], + ); + } + + Future _autoRegister(BuildContext context) async { + final result = await getIt().signUpAsGuest(); + result.fold( + (user) => getIt().goHomeScreen(context, user), + (error) => Log.error(error), + ); + } + + Future _relaunchAppAndAutoRegister() async => runAppFlowy(isAnon: true); +} + +class SkipLoginPageFooter extends StatelessWidget { + const SkipLoginPageFooter({super.key}); + + @override + Widget build(BuildContext context) { + // The placeholderWidth should be greater than the longest width of the LanguageSelectorOnWelcomePage + const double placeholderWidth = 180; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (!UniversalPlatform.isMobile) const HSpace(placeholderWidth), + const Expanded(child: SubscribeButtons()), + const SizedBox( + width: placeholderWidth, + height: 28, + child: Row( + children: [ + Spacer(), + LanguageSelectorOnWelcomePage(), + ], + ), + ), + ], + ), + ); + } +} + +class SubscribeButtons extends StatelessWidget { + const SubscribeButtons({super.key}); + + @override + Widget build(BuildContext context) { + return Wrap( + alignment: WrapAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.regular( + LocaleKeys.youCanAlso.tr(), + fontSize: FontSizes.s12, + ), + FlowyTextButton( + LocaleKeys.githubStarText.tr(), + padding: const EdgeInsets.symmetric(horizontal: 4), + fontWeight: FontWeight.w500, + fontColor: Theme.of(context).colorScheme.primary, + hoverColor: Colors.transparent, + fillColor: Colors.transparent, + onPressed: () => + afLaunchUrlString('https://github.com/AppFlowy-IO/appflowy'), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.regular(LocaleKeys.and.tr(), fontSize: FontSizes.s12), + FlowyTextButton( + LocaleKeys.subscribeNewsletterText.tr(), + padding: const EdgeInsets.symmetric(horizontal: 4.0), + fontWeight: FontWeight.w500, + fontColor: Theme.of(context).colorScheme.primary, + hoverColor: Colors.transparent, + fillColor: Colors.transparent, + onPressed: () => + afLaunchUrlString('https://www.appflowy.io/blog'), + ), + ], + ), + ], + ); + } +} + +class LanguageSelectorOnWelcomePage extends StatelessWidget { + const LanguageSelectorOnWelcomePage({super.key}); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + offset: const Offset(0, -450), + direction: PopoverDirection.bottomWithRightAligned, + child: FlowyButton( + useIntrinsicWidth: true, + text: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const FlowySvg(FlowySvgs.ethernet_m, size: Size.square(20)), + const HSpace(4), + Builder( + builder: (context) { + final currentLocale = + context.watch().state.locale; + return FlowyText(languageFromLocale(currentLocale)); + }, + ), + const FlowySvg(FlowySvgs.drop_menu_hide_m, size: Size.square(20)), + ], + ), + ), + popupBuilder: (BuildContext context) { + final easyLocalization = EasyLocalization.of(context); + if (easyLocalization == null) { + return const SizedBox.shrink(); + } + + return LanguageItemsListView( + allLocales: easyLocalization.supportedLocales, + ); + }, + ); + } +} + +class LanguageItemsListView extends StatelessWidget { + const LanguageItemsListView({super.key, required this.allLocales}); + + final List allLocales; + + @override + Widget build(BuildContext context) { + // get current locale from cubit + final state = context.watch().state; + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: ListView.builder( + itemCount: allLocales.length, + itemBuilder: (context, index) { + final locale = allLocales[index]; + return LanguageItem(locale: locale, currentLocale: state.locale); + }, + ), + ); + } +} + +class LanguageItem extends StatelessWidget { + const LanguageItem({ + super.key, + required this.locale, + required this.currentLocale, + }); + + final Locale locale; + final Locale currentLocale; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: FlowyButton( + text: FlowyText.medium( + languageFromLocale(locale), + ), + rightIcon: + currentLocale == locale ? const FlowySvg(FlowySvgs.check_s) : null, + onTap: () { + if (currentLocale != locale) { + context.read().setLocale(context, locale); + } + PopoverContainer.of(context).close(); + }, + ), + ); + } +} + +class GoButton extends StatelessWidget { + const GoButton({super.key, required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => AnonUserBloc()..add(const AnonUserEvent.initial()), + child: BlocListener( + listener: (context, state) async { + if (state.openedAnonUser != null) { + await runAppFlowy(); + } + }, + child: BlocBuilder( + builder: (context, state) { + final text = state.anonUsers.isEmpty + ? LocaleKeys.letsGoButtonText.tr() + : LocaleKeys.signIn_continueAnonymousUser.tr(); + + final textWidget = Row( + children: [ + Expanded( + child: FlowyText.medium( + text, + textAlign: TextAlign.center, + fontSize: 14, + ), + ), + ], + ); + + return SizedBox( + width: 340, + height: 48, + child: FlowyButton( + isSelected: true, + text: textWidget, + radius: Corners.s6Border, + onTap: () { + if (state.anonUsers.isNotEmpty) { + final bloc = context.read(); + final historicalUser = state.anonUsers.first; + bloc.add( + AnonUserEvent.openAnonUser(historicalUser), + ); + } else { + onPressed(); + } + }, + ), + ); + }, + ), + ), + ); + } +} + +class _SkipLoginMoveWindow extends StatelessWidget + implements PreferredSizeWidget { + const _SkipLoginMoveWindow(); + + @override + Widget build(BuildContext context) => + const Row(children: [Expanded(child: MoveWindowDetector())]); + + @override + Size get preferredSize => const Size.fromHeight(55.0); +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart new file mode 100644 index 0000000000000..146bf06df1e74 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart @@ -0,0 +1,147 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/splash_bloc.dart'; +import 'package:appflowy/user/domain/auth_state.dart'; +import 'package:appflowy/user/presentation/helpers/helpers.dart'; +import 'package:appflowy/user/presentation/router.dart'; +import 'package:appflowy/user/presentation/screens/screens.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class SplashScreen extends StatelessWidget { + /// Root Page of the app. + const SplashScreen({super.key, required this.isAnon}); + + final bool isAnon; + + @override + Widget build(BuildContext context) { + if (isAnon) { + return FutureBuilder( + future: _registerIfNeeded(), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const SizedBox.shrink(); + } + return _buildChild(context); + }, + ); + } else { + return _buildChild(context); + } + } + + BlocProvider _buildChild(BuildContext context) { + return BlocProvider( + create: (context) => + getIt()..add(const SplashEvent.getUser()), + child: Scaffold( + body: BlocListener( + listener: (context, state) { + state.auth.map( + authenticated: (r) => _handleAuthenticated(context, r), + unauthenticated: (r) => _handleUnauthenticated(context, r), + initial: (r) => {}, + ); + }, + child: const Body(), + ), + ), + ); + } + + /// Handles the authentication flow once a user is authenticated. + Future _handleAuthenticated( + BuildContext context, + Authenticated authenticated, + ) async { + final userProfile = authenticated.userProfile; + + /// After a user is authenticated, this function checks if encryption is required. + final result = await UserEventCheckEncryptionSign().send(); + await result.fold( + (check) async { + /// If encryption is needed, the user is navigated to the encryption screen. + /// Otherwise, it fetches the current workspace for the user and navigates them + if (check.requireSecret) { + getIt().pushEncryptionScreen(context, userProfile); + } else { + final result = await FolderEventGetCurrentWorkspaceSetting().send(); + result.fold( + (workspaceSetting) { + // After login, replace Splash screen by corresponding home screen + getIt().goHomeScreen( + context, + ); + }, + (error) => handleOpenWorkspaceError(context, error), + ); + } + }, + (err) { + Log.error(err); + }, + ); + } + + void _handleUnauthenticated(BuildContext context, Unauthenticated result) { + // replace Splash screen as root page + if (isAuthEnabled || UniversalPlatform.isMobile) { + context.go(SignInScreen.routeName); + } else { + // if the env is not configured, we will skip to the 'skip login screen'. + context.go(SkipLogInScreen.routeName); + } + } + + Future _registerIfNeeded() async { + final result = await UserEventGetUserProfile().send(); + if (result.isFailure) { + await getIt().signUpAsGuest(); + } + } +} + +class Body extends StatelessWidget { + const Body({super.key}); + @override + Widget build(BuildContext context) { + return Container( + alignment: Alignment.center, + child: UniversalPlatform.isMobile + ? const FlowySvg(FlowySvgs.flowy_logo_xl, blendMode: null) + : const _DesktopSplashBody(), + ); + } +} + +class _DesktopSplashBody extends StatelessWidget { + const _DesktopSplashBody(); + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + return SingleChildScrollView( + child: Stack( + alignment: Alignment.center, + children: [ + Image( + fit: BoxFit.cover, + width: size.width, + height: size.height, + image: const AssetImage( + 'assets/images/appflowy_launch_splash.jpg', + ), + ), + const CircularProgressIndicator.adaptive(), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart new file mode 100644 index 0000000000000..d79127e04c0ba --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_error_screen.dart @@ -0,0 +1,199 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../application/workspace_error_bloc.dart'; + +class WorkspaceErrorScreen extends StatelessWidget { + const WorkspaceErrorScreen({ + super.key, + required this.userFolder, + required this.error, + }); + + final UserFolderPB userFolder; + final FlowyError error; + + static const routeName = "/WorkspaceErrorScreen"; + // arguments names to used in GoRouter + static const argError = "error"; + static const argUserFolder = "userFolder"; + + @override + Widget build(BuildContext context) { + return Scaffold( + extendBody: true, + body: BlocProvider( + create: (context) => WorkspaceErrorBloc( + userFolder: userFolder, + error: error, + )..add(const WorkspaceErrorEvent.init()), + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (previous, current) => + previous.workspaceState != current.workspaceState, + listener: (context, state) async { + await state.workspaceState.when( + initial: () {}, + logout: () async { + await getIt().signOut(); + await runAppFlowy(); + }, + reset: () async { + await getIt().signOut(); + await runAppFlowy(); + }, + restoreFromSnapshot: () {}, + createNewWorkspace: () {}, + ); + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous.loadingState != current.loadingState, + listener: (context, state) async { + state.loadingState?.when( + loading: () {}, + finish: (error) { + error.fold( + (_) {}, + (err) { + showSnapBar(context, err.msg); + }, + ); + }, + idle: () {}, + ); + }, + ), + ], + child: BlocBuilder( + builder: (context, state) { + final List children = [ + WorkspaceErrorDescription(error: error), + ]; + + children.addAll([ + const VSpace(50), + const LogoutButton(), + const VSpace(20), + const ResetWorkspaceButton(), + ]); + + return Center( + child: SizedBox( + width: 500, + child: IntrinsicHeight( + child: Column( + children: children, + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} + +class WorkspaceErrorDescription extends StatelessWidget { + const WorkspaceErrorDescription({super.key, required this.error}); + + final FlowyError error; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.medium( + state.initialError.msg.toString(), + fontSize: 14, + maxLines: 10, + ), + FlowyText.medium( + "Error code: ${state.initialError.code.value.toString()}", + fontSize: 12, + ), + ], + ); + }, + ); + } +} + +class LogoutButton extends StatelessWidget { + const LogoutButton({super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 40, + width: 200, + child: FlowyButton( + text: FlowyText.medium( + LocaleKeys.settings_menu_logout.tr(), + textAlign: TextAlign.center, + ), + onTap: () async { + context.read().add( + const WorkspaceErrorEvent.logout(), + ); + }, + ), + ); + } +} + +class ResetWorkspaceButton extends StatelessWidget { + const ResetWorkspaceButton({super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 200, + height: 40, + child: BlocBuilder( + builder: (context, state) { + final isLoading = state.loadingState?.isLoading() ?? false; + final icon = isLoading + ? const Center( + child: CircularProgressIndicator.adaptive(), + ) + : null; + + return FlowyButton( + text: FlowyText.medium( + LocaleKeys.workspace_reset.tr(), + textAlign: TextAlign.center, + ), + onTap: () { + NavigatorAlertDialog( + title: LocaleKeys.workspace_resetWorkspacePrompt.tr(), + confirm: () { + context.read().add( + const WorkspaceErrorEvent.resetWorkspace(), + ); + }, + ).show(context); + }, + rightIcon: icon, + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart new file mode 100644 index 0000000000000..af5e7367e5611 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart @@ -0,0 +1,107 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/workspace/application/workspace/prelude.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class DesktopWorkspaceStartScreen extends StatelessWidget { + const DesktopWorkspaceStartScreen({super.key, required this.workspaceState}); + + final WorkspaceState workspaceState; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(60.0), + child: Column( + children: [ + _renderBody(workspaceState), + _renderCreateButton(context), + ], + ), + ), + ); + } +} + +Widget _renderBody(WorkspaceState state) { + final body = state.successOrFailure.fold( + (_) => _renderList(state.workspaces), + (error) => Center( + child: AppFlowyErrorPage( + error: error, + ), + ), + ); + return body; +} + +Widget _renderList(List workspaces) { + return Expanded( + child: StyledListView( + itemBuilder: (BuildContext context, int index) { + final workspace = workspaces[index]; + return _WorkspaceItem( + workspace: workspace, + onPressed: (workspace) => _popToWorkspace(context, workspace), + ); + }, + itemCount: workspaces.length, + ), + ); +} + +class _WorkspaceItem extends StatelessWidget { + const _WorkspaceItem({ + required this.workspace, + required this.onPressed, + }); + + final WorkspacePB workspace; + final void Function(WorkspacePB workspace) onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 46, + child: FlowyTextButton( + workspace.name, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + fontSize: 14, + onPressed: () => onPressed(workspace), + ), + ); + } +} + +Widget _renderCreateButton(BuildContext context) { + return SizedBox( + width: 200, + height: 40, + child: FlowyTextButton( + LocaleKeys.workspace_create.tr(), + fontSize: 14, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + onPressed: () { + // same method as in mobile + context.read().add( + WorkspaceEvent.createWorkspace( + LocaleKeys.workspace_hint.tr(), + "", + ), + ); + }, + ), + ); +} + +// same method as in mobile +void _popToWorkspace(BuildContext context, WorkspacePB workspace) { + context.pop(workspace.id); +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart new file mode 100644 index 0000000000000..59b61aa54b9f9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart @@ -0,0 +1,149 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/workspace/application/workspace/prelude.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +// TODO: needs refactor when multiple workspaces are supported +class MobileWorkspaceStartScreen extends StatefulWidget { + const MobileWorkspaceStartScreen({ + super.key, + required this.workspaceState, + }); + + @override + State createState() => + _MobileWorkspaceStartScreenState(); + final WorkspaceState workspaceState; +} + +class _MobileWorkspaceStartScreenState + extends State { + WorkspacePB? selectedWorkspace; + final TextEditingController controller = TextEditingController(); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final style = Theme.of(context); + final size = MediaQuery.of(context).size; + const double spacing = 16.0; + final List> workspaceEntries = + >[]; + for (final WorkspacePB workspace in widget.workspaceState.workspaces) { + workspaceEntries.add( + DropdownMenuEntry( + value: workspace, + label: workspace.name, + ), + ); + } + +// render the workspace dropdown menu if success, otherwise render error page + final body = widget.workspaceState.successOrFailure.fold( + (_) { + return Padding( + padding: const EdgeInsets.fromLTRB(50, 0, 50, 30), + child: Column( + children: [ + const Spacer(), + const FlowySvg( + FlowySvgs.flowy_logo_xl, + size: Size.square(64), + blendMode: null, + ), + const VSpace(spacing * 2), + Text( + LocaleKeys.workspace_chooseWorkspace.tr(), + style: style.textTheme.displaySmall, + textAlign: TextAlign.center, + ), + const VSpace(spacing * 4), + DropdownMenu( + width: size.width - 100, + // TODO: The following code cause the bad state error, need to fix it + // initialSelection: widget.workspaceState.workspaces.first, + label: const Text('Workspace'), + controller: controller, + dropdownMenuEntries: workspaceEntries, + onSelected: (WorkspacePB? workspace) { + setState(() { + selectedWorkspace = workspace; + }); + }, + ), + const Spacer(), + // TODO: needs to implement create workspace in the future + // TextButton( + // child: Text( + // LocaleKeys.workspace_create.tr(), + // style: style.textTheme.labelMedium, + // textAlign: TextAlign.center, + // ), + // onPressed: () { + // setState(() { + // // same method as in desktop + // context.read().add( + // WorkspaceEvent.createWorkspace( + // LocaleKeys.workspace_hint.tr(), + // "", + // ), + // ); + // }); + // }, + // ), + const VSpace(spacing / 2), + ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 56), + ), + onPressed: () { + if (selectedWorkspace == null) { + // If user didn't choose any workspace, pop to the initial workspace(first workspace) + _popToWorkspace( + context, + widget.workspaceState.workspaces.first, + ); + return; + } + // pop to the selected workspace + _popToWorkspace( + context, + selectedWorkspace!, + ); + }, + child: Text(LocaleKeys.signUp_getStartedText.tr()), + ), + const VSpace(spacing), + ], + ), + ); + }, + (error) { + return Center( + child: AppFlowyErrorPage( + error: error, + ), + ); + }, + ); + + return Scaffold( + body: body, + ); + } +} + +// same method as in desktop +void _popToWorkspace(BuildContext context, WorkspacePB workspace) { + context.pop(workspace.id); +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/workspace_start_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/workspace_start_screen.dart new file mode 100644 index 0000000000000..a8a9305539d10 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/workspace_start_screen/workspace_start_screen.dart @@ -0,0 +1,39 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/presentation/screens/workspace_start_screen/desktop_workspace_start_screen.dart'; +import 'package:appflowy/user/presentation/screens/workspace_start_screen/mobile_workspace_start_screen.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +// For future use +class WorkspaceStartScreen extends StatelessWidget { + /// To choose which screen is going to open + const WorkspaceStartScreen({super.key, required this.userProfile}); + + final UserProfilePB userProfile; + + static const routeName = "/WorkspaceStartScreen"; + static const argUserProfile = "userProfile"; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => getIt(param1: userProfile) + ..add(const WorkspaceEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + if (UniversalPlatform.isMobile) { + return MobileWorkspaceStartScreen( + workspaceState: state, + ); + } + return DesktopWorkspaceStartScreen( + workspaceState: state, + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart b/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart new file mode 100644 index 0000000000000..8ce09a5b7f08c --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/widgets/auth_form_container.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class AuthFormContainer extends StatelessWidget { + const AuthFormContainer({ + super.key, + required this.children, + }); + + final List children; + + static const double width = 340; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: children, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart b/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart new file mode 100644 index 0000000000000..c2a13eac82a10 --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/widgets/flowy_logo_title.dart @@ -0,0 +1,42 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class FlowyLogoTitle extends StatelessWidget { + const FlowyLogoTitle({ + super.key, + required this.title, + this.logoSize = const Size.square(40), + }); + + final String title; + final Size logoSize; + + @override + Widget build(BuildContext context) { + return SizedBox( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.fromSize( + size: logoSize, + child: const FlowySvg( + FlowySvgs.flowy_logo_xl, + blendMode: null, + ), + ), + const VSpace(20), + FlowyText.regular( + title, + fontSize: FontSizes.s24, + fontFamily: + GoogleFonts.poppins(fontWeight: FontWeight.w500).fontFamily, + color: Theme.of(context).colorScheme.tertiary, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/widgets/folder_widget.dart b/frontend/appflowy_flutter/lib/user/presentation/widgets/folder_widget.dart new file mode 100644 index 0000000000000..64ed445be998f --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/widgets/folder_widget.dart @@ -0,0 +1,319 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../generated/locale_keys.g.dart'; +import '../../../startup/startup.dart'; +import '../../../workspace/presentation/home/toast.dart'; + +enum _FolderPage { + options, + create, + open, +} + +class FolderWidget extends StatefulWidget { + const FolderWidget({ + super.key, + required this.createFolderCallback, + }); + + final Future Function() createFolderCallback; + + @override + State createState() => _FolderWidgetState(); +} + +class _FolderWidgetState extends State { + var page = _FolderPage.options; + + @override + Widget build(BuildContext context) { + return _mapIndexToWidget(context); + } + + Widget _mapIndexToWidget(BuildContext context) { + switch (page) { + case _FolderPage.options: + return FolderOptionsWidget( + onPressedOpen: () { + _openFolder(); + }, + ); + case _FolderPage.create: + return CreateFolderWidget( + onPressedBack: () { + setState(() => page = _FolderPage.options); + }, + onPressedCreate: widget.createFolderCallback, + ); + case _FolderPage.open: + return const SizedBox.shrink(); + } + } + + Future _openFolder() async { + final path = await getIt().getDirectoryPath(); + if (path != null) { + await getIt().setCustomPath(path); + await widget.createFolderCallback(); + setState(() {}); + } + } +} + +class FolderOptionsWidget extends StatelessWidget { + const FolderOptionsWidget({ + super.key, + required this.onPressedOpen, + }); + + final VoidCallback onPressedOpen; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: getIt().getPath(), + builder: (context, result) { + final subtitle = result.hasData ? result.data! : ''; + return _FolderCard( + icon: const FlowySvg(FlowySvgs.archive_m), + title: LocaleKeys.settings_files_defineWhereYourDataIsStored.tr(), + subtitle: subtitle, + trailing: _buildTextButton( + context, + LocaleKeys.settings_files_set.tr(), + onPressedOpen, + ), + ); + }, + ); + } +} + +class CreateFolderWidget extends StatefulWidget { + const CreateFolderWidget({ + super.key, + required this.onPressedBack, + required this.onPressedCreate, + }); + + final VoidCallback onPressedBack; + final Future Function() onPressedCreate; + + @override + State createState() => CreateFolderWidgetState(); +} + +@visibleForTesting +class CreateFolderWidgetState extends State { + var _folderName = 'appflowy'; + @visibleForTesting + var directory = ''; + + final _fToast = FToast(); + + @override + void initState() { + super.initState(); + _fToast.init(context); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: widget.onPressedBack, + icon: const Icon(Icons.arrow_back_rounded), + label: const Text('Back'), + ), + ), + _FolderCard( + title: LocaleKeys.settings_files_location.tr(), + subtitle: LocaleKeys.settings_files_locationDesc.tr(), + trailing: SizedBox( + width: 120, + child: FlowyTextField( + hintText: LocaleKeys.settings_files_folderHintText.tr(), + onChanged: (name) => _folderName = name, + onSubmitted: (name) => setState( + () => _folderName = name, + ), + ), + ), + ), + _FolderCard( + title: LocaleKeys.settings_files_folderPath.tr(), + subtitle: _path, + trailing: _buildTextButton( + context, + LocaleKeys.settings_files_browser.tr(), + () async { + final dir = await getIt().getDirectoryPath(); + if (dir != null) { + setState(() => directory = dir); + } + }, + ), + ), + const VSpace(4.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: _buildTextButton( + context, + LocaleKeys.settings_files_create.tr(), + () async { + if (_path.isEmpty) { + _showToast( + LocaleKeys.settings_files_locationCannotBeEmpty.tr(), + ); + } else { + await getIt().setCustomPath(_path); + await widget.onPressedCreate(); + } + }, + ), + ), + ], + ); + } + + String get _path { + if (directory.isEmpty) return ''; + final String path; + if (Platform.isMacOS) { + path = directory.replaceAll('/Volumes/Macintosh HD', ''); + } else { + path = directory; + } + return '$path/$_folderName'; + } + + void _showToast(String message) { + _fToast.showToast( + child: FlowyMessageToast(message: message), + gravity: ToastGravity.CENTER, + ); + } +} + +Widget _buildTextButton( + BuildContext context, + String title, + VoidCallback onPressed, +) { + return SecondaryTextButton( + title, + mode: TextButtonMode.small, + onPressed: onPressed, + ); +} + +class _FolderCard extends StatelessWidget { + const _FolderCard({ + required this.title, + required this.subtitle, + this.trailing, + this.icon, + }); + + final String title; + final String subtitle; + final Widget? icon; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + const cardSpacing = 16.0; + return Card( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: cardSpacing, + horizontal: cardSpacing, + ), + child: Row( + children: [ + if (icon != null) + Padding( + padding: const EdgeInsets.only(right: cardSpacing), + child: icon!, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: FlowyText.regular( + title, + fontSize: FontSizes.s14, + fontFamily: GoogleFonts.poppins( + fontWeight: FontWeight.w500, + ).fontFamily, + overflow: TextOverflow.ellipsis, + ), + ), + Tooltip( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(6), + ), + preferBelow: false, + richMessage: WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Container( + color: Theme.of(context).colorScheme.surface, + padding: const EdgeInsets.all(10), + constraints: const BoxConstraints(maxWidth: 450), + child: FlowyText( + LocaleKeys.settings_menu_customPathPrompt.tr(), + maxLines: null, + ), + ), + ), + child: const FlowyIconButton( + icon: Icon( + Icons.warning_amber_rounded, + size: 20, + color: Colors.orangeAccent, + ), + ), + ), + ], + ), + const VSpace(4), + FlowyText.regular( + subtitle, + overflow: TextOverflow.ellipsis, + fontSize: FontSizes.s12, + fontFamily: GoogleFonts.poppins( + fontWeight: FontWeight.w300, + ).fontFamily, + ), + ], + ), + ), + if (trailing != null) ...[ + const HSpace(cardSpacing), + trailing!, + ], + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/user/presentation/widgets/widgets.dart b/frontend/appflowy_flutter/lib/user/presentation/widgets/widgets.dart new file mode 100644 index 0000000000000..eecb9d5300dcf --- /dev/null +++ b/frontend/appflowy_flutter/lib/user/presentation/widgets/widgets.dart @@ -0,0 +1,3 @@ +export 'folder_widget.dart'; +export 'flowy_logo_title.dart'; +export 'auth_form_container.dart'; diff --git a/frontend/appflowy_flutter/lib/util/built_in_svgs.dart b/frontend/appflowy_flutter/lib/util/built_in_svgs.dart new file mode 100644 index 0000000000000..6e7f2087b4bf8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/built_in_svgs.dart @@ -0,0 +1,13 @@ +final builtInSVGIcons = [ + '1F9CC', + '1F9DB', + '1F9DD-200D-2642-FE0F', + '1F9DE-200D-2642-FE0F', + '1F9DF', + '1F42F', + '1F43A', + '1F431', + '1F435', + '1F600', + '1F984', +]; diff --git a/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart b/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart new file mode 100644 index 0000000000000..f1bf9262a010c --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/color_generator/color_generator.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +// the color set generated from AI +final _builtInColorSet = [ + (const Color(0xFF8A2BE2), const Color(0xFFF0E6FF)), + (const Color(0xFF2E8B57), const Color(0xFFE0FFF0)), + (const Color(0xFF1E90FF), const Color(0xFFE6F3FF)), + (const Color(0xFFFF7F50), const Color(0xFFFFF0E6)), + (const Color(0xFFFF69B4), const Color(0xFFFFE6F0)), + (const Color(0xFF20B2AA), const Color(0xFFE0FFFF)), + (const Color(0xFFDC143C), const Color(0xFFFFE6E6)), + (const Color(0xFF8B4513), const Color(0xFFFFF0E6)), +]; + +extension type ColorGenerator(String value) { + Color toColor() { + final int hash = value.codeUnits.fold(0, (int acc, int unit) => acc + unit); + final double hue = (hash % 360).toDouble(); + return HSLColor.fromAHSL(1.0, hue, 0.5, 0.8).toColor(); + } + + // shuffle a color from the built-in color set, for the same name, the result should be the same + (Color, Color) randomColor() { + final hash = value.codeUnits.fold(0, (int acc, int unit) => acc + unit); + final index = hash % _builtInColorSet.length; + return _builtInColorSet[index]; + } +} diff --git a/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart b/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart new file mode 100644 index 0000000000000..34925235cbaac --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/color_to_hex_string.dart @@ -0,0 +1,16 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +extension ColorExtension on Color { + /// return a hex string in 0xff000000 format + String toHexString() { + return '0x${value.toRadixString(16).padLeft(8, '0')}'; + } + + /// return a random color + static Color random({double opacity = 1.0}) { + return Color((math.Random().nextDouble() * 0xFFFFFF).toInt()) + .withOpacity(opacity); + } +} diff --git a/frontend/appflowy_flutter/lib/util/debounce.dart b/frontend/appflowy_flutter/lib/util/debounce.dart new file mode 100644 index 0000000000000..1929d07328c43 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/debounce.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +class Debounce { + Debounce({ + this.duration = const Duration(milliseconds: 1000), + }); + + final Duration duration; + Timer? _timer; + + void call(Function action) { + dispose(); + + _timer = Timer(duration, () { + action(); + }); + } + + void dispose() { + _timer?.cancel(); + _timer = null; + } +} diff --git a/frontend/appflowy_flutter/lib/util/default_extensions.dart b/frontend/appflowy_flutter/lib/util/default_extensions.dart new file mode 100644 index 0000000000000..d0d36d2698407 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/default_extensions.dart @@ -0,0 +1,25 @@ +/// List of default file extensions used for images. +/// +/// This is used to make sure that only images that are allowed are picked/uploaded. The extensions +/// should be supported by Flutter, to avoid causing issues. +/// +/// See [Image-class documentation](https://api.flutter.dev/flutter/widgets/Image-class.html) +/// +const List defaultImageExtensions = [ + 'jpg', + 'png', + 'jpeg', + 'gif', + 'webp', + 'bmp', +]; + +extension ImageStringExtension on String { + bool isImageUrl() { + final imagePattern = RegExp( + r'\.(jpe?g|png|gif|webp|bmp)$', + caseSensitive: false, + ); + return imagePattern.hasMatch(this); + } +} diff --git a/frontend/appflowy_flutter/lib/util/field_type_extension.dart b/frontend/appflowy_flutter/lib/util/field_type_extension.dart new file mode 100644 index 0000000000000..4ef11bf5c68d4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/field_type_extension.dart @@ -0,0 +1,166 @@ +import 'dart:ui'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:protobuf/protobuf.dart'; + +extension FieldTypeExtension on FieldType { + String get i18n => switch (this) { + FieldType.RichText => LocaleKeys.grid_field_textFieldName.tr(), + FieldType.Number => LocaleKeys.grid_field_numberFieldName.tr(), + FieldType.DateTime => LocaleKeys.grid_field_dateFieldName.tr(), + FieldType.SingleSelect => + LocaleKeys.grid_field_singleSelectFieldName.tr(), + FieldType.MultiSelect => + LocaleKeys.grid_field_multiSelectFieldName.tr(), + FieldType.Checkbox => LocaleKeys.grid_field_checkboxFieldName.tr(), + FieldType.Checklist => LocaleKeys.grid_field_checklistFieldName.tr(), + FieldType.URL => LocaleKeys.grid_field_urlFieldName.tr(), + FieldType.LastEditedTime => + LocaleKeys.grid_field_updatedAtFieldName.tr(), + FieldType.CreatedTime => LocaleKeys.grid_field_createdAtFieldName.tr(), + FieldType.Relation => LocaleKeys.grid_field_relationFieldName.tr(), + FieldType.Summary => LocaleKeys.grid_field_summaryFieldName.tr(), + FieldType.Time => LocaleKeys.grid_field_timeFieldName.tr(), + FieldType.Translate => LocaleKeys.grid_field_translateFieldName.tr(), + FieldType.Media => LocaleKeys.grid_field_mediaFieldName.tr(), + _ => throw UnimplementedError(), + }; + + FlowySvgData get svgData => switch (this) { + FieldType.RichText => FlowySvgs.text_s, + FieldType.Number => FlowySvgs.number_s, + FieldType.DateTime => FlowySvgs.date_s, + FieldType.SingleSelect => FlowySvgs.single_select_s, + FieldType.MultiSelect => FlowySvgs.multiselect_s, + FieldType.Checkbox => FlowySvgs.checkbox_s, + FieldType.URL => FlowySvgs.url_s, + FieldType.Checklist => FlowySvgs.checklist_s, + FieldType.LastEditedTime => FlowySvgs.time_s, + FieldType.CreatedTime => FlowySvgs.time_s, + FieldType.Relation => FlowySvgs.relation_s, + FieldType.Summary => FlowySvgs.ai_summary_s, + FieldType.Time => FlowySvgs.timer_start_s, + FieldType.Translate => FlowySvgs.ai_translate_s, + FieldType.Media => FlowySvgs.media_s, + _ => throw UnimplementedError(), + }; + + FlowySvgData? get rightIcon => switch (this) { + FieldType.Summary => FlowySvgs.ai_indicator_s, + FieldType.Translate => FlowySvgs.ai_indicator_s, + _ => null, + }; + + Color get mobileIconBackgroundColor => switch (this) { + FieldType.RichText => const Color(0xFFBECCFF), + FieldType.Number => const Color(0xFFCABDFF), + FieldType.URL => const Color(0xFFFFB9EF), + FieldType.SingleSelect => const Color(0xFFBECCFF), + FieldType.MultiSelect => const Color(0xFFBECCFF), + FieldType.DateTime => const Color(0xFFFDEDA7), + FieldType.LastEditedTime => const Color(0xFFFDEDA7), + FieldType.CreatedTime => const Color(0xFFFDEDA7), + FieldType.Checkbox => const Color(0xFF98F4CD), + FieldType.Checklist => const Color(0xFF98F4CD), + FieldType.Relation => const Color(0xFFFDEDA7), + FieldType.Summary => const Color(0xFFBECCFF), + FieldType.Time => const Color(0xFFFDEDA7), + FieldType.Translate => const Color(0xFFBECCFF), + FieldType.Media => const Color(0xFF91EBF5), + _ => throw UnimplementedError(), + }; + + // TODO(RS): inner icon color isn't always white + Color get mobileIconBackgroundColorDark => switch (this) { + FieldType.RichText => const Color(0xFF6859A7), + FieldType.Number => const Color(0xFF6859A7), + FieldType.URL => const Color(0xFFA75C96), + FieldType.SingleSelect => const Color(0xFF5366AB), + FieldType.MultiSelect => const Color(0xFF5366AB), + FieldType.DateTime => const Color(0xFFB0A26D), + FieldType.LastEditedTime => const Color(0xFFB0A26D), + FieldType.CreatedTime => const Color(0xFFB0A26D), + FieldType.Checkbox => const Color(0xFF42AD93), + FieldType.Checklist => const Color(0xFF42AD93), + FieldType.Relation => const Color(0xFFFDEDA7), + FieldType.Summary => const Color(0xFF6859A7), + FieldType.Time => const Color(0xFFFDEDA7), + FieldType.Translate => const Color(0xFF6859A7), + FieldType.Media => const Color(0xFF91EBF5), + _ => throw UnimplementedError(), + }; + + bool get canBeGroup => switch (this) { + FieldType.URL || + FieldType.Checkbox || + FieldType.MultiSelect || + FieldType.SingleSelect || + FieldType.DateTime => + true, + _ => false + }; + + bool get canCreateFilter => switch (this) { + FieldType.Number || + FieldType.Checkbox || + FieldType.MultiSelect || + FieldType.RichText || + FieldType.SingleSelect || + FieldType.Checklist || + FieldType.URL || + FieldType.DateTime || + FieldType.CreatedTime || + FieldType.LastEditedTime => + true, + _ => false + }; + + bool get canCreateSort => switch (this) { + FieldType.RichText || + FieldType.Checkbox || + FieldType.Number || + FieldType.DateTime || + FieldType.SingleSelect || + FieldType.MultiSelect || + FieldType.LastEditedTime || + FieldType.CreatedTime || + FieldType.Checklist || + FieldType.URL || + FieldType.Time => + true, + _ => false + }; + + bool get canEditHeader => switch (this) { + FieldType.MultiSelect => true, + FieldType.SingleSelect => true, + _ => false, + }; + + bool get canCreateNewGroup => switch (this) { + FieldType.MultiSelect => true, + FieldType.SingleSelect => true, + _ => false, + }; + + bool get canDeleteGroup => switch (this) { + FieldType.URL || + FieldType.SingleSelect || + FieldType.MultiSelect || + FieldType.DateTime => + true, + _ => false, + }; + + List get groupConditions { + switch (this) { + case FieldType.DateTime: + return DateConditionPB.values; + default: + return []; + } + } +} diff --git a/frontend/appflowy_flutter/lib/util/file_extension.dart b/frontend/appflowy_flutter/lib/util/file_extension.dart new file mode 100644 index 0000000000000..69c20d1dcb10d --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/file_extension.dart @@ -0,0 +1,11 @@ +import 'dart:io'; + +extension FileSizeExtension on String { + int? get fileSize { + final file = File(this); + if (file.existsSync()) { + return file.lengthSync(); + } + return null; + } +} diff --git a/frontend/appflowy_flutter/lib/util/font_family_extension.dart b/frontend/appflowy_flutter/lib/util/font_family_extension.dart new file mode 100644 index 0000000000000..12fb5aaad0ba8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/font_family_extension.dart @@ -0,0 +1,15 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:easy_localization/easy_localization.dart'; + +extension FontFamilyExtension on String { + String parseFontFamilyName() => replaceAll('_regular', '') + .replaceAllMapped(camelCaseRegex, (m) => ' ${m.group(0)}'); + + // display the default font name if the font family name is empty + // or using the default font family + String get fontFamilyDisplayName => isEmpty || this == defaultFontFamily + ? LocaleKeys.settings_appearance_fontFamily_defaultFont.tr() + : parseFontFamilyName(); +} diff --git a/frontend/appflowy_flutter/lib/util/int64_extension.dart b/frontend/appflowy_flutter/lib/util/int64_extension.dart new file mode 100644 index 0000000000000..8c7f1579f5ddd --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/int64_extension.dart @@ -0,0 +1,5 @@ +import 'package:fixnum/fixnum.dart'; + +extension DateConversion on Int64 { + DateTime toDateTime() => DateTime.fromMillisecondsSinceEpoch(toInt() * 1000); +} diff --git a/frontend/appflowy_flutter/lib/util/json_print.dart b/frontend/appflowy_flutter/lib/util/json_print.dart new file mode 100644 index 0000000000000..35824b82121a6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/json_print.dart @@ -0,0 +1,10 @@ +import 'dart:convert'; + +import 'package:appflowy_backend/log.dart'; +import 'package:flutter/material.dart'; + +const JsonEncoder _encoder = JsonEncoder.withIndent(' '); +void prettyPrintJson(Object? object) { + Log.trace(_encoder.convert(object)); + debugPrint(_encoder.convert(object)); +} diff --git a/frontend/appflowy_flutter/lib/util/levenshtein.dart b/frontend/appflowy_flutter/lib/util/levenshtein.dart new file mode 100644 index 0000000000000..b8b8eafeccf8b --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/levenshtein.dart @@ -0,0 +1,26 @@ +import 'dart:math'; + +int levenshtein(String s, String t, {bool caseSensitive = true}) { + if (!caseSensitive) { + s = s.toLowerCase(); + t = t.toLowerCase(); + } + + if (s == t) return 0; + + final v0 = List.generate(t.length + 1, (i) => i); + final v1 = List.filled(t.length + 1, 0); + + for (var i = 0; i < s.length; i++) { + v1[0] = i + 1; + + for (var j = 0; j < t.length; j++) { + final cost = (s[i] == t[j]) ? 0 : 1; + v1[j + 1] = min(v1[j] + 1, min(v0[j + 1] + 1, v0[j] + cost)); + } + + v0.setAll(0, v1); + } + + return v1[t.length]; +} diff --git a/frontend/appflowy_flutter/lib/util/navigator_context_extension.dart b/frontend/appflowy_flutter/lib/util/navigator_context_extension.dart new file mode 100644 index 0000000000000..b5cfeb64fef02 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/navigator_context_extension.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +extension NavigatorContext on BuildContext { + void popToHome() { + Navigator.of(this).popUntil((route) { + if (route.settings.name == '/') { + return true; + } + return false; + }); + } +} diff --git a/frontend/appflowy_flutter/lib/util/share_log_files.dart b/frontend/appflowy_flutter/lib/util/share_log_files.dart new file mode 100644 index 0000000000000..d7e7b6ce87151 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/share_log_files.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:archive/archive_io.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +Future shareLogFiles(BuildContext? context) async { + final dir = await getApplicationSupportDirectory(); + final zipEncoder = ZipEncoder(); + + final archiveLogFiles = dir + .listSync(recursive: true) + .where((e) => p.basename(e.path).startsWith('log.')) + .map((e) { + final bytes = File(e.path).readAsBytesSync(); + return ArchiveFile(p.basename(e.path), bytes.length, bytes); + }); + + if (archiveLogFiles.isEmpty) { + if (context != null && context.mounted) { + showToastNotification( + context, + message: LocaleKeys.noLogFiles.tr(), + type: ToastificationType.error, + ); + } + return; + } + + final archive = Archive(); + for (final file in archiveLogFiles) { + archive.addFile(file); + } + + final zip = zipEncoder.encode(archive); + if (zip == null) { + if (context != null && context.mounted) { + showToastNotification( + context, + message: LocaleKeys.noLogFiles.tr(), + type: ToastificationType.error, + ); + } + return; + } + + // create a zipped appflowy logs file + try { + final tempDirectory = await getTemporaryDirectory(); + final path = Platform.isAndroid ? tempDirectory.path : dir.path; + final zipFile = + await File(p.join(path, 'appflowy_logs.zip')).writeAsBytes(zip); + + if (Platform.isIOS) { + await Share.shareUri(zipFile.uri); + // delete the zipped appflowy logs file + await zipFile.delete(); + } else if (Platform.isAndroid) { + await Share.shareXFiles([XFile(zipFile.path)]); + // delete the zipped appflowy logs file + await zipFile.delete(); + } else { + // open the directory + await afLaunchUri(zipFile.uri); + } + } catch (e) { + if (context != null && context.mounted) { + showToastNotification( + context, + message: e.toString(), + type: ToastificationType.error, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/util/string_extension.dart b/frontend/appflowy_flutter/lib/util/string_extension.dart new file mode 100644 index 0000000000000..3e66e73052d97 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/string_extension.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' hide Icon; + +extension StringExtension on String { + static const _specialCharacters = r'\/:*?"<>| '; + + /// Encode a string to a file name. + /// + /// Normalizes the string to remove special characters and replaces the "\/:*?"<>|" with underscores. + String toFileName() { + final buffer = StringBuffer(); + for (final character in characters) { + if (_specialCharacters.contains(character)) { + buffer.write('_'); + } else { + buffer.write(character); + } + } + return buffer.toString(); + } + + /// Returns the file size of the file at the given path. + /// + /// Returns null if the file does not exist. + int? get fileSize { + final file = File(this); + if (file.existsSync()) { + return file.lengthSync(); + } + return null; + } + + /// Returns true if the string is a appflowy cloud url. + bool get isAppFlowyCloudUrl => appflowyCloudUrlRegex.hasMatch(this); + + /// Returns the color of the string. + /// + /// ONLY used for the cover. + Color? coverColor(BuildContext context) { + // try to parse the color from the tint id, + // if it fails, try to parse the color as a hex string + return FlowyTint.fromId(this)?.color(context) ?? tryToColor(); + } + + String orDefault(String defaultValue) { + return isEmpty ? defaultValue : this; + } +} + +extension NullableStringExtension on String? { + String orDefault(String defaultValue) { + return this?.isEmpty ?? true ? defaultValue : this ?? ''; + } +} + +extension IconExtension on String { + Icon? get icon { + final values = split('/'); + if (values.length != 2) { + return null; + } + final iconGroup = IconGroup(name: values.first, icons: []); + if (kDebugMode) { + // Ensure the icon group and icon exist + assert(kIconGroups!.any((group) => group.name == values.first)); + assert( + kIconGroups! + .firstWhere((group) => group.name == values.first) + .icons + .any((icon) => icon.name == values.last), + ); + } + return Icon( + content: values.last, + name: values.last, + keywords: [], + )..iconGroup = iconGroup; + } +} diff --git a/frontend/appflowy_flutter/lib/util/theme_extension.dart b/frontend/appflowy_flutter/lib/util/theme_extension.dart new file mode 100644 index 0000000000000..c7b56699d312d --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/theme_extension.dart @@ -0,0 +1,5 @@ +import 'package:flutter/material.dart'; + +extension IsLightMode on ThemeData { + bool get isLightMode => brightness == Brightness.light; +} diff --git a/frontend/appflowy_flutter/lib/util/theme_mode_extension.dart b/frontend/appflowy_flutter/lib/util/theme_mode_extension.dart new file mode 100644 index 0000000000000..228339915a48f --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/theme_mode_extension.dart @@ -0,0 +1,12 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +extension LabelTextPhrasing on ThemeMode { + String get labelText => switch (this) { + ThemeMode.light => LocaleKeys.settings_appearance_themeMode_light.tr(), + ThemeMode.dark => LocaleKeys.settings_appearance_themeMode_dark.tr(), + ThemeMode.system => + LocaleKeys.settings_appearance_themeMode_system.tr(), + }; +} diff --git a/frontend/appflowy_flutter/lib/util/throttle.dart b/frontend/appflowy_flutter/lib/util/throttle.dart new file mode 100644 index 0000000000000..c8c6dcf0ca675 --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/throttle.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +class Throttler { + Throttler({ + this.duration = const Duration(milliseconds: 1000), + }); + + final Duration duration; + Timer? _timer; + + void call(Function callback) { + if (_timer?.isActive ?? false) return; + + _timer = Timer(duration, () { + callback(); + }); + } + + void dispose() { + _timer?.cancel(); + _timer = null; + } +} diff --git a/frontend/appflowy_flutter/lib/util/time.dart b/frontend/appflowy_flutter/lib/util/time.dart new file mode 100644 index 0000000000000..cdeb9834fcdcd --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/time.dart @@ -0,0 +1,43 @@ +final RegExp timerRegExp = + RegExp(r'(?:(?\d*)h)? ?(?:(?\d*)m)?'); + +int? parseTime(String timerStr) { + int? res = int.tryParse(timerStr); + if (res != null) { + return res; + } + + final matches = timerRegExp.firstMatch(timerStr); + if (matches == null) { + return null; + } + final hours = int.tryParse(matches.namedGroup('hours') ?? ""); + final minutes = int.tryParse(matches.namedGroup('minutes') ?? ""); + if (hours == null && minutes == null) { + return null; + } + + final expected = + "${hours != null ? '${hours}h' : ''}${hours != null && minutes != null ? ' ' : ''}${minutes != null ? '${minutes}m' : ''}"; + if (timerStr != expected) { + return null; + } + + res = 0; + res += hours != null ? hours * 60 : res; + res += minutes ?? 0; + + return res; +} + +String formatTime(int minutes) { + if (minutes >= 60) { + if (minutes % 60 == 0) { + return "${minutes ~/ 60}h"; + } + return "${minutes ~/ 60}h ${minutes % 60}m"; + } else if (minutes >= 0) { + return "${minutes}m"; + } + return ""; +} diff --git a/frontend/appflowy_flutter/lib/util/xfile_ext.dart b/frontend/appflowy_flutter/lib/util/xfile_ext.dart new file mode 100644 index 0000000000000..593ea337c1a3b --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/xfile_ext.dart @@ -0,0 +1,109 @@ +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pbenum.dart'; +import 'package:cross_file/cross_file.dart'; + +enum FileType { + other, + image, + link, + document, + archive, + video, + audio, + text; +} + +extension TypeRecognizer on XFile { + FileType get fileType { + // Prefer mime over using regexp as it is more reliable. + // Refer to Microsoft Documentation for common mime types: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types + if (mimeType?.isNotEmpty == true) { + if (mimeType!.contains('image')) { + return FileType.image; + } + if (mimeType!.contains('video')) { + return FileType.video; + } + if (mimeType!.contains('audio')) { + return FileType.audio; + } + if (mimeType!.contains('text')) { + return FileType.text; + } + if (mimeType!.contains('application')) { + if (mimeType!.contains('pdf') || + mimeType!.contains('doc') || + mimeType!.contains('docx')) { + return FileType.document; + } + if (mimeType!.contains('zip') || + mimeType!.contains('tar') || + mimeType!.contains('gz') || + mimeType!.contains('7z') || + // archive is used in eg. Java archives (jar) + mimeType!.contains('archive') || + mimeType!.contains('rar')) { + return FileType.archive; + } + if (mimeType!.contains('rtf')) { + return FileType.text; + } + } + + return FileType.other; + } + + // Check if the file is an image + if (imgExtensionRegex.hasMatch(path)) { + return FileType.image; + } + + // Check if the file is a video + if (videoExtensionRegex.hasMatch(path)) { + return FileType.video; + } + + // Check if the file is an audio + if (audioExtensionRegex.hasMatch(path)) { + return FileType.audio; + } + + // Check if the file is a document + if (documentExtensionRegex.hasMatch(path)) { + return FileType.document; + } + + // Check if the file is an archive + if (archiveExtensionRegex.hasMatch(path)) { + return FileType.archive; + } + + // Check if the file is a text + if (textExtensionRegex.hasMatch(path)) { + return FileType.text; + } + + return FileType.other; + } +} + +extension ToMediaFileTypePB on FileType { + MediaFileTypePB toMediaFileTypePB() { + switch (this) { + case FileType.image: + return MediaFileTypePB.Image; + case FileType.video: + return MediaFileTypePB.Video; + case FileType.audio: + return MediaFileTypePB.Audio; + case FileType.document: + return MediaFileTypePB.Document; + case FileType.archive: + return MediaFileTypePB.Archive; + case FileType.text: + return MediaFileTypePB.Text; + default: + return MediaFileTypePB.Other; + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/action_navigation/action_navigation_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/action_navigation/action_navigation_bloc.dart new file mode 100644 index 0000000000000..5bc64b6785bc3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/action_navigation/action_navigation_bloc.dart @@ -0,0 +1,81 @@ +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'action_navigation_bloc.freezed.dart'; + +class ActionNavigationBloc + extends Bloc { + ActionNavigationBloc() : super(const ActionNavigationState.initial()) { + on((event, emit) async { + await event.when( + performAction: (action, nextActions) async { + NavigationAction currentAction = action; + if (currentAction.arguments?[ActionArgumentKeys.view] == null && + action.type == ActionType.openView) { + final result = await ViewBackendService.getView(action.objectId); + final view = result.toNullable(); + if (view != null) { + if (currentAction.arguments == null) { + currentAction = currentAction.copyWith(arguments: {}); + } + + currentAction.arguments?.addAll({ActionArgumentKeys.view: view}); + } + } + + emit(state.copyWith(action: currentAction, nextActions: nextActions)); + + if (nextActions.isNotEmpty) { + final newActions = [...nextActions]; + final next = newActions.removeAt(0); + + add( + ActionNavigationEvent.performAction( + action: next, + nextActions: newActions, + ), + ); + } else { + emit(state.setNoAction()); + } + }, + ); + }); + } +} + +@freezed +class ActionNavigationEvent with _$ActionNavigationEvent { + const factory ActionNavigationEvent.performAction({ + required NavigationAction action, + @Default([]) List nextActions, + }) = _PerformAction; +} + +class ActionNavigationState { + const ActionNavigationState.initial() + : action = null, + nextActions = const []; + + const ActionNavigationState({ + required this.action, + this.nextActions = const [], + }); + + final NavigationAction? action; + final List nextActions; + + ActionNavigationState copyWith({ + NavigationAction? action, + List? nextActions, + }) => + ActionNavigationState( + action: action ?? this.action, + nextActions: nextActions ?? this.nextActions, + ); + + ActionNavigationState setNoAction() => + const ActionNavigationState(action: null); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/action_navigation/navigation_action.dart b/frontend/appflowy_flutter/lib/workspace/application/action_navigation/navigation_action.dart new file mode 100644 index 0000000000000..52663ea219810 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/action_navigation/navigation_action.dart @@ -0,0 +1,41 @@ +enum ActionType { + openView, + jumpToBlock, + openRow, +} + +class ActionArgumentKeys { + static String view = "view"; + static String nodePath = "node_path"; + static String blockId = "block_id"; + static String rowId = "row_id"; +} + +/// A [NavigationAction] is used to communicate with the +/// [ActionNavigationBloc] to perform actions based on an event +/// triggered by pressing a notification, such as opening a specific +/// view and jumping to a specific block. +/// +class NavigationAction { + const NavigationAction({ + this.type = ActionType.openView, + this.arguments, + required this.objectId, + }); + + final ActionType type; + + final String objectId; + final Map? arguments; + + NavigationAction copyWith({ + ActionType? type, + String? objectId, + Map? arguments, + }) => + NavigationAction( + type: type ?? this.type, + objectId: objectId ?? this.objectId, + arguments: arguments ?? this.arguments, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart b/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart new file mode 100644 index 0000000000000..d900afd6eb838 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:flowy_infra/theme.dart'; + +/// A class for the default appearance settings for the app +class DefaultAppearanceSettings { + static const kDefaultFontFamily = defaultFontFamily; + static const kDefaultThemeMode = ThemeMode.system; + static const kDefaultThemeName = "Default"; + static const kDefaultTheme = BuiltInTheme.defaultTheme; + + static Color getDefaultCursorColor(BuildContext context) { + return Theme.of(context).colorScheme.primary; + } + + static Color getDefaultSelectionColor(BuildContext context) { + return Theme.of(context).colorScheme.primary.withOpacity(0.2); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart new file mode 100644 index 0000000000000..7787525847ba3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/command_palette_bloc.dart @@ -0,0 +1,204 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:appflowy/plugins/trash/application/trash_listener.dart'; +import 'package:appflowy/plugins/trash/application/trash_service.dart'; +import 'package:appflowy/workspace/application/command_palette/search_listener.dart'; +import 'package:appflowy/workspace/application/command_palette/search_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'command_palette_bloc.freezed.dart'; + +const _searchChannel = 'CommandPalette'; + +class CommandPaletteBloc + extends Bloc { + CommandPaletteBloc() : super(CommandPaletteState.initial()) { + _searchListener.start( + onResultsChanged: _onResultsChanged, + ); + + _initTrash(); + _dispatch(); + } + + Timer? _debounceOnChanged; + final TrashService _trashService = TrashService(); + final SearchListener _searchListener = SearchListener( + channel: _searchChannel, + ); + final TrashListener _trashListener = TrashListener(); + String? _oldQuery; + String? _workspaceId; + int _messagesReceived = 0; + + @override + Future close() { + _trashListener.close(); + _searchListener.stop(); + _debounceOnChanged?.cancel(); + return super.close(); + } + + void _dispatch() { + on((event, emit) async { + event.when( + searchChanged: _debounceOnSearchChanged, + trashChanged: (trash) async { + if (trash != null) { + return emit(state.copyWith(trash: trash)); + } + + final trashOrFailure = await _trashService.readTrash(); + final trashRes = trashOrFailure.fold( + (trash) => trash, + (error) => null, + ); + + if (trashRes != null) { + emit(state.copyWith(trash: trashRes.items)); + } + }, + performSearch: (search) async { + if (search.isNotEmpty && search != state.query) { + _oldQuery = state.query; + emit(state.copyWith(query: search, isLoading: true)); + await SearchBackendService.performSearch( + search, + workspaceId: _workspaceId, + channel: _searchChannel, + ); + } else { + emit(state.copyWith(query: null, isLoading: false, results: [])); + } + }, + resultsChanged: (results) { + if (state.query != _oldQuery) { + emit(state.copyWith(results: [], isLoading: true)); + _oldQuery = state.query; + _messagesReceived = 0; + } + + if (state.query != results.query) { + return; + } + + _messagesReceived++; + + final searchResults = _filterDuplicates(results.items); + searchResults.sort((a, b) => b.score.compareTo(a.score)); + + emit( + state.copyWith( + results: searchResults, + isLoading: _messagesReceived != results.sends.toInt(), + ), + ); + }, + workspaceChanged: (workspaceId) { + _workspaceId = workspaceId; + emit(state.copyWith(results: [], query: '', isLoading: false)); + }, + clearSearch: () { + emit(state.copyWith(results: [], query: '', isLoading: false)); + }, + ); + }); + } + + Future _initTrash() async { + _trashListener.start( + trashUpdated: (trashOrFailed) { + final trash = trashOrFailed.toNullable(); + add(CommandPaletteEvent.trashChanged(trash: trash)); + }, + ); + + final trashOrFailure = await _trashService.readTrash(); + final trash = trashOrFailure.toNullable(); + + add(CommandPaletteEvent.trashChanged(trash: trash?.items)); + } + + void _debounceOnSearchChanged(String value) { + _debounceOnChanged?.cancel(); + _debounceOnChanged = Timer( + const Duration(milliseconds: 300), + () => _performSearch(value), + ); + } + + List _filterDuplicates(List results) { + final currentItems = [...state.results]; + final res = [...results]; + + for (final item in results) { + if (item.data.trim().isEmpty) { + continue; + } + + final duplicateIndex = currentItems.indexWhere((a) => a.id == item.id); + if (duplicateIndex == -1) { + continue; + } + + final duplicate = currentItems[duplicateIndex]; + if (item.score < duplicate.score) { + res.remove(item); + } else { + currentItems.remove(duplicate); + } + } + + return res..addAll(currentItems); + } + + void _performSearch(String value) => + add(CommandPaletteEvent.performSearch(search: value)); + + void _onResultsChanged(SearchResultNotificationPB results) => + add(CommandPaletteEvent.resultsChanged(results: results)); +} + +@freezed +class CommandPaletteEvent with _$CommandPaletteEvent { + const factory CommandPaletteEvent.searchChanged({required String search}) = + _SearchChanged; + + const factory CommandPaletteEvent.performSearch({required String search}) = + _PerformSearch; + + const factory CommandPaletteEvent.resultsChanged({ + required SearchResultNotificationPB results, + }) = _ResultsChanged; + + const factory CommandPaletteEvent.trashChanged({ + @Default(null) List? trash, + }) = _TrashChanged; + + const factory CommandPaletteEvent.workspaceChanged({ + @Default(null) String? workspaceId, + }) = _WorkspaceChanged; + + const factory CommandPaletteEvent.clearSearch() = _ClearSearch; +} + +@freezed +class CommandPaletteState with _$CommandPaletteState { + const CommandPaletteState._(); + + const factory CommandPaletteState({ + @Default(null) String? query, + required List results, + required bool isLoading, + @Default([]) List trash, + }) = _CommandPaletteState; + + factory CommandPaletteState.initial() => + const CommandPaletteState(results: [], isLoading: false); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_listener.dart new file mode 100644 index 0000000000000..b22630eb74def --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_listener.dart @@ -0,0 +1,74 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/search_notification.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +// Do not modify! +const _searchObjectId = "SEARCH_IDENTIFIER"; + +class SearchListener { + SearchListener({this.channel}); + + /// Use this to filter out search results from other channels. + /// + /// If null, it will receive search results from all + /// channels, otherwise it will only receive search results from the specified + /// channel. + /// + final String? channel; + + PublishNotifier? _updateNotifier = + PublishNotifier(); + PublishNotifier? _updateDidCloseNotifier = + PublishNotifier(); + SearchNotificationListener? _listener; + + void start({ + void Function(SearchResultNotificationPB)? onResultsChanged, + void Function(SearchResultNotificationPB)? onResultsClosed, + }) { + if (onResultsChanged != null) { + _updateNotifier?.addPublishListener(onResultsChanged); + } + + if (onResultsClosed != null) { + _updateDidCloseNotifier?.addPublishListener(onResultsClosed); + } + + _listener = SearchNotificationListener( + objectId: _searchObjectId, + handler: _handler, + channel: channel, + ); + } + + void _handler( + SearchNotification ty, + FlowyResult result, + ) { + switch (ty) { + case SearchNotification.DidUpdateResults: + result.fold( + (payload) => _updateNotifier?.value = + SearchResultNotificationPB.fromBuffer(payload), + (err) => Log.error(err), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _updateNotifier?.dispose(); + _updateNotifier = null; + _updateDidCloseNotifier?.dispose(); + _updateDidCloseNotifier = null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart new file mode 100644 index 0000000000000..7b2eece965290 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_result_ext.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; + +extension GetIcon on SearchResultPB { + Widget? getIcon() { + if (icon.ty == ResultIconTypePB.Emoji) { + return icon.value.isNotEmpty + ? Text( + icon.value, + style: const TextStyle(fontSize: 18.0), + ) + : null; + } else if (icon.ty == ResultIconTypePB.Icon) { + return FlowySvg(icon.getViewSvg(), size: const Size.square(20)); + } + + return null; + } +} + +extension _ToViewIcon on ResultIconPB { + FlowySvgData getViewSvg() => switch (value) { + "0" => FlowySvgs.icon_document_s, + "1" => FlowySvgs.icon_grid_s, + "2" => FlowySvgs.icon_board_s, + "3" => FlowySvgs.icon_calendar_s, + _ => FlowySvgs.icon_document_s, + }; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart new file mode 100644 index 0000000000000..53a229ae66cd6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/command_palette/search_service.dart @@ -0,0 +1,22 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/query.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/search_filter.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class SearchBackendService { + static Future> performSearch( + String keyword, { + String? workspaceId, + String? channel, + }) async { + final filter = SearchFilterPB(workspaceId: workspaceId); + final request = SearchQueryPB( + search: keyword, + filter: filter, + channel: channel, + ); + + return SearchEventSearch(request).send(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/edit_panel/edit_context.dart b/frontend/appflowy_flutter/lib/workspace/application/edit_panel/edit_context.dart new file mode 100644 index 0000000000000..b0b4406ac316e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/edit_panel/edit_context.dart @@ -0,0 +1,17 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +abstract class EditPanelContext extends Equatable { + const EditPanelContext({ + required this.identifier, + required this.title, + required this.child, + }); + + final String identifier; + final String title; + final Widget child; + + @override + List get props => [identifier]; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/edit_panel/edit_panel_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/edit_panel/edit_panel_bloc.dart new file mode 100644 index 0000000000000..52d36033fe38c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/edit_panel/edit_panel_bloc.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/workspace/application/edit_panel/edit_context.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'edit_panel_bloc.freezed.dart'; + +class EditPanelBloc extends Bloc { + EditPanelBloc() : super(EditPanelState.initial()) { + on((event, emit) async { + await event.map( + startEdit: (e) async { + emit(state.copyWith(isEditing: true, editContext: e.context)); + }, + endEdit: (value) async { + emit(state.copyWith(isEditing: false, editContext: null)); + }, + ); + }); + } +} + +@freezed +class EditPanelEvent with _$EditPanelEvent { + const factory EditPanelEvent.startEdit(EditPanelContext context) = _StartEdit; + + const factory EditPanelEvent.endEdit(EditPanelContext context) = _EndEdit; +} + +@freezed +class EditPanelState with _$EditPanelState { + const factory EditPanelState({ + required bool isEditing, + required EditPanelContext? editContext, + }) = _EditPanelState; + + factory EditPanelState.initial() => const EditPanelState( + isEditing: false, + editContext: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart b/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart new file mode 100644 index 0000000000000..df46ab97e7ad0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/export/document_exporter.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; + +enum DocumentExportType { + json, + markdown, + text, + html, +} + +class DocumentExporter { + const DocumentExporter( + this.view, + ); + + final ViewPB view; + + Future> export( + DocumentExportType type, + ) async { + final documentService = DocumentService(); + final result = await documentService.openDocument(documentId: view.id); + return result.fold( + (r) { + final document = r.toDocument(); + if (document == null) { + return FlowyResult.failure( + FlowyError( + msg: LocaleKeys.settings_files_exportFileFail.tr(), + ), + ); + } + switch (type) { + case DocumentExportType.json: + return FlowyResult.success(jsonEncode(document)); + case DocumentExportType.markdown: + final markdown = customDocumentToMarkdown(document); + return FlowyResult.success(markdown); + case DocumentExportType.text: + throw UnimplementedError(); + case DocumentExportType.html: + final html = documentToHTML( + document, + ); + return FlowyResult.success(html); + } + }, + (error) => FlowyResult.failure(error), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart new file mode 100644 index 0000000000000..13322807b3be5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_bloc.dart @@ -0,0 +1,119 @@ +import 'package:appflowy/workspace/application/favorite/favorite_service.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'favorite_listener.dart'; + +part 'favorite_bloc.freezed.dart'; + +class FavoriteBloc extends Bloc { + FavoriteBloc() : super(FavoriteState.initial()) { + _dispatch(); + } + + final _service = FavoriteService(); + final _listener = FavoriteListener(); + + @override + Future close() async { + await _listener.stop(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () async { + _listener.start( + favoritesUpdated: _onFavoritesUpdated, + ); + add(const FavoriteEvent.fetchFavorites()); + }, + fetchFavorites: () async { + final result = await _service.readFavorites(); + emit( + result.fold( + (favoriteViews) { + final views = favoriteViews.items.toList(); + final pinnedViews = + views.where((v) => v.item.isPinned).toList(); + final unpinnedViews = + views.where((v) => !v.item.isPinned).toList(); + return state.copyWith( + isLoading: false, + views: views, + pinnedViews: pinnedViews, + unpinnedViews: unpinnedViews, + ); + }, + (error) => state.copyWith( + isLoading: false, + views: [], + ), + ), + ); + }, + toggle: (view) async { + final isFavorited = state.views.any((v) => v.item.id == view.id); + if (isFavorited) { + await _service.unpinFavorite(view); + } else if (state.pinnedViews.length < 3) { + // pin the view if there are less than 3 pinned views + await _service.pinFavorite(view); + } + + await _service.toggleFavorite( + view.id, + !view.isFavorite, + ); + }, + pin: (view) async { + await _service.pinFavorite(view); + add(const FavoriteEvent.fetchFavorites()); + }, + unpin: (view) async { + await _service.unpinFavorite(view); + add(const FavoriteEvent.fetchFavorites()); + }, + ); + }, + ); + } + + void _onFavoritesUpdated( + FlowyResult favoriteOrFailed, + bool didFavorite, + ) { + favoriteOrFailed.fold( + (favorite) => add(const FetchFavorites()), + (error) => Log.error(error), + ); + } +} + +@freezed +class FavoriteEvent with _$FavoriteEvent { + const factory FavoriteEvent.initial() = Initial; + const factory FavoriteEvent.toggle(ViewPB view) = ToggleFavorite; + const factory FavoriteEvent.fetchFavorites() = FetchFavorites; + const factory FavoriteEvent.pin(ViewPB view) = PinFavorite; + const factory FavoriteEvent.unpin(ViewPB view) = UnpinFavorite; +} + +@freezed +class FavoriteState with _$FavoriteState { + const factory FavoriteState({ + @Default([]) List views, + @Default([]) List pinnedViews, + @Default([]) List unpinnedViews, + @Default(true) bool isLoading, + }) = _FavoriteState; + + factory FavoriteState.initial() => const FavoriteState(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_listener.dart new file mode 100644 index 0000000000000..0cadf9c91f365 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_listener.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:appflowy/core/notification/folder_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/foundation.dart'; + +typedef FavoriteUpdated = void Function( + FlowyResult result, + bool isFavorite, +); + +class FavoriteListener { + StreamSubscription? _streamSubscription; + FolderNotificationParser? _parser; + + FavoriteUpdated? _favoriteUpdated; + + void start({ + FavoriteUpdated? favoritesUpdated, + }) { + _favoriteUpdated = favoritesUpdated; + _parser = FolderNotificationParser( + id: 'favorite', + callback: _observableCallback, + ); + _streamSubscription = RustStreamReceiver.listen( + (observable) => _parser?.parse(observable), + ); + } + + void _observableCallback( + FolderNotification ty, + FlowyResult result, + ) { + switch (ty) { + case FolderNotification.DidFavoriteView: + result.onSuccess( + (success) => _favoriteUpdated?.call( + FlowyResult.success(RepeatedViewPB.fromBuffer(success)), + true, + ), + ); + case FolderNotification.DidUnfavoriteView: + result.map( + (success) => _favoriteUpdated?.call( + FlowyResult.success(RepeatedViewPB.fromBuffer(success)), + false, + ), + ); + break; + default: + break; + } + } + + Future stop() async { + _parser = null; + await _streamSubscription?.cancel(); + _streamSubscription = null; + _favoriteUpdated = null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart new file mode 100644 index 0000000000000..2ff57bd80ff2a --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/favorite_service.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; + +class FavoriteService { + Future> readFavorites() { + final result = FolderEventReadFavorites().send(); + return result.then((result) { + return result.fold( + (favoriteViews) { + return FlowyResult.success( + RepeatedFavoriteViewPB( + items: favoriteViews.items.where((e) => !e.item.isSpace), + ), + ); + }, + (error) => FlowyResult.failure(error), + ); + }); + } + + Future> toggleFavorite( + String viewId, + bool favoriteStatus, + ) async { + final id = RepeatedViewIdPB.create()..items.add(viewId); + return FolderEventToggleFavorite(id).send(); + } + + Future> pinFavorite(ViewPB view) async { + return pinOrUnpinFavorite(view, true); + } + + Future> unpinFavorite(ViewPB view) async { + return pinOrUnpinFavorite(view, false); + } + + Future> pinOrUnpinFavorite( + ViewPB view, + bool isPinned, + ) async { + try { + final current = + view.extra.isNotEmpty ? jsonDecode(view.extra) : {}; + final merged = mergeMaps( + current, + {ViewExtKeys.isPinnedKey: isPinned}, + ); + await ViewBackendService.updateView( + viewId: view.id, + extra: jsonEncode(merged), + ); + } catch (e) { + return FlowyResult.failure(FlowyError(msg: 'Failed to pin favorite: $e')); + } + + return FlowyResult.success(null); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/favorite/prelude.dart b/frontend/appflowy_flutter/lib/workspace/application/favorite/prelude.dart new file mode 100644 index 0000000000000..15a3302d435a4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/favorite/prelude.dart @@ -0,0 +1,3 @@ +export 'favorite_bloc.dart'; +export 'favorite_listener.dart'; +export 'favorite_service.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart new file mode 100644 index 0000000000000..2fb801595cacb --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/home/home_bloc.dart @@ -0,0 +1,89 @@ +import 'package:appflowy/user/application/user_listener.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart' + show WorkspaceSettingPB; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'home_bloc.freezed.dart'; + +class HomeBloc extends Bloc { + HomeBloc(WorkspaceSettingPB workspaceSetting) + : _workspaceListener = FolderListener(), + super(HomeState.initial(workspaceSetting)) { + _dispatch(workspaceSetting); + } + + final FolderListener _workspaceListener; + + @override + Future close() async { + await _workspaceListener.stop(); + return super.close(); + } + + void _dispatch(WorkspaceSettingPB workspaceSetting) { + on( + (event, emit) async { + await event.map( + initial: (_Initial value) { + Future.delayed(const Duration(milliseconds: 300), () { + if (!isClosed) { + add(HomeEvent.didReceiveWorkspaceSetting(workspaceSetting)); + } + }); + + _workspaceListener.start( + onSettingUpdated: (result) { + result.fold( + (setting) => + add(HomeEvent.didReceiveWorkspaceSetting(setting)), + (r) => Log.error(r), + ); + }, + ); + }, + showLoading: (e) async { + emit(state.copyWith(isLoading: e.isLoading)); + }, + didReceiveWorkspaceSetting: (_DidReceiveWorkspaceSetting value) { + final latestView = value.setting.hasLatestView() + ? value.setting.latestView + : state.latestView; + + emit( + state.copyWith( + workspaceSetting: value.setting, + latestView: latestView, + ), + ); + }, + ); + }, + ); + } +} + +@freezed +class HomeEvent with _$HomeEvent { + const factory HomeEvent.initial() = _Initial; + const factory HomeEvent.showLoading(bool isLoading) = _ShowLoading; + const factory HomeEvent.didReceiveWorkspaceSetting( + WorkspaceSettingPB setting, + ) = _DidReceiveWorkspaceSetting; +} + +@freezed +class HomeState with _$HomeState { + const factory HomeState({ + required bool isLoading, + required WorkspaceSettingPB workspaceSetting, + ViewPB? latestView, + }) = _HomeState; + + factory HomeState.initial(WorkspaceSettingPB workspaceSetting) => HomeState( + isLoading: false, + workspaceSetting: workspaceSetting, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart new file mode 100644 index 0000000000000..657f2592d73a2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart @@ -0,0 +1,169 @@ +import 'package:appflowy/user/application/user_listener.dart'; +import 'package:appflowy/workspace/application/edit_panel/edit_context.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart' + show WorkspaceSettingPB; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/time/duration.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'home_setting_bloc.freezed.dart'; + +class HomeSettingBloc extends Bloc { + HomeSettingBloc( + WorkspaceSettingPB workspaceSetting, + AppearanceSettingsCubit appearanceSettingsCubit, + double screenWidthPx, + ) : _listener = FolderListener(), + _appearanceSettingsCubit = appearanceSettingsCubit, + super( + HomeSettingState.initial( + workspaceSetting, + appearanceSettingsCubit.state, + screenWidthPx, + ), + ) { + _dispatch(); + } + + final FolderListener _listener; + final AppearanceSettingsCubit _appearanceSettingsCubit; + + @override + Future close() async { + await _listener.stop(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.map( + initial: (_Initial value) {}, + setEditPanel: (e) async { + emit(state.copyWith(panelContext: e.editContext)); + }, + dismissEditPanel: (value) async { + emit(state.copyWith(panelContext: null)); + }, + didReceiveWorkspaceSetting: (_DidReceiveWorkspaceSetting value) { + emit(state.copyWith(workspaceSetting: value.setting)); + }, + collapseMenu: (_CollapseMenu e) { + final isMenuCollapsed = !state.isMenuCollapsed; + _appearanceSettingsCubit.saveIsMenuCollapsed(isMenuCollapsed); + emit( + state.copyWith( + isMenuCollapsed: isMenuCollapsed, + keepMenuCollapsed: isMenuCollapsed, + ), + ); + }, + checkScreenSize: (_CheckScreenSize e) { + final bool isScreenSmall = + e.screenWidthPx < PageBreaks.tabletLandscape; + + if (state.isScreenSmall != isScreenSmall) { + final isMenuCollapsed = isScreenSmall || state.keepMenuCollapsed; + emit( + state.copyWith( + isMenuCollapsed: isMenuCollapsed, + isScreenSmall: isScreenSmall, + ), + ); + } else { + emit(state.copyWith(isScreenSmall: isScreenSmall)); + } + }, + editPanelResizeStart: (_EditPanelResizeStart e) { + emit( + state.copyWith( + resizeType: MenuResizeType.drag, + resizeStart: state.resizeOffset, + ), + ); + }, + editPanelResized: (_EditPanelResized e) { + final newPosition = + (state.resizeStart + e.offset).clamp(0, 200).toDouble(); + if (state.resizeOffset != newPosition) { + emit(state.copyWith(resizeOffset: newPosition)); + } + }, + editPanelResizeEnd: (_EditPanelResizeEnd e) { + _appearanceSettingsCubit.saveMenuOffset(state.resizeOffset); + emit(state.copyWith(resizeType: MenuResizeType.slide)); + }, + ); + }, + ); + } +} + +enum MenuResizeType { + slide, + drag, +} + +extension MenuResizeTypeExtension on MenuResizeType { + Duration duration() { + switch (this) { + case MenuResizeType.drag: + return 30.milliseconds; + case MenuResizeType.slide: + return 350.milliseconds; + } + } +} + +@freezed +class HomeSettingEvent with _$HomeSettingEvent { + const factory HomeSettingEvent.initial() = _Initial; + const factory HomeSettingEvent.setEditPanel(EditPanelContext editContext) = + _ShowEditPanel; + const factory HomeSettingEvent.dismissEditPanel() = _DismissEditPanel; + const factory HomeSettingEvent.didReceiveWorkspaceSetting( + WorkspaceSettingPB setting, + ) = _DidReceiveWorkspaceSetting; + const factory HomeSettingEvent.collapseMenu() = _CollapseMenu; + const factory HomeSettingEvent.checkScreenSize(double screenWidthPx) = + _CheckScreenSize; + const factory HomeSettingEvent.editPanelResized(double offset) = + _EditPanelResized; + const factory HomeSettingEvent.editPanelResizeStart() = _EditPanelResizeStart; + const factory HomeSettingEvent.editPanelResizeEnd() = _EditPanelResizeEnd; +} + +@freezed +class HomeSettingState with _$HomeSettingState { + const factory HomeSettingState({ + required EditPanelContext? panelContext, + required WorkspaceSettingPB workspaceSetting, + required bool unauthorized, + required bool isMenuCollapsed, + required bool keepMenuCollapsed, + required bool isScreenSmall, + required double resizeOffset, + required double resizeStart, + required MenuResizeType resizeType, + }) = _HomeSettingState; + + factory HomeSettingState.initial( + WorkspaceSettingPB workspaceSetting, + AppearanceSettingsState appearanceSettingsState, + double screenWidthPx, + ) { + return HomeSettingState( + panelContext: null, + workspaceSetting: workspaceSetting, + unauthorized: false, + isMenuCollapsed: appearanceSettingsState.isMenuCollapsed, + isScreenSmall: screenWidthPx < PageBreaks.tabletLandscape, + keepMenuCollapsed: false, + resizeOffset: appearanceSettingsState.menuOffset, + resizeStart: 0, + resizeType: MenuResizeType.slide, + ); + } +} diff --git a/frontend/app_flowy/lib/workspace/application/home/prelude.dart b/frontend/appflowy_flutter/lib/workspace/application/home/prelude.dart similarity index 100% rename from frontend/app_flowy/lib/workspace/application/home/prelude.dart rename to frontend/appflowy_flutter/lib/workspace/application/home/prelude.dart diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/menu_user_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_user_bloc.dart new file mode 100644 index 0000000000000..a9e4d28a3aab9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/menu_user_bloc.dart @@ -0,0 +1,98 @@ +import 'package:appflowy/user/application/user_listener.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'menu_user_bloc.freezed.dart'; + +class MenuUserBloc extends Bloc { + MenuUserBloc(this.userProfile) + : _userListener = UserListener(userProfile: userProfile), + _userWorkspaceListener = FolderListener(), + _userService = UserBackendService(userId: userProfile.id), + super(MenuUserState.initial(userProfile)) { + _dispatch(); + } + + final UserBackendService _userService; + final UserListener _userListener; + final FolderListener _userWorkspaceListener; + final UserProfilePB userProfile; + + @override + Future close() async { + await _userListener.stop(); + await _userWorkspaceListener.stop(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () async { + _userListener.start(onProfileUpdated: _profileUpdated); + await _initUser(); + }, + didReceiveUserProfile: (UserProfilePB newUserProfile) { + emit(state.copyWith(userProfile: newUserProfile)); + }, + updateUserName: (String name) { + _userService.updateUserProfile(name: name).then((result) { + result.fold( + (l) => null, + (err) => Log.error(err), + ); + }); + }, + ); + }, + ); + } + + Future _initUser() async { + final result = await _userService.initUser(); + result.fold((l) => null, (error) => Log.error(error)); + } + + void _profileUpdated( + FlowyResult userProfileOrFailed, + ) { + if (isClosed) { + return; + } + userProfileOrFailed.fold( + (profile) => add(MenuUserEvent.didReceiveUserProfile(profile)), + (err) => Log.error(err), + ); + } +} + +@freezed +class MenuUserEvent with _$MenuUserEvent { + const factory MenuUserEvent.initial() = _Initial; + const factory MenuUserEvent.updateUserName(String name) = _UpdateUserName; + const factory MenuUserEvent.didReceiveUserProfile( + UserProfilePB newUserProfile, + ) = _DidReceiveUserProfile; +} + +@freezed +class MenuUserState with _$MenuUserState { + const factory MenuUserState({ + required UserProfilePB userProfile, + required List? workspaces, + required FlowyResult successOrFailure, + }) = _MenuUserState; + + factory MenuUserState.initial(UserProfilePB userProfile) => MenuUserState( + userProfile: userProfile, + workspaces: null, + successOrFailure: FlowyResult.success(null), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/prelude.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/prelude.dart new file mode 100644 index 0000000000000..0412a9956d172 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/prelude.dart @@ -0,0 +1,2 @@ +export 'menu_user_bloc.dart'; +export 'sidebar_sections_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart new file mode 100644 index 0000000000000..5a20b29c09bee --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/menu/sidebar_sections_bloc.dart @@ -0,0 +1,314 @@ +import 'dart:async'; + +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_sections_listener.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sidebar_sections_bloc.freezed.dart'; + +class SidebarSection { + const SidebarSection({ + required this.publicViews, + required this.privateViews, + }); + + const SidebarSection.empty() + : publicViews = const [], + privateViews = const []; + + final List publicViews; + final List privateViews; + + List get views => publicViews + privateViews; + + SidebarSection copyWith({ + List? publicViews, + List? privateViews, + }) { + return SidebarSection( + publicViews: publicViews ?? this.publicViews, + privateViews: privateViews ?? this.privateViews, + ); + } +} + +/// The [SidebarSectionsBloc] is responsible for +/// managing the root views in different sections of the workspace. +class SidebarSectionsBloc + extends Bloc { + SidebarSectionsBloc() : super(SidebarSectionsState.initial()) { + on( + (event, emit) async { + await event.when( + initial: (userProfile, workspaceId) async { + _initial(userProfile, workspaceId); + final sectionViews = await _getSectionViews(); + if (sectionViews != null) { + final containsSpace = _containsSpace(sectionViews); + emit( + state.copyWith( + section: sectionViews, + containsSpace: containsSpace, + ), + ); + } + }, + reset: (userProfile, workspaceId) async { + _reset(userProfile, workspaceId); + final sectionViews = await _getSectionViews(); + if (sectionViews != null) { + final containsSpace = _containsSpace(sectionViews); + emit( + state.copyWith( + section: sectionViews, + containsSpace: containsSpace, + ), + ); + } + }, + createRootViewInSection: (name, section, index) async { + final result = await _workspaceService.createView( + name: name, + viewSection: section, + index: index, + ); + result.fold( + (view) => emit( + state.copyWith( + lastCreatedRootView: view, + createRootViewResult: FlowyResult.success(null), + ), + ), + (error) { + Log.error('Failed to create root view: $error'); + emit( + state.copyWith( + createRootViewResult: FlowyResult.failure(error), + ), + ); + }, + ); + }, + receiveSectionViewsUpdate: (sectionViews) async { + final section = sectionViews.section; + switch (section) { + case ViewSectionPB.Public: + emit( + state.copyWith( + containsSpace: state.containsSpace || + sectionViews.views.any((view) => view.isSpace), + section: state.section.copyWith( + publicViews: sectionViews.views, + ), + ), + ); + case ViewSectionPB.Private: + emit( + state.copyWith( + containsSpace: state.containsSpace || + sectionViews.views.any((view) => view.isSpace), + section: state.section.copyWith( + privateViews: sectionViews.views, + ), + ), + ); + break; + default: + break; + } + }, + moveRootView: (fromIndex, toIndex, fromSection, toSection) async { + final views = fromSection == ViewSectionPB.Public + ? List.from(state.section.publicViews) + : List.from(state.section.privateViews); + if (fromIndex < 0 || fromIndex >= views.length) { + Log.error( + 'Invalid fromIndex: $fromIndex, maxIndex: ${views.length - 1}', + ); + return; + } + final view = views[fromIndex]; + final result = await _workspaceService.moveView( + viewId: view.id, + fromIndex: fromIndex, + toIndex: toIndex, + ); + result.fold( + (value) { + views.insert(toIndex, views.removeAt(fromIndex)); + var newState = state; + if (fromSection == ViewSectionPB.Public) { + newState = newState.copyWith( + section: newState.section.copyWith(publicViews: views), + ); + } else if (fromSection == ViewSectionPB.Private) { + newState = newState.copyWith( + section: newState.section.copyWith(privateViews: views), + ); + } + emit(newState); + }, + (error) { + Log.error('Failed to move root view: $error'); + }, + ); + }, + reload: (userProfile, workspaceId) async { + _initial(userProfile, workspaceId); + final sectionViews = await _getSectionViews(); + if (sectionViews != null) { + final containsSpace = _containsSpace(sectionViews); + emit( + state.copyWith( + section: sectionViews, + containsSpace: containsSpace, + ), + ); + // try to open the fist view in public section or private section + if (sectionViews.publicViews.isNotEmpty) { + getIt().add( + TabsEvent.openPlugin( + plugin: sectionViews.publicViews.first.plugin(), + ), + ); + } else if (sectionViews.privateViews.isNotEmpty) { + getIt().add( + TabsEvent.openPlugin( + plugin: sectionViews.privateViews.first.plugin(), + ), + ); + } else { + getIt().add( + TabsEvent.openPlugin( + plugin: makePlugin(pluginType: PluginType.blank), + ), + ); + } + } + }, + ); + }, + ); + } + + late WorkspaceService _workspaceService; + WorkspaceSectionsListener? _listener; + + @override + Future close() async { + await _listener?.stop(); + _listener = null; + return super.close(); + } + + ViewSectionPB? getViewSection(ViewPB view) { + final publicViews = state.section.publicViews.map((e) => e.id); + final privateViews = state.section.privateViews.map((e) => e.id); + if (publicViews.contains(view.id)) { + return ViewSectionPB.Public; + } else if (privateViews.contains(view.id)) { + return ViewSectionPB.Private; + } else { + return null; + } + } + + Future _getSectionViews() async { + try { + final publicViews = await _workspaceService.getPublicViews().getOrThrow(); + final privateViews = + await _workspaceService.getPrivateViews().getOrThrow(); + return SidebarSection( + publicViews: publicViews, + privateViews: privateViews, + ); + } catch (e) { + Log.error('Failed to get section views: $e'); + return null; + } + } + + bool _containsSpace(SidebarSection section) { + return section.publicViews.any((view) => view.isSpace) || + section.privateViews.any((view) => view.isSpace); + } + + void _initial(UserProfilePB userProfile, String workspaceId) { + _workspaceService = WorkspaceService(workspaceId: workspaceId); + + _listener = WorkspaceSectionsListener( + user: userProfile, + workspaceId: workspaceId, + )..start( + sectionChanged: (result) { + if (!isClosed) { + result.fold( + (s) => add(SidebarSectionsEvent.receiveSectionViewsUpdate(s)), + (f) => Log.error('Failed to receive section views: $f'), + ); + } + }, + ); + } + + void _reset(UserProfilePB userProfile, String workspaceId) { + _listener?.stop(); + _listener = null; + + _initial(userProfile, workspaceId); + } +} + +@freezed +class SidebarSectionsEvent with _$SidebarSectionsEvent { + const factory SidebarSectionsEvent.initial( + UserProfilePB userProfile, + String workspaceId, + ) = _Initial; + const factory SidebarSectionsEvent.reset( + UserProfilePB userProfile, + String workspaceId, + ) = _Reset; + const factory SidebarSectionsEvent.createRootViewInSection({ + required String name, + required ViewSectionPB viewSection, + int? index, + }) = _CreateRootViewInSection; + const factory SidebarSectionsEvent.moveRootView({ + required int fromIndex, + required int toIndex, + required ViewSectionPB fromSection, + required ViewSectionPB toSection, + }) = _MoveRootView; + const factory SidebarSectionsEvent.receiveSectionViewsUpdate( + SectionViewsPB sectionViews, + ) = _ReceiveSectionViewsUpdate; + const factory SidebarSectionsEvent.reload( + UserProfilePB userProfile, + String workspaceId, + ) = _Reload; +} + +@freezed +class SidebarSectionsState with _$SidebarSectionsState { + const factory SidebarSectionsState({ + required SidebarSection section, + @Default(null) ViewPB? lastCreatedRootView, + FlowyResult? createRootViewResult, + @Default(true) bool containsSpace, + }) = _SidebarSectionsState; + + factory SidebarSectionsState.initial() => const SidebarSectionsState( + section: SidebarSection.empty(), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart b/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart new file mode 100644 index 0000000000000..5418eb2b1c222 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/notification/notification_service.dart @@ -0,0 +1,41 @@ +import 'package:flutter/foundation.dart'; + +import 'package:local_notifier/local_notifier.dart'; + +const _appName = "AppFlowy"; + +/// Manages Local Notifications +/// +/// Currently supports: +/// - MacOS +/// - Windows +/// - Linux +/// +class NotificationService { + static Future initialize() async { + await localNotifier.setup(appName: _appName); + } +} + +/// Creates and shows a Notification +/// +class NotificationMessage { + NotificationMessage({ + required String title, + required String body, + String? identifier, + VoidCallback? onClick, + }) { + _notification = LocalNotification( + identifier: identifier, + title: title, + body: body, + )..onClick = onClick; + + _show(); + } + + late final LocalNotification _notification; + + void _show() => _notification.show(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/recent/cached_recent_service.dart b/frontend/appflowy_flutter/lib/workspace/application/recent/cached_recent_service.dart new file mode 100644 index 0000000000000..a5381ce17fe52 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/recent/cached_recent_service.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:appflowy/shared/list_extension.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/recent/recent_listener.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; + +/// This is a lazy-singleton to share recent views across the application. +/// +/// Use-cases: +/// - Desktop: Command Palette recent view history +/// - Desktop: (Documents) Inline-page reference recent view history +/// - Mobile: Recent view history on home screen +/// +/// See the related [LaunchTask] in [RecentServiceTask]. +/// +class CachedRecentService { + CachedRecentService(); + + Completer _completer = Completer(); + + ValueNotifier> notifier = ValueNotifier(const []); + + List get _recentViews => notifier.value; + set _recentViews(List value) => notifier.value = value; + + final _listener = RecentViewsListener(); + + Future> recentViews() async { + if (_isInitialized || _completer.isCompleted) return _recentViews; + + _isInitialized = true; + + _listener.start(recentViewsUpdated: _recentViewsUpdated); + _recentViews = await _readRecentViews().fold( + (s) => s.items.unique((e) => e.item.id), + (_) => [], + ); + _completer.complete(); + + return _recentViews; + } + + /// Updates the recent views history + Future> updateRecentViews( + List viewIds, + bool addInRecent, + ) async { + final List duplicatedViewIds = []; + for (final viewId in viewIds) { + for (final view in _recentViews) { + if (view.item.id == viewId) { + duplicatedViewIds.add(viewId); + } + } + } + return FolderEventUpdateRecentViews( + UpdateRecentViewPayloadPB( + viewIds: addInRecent ? viewIds : duplicatedViewIds, + addInRecent: addInRecent, + ), + ).send(); + } + + Future> + _readRecentViews() async { + final payload = ReadRecentViewsPB(start: Int64(), limit: Int64(100)); + final result = await FolderEventReadRecentViews(payload).send(); + return result.fold( + (recentViews) { + return FlowyResult.success( + RepeatedRecentViewPB( + // filter the space view and the orphan view + items: recentViews.items.where( + (e) => !e.item.isSpace && e.item.id != e.item.parentViewId, + ), + ), + ); + }, + (error) => FlowyResult.failure(error), + ); + } + + bool _isInitialized = false; + + Future reset() async { + await _listener.stop(); + _resetCompleter(); + _isInitialized = false; + _recentViews = const []; + } + + Future dispose() async { + notifier.dispose(); + await _listener.stop(); + } + + void _recentViewsUpdated( + FlowyResult result, + ) async { + final viewIds = result.toNullable(); + if (viewIds != null) { + _recentViews = await _readRecentViews().fold( + (s) => s.items.unique((e) => e.item.id), + (_) => [], + ); + } + } + + void _resetCompleter() { + if (!_completer.isCompleted) { + _completer.complete(); + } + _completer = Completer(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/recent/prelude.dart b/frontend/appflowy_flutter/lib/workspace/application/recent/prelude.dart new file mode 100644 index 0000000000000..5266a440c8fd8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/recent/prelude.dart @@ -0,0 +1,2 @@ +export 'cached_recent_service.dart'; +export 'recent_views_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/recent/recent_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/recent/recent_listener.dart new file mode 100644 index 0000000000000..fc70ef0602ea6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/recent/recent_listener.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:appflowy/core/notification/folder_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/foundation.dart'; + +typedef RecentViewsUpdated = void Function( + FlowyResult result, +); + +class RecentViewsListener { + StreamSubscription? _streamSubscription; + FolderNotificationParser? _parser; + + RecentViewsUpdated? _recentViewsUpdated; + + void start({ + RecentViewsUpdated? recentViewsUpdated, + }) { + _recentViewsUpdated = recentViewsUpdated; + _parser = FolderNotificationParser( + id: 'recent_views', + callback: _observableCallback, + ); + _streamSubscription = RustStreamReceiver.listen( + (observable) => _parser?.parse(observable), + ); + } + + void _observableCallback( + FolderNotification ty, + FlowyResult result, + ) { + if (_recentViewsUpdated == null) { + return; + } + + result.fold( + (payload) { + final view = RepeatedViewIdPB.fromBuffer(payload); + _recentViewsUpdated?.call( + FlowyResult.success(view), + ); + }, + (error) => _recentViewsUpdated?.call( + FlowyResult.failure(error), + ), + ); + } + + Future stop() async { + _parser = null; + await _streamSubscription?.cancel(); + _recentViewsUpdated = null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/recent/recent_views_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/recent/recent_views_bloc.dart new file mode 100644 index 0000000000000..d67c24e854d2b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/recent/recent_views_bloc.dart @@ -0,0 +1,77 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'recent_views_bloc.freezed.dart'; + +class RecentViewsBloc extends Bloc { + RecentViewsBloc() : super(RecentViewsState.initial()) { + _service = getIt(); + _dispatch(); + } + + late final CachedRecentService _service; + + @override + Future close() async { + _service.notifier.removeListener(_onRecentViewsUpdated); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.map( + initial: (e) async { + _service.notifier.addListener(_onRecentViewsUpdated); + add(const RecentViewsEvent.fetchRecentViews()); + }, + addRecentViews: (e) async { + await _service.updateRecentViews(e.viewIds, true); + }, + removeRecentViews: (e) async { + await _service.updateRecentViews(e.viewIds, false); + }, + fetchRecentViews: (e) async { + emit( + state.copyWith( + isLoading: false, + views: await _service.recentViews(), + ), + ); + }, + resetRecentViews: (e) async { + await _service.reset(); + add(const RecentViewsEvent.fetchRecentViews()); + }, + ); + }, + ); + } + + void _onRecentViewsUpdated() => + add(const RecentViewsEvent.fetchRecentViews()); +} + +@freezed +class RecentViewsEvent with _$RecentViewsEvent { + const factory RecentViewsEvent.initial() = Initial; + const factory RecentViewsEvent.addRecentViews(List viewIds) = + AddRecentViews; + const factory RecentViewsEvent.removeRecentViews(List viewIds) = + RemoveRecentViews; + const factory RecentViewsEvent.fetchRecentViews() = FetchRecentViews; + const factory RecentViewsEvent.resetRecentViews() = ResetRecentViews; +} + +@freezed +class RecentViewsState with _$RecentViewsState { + const factory RecentViewsState({ + required List views, + @Default(true) bool isLoading, + }) = _RecentViewsState; + + factory RecentViewsState.initial() => const RecentViewsState(views: []); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_model_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_model_bloc.dart new file mode 100644 index 0000000000000..8be68e813e67e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_model_bloc.dart @@ -0,0 +1,167 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:isolate'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:fixnum/fixnum.dart'; +part 'download_model_bloc.freezed.dart'; + +class DownloadModelBloc extends Bloc { + DownloadModelBloc(LLMModelPB model) + : super(DownloadModelState.initial(model)) { + on(_handleEvent); + } + + Future _handleEvent( + DownloadModelEvent event, + Emitter emit, + ) async { + await event.when( + started: () async { + final downloadStream = DownloadingStream(); + downloadStream.listen( + onModelPercentage: (name, percent) { + if (!isClosed) { + add( + DownloadModelEvent.updatePercent(name, percent), + ); + } + }, + onPluginPercentage: (percent) { + if (!isClosed) { + add(DownloadModelEvent.updatePercent("AppFlowy Plugin", percent)); + } + }, + onFinish: () { + add(const DownloadModelEvent.downloadFinish()); + }, + onError: (err) { + Log.error(err); + }, + ); + + final payload = + DownloadLLMPB(progressStream: Int64(downloadStream.nativePort)); + final result = await AIEventDownloadLLMResource(payload).send(); + result.fold((_) { + emit( + state.copyWith( + downloadStream: downloadStream, + loadingState: const ChatLoadingState.finish(), + downloadError: null, + ), + ); + }, (err) { + emit( + state.copyWith( + loadingState: ChatLoadingState.finish(error: err), + ), + ); + }); + }, + updatePercent: (String object, double percent) { + emit(state.copyWith(object: object, percent: percent)); + }, + downloadFinish: () { + emit(state.copyWith(isFinish: true)); + }, + ); + } + + @override + Future close() async { + await state.downloadStream?.dispose(); + return super.close(); + } +} + +@freezed +class DownloadModelEvent with _$DownloadModelEvent { + const factory DownloadModelEvent.started() = _Started; + const factory DownloadModelEvent.updatePercent( + String object, + double percent, + ) = _UpdatePercent; + const factory DownloadModelEvent.downloadFinish() = _DownloadFinish; +} + +@freezed +class DownloadModelState with _$DownloadModelState { + const factory DownloadModelState({ + required LLMModelPB model, + DownloadingStream? downloadStream, + String? downloadError, + @Default("") String object, + @Default(0) double percent, + @Default(false) bool isFinish, + String? bigFileDownloadPrompt, + @Default(ChatLoadingState.loading()) ChatLoadingState loadingState, + }) = _DownloadModelState; + + factory DownloadModelState.initial(LLMModelPB model) { + // bigger than 1 GB then show download big file prompt + String? bigFileDownloadPrompt; + if (model.fileSize > 1 * 1024 * 1024 * 1024) { + bigFileDownloadPrompt = + LocaleKeys.settings_aiPage_keys_downloadBigFilePrompt.tr(); + } + return DownloadModelState( + model: model, + bigFileDownloadPrompt: bigFileDownloadPrompt, + ); + } +} + +class DownloadingStream { + DownloadingStream() { + _port.handler = _controller.add; + } + + final RawReceivePort _port = RawReceivePort(); + StreamSubscription? _sub; + final StreamController _controller = StreamController.broadcast(); + int get nativePort => _port.sendPort.nativePort; + + Future dispose() async { + await _sub?.cancel(); + await _controller.close(); + _port.close(); + } + + void listen({ + void Function(String modelName, double percent)? onModelPercentage, + void Function(double percent)? onPluginPercentage, + void Function(String data)? onError, + void Function()? onFinish, + }) { + _sub = _controller.stream.listen((text) { + if (text.contains(':progress:')) { + final progressIndex = text.indexOf(':progress:'); + final modelName = text.substring(0, progressIndex); + final progressValue = text + .substring(progressIndex + 10); // 10 is the length of ":progress:" + final percent = double.tryParse(progressValue); + if (percent != null) { + onModelPercentage?.call(modelName, percent); + } + } else if (text.startsWith('plugin:progress:')) { + final percent = double.tryParse(text.substring(16)); + if (percent != null) { + onPluginPercentage?.call(percent); + } + } else if (text.startsWith('finish')) { + onFinish?.call(); + } else if (text.startsWith('error:')) { + // substring 6 to remove "error:" + onError?.call(text.substring(6)); + } + }); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart new file mode 100644 index 0000000000000..829bd2f62a136 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:url_launcher/url_launcher.dart' show launchUrl; +part 'download_offline_ai_app_bloc.freezed.dart'; + +class DownloadOfflineAIBloc + extends Bloc { + DownloadOfflineAIBloc() : super(const DownloadOfflineAIState()) { + on(_handleEvent); + } + + Future _handleEvent( + DownloadOfflineAIEvent event, + Emitter emit, + ) async { + await event.when( + started: () async { + final result = await AIEventGetOfflineAIAppLink().send(); + await result.fold( + (app) async { + await launchUrl(Uri.parse(app.link)); + }, + (err) {}, + ); + }, + ); + } +} + +@freezed +class DownloadOfflineAIEvent with _$DownloadOfflineAIEvent { + const factory DownloadOfflineAIEvent.started() = _Started; +} + +@freezed +class DownloadOfflineAIState with _$DownloadOfflineAIState { + const factory DownloadOfflineAIState() = _DownloadOfflineAIState; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart new file mode 100644 index 0000000000000..3c3d20039dcff --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_bloc.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +part 'local_ai_bloc.freezed.dart'; + +class LocalAIToggleBloc extends Bloc { + LocalAIToggleBloc() : super(const LocalAIToggleState()) { + on(_handleEvent); + } + + Future _handleEvent( + LocalAIToggleEvent event, + Emitter emit, + ) async { + await event.when( + started: () async { + final result = await AIEventGetLocalAIState().send(); + _handleResult(emit, result); + }, + toggle: () async { + emit( + state.copyWith( + pageIndicator: const LocalAIToggleStateIndicator.loading(), + ), + ); + unawaited( + AIEventToggleLocalAI().send().then( + (result) { + if (!isClosed) { + add(LocalAIToggleEvent.handleResult(result)); + } + }, + ), + ); + }, + handleResult: (result) { + _handleResult(emit, result); + }, + ); + } + + void _handleResult( + Emitter emit, + FlowyResult result, + ) { + result.fold( + (localAI) { + emit( + state.copyWith( + pageIndicator: LocalAIToggleStateIndicator.ready(localAI.enabled), + ), + ); + }, + (err) { + emit( + state.copyWith( + pageIndicator: LocalAIToggleStateIndicator.error(err), + ), + ); + }, + ); + } +} + +@freezed +class LocalAIToggleEvent with _$LocalAIToggleEvent { + const factory LocalAIToggleEvent.started() = _Started; + const factory LocalAIToggleEvent.toggle() = _Toggle; + const factory LocalAIToggleEvent.handleResult( + FlowyResult result, + ) = _HandleResult; +} + +@freezed +class LocalAIToggleState with _$LocalAIToggleState { + const factory LocalAIToggleState({ + @Default(LocalAIToggleStateIndicator.loading()) + LocalAIToggleStateIndicator pageIndicator, + }) = _LocalAIToggleState; +} + +@freezed +class LocalAIToggleStateIndicator with _$LocalAIToggleStateIndicator { + // when start downloading the model + const factory LocalAIToggleStateIndicator.error(FlowyError error) = _OnError; + const factory LocalAIToggleStateIndicator.ready(bool isEnabled) = _Ready; + const factory LocalAIToggleStateIndicator.loading() = _Loading; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_bloc.dart new file mode 100644 index 0000000000000..7f1df258ea759 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_bloc.dart @@ -0,0 +1,287 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'local_ai_chat_bloc.freezed.dart'; + +class LocalAIChatSettingBloc + extends Bloc { + LocalAIChatSettingBloc() + : listener = LocalLLMListener(), + super(const LocalAIChatSettingState()) { + listener.start( + stateCallback: (newState) { + if (!isClosed) { + add(LocalAIChatSettingEvent.updatePluginState(newState)); + } + }, + ); + + on(_handleEvent); + } + + final LocalLLMListener listener; + + /// Handles incoming events and dispatches them to the appropriate handler. + Future _handleEvent( + LocalAIChatSettingEvent event, + Emitter emit, + ) async { + await event.when( + refreshAISetting: _handleStarted, + didLoadModelInfo: (FlowyResult result) { + result.fold( + (modelInfo) { + _fetchCurremtLLMState(); + emit( + state.copyWith( + modelInfo: modelInfo, + models: modelInfo.models, + selectedLLMModel: modelInfo.selectedModel, + aiModelProgress: const AIModelProgress.finish(), + ), + ); + }, + (err) { + emit( + state.copyWith( + aiModelProgress: AIModelProgress.finish(error: err), + ), + ); + }, + ); + }, + selectLLMConfig: (LLMModelPB llmModel) async { + final result = await AIEventUpdateLocalLLM(llmModel).send(); + result.fold( + (llmResource) { + // If all resources are downloaded, show reload plugin + if (llmResource.pendingResources.isNotEmpty) { + emit( + state.copyWith( + selectedLLMModel: llmModel, + progressIndicator: LocalAIProgress.showDownload( + llmResource, + llmModel, + ), + selectLLMState: const ChatLoadingState.finish(), + ), + ); + } else { + emit( + state.copyWith( + selectedLLMModel: llmModel, + selectLLMState: const ChatLoadingState.finish(), + progressIndicator: const LocalAIProgress.checkPluginState(), + ), + ); + } + }, + (err) { + emit( + state.copyWith( + selectLLMState: ChatLoadingState.finish(error: err), + ), + ); + }, + ); + }, + refreshLLMState: (LocalModelResourcePB llmResource) { + if (state.selectedLLMModel == null) { + Log.error( + 'Unexpected null selected config. It should be set already', + ); + return; + } + + // reload plugin if all resources are downloaded + if (llmResource.pendingResources.isEmpty) { + emit( + state.copyWith( + progressIndicator: const LocalAIProgress.checkPluginState(), + ), + ); + } else { + if (state.selectedLLMModel != null) { + // Go to download page if the selected model is downloading + if (llmResource.isDownloading) { + emit( + state.copyWith( + progressIndicator: + LocalAIProgress.startDownloading(state.selectedLLMModel!), + selectLLMState: const ChatLoadingState.finish(), + ), + ); + return; + } else { + emit( + state.copyWith( + progressIndicator: LocalAIProgress.showDownload( + llmResource, + state.selectedLLMModel!, + ), + selectLLMState: const ChatLoadingState.finish(), + ), + ); + } + } + } + }, + startDownloadModel: (LLMModelPB llmModel) { + emit( + state.copyWith( + progressIndicator: LocalAIProgress.startDownloading(llmModel), + selectLLMState: const ChatLoadingState.finish(), + ), + ); + }, + cancelDownload: () async { + final _ = await AIEventCancelDownloadLLMResource().send(); + _fetchCurremtLLMState(); + }, + finishDownload: () async { + emit( + state.copyWith( + progressIndicator: const LocalAIProgress.finishDownload(), + ), + ); + }, + updatePluginState: (LocalAIPluginStatePB pluginState) { + if (pluginState.offlineAiReady) { + AIEventRefreshLocalAIModelInfo().send().then((result) { + if (!isClosed) { + add(LocalAIChatSettingEvent.didLoadModelInfo(result)); + } + }); + + if (pluginState.state == RunningStatePB.Stopped) { + emit( + state.copyWith( + runningState: pluginState.state, + progressIndicator: const LocalAIProgress.checkPluginState(), + ), + ); + } else { + emit( + state.copyWith( + runningState: pluginState.state, + ), + ); + } + } else { + emit( + state.copyWith( + progressIndicator: const LocalAIProgress.startOfflineAIApp(), + ), + ); + } + }, + ); + } + + void _fetchCurremtLLMState() async { + final result = await AIEventGetLocalLLMState().send(); + result.fold( + (llmResource) { + if (!isClosed) { + add(LocalAIChatSettingEvent.refreshLLMState(llmResource)); + } + }, + (err) { + Log.error(err); + }, + ); + } + + /// Handles the event to fetch local AI settings when the application starts. + Future _handleStarted() async { + final result = await AIEventGetLocalAIPluginState().send(); + result.fold( + (pluginState) async { + if (!isClosed) { + add(LocalAIChatSettingEvent.updatePluginState(pluginState)); + if (pluginState.offlineAiReady) { + final result = await AIEventRefreshLocalAIModelInfo().send(); + if (!isClosed) { + add(LocalAIChatSettingEvent.didLoadModelInfo(result)); + } + } + } + }, + (err) => Log.error(err.toString()), + ); + } + + @override + Future close() async { + await listener.stop(); + return super.close(); + } +} + +@freezed +class LocalAIChatSettingEvent with _$LocalAIChatSettingEvent { + const factory LocalAIChatSettingEvent.refreshAISetting() = _RefreshAISetting; + const factory LocalAIChatSettingEvent.didLoadModelInfo( + FlowyResult result, + ) = _ModelInfo; + const factory LocalAIChatSettingEvent.selectLLMConfig(LLMModelPB config) = + _SelectLLMConfig; + + const factory LocalAIChatSettingEvent.refreshLLMState( + LocalModelResourcePB llmResource, + ) = _RefreshLLMResource; + const factory LocalAIChatSettingEvent.startDownloadModel( + LLMModelPB llmModel, + ) = _StartDownloadModel; + + const factory LocalAIChatSettingEvent.cancelDownload() = _CancelDownload; + const factory LocalAIChatSettingEvent.finishDownload() = _FinishDownload; + const factory LocalAIChatSettingEvent.updatePluginState( + LocalAIPluginStatePB pluginState, + ) = _PluginState; +} + +@freezed +class LocalAIChatSettingState with _$LocalAIChatSettingState { + const factory LocalAIChatSettingState({ + LLMModelInfoPB? modelInfo, + LLMModelPB? selectedLLMModel, + LocalAIProgress? progressIndicator, + @Default(AIModelProgress.init()) AIModelProgress aiModelProgress, + @Default(ChatLoadingState.loading()) ChatLoadingState selectLLMState, + @Default([]) List models, + @Default(RunningStatePB.Connecting) RunningStatePB runningState, + }) = _LocalAIChatSettingState; +} + +@freezed +class LocalAIProgress with _$LocalAIProgress { + // when user comes back to the setting page, it will auto detect current llm state + const factory LocalAIProgress.showDownload( + LocalModelResourcePB llmResource, + LLMModelPB llmModel, + ) = _DownloadNeeded; + + // when start downloading the model + const factory LocalAIProgress.startDownloading(LLMModelPB llmModel) = + _Downloading; + const factory LocalAIProgress.finishDownload() = _Finish; + const factory LocalAIProgress.checkPluginState() = _CheckPluginState; + const factory LocalAIProgress.startOfflineAIApp() = _StartOfflineAIApp; +} + +@freezed +class AIModelProgress with _$AIModelProgress { + const factory AIModelProgress.init() = _AIModelProgressInit; + const factory AIModelProgress.loading() = _AIModelDownloading; + const factory AIModelProgress.finish({FlowyError? error}) = _AIModelFinish; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart new file mode 100644 index 0000000000000..4feac1247a600 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +part 'local_ai_chat_toggle_bloc.freezed.dart'; + +class LocalAIChatToggleBloc + extends Bloc { + LocalAIChatToggleBloc() : super(const LocalAIChatToggleState()) { + on(_handleEvent); + } + + Future _handleEvent( + LocalAIChatToggleEvent event, + Emitter emit, + ) async { + await event.when( + started: () async { + final result = await AIEventGetLocalAIChatState().send(); + _handleResult(emit, result); + }, + toggle: () async { + emit( + state.copyWith( + pageIndicator: const LocalAIChatToggleStateIndicator.loading(), + ), + ); + unawaited( + AIEventToggleLocalAIChat().send().then( + (result) { + if (!isClosed) { + add(LocalAIChatToggleEvent.handleResult(result)); + } + }, + ), + ); + }, + handleResult: (result) { + _handleResult(emit, result); + }, + ); + } + + void _handleResult( + Emitter emit, + FlowyResult result, + ) { + result.fold( + (localAI) { + emit( + state.copyWith( + pageIndicator: + LocalAIChatToggleStateIndicator.ready(localAI.enabled), + ), + ); + }, + (err) { + emit( + state.copyWith( + pageIndicator: LocalAIChatToggleStateIndicator.error(err), + ), + ); + }, + ); + } +} + +@freezed +class LocalAIChatToggleEvent with _$LocalAIChatToggleEvent { + const factory LocalAIChatToggleEvent.started() = _Started; + const factory LocalAIChatToggleEvent.toggle() = _Toggle; + const factory LocalAIChatToggleEvent.handleResult( + FlowyResult result, + ) = _HandleResult; +} + +@freezed +class LocalAIChatToggleState with _$LocalAIChatToggleState { + const factory LocalAIChatToggleState({ + @Default(LocalAIChatToggleStateIndicator.loading()) + LocalAIChatToggleStateIndicator pageIndicator, + }) = _LocalAIChatToggleState; +} + +@freezed +class LocalAIChatToggleStateIndicator with _$LocalAIChatToggleStateIndicator { + const factory LocalAIChatToggleStateIndicator.error(FlowyError error) = + _OnError; + const factory LocalAIChatToggleStateIndicator.ready(bool isEnabled) = _Ready; + const factory LocalAIChatToggleStateIndicator.loading() = _Loading; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart new file mode 100644 index 0000000000000..2c1bf34a87712 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart @@ -0,0 +1,129 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'local_ai_on_boarding_bloc.freezed.dart'; + +class LocalAIOnBoardingBloc + extends Bloc { + LocalAIOnBoardingBloc( + this.userProfile, + this.currentWorkspaceMemberRole, + this.workspaceId, + ) : super(const LocalAIOnBoardingState()) { + _userService = UserBackendService(userId: userProfile.id); + _successListenable = getIt(); + _successListenable.addListener(_onPaymentSuccessful); + _dispatch(); + } + + Future _onPaymentSuccessful() async { + if (isClosed) { + return; + } + + add( + LocalAIOnBoardingEvent.paymentSuccessful( + _successListenable.subscribedPlan, + ), + ); + } + + final UserProfilePB userProfile; + final AFRolePB? currentWorkspaceMemberRole; + final String workspaceId; + late final IUserBackendService _userService; + late final SubscriptionSuccessListenable _successListenable; + + @override + Future close() async { + _successListenable.removeListener(_onPaymentSuccessful); + await super.close(); + } + + void _dispatch() { + on((event, emit) { + event.when( + started: () { + _loadSubscriptionPlans(); + }, + addSubscription: (plan) async { + emit(state.copyWith(isLoading: true)); + final result = await _userService.createSubscription( + workspaceId, + plan, + ); + + result.fold( + (pl) => afLaunchUrlString(pl.paymentLink), + (f) => Log.error( + 'Failed to fetch paymentlink for $plan: ${f.msg}', + f, + ), + ); + }, + didGetSubscriptionPlans: (result) { + result.fold( + (workspaceSubInfo) { + final isPurchaseAILocal = workspaceSubInfo.addOns.any((addOn) { + return addOn.type == WorkspaceAddOnPBType.AddOnAiLocal; + }); + + emit( + state.copyWith(isPurchaseAILocal: isPurchaseAILocal), + ); + }, + (err) { + Log.warn("Failed to get subscription plans: $err"); + }, + ); + }, + paymentSuccessful: (SubscriptionPlanPB? plan) { + if (plan == SubscriptionPlanPB.AiLocal) { + emit(state.copyWith(isPurchaseAILocal: true, isLoading: false)); + } + }, + ); + }); + } + + void _loadSubscriptionPlans() { + final payload = UserWorkspaceIdPB()..workspaceId = workspaceId; + UserEventGetWorkspaceSubscriptionInfo(payload).send().then((result) { + if (!isClosed) { + add(LocalAIOnBoardingEvent.didGetSubscriptionPlans(result)); + } + }); + } +} + +@freezed +class LocalAIOnBoardingEvent with _$LocalAIOnBoardingEvent { + const factory LocalAIOnBoardingEvent.started() = _Started; + const factory LocalAIOnBoardingEvent.addSubscription( + SubscriptionPlanPB plan, + ) = _AddSubscription; + const factory LocalAIOnBoardingEvent.paymentSuccessful( + SubscriptionPlanPB? plan, + ) = _PaymentSuccessful; + const factory LocalAIOnBoardingEvent.didGetSubscriptionPlans( + FlowyResult result, + ) = _LoadSubscriptionPlans; +} + +@freezed +class LocalAIOnBoardingState with _$LocalAIOnBoardingState { + const factory LocalAIOnBoardingState({ + @Default(false) bool isPurchaseAILocal, + @Default(false) bool isLoading, + }) = _LocalAIOnBoardingState; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_llm_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_llm_listener.dart new file mode 100644 index 0000000000000..a7778d7d99564 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_llm_listener.dart @@ -0,0 +1,60 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/plugins/ai_chat/application/chat_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +typedef PluginStateCallback = void Function(LocalAIPluginStatePB state); +typedef LocalAIChatCallback = void Function(LocalAIChatPB chatState); + +class LocalLLMListener { + LocalLLMListener() { + _parser = + ChatNotificationParser(id: "appflowy_ai_plugin", callback: _callback); + _subscription = RustStreamReceiver.listen( + (observable) => _parser?.parse(observable), + ); + } + + StreamSubscription? _subscription; + ChatNotificationParser? _parser; + + PluginStateCallback? stateCallback; + LocalAIChatCallback? chatStateCallback; + void Function()? finishStreamingCallback; + + void start({ + PluginStateCallback? stateCallback, + LocalAIChatCallback? chatStateCallback, + }) { + this.stateCallback = stateCallback; + this.chatStateCallback = chatStateCallback; + } + + void _callback( + ChatNotification ty, + FlowyResult result, + ) { + result.map((r) { + switch (ty) { + case ChatNotification.UpdateChatPluginState: + stateCallback?.call(LocalAIPluginStatePB.fromBuffer(r)); + break; + case ChatNotification.UpdateLocalChatAI: + chatStateCallback?.call(LocalAIChatPB.fromBuffer(r)); + break; + default: + break; + } + }); + } + + Future stop() async { + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart new file mode 100644 index 0000000000000..4f24309bdee47 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:url_launcher/url_launcher.dart' show launchUrl; + +part 'plugin_state_bloc.freezed.dart'; + +class PluginStateBloc extends Bloc { + PluginStateBloc() + : listener = LocalLLMListener(), + super( + const PluginStateState( + action: PluginStateAction.init(), + ), + ) { + listener.start( + stateCallback: (pluginState) { + if (!isClosed) { + add(PluginStateEvent.updateState(pluginState)); + } + }, + ); + + on(_handleEvent); + } + + final LocalLLMListener listener; + + @override + Future close() async { + await listener.stop(); + return super.close(); + } + + Future _handleEvent( + PluginStateEvent event, + Emitter emit, + ) async { + await event.when( + started: () async { + final result = await AIEventGetLocalAIPluginState().send(); + result.fold( + (pluginState) { + if (!isClosed) { + add(PluginStateEvent.updateState(pluginState)); + } + }, + (err) => Log.error(err.toString()), + ); + }, + updateState: (LocalAIPluginStatePB pluginState) { + // if the offline ai is not started, ask user to start it + if (pluginState.offlineAiReady) { + // Chech state of the plugin + switch (pluginState.state) { + case RunningStatePB.Connecting: + emit( + const PluginStateState( + action: PluginStateAction.loadingPlugin(), + ), + ); + case RunningStatePB.Running: + emit(const PluginStateState(action: PluginStateAction.ready())); + break; + default: + emit( + state.copyWith(action: const PluginStateAction.restartPlugin()), + ); + break; + } + } else { + emit( + const PluginStateState( + action: PluginStateAction.startAIOfflineApp(), + ), + ); + } + }, + restartLocalAI: () async { + emit( + const PluginStateState(action: PluginStateAction.loadingPlugin()), + ); + unawaited(AIEventRestartLocalAIChat().send()); + }, + openModelDirectory: () async { + final result = await AIEventGetModelStorageDirectory().send(); + result.fold( + (data) { + afLaunchUri(Uri.file(data.filePath)); + }, + (err) => Log.error(err.toString()), + ); + }, + downloadOfflineAIApp: () async { + final result = await AIEventGetOfflineAIAppLink().send(); + await result.fold( + (app) async { + await launchUrl(Uri.parse(app.link)); + }, + (err) {}, + ); + }, + ); + } +} + +@freezed +class PluginStateEvent with _$PluginStateEvent { + const factory PluginStateEvent.started() = _Started; + const factory PluginStateEvent.updateState(LocalAIPluginStatePB pluginState) = + _UpdatePluginState; + const factory PluginStateEvent.restartLocalAI() = _RestartLocalAI; + const factory PluginStateEvent.openModelDirectory() = + _OpenModelStorageDirectory; + const factory PluginStateEvent.downloadOfflineAIApp() = _DownloadOfflineAIApp; +} + +@freezed +class PluginStateState with _$PluginStateState { + const factory PluginStateState({ + required PluginStateAction action, + }) = _PluginStateState; +} + +@freezed +class PluginStateAction with _$PluginStateAction { + const factory PluginStateAction.init() = _Init; + const factory PluginStateAction.loadingPlugin() = _LoadingPlugin; + const factory PluginStateAction.ready() = _Ready; + const factory PluginStateAction.restartPlugin() = _RestartPlugin; + const factory PluginStateAction.startAIOfflineApp() = _StartAIOfflineApp; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart new file mode 100644 index 0000000000000..91ac63944cf2e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/settings_ai_bloc.dart @@ -0,0 +1,163 @@ +import 'package:appflowy/user/application/user_listener.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'settings_ai_bloc.freezed.dart'; + +class SettingsAIBloc extends Bloc { + SettingsAIBloc( + this.userProfile, + this.workspaceId, + AFRolePB? currentWorkspaceMemberRole, + ) : _userListener = UserListener(userProfile: userProfile), + _userService = UserBackendService(userId: userProfile.id), + super( + SettingsAIState( + userProfile: userProfile, + currentWorkspaceMemberRole: currentWorkspaceMemberRole, + ), + ) { + _dispatch(); + + if (currentWorkspaceMemberRole == null) { + _userService.getWorkspaceMember().then((result) { + result.fold( + (member) { + if (!isClosed) { + add(SettingsAIEvent.refreshMember(member)); + } + }, + (err) { + Log.error(err); + }, + ); + }); + } + } + + final UserListener _userListener; + final UserProfilePB userProfile; + final UserBackendService _userService; + final String workspaceId; + + @override + Future close() async { + await _userListener.stop(); + return super.close(); + } + + void _dispatch() { + on((event, emit) { + event.when( + started: () { + _userListener.start( + onProfileUpdated: _onProfileUpdated, + onUserWorkspaceSettingUpdated: (settings) { + if (!isClosed) { + add(SettingsAIEvent.didLoadAISetting(settings)); + } + }, + ); + _loadUserWorkspaceSetting(); + }, + didReceiveUserProfile: (userProfile) { + emit(state.copyWith(userProfile: userProfile)); + }, + toggleAISearch: () { + emit( + state.copyWith(enableSearchIndexing: !state.enableSearchIndexing), + ); + _updateUserWorkspaceSetting( + disableSearchIndexing: + !(state.aiSettings?.disableSearchIndexing ?? false), + ); + }, + selectModel: (AIModelPB model) { + _updateUserWorkspaceSetting(model: model); + }, + didLoadAISetting: (UseAISettingPB settings) { + emit( + state.copyWith( + aiSettings: settings, + enableSearchIndexing: !settings.disableSearchIndexing, + ), + ); + }, + refreshMember: (member) { + emit(state.copyWith(currentWorkspaceMemberRole: member.role)); + }, + ); + }); + } + + void _updateUserWorkspaceSetting({ + bool? disableSearchIndexing, + AIModelPB? model, + }) { + final payload = UpdateUserWorkspaceSettingPB( + workspaceId: workspaceId, + ); + if (disableSearchIndexing != null) { + payload.disableSearchIndexing = disableSearchIndexing; + } + if (model != null) { + payload.aiModel = model; + } + UserEventUpdateWorkspaceSetting(payload).send(); + } + + void _onProfileUpdated( + FlowyResult userProfileOrFailed, + ) => + userProfileOrFailed.fold( + (profile) => add(SettingsAIEvent.didReceiveUserProfile(profile)), + (err) => Log.error(err), + ); + + void _loadUserWorkspaceSetting() { + final payload = UserWorkspaceIdPB(workspaceId: workspaceId); + UserEventGetWorkspaceSetting(payload).send().then((result) { + result.fold((settings) { + if (!isClosed) { + add(SettingsAIEvent.didLoadAISetting(settings)); + } + }, (err) { + Log.error(err); + }); + }); + } +} + +@freezed +class SettingsAIEvent with _$SettingsAIEvent { + const factory SettingsAIEvent.started() = _Started; + const factory SettingsAIEvent.didLoadAISetting( + UseAISettingPB settings, + ) = _DidLoadWorkspaceSetting; + + const factory SettingsAIEvent.toggleAISearch() = _toggleAISearch; + const factory SettingsAIEvent.refreshMember(WorkspaceMemberPB member) = + _RefreshMember; + + const factory SettingsAIEvent.selectModel(AIModelPB model) = _SelectAIModel; + + const factory SettingsAIEvent.didReceiveUserProfile( + UserProfilePB newUserProfile, + ) = _DidReceiveUserProfile; +} + +@freezed +class SettingsAIState with _$SettingsAIState { + const factory SettingsAIState({ + required UserProfilePB userProfile, + UseAISettingPB? aiSettings, + AFRolePB? currentWorkspaceMemberRole, + @Default(true) bool enableSearchIndexing, + }) = _SettingsAIState; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart new file mode 100644 index 0000000000000..a034558110446 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/appearance_cubit.dart @@ -0,0 +1,437 @@ +import 'dart:async'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/user_settings_service.dart'; +import 'package:appflowy/util/color_to_hex_string.dart'; +import 'package:appflowy/workspace/application/appearance_defaults.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' + show AppFlowyEditorLocalizations; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'appearance_cubit.freezed.dart'; + +/// [AppearanceSettingsCubit] is used to modify the appearance of AppFlowy. +/// It includes: +/// - [AppTheme] +/// - [ThemeMode] +/// - [TextStyle]'s +/// - [Locale] +/// - [UserDateFormatPB] +/// - [UserTimeFormatPB] +/// +class AppearanceSettingsCubit extends Cubit { + AppearanceSettingsCubit( + AppearanceSettingsPB appearanceSettings, + DateTimeSettingsPB dateTimeSettings, + AppTheme appTheme, + ) : _appearanceSettings = appearanceSettings, + _dateTimeSettings = dateTimeSettings, + super( + AppearanceSettingsState.initial( + appTheme, + appearanceSettings.themeMode, + appearanceSettings.font, + appearanceSettings.layoutDirection, + appearanceSettings.textDirection, + appearanceSettings.enableRtlToolbarItems, + appearanceSettings.locale, + appearanceSettings.isMenuCollapsed, + appearanceSettings.menuOffset, + dateTimeSettings.dateFormat, + dateTimeSettings.timeFormat, + dateTimeSettings.timezoneId, + appearanceSettings.documentSetting.cursorColor.isEmpty + ? null + : Color( + int.parse(appearanceSettings.documentSetting.cursorColor), + ), + appearanceSettings.documentSetting.selectionColor.isEmpty + ? null + : Color( + int.parse( + appearanceSettings.documentSetting.selectionColor, + ), + ), + 1.0, + ), + ) { + readTextScaleFactor(); + } + + final AppearanceSettingsPB _appearanceSettings; + final DateTimeSettingsPB _dateTimeSettings; + + Future setTextScaleFactor(double textScaleFactor) async { + // only saved in local storage, this value is not synced across devices + await getIt().set( + KVKeys.textScaleFactor, + textScaleFactor.toString(), + ); + + // don't allow the text scale factor to be greater than 1.0, it will cause + // ui issues + emit(state.copyWith(textScaleFactor: textScaleFactor.clamp(0.7, 1.0))); + } + + Future readTextScaleFactor() async { + final textScaleFactor = await getIt().getWithFormat( + KVKeys.textScaleFactor, + (value) => double.parse(value), + ) ?? + 1.0; + emit(state.copyWith(textScaleFactor: textScaleFactor.clamp(0.7, 1.0))); + } + + /// Update selected theme in the user's settings and emit an updated state + /// with the AppTheme named [themeName]. + Future setTheme(String themeName) async { + _appearanceSettings.theme = themeName; + unawaited(_saveAppearanceSettings()); + emit(state.copyWith(appTheme: await AppTheme.fromName(themeName))); + } + + /// Reset the current user selected theme back to the default + Future resetTheme() => + setTheme(DefaultAppearanceSettings.kDefaultThemeName); + + /// Update the theme mode in the user's settings and emit an updated state. + void setThemeMode(ThemeMode themeMode) { + _appearanceSettings.themeMode = _themeModeToPB(themeMode); + _saveAppearanceSettings(); + emit(state.copyWith(themeMode: themeMode)); + } + + /// Resets the current brightness setting + void resetThemeMode() => + setThemeMode(DefaultAppearanceSettings.kDefaultThemeMode); + + /// Toggle the theme mode + void toggleThemeMode() { + final currentThemeMode = state.themeMode; + setThemeMode( + currentThemeMode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light, + ); + } + + void setLayoutDirection(LayoutDirection layoutDirection) { + _appearanceSettings.layoutDirection = layoutDirection.toLayoutDirectionPB(); + _saveAppearanceSettings(); + emit(state.copyWith(layoutDirection: layoutDirection)); + } + + void setTextDirection(AppFlowyTextDirection? textDirection) { + _appearanceSettings.textDirection = + textDirection?.toTextDirectionPB() ?? TextDirectionPB.FALLBACK; + _saveAppearanceSettings(); + emit(state.copyWith(textDirection: textDirection)); + } + + void setEnableRTLToolbarItems(bool value) { + _appearanceSettings.enableRtlToolbarItems = value; + _saveAppearanceSettings(); + emit(state.copyWith(enableRtlToolbarItems: value)); + } + + /// Update selected font in the user's settings and emit an updated state + /// with the font name. + void setFontFamily(String fontFamilyName) { + _appearanceSettings.font = fontFamilyName; + _saveAppearanceSettings(); + emit(state.copyWith(font: fontFamilyName)); + } + + /// Resets the current font family for the user preferences + void resetFontFamily() => + setFontFamily(DefaultAppearanceSettings.kDefaultFontFamily); + + /// Update document cursor color in the appearance settings and emit an updated state. + void setDocumentCursorColor(Color color) { + _appearanceSettings.documentSetting.cursorColor = color.toHexString(); + _saveAppearanceSettings(); + emit(state.copyWith(documentCursorColor: color)); + } + + /// Reset document cursor color in the appearance settings + void resetDocumentCursorColor() { + _appearanceSettings.documentSetting.cursorColor = ''; + _saveAppearanceSettings(); + emit(state.copyWith(documentCursorColor: null)); + } + + /// Update document selection color in the appearance settings and emit an updated state. + void setDocumentSelectionColor(Color color) { + _appearanceSettings.documentSetting.selectionColor = color.toHexString(); + _saveAppearanceSettings(); + emit(state.copyWith(documentSelectionColor: color)); + } + + /// Reset document selection color in the appearance settings + void resetDocumentSelectionColor() { + _appearanceSettings.documentSetting.selectionColor = ''; + _saveAppearanceSettings(); + emit(state.copyWith(documentSelectionColor: null)); + } + + /// Updates the current locale and notify the listeners the locale was + /// changed. Fallback to [en] locale if [newLocale] is not supported. + void setLocale(BuildContext context, Locale newLocale) { + if (!context.supportedLocales.contains(newLocale)) { + // Log.warn("Unsupported locale: $newLocale, Fallback to locale: en"); + newLocale = const Locale('en'); + } + + context.setLocale(newLocale).catchError((e) { + Log.warn('Catch error in setLocale: $e}'); + }); + + // Sync the app's locale with the editor (initialization and update) + AppFlowyEditorLocalizations.load(newLocale); + + if (state.locale != newLocale) { + _appearanceSettings.locale.languageCode = newLocale.languageCode; + _appearanceSettings.locale.countryCode = newLocale.countryCode ?? ""; + _saveAppearanceSettings(); + emit(state.copyWith(locale: newLocale)); + } + } + + // Saves the menus current visibility + void saveIsMenuCollapsed(bool collapsed) { + _appearanceSettings.isMenuCollapsed = collapsed; + _saveAppearanceSettings(); + } + + // Saves the current resize offset of the menu + void saveMenuOffset(double offset) { + _appearanceSettings.menuOffset = offset; + _saveAppearanceSettings(); + } + + /// Saves key/value setting to disk. + /// Removes the key if the passed in value is null + void setKeyValue(String key, String? value) { + if (key.isEmpty) { + Log.warn("The key should not be empty"); + return; + } + + if (value == null) { + _appearanceSettings.settingKeyValue.remove(key); + } + + if (_appearanceSettings.settingKeyValue[key] != value) { + if (value == null) { + _appearanceSettings.settingKeyValue.remove(key); + } else { + _appearanceSettings.settingKeyValue[key] = value; + } + } + _saveAppearanceSettings(); + } + + String? getValue(String key) { + if (key.isEmpty) { + Log.warn("The key should not be empty"); + return null; + } + return _appearanceSettings.settingKeyValue[key]; + } + + /// Called when the application launches. + /// Uses the device locale when the application is opened for the first time. + void readLocaleWhenAppLaunch(BuildContext context) { + if (_appearanceSettings.resetToDefault) { + _appearanceSettings.resetToDefault = false; + _saveAppearanceSettings(); + setLocale(context, context.deviceLocale); + return; + } + + setLocale(context, state.locale); + } + + void setDateFormat(UserDateFormatPB format) { + _dateTimeSettings.dateFormat = format; + _saveDateTimeSettings(); + emit(state.copyWith(dateFormat: format)); + } + + void setTimeFormat(UserTimeFormatPB format) { + _dateTimeSettings.timeFormat = format; + _saveDateTimeSettings(); + emit(state.copyWith(timeFormat: format)); + } + + Future _saveDateTimeSettings() async { + final result = await UserSettingsBackendService() + .setDateTimeSettings(_dateTimeSettings); + result.fold( + (_) => null, + (error) => Log.error(error), + ); + } + + Future _saveAppearanceSettings() async { + final result = await UserSettingsBackendService() + .setAppearanceSetting(_appearanceSettings); + result.fold( + (l) => null, + (error) => Log.error(error), + ); + } +} + +ThemeMode _themeModeFromPB(ThemeModePB themeModePB) { + switch (themeModePB) { + case ThemeModePB.Light: + return ThemeMode.light; + case ThemeModePB.Dark: + return ThemeMode.dark; + case ThemeModePB.System: + default: + return ThemeMode.system; + } +} + +ThemeModePB _themeModeToPB(ThemeMode themeMode) { + switch (themeMode) { + case ThemeMode.light: + return ThemeModePB.Light; + case ThemeMode.dark: + return ThemeModePB.Dark; + case ThemeMode.system: + default: + return ThemeModePB.System; + } +} + +enum LayoutDirection { + ltrLayout, + rtlLayout; + + static LayoutDirection fromLayoutDirectionPB( + LayoutDirectionPB layoutDirectionPB, + ) => + layoutDirectionPB == LayoutDirectionPB.RTLLayout + ? LayoutDirection.rtlLayout + : LayoutDirection.ltrLayout; + + LayoutDirectionPB toLayoutDirectionPB() => this == LayoutDirection.rtlLayout + ? LayoutDirectionPB.RTLLayout + : LayoutDirectionPB.LTRLayout; +} + +enum AppFlowyTextDirection { + ltr, + rtl, + auto; + + static AppFlowyTextDirection? fromTextDirectionPB( + TextDirectionPB? textDirectionPB, + ) { + switch (textDirectionPB) { + case TextDirectionPB.LTR: + return AppFlowyTextDirection.ltr; + case TextDirectionPB.RTL: + return AppFlowyTextDirection.rtl; + case TextDirectionPB.AUTO: + return AppFlowyTextDirection.auto; + default: + return null; + } + } + + TextDirectionPB toTextDirectionPB() { + switch (this) { + case AppFlowyTextDirection.ltr: + return TextDirectionPB.LTR; + case AppFlowyTextDirection.rtl: + return TextDirectionPB.RTL; + case AppFlowyTextDirection.auto: + return TextDirectionPB.AUTO; + default: + return TextDirectionPB.FALLBACK; + } + } +} + +@freezed +class AppearanceSettingsState with _$AppearanceSettingsState { + const AppearanceSettingsState._(); + + const factory AppearanceSettingsState({ + required AppTheme appTheme, + required ThemeMode themeMode, + required String font, + required LayoutDirection layoutDirection, + required AppFlowyTextDirection? textDirection, + required bool enableRtlToolbarItems, + required Locale locale, + required bool isMenuCollapsed, + required double menuOffset, + required UserDateFormatPB dateFormat, + required UserTimeFormatPB timeFormat, + required String timezoneId, + required Color? documentCursorColor, + required Color? documentSelectionColor, + required double textScaleFactor, + }) = _AppearanceSettingsState; + + factory AppearanceSettingsState.initial( + AppTheme appTheme, + ThemeModePB themeModePB, + String font, + LayoutDirectionPB layoutDirectionPB, + TextDirectionPB? textDirectionPB, + bool enableRtlToolbarItems, + LocaleSettingsPB localePB, + bool isMenuCollapsed, + double menuOffset, + UserDateFormatPB dateFormat, + UserTimeFormatPB timeFormat, + String timezoneId, + Color? documentCursorColor, + Color? documentSelectionColor, + double textScaleFactor, + ) { + return AppearanceSettingsState( + appTheme: appTheme, + font: font, + layoutDirection: LayoutDirection.fromLayoutDirectionPB(layoutDirectionPB), + textDirection: AppFlowyTextDirection.fromTextDirectionPB(textDirectionPB), + enableRtlToolbarItems: enableRtlToolbarItems, + themeMode: _themeModeFromPB(themeModePB), + locale: Locale(localePB.languageCode, localePB.countryCode), + isMenuCollapsed: isMenuCollapsed, + menuOffset: menuOffset, + dateFormat: dateFormat, + timeFormat: timeFormat, + timezoneId: timezoneId, + documentCursorColor: documentCursorColor, + documentSelectionColor: documentSelectionColor, + textScaleFactor: textScaleFactor, + ); + } + + ThemeData get lightTheme => _getThemeData(Brightness.light); + ThemeData get darkTheme => _getThemeData(Brightness.dark); + + ThemeData _getThemeData(Brightness brightness) { + return getIt().getThemeData( + appTheme, + brightness, + font, + builtInCodeFontFamily, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart new file mode 100644 index 0000000000000..6be53cf158f38 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart @@ -0,0 +1,125 @@ +import 'package:appflowy/shared/google_fonts_extension.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flutter/material.dart'; + +// the default font family is empty, so we can use the default font family of the platform +// the system will choose the default font family of the platform +// iOS: San Francisco +// Android: Roboto +// Desktop: Based on the OS +const defaultFontFamily = ''; + +const builtInCodeFontFamily = 'RobotoMono'; + +abstract class BaseAppearance { + final white = const Color(0xFFFFFFFF); + + final Set scrollbarInteractiveStates = { + WidgetState.pressed, + WidgetState.hovered, + WidgetState.dragged, + }; + + TextStyle getFontStyle({ + required String fontFamily, + double? fontSize, + FontWeight? fontWeight, + Color? fontColor, + double? letterSpacing, + double? lineHeight, + }) { + fontSize = fontSize ?? FontSizes.s14; + fontWeight = fontWeight ?? FontWeight.w400; + letterSpacing = fontSize * (letterSpacing ?? 0.005); + + final textStyle = TextStyle( + fontFamily: fontFamily.isEmpty ? null : fontFamily, + fontSize: fontSize, + color: fontColor, + fontWeight: fontWeight, + letterSpacing: letterSpacing, + height: lineHeight, + ); + + if (fontFamily == defaultFontFamily) { + return textStyle; + } + + try { + return getGoogleFontSafely( + fontFamily, + fontSize: fontSize, + fontColor: fontColor, + fontWeight: fontWeight, + letterSpacing: letterSpacing, + lineHeight: lineHeight, + ); + } catch (e) { + return textStyle; + } + } + + TextTheme getTextTheme({ + required String fontFamily, + required Color fontColor, + }) { + return TextTheme( + displayLarge: getFontStyle( + fontFamily: fontFamily, + fontSize: FontSizes.s32, + fontColor: fontColor, + fontWeight: FontWeight.w600, + lineHeight: 42.0, + ), // h2 + displayMedium: getFontStyle( + fontFamily: fontFamily, + fontSize: FontSizes.s24, + fontColor: fontColor, + fontWeight: FontWeight.w600, + lineHeight: 34.0, + ), // h3 + displaySmall: getFontStyle( + fontFamily: fontFamily, + fontSize: FontSizes.s20, + fontColor: fontColor, + fontWeight: FontWeight.w600, + lineHeight: 28.0, + ), // h4 + titleLarge: getFontStyle( + fontFamily: fontFamily, + fontSize: FontSizes.s18, + fontColor: fontColor, + fontWeight: FontWeight.w600, + ), // title + titleMedium: getFontStyle( + fontFamily: fontFamily, + fontSize: FontSizes.s16, + fontColor: fontColor, + fontWeight: FontWeight.w600, + ), // heading + titleSmall: getFontStyle( + fontFamily: fontFamily, + fontSize: FontSizes.s14, + fontColor: fontColor, + fontWeight: FontWeight.w600, + ), // subheading + bodyMedium: getFontStyle( + fontFamily: fontFamily, + fontColor: fontColor, + ), // body-regular + bodySmall: getFontStyle( + fontFamily: fontFamily, + fontColor: fontColor, + fontWeight: FontWeight.w400, + ), // body-thin + ); + } + + ThemeData getThemeData( + AppTheme appTheme, + Brightness brightness, + String fontFamily, + String codeFontFamily, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart new file mode 100644 index 0000000000000..e1ea22a6ebebf --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/desktop_appearance.dart @@ -0,0 +1,157 @@ +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +class DesktopAppearance extends BaseAppearance { + @override + ThemeData getThemeData( + AppTheme appTheme, + Brightness brightness, + String fontFamily, + String codeFontFamily, + ) { + assert(codeFontFamily.isNotEmpty); + + final theme = brightness == Brightness.light + ? appTheme.lightTheme + : appTheme.darkTheme; + + final colorScheme = ColorScheme( + brightness: brightness, + primary: theme.primary, + onPrimary: theme.onPrimary, + primaryContainer: theme.main2, + onPrimaryContainer: white, + // page title hover color + secondary: theme.hoverBG1, + onSecondary: theme.shader1, + // setting value hover color + secondaryContainer: theme.selector, + onSecondaryContainer: theme.topbarBg, + tertiary: theme.shader7, + // Editor: toolbarColor + onTertiary: theme.toolbarColor, + tertiaryContainer: theme.questionBubbleBG, + surface: theme.surface, + // text&icon color when it is hovered + onSurface: theme.hoverFG, + // grey hover color + inverseSurface: theme.hoverBG3, + onError: theme.onPrimary, + error: theme.red, + outline: theme.shader4, + surfaceContainerHighest: theme.sidebarBg, + shadow: theme.shadow, + ); + + // Due to Desktop version has multiple themes, it relies on the current theme to build the ThemeData + return ThemeData( + visualDensity: VisualDensity.standard, + useMaterial3: false, + brightness: brightness, + dialogBackgroundColor: theme.surface, + textTheme: getTextTheme( + fontFamily: fontFamily, + fontColor: theme.text, + ), + textButtonTheme: const TextButtonThemeData( + style: ButtonStyle( + minimumSize: WidgetStatePropertyAll(Size.zero), + ), + ), + textSelectionTheme: TextSelectionThemeData( + cursorColor: theme.main2, + selectionHandleColor: theme.main2, + ), + iconTheme: IconThemeData(color: theme.icon), + tooltipTheme: TooltipThemeData( + textStyle: getFontStyle( + fontFamily: fontFamily, + fontSize: FontSizes.s11, + fontWeight: FontWeight.w400, + fontColor: theme.surface, + ), + ), + scaffoldBackgroundColor: theme.surface, + snackBarTheme: SnackBarThemeData( + backgroundColor: colorScheme.primary, + contentTextStyle: TextStyle(color: colorScheme.onSurface), + ), + scrollbarTheme: ScrollbarThemeData( + thumbColor: WidgetStateProperty.resolveWith( + (states) => states.any(scrollbarInteractiveStates.contains) + ? theme.scrollbarHoverColor + : theme.scrollbarColor, + ), + thickness: WidgetStateProperty.resolveWith((_) => 4.0), + crossAxisMargin: 0.0, + mainAxisMargin: 6.0, + radius: Corners.s10Radius, + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + //dropdown menu color + canvasColor: theme.surface, + dividerColor: theme.divider, + hintColor: theme.hint, + //action item hover color + hoverColor: theme.hoverBG2, + disabledColor: theme.shader4, + highlightColor: theme.main1, + indicatorColor: theme.main1, + cardColor: theme.input, + colorScheme: colorScheme, + + extensions: [ + AFThemeExtension( + warning: theme.yellow, + success: theme.green, + tint1: theme.tint1, + tint2: theme.tint2, + tint3: theme.tint3, + tint4: theme.tint4, + tint5: theme.tint5, + tint6: theme.tint6, + tint7: theme.tint7, + tint8: theme.tint8, + tint9: theme.tint9, + textColor: theme.text, + secondaryTextColor: theme.secondaryText, + strongText: theme.strongText, + greyHover: theme.hoverBG1, + greySelect: theme.bg3, + lightGreyHover: theme.hoverBG3, + toggleOffFill: theme.shader5, + progressBarBGColor: theme.progressBarBGColor, + toggleButtonBGColor: theme.toggleButtonBGColor, + calendarWeekendBGColor: theme.calendarWeekendBGColor, + gridRowCountColor: theme.gridRowCountColor, + code: getFontStyle( + fontFamily: codeFontFamily, + fontColor: theme.shader3, + ), + callout: getFontStyle( + fontFamily: fontFamily, + fontSize: FontSizes.s11, + fontColor: theme.shader3, + ), + calloutBGColor: theme.hoverBG3, + tableCellBGColor: theme.surface, + caption: getFontStyle( + fontFamily: fontFamily, + fontSize: FontSizes.s11, + fontWeight: FontWeight.w400, + fontColor: theme.hint, + ), + onBackground: theme.text, + background: theme.surface, + borderColor: theme.borderColor, + scrollbarColor: theme.scrollbarColor, + scrollbarHoverColor: theme.scrollbarHoverColor, + lightIconColor: theme.lightIconColor, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart new file mode 100644 index 0000000000000..63235ba217a25 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/mobile_appearance.dart @@ -0,0 +1,288 @@ +// ThemeData in mobile +import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/aa_menu/_toolbar_theme.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +class MobileAppearance extends BaseAppearance { + static const _primaryColor = Color(0xFF00BCF0); //primary 100 + static const _onBackgroundColor = Color(0xff2F3030); // text/title color + static const _onSurfaceColor = Color(0xff676666); // text/body color + static const _onSecondaryColor = Color(0xFFC5C7CB); // text/body2 color + static const _hintColorInDarkMode = Color(0xff626262); // hint color + + @override + ThemeData getThemeData( + AppTheme appTheme, + Brightness brightness, + String fontFamily, + String codeFontFamily, + ) { + assert(codeFontFamily.isNotEmpty); + + final fontStyle = getFontStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w400, + ); + + final codeFontStyle = getFontStyle(fontFamily: codeFontFamily); + + final theme = brightness == Brightness.light + ? appTheme.lightTheme + : appTheme.darkTheme; + + final colorTheme = brightness == Brightness.light + ? ColorScheme( + brightness: brightness, + primary: _primaryColor, + onPrimary: Colors.white, + // group card header background color + primaryContainer: const Color(0xffF1F1F4), // primary 20 + // group card & property edit background color + secondary: const Color(0xfff7f8fc), // shade 10 + onSecondary: _onSecondaryColor, + // hidden group title & card text color + tertiary: const Color(0xff858585), // for light text + error: const Color(0xffFB006D), + onError: const Color(0xffFB006D), + outline: const Color(0xffe3e3e3), + outlineVariant: const Color(0xffCBD5E0).withOpacity(0.24), + //Snack bar + surface: Colors.white, + onSurface: _onSurfaceColor, // text/body color + surfaceContainerHighest: theme.sidebarBg, + ) + : ColorScheme( + brightness: brightness, + primary: _primaryColor, + onPrimary: Colors.black, + secondary: const Color(0xff2d2d2d), //temp + onSecondary: Colors.white, + tertiary: const Color(0xff858585), // temp + error: const Color(0xffFB006D), + onError: const Color(0xffFB006D), + outline: _hintColorInDarkMode, + outlineVariant: Colors.black, + //Snack bar + surface: const Color(0xFF171A1F), + onSurface: const Color(0xffC5C6C7), // text/body color + surfaceContainerHighest: theme.sidebarBg, + ); + final hintColor = brightness == Brightness.light + ? const Color(0x991F2329) + : _hintColorInDarkMode; + final onBackground = + brightness == Brightness.light ? _onBackgroundColor : Colors.white; + final background = + brightness == Brightness.light ? Colors.white : const Color(0xff121212); + + return ThemeData( + useMaterial3: false, + primaryColor: colorTheme.primary, //primary 100 + primaryColorLight: const Color(0xFF57B5F8), //primary 80 + dividerColor: colorTheme.outline, //caption + hintColor: hintColor, + disabledColor: colorTheme.outline, + scaffoldBackgroundColor: background, + appBarTheme: AppBarTheme( + toolbarHeight: 44.0, + foregroundColor: onBackground, + backgroundColor: background, + centerTitle: false, + titleTextStyle: TextStyle( + color: onBackground, + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: 0.05, + ), + shadowColor: colorTheme.outlineVariant, + ), + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return colorTheme.primary; + } + return colorTheme.outline; + }), + ), + // button + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + fixedSize: WidgetStateProperty.all(const Size.fromHeight(48)), + elevation: WidgetStateProperty.all(0), + textStyle: WidgetStateProperty.all( + TextStyle( + fontSize: 14, + fontFamily: fontStyle.fontFamily, + fontWeight: FontWeight.w600, + ), + ), + shadowColor: WidgetStateProperty.all(null), + foregroundColor: WidgetStateProperty.all(Colors.white), + backgroundColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return _primaryColor; + } + return colorTheme.primary; + }, + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: ButtonStyle( + textStyle: WidgetStateProperty.all( + TextStyle( + fontSize: 14, + fontFamily: fontStyle.fontFamily, + fontWeight: FontWeight.w500, + ), + ), + foregroundColor: WidgetStateProperty.all(onBackground), + backgroundColor: WidgetStateProperty.all(background), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + side: WidgetStateProperty.all( + BorderSide(color: colorTheme.outline, width: 0.5), + ), + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: ButtonStyle( + textStyle: WidgetStateProperty.all(fontStyle), + ), + ), + // text + fontFamily: fontStyle.fontFamily, + textTheme: TextTheme( + displayLarge: const TextStyle( + color: _primaryColor, + fontSize: 32, + fontWeight: FontWeight.w700, + height: 1.20, + letterSpacing: 0.16, + ), + displayMedium: fontStyle.copyWith( + color: onBackground, + fontSize: 32, + fontWeight: FontWeight.w600, + height: 1.20, + letterSpacing: 0.16, + ), + // H1 Semi 26 + displaySmall: fontStyle.copyWith( + color: onBackground, + fontWeight: FontWeight.w600, + height: 1.10, + letterSpacing: 0.13, + ), + // body2 14 Regular + bodyMedium: fontStyle.copyWith( + color: onBackground, + fontWeight: FontWeight.w400, + letterSpacing: 0.07, + ), + // Trash empty title + labelLarge: fontStyle.copyWith( + color: onBackground, + fontSize: 22, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), + // setting item title + labelMedium: fontStyle.copyWith( + color: onBackground, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + // setting group title + labelSmall: fontStyle.copyWith( + color: onBackground, + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.05, + ), + ), + inputDecorationTheme: InputDecorationTheme( + contentPadding: const EdgeInsets.all(8), + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide( + width: 2, + color: _primaryColor, + ), + borderRadius: BorderRadius.all(Radius.circular(6)), + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorTheme.error), + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorTheme.error), + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: colorTheme.outline, + ), + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + ), + colorScheme: colorTheme, + indicatorColor: Colors.blue, + extensions: [ + AFThemeExtension( + warning: theme.yellow, + success: theme.green, + tint1: theme.tint1, + tint2: theme.tint2, + tint3: theme.tint3, + tint4: theme.tint4, + tint5: theme.tint5, + tint6: theme.tint6, + tint7: theme.tint7, + tint8: theme.tint8, + tint9: theme.tint9, + textColor: theme.text, + secondaryTextColor: theme.secondaryText, + strongText: theme.strongText, + greyHover: theme.hoverBG1, + greySelect: theme.bg3, + lightGreyHover: theme.hoverBG3, + toggleOffFill: theme.shader5, + progressBarBGColor: theme.progressBarBGColor, + toggleButtonBGColor: theme.toggleButtonBGColor, + calendarWeekendBGColor: theme.calendarWeekendBGColor, + gridRowCountColor: theme.gridRowCountColor, + code: codeFontStyle.copyWith( + color: theme.shader3, + ), + callout: fontStyle.copyWith( + fontSize: FontSizes.s11, + color: theme.shader3, + ), + calloutBGColor: theme.hoverBG3, + tableCellBGColor: theme.surface, + caption: fontStyle.copyWith( + fontSize: FontSizes.s11, + fontWeight: FontWeight.w400, + color: theme.hint, + ), + onBackground: onBackground, + background: background, + borderColor: theme.borderColor, + scrollbarColor: theme.scrollbarColor, + scrollbarHoverColor: theme.scrollbarHoverColor, + lightIconColor: theme.lightIconColor, + ), + ToolbarColorExtension.fromBrightness(brightness), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_setting_bloc.dart new file mode 100644 index 0000000000000..febb89727aa40 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_setting_bloc.dart @@ -0,0 +1,119 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/cloud_setting_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'appflowy_cloud_setting_bloc.freezed.dart'; + +class AppFlowyCloudSettingBloc + extends Bloc { + AppFlowyCloudSettingBloc(CloudSettingPB setting) + : _listener = UserCloudConfigListener(), + super(AppFlowyCloudSettingState.initial(setting, false)) { + _dispatch(); + } + + final UserCloudConfigListener _listener; + + @override + Future close() async { + await _listener.stop(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () async { + await getSyncLogEnabled().then((value) { + emit(state.copyWith(isSyncLogEnabled: value)); + }); + + _listener.start( + onSettingChanged: (result) { + if (isClosed) { + return; + } + result.fold( + (setting) => + add(AppFlowyCloudSettingEvent.didReceiveSetting(setting)), + (error) => Log.error(error), + ); + }, + ); + }, + enableSync: (isEnable) async { + final config = UpdateCloudConfigPB.create()..enableSync = isEnable; + await UserEventSetCloudConfig(config).send(); + }, + enableSyncLog: (isEnable) async { + await setSyncLogEnabled(isEnable); + emit(state.copyWith(isSyncLogEnabled: isEnable)); + }, + didReceiveSetting: (CloudSettingPB setting) { + emit( + state.copyWith( + setting: setting, + showRestartHint: setting.serverUrl.isNotEmpty, + ), + ); + }, + ); + }, + ); + } +} + +@freezed +class AppFlowyCloudSettingEvent with _$AppFlowyCloudSettingEvent { + const factory AppFlowyCloudSettingEvent.initial() = _Initial; + const factory AppFlowyCloudSettingEvent.enableSync(bool isEnable) = + _EnableSync; + const factory AppFlowyCloudSettingEvent.enableSyncLog(bool isEnable) = + _EnableSyncLog; + const factory AppFlowyCloudSettingEvent.didReceiveSetting( + CloudSettingPB setting, + ) = _DidUpdateSetting; +} + +@freezed +class AppFlowyCloudSettingState with _$AppFlowyCloudSettingState { + const factory AppFlowyCloudSettingState({ + required CloudSettingPB setting, + required bool showRestartHint, + required bool isSyncLogEnabled, + }) = _AppFlowyCloudSettingState; + + factory AppFlowyCloudSettingState.initial( + CloudSettingPB setting, + bool isSyncLogEnabled, + ) => + AppFlowyCloudSettingState( + setting: setting, + showRestartHint: setting.serverUrl.isNotEmpty, + isSyncLogEnabled: isSyncLogEnabled, + ); +} + +FlowyResult validateUrl(String url) { + try { + // Use Uri.parse to validate the url. + final uri = Uri.parse(url); + if (uri.isScheme('HTTP') || uri.isScheme('HTTPS')) { + return FlowyResult.success(null); + } else { + return FlowyResult.failure( + LocaleKeys.settings_menu_invalidCloudURLScheme.tr(), + ); + } + } catch (e) { + return FlowyResult.failure(e.toString()); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_urls_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_urls_bloc.dart new file mode 100644 index 0000000000000..998e6d632fe06 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_urls_bloc.dart @@ -0,0 +1,113 @@ +import 'package:appflowy/env/backend_env.dart'; +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'appflowy_cloud_urls_bloc.freezed.dart'; + +class AppFlowyCloudURLsBloc + extends Bloc { + AppFlowyCloudURLsBloc() : super(AppFlowyCloudURLsState.initial()) { + on((event, emit) async { + await event.when( + initial: () async {}, + updateServerUrl: (url) { + emit( + state.copyWith( + updatedServerUrl: url, + urlError: null, + showRestartHint: url.isNotEmpty, + ), + ); + }, + confirmUpdate: () async { + if (state.updatedServerUrl.isEmpty) { + emit( + state.copyWith( + updatedServerUrl: "", + urlError: + LocaleKeys.settings_menu_appFlowyCloudUrlCanNotBeEmpty.tr(), + restartApp: false, + ), + ); + } else { + validateUrl(state.updatedServerUrl).fold( + (url) async { + await useSelfHostedAppFlowyCloudWithURL(url); + add(const AppFlowyCloudURLsEvent.didSaveConfig()); + }, + (err) => emit(state.copyWith(urlError: err)), + ); + } + }, + didSaveConfig: () { + emit( + state.copyWith( + urlError: null, + restartApp: true, + ), + ); + }, + ); + }); + } +} + +@freezed +class AppFlowyCloudURLsEvent with _$AppFlowyCloudURLsEvent { + const factory AppFlowyCloudURLsEvent.initial() = _Initial; + const factory AppFlowyCloudURLsEvent.updateServerUrl(String text) = + _ServerUrl; + const factory AppFlowyCloudURLsEvent.confirmUpdate() = _UpdateConfig; + const factory AppFlowyCloudURLsEvent.didSaveConfig() = _DidSaveConfig; +} + +@freezed +class AppFlowyCloudURLsState with _$AppFlowyCloudURLsState { + const factory AppFlowyCloudURLsState({ + required AppFlowyCloudConfiguration config, + required String updatedServerUrl, + required String? urlError, + required bool restartApp, + required bool showRestartHint, + }) = _AppFlowyCloudURLsState; + + factory AppFlowyCloudURLsState.initial() => AppFlowyCloudURLsState( + config: getIt().appflowyCloudConfig, + urlError: null, + updatedServerUrl: + getIt().appflowyCloudConfig.base_url, + showRestartHint: getIt() + .appflowyCloudConfig + .base_url + .isNotEmpty, + restartApp: false, + ); +} + +FlowyResult validateUrl(String url) { + try { + // Use Uri.parse to validate the url. + final uri = Uri.parse(removeTrailingSlash(url)); + if (uri.isScheme('HTTP') || uri.isScheme('HTTPS')) { + return FlowyResult.success(uri.toString()); + } else { + return FlowyResult.failure( + LocaleKeys.settings_menu_invalidCloudURLScheme.tr(), + ); + } + } catch (e) { + return FlowyResult.failure(e.toString()); + } +} + +String removeTrailingSlash(String input) { + if (input.endsWith('/')) { + return input.substring(0, input.length - 1); + } + return input; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/application_data_storage.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/application_data_storage.dart new file mode 100644 index 0000000000000..b0c8cb09485ef --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/application_data_storage.dart @@ -0,0 +1,108 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:path/path.dart' as p; + +import '../../../startup/tasks/prelude.dart'; + +const appFlowyDataFolder = "AppFlowyDataDoNotRename"; + +class ApplicationDataStorage { + ApplicationDataStorage(); + String? _cachePath; + + /// Set the custom path to store the data. + /// If the path is not exists, the path will be created. + /// If the path is invalid, the path will be set to the default path. + Future setCustomPath(String path) async { + if (kIsWeb || Platform.isAndroid || Platform.isIOS) { + Log.info('LocalFileStorage is not supported on this platform.'); + return; + } + + if (Platform.isMacOS) { + // remove the prefix `/Volumes/*` + path = path.replaceFirst(macOSVolumesRegex, ''); + } else if (Platform.isWindows) { + path = path.replaceAll('/', '\\'); + } + + // If the path is not ends with `AppFlowyData`, we will append the + // `AppFlowyData` to the path. If the path is ends with `AppFlowyData`, + // which means the path is the custom path. + if (p.basename(path) != appFlowyDataFolder) { + path = p.join(path, appFlowyDataFolder); + } + + // create the directory if not exists. + final directory = Directory(path); + if (!directory.existsSync()) { + await directory.create(recursive: true); + } + + await setPath(path); + } + + Future setPath(String path) async { + if (kIsWeb || Platform.isAndroid || Platform.isIOS) { + Log.info('LocalFileStorage is not supported on this platform.'); + return; + } + + await getIt().set(KVKeys.pathLocation, path); + // clear the cache path, and not set the cache path to the new path because the set path may be invalid + _cachePath = null; + } + + Future getPath() async { + if (_cachePath != null) { + return _cachePath!; + } + + final response = await getIt().get(KVKeys.pathLocation); + + String path; + if (response == null) { + final directory = await appFlowyApplicationDataDirectory(); + path = directory.path; + } else { + path = response; + } + _cachePath = path; + + // if the path is not exists means the path is invalid, so we should clear the kv store + if (!Directory(path).existsSync()) { + await getIt().clear(); + final directory = await appFlowyApplicationDataDirectory(); + path = directory.path; + } + + return path; + } +} + +class MockApplicationDataStorage extends ApplicationDataStorage { + MockApplicationDataStorage(); + + // this value will be clear after setup + // only for the initial step + @visibleForTesting + static String? initialPath; + + @override + Future getPath() async { + final path = initialPath; + if (path != null) { + initialPath = null; + await super.setPath(path); + return Future.value(path); + } + return super.getPath(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart new file mode 100644 index 0000000000000..ab324df87f564 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/billing/settings_billing_bloc.dart @@ -0,0 +1,324 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; +import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'settings_billing_bloc.freezed.dart'; + +class SettingsBillingBloc + extends Bloc { + SettingsBillingBloc({ + required this.workspaceId, + required Int64 userId, + }) : super(const _Initial()) { + _userService = UserBackendService(userId: userId); + _service = WorkspaceService(workspaceId: workspaceId); + _successListenable = getIt(); + _successListenable.addListener(_onPaymentSuccessful); + + on((event, emit) async { + await event.when( + started: () async { + emit(const SettingsBillingState.loading()); + + FlowyError? error; + + final result = await UserBackendService.getWorkspaceSubscriptionInfo( + workspaceId, + ); + + final subscriptionInfo = result.fold( + (s) => s, + (e) { + error = e; + return null; + }, + ); + + if (subscriptionInfo == null || error != null) { + return emit(SettingsBillingState.error(error: error)); + } + + if (!_billingPortalCompleter.isCompleted) { + unawaited(_fetchBillingPortal()); + unawaited( + _billingPortalCompleter.future.then( + (result) { + if (isClosed) return; + + result.fold( + (portal) { + _billingPortal = portal; + add( + SettingsBillingEvent.billingPortalFetched( + billingPortal: portal, + ), + ); + }, + (e) => Log.error('Error fetching billing portal: $e'), + ); + }, + ), + ); + } + + emit( + SettingsBillingState.ready( + subscriptionInfo: subscriptionInfo, + billingPortal: _billingPortal, + ), + ); + }, + billingPortalFetched: (billingPortal) async => state.maybeWhen( + orElse: () {}, + ready: (subscriptionInfo, _, plan, isLoading) => emit( + SettingsBillingState.ready( + subscriptionInfo: subscriptionInfo, + billingPortal: billingPortal, + successfulPlanUpgrade: plan, + isLoading: isLoading, + ), + ), + ), + openCustomerPortal: () async { + if (_billingPortalCompleter.isCompleted && _billingPortal != null) { + return afLaunchUrlString(_billingPortal!.url); + } + await _billingPortalCompleter.future; + if (_billingPortal != null) { + await afLaunchUrlString(_billingPortal!.url); + } + }, + addSubscription: (plan) async { + final result = + await _userService.createSubscription(workspaceId, plan); + + result.fold( + (link) => afLaunchUrlString(link.paymentLink), + (f) => Log.error(f.msg, f), + ); + }, + cancelSubscription: (plan, reason) async { + final s = state.mapOrNull(ready: (s) => s); + if (s == null) { + return; + } + + emit(s.copyWith(isLoading: true)); + + final result = + await _userService.cancelSubscription(workspaceId, plan, reason); + final successOrNull = result.fold( + (_) => true, + (f) { + Log.error( + 'Failed to cancel subscription of ${plan.label}: ${f.msg}', + f, + ); + return null; + }, + ); + + if (successOrNull != true) { + return; + } + + final subscriptionInfo = state.mapOrNull( + ready: (s) => s.subscriptionInfo, + ); + + // This is impossible, but for good measure + if (subscriptionInfo == null) { + return; + } + + subscriptionInfo.freeze(); + final newInfo = subscriptionInfo.rebuild((value) { + if (plan.isAddOn) { + value.addOns.removeWhere( + (addon) => addon.addOnSubscription.subscriptionPlan == plan, + ); + } + + if (plan == WorkspacePlanPB.ProPlan && + value.plan == WorkspacePlanPB.ProPlan) { + value.plan = WorkspacePlanPB.FreePlan; + value.planSubscription.freeze(); + value.planSubscription = value.planSubscription.rebuild((sub) { + sub.status = WorkspaceSubscriptionStatusPB.Active; + sub.subscriptionPlan = SubscriptionPlanPB.Free; + }); + } + }); + + emit( + SettingsBillingState.ready( + subscriptionInfo: newInfo, + billingPortal: _billingPortal, + ), + ); + }, + paymentSuccessful: (plan) async { + final result = await UserBackendService.getWorkspaceSubscriptionInfo( + workspaceId, + ); + + final subscriptionInfo = result.toNullable(); + if (subscriptionInfo != null) { + emit( + SettingsBillingState.ready( + subscriptionInfo: subscriptionInfo, + billingPortal: _billingPortal, + ), + ); + } + }, + updatePeriod: (plan, interval) async { + final s = state.mapOrNull(ready: (s) => s); + if (s == null) { + return; + } + + emit(s.copyWith(isLoading: true)); + + final result = await _userService.updateSubscriptionPeriod( + workspaceId, + plan, + interval, + ); + final successOrNull = result.fold((_) => true, (f) { + Log.error( + 'Failed to update subscription period of ${plan.label}: ${f.msg}', + f, + ); + return null; + }); + + if (successOrNull != true) { + return emit(s.copyWith(isLoading: false)); + } + + // Fetch new subscription info + final newResult = + await UserBackendService.getWorkspaceSubscriptionInfo( + workspaceId, + ); + + final newSubscriptionInfo = newResult.toNullable(); + if (newSubscriptionInfo != null) { + emit( + SettingsBillingState.ready( + subscriptionInfo: newSubscriptionInfo, + billingPortal: _billingPortal, + ), + ); + } + }, + ); + }); + } + + late final String workspaceId; + late final WorkspaceService _service; + late final UserBackendService _userService; + final _billingPortalCompleter = + Completer>(); + + BillingPortalPB? _billingPortal; + late final SubscriptionSuccessListenable _successListenable; + + @override + Future close() { + _successListenable.removeListener(_onPaymentSuccessful); + return super.close(); + } + + Future _fetchBillingPortal() async { + final billingPortalResult = await _service.getBillingPortal(); + _billingPortalCompleter.complete(billingPortalResult); + } + + Future _onPaymentSuccessful() async => add( + SettingsBillingEvent.paymentSuccessful( + plan: _successListenable.subscribedPlan, + ), + ); +} + +@freezed +class SettingsBillingEvent with _$SettingsBillingEvent { + const factory SettingsBillingEvent.started() = _Started; + + const factory SettingsBillingEvent.billingPortalFetched({ + required BillingPortalPB billingPortal, + }) = _BillingPortalFetched; + + const factory SettingsBillingEvent.openCustomerPortal() = _OpenCustomerPortal; + + const factory SettingsBillingEvent.addSubscription(SubscriptionPlanPB plan) = + _AddSubscription; + + const factory SettingsBillingEvent.cancelSubscription( + SubscriptionPlanPB plan, { + @Default(null) String? reason, + }) = _CancelSubscription; + + const factory SettingsBillingEvent.paymentSuccessful({ + SubscriptionPlanPB? plan, + }) = _PaymentSuccessful; + + const factory SettingsBillingEvent.updatePeriod({ + required SubscriptionPlanPB plan, + required RecurringIntervalPB interval, + }) = _UpdatePeriod; +} + +@freezed +class SettingsBillingState extends Equatable with _$SettingsBillingState { + const SettingsBillingState._(); + + const factory SettingsBillingState.initial() = _Initial; + + const factory SettingsBillingState.loading() = _Loading; + + const factory SettingsBillingState.error({ + @Default(null) FlowyError? error, + }) = _Error; + + const factory SettingsBillingState.ready({ + required WorkspaceSubscriptionInfoPB subscriptionInfo, + required BillingPortalPB? billingPortal, + @Default(null) SubscriptionPlanPB? successfulPlanUpgrade, + @Default(false) bool isLoading, + }) = _Ready; + + @override + List get props => maybeWhen( + orElse: () => const [], + error: (error) => [error], + ready: (subscription, billingPortal, plan, isLoading) => [ + subscription, + billingPortal, + plan, + isLoading, + ...subscription.addOns, + ], + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_bloc.dart new file mode 100644 index 0000000000000..9845b86d99591 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_bloc.dart @@ -0,0 +1,39 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'cloud_setting_bloc.freezed.dart'; + +class CloudSettingBloc extends Bloc { + CloudSettingBloc(AuthenticatorType cloudType) + : super(CloudSettingState.initial(cloudType)) { + on((event, emit) async { + await event.when( + initial: () async {}, + updateCloudType: (AuthenticatorType newCloudType) async { + emit(state.copyWith(cloudType: newCloudType)); + }, + ); + }); + } +} + +@freezed +class CloudSettingEvent with _$CloudSettingEvent { + const factory CloudSettingEvent.initial() = _Initial; + const factory CloudSettingEvent.updateCloudType( + AuthenticatorType newCloudType, + ) = _UpdateCloudType; +} + +@freezed +class CloudSettingState with _$CloudSettingState { + const factory CloudSettingState({ + required AuthenticatorType cloudType, + }) = _CloudSettingState; + + factory CloudSettingState.initial(AuthenticatorType cloudType) => + CloudSettingState( + cloudType: cloudType, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_listener.dart new file mode 100644 index 0000000000000..b6007847b3071 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_listener.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +import '../../../core/notification/user_notification.dart'; + +class UserCloudConfigListener { + UserCloudConfigListener(); + + UserNotificationParser? _userParser; + StreamSubscription? _subscription; + void Function(FlowyResult)? _onSettingChanged; + + void start({ + void Function(FlowyResult)? onSettingChanged, + }) { + _onSettingChanged = onSettingChanged; + _userParser = UserNotificationParser( + id: 'user_cloud_config', + callback: _userNotificationCallback, + ); + _subscription = RustStreamReceiver.listen((observable) { + _userParser?.parse(observable); + }); + } + + Future stop() async { + _userParser = null; + await _subscription?.cancel(); + _onSettingChanged = null; + } + + void _userNotificationCallback( + UserNotification ty, + FlowyResult result, + ) { + switch (ty) { + case UserNotification.DidUpdateCloudConfig: + result.fold( + (payload) => _onSettingChanged + ?.call(FlowyResult.success(CloudSettingPB.fromBuffer(payload))), + (error) => _onSettingChanged?.call(FlowyResult.failure(error)), + ); + break; + default: + break; + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/create_file_settings_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/create_file_settings_cubit.dart new file mode 100644 index 0000000000000..7ff1ed5fedd54 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/create_file_settings_cubit.dart @@ -0,0 +1,26 @@ +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class CreateFileSettingsCubit extends Cubit { + CreateFileSettingsCubit(super.initialState) { + getInitialSettings(); + } + + Future toggle({bool? value}) async { + await getIt().set( + KVKeys.showRenameDialogWhenCreatingNewFile, + (value ?? !state).toString(), + ); + emit(value ?? !state); + } + + Future getInitialSettings() async { + final settingsOrFailure = await getIt().getWithFormat( + KVKeys.showRenameDialogWhenCreatingNewFile, + (value) => bool.parse(value), + ); + emit(settingsOrFailure ?? false); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart new file mode 100644 index 0000000000000..8d2a65c0298bc --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/date_format_ext.dart @@ -0,0 +1,41 @@ +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; + +const _localFmt = 'MM/dd/y'; +const _usFmt = 'y/MM/dd'; +const _isoFmt = 'y-MM-dd'; +const _friendlyFmt = 'MMM dd, y'; +const _dmyFmt = 'dd/MM/y'; + +extension DateFormatter on UserDateFormatPB { + DateFormat get toFormat => DateFormat(_toFormat[this] ?? _friendlyFmt); + + String formatDate( + DateTime date, + bool includeTime, [ + UserTimeFormatPB? timeFormat, + ]) { + final format = toFormat; + + if (includeTime) { + switch (timeFormat) { + case UserTimeFormatPB.TwentyFourHour: + return format.add_Hm().format(date); + case UserTimeFormatPB.TwelveHour: + return format.add_jm().format(date); + default: + return format.format(date); + } + } + + return format.format(date); + } +} + +final _toFormat = { + UserDateFormatPB.Locally: _localFmt, + UserDateFormatPB.US: _usFmt, + UserDateFormatPB.ISO: _isoFmt, + UserDateFormatPB.Friendly: _friendlyFmt, + UserDateFormatPB.DayMonthYear: _dmyFmt, +}; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_format_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_format_ext.dart new file mode 100644 index 0000000000000..0dfa2807b9ac3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_format_ext.dart @@ -0,0 +1,13 @@ +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; + +extension TimeFormatter on UserTimeFormatPB { + DateFormat get toFormat => _toFormat[this]!; + + String formatTime(DateTime date) => toFormat.format(date); +} + +final _toFormat = { + UserTimeFormatPB.TwentyFourHour: DateFormat.Hm(), + UserTimeFormatPB.TwelveHour: DateFormat.jm(), +}; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/file_storage/file_storage_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/file_storage/file_storage_listener.dart new file mode 100644 index 0000000000000..58560bae0325c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/file_storage/file_storage_listener.dart @@ -0,0 +1,68 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/notification_helper.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-storage/notification.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class StoregeNotificationParser + extends NotificationParser { + StoregeNotificationParser({ + super.id, + required super.callback, + }) : super( + tyParser: (ty, source) => + source == "storage" ? StorageNotification.valueOf(ty) : null, + errorParser: (bytes) => FlowyError.fromBuffer(bytes), + ); +} + +class StoreageNotificationListener { + StoreageNotificationListener({ + void Function(FlowyError error)? onError, + }) : _parser = StoregeNotificationParser( + callback: ( + StorageNotification ty, + FlowyResult result, + ) { + result.fold( + (data) { + try { + switch (ty) { + case StorageNotification.FileStorageLimitExceeded: + onError?.call(FlowyError.fromBuffer(data)); + break; + case StorageNotification.SingleFileLimitExceeded: + onError?.call(FlowyError.fromBuffer(data)); + break; + } + } catch (e) { + Log.error( + "$StoreageNotificationListener deserialize PB fail", + e, + ); + } + }, + (err) { + Log.error("Error in StoreageNotificationListener", err); + }, + ); + }, + ) { + _subscription = + RustStreamReceiver.listen((observable) => _parser?.parse(observable)); + } + + StoregeNotificationParser? _parser; + StreamSubscription? _subscription; + + Future stop() async { + _parser = null; + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/notifications/notification_settings_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/notifications/notification_settings_cubit.dart new file mode 100644 index 0000000000000..ea6b3b6f0153e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/notifications/notification_settings_cubit.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/user_settings_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'notification_settings_cubit.freezed.dart'; + +class NotificationSettingsCubit extends Cubit { + NotificationSettingsCubit() : super(NotificationSettingsState.initial()) { + _initialize(); + } + + final Completer _initCompleter = Completer(); + + late final NotificationSettingsPB _notificationSettings; + + Future _initialize() async { + _notificationSettings = + await UserSettingsBackendService().getNotificationSettings(); + + final showNotificationSetting = await getIt() + .getWithFormat(KVKeys.showNotificationIcon, (v) => bool.parse(v)); + + emit( + state.copyWith( + isNotificationsEnabled: _notificationSettings.notificationsEnabled, + isShowNotificationsIconEnabled: showNotificationSetting ?? true, + ), + ); + + _initCompleter.complete(); + } + + Future toggleNotificationsEnabled() async { + await _initCompleter.future; + + _notificationSettings.notificationsEnabled = !state.isNotificationsEnabled; + + emit( + state.copyWith( + isNotificationsEnabled: _notificationSettings.notificationsEnabled, + ), + ); + + await _saveNotificationSettings(); + } + + Future toogleShowNotificationIconEnabled() async { + await _initCompleter.future; + + emit( + state.copyWith( + isShowNotificationsIconEnabled: !state.isShowNotificationsIconEnabled, + ), + ); + } + + Future _saveNotificationSettings() async { + await _initCompleter.future; + + await getIt().set( + KVKeys.showNotificationIcon, + state.isShowNotificationsIconEnabled.toString(), + ); + + final result = await UserSettingsBackendService() + .setNotificationSettings(_notificationSettings); + result.fold( + (r) => null, + (error) => Log.error(error), + ); + } +} + +@freezed +class NotificationSettingsState with _$NotificationSettingsState { + const NotificationSettingsState._(); + + const factory NotificationSettingsState({ + required bool isNotificationsEnabled, + required bool isShowNotificationsIconEnabled, + }) = _NotificationSettingsState; + + factory NotificationSettingsState.initial() => + const NotificationSettingsState( + isNotificationsEnabled: true, + isShowNotificationsIconEnabled: true, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart new file mode 100644 index 0000000000000..f7512a834e7f0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/settings_plan_bloc.dart @@ -0,0 +1,235 @@ +import 'package:flutter/foundation.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; +import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart'; +import 'package:bloc/bloc.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'settings_plan_bloc.freezed.dart'; + +class SettingsPlanBloc extends Bloc { + SettingsPlanBloc({ + required this.workspaceId, + required Int64 userId, + }) : super(const _Initial()) { + _service = WorkspaceService(workspaceId: workspaceId); + _userService = UserBackendService(userId: userId); + _successListenable = getIt(); + _successListenable.addListener(_onPaymentSuccessful); + + on((event, emit) async { + await event.when( + started: (withSuccessfulUpgrade, shouldLoad) async { + if (shouldLoad) { + emit(const SettingsPlanState.loading()); + } + + final snapshots = await Future.wait([ + _service.getWorkspaceUsage(), + UserBackendService.getWorkspaceSubscriptionInfo(workspaceId), + ]); + + FlowyError? error; + + final usageResult = snapshots.first.fold( + (s) => s as WorkspaceUsagePB, + (f) { + error = f; + return null; + }, + ); + + final subscriptionInfo = snapshots[1].fold( + (s) => s as WorkspaceSubscriptionInfoPB, + (f) { + error = f; + return null; + }, + ); + + if (usageResult == null || + subscriptionInfo == null || + error != null) { + return emit(SettingsPlanState.error(error: error)); + } + + emit( + SettingsPlanState.ready( + workspaceUsage: usageResult, + subscriptionInfo: subscriptionInfo, + successfulPlanUpgrade: withSuccessfulUpgrade, + ), + ); + + if (withSuccessfulUpgrade != null) { + emit( + SettingsPlanState.ready( + workspaceUsage: usageResult, + subscriptionInfo: subscriptionInfo, + ), + ); + } + }, + addSubscription: (plan) async { + final result = await _userService.createSubscription( + workspaceId, + plan, + ); + + result.fold( + (pl) => afLaunchUrlString(pl.paymentLink), + (f) => Log.error( + 'Failed to fetch paymentlink for $plan: ${f.msg}', + f, + ), + ); + }, + cancelSubscription: (reason) async { + final newState = state + .mapOrNull(ready: (state) => state) + ?.copyWith(downgradeProcessing: true); + emit(newState ?? state); + + // We can hardcode the subscription plan here because we cannot cancel addons + // on the Plan page + final result = await _userService.cancelSubscription( + workspaceId, + SubscriptionPlanPB.Pro, + reason, + ); + + final successOrNull = result.fold( + (_) => true, + (f) { + Log.error('Failed to cancel subscription of Pro: ${f.msg}', f); + return null; + }, + ); + + if (successOrNull != true) { + return; + } + + final subscriptionInfo = state.mapOrNull( + ready: (s) => s.subscriptionInfo, + ); + + // This is impossible, but for good measure + if (subscriptionInfo == null) { + return; + } + + // We assume their new plan is Free, since we only have Pro plan + // at the moment. + subscriptionInfo.freeze(); + final newInfo = subscriptionInfo.rebuild((value) { + value.plan = WorkspacePlanPB.FreePlan; + value.planSubscription.freeze(); + value.planSubscription = value.planSubscription.rebuild((sub) { + sub.status = WorkspaceSubscriptionStatusPB.Active; + sub.subscriptionPlan = SubscriptionPlanPB.Free; + }); + }); + + // We need to remove unlimited indicator for storage and + // AI usage, if they don't have an addon that changes this behavior. + final usage = state.mapOrNull(ready: (s) => s.workspaceUsage)!; + + usage.freeze(); + final newUsage = usage.rebuild((value) { + if (!newInfo.hasAIMax && !newInfo.hasAIOnDevice) { + value.aiResponsesUnlimited = false; + } + + value.storageBytesUnlimited = false; + }); + + emit( + SettingsPlanState.ready( + subscriptionInfo: newInfo, + workspaceUsage: newUsage, + ), + ); + }, + paymentSuccessful: (plan) { + final readyState = state.mapOrNull(ready: (state) => state); + if (readyState == null) { + return; + } + + add( + SettingsPlanEvent.started( + withSuccessfulUpgrade: plan, + shouldLoad: false, + ), + ); + }, + ); + }); + } + + late final String workspaceId; + late final WorkspaceService _service; + late final IUserBackendService _userService; + late final SubscriptionSuccessListenable _successListenable; + + Future _onPaymentSuccessful() async => add( + SettingsPlanEvent.paymentSuccessful( + plan: _successListenable.subscribedPlan, + ), + ); + + @override + Future close() async { + _successListenable.removeListener(_onPaymentSuccessful); + return super.close(); + } +} + +@freezed +class SettingsPlanEvent with _$SettingsPlanEvent { + const factory SettingsPlanEvent.started({ + @Default(null) SubscriptionPlanPB? withSuccessfulUpgrade, + @Default(true) bool shouldLoad, + }) = _Started; + + const factory SettingsPlanEvent.addSubscription(SubscriptionPlanPB plan) = + _AddSubscription; + + const factory SettingsPlanEvent.cancelSubscription({ + @Default(null) String? reason, + }) = _CancelSubscription; + + const factory SettingsPlanEvent.paymentSuccessful({ + @Default(null) SubscriptionPlanPB? plan, + }) = _PaymentSuccessful; +} + +@freezed +class SettingsPlanState with _$SettingsPlanState { + const factory SettingsPlanState.initial() = _Initial; + + const factory SettingsPlanState.loading() = _Loading; + + const factory SettingsPlanState.error({ + @Default(null) FlowyError? error, + }) = _Error; + + const factory SettingsPlanState.ready({ + required WorkspaceUsagePB workspaceUsage, + required WorkspaceSubscriptionInfoPB subscriptionInfo, + @Default(null) SubscriptionPlanPB? successfulPlanUpgrade, + @Default(false) bool downgradeProcessing, + }) = _Ready; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart new file mode 100644 index 0000000000000..9d91ade4d3291 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_subscription_ext.dart @@ -0,0 +1,123 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbserver.dart'; +import 'package:easy_localization/easy_localization.dart'; + +extension SubscriptionInfoHelpers on WorkspaceSubscriptionInfoPB { + String get label => switch (plan) { + WorkspacePlanPB.FreePlan => + LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(), + WorkspacePlanPB.ProPlan => + LocaleKeys.settings_planPage_planUsage_currentPlan_proTitle.tr(), + WorkspacePlanPB.TeamPlan => + LocaleKeys.settings_planPage_planUsage_currentPlan_teamTitle.tr(), + _ => 'N/A', + }; + + String get info => switch (plan) { + WorkspacePlanPB.FreePlan => + LocaleKeys.settings_planPage_planUsage_currentPlan_freeInfo.tr(), + WorkspacePlanPB.ProPlan => + LocaleKeys.settings_planPage_planUsage_currentPlan_proInfo.tr(), + WorkspacePlanPB.TeamPlan => + LocaleKeys.settings_planPage_planUsage_currentPlan_teamInfo.tr(), + _ => 'N/A', + }; + + bool get isBillingPortalEnabled { + if (plan != WorkspacePlanPB.FreePlan || addOns.isNotEmpty) { + return true; + } + + return false; + } +} + +extension AllSubscriptionLabels on SubscriptionPlanPB { + String get label => switch (this) { + SubscriptionPlanPB.Free => + LocaleKeys.settings_planPage_planUsage_currentPlan_freeTitle.tr(), + SubscriptionPlanPB.Pro => + LocaleKeys.settings_planPage_planUsage_currentPlan_proTitle.tr(), + SubscriptionPlanPB.Team => + LocaleKeys.settings_planPage_planUsage_currentPlan_teamTitle.tr(), + SubscriptionPlanPB.AiMax => + LocaleKeys.settings_billingPage_addons_aiMax_label.tr(), + SubscriptionPlanPB.AiLocal => + LocaleKeys.settings_billingPage_addons_aiOnDevice_label.tr(), + _ => 'N/A', + }; +} + +extension WorkspaceSubscriptionStatusExt on WorkspaceSubscriptionInfoPB { + bool get isCanceled => + planSubscription.status == WorkspaceSubscriptionStatusPB.Canceled; +} + +extension WorkspaceAddonsExt on WorkspaceSubscriptionInfoPB { + bool get hasAIMax => + addOns.any((addon) => addon.type == WorkspaceAddOnPBType.AddOnAiMax); + + bool get hasAIOnDevice => + addOns.any((addon) => addon.type == WorkspaceAddOnPBType.AddOnAiLocal); +} + +/// These have to match [SubscriptionSuccessListenable.subscribedPlan] labels +extension ToRecognizable on SubscriptionPlanPB { + String? toRecognizable() => switch (this) { + SubscriptionPlanPB.Free => 'free', + SubscriptionPlanPB.Pro => 'pro', + SubscriptionPlanPB.Team => 'team', + SubscriptionPlanPB.AiMax => 'ai_max', + SubscriptionPlanPB.AiLocal => 'ai_local', + _ => null, + }; +} + +extension PlanHelper on SubscriptionPlanPB { + /// Returns true if the plan is an add-on and not + /// a workspace plan. + /// + bool get isAddOn => switch (this) { + SubscriptionPlanPB.AiMax => true, + SubscriptionPlanPB.AiLocal => true, + _ => false, + }; + + String get priceMonthBilling => switch (this) { + SubscriptionPlanPB.Free => 'US\$0', + SubscriptionPlanPB.Pro => 'US\$12.5', + SubscriptionPlanPB.Team => 'US\$15', + SubscriptionPlanPB.AiMax => 'US\$10', + SubscriptionPlanPB.AiLocal => 'US\$10', + _ => 'US\$0', + }; + + String get priceAnnualBilling => switch (this) { + SubscriptionPlanPB.Free => 'US\$0', + SubscriptionPlanPB.Pro => 'US\$10', + SubscriptionPlanPB.Team => 'US\$12.5', + SubscriptionPlanPB.AiMax => 'US\$8', + SubscriptionPlanPB.AiLocal => 'US\$8', + _ => 'US\$0', + }; +} + +extension IntervalLabel on RecurringIntervalPB { + String get label => switch (this) { + RecurringIntervalPB.Month => + LocaleKeys.settings_billingPage_monthlyInterval.tr(), + RecurringIntervalPB.Year => + LocaleKeys.settings_billingPage_annualInterval.tr(), + _ => LocaleKeys.settings_billingPage_monthlyInterval.tr(), + }; + + String get priceInfo => switch (this) { + RecurringIntervalPB.Month => + LocaleKeys.settings_billingPage_monthlyPriceInfo.tr(), + RecurringIntervalPB.Year => + LocaleKeys.settings_billingPage_annualPriceInfo.tr(), + _ => LocaleKeys.settings_billingPage_monthlyPriceInfo.tr(), + }; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart new file mode 100644 index 0000000000000..ddaca15f5cc93 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/plan/workspace_usage_ext.dart @@ -0,0 +1,25 @@ +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:intl/intl.dart'; + +final _storageNumberFormat = NumberFormat() + ..maximumFractionDigits = 2 + ..minimumFractionDigits = 0; + +extension PresentableUsage on WorkspaceUsagePB { + String get totalBlobInGb { + if (storageBytesLimit == 0) { + return '0'; + } + return _storageNumberFormat + .format(storageBytesLimit.toInt() / (1024 * 1024 * 1024)); + } + + /// We use [NumberFormat] to format the current blob in GB. + /// + /// Where the [totalBlobBytes] is the total blob bytes in bytes. + /// And [NumberFormat.maximumFractionDigits] is set to 2. + /// And [NumberFormat.minimumFractionDigits] is set to 0. + /// + String get currentBlobInGb => + _storageNumberFormat.format(storageBytes.toInt() / 1024 / 1024 / 1024); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/prelude.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/prelude.dart new file mode 100644 index 0000000000000..926ff91788b8f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/prelude.dart @@ -0,0 +1,3 @@ +export 'application_data_storage.dart'; +export 'create_file_settings_cubit.dart'; +export 'settings_dialog_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/setting_file_importer_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/setting_file_importer_bloc.dart new file mode 100644 index 0000000000000..7a1d3efc451c9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/setting_file_importer_bloc.dart @@ -0,0 +1,93 @@ +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/plugins/database/application/defines.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/import_data.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'setting_file_importer_bloc.freezed.dart'; + +class SettingFileImportBloc + extends Bloc { + SettingFileImportBloc() : super(SettingFileImportState.initial()) { + on( + (event, emit) async { + await event.when( + importAppFlowyDataFolder: (String path) async { + final formattedDate = + DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()); + final spaceId = + await getIt().get(KVKeys.lastOpenedSpaceId); + + final payload = ImportAppFlowyDataPB.create() + ..path = path + ..importContainerName = "import_$formattedDate"; + + if (spaceId != null) { + payload.parentViewId = spaceId; + } + + emit( + state.copyWith(loadingState: const LoadingState.loading()), + ); + final result = + await UserEventImportAppFlowyDataFolder(payload).send(); + if (!isClosed) { + add(SettingFileImportEvent.finishImport(result)); + } + }, + finishImport: (result) { + result.fold( + (l) { + emit( + state.copyWith( + successOrFail: FlowyResult.success(null), + loadingState: + LoadingState.finish(FlowyResult.success(null)), + ), + ); + }, + (err) { + Log.error(err); + emit( + state.copyWith( + successOrFail: FlowyResult.failure(err), + loadingState: LoadingState.finish(FlowyResult.failure(err)), + ), + ); + }, + ); + }, + ); + }, + ); + } +} + +@freezed +class SettingFileImportEvent with _$SettingFileImportEvent { + const factory SettingFileImportEvent.importAppFlowyDataFolder(String path) = + _ImportAppFlowyDataFolder; + const factory SettingFileImportEvent.finishImport( + FlowyResult result, + ) = _ImportResult; +} + +@freezed +class SettingFileImportState with _$SettingFileImportState { + const factory SettingFileImportState({ + required LoadingState loadingState, + required FlowyResult? successOrFail, + }) = _SettingFileImportState; + + factory SettingFileImportState.initial() => const SettingFileImportState( + loadingState: LoadingState.idle(), + successOrFail: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart new file mode 100644 index 0000000000000..0578d9808b048 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -0,0 +1,153 @@ +import 'package:appflowy/user/application/user_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'settings_dialog_bloc.freezed.dart'; + +enum SettingsPage { + // NEW + account, + workspace, + manageData, + shortcuts, + ai, + plan, + billing, + sites, + // OLD + notifications, + cloud, + member, + featureFlags, +} + +class SettingsDialogBloc + extends Bloc { + SettingsDialogBloc( + this.userProfile, + this.currentWorkspaceMemberRole, { + SettingsPage? initPage, + }) : _userListener = UserListener(userProfile: userProfile), + super(SettingsDialogState.initial(userProfile, initPage)) { + _dispatch(); + } + + final UserProfilePB userProfile; + final AFRolePB? currentWorkspaceMemberRole; + final UserListener _userListener; + + @override + Future close() async { + await _userListener.stop(); + await super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () async { + _userListener.start(onProfileUpdated: _profileUpdated); + + final isBillingEnabled = await _isBillingEnabled( + userProfile, + currentWorkspaceMemberRole, + ); + if (isBillingEnabled) { + emit(state.copyWith(isBillingEnabled: true)); + } + }, + didReceiveUserProfile: (UserProfilePB newUserProfile) { + emit(state.copyWith(userProfile: newUserProfile)); + }, + setSelectedPage: (SettingsPage page) { + emit(state.copyWith(page: page)); + }, + ); + }, + ); + } + + void _profileUpdated( + FlowyResult userProfileOrFailed, + ) { + userProfileOrFailed.fold( + (newUserProfile) => + add(SettingsDialogEvent.didReceiveUserProfile(newUserProfile)), + (err) => Log.error(err), + ); + } + + Future _isBillingEnabled( + UserProfilePB userProfile, [ + AFRolePB? currentWorkspaceMemberRole, + ]) async { + if ([ + AuthenticatorPB.Local, + ].contains(userProfile.authenticator)) { + return false; + } + + if (currentWorkspaceMemberRole == null || + currentWorkspaceMemberRole != AFRolePB.Owner) { + return false; + } + + if (kDebugMode) { + return true; + } + + final result = await UserEventGetCloudConfig().send(); + return result.fold( + (cloudSetting) { + final whiteList = [ + "https://beta.appflowy.cloud", + "https://test.appflowy.cloud", + ]; + + return whiteList.contains(cloudSetting.serverUrl); + }, + (err) { + Log.error("Failed to get cloud config: $err"); + return false; + }, + ); + } +} + +@freezed +class SettingsDialogEvent with _$SettingsDialogEvent { + const factory SettingsDialogEvent.initial() = _Initial; + const factory SettingsDialogEvent.didReceiveUserProfile( + UserProfilePB newUserProfile, + ) = _DidReceiveUserProfile; + const factory SettingsDialogEvent.setSelectedPage(SettingsPage page) = + _SetViewIndex; +} + +@freezed +class SettingsDialogState with _$SettingsDialogState { + const factory SettingsDialogState({ + required UserProfilePB userProfile, + required SettingsPage page, + required bool isBillingEnabled, + }) = _SettingsDialogState; + + factory SettingsDialogState.initial( + UserProfilePB userProfile, + SettingsPage? page, + ) => + SettingsDialogState( + userProfile: userProfile, + page: page ?? SettingsPage.account, + isBillingEnabled: false, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_file_exporter_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_file_exporter_cubit.dart new file mode 100644 index 0000000000000..9d141db9ac9ae --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_file_exporter_cubit.dart @@ -0,0 +1,101 @@ +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SettingsFileExportState { + SettingsFileExportState({ + required this.views, + }) { + initialize(); + } + + List get selectedViews { + final selectedViews = []; + for (var i = 0; i < views.length; i++) { + if (selectedApps[i]) { + for (var j = 0; j < views[i].childViews.length; j++) { + if (selectedItems[i][j]) { + selectedViews.add(views[i].childViews[j]); + } + } + } + } + return selectedViews; + } + + List views; + List expanded = []; + List selectedApps = []; + List> selectedItems = []; + + SettingsFileExportState copyWith({ + List? views, + List? expanded, + List? selectedApps, + List>? selectedItems, + }) { + final state = SettingsFileExportState( + views: views ?? this.views, + ); + state.expanded = expanded ?? this.expanded; + state.selectedApps = selectedApps ?? this.selectedApps; + state.selectedItems = selectedItems ?? this.selectedItems; + return state; + } + + void initialize() { + expanded = views.map((e) => true).toList(); + selectedApps = views.map((e) => true).toList(); + selectedItems = + views.map((e) => e.childViews.map((e) => true).toList()).toList(); + } +} + +class SettingsFileExporterCubit extends Cubit { + SettingsFileExporterCubit({ + required List views, + }) : super(SettingsFileExportState(views: views)); + + void selectOrDeselectAllItems() { + final List> selectedItems = state.selectedItems; + final isSelectAll = + selectedItems.expand((element) => element).every((element) => element); + for (var i = 0; i < selectedItems.length; i++) { + for (var j = 0; j < selectedItems[i].length; j++) { + selectedItems[i][j] = !isSelectAll; + } + } + emit(state.copyWith(selectedItems: selectedItems)); + } + + void selectOrDeselectItem(int outerIndex, int innerIndex) { + final selectedItems = state.selectedItems; + selectedItems[outerIndex][innerIndex] = + !selectedItems[outerIndex][innerIndex]; + emit(state.copyWith(selectedItems: selectedItems)); + } + + void expandOrUnexpandApp(int outerIndex) { + final expanded = state.expanded; + expanded[outerIndex] = !expanded[outerIndex]; + emit(state.copyWith(expanded: expanded)); + } + + Map> fetchSelectedPages() { + final views = state.views; + final selectedItems = state.selectedItems; + final Map> result = {}; + for (var i = 0; i < selectedItems.length; i++) { + final selectedItem = selectedItems[i]; + final ids = []; + for (var j = 0; j < selectedItem.length; j++) { + if (selectedItem[j]) { + ids.add(views[i].childViews[j].id); + } + } + if (ids.isNotEmpty) { + result[views[i].id] = ids; + } + } + return result; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_location_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_location_cubit.dart new file mode 100644 index 0000000000000..696bb9c348e91 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_location_cubit.dart @@ -0,0 +1,46 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../startup/tasks/prelude.dart'; + +part 'settings_location_cubit.freezed.dart'; + +@freezed +class SettingsLocationState with _$SettingsLocationState { + const factory SettingsLocationState.initial() = _Initial; + const factory SettingsLocationState.didReceivedPath(String path) = + _DidReceivedPath; +} + +class SettingsLocationCubit extends Cubit { + SettingsLocationCubit() : super(const SettingsLocationState.initial()) { + _init(); + } + + Future resetDataStoragePathToApplicationDefault() async { + final directory = await appFlowyApplicationDataDirectory(); + await getIt().setPath(directory.path); + emit(SettingsLocationState.didReceivedPath(directory.path)); + } + + Future setCustomPath(String path) async { + await getIt().setCustomPath(path); + emit(SettingsLocationState.didReceivedPath(path)); + } + + Future _init() async { + // The backend might change the real path that storge the data. So it needs + // to get the path from the backend instead of the KeyValueStorage + await UserEventGetUserSetting().send().then((result) { + result.fold( + (l) => emit(SettingsLocationState.didReceivedPath(l.userFolder)), + (r) => Log.error(r), + ); + }); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/share/export_service.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/share/export_service.dart new file mode 100644 index 0000000000000..e890959949688 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/share/export_service.dart @@ -0,0 +1,23 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/share_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class BackendExportService { + static Future> + exportDatabaseAsCSV( + String viewId, + ) async { + final payload = DatabaseViewIdPB.create()..value = viewId; + return DatabaseEventExportCSV(payload).send(); + } + + static Future> + exportDatabaseAsRawData( + String viewId, + ) async { + final payload = DatabaseViewIdPB.create()..value = viewId; + return DatabaseEventExportRawDatabaseData(payload).send(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/share/import_service.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/share/import_service.dart new file mode 100644 index 0000000000000..205a61f7e3b64 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/share/import_service.dart @@ -0,0 +1,42 @@ +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class ImportPayload { + ImportPayload({ + required this.name, + required this.data, + required this.layout, + }); + + final String name; + final List data; + final ViewLayoutPB layout; +} + +class ImportBackendService { + static Future> importPages( + String parentViewId, + List values, + ) async { + final request = ImportPayloadPB( + parentViewId: parentViewId, + items: values, + ); + + return FolderEventImportData(request).send(); + } + + static Future> importZipFiles( + List values, + ) async { + for (final value in values) { + final result = await FolderEventImportZipFile(value).send(); + if (result.isFailure) { + return result; + } + } + return FlowyResult.success(null); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart new file mode 100644 index 0000000000000..569b4a4ea4ff6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart @@ -0,0 +1,141 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'settings_shortcuts_cubit.freezed.dart'; + +@freezed +class ShortcutsState with _$ShortcutsState { + const factory ShortcutsState({ + @Default([]) + List commandShortcutEvents, + @Default(ShortcutsStatus.initial) ShortcutsStatus status, + @Default('') String error, + }) = _ShortcutsState; +} + +enum ShortcutsStatus { + initial, + updating, + success, + failure; + + /// Helper getter for when the [ShortcutsStatus] signifies + /// that the shortcuts have not been loaded yet. + /// + bool get isLoading => [initial, updating].contains(this); + + /// Helper getter for when the [ShortcutsStatus] signifies + /// a failure by itself being [ShortcutsStatus.failure] + /// + bool get isFailure => this == ShortcutsStatus.failure; + + /// Helper getter for when the [ShortcutsStatus] signifies + /// a success by itself being [ShortcutsStatus.success] + /// + bool get isSuccess => this == ShortcutsStatus.success; +} + +class ShortcutsCubit extends Cubit { + ShortcutsCubit(this.service) : super(const ShortcutsState()); + + final SettingsShortcutService service; + + Future fetchShortcuts() async { + emit( + state.copyWith( + status: ShortcutsStatus.updating, + error: '', + ), + ); + + try { + final customizeShortcuts = await service.getCustomizeShortcuts(); + await service.updateCommandShortcuts( + commandShortcutEvents, + customizeShortcuts, + ); + + //sort the shortcuts + commandShortcutEvents.sort( + (a, b) => a.key.toLowerCase().compareTo(b.key.toLowerCase()), + ); + + emit( + state.copyWith( + status: ShortcutsStatus.success, + commandShortcutEvents: commandShortcutEvents, + error: '', + ), + ); + } catch (e) { + emit( + state.copyWith( + status: ShortcutsStatus.failure, + error: LocaleKeys.settings_shortcutsPage_couldNotLoadErrorMsg.tr(), + ), + ); + } + } + + Future updateAllShortcuts() async { + emit(state.copyWith(status: ShortcutsStatus.updating, error: '')); + + try { + await service.saveAllShortcuts(state.commandShortcutEvents); + emit(state.copyWith(status: ShortcutsStatus.success, error: '')); + } catch (e) { + emit( + state.copyWith( + status: ShortcutsStatus.failure, + error: LocaleKeys.settings_shortcutsPage_couldNotSaveErrorMsg.tr(), + ), + ); + } + } + + Future resetToDefault() async { + emit(state.copyWith(status: ShortcutsStatus.updating, error: '')); + + try { + await service.saveAllShortcuts(defaultCommandShortcutEvents); + await fetchShortcuts(); + } catch (e) { + emit( + state.copyWith( + status: ShortcutsStatus.failure, + error: LocaleKeys.settings_shortcutsPage_couldNotSaveErrorMsg.tr(), + ), + ); + } + } + + /// Checks if the new command is conflicting with other shortcut + /// We also check using the key, whether this command is a codeblock + /// shortcut, if so we only check a conflict with other codeblock shortcut. + CommandShortcutEvent? getConflict( + CommandShortcutEvent currentShortcut, + String command, + ) { + // check if currentShortcut is a codeblock shortcut. + final isCodeBlockCommand = currentShortcut.isCodeBlockCommand; + + for (final shortcut in state.commandShortcutEvents) { + final keybindings = shortcut.command.split(','); + if (keybindings.contains(command) && + shortcut.isCodeBlockCommand == isCodeBlockCommand) { + return shortcut; + } + } + + return null; + } +} + +extension on CommandShortcutEvent { + bool get isCodeBlockCommand => localizedCodeBlockCommands.contains(this); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart new file mode 100644 index 0000000000000..25b51c9a81499 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_service.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as p; + +import 'shortcuts_model.dart'; + +class SettingsShortcutService { + /// If file is non null then the SettingsShortcutService uses that + /// file to store all the shortcuts, otherwise uses the default + /// Document Directory. + /// Typically we only intend to pass a file during testing. + SettingsShortcutService({ + File? file, + }) { + _initializeService(file); + } + + late final File _file; + final _initCompleter = Completer(); + + /// Takes in commandShortcuts as an input and saves them to the shortcuts.JSON file. + Future saveAllShortcuts( + List commandShortcuts, + ) async { + final shortcuts = EditorShortcuts( + commandShortcuts: commandShortcuts.toCommandShortcutModelList(), + ); + + await _file.writeAsString( + jsonEncode(shortcuts.toJson()), + flush: true, + ); + } + + /// Checks the file for saved shortcuts. If shortcuts do NOT exist then returns + /// an empty list. If shortcuts exist + /// then calls an utility method i.e getShortcutsFromJson which returns the saved shortcuts. + Future> getCustomizeShortcuts() async { + await _initCompleter.future; + final shortcutsInJson = await _file.readAsString(); + + if (shortcutsInJson.isEmpty) { + return []; + } else { + return getShortcutsFromJson(shortcutsInJson); + } + } + + /// Extracts shortcuts from the saved json file. The shortcuts in the saved file consist of [List]. + /// This list needs to be converted to List. This function is intended to facilitate the same. + List getShortcutsFromJson(String savedJson) { + final shortcuts = EditorShortcuts.fromJson(jsonDecode(savedJson)); + return shortcuts.commandShortcuts; + } + + Future updateCommandShortcuts( + List commandShortcuts, + List customizeShortcuts, + ) async { + for (final shortcut in customizeShortcuts) { + final shortcutEvent = commandShortcuts.firstWhereOrNull( + (s) => s.key == shortcut.key && s.command != shortcut.command, + ); + shortcutEvent?.updateCommand(command: shortcut.command); + } + } + + Future resetToDefaultShortcuts() async { + await _initCompleter.future; + await saveAllShortcuts(defaultCommandShortcutEvents); + } + + // Accesses the shortcuts.json file within the default AppFlowy Document Directory or creates a new file if it already doesn't exist. + Future _initializeService(File? file) async { + _file = file ?? await _defaultShortcutFile(); + _initCompleter.complete(); + } + + //returns the default file for storing shortcuts + Future _defaultShortcutFile() async { + final path = await getIt().getPath(); + return File( + p.join(path, 'shortcuts', 'shortcuts.json'), + )..createSync(recursive: true); + } +} + +extension on List { + /// Utility method for converting a CommandShortcutEvent List to a + /// CommandShortcutModal List. This is necessary for creating shortcuts + /// object, which is used for saving the shortcuts list. + List toCommandShortcutModelList() => + map((e) => CommandShortcutModel.fromCommandEvent(e)).toList(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/shortcuts_model.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/shortcuts_model.dart new file mode 100644 index 0000000000000..93cebf83a4e60 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/shortcuts_model.dart @@ -0,0 +1,52 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +class EditorShortcuts { + factory EditorShortcuts.fromJson(Map json) => + EditorShortcuts( + commandShortcuts: List.from( + json["commandShortcuts"].map((x) => CommandShortcutModel.fromJson(x)), + ), + ); + + EditorShortcuts({required this.commandShortcuts}); + + final List commandShortcuts; + + Map toJson() => { + "commandShortcuts": + List.from(commandShortcuts.map((x) => x.toJson())), + }; +} + +class CommandShortcutModel { + factory CommandShortcutModel.fromCommandEvent( + CommandShortcutEvent commandShortcutEvent, + ) => + CommandShortcutModel( + key: commandShortcutEvent.key, + command: commandShortcutEvent.command, + ); + + factory CommandShortcutModel.fromJson(Map json) => + CommandShortcutModel( + key: json["key"], + command: json["command"] ?? '', + ); + + const CommandShortcutModel({required this.key, required this.command}); + + final String key; + final String command; + + Map toJson() => {"key": key, "command": command}; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CommandShortcutModel && + key == other.key && + command == other.command; + + @override + int get hashCode => key.hashCode ^ command.hashCode; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/workspace/workspace_settings_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/workspace/workspace_settings_bloc.dart new file mode 100644 index 0000000000000..b8081cb2d5d00 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/workspace/workspace_settings_bloc.dart @@ -0,0 +1,152 @@ +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'workspace_settings_bloc.freezed.dart'; + +class WorkspaceSettingsBloc + extends Bloc { + WorkspaceSettingsBloc() : super(WorkspaceSettingsState.initial()) { + on( + (event, emit) async { + await event.when( + initial: (userProfile, workspace) async { + _userService = UserBackendService(userId: userProfile.id); + + try { + final currentWorkspace = + await UserBackendService.getCurrentWorkspace().getOrThrow(); + + final workspaces = + await _userService!.getWorkspaces().getOrThrow(); + if (workspaces.isEmpty) { + workspaces.add( + UserWorkspacePB.create() + ..workspaceId = currentWorkspace.id + ..name = currentWorkspace.name + ..createdAtTimestamp = currentWorkspace.createTime, + ); + } + + final currentWorkspaceInList = workspaces.firstWhereOrNull( + (e) => e.workspaceId == currentWorkspace.id, + ) ?? + workspaces.firstOrNull; + + // We emit here because the next event might take longer. + emit(state.copyWith(workspace: currentWorkspaceInList)); + + if (currentWorkspaceInList == null) { + return; + } + + final members = await _getWorkspaceMembers( + currentWorkspaceInList.workspaceId, + ); + + emit( + state.copyWith( + workspace: currentWorkspaceInList, + members: members, + ), + ); + } catch (e) { + Log.error('Failed to get or create current workspace'); + } + }, + updateWorkspaceName: (name) async { + final request = RenameWorkspacePB( + workspaceId: state.workspace?.workspaceId, + newName: name, + ); + final result = await UserEventRenameWorkspace(request).send(); + + state.workspace!.freeze(); + final update = state.workspace!.rebuild((p0) => p0.name = name); + + result.fold( + (_) => emit(state.copyWith(workspace: update)), + (e) => Log.error('Failed to rename workspace: $e'), + ); + }, + updateWorkspaceIcon: (icon) async { + if (state.workspace == null) { + return null; + } + + final request = ChangeWorkspaceIconPB() + ..workspaceId = state.workspace!.workspaceId + ..newIcon = icon; + final result = await UserEventChangeWorkspaceIcon(request).send(); + + result.fold( + (_) { + state.workspace!.freeze(); + final newWorkspace = + state.workspace!.rebuild((p0) => p0.icon = icon); + + return emit(state.copyWith(workspace: newWorkspace)); + }, + (e) => Log.error('Failed to update workspace icon: $e'), + ); + }, + deleteWorkspace: () async => + emit(state.copyWith(deleteWorkspace: true)), + leaveWorkspace: () async => + emit(state.copyWith(leaveWorkspace: true)), + ); + }, + ); + } + + UserBackendService? _userService; + + Future> _getWorkspaceMembers( + String workspaceId, + ) async { + final data = QueryWorkspacePB()..workspaceId = workspaceId; + final result = await UserEventGetWorkspaceMembers(data).send(); + return result.fold( + (s) => s.items, + (e) { + Log.error('Failed to read workspace members: $e'); + return []; + }, + ); + } +} + +@freezed +class WorkspaceSettingsEvent with _$WorkspaceSettingsEvent { + const factory WorkspaceSettingsEvent.initial({ + required UserProfilePB userProfile, + @Default(null) UserWorkspacePB? workspace, + }) = Initial; + + // Workspace itself + const factory WorkspaceSettingsEvent.updateWorkspaceName(String name) = + UpdateWorkspaceName; + const factory WorkspaceSettingsEvent.updateWorkspaceIcon(String icon) = + UpdateWorkspaceIcon; + const factory WorkspaceSettingsEvent.deleteWorkspace() = DeleteWorkspace; + const factory WorkspaceSettingsEvent.leaveWorkspace() = LeaveWorkspace; +} + +@freezed +class WorkspaceSettingsState with _$WorkspaceSettingsState { + const factory WorkspaceSettingsState({ + @Default(null) UserWorkspacePB? workspace, + @Default([]) List members, + @Default(false) bool deleteWorkspace, + @Default(false) bool leaveWorkspace, + }) = _WorkspaceSettingsState; + + factory WorkspaceSettingsState.initial() => const WorkspaceSettingsState(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart new file mode 100644 index 0000000000000..17374945307cf --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart @@ -0,0 +1,237 @@ +import 'dart:async'; + +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/file_storage/file_storage_listener.dart'; +import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/dispatch/error.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sidebar_plan_bloc.freezed.dart'; + +class SidebarPlanBloc extends Bloc { + SidebarPlanBloc() : super(const SidebarPlanState()) { + // 1. Listen to user subscription payment callback. After user client 'Open AppFlowy', this listenable will be triggered. + _subscriptionListener = getIt(); + _subscriptionListener.addListener(_onPaymentSuccessful); + + // 2. Listen to the storage notification + _storageListener = StoreageNotificationListener( + onError: (error) { + if (!isClosed) { + add(SidebarPlanEvent.receiveError(error)); + } + }, + ); + + // 3. Listen to specific error codes + _globalErrorListener = GlobalErrorCodeNotifier.add( + onError: (error) { + if (!isClosed) { + add(SidebarPlanEvent.receiveError(error)); + } + }, + onErrorIf: (error) { + const relevantErrorCodes = { + ErrorCode.AIResponseLimitExceeded, + ErrorCode.FileStorageLimitExceeded, + }; + return relevantErrorCodes.contains(error.code); + }, + ); + + on(_handleEvent); + } + + void _onPaymentSuccessful() { + final plan = _subscriptionListener.subscribedPlan; + Log.info("Subscription success listenable triggered: $plan"); + + if (!isClosed) { + // Notify the user that they have switched to a new plan. It would be better if we use websocket to + // notify the client when plan switching. + if (state.workspaceId != null) { + final payload = SuccessWorkspaceSubscriptionPB( + workspaceId: state.workspaceId, + ); + + if (plan != null) { + payload.plan = plan; + } + + UserEventNotifyDidSwitchPlan(payload).send().then((result) { + result.fold( + // After the user has switched to a new plan, we need to refresh the workspace usage. + (_) => _checkWorkspaceUsage(), + (error) => Log.error("NotifyDidSwitchPlan failed: $error"), + ); + }); + } else { + Log.error( + "Unexpected empty workspace id when subscription success listenable triggered. It should not happen. If happens, it must be a bug", + ); + } + } + } + + Future dispose() async { + if (_globalErrorListener != null) { + GlobalErrorCodeNotifier.remove(_globalErrorListener!); + } + _subscriptionListener.removeListener(_onPaymentSuccessful); + await _storageListener?.stop(); + _storageListener = null; + } + + ErrorListener? _globalErrorListener; + StoreageNotificationListener? _storageListener; + late final SubscriptionSuccessListenable _subscriptionListener; + + Future _handleEvent( + SidebarPlanEvent event, + Emitter emit, + ) async { + await event.when( + receiveError: (FlowyError error) async { + if (error.code == ErrorCode.AIResponseLimitExceeded) { + emit( + state.copyWith( + tierIndicator: const SidebarToastTierIndicator.aiMaxiLimitHit(), + ), + ); + } else if (error.code == ErrorCode.FileStorageLimitExceeded) { + emit( + state.copyWith( + tierIndicator: const SidebarToastTierIndicator.storageLimitHit(), + ), + ); + } else if (error.code == ErrorCode.SingleUploadLimitExceeded) { + emit( + state.copyWith( + tierIndicator: + const SidebarToastTierIndicator.singleFileLimitHit(), + ), + ); + } else { + Log.error("Unhandle Unexpected error: $error"); + } + }, + init: (String workspaceId, UserProfilePB userProfile) { + emit( + state.copyWith( + workspaceId: workspaceId, + userProfile: userProfile, + ), + ); + + _checkWorkspaceUsage(); + }, + updateWorkspaceUsage: (WorkspaceUsagePB usage) { + // when the user's storage bytes are limited, show the upgrade tier button + if (!usage.storageBytesUnlimited) { + if (usage.storageBytes >= usage.storageBytesLimit) { + add( + const SidebarPlanEvent.updateTierIndicator( + SidebarToastTierIndicator.storageLimitHit(), + ), + ); + + /// Checks if the user needs to upgrade to the Pro Plan. + /// If the user needs to upgrade, it means they don't need to enable the AI max tier. + /// This function simply returns without performing any further actions. + return; + } + } + + // when user's AI responses are limited, show the AI max tier button. + if (!usage.aiResponsesUnlimited) { + if (usage.aiResponsesCount >= usage.aiResponsesCountLimit) { + add( + const SidebarPlanEvent.updateTierIndicator( + SidebarToastTierIndicator.aiMaxiLimitHit(), + ), + ); + return; + } + } + + // hide the tier indicator + add( + const SidebarPlanEvent.updateTierIndicator( + SidebarToastTierIndicator.loading(), + ), + ); + }, + updateTierIndicator: (SidebarToastTierIndicator indicator) { + emit( + state.copyWith( + tierIndicator: indicator, + ), + ); + }, + changedWorkspace: (workspaceId) { + emit(state.copyWith(workspaceId: workspaceId)); + _checkWorkspaceUsage(); + }, + ); + } + + void _checkWorkspaceUsage() { + if (state.workspaceId != null) { + final payload = UserWorkspaceIdPB(workspaceId: state.workspaceId!); + UserEventGetWorkspaceUsage(payload).send().then((result) { + result.onSuccess( + (usage) { + add(SidebarPlanEvent.updateWorkspaceUsage(usage)); + }, + ); + }); + } + } +} + +@freezed +class SidebarPlanEvent with _$SidebarPlanEvent { + const factory SidebarPlanEvent.init( + String workspaceId, + UserProfilePB userProfile, + ) = _Init; + const factory SidebarPlanEvent.updateWorkspaceUsage( + WorkspaceUsagePB usage, + ) = _UpdateWorkspaceUsage; + const factory SidebarPlanEvent.updateTierIndicator( + SidebarToastTierIndicator indicator, + ) = _UpdateTierIndicator; + const factory SidebarPlanEvent.receiveError(FlowyError error) = _ReceiveError; + + const factory SidebarPlanEvent.changedWorkspace({ + required String workspaceId, + }) = _ChangedWorkspace; +} + +@freezed +class SidebarPlanState with _$SidebarPlanState { + const factory SidebarPlanState({ + FlowyError? error, + UserProfilePB? userProfile, + String? workspaceId, + WorkspaceUsagePB? usage, + @Default(SidebarToastTierIndicator.loading()) + SidebarToastTierIndicator tierIndicator, + }) = _SidebarPlanState; +} + +@freezed +class SidebarToastTierIndicator with _$SidebarToastTierIndicator { + const factory SidebarToastTierIndicator.storageLimitHit() = _StorageLimitHit; + const factory SidebarToastTierIndicator.singleFileLimitHit() = + _SingleFileLimitHit; + const factory SidebarToastTierIndicator.aiMaxiLimitHit() = _aiMaxLimitHit; + const factory SidebarToastTierIndicator.loading() = _Loading; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart new file mode 100644 index 0000000000000..609b9ce0ae4f9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'folder_bloc.freezed.dart'; + +enum FolderSpaceType { + favorite, + private, + public, + unknown; + + ViewSectionPB get toViewSectionPB { + switch (this) { + case FolderSpaceType.private: + return ViewSectionPB.Private; + case FolderSpaceType.public: + return ViewSectionPB.Public; + case FolderSpaceType.favorite: + case FolderSpaceType.unknown: + throw UnimplementedError(); + } + } +} + +class FolderBloc extends Bloc { + FolderBloc({ + required FolderSpaceType type, + }) : super(FolderState.initial(type)) { + on((event, emit) async { + await event.map( + initial: (e) async { + // fetch the expand status + final isExpanded = await _getFolderExpandStatus(); + emit(state.copyWith(isExpanded: isExpanded)); + }, + expandOrUnExpand: (e) async { + final isExpanded = e.isExpanded ?? !state.isExpanded; + await _setFolderExpandStatus(isExpanded); + emit(state.copyWith(isExpanded: isExpanded)); + }, + ); + }); + } + + Future _setFolderExpandStatus(bool isExpanded) async { + final result = await getIt().get(KVKeys.expandedViews); + var map = {}; + if (result != null) { + map = jsonDecode(result); + } + if (isExpanded) { + // set expand status to true if it's not expanded + map[state.type.name] = true; + } else { + // remove the expand status if it's expanded + map.remove(state.type.name); + } + await getIt().set(KVKeys.expandedViews, jsonEncode(map)); + } + + Future _getFolderExpandStatus() async { + return getIt().get(KVKeys.expandedViews).then((result) { + if (result == null) { + return true; + } + final map = jsonDecode(result); + return map[state.type.name] ?? true; + }); + } +} + +@freezed +class FolderEvent with _$FolderEvent { + const factory FolderEvent.initial() = Initial; + const factory FolderEvent.expandOrUnExpand({ + bool? isExpanded, + }) = ExpandOrUnExpand; +} + +@freezed +class FolderState with _$FolderState { + const factory FolderState({ + required FolderSpaceType type, + required bool isExpanded, + }) = _FolderState; + + factory FolderState.initial( + FolderSpaceType type, + ) => + FolderState( + type: type, + isExpanded: true, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/rename_view/rename_view_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/rename_view/rename_view_bloc.dart new file mode 100644 index 0000000000000..de398ddf06306 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/rename_view/rename_view_bloc.dart @@ -0,0 +1,37 @@ +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'rename_view_bloc.freezed.dart'; + +class RenameViewBloc extends Bloc { + RenameViewBloc(PopoverController controller) + : _controller = controller, + super(RenameViewState(controller: controller)) { + on((event, emit) { + event.when( + open: () => _controller.show(), + ); + }); + } + + final PopoverController _controller; + + @override + Future close() async { + _controller.close(); + await super.close(); + } +} + +@freezed +class RenameViewEvent with _$RenameViewEvent { + const factory RenameViewEvent.open() = _Open; +} + +@freezed +class RenameViewState with _$RenameViewState { + const factory RenameViewState({ + required PopoverController controller, + }) = _RenameViewState; +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart new file mode 100644 index 0000000000000..0f1dc4e9872a1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart @@ -0,0 +1,820 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/shared/list_extension.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/application/workspace/prelude.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_sections_listener.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:universal_platform/universal_platform.dart'; + +part 'space_bloc.freezed.dart'; + +enum SpacePermission { + publicToAll, + private, +} + +class SidebarSection { + const SidebarSection({ + required this.publicViews, + required this.privateViews, + }); + + const SidebarSection.empty() + : publicViews = const [], + privateViews = const []; + + final List publicViews; + final List privateViews; + + List get views => publicViews + privateViews; + + SidebarSection copyWith({ + List? publicViews, + List? privateViews, + }) { + return SidebarSection( + publicViews: publicViews ?? this.publicViews, + privateViews: privateViews ?? this.privateViews, + ); + } +} + +/// The [SpaceBloc] is responsible for +/// managing the root views in different sections of the workspace. +class SpaceBloc extends Bloc { + SpaceBloc({ + required this.userProfile, + required this.workspaceId, + }) : super(SpaceState.initial()) { + on( + (event, emit) async { + await event.when( + initial: (openFirstPage) async { + this.openFirstPage = openFirstPage; + + _initial(userProfile, workspaceId); + + final (spaces, publicViews, privateViews) = await _getSpaces(); + + final shouldShowUpgradeDialog = await this.shouldShowUpgradeDialog( + spaces: spaces, + publicViews: publicViews, + privateViews: privateViews, + ); + + final currentSpace = await _getLastOpenedSpace(spaces); + final isExpanded = await _getSpaceExpandStatus(currentSpace); + emit( + state.copyWith( + spaces: spaces, + currentSpace: currentSpace, + isExpanded: isExpanded, + shouldShowUpgradeDialog: shouldShowUpgradeDialog, + isInitialized: true, + ), + ); + + if (shouldShowUpgradeDialog && !integrationMode().isTest) { + if (!isClosed) { + add(const SpaceEvent.migrate()); + } + } + + if (openFirstPage) { + if (currentSpace != null) { + if (!isClosed) { + add(SpaceEvent.open(currentSpace)); + } + } + } + }, + create: ( + name, + icon, + iconColor, + permission, + createNewPageByDefault, + openAfterCreate, + ) async { + final space = await _createSpace( + name: name, + icon: icon, + iconColor: iconColor, + permission: permission, + ); + + Log.info('create space: $space'); + + if (space != null) { + emit( + state.copyWith( + spaces: [...state.spaces, space], + currentSpace: space, + ), + ); + add(SpaceEvent.open(space)); + Log.info('open space: ${space.name}(${space.id})'); + + if (createNewPageByDefault) { + add( + SpaceEvent.createPage( + name: '', + index: 0, + layout: ViewLayoutPB.Document, + openAfterCreate: openAfterCreate, + ), + ); + Log.info('create page: ${space.name}(${space.id})'); + } + } + }, + delete: (space) async { + if (state.spaces.length <= 1) { + return; + } + + final deletedSpace = space ?? state.currentSpace; + if (deletedSpace == null) { + return; + } + + await ViewBackendService.deleteView(viewId: deletedSpace.id); + + Log.info('delete space: ${deletedSpace.name}(${deletedSpace.id})'); + }, + rename: (space, name) async { + add( + SpaceEvent.update( + space: space, + name: name, + icon: space.spaceIcon, + iconColor: space.spaceIconColor, + permission: space.spacePermission, + ), + ); + }, + changeIcon: (space, icon, iconColor) async { + add( + SpaceEvent.update( + space: space, + icon: icon, + iconColor: iconColor, + ), + ); + }, + update: (space, name, icon, iconColor, permission) async { + space ??= state.currentSpace; + if (space == null) { + Log.error('update space failed, space is null'); + return; + } + + if (name != null) { + await _rename(space, name); + } + + if (icon != null || iconColor != null || permission != null) { + try { + final extra = space.extra; + final current = extra.isNotEmpty == true + ? jsonDecode(extra) + : {}; + final updated = {}; + if (icon != null) { + updated[ViewExtKeys.spaceIconKey] = icon; + } + if (iconColor != null) { + updated[ViewExtKeys.spaceIconColorKey] = iconColor; + } + if (permission != null) { + updated[ViewExtKeys.spacePermissionKey] = permission.index; + } + final merged = mergeMaps(current, updated); + await ViewBackendService.updateView( + viewId: space.id, + extra: jsonEncode(merged), + ); + + Log.info( + 'update space: ${space.name}(${space.id}), merged: $merged', + ); + } catch (e) { + Log.error('Failed to migrating cover: $e'); + } + } else if (icon == null) { + try { + final extra = space.extra; + final Map current = extra.isNotEmpty == true + ? jsonDecode(extra) + : {}; + current.remove(ViewExtKeys.spaceIconKey); + current.remove(ViewExtKeys.spaceIconColorKey); + await ViewBackendService.updateView( + viewId: space.id, + extra: jsonEncode(current), + ); + + Log.info( + 'update space: ${space.name}(${space.id}), current: $current', + ); + } catch (e) { + Log.error('Failed to migrating cover: $e'); + } + } + + if (permission != null) { + await ViewBackendService.updateViewsVisibility( + [space], + permission == SpacePermission.publicToAll, + ); + } + }, + open: (space) async { + await _openSpace(space); + final isExpanded = await _getSpaceExpandStatus(space); + final views = await ViewBackendService.getChildViews( + viewId: space.id, + ); + final currentSpace = views.fold( + (views) { + space.freeze(); + return space.rebuild((b) { + b.childViews.clear(); + b.childViews.addAll(views); + }); + }, + (_) => space, + ); + emit( + state.copyWith( + currentSpace: currentSpace, + isExpanded: isExpanded, + ), + ); + + // don't open the page automatically on mobile + if (UniversalPlatform.isDesktop) { + // open the first page by default + if (currentSpace.childViews.isNotEmpty) { + final firstPage = currentSpace.childViews.first; + emit( + state.copyWith( + lastCreatedPage: firstPage, + ), + ); + } else { + emit( + state.copyWith( + lastCreatedPage: ViewPB(), + ), + ); + } + } + }, + expand: (space, isExpanded) async { + await _setSpaceExpandStatus(space, isExpanded); + emit(state.copyWith(isExpanded: isExpanded)); + }, + createPage: (name, layout, index, openAfterCreate) async { + final parentViewId = state.currentSpace?.id; + if (parentViewId == null) { + return; + } + + final result = await ViewBackendService.createView( + name: name, + layoutType: layout, + parentViewId: parentViewId, + index: index, + openAfterCreate: openAfterCreate, + ); + result.fold( + (view) { + emit( + state.copyWith( + lastCreatedPage: openAfterCreate ? view : null, + createPageResult: FlowyResult.success(null), + ), + ); + }, + (error) { + Log.error('Failed to create root view: $error'); + emit( + state.copyWith( + createPageResult: FlowyResult.failure(error), + ), + ); + }, + ); + }, + didReceiveSpaceUpdate: () async { + final (spaces, _, _) = await _getSpaces(); + final currentSpace = await _getLastOpenedSpace(spaces); + + Log.info( + 'receive space update, current space: ${currentSpace?.name}(${currentSpace?.id})', + ); + + for (var i = 0; i < spaces.length; i++) { + Log.info( + 'receive space update[$i]: ${spaces[i].name}(${spaces[i].id})', + ); + } + + emit( + state.copyWith( + spaces: spaces, + currentSpace: currentSpace, + ), + ); + }, + reset: (userProfile, workspaceId, openFirstPage) async { + if (this.workspaceId == workspaceId) { + return; + } + + _reset(userProfile, workspaceId); + + add( + SpaceEvent.initial( + openFirstPage: openFirstPage, + ), + ); + }, + migrate: () async { + final result = await migrate(); + emit(state.copyWith(shouldShowUpgradeDialog: !result)); + }, + switchToNextSpace: () async { + final spaces = state.spaces; + if (spaces.isEmpty) { + return; + } + + final currentSpace = state.currentSpace; + if (currentSpace == null) { + return; + } + final currentIndex = spaces.indexOf(currentSpace); + final nextIndex = (currentIndex + 1) % spaces.length; + final nextSpace = spaces[nextIndex]; + add(SpaceEvent.open(nextSpace)); + }, + duplicate: (space) async { + space ??= state.currentSpace; + if (space == null) { + Log.error('duplicate space failed, space is null'); + return; + } + + Log.info('duplicate space: ${space.name}(${space.id})'); + + emit(state.copyWith(isDuplicatingSpace: true)); + + final newSpace = await _duplicateSpace(space); + // open the duplicated space + if (newSpace != null) { + add(const SpaceEvent.didReceiveSpaceUpdate()); + add(SpaceEvent.open(newSpace)); + } + + emit(state.copyWith(isDuplicatingSpace: false)); + }, + ); + }, + ); + } + + late WorkspaceService _workspaceService; + late String workspaceId; + late UserProfilePB userProfile; + WorkspaceSectionsListener? _listener; + bool openFirstPage = false; + + @override + Future close() async { + await _listener?.stop(); + _listener = null; + return super.close(); + } + + Future<(List, List, List)> _getSpaces() async { + final sectionViews = await _getSectionViews(); + if (sectionViews == null || sectionViews.views.isEmpty) { + return ([], [], []); + } + + final publicViews = sectionViews.publicViews.unique((e) => e.id); + final privateViews = sectionViews.privateViews.unique((e) => e.id); + + final publicSpaces = publicViews.where((e) => e.isSpace); + final privateSpaces = privateViews.where((e) => e.isSpace); + + return ([...publicSpaces, ...privateSpaces], publicViews, privateViews); + } + + Future _createSpace({ + required String name, + required String icon, + required String iconColor, + required SpacePermission permission, + String? viewId, + }) async { + final section = switch (permission) { + SpacePermission.publicToAll => ViewSectionPB.Public, + SpacePermission.private => ViewSectionPB.Private, + }; + + final extra = { + ViewExtKeys.isSpaceKey: true, + ViewExtKeys.spaceIconKey: icon, + ViewExtKeys.spaceIconColorKey: iconColor, + ViewExtKeys.spacePermissionKey: permission.index, + ViewExtKeys.spaceCreatedAtKey: DateTime.now().millisecondsSinceEpoch, + }; + final result = await _workspaceService.createView( + name: name, + viewSection: section, + setAsCurrent: true, + viewId: viewId, + extra: jsonEncode(extra), + ); + return await result.fold((space) async { + Log.info('Space created: $space'); + return space; + }, (error) { + Log.error('Failed to create space: $error'); + return null; + }); + } + + Future _rename(ViewPB space, String name) async { + final result = + await ViewBackendService.updateView(viewId: space.id, name: name); + return result.fold((_) { + space.freeze(); + return space.rebuild((b) => b.name = name); + }, (error) { + Log.error('Failed to rename space: $error'); + return space; + }); + } + + Future _getSectionViews() async { + try { + final publicViews = await _workspaceService.getPublicViews().getOrThrow(); + final privateViews = + await _workspaceService.getPrivateViews().getOrThrow(); + return SidebarSection( + publicViews: publicViews, + privateViews: privateViews, + ); + } catch (e) { + Log.error('Failed to get section views: $e'); + return null; + } + } + + void _initial(UserProfilePB userProfile, String workspaceId) { + Log.info('initial(or reset) space bloc: $workspaceId, ${userProfile.id}'); + _workspaceService = WorkspaceService(workspaceId: workspaceId); + + this.userProfile = userProfile; + this.workspaceId = workspaceId; + + _listener = WorkspaceSectionsListener( + user: userProfile, + workspaceId: workspaceId, + )..start( + sectionChanged: (result) async { + Log.info('did receive section views changed'); + if (isClosed) { + return; + } + add(const SpaceEvent.didReceiveSpaceUpdate()); + }, + ); + } + + void _reset(UserProfilePB userProfile, String workspaceId) { + _listener?.stop(); + _listener = null; + + this.userProfile = userProfile; + this.workspaceId = workspaceId; + } + + Future _getLastOpenedSpace(List spaces) async { + if (spaces.isEmpty) { + return null; + } + + final spaceId = + await getIt().get(KVKeys.lastOpenedSpaceId); + if (spaceId == null) { + return spaces.first; + } + + final space = + spaces.firstWhereOrNull((e) => e.id == spaceId) ?? spaces.first; + return space; + } + + Future _openSpace(ViewPB space) async { + await getIt().set(KVKeys.lastOpenedSpaceId, space.id); + } + + Future _setSpaceExpandStatus(ViewPB? space, bool isExpanded) async { + if (space == null) { + return; + } + + final result = await getIt().get(KVKeys.expandedViews); + var map = {}; + if (result != null) { + map = jsonDecode(result); + } + if (isExpanded) { + // set expand status to true if it's not expanded + map[space.id] = true; + } else { + // remove the expand status if it's expanded + map.remove(space.id); + } + await getIt().set(KVKeys.expandedViews, jsonEncode(map)); + } + + Future _getSpaceExpandStatus(ViewPB? space) async { + if (space == null) { + return true; + } + + return getIt().get(KVKeys.expandedViews).then((result) { + if (result == null) { + return true; + } + final map = jsonDecode(result); + return map[space.id] ?? true; + }); + } + + Future migrate({bool auto = true}) async { + try { + final user = + await UserBackendService.getCurrentUserProfile().getOrThrow(); + final service = UserBackendService(userId: user.id); + final members = + await service.getWorkspaceMembers(workspaceId).getOrThrow(); + final isOwner = members.items + .any((e) => e.role == AFRolePB.Owner && e.email == user.email); + + if (members.items.isEmpty) { + return true; + } + + // only one member in the workspace, migrate it immediately + // only the owner can migrate the public space + if (members.items.length == 1 || isOwner) { + // create a new public space and a new private space + // move all the views in the workspace to the new public/private space + var publicViews = await _workspaceService.getPublicViews().getOrThrow(); + final containsPublicSpace = publicViews.any( + (e) => e.isSpace && e.spacePermission == SpacePermission.publicToAll, + ); + publicViews = publicViews.where((e) => !e.isSpace).toList(); + + for (final view in publicViews) { + Log.info( + 'migrating: the public view should be migrated: ${view.name}(${view.id})', + ); + } + + // if there is already a public space, don't migrate the public space + // only migrate the public space if there are any public views + if (publicViews.isEmpty || containsPublicSpace) { + return true; + } + + final viewId = fixedUuid( + user.id.toInt() + workspaceId.hashCode, + UuidType.publicSpace, + ); + final publicSpace = await _createSpace( + name: 'Shared', + icon: builtInSpaceIcons.first, + iconColor: builtInSpaceColors.first, + permission: SpacePermission.publicToAll, + viewId: viewId, + ); + + Log.info('migrating: created a new public space: ${publicSpace?.id}'); + + if (publicSpace != null) { + for (final view in publicViews.reversed) { + if (view.isSpace) { + continue; + } + await ViewBackendService.moveViewV2( + viewId: view.id, + newParentId: publicSpace.id, + prevViewId: null, + ); + Log.info( + 'migrating: migrate ${view.name}(${view.id}) to public space(${publicSpace.id})', + ); + } + } + } + + // create a new private space + final viewId = fixedUuid(user.id.toInt(), UuidType.privateSpace); + var privateViews = await _workspaceService.getPrivateViews().getOrThrow(); + // if there is already a private space, don't migrate the private space + final containsPrivateSpace = privateViews.any( + (e) => e.isSpace && e.spacePermission == SpacePermission.private, + ); + privateViews = privateViews.where((e) => !e.isSpace).toList(); + + for (final view in privateViews) { + Log.info( + 'migrating: the private view should be migrated: ${view.name}(${view.id})', + ); + } + + if (privateViews.isEmpty || containsPrivateSpace) { + return true; + } + // only migrate the private space if there are any private views + final privateSpace = await _createSpace( + name: 'Private', + icon: builtInSpaceIcons.last, + iconColor: builtInSpaceColors.last, + permission: SpacePermission.private, + viewId: viewId, + ); + Log.info('migrating: created a new private space: ${privateSpace?.id}'); + + if (privateSpace != null) { + for (final view in privateViews.reversed) { + if (view.isSpace) { + continue; + } + await ViewBackendService.moveViewV2( + viewId: view.id, + newParentId: privateSpace.id, + prevViewId: null, + ); + Log.info( + 'migrating: migrate ${view.name}(${view.id}) to private space(${privateSpace.id})', + ); + } + } + + return true; + } catch (e) { + Log.error('migrate space error: $e'); + return false; + } + } + + Future shouldShowUpgradeDialog({ + required List spaces, + required List publicViews, + required List privateViews, + }) async { + final publicSpaces = spaces.where( + (e) => e.spacePermission == SpacePermission.publicToAll, + ); + if (publicSpaces.isEmpty && publicViews.isNotEmpty) { + return true; + } + + final privateSpaces = spaces.where( + (e) => e.spacePermission == SpacePermission.private, + ); + if (privateSpaces.isEmpty && privateViews.isNotEmpty) { + return true; + } + + return false; + } + + Future _duplicateSpace(ViewPB space) async { + // if the space is not duplicated, try to create a new space + final icon = space.spaceIcon.orDefault(builtInSpaceIcons.first); + final iconColor = space.spaceIconColor.orDefault(builtInSpaceColors.first); + final newSpace = await _createSpace( + name: '${space.name} (copy)', + icon: icon, + iconColor: iconColor, + permission: space.spacePermission, + ); + + if (newSpace == null) { + return null; + } + + for (final view in space.childViews) { + await ViewBackendService.duplicate( + view: view, + openAfterDuplicate: true, + syncAfterDuplicate: true, + includeChildren: true, + parentViewId: newSpace.id, + suffix: '', + ); + } + + Log.info('Space duplicated: $newSpace'); + + return newSpace; + } +} + +@freezed +class SpaceEvent with _$SpaceEvent { + const factory SpaceEvent.initial({ + required bool openFirstPage, + }) = _Initial; + const factory SpaceEvent.create({ + required String name, + required String icon, + required String iconColor, + required SpacePermission permission, + required bool createNewPageByDefault, + required bool openAfterCreate, + }) = _Create; + const factory SpaceEvent.rename({ + required ViewPB space, + required String name, + }) = _Rename; + const factory SpaceEvent.changeIcon({ + ViewPB? space, + String? icon, + String? iconColor, + }) = _ChangeIcon; + const factory SpaceEvent.duplicate({ + ViewPB? space, + }) = _Duplicate; + const factory SpaceEvent.update({ + ViewPB? space, + String? name, + String? icon, + String? iconColor, + SpacePermission? permission, + }) = _Update; + const factory SpaceEvent.open(ViewPB space) = _Open; + const factory SpaceEvent.expand(ViewPB space, bool isExpanded) = _Expand; + const factory SpaceEvent.createPage({ + required String name, + required ViewLayoutPB layout, + int? index, + required bool openAfterCreate, + }) = _CreatePage; + const factory SpaceEvent.delete(ViewPB? space) = _Delete; + const factory SpaceEvent.didReceiveSpaceUpdate() = _DidReceiveSpaceUpdate; + const factory SpaceEvent.reset( + UserProfilePB userProfile, + String workspaceId, + bool openFirstPage, + ) = _Reset; + const factory SpaceEvent.migrate() = _Migrate; + const factory SpaceEvent.switchToNextSpace() = _SwitchToNextSpace; +} + +@freezed +class SpaceState with _$SpaceState { + const factory SpaceState({ + // use root view with space attributes to represent the space + @Default([]) List spaces, + @Default(null) ViewPB? currentSpace, + @Default(true) bool isExpanded, + @Default(null) ViewPB? lastCreatedPage, + FlowyResult? createPageResult, + @Default(false) bool shouldShowUpgradeDialog, + @Default(false) bool isDuplicatingSpace, + @Default(false) bool isInitialized, + }) = _SpaceState; + + factory SpaceState.initial() => const SpaceState(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_search_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_search_bloc.dart new file mode 100644 index 0000000000000..04e3ad7896648 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_search_bloc.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'space_search_bloc.freezed.dart'; + +class SpaceSearchBloc extends Bloc { + SpaceSearchBloc() : super(SpaceSearchState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + _allViews = await ViewBackendService.getAllViews().fold( + (s) => s.items, + (_) => [], + ); + }, + search: (query) { + if (query.isEmpty) { + emit( + state.copyWith( + queryResults: null, + ), + ); + } else { + final queryResults = _allViews.where( + (view) => view.name.toLowerCase().contains(query.toLowerCase()), + ); + emit( + state.copyWith( + queryResults: queryResults.toList(), + ), + ); + } + }, + ); + }, + ); + } + + late final List _allViews; +} + +@freezed +class SpaceSearchEvent with _$SpaceSearchEvent { + const factory SpaceSearchEvent.initial() = _Initial; + const factory SpaceSearchEvent.search(String query) = _Search; +} + +@freezed +class SpaceSearchState with _$SpaceSearchState { + const factory SpaceSearchState({ + List? queryResults, + }) = _SpaceSearchState; + + factory SpaceSearchState.initial() => const SpaceSearchState(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/subscription_success_listenable/subscription_success_listenable.dart b/frontend/appflowy_flutter/lib/workspace/application/subscription_success_listenable/subscription_success_listenable.dart new file mode 100644 index 0000000000000..0cf436630f530 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/subscription_success_listenable/subscription_success_listenable.dart @@ -0,0 +1,25 @@ +import 'package:appflowy_backend/log.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; + +class SubscriptionSuccessListenable extends ChangeNotifier { + SubscriptionSuccessListenable(); + + String? _plan; + + SubscriptionPlanPB? get subscribedPlan => switch (_plan) { + 'free' => SubscriptionPlanPB.Free, + 'pro' => SubscriptionPlanPB.Pro, + 'team' => SubscriptionPlanPB.Team, + 'ai_max' => SubscriptionPlanPB.AiMax, + 'ai_local' => SubscriptionPlanPB.AiLocal, + _ => null, + }; + + void onPaymentSuccess(String? plan) { + Log.info("Payment success: $plan"); + _plan = plan; + notifyListeners(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart new file mode 100644 index 0000000000000..a437c9747c4d5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/tabs/tabs_bloc.dart @@ -0,0 +1,378 @@ +import 'package:appflowy/plugins/util.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'tabs_bloc.freezed.dart'; + +class TabsBloc extends Bloc { + TabsBloc() : super(TabsState()) { + menuSharedState = getIt(); + _dispatch(); + } + + late final MenuSharedState menuSharedState; + + @override + Future close() { + state.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + event.when( + selectTab: (int index) { + if (index != state.currentIndex && + index >= 0 && + index < state.pages) { + emit(state.copyWith(currentIndex: index)); + _setLatestOpenView(); + } + }, + moveTab: () {}, + closeTab: (String pluginId) { + final pm = state._pageManagers + .firstWhereOrNull((pm) => pm.plugin.id == pluginId); + if (pm?.isPinned == true) { + return; + } + + emit(state.closeView(pluginId)); + _setLatestOpenView(); + }, + closeCurrentTab: () { + if (state.currentPageManager.isPinned) { + return; + } + + emit(state.closeView(state.currentPageManager.plugin.id)); + _setLatestOpenView(); + }, + openTab: (Plugin plugin, ViewPB view) { + state.currentPageManager.hideSecondaryPlugin(); + emit(state.openView(plugin)); + _setLatestOpenView(view); + }, + openPlugin: (Plugin plugin, ViewPB? view, bool setLatest) { + state.currentPageManager.hideSecondaryPlugin(); + emit(state.openPlugin(plugin: plugin, setLatest: setLatest)); + if (setLatest) { + _setLatestOpenView(view); + } + }, + closeOtherTabs: (String pluginId) { + final pageManagers = [ + ...state._pageManagers + .where((pm) => pm.plugin.id == pluginId || pm.isPinned), + ]; + + int newIndex; + if (state.currentPageManager.isPinned) { + // Retain current index if it's already pinned + newIndex = state.currentIndex; + } else { + final pm = state._pageManagers + .firstWhereOrNull((pm) => pm.plugin.id == pluginId); + newIndex = pm != null ? pageManagers.indexOf(pm) : 0; + } + + emit( + state.copyWith( + currentIndex: newIndex, + pageManagers: pageManagers, + ), + ); + + _setLatestOpenView(); + }, + togglePin: (String pluginId) { + final pm = state._pageManagers + .firstWhereOrNull((pm) => pm.plugin.id == pluginId); + if (pm != null) { + final index = state._pageManagers.indexOf(pm); + + int newIndex = state.currentIndex; + if (pm.isPinned) { + // Unpinning logic + final indexOfFirstUnpinnedTab = + state._pageManagers.indexWhere((tab) => !tab.isPinned); + + // Determine the correct insertion point + final newUnpinnedIndex = indexOfFirstUnpinnedTab != -1 + ? indexOfFirstUnpinnedTab // Insert before the first unpinned tab + : state._pageManagers + .length; // Append at the end if no unpinned tabs exist + + state._pageManagers.removeAt(index); + + final adjustedUnpinnedIndex = newUnpinnedIndex > index + ? newUnpinnedIndex - 1 + : newUnpinnedIndex; + + state._pageManagers.insert(adjustedUnpinnedIndex, pm); + newIndex = _adjustCurrentIndex( + currentIndex: state.currentIndex, + tabIndex: index, + newIndex: adjustedUnpinnedIndex, + ); + } else { + // Pinning logic + final indexOfLastPinnedTab = + state._pageManagers.lastIndexWhere((tab) => tab.isPinned); + final newPinnedIndex = indexOfLastPinnedTab + 1; + + state._pageManagers.removeAt(index); + + final adjustedPinnedIndex = newPinnedIndex > index + ? newPinnedIndex - 1 + : newPinnedIndex; + + state._pageManagers.insert(adjustedPinnedIndex, pm); + newIndex = _adjustCurrentIndex( + currentIndex: state.currentIndex, + tabIndex: index, + newIndex: adjustedPinnedIndex, + ); + } + + pm.isPinned = !pm.isPinned; + + emit( + state.copyWith( + currentIndex: newIndex, + pageManagers: [...state._pageManagers], + ), + ); + } + }, + openSecondaryPlugin: (plugin, view) { + state.currentPageManager.setSecondaryPlugin(plugin); + }, + closeSecondaryPlugin: () { + final pageManager = state.currentPageManager; + pageManager.hideSecondaryPlugin(); + }, + expandSecondaryPlugin: () { + final pageManager = state.currentPageManager; + pageManager.setPlugin( + pageManager.secondaryNotifier.plugin, + true, + false, + ); + pageManager.hideSecondaryPlugin(); + _setLatestOpenView(); + }, + switchWorkspace: (workspaceId) { + final pluginId = state.currentPageManager.plugin.id; + + // Close all tabs except current + final pagesToClose = [ + ...state._pageManagers + .where((pm) => pm.plugin.id != pluginId && !pm.isPinned), + ]; + + if (pagesToClose.isNotEmpty) { + final newstate = state; + for (final pm in pagesToClose) { + newstate.closeView(pm.plugin.id); + } + emit(newstate.copyWith(currentIndex: 0)); + } + }, + ); + }, + ); + } + + void _setLatestOpenView([ViewPB? view]) { + if (view != null) { + menuSharedState.latestOpenView = view; + } else { + final pageManager = state.currentPageManager; + final notifier = pageManager.plugin.notifier; + if (notifier is ViewPluginNotifier && + menuSharedState.latestOpenView?.id != notifier.view.id) { + menuSharedState.latestOpenView = notifier.view; + } + } + } + + int _adjustCurrentIndex({ + required int currentIndex, + required int tabIndex, + required int newIndex, + }) { + if (tabIndex < currentIndex && newIndex >= currentIndex) { + return currentIndex - 1; // Tab moved forward, shift currentIndex back + } else if (tabIndex > currentIndex && newIndex <= currentIndex) { + return currentIndex + 1; // Tab moved backward, shift currentIndex forward + } else if (tabIndex == currentIndex) { + return newIndex; // Tab is the current tab, update to newIndex + } + + return currentIndex; + } + + /// Adds a [TabsEvent.openTab] event for the provided [ViewPB] + void openTab(ViewPB view) => + add(TabsEvent.openTab(plugin: view.plugin(), view: view)); + + /// Adds a [TabsEvent.openPlugin] event for the provided [ViewPB] + void openPlugin( + ViewPB view, { + Map arguments = const {}, + }) { + add( + TabsEvent.openPlugin( + plugin: view.plugin(arguments: arguments), + view: view, + ), + ); + } +} + +@freezed +class TabsEvent with _$TabsEvent { + const factory TabsEvent.moveTab() = _MoveTab; + const factory TabsEvent.closeTab(String pluginId) = _CloseTab; + const factory TabsEvent.closeOtherTabs(String pluginId) = _CloseOtherTabs; + const factory TabsEvent.closeCurrentTab() = _CloseCurrentTab; + const factory TabsEvent.selectTab(int index) = _SelectTab; + const factory TabsEvent.togglePin(String pluginId) = _TogglePin; + const factory TabsEvent.openTab({ + required Plugin plugin, + required ViewPB view, + }) = _OpenTab; + const factory TabsEvent.openPlugin({ + required Plugin plugin, + ViewPB? view, + @Default(true) bool setLatest, + }) = _OpenPlugin; + const factory TabsEvent.openSecondaryPlugin({ + required Plugin plugin, + ViewPB? view, + }) = _OpenSecondaryPlugin; + const factory TabsEvent.closeSecondaryPlugin() = _CloseSecondaryPlugin; + const factory TabsEvent.expandSecondaryPlugin() = _ExpandSecondaryPlugin; + const factory TabsEvent.switchWorkspace(String workspaceId) = + _SwitchWorkspace; +} + +class TabsState { + TabsState({ + this.currentIndex = 0, + List? pageManagers, + }) : _pageManagers = pageManagers ?? [PageManager()]; + + final int currentIndex; + final List _pageManagers; + int get pages => _pageManagers.length; + PageManager get currentPageManager => _pageManagers[currentIndex]; + List get pageManagers => _pageManagers; + + bool get isAllPinned => _pageManagers.every((pm) => pm.isPinned); + + /// This opens a new tab given a [Plugin]. + /// + /// If the [Plugin.id] is already associated with an open tab, + /// then it selects that tab. + /// + TabsState openView(Plugin plugin) { + final selectExistingPlugin = _selectPluginIfOpen(plugin.id); + + if (selectExistingPlugin == null) { + _pageManagers.add(PageManager()..setPlugin(plugin, true)); + + return copyWith( + currentIndex: pages - 1, + pageManagers: [..._pageManagers], + ); + } + + return selectExistingPlugin; + } + + TabsState closeView(String pluginId) { + // Avoid closing the only open tab + if (_pageManagers.length == 1) { + return this; + } + + _pageManagers.removeWhere((pm) => pm.plugin.id == pluginId); + + /// If currentIndex is greater than the amount of allowed indices + /// And the current selected tab isn't the first (index 0) + /// as currentIndex cannot be -1 + /// Then decrease currentIndex by 1 + final newIndex = currentIndex > pages - 1 && currentIndex > 0 + ? currentIndex - 1 + : currentIndex; + + return copyWith( + currentIndex: newIndex, + pageManagers: [..._pageManagers], + ); + } + + /// This opens a plugin in the current selected tab, + /// due to how Document currently works, only one tab + /// per plugin can currently be active. + /// + /// If the plugin is already open in a tab, then that tab + /// will become selected. + /// + TabsState openPlugin({required Plugin plugin, bool setLatest = true}) { + final selectExistingPlugin = _selectPluginIfOpen(plugin.id); + + if (selectExistingPlugin == null) { + final pageManagers = [..._pageManagers]; + pageManagers[currentIndex].setPlugin(plugin, setLatest); + + return copyWith(pageManagers: pageManagers); + } + + return selectExistingPlugin; + } + + /// Checks if a [Plugin.id] is already associated with an open tab. + /// Returns a [TabState] with new index if there is a match. + /// + /// If no match it returns null + /// + TabsState? _selectPluginIfOpen(String id) { + final index = _pageManagers.indexWhere((pm) => pm.plugin.id == id); + + if (index == -1) { + return null; + } + + if (index == currentIndex) { + return this; + } + + return copyWith(currentIndex: index); + } + + TabsState copyWith({ + int? currentIndex, + List? pageManagers, + }) => + TabsState( + currentIndex: currentIndex ?? this.currentIndex, + pageManagers: pageManagers ?? _pageManagers, + ); + + void dispose() { + for (final manager in pageManagers) { + manager.dispose(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/prelude.dart b/frontend/appflowy_flutter/lib/workspace/application/user/prelude.dart new file mode 100644 index 0000000000000..b9bbb0ff083c4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/user/prelude.dart @@ -0,0 +1,2 @@ +export 'settings_user_bloc.dart'; +export 'user_workspace_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart new file mode 100644 index 0000000000000..56faa9f8d8b21 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/user/settings_user_bloc.dart @@ -0,0 +1,129 @@ +import 'package:appflowy/user/application/user_listener.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'settings_user_bloc.freezed.dart'; + +class SettingsUserViewBloc extends Bloc { + SettingsUserViewBloc(this.userProfile) + : _userListener = UserListener(userProfile: userProfile), + _userService = UserBackendService(userId: userProfile.id), + super(SettingsUserState.initial(userProfile)) { + _dispatch(); + } + + final UserBackendService _userService; + final UserListener _userListener; + final UserProfilePB userProfile; + + @override + Future close() async { + await _userListener.stop(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () async { + _loadUserProfile(); + _userListener.start(onProfileUpdated: _profileUpdated); + }, + didReceiveUserProfile: (UserProfilePB newUserProfile) { + emit(state.copyWith(userProfile: newUserProfile)); + }, + updateUserName: (String name) { + _userService.updateUserProfile(name: name).then((result) { + result.fold( + (l) => null, + (err) => Log.error(err), + ); + }); + }, + updateUserIcon: (String iconUrl) { + _userService.updateUserProfile(iconUrl: iconUrl).then((result) { + result.fold( + (l) => null, + (err) => Log.error(err), + ); + }); + }, + removeUserIcon: () { + // Empty Icon URL = No icon + _userService.updateUserProfile(iconUrl: "").then((result) { + result.fold( + (l) => null, + (err) => Log.error(err), + ); + }); + }, + updateUserEmail: (String email) { + _userService.updateUserProfile(email: email).then((result) { + result.fold( + (l) => null, + (err) => Log.error(err), + ); + }); + }, + ); + }, + ); + } + + void _loadUserProfile() { + UserBackendService.getCurrentUserProfile().then((result) { + if (isClosed) { + return; + } + + result.fold( + (userProfile) => add( + SettingsUserEvent.didReceiveUserProfile(userProfile), + ), + (err) => Log.error(err), + ); + }); + } + + void _profileUpdated( + FlowyResult userProfileOrFailed, + ) => + userProfileOrFailed.fold( + (newUserProfile) => + add(SettingsUserEvent.didReceiveUserProfile(newUserProfile)), + (err) => Log.error(err), + ); +} + +@freezed +class SettingsUserEvent with _$SettingsUserEvent { + const factory SettingsUserEvent.initial() = _Initial; + const factory SettingsUserEvent.updateUserName(String name) = _UpdateUserName; + const factory SettingsUserEvent.updateUserEmail(String email) = _UpdateEmail; + const factory SettingsUserEvent.updateUserIcon({required String iconUrl}) = + _UpdateUserIcon; + const factory SettingsUserEvent.removeUserIcon() = _RemoveUserIcon; + const factory SettingsUserEvent.didReceiveUserProfile( + UserProfilePB newUserProfile, + ) = _DidReceiveUserProfile; +} + +@freezed +class SettingsUserState with _$SettingsUserState { + const factory SettingsUserState({ + required UserProfilePB userProfile, + required FlowyResult successOrFailure, + }) = _SettingsUserState; + + factory SettingsUserState.initial(UserProfilePB userProfile) => + SettingsUserState( + userProfile: userProfile, + successOrFailure: FlowyResult.success(null), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart new file mode 100644 index 0000000000000..7f32a86d1cc91 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -0,0 +1,524 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/user/application/user_listener.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'user_workspace_bloc.freezed.dart'; + +class UserWorkspaceBloc extends Bloc { + UserWorkspaceBloc({ + required this.userProfile, + }) : _userService = UserBackendService(userId: userProfile.id), + _listener = UserListener(userProfile: userProfile), + super(UserWorkspaceState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + _listener.start( + onUserWorkspaceListUpdated: (workspaces) => + add(UserWorkspaceEvent.updateWorkspaces(workspaces)), + onUserWorkspaceUpdated: (workspace) { + // If currentWorkspace is updated, eg. Icon or Name, we should notify + // the UI to render the updated information. + final currentWorkspace = state.currentWorkspace; + if (currentWorkspace?.workspaceId == workspace.workspaceId) { + add(UserWorkspaceEvent.updateCurrentWorkspace(workspace)); + } + }, + ); + + final result = await _fetchWorkspaces(); + final currentWorkspace = result.$1; + final workspaces = result.$2; + final isCollabWorkspaceOn = + userProfile.authenticator == AuthenticatorPB.AppFlowyCloud && + FeatureFlag.collaborativeWorkspace.isOn; + Log.info( + 'init workspace, current workspace: ${currentWorkspace?.workspaceId}, ' + 'workspaces: ${workspaces.map((e) => e.workspaceId)}, isCollabWorkspaceOn: $isCollabWorkspaceOn', + ); + if (currentWorkspace != null && result.$3 == true) { + Log.info('init open workspace: ${currentWorkspace.workspaceId}'); + await _userService.openWorkspace(currentWorkspace.workspaceId); + } + + emit( + state.copyWith( + currentWorkspace: currentWorkspace, + workspaces: workspaces, + isCollabWorkspaceOn: isCollabWorkspaceOn, + actionResult: null, + ), + ); + }, + fetchWorkspaces: () async { + final result = await _fetchWorkspaces(); + + final currentWorkspace = result.$1; + final workspaces = result.$2; + Log.info( + 'fetch workspaces: current workspace: ${currentWorkspace?.workspaceId}, workspaces: ${workspaces.map((e) => e.workspaceId)}', + ); + + emit( + state.copyWith( + workspaces: workspaces, + ), + ); + + // try to open the workspace if the current workspace is not the same + if (currentWorkspace != null && + currentWorkspace.workspaceId != + state.currentWorkspace?.workspaceId) { + Log.info( + 'fetch workspaces: try to open workspace: ${currentWorkspace.workspaceId}', + ); + add(OpenWorkspace(currentWorkspace.workspaceId)); + } + }, + createWorkspace: (name) async { + emit( + state.copyWith( + actionResult: const UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.create, + isLoading: true, + result: null, + ), + ), + ); + final result = await _userService.createUserWorkspace(name); + final workspaces = result.fold( + (s) => [...state.workspaces, s], + (e) => state.workspaces, + ); + emit( + state.copyWith( + workspaces: workspaces, + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.create, + isLoading: false, + result: result, + ), + ), + ); + // open the created workspace by default + result + ..onSuccess((s) { + Log.info('create workspace success: $s'); + add(OpenWorkspace(s.workspaceId)); + }) + ..onFailure((f) { + Log.error('create workspace error: $f'); + }); + }, + deleteWorkspace: (workspaceId) async { + Log.info('try to delete workspace: $workspaceId'); + emit( + state.copyWith( + actionResult: const UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.delete, + isLoading: true, + result: null, + ), + ), + ); + final remoteWorkspaces = await _fetchWorkspaces().then( + (value) => value.$2, + ); + if (state.workspaces.length <= 1 || remoteWorkspaces.length <= 1) { + // do not allow to delete the last workspace, otherwise the user + // cannot do create workspace again + Log.error('cannot delete the only workspace'); + final result = FlowyResult.failure( + FlowyError( + code: ErrorCode.Internal, + msg: LocaleKeys.workspace_cannotDeleteTheOnlyWorkspace.tr(), + ), + ); + return emit( + state.copyWith( + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.delete, + result: result, + isLoading: false, + ), + ), + ); + } + + final result = await _userService.deleteWorkspaceById(workspaceId); + // fetch the workspaces again to check if the current workspace is deleted + final workspacesResult = await _fetchWorkspaces(); + final workspaces = workspacesResult.$2; + final containsDeletedWorkspace = workspaces.any( + (e) => e.workspaceId == workspaceId, + ); + result + ..onSuccess((_) { + Log.info('delete workspace success: $workspaceId'); + // if the current workspace is deleted, open the first workspace + if (state.currentWorkspace?.workspaceId == workspaceId) { + add(OpenWorkspace(workspaces.first.workspaceId)); + } + }) + ..onFailure((f) { + Log.error('delete workspace error: $f'); + // if the workspace is deleted but return an error, we need to + // open the first workspace + if (!containsDeletedWorkspace) { + add(OpenWorkspace(workspaces.first.workspaceId)); + } + }); + emit( + state.copyWith( + workspaces: workspaces, + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.delete, + result: result, + isLoading: false, + ), + ), + ); + }, + openWorkspace: (workspaceId) async { + emit( + state.copyWith( + actionResult: const UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.open, + isLoading: true, + result: null, + ), + ), + ); + final result = await _userService.openWorkspace(workspaceId); + final currentWorkspace = result.fold( + (s) => state.workspaces.firstWhereOrNull( + (e) => e.workspaceId == workspaceId, + ), + (e) => state.currentWorkspace, + ); + + result + ..onSuccess((s) { + Log.info( + 'open workspace success: $workspaceId, current workspace: ${currentWorkspace?.toProto3Json()}', + ); + }) + ..onFailure((f) { + Log.error('open workspace error: $f'); + }); + + emit( + state.copyWith( + currentWorkspace: currentWorkspace, + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.open, + isLoading: false, + result: result, + ), + ), + ); + }, + renameWorkspace: (workspaceId, name) async { + final result = + await _userService.renameWorkspace(workspaceId, name); + final workspaces = result.fold( + (s) => state.workspaces.map( + (e) { + if (e.workspaceId == workspaceId) { + e.freeze(); + return e.rebuild((p0) { + p0.name = name; + }); + } + return e; + }, + ).toList(), + (f) => state.workspaces, + ); + final currentWorkspace = workspaces.firstWhere( + (e) => e.workspaceId == state.currentWorkspace?.workspaceId, + ); + + Log.info( + 'rename workspace: $workspaceId, name: $name', + ); + + result.onFailure((f) { + Log.error('rename workspace error: $f'); + }); + + emit( + state.copyWith( + workspaces: workspaces, + currentWorkspace: currentWorkspace, + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.rename, + isLoading: false, + result: result, + ), + ), + ); + }, + updateWorkspaceIcon: (workspaceId, icon) async { + final workspace = state.workspaces.firstWhere( + (e) => e.workspaceId == workspaceId, + ); + if (icon == workspace.icon) { + Log.info('ignore same icon update'); + return; + } + + final result = await _userService.updateWorkspaceIcon( + workspaceId, + icon, + ); + final workspaces = result.fold( + (s) => state.workspaces.map( + (e) { + if (e.workspaceId == workspaceId) { + e.freeze(); + return e.rebuild((p0) { + p0.icon = icon; + }); + } + return e; + }, + ).toList(), + (f) => state.workspaces, + ); + final currentWorkspace = workspaces.firstWhere( + (e) => e.workspaceId == state.currentWorkspace?.workspaceId, + ); + + Log.info( + 'update workspace icon: $workspaceId, icon: $icon', + ); + + result.onFailure((f) { + Log.error('update workspace icon error: $f'); + }); + + emit( + state.copyWith( + workspaces: workspaces, + currentWorkspace: currentWorkspace, + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.updateIcon, + isLoading: false, + result: result, + ), + ), + ); + }, + leaveWorkspace: (workspaceId) async { + final result = await _userService.leaveWorkspace(workspaceId); + final workspaces = result.fold( + (s) => state.workspaces + .where((e) => e.workspaceId != workspaceId) + .toList(), + (e) => state.workspaces, + ); + result + ..onSuccess((_) { + Log.info('leave workspace success: $workspaceId'); + // if leaving the current workspace, open the first workspace + if (state.currentWorkspace?.workspaceId == workspaceId) { + add(OpenWorkspace(workspaces.first.workspaceId)); + } + }) + ..onFailure((f) { + Log.error('leave workspace error: $f'); + }); + emit( + state.copyWith( + workspaces: workspaces, + actionResult: UserWorkspaceActionResult( + actionType: UserWorkspaceActionType.leave, + isLoading: false, + result: result, + ), + ), + ); + }, + updateWorkspaces: (workspaces) async { + emit( + state.copyWith( + workspaces: workspaces.items + ..sort( + (a, b) => + a.createdAtTimestamp.compareTo(b.createdAtTimestamp), + ), + ), + ); + }, + updateCurrentWorkspace: (workspace) async { + final workspaces = [...state.workspaces]; + final index = workspaces + .indexWhere((e) => e.workspaceId == workspace.workspaceId); + if (index != -1) { + workspaces[index] = workspace; + } + + emit( + state.copyWith( + currentWorkspace: workspace, + workspaces: workspaces + ..sort( + (a, b) => + a.createdAtTimestamp.compareTo(b.createdAtTimestamp), + ), + ), + ); + }, + ); + }, + ); + } + + @override + Future close() { + _listener.stop(); + return super.close(); + } + + final UserProfilePB userProfile; + final UserBackendService _userService; + final UserListener _listener; + + Future< + ( + UserWorkspacePB? currentWorkspace, + List workspaces, + bool shouldOpenWorkspace, + )> _fetchWorkspaces() async { + try { + final currentWorkspace = + await UserBackendService.getCurrentWorkspace().getOrThrow(); + final workspaces = await _userService.getWorkspaces().getOrThrow(); + if (workspaces.isEmpty) { + workspaces.add(convertWorkspacePBToUserWorkspace(currentWorkspace)); + } + final currentWorkspaceInList = workspaces + .firstWhereOrNull((e) => e.workspaceId == currentWorkspace.id) ?? + workspaces.firstOrNull; + return ( + currentWorkspaceInList, + workspaces + ..sort( + (a, b) => a.createdAtTimestamp.compareTo(b.createdAtTimestamp), + ), + currentWorkspaceInList?.workspaceId != currentWorkspace.id + ); + } catch (e) { + Log.error('fetch workspace error: $e'); + return (null, [], false); + } + } + + UserWorkspacePB convertWorkspacePBToUserWorkspace(WorkspacePB workspace) { + return UserWorkspacePB.create() + ..workspaceId = workspace.id + ..name = workspace.name + ..createdAtTimestamp = workspace.createTime; + } +} + +@freezed +class UserWorkspaceEvent with _$UserWorkspaceEvent { + const factory UserWorkspaceEvent.initial() = Initial; + const factory UserWorkspaceEvent.fetchWorkspaces() = FetchWorkspaces; + const factory UserWorkspaceEvent.createWorkspace(String name) = + CreateWorkspace; + const factory UserWorkspaceEvent.deleteWorkspace(String workspaceId) = + DeleteWorkspace; + const factory UserWorkspaceEvent.openWorkspace(String workspaceId) = + OpenWorkspace; + const factory UserWorkspaceEvent.renameWorkspace( + String workspaceId, + String name, + ) = _RenameWorkspace; + const factory UserWorkspaceEvent.updateWorkspaceIcon( + String workspaceId, + String icon, + ) = _UpdateWorkspaceIcon; + const factory UserWorkspaceEvent.leaveWorkspace(String workspaceId) = + LeaveWorkspace; + const factory UserWorkspaceEvent.updateWorkspaces( + RepeatedUserWorkspacePB workspaces, + ) = UpdateWorkspaces; + const factory UserWorkspaceEvent.updateCurrentWorkspace( + UserWorkspacePB workspace, + ) = UpdateCurrentWorkspace; +} + +enum UserWorkspaceActionType { + none, + create, + delete, + open, + rename, + updateIcon, + fetchWorkspaces, + leave; +} + +class UserWorkspaceActionResult { + const UserWorkspaceActionResult({ + required this.actionType, + required this.isLoading, + required this.result, + }); + + final UserWorkspaceActionType actionType; + final bool isLoading; + final FlowyResult? result; + + @override + String toString() { + return 'UserWorkspaceActionResult(actionType: $actionType, isLoading: $isLoading, result: $result)'; + } +} + +@freezed +class UserWorkspaceState with _$UserWorkspaceState { + const UserWorkspaceState._(); + + const factory UserWorkspaceState({ + @Default(null) UserWorkspacePB? currentWorkspace, + @Default([]) List workspaces, + @Default(null) UserWorkspaceActionResult? actionResult, + @Default(false) bool isCollabWorkspaceOn, + }) = _UserWorkspaceState; + + factory UserWorkspaceState.initial() => const UserWorkspaceState(); + + @override + int get hashCode => runtimeType.hashCode; + + final DeepCollectionEquality _deepCollectionEquality = + const DeepCollectionEquality(); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is UserWorkspaceState && + other.currentWorkspace == currentWorkspace && + _deepCollectionEquality.equals(other.workspaces, workspaces) && + identical(other.actionResult, actionResult); + } +} diff --git a/frontend/app_flowy/lib/workspace/application/view/prelude.dart b/frontend/appflowy_flutter/lib/workspace/application/view/prelude.dart similarity index 100% rename from frontend/app_flowy/lib/workspace/application/view/prelude.dart rename to frontend/appflowy_flutter/lib/workspace/application/view/prelude.dart diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart new file mode 100644 index 0000000000000..30a32bbd2d521 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart @@ -0,0 +1,506 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_listener.dart'; +import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'view_bloc.freezed.dart'; + +class ViewBloc extends Bloc { + ViewBloc({required this.view, this.shouldLoadChildViews = true}) + : viewBackendSvc = ViewBackendService(), + listener = ViewListener(viewId: view.id), + favoriteListener = FavoriteListener(), + super(ViewState.init(view)) { + _dispatch(); + } + + final ViewPB view; + final ViewBackendService viewBackendSvc; + final ViewListener listener; + final FavoriteListener favoriteListener; + final bool shouldLoadChildViews; + + @override + Future close() async { + await listener.stop(); + await favoriteListener.stop(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.map( + initial: (e) async { + listener.start( + onViewUpdated: (result) { + add(ViewEvent.viewDidUpdate(FlowyResult.success(result))); + }, + onViewChildViewsUpdated: (result) async { + final view = await _updateChildViews(result); + if (!isClosed && view != null) { + add(ViewEvent.viewUpdateChildView(view)); + } + }, + ); + favoriteListener.start( + favoritesUpdated: (result, isFavorite) { + result.fold( + (result) { + final current = result.items + .firstWhereOrNull((v) => v.id == state.view.id); + if (current != null) { + add( + ViewEvent.viewDidUpdate( + FlowyResult.success(current), + ), + ); + } + }, + (error) {}, + ); + }, + ); + final isExpanded = await _getViewIsExpanded(view); + emit(state.copyWith(isExpanded: isExpanded, view: view)); + if (shouldLoadChildViews) { + await _loadChildViews(emit); + } + }, + setIsEditing: (e) { + emit(state.copyWith(isEditing: e.isEditing)); + }, + setIsExpanded: (e) async { + if (e.isExpanded && !state.isExpanded) { + await _loadViewsWhenExpanded(emit, true); + } else { + emit(state.copyWith(isExpanded: e.isExpanded)); + } + await _setViewIsExpanded(view, e.isExpanded); + }, + viewDidUpdate: (e) async { + final result = await ViewBackendService.getView(view.id); + final view_ = result.fold((l) => l, (r) => null); + e.result.fold( + (view) async { + // ignore child view changes because it only contains one level + // children data. + if (_isSameViewIgnoreChildren(view, state.view)) { + // do nothing. + } + emit( + state.copyWith( + view: view_ ?? view, + successOrFailure: FlowyResult.success(null), + ), + ); + }, + (error) => emit( + state.copyWith(successOrFailure: FlowyResult.failure(error)), + ), + ); + }, + rename: (e) async { + final result = await ViewBackendService.updateView( + viewId: view.id, + name: e.newName, + ); + emit( + result.fold( + (l) { + final view = state.view; + view.freeze(); + final newView = view.rebuild( + (b) => b.name = e.newName, + ); + Log.info('rename view: ${newView.id} to ${newView.name}'); + return state.copyWith( + successOrFailure: FlowyResult.success(null), + view: newView, + ); + }, + (error) { + Log.error('rename view failed: $error'); + return state.copyWith( + successOrFailure: FlowyResult.failure(error), + ); + }, + ), + ); + }, + delete: (e) async { + // unpublish the page and all its child pages if they are published + await _unpublishPage(view); + + final result = await ViewBackendService.deleteView(viewId: view.id); + + emit( + result.fold( + (l) { + return state.copyWith( + successOrFailure: FlowyResult.success(null), + isDeleted: true, + ); + }, + (error) => state.copyWith( + successOrFailure: FlowyResult.failure(error), + ), + ), + ); + await getIt().updateRecentViews( + [view.id], + false, + ); + }, + duplicate: (e) async { + final result = await ViewBackendService.duplicate( + view: view, + openAfterDuplicate: true, + syncAfterDuplicate: true, + includeChildren: true, + ); + emit( + result.fold( + (l) => + state.copyWith(successOrFailure: FlowyResult.success(null)), + (error) => state.copyWith( + successOrFailure: FlowyResult.failure(error), + ), + ), + ); + }, + move: (value) async { + final result = await ViewBackendService.moveViewV2( + viewId: value.from.id, + newParentId: value.newParentId, + prevViewId: value.prevId, + fromSection: value.fromSection, + toSection: value.toSection, + ); + emit( + result.fold( + (l) { + return state.copyWith( + successOrFailure: FlowyResult.success(null), + ); + }, + (error) => state.copyWith( + successOrFailure: FlowyResult.failure(error), + ), + ), + ); + }, + createView: (e) async { + final result = await ViewBackendService.createView( + parentViewId: view.id, + name: e.name, + layoutType: e.layoutType, + ext: {}, + openAfterCreate: e.openAfterCreated, + section: e.section, + ); + emit( + result.fold( + (view) => state.copyWith( + lastCreatedView: view, + successOrFailure: FlowyResult.success(null), + ), + (error) => state.copyWith( + successOrFailure: FlowyResult.failure(error), + ), + ), + ); + }, + viewUpdateChildView: (e) async { + emit( + state.copyWith( + view: e.result, + ), + ); + }, + updateViewVisibility: (value) async { + final view = value.view; + await ViewBackendService.updateViewsVisibility( + [view], + value.isPublic, + ); + }, + updateIcon: (value) async { + await ViewBackendService.updateViewIcon( + viewId: view.id, + viewIcon: view.icon.toEmojiIconData(), + ); + }, + collapseAllPages: (value) async { + for (final childView in view.childViews) { + await _setViewIsExpanded(childView, false); + } + add(const ViewEvent.setIsExpanded(false)); + }, + unpublish: (value) async { + if (value.sync) { + await _unpublishPage(view); + } else { + unawaited(_unpublishPage(view)); + } + }, + ); + }, + ); + } + + Future _loadViewsWhenExpanded( + Emitter emit, + bool isExpanded, + ) async { + if (!isExpanded) { + emit( + state.copyWith( + view: view, + isExpanded: false, + isLoading: false, + ), + ); + return; + } + + final viewsOrFailed = + await ViewBackendService.getChildViews(viewId: state.view.id); + + viewsOrFailed.fold( + (childViews) { + state.view.freeze(); + final viewWithChildViews = state.view.rebuild((b) { + b.childViews.clear(); + b.childViews.addAll(childViews); + }); + emit( + state.copyWith( + view: viewWithChildViews, + isExpanded: true, + isLoading: false, + ), + ); + }, + (error) => emit( + state.copyWith( + successOrFailure: FlowyResult.failure(error), + isExpanded: true, + isLoading: false, + ), + ), + ); + } + + Future _loadChildViews( + Emitter emit, + ) async { + final viewsOrFailed = + await ViewBackendService.getChildViews(viewId: state.view.id); + + viewsOrFailed.fold( + (childViews) { + state.view.freeze(); + final viewWithChildViews = state.view.rebuild((b) { + b.childViews.clear(); + b.childViews.addAll(childViews); + }); + emit( + state.copyWith( + view: viewWithChildViews, + ), + ); + }, + (error) => emit( + state.copyWith( + successOrFailure: FlowyResult.failure(error), + ), + ), + ); + } + + Future _setViewIsExpanded(ViewPB view, bool isExpanded) async { + final result = await getIt().get(KVKeys.expandedViews); + final Map map; + if (result != null) { + map = jsonDecode(result); + } else { + map = {}; + } + if (isExpanded) { + map[view.id] = true; + } else { + map.remove(view.id); + } + await getIt().set(KVKeys.expandedViews, jsonEncode(map)); + } + + Future _getViewIsExpanded(ViewPB view) { + return getIt().get(KVKeys.expandedViews).then((result) { + if (result == null) { + return false; + } + final map = jsonDecode(result); + return map[view.id] ?? false; + }); + } + + Future _updateChildViews( + ChildViewUpdatePB update, + ) async { + if (update.createChildViews.isNotEmpty) { + // refresh the child views if the update isn't empty + // because there's no info to get the inserted index. + assert(update.parentViewId == this.view.id); + final view = await ViewBackendService.getView( + update.parentViewId, + ); + return view.fold((l) => l, (r) => null); + } + + final view = state.view; + view.freeze(); + final childViews = [...view.childViews]; + if (update.deleteChildViews.isNotEmpty) { + childViews.removeWhere((v) => update.deleteChildViews.contains(v.id)); + return view.rebuild((p0) { + p0.childViews.clear(); + p0.childViews.addAll(childViews); + }); + } + + if (update.updateChildViews.isNotEmpty) { + final view = await ViewBackendService.getView(update.parentViewId); + final childViews = view.fold((l) => l.childViews, (r) => []); + bool isSameOrder = true; + if (childViews.length == update.updateChildViews.length) { + for (var i = 0; i < childViews.length; i++) { + if (childViews[i].id != update.updateChildViews[i].id) { + isSameOrder = false; + break; + } + } + } else { + isSameOrder = false; + } + if (!isSameOrder) { + return view.fold((l) => l, (r) => null); + } + } + + return null; + } + + // unpublish the page and all its child pages + Future _unpublishPage(ViewPB views) async { + final (_, publishedPages) = await ViewBackendService.containPublishedPage( + view, + ); + + await Future.wait( + publishedPages.map((view) async { + Log.info('unpublishing page: ${view.id}, ${view.name}'); + await ViewBackendService.unpublish(view); + }), + ); + } + + bool _isSameViewIgnoreChildren(ViewPB from, ViewPB to) { + return _hash(from) == _hash(to); + } + + int _hash(ViewPB view) => Object.hash( + view.id, + view.name, + view.createTime, + view.icon, + view.parentViewId, + view.layout, + ); +} + +@freezed +class ViewEvent with _$ViewEvent { + const factory ViewEvent.initial() = Initial; + + const factory ViewEvent.setIsEditing(bool isEditing) = SetEditing; + + const factory ViewEvent.setIsExpanded(bool isExpanded) = SetIsExpanded; + + const factory ViewEvent.rename(String newName) = Rename; + + const factory ViewEvent.delete() = Delete; + + const factory ViewEvent.duplicate() = Duplicate; + + const factory ViewEvent.move( + ViewPB from, + String newParentId, + String? prevId, + ViewSectionPB? fromSection, + ViewSectionPB? toSection, + ) = Move; + + const factory ViewEvent.createView( + String name, + ViewLayoutPB layoutType, { + /// open the view after created + @Default(true) bool openAfterCreated, + ViewSectionPB? section, + }) = CreateView; + + const factory ViewEvent.viewDidUpdate( + FlowyResult result, + ) = ViewDidUpdate; + + const factory ViewEvent.viewUpdateChildView(ViewPB result) = + ViewUpdateChildView; + + const factory ViewEvent.updateViewVisibility( + ViewPB view, + bool isPublic, + ) = UpdateViewVisibility; + + const factory ViewEvent.updateIcon(String? icon) = UpdateIcon; + + const factory ViewEvent.collapseAllPages() = CollapseAllPages; + + // this event will unpublish the page and all its child pages if they are published + const factory ViewEvent.unpublish({required bool sync}) = Unpublish; +} + +@freezed +class ViewState with _$ViewState { + const factory ViewState({ + required ViewPB view, + required bool isEditing, + required bool isExpanded, + required FlowyResult successOrFailure, + @Default(false) bool isDeleted, + @Default(true) bool isLoading, + @Default(null) ViewPB? lastCreatedView, + }) = _ViewState; + + factory ViewState.init(ViewPB view) => ViewState( + view: view, + isExpanded: false, + isEditing: false, + successOrFailure: FlowyResult.success(null), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart new file mode 100644 index 0000000000000..375d8f45346ad --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart @@ -0,0 +1,358 @@ +import 'dart:convert'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; +import 'package:appflowy/plugins/ai_chat/chat.dart'; +import 'package:appflowy/plugins/database/board/presentation/board_page.dart'; +import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart'; +import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart'; +import 'package:appflowy/plugins/database/grid/presentation/mobile_grid_page.dart'; +import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart'; +import 'package:appflowy/plugins/document/document.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class PluginArgumentKeys { + static String selection = "selection"; + static String rowId = "row_id"; + static String blockId = "block_id"; +} + +class ViewExtKeys { + // used for customizing the font family. + static String fontKey = 'font'; + + // used for customizing the font layout. + static String fontLayoutKey = 'font_layout'; + + // used for customizing the line height layout. + static String lineHeightLayoutKey = 'line_height_layout'; + + // cover keys + static String coverKey = 'cover'; + static String coverTypeKey = 'type'; + static String coverValueKey = 'value'; + + // is pinned + static String isPinnedKey = 'is_pinned'; + + // space + static String isSpaceKey = 'is_space'; + static String spaceCreatorKey = 'space_creator'; + static String spaceCreatedAtKey = 'space_created_at'; + static String spaceIconKey = 'space_icon'; + static String spaceIconColorKey = 'space_icon_color'; + static String spacePermissionKey = 'space_permission'; +} + +extension MinimalViewExtension on FolderViewMinimalPB { + Widget defaultIcon({Size? size}) => FlowySvg( + switch (layout) { + ViewLayoutPB.Board => FlowySvgs.icon_board_s, + ViewLayoutPB.Calendar => FlowySvgs.icon_calendar_s, + ViewLayoutPB.Grid => FlowySvgs.icon_grid_s, + ViewLayoutPB.Document => FlowySvgs.icon_document_s, + ViewLayoutPB.Chat => FlowySvgs.chat_ai_page_s, + _ => FlowySvgs.icon_document_s, + }, + size: size, + ); +} + +extension ViewExtension on ViewPB { + String get nameOrDefault => + name.isEmpty ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() : name; + + Widget defaultIcon({Size? size}) => FlowySvg( + switch (layout) { + ViewLayoutPB.Board => FlowySvgs.icon_board_s, + ViewLayoutPB.Calendar => FlowySvgs.icon_calendar_s, + ViewLayoutPB.Grid => FlowySvgs.icon_grid_s, + ViewLayoutPB.Document => FlowySvgs.icon_document_s, + ViewLayoutPB.Chat => FlowySvgs.chat_ai_page_s, + _ => FlowySvgs.icon_document_s, + }, + size: size, + ); + + PluginType get pluginType => switch (layout) { + ViewLayoutPB.Board => PluginType.board, + ViewLayoutPB.Calendar => PluginType.calendar, + ViewLayoutPB.Document => PluginType.document, + ViewLayoutPB.Grid => PluginType.grid, + ViewLayoutPB.Chat => PluginType.chat, + _ => throw UnimplementedError(), + }; + + Plugin plugin({ + Map arguments = const {}, + }) { + switch (layout) { + case ViewLayoutPB.Board: + case ViewLayoutPB.Calendar: + case ViewLayoutPB.Grid: + final String? rowId = arguments[PluginArgumentKeys.rowId]; + + return DatabaseTabBarViewPlugin( + view: this, + pluginType: pluginType, + initialRowId: rowId, + ); + case ViewLayoutPB.Document: + final Selection? initialSelection = + arguments[PluginArgumentKeys.selection]; + final String? initialBlockId = arguments[PluginArgumentKeys.blockId]; + + return DocumentPlugin( + view: this, + pluginType: pluginType, + initialSelection: initialSelection, + initialBlockId: initialBlockId, + ); + case ViewLayoutPB.Chat: + return AIChatPagePlugin(view: this); + } + throw UnimplementedError; + } + + DatabaseTabBarItemBuilder tabBarItem() => switch (layout) { + ViewLayoutPB.Board => BoardPageTabBarBuilderImpl(), + ViewLayoutPB.Calendar => CalendarPageTabBarBuilderImpl(), + ViewLayoutPB.Grid => DesktopGridTabBarBuilderImpl(), + _ => throw UnimplementedError, + }; + + DatabaseTabBarItemBuilder mobileTabBarItem() => switch (layout) { + ViewLayoutPB.Board => BoardPageTabBarBuilderImpl(), + ViewLayoutPB.Calendar => CalendarPageTabBarBuilderImpl(), + ViewLayoutPB.Grid => MobileGridTabBarBuilderImpl(), + _ => throw UnimplementedError, + }; + + FlowySvgData get iconData => layout.icon; + + bool get isSpace { + try { + if (extra.isEmpty) { + return false; + } + + final ext = jsonDecode(extra); + final isSpace = ext[ViewExtKeys.isSpaceKey] ?? false; + return isSpace; + } catch (e) { + return false; + } + } + + SpacePermission get spacePermission { + try { + final ext = jsonDecode(extra); + final permission = ext[ViewExtKeys.spacePermissionKey] ?? 1; + return SpacePermission.values[permission]; + } catch (e) { + return SpacePermission.private; + } + } + + FlowySvg? buildSpaceIconSvg(BuildContext context, {Size? size}) { + try { + if (extra.isEmpty) { + return null; + } + + final ext = jsonDecode(extra); + final icon = ext[ViewExtKeys.spaceIconKey]; + final color = ext[ViewExtKeys.spaceIconColorKey]; + if (icon == null || color == null) { + return null; + } + // before version 0.6.7 + if (icon.contains('space_icon')) { + return FlowySvg( + FlowySvgData('assets/flowy_icons/16x/$icon.svg'), + color: Theme.of(context).colorScheme.surface, + ); + } + + final values = icon.split('/'); + if (values.length != 2) { + return null; + } + final groupName = values[0]; + final iconName = values[1]; + final svgString = kIconGroups + ?.firstWhereOrNull( + (group) => group.name == groupName, + ) + ?.icons + .firstWhereOrNull( + (icon) => icon.name == iconName, + ) + ?.content; + if (svgString == null) { + return null; + } + return FlowySvg.string( + svgString, + color: Theme.of(context).colorScheme.surface, + size: size, + ); + } catch (e) { + return null; + } + } + + String? get spaceIcon { + try { + final ext = jsonDecode(extra); + final icon = ext[ViewExtKeys.spaceIconKey]; + return icon; + } catch (e) { + return null; + } + } + + String? get spaceIconColor { + try { + final ext = jsonDecode(extra); + final color = ext[ViewExtKeys.spaceIconColorKey]; + return color; + } catch (e) { + return null; + } + } + + bool get isPinned { + try { + final ext = jsonDecode(extra); + final isPinned = ext[ViewExtKeys.isPinnedKey] ?? false; + return isPinned; + } catch (e) { + return false; + } + } + + PageStyleCover? get cover { + if (layout != ViewLayoutPB.Document) { + return null; + } + + if (extra.isEmpty) { + return null; + } + + try { + final ext = jsonDecode(extra); + final cover = ext[ViewExtKeys.coverKey] ?? {}; + final coverType = cover[ViewExtKeys.coverTypeKey] ?? + PageStyleCoverImageType.none.toString(); + final coverValue = cover[ViewExtKeys.coverValueKey] ?? ''; + return PageStyleCover( + type: PageStyleCoverImageType.fromString(coverType), + value: coverValue, + ); + } catch (e) { + return null; + } + } + + PageStyleLineHeightLayout get lineHeightLayout { + if (layout != ViewLayoutPB.Document) { + return PageStyleLineHeightLayout.normal; + } + try { + final ext = jsonDecode(extra); + final lineHeight = ext[ViewExtKeys.lineHeightLayoutKey]; + return PageStyleLineHeightLayout.fromString(lineHeight); + } catch (e) { + return PageStyleLineHeightLayout.normal; + } + } + + PageStyleFontLayout get fontLayout { + if (layout != ViewLayoutPB.Document) { + return PageStyleFontLayout.normal; + } + try { + final ext = jsonDecode(extra); + final fontLayout = ext[ViewExtKeys.fontLayoutKey]; + return PageStyleFontLayout.fromString(fontLayout); + } catch (e) { + return PageStyleFontLayout.normal; + } + } +} + +extension ViewLayoutExtension on ViewLayoutPB { + FlowySvgData get icon => switch (this) { + ViewLayoutPB.Board => FlowySvgs.icon_board_s, + ViewLayoutPB.Calendar => FlowySvgs.icon_calendar_s, + ViewLayoutPB.Grid => FlowySvgs.icon_grid_s, + ViewLayoutPB.Document => FlowySvgs.icon_document_s, + ViewLayoutPB.Chat => FlowySvgs.chat_ai_page_s, + _ => FlowySvgs.icon_document_s, + }; + + bool get isDocumentView => switch (this) { + ViewLayoutPB.Document => true, + ViewLayoutPB.Chat || + ViewLayoutPB.Grid || + ViewLayoutPB.Board || + ViewLayoutPB.Calendar => + false, + _ => throw Exception('Unknown layout type'), + }; + + bool get isDatabaseView => switch (this) { + ViewLayoutPB.Grid || + ViewLayoutPB.Board || + ViewLayoutPB.Calendar => + true, + ViewLayoutPB.Document || ViewLayoutPB.Chat => false, + _ => throw Exception('Unknown layout type'), + }; + + String get defaultName => switch (this) { + ViewLayoutPB.Document => '', + _ => LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + }; + + bool get shrinkWrappable => switch (this) { + ViewLayoutPB.Grid => true, + _ => false, + }; + + double get pluginHeight => switch (this) { + ViewLayoutPB.Document || ViewLayoutPB.Board || ViewLayoutPB.Chat => 450, + ViewLayoutPB.Calendar => 650, + ViewLayoutPB.Grid => double.infinity, + _ => throw UnimplementedError(), + }; +} + +extension ViewFinder on List { + ViewPB? findView(String id) { + for (final view in this) { + if (view.id == id) { + return view; + } + + if (view.childViews.isNotEmpty) { + final v = view.childViews.findView(id); + if (v != null) { + return v; + } + } + } + + return null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_listener.dart new file mode 100644 index 0000000000000..95505b8216541 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_listener.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/folder_notification.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart'; +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +// Delete the view from trash, which means the view was deleted permanently +typedef DeleteViewNotifyValue = FlowyResult; +// The view get updated +typedef UpdateViewNotifiedValue = ViewPB; +// Restore the view from trash +typedef RestoreViewNotifiedValue = FlowyResult; +// Move the view to trash +typedef MoveToTrashNotifiedValue = FlowyResult; + +class ViewListener { + ViewListener({required this.viewId}); + + StreamSubscription? _subscription; + void Function(UpdateViewNotifiedValue)? _updatedViewNotifier; + void Function(ChildViewUpdatePB)? _updateViewChildViewsNotifier; + void Function(DeleteViewNotifyValue)? _deletedNotifier; + void Function(RestoreViewNotifiedValue)? _restoredNotifier; + void Function(MoveToTrashNotifiedValue)? _moveToTrashNotifier; + bool _isDisposed = false; + + FolderNotificationParser? _parser; + final String viewId; + + void start({ + void Function(UpdateViewNotifiedValue)? onViewUpdated, + void Function(ChildViewUpdatePB)? onViewChildViewsUpdated, + void Function(DeleteViewNotifyValue)? onViewDeleted, + void Function(RestoreViewNotifiedValue)? onViewRestored, + void Function(MoveToTrashNotifiedValue)? onViewMoveToTrash, + }) { + if (_isDisposed) { + Log.warn("ViewListener is already disposed"); + return; + } + + _updatedViewNotifier = onViewUpdated; + _deletedNotifier = onViewDeleted; + _restoredNotifier = onViewRestored; + _moveToTrashNotifier = onViewMoveToTrash; + _updateViewChildViewsNotifier = onViewChildViewsUpdated; + + _parser = FolderNotificationParser( + id: viewId, + callback: (ty, result) { + _handleObservableType(ty, result); + }, + ); + + _subscription = + RustStreamReceiver.listen((observable) => _parser?.parse(observable)); + } + + void _handleObservableType( + FolderNotification ty, + FlowyResult result, + ) { + switch (ty) { + case FolderNotification.DidUpdateView: + result.fold( + (payload) { + final view = ViewPB.fromBuffer(payload); + _updatedViewNotifier?.call(view); + }, + (error) => Log.error(error), + ); + break; + case FolderNotification.DidUpdateChildViews: + result.fold( + (payload) { + final pb = ChildViewUpdatePB.fromBuffer(payload); + _updateViewChildViewsNotifier?.call(pb); + }, + (error) => Log.error(error), + ); + break; + case FolderNotification.DidDeleteView: + result.fold( + (payload) => _deletedNotifier + ?.call(FlowyResult.success(ViewPB.fromBuffer(payload))), + (error) => _deletedNotifier?.call(FlowyResult.failure(error)), + ); + break; + case FolderNotification.DidRestoreView: + result.fold( + (payload) => _restoredNotifier + ?.call(FlowyResult.success(ViewPB.fromBuffer(payload))), + (error) => _restoredNotifier?.call(FlowyResult.failure(error)), + ); + break; + case FolderNotification.DidMoveViewToTrash: + result.fold( + (payload) => _moveToTrashNotifier + ?.call(FlowyResult.success(DeletedViewPB.fromBuffer(payload))), + (error) => _moveToTrashNotifier?.call(FlowyResult.failure(error)), + ); + break; + default: + break; + } + } + + Future stop() async { + _isDisposed = true; + _parser = null; + await _subscription?.cancel(); + _updatedViewNotifier = null; + _deletedNotifier = null; + _restoredNotifier = null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart new file mode 100644 index 0000000000000..d20aed8c1bc6f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart @@ -0,0 +1,395 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_bloc.dart'; +import 'package:appflowy/plugins/trash/application/trash_service.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; + +class ViewBackendService { + static Future> createView({ + /// The [layoutType] is the type of the view. + required ViewLayoutPB layoutType, + + /// The [parentViewId] is the parent view id. + required String parentViewId, + + /// The [name] is the name of the view. + required String name, + + /// The default value of [openAfterCreate] is false, meaning the view will + /// not be opened nor set as the current view. However, if set to true, the + /// view will be opened and set as the current view. Upon relaunching the + /// app, this view will be opened + bool openAfterCreate = false, + + /// The initial data should be a JSON that represent the DocumentDataPB. + /// Currently, only support create document with initial data. + List? initialDataBytes, + + /// The [ext] is used to pass through the custom configuration + /// to the backend. + /// Linking the view to the existing database, it needs to pass + /// the database id. For example: "database_id": "xxx" + /// + Map ext = const {}, + + /// The [index] is the index of the view in the parent view. + /// If the index is null, the view will be added to the end of the list. + int? index, + ViewSectionPB? section, + final String? viewId, + }) { + final payload = CreateViewPayloadPB.create() + ..parentViewId = parentViewId + ..name = name + ..layout = layoutType + ..setAsCurrent = openAfterCreate + ..initialData = initialDataBytes ?? []; + + if (ext.isNotEmpty) { + payload.meta.addAll(ext); + } + + if (index != null) { + payload.index = index; + } + + if (section != null) { + payload.section = section; + } + + if (viewId != null) { + payload.viewId = viewId; + } + + return FolderEventCreateView(payload).send(); + } + + /// The orphan view is meant to be a view that is not attached to any parent view. By default, this + /// view will not be shown in the view list unless it is attached to a parent view that is shown in + /// the view list. + static Future> createOrphanView({ + required String viewId, + required ViewLayoutPB layoutType, + required String name, + String? desc, + + /// The initial data should be a JSON that represent the DocumentDataPB. + /// Currently, only support create document with initial data. + List? initialDataBytes, + }) { + final payload = CreateOrphanViewPayloadPB.create() + ..viewId = viewId + ..name = name + ..layout = layoutType + ..initialData = initialDataBytes ?? []; + + return FolderEventCreateOrphanView(payload).send(); + } + + static Future> createDatabaseLinkedView({ + required String parentViewId, + required String databaseId, + required ViewLayoutPB layoutType, + required String name, + }) { + return createView( + layoutType: layoutType, + parentViewId: parentViewId, + name: name, + ext: {'database_id': databaseId}, + ); + } + + /// Returns a list of views that are the children of the given [viewId]. + static Future, FlowyError>> getChildViews({ + required String viewId, + }) { + final payload = ViewIdPB.create()..value = viewId; + + return FolderEventGetView(payload).send().then((result) { + return result.fold( + (view) => FlowyResult.success(view.childViews), + (error) => FlowyResult.failure(error), + ); + }); + } + + static Future> deleteView({ + required String viewId, + }) { + final request = RepeatedViewIdPB.create()..items.add(viewId); + return FolderEventDeleteView(request).send(); + } + + static Future> deleteViews({ + required List viewIds, + }) { + final request = RepeatedViewIdPB.create()..items.addAll(viewIds); + return FolderEventDeleteView(request).send(); + } + + static Future> duplicate({ + required ViewPB view, + required bool openAfterDuplicate, + // should include children views + required bool includeChildren, + String? parentViewId, + String? suffix, + required bool syncAfterDuplicate, + }) { + final payload = DuplicateViewPayloadPB.create() + ..viewId = view.id + ..openAfterDuplicate = openAfterDuplicate + ..includeChildren = includeChildren + ..syncAfterCreate = syncAfterDuplicate; + + if (parentViewId != null) { + payload.parentViewId = parentViewId; + } + + if (suffix != null) { + payload.suffix = suffix; + } + + return FolderEventDuplicateView(payload).send(); + } + + static Future> favorite({ + required String viewId, + }) { + final request = RepeatedViewIdPB.create()..items.add(viewId); + return FolderEventToggleFavorite(request).send(); + } + + static Future> updateView({ + required String viewId, + String? name, + bool? isFavorite, + String? extra, + }) { + final payload = UpdateViewPayloadPB.create()..viewId = viewId; + + if (name != null) { + payload.name = name; + } + + if (isFavorite != null) { + payload.isFavorite = isFavorite; + } + + if (extra != null) { + payload.extra = extra; + } + + return FolderEventUpdateView(payload).send(); + } + + static Future> updateViewIcon({ + required String viewId, + required EmojiIconData viewIcon, + }) { + final icon = viewIcon.toViewIcon(); + final payload = UpdateViewIconPayloadPB.create() + ..viewId = viewId + ..icon = icon; + + return FolderEventUpdateViewIcon(payload).send(); + } + + // deprecated + static Future> moveView({ + required String viewId, + required int fromIndex, + required int toIndex, + }) { + final payload = MoveViewPayloadPB.create() + ..viewId = viewId + ..from = fromIndex + ..to = toIndex; + + return FolderEventMoveView(payload).send(); + } + + /// Move the view to the new parent view. + /// + /// supports nested view + /// if the [prevViewId] is null, the view will be moved to the beginning of the list + static Future> moveViewV2({ + required String viewId, + required String newParentId, + required String? prevViewId, + ViewSectionPB? fromSection, + ViewSectionPB? toSection, + }) { + final payload = MoveNestedViewPayloadPB( + viewId: viewId, + newParentId: newParentId, + prevViewId: prevViewId, + fromSection: fromSection, + toSection: toSection, + ); + + return FolderEventMoveNestedView(payload).send(); + } + + /// Fetches a flattened list of all Views. + /// + /// Views do not contain their children in this list, as they all exist + /// in the same level in this version. + /// + static Future> getAllViews() async { + return FolderEventGetAllViews().send(); + } + + static Future> getView( + String viewId, + ) async { + final payload = ViewIdPB.create()..value = viewId; + return FolderEventGetView(payload).send(); + } + + static Future getMentionPageStatus(String pageId) async { + final view = await ViewBackendService.getView(pageId).then( + (value) => value.toNullable(), + ); + + // found the page + if (view != null) { + return (view, false, false); + } + + // if the view is not found, try to fetch from trash + final trashViews = await TrashService().readTrash(); + final trash = trashViews.fold( + (l) => l.items.firstWhereOrNull((element) => element.id == pageId), + (r) => null, + ); + if (trash != null) { + final trashView = ViewPB() + ..id = trash.id + ..name = trash.name; + return (trashView, true, false); + } + + // the page was deleted + return (null, false, true); + } + + static Future> getViewAncestors( + String viewId, + ) async { + final payload = ViewIdPB.create()..value = viewId; + return FolderEventGetViewAncestors(payload).send(); + } + + Future> getChildView({ + required String parentViewId, + required String childViewId, + }) async { + final payload = ViewIdPB.create()..value = parentViewId; + return FolderEventGetView(payload).send().then((result) { + return result.fold( + (app) => FlowyResult.success( + app.childViews.firstWhere((e) => e.id == childViewId), + ), + (error) => FlowyResult.failure(error), + ); + }); + } + + static Future> updateViewsVisibility( + List views, + bool isPublic, + ) async { + final payload = UpdateViewVisibilityStatusPayloadPB( + viewIds: views.map((e) => e.id).toList(), + isPublic: isPublic, + ); + return FolderEventUpdateViewVisibilityStatus(payload).send(); + } + + static Future> getPublishInfo( + ViewPB view, + ) async { + final payload = ViewIdPB()..value = view.id; + return FolderEventGetPublishInfo(payload).send(); + } + + static Future> publish( + ViewPB view, { + String? name, + List? selectedViewIds, + }) async { + final payload = PublishViewParamsPB()..viewId = view.id; + + if (name != null) { + payload.publishName = name; + } + + if (selectedViewIds != null && selectedViewIds.isNotEmpty) { + payload.selectedViewIds = RepeatedViewIdPB(items: selectedViewIds); + } + + return FolderEventPublishView(payload).send(); + } + + static Future> unpublish( + ViewPB view, + ) async { + final payload = UnpublishViewsPayloadPB(viewIds: [view.id]); + return FolderEventUnpublishViews(payload).send(); + } + + static Future> setPublishNameSpace( + String name, + ) async { + final payload = SetPublishNamespacePayloadPB()..newNamespace = name; + return FolderEventSetPublishNamespace(payload).send(); + } + + static Future> + getPublishNameSpace() async { + return FolderEventGetPublishNamespace().send(); + } + + static Future> getAllChildViews(ViewPB view) async { + final views = []; + + final childViews = + await ViewBackendService.getChildViews(viewId: view.id).fold( + (s) => s, + (f) => [], + ); + + for (final child in childViews) { + // filter the view itself + if (child.id == view.id) { + continue; + } + views.add(child); + views.addAll(await getAllChildViews(child)); + } + + return views; + } + + static Future<(bool, List)> containPublishedPage(ViewPB view) async { + final childViews = await ViewBackendService.getAllChildViews(view); + final views = [view, ...childViews]; + final List publishedPages = []; + + for (final view in views) { + final publishInfo = await ViewBackendService.getPublishInfo(view); + if (publishInfo.isSuccess) { + publishedPages.add(view); + } + } + + return (publishedPages.isNotEmpty, publishedPages); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/view_info/view_info_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view_info/view_info_bloc.dart new file mode 100644 index 0000000000000..b24c4f6a9a441 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/view_info/view_info_bloc.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/util/int64_extension.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:bloc/bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'view_info_bloc.freezed.dart'; + +class ViewInfoBloc extends Bloc { + ViewInfoBloc({required this.view}) : super(ViewInfoState.initial()) { + on((event, emit) { + event.when( + started: () { + emit(state.copyWith(createdAt: view.createTime.toDateTime())); + }, + unregisterEditorState: () { + _clearWordCountService(); + emit(state.copyWith(documentCounters: null)); + }, + registerEditorState: (editorState) { + _clearWordCountService(); + _wordCountService = WordCountService(editorState: editorState) + ..addListener(_onWordCountChanged) + ..register(); + + emit( + state.copyWith( + documentCounters: _wordCountService!.documentCounters, + ), + ); + }, + wordCountChanged: () { + emit( + state.copyWith( + documentCounters: _wordCountService?.documentCounters, + ), + ); + }, + ); + }); + } + + final ViewPB view; + + WordCountService? _wordCountService; + + @override + Future close() async { + _clearWordCountService(); + await super.close(); + } + + void _onWordCountChanged() => add(const ViewInfoEvent.wordCountChanged()); + + void _clearWordCountService() { + _wordCountService + ?..removeListener(_onWordCountChanged) + ..dispose(); + _wordCountService = null; + } +} + +@freezed +class ViewInfoEvent with _$ViewInfoEvent { + const factory ViewInfoEvent.started() = _Started; + + const factory ViewInfoEvent.unregisterEditorState() = _UnregisterEditorState; + + const factory ViewInfoEvent.registerEditorState({ + required EditorState editorState, + }) = _RegisterEditorState; + + const factory ViewInfoEvent.wordCountChanged() = _WordCountChanged; +} + +@freezed +class ViewInfoState with _$ViewInfoState { + const factory ViewInfoState({ + required Counters? documentCounters, + required DateTime? createdAt, + }) = _ViewInfoState; + + factory ViewInfoState.initial() => const ViewInfoState( + documentCounters: null, + createdAt: null, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/view_title/view_title_bar_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view_title/view_title_bar_bloc.dart new file mode 100644 index 0000000000000..1530c96d32b79 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/view_title/view_title_bar_bloc.dart @@ -0,0 +1,92 @@ +import 'package:appflowy/plugins/trash/application/prelude.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'view_title_bar_bloc.freezed.dart'; + +class ViewTitleBarBloc extends Bloc { + ViewTitleBarBloc({required this.view}) : super(ViewTitleBarState.initial()) { + on( + (event, emit) async { + await event.when( + reload: () async { + final List ancestors = + await ViewBackendService.getViewAncestors(view.id).fold( + (s) => s.items, + (f) => [], + ); + + final isDeleted = (await trashService.readTrash()).fold( + (s) => s.items.any((t) => t.id == view.id), + (f) => false, + ); + + emit(state.copyWith(ancestors: ancestors, isDeleted: isDeleted)); + }, + trashUpdated: (trash) { + if (trash.any((t) => t.id == view.id)) { + emit(state.copyWith(isDeleted: true)); + } + }, + ); + }, + ); + + trashService = TrashService(); + viewListener = ViewListener(viewId: view.id) + ..start( + onViewChildViewsUpdated: (_) { + if (!isClosed) { + add(const ViewTitleBarEvent.reload()); + } + }, + ); + trashListener = TrashListener() + ..start( + trashUpdated: (trashOrFailed) { + final trash = trashOrFailed.toNullable(); + if (trash != null && !isClosed) { + add(ViewTitleBarEvent.trashUpdated(trash: trash)); + } + }, + ); + + if (!isClosed) { + add(const ViewTitleBarEvent.reload()); + } + } + + final ViewPB view; + late final TrashService trashService; + late final ViewListener viewListener; + late final TrashListener trashListener; + + @override + Future close() { + trashListener.close(); + viewListener.stop(); + return super.close(); + } +} + +@freezed +class ViewTitleBarEvent with _$ViewTitleBarEvent { + const factory ViewTitleBarEvent.reload() = Reload; + const factory ViewTitleBarEvent.trashUpdated({ + required List trash, + }) = TrashUpdated; +} + +@freezed +class ViewTitleBarState with _$ViewTitleBarState { + const factory ViewTitleBarState({ + required List ancestors, + @Default(false) bool isDeleted, + }) = _ViewTitleBarState; + + factory ViewTitleBarState.initial() => const ViewTitleBarState(ancestors: []); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/view_title/view_title_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view_title/view_title_bloc.dart new file mode 100644 index 0000000000000..fdb9dc9321abe --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/view_title/view_title_bloc.dart @@ -0,0 +1,86 @@ +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; + +part 'view_title_bloc.freezed.dart'; + +class ViewTitleBloc extends Bloc { + ViewTitleBloc({ + required this.view, + }) : viewListener = ViewListener(viewId: view.id), + super(ViewTitleState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + emit( + state.copyWith( + name: view.name, + icon: view.icon.toEmojiIconData(), + view: view, + ), + ); + + viewListener.start( + onViewUpdated: (view) { + add( + ViewTitleEvent.updateNameOrIcon( + view.name, + view.icon.toEmojiIconData(), + view, + ), + ); + }, + ); + }, + updateNameOrIcon: (name, icon, view) async { + emit( + state.copyWith( + name: name, + icon: icon, + view: view, + ), + ); + }, + ); + }, + ); + } + + final ViewPB view; + final ViewListener viewListener; + + @override + Future close() { + viewListener.stop(); + return super.close(); + } +} + +@freezed +class ViewTitleEvent with _$ViewTitleEvent { + const factory ViewTitleEvent.initial() = Initial; + + const factory ViewTitleEvent.updateNameOrIcon( + String name, + EmojiIconData icon, + ViewPB? view, + ) = UpdateNameOrIcon; +} + +@freezed +class ViewTitleState with _$ViewTitleState { + const factory ViewTitleState({ + required String name, + required EmojiIconData icon, + @Default(null) ViewPB? view, + }) = _ViewTitleState; + + factory ViewTitleState.initial() => ViewTitleState( + name: '', + icon: EmojiIconData.none(), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/prelude.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/prelude.dart new file mode 100644 index 0000000000000..71145b8e505d3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/prelude.dart @@ -0,0 +1,3 @@ +export 'workspace_bloc.dart'; +export 'workspace_listener.dart'; +export 'workspace_service.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart new file mode 100644 index 0000000000000..d8d5db45b47b0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_bloc.dart @@ -0,0 +1,105 @@ +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'workspace_bloc.freezed.dart'; + +class WorkspaceBloc extends Bloc { + WorkspaceBloc({required this.userService}) : super(WorkspaceState.initial()) { + _dispatch(); + } + + final UserBackendService userService; + + void _dispatch() { + on( + (event, emit) async { + await event.map( + initial: (e) async { + await _fetchWorkspaces(emit); + }, + createWorkspace: (e) async { + await _createWorkspace(e.name, e.desc, emit); + }, + workspacesReceived: (e) async { + emit( + e.workspacesOrFail.fold( + (workspaces) => state.copyWith( + workspaces: workspaces, + successOrFailure: FlowyResult.success(null), + ), + (error) => state.copyWith( + successOrFailure: FlowyResult.failure(error), + ), + ), + ); + }, + ); + }, + ); + } + + Future _fetchWorkspaces(Emitter emit) async { + final workspacesOrFailed = await userService.getWorkspaces(); + emit( + workspacesOrFailed.fold( + (workspaces) => state.copyWith( + workspaces: [], + successOrFailure: FlowyResult.success(null), + ), + (error) { + Log.error(error); + return state.copyWith(successOrFailure: FlowyResult.failure(error)); + }, + ), + ); + } + + Future _createWorkspace( + String name, + String desc, + Emitter emit, + ) async { + final result = await userService.createWorkspace(name, desc); + emit( + result.fold( + (workspace) { + return state.copyWith(successOrFailure: FlowyResult.success(null)); + }, + (error) { + Log.error(error); + return state.copyWith(successOrFailure: FlowyResult.failure(error)); + }, + ), + ); + } +} + +@freezed +class WorkspaceEvent with _$WorkspaceEvent { + const factory WorkspaceEvent.initial() = Initial; + const factory WorkspaceEvent.createWorkspace(String name, String desc) = + CreateWorkspace; + const factory WorkspaceEvent.workspacesReceived( + FlowyResult, FlowyError> workspacesOrFail, + ) = WorkspacesReceived; +} + +@freezed +class WorkspaceState with _$WorkspaceState { + const factory WorkspaceState({ + required bool isLoading, + required List workspaces, + required FlowyResult successOrFailure, + }) = _WorkspaceState; + + factory WorkspaceState.initial() => WorkspaceState( + isLoading: false, + workspaces: List.empty(), + successOrFailure: FlowyResult.success(null), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_listener.dart new file mode 100644 index 0000000000000..fb3beb4dd569a --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_listener.dart @@ -0,0 +1,85 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/folder_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +typedef RootViewsNotifyValue = FlowyResult, FlowyError>; +typedef WorkspaceNotifyValue = FlowyResult; + +/// The [WorkspaceListener] listens to the changes including the below: +/// +/// - The root views of the workspace. (Not including the views are inside the root views) +/// - The workspace itself. +class WorkspaceListener { + WorkspaceListener({required this.user, required this.workspaceId}); + + final UserProfilePB user; + final String workspaceId; + + PublishNotifier? _appsChangedNotifier = + PublishNotifier(); + PublishNotifier? _workspaceUpdatedNotifier = + PublishNotifier(); + + FolderNotificationListener? _listener; + + void start({ + void Function(RootViewsNotifyValue)? appsChanged, + void Function(WorkspaceNotifyValue)? onWorkspaceUpdated, + }) { + if (appsChanged != null) { + _appsChangedNotifier?.addPublishListener(appsChanged); + } + + if (onWorkspaceUpdated != null) { + _workspaceUpdatedNotifier?.addPublishListener(onWorkspaceUpdated); + } + + _listener = FolderNotificationListener( + objectId: workspaceId, + handler: _handleObservableType, + ); + } + + void _handleObservableType( + FolderNotification ty, + FlowyResult result, + ) { + switch (ty) { + case FolderNotification.DidUpdateWorkspace: + result.fold( + (payload) => _workspaceUpdatedNotifier?.value = + FlowyResult.success(WorkspacePB.fromBuffer(payload)), + (error) => + _workspaceUpdatedNotifier?.value = FlowyResult.failure(error), + ); + break; + case FolderNotification.DidUpdateWorkspaceViews: + result.fold( + (payload) => _appsChangedNotifier?.value = + FlowyResult.success(RepeatedViewPB.fromBuffer(payload).items), + (error) => _appsChangedNotifier?.value = FlowyResult.failure(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _appsChangedNotifier?.dispose(); + _appsChangedNotifier = null; + + _workspaceUpdatedNotifier?.dispose(); + _workspaceUpdatedNotifier = null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_sections_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_sections_listener.dart new file mode 100644 index 0000000000000..73c2a9045f466 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_sections_listener.dart @@ -0,0 +1,68 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:appflowy/core/notification/folder_notification.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/notification.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flowy_infra/notifier.dart'; + +typedef SectionNotifyValue = FlowyResult; + +/// The [WorkspaceSectionsListener] listens to the changes including the below: +/// +/// - The root views inside different section of the workspace. (Not including the views are inside the root views) +/// depends on the section type(s). +class WorkspaceSectionsListener { + WorkspaceSectionsListener({ + required this.user, + required this.workspaceId, + }); + + final UserProfilePB user; + final String workspaceId; + + final _sectionNotifier = PublishNotifier(); + late final FolderNotificationListener _listener; + + void start({ + void Function(SectionNotifyValue)? sectionChanged, + }) { + if (sectionChanged != null) { + _sectionNotifier.addPublishListener(sectionChanged); + } + + _listener = FolderNotificationListener( + objectId: workspaceId, + handler: _handleObservableType, + ); + } + + void _handleObservableType( + FolderNotification ty, + FlowyResult result, + ) { + switch (ty) { + case FolderNotification.DidUpdateSectionViews: + final FlowyResult value = result.fold( + (s) => FlowyResult.success( + SectionViewsPB.fromBuffer(s), + ), + (f) => FlowyResult.failure(f), + ); + _sectionNotifier.value = value; + break; + default: + break; + } + } + + Future stop() async { + _sectionNotifier.dispose(); + + await _listener.stop(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart new file mode 100644 index 0000000000000..b958e5cd3071f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/workspace/workspace_service.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; + +class WorkspaceService { + WorkspaceService({required this.workspaceId}); + + final String workspaceId; + + Future> createView({ + required String name, + required ViewSectionPB viewSection, + int? index, + ViewLayoutPB? layout, + bool? setAsCurrent, + String? viewId, + String? extra, + }) { + final payload = CreateViewPayloadPB.create() + ..parentViewId = workspaceId + ..name = name + ..layout = layout ?? ViewLayoutPB.Document + ..section = viewSection; + + if (index != null) { + payload.index = index; + } + + if (setAsCurrent != null) { + payload.setAsCurrent = setAsCurrent; + } + + if (viewId != null) { + payload.viewId = viewId; + } + + if (extra != null) { + payload.extra = extra; + } + + return FolderEventCreateView(payload).send(); + } + + Future> getWorkspace() { + return FolderEventReadCurrentWorkspace().send(); + } + + Future, FlowyError>> getPublicViews() { + final payload = GetWorkspaceViewPB.create()..value = workspaceId; + return FolderEventReadWorkspaceViews(payload).send().then((result) { + return result.fold( + (views) => FlowyResult.success(views.items), + (error) => FlowyResult.failure(error), + ); + }); + } + + Future, FlowyError>> getPrivateViews() { + final payload = GetWorkspaceViewPB.create()..value = workspaceId; + return FolderEventReadPrivateViews(payload).send().then((result) { + return result.fold( + (views) => FlowyResult.success(views.items), + (error) => FlowyResult.failure(error), + ); + }); + } + + Future> moveView({ + required String viewId, + required int fromIndex, + required int toIndex, + }) { + final payload = MoveViewPayloadPB.create() + ..viewId = viewId + ..from = fromIndex + ..to = toIndex; + + return FolderEventMoveView(payload).send(); + } + + Future> getWorkspaceUsage() { + final payload = UserWorkspaceIdPB(workspaceId: workspaceId); + return UserEventGetWorkspaceUsage(payload).send(); + } + + Future> getBillingPortal() { + return UserEventGetBillingPortal().send(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart new file mode 100644 index 0000000000000..8eb7765c3a82b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/command_palette.dart @@ -0,0 +1,232 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/search_results_list.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class CommandPalette extends InheritedWidget { + CommandPalette({ + super.key, + required Widget? child, + required this.notifier, + }) : super( + child: _CommandPaletteController(notifier: notifier, child: child), + ); + + final ValueNotifier notifier; + + static CommandPalette of(BuildContext context) { + final CommandPalette? result = + context.dependOnInheritedWidgetOfExactType(); + + assert(result != null, "CommandPalette could not be found"); + + return result!; + } + + void toggle() => notifier.value = !notifier.value; + + @override + bool updateShouldNotify(covariant InheritedWidget oldWidget) => false; +} + +class _ToggleCommandPaletteIntent extends Intent { + const _ToggleCommandPaletteIntent(); +} + +class _CommandPaletteController extends StatefulWidget { + const _CommandPaletteController({ + required this.child, + required this.notifier, + }); + + final Widget? child; + final ValueNotifier notifier; + + @override + State<_CommandPaletteController> createState() => + _CommandPaletteControllerState(); +} + +class _CommandPaletteControllerState extends State<_CommandPaletteController> { + late ValueNotifier _toggleNotifier = widget.notifier; + bool _isOpen = false; + + @override + void initState() { + super.initState(); + _toggleNotifier.addListener(_onToggle); + } + + @override + void dispose() { + _toggleNotifier.removeListener(_onToggle); + super.dispose(); + } + + @override + void didUpdateWidget(_CommandPaletteController oldWidget) { + if (oldWidget.notifier != widget.notifier) { + oldWidget.notifier.removeListener(_onToggle); + _toggleNotifier = widget.notifier; + _toggleNotifier.addListener(_onToggle); + } + super.didUpdateWidget(oldWidget); + } + + void _onToggle() { + if (_toggleNotifier.value && !_isOpen) { + _isOpen = true; + FlowyOverlay.show( + context: context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: CommandPaletteModal(shortcutBuilder: _buildShortcut), + ), + ).then((_) { + _isOpen = false; + _toggleNotifier.value = false; + }); + } else if (!_toggleNotifier.value && _isOpen) { + FlowyOverlay.pop(context); + _isOpen = false; + } + } + + @override + Widget build(BuildContext context) => + _buildShortcut(widget.child ?? const SizedBox.shrink()); + + Widget _buildShortcut(Widget child) => FocusableActionDetector( + actions: { + _ToggleCommandPaletteIntent: + CallbackAction<_ToggleCommandPaletteIntent>( + onInvoke: (intent) => + _toggleNotifier.value = !_toggleNotifier.value, + ), + }, + shortcuts: { + LogicalKeySet( + UniversalPlatform.isMacOS + ? LogicalKeyboardKey.meta + : LogicalKeyboardKey.control, + LogicalKeyboardKey.keyP, + ): const _ToggleCommandPaletteIntent(), + }, + child: child, + ); +} + +class CommandPaletteModal extends StatelessWidget { + const CommandPaletteModal({super.key, required this.shortcutBuilder}); + + final Widget Function(Widget) shortcutBuilder; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => FlowyDialog( + alignment: Alignment.topCenter, + insetPadding: const EdgeInsets.only(top: 100), + constraints: const BoxConstraints(maxHeight: 420, maxWidth: 510), + expandHeight: false, + child: shortcutBuilder( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + SearchField(query: state.query, isLoading: state.isLoading), + if (state.query?.isEmpty ?? true) ...[ + const Divider(height: 0), + Flexible( + child: RecentViewsList( + onSelected: () => FlowyOverlay.pop(context), + ), + ), + ], + if (state.results.isNotEmpty && + (state.query?.isNotEmpty ?? false)) ...[ + const Divider(height: 0), + Flexible( + child: SearchResultsList( + trash: state.trash, + results: state.results, + ), + ), + ] else if ((state.query?.isNotEmpty ?? false) && + !state.isLoading) ...[ + const _NoResultsHint(), + ], + _CommandPaletteFooter( + shouldShow: state.results.isNotEmpty && + (state.query?.isNotEmpty ?? false), + ), + ], + ), + ), + ), + ); + } +} + +class _NoResultsHint extends StatelessWidget { + const _NoResultsHint(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: FlowyText.regular( + LocaleKeys.commandPalette_noResultsHint.tr(), + textAlign: TextAlign.left, + ), + ), + ], + ); + } +} + +class _CommandPaletteFooter extends StatelessWidget { + const _CommandPaletteFooter({required this.shouldShow}); + + final bool shouldShow; + + @override + Widget build(BuildContext context) { + if (!shouldShow) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).lightGreyHover, + borderRadius: BorderRadius.circular(4), + ), + child: const FlowyText.semibold('TAB', fontSize: 10), + ), + const HSpace(4), + FlowyText(LocaleKeys.commandPalette_navigateHint.tr(), fontSize: 11), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_view_tile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_view_tile.dart new file mode 100644 index 0000000000000..c4bc5aa845061 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_view_tile.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; + +class RecentViewTile extends StatelessWidget { + const RecentViewTile({ + super.key, + required this.icon, + required this.view, + required this.onSelected, + }); + + final Widget icon; + final ViewPB view; + final VoidCallback onSelected; + + @override + Widget build(BuildContext context) { + return ListTile( + dense: true, + title: Row( + children: [ + icon, + const HSpace(6), + FlowyText(view.nameOrDefault), + ], + ), + focusColor: Theme.of(context).colorScheme.primary.withOpacity(0.1), + hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.1), + onTap: () { + onSelected(); + + getIt().add( + ActionNavigationEvent.performAction( + action: NavigationAction(objectId: view.id), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart new file mode 100644 index 0000000000000..b0f87005d2ba5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/recent_views_list.dart @@ -0,0 +1,67 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_view_tile.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class RecentViewsList extends StatelessWidget { + const RecentViewsList({super.key, required this.onSelected}); + + final VoidCallback onSelected; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + RecentViewsBloc()..add(const RecentViewsEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + // We remove duplicates by converting the list to a set first + final List recentViews = + state.views.map((e) => e.item).toSet().toList(); + + return ListView.separated( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + itemCount: recentViews.length + 1, + itemBuilder: (_, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: FlowyText( + LocaleKeys.commandPalette_recentHistory.tr(), + ), + ); + } + + final view = recentViews[index - 1]; + final icon = view.icon.value.isNotEmpty + ? EmojiIconWidget( + emoji: view.icon.toEmojiIconData(), + emojiSize: 18.0, + ) + : FlowySvg(view.iconData, size: const Size.square(20)); + + return RecentViewTile( + icon: SizedBox(width: 24, child: icon), + view: view, + onSelected: onSelected, + ); + }, + separatorBuilder: (_, __) => const Divider(height: 0), + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_field.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_field.dart new file mode 100644 index 0000000000000..c18024a909ba8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_field.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SearchField extends StatefulWidget { + const SearchField({super.key, this.query, this.isLoading = false}); + + final String? query; + final bool isLoading; + + @override + State createState() => _SearchFieldState(); +} + +class _SearchFieldState extends State { + late final FocusNode focusNode; + late final controller = TextEditingController(text: widget.query); + + @override + void initState() { + super.initState(); + focusNode = FocusNode( + onKeyEvent: (node, event) { + if (node.hasFocus && + event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.arrowDown) { + node.nextFocus(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + }, + ); + focusNode.requestFocus(); + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controller.text.length, + ); + } + + @override + void dispose() { + focusNode.dispose(); + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const HSpace(12), + FlowySvg( + FlowySvgs.search_m, + color: Theme.of(context).hintColor, + ), + Expanded( + child: FlowyTextField( + focusNode: focusNode, + controller: controller, + textStyle: + Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 14), + decoration: InputDecoration( + constraints: const BoxConstraints(maxHeight: 48), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), + borderRadius: Corners.s8Border, + ), + isDense: false, + hintText: LocaleKeys.commandPalette_placeholder.tr(), + hintStyle: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 14, + color: Theme.of(context).hintColor, + ), + errorStyle: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(color: Theme.of(context).colorScheme.error), + suffix: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedOpacity( + opacity: controller.text.trim().isNotEmpty ? 1 : 0, + duration: const Duration(milliseconds: 200), + child: Builder( + builder: (context) { + final icon = Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AFThemeExtension.of(context).lightGreyHover, + ), + child: const FlowySvg( + FlowySvgs.close_s, + size: Size.square(16), + ), + ); + if (controller.text.isEmpty) { + return icon; + } + + return FlowyTooltip( + message: + LocaleKeys.commandPalette_clearSearchTooltip.tr(), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: controller.text.trim().isNotEmpty + ? _clearSearch + : null, + child: icon, + ), + ), + ); + }, + ), + ), + const HSpace(8), + FlowyTooltip( + message: LocaleKeys.commandPalette_betaTooltip.tr(), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 2, + ), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).lightGreyHover, + borderRadius: BorderRadius.circular(4), + ), + child: FlowyText.semibold( + LocaleKeys.commandPalette_betaLabel.tr(), + fontSize: 11, + lineHeight: 1.2, + ), + ), + ), + ], + ), + counterText: "", + focusedBorder: const OutlineInputBorder( + borderRadius: Corners.s8Border, + borderSide: BorderSide(color: Colors.transparent), + ), + errorBorder: OutlineInputBorder( + borderRadius: Corners.s8Border, + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + onChanged: (value) => context + .read() + .add(CommandPaletteEvent.searchChanged(search: value)), + ), + ), + if (widget.isLoading) ...[ + FlowyTooltip( + message: LocaleKeys.commandPalette_loadingTooltip.tr(), + child: const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + ), + const HSpace(12), + ], + ], + ); + } + + void _clearSearch() { + controller.clear(); + context + .read() + .add(const CommandPaletteEvent.clearSearch()); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart new file mode 100644 index 0000000000000..0edcde3664656 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_result_tile.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; + +class SearchResultTile extends StatefulWidget { + const SearchResultTile({ + super.key, + required this.result, + required this.onSelected, + this.isTrashed = false, + }); + + final SearchResultPB result; + final VoidCallback onSelected; + final bool isTrashed; + + @override + State createState() => _SearchResultTileState(); +} + +class _SearchResultTileState extends State { + bool _hasFocus = false; + + final focusNode = FocusNode(); + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final icon = widget.result.getIcon(); + final cleanedPreview = _cleanPreview(widget.result.preview); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + widget.onSelected(); + + getIt().add( + ActionNavigationEvent.performAction( + action: NavigationAction(objectId: widget.result.viewId), + ), + ); + }, + child: Focus( + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.enter) { + widget.onSelected(); + + getIt().add( + ActionNavigationEvent.performAction( + action: NavigationAction(objectId: widget.result.viewId), + ), + ); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + }, + onFocusChange: (hasFocus) => setState(() => _hasFocus = hasFocus), + child: FlowyHover( + isSelected: () => _hasFocus, + style: HoverStyle( + hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.1), + foregroundColorOnHover: AFThemeExtension.of(context).textColor, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (icon != null) ...[ + SizedBox(width: 24, child: icon), + const HSpace(6), + ], + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.isTrashed) ...[ + FlowyText( + LocaleKeys.commandPalette_fromTrashHint.tr(), + color: AFThemeExtension.of(context) + .textColor + .withAlpha(175), + fontSize: 10, + ), + ], + FlowyText( + widget.result.data, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + if (cleanedPreview.isNotEmpty) ...[ + const VSpace(4), + _DocumentPreview(preview: cleanedPreview), + ], + ], + ), + ), + ), + ), + ); + } + + String _cleanPreview(String preview) { + return preview.replaceAll('\n', ' ').trim(); + } +} + +class _DocumentPreview extends StatelessWidget { + const _DocumentPreview({required this.preview}); + + final String preview; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16) + + const EdgeInsets.only(left: 14), + child: FlowyText.regular( + preview, + color: Theme.of(context).hintColor, + fontSize: 12, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart new file mode 100644 index 0000000000000..ed9becf29e443 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/command_palette/widgets/search_results_list.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_tile.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class SearchResultsList extends StatelessWidget { + const SearchResultsList({ + super.key, + required this.trash, + required this.results, + }); + + final List trash; + final List results; + + @override + Widget build(BuildContext context) { + return ListView.separated( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + separatorBuilder: (_, __) => const Divider(height: 0), + itemCount: results.length + 1, + itemBuilder: (_, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8) + + const EdgeInsets.only(left: 16), + child: FlowyText( + LocaleKeys.commandPalette_bestMatches.tr(), + ), + ); + } + + final result = results[index - 1]; + return SearchResultTile( + result: result, + onSelected: () => FlowyOverlay.pop(context), + isTrashed: trash.any((t) => t.id == result.viewId), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/af_focus_manager.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/af_focus_manager.dart new file mode 100644 index 0000000000000..17d09b68218ec --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/af_focus_manager.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +/// Simple ChangeNotifier that can be listened to, notifies the +/// application on events that should trigger focus loss. +/// +/// Eg. lose focus in AppFlowyEditor +/// +abstract class ShouldLoseFocus with ChangeNotifier {} + +/// Private implementation to allow the [AFFocusManager] to +/// call [notifyListeners] without being directly invokable. +/// +class _ShouldLoseFocusImpl extends ShouldLoseFocus { + void notify() => notifyListeners(); +} + +class AFFocusManager extends InheritedWidget { + AFFocusManager({super.key, required super.child}); + + final ShouldLoseFocus loseFocusNotifier = _ShouldLoseFocusImpl(); + + void notifyLoseFocus() { + (loseFocusNotifier as _ShouldLoseFocusImpl).notify(); + } + + @override + bool updateShouldNotify(covariant InheritedWidget oldWidget) => false; + + static AFFocusManager of(BuildContext context) { + final AFFocusManager? result = + context.dependOnInheritedWidgetOfExactType(); + + assert(result != null, "AFFocusManager could not be found"); + return result!; + } + + static AFFocusManager? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart new file mode 100644 index 0000000000000..a8d768aa79b01 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart @@ -0,0 +1,310 @@ +import 'package:appflowy/plugins/blank/blank.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/memory_leak_detector.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/home/home_bloc.dart'; +import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; +import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart'; +import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart'; +import 'package:appflowy/workspace/presentation/widgets/edit_panel/panel_animation.dart'; +import 'package:appflowy/workspace/presentation/widgets/float_bubble/question_bubble.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:flowy_infra_ui/style_widget/container.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sized_context/sized_context.dart'; +import 'package:styled_widget/styled_widget.dart'; + +import '../widgets/edit_panel/edit_panel.dart'; +import '../widgets/sidebar_resizer.dart'; +import 'home_layout.dart'; +import 'home_stack.dart'; + +class DesktopHomeScreen extends StatelessWidget { + const DesktopHomeScreen({super.key}); + + static const routeName = '/DesktopHomeScreen'; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: Future.wait([ + FolderEventGetCurrentWorkspaceSetting().send(), + getIt().getUser(), + ]), + builder: (context, snapshots) { + if (!snapshots.hasData) { + return _buildLoading(); + } + + final workspaceSetting = snapshots.data?[0].fold( + (workspaceSettingPB) => workspaceSettingPB as WorkspaceSettingPB, + (error) => null, + ); + + final userProfile = snapshots.data?[1].fold( + (userProfilePB) => userProfilePB as UserProfilePB, + (error) => null, + ); + + // In the unlikely case either of the above is null, eg. + // when a workspace is already open this can happen. + if (workspaceSetting == null || userProfile == null) { + return const WorkspaceFailedScreen(); + } + + Sentry.configureScope( + (scope) => scope.setUser( + SentryUser( + id: userProfile.id.toString(), + ), + ), + ); + + return AFFocusManager( + child: MultiBlocProvider( + key: ValueKey(userProfile.id), + providers: [ + BlocProvider.value( + value: getIt(), + ), + BlocProvider.value(value: getIt()), + BlocProvider( + create: (_) => + HomeBloc(workspaceSetting)..add(const HomeEvent.initial()), + ), + BlocProvider( + create: (_) => HomeSettingBloc( + workspaceSetting, + context.read(), + context.widthPx, + )..add(const HomeSettingEvent.initial()), + ), + BlocProvider( + create: (context) => + FavoriteBloc()..add(const FavoriteEvent.initial()), + ), + ], + child: Scaffold( + floatingActionButton: enableMemoryLeakDetect + ? const FloatingActionButton( + onPressed: dumpMemoryLeak, + child: Icon(Icons.memory), + ) + : null, + body: BlocListener( + listenWhen: (p, c) => p.latestView != c.latestView, + listener: (context, state) { + final view = state.latestView; + if (view != null) { + // Only open the last opened view if the [TabsState.currentPageManager] current opened plugin is blank and the last opened view is not null. + // All opened widgets that display on the home screen are in the form of plugins. There is a list of built-in plugins defined in the [PluginType] enum, including board, grid and trash. + final currentPageManager = + context.read().state.currentPageManager; + + if (currentPageManager.plugin.pluginType == + PluginType.blank) { + getIt().add( + TabsEvent.openPlugin(plugin: view.plugin()), + ); + } + } + }, + child: BlocBuilder( + buildWhen: (previous, current) => previous != current, + builder: (context, state) => BlocProvider( + create: (_) => UserWorkspaceBloc(userProfile: userProfile) + ..add(const UserWorkspaceEvent.initial()), + child: HomeHotKeys( + userProfile: userProfile, + child: FlowyContainer( + Theme.of(context).colorScheme.surface, + child: _buildBody( + context, + userProfile, + workspaceSetting, + ), + ), + ), + ), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildLoading() => + const Center(child: CircularProgressIndicator.adaptive()); + + Widget _buildBody( + BuildContext context, + UserProfilePB userProfile, + WorkspaceSettingPB workspaceSetting, + ) { + final layout = HomeLayout(context); + final homeStack = HomeStack( + layout: layout, + delegate: DesktopHomeScreenStackAdaptor(context), + userProfile: userProfile, + ); + final sidebar = _buildHomeSidebar( + context, + layout: layout, + userProfile: userProfile, + workspaceSetting: workspaceSetting, + ); + + final homeMenuResizer = + layout.showMenu ? const SidebarResizer() : const SizedBox.shrink(); + final editPanel = _buildEditPanel(context, layout: layout); + + return _layoutWidgets( + layout: layout, + homeStack: homeStack, + sidebar: sidebar, + editPanel: editPanel, + bubble: const QuestionBubble(), + homeMenuResizer: homeMenuResizer, + ); + } + + Widget _buildHomeSidebar( + BuildContext context, { + required HomeLayout layout, + required UserProfilePB userProfile, + required WorkspaceSettingPB workspaceSetting, + }) { + final homeMenu = HomeSideBar( + userProfile: userProfile, + workspaceSetting: workspaceSetting, + ); + return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu)); + } + + Widget _buildEditPanel( + BuildContext context, { + required HomeLayout layout, + }) { + final homeBloc = context.read(); + return BlocBuilder( + buildWhen: (previous, current) => + previous.panelContext != current.panelContext, + builder: (context, state) { + final panelContext = state.panelContext; + if (panelContext == null) { + return const SizedBox.shrink(); + } + + return FocusTraversalGroup( + child: RepaintBoundary( + child: EditPanel( + panelContext: panelContext, + onEndEdit: () => homeBloc.add( + const HomeSettingEvent.dismissEditPanel(), + ), + ), + ), + ); + }, + ); + } + + Widget _layoutWidgets({ + required HomeLayout layout, + required Widget sidebar, + required Widget homeStack, + required Widget editPanel, + required Widget bubble, + required Widget homeMenuResizer, + }) { + return Stack( + children: [ + homeStack + .constrained(minWidth: 500) + .positioned( + left: layout.homePageLOffset, + right: layout.homePageROffset, + bottom: 0, + top: 0, + animate: true, + ) + .animate(layout.animDuration, Curves.easeOutQuad), + bubble + .positioned(right: 20, bottom: 16, animate: true) + .animate(layout.animDuration, Curves.easeOut), + editPanel + .animatedPanelX( + duration: layout.animDuration.inMilliseconds * 0.001, + closeX: layout.editPanelWidth, + isClosed: !layout.showEditPanel, + curve: Curves.easeOutQuad, + ) + .positioned( + top: 0, + right: 0, + bottom: 0, + width: layout.editPanelWidth, + ), + sidebar + .animatedPanelX( + closeX: -layout.menuWidth, + isClosed: !layout.showMenu, + curve: Curves.easeOutQuad, + duration: layout.animDuration.inMilliseconds * 0.001, + ) + .positioned(left: 0, top: 0, width: layout.menuWidth, bottom: 0), + homeMenuResizer + .positioned(left: layout.menuWidth) + .animate(layout.animDuration, Curves.easeOutQuad), + ], + ); + } +} + +class DesktopHomeScreenStackAdaptor extends HomeStackDelegate { + DesktopHomeScreenStackAdaptor(this.buildContext); + + final BuildContext buildContext; + + @override + void didDeleteStackWidget(ViewPB view, int? index) { + ViewBackendService.getView(view.parentViewId).then( + (result) => result.fold( + (parentView) { + final List views = parentView.childViews; + if (views.isNotEmpty) { + ViewPB lastView = views.last; + if (index != null && index != 0 && views.length > index - 1) { + lastView = views[index - 1]; + } + + return getIt() + .add(TabsEvent.openPlugin(plugin: lastView.plugin())); + } + + getIt() + .add(TabsEvent.openPlugin(plugin: BlankPagePlugin())); + }, + (err) => Log.error(err), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/errors/workspace_failed_screen.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/errors/workspace_failed_screen.dart new file mode 100644 index 0000000000000..68cac12dc5ec0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/errors/workspace_failed_screen.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/rounded_button.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class WorkspaceFailedScreen extends StatefulWidget { + const WorkspaceFailedScreen({super.key}); + + @override + State createState() => _WorkspaceFailedScreenState(); +} + +class _WorkspaceFailedScreenState extends State { + String version = ''; + final String os = Platform.operatingSystem; + + @override + void initState() { + super.initState(); + initVersion(); + } + + Future initVersion() async { + final platformInfo = await PackageInfo.fromPlatform(); + setState(() { + version = platformInfo.version; + }); + } + + @override + Widget build(BuildContext context) { + return Material( + child: Scaffold( + body: Center( + child: SizedBox( + width: 400, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(LocaleKeys.workspace_failedToLoad.tr()), + const VSpace(20), + Row( + children: [ + Flexible( + child: RoundedTextButton( + title: + LocaleKeys.workspace_errorActions_reportIssue.tr(), + height: 40, + onPressed: () => afLaunchUrlString( + 'https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&projects=&template=bug_report.yaml&title=[Bug]%20Workspace%20failed%20to%20load&version=$version&os=$os', + ), + ), + ), + const HSpace(20), + Flexible( + child: RoundedTextButton( + title: LocaleKeys.workspace_errorActions_reachOut.tr(), + height: 40, + onPressed: () => + afLaunchUrlString('https://discord.gg/JucBXeU2FE'), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart new file mode 100644 index 0000000000000..98139f1db7f56 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart @@ -0,0 +1,50 @@ +import 'dart:io' show Platform; +import 'dart:math'; + +import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +// ignore: import_of_legacy_library_into_null_safe +import 'package:sized_context/sized_context.dart'; + +import 'home_sizes.dart'; + +class HomeLayout { + HomeLayout(BuildContext context) { + final homeSetting = context.read().state; + showEditPanel = homeSetting.panelContext != null; + + menuWidth = max( + HomeSizes.minimumSidebarWidth + homeSetting.resizeOffset, + HomeSizes.minimumSidebarWidth, + ); + + final screenWidthPx = context.widthPx; + context + .read() + .add(HomeSettingEvent.checkScreenSize(screenWidthPx)); + + showMenu = !homeSetting.isMenuCollapsed; + if (showMenu) { + menuIsDrawer = context.widthPx <= PageBreaks.tabletPortrait; + } + + homePageLOffset = (showMenu && !menuIsDrawer) ? menuWidth : 0.0; + + menuSpacing = !showMenu && Platform.isMacOS ? 80.0 : 0.0; + animDuration = homeSetting.resizeType.duration(); + editPanelWidth = HomeSizes.editPanelWidth; + homePageROffset = showEditPanel ? editPanelWidth : 0; + } + + late bool showEditPanel; + late double menuWidth; + late bool showMenu; + late bool menuIsDrawer; + late double homePageLOffset; + late double menuSpacing; + late Duration animDuration; + late double editPanelWidth; + late double homePageROffset; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart new file mode 100644 index 0000000000000..18d76057a275d --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_sizes.dart @@ -0,0 +1,28 @@ +class HomeSizes { + static const double menuAddButtonHeight = 60; + static const double topBarHeight = 44; + static const double editPanelTopBarHeight = 60; + static const double editPanelWidth = 400; + static const double tabBarHeight = 40; + static const double tabBarWidth = 200; + static const double workspaceSectionHeight = 32; + static const double searchSectionHeight = 30; + static const double newPageSectionHeight = 30; + static const double minimumSidebarWidth = 268; +} + +class HomeInsets { + static const double topBarTitleHorizontalPadding = 12; + static const double topBarTitleVerticalPadding = 12; +} + +class HomeSpaceViewSizes { + static const double leftPadding = 16.0; + static const double viewHeight = 30.0; + + // mobile, m represents mobile + static const double mViewHeight = 48.0; + static const double mViewButtonDimension = 34.0; + static const double mHorizontalPadding = 20.0; + static const double mVerticalPadding = 12.0; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart new file mode 100644 index 0000000000000..4261865e5ec45 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_stack.dart @@ -0,0 +1,779 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +import 'package:appflowy/core/frameless_window.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/blank/blank.dart'; +import 'package:appflowy/shared/window_title_bar.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/navigation.dart'; +import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import 'package:time/time.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'home_layout.dart'; + +typedef NavigationCallback = void Function(String id); + +abstract class HomeStackDelegate { + void didDeleteStackWidget(ViewPB view, int? index); +} + +class HomeStack extends StatefulWidget { + const HomeStack({ + super.key, + required this.delegate, + required this.layout, + required this.userProfile, + }); + + final HomeStackDelegate delegate; + final HomeLayout layout; + final UserProfilePB userProfile; + + @override + State createState() => _HomeStackState(); +} + +class _HomeStackState extends State { + int selectedIndex = 0; + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: getIt(), + child: BlocBuilder( + builder: (context, state) => Column( + children: [ + if (UniversalPlatform.isWindows) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + WindowTitleBar( + leftChildren: [_buildToggleMenuButton(context)], + ), + ], + ), + Padding( + padding: EdgeInsets.only(left: widget.layout.menuSpacing), + child: TabsManager( + onIndexChanged: (index) { + if (selectedIndex != index) { + // Unfocus editor to hide selection toolbar + FocusScope.of(context).unfocus(); + + context.read().add(TabsEvent.selectTab(index)); + setState(() => selectedIndex = index); + } + }, + ), + ), + Expanded( + child: IndexedStack( + index: selectedIndex, + children: state.pageManagers + .map( + (pm) => LayoutBuilder( + builder: (context, constraints) { + return Row( + children: [ + Expanded( + child: Column( + children: [ + pm.stackTopBar(layout: widget.layout), + Expanded( + child: PageStack( + pageManager: pm, + delegate: widget.delegate, + userProfile: widget.userProfile, + ), + ), + ], + ), + ), + SecondaryView( + pageManager: pm, + adaptedPercentageWidth: + constraints.maxWidth * 3 / 7, + ), + ], + ); + }, + ), + ) + .toList(), + ), + ), + ], + ), + ), + ); + } + + Widget _buildToggleMenuButton(BuildContext context) { + if (!context.read().state.isMenuCollapsed) { + return const SizedBox.shrink(); + } + + final textSpan = TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.sideBar_openSidebar.tr()}\n', + style: context.tooltipTextStyle(), + ), + TextSpan( + text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\', + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], + ); + + return FlowyTooltip( + richMessage: textSpan, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => context + .read() + .add(const HomeSettingEvent.collapseMenu()), + child: FlowyHover( + child: Container( + width: 24, + padding: const EdgeInsets.all(4), + child: const RotatedBox( + quarterTurns: 2, + child: FlowySvg(FlowySvgs.hide_menu_s), + ), + ), + ), + ), + ); + } +} + +class PageStack extends StatefulWidget { + const PageStack({ + super.key, + required this.pageManager, + required this.delegate, + required this.userProfile, + }); + + final PageManager pageManager; + final HomeStackDelegate delegate; + final UserProfilePB userProfile; + + @override + State createState() => _PageStackState(); +} + +class _PageStackState extends State + with AutomaticKeepAliveClientMixin { + @override + Widget build(BuildContext context) { + super.build(context); + + return Container( + color: Theme.of(context).colorScheme.surface, + child: FocusTraversalGroup( + child: widget.pageManager.stackWidget( + userProfile: widget.userProfile, + onDeleted: (view, index) { + widget.delegate.didDeleteStackWidget(view, index); + }, + ), + ), + ); + } + + @override + bool get wantKeepAlive => true; +} + +class SecondaryView extends StatefulWidget { + const SecondaryView({ + super.key, + required this.pageManager, + required this.adaptedPercentageWidth, + }); + + final PageManager pageManager; + final double adaptedPercentageWidth; + + @override + State createState() => _SecondaryViewState(); +} + +class _SecondaryViewState extends State + with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { + late final ValueNotifier widthNotifier; + + late final AnimationController animationController; + late Animation widthAnimation; + late final Animation offsetAnimation; + + CurvedAnimation get curveAnimation => CurvedAnimation( + parent: animationController, + curve: Curves.easeOut, + ); + + @override + void initState() { + super.initState(); + widget.pageManager.showSecondaryPluginNotifier + .addListener(onShowSecondaryChanged); + final width = widget.pageManager.showSecondaryPluginNotifier.value + ? max(450.0, widget.adaptedPercentageWidth) + : 0.0; + widthNotifier = ValueNotifier(width) + ..addListener(updateWidthAnimation); + + animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + widthAnimation = Tween( + begin: 0.0, + end: width, + ).animate(curveAnimation); + offsetAnimation = Tween( + begin: const Offset(1.0, 0.0), + end: Offset.zero, + ).animate(curveAnimation); + } + + @override + void dispose() { + widget.pageManager.showSecondaryPluginNotifier + .removeListener(onShowSecondaryChanged); + widthNotifier.dispose(); + super.dispose(); + } + + void onShowSecondaryChanged() async { + if (widget.pageManager.showSecondaryPluginNotifier.value) { + widthNotifier.value = max(450.0, widget.adaptedPercentageWidth); + updateWidthAnimation(); + await animationController.forward(); + } else { + updateWidthAnimation(); + await animationController.reverse(); + setState(() => widthNotifier.value = 0.0); + } + } + + void updateWidthAnimation() { + widthAnimation = Tween( + begin: 0.0, + end: widthNotifier.value, + ).animate(curveAnimation); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Container( + color: Theme.of(context).colorScheme.surface, + child: FocusTraversalGroup( + child: ValueListenableBuilder( + valueListenable: widthNotifier, + builder: (context, value, child) { + return AnimatedBuilder( + animation: Listenable.merge([ + widthAnimation, + offsetAnimation, + ]), + builder: (context, child) { + return Container( + width: widthAnimation.value, + alignment: Alignment( + offsetAnimation.value.dx, + offsetAnimation.value.dy, + ), + child: OverflowBox( + alignment: AlignmentDirectional.centerStart, + maxWidth: value, + child: SecondaryViewResizer( + pageManager: widget.pageManager, + notifier: widthNotifier, + child: Column( + children: [ + widget.pageManager.stackSecondaryTopBar(value), + Expanded( + child: + widget.pageManager.stackSecondaryWidget(value), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ), + ); + } + + @override + bool get wantKeepAlive => true; +} + +class SecondaryViewResizer extends StatefulWidget { + const SecondaryViewResizer({ + super.key, + required this.pageManager, + required this.notifier, + required this.child, + }); + + final PageManager pageManager; + final ValueNotifier notifier; + final Widget child; + + @override + State createState() => _SecondaryViewResizerState(); +} + +class _SecondaryViewResizerState extends State { + final overlayController = OverlayPortalController(); + final layerLink = LayerLink(); + + bool isHover = false; + bool isDragging = false; + Timer? showHoverTimer; + + @override + void initState() { + super.initState(); + overlayController.show(); + } + + @override + Widget build(BuildContext context) { + return OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) { + return CompositedTransformFollower( + showWhenUnlinked: false, + link: layerLink, + targetAnchor: Alignment.center, + followerAnchor: Alignment.center, + child: Center( + child: MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + onEnter: (_) { + showHoverTimer = Timer(const Duration(milliseconds: 500), () { + setState(() => isHover = true); + }); + }, + onExit: (_) { + showHoverTimer?.cancel(); + setState(() => isHover = false); + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) => setState(() => isDragging = true), + onHorizontalDragUpdate: (details) { + final newWidth = MediaQuery.sizeOf(context).width - + details.globalPosition.dx; + if (newWidth >= 450.0) { + widget.notifier.value = newWidth; + } + }, + onHorizontalDragEnd: (_) => setState(() => isDragging = false), + child: TweenAnimationBuilder( + tween: ColorTween( + end: isHover || isDragging + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + ), + duration: const Duration(milliseconds: 100), + builder: (context, color, child) { + return SizedBox( + width: 11, + child: Center( + child: Container( + color: color, + width: 2, + ), + ), + ); + }, + ), + ), + ), + ), + ); + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CompositedTransformTarget( + link: layerLink, + child: Container( + width: 1, + color: Theme.of(context).dividerColor, + ), + ), + Flexible(child: widget.child), + ], + ), + ); + } +} + +class FadingIndexedStack extends StatefulWidget { + const FadingIndexedStack({ + super.key, + required this.index, + required this.children, + this.duration = const Duration(milliseconds: 250), + }); + + final int index; + final List children; + final Duration duration; + + @override + FadingIndexedStackState createState() => FadingIndexedStackState(); +} + +class FadingIndexedStackState extends State { + double _targetOpacity = 1; + + @override + void initState() { + super.initState(); + initToastWithContext(context); + } + + @override + void didUpdateWidget(FadingIndexedStack oldWidget) { + if (oldWidget.index == widget.index) return; + _targetOpacity = 0; + SchedulerBinding.instance.addPostFrameCallback( + (_) => setState(() => _targetOpacity = 1), + ); + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + duration: _targetOpacity > 0 ? widget.duration : 0.milliseconds, + tween: Tween(begin: 0, end: _targetOpacity), + builder: (_, value, child) => Opacity(opacity: value, child: child), + child: IndexedStack(index: widget.index, children: widget.children), + ); + } +} + +abstract mixin class NavigationItem { + String? get viewName; + Widget get leftBarItem; + Widget? get rightBarItem => null; + Widget tabBarItem(String pluginId, [bool shortForm = false]); + + NavigationCallback get action => (id) => throw UnimplementedError(); +} + +class PageNotifier extends ChangeNotifier { + PageNotifier({Plugin? plugin}) + : _plugin = plugin ?? makePlugin(pluginType: PluginType.blank); + + Plugin _plugin; + + Widget get titleWidget => _plugin.widgetBuilder.leftBarItem; + + Widget tabBarWidget( + String pluginId, [ + bool shortForm = false, + ]) => + _plugin.widgetBuilder.tabBarItem(pluginId, shortForm); + + /// This is the only place where the plugin is set. + /// No need compare the old plugin with the new plugin. Just set it. + void setPlugin(Plugin newPlugin, bool setLatest) { + if (newPlugin.id != plugin.id) { + _plugin.dispose(); + } + + // Set the plugin view as the latest view. + if (setLatest) { + FolderEventSetLatestView(ViewIdPB(value: newPlugin.id)).send(); + } + + _plugin = newPlugin; + notifyListeners(); + } + + Plugin get plugin => _plugin; +} + +// PageManager manages the view for one Tab +class PageManager { + PageManager(); + + final PageNotifier _notifier = PageNotifier(); + final PageNotifier _secondaryNotifier = PageNotifier(); + + PageNotifier get notifier => _notifier; + PageNotifier get secondaryNotifier => _secondaryNotifier; + + bool isPinned = false; + + final showSecondaryPluginNotifier = ValueNotifier(false); + + Plugin get plugin => _notifier.plugin; + + void setPlugin(Plugin newPlugin, bool setLatest, [bool init = true]) { + if (init) { + newPlugin.init(); + } + _notifier.setPlugin(newPlugin, setLatest); + } + + void setSecondaryPlugin(Plugin newPlugin) { + newPlugin.init(); + _secondaryNotifier.setPlugin(newPlugin, false); + showSecondaryPluginNotifier.value = true; + } + + void hideSecondaryPlugin() { + showSecondaryPluginNotifier.value = false; + } + + Widget stackTopBar({required HomeLayout layout}) { + return ChangeNotifierProvider.value( + value: _notifier, + child: Selector( + selector: (context, notifier) => notifier.titleWidget, + builder: (_, __, child) => MoveWindowDetector( + child: HomeTopBar(layout: layout), + ), + ), + ); + } + + Widget stackWidget({ + required UserProfilePB userProfile, + required Function(ViewPB, int?) onDeleted, + }) { + return ChangeNotifierProvider.value( + value: _notifier, + child: Consumer( + builder: (_, notifier, __) { + return FadingIndexedStack( + index: getIt().indexOf(notifier.plugin.pluginType), + children: getIt().supportPluginTypes.map( + (pluginType) { + if (pluginType == notifier.plugin.pluginType) { + final builder = notifier.plugin.widgetBuilder; + final pluginWidget = builder.buildWidget( + context: PluginContext( + onDeleted: onDeleted, + userProfile: userProfile, + ), + shrinkWrap: false, + ); + + return Padding( + padding: builder.contentPadding, + child: pluginWidget, + ); + } + + return const BlankPage(); + }, + ).toList(), + ); + }, + ), + ); + } + + Widget stackSecondaryWidget(double width) { + return ValueListenableBuilder( + valueListenable: showSecondaryPluginNotifier, + builder: (context, value, child) { + if (width == 0.0) { + return const SizedBox.shrink(); + } + + return child!; + }, + child: ChangeNotifierProvider.value( + value: _secondaryNotifier, + child: Selector( + selector: (context, notifier) => notifier.plugin.widgetBuilder, + builder: (_, widgetBuilder, __) { + return widgetBuilder.buildWidget( + context: PluginContext(), + shrinkWrap: false, + ); + }, + ), + ), + ); + } + + Widget stackSecondaryTopBar(double width) { + return ValueListenableBuilder( + valueListenable: showSecondaryPluginNotifier, + builder: (context, value, child) { + if (width == 0.0) { + return const SizedBox.shrink(); + } + + return child!; + }, + child: ChangeNotifierProvider.value( + value: _secondaryNotifier, + child: Selector( + selector: (context, notifier) => notifier.plugin.widgetBuilder, + builder: (_, widgetBuilder, __) { + return const MoveWindowDetector( + child: HomeSecondaryTopBar(), + ); + }, + ), + ), + ); + } + + void dispose() { + _notifier.dispose(); + _secondaryNotifier.dispose(); + showSecondaryPluginNotifier.dispose(); + } +} + +class HomeTopBar extends StatefulWidget { + const HomeTopBar({super.key, required this.layout}); + + final HomeLayout layout; + + @override + State createState() => _HomeTopBarState(); +} + +class _HomeTopBarState extends State + with AutomaticKeepAliveClientMixin { + @override + Widget build(BuildContext context) { + super.build(context); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + ), + height: HomeSizes.topBarHeight + HomeInsets.topBarTitleVerticalPadding, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: HomeInsets.topBarTitleHorizontalPadding, + vertical: HomeInsets.topBarTitleVerticalPadding, + ), + child: Row( + children: [ + HSpace(widget.layout.menuSpacing), + const FlowyNavigation(), + const HSpace(16), + ChangeNotifierProvider.value( + value: Provider.of(context, listen: false), + child: Consumer( + builder: (_, PageNotifier notifier, __) => + notifier.plugin.widgetBuilder.rightBarItem ?? + const SizedBox.shrink(), + ), + ), + ], + ), + ), + ); + } + + @override + bool get wantKeepAlive => true; +} + +class HomeSecondaryTopBar extends StatelessWidget { + const HomeSecondaryTopBar({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + ), + height: HomeSizes.topBarHeight + HomeInsets.topBarTitleVerticalPadding, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: HomeInsets.topBarTitleHorizontalPadding, + vertical: HomeInsets.topBarTitleVerticalPadding, + ), + child: Row( + children: [ + FlowyIconButton( + width: 24, + radius: const BorderRadius.all(Radius.circular(8.0)), + icon: const FlowySvg( + FlowySvgs.show_menu_s, + size: Size.square(16), + ), + onPressed: () { + getIt().add(const TabsEvent.closeSecondaryPlugin()); + }, + ), + const HSpace(8.0), + FlowyIconButton( + width: 24, + radius: const BorderRadius.all(Radius.circular(8.0)), + icon: const FlowySvg( + FlowySvgs.full_view_s, + size: Size.square(16), + ), + onPressed: () { + getIt().add(const TabsEvent.expandSecondaryPlugin()); + }, + ), + Expanded( + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: ChangeNotifierProvider.value( + value: Provider.of(context, listen: false), + child: Consumer( + builder: (_, PageNotifier notifier, __) => + notifier.plugin.widgetBuilder.rightBarItem ?? + const SizedBox.shrink(), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart new file mode 100644 index 0000000000000..af3db13d53101 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/hotkeys.dart @@ -0,0 +1,266 @@ +import 'dart:io'; + +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/app_window_size_manager.dart'; +import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flutter/material.dart'; +import 'package:hotkey_manager/hotkey_manager.dart'; +import 'package:provider/provider.dart'; +import 'package:scaled_app/scaled_app.dart'; + +typedef KeyDownHandler = void Function(HotKey hotKey); + +ValueNotifier switchToTheNextSpace = ValueNotifier(0); +ValueNotifier createNewPageNotifier = ValueNotifier(0); + +@visibleForTesting +final zoomInKeyCodes = [KeyCode.equal, KeyCode.numpadAdd, KeyCode.add]; +@visibleForTesting +final zoomOutKeyCodes = [KeyCode.minus, KeyCode.numpadSubtract]; +@visibleForTesting +final resetZoomKeyCodes = [KeyCode.digit0, KeyCode.numpad0]; + +// Use a global value to store the zoom level and update it in the hotkeys. +@visibleForTesting +double appflowyScaleFactor = 1.0; + +/// Helper class that utilizes the global [HotKeyManager] to easily +/// add a [HotKey] with different handlers. +/// +/// Makes registration of a [HotKey] simple and easy to read, and makes +/// sure the [KeyDownHandler], and other handlers, are grouped with the +/// relevant [HotKey]. +/// +class HotKeyItem { + HotKeyItem({ + required this.hotKey, + this.keyDownHandler, + }); + + final HotKey hotKey; + final KeyDownHandler? keyDownHandler; + + void register() => + hotKeyManager.register(hotKey, keyDownHandler: keyDownHandler); +} + +class HomeHotKeys extends StatefulWidget { + const HomeHotKeys({ + super.key, + required this.userProfile, + required this.child, + }); + + final UserProfilePB userProfile; + final Widget child; + + @override + State createState() => _HomeHotKeysState(); +} + +class _HomeHotKeysState extends State { + final windowSizeManager = WindowSizeManager(); + + late final items = [ + // Collapse sidebar menu (using slash) + HotKeyItem( + hotKey: HotKey( + KeyCode.backslash, + modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => context + .read() + .add(const HomeSettingEvent.collapseMenu()), + ), + + // Collapse sidebar menu (using .) + HotKeyItem( + hotKey: HotKey( + KeyCode.period, + modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => context + .read() + .add(const HomeSettingEvent.collapseMenu()), + ), + + // Toggle theme mode light/dark + HotKeyItem( + hotKey: HotKey( + KeyCode.keyL, + modifiers: [ + Platform.isMacOS ? KeyModifier.meta : KeyModifier.control, + KeyModifier.shift, + ], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => + context.read().toggleThemeMode(), + ), + + // Close current tab + HotKeyItem( + hotKey: HotKey( + KeyCode.keyW, + modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => + context.read().add(const TabsEvent.closeCurrentTab()), + ), + + // Go to previous tab + HotKeyItem( + hotKey: HotKey( + KeyCode.pageUp, + modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => _selectTab(context, -1), + ), + + // Go to next tab + HotKeyItem( + hotKey: HotKey( + KeyCode.pageDown, + modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => _selectTab(context, 1), + ), + + // Rename current view + HotKeyItem( + hotKey: HotKey( + KeyCode.f2, + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => + getIt().add(const RenameViewEvent.open()), + ), + + // Scale up/down the app + // In some keyboards, the system returns equal as + keycode, while others may return add as + keycode, so add them both as zoom in key. + ...zoomInKeyCodes.map( + (keycode) => HotKeyItem( + hotKey: HotKey( + keycode, + modifiers: [ + Platform.isMacOS ? KeyModifier.meta : KeyModifier.control, + ], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => _scaleWithStep(0.1), + ), + ), + + ...zoomOutKeyCodes.map( + (keycode) => HotKeyItem( + hotKey: HotKey( + keycode, + modifiers: [ + Platform.isMacOS ? KeyModifier.meta : KeyModifier.control, + ], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => _scaleWithStep(-0.1), + ), + ), + + // Reset app scaling + ...resetZoomKeyCodes.map( + (keycode) => HotKeyItem( + hotKey: HotKey( + keycode, + modifiers: [ + Platform.isMacOS ? KeyModifier.meta : KeyModifier.control, + ], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => _scale(1), + ), + ), + + // Switch to the next space + HotKeyItem( + hotKey: HotKey( + KeyCode.keyO, + modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => switchToTheNextSpace.value++, + ), + + // Create a new page + HotKeyItem( + hotKey: HotKey( + KeyCode.keyN, + modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], + scope: HotKeyScope.inapp, + ), + keyDownHandler: (_) => createNewPageNotifier.value++, + ), + + // Open settings dialog + openSettingsHotKey(context, widget.userProfile), + ]; + + @override + void initState() { + super.initState(); + _registerHotKeys(context); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _registerHotKeys(context); + } + + @override + Widget build(BuildContext context) => widget.child; + + void _registerHotKeys(BuildContext context) { + for (final element in items) { + element.register(); + } + } + + void _selectTab(BuildContext context, int change) { + final bloc = context.read(); + bloc.add(TabsEvent.selectTab(bloc.state.currentIndex + change)); + } + + Future _scaleWithStep(double step) async { + final currentScaleFactor = await windowSizeManager.getScaleFactor(); + final textScale = (currentScaleFactor + step).clamp( + WindowSizeManager.minScaleFactor, + WindowSizeManager.maxScaleFactor, + ); + + Log.info('scale the app from $currentScaleFactor to $textScale'); + + await _scale(textScale); + } + + Future _scale(double scaleFactor) async { + if (FlowyRunner.currentMode == IntegrationMode.integrationTest) { + // The integration test will fail if we check the scale factor in the test. + // #0 ScaledWidgetsFlutterBinding.Eval () + // #1 ScaledWidgetsFlutterBinding.instance (package:scaled_app/scaled_app.dart:66:62) + appflowyScaleFactor = scaleFactor; + } else { + ScaledWidgetsFlutterBinding.instance.scaleFactor = (_) => scaleFactor; + } + + await windowSizeManager.setScaleFactor(scaleFactor); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu_shared_state.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu_shared_state.dart new file mode 100644 index 0000000000000..8c9d8004703b1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/menu_shared_state.dart @@ -0,0 +1,29 @@ +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; + +class MenuSharedState { + MenuSharedState({ + ViewPB? view, + }) { + _latestOpenView.value = view; + } + + final ValueNotifier _latestOpenView = ValueNotifier(null); + + ViewPB? get latestOpenView => _latestOpenView.value; + ValueNotifier get notifier => _latestOpenView; + + set latestOpenView(ViewPB? view) { + if (_latestOpenView.value?.id != view?.id) { + _latestOpenView.value = view; + } + } + + void addLatestViewListener(VoidCallback listener) { + _latestOpenView.addListener(listener); + } + + void removeLatestViewListener(VoidCallback listener) { + _latestOpenView.removeListener(listener); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart new file mode 100644 index 0000000000000..7751c0782b0d5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart @@ -0,0 +1,179 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_menu.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_more_actions.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_pin_action.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class FavoriteFolder extends StatefulWidget { + const FavoriteFolder({super.key, required this.views}); + + final List views; + + @override + State createState() => _FavoriteFolderState(); +} + +class _FavoriteFolderState extends State { + final isHovered = ValueNotifier(false); + + @override + void dispose() { + isHovered.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.views.isEmpty) { + return const SizedBox.shrink(); + } + + return BlocProvider( + create: (context) => FolderBloc(type: FolderSpaceType.favorite) + ..add(const FolderEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + return MouseRegion( + onEnter: (_) => isHovered.value = true, + onExit: (_) => isHovered.value = false, + child: Column( + children: [ + FavoriteHeader( + onPressed: () => context + .read() + .add(const FolderEvent.expandOrUnExpand()), + ), + // pages + ..._buildViews(context, state, isHovered), + if (state.isExpanded) ...[ + // more button + const VSpace(2), + const FavoriteMoreButton(), + ], + ], + ), + ); + }, + ), + ); + } + + Iterable _buildViews( + BuildContext context, + FolderState state, + ValueNotifier isHovered, + ) { + if (!state.isExpanded) { + return []; + } + + final pinnedViews = + context.read().state.pinnedViews.map((e) => e.item); + + return pinnedViews.map( + (view) => ViewItem( + key: ValueKey('${FolderSpaceType.favorite.name} ${view.id}'), + spaceType: FolderSpaceType.favorite, + isDraggable: false, + isFirstChild: view.id == widget.views.first.id, + isFeedback: false, + view: view, + enableRightClickContext: true, + leftPadding: HomeSpaceViewSizes.leftPadding, + leftIconBuilder: (_, __) => + const HSpace(HomeSpaceViewSizes.leftPadding), + level: 0, + isHovered: isHovered, + rightIconsBuilder: (context, view) => [ + FavoriteMoreActions(view: view), + const HSpace(8.0), + FavoritePinAction(view: view), + const HSpace(4.0), + ], + shouldRenderChildren: false, + shouldLoadChildViews: false, + onTertiarySelected: (_, view) => context.read().openTab(view), + onSelected: (_, view) { + if (HardwareKeyboard.instance.isControlPressed) { + context.read().openTab(view); + } + + context.read().openPlugin(view); + }, + ), + ); + } +} + +class FavoriteHeader extends StatelessWidget { + const FavoriteHeader({super.key, required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: HomeSizes.newPageSectionHeight, + child: FlowyButton( + onTap: onPressed, + margin: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 3.0), + leftIcon: const FlowySvg( + FlowySvgs.favorite_header_icon_m, + blendMode: null, + ), + leftIconSize: const Size.square(24.0), + iconPadding: 8.0, + text: FlowyText.regular( + LocaleKeys.sideBar_favorites.tr(), + lineHeight: 1.15, + ), + ), + ); + } +} + +class FavoriteMoreButton extends StatelessWidget { + const FavoriteMoreButton({super.key}); + + @override + Widget build(BuildContext context) { + final favoriteBloc = context.watch(); + final tabsBloc = context.read(); + final unpinnedViews = favoriteBloc.state.unpinnedViews; + // only show the more button if there are unpinned views + if (unpinnedViews.isEmpty) { + return const SizedBox.shrink(); + } + + const minWidth = 260.0; + return AppFlowyPopover( + constraints: const BoxConstraints( + minWidth: minWidth, + ), + popupBuilder: (_) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: favoriteBloc), + BlocProvider.value(value: tabsBloc), + ], + child: const FavoriteMenu(minWidth: minWidth), + ), + margin: EdgeInsets.zero, + child: FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 7.0), + leftIcon: const FlowySvg(FlowySvgs.workspace_three_dots_s), + text: FlowyText.regular(LocaleKeys.button_more.tr()), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_menu.dart new file mode 100644 index 0000000000000..1bf6635037047 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_menu.dart @@ -0,0 +1,199 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_menu_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_more_actions.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_pin_action.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +const double _kHorizontalPadding = 10.0; +const double _kVerticalPadding = 10.0; + +class FavoriteMenu extends StatelessWidget { + const FavoriteMenu({super.key, required this.minWidth}); + + final double minWidth; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only( + left: _kHorizontalPadding, + right: _kHorizontalPadding, + top: _kVerticalPadding, + bottom: _kVerticalPadding, + ), + child: BlocProvider( + create: (context) => + FavoriteMenuBloc()..add(const FavoriteMenuEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const VSpace(4), + SpaceSearchField( + width: minWidth - 2 * _kHorizontalPadding, + onSearch: (context, text) { + context + .read() + .add(FavoriteMenuEvent.search(text)); + }, + ), + const VSpace(12), + _FavoriteGroups( + minWidth: minWidth, + state: state, + ), + ], + ); + }, + ), + ), + ); + } +} + +class _FavoriteGroupedViews extends StatelessWidget { + const _FavoriteGroupedViews({ + required this.views, + }); + + final List views; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: views + .map( + (e) => ViewItem( + key: ValueKey(e.id), + view: e, + spaceType: FolderSpaceType.favorite, + level: 0, + onSelected: (_, view) { + context.read().openPlugin(view); + PopoverContainer.maybeOf(context)?.close(); + }, + isFeedback: false, + isDraggable: false, + shouldRenderChildren: false, + extendBuilder: (view) => view.isPinned + ? [ + const HSpace(4.0), + const FlowySvg( + FlowySvgs.favorite_pin_s, + blendMode: null, + ), + ] + : [], + leftIconBuilder: (_, __) => const HSpace(4.0), + rightIconsBuilder: (_, view) => [ + FavoriteMoreActions(view: view), + const HSpace(6.0), + FavoritePinAction(view: view), + const HSpace(4.0), + ], + ), + ) + .toList(), + ); + } +} + +class _FavoriteGroups extends StatelessWidget { + const _FavoriteGroups({ + required this.minWidth, + required this.state, + }); + + final double minWidth; + final FavoriteMenuState state; + + @override + Widget build(BuildContext context) { + final today = _buildGroups( + context, + state.todayViews, + LocaleKeys.sideBar_today.tr(), + ); + final thisWeek = _buildGroups( + context, + state.thisWeekViews, + LocaleKeys.sideBar_thisWeek.tr(), + ); + final others = _buildGroups( + context, + state.otherViews, + LocaleKeys.sideBar_others.tr(), + ); + + return Container( + width: minWidth - 2 * _kHorizontalPadding, + constraints: const BoxConstraints( + maxHeight: 300, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (today.isNotEmpty) ...[ + ...today, + ], + if (thisWeek.isNotEmpty) ...[ + if (today.isNotEmpty) ...[ + const FlowyDivider(), + const VSpace(16), + ], + ...thisWeek, + ], + if ((thisWeek.isNotEmpty || today.isNotEmpty) && + others.isNotEmpty) ...[ + const FlowyDivider(), + const VSpace(16), + ], + ...others.isNotEmpty && (today.isNotEmpty || thisWeek.isNotEmpty) + ? others + : _buildGroups( + context, + state.otherViews, + LocaleKeys.sideBar_others.tr(), + showHeader: false, + ), + ], + ), + ), + ); + } + + List _buildGroups( + BuildContext context, + List views, + String title, { + bool showHeader = true, + }) { + return [ + if (views.isNotEmpty) ...[ + if (showHeader) + FlowyText( + title, + fontSize: 12.0, + color: Theme.of(context).hintColor, + ), + const VSpace(2), + _FavoriteGroupedViews(views: views), + const VSpace(8), + ], + ]; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_menu_bloc.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_menu_bloc.dart new file mode 100644 index 0000000000000..443e8a98406b8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_menu_bloc.dart @@ -0,0 +1,119 @@ +import 'package:appflowy/workspace/application/favorite/favorite_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'favorite_menu_bloc.freezed.dart'; + +class FavoriteMenuBloc extends Bloc { + FavoriteMenuBloc() : super(FavoriteMenuState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + final favoriteViews = await _service.readFavorites(); + List views = []; + List todayViews = []; + List thisWeekViews = []; + List otherViews = []; + + favoriteViews.onSuccess((s) { + _source = s; + (views, todayViews, thisWeekViews, otherViews) = _getViews(s); + }); + + emit( + state.copyWith( + views: views, + queriedViews: views, + todayViews: todayViews, + thisWeekViews: thisWeekViews, + otherViews: otherViews, + ), + ); + }, + search: (query) async { + if (_source == null) { + return; + } + var (views, todayViews, thisWeekViews, otherViews) = + _getViews(_source!); + var queriedViews = views; + + if (query.isNotEmpty) { + queriedViews = _filter(views, query); + todayViews = _filter(todayViews, query); + thisWeekViews = _filter(thisWeekViews, query); + otherViews = _filter(otherViews, query); + } + + emit( + state.copyWith( + views: views, + queriedViews: queriedViews, + todayViews: todayViews, + thisWeekViews: thisWeekViews, + otherViews: otherViews, + ), + ); + }, + ); + }, + ); + } + + final FavoriteService _service = FavoriteService(); + RepeatedFavoriteViewPB? _source; + + List _filter(List views, String query) => views + .where((view) => view.name.toLowerCase().contains(query.toLowerCase())) + .toList(); + + // all, today, last week, other + (List, List, List, List) _getViews( + RepeatedFavoriteViewPB source, + ) { + final now = DateTime.now(); + + final List views = source.items.map((v) => v.item).toList(); + final List todayViews = []; + final List thisWeekViews = []; + final List otherViews = []; + + for (final favoriteView in source.items) { + final view = favoriteView.item; + final date = DateTime.fromMillisecondsSinceEpoch( + favoriteView.timestamp.toInt() * 1000, + ); + final diff = now.difference(date).inDays; + if (diff == 0) { + todayViews.add(view); + } else if (diff < 7) { + thisWeekViews.add(view); + } else { + otherViews.add(view); + } + } + + return (views, todayViews, thisWeekViews, otherViews); + } +} + +@freezed +class FavoriteMenuEvent with _$FavoriteMenuEvent { + const factory FavoriteMenuEvent.initial() = Initial; + const factory FavoriteMenuEvent.search(String query) = Search; +} + +@freezed +class FavoriteMenuState with _$FavoriteMenuState { + const factory FavoriteMenuState({ + @Default([]) List views, + @Default([]) List queriedViews, + @Default([]) List todayViews, + @Default([]) List thisWeekViews, + @Default([]) List otherViews, + }) = _FavoriteMenuState; + + factory FavoriteMenuState.initial() => const FavoriteMenuState(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_more_actions.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_more_actions.dart new file mode 100644 index 0000000000000..09b8a4484297c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_more_actions.dart @@ -0,0 +1,79 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class FavoriteMoreActions extends StatelessWidget { + const FavoriteMoreActions({super.key, required this.view}); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(), + child: ViewMoreActionPopover( + view: view, + spaceType: FolderSpaceType.favorite, + isExpanded: false, + onEditing: (value) => + context.read().add(ViewEvent.setIsEditing(value)), + onAction: (action, _) { + switch (action) { + case ViewMoreActionType.favorite: + case ViewMoreActionType.unFavorite: + context.read().add(FavoriteEvent.toggle(view)); + PopoverContainer.maybeOf(context)?.closeAll(); + break; + case ViewMoreActionType.rename: + NavigatorTextFieldDialog( + title: LocaleKeys.disclosureAction_rename.tr(), + autoSelectAllText: true, + value: view.nameOrDefault, + maxLength: 256, + onConfirm: (newValue, _) { + // can not use bloc here because it has been disposed. + ViewBackendService.updateView( + viewId: view.id, + name: newValue, + ); + }, + ).show(context); + PopoverContainer.maybeOf(context)?.closeAll(); + break; + + case ViewMoreActionType.openInNewTab: + getIt().openTab(view); + break; + case ViewMoreActionType.delete: + case ViewMoreActionType.duplicate: + default: + throw UnsupportedError('$action is not supported'); + } + }, + buildChild: (popover) => FlowyIconButton( + width: 24, + icon: const FlowySvg(FlowySvgs.workspace_three_dots_s), + onPressed: () { + popover.show(); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_pin_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_pin_action.dart new file mode 100644 index 0000000000000..3bd2ffe67f8b1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_pin_action.dart @@ -0,0 +1,39 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class FavoritePinAction extends StatelessWidget { + const FavoritePinAction({super.key, required this.view}); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + final tooltip = view.isPinned + ? LocaleKeys.favorite_removeFromSidebar.tr() + : LocaleKeys.favorite_addToSidebar.tr(); + final icon = FlowySvg( + view.isPinned + ? FlowySvgs.favorite_section_unpin_s + : FlowySvgs.favorite_section_pin_s, + ); + return FlowyTooltip( + message: tooltip, + child: FlowyIconButton( + width: 24, + icon: icon, + onPressed: () { + view.isPinned + ? context.read().add(FavoriteEvent.unpin(view)) + : context.read().add(FavoriteEvent.pin(view)); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_pin_bloc.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_pin_bloc.dart new file mode 100644 index 0000000000000..e4a335e34bac7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/favorites/favorite_pin_bloc.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/workspace/application/favorite/favorite_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'favorite_pin_bloc.freezed.dart'; + +class FavoritePinBloc extends Bloc { + FavoritePinBloc() : super(FavoritePinState.initial()) { + on( + (event, emit) async { + await event.when( + initial: () async { + final List views = await _service + .readFavorites() + .fold((s) => s.items.map((v) => v.item).toList(), (f) => []); + emit(state.copyWith(views: views, queriedViews: views)); + }, + search: (query) async { + if (query.isEmpty) { + emit(state.copyWith(queriedViews: state.views)); + return; + } + + final queriedViews = state.views + .where( + (view) => + view.name.toLowerCase().contains(query.toLowerCase()), + ) + .toList(); + emit(state.copyWith(queriedViews: queriedViews)); + }, + ); + }, + ); + } + + final FavoriteService _service = FavoriteService(); +} + +@freezed +class FavoritePinEvent with _$FavoritePinEvent { + const factory FavoritePinEvent.initial() = Initial; + const factory FavoritePinEvent.search(String query) = Search; +} + +@freezed +class FavoritePinState with _$FavoritePinState { + const factory FavoritePinState({ + @Default([]) List views, + @Default([]) List queriedViews, + @Default([]) List> todayViews, + @Default([]) List> lastWeekViews, + @Default([]) List> otherViews, + }) = _FavoritePinState; + + factory FavoritePinState.initial() => const FavoritePinState(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_folder_header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_folder_header.dart new file mode 100644 index 0000000000000..d73060d0b3f31 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_folder_header.dart @@ -0,0 +1,79 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +class FolderHeader extends StatefulWidget { + const FolderHeader({ + super.key, + required this.title, + required this.expandButtonTooltip, + required this.addButtonTooltip, + required this.onPressed, + required this.onAdded, + required this.isExpanded, + }); + + final String title; + final String expandButtonTooltip; + final String addButtonTooltip; + final VoidCallback onPressed; + final VoidCallback onAdded; + final bool isExpanded; + + @override + State createState() => _FolderHeaderState(); +} + +class _FolderHeaderState extends State { + final isHovered = ValueNotifier(false); + + @override + void dispose() { + isHovered.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: HomeSizes.workspaceSectionHeight, + child: MouseRegion( + onEnter: (_) => isHovered.value = true, + onExit: (_) => isHovered.value = false, + child: FlowyButton( + onTap: widget.onPressed, + margin: const EdgeInsets.only(left: 6.0, right: 4.0), + rightIcon: ValueListenableBuilder( + valueListenable: isHovered, + builder: (context, onHover, child) => + Opacity(opacity: onHover ? 1 : 0, child: child), + child: FlowyIconButton( + width: 24, + iconPadding: const EdgeInsets.all(4.0), + tooltipText: widget.addButtonTooltip, + icon: const FlowySvg(FlowySvgs.view_item_add_s), + onPressed: widget.onAdded, + ), + ), + iconPadding: 10.0, + text: Row( + children: [ + FlowyText( + widget.title, + lineHeight: 1.15, + ), + const HSpace(4.0), + FlowySvg( + widget.isExpanded + ? FlowySvgs.workspace_drop_down_menu_show_s + : FlowySvgs.workspace_drop_down_menu_hide_s, + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart new file mode 100644 index 0000000000000..0b383cc5a1980 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart @@ -0,0 +1,144 @@ +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/_folder_header.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SectionFolder extends StatefulWidget { + const SectionFolder({ + super.key, + required this.title, + required this.spaceType, + required this.views, + this.isHoverEnabled = true, + required this.expandButtonTooltip, + required this.addButtonTooltip, + }); + + final String title; + final FolderSpaceType spaceType; + final List views; + final bool isHoverEnabled; + final String expandButtonTooltip; + final String addButtonTooltip; + + @override + State createState() => _SectionFolderState(); +} + +class _SectionFolderState extends State { + final isHovered = ValueNotifier(false); + + @override + void dispose() { + isHovered.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => isHovered.value = true, + onExit: (_) => isHovered.value = false, + child: BlocProvider( + create: (_) => FolderBloc(type: widget.spaceType) + ..add(const FolderEvent.initial()), + child: BlocBuilder( + builder: (context, state) => Column( + children: [ + _buildHeader(context), + // Pages + const VSpace(4.0), + ..._buildViews(context, state, isHovered), + // Add a placeholder if there are no views + _buildDraggablePlaceholder(context), + ], + ), + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return FolderHeader( + title: widget.title, + isExpanded: context.watch().state.isExpanded, + expandButtonTooltip: widget.expandButtonTooltip, + addButtonTooltip: widget.addButtonTooltip, + onPressed: () => + context.read().add(const FolderEvent.expandOrUnExpand()), + onAdded: () { + context.read().add( + SidebarSectionsEvent.createRootViewInSection( + name: '', + index: 0, + viewSection: widget.spaceType.toViewSectionPB, + ), + ); + + context + .read() + .add(const FolderEvent.expandOrUnExpand(isExpanded: true)); + }, + ); + } + + Iterable _buildViews( + BuildContext context, + FolderState state, + ValueNotifier isHovered, + ) { + if (!state.isExpanded) { + return []; + } + + return widget.views.map( + (view) => ViewItem( + key: ValueKey('${widget.spaceType.name} ${view.id}'), + spaceType: widget.spaceType, + isFirstChild: view.id == widget.views.first.id, + view: view, + level: 0, + leftPadding: HomeSpaceViewSizes.leftPadding, + isFeedback: false, + isHovered: isHovered, + enableRightClickContext: true, + onSelected: (viewContext, view) { + if (HardwareKeyboard.instance.isControlPressed) { + context.read().openTab(view); + } + + context.read().openPlugin(view); + }, + onTertiarySelected: (viewContext, view) => + context.read().openTab(view), + isHoverEnabled: widget.isHoverEnabled, + ), + ); + } + + Widget _buildDraggablePlaceholder(BuildContext context) { + if (widget.views.isNotEmpty) { + return const SizedBox.shrink(); + } + final parentViewId = + context.read().state.currentWorkspace?.workspaceId; + return ViewItem( + spaceType: widget.spaceType, + view: ViewPB(parentViewId: parentViewId ?? ''), + level: 0, + leftPadding: HomeSpaceViewSizes.leftPadding, + isFeedback: false, + onSelected: (_, __) {}, + isHoverEnabled: widget.isHoverEnabled, + isPlaceholder: true, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart new file mode 100644 index 0000000000000..57168b40a5bae --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart @@ -0,0 +1,111 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +import 'sidebar_footer_button.dart'; + +class SidebarFooter extends StatelessWidget { + const SidebarFooter({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (FeatureFlag.planBilling.isOn) + BillingGateGuard( + builder: (context) { + return const SidebarToast(); + }, + ), + Row( + // mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Expanded(child: SidebarTemplateButton()), + _buildVerticalDivider(context), + const Expanded(child: SidebarTrashButton()), + ], + ), + ], + ); + } + + Widget _buildVerticalDivider(BuildContext context) { + return Container( + width: 1.0, + height: 14, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: AFThemeExtension.of(context).borderColor, + ); + } +} + +class SidebarTemplateButton extends StatelessWidget { + const SidebarTemplateButton({super.key}); + + @override + Widget build(BuildContext context) { + return SidebarFooterButton( + leftIconSize: const Size.square(16.0), + leftIcon: const FlowySvg( + FlowySvgs.icon_template_s, + ), + text: LocaleKeys.template_label.tr(), + onTap: () => afLaunchUrlString('https://appflowy.io/templates'), + ); + } +} + +class SidebarTrashButton extends StatelessWidget { + const SidebarTrashButton({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: getIt().notifier, + builder: (context, value, child) { + return SidebarFooterButton( + leftIconSize: const Size.square(18.0), + leftIcon: const FlowySvg( + FlowySvgs.icon_delete_s, + ), + text: LocaleKeys.trash_text.tr(), + onTap: () { + getIt().latestOpenView = null; + getIt().add( + TabsEvent.openPlugin( + plugin: makePlugin(pluginType: PluginType.trash), + ), + ); + }, + ); + }, + ); + } +} + +class SidebarWidgetButton extends StatelessWidget { + const SidebarWidgetButton({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () {}, + child: const FlowySvg(FlowySvgs.sidebar_footer_widget_s), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer_button.dart new file mode 100644 index 0000000000000..cbb969d191d11 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_footer_button.dart @@ -0,0 +1,43 @@ +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +// This button style is used in +// - Trash button +// - Template button +class SidebarFooterButton extends StatelessWidget { + const SidebarFooterButton({ + super.key, + required this.leftIcon, + required this.leftIconSize, + required this.text, + required this.onTap, + }); + + final Widget leftIcon; + final Size leftIconSize; + final String text; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: HomeSizes.workspaceSectionHeight, + child: FlowyButton( + leftIcon: leftIcon, + leftIconSize: leftIconSize, + margin: const EdgeInsets.all(4.0), + expandText: false, + text: Padding( + padding: const EdgeInsets.only(right: 6.0), + child: FlowyText( + text, + fontWeight: FontWeight.w400, + figmaLineHeight: 18.0, + ), + ), + onTap: onTap, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart new file mode 100644 index 0000000000000..6d2b93be45216 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/footer/sidebar_toast.dart @@ -0,0 +1,299 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; +import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/billing/sidebar_plan_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SidebarToast extends StatelessWidget { + const SidebarToast({super.key}); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (_, state) { + // Show a dialog when the user hits the storage limit, After user click ok, it will navigate to the plan page. + // Even though the dislog is dissmissed, if the user triggers the storage limit again, the dialog will show again. + state.tierIndicator.maybeWhen( + storageLimitHit: () => WidgetsBinding.instance.addPostFrameCallback( + (_) => _showStorageLimitDialog(context), + ), + singleFileLimitHit: () => + WidgetsBinding.instance.addPostFrameCallback( + (_) => _showSingleFileLimitDialog(context), + ), + orElse: () {}, + ); + }, + builder: (_, state) { + return state.tierIndicator.when( + loading: () => const SizedBox.shrink(), + storageLimitHit: () => PlanIndicator( + planName: SubscriptionPlanPB.Free.label, + text: LocaleKeys.sideBar_upgradeToPro.tr(), + onTap: () => _handleOnTap(context, SubscriptionPlanPB.Pro), + reason: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), + ), + aiMaxiLimitHit: () => PlanIndicator( + planName: SubscriptionPlanPB.AiMax.label, + text: LocaleKeys.sideBar_upgradeToAIMax.tr(), + onTap: () => _handleOnTap(context, SubscriptionPlanPB.AiMax), + reason: LocaleKeys.sideBar_aiResponseLimitTitle.tr(), + ), + singleFileLimitHit: () => const SizedBox.shrink(), + ); + }, + ); + } + + void _showStorageLimitDialog(BuildContext context) => showConfirmDialog( + context: context, + title: LocaleKeys.sideBar_purchaseStorageSpace.tr(), + description: LocaleKeys.sideBar_storageLimitDialogTitle.tr(), + confirmLabel: + LocaleKeys.settings_comparePlanDialog_actions_upgrade.tr(), + onConfirm: () { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _handleOnTap(context, SubscriptionPlanPB.Pro), + ); + }, + ); + + void _showSingleFileLimitDialog(BuildContext context) => showConfirmDialog( + context: context, + title: LocaleKeys.sideBar_upgradeToPro.tr(), + description: + LocaleKeys.sideBar_singleFileProPlanLimitationDescription.tr(), + confirmLabel: + LocaleKeys.settings_comparePlanDialog_actions_upgrade.tr(), + onConfirm: () { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _handleOnTap(context, SubscriptionPlanPB.Pro), + ); + }, + ); + + void _handleOnTap(BuildContext context, SubscriptionPlanPB plan) { + final userProfile = context.read().state.userProfile; + if (userProfile == null) { + return Log.error( + 'UserProfile is null, this should NOT happen! Please file a bug report', + ); + } + + final userWorkspaceBloc = context.read(); + final role = userWorkspaceBloc.state.currentWorkspace?.role; + if (role == null) { + return Log.error( + "Member is null. It should not happen. If you see this error, it's a bug", + ); + } + + // Only if the user is the workspace owner will we navigate to the plan page. + if (role.isOwner) { + showSettingsDialog( + context, + userProfile, + userWorkspaceBloc, + SettingsPage.plan, + ); + } else { + final String message; + if (plan == SubscriptionPlanPB.AiMax) { + message = Platform.isIOS + ? LocaleKeys.sideBar_askOwnerToUpgradeToAIMaxIOS.tr() + : LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(); + } else { + message = Platform.isIOS + ? LocaleKeys.sideBar_askOwnerToUpgradeToProIOS.tr() + : LocaleKeys.sideBar_askOwnerToUpgradeToPro.tr(); + } + + showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: false, + builder: (dialogContext) => _AskOwnerToChangePlan( + message: message, + onOkPressed: () {}, + ), + ); + } + } +} + +class PlanIndicator extends StatefulWidget { + const PlanIndicator({ + super.key, + required this.planName, + required this.text, + required this.onTap, + required this.reason, + }); + + final String planName; + final String reason; + final String text; + final Function() onTap; + + @override + State createState() => _PlanIndicatorState(); +} + +class _PlanIndicatorState extends State { + final popoverController = PopoverController(); + + @override + void dispose() { + popoverController.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const textGradient = LinearGradient( + begin: Alignment.bottomLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF8032FF), Color(0xFFEF35FF)], + stops: [0.1545, 0.8225], + ); + + final backgroundGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + const Color(0xFF8032FF).withOpacity(.1), + const Color(0xFFEF35FF).withOpacity(.1), + ], + ); + + return AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.rightWithBottomAligned, + offset: const Offset(10, -12), + popupBuilder: (context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + widget.text, + color: AFThemeExtension.of(context).strongText, + ), + const VSpace(12), + Opacity( + opacity: 0.7, + child: FlowyText.regular( + widget.reason, + maxLines: null, + lineHeight: 1.3, + textAlign: TextAlign.center, + ), + ), + const VSpace(12), + Row( + children: [ + Expanded( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + popoverController.close(); + widget.onTap(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(9), + ), + child: Center( + child: FlowyText( + LocaleKeys + .settings_comparePlanDialog_actions_upgrade + .tr(), + color: Colors.white, + fontSize: 12, + strutStyle: const StrutStyle( + forceStrutHeight: true, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + ); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + gradient: backgroundGradient, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.upgrade_storage_s, + blendMode: null, + ), + const HSpace(6), + ShaderMask( + shaderCallback: (bounds) => textGradient.createShader(bounds), + blendMode: BlendMode.srcIn, + child: FlowyText( + widget.text, + color: AFThemeExtension.of(context).strongText, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AskOwnerToChangePlan extends StatelessWidget { + const _AskOwnerToChangePlan({ + required this.message, + required this.onOkPressed, + }); + final String message; + final VoidCallback onOkPressed; + + @override + Widget build(BuildContext context) { + return NavigatorOkCancelDialog( + message: message, + okTitle: LocaleKeys.button_ok.tr(), + onOkPressed: onOkPressed, + titleUpperCase: false, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart new file mode 100644 index 0000000000000..559c1899254a2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart @@ -0,0 +1,108 @@ +import 'dart:io' show Platform; + +import 'package:appflowy/core/frameless_window.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +/// Sidebar top menu is the top bar of the sidebar. +/// +/// in the top menu, we have: +/// - appflowy icon (Windows or Linux) +/// - close / expand sidebar button +class SidebarTopMenu extends StatelessWidget { + const SidebarTopMenu({ + super.key, + required this.isSidebarOnHover, + }); + + final ValueNotifier isSidebarOnHover; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, _) => SizedBox( + height: !UniversalPlatform.isWindows ? HomeSizes.topBarHeight : 45, + child: MoveWindowDetector( + child: Row( + children: [ + _buildLogoIcon(context), + const Spacer(), + _buildCollapseMenuButton(context), + ], + ), + ), + ), + ); + } + + Widget _buildLogoIcon(BuildContext context) { + if (Platform.isMacOS) { + return const SizedBox.shrink(); + } + + final svgData = Theme.of(context).brightness == Brightness.dark + ? FlowySvgs.flowy_logo_dark_mode_xl + : FlowySvgs.flowy_logo_text_xl; + + return Padding( + padding: const EdgeInsets.only(top: 12.0, left: 8), + child: FlowySvg( + svgData, + size: const Size(92, 17), + blendMode: null, + ), + ); + } + + Widget _buildCollapseMenuButton(BuildContext context) { + final textSpan = TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.sideBar_closeSidebar.tr()}\n', + style: context.tooltipTextStyle(), + ), + TextSpan( + text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\', + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], + ); + + return ValueListenableBuilder( + valueListenable: isSidebarOnHover, + builder: (_, value, ___) => Opacity( + opacity: value ? 1 : 0, + child: Padding( + padding: const EdgeInsets.only(top: 12.0, right: 6.0), + child: FlowyTooltip( + richMessage: textSpan, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => context + .read() + .add(const HomeSettingEvent.collapseMenu()), + child: FlowyHover( + child: Container( + width: 24, + padding: const EdgeInsets.all(4), + child: const FlowySvg(FlowySvgs.hide_menu_s), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_user.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_user.dart new file mode 100644 index 0000000000000..524934aa82f66 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/header/sidebar_user.dart @@ -0,0 +1,75 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/menu/menu_user_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +// keep this widget in case we need to roll back (lucas.xu) +class SidebarUser extends StatelessWidget { + const SidebarUser({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => + MenuUserBloc(userProfile)..add(const MenuUserEvent.initial()), + child: BlocBuilder( + builder: (context, state) => Row( + children: [ + const HSpace(4), + UserAvatar( + iconUrl: state.userProfile.iconUrl, + name: state.userProfile.name, + size: 24.0, + fontSize: 16.0, + decoration: ShapeDecoration( + color: const Color(0xFFFBE8FB), + shape: RoundedRectangleBorder( + side: const BorderSide(width: 0.50, color: Color(0x19171717)), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + const HSpace(8), + Expanded(child: _buildUserName(context, state)), + UserSettingButton(userProfile: state.userProfile), + const HSpace(8.0), + const NotificationButton(), + const HSpace(10.0), + ], + ), + ), + ); + } + + Widget _buildUserName(BuildContext context, MenuUserState state) { + final String name = _userName(state.userProfile); + return FlowyText.medium( + name, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).colorScheme.tertiary, + fontSize: 15.0, + ); + } + + /// Return the user name, if the user name is empty, return the default user name. + String _userName(UserProfilePB userProfile) { + String name = userProfile.name; + if (name.isEmpty) { + name = LocaleKeys.defaultUsername.tr(); + } + return name; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/import/import_panel.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/import/import_panel.dart new file mode 100644 index 0000000000000..6a23c7def989e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/import/import_panel.dart @@ -0,0 +1,236 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/share/import_service.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/import/import_type.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/container.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; + +typedef ImportCallback = void Function( + ImportType type, + String name, + List? document, +); + +Future showImportPanel( + String parentViewId, + BuildContext context, + ImportCallback callback, +) async { + await FlowyOverlay.show( + context: context, + builder: (context) => FlowyDialog( + backgroundColor: Theme.of(context).colorScheme.surface, + title: FlowyText.semibold( + LocaleKeys.moreAction_import.tr(), + fontSize: 20, + color: Theme.of(context).colorScheme.tertiary, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 10.0, + horizontal: 20.0, + ), + child: ImportPanel( + parentViewId: parentViewId, + importCallback: callback, + ), + ), + ), + ); +} + +class ImportPanel extends StatefulWidget { + const ImportPanel({ + super.key, + required this.parentViewId, + required this.importCallback, + }); + + final String parentViewId; + final ImportCallback importCallback; + + @override + State createState() => _ImportPanelState(); +} + +class _ImportPanelState extends State { + final flowyContainerFocusNode = FocusNode(); + final ValueNotifier showLoading = ValueNotifier(false); + + @override + void dispose() { + flowyContainerFocusNode.dispose(); + showLoading.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width * 0.7; + final height = width * 0.5; + return KeyboardListener( + autofocus: true, + focusNode: flowyContainerFocusNode, + onKeyEvent: (event) { + if (event is KeyDownEvent && + event.physicalKey == PhysicalKeyboardKey.escape) { + FlowyOverlay.pop(context); + } + }, + child: Stack( + children: [ + FlowyContainer( + Theme.of(context).colorScheme.surface, + height: height, + width: width, + child: GridView.count( + childAspectRatio: 1 / .2, + crossAxisCount: 2, + children: ImportType.values + .where((element) => element.enableOnRelease) + .map( + (e) => Card( + child: FlowyButton( + leftIcon: e.icon(context), + leftIconSize: const Size.square(20), + text: FlowyText.medium( + e.toString(), + fontSize: 15, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).colorScheme.tertiary, + ), + onTap: () async { + await _importFile(widget.parentViewId, e); + if (context.mounted) { + FlowyOverlay.pop(context); + } + }, + ), + ), + ) + .toList(), + ), + ), + ValueListenableBuilder( + valueListenable: showLoading, + builder: (context, showLoading, child) { + if (!showLoading) { + return const SizedBox.shrink(); + } + return const Center( + child: CircularProgressIndicator(), + ); + }, + ), + ], + ), + ); + } + + Future _importFile(String parentViewId, ImportType importType) async { + final result = await getIt().pickFiles( + type: FileType.custom, + allowMultiple: importType.allowMultiSelect, + allowedExtensions: importType.allowedExtensions, + ); + if (result == null || result.files.isEmpty) { + return; + } + + showLoading.value = true; + + final importValues = []; + for (final file in result.files) { + final path = file.path; + if (path == null) { + continue; + } + final name = p.basenameWithoutExtension(path); + + switch (importType) { + case ImportType.historyDatabase: + final data = await File(path).readAsString(); + importValues.add( + ImportItemPayloadPB.create() + ..name = name + ..data = utf8.encode(data) + ..viewLayout = ViewLayoutPB.Grid + ..importType = ImportTypePB.HistoryDatabase, + ); + break; + case ImportType.historyDocument: + case ImportType.markdownOrText: + final data = await File(path).readAsString(); + final bytes = _documentDataFrom(importType, data); + if (bytes != null) { + importValues.add( + ImportItemPayloadPB.create() + ..name = name + ..data = bytes + ..viewLayout = ViewLayoutPB.Document + ..importType = ImportTypePB.Markdown, + ); + } + break; + case ImportType.csv: + final data = await File(path).readAsString(); + importValues.add( + ImportItemPayloadPB.create() + ..name = name + ..data = utf8.encode(data) + ..viewLayout = ViewLayoutPB.Grid + ..importType = ImportTypePB.CSV, + ); + break; + case ImportType.afDatabase: + final data = await File(path).readAsString(); + importValues.add( + ImportItemPayloadPB.create() + ..name = name + ..data = utf8.encode(data) + ..viewLayout = ViewLayoutPB.Grid + ..importType = ImportTypePB.AFDatabase, + ); + break; + default: + break; + } + } + + if (importValues.isNotEmpty) { + await ImportBackendService.importPages( + parentViewId, + importValues, + ); + } + + showLoading.value = false; + widget.importCallback(importType, '', null); + } +} + +Uint8List? _documentDataFrom(ImportType importType, String data) { + switch (importType) { + case ImportType.historyDocument: + final document = EditorMigration.migrateDocument(data); + return DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer(); + case ImportType.markdownOrText: + final document = customMarkdownToDocument(data); + return DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer(); + default: + assert(false, 'Unsupported Type $importType'); + return null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/import/import_type.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/import/import_type.dart new file mode 100644 index 0000000000000..5c7c297327bf9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/import/import_type.dart @@ -0,0 +1,84 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +enum ImportType { + historyDocument, + historyDatabase, + markdownOrText, + csv, + afDatabase; + + @override + String toString() { + switch (this) { + case ImportType.historyDocument: + return LocaleKeys.importPanel_documentFromV010.tr(); + case ImportType.historyDatabase: + return LocaleKeys.importPanel_databaseFromV010.tr(); + case ImportType.markdownOrText: + return LocaleKeys.importPanel_textAndMarkdown.tr(); + case ImportType.csv: + return LocaleKeys.importPanel_csv.tr(); + case ImportType.afDatabase: + return LocaleKeys.importPanel_database.tr(); + } + } + + WidgetBuilder get icon => (context) { + final FlowySvgData svg; + switch (this) { + case ImportType.historyDatabase: + svg = FlowySvgs.document_s; + case ImportType.historyDocument: + case ImportType.csv: + case ImportType.afDatabase: + svg = FlowySvgs.board_s; + case ImportType.markdownOrText: + svg = FlowySvgs.text_s; + } + + return FlowySvg( + svg, + color: Theme.of(context).colorScheme.tertiary, + ); + }; + + bool get enableOnRelease { + switch (this) { + case ImportType.historyDatabase: + case ImportType.historyDocument: + case ImportType.afDatabase: + return kDebugMode; + default: + return true; + } + } + + List get allowedExtensions { + switch (this) { + case ImportType.historyDocument: + return ['afdoc']; + case ImportType.historyDatabase: + case ImportType.afDatabase: + return ['afdb']; + case ImportType.markdownOrText: + return ['md', 'txt']; + case ImportType.csv: + return ['csv']; + } + } + + bool get allowMultiSelect { + switch (this) { + case ImportType.historyDocument: + case ImportType.historyDatabase: + case ImportType.csv: + case ImportType.afDatabase: + case ImportType.markdownOrText: + return true; + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/move_to/move_page_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/move_to/move_page_menu.dart new file mode 100644 index 0000000000000..631e20f14a49b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/move_to/move_page_menu.dart @@ -0,0 +1,172 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_search_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +typedef MovePageMenuOnSelected = void Function(ViewPB space, ViewPB view); + +class MovePageMenu extends StatefulWidget { + const MovePageMenu({ + super.key, + required this.sourceView, + required this.onSelected, + }); + + final ViewPB sourceView; + final MovePageMenuOnSelected onSelected; + + @override + State createState() => _MovePageMenuState(); +} + +class _MovePageMenuState extends State { + final isExpandedNotifier = PropertyValueNotifier(true); + final isHoveredNotifier = ValueNotifier(true); + + @override + void dispose() { + isExpandedNotifier.dispose(); + isHoveredNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SpaceSearchBloc()..add(const SpaceSearchEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + final space = state.currentSpace; + if (space == null) { + return const SizedBox.shrink(); + } + + return Column( + children: [ + SpaceSearchField( + width: 240, + onSearch: (context, value) => context + .read() + .add(SpaceSearchEvent.search(value)), + ), + const VSpace(10), + BlocBuilder( + builder: (context, state) { + if (state.queryResults == null) { + return Expanded(child: _buildSpace(space)); + } + return Expanded( + child: _buildGroupedViews(space, state.queryResults!), + ); + }, + ), + ], + ); + }, + ), + ); + } + + Widget _buildGroupedViews(ViewPB space, List views) { + final groupedViews = views + .where((v) => !_shouldIgnoreView(v, widget.sourceView) && !v.isSpace) + .toList(); + return _MovePageGroupedViews( + views: groupedViews, + onSelected: (view) => widget.onSelected(space, view), + ); + } + + Column _buildSpace(ViewPB space) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SpacePopup( + useIntrinsicWidth: false, + expand: true, + height: 30, + showCreateButton: false, + child: FlowyTooltip( + message: LocaleKeys.space_switchSpace.tr(), + child: CurrentSpace( + // move the page to current space + onTapBlankArea: () => widget.onSelected(space, space), + space: space, + ), + ), + ), + Expanded( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: SpacePages( + key: ValueKey(space.id), + space: space, + isHovered: isHoveredNotifier, + isExpandedNotifier: isExpandedNotifier, + shouldIgnoreView: (view) { + if (_shouldIgnoreView(view, widget.sourceView)) { + return IgnoreViewType.hide; + } + if (view.layout != ViewLayoutPB.Document) { + return IgnoreViewType.disable; + } + return IgnoreViewType.none; + }, + // hide the hover status and disable the editing actions + disableSelectedStatus: true, + // hide the ... and + buttons + rightIconsBuilder: (context, view) => [], + onSelected: (_, view) => widget.onSelected(space, view), + ), + ), + ), + ], + ); + } +} + +class _MovePageGroupedViews extends StatelessWidget { + const _MovePageGroupedViews({required this.views, required this.onSelected}); + + final List views; + final void Function(ViewPB view) onSelected; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: views + .map( + (view) => ViewItem( + key: ValueKey(view.id), + view: view, + spaceType: FolderSpaceType.unknown, + level: 0, + onSelected: (_, view) => onSelected(view), + isFeedback: false, + isDraggable: false, + shouldRenderChildren: false, + leftIconBuilder: (_, __) => const HSpace(0.0), + rightIconsBuilder: (_, view) => [], + ), + ) + .toList(), + ), + ); + } +} + +bool _shouldIgnoreView(ViewPB view, ViewPB sourceView) { + return view.id == sourceView.id; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart new file mode 100644 index 0000000000000..c27f259b68c0a --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart @@ -0,0 +1,113 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/folder/_section_folder.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SidebarFolder extends StatelessWidget { + const SidebarFolder({ + super.key, + this.isHoverEnabled = true, + required this.userProfile, + }); + + final bool isHoverEnabled; + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + const sectionPadding = 16.0; + return ValueListenableBuilder( + valueListenable: getIt().notifier, + builder: (context, value, child) { + return Column( + children: [ + const VSpace(4.0), + // favorite + BlocBuilder( + builder: (context, state) { + if (state.views.isEmpty) { + return const SizedBox.shrink(); + } + return FavoriteFolder( + views: state.views.map((e) => e.item).toList(), + ); + }, + ), + // public or private + BlocBuilder( + builder: (context, state) { + // only show public and private section if the workspace is collaborative and not local + final isCollaborativeWorkspace = + context.read().state.isCollabWorkspaceOn; + + // only show public and private section if the workspace is collaborative + return Column( + children: isCollaborativeWorkspace + ? [ + // public + const VSpace(sectionPadding), + PublicSectionFolder(views: state.section.publicViews), + + // private + const VSpace(sectionPadding), + PrivateSectionFolder( + views: state.section.privateViews, + ), + ] + : [ + // personal + const VSpace(sectionPadding), + PersonalSectionFolder( + views: state.section.publicViews, + ), + ], + ); + }, + ), + const VSpace(200), + ], + ); + }, + ); + } +} + +class PrivateSectionFolder extends SectionFolder { + PrivateSectionFolder({super.key, required super.views}) + : super( + title: LocaleKeys.sideBar_private.tr(), + spaceType: FolderSpaceType.private, + expandButtonTooltip: LocaleKeys.sideBar_clickToHidePrivate.tr(), + addButtonTooltip: LocaleKeys.sideBar_addAPageToPrivate.tr(), + ); +} + +class PublicSectionFolder extends SectionFolder { + PublicSectionFolder({super.key, required super.views}) + : super( + title: LocaleKeys.sideBar_workspace.tr(), + spaceType: FolderSpaceType.public, + expandButtonTooltip: LocaleKeys.sideBar_clickToHideWorkspace.tr(), + addButtonTooltip: LocaleKeys.sideBar_addAPageToWorkspace.tr(), + ); +} + +class PersonalSectionFolder extends SectionFolder { + PersonalSectionFolder({super.key, required super.views}) + : super( + title: LocaleKeys.sideBar_personal.tr(), + spaceType: FolderSpaceType.public, + expandButtonTooltip: LocaleKeys.sideBar_clickToHidePersonal.tr(), + addButtonTooltip: LocaleKeys.sideBar_addAPage.tr(), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart new file mode 100644 index 0000000000000..d35c4cd1489b5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart @@ -0,0 +1,83 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SidebarNewPageButton extends StatefulWidget { + const SidebarNewPageButton({ + super.key, + }); + + @override + State createState() => _SidebarNewPageButtonState(); +} + +class _SidebarNewPageButtonState extends State { + @override + void initState() { + super.initState(); + createNewPageNotifier.addListener(_createNewPage); + } + + @override + void dispose() { + createNewPageNotifier.removeListener(_createNewPage); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + height: HomeSizes.newPageSectionHeight, + child: FlowyButton( + onTap: () async => _createNewPage(), + leftIcon: const FlowySvg( + FlowySvgs.new_app_m, + blendMode: null, + ), + leftIconSize: const Size.square(24.0), + margin: const EdgeInsets.only(left: 4.0), + iconPadding: 8.0, + text: FlowyText.regular( + LocaleKeys.newPageText.tr(), + lineHeight: 1.15, + ), + ), + ); + } + + Future _createNewPage() async { + // if the workspace is collaborative, create the view in the private section by default. + final section = context.read().state.isCollabWorkspaceOn + ? ViewSectionPB.Private + : ViewSectionPB.Public; + final spaceState = context.read().state; + if (spaceState.spaces.isNotEmpty) { + context.read().add( + const SpaceEvent.createPage( + name: '', + index: 0, + layout: ViewLayoutPB.Document, + openAfterCreate: true, + ), + ); + } else { + context.read().add( + SidebarSectionsEvent.createRootViewInSection( + name: '', + viewSection: section, + index: 0, + ), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart new file mode 100644 index 0000000000000..84a76cfe8312b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart @@ -0,0 +1,137 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; +import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; +import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hotkey_manager/hotkey_manager.dart'; +import 'package:universal_platform/universal_platform.dart'; + +final GlobalKey _settingsDialogKey = GlobalKey(); + +HotKeyItem openSettingsHotKey( + BuildContext context, + UserProfilePB userProfile, +) => + HotKeyItem( + hotKey: HotKey( + KeyCode.comma, + scope: HotKeyScope.inapp, + modifiers: [ + UniversalPlatform.isMacOS ? KeyModifier.meta : KeyModifier.control, + ], + ), + keyDownHandler: (_) { + if (_settingsDialogKey.currentContext == null) { + showSettingsDialog(context, userProfile); + } else { + Navigator.of(context, rootNavigator: true) + .popUntil((route) => route.isFirst); + } + }, + ); + +class UserSettingButton extends StatefulWidget { + const UserSettingButton({ + super.key, + required this.userProfile, + this.isHover = false, + }); + + final UserProfilePB userProfile; + final bool isHover; + + @override + State createState() => _UserSettingButtonState(); +} + +class _UserSettingButtonState extends State { + late UserWorkspaceBloc _userWorkspaceBloc; + + @override + void initState() { + super.initState(); + _userWorkspaceBloc = context.read(); + } + + @override + void didChangeDependencies() { + _userWorkspaceBloc = context.read(); + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 24.0, + child: FlowyTooltip( + message: LocaleKeys.settings_menu_open.tr(), + child: FlowyButton( + onTap: () => showSettingsDialog( + context, + widget.userProfile, + _userWorkspaceBloc, + ), + margin: EdgeInsets.zero, + text: FlowySvg( + FlowySvgs.settings_s, + color: + widget.isHover ? Theme.of(context).colorScheme.onSurface : null, + opacity: 0.7, + ), + ), + ), + ); + } +} + +void showSettingsDialog( + BuildContext context, + UserProfilePB userProfile, [ + UserWorkspaceBloc? bloc, + SettingsPage? initPage, +]) { + AFFocusManager.maybeOf(context)?.notifyLoseFocus(); + showDialog( + context: context, + builder: (dialogContext) => MultiBlocProvider( + key: _settingsDialogKey, + providers: [ + BlocProvider.value( + value: BlocProvider.of(dialogContext), + ), + BlocProvider.value(value: bloc ?? context.read()), + ], + child: SettingsDialog( + userProfile, + initPage: initPage, + didLogout: () async { + // Pop the dialog using the dialog context + Navigator.of(dialogContext).pop(); + await runAppFlowy(); + }, + dismissDialog: () { + if (Navigator.of(dialogContext).canPop()) { + return Navigator.of(dialogContext).pop(); + } + Log.warn("Can't pop dialog context"); + }, + restartApp: () async { + // Pop the dialog using the dialog context + Navigator.of(dialogContext).pop(); + await runAppFlowy(); + }, + ), + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart new file mode 100644 index 0000000000000..1a396b26dd108 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -0,0 +1,480 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/blank/blank.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/favorite/prelude.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/recent/cached_recent_service.dart'; +import 'package:appflowy/workspace/application/sidebar/billing/sidebar_plan_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/header/sidebar_top_menu.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/header/sidebar_user.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_migration.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart' + show UserProfilePB; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +Loading? _duplicateSpaceLoading; + +/// Home Sidebar is the left side bar of the home page. +/// +/// in the sidebar, we have: +/// - user icon, user name +/// - settings +/// - scrollable document list +/// - trash +class HomeSideBar extends StatelessWidget { + const HomeSideBar({ + super.key, + required this.userProfile, + required this.workspaceSetting, + }); + + final UserProfilePB userProfile; + + final WorkspaceSettingPB workspaceSetting; + + @override + Widget build(BuildContext context) { + // Workspace Bloc: control the current workspace + // | + // +-- Workspace Menu + // | | + // | +-- Workspace List: control to switch workspace + // | | + // | +-- Workspace Settings + // | | + // | +-- Notification Center + // | + // +-- Favorite Section + // | + // +-- Public Or Private Section: control the sections of the workspace + // | + // +-- Trash Section + return BlocProvider( + create: (context) => SidebarPlanBloc() + ..add(SidebarPlanEvent.init(workspaceSetting.workspaceId, userProfile)), + child: BlocConsumer( + listenWhen: (prev, curr) => + prev.currentWorkspace?.workspaceId != + curr.currentWorkspace?.workspaceId, + listener: (context, state) { + if (FeatureFlag.search.isOn) { + // Notify command palette that workspace has changed + context.read().add( + CommandPaletteEvent.workspaceChanged( + workspaceId: state.currentWorkspace?.workspaceId, + ), + ); + } + + if (state.currentWorkspace != null) { + context.read().add( + SidebarPlanEvent.changedWorkspace( + workspaceId: state.currentWorkspace!.workspaceId, + ), + ); + } + + // Re-initialize workspace-specific services + getIt().reset(); + }, + // Rebuild the whole sidebar when the current workspace changes + buildWhen: (previous, current) => + previous.currentWorkspace?.workspaceId != + current.currentWorkspace?.workspaceId, + builder: (context, state) { + if (state.currentWorkspace == null) { + return const SizedBox.shrink(); + } + + final workspaceId = state.currentWorkspace?.workspaceId ?? + workspaceSetting.workspaceId; + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: getIt()), + BlocProvider( + create: (_) => SidebarSectionsBloc() + ..add(SidebarSectionsEvent.initial(userProfile, workspaceId)), + ), + BlocProvider( + create: (_) => SpaceBloc( + userProfile: userProfile, + workspaceId: workspaceId, + )..add(const SpaceEvent.initial(openFirstPage: false)), + ), + ], + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (p, c) => + p.lastCreatedRootView?.id != c.lastCreatedRootView?.id, + listener: (context, state) => context.read().add( + TabsEvent.openPlugin( + plugin: state.lastCreatedRootView!.plugin(), + ), + ), + ), + BlocListener( + listenWhen: (prev, curr) => + prev.lastCreatedPage?.id != curr.lastCreatedPage?.id || + prev.isDuplicatingSpace != curr.isDuplicatingSpace, + listener: (context, state) { + final page = state.lastCreatedPage; + if (page == null || page.id.isEmpty) { + // open the blank page + context + .read() + .add(TabsEvent.openPlugin(plugin: BlankPagePlugin())); + } else { + context.read().add( + TabsEvent.openPlugin( + plugin: state.lastCreatedPage!.plugin(), + ), + ); + } + + if (state.isDuplicatingSpace) { + _duplicateSpaceLoading ??= Loading(context); + _duplicateSpaceLoading?.start(); + } else if (_duplicateSpaceLoading != null) { + _duplicateSpaceLoading?.stop(); + _duplicateSpaceLoading = null; + } + }, + ), + BlocListener( + listenWhen: (_, curr) => curr.action != null, + listener: _onNotificationAction, + ), + BlocListener( + listener: (context, state) { + final actionType = state.actionResult?.actionType; + + if (actionType == UserWorkspaceActionType.create || + actionType == UserWorkspaceActionType.delete || + actionType == UserWorkspaceActionType.open) { + if (context.read().state.spaces.isEmpty) { + context.read().add( + SidebarSectionsEvent.reload( + userProfile, + state.currentWorkspace?.workspaceId ?? + workspaceSetting.workspaceId, + ), + ); + } else { + context.read().add( + SpaceEvent.reset( + userProfile, + state.currentWorkspace?.workspaceId ?? + workspaceSetting.workspaceId, + true, + ), + ); + } + + context + .read() + .add(const FavoriteEvent.fetchFavorites()); + } + }, + ), + ], + child: _Sidebar(userProfile: userProfile), + ), + ); + }, + ), + ); + } + + void _onNotificationAction( + BuildContext context, + ActionNavigationState state, + ) { + final action = state.action; + if (action?.type == ActionType.openView) { + final view = action!.arguments?[ActionArgumentKeys.view]; + if (view != null) { + final Map arguments = {}; + final nodePath = action.arguments?[ActionArgumentKeys.nodePath]; + if (nodePath != null) { + arguments[PluginArgumentKeys.selection] = Selection.collapsed( + Position(path: [nodePath]), + ); + } + + final blockId = action.arguments?[ActionArgumentKeys.blockId]; + if (blockId != null) { + arguments[PluginArgumentKeys.blockId] = blockId; + } + + final rowId = action.arguments?[ActionArgumentKeys.rowId]; + if (rowId != null) { + arguments[PluginArgumentKeys.rowId] = rowId; + } + + context.read().openPlugin(view, arguments: arguments); + } + } + } +} + +class _Sidebar extends StatefulWidget { + const _Sidebar({required this.userProfile}); + + final UserProfilePB userProfile; + + @override + State<_Sidebar> createState() => _SidebarState(); +} + +class _SidebarState extends State<_Sidebar> { + final _scrollController = ScrollController(); + Timer? _scrollDebounce; + bool _isScrolling = false; + final _isHovered = ValueNotifier(false); + final _scrollOffset = ValueNotifier(0); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScrollChanged); + } + + @override + void dispose() { + _scrollDebounce?.cancel(); + _scrollController.removeListener(_onScrollChanged); + _scrollController.dispose(); + _scrollOffset.dispose(); + _isHovered.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const menuHorizontalInset = EdgeInsets.symmetric(horizontal: 8); + return MouseRegion( + onEnter: (_) => _isHovered.value = true, + onExit: (_) => _isHovered.value = false, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + border: Border( + right: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // top menu + Padding( + padding: menuHorizontalInset, + child: SidebarTopMenu( + isSidebarOnHover: _isHovered, + ), + ), + // user or workspace, setting + BlocBuilder( + builder: (context, state) => Container( + height: HomeSizes.workspaceSectionHeight, + padding: menuHorizontalInset - const EdgeInsets.only(right: 6), + // if the workspaces are empty, show the user profile instead + child: state.isCollabWorkspaceOn && state.workspaces.isNotEmpty + ? SidebarWorkspace(userProfile: widget.userProfile) + : SidebarUser(userProfile: widget.userProfile), + ), + ), + if (FeatureFlag.search.isOn) ...[ + const VSpace(6), + Container( + padding: menuHorizontalInset, + height: HomeSizes.searchSectionHeight, + child: const _SidebarSearchButton(), + ), + ], + const VSpace(6.0), + // new page button + const SidebarNewPageButton(), + // scrollable document list + const VSpace(12.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: ValueListenableBuilder( + valueListenable: _scrollOffset, + builder: (_, offset, child) => Opacity( + opacity: offset > 0 ? 1 : 0, + child: child, + ), + child: const FlowyDivider(), + ), + ), + + _renderFolderOrSpace(menuHorizontalInset), + + // trash + Padding( + padding: menuHorizontalInset + + const EdgeInsets.symmetric(horizontal: 4.0), + child: const FlowyDivider(), + ), + const VSpace(8), + + _renderUpgradeSpaceButton(menuHorizontalInset), + + const VSpace(8), + Padding( + padding: menuHorizontalInset + + const EdgeInsets.symmetric(horizontal: 4.0), + child: const SidebarFooter(), + ), + const VSpace(14), + ], + ), + ), + ); + } + + Widget _renderFolderOrSpace(EdgeInsets menuHorizontalInset) { + final spaceState = context.read().state; + final workspaceState = context.read().state; + + if (!spaceState.isInitialized) { + return const SizedBox.shrink(); + } + + // there's no space or the workspace is not collaborative, + // show the folder section (Workspace, Private, Personal) + // otherwise, show the space + final sidebarSectionBloc = context.watch(); + final containsSpace = sidebarSectionBloc.state.containsSpace; + + if (containsSpace && spaceState.spaces.isEmpty) { + context.read().add(const SpaceEvent.didReceiveSpaceUpdate()); + } + + return !containsSpace || + spaceState.spaces.isEmpty || + !workspaceState.isCollabWorkspaceOn + ? Expanded( + child: Padding( + padding: menuHorizontalInset - const EdgeInsets.only(right: 6), + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 6), + controller: _scrollController, + physics: const ClampingScrollPhysics(), + child: SidebarFolder( + userProfile: widget.userProfile, + isHoverEnabled: !_isScrolling, + ), + ), + ), + ) + : Expanded( + child: Padding( + padding: menuHorizontalInset - const EdgeInsets.only(right: 6), + child: FlowyScrollbar( + controller: _scrollController, + child: SingleChildScrollView( + padding: const EdgeInsets.only(right: 6), + controller: _scrollController, + physics: const ClampingScrollPhysics(), + child: SidebarSpace( + userProfile: widget.userProfile, + isHoverEnabled: !_isScrolling, + ), + ), + ), + ), + ); + } + + Widget _renderUpgradeSpaceButton(EdgeInsets menuHorizontalInset) { + final spaceState = context.watch().state; + final workspaceState = context.read().state; + return !spaceState.shouldShowUpgradeDialog || + !workspaceState.isCollabWorkspaceOn + ? const SizedBox.shrink() + : Padding( + padding: menuHorizontalInset + + const EdgeInsets.only( + left: 4.0, + right: 4.0, + top: 8.0, + ), + child: const SpaceMigration(), + ); + } + + void _onScrollChanged() { + setState(() => _isScrolling = true); + + _scrollDebounce?.cancel(); + _scrollDebounce = + Timer(const Duration(milliseconds: 300), _setScrollStopped); + + _scrollOffset.value = _scrollController.offset; + } + + void _setScrollStopped() { + if (mounted) { + setState(() => _isScrolling = false); + } + } +} + +class _SidebarSearchButton extends StatelessWidget { + const _SidebarSearchButton(); + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + richMessage: TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.search_sidebarSearchIcon.tr()}\n', + style: context.tooltipTextStyle(), + ), + TextSpan( + text: Platform.isMacOS ? '⌘+P' : 'Ctrl+P', + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], + ), + child: FlowyButton( + onTap: () => CommandPalette.of(context).toggle(), + leftIcon: const FlowySvg(FlowySvgs.search_s), + iconPadding: 12.0, + margin: const EdgeInsets.only(left: 8.0), + text: FlowyText.regular(LocaleKeys.search_label.tr()), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/_extension.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/_extension.dart new file mode 100644 index 0000000000000..40f20f098a688 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/_extension.dart @@ -0,0 +1,8 @@ +import 'package:appflowy/util/theme_extension.dart'; +import 'package:flutter/material.dart'; + +extension SpacePermissionColorExtension on BuildContext { + Color get enableBorderColor => Theme.of(this).isLightMode + ? const Color(0x1E171717) + : const Color(0xFF3A3F49); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart new file mode 100644 index 0000000000000..e3ce26e835c3e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart @@ -0,0 +1,131 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/_extension.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class CreateSpacePopup extends StatefulWidget { + const CreateSpacePopup({super.key}); + + @override + State createState() => _CreateSpacePopupState(); +} + +class _CreateSpacePopupState extends State { + String spaceName = LocaleKeys.space_defaultSpaceName.tr(); + String? spaceIcon = kDefaultSpaceIconId; + String? spaceIconColor = builtInSpaceColors.first; + SpacePermission spacePermission = SpacePermission.publicToAll; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + width: 524, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + LocaleKeys.space_createNewSpace.tr(), + fontSize: 18.0, + figmaLineHeight: 24.0, + ), + const VSpace(2.0), + FlowyText( + LocaleKeys.space_createSpaceDescription.tr(), + fontSize: 14.0, + fontWeight: FontWeight.w300, + color: Theme.of(context).hintColor, + figmaLineHeight: 18.0, + maxLines: 2, + ), + const VSpace(16.0), + SizedBox.square( + dimension: 56, + child: SpaceIconPopup( + onIconChanged: (icon, iconColor) { + spaceIcon = icon; + spaceIconColor = iconColor; + }, + ), + ), + const VSpace(8.0), + _SpaceNameTextField( + onChanged: (value) => spaceName = value, + onSubmitted: (value) { + spaceName = value; + _createSpace(); + }, + ), + const VSpace(20.0), + SpacePermissionSwitch( + onPermissionChanged: (value) => spacePermission = value, + ), + const VSpace(20.0), + SpaceCancelOrConfirmButton( + confirmButtonName: LocaleKeys.button_create.tr(), + onCancel: () => Navigator.of(context).pop(), + onConfirm: () => _createSpace(), + ), + ], + ), + ); + } + + void _createSpace() { + context.read().add( + SpaceEvent.create( + name: spaceName, + // fixme: space issue + icon: spaceIcon!, + iconColor: spaceIconColor!, + permission: spacePermission, + createNewPageByDefault: true, + openAfterCreate: true, + ), + ); + + Navigator.of(context).pop(); + } +} + +class _SpaceNameTextField extends StatelessWidget { + const _SpaceNameTextField({ + required this.onChanged, + required this.onSubmitted, + }); + + final void Function(String name) onChanged; + final void Function(String name) onSubmitted; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular( + LocaleKeys.space_spaceName.tr(), + fontSize: 14.0, + color: Theme.of(context).hintColor, + figmaLineHeight: 18.0, + ), + const VSpace(6.0), + SizedBox( + height: 40, + child: FlowyTextField( + hintText: LocaleKeys.space_spaceNamePlaceholder.tr(), + onChanged: onChanged, + onSubmitted: onSubmitted, + enableBorderColor: context.enableBorderColor, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/manage_space_popup.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/manage_space_popup.dart new file mode 100644 index 0000000000000..eb8c54025d72d --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/manage_space_popup.dart @@ -0,0 +1,125 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ManageSpacePopup extends StatefulWidget { + const ManageSpacePopup({super.key}); + + @override + State createState() => _ManageSpacePopupState(); +} + +class _ManageSpacePopupState extends State { + String? spaceName; + String? spaceIcon; + String? spaceIconColor; + SpacePermission? spacePermission; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + width: 500, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + LocaleKeys.space_manage.tr(), + fontSize: 18.0, + ), + const VSpace(16.0), + _SpaceNameTextField( + onNameChanged: (name) => spaceName = name, + onIconChanged: (icon, color) { + spaceIcon = icon; + spaceIconColor = color; + }, + ), + const VSpace(16.0), + SpacePermissionSwitch( + spacePermission: + context.read().state.currentSpace?.spacePermission, + onPermissionChanged: (value) => spacePermission = value, + ), + const VSpace(16.0), + SpaceCancelOrConfirmButton( + confirmButtonName: LocaleKeys.button_save.tr(), + onCancel: () => Navigator.of(context).pop(), + onConfirm: () { + context.read().add( + SpaceEvent.update( + name: spaceName, + icon: spaceIcon, + iconColor: spaceIconColor, + permission: spacePermission, + ), + ); + + Navigator.of(context).pop(); + }, + ), + ], + ), + ); + } +} + +class _SpaceNameTextField extends StatelessWidget { + const _SpaceNameTextField({ + required this.onNameChanged, + required this.onIconChanged, + }); + + final void Function(String name) onNameChanged; + final void Function(String? icon, String? color) onIconChanged; + + @override + Widget build(BuildContext context) { + final space = context.read().state.currentSpace; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular( + LocaleKeys.space_spaceName.tr(), + fontSize: 14.0, + color: Theme.of(context).hintColor, + ), + const VSpace(8.0), + SizedBox( + height: 40, + child: Row( + children: [ + SizedBox.square( + dimension: 40, + child: SpaceIconPopup( + space: space, + cornerRadius: 12, + icon: space?.spaceIcon, + iconColor: space?.spaceIconColor, + onIconChanged: onIconChanged, + ), + ), + const HSpace(12), + Expanded( + child: SizedBox( + height: 40, + child: FlowyTextField( + text: space?.name, + onChanged: onNameChanged, + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart new file mode 100644 index 0000000000000..7afb4a6298c23 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart @@ -0,0 +1,682 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/_extension.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class SpacePermissionSwitch extends StatefulWidget { + const SpacePermissionSwitch({ + super.key, + required this.onPermissionChanged, + this.spacePermission, + this.showArrow = false, + }); + + final SpacePermission? spacePermission; + final void Function(SpacePermission permission) onPermissionChanged; + final bool showArrow; + + @override + State createState() => _SpacePermissionSwitchState(); +} + +class _SpacePermissionSwitchState extends State { + late SpacePermission spacePermission = + widget.spacePermission ?? SpacePermission.publicToAll; + final popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular( + LocaleKeys.space_permission.tr(), + fontSize: 14.0, + color: Theme.of(context).hintColor, + figmaLineHeight: 18.0, + ), + const VSpace(6.0), + AppFlowyPopover( + controller: popoverController, + direction: PopoverDirection.bottomWithCenterAligned, + constraints: const BoxConstraints(maxWidth: 500), + offset: const Offset(0, 4), + margin: EdgeInsets.zero, + popupBuilder: (_) => _buildPermissionButtons(), + child: DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide(color: context.enableBorderColor), + borderRadius: BorderRadius.circular(10), + ), + ), + child: SpacePermissionButton( + showArrow: true, + permission: spacePermission, + ), + ), + ), + ], + ); + } + + Widget _buildPermissionButtons() { + return SizedBox( + width: 452, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SpacePermissionButton( + permission: SpacePermission.publicToAll, + onTap: () => _onPermissionChanged(SpacePermission.publicToAll), + ), + SpacePermissionButton( + permission: SpacePermission.private, + onTap: () => _onPermissionChanged(SpacePermission.private), + ), + ], + ), + ); + } + + void _onPermissionChanged(SpacePermission permission) { + widget.onPermissionChanged(permission); + + setState(() { + spacePermission = permission; + }); + + popoverController.close(); + } +} + +class SpacePermissionButton extends StatelessWidget { + const SpacePermissionButton({ + super.key, + required this.permission, + this.onTap, + this.showArrow = false, + }); + + final SpacePermission permission; + final VoidCallback? onTap; + final bool showArrow; + + @override + Widget build(BuildContext context) { + final (title, desc, icon) = switch (permission) { + SpacePermission.publicToAll => ( + LocaleKeys.space_publicPermission.tr(), + LocaleKeys.space_publicPermissionDescription.tr(), + FlowySvgs.space_permission_public_s + ), + SpacePermission.private => ( + LocaleKeys.space_privatePermission.tr(), + LocaleKeys.space_privatePermissionDescription.tr(), + FlowySvgs.space_permission_private_s + ), + }; + + return FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0), + radius: BorderRadius.circular(10), + iconPadding: 16.0, + leftIcon: FlowySvg(icon), + leftIconSize: const Size.square(20), + rightIcon: showArrow + ? const FlowySvg(FlowySvgs.space_permission_dropdown_s) + : null, + borderColor: Theme.of(context).isLightMode + ? const Color(0x1E171717) + : const Color(0xFF3A3F49), + text: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular(title), + const VSpace(4.0), + FlowyText.regular( + desc, + fontSize: 12.0, + color: Theme.of(context).hintColor, + ), + ], + ), + onTap: onTap, + ); + } +} + +class SpaceCancelOrConfirmButton extends StatelessWidget { + const SpaceCancelOrConfirmButton({ + super.key, + required this.onCancel, + required this.onConfirm, + required this.confirmButtonName, + this.confirmButtonColor, + }); + + final VoidCallback onCancel; + final VoidCallback onConfirm; + final String confirmButtonName; + final Color? confirmButtonColor; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedRoundedButton( + text: LocaleKeys.button_cancel.tr(), + onTap: onCancel, + ), + const HSpace(12.0), + DecoratedBox( + decoration: ShapeDecoration( + color: confirmButtonColor ?? Theme.of(context).colorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0), + radius: BorderRadius.circular(8), + text: FlowyText.regular( + confirmButtonName, + lineHeight: 1.0, + color: Theme.of(context).colorScheme.onPrimary, + ), + onTap: onConfirm, + ), + ), + ], + ); + } +} + +class SpaceOkButton extends StatelessWidget { + const SpaceOkButton({ + super.key, + required this.onConfirm, + required this.confirmButtonName, + this.confirmButtonColor, + }); + + final VoidCallback onConfirm; + final String confirmButtonName; + final Color? confirmButtonColor; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + PrimaryRoundedButton( + text: confirmButtonName, + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0), + radius: 8.0, + onTap: onConfirm, + ), + ], + ); + } +} + +enum ConfirmPopupStyle { + onlyOk, + cancelAndOk, +} + +class ConfirmPopupColor { + static Color titleColor(BuildContext context) { + if (Theme.of(context).isLightMode) { + return const Color(0xFF171717).withOpacity(0.8); + } + return const Color(0xFFffffff).withOpacity(0.8); + } + + static Color descriptionColor(BuildContext context) { + if (Theme.of(context).isLightMode) { + return const Color(0xFF171717).withOpacity(0.7); + } + return const Color(0xFFffffff).withOpacity(0.7); + } +} + +class ConfirmPopup extends StatefulWidget { + const ConfirmPopup({ + super.key, + this.style = ConfirmPopupStyle.cancelAndOk, + required this.title, + required this.description, + required this.onConfirm, + this.onCancel, + this.confirmLabel, + this.confirmButtonColor, + this.child, + this.closeOnAction = true, + }); + + final String title; + final String description; + final VoidCallback onConfirm; + final VoidCallback? onCancel; + final Color? confirmButtonColor; + final ConfirmPopupStyle style; + + /// The label of the confirm button. + /// + /// Defaults to 'Delete' for [ConfirmPopupStyle.cancelAndOk] style. + /// Defaults to 'Ok' for [ConfirmPopupStyle.onlyOk] style. + /// + final String? confirmLabel; + + /// Allows to add a child to the popup. + /// + /// This is useful when you want to add more content to the popup. + /// The child will be placed below the description. + /// + final Widget? child; + + /// Decides whether the popup should be closed when the confirm button is clicked. + /// Defaults to true. + /// + final bool closeOnAction; + + @override + State createState() => _ConfirmPopupState(); +} + +class _ConfirmPopupState extends State { + final focusNode = FocusNode(); + + @override + Widget build(BuildContext context) { + return KeyboardListener( + focusNode: focusNode, + autofocus: true, + onKeyEvent: (event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.of(context).pop(); + } else if (event is KeyUpEvent && + event.logicalKey == LogicalKeyboardKey.enter) { + widget.onConfirm(); + if (widget.closeOnAction) { + Navigator.of(context).pop(); + } + } + }, + child: Container( + padding: const EdgeInsets.all(20), + color: UniversalPlatform.isDesktop + ? null + : Theme.of(context).colorScheme.surface, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(), + if (widget.description.isNotEmpty) ...[ + const VSpace(6), + _buildDescription(), + ], + if (widget.child != null) ...[ + const VSpace(12), + widget.child!, + ], + const VSpace(20), + _buildStyledButton(context), + ], + ), + ), + ); + } + + Widget _buildTitle() { + return Row( + children: [ + Expanded( + child: FlowyText( + widget.title, + fontSize: 16.0, + figmaLineHeight: 22.0, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + color: ConfirmPopupColor.titleColor(context), + ), + ), + const HSpace(6.0), + FlowyButton( + margin: const EdgeInsets.all(3), + useIntrinsicWidth: true, + text: const FlowySvg( + FlowySvgs.upgrade_close_s, + size: Size.square(18.0), + ), + onTap: () => Navigator.of(context).pop(), + ), + ], + ); + } + + Widget _buildDescription() { + if (widget.description.isEmpty) { + return const SizedBox.shrink(); + } + + return FlowyText.regular( + widget.description, + fontSize: 16.0, + color: ConfirmPopupColor.descriptionColor(context), + maxLines: 5, + figmaLineHeight: 22.0, + ); + } + + Widget _buildStyledButton(BuildContext context) { + switch (widget.style) { + case ConfirmPopupStyle.onlyOk: + return SpaceOkButton( + onConfirm: () { + widget.onConfirm(); + if (widget.closeOnAction) { + Navigator.of(context).pop(); + } + }, + confirmButtonName: widget.confirmLabel ?? LocaleKeys.button_ok.tr(), + confirmButtonColor: widget.confirmButtonColor ?? + Theme.of(context).colorScheme.primary, + ); + case ConfirmPopupStyle.cancelAndOk: + return SpaceCancelOrConfirmButton( + onCancel: () { + widget.onCancel?.call(); + Navigator.of(context).pop(); + }, + onConfirm: () { + widget.onConfirm(); + if (widget.closeOnAction) { + Navigator.of(context).pop(); + } + }, + confirmButtonName: + widget.confirmLabel ?? LocaleKeys.space_delete.tr(), + confirmButtonColor: + widget.confirmButtonColor ?? Theme.of(context).colorScheme.error, + ); + } + } +} + +class SpacePopup extends StatelessWidget { + const SpacePopup({ + super.key, + this.height, + this.useIntrinsicWidth = true, + this.expand = false, + required this.showCreateButton, + required this.child, + }); + + final bool showCreateButton; + final bool useIntrinsicWidth; + final bool expand; + final double? height; + final Widget child; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height ?? HomeSizes.workspaceSectionHeight, + child: AppFlowyPopover( + constraints: const BoxConstraints(maxWidth: 260), + direction: PopoverDirection.bottomWithLeftAligned, + clickHandler: PopoverClickHandler.gestureDetector, + offset: const Offset(0, 4), + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: SidebarSpaceMenu( + showCreateButton: showCreateButton, + ), + ), + child: FlowyButton( + useIntrinsicWidth: useIntrinsicWidth, + expand: expand, + margin: const EdgeInsets.only(left: 3.0, right: 4.0), + iconPadding: 10.0, + text: child, + ), + ), + ); + } +} + +class CurrentSpace extends StatelessWidget { + const CurrentSpace({ + super.key, + this.onTapBlankArea, + required this.space, + this.isHovered = false, + }); + + final ViewPB space; + final VoidCallback? onTapBlankArea; + final bool isHovered; + + @override + Widget build(BuildContext context) { + final child = Row( + mainAxisSize: MainAxisSize.min, + children: [ + SpaceIcon( + dimension: 22, + space: space, + svgSize: 12, + cornerRadius: 8.0, + ), + const HSpace(10), + Flexible( + child: FlowyText.medium( + space.name, + fontSize: 14.0, + figmaLineHeight: 18.0, + overflow: TextOverflow.ellipsis, + color: isHovered ? Theme.of(context).colorScheme.onSurface : null, + ), + ), + const HSpace(4.0), + FlowySvg( + context.read().state.isExpanded + ? FlowySvgs.workspace_drop_down_menu_show_s + : FlowySvgs.workspace_drop_down_menu_hide_s, + color: isHovered ? Theme.of(context).colorScheme.onSurface : null, + ), + ], + ); + + if (onTapBlankArea != null) { + return Row( + children: [ + Expanded( + flex: 2, + child: FlowyHover( + child: Padding( + padding: const EdgeInsets.all(2.0), + child: child, + ), + ), + ), + Expanded( + child: FlowyTooltip( + message: LocaleKeys.space_movePageToSpace.tr(), + child: GestureDetector( + onTap: onTapBlankArea, + ), + ), + ), + ], + ); + } + + return child; + } +} + +class SpacePages extends StatelessWidget { + const SpacePages({ + super.key, + required this.space, + required this.isHovered, + required this.isExpandedNotifier, + required this.onSelected, + this.rightIconsBuilder, + this.disableSelectedStatus = false, + this.onTertiarySelected, + this.shouldIgnoreView, + }); + + final ViewPB space; + final ValueNotifier isHovered; + final PropertyValueNotifier isExpandedNotifier; + final bool disableSelectedStatus; + final ViewItemRightIconsBuilder? rightIconsBuilder; + final ViewItemOnSelected onSelected; + final ViewItemOnSelected? onTertiarySelected; + final IgnoreViewType Function(ViewPB view)? shouldIgnoreView; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => ViewBloc(view: space)..add(const ViewEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + // filter the child views that should be ignored + List childViews = state.view.childViews; + if (shouldIgnoreView != null) { + childViews = childViews + .where((v) => shouldIgnoreView!(v) != IgnoreViewType.hide) + .toList(); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: childViews + .map( + (view) => ViewItem( + key: ValueKey('${space.id} ${view.id}'), + spaceType: + space.spacePermission == SpacePermission.publicToAll + ? FolderSpaceType.public + : FolderSpaceType.private, + isFirstChild: view.id == childViews.first.id, + view: view, + level: 0, + leftPadding: HomeSpaceViewSizes.leftPadding, + isFeedback: false, + isHovered: isHovered, + enableRightClickContext: !disableSelectedStatus, + disableSelectedStatus: disableSelectedStatus, + isExpandedNotifier: isExpandedNotifier, + rightIconsBuilder: rightIconsBuilder, + onSelected: onSelected, + onTertiarySelected: onTertiarySelected, + shouldIgnoreView: shouldIgnoreView, + ), + ) + .toList(), + ); + }, + ), + ); + } +} + +class SpaceSearchField extends StatefulWidget { + const SpaceSearchField({ + super.key, + required this.width, + required this.onSearch, + }); + + final double width; + final void Function(BuildContext context, String text) onSearch; + + @override + State createState() => _SpaceSearchFieldState(); +} + +class _SpaceSearchFieldState extends State { + final focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + focusNode.requestFocus(); + } + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: 30, + width: widget.width, + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide( + width: 1.20, + strokeAlign: BorderSide.strokeAlignOutside, + color: Color(0xFF00BCF0), + ), + borderRadius: BorderRadius.circular(8), + ), + ), + child: CupertinoSearchTextField( + onChanged: (text) => widget.onSearch(context, text), + padding: EdgeInsets.zero, + focusNode: focusNode, + placeholder: LocaleKeys.search_label.tr(), + prefixIcon: const FlowySvg(FlowySvgs.magnifier_s), + prefixInsets: const EdgeInsets.only(left: 12.0, right: 8.0), + suffixIcon: const Icon(Icons.close), + suffixInsets: const EdgeInsets.only(right: 8.0), + itemSize: 16.0, + decoration: const BoxDecoration( + color: Colors.transparent, + ), + placeholderStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + fontWeight: FontWeight.w400, + ), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w400, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart new file mode 100644 index 0000000000000..e4be64d5b9d8a --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space.dart @@ -0,0 +1,178 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/favorites/favorite_folder.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +class SidebarSpace extends StatelessWidget { + const SidebarSpace({ + super.key, + this.isHoverEnabled = true, + required this.userProfile, + }); + + final bool isHoverEnabled; + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: getIt().notifier, + builder: (_, __, ___) => Provider.value( + value: userProfile, + child: Column( + children: [ + const VSpace(4.0), + // favorite + BlocBuilder( + builder: (context, state) { + if (state.views.isEmpty) { + return const SizedBox.shrink(); + } + return FavoriteFolder( + views: state.views.map((e) => e.item).toList(), + ); + }, + ), + const VSpace(16.0), + // spaces + const _Space(), + const VSpace(200), + ], + ), + ), + ); + } +} + +class _Space extends StatefulWidget { + const _Space(); + + @override + State<_Space> createState() => _SpaceState(); +} + +class _SpaceState extends State<_Space> { + final isHovered = ValueNotifier(false); + final isExpandedNotifier = PropertyValueNotifier(false); + + @override + void initState() { + super.initState(); + switchToTheNextSpace.addListener(_switchToNextSpace); + } + + @override + void dispose() { + switchToTheNextSpace.removeListener(_switchToNextSpace); + isHovered.dispose(); + isExpandedNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final currentWorkspace = + context.watch().state.currentWorkspace; + return BlocBuilder( + builder: (context, state) { + if (state.spaces.isEmpty) { + return const SizedBox.shrink(); + } + + final currentSpace = state.currentSpace ?? state.spaces.first; + + return Column( + children: [ + SidebarSpaceHeader( + isExpanded: state.isExpanded, + space: currentSpace, + onAdded: (layout) => _showCreatePagePopup( + context, + currentSpace, + layout, + ), + onCreateNewSpace: () => _showCreateSpaceDialog(context), + onCollapseAllPages: () => isExpandedNotifier.value = true, + ), + if (state.isExpanded) + MouseRegion( + onEnter: (_) => isHovered.value = true, + onExit: (_) => isHovered.value = false, + child: SpacePages( + key: ValueKey( + Object.hashAll([ + currentWorkspace?.workspaceId ?? '', + currentSpace.id, + ]), + ), + isExpandedNotifier: isExpandedNotifier, + space: currentSpace, + isHovered: isHovered, + onSelected: (context, view) { + if (HardwareKeyboard.instance.isControlPressed) { + context.read().openTab(view); + } + context.read().openPlugin(view); + }, + onTertiarySelected: (context, view) => + context.read().openTab(view), + ), + ), + ], + ); + }, + ); + } + + void _showCreateSpaceDialog(BuildContext context) { + final spaceBloc = context.read(); + showDialog( + context: context, + builder: (_) => Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: BlocProvider.value( + value: spaceBloc, + child: const CreateSpacePopup(), + ), + ), + ); + } + + void _showCreatePagePopup( + BuildContext context, + ViewPB space, + ViewLayoutPB layout, + ) { + context.read().add( + SpaceEvent.createPage( + name: '', + layout: layout, + index: 0, + openAfterCreate: true, + ), + ); + + context.read().add(SpaceEvent.expand(space, true)); + } + + void _switchToNextSpace() { + context.read().add(const SpaceEvent.switchToNextSpace()); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart new file mode 100644 index 0000000000000..03b96c960714f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart @@ -0,0 +1,262 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/manage_space_popup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart' hide Icon; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SidebarSpaceHeader extends StatefulWidget { + const SidebarSpaceHeader({ + super.key, + required this.space, + required this.onAdded, + required this.onCreateNewSpace, + required this.onCollapseAllPages, + required this.isExpanded, + }); + + final ViewPB space; + final void Function(ViewLayoutPB layout) onAdded; + final VoidCallback onCreateNewSpace; + final VoidCallback onCollapseAllPages; + final bool isExpanded; + + @override + State createState() => _SidebarSpaceHeaderState(); +} + +class _SidebarSpaceHeaderState extends State { + final isHovered = ValueNotifier(false); + final onEditing = ValueNotifier(false); + + @override + void dispose() { + isHovered.dispose(); + onEditing.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: isHovered, + builder: (context, onHover, child) { + return MouseRegion( + onEnter: (_) => isHovered.value = true, + onExit: (_) => isHovered.value = false, + child: GestureDetector( + onTap: () => context + .read() + .add(SpaceEvent.expand(widget.space, !widget.isExpanded)), + child: _buildSpaceName(onHover), + ), + ); + }, + ); + } + + Widget _buildSpaceName(bool isHovered) { + return Container( + height: HomeSizes.workspaceSectionHeight, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(6)), + color: isHovered ? Theme.of(context).colorScheme.secondary : null, + ), + child: Stack( + alignment: Alignment.center, + children: [ + ValueListenableBuilder( + valueListenable: onEditing, + builder: (context, onEditing, child) => Positioned( + left: 3, + top: 3, + bottom: 3, + right: isHovered || onEditing ? 88 : 0, + child: SpacePopup( + showCreateButton: true, + child: _buildChild(isHovered), + ), + ), + ), + Positioned( + right: 4, + child: _buildRightIcon(isHovered), + ), + ], + ), + ); + } + + Widget _buildChild(bool isHovered) { + final textSpan = TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.space_quicklySwitch.tr()}\n', + style: context.tooltipTextStyle(), + ), + TextSpan( + text: Platform.isMacOS ? '⌘+O' : 'Ctrl+O', + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], + ); + return FlowyTooltip( + richMessage: textSpan, + child: CurrentSpace( + space: widget.space, + isHovered: isHovered, + ), + ); + } + + Widget _buildRightIcon(bool isHovered) { + return ValueListenableBuilder( + valueListenable: onEditing, + builder: (context, onEditing, child) => Opacity( + opacity: isHovered || onEditing ? 1 : 0, + child: Row( + children: [ + SpaceMorePopup( + space: widget.space, + onEditing: (value) => this.onEditing.value = value, + onAction: _onAction, + isHovered: isHovered, + ), + const HSpace(8.0), + FlowyTooltip( + message: LocaleKeys.sideBar_addAPage.tr(), + child: ViewAddButton( + parentViewId: widget.space.id, + onEditing: (_) {}, + onSelected: ( + pluginBuilder, + name, + initialDataBytes, + openAfterCreated, + createNewView, + ) { + if (pluginBuilder.layoutType == ViewLayoutPB.Document) { + name = ''; + } + if (createNewView) { + widget.onAdded(pluginBuilder.layoutType!); + } + }, + isHovered: isHovered, + ), + ), + ], + ), + ), + ); + } + + Future _onAction(SpaceMoreActionType type, dynamic data) async { + switch (type) { + case SpaceMoreActionType.rename: + await _showRenameDialog(); + break; + case SpaceMoreActionType.changeIcon: + final result = data as EmojiIconData; + if (data.type == FlowyIconType.icon) { + try { + final iconsData = IconsData.fromJson(jsonDecode(result.emoji)); + context.read().add( + SpaceEvent.changeIcon( + icon: '${iconsData.groupName}/${iconsData.iconName}', + iconColor: iconsData.color, + ), + ); + } on FormatException catch (e) { + context + .read() + .add(const SpaceEvent.changeIcon(icon: '')); + Log.warn('SidebarSpaceHeader changeIcon error:$e'); + } + } + break; + case SpaceMoreActionType.manage: + _showManageSpaceDialog(context); + break; + case SpaceMoreActionType.addNewSpace: + widget.onCreateNewSpace(); + break; + case SpaceMoreActionType.collapseAllPages: + widget.onCollapseAllPages(); + break; + case SpaceMoreActionType.delete: + _showDeleteSpaceDialog(context); + break; + case SpaceMoreActionType.duplicate: + context.read().add(const SpaceEvent.duplicate()); + break; + case SpaceMoreActionType.divider: + break; + } + } + + Future _showRenameDialog() async { + await NavigatorTextFieldDialog( + title: LocaleKeys.space_rename.tr(), + value: widget.space.name, + autoSelectAllText: true, + hintText: LocaleKeys.space_spaceName.tr(), + onConfirm: (name, _) { + context.read().add( + SpaceEvent.rename( + space: widget.space, + name: name, + ), + ); + }, + ).show(context); + } + + void _showManageSpaceDialog(BuildContext context) { + final spaceBloc = context.read(); + showDialog( + context: context, + builder: (_) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: BlocProvider.value( + value: spaceBloc, + child: const ManageSpacePopup(), + ), + ); + }, + ); + } + + void _showDeleteSpaceDialog(BuildContext context) { + final spaceBloc = context.read(); + final space = spaceBloc.state.currentSpace; + final name = space != null ? space.name : ''; + showConfirmDeletionDialog( + context: context, + name: name, + description: LocaleKeys.space_deleteConfirmationDescription.tr(), + onConfirm: () { + context.read().add(const SpaceEvent.delete(null)); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart new file mode 100644 index 0000000000000..f4d910700dbee --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart @@ -0,0 +1,143 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/create_space_popup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SidebarSpaceMenu extends StatelessWidget { + const SidebarSpaceMenu({ + super.key, + required this.showCreateButton, + }); + + final bool showCreateButton; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const VSpace(4.0), + for (final space in state.spaces) + SizedBox( + height: HomeSpaceViewSizes.viewHeight, + child: SidebarSpaceMenuItem( + space: space, + isSelected: state.currentSpace?.id == space.id, + ), + ), + if (showCreateButton) ...[ + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: FlowyDivider(), + ), + const SizedBox( + height: HomeSpaceViewSizes.viewHeight, + child: _CreateSpaceButton(), + ), + ], + ], + ); + }, + ); + } +} + +class SidebarSpaceMenuItem extends StatelessWidget { + const SidebarSpaceMenuItem({ + super.key, + required this.space, + required this.isSelected, + }); + + final ViewPB space; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return FlowyButton( + text: Row( + children: [ + Flexible( + child: FlowyText.regular( + space.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(6.0), + if (space.spacePermission == SpacePermission.private) + FlowyTooltip( + message: LocaleKeys.space_privatePermissionDescription.tr(), + child: const FlowySvg( + FlowySvgs.space_lock_s, + ), + ), + ], + ), + iconPadding: 10, + leftIcon: SpaceIcon( + dimension: 20, + space: space, + svgSize: 12.0, + cornerRadius: 6.0, + ), + leftIconSize: const Size.square(20), + rightIcon: isSelected + ? const FlowySvg( + FlowySvgs.workspace_selected_s, + blendMode: null, + ) + : null, + onTap: () { + context.read().add(SpaceEvent.open(space)); + PopoverContainer.of(context).close(); + }, + ); + } +} + +class _CreateSpaceButton extends StatelessWidget { + const _CreateSpaceButton(); + + @override + Widget build(BuildContext context) { + return FlowyButton( + text: FlowyText.regular(LocaleKeys.space_createNewSpace.tr()), + iconPadding: 10, + leftIcon: const FlowySvg( + FlowySvgs.space_add_s, + ), + onTap: () { + PopoverContainer.of(context).close(); + _showCreateSpaceDialog(context); + }, + ); + } + + void _showCreateSpaceDialog(BuildContext context) { + final spaceBloc = context.read(); + showDialog( + context: context, + builder: (_) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: BlocProvider.value( + value: spaceBloc, + child: const CreateSpacePopup(), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_action_type.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_action_type.dart new file mode 100644 index 0000000000000..be0eadd8ed15d --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_action_type.dart @@ -0,0 +1,73 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +enum SpaceMoreActionType { + delete, + rename, + changeIcon, + collapseAllPages, + divider, + addNewSpace, + manage, + duplicate, +} + +extension ViewMoreActionTypeExtension on SpaceMoreActionType { + String get name { + switch (this) { + case SpaceMoreActionType.delete: + return LocaleKeys.space_delete.tr(); + case SpaceMoreActionType.rename: + return LocaleKeys.space_rename.tr(); + case SpaceMoreActionType.changeIcon: + return LocaleKeys.space_changeIcon.tr(); + case SpaceMoreActionType.collapseAllPages: + return LocaleKeys.space_collapseAllSubPages.tr(); + case SpaceMoreActionType.addNewSpace: + return LocaleKeys.space_addNewSpace.tr(); + case SpaceMoreActionType.manage: + return LocaleKeys.space_manage.tr(); + case SpaceMoreActionType.duplicate: + return LocaleKeys.space_duplicate.tr(); + case SpaceMoreActionType.divider: + return ''; + } + } + + FlowySvgData get leftIconSvg { + switch (this) { + case SpaceMoreActionType.delete: + return FlowySvgs.trash_s; + case SpaceMoreActionType.rename: + return FlowySvgs.view_item_rename_s; + case SpaceMoreActionType.changeIcon: + return FlowySvgs.change_icon_s; + case SpaceMoreActionType.collapseAllPages: + return FlowySvgs.collapse_all_page_s; + case SpaceMoreActionType.addNewSpace: + return FlowySvgs.space_add_s; + case SpaceMoreActionType.manage: + return FlowySvgs.space_manage_s; + case SpaceMoreActionType.duplicate: + return FlowySvgs.duplicate_s; + case SpaceMoreActionType.divider: + throw UnsupportedError('Divider does not have an icon'); + } + } + + Widget get rightIcon { + switch (this) { + case SpaceMoreActionType.changeIcon: + case SpaceMoreActionType.rename: + case SpaceMoreActionType.collapseAllPages: + case SpaceMoreActionType.divider: + case SpaceMoreActionType.delete: + case SpaceMoreActionType.addNewSpace: + case SpaceMoreActionType.manage: + case SpaceMoreActionType.duplicate: + return const SizedBox.shrink(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon.dart new file mode 100644 index 0000000000000..ad9e5e8f0a01c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon.dart @@ -0,0 +1,154 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class SpaceIcon extends StatelessWidget { + const SpaceIcon({ + super.key, + required this.dimension, + this.textDimension, + this.cornerRadius = 0, + required this.space, + this.svgSize, + }); + + final double dimension; + final double? textDimension; + final double cornerRadius; + final ViewPB space; + final double? svgSize; + + @override + Widget build(BuildContext context) { + final (icon, color) = _buildSpaceIcon(context); + + return ClipRRect( + borderRadius: BorderRadius.circular(cornerRadius), + child: Container( + width: dimension, + height: dimension, + color: color, + child: Center( + child: icon, + ), + ), + ); + } + + (Widget, Color?) _buildSpaceIcon(BuildContext context) { + final spaceIcon = space.spaceIcon; + if (spaceIcon == null || spaceIcon.isEmpty == true) { + // if space icon is null, use the first character of space name as icon + return _buildEmptySpaceIcon(context); + } else { + return _buildCustomSpaceIcon(context); + } + } + + (Widget, Color?) _buildEmptySpaceIcon(BuildContext context) { + final name = space.name.isNotEmpty ? space.name.capitalize()[0] : ''; + final icon = FlowyText.medium( + name, + color: Theme.of(context).colorScheme.surface, + fontSize: svgSize, + figmaLineHeight: textDimension ?? dimension, + ); + Color? color; + try { + final defaultColor = builtInSpaceColors.firstOrNull; + if (defaultColor != null) { + color = Color(int.parse(defaultColor)); + } + } catch (e) { + Log.error('Failed to parse default space icon color: $e'); + } + return (icon, color); + } + + (Widget, Color?) _buildCustomSpaceIcon(BuildContext context) { + final spaceIconColor = space.spaceIconColor; + + final svg = space.buildSpaceIconSvg( + context, + size: svgSize != null ? Size.square(svgSize!) : null, + ); + Widget icon; + if (svg == null) { + icon = const SizedBox.shrink(); + } else { + icon = svgSize == null || + space.spaceIcon?.contains(ViewExtKeys.spaceIconKey) == true + ? svg + : SizedBox.square(dimension: svgSize!, child: svg); + } + + Color color = Colors.transparent; + if (spaceIconColor != null && spaceIconColor.isNotEmpty) { + try { + color = Color(int.parse(spaceIconColor)); + } catch (e) { + Log.error( + 'Failed to parse space icon color: $e, value: $spaceIconColor', + ); + } + } + + return (icon, color); + } +} + +const kDefaultSpaceIconId = 'interface_essential/home-3'; + +class DefaultSpaceIcon extends StatelessWidget { + const DefaultSpaceIcon({ + super.key, + required this.dimension, + required this.iconDimension, + this.cornerRadius = 0, + }); + + final double dimension; + final double cornerRadius; + final double iconDimension; + + @override + Widget build(BuildContext context) { + final svgContent = kIconGroups?.findSvgContent( + kDefaultSpaceIconId, + ); + + final Widget svg; + if (svgContent != null) { + svg = FlowySvg.string( + svgContent, + size: Size.square(iconDimension), + color: Theme.of(context).colorScheme.surface, + ); + } else { + svg = FlowySvg( + FlowySvgData('assets/flowy_icons/16x/${builtInSpaceIcons.first}.svg'), + color: Theme.of(context).colorScheme.surface, + size: Size.square(iconDimension), + ); + } + + final color = Color(int.parse(builtInSpaceColors.first)); + return ClipRRect( + borderRadius: BorderRadius.circular(cornerRadius), + child: Container( + width: dimension, + height: dimension, + color: color, + child: Center( + child: svg, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart new file mode 100644 index 0000000000000..82410b387ecfd --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart @@ -0,0 +1,390 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart' hide Icon; + +final builtInSpaceColors = [ + '0xFFA34AFD', + '0xFFFB006D', + '0xFF00C8FF', + '0xFFFFBA00', + '0xFFF254BC', + '0xFF2AC985', + '0xFFAAD93D', + '0xFF535CE4', + '0xFF808080', + '0xFFD2515F', + '0xFF409BF8', + '0xFFFF8933', +]; + +String generateRandomSpaceColor() { + final random = Random(); + return builtInSpaceColors[random.nextInt(builtInSpaceColors.length)]; +} + +final builtInSpaceIcons = + List.generate(15, (index) => 'space_icon_${index + 1}'); + +class SpaceIconPopup extends StatefulWidget { + const SpaceIconPopup({ + super.key, + this.icon, + this.iconColor, + this.cornerRadius = 16, + this.space, + required this.onIconChanged, + }); + + final String? icon; + final String? iconColor; + final ViewPB? space; + final void Function(String? icon, String? color) onIconChanged; + final double cornerRadius; + + @override + State createState() => _SpaceIconPopupState(); +} + +class _SpaceIconPopupState extends State { + late ValueNotifier selectedIcon = ValueNotifier( + widget.icon, + ); + late ValueNotifier selectedColor = ValueNotifier( + widget.iconColor ?? builtInSpaceColors.first, + ); + + @override + void dispose() { + selectedColor.dispose(); + selectedIcon.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + offset: const Offset(0, 4), + constraints: BoxConstraints.loose(const Size(360, 432)), + margin: const EdgeInsets.all(0), + direction: PopoverDirection.bottomWithCenterAligned, + child: _buildPreview(), + popupBuilder: (context) { + return FlowyIconEmojiPicker( + tabs: const [PickerTabType.icon], + onSelectedEmoji: (r) { + if (r.type == FlowyIconType.icon) { + try { + final iconsData = IconsData.fromJson(jsonDecode(r.emoji)); + final color = iconsData.color; + selectedIcon.value = + '${iconsData.groupName}/${iconsData.iconName}'; + if (color != null) { + selectedColor.value = color; + } + widget.onIconChanged(selectedIcon.value, selectedColor.value); + } on FormatException catch (e) { + selectedIcon.value = ''; + widget.onIconChanged(selectedIcon.value, selectedColor.value); + Log.warn('SpaceIconPopup onSelectedEmoji error:$e'); + } + } + PopoverContainer.of(context).close(); + }, + ); + }, + ); + } + + Widget _buildPreview() { + bool onHover = false; + return StatefulBuilder( + builder: (context, setState) { + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (event) => setState(() => onHover = true), + onExit: (event) => setState(() => onHover = false), + child: ValueListenableBuilder( + valueListenable: selectedColor, + builder: (_, color, __) { + return ValueListenableBuilder( + valueListenable: selectedIcon, + builder: (_, value, __) { + Widget child; + if (value == null) { + if (widget.space == null) { + child = DefaultSpaceIcon( + cornerRadius: widget.cornerRadius, + dimension: 32, + iconDimension: 32, + ); + } else { + child = SpaceIcon( + dimension: 32, + space: widget.space!, + svgSize: 24, + cornerRadius: widget.cornerRadius, + ); + } + } else if (value.contains('space_icon')) { + child = ClipRRect( + borderRadius: BorderRadius.circular(widget.cornerRadius), + child: Container( + color: Color(int.parse(color)), + child: Align( + child: FlowySvg( + FlowySvgData('assets/flowy_icons/16x/$value.svg'), + size: const Size.square(42), + color: Theme.of(context).colorScheme.surface, + ), + ), + ), + ); + } else { + final content = kIconGroups?.findSvgContent(value); + if (content == null) { + child = const SizedBox.shrink(); + } else { + child = ClipRRect( + borderRadius: + BorderRadius.circular(widget.cornerRadius), + child: Container( + color: Color(int.parse(color)), + child: Align( + child: FlowySvg.string( + content, + size: const Size.square(24), + color: Theme.of(context).colorScheme.surface, + ), + ), + ), + ); + } + } + + if (onHover) { + return Stack( + children: [ + Positioned.fill( + child: Opacity(opacity: 0.2, child: child), + ), + const Center( + child: FlowySvg( + FlowySvgs.view_item_rename_s, + size: Size.square(20), + ), + ), + ], + ); + } + return child; + }, + ); + }, + ), + ); + }, + ); + } +} + +class SpaceIconPicker extends StatefulWidget { + const SpaceIconPicker({ + super.key, + required this.onIconChanged, + this.skipFirstNotification = false, + this.icon, + this.iconColor, + }); + + final bool skipFirstNotification; + final void Function(String icon, String color) onIconChanged; + final String? icon; + final String? iconColor; + + @override + State createState() => _SpaceIconPickerState(); +} + +class _SpaceIconPickerState extends State { + late ValueNotifier selectedColor = + ValueNotifier(widget.iconColor ?? builtInSpaceColors.first); + late ValueNotifier selectedIcon = + ValueNotifier(widget.icon ?? builtInSpaceIcons.first); + + @override + void initState() { + super.initState(); + + if (!widget.skipFirstNotification) { + widget.onIconChanged(selectedIcon.value, selectedColor.value); + } + + selectedColor.addListener(_onColorChanged); + selectedIcon.addListener(_onIconChanged); + } + + void _onColorChanged() { + widget.onIconChanged(selectedIcon.value, selectedColor.value); + } + + void _onIconChanged() { + widget.onIconChanged(selectedIcon.value, selectedColor.value); + } + + @override + void dispose() { + selectedColor.removeListener(_onColorChanged); + selectedColor.dispose(); + + selectedIcon.removeListener(_onIconChanged); + selectedIcon.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.regular( + LocaleKeys.space_spaceIconBackground.tr(), + color: Theme.of(context).hintColor, + ), + const VSpace(10.0), + _Colors( + selectedColor: selectedColor.value, + onColorSelected: (color) => selectedColor.value = color, + ), + const VSpace(12.0), + FlowyText.regular( + LocaleKeys.space_spaceIcon.tr(), + color: Theme.of(context).hintColor, + ), + const VSpace(10.0), + ValueListenableBuilder( + valueListenable: selectedColor, + builder: (_, value, ___) => _Icons( + selectedColor: value, + selectedIcon: selectedIcon.value, + onIconSelected: (icon) => selectedIcon.value = icon, + ), + ), + ], + ); + } +} + +class _Colors extends StatefulWidget { + const _Colors({ + required this.selectedColor, + required this.onColorSelected, + }); + + final String selectedColor; + final void Function(String color) onColorSelected; + + @override + State<_Colors> createState() => _ColorsState(); +} + +class _ColorsState extends State<_Colors> { + late String selectedColor = widget.selectedColor; + + @override + Widget build(BuildContext context) { + return GridView.count( + shrinkWrap: true, + crossAxisCount: 6, + mainAxisSpacing: 4.0, + children: builtInSpaceColors.map((color) { + return GestureDetector( + onTap: () { + setState(() => selectedColor = color); + + widget.onColorSelected(color); + }, + child: Container( + margin: const EdgeInsets.all(2.0), + padding: const EdgeInsets.all(2.0), + decoration: selectedColor == color + ? ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide( + width: 1.50, + strokeAlign: BorderSide.strokeAlignOutside, + color: Color(0xFF00BCF0), + ), + borderRadius: BorderRadius.circular(20), + ), + ) + : null, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(int.parse(color)), + borderRadius: BorderRadius.circular(20.0), + ), + ), + ), + ); + }).toList(), + ); + } +} + +class _Icons extends StatefulWidget { + const _Icons({ + required this.selectedColor, + required this.selectedIcon, + required this.onIconSelected, + }); + + final String selectedColor; + final String selectedIcon; + final void Function(String color) onIconSelected; + + @override + State<_Icons> createState() => _IconsState(); +} + +class _IconsState extends State<_Icons> { + late String selectedIcon = widget.selectedIcon; + + @override + Widget build(BuildContext context) { + return GridView.count( + shrinkWrap: true, + crossAxisCount: 5, + mainAxisSpacing: 8.0, + crossAxisSpacing: 12.0, + children: builtInSpaceIcons.map((icon) { + return GestureDetector( + onTap: () { + setState(() => selectedIcon = icon); + + widget.onIconSelected(icon); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: FlowySvg( + FlowySvgData('assets/flowy_icons/16x/$icon.svg'), + color: Color(int.parse(widget.selectedColor)), + blendMode: BlendMode.srcOut, + ), + ), + ); + }).toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_migration.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_migration.dart new file mode 100644 index 0000000000000..10ef94ba018be --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_migration.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SpaceMigration extends StatefulWidget { + const SpaceMigration({super.key}); + + @override + State createState() => _SpaceMigrationState(); +} + +class _SpaceMigrationState extends State { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: Theme.of(context).isLightMode + ? const Color(0x66F5EAFF) + : const Color(0x1AFFFFFF), + shape: RoundedRectangleBorder( + side: const BorderSide( + strokeAlign: BorderSide.strokeAlignOutside, + color: Color(0x339327FF), + ), + borderRadius: BorderRadius.circular(10), + ), + ), + child: _isExpanded + ? _buildExpandedMigrationContent() + : _buildCollapsedMigrationContent(), + ); + } + + Widget _buildExpandedMigrationContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MigrationTitle( + onClose: () => setState(() => _isExpanded = false), + ), + const VSpace(6.0), + Opacity( + opacity: 0.7, + child: FlowyText.regular( + LocaleKeys.space_upgradeSpaceDescription.tr(), + maxLines: null, + fontSize: 13.0, + lineHeight: 1.3, + ), + ), + const VSpace(12.0), + _ExpandedUpgradeButton( + onUpgrade: () => + context.read().add(const SpaceEvent.migrate()), + ), + ], + ); + } + + Widget _buildCollapsedMigrationContent() { + const linearGradient = LinearGradient( + begin: Alignment.bottomLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF8032FF), Color(0xFFEF35FF)], + stops: [0.1545, 0.8225], + ); + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => setState(() => _isExpanded = true), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.upgrade_s, + blendMode: null, + ), + const HSpace(8.0), + Expanded( + child: ShaderMask( + shaderCallback: (Rect bounds) => + linearGradient.createShader(bounds), + blendMode: BlendMode.srcIn, + child: FlowyText( + LocaleKeys.space_upgradeYourSpace.tr(), + ), + ), + ), + const FlowySvg( + FlowySvgs.space_arrow_right_s, + blendMode: null, + ), + ], + ), + ); + } +} + +class _MigrationTitle extends StatelessWidget { + const _MigrationTitle({required this.onClose}); + + final VoidCallback? onClose; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const FlowySvg( + FlowySvgs.upgrade_s, + blendMode: null, + ), + const HSpace(8.0), + Expanded( + child: FlowyText( + LocaleKeys.space_upgradeSpaceTitle.tr(), + maxLines: 3, + lineHeight: 1.2, + ), + ), + ], + ); + } +} + +class _ExpandedUpgradeButton extends StatelessWidget { + const _ExpandedUpgradeButton({required this.onUpgrade}); + + final VoidCallback? onUpgrade; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onUpgrade, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: ShapeDecoration( + color: const Color(0xFFA44AFD), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(9)), + ), + child: FlowyText( + LocaleKeys.space_upgrade.tr(), + color: Colors.white, + fontSize: 12.0, + strutStyle: const StrutStyle(forceStrutHeight: true), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart new file mode 100644 index 0000000000000..4b13062c3e7a3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart @@ -0,0 +1,211 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/tab.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SpaceMorePopup extends StatelessWidget { + const SpaceMorePopup({ + super.key, + required this.space, + required this.onAction, + required this.onEditing, + this.isHovered = false, + }); + + final ViewPB space; + final void Function(SpaceMoreActionType type, dynamic data) onAction; + final void Function(bool value) onEditing; + final bool isHovered; + + @override + Widget build(BuildContext context) { + final wrappers = _buildActionTypeWrappers(); + return PopoverActionList( + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 8), + actions: wrappers, + constraints: const BoxConstraints( + minWidth: 260, + ), + buildChild: (popover) { + return FlowyIconButton( + width: 24, + icon: FlowySvg( + FlowySvgs.workspace_three_dots_s, + color: isHovered ? Theme.of(context).colorScheme.onSurface : null, + ), + tooltipText: LocaleKeys.space_manage.tr(), + onPressed: () { + onEditing(true); + popover.show(); + }, + ); + }, + onSelected: (_, __) {}, + onClosed: () => onEditing(false), + ); + } + + List _buildActionTypeWrappers() { + final actionTypes = _buildActionTypes(); + return actionTypes + .map( + (e) => SpaceMoreActionTypeWrapper(e, (controller, data) { + onAction(e, data); + controller.close(); + }), + ) + .toList(); + } + + List _buildActionTypes() { + return [ + SpaceMoreActionType.rename, + SpaceMoreActionType.changeIcon, + SpaceMoreActionType.manage, + SpaceMoreActionType.duplicate, + SpaceMoreActionType.divider, + SpaceMoreActionType.addNewSpace, + SpaceMoreActionType.collapseAllPages, + SpaceMoreActionType.divider, + SpaceMoreActionType.delete, + ]; + } +} + +class SpaceMoreActionTypeWrapper extends CustomActionCell { + SpaceMoreActionTypeWrapper(this.inner, this.onTap); + + final SpaceMoreActionType inner; + final void Function(PopoverController controller, dynamic data) onTap; + + @override + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { + if (inner == SpaceMoreActionType.divider) { + return _buildDivider(); + } else if (inner == SpaceMoreActionType.changeIcon) { + return _buildEmojiActionButton(context, controller); + } else { + return _buildNormalActionButton(context, controller); + } + } + + Widget _buildNormalActionButton( + BuildContext context, + PopoverController controller, + ) { + return _buildActionButton(context, () => onTap(controller, null)); + } + + Widget _buildEmojiActionButton( + BuildContext context, + PopoverController controller, + ) { + final child = _buildActionButton(context, null); + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(360, 432)), + margin: const EdgeInsets.all(0), + clickHandler: PopoverClickHandler.gestureDetector, + offset: const Offset(0, -40), + popupBuilder: (context) { + return FlowyIconEmojiPicker( + tabs: const [PickerTabType.icon], + onSelectedEmoji: (r) => onTap(controller, r), + ); + }, + child: child, + ); + } + + Widget _buildDivider() { + return const Padding( + padding: EdgeInsets.all(8.0), + child: FlowyDivider(), + ); + } + + Widget _buildActionButton( + BuildContext context, + VoidCallback? onTap, + ) { + final spaceBloc = context.read(); + final spaces = spaceBloc.state.spaces; + final currentSpace = spaceBloc.state.currentSpace; + + final isOwner = context + .read() + ?.state + .currentWorkspace + ?.role + .isOwner ?? + false; + final isPageCreator = + currentSpace?.createdBy == context.read().id; + final allowToDelete = isOwner || isPageCreator; + + bool disable = false; + var message = ''; + if (inner == SpaceMoreActionType.delete) { + if (spaces.length <= 1) { + disable = true; + message = LocaleKeys.space_unableToDeleteLastSpace.tr(); + } else if (!allowToDelete) { + disable = true; + message = LocaleKeys.space_unableToDeleteSpaceNotCreatedByYou.tr(); + } + } + + final child = Container( + height: 34, + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: Opacity( + opacity: disable ? 0.3 : 1.0, + child: FlowyIconTextButton( + disable: disable, + margin: const EdgeInsets.symmetric(horizontal: 6), + iconPadding: 10.0, + onTap: onTap, + leftIconBuilder: (onHover) => FlowySvg( + inner.leftIconSvg, + color: inner == SpaceMoreActionType.delete && onHover + ? Theme.of(context).colorScheme.error + : null, + ), + rightIconBuilder: (_) => inner.rightIcon, + textBuilder: (onHover) => FlowyText.regular( + inner.name, + fontSize: 14.0, + figmaLineHeight: 18.0, + color: inner == SpaceMoreActionType.delete && onHover + ? Theme.of(context).colorScheme.error + : null, + ), + ), + ), + ); + + if (inner == SpaceMoreActionType.delete) { + return FlowyTooltip( + message: message, + child: child, + ); + } + + return child; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_import_notion.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_import_notion.dart new file mode 100644 index 0000000000000..f8b17c23c1a3b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_import_notion.dart @@ -0,0 +1,94 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/share/import_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/import.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class NotionImporter extends StatelessWidget { + const NotionImporter({required this.filePath, super.key}); + + final String filePath; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 30, maxHeight: 200), + child: FutureBuilder( + future: _uploadFile(), + builder: (context, snapshots) { + if (!snapshots.hasData) { + return const _Uploading(); + } + + final result = snapshots.data; + if (result == null) { + return const _UploadSuccess(); + } else { + return result.fold( + (_) => const _UploadSuccess(), + (err) => _UploadError(error: err), + ); + } + }, + ), + ); + } + + Future> _uploadFile() async { + final importResult = await ImportBackendService.importZipFiles( + [ImportZipPB()..filePath = filePath], + ); + + return importResult; + } +} + +class _UploadSuccess extends StatelessWidget { + const _UploadSuccess(); + + @override + Widget build(BuildContext context) { + return FlowyText( + fontSize: 16, + LocaleKeys.settings_common_uploadNotionSuccess.tr(), + maxLines: 10, + ); + } +} + +class _Uploading extends StatelessWidget { + const _Uploading(); + + @override + Widget build(BuildContext context) { + return IntrinsicHeight( + child: Center( + child: Column( + children: [ + const CircularProgressIndicator.adaptive(), + const VSpace(12), + FlowyText( + fontSize: 16, + LocaleKeys.settings_common_uploadingFile.tr(), + maxLines: null, + ), + ], + ), + ), + ); + } +} + +class _UploadError extends StatelessWidget { + const _UploadError({required this.error}); + + final FlowyError error; + + @override + Widget build(BuildContext context) { + return FlowyText(error.msg, maxLines: 10); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart new file mode 100644 index 0000000000000..ee5bc3fab3bff --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart @@ -0,0 +1,200 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +enum WorkspaceMoreAction { + rename, + delete, + leave, + divider, +} + +class WorkspaceMoreActionList extends StatelessWidget { + const WorkspaceMoreActionList({ + super.key, + required this.workspace, + required this.isShowingMoreActions, + }); + + final UserWorkspacePB workspace; + final ValueNotifier isShowingMoreActions; + + @override + Widget build(BuildContext context) { + final myRole = context.read().state.myRole; + final actions = []; + if (myRole.isOwner) { + actions.add(WorkspaceMoreAction.rename); + actions.add(WorkspaceMoreAction.divider); + actions.add(WorkspaceMoreAction.delete); + } else if (myRole.canLeave) { + actions.add(WorkspaceMoreAction.leave); + } + if (actions.isEmpty) { + return const SizedBox.shrink(); + } + return PopoverActionList<_WorkspaceMoreActionWrapper>( + direction: PopoverDirection.bottomWithLeftAligned, + actions: actions + .map((e) => _WorkspaceMoreActionWrapper(e, workspace)) + .toList(), + constraints: const BoxConstraints(minWidth: 220), + animationDuration: Durations.short3, + slideDistance: 2, + beginScaleFactor: 1.0, + beginOpacity: 0.8, + onClosed: () { + isShowingMoreActions.value = false; + }, + buildChild: (controller) { + return SizedBox.square( + dimension: 24.0, + child: FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 4.0), + text: const FlowySvg( + FlowySvgs.workspace_three_dots_s, + ), + onTap: () { + if (!isShowingMoreActions.value) { + controller.show(); + } + + isShowingMoreActions.value = true; + }, + ), + ); + }, + onSelected: (action, controller) {}, + ); + } +} + +class _WorkspaceMoreActionWrapper extends CustomActionCell { + _WorkspaceMoreActionWrapper(this.inner, this.workspace); + + final WorkspaceMoreAction inner; + final UserWorkspacePB workspace; + + @override + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { + if (inner == WorkspaceMoreAction.divider) { + return const Divider(); + } + + return _buildActionButton(context, controller); + } + + Widget _buildActionButton( + BuildContext context, + PopoverController controller, + ) { + return FlowyIconTextButton( + leftIconBuilder: (onHover) => buildLeftIcon(context, onHover), + iconPadding: 10.0, + textBuilder: (onHover) => FlowyText.regular( + name, + fontSize: 14.0, + figmaLineHeight: 18.0, + color: [WorkspaceMoreAction.delete, WorkspaceMoreAction.leave] + .contains(inner) && + onHover + ? Theme.of(context).colorScheme.error + : null, + ), + margin: const EdgeInsets.all(6), + onTap: () async { + PopoverContainer.of(context).closeAll(); + + final workspaceBloc = context.read(); + switch (inner) { + case WorkspaceMoreAction.divider: + break; + case WorkspaceMoreAction.delete: + await showConfirmDeletionDialog( + context: context, + name: workspace.name, + description: LocaleKeys.workspace_deleteWorkspaceHintText.tr(), + onConfirm: () { + workspaceBloc.add( + UserWorkspaceEvent.deleteWorkspace(workspace.workspaceId), + ); + }, + ); + case WorkspaceMoreAction.rename: + await NavigatorTextFieldDialog( + title: LocaleKeys.workspace_renameWorkspace.tr(), + value: workspace.name, + hintText: '', + autoSelectAllText: true, + onConfirm: (name, context) async { + workspaceBloc.add( + UserWorkspaceEvent.renameWorkspace( + workspace.workspaceId, + name, + ), + ); + }, + ).show(context); + case WorkspaceMoreAction.leave: + await showConfirmDialog( + context: context, + title: LocaleKeys.workspace_leaveCurrentWorkspace.tr(), + description: + LocaleKeys.workspace_leaveCurrentWorkspacePrompt.tr(), + confirmLabel: LocaleKeys.button_yes.tr(), + onConfirm: () { + workspaceBloc.add( + UserWorkspaceEvent.leaveWorkspace(workspace.workspaceId), + ); + }, + ); + } + }, + ); + } + + String get name { + switch (inner) { + case WorkspaceMoreAction.delete: + return LocaleKeys.button_delete.tr(); + case WorkspaceMoreAction.rename: + return LocaleKeys.button_rename.tr(); + case WorkspaceMoreAction.leave: + return LocaleKeys.workspace_leaveCurrentWorkspace.tr(); + case WorkspaceMoreAction.divider: + return ''; + } + } + + Widget buildLeftIcon(BuildContext context, bool onHover) { + switch (inner) { + case WorkspaceMoreAction.delete: + return FlowySvg( + FlowySvgs.trash_s, + color: onHover ? Theme.of(context).colorScheme.error : null, + ); + case WorkspaceMoreAction.rename: + return const FlowySvg(FlowySvgs.view_item_rename_s); + case WorkspaceMoreAction.leave: + return FlowySvg( + FlowySvgs.logout_s, + color: onHover ? Theme.of(context).colorScheme.error : null, + ); + case WorkspaceMoreAction.divider: + return const SizedBox.shrink(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart new file mode 100644 index 0000000000000..12dec420261ff --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart @@ -0,0 +1,130 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/util/color_generator/color_generator.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../../../../../shared/icon_emoji_picker/tab.dart'; + +class WorkspaceIcon extends StatefulWidget { + const WorkspaceIcon({ + super.key, + required this.workspace, + required this.enableEdit, + required this.iconSize, + required this.fontSize, + required this.onSelected, + this.borderRadius = 4, + this.emojiSize, + this.alignment, + required this.figmaLineHeight, + this.showBorder = true, + }); + + final UserWorkspacePB workspace; + final double iconSize; + final bool enableEdit; + final double fontSize; + final double? emojiSize; + final void Function(EmojiIconData) onSelected; + final double borderRadius; + final Alignment? alignment; + final double figmaLineHeight; + final bool showBorder; + + @override + State createState() => _WorkspaceIconState(); +} + +class _WorkspaceIconState extends State { + final controller = PopoverController(); + + @override + Widget build(BuildContext context) { + final color = ColorGenerator(widget.workspace.name).randomColor(); + Widget child = widget.workspace.icon.isNotEmpty + ? FlowyText.emoji( + widget.workspace.icon, + fontSize: widget.emojiSize, + figmaLineHeight: widget.figmaLineHeight, + optimizeEmojiAlign: true, + ) + : FlowyText.semibold( + widget.workspace.name.isEmpty + ? '' + : widget.workspace.name.substring(0, 1), + fontSize: widget.fontSize, + color: color.$1, + ); + + child = Container( + alignment: Alignment.center, + width: widget.iconSize, + height: widget.iconSize, + decoration: BoxDecoration( + color: widget.workspace.icon.isNotEmpty ? null : color.$2, + borderRadius: BorderRadius.circular(widget.borderRadius), + border: widget.showBorder + ? Border.all( + color: const Color(0x1A717171), + ) + : null, + ), + child: child, + ); + + if (widget.enableEdit) { + child = _buildEditableIcon(child); + } + + return child; + } + + Widget _buildEditableIcon(Widget child) { + if (UniversalPlatform.isDesktopOrWeb) { + return AppFlowyPopover( + offset: const Offset(0, 8), + controller: controller, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: BoxConstraints.loose(const Size(364, 356)), + clickHandler: PopoverClickHandler.gestureDetector, + margin: const EdgeInsets.all(0), + popupBuilder: (_) => FlowyIconEmojiPicker( + tabs: const [PickerTabType.emoji], + onSelectedEmoji: (result) { + widget.onSelected(result); + controller.close(); + }, + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: child, + ), + ); + } + + return GestureDetector( + onTap: () async { + final result = await context.push( + Uri( + path: MobileEmojiPickerScreen.routeName, + queryParameters: { + MobileEmojiPickerScreen.pageTitle: + LocaleKeys.settings_workspacePage_workspaceIcon_title.tr(), + MobileEmojiPickerScreen.selectTabs: [PickerTabType.emoji.name], + }, + ).toString(), + ); + if (result != null) { + widget.onSelected(result); + } + }, + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart new file mode 100644 index 0000000000000..cfb86b8832d0d --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -0,0 +1,513 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '_sidebar_import_notion.dart'; + +@visibleForTesting +const createWorkspaceButtonKey = ValueKey('createWorkspaceButton'); + +@visibleForTesting +const importNotionButtonKey = ValueKey('importNotinoButton'); + +class WorkspacesMenu extends StatefulWidget { + const WorkspacesMenu({ + super.key, + required this.userProfile, + required this.currentWorkspace, + required this.workspaces, + }); + + final UserProfilePB userProfile; + final UserWorkspacePB currentWorkspace; + final List workspaces; + + @override + State createState() => _WorkspacesMenuState(); +} + +class _WorkspacesMenuState extends State { + final ValueNotifier isShowingMoreActions = ValueNotifier(false); + + @override + void dispose() { + isShowingMoreActions.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // user email + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Row( + children: [ + Expanded( + child: FlowyText.medium( + _getUserInfo(), + fontSize: 12.0, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).hintColor, + ), + ), + const HSpace(4.0), + const _WorkspaceMoreButton(), + const HSpace(8.0), + ], + ), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Divider(height: 1.0), + ), + // workspace list + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final workspace in widget.workspaces) ...[ + WorkspaceMenuItem( + key: ValueKey(workspace.workspaceId), + workspace: workspace, + userProfile: widget.userProfile, + isSelected: workspace.workspaceId == + widget.currentWorkspace.workspaceId, + isShowingMoreActions: isShowingMoreActions, + ), + const VSpace(6.0), + ], + ], + ), + ), + ), + // add new workspace + const _CreateWorkspaceButton(), + const VSpace(6.0), + + if (UniversalPlatform.isDesktop) ...[ + const _ImportNotionButton(), + const VSpace(6.0), + ], + ], + ); + } + + String _getUserInfo() { + if (widget.userProfile.email.isNotEmpty) { + return widget.userProfile.email; + } + + if (widget.userProfile.name.isNotEmpty) { + return widget.userProfile.name; + } + + return LocaleKeys.defaultUsername.tr(); + } +} + +class WorkspaceMenuItem extends StatefulWidget { + const WorkspaceMenuItem({ + super.key, + required this.workspace, + required this.userProfile, + required this.isSelected, + required this.isShowingMoreActions, + }); + + final UserProfilePB userProfile; + final UserWorkspacePB workspace; + final bool isSelected; + final ValueNotifier isShowingMoreActions; + + @override + State createState() => _WorkspaceMenuItemState(); +} + +class _WorkspaceMenuItemState extends State { + final ValueNotifier isHovered = ValueNotifier(false); + + @override + void dispose() { + isHovered.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => WorkspaceMemberBloc( + userProfile: widget.userProfile, + workspace: widget.workspace, + )..add(const WorkspaceMemberEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + // settings right icon inside the flowy button will + // cause the popover dismiss intermediately when click the right icon. + // so using the stack to put the right icon on the flowy button. + return SizedBox( + height: 44, + child: MouseRegion( + onEnter: (_) => isHovered.value = true, + onExit: (_) => isHovered.value = false, + child: Stack( + alignment: Alignment.center, + children: [ + _WorkspaceInfo( + isSelected: widget.isSelected, + workspace: widget.workspace, + ), + Positioned(left: 4, child: _buildLeftIcon(context)), + Positioned( + right: 4.0, + child: Align(child: _buildRightIcon(context, isHovered)), + ), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildLeftIcon(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.document_plugins_cover_changeIcon.tr(), + child: WorkspaceIcon( + workspace: widget.workspace, + iconSize: 36, + emojiSize: 24.0, + fontSize: 18.0, + figmaLineHeight: 26.0, + borderRadius: 12.0, + enableEdit: true, + onSelected: (result) => context.read().add( + UserWorkspaceEvent.updateWorkspaceIcon( + widget.workspace.workspaceId, + result.emoji, + ), + ), + ), + ); + } + + Widget _buildRightIcon(BuildContext context, ValueNotifier isHovered) { + return Row( + children: [ + // only the owner can update or delete workspace. + if (!context.read().state.isLoading) + ValueListenableBuilder( + valueListenable: isHovered, + builder: (context, value, child) { + return Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Opacity( + opacity: value ? 1.0 : 0.0, + child: child, + ), + ); + }, + child: WorkspaceMoreActionList( + workspace: widget.workspace, + isShowingMoreActions: widget.isShowingMoreActions, + ), + ), + const HSpace(8.0), + if (widget.isSelected) ...[ + const Padding( + padding: EdgeInsets.all(5.0), + child: FlowySvg( + FlowySvgs.workspace_selected_s, + blendMode: null, + size: Size.square(14.0), + ), + ), + const HSpace(8.0), + ], + ], + ); + } +} + +class _WorkspaceInfo extends StatelessWidget { + const _WorkspaceInfo({ + required this.isSelected, + required this.workspace, + }); + + final bool isSelected; + final UserWorkspacePB workspace; + + @override + Widget build(BuildContext context) { + final memberCount = workspace.memberCount.toInt(); + return FlowyButton( + onTap: () => _openWorkspace(context), + iconPadding: 10.0, + leftIconSize: const Size.square(32), + leftIcon: const SizedBox.square(dimension: 32), + rightIcon: const HSpace(32.0), + text: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // workspace name + FlowyText.medium( + workspace.name, + fontSize: 14.0, + figmaLineHeight: 17.0, + overflow: TextOverflow.ellipsis, + withTooltip: true, + ), + // workspace members count + FlowyText.regular( + memberCount == 0 + ? '' + : LocaleKeys.settings_appearance_members_membersCount.plural( + memberCount, + ), + fontSize: 10.0, + figmaLineHeight: 12.0, + color: Theme.of(context).hintColor, + ), + ], + ), + ); + } + + void _openWorkspace(BuildContext context) { + if (!isSelected) { + Log.info('open workspace: ${workspace.workspaceId}'); + + // Persist and close other tabs when switching workspace, restore tabs for new workspace + getIt().add(TabsEvent.switchWorkspace(workspace.workspaceId)); + + context + .read() + .add(UserWorkspaceEvent.openWorkspace(workspace.workspaceId)); + + PopoverContainer.of(context).closeAll(); + } + } +} + +class CreateWorkspaceDialog extends StatelessWidget { + const CreateWorkspaceDialog({ + super.key, + required this.onConfirm, + }); + + final void Function(String name) onConfirm; + + @override + Widget build(BuildContext context) { + return NavigatorTextFieldDialog( + title: LocaleKeys.workspace_create.tr(), + value: '', + hintText: '', + autoSelectAllText: true, + onConfirm: (name, _) => onConfirm(name), + ); + } +} + +class _CreateWorkspaceButton extends StatelessWidget { + const _CreateWorkspaceButton(); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 40, + child: FlowyButton( + key: createWorkspaceButtonKey, + onTap: () { + _showCreateWorkspaceDialog(context); + PopoverContainer.of(context).closeAll(); + }, + margin: const EdgeInsets.symmetric(horizontal: 4.0), + text: Row( + children: [ + _buildLeftIcon(context), + const HSpace(8.0), + FlowyText.regular( + LocaleKeys.workspace_create.tr(), + ), + ], + ), + ), + ); + } + + Widget _buildLeftIcon(BuildContext context) { + return Container( + width: 36.0, + height: 36.0, + padding: const EdgeInsets.all(7.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x01717171).withOpacity(0.12), + width: 0.8, + ), + ), + child: const FlowySvg(FlowySvgs.add_workspace_s), + ); + } + + Future _showCreateWorkspaceDialog(BuildContext context) async { + if (context.mounted) { + final workspaceBloc = context.read(); + await CreateWorkspaceDialog( + onConfirm: (name) { + workspaceBloc.add(UserWorkspaceEvent.createWorkspace(name)); + }, + ).show(context); + } + } +} + +class _ImportNotionButton extends StatelessWidget { + const _ImportNotionButton(); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 40, + child: Stack( + alignment: Alignment.centerRight, + children: [ + FlowyButton( + key: importNotionButtonKey, + onTap: () { + _showImportNotinoDialog(context); + }, + margin: const EdgeInsets.symmetric(horizontal: 4.0), + text: Row( + children: [ + _buildLeftIcon(context), + const HSpace(8.0), + FlowyText.regular( + LocaleKeys.workspace_importFromNotion.tr(), + ), + ], + ), + ), + FlowyTooltip( + message: LocaleKeys.workspace_learnMore.tr(), + preferBelow: true, + child: FlowyIconButton( + icon: const FlowySvg( + FlowySvgs.information_s, + ), + onPressed: () { + afLaunchUrlString( + 'https://docs.appflowy.io/docs/guides/import-from-notion', + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildLeftIcon(BuildContext context) { + return Container( + width: 36.0, + height: 36.0, + padding: const EdgeInsets.all(7.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0x01717171).withOpacity(0.12), + width: 0.8, + ), + ), + child: const FlowySvg(FlowySvgs.add_workspace_s), + ); + } + + Future _showImportNotinoDialog(BuildContext context) async { + final result = await getIt().pickFiles( + type: FileType.custom, + allowedExtensions: ['zip'], + ); + + if (result == null || result.files.isEmpty) { + return; + } + + final path = result.files.first.path; + if (path == null) { + return; + } + + if (context.mounted) { + PopoverContainer.of(context).closeAll(); + await NavigatorCustomDialog( + hideCancelButton: true, + confirm: () {}, + child: NotionImporter( + filePath: path, + ), + ).show(context); + } else { + Log.error('context is not mounted when showing import notion dialog'); + } + } +} + +class _WorkspaceMoreButton extends StatelessWidget { + const _WorkspaceMoreButton(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 6), + popupBuilder: (_) => FlowyButton( + margin: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 7.0), + leftIcon: const FlowySvg(FlowySvgs.workspace_logout_s), + iconPadding: 10.0, + text: FlowyText.regular(LocaleKeys.button_logout.tr()), + onTap: () async { + await getIt().signOut(); + await runAppFlowy(); + }, + ), + child: SizedBox.square( + dimension: 24.0, + child: FlowyButton( + useIntrinsicWidth: true, + margin: EdgeInsets.zero, + text: const FlowySvg( + FlowySvgs.workspace_three_dots_s, + size: Size.square(16.0), + ), + onTap: () {}, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart new file mode 100644 index 0000000000000..c3480a94bcd35 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart @@ -0,0 +1,321 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SidebarWorkspace extends StatefulWidget { + const SidebarWorkspace({super.key, required this.userProfile}); + + final UserProfilePB userProfile; + + @override + State createState() => _SidebarWorkspaceState(); +} + +class _SidebarWorkspaceState extends State { + Loading? loadingIndicator; + + final ValueNotifier onHover = ValueNotifier(false); + + @override + void dispose() { + onHover.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (previous, current) => + previous.actionResult != current.actionResult, + listener: _showResultDialog, + builder: (context, state) { + final currentWorkspace = state.currentWorkspace; + if (currentWorkspace == null) { + return const SizedBox.shrink(); + } + return MouseRegion( + onEnter: (_) => onHover.value = true, + onExit: (_) => onHover.value = false, + child: ValueListenableBuilder( + valueListenable: onHover, + builder: (_, onHover, child) { + return Container( + margin: const EdgeInsets.only(right: 8.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + color: onHover + ? Theme.of(context).colorScheme.secondary + : Colors.transparent, + ), + child: Row( + children: [ + Expanded( + child: SidebarSwitchWorkspaceButton( + userProfile: widget.userProfile, + currentWorkspace: currentWorkspace, + isHover: onHover, + ), + ), + UserSettingButton( + userProfile: widget.userProfile, + isHover: onHover, + ), + const HSpace(8.0), + NotificationButton(isHover: onHover), + const HSpace(4.0), + ], + ), + ); + }, + ), + ); + }, + ); + } + + void _showResultDialog(BuildContext context, UserWorkspaceState state) { + final actionResult = state.actionResult; + if (actionResult == null) { + return; + } + + final actionType = actionResult.actionType; + final result = actionResult.result; + final isLoading = actionResult.isLoading; + + if (isLoading) { + loadingIndicator ??= Loading(context)..start(); + return; + } else { + loadingIndicator?.stop(); + loadingIndicator = null; + } + + if (result == null) { + return; + } + + result.onFailure((f) { + Log.error( + '[Workspace] Failed to perform ${actionType.toString()} action: $f', + ); + }); + + // show a confirmation dialog if the action is create and the result is LimitExceeded failure + if (actionType == UserWorkspaceActionType.create && + result.isFailure && + result.getFailure().code == ErrorCode.WorkspaceLimitExceeded) { + showDialog( + context: context, + builder: (context) => NavigatorOkCancelDialog( + message: LocaleKeys.workspace_createLimitExceeded.tr(), + ), + ); + return; + } + + final String? message; + switch (actionType) { + case UserWorkspaceActionType.create: + message = result.fold( + (s) => LocaleKeys.workspace_createSuccess.tr(), + (e) => '${LocaleKeys.workspace_createFailed.tr()}: ${e.msg}', + ); + break; + case UserWorkspaceActionType.delete: + message = result.fold( + (s) => LocaleKeys.workspace_deleteSuccess.tr(), + (e) => '${LocaleKeys.workspace_deleteFailed.tr()}: ${e.msg}', + ); + break; + case UserWorkspaceActionType.open: + message = result.fold( + (s) => LocaleKeys.workspace_openSuccess.tr(), + (e) => '${LocaleKeys.workspace_openFailed.tr()}: ${e.msg}', + ); + break; + case UserWorkspaceActionType.updateIcon: + message = result.fold( + (s) => LocaleKeys.workspace_updateIconSuccess.tr(), + (e) => '${LocaleKeys.workspace_updateIconFailed.tr()}: ${e.msg}', + ); + break; + case UserWorkspaceActionType.rename: + message = result.fold( + (s) => LocaleKeys.workspace_renameSuccess.tr(), + (e) => '${LocaleKeys.workspace_renameFailed.tr()}: ${e.msg}', + ); + break; + case UserWorkspaceActionType.none: + case UserWorkspaceActionType.fetchWorkspaces: + case UserWorkspaceActionType.leave: + message = null; + break; + } + + if (message != null) { + showToastNotification( + context, + message: message, + type: result.fold( + (_) => ToastificationType.success, + (_) => ToastificationType.error, + ), + ); + } + } +} + +class SidebarSwitchWorkspaceButton extends StatefulWidget { + const SidebarSwitchWorkspaceButton({ + super.key, + required this.userProfile, + required this.currentWorkspace, + this.isHover = false, + }); + + final UserWorkspacePB currentWorkspace; + final UserProfilePB userProfile; + final bool isHover; + + @override + State createState() => + _SidebarSwitchWorkspaceButtonState(); +} + +class _SidebarSwitchWorkspaceButtonState + extends State { + final PopoverController _popoverController = PopoverController(); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 5), + constraints: const BoxConstraints(maxWidth: 300, maxHeight: 600), + animationDuration: Durations.short3, + beginScaleFactor: 1.0, + beginOpacity: 0.8, + controller: _popoverController, + triggerActions: PopoverTriggerFlags.none, + onOpen: () { + context + .read() + .add(const UserWorkspaceEvent.fetchWorkspaces()); + }, + onClose: () { + Log.info('close workspace menu'); + }, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: BlocBuilder( + builder: (context, state) { + final currentWorkspace = state.currentWorkspace; + final workspaces = state.workspaces; + if (currentWorkspace == null) { + return const SizedBox.shrink(); + } + Log.info('open workspace menu'); + return WorkspacesMenu( + userProfile: widget.userProfile, + currentWorkspace: currentWorkspace, + workspaces: workspaces, + ); + }, + ), + ); + }, + child: _SideBarSwitchWorkspaceButtonChild( + currentWorkspace: widget.currentWorkspace, + popoverController: _popoverController, + isHover: widget.isHover, + ), + ); + } +} + +class _SideBarSwitchWorkspaceButtonChild extends StatelessWidget { + const _SideBarSwitchWorkspaceButtonChild({ + required this.popoverController, + required this.currentWorkspace, + required this.isHover, + }); + + final PopoverController popoverController; + final UserWorkspacePB currentWorkspace; + final bool isHover; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + context.read().add( + const UserWorkspaceEvent.fetchWorkspaces(), + ); + popoverController.show(); + }, + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: 30, + child: Row( + children: [ + const HSpace(4.0), + WorkspaceIcon( + workspace: currentWorkspace, + iconSize: 26, + fontSize: 16, + emojiSize: 20, + enableEdit: false, + borderRadius: 8.0, + figmaLineHeight: 18.0, + showBorder: false, + onSelected: (result) => context.read().add( + UserWorkspaceEvent.updateWorkspaceIcon( + currentWorkspace.workspaceId, + result.emoji, + ), + ), + ), + const HSpace(6), + Flexible( + child: FlowyText.medium( + currentWorkspace.name, + color: + isHover ? Theme.of(context).colorScheme.onSurface : null, + overflow: TextOverflow.ellipsis, + withTooltip: true, + fontSize: 15.0, + ), + ), + if (isHover) ...[ + const HSpace(4), + FlowySvg( + FlowySvgs.workspace_drop_down_menu_show_s, + color: + isHover ? Theme.of(context).colorScheme.onSurface : null, + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart new file mode 100644 index 0000000000000..2b27024cffe3f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/draggable_view_item.dart @@ -0,0 +1,281 @@ +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/draggable_item/draggable_item.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +enum DraggableHoverPosition { + none, + top, + center, + bottom, +} + +const kDraggableViewItemDividerHeight = 2.0; + +class DraggableViewItem extends StatefulWidget { + const DraggableViewItem({ + super.key, + required this.view, + this.feedback, + required this.child, + this.isFirstChild = false, + this.centerHighlightColor, + this.topHighlightColor, + this.bottomHighlightColor, + this.onDragging, + this.onMove, + }); + + final Widget child; + final WidgetBuilder? feedback; + final ViewPB view; + final bool isFirstChild; + final Color? centerHighlightColor; + final Color? topHighlightColor; + final Color? bottomHighlightColor; + final void Function(bool isDragging)? onDragging; + final void Function(ViewPB from, ViewPB to)? onMove; + + @override + State createState() => _DraggableViewItemState(); +} + +class _DraggableViewItemState extends State { + DraggableHoverPosition position = DraggableHoverPosition.none; + final hoverColor = const Color(0xFF00C8FF); + + @override + Widget build(BuildContext context) { + // add top border if the draggable item is on the top of the list + // highlight the draggable item if the draggable item is on the center + // add bottom border if the draggable item is on the bottom of the list + final child = UniversalPlatform.isMobile + ? _buildMobileDraggableItem() + : _buildDesktopDraggableItem(); + + return DraggableItem( + data: widget.view, + onDragging: widget.onDragging, + onWillAcceptWithDetails: (data) => true, + onMove: (data) { + final renderBox = context.findRenderObject() as RenderBox; + final offset = renderBox.globalToLocal(data.offset); + + if (offset.dx > renderBox.size.width) { + return; + } + + final position = _computeHoverPosition(offset, renderBox.size); + if (!_shouldAccept(data.data, position)) { + return; + } + _updatePosition(position); + }, + onLeave: (_) => _updatePosition( + DraggableHoverPosition.none, + ), + onAcceptWithDetails: (details) { + final data = details.data; + _move(data, widget.view); + _updatePosition(DraggableHoverPosition.none); + }, + feedback: IntrinsicWidth( + child: Opacity( + opacity: 0.5, + child: widget.feedback?.call(context) ?? child, + ), + ), + child: child, + ); + } + + Widget _buildDesktopDraggableItem() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // only show the top border when the draggable item is the first child + if (widget.isFirstChild) + Divider( + height: kDraggableViewItemDividerHeight, + thickness: kDraggableViewItemDividerHeight, + color: position == DraggableHoverPosition.top + ? widget.topHighlightColor ?? hoverColor + : Colors.transparent, + ), + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6.0), + color: position == DraggableHoverPosition.center + ? widget.centerHighlightColor ?? hoverColor.withOpacity(0.5) + : Colors.transparent, + ), + child: widget.child, + ), + Divider( + height: kDraggableViewItemDividerHeight, + thickness: kDraggableViewItemDividerHeight, + color: position == DraggableHoverPosition.bottom + ? widget.bottomHighlightColor ?? hoverColor + : Colors.transparent, + ), + ], + ); + } + + Widget _buildMobileDraggableItem() { + return Stack( + children: [ + if (widget.isFirstChild) + Positioned( + top: 0, + left: 0, + right: 0, + height: kDraggableViewItemDividerHeight, + child: Divider( + height: kDraggableViewItemDividerHeight, + thickness: kDraggableViewItemDividerHeight, + color: position == DraggableHoverPosition.top + ? widget.topHighlightColor ?? + Theme.of(context).colorScheme.secondary + : Colors.transparent, + ), + ), + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4.0), + color: position == DraggableHoverPosition.center + ? widget.centerHighlightColor ?? + Theme.of(context).colorScheme.secondary.withOpacity(0.5) + : Colors.transparent, + ), + child: widget.child, + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + height: kDraggableViewItemDividerHeight, + child: Divider( + height: kDraggableViewItemDividerHeight, + thickness: kDraggableViewItemDividerHeight, + color: position == DraggableHoverPosition.bottom + ? widget.bottomHighlightColor ?? + Theme.of(context).colorScheme.secondary + : Colors.transparent, + ), + ), + ], + ); + } + + void _updatePosition(DraggableHoverPosition position) { + if (UniversalPlatform.isMobile && position != this.position) { + HapticFeedback.mediumImpact(); + } + setState(() => this.position = position); + } + + void _move(ViewPB from, ViewPB to) { + if (position == DraggableHoverPosition.center && + to.layout != ViewLayoutPB.Document) { + // not support moving into a database + return; + } + + if (widget.onMove != null) { + widget.onMove?.call(from, to); + return; + } + + final fromSection = getViewSection(from); + final toSection = getViewSection(to); + + switch (position) { + case DraggableHoverPosition.top: + context.read().add( + ViewEvent.move( + from, + to.parentViewId, + null, + fromSection, + toSection, + ), + ); + break; + case DraggableHoverPosition.bottom: + context.read().add( + ViewEvent.move( + from, + to.parentViewId, + to.id, + fromSection, + toSection, + ), + ); + break; + case DraggableHoverPosition.center: + context.read().add( + ViewEvent.move( + from, + to.id, + to.childViews.lastOrNull?.id, + fromSection, + toSection, + ), + ); + break; + case DraggableHoverPosition.none: + break; + } + } + + DraggableHoverPosition _computeHoverPosition(Offset offset, Size size) { + final threshold = size.height / 5.0; + if (widget.isFirstChild && offset.dy < -5.0) { + return DraggableHoverPosition.top; + } + if (offset.dy > threshold) { + return DraggableHoverPosition.bottom; + } + return DraggableHoverPosition.center; + } + + bool _shouldAccept(ViewPB data, DraggableHoverPosition position) { + // could not move the view to a database + if (widget.view.layout.isDatabaseView && + position == DraggableHoverPosition.center) { + return false; + } + + // ignore moving the view to itself + if (data.id == widget.view.id) { + return false; + } + + // ignore moving the view to its child view + if (data.containsView(widget.view)) { + return false; + } + + return true; + } + + ViewSectionPB? getViewSection(ViewPB view) { + return context.read().getViewSection(view); + } +} + +extension on ViewPB { + bool containsView(ViewPB view) { + if (id == view.id) { + return true; + } + + return childViews.any((v) => v.containsView(view)); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart new file mode 100644 index 0000000000000..fe2e5def48cb4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_action_type.dart @@ -0,0 +1,98 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +enum ViewMoreActionType { + delete, + favorite, + unFavorite, + duplicate, + copyLink, // not supported yet. + rename, + moveTo, + openInNewTab, + changeIcon, + collapseAllPages, // including sub pages + divider, + lastModified, + created, +} + +extension ViewMoreActionTypeExtension on ViewMoreActionType { + String get name { + switch (this) { + case ViewMoreActionType.delete: + return LocaleKeys.disclosureAction_delete.tr(); + case ViewMoreActionType.favorite: + return LocaleKeys.disclosureAction_favorite.tr(); + case ViewMoreActionType.unFavorite: + return LocaleKeys.disclosureAction_unfavorite.tr(); + case ViewMoreActionType.duplicate: + return LocaleKeys.disclosureAction_duplicate.tr(); + case ViewMoreActionType.copyLink: + return LocaleKeys.disclosureAction_copyLink.tr(); + case ViewMoreActionType.rename: + return LocaleKeys.disclosureAction_rename.tr(); + case ViewMoreActionType.moveTo: + return LocaleKeys.disclosureAction_moveTo.tr(); + case ViewMoreActionType.openInNewTab: + return LocaleKeys.disclosureAction_openNewTab.tr(); + case ViewMoreActionType.changeIcon: + return LocaleKeys.disclosureAction_changeIcon.tr(); + case ViewMoreActionType.collapseAllPages: + return LocaleKeys.disclosureAction_collapseAllPages.tr(); + case ViewMoreActionType.divider: + case ViewMoreActionType.lastModified: + case ViewMoreActionType.created: + return ''; + } + } + + FlowySvgData get leftIconSvg { + switch (this) { + case ViewMoreActionType.delete: + return FlowySvgs.trash_s; + case ViewMoreActionType.favorite: + return FlowySvgs.favorite_s; + case ViewMoreActionType.unFavorite: + return FlowySvgs.unfavorite_s; + case ViewMoreActionType.duplicate: + return FlowySvgs.duplicate_s; + case ViewMoreActionType.rename: + return FlowySvgs.view_item_rename_s; + case ViewMoreActionType.moveTo: + return FlowySvgs.move_to_s; + case ViewMoreActionType.openInNewTab: + return FlowySvgs.view_item_open_in_new_tab_s; + case ViewMoreActionType.changeIcon: + return FlowySvgs.change_icon_s; + case ViewMoreActionType.collapseAllPages: + return FlowySvgs.collapse_all_page_s; + case ViewMoreActionType.divider: + case ViewMoreActionType.lastModified: + case ViewMoreActionType.copyLink: + case ViewMoreActionType.created: + throw UnsupportedError('No left icon for $this'); + } + } + + Widget get rightIcon { + switch (this) { + case ViewMoreActionType.changeIcon: + case ViewMoreActionType.moveTo: + case ViewMoreActionType.favorite: + case ViewMoreActionType.unFavorite: + case ViewMoreActionType.duplicate: + case ViewMoreActionType.copyLink: + case ViewMoreActionType.rename: + case ViewMoreActionType.openInNewTab: + case ViewMoreActionType.collapseAllPages: + case ViewMoreActionType.divider: + case ViewMoreActionType.delete: + case ViewMoreActionType.lastModified: + case ViewMoreActionType.created: + return const SizedBox.shrink(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_add_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_add_button.dart new file mode 100644 index 0000000000000..d0b99e2a5d7ba --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_add_button.dart @@ -0,0 +1,138 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/document.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/import/import_panel.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class ViewAddButton extends StatelessWidget { + const ViewAddButton({ + super.key, + required this.parentViewId, + required this.onEditing, + required this.onSelected, + this.isHovered = false, + }); + + final String parentViewId; + final void Function(bool value) onEditing; + final Function( + PluginBuilder, + String? name, + List? initialDataBytes, + bool openAfterCreated, + bool createNewView, + ) onSelected; + final bool isHovered; + + List get _actions { + return [ + // document, grid, kanban, calendar + ...pluginBuilders().map( + (pluginBuilder) => ViewAddButtonActionWrapper( + pluginBuilder: pluginBuilder, + ), + ), + // import from ... + ...getIt().builders.whereType().map( + (pluginBuilder) => ViewImportActionWrapper( + pluginBuilder: pluginBuilder, + ), + ), + ]; + } + + @override + Widget build(BuildContext context) { + return PopoverActionList( + direction: PopoverDirection.bottomWithLeftAligned, + actions: _actions, + offset: const Offset(0, 8), + constraints: const BoxConstraints( + minWidth: 200, + ), + buildChild: (popover) { + return FlowyIconButton( + width: 24, + icon: FlowySvg( + FlowySvgs.view_item_add_s, + color: isHovered ? Theme.of(context).colorScheme.onSurface : null, + ), + onPressed: () { + onEditing(true); + popover.show(); + }, + ); + }, + onSelected: (action, popover) { + onEditing(false); + if (action is ViewAddButtonActionWrapper) { + _showViewAddButtonActions(context, action); + } else if (action is ViewImportActionWrapper) { + _showViewImportAction(context, action); + } + popover.close(); + }, + onClosed: () { + onEditing(false); + }, + ); + } + + void _showViewAddButtonActions( + BuildContext context, + ViewAddButtonActionWrapper action, + ) { + onSelected(action.pluginBuilder, null, null, true, true); + } + + void _showViewImportAction( + BuildContext context, + ViewImportActionWrapper action, + ) { + showImportPanel( + parentViewId, + context, + (type, name, initialDataBytes) { + onSelected(action.pluginBuilder, null, null, true, false); + }, + ); + } +} + +class ViewAddButtonActionWrapper extends ActionCell { + ViewAddButtonActionWrapper({ + required this.pluginBuilder, + }); + + final PluginBuilder pluginBuilder; + + @override + Widget? leftIcon(Color iconColor) => FlowySvg( + pluginBuilder.icon, + size: const Size.square(16), + ); + + @override + String get name => pluginBuilder.menuName; + + PluginType get pluginType => pluginBuilder.pluginType; +} + +class ViewImportActionWrapper extends ActionCell { + ViewImportActionWrapper({ + required this.pluginBuilder, + }); + + final DocumentPluginBuilder pluginBuilder; + + @override + Widget? leftIcon(Color iconColor) => const FlowySvg(FlowySvgs.icon_import_s); + + @override + String get name => LocaleKeys.moreAction_import.tr(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart new file mode 100644 index 0000000000000..d6b5dfe29738b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart @@ -0,0 +1,914 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +typedef ViewItemOnSelected = void Function(BuildContext context, ViewPB view); +typedef ViewItemLeftIconBuilder = Widget Function( + BuildContext context, + ViewPB view, +); +typedef ViewItemRightIconsBuilder = List Function( + BuildContext context, + ViewPB view, +); + +enum IgnoreViewType { none, hide, disable } + +class ViewItem extends StatelessWidget { + const ViewItem({ + super.key, + required this.view, + this.parentView, + required this.spaceType, + required this.level, + this.leftPadding = 10, + required this.onSelected, + this.onTertiarySelected, + this.isFirstChild = false, + this.isDraggable = true, + required this.isFeedback, + this.height = HomeSpaceViewSizes.viewHeight, + this.isHoverEnabled = false, + this.isPlaceholder = false, + this.isHovered, + this.shouldRenderChildren = true, + this.leftIconBuilder, + this.rightIconsBuilder, + this.shouldLoadChildViews = true, + this.isExpandedNotifier, + this.extendBuilder, + this.disableSelectedStatus, + this.shouldIgnoreView, + this.enableRightClickContext = false, + }); + + final ViewPB view; + final ViewPB? parentView; + + final FolderSpaceType spaceType; + + // indicate the level of the view item + // used to calculate the left padding + final int level; + + // the left padding of the view item for each level + // the left padding of the each level = level * leftPadding + final double leftPadding; + + // Selected by normal conventions + final ViewItemOnSelected onSelected; + + // Selected by middle mouse button + final ViewItemOnSelected? onTertiarySelected; + + // used for indicating the first child of the parent view, so that we can + // add top border to the first child + final bool isFirstChild; + + // it should be false when it's rendered as feedback widget inside DraggableItem + final bool isDraggable; + + // identify if the view item is rendered as feedback widget inside DraggableItem + final bool isFeedback; + + final double height; + + final bool isHoverEnabled; + + // all the view movement depends on the [ViewItem] widget, so we have to add a + // placeholder widget to receive the drop event when moving view across sections. + final bool isPlaceholder; + + // used for control the expand/collapse icon + final ValueNotifier? isHovered; + + // render the child views of the view + final bool shouldRenderChildren; + + // custom the left icon widget, if it's null, the default expand/collapse icon will be used + final ViewItemLeftIconBuilder? leftIconBuilder; + + // custom the right icon widget, if it's null, the default ... and + button will be used + final ViewItemRightIconsBuilder? rightIconsBuilder; + + final bool shouldLoadChildViews; + final PropertyValueNotifier? isExpandedNotifier; + + final List Function(ViewPB view)? extendBuilder; + + // disable the selected status of the view item + final bool? disableSelectedStatus; + + // ignore the views when rendering the child views + final IgnoreViewType Function(ViewPB view)? shouldIgnoreView; + + /// Whether to add right-click to show the view action context menu + /// + final bool enableRightClickContext; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => + ViewBloc(view: view, shouldLoadChildViews: shouldLoadChildViews) + ..add(const ViewEvent.initial()), + child: BlocConsumer( + listenWhen: (p, c) => + c.lastCreatedView != null && + p.lastCreatedView?.id != c.lastCreatedView!.id, + listener: (context, state) => + context.read().openPlugin(state.lastCreatedView!), + builder: (context, state) { + // filter the child views that should be ignored + List childViews = state.view.childViews; + if (shouldIgnoreView != null) { + childViews = childViews + .where((v) => shouldIgnoreView!(v) != IgnoreViewType.hide) + .toList(); + } + + final Widget child = InnerViewItem( + view: state.view, + parentView: parentView, + childViews: childViews, + spaceType: spaceType, + level: level, + leftPadding: leftPadding, + showActions: state.isEditing, + enableRightClickContext: enableRightClickContext, + isExpanded: state.isExpanded, + disableSelectedStatus: disableSelectedStatus, + onSelected: onSelected, + onTertiarySelected: onTertiarySelected, + isFirstChild: isFirstChild, + isDraggable: isDraggable, + isFeedback: isFeedback, + height: height, + isHoverEnabled: isHoverEnabled, + isPlaceholder: isPlaceholder, + isHovered: isHovered, + shouldRenderChildren: shouldRenderChildren, + leftIconBuilder: leftIconBuilder, + rightIconsBuilder: rightIconsBuilder, + isExpandedNotifier: isExpandedNotifier, + extendBuilder: extendBuilder, + shouldIgnoreView: shouldIgnoreView, + ); + + if (shouldIgnoreView?.call(view) == IgnoreViewType.disable) { + return Opacity( + opacity: 0.5, + child: FlowyTooltip( + message: LocaleKeys.space_cannotMovePageToDatabase.tr(), + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: IgnorePointer(child: child), + ), + ), + ); + } + + return child; + }, + ), + ); + } +} + +// TODO: We shouldn't have local global variables +bool _isDragging = false; + +class InnerViewItem extends StatefulWidget { + const InnerViewItem({ + super.key, + required this.view, + required this.parentView, + required this.childViews, + required this.spaceType, + this.isDraggable = true, + this.isExpanded = true, + required this.level, + required this.leftPadding, + required this.showActions, + this.enableRightClickContext = false, + required this.onSelected, + this.onTertiarySelected, + this.isFirstChild = false, + required this.isFeedback, + required this.height, + this.isHoverEnabled = true, + this.isPlaceholder = false, + this.isHovered, + this.shouldRenderChildren = true, + required this.leftIconBuilder, + required this.rightIconsBuilder, + this.isExpandedNotifier, + required this.extendBuilder, + this.disableSelectedStatus, + required this.shouldIgnoreView, + }); + + final ViewPB view; + final ViewPB? parentView; + final List childViews; + final FolderSpaceType spaceType; + + final bool isDraggable; + final bool isExpanded; + final bool isFirstChild; + + // identify if the view item is rendered as feedback widget inside DraggableItem + final bool isFeedback; + + final int level; + final double leftPadding; + + final bool showActions; + final bool enableRightClickContext; + final ViewItemOnSelected onSelected; + final ViewItemOnSelected? onTertiarySelected; + final double height; + + final bool isHoverEnabled; + final bool isPlaceholder; + final bool? disableSelectedStatus; + final ValueNotifier? isHovered; + final bool shouldRenderChildren; + final ViewItemLeftIconBuilder? leftIconBuilder; + final ViewItemRightIconsBuilder? rightIconsBuilder; + + final PropertyValueNotifier? isExpandedNotifier; + final List Function(ViewPB view)? extendBuilder; + final IgnoreViewType Function(ViewPB view)? shouldIgnoreView; + + @override + State createState() => _InnerViewItemState(); +} + +class _InnerViewItemState extends State { + @override + void initState() { + super.initState(); + widget.isExpandedNotifier?.addListener(_collapseAllPages); + } + + @override + void dispose() { + widget.isExpandedNotifier?.removeListener(_collapseAllPages); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child = ValueListenableBuilder( + valueListenable: getIt().notifier, + builder: (context, value, _) { + final isSelected = value?.id == widget.view.id; + return SingleInnerViewItem( + view: widget.view, + parentView: widget.parentView, + level: widget.level, + showActions: widget.showActions, + enableRightClickContext: widget.enableRightClickContext, + spaceType: widget.spaceType, + onSelected: widget.onSelected, + onTertiarySelected: widget.onTertiarySelected, + isExpanded: widget.isExpanded, + isDraggable: widget.isDraggable, + leftPadding: widget.leftPadding, + isFeedback: widget.isFeedback, + height: widget.height, + isPlaceholder: widget.isPlaceholder, + isHovered: widget.isHovered, + leftIconBuilder: widget.leftIconBuilder, + rightIconsBuilder: widget.rightIconsBuilder, + extendBuilder: widget.extendBuilder, + disableSelectedStatus: widget.disableSelectedStatus, + shouldIgnoreView: widget.shouldIgnoreView, + isSelected: isSelected, + ); + }, + ); + + // if the view is expanded and has child views, render its child views + if (widget.isExpanded && + widget.shouldRenderChildren && + widget.childViews.isNotEmpty) { + final children = widget.childViews.map((childView) { + return ViewItem( + key: ValueKey('${widget.spaceType.name} ${childView.id}'), + parentView: widget.view, + spaceType: widget.spaceType, + isFirstChild: childView.id == widget.childViews.first.id, + view: childView, + level: widget.level + 1, + enableRightClickContext: widget.enableRightClickContext, + onSelected: widget.onSelected, + onTertiarySelected: widget.onTertiarySelected, + isDraggable: widget.isDraggable, + disableSelectedStatus: widget.disableSelectedStatus, + leftPadding: widget.leftPadding, + isFeedback: widget.isFeedback, + isPlaceholder: widget.isPlaceholder, + isHovered: widget.isHovered, + leftIconBuilder: widget.leftIconBuilder, + rightIconsBuilder: widget.rightIconsBuilder, + extendBuilder: widget.extendBuilder, + shouldIgnoreView: widget.shouldIgnoreView, + ); + }).toList(); + + child = Column( + mainAxisSize: MainAxisSize.min, + children: [child, ...children], + ); + } + + // wrap the child with DraggableItem if isDraggable is true + if ((widget.isDraggable || widget.isPlaceholder) && + !isReferencedDatabaseView(widget.view, widget.parentView)) { + child = DraggableViewItem( + isFirstChild: widget.isFirstChild, + view: widget.view, + onDragging: (isDragging) => _isDragging = isDragging, + onMove: widget.isPlaceholder + ? (from, to) => moveViewCrossSpace( + context, + null, + widget.view, + widget.parentView, + widget.spaceType, + from, + to.parentViewId, + ) + : null, + feedback: (context) => Container( + width: 250, + decoration: BoxDecoration( + color: Brightness.light == Theme.of(context).brightness + ? Colors.white + : Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: ViewItem( + view: widget.view, + parentView: widget.parentView, + spaceType: widget.spaceType, + level: widget.level, + onSelected: widget.onSelected, + onTertiarySelected: widget.onTertiarySelected, + isDraggable: false, + leftPadding: widget.leftPadding, + isFeedback: true, + enableRightClickContext: widget.enableRightClickContext, + leftIconBuilder: widget.leftIconBuilder, + rightIconsBuilder: widget.rightIconsBuilder, + extendBuilder: widget.extendBuilder, + shouldIgnoreView: widget.shouldIgnoreView, + ), + ), + child: child, + ); + } else { + // keep the same height of the DraggableItem + child = Padding( + padding: const EdgeInsets.only(top: kDraggableViewItemDividerHeight), + child: child, + ); + } + + return child; + } + + void _collapseAllPages() { + if (widget.isExpandedNotifier?.value == true) { + context.read().add(const ViewEvent.collapseAllPages()); + } + } +} + +class SingleInnerViewItem extends StatefulWidget { + const SingleInnerViewItem({ + super.key, + required this.view, + required this.parentView, + required this.isExpanded, + required this.level, + required this.leftPadding, + this.isDraggable = true, + required this.spaceType, + required this.showActions, + this.enableRightClickContext = false, + required this.onSelected, + this.onTertiarySelected, + required this.isFeedback, + required this.height, + this.isHoverEnabled = true, + this.isPlaceholder = false, + this.isHovered, + required this.leftIconBuilder, + required this.rightIconsBuilder, + required this.extendBuilder, + required this.disableSelectedStatus, + required this.shouldIgnoreView, + required this.isSelected, + }); + + final ViewPB view; + final ViewPB? parentView; + final bool isExpanded; + + // identify if the view item is rendered as feedback widget inside DraggableItem + final bool isFeedback; + + final int level; + final double leftPadding; + + final bool isDraggable; + final bool showActions; + final bool enableRightClickContext; + final ViewItemOnSelected onSelected; + final ViewItemOnSelected? onTertiarySelected; + final FolderSpaceType spaceType; + final double height; + + final bool isHoverEnabled; + final bool isPlaceholder; + final bool? disableSelectedStatus; + final ValueNotifier? isHovered; + final ViewItemLeftIconBuilder? leftIconBuilder; + final ViewItemRightIconsBuilder? rightIconsBuilder; + + final List Function(ViewPB view)? extendBuilder; + final IgnoreViewType Function(ViewPB view)? shouldIgnoreView; + final bool isSelected; + + @override + State createState() => _SingleInnerViewItemState(); +} + +class _SingleInnerViewItemState extends State { + final controller = PopoverController(); + final viewMoreActionController = PopoverController(); + + bool isIconPickerOpened = false; + + @override + Widget build(BuildContext context) { + bool isSelected = widget.isSelected; + + if (widget.disableSelectedStatus == true) { + isSelected = false; + } + + if (widget.isPlaceholder) { + return const SizedBox(height: 4, width: double.infinity); + } + + if (widget.isFeedback || !widget.isHoverEnabled) { + return _buildViewItem( + false, + !widget.isHoverEnabled ? isSelected : false, + ); + } + + return FlowyHover( + style: HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary), + resetHoverOnRebuild: widget.showActions || !isIconPickerOpened, + buildWhenOnHover: () => + !widget.showActions && !_isDragging && !isIconPickerOpened, + isSelected: () => widget.showActions || isSelected, + builder: (_, onHover) => _buildViewItem(onHover, isSelected), + ); + } + + Widget _buildViewItem(bool onHover, [bool isSelected = false]) { + final name = FlowyText.regular( + widget.view.nameOrDefault, + overflow: TextOverflow.ellipsis, + fontSize: 14.0, + figmaLineHeight: 18.0, + ); + final children = [ + const HSpace(2), + // expand icon or placeholder + widget.leftIconBuilder?.call(context, widget.view) ?? _buildLeftIcon(), + const HSpace(2), + // icon + _buildViewIconButton(), + const HSpace(6), + // title + Expanded( + child: widget.extendBuilder != null + ? Row( + children: [ + Flexible(child: name), + ...widget.extendBuilder!(widget.view), + ], + ) + : name, + ), + ]; + + // hover action + if (widget.showActions || onHover) { + if (widget.rightIconsBuilder != null) { + children.addAll(widget.rightIconsBuilder!(context, widget.view)); + } else { + // ··· more action button + children.add( + _buildViewMoreActionButton( + context, + viewMoreActionController, + (_) => FlowyTooltip( + message: LocaleKeys.menuAppHeader_moreButtonToolTip.tr(), + child: FlowyIconButton( + width: 24, + icon: const FlowySvg(FlowySvgs.workspace_three_dots_s), + onPressed: viewMoreActionController.show, + ), + ), + ), + ); + // only support add button for document layout + if (widget.view.layout == ViewLayoutPB.Document) { + // + button + children.add(const HSpace(8.0)); + children.add(_buildViewAddButton(context)); + } + children.add(const HSpace(4.0)); + } + } + + final child = GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => widget.onSelected(context, widget.view), + onTertiaryTapDown: (_) => + widget.onTertiarySelected?.call(context, widget.view), + child: SizedBox( + height: widget.height, + child: Padding( + padding: EdgeInsets.only(left: widget.level * widget.leftPadding), + child: Listener( + onPointerDown: (event) { + if (event.buttons == kSecondaryMouseButton && + widget.enableRightClickContext) { + viewMoreActionController.showAt( + // We add some horizontal offset + event.position + const Offset(4, 0), + ); + } + }, + behavior: HitTestBehavior.opaque, + child: Row(children: children), + ), + ), + ), + ); + + if (isSelected) { + final popoverController = getIt().state.controller; + return AppFlowyPopover( + controller: popoverController, + triggerActions: PopoverTriggerFlags.none, + offset: const Offset(0, 5), + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (_) => RenameViewPopover( + viewId: widget.view.id, + name: widget.view.name, + emoji: widget.view.icon.toEmojiIconData(), + popoverController: popoverController, + showIconChanger: false, + ), + child: child, + ); + } + + return child; + } + + Widget _buildViewIconButton() { + final iconData = widget.view.icon.toEmojiIconData(); + final icon = iconData.isNotEmpty + ? RawEmojiIconWidget(emoji: iconData, emojiSize: 16.0) + : Opacity(opacity: 0.6, child: widget.view.defaultIcon()); + + return AppFlowyPopover( + offset: const Offset(20, 0), + controller: controller, + direction: PopoverDirection.rightWithCenterAligned, + constraints: BoxConstraints.loose(const Size(364, 356)), + margin: const EdgeInsets.all(0), + onClose: () => setState(() => isIconPickerOpened = false), + child: GestureDetector( + // prevent the tap event from being passed to the parent widget + onTap: () {}, + child: FlowyTooltip( + message: LocaleKeys.document_plugins_cover_changeIcon.tr(), + child: SizedBox(width: 16.0, child: icon), + ), + ), + popupBuilder: (context) { + isIconPickerOpened = true; + return FlowyIconEmojiPicker( + onSelectedEmoji: (result) { + ViewBackendService.updateViewIcon( + viewId: widget.view.id, + viewIcon: result, + ); + controller.close(); + }, + ); + }, + ); + } + + // > button or · button + // show > if the view is expandable. + // show · if the view can't contain child views. + Widget _buildLeftIcon() { + return ViewItemDefaultLeftIcon( + view: widget.view, + parentView: widget.parentView, + isExpanded: widget.isExpanded, + leftPadding: widget.leftPadding, + isHovered: widget.isHovered, + ); + } + + // + button + Widget _buildViewAddButton(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.menuAppHeader_addPageTooltip.tr(), + child: ViewAddButton( + parentViewId: widget.view.id, + onEditing: (value) => + context.read().add(ViewEvent.setIsEditing(value)), + onSelected: _onSelected, + ), + ); + } + + void _onSelected( + PluginBuilder pluginBuilder, + String? name, + List? initialDataBytes, + bool openAfterCreated, + bool createNewView, + ) { + final viewBloc = context.read(); + + // the name of new document should be empty + final viewName = pluginBuilder.layoutType != ViewLayoutPB.Document + ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() + : ''; + viewBloc.add( + ViewEvent.createView( + viewName, + pluginBuilder.layoutType!, + openAfterCreated: openAfterCreated, + section: widget.spaceType.toViewSectionPB, + ), + ); + + viewBloc.add(const ViewEvent.setIsExpanded(true)); + } + + // ··· more action button + Widget _buildViewMoreActionButton( + BuildContext context, + PopoverController controller, + Widget Function(PopoverController) buildChild, + ) { + return BlocProvider( + create: (context) => SpaceBloc( + userProfile: context.read().userProfile, + workspaceId: context.read().workspaceId, + )..add(const SpaceEvent.initial(openFirstPage: false)), + child: ViewMoreActionPopover( + view: widget.view, + controller: controller, + isExpanded: widget.isExpanded, + spaceType: widget.spaceType, + onEditing: (value) => + context.read().add(ViewEvent.setIsEditing(value)), + buildChild: buildChild, + onAction: (action, data) async { + switch (action) { + case ViewMoreActionType.favorite: + case ViewMoreActionType.unFavorite: + context + .read() + .add(FavoriteEvent.toggle(widget.view)); + break; + case ViewMoreActionType.rename: + unawaited( + NavigatorTextFieldDialog( + title: LocaleKeys.disclosureAction_rename.tr(), + autoSelectAllText: true, + value: widget.view.nameOrDefault, + maxLength: 256, + onConfirm: (newValue, _) { + context.read().add(ViewEvent.rename(newValue)); + }, + ).show(context), + ); + break; + case ViewMoreActionType.delete: + // get if current page contains published child views + final (containPublishedPage, _) = + await ViewBackendService.containPublishedPage(widget.view); + if (containPublishedPage && context.mounted) { + await showConfirmDeletionDialog( + context: context, + name: widget.view.name, + description: LocaleKeys.publish_containsPublishedPage.tr(), + onConfirm: () => + context.read().add(const ViewEvent.delete()), + ); + } else if (context.mounted) { + context.read().add(const ViewEvent.delete()); + } + break; + case ViewMoreActionType.duplicate: + context.read().add(const ViewEvent.duplicate()); + break; + case ViewMoreActionType.openInNewTab: + context.read().openTab(widget.view); + break; + case ViewMoreActionType.collapseAllPages: + context.read().add(const ViewEvent.collapseAllPages()); + break; + case ViewMoreActionType.changeIcon: + if (data is! EmojiIconData) { + return; + } + final result = data; + await ViewBackendService.updateViewIcon( + viewId: widget.view.id, + viewIcon: result, + ); + break; + case ViewMoreActionType.moveTo: + final value = data; + if (value is! (ViewPB, ViewPB)) { + return; + } + final space = value.$1; + final target = value.$2; + moveViewCrossSpace( + context, + space, + widget.view, + widget.parentView, + widget.spaceType, + widget.view, + target.id, + ); + default: + throw UnsupportedError('$action is not supported'); + } + }, + ), + ); + } +} + +class _DotIconWidget extends StatelessWidget { + const _DotIconWidget(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(6.0), + child: Container( + width: 4, + height: 4, + decoration: BoxDecoration( + color: Theme.of(context).iconTheme.color, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } +} + +// workaround: we should use view.isEndPoint or something to check if the view can contain child views. But currently, we don't have that field. +bool isReferencedDatabaseView(ViewPB view, ViewPB? parentView) { + if (parentView == null) { + return false; + } + return view.layout.isDatabaseView && parentView.layout.isDatabaseView; +} + +void moveViewCrossSpace( + BuildContext context, + ViewPB? toSpace, + ViewPB view, + ViewPB? parentView, + FolderSpaceType spaceType, + ViewPB from, + String toId, +) { + if (isReferencedDatabaseView(view, parentView)) { + return; + } + + if (from.id == toId) { + return; + } + + final currentSpace = context.read().state.currentSpace; + if (currentSpace != null && + toSpace != null && + currentSpace.id != toSpace.id) { + Log.info( + 'Move view(${from.name}) to another space(${toSpace.name}), unpublish the view', + ); + context.read().add(const ViewEvent.unpublish(sync: false)); + } + + context.read().add(ViewEvent.move(from, toId, null, null, null)); +} + +class ViewItemDefaultLeftIcon extends StatelessWidget { + const ViewItemDefaultLeftIcon({ + super.key, + required this.view, + required this.parentView, + required this.isExpanded, + required this.leftPadding, + required this.isHovered, + }); + + final ViewPB view; + final ViewPB? parentView; + final bool isExpanded; + final double leftPadding; + final ValueNotifier? isHovered; + + @override + Widget build(BuildContext context) { + if (isReferencedDatabaseView(view, parentView)) { + return const _DotIconWidget(); + } + + if (context.read().state.view.childViews.isEmpty) { + return HSpace(leftPadding); + } + + final child = FlowyHover( + child: GestureDetector( + child: FlowySvg( + isExpanded + ? FlowySvgs.view_item_expand_s + : FlowySvgs.view_item_unexpand_s, + size: const Size.square(16.0), + ), + onTap: () => + context.read().add(ViewEvent.setIsExpanded(!isExpanded)), + ), + ); + + if (isHovered != null) { + return ValueListenableBuilder( + valueListenable: isHovered!, + builder: (_, isHovered, child) => + Opacity(opacity: isHovered ? 1.0 : 0.0, child: child), + child: child, + ); + } + + return child; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart new file mode 100644 index 0000000000000..ba9a946cc20a6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart @@ -0,0 +1,286 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/move_to/move_page_menu.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// ··· button beside the view name +class ViewMoreActionPopover extends StatelessWidget { + const ViewMoreActionPopover({ + super.key, + required this.view, + this.controller, + required this.onEditing, + required this.onAction, + required this.spaceType, + required this.isExpanded, + required this.buildChild, + this.showAtCursor = false, + }); + + final ViewPB view; + final PopoverController? controller; + final void Function(bool value) onEditing; + final void Function(ViewMoreActionType type, dynamic data) onAction; + final FolderSpaceType spaceType; + final bool isExpanded; + final Widget Function(PopoverController) buildChild; + final bool showAtCursor; + + @override + Widget build(BuildContext context) { + final wrappers = _buildActionTypeWrappers(); + return PopoverActionList( + controller: controller, + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 8), + actions: wrappers, + constraints: const BoxConstraints(minWidth: 260), + onPopupBuilder: () => onEditing(true), + buildChild: buildChild, + onSelected: (_, __) {}, + onClosed: () => onEditing(false), + showAtCursor: showAtCursor, + ); + } + + List _buildActionTypeWrappers() { + final actionTypes = _buildActionTypes(); + return actionTypes + .map( + (e) => ViewMoreActionTypeWrapper(e, view, (controller, data) { + onEditing(false); + onAction(e, data); + controller.close(); + }), + ) + .toList(); + } + + List _buildActionTypes() { + final List actionTypes = []; + + if (spaceType == FolderSpaceType.favorite) { + actionTypes.addAll([ + ViewMoreActionType.unFavorite, + ViewMoreActionType.divider, + ViewMoreActionType.rename, + ViewMoreActionType.openInNewTab, + ]); + } else { + actionTypes.add( + view.isFavorite + ? ViewMoreActionType.unFavorite + : ViewMoreActionType.favorite, + ); + + actionTypes.addAll([ + ViewMoreActionType.divider, + ViewMoreActionType.rename, + ]); + + // Chat doesn't change icon and duplicate + if (view.layout != ViewLayoutPB.Chat) { + actionTypes.addAll([ + ViewMoreActionType.changeIcon, + ViewMoreActionType.duplicate, + ]); + } + + actionTypes.addAll([ + ViewMoreActionType.moveTo, + ViewMoreActionType.delete, + ViewMoreActionType.divider, + ]); + + // Chat doesn't change collapse + // Only show collapse all pages if the view has child views + if (view.layout != ViewLayoutPB.Chat && + view.childViews.isNotEmpty && + isExpanded) { + actionTypes.add(ViewMoreActionType.collapseAllPages); + actionTypes.add(ViewMoreActionType.divider); + } + + actionTypes.add(ViewMoreActionType.openInNewTab); + } + + return actionTypes; + } +} + +class ViewMoreActionTypeWrapper extends CustomActionCell { + ViewMoreActionTypeWrapper( + this.inner, + this.sourceView, + this.onTap, { + this.moveActionDirection, + this.moveActionOffset, + }); + + final ViewMoreActionType inner; + final ViewPB sourceView; + final void Function(PopoverController controller, dynamic data) onTap; + + // custom the move to action button + final PopoverDirection? moveActionDirection; + final Offset? moveActionOffset; + + @override + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { + if (inner == ViewMoreActionType.divider) { + return _buildDivider(); + } else if (inner == ViewMoreActionType.lastModified) { + return _buildLastModified(context); + } else if (inner == ViewMoreActionType.created) { + return _buildCreated(context); + } else if (inner == ViewMoreActionType.changeIcon) { + return _buildEmojiActionButton(context, controller); + } else if (inner == ViewMoreActionType.moveTo) { + return _buildMoveToActionButton(context, controller); + } + + return _buildNormalActionButton(context, controller); + } + + Widget _buildNormalActionButton( + BuildContext context, + PopoverController controller, + ) { + return _buildActionButton(context, () => onTap(controller, null)); + } + + Widget _buildEmojiActionButton( + BuildContext context, + PopoverController controller, + ) { + final child = _buildActionButton(context, null); + + return AppFlowyPopover( + constraints: BoxConstraints.loose(const Size(364, 356)), + margin: const EdgeInsets.all(0), + clickHandler: PopoverClickHandler.gestureDetector, + popupBuilder: (_) => FlowyIconEmojiPicker( + onSelectedEmoji: (result) => onTap(controller, result), + ), + child: child, + ); + } + + Widget _buildMoveToActionButton( + BuildContext context, + PopoverController controller, + ) { + final userProfile = context.read().userProfile; + // move to feature doesn't support in local mode + if (userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) { + return const SizedBox.shrink(); + } + return BlocProvider.value( + value: context.read(), + child: BlocBuilder( + builder: (context, state) { + final child = _buildActionButton(context, null); + return AppFlowyPopover( + constraints: const BoxConstraints( + maxWidth: 260, + maxHeight: 345, + ), + margin: const EdgeInsets.symmetric( + horizontal: 14.0, + vertical: 12.0, + ), + clickHandler: PopoverClickHandler.gestureDetector, + direction: + moveActionDirection ?? PopoverDirection.rightWithTopAligned, + offset: moveActionOffset, + popupBuilder: (_) { + return BlocProvider.value( + value: context.read(), + child: MovePageMenu( + sourceView: sourceView, + onSelected: (space, view) { + onTap(controller, (space, view)); + }, + ), + ); + }, + child: child, + ); + }, + ), + ); + } + + Widget _buildDivider() { + return const Padding( + padding: EdgeInsets.all(8.0), + child: FlowyDivider(), + ); + } + + Widget _buildLastModified(BuildContext context) { + return Container( + height: 40, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + ), + ); + } + + Widget _buildCreated(BuildContext context) { + return Container( + height: 40, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + ), + ); + } + + Widget _buildActionButton( + BuildContext context, + VoidCallback? onTap, + ) { + return Container( + height: 34, + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: FlowyIconTextButton( + margin: const EdgeInsets.symmetric(horizontal: 6), + onTap: onTap, + // show the error color when delete is hovered + leftIconBuilder: (onHover) => FlowySvg( + inner.leftIconSvg, + color: inner == ViewMoreActionType.delete && onHover + ? Theme.of(context).colorScheme.error + : null, + ), + rightIconBuilder: (_) => inner.rightIcon, + iconPadding: 10.0, + textBuilder: (onHover) => FlowyText.regular( + inner.name, + fontSize: 14.0, + lineHeight: 1.0, + figmaLineHeight: 18.0, + color: inner == ViewMoreActionType.delete && onHover + ? Theme.of(context).colorScheme.error + : null, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart new file mode 100644 index 0000000000000..d588e512b0f19 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/navigation.dart @@ -0,0 +1,187 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class NavigationNotifier with ChangeNotifier { + NavigationNotifier({required this.navigationItems}); + + List navigationItems; + + void update(PageNotifier notifier) { + if (navigationItems != notifier.plugin.widgetBuilder.navigationItems) { + navigationItems = notifier.plugin.widgetBuilder.navigationItems; + notifyListeners(); + } + } +} + +class FlowyNavigation extends StatelessWidget { + const FlowyNavigation({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProxyProvider( + create: (_) { + final notifier = Provider.of(context, listen: false); + return NavigationNotifier( + navigationItems: notifier.plugin.widgetBuilder.navigationItems, + ); + }, + update: (_, notifier, controller) => controller!..update(notifier), + child: Expanded( + child: Row( + children: [ + _renderCollapse(context), + Selector>( + selector: (context, notifier) => notifier.navigationItems, + builder: (ctx, items, child) => Expanded( + child: Row( + children: _renderNavigationItems(items), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _renderCollapse(BuildContext context) { + return BlocBuilder( + buildWhen: (p, c) => p.isMenuCollapsed != c.isMenuCollapsed, + builder: (context, state) { + if (!UniversalPlatform.isWindows && state.isMenuCollapsed) { + final textSpan = TextSpan( + children: [ + TextSpan( + text: '${LocaleKeys.sideBar_openSidebar.tr()}\n', + style: context.tooltipTextStyle(), + ), + TextSpan( + text: Platform.isMacOS ? '⌘+.' : 'Ctrl+\\', + style: context + .tooltipTextStyle() + ?.copyWith(color: Theme.of(context).hintColor), + ), + ], + ); + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: RotationTransition( + turns: const AlwaysStoppedAnimation(180 / 360), + child: FlowyTooltip( + richMessage: textSpan, + child: Listener( + onPointerDown: (event) => context + .read() + .add(const HomeSettingEvent.collapseMenu()), + child: FlowyIconButton( + width: 24, + onPressed: () {}, + iconPadding: const EdgeInsets.all(4), + icon: const FlowySvg(FlowySvgs.hide_menu_s), + ), + ), + ), + ), + ); + } + + return const SizedBox.shrink(); + }, + ); + } + + List _renderNavigationItems(List items) { + if (items.isEmpty) { + return []; + } + + final List newItems = _filter(items); + final Widget last = NaviItemWidget(newItems.removeLast()); + + final List widgets = List.empty(growable: true); + // widgets.addAll(newItems.map((item) => NaviItemDivider(child: NaviItemWidget(item))).toList()); + + for (final item in newItems) { + widgets.add(NaviItemWidget(item)); + widgets.add(const Text('/')); + } + + widgets.add(last); + + return widgets; + } + + List _filter(List items) { + final length = items.length; + if (length > 4) { + final first = items[0]; + final ellipsisItems = items.getRange(1, length - 2).toList(); + final last = items.getRange(length - 2, length).toList(); + return [ + first, + EllipsisNaviItem(items: ellipsisItems), + ...last, + ]; + } else { + return items; + } + } +} + +class NaviItemWidget extends StatelessWidget { + const NaviItemWidget(this.item, {super.key}); + + final NavigationItem item; + + @override + Widget build(BuildContext context) { + return Expanded( + child: item.leftBarItem.padding(horizontal: 2, vertical: 2), + ); + } +} + +class EllipsisNaviItem extends NavigationItem { + EllipsisNaviItem({required this.items}); + + final List items; + + @override + String? get viewName => null; + + @override + Widget get leftBarItem => FlowyText.medium('...', fontSize: FontSizes.s16); + + @override + Widget tabBarItem(String pluginId, [bool shortForm = false]) => leftBarItem; + + @override + NavigationCallback get action => (id) {}; +} + +TextSpan sidebarTooltipTextSpan(BuildContext context, String hintText) => + TextSpan( + children: [ + TextSpan( + text: "$hintText\n", + ), + TextSpan( + text: Platform.isMacOS ? "⌘+." : "Ctrl+\\", + ), + ], + ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart new file mode 100644 index 0000000000000..7e4a5f8df12d4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/flowy_tab.dart @@ -0,0 +1,237 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/home_stack.dart'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +class FlowyTab extends StatefulWidget { + const FlowyTab({ + super.key, + required this.pageManager, + required this.isCurrent, + required this.onTap, + required this.isAllPinned, + }); + + final PageManager pageManager; + final bool isCurrent; + final VoidCallback onTap; + + /// Signifies whether all tabs are pinned + /// + final bool isAllPinned; + + @override + State createState() => _FlowyTabState(); +} + +class _FlowyTabState extends State { + final controller = PopoverController(); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.pageManager.isPinned ? 54 : null, + child: _wrapInTooltip( + widget.pageManager.plugin.widgetBuilder.viewName, + child: FlowyHover( + resetHoverOnRebuild: false, + style: HoverStyle( + borderRadius: BorderRadius.zero, + backgroundColor: widget.isCurrent + ? Theme.of(context).colorScheme.surface + : Theme.of(context).colorScheme.surfaceContainerHighest, + hoverColor: + widget.isCurrent ? Theme.of(context).colorScheme.surface : null, + ), + builder: (context, isHovering) => AppFlowyPopover( + controller: controller, + offset: const Offset(4, 4), + triggerActions: PopoverTriggerFlags.secondaryClick, + showAtCursor: true, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: TabMenu( + controller: controller, + pageId: widget.pageManager.plugin.id, + isPinned: widget.pageManager.isPinned, + isAllPinned: widget.isAllPinned, + ), + ), + child: ChangeNotifierProvider.value( + value: widget.pageManager.notifier, + child: Consumer( + builder: (context, value, _) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + // We use a Listener to avoid gesture detector onPanStart debounce + child: Listener( + onPointerDown: (event) { + if (event.buttons == kPrimaryButton) { + widget.onTap(); + } + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + // Stop move window detector + onPanStart: (_) {}, + child: Container( + constraints: BoxConstraints( + maxWidth: HomeSizes.tabBarWidth, + minWidth: widget.pageManager.isPinned ? 54 : 100, + ), + height: HomeSizes.tabBarHeight, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: widget.pageManager.notifier.tabBarWidget( + widget.pageManager.plugin.id, + widget.pageManager.isPinned, + ), + ), + if (!widget.pageManager.isPinned) ...[ + Visibility( + visible: isHovering, + child: SizedBox( + width: 26, + height: 26, + child: FlowyIconButton( + onPressed: () => _closeTab(context), + icon: const FlowySvg( + FlowySvgs.close_s, + size: Size.square(22), + ), + ), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + void _closeTab(BuildContext context) => context + .read() + .add(TabsEvent.closeTab(widget.pageManager.plugin.id)); + + Widget _wrapInTooltip(String? viewName, {required Widget child}) { + if (viewName != null) { + return FlowyTooltip( + message: viewName, + child: child, + ); + } + + return child; + } +} + +@visibleForTesting +class TabMenu extends StatelessWidget { + const TabMenu({ + super.key, + required this.controller, + required this.pageId, + required this.isPinned, + required this.isAllPinned, + }); + + final PopoverController controller; + final String pageId; + final bool isPinned; + final bool isAllPinned; + + @override + Widget build(BuildContext context) { + return SeparatedColumn( + separatorBuilder: () => const VSpace(4), + mainAxisSize: MainAxisSize.min, + children: [ + Opacity( + opacity: isPinned ? 0.5 : 1, + child: _wrapInTooltip( + shouldWrap: isPinned, + message: LocaleKeys.tabMenu_closeDisabledHint.tr(), + child: FlowyButton( + text: FlowyText.regular(LocaleKeys.tabMenu_close.tr()), + onTap: () => _closeTab(context), + disable: isPinned, + ), + ), + ), + Opacity( + opacity: isAllPinned ? 0.5 : 1, + child: _wrapInTooltip( + shouldWrap: true, + message: isAllPinned + ? LocaleKeys.tabMenu_closeOthersDisabledHint.tr() + : LocaleKeys.tabMenu_closeOthersHint.tr(), + child: FlowyButton( + text: FlowyText.regular( + LocaleKeys.tabMenu_closeOthers.tr(), + ), + onTap: () => _closeOtherTabs(context), + disable: isAllPinned, + ), + ), + ), + const Divider(height: 0.5), + FlowyButton( + text: FlowyText.regular( + isPinned + ? LocaleKeys.tabMenu_unpinTab.tr() + : LocaleKeys.tabMenu_pinTab.tr(), + ), + onTap: () => _togglePin(context), + ), + ], + ); + } + + Widget _wrapInTooltip({ + required bool shouldWrap, + String? message, + required Widget child, + }) { + if (shouldWrap) { + return FlowyTooltip( + message: message, + child: child, + ); + } + + return child; + } + + void _closeTab(BuildContext context) { + context.read().add(TabsEvent.closeTab(pageId)); + controller.close(); + } + + void _closeOtherTabs(BuildContext context) { + context.read().add(TabsEvent.closeOtherTabs(pageId)); + controller.close(); + } + + void _togglePin(BuildContext context) { + context.read().add(TabsEvent.togglePin(pageId)); + controller.close(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart new file mode 100644 index 0000000000000..38ede2421e441 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/tabs/tabs_manager.dart @@ -0,0 +1,62 @@ +import 'package:appflowy/core/frameless_window.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class TabsManager extends StatelessWidget { + const TabsManager({super.key, required this.onIndexChanged}); + + final void Function(int) onIndexChanged; + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (prev, curr) => + prev.currentIndex != curr.currentIndex || prev.pages != curr.pages, + listener: (context, state) => onIndexChanged(state.currentIndex), + builder: (context, state) { + if (state.pages == 1) { + return const SizedBox.shrink(); + } + + final isAllPinned = state.isAllPinned; + + return Container( + alignment: Alignment.bottomLeft, + height: HomeSizes.tabBarHeight, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + child: MoveWindowDetector( + child: Row( + children: state.pageManagers.map((pm) { + return Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: HomeSizes.tabBarWidth, + ), + child: FlowyTab( + key: ValueKey('tab-${pm.plugin.id}'), + pageManager: pm, + isCurrent: state.currentPageManager == pm, + isAllPinned: isAllPinned, + onTap: () { + if (state.currentPageManager != pm) { + final index = state.pageManagers.indexOf(pm); + onIndexChanged(index); + } + }, + ), + ), + ); + }).toList(), + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart new file mode 100644 index 0000000000000..22906ce724bee --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/toast.dart @@ -0,0 +1,78 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class FlowyMessageToast extends StatelessWidget { + const FlowyMessageToast({required this.message, super.key}); + + final String message; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4)), + color: Theme.of(context).colorScheme.surface, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: FlowyText.medium( + message, + fontSize: FontSizes.s16, + maxLines: 3, + ), + ), + ); + } +} + +void initToastWithContext(BuildContext context) { + getIt().init(context); +} + +void showMessageToast( + String message, { + BuildContext? context, + ToastGravity gravity = ToastGravity.BOTTOM, +}) { + final child = FlowyMessageToast(message: message); + final toast = context == null ? getIt() : (FToast()..init(context)); + toast.showToast( + child: child, + gravity: gravity, + toastDuration: const Duration(seconds: 3), + ); +} + +void showSnackBarMessage( + BuildContext context, + String message, { + bool showCancel = false, + Duration duration = const Duration(seconds: 4), +}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + duration: duration, + action: !showCancel + ? null + : SnackBarAction( + label: LocaleKeys.button_cancel.tr(), + textColor: Colors.white, + onPressed: () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + }, + ), + content: FlowyText( + message, + maxLines: 2, + fontSize: UniversalPlatform.isDesktop ? 14 : 12, + ), + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart new file mode 100644 index 0000000000000..7a14b6b1c521a --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/notification_dialog.dart @@ -0,0 +1,128 @@ +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/notification_filter/notification_filter_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/presentation/notifications/reminder_extension.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/inbox_action_bar.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_hub_title.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_view.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class NotificationDialog extends StatefulWidget { + const NotificationDialog({ + super.key, + required this.views, + required this.mutex, + }); + + final List views; + final PopoverMutex mutex; + + @override + State createState() => _NotificationDialogState(); +} + +class _NotificationDialogState extends State + with SingleTickerProviderStateMixin { + late final TabController controller = TabController(length: 2, vsync: this); + final PopoverMutex mutex = PopoverMutex(); + final ReminderBloc reminderBloc = getIt(); + + @override + void initState() { + super.initState(); + // Get all the past and upcoming reminders + reminderBloc.add(const ReminderEvent.started()); + controller.addListener(updateState); + } + + void updateState() => setState(() {}); + + @override + void dispose() { + mutex.dispose(); + controller.removeListener(updateState); + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: reminderBloc), + BlocProvider( + create: (_) => NotificationFilterBloc(), + ), + ], + child: BlocBuilder( + builder: (context, filterState) => + BlocBuilder( + builder: (context, state) { + List pastReminders = + state.pastReminders.sortByScheduledAt(); + if (filterState.showUnreadsOnly) { + pastReminders = pastReminders.where((r) => !r.isRead).toList(); + } + + final upcomingReminders = + state.upcomingReminders.sortByScheduledAt(); + final hasUnreads = pastReminders.any((r) => !r.isRead); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const NotificationHubTitle(), + NotificationTabBar(tabController: controller), + Expanded( + child: TabBarView( + controller: controller, + children: [ + NotificationsView( + shownReminders: pastReminders, + reminderBloc: reminderBloc, + views: widget.views, + onAction: onAction, + onReadChanged: _onReadChanged, + actionBar: InboxActionBar( + hasUnreads: hasUnreads, + showUnreadsOnly: filterState.showUnreadsOnly, + ), + ), + NotificationsView( + shownReminders: upcomingReminders, + reminderBloc: reminderBloc, + views: widget.views, + isUpcoming: true, + onAction: onAction, + ), + ], + ), + ), + ], + ); + }, + ), + ), + ); + } + + void onAction(ReminderPB reminder, int? path, ViewPB? view) { + reminderBloc.add( + ReminderEvent.pressReminder(reminderId: reminder.id, path: path), + ); + + widget.mutex.close(); + } + + void _onReadChanged(ReminderPB reminder, bool isRead) { + reminderBloc.add( + ReminderEvent.update(ReminderUpdate(id: reminder.id, isRead: isRead)), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/reminder_extension.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/reminder_extension.dart new file mode 100644 index 0000000000000..3abe16309037b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/reminder_extension.dart @@ -0,0 +1,7 @@ +import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; +import 'package:collection/collection.dart'; + +extension ReminderSort on Iterable { + List sortByScheduledAt() => + sorted((a, b) => b.scheduledAt.compareTo(a.scheduledAt)); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/flowy_tab.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/flowy_tab.dart new file mode 100644 index 0000000000000..87d839d71a0fc --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/flowy_tab.dart @@ -0,0 +1,37 @@ +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class FlowyTabItem extends StatelessWidget { + const FlowyTabItem({ + super.key, + required this.label, + required this.isSelected, + }); + + final String label; + final bool isSelected; + + static const double mobileHeight = 40; + static const EdgeInsets mobilePadding = EdgeInsets.symmetric(horizontal: 12); + + static const double desktopHeight = 26; + static const EdgeInsets desktopPadding = EdgeInsets.symmetric(horizontal: 8); + + @override + Widget build(BuildContext context) { + return Tab( + height: UniversalPlatform.isMobile ? mobileHeight : desktopHeight, + child: Padding( + padding: UniversalPlatform.isMobile ? mobilePadding : desktopPadding, + child: FlowyText.regular( + label, + color: isSelected + ? AFThemeExtension.of(context).textColor + : Theme.of(context).hintColor, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/inbox_action_bar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/inbox_action_bar.dart new file mode 100644 index 0000000000000..988ca40fca663 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/inbox_action_bar.dart @@ -0,0 +1,174 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/notification_filter/notification_filter_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class InboxActionBar extends StatelessWidget { + const InboxActionBar({ + super.key, + required this.hasUnreads, + required this.showUnreadsOnly, + }); + + final bool hasUnreads; + final bool showUnreadsOnly; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: AFThemeExtension.of(context).calloutBGColor, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _MarkAsReadButton( + onMarkAllRead: !hasUnreads + ? null + : () => context + .read() + .add(const ReminderEvent.markAllRead()), + ), + _ToggleUnreadsButton( + showUnreadsOnly: showUnreadsOnly, + onToggled: (_) => context + .read() + .add(const NotificationFilterEvent.toggleShowUnreadsOnly()), + ), + ], + ), + ), + ); + } +} + +class _ToggleUnreadsButton extends StatefulWidget { + const _ToggleUnreadsButton({ + required this.onToggled, + this.showUnreadsOnly = false, + }); + + final Function(bool) onToggled; + final bool showUnreadsOnly; + + @override + State<_ToggleUnreadsButton> createState() => _ToggleUnreadsButtonState(); +} + +class _ToggleUnreadsButtonState extends State<_ToggleUnreadsButton> { + late bool showUnreadsOnly = widget.showUnreadsOnly; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + onSelectionChanged: (Set newSelection) { + setState(() => showUnreadsOnly = newSelection.first); + widget.onToggled(showUnreadsOnly); + }, + showSelectedIcon: false, + style: ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: WidgetStatePropertyAll( + BorderSide(color: Theme.of(context).dividerColor), + ), + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: Corners.s6Border, + ), + ), + foregroundColor: WidgetStateProperty.resolveWith( + (state) { + if (state.contains(WidgetState.selected)) { + return Theme.of(context).colorScheme.onPrimary; + } + + return AFThemeExtension.of(context).textColor; + }, + ), + backgroundColor: WidgetStateProperty.resolveWith( + (state) { + if (state.contains(WidgetState.selected)) { + return Theme.of(context).colorScheme.primary; + } + + if (state.contains(WidgetState.hovered)) { + return AFThemeExtension.of(context).lightGreyHover; + } + + return Theme.of(context).cardColor; + }, + ), + ), + segments: [ + ButtonSegment( + value: false, + label: Text( + LocaleKeys.notificationHub_actions_showAll.tr(), + style: const TextStyle(fontSize: 12), + ), + ), + ButtonSegment( + value: true, + label: Text( + LocaleKeys.notificationHub_actions_showUnreads.tr(), + style: const TextStyle(fontSize: 12), + ), + ), + ], + selected: {showUnreadsOnly}, + ); + } +} + +class _MarkAsReadButton extends StatefulWidget { + const _MarkAsReadButton({this.onMarkAllRead}); + + final VoidCallback? onMarkAllRead; + + @override + State<_MarkAsReadButton> createState() => _MarkAsReadButtonState(); +} + +class _MarkAsReadButtonState extends State<_MarkAsReadButton> { + bool _isHovering = false; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: widget.onMarkAllRead != null ? 1 : 0.5, + child: FlowyHover( + onHover: (isHovering) => setState(() => _isHovering = isHovering), + resetHoverOnRebuild: false, + child: FlowyTextButton( + LocaleKeys.notificationHub_actions_markAllRead.tr(), + fontColor: widget.onMarkAllRead != null && _isHovering + ? Theme.of(context).colorScheme.onSurface + : AFThemeExtension.of(context).textColor, + heading: FlowySvg( + FlowySvgs.checklist_s, + color: widget.onMarkAllRead != null && _isHovering + ? Theme.of(context).colorScheme.onSurface + : AFThemeExtension.of(context).textColor, + ), + hoverColor: widget.onMarkAllRead != null && _isHovering + ? Theme.of(context).colorScheme.primary + : null, + onPressed: widget.onMarkAllRead, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart new file mode 100644 index 0000000000000..6f29c8e2aaacf --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_button.dart @@ -0,0 +1,107 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/red_dot.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart'; +import 'package:appflowy/workspace/presentation/notifications/notification_dialog.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class NotificationButton extends StatefulWidget { + const NotificationButton({ + super.key, + this.isHover = false, + }); + + final bool isHover; + + @override + State createState() => _NotificationButtonState(); +} + +class _NotificationButtonState extends State { + final mutex = PopoverMutex(); + + @override + void initState() { + super.initState(); + getIt().add(const ReminderEvent.started()); + } + + @override + void dispose() { + mutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final views = context.watch().state.section.views; + + return BlocProvider.value( + value: getIt(), + child: BlocBuilder( + builder: (notificationSettingsContext, notificationSettingsState) { + return BlocBuilder( + builder: (context, state) { + final hasUnreads = state.pastReminders.any((r) => !r.isRead); + return notificationSettingsState.isShowNotificationsIconEnabled + ? FlowyTooltip( + message: LocaleKeys.notificationHub_title.tr(), + child: AppFlowyPopover( + mutex: mutex, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: + const BoxConstraints(maxHeight: 500, maxWidth: 425), + windowPadding: EdgeInsets.zero, + margin: EdgeInsets.zero, + popupBuilder: (_) => + NotificationDialog(views: views, mutex: mutex), + child: SizedBox.square( + dimension: 24.0, + child: FlowyButton( + useIntrinsicWidth: true, + margin: EdgeInsets.zero, + text: _buildNotificationIcon( + context, + hasUnreads, + ), + ), + ), + ), + ) + : const SizedBox.shrink(); + }, + ); + }, + ), + ); + } + + Widget _buildNotificationIcon(BuildContext context, bool hasUnreads) { + return Stack( + children: [ + Center( + child: FlowySvg( + FlowySvgs.notification_s, + color: + widget.isHover ? Theme.of(context).colorScheme.onSurface : null, + opacity: 0.7, + ), + ), + if (hasUnreads) + const Positioned( + top: 4, + right: 6, + child: NotificationRedDot( + size: 5, + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_hub_title.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_hub_title.dart new file mode 100644 index 0000000000000..0434cc56d0c6b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_hub_title.dart @@ -0,0 +1,23 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class NotificationHubTitle extends StatelessWidget { + const NotificationHubTitle({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16) + + const EdgeInsets.only(top: 12, bottom: 4), + child: FlowyText.semibold( + LocaleKeys.notificationHub_title.tr(), + color: Theme.of(context).colorScheme.tertiary, + fontSize: 16, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart new file mode 100644 index 0000000000000..3d12a6afb74b6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_item.dart @@ -0,0 +1,296 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class NotificationItem extends StatefulWidget { + const NotificationItem({ + super.key, + required this.reminder, + required this.title, + required this.scheduled, + required this.body, + required this.isRead, + this.block, + this.includeTime = false, + this.readOnly = false, + this.onAction, + this.onReadChanged, + this.view, + }); + + final ReminderPB reminder; + final String title; + final Int64 scheduled; + final String body; + final bool isRead; + final ViewPB? view; + + /// If [block] is provided, then [body] will be shown only if + /// [block] fails to fetch. + /// + /// [block] is rendered as a result of a [FutureBuilder]. + /// + final Future? block; + + final bool includeTime; + final bool readOnly; + + final void Function(int? path)? onAction; + final void Function(bool isRead)? onReadChanged; + + @override + State createState() => _NotificationItemState(); +} + +class _NotificationItemState extends State { + final PopoverMutex mutex = PopoverMutex(); + bool _isHovering = false; + int? path; + + late final String infoString; + + @override + void initState() { + super.initState(); + widget.block?.then((b) => path = b?.path.first); + infoString = _buildInfoString(); + } + + @override + void dispose() { + mutex.dispose(); + super.dispose(); + } + + String _buildInfoString() { + String scheduledString = + _scheduledString(widget.scheduled, widget.includeTime); + + if (widget.view != null) { + scheduledString = '$scheduledString - ${widget.view!.name}'; + } + + return scheduledString; + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => _onHover(true), + onExit: (_) => _onHover(false), + cursor: widget.onAction != null + ? SystemMouseCursors.click + : MouseCursor.defer, + child: Stack( + children: [ + GestureDetector( + onTap: () => widget.onAction?.call(path), + child: AbsorbPointer( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: UniversalPlatform.isMobile + ? BorderSide( + color: AFThemeExtension.of(context).calloutBGColor, + ) + : BorderSide.none, + ), + ), + child: Opacity( + opacity: widget.isRead && !widget.readOnly ? 0.5 : 1, + child: DecoratedBox( + decoration: BoxDecoration( + color: _isHovering && widget.onAction != null + ? AFThemeExtension.of(context).lightGreyHover + : Colors.transparent, + border: widget.isRead || widget.readOnly + ? null + : Border( + left: BorderSide( + width: UniversalPlatform.isMobile ? 4 : 2, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 16, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowySvg( + FlowySvgs.time_s, + size: Size.square( + UniversalPlatform.isMobile ? 24 : 20, + ), + color: AFThemeExtension.of(context).textColor, + ), + const HSpace(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.semibold( + widget.title, + fontSize: + UniversalPlatform.isMobile ? 16 : 14, + color: AFThemeExtension.of(context).textColor, + ), + FlowyText.regular( + infoString, + fontSize: + UniversalPlatform.isMobile ? 12 : 10, + ), + const VSpace(5), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + color: + Theme.of(context).colorScheme.surface, + ), + child: _NotificationContent( + block: widget.block, + reminder: widget.reminder, + body: widget.body, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + if (UniversalPlatform.isMobile && !widget.readOnly || + _isHovering && !widget.readOnly) + Positioned( + right: UniversalPlatform.isMobile ? 8 : 4, + top: UniversalPlatform.isMobile ? 8 : 4, + child: NotificationItemActions( + isRead: widget.isRead, + onReadChanged: widget.onReadChanged, + ), + ), + ], + ), + ); + } + + String _scheduledString(Int64 secondsSinceEpoch, bool includeTime) { + final appearance = context.read().state; + return appearance.dateFormat.formatDate( + DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch.toInt() * 1000), + includeTime, + appearance.timeFormat, + ); + } + + void _onHover(bool isHovering) => setState(() => _isHovering = isHovering); +} + +class _NotificationContent extends StatelessWidget { + const _NotificationContent({ + required this.body, + required this.reminder, + required this.block, + }); + + final String body; + final ReminderPB reminder; + final Future? block; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: block, + builder: (context, snapshot) { + if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) { + return FlowyText.regular(body, maxLines: 4); + } + + return IntrinsicHeight( + child: NotificationDocumentContent( + nodes: [snapshot.data!], + reminder: reminder, + ), + ); + }, + ); + } +} + +class NotificationItemActions extends StatelessWidget { + const NotificationItemActions({ + super.key, + required this.isRead, + this.onReadChanged, + }); + + final bool isRead; + final void Function(bool isRead)? onReadChanged; + + @override + Widget build(BuildContext context) { + final double size = UniversalPlatform.isMobile ? 40.0 : 30.0; + + return Container( + height: size, + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + border: Border.all( + color: AFThemeExtension.of(context).lightGreyHover, + ), + borderRadius: BorderRadius.circular(6), + ), + child: IntrinsicHeight( + child: Row( + children: [ + if (isRead) ...[ + FlowyIconButton( + height: size, + width: size, + radius: BorderRadius.circular(4), + tooltipText: + LocaleKeys.reminderNotification_tooltipMarkUnread.tr(), + icon: const FlowySvg(FlowySvgs.restore_s), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + onPressed: () => onReadChanged?.call(false), + ), + ] else ...[ + FlowyIconButton( + height: size, + width: size, + radius: BorderRadius.circular(4), + tooltipText: + LocaleKeys.reminderNotification_tooltipMarkRead.tr(), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + icon: const FlowySvg(FlowySvgs.messages_s), + onPressed: () => onReadChanged?.call(true), + ), + ], + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_tab_bar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_tab_bar.dart new file mode 100644 index 0000000000000..099203a90ace8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_tab_bar.dart @@ -0,0 +1,51 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/flowy_tab.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class NotificationTabBar extends StatelessWidget { + const NotificationTabBar({super.key, required this.tabController}); + + final TabController tabController; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: TabBar( + controller: tabController, + padding: const EdgeInsets.symmetric(horizontal: 8), + labelPadding: EdgeInsets.zero, + indicatorSize: TabBarIndicatorSize.label, + indicator: UnderlineTabIndicator( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + isScrollable: true, + tabs: [ + FlowyTabItem( + label: LocaleKeys.notificationHub_tabs_inbox.tr(), + isSelected: tabController.index == 0, + ), + FlowyTabItem( + label: LocaleKeys.notificationHub_tabs_upcoming.tr(), + isSelected: tabController.index == 1, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart new file mode 100644 index 0000000000000..0465256f60734 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notification_view.dart @@ -0,0 +1,141 @@ +import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart'; +import 'package:appflowy/plugins/document/application/prelude.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notification_item.dart'; +import 'package:appflowy/workspace/presentation/notifications/widgets/notifications_hub_empty.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/reminder.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/material.dart'; + +/// Displays a Lsit of Notifications, currently used primarily to +/// display Reminders. +/// +/// Optimized for both Mobile & Desktop use +/// +class NotificationsView extends StatelessWidget { + const NotificationsView({ + super.key, + required this.shownReminders, + required this.reminderBloc, + required this.views, + this.isUpcoming = false, + this.onAction, + this.onReadChanged, + this.actionBar, + }); + + final List shownReminders; + final ReminderBloc reminderBloc; + final List views; + final bool isUpcoming; + final Function(ReminderPB reminder, int? path, ViewPB? view)? onAction; + final Function(ReminderPB reminder, bool isRead)? onReadChanged; + final Widget? actionBar; + + @override + Widget build(BuildContext context) { + if (shownReminders.isEmpty) { + return Column( + children: [ + if (actionBar != null) actionBar!, + const Expanded(child: NotificationsHubEmpty()), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (actionBar != null) actionBar!, + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + ...shownReminders.map( + (ReminderPB reminder) { + final blockId = reminder.meta[ReminderMetaKeys.blockId]; + + final documentService = DocumentService(); + final documentFuture = documentService.openDocument( + documentId: reminder.objectId, + ); + + Future? nodeBuilder; + if (blockId != null) { + nodeBuilder = + _getNodeFromDocument(documentFuture, blockId); + } + + final view = views.findView(reminder.objectId); + return NotificationItem( + reminder: reminder, + key: ValueKey(reminder.id), + title: reminder.title, + scheduled: reminder.scheduledAt, + body: reminder.message, + block: nodeBuilder, + isRead: reminder.isRead, + includeTime: reminder.includeTime ?? false, + readOnly: isUpcoming, + onReadChanged: (isRead) => + onReadChanged?.call(reminder, isRead), + onAction: (path) => onAction?.call(reminder, path, view), + view: view, + ); + }, + ), + ], + ), + ), + ), + ], + ); + } + + Future _getNodeFromDocument( + Future> documentFuture, + String blockId, + ) async { + final document = (await documentFuture).fold( + (document) => document, + (_) => null, + ); + + if (document == null) { + return null; + } + + final rootNode = document.toDocument()?.root; + if (rootNode == null) { + return null; + } + + return _searchById(rootNode, blockId); + } +} + +/// Recursively iterates a [Node] and compares by its [id] +/// +Node? _searchById(Node current, String id) { + if (current.id == id) { + return current; + } + + if (current.children.isNotEmpty) { + for (final child in current.children) { + final node = _searchById(child, id); + + if (node != null) { + return node; + } + } + } + + return null; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notifications_hub_empty.dart b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notifications_hub_empty.dart new file mode 100644 index 0000000000000..03eeec921f968 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/notifications/widgets/notifications_hub_empty.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; + +class NotificationsHubEmpty extends StatelessWidget { + const NotificationsHubEmpty({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText( + LocaleKeys.notificationHub_emptyTitle.tr(), + fontWeight: FontWeight.w700, + fontSize: 14, + ), + const VSpace(8), + FlowyText.regular( + LocaleKeys.notificationHub_emptyBody.tr(), + textAlign: TextAlign.center, + maxLines: 2, + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart new file mode 100644 index 0000000000000..7b337d8d789b7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart @@ -0,0 +1,3 @@ +export 'account_deletion.dart'; +export 'account_sign_in_out.dart'; +export 'account_user_profile.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart new file mode 100644 index 0000000000000..368cd1913843e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart @@ -0,0 +1,256 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/util/navigator_context_extension.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +const _confirmText = 'DELETE MY ACCOUNT'; +const _acceptableConfirmTexts = [ + 'delete my account', + 'deletemyaccount', + 'DELETE MY ACCOUNT', + 'DELETEMYACCOUNT', +]; + +class AccountDeletionButton extends StatefulWidget { + const AccountDeletionButton({ + super.key, + }); + + @override + State createState() => _AccountDeletionButtonState(); +} + +class _AccountDeletionButtonState extends State { + final textEditingController = TextEditingController(); + final isCheckedNotifier = ValueNotifier(false); + + @override + void dispose() { + textEditingController.dispose(); + isCheckedNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textColor = Theme.of(context).brightness == Brightness.light + ? const Color(0xFF4F4F4F) + : const Color(0xFFB0B0B0); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + LocaleKeys.button_deleteAccount.tr(), + fontSize: 14.0, + fontWeight: FontWeight.w500, + figmaLineHeight: 21.0, + color: textColor, + ), + const VSpace(8), + Row( + children: [ + Flexible( + child: FlowyText.regular( + LocaleKeys.newSettings_myAccount_deleteAccount_description.tr(), + fontSize: 12.0, + figmaLineHeight: 13.0, + maxLines: 2, + color: textColor, + ), + ), + const HSpace(32), + FlowyTextButton( + LocaleKeys.button_deleteAccount.tr(), + constraints: const BoxConstraints(minHeight: 32), + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 10), + fillColor: Colors.transparent, + radius: Corners.s8Border, + hoverColor: Theme.of(context).colorScheme.error.withOpacity(0.1), + fontColor: Theme.of(context).colorScheme.error, + fontSize: 12, + isDangerous: true, + onPressed: () { + isCheckedNotifier.value = false; + textEditingController.clear(); + + showCancelAndDeleteDialog( + context: context, + title: + LocaleKeys.newSettings_myAccount_deleteAccount_title.tr(), + description: '', + builder: (_) => _AccountDeletionDialog( + controller: textEditingController, + isChecked: isCheckedNotifier, + ), + onDelete: () => deleteMyAccount( + context, + textEditingController.text.trim(), + isCheckedNotifier.value, + onSuccess: () { + context.popToHome(); + }, + ), + ); + }, + ), + ], + ), + ], + ); + } +} + +class _AccountDeletionDialog extends StatelessWidget { + const _AccountDeletionDialog({ + required this.controller, + required this.isChecked, + }); + + final TextEditingController controller; + final ValueNotifier isChecked; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.regular( + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint1.tr(), + fontSize: 14.0, + figmaLineHeight: 18.0, + maxLines: 2, + color: ConfirmPopupColor.descriptionColor(context), + ), + const VSpace(12.0), + FlowyTextField( + hintText: _confirmText, + controller: controller, + ), + const VSpace(16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => isChecked.value = !isChecked.value, + child: ValueListenableBuilder( + valueListenable: isChecked, + builder: (context, isChecked, _) { + return FlowySvg( + isChecked ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + size: const Size.square(16.0), + blendMode: isChecked ? null : BlendMode.srcIn, + ); + }, + ), + ), + const HSpace(6.0), + Expanded( + child: FlowyText.regular( + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint2 + .tr(), + fontSize: 14.0, + figmaLineHeight: 16.0, + maxLines: 3, + color: ConfirmPopupColor.descriptionColor(context), + ), + ), + ], + ), + ], + ); + } +} + +bool _isConfirmTextValid(String text) { + // don't convert the text to lower case or upper case, + // just check if the text is in the list + return _acceptableConfirmTexts.contains(text); +} + +Future deleteMyAccount( + BuildContext context, + String confirmText, + bool isChecked, { + VoidCallback? onSuccess, + VoidCallback? onFailure, +}) async { + final bottomPadding = UniversalPlatform.isMobile + ? MediaQuery.of(context).viewInsets.bottom + : 0.0; + + if (!isChecked) { + showToastNotification( + context, + type: ToastificationType.warning, + bottomPadding: bottomPadding, + message: LocaleKeys + .newSettings_myAccount_deleteAccount_checkToConfirmError + .tr(), + ); + return; + } + if (!context.mounted) { + return; + } + + if (confirmText.isEmpty || !_isConfirmTextValid(confirmText)) { + showToastNotification( + context, + type: ToastificationType.warning, + bottomPadding: bottomPadding, + message: LocaleKeys + .newSettings_myAccount_deleteAccount_confirmTextValidationFailed + .tr(), + ); + return; + } + + final loading = Loading(context)..start(); + + await UserBackendService.deleteCurrentAccount().fold( + (s) { + Log.info('account deletion success'); + + loading.stop(); + showToastNotification( + context, + message: LocaleKeys + .newSettings_myAccount_deleteAccount_deleteAccountSuccess + .tr(), + ); + + // delay 1 second to make sure the toast notification is shown + Future.delayed(const Duration(seconds: 1), () async { + onSuccess?.call(); + + // restart the application + await runAppFlowy(); + }); + }, + (f) { + Log.error('account deletion failed, error: $f'); + + loading.stop(); + showToastNotification( + context, + type: ToastificationType.error, + bottomPadding: bottomPadding, + message: f.msg, + ); + + onFailure?.call(); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart new file mode 100644 index 0000000000000..8d15c80181340 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart @@ -0,0 +1,188 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/prelude.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart'; +import 'package:appflowy/util/navigator_context_extension.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_third_party_login.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class AccountSignInOutButton extends StatelessWidget { + const AccountSignInOutButton({ + super.key, + required this.userProfile, + required this.onAction, + this.signIn = true, + }); + + final UserProfilePB userProfile; + final VoidCallback onAction; + final bool signIn; + + @override + Widget build(BuildContext context) { + return PrimaryRoundedButton( + text: signIn + ? LocaleKeys.settings_accountPage_login_loginLabel.tr() + : LocaleKeys.settings_accountPage_login_logoutLabel.tr(), + margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + fontWeight: FontWeight.w600, + radius: 12.0, + onTap: () => + signIn ? _showSignInDialog(context) : _showLogoutDialog(context), + ); + } + + void _showLogoutDialog(BuildContext context) { + showConfirmDialog( + context: context, + title: LocaleKeys.settings_accountPage_login_logoutLabel.tr(), + description: userProfile.encryptionType == EncryptionTypePB.Symmetric + ? LocaleKeys.settings_menu_selfEncryptionLogoutPrompt.tr() + : LocaleKeys.settings_menu_logoutPrompt.tr(), + onConfirm: () async { + await getIt().signOut(); + onAction(); + }, + ); + } + + Future _showSignInDialog(BuildContext context) async { + await showDialog( + context: context, + builder: (context) => BlocProvider( + create: (context) => getIt(), + child: const FlowyDialog( + constraints: BoxConstraints(maxHeight: 485, maxWidth: 375), + child: _SignInDialogContent(), + ), + ), + ); + } +} + +class _SignInDialogContent extends StatelessWidget { + const _SignInDialogContent(); + + @override + Widget build(BuildContext context) { + return ScaffoldMessenger( + child: Scaffold( + body: Padding( + padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Column( + children: [ + const _DialogHeader(), + const _DialogTitle(), + const VSpace(16), + const SignInWithMagicLinkButtons(), + if (isAuthEnabled) ...[ + const VSpace(20), + const _OrDivider(), + const VSpace(10), + SettingThirdPartyLogin( + didLogin: () { + context.popToHome(); + }, + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +class _DialogHeader extends StatelessWidget { + const _DialogHeader(); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildBackButton(context), + _buildCloseButton(context), + ], + ); + } + + Widget _buildBackButton(BuildContext context) { + return GestureDetector( + onTap: Navigator.of(context).pop, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + children: [ + const FlowySvg(FlowySvgs.arrow_back_m, size: Size.square(24)), + const HSpace(8), + FlowyText.semibold(LocaleKeys.button_back.tr(), fontSize: 16), + ], + ), + ), + ); + } + + Widget _buildCloseButton(BuildContext context) { + return GestureDetector( + onTap: Navigator.of(context).pop, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowySvg( + FlowySvgs.m_close_m, + size: const Size.square(20), + color: Theme.of(context).colorScheme.outline, + ), + ), + ); + } +} + +class _DialogTitle extends StatelessWidget { + const _DialogTitle(); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: FlowyText.medium( + LocaleKeys.settings_accountPage_login_loginLabel.tr(), + fontSize: 22, + color: Theme.of(context).colorScheme.tertiary, + maxLines: null, + ), + ), + ], + ); + } +} + +class _OrDivider extends StatelessWidget { + const _OrDivider(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Flexible(child: Divider(thickness: 1)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: FlowyText.regular(LocaleKeys.signIn_or.tr()), + ), + const Flexible(child: Divider(thickness: 1)), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart new file mode 100644 index 0000000000000..bd08501ae46d1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart @@ -0,0 +1,182 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_input_field.dart'; +import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../shared/icon_emoji_picker/tab.dart'; + +// Account name and account avatar +class AccountUserProfile extends StatefulWidget { + const AccountUserProfile({ + super.key, + required this.name, + required this.iconUrl, + this.onSave, + }); + + final String name; + final String iconUrl; + final void Function(String)? onSave; + + @override + State createState() => _AccountUserProfileState(); +} + +class _AccountUserProfileState extends State { + late final TextEditingController nameController = + TextEditingController(text: widget.name); + final FocusNode focusNode = FocusNode(); + bool isEditing = false; + bool isHovering = false; + + @override + void initState() { + super.initState(); + + focusNode + ..addListener(_handleFocusChange) + ..onKeyEvent = _handleKeyEvent; + } + + @override + void dispose() { + nameController.dispose(); + focusNode.removeListener(_handleFocusChange); + focusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildAvatar(), + const HSpace(16), + Flexible( + child: isEditing ? _buildEditingField() : _buildNameDisplay(), + ), + ], + ); + } + + Widget _buildAvatar() { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _showIconPickerDialog(context), + child: FlowyHover( + resetHoverOnRebuild: false, + onHover: (state) => setState(() => isHovering = state), + style: HoverStyle( + hoverColor: Colors.transparent, + borderRadius: BorderRadius.circular(100), + ), + child: FlowyTooltip( + message: + LocaleKeys.settings_accountPage_general_changeProfilePicture.tr(), + child: UserAvatar( + iconUrl: widget.iconUrl, + name: widget.name, + size: 48, + fontSize: 20, + isHovering: isHovering, + ), + ), + ), + ); + } + + Widget _buildNameDisplay() { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: FlowyText.medium( + widget.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => isEditing = true), + child: const FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: EdgeInsets.all(4), + child: FlowySvg(FlowySvgs.edit_s), + ), + ), + ), + ], + ), + ); + } + + Widget _buildEditingField() { + return SettingsInputField( + textController: nameController, + value: widget.name, + focusNode: focusNode..requestFocus(), + onCancel: () => setState(() => isEditing = false), + onSave: (_) => _saveChanges(), + ); + } + + Future _showIconPickerDialog(BuildContext context) { + return showDialog( + context: context, + builder: (dialogContext) => SimpleDialog( + children: [ + Container( + height: 380, + width: 360, + margin: const EdgeInsets.all(0), + child: FlowyIconEmojiPicker( + tabs: const [PickerTabType.emoji], + onSelectedEmoji: (r) { + context + .read() + .add(SettingsUserEvent.updateUserIcon(iconUrl: r.emoji)); + Navigator.of(dialogContext).pop(); + }, + ), + ), + ], + ), + ); + } + + void _handleFocusChange() { + if (!focusNode.hasFocus && isEditing && mounted) { + _saveChanges(); + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape && + isEditing && + mounted) { + setState(() => isEditing = false); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _saveChanges() { + widget.onSave?.call(nameController.text); + setState(() => isEditing = false); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/fix_data_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/fix_data_widget.dart new file mode 100644 index 0000000000000..2cecb25b3049f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/fix_data_widget.dart @@ -0,0 +1,216 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class FixDataWidget extends StatelessWidget { + const FixDataWidget({super.key}); + + @override + Widget build(BuildContext context) { + return SettingsCategory( + title: LocaleKeys.settings_manageDataPage_data_fixYourData.tr(), + children: [ + SingleSettingAction( + labelMaxLines: 4, + label: LocaleKeys.settings_manageDataPage_data_fixYourDataDescription + .tr(), + buttonLabel: LocaleKeys.settings_manageDataPage_data_fixButton.tr(), + onPressed: () { + WorkspaceDataManager.checkWorkspaceHealth(dryRun: true); + }, + ), + ], + ); + } +} + +class WorkspaceDataManager { + static Future checkWorkspaceHealth({ + required bool dryRun, + }) async { + try { + final currentWorkspace = + await UserBackendService.getCurrentWorkspace().getOrThrow(); + // get all the views in the workspace + final result = await ViewBackendService.getAllViews().getOrThrow(); + final allViews = result.items; + + // dump all the views in the workspace + dumpViews('all views', allViews); + + // get the workspace + final workspaces = allViews.where( + (e) => e.parentViewId == '' && e.id == currentWorkspace.id, + ); + dumpViews('workspaces', workspaces.toList()); + + if (workspaces.length != 1) { + Log.error('Failed to fix workspace: workspace not found'); + // there should be only one workspace + return; + } + + final workspace = workspaces.first; + + // check the health of the spaces + await checkSpaceHealth(workspace: workspace, allViews: allViews); + + // check the health of the views + await checkViewHealth(workspace: workspace, allViews: allViews); + + // add other checks here + // ... + } catch (e) { + Log.error('Failed to fix space relation: $e'); + } + } + + static Future checkSpaceHealth({ + required ViewPB workspace, + required List allViews, + bool dryRun = true, + }) async { + try { + final workspaceChildViews = + await ViewBackendService.getChildViews(viewId: workspace.id) + .getOrThrow(); + final workspaceChildViewIds = + workspaceChildViews.map((e) => e.id).toSet(); + final spaces = allViews.where((e) => e.isSpace).toList(); + + for (final space in spaces) { + // the space is the top level view, so its parent view id should be the workspace id + // and the workspace should have the space in its child views + if (space.parentViewId != workspace.id || + !workspaceChildViewIds.contains(space.id)) { + Log.info('found an issue: space is not in the workspace: $space'); + if (!dryRun) { + // move the space to the workspace if it is not in the workspace + await ViewBackendService.moveViewV2( + viewId: space.id, + newParentId: workspace.id, + prevViewId: null, + ); + } + workspaceChildViewIds.add(space.id); + } + } + } catch (e) { + Log.error('Failed to check space health: $e'); + } + } + + static Future> checkViewHealth({ + ViewPB? workspace, + List? allViews, + bool dryRun = true, + }) async { + // Views whose parent view does not have the view in its child views + final List unlistedChildViews = []; + // Views whose parent is not in allViews + final List orphanViews = []; + // Row pages + final List rowPageViews = []; + + try { + if (workspace == null || allViews == null) { + final currentWorkspace = + await UserBackendService.getCurrentWorkspace().getOrThrow(); + // get all the views in the workspace + final result = await ViewBackendService.getAllViews().getOrThrow(); + allViews = result.items; + workspace = allViews.firstWhereOrNull( + (e) => e.id == currentWorkspace.id, + ); + } + + for (final view in allViews) { + if (view.parentViewId == '') { + continue; + } + + final parentView = allViews.firstWhereOrNull( + (e) => e.id == view.parentViewId, + ); + + if (parentView == null) { + orphanViews.add(view); + continue; + } + + if (parentView.id == view.id) { + rowPageViews.add(view); + continue; + } + + final childViewsOfParent = + await ViewBackendService.getChildViews(viewId: parentView.id) + .getOrThrow(); + final result = childViewsOfParent.any((e) => e.id == view.id); + if (!result) { + unlistedChildViews.add(view); + } + } + } catch (e) { + Log.error('Failed to check space health: $e'); + return []; + } + + for (final view in unlistedChildViews) { + Log.info( + '[workspace] found an issue: view is not in the parent view\'s child views, view: ${view.toProto3Json()}}', + ); + } + + for (final view in orphanViews) { + Log.info('[workspace] orphanViews: ${view.toProto3Json()}'); + } + + for (final view in rowPageViews) { + Log.info('[workspace] rowPageViews: ${view.toProto3Json()}'); + } + + if (!dryRun && unlistedChildViews.isNotEmpty) { + Log.info( + '[workspace] start to fix ${unlistedChildViews.length} unlistedChildViews ...', + ); + for (final view in unlistedChildViews) { + // move the view to the parent view if it is not in the parent view's child views + Log.info( + '[workspace] move view: $view to its parent view ${view.parentViewId}', + ); + await ViewBackendService.moveViewV2( + viewId: view.id, + newParentId: view.parentViewId, + prevViewId: null, + ); + } + + Log.info('[workspace] end to fix unlistedChildViews'); + } + + if (unlistedChildViews.isEmpty && orphanViews.isEmpty) { + Log.info('[workspace] all views are healthy'); + } + + Log.info('[workspace] done checking view health'); + + return unlistedChildViews; + } + + static void dumpViews(String prefix, List views) { + for (int i = 0; i < views.length; i++) { + final view = views[i]; + Log.info('$prefix $i: $view)'); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart new file mode 100644 index 0000000000000..a7ed782aea371 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart @@ -0,0 +1,119 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/ai/download_model_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:percent_indicator/linear_percent_indicator.dart'; + +class DownloadingIndicator extends StatelessWidget { + const DownloadingIndicator({ + required this.llmModel, + required this.onCancel, + required this.onFinish, + super.key, + }); + final LLMModelPB llmModel; + final VoidCallback onCancel; + final VoidCallback onFinish; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + DownloadModelBloc(llmModel)..add(const DownloadModelEvent.started()), + child: BlocListener( + listener: (context, state) { + if (state.isFinish) { + onFinish(); + } + }, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: BlocBuilder( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DownloadingProgressBar(onCancel: onCancel), + if (state.bigFileDownloadPrompt != null) ...[ + const VSpace(2), + Opacity( + opacity: 0.6, + child: + FlowyText(state.bigFileDownloadPrompt!, fontSize: 11), + ), + ], + ], + ); + }, + ), + ), + ), + ); + } +} + +class DownloadingProgressBar extends StatelessWidget { + const DownloadingProgressBar({required this.onCancel, super.key}); + + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Opacity( + opacity: 0.6, + child: FlowyText( + "${LocaleKeys.settings_aiPage_keys_downloadingModel.tr()}: ${state.object}", + fontSize: 11, + ), + ), + IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: LinearPercentIndicator( + lineHeight: 9.0, + percent: state.percent, + padding: EdgeInsets.zero, + progressColor: AFThemeExtension.of(context).success, + backgroundColor: + AFThemeExtension.of(context).progressBarBGColor, + barRadius: const Radius.circular(8), + trailing: FlowyText( + "${(state.percent * 100).toStringAsFixed(0)}%", + fontSize: 11, + color: AFThemeExtension.of(context).success, + ), + ), + ), + const HSpace(12), + FlowyButton( + useIntrinsicWidth: true, + text: FlowyText( + LocaleKeys.button_cancel.tr(), + fontSize: 11, + ), + onTap: onCancel, + ), + ], + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart new file mode 100644 index 0000000000000..d924b468250f2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart @@ -0,0 +1,78 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class InitLocalAIIndicator extends StatelessWidget { + const InitLocalAIIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + color: Color(0xFFEDF7ED), + borderRadius: BorderRadius.all( + Radius.circular(4), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: BlocBuilder( + builder: (context, state) { + switch (state.runningState) { + case RunningStatePB.Connecting: + case RunningStatePB.Connected: + return Row( + children: [ + const HSpace(8), + FlowyText( + LocaleKeys.settings_aiPage_keys_localAILoading.tr(), + fontSize: 11, + color: const Color(0xFF1E4620), + ), + ], + ); + case RunningStatePB.Running: + return SizedBox( + height: 30, + child: Row( + children: [ + const HSpace(8), + const FlowySvg( + FlowySvgs.download_success_s, + color: Color(0xFF2E7D32), + ), + const HSpace(6), + FlowyText( + LocaleKeys.settings_aiPage_keys_localAILoaded.tr(), + fontSize: 11, + color: const Color(0xFF1E4620), + ), + ], + ), + ); + case RunningStatePB.Stopped: + return Row( + children: [ + const HSpace(8), + FlowyText( + LocaleKeys.settings_aiPage_keys_localAIStopped.tr(), + fontSize: 11, + color: const Color(0xFFC62828), + ), + ], + ); + default: + return const SizedBox.shrink(); + } + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart new file mode 100644 index 0000000000000..9cb3a17d88c0f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart @@ -0,0 +1,370 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_bloc.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_ai_chat_toggle_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/downloading_model.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:expandable/expandable.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class LocalAIChatSetting extends StatelessWidget { + const LocalAIChatSetting({super.key}); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider(create: (context) => LocalAIChatSettingBloc()), + BlocProvider( + create: (context) => LocalAIChatToggleBloc() + ..add(const LocalAIChatToggleEvent.started()), + ), + ], + child: ExpandableNotifier( + child: BlocListener( + listener: (context, state) { + // Listen to the toggle state and expand the panel if the state is ready. + final controller = ExpandableController.of( + context, + required: true, + )!; + + // Neet to wrap with WidgetsBinding.instance.addPostFrameCallback otherwise the + // ExpandablePanel not expanded sometimes. Maybe because the ExpandablePanel is not + // built yet when the listener is called. + WidgetsBinding.instance.addPostFrameCallback( + (_) { + state.pageIndicator.when( + error: (_) => controller.expanded = false, + ready: (enabled) { + controller.expanded = enabled; + context.read().add( + const LocalAIChatSettingEvent.refreshAISetting(), + ); + }, + loading: () => controller.expanded = false, + ); + }, + debugLabel: 'LocalAI.showLocalAIChatSetting', + ); + }, + child: ExpandablePanel( + theme: const ExpandableThemeData( + headerAlignment: ExpandablePanelHeaderAlignment.center, + tapBodyToCollapse: false, + hasIcon: false, + tapBodyToExpand: false, + tapHeaderToExpand: false, + ), + header: const SizedBox.shrink(), + collapsed: const SizedBox.shrink(), + expanded: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + // child: _LocalLLMInfoWidget(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BlocBuilder( + builder: (context, state) { + // If the progress indicator is startOfflineAIApp, then don't show the LLM model. + if (state.progressIndicator == + const LocalAIProgress.startOfflineAIApp()) { + return const SizedBox.shrink(); + } else { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: FlowyText.medium( + LocaleKeys.settings_aiPage_keys_llmModel.tr(), + fontSize: 14, + ), + ), + const Spacer(), + state.aiModelProgress.when( + init: () => const SizedBox.shrink(), + loading: () { + return const Expanded( + child: Row( + children: [ + Spacer(), + CircularProgressIndicator.adaptive(), + ], + ), + ); + }, + finish: (err) => (err == null) + ? const _SelectLocalModelDropdownMenu() + : const SizedBox.shrink(), + ), + ], + ); + } + }, + ), + const IntrinsicHeight(child: _LocalAIStateWidget()), + ], + ), + ), + ), + ), + ), + ); + } +} + +class LocalAIChatSettingHeader extends StatelessWidget { + const LocalAIChatSettingHeader({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state.pageIndicator.when( + error: (error) { + return const SizedBox.shrink(); + }, + loading: () { + return Row( + children: [ + FlowyText( + LocaleKeys.settings_aiPage_keys_localAIStart.tr(), + ), + const Spacer(), + const CircularProgressIndicator.adaptive(), + const HSpace(8), + ], + ); + }, + ready: (isEnabled) { + return Row( + children: [ + const FlowyText('Enable Local AI Chat'), + const Spacer(), + Toggle( + value: isEnabled, + onChanged: (_) { + context + .read() + .add(const LocalAIChatToggleEvent.toggle()); + }, + ), + ], + ); + }, + ); + }, + ); + } +} + +class _SelectLocalModelDropdownMenu extends StatelessWidget { + const _SelectLocalModelDropdownMenu(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Flexible( + child: SettingsDropdown( + key: const Key('_SelectLocalModelDropdownMenu'), + onChanged: (model) => context.read().add( + LocalAIChatSettingEvent.selectLLMConfig(model), + ), + selectedOption: state.selectedLLMModel!, + options: state.models + .map( + (llm) => buildDropdownMenuEntry( + context, + value: llm, + label: llm.chatModel, + ), + ) + .toList(), + ), + ); + }, + ); + } +} + +class _LocalAIStateWidget extends StatelessWidget { + const _LocalAIStateWidget(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final error = errorFromState(state); + if (error == null) { + // If the error is null, handle selected llm model. + if (state.progressIndicator != null) { + final child = state.progressIndicator!.when( + showDownload: ( + LocalModelResourcePB llmResource, + LLMModelPB llmModel, + ) => + _ShowDownloadIndicator( + llmResource: llmResource, + llmModel: llmModel, + ), + startDownloading: (llmModel) { + return DownloadingIndicator( + key: UniqueKey(), + llmModel: llmModel, + onFinish: () => context + .read() + .add(const LocalAIChatSettingEvent.finishDownload()), + onCancel: () => context + .read() + .add(const LocalAIChatSettingEvent.cancelDownload()), + ); + }, + finishDownload: () => const InitLocalAIIndicator(), + checkPluginState: () => const PluginStateIndicator(), + startOfflineAIApp: () => OpenOrDownloadOfflineAIApp( + onRetry: () { + context + .read() + .add(const LocalAIChatSettingEvent.refreshAISetting()); + }, + ), + ); + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: child, + ); + } else { + return const SizedBox.shrink(); + } + } else { + return Opacity( + opacity: 0.5, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: FlowyText( + error.msg, + maxLines: 10, + ), + ), + ); + } + }, + ); + } + + FlowyError? errorFromState(LocalAIChatSettingState state) { + final err = state.aiModelProgress.when( + loading: () => null, + finish: (err) => err, + init: () {}, + ); + + if (err == null) { + state.selectLLMState.when( + loading: () => null, + finish: (err) => err, + ); + } + + return err; + } +} + +void _showDownloadDialog( + BuildContext context, + LocalModelResourcePB llmResource, + LLMModelPB llmModel, +) { + if (llmResource.pendingResources.isEmpty) { + return; + } + + final res = llmResource.pendingResources.first; + String desc = ""; + switch (res.resType) { + case PendingResourceTypePB.AIModel: + desc = LocaleKeys.settings_aiPage_keys_downloadLLMPromptDetail.tr( + args: [ + llmResource.pendingResources[0].name, + llmResource.pendingResources[0].fileSize, + ], + ); + break; + case PendingResourceTypePB.OfflineApp: + desc = LocaleKeys.settings_aiPage_keys_downloadAppFlowyOfflineAI.tr(); + break; + } + + showConfirmDialog( + context: context, + style: ConfirmPopupStyle.cancelAndOk, + title: LocaleKeys.settings_aiPage_keys_downloadLLMPrompt.tr( + args: [res.name], + ), + description: desc, + confirmLabel: LocaleKeys.button_confirm.tr(), + onConfirm: () => context.read().add( + LocalAIChatSettingEvent.startDownloadModel( + llmModel, + ), + ), + onCancel: () => context.read().add( + const LocalAIChatSettingEvent.cancelDownload(), + ), + ); +} + +class _ShowDownloadIndicator extends StatelessWidget { + const _ShowDownloadIndicator({ + required this.llmResource, + required this.llmModel, + }); + final LocalModelResourcePB llmResource; + final LLMModelPB llmModel; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Row( + children: [ + const Spacer(), + IntrinsicWidth( + child: SizedBox( + height: 30, + child: FlowyButton( + text: FlowyText( + LocaleKeys.settings_aiPage_keys_downloadAIModelButton.tr(), + fontSize: 14, + color: const Color(0xFF005483), + ), + leftIcon: const FlowySvg( + FlowySvgs.local_model_download_s, + color: Color(0xFF005483), + ), + onTap: () { + _showDownloadDialog(context, llmResource, llmModel); + }, + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart new file mode 100644 index 0000000000000..f9dc6fa4d8c55 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; +import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_chat_setting.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:expandable/expandable.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class LocalAISetting extends StatefulWidget { + const LocalAISetting({super.key}); + + @override + State createState() => _LocalAISettingState(); +} + +class _LocalAISettingState extends State { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.aiSettings == null) { + return const SizedBox.shrink(); + } + + return BlocProvider( + create: (context) => + LocalAIToggleBloc()..add(const LocalAIToggleEvent.started()), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: ExpandableNotifier( + child: BlocListener( + listener: (context, state) { + final controller = + ExpandableController.of(context, required: true)!; + state.pageIndicator.when( + error: (_) => controller.expanded = false, + ready: (enabled) => controller.expanded = enabled, + loading: () => controller.expanded = false, + ); + }, + child: ExpandablePanel( + theme: const ExpandableThemeData( + headerAlignment: ExpandablePanelHeaderAlignment.center, + tapBodyToCollapse: false, + hasIcon: false, + tapBodyToExpand: false, + tapHeaderToExpand: false, + ), + header: const LocalAISettingHeader(), + collapsed: const SizedBox.shrink(), + expanded: Column( + children: [ + const VSpace(6), + DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + borderRadius: + const BorderRadius.all(Radius.circular(4)), + ), + child: const Padding( + padding: + EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: LocalAIChatSetting(), + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +class LocalAISettingHeader extends StatelessWidget { + const LocalAISettingHeader({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return state.pageIndicator.when( + error: (error) { + return const SizedBox.shrink(); + }, + loading: () { + return const CircularProgressIndicator.adaptive(); + }, + ready: (isEnabled) { + return Row( + children: [ + FlowyText( + LocaleKeys.settings_aiPage_keys_localAIToggleTitle.tr(), + ), + const Spacer(), + Toggle( + value: isEnabled, + onChanged: (_) { + if (isEnabled) { + showConfirmDialog( + context: context, + title: LocaleKeys + .settings_aiPage_keys_disableLocalAITitle + .tr(), + description: LocaleKeys + .settings_aiPage_keys_disableLocalAIDescription + .tr(), + confirmLabel: LocaleKeys.button_confirm.tr(), + onConfirm: () => context + .read() + .add(const LocalAIToggleEvent.toggle()), + ); + } else { + context + .read() + .add(const LocalAIToggleEvent.toggle()); + } + }, + ), + ], + ); + }, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart new file mode 100644 index 0000000000000..22aaf0bccad54 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class AIModelSelection extends StatelessWidget { + const AIModelSelection({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: FlowyText.medium( + LocaleKeys.settings_aiPage_keys_llmModelType.tr(), + fontSize: 14, + ), + ), + const Spacer(), + Flexible( + child: SettingsDropdown( + key: const Key('_AIModelSelection'), + onChanged: (model) => context + .read() + .add(SettingsAIEvent.selectModel(model)), + selectedOption: state.userProfile.aiModel, + options: _availableModels + .map( + (format) => buildDropdownMenuEntry( + context, + value: format, + label: _titleForAIModel(format), + ), + ) + .toList(), + ), + ), + ], + ), + ); + }, + ); + } +} + +List _availableModels = [ + AIModelPB.DefaultModel, + AIModelPB.Claude3Opus, + AIModelPB.Claude3Sonnet, + AIModelPB.GPT4oMini, + AIModelPB.GPT4o, +]; + +String _titleForAIModel(AIModelPB model) { + switch (model) { + case AIModelPB.DefaultModel: + return "Default"; + case AIModelPB.Claude3Opus: + return "Claude 3 Opus"; + case AIModelPB.Claude3Sonnet: + return "Claude 3 Sonnet"; + case AIModelPB.GPT4oMini: + return "GPT-4o-mini"; + case AIModelPB.GPT4o: + return "GPT-4o"; + default: + Log.error("Unknown AI model: $model, fallback to default"); + return "Default"; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart new file mode 100644 index 0000000000000..bf601b618405a --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart @@ -0,0 +1,255 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/ai/download_offline_ai_app_bloc.dart'; +import 'package:appflowy/workspace/application/settings/ai/plugin_state_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class PluginStateIndicator extends StatelessWidget { + const PluginStateIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + PluginStateBloc()..add(const PluginStateEvent.started()), + child: BlocBuilder( + builder: (context, state) { + return state.action.when( + init: () => const _InitPlugin(), + ready: () => const _LocalAIReadyToUse(), + restartPlugin: () => const _ReloadButton(), + loadingPlugin: () => const _InitPlugin(), + startAIOfflineApp: () => OpenOrDownloadOfflineAIApp( + onRetry: () { + context + .read() + .add(const PluginStateEvent.started()); + }, + ), + ); + }, + ), + ); + } +} + +class _InitPlugin extends StatelessWidget { + const _InitPlugin(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + FlowyText(LocaleKeys.settings_aiPage_keys_localAIStart.tr()), + const Spacer(), + const SizedBox( + height: 20, + child: CircularProgressIndicator.adaptive(), + ), + ], + ); + } +} + +class _ReloadButton extends StatelessWidget { + const _ReloadButton(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const FlowySvg( + FlowySvgs.download_warn_s, + color: Color(0xFFC62828), + ), + const HSpace(6), + FlowyText(LocaleKeys.settings_aiPage_keys_failToLoadLocalAI.tr()), + const Spacer(), + SizedBox( + height: 30, + child: FlowyButton( + useIntrinsicWidth: true, + text: + FlowyText(LocaleKeys.settings_aiPage_keys_restartLocalAI.tr()), + onTap: () { + context.read().add( + const PluginStateEvent.restartLocalAI(), + ); + }, + ), + ), + ], + ); + } +} + +class _LocalAIReadyToUse extends StatelessWidget { + const _LocalAIReadyToUse(); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + color: Color(0xFFEDF7ED), + borderRadius: BorderRadius.all( + Radius.circular(4), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Row( + children: [ + const HSpace(8), + const FlowySvg( + FlowySvgs.download_success_s, + color: Color(0xFF2E7D32), + ), + const HSpace(6), + Flexible( + child: FlowyText( + LocaleKeys.settings_aiPage_keys_localAILoaded.tr(), + fontSize: 11, + color: const Color(0xFF1E4620), + maxLines: 3, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: FlowyButton( + useIntrinsicWidth: true, + text: FlowyText( + LocaleKeys.settings_aiPage_keys_openModelDirectory.tr(), + fontSize: 11, + color: const Color(0xFF1E4620), + ), + onTap: () { + context.read().add( + const PluginStateEvent.openModelDirectory(), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class OpenOrDownloadOfflineAIApp extends StatelessWidget { + const OpenOrDownloadOfflineAIApp({required this.onRetry, super.key}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DownloadOfflineAIBloc(), + child: BlocBuilder( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + maxLines: 3, + textAlign: TextAlign.left, + text: TextSpan( + children: [ + TextSpan( + text: + "${LocaleKeys.settings_aiPage_keys_offlineAIInstruction1.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction2.tr()} ", + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: FontSizes.s14, + color: Theme.of(context).colorScheme.primary, + height: 1.5, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => afLaunchUrlString( + "https://docs.appflowy.io/docs/appflowy/product/appflowy-ai-offline", + ), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction3.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + TextSpan( + text: + "${LocaleKeys.settings_aiPage_keys_offlineAIDownload1.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload2.tr()} ", + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: FontSizes.s14, + color: Theme.of(context).colorScheme.primary, + height: 1.5, + ), + recognizer: TapGestureRecognizer() + ..onTap = + () => context.read().add( + const DownloadOfflineAIEvent.started(), + ), + ), + TextSpan( + text: + " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload3.tr()} ", + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(height: 1.5), + ), + ], + ), + ), + // const SizedBox( + // height: 6, + // ), // Replaced VSpace with SizedBox for simplicity + // SizedBox( + // height: 30, + // child: FlowyButton( + // useIntrinsicWidth: true, + // margin: const EdgeInsets.symmetric(horizontal: 12), + // text: FlowyText( + // LocaleKeys.settings_aiPage_keys_activeOfflineAI.tr(), + // ), + // onTap: onRetry, + // ), + // ), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart new file mode 100644 index 0000000000000..a6181ba0160eb --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart @@ -0,0 +1,245 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/workspace/application/settings/ai/local_ai_on_boarding_bloc.dart'; +import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class AIFeatureOnlySupportedWhenUsingAppFlowyCloud extends StatelessWidget { + const AIFeatureOnlySupportedWhenUsingAppFlowyCloud({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 60, vertical: 30), + child: FlowyText( + LocaleKeys.settings_aiPage_keys_loginToEnableAIFeature.tr(), + maxLines: null, + fontSize: 16, + lineHeight: 1.6, + ), + ); + } +} + +class SettingsAIView extends StatelessWidget { + const SettingsAIView({ + super.key, + required this.userProfile, + required this.currentWorkspaceMemberRole, + required this.workspaceId, + }); + + final UserProfilePB userProfile; + final AFRolePB? currentWorkspaceMemberRole; + final String workspaceId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => + SettingsAIBloc(userProfile, workspaceId, currentWorkspaceMemberRole) + ..add(const SettingsAIEvent.started()), + child: BlocBuilder( + builder: (context, state) { + final children = [ + const AIModelSelection(), + ]; + + children.add(const _AISearchToggle(value: false)); + + if (state.currentWorkspaceMemberRole != null) { + children.add( + _LocalAIOnBoarding( + userProfile: userProfile, + currentWorkspaceMemberRole: state.currentWorkspaceMemberRole!, + workspaceId: workspaceId, + ), + ); + } + + return SettingsBody( + title: LocaleKeys.settings_aiPage_title.tr(), + description: + LocaleKeys.settings_aiPage_keys_aiSettingsDescription.tr(), + children: children, + ); + }, + ), + ); + } +} + +class _AISearchToggle extends StatelessWidget { + const _AISearchToggle({required this.value}); + + final bool value; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + children: [ + FlowyText.medium( + LocaleKeys.settings_aiPage_keys_enableAISearchTitle.tr(), + ), + const Spacer(), + BlocBuilder( + builder: (context, state) { + if (state.aiSettings == null) { + return const Padding( + padding: EdgeInsets.only(top: 6), + child: SizedBox( + height: 26, + width: 26, + child: CircularProgressIndicator.adaptive(), + ), + ); + } else { + return Toggle( + value: state.enableSearchIndexing, + onChanged: (_) => context + .read() + .add(const SettingsAIEvent.toggleAISearch()), + ); + } + }, + ), + ], + ), + ], + ); + } +} + +// ignore: unused_element +class _LocalAIOnBoarding extends StatelessWidget { + const _LocalAIOnBoarding({ + required this.userProfile, + required this.currentWorkspaceMemberRole, + required this.workspaceId, + }); + final UserProfilePB userProfile; + final AFRolePB? currentWorkspaceMemberRole; + final String workspaceId; + + @override + Widget build(BuildContext context) { + if (FeatureFlag.planBilling.isOn) { + return BillingGateGuard( + builder: (context) { + return BlocProvider( + create: (context) => LocalAIOnBoardingBloc( + userProfile, + currentWorkspaceMemberRole, + workspaceId, + )..add(const LocalAIOnBoardingEvent.started()), + child: BlocBuilder( + builder: (context, state) { + // Show the local AI settings if the user has purchased the AI Local plan + if (kDebugMode || state.isPurchaseAILocal) { + return const LocalAISetting(); + } else { + if (currentWorkspaceMemberRole?.isOwner ?? false) { + // Show the upgrade to AI Local plan button if the user has not purchased the AI Local plan + return _UpgradeToAILocalPlan( + onTap: () { + context.read().add( + const LocalAIOnBoardingEvent.addSubscription( + SubscriptionPlanPB.AiLocal, + ), + ); + }, + ); + } else { + return const _AskOwnerUpgradeToLocalAI(); + } + } + }, + ), + ); + }, + ); + } else { + return const SizedBox.shrink(); + } + } +} + +class _AskOwnerUpgradeToLocalAI extends StatelessWidget { + const _AskOwnerUpgradeToLocalAI(); + + @override + Widget build(BuildContext context) { + return FlowyText( + LocaleKeys.sideBar_askOwnerToUpgradeToLocalAI.tr(), + color: AFThemeExtension.of(context).strongText, + ); + } +} + +class _UpgradeToAILocalPlan extends StatefulWidget { + const _UpgradeToAILocalPlan({required this.onTap}); + + final VoidCallback onTap; + + @override + State<_UpgradeToAILocalPlan> createState() => _UpgradeToAILocalPlanState(); +} + +class _UpgradeToAILocalPlanState extends State<_UpgradeToAILocalPlan> { + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.medium( + LocaleKeys.sideBar_upgradeToAILocal.tr(), + maxLines: 10, + lineHeight: 1.5, + ), + const VSpace(4), + Opacity( + opacity: 0.6, + child: FlowyText( + LocaleKeys.sideBar_upgradeToAILocalDesc.tr(), + fontSize: 12, + maxLines: 10, + lineHeight: 1.5, + ), + ), + ], + ), + ), + BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const CircularProgressIndicator.adaptive(); + } else { + return Toggle( + value: false, + onChanged: (_) => widget.onTap(), + ); + } + }, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart new file mode 100644 index 0000000000000..bb0e4aac9f317 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart @@ -0,0 +1,107 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SettingsAccountView extends StatefulWidget { + const SettingsAccountView({ + super.key, + required this.userProfile, + required this.didLogin, + required this.didLogout, + }); + + final UserProfilePB userProfile; + + // Called when the user signs in from the setting dialog + final VoidCallback didLogin; + + // Called when the user logout in the setting dialog + final VoidCallback didLogout; + + @override + State createState() => _SettingsAccountViewState(); +} + +class _SettingsAccountViewState extends State { + late String userName = widget.userProfile.name; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + getIt(param1: widget.userProfile) + ..add(const SettingsUserEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + return SettingsBody( + title: LocaleKeys.settings_accountPage_title.tr(), + children: [ + // user profile + SettingsCategory( + title: LocaleKeys.settings_accountPage_general_title.tr(), + children: [ + AccountUserProfile( + name: userName, + iconUrl: state.userProfile.iconUrl, + onSave: (newName) { + // Pseudo change the name to update the UI before the backend + // processes the request. This is to give the user a sense of + // immediate feedback, and avoid UI flickering. + setState(() => userName = newName); + context + .read() + .add(SettingsUserEvent.updateUserName(newName)); + }, + ), + ], + ), + + // user email + // Only show email if the user is authenticated and not using local auth + if (isAuthEnabled && + state.userProfile.authenticator != AuthenticatorPB.Local) ...[ + SettingsCategory( + title: LocaleKeys.settings_accountPage_email_title.tr(), + children: [ + FlowyText.regular(state.userProfile.email), + ], + ), + ], + + // user sign in/out + SettingsCategory( + title: LocaleKeys.settings_accountPage_login_title.tr(), + children: [ + AccountSignInOutButton( + userProfile: state.userProfile, + onAction: + state.userProfile.authenticator == AuthenticatorPB.Local + ? widget.didLogin + : widget.didLogout, + signIn: state.userProfile.authenticator == + AuthenticatorPB.Local, + ), + ], + ), + + // user deletion + if (widget.userProfile.authenticator == + AuthenticatorPB.AppFlowyCloud) + const AccountDeletionButton(), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart new file mode 100644 index 0000000000000..0c57c9e1585b0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_billing_view.dart @@ -0,0 +1,600 @@ +import 'dart:io'; + +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/util/int64_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/billing/settings_billing_bloc.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart'; +import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_dashed_divider.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../generated/locale_keys.g.dart'; +import '../../../../plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; + +const _buttonsMinWidth = 100.0; + +class SettingsBillingView extends StatefulWidget { + const SettingsBillingView({ + super.key, + required this.workspaceId, + required this.user, + }); + + final String workspaceId; + final UserProfilePB user; + + @override + State createState() => _SettingsBillingViewState(); +} + +class _SettingsBillingViewState extends State { + Loading? loadingIndicator; + RecurringIntervalPB? selectedInterval; + final ValueNotifier enablePlanChangeNotifier = ValueNotifier(false); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SettingsBillingBloc( + workspaceId: widget.workspaceId, + userId: widget.user.id, + )..add(const SettingsBillingEvent.started()), + child: BlocConsumer( + listenWhen: (previous, current) => + previous.mapOrNull(ready: (s) => s.isLoading) != + current.mapOrNull(ready: (s) => s.isLoading), + listener: (context, state) { + if (state.mapOrNull(ready: (s) => s.isLoading) == true) { + loadingIndicator = Loading(context)..start(); + } else { + loadingIndicator?.stop(); + loadingIndicator = null; + } + }, + builder: (context, state) { + return state.map( + initial: (_) => const SizedBox.shrink(), + loading: (_) => const Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 3), + ), + ), + error: (state) { + if (state.error != null) { + return Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: AppFlowyErrorPage( + error: state.error!, + ), + ), + ); + } + + return ErrorWidget.withDetails(message: 'Something went wrong!'); + }, + ready: (state) { + final billingPortalEnabled = + state.subscriptionInfo.isBillingPortalEnabled; + + return SettingsBody( + title: LocaleKeys.settings_billingPage_title.tr(), + children: [ + SettingsCategory( + title: LocaleKeys.settings_billingPage_plan_title.tr(), + children: [ + SingleSettingAction( + onPressed: () => _openPricingDialog( + context, + widget.workspaceId, + widget.user.id, + state.subscriptionInfo, + ), + fontWeight: FontWeight.w500, + label: state.subscriptionInfo.label, + buttonLabel: LocaleKeys + .settings_billingPage_plan_planButtonLabel + .tr(), + minWidth: _buttonsMinWidth, + ), + if (billingPortalEnabled) + SingleSettingAction( + onPressed: () { + SettingsAlertDialog( + title: LocaleKeys + .settings_billingPage_changePeriod + .tr(), + enableConfirmNotifier: enablePlanChangeNotifier, + children: [ + ChangePeriod( + plan: state.subscriptionInfo.planSubscription + .subscriptionPlan, + selectedInterval: state.subscriptionInfo + .planSubscription.interval, + onSelected: (interval) { + enablePlanChangeNotifier.value = interval != + state.subscriptionInfo.planSubscription + .interval; + selectedInterval = interval; + }, + ), + ], + confirm: () { + if (selectedInterval != + state.subscriptionInfo.planSubscription + .interval) { + context.read().add( + SettingsBillingEvent.updatePeriod( + plan: state + .subscriptionInfo + .planSubscription + .subscriptionPlan, + interval: selectedInterval!, + ), + ); + } + Navigator.of(context).pop(); + }, + ).show(context); + }, + label: LocaleKeys + .settings_billingPage_plan_billingPeriod + .tr(), + description: state + .subscriptionInfo.planSubscription.interval.label, + fontWeight: FontWeight.w500, + buttonLabel: LocaleKeys + .settings_billingPage_plan_periodButtonLabel + .tr(), + minWidth: _buttonsMinWidth, + ), + ], + ), + if (billingPortalEnabled) + SettingsCategory( + title: LocaleKeys + .settings_billingPage_paymentDetails_title + .tr(), + children: [ + SingleSettingAction( + onPressed: () => context + .read() + .add( + const SettingsBillingEvent.openCustomerPortal(), + ), + label: LocaleKeys + .settings_billingPage_paymentDetails_methodLabel + .tr(), + fontWeight: FontWeight.w500, + buttonLabel: LocaleKeys + .settings_billingPage_paymentDetails_methodButtonLabel + .tr(), + minWidth: _buttonsMinWidth, + ), + ], + ), + SettingsCategory( + title: LocaleKeys.settings_billingPage_addons_title.tr(), + children: [ + _AITile( + plan: SubscriptionPlanPB.AiMax, + label: LocaleKeys + .settings_billingPage_addons_aiMax_label + .tr(), + description: LocaleKeys + .settings_billingPage_addons_aiMax_description, + activeDescription: LocaleKeys + .settings_billingPage_addons_aiMax_activeDescription, + canceledDescription: LocaleKeys + .settings_billingPage_addons_aiMax_canceledDescription, + subscriptionInfo: + state.subscriptionInfo.addOns.firstWhereOrNull( + (a) => a.type == WorkspaceAddOnPBType.AddOnAiMax, + ), + ), + const SettingsDashedDivider(), + + // Currently, the AI Local tile is only available on macOS + // TODO(nathan): enable windows and linux + if (Platform.isMacOS) + _AITile( + plan: SubscriptionPlanPB.AiLocal, + label: LocaleKeys + .settings_billingPage_addons_aiOnDevice_label + .tr(), + description: LocaleKeys + .settings_billingPage_addons_aiOnDevice_description, + activeDescription: LocaleKeys + .settings_billingPage_addons_aiOnDevice_activeDescription, + canceledDescription: LocaleKeys + .settings_billingPage_addons_aiOnDevice_canceledDescription, + subscriptionInfo: + state.subscriptionInfo.addOns.firstWhereOrNull( + (a) => a.type == WorkspaceAddOnPBType.AddOnAiLocal, + ), + ), + ], + ), + ], + ); + }, + ); + }, + ), + ); + } + + void _openPricingDialog( + BuildContext context, + String workspaceId, + Int64 userId, + WorkspaceSubscriptionInfoPB subscriptionInfo, + ) => + showDialog( + context: context, + builder: (_) => BlocProvider( + create: (_) => + SettingsPlanBloc(workspaceId: workspaceId, userId: widget.user.id) + ..add(const SettingsPlanEvent.started()), + child: SettingsPlanComparisonDialog( + workspaceId: workspaceId, + subscriptionInfo: subscriptionInfo, + ), + ), + ).then((didChangePlan) { + if (didChangePlan == true && context.mounted) { + context + .read() + .add(const SettingsBillingEvent.started()); + } + }); +} + +class _AITile extends StatefulWidget { + const _AITile({ + required this.label, + required this.description, + required this.canceledDescription, + required this.activeDescription, + required this.plan, + this.subscriptionInfo, + }); + + final String label; + final String description; + final String canceledDescription; + final String activeDescription; + final SubscriptionPlanPB plan; + final WorkspaceAddOnPB? subscriptionInfo; + + @override + State<_AITile> createState() => _AITileState(); +} + +class _AITileState extends State<_AITile> { + RecurringIntervalPB? selectedInterval; + + final enableConfirmNotifier = ValueNotifier(false); + + @override + Widget build(BuildContext context) { + final isCanceled = widget.subscriptionInfo?.addOnSubscription.status == + WorkspaceSubscriptionStatusPB.Canceled; + + final dateFormat = context.read().state.dateFormat; + + return Column( + children: [ + SingleSettingAction( + label: widget.label, + description: widget.subscriptionInfo != null && isCanceled + ? widget.canceledDescription.tr( + args: [ + dateFormat.formatDate( + widget.subscriptionInfo!.addOnSubscription.endDate + .toDateTime(), + false, + ), + ], + ) + : widget.subscriptionInfo != null + ? widget.activeDescription.tr( + args: [ + dateFormat.formatDate( + widget.subscriptionInfo!.addOnSubscription.endDate + .toDateTime(), + false, + ), + ], + ) + : widget.description.tr(), + buttonLabel: widget.subscriptionInfo != null + ? isCanceled + ? LocaleKeys.settings_billingPage_addons_renewLabel.tr() + : LocaleKeys.settings_billingPage_addons_removeLabel.tr() + : LocaleKeys.settings_billingPage_addons_addLabel.tr(), + fontWeight: FontWeight.w500, + minWidth: _buttonsMinWidth, + onPressed: () async { + if (widget.subscriptionInfo != null) { + await showConfirmDialog( + context: context, + style: ConfirmPopupStyle.cancelAndOk, + title: LocaleKeys.settings_billingPage_addons_removeDialog_title + .tr(args: [widget.plan.label]).tr(), + description: LocaleKeys + .settings_billingPage_addons_removeDialog_description + .tr(namedArgs: {"plan": widget.plan.label.tr()}), + confirmLabel: LocaleKeys.button_confirm.tr(), + onConfirm: () => context + .read() + .add(SettingsBillingEvent.cancelSubscription(widget.plan)), + ); + } else { + // Add the addon + context + .read() + .add(SettingsBillingEvent.addSubscription(widget.plan)); + } + }, + ), + if (widget.subscriptionInfo != null) ...[ + const VSpace(10), + SingleSettingAction( + label: LocaleKeys.settings_billingPage_planPeriod.tr( + args: [ + widget + .subscriptionInfo!.addOnSubscription.subscriptionPlan.label, + ], + ), + description: + widget.subscriptionInfo!.addOnSubscription.interval.label, + buttonLabel: + LocaleKeys.settings_billingPage_plan_periodButtonLabel.tr(), + minWidth: _buttonsMinWidth, + onPressed: () { + enableConfirmNotifier.value = false; + SettingsAlertDialog( + title: LocaleKeys.settings_billingPage_changePeriod.tr(), + enableConfirmNotifier: enableConfirmNotifier, + children: [ + ChangePeriod( + plan: widget + .subscriptionInfo!.addOnSubscription.subscriptionPlan, + selectedInterval: + widget.subscriptionInfo!.addOnSubscription.interval, + onSelected: (interval) { + enableConfirmNotifier.value = interval != + widget.subscriptionInfo!.addOnSubscription.interval; + selectedInterval = interval; + }, + ), + ], + confirm: () { + if (selectedInterval != + widget.subscriptionInfo!.addOnSubscription.interval) { + context.read().add( + SettingsBillingEvent.updatePeriod( + plan: widget.subscriptionInfo!.addOnSubscription + .subscriptionPlan, + interval: selectedInterval!, + ), + ); + } + Navigator.of(context).pop(); + }, + ).show(context); + }, + ), + ], + ], + ); + } +} + +class ChangePeriod extends StatefulWidget { + const ChangePeriod({ + super.key, + required this.plan, + required this.selectedInterval, + required this.onSelected, + }); + + final SubscriptionPlanPB plan; + final RecurringIntervalPB selectedInterval; + final Function(RecurringIntervalPB interval) onSelected; + + @override + State createState() => _ChangePeriodState(); +} + +class _ChangePeriodState extends State { + RecurringIntervalPB? _selectedInterval; + + @override + void initState() { + super.initState(); + _selectedInterval = widget.selectedInterval; + } + + @override + void didChangeDependencies() { + _selectedInterval = widget.selectedInterval; + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _PeriodSelector( + price: widget.plan.priceMonthBilling, + interval: RecurringIntervalPB.Month, + isSelected: _selectedInterval == RecurringIntervalPB.Month, + isCurrent: widget.selectedInterval == RecurringIntervalPB.Month, + onSelected: () { + widget.onSelected(RecurringIntervalPB.Month); + setState( + () => _selectedInterval = RecurringIntervalPB.Month, + ); + }, + ), + const VSpace(16), + _PeriodSelector( + price: widget.plan.priceAnnualBilling, + interval: RecurringIntervalPB.Year, + isSelected: _selectedInterval == RecurringIntervalPB.Year, + isCurrent: widget.selectedInterval == RecurringIntervalPB.Year, + onSelected: () { + widget.onSelected(RecurringIntervalPB.Year); + setState( + () => _selectedInterval = RecurringIntervalPB.Year, + ); + }, + ), + ], + ); + } +} + +class _PeriodSelector extends StatelessWidget { + const _PeriodSelector({ + required this.price, + required this.interval, + required this.onSelected, + required this.isSelected, + required this.isCurrent, + }); + + final String price; + final RecurringIntervalPB interval; + final VoidCallback onSelected; + final bool isSelected; + final bool isCurrent; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: isCurrent && !isSelected ? 0.7 : 1, + child: GestureDetector( + onTap: isCurrent ? null : onSelected, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FlowyText( + interval.label, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + if (isCurrent) ...[ + const HSpace(8), + DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + child: FlowyText( + LocaleKeys + .settings_billingPage_currentPeriodBadge + .tr(), + fontSize: 11, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ), + ], + ], + ), + const VSpace(8), + FlowyText( + price, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + const VSpace(4), + FlowyText( + interval.priceInfo, + fontWeight: FontWeight.w400, + fontSize: 12, + ), + ], + ), + const Spacer(), + if (!isCurrent && !isSelected || isSelected) ...[ + DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + width: 1.5, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + ), + ), + child: SizedBox( + height: 22, + width: 22, + child: Center( + child: SizedBox( + width: 10, + height: 10, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + ), + ), + ), + ), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart new file mode 100644 index 0000000000000..7c096b4b2f99f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart @@ -0,0 +1,457 @@ +import 'dart:async'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/appflowy_cache_manager.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/startup/tasks/rust_sdk.dart'; +import 'package:appflowy/util/share_log_files.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/settings/setting_file_importer_bloc.dart'; +import 'package:appflowy/workspace/application/settings/settings_location_cubit.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/fix_data_widget.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/setting_action.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/files/settings_export_file_widget.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; + +class SettingsManageDataView extends StatelessWidget { + const SettingsManageDataView({super.key, required this.userProfile}); + + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SettingsLocationCubit(), + child: BlocBuilder( + builder: (context, state) { + return SettingsBody( + title: LocaleKeys.settings_manageDataPage_title.tr(), + description: LocaleKeys.settings_manageDataPage_description.tr(), + children: [ + SettingsCategory( + title: + LocaleKeys.settings_manageDataPage_dataStorage_title.tr(), + tooltip: + LocaleKeys.settings_manageDataPage_dataStorage_tooltip.tr(), + actions: [ + if (state.mapOrNull(didReceivedPath: (_) => true) == true) + SettingAction( + tooltip: LocaleKeys + .settings_manageDataPage_dataStorage_actions_resetTooltip + .tr(), + icon: const FlowySvg( + FlowySvgs.restore_s, + size: Size.square(20), + ), + label: LocaleKeys.settings_common_reset.tr(), + onPressed: () => showConfirmDialog( + context: context, + confirmLabel: LocaleKeys.button_confirm.tr(), + title: LocaleKeys + .settings_manageDataPage_dataStorage_resetDialog_title + .tr(), + description: LocaleKeys + .settings_manageDataPage_dataStorage_resetDialog_description + .tr(), + onConfirm: () async { + final directory = + await appFlowyApplicationDataDirectory(); + final path = directory.path; + if (!context.mounted || + state.mapOrNull(didReceivedPath: (e) => e.path) == + path) { + return; + } + + await context + .read() + .resetDataStoragePathToApplicationDefault(); + await runAppFlowy(isAnon: true); + }, + ), + ), + ], + children: state + .map( + initial: (_) => [const CircularProgressIndicator()], + didReceivedPath: (event) => [ + _CurrentPath(path: event.path), + _DataPathActions(currentPath: event.path), + ], + ) + .toList(), + ), + SettingsCategory( + title: LocaleKeys.settings_manageDataPage_importData_title.tr(), + tooltip: + LocaleKeys.settings_manageDataPage_importData_tooltip.tr(), + children: const [_ImportDataField()], + ), + if (kDebugMode) ...[ + SettingsCategory( + title: LocaleKeys.settings_files_exportData.tr(), + children: const [ + SettingsExportFileWidget(), + FixDataWidget(), + ], + ), + ], + SettingsCategory( + title: LocaleKeys.workspace_errorActions_exportLogFiles.tr(), + children: [ + SingleSettingAction( + labelMaxLines: 4, + label: + LocaleKeys.workspace_errorActions_exportLogFiles.tr(), + buttonLabel: LocaleKeys.settings_files_export.tr(), + onPressed: () { + shareLogFiles(context); + }, + ), + ], + ), + SettingsCategory( + title: LocaleKeys.settings_manageDataPage_cache_title.tr(), + children: [ + SingleSettingAction( + labelMaxLines: 4, + label: LocaleKeys.settings_manageDataPage_cache_description + .tr(), + buttonLabel: + LocaleKeys.settings_manageDataPage_cache_title.tr(), + onPressed: () { + showCancelAndConfirmDialog( + context: context, + title: LocaleKeys + .settings_manageDataPage_cache_dialog_title + .tr(), + description: LocaleKeys + .settings_manageDataPage_cache_dialog_description + .tr(), + confirmLabel: LocaleKeys.button_ok.tr(), + onConfirm: () async { + // clear all cache + await getIt().clearAllCache(); + + // check the workspace and space health + await WorkspaceDataManager.checkViewHealth( + dryRun: false, + ); + + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys + .settings_manageDataPage_cache_dialog_successHint + .tr(), + ); + } + }, + ); + }, + ), + ], + ), + ], + ); + }, + ), + ); + } +} + +// class _EncryptDataSetting extends StatelessWidget { +// const _EncryptDataSetting({required this.userProfile}); + +// final UserProfilePB userProfile; + +// @override +// Widget build(BuildContext context) { +// return BlocProvider.value( +// value: context.read(), +// child: BlocBuilder( +// builder: (context, state) { +// if (state.loadingState?.isLoading() == true) { +// return const Row( +// children: [ +// SizedBox( +// width: 20, +// height: 20, +// child: CircularProgressIndicator( +// strokeWidth: 3, +// ), +// ), +// HSpace(16), +// FlowyText.medium( +// 'Encrypting data...', +// fontSize: 14, +// ), +// ], +// ); +// } + +// if (userProfile.encryptionType == EncryptionTypePB.NoEncryption) { +// return Row( +// children: [ +// SizedBox( +// height: 42, +// child: FlowyTextButton( +// LocaleKeys.settings_manageDataPage_encryption_action.tr(), +// padding: const EdgeInsets.symmetric( +// horizontal: 24, +// vertical: 12, +// ), +// fontWeight: FontWeight.w600, +// radius: BorderRadius.circular(12), +// fillColor: Theme.of(context).colorScheme.primary, +// hoverColor: const Color(0xFF005483), +// fontHoverColor: Colors.white, +// onPressed: () => SettingsAlertDialog( +// title: LocaleKeys +// .settings_manageDataPage_encryption_dialog_title +// .tr(), +// subtitle: LocaleKeys +// .settings_manageDataPage_encryption_dialog_description +// .tr(), +// confirmLabel: LocaleKeys +// .settings_manageDataPage_encryption_dialog_title +// .tr(), +// implyLeading: true, +// // Generate a secret one time for the user +// confirm: () => context +// .read() +// .add(const EncryptSecretEvent.setEncryptSecret('')), +// ).show(context), +// ), +// ), +// ], +// ); +// } +// // Show encryption secret for copy/save +// return const SizedBox.shrink(); +// }, +// ), +// ); +// } +// } + +class _ImportDataField extends StatefulWidget { + const _ImportDataField(); + + @override + State<_ImportDataField> createState() => _ImportDataFieldState(); +} + +class _ImportDataFieldState extends State<_ImportDataField> { + final _fToast = FToast(); + + @override + void initState() { + super.initState(); + _fToast.init(context); + } + + @override + void dispose() { + _fToast.removeQueuedCustomToasts(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SettingFileImportBloc(), + child: BlocConsumer( + listenWhen: (previous, current) => + previous.successOrFail != current.successOrFail, + listener: (_, state) => state.successOrFail?.fold( + (_) => _showToast(LocaleKeys.settings_menu_importSuccess.tr()), + (_) => _showToast(LocaleKeys.settings_menu_importFailed.tr()), + ), + builder: (context, state) { + return SingleSettingAction( + label: + LocaleKeys.settings_manageDataPage_importData_description.tr(), + labelMaxLines: 2, + buttonLabel: + LocaleKeys.settings_manageDataPage_importData_action.tr(), + onPressed: () async { + final path = await getIt().getDirectoryPath(); + if (path == null || !context.mounted) { + return; + } + + context + .read() + .add(SettingFileImportEvent.importAppFlowyDataFolder(path)); + }, + ); + }, + ), + ); + } + + void _showToast(String message) { + _fToast.showToast( + child: FlowyMessageToast(message: message), + gravity: ToastGravity.CENTER, + ); + } +} + +class _CurrentPath extends StatefulWidget { + const _CurrentPath({required this.path}); + + final String path; + + @override + State<_CurrentPath> createState() => _CurrentPathState(); +} + +class _CurrentPathState extends State<_CurrentPath> { + Timer? linkCopiedTimer; + bool showCopyMessage = false; + + @override + void dispose() { + linkCopiedTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isLM = Theme.of(context).isLightMode; + + return Column( + children: [ + Row( + children: [ + Expanded( + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (_) => _copyLink(widget.path), + child: FlowyHover( + style: const HoverStyle.transparent(), + resetHoverOnRebuild: false, + builder: (_, isHovering) => FlowyText.regular( + widget.path, + maxLines: 2, + overflow: TextOverflow.ellipsis, + lineHeight: 1.5, + decoration: isHovering ? TextDecoration.underline : null, + color: isLM + ? const Color(0xFF005483) + : Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + const HSpace(8), + showCopyMessage + ? SizedBox( + height: 36, + child: FlowyTextButton( + LocaleKeys + .settings_manageDataPage_dataStorage_actions_copiedHint + .tr(), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + fontWeight: FontWeight.w500, + radius: BorderRadius.circular(12), + fillColor: AFThemeExtension.of(context).tint7, + hoverColor: AFThemeExtension.of(context).tint7, + ), + ) + : Padding( + padding: const EdgeInsets.only(left: 100), + child: SettingAction( + tooltip: LocaleKeys + .settings_manageDataPage_dataStorage_actions_copy + .tr(), + icon: const FlowySvg( + FlowySvgs.copy_s, + size: Size.square(24), + ), + onPressed: () => _copyLink(widget.path), + ), + ), + ], + ), + ], + ); + } + + void _copyLink(String? path) { + AppFlowyClipboard.setData(text: path); + setState(() => showCopyMessage = true); + linkCopiedTimer?.cancel(); + linkCopiedTimer = Timer( + const Duration(milliseconds: 300), + () => mounted ? setState(() => showCopyMessage = false) : null, + ); + } +} + +class _DataPathActions extends StatelessWidget { + const _DataPathActions({required this.currentPath}); + + final String currentPath; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + height: 42, + child: PrimaryRoundedButton( + text: LocaleKeys.settings_manageDataPage_dataStorage_actions_change + .tr(), + margin: const EdgeInsets.symmetric(horizontal: 24), + fontWeight: FontWeight.w600, + radius: 12.0, + onTap: () async { + final path = await getIt().getDirectoryPath(); + if (!context.mounted || path == null || currentPath == path) { + return; + } + + await context.read().setCustomPath(path); + await runAppFlowy(isAnon: true); + + if (context.mounted) Navigator.of(context).pop(); + }, + ), + ), + const HSpace(16), + SettingAction( + tooltip: LocaleKeys + .settings_manageDataPage_dataStorage_actions_openTooltip + .tr(), + label: + LocaleKeys.settings_manageDataPage_dataStorage_actions_open.tr(), + icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(20)), + onPressed: () => afLaunchUri(Uri.file(currentPath)), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart new file mode 100644 index 0000000000000..d9103b4cd441b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart @@ -0,0 +1,756 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart'; +import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/cancel_plan_survey_dialog.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../generated/locale_keys.g.dart'; +import '../../../../plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; + +class SettingsPlanComparisonDialog extends StatefulWidget { + const SettingsPlanComparisonDialog({ + super.key, + required this.workspaceId, + required this.subscriptionInfo, + }); + + final String workspaceId; + final WorkspaceSubscriptionInfoPB subscriptionInfo; + + @override + State createState() => + _SettingsPlanComparisonDialogState(); +} + +class _SettingsPlanComparisonDialogState + extends State { + final horizontalController = ScrollController(); + final verticalController = ScrollController(); + + late WorkspaceSubscriptionInfoPB currentInfo = widget.subscriptionInfo; + + Loading? loadingIndicator; + + @override + void dispose() { + horizontalController.dispose(); + verticalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isLM = Theme.of(context).isLightMode; + + return BlocConsumer( + listener: (context, state) { + final readyState = state.mapOrNull(ready: (state) => state); + + if (readyState == null) { + return; + } + + if (readyState.downgradeProcessing) { + loadingIndicator = Loading(context)..start(); + } else { + loadingIndicator?.stop(); + loadingIndicator = null; + } + + if (readyState.successfulPlanUpgrade != null) { + showConfirmDialog( + context: context, + title: LocaleKeys.settings_comparePlanDialog_paymentSuccess_title + .tr(args: [readyState.successfulPlanUpgrade!.label]), + description: LocaleKeys + .settings_comparePlanDialog_paymentSuccess_description + .tr(args: [readyState.successfulPlanUpgrade!.label]), + confirmLabel: LocaleKeys.button_close.tr(), + onConfirm: () {}, + ); + } + + setState(() => currentInfo = readyState.subscriptionInfo); + }, + builder: (context, state) => FlowyDialog( + constraints: const BoxConstraints(maxWidth: 784, minWidth: 674), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 24, left: 24, right: 24), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.semibold( + LocaleKeys.settings_comparePlanDialog_title.tr(), + fontSize: 24, + color: AFThemeExtension.of(context).strongText, + ), + const Spacer(), + GestureDetector( + onTap: () => Navigator.of(context).pop( + currentInfo.plan != widget.subscriptionInfo.plan, + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowySvg( + FlowySvgs.m_close_m, + size: const Size.square(20), + color: AFThemeExtension.of(context).strongText, + ), + ), + ), + ], + ), + ), + const VSpace(16), + Flexible( + child: SingleChildScrollView( + controller: horizontalController, + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + controller: verticalController, + padding: const EdgeInsets.only( + left: 24, + right: 24, + bottom: 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 250, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(30), + SizedBox( + height: 116, + child: FlowyText.semibold( + LocaleKeys + .settings_comparePlanDialog_planFeatures + .tr(), + fontSize: 24, + maxLines: 2, + color: isLM + ? const Color(0xFF5C3699) + : const Color(0xFFE8E0FF), + ), + ), + const SizedBox(height: 116), + const SizedBox(height: 56), + ..._planLabels.map( + (e) => _ComparisonCell( + label: e.label, + tooltip: e.tooltip, + ), + ), + ], + ), + ), + _PlanTable( + title: LocaleKeys + .settings_comparePlanDialog_freePlan_title + .tr(), + description: LocaleKeys + .settings_comparePlanDialog_freePlan_description + .tr(), + price: LocaleKeys + .settings_comparePlanDialog_freePlan_price + .tr( + args: [ + SubscriptionPlanPB.Free.priceMonthBilling, + ], + ), + priceInfo: LocaleKeys + .settings_comparePlanDialog_freePlan_priceInfo + .tr(), + cells: _freeLabels, + isCurrent: + currentInfo.plan == WorkspacePlanPB.FreePlan, + buttonType: WorkspacePlanPB.FreePlan.buttonTypeFor( + currentInfo.plan, + ), + onSelected: () async { + if (currentInfo.plan == + WorkspacePlanPB.FreePlan || + currentInfo.isCanceled) { + return; + } + + final reason = + await showCancelSurveyDialog(context); + if (reason == null || !context.mounted) { + return; + } + + await showConfirmDialog( + context: context, + title: LocaleKeys + .settings_comparePlanDialog_downgradeDialog_title + .tr(args: [currentInfo.label]), + description: LocaleKeys + .settings_comparePlanDialog_downgradeDialog_description + .tr(), + confirmLabel: LocaleKeys + .settings_comparePlanDialog_downgradeDialog_downgradeLabel + .tr(), + style: ConfirmPopupStyle.cancelAndOk, + onConfirm: () => + context.read().add( + SettingsPlanEvent.cancelSubscription( + reason: reason, + ), + ), + ); + }, + ), + _PlanTable( + title: LocaleKeys + .settings_comparePlanDialog_proPlan_title + .tr(), + description: LocaleKeys + .settings_comparePlanDialog_proPlan_description + .tr(), + price: LocaleKeys + .settings_comparePlanDialog_proPlan_price + .tr( + args: [SubscriptionPlanPB.Pro.priceAnnualBilling], + ), + priceInfo: LocaleKeys + .settings_comparePlanDialog_proPlan_priceInfo + .tr( + args: [SubscriptionPlanPB.Pro.priceMonthBilling], + ), + cells: _proLabels, + isCurrent: + currentInfo.plan == WorkspacePlanPB.ProPlan, + buttonType: WorkspacePlanPB.ProPlan.buttonTypeFor( + currentInfo.plan, + ), + onSelected: () => + context.read().add( + const SettingsPlanEvent.addSubscription( + SubscriptionPlanPB.Pro, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +enum _PlanButtonType { + none, + upgrade, + downgrade; + + bool get isDowngrade => this == downgrade; + bool get isUpgrade => this == upgrade; +} + +extension _ButtonTypeFrom on WorkspacePlanPB { + /// Returns the button type for the given plan, taking the + /// current plan as [other]. + /// + _PlanButtonType buttonTypeFor(WorkspacePlanPB other) { + /// Current plan, no action + if (this == other) { + return _PlanButtonType.none; + } + + // Free plan, can downgrade if not on the free plan + if (this == WorkspacePlanPB.FreePlan && other != WorkspacePlanPB.FreePlan) { + return _PlanButtonType.downgrade; + } + + // Else we can assume it's an upgrade + return _PlanButtonType.upgrade; + } +} + +class _PlanTable extends StatelessWidget { + const _PlanTable({ + required this.title, + required this.description, + required this.price, + required this.priceInfo, + required this.cells, + required this.isCurrent, + required this.onSelected, + this.buttonType = _PlanButtonType.none, + }); + + final String title; + final String description; + final String price; + final String priceInfo; + + final List<_CellItem> cells; + final bool isCurrent; + final VoidCallback onSelected; + final _PlanButtonType buttonType; + + @override + Widget build(BuildContext context) { + final highlightPlan = !isCurrent && buttonType == _PlanButtonType.upgrade; + final isLM = Theme.of(context).isLightMode; + + return Container( + width: 215, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: !highlightPlan + ? null + : LinearGradient( + colors: [ + isLM ? const Color(0xFF251D37) : const Color(0xFF7459AD), + isLM ? const Color(0xFF7547C0) : const Color(0xFFDDC8FF), + ], + ), + ), + padding: !highlightPlan + ? const EdgeInsets.only(top: 4) + : const EdgeInsets.all(4), + child: Container( + padding: isCurrent + ? const EdgeInsets.only(bottom: 22) + : const EdgeInsets.symmetric(vertical: 22), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + color: Theme.of(context).cardColor, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isCurrent) const _CurrentBadge(), + const VSpace(4), + _Heading( + title: title, + description: description, + isPrimary: !highlightPlan, + ), + _Heading( + title: price, + description: priceInfo, + isPrimary: !highlightPlan, + ), + if (buttonType == _PlanButtonType.none) ...[ + const SizedBox(height: 56), + ] else ...[ + Opacity( + opacity: 1, + child: Padding( + padding: EdgeInsets.only( + left: 12 + (buttonType.isUpgrade ? 12 : 0), + ), + child: _ActionButton( + label: buttonType.isUpgrade + ? LocaleKeys.settings_comparePlanDialog_actions_upgrade + .tr() + : LocaleKeys + .settings_comparePlanDialog_actions_downgrade + .tr(), + onPressed: onSelected, + isUpgrade: buttonType.isUpgrade, + useGradientBorder: buttonType.isUpgrade, + ), + ), + ), + ], + ...cells.map( + (cell) => _ComparisonCell( + label: cell.label, + icon: cell.icon, + isHighlighted: highlightPlan, + ), + ), + ], + ), + ), + ); + } +} + +class _CurrentBadge extends StatelessWidget { + const _CurrentBadge(); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(left: 12), + height: 22, + width: 72, + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? const Color(0xFF4F3F5F) + : const Color(0xFFE8E0FF), + borderRadius: BorderRadius.circular(4), + ), + child: Center( + child: FlowyText.medium( + LocaleKeys.settings_comparePlanDialog_current.tr(), + fontSize: 12, + color: Theme.of(context).isLightMode ? Colors.white : Colors.black, + ), + ), + ); + } +} + +class _ComparisonCell extends StatelessWidget { + const _ComparisonCell({ + this.label, + this.icon, + this.tooltip, + this.isHighlighted = false, + }); + + final String? label; + final FlowySvgData? icon; + final String? tooltip; + final bool isHighlighted; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12) + + EdgeInsets.only(left: isHighlighted ? 12 : 0), + height: 36, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + ), + child: Row( + children: [ + if (icon != null) ...[ + FlowySvg( + icon!, + color: AFThemeExtension.of(context).strongText, + ), + ] else if (label != null) ...[ + Expanded( + child: FlowyText.medium( + label!, + lineHeight: 1.2, + color: AFThemeExtension.of(context).strongText, + ), + ), + ], + if (tooltip != null) + FlowyTooltip( + message: tooltip, + child: FlowySvg( + FlowySvgs.information_s, + color: AFThemeExtension.of(context).strongText, + ), + ), + ], + ), + ); + } +} + +class _ActionButton extends StatelessWidget { + const _ActionButton({ + required this.label, + required this.onPressed, + required this.isUpgrade, + this.useGradientBorder = false, + }); + + final String label; + final VoidCallback? onPressed; + final bool isUpgrade; + final bool useGradientBorder; + + @override + Widget build(BuildContext context) { + final isLM = Theme.of(context).isLightMode; + + return SizedBox( + height: 56, + child: Row( + children: [ + GestureDetector( + onTap: onPressed, + child: MouseRegion( + cursor: onPressed != null + ? SystemMouseCursors.click + : MouseCursor.defer, + child: _drawBorder( + context, + isLM: isLM, + isUpgrade: isUpgrade, + child: Container( + height: 36, + width: 148, + decoration: BoxDecoration( + color: useGradientBorder + ? Theme.of(context).cardColor + : Colors.transparent, + border: Border.all(color: Colors.transparent), + borderRadius: BorderRadius.circular(14), + ), + child: Center(child: _drawText(label, isLM, isUpgrade)), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _drawText(String text, bool isLM, bool isUpgrade) { + final child = FlowyText( + text, + fontSize: 14, + lineHeight: 1.2, + fontWeight: useGradientBorder ? FontWeight.w600 : FontWeight.w500, + color: isUpgrade ? const Color(0xFFC49BEC) : null, + ); + + if (!useGradientBorder || !isLM) { + return child; + } + + return ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => const LinearGradient( + transform: GradientRotation(-1.55), + stops: [0.4, 1], + colors: [Color(0xFF251D37), Color(0xFF7547C0)], + ).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)), + child: child, + ); + } + + Widget _drawBorder( + BuildContext context, { + required bool isLM, + required bool isUpgrade, + required Widget child, + }) { + return Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + gradient: isUpgrade + ? LinearGradient( + transform: const GradientRotation(-1.2), + stops: const [0.4, 1], + colors: [ + isLM ? const Color(0xFF251D37) : const Color(0xFF7459AD), + isLM ? const Color(0xFF7547C0) : const Color(0xFFDDC8FF), + ], + ) + : null, + border: isUpgrade ? null : Border.all(color: const Color(0xFF333333)), + borderRadius: BorderRadius.circular(16), + ), + child: child, + ); + } +} + +class _Heading extends StatelessWidget { + const _Heading({ + required this.title, + this.description, + this.isPrimary = true, + }); + + final String title; + final String? description; + final bool isPrimary; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 185, + height: 116, + child: Padding( + padding: EdgeInsets.only(left: 12 + (!isPrimary ? 12 : 0)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: FlowyText.semibold( + title, + fontSize: 24, + overflow: TextOverflow.ellipsis, + color: isPrimary + ? AFThemeExtension.of(context).strongText + : Theme.of(context).isLightMode + ? const Color(0xFF5C3699) + : const Color(0xFFC49BEC), + ), + ), + ], + ), + if (description != null && description!.isNotEmpty) ...[ + const VSpace(4), + Flexible( + child: FlowyText.regular( + description!, + fontSize: 12, + maxLines: 5, + lineHeight: 1.5, + ), + ), + ], + ], + ), + ), + ); + } +} + +class _PlanItem { + const _PlanItem({required this.label, this.tooltip}); + + final String label; + final String? tooltip; +} + +final _planLabels = [ + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemOne.tr(), + ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemTwo.tr(), + ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemThree.tr(), + ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFour.tr(), + ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFive.tr(), + ), + _PlanItem( + label: + LocaleKeys.settings_comparePlanDialog_planLabels_intelligentSearch.tr(), + ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemSix.tr(), + tooltip: LocaleKeys.settings_comparePlanDialog_planLabels_tooltipSix.tr(), + ), + _PlanItem( + label: LocaleKeys.settings_comparePlanDialog_planLabels_itemFileUpload.tr(), + ), + _PlanItem( + label: + LocaleKeys.settings_comparePlanDialog_planLabels_customNamespace.tr(), + tooltip: LocaleKeys + .settings_comparePlanDialog_planLabels_customNamespaceTooltip + .tr(), + ), +]; + +class _CellItem { + const _CellItem({this.label, this.icon}); + + final String? label; + final FlowySvgData? icon; +} + +final List<_CellItem> _freeLabels = [ + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemOne.tr(), + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemTwo.tr(), + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemThree.tr(), + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemFour.tr(), + icon: FlowySvgs.check_m, + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemFive.tr(), + icon: FlowySvgs.check_m, + ), + _CellItem( + label: + LocaleKeys.settings_comparePlanDialog_freeLabels_intelligentSearch.tr(), + icon: FlowySvgs.check_m, + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemSix.tr(), + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_freeLabels_itemFileUpload.tr(), + ), + const _CellItem( + label: '', + ), +]; + +final List<_CellItem> _proLabels = [ + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemOne.tr(), + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemTwo.tr(), + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemThree.tr(), + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemFour.tr(), + icon: FlowySvgs.check_m, + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemFive.tr(), + icon: FlowySvgs.check_m, + ), + _CellItem( + label: + LocaleKeys.settings_comparePlanDialog_proLabels_intelligentSearch.tr(), + icon: FlowySvgs.check_m, + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemSix.tr(), + ), + _CellItem( + label: LocaleKeys.settings_comparePlanDialog_proLabels_itemFileUpload.tr(), + ), + const _CellItem( + label: '', + icon: FlowySvgs.check_m, + ), +]; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart new file mode 100644 index 0000000000000..61cb83d1ae62a --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_plan_view.dart @@ -0,0 +1,984 @@ +import 'dart:io'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/colors.dart'; +import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/util/int64_extension.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/application/settings/plan/settings_plan_bloc.dart'; +import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; +import 'package:appflowy/workspace/application/settings/plan/workspace_usage_ext.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_comparison_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/flowy_gradient_button.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; + +class SettingsPlanView extends StatefulWidget { + const SettingsPlanView({ + super.key, + required this.workspaceId, + required this.user, + }); + + final String workspaceId; + final UserProfilePB user; + + @override + State createState() => _SettingsPlanViewState(); +} + +class _SettingsPlanViewState extends State { + Loading? loadingIndicator; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SettingsPlanBloc( + workspaceId: widget.workspaceId, + userId: widget.user.id, + )..add(const SettingsPlanEvent.started()), + child: BlocConsumer( + listenWhen: (previous, current) => + previous.mapOrNull(ready: (s) => s.downgradeProcessing) != + current.mapOrNull(ready: (s) => s.downgradeProcessing), + listener: (context, state) { + if (state.mapOrNull(ready: (s) => s.downgradeProcessing) == true) { + loadingIndicator = Loading(context)..start(); + } else { + loadingIndicator?.stop(); + loadingIndicator = null; + } + }, + builder: (context, state) { + return state.map( + initial: (_) => const SizedBox.shrink(), + loading: (_) => const Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 3), + ), + ), + error: (state) { + if (state.error != null) { + return Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: AppFlowyErrorPage( + error: state.error!, + ), + ), + ); + } + + return ErrorWidget.withDetails(message: 'Something went wrong!'); + }, + ready: (state) => SettingsBody( + autoSeparate: false, + title: LocaleKeys.settings_planPage_title.tr(), + children: [ + _PlanUsageSummary( + usage: state.workspaceUsage, + subscriptionInfo: state.subscriptionInfo, + ), + const VSpace(16), + _CurrentPlanBox(subscriptionInfo: state.subscriptionInfo), + const VSpace(16), + FlowyText( + LocaleKeys.settings_planPage_planUsage_addons_title.tr(), + fontSize: 18, + color: AFThemeExtension.of(context).strongText, + fontWeight: FontWeight.w600, + ), + const VSpace(8), + Row( + children: [ + Flexible( + child: _AddOnBox( + title: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_title + .tr(), + description: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_description + .tr(), + price: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_price + .tr( + args: [SubscriptionPlanPB.AiMax.priceAnnualBilling], + ), + priceInfo: LocaleKeys + .settings_planPage_planUsage_addons_aiMax_priceInfo + .tr(), + recommend: '', + buttonText: state.subscriptionInfo.hasAIMax + ? LocaleKeys + .settings_planPage_planUsage_addons_activeLabel + .tr() + : LocaleKeys + .settings_planPage_planUsage_addons_addLabel + .tr(), + isActive: state.subscriptionInfo.hasAIMax, + plan: SubscriptionPlanPB.AiMax, + ), + ), + const HSpace(8), + + // Currently, the AI Local tile is only available on macOS + // TODO(nathan): enable windows and linux + if (Platform.isMacOS) + Flexible( + child: _AddOnBox( + title: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_title + .tr(), + description: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_description + .tr(), + price: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_price + .tr( + args: [ + SubscriptionPlanPB.AiLocal.priceAnnualBilling, + ], + ), + priceInfo: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_priceInfo + .tr(), + recommend: LocaleKeys + .settings_planPage_planUsage_addons_aiOnDevice_recommend + .tr( + args: [ + SubscriptionPlanPB.AiLocal.priceMonthBilling, + ], + ), + buttonText: state.subscriptionInfo.hasAIOnDevice + ? LocaleKeys + .settings_planPage_planUsage_addons_activeLabel + .tr() + : LocaleKeys + .settings_planPage_planUsage_addons_addLabel + .tr(), + isActive: state.subscriptionInfo.hasAIOnDevice, + plan: SubscriptionPlanPB.AiLocal, + ), + ), + ], + ), + ], + ), + ); + }, + ), + ); + } +} + +class _CurrentPlanBox extends StatefulWidget { + const _CurrentPlanBox({required this.subscriptionInfo}); + + final WorkspaceSubscriptionInfoPB subscriptionInfo; + + @override + State<_CurrentPlanBox> createState() => _CurrentPlanBoxState(); +} + +class _CurrentPlanBoxState extends State<_CurrentPlanBox> { + late SettingsPlanBloc planBloc; + + @override + void initState() { + super.initState(); + planBloc = context.read(); + } + + @override + void didChangeDependencies() { + planBloc = context.read(); + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Container( + margin: const EdgeInsets.only(top: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFBDBDBD)), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + flex: 6, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const VSpace(4), + FlowyText.semibold( + widget.subscriptionInfo.label, + fontSize: 24, + color: AFThemeExtension.of(context).strongText, + ), + const VSpace(8), + FlowyText.regular( + widget.subscriptionInfo.info, + fontSize: 14, + color: AFThemeExtension.of(context).strongText, + maxLines: 3, + ), + ], + ), + ), + Flexible( + flex: 5, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: FlowyGradientButton( + label: LocaleKeys + .settings_planPage_planUsage_currentPlan_upgrade + .tr(), + onPressed: () => _openPricingDialog( + context, + context.read().workspaceId, + widget.subscriptionInfo, + ), + ), + ), + ], + ), + ), + ], + ), + if (widget.subscriptionInfo.isCanceled) ...[ + const VSpace(12), + FlowyText( + LocaleKeys + .settings_planPage_planUsage_currentPlan_canceledInfo + .tr( + args: [_canceledDate(context)], + ), + maxLines: 5, + fontSize: 12, + color: Theme.of(context).colorScheme.error, + ), + ], + ], + ), + ), + Positioned( + top: 0, + left: 0, + child: Container( + height: 30, + padding: const EdgeInsets.symmetric(horizontal: 24), + decoration: const BoxDecoration( + color: Color(0xFF4F3F5F), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(4), + bottomRight: Radius.circular(4), + ), + ), + child: Center( + child: FlowyText.semibold( + LocaleKeys.settings_planPage_planUsage_currentPlan_bannerLabel + .tr(), + fontSize: 14, + color: Colors.white, + ), + ), + ), + ), + ], + ); + } + + String _canceledDate(BuildContext context) { + final appearance = context.read().state; + return appearance.dateFormat.formatDate( + widget.subscriptionInfo.planSubscription.endDate.toDateTime(), + false, + ); + } + + void _openPricingDialog( + BuildContext context, + String workspaceId, + WorkspaceSubscriptionInfoPB subscriptionInfo, + ) => + showDialog( + context: context, + builder: (_) => BlocProvider.value( + value: planBloc, + child: SettingsPlanComparisonDialog( + workspaceId: workspaceId, + subscriptionInfo: subscriptionInfo, + ), + ), + ); +} + +class _PlanUsageSummary extends StatelessWidget { + const _PlanUsageSummary({ + required this.usage, + required this.subscriptionInfo, + }); + + final WorkspaceUsagePB usage; + final WorkspaceSubscriptionInfoPB subscriptionInfo; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.semibold( + LocaleKeys.settings_planPage_planUsage_title.tr(), + maxLines: 2, + fontSize: 16, + overflow: TextOverflow.ellipsis, + color: AFThemeExtension.of(context).secondaryTextColor, + ), + const VSpace(16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _UsageBox( + title: LocaleKeys.settings_planPage_planUsage_storageLabel.tr(), + unlimitedLabel: LocaleKeys + .settings_planPage_planUsage_unlimitedStorageLabel + .tr(), + unlimited: usage.storageBytesUnlimited, + label: LocaleKeys.settings_planPage_planUsage_storageUsage.tr( + args: [ + usage.currentBlobInGb, + usage.totalBlobInGb, + ], + ), + value: usage.storageBytes.toInt() / + usage.storageBytesLimit.toInt(), + ), + ), + Expanded( + child: _UsageBox( + title: + LocaleKeys.settings_planPage_planUsage_aiResponseLabel.tr(), + label: + LocaleKeys.settings_planPage_planUsage_aiResponseUsage.tr( + args: [ + usage.aiResponsesCount.toString(), + usage.aiResponsesCountLimit.toString(), + ], + ), + unlimitedLabel: LocaleKeys + .settings_planPage_planUsage_unlimitedAILabel + .tr(), + unlimited: usage.aiResponsesUnlimited, + value: usage.aiResponsesCount.toInt() / + usage.aiResponsesCountLimit.toInt(), + ), + ), + ], + ), + const VSpace(16), + SeparatedColumn( + crossAxisAlignment: CrossAxisAlignment.start, + separatorBuilder: () => const VSpace(4), + children: [ + if (subscriptionInfo.plan == WorkspacePlanPB.FreePlan) ...[ + _ToggleMore( + value: false, + label: + LocaleKeys.settings_planPage_planUsage_memberProToggle.tr(), + badgeLabel: + LocaleKeys.settings_planPage_planUsage_proBadge.tr(), + onTap: () async { + context.read().add( + const SettingsPlanEvent.addSubscription( + SubscriptionPlanPB.Pro, + ), + ); + await Future.delayed(const Duration(seconds: 2), () {}); + }, + ), + ], + if (!subscriptionInfo.hasAIMax && !usage.aiResponsesUnlimited) ...[ + _ToggleMore( + value: false, + label: LocaleKeys.settings_planPage_planUsage_aiMaxToggle.tr(), + badgeLabel: + LocaleKeys.settings_planPage_planUsage_aiMaxBadge.tr(), + onTap: () async { + context.read().add( + const SettingsPlanEvent.addSubscription( + SubscriptionPlanPB.AiMax, + ), + ); + await Future.delayed(const Duration(seconds: 2), () {}); + }, + ), + ], + if (!subscriptionInfo.hasAIOnDevice) ...[ + _ToggleMore( + value: false, + label: LocaleKeys.settings_planPage_planUsage_aiOnDeviceToggle + .tr(), + badgeLabel: + LocaleKeys.settings_planPage_planUsage_aiOnDeviceBadge.tr(), + onTap: () async { + context.read().add( + const SettingsPlanEvent.addSubscription( + SubscriptionPlanPB.AiLocal, + ), + ); + await Future.delayed(const Duration(seconds: 2), () {}); + }, + ), + ], + ], + ), + ], + ); + } +} + +class _UsageBox extends StatelessWidget { + const _UsageBox({ + required this.title, + required this.label, + required this.value, + required this.unlimitedLabel, + this.unlimited = false, + }); + + final String title; + final String label; + final double value; + + final String unlimitedLabel; + + // Replaces the progress bar with an unlimited badge + final bool unlimited; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.medium( + title, + fontSize: 11, + color: AFThemeExtension.of(context).secondaryTextColor, + ), + if (unlimited) ...[ + Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.check_circle_outlined_s, + color: Color(0xFF9C00FB), + ), + const HSpace(4), + FlowyText( + unlimitedLabel, + fontWeight: FontWeight.w500, + fontSize: 11, + ), + ], + ), + ), + ] else ...[ + const VSpace(4), + _PlanProgressIndicator(label: label, progress: value), + ], + ], + ); + } +} + +class _ToggleMore extends StatefulWidget { + const _ToggleMore({ + required this.value, + required this.label, + this.badgeLabel, + this.onTap, + }); + + final bool value; + final String label; + final String? badgeLabel; + final Future Function()? onTap; + + @override + State<_ToggleMore> createState() => _ToggleMoreState(); +} + +class _ToggleMoreState extends State<_ToggleMore> { + late bool toggleValue = widget.value; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Toggle( + value: toggleValue, + padding: EdgeInsets.zero, + onChanged: (_) async { + if (widget.onTap == null || toggleValue) { + return; + } + + setState(() => toggleValue = !toggleValue); + await widget.onTap!(); + + if (mounted) { + setState(() => toggleValue = !toggleValue); + } + }, + ), + const HSpace(10), + FlowyText.regular( + widget.label, + fontSize: 14, + color: AFThemeExtension.of(context).strongText, + ), + if (widget.badgeLabel != null && widget.badgeLabel!.isNotEmpty) ...[ + const HSpace(10), + SizedBox( + height: 26, + child: Badge( + padding: const EdgeInsets.symmetric(horizontal: 10), + backgroundColor: context.proSecondaryColor, + label: FlowyText.semibold( + widget.badgeLabel!, + fontSize: 12, + color: context.proPrimaryColor, + ), + ), + ), + ], + ], + ); + } +} + +class _PlanProgressIndicator extends StatelessWidget { + const _PlanProgressIndicator({required this.label, required this.progress}); + + final String label; + final double progress; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Expanded( + child: Container( + height: 8, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: AFThemeExtension.of(context).progressBarBGColor, + border: Border.all( + color: const Color(0xFFDDF1F7).withOpacity( + theme.brightness == Brightness.light ? 1 : 0.1, + ), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + children: [ + FractionallySizedBox( + widthFactor: progress, + child: Container( + decoration: BoxDecoration( + color: progress >= 1 + ? theme.colorScheme.error + : theme.colorScheme.primary, + ), + ), + ), + ], + ), + ), + ), + ), + const HSpace(8), + FlowyText.medium( + label, + fontSize: 11, + color: AFThemeExtension.of(context).secondaryTextColor, + ), + const HSpace(16), + ], + ); + } +} + +class _AddOnBox extends StatelessWidget { + const _AddOnBox({ + required this.title, + required this.description, + required this.price, + required this.priceInfo, + required this.recommend, + required this.buttonText, + required this.isActive, + required this.plan, + }); + + final String title; + final String description; + final String price; + final String priceInfo; + final String recommend; + final String buttonText; + final bool isActive; + final SubscriptionPlanPB plan; + + @override + Widget build(BuildContext context) { + final isLM = Theme.of(context).isLightMode; + + return Container( + height: 220, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + border: Border.all( + color: isActive ? const Color(0xFFBDBDBD) : const Color(0xFF9C00FB), + ), + color: const Color(0xFFF7F8FC).withOpacity(0.05), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.semibold( + title, + fontSize: 14, + color: AFThemeExtension.of(context).strongText, + ), + const VSpace(10), + FlowyText.regular( + description, + fontSize: 12, + color: AFThemeExtension.of(context).secondaryTextColor, + maxLines: 4, + ), + const VSpace(10), + FlowyText( + price, + fontSize: 24, + color: AFThemeExtension.of(context).strongText, + ), + FlowyText( + priceInfo, + fontSize: 12, + color: AFThemeExtension.of(context).strongText, + ), + const VSpace(12), + Row( + children: [ + Expanded( + child: FlowyText( + recommend, + color: AFThemeExtension.of(context).secondaryTextColor, + fontSize: 11, + maxLines: 2, + ), + ), + ], + ), + const Spacer(), + Row( + children: [ + Expanded( + child: FlowyTextButton( + buttonText, + heading: isActive + ? const FlowySvg( + FlowySvgs.check_circle_outlined_s, + color: Color(0xFF9C00FB), + ) + : null, + mainAxisAlignment: MainAxisAlignment.center, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + fillColor: isActive + ? const Color(0xFFE8E2EE) + : isLM + ? Colors.transparent + : const Color(0xFF5C3699), + constraints: const BoxConstraints(minWidth: 115), + radius: Corners.s16Border, + hoverColor: isActive + ? const Color(0xFFE8E2EE) + : isLM + ? const Color(0xFF5C3699) + : const Color(0xFF4d3472), + fontColor: + isLM || isActive ? const Color(0xFF5C3699) : Colors.white, + fontHoverColor: + isActive ? const Color(0xFF5C3699) : Colors.white, + borderColor: isActive + ? const Color(0xFFE8E2EE) + : isLM + ? const Color(0xFF5C3699) + : const Color(0xFF4d3472), + fontSize: 12, + onPressed: isActive + ? null + : () => context + .read() + .add(SettingsPlanEvent.addSubscription(plan)), + ), + ), + ], + ), + ], + ), + ); + } +} + +/// Uncomment if we need it in the future +// class _DealBox extends StatelessWidget { +// const _DealBox(); + +// @override +// Widget build(BuildContext context) { +// final isLM = Theme.of(context).brightness == Brightness.light; + +// return Container( +// clipBehavior: Clip.antiAlias, +// decoration: BoxDecoration( +// gradient: LinearGradient( +// stops: isLM ? null : [.2, .3, .6], +// transform: isLM ? null : const GradientRotation(-.9), +// begin: isLM ? Alignment.centerLeft : Alignment.topRight, +// end: isLM ? Alignment.centerRight : Alignment.bottomLeft, +// colors: [ +// isLM +// ? const Color(0xFF7547C0).withAlpha(60) +// : const Color(0xFF7547C0), +// if (!isLM) const Color.fromARGB(255, 94, 57, 153), +// isLM +// ? const Color(0xFF251D37).withAlpha(60) +// : const Color(0xFF251D37), +// ], +// ), +// borderRadius: BorderRadius.circular(16), +// ), +// child: Stack( +// children: [ +// Padding( +// padding: const EdgeInsets.all(16), +// child: Row( +// children: [ +// Expanded( +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// const VSpace(18), +// FlowyText.semibold( +// LocaleKeys.settings_planPage_planUsage_deal_title.tr(), +// fontSize: 24, +// color: Theme.of(context).colorScheme.tertiary, +// ), +// const VSpace(8), +// FlowyText.medium( +// LocaleKeys.settings_planPage_planUsage_deal_info.tr(), +// maxLines: 6, +// color: Theme.of(context).colorScheme.tertiary, +// ), +// const VSpace(8), +// FlowyGradientButton( +// label: LocaleKeys +// .settings_planPage_planUsage_deal_viewPlans +// .tr(), +// fontWeight: FontWeight.w500, +// backgroundColor: isLM ? null : Colors.white, +// textColor: isLM +// ? Colors.white +// : Theme.of(context).colorScheme.onPrimary, +// ), +// ], +// ), +// ), +// ], +// ), +// ), +// Positioned( +// right: 0, +// top: 9, +// child: Container( +// height: 32, +// padding: const EdgeInsets.symmetric(horizontal: 16), +// decoration: BoxDecoration( +// gradient: LinearGradient( +// transform: const GradientRotation(.7), +// colors: [ +// if (isLM) const Color(0xFF7156DF), +// isLM +// ? const Color(0xFF3B2E8A) +// : const Color(0xFFCE006F).withAlpha(150), +// isLM ? const Color(0xFF261A48) : const Color(0xFF431459), +// ], +// ), +// ), +// child: Center( +// child: FlowyText.semibold( +// LocaleKeys.settings_planPage_planUsage_deal_bannerLabel.tr(), +// fontSize: 16, +// color: Colors.white, +// ), +// ), +// ), +// ), +// ], +// ), +// ); +// } +// } + +/// Uncomment if we need it in the future +// class _AddAICreditBox extends StatelessWidget { +// const _AddAICreditBox(); + +// @override +// Widget build(BuildContext context) { +// return DecoratedBox( +// decoration: BoxDecoration( +// border: Border.all(color: const Color(0xFFBDBDBD)), +// borderRadius: BorderRadius.circular(16), +// ), +// child: Padding( +// padding: const EdgeInsets.all(16), +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// FlowyText.semibold( +// LocaleKeys.settings_planPage_planUsage_aiCredit_title.tr(), +// fontSize: 18, +// color: AFThemeExtension.of(context).secondaryTextColor, +// ), +// const VSpace(8), +// Row( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Flexible( +// flex: 5, +// child: ConstrainedBox( +// constraints: const BoxConstraints(maxWidth: 180), +// child: Column( +// mainAxisSize: MainAxisSize.min, +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// FlowyText.semibold( +// LocaleKeys.settings_planPage_planUsage_aiCredit_price +// .tr(args: ['5\$]), +// fontSize: 24, +// ), +// FlowyText.medium( +// LocaleKeys +// .settings_planPage_planUsage_aiCredit_priceDescription +// .tr(), +// fontSize: 14, +// color: +// AFThemeExtension.of(context).secondaryTextColor, +// ), +// const VSpace(8), +// FlowyGradientButton( +// label: LocaleKeys +// .settings_planPage_planUsage_aiCredit_purchase +// .tr(), +// ), +// ], +// ), +// ), +// ), +// const HSpace(16), +// Flexible( +// flex: 6, +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// mainAxisSize: MainAxisSize.min, +// children: [ +// FlowyText.regular( +// LocaleKeys.settings_planPage_planUsage_aiCredit_info +// .tr(), +// overflow: TextOverflow.ellipsis, +// maxLines: 5, +// ), +// const VSpace(8), +// SeparatedColumn( +// separatorBuilder: () => const VSpace(4), +// children: [ +// _AIStarItem( +// label: LocaleKeys +// .settings_planPage_planUsage_aiCredit_infoItemOne +// .tr(), +// ), +// _AIStarItem( +// label: LocaleKeys +// .settings_planPage_planUsage_aiCredit_infoItemTwo +// .tr(), +// ), +// ], +// ), +// ], +// ), +// ), +// ], +// ), +// ], +// ), +// ), +// ); +// } +// } + +/// Uncomment if we need it in the future +// class _AIStarItem extends StatelessWidget { +// const _AIStarItem({required this.label}); + +// final String label; + +// @override +// Widget build(BuildContext context) { +// return Row( +// children: [ +// const FlowySvg(FlowySvgs.ai_star_s, color: Color(0xFF750D7E)), +// const HSpace(4), +// Expanded(child: FlowyText(label, maxLines: 2)), +// ], +// ); +// } +// } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart new file mode 100644 index 0000000000000..87ea9d9260392 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_shortcuts_view.dart @@ -0,0 +1,747 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/custom_cut_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcuts.dart'; +import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart'; +import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_shortcut_event.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/error_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class SettingsShortcutsView extends StatefulWidget { + const SettingsShortcutsView({super.key}); + + @override + State createState() => _SettingsShortcutsViewState(); +} + +class _SettingsShortcutsViewState extends State { + String _query = ''; + bool _isEditing = false; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => + ShortcutsCubit(SettingsShortcutService())..fetchShortcuts(), + child: Builder( + builder: (context) => SettingsBody( + title: LocaleKeys.settings_shortcutsPage_title.tr(), + autoSeparate: false, + children: [ + Row( + children: [ + Flexible( + child: _SearchBar( + onSearchChanged: (v) => setState(() => _query = v), + ), + ), + const HSpace(10), + _ResetButton( + onReset: () => SettingsAlertDialog( + isDangerous: true, + title: LocaleKeys.settings_shortcutsPage_resetDialog_title + .tr(), + subtitle: LocaleKeys + .settings_shortcutsPage_resetDialog_description + .tr(), + confirmLabel: LocaleKeys + .settings_shortcutsPage_resetDialog_buttonLabel + .tr(), + confirm: () { + Navigator.of(context).pop(); + context.read().resetToDefault(); + }, + ).show(context), + ), + ], + ), + BlocBuilder( + builder: (context, state) { + final filtered = state.commandShortcutEvents + .where( + (e) => e.afLabel + .toLowerCase() + .contains(_query.toLowerCase()), + ) + .toList(); + + return Column( + children: [ + const VSpace(16), + if (state.status.isLoading) ...[ + const CircularProgressIndicator(), + ] else if (state.status.isFailure) ...[ + FlowyErrorPage.message( + LocaleKeys.settings_shortcutsPage_errorPage_message + .tr(args: [state.error]), + howToFix: LocaleKeys + .settings_shortcutsPage_errorPage_howToFix + .tr(), + ), + ] else ...[ + ListView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: filtered.length, + itemBuilder: (context, index) => ShortcutSettingTile( + command: filtered[index], + canStartEditing: () => !_isEditing, + onStartEditing: () => + setState(() => _isEditing = true), + onFinishEditing: () => + setState(() => _isEditing = false), + ), + ), + ], + ], + ); + }, + ), + ], + ), + ), + ); + } +} + +class _SearchBar extends StatelessWidget { + const _SearchBar({this.onSearchChanged}); + + final void Function(String)? onSearchChanged; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 36, + child: FlowyTextField( + onChanged: onSearchChanged, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + ), + decoration: InputDecoration( + hintText: LocaleKeys.settings_shortcutsPage_searchHint.tr(), + counterText: '', + contentPadding: const EdgeInsets.symmetric( + vertical: 9, + horizontal: 16, + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + borderRadius: Corners.s12Border, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + borderRadius: Corners.s12Border, + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + borderRadius: Corners.s12Border, + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + borderRadius: Corners.s12Border, + ), + ), + ), + ); + } +} + +class _ResetButton extends StatelessWidget { + const _ResetButton({this.onReset}); + + final void Function()? onReset; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onReset, + child: FlowyHover( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 6, + ), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.restore_s, + size: Size.square(20), + ), + const HSpace(6), + SizedBox( + height: 16, + child: FlowyText.regular( + LocaleKeys.settings_shortcutsPage_actions_resetDefault.tr(), + color: AFThemeExtension.of(context).strongText, + ), + ), + ], + ), + ), + ), + ); + } +} + +class ShortcutSettingTile extends StatefulWidget { + const ShortcutSettingTile({ + super.key, + required this.command, + required this.onStartEditing, + required this.onFinishEditing, + required this.canStartEditing, + }); + + final CommandShortcutEvent command; + final VoidCallback onStartEditing; + final VoidCallback onFinishEditing; + final bool Function() canStartEditing; + + @override + State createState() => _ShortcutSettingTileState(); +} + +class _ShortcutSettingTileState extends State { + final keybindController = TextEditingController(); + + late final FocusNode focusNode; + + bool isHovering = false; + bool isEditing = false; + bool canClickOutside = false; + + @override + void initState() { + super.initState(); + focusNode = FocusNode( + onKeyEvent: (focusNode, key) { + if (key is! KeyDownEvent && key is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + if (key.logicalKey == LogicalKeyboardKey.enter && + !HardwareKeyboard.instance.isShiftPressed) { + if (keybindController.text == widget.command.command) { + _finishEditing(); + return KeyEventResult.handled; + } + + final conflict = context.read().getConflict( + widget.command, + keybindController.text, + ); + + if (conflict != null) { + canClickOutside = true; + SettingsAlertDialog( + title: LocaleKeys.settings_shortcutsPage_conflictDialog_title + .tr(args: [keybindController.text]), + confirm: () { + conflict.clearCommand(); + _updateCommand(); + Navigator.of(context).pop(); + }, + confirmLabel: LocaleKeys + .settings_shortcutsPage_conflictDialog_confirmLabel + .tr(), + children: [ + RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 16, + fontWeight: FontWeight.normal, + ), + children: [ + TextSpan( + text: LocaleKeys + .settings_shortcutsPage_conflictDialog_descriptionPrefix + .tr(), + ), + TextSpan( + text: conflict.afLabel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: LocaleKeys + .settings_shortcutsPage_conflictDialog_descriptionSuffix + .tr(args: [keybindController.text]), + ), + ], + ), + ), + ], + ).show(context).then((_) => canClickOutside = false); + } else { + _updateCommand(); + } + } else if (key.logicalKey == LogicalKeyboardKey.escape) { + _finishEditing(); + } else { + // Extract complete keybinding + setState(() => keybindController.text = key.toCommand); + } + + return KeyEventResult.handled; + }, + ); + } + + void _finishEditing() => setState(() { + isEditing = false; + keybindController.clear(); + widget.onFinishEditing(); + }); + + void _updateCommand() { + widget.command.updateCommand(command: keybindController.text); + context.read().updateAllShortcuts(); + _finishEditing(); + } + + @override + void dispose() { + focusNode.dispose(); + keybindController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: FlowyHover( + cursor: MouseCursor.defer, + style: HoverStyle( + hoverColor: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.zero, + ), + resetHoverOnRebuild: false, + builder: (context, isHovering) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + const HSpace(8), + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 10), + child: FlowyText.regular( + widget.command.afLabel, + fontSize: 14, + lineHeight: 1, + maxLines: 2, + color: AFThemeExtension.of(context).strongText, + ), + ), + ), + Expanded( + child: isEditing + ? _renderKeybindEditor() + : _renderKeybindings(isHovering), + ), + ], + ), + ), + ), + ); + } + + Widget _renderKeybindings(bool isHovering) => Row( + children: [ + if (widget.command.keybindings.isNotEmpty) ...[ + ..._toParts(widget.command.keybindings.first).map( + (key) => KeyBadge(keyLabel: key), + ), + ] else ...[ + const SizedBox(height: 24), + ], + const Spacer(), + if (isHovering) + GestureDetector( + onTap: () { + if (widget.canStartEditing()) { + setState(() { + widget.onStartEditing(); + isEditing = true; + }); + } + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowyTooltip( + message: LocaleKeys.settings_shortcutsPage_editTooltip.tr(), + child: const FlowySvg( + FlowySvgs.edit_s, + size: Size.square(16), + ), + ), + ), + ), + const HSpace(8), + ], + ); + + Widget _renderKeybindEditor() => TapRegion( + onTapOutside: canClickOutside ? null : (_) => _finishEditing(), + child: FlowyTextField( + focusNode: focusNode, + controller: keybindController, + hintText: LocaleKeys.settings_shortcutsPage_editBindingHint.tr(), + onChanged: (_) => setState(() {}), + suffixIcon: keybindController.text.isNotEmpty + ? MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => setState(() => keybindController.clear()), + child: const FlowySvg( + FlowySvgs.close_s, + size: Size.square(10), + ), + ), + ) + : null, + ), + ); + + List _toParts(Keybinding binding) { + final List keys = []; + + if (binding.isControlPressed) { + keys.add('ctrl'); + } + if (binding.isMetaPressed) { + keys.add('meta'); + } + if (binding.isShiftPressed) { + keys.add('shift'); + } + if (binding.isAltPressed) { + keys.add('alt'); + } + + return keys..add(binding.keyLabel); + } +} + +@visibleForTesting +class KeyBadge extends StatelessWidget { + const KeyBadge({super.key, required this.keyLabel}); + + final String keyLabel; + + @override + Widget build(BuildContext context) { + if (iconData == null && keyLabel.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + height: 24, + margin: const EdgeInsets.only(right: 4), + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greySelect, + borderRadius: Corners.s4Border, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.25), + blurRadius: 1, + offset: const Offset(0, 1), + ), + ], + ), + child: Center( + child: iconData != null + ? FlowySvg(iconData!, color: Colors.black) + : FlowyText.medium( + keyLabel.toLowerCase(), + fontSize: 12, + color: Colors.black, + ), + ), + ); + } + + FlowySvgData? get iconData => switch (keyLabel) { + 'meta' => FlowySvgs.keyboard_meta_s, + 'arrow left' => FlowySvgs.keyboard_arrow_left_s, + 'arrow right' => FlowySvgs.keyboard_arrow_right_s, + 'arrow up' => FlowySvgs.keyboard_arrow_up_s, + 'arrow down' => FlowySvgs.keyboard_arrow_down_s, + 'shift' => FlowySvgs.keyboard_shift_s, + 'tab' => FlowySvgs.keyboard_tab_s, + 'enter' || 'return' => FlowySvgs.keyboard_return_s, + 'opt' || 'option' => FlowySvgs.keyboard_option_s, + _ => null, + }; +} + +extension ToCommand on KeyEvent { + String get toCommand { + String command = ''; + if (HardwareKeyboard.instance.isControlPressed) { + command += 'ctrl+'; + } + if (HardwareKeyboard.instance.isMetaPressed) { + command += 'meta+'; + } + if (HardwareKeyboard.instance.isShiftPressed) { + command += 'shift+'; + } + if (HardwareKeyboard.instance.isAltPressed) { + command += 'alt+'; + } + + if ([ + LogicalKeyboardKey.control, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + LogicalKeyboardKey.meta, + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.metaRight, + LogicalKeyboardKey.alt, + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.altRight, + LogicalKeyboardKey.shift, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + ].contains(logicalKey)) { + return command; + } + + final keyPressed = keyToCodeMapping.keys.firstWhere( + (k) => keyToCodeMapping[k] == logicalKey.keyId, + orElse: () => '', + ); + + return command += keyPressed; + } +} + +extension CommandLabel on CommandShortcutEvent { + String get afLabel { + String? label; + + if (key == toggleToggleListCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_toggleToDoList.tr(); + } else if (key == insertNewParagraphNextToCodeBlockCommand('').key) { + label = LocaleKeys + .settings_shortcutsPage_keybindings_insertNewParagraphInCodeblock + .tr(); + } else if (key == pasteInCodeblock('').key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_pasteInCodeblock.tr(); + } else if (key == selectAllInCodeBlockCommand('').key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_selectAllCodeblock.tr(); + } else if (key == tabToInsertSpacesInCodeBlockCommand('').key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_indentLineCodeblock + .tr(); + } else if (key == tabToDeleteSpacesInCodeBlockCommand('').key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_outdentLineCodeblock + .tr(); + } else if (key == tabSpacesAtCurosrInCodeBlockCommand('').key) { + label = LocaleKeys + .settings_shortcutsPage_keybindings_twoSpacesCursorCodeblock + .tr(); + } else if (key == customCopyCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_copy.tr(); + } else if (key == customPasteCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_paste.tr(); + } else if (key == customCutCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_cut.tr(); + } else if (key == customTextLeftAlignCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_alignLeft.tr(); + } else if (key == customTextCenterAlignCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_alignCenter.tr(); + } else if (key == customTextRightAlignCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_alignRight.tr(); + } else if (key == undoCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_undo.tr(); + } else if (key == redoCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_redo.tr(); + } else if (key == convertToParagraphCommand.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_convertToParagraph.tr(); + } else if (key == backspaceCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_backspace.tr(); + } else if (key == deleteLeftWordCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_deleteLeftWord.tr(); + } else if (key == deleteLeftSentenceCommand.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_deleteLeftSentence.tr(); + } else if (key == deleteCommand.key) { + label = UniversalPlatform.isMacOS + ? LocaleKeys.settings_shortcutsPage_keybindings_deleteMacOS.tr() + : LocaleKeys.settings_shortcutsPage_keybindings_delete.tr(); + } else if (key == deleteRightWordCommand.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_deleteRightWord.tr(); + } else if (key == moveCursorLeftCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorLeft.tr(); + } else if (key == moveCursorToBeginCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorBeginning + .tr(); + } else if (key == moveCursorToLeftWordCommand.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_moveCursorLeftWord.tr(); + } else if (key == moveCursorLeftSelectCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorLeftSelect + .tr(); + } else if (key == moveCursorBeginSelectCommand.key) { + label = LocaleKeys + .settings_shortcutsPage_keybindings_moveCursorBeginSelect + .tr(); + } else if (key == moveCursorLeftWordSelectCommand.key) { + label = LocaleKeys + .settings_shortcutsPage_keybindings_moveCursorLeftWordSelect + .tr(); + } else if (key == moveCursorRightCommand.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_moveCursorRight.tr(); + } else if (key == moveCursorToEndCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorEnd.tr(); + } else if (key == moveCursorToRightWordCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorRightWord + .tr(); + } else if (key == moveCursorRightSelectCommand.key) { + label = LocaleKeys + .settings_shortcutsPage_keybindings_moveCursorRightSelect + .tr(); + } else if (key == moveCursorEndSelectCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorEndSelect + .tr(); + } else if (key == moveCursorRightWordSelectCommand.key) { + label = LocaleKeys + .settings_shortcutsPage_keybindings_moveCursorRightWordSelect + .tr(); + } else if (key == moveCursorUpCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorUp.tr(); + } else if (key == moveCursorTopSelectCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorTopSelect + .tr(); + } else if (key == moveCursorTopCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorTop.tr(); + } else if (key == moveCursorUpSelectCommand.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_moveCursorUpSelect.tr(); + } else if (key == moveCursorDownCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorDown.tr(); + } else if (key == moveCursorBottomSelectCommand.key) { + label = LocaleKeys + .settings_shortcutsPage_keybindings_moveCursorBottomSelect + .tr(); + } else if (key == moveCursorBottomCommand.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_moveCursorBottom.tr(); + } else if (key == moveCursorDownSelectCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_moveCursorDownSelect + .tr(); + } else if (key == homeCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_home.tr(); + } else if (key == endCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_end.tr(); + } else if (key == toggleBoldCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_toggleBold.tr(); + } else if (key == toggleItalicCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_toggleItalic.tr(); + } else if (key == toggleUnderlineCommand.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_toggleUnderline.tr(); + } else if (key == toggleStrikethroughCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_toggleStrikethrough + .tr(); + } else if (key == toggleCodeCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_toggleCode.tr(); + } else if (key == toggleHighlightCommand.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_toggleHighlight.tr(); + } else if (key == showLinkMenuCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_showLinkMenu.tr(); + } else if (key == openInlineLinkCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_openInlineLink.tr(); + } else if (key == openLinksCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_openLinks.tr(); + } else if (key == indentCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_indent.tr(); + } else if (key == outdentCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_outdent.tr(); + } else if (key == exitEditingCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_exit.tr(); + } else if (key == pageUpCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_pageUp.tr(); + } else if (key == pageDownCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_pageDown.tr(); + } else if (key == selectAllCommand.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_selectAll.tr(); + } else if (key == pasteTextWithoutFormattingCommand.key) { + label = LocaleKeys + .settings_shortcutsPage_keybindings_pasteWithoutFormatting + .tr(); + } else if (key == emojiShortcutEvent.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_showEmojiPicker.tr(); + } else if (key == enterInTableCell.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_enterInTableCell.tr(); + } else if (key == leftInTableCell.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_leftInTableCell.tr(); + } else if (key == rightInTableCell.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_rightInTableCell.tr(); + } else if (key == upInTableCell.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_upInTableCell.tr(); + } else if (key == downInTableCell.key) { + label = + LocaleKeys.settings_shortcutsPage_keybindings_downInTableCell.tr(); + } else if (key == tabInTableCell.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_tabInTableCell.tr(); + } else if (key == shiftTabInTableCell.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_shiftTabInTableCell + .tr(); + } else if (key == backSpaceInTableCell.key) { + label = LocaleKeys.settings_shortcutsPage_keybindings_backSpaceInTableCell + .tr(); + } + + return label ?? description?.capitalize() ?? ''; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart new file mode 100644 index 0000000000000..94b8923db5183 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart @@ -0,0 +1,1374 @@ +import 'dart:async'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/shared/google_fonts_extension.dart'; +import 'package:appflowy/util/font_family_extension.dart'; +import 'package:appflowy/workspace/application/appearance_defaults.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart'; +import 'package:appflowy/workspace/application/settings/workspace/workspace_settings_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/document_color_setting_button.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/setting_action.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_dashed_divider.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_input_field.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_radio_select.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/language.dart'; +import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart'; +import 'package:flowy_infra/plugins/bloc/dynamic_plugin_event.dart'; +import 'package:flowy_infra/plugins/bloc/dynamic_plugin_state.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class SettingsWorkspaceView extends StatelessWidget { + const SettingsWorkspaceView({ + super.key, + required this.userProfile, + this.currentWorkspaceMemberRole, + }); + + final UserProfilePB userProfile; + final AFRolePB? currentWorkspaceMemberRole; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => WorkspaceSettingsBloc() + ..add(WorkspaceSettingsEvent.initial(userProfile: userProfile)), + child: BlocConsumer( + listener: (context, state) { + if (state.deleteWorkspace) { + context.read().add( + UserWorkspaceEvent.deleteWorkspace( + state.workspace!.workspaceId, + ), + ); + Navigator.of(context).pop(); + } + if (state.leaveWorkspace) { + context.read().add( + UserWorkspaceEvent.leaveWorkspace( + state.workspace!.workspaceId, + ), + ); + Navigator.of(context).pop(); + } + }, + builder: (context, state) { + return SettingsBody( + title: LocaleKeys.settings_workspacePage_title.tr(), + description: LocaleKeys.settings_workspacePage_description.tr(), + autoSeparate: false, + children: [ + // We don't allow changing workspace name/icon for local/offline + if (userProfile.authenticator != AuthenticatorPB.Local) ...[ + SettingsCategory( + title: LocaleKeys.settings_workspacePage_workspaceName_title + .tr(), + children: [ + _WorkspaceNameSetting( + currentWorkspaceMemberRole: currentWorkspaceMemberRole, + ), + ], + ), + const SettingsCategorySpacer(), + SettingsCategory( + title: LocaleKeys.settings_workspacePage_workspaceIcon_title + .tr(), + description: LocaleKeys + .settings_workspacePage_workspaceIcon_description + .tr(), + children: [ + _WorkspaceIconSetting( + enableEdit: currentWorkspaceMemberRole?.isOwner ?? false, + workspace: state.workspace, + ), + ], + ), + const SettingsCategorySpacer(), + ], + SettingsCategory( + title: LocaleKeys.settings_workspacePage_appearance_title.tr(), + children: const [AppearanceSelector()], + ), + const VSpace(16), + // const SettingsCategorySpacer(), + SettingsCategory( + title: LocaleKeys.settings_workspacePage_theme_title.tr(), + description: + LocaleKeys.settings_workspacePage_theme_description.tr(), + children: const [ + _ThemeDropdown(), + _DocumentCursorColorSetting(), + _DocumentSelectionColorSetting(), + DocumentPaddingSetting(), + ], + ), + const SettingsCategorySpacer(), + SettingsCategory( + title: + LocaleKeys.settings_workspacePage_workspaceFont_title.tr(), + children: [ + _FontSelectorDropdown( + currentFont: + context.read().state.font, + ), + SettingsDashedDivider( + color: Theme.of(context).colorScheme.outline, + ), + SettingsCategory( + title: LocaleKeys.settings_workspacePage_textDirection_title + .tr(), + children: const [ + TextDirectionSelect(), + EnableRTLItemsSwitcher(), + ], + ), + ], + ), + const VSpace(16), + SettingsCategory( + title: LocaleKeys.settings_workspacePage_layoutDirection_title + .tr(), + children: const [_LayoutDirectionSelect()], + ), + const SettingsCategorySpacer(), + + SettingsCategory( + title: LocaleKeys.settings_workspacePage_dateTime_title.tr(), + children: [ + const _DateTimeFormatLabel(), + const _TimeFormatSwitcher(), + SettingsDashedDivider( + color: Theme.of(context).colorScheme.outline, + ), + const _DateFormatDropdown(), + ], + ), + const SettingsCategorySpacer(), + + SettingsCategory( + title: LocaleKeys.settings_workspacePage_language_title.tr(), + children: const [LanguageDropdown()], + ), + const SettingsCategorySpacer(), + + if (userProfile.authenticator != AuthenticatorPB.Local) ...[ + SingleSettingAction( + label: LocaleKeys.settings_workspacePage_manageWorkspace_title + .tr(), + fontSize: 16, + fontWeight: FontWeight.w600, + onPressed: () => showConfirmDialog( + context: context, + title: currentWorkspaceMemberRole?.isOwner ?? false + ? LocaleKeys + .settings_workspacePage_deleteWorkspacePrompt_title + .tr() + : LocaleKeys + .settings_workspacePage_leaveWorkspacePrompt_title + .tr(), + description: currentWorkspaceMemberRole?.isOwner ?? false + ? LocaleKeys + .settings_workspacePage_deleteWorkspacePrompt_content + .tr() + : LocaleKeys + .settings_workspacePage_leaveWorkspacePrompt_content + .tr(), + style: ConfirmPopupStyle.cancelAndOk, + onConfirm: () => context.read().add( + currentWorkspaceMemberRole?.isOwner ?? false + ? const WorkspaceSettingsEvent.deleteWorkspace() + : const WorkspaceSettingsEvent.leaveWorkspace(), + ), + ), + buttonType: SingleSettingsButtonType.danger, + buttonLabel: currentWorkspaceMemberRole?.isOwner ?? false + ? LocaleKeys + .settings_workspacePage_manageWorkspace_deleteWorkspace + .tr() + : LocaleKeys + .settings_workspacePage_manageWorkspace_leaveWorkspace + .tr(), + ), + ], + ], + ); + }, + ), + ); + } +} + +class _WorkspaceNameSetting extends StatefulWidget { + const _WorkspaceNameSetting({ + this.currentWorkspaceMemberRole, + }); + + final AFRolePB? currentWorkspaceMemberRole; + + @override + State<_WorkspaceNameSetting> createState() => _WorkspaceNameSettingState(); +} + +class _WorkspaceNameSettingState extends State<_WorkspaceNameSetting> { + final TextEditingController workspaceNameController = TextEditingController(); + final focusNode = FocusNode(); + Timer? _debounce; + + @override + void dispose() { + focusNode.dispose(); + workspaceNameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (_, state) { + final newName = state.workspace?.name; + if (newName != null && newName != workspaceNameController.text) { + workspaceNameController.text = newName; + } + }, + builder: (_, state) { + if (widget.currentWorkspaceMemberRole == null || + !widget.currentWorkspaceMemberRole!.isOwner) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.5), + child: FlowyText.regular( + workspaceNameController.text, + fontSize: 14, + ), + ); + } + + return Flexible( + child: SettingsInputField( + textController: workspaceNameController, + value: workspaceNameController.text, + focusNode: focusNode, + onSave: (_) => + _saveWorkspaceName(name: workspaceNameController.text), + onChanged: _debounceSaveName, + hideActions: true, + ), + ); + }, + ); + } + + void _debounceSaveName(String name) { + _debounce?.cancel(); + _debounce = Timer( + const Duration(milliseconds: 300), + () => _saveWorkspaceName(name: name), + ); + } + + void _saveWorkspaceName({required String name}) { + if (name.isNotEmpty) { + context + .read() + .add(WorkspaceSettingsEvent.updateWorkspaceName(name)); + } + } +} + +@visibleForTesting +class LanguageDropdown extends StatelessWidget { + const LanguageDropdown({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SettingsDropdown( + key: const Key('LanguageDropdown'), + expandWidth: false, + onChanged: (locale) => context + .read() + .setLocale(context, locale), + selectedOption: state.locale, + options: EasyLocalization.of(context)! + .supportedLocales + .map( + (locale) => buildDropdownMenuEntry( + context, + selectedValue: state.locale, + value: locale, + label: languageFromLocale(locale), + ), + ) + .toList(), + ); + }, + ); + } +} + +class _WorkspaceIconSetting extends StatelessWidget { + const _WorkspaceIconSetting({required this.enableEdit, this.workspace}); + + final bool enableEdit; + final UserWorkspacePB? workspace; + + @override + Widget build(BuildContext context) { + if (workspace == null) { + return const SizedBox( + height: 64, + width: 64, + child: CircularProgressIndicator(), + ); + } + + return SizedBox( + height: 64, + width: 64, + child: Padding( + padding: const EdgeInsets.all(1), + child: WorkspaceIcon( + workspace: workspace!, + iconSize: 36, + emojiSize: 24.0, + fontSize: 24.0, + figmaLineHeight: 26.0, + borderRadius: 18.0, + enableEdit: true, + onSelected: (r) => context + .read() + .add(WorkspaceSettingsEvent.updateWorkspaceIcon(r.emoji)), + ), + ), + ); + } +} + +@visibleForTesting +class TextDirectionSelect extends StatelessWidget { + const TextDirectionSelect({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final selectedItem = state.textDirection ?? AppFlowyTextDirection.ltr; + + return SettingsRadioSelect( + onChanged: (item) { + context + .read() + .setTextDirection(item.value); + context + .read() + .syncDefaultTextDirection(item.value.name); + }, + items: [ + SettingsRadioItem( + value: AppFlowyTextDirection.ltr, + icon: const FlowySvg(FlowySvgs.textdirection_ltr_m), + label: LocaleKeys.settings_workspacePage_textDirection_leftToRight + .tr(), + isSelected: selectedItem == AppFlowyTextDirection.ltr, + ), + SettingsRadioItem( + value: AppFlowyTextDirection.rtl, + icon: const FlowySvg(FlowySvgs.textdirection_rtl_m), + label: LocaleKeys.settings_workspacePage_textDirection_rightToLeft + .tr(), + isSelected: selectedItem == AppFlowyTextDirection.rtl, + ), + SettingsRadioItem( + value: AppFlowyTextDirection.auto, + icon: const FlowySvg(FlowySvgs.textdirection_auto_m), + label: LocaleKeys.settings_workspacePage_textDirection_auto.tr(), + isSelected: selectedItem == AppFlowyTextDirection.auto, + ), + ], + ); + }, + ); + } +} + +@visibleForTesting +class EnableRTLItemsSwitcher extends StatelessWidget { + const EnableRTLItemsSwitcher({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FlowyText.regular( + LocaleKeys.settings_workspacePage_textDirection_enableRTLItems.tr(), + fontSize: 16, + ), + ), + const HSpace(16), + Toggle( + value: context + .watch() + .state + .enableRtlToolbarItems, + onChanged: (value) => context + .read() + .setEnableRTLToolbarItems(value), + ), + ], + ); + } +} + +class _LayoutDirectionSelect extends StatelessWidget { + const _LayoutDirectionSelect(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SettingsRadioSelect( + onChanged: (item) => context + .read() + .setLayoutDirection(item.value), + items: [ + SettingsRadioItem( + value: LayoutDirection.ltrLayout, + icon: const FlowySvg(FlowySvgs.textdirection_ltr_m), + label: LocaleKeys + .settings_workspacePage_layoutDirection_leftToRight + .tr(), + isSelected: state.layoutDirection == LayoutDirection.ltrLayout, + ), + SettingsRadioItem( + value: LayoutDirection.rtlLayout, + icon: const FlowySvg(FlowySvgs.textdirection_rtl_m), + label: LocaleKeys + .settings_workspacePage_layoutDirection_rightToLeft + .tr(), + isSelected: state.layoutDirection == LayoutDirection.rtlLayout, + ), + ], + ); + }, + ); + } +} + +class _DateFormatDropdown extends StatelessWidget { + const _DateFormatDropdown(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular( + LocaleKeys.settings_workspacePage_dateTime_dateFormat_label + .tr(), + fontSize: 16, + ), + const VSpace(8), + SettingsDropdown( + key: const Key('DateFormatDropdown'), + expandWidth: false, + onChanged: (format) => context + .read() + .setDateFormat(format), + selectedOption: state.dateFormat, + options: UserDateFormatPB.values + .map( + (format) => buildDropdownMenuEntry( + context, + value: format, + label: _formatLabel(format), + ), + ) + .toList(), + ), + ], + ), + ); + }, + ); + } + + String _formatLabel(UserDateFormatPB format) => switch (format) { + UserDateFormatPB.Locally => + LocaleKeys.settings_workspacePage_dateTime_dateFormat_local.tr(), + UserDateFormatPB.US => + LocaleKeys.settings_workspacePage_dateTime_dateFormat_us.tr(), + UserDateFormatPB.ISO => + LocaleKeys.settings_workspacePage_dateTime_dateFormat_iso.tr(), + UserDateFormatPB.Friendly => + LocaleKeys.settings_workspacePage_dateTime_dateFormat_friendly.tr(), + UserDateFormatPB.DayMonthYear => + LocaleKeys.settings_workspacePage_dateTime_dateFormat_dmy.tr(), + _ => "Unknown format", + }; +} + +class _DateTimeFormatLabel extends StatelessWidget { + const _DateTimeFormatLabel(); + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + + return BlocBuilder( + builder: (context, state) { + return FlowyText.regular( + LocaleKeys.settings_workspacePage_dateTime_example.tr( + args: [ + state.dateFormat.formatDate(now, false), + state.timeFormat.formatTime(now), + now.timeZoneName, + ], + ), + maxLines: 2, + fontSize: 16, + color: AFThemeExtension.of(context).secondaryTextColor, + ); + }, + ); + } +} + +class _TimeFormatSwitcher extends StatelessWidget { + const _TimeFormatSwitcher(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FlowyText.regular( + LocaleKeys.settings_workspacePage_dateTime_24HourTime.tr(), + fontSize: 16, + ), + ), + const HSpace(16), + Toggle( + value: context.watch().state.timeFormat == + UserTimeFormatPB.TwentyFourHour, + onChanged: (value) => + context.read().setTimeFormat( + value + ? UserTimeFormatPB.TwentyFourHour + : UserTimeFormatPB.TwelveHour, + ), + ), + ], + ); + } +} + +class _ThemeDropdown extends StatelessWidget { + const _ThemeDropdown(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DynamicPluginBloc()..add(DynamicPluginEvent.load()), + child: BlocBuilder( + buildWhen: (_, current) => current is Ready, + builder: (context, state) { + final appearance = context.watch().state; + final isLightMode = Theme.of(context).brightness == Brightness.light; + + final customThemes = state.whenOrNull( + ready: (ps) => ps.map((p) => p.theme).whereType(), + ); + + return SettingsDropdown( + key: const Key('ThemeSelectorDropdown'), + actions: [ + SettingAction( + tooltip: LocaleKeys + .settings_workspacePage_theme_uploadCustomThemeTooltip + .tr(), + icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(20)), + onPressed: () => Dialogs.show( + context, + child: BlocProvider.value( + value: context.read(), + child: const FlowyDialog( + constraints: BoxConstraints(maxHeight: 300), + child: ThemeUploadWidget(), + ), + ), + ).then((val) { + if (val != null && context.mounted) { + showSnackBarMessage( + context, + LocaleKeys.settings_appearance_themeUpload_uploadSuccess + .tr(), + ); + } + }), + ), + SettingAction( + icon: const FlowySvg( + FlowySvgs.restore_s, + size: Size.square(20), + ), + label: LocaleKeys.settings_common_reset.tr(), + onPressed: () => context + .read() + .setTheme(AppTheme.builtins.first.themeName), + ), + ], + onChanged: (theme) => + context.read().setTheme(theme), + selectedOption: appearance.appTheme.themeName, + options: [ + ...AppTheme.builtins.map( + (t) { + final theme = isLightMode ? t.lightTheme : t.darkTheme; + + return buildDropdownMenuEntry( + context, + selectedValue: appearance.appTheme.themeName, + value: t.themeName, + label: t.themeName, + leadingWidget: _ThemeLeading(color: theme.sidebarBg), + ); + }, + ), + ...?customThemes?.map( + (t) { + final theme = isLightMode ? t.lightTheme : t.darkTheme; + + return buildDropdownMenuEntry( + context, + selectedValue: appearance.appTheme.themeName, + value: t.themeName, + label: t.themeName, + leadingWidget: _ThemeLeading(color: theme.sidebarBg), + trailingWidget: FlowyIconButton( + icon: const FlowySvg(FlowySvgs.delete_s), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + onPressed: () { + context.read().add( + DynamicPluginEvent.removePlugin( + name: t.themeName, + ), + ); + + if (appearance.appTheme.themeName == t.themeName) { + context + .read() + .setTheme(AppTheme.builtins.first.themeName); + } + }, + ), + ); + }, + ), + ], + ); + }, + ), + ); + } +} + +class _ThemeLeading extends StatelessWidget { + const _ThemeLeading({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: color, + borderRadius: Corners.s4Border, + border: Border.all(color: Theme.of(context).colorScheme.outline), + ), + ); + } +} + +@visibleForTesting +class AppearanceSelector extends StatelessWidget { + const AppearanceSelector({super.key}); + + @override + Widget build(BuildContext context) { + final themeMode = context.read().state.themeMode; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...ThemeMode.values.map( + (t) => Padding( + padding: const EdgeInsets.only(right: 16), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => + context.read().setThemeMode(t), + child: FlowyHover( + style: HoverStyle.transparent( + foregroundColorOnHover: + AFThemeExtension.of(context).textColor, + ), + child: Column( + children: [ + Container( + width: 88, + height: 72, + decoration: BoxDecoration( + border: Border.all( + color: t == themeMode + ? Theme.of(context).colorScheme.onSecondary + : Theme.of(context).colorScheme.outline, + ), + borderRadius: Corners.s4Border, + image: DecorationImage( + fit: BoxFit.cover, + image: AssetImage( + 'assets/images/appearance/${t.name.toLowerCase()}.png', + ), + ), + ), + child: t != themeMode + ? null + : const _SelectedModeIndicator(), + ), + const VSpace(6), + FlowyText.regular(getLabel(t), textAlign: TextAlign.center), + ], + ), + ), + ), + ), + ), + ], + ); + } + + String getLabel(ThemeMode t) => switch (t) { + ThemeMode.system => + LocaleKeys.settings_workspacePage_appearance_options_system.tr(), + ThemeMode.light => + LocaleKeys.settings_workspacePage_appearance_options_light.tr(), + ThemeMode.dark => + LocaleKeys.settings_workspacePage_appearance_options_dark.tr(), + }; +} + +class _SelectedModeIndicator extends StatelessWidget { + const _SelectedModeIndicator(); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned( + top: 4, + left: 4, + child: Material( + shape: const CircleBorder(), + elevation: 2, + child: Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + ), + height: 16, + width: 16, + child: const FlowySvg( + FlowySvgs.settings_selected_theme_m, + size: Size.square(16), + blendMode: BlendMode.dstIn, + ), + ), + ), + ), + ], + ); + } +} + +class _FontSelectorDropdown extends StatefulWidget { + const _FontSelectorDropdown({required this.currentFont}); + + final String currentFont; + + @override + State<_FontSelectorDropdown> createState() => _FontSelectorDropdownState(); +} + +class _FontSelectorDropdownState extends State<_FontSelectorDropdown> { + late final _options = [defaultFontFamily, ...GoogleFonts.asMap().keys]; + final _focusNode = FocusNode(); + final _controller = PopoverController(); + late final ScrollController _scrollController; + final _textController = TextEditingController(); + + @override + void initState() { + super.initState(); + const itemExtent = 32; + final index = _options.indexOf(widget.currentFont); + final newPosition = (index * itemExtent).toDouble(); + _scrollController = ScrollController(initialScrollOffset: newPosition); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _textController.text = context + .read() + .state + .font + .fontFamilyDisplayName; + }); + } + + @override + void dispose() { + _controller.close(); + _focusNode.dispose(); + _scrollController.dispose(); + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final appearance = context.watch().state; + return LayoutBuilder( + builder: (context, constraints) => AppFlowyPopover( + margin: EdgeInsets.zero, + controller: _controller, + skipTraversal: true, + triggerActions: PopoverTriggerFlags.none, + onClose: () { + _focusNode.unfocus(); + setState(() {}); + }, + direction: PopoverDirection.bottomWithLeftAligned, + constraints: BoxConstraints( + maxHeight: 150, + maxWidth: constraints.maxWidth - 90, + ), + borderRadius: const BorderRadius.all(Radius.circular(4.0)), + popupBuilder: (_) => _FontListPopup( + currentFont: appearance.font, + scrollController: _scrollController, + controller: _controller, + options: _options, + textController: _textController, + focusNode: _focusNode, + ), + child: Row( + children: [ + Expanded( + child: TapRegion( + behavior: HitTestBehavior.translucent, + onTapOutside: (_) { + _focusNode.unfocus(); + setState(() {}); + }, + child: Listener( + onPointerDown: (_) { + _focusNode.requestFocus(); + setState(() {}); + _controller.show(); + }, + child: FlowyTextField( + autoFocus: false, + focusNode: _focusNode, + controller: _textController, + decoration: InputDecoration( + suffixIcon: const MouseRegion( + cursor: SystemMouseCursors.click, + child: Icon(Icons.arrow_drop_down), + ), + counterText: '', + contentPadding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 18, + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + borderRadius: Corners.s8Border, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + borderRadius: Corners.s8Border, + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + borderRadius: Corners.s8Border, + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + borderRadius: Corners.s8Border, + ), + ), + ), + ), + ), + ), + const HSpace(16), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => context + .read() + .setFontFamily(defaultFontFamily), + child: SizedBox( + height: 26, + child: FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.restore_s, + size: Size.square(20), + ), + const HSpace(4), + FlowyText.regular( + LocaleKeys.settings_common_reset.tr(), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _FontListPopup extends StatefulWidget { + const _FontListPopup({ + required this.controller, + required this.scrollController, + required this.options, + required this.currentFont, + required this.textController, + required this.focusNode, + }); + + final ScrollController scrollController; + final List options; + final String currentFont; + final TextEditingController textController; + final FocusNode focusNode; + final PopoverController controller; + + @override + State<_FontListPopup> createState() => _FontListPopupState(); +} + +class _FontListPopupState extends State<_FontListPopup> { + late List _filteredOptions = widget.options; + + @override + void initState() { + super.initState(); + widget.textController.addListener(_onTextFieldChanged); + } + + void _onTextFieldChanged() { + final value = widget.textController.text; + + if (value.trim().isEmpty) { + _filteredOptions = widget.options; + } else { + if (value.fontFamilyDisplayName == + widget.currentFont.fontFamilyDisplayName) { + return; + } + + _filteredOptions = widget.options + .where( + (f) => + f.toLowerCase().contains(value.trim().toLowerCase()) || + f.fontFamilyDisplayName + .toLowerCase() + .contains(value.trim().fontFamilyDisplayName.toLowerCase()), + ) + .toList(); + + // Default font family is "", but the display name is "System", + // which means it's hard compared to other font families to find this one. + if (!_filteredOptions.contains(defaultFontFamily) && + 'system'.contains(value.trim().toLowerCase())) { + _filteredOptions.insert(0, defaultFontFamily); + } + } + + setState(() {}); + } + + @override + void dispose() { + widget.textController.removeListener(_onTextFieldChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_filteredOptions.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: FlowyText.medium( + LocaleKeys.settings_workspacePage_workspaceFont_noFontHint.tr(), + ), + ), + Flexible( + child: ListView.separated( + shrinkWrap: _filteredOptions.length < 10, + controller: widget.scrollController, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), + itemCount: _filteredOptions.length, + separatorBuilder: (_, __) => const VSpace(6), + itemBuilder: (context, index) { + final font = _filteredOptions[index]; + final isSelected = widget.currentFont == font; + return SizedBox( + height: 29, + child: ListTile( + minVerticalPadding: 0, + selected: isSelected, + dense: true, + hoverColor: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.12), + selectedTileColor: + Theme.of(context).colorScheme.primary.withOpacity(0.12), + contentPadding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + minTileHeight: 0, + onTap: () { + context + .read() + .setFontFamily(font); + + widget.textController.text = font.fontFamilyDisplayName; + + // This is a workaround such that when dialog rebuilds due + // to font changing, the font selector won't retain focus. + widget.focusNode.parent?.requestFocus(); + + widget.controller.close(); + }, + title: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + font.fontFamilyDisplayName, + style: TextStyle( + color: AFThemeExtension.of(context).textColor, + fontFamily: getGoogleFontSafely(font).fontFamily, + ), + ), + ), + trailing: + isSelected ? const FlowySvg(FlowySvgs.check_s) : null, + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _DocumentCursorColorSetting extends StatelessWidget { + const _DocumentCursorColorSetting(); + + @override + Widget build(BuildContext context) { + final label = + LocaleKeys.settings_appearance_documentSettings_cursorColor.tr(); + return BlocBuilder( + builder: (context, state) { + return SettingListTile( + label: label, + resetButtonKey: const Key('DocumentCursorColorResetButton'), + onResetRequested: () { + showConfirmDialog( + context: context, + title: + LocaleKeys.settings_workspacePage_resetCursorColor_title.tr(), + description: LocaleKeys + .settings_workspacePage_resetCursorColor_description + .tr(), + style: ConfirmPopupStyle.cancelAndOk, + confirmLabel: LocaleKeys.settings_common_reset.tr(), + onConfirm: () => context + ..read().resetDocumentCursorColor() + ..read().syncCursorColor(null), + ); + }, + trailing: [ + DocumentColorSettingButton( + key: const Key('DocumentCursorColorSettingButton'), + currentColor: state.cursorColor ?? + DefaultAppearanceSettings.getDefaultCursorColor(context), + previewWidgetBuilder: (color) => _CursorColorValueWidget( + cursorColor: color ?? + DefaultAppearanceSettings.getDefaultCursorColor(context), + ), + dialogTitle: label, + onApply: (color) => context + ..read().setDocumentCursorColor(color) + ..read().syncCursorColor(color), + ), + ], + ); + }, + ); + } +} + +class _CursorColorValueWidget extends StatelessWidget { + const _CursorColorValueWidget({required this.cursorColor}); + + final Color cursorColor; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(color: cursorColor, width: 2, height: 16), + FlowyText( + LocaleKeys.appName.tr(), + // To avoid the text color changes when it is hovered in dark mode + color: AFThemeExtension.of(context).onBackground, + ), + ], + ); + } +} + +class _DocumentSelectionColorSetting extends StatelessWidget { + const _DocumentSelectionColorSetting(); + + @override + Widget build(BuildContext context) { + final label = + LocaleKeys.settings_appearance_documentSettings_selectionColor.tr(); + + return BlocBuilder( + builder: (context, state) { + return SettingListTile( + label: label, + resetButtonKey: const Key('DocumentSelectionColorResetButton'), + onResetRequested: () { + showConfirmDialog( + context: context, + title: LocaleKeys.settings_workspacePage_resetSelectionColor_title + .tr(), + description: LocaleKeys + .settings_workspacePage_resetSelectionColor_description + .tr(), + style: ConfirmPopupStyle.cancelAndOk, + confirmLabel: LocaleKeys.settings_common_reset.tr(), + onConfirm: () => context + ..read().resetDocumentSelectionColor() + ..read().syncSelectionColor(null), + ); + }, + trailing: [ + DocumentColorSettingButton( + currentColor: state.selectionColor ?? + DefaultAppearanceSettings.getDefaultSelectionColor(context), + previewWidgetBuilder: (color) => _SelectionColorValueWidget( + selectionColor: color ?? + DefaultAppearanceSettings.getDefaultSelectionColor(context), + ), + dialogTitle: label, + onApply: (c) => context + ..read().setDocumentSelectionColor(c) + ..read().syncSelectionColor(c), + ), + ], + ); + }, + ); + } +} + +class _SelectionColorValueWidget extends StatelessWidget { + const _SelectionColorValueWidget({required this.selectionColor}); + + final Color selectionColor; + + @override + Widget build(BuildContext context) { + // To avoid the text color changes when it is hovered in dark mode + final textColor = AFThemeExtension.of(context).onBackground; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + color: selectionColor, + child: FlowyText( + LocaleKeys.settings_appearance_documentSettings_app.tr(), + color: textColor, + ), + ), + FlowyText( + LocaleKeys.settings_appearance_documentSettings_flowy.tr(), + color: textColor, + ), + ], + ); + } +} + +class DocumentPaddingSetting extends StatelessWidget { + const DocumentPaddingSetting({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + children: [ + Row( + children: [ + FlowyText.medium( + LocaleKeys.settings_appearance_documentSettings_width.tr(), + ), + const Spacer(), + SettingsResetButton( + onResetRequested: () => + context.read().syncWidth(null), + ), + ], + ), + const VSpace(6), + Container( + height: 32, + padding: const EdgeInsets.only(right: 4), + child: _DocumentPaddingSlider( + onPaddingChanged: (value) { + context.read().syncWidth(value); + }, + ), + ), + ], + ); + }, + ); + } +} + +class _DocumentPaddingSlider extends StatefulWidget { + const _DocumentPaddingSlider({ + required this.onPaddingChanged, + }); + + final void Function(double) onPaddingChanged; + + @override + State<_DocumentPaddingSlider> createState() => _DocumentPaddingSliderState(); +} + +class _DocumentPaddingSliderState extends State<_DocumentPaddingSlider> { + late double width; + + @override + void initState() { + super.initState(); + + width = context.read().state.width; + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.width != width) { + width = state.width; + } + return SliderTheme( + data: Theme.of(context).sliderTheme.copyWith( + showValueIndicator: ShowValueIndicator.never, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 8, + ), + overlayShape: SliderComponentShape.noThumb, + ), + child: Slider( + value: width.clamp( + EditorStyleCustomizer.minDocumentWidth, + EditorStyleCustomizer.maxDocumentWidth, + ), + min: EditorStyleCustomizer.minDocumentWidth, + max: EditorStyleCustomizer.maxDocumentWidth, + divisions: 10, + onChanged: (value) { + setState(() => width = value); + + widget.onPaddingChanged(value); + }, + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart new file mode 100644 index 0000000000000..f764bec9e7ee8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/constants.dart @@ -0,0 +1,60 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class SettingsPageSitesConstants { + static const threeDotsButtonWidth = 26.0; + static const alignPadding = 6.0; + + static final dateFormat = DateFormat('MMM d, yyyy'); + + static final publishedViewHeaderTitles = [ + LocaleKeys.settings_sites_publishedPage_page.tr(), + LocaleKeys.settings_sites_publishedPage_pathName.tr(), + LocaleKeys.settings_sites_publishedPage_date.tr(), + ]; + + static final namespaceHeaderTitles = [ + LocaleKeys.settings_sites_namespaceHeader.tr(), + LocaleKeys.settings_sites_homepageHeader.tr(), + ]; + + // the published view name is longer than the other two, so we give it more flex + static final publishedViewItemFlexes = [1, 1, 1]; +} + +class SettingsPageSitesEvent { + static void visitSite( + PublishInfoViewPB publishInfoView, { + String? nameSpace, + }) { + // visit the site + final url = ShareConstants.buildPublishUrl( + nameSpace: nameSpace ?? publishInfoView.info.namespace, + publishName: publishInfoView.info.publishName, + ); + afLaunchUrlString(url); + } + + static void copySiteLink( + BuildContext context, + PublishInfoViewPB publishInfoView, { + String? nameSpace, + }) { + final url = ShareConstants.buildPublishUrl( + nameSpace: nameSpace ?? publishInfoView.info.namespace, + publishName: publishInfoView.info.publishName, + ); + getIt().setData(ClipboardServiceData(plainText: url)); + showToastNotification( + context, + message: LocaleKeys.grid_url_copy.tr(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_header.dart new file mode 100644 index 0000000000000..23a1bb2d7fca7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_header.dart @@ -0,0 +1,28 @@ +import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class DomainHeader extends StatelessWidget { + const DomainHeader({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + ...SettingsPageSitesConstants.namespaceHeaderTitles.map( + (title) => Expanded( + child: FlowyText.medium( + title, + fontSize: 14.0, + textAlign: TextAlign.left, + ), + ), + ), + // it used to align the three dots button in the published page item + const HSpace(SettingsPageSitesConstants.threeDotsButtonWidth), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_item.dart new file mode 100644 index 0000000000000..d075deabb010e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_item.dart @@ -0,0 +1,277 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/shared/colors.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_more_action.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/home_page_menu.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/publish_info_view_item.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DomainItem extends StatelessWidget { + const DomainItem({ + super.key, + required this.namespace, + required this.homepage, + }); + + final String namespace; + final String homepage; + + @override + Widget build(BuildContext context) { + final namespaceUrl = ShareConstants.buildNamespaceUrl( + nameSpace: namespace, + ); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Namespace + Expanded( + child: _buildNamespace(context, namespaceUrl), + ), + // Homepage + Expanded( + child: _buildHomepage(context), + ), + // ... button + DomainMoreAction(namespace: namespace), + ], + ); + } + + Widget _buildNamespace(BuildContext context, String namespaceUrl) { + return Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(right: 12.0), + child: FlowyTooltip( + message: '${LocaleKeys.shareAction_visitSite.tr()}\n$namespaceUrl', + child: FlowyButton( + useIntrinsicWidth: true, + text: FlowyText( + namespaceUrl, + fontSize: 14.0, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + final namespaceUrl = ShareConstants.buildNamespaceUrl( + nameSpace: namespace, + withHttps: true, + ); + afLaunchUrlString(namespaceUrl); + }, + ), + ), + ); + } + + Widget _buildHomepage(BuildContext context) { + final plan = context.read().state.subscriptionInfo?.plan; + + if (plan == null) { + return const SizedBox.shrink(); + } + + final isFreePlan = plan == WorkspacePlanPB.FreePlan; + if (isFreePlan) { + return const Padding( + padding: EdgeInsets.only( + left: SettingsPageSitesConstants.alignPadding, + ), + child: _FreePlanUpgradeButton(), + ); + } + + return const _HomePageButton(); + } +} + +class _HomePageButton extends StatelessWidget { + const _HomePageButton(); + + @override + Widget build(BuildContext context) { + final settingsSitesState = context.watch().state; + if (settingsSitesState.isLoading) { + return const SizedBox.shrink(); + } + + final isOwner = context + .watch() + .state + .currentWorkspace + ?.role + .isOwner ?? + false; + + final homePageView = settingsSitesState.homePageView; + Widget child = homePageView == null + ? _defaultHomePageButton(context) + : PublishInfoViewItem( + publishInfoView: homePageView, + margin: isOwner ? null : EdgeInsets.zero, + ); + + if (isOwner) { + child = _buildHomePageButtonForOwner( + context, + homePageView: homePageView, + child: child, + ); + } else { + child = _buildHomePageButtonForNonOwner(context, child); + } + + return Container( + alignment: Alignment.centerLeft, + child: child, + ); + } + + Widget _buildHomePageButtonForOwner( + BuildContext context, { + required PublishInfoViewPB? homePageView, + required Widget child, + }) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppFlowyPopover( + direction: PopoverDirection.bottomWithCenterAligned, + constraints: const BoxConstraints( + maxWidth: 260, + maxHeight: 345, + ), + margin: const EdgeInsets.symmetric( + horizontal: 14.0, + vertical: 12.0, + ), + popupBuilder: (_) { + final bloc = context.read(); + return BlocProvider.value( + value: bloc, + child: SelectHomePageMenu( + userProfile: bloc.user, + workspaceId: bloc.workspaceId, + onSelected: (view) {}, + ), + ); + }, + child: child, + ), + if (homePageView != null) + FlowyTooltip( + message: LocaleKeys.settings_sites_clearHomePage.tr(), + child: FlowyButton( + margin: const EdgeInsets.all(4.0), + useIntrinsicWidth: true, + onTap: () { + context.read().add( + const SettingsSitesEvent.removeHomePage(), + ); + }, + text: const FlowySvg( + FlowySvgs.close_m, + size: Size.square(19.0), + ), + ), + ), + ], + ); + } + + Widget _buildHomePageButtonForNonOwner( + BuildContext context, + Widget child, + ) { + return FlowyTooltip( + message: LocaleKeys + .settings_sites_namespace_onlyWorkspaceOwnerCanSetHomePage + .tr(), + child: IgnorePointer( + child: child, + ), + ); + } + + Widget _defaultHomePageButton(BuildContext context) { + return FlowyButton( + useIntrinsicWidth: true, + leftIcon: const FlowySvg( + FlowySvgs.search_s, + ), + leftIconSize: const Size.square(14.0), + text: FlowyText( + LocaleKeys.settings_sites_selectHomePage.tr(), + figmaLineHeight: 18.0, + ), + ); + } +} + +class _FreePlanUpgradeButton extends StatelessWidget { + const _FreePlanUpgradeButton(); + + @override + Widget build(BuildContext context) { + final isOwner = context + .watch() + .state + .currentWorkspace + ?.role + .isOwner ?? + false; + return Container( + alignment: Alignment.centerLeft, + child: FlowyTooltip( + message: LocaleKeys.settings_sites_namespace_upgradeToPro.tr(), + child: PrimaryRoundedButton( + text: 'Pro ↗', + fontSize: 12.0, + figmaLineHeight: 16.0, + fontWeight: FontWeight.w600, + radius: 8.0, + textColor: context.proPrimaryColor, + backgroundColor: context.proSecondaryColor, + margin: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 6.0, + ), + hoverColor: context.proSecondaryColor.withOpacity(0.9), + onTap: () { + if (isOwner) { + showToastNotification( + context, + message: + LocaleKeys.settings_sites_namespace_redirectToPayment.tr(), + type: ToastificationType.info, + ); + + context.read().add( + const SettingsSitesEvent.upgradeSubscription(), + ); + } else { + showToastNotification( + context, + message: LocaleKeys + .settings_sites_namespace_pleaseAskOwnerToSetHomePage + .tr(), + type: ToastificationType.info, + ); + } + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_more_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_more_action.dart new file mode 100644 index 0000000000000..9c506b22ff4c4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_more_action.dart @@ -0,0 +1,210 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DomainMoreAction extends StatefulWidget { + const DomainMoreAction({ + super.key, + required this.namespace, + }); + + final String namespace; + + @override + State createState() => _DomainMoreActionState(); +} + +class _DomainMoreActionState extends State { + @override + void initState() { + super.initState(); + + // update the current workspace to ensure the owner check is correct + context.read().add(const UserWorkspaceEvent.initial()); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + constraints: const BoxConstraints(maxWidth: 188), + offset: const Offset(6, 0), + animationDuration: Durations.short3, + beginScaleFactor: 1.0, + beginOpacity: 0.8, + child: const SizedBox( + width: SettingsPageSitesConstants.threeDotsButtonWidth, + child: FlowyButton( + useIntrinsicWidth: true, + text: FlowySvg(FlowySvgs.three_dots_s), + ), + ), + popupBuilder: (builderContext) { + return BlocProvider.value( + value: context.read(), + child: _buildUpdateNamespaceButton( + context, + builderContext, + ), + ); + }, + ); + } + + Widget _buildUpdateNamespaceButton( + BuildContext context, + BuildContext builderContext, + ) { + final child = _buildActionButton( + context, + builderContext, + type: _ActionType.updateNamespace, + ); + + final plan = context.read().state.subscriptionInfo?.plan; + + if (plan != WorkspacePlanPB.ProPlan) { + return _buildForbiddenActionButton( + context, + tooltipMessage: LocaleKeys.settings_sites_namespace_upgradeToPro.tr(), + child: child, + ); + } + + final isOwner = context + .watch() + .state + .currentWorkspace + ?.role + .isOwner ?? + false; + + if (!isOwner) { + return _buildForbiddenActionButton( + context, + tooltipMessage: LocaleKeys + .settings_sites_error_onlyWorkspaceOwnerCanUpdateNamespace + .tr(), + child: child, + ); + } + + return child; + } + + Widget _buildForbiddenActionButton( + BuildContext context, { + required String tooltipMessage, + required Widget child, + }) { + return Opacity( + opacity: 0.5, + child: FlowyTooltip( + message: tooltipMessage, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: IgnorePointer(child: child), + ), + ), + ); + } + + Widget _buildActionButton( + BuildContext context, + BuildContext builderContext, { + required _ActionType type, + }) { + return Container( + height: 34, + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: FlowyIconTextButton( + margin: const EdgeInsets.symmetric(horizontal: 6), + iconPadding: 10.0, + onTap: () => _onTap(context, builderContext, type), + leftIconBuilder: (onHover) => FlowySvg( + type.leftIconSvg, + ), + textBuilder: (onHover) => FlowyText.regular( + type.name, + fontSize: 14.0, + figmaLineHeight: 18.0, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } + + void _onTap( + BuildContext context, + BuildContext builderContext, + _ActionType type, + ) { + switch (type) { + case _ActionType.updateNamespace: + _showSettingsDialog( + context, + builderContext, + ); + break; + case _ActionType.removeHomePage: + context.read().add( + const SettingsSitesEvent.removeHomePage(), + ); + break; + } + + PopoverContainer.of(builderContext).closeAll(); + } + + void _showSettingsDialog( + BuildContext context, + BuildContext builderContext, + ) { + showDialog( + context: context, + builder: (_) { + return BlocProvider.value( + value: context.read(), + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 460, + child: DomainSettingsDialog( + namespace: widget.namespace, + ), + ), + ), + ); + }, + ); + } +} + +enum _ActionType { + updateNamespace, + removeHomePage, +} + +extension _ActionTypeExtension on _ActionType { + String get name => switch (this) { + _ActionType.updateNamespace => + LocaleKeys.settings_sites_updateNamespace.tr(), + _ActionType.removeHomePage => + LocaleKeys.settings_sites_removeHomepage.tr(), + }; + + FlowySvgData get leftIconSvg => switch (this) { + _ActionType.updateNamespace => FlowySvgs.view_item_rename_s, + _ActionType.removeHomePage => FlowySvgs.trash_s, + }; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart new file mode 100644 index 0000000000000..65554941443e8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart @@ -0,0 +1,245 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/plugins/shared/share/publish_color_extension.dart'; +import 'package:appflowy/shared/error_code/error_code_map.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DomainSettingsDialog extends StatefulWidget { + const DomainSettingsDialog({ + super.key, + required this.namespace, + }); + + final String namespace; + + @override + State createState() => _DomainSettingsDialogState(); +} + +class _DomainSettingsDialogState extends State { + final focusNode = FocusNode(); + final controller = TextEditingController(); + late final controllerText = ValueNotifier(widget.namespace); + String errorHintText = ''; + + @override + void initState() { + super.initState(); + + controller.text = widget.namespace; + controller.addListener(_onTextChanged); + } + + void _onTextChanged() => controllerText.value = controller.text; + + @override + void dispose() { + focusNode.dispose(); + controller.removeListener(_onTextChanged); + controller.dispose(); + controllerText.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: _onListener, + child: KeyboardListener( + focusNode: focusNode, + autofocus: true, + onKeyEvent: (event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.of(context).pop(); + } + }, + child: Container( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(), + const VSpace(12), + _buildNamespaceDescription(), + const VSpace(20), + _buildNamespaceTextField(), + _buildPreviewNamespace(), + _buildErrorHintText(), + const VSpace(20), + _buildButtons(), + ], + ), + ), + ), + ); + } + + Widget _buildTitle() { + return Row( + children: [ + FlowyText( + LocaleKeys.settings_sites_namespace_updateExistingNamespace.tr(), + fontSize: 16.0, + figmaLineHeight: 22.0, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + ), + const HSpace(6.0), + FlowyTooltip( + message: LocaleKeys.settings_sites_namespace_tooltip.tr(), + child: const FlowySvg(FlowySvgs.information_s), + ), + const HSpace(6.0), + const Spacer(), + FlowyButton( + margin: const EdgeInsets.all(3), + useIntrinsicWidth: true, + text: const FlowySvg( + FlowySvgs.upgrade_close_s, + size: Size.square(18.0), + ), + onTap: () => Navigator.of(context).pop(), + ), + ], + ); + } + + Widget _buildNamespaceDescription() { + return FlowyText( + LocaleKeys.settings_sites_namespace_description.tr(), + fontSize: 14.0, + color: Theme.of(context).hintColor, + figmaLineHeight: 16.0, + maxLines: 3, + ); + } + + Widget _buildNamespaceTextField() { + return SizedBox( + height: 36, + child: FlowyTextField( + autoFocus: false, + controller: controller, + enableBorderColor: ShareMenuColors.borderColor(context), + ), + ); + } + + Widget _buildButtons() { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedRoundedButton( + text: LocaleKeys.button_cancel.tr(), + onTap: () => Navigator.of(context).pop(), + ), + const HSpace(12.0), + PrimaryRoundedButton( + text: LocaleKeys.button_save.tr(), + radius: 8.0, + margin: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 9.0, + ), + onTap: _onSave, + ), + ], + ); + } + + Widget _buildErrorHintText() { + if (errorHintText.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: 4.0, left: 2.0), + child: FlowyText( + errorHintText, + fontSize: 12.0, + figmaLineHeight: 18.0, + color: Theme.of(context).colorScheme.error, + ), + ); + } + + Widget _buildPreviewNamespace() { + return ValueListenableBuilder( + valueListenable: controllerText, + builder: (context, value, child) { + final url = ShareConstants.buildNamespaceUrl( + nameSpace: value, + ); + return Padding( + padding: const EdgeInsets.only(top: 4.0, left: 2.0), + child: Opacity( + opacity: 0.8, + child: FlowyText( + url, + fontSize: 14.0, + figmaLineHeight: 18.0, + withTooltip: true, + overflow: TextOverflow.ellipsis, + ), + ), + ); + }, + ); + } + + void _onSave() { + // listen on the result + context + .read() + .add(SettingsSitesEvent.updateNamespace(controller.text)); + } + + void _onListener(BuildContext context, SettingsSitesState state) { + final actionResult = state.actionResult; + final type = actionResult?.actionType; + final result = actionResult?.result; + if (type != SettingsSitesActionType.updateNamespace || result == null) { + return; + } + + result.fold( + (s) { + showToastNotification( + context, + message: LocaleKeys.settings_sites_success_namespaceUpdated.tr(), + ); + + Navigator.of(context).pop(); + }, + (f) { + final basicErrorMessage = + LocaleKeys.settings_sites_error_failedToUpdateNamespace.tr(); + final errorMessage = f.code.namespaceErrorMessage; + + setState(() { + errorHintText = errorMessage.orDefault(basicErrorMessage); + }); + + Log.error('Failed to update namespace: $f'); + + showToastNotification( + context, + message: basicErrorMessage, + type: ToastificationType.error, + description: errorMessage, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/home_page_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/home_page_menu.dart new file mode 100644 index 0000000000000..f1236c1024217 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/domain/home_page_menu.dart @@ -0,0 +1,111 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/publish_info_view_item.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +typedef OnSelectedHomePage = void Function(ViewPB view); + +class SelectHomePageMenu extends StatefulWidget { + const SelectHomePageMenu({ + super.key, + required this.onSelected, + required this.userProfile, + required this.workspaceId, + }); + + final OnSelectedHomePage onSelected; + final UserProfilePB userProfile; + final String workspaceId; + + @override + State createState() => _SelectHomePageMenuState(); +} + +class _SelectHomePageMenuState extends State { + List source = []; + List views = []; + + @override + void initState() { + super.initState(); + + source = context.read().state.publishedViews; + views = [...source]; + } + + @override + Widget build(BuildContext context) { + if (views.isEmpty) { + return _buildNoPublishedViews(); + } + + return _buildMenu(context); + } + + Widget _buildNoPublishedViews() { + return FlowyText.regular( + LocaleKeys.settings_sites_publishedPage_noPublishedPages.tr(), + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ); + } + + Widget _buildMenu(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SpaceSearchField( + width: 240, + onSearch: (context, value) => _onSearch(value), + ), + const VSpace(10), + Expanded( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...views.map( + (view) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: PublishInfoViewItem( + publishInfoView: view, + useIntrinsicWidth: false, + onTap: () { + context.read().add( + SettingsSitesEvent.setHomePage(view.info.viewId), + ); + + PopoverContainer.of(context).close(); + }, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + void _onSearch(String value) { + setState(() { + if (value.isEmpty) { + views = source; + } else { + views = source + .where( + (view) => + view.view.name.toLowerCase().contains(value.toLowerCase()), + ) + .toList(); + } + }); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/publish_info_view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/publish_info_view_item.dart new file mode 100644 index 0000000000000..f2a3980bf6bc3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/publish_info_view_item.dart @@ -0,0 +1,63 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/string_extension.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class PublishInfoViewItem extends StatelessWidget { + const PublishInfoViewItem({ + super.key, + required this.publishInfoView, + this.onTap, + this.useIntrinsicWidth = true, + this.margin, + this.extraTooltipMessage, + }); + + final PublishInfoViewPB publishInfoView; + final VoidCallback? onTap; + final bool useIntrinsicWidth; + final EdgeInsets? margin; + final String? extraTooltipMessage; + + @override + Widget build(BuildContext context) { + final name = publishInfoView.view.name.orDefault( + LocaleKeys.menuAppHeader_defaultNewPageName.tr(), + ); + final tooltipMessage = + extraTooltipMessage != null ? '$extraTooltipMessage\n$name' : name; + return Container( + alignment: Alignment.centerLeft, + child: FlowyButton( + margin: margin, + useIntrinsicWidth: useIntrinsicWidth, + mainAxisAlignment: MainAxisAlignment.start, + leftIcon: _buildIcon(), + text: FlowyTooltip( + message: tooltipMessage, + child: FlowyText.regular( + name, + fontSize: 14.0, + figmaLineHeight: 18.0, + overflow: TextOverflow.ellipsis, + ), + ), + onTap: onTap, + ), + ); + } + + Widget _buildIcon() { + final icon = publishInfoView.view.icon.value; + return icon.isNotEmpty + ? FlowyText.emoji( + icon, + fontSize: 16.0, + figmaLineHeight: 18.0, + ) + : publishInfoView.view.defaultIcon(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_item.dart new file mode 100644 index 0000000000000..99c310c901c7c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_item.dart @@ -0,0 +1,120 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/navigator_context_extension.dart'; +import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; +import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/publish_info_view_item.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_more_action.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class PublishedViewItem extends StatelessWidget { + const PublishedViewItem({ + super.key, + required this.publishInfoView, + }); + + final PublishInfoViewPB publishInfoView; + + @override + Widget build(BuildContext context) { + final flexes = SettingsPageSitesConstants.publishedViewItemFlexes; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Published page name + Expanded( + flex: flexes[0], + child: _buildPublishedPageName(context), + ), + + // Published Name + Expanded( + flex: flexes[1], + child: _buildPublishedName(context), + ), + + // Published at + Expanded( + flex: flexes[2], + child: Padding( + padding: const EdgeInsets.only( + left: SettingsPageSitesConstants.alignPadding, + ), + child: _buildPublishedAt(context), + ), + ), + + // More actions + PublishedViewMoreAction( + publishInfoView: publishInfoView, + ), + ], + ); + } + + Widget _buildPublishedPageName(BuildContext context) { + return PublishInfoViewItem( + extraTooltipMessage: + LocaleKeys.settings_sites_publishedPage_clickToOpenPageInApp.tr(), + publishInfoView: publishInfoView, + onTap: () { + context.popToHome(); + + getIt().add( + ActionNavigationEvent.performAction( + action: NavigationAction( + objectId: publishInfoView.view.viewId, + ), + ), + ); + }, + ); + } + + Widget _buildPublishedAt(BuildContext context) { + final formattedDate = SettingsPageSitesConstants.dateFormat.format( + DateTime.fromMillisecondsSinceEpoch( + publishInfoView.info.publishTimestampSec.toInt() * 1000, + ), + ); + return FlowyText( + formattedDate, + fontSize: 14.0, + overflow: TextOverflow.ellipsis, + ); + } + + Widget _buildPublishedName(BuildContext context) { + return Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(right: 48.0), + child: FlowyButton( + useIntrinsicWidth: true, + onTap: () { + final url = ShareConstants.buildPublishUrl( + nameSpace: publishInfoView.info.namespace, + publishName: publishInfoView.info.publishName, + ); + afLaunchUrlString(url); + }, + text: FlowyTooltip( + message: + '${LocaleKeys.settings_sites_publishedPage_clickToOpenPageInBrowser.tr()}\n${publishInfoView.info.publishName}', + child: FlowyText( + publishInfoView.info.publishName, + fontSize: 14.0, + figmaLineHeight: 18.0, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_item_header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_item_header.dart new file mode 100644 index 0000000000000..34fefb4cd8a47 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_item_header.dart @@ -0,0 +1,37 @@ +import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class PublishViewItemHeader extends StatelessWidget { + const PublishViewItemHeader({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final items = List.generate( + SettingsPageSitesConstants.publishedViewHeaderTitles.length, + (index) => ( + title: SettingsPageSitesConstants.publishedViewHeaderTitles[index], + flex: SettingsPageSitesConstants.publishedViewItemFlexes[index], + ), + ); + + return Row( + children: [ + ...items.map( + (item) => Expanded( + flex: item.flex, + child: FlowyText.medium( + item.title, + fontSize: 14.0, + textAlign: TextAlign.left, + ), + ), + ), + // it used to align the three dots button in the published page item + const HSpace(SettingsPageSitesConstants.threeDotsButtonWidth), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_more_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_more_action.dart new file mode 100644 index 0000000000000..6c7b17a70b4d6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_more_action.dart @@ -0,0 +1,187 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class PublishedViewMoreAction extends StatelessWidget { + const PublishedViewMoreAction({ + super.key, + required this.publishInfoView, + }); + + final PublishInfoViewPB publishInfoView; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + constraints: const BoxConstraints(maxWidth: 168), + offset: const Offset(6, 0), + animationDuration: Durations.short3, + beginScaleFactor: 1.0, + beginOpacity: 0.8, + child: const SizedBox( + width: SettingsPageSitesConstants.threeDotsButtonWidth, + child: FlowyButton( + useIntrinsicWidth: true, + text: FlowySvg(FlowySvgs.three_dots_s), + ), + ), + popupBuilder: (builderContext) { + return BlocProvider.value( + value: context.read(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildActionButton( + context, + builderContext, + type: _ActionType.viewSite, + ), + _buildActionButton( + context, + builderContext, + type: _ActionType.copySiteLink, + ), + _buildActionButton( + context, + builderContext, + type: _ActionType.unpublish, + ), + _buildActionButton( + context, + builderContext, + type: _ActionType.customUrl, + ), + _buildActionButton( + context, + builderContext, + type: _ActionType.settings, + ), + ], + ), + ); + }, + ); + } + + Widget _buildActionButton( + BuildContext context, + BuildContext builderContext, { + required _ActionType type, + }) { + return Container( + height: 34, + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: FlowyIconTextButton( + margin: const EdgeInsets.symmetric(horizontal: 6), + iconPadding: 10.0, + onTap: () => _onTap(context, builderContext, type), + leftIconBuilder: (onHover) => FlowySvg( + type.leftIconSvg, + ), + textBuilder: (onHover) => FlowyText.regular( + type.name, + fontSize: 14.0, + figmaLineHeight: 18.0, + ), + ), + ); + } + + void _onTap( + BuildContext context, + BuildContext builderContext, + _ActionType type, + ) { + switch (type) { + case _ActionType.viewSite: + SettingsPageSitesEvent.visitSite( + publishInfoView, + nameSpace: context.read().state.namespace, + ); + break; + case _ActionType.copySiteLink: + SettingsPageSitesEvent.copySiteLink( + context, + publishInfoView, + nameSpace: context.read().state.namespace, + ); + break; + case _ActionType.settings: + _showSettingsDialog( + context, + builderContext, + ); + break; + case _ActionType.unpublish: + context.read().add( + SettingsSitesEvent.unpublishView(publishInfoView.info.viewId), + ); + PopoverContainer.maybeOf(builderContext)?.close(); + break; + case _ActionType.customUrl: + _showSettingsDialog( + context, + builderContext, + ); + break; + } + + PopoverContainer.of(builderContext).closeAll(); + } + + void _showSettingsDialog( + BuildContext context, + BuildContext builderContext, + ) { + showDialog( + context: context, + builder: (_) { + return BlocProvider.value( + value: context.read(), + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 440, + child: PublishedViewSettingsDialog( + publishInfoView: publishInfoView, + ), + ), + ), + ); + }, + ); + } +} + +enum _ActionType { + viewSite, + copySiteLink, + settings, + unpublish, + customUrl; + + String get name => switch (this) { + _ActionType.viewSite => LocaleKeys.shareAction_visitSite.tr(), + _ActionType.copySiteLink => LocaleKeys.shareAction_copyLink.tr(), + _ActionType.settings => LocaleKeys.settings_popupMenuItem_settings.tr(), + _ActionType.unpublish => LocaleKeys.shareAction_unPublish.tr(), + _ActionType.customUrl => LocaleKeys.settings_sites_customUrl.tr(), + }; + + FlowySvgData get leftIconSvg => switch (this) { + _ActionType.viewSite => FlowySvgs.share_publish_s, + _ActionType.copySiteLink => FlowySvgs.copy_s, + _ActionType.settings => FlowySvgs.settings_s, + _ActionType.unpublish => FlowySvgs.delete_s, + _ActionType.customUrl => FlowySvgs.edit_s, + }; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart new file mode 100644 index 0000000000000..b7f3cecebf5a9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart @@ -0,0 +1,223 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/shared/share/publish_color_extension.dart'; +import 'package:appflowy/shared/error_code/error_code_map.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class PublishedViewSettingsDialog extends StatefulWidget { + const PublishedViewSettingsDialog({ + super.key, + required this.publishInfoView, + }); + + final PublishInfoViewPB publishInfoView; + + @override + State createState() => + _PublishedViewSettingsDialogState(); +} + +class _PublishedViewSettingsDialogState + extends State { + final focusNode = FocusNode(); + final controller = TextEditingController(); + + @override + void initState() { + super.initState(); + + controller.text = widget.publishInfoView.info.publishName; + } + + @override + void dispose() { + focusNode.dispose(); + controller.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: _onListener, + child: KeyboardListener( + focusNode: focusNode, + autofocus: true, + onKeyEvent: (event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.of(context).pop(); + } + }, + child: Container( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(), + const VSpace(20), + _buildPublishNameLabel(), + const VSpace(8), + _buildPublishNameTextField(), + const VSpace(20), + _buildButtons(), + ], + ), + ), + ), + ); + } + + Widget _buildTitle() { + return Row( + children: [ + Expanded( + child: FlowyText( + LocaleKeys.settings_sites_publishedPage_settings.tr(), + fontSize: 16.0, + figmaLineHeight: 22.0, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(6.0), + FlowyButton( + margin: const EdgeInsets.all(3), + useIntrinsicWidth: true, + text: const FlowySvg( + FlowySvgs.upgrade_close_s, + size: Size.square(18.0), + ), + onTap: () => Navigator.of(context).pop(), + ), + ], + ); + } + + Widget _buildPublishNameLabel() { + return FlowyText( + LocaleKeys.settings_sites_publishedPage_pathName.tr(), + fontSize: 14.0, + color: Theme.of(context).hintColor, + ); + } + + Widget _buildPublishNameTextField() { + return Row( + children: [ + Expanded( + child: SizedBox( + height: 36, + child: FlowyTextField( + autoFocus: false, + controller: controller, + enableBorderColor: ShareMenuColors.borderColor(context), + textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + height: 1.4, + ), + ), + ), + ), + const HSpace(12.0), + OutlinedRoundedButton( + text: LocaleKeys.button_save.tr(), + radius: 8.0, + margin: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 11.0, + ), + onTap: _savePublishName, + ), + ], + ); + } + + Widget _buildButtons() { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedRoundedButton( + text: LocaleKeys.shareAction_unPublish.tr(), + onTap: _unpublishView, + ), + const HSpace(12.0), + PrimaryRoundedButton( + text: LocaleKeys.shareAction_visitSite.tr(), + radius: 8.0, + margin: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 9.0, + ), + onTap: _visitSite, + ), + ], + ); + } + + void _savePublishName() { + context.read().add( + SettingsSitesEvent.updatePublishName( + widget.publishInfoView.info.viewId, + controller.text, + ), + ); + } + + void _unpublishView() { + context.read().add( + SettingsSitesEvent.unpublishView( + widget.publishInfoView.info.viewId, + ), + ); + + Navigator.of(context).pop(); + } + + void _visitSite() { + SettingsPageSitesEvent.visitSite( + widget.publishInfoView, + nameSpace: context.read().state.namespace, + ); + } + + void _onListener(BuildContext context, SettingsSitesState state) { + final actionResult = state.actionResult; + final result = actionResult?.result; + if (actionResult == null || + result == null || + actionResult.actionType != SettingsSitesActionType.updatePublishName) { + return; + } + + result.fold( + (s) { + showToastNotification( + context, + message: LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(), + ); + Navigator.of(context).pop(); + }, + (f) { + Log.error('update path name failed: $f'); + + showToastNotification( + context, + message: LocaleKeys.settings_sites_error_updatePathNameFailed.tr(), + type: ToastificationType.error, + description: f.code.publishErrorMessage, + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart new file mode 100644 index 0000000000000..e03eed3f46ae8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart @@ -0,0 +1,372 @@ +import 'dart:async'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart' hide FieldInfo; + +part 'settings_sites_bloc.freezed.dart'; + +// workspaceId -> namespace +Map _namespaceCache = {}; + +class SettingsSitesBloc extends Bloc { + SettingsSitesBloc({ + required this.workspaceId, + required this.user, + }) : super(const SettingsSitesState()) { + on((event, emit) async { + await event.when( + initial: () async => _initial(emit), + upgradeSubscription: () async => _upgradeSubscription(emit), + unpublishView: (viewId) async => _unpublishView( + viewId, + emit, + ), + updateNamespace: (namespace) async => _updateNamespace( + namespace, + emit, + ), + updatePublishName: (viewId, name) async => _updatePublishName( + viewId, + name, + emit, + ), + setHomePage: (viewId) async => _setHomePage( + viewId, + emit, + ), + removeHomePage: () async => _removeHomePage(emit), + ); + }); + } + + final String workspaceId; + final UserProfilePB user; + + Future _initial(Emitter emit) async { + final lastNamespace = _namespaceCache[workspaceId] ?? ''; + emit( + state.copyWith( + isLoading: true, + namespace: lastNamespace, + ), + ); + + // Combine fetching subscription info and namespace + final (subscriptionInfo, namespace) = await ( + _fetchUserSubscription(), + _fetchPublishNamespace(), + ).wait; + + emit( + state.copyWith( + subscriptionInfo: subscriptionInfo, + namespace: namespace, + ), + ); + + // This request is not blocking, render the namespace and subscription info first. + final (publishViews, homePageId) = await ( + _fetchPublishedViews(), + _fetchHomePageView(), + ).wait; + + final homePageView = publishViews.firstWhereOrNull( + (view) => view.info.viewId == homePageId, + ); + + emit( + state.copyWith( + publishedViews: publishViews, + homePageView: homePageView, + isLoading: false, + ), + ); + } + + Future _fetchUserSubscription() async { + final result = await UserBackendService.getWorkspaceSubscriptionInfo( + workspaceId, + ); + return result.fold((s) => s, (f) { + Log.error('Failed to fetch user subscription info: $f'); + return null; + }); + } + + Future _fetchPublishNamespace() async { + final result = await FolderEventGetPublishNamespace().send(); + _namespaceCache[workspaceId] = result.fold((s) => s.namespace, (_) => null); + return _namespaceCache[workspaceId] ?? ''; + } + + Future> _fetchPublishedViews() async { + final result = await FolderEventListPublishedViews().send(); + return result.fold( + // new -> old + (s) => s.items.sorted( + (a, b) => + b.info.publishTimestampSec.toInt() - + a.info.publishTimestampSec.toInt(), + ), + (_) => [], + ); + } + + Future _unpublishView( + String viewId, + Emitter emit, + ) async { + emit( + state.copyWith( + actionResult: const SettingsSitesActionResult( + actionType: SettingsSitesActionType.unpublishView, + isLoading: true, + result: null, + ), + ), + ); + + final request = UnpublishViewsPayloadPB(viewIds: [viewId]); + final result = await FolderEventUnpublishViews(request).send(); + final publishedViews = result.fold( + (_) => state.publishedViews + .where((view) => view.info.viewId != viewId) + .toList(), + (_) => state.publishedViews, + ); + + final isHomepage = result.fold( + (_) => state.homePageView?.info.viewId == viewId, + (_) => false, + ); + + emit( + state.copyWith( + publishedViews: publishedViews, + actionResult: SettingsSitesActionResult( + actionType: SettingsSitesActionType.unpublishView, + isLoading: false, + result: result, + ), + homePageView: isHomepage ? null : state.homePageView, + ), + ); + } + + Future _updateNamespace( + String namespace, + Emitter emit, + ) async { + emit( + state.copyWith( + actionResult: const SettingsSitesActionResult( + actionType: SettingsSitesActionType.updateNamespace, + isLoading: true, + result: null, + ), + ), + ); + + final request = SetPublishNamespacePayloadPB()..newNamespace = namespace; + final result = await FolderEventSetPublishNamespace(request).send(); + + emit( + state.copyWith( + namespace: result.fold((_) => namespace, (_) => state.namespace), + actionResult: SettingsSitesActionResult( + actionType: SettingsSitesActionType.updateNamespace, + isLoading: false, + result: result, + ), + ), + ); + } + + Future _updatePublishName( + String viewId, + String name, + Emitter emit, + ) async { + emit( + state.copyWith( + actionResult: const SettingsSitesActionResult( + actionType: SettingsSitesActionType.updatePublishName, + isLoading: true, + result: null, + ), + ), + ); + + final request = SetPublishNamePB() + ..viewId = viewId + ..newName = name; + final result = await FolderEventSetPublishName(request).send(); + final publishedViews = result.fold( + (_) => state.publishedViews.map((view) { + view.freeze(); + if (view.info.viewId == viewId) { + view = view.rebuild((b) { + final info = b.info; + info.freeze(); + b.info = info.rebuild((b) => b.publishName = name); + }); + } + return view; + }).toList(), + (_) => state.publishedViews, + ); + + emit( + state.copyWith( + publishedViews: publishedViews, + actionResult: SettingsSitesActionResult( + actionType: SettingsSitesActionType.updatePublishName, + isLoading: false, + result: result, + ), + ), + ); + } + + Future _upgradeSubscription(Emitter emit) async { + final userService = UserBackendService(userId: user.id); + final result = await userService.createSubscription( + workspaceId, + SubscriptionPlanPB.Pro, + ); + + result.onSuccess((s) { + afLaunchUrlString(s.paymentLink); + }); + + emit( + state.copyWith( + actionResult: SettingsSitesActionResult( + actionType: SettingsSitesActionType.upgradeSubscription, + isLoading: false, + result: result, + ), + ), + ); + } + + Future _setHomePage( + String? viewId, + Emitter emit, + ) async { + if (viewId == null) { + return; + } + final viewIdPB = ViewIdPB()..value = viewId; + final result = await FolderEventSetDefaultPublishView(viewIdPB).send(); + final homePageView = state.publishedViews.firstWhereOrNull( + (view) => view.info.viewId == viewId, + ); + + emit( + state.copyWith( + homePageView: homePageView, + actionResult: SettingsSitesActionResult( + actionType: SettingsSitesActionType.setHomePage, + isLoading: false, + result: result, + ), + ), + ); + } + + Future _removeHomePage(Emitter emit) async { + final result = await FolderEventRemoveDefaultPublishView().send(); + + emit( + state.copyWith( + homePageView: result.fold((_) => null, (_) => state.homePageView), + actionResult: SettingsSitesActionResult( + actionType: SettingsSitesActionType.removeHomePage, + isLoading: false, + result: result, + ), + ), + ); + } + + Future _fetchHomePageView() async { + final result = await FolderEventGetDefaultPublishInfo().send(); + return result.fold((s) => s.viewId, (_) => null); + } +} + +@freezed +class SettingsSitesState with _$SettingsSitesState { + const factory SettingsSitesState({ + @Default([]) List publishedViews, + SettingsSitesActionResult? actionResult, + @Default('') String namespace, + @Default(null) WorkspaceSubscriptionInfoPB? subscriptionInfo, + @Default(true) bool isLoading, + @Default(null) PublishInfoViewPB? homePageView, + }) = _SettingsSitesState; + + factory SettingsSitesState.initial() => const SettingsSitesState(); +} + +@freezed +class SettingsSitesEvent with _$SettingsSitesEvent { + const factory SettingsSitesEvent.initial() = _Initial; + const factory SettingsSitesEvent.unpublishView(String viewId) = + _UnpublishView; + const factory SettingsSitesEvent.updateNamespace(String namespace) = + _UpdateNamespace; + const factory SettingsSitesEvent.updatePublishName( + String viewId, + String name, + ) = _UpdatePublishName; + const factory SettingsSitesEvent.upgradeSubscription() = _UpgradeSubscription; + const factory SettingsSitesEvent.setHomePage(String? viewId) = _SetHomePage; + const factory SettingsSitesEvent.removeHomePage() = _RemoveHomePage; +} + +enum SettingsSitesActionType { + none, + unpublishView, + updateNamespace, + fetchPublishedViews, + updatePublishName, + fetchUserSubscription, + upgradeSubscription, + setHomePage, + removeHomePage, +} + +class SettingsSitesActionResult { + const SettingsSitesActionResult({ + required this.actionType, + required this.isLoading, + required this.result, + }); + + factory SettingsSitesActionResult.none() => const SettingsSitesActionResult( + actionType: SettingsSitesActionType.none, + isLoading: false, + result: null, + ); + + final SettingsSitesActionType actionType; + final FlowyResult? result; + final bool isLoading; + + @override + String toString() { + return 'SettingsSitesActionResult(actionType: $actionType, isLoading: $isLoading, result: $result)'; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_view.dart new file mode 100644 index 0000000000000..7b00e652edc15 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/sites/settings_sites_view.dart @@ -0,0 +1,237 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/constants.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_header.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_item.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_item.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_item_header.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SettingsSitesPage extends StatelessWidget { + const SettingsSitesPage({ + super.key, + required this.workspaceId, + required this.user, + }); + + final String workspaceId; + final UserProfilePB user; + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => SettingsSitesBloc( + workspaceId: workspaceId, + user: user, + )..add(const SettingsSitesEvent.initial()), + ), + BlocProvider( + create: (context) => UserWorkspaceBloc(userProfile: user) + ..add(const UserWorkspaceEvent.initial()), + ), + ], + child: const _SettingsSitesPageView(), + ); + } +} + +class _SettingsSitesPageView extends StatelessWidget { + const _SettingsSitesPageView(); + + @override + Widget build(BuildContext context) { + return SettingsBody( + title: LocaleKeys.settings_sites_title.tr(), + autoSeparate: false, + children: [ + // Domain / Namespace + _buildNamespaceCategory(context), + const VSpace(36), + // All published pages + _buildPublishedViewsCategory(context), + ], + ); + } + + Widget _buildNamespaceCategory(BuildContext context) { + return SettingsCategory( + title: LocaleKeys.settings_sites_namespaceHeader.tr(), + description: LocaleKeys.settings_sites_namespaceDescription.tr(), + descriptionColor: Theme.of(context).hintColor, + children: [ + const FlowyDivider(), + BlocConsumer( + listener: _onListener, + builder: (context, state) { + return SeparatedColumn( + crossAxisAlignment: CrossAxisAlignment.start, + separatorBuilder: () => const FlowyDivider( + padding: EdgeInsets.symmetric(vertical: 12.0), + ), + children: [ + const DomainHeader(), + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 36.0), + child: Transform.translate( + offset: const Offset( + -SettingsPageSitesConstants.alignPadding, + 0, + ), + child: DomainItem( + namespace: state.namespace, + homepage: '', + ), + ), + ), + ], + ); + }, + ), + ], + ); + } + + Widget _buildPublishedViewsCategory(BuildContext context) { + return SettingsCategory( + title: LocaleKeys.settings_sites_publishedPage_title.tr(), + description: LocaleKeys.settings_sites_publishedPage_description.tr(), + descriptionColor: Theme.of(context).hintColor, + children: [ + const FlowyDivider(), + BlocBuilder( + builder: (context, state) { + return SeparatedColumn( + crossAxisAlignment: CrossAxisAlignment.start, + separatorBuilder: () => const FlowyDivider( + padding: EdgeInsets.symmetric(vertical: 12.0), + ), + children: _buildPublishedViewsResult(context, state), + ); + }, + ), + ], + ); + } + + List _buildPublishedViewsResult( + BuildContext context, + SettingsSitesState state, + ) { + final publishedViews = state.publishedViews; + final List children = [ + const PublishViewItemHeader(), + ]; + + if (!state.isLoading) { + if (publishedViews.isEmpty) { + children.add( + FlowyText.regular( + LocaleKeys.settings_sites_publishedPage_emptyHinText.tr(), + color: Theme.of(context).hintColor, + ), + ); + } else { + children.addAll( + publishedViews.map( + (view) => Transform.translate( + offset: const Offset( + -SettingsPageSitesConstants.alignPadding, + 0, + ), + child: PublishedViewItem(publishInfoView: view), + ), + ), + ); + } + } else { + children.add( + const Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 3), + ), + ), + ); + } + + return children; + } + + void _onListener(BuildContext context, SettingsSitesState state) { + final actionResult = state.actionResult; + final type = actionResult?.actionType; + final result = actionResult?.result; + if (type == SettingsSitesActionType.upgradeSubscription && result != null) { + result.onFailure((f) { + Log.error('Failed to generate payment link for Pro Plan: ${f.msg}'); + + showToastNotification( + context, + message: + LocaleKeys.settings_sites_error_failedToGeneratePaymentLink.tr(), + type: ToastificationType.error, + ); + }); + } else if (type == SettingsSitesActionType.unpublishView && + result != null) { + result.fold((_) { + showToastNotification( + context, + message: LocaleKeys.publish_unpublishSuccessfully.tr(), + ); + }, (f) { + Log.error('Failed to unpublish view: ${f.msg}'); + + showToastNotification( + context, + message: LocaleKeys.publish_unpublishFailed.tr(), + type: ToastificationType.error, + description: f.msg, + ); + }); + } else if (type == SettingsSitesActionType.setHomePage && result != null) { + result.fold((s) { + showToastNotification( + context, + message: LocaleKeys.settings_sites_success_setHomepageSuccess.tr(), + ); + }, (f) { + Log.error('Failed to set homepage: ${f.msg}'); + + showToastNotification( + context, + message: LocaleKeys.settings_sites_error_setHomepageFailed.tr(), + type: ToastificationType.error, + ); + }); + } else if (type == SettingsSitesActionType.removeHomePage && + result != null) { + result.fold((s) { + showToastNotification( + context, + message: LocaleKeys.settings_sites_success_removeHomePageSuccess.tr(), + ); + }, (f) { + Log.error('Failed to remove homepage: ${f.msg}'); + + showToastNotification( + context, + message: LocaleKeys.settings_sites_error_removeHomePageFailed.tr(), + type: ToastificationType.error, + ); + }); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart new file mode 100644 index 0000000000000..24e1e52deb792 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -0,0 +1,489 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/appflowy_cache_manager.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/share_log_files.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appflowy_cloud_urls_bloc.dart'; +import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/settings_ai_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_billing_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_manage_data_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_plan_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_shortcuts_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/sites/settings_sites_view.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_page.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_notifications_view.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'widgets/setting_cloud.dart'; + +@visibleForTesting +const kSelfHostedTextInputFieldKey = + ValueKey('self_hosted_url_input_text_field'); + +class SettingsDialog extends StatelessWidget { + SettingsDialog( + this.user, { + required this.dismissDialog, + required this.didLogout, + required this.restartApp, + this.initPage, + }) : super(key: ValueKey(user.id)); + + final UserProfilePB user; + final SettingsPage? initPage; + final VoidCallback dismissDialog; + final VoidCallback didLogout; + final VoidCallback restartApp; + + @override + Widget build(BuildContext context) { + final width = MediaQuery.of(context).size.width * 0.6; + return BlocProvider( + create: (context) => SettingsDialogBloc( + user, + context.read().state.currentWorkspace?.role, + initPage: initPage, + )..add(const SettingsDialogEvent.initial()), + child: BlocBuilder( + builder: (context, state) => FlowyDialog( + width: width, + constraints: const BoxConstraints(minWidth: 564), + child: ScaffoldMessenger( + child: Scaffold( + backgroundColor: Colors.transparent, + body: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 200, + child: SettingsMenu( + userProfile: user, + changeSelectedPage: (index) => context + .read() + .add(SettingsDialogEvent.setSelectedPage(index)), + currentPage: + context.read().state.page, + isBillingEnabled: state.isBillingEnabled, + ), + ), + Expanded( + child: getSettingsView( + context + .read() + .state + .currentWorkspace! + .workspaceId, + context.read().state.page, + context.read().state.userProfile, + context + .read() + .state + .currentWorkspace + ?.role, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget getSettingsView( + String workspaceId, + SettingsPage page, + UserProfilePB user, + AFRolePB? currentWorkspaceMemberRole, + ) { + switch (page) { + case SettingsPage.account: + return SettingsAccountView( + userProfile: user, + didLogout: didLogout, + didLogin: dismissDialog, + ); + case SettingsPage.workspace: + return SettingsWorkspaceView( + userProfile: user, + currentWorkspaceMemberRole: currentWorkspaceMemberRole, + ); + case SettingsPage.manageData: + return SettingsManageDataView(userProfile: user); + case SettingsPage.notifications: + return const SettingsNotificationsView(); + case SettingsPage.cloud: + return SettingCloud(restartAppFlowy: () => restartApp()); + case SettingsPage.shortcuts: + return const SettingsShortcutsView(); + case SettingsPage.ai: + if (user.authenticator == AuthenticatorPB.AppFlowyCloud) { + return SettingsAIView( + userProfile: user, + currentWorkspaceMemberRole: currentWorkspaceMemberRole, + workspaceId: workspaceId, + ); + } else { + return const AIFeatureOnlySupportedWhenUsingAppFlowyCloud(); + } + case SettingsPage.member: + return WorkspaceMembersPage( + userProfile: user, + workspaceId: workspaceId, + ); + case SettingsPage.plan: + return SettingsPlanView( + workspaceId: workspaceId, + user: user, + ); + case SettingsPage.billing: + return SettingsBillingView( + workspaceId: workspaceId, + user: user, + ); + case SettingsPage.sites: + return SettingsSitesPage( + workspaceId: workspaceId, + user: user, + ); + case SettingsPage.featureFlags: + return const FeatureFlagsPage(); + default: + return const SizedBox.shrink(); + } + } +} + +class SimpleSettingsDialog extends StatefulWidget { + const SimpleSettingsDialog({super.key}); + + @override + State createState() => _SimpleSettingsDialogState(); +} + +class _SimpleSettingsDialogState extends State { + SettingsPage page = SettingsPage.cloud; + + @override + Widget build(BuildContext context) { + final settings = context.watch().state; + + return FlowyDialog( + width: MediaQuery.of(context).size.width * 0.7, + constraints: const BoxConstraints(maxWidth: 784, minWidth: 564), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // header + FlowyText( + LocaleKeys.signIn_settings.tr(), + fontSize: 36.0, + fontWeight: FontWeight.w600, + ), + const VSpace(18.0), + + // language + _LanguageSettings(key: ValueKey('language${settings.hashCode}')), + const VSpace(22.0), + + // self-host cloud + _SelfHostSettings(key: ValueKey('selfhost${settings.hashCode}')), + const VSpace(22.0), + + // support + _SupportSettings(key: ValueKey('support${settings.hashCode}')), + ], + ), + ), + ), + ); + } +} + +class _LanguageSettings extends StatelessWidget { + const _LanguageSettings({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return SettingsCategory( + title: LocaleKeys.settings_workspacePage_language_title.tr(), + children: const [LanguageDropdown()], + ); + } +} + +class _SelfHostSettings extends StatefulWidget { + const _SelfHostSettings({ + super.key, + }); + + @override + State<_SelfHostSettings> createState() => _SelfHostSettingsState(); +} + +class _SelfHostSettingsState extends State<_SelfHostSettings> { + final textController = TextEditingController(); + AuthenticatorType type = AuthenticatorType.appflowyCloud; + + @override + void initState() { + super.initState(); + + getAppFlowyCloudUrl().then((url) { + textController.text = url; + if (kAppflowyCloudUrl != url) { + setState(() { + type = AuthenticatorType.appflowyCloudSelfHost; + }); + } + }); + } + + @override + void dispose() { + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SettingsCategory( + title: LocaleKeys.settings_menu_cloudAppFlowy.tr(), + children: [ + Flexible( + child: SettingsServerDropdownMenu( + selectedServer: type, + onSelected: _onSelected, + ), + ), + if (type == AuthenticatorType.appflowyCloudSelfHost) _buildInputField(), + ], + ); + } + + Widget _buildInputField() { + return Row( + children: [ + Expanded( + child: SizedBox( + height: 36, + child: FlowyTextField( + key: kSelfHostedTextInputFieldKey, + controller: textController, + autoFocus: false, + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + ), + hintText: kAppflowyCloudUrl, + onEditingComplete: () => _saveUrl( + url: textController.text, + type: AuthenticatorType.appflowyCloudSelfHost, + ), + ), + ), + ), + const HSpace(12.0), + Container( + height: 36, + constraints: const BoxConstraints(minWidth: 78), + child: OutlinedRoundedButton( + text: LocaleKeys.button_save.tr(), + onTap: () => _saveUrl( + url: textController.text, + type: AuthenticatorType.appflowyCloudSelfHost, + ), + ), + ), + ], + ); + } + + void _onSelected(AuthenticatorType type) { + if (type == this.type) { + return; + } + + Log.info('Switching server type to $type'); + + setState(() { + this.type = type; + }); + + if (type == AuthenticatorType.appflowyCloud) { + textController.text = kAppflowyCloudUrl; + _saveUrl( + url: textController.text, + type: type, + ); + } + } + + void _saveUrl({ + required String url, + required AuthenticatorType type, + }) { + if (url.isEmpty) { + showToastNotification( + context, + message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(), + type: ToastificationType.error, + ); + return; + } + + validateUrl(url).fold( + (url) async { + showToastNotification( + context, + message: LocaleKeys.settings_menu_changeUrl.tr(args: [url]), + ); + + Navigator.of(context).pop(); + await useAppFlowyBetaCloudWithURL(url, type); + await runAppFlowy(); + }, + (err) { + showToastNotification( + context, + message: LocaleKeys.settings_menu_pleaseInputValidURL.tr(), + type: ToastificationType.error, + ); + Log.error(err); + }, + ); + } +} + +@visibleForTesting +extension SettingsServerDropdownMenuExtension on AuthenticatorType { + String get label { + switch (this) { + case AuthenticatorType.appflowyCloud: + return LocaleKeys.settings_menu_cloudAppFlowy.tr(); + case AuthenticatorType.appflowyCloudSelfHost: + return LocaleKeys.settings_menu_cloudAppFlowySelfHost.tr(); + default: + throw Exception('Unsupported server type: $this'); + } + } +} + +@visibleForTesting +class SettingsServerDropdownMenu extends StatelessWidget { + const SettingsServerDropdownMenu({ + super.key, + required this.selectedServer, + required this.onSelected, + }); + + final AuthenticatorType selectedServer; + final void Function(AuthenticatorType type) onSelected; + + // in the settings page from sign in page, we only support appflowy cloud and self-hosted + static final supportedServers = [ + AuthenticatorType.appflowyCloud, + AuthenticatorType.appflowyCloudSelfHost, + ]; + + @override + Widget build(BuildContext context) { + return SettingsDropdown( + expandWidth: false, + onChanged: onSelected, + selectedOption: selectedServer, + options: supportedServers + .map( + (serverType) => buildDropdownMenuEntry( + context, + selectedValue: selectedServer, + value: serverType, + label: serverType.label, + ), + ) + .toList(), + ); + } +} + +class _SupportSettings extends StatelessWidget { + const _SupportSettings({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return SettingsCategory( + title: LocaleKeys.settings_mobile_support.tr(), + children: [ + // export logs + Row( + children: [ + FlowyText( + LocaleKeys.workspace_errorActions_exportLogFiles.tr(), + ), + const Spacer(), + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 78), + child: OutlinedRoundedButton( + text: LocaleKeys.settings_files_export.tr(), + onTap: () { + shareLogFiles(context); + }, + ), + ), + ], + ), + // clear cache + Row( + children: [ + FlowyText( + LocaleKeys.settings_files_clearCache.tr(), + ), + const Spacer(), + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 78), + child: OutlinedRoundedButton( + text: LocaleKeys.button_clear.tr(), + onTap: () async { + await getIt().clearAllCache(); + if (context.mounted) { + showToastNotification( + context, + message: LocaleKeys + .settings_manageDataPage_cache_dialog_successHint + .tr(), + ); + } + }, + ), + ), + ], + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart new file mode 100644 index 0000000000000..d005901cff8bf --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/shared/google_fonts_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +DropdownMenuEntry buildDropdownMenuEntry( + BuildContext context, { + required T value, + required String label, + T? selectedValue, + Widget? leadingWidget, + Widget? trailingWidget, + String? fontFamily, +}) { + final fontFamilyUsed = fontFamily != null + ? getGoogleFontSafely(fontFamily).fontFamily ?? defaultFontFamily + : defaultFontFamily; + + return DropdownMenuEntry( + style: ButtonStyle( + foregroundColor: + WidgetStatePropertyAll(Theme.of(context).colorScheme.primary), + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + ), + minimumSize: const WidgetStatePropertyAll(Size(double.infinity, 29)), + maximumSize: const WidgetStatePropertyAll(Size(double.infinity, 29)), + ), + value: value, + label: label, + leadingIcon: leadingWidget, + labelWidget: FlowyText.regular( + label, + fontSize: 14, + textAlign: TextAlign.start, + fontFamily: fontFamilyUsed, + ), + trailingIcon: Row( + children: [ + if (trailingWidget != null) ...[ + trailingWidget, + const HSpace(8), + ], + value == selectedValue + ? const FlowySvg(FlowySvgs.check_s) + : const SizedBox.shrink(), + ], + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/document_color_setting_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/document_color_setting_button.dart new file mode 100644 index 0000000000000..9892fd18a8e74 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/document_color_setting_button.dart @@ -0,0 +1,429 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/color_to_hex_string.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/utils/hex_opacity_string_extension.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flex_color_picker/flex_color_picker.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; + +class DocumentColorSettingButton extends StatefulWidget { + const DocumentColorSettingButton({ + super.key, + required this.currentColor, + required this.previewWidgetBuilder, + required this.dialogTitle, + required this.onApply, + }); + + /// current color from backend + final Color currentColor; + + /// Build a preview widget with the given color + /// It shows both on the [DocumentColorSettingButton] and [_DocumentColorSettingDialog] + final Widget Function(Color? color) previewWidgetBuilder; + + final String dialogTitle; + + final void Function(Color selectedColorOnDialog) onApply; + + @override + State createState() => + _DocumentColorSettingButtonState(); +} + +class _DocumentColorSettingButtonState + extends State { + late Color newColor = widget.currentColor; + + @override + Widget build(BuildContext context) { + return FlowyButton( + margin: const EdgeInsets.all(8), + text: widget.previewWidgetBuilder.call(widget.currentColor), + hoverColor: Theme.of(context).colorScheme.secondaryContainer, + expandText: false, + onTap: () => SettingsAlertDialog( + title: widget.dialogTitle, + confirm: () { + widget.onApply(newColor); + Navigator.of(context).pop(); + }, + children: [ + _DocumentColorSettingDialog( + formKey: GlobalKey(), + currentColor: widget.currentColor, + previewWidgetBuilder: widget.previewWidgetBuilder, + onChanged: (color) => newColor = color, + ), + ], + ).show(context), + ); + } +} + +class _DocumentColorSettingDialog extends StatefulWidget { + const _DocumentColorSettingDialog({ + required this.formKey, + required this.currentColor, + required this.previewWidgetBuilder, + required this.onChanged, + }); + + final GlobalKey formKey; + final Color currentColor; + final Widget Function(Color?) previewWidgetBuilder; + final void Function(Color selectedColor) onChanged; + + @override + State<_DocumentColorSettingDialog> createState() => + DocumentColorSettingDialogState(); +} + +class DocumentColorSettingDialogState + extends State<_DocumentColorSettingDialog> { + /// The color displayed in the dialog. + /// It is `null` when the user didn't enter a valid color value. + late Color? selectedColorOnDialog; + late String currentColorHexString; + late TextEditingController hexController; + late TextEditingController opacityController; + + @override + void initState() { + super.initState(); + selectedColorOnDialog = widget.currentColor; + currentColorHexString = ColorExtension(widget.currentColor).toHexString(); + hexController = TextEditingController( + text: currentColorHexString.extractHex(), + ); + opacityController = TextEditingController( + text: currentColorHexString.extractOpacity(), + ); + } + + @override + void dispose() { + hexController.dispose(); + opacityController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SizedBox( + width: 100, + height: 40, + child: Center( + child: widget.previewWidgetBuilder( + selectedColorOnDialog, + ), + ), + ), + const VSpace(8), + Form( + key: widget.formKey, + child: Column( + children: [ + _ColorSettingTextField( + controller: hexController, + labelText: LocaleKeys.editor_hexValue.tr(), + hintText: '6fc9e7', + onChanged: (_) => _updateSelectedColor(), + onFieldSubmitted: (_) => _updateSelectedColor(), + validator: (v) => validateHexValue(v, opacityController.text), + suffixIcon: Padding( + padding: const EdgeInsets.all(6.0), + child: FlowyIconButton( + onPressed: () => _showColorPickerDialog( + context: context, + currentColor: widget.currentColor, + updateColor: _updateColor, + ), + icon: const FlowySvg( + FlowySvgs.m_aa_color_s, + size: Size.square(20), + ), + ), + ), + ), + const VSpace(8), + _ColorSettingTextField( + controller: opacityController, + labelText: LocaleKeys.editor_opacity.tr(), + hintText: '50', + onChanged: (_) => _updateSelectedColor(), + onFieldSubmitted: (_) => _updateSelectedColor(), + validator: (value) => validateOpacityValue(value), + ), + ], + ), + ), + ], + ); + } + + void _updateSelectedColor() { + if (widget.formKey.currentState!.validate()) { + setState(() { + final colorValue = int.tryParse( + hexController.text.combineHexWithOpacity(opacityController.text), + ); + // colorValue has been validated in the _ColorSettingTextField for hex value and it won't be null as this point + selectedColorOnDialog = Color(colorValue!); + widget.onChanged(selectedColorOnDialog!); + }); + } + } + + void _updateColor(Color color) { + setState(() { + hexController.text = ColorExtension(color).toHexString().extractHex(); + opacityController.text = + ColorExtension(color).toHexString().extractOpacity(); + }); + _updateSelectedColor(); + } +} + +class _ColorSettingTextField extends StatelessWidget { + const _ColorSettingTextField({ + required this.controller, + required this.labelText, + required this.hintText, + required this.onFieldSubmitted, + this.suffixIcon, + this.onChanged, + this.validator, + }); + + final TextEditingController controller; + final String labelText; + final String hintText; + final void Function(String) onFieldSubmitted; + final Widget? suffixIcon; + final void Function(String)? onChanged; + final String? Function(String?)? validator; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context); + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + suffixIcon: suffixIcon, + border: OutlineInputBorder( + borderSide: BorderSide(color: style.colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: style.colorScheme.outline), + ), + ), + style: style.textTheme.bodyMedium, + onChanged: onChanged, + onFieldSubmitted: onFieldSubmitted, + validator: validator, + autovalidateMode: AutovalidateMode.onUserInteraction, + ); + } +} + +String? validateHexValue(String? hexValue, String opacityValue) { + if (hexValue == null || hexValue.isEmpty) { + return LocaleKeys.settings_appearance_documentSettings_hexEmptyError.tr(); + } + if (hexValue.length != 6) { + return LocaleKeys.settings_appearance_documentSettings_hexLengthError.tr(); + } + + if (validateOpacityValue(opacityValue) == null) { + final colorValue = + int.tryParse(hexValue.combineHexWithOpacity(opacityValue)); + + if (colorValue == null) { + return LocaleKeys.settings_appearance_documentSettings_hexInvalidError + .tr(); + } + } + + return null; +} + +String? validateOpacityValue(String? value) { + if (value == null || value.isEmpty) { + return LocaleKeys.settings_appearance_documentSettings_opacityEmptyError + .tr(); + } + + final opacityInt = int.tryParse(value); + if (opacityInt == null || opacityInt > 100 || opacityInt <= 0) { + return LocaleKeys.settings_appearance_documentSettings_opacityRangeError + .tr(); + } + return null; +} + +const _kColorCircleWidth = 32.0; +const _kColorCircleHeight = 32.0; +const _kColorCircleRadius = 20.0; +const _kColorOpacityThumbRadius = 23.0; +const _kDialogButtonPaddingHorizontal = 24.0; +const _kDialogButtonPaddingVertical = 12.0; +const _kColorsColumnSpacing = 12.0; + +class _ColorPicker extends StatelessWidget { + const _ColorPicker({ + required this.selectedColor, + required this.onColorChanged, + }); + + final Color selectedColor; + final void Function(Color) onColorChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ColorPicker( + width: _kColorCircleWidth, + height: _kColorCircleHeight, + borderRadius: _kColorCircleRadius, + enableOpacity: true, + opacityThumbRadius: _kColorOpacityThumbRadius, + columnSpacing: _kColorsColumnSpacing, + enableTooltips: false, + hasBorder: true, + borderColor: theme.colorScheme.outline, + pickersEnabled: const { + ColorPickerType.both: false, + ColorPickerType.primary: true, + ColorPickerType.accent: true, + ColorPickerType.wheel: true, + }, + subheading: Text( + LocaleKeys.settings_appearance_documentSettings_colorShade.tr(), + style: theme.textTheme.labelLarge, + ), + opacitySubheading: Text( + LocaleKeys.settings_appearance_documentSettings_opacity.tr(), + style: theme.textTheme.labelLarge, + ), + onColorChanged: onColorChanged, + ); + } +} + +class _ColorPickerActions extends StatelessWidget { + const _ColorPickerActions({ + required this.onReset, + required this.onUpdate, + }); + + final VoidCallback onReset; + final VoidCallback onUpdate; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: 24, + child: FlowyTextButton( + LocaleKeys.button_cancel.tr(), + padding: const EdgeInsets.symmetric( + horizontal: _kDialogButtonPaddingHorizontal, + vertical: _kDialogButtonPaddingVertical, + ), + fontColor: AFThemeExtension.of(context).textColor, + fillColor: Colors.transparent, + hoverColor: Colors.transparent, + radius: Corners.s12Border, + onPressed: onReset, + ), + ), + const HSpace(8), + SizedBox( + height: 48, + child: FlowyTextButton( + LocaleKeys.button_done.tr(), + padding: const EdgeInsets.symmetric( + horizontal: _kDialogButtonPaddingHorizontal, + vertical: _kDialogButtonPaddingVertical, + ), + radius: Corners.s12Border, + fontHoverColor: Colors.white, + fillColor: Theme.of(context).colorScheme.primary, + hoverColor: const Color(0xFF005483), + onPressed: onUpdate, + ), + ), + ], + ); + } +} + +void _showColorPickerDialog({ + required BuildContext context, + String? title, + required Color currentColor, + required void Function(Color) updateColor, +}) { + Color selectedColor = currentColor; + + showDialog( + context: context, + barrierColor: const Color.fromARGB(128, 0, 0, 0), + builder: (context) => FlowyDialog( + expandHeight: false, + title: Row( + children: [ + const FlowySvg(FlowySvgs.m_aa_color_s), + const HSpace(12), + FlowyText( + title ?? + LocaleKeys.settings_appearance_documentSettings_pickColor.tr(), + fontSize: 20, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ColorPicker( + selectedColor: selectedColor, + onColorChanged: (color) => selectedColor = color, + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const HSpace(8), + _ColorPickerActions( + onReset: () { + updateColor(currentColor); + Navigator.of(context).pop(); + }, + onUpdate: () { + updateColor(selectedColor); + Navigator.of(context).pop(); + }, + ), + ], + ), + ), + ], + ), + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart new file mode 100644 index 0000000000000..eb39df8b32c19 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/flowy_gradient_button.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra_ui/style_widget/text.dart'; + +class FlowyGradientButton extends StatefulWidget { + const FlowyGradientButton({ + super.key, + required this.label, + this.onPressed, + this.fontWeight = FontWeight.w600, + this.textColor = Colors.white, + this.backgroundColor, + }); + + final String label; + final VoidCallback? onPressed; + final FontWeight fontWeight; + + /// Used to provide a custom foreground color for the button, used in cases + /// where a custom [backgroundColor] is provided and the default text color + /// does not have enough contrast. + /// + final Color textColor; + + /// Used to provide a custom background color for the button, this will + /// override the gradient behavior, and is mostly used in rare cases + /// where the gradient doesn't have contrast with the background. + /// + final Color? backgroundColor; + + @override + State createState() => _FlowyGradientButtonState(); +} + +class _FlowyGradientButtonState extends State { + bool isHovering = false; + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: (_) => widget.onPressed?.call(), + child: MouseRegion( + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + cursor: SystemMouseCursors.click, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + blurRadius: 4, + color: Colors.black.withOpacity(0.25), + offset: const Offset(0, 2), + ), + ], + borderRadius: BorderRadius.circular(16), + color: widget.backgroundColor, + gradient: widget.backgroundColor != null + ? null + : LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + isHovering + ? const Color.fromARGB(255, 57, 40, 92) + : const Color(0xFF44326B), + isHovering + ? const Color.fromARGB(255, 96, 53, 164) + : const Color(0xFF7547C0), + ], + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: FlowyText( + widget.label, + fontSize: 16, + fontWeight: widget.fontWeight, + color: widget.textColor, + maxLines: 2, + textAlign: TextAlign.center, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_action.dart new file mode 100644 index 0000000000000..e4551d1c2c2ab --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_action.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; + +class SettingAction extends StatelessWidget { + const SettingAction({ + super.key, + required this.onPressed, + required this.icon, + this.label, + this.tooltip, + }); + + final VoidCallback onPressed; + final Widget icon; + final String? label; + final String? tooltip; + + @override + Widget build(BuildContext context) { + final child = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onPressed, + child: SizedBox( + height: 26, + child: FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + children: [ + icon, + if (label != null) ...[ + const HSpace(4), + FlowyText.regular(label!), + ], + ], + ), + ), + ), + ), + ); + + if (tooltip != null) { + return FlowyTooltip( + message: tooltip!, + child: child, + ); + } + + return child; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_list_tile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_list_tile.dart new file mode 100644 index 0000000000000..9c1f0f4fc434e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_list_tile.dart @@ -0,0 +1,91 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class SettingListTile extends StatelessWidget { + const SettingListTile({ + super.key, + this.resetTooltipText, + this.resetButtonKey, + required this.label, + this.hint, + this.trailing, + this.subtitle, + this.onResetRequested, + }); + + final String label; + final String? hint; + final String? resetTooltipText; + final Key? resetButtonKey; + final List? trailing; + final List? subtitle; + final VoidCallback? onResetRequested; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.medium( + label, + fontSize: 14, + overflow: TextOverflow.ellipsis, + ), + if (hint != null) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: FlowyText.regular( + hint!, + fontSize: 10, + color: Theme.of(context).hintColor, + ), + ), + if (subtitle != null) ...subtitle!, + ], + ), + ), + if (trailing != null) ...trailing!, + if (onResetRequested != null) + SettingsResetButton( + key: resetButtonKey, + resetTooltipText: resetTooltipText, + onResetRequested: onResetRequested, + ), + ], + ); + } +} + +class SettingsResetButton extends StatelessWidget { + const SettingsResetButton({ + super.key, + this.resetTooltipText, + this.onResetRequested, + }); + + final String? resetTooltipText; + final VoidCallback? onResetRequested; + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + hoverColor: Theme.of(context).colorScheme.secondaryContainer, + width: 24, + icon: FlowySvg( + FlowySvgs.restore_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(20), + ), + iconColorOnHover: Theme.of(context).colorScheme.onPrimary, + tooltipText: + resetTooltipText ?? LocaleKeys.settings_appearance_resetSetting.tr(), + onPressed: onResetRequested, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_value_dropdown.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_value_dropdown.dart new file mode 100644 index 0000000000000..5d1c858d29c40 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_value_dropdown.dart @@ -0,0 +1,53 @@ +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class SettingValueDropDown extends StatefulWidget { + const SettingValueDropDown({ + super.key, + required this.currentValue, + required this.popupBuilder, + this.popoverKey, + this.onClose, + this.child, + this.popoverController, + this.offset, + }); + + final String currentValue; + final Key? popoverKey; + final Widget Function(BuildContext) popupBuilder; + final void Function()? onClose; + final Widget? child; + final PopoverController? popoverController; + final Offset? offset; + + @override + State createState() => _SettingValueDropDownState(); +} + +class _SettingValueDropDownState extends State { + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + key: widget.popoverKey, + controller: widget.popoverController, + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: widget.popupBuilder, + constraints: const BoxConstraints( + minWidth: 80, + maxWidth: 160, + maxHeight: 400, + ), + offset: widget.offset, + onClose: widget.onClose, + child: widget.child ?? + FlowyTextButton( + widget.currentValue, + fontColor: AFThemeExtension.maybeOf(context)?.onBackground, + fillColor: Colors.transparent, + onPressed: () {}, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_actionable_input.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_actionable_input.dart new file mode 100644 index 0000000000000..6f92696f28926 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_actionable_input.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class SettingsActionableInput extends StatelessWidget { + const SettingsActionableInput({ + super.key, + required this.controller, + this.focusNode, + this.placeholder, + this.onSave, + this.actions = const [], + }); + + final TextEditingController controller; + final FocusNode? focusNode; + final String? placeholder; + final Function(String)? onSave; + final List actions; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Flexible( + child: SizedBox( + height: 48, + child: FlowyTextField( + controller: controller, + focusNode: focusNode, + hintText: placeholder, + autoFocus: false, + isDense: false, + suffixIconConstraints: + BoxConstraints.tight(const Size(23 + 18, 24)), + textStyle: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + onSubmitted: onSave, + ), + ), + ), + if (actions.isNotEmpty) ...[ + const HSpace(8), + SeparatedRow( + separatorBuilder: () => const HSpace(16), + children: actions, + ), + ], + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_alert_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_alert_dialog.dart new file mode 100644 index 0000000000000..61a29b77a19f5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_alert_dialog.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; + +class SettingsAlertDialog extends StatefulWidget { + const SettingsAlertDialog({ + super.key, + this.icon, + required this.title, + this.subtitle, + this.children, + this.cancel, + this.confirm, + this.confirmLabel, + this.hideCancelButton = false, + this.isDangerous = false, + this.implyLeading = false, + this.enableConfirmNotifier, + }); + + final Widget? icon; + final String title; + final String? subtitle; + final List? children; + final void Function()? cancel; + final void Function()? confirm; + final String? confirmLabel; + final bool hideCancelButton; + final bool isDangerous; + final ValueNotifier? enableConfirmNotifier; + + /// If true, a back button will show in the top left corner + final bool implyLeading; + + @override + State createState() => _SettingsAlertDialogState(); +} + +class _SettingsAlertDialogState extends State { + bool enableConfirm = true; + + @override + void initState() { + super.initState(); + if (widget.enableConfirmNotifier != null) { + widget.enableConfirmNotifier!.addListener(_updateEnableConfirm); + enableConfirm = widget.enableConfirmNotifier!.value; + } + } + + void _updateEnableConfirm() { + setState(() => enableConfirm = widget.enableConfirmNotifier!.value); + } + + @override + void dispose() { + if (widget.enableConfirmNotifier != null) { + widget.enableConfirmNotifier!.removeListener(_updateEnableConfirm); + } + super.dispose(); + } + + @override + void didUpdateWidget(covariant SettingsAlertDialog oldWidget) { + oldWidget.enableConfirmNotifier?.removeListener(_updateEnableConfirm); + widget.enableConfirmNotifier?.addListener(_updateEnableConfirm); + enableConfirm = widget.enableConfirmNotifier?.value ?? true; + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return StyledDialog( + maxHeight: 600, + maxWidth: 600, + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.implyLeading) ...[ + GestureDetector( + onTap: Navigator.of(context).pop, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + children: [ + const FlowySvg( + FlowySvgs.arrow_back_m, + size: Size.square(24), + ), + const HSpace(8), + FlowyText.semibold( + LocaleKeys.button_back.tr(), + fontSize: 16, + ), + ], + ), + ), + ), + ], + const Spacer(), + GestureDetector( + onTap: Navigator.of(context).pop, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowySvg( + FlowySvgs.m_close_m, + size: const Size.square(20), + color: Theme.of(context).colorScheme.outline, + ), + ), + ), + ], + ), + if (widget.icon != null) ...[ + widget.icon!, + const VSpace(16), + ], + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: FlowyText.medium( + widget.title, + fontSize: 22, + color: Theme.of(context).colorScheme.tertiary, + maxLines: null, + ), + ), + ], + ), + if (widget.subtitle?.isNotEmpty ?? false) ...[ + const VSpace(16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: FlowyText.regular( + widget.subtitle!, + fontSize: 16, + color: Theme.of(context).colorScheme.tertiary, + textAlign: TextAlign.center, + maxLines: null, + ), + ), + ], + ), + ], + if (widget.children?.isNotEmpty ?? false) ...[ + const VSpace(16), + ...widget.children!, + ], + if (widget.confirm != null || !widget.hideCancelButton) ...[ + const VSpace(20), + ], + _Actions( + hideCancelButton: widget.hideCancelButton, + confirmLabel: widget.confirmLabel, + cancel: widget.cancel, + confirm: widget.confirm, + isDangerous: widget.isDangerous, + enableConfirm: enableConfirm, + ), + ], + ), + ); + } +} + +class _Actions extends StatelessWidget { + const _Actions({ + required this.hideCancelButton, + this.confirmLabel, + this.cancel, + this.confirm, + this.isDangerous = false, + this.enableConfirm = true, + }); + + final bool hideCancelButton; + final String? confirmLabel; + final VoidCallback? cancel; + final VoidCallback? confirm; + final bool isDangerous; + final bool enableConfirm; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (!hideCancelButton) ...[ + SizedBox( + height: 24, + child: FlowyTextButton( + LocaleKeys.button_cancel.tr(), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + fontColor: AFThemeExtension.of(context).textColor, + fillColor: Colors.transparent, + hoverColor: Colors.transparent, + radius: Corners.s12Border, + onPressed: () { + cancel?.call(); + Navigator.of(context).pop(); + }, + ), + ), + ], + if (confirm != null && !hideCancelButton) ...[ + const HSpace(8), + ], + if (confirm != null) ...[ + SizedBox( + height: 48, + child: FlowyTextButton( + confirmLabel ?? LocaleKeys.button_confirm.tr(), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + radius: Corners.s12Border, + fontColor: isDangerous ? Colors.white : null, + fontHoverColor: !enableConfirm ? null : Colors.white, + fillColor: !enableConfirm + ? Theme.of(context).dividerColor + : isDangerous + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + hoverColor: !enableConfirm + ? Theme.of(context).dividerColor + : isDangerous + ? Theme.of(context).colorScheme.error + : const Color(0xFF005483), + onPressed: enableConfirm ? confirm : null, + ), + ), + ], + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart new file mode 100644 index 0000000000000..8091a72684381 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class SettingsBody extends StatelessWidget { + const SettingsBody({ + super.key, + required this.title, + this.description, + this.autoSeparate = true, + required this.children, + }); + + final String title; + final String? description; + final bool autoSeparate; + final List children; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + physics: const ClampingScrollPhysics(), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsHeader(title: title, description: description), + Flexible( + child: SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => autoSeparate + ? const SettingsCategorySpacer() + : const SizedBox.shrink(), + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart new file mode 100644 index 0000000000000..a111fa2626472 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category.dart @@ -0,0 +1,71 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +/// Renders a simple category taking a title and the list +/// of children (settings) to be rendered. +/// +class SettingsCategory extends StatelessWidget { + const SettingsCategory({ + super.key, + required this.title, + this.description, + this.descriptionColor, + this.tooltip, + this.actions, + required this.children, + }); + + final String title; + final String? description; + final Color? descriptionColor; + final String? tooltip; + final List? actions; + final List children; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FlowyText.semibold( + title, + maxLines: 2, + fontSize: 16, + overflow: TextOverflow.ellipsis, + ), + if (tooltip != null) ...[ + const HSpace(4), + FlowyTooltip( + message: tooltip, + child: const FlowySvg(FlowySvgs.information_s), + ), + ], + const Spacer(), + if (actions != null) ...actions!, + ], + ), + const VSpace(8), + if (description?.isNotEmpty ?? false) ...[ + FlowyText.regular( + description!, + maxLines: 4, + fontSize: 12, + overflow: TextOverflow.ellipsis, + color: descriptionColor, + ), + const VSpace(8), + ], + SeparatedColumn( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + separatorBuilder: () => + children.length > 1 ? const VSpace(16) : const SizedBox.shrink(), + children: children, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category_spacer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category_spacer.dart new file mode 100644 index 0000000000000..5637fdd20c9fe --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_category_spacer.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +/// This is used to create a uniform space and divider +/// between categories in settings. +/// +class SettingsCategorySpacer extends StatelessWidget { + const SettingsCategorySpacer({super.key}); + + @override + Widget build(BuildContext context) => + const Divider(height: 32, color: Color(0xFFF2F2F2)); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dashed_divider.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dashed_divider.dart new file mode 100644 index 0000000000000..6a8405dc56ddb --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dashed_divider.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +/// Renders a dashed divider +/// +/// The length of each dash is the same as the gap. +/// +class SettingsDashedDivider extends StatelessWidget { + const SettingsDashedDivider({ + super.key, + this.color, + this.height, + this.strokeWidth = 1.0, + this.gap = 3.0, + this.direction = Axis.horizontal, + }); + + // The color of the divider, defaults to the theme's divider color + final Color? color; + + // The height of the divider, this will surround the divider equally + final double? height; + + // Thickness of the divider + final double strokeWidth; + + // Gap between the dashes + final double gap; + + // Direction of the divider + final Axis direction; + + @override + Widget build(BuildContext context) { + final double padding = + height != null && height! > 0 ? (height! - strokeWidth) / 2 : 0; + + return LayoutBuilder( + builder: (context, constraints) { + final items = _calculateItems(constraints); + return Padding( + padding: EdgeInsets.symmetric( + vertical: direction == Axis.horizontal ? padding : 0, + horizontal: direction == Axis.vertical ? padding : 0, + ), + child: Wrap( + direction: direction, + children: List.generate( + items, + (index) => Container( + margin: EdgeInsets.only( + right: direction == Axis.horizontal ? gap : 0, + bottom: direction == Axis.vertical ? gap : 0, + ), + width: direction == Axis.horizontal ? gap : strokeWidth, + height: direction == Axis.vertical ? gap : strokeWidth, + decoration: BoxDecoration( + color: color ?? Theme.of(context).dividerColor, + borderRadius: BorderRadius.circular(1.0), + ), + ), + ), + ), + ); + }, + ); + } + + int _calculateItems(BoxConstraints constraints) { + final double totalLength = direction == Axis.horizontal + ? constraints.maxWidth + : constraints.maxHeight; + + return (totalLength / (gap * 2)).floor(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart new file mode 100644 index 0000000000000..56d2c8d2cc6e6 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart @@ -0,0 +1,118 @@ +import 'package:appflowy/flutter/af_dropdown_menu.dart'; +import 'package:appflowy/shared/google_fonts_extension.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SettingsDropdown extends StatefulWidget { + const SettingsDropdown({ + super.key, + required this.selectedOption, + required this.options, + this.onChanged, + this.actions, + this.expandWidth = true, + }); + + final T selectedOption; + final List> options; + final void Function(T)? onChanged; + final List? actions; + final bool expandWidth; + + @override + State> createState() => _SettingsDropdownState(); +} + +class _SettingsDropdownState extends State> { + late final TextEditingController controller = TextEditingController( + text: widget.selectedOption is String + ? widget.selectedOption as String + : widget.options + .firstWhereOrNull((e) => e.value == widget.selectedOption) + ?.label ?? + '', + ); + + @override + Widget build(BuildContext context) { + final fontFamily = context.read().state.font; + final fontFamilyUsed = + getGoogleFontSafely(fontFamily).fontFamily ?? defaultFontFamily; + + return Row( + children: [ + Expanded( + child: AFDropdownMenu( + controller: controller, + expandedInsets: widget.expandWidth ? EdgeInsets.zero : null, + initialSelection: widget.selectedOption, + dropdownMenuEntries: widget.options, + textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontFamily: fontFamilyUsed, + fontWeight: FontWeight.w400, + ), + menuStyle: MenuStyle( + maximumSize: + const WidgetStatePropertyAll(Size(double.infinity, 250)), + elevation: const WidgetStatePropertyAll(10), + shadowColor: + WidgetStatePropertyAll(Colors.black.withOpacity(0.4)), + backgroundColor: WidgetStatePropertyAll( + Theme.of(context).cardColor, + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 6, vertical: 8), + ), + alignment: Alignment.bottomLeft, + ), + inputDecorationTheme: InputDecorationTheme( + contentPadding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 18, + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + borderRadius: Corners.s8Border, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + borderRadius: Corners.s8Border, + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + borderRadius: Corners.s8Border, + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + borderRadius: Corners.s8Border, + ), + ), + onSelected: (v) async { + v != null ? widget.onChanged?.call(v) : null; + }, + ), + ), + if (widget.actions?.isNotEmpty == true) ...[ + const HSpace(16), + SeparatedRow( + separatorBuilder: () => const HSpace(8), + children: widget.actions!, + ), + ], + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart new file mode 100644 index 0000000000000..c028e6886d151 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_header.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +/// Renders a simple header for the settings view +/// +class SettingsHeader extends StatelessWidget { + const SettingsHeader({super.key, required this.title, this.description}); + + final String title; + final String? description; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.semibold(title, fontSize: 24), + if (description?.isNotEmpty == true) ...[ + const VSpace(8), + FlowyText( + description!, + maxLines: 4, + fontSize: 12, + color: AFThemeExtension.of(context).secondaryTextColor, + ), + ], + const VSpace(16), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_input_field.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_input_field.dart new file mode 100644 index 0000000000000..d5a81655a5ec1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_input_field.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +/// This is used to describe a settings input field +/// +/// The input will have secondary action of "save" and "cancel" +/// which will only be shown when the input has changed. +/// +/// _Note: The label can overflow and will be ellipsized._ +/// +class SettingsInputField extends StatefulWidget { + const SettingsInputField({ + super.key, + this.label, + this.textController, + this.focusNode, + this.obscureText = false, + this.value, + this.placeholder, + this.tooltip, + this.onSave, + this.onCancel, + this.hideActions = false, + this.onChanged, + }); + + final String? label; + final TextEditingController? textController; + final FocusNode? focusNode; + + /// If true, the input field will be obscured + /// and an option to toggle to show the text will be provided. + /// + final bool obscureText; + + final String? value; + final String? placeholder; + final String? tooltip; + + /// If true the save and cancel options will not show below the + /// input field. + /// + final bool hideActions; + + final void Function(String)? onSave; + + /// The action to be performed when the cancel button is pressed. + /// + /// If null the button will **NOT** be disabled! Instead it will + /// reset the input to the original value. + /// + final void Function()? onCancel; + + final void Function(String)? onChanged; + + @override + State createState() => _SettingsInputFieldState(); +} + +class _SettingsInputFieldState extends State { + late final controller = + widget.textController ?? TextEditingController(text: widget.value); + late final FocusNode focusNode = widget.focusNode ?? FocusNode(); + late bool obscureText = widget.obscureText; + + @override + void dispose() { + if (widget.focusNode == null) { + focusNode.dispose(); + } + if (widget.textController == null) { + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + children: [ + if (widget.label?.isNotEmpty == true) ...[ + Flexible( + child: FlowyText.medium( + widget.label!, + color: AFThemeExtension.of(context).secondaryTextColor, + ), + ), + ], + if (widget.tooltip != null) ...[ + const HSpace(4), + FlowyTooltip( + message: widget.tooltip, + child: const FlowySvg(FlowySvgs.information_s), + ), + ], + ], + ), + if (widget.label?.isNotEmpty ?? false || widget.tooltip != null) + const VSpace(8), + SizedBox( + height: 48, + child: FlowyTextField( + focusNode: focusNode, + hintText: widget.placeholder, + controller: controller, + autoFocus: false, + obscureText: obscureText, + isDense: false, + suffixIconConstraints: + BoxConstraints.tight(const Size(23 + 18, 24)), + suffixIcon: !widget.obscureText + ? null + : GestureDetector( + onTap: () => setState(() => obscureText = !obscureText), + child: Padding( + padding: const EdgeInsets.only(right: 18), + child: FlowySvg( + obscureText ? FlowySvgs.show_m : FlowySvgs.hide_m, + size: const Size(12, 15), + color: Theme.of(context).colorScheme.outline, + ), + ), + ), + onSubmitted: widget.onSave, + onChanged: (_) { + widget.onChanged?.call(controller.text); + setState(() {}); + }, + ), + ), + if (!widget.hideActions && + ((widget.value == null && controller.text.isNotEmpty) || + widget.value != null && widget.value != controller.text)) ...[ + const VSpace(8), + Row( + children: [ + const Spacer(), + SizedBox( + height: 21, + child: FlowyTextButton( + LocaleKeys.button_save.tr(), + fontWeight: FontWeight.normal, + padding: EdgeInsets.zero, + fillColor: Colors.transparent, + hoverColor: Colors.transparent, + fontColor: AFThemeExtension.of(context).textColor, + onPressed: () => widget.onSave?.call(controller.text), + ), + ), + const HSpace(24), + SizedBox( + height: 21, + child: FlowyTextButton( + LocaleKeys.button_cancel.tr(), + fontWeight: FontWeight.normal, + padding: EdgeInsets.zero, + fillColor: Colors.transparent, + hoverColor: Colors.transparent, + fontColor: AFThemeExtension.of(context).textColor, + onPressed: () { + setState(() => controller.text = widget.value ?? ''); + widget.onCancel?.call(); + }, + ), + ), + ], + ), + ], + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_radio_select.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_radio_select.dart new file mode 100644 index 0000000000000..91d780cedadce --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_radio_select.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; + +class SettingsRadioItem { + const SettingsRadioItem({ + required this.value, + required this.label, + required this.isSelected, + this.icon, + }); + + final T value; + final String label; + final bool isSelected; + final Widget? icon; +} + +class SettingsRadioSelect extends StatelessWidget { + const SettingsRadioSelect({ + super.key, + required this.items, + required this.onChanged, + this.selectedItem, + }); + + final List> items; + final void Function(SettingsRadioItem) onChanged; + final SettingsRadioItem? selectedItem; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 24, + runSpacing: 8, + children: items + .map( + (i) => GestureDetector( + onTap: () => onChanged(i), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 14, + height: 14, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AFThemeExtension.of(context).textColor, + ), + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: i.isSelected + ? AFThemeExtension.of(context).textColor + : Colors.transparent, + shape: BoxShape.circle, + ), + ), + ), + const HSpace(8), + if (i.icon != null) ...[i.icon!, const HSpace(4)], + FlowyText.regular(i.label, fontSize: 14), + ], + ), + ), + ) + .toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_subcategory.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_subcategory.dart new file mode 100644 index 0000000000000..4be6dbee05613 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_subcategory.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +/// Renders a simple category taking a title and the list +/// of children (settings) to be rendered. +/// +class SettingsSubcategory extends StatelessWidget { + const SettingsSubcategory({ + super.key, + required this.title, + required this.children, + }); + + final String title; + final List children; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.medium( + title, + color: AFThemeExtension.of(context).secondaryTextColor, + maxLines: 2, + fontSize: 14, + overflow: TextOverflow.ellipsis, + ), + const VSpace(8), + SeparatedColumn( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => + children.length > 1 ? const VSpace(16) : const SizedBox.shrink(), + children: children, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart new file mode 100644 index 0000000000000..a356e3fd50e63 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/single_setting_action.dart @@ -0,0 +1,167 @@ +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; + +enum SingleSettingsButtonType { + primary, + danger, + highlight; + + bool get isPrimary => this == primary; + bool get isDangerous => this == danger; + bool get isHighlight => this == highlight; +} + +/// This is used to describe a single setting action +/// +/// This will render a simple action that takes the title, +/// the button label, and the button action. +/// +/// _Note: The label can overflow and will be ellipsized, +/// unless maxLines is overriden._ +/// +class SingleSettingAction extends StatelessWidget { + const SingleSettingAction({ + super.key, + required this.label, + this.description, + this.labelMaxLines, + required this.buttonLabel, + this.onPressed, + this.buttonType = SingleSettingsButtonType.primary, + this.fontSize = 14, + this.fontWeight = FontWeight.normal, + this.minWidth, + }); + + final String label; + final String? description; + final int? labelMaxLines; + final String buttonLabel; + + /// The action to be performed when the button is pressed + /// + /// If null the button will be rendered as disabled. + /// + final VoidCallback? onPressed; + + final SingleSettingsButtonType buttonType; + + final double fontSize; + final FontWeight fontWeight; + final double? minWidth; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Column( + children: [ + Row( + children: [ + Expanded( + child: FlowyText( + label, + fontSize: fontSize, + fontWeight: fontWeight, + maxLines: labelMaxLines, + overflow: TextOverflow.ellipsis, + color: AFThemeExtension.of(context).secondaryTextColor, + ), + ), + ], + ), + if (description != null) ...[ + const VSpace(4), + Row( + children: [ + Expanded( + child: FlowyText.regular( + description!, + fontSize: 11, + color: AFThemeExtension.of(context).secondaryTextColor, + maxLines: 2, + ), + ), + ], + ), + ], + ], + ), + ), + const HSpace(24), + ConstrainedBox( + constraints: BoxConstraints( + minWidth: minWidth ?? 0.0, + maxHeight: 32, + minHeight: 32, + ), + child: FlowyTextButton( + buttonLabel, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + fillColor: fillColor(context), + radius: Corners.s8Border, + hoverColor: hoverColor(context), + fontColor: fontColor(context), + fontHoverColor: fontHoverColor(context), + borderColor: borderColor(context), + fontSize: 12, + isDangerous: buttonType.isDangerous, + onPressed: onPressed, + lineHeight: 1.0, + ), + ), + ], + ); + } + + Color? fillColor(BuildContext context) { + if (buttonType.isPrimary) { + return Theme.of(context).colorScheme.primary; + } + return Colors.transparent; + } + + Color? hoverColor(BuildContext context) { + if (buttonType.isDangerous) { + return Theme.of(context).colorScheme.error.withOpacity(0.1); + } + + if (buttonType.isPrimary) { + return Theme.of(context).colorScheme.primary.withOpacity(0.9); + } + + if (buttonType.isHighlight) { + return const Color(0xFF5C3699); + } + return null; + } + + Color? fontColor(BuildContext context) { + if (buttonType.isDangerous) { + return Theme.of(context).colorScheme.error; + } + + if (buttonType.isHighlight) { + return const Color(0xFF5C3699); + } + + return Theme.of(context).colorScheme.onPrimary; + } + + Color? fontHoverColor(BuildContext context) { + return Colors.white; + } + + Color? borderColor(BuildContext context) { + if (buttonType.isHighlight) { + return const Color(0xFF5C3699); + } + + return null; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/_restart_app_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/_restart_app_button.dart new file mode 100644 index 0000000000000..d8aa15a944975 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/_restart_app_button.dart @@ -0,0 +1,76 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class RestartButton extends StatelessWidget { + const RestartButton({ + super.key, + required this.showRestartHint, + required this.onClick, + }); + + final bool showRestartHint; + final VoidCallback onClick; + + @override + Widget build(BuildContext context) { + final List children = [_buildRestartButton(context)]; + if (showRestartHint) { + children.add( + Padding( + padding: const EdgeInsets.only(top: 10), + child: FlowyText( + LocaleKeys.settings_menu_restartAppTip.tr(), + maxLines: null, + ), + ), + ); + } + + return Column(children: children); + } + + Widget _buildRestartButton(BuildContext context) { + if (UniversalPlatform.isDesktopOrWeb) { + return Row( + children: [ + SizedBox( + height: 42, + child: PrimaryRoundedButton( + text: LocaleKeys.settings_menu_restartApp.tr(), + margin: const EdgeInsets.symmetric(horizontal: 24), + fontWeight: FontWeight.w600, + radius: 12.0, + onTap: onClick, + ), + ), + ], + ); + // Row( + // children: [ + // FlowyButton( + // isSelected: true, + // useIntrinsicWidth: true, + // margin: const EdgeInsets.symmetric( + // horizontal: 30, + // vertical: 10, + // ), + // text: FlowyText( + // LocaleKeys.settings_menu_restartApp.tr(), + // ), + // onTap: onClick, + // ), + // const Spacer(), + // ], + // ); + } else { + return MobileLogoutButton( + text: LocaleKeys.settings_menu_restartApp.tr(), + onPressed: onClick, + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/cancel_plan_survey_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/cancel_plan_survey_dialog.dart new file mode 100644 index 0000000000000..e60629257209c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/cancel_plan_survey_dialog.dart @@ -0,0 +1,431 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +Future showCancelSurveyDialog(BuildContext context) { + return showDialog( + context: context, + builder: (_) => const _Survey(), + ); +} + +class _Survey extends StatefulWidget { + const _Survey(); + + @override + State<_Survey> createState() => _SurveyState(); +} + +class _SurveyState extends State<_Survey> { + final PageController pageController = PageController(); + final Map answers = {}; + + @override + void dispose() { + pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 674, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Survey title + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: FlowyText( + LocaleKeys.settings_cancelSurveyDialog_title.tr(), + fontSize: 22.0, + overflow: TextOverflow.ellipsis, + color: AFThemeExtension.of(context).strongText, + ), + ), + FlowyButton( + useIntrinsicWidth: true, + text: const FlowySvg(FlowySvgs.upgrade_close_s), + onTap: () => Navigator.of(context).pop(), + ), + ], + ), + const VSpace(12), + // Survey explanation + FlowyText( + LocaleKeys.settings_cancelSurveyDialog_description.tr(), + maxLines: 3, + ), + const VSpace(8), + const Divider(), + const VSpace(8), + // Question "sheet" + SizedBox( + height: 400, + width: 650, + child: PageView.builder( + controller: pageController, + itemCount: _questionsAndAnswers.length, + itemBuilder: (context, index) => _QAPage( + qa: _questionsAndAnswers[index], + isFirstQuestion: index == 0, + isFinalQuestion: + index == _questionsAndAnswers.length - 1, + selectedAnswer: + answers[_questionsAndAnswers[index].question], + onPrevious: () { + if (index > 0) { + pageController.animateToPage( + index - 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + onAnswerChanged: (answer) { + answers[_questionsAndAnswers[index].question] = + answer; + }, + onAnswerSelected: (answer) { + answers[_questionsAndAnswers[index].question] = + answer; + + if (index == _questionsAndAnswers.length - 1) { + Navigator.of(context).pop(jsonEncode(answers)); + } else { + pageController.animateToPage( + index + 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _QAPage extends StatefulWidget { + const _QAPage({ + required this.qa, + required this.onAnswerSelected, + required this.onAnswerChanged, + required this.onPrevious, + this.selectedAnswer, + this.isFirstQuestion = false, + this.isFinalQuestion = false, + }); + + final _QA qa; + final String? selectedAnswer; + + /// Called when "Next" is pressed + /// + final Function(String) onAnswerSelected; + + /// Called whenever an answer is selected or changed + /// + final Function(String) onAnswerChanged; + final VoidCallback onPrevious; + final bool isFirstQuestion; + final bool isFinalQuestion; + + @override + State<_QAPage> createState() => _QAPageState(); +} + +class _QAPageState extends State<_QAPage> { + final otherController = TextEditingController(); + + int _selectedIndex = -1; + String? answer; + + @override + void initState() { + super.initState(); + if (widget.selectedAnswer != null) { + answer = widget.selectedAnswer; + _selectedIndex = widget.qa.answers.indexOf(widget.selectedAnswer!); + if (_selectedIndex == -1) { + // We assume the last question is "Other" + _selectedIndex = widget.qa.answers.length - 1; + otherController.text = widget.selectedAnswer!; + } + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + widget.qa.question, + fontSize: 16.0, + color: AFThemeExtension.of(context).strongText, + ), + const VSpace(18), + SeparatedColumn( + separatorBuilder: () => const VSpace(6), + crossAxisAlignment: CrossAxisAlignment.start, + children: widget.qa.answers + .mapIndexed( + (index, option) => _AnswerOption( + prefix: _indexToLetter(index), + option: option, + isSelected: _selectedIndex == index, + onTap: () => setState(() { + _selectedIndex = index; + if (_selectedIndex == widget.qa.answers.length - 1 && + widget.qa.lastIsOther) { + answer = otherController.text; + } else { + answer = option; + } + widget.onAnswerChanged(option); + }), + ), + ) + .toList(), + ), + if (widget.qa.lastIsOther && + _selectedIndex == widget.qa.answers.length - 1) ...[ + const VSpace(8), + FlowyTextField( + controller: otherController, + hintText: LocaleKeys.settings_cancelSurveyDialog_otherHint.tr(), + onChanged: (value) => setState(() { + answer = value; + widget.onAnswerChanged(value); + }), + ), + ], + const VSpace(20), + Row( + children: [ + if (!widget.isFirstQuestion) ...[ + DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: const BorderSide(color: Color(0x1E14171B)), + borderRadius: BorderRadius.circular(8), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 9.0, + ), + text: FlowyText.regular(LocaleKeys.button_previous.tr()), + onTap: widget.onPrevious, + ), + ), + const HSpace(12.0), + ], + DecoratedBox( + decoration: ShapeDecoration( + color: Theme.of(context).colorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0), + radius: BorderRadius.circular(8), + text: FlowyText.regular( + widget.isFinalQuestion + ? LocaleKeys.button_submit.tr() + : LocaleKeys.button_next.tr(), + color: Colors.white, + ), + disable: !canProceed(), + onTap: canProceed() + ? () => widget.onAnswerSelected( + answer ?? widget.qa.answers[_selectedIndex], + ) + : null, + ), + ), + ], + ), + ], + ); + } + + bool canProceed() { + if (_selectedIndex == widget.qa.answers.length - 1 && + widget.qa.lastIsOther) { + return answer != null && + answer!.isNotEmpty && + answer != LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(); + } + + return _selectedIndex != -1; + } +} + +class _AnswerOption extends StatelessWidget { + const _AnswerOption({ + required this.prefix, + required this.option, + required this.onTap, + this.isSelected = false, + }); + + final String prefix; + final String option; + final VoidCallback onTap; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + border: Border.all( + width: 2, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const HSpace(2), + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).dividerColor, + borderRadius: Corners.s6Border, + ), + child: Center( + child: FlowyText( + prefix, + color: isSelected ? Colors.white : null, + ), + ), + ), + const HSpace(8), + FlowyText( + option, + fontWeight: FontWeight.w400, + fontSize: 16.0, + color: AFThemeExtension.of(context).strongText, + ), + const HSpace(6), + ], + ), + ), + ), + ); + } +} + +final _questionsAndAnswers = [ + _QA( + question: LocaleKeys.settings_cancelSurveyDialog_questionOne_question.tr(), + answers: [ + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerOne.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerTwo.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerThree.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerFour.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionOne_answerFive.tr(), + LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(), + ], + lastIsOther: true, + ), + _QA( + question: LocaleKeys.settings_cancelSurveyDialog_questionTwo_question.tr(), + answers: [ + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerOne.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerTwo.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerThree.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerFour.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionTwo_answerFive.tr(), + ], + ), + _QA( + question: + LocaleKeys.settings_cancelSurveyDialog_questionThree_question.tr(), + answers: [ + LocaleKeys.settings_cancelSurveyDialog_questionThree_answerOne.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionThree_answerTwo.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionThree_answerThree.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionThree_answerFour.tr(), + LocaleKeys.settings_cancelSurveyDialog_commonOther.tr(), + ], + lastIsOther: true, + ), + _QA( + question: LocaleKeys.settings_cancelSurveyDialog_questionFour_question.tr(), + answers: [ + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerOne.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerTwo.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerThree.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerFour.tr(), + LocaleKeys.settings_cancelSurveyDialog_questionFour_answerFive.tr(), + ], + ), +]; + +class _QA { + const _QA({ + required this.question, + required this.answers, + this.lastIsOther = false, + }); + + final String question; + final List answers; + final bool lastIsOther; +} + +/// Returns the letter corresponding to the index. +/// +/// Eg. 0 -> A, 1 -> B, 2 -> C, ..., and so forth. +/// +String _indexToLetter(int index) { + return String.fromCharCode(65 + index); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart new file mode 100644 index 0000000000000..6cdccb3b3bbfe --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/decoration.dart'; + +SelectionMenuItem emojiMenuItem = SelectionMenuItem( + getName: LocaleKeys.document_plugins_emoji.tr, + icon: (editorState, onSelected, style) => SelectableIconWidget( + icon: Icons.emoji_emotions_outlined, + isSelected: onSelected, + style: style, + ), + keywords: ['emoji'], + handler: (editorState, menuService, context) { + final container = Overlay.of(context); + menuService.dismiss(); + showEmojiPickerMenu( + container, + editorState, + menuService.alignment, + menuService.offset, + ); + }, +); + +void showEmojiPickerMenu( + OverlayState container, + EditorState editorState, + Alignment alignment, + Offset offset, +) { + final top = alignment == Alignment.topLeft ? offset.dy : null; + final bottom = alignment == Alignment.bottomLeft ? offset.dy : null; + + keepEditorFocusNotifier.increase(); + late OverlayEntry emojiPickerMenuEntry; + emojiPickerMenuEntry = FullScreenOverlayEntry( + top: top, + bottom: bottom, + left: offset.dx, + dismissCallback: () => keepEditorFocusNotifier.decrease(), + builder: (context) => Material( + type: MaterialType.transparency, + child: Container( + width: 360, + height: 380, + padding: const EdgeInsets.all(4.0), + decoration: FlowyDecoration.decoration( + Theme.of(context).cardColor, + Theme.of(context).colorScheme.shadow, + ), + child: EmojiSelectionMenu( + onSubmitted: (emoji) { + editorState.insertTextAtCurrentSelection(emoji); + }, + onExit: () { + // close emoji panel + emojiPickerMenuEntry.remove(); + }, + ), + ), + ), + ).build(); + container.insert(emojiPickerMenuEntry); +} + +class EmojiSelectionMenu extends StatefulWidget { + const EmojiSelectionMenu({ + super.key, + required this.onSubmitted, + required this.onExit, + }); + + final void Function(String emoji) onSubmitted; + final void Function() onExit; + + @override + State createState() => _EmojiSelectionMenuState(); +} + +class _EmojiSelectionMenuState extends State { + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent); + } + + bool _handleGlobalKeyEvent(KeyEvent event) { + if (event.logicalKey == LogicalKeyboardKey.escape && + event is KeyDownEvent) { + //triggers on esc + widget.onExit(); + return true; + } + return false; + } + + @override + void deactivate() { + HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); + super.deactivate(); + } + + @override + Widget build(BuildContext context) { + return FlowyEmojiPicker( + onEmojiSelected: (_, emoji) => widget.onSubmitted(emoji), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart new file mode 100644 index 0000000000000..a369cc6b87be4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart @@ -0,0 +1,7 @@ +export 'emoji_menu_item.dart'; +export 'emoji_shortcut_event.dart'; +export 'src/emji_picker_config.dart'; +export 'src/emoji_picker.dart'; +export 'src/emoji_picker_builder.dart'; +export 'src/flowy_emoji_picker_config.dart'; +export 'src/models/emoji_model.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_shortcut_event.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_shortcut_event.dart new file mode 100644 index 0000000000000..078cf64963f47 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/emoji_shortcut_event.dart @@ -0,0 +1,87 @@ +import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +final CommandShortcutEvent emojiShortcutEvent = CommandShortcutEvent( + key: 'Ctrl + Alt + E to show emoji picker', + command: 'ctrl+alt+e', + macOSCommand: 'cmd+alt+e', + getDescription: () => 'Show an emoji picker', + handler: _emojiShortcutHandler, +); + +CommandShortcutEventHandler _emojiShortcutHandler = (editorState) { + final selection = editorState.selection; + if (selection == null) { + return KeyEventResult.ignored; + } + final context = editorState.getNodeAtPath(selection.start.path)?.context; + if (context == null) { + return KeyEventResult.ignored; + } + + final container = Overlay.of(context); + + Alignment alignment = Alignment.topLeft; + Offset offset = Offset.zero; + + final selectionService = editorState.service.selectionService; + final selectionRects = selectionService.selectionRects; + if (selectionRects.isEmpty) { + return KeyEventResult.ignored; + } + final rect = selectionRects.first; + + // Calculate the offset and alignment + // Don't like these values being hardcoded but unsure how to grab the + // values dynamically to match the /emoji command. + const menuHeight = 200.0; + const menuOffset = Offset(10, 10); // Tried (0, 10) but that looked off + + final editorOffset = + editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final editorHeight = editorState.renderBox!.size.height; + final editorWidth = editorState.renderBox!.size.width; + + // show below default + alignment = Alignment.topLeft; + final bottomRight = rect.bottomRight; + final topRight = rect.topRight; + final newOffset = bottomRight + menuOffset; + offset = Offset( + newOffset.dx, + newOffset.dy, + ); + + // show above + if (newOffset.dy + menuHeight >= editorOffset.dy + editorHeight) { + offset = topRight - menuOffset; + alignment = Alignment.bottomLeft; + + offset = Offset( + newOffset.dx, + MediaQuery.of(context).size.height - newOffset.dy, + ); + } + + // show on left + if (offset.dx - editorOffset.dx > editorWidth / 2) { + alignment = alignment == Alignment.topLeft + ? Alignment.topRight + : Alignment.bottomRight; + + offset = Offset( + editorWidth - offset.dx + editorOffset.dx, + offset.dy, + ); + } + + showEmojiPickerMenu( + container, + editorState, + alignment, + offset, + ); + + return KeyEventResult.handled; +}; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/default_emoji_picker_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/default_emoji_picker_view.dart new file mode 100644 index 0000000000000..9a4690f240d70 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/default_emoji_picker_view.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; + +import 'emoji_picker.dart'; +import 'emoji_picker_builder.dart'; +import 'models/emoji_category_models.dart'; +import 'models/emoji_model.dart'; + +class DefaultEmojiPickerView extends EmojiPickerBuilder { + const DefaultEmojiPickerView( + super.config, + super.state, { + super.key, + }); + + @override + DefaultEmojiPickerViewState createState() => DefaultEmojiPickerViewState(); +} + +class DefaultEmojiPickerViewState extends State + with TickerProviderStateMixin { + PageController? _pageController; + TabController? _tabController; + final TextEditingController _emojiController = TextEditingController(); + final FocusNode _emojiFocusNode = FocusNode(); + EmojiCategoryGroup searchEmojiList = + EmojiCategoryGroup(EmojiCategory.SEARCH, []); + final scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + + int initCategory = widget.state.emojiCategoryGroupList.indexWhere( + (el) => el.category == widget.config.initCategory, + ); + if (initCategory == -1) { + initCategory = 0; + } + _tabController = TabController( + initialIndex: initCategory, + length: widget.state.emojiCategoryGroupList.length, + vsync: this, + ); + _pageController = PageController(initialPage: initCategory); + _emojiFocusNode.requestFocus(); + _emojiController.addListener(_onEmojiChanged); + } + + @override + void dispose() { + _emojiController.removeListener(_onEmojiChanged); + _emojiController.dispose(); + _emojiFocusNode.dispose(); + _pageController?.dispose(); + _tabController?.dispose(); + scrollController.dispose(); + super.dispose(); + } + + void _onEmojiChanged() { + final String query = _emojiController.text.toLowerCase(); + if (query.isEmpty) { + searchEmojiList.emoji.clear(); + _pageController!.jumpToPage(_tabController!.index); + } else { + searchEmojiList.emoji.clear(); + for (final element in widget.state.emojiCategoryGroupList) { + searchEmojiList.emoji.addAll( + element.emoji + .where((item) => item.name.toLowerCase().contains(query)) + .toList(), + ); + } + } + setState(() {}); + } + + Widget _buildBackspaceButton() { + if (widget.state.onBackspacePressed != null) { + return Material( + type: MaterialType.transparency, + child: IconButton( + padding: const EdgeInsets.only(bottom: 2), + icon: Icon(Icons.backspace, color: widget.config.backspaceColor), + onPressed: () => widget.state.onBackspacePressed!(), + ), + ); + } + + return const SizedBox.shrink(); + } + + bool isEmojiSearching() => + searchEmojiList.emoji.isNotEmpty || _emojiController.text.isNotEmpty; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final emojiSize = widget.config.getEmojiSize(constraints.maxWidth); + final style = Theme.of(context); + + return Container( + color: widget.config.bgColor, + padding: const EdgeInsets.all(4), + child: Column( + children: [ + const VSpace(4), + // search bar + SizedBox( + height: 32.0, + child: TextField( + controller: _emojiController, + focusNode: _emojiFocusNode, + autofocus: true, + style: style.textTheme.bodyMedium, + cursorColor: style.textTheme.bodyMedium?.color, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8), + hintText: widget.config.searchHintText, + hintStyle: widget.config.serachHintTextStyle, + enabledBorder: widget.config.serachBarEnableBorder, + focusedBorder: widget.config.serachBarFocusedBorder, + ), + ), + ), + const VSpace(4), + Row( + children: [ + Expanded( + child: TabBar( + labelColor: widget.config.selectedCategoryIconColor, + unselectedLabelColor: widget.config.categoryIconColor, + controller: isEmojiSearching() + ? TabController(length: 1, vsync: this) + : _tabController, + labelPadding: EdgeInsets.zero, + indicatorColor: + widget.config.selectedCategoryIconBackgroundColor, + padding: const EdgeInsets.symmetric(vertical: 4.0), + indicator: BoxDecoration( + border: Border.all(color: Colors.transparent), + borderRadius: BorderRadius.circular(4.0), + color: style.colorScheme.secondary, + ), + onTap: (index) { + _pageController!.animateToPage( + index, + duration: widget.config.tabIndicatorAnimDuration, + curve: Curves.ease, + ); + }, + tabs: isEmojiSearching() + ? [_buildCategory(EmojiCategory.SEARCH, emojiSize)] + : widget.state.emojiCategoryGroupList + .asMap() + .entries + .map( + (item) => _buildCategory( + item.value.category, + emojiSize, + ), + ) + .toList(), + ), + ), + _buildBackspaceButton(), + ], + ), + Flexible( + child: PageView.builder( + itemCount: searchEmojiList.emoji.isNotEmpty + ? 1 + : widget.state.emojiCategoryGroupList.length, + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final EmojiCategoryGroup emojiCategoryGroup = + isEmojiSearching() + ? searchEmojiList + : widget.state.emojiCategoryGroupList[index]; + return _buildPage(emojiSize, emojiCategoryGroup); + }, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildCategory(EmojiCategory category, double categorySize) { + return Tab( + height: categorySize, + child: Icon( + widget.config.getIconForCategory(category), + size: categorySize / 1.3, + ), + ); + } + + Widget _buildButtonWidget({ + required VoidCallback onPressed, + required Widget child, + }) { + if (widget.config.buttonMode == ButtonMode.MATERIAL) { + return InkWell(onTap: onPressed, child: child); + } + return GestureDetector(onTap: onPressed, child: child); + } + + Widget _buildPage(double emojiSize, EmojiCategoryGroup emojiCategoryGroup) { + // Display notice if recent has no entries yet + if (emojiCategoryGroup.category == EmojiCategory.RECENT && + emojiCategoryGroup.emoji.isEmpty) { + return _buildNoRecent(); + } else if (emojiCategoryGroup.category == EmojiCategory.SEARCH && + emojiCategoryGroup.emoji.isEmpty) { + return Center(child: Text(widget.config.noEmojiFoundText)); + } + // Build page normally + return ScrollbarListStack( + axis: Axis.vertical, + controller: scrollController, + barSize: 4.0, + scrollbarPadding: const EdgeInsets.symmetric(horizontal: 4.0), + handleColor: widget.config.scrollBarHandleColor, + showTrack: true, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: GridView.builder( + controller: scrollController, + padding: const EdgeInsets.all(0), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.config.emojiNumberPerRow, + mainAxisSpacing: widget.config.verticalSpacing, + crossAxisSpacing: widget.config.horizontalSpacing, + ), + itemCount: emojiCategoryGroup.emoji.length, + itemBuilder: (context, index) { + final item = emojiCategoryGroup.emoji[index]; + return _buildEmoji(emojiSize, emojiCategoryGroup, item); + }, + cacheExtent: 10, + ), + ), + ); + } + + Widget _buildEmoji( + double emojiSize, + EmojiCategoryGroup emojiCategoryGroup, + Emoji emoji, + ) { + return _buildButtonWidget( + onPressed: () { + widget.state.onEmojiSelected(emojiCategoryGroup.category, emoji); + }, + child: FlowyHover( + child: FittedBox( + child: Text( + emoji.emoji, + style: TextStyle(fontSize: emojiSize), + ), + ), + ), + ); + } + + Widget _buildNoRecent() { + return Center( + child: Text( + widget.config.noRecentsText, + style: widget.config.noRecentsStyle, + textAlign: TextAlign.center, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emji_picker_config.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emji_picker_config.dart new file mode 100644 index 0000000000000..22d2bbe03490b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emji_picker_config.dart @@ -0,0 +1,94 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'models/emoji_category_models.dart'; +import 'emoji_picker.dart'; + +part 'emji_picker_config.freezed.dart'; + +@freezed +class EmojiPickerConfig with _$EmojiPickerConfig { + // private empty constructor is used to make method work in freezed + // https://pub.dev/packages/freezed#adding-getters-and-methods-to-our-models + const EmojiPickerConfig._(); + const factory EmojiPickerConfig({ + @Default(7) int emojiNumberPerRow, + // The maximum size(width and height) of emoji + // It also depaneds on the screen size and emojiNumberPerRow + @Default(32) double emojiSizeMax, + // Vertical spacing between emojis + @Default(0) double verticalSpacing, + // Horizontal spacing between emojis + @Default(0) double horizontalSpacing, + // The initial [EmojiCategory] that will be selected + @Default(EmojiCategory.RECENT) EmojiCategory initCategory, + // The background color of the Widget + @Default(Color(0xFFEBEFF2)) Color? bgColor, + // The color of the category icons + @Default(Colors.grey) Color? categoryIconColor, + // The color of the category icon when selected + @Default(Colors.blue) Color? selectedCategoryIconColor, + // The color of the category indicator + @Default(Colors.blue) Color? selectedCategoryIconBackgroundColor, + // The color of the loading indicator during initialization + @Default(Colors.blue) Color? progressIndicatorColor, + // The color of the backspace icon button + @Default(Colors.blue) Color? backspaceColor, + // Show extra tab with recently used emoji + @Default(true) bool showRecentsTab, + // Limit of recently used emoji that will be saved + @Default(28) int recentsLimit, + @Default('Search emoji') String searchHintText, + TextStyle? serachHintTextStyle, + InputBorder? serachBarEnableBorder, + InputBorder? serachBarFocusedBorder, + // The text to be displayed if no recent emojis to display + @Default('No recent emoji') String noRecentsText, + TextStyle? noRecentsStyle, + // The text to be displayed if no emoji found + @Default('No emoji found') String noEmojiFoundText, + Color? scrollBarHandleColor, + // Duration of tab indicator to animate to next category + @Default(kTabScrollDuration) Duration tabIndicatorAnimDuration, + // Determines the icon to display for each [EmojiCategory] + @Default(EmojiCategoryIcons()) EmojiCategoryIcons emojiCategoryIcons, + // Change between Material and Cupertino button style + @Default(ButtonMode.MATERIAL) ButtonMode buttonMode, + }) = _EmojiPickerConfig; + + /// Get Emoji size based on properties and screen width + double getEmojiSize(double width) { + final maxSize = width / emojiNumberPerRow; + return min(maxSize, emojiSizeMax); + } + + /// Returns the icon for the category + IconData getIconForCategory(EmojiCategory category) { + switch (category) { + case EmojiCategory.RECENT: + return emojiCategoryIcons.recentIcon; + case EmojiCategory.SMILEYS: + return emojiCategoryIcons.smileyIcon; + case EmojiCategory.ANIMALS: + return emojiCategoryIcons.animalIcon; + case EmojiCategory.FOODS: + return emojiCategoryIcons.foodIcon; + case EmojiCategory.TRAVEL: + return emojiCategoryIcons.travelIcon; + case EmojiCategory.ACTIVITIES: + return emojiCategoryIcons.activityIcon; + case EmojiCategory.OBJECTS: + return emojiCategoryIcons.objectIcon; + case EmojiCategory.SYMBOLS: + return emojiCategoryIcons.symbolIcon; + case EmojiCategory.FLAGS: + return emojiCategoryIcons.flagIcon; + case EmojiCategory.SEARCH: + return emojiCategoryIcons.searchIcon; + default: + throw Exception('Unsupported EmojiCategory'); + } + } +} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_lists.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_lists.dart similarity index 99% rename from frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_lists.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_lists.dart index d012b0e7ca1a7..6cf6f234864f4 100644 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_lists.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_lists.dart @@ -373,7 +373,7 @@ final Map smileys = Map.fromIterables([ 'Rescue Worker’s Helmet', 'Lipstick', 'Ring', - 'Briefcase' + 'Briefcase', ], [ '😀', '😃', @@ -734,7 +734,7 @@ final Map smileys = Map.fromIterables([ '⛑', '💄', '💍', - '💼' + '💼', ]); /// Map of all possible emojis along with their names in [Category.ANIMALS] @@ -919,7 +919,7 @@ final Map animals = Map.fromIterables([ 'Christmas Tree', 'Sparkles', 'Tanabata Tree', - 'Pine Decoration' + 'Pine Decoration', ], [ '🐶', '🐱', @@ -1101,7 +1101,7 @@ final Map animals = Map.fromIterables([ '🎄', '✨', '🎋', - '🎍' + '🎍', ]); /// Map of all possible emojis along with their names in [Category.FOODS] @@ -1210,7 +1210,7 @@ final Map foods = Map.fromIterables([ 'Chopsticks', 'Fork and Knife With Plate', 'Fork and Knife', - 'Spoon' + 'Spoon', ], [ '🍇', '🍈', @@ -1316,7 +1316,7 @@ final Map foods = Map.fromIterables([ '🥢', '🍽', '🍴', - '🥄' + '🥄', ]); /// Map of all possible emojis along with their names in [Category.TRAVEL] @@ -1445,7 +1445,7 @@ final Map travel = Map.fromIterables([ 'Passport Control', 'Customs', 'Baggage Claim', - 'Left Luggage' + 'Left Luggage', ], [ '🚣', '🗾', @@ -1571,7 +1571,7 @@ final Map travel = Map.fromIterables([ '🛂', '🛃', '🛄', - '🛅' + '🛅', ]); /// Map of all possible emojis along with their names in [Category.ACTIVITIES] @@ -1667,7 +1667,7 @@ final Map activities = Map.fromIterables([ 'Violin', 'Drum', 'Clapper Board', - 'Bow and Arrow' + 'Bow and Arrow', ], [ '🕴', '🧗', @@ -1760,7 +1760,7 @@ final Map activities = Map.fromIterables([ '🎻', '🥁', '🎬', - '🏹' + '🏹', ]); /// Map of all possible emojis along with their names in [Category.OBJECTS] @@ -1962,7 +1962,7 @@ final Map objects = Map.fromIterables([ 'Coffin', 'Funeral Urn', 'Moai', - 'Potable Water' + 'Potable Water', ], [ '💌', '🕳', @@ -2161,7 +2161,7 @@ final Map objects = Map.fromIterables([ '⚰', '⚱', '🗿', - '🚰' + '🚰', ]); /// Map of all possible emojis along with their names in [Category.SYMBOLS] @@ -2424,7 +2424,7 @@ final Map symbols = Map.fromIterables([ 'Red Triangle Pointed Down', 'Diamond With a Dot', 'White Square Button', - 'Black Square Button' + 'Black Square Button', ], [ '💘', '💝', @@ -2684,7 +2684,7 @@ final Map symbols = Map.fromIterables([ '🔻', '💠', '🔳', - '🔲' + '🔲', ]); /// Map of all possible emojis along with their names in [Category.FLAGS] @@ -2706,7 +2706,7 @@ final Map flags = Map.fromIterables([ 'Flag: Armenia', 'Flag: Angola', 'Flag: Antarctica', - 'Flag: Argentina', + 'Flag: argentina', 'Flag: American Samoa', 'Flag: Austria', 'Flag: Australia', @@ -2775,7 +2775,7 @@ final Map flags = Map.fromIterables([ 'Flag: Falkland Islands', 'Flag: Micronesia', 'Flag: Faroe Islands', - 'Flag: France', + 'Flag: france', 'Flag: Gabon', 'Flag: United Kingdom', 'Flag: Grenada', @@ -2953,7 +2953,7 @@ final Map flags = Map.fromIterables([ 'Flag: Mayotte', 'Flag: South Africa', 'Flag: Zambia', - 'Flag: Zimbabwe' + 'Flag: Zimbabwe', ], [ '🏁', '🚩', @@ -3219,5 +3219,5 @@ final Map flags = Map.fromIterables([ '🇾🇹', '🇿🇦', '🇿🇲', - '🇿🇼' + '🇿🇼', ]); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_picker.dart new file mode 100644 index 0000000000000..8621a0d55bab3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_picker.dart @@ -0,0 +1,340 @@ +// ignore_for_file: constant_identifier_names + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'models/emoji_category_models.dart'; +import 'emji_picker_config.dart'; +import 'default_emoji_picker_view.dart'; +import 'models/emoji_model.dart'; +import 'emoji_lists.dart' as emoji_list; +import 'emoji_view_state.dart'; +import 'models/recent_emoji_model.dart'; + +/// The emoji category shown on the category tab +enum EmojiCategory { + /// Searched emojis + SEARCH, + + /// Recent emojis + RECENT, + + /// Smiley emojis + SMILEYS, + + /// Animal emojis + ANIMALS, + + /// Food emojis + FOODS, + + /// Activity emojis + ACTIVITIES, + + /// Travel emojis + TRAVEL, + + /// Objects emojis + OBJECTS, + + /// Sumbol emojis + SYMBOLS, + + /// Flag emojis + FLAGS, +} + +/// Enum to alter the keyboard button style +enum ButtonMode { + /// Android button style - gives the button a splash color with ripple effect + MATERIAL, + + /// iOS button style - gives the button a fade out effect when pressed + CUPERTINO +} + +/// Callback function for when emoji is selected +/// +/// The function returns the selected [Emoji] as well +/// as the [EmojiCategory] from which it originated +typedef OnEmojiSelected = void Function(EmojiCategory category, Emoji emoji); + +/// Callback function for backspace button +typedef OnBackspacePressed = void Function(); + +/// Callback function for custom view +typedef EmojiViewBuilder = Widget Function( + EmojiPickerConfig config, + EmojiViewState state, +); + +/// The Emoji Keyboard widget +/// +/// This widget displays a grid of [Emoji] sorted by [EmojiCategory] +/// which the user can horizontally scroll through. +/// +/// There is also a bottombar which displays all the possible [EmojiCategory] +/// and allow the user to quickly switch to that [EmojiCategory] +class EmojiPicker extends StatefulWidget { + /// EmojiPicker for flutter + const EmojiPicker({ + super.key, + required this.onEmojiSelected, + this.onBackspacePressed, + this.config = const EmojiPickerConfig(), + this.customWidget, + }); + + /// Custom widget + final EmojiViewBuilder? customWidget; + + /// The function called when the emoji is selected + final OnEmojiSelected onEmojiSelected; + + /// The function called when backspace button is pressed + final OnBackspacePressed? onBackspacePressed; + + /// Config for customizations + final EmojiPickerConfig config; + + @override + EmojiPickerState createState() => EmojiPickerState(); +} + +class EmojiPickerState extends State { + static const platform = MethodChannel('emoji_picker_flutter'); + + List emojiCategoryGroupList = List.empty(growable: true); + List recentEmojiList = List.empty(growable: true); + late Future updateEmojiFuture; + + // Prevent emojis to be reloaded with every build + bool loaded = false; + + @override + void initState() { + super.initState(); + updateEmojiFuture = _updateEmojis(); + } + + @override + void didUpdateWidget(covariant EmojiPicker oldWidget) { + if (oldWidget.config != widget.config) { + // EmojiPickerConfig changed - rebuild EmojiPickerView completely + loaded = false; + updateEmojiFuture = _updateEmojis(); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + if (!loaded) { + // Load emojis + updateEmojiFuture.then( + (value) => WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() { + loaded = true; + }); + }), + ); + + // Show loading indicator + return const Center(child: CircularProgressIndicator()); + } + if (widget.config.showRecentsTab) { + emojiCategoryGroupList[0].emoji = + recentEmojiList.map((e) => e.emoji).toList().cast(); + } + + final state = EmojiViewState( + emojiCategoryGroupList, + _getOnEmojiListener(), + widget.onBackspacePressed, + ); + + // Build + return widget.customWidget == null + ? DefaultEmojiPickerView(widget.config, state) + : widget.customWidget!(widget.config, state); + } + + // Add recent emoji handling to tap listener + OnEmojiSelected _getOnEmojiListener() { + return (category, emoji) { + if (widget.config.showRecentsTab) { + _addEmojiToRecentlyUsed(emoji).then((value) { + if (category != EmojiCategory.RECENT && mounted) { + setState(() { + // rebuild to update recent emoji tab + // when it is not current tab + }); + } + }); + } + widget.onEmojiSelected(category, emoji); + }; + } + + // Initialize emoji data + Future _updateEmojis() async { + emojiCategoryGroupList.clear(); + if (widget.config.showRecentsTab) { + recentEmojiList = await _getRecentEmojis(); + final List recentEmojiMap = + recentEmojiList.map((e) => e.emoji).toList().cast(); + emojiCategoryGroupList + .add(EmojiCategoryGroup(EmojiCategory.RECENT, recentEmojiMap)); + } + emojiCategoryGroupList.addAll([ + EmojiCategoryGroup( + EmojiCategory.SMILEYS, + await _getAvailableEmojis(emoji_list.smileys, title: 'smileys'), + ), + EmojiCategoryGroup( + EmojiCategory.ANIMALS, + await _getAvailableEmojis(emoji_list.animals, title: 'animals'), + ), + EmojiCategoryGroup( + EmojiCategory.FOODS, + await _getAvailableEmojis(emoji_list.foods, title: 'foods'), + ), + EmojiCategoryGroup( + EmojiCategory.ACTIVITIES, + await _getAvailableEmojis( + emoji_list.activities, + title: 'activities', + ), + ), + EmojiCategoryGroup( + EmojiCategory.TRAVEL, + await _getAvailableEmojis(emoji_list.travel, title: 'travel'), + ), + EmojiCategoryGroup( + EmojiCategory.OBJECTS, + await _getAvailableEmojis(emoji_list.objects, title: 'objects'), + ), + EmojiCategoryGroup( + EmojiCategory.SYMBOLS, + await _getAvailableEmojis(emoji_list.symbols, title: 'symbols'), + ), + EmojiCategoryGroup( + EmojiCategory.FLAGS, + await _getAvailableEmojis(emoji_list.flags, title: 'flags'), + ), + ]); + } + + // Get available emoji for given category title + Future> _getAvailableEmojis( + Map map, { + required String title, + }) async { + Map? newMap; + + // Get Emojis cached locally if available + newMap = await _restoreFilteredEmojis(title); + + if (newMap == null) { + // Check if emoji is available on this platform + newMap = await _getPlatformAvailableEmoji(map); + // Save available Emojis to local storage for faster loading next time + if (newMap != null) { + await _cacheFilteredEmojis(title, newMap); + } + } + + // Map to Emoji Object + return newMap!.entries + .map((entry) => Emoji(entry.key, entry.value)) + .toList(); + } + + // Check if emoji is available on current platform + Future?> _getPlatformAvailableEmoji( + Map emoji, + ) async { + if (Platform.isAndroid) { + Map? filtered = {}; + const delimiter = '|'; + try { + final entries = emoji.values.join(delimiter); + final keys = emoji.keys.join(delimiter); + final result = (await platform.invokeMethod( + 'checkAvailability', + {'emojiKeys': keys, 'emojiEntries': entries}, + )) as String; + final resultKeys = result.split(delimiter); + for (var i = 0; i < resultKeys.length; i++) { + filtered[resultKeys[i]] = emoji[resultKeys[i]]!; + } + } on PlatformException catch (_) { + filtered = null; + } + return filtered; + } else { + return emoji; + } + } + + // Restore locally cached emoji + Future?> _restoreFilteredEmojis(String title) async { + final prefs = await SharedPreferences.getInstance(); + final emojiJson = prefs.getString(title); + if (emojiJson == null) { + return null; + } + final emojis = + Map.from(jsonDecode(emojiJson) as Map); + return emojis; + } + + // Stores filtered emoji locally for faster access next time + Future _cacheFilteredEmojis( + String title, + Map emojis, + ) async { + final prefs = await SharedPreferences.getInstance(); + final emojiJson = jsonEncode(emojis); + await prefs.setString(title, emojiJson); + } + + // Returns list of recently used emoji from cache + Future> _getRecentEmojis() async { + final prefs = await SharedPreferences.getInstance(); + final emojiJson = prefs.getString('recent'); + if (emojiJson == null) { + return []; + } + final json = jsonDecode(emojiJson) as List; + return json.map(RecentEmoji.fromJson).toList(); + } + + // Add an emoji to recently used list or increase its counter + Future _addEmojiToRecentlyUsed(Emoji emoji) async { + final prefs = await SharedPreferences.getInstance(); + final recentEmojiIndex = recentEmojiList + .indexWhere((element) => element.emoji.emoji == emoji.emoji); + if (recentEmojiIndex != -1) { + // Already exist in recent list + // Just update counter + recentEmojiList[recentEmojiIndex].counter++; + } else { + recentEmojiList.add(RecentEmoji(emoji, 1)); + } + // Sort by counter desc + recentEmojiList.sort((a, b) => b.counter - a.counter); + // Limit entries to recentsLimit + recentEmojiList = recentEmojiList.sublist( + 0, + min(widget.config.recentsLimit, recentEmojiList.length), + ); + // save locally + await prefs.setString('recent', jsonEncode(recentEmojiList)); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_picker_builder.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_picker_builder.dart new file mode 100644 index 0000000000000..ea22a13662e51 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_picker_builder.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +import 'emji_picker_config.dart'; +import 'emoji_view_state.dart'; + +/// Template class for custom implementation +/// Inherit this class to create your own EmojiPicker +abstract class EmojiPickerBuilder extends StatefulWidget { + /// Constructor + const EmojiPickerBuilder(this.config, this.state, {super.key}); + + /// Config for customizations + final EmojiPickerConfig config; + + /// State that holds current emoji data + final EmojiViewState state; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_view_state.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_view_state.dart new file mode 100644 index 0000000000000..e6d0c5d5598b3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/emoji_view_state.dart @@ -0,0 +1,21 @@ +import 'models/emoji_category_models.dart'; +import 'emoji_picker.dart'; + +/// State that holds current emoji data +class EmojiViewState { + /// Constructor + EmojiViewState( + this.emojiCategoryGroupList, + this.onEmojiSelected, + this.onBackspacePressed, + ); + + /// List of all categories including their emojis + final List emojiCategoryGroupList; + + /// Callback when pressed on emoji + final OnEmojiSelected onEmojiSelected; + + /// Callback when pressed on backspace + final OnBackspacePressed? onBackspacePressed; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/flowy_emoji_picker_config.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/flowy_emoji_picker_config.dart new file mode 100644 index 0000000000000..4ef6e00994306 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/flowy_emoji_picker_config.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/src/emji_picker_config.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +EmojiPickerConfig buildFlowyEmojiPickerConfig(BuildContext context) { + final style = Theme.of(context); + return EmojiPickerConfig( + bgColor: style.cardColor, + categoryIconColor: style.iconTheme.color, + selectedCategoryIconColor: style.colorScheme.onSurface, + selectedCategoryIconBackgroundColor: style.colorScheme.primary, + progressIndicatorColor: style.colorScheme.primary, + backspaceColor: style.colorScheme.primary, + searchHintText: LocaleKeys.emoji_search.tr(), + serachHintTextStyle: style.textTheme.bodyMedium?.copyWith( + color: style.hintColor, + ), + serachBarEnableBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide(color: style.dividerColor), + ), + serachBarFocusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: BorderSide( + color: style.colorScheme.primary, + ), + ), + noRecentsText: LocaleKeys.emoji_noRecent.tr(), + noRecentsStyle: style.textTheme.bodyMedium, + noEmojiFoundText: LocaleKeys.emoji_noEmojiFound.tr(), + scrollBarHandleColor: style.colorScheme.onSurface, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/models/emoji_category_models.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/models/emoji_category_models.dart new file mode 100644 index 0000000000000..65d0d652a222b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/models/emoji_category_models.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +import 'emoji_model.dart'; +import '../emoji_picker.dart'; + +/// EmojiCategory with its emojis +class EmojiCategoryGroup { + EmojiCategoryGroup(this.category, this.emoji); + + final EmojiCategory category; + + /// List of emoji of this category + List emoji; + + @override + String toString() { + return 'Name: $category, Emoji: $emoji'; + } +} + +/// Class that defines the icon representing a [EmojiCategory] +class EmojiCategoryIcon { + /// Icon of Category + const EmojiCategoryIcon({ + required this.icon, + this.color = const Color(0xffd3d3d3), + this.selectedColor = const Color(0xffb2b2b2), + }); + + /// The icon to represent the category + final IconData icon; + + /// The default color of the icon + final Color color; + + /// The color of the icon once the category is selected + final Color selectedColor; +} + +/// Class used to define all the [EmojiCategoryIcon] shown for each [EmojiCategory] +/// +/// This allows the keyboard to be personalized by changing icons shown. +/// If a [EmojiCategoryIcon] is set as null or not defined during initialization, +/// the default icons will be used instead +class EmojiCategoryIcons { + /// Constructor + const EmojiCategoryIcons({ + this.recentIcon = Icons.access_time, + this.smileyIcon = Icons.tag_faces, + this.animalIcon = Icons.pets, + this.foodIcon = Icons.fastfood, + this.activityIcon = Icons.directions_run, + this.travelIcon = Icons.location_city, + this.objectIcon = Icons.lightbulb_outline, + this.symbolIcon = Icons.emoji_symbols, + this.flagIcon = Icons.flag, + this.searchIcon = Icons.search, + }); + + /// Icon for [EmojiCategory.RECENT] + final IconData recentIcon; + + /// Icon for [EmojiCategory.SMILEYS] + final IconData smileyIcon; + + /// Icon for [EmojiCategory.ANIMALS] + final IconData animalIcon; + + /// Icon for [EmojiCategory.FOODS] + final IconData foodIcon; + + /// Icon for [EmojiCategory.ACTIVITIES] + final IconData activityIcon; + + /// Icon for [EmojiCategory.TRAVEL] + final IconData travelIcon; + + /// Icon for [EmojiCategory.OBJECTS] + final IconData objectIcon; + + /// Icon for [EmojiCategory.SYMBOLS] + final IconData symbolIcon; + + /// Icon for [EmojiCategory.FLAGS] + final IconData flagIcon; + + /// Icon for [EmojiCategory.SEARCH] + final IconData searchIcon; +} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/models/emoji_model.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/models/emoji_model.dart similarity index 100% rename from frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/models/emoji_model.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/models/emoji_model.dart diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/models/recent_emoji_model.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/models/recent_emoji_model.dart similarity index 100% rename from frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/models/recent_emoji_model.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/emoji_picker/src/models/recent_emoji_model.dart diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart new file mode 100644 index 0000000000000..90ce4d6f9b2af --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class FeatureFlagsPage extends StatelessWidget { + const FeatureFlagsPage({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return SettingsBody( + title: 'Feature flags', + children: [ + SeparatedColumn( + children: FeatureFlag.data.entries + .where((e) => e.key != FeatureFlag.unknown) + .map((e) => _FeatureFlagItem(featureFlag: e.key)) + .toList(), + ), + FlowyTextButton( + 'Restart the app to apply changes', + fontSize: 16.0, + fontColor: Colors.red, + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + onPressed: () async => runAppFlowy(), + ), + ], + ); + } +} + +class _FeatureFlagItem extends StatefulWidget { + const _FeatureFlagItem({required this.featureFlag}); + + final FeatureFlag featureFlag; + + @override + State<_FeatureFlagItem> createState() => _FeatureFlagItemState(); +} + +class _FeatureFlagItemState extends State<_FeatureFlagItem> { + @override + Widget build(BuildContext context) { + return ListTile( + title: FlowyText(widget.featureFlag.name, fontSize: 16.0), + subtitle: FlowyText.small(widget.featureFlag.description, maxLines: 3), + trailing: Switch.adaptive( + value: widget.featureFlag.isOn, + onChanged: (value) async { + await widget.featureFlag.update(value); + setState(() {}); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/mobile_feature_flag_screen.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/mobile_feature_flag_screen.dart new file mode 100644 index 0000000000000..c55522b7d3808 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/mobile_feature_flag_screen.dart @@ -0,0 +1,20 @@ +import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart'; +import 'package:flutter/material.dart'; + +class FeatureFlagScreen extends StatelessWidget { + const FeatureFlagScreen({ + super.key, + }); + + static const routeName = '/feature_flag'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Feature Flags'), + ), + body: const FeatureFlagsPage(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_export_file_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_export_file_widget.dart new file mode 100644 index 0000000000000..ed6c8949b700a --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_export_file_widget.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/files/settings_file_exporter_widget.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:styled_widget/styled_widget.dart'; + +import '../../../../../generated/locale_keys.g.dart'; + +class SettingsExportFileWidget extends StatefulWidget { + const SettingsExportFileWidget({super.key}); + + @override + State createState() => + SettingsExportFileWidgetState(); +} + +@visibleForTesting +class SettingsExportFileWidgetState extends State { + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.medium( + LocaleKeys.settings_files_exportData.tr(), + fontSize: 13, + overflow: TextOverflow.ellipsis, + ).padding(horizontal: 5.0), + const Spacer(), + _OpenExportedDirectoryButton( + onTap: () async { + await showDialog( + context: context, + builder: (context) { + return const FlowyDialog( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), + child: FileExporterWidget(), + ), + ); + }, + ); + }, + ), + ], + ); + } +} + +class _OpenExportedDirectoryButton extends StatelessWidget { + const _OpenExportedDirectoryButton({ + required this.onTap, + }); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + hoverColor: Theme.of(context).colorScheme.secondaryContainer, + tooltipText: LocaleKeys.settings_files_export.tr(), + icon: FlowySvg( + FlowySvgs.open_folder_lg, + color: Theme.of(context).iconTheme.color, + ), + onPressed: onTap, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_exporter_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_exporter_widget.dart new file mode 100644 index 0000000000000..7c8e128ec68d4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/files/settings_file_exporter_widget.dart @@ -0,0 +1,275 @@ +import 'dart:io'; + +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/navigator_context_extension.dart'; +import 'package:appflowy/workspace/application/export/document_exporter.dart'; +import 'package:appflowy/workspace/application/settings/settings_file_exporter_cubit.dart'; +import 'package:appflowy/workspace/application/settings/share/export_service.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:path/path.dart' as p; + +import '../../../../../generated/locale_keys.g.dart'; + +class FileExporterWidget extends StatefulWidget { + const FileExporterWidget({super.key}); + + @override + State createState() => _FileExporterWidgetState(); +} + +class _FileExporterWidgetState extends State { + // Map> _selectedPages = {}; + + SettingsFileExporterCubit? cubit; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: FolderEventReadCurrentWorkspace().send(), + builder: (context, snapshot) { + if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + final workspace = snapshot.data?.fold((s) => s, (e) => null); + if (workspace != null) { + final views = workspace.views; + cubit ??= SettingsFileExporterCubit(views: views); + return BlocProvider.value( + value: cubit!, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FlowyText.medium( + LocaleKeys.settings_files_selectFiles.tr(), + fontSize: 16.0, + ), + BlocBuilder( + builder: (context, state) => FlowyTextButton( + state.selectedItems + .expand((element) => element) + .every((element) => element) + ? LocaleKeys.settings_files_deselectAll.tr() + : LocaleKeys.settings_files_selectAll.tr(), + fontColor: AFThemeExtension.of(context).textColor, + onPressed: () { + context + .read() + .selectOrDeselectAllItems(); + }, + ), + ), + ], + ), + const VSpace(8), + const Expanded(child: _ExpandedList()), + const VSpace(8), + _buildButtons(), + ], + ), + ); + } + } + return const CircularProgressIndicator(); + }, + ); + } + + Widget _buildButtons() { + return Row( + children: [ + const Spacer(), + FlowyTextButton( + LocaleKeys.button_cancel.tr(), + fontColor: AFThemeExtension.of(context).textColor, + onPressed: () => Navigator.of(context).pop(), + ), + const HSpace(8), + FlowyTextButton( + LocaleKeys.button_ok.tr(), + fontColor: AFThemeExtension.of(context).textColor, + onPressed: () async { + await getIt() + .getDirectoryPath() + .then((exportPath) async { + if (exportPath != null && cubit != null) { + final views = cubit!.state.selectedViews; + final result = + await _AppFlowyFileExporter.exportToPath(exportPath, views); + if (mounted) { + if (result.$1) { + // success + showSnackBarMessage( + context, + LocaleKeys.settings_files_exportFileSuccess.tr(), + ); + } else { + showSnackBarMessage( + context, + LocaleKeys.settings_files_exportFileFail.tr() + + result.$2.join('\n'), + ); + } + } + } else if (mounted) { + showSnackBarMessage( + context, + LocaleKeys.settings_files_exportFileFail.tr(), + ); + } + if (mounted) { + context.popToHome(); + } + }); + }, + ), + ], + ); + } +} + +class _ExpandedList extends StatefulWidget { + const _ExpandedList(); + + // final List apps; + // final void Function(Map> selectedPages) onChanged; + + @override + State<_ExpandedList> createState() => _ExpandedListState(); +} + +class _ExpandedListState extends State<_ExpandedList> { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Material( + color: Colors.transparent, + child: SingleChildScrollView( + child: Column( + children: _buildChildren(context), + ), + ), + ); + }, + ); + } + + List _buildChildren(BuildContext context) { + final apps = context.read().state.views; + final List children = []; + for (var i = 0; i < apps.length; i++) { + children.add(_buildExpandedItem(context, i)); + } + return children; + } + + Widget _buildExpandedItem(BuildContext context, int index) { + final state = context.read().state; + final apps = state.views; + final expanded = state.expanded; + final selectedItems = state.selectedItems; + final isExpanded = expanded[index] == true; + final List expandedChildren = []; + if (isExpanded) { + for (var i = 0; i < selectedItems[index].length; i++) { + final name = apps[index].childViews[i].name; + final checkbox = CheckboxListTile( + value: selectedItems[index][i], + onChanged: (value) { + // update selected item + context + .read() + .selectOrDeselectItem(index, i); + }, + title: FlowyText.regular(' $name'), + ); + expandedChildren.add(checkbox); + } + } + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => context + .read() + .expandOrUnexpandApp(index), + child: ListTile( + title: FlowyText.medium(apps[index].name), + trailing: Icon( + isExpanded + ? Icons.arrow_drop_down_rounded + : Icons.arrow_drop_up_rounded, + ), + ), + ), + ...expandedChildren, + ], + ); + } +} + +class _AppFlowyFileExporter { + static Future<(bool result, List failedNames)> exportToPath( + String path, + List views, + ) async { + final failedFileNames = []; + final Map names = {}; + for (final view in views) { + String? content; + String? fileExtension; + switch (view.layout) { + case ViewLayoutPB.Document: + final documentExporter = DocumentExporter(view); + final result = await documentExporter.export( + DocumentExportType.json, + ); + result.fold( + (json) { + content = json; + }, + (e) => Log.error(e), + ); + fileExtension = 'afdocument'; + break; + default: + final result = + await BackendExportService.exportDatabaseAsCSV(view.id); + result.fold( + (l) => content = l.data, + (r) => Log.error(r), + ); + fileExtension = 'csv'; + break; + } + if (content != null) { + final count = names.putIfAbsent(view.name, () => 0); + final name = count == 0 ? view.name : '${view.name}($count)'; + final file = File(p.join(path, '$name.$fileExtension')); + await file.writeAsString(content!); + names[view.name] = count + 1; + } else { + failedFileNames.add(view.name); + } + } + + return (failedFileNames.isEmpty, failedFileNames); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart new file mode 100644 index 0000000000000..c9fcb3420469d --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart @@ -0,0 +1,312 @@ +import 'dart:async'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'workspace_member_bloc.freezed.dart'; + +// 1. get the workspace members +// 2. display the content based on the user role +// Owner: +// - invite member button +// - delete member button +// - member list +// Member: +// Guest: +// - member list +class WorkspaceMemberBloc + extends Bloc { + WorkspaceMemberBloc({ + required this.userProfile, + String? workspaceId, + this.workspace, + }) : _userBackendService = UserBackendService(userId: userProfile.id), + super(WorkspaceMemberState.initial()) { + on((event, emit) async { + await event.when( + initial: () async { + await _setCurrentWorkspaceId(workspaceId); + + final result = await _userBackendService.getWorkspaceMembers( + _workspaceId, + ); + final members = result.fold>( + (s) => s.items, + (e) => [], + ); + final myRole = _getMyRole(members); + + if (myRole.isOwner) { + unawaited(_fetchWorkspaceSubscriptionInfo()); + } + emit( + state.copyWith( + members: members, + myRole: myRole, + isLoading: false, + actionResult: WorkspaceMemberActionResult( + actionType: WorkspaceMemberActionType.get, + result: result, + ), + ), + ); + }, + getWorkspaceMembers: () async { + final result = await _userBackendService.getWorkspaceMembers( + _workspaceId, + ); + final members = result.fold>( + (s) => s.items, + (e) => [], + ); + final myRole = _getMyRole(members); + emit( + state.copyWith( + members: members, + myRole: myRole, + actionResult: WorkspaceMemberActionResult( + actionType: WorkspaceMemberActionType.get, + result: result, + ), + ), + ); + }, + addWorkspaceMember: (email) async { + final result = await _userBackendService.addWorkspaceMember( + _workspaceId, + email, + ); + emit( + state.copyWith( + actionResult: WorkspaceMemberActionResult( + actionType: WorkspaceMemberActionType.add, + result: result, + ), + ), + ); + // the addWorkspaceMember doesn't return the updated members, + // so we need to get the members again + result.onSuccess((s) { + add(const WorkspaceMemberEvent.getWorkspaceMembers()); + }); + }, + inviteWorkspaceMember: (email) async { + final result = await _userBackendService.inviteWorkspaceMember( + _workspaceId, + email, + role: AFRolePB.Member, + ); + emit( + state.copyWith( + actionResult: WorkspaceMemberActionResult( + actionType: WorkspaceMemberActionType.invite, + result: result, + ), + ), + ); + }, + removeWorkspaceMember: (email) async { + final result = await _userBackendService.removeWorkspaceMember( + _workspaceId, + email, + ); + final members = result.fold( + (s) => state.members.where((e) => e.email != email).toList(), + (e) => state.members, + ); + emit( + state.copyWith( + members: members, + actionResult: WorkspaceMemberActionResult( + actionType: WorkspaceMemberActionType.remove, + result: result, + ), + ), + ); + }, + updateWorkspaceMember: (email, role) async { + final result = await _userBackendService.updateWorkspaceMember( + _workspaceId, + email, + role, + ); + final members = result.fold( + (s) => state.members.map((e) { + if (e.email == email) { + e.freeze(); + return e.rebuild((p0) => p0.role = role); + } + return e; + }).toList(), + (e) => state.members, + ); + emit( + state.copyWith( + members: members, + actionResult: WorkspaceMemberActionResult( + actionType: WorkspaceMemberActionType.updateRole, + result: result, + ), + ), + ); + }, + updateSubscriptionInfo: (info) async => + emit(state.copyWith(subscriptionInfo: info)), + upgradePlan: () async { + final plan = state.subscriptionInfo?.plan; + if (plan == null) { + return Log.error('Failed to upgrade plan: plan is null'); + } + + if (plan == WorkspacePlanPB.FreePlan) { + final checkoutLink = await _userBackendService.createSubscription( + _workspaceId, + SubscriptionPlanPB.Pro, + ); + + checkoutLink.fold( + (pl) => afLaunchUrlString(pl.paymentLink), + (f) => Log.error('Failed to create subscription: ${f.msg}', f), + ); + } + }, + ); + }); + } + + final UserProfilePB userProfile; + + // if the workspace is null, use the current workspace + final UserWorkspacePB? workspace; + + late final String _workspaceId; + final UserBackendService _userBackendService; + + AFRolePB _getMyRole(List members) { + final role = members + .firstWhereOrNull( + (e) => e.email == userProfile.email, + ) + ?.role; + if (role == null) { + Log.error('Failed to get my role'); + return AFRolePB.Guest; + } + return role; + } + + Future _setCurrentWorkspaceId(String? workspaceId) async { + if (workspace != null) { + _workspaceId = workspace!.workspaceId; + } else if (workspaceId != null && workspaceId.isNotEmpty) { + _workspaceId = workspaceId; + } else { + final currentWorkspace = await FolderEventReadCurrentWorkspace().send(); + currentWorkspace.fold((s) { + _workspaceId = s.id; + }, (e) { + assert(false, 'Failed to read current workspace: $e'); + Log.error('Failed to read current workspace: $e'); + _workspaceId = ''; + }); + } + } + + // We fetch workspace subscription info lazily as it's not needed in the first + // render of the page. + Future _fetchWorkspaceSubscriptionInfo() async { + final result = + await UserBackendService.getWorkspaceSubscriptionInfo(_workspaceId); + + result.fold( + (info) { + if (!isClosed) { + add(WorkspaceMemberEvent.updateSubscriptionInfo(info)); + } + }, + (f) => Log.error('Failed to fetch subscription info: ${f.msg}', f), + ); + } +} + +@freezed +class WorkspaceMemberEvent with _$WorkspaceMemberEvent { + const factory WorkspaceMemberEvent.initial() = Initial; + const factory WorkspaceMemberEvent.getWorkspaceMembers() = + GetWorkspaceMembers; + const factory WorkspaceMemberEvent.addWorkspaceMember(String email) = + AddWorkspaceMember; + const factory WorkspaceMemberEvent.inviteWorkspaceMember(String email) = + InviteWorkspaceMember; + const factory WorkspaceMemberEvent.removeWorkspaceMember(String email) = + RemoveWorkspaceMember; + const factory WorkspaceMemberEvent.updateWorkspaceMember( + String email, + AFRolePB role, + ) = UpdateWorkspaceMember; + const factory WorkspaceMemberEvent.updateSubscriptionInfo( + WorkspaceSubscriptionInfoPB subscriptionInfo, + ) = UpdateSubscriptionInfo; + + const factory WorkspaceMemberEvent.upgradePlan() = UpgradePlan; +} + +enum WorkspaceMemberActionType { + none, + get, + // this event will send an invitation to the member + invite, + // this event will add the member without sending an invitation + add, + remove, + updateRole, +} + +class WorkspaceMemberActionResult { + const WorkspaceMemberActionResult({ + required this.actionType, + required this.result, + }); + + final WorkspaceMemberActionType actionType; + final FlowyResult result; +} + +@freezed +class WorkspaceMemberState with _$WorkspaceMemberState { + const WorkspaceMemberState._(); + + const factory WorkspaceMemberState({ + @Default([]) List members, + @Default(AFRolePB.Guest) AFRolePB myRole, + @Default(null) WorkspaceMemberActionResult? actionResult, + @Default(true) bool isLoading, + @Default(null) WorkspaceSubscriptionInfoPB? subscriptionInfo, + }) = _WorkspaceMemberState; + + factory WorkspaceMemberState.initial() => const WorkspaceMemberState(); + + @override + int get hashCode => runtimeType.hashCode; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is WorkspaceMemberState && + other.members == members && + other.myRole == myRole && + other.subscriptionInfo == subscriptionInfo && + identical(other.actionResult, actionResult); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart new file mode 100644 index 0000000000000..bf33ab9d72fc7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart @@ -0,0 +1,620 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:string_validator/string_validator.dart'; + +class WorkspaceMembersPage extends StatelessWidget { + const WorkspaceMembersPage({ + super.key, + required this.userProfile, + required this.workspaceId, + }); + + final UserProfilePB userProfile; + final String workspaceId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => WorkspaceMemberBloc(userProfile: userProfile) + ..add(const WorkspaceMemberEvent.initial()), + child: BlocConsumer( + listener: _showResultDialog, + builder: (context, state) { + return SettingsBody( + title: LocaleKeys.settings_appearance_members_title.tr(), + autoSeparate: false, + children: [ + if (state.actionResult != null) ...[ + _showMemberLimitWarning(context, state), + const VSpace(16), + ], + if (state.myRole.canInvite) ...[ + const _InviteMember(), + const SettingsCategorySpacer(), + ], + if (state.members.isNotEmpty) + _MemberList( + members: state.members, + userProfile: userProfile, + myRole: state.myRole, + ), + ], + ); + }, + ), + ); + } + + Widget _showMemberLimitWarning( + BuildContext context, + WorkspaceMemberState state, + ) { + // We promise that state.actionResult != null before calling + // this method + final actionResult = state.actionResult!.result; + final actionType = state.actionResult!.actionType; + + if (actionType == WorkspaceMemberActionType.invite && + actionResult.isFailure) { + final error = actionResult.getFailure().code; + if (error == ErrorCode.WorkspaceMemberLimitExceeded) { + return Row( + children: [ + const FlowySvg( + FlowySvgs.warning_s, + blendMode: BlendMode.dst, + size: Size.square(20), + ), + const HSpace(12), + Expanded( + child: RichText( + text: TextSpan( + children: [ + if (state.subscriptionInfo?.plan == + WorkspacePlanPB.ProPlan) ...[ + TextSpan( + text: LocaleKeys + .settings_appearance_members_memberLimitExceededPro + .tr(), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AFThemeExtension.of(context).strongText, + ), + ), + WidgetSpan( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + // Hardcoded support email, in the future we might + // want to add this to an environment variable + onTap: () async => afLaunchUrlString( + 'mailto:support@appflowy.io', + ), + child: FlowyText( + LocaleKeys + .settings_appearance_members_memberLimitExceededProContact + .tr(), + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + ] else ...[ + TextSpan( + text: LocaleKeys + .settings_appearance_members_memberLimitExceeded + .tr(), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AFThemeExtension.of(context).strongText, + ), + ), + WidgetSpan( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => context + .read() + .add(const WorkspaceMemberEvent.upgradePlan()), + child: FlowyText( + LocaleKeys + .settings_appearance_members_memberLimitExceededUpgrade + .tr(), + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + ], + ], + ), + ), + ), + ], + ); + } + } + + return const SizedBox.shrink(); + } + + void _showResultDialog(BuildContext context, WorkspaceMemberState state) { + final actionResult = state.actionResult; + if (actionResult == null) { + return; + } + + final actionType = actionResult.actionType; + final result = actionResult.result; + + // only show the result dialog when the action is WorkspaceMemberActionType.add + if (actionType == WorkspaceMemberActionType.add) { + result.fold( + (s) { + showSnackBarMessage( + context, + LocaleKeys.settings_appearance_members_addMemberSuccess.tr(), + ); + }, + (f) { + Log.error('add workspace member failed: $f'); + final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded + ? LocaleKeys.settings_appearance_members_memberLimitExceeded.tr() + : LocaleKeys.settings_appearance_members_failedToAddMember.tr(); + showDialog( + context: context, + builder: (context) => NavigatorOkCancelDialog(message: message), + ); + }, + ); + } else if (actionType == WorkspaceMemberActionType.invite) { + result.fold( + (s) { + showSnackBarMessage( + context, + LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(), + ); + }, + (f) { + Log.error('invite workspace member failed: $f'); + final message = f.code == ErrorCode.WorkspaceMemberLimitExceeded + ? LocaleKeys.settings_appearance_members_inviteFailedMemberLimit + .tr() + : LocaleKeys.settings_appearance_members_failedToInviteMember + .tr(); + showConfirmDialog( + context: context, + title: LocaleKeys + .settings_appearance_members_inviteFailedDialogTitle + .tr(), + description: message, + confirmLabel: LocaleKeys.button_ok.tr(), + ); + }, + ); + } + } +} + +class _InviteMember extends StatefulWidget { + const _InviteMember(); + + @override + State<_InviteMember> createState() => _InviteMemberState(); +} + +class _InviteMemberState extends State<_InviteMember> { + final _emailController = TextEditingController(); + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.semibold( + LocaleKeys.settings_appearance_members_inviteMembers.tr(), + fontSize: 16.0, + ), + const VSpace(8.0), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints.tightFor( + height: 48.0, + ), + child: FlowyTextField( + hintText: + LocaleKeys.settings_appearance_members_inviteHint.tr(), + controller: _emailController, + onEditingComplete: _inviteMember, + ), + ), + ), + const HSpace(10.0), + SizedBox( + height: 48.0, + child: IntrinsicWidth( + child: PrimaryRoundedButton( + text: LocaleKeys.settings_appearance_members_sendInvite.tr(), + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + onTap: _inviteMember, + ), + ), + ), + ], + ), + /* Enable this when the feature is ready + PrimaryButton( + backgroundColor: const Color(0xFFE0E0E0), + child: Padding( + padding: const EdgeInsets.only( + left: 20, + right: 24, + top: 8, + bottom: 8, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const FlowySvg( + FlowySvgs.invite_member_link_m, + color: Colors.black, + ), + const HSpace(8.0), + FlowyText( + LocaleKeys.settings_appearance_members_copyInviteLink.tr(), + color: Colors.black, + ), + ], + ), + ), + onPressed: () { + showSnackBarMessage(context, 'not implemented'); + }, + ), + const VSpace(16.0), + */ + ], + ); + } + + void _inviteMember() { + final email = _emailController.text; + if (!isEmail(email)) { + return showSnackBarMessage( + context, + LocaleKeys.settings_appearance_members_emailInvalidError.tr(), + ); + } + context + .read() + .add(WorkspaceMemberEvent.inviteWorkspaceMember(email)); + // clear the email field after inviting + _emailController.clear(); + } +} + +class _MemberList extends StatelessWidget { + const _MemberList({ + required this.members, + required this.myRole, + required this.userProfile, + }); + + final List members; + final AFRolePB myRole; + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + return SeparatedColumn( + crossAxisAlignment: CrossAxisAlignment.start, + separatorBuilder: () => const Divider(), + children: [ + const _MemberListHeader(), + ...members.map( + (member) => _MemberItem( + member: member, + myRole: myRole, + userProfile: userProfile, + ), + ), + ], + ); + } +} + +class _MemberListHeader extends StatelessWidget { + const _MemberListHeader(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.semibold( + LocaleKeys.settings_appearance_members_label.tr(), + fontSize: 16.0, + ), + const VSpace(16.0), + Row( + children: [ + Expanded( + child: FlowyText.semibold( + LocaleKeys.settings_appearance_members_user.tr(), + fontSize: 14.0, + ), + ), + Expanded( + child: FlowyText.semibold( + LocaleKeys.settings_appearance_members_role.tr(), + fontSize: 14.0, + ), + ), + const HSpace(28.0), + ], + ), + ], + ); + } +} + +class _MemberItem extends StatelessWidget { + const _MemberItem({ + required this.member, + required this.myRole, + required this.userProfile, + }); + + final WorkspaceMemberPB member; + final AFRolePB myRole; + final UserProfilePB userProfile; + + @override + Widget build(BuildContext context) { + final textColor = member.role.isOwner ? Theme.of(context).hintColor : null; + return Row( + children: [ + Expanded( + child: FlowyText.medium( + member.name, + color: textColor, + fontSize: 14.0, + ), + ), + Expanded( + child: member.role.isOwner || !myRole.canUpdate + ? FlowyText.medium( + member.role.description, + color: textColor, + fontSize: 14.0, + ) + : _MemberRoleActionList( + member: member, + ), + ), + myRole.canDelete && + member.email != userProfile.email // can't delete self + ? _MemberMoreActionList(member: member) + : const HSpace(28.0), + ], + ); + } +} + +enum _MemberMoreAction { + delete, +} + +class _MemberMoreActionList extends StatelessWidget { + const _MemberMoreActionList({ + required this.member, + }); + + final WorkspaceMemberPB member; + + @override + Widget build(BuildContext context) { + return PopoverActionList<_MemberMoreActionWrapper>( + asBarrier: true, + direction: PopoverDirection.bottomWithCenterAligned, + actions: _MemberMoreAction.values + .map((e) => _MemberMoreActionWrapper(e, member)) + .toList(), + buildChild: (controller) { + return FlowyButton( + useIntrinsicWidth: true, + text: const FlowySvg( + FlowySvgs.three_dots_vertical_s, + ), + onTap: () { + controller.show(); + }, + ); + }, + onSelected: (action, controller) { + switch (action.inner) { + case _MemberMoreAction.delete: + showDialog( + context: context, + builder: (_) => NavigatorOkCancelDialog( + title: LocaleKeys.settings_appearance_members_removeMember.tr(), + message: LocaleKeys + .settings_appearance_members_areYouSureToRemoveMember + .tr(), + onOkPressed: () => context.read().add( + WorkspaceMemberEvent.removeWorkspaceMember( + action.member.email, + ), + ), + okTitle: LocaleKeys.button_yes.tr(), + ), + ); + break; + } + controller.close(); + }, + ); + } +} + +class _MemberMoreActionWrapper extends ActionCell { + _MemberMoreActionWrapper(this.inner, this.member); + + final _MemberMoreAction inner; + final WorkspaceMemberPB member; + + @override + String get name { + switch (inner) { + case _MemberMoreAction.delete: + return LocaleKeys.settings_appearance_members_removeFromWorkspace.tr(); + } + } +} + +class _MemberRoleActionList extends StatelessWidget { + const _MemberRoleActionList({ + required this.member, + }); + + final WorkspaceMemberPB member; + + @override + Widget build(BuildContext context) { + return PopoverActionList<_MemberRoleActionWrapper>( + asBarrier: true, + direction: PopoverDirection.bottomWithLeftAligned, + actions: [AFRolePB.Member] + .map((e) => _MemberRoleActionWrapper(e, member)) + .toList(), + offset: const Offset(0, 10), + buildChild: (controller) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => controller.show(), + child: Row( + children: [ + FlowyText.medium( + member.role.description, + fontSize: 14.0, + ), + const HSpace(8.0), + const FlowySvg( + FlowySvgs.drop_menu_show_s, + ), + ], + ), + ), + ); + }, + onSelected: (action, controller) async { + switch (action.inner) { + case AFRolePB.Member: + case AFRolePB.Guest: + context.read().add( + WorkspaceMemberEvent.updateWorkspaceMember( + action.member.email, + action.inner, + ), + ); + break; + case AFRolePB.Owner: + break; + } + controller.close(); + }, + ); + } +} + +class _MemberRoleActionWrapper extends ActionCell { + _MemberRoleActionWrapper(this.inner, this.member); + + final AFRolePB inner; + final WorkspaceMemberPB member; + + @override + Widget? rightIcon(Color iconColor) { + return SizedBox( + width: 58.0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + FlowyTooltip( + message: tooltip, + child: const FlowySvg( + FlowySvgs.information_s, + // color: iconColor, + ), + ), + const Spacer(), + if (member.role == inner) + const FlowySvg( + FlowySvgs.checkmark_tiny_s, + ), + ], + ), + ); + } + + @override + String get name { + switch (inner) { + case AFRolePB.Guest: + return LocaleKeys.settings_appearance_members_guest.tr(); + case AFRolePB.Member: + return LocaleKeys.settings_appearance_members_member.tr(); + case AFRolePB.Owner: + return LocaleKeys.settings_appearance_members_owner.tr(); + } + throw UnimplementedError('Unknown role: $inner'); + } + + String get tooltip { + switch (inner) { + case AFRolePB.Guest: + return LocaleKeys.settings_appearance_members_guestHintText.tr(); + case AFRolePB.Member: + return LocaleKeys.settings_appearance_members_memberHintText.tr(); + case AFRolePB.Owner: + return ''; + } + throw UnimplementedError('Unknown role: $inner'); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart new file mode 100644 index 0000000000000..d99a6cbf01682 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart @@ -0,0 +1,423 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/env/env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/appflowy_cloud_setting_bloc.dart'; +import 'package:appflowy/workspace/application/settings/appflowy_cloud_urls_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/_restart_app_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/error_page.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class AppFlowyCloudViewSetting extends StatelessWidget { + const AppFlowyCloudViewSetting({ + super.key, + this.serverURL = kAppflowyCloudUrl, + this.authenticatorType = AuthenticatorType.appflowyCloud, + required this.restartAppFlowy, + }); + + final String serverURL; + final AuthenticatorType authenticatorType; + final VoidCallback restartAppFlowy; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: UserEventGetCloudConfig().send(), + builder: (context, snapshot) { + if (snapshot.data != null && + snapshot.connectionState == ConnectionState.done) { + return snapshot.data!.fold( + (setting) => _renderContent(context, setting), + (err) => FlowyErrorPage.message(err.toString(), howToFix: ""), + ); + } + + return const Center( + child: CircularProgressIndicator(), + ); + }, + ); + } + + BlocProvider _renderContent( + BuildContext context, + CloudSettingPB setting, + ) { + return BlocProvider( + create: (context) => AppFlowyCloudSettingBloc(setting) + ..add(const AppFlowyCloudSettingEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + const AppFlowyCloudEnableSync(), + const AppFlowyCloudSyncLogEnabled(), + const VSpace(12), + RestartButton( + onClick: () { + NavigatorAlertDialog( + title: LocaleKeys.settings_menu_restartAppTip.tr(), + confirm: () async { + await useAppFlowyBetaCloudWithURL( + serverURL, + authenticatorType, + ); + restartAppFlowy(); + }, + ).show(context); + }, + showRestartHint: state.showRestartHint, + ), + ], + ); + }, + ), + ); + } +} + +class CustomAppFlowyCloudView extends StatelessWidget { + const CustomAppFlowyCloudView({required this.restartAppFlowy, super.key}); + + final VoidCallback restartAppFlowy; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: UserEventGetCloudConfig().send(), + builder: (context, snapshot) { + if (snapshot.data != null && + snapshot.connectionState == ConnectionState.done) { + return snapshot.data!.fold( + (setting) => _renderContent(setting), + (err) => FlowyErrorPage.message(err.toString(), howToFix: ""), + ); + } else { + return const Center( + child: CircularProgressIndicator(), + ); + } + }, + ); + } + + BlocProvider _renderContent( + CloudSettingPB setting, + ) { + final List children = []; + children.addAll([ + const AppFlowyCloudEnableSync(), + const AppFlowyCloudSyncLogEnabled(), + const VSpace(40), + ]); + + // If the enableCustomCloud flag is true, then the user can dynamically configure cloud settings. Otherwise, the user cannot dynamically configure cloud settings. + if (Env.enableCustomCloud) { + children.add( + AppFlowyCloudURLs(restartAppFlowy: () => restartAppFlowy()), + ); + } else { + children.add( + Row( + children: [ + FlowyText(LocaleKeys.settings_menu_cloudServerType.tr()), + const Spacer(), + const FlowyText(Env.afCloudUrl), + ], + ), + ); + } + return BlocProvider( + create: (context) => AppFlowyCloudSettingBloc(setting) + ..add(const AppFlowyCloudSettingEvent.initial()), + child: Column( + children: children, + ), + ); + } +} + +class AppFlowyCloudURLs extends StatelessWidget { + const AppFlowyCloudURLs({super.key, required this.restartAppFlowy}); + + final VoidCallback restartAppFlowy; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + AppFlowyCloudURLsBloc()..add(const AppFlowyCloudURLsEvent.initial()), + child: BlocListener( + listener: (context, state) async { + if (state.restartApp) { + restartAppFlowy(); + } + }, + child: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + const AppFlowySelfhostTip(), + CloudURLInput( + title: LocaleKeys.settings_menu_cloudURL.tr(), + url: state.config.base_url, + hint: LocaleKeys.settings_menu_cloudURLHint.tr(), + onChanged: (text) { + context.read().add( + AppFlowyCloudURLsEvent.updateServerUrl( + text, + ), + ); + }, + ), + const VSpace(8), + RestartButton( + onClick: () { + NavigatorAlertDialog( + title: LocaleKeys.settings_menu_restartAppTip.tr(), + confirm: () { + context.read().add( + const AppFlowyCloudURLsEvent.confirmUpdate(), + ); + }, + ).show(context); + }, + showRestartHint: state.showRestartHint, + ), + ], + ); + }, + ), + ), + ); + } +} + +class AppFlowySelfhostTip extends StatelessWidget { + const AppFlowySelfhostTip({super.key}); + + final url = + "https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy#build-appflowy-with-a-self-hosted-server"; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: 0.6, + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_menu_selfHostStart.tr(), + style: Theme.of(context).textTheme.bodySmall!, + ), + TextSpan( + text: " ${LocaleKeys.settings_menu_selfHostContent.tr()} ", + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: FontSizes.s14, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => afLaunchUrlString(url), + ), + TextSpan( + text: LocaleKeys.settings_menu_selfHostEnd.tr(), + style: Theme.of(context).textTheme.bodySmall!, + ), + ], + ), + ), + ); + } +} + +@visibleForTesting +class CloudURLInput extends StatefulWidget { + const CloudURLInput({ + super.key, + required this.title, + required this.url, + required this.hint, + required this.onChanged, + }); + + final String title; + final String url; + final String hint; + final Function(String) onChanged; + + @override + CloudURLInputState createState() => CloudURLInputState(); +} + +class CloudURLInputState extends State { + late TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.url); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + style: const TextStyle(fontSize: 12.0), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(vertical: 6), + labelText: widget.title, + labelStyle: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontWeight: FontWeight.w400, fontSize: 16), + enabledBorder: UnderlineInputBorder( + borderSide: + BorderSide(color: AFThemeExtension.of(context).onBackground), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), + ), + hintText: widget.hint, + errorText: context.read().state.urlError, + ), + onChanged: widget.onChanged, + ); + } +} + +class AppFlowyCloudEnableSync extends StatelessWidget { + const AppFlowyCloudEnableSync({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Row( + children: [ + FlowyText.medium(LocaleKeys.settings_menu_enableSync.tr()), + const Spacer(), + Toggle( + value: state.setting.enableSync, + onChanged: (value) => context + .read() + .add(AppFlowyCloudSettingEvent.enableSync(value)), + ), + ], + ); + }, + ); + } +} + +class AppFlowyCloudSyncLogEnabled extends StatelessWidget { + const AppFlowyCloudSyncLogEnabled({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Row( + children: [ + FlowyText.medium(LocaleKeys.settings_menu_enableSyncLog.tr()), + const Spacer(), + Toggle( + value: state.isSyncLogEnabled, + onChanged: (value) { + if (value) { + showCancelAndConfirmDialog( + context: context, + title: LocaleKeys.settings_menu_enableSyncLog.tr(), + description: + LocaleKeys.settings_menu_enableSyncLogWarning.tr(), + confirmLabel: LocaleKeys.button_confirm.tr(), + onConfirm: () { + context + .read() + .add(AppFlowyCloudSettingEvent.enableSyncLog(value)); + }, + ); + } else { + context + .read() + .add(AppFlowyCloudSettingEvent.enableSyncLog(value)); + } + }, + ), + ], + ); + }, + ); + } +} + +class BillingGateGuard extends StatelessWidget { + const BillingGateGuard({required this.builder, super.key}); + + final Widget Function(BuildContext context) builder; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: isBillingEnabled(), + builder: (context, snapshot) { + final isBillingEnabled = snapshot.data ?? false; + if (isBillingEnabled && + snapshot.connectionState == ConnectionState.done) { + return builder(context); + } + + // If the billing is not enabled, show nothing + return const SizedBox.shrink(); + }, + ); + } +} + +Future isBillingEnabled() async { + final result = await UserEventGetCloudConfig().send(); + return result.fold( + (cloudSetting) { + final whiteList = [ + "https://beta.appflowy.cloud", + "https://test.appflowy.cloud", + ]; + if (kDebugMode) { + whiteList.add("http://localhost:8000"); + } + + final isWhiteListed = whiteList.contains(cloudSetting.serverUrl); + if (!isWhiteListed) { + Log.warn("Billing is not enabled for server ${cloudSetting.serverUrl}"); + } + return isWhiteListed; + }, + (err) { + Log.error("Failed to get cloud config: $err"); + return false; + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart new file mode 100644 index 0000000000000..d937b47736638 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/env/env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/settings/cloud_setting_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_local_cloud.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import 'setting_appflowy_cloud.dart'; + +class SettingCloud extends StatelessWidget { + const SettingCloud({ + super.key, + required this.restartAppFlowy, + }); + + final VoidCallback restartAppFlowy; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: getAuthenticatorType(), + builder: + (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + final cloudType = snapshot.data!; + return BlocProvider( + create: (context) => CloudSettingBloc(cloudType), + child: BlocBuilder( + builder: (context, state) { + return SettingsBody( + title: LocaleKeys.settings_menu_cloudSettings.tr(), + autoSeparate: false, + children: [ + if (Env.enableCustomCloud) + Row( + children: [ + Expanded( + child: FlowyText.medium( + LocaleKeys.settings_menu_cloudServerType.tr(), + ), + ), + Flexible( + child: CloudTypeSwitcher( + cloudType: state.cloudType, + onSelected: (type) => context + .read() + .add(CloudSettingEvent.updateCloudType(type)), + ), + ), + ], + ), + _viewFromCloudType(state.cloudType), + ], + ); + }, + ), + ); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, + ); + } + + Widget _viewFromCloudType(AuthenticatorType cloudType) { + switch (cloudType) { + case AuthenticatorType.local: + return SettingLocalCloud(restartAppFlowy: restartAppFlowy); + case AuthenticatorType.appflowyCloud: + return AppFlowyCloudViewSetting(restartAppFlowy: restartAppFlowy); + case AuthenticatorType.appflowyCloudSelfHost: + return CustomAppFlowyCloudView(restartAppFlowy: restartAppFlowy); + case AuthenticatorType.appflowyCloudDevelop: + return AppFlowyCloudViewSetting( + serverURL: "http://localhost", + authenticatorType: AuthenticatorType.appflowyCloudDevelop, + restartAppFlowy: restartAppFlowy, + ); + } + } +} + +class CloudTypeSwitcher extends StatelessWidget { + const CloudTypeSwitcher({ + super.key, + required this.cloudType, + required this.onSelected, + }); + + final AuthenticatorType cloudType; + final Function(AuthenticatorType) onSelected; + + @override + Widget build(BuildContext context) { + final isDevelopMode = integrationMode().isDevelop; + // Only show the appflowyCloudDevelop in develop mode + final values = AuthenticatorType.values.where((element) { + return isDevelopMode || element != AuthenticatorType.appflowyCloudDevelop; + }).toList(); + return UniversalPlatform.isDesktopOrWeb + ? SettingsDropdown( + selectedOption: cloudType, + onChanged: (type) { + if (type != cloudType) { + NavigatorAlertDialog( + title: LocaleKeys.settings_menu_changeServerTip.tr(), + confirm: () async { + onSelected(type); + }, + hideCancelButton: true, + ).show(context); + } + }, + options: values + .map( + (type) => buildDropdownMenuEntry( + context, + value: type, + label: titleFromCloudType(type), + ), + ) + .toList(), + ) + : FlowyButton( + text: FlowyText(titleFromCloudType(cloudType)), + useIntrinsicWidth: true, + rightIcon: const Icon( + Icons.chevron_right, + ), + onTap: () => showMobileBottomSheet( + context, + showHeader: true, + showDragHandle: true, + showDivider: false, + title: LocaleKeys.settings_menu_cloudServerType.tr(), + builder: (context) => Column( + children: values + .mapIndexed( + (i, e) => FlowyOptionTile.checkbox( + text: titleFromCloudType(values[i]), + isSelected: cloudType == values[i], + onTap: () { + onSelected(e); + context.pop(); + }, + showBottomBorder: i == values.length - 1, + ), + ) + .toList(), + ), + ), + ); + } +} + +class CloudTypeItem extends StatelessWidget { + const CloudTypeItem({ + super.key, + required this.cloudType, + required this.currentCloudtype, + required this.onSelected, + }); + + final AuthenticatorType cloudType; + final AuthenticatorType currentCloudtype; + final Function(AuthenticatorType) onSelected; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: FlowyButton( + text: FlowyText.medium( + titleFromCloudType(cloudType), + ), + rightIcon: currentCloudtype == cloudType + ? const FlowySvg(FlowySvgs.check_s) + : null, + onTap: () { + if (currentCloudtype != cloudType) { + NavigatorAlertDialog( + title: LocaleKeys.settings_menu_changeServerTip.tr(), + confirm: () async { + onSelected(cloudType); + }, + hideCancelButton: true, + ).show(context); + } + PopoverContainer.of(context).close(); + }, + ), + ); + } +} + +String titleFromCloudType(AuthenticatorType cloudType) { + switch (cloudType) { + case AuthenticatorType.local: + return LocaleKeys.settings_menu_cloudLocal.tr(); + case AuthenticatorType.appflowyCloud: + return LocaleKeys.settings_menu_cloudAppFlowy.tr(); + case AuthenticatorType.appflowyCloudSelfHost: + return LocaleKeys.settings_menu_cloudAppFlowySelfHost.tr(); + case AuthenticatorType.appflowyCloudDevelop: + return "AppFlowyCloud Develop"; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_local_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_local_cloud.dart new file mode 100644 index 0000000000000..68680c0dd0fe1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_local_cloud.dart @@ -0,0 +1,30 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/_restart_app_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class SettingLocalCloud extends StatelessWidget { + const SettingLocalCloud({super.key, required this.restartAppFlowy}); + + final VoidCallback restartAppFlowy; + + @override + Widget build(BuildContext context) { + return RestartButton( + onClick: () => onPressed(context), + showRestartHint: true, + ); + } + + void onPressed(BuildContext context) { + NavigatorAlertDialog( + title: LocaleKeys.settings_menu_restartAppTip.tr(), + confirm: () async { + await useLocalServer(); + restartAppFlowy(); + }, + ).show(context); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart new file mode 100644 index 0000000000000..cf51d7a3e9616 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart @@ -0,0 +1,77 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/sign_in_bloc.dart'; +import 'package:appflowy/user/presentation/router.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SettingThirdPartyLogin extends StatelessWidget { + const SettingThirdPartyLogin({ + super.key, + required this.didLogin, + }); + + final VoidCallback didLogin; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => getIt(), + child: BlocConsumer( + listener: (context, state) { + final successOrFail = state.successOrFail; + if (successOrFail != null) { + _handleSuccessOrFail(successOrFail, context); + } + }, + builder: (_, state) { + final indicator = state.isSubmitting + ? const LinearProgressIndicator(minHeight: 1) + : const SizedBox.shrink(); + + final promptMessage = state.isSubmitting + ? FlowyText.medium( + LocaleKeys.signIn_syncPromptMessage.tr(), + maxLines: null, + ) + : const SizedBox.shrink(); + + return Column( + children: [ + promptMessage, + const VSpace(6), + indicator, + const VSpace(6), + if (isAuthEnabled) const ThirdPartySignInButtons(), + ], + ); + }, + ), + ); + } + + Future _handleSuccessOrFail( + FlowyResult result, + BuildContext context, + ) async { + result.fold( + (user) async { + if (user.encryptionType == EncryptionTypePB.Symmetric) { + getIt().pushEncryptionScreen(context, user); + } else { + didLogin(); + await runAppFlowy(); + } + }, + (error) => showSnapBar(context, error.msg), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart new file mode 100644 index 0000000000000..c9069b8be34db --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -0,0 +1,208 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/feature_flags.dart'; +import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class SettingsMenu extends StatelessWidget { + const SettingsMenu({ + super.key, + required this.changeSelectedPage, + required this.currentPage, + required this.userProfile, + required this.isBillingEnabled, + }); + + final Function changeSelectedPage; + final SettingsPage currentPage; + final UserProfilePB userProfile; + final bool isBillingEnabled; + + @override + Widget build(BuildContext context) { + // Column > Expanded for full size no matter the content + return Column( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8) + + const EdgeInsets.only(left: 8, right: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadiusDirectional.only( + topStart: Radius.circular(8), + bottomStart: Radius.circular(8), + ), + ), + child: SingleChildScrollView( + // Right padding is added to make the scrollbar centered + // in the space between the menu and the content + padding: const EdgeInsets.only(right: 4) + + const EdgeInsets.symmetric(vertical: 16), + physics: const ClampingScrollPhysics(), + child: SeparatedColumn( + separatorBuilder: () => const VSpace(16), + children: [ + SettingsMenuElement( + page: SettingsPage.account, + selectedPage: currentPage, + label: LocaleKeys.settings_accountPage_menuLabel.tr(), + icon: const FlowySvg(FlowySvgs.settings_account_m), + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.workspace, + selectedPage: currentPage, + label: LocaleKeys.settings_workspacePage_menuLabel.tr(), + icon: const FlowySvg(FlowySvgs.settings_workplace_m), + changeSelectedPage: changeSelectedPage, + ), + if (FeatureFlag.membersSettings.isOn && + userProfile.authenticator == + AuthenticatorPB.AppFlowyCloud) + SettingsMenuElement( + page: SettingsPage.member, + selectedPage: currentPage, + label: LocaleKeys.settings_appearance_members_label.tr(), + icon: const Icon(Icons.people), + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.manageData, + selectedPage: currentPage, + label: LocaleKeys.settings_manageDataPage_menuLabel.tr(), + icon: const FlowySvg(FlowySvgs.settings_data_m), + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.notifications, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_notifications.tr(), + icon: const Icon(Icons.notifications_outlined), + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.cloud, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_cloudSettings.tr(), + icon: const Icon(Icons.sync), + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.shortcuts, + selectedPage: currentPage, + label: LocaleKeys.settings_shortcutsPage_menuLabel.tr(), + icon: const FlowySvg(FlowySvgs.settings_shortcuts_m), + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.ai, + selectedPage: currentPage, + label: LocaleKeys.settings_aiPage_menuLabel.tr(), + icon: const FlowySvg( + FlowySvgs.ai_summary_generate_s, + size: Size.square(24), + ), + changeSelectedPage: changeSelectedPage, + ), + if (userProfile.authenticator == + AuthenticatorPB.AppFlowyCloud) + SettingsMenuElement( + page: SettingsPage.sites, + selectedPage: currentPage, + label: LocaleKeys.settings_sites_title.tr(), + icon: const Icon(Icons.web), + changeSelectedPage: changeSelectedPage, + ), + if (FeatureFlag.planBilling.isOn && isBillingEnabled) ...[ + SettingsMenuElement( + page: SettingsPage.plan, + selectedPage: currentPage, + label: LocaleKeys.settings_planPage_menuLabel.tr(), + icon: const FlowySvg(FlowySvgs.settings_plan_m), + changeSelectedPage: changeSelectedPage, + ), + SettingsMenuElement( + page: SettingsPage.billing, + selectedPage: currentPage, + label: LocaleKeys.settings_billingPage_menuLabel.tr(), + icon: const FlowySvg(FlowySvgs.settings_billing_m), + changeSelectedPage: changeSelectedPage, + ), + ], + if (kDebugMode) + SettingsMenuElement( + // no need to translate this page + page: SettingsPage.featureFlags, + selectedPage: currentPage, + label: 'Feature Flags', + icon: const Icon(Icons.flag), + changeSelectedPage: changeSelectedPage, + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +class SimpleSettingsMenu extends StatelessWidget { + const SimpleSettingsMenu({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8) + + const EdgeInsets.only(left: 8, right: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), + ), + ), + child: SingleChildScrollView( + // Right padding is added to make the scrollbar centered + // in the space between the menu and the content + padding: const EdgeInsets.only(right: 4) + + const EdgeInsets.symmetric(vertical: 16), + physics: const ClampingScrollPhysics(), + child: SeparatedColumn( + separatorBuilder: () => const VSpace(16), + children: [ + SettingsMenuElement( + page: SettingsPage.cloud, + selectedPage: SettingsPage.cloud, + label: LocaleKeys.settings_menu_cloudSettings.tr(), + icon: const Icon(Icons.sync), + changeSelectedPage: () {}, + ), + if (kDebugMode) + SettingsMenuElement( + // no need to translate this page + page: SettingsPage.featureFlags, + selectedPage: SettingsPage.cloud, + label: 'Feature Flags', + icon: const Icon(Icons.flag), + changeSelectedPage: () {}, + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu_element.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu_element.dart new file mode 100644 index 0000000000000..b1bef7cceb610 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu_element.dart @@ -0,0 +1,64 @@ +import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class SettingsMenuElement extends StatelessWidget { + const SettingsMenuElement({ + super.key, + required this.page, + required this.label, + required this.icon, + required this.changeSelectedPage, + required this.selectedPage, + }); + + final SettingsPage page; + final SettingsPage selectedPage; + final String label; + final Widget icon; + final Function changeSelectedPage; + + @override + Widget build(BuildContext context) { + return FlowyHover( + isSelected: () => page == selectedPage, + resetHoverOnRebuild: false, + style: HoverStyle( + hoverColor: AFThemeExtension.of(context).greyHover, + borderRadius: BorderRadius.circular(4), + ), + builder: (_, isHovering) => ListTile( + dense: true, + leading: iconWidget( + isHovering || page == selectedPage + ? Theme.of(context).colorScheme.onSurface + : AFThemeExtension.of(context).textColor, + ), + onTap: () => changeSelectedPage(page), + selected: page == selectedPage, + selectedColor: Theme.of(context).colorScheme.onSurface, + selectedTileColor: Theme.of(context).colorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + minLeadingWidth: 0, + title: FlowyText.medium( + label, + fontSize: FontSizes.s14, + overflow: TextOverflow.ellipsis, + color: page == selectedPage + ? Theme.of(context).colorScheme.onSurface + : null, + ), + ), + ); + } + + Widget iconWidget(Color color) => IconTheme( + data: IconThemeData(color: color), + child: icon, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart new file mode 100644 index 0000000000000..29ba2baf5c5e8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SettingsNotificationsView extends StatelessWidget { + const SettingsNotificationsView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SettingsBody( + title: LocaleKeys.settings_menu_notifications.tr(), + children: [ + SettingListTile( + label: LocaleKeys.settings_notifications_enableNotifications_label + .tr(), + hint: LocaleKeys.settings_notifications_enableNotifications_hint + .tr(), + trailing: [ + Toggle( + value: state.isNotificationsEnabled, + onChanged: (_) => context + .read() + .toggleNotificationsEnabled(), + ), + ], + ), + SettingListTile( + label: LocaleKeys + .settings_notifications_showNotificationsIcon_label + .tr(), + hint: LocaleKeys.settings_notifications_showNotificationsIcon_hint + .tr(), + trailing: [ + Toggle( + value: state.isShowNotificationsIconEnabled, + onChanged: (_) => context + .read() + .toogleShowNotificationIconEnabled(), + ), + ], + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_confirm_delete_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_confirm_delete_dialog.dart new file mode 100644 index 0000000000000..a48996af18c93 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_confirm_delete_dialog.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +import 'theme_upload_view.dart'; + +class ThemeConfirmDeleteDialog extends StatelessWidget { + const ThemeConfirmDeleteDialog({ + super.key, + required this.theme, + }); + + final AppTheme theme; + + void onConfirm(BuildContext context) => Navigator.of(context).pop(true); + void onCancel(BuildContext context) => Navigator.of(context).pop(false); + + @override + Widget build(BuildContext context) { + return FlowyDialog( + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 300, + height: 100, + ), + title: FlowyText.regular( + LocaleKeys.document_plugins_cover_alertDialogConfirmation.tr(), + textAlign: TextAlign.center, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + width: ThemeUploadWidget.buttonSize.width, + child: FlowyButton( + text: FlowyText.semibold( + LocaleKeys.button_ok.tr(), + fontSize: ThemeUploadWidget.buttonFontSize, + ), + onTap: () => onConfirm(context), + ), + ), + SizedBox( + width: ThemeUploadWidget.buttonSize.width, + child: FlowyButton( + text: FlowyText.semibold( + LocaleKeys.button_cancel.tr(), + fontSize: ThemeUploadWidget.buttonFontSize, + ), + onTap: () => onCancel(context), + ), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload.dart new file mode 100644 index 0000000000000..07a9b192733e1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload.dart @@ -0,0 +1,8 @@ +export 'theme_confirm_delete_dialog.dart'; +export 'theme_upload_button.dart'; +export 'theme_upload_learn_more_button.dart'; +export 'theme_upload_decoration.dart'; +export 'theme_upload_failure_widget.dart'; +export 'theme_upload_loading_widget.dart'; +export 'theme_upload_view.dart'; +export 'upload_new_theme_widget.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_button.dart new file mode 100644 index 0000000000000..73fb23b80660e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_button.dart @@ -0,0 +1,41 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart'; +import 'package:flowy_infra/plugins/bloc/dynamic_plugin_event.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'theme_upload_view.dart'; + +class ThemeUploadButton extends StatelessWidget { + const ThemeUploadButton({super.key, this.color}); + + final Color? color; + + @override + Widget build(BuildContext context) { + return SizedBox.fromSize( + size: ThemeUploadWidget.buttonSize, + child: FlowyButton( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: color ?? Theme.of(context).colorScheme.primary, + ), + hoverColor: color, + text: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlowyText.medium( + fontSize: ThemeUploadWidget.buttonFontSize, + color: Theme.of(context).colorScheme.onPrimary, + LocaleKeys.settings_appearance_themeUpload_button.tr(), + ), + ], + ), + onTap: () => BlocProvider.of(context) + .add(DynamicPluginEvent.addPlugin()), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_decoration.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_decoration.dart new file mode 100644 index 0000000000000..f3cd25afde555 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_decoration.dart @@ -0,0 +1,40 @@ +import 'package:dotted_border/dotted_border.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +import 'theme_upload_view.dart'; + +class ThemeUploadDecoration extends StatelessWidget { + const ThemeUploadDecoration({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(ThemeUploadWidget.borderRadius), + color: Theme.of(context).colorScheme.surface, + border: Border.all( + color: AFThemeExtension.of(context).onBackground.withOpacity( + ThemeUploadWidget.fadeOpacity, + ), + ), + ), + padding: ThemeUploadWidget.padding, + child: DottedBorder( + borderType: BorderType.RRect, + dashPattern: const [6, 6], + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(ThemeUploadWidget.fadeOpacity), + radius: const Radius.circular(ThemeUploadWidget.borderRadius), + child: ClipRRect( + borderRadius: BorderRadius.circular(ThemeUploadWidget.borderRadius), + child: child, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_failure_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_failure_widget.dart new file mode 100644 index 0000000000000..edb382d6eefe2 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_failure_widget.dart @@ -0,0 +1,43 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class ThemeUploadFailureWidget extends StatelessWidget { + const ThemeUploadFailureWidget({super.key, required this.errorMessage}); + + final String errorMessage; + + @override + Widget build(BuildContext context) { + return Container( + color: Theme.of(context) + .colorScheme + .error + .withOpacity(ThemeUploadWidget.fadeOpacity), + constraints: const BoxConstraints.expand(), + padding: ThemeUploadWidget.padding, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(), + FlowySvg( + FlowySvgs.close_m, + size: ThemeUploadWidget.iconSize, + color: AFThemeExtension.of(context).onBackground, + ), + FlowyText.medium( + errorMessage, + overflow: TextOverflow.ellipsis, + ), + ThemeUploadWidget.elementSpacer, + const ThemeUploadLearnMoreButton(), + ThemeUploadWidget.elementSpacer, + ThemeUploadButton(color: Theme.of(context).colorScheme.error), + ThemeUploadWidget.elementSpacer, + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart new file mode 100644 index 0000000000000..9e25dece0ec84 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart @@ -0,0 +1,62 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; +import 'package:flowy_infra_ui/widget/error_page.dart'; +import 'package:flutter/material.dart'; + +class ThemeUploadLearnMoreButton extends StatelessWidget { + const ThemeUploadLearnMoreButton({super.key}); + + static const learnMoreURL = + 'https://docs.appflowy.io/docs/appflowy/product/themes'; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: ThemeUploadWidget.buttonSize.height, + child: IntrinsicWidth( + child: SecondaryButton( + outlineColor: AFThemeExtension.of(context).onBackground, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: FlowyText.medium( + fontSize: ThemeUploadWidget.buttonFontSize, + LocaleKeys.document_plugins_autoGeneratorLearnMore.tr(), + ), + ), + onPressed: () async { + final uri = Uri.parse(learnMoreURL); + await afLaunchUri( + uri, + context: context, + onFailure: (_) async { + if (context.mounted) { + await Dialogs.show( + context, + child: FlowyDialog( + child: FlowyErrorPage.message( + LocaleKeys + .settings_appearance_themeUpload_urlUploadFailure + .tr() + .replaceAll( + '{}', + uri.toString(), + ), + howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(), + ), + ), + ); + } + }, + ); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_loading_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_loading_widget.dart new file mode 100644 index 0000000000000..5e0ad15f385a9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_loading_widget.dart @@ -0,0 +1,33 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class ThemeUploadLoadingWidget extends StatelessWidget { + const ThemeUploadLoadingWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: ThemeUploadWidget.padding, + color: Theme.of(context) + .colorScheme + .surface + .withOpacity(ThemeUploadWidget.fadeOpacity), + constraints: const BoxConstraints.expand(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + ThemeUploadWidget.elementSpacer, + FlowyText.regular( + LocaleKeys.settings_appearance_themeUpload_loading.tr(), + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart new file mode 100644 index 0000000000000..1b22dba659140 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart'; +import 'package:flowy_infra/plugins/bloc/dynamic_plugin_state.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'theme_upload_decoration.dart'; +import 'theme_upload_failure_widget.dart'; +import 'theme_upload_loading_widget.dart'; +import 'upload_new_theme_widget.dart'; + +class ThemeUploadWidget extends StatefulWidget { + const ThemeUploadWidget({super.key}); + + static const double borderRadius = 8; + static const double buttonFontSize = 14; + static const Size buttonSize = Size(100, 32); + static const EdgeInsets padding = EdgeInsets.all(12.0); + static const Size iconSize = Size.square(48); + static const Widget elementSpacer = SizedBox(height: 12); + static const double fadeOpacity = 0.5; + static const Duration fadeDuration = Duration(milliseconds: 750); + + @override + State createState() => _ThemeUploadWidgetState(); +} + +class _ThemeUploadWidgetState extends State { + void listen(BuildContext context, DynamicPluginState state) { + setState(() { + state.whenOrNull( + ready: (plugins) { + child = + const UploadNewThemeWidget(key: Key('upload_new_theme_widget')); + }, + deletionSuccess: () { + child = + const UploadNewThemeWidget(key: Key('upload_new_theme_widget')); + }, + processing: () { + child = const ThemeUploadLoadingWidget( + key: Key('upload_theme_loading_widget'), + ); + }, + compilationFailure: (errorMessage) { + child = ThemeUploadFailureWidget( + key: const Key('upload_theme_failure_widget'), + errorMessage: errorMessage, + ); + }, + compilationSuccess: () { + if (Navigator.of(context).canPop()) { + Navigator.of(context) + .pop(const DynamicPluginState.compilationSuccess()); + } + }, + ); + }); + } + + Widget child = const UploadNewThemeWidget( + key: Key('upload_new_theme_widget'), + ); + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: listen, + child: ThemeUploadDecoration( + child: Center( + child: AnimatedSwitcher( + duration: ThemeUploadWidget.fadeDuration, + switchInCurve: Curves.easeInOutCubicEmphasized, + child: child, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/upload_new_theme_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/upload_new_theme_widget.dart new file mode 100644 index 0000000000000..0113d26a37f5c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/upload_new_theme_widget.dart @@ -0,0 +1,44 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class UploadNewThemeWidget extends StatelessWidget { + const UploadNewThemeWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + color: Theme.of(context) + .colorScheme + .surface + .withOpacity(ThemeUploadWidget.fadeOpacity), + padding: ThemeUploadWidget.padding, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(), + FlowySvg( + FlowySvgs.folder_m, + size: ThemeUploadWidget.iconSize, + color: AFThemeExtension.of(context).onBackground, + ), + FlowyText.medium( + LocaleKeys.settings_appearance_themeUpload_description.tr(), + overflow: TextOverflow.ellipsis, + ), + ThemeUploadWidget.elementSpacer, + const ThemeUploadLearnMoreButton(), + ThemeUploadWidget.elementSpacer, + const Divider(), + ThemeUploadWidget.elementSpacer, + const ThemeUploadButton(), + ThemeUploadWidget.elementSpacer, + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/utils/form_factor.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/utils/form_factor.dart new file mode 100644 index 0000000000000..715b88f27992d --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/utils/form_factor.dart @@ -0,0 +1,19 @@ +enum FormFactor { + mobile._(600), + tablet._(840), + desktop._(1280); + + const FormFactor._(this.width); + + factory FormFactor.fromWidth(double width) { + if (width < FormFactor.mobile.width) { + return FormFactor.mobile; + } else if (width < FormFactor.tablet.width) { + return FormFactor.tablet; + } else { + return FormFactor.desktop; + } + } + + final double width; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/utils/hex_opacity_string_extension.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/utils/hex_opacity_string_extension.dart new file mode 100644 index 0000000000000..537de2768cfb5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/utils/hex_opacity_string_extension.dart @@ -0,0 +1,19 @@ +extension HexOpacityExtension on String { + /// Only used in a valid color String like '0xff00bcf0' + String extractHex() { + return substring(4); + } + + /// Only used in a valid color String like '0xff00bcf0' + String extractOpacity() { + final opacityString = substring(2, 4); + final opacityInt = int.parse(opacityString, radix: 16) / 2.55; + return opacityInt.toStringAsFixed(0); + } + + /// Apply on the hex string like '00bcf0', with opacity like '100' + String combineHexWithOpacity(String opacity) { + final opacityInt = (int.parse(opacity) * 2.55).round().toRadixString(16); + return '0x$opacityInt$this'; + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart new file mode 100644 index 0000000000000..8bfc187422940 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart @@ -0,0 +1,335 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter/widgets.dart'; + +import 'widgets/reminder_selector.dart'; + +typedef DaySelectedCallback = void Function(DateTime); +typedef RangeSelectedCallback = void Function(DateTime, DateTime); +typedef IsRangeChangedCallback = void Function(bool, DateTime?, DateTime?); +typedef IncludeTimeChangedCallback = void Function(bool, DateTime?, DateTime?); + +abstract class AppFlowyDatePicker extends StatefulWidget { + const AppFlowyDatePicker({ + super.key, + required this.dateTime, + this.endDateTime, + required this.includeTime, + required this.isRange, + this.reminderOption = ReminderOption.none, + required this.dateFormat, + required this.timeFormat, + this.onDaySelected, + this.onRangeSelected, + this.onIncludeTimeChanged, + this.onIsRangeChanged, + this.onReminderSelected, + }); + + final DateTime? dateTime; + final DateTime? endDateTime; + + final DateFormatPB dateFormat; + final TimeFormatPB timeFormat; + + /// Called when the date is picked, whether by submitting a date from the top + /// or by selecting a date in the calendar. Will not be called if isRange is + /// true + final DaySelectedCallback? onDaySelected; + + /// Called when a date range is picked. Will not be called if isRange is false + final RangeSelectedCallback? onRangeSelected; + + /// Whether the date picker allows inputting a time in addition to the date + final bool includeTime; + + /// Called when the include time value is changed. This callback has the side + /// effect of changing the dateTime values as well + final IncludeTimeChangedCallback? onIncludeTimeChanged; + + // Whether the date picker supports date ranges + final bool isRange; + + /// Called when the is range value is changed. This callback has the side + /// effect of changing the dateTime values as well + final IsRangeChangedCallback? onIsRangeChanged; + + final ReminderOption reminderOption; + final OnReminderSelected? onReminderSelected; +} + +abstract class AppFlowyDatePickerState + extends State { + // store date values in the state and refresh the ui upon any changes made, instead of only updating them after receiving update from backend. + late DateTime? dateTime; + late DateTime? startDateTime; + late DateTime? endDateTime; + late bool includeTime; + late bool isRange; + late ReminderOption reminderOption; + + late DateTime focusedDateTime; + PageController? pageController; + + bool justChangedIsRange = false; + + @override + void initState() { + super.initState(); + + dateTime = widget.dateTime; + startDateTime = widget.isRange ? widget.dateTime : null; + endDateTime = widget.isRange ? widget.endDateTime : null; + includeTime = widget.includeTime; + isRange = widget.isRange; + reminderOption = widget.reminderOption; + + focusedDateTime = widget.dateTime ?? DateTime.now(); + } + + @override + void didUpdateWidget(covariant oldWidget) { + dateTime = widget.dateTime; + if (widget.isRange) { + startDateTime = widget.dateTime; + endDateTime = widget.endDateTime; + } else { + startDateTime = endDateTime = null; + } + includeTime = widget.includeTime; + isRange = widget.isRange; + if (oldWidget.reminderOption != widget.reminderOption) { + reminderOption = widget.reminderOption; + } + super.didUpdateWidget(oldWidget); + } + + void onDateSelectedFromDatePicker( + DateTime? newStartDateTime, + DateTime? newEndDateTime, + ) { + if (newStartDateTime == null) { + return; + } + if (isRange) { + if (newEndDateTime == null) { + if (justChangedIsRange && dateTime != null) { + justChangedIsRange = false; + DateTime start = dateTime!; + DateTime end = combineDateTimes( + DateTime( + newStartDateTime.year, + newStartDateTime.month, + newStartDateTime.day, + ), + start, + ); + if (end.isBefore(start)) { + (start, end) = (end, start); + } + widget.onRangeSelected?.call(start, end); + setState(() { + // hAcK: Resetting these state variables to null to reset the click counter of the table calendar widget, which doesn't expose a controller for us to do so otherwise. The parent widget needs to provide the data again so that it can be shown. + dateTime = startDateTime = endDateTime = null; + focusedDateTime = getNewFocusedDay(newStartDateTime); + }); + } else { + final combined = combineDateTimes(newStartDateTime, dateTime); + setState(() { + dateTime = combined; + startDateTime = combined; + endDateTime = null; + focusedDateTime = getNewFocusedDay(combined); + }); + } + } else { + bool switched = false; + DateTime combinedDateTime = + combineDateTimes(newStartDateTime, dateTime); + DateTime combinedEndDateTime = + combineDateTimes(newEndDateTime, widget.endDateTime); + + if (combinedEndDateTime.isBefore(combinedDateTime)) { + (combinedDateTime, combinedEndDateTime) = + (combinedEndDateTime, combinedDateTime); + switched = true; + } + + widget.onRangeSelected?.call(combinedDateTime, combinedEndDateTime); + + setState(() { + dateTime = switched ? combinedDateTime : combinedEndDateTime; + startDateTime = combinedDateTime; + endDateTime = combinedEndDateTime; + focusedDateTime = getNewFocusedDay(newEndDateTime); + }); + } + } else { + final combinedDateTime = combineDateTimes(newStartDateTime, dateTime); + widget.onDaySelected?.call(combinedDateTime); + + setState(() { + dateTime = combinedDateTime; + focusedDateTime = getNewFocusedDay(combinedDateTime); + }); + } + } + + DateTime combineDateTimes(DateTime date, DateTime? time) { + final timeComponent = time == null + ? Duration.zero + : Duration(hours: time.hour, minutes: time.minute); + + return DateTime(date.year, date.month, date.day).add(timeComponent); + } + + void onDateTimeInputSubmitted(DateTime value) { + if (isRange) { + DateTime end = endDateTime ?? value; + if (end.isBefore(value)) { + (value, end) = (end, value); + } + + widget.onRangeSelected?.call(value, end); + + setState(() { + dateTime = value; + startDateTime = value; + endDateTime = end; + focusedDateTime = getNewFocusedDay(value); + }); + } else { + widget.onDaySelected?.call(value); + + setState(() { + dateTime = value; + focusedDateTime = getNewFocusedDay(value); + }); + } + } + + void onEndDateTimeInputSubmitted(DateTime value) { + if (isRange) { + if (endDateTime == null) { + value = combineDateTimes(value, widget.endDateTime); + } + DateTime start = startDateTime ?? value; + if (value.isBefore(start)) { + (start, value) = (value, start); + } + + widget.onRangeSelected?.call(start, value); + + if (endDateTime == null) { + // hAcK: Resetting these state variables to null to reset the click counter of the table calendar widget, which doesn't expose a controller for us to do so otherwise. The parent widget needs to provide the data again so that it can be shown. + setState(() { + dateTime = startDateTime = endDateTime = null; + focusedDateTime = getNewFocusedDay(value); + }); + } else { + setState(() { + dateTime = start; + startDateTime = start; + endDateTime = value; + focusedDateTime = getNewFocusedDay(value); + }); + } + } else { + widget.onDaySelected?.call(value); + + setState(() { + dateTime = value; + focusedDateTime = getNewFocusedDay(value); + }); + } + } + + DateTime getNewFocusedDay(DateTime dateTime) { + if (focusedDateTime.year != dateTime.year || + focusedDateTime.month != dateTime.month) { + return DateTime(dateTime.year, dateTime.month); + } else { + return focusedDateTime; + } + } + + void onIsRangeChanged(bool value) { + if (value) { + justChangedIsRange = true; + } + + final now = DateTime.now(); + final fillerDate = includeTime + ? DateTime(now.year, now.month, now.day, now.hour, now.minute) + : DateTime(now.year, now.month, now.day); + final newDateTime = dateTime ?? fillerDate; + + if (value) { + widget.onIsRangeChanged!.call(value, newDateTime, newDateTime); + } else { + widget.onIsRangeChanged!.call(value, null, null); + } + + setState(() { + isRange = value; + dateTime = focusedDateTime = newDateTime; + if (value) { + startDateTime = endDateTime = newDateTime; + } else { + startDateTime = endDateTime = null; + } + }); + } + + void onIncludeTimeChanged(bool value) { + late final DateTime? newDateTime; + late final DateTime? newEndDateTime; + + final now = DateTime.now(); + final fillerDate = value + ? DateTime(now.year, now.month, now.day, now.hour, now.minute) + : DateTime(now.year, now.month, now.day); + + if (value) { + // fill date if empty, add time component + newDateTime = dateTime == null + ? fillerDate + : combineDateTimes(dateTime!, fillerDate); + newEndDateTime = isRange + ? endDateTime == null + ? fillerDate + : combineDateTimes(endDateTime!, fillerDate) + : null; + } else { + // fill date if empty, remove time component + newDateTime = dateTime == null + ? fillerDate + : DateTime( + dateTime!.year, + dateTime!.month, + dateTime!.day, + ); + newEndDateTime = isRange + ? endDateTime == null + ? fillerDate + : DateTime( + endDateTime!.year, + endDateTime!.month, + endDateTime!.day, + ) + : null; + } + + widget.onIncludeTimeChanged!.call(value, newDateTime, newEndDateTime); + + setState(() { + includeTime = value; + dateTime = newDateTime ?? dateTime; + if (isRange) { + startDateTime = newDateTime ?? dateTime; + endDateTime = newEndDateTime ?? endDateTime; + } else { + startDateTime = endDateTime = null; + } + }); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/desktop_date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/desktop_date_picker.dart new file mode 100644 index 0000000000000..c404f576b1dc9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/desktop_date_picker.dart @@ -0,0 +1,306 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart'; +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'appflowy_date_picker_base.dart'; +import 'widgets/date_picker.dart'; +import 'widgets/date_time_text_field.dart'; +import 'widgets/end_time_button.dart'; +import 'widgets/reminder_selector.dart'; + +class OptionGroup { + OptionGroup({required this.options}); + + final List options; +} + +class DesktopAppFlowyDatePicker extends AppFlowyDatePicker { + const DesktopAppFlowyDatePicker({ + super.key, + required super.dateTime, + super.endDateTime, + required super.includeTime, + required super.isRange, + super.reminderOption = ReminderOption.none, + required super.dateFormat, + required super.timeFormat, + super.onDaySelected, + super.onRangeSelected, + super.onIncludeTimeChanged, + super.onIsRangeChanged, + super.onReminderSelected, + this.popoverMutex, + this.options = const [], + }); + + final PopoverMutex? popoverMutex; + + final List options; + + @override + State createState() => DesktopAppFlowyDatePickerState(); +} + +@visibleForTesting +class DesktopAppFlowyDatePickerState + extends AppFlowyDatePickerState { + final isTabPressedNotifier = ValueNotifier(false); + final refreshStartTextFieldNotifier = RefreshDateTimeTextFieldController(); + final refreshEndTextFieldNotifier = RefreshDateTimeTextFieldController(); + + @override + void dispose() { + isTabPressedNotifier.dispose(); + refreshStartTextFieldNotifier.dispose(); + refreshEndTextFieldNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // GestureDetector is a workaround to stop popover from closing + // when clicking on the date picker. + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: Padding( + padding: const EdgeInsets.only(top: 18.0, bottom: 12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DateTimeTextField( + key: const ValueKey('date_time_text_field'), + includeTime: includeTime, + dateTime: isRange ? startDateTime : dateTime, + dateFormat: widget.dateFormat, + timeFormat: widget.timeFormat, + popoverMutex: widget.popoverMutex, + isTabPressed: isTabPressedNotifier, + refreshTextController: refreshStartTextFieldNotifier, + onSubmitted: onDateTimeInputSubmitted, + showHint: true, + ), + if (isRange) ...[ + const VSpace(8), + DateTimeTextField( + key: const ValueKey('end_date_time_text_field'), + includeTime: includeTime, + dateTime: endDateTime, + dateFormat: widget.dateFormat, + timeFormat: widget.timeFormat, + popoverMutex: widget.popoverMutex, + isTabPressed: isTabPressedNotifier, + refreshTextController: refreshEndTextFieldNotifier, + onSubmitted: onEndDateTimeInputSubmitted, + showHint: isRange && !(dateTime != null && endDateTime == null), + ), + ], + const VSpace(14), + Focus( + descendantsAreTraversable: false, + child: _buildDatePickerHeader(), + ), + const VSpace(14), + DatePicker( + isRange: isRange, + onDaySelected: (selectedDay, focusedDay) { + onDateSelectedFromDatePicker(selectedDay, null); + }, + onRangeSelected: (start, end, focusedDay) { + onDateSelectedFromDatePicker(start, end); + }, + selectedDay: dateTime, + startDay: isRange ? startDateTime : null, + endDay: isRange ? endDateTime : null, + focusedDay: focusedDateTime, + onCalendarCreated: (controller) { + pageController = controller; + }, + onPageChanged: (focusedDay) { + setState( + () => focusedDateTime = DateTime( + focusedDay.year, + focusedDay.month, + focusedDay.day, + ), + ); + }, + ), + if (widget.onIsRangeChanged != null || + widget.onIncludeTimeChanged != null) + const TypeOptionSeparator(spacing: 12.0), + if (widget.onIsRangeChanged != null) ...[ + EndTimeButton( + isRange: isRange, + onChanged: onIsRangeChanged, + ), + const VSpace(4.0), + ], + if (widget.onIncludeTimeChanged != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: IncludeTimeButton( + includeTime: includeTime, + onChanged: onIncludeTimeChanged, + ), + ), + if (widget.onReminderSelected != null) ...[ + const _GroupSeparator(), + ReminderSelector( + mutex: widget.popoverMutex, + hasTime: widget.includeTime, + timeFormat: widget.timeFormat, + selectedOption: reminderOption, + onOptionSelected: (option) { + widget.onReminderSelected?.call(option); + setState(() => reminderOption = option); + }, + ), + ], + if (widget.options.isNotEmpty) ...[ + const _GroupSeparator(), + ListView.separated( + shrinkWrap: true, + itemCount: widget.options.length, + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: (_, __) => const _GroupSeparator(), + itemBuilder: (_, index) => + _renderGroupOptions(widget.options[index].options), + ), + ], + ], + ), + ), + ); + } + + Widget _buildDatePickerHeader() { + return Padding( + padding: const EdgeInsetsDirectional.only(start: 22.0, end: 18.0), + child: Row( + children: [ + Expanded( + child: FlowyText( + DateFormat.yMMMM().format(focusedDateTime), + ), + ), + FlowyIconButton( + width: 20, + icon: FlowySvg( + FlowySvgs.arrow_left_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(20.0), + ), + onPressed: () => pageController?.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ), + ), + const HSpace(4.0), + FlowyIconButton( + width: 20, + icon: FlowySvg( + FlowySvgs.arrow_right_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(20.0), + ), + onPressed: () { + pageController?.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }, + ), + ], + ), + ); + } + + Widget _renderGroupOptions(List options) => ListView.separated( + shrinkWrap: true, + itemCount: options.length, + separatorBuilder: (_, __) => const VSpace(4), + itemBuilder: (_, index) => options[index], + ); + + @override + void onDateTimeInputSubmitted(DateTime value) { + if (isRange) { + DateTime end = endDateTime ?? value; + if (end.isBefore(value)) { + (value, end) = (end, value); + refreshStartTextFieldNotifier.refresh(); + } + + widget.onRangeSelected?.call(value, end); + + setState(() { + dateTime = value; + startDateTime = value; + endDateTime = end; + }); + } else { + widget.onDaySelected?.call(value); + + setState(() { + dateTime = value; + focusedDateTime = getNewFocusedDay(value); + }); + } + } + + @override + void onEndDateTimeInputSubmitted(DateTime value) { + if (isRange) { + if (endDateTime == null) { + value = combineDateTimes(value, widget.endDateTime); + } + DateTime start = startDateTime ?? value; + if (value.isBefore(start)) { + (start, value) = (value, start); + refreshEndTextFieldNotifier.refresh(); + } + + widget.onRangeSelected?.call(start, value); + + if (endDateTime == null) { + // hAcK: Resetting these state variables to null to reset the click counter of the table calendar widget, which doesn't expose a controller for us to do so otherwise. The parent widget needs to provide the data again so that it can be shown. + setState(() { + dateTime = startDateTime = endDateTime = null; + focusedDateTime = getNewFocusedDay(value); + }); + } else { + setState(() { + dateTime = start; + startDateTime = start; + endDateTime = value; + focusedDateTime = getNewFocusedDay(value); + }); + } + } else { + widget.onDaySelected?.call(value); + + setState(() { + dateTime = value; + focusedDateTime = getNewFocusedDay(value); + }); + } + } +} + +class _GroupSeparator extends StatelessWidget { + const _GroupSeparator(); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Container(color: Theme.of(context).dividerColor, height: 1.0), + ); +} + +class RefreshDateTimeTextFieldController extends ChangeNotifier { + void refresh() => notifyListeners(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_date_picker.dart new file mode 100644 index 0000000000000..e9f3262cc3410 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/mobile_date_picker.dart @@ -0,0 +1,558 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_option_decorate_box.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/mobile_date_editor.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'appflowy_date_picker_base.dart'; + +class MobileAppFlowyDatePicker extends AppFlowyDatePicker { + const MobileAppFlowyDatePicker({ + super.key, + required super.dateTime, + super.endDateTime, + required super.includeTime, + required super.isRange, + super.reminderOption = ReminderOption.none, + required super.dateFormat, + required super.timeFormat, + super.onDaySelected, + super.onRangeSelected, + super.onIncludeTimeChanged, + super.onIsRangeChanged, + super.onReminderSelected, + this.onClearDate, + }); + + final VoidCallback? onClearDate; + + @override + State createState() => + _MobileAppFlowyDatePickerState(); +} + +class _MobileAppFlowyDatePickerState + extends AppFlowyDatePickerState { + @override + Widget build(BuildContext context) { + return Column( + children: [ + FlowyOptionDecorateBox( + showTopBorder: false, + child: _TimePicker( + dateTime: isRange ? startDateTime : dateTime, + endDateTime: endDateTime, + includeTime: includeTime, + isRange: isRange, + dateFormat: widget.dateFormat, + timeFormat: widget.timeFormat, + onStartTimeChanged: onDateTimeInputSubmitted, + onEndTimeChanged: onEndDateTimeInputSubmitted, + ), + ), + const _Divider(), + FlowyOptionDecorateBox( + child: MobileDatePicker( + isRange: isRange, + selectedDay: dateTime, + startDay: isRange ? startDateTime : null, + endDay: isRange ? endDateTime : null, + focusedDay: focusedDateTime, + onDaySelected: (selectedDay) { + onDateSelectedFromDatePicker(selectedDay, null); + }, + onRangeSelected: (start, end) { + onDateSelectedFromDatePicker(start, end); + }, + onPageChanged: (focusedDay) { + setState(() => focusedDateTime = focusedDay); + }, + ), + ), + const _Divider(), + if (widget.onIsRangeChanged != null) + _IsRangeSwitch( + isRange: widget.isRange, + onRangeChanged: onIsRangeChanged, + ), + if (widget.onIncludeTimeChanged != null) + _IncludeTimeSwitch( + showTopBorder: widget.onIsRangeChanged == null, + includeTime: includeTime, + onIncludeTimeChanged: onIncludeTimeChanged, + ), + if (widget.onReminderSelected != null) ...[ + const _Divider(), + _ReminderSelector( + selectedReminderOption: reminderOption, + onReminderSelected: (option) { + widget.onReminderSelected!.call(option); + setState(() => reminderOption = option); + }, + timeFormat: widget.timeFormat, + hasTime: widget.includeTime, + ), + ], + if (widget.onClearDate != null) ...[ + const _Divider(), + _ClearDateButton( + onClearDate: () { + widget.onClearDate!.call(); + Navigator.of(context).pop(); + }, + ), + ], + const _Divider(), + ], + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) => const VSpace(20.0); +} + +class _ReminderSelector extends StatelessWidget { + const _ReminderSelector({ + this.selectedReminderOption, + required this.onReminderSelected, + required this.timeFormat, + this.hasTime = false, + }); + + final ReminderOption? selectedReminderOption; + final OnReminderSelected onReminderSelected; + final TimeFormatPB timeFormat; + final bool hasTime; + + @override + Widget build(BuildContext context) { + final option = selectedReminderOption ?? ReminderOption.none; + + final availableOptions = [...ReminderOption.values]; + if (option != ReminderOption.custom) { + availableOptions.remove(ReminderOption.custom); + } + + availableOptions.removeWhere( + (o) => !o.timeExempt && (!hasTime ? !o.withoutTime : o.requiresNoTime), + ); + + return FlowyOptionTile.text( + text: LocaleKeys.datePicker_reminderLabel.tr(), + trailing: Row( + children: [ + const HSpace(6.0), + FlowyText( + option.label, + color: Theme.of(context).hintColor, + ), + const HSpace(4.0), + FlowySvg( + FlowySvgs.arrow_right_s, + color: Theme.of(context).hintColor, + size: const Size.square(18.0), + ), + ], + ), + onTap: () => showMobileBottomSheet( + context, + builder: (_) => DraggableScrollableSheet( + expand: false, + snap: true, + initialChildSize: 0.7, + minChildSize: 0.7, + builder: (context, controller) => Column( + children: [ + ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: const Center(child: DragHandle()), + ), + const _ReminderSelectHeader(), + Flexible( + child: SingleChildScrollView( + controller: controller, + child: Column( + children: availableOptions.map( + (o) { + String label = o.label; + if (o.withoutTime && !o.timeExempt) { + const time = "09:00"; + final t = timeFormat == TimeFormatPB.TwelveHour + ? "$time AM" + : time; + + label = "$label ($t)"; + } + + return FlowyOptionTile.text( + text: label, + showTopBorder: o == ReminderOption.none, + onTap: () { + onReminderSelected(o); + context.pop(); + }, + ); + }, + ).toList() + ..insert(0, const _Divider()), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ReminderSelectHeader extends StatelessWidget { + const _ReminderSelectHeader(); + + @override + Widget build(BuildContext context) { + return Container( + height: 56, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + width: 120, + child: AppBarCancelButton(onTap: context.pop), + ), + FlowyText.medium( + LocaleKeys.datePicker_selectReminder.tr(), + fontSize: 17.0, + ), + const HSpace(120), + ], + ), + ); + } +} + +class _TimePicker extends StatelessWidget { + const _TimePicker({ + required this.dateTime, + required this.endDateTime, + required this.dateFormat, + required this.timeFormat, + required this.includeTime, + required this.isRange, + required this.onStartTimeChanged, + this.onEndTimeChanged, + }); + + final DateTime? dateTime; + final DateTime? endDateTime; + + final bool includeTime; + final bool isRange; + + final DateFormatPB dateFormat; + final TimeFormatPB timeFormat; + + final void Function(DateTime time) onStartTimeChanged; + final void Function(DateTime time)? onEndTimeChanged; + + @override + Widget build(BuildContext context) { + final dateStr = getDateStr(dateTime); + final timeStr = getTimeStr(dateTime); + final endDateStr = getDateStr(endDateTime); + final endTimeStr = getTimeStr(endDateTime); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTime( + context, + dateStr, + timeStr, + includeTime, + true, + ), + if (isRange) ...[ + VSpace(8.0, color: Theme.of(context).colorScheme.surface), + _buildTime( + context, + endDateStr, + endTimeStr, + includeTime, + false, + ), + ], + ], + ), + ); + } + + Widget _buildTime( + BuildContext context, + String dateStr, + String timeStr, + bool includeTime, + bool isStartDay, + ) { + final List children = []; + + final now = DateTime.now(); + final hintDate = DateTime(now.year, now.month, 1, 9); + + if (!includeTime) { + children.add( + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () async { + final result = await _showDateTimePicker( + context, + isStartDay ? dateTime : endDateTime, + use24hFormat: timeFormat == TimeFormatPB.TwentyFourHour, + mode: CupertinoDatePickerMode.date, + ); + handleDateTimePickerResult(result, isStartDay, true); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8, + ), + child: FlowyText( + dateStr.isNotEmpty ? dateStr : getDateStr(hintDate), + color: dateStr.isEmpty ? Theme.of(context).hintColor : null, + ), + ), + ), + ), + ); + } else { + children.addAll([ + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () async { + final result = await _showDateTimePicker( + context, + isStartDay ? dateTime : endDateTime, + use24hFormat: timeFormat == TimeFormatPB.TwentyFourHour, + mode: CupertinoDatePickerMode.date, + ); + handleDateTimePickerResult(result, isStartDay, true); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: FlowyText( + dateStr.isNotEmpty ? dateStr : "", + textAlign: TextAlign.center, + ), + ), + ), + ), + Container( + width: 1, + height: 16, + color: Theme.of(context).colorScheme.outline, + ), + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () async { + final result = await _showDateTimePicker( + context, + isStartDay ? dateTime : endDateTime, + use24hFormat: timeFormat == TimeFormatPB.TwentyFourHour, + mode: CupertinoDatePickerMode.time, + ); + handleDateTimePickerResult(result, isStartDay, false); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: FlowyText( + timeStr.isNotEmpty ? timeStr : "", + textAlign: TextAlign.center, + ), + ), + ), + ), + ]); + } + + return Container( + constraints: const BoxConstraints(minHeight: 36), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: Theme.of(context).colorScheme.secondaryContainer, + border: Border.all( + color: Theme.of(context).colorScheme.outline, + ), + ), + child: Row( + children: children, + ), + ); + } + + Future _showDateTimePicker( + BuildContext context, + DateTime? dateTime, { + required CupertinoDatePickerMode mode, + required bool use24hFormat, + }) async { + DateTime? result; + + return showMobileBottomSheet( + context, + builder: (context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: CupertinoDatePicker( + mode: mode, + initialDateTime: dateTime, + use24hFormat: use24hFormat, + onDateTimeChanged: (dateTime) { + result = dateTime; + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: FlowyTextButton( + LocaleKeys.button_confirm.tr(), + constraints: const BoxConstraints.tightFor(height: 42), + mainAxisAlignment: MainAxisAlignment.center, + fontColor: Theme.of(context).colorScheme.onPrimary, + fillColor: Theme.of(context).primaryColor, + onPressed: () { + Navigator.of(context).pop(result); + }, + ), + ), + const VSpace(18.0), + ], + ), + ); + } + + void handleDateTimePickerResult( + DateTime? result, + bool isStartDay, + bool isDate, + ) { + if (result == null) { + return; + } + + if (isDate) { + final date = isStartDay ? dateTime : endDateTime; + + if (date != null) { + final timeComponent = Duration(hours: date.hour, minutes: date.minute); + result = + DateTime(result.year, result.month, result.day).add(timeComponent); + } + } + + if (isStartDay) { + onStartTimeChanged.call(result); + } else { + onEndTimeChanged?.call(result); + } + } + + String getDateStr(DateTime? dateTime) { + if (dateTime == null) { + return ""; + } + return DateFormat(dateFormat.pattern).format(dateTime); + } + + String getTimeStr(DateTime? dateTime) { + if (dateTime == null || !includeTime) { + return ""; + } + return DateFormat(timeFormat.pattern).format(dateTime); + } +} + +class _IsRangeSwitch extends StatelessWidget { + const _IsRangeSwitch({ + required this.isRange, + required this.onRangeChanged, + }); + + final bool isRange; + final Function(bool) onRangeChanged; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.toggle( + text: LocaleKeys.grid_field_isRange.tr(), + isSelected: isRange, + onValueChanged: onRangeChanged, + ); + } +} + +class _IncludeTimeSwitch extends StatelessWidget { + const _IncludeTimeSwitch({ + this.showTopBorder = true, + required this.includeTime, + required this.onIncludeTimeChanged, + }); + + final bool showTopBorder; + final bool includeTime; + final Function(bool) onIncludeTimeChanged; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.toggle( + showTopBorder: showTopBorder, + text: LocaleKeys.grid_field_includeTime.tr(), + isSelected: includeTime, + onValueChanged: onIncludeTimeChanged, + ); + } +} + +class _ClearDateButton extends StatelessWidget { + const _ClearDateButton({required this.onClearDate}); + + final VoidCallback onClearDate; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.text( + text: LocaleKeys.grid_field_clearDate.tr(), + onTap: onClearDate, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/utils/date_time_format_ext.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/utils/date_time_format_ext.dart new file mode 100644 index 0000000000000..36657a43210c9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/utils/date_time_format_ext.dart @@ -0,0 +1,13 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; + +extension ToDateFormat on UserDateFormatPB { + DateFormatPB get simplified => switch (this) { + UserDateFormatPB.DayMonthYear => DateFormatPB.DayMonthYear, + UserDateFormatPB.Friendly => DateFormatPB.Friendly, + UserDateFormatPB.ISO => DateFormatPB.ISO, + UserDateFormatPB.Locally => DateFormatPB.Local, + UserDateFormatPB.US => DateFormatPB.US, + _ => DateFormatPB.Friendly, + }; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/utils/layout.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/utils/layout.dart new file mode 100644 index 0000000000000..7dde3368f285c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/utils/layout.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class DatePickerSize { + static double scale = 1; + + static double get itemHeight => 26 * scale; + static double get seperatorHeight => 4 * scale; + + static EdgeInsets get itemOptionInsets => const EdgeInsets.all(4); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/utils/user_time_format_ext.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/utils/user_time_format_ext.dart new file mode 100644 index 0000000000000..2040785371ebf --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/utils/user_time_format_ext.dart @@ -0,0 +1,10 @@ +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; + +extension ToTimeFormat on UserTimeFormatPB { + TimeFormatPB get simplified => switch (this) { + UserTimeFormatPB.TwelveHour => TimeFormatPB.TwelveHour, + UserTimeFormatPB.TwentyFourHour => TimeFormatPB.TwentyFourHour, + _ => TimeFormatPB.TwentyFourHour, + }; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart new file mode 100644 index 0000000000000..3eaa674df8dfc --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/layout.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; + +class ClearDateButton extends StatelessWidget { + const ClearDateButton({ + super.key, + required this.onClearDate, + }); + + final VoidCallback onClearDate; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: SizedBox( + height: DatePickerSize.itemHeight, + child: FlowyButton( + text: FlowyText(LocaleKeys.datePicker_clearDate.tr()), + onTap: () { + onClearDate(); + PopoverContainer.of(context).close(); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker.dart new file mode 100644 index 0000000000000..5cbdc2bc43216 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker.dart @@ -0,0 +1,191 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:table_calendar/table_calendar.dart'; +import 'package:universal_platform/universal_platform.dart'; + +final kFirstDay = DateTime.utc(1970); +final kLastDay = DateTime.utc(2100); + +class DatePicker extends StatefulWidget { + const DatePicker({ + super.key, + required this.isRange, + this.calendarFormat = CalendarFormat.month, + this.startDay, + this.endDay, + this.selectedDay, + required this.focusedDay, + this.onDaySelected, + this.onRangeSelected, + this.onCalendarCreated, + this.onPageChanged, + }); + + final bool isRange; + final CalendarFormat calendarFormat; + + final DateTime? startDay; + final DateTime? endDay; + final DateTime? selectedDay; + + final DateTime focusedDay; + + final void Function( + DateTime selectedDay, + DateTime focusedDay, + )? onDaySelected; + + final void Function( + DateTime? start, + DateTime? end, + DateTime focusedDay, + )? onRangeSelected; + + final void Function(PageController pageController)? onCalendarCreated; + + final void Function(DateTime focusedDay)? onPageChanged; + + @override + State createState() => _DatePickerState(); +} + +class _DatePickerState extends State { + late CalendarFormat _calendarFormat = widget.calendarFormat; + + @override + Widget build(BuildContext context) { + final textStyle = Theme.of(context).textTheme.bodyMedium!; + final boxDecoration = BoxDecoration( + color: Theme.of(context).cardColor, + shape: BoxShape.circle, + ); + + final calendarStyle = UniversalPlatform.isMobile + ? _CalendarStyle.mobile( + dowTextStyle: textStyle.copyWith( + color: Theme.of(context).hintColor, + fontSize: 14.0, + ), + ) + : _CalendarStyle.desktop( + dowTextStyle: AFThemeExtension.of(context).caption, + selectedColor: Theme.of(context).colorScheme.primary, + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: TableCalendar( + firstDay: kFirstDay, + lastDay: kLastDay, + focusedDay: widget.focusedDay, + rowHeight: calendarStyle.rowHeight, + calendarFormat: _calendarFormat, + daysOfWeekHeight: calendarStyle.dowHeight, + rangeSelectionMode: widget.isRange + ? RangeSelectionMode.enforced + : RangeSelectionMode.disabled, + rangeStartDay: widget.isRange ? widget.startDay : null, + rangeEndDay: widget.isRange ? widget.endDay : null, + availableGestures: calendarStyle.availableGestures, + availableCalendarFormats: const {CalendarFormat.month: 'Month'}, + onCalendarCreated: widget.onCalendarCreated, + headerVisible: calendarStyle.headerVisible, + headerStyle: calendarStyle.headerStyle, + calendarStyle: CalendarStyle( + cellMargin: const EdgeInsets.all(3.5), + defaultDecoration: boxDecoration, + selectedDecoration: boxDecoration.copyWith( + color: calendarStyle.selectedColor, + ), + todayDecoration: boxDecoration.copyWith( + color: Colors.transparent, + border: Border.all(color: calendarStyle.selectedColor), + ), + weekendDecoration: boxDecoration, + outsideDecoration: boxDecoration, + rangeStartDecoration: boxDecoration.copyWith( + color: calendarStyle.selectedColor, + ), + rangeEndDecoration: boxDecoration.copyWith( + color: calendarStyle.selectedColor, + ), + defaultTextStyle: textStyle, + weekendTextStyle: textStyle, + selectedTextStyle: textStyle.copyWith( + color: Theme.of(context).colorScheme.surface, + ), + rangeStartTextStyle: textStyle.copyWith( + color: Theme.of(context).colorScheme.surface, + ), + rangeEndTextStyle: textStyle.copyWith( + color: Theme.of(context).colorScheme.surface, + ), + todayTextStyle: textStyle, + outsideTextStyle: textStyle.copyWith( + color: Theme.of(context).disabledColor, + ), + rangeHighlightColor: Theme.of(context).colorScheme.secondaryContainer, + ), + calendarBuilders: CalendarBuilders( + dowBuilder: (context, day) { + final locale = context.locale.toLanguageTag(); + final label = DateFormat.E(locale).format(day); + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Center( + child: Text(label, style: calendarStyle.dowTextStyle), + ), + ); + }, + ), + selectedDayPredicate: (day) => + widget.isRange ? false : isSameDay(widget.selectedDay, day), + onFormatChanged: (calendarFormat) => + setState(() => _calendarFormat = calendarFormat), + onPageChanged: (focusedDay) { + widget.onPageChanged?.call(focusedDay); + }, + onDaySelected: widget.onDaySelected, + onRangeSelected: widget.onRangeSelected, + ), + ); + } +} + +class _CalendarStyle { + _CalendarStyle.desktop({ + required this.selectedColor, + required this.dowTextStyle, + }) : rowHeight = 33, + dowHeight = 35, + headerVisible = false, + headerStyle = const HeaderStyle(), + availableGestures = AvailableGestures.horizontalSwipe; + + _CalendarStyle.mobile({required this.dowTextStyle}) + : rowHeight = 48, + dowHeight = 48, + headerVisible = false, + headerStyle = const HeaderStyle(), + selectedColor = const Color(0xFF00BCF0), + availableGestures = AvailableGestures.horizontalSwipe; + + _CalendarStyle({ + required this.rowHeight, + required this.dowHeight, + required this.headerVisible, + required this.headerStyle, + required this.dowTextStyle, + required this.selectedColor, + required this.availableGestures, + }); + + final double rowHeight; + final double dowHeight; + final bool headerVisible; + final HeaderStyle headerStyle; + final TextStyle dowTextStyle; + final Color selectedColor; + final AvailableGestures availableGestures; +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart new file mode 100644 index 0000000000000..301fd038ee74c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_picker_dialog.dart @@ -0,0 +1,177 @@ +import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/date_time_format_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/user_time_format_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra_ui/style_widget/decoration.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Provides arguemnts for [AppFlowyDatePicker] when showing +/// a [DatePickerMenu] +/// +class DatePickerOptions { + DatePickerOptions({ + DateTime? focusedDay, + this.popoverMutex, + this.selectedDay, + this.includeTime = false, + this.isRange = false, + this.dateFormat = UserDateFormatPB.Friendly, + this.timeFormat = UserTimeFormatPB.TwentyFourHour, + this.selectedReminderOption, + this.onDaySelected, + this.onIncludeTimeChanged, + this.onRangeSelected, + this.onIsRangeChanged, + this.onReminderSelected, + }) : focusedDay = focusedDay ?? DateTime.now(); + + final DateTime focusedDay; + final PopoverMutex? popoverMutex; + final DateTime? selectedDay; + final bool includeTime; + final bool isRange; + final UserDateFormatPB dateFormat; + final UserTimeFormatPB timeFormat; + final ReminderOption? selectedReminderOption; + + final DaySelectedCallback? onDaySelected; + final RangeSelectedCallback? onRangeSelected; + final IncludeTimeChangedCallback? onIncludeTimeChanged; + final IsRangeChangedCallback? onIsRangeChanged; + final OnReminderSelected? onReminderSelected; +} + +abstract class DatePickerService { + void show(Offset offset, {required DatePickerOptions options}); + void dismiss(); +} + +const double _datePickerWidth = 260; +const double _datePickerHeight = 404; +const double _ySpacing = 15; + +class DatePickerMenu extends DatePickerService { + DatePickerMenu({required this.context, required this.editorState}); + + final BuildContext context; + final EditorState editorState; + + OverlayEntry? _menuEntry; + + @override + void dismiss() { + _menuEntry?.remove(); + _menuEntry = null; + } + + @override + void show(Offset offset, {required DatePickerOptions options}) => + _show(offset, options: options); + + void _show(Offset offset, {required DatePickerOptions options}) { + dismiss(); + + final editorSize = editorState.renderBox!.size; + + double offsetX = offset.dx; + double offsetY = offset.dy; + + final showRight = (offset.dx + _datePickerWidth) < editorSize.width; + if (!showRight) { + offsetX = offset.dx - _datePickerWidth; + } + + final showBelow = (offset.dy + _datePickerHeight) < editorSize.height; + if (!showBelow) { + if ((offset.dy - _datePickerHeight) < 0) { + // Show dialog in the middle + offsetY = offset.dy - (_datePickerHeight / 3); + } else { + // Show above + offsetY = offset.dy - _datePickerHeight; + } + } + + _menuEntry = OverlayEntry( + builder: (_) => Material( + type: MaterialType.transparency, + child: SizedBox( + height: editorSize.height, + width: editorSize.width, + child: KeyboardListener( + focusNode: FocusNode()..requestFocus(), + onKeyEvent: (event) { + if (event.logicalKey == LogicalKeyboardKey.escape) { + dismiss(); + } + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Stack( + children: [ + _AnimatedDatePicker( + offset: Offset(offsetX, offsetY), + showBelow: showBelow, + options: options, + ), + ], + ), + ), + ), + ), + ), + ); + + Overlay.of(context).insert(_menuEntry!); + } +} + +class _AnimatedDatePicker extends StatelessWidget { + const _AnimatedDatePicker({ + required this.offset, + required this.showBelow, + required this.options, + }); + + final Offset offset; + final bool showBelow; + final DatePickerOptions options; + + @override + Widget build(BuildContext context) { + final dy = offset.dy + (showBelow ? _ySpacing : -_ySpacing); + + return AnimatedPositioned( + duration: const Duration(milliseconds: 200), + top: dy, + left: offset.dx, + child: Container( + decoration: FlowyDecoration.decoration( + Theme.of(context).cardColor, + Theme.of(context).colorScheme.shadow, + ), + constraints: BoxConstraints.loose(const Size(_datePickerWidth, 465)), + child: DesktopAppFlowyDatePicker( + includeTime: options.includeTime, + onIncludeTimeChanged: options.onIncludeTimeChanged, + isRange: options.isRange, + onIsRangeChanged: options.onIsRangeChanged, + dateFormat: options.dateFormat.simplified, + timeFormat: options.timeFormat.simplified, + dateTime: options.selectedDay, + popoverMutex: options.popoverMutex, + reminderOption: options.selectedReminderOption ?? ReminderOption.none, + onDaySelected: options.onDaySelected, + onRangeSelected: options.onRangeSelected, + onReminderSelected: options.onReminderSelected, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_time_settings.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_time_settings.dart new file mode 100644 index 0000000000000..7447700fefb6f --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_time_settings.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/layout.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class DateTimeSetting extends StatefulWidget { + const DateTimeSetting({ + super.key, + required this.dateFormat, + required this.timeFormat, + required this.onDateFormatChanged, + required this.onTimeFormatChanged, + }); + + final DateFormatPB dateFormat; + final TimeFormatPB timeFormat; + final Function(DateFormatPB) onDateFormatChanged; + final Function(TimeFormatPB) onTimeFormatChanged; + + @override + State createState() => _DateTimeSettingState(); +} + +class _DateTimeSettingState extends State { + final timeSettingPopoverMutex = PopoverMutex(); + String? overlayIdentifier; + + @override + void dispose() { + timeSettingPopoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final List children = [ + AppFlowyPopover( + mutex: timeSettingPopoverMutex, + triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + offset: const Offset(8, 0), + popupBuilder: (_) => DateFormatList( + selectedFormat: widget.dateFormat, + onSelected: _onDateFormatChanged, + ), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 6.0), + child: DateFormatButton(), + ), + ), + AppFlowyPopover( + mutex: timeSettingPopoverMutex, + triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + offset: const Offset(8, 0), + popupBuilder: (_) => TimeFormatList( + selectedFormat: widget.timeFormat, + onSelected: _onTimeFormatChanged, + ), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 6.0), + child: TimeFormatButton(), + ), + ), + ]; + + return SizedBox( + width: 180, + child: ListView.separated( + shrinkWrap: true, + separatorBuilder: (_, __) => VSpace(DatePickerSize.seperatorHeight), + itemCount: children.length, + itemBuilder: (_, int index) => children[index], + padding: const EdgeInsets.symmetric(vertical: 6.0), + ), + ); + } + + void _onTimeFormatChanged(TimeFormatPB format) { + widget.onTimeFormatChanged(format); + timeSettingPopoverMutex.close(); + } + + void _onDateFormatChanged(DateFormatPB format) { + widget.onDateFormatChanged(format); + timeSettingPopoverMutex.close(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_time_text_field.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_time_text_field.dart new file mode 100644 index 0000000000000..acd50ce764019 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_time_text_field.dart @@ -0,0 +1,368 @@ +import 'package:any_date/any_date.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../desktop_date_picker.dart'; +import 'date_picker.dart'; + +class DateTimeTextField extends StatefulWidget { + const DateTimeTextField({ + super.key, + required this.dateTime, + required this.includeTime, + required this.dateFormat, + this.timeFormat, + this.onSubmitted, + this.popoverMutex, + this.isTabPressed, + this.refreshTextController, + required this.showHint, + }) : assert(includeTime && timeFormat != null || !includeTime); + + final DateTime? dateTime; + final bool includeTime; + final void Function(DateTime dateTime)? onSubmitted; + final DateFormatPB dateFormat; + final TimeFormatPB? timeFormat; + final PopoverMutex? popoverMutex; + final ValueNotifier? isTabPressed; + final RefreshDateTimeTextFieldController? refreshTextController; + final bool showHint; + + @override + State createState() => _DateTimeTextFieldState(); +} + +class _DateTimeTextFieldState extends State { + late final FocusNode focusNode; + late final FocusNode dateFocusNode; + late final FocusNode timeFocusNode; + + final dateTextController = TextEditingController(); + final timeTextController = TextEditingController(); + + final statesController = WidgetStatesController(); + + bool justSubmitted = false; + + DateFormat get dateFormat => DateFormat(widget.dateFormat.pattern); + DateFormat get timeFormat => DateFormat(widget.timeFormat?.pattern); + + @override + void initState() { + super.initState(); + updateTextControllers(); + + focusNode = FocusNode()..addListener(focusNodeListener); + dateFocusNode = FocusNode(onKeyEvent: textFieldOnKeyEvent) + ..addListener(dateFocusNodeListener); + timeFocusNode = FocusNode(onKeyEvent: textFieldOnKeyEvent) + ..addListener(timeFocusNodeListener); + widget.isTabPressed?.addListener(isTabPressedListener); + widget.refreshTextController?.addListener(updateTextControllers); + widget.popoverMutex?.addPopoverListener(popoverListener); + } + + @override + void didUpdateWidget(covariant oldWidget) { + if (oldWidget.dateTime != widget.dateTime || + oldWidget.dateFormat != widget.dateFormat || + oldWidget.timeFormat != widget.timeFormat) { + statesController.update(WidgetState.error, false); + updateTextControllers(); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + dateTextController.dispose(); + timeTextController.dispose(); + widget.popoverMutex?.removePopoverListener(popoverListener); + widget.isTabPressed?.removeListener(isTabPressedListener); + widget.refreshTextController?.removeListener(updateTextControllers); + dateFocusNode + ..removeListener(dateFocusNodeListener) + ..dispose(); + timeFocusNode + ..removeListener(timeFocusNodeListener) + ..dispose(); + focusNode + ..removeListener(focusNodeListener) + ..dispose(); + statesController.dispose(); + super.dispose(); + } + + void focusNodeListener() { + if (focusNode.hasFocus) { + statesController.update(WidgetState.focused, true); + widget.popoverMutex?.close(); + } else { + statesController.update(WidgetState.focused, false); + } + } + + void isTabPressedListener() { + if (!dateFocusNode.hasFocus && !timeFocusNode.hasFocus) { + return; + } + final controller = + dateFocusNode.hasFocus ? dateTextController : timeTextController; + if (widget.isTabPressed != null && widget.isTabPressed!.value) { + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controller.text.characters.length, + ); + widget.isTabPressed?.value = false; + } + } + + KeyEventResult textFieldOnKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyUpEvent && event.logicalKey == LogicalKeyboardKey.tab) { + widget.isTabPressed?.value = true; + } + return KeyEventResult.ignored; + } + + void dateFocusNodeListener() { + if (dateFocusNode.hasFocus || justSubmitted) { + justSubmitted = true; + return; + } + + final expected = widget.dateTime == null + ? "" + : DateFormat(widget.dateFormat.pattern).format(widget.dateTime!); + if (expected != dateTextController.text.trim()) { + onDateTextFieldSubmitted(); + } + } + + void timeFocusNodeListener() { + if (timeFocusNode.hasFocus || widget.timeFormat == null || justSubmitted) { + justSubmitted = true; + return; + } + + final expected = widget.dateTime == null + ? "" + : DateFormat(widget.timeFormat!.pattern).format(widget.dateTime!); + if (expected != timeTextController.text.trim()) { + onTimeTextFieldSubmitted(); + } + } + + void popoverListener() { + if (focusNode.hasFocus) { + focusNode.unfocus(); + } + } + + void updateTextControllers() { + if (widget.dateTime == null) { + dateTextController.clear(); + timeTextController.clear(); + return; + } + + dateTextController.text = dateFormat.format(widget.dateTime!); + timeTextController.text = timeFormat.format(widget.dateTime!); + } + + void onDateTextFieldSubmitted() { + DateTime? dateTime = parseDateTimeStr(dateTextController.text.trim()); + if (dateTime == null) { + statesController.update(WidgetState.error, true); + return; + } + statesController.update(WidgetState.error, false); + if (widget.dateTime != null) { + final timeComponent = Duration( + hours: widget.dateTime!.hour, + minutes: widget.dateTime!.minute, + seconds: widget.dateTime!.second, + ); + dateTime = DateTime( + dateTime.year, + dateTime.month, + dateTime.day, + ).add(timeComponent); + } + widget.onSubmitted?.call(dateTime); + } + + void onTimeTextFieldSubmitted() { + // this happens in the middle of a date range selection + if (widget.dateTime == null) { + widget.refreshTextController?.refresh(); + statesController.update(WidgetState.error, true); + return; + } + final adjustedTimeStr = + "${dateTextController.text} ${timeTextController.text.trim()}"; + final dateTime = parseDateTimeStr(adjustedTimeStr); + + if (dateTime == null) { + statesController.update(WidgetState.error, true); + return; + } + statesController.update(WidgetState.error, false); + widget.onSubmitted?.call(dateTime); + } + + DateTime? parseDateTimeStr(String string) { + final locale = context.locale.toLanguageTag(); + final parser = AnyDate.fromLocale(locale); + final result = parser.tryParse(string); + if (result == null || + result.isBefore(kFirstDay) || + result.isAfter(kLastDay)) { + return null; + } + return result; + } + + late final WidgetStateProperty borderColor = + WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.error)) { + return Theme.of(context).colorScheme.errorContainer; + } + if (states.contains(WidgetState.focused)) { + return Theme.of(context).colorScheme.primary; + } + return Theme.of(context).colorScheme.outline; + }, + ); + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + final hintDate = DateTime(now.year, now.month, 1, 9); + + return Focus( + focusNode: focusNode, + skipTraversal: true, + child: wrapWithGestures( + child: ListenableBuilder( + listenable: statesController, + builder: (context, child) { + final resolved = borderColor.resolve(statesController.value); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Container( + constraints: const BoxConstraints.tightFor(height: 32), + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide( + color: resolved ?? Colors.transparent, + ), + ), + borderRadius: Corners.s8Border, + ), + child: child, + ), + ); + }, + child: widget.includeTime + ? Row( + children: [ + Expanded( + child: TextField( + key: const ValueKey('date_time_text_field_date'), + focusNode: dateFocusNode, + controller: dateTextController, + style: Theme.of(context).textTheme.bodyMedium, + decoration: getInputDecoration( + const EdgeInsetsDirectional.fromSTEB(12, 6, 6, 6), + dateFormat.format(hintDate), + ), + onSubmitted: (value) { + justSubmitted = true; + onDateTextFieldSubmitted(); + }, + ), + ), + VerticalDivider( + indent: 4, + endIndent: 4, + width: 1, + color: Theme.of(context).colorScheme.outline, + ), + Expanded( + child: TextField( + key: const ValueKey('date_time_text_field_time'), + focusNode: timeFocusNode, + controller: timeTextController, + style: Theme.of(context).textTheme.bodyMedium, + decoration: getInputDecoration( + const EdgeInsetsDirectional.fromSTEB(6, 6, 12, 6), + timeFormat.format(hintDate), + ), + onSubmitted: (value) { + justSubmitted = true; + onTimeTextFieldSubmitted(); + }, + ), + ), + ], + ) + : Center( + child: TextField( + key: const ValueKey('date_time_text_field_date'), + focusNode: dateFocusNode, + controller: dateTextController, + style: Theme.of(context).textTheme.bodyMedium, + decoration: getInputDecoration( + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + dateFormat.format(hintDate), + ), + onSubmitted: (value) { + justSubmitted = true; + onDateTextFieldSubmitted(); + }, + ), + ), + ), + ), + ); + } + + Widget wrapWithGestures({required Widget child}) { + return GestureDetector( + onTapDown: (_) { + statesController.update(WidgetState.pressed, true); + }, + onTapCancel: () { + statesController.update(WidgetState.pressed, false); + }, + onTap: () { + statesController.update(WidgetState.pressed, false); + }, + child: child, + ); + } + + InputDecoration getInputDecoration( + EdgeInsetsGeometry padding, + String? hintText, + ) { + return InputDecoration( + border: InputBorder.none, + contentPadding: padding, + isCollapsed: true, + isDense: true, + hintText: widget.showHint ? hintText : null, + hintStyle: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Theme.of(context).hintColor), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart new file mode 100644 index 0000000000000..9bb819a243ff3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_time_settings.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class DateTypeOptionButton extends StatelessWidget { + const DateTypeOptionButton({ + super.key, + required this.dateFormat, + required this.timeFormat, + required this.onDateFormatChanged, + required this.onTimeFormatChanged, + required this.popoverMutex, + }); + + final DateFormatPB dateFormat; + final TimeFormatPB timeFormat; + final Function(DateFormatPB) onDateFormatChanged; + final Function(TimeFormatPB) onTimeFormatChanged; + final PopoverMutex? popoverMutex; + + @override + Widget build(BuildContext context) { + final title = + "${LocaleKeys.datePicker_dateFormat.tr()} & ${LocaleKeys.datePicker_timeFormat.tr()}"; + return AppFlowyPopover( + mutex: popoverMutex, + offset: const Offset(8, 0), + margin: EdgeInsets.zero, + constraints: BoxConstraints.loose(const Size(140, 100)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: SizedBox( + height: GridSize.popoverItemHeight, + child: FlowyButton( + text: FlowyText(title), + rightIcon: const FlowySvg(FlowySvgs.more_s), + ), + ), + ), + popupBuilder: (_) => DateTimeSetting( + dateFormat: dateFormat, + timeFormat: timeFormat, + onDateFormatChanged: (format) { + onDateFormatChanged(format); + popoverMutex?.close(); + }, + onTimeFormatChanged: (format) { + onTimeFormatChanged(format); + popoverMutex?.close(); + }, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/end_time_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/end_time_button.dart new file mode 100644 index 0000000000000..fdb24fb76118c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/end_time_button.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; + +class EndTimeButton extends StatelessWidget { + const EndTimeButton({ + super.key, + required this.isRange, + required this.onChanged, + }); + + final bool isRange; + final Function(bool value) onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: SizedBox( + height: GridSize.popoverItemHeight, + child: Padding( + padding: GridSize.typeOptionContentInsets, + child: Row( + children: [ + FlowySvg( + FlowySvgs.date_s, + color: Theme.of(context).iconTheme.color, + ), + const HSpace(6), + FlowyText(LocaleKeys.datePicker_isRange.tr()), + const Spacer(), + Toggle( + value: isRange, + onChanged: onChanged, + padding: EdgeInsets.zero, + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_editor.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_editor.dart new file mode 100644 index 0000000000000..4d8176ba5c17b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_editor.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class MobileDatePicker extends StatefulWidget { + const MobileDatePicker({ + super.key, + this.selectedDay, + this.startDay, + this.endDay, + required this.focusedDay, + required this.isRange, + this.onDaySelected, + this.onRangeSelected, + this.onPageChanged, + }); + + final DateTime? selectedDay; + final DateTime? startDay; + final DateTime? endDay; + final DateTime focusedDay; + + final bool isRange; + + final void Function(DateTime)? onDaySelected; + final void Function(DateTime?, DateTime?)? onRangeSelected; + final void Function(DateTime)? onPageChanged; + + @override + State createState() => _MobileDatePickerState(); +} + +class _MobileDatePickerState extends State { + PageController? pageController; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const VSpace(8.0), + _buildHeader(context), + const VSpace(8.0), + _buildCalendar(context), + const VSpace(16.0), + ], + ); + } + + Widget _buildCalendar(BuildContext context) { + return DatePicker( + isRange: widget.isRange, + onDaySelected: (selectedDay, _) { + widget.onDaySelected?.call(selectedDay); + }, + focusedDay: widget.focusedDay, + onRangeSelected: (start, end, focusedDay) { + widget.onRangeSelected?.call(start, end); + }, + selectedDay: widget.selectedDay, + startDay: widget.startDay, + endDay: widget.endDay, + onCalendarCreated: (pageController) { + this.pageController = pageController; + }, + onPageChanged: widget.onPageChanged, + ); + } + + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.only(start: 16, end: 8), + child: Row( + children: [ + Expanded( + child: FlowyText( + DateFormat.yMMMM().format(widget.focusedDay), + ), + ), + FlowyButton( + useIntrinsicWidth: true, + text: FlowySvg( + FlowySvgs.arrow_left_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(24.0), + ), + onTap: () { + pageController?.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }, + ), + const HSpace(24.0), + FlowyButton( + useIntrinsicWidth: true, + text: FlowySvg( + FlowySvgs.arrow_right_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(24.0), + ), + onTap: () { + pageController?.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart new file mode 100644 index 0000000000000..35d4d89531a96 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/mobile_date_header.dart @@ -0,0 +1,32 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +const _height = 44.0; + +class MobileDateHeader extends StatelessWidget { + const MobileDateHeader({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + color: Theme.of(context).colorScheme.surface, + child: Stack( + children: [ + const Align( + alignment: Alignment.centerLeft, + child: AppBarCloseButton(), + ), + Align( + child: FlowyText.medium( + LocaleKeys.grid_field_dateFieldName.tr(), + fontSize: 16, + ), + ), + ].map((e) => SizedBox(height: _height, child: e)).toList(), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart new file mode 100644 index 0000000000000..d795e2ab7d7ff --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/utils/layout.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pbenum.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +typedef OnReminderSelected = void Function(ReminderOption option); + +class ReminderSelector extends StatelessWidget { + const ReminderSelector({ + super.key, + required this.mutex, + required this.selectedOption, + required this.onOptionSelected, + required this.timeFormat, + this.hasTime = false, + }); + + final PopoverMutex? mutex; + final ReminderOption selectedOption; + final OnReminderSelected? onOptionSelected; + final TimeFormatPB timeFormat; + final bool hasTime; + + @override + Widget build(BuildContext context) { + final options = ReminderOption.values.toList(); + if (selectedOption != ReminderOption.custom) { + options.remove(ReminderOption.custom); + } + + options.removeWhere( + (o) => !o.timeExempt && (!hasTime ? !o.withoutTime : o.requiresNoTime), + ); + + final optionWidgets = options.map( + (o) { + String label = o.label; + if (o.withoutTime && !o.timeExempt) { + const time = "09:00"; + final t = timeFormat == TimeFormatPB.TwelveHour ? "$time AM" : time; + + label = "$label ($t)"; + } + + return SizedBox( + height: DatePickerSize.itemHeight, + child: FlowyButton( + text: FlowyText(label), + rightIcon: + o == selectedOption ? const FlowySvg(FlowySvgs.check_s) : null, + onTap: () { + if (o != selectedOption) { + onOptionSelected?.call(o); + mutex?.close(); + } + }, + ), + ); + }, + ).toList(); + + return AppFlowyPopover( + mutex: mutex, + offset: const Offset(8, 0), + margin: EdgeInsets.zero, + constraints: const BoxConstraints(maxHeight: 400, maxWidth: 205), + popupBuilder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(6.0), + child: SeparatedColumn( + children: optionWidgets, + separatorBuilder: () => VSpace(DatePickerSize.seperatorHeight), + ), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: SizedBox( + height: DatePickerSize.itemHeight, + child: FlowyButton( + text: FlowyText(LocaleKeys.datePicker_reminderLabel.tr()), + rightIcon: Row( + children: [ + FlowyText.regular(selectedOption.label), + const FlowySvg(FlowySvgs.more_s), + ], + ), + ), + ), + ), + ); + } +} + +enum ReminderOption { + none(time: Duration()), + atTimeOfEvent(time: Duration()), + fiveMinsBefore(time: Duration(minutes: 5)), + tenMinsBefore(time: Duration(minutes: 10)), + fifteenMinsBefore(time: Duration(minutes: 15)), + thirtyMinsBefore(time: Duration(minutes: 30)), + oneHourBefore(time: Duration(hours: 1)), + twoHoursBefore(time: Duration(hours: 2)), + onDayOfEvent( + time: Duration(hours: 9), + withoutTime: true, + requiresNoTime: true, + ), + // 9:00 AM the day before (24-9) + oneDayBefore(time: Duration(hours: 15), withoutTime: true), + twoDaysBefore(time: Duration(days: 1, hours: 15), withoutTime: true), + oneWeekBefore(time: Duration(days: 6, hours: 15), withoutTime: true), + custom(time: Duration()); + + const ReminderOption({ + required this.time, + this.withoutTime = false, + this.requiresNoTime = false, + }) : assert(!requiresNoTime || withoutTime); + + final Duration time; + + /// If true, don't consider the time component of the dateTime + final bool withoutTime; + + /// If true, [withoutTime] must be true as well. Will add time instead of subtract to get notification time. + final bool requiresNoTime; + + bool get timeExempt => + [ReminderOption.none, ReminderOption.custom].contains(this); + + String get label => switch (this) { + ReminderOption.none => LocaleKeys.datePicker_reminderOptions_none.tr(), + ReminderOption.atTimeOfEvent => + LocaleKeys.datePicker_reminderOptions_atTimeOfEvent.tr(), + ReminderOption.fiveMinsBefore => + LocaleKeys.datePicker_reminderOptions_fiveMinsBefore.tr(), + ReminderOption.tenMinsBefore => + LocaleKeys.datePicker_reminderOptions_tenMinsBefore.tr(), + ReminderOption.fifteenMinsBefore => + LocaleKeys.datePicker_reminderOptions_fifteenMinsBefore.tr(), + ReminderOption.thirtyMinsBefore => + LocaleKeys.datePicker_reminderOptions_thirtyMinsBefore.tr(), + ReminderOption.oneHourBefore => + LocaleKeys.datePicker_reminderOptions_oneHourBefore.tr(), + ReminderOption.twoHoursBefore => + LocaleKeys.datePicker_reminderOptions_twoHoursBefore.tr(), + ReminderOption.onDayOfEvent => + LocaleKeys.datePicker_reminderOptions_onDayOfEvent.tr(), + ReminderOption.oneDayBefore => + LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(), + ReminderOption.twoDaysBefore => + LocaleKeys.datePicker_reminderOptions_twoDaysBefore.tr(), + ReminderOption.oneWeekBefore => + LocaleKeys.datePicker_reminderOptions_oneWeekBefore.tr(), + ReminderOption.custom => + LocaleKeys.datePicker_reminderOptions_custom.tr(), + }; + + static ReminderOption fromDateDifference( + DateTime eventDate, + DateTime reminderDate, + ) { + final def = fromMinutes(eventDate.difference(reminderDate).inMinutes); + if (def != ReminderOption.custom) { + return def; + } + + final diff = eventDate.withoutTime.difference(reminderDate).inMinutes; + return fromMinutes(diff); + } + + static ReminderOption fromMinutes(int minutes) => switch (minutes) { + 0 => ReminderOption.atTimeOfEvent, + 5 => ReminderOption.fiveMinsBefore, + 10 => ReminderOption.tenMinsBefore, + 15 => ReminderOption.fifteenMinsBefore, + 30 => ReminderOption.thirtyMinsBefore, + 60 => ReminderOption.oneHourBefore, + 120 => ReminderOption.twoHoursBefore, + // Negative because Event Day Today + 940 minutes + -540 => ReminderOption.onDayOfEvent, + 900 => ReminderOption.oneDayBefore, + 2340 => ReminderOption.twoDaysBefore, + 9540 => ReminderOption.oneWeekBefore, + _ => ReminderOption.custom, + }; + + DateTime getNotificationDateTime(DateTime date) { + return withoutTime + ? requiresNoTime + ? date.withoutTime.add(time) + : date.withoutTime.subtract(time) + : date.subtract(time); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart new file mode 100644 index 0000000000000..7686b1d8913cf --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -0,0 +1,659 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/tasks/app_widget.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_input.dart'; +import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; +import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; +import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:toastification/toastification.dart'; +import 'package:universal_platform/universal_platform.dart'; + +export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; +export 'package:toastification/toastification.dart'; + +class NavigatorCustomDialog extends StatefulWidget { + const NavigatorCustomDialog({ + super.key, + required this.child, + this.cancel, + this.confirm, + this.hideCancelButton = false, + }); + + final Widget child; + final void Function()? cancel; + final void Function()? confirm; + final bool hideCancelButton; + + @override + State createState() => _NavigatorCustomDialog(); +} + +class _NavigatorCustomDialog extends State { + @override + Widget build(BuildContext context) { + return StyledDialog( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ...[ + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 400, + maxHeight: 260, + ), + child: widget.child, + ), + ], + if (widget.confirm != null) ...[ + const VSpace(20), + OkCancelButton( + onOkPressed: () { + widget.confirm?.call(); + Navigator.of(context).pop(); + }, + onCancelPressed: widget.hideCancelButton + ? null + : () { + widget.cancel?.call(); + Navigator.of(context).pop(); + }, + ), + ], + ], + ), + ); + } +} + +class NavigatorTextFieldDialog extends StatefulWidget { + const NavigatorTextFieldDialog({ + super.key, + required this.title, + this.autoSelectAllText = false, + required this.value, + required this.onConfirm, + this.onCancel, + this.maxLength, + this.hintText, + }); + + final String value; + final String title; + final VoidCallback? onCancel; + final void Function(String, BuildContext) onConfirm; + final bool autoSelectAllText; + final int? maxLength; + final String? hintText; + + @override + State createState() => + _NavigatorTextFieldDialogState(); +} + +class _NavigatorTextFieldDialogState extends State { + String newValue = ""; + final controller = TextEditingController(); + + @override + void initState() { + super.initState(); + newValue = widget.value; + controller.text = newValue; + if (widget.autoSelectAllText) { + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: newValue.length, + ); + } + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StyledDialog( + child: Column( + children: [ + FlowyText.medium( + widget.title, + color: Theme.of(context).colorScheme.tertiary, + fontSize: FontSizes.s16, + ), + VSpace(Insets.m), + FlowyFormTextInput( + hintText: + widget.hintText ?? LocaleKeys.dialogCreatePageNameHint.tr(), + controller: controller, + textStyle: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(fontSize: FontSizes.s16), + maxLength: widget.maxLength, + showCounter: false, + autoFocus: true, + onChanged: (text) { + newValue = text; + }, + onEditingComplete: () { + widget.onConfirm(newValue, context); + AppGlobals.nav.pop(); + }, + ), + VSpace(Insets.xl), + OkCancelButton( + onOkPressed: () { + if (newValue.isEmpty) { + showToastNotification( + context, + message: LocaleKeys.space_spaceNameCannotBeEmpty.tr(), + ); + return; + } + widget.onConfirm(newValue, context); + Navigator.of(context).pop(); + }, + onCancelPressed: () { + widget.onCancel?.call(); + Navigator.of(context).pop(); + }, + ), + ], + ), + ); + } +} + +class NavigatorAlertDialog extends StatefulWidget { + const NavigatorAlertDialog({ + super.key, + required this.title, + this.cancel, + this.confirm, + this.hideCancelButton = false, + this.constraints, + }); + + final String title; + final void Function()? cancel; + final void Function()? confirm; + final bool hideCancelButton; + final BoxConstraints? constraints; + + @override + State createState() => _CreateFlowyAlertDialog(); +} + +class _CreateFlowyAlertDialog extends State { + @override + Widget build(BuildContext context) { + return StyledDialog( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ...[ + ConstrainedBox( + constraints: widget.constraints ?? + const BoxConstraints( + maxWidth: 400, + maxHeight: 260, + ), + child: FlowyText.medium( + widget.title, + fontSize: FontSizes.s16, + textAlign: TextAlign.center, + color: Theme.of(context).colorScheme.tertiary, + maxLines: null, + ), + ), + ], + if (widget.confirm != null) ...[ + const VSpace(20), + OkCancelButton( + onOkPressed: () { + widget.confirm?.call(); + Navigator.of(context).pop(); + }, + onCancelPressed: widget.hideCancelButton + ? null + : () { + widget.cancel?.call(); + Navigator.of(context).pop(); + }, + ), + ], + ], + ), + ); + } +} + +class NavigatorOkCancelDialog extends StatelessWidget { + const NavigatorOkCancelDialog({ + super.key, + this.onOkPressed, + this.onCancelPressed, + this.okTitle, + this.cancelTitle, + this.title, + this.message, + this.maxWidth, + this.titleUpperCase = true, + this.autoDismiss = true, + }); + + final VoidCallback? onOkPressed; + final VoidCallback? onCancelPressed; + final String? okTitle; + final String? cancelTitle; + final String? title; + final String? message; + final double? maxWidth; + final bool titleUpperCase; + final bool autoDismiss; + + @override + Widget build(BuildContext context) { + final onCancel = onCancelPressed == null + ? null + : () { + onCancelPressed?.call(); + if (autoDismiss) { + Navigator.of(context).pop(); + } + }; + return StyledDialog( + maxWidth: maxWidth ?? 500, + padding: EdgeInsets.symmetric(horizontal: Insets.xl, vertical: Insets.l), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) ...[ + FlowyText.medium( + titleUpperCase ? title!.toUpperCase() : title!, + fontSize: FontSizes.s16, + maxLines: 3, + ), + VSpace(Insets.sm * 1.5), + Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + height: 1, + ), + VSpace(Insets.m * 1.5), + ], + if (message != null) + FlowyText.medium( + message!, + maxLines: 3, + ), + SizedBox(height: Insets.l), + OkCancelButton( + onOkPressed: () { + onOkPressed?.call(); + if (autoDismiss) { + Navigator.of(context).pop(); + } + }, + onCancelPressed: onCancel, + okTitle: okTitle?.toUpperCase(), + cancelTitle: cancelTitle?.toUpperCase(), + ), + ], + ), + ); + } +} + +class OkCancelButton extends StatelessWidget { + const OkCancelButton({ + super.key, + this.onOkPressed, + this.onCancelPressed, + this.okTitle, + this.cancelTitle, + this.minHeight, + this.alignment = MainAxisAlignment.spaceAround, + this.mode = TextButtonMode.big, + }); + + final VoidCallback? onOkPressed; + final VoidCallback? onCancelPressed; + final String? okTitle; + final String? cancelTitle; + final double? minHeight; + final MainAxisAlignment alignment; + final TextButtonMode mode; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 48, + child: Row( + mainAxisAlignment: alignment, + children: [ + if (onCancelPressed != null) + SecondaryTextButton( + cancelTitle ?? LocaleKeys.button_cancel.tr(), + onPressed: onCancelPressed, + mode: mode, + ), + if (onCancelPressed != null) HSpace(Insets.m), + if (onOkPressed != null) + PrimaryTextButton( + okTitle ?? LocaleKeys.button_ok.tr(), + onPressed: onOkPressed, + mode: mode, + ), + ], + ), + ); + } +} + +void showToastNotification( + BuildContext context, { + required String message, + String? description, + ToastificationType type = ToastificationType.success, + ToastificationCallbacks? callbacks, + double bottomPadding = 100, +}) { + if (UniversalPlatform.isMobile) { + toastification.showCustom( + alignment: Alignment.bottomCenter, + autoCloseDuration: const Duration(milliseconds: 3000), + callbacks: callbacks ?? const ToastificationCallbacks(), + builder: (_, __) => _MToast( + message: message, + type: type, + bottomPadding: bottomPadding, + description: description, + ), + ); + return; + } + + toastification.show( + context: context, + type: type, + style: ToastificationStyle.flat, + closeButtonShowType: CloseButtonShowType.onHover, + alignment: Alignment.bottomCenter, + autoCloseDuration: const Duration(milliseconds: 3000), + showProgressBar: false, + backgroundColor: Theme.of(context).colorScheme.surface, + borderSide: BorderSide( + color: Colors.grey.withOpacity(0.4), + ), + title: FlowyText( + message, + maxLines: 3, + ), + description: description != null + ? FlowyText.regular( + description, + fontSize: 12, + lineHeight: 1.2, + maxLines: 3, + ) + : null, + ); +} + +class _MToast extends StatelessWidget { + const _MToast({ + required this.message, + this.type = ToastificationType.success, + this.bottomPadding = 100, + this.description, + }); + + final String message; + final ToastificationType type; + final double bottomPadding; + final String? description; + + @override + Widget build(BuildContext context) { + final hintText = FlowyText.regular( + message, + fontSize: 16.0, + figmaLineHeight: 18.0, + color: Colors.white, + maxLines: 10, + ); + final descriptionText = description != null + ? FlowyText.regular( + description!, + fontSize: 12, + color: Colors.white, + maxLines: 10, + ) + : null; + return Container( + alignment: Alignment.bottomCenter, + padding: EdgeInsets.only( + bottom: bottomPadding, + left: 16, + right: 16, + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 13.0, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + color: const Color(0xE5171717), + ), + child: type == ToastificationType.success + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (type == ToastificationType.success) ...[ + const FlowySvg( + FlowySvgs.success_s, + blendMode: null, + ), + const HSpace(8.0), + ], + Expanded(child: hintText), + ], + ), + if (descriptionText != null) ...[ + const VSpace(4.0), + descriptionText, + ], + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + hintText, + if (descriptionText != null) ...[ + const VSpace(4.0), + descriptionText, + ], + ], + ), + ), + ); + } +} + +Future showConfirmDeletionDialog({ + required BuildContext context, + required String name, + required String description, + required VoidCallback onConfirm, +}) { + return showDialog( + context: context, + builder: (_) { + final title = LocaleKeys.space_deleteConfirmation.tr() + name; + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 440, + child: ConfirmPopup( + title: title, + description: description, + onConfirm: onConfirm, + ), + ), + ); + }, + ); +} + +Future showConfirmDialog({ + required BuildContext context, + required String title, + required String description, + VoidCallback? onConfirm, + VoidCallback? onCancel, + String? confirmLabel, + ConfirmPopupStyle style = ConfirmPopupStyle.onlyOk, +}) { + return showDialog( + context: context, + builder: (_) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 440, + child: ConfirmPopup( + title: title, + description: description, + onConfirm: () => onConfirm?.call(), + onCancel: () => onCancel?.call(), + confirmLabel: confirmLabel, + style: style, + ), + ), + ); + }, + ); +} + +Future showCancelAndConfirmDialog({ + required BuildContext context, + required String title, + required String description, + VoidCallback? onConfirm, + VoidCallback? onCancel, + String? confirmLabel, +}) { + return showDialog( + context: context, + builder: (_) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 440, + child: ConfirmPopup( + title: title, + description: description, + onConfirm: () => onConfirm?.call(), + confirmLabel: confirmLabel, + confirmButtonColor: Theme.of(context).colorScheme.primary, + onCancel: () => onCancel?.call(), + ), + ), + ); + }, + ); +} + +Future showCustomConfirmDialog({ + required BuildContext context, + required String title, + required String description, + required Widget Function(BuildContext) builder, + VoidCallback? onConfirm, + VoidCallback? onCancel, + String? confirmLabel, + ConfirmPopupStyle style = ConfirmPopupStyle.onlyOk, + bool closeOnConfirm = true, +}) { + return showDialog( + context: context, + builder: (context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 440, + child: ConfirmPopup( + title: title, + description: description, + onConfirm: () => onConfirm?.call(), + onCancel: onCancel, + confirmLabel: confirmLabel, + confirmButtonColor: Theme.of(context).colorScheme.primary, + style: style, + closeOnAction: closeOnConfirm, + child: builder(context), + ), + ), + ); + }, + ); +} + +Future showCancelAndDeleteDialog({ + required BuildContext context, + required String title, + required String description, + Widget Function(BuildContext)? builder, + VoidCallback? onDelete, + String? confirmLabel, + bool closeOnAction = false, +}) { + return showDialog( + context: context, + builder: (_) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 440, + child: ConfirmPopup( + title: title, + description: description, + onConfirm: () => onDelete?.call(), + closeOnAction: closeOnAction, + confirmLabel: confirmLabel, + confirmButtonColor: Theme.of(context).colorScheme.error, + child: builder?.call(context), + ), + ), + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/draggable_item/draggable_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/draggable_item/draggable_item.dart new file mode 100644 index 0000000000000..cfada71311687 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/draggable_item/draggable_item.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class DraggableItem extends StatefulWidget { + const DraggableItem({ + super.key, + required this.child, + required this.data, + this.feedback, + this.childWhenDragging, + this.onAcceptWithDetails, + this.onWillAcceptWithDetails, + this.onMove, + this.onLeave, + this.enableAutoScroll = true, + this.hitTestSize = const Size(100, 100), + this.onDragging, + }); + + final T data; + + final Widget child; + final Widget? feedback; + final Widget? childWhenDragging; + + final DragTargetAcceptWithDetails? onAcceptWithDetails; + final DragTargetWillAcceptWithDetails? onWillAcceptWithDetails; + final DragTargetMove? onMove; + final DragTargetLeave? onLeave; + + /// Whether to enable auto scroll when dragging. + /// + /// If true, the draggable item must be wrapped inside a [Scrollable] widget. + final bool enableAutoScroll; + final Size hitTestSize; + + final void Function(bool isDragging)? onDragging; + + @override + State> createState() => _DraggableItemState(); +} + +class _DraggableItemState extends State> { + ScrollableState? scrollable; + EdgeDraggingAutoScroller? autoScroller; + Rect? dragTarget; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + initAutoScrollerIfNeeded(context); + } + + @override + Widget build(BuildContext context) { + initAutoScrollerIfNeeded(context); + + return DragTarget( + onAcceptWithDetails: widget.onAcceptWithDetails, + onWillAcceptWithDetails: widget.onWillAcceptWithDetails, + onMove: widget.onMove, + onLeave: widget.onLeave, + builder: (_, __, ___) => _Draggable( + data: widget.data, + feedback: widget.feedback ?? widget.child, + childWhenDragging: widget.childWhenDragging ?? widget.child, + child: widget.child, + onDragUpdate: (details) { + if (widget.enableAutoScroll) { + dragTarget = details.globalPosition & widget.hitTestSize; + autoScroller?.startAutoScrollIfNecessary(dragTarget!); + } + widget.onDragging?.call(true); + }, + onDragEnd: (details) { + autoScroller?.stopAutoScroll(); + dragTarget = null; + widget.onDragging?.call(false); + }, + onDraggableCanceled: (_, __) { + autoScroller?.stopAutoScroll(); + dragTarget = null; + widget.onDragging?.call(false); + }, + ), + ); + } + + void initAutoScrollerIfNeeded(BuildContext context) { + if (!widget.enableAutoScroll) { + return; + } + + scrollable = Scrollable.of(context); + if (scrollable == null) { + throw FlutterError( + 'DraggableItem must be wrapped inside a Scrollable widget ' + 'when enableAutoScroll is true.', + ); + } + + autoScroller?.stopAutoScroll(); + autoScroller = EdgeDraggingAutoScroller( + scrollable!, + onScrollViewScrolled: () { + if (dragTarget != null) { + autoScroller!.startAutoScrollIfNecessary(dragTarget!); + } + }, + velocityScalar: 20, + ); + } +} + +class _Draggable extends StatelessWidget { + const _Draggable({ + required this.child, + required this.feedback, + this.data, + this.childWhenDragging, + this.onDragStarted, + this.onDragUpdate, + this.onDraggableCanceled, + this.onDragEnd, + this.onDragCompleted, + }); + + /// The data that will be dropped by this draggable. + final T? data; + + final Widget child; + + final Widget? childWhenDragging; + final Widget feedback; + + /// Called when the draggable starts being dragged. + final VoidCallback? onDragStarted; + + final DragUpdateCallback? onDragUpdate; + + final DraggableCanceledCallback? onDraggableCanceled; + + final VoidCallback? onDragCompleted; + final DragEndCallback? onDragEnd; + + @override + Widget build(BuildContext context) { + return UniversalPlatform.isMobile + ? LongPressDraggable( + data: data, + feedback: feedback, + childWhenDragging: childWhenDragging, + onDragUpdate: onDragUpdate, + onDragEnd: onDragEnd, + onDraggableCanceled: onDraggableCanceled, + child: child, + ) + : Draggable( + data: data, + feedback: feedback, + childWhenDragging: childWhenDragging, + onDragUpdate: onDragUpdate, + onDragEnd: onDragEnd, + onDraggableCanceled: onDraggableCanceled, + child: child, + ); + } +} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/edit_panel/edit_panel.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/edit_panel/edit_panel.dart similarity index 78% rename from frontend/app_flowy/lib/workspace/presentation/widgets/edit_panel/edit_panel.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/widgets/edit_panel/edit_panel.dart index ef0b61865ea7b..70d6479cf5716 100644 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/edit_panel/edit_panel.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/edit_panel/edit_panel.dart @@ -1,22 +1,23 @@ -import 'package:app_flowy/workspace/application/edit_panel/edit_panel_bloc.dart'; -import 'package:app_flowy/workspace/application/edit_panel/edit_context.dart'; -import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/workspace/presentation/home/home_sizes.dart'; +import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart'; +import 'package:appflowy/workspace/application/edit_panel/edit_context.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/presentation/home/home_sizes.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/bar_title.dart'; import 'package:flowy_infra_ui/style_widget/close_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:app_flowy/generated/locale_keys.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; class EditPanel extends StatelessWidget { - final EditPanelContext panelContext; - final VoidCallback onEndEdit; const EditPanel({ - Key? key, + super.key, required this.panelContext, required this.onEndEdit, - }) : super(key: key); + }); + + final EditPanelContext panelContext; + final VoidCallback onEndEdit; @override Widget build(BuildContext context) { @@ -43,8 +44,10 @@ class EditPanel extends StatelessWidget { } class EditPanelTopBar extends StatelessWidget { + const EditPanelTopBar({super.key, required this.onClose}); + final VoidCallback onClose; - const EditPanelTopBar({Key? key, required this.onClose}) : super(key: key); + @override Widget build(BuildContext context) { return SizedBox( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/edit_panel/panel_animation.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/edit_panel/panel_animation.dart new file mode 100644 index 0000000000000..1338048e1b4ff --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/edit_panel/panel_animation.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +class AnimatedPanel extends StatefulWidget { + const AnimatedPanel({ + super.key, + this.isClosed = false, + this.closedX = 0.0, + this.closedY = 0.0, + this.duration = 0.0, + this.curve, + this.child, + }); + + final bool isClosed; + final double closedX; + final double closedY; + final double duration; + final Curve? curve; + final Widget? child; + + @override + AnimatedPanelState createState() => AnimatedPanelState(); +} + +class AnimatedPanelState extends State { + bool _isHidden = true; + + @override + Widget build(BuildContext context) { + final Offset closePos = Offset(widget.closedX, widget.closedY); + final double duration = _isHidden && widget.isClosed ? 0 : widget.duration; + return TweenAnimationBuilder( + curve: widget.curve ?? Curves.easeOut, + tween: Tween( + begin: !widget.isClosed ? Offset.zero : closePos, + end: !widget.isClosed ? Offset.zero : closePos, + ), + duration: Duration(milliseconds: (duration * 1000).round()), + builder: (_, Offset value, Widget? c) { + _isHidden = + widget.isClosed && value == Offset(widget.closedX, widget.closedY); + return _isHidden + ? const SizedBox.shrink() + : Transform.translate(offset: value, child: c); + }, + child: widget.child, + ); + } +} + +extension AnimatedPanelExtensions on Widget { + Widget animatedPanelX({ + double closeX = 0.0, + bool? isClosed, + double? duration, + Curve? curve, + }) => + animatedPanel( + closePos: Offset(closeX, 0), + isClosed: isClosed, + curve: curve, + duration: duration, + ); + + Widget animatedPanelY({ + double closeY = 0.0, + bool? isClosed, + double? duration, + Curve? curve, + }) => + animatedPanel( + closePos: Offset(0, closeY), + isClosed: isClosed, + curve: curve, + duration: duration, + ); + + Widget animatedPanel({ + required Offset closePos, + bool? isClosed, + double? duration, + Curve? curve, + }) { + return AnimatedPanel( + closedX: closePos.dx, + closedY: closePos.dy, + isClosed: isClosed ?? false, + duration: duration ?? .35, + curve: curve, + child: this, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/favorite_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/favorite_button.dart new file mode 100644 index 0000000000000..6e1c377277cc7 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/favorite_button.dart @@ -0,0 +1,47 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ViewFavoriteButton extends StatelessWidget { + const ViewFavoriteButton({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final isFavorite = state.views.any((v) => v.item.id == view.id); + return Listener( + onPointerDown: (_) => + context.read().add(FavoriteEvent.toggle(view)), + child: FlowyTooltip( + message: isFavorite + ? LocaleKeys.button_removeFromFavorites.tr() + : LocaleKeys.button_addToFavorites.tr(), + child: FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: const EdgeInsets.all(6), + child: FlowySvg( + isFavorite ? FlowySvgs.favorited_s : FlowySvgs.favorite_s, + size: const Size.square(18), + blendMode: isFavorite ? null : BlendMode.srcIn, + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart new file mode 100644 index 0000000000000..d666e606f6829 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart @@ -0,0 +1,236 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/tasks/rust_sdk.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/float_bubble/social_media_section.dart'; +import 'package:appflowy/workspace/presentation/widgets/float_bubble/version_section.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class QuestionBubble extends StatelessWidget { + const QuestionBubble({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox.square( + dimension: 32.0, + child: BubbleActionList(), + ); + } +} + +class BubbleActionList extends StatefulWidget { + const BubbleActionList({super.key}); + + @override + State createState() => _BubbleActionListState(); +} + +class _BubbleActionListState extends State { + bool isOpen = false; + + Color get fontColor => isOpen + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.tertiary; + + Color get fillColor => isOpen + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.tertiaryContainer; + + void toggle() { + setState(() { + isOpen = !isOpen; + }); + } + + @override + Widget build(BuildContext context) { + final List actions = []; + actions.addAll( + BubbleAction.values.map((action) => BubbleActionWrapper(action)), + ); + + actions.add(SocialMediaSection()); + actions.add(FlowyVersionSection()); + + final (color, borderColor, shadowColor, iconColor) = + Theme.of(context).isLightMode + ? ( + Colors.white, + const Color(0x2D454849), + const Color(0x14000000), + Colors.black, + ) + : ( + const Color(0xFF242B37), + const Color(0x2DFFFFFF), + const Color(0x14000000), + Colors.white, + ); + + return PopoverActionList( + direction: PopoverDirection.topWithRightAligned, + actions: actions, + offset: const Offset(0, -8), + constraints: const BoxConstraints( + minWidth: 200, + maxWidth: 460, + maxHeight: 400, + ), + buildChild: (controller) { + return FlowyTooltip( + message: LocaleKeys.questionBubble_help.tr(), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + child: Container( + padding: const EdgeInsets.all(8.0), + decoration: ShapeDecoration( + color: color, + shape: RoundedRectangleBorder( + side: BorderSide(width: 0.50, color: borderColor), + borderRadius: BorderRadius.circular(18), + ), + shadows: [ + BoxShadow( + color: shadowColor, + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: FlowySvg( + FlowySvgs.help_center_s, + color: iconColor, + ), + ), + onTap: () => controller.show(), + ), + ), + ); + }, + onClosed: toggle, + onSelected: (action, controller) { + if (action is BubbleActionWrapper) { + switch (action.inner) { + case BubbleAction.whatsNews: + afLaunchUrlString("https://www.appflowy.io/what-is-new"); + break; + case BubbleAction.help: + afLaunchUrlString("https://discord.gg/9Q2xaN37tV"); + break; + case BubbleAction.debug: + _DebugToast().show(); + break; + case BubbleAction.shortcuts: + afLaunchUrlString( + "https://docs.appflowy.io/docs/appflowy/product/shortcuts", + ); + break; + case BubbleAction.markdown: + afLaunchUrlString( + "https://docs.appflowy.io/docs/appflowy/product/markdown", + ); + break; + case BubbleAction.github: + afLaunchUrlString( + 'https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose', + ); + break; + } + } + + controller.close(); + }, + ); + } +} + +class _DebugToast { + void show() async { + String debugInfo = ""; + debugInfo += await _getDeviceInfo(); + debugInfo += await _getDocumentPath(); + await Clipboard.setData(ClipboardData(text: debugInfo)); + + showMessageToast(LocaleKeys.questionBubble_debug_success.tr()); + } + + Future _getDeviceInfo() async { + final deviceInfoPlugin = DeviceInfoPlugin(); + final deviceInfo = await deviceInfoPlugin.deviceInfo; + + return deviceInfo.data.entries + .fold('', (prev, el) => "$prev${el.key}: ${el.value}\n"); + } + + Future _getDocumentPath() async { + return appFlowyApplicationDataDirectory().then((directory) { + final path = directory.path.toString(); + return "Document: $path\n"; + }); + } +} + +enum BubbleAction { + whatsNews, + help, + debug, + shortcuts, + markdown, + github, +} + +class BubbleActionWrapper extends ActionCell { + BubbleActionWrapper(this.inner); + + final BubbleAction inner; + @override + Widget? leftIcon(Color iconColor) => inner.icons; + + @override + String get name => inner.name; +} + +extension QuestionBubbleExtension on BubbleAction { + String get name { + switch (this) { + case BubbleAction.whatsNews: + return LocaleKeys.questionBubble_whatsNew.tr(); + case BubbleAction.help: + return LocaleKeys.questionBubble_help.tr(); + case BubbleAction.debug: + return LocaleKeys.questionBubble_debug_name.tr(); + case BubbleAction.shortcuts: + return LocaleKeys.questionBubble_shortcuts.tr(); + case BubbleAction.markdown: + return LocaleKeys.questionBubble_markdown.tr(); + case BubbleAction.github: + return LocaleKeys.questionBubble_feedback.tr(); + } + } + + Widget? get icons { + switch (this) { + case BubbleAction.whatsNews: + return const FlowySvg(FlowySvgs.star_s); + case BubbleAction.help: + return const FlowySvg(FlowySvgs.message_support_s); + case BubbleAction.debug: + return const FlowySvg(FlowySvgs.debug_s); + case BubbleAction.shortcuts: + return const FlowySvg(FlowySvgs.keyboard_s); + case BubbleAction.markdown: + return const FlowySvg(FlowySvgs.number_s); + case BubbleAction.github: + return const FlowySvg(FlowySvgs.share_feedback_s); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart new file mode 100644 index 0000000000000..f5c8ffa146145 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart @@ -0,0 +1,109 @@ +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; + +class SocialMediaSection extends CustomActionCell { + @override + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { + final List children = [ + Divider( + height: 1, + color: Theme.of(context).dividerColor, + thickness: 1.0, + ), + ]; + + children.addAll( + SocialMedia.values.map( + (social) { + return ActionCellWidget( + action: SocialMediaWrapper(social), + itemHeight: ActionListSizes.itemHeight, + onSelected: (action) { + switch (action.inner) { + case SocialMedia.reddit: + afLaunchUrlString( + 'https://www.reddit.com/r/AppFlowy/', + ); + case SocialMedia.twitter: + afLaunchUrlString( + 'https://x.com/appflowy', + ); + case SocialMedia.forum: + afLaunchUrlString('https://forum.appflowy.io/'); + } + }, + ); + }, + ), + ); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + children: children, + ), + ); + } +} + +enum SocialMedia { forum, twitter, reddit } + +class SocialMediaWrapper extends ActionCell { + SocialMediaWrapper(this.inner); + + final SocialMedia inner; + @override + Widget? leftIcon(Color iconColor) => inner.icons; + + @override + String get name => inner.name; + + @override + Color? textColor(BuildContext context) => inner.textColor(context); +} + +extension QuestionBubbleExtension on SocialMedia { + Color? textColor(BuildContext context) { + switch (this) { + case SocialMedia.reddit: + return Theme.of(context).hintColor; + + case SocialMedia.twitter: + return Theme.of(context).hintColor; + + case SocialMedia.forum: + return Theme.of(context).hintColor; + + default: + return null; + } + } + + String get name { + switch (this) { + case SocialMedia.forum: + return "Community Forum"; + case SocialMedia.twitter: + return "Twitter – @appflowy"; + case SocialMedia.reddit: + return "Reddit – r/appflowy"; + } + } + + Widget? get icons { + switch (this) { + case SocialMedia.reddit: + return null; + case SocialMedia.twitter: + return null; + case SocialMedia.forum: + return null; + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart new file mode 100644 index 0000000000000..923f69518876e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart @@ -0,0 +1,77 @@ +import 'package:appflowy/env/env.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class FlowyVersionSection extends CustomActionCell { + @override + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { + return FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.hasError) { + return FlowyText( + "Error: ${snapshot.error}", + color: Theme.of(context).disabledColor, + ); + } + + final PackageInfo packageInfo = snapshot.data; + final String appName = packageInfo.appName; + final String version = packageInfo.version; + + return SizedBox( + height: 30, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Divider( + height: 1, + color: Theme.of(context).dividerColor, + thickness: 1.0, + ), + const VSpace(6), + GestureDetector( + behavior: HitTestBehavior.opaque, + onDoubleTap: () { + if (Env.internalBuild != '1' && !kDebugMode) { + return; + } + enableDocumentInternalLog = !enableDocumentInternalLog; + showToastNotification( + context, + message: enableDocumentInternalLog + ? 'Enabled Internal Log' + : 'Disabled Internal Log', + ); + }, + child: FlowyText( + '$appName $version', + color: Theme.of(context).hintColor, + fontSize: 12, + ).padding( + horizontal: ActionListSizes.itemHPadding, + ), + ), + ], + ), + ); + } else { + return const SizedBox(height: 30); + } + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/image_provider.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/image_provider.dart new file mode 100644 index 0000000000000..7fb9f1edbea56 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/image_provider.dart @@ -0,0 +1,68 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flutter/widgets.dart'; + +/// Abstract class for providing images to the [InteractiveImageViewer]. +/// +abstract class AFImageProvider { + const AFImageProvider({this.onDeleteImage}); + + /// Provide this callback if you want it to be possible to + /// delete the Image through the [InteractiveImageViewer]. + /// + final Function(int index)? onDeleteImage; + + int get imageCount; + int get initialIndex; + + ImageBlockData getImage(int index); + Widget renderImage( + BuildContext context, + int index, [ + UserProfilePB? userProfile, + ]); +} + +class AFBlockImageProvider implements AFImageProvider { + const AFBlockImageProvider({ + required this.images, + this.initialIndex = 0, + required this.onDeleteImage, + }); + + final List images; + + @override + final Function(int) onDeleteImage; + + @override + final int initialIndex; + + @override + int get imageCount => images.length; + + @override + ImageBlockData getImage(int index) => images[index]; + + @override + Widget renderImage( + BuildContext context, + int index, [ + UserProfilePB? userProfile, + ]) { + final image = getImage(index); + + if (image.type == CustomImageType.local && + localPathRegex.hasMatch(image.url)) { + return Image(image: image.toImageProvider()); + } + + return FlowyNetworkImage( + url: image.url, + userProfilePB: userProfile, + fit: BoxFit.contain, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart new file mode 100644 index 0000000000000..a602aaed58538 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart @@ -0,0 +1,336 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_impl.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class InteractiveImageToolbar extends StatelessWidget { + const InteractiveImageToolbar({ + super.key, + required this.currentImage, + required this.imageCount, + required this.isFirstIndex, + required this.isLastIndex, + required this.currentScale, + required this.onPrevious, + required this.onNext, + required this.onZoomIn, + required this.onZoomOut, + required this.onScaleChanged, + this.onDelete, + this.userProfile, + }); + + final ImageBlockData currentImage; + final int imageCount; + final bool isFirstIndex; + final bool isLastIndex; + final int currentScale; + + final VoidCallback onPrevious; + final VoidCallback onNext; + final VoidCallback onZoomIn; + final VoidCallback onZoomOut; + final Function(double scale) onScaleChanged; + final UserProfilePB? userProfile; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 16, + left: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: 200, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (imageCount > 1) + _renderToolbarItems( + children: [ + _ToolbarItem( + isDisabled: isFirstIndex, + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_previousImageTooltip + .tr(), + icon: FlowySvgs.arrow_left_s, + onTap: () { + if (!isFirstIndex) { + onPrevious(); + } + }, + ), + _ToolbarItem( + isDisabled: isLastIndex, + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_nextImageTooltip + .tr(), + icon: FlowySvgs.arrow_right_s, + onTap: () { + if (!isLastIndex) { + onNext(); + } + }, + ), + ], + ), + const HSpace(10), + _renderToolbarItems( + children: [ + _ToolbarItem( + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_zoomOutTooltip + .tr(), + icon: FlowySvgs.minus_s, + onTap: onZoomOut, + ), + AppFlowyPopover( + offset: const Offset(0, -8), + decorationColor: Colors.transparent, + direction: PopoverDirection.topWithCenterAligned, + constraints: const BoxConstraints(maxHeight: 50), + popupBuilder: (context) => _renderToolbarItems( + children: [ + _ScaleSlider( + currentScale: currentScale, + onScaleChanged: onScaleChanged, + ), + ], + ), + child: FlowyTooltip( + message: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_changeZoomLevelTooltip + .tr(), + child: FlowyHover( + resetHoverOnRebuild: false, + style: HoverStyle( + hoverColor: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.all(6), + child: SizedBox( + width: 40, + child: Center( + child: FlowyText( + LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_scalePercentage + .tr(args: [currentScale.toString()]), + color: Colors.white, + ), + ), + ), + ), + ), + ), + ), + _ToolbarItem( + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_zoomInTooltip + .tr(), + icon: FlowySvgs.add_s, + onTap: onZoomIn, + ), + ], + ), + const HSpace(10), + _renderToolbarItems( + children: [ + if (onDelete != null) + _ToolbarItem( + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_deleteImageTooltip + .tr(), + icon: FlowySvgs.delete_s, + onTap: () { + onDelete!(); + Navigator.of(context).pop(); + }, + ), + if (!UniversalPlatform.isMobile) ...[ + _ToolbarItem( + tooltip: currentImage.isNotInternal + ? LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_openLocalImage + .tr() + : LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_downloadImage + .tr(), + icon: currentImage.isNotInternal + ? currentImage.isLocal + ? FlowySvgs.folder_m + : FlowySvgs.m_aa_link_s + : FlowySvgs.download_s, + onTap: () => _locateOrDownloadImage(context), + ), + ], + ], + ), + const HSpace(10), + _renderToolbarItems( + children: [ + _ToolbarItem( + tooltip: LocaleKeys + .document_imageBlock_interactiveViewer_toolbar_closeViewer + .tr(), + icon: FlowySvgs.close_viewer_s, + onTap: () => Navigator.of(context).pop(), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _renderToolbarItems({required List children}) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: Colors.black.withOpacity(0.6), + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: SeparatedRow( + mainAxisSize: MainAxisSize.min, + separatorBuilder: () => const HSpace(4), + children: children, + ), + ), + ); + } + + Future _locateOrDownloadImage(BuildContext context) async { + if (currentImage.isLocal || currentImage.isNotInternal) { + /// If the image type is local, we simply open the image + /// + /// // In case of eg. Unsplash images (images without extension type in URL), + // we don't know their mimetype. In the future we can write a parser + // using the Mime package and read the image to get the proper extension. + await afLaunchUrlString(currentImage.url); + } else { + if (userProfile == null) { + return showSnapBar( + context, + LocaleKeys.document_plugins_image_imageDownloadFailedToken.tr(), + ); + } + + final uri = Uri.parse(currentImage.url); + final imgFile = File(uri.pathSegments.last); + final savePath = await FilePicker().saveFile( + fileName: basename(imgFile.path), + ); + + if (savePath != null) { + final uri = Uri.parse(currentImage.url); + + final token = jsonDecode(userProfile!.token)['access_token']; + final response = await http.get( + uri, + headers: {'Authorization': 'Bearer $token'}, + ); + if (response.statusCode == 200) { + final imgFile = File(savePath); + await imgFile.writeAsBytes(response.bodyBytes); + } else if (context.mounted) { + showSnapBar( + context, + LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), + ); + } + } + } + } +} + +class _ToolbarItem extends StatelessWidget { + const _ToolbarItem({ + required this.tooltip, + required this.icon, + required this.onTap, + this.isDisabled = false, + }); + + final String tooltip; + final FlowySvgData icon; + final VoidCallback onTap; + final bool isDisabled; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: FlowyTooltip( + message: tooltip, + child: FlowyHover( + resetHoverOnRebuild: false, + style: HoverStyle( + hoverColor: + isDisabled ? Colors.transparent : Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Container( + width: 32, + height: 32, + padding: const EdgeInsets.all(8), + child: FlowySvg( + icon, + color: isDisabled ? Colors.grey : Colors.white, + ), + ), + ), + ), + ); + } +} + +class _ScaleSlider extends StatefulWidget { + const _ScaleSlider({ + required this.currentScale, + required this.onScaleChanged, + }); + + final int currentScale; + final Function(double scale) onScaleChanged; + + @override + State<_ScaleSlider> createState() => __ScaleSliderState(); +} + +class __ScaleSliderState extends State<_ScaleSlider> { + late int _currentScale = widget.currentScale; + + @override + Widget build(BuildContext context) { + return Slider( + max: 5.0, + min: 0.5, + value: _currentScale / 100, + onChanged: (scale) { + widget.onScaleChanged(scale); + setState( + () => _currentScale = (scale * 100).toInt(), + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart new file mode 100644 index 0000000000000..1678be5a163b5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:provider/provider.dart'; + +const double _minScaleFactor = .5; +const double _maxScaleFactor = 5; + +class InteractiveImageViewer extends StatefulWidget { + const InteractiveImageViewer({ + super.key, + this.userProfile, + required this.imageProvider, + }); + + final UserProfilePB? userProfile; + final AFImageProvider imageProvider; + + @override + State createState() => _InteractiveImageViewerState(); +} + +class _InteractiveImageViewerState extends State { + final TransformationController controller = TransformationController(); + final focusNode = FocusNode(); + + int currentScale = 100; + late int currentIndex = widget.imageProvider.initialIndex; + + bool get isLastIndex => currentIndex == widget.imageProvider.imageCount - 1; + bool get isFirstIndex => currentIndex == 0; + + late ImageBlockData currentImage; + + UserProfilePB? userProfile; + + @override + void initState() { + super.initState(); + controller.addListener(_onControllerChanged); + currentImage = widget.imageProvider.getImage(currentIndex); + userProfile = + widget.userProfile ?? context.read().state.userProfilePB; + focusNode.requestFocus(); + } + + void _onControllerChanged() { + final scale = controller.value.getMaxScaleOnAxis(); + final percentage = (scale * 100).toInt(); + setState(() => currentScale = percentage); + } + + @override + void dispose() { + controller.removeListener(_onControllerChanged); + controller.dispose(); + focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + + return KeyboardListener( + focusNode: focusNode, + onKeyEvent: (event) { + if (event is! KeyDownEvent) { + return; + } + + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + _move(-1); + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + _move(1); + } else if ([ + LogicalKeyboardKey.add, + LogicalKeyboardKey.numpadAdd, + ].contains(event.logicalKey)) { + _zoom(1.1, size); + } else if ([ + LogicalKeyboardKey.minus, + LogicalKeyboardKey.numpadSubtract, + ].contains(event.logicalKey)) { + _zoom(.9, size); + } else if ([ + LogicalKeyboardKey.numpad0, + LogicalKeyboardKey.digit0, + ].contains(event.logicalKey)) { + controller.value = Matrix4.identity(); + _onControllerChanged(); + } + }, + child: Stack( + fit: StackFit.expand, + children: [ + SizedBox.expand( + child: InteractiveViewer( + boundaryMargin: const EdgeInsets.all(double.infinity), + transformationController: controller, + constrained: false, + minScale: _minScaleFactor, + maxScale: _maxScaleFactor, + scaleFactor: 500, + child: SizedBox( + height: size.height, + width: size.width, + child: GestureDetector( + // We can consider adding zoom behavior instead in a later iteration + onDoubleTap: () => Navigator.of(context).pop(), + child: widget.imageProvider.renderImage( + context, + currentIndex, + userProfile, + ), + ), + ), + ), + ), + InteractiveImageToolbar( + currentImage: currentImage, + imageCount: widget.imageProvider.imageCount, + isFirstIndex: isFirstIndex, + isLastIndex: isLastIndex, + currentScale: currentScale, + userProfile: userProfile, + onPrevious: () => _move(-1), + onNext: () => _move(1), + onZoomIn: () => _zoom(1.1, size), + onZoomOut: () => _zoom(.9, size), + onScaleChanged: (scale) { + final currentScale = controller.value.getMaxScaleOnAxis(); + final scaleStep = scale / currentScale; + _zoom(scaleStep, size); + }, + onDelete: () => + widget.imageProvider.onDeleteImage?.call(currentIndex), + ), + ], + ), + ); + } + + void _move(int steps) { + setState(() { + final index = currentIndex + steps; + currentIndex = index.clamp(0, widget.imageProvider.imageCount - 1); + currentImage = widget.imageProvider.getImage(currentIndex); + }); + } + + void _zoom(double scaleStep, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final scenePointBefore = controller.toScene(center); + final currentScale = controller.value.getMaxScaleOnAxis(); + final newScale = (currentScale * scaleStep).clamp( + _minScaleFactor, + _maxScaleFactor, + ); + + // Create a new transformation + final newMatrix = Matrix4.identity() + ..translate(scenePointBefore.dx, scenePointBefore.dy) + ..scale(newScale / currentScale) + ..translate(-scenePointBefore.dx, -scenePointBefore.dy); + + // Apply the new transformation + controller.value = newMatrix * controller.value; + + // Convert the center point to scene coordinates after scaling + final scenePointAfter = controller.toScene(center); + + // Compute difference to keep the same center point + final dx = scenePointAfter.dx - scenePointBefore.dx; + final dy = scenePointAfter.dy - scenePointBefore.dy; + + // Apply the translation + controller.value = Matrix4.identity() + ..translate(-dx, -dy) + ..multiply(controller.value); + + _onControllerChanged(); + } +} + +void openInteractiveViewerFromFile( + BuildContext context, + MediaFilePB file, { + required void Function(int) onDeleteImage, + UserProfilePB? userProfile, +}) => + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: userProfile, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: file.url, + type: file.uploadType.toCustomImageType(), + ), + ], + onDeleteImage: onDeleteImage, + ), + ), + ); + +void openInteractiveViewerFromFiles( + BuildContext context, + List files, { + required void Function(int) onDeleteImage, + int initialIndex = 0, + UserProfilePB? userProfile, +}) => + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: userProfile, + imageProvider: AFBlockImageProvider( + initialIndex: initialIndex, + images: files + .map( + (f) => ImageBlockData( + url: f.url, + type: f.uploadType.toCustomImageType(), + ), + ) + .toList(), + onDeleteImage: onDeleteImage, + ), + ), + ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart new file mode 100644 index 0000000000000..09885dc796c91 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart @@ -0,0 +1,165 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_action.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MoreViewActions extends StatefulWidget { + const MoreViewActions({ + super.key, + required this.view, + this.isDocument = true, + }); + + /// The view to show the actions for. + final ViewPB view; + + /// If false the view is a Database, otherwise it is a Document. + final bool isDocument; + + @override + State createState() => _MoreViewActionsState(); +} + +class _MoreViewActionsState extends State { + final popoverMutex = PopoverMutex(); + + @override + void dispose() { + popoverMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return AppFlowyPopover( + mutex: popoverMutex, + constraints: const BoxConstraints(maxWidth: 220), + offset: const Offset(0, 42), + popupBuilder: (_) => _buildPopup(state), + child: const _ThreeDots(), + ); + }, + ); + } + + Widget _buildPopup(ViewInfoState state) { + final userWorkspaceBloc = context.read(); + final userProfile = userWorkspaceBloc.userProfile; + final workspaceId = + userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? ''; + final actions = _buildActions(state); + + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => + ViewBloc(view: widget.view)..add(const ViewEvent.initial()), + ), + BlocProvider( + create: (context) => SpaceBloc( + userProfile: userProfile, + workspaceId: workspaceId, + )..add( + const SpaceEvent.initial(openFirstPage: false), + ), + ), + ], + child: BlocBuilder( + builder: (context, state) { + if (state.spaces.isEmpty && + userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) { + return const SizedBox.shrink(); + } + + return ListView.builder( + key: ValueKey(state.spaces.hashCode), + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: actions.length, + physics: StyledScrollPhysics(), + itemBuilder: (_, index) => actions[index], + ); + }, + ), + ); + } + + List _buildActions(ViewInfoState state) { + final appearanceSettings = context.watch().state; + final dateFormat = appearanceSettings.dateFormat; + final timeFormat = appearanceSettings.timeFormat; + + final viewMoreActionTypes = [ + if (widget.isDocument) ViewMoreActionType.divider, + ViewMoreActionType.duplicate, + ViewMoreActionType.moveTo, + ViewMoreActionType.delete, + ViewMoreActionType.divider, + ]; + + final actions = [ + if (widget.isDocument) ...[ + const FontSizeAction(), + ], + ...viewMoreActionTypes.map( + (type) => ViewAction( + type: type, + view: widget.view, + mutex: popoverMutex, + ), + ), + if (state.documentCounters != null || state.createdAt != null) ...[ + ViewMetaInfo( + dateFormat: dateFormat, + timeFormat: timeFormat, + documentCounters: state.documentCounters, + createdAt: state.createdAt, + ), + const VSpace(4.0), + ], + ]; + return actions; + } +} + +class _ThreeDots extends StatelessWidget { + const _ThreeDots(); + + @override + Widget build(BuildContext context) { + return FlowyTooltip( + message: LocaleKeys.moreAction_moreOptions.tr(), + child: FlowyHover( + style: HoverStyle( + foregroundColorOnHover: Theme.of(context).colorScheme.onPrimary, + ), + builder: (context, isHovering) => Padding( + padding: const EdgeInsets.all(6), + child: FlowySvg( + FlowySvgs.three_dots_s, + size: const Size.square(18), + color: isHovering + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).iconTheme.color, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart new file mode 100644 index 0000000000000..5d0cf62f739c8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart @@ -0,0 +1,99 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart'; +import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ViewAction extends StatelessWidget { + const ViewAction({ + super.key, + required this.type, + required this.view, + this.mutex, + }); + + final ViewMoreActionType type; + final ViewPB view; + final PopoverMutex? mutex; + + @override + Widget build(BuildContext context) { + final wrapper = ViewMoreActionTypeWrapper( + type, + view, + (controller, data) async { + await _onAction(context, data); + mutex?.close(); + }, + moveActionDirection: PopoverDirection.leftWithTopAligned, + moveActionOffset: const Offset(-10, 0), + ); + return wrapper.buildWithContext( + context, + // this is a dummy controller, we don't need to control the popover here. + PopoverController(), + null, + ); + } + + Future _onAction( + BuildContext context, + dynamic data, + ) async { + switch (type) { + case ViewMoreActionType.delete: + final (containPublishedPage, _) = + await ViewBackendService.containPublishedPage(view); + + if (containPublishedPage && context.mounted) { + await showConfirmDeletionDialog( + context: context, + name: view.nameOrDefault, + description: LocaleKeys.publish_containsPublishedPage.tr(), + onConfirm: () { + context.read().add(const ViewEvent.delete()); + }, + ); + } else if (context.mounted) { + context.read().add(const ViewEvent.delete()); + } + case ViewMoreActionType.duplicate: + context.read().add(const ViewEvent.duplicate()); + case ViewMoreActionType.moveTo: + final value = data; + if (value is! (ViewPB, ViewPB)) { + return; + } + final space = value.$1; + final target = value.$2; + final result = await ViewBackendService.getView(view.parentViewId); + result.fold( + (parentView) => moveViewCrossSpace( + context, + space, + view, + parentView, + FolderSpaceType.public, + view, + target.id, + ), + (f) => Log.error(f), + ); + + // the move action is handled in the button itself + break; + default: + throw UnimplementedError(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_action.dart new file mode 100644 index 0000000000000..8e0fa8c43ce02 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_action.dart @@ -0,0 +1,54 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class FontSizeAction extends StatelessWidget { + const FontSizeAction({super.key}); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.leftWithCenterAligned, + constraints: const BoxConstraints(maxHeight: 40, maxWidth: 240), + offset: const Offset(-10, 0), + popupBuilder: (context) { + return BlocBuilder( + builder: (_, state) => FontSizeStepper( + minimumValue: 10, + maximumValue: 24, + value: state.fontSize, + divisions: 8, + onChanged: (newFontSize) => context + .read() + .syncFontSize(newFontSize), + ), + ); + }, + child: Container( + height: 34, + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: FlowyButton( + text: FlowyText.regular( + LocaleKeys.moreAction_fontSize.tr(), + fontSize: 14.0, + lineHeight: 1.0, + figmaLineHeight: 18.0, + color: AFThemeExtension.of(context).textColor, + ), + leftIcon: Icon( + Icons.format_size_sharp, + color: Theme.of(context).iconTheme.color, + size: 18, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart new file mode 100644 index 0000000000000..67261598ff173 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; + +class FontSizeStepper extends StatefulWidget { + const FontSizeStepper({ + super.key, + required this.minimumValue, + required this.maximumValue, + required this.value, + required this.divisions, + required this.onChanged, + }); + + final double minimumValue; + final double maximumValue; + final double value; + final ValueChanged onChanged; + final int divisions; + + @override + State createState() => _FontSizeStepperState(); +} + +class _FontSizeStepperState extends State { + late double _value = widget.value.clamp( + widget.minimumValue, + widget.maximumValue, + ); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + const FlowyText('A', fontSize: 14), + const HSpace(6), + Expanded( + child: SliderTheme( + data: Theme.of(context).sliderTheme.copyWith( + showValueIndicator: ShowValueIndicator.never, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 8, + ), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 16, + ), + ), + child: Slider( + value: _value, + min: widget.minimumValue, + max: widget.maximumValue, + divisions: widget.divisions, + onChanged: (value) { + setState(() => _value = value); + widget.onChanged(value); + }, + ), + ), + ), + const HSpace(6), + const FlowyText('A', fontSize: 20), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart new file mode 100644 index 0000000000000..5b746a2aa3d2e --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart @@ -0,0 +1,70 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; + +class ViewMetaInfo extends StatelessWidget { + const ViewMetaInfo({ + super.key, + required this.dateFormat, + required this.timeFormat, + this.documentCounters, + this.createdAt, + }); + + final UserDateFormatPB dateFormat; + final UserTimeFormatPB timeFormat; + final Counters? documentCounters; + final DateTime? createdAt; + + @override + Widget build(BuildContext context) { + final numberFormat = NumberFormat(); + + // If more info is added to this Widget, use a separated ListView + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (documentCounters != null) ...[ + FlowyText.regular( + LocaleKeys.moreAction_wordCount.tr( + args: [ + numberFormat.format(documentCounters!.wordCount).toString(), + ], + ), + fontSize: 12, + color: Theme.of(context).hintColor, + ), + const VSpace(2), + FlowyText.regular( + LocaleKeys.moreAction_charCount.tr( + args: [ + numberFormat.format(documentCounters!.charCount).toString(), + ], + ), + fontSize: 12, + color: Theme.of(context).hintColor, + ), + ], + if (createdAt != null) ...[ + if (documentCounters != null) const VSpace(2), + FlowyText.regular( + LocaleKeys.moreAction_createdAt.tr( + args: [dateFormat.formatDate(createdAt!, true, timeFormat)], + ), + fontSize: 12, + maxLines: 2, + color: Theme.of(context).hintColor, + ), + ], + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart new file mode 100644 index 0000000000000..fb39d73965717 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart @@ -0,0 +1,313 @@ +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class PopoverActionList extends StatefulWidget { + const PopoverActionList({ + super.key, + this.controller, + this.popoverMutex, + required this.actions, + required this.buildChild, + required this.onSelected, + this.mutex, + this.onClosed, + this.onPopupBuilder, + this.direction = PopoverDirection.rightWithTopAligned, + this.asBarrier = false, + this.offset = Offset.zero, + this.animationDuration = const Duration(), + this.slideDistance = 20, + this.beginScaleFactor = 0.9, + this.endScaleFactor = 1.0, + this.beginOpacity = 0.0, + this.endOpacity = 1.0, + this.constraints = const BoxConstraints( + minWidth: 120, + maxWidth: 460, + maxHeight: 300, + ), + this.showAtCursor = false, + }); + + final PopoverController? controller; + final PopoverMutex? popoverMutex; + final List actions; + final Widget Function(PopoverController) buildChild; + final Function(T, PopoverController) onSelected; + final PopoverMutex? mutex; + final VoidCallback? onClosed; + final VoidCallback? onPopupBuilder; + final PopoverDirection direction; + final bool asBarrier; + final Offset offset; + final BoxConstraints constraints; + final Duration animationDuration; + final double slideDistance; + final double beginScaleFactor; + final double endScaleFactor; + final double beginOpacity; + final double endOpacity; + final bool showAtCursor; + + @override + State> createState() => _PopoverActionListState(); +} + +class _PopoverActionListState + extends State> { + late PopoverController popoverController = + widget.controller ?? PopoverController(); + + @override + void dispose() { + if (widget.controller == null) { + popoverController.close(); + } + super.dispose(); + } + + @override + void didUpdateWidget(covariant PopoverActionList oldWidget) { + if (widget.controller != oldWidget.controller) { + popoverController.close(); + popoverController = widget.controller ?? PopoverController(); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + final child = widget.buildChild(popoverController); + return AppFlowyPopover( + asBarrier: widget.asBarrier, + animationDuration: widget.animationDuration, + slideDistance: widget.slideDistance, + beginScaleFactor: widget.beginScaleFactor, + endScaleFactor: widget.endScaleFactor, + beginOpacity: widget.beginOpacity, + endOpacity: widget.endOpacity, + controller: popoverController, + constraints: widget.constraints, + direction: widget.direction, + mutex: widget.mutex, + offset: widget.offset, + triggerActions: PopoverTriggerFlags.none, + onClose: widget.onClosed, + showAtCursor: widget.showAtCursor, + popupBuilder: (_) { + widget.onPopupBuilder?.call(); + final List children = widget.actions.map((action) { + if (action is ActionCell) { + return ActionCellWidget( + action: action, + itemHeight: ActionListSizes.itemHeight, + onSelected: (action) { + widget.onSelected(action, popoverController); + }, + ); + } else if (action is PopoverActionCell) { + return PopoverActionCellWidget( + popoverMutex: widget.popoverMutex, + popoverController: popoverController, + action: action, + itemHeight: ActionListSizes.itemHeight, + ); + } else { + final custom = action as CustomActionCell; + return custom.buildWithContext( + context, + popoverController, + widget.popoverMutex, + ); + } + }).toList(); + + return IntrinsicHeight( + child: IntrinsicWidth( + child: Column(children: children), + ), + ); + }, + child: child, + ); + } +} + +abstract class ActionCell extends PopoverAction { + Widget? leftIcon(Color iconColor) => null; + Widget? rightIcon(Color iconColor) => null; + String get name; + Color? textColor(BuildContext context) { + return null; + } +} + +typedef PopoverActionCellBuilder = Widget Function( + BuildContext context, + PopoverController parentController, + PopoverController controller, +); + +abstract class PopoverActionCell extends PopoverAction { + Widget? leftIcon(Color iconColor) => null; + Widget? rightIcon(Color iconColor) => null; + String get name; + + PopoverActionCellBuilder get builder; +} + +abstract class CustomActionCell extends PopoverAction { + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ); +} + +abstract class PopoverAction {} + +class ActionListSizes { + static double itemHPadding = 10; + static double itemHeight = 20; + static double vPadding = 6; + static double hPadding = 10; +} + +class ActionCellWidget extends StatelessWidget { + const ActionCellWidget({ + super.key, + required this.action, + required this.onSelected, + required this.itemHeight, + }); + + final T action; + final Function(T) onSelected; + final double itemHeight; + + @override + Widget build(BuildContext context) { + final actionCell = action as ActionCell; + final leftIcon = + actionCell.leftIcon(Theme.of(context).colorScheme.onSurface); + + final rightIcon = + actionCell.rightIcon(Theme.of(context).colorScheme.onSurface); + + return HoverButton( + itemHeight: itemHeight, + leftIcon: leftIcon, + rightIcon: rightIcon, + name: actionCell.name, + textColor: actionCell.textColor(context), + onTap: () => onSelected(action), + ); + } +} + +class PopoverActionCellWidget extends StatefulWidget { + const PopoverActionCellWidget({ + super.key, + this.popoverMutex, + required this.popoverController, + required this.action, + required this.itemHeight, + }); + + final PopoverMutex? popoverMutex; + final T action; + final double itemHeight; + + final PopoverController popoverController; + + @override + State createState() => + _PopoverActionCellWidgetState(); +} + +class _PopoverActionCellWidgetState + extends State> { + final popoverController = PopoverController(); + @override + Widget build(BuildContext context) { + final actionCell = widget.action as PopoverActionCell; + final leftIcon = + actionCell.leftIcon(Theme.of(context).colorScheme.onSurface); + final rightIcon = + actionCell.rightIcon(Theme.of(context).colorScheme.onSurface); + return AppFlowyPopover( + mutex: widget.popoverMutex, + controller: popoverController, + asBarrier: true, + popupBuilder: (context) => actionCell.builder( + context, + widget.popoverController, + popoverController, + ), + child: HoverButton( + itemHeight: widget.itemHeight, + leftIcon: leftIcon, + rightIcon: rightIcon, + name: actionCell.name, + onTap: () => popoverController.show(), + ), + ); + } +} + +class HoverButton extends StatelessWidget { + const HoverButton({ + super.key, + required this.onTap, + required this.itemHeight, + this.leftIcon, + required this.name, + this.rightIcon, + this.textColor, + }); + + final VoidCallback onTap; + final double itemHeight; + final Widget? leftIcon; + final Widget? rightIcon; + final String name; + final Color? textColor; + + @override + Widget build(BuildContext context) { + return FlowyHover( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + height: itemHeight, + child: Row( + children: [ + if (leftIcon != null) ...[ + leftIcon!, + HSpace(ActionListSizes.itemHPadding), + ], + Expanded( + child: FlowyText.regular( + name, + overflow: TextOverflow.visible, + lineHeight: 1.15, + color: textColor, + ), + ), + if (rightIcon != null) ...[ + HSpace(ActionListSizes.itemHPadding), + rightIcon!, + ], + ], + ), + ).padding( + horizontal: ActionListSizes.hPadding, + vertical: ActionListSizes.vPadding, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/rename_view_popover.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/rename_view_popover.dart new file mode 100644 index 0000000000000..ccea2f895cda1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/rename_view_popover.dart @@ -0,0 +1,103 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; + +class RenameViewPopover extends StatefulWidget { + const RenameViewPopover({ + super.key, + required this.viewId, + required this.name, + required this.popoverController, + required this.emoji, + this.icon, + this.showIconChanger = true, + }); + + final String viewId; + final String name; + final PopoverController popoverController; + final EmojiIconData emoji; + final Widget? icon; + final bool showIconChanger; + + @override + State createState() => _RenameViewPopoverState(); +} + +class _RenameViewPopoverState extends State { + final TextEditingController _controller = TextEditingController(); + + @override + void initState() { + super.initState(); + _controller.text = widget.name; + _controller.selection = + TextSelection(baseOffset: 0, extentOffset: widget.name.length); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.showIconChanger) ...[ + SizedBox( + width: 30.0, + child: EmojiPickerButton( + emoji: widget.emoji, + defaultIcon: widget.icon, + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 18), + onSubmitted: _updateViewIcon, + ), + ), + const HSpace(6), + ], + SizedBox( + height: 32.0, + width: 220, + child: FlowyTextField( + controller: _controller, + maxLength: 256, + onSubmitted: _updateViewName, + onCanceled: () => _updateViewName(_controller.text), + showCounter: false, + ), + ), + ], + ); + } + + Future _updateViewName(String name) async { + if (name.isNotEmpty && name != widget.name) { + await ViewBackendService.updateView( + viewId: widget.viewId, + name: _controller.text, + ); + widget.popoverController.close(); + } + } + + Future _updateViewIcon( + EmojiIconData emoji, + PopoverController? _, + ) async { + await ViewBackendService.updateViewIcon( + viewId: widget.viewId, + viewIcon: emoji, + ); + widget.popoverController.close(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/sidebar_resizer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/sidebar_resizer.dart new file mode 100644 index 0000000000000..f229951e04aab --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/sidebar_resizer.dart @@ -0,0 +1,84 @@ +import 'package:appflowy/workspace/application/home/home_setting_bloc.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class SidebarResizer extends StatefulWidget { + const SidebarResizer({super.key}); + + @override + State createState() => _SidebarResizerState(); +} + +class _SidebarResizerState extends State { + final ValueNotifier isHovered = ValueNotifier(false); + final ValueNotifier isDragging = ValueNotifier(false); + + @override + void dispose() { + isHovered.dispose(); + isDragging.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + onEnter: (_) => isHovered.value = true, + onExit: (_) => isHovered.value = false, + child: GestureDetector( + dragStartBehavior: DragStartBehavior.down, + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (details) { + isDragging.value = true; + + context + .read() + .add(const HomeSettingEvent.editPanelResizeStart()); + }, + onHorizontalDragUpdate: (details) { + isDragging.value = true; + + context + .read() + .add(HomeSettingEvent.editPanelResized(details.localPosition.dx)); + }, + onHorizontalDragEnd: (details) { + isDragging.value = false; + + context + .read() + .add(const HomeSettingEvent.editPanelResizeEnd()); + }, + onHorizontalDragCancel: () { + isDragging.value = false; + + context + .read() + .add(const HomeSettingEvent.editPanelResizeEnd()); + }, + child: ValueListenableBuilder( + valueListenable: isHovered, + builder: (context, isHovered, _) { + return ValueListenableBuilder( + valueListenable: isDragging, + builder: (context, isDragging, _) { + return Container( + width: 2, + // increase the width of the resizer to make it easier to drag + margin: const EdgeInsets.only(right: 2.0), + height: MediaQuery.of(context).size.height, + color: isHovered || isDragging + ? const Color(0xFF00B5FF) + : Colors.transparent, + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/tab_bar_item.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/tab_bar_item.dart new file mode 100644 index 0000000000000..4ce5a93ab02e4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/tab_bar_item.dart @@ -0,0 +1,71 @@ +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/workspace/application/view/view_listener.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class ViewTabBarItem extends StatefulWidget { + const ViewTabBarItem({ + super.key, + required this.view, + this.shortForm = false, + }); + + final ViewPB view; + final bool shortForm; + + @override + State createState() => _ViewTabBarItemState(); +} + +class _ViewTabBarItemState extends State { + late final ViewListener _viewListener; + late ViewPB view; + + @override + void initState() { + super.initState(); + view = widget.view; + _viewListener = ViewListener(viewId: widget.view.id); + _viewListener.start( + onViewUpdated: (updatedView) { + if (mounted) { + setState(() => view = updatedView); + } + }, + ); + } + + @override + void dispose() { + _viewListener.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: + widget.shortForm ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + if (widget.view.icon.value.isNotEmpty) + FlowyText.emoji( + widget.view.icon.value, + fontSize: 16.0, + figmaLineHeight: 20.0, + optimizeEmojiAlign: true, + ), + if (!widget.shortForm && view.icon.value.isNotEmpty) const HSpace(6), + if (!widget.shortForm || view.icon.value.isEmpty) ...[ + Flexible( + child: FlowyText.medium( + view.nameOrDefault, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/toggle/toggle.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/toggle/toggle.dart new file mode 100644 index 0000000000000..673f08c6680b9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/toggle/toggle.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra/theme_extension.dart'; + +class ToggleStyle { + const ToggleStyle({ + required this.height, + required this.width, + required this.thumbRadius, + }); + + const ToggleStyle.big() + : height = 16, + width = 27, + thumbRadius = 14; + + const ToggleStyle.small() + : height = 10, + width = 16, + thumbRadius = 8; + + const ToggleStyle.mobile() + : height = 24, + width = 42, + thumbRadius = 18; + + final double height; + final double width; + final double thumbRadius; +} + +class Toggle extends StatelessWidget { + const Toggle({ + super.key, + required this.value, + required this.onChanged, + this.style = const ToggleStyle.big(), + this.thumbColor, + this.activeBackgroundColor, + this.inactiveBackgroundColor, + this.padding = const EdgeInsets.all(8.0), + }); + + final bool value; + final void Function(bool) onChanged; + final ToggleStyle style; + final Color? thumbColor; + final Color? activeBackgroundColor; + final Color? inactiveBackgroundColor; + final EdgeInsets padding; + + @override + Widget build(BuildContext context) { + final backgroundColor = value + ? activeBackgroundColor ?? Theme.of(context).colorScheme.primary + : inactiveBackgroundColor ?? + AFThemeExtension.of(context).toggleButtonBGColor; + return GestureDetector( + onTap: () => onChanged(!value), + child: Padding( + padding: padding, + child: Stack( + children: [ + Container( + height: style.height, + width: style.width, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(style.height / 2), + ), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 150), + top: (style.height - style.thumbRadius) / 2, + left: value ? style.width - style.thumbRadius - 1 : 1, + child: Container( + height: style.thumbRadius, + width: style.thumbRadius, + decoration: BoxDecoration( + color: thumbColor ?? Colors.white, + borderRadius: BorderRadius.circular(style.thumbRadius / 2), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart new file mode 100644 index 0000000000000..adb3b1e454a39 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/user_avatar.dart @@ -0,0 +1,151 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/util/built_in_svgs.dart'; +import 'package:appflowy/util/color_generator/color_generator.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:string_validator/string_validator.dart'; + +class UserAvatar extends StatelessWidget { + const UserAvatar({ + super.key, + required this.iconUrl, + required this.name, + required this.size, + required this.fontSize, + this.isHovering = false, + this.decoration, + }); + + final String iconUrl; + final String name; + final double size; + final double fontSize; + final Decoration? decoration; + + // If true, a border will be applied on top of the avatar + final bool isHovering; + + @override + Widget build(BuildContext context) { + if (iconUrl.isEmpty) { + return _buildEmptyAvatar(context); + } else if (isURL(iconUrl)) { + return _buildUrlAvatar(context); + } else { + return _buildEmojiAvatar(context); + } + } + + Widget _buildEmptyAvatar(BuildContext context) { + final String nameOrDefault = _userName(name); + final Color color = ColorGenerator(name).toColor(); + const initialsCount = 2; + + // Taking the first letters of the name components and limiting to 2 elements + final nameInitials = nameOrDefault + .split(' ') + .where((element) => element.isNotEmpty) + .take(initialsCount) + .map((element) => element[0].toUpperCase()) + .join(); + + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: decoration ?? + BoxDecoration( + color: color, + shape: BoxShape.circle, + border: isHovering + ? Border.all( + color: _darken(color), + width: 4, + ) + : null, + ), + child: FlowyText.medium( + nameInitials, + color: Colors.black, + fontSize: fontSize, + ), + ); + } + + Widget _buildUrlAvatar(BuildContext context) { + return SizedBox.square( + dimension: size, + child: DecoratedBox( + decoration: decoration ?? + BoxDecoration( + shape: BoxShape.circle, + border: isHovering + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 4, + ) + : null, + ), + child: ClipRRect( + borderRadius: Corners.s5Border, + child: CircleAvatar( + backgroundColor: Colors.transparent, + child: Image.network( + iconUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _buildEmptyAvatar(context), + ), + ), + ), + ), + ); + } + + Widget _buildEmojiAvatar(BuildContext context) { + return SizedBox.square( + dimension: size, + child: DecoratedBox( + decoration: decoration ?? + BoxDecoration( + shape: BoxShape.circle, + border: isHovering + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 4, + ) + : null, + ), + child: ClipRRect( + borderRadius: Corners.s5Border, + child: CircleAvatar( + backgroundColor: Colors.transparent, + child: builtInSVGIcons.contains(iconUrl) + ? FlowySvg( + FlowySvgData('emoji/$iconUrl'), + blendMode: null, + ) + : FlowyText.emoji(iconUrl, fontSize: fontSize), + ), + ), + ), + ); + } + + /// Return the user name, if the user name is empty, + /// return the default user name. + /// + String _userName(String name) => + name.isEmpty ? LocaleKeys.defaultUsername.tr() : name; + + /// Used to darken the generated color for the hover border effect. + /// The color is darkened by 15% - Hence the 0.15 value. + /// + Color _darken(Color color) { + final hsl = HSLColor.fromColor(color); + return hsl.withLightness((hsl.lightness - 0.15).clamp(0.0, 1.0)).toColor(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart new file mode 100644 index 0000000000000..03eabab65c1f0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/view_title_bar.dart @@ -0,0 +1,347 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/plugin/plugin.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/application/view_title/view_title_bar_bloc.dart'; +import 'package:appflowy/workspace/application/view_title/view_title_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; + +// space name > ... > view_title +class ViewTitleBar extends StatelessWidget { + const ViewTitleBar({ + super.key, + required this.view, + }); + + final ViewPB view; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => ViewTitleBarBloc(view: view), + child: BlocBuilder( + builder: (context, state) { + final ancestors = state.ancestors; + if (ancestors.isEmpty) { + return const SizedBox.shrink(); + } + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + height: 24, + child: Row( + children: _buildViewTitles( + context, + ancestors, + state.isDeleted, + ), + ), + ), + ); + }, + ), + ); + } + + List _buildViewTitles( + BuildContext context, + List views, + bool isDeleted, + ) { + if (isDeleted) { + return _buildDeletedTitle(context, views.last); + } + + // if the level is too deep, only show the last two view, the first one view and the root view + // for example: + // if the views are [root, view1, view2, view3, view4, view5], only show [root, view1, ..., view4, view5] + // if the views are [root, view1, view2, view3], show [root, view1, view2, view3] + const lowerBound = 2; + final upperBound = views.length - 2; + bool hasAddedEllipsis = false; + final children = []; + + if (views.length <= 1) { + return []; + } + + // ignore the workspace name, use section name instead in the future + // skip the workspace view + for (var i = 1; i < views.length; i++) { + final view = views[i]; + + if (i >= lowerBound && i < upperBound) { + if (!hasAddedEllipsis) { + hasAddedEllipsis = true; + children.addAll([ + const FlowyText.regular(' ... '), + const FlowySvg(FlowySvgs.title_bar_divider_s), + ]); + } + continue; + } + + final child = FlowyTooltip( + key: ValueKey(view.id), + message: view.name, + child: ViewTitle( + view: view, + behavior: i == views.length - 1 + ? ViewTitleBehavior.editable // only the last one is editable + : ViewTitleBehavior.uneditable, // others are not editable + onUpdated: () { + context + .read() + .add(const ViewTitleBarEvent.reload()); + }, + ), + ); + + children.add(child); + + if (i != views.length - 1) { + // if not the last one, add a divider + children.add(const FlowySvg(FlowySvgs.title_bar_divider_s)); + } + } + return children; + } + + List _buildDeletedTitle(BuildContext context, ViewPB view) { + return [ + const TrashBreadcrumb(), + const FlowySvg(FlowySvgs.title_bar_divider_s), + FlowyTooltip( + key: ValueKey(view.id), + message: view.name, + child: ViewTitle( + view: view, + onUpdated: () => context + .read() + .add(const ViewTitleBarEvent.reload()), + ), + ), + ]; + } +} + +class TrashBreadcrumb extends StatelessWidget { + const TrashBreadcrumb({super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric(horizontal: 6.0), + onTap: () { + getIt().latestOpenView = null; + getIt().add( + TabsEvent.openPlugin( + plugin: makePlugin(pluginType: PluginType.trash), + ), + ); + }, + text: Row( + children: [ + const FlowySvg(FlowySvgs.trash_s, size: Size.square(14)), + const HSpace(4.0), + FlowyText.regular( + LocaleKeys.trash_text.tr(), + fontSize: 14.0, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 18.0, + ), + ], + ), + ), + ); + } +} + +enum ViewTitleBehavior { + editable, + uneditable, +} + +class ViewTitle extends StatefulWidget { + const ViewTitle({ + super.key, + required this.view, + this.behavior = ViewTitleBehavior.editable, + required this.onUpdated, + }); + + final ViewPB view; + final ViewTitleBehavior behavior; + final VoidCallback onUpdated; + + @override + State createState() => _ViewTitleState(); +} + +class _ViewTitleState extends State { + final popoverController = PopoverController(); + final textEditingController = TextEditingController(); + + @override + void dispose() { + textEditingController.dispose(); + popoverController.close(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isEditable = widget.behavior == ViewTitleBehavior.editable; + + return BlocProvider( + create: (_) => + ViewTitleBloc(view: widget.view)..add(const ViewTitleEvent.initial()), + child: BlocConsumer( + listenWhen: (previous, current) { + if (previous.view == null || current.view == null) { + return false; + } + + return previous.view != current.view; + }, + listener: (_, state) { + _resetTextEditingController(state); + widget.onUpdated(); + }, + builder: (context, state) { + // root view + if (widget.view.parentViewId.isEmpty) { + return Row( + children: [ + FlowyText.regular(state.name), + const HSpace(4.0), + ], + ); + } else if (widget.view.isSpace) { + return _buildSpaceTitle(context, state); + } else if (isEditable) { + return _buildEditableViewTitle(context, state); + } else { + return _buildUnEditableViewTitle(context, state); + } + }, + ), + ); + } + + Widget _buildSpaceTitle(BuildContext context, ViewTitleState state) { + return Container( + alignment: Alignment.center, + margin: const EdgeInsets.symmetric(horizontal: 6.0), + child: _buildIconAndName(context, state, false), + ); + } + + Widget _buildUnEditableViewTitle(BuildContext context, ViewTitleState state) { + return Listener( + onPointerDown: (_) => context.read().openPlugin(widget.view), + child: SizedBox( + height: 32.0, + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric(horizontal: 6.0), + text: _buildIconAndName(context, state, false), + ), + ), + ); + } + + Widget _buildEditableViewTitle(BuildContext context, ViewTitleState state) { + return AppFlowyPopover( + constraints: const BoxConstraints( + maxWidth: 300, + maxHeight: 44, + ), + controller: popoverController, + direction: PopoverDirection.bottomWithLeftAligned, + offset: const Offset(0, 6), + popupBuilder: (context) { + // icon + textfield + _resetTextEditingController(state); + return RenameViewPopover( + viewId: widget.view.id, + name: widget.view.name, + popoverController: popoverController, + icon: widget.view.defaultIcon(), + emoji: state.icon, + ); + }, + child: SizedBox( + height: 32.0, + child: FlowyButton( + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric(horizontal: 6.0), + text: _buildIconAndName(context, state, true), + ), + ), + ); + } + + Widget _buildIconAndName( + BuildContext context, + ViewTitleState state, + bool isEditable, + ) { + final spaceIcon = state.view?.buildSpaceIconSvg(context); + return SingleChildScrollView( + child: Row( + children: [ + if (state.icon.isNotEmpty) ...[ + RawEmojiIconWidget(emoji: state.icon, emojiSize: 14.0), + const HSpace(4.0), + ], + if (state.view?.isSpace == true && spaceIcon != null) ...[ + SpaceIcon( + dimension: 14, + svgSize: 8.5, + space: state.view!, + cornerRadius: 4, + ), + const HSpace(6.0), + ], + Opacity( + opacity: isEditable ? 1.0 : 0.5, + child: FlowyText.regular( + state.name.isEmpty + ? LocaleKeys.menuAppHeader_defaultNewPageName.tr() + : state.name, + fontSize: 14.0, + overflow: TextOverflow.ellipsis, + figmaLineHeight: 18.0, + ), + ), + ], + ), + ); + } + + void _resetTextEditingController(ViewTitleState state) { + textEditingController + ..text = state.name + ..selection = TextSelection( + baseOffset: 0, + extentOffset: state.name.length, + ); + } +} diff --git a/frontend/app_flowy/linux/.gitignore b/frontend/appflowy_flutter/linux/.gitignore similarity index 100% rename from frontend/app_flowy/linux/.gitignore rename to frontend/appflowy_flutter/linux/.gitignore diff --git a/frontend/appflowy_flutter/linux/CMakeLists.txt b/frontend/appflowy_flutter/linux/CMakeLists.txt new file mode 100644 index 0000000000000..b9f7cce1742fd --- /dev/null +++ b/frontend/appflowy_flutter/linux/CMakeLists.txt @@ -0,0 +1,121 @@ +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +set(BINARY_NAME "AppFlowy") +set(APPLICATION_ID "io.appflowy.appflowy") + +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Configure build options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Application build +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) +apply_standard_settings(${BINARY_NAME}) +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") + +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(DART_FFI_DIR "${CMAKE_INSTALL_PREFIX}/lib") +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${DART_FFI_DLL}" DESTINATION "${DART_FFI_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/frontend/app_flowy/linux/flutter/CMakeLists.txt b/frontend/appflowy_flutter/linux/flutter/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/linux/flutter/CMakeLists.txt rename to frontend/appflowy_flutter/linux/flutter/CMakeLists.txt diff --git a/frontend/appflowy_flutter/linux/flutter/dart_ffi/binding.h b/frontend/appflowy_flutter/linux/flutter/dart_ffi/binding.h new file mode 100644 index 0000000000000..78992141ca082 --- /dev/null +++ b/frontend/appflowy_flutter/linux/flutter/dart_ffi/binding.h @@ -0,0 +1,20 @@ +#include +#include +#include +#include + +int64_t init_sdk(int64_t port, char *data); + +void async_event(int64_t port, const uint8_t *input, uintptr_t len); + +const uint8_t *sync_event(const uint8_t *input, uintptr_t len); + +int32_t set_stream_port(int64_t port); + +int32_t set_log_stream_port(int64_t port); + +void link_me_please(void); + +void rust_log(int64_t level, const char *data); + +void set_env(const char *data); diff --git a/frontend/app_flowy/linux/main.cc b/frontend/appflowy_flutter/linux/main.cc similarity index 100% rename from frontend/app_flowy/linux/main.cc rename to frontend/appflowy_flutter/linux/main.cc diff --git a/frontend/appflowy_flutter/linux/my_application.cc b/frontend/appflowy_flutter/linux/my_application.cc new file mode 100644 index 0000000000000..2a3a02cac477d --- /dev/null +++ b/frontend/appflowy_flutter/linux/my_application.cc @@ -0,0 +1,91 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication +{ + GtkApplication parent_instance; + char **dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication *application) +{ + MyApplication *self = MY_APPLICATION(application); + + GList* windows = gtk_application_get_windows(GTK_APPLICATION(application)); + if (windows) { + gtk_window_present(GTK_WINDOW(windows->data)); + return; + } + + GtkWindow *window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + gtk_window_set_title(window, "AppFlowy"); + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView *view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication *application, gchar ***arguments, int *exit_status) +{ + MyApplication *self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) + { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return FALSE; +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject *object) +{ + MyApplication *self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass *klass) +{ + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication *self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN, + nullptr)); +} diff --git a/frontend/app_flowy/linux/my_application.h b/frontend/appflowy_flutter/linux/my_application.h similarity index 100% rename from frontend/app_flowy/linux/my_application.h rename to frontend/appflowy_flutter/linux/my_application.h diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/.gitignore b/frontend/appflowy_flutter/macos/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/macos/.gitignore rename to frontend/appflowy_flutter/macos/.gitignore diff --git a/frontend/app_flowy/macos/Flutter/Flutter-Debug.xcconfig b/frontend/appflowy_flutter/macos/Flutter/Flutter-Debug.xcconfig similarity index 100% rename from frontend/app_flowy/macos/Flutter/Flutter-Debug.xcconfig rename to frontend/appflowy_flutter/macos/Flutter/Flutter-Debug.xcconfig diff --git a/frontend/app_flowy/macos/Flutter/Flutter-Release.xcconfig b/frontend/appflowy_flutter/macos/Flutter/Flutter-Release.xcconfig similarity index 100% rename from frontend/app_flowy/macos/Flutter/Flutter-Release.xcconfig rename to frontend/appflowy_flutter/macos/Flutter/Flutter-Release.xcconfig diff --git a/frontend/appflowy_flutter/macos/Podfile b/frontend/appflowy_flutter/macos/Podfile new file mode 100644 index 0000000000000..38ec205fff45c --- /dev/null +++ b/frontend/appflowy_flutter/macos/Podfile @@ -0,0 +1,89 @@ +MINIMUM_MACOSX_DEPLOYMENT_TARGET_SUPPORTED = '10.14' + +platform :osx, MINIMUM_MACOSX_DEPLOYMENT_TARGET_SUPPORTED + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +def build_specify_archs_only + if ENV.has_key?('BUILD_ACTIVE_ARCHS_ONLY') + xcodeproj_path = File.dirname(__FILE__) + '/Runner.xcodeproj' + project = Xcodeproj::Project.open(xcodeproj_path) + project.targets.each do |target| + if target.name == 'Runner' + target.build_configurations.each do |config| + config.build_settings['ONLY_ACTIVE_ARCH'] = ENV['BUILD_ACTIVE_ARCHS_ONLY'] + end + end + end + project.save() + end + + if ENV.has_key?('BUILD_ARCHS') + xcodeproj_path = File.dirname(__FILE__) + '/Runner.xcodeproj' + project = Xcodeproj::Project.open(xcodeproj_path) + project.targets.each do |target| + if target.name == 'Runner' + target.build_configurations.each do |config| + config.build_settings['ARCHS'] = ENV['BUILD_ARCHS'] + end + end + end + project.save() + end +end + +build_specify_archs_only() + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end + + installer.aggregate_targets.each do |target| + target.xcconfigs.each do |variant, xcconfig| + xcconfig_path = target.client_root + target.xcconfig_relative_path(variant) + IO.write(xcconfig_path, IO.read(xcconfig_path).gsub("DT_TOOLCHAIN_DIR", "TOOLCHAIN_DIR")) + end + end + + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.base_configuration_reference.is_a? Xcodeproj::Project::Object::PBXFileReference + xcconfig_path = config.base_configuration_reference.real_path + IO.write(xcconfig_path, IO.read(xcconfig_path).gsub("DT_TOOLCHAIN_DIR", "TOOLCHAIN_DIR")) + end + config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = MINIMUM_MACOSX_DEPLOYMENT_TARGET_SUPPORTED + end + end +end diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock new file mode 100644 index 0000000000000..300519a5dcdec --- /dev/null +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -0,0 +1,161 @@ +PODS: + - app_links (1.0.0): + - FlutterMacOS + - appflowy_backend (0.0.1): + - FlutterMacOS + - bitsdojo_window_macos (0.0.1): + - FlutterMacOS + - connectivity_plus (0.0.1): + - FlutterMacOS + - ReachabilitySwift + - desktop_drop (0.0.1): + - FlutterMacOS + - device_info_plus (0.0.1): + - FlutterMacOS + - file_selector_macos (0.0.1): + - FlutterMacOS + - flowy_infra_ui (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - HotKey (0.2.0) + - hotkey_manager (0.0.1): + - FlutterMacOS + - HotKey + - irondash_engine_context (0.0.1): + - FlutterMacOS + - local_notifier (0.1.0): + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - ReachabilitySwift (5.2.3) + - screen_retriever (0.0.1): + - FlutterMacOS + - Sentry/HybridSDK (8.35.1) + - sentry_flutter (8.8.0): + - Flutter + - FlutterMacOS + - Sentry/HybridSDK (= 8.35.1) + - share_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite (0.0.3): + - Flutter + - FlutterMacOS + - super_native_extensions (0.0.1): + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + - window_manager (0.2.0): + - FlutterMacOS + +DEPENDENCIES: + - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) + - appflowy_backend (from `Flutter/ephemeral/.symlinks/plugins/appflowy_backend/macos`) + - bitsdojo_window_macos (from `Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos`) + - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) + - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flowy_infra_ui (from `Flutter/ephemeral/.symlinks/plugins/flowy_infra_ui/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - hotkey_manager (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager/macos`) + - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`) + - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) + - sentry_flutter (from `Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) + - super_native_extensions (from `Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) + +SPEC REPOS: + trunk: + - HotKey + - ReachabilitySwift + - Sentry + +EXTERNAL SOURCES: + app_links: + :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos + appflowy_backend: + :path: Flutter/ephemeral/.symlinks/plugins/appflowy_backend/macos + bitsdojo_window_macos: + :path: Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos + connectivity_plus: + :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos + desktop_drop: + :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flowy_infra_ui: + :path: Flutter/ephemeral/.symlinks/plugins/flowy_infra_ui/macos + FlutterMacOS: + :path: Flutter/ephemeral + hotkey_manager: + :path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager/macos + irondash_engine_context: + :path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos + local_notifier: + :path: Flutter/ephemeral/.symlinks/plugins/local_notifier/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + screen_retriever: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos + sentry_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin + super_native_extensions: + :path: Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + window_manager: + :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos + +SPEC CHECKSUMS: + app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a + appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9 + bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 + connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 + desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 + device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 + file_selector_macos: 54fdab7caa3ac3fc43c9fac4d7d8d231277f8cf2 + flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38 + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + HotKey: e96d8a2ddbf4591131e2bb3f54e69554d90cdca6 + hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c + irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478 + local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff + package_info_plus: fa739dd842b393193c5ca93c26798dff6e3d0e0c + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + ReachabilitySwift: 7f151ff156cea1481a8411701195ac6a984f4979 + screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 + Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 + share_plus: 36537c04ce0c3e3f5bd297ce4318b6d5ee5fd6cf + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 + url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399 + window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 + +PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 + +COCOAPODS: 1.15.2 diff --git a/frontend/appflowy_flutter/macos/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000000..1c3367e430300 --- /dev/null +++ b/frontend/appflowy_flutter/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,669 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 706E045829F286F600B789F4 /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 706E045729F286EC00B789F4 /* libc++.tbd */; }; + D7360C6D6177708F7B2D3C9D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD81A6C7244B2318E0BA2E8 /* Pods_Runner.framework */; }; + FB4E0E4F2CC9F3F900C57E87 /* libbz2.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = FB4E0E4E2CC9F3E900C57E87 /* libbz2.tbd */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1823EB6E74189944EAA69652 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 1CD81A6C7244B2318E0BA2E8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* AppFlowy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppFlowy.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 6DEEC7DEFA746DDF1338FF4D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 706E045729F286EC00B789F4 /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 7D41C30A3910C3A40B6085E3 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + FB4E0E4E2CC9F3E900C57E87 /* libbz2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libbz2.tbd; path = usr/lib/libbz2.tbd; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FB4E0E4F2CC9F3F900C57E87 /* libbz2.tbd in Frameworks */, + 706E045829F286F600B789F4 /* libc++.tbd in Frameworks */, + D7360C6D6177708F7B2D3C9D /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 6B44C542FA0845A8F2FA3624 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* AppFlowy.app */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 6B44C542FA0845A8F2FA3624 /* Pods */ = { + isa = PBXGroup; + children = ( + 6DEEC7DEFA746DDF1338FF4D /* Pods-Runner.debug.xcconfig */, + 7D41C30A3910C3A40B6085E3 /* Pods-Runner.release.xcconfig */, + 1823EB6E74189944EAA69652 /* Pods-Runner.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + FB4E0E4E2CC9F3E900C57E87 /* libbz2.tbd */, + 706E045729F286EC00B789F4 /* libc++.tbd */, + 1CD81A6C7244B2318E0BA2E8 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 611CF908D5E75C6DF581F81A /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 4372B34726148A3BC297850A /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* AppFlowy.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 4372B34726148A3BC297850A /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 611CF908D5E75C6DF581F81A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + STRIP_STYLE = "non-global"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = NO; + EXCLUDED_ARCHS = ""; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 10.14; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; + PRODUCT_NAME = AppFlowy; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRIP_STYLE = "non-global"; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + STRIP_STYLE = "non-global"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + STRIP_STYLE = "non-global"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = NO; + EXCLUDED_ARCHS = ""; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 10.14; + PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; + PRODUCT_NAME = AppFlowy; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRIP_STYLE = "non-global"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ARCHS = "$(ARCHS_STANDARD)"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + EXCLUDED_ARCHS = ""; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AppFlowy; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 10.14; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.appflowy.appflowy.flutter; + PRODUCT_NAME = AppFlowy; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRIP_STYLE = "non-global"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/frontend/app_flowy/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/appflowy_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000000..74866db678246 --- /dev/null +++ b/frontend/appflowy_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/macos/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/macos/Runner.xcworkspace/contents.xcworkspacedata rename to frontend/appflowy_flutter/macos/Runner.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/appflowy_flutter/macos/Runner/AppDelegate.swift b/frontend/appflowy_flutter/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000000..cad0330b854a8 --- /dev/null +++ b/frontend/appflowy_flutter/macos/Runner/AppDelegate.swift @@ -0,0 +1,19 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return false + } + + override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { + for window in sender.windows { + window.makeKeyAndOrderFront(self) + } + } + + return true + } +} diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/100.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/100.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/100.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/100.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/1024.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/1024.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/1024.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/1024.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/114.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/114.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/114.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/114.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/120.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/120.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/120.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/120.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/128.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/128.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/128.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/128.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/144.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/144.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/144.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/144.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/152.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/152.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/152.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/152.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/16.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/16.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/16.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/16.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/167.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/167.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/167.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/167.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/180.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/180.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/180.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/180.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/20.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/20.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/20.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/20.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/256.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/256.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/256.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/256.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/29.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/29.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/29.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/29.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/32.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/32.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/32.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/32.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/40.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/40.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/40.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/40.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/50.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/50.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/50.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/50.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/512.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/512.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/512.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/512.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/57.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/57.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/57.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/57.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/58.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/58.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/58.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/58.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/60.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/60.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/60.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/60.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/64.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/64.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/64.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/64.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/72.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/72.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/72.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/72.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/76.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/76.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/76.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/76.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/80.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/80.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/80.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/80.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/87.png b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/87.png similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/87.png rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/87.png diff --git a/frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from frontend/app_flowy/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to frontend/appflowy_flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/frontend/app_flowy/macos/Runner/Base.lproj/MainMenu.xib b/frontend/appflowy_flutter/macos/Runner/Base.lproj/MainMenu.xib similarity index 100% rename from frontend/app_flowy/macos/Runner/Base.lproj/MainMenu.xib rename to frontend/appflowy_flutter/macos/Runner/Base.lproj/MainMenu.xib diff --git a/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig b/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000000..da469610ebce8 --- /dev/null +++ b/frontend/appflowy_flutter/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = AppFlowy + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = io.appflowy.appflowy + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2024 AppFlowy.IO. All rights reserved. diff --git a/frontend/app_flowy/macos/Runner/Configs/Debug.xcconfig b/frontend/appflowy_flutter/macos/Runner/Configs/Debug.xcconfig similarity index 100% rename from frontend/app_flowy/macos/Runner/Configs/Debug.xcconfig rename to frontend/appflowy_flutter/macos/Runner/Configs/Debug.xcconfig diff --git a/frontend/app_flowy/macos/Runner/Configs/Release.xcconfig b/frontend/appflowy_flutter/macos/Runner/Configs/Release.xcconfig similarity index 100% rename from frontend/app_flowy/macos/Runner/Configs/Release.xcconfig rename to frontend/appflowy_flutter/macos/Runner/Configs/Release.xcconfig diff --git a/frontend/app_flowy/macos/Runner/Configs/Warnings.xcconfig b/frontend/appflowy_flutter/macos/Runner/Configs/Warnings.xcconfig similarity index 100% rename from frontend/app_flowy/macos/Runner/Configs/Warnings.xcconfig rename to frontend/appflowy_flutter/macos/Runner/Configs/Warnings.xcconfig diff --git a/frontend/appflowy_flutter/macos/Runner/DebugProfile.entitlements b/frontend/appflowy_flutter/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000000..71949adefe794 --- /dev/null +++ b/frontend/appflowy_flutter/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.temporary-exception.files.absolute-path.read-write + + / + + + diff --git a/frontend/appflowy_flutter/macos/Runner/Info.plist b/frontend/appflowy_flutter/macos/Runner/Info.plist new file mode 100644 index 0000000000000..cd078871349ee --- /dev/null +++ b/frontend/appflowy_flutter/macos/Runner/Info.plist @@ -0,0 +1,57 @@ + + + + + LSApplicationCategoryType + public.app-category.productivity + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + fr + it + zh + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleURLTypes + + + CFBundleURLName + + CFBundleURLSchemes + + appflowy-flutter + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/frontend/appflowy_flutter/macos/Runner/MainFlutterWindow.swift b/frontend/appflowy_flutter/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000000..620ad5c9bcf29 --- /dev/null +++ b/frontend/appflowy_flutter/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,91 @@ +import Cocoa +import FlutterMacOS + +private let kTrafficLightOffetTop = 14 + +class MainFlutterWindow: NSWindow { + func registerMethodChannel(flutterViewController: FlutterViewController) { + let cocoaWindowChannel = FlutterMethodChannel(name: "flutter/cocoaWindow", binaryMessenger: flutterViewController.engine.binaryMessenger) + cocoaWindowChannel.setMethodCallHandler({ + (call: FlutterMethodCall, result: FlutterResult) -> Void in + if call.method == "setWindowPosition" { + guard let position = call.arguments as? NSArray else { + result(nil) + return + } + let nX = position[0] as! NSNumber + let nY = position[1] as! NSNumber + let x = nX.doubleValue + let y = nY.doubleValue + + self.setFrameOrigin(NSPoint(x: x, y: y)) + result(nil) + return + } else if call.method == "getWindowPosition" { + let frame = self.frame + result([frame.origin.x, frame.origin.y]) + return + } else if call.method == "zoom" { + self.zoom(self) + result(nil) + return + } + + result(FlutterMethodNotImplemented) + }) + } + + func layoutTrafficLightButton(titlebarView: NSView, button: NSButton, offsetTop: CGFloat, offsetLeft: CGFloat) { + button.translatesAutoresizingMaskIntoConstraints = false; + titlebarView.addConstraint(NSLayoutConstraint.init( + item: button, + attribute: NSLayoutConstraint.Attribute.top, relatedBy: NSLayoutConstraint.Relation.equal, toItem: titlebarView, attribute: NSLayoutConstraint.Attribute.top, multiplier: 1, constant: offsetTop)) + titlebarView.addConstraint(NSLayoutConstraint.init( + item: button, + attribute: NSLayoutConstraint.Attribute.left, relatedBy: NSLayoutConstraint.Relation.equal, toItem: titlebarView, attribute: NSLayoutConstraint.Attribute.left, multiplier: 1, constant: offsetLeft)) + } + + func layoutTrafficLights() { + let closeButton = self.standardWindowButton(ButtonType.closeButton)! + let minButton = self.standardWindowButton(ButtonType.miniaturizeButton)! + let zoomButton = self.standardWindowButton(ButtonType.zoomButton)! + let titlebarView = closeButton.superview! + + self.layoutTrafficLightButton(titlebarView: titlebarView, button: closeButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 12) + self.layoutTrafficLightButton(titlebarView: titlebarView, button: minButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 30) + self.layoutTrafficLightButton(titlebarView: titlebarView, button: zoomButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 48) + + let customToolbar = NSTitlebarAccessoryViewController() + let newView = NSView() + newView.frame = NSRect(origin: CGPoint(), size: CGSize(width: 0, height: 40)) // only the height is cared + customToolbar.view = newView + self.addTitlebarAccessoryViewController(customToolbar) + } + + override func awakeFromNib() { + let flutterViewController = FlutterViewController.init() + let windowFrame = self.frame + self.contentViewController = flutterViewController + + self.registerMethodChannel(flutterViewController: flutterViewController) + + self.setFrame(windowFrame, display: true) + self.titlebarAppearsTransparent = true + self.titleVisibility = .hidden + self.styleMask.insert(StyleMask.fullSizeContentView) + self.isMovableByWindowBackground = true + + // For the macOS version 15 or higher, set it to true to enable the window tiling + if #available(macOS 15.0, *) { + self.isMovable = true + } else { + self.isMovable = false + } + + self.layoutTrafficLights() + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/frontend/appflowy_flutter/macos/Runner/Release.entitlements b/frontend/appflowy_flutter/macos/Runner/Release.entitlements new file mode 100644 index 0000000000000..71949adefe794 --- /dev/null +++ b/frontend/appflowy_flutter/macos/Runner/Release.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.temporary-exception.files.absolute-path.read-write + + / + + + diff --git a/frontend/app_flowy/packages/flowy_sdk/.gitignore b/frontend/appflowy_flutter/packages/appflowy_backend/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_backend/.gitignore diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/.metadata b/frontend/appflowy_flutter/packages/appflowy_backend/.metadata new file mode 100644 index 0000000000000..3ebe9d182d997 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/.metadata @@ -0,0 +1,39 @@ +# 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. + +version: + revision: 135454af32477f815a7525073027a3ff9eff1bfd + channel: stable + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 135454af32477f815a7525073027a3ff9eff1bfd + base_revision: 135454af32477f815a7525073027a3ff9eff1bfd + - platform: android + create_revision: 135454af32477f815a7525073027a3ff9eff1bfd + base_revision: 135454af32477f815a7525073027a3ff9eff1bfd + - platform: ios + create_revision: 135454af32477f815a7525073027a3ff9eff1bfd + base_revision: 135454af32477f815a7525073027a3ff9eff1bfd + - platform: macos + create_revision: 135454af32477f815a7525073027a3ff9eff1bfd + base_revision: 135454af32477f815a7525073027a3ff9eff1bfd + - platform: windows + create_revision: 135454af32477f815a7525073027a3ff9eff1bfd + base_revision: 135454af32477f815a7525073027a3ff9eff1bfd + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/frontend/app_flowy/packages/appflowy_popover/CHANGELOG.md b/frontend/appflowy_flutter/packages/appflowy_backend/CHANGELOG.md similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/CHANGELOG.md rename to frontend/appflowy_flutter/packages/appflowy_backend/CHANGELOG.md diff --git a/frontend/app_flowy/packages/appflowy_popover/LICENSE b/frontend/appflowy_flutter/packages/appflowy_backend/LICENSE similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/LICENSE rename to frontend/appflowy_flutter/packages/appflowy_backend/LICENSE diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/README.md b/frontend/appflowy_flutter/packages/appflowy_backend/README.md new file mode 100644 index 0000000000000..54001a0d74b36 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/README.md @@ -0,0 +1,18 @@ +# appflowy_backend + +A new flutter plugin project. + +## Getting Started + +This project is a starting point for a Flutter +[plug-in package](https://flutter.dev/developing-packages/), +a specialized package that includes platform-specific implementation code for +Android and/or iOS. + +For help getting started with Flutter, view our +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + +The plugin project was generated without specifying the `--platforms` flag, no platforms are currently supported. +To add platforms, run `flutter create -t plugin --platforms .` under the same +directory. You can also find a detailed instruction on how to add platforms in the `pubspec.yaml` at https://flutter.dev/docs/development/packages-and-plugins/developing-packages#plugin-platforms. diff --git a/frontend/app_flowy/packages/flowy_sdk/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_backend/analysis_options.yaml similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/analysis_options.yaml rename to frontend/appflowy_flutter/packages/appflowy_backend/analysis_options.yaml diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/.gitignore b/frontend/appflowy_flutter/packages/appflowy_backend/android/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/android/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_backend/android/.gitignore diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/android/build.gradle b/frontend/appflowy_flutter/packages/appflowy_backend/android/build.gradle new file mode 100644 index 0000000000000..0090800f92983 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/android/build.gradle @@ -0,0 +1,40 @@ +group 'com.plugin.appflowy_backend' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.8.0' + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.4.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + jcenter() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion 33 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + defaultConfig { + minSdkVersion 23 + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/gradle.properties b/frontend/appflowy_flutter/packages/appflowy_backend/android/gradle.properties similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/gradle.properties rename to frontend/appflowy_flutter/packages/appflowy_backend/android/gradle.properties diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/gradle/wrapper/gradle-wrapper.properties b/frontend/appflowy_flutter/packages/appflowy_backend/android/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/android/gradle/wrapper/gradle-wrapper.properties rename to frontend/appflowy_flutter/packages/appflowy_backend/android/gradle/wrapper/gradle-wrapper.properties diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/android/settings.gradle b/frontend/appflowy_flutter/packages/appflowy_backend/android/settings.gradle new file mode 100644 index 0000000000000..9d3fe3632c821 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'appflowy_backend' diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/android/src/main/AndroidManifest.xml b/frontend/appflowy_flutter/packages/appflowy_backend/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000000..ad98e449265c3 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/android/src/main/kotlin/com/plugin/appflowy_backend/AppFlowyBackendPlugin.kt b/frontend/appflowy_flutter/packages/appflowy_backend/android/src/main/kotlin/com/plugin/appflowy_backend/AppFlowyBackendPlugin.kt new file mode 100644 index 0000000000000..eca9a922ff4d6 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/android/src/main/kotlin/com/plugin/appflowy_backend/AppFlowyBackendPlugin.kt @@ -0,0 +1,36 @@ +package com.plugin.appflowy_backend + +import androidx.annotation.NonNull + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.PluginRegistry.Registrar + +/** AppFlowyBackendPlugin */ +class AppFlowyBackendPlugin: FlutterPlugin, MethodCallHandler { + /// The MethodChannel that will the communication between Flutter and native Android + /// + /// This local reference serves to register the plugin with the Flutter Engine and unregister it + /// when the Flutter Engine is detached from the Activity + private lateinit var channel : MethodChannel + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "appflowy_backend") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + if (call.method == "getPlatformVersion") { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } else { + result.notImplemented() + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/.gitignore b/frontend/appflowy_flutter/packages/appflowy_backend/example/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_backend/example/.gitignore diff --git a/frontend/app_flowy/packages/flowy_sdk/example/.metadata b/frontend/appflowy_flutter/packages/appflowy_backend/example/.metadata similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/.metadata rename to frontend/appflowy_flutter/packages/appflowy_backend/example/.metadata diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/README.md b/frontend/appflowy_flutter/packages/appflowy_backend/example/README.md new file mode 100644 index 0000000000000..4f4918689ea80 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/README.md @@ -0,0 +1,16 @@ +# flowy_sdk_example + +Demonstrates how to use the appflowy_backend plugin. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) + +For help getting started with Flutter, view our +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/frontend/app_flowy/packages/flowy_sdk/example/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_backend/example/analysis_options.yaml similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/analysis_options.yaml rename to frontend/appflowy_flutter/packages/appflowy_backend/example/analysis_options.yaml diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/.gitignore b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/android/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/.gitignore diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/build.gradle b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/build.gradle new file mode 100644 index 0000000000000..b96b43daa3b9b --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/build.gradle @@ -0,0 +1,59 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 30 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.plugin.flowy_sdk_example" + minSdkVersion 33 + targetSdkVersion 33 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/debug/AndroidManifest.xml b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/debug/AndroidManifest.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/android/app/src/debug/AndroidManifest.xml rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/debug/AndroidManifest.xml diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/AndroidManifest.xml b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/AndroidManifest.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/AndroidManifest.xml rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/AndroidManifest.xml diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/kotlin/com/plugin/flowy_sdk_example/MainActivity.kt b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/kotlin/com/plugin/flowy_sdk_example/MainActivity.kt similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/kotlin/com/plugin/flowy_sdk_example/MainActivity.kt rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/kotlin/com/plugin/flowy_sdk_example/MainActivity.kt diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/drawable-v21/launch_background.xml b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/drawable-v21/launch_background.xml similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/drawable-v21/launch_background.xml rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/drawable-v21/launch_background.xml diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/drawable/launch_background.xml b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/drawable/launch_background.xml similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/drawable/launch_background.xml rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/drawable/launch_background.xml diff --git a/frontend/app_flowy/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/frontend/app_flowy/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/frontend/app_flowy/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/frontend/app_flowy/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/frontend/app_flowy/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/values-night/styles.xml b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/values-night/styles.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/values-night/styles.xml rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/values-night/styles.xml diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/values/styles.xml b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/values/styles.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/res/values/styles.xml rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/main/res/values/styles.xml diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/profile/AndroidManifest.xml b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/profile/AndroidManifest.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/android/app/src/profile/AndroidManifest.xml rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/app/src/profile/AndroidManifest.xml diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/build.gradle b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/build.gradle similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/android/build.gradle rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/build.gradle diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/gradle.properties b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/gradle.properties similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/gradle.properties rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/gradle.properties diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/gradle/wrapper/gradle-wrapper.properties b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/android/gradle/wrapper/gradle-wrapper.properties rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/gradle/wrapper/gradle-wrapper.properties diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/settings.gradle b/frontend/appflowy_flutter/packages/appflowy_backend/example/android/settings.gradle similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/settings.gradle rename to frontend/appflowy_flutter/packages/appflowy_backend/example/android/settings.gradle diff --git a/frontend/app_flowy/packages/flowy_sdk/example/integration_test/app_test.dart b/frontend/appflowy_flutter/packages/appflowy_backend/example/integration_test/app_test.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/integration_test/app_test.dart rename to frontend/appflowy_flutter/packages/appflowy_backend/example/integration_test/app_test.dart diff --git a/frontend/app_flowy/packages/flowy_sdk/example/integration_test/driver.dart b/frontend/appflowy_flutter/packages/appflowy_backend/example/integration_test/driver.dart similarity index 99% rename from frontend/app_flowy/packages/flowy_sdk/example/integration_test/driver.dart rename to frontend/appflowy_flutter/packages/appflowy_backend/example/integration_test/driver.dart index 0e385dc6f2b66..d0ea799fe3e08 100644 --- a/frontend/app_flowy/packages/flowy_sdk/example/integration_test/driver.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/integration_test/driver.dart @@ -2,4 +2,3 @@ // flutter drive command. // // flutter drive --driver integration_test/driver.dart --target integration_test/app_test.dart - diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/.gitignore b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/ios/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/.gitignore diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Flutter/AppFrameworkInfo.plist b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Flutter/AppFrameworkInfo.plist similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/ios/Flutter/AppFrameworkInfo.plist rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Flutter/AppFrameworkInfo.plist diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Flutter/Debug.xcconfig b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Flutter/Debug.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/ios/Flutter/Debug.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Flutter/Debug.xcconfig diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Flutter/Release.xcconfig b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Flutter/Release.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/ios/Flutter/Release.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Flutter/Release.xcconfig diff --git a/frontend/app_flowy/ios/Podfile b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Podfile similarity index 100% rename from frontend/app_flowy/ios/Podfile rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Podfile diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/project.pbxproj similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcodeproj/project.pbxproj rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/project.pbxproj diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcworkspace/contents.xcworkspacedata rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner.xcworkspace/contents.xcworkspacedata rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/AppDelegate.swift b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/AppDelegate.swift similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/AppDelegate.swift rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/AppDelegate.swift diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png diff --git a/frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png similarity index 100% rename from frontend/app_flowy/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Base.lproj/LaunchScreen.storyboard rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Base.lproj/LaunchScreen.storyboard diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Base.lproj/Main.storyboard b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Base.lproj/Main.storyboard similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Base.lproj/Main.storyboard rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Base.lproj/Main.storyboard diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Info.plist b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Info.plist similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/ios/Runner/Info.plist rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Info.plist diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Runner-Bridging-Header.h b/frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Runner-Bridging-Header.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Runner-Bridging-Header.h rename to frontend/appflowy_flutter/packages/appflowy_backend/example/ios/Runner/Runner-Bridging-Header.h diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/lib/main.dart b/frontend/appflowy_flutter/packages/appflowy_backend/example/lib/main.dart new file mode 100644 index 0000000000000..a3fb8967a5037 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/lib/main.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy_backend/appflowy_backend.dart'; + +void main() { + runApp(MyApp()); +} + +class MyApp extends StatefulWidget { + @override + _MyAppState createState() => _MyAppState(); +} + +class _MyAppState extends State { + String _platformVersion = 'Unknown'; + + @override + void initState() { + super.initState(); + initPlatformState(); + } + + // Platform messages are asynchronous, so we initialize in an async method. + Future initPlatformState() async { + String platformVersion; + // Platform messages may fail, so we use a try/catch PlatformException. + try { + platformVersion = await FlowySDK.platformVersion; + } on PlatformException { + platformVersion = 'Failed to get platform version.'; + } + + // If the widget was removed from the tree while the asynchronous platform + // message was in flight, we want to discard the reply rather than calling + // setState to update our non-existent appearance. + if (!mounted) return; + setState(() => _platformVersion = platformVersion); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Plugin example app')), + body: Center(child: Text('Running on: $_platformVersion\n')), + ), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/.gitignore b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/macos/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/.gitignore diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Flutter/Flutter-Debug.xcconfig b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Flutter/Flutter-Debug.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/macos/Flutter/Flutter-Debug.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Flutter/Flutter-Debug.xcconfig diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Flutter/Flutter-Release.xcconfig b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Flutter/Flutter-Release.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/macos/Flutter/Flutter-Release.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Flutter/Flutter-Release.xcconfig diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile new file mode 100644 index 0000000000000..8c77835677a9e --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile @@ -0,0 +1,82 @@ +platform :osx, '10.13' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def parse_KV_file(file, separator='=') + file_abs_path = File.expand_path(file) + if !File.exists? file_abs_path + return []; + end + pods_ary = [] + skip_line_start_symbols = ["#", "/"] + File.foreach(file_abs_path) { |line| + next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } + plugin = line.split(pattern=separator) + if plugin.length == 2 + podname = plugin[0].strip() + path = plugin[1].strip() + podpath = File.expand_path("#{path}", file_abs_path) + pods_ary.push({:name => podname, :path => podpath}); + else + puts "Invalid plugin specification: #{line}" + end + } + return pods_ary +end + +def pubspec_supports_macos(file) + file_abs_path = File.expand_path(file) + if !File.exists? file_abs_path + return false; + end + File.foreach(file_abs_path) { |line| + return true if line =~ /^\s*macos:/ + } + return false +end + +target 'Runner' do + use_frameworks! + use_modular_headers! + + # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock + # referring to absolute paths on developers' machines. + ephemeral_dir = File.join('Flutter', 'ephemeral') + symlink_dir = File.join(ephemeral_dir, '.symlinks') + symlink_plugins_dir = File.join(symlink_dir, 'plugins') + system("rm -rf #{symlink_dir}") + system("mkdir -p #{symlink_plugins_dir}") + + # Flutter Pods + generated_xcconfig = parse_KV_file(File.join(ephemeral_dir, 'Flutter-Generated.xcconfig')) + if generated_xcconfig.empty? + puts "Flutter-Generated.xcconfig must exist. If you're running pod install manually, make sure flutter packages get is executed first." + end + generated_xcconfig.map { |p| + if p[:name] == 'FLUTTER_FRAMEWORK_DIR' + symlink = File.join(symlink_dir, 'flutter') + File.symlink(File.dirname(p[:path]), symlink) + pod 'FlutterMacOS', :path => File.join(symlink, File.basename(p[:path])) + end + } + + # Plugin Pods + plugin_pods = parse_KV_file('../.flutter-plugins') + plugin_pods.map { |p| + symlink = File.join(symlink_plugins_dir, p[:name]) + File.symlink(p[:path], symlink) + if pubspec_supports_macos(File.join(symlink, 'pubspec.yaml')) + pod p[:name], :path => File.join(symlink, 'macos') + end + } +end + +# Prevent Cocoapods from embedding a second Flutter framework and causing an error with the new Xcode build system. +install! 'cocoapods', :disable_input_output_paths => true diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile.lock b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile.lock new file mode 100644 index 0000000000000..0f8ecbb9f1e6e --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Podfile.lock @@ -0,0 +1,39 @@ +PODS: + - flowy_sqlite (0.0.1): + - FlutterMacOS + - appflowy_backend (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - path_provider (0.0.1) + - path_provider_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - flowy_sqlite (from `Flutter/ephemeral/.symlinks/plugins/flowy_sqlite/macos`) + - appflowy_backend (from `Flutter/ephemeral/.symlinks/plugins/appflowy_backend/macos`) + - FlutterMacOS (from `Flutter/ephemeral/.symlinks/flutter/darwin-x64`) + - path_provider (from `Flutter/ephemeral/.symlinks/plugins/path_provider/macos`) + - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) + +EXTERNAL SOURCES: + flowy_sqlite: + :path: Flutter/ephemeral/.symlinks/plugins/flowy_sqlite/macos + appflowy_backend: + :path: Flutter/ephemeral/.symlinks/plugins/appflowy_backend/macos + FlutterMacOS: + :path: Flutter/ephemeral/.symlinks/flutter/darwin-x64 + path_provider: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider/macos + path_provider_macos: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos + +SPEC CHECKSUMS: + flowy_sqlite: 6638deea1ca0baeef75156d16236123d4703161d + appflowy_backend: 12d2c047ed260a0aa8788a0b9616da46e2312025 + FlutterMacOS: 15bea8a44d2fa024068daa0140371c020b4b6ff9 + path_provider: e0848572d1d38b9a7dd099e79cf83f5b7e2cde9f + path_provider_macos: a0a3fd666cb7cd0448e936fb4abad4052961002b + +PODFILE CHECKSUM: d8ba9b3e9e93c62c74a660b46c6fcb09f03991a7 + +COCOAPODS: 1.10.1 diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.pbxproj similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/macos/Runner.xcodeproj/project.pbxproj rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.pbxproj diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcworkspace/contents.xcworkspacedata rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/macos/Runner/AppDelegate.swift b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/AppDelegate.swift similarity index 100% rename from frontend/app_flowy/macos/Runner/AppDelegate.swift rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/AppDelegate.swift diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Base.lproj/MainMenu.xib b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Base.lproj/MainMenu.xib similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Base.lproj/MainMenu.xib rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Base.lproj/MainMenu.xib diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Configs/AppInfo.xcconfig b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Configs/AppInfo.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Configs/AppInfo.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Configs/AppInfo.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Configs/Debug.xcconfig b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Configs/Debug.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Configs/Debug.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Configs/Debug.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Configs/Release.xcconfig b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Configs/Release.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Configs/Release.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Configs/Release.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Configs/Warnings.xcconfig b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Configs/Warnings.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Configs/Warnings.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Configs/Warnings.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/DebugProfile.entitlements b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/DebugProfile.entitlements similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/DebugProfile.entitlements rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/DebugProfile.entitlements diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Info.plist b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Info.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Info.plist rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Info.plist diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/MainFlutterWindow.swift b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/MainFlutterWindow.swift similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/MainFlutterWindow.swift rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/MainFlutterWindow.swift diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Release.entitlements b/frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Release.entitlements similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Release.entitlements rename to frontend/appflowy_flutter/packages/appflowy_backend/example/macos/Runner/Release.entitlements diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_backend/example/pubspec.yaml new file mode 100644 index 0000000000000..665892c8004f3 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/pubspec.yaml @@ -0,0 +1,74 @@ +name: flowy_sdk_example +description: Demonstrates how to use the appflowy_backend plugin. + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +environment: + sdk: ">=2.12.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + + appflowy_backend: + # When depending on this package from a real application you should use: + # appflowy_backend: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.1 + +dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + flutter_lints: ^3.0.1 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/test/widget_test.dart b/frontend/appflowy_flutter/packages/appflowy_backend/example/test/widget_test.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/test/widget_test.dart rename to frontend/appflowy_flutter/packages/appflowy_backend/example/test/widget_test.dart diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/.gitignore b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/.gitignore similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/.gitignore diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/CMakeLists.txt b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/windows/CMakeLists.txt rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/CMakeLists.txt diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/flutter/CMakeLists.txt b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/flutter/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/windows/flutter/CMakeLists.txt rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/flutter/CMakeLists.txt diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/CMakeLists.txt b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/windows/runner/CMakeLists.txt rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/CMakeLists.txt diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/Runner.rc b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/Runner.rc new file mode 100644 index 0000000000000..cef21e39d3f04 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#ifdef FLUTTER_BUILD_NUMBER +#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#else +#define VERSION_AS_NUMBER 1,0,0 +#endif + +#ifdef FLUTTER_BUILD_NAME +#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.plugin" "\0" + VALUE "FileDescription", "Demonstrates how to use the appflowy_backend plugin." "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "flowy_sdk_example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2021 com.plugin. All rights reserved." "\0" + VALUE "OriginalFilename", "flowy_sdk_example.exe" "\0" + VALUE "ProductName", "flowy_sdk_example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/runner/flutter_window.cpp b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/flutter_window.cpp similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/runner/flutter_window.cpp rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/flutter_window.cpp diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/runner/flutter_window.h b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/flutter_window.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/runner/flutter_window.h rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/flutter_window.h diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/main.cpp b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/main.cpp similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/windows/runner/main.cpp rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/main.cpp diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/runner/resource.h b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/resource.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/runner/resource.h rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/resource.h diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/runner/resources/app_icon.ico b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/resources/app_icon.ico similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/runner/resources/app_icon.ico rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/resources/app_icon.ico diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/runner/runner.exe.manifest b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/runner.exe.manifest similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/runner/runner.exe.manifest rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/runner.exe.manifest diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/utils.cpp b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/utils.cpp similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/utils.cpp rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/utils.cpp diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/runner/utils.h b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/utils.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/runner/utils.h rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/utils.h diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/runner/win32_window.cpp b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/win32_window.cpp similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/runner/win32_window.cpp rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/win32_window.cpp diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/runner/win32_window.h b/frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/win32_window.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/runner/win32_window.h rename to frontend/appflowy_flutter/packages/appflowy_backend/example/windows/runner/win32_window.h diff --git a/frontend/app_flowy/packages/flowy_sdk/ios/.gitignore b/frontend/appflowy_flutter/packages/appflowy_backend/ios/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/ios/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_backend/ios/.gitignore diff --git a/frontend/app_flowy/packages/flowy_infra_ui/ios/Assets/.gitkeep b/frontend/appflowy_flutter/packages/appflowy_backend/ios/Assets/.gitkeep similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/ios/Assets/.gitkeep rename to frontend/appflowy_flutter/packages/appflowy_backend/ios/Assets/.gitkeep diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.h b/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.h new file mode 100644 index 0000000000000..1bb542b437c1d --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface AppFlowyBackendPlugin : NSObject +@end diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.m b/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.m new file mode 100644 index 0000000000000..a970b993e8fc6 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.m @@ -0,0 +1,15 @@ +#import "AppFlowyBackendPlugin.h" +#if __has_include() +#import +#else +// Support project import fallback if the generated compatibility header +// is not copied when this plugin is created as a library. +// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 +#import "appflowy_backend-Swift.h" +#endif + +@implementation AppFlowyBackendPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftAppFlowyBackendPlugin registerWithRegistrar:registrar]; +} +@end diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.swift b/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.swift new file mode 100644 index 0000000000000..1a50e5fc5afb1 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/AppFlowyBackendPlugin.swift @@ -0,0 +1,18 @@ +import Flutter +import UIKit + +public class SwiftAppFlowyBackendPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "appflowy_backend", binaryMessenger: registrar.messenger()) + let instance = SwiftAppFlowyBackendPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + result("iOS " + UIDevice.current.systemVersion) + } + + public static func dummyMethodToEnforceBundling() { + link_me_please() + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/binding.h b/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/binding.h new file mode 100644 index 0000000000000..78992141ca082 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/ios/Classes/binding.h @@ -0,0 +1,20 @@ +#include +#include +#include +#include + +int64_t init_sdk(int64_t port, char *data); + +void async_event(int64_t port, const uint8_t *input, uintptr_t len); + +const uint8_t *sync_event(const uint8_t *input, uintptr_t len); + +int32_t set_stream_port(int64_t port); + +int32_t set_log_stream_port(int64_t port); + +void link_me_please(void); + +void rust_log(int64_t level, const char *data); + +void set_env(const char *data); diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/ios/appflowy_backend.podspec b/frontend/appflowy_flutter/packages/appflowy_backend/ios/appflowy_backend.podspec new file mode 100644 index 0000000000000..138013d18b8d0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/ios/appflowy_backend.podspec @@ -0,0 +1,26 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint appflowy_backend.podspec' to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'appflowy_backend' + s.version = '0.0.1' + s.summary = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'AppFlowy' => 'annie@appflowy.io' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '8.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + s.static_framework = true + s.vendored_libraries = "libdart_ffi.a" + s.library = "c++" +end diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart new file mode 100644 index 0000000000000..69e287f117be9 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:appflowy_backend/rust_stream.dart'; +import 'package:ffi/ffi.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:logger/logger.dart'; + +import 'ffi.dart' as ffi; + +export 'package:async/async.dart'; + +enum ExceptionType { + AppearanceSettingsIsEmpty, +} + +class FlowySDKException implements Exception { + ExceptionType type; + FlowySDKException(this.type); +} + +class FlowySDK { + static const MethodChannel _channel = MethodChannel('appflowy_backend'); + static Future get platformVersion async { + final String version = await _channel.invokeMethod('getPlatformVersion'); + return version; + } + + FlowySDK(); + + Future dispose() async {} + + Future init(String configuration) async { + ffi.set_stream_port(RustStreamReceiver.shared.port); + ffi.store_dart_post_cobject(NativeApi.postCObject); + + // On iOS, VSCode can't print logs from Rust, so we need to use a different method to print logs. + // So we use a shared port to receive logs from Rust and print them using the logger. In release mode, we don't print logs. + if (Platform.isIOS && kDebugMode) { + ffi.set_log_stream_port(RustLogStreamReceiver.logShared.port); + } + + // final completer = Completer(); + // // Create a SendPort that accepts only one message. + // final sendPort = singleCompletePort(completer); + + final code = ffi.init_sdk(0, configuration.toNativeUtf8()); + if (code != 0) { + throw Exception('Failed to initialize the SDK'); + } + // return completer.future; + } +} + +class RustLogStreamReceiver { + static RustLogStreamReceiver logShared = RustLogStreamReceiver._internal(); + late RawReceivePort _ffiPort; + late StreamController _streamController; + late StreamSubscription _subscription; + int get port => _ffiPort.sendPort.nativePort; + late Logger _logger; + + RustLogStreamReceiver._internal() { + _ffiPort = RawReceivePort(); + _streamController = StreamController(); + _ffiPort.handler = _streamController.add; + _logger = Logger( + printer: PrettyPrinter( + methodCount: 0, // number of method calls to be displayed + errorMethodCount: 8, // number of method calls if stacktrace is provided + lineLength: 120, // width of the output + colors: false, // Colorful log messages + printEmojis: false, // Print an emoji for each log message + dateTimeFormat: + DateTimeFormat.none, // Should each log print contain a timestamp + ), + level: kDebugMode ? Level.trace : Level.info, + ); + + _subscription = _streamController.stream.listen((data) { + String decodedString = utf8.decode(data); + _logger.i(decodedString); + }); + } + + factory RustLogStreamReceiver() { + return logShared; + } + + Future dispose() async { + await _streamController.close(); + await _subscription.cancel(); + _ffiPort.close(); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend_method_channel.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend_method_channel.dart new file mode 100644 index 0000000000000..86386b292c1fe --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend_method_channel.dart @@ -0,0 +1,18 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'appflowy_backend_platform_interface.dart'; + +/// An implementation of [AppFlowyBackendPlatform] that uses method channels. +class MethodChannelFlowySdk extends AppFlowyBackendPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('appflowy_backend'); + + @override + Future getPlatformVersion() async { + final version = + await methodChannel.invokeMethod('getPlatformVersion'); + return version; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend_platform_interface.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend_platform_interface.dart new file mode 100644 index 0000000000000..8bd723763bfab --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/appflowy_backend_platform_interface.dart @@ -0,0 +1,29 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'appflowy_backend_method_channel.dart'; + +abstract class AppFlowyBackendPlatform extends PlatformInterface { + /// Constructs a FlowySdkPlatform. + AppFlowyBackendPlatform() : super(token: _token); + + static final Object _token = Object(); + + static AppFlowyBackendPlatform _instance = MethodChannelFlowySdk(); + + /// The default instance of [AppFlowyBackendPlatform] to use. + /// + /// Defaults to [MethodChannelFlowySdk]. + static AppFlowyBackendPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [AppFlowyBackendPlatform] when + /// they register themselves. + static set instance(AppFlowyBackendPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future getPlatformVersion() { + throw UnimplementedError('platformVersion() has not been implemented.'); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart new file mode 100644 index 0000000000000..552c6a268f202 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/dispatch.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:convert' show utf8; +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; + +import 'package:appflowy_backend/ffi.dart' as ffi; +import 'package:appflowy_backend/log.dart'; +// ignore: unnecessary_import +import 'package:appflowy_backend/protobuf/dart-ffi/ffi_response.pb.dart'; +import 'package:appflowy_backend/protobuf/dart-ffi/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-search/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-storage/protobuf.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:ffi/ffi.dart'; +import 'package:isolates/isolates.dart'; +import 'package:isolates/ports.dart'; +import 'package:protobuf/protobuf.dart'; + +import '../protobuf/flowy-config/entities.pb.dart'; +import '../protobuf/flowy-config/event_map.pb.dart'; +import '../protobuf/flowy-date/entities.pb.dart'; +import '../protobuf/flowy-date/event_map.pb.dart'; + +import 'error.dart'; + +part 'dart_event/flowy-folder/dart_event.dart'; +part 'dart_event/flowy-user/dart_event.dart'; +part 'dart_event/flowy-database2/dart_event.dart'; +part 'dart_event/flowy-document/dart_event.dart'; +part 'dart_event/flowy-config/dart_event.dart'; +part 'dart_event/flowy-date/dart_event.dart'; +part 'dart_event/flowy-search/dart_event.dart'; +part 'dart_event/flowy-ai/dart_event.dart'; +part 'dart_event/flowy-storage/dart_event.dart'; + +enum FFIException { + RequestIsEmpty, +} + +class DispatchException implements Exception { + FFIException type; + DispatchException(this.type); +} + +class Dispatch { + static Future> asyncRequest( + FFIRequest request) { + // FFIRequest => Rust SDK + final bytesFuture = _sendToRust(request); + + // Rust SDK => FFIResponse + final responseFuture = _extractResponse(bytesFuture); + + // FFIResponse's payload is the bytes of the Response object + final payloadFuture = _extractPayload(responseFuture); + + return payloadFuture; + } +} + +Future> _extractPayload( + Future> responseFuture) { + return responseFuture.then((result) { + return result.fold( + (response) { + switch (response.code) { + case FFIStatusCode.Ok: + return FlowySuccess(Uint8List.fromList(response.payload)); + case FFIStatusCode.Err: + final errorBytes = Uint8List.fromList(response.payload); + GlobalErrorCodeNotifier.receiveErrorBytes(errorBytes); + return FlowyFailure(errorBytes); + case FFIStatusCode.Internal: + final error = utf8.decode(response.payload); + Log.error("Dispatch internal error: $error"); + return FlowyFailure(emptyBytes()); + default: + Log.error("Impossible to here"); + return FlowyFailure(emptyBytes()); + } + }, + (error) { + Log.error("Response should not be empty $error"); + return FlowyFailure(emptyBytes()); + }, + ); + }); +} + +Future> _extractResponse( + Completer bytesFuture) { + return bytesFuture.future.then((bytes) { + try { + final response = FFIResponse.fromBuffer(bytes); + return FlowySuccess(response); + } catch (e, s) { + final error = StackTraceError(e, s); + Log.error('Deserialize response failed. ${error.toString()}'); + return FlowyFailure(error.asFlowyError()); + } + }); +} + +Completer _sendToRust(FFIRequest request) { + Uint8List bytes = request.writeToBuffer(); + assert(bytes.isEmpty == false); + if (bytes.isEmpty) { + throw DispatchException(FFIException.RequestIsEmpty); + } + + final Pointer input = calloc.allocate(bytes.length); + final list = input.asTypedList(bytes.length); + list.setAll(0, bytes); + + final completer = Completer(); + final port = singleCompletePort(completer); + ffi.async_event(port.nativePort, input, bytes.length); + calloc.free(input); + + return completer; +} + +Uint8List requestToBytes(T? message) { + try { + if (message != null) { + return message.writeToBuffer(); + } else { + return emptyBytes(); + } + } catch (e, s) { + final error = StackTraceError(e, s); + Log.error('Serial request failed. ${error.toString()}'); + return emptyBytes(); + } +} + +Uint8List emptyBytes() { + return Uint8List.fromList([]); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart new file mode 100644 index 0000000000000..639945f102b39 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/dispatch/error.dart @@ -0,0 +1,119 @@ +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/dart-ffi/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; +import 'package:flutter/foundation.dart'; + +class FlowyInternalError { + late FFIStatusCode _statusCode; + late String _error; + + FFIStatusCode get statusCode { + return _statusCode; + } + + String get error { + return _error; + } + + bool get has_error { + return _statusCode != FFIStatusCode.Ok; + } + + String toString() { + return "$_statusCode: $_error"; + } + + FlowyInternalError({ + required FFIStatusCode statusCode, + required String error, + }) { + _statusCode = statusCode; + _error = error; + } +} + +class StackTraceError { + Object error; + StackTrace trace; + StackTraceError( + this.error, + this.trace, + ); + + FlowyInternalError asFlowyError() { + return FlowyInternalError( + statusCode: FFIStatusCode.Err, error: this.toString()); + } + + String toString() { + return '${error.runtimeType}. Stack trace: $trace'; + } +} + +typedef void ErrorListener(); + +/// Receive error when Rust backend send error message back to the flutter frontend +/// +class GlobalErrorCodeNotifier extends ChangeNotifier { + // Static instance with lazy initialization + static final GlobalErrorCodeNotifier _instance = + GlobalErrorCodeNotifier._internal(); + + FlowyError? _error; + + // Private internal constructor + GlobalErrorCodeNotifier._internal(); + + // Factory constructor to return the same instance + factory GlobalErrorCodeNotifier() { + return _instance; + } + + static void receiveError(FlowyError error) { + if (_instance._error?.code != error.code) { + _instance._error = error; + _instance.notifyListeners(); + } + } + + static void receiveErrorBytes(Uint8List bytes) { + try { + final error = FlowyError.fromBuffer(bytes); + if (_instance._error?.code != error.code) { + _instance._error = error; + _instance.notifyListeners(); + } + } catch (e) { + Log.error("Can not parse error bytes: $e"); + } + } + + static ErrorListener add({ + required void Function(FlowyError error) onError, + bool Function(FlowyError code)? onErrorIf, + }) { + void listener() { + final error = _instance._error; + if (error != null) { + if (onErrorIf == null || onErrorIf(error)) { + onError(error); + } + } + } + + _instance.addListener(listener); + return listener; + } + + static void remove(ErrorListener listener) { + _instance.removeListener(listener); + } +} + +extension FlowyErrorExtension on FlowyError { + bool get isAIResponseLimitExceeded => + code == ErrorCode.AIResponseLimitExceeded; + + bool get isStorageLimitExceeded => code == ErrorCode.FileStorageLimitExceeded; +} diff --git a/frontend/app_flowy/packages/flowy_sdk/lib/ffi.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/ffi.dart similarity index 76% rename from frontend/app_flowy/packages/flowy_sdk/lib/ffi.dart rename to frontend/appflowy_flutter/packages/appflowy_backend/lib/ffi.dart index 6d9d5a2416255..a1eb8947dfc57 100644 --- a/frontend/app_flowy/packages/flowy_sdk/lib/ffi.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/ffi.dart @@ -2,6 +2,7 @@ import 'dart:ffi'; import 'dart:io'; + // ignore: import_of_legacy_library_into_null_safe import 'package:ffi/ffi.dart' as ffi; import 'package:flutter/foundation.dart' as Foundation; @@ -77,17 +78,20 @@ typedef _invoke_sync_Dart = Pointer Function( /// C function `init_sdk`. int init_sdk( - Pointer path, + int port, + Pointer data, ) { - return _init_sdk(path); + return _init_sdk(port, data); } final _init_sdk_Dart _init_sdk = _dart_ffi_lib.lookupFunction<_init_sdk_C, _init_sdk_Dart>('init_sdk'); typedef _init_sdk_C = Int64 Function( + Int64 port, Pointer path, ); typedef _init_sdk_Dart = int Function( + int port, Pointer path, ); @@ -107,6 +111,22 @@ typedef _set_stream_port_Dart = int Function( int port, ); +/// C function `set log stream port`. +int set_log_stream_port(int port) { + return _set_log_stream_port(port); +} + +final _set_log_stream_port_Dart _set_log_stream_port = _dart_ffi_lib + .lookupFunction<_set_log_stream_port_C, _set_log_stream_port_Dart>( + 'set_log_stream_port'); + +typedef _set_log_stream_port_C = Int32 Function( + Int64 port, +); +typedef _set_log_stream_port_Dart = int Function( + int port, +); + /// C function `link_me_please`. void link_me_please() { _link_me_please(); @@ -133,3 +153,37 @@ typedef _store_dart_post_cobject_C = Void Function( typedef _store_dart_post_cobject_Dart = void Function( Pointer)>> ptr, ); + +void rust_log( + int level, + Pointer data, +) { + _invoke_rust_log(level, data); +} + +final _invoke_rust_log_Dart _invoke_rust_log = _dart_ffi_lib + .lookupFunction<_invoke_rust_log_C, _invoke_rust_log_Dart>('rust_log'); +typedef _invoke_rust_log_C = Void Function( + Int64 level, + Pointer data, +); +typedef _invoke_rust_log_Dart = void Function( + int level, + Pointer, +); + +/// C function `set_env`. +void set_env( + Pointer data, +) { + _set_env(data); +} + +final _set_env_Dart _set_env = + _dart_ffi_lib.lookupFunction<_set_env_C, _set_env_Dart>('set_env'); +typedef _set_env_C = Void Function( + Pointer data, +); +typedef _set_env_Dart = void Function( + Pointer data, +); diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart new file mode 100644 index 0000000000000..355a196621475 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart @@ -0,0 +1,121 @@ +// ignore: import_of_legacy_library_into_null_safe +import 'dart:ffi'; + +import 'package:ffi/ffi.dart' as ffi; +import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; + +import 'ffi.dart'; + +class Log { + static final shared = Log(); + // ignore: unused_field + late Logger _logger; + + bool _enabled = false; + + // used to disable log in tests + @visibleForTesting + bool disableLog = false; + + Log() { + _logger = Logger( + printer: PrettyPrinter( + methodCount: 2, // Number of method calls to be displayed + errorMethodCount: 8, // Number of method calls if stacktrace is provided + lineLength: 120, // Width of the output + colors: true, // Colorful log messages + printEmojis: true, // Print an emoji for each log message + ), + level: kDebugMode ? Level.trace : Level.info, + ); + } + + static void enableFlutterLog() { + shared._enabled = true; + } + + // Generic internal logging function to reduce code duplication + static void _log(Level level, int rustLevel, dynamic msg, + [dynamic error, StackTrace? stackTrace]) { + if (shared._enabled) { + switch (level) { + case Level.info: + shared._logger.i(msg, stackTrace: stackTrace); + break; + case Level.debug: + shared._logger.d(msg, stackTrace: stackTrace); + break; + case Level.warning: + shared._logger.w(msg, stackTrace: stackTrace); + break; + case Level.error: + shared._logger.e(msg, stackTrace: stackTrace); + break; + case Level.trace: + shared._logger.t(msg, stackTrace: stackTrace); + break; + default: + shared._logger.log(level, msg, stackTrace: stackTrace); + } + } + String formattedMessage = _formatMessageWithStackTrace(msg, stackTrace); + rust_log(rustLevel, toNativeUtf8(formattedMessage)); + } + + static void info(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (shared.disableLog) { + return; + } + + _log(Level.info, 0, msg, error, stackTrace); + } + + static void debug(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (shared.disableLog) { + return; + } + + _log(Level.debug, 1, msg, error, stackTrace); + } + + static void warn(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (shared.disableLog) { + return; + } + + _log(Level.warning, 3, msg, error, stackTrace); + } + + static void trace(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (shared.disableLog) { + return; + } + + _log(Level.trace, 2, msg, error, stackTrace); + } + + static void error(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (shared.disableLog) { + return; + } + + _log(Level.error, 4, msg, error, stackTrace); + } +} + +bool isReleaseVersion() { + return kReleaseMode; +} + +// Utility to convert a message to native Utf8 (used in rust_log) +Pointer toNativeUtf8(dynamic msg) { + return "$msg".toNativeUtf8(); +} + +String _formatMessageWithStackTrace(dynamic msg, StackTrace? stackTrace) { + if (stackTrace != null) { + return "$msg\nStackTrace:\n$stackTrace"; // Append the stack trace to the message + } + return msg.toString(); +} diff --git a/frontend/app_flowy/packages/flowy_sdk/lib/rust_stream.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/rust_stream.dart similarity index 75% rename from frontend/app_flowy/packages/flowy_sdk/lib/rust_stream.dart rename to frontend/appflowy_flutter/packages/appflowy_backend/lib/rust_stream.dart index 0aaa565e43caf..df904c2d19610 100644 --- a/frontend/app_flowy/packages/flowy_sdk/lib/rust_stream.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/rust_stream.dart @@ -1,9 +1,10 @@ -import 'dart:isolate'; import 'dart:async'; -import 'dart:typed_data'; import 'dart:ffi'; -import 'package:flowy_sdk/log.dart'; -import 'protobuf/dart-notify/subject.pb.dart'; +import 'dart:isolate'; +import 'dart:typed_data'; +import 'package:appflowy_backend/log.dart'; + +import 'protobuf/flowy-notification/subject.pb.dart'; typedef ObserverCallback = void Function(SubscribeObject observable); @@ -23,23 +24,25 @@ class RustStreamReceiver { _observableController = StreamController.broadcast(); _ffiPort.handler = _streamController.add; - _ffiSubscription = _streamController.stream.listen(streamCallback); + _ffiSubscription = _streamController.stream.listen(_streamCallback); } factory RustStreamReceiver() { return shared; } - static StreamSubscription listen(void Function(SubscribeObject subject) callback) { + static StreamSubscription listen( + void Function(SubscribeObject subject) callback) { return RustStreamReceiver.shared.observable.stream.listen(callback); } - void streamCallback(Uint8List bytes) { + void _streamCallback(Uint8List bytes) { try { final observable = SubscribeObject.fromBuffer(bytes); _observableController.add(observable); } catch (e, s) { - Log.error('RustStreamReceiver SubscribeObject deserialize error: ${e.runtimeType}'); + Log.error( + 'RustStreamReceiver SubscribeObject deserialize error: ${e.runtimeType}'); Log.error('Stack trace \n $s'); rethrow; } diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/linux/Classes/binding.h b/frontend/appflowy_flutter/packages/appflowy_backend/linux/Classes/binding.h new file mode 100644 index 0000000000000..c14543eec1ccf --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/linux/Classes/binding.h @@ -0,0 +1,19 @@ +#include +#include +#include +#include + + +int64_t init_sdk(char *path); + +void async_command(int64_t port, const uint8_t *input, uintptr_t len); + +const uint8_t *sync_command(const uint8_t *input, uintptr_t len); + +int32_t set_stream_port(int64_t port); + +void link_me_please(void); + +void rust_log(int64_t level, const char *data); + +void set_env(const char *data); diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/macos/Classes/AppFlowyBackendPlugin.swift b/frontend/appflowy_flutter/packages/appflowy_backend/macos/Classes/AppFlowyBackendPlugin.swift new file mode 100644 index 0000000000000..e482a870861fe --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/macos/Classes/AppFlowyBackendPlugin.swift @@ -0,0 +1,23 @@ +import Cocoa +import FlutterMacOS + +public class AppFlowyBackendPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "appflowy_backend", binaryMessenger: registrar.messenger) + let instance = AppFlowyBackendPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("macOS " + ProcessInfo.processInfo.operatingSystemVersionString) + default: + result(FlutterMethodNotImplemented) + } + } + + public static func dummyMethodToEnforceBundling() { + link_me_please() + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/macos/Classes/binding.h b/frontend/appflowy_flutter/packages/appflowy_backend/macos/Classes/binding.h new file mode 100644 index 0000000000000..78992141ca082 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/macos/Classes/binding.h @@ -0,0 +1,20 @@ +#include +#include +#include +#include + +int64_t init_sdk(int64_t port, char *data); + +void async_event(int64_t port, const uint8_t *input, uintptr_t len); + +const uint8_t *sync_event(const uint8_t *input, uintptr_t len); + +int32_t set_stream_port(int64_t port); + +int32_t set_log_stream_port(int64_t port); + +void link_me_please(void); + +void rust_log(int64_t level, const char *data); + +void set_env(const char *data); diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/macos/appflowy_backend.podspec b/frontend/appflowy_flutter/packages/appflowy_backend/macos/appflowy_backend.podspec new file mode 100644 index 0000000000000..00d9b71180963 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/macos/appflowy_backend.podspec @@ -0,0 +1,25 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint appflowy_backend.podspec' to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'appflowy_backend' + s.version = '0.0.1' + s.summary = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'AppFlowy' => 'annie@appflowy.io' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.13' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' + s.static_framework = true + s.vendored_libraries = "libdart_ffi.a" +end diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml new file mode 100644 index 0000000000000..9ff267929a1b0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/pubspec.yaml @@ -0,0 +1,78 @@ +name: appflowy_backend +description: A new flutter plugin project. +version: 0.0.1 +homepage: https://appflowy.io +publish_to: "none" + +environment: + sdk: ">=2.17.0-0 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + ffi: ^2.0.2 + isolates: ^3.0.3+8 + protobuf: ^3.1.0 + logger: ^2.4.0 + plugin_platform_interface: ^2.1.3 + appflowy_result: + path: ../appflowy_result + fixnum: ^1.1.0 + async: ^2.11.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' and Android 'package' identifiers should not ordinarily + # be modified. They are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.plugin.appflowy_backend + pluginClass: AppFlowyBackendPlugin + ios: + pluginClass: AppFlowyBackendPlugin + macos: + pluginClass: AppFlowyBackendPlugin + windows: + pluginClass: AppFlowyBackendPlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/test/appflowy_backend_method_channel_test.dart b/frontend/appflowy_flutter/packages/appflowy_backend/test/appflowy_backend_method_channel_test.dart new file mode 100644 index 0000000000000..46807487fd8e9 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/test/appflowy_backend_method_channel_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:appflowy_backend/appflowy_backend_method_channel.dart'; + +void main() { + MethodChannelFlowySdk platform = MethodChannelFlowySdk(); + const MethodChannel channel = MethodChannel('appflowy_backend'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (MethodCall methodCall) async { + return '42'; + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + null, + ); + }); + + test('getPlatformVersion', () async { + expect(await platform.getPlatformVersion(), '42'); + }); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/test/appflowy_backend_test.dart b/frontend/appflowy_flutter/packages/appflowy_backend/test/appflowy_backend_test.dart new file mode 100644 index 0000000000000..30e008df615b2 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/test/appflowy_backend_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:appflowy_backend/appflowy_backend.dart'; + +void main() { + const MethodChannel channel = MethodChannel('appflowy_backend'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return '42'; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + null, + ); + }); + + test('getPlatformVersion', () async { + expect(await FlowySDK.platformVersion, '42'); + }); +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/windows/.gitignore b/frontend/appflowy_flutter/packages/appflowy_backend/windows/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/windows/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_backend/windows/.gitignore diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/windows/CMakeLists.txt b/frontend/appflowy_flutter/packages/appflowy_backend/windows/CMakeLists.txt new file mode 100644 index 0000000000000..f164febfeeaaf --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/windows/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.14) +set(PROJECT_NAME "appflowy_backend") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "appflowy_backend_plugin") + +add_library(${PLUGIN_NAME} SHARED + "appflowy_backend_plugin.cpp" +) +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) + +# List of absolute paths to libraries that should be bundled with the plugin +set(appflowy_backend_bundled_libraries + "" + PARENT_SCOPE +) + + + diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/windows/app_flowy_backend_plugin.h b/frontend/appflowy_flutter/packages/appflowy_backend/windows/app_flowy_backend_plugin.h new file mode 100644 index 0000000000000..2e191badf142e --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/windows/app_flowy_backend_plugin.h @@ -0,0 +1,32 @@ +#ifndef FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ +#define FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ + +#include +#include + +#include + +namespace appflowy_backend { + +class AppFlowyBackendPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); + + AppFlowyBackendPlugin(); + + virtual ~AppFlowyBackendPlugin(); + + // Disallow copy and assign. + AppFlowyBackendPlugin(const AppFlowyBackendPlugin&) = delete; + AppFlowyBackendPlugin& operator=(const AppFlowyBackendPlugin&) = delete; + + private: + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result); +}; + +} // namespace appflowy_backend + +#endif // FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/windows/appflowy_backend_plugin.cpp b/frontend/appflowy_flutter/packages/appflowy_backend/windows/appflowy_backend_plugin.cpp new file mode 100644 index 0000000000000..701547e02216b --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/windows/appflowy_backend_plugin.cpp @@ -0,0 +1,96 @@ + +// This must be included before many other Windows headers. +#include + +// For getPlatformVersion; remove unless needed for your plugin implementation. +#include + +#include +#include +#include + +#include +#include +#include +#include "include/appflowy_backend/app_flowy_backend_plugin.h" + +namespace +{ + + class AppFlowyBackendPlugin : public flutter::Plugin + { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); + + AppFlowyBackendPlugin(); + + virtual ~AppFlowyBackendPlugin(); + + private: + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result); + }; + + // static + void AppFlowyBackendPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows *registrar) + { + auto channel = + std::make_unique>( + registrar->messenger(), "appflowy_backend", + &flutter::StandardMethodCodec::GetInstance()); + + auto plugin = std::make_unique(); + + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto &call, auto result) + { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + + registrar->AddPlugin(std::move(plugin)); + } + + AppFlowyBackendPlugin::AppFlowyBackendPlugin() {} + + AppFlowyBackendPlugin::~AppFlowyBackendPlugin() {} + + void AppFlowyBackendPlugin::HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result) + { + if (method_call.method_name().compare("getPlatformVersion") == 0) + { + std::ostringstream version_stream; + version_stream << "Windows "; + if (IsWindows10OrGreater()) + { + version_stream << "10+"; + } + else if (IsWindows8OrGreater()) + { + version_stream << "8"; + } + else if (IsWindows7OrGreater()) + { + version_stream << "7"; + } + result->Success(flutter::EncodableValue(version_stream.str())); + } + else + { + result->NotImplemented(); + } + } + +} // namespace + +void AppFlowyBackendPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) +{ + AppFlowyBackendPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/windows/appflowy_backend_plugin_c_api.cpp b/frontend/appflowy_flutter/packages/appflowy_backend/windows/appflowy_backend_plugin_c_api.cpp new file mode 100644 index 0000000000000..58d345008b1a3 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/windows/appflowy_backend_plugin_c_api.cpp @@ -0,0 +1,13 @@ +#include "include/appflowy_backend/appflowy_backend_plugin_c_api.h" + +#include + +#include "appflowy_flutter_backend_plugin.h" + +void AppFlowyBackendPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) +{ + appflowy_backend::AppFlowyBackendPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/windows/include/appflowy_backend/app_flowy_backend_plugin.h b/frontend/appflowy_flutter/packages/appflowy_backend/windows/include/appflowy_backend/app_flowy_backend_plugin.h new file mode 100644 index 0000000000000..6aca5983f5b82 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/windows/include/appflowy_backend/app_flowy_backend_plugin.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ +#define FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void AppFlowyBackendPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_FLOWY_SDK_PLUGIN_H_ diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/windows/include/appflowy_backend/appflowy_backend_plugin_c_api.h b/frontend/appflowy_flutter/packages/appflowy_backend/windows/include/appflowy_backend/appflowy_backend_plugin_c_api.h new file mode 100644 index 0000000000000..637a544aaa1ec --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_backend/windows/include/appflowy_backend/appflowy_backend_plugin_c_api.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_appflowy_backend_plugin_c_api_H_ +#define FLUTTER_PLUGIN_appflowy_backend_plugin_c_api_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void AppFlowyBackendPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_appflowy_backend_plugin_c_api_H_ diff --git a/frontend/app_flowy/packages/appflowy_board/.gitignore b/frontend/appflowy_flutter/packages/appflowy_popover/.gitignore similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_popover/.gitignore diff --git a/frontend/app_flowy/packages/appflowy_board/.metadata b/frontend/appflowy_flutter/packages/appflowy_popover/.metadata similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/.metadata rename to frontend/appflowy_flutter/packages/appflowy_popover/.metadata diff --git a/frontend/app_flowy/packages/flowy_infra_ui/CHANGELOG.md b/frontend/appflowy_flutter/packages/appflowy_popover/CHANGELOG.md similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/CHANGELOG.md rename to frontend/appflowy_flutter/packages/appflowy_popover/CHANGELOG.md diff --git a/frontend/app_flowy/packages/flowy_infra/LICENSE b/frontend/appflowy_flutter/packages/appflowy_popover/LICENSE similarity index 100% rename from frontend/app_flowy/packages/flowy_infra/LICENSE rename to frontend/appflowy_flutter/packages/appflowy_popover/LICENSE diff --git a/frontend/app_flowy/packages/appflowy_popover/README.md b/frontend/appflowy_flutter/packages/appflowy_popover/README.md similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/README.md rename to frontend/appflowy_flutter/packages/appflowy_popover/README.md diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_popover/analysis_options.yaml new file mode 100644 index 0000000000000..75b15a0f70473 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/analysis_options.yaml @@ -0,0 +1,60 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. + +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + - require_trailing_commas + + - prefer_collection_literals + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + + - sized_box_for_whitespace + - use_decorated_box + + - unnecessary_parenthesis + - unnecessary_await_in_return + - unnecessary_raw_strings + + - avoid_unnecessary_containers + - avoid_redundant_argument_values + - avoid_unused_constructor_parameters + + - always_declare_return_types + + - sort_constructors_first + - unawaited_futures + + - prefer_single_quotes + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options + +errors: + invalid_annotation_target: ignore diff --git a/frontend/app_flowy/packages/appflowy_editor/example/.gitignore b/frontend/appflowy_flutter/packages/appflowy_popover/example/.gitignore similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_popover/example/.gitignore diff --git a/frontend/app_flowy/packages/appflowy_popover/example/.metadata b/frontend/appflowy_flutter/packages/appflowy_popover/example/.metadata similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/.metadata rename to frontend/appflowy_flutter/packages/appflowy_popover/example/.metadata diff --git a/frontend/app_flowy/packages/appflowy_board/example/README.md b/frontend/appflowy_flutter/packages/appflowy_popover/example/README.md similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/README.md rename to frontend/appflowy_flutter/packages/appflowy_popover/example/README.md diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/example/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_popover/example/analysis_options.yaml new file mode 100644 index 0000000000000..75b15a0f70473 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/analysis_options.yaml @@ -0,0 +1,60 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. + +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + - require_trailing_commas + + - prefer_collection_literals + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + + - sized_box_for_whitespace + - use_decorated_box + + - unnecessary_parenthesis + - unnecessary_await_in_return + - unnecessary_raw_strings + + - avoid_unnecessary_containers + - avoid_redundant_argument_values + - avoid_unused_constructor_parameters + + - always_declare_return_types + + - sort_constructors_first + - unawaited_futures + + - prefer_single_quotes + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options + +errors: + invalid_annotation_target: ignore diff --git a/frontend/app_flowy/android/.gitignore b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/.gitignore similarity index 100% rename from frontend/app_flowy/android/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/.gitignore diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/build.gradle b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/build.gradle similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/app/build.gradle rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/build.gradle diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/debug/AndroidManifest.xml b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/debug/AndroidManifest.xml similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/app/src/debug/AndroidManifest.xml rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/debug/AndroidManifest.xml diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/AndroidManifest.xml b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/AndroidManifest.xml similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/AndroidManifest.xml rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/AndroidManifest.xml diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/drawable-v21/launch_background.xml b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/drawable-v21/launch_background.xml similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/drawable-v21/launch_background.xml rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/drawable-v21/launch_background.xml diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/drawable/launch_background.xml b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/drawable/launch_background.xml similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/drawable/launch_background.xml rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/drawable/launch_background.xml diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/values-night/styles.xml b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/values-night/styles.xml similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/values-night/styles.xml rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/values-night/styles.xml diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/values/styles.xml b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/values/styles.xml similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/app/src/main/res/values/styles.xml rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/main/res/values/styles.xml diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/profile/AndroidManifest.xml b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/profile/AndroidManifest.xml similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/app/src/profile/AndroidManifest.xml rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/app/src/profile/AndroidManifest.xml diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/build.gradle b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/build.gradle similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/build.gradle rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/build.gradle diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/gradle.properties b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/gradle.properties similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/android/gradle.properties rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/gradle.properties diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/gradle/wrapper/gradle-wrapper.properties b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/gradle/wrapper/gradle-wrapper.properties rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/gradle/wrapper/gradle-wrapper.properties diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/settings.gradle b/frontend/appflowy_flutter/packages/appflowy_popover/example/android/settings.gradle similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/settings.gradle rename to frontend/appflowy_flutter/packages/appflowy_popover/example/android/settings.gradle diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/.gitignore b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/.gitignore similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/.gitignore diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/AppFrameworkInfo.plist b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000000..7c56964006274 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Flutter/Debug.xcconfig b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/Debug.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Flutter/Debug.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/Debug.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Flutter/Release.xcconfig b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/Release.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Flutter/Release.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Flutter/Release.xcconfig diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000000..2d5f6c6b7c370 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,484 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000000..5e31d3d342f3c --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcworkspace/contents.xcworkspacedata rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/AppDelegate.swift b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/AppDelegate.swift similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/AppDelegate.swift rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/AppDelegate.swift diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Base.lproj/LaunchScreen.storyboard rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Base.lproj/LaunchScreen.storyboard diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Base.lproj/Main.storyboard b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Base.lproj/Main.storyboard similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Base.lproj/Main.storyboard rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Base.lproj/Main.storyboard diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Info.plist b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Info.plist new file mode 100644 index 0000000000000..7f553465b77e1 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Info.plist @@ -0,0 +1,51 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Runner-Bridging-Header.h b/frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Runner-Bridging-Header.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Runner-Bridging-Header.h rename to frontend/appflowy_flutter/packages/appflowy_popover/example/ios/Runner/Runner-Bridging-Header.h diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart b/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart new file mode 100644 index 0000000000000..ebd757aad3ea9 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart @@ -0,0 +1,111 @@ +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; + +class PopoverMenu extends StatefulWidget { + const PopoverMenu({super.key}); + + @override + State createState() => _PopoverMenuState(); +} + +class _PopoverMenuState extends State { + final PopoverMutex popOverMutex = PopoverMutex(); + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.all(Radius.circular(8)), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.5), + spreadRadius: 5, + blurRadius: 7, + offset: const Offset(0, 3), // changes position of shadow + ), + ], + ), + child: ListView( + children: [ + Container( + margin: const EdgeInsets.all(8), + child: const Text( + 'Popover', + style: TextStyle( + fontSize: 14, + color: Colors.black, + ), + ), + ), + Popover( + triggerActions: + PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + mutex: popOverMutex, + offset: const Offset(10, 0), + asBarrier: true, + debugId: 'First', + popupBuilder: (BuildContext context) { + return const PopoverMenu(); + }, + child: TextButton( + onPressed: () {}, + child: const Text('First'), + ), + ), + Popover( + triggerActions: + PopoverTriggerFlags.hover | PopoverTriggerFlags.click, + mutex: popOverMutex, + asBarrier: true, + debugId: 'Second', + offset: const Offset(10, 0), + popupBuilder: (BuildContext context) { + return const PopoverMenu(); + }, + child: TextButton( + onPressed: () {}, + child: const Text('Second'), + ), + ), + ], + ), + ), + ); + } +} + +class ExampleButton extends StatelessWidget { + const ExampleButton({ + super.key, + required this.label, + required this.direction, + this.offset = Offset.zero, + }); + + final String label; + final Offset? offset; + final PopoverDirection direction; + + @override + Widget build(BuildContext context) { + return Popover( + triggerActions: PopoverTriggerFlags.click, + animationDuration: Durations.medium1, + offset: offset, + direction: direction, + debugId: label, + child: TextButton( + child: Text(label), + onPressed: () {}, + ), + popupBuilder: (BuildContext context) { + return const PopoverMenu(); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/main.dart b/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/main.dart new file mode 100644 index 0000000000000..80c4dc6f72eb1 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/main.dart @@ -0,0 +1,116 @@ +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; + +import './example_button.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: const MyHomePage(title: 'AppFlowy Popover Example'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + final String title; + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: const Padding( + padding: EdgeInsets.symmetric(horizontal: 48.0, vertical: 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ExampleButton( + label: 'Left top', + offset: Offset(0, 10), + direction: PopoverDirection.bottomWithLeftAligned, + ), + ExampleButton( + label: 'Left Center', + offset: Offset(0, -10), + direction: PopoverDirection.rightWithCenterAligned, + ), + ExampleButton( + label: 'Left bottom', + offset: Offset(0, -10), + direction: PopoverDirection.topWithLeftAligned, + ), + ], + ), + Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ExampleButton( + label: 'Top', + offset: Offset(0, 10), + direction: PopoverDirection.bottomWithCenterAligned, + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ExampleButton( + label: 'Central', + offset: Offset(0, 10), + direction: PopoverDirection.bottomWithCenterAligned, + ), + ], + ), + ExampleButton( + label: 'Bottom', + offset: Offset(0, -10), + direction: PopoverDirection.topWithCenterAligned, + ), + ], + ), + Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ExampleButton( + label: 'Right top', + offset: Offset(0, 10), + direction: PopoverDirection.bottomWithRightAligned, + ), + ExampleButton( + label: 'Right Center', + offset: Offset(0, 10), + direction: PopoverDirection.leftWithCenterAligned, + ), + ExampleButton( + label: 'Right bottom', + offset: Offset(0, -10), + direction: PopoverDirection.topWithRightAligned, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/frontend/app_flowy/packages/appflowy_board/example/linux/.gitignore b/frontend/appflowy_flutter/packages/appflowy_popover/example/linux/.gitignore similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/linux/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_popover/example/linux/.gitignore diff --git a/frontend/app_flowy/packages/appflowy_editor/example/linux/CMakeLists.txt b/frontend/appflowy_flutter/packages/appflowy_popover/example/linux/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/linux/CMakeLists.txt rename to frontend/appflowy_flutter/packages/appflowy_popover/example/linux/CMakeLists.txt diff --git a/frontend/app_flowy/packages/appflowy_board/example/linux/flutter/CMakeLists.txt b/frontend/appflowy_flutter/packages/appflowy_popover/example/linux/flutter/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/linux/flutter/CMakeLists.txt rename to frontend/appflowy_flutter/packages/appflowy_popover/example/linux/flutter/CMakeLists.txt diff --git a/frontend/app_flowy/packages/appflowy_board/example/linux/main.cc b/frontend/appflowy_flutter/packages/appflowy_popover/example/linux/main.cc similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/linux/main.cc rename to frontend/appflowy_flutter/packages/appflowy_popover/example/linux/main.cc diff --git a/frontend/app_flowy/packages/appflowy_board/example/linux/my_application.cc b/frontend/appflowy_flutter/packages/appflowy_popover/example/linux/my_application.cc similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/linux/my_application.cc rename to frontend/appflowy_flutter/packages/appflowy_popover/example/linux/my_application.cc diff --git a/frontend/app_flowy/packages/appflowy_board/example/linux/my_application.h b/frontend/appflowy_flutter/packages/appflowy_popover/example/linux/my_application.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/linux/my_application.h rename to frontend/appflowy_flutter/packages/appflowy_popover/example/linux/my_application.h diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/.gitignore b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/.gitignore similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/.gitignore diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Flutter/Flutter-Debug.xcconfig b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Flutter/Flutter-Debug.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Flutter/Flutter-Debug.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Flutter/Flutter-Debug.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Flutter/Flutter-Release.xcconfig b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Flutter/Flutter-Release.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Flutter/Flutter-Release.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Flutter/Flutter-Release.xcconfig diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000000..d9ae3f484ae08 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,573 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example.app */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000000..5b055a3a376ea --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcworkspace/contents.xcworkspacedata rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/AppDelegate.swift b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/AppDelegate.swift similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/AppDelegate.swift rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/AppDelegate.swift diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Base.lproj/MainMenu.xib b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Base.lproj/MainMenu.xib similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/macos/Runner/Base.lproj/MainMenu.xib rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Base.lproj/MainMenu.xib diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/AppInfo.xcconfig b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Configs/AppInfo.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/AppInfo.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Configs/AppInfo.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/Debug.xcconfig b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Configs/Debug.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/Debug.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Configs/Debug.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/Release.xcconfig b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Configs/Release.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/Release.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Configs/Release.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/Warnings.xcconfig b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Configs/Warnings.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/Warnings.xcconfig rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Configs/Warnings.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/DebugProfile.entitlements b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/DebugProfile.entitlements similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/DebugProfile.entitlements rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/DebugProfile.entitlements diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Info.plist b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Info.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Info.plist rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Info.plist diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/MainFlutterWindow.swift b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/MainFlutterWindow.swift similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/MainFlutterWindow.swift rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/MainFlutterWindow.swift diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Release.entitlements b/frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Release.entitlements similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Release.entitlements rename to frontend/appflowy_flutter/packages/appflowy_popover/example/macos/Runner/Release.entitlements diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/example/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_popover/example/pubspec.yaml new file mode 100644 index 0000000000000..e1def9a5ef3fd --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/pubspec.yaml @@ -0,0 +1,89 @@ +name: example +description: A new Flutter project. + +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.0+1 + +environment: + sdk: ">=3.3.0 <4.0.0" + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + appflowy_popover: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^3.0.1 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/packages/appflowy_board/example/web/favicon.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/web/favicon.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/web/favicon.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/web/favicon.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/web/icons/Icon-192.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/web/icons/Icon-192.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/web/icons/Icon-192.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/web/icons/Icon-192.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/web/icons/Icon-512.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/web/icons/Icon-512.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/web/icons/Icon-512.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/web/icons/Icon-512.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/web/icons/Icon-maskable-192.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/web/icons/Icon-maskable-192.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/web/icons/Icon-maskable-192.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/web/icons/Icon-maskable-192.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/web/icons/Icon-maskable-512.png b/frontend/appflowy_flutter/packages/appflowy_popover/example/web/icons/Icon-maskable-512.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/web/icons/Icon-maskable-512.png rename to frontend/appflowy_flutter/packages/appflowy_popover/example/web/icons/Icon-maskable-512.png diff --git a/frontend/app_flowy/packages/appflowy_board/example/web/index.html b/frontend/appflowy_flutter/packages/appflowy_popover/example/web/index.html similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/web/index.html rename to frontend/appflowy_flutter/packages/appflowy_popover/example/web/index.html diff --git a/frontend/app_flowy/packages/appflowy_board/example/web/manifest.json b/frontend/appflowy_flutter/packages/appflowy_popover/example/web/manifest.json similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/web/manifest.json rename to frontend/appflowy_flutter/packages/appflowy_popover/example/web/manifest.json diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/.gitignore b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/.gitignore similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/windows/.gitignore rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/.gitignore diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/CMakeLists.txt b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/CMakeLists.txt rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/CMakeLists.txt diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/flutter/CMakeLists.txt b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/flutter/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/flutter/CMakeLists.txt rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/flutter/CMakeLists.txt diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/runner/CMakeLists.txt b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/runner/CMakeLists.txt rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/CMakeLists.txt diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/Runner.rc b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/Runner.rc similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/windows/runner/Runner.rc rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/Runner.rc diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/flutter_window.cpp b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/flutter_window.cpp similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/windows/runner/flutter_window.cpp rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/flutter_window.cpp diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/flutter_window.h b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/flutter_window.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/windows/runner/flutter_window.h rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/flutter_window.h diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/runner/main.cpp b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/main.cpp similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/runner/main.cpp rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/main.cpp diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/resource.h b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/resource.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/windows/runner/resource.h rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/resource.h diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/resources/app_icon.ico b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/resources/app_icon.ico similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/windows/runner/resources/app_icon.ico rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/resources/app_icon.ico diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/runner.exe.manifest b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/runner.exe.manifest similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/windows/runner/runner.exe.manifest rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/runner.exe.manifest diff --git a/frontend/app_flowy/packages/appflowy_board/example/windows/runner/utils.cpp b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/utils.cpp similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/windows/runner/utils.cpp rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/utils.cpp diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/utils.h b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/utils.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/windows/runner/utils.h rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/utils.h diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/win32_window.cpp b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/win32_window.cpp similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/windows/runner/win32_window.cpp rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/win32_window.cpp diff --git a/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/win32_window.h b/frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/win32_window.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/windows/runner/win32_window.h rename to frontend/appflowy_flutter/packages/appflowy_popover/example/windows/runner/win32_window.h diff --git a/frontend/app_flowy/packages/appflowy_popover/lib/appflowy_popover.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/appflowy_popover.dart similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/lib/appflowy_popover.dart rename to frontend/appflowy_flutter/packages/appflowy_popover/lib/appflowy_popover.dart diff --git a/frontend/app_flowy/packages/appflowy_popover/lib/src/follower.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/follower.dart similarity index 84% rename from frontend/app_flowy/packages/appflowy_popover/lib/src/follower.dart rename to frontend/appflowy_flutter/packages/appflowy_popover/lib/src/follower.dart index ff54eaac616df..1a61851a71af3 100644 --- a/frontend/app_flowy/packages/appflowy_popover/lib/src/follower.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/follower.dart @@ -27,7 +27,9 @@ class PopoverCompositedTransformFollower extends CompositedTransformFollower { @override void updateRenderObject( - BuildContext context, PopoverRenderFollowerLayer renderObject) { + BuildContext context, + PopoverRenderFollowerLayer renderObject, + ) { final screenSize = MediaQuery.of(context).size; renderObject ..screenSize = screenSize @@ -40,8 +42,6 @@ class PopoverCompositedTransformFollower extends CompositedTransformFollower { } class PopoverRenderFollowerLayer extends RenderFollowerLayer { - Size screenSize; - PopoverRenderFollowerLayer({ required super.link, super.showWhenUnlinked = true, @@ -52,6 +52,8 @@ class PopoverRenderFollowerLayer extends RenderFollowerLayer { required this.screenSize, }); + Size screenSize; + @override void paint(PaintingContext context, Offset offset) { super.paint(context, offset); @@ -59,13 +61,6 @@ class PopoverRenderFollowerLayer extends RenderFollowerLayer { if (link.leader == null) { return; } - - if (link.leader!.offset.dx + link.leaderSize!.width + size.width > - screenSize.width) { - debugPrint("over flow"); - } - debugPrint( - "right: ${link.leader!.offset.dx + link.leaderSize!.width + size.width}, screen with: ${screenSize.width}"); } } diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/layout.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/layout.dart new file mode 100644 index 0000000000000..f297e901c848f --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/layout.dart @@ -0,0 +1,310 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import './popover.dart'; + +class PopoverLayoutDelegate extends SingleChildLayoutDelegate { + PopoverLayoutDelegate({ + required this.link, + required this.direction, + required this.offset, + required this.windowPadding, + this.position, + this.showAtCursor = false, + }); + + PopoverLink link; + PopoverDirection direction; + final Offset offset; + final EdgeInsets windowPadding; + + /// Required when [showAtCursor] is true. + /// + final Offset? position; + + /// If true, the popover will be shown at the cursor position. + /// This will ignore the [direction], and the child size. + /// + final bool showAtCursor; + + @override + bool shouldRelayout(PopoverLayoutDelegate oldDelegate) { + if (direction != oldDelegate.direction) { + return true; + } + + if (link != oldDelegate.link) { + return true; + } + + if (link.leaderOffset != oldDelegate.link.leaderOffset) { + return true; + } + + if (link.leaderSize != oldDelegate.link.leaderSize) { + return true; + } + + return false; + } + + @override + Size getSize(BoxConstraints constraints) { + return Size( + constraints.maxWidth - windowPadding.left - windowPadding.right, + constraints.maxHeight - windowPadding.top - windowPadding.bottom, + ); + } + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return BoxConstraints( + maxWidth: constraints.maxWidth - windowPadding.left - windowPadding.right, + maxHeight: + constraints.maxHeight - windowPadding.top - windowPadding.bottom, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final effectiveOffset = link.leaderOffset; + final leaderSize = link.leaderSize; + + if (effectiveOffset == null || leaderSize == null) { + return Offset.zero; + } + + Offset position; + if (showAtCursor && this.position != null) { + position = this.position! + + Offset( + effectiveOffset.dx + offset.dx, + effectiveOffset.dy + offset.dy, + ); + } else { + final anchorRect = Rect.fromLTWH( + effectiveOffset.dx + offset.dx, + effectiveOffset.dy + offset.dy, + leaderSize.width, + leaderSize.height, + ); + + switch (direction) { + case PopoverDirection.topLeft: + position = Offset( + anchorRect.left - childSize.width, + anchorRect.top - childSize.height, + ); + break; + case PopoverDirection.topRight: + position = Offset( + anchorRect.right, + anchorRect.top - childSize.height, + ); + break; + case PopoverDirection.bottomLeft: + position = Offset( + anchorRect.left - childSize.width, + anchorRect.bottom, + ); + break; + case PopoverDirection.bottomRight: + position = Offset( + anchorRect.right, + anchorRect.bottom, + ); + break; + case PopoverDirection.center: + position = anchorRect.center; + break; + case PopoverDirection.topWithLeftAligned: + position = Offset( + anchorRect.left, + anchorRect.top - childSize.height, + ); + break; + case PopoverDirection.topWithCenterAligned: + position = Offset( + anchorRect.left + anchorRect.width / 2.0 - childSize.width / 2.0, + anchorRect.top - childSize.height, + ); + break; + case PopoverDirection.topWithRightAligned: + position = Offset( + anchorRect.right - childSize.width, + anchorRect.top - childSize.height, + ); + break; + case PopoverDirection.rightWithTopAligned: + position = Offset(anchorRect.right, anchorRect.top); + break; + case PopoverDirection.rightWithCenterAligned: + position = Offset( + anchorRect.right, + anchorRect.top + anchorRect.height / 2.0 - childSize.height / 2.0, + ); + break; + case PopoverDirection.rightWithBottomAligned: + position = Offset( + anchorRect.right, + anchorRect.bottom - childSize.height, + ); + break; + case PopoverDirection.bottomWithLeftAligned: + position = Offset( + anchorRect.left, + anchorRect.bottom, + ); + break; + case PopoverDirection.bottomWithCenterAligned: + position = Offset( + anchorRect.left + anchorRect.width / 2.0 - childSize.width / 2.0, + anchorRect.bottom, + ); + break; + case PopoverDirection.bottomWithRightAligned: + position = Offset( + anchorRect.right - childSize.width, + anchorRect.bottom, + ); + break; + case PopoverDirection.leftWithTopAligned: + position = Offset( + anchorRect.left - childSize.width, + anchorRect.top, + ); + break; + case PopoverDirection.leftWithCenterAligned: + position = Offset( + anchorRect.left - childSize.width, + anchorRect.top + anchorRect.height / 2.0 - childSize.height / 2.0, + ); + break; + case PopoverDirection.leftWithBottomAligned: + position = Offset( + anchorRect.left - childSize.width, + anchorRect.bottom - childSize.height, + ); + break; + default: + throw UnimplementedError(); + } + } + + return Offset( + math.max( + windowPadding.left, + math.min( + windowPadding.left + size.width - childSize.width, + position.dx, + ), + ), + math.max( + windowPadding.top, + math.min( + windowPadding.top + size.height - childSize.height, + position.dy, + ), + ), + ); + } + + PopoverLayoutDelegate copyWith({ + PopoverLink? link, + PopoverDirection? direction, + Offset? offset, + EdgeInsets? windowPadding, + Offset? position, + bool? showAtCursor, + }) { + return PopoverLayoutDelegate( + link: link ?? this.link, + direction: direction ?? this.direction, + offset: offset ?? this.offset, + windowPadding: windowPadding ?? this.windowPadding, + position: position ?? this.position, + showAtCursor: showAtCursor ?? this.showAtCursor, + ); + } +} + +class PopoverTarget extends SingleChildRenderObjectWidget { + const PopoverTarget({ + super.key, + super.child, + required this.link, + }); + + final PopoverLink link; + + @override + PopoverTargetRenderBox createRenderObject(BuildContext context) { + return PopoverTargetRenderBox( + link: link, + ); + } + + @override + void updateRenderObject( + BuildContext context, + PopoverTargetRenderBox renderObject, + ) { + renderObject.link = link; + } +} + +class PopoverTargetRenderBox extends RenderProxyBox { + PopoverTargetRenderBox({ + required this.link, + RenderBox? child, + }) : super(child); + + PopoverLink link; + + @override + bool get alwaysNeedsCompositing => true; + + @override + void performLayout() { + super.performLayout(); + link.leaderSize = size; + } + + @override + void paint(PaintingContext context, Offset offset) { + link.leaderOffset = localToGlobal(Offset.zero); + super.paint(context, offset); + } + + @override + void detach() { + super.detach(); + link.leaderOffset = null; + link.leaderSize = null; + } + + @override + void attach(covariant PipelineOwner owner) { + super.attach(owner); + if (hasSize) { + // The leaderSize was set after [performLayout], but was + // set to null when [detach] get called. + // + // set the leaderSize when attach get called + link.leaderSize = size; + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('link', link)); + } +} + +class PopoverLink { + Offset? leaderOffset; + Size? leaderSize; +} diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mask.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mask.dart new file mode 100644 index 0000000000000..8e740cb6d23ca --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mask.dart @@ -0,0 +1,103 @@ +import 'dart:collection'; + +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; + +typedef _EntryMap = LinkedHashMap; + +class RootOverlayEntry { + final _EntryMap _entries = _EntryMap(); + + bool contains(PopoverState state) => _entries.containsKey(state); + + bool get isEmpty => _entries.isEmpty; + bool get isNotEmpty => _entries.isNotEmpty; + + void addEntry( + BuildContext context, + String id, + PopoverState newState, + OverlayEntry entry, + bool asBarrier, + AnimationController animationController, + ) { + _entries[newState] = OverlayEntryContext( + id, + entry, + newState, + asBarrier, + animationController, + ); + Overlay.of(context).insert(entry); + } + + void removeEntry(PopoverState state) { + final removedEntry = _entries.remove(state); + removedEntry?.overlayEntry.remove(); + } + + OverlayEntryContext? popEntry() { + if (isEmpty) { + return null; + } + + final lastEntry = _entries.values.last; + _entries.remove(lastEntry.popoverState); + lastEntry.animationController.reverse().then((_) { + lastEntry.overlayEntry.remove(); + lastEntry.popoverState.widget.onClose?.call(); + }); + + return lastEntry.asBarrier ? lastEntry : popEntry(); + } + + bool isLastEntryAsBarrier() { + if (isEmpty) { + return false; + } + + return _entries.values.last.asBarrier; + } +} + +class OverlayEntryContext { + OverlayEntryContext( + this.id, + this.overlayEntry, + this.popoverState, + this.asBarrier, + this.animationController, + ); + + final String id; + final OverlayEntry overlayEntry; + final PopoverState popoverState; + final bool asBarrier; + final AnimationController animationController; + + @override + String toString() { + return 'OverlayEntryContext(id: $id, asBarrier: $asBarrier, popoverState: ${popoverState.widget.debugId})'; + } +} + +class PopoverMask extends StatelessWidget { + const PopoverMask({ + super.key, + required this.onTap, + this.decoration, + }); + + final VoidCallback onTap; + final Decoration? decoration; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + decoration: decoration, + ), + ); + } +} diff --git a/frontend/app_flowy/packages/appflowy_popover/lib/src/mutex.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mutex.dart similarity index 83% rename from frontend/app_flowy/packages/appflowy_popover/lib/src/mutex.dart rename to frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mutex.dart index 8ab88e98e1b1a..be20803ebac07 100644 --- a/frontend/app_flowy/packages/appflowy_popover/lib/src/mutex.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mutex.dart @@ -5,20 +5,16 @@ import 'popover.dart'; /// If multiple popovers are exclusive, /// pass the same mutex to them. class PopoverMutex { - final _PopoverStateNotifier _stateNotifier = _PopoverStateNotifier(); PopoverMutex(); - void removePopoverListener(VoidCallback listener) { - _stateNotifier.removeListener(listener); - } + final _PopoverStateNotifier _stateNotifier = _PopoverStateNotifier(); - VoidCallback listenOnPopoverChanged(VoidCallback callback) { - listenerCallback() { - callback(); - } + void addPopoverListener(VoidCallback listener) { + _stateNotifier.addListener(listener); + } - _stateNotifier.addListener(listenerCallback); - return listenerCallback; + void removePopoverListener(VoidCallback listener) { + _stateNotifier.removeListener(listener); } void close() => _stateNotifier.state?.close(); diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart new file mode 100644 index 0000000000000..7af2bdae48710 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart @@ -0,0 +1,559 @@ +import 'package:appflowy_popover/src/layout.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'mask.dart'; +import 'mutex.dart'; + +class PopoverController { + PopoverState? _state; + + void close() => _state?.close(); + void show() => _state?.showOverlay(); + void showAt(Offset position) => _state?.showOverlay(position); +} + +class PopoverTriggerFlags { + static const int none = 0x00; + static const int click = 0x01; + static const int hover = 0x02; + static const int secondaryClick = 0x04; +} + +enum PopoverDirection { + // Corner aligned with a corner of the SourceWidget + topLeft, + topRight, + bottomLeft, + bottomRight, + center, + + // Edge aligned with a edge of the SourceWidget + topWithLeftAligned, + topWithCenterAligned, + topWithRightAligned, + rightWithTopAligned, + rightWithCenterAligned, + rightWithBottomAligned, + bottomWithLeftAligned, + bottomWithCenterAligned, + bottomWithRightAligned, + leftWithTopAligned, + leftWithCenterAligned, + leftWithBottomAligned, + + custom, +} + +enum PopoverClickHandler { + listener, + gestureDetector, +} + +class Popover extends StatefulWidget { + const Popover({ + super.key, + required this.child, + required this.popupBuilder, + this.controller, + this.offset, + this.triggerActions = 0, + this.direction = PopoverDirection.rightWithTopAligned, + this.mutex, + this.windowPadding, + this.onOpen, + this.onClose, + this.canClose, + this.asBarrier = false, + this.clickHandler = PopoverClickHandler.listener, + this.skipTraversal = false, + this.animationDuration = const Duration(milliseconds: 200), + this.beginOpacity = 0.0, + this.endOpacity = 1.0, + this.beginScaleFactor = 1.0, + this.endScaleFactor = 1.0, + this.slideDistance = 5.0, + this.debugId, + this.maskDecoration = const BoxDecoration( + color: Color.fromARGB(0, 244, 67, 54), + ), + this.showAtCursor = false, + }); + + final PopoverController? controller; + + /// The offset from the [child] where the popover will be drawn + final Offset? offset; + + /// Amount of padding between the edges of the window and the popover + final EdgeInsets? windowPadding; + + final Decoration? maskDecoration; + + /// The function used to build the popover. + final Widget? Function(BuildContext context) popupBuilder; + + /// Specify how the popover can be triggered when interacting with the child + /// by supplying a bitwise-OR combination of one or more [PopoverTriggerFlags] + final int triggerActions; + + /// If multiple popovers are exclusive, + /// pass the same mutex to them. + final PopoverMutex? mutex; + + /// The direction of the popover + final PopoverDirection direction; + + final VoidCallback? onOpen; + final VoidCallback? onClose; + final Future Function()? canClose; + + final bool asBarrier; + + /// The widget that will be used to trigger the popover. + /// + /// Why do we need this? + /// Because if the parent widget of the popover is GestureDetector, + /// the conflict won't be resolve by using Listener, we want these two gestures exclusive. + final PopoverClickHandler clickHandler; + + final bool skipTraversal; + + /// Animation time of the popover. + final Duration animationDuration; + + /// The distance of the popover's slide animation. + final double slideDistance; + + /// The scale factor of the popover's scale animation. + final double beginScaleFactor; + final double endScaleFactor; + + /// The opacity of the popover's fade animation. + final double beginOpacity; + final double endOpacity; + + final String? debugId; + + /// Whether the popover should be shown at the cursor position. + /// + /// This only works when using [PopoverClickHandler.listener] as the click handler. + /// + /// Alternatively for having a normal popover, and use the cursor position only on + /// secondary click, consider showing the popover programatically with [PopoverController.showAt]. + /// + final bool showAtCursor; + + /// The content area of the popover. + final Widget child; + + @override + State createState() => PopoverState(); +} + +class PopoverState extends State with SingleTickerProviderStateMixin { + static final RootOverlayEntry rootEntry = RootOverlayEntry(); + + final PopoverLink popoverLink = PopoverLink(); + late PopoverLayoutDelegate layoutDelegate = PopoverLayoutDelegate( + direction: widget.direction, + link: popoverLink, + offset: widget.offset ?? Offset.zero, + windowPadding: widget.windowPadding ?? EdgeInsets.zero, + ); + + late AnimationController animationController; + late Animation fadeAnimation; + late Animation scaleAnimation; + late Animation slideAnimation; + + // If the widget is disposed, prevent the animation from being called. + bool isDisposed = false; + + Offset? cursorPosition; + + @override + void initState() { + super.initState(); + + widget.controller?._state = this; + _buildAnimations(); + } + + @override + void deactivate() { + close(notify: false); + + super.deactivate(); + } + + @override + void dispose() { + isDisposed = true; + animationController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopoverTarget( + link: popoverLink, + child: _buildChild(context), + ); + } + + @override + void reassemble() { + // clear the overlay + while (rootEntry.isNotEmpty) { + rootEntry.popEntry(); + } + + super.reassemble(); + } + + void showOverlay([Offset? position]) { + close(withAnimation: true); + + if (widget.mutex != null) { + widget.mutex?.state = this; + } + + if (position != null) { + final RenderBox? renderBox = context.findRenderObject() as RenderBox?; + final offset = renderBox?.globalToLocal(position); + layoutDelegate = layoutDelegate.copyWith( + position: offset ?? position, + windowPadding: EdgeInsets.zero, + showAtCursor: true, + ); + } + + final shouldAddMask = rootEntry.isEmpty; + rootEntry.addEntry( + context, + widget.debugId ?? '', + this, + OverlayEntry(builder: (_) => _buildOverlayContent(shouldAddMask)), + widget.asBarrier, + animationController, + ); + + if (widget.animationDuration != Duration.zero) { + animationController.forward(); + } + } + + void close({ + bool notify = true, + bool withAnimation = false, + }) { + if (rootEntry.contains(this)) { + void callback() { + rootEntry.removeEntry(this); + if (notify) { + widget.onClose?.call(); + } + } + + if (isDisposed || + !withAnimation || + widget.animationDuration == Duration.zero) { + callback(); + } else { + animationController.reverse().then((_) => callback()); + } + } + } + + void _removeRootOverlay() { + rootEntry.popEntry(); + + if (widget.mutex?.state == this) { + widget.mutex?.removeState(); + } + } + + Widget _buildChild(BuildContext context) { + Widget child = widget.child; + + if (widget.triggerActions == 0) { + return child; + } + + child = _buildClickHandler( + child, + () { + widget.onOpen?.call(); + if (widget.triggerActions & PopoverTriggerFlags.none != 0) { + return; + } + + showOverlay(cursorPosition); + }, + ); + + if (widget.triggerActions & PopoverTriggerFlags.hover != 0) { + child = MouseRegion( + onEnter: (event) => showOverlay(), + child: child, + ); + } + + return child; + } + + Widget _buildClickHandler(Widget child, VoidCallback handler) { + return switch (widget.clickHandler) { + PopoverClickHandler.listener => Listener( + onPointerDown: (event) { + cursorPosition = widget.showAtCursor ? event.position : null; + + if (event.buttons == kSecondaryMouseButton && + widget.triggerActions & PopoverTriggerFlags.secondaryClick != + 0) { + return _callHandler(handler); + } + + if (event.buttons == kPrimaryMouseButton && + widget.triggerActions & PopoverTriggerFlags.click != 0) { + return _callHandler(handler); + } + }, + child: child, + ), + PopoverClickHandler.gestureDetector => GestureDetector( + onTap: () { + if (widget.triggerActions & PopoverTriggerFlags.click != 0) { + return _callHandler(handler); + } + }, + onSecondaryTap: () { + if (widget.triggerActions & PopoverTriggerFlags.secondaryClick != + 0) { + return _callHandler(handler); + } + }, + child: child, + ), + }; + } + + void _callHandler(VoidCallback handler) { + if (rootEntry.contains(this)) { + close(); + } else { + handler(); + } + } + + Widget _buildOverlayContent(bool shouldAddMask) { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): _removeRootOverlay, + }, + child: FocusScope( + child: Stack( + children: [ + if (shouldAddMask) _buildMask(), + _buildPopoverContainer(), + ], + ), + ), + ); + } + + Widget _buildMask() { + return PopoverMask( + decoration: widget.maskDecoration, + onTap: () async { + if (await widget.canClose?.call() ?? true) { + _removeRootOverlay(); + } + }, + ); + } + + Widget _buildPopoverContainer() { + Widget child = PopoverContainer( + delegate: layoutDelegate, + popupBuilder: widget.popupBuilder, + skipTraversal: widget.skipTraversal, + onClose: close, + onCloseAll: _removeRootOverlay, + ); + + if (widget.animationDuration != Duration.zero) { + child = AnimatedBuilder( + animation: animationController, + builder: (_, child) => Opacity( + opacity: fadeAnimation.value, + child: Transform.scale( + scale: scaleAnimation.value, + child: Transform.translate( + offset: slideAnimation.value, + child: child, + ), + ), + ), + child: child, + ); + } + + return child; + } + + void _buildAnimations() { + animationController = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + fadeAnimation = _buildFadeAnimation(); + scaleAnimation = _buildScaleAnimation(); + slideAnimation = _buildSlideAnimation(); + } + + Animation _buildFadeAnimation() { + return Tween( + begin: widget.beginOpacity, + end: widget.endOpacity, + ).animate( + CurvedAnimation( + parent: animationController, + curve: Curves.easeInOut, + ), + ); + } + + Animation _buildScaleAnimation() { + return Tween( + begin: widget.beginScaleFactor, + end: widget.endScaleFactor, + ).animate( + CurvedAnimation( + parent: animationController, + curve: Curves.easeInOut, + ), + ); + } + + Animation _buildSlideAnimation() { + final values = _getSlideAnimationValues(); + return Tween( + begin: values.$1, + end: values.$2, + ).animate( + CurvedAnimation( + parent: animationController, + curve: Curves.linear, + ), + ); + } + + (Offset, Offset) _getSlideAnimationValues() { + final slideDistance = widget.slideDistance; + + switch (widget.direction) { + case PopoverDirection.bottomWithLeftAligned: + return ( + Offset(-slideDistance, -slideDistance), + Offset.zero, + ); + case PopoverDirection.bottomWithCenterAligned: + return ( + Offset(0, -slideDistance), + Offset.zero, + ); + case PopoverDirection.bottomWithRightAligned: + return ( + Offset(slideDistance, -slideDistance), + Offset.zero, + ); + case PopoverDirection.topWithLeftAligned: + return ( + Offset(-slideDistance, slideDistance), + Offset.zero, + ); + case PopoverDirection.topWithCenterAligned: + return ( + Offset(0, slideDistance), + Offset.zero, + ); + case PopoverDirection.topWithRightAligned: + return ( + Offset(slideDistance, slideDistance), + Offset.zero, + ); + case PopoverDirection.leftWithTopAligned: + case PopoverDirection.leftWithCenterAligned: + case PopoverDirection.leftWithBottomAligned: + return ( + Offset(slideDistance, 0), + Offset.zero, + ); + case PopoverDirection.rightWithTopAligned: + case PopoverDirection.rightWithCenterAligned: + case PopoverDirection.rightWithBottomAligned: + return ( + Offset(-slideDistance, 0), + Offset.zero, + ); + default: + return (Offset.zero, Offset.zero); + } + } +} + +class PopoverContainer extends StatefulWidget { + const PopoverContainer({ + super.key, + required this.popupBuilder, + required this.delegate, + required this.onClose, + required this.onCloseAll, + required this.skipTraversal, + }); + + final Widget? Function(BuildContext context) popupBuilder; + final void Function() onClose; + final void Function() onCloseAll; + final bool skipTraversal; + final PopoverLayoutDelegate delegate; + + @override + State createState() => PopoverContainerState(); + + static PopoverContainerState of(BuildContext context) { + if (context is StatefulElement && context.state is PopoverContainerState) { + return context.state as PopoverContainerState; + } + return context.findAncestorStateOfType()!; + } + + static PopoverContainerState? maybeOf(BuildContext context) { + if (context is StatefulElement && context.state is PopoverContainerState) { + return context.state as PopoverContainerState; + } + return context.findAncestorStateOfType(); + } +} + +class PopoverContainerState extends State { + @override + Widget build(BuildContext context) { + return Focus( + autofocus: true, + skipTraversal: widget.skipTraversal, + child: CustomSingleChildLayout( + delegate: widget.delegate, + child: widget.popupBuilder(context), + ), + ); + } + + void close() => widget.onClose(); + + void closeAll() => widget.onCloseAll(); +} diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_popover/pubspec.yaml new file mode 100644 index 0000000000000..5d6e335621bee --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_popover/pubspec.yaml @@ -0,0 +1,17 @@ +name: appflowy_popover +description: A new Flutter package project. +version: 0.0.1 +homepage: https://appflowy.io + +environment: + flutter: ">=3.22.0" + sdk: ">=3.3.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 diff --git a/frontend/app_flowy/packages/appflowy_popover/screenshot.png b/frontend/appflowy_flutter/packages/appflowy_popover/screenshot.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/screenshot.png rename to frontend/appflowy_flutter/packages/appflowy_popover/screenshot.png diff --git a/frontend/app_flowy/packages/appflowy_popover/test/popover_test.dart b/frontend/appflowy_flutter/packages/appflowy_popover/test/popover_test.dart similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/test/popover_test.dart rename to frontend/appflowy_flutter/packages/appflowy_popover/test/popover_test.dart diff --git a/frontend/appflowy_flutter/packages/appflowy_result/.gitignore b/frontend/appflowy_flutter/packages/appflowy_result/.gitignore new file mode 100644 index 0000000000000..ac5aa9893e489 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/.gitignore @@ -0,0 +1,29 @@ +# 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/ +build/ diff --git a/frontend/appflowy_flutter/packages/appflowy_result/.metadata b/frontend/appflowy_flutter/packages/appflowy_result/.metadata new file mode 100644 index 0000000000000..ea18fe993df39 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/.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: "bae5e49bc2a867403c43b2aae2de8f8c33b037e4" + channel: "[user-branch]" + +project_type: package diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/CHANGELOG.md b/frontend/appflowy_flutter/packages/appflowy_result/CHANGELOG.md similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/CHANGELOG.md rename to frontend/appflowy_flutter/packages/appflowy_result/CHANGELOG.md diff --git a/frontend/app_flowy/packages/flowy_infra_ui/LICENSE b/frontend/appflowy_flutter/packages/appflowy_result/LICENSE similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/LICENSE rename to frontend/appflowy_flutter/packages/appflowy_result/LICENSE diff --git a/frontend/appflowy_flutter/packages/appflowy_result/README.md b/frontend/appflowy_flutter/packages/appflowy_result/README.md new file mode 100644 index 0000000000000..02fe8ecabcbf3 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/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/frontend/app_flowy/packages/appflowy_board/analysis_options.yaml b/frontend/appflowy_flutter/packages/appflowy_result/analysis_options.yaml similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/analysis_options.yaml rename to frontend/appflowy_flutter/packages/appflowy_result/analysis_options.yaml diff --git a/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart b/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart new file mode 100644 index 0000000000000..97b81cfe1a251 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/lib/appflowy_result.dart @@ -0,0 +1,4 @@ +library appflowy_result; + +export 'src/async_result.dart'; +export 'src/result.dart'; diff --git a/frontend/appflowy_flutter/packages/appflowy_result/lib/src/async_result.dart b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/async_result.dart new file mode 100644 index 0000000000000..94cd9a68a61b0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/async_result.dart @@ -0,0 +1,37 @@ +import 'package:appflowy_result/appflowy_result.dart'; + +typedef FlowyAsyncResult = Future>; + +extension FlowyAsyncResultExtension + on FlowyAsyncResult { + Future getOrElse(S Function(F f) onFailure) { + return then((result) => result.getOrElse(onFailure)); + } + + Future toNullable() { + return then((result) => result.toNullable()); + } + + Future getOrThrow() { + return then((result) => result.getOrThrow()); + } + + Future fold( + W Function(S s) onSuccess, + W Function(F f) onFailure, + ) { + return then((result) => result.fold(onSuccess, onFailure)); + } + + Future isError() { + return then((result) => result.isFailure); + } + + Future isSuccess() { + return then((result) => result.isSuccess); + } + + FlowyAsyncResult onFailure(void Function(F failure) onFailure) { + return then((result) => result..onFailure(onFailure)); + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart new file mode 100644 index 0000000000000..e8d3be8d90428 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/lib/src/result.dart @@ -0,0 +1,167 @@ +abstract class FlowyResult { + const FlowyResult(); + + factory FlowyResult.success(S s) => FlowySuccess(s); + + factory FlowyResult.failure(F f) => FlowyFailure(f); + + T fold(T Function(S s) onSuccess, T Function(F f) onFailure); + + FlowyResult map(T Function(S success) fn); + FlowyResult mapError(T Function(F failure) fn); + + bool get isSuccess; + bool get isFailure; + + S? toNullable(); + + T? onSuccess(T? Function(S s) onSuccess); + T? onFailure(T? Function(F f) onFailure); + + S getOrElse(S Function(F failure) onFailure); + S getOrThrow(); + + F getFailure(); +} + +class FlowySuccess implements FlowyResult { + final S _value; + + FlowySuccess(this._value); + + S get value => _value; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FlowySuccess && + runtimeType == other.runtimeType && + _value == other._value; + + @override + int get hashCode => _value.hashCode; + + @override + String toString() => 'Success(value: $_value)'; + + @override + T fold(T Function(S s) onSuccess, T Function(F e) onFailure) => + onSuccess(_value); + + @override + map(T Function(S success) fn) { + return FlowySuccess(fn(_value)); + } + + @override + FlowyResult mapError(T Function(F error) fn) { + return FlowySuccess(_value); + } + + @override + bool get isSuccess => true; + + @override + bool get isFailure => false; + + @override + S? toNullable() { + return _value; + } + + @override + T? onSuccess(T? Function(S success) onSuccess) { + return onSuccess(_value); + } + + @override + T? onFailure(T? Function(F failure) onFailure) { + return null; + } + + @override + S getOrElse(S Function(F failure) onFailure) { + return _value; + } + + @override + S getOrThrow() { + return _value; + } + + @override + F getFailure() { + throw UnimplementedError(); + } +} + +class FlowyFailure implements FlowyResult { + final F _value; + + FlowyFailure(this._value); + + F get error => _value; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FlowyFailure && + runtimeType == other.runtimeType && + _value == other._value; + + @override + int get hashCode => _value.hashCode; + + @override + String toString() => 'Failure(error: $_value)'; + + @override + T fold(T Function(S s) onSuccess, T Function(F e) onFailure) => + onFailure(_value); + + @override + map(T Function(S success) fn) { + return FlowyFailure(_value); + } + + @override + FlowyResult mapError(T Function(F error) fn) { + return FlowyFailure(fn(_value)); + } + + @override + bool get isSuccess => false; + + @override + bool get isFailure => true; + + @override + S? toNullable() { + return null; + } + + @override + T? onSuccess(T? Function(S success) onSuccess) { + return null; + } + + @override + T? onFailure(T? Function(F failure) onFailure) { + return onFailure(_value); + } + + @override + S getOrElse(S Function(F failure) onFailure) { + return onFailure(_value); + } + + @override + S getOrThrow() { + throw _value; + } + + @override + F getFailure() { + return _value; + } +} diff --git a/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml b/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml new file mode 100644 index 0000000000000..fa2e35f329216 --- /dev/null +++ b/frontend/appflowy_flutter/packages/appflowy_result/pubspec.yaml @@ -0,0 +1,48 @@ +name: appflowy_result +description: "A new Flutter package project." +version: 0.0.1 +homepage: + +environment: + sdk: ">=3.3.0 <4.0.0" + flutter: ">=1.17.0" + +dev_dependencies: + flutter_lints: ^3.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/packages/flowy_infra/.gitignore b/frontend/appflowy_flutter/packages/flowy_infra/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_infra/.gitignore rename to frontend/appflowy_flutter/packages/flowy_infra/.gitignore diff --git a/frontend/app_flowy/packages/flowy_infra/.metadata b/frontend/appflowy_flutter/packages/flowy_infra/.metadata similarity index 100% rename from frontend/app_flowy/packages/flowy_infra/.metadata rename to frontend/appflowy_flutter/packages/flowy_infra/.metadata diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/LICENSE b/frontend/appflowy_flutter/packages/flowy_infra/LICENSE similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/LICENSE rename to frontend/appflowy_flutter/packages/flowy_infra/LICENSE diff --git a/frontend/app_flowy/packages/appflowy_editor/analysis_options.yaml b/frontend/appflowy_flutter/packages/flowy_infra/analysis_options.yaml similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/analysis_options.yaml rename to frontend/appflowy_flutter/packages/flowy_infra/analysis_options.yaml diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart new file mode 100644 index 0000000000000..9cd3a06313d04 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/colorscheme.dart @@ -0,0 +1,197 @@ +import 'package:flowy_infra/theme.dart'; +import 'package:flowy_infra/utils/color_converter.dart'; +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'dandelion.dart'; +import 'default_colorscheme.dart'; +import 'lavender.dart'; +import 'lemonade.dart'; + +part 'colorscheme.g.dart'; + +/// A map of all the built-in themes. +/// +/// The key is the theme name, and the value is a list of two color schemes: +/// the first is for light mode, and the second is for dark mode. +const Map> themeMap = { + BuiltInTheme.defaultTheme: [ + DefaultColorScheme.light(), + DefaultColorScheme.dark(), + ], + BuiltInTheme.dandelion: [ + DandelionColorScheme.light(), + DandelionColorScheme.dark(), + ], + BuiltInTheme.lemonade: [ + LemonadeColorScheme.light(), + LemonadeColorScheme.dark(), + ], + BuiltInTheme.lavender: [ + LavenderColorScheme.light(), + LavenderColorScheme.dark(), + ], +}; + +@JsonSerializable(converters: [ColorConverter()]) +class FlowyColorScheme { + const FlowyColorScheme({ + required this.surface, + required this.hover, + required this.selector, + required this.red, + required this.yellow, + required this.green, + required this.shader1, + required this.shader2, + required this.shader3, + required this.shader4, + required this.shader5, + required this.shader6, + required this.shader7, + required this.bg1, + required this.bg2, + required this.bg3, + required this.bg4, + required this.tint1, + required this.tint2, + required this.tint3, + required this.tint4, + required this.tint5, + required this.tint6, + required this.tint7, + required this.tint8, + required this.tint9, + required this.main1, + required this.main2, + required this.shadow, + required this.sidebarBg, + required this.divider, + required this.topbarBg, + required this.icon, + required this.text, + required this.secondaryText, + required this.strongText, + required this.input, + required this.hint, + required this.primary, + required this.onPrimary, + required this.hoverBG1, + required this.hoverBG2, + required this.hoverBG3, + required this.hoverFG, + required this.questionBubbleBG, + required this.progressBarBGColor, + required this.toolbarColor, + required this.toggleButtonBGColor, + required this.calendarWeekendBGColor, + required this.gridRowCountColor, + required this.borderColor, + required this.scrollbarColor, + required this.scrollbarHoverColor, + required this.lightIconColor, + }); + + final Color surface; + final Color hover; + final Color selector; + final Color red; + final Color yellow; + final Color green; + final Color shader1; + final Color shader2; + final Color shader3; + final Color shader4; + final Color shader5; + final Color shader6; + final Color shader7; + final Color bg1; + final Color bg2; + final Color bg3; + final Color bg4; + final Color tint1; + final Color tint2; + final Color tint3; + final Color tint4; + final Color tint5; + final Color tint6; + final Color tint7; + final Color tint8; + final Color tint9; + final Color main1; + final Color main2; + final Color shadow; + final Color sidebarBg; + final Color divider; + final Color topbarBg; + final Color icon; + final Color text; + final Color secondaryText; + final Color strongText; + final Color input; + final Color hint; + final Color primary; + final Color onPrimary; + //page title hover effect + final Color hoverBG1; + //action item hover effect + final Color hoverBG2; + final Color hoverBG3; + //the text color when it is hovered + final Color hoverFG; + final Color questionBubbleBG; + final Color progressBarBGColor; + //editor toolbar BG color + final Color toolbarColor; + final Color toggleButtonBGColor; + final Color calendarWeekendBGColor; + //grid bottom count color + final Color gridRowCountColor; + + final Color borderColor; + + final Color scrollbarColor; + final Color scrollbarHoverColor; + + final Color lightIconColor; + + factory FlowyColorScheme.fromJson(Map json) => + _$FlowyColorSchemeFromJson(json); + + Map toJson() => _$FlowyColorSchemeToJson(this); + + /// Merges the given [json] with the default color scheme + /// based on the given [brightness]. + /// + factory FlowyColorScheme.fromJsonSoft( + Map json, [ + Brightness brightness = Brightness.light, + ]) { + final colorScheme = brightness == Brightness.light + ? const DefaultColorScheme.light() + : const DefaultColorScheme.dark(); + final defaultMap = colorScheme.toJson(); + final mergedMap = Map.from(defaultMap)..addAll(json); + + return FlowyColorScheme.fromJson(mergedMap); + } + + /// Useful in validating that a teheme adheres to the default color scheme. + /// Returns the keys that are missing from the [json]. + /// + /// We use this for testing and debugging, and we might make it possible for users to + /// check their themes for missing keys in the future. + /// + /// Sample usage: + /// ```dart + /// final lightJson = await jsonDecode(await light.readAsString()); + /// final lightMissingKeys = FlowyColorScheme.getMissingKeys(lightJson); + /// ``` + /// + static List getMissingKeys(Map json) { + final defaultKeys = const DefaultColorScheme.light().toJson().keys; + final jsonKeys = json.keys; + + return defaultKeys.where((key) => !jsonKeys.contains(key)).toList(); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart new file mode 100644 index 0000000000000..2aa455b40443a --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/dandelion.dart @@ -0,0 +1,148 @@ +import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; +import 'package:flutter/material.dart'; + +import 'colorscheme.dart'; + +const _black = Color(0xff000000); +const _white = Color(0xFFFFFFFF); +const _lightBg1 = Color(0xFFFFD13E); +const _lightShader1 = Color(0xff333333); +const _lightShader3 = Color(0xff828282); +const _lightShader5 = Color(0xffe0e0e0); +const _lightShader6 = Color(0xfff2f2f2); +const _lightDandelionYellow = Color(0xffffcb00); +const _lightDandelionLightYellow = Color(0xffffdf66); +const _lightDandelionGreen = Color(0xff9bc53d); +const _lightTint9 = Color(0xffe1fbff); + +const _darkShader1 = Color(0xff131720); +const _darkShader2 = Color(0xff1A202C); +const _darkShader3 = Color(0xff363D49); +const _darkShader5 = Color(0xffBBC3CD); +const _darkShader6 = Color(0xffF2F2F2); +const _darkMain1 = Color(0xffffcb00); +const _darkInput = Color(0xff282E3A); + +class DandelionColorScheme extends FlowyColorScheme { + const DandelionColorScheme.light() + : super( + surface: Colors.white, + hover: const Color(0xFFe0f8ff), + // hover effect on setting value + selector: _lightDandelionLightYellow, + red: const Color(0xfffb006d), + yellow: const Color(0xffffd667), + green: const Color(0xff66cf80), + shader1: const Color(0xff333333), + shader2: const Color(0xff4f4f4f), + shader3: const Color(0xff828282), + // disable text color + shader4: const Color(0xffbdbdbd), + shader5: _lightShader5, + shader6: const Color(0xfff2f2f2), + shader7: _black, + bg1: _lightBg1, + bg2: const Color(0xffedeef2), + // Hover color on trash button + bg3: _lightDandelionYellow, + bg4: const Color(0xff2c144b), + tint1: const Color(0xffe8e0ff), + tint2: const Color(0xffffe7fd), + tint3: const Color(0xffffe7ee), + tint4: const Color(0xffffefe3), + tint5: const Color(0xfffff2cd), + tint6: const Color(0xfff5ffdc), + tint7: const Color(0xffddffd6), + tint8: const Color(0xffdefff1), + tint9: _lightTint9, + main1: _lightDandelionYellow, + // cursor color + main2: _lightDandelionYellow, + shadow: const Color.fromRGBO(0, 0, 0, 0.15), + sidebarBg: _lightDandelionGreen, + divider: _lightShader6, + topbarBg: _white, + icon: _lightShader1, + text: _lightShader1, + secondaryText: _lightShader1, + strongText: Colors.black, + input: _white, + hint: _lightShader3, + primary: _lightDandelionYellow, + onPrimary: _lightShader1, + // hover color in sidebar + hoverBG1: _lightDandelionYellow, + // tool bar hover color + hoverBG2: _lightDandelionLightYellow, + hoverBG3: _lightShader6, + hoverFG: _lightShader1, + questionBubbleBG: _lightDandelionLightYellow, + progressBarBGColor: _lightTint9, + toolbarColor: _lightShader1, + toggleButtonBGColor: _lightDandelionYellow, + calendarWeekendBGColor: const Color(0xFFFBFBFC), + gridRowCountColor: _black, + borderColor: ColorSchemeConstants.lightBorderColor, + scrollbarColor: const Color(0x3F171717), + scrollbarHoverColor: const Color(0x7F171717), + lightIconColor: const Color(0xFF8F959E), + ); + + const DandelionColorScheme.dark() + : super( + surface: const Color(0xff292929), + hover: const Color(0xff1f1f1f), + selector: _darkShader2, + red: const Color(0xfffb006d), + yellow: const Color(0xffffd667), + green: const Color(0xff66cf80), + shader1: _white, + shader2: _darkShader2, + shader3: const Color(0xff828282), + shader4: const Color(0xffbdbdbd), + shader5: _darkShader5, + shader6: _darkShader6, + shader7: _white, + bg1: const Color(0xFFD5A200), + bg2: _black, + bg3: _darkMain1, + bg4: const Color(0xff2c144b), + tint1: const Color(0x4d9327FF), + tint2: const Color(0x66FC0088), + tint3: const Color(0x4dFC00E2), + tint4: const Color(0x80BE5B00), + tint5: const Color(0x33F8EE00), + tint6: const Color(0x4d6DC300), + tint7: const Color(0x5900BD2A), + tint8: const Color(0x80008890), + tint9: const Color(0x4d0029FF), + main1: _darkMain1, + main2: _darkMain1, + shadow: const Color(0xff0F131C), + sidebarBg: const Color(0xff25300e), + divider: _darkShader3, + topbarBg: _darkShader1, + icon: _darkShader5, + text: _darkShader5, + secondaryText: _darkShader5, + strongText: Colors.white, + input: _darkInput, + hint: _darkShader5, + primary: _darkMain1, + onPrimary: _darkShader1, + hoverBG1: _darkMain1, + hoverBG2: _darkMain1, + hoverBG3: _darkShader3, + hoverFG: _darkShader1, + questionBubbleBG: _darkShader3, + progressBarBGColor: _darkShader3, + toolbarColor: _darkInput, + toggleButtonBGColor: _darkShader1, + calendarWeekendBGColor: const Color(0xff121212), + gridRowCountColor: _darkMain1, + borderColor: ColorSchemeConstants.darkBorderColor, + scrollbarColor: const Color(0x40FFFFFF), + scrollbarHoverColor: const Color(0x80FFFFFF), + lightIconColor: const Color(0xFF8F959E), + ); +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart new file mode 100644 index 0000000000000..f829d3a67e3be --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/default_colorscheme.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; + +import 'colorscheme.dart'; + +class ColorSchemeConstants { + static const white = Color(0xFFFFFFFF); + static const lightHover = Color(0xFFe0f8FF); + static const lightSelector = Color(0xFFf2fcFF); + static const lightBg1 = Color(0xFFf7f8fc); + static const lightBg2 = Color(0x0F1F2329); + static const lightShader1 = Color(0xFF333333); + static const lightShader3 = Color(0xFF828282); + static const lightShader5 = Color(0xFFe0e0e0); + static const lightShader6 = Color(0xFFf2f2f2); + static const lightMain1 = Color(0xFF00bcf0); + static const lightTint9 = Color(0xFFe1fbFF); + static const darkShader1 = Color(0xFF131720); + static const darkShader2 = Color(0xFF1A202C); + static const darkShader3 = Color(0xFF363D49); + static const darkShader5 = Color(0xFFBBC3CD); + static const darkShader6 = Color(0xFFF2F2F2); + static const darkMain1 = Color(0xFF00BCF0); + static const darkMain2 = Color(0xFF00BCF0); + static const darkInput = Color(0xFF282E3A); + static const lightBorderColor = Color(0xFFEDEDEE); + static const darkBorderColor = Color(0xFF3A3F49); +} + +class DefaultColorScheme extends FlowyColorScheme { + const DefaultColorScheme.light() + : super( + surface: ColorSchemeConstants.white, + hover: ColorSchemeConstants.lightHover, + selector: ColorSchemeConstants.lightSelector, + red: const Color(0xFFfb006d), + yellow: const Color(0xFFFFd667), + green: const Color(0xFF66cf80), + shader1: ColorSchemeConstants.lightShader1, + shader2: const Color(0xFF4f4f4f), + shader3: ColorSchemeConstants.lightShader3, + shader4: const Color(0xFFbdbdbd), + shader5: ColorSchemeConstants.lightShader5, + shader6: ColorSchemeConstants.lightShader6, + shader7: ColorSchemeConstants.lightShader1, + bg1: ColorSchemeConstants.lightBg1, + bg2: ColorSchemeConstants.lightBg2, + bg3: const Color(0xFFe2e4eb), + bg4: const Color(0xFF2c144b), + tint1: const Color(0xFFe8e0FF), + tint2: const Color(0xFFFFe7fd), + tint3: const Color(0xFFFFe7ee), + tint4: const Color(0xFFFFefe3), + tint5: const Color(0xFFFFf2cd), + tint6: const Color(0xFFf5FFdc), + tint7: const Color(0xFFddFFd6), + tint8: const Color(0xFFdeFFf1), + tint9: ColorSchemeConstants.lightTint9, + main1: ColorSchemeConstants.lightMain1, + main2: const Color(0xFF00b7ea), + shadow: const Color.fromRGBO(0, 0, 0, 0.15), + sidebarBg: ColorSchemeConstants.lightBg1, + divider: ColorSchemeConstants.lightShader6, + topbarBg: ColorSchemeConstants.white, + icon: ColorSchemeConstants.lightShader1, + text: ColorSchemeConstants.lightShader1, + secondaryText: const Color(0xFF4f4f4f), + strongText: Colors.black, + input: ColorSchemeConstants.white, + hint: ColorSchemeConstants.lightShader3, + primary: ColorSchemeConstants.lightMain1, + onPrimary: ColorSchemeConstants.white, + hoverBG1: ColorSchemeConstants.lightBg2, + hoverBG2: ColorSchemeConstants.lightHover, + hoverBG3: ColorSchemeConstants.lightShader6, + hoverFG: ColorSchemeConstants.lightShader1, + questionBubbleBG: ColorSchemeConstants.lightSelector, + progressBarBGColor: ColorSchemeConstants.lightTint9, + toolbarColor: ColorSchemeConstants.lightShader1, + toggleButtonBGColor: ColorSchemeConstants.lightShader5, + calendarWeekendBGColor: const Color(0xFFFBFBFC), + gridRowCountColor: ColorSchemeConstants.lightShader1, + borderColor: ColorSchemeConstants.lightBorderColor, + scrollbarColor: const Color(0x3F171717), + scrollbarHoverColor: const Color(0x7F171717), + lightIconColor: const Color(0xFF8F959E), + ); + + const DefaultColorScheme.dark() + : super( + surface: ColorSchemeConstants.darkShader2, + hover: ColorSchemeConstants.darkMain1, + selector: ColorSchemeConstants.darkShader2, + red: const Color(0xFFfb006d), + yellow: const Color(0xFFF7CF46), + green: const Color(0xFF66CF80), + shader1: ColorSchemeConstants.darkShader1, + shader2: ColorSchemeConstants.darkShader2, + shader3: ColorSchemeConstants.darkShader3, + shader4: const Color(0xFF505469), + shader5: ColorSchemeConstants.darkShader5, + shader6: ColorSchemeConstants.darkShader6, + shader7: ColorSchemeConstants.white, + bg1: const Color(0xFF1A202C), + bg2: const Color(0xFFEDEEF2), + bg3: ColorSchemeConstants.darkMain1, + bg4: const Color(0xFF2C144B), + tint1: const Color(0x4D502FD6), + tint2: const Color(0x4DBF1CC0), + tint3: const Color(0x4DC42A53), + tint4: const Color(0x4DD77922), + tint5: const Color(0x4DC59A1A), + tint6: const Color(0x4DA4C824), + tint7: const Color(0x4D23CA2E), + tint8: const Color(0x4D19CCAC), + tint9: const Color(0x4D04A9D7), + main1: ColorSchemeConstants.darkMain2, + main2: const Color(0xFF00B7EA), + shadow: const Color(0xFF0F131C), + sidebarBg: const Color(0xFF232B38), + divider: ColorSchemeConstants.darkShader3, + topbarBg: ColorSchemeConstants.darkShader1, + icon: ColorSchemeConstants.darkShader5, + text: ColorSchemeConstants.darkShader5, + secondaryText: ColorSchemeConstants.darkShader5, + strongText: Colors.white, + input: ColorSchemeConstants.darkInput, + hint: const Color(0xFF59647a), + primary: ColorSchemeConstants.darkMain2, + onPrimary: ColorSchemeConstants.darkShader1, + hoverBG1: const Color(0x1AFFFFFF), + hoverBG2: ColorSchemeConstants.darkMain1, + hoverBG3: ColorSchemeConstants.darkShader3, + hoverFG: const Color(0xE5FFFFFF), + questionBubbleBG: ColorSchemeConstants.darkShader3, + progressBarBGColor: ColorSchemeConstants.darkShader3, + toolbarColor: ColorSchemeConstants.darkInput, + toggleButtonBGColor: const Color(0xFF828282), + calendarWeekendBGColor: ColorSchemeConstants.darkShader1, + gridRowCountColor: ColorSchemeConstants.darkShader5, + borderColor: ColorSchemeConstants.darkBorderColor, + scrollbarColor: const Color(0x40FFFFFF), + scrollbarHoverColor: const Color(0x80FFFFFF), + lightIconColor: const Color(0xFF8F959E), + ); +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart new file mode 100644 index 0000000000000..97ff5221deeba --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lavender.dart @@ -0,0 +1,144 @@ +import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; +import 'package:flutter/material.dart'; + +import 'colorscheme.dart'; + +const _black = Color(0xff000000); +const _white = Color(0xFFFFFFFF); + +const _lightHover = Color(0xffd8d6fc); +const _lightSelector = Color(0xffe5e3f9); +const _lightBg1 = Color(0xfff2f0f6); +const _lightBg2 = Color(0xffd8d6fc); +const _lightShader1 = Color(0xff333333); +const _lightShader3 = Color(0xff828282); +const _lightShader5 = Color(0xffe0e0e0); +const _lightShader6 = Color(0xffd8d6fc); +const _lightMain1 = Color(0xffaba9e7); +const _lightTint9 = Color(0xffe1fbff); + +const _darkShader1 = Color(0xff131720); +const _darkShader2 = Color(0xff1A202C); +const _darkShader3 = Color(0xff363D49); +const _darkShader5 = Color(0xffBBC3CD); +const _darkShader6 = Color(0xffF2F2F2); +const _darkMain1 = Color(0xffab00ff); +const _darkInput = Color(0xff282E3A); + +class LavenderColorScheme extends FlowyColorScheme { + const LavenderColorScheme.light() + : super( + surface: Colors.white, + hover: _lightHover, + selector: _lightSelector, + red: const Color(0xfffb006d), + yellow: const Color(0xffffd667), + green: const Color(0xff66cf80), + shader1: const Color(0xff333333), + shader2: const Color(0xff4f4f4f), + shader3: const Color(0xff828282), + shader4: const Color(0xffbdbdbd), + shader5: _lightShader5, + shader6: const Color(0xfff2f2f2), + shader7: _black, + bg1: const Color(0xffAC59FF), + bg2: const Color(0xffedeef2), + bg3: _lightHover, + bg4: const Color(0xff2c144b), + tint1: const Color(0xffe8e0ff), + tint2: const Color(0xffffe7fd), + tint3: const Color(0xffffe7ee), + tint4: const Color(0xffffefe3), + tint5: const Color(0xfffff2cd), + tint6: const Color(0xfff5ffdc), + tint7: const Color(0xffddffd6), + tint8: const Color(0xffdefff1), + tint9: _lightMain1, + main1: _lightMain1, + main2: _lightMain1, + shadow: const Color.fromRGBO(0, 0, 0, 0.15), + sidebarBg: _lightBg1, + divider: _lightShader6, + topbarBg: _white, + icon: _lightShader1, + text: _lightShader1, + secondaryText: _lightShader1, + strongText: Colors.black, + input: _white, + hint: _lightShader3, + primary: _lightMain1, + onPrimary: _lightShader1, + hoverBG1: _lightBg2, + hoverBG2: _lightHover, + hoverBG3: _lightShader6, + hoverFG: _lightShader1, + questionBubbleBG: _lightSelector, + progressBarBGColor: _lightTint9, + toolbarColor: _lightShader1, + toggleButtonBGColor: _lightSelector, + calendarWeekendBGColor: const Color(0xFFFBFBFC), + gridRowCountColor: _black, + borderColor: ColorSchemeConstants.lightBorderColor, + scrollbarColor: const Color(0x3F171717), + scrollbarHoverColor: const Color(0x7F171717), + lightIconColor: const Color(0xFF8F959E), + ); + + const LavenderColorScheme.dark() + : super( + surface: const Color(0xFF1B1A1D), + hover: _darkMain1, + selector: _darkShader2, + red: const Color(0xfffb006d), + yellow: const Color(0xffffd667), + green: const Color(0xff66cf80), + shader1: _white, + shader2: _darkShader2, + shader3: const Color(0xff828282), + shader4: const Color(0xffbdbdbd), + shader5: _white, + shader6: _darkShader6, + shader7: _white, + bg1: const Color(0xff8C23F6), + bg2: _black, + bg3: _darkMain1, + bg4: const Color(0xff2c144b), + tint1: const Color(0x4d9327FF), + tint2: const Color(0x66FC0088), + tint3: const Color(0x4dFC00E2), + tint4: const Color(0x80BE5B00), + tint5: const Color(0x33F8EE00), + tint6: const Color(0x4d6DC300), + tint7: const Color(0x5900BD2A), + tint8: const Color(0x80008890), + tint9: const Color(0x4d0029FF), + main1: _darkMain1, + main2: _darkMain1, + shadow: const Color(0xff0F131C), + sidebarBg: const Color(0xff2D223B), + divider: _darkShader3, + topbarBg: _darkShader1, + icon: _darkShader5, + text: _darkShader5, + secondaryText: _darkShader5, + strongText: Colors.white, + input: _darkInput, + hint: _darkShader5, + primary: _darkMain1, + onPrimary: _darkShader1, + hoverBG1: _darkMain1, + hoverBG2: _darkMain1, + hoverBG3: _darkShader3, + hoverFG: _darkShader1, + questionBubbleBG: _darkShader3, + progressBarBGColor: _darkShader3, + toolbarColor: _darkInput, + toggleButtonBGColor: _darkShader1, + calendarWeekendBGColor: const Color(0xff121212), + gridRowCountColor: _darkMain1, + borderColor: ColorSchemeConstants.darkBorderColor, + scrollbarColor: const Color(0x40FFFFFF), + scrollbarHoverColor: const Color(0x80FFFFFF), + lightIconColor: const Color(0xFF8F959E), + ); +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart new file mode 100644 index 0000000000000..5d9ff8b97c6a5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/colorscheme/lemonade.dart @@ -0,0 +1,150 @@ +import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; +import 'package:flutter/material.dart'; + +import 'colorscheme.dart'; + +const _black = Color(0xff000000); +const _white = Color(0xFFFFFFFF); +const _lightBg1 = Color(0xFFFFD13E); +const _lightShader1 = Color(0xff333333); +const _lightShader3 = Color(0xff828282); +const _lightShader5 = Color(0xffe0e0e0); +const _lightShader6 = Color(0xfff2f2f2); +const _lightDandelionYellow = Color(0xffffcb00); +const _lightDandelionLightYellow = Color(0xffffdf66); +const _lightTint9 = Color(0xffe1fbff); + +const _darkShader1 = Color(0xff131720); +const _darkShader2 = Color(0xff1A202C); +const _darkShader3 = Color(0xff363D49); +const _darkShader5 = Color(0xffBBC3CD); +const _darkShader6 = Color(0xffF2F2F2); +const _darkMain1 = Color(0xffffcb00); +const _darkInput = Color(0xff282E3A); + +// Derive from [DandelionColorScheme] +// Use a light yellow color in the sidebar intead of a green color in Dandelion +// Some field name are still included 'Dandelion' to indicate they are the same color as the one in Dandelion +class LemonadeColorScheme extends FlowyColorScheme { + const LemonadeColorScheme.light() + : super( + surface: Colors.white, + hover: const Color(0xFFe0f8ff), + // hover effect on setting value + selector: _lightDandelionLightYellow, + red: const Color(0xfffb006d), + yellow: const Color(0xffffd667), + green: const Color(0xff66cf80), + shader1: const Color(0xff333333), + shader2: const Color(0xff4f4f4f), + shader3: const Color(0xff828282), + // disable text color + shader4: const Color(0xffbdbdbd), + shader5: _lightShader5, + shader6: const Color(0xfff2f2f2), + shader7: _black, + bg1: _lightBg1, + bg2: const Color(0xffedeef2), + // Hover color on trash button + bg3: _lightDandelionYellow, + bg4: const Color(0xff2c144b), + tint1: const Color(0xffe8e0ff), + tint2: const Color(0xffffe7fd), + tint3: const Color(0xffffe7ee), + tint4: const Color(0xffffefe3), + tint5: const Color(0xfffff2cd), + tint6: const Color(0xfff5ffdc), + tint7: const Color(0xffddffd6), + tint8: const Color(0xffdefff1), + tint9: _lightTint9, + main1: _lightDandelionYellow, + // cursor color + main2: _lightDandelionYellow, + shadow: const Color.fromRGBO(0, 0, 0, 0.15), + sidebarBg: const Color(0xfffaf0c8), + divider: _lightShader6, + topbarBg: _white, + icon: _lightShader1, + text: _lightShader1, + secondaryText: _lightShader1, + strongText: Colors.black, + input: _white, + hint: _lightShader3, + primary: _lightDandelionYellow, + onPrimary: _lightShader1, + // hover color in sidebar + hoverBG1: _lightDandelionYellow, + // tool bar hover color + hoverBG2: _lightDandelionLightYellow, + hoverBG3: _lightShader6, + hoverFG: _lightShader1, + questionBubbleBG: _lightDandelionLightYellow, + progressBarBGColor: _lightTint9, + toolbarColor: _lightShader1, + toggleButtonBGColor: _lightDandelionYellow, + calendarWeekendBGColor: const Color(0xFFFBFBFC), + gridRowCountColor: _black, + borderColor: ColorSchemeConstants.lightBorderColor, + scrollbarColor: const Color(0x3F171717), + scrollbarHoverColor: const Color(0x7F171717), + lightIconColor: const Color(0xFF8F959E), + ); + + const LemonadeColorScheme.dark() + : super( + surface: const Color(0xff292929), + hover: const Color(0xff1f1f1f), + selector: _darkShader2, + red: const Color(0xfffb006d), + yellow: const Color(0xffffd667), + green: const Color(0xff66cf80), + shader1: _white, + shader2: _darkShader2, + shader3: const Color(0xff828282), + shader4: const Color(0xffbdbdbd), + shader5: _darkShader5, + shader6: _darkShader6, + shader7: _white, + bg1: const Color(0xFFD5A200), + bg2: _black, + bg3: _darkMain1, + bg4: const Color(0xff2c144b), + tint1: const Color(0x4d9327FF), + tint2: const Color(0x66FC0088), + tint3: const Color(0x4dFC00E2), + tint4: const Color(0x80BE5B00), + tint5: const Color(0x33F8EE00), + tint6: const Color(0x4d6DC300), + tint7: const Color(0x5900BD2A), + tint8: const Color(0x80008890), + tint9: const Color(0x4d0029FF), + main1: _darkMain1, + main2: _darkMain1, + shadow: _black, + sidebarBg: const Color(0xff232B38), + divider: _darkShader3, + topbarBg: _darkShader1, + icon: _darkShader5, + text: _darkShader5, + secondaryText: _darkShader5, + strongText: Colors.white, + input: _darkInput, + hint: _darkShader5, + primary: _darkMain1, + onPrimary: _darkShader1, + hoverBG1: _darkMain1, + hoverBG2: _darkMain1, + hoverBG3: _darkShader3, + hoverFG: _darkShader1, + questionBubbleBG: _darkShader3, + progressBarBGColor: _darkShader3, + toolbarColor: _darkInput, + toggleButtonBGColor: _darkShader1, + calendarWeekendBGColor: const Color(0xff121212), + gridRowCountColor: _darkMain1, + borderColor: ColorSchemeConstants.darkBorderColor, + scrollbarColor: const Color(0x40FFFFFF), + scrollbarHoverColor: const Color(0x80FFFFFF), + lightIconColor: const Color(0xFF8F959E), + ); +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart new file mode 100644 index 0000000000000..1e6f6a99e29d6 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart @@ -0,0 +1,67 @@ +import 'package:flutter/services.dart'; + +import 'package:file_picker/file_picker.dart' as fp; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; + +class FilePicker implements FilePickerService { + @override + Future getDirectoryPath({String? title}) { + return fp.FilePicker.platform.getDirectoryPath(); + } + + @override + Future pickFiles({ + String? dialogTitle, + String? initialDirectory, + fp.FileType type = fp.FileType.any, + List? allowedExtensions, + Function(fp.FilePickerStatus p1)? onFileLoading, + bool allowCompression = true, + bool allowMultiple = false, + bool withData = false, + bool withReadStream = false, + bool lockParentWindow = false, + }) async { + final result = await fp.FilePicker.platform.pickFiles( + dialogTitle: dialogTitle, + initialDirectory: initialDirectory, + type: type, + allowedExtensions: allowedExtensions, + onFileLoading: onFileLoading, + allowCompression: allowCompression, + allowMultiple: allowMultiple, + withData: withData, + withReadStream: withReadStream, + lockParentWindow: lockParentWindow, + ); + return FilePickerResult(result?.files ?? []); + } + + /// On Desktop it will return the path to which the file should be saved. + /// + /// On Mobile it will return the path to where the file has been saved, and will + /// automatically save it. The [bytes] parameter is required on Mobile. + /// + @override + Future saveFile({ + String? dialogTitle, + String? fileName, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + bool lockParentWindow = false, + Uint8List? bytes, + }) async { + final result = await fp.FilePicker.platform.saveFile( + dialogTitle: dialogTitle, + fileName: fileName, + initialDirectory: initialDirectory, + type: type, + allowedExtensions: allowedExtensions, + lockParentWindow: lockParentWindow, + bytes: bytes, + ); + + return result; + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_service.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_service.dart new file mode 100644 index 0000000000000..e8991397a9f8e --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_service.dart @@ -0,0 +1,43 @@ +import 'package:file_picker/file_picker.dart'; + +export 'package:file_picker/file_picker.dart' + show FileType, FilePickerStatus, PlatformFile; + +class FilePickerResult { + const FilePickerResult(this.files); + + /// Picked files. + final List files; +} + +/// Abstract file picker as a service to implement dependency injection. +abstract class FilePickerService { + Future getDirectoryPath({ + String? title, + }) async => + throw UnimplementedError('getDirectoryPath() has not been implemented.'); + + Future pickFiles({ + String? dialogTitle, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + Function(FilePickerStatus)? onFileLoading, + bool allowCompression = true, + bool allowMultiple = false, + bool withData = false, + bool withReadStream = false, + bool lockParentWindow = false, + }) async => + throw UnimplementedError('pickFiles() has not been implemented.'); + + Future saveFile({ + String? dialogTitle, + String? fileName, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + bool lockParentWindow = false, + }) async => + throw UnimplementedError('saveFile() has not been implemented.'); +} diff --git a/frontend/app_flowy/packages/flowy_infra/lib/icon_data.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/icon_data.dart similarity index 98% rename from frontend/app_flowy/packages/flowy_infra/lib/icon_data.dart rename to frontend/appflowy_flutter/packages/flowy_infra/lib/icon_data.dart index e6df4c7428c81..c797d4c513f09 100644 --- a/frontend/app_flowy/packages/flowy_infra/lib/icon_data.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/icon_data.dart @@ -13,6 +13,7 @@ /// /// /// +library; // ignore_for_file: constant_identifier_names import 'package:flutter/widgets.dart'; diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart new file mode 100644 index 0000000000000..0b6ff4fb3fb9b --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +String languageFromLocale(Locale locale) { + switch (locale.languageCode) { + // Most often used languages + case "en": + return "English"; + case "zh": + switch (locale.countryCode) { + case "CN": + return "简体中文"; + case "TW": + return "繁體中文"; + default: + return locale.languageCode; + } + + // Then in alphabetical order + case "am": + return "አማርኛ"; + case "ar": + return "العربية"; + case "ca": + return "Català"; + case "cs": + return "Čeština"; + case "ckb": + switch (locale.countryCode) { + case "KU": + return "کوردی سۆرانی"; + default: + return locale.languageCode; + } + case "de": + return "Deutsch"; + case "es": + return "Español"; + case "eu": + return "Euskera"; + case "el": + return "Ελληνικά"; + case "fr": + switch (locale.countryCode) { + case "CA": + return "Français (CA)"; + case "FR": + return "Français (FR)"; + default: + return locale.languageCode; + } + case "he": + return "עברית"; + case "hu": + return "Magyar"; + case "id": + return "Bahasa Indonesia"; + case "it": + return "Italiano"; + case "ja": + return "日本語"; + case "ko": + return "한국어"; + case "pl": + return "Polski"; + case "pt": + return "Português"; + case "ru": + return "русский"; + case "sv": + return "Svenska"; + case "th": + return "ไทย"; + case "tr": + return "Türkçe"; + case "fa": + return "فارسی"; + case "uk": + return "українська"; + case "ur": + return "اردو"; + case "hin": + return "हिन्दी"; + } + // If not found then the language code will be displayed + return locale.languageCode; +} diff --git a/frontend/app_flowy/packages/flowy_infra/lib/notifier.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/notifier.dart similarity index 92% rename from frontend/app_flowy/packages/flowy_infra/lib/notifier.dart rename to frontend/appflowy_flutter/packages/flowy_infra/lib/notifier.dart index bbb55bf8858c3..41017faafb377 100644 --- a/frontend/app_flowy/packages/flowy_infra/lib/notifier.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/notifier.dart @@ -48,9 +48,3 @@ class PublishNotifier extends ChangeNotifier { ); } } - -class Notifier extends ChangeNotifier { - void notify() { - notifyListeners(); - } -} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/platform_extension.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/platform_extension.dart new file mode 100644 index 0000000000000..9e2085e3e4c1f --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/platform_extension.dart @@ -0,0 +1,57 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +extension PlatformExtension on Platform { + /// Returns true if the operating system is macOS and not running on Web platform. + static bool get isMacOS { + if (kIsWeb) { + return false; + } + return Platform.isMacOS; + } + + /// Returns true if the operating system is Windows and not running on Web platform. + static bool get isWindows { + if (kIsWeb) { + return false; + } + return Platform.isWindows; + } + + /// Returns true if the operating system is Linux and not running on Web platform. + static bool get isLinux { + if (kIsWeb) { + return false; + } + return Platform.isLinux; + } + + static bool get isDesktopOrWeb { + if (kIsWeb) { + return true; + } + return isDesktop; + } + + static bool get isDesktop { + if (kIsWeb) { + return false; + } + return Platform.isWindows || Platform.isLinux || Platform.isMacOS; + } + + static bool get isMobile { + if (kIsWeb) { + return false; + } + return Platform.isAndroid || Platform.isIOS; + } + + static bool get isNotMobile { + if (kIsWeb) { + return false; + } + return !isMobile; + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_bloc.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_bloc.dart new file mode 100644 index 0000000000000..0dbfc84564fb9 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_bloc.dart @@ -0,0 +1,85 @@ +import 'package:bloc/bloc.dart'; +import 'package:flowy_infra/plugins/service/models/exceptions.dart'; +import 'package:flowy_infra/plugins/service/plugin_service.dart'; + +import '../../file_picker/file_picker_impl.dart'; + +import 'dynamic_plugin_event.dart'; +import 'dynamic_plugin_state.dart'; + +class DynamicPluginBloc extends Bloc { + DynamicPluginBloc({FilePicker? filePicker}) + : super(const DynamicPluginState.uninitialized()) { + on(dispatch); + add(DynamicPluginEvent.load()); + } + + Future dispatch( + DynamicPluginEvent event, Emitter emit) async { + await event.when( + addPlugin: () => addPlugin(emit), + removePlugin: (name) => removePlugin(emit, name), + load: () => onLoadRequested(emit), + ); + } + + Future onLoadRequested(Emitter emit) async { + emit( + DynamicPluginState.ready( + plugins: await FlowyPluginService.instance.plugins, + ), + ); + } + + Future addPlugin(Emitter emit) async { + emit(const DynamicPluginState.processing()); + try { + final plugin = await FlowyPluginService.pick(); + if (plugin == null) { + return emit( + DynamicPluginState.ready( + plugins: await FlowyPluginService.instance.plugins, + ), + ); + } + await FlowyPluginService.instance.addPlugin(plugin); + } on PluginCompilationException catch (exception) { + return emit( + DynamicPluginState.compilationFailure(errorMessage: exception.message), + ); + } + + emit(const DynamicPluginState.compilationSuccess()); + emit( + DynamicPluginState.ready( + plugins: await FlowyPluginService.instance.plugins, + ), + ); + } + + Future removePlugin( + Emitter emit, + String name, + ) async { + emit(const DynamicPluginState.processing()); + + final plugin = await FlowyPluginService.instance.lookup(name: name); + + if (plugin == null) { + return emit( + DynamicPluginState.ready( + plugins: await FlowyPluginService.instance.plugins, + ), + ); + } + + await FlowyPluginService.removePlugin(plugin); + + emit(const DynamicPluginState.deletionSuccess()); + emit( + DynamicPluginState.ready( + plugins: await FlowyPluginService.instance.plugins, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_event.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_event.dart new file mode 100644 index 0000000000000..71c1fce9c0dae --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_event.dart @@ -0,0 +1,11 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'dynamic_plugin_event.freezed.dart'; + +@freezed +class DynamicPluginEvent with _$DynamicPluginEvent { + factory DynamicPluginEvent.addPlugin() = _AddPlugin; + factory DynamicPluginEvent.removePlugin({required String name}) = + _RemovePlugin; + factory DynamicPluginEvent.load() = _Load; +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_state.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_state.dart new file mode 100644 index 0000000000000..cc34e8ebacee1 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/bloc/dynamic_plugin_state.dart @@ -0,0 +1,21 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../service/models/flowy_dynamic_plugin.dart'; + +part 'dynamic_plugin_state.freezed.dart'; + +@freezed +class DynamicPluginState with _$DynamicPluginState { + const factory DynamicPluginState.uninitialized() = _Uninitialized; + const factory DynamicPluginState.ready({ + required Iterable plugins, + }) = Ready; + const factory DynamicPluginState.processing() = _Processing; + const factory DynamicPluginState.compilationFailure( + {required String errorMessage}) = _CompilationFailure; + const factory DynamicPluginState.deletionFailure({ + required String path, + }) = _DeletionFailure; + const factory DynamicPluginState.deletionSuccess() = _DeletionSuccess; + const factory DynamicPluginState.compilationSuccess() = _CompilationSuccess; +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/location_service.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/location_service.dart new file mode 100644 index 0000000000000..64c6d2b586ee0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/location_service.dart @@ -0,0 +1,13 @@ +import 'dart:io'; + +class PluginLocationService { + const PluginLocationService({ + required Future fallback, + }) : _fallback = fallback; + + final Future _fallback; + + Future get fallback async => _fallback; + + Future get location async => fallback; +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/exceptions.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/exceptions.dart new file mode 100644 index 0000000000000..6f3c1e34b9b64 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/exceptions.dart @@ -0,0 +1,5 @@ +class PluginCompilationException implements Exception { + final String message; + + PluginCompilationException(this.message); +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/flowy_dynamic_plugin.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/flowy_dynamic_plugin.dart new file mode 100644 index 0000000000000..2f31ffbf1e643 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/flowy_dynamic_plugin.dart @@ -0,0 +1,161 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:file/memory.dart'; +import 'package:flowy_infra/colorscheme/colorscheme.dart'; +import 'package:flowy_infra/plugins/service/models/exceptions.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:path/path.dart' as p; + +import 'plugin_type.dart'; + +typedef DynamicPluginLibrary = Iterable; + +/// A class that encapsulates dynamically loaded plugins for AppFlowy. +/// +/// This class can be modified to support loading node widget builders and other +/// plugins that are dynamically loaded at runtime for the editor. For now, +/// it only supports loading app themes. +class FlowyDynamicPlugin { + FlowyDynamicPlugin._({ + required String name, + required String path, + this.theme, + }) : _name = name, + _path = path; + + /// The plugins should be loaded into a folder with the extension `.flowy_plugin`. + static bool isPlugin(FileSystemEntity entity) => + entity is Directory && p.extension(entity.path).contains(ext); + + /// The extension for the plugin folder. + static const String ext = 'flowy_plugin'; + static String get lightExtension => ['light', 'json'].join('.'); + static String get darkExtension => ['dark', 'json'].join('.'); + + String get name => _name; + late final String _name; + + String get _fsPluginName => [name, ext].join('.'); + + final AppTheme? theme; + final String _path; + + Directory get source { + return Directory(_path); + } + + /// Loads and "compiles" loaded plugins. + /// + /// If the plugin loaded does not contain the `.flowy_plugin` extension, this + /// this method will throw an error. Likewise, if the plugin does not follow + /// the expected format, this method will throw an error. + static Future decode({required Directory src}) async { + // throw an error if the plugin does not follow the proper format. + if (!isPlugin(src)) { + throw PluginCompilationException( + 'The plugin directory must have the extension `.flowy_plugin`.', + ); + } + + // throws an error if the plugin does not follow the proper format. + final type = PluginType.from(src: src); + + switch (type) { + case PluginType.theme: + return _theme(src: src); + } + } + + /// Encodes the plugin in memory. The Directory given is not the actual + /// directory on the file system, but rather a virtual directory in memory. + /// + /// Instances of this class should always have a path on disk, otherwise a + /// compilation error will be thrown during the construction of this object. + Future encode() async { + final fs = MemoryFileSystem(); + final directory = fs.directory(_fsPluginName)..createSync(); + + final lightThemeFileName = '$name.$lightExtension'; + directory.childFile(lightThemeFileName).createSync(); + directory + .childFile(lightThemeFileName) + .writeAsStringSync(jsonEncode(theme!.lightTheme.toJson())); + + final darkThemeFileName = '$name.$darkExtension'; + directory.childFile(darkThemeFileName).createSync(); + directory + .childFile(darkThemeFileName) + .writeAsStringSync(jsonEncode(theme!.darkTheme.toJson())); + + return directory; + } + + /// Theme plugins should have the following format. + /// > directory.flowy_plugin // plugin root + /// > - theme.light.json // the light theme + /// > - theme.dark.json // the dark theme + /// + /// If the theme does not adhere to that format, it is considered an error. + static Future _theme({required Directory src}) async { + late final String name; + try { + name = p.basenameWithoutExtension(src.path).split('.').first; + } catch (e) { + throw PluginCompilationException( + 'The theme plugin does not adhere to the following format: `.flowy_plugin`.', + ); + } + + final light = src + .listSync() + .where((event) => + event is File && p.basename(event.path).contains(lightExtension)) + .first as File; + + final dark = src + .listSync() + .where((event) => + event is File && p.basename(event.path).contains(darkExtension)) + .first as File; + + late final FlowyColorScheme lightTheme; + late final FlowyColorScheme darkTheme; + + try { + lightTheme = FlowyColorScheme.fromJsonSoft( + await jsonDecode(await light.readAsString()), + ); + } catch (e) { + throw PluginCompilationException( + 'The light theme json file is not valid.', + ); + } + + try { + darkTheme = FlowyColorScheme.fromJsonSoft( + await jsonDecode(await dark.readAsString()), + Brightness.dark, + ); + } catch (e) { + throw PluginCompilationException( + 'The dark theme json file is not valid.', + ); + } + + final theme = AppTheme( + themeName: name, + builtIn: false, + lightTheme: lightTheme, + darkTheme: darkTheme, + ); + + return FlowyDynamicPlugin._( + name: theme.themeName, + path: src.path, + theme: theme, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/plugin_type.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/plugin_type.dart new file mode 100644 index 0000000000000..1b6f8a8f4a663 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/models/plugin_type.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:flowy_infra/plugins/service/models/exceptions.dart'; +import 'package:flowy_infra/plugins/service/models/flowy_dynamic_plugin.dart'; +import 'package:path/path.dart' as p; + +enum PluginType { + theme._(); + + const PluginType._(); + + factory PluginType.from({required Directory src}) { + if (_isTheme(src)) { + return PluginType.theme; + } + throw PluginCompilationException( + 'Could not determine the plugin type from source `$src`.'); + } + + static bool _isTheme(Directory plugin) { + final files = plugin.listSync(); + return files.any((entity) => + entity is File && + p + .basename(entity.path) + .endsWith(FlowyDynamicPlugin.lightExtension)) && + files.any((entity) => + entity is File && + p.basename(entity.path).endsWith(FlowyDynamicPlugin.darkExtension)); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/plugin_service.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/plugin_service.dart new file mode 100644 index 0000000000000..bd81333be8f6e --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/plugins/service/plugin_service.dart @@ -0,0 +1,102 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flowy_infra/file_picker/file_picker_impl.dart'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'location_service.dart'; +import 'models/flowy_dynamic_plugin.dart'; + +/// A service to maintain the state of the plugins for AppFlowy. +class FlowyPluginService { + FlowyPluginService._(); + static final FlowyPluginService _instance = FlowyPluginService._(); + static FlowyPluginService get instance => _instance; + + PluginLocationService _locationService = PluginLocationService( + fallback: getApplicationDocumentsDirectory(), + ); + + void setLocation(PluginLocationService locationService) => + _locationService = locationService; + + Future> get _targets async { + final location = await _locationService.location; + final targets = location.listSync().where(FlowyDynamicPlugin.isPlugin); + return targets.map((entity) => entity as Directory).toList(); + } + + /// Searches the [PluginLocationService.location] for plugins and compiles them. + Future get plugins async { + final List compiled = []; + for (final src in await _targets) { + final plugin = await FlowyDynamicPlugin.decode(src: src); + compiled.add(plugin); + } + return compiled; + } + + /// Chooses a plugin from the file system using FilePickerService and tries to compile it. + /// + /// If the operation is cancelled or the plugin is invalid, this method will return null. + static Future pick({FilePicker? service}) async { + service ??= FilePicker(); + + final result = await service.getDirectoryPath(); + + if (result == null) { + return null; + } + + final directory = Directory(result); + return FlowyDynamicPlugin.decode(src: directory); + } + + /// Searches the plugin registry for a plugin with the given name. + Future lookup({required String name}) async { + final library = await plugins; + return library + // cast to nullable type to allow return of null if not found. + .cast() + // null assert is fine here because the original list was non-nullable + .firstWhere((plugin) => plugin!.name == name, orElse: () => null); + } + + /// Adds a plugin to the registry. To construct a [FlowyDynamicPlugin] + /// use [FlowyDynamicPlugin.encode()] + Future addPlugin(FlowyDynamicPlugin plugin) async { + // try to compile the plugin before we add it to the registry. + final source = await plugin.encode(); + // add the plugin to the registry + final destionation = [ + (await _locationService.location).path, + p.basename(source.path), + ].join(Platform.pathSeparator); + + _copyDirectorySync(source, Directory(destionation)); + } + + /// Removes a plugin from the registry. + static Future removePlugin(FlowyDynamicPlugin plugin) async { + final target = plugin.source; + await target.delete(recursive: true); + } + + static void _copyDirectorySync(Directory source, Directory destination) { + if (!destination.existsSync()) { + destination.createSync(recursive: true); + } + + for (final child in source.listSync(recursive: false)) { + final newPath = p.join(destination.path, p.basename(child.path)); + if (child is File) { + File(newPath) + ..createSync(recursive: true) + ..writeAsStringSync(child.readAsStringSync()); + } else if (child is Directory) { + _copyDirectorySync(child, Directory(newPath)); + } + } + } +} diff --git a/frontend/app_flowy/packages/flowy_infra/lib/size.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart similarity index 77% rename from frontend/app_flowy/packages/flowy_infra/lib/size.dart rename to frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart index 4e54b7dc1cf97..f58dad95b527d 100644 --- a/frontend/app_flowy/packages/flowy_infra/lib/size.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart @@ -11,14 +11,8 @@ class PageBreaks { } class Insets { - static double gutterScale = 1; - - static double scale = 1; - /// Dynamic insets, may get scaled with the device size - static double get mGutter => m * gutterScale; - - static double get lGutter => l * gutterScale; + static double scale = 1; static double get xs => 2 * scale; @@ -29,6 +23,10 @@ class Insets { static double get l => 24 * scale; static double get xl => 36 * scale; + + static double get xxl => 64 * scale; + + static double get xxxl => 80 * scale; } class FontSizes { @@ -43,6 +41,14 @@ class FontSizes { static double get s16 => 16 * scale; static double get s18 => 18 * scale; + + static double get s20 => 20 * scale; + + static double get s24 => 24 * scale; + + static double get s32 => 32 * scale; + + static double get s44 => 44 * scale; } class Sizes { @@ -51,16 +57,15 @@ class Sizes { static double get hit => 40 * hitScale; static double get iconMed => 20; - - static double get sideBarMed => 225 * hitScale; - - static double get sideBarLg => 290 * hitScale; } class Corners { static const BorderRadius s3Border = BorderRadius.all(s3Radius); static const Radius s3Radius = Radius.circular(3); + static const BorderRadius s4Border = BorderRadius.all(s4Radius); + static const Radius s4Radius = Radius.circular(4); + static const BorderRadius s5Border = BorderRadius.all(s5Radius); static const Radius s5Radius = Radius.circular(5); @@ -75,4 +80,7 @@ class Corners { static const BorderRadius s12Border = BorderRadius.all(s12Radius); static const Radius s12Radius = Radius.circular(12); + + static const BorderRadius s16Border = BorderRadius.all(s16Radius); + static const Radius s16Radius = Radius.circular(16); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme.dart new file mode 100644 index 0000000000000..a79ce01107c7f --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme.dart @@ -0,0 +1,68 @@ +import 'package:flowy_infra/colorscheme/colorscheme.dart'; +import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; +import 'plugins/service/plugin_service.dart'; + +class BuiltInTheme { + static const String defaultTheme = 'Default'; + static const String dandelion = 'Dandelion'; + static const String lemonade = 'Lemonade'; + static const String lavender = 'Lavender'; +} + +class AppTheme { + // metadata member + final bool builtIn; + final String themeName; + final FlowyColorScheme lightTheme; + final FlowyColorScheme darkTheme; + // static final Map _cachedJsonData = {}; + + const AppTheme({ + required this.builtIn, + required this.themeName, + required this.lightTheme, + required this.darkTheme, + }); + + static const AppTheme fallback = AppTheme( + builtIn: true, + themeName: BuiltInTheme.defaultTheme, + lightTheme: DefaultColorScheme.light(), + darkTheme: DefaultColorScheme.dark(), + ); + + static Future> _plugins(FlowyPluginService service) async { + final plugins = await service.plugins; + return plugins.map((plugin) => plugin.theme).whereType(); + } + + static Iterable get builtins => themeMap.entries + .map( + (entry) => AppTheme( + builtIn: true, + themeName: entry.key, + lightTheme: entry.value[0], + darkTheme: entry.value[1], + ), + ) + .toList(); + + static Future> themes(FlowyPluginService service) async => + [ + ...builtins, + ...(await _plugins(service)), + ]; + + static Future fromName( + String themeName, { + FlowyPluginService? pluginService, + }) async { + pluginService ??= FlowyPluginService.instance; + for (final theme in await themes(pluginService)) { + if (theme.themeName == themeName) { + return theme; + } + } + throw ArgumentError('The theme $themeName does not exist.'); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart new file mode 100644 index 0000000000000..a2bfd16b06ead --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/theme_extension.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; + +@immutable +class AFThemeExtension extends ThemeExtension { + static AFThemeExtension of(BuildContext context) => + Theme.of(context).extension()!; + + static AFThemeExtension? maybeOf(BuildContext context) => + Theme.of(context).extension(); + + const AFThemeExtension({ + required this.warning, + required this.success, + required this.tint1, + required this.tint2, + required this.tint3, + required this.tint4, + required this.tint5, + required this.tint6, + required this.tint7, + required this.tint8, + required this.tint9, + required this.greyHover, + required this.greySelect, + required this.lightGreyHover, + required this.toggleOffFill, + required this.textColor, + required this.secondaryTextColor, + required this.strongText, + required this.calloutBGColor, + required this.tableCellBGColor, + required this.calendarWeekendBGColor, + required this.code, + required this.callout, + required this.caption, + required this.progressBarBGColor, + required this.toggleButtonBGColor, + required this.gridRowCountColor, + required this.background, + required this.onBackground, + required this.borderColor, + required this.scrollbarColor, + required this.scrollbarHoverColor, + required this.lightIconColor, + }); + + final Color? warning; + final Color? success; + + final Color tint1; + final Color tint2; + final Color tint3; + final Color tint4; + final Color tint5; + final Color tint6; + final Color tint7; + final Color tint8; + final Color tint9; + + final Color textColor; + final Color secondaryTextColor; + final Color strongText; + final Color greyHover; + final Color greySelect; + final Color lightGreyHover; + final Color toggleOffFill; + final Color progressBarBGColor; + final Color toggleButtonBGColor; + final Color calloutBGColor; + final Color tableCellBGColor; + final Color calendarWeekendBGColor; + final Color gridRowCountColor; + + final TextStyle code; + final TextStyle callout; + final TextStyle caption; + + final Color background; + final Color onBackground; + + /// The color of the border of the widget. + /// + /// This is used in the divider, outline border, etc. + final Color borderColor; + + final Color scrollbarColor; + final Color scrollbarHoverColor; + + final Color lightIconColor; + + @override + AFThemeExtension copyWith({ + Color? warning, + Color? success, + Color? tint1, + Color? tint2, + Color? tint3, + Color? tint4, + Color? tint5, + Color? tint6, + Color? tint7, + Color? tint8, + Color? tint9, + Color? textColor, + Color? secondaryTextColor, + Color? strongText, + Color? calloutBGColor, + Color? tableCellBGColor, + Color? greyHover, + Color? greySelect, + Color? lightGreyHover, + Color? toggleOffFill, + Color? progressBarBGColor, + Color? toggleButtonBGColor, + Color? calendarWeekendBGColor, + Color? gridRowCountColor, + TextStyle? code, + TextStyle? callout, + TextStyle? caption, + Color? background, + Color? onBackground, + Color? borderColor, + Color? scrollbarColor, + Color? scrollbarHoverColor, + Color? lightIconColor, + }) => + AFThemeExtension( + warning: warning ?? this.warning, + success: success ?? this.success, + tint1: tint1 ?? this.tint1, + tint2: tint2 ?? this.tint2, + tint3: tint3 ?? this.tint3, + tint4: tint4 ?? this.tint4, + tint5: tint5 ?? this.tint5, + tint6: tint6 ?? this.tint6, + tint7: tint7 ?? this.tint7, + tint8: tint8 ?? this.tint8, + tint9: tint9 ?? this.tint9, + textColor: textColor ?? this.textColor, + secondaryTextColor: secondaryTextColor ?? this.secondaryTextColor, + strongText: strongText ?? this.strongText, + calloutBGColor: calloutBGColor ?? this.calloutBGColor, + tableCellBGColor: tableCellBGColor ?? this.tableCellBGColor, + greyHover: greyHover ?? this.greyHover, + greySelect: greySelect ?? this.greySelect, + lightGreyHover: lightGreyHover ?? this.lightGreyHover, + toggleOffFill: toggleOffFill ?? this.toggleOffFill, + progressBarBGColor: progressBarBGColor ?? this.progressBarBGColor, + toggleButtonBGColor: toggleButtonBGColor ?? this.toggleButtonBGColor, + calendarWeekendBGColor: + calendarWeekendBGColor ?? this.calendarWeekendBGColor, + gridRowCountColor: gridRowCountColor ?? this.gridRowCountColor, + code: code ?? this.code, + callout: callout ?? this.callout, + caption: caption ?? this.caption, + onBackground: onBackground ?? this.onBackground, + background: background ?? this.background, + borderColor: borderColor ?? this.borderColor, + scrollbarColor: scrollbarColor ?? this.scrollbarColor, + scrollbarHoverColor: scrollbarHoverColor ?? this.scrollbarHoverColor, + lightIconColor: lightIconColor ?? this.lightIconColor, + ); + + @override + ThemeExtension lerp( + ThemeExtension? other, double t) { + if (other is! AFThemeExtension) { + return this; + } + return AFThemeExtension( + warning: Color.lerp(warning, other.warning, t), + success: Color.lerp(success, other.success, t), + tint1: Color.lerp(tint1, other.tint1, t)!, + tint2: Color.lerp(tint2, other.tint2, t)!, + tint3: Color.lerp(tint3, other.tint3, t)!, + tint4: Color.lerp(tint4, other.tint4, t)!, + tint5: Color.lerp(tint5, other.tint5, t)!, + tint6: Color.lerp(tint6, other.tint6, t)!, + tint7: Color.lerp(tint7, other.tint7, t)!, + tint8: Color.lerp(tint8, other.tint8, t)!, + tint9: Color.lerp(tint9, other.tint9, t)!, + textColor: Color.lerp(textColor, other.textColor, t)!, + secondaryTextColor: Color.lerp( + secondaryTextColor, + other.secondaryTextColor, + t, + )!, + strongText: Color.lerp( + strongText, + other.strongText, + t, + )!, + calloutBGColor: Color.lerp(calloutBGColor, other.calloutBGColor, t)!, + tableCellBGColor: + Color.lerp(tableCellBGColor, other.tableCellBGColor, t)!, + greyHover: Color.lerp(greyHover, other.greyHover, t)!, + greySelect: Color.lerp(greySelect, other.greySelect, t)!, + lightGreyHover: Color.lerp(lightGreyHover, other.lightGreyHover, t)!, + toggleOffFill: Color.lerp(toggleOffFill, other.toggleOffFill, t)!, + progressBarBGColor: + Color.lerp(progressBarBGColor, other.progressBarBGColor, t)!, + toggleButtonBGColor: + Color.lerp(toggleButtonBGColor, other.toggleButtonBGColor, t)!, + calendarWeekendBGColor: + Color.lerp(calendarWeekendBGColor, other.calendarWeekendBGColor, t)!, + gridRowCountColor: + Color.lerp(gridRowCountColor, other.gridRowCountColor, t)!, + code: other.code, + callout: other.callout, + caption: other.caption, + onBackground: Color.lerp(onBackground, other.onBackground, t)!, + background: Color.lerp(background, other.background, t)!, + borderColor: Color.lerp(borderColor, other.borderColor, t)!, + scrollbarColor: Color.lerp(scrollbarColor, other.scrollbarColor, t)!, + scrollbarHoverColor: + Color.lerp(scrollbarHoverColor, other.scrollbarHoverColor, t)!, + lightIconColor: Color.lerp(lightIconColor, other.lightIconColor, t)!, + ); + } +} + +enum FlowyTint { + tint1, + tint2, + tint3, + tint4, + tint5, + tint6, + tint7, + tint8, + tint9; + + String toJson() => name; + static FlowyTint fromJson(String json) { + try { + return FlowyTint.values.byName(json); + } catch (_) { + return FlowyTint.tint1; + } + } + + static FlowyTint? fromId(String id) { + for (final value in FlowyTint.values) { + if (value.id == id) { + return value; + } + } + return null; + } + + Color color(BuildContext context, {AFThemeExtension? theme}) => + switch (this) { + FlowyTint.tint1 => theme?.tint1 ?? AFThemeExtension.of(context).tint1, + FlowyTint.tint2 => theme?.tint2 ?? AFThemeExtension.of(context).tint2, + FlowyTint.tint3 => theme?.tint3 ?? AFThemeExtension.of(context).tint3, + FlowyTint.tint4 => theme?.tint4 ?? AFThemeExtension.of(context).tint4, + FlowyTint.tint5 => theme?.tint5 ?? AFThemeExtension.of(context).tint5, + FlowyTint.tint6 => theme?.tint6 ?? AFThemeExtension.of(context).tint6, + FlowyTint.tint7 => theme?.tint7 ?? AFThemeExtension.of(context).tint7, + FlowyTint.tint8 => theme?.tint8 ?? AFThemeExtension.of(context).tint8, + FlowyTint.tint9 => theme?.tint9 ?? AFThemeExtension.of(context).tint9, + }; + + String get id => switch (this) { + // DON'T change this name because it's saved in the database! + FlowyTint.tint1 => 'appflowy_them_color_tint1', + FlowyTint.tint2 => 'appflowy_them_color_tint2', + FlowyTint.tint3 => 'appflowy_them_color_tint3', + FlowyTint.tint4 => 'appflowy_them_color_tint4', + FlowyTint.tint5 => 'appflowy_them_color_tint5', + FlowyTint.tint6 => 'appflowy_them_color_tint6', + FlowyTint.tint7 => 'appflowy_them_color_tint7', + FlowyTint.tint8 => 'appflowy_them_color_tint8', + FlowyTint.tint9 => 'appflowy_them_color_tint9', + }; +} diff --git a/frontend/app_flowy/packages/flowy_infra/lib/time/duration.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/time/duration.dart similarity index 93% rename from frontend/app_flowy/packages/flowy_infra/lib/time/duration.dart rename to frontend/appflowy_flutter/packages/flowy_infra/lib/time/duration.dart index 5763ce1d4659e..a408470da9d83 100644 --- a/frontend/app_flowy/packages/flowy_infra/lib/time/duration.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/time/duration.dart @@ -1,7 +1,8 @@ import 'package:time/time.dart'; + export 'package:time/time.dart'; -class Durations { +class FlowyDurations { static Duration get fastest => .15.seconds; static Duration get fast => .25.seconds; diff --git a/frontend/app_flowy/packages/flowy_infra/lib/time/prelude.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/time/prelude.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_infra/lib/time/prelude.dart rename to frontend/appflowy_flutter/packages/flowy_infra/lib/time/prelude.dart diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/utils/color_converter.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/utils/color_converter.dart new file mode 100644 index 0000000000000..19ca90d78f87e --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/utils/color_converter.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:json_annotation/json_annotation.dart'; + +class ColorConverter implements JsonConverter { + const ColorConverter(); + + static const Color fallback = Colors.transparent; + + @override + Color fromJson(String radixString) { + final int? color = int.tryParse(radixString); + return color == null ? fallback : Color(color); + } + + @override + String toJson(Color color) => "0x${color.value.toRadixString(16)}"; +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/uuid.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/uuid.dart new file mode 100644 index 0000000000000..1f4c9036256c8 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/uuid.dart @@ -0,0 +1,19 @@ +import 'package:uuid/data.dart'; +import 'package:uuid/rng.dart'; +import 'package:uuid/uuid.dart'; + +const _uuid = Uuid(); + +String uuid() { + return _uuid.v4(); +} + +String fixedUuid(int seed, UuidType type) { + return _uuid.v4(config: V4Options(null, MathRNG(seed: seed + type.index))); +} + +enum UuidType { + // 0.6.0 + publicSpace, + privateSpace, +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml new file mode 100644 index 0000000000000..cb5cbb9cee987 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra/pubspec.yaml @@ -0,0 +1,64 @@ +name: flowy_infra +description: A new Flutter package project. +version: 0.0.1 +homepage: https://appflowy.io + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.10.1" + +dependencies: + flutter: + sdk: flutter + json_annotation: ^4.7.0 + path_provider: ^2.0.15 + path: ^1.8.2 + time: ">=2.0.0" + uuid: ">=2.2.2" + bloc: ^8.1.2 + freezed_annotation: ^2.1.0 + file_picker: ^8.0.2 + file: ^7.0.0 + +dev_dependencies: + build_runner: ^2.4.9 + flutter_lints: ^3.0.1 + freezed: ^2.4.7 + json_serializable: ^6.5.4 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/frontend/app_flowy/packages/flowy_infra_ui/.gitignore b/frontend/appflowy_flutter/packages/flowy_infra_ui/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/.gitignore rename to frontend/appflowy_flutter/packages/flowy_infra_ui/.gitignore diff --git a/frontend/app_flowy/packages/flowy_infra_ui/.metadata b/frontend/appflowy_flutter/packages/flowy_infra_ui/.metadata similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/.metadata rename to frontend/appflowy_flutter/packages/flowy_infra_ui/.metadata diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/CHANGELOG.md b/frontend/appflowy_flutter/packages/flowy_infra_ui/CHANGELOG.md similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/CHANGELOG.md rename to frontend/appflowy_flutter/packages/flowy_infra_ui/CHANGELOG.md diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/LICENSE b/frontend/appflowy_flutter/packages/flowy_infra_ui/LICENSE similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/LICENSE rename to frontend/appflowy_flutter/packages/flowy_infra_ui/LICENSE diff --git a/frontend/app_flowy/packages/flowy_infra_ui/README.md b/frontend/appflowy_flutter/packages/flowy_infra_ui/README.md similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/README.md rename to frontend/appflowy_flutter/packages/flowy_infra_ui/README.md diff --git a/frontend/app_flowy/packages/appflowy_popover/analysis_options.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/analysis_options.yaml similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/analysis_options.yaml rename to frontend/appflowy_flutter/packages/flowy_infra_ui/analysis_options.yaml diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/.classpath b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/.classpath similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/android/.classpath rename to frontend/appflowy_flutter/packages/flowy_infra_ui/android/.classpath diff --git a/frontend/app_flowy/packages/flowy_sdk/android/.gitignore b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/android/.gitignore rename to frontend/appflowy_flutter/packages/flowy_infra_ui/android/.gitignore diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/.project b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/.project similarity index 88% rename from frontend/app_flowy/packages/flowy_infra_ui/android/.project rename to frontend/appflowy_flutter/packages/flowy_infra_ui/android/.project index 77aded223a78c..4141b13cf152d 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/android/.project +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/.project @@ -22,12 +22,12 @@ - 1626576261667 + 1693395487121 30 org.eclipse.core.resources.regexFilterMatcher - node_modules|.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/android/.settings/org.eclipse.buildship.core.prefs b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000000000..d76c0b7ac1d30 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,13 @@ +arguments=--init-script /var/folders/th/tfqrqcp12kvgzs3c3z0xqxlc0000gn/T/d146c9752a26f79b52047fb6dc6ed385d064e120494f96f08ca63a317c41f94c.gradle --init-script /var/folders/th/tfqrqcp12kvgzs3c3z0xqxlc0000gn/T/52cde0cfcf3e28b8b7510e992210d9614505e0911af0c190bd590d7158574963.gradle +auto.sync=false +build.scans.enabled=false +connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(7.4.2)) +connection.project.dir= +eclipse.preferences.version=1 +gradle.user.home= +java.home=/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home +jvm.arguments= +offline.mode=false +override.workspace.settings=true +show.console.view=true +show.executions.view=true diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/android/build.gradle b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/build.gradle new file mode 100644 index 0000000000000..68cb27f4b0f83 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/build.gradle @@ -0,0 +1,35 @@ +group 'com.example.flowy_infra_ui' +version '1.0' + +buildscript { + ext.kotlin_version = '1.8.0' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.4.2' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' + +android { + compileSdkVersion 33 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + dependencies { + implementation "androidx.core:core:1.5.0-rc01" + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/gradle.properties b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/gradle.properties similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/android/gradle.properties rename to frontend/appflowy_flutter/packages/flowy_infra_ui/android/gradle.properties diff --git a/frontend/app_flowy/packages/flowy_sdk/android/gradle/wrapper/gradle-wrapper.properties b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/android/gradle/wrapper/gradle-wrapper.properties rename to frontend/appflowy_flutter/packages/flowy_infra_ui/android/gradle/wrapper/gradle-wrapper.properties diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/settings.gradle b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/settings.gradle similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/android/settings.gradle rename to frontend/appflowy_flutter/packages/flowy_infra_ui/android/settings.gradle diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/src/main/AndroidManifest.xml b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/src/main/AndroidManifest.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/android/src/main/AndroidManifest.xml rename to frontend/appflowy_flutter/packages/flowy_infra_ui/android/src/main/AndroidManifest.xml diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/FlowyInfraUIPlugin.java b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/FlowyInfraUIPlugin.java similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/FlowyInfraUIPlugin.java rename to frontend/appflowy_flutter/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/FlowyInfraUIPlugin.java diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/event/KeyboardEventHandler.java b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/event/KeyboardEventHandler.java similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/event/KeyboardEventHandler.java rename to frontend/appflowy_flutter/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/event/KeyboardEventHandler.java diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/src/main/kotlin/com/example/flowy_infra_ui/FlowyInfraUiPlugin.kt b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/src/main/kotlin/com/example/flowy_infra_ui/FlowyInfraUiPlugin.kt similarity index 93% rename from frontend/app_flowy/packages/flowy_infra_ui/android/src/main/kotlin/com/example/flowy_infra_ui/FlowyInfraUiPlugin.kt rename to frontend/appflowy_flutter/packages/flowy_infra_ui/android/src/main/kotlin/com/example/flowy_infra_ui/FlowyInfraUiPlugin.kt index 5d98dd8f103dd..1775aeaf9ce36 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/android/src/main/kotlin/com/example/flowy_infra_ui/FlowyInfraUiPlugin.kt +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/android/src/main/kotlin/com/example/flowy_infra_ui/FlowyInfraUiPlugin.kt @@ -8,8 +8,8 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result -/** FlowyInfraUiPlugin */ -class FlowyInfraUiPlugin: FlutterPlugin, MethodCallHandler { +/** FlowyInfraUIPlugin */ +class FlowyInfraUIPlugin: FlutterPlugin, MethodCallHandler { /// The MethodChannel that will the communication between Flutter and native Android /// /// This local reference serves to register the plugin with the Flutter Engine and unregister it diff --git a/frontend/app_flowy/packages/flowy_sdk/example/.gitignore b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/.gitignore rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/.gitignore diff --git a/frontend/app_flowy/.metadata b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/.metadata similarity index 100% rename from frontend/app_flowy/.metadata rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/.metadata diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/README.md b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/README.md similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/README.md rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/README.md diff --git a/frontend/app_flowy/packages/appflowy_board/example/analysis_options.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/analysis_options.yaml similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/analysis_options.yaml rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/analysis_options.yaml diff --git a/frontend/app_flowy/packages/appflowy_board/example/android/.gitignore b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/.gitignore similarity index 100% rename from frontend/app_flowy/packages/appflowy_board/example/android/.gitignore rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/.gitignore diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/.project b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/.project similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/.project rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/.project diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/.settings/org.eclipse.buildship.core.prefs b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/.settings/org.eclipse.buildship.core.prefs similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/.settings/org.eclipse.buildship.core.prefs rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/.settings/org.eclipse.buildship.core.prefs diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/.classpath b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/.classpath similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/app/.classpath rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/.classpath diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/.project b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/.project similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/app/.project rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/.project diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/.settings/org.eclipse.buildship.core.prefs b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000000000..25e42122856a3 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,13 @@ +arguments= +auto.sync=false +build.scans.enabled=false +connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) +connection.project.dir= +eclipse.preferences.version=1 +gradle.user.home= +java.home=/Library/Java/JavaVirtualMachines/jdk11.0.5-zulu.jdk/Contents/Home +jvm.arguments= +offline.mode=false +override.workspace.settings=true +show.console.view=true +show.executions.view=true diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/build.gradle b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/build.gradle similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/app/build.gradle rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/build.gradle diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/debug/AndroidManifest.xml b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/debug/AndroidManifest.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/debug/AndroidManifest.xml rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/debug/AndroidManifest.xml diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/AndroidManifest.xml b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/AndroidManifest.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/AndroidManifest.xml rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/AndroidManifest.xml diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/kotlin/com/example/flowy_infra_ui_example/MainActivity.kt b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/kotlin/com/example/flowy_infra_ui_example/MainActivity.kt similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/kotlin/com/example/flowy_infra_ui_example/MainActivity.kt rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/kotlin/com/example/flowy_infra_ui_example/MainActivity.kt diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/drawable-v21/launch_background.xml b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/drawable-v21/launch_background.xml similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/drawable-v21/launch_background.xml rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/drawable-v21/launch_background.xml diff --git a/frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/drawable/launch_background.xml b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/drawable/launch_background.xml similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/android/app/src/main/res/drawable/launch_background.xml rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/drawable/launch_background.xml diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/values-night/styles.xml b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/values-night/styles.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/values-night/styles.xml rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/values-night/styles.xml diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/values/styles.xml b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/values/styles.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/android/app/src/main/res/values/styles.xml rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/main/res/values/styles.xml diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/profile/AndroidManifest.xml b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/profile/AndroidManifest.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/profile/AndroidManifest.xml rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/app/src/profile/AndroidManifest.xml diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/build.gradle b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/build.gradle similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/build.gradle rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/build.gradle diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/gradle.properties b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/gradle.properties similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/gradle.properties rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/gradle.properties diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/gradle/wrapper/gradle-wrapper.properties b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/gradle/wrapper/gradle-wrapper.properties rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/gradle/wrapper/gradle-wrapper.properties diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/settings.gradle b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/settings.gradle similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/android/settings.gradle rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/android/settings.gradle diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/example/android/app/src/main/java/com/example/flowy_infra_ui_example/FlutterActivity.java b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/example/android/app/src/main/java/com/example/flowy_infra_ui_example/FlutterActivity.java similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/example/android/app/src/main/java/com/example/flowy_infra_ui_example/FlutterActivity.java rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/example/android/app/src/main/java/com/example/flowy_infra_ui_example/FlutterActivity.java diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/.gitignore b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/ios/.gitignore rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/.gitignore diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Flutter/AppFrameworkInfo.plist b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Flutter/AppFrameworkInfo.plist similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/ios/Flutter/AppFrameworkInfo.plist rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Flutter/AppFrameworkInfo.plist diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Flutter/Debug.xcconfig b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Flutter/Debug.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Flutter/Debug.xcconfig rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Flutter/Debug.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Flutter/Release.xcconfig b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Flutter/Release.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Flutter/Release.xcconfig rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Flutter/Release.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Podfile b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Podfile similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Podfile rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Podfile diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Podfile.lock b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Podfile.lock similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/ios/Podfile.lock rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Podfile.lock diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.pbxproj similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.pbxproj rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.pbxproj diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/contents.xcworkspacedata rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/AppDelegate.swift b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/AppDelegate.swift similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/AppDelegate.swift rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/AppDelegate.swift diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Base.lproj/LaunchScreen.storyboard rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Base.lproj/LaunchScreen.storyboard diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Base.lproj/Main.storyboard b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Base.lproj/Main.storyboard similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Base.lproj/Main.storyboard rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Base.lproj/Main.storyboard diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Info.plist b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Info.plist similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/ios/Runner/Info.plist rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Info.plist diff --git a/frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Runner-Bridging-Header.h b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Runner-Bridging-Header.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/ios/Runner/Runner-Bridging-Header.h rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/ios/Runner/Runner-Bridging-Header.h diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/lib/home/demo_item.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/home/demo_item.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/lib/home/demo_item.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/home/demo_item.dart diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/home/home_screen.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/home/home_screen.dart new file mode 100644 index 0000000000000..875440c04eacb --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/home/home_screen.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../overlay/overlay_screen.dart'; +import '../keyboard/keyboard_screen.dart'; +import 'demo_item.dart'; + +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + static List items = [ + SectionHeaderItem('Widget Demos'), + KeyboardItem(), + OverlayItem(), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Demos'), + ), + body: ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + if (item is SectionHeaderItem) { + return Container( + constraints: const BoxConstraints(maxHeight: 48.0), + color: Colors.grey[300], + alignment: Alignment.center, + child: ListTile( + title: Text(item.title), + ), + ); + } else if (item is DemoItem) { + return ListTile( + title: Text(item.buildTitle()), + onTap: () => item.handleTap(context), + ); + } + return const ListTile( + title: Text('Unknow.'), + ); + }, + ), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/lib/keyboard/keyboard_screen.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/keyboard/keyboard_screen.dart similarity index 94% rename from frontend/app_flowy/packages/flowy_infra_ui/example/lib/keyboard/keyboard_screen.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/keyboard/keyboard_screen.dart index fde544365be51..2b1a1cf59e5d7 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/lib/keyboard/keyboard_screen.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/keyboard/keyboard_screen.dart @@ -19,7 +19,7 @@ class KeyboardItem extends DemoItem { } class KeyboardScreen extends StatefulWidget { - const KeyboardScreen({Key? key}) : super(key: key); + const KeyboardScreen({super.key}); @override State createState() => _KeyboardScreenState(); @@ -30,6 +30,12 @@ class _KeyboardScreenState extends State { final TextEditingController _controller = TextEditingController(text: 'Hello Flowy'); + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/main.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/main.dart new file mode 100644 index 0000000000000..ecaa6266c6891 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/main.dart @@ -0,0 +1,22 @@ +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui_web.dart'; +import 'package:flutter/material.dart'; + +import 'home/home_screen.dart'; + +void main() { + runApp(const ExampleApp()); +} + +class ExampleApp extends StatelessWidget { + const ExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + builder: overlayManagerBuilder, + title: "Flowy Infra Title", + home: HomeScreen(), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/lib/overlay/overlay_screen.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/overlay/overlay_screen.dart similarity index 99% rename from frontend/app_flowy/packages/flowy_infra_ui/example/lib/overlay/overlay_screen.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/overlay/overlay_screen.dart index ae5cf7ef67c47..bec711cc98676 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/lib/overlay/overlay_screen.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/lib/overlay/overlay_screen.dart @@ -44,7 +44,7 @@ class OverlayDemoConfiguration extends ChangeNotifier { } class OverlayScreen extends StatelessWidget { - const OverlayScreen({Key? key}) : super(key: key); + const OverlayScreen({super.key}); @override Widget build(BuildContext context) { diff --git a/frontend/app_flowy/packages/appflowy_editor/example/linux/.gitignore b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/.gitignore similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/linux/.gitignore rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/.gitignore diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/CMakeLists.txt b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/linux/CMakeLists.txt rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/CMakeLists.txt diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/flutter/CMakeLists.txt b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/flutter/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/linux/flutter/CMakeLists.txt rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/flutter/CMakeLists.txt diff --git a/frontend/app_flowy/packages/appflowy_editor/example/linux/main.cc b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/main.cc similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/linux/main.cc rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/main.cc diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/linux/my_application.cc b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/my_application.cc similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/linux/my_application.cc rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/my_application.cc diff --git a/frontend/app_flowy/packages/appflowy_editor/example/linux/my_application.h b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/my_application.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/linux/my_application.h rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/linux/my_application.h diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/.gitignore b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/.gitignore new file mode 100644 index 0000000000000..d2fd3772308cc --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/.gitignore @@ -0,0 +1,6 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/xcuserdata/ diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/Flutter-Debug.xcconfig b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Flutter/Flutter-Debug.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/Flutter-Debug.xcconfig rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Flutter/Flutter-Debug.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/Flutter-Release.xcconfig b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Flutter/Flutter-Release.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/Flutter-Release.xcconfig rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Flutter/Flutter-Release.xcconfig diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Podfile b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Podfile new file mode 100644 index 0000000000000..fe733905db657 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.13' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Podfile.lock b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Podfile.lock similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/macos/Podfile.lock rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Podfile.lock diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/project.pbxproj similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/project.pbxproj rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/project.pbxproj diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/AppDelegate.swift b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/AppDelegate.swift similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/AppDelegate.swift rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/AppDelegate.swift diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png diff --git a/frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Base.lproj/MainMenu.xib b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Base.lproj/MainMenu.xib similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/macos/Runner/Base.lproj/MainMenu.xib rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Base.lproj/MainMenu.xib diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Configs/AppInfo.xcconfig b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Configs/AppInfo.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Configs/AppInfo.xcconfig rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Configs/AppInfo.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Configs/Debug.xcconfig b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Configs/Debug.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Configs/Debug.xcconfig rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Configs/Debug.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Configs/Release.xcconfig b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Configs/Release.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Configs/Release.xcconfig rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Configs/Release.xcconfig diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Configs/Warnings.xcconfig b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Configs/Warnings.xcconfig similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Configs/Warnings.xcconfig rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Configs/Warnings.xcconfig diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/DebugProfile.entitlements b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/DebugProfile.entitlements similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/DebugProfile.entitlements rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/DebugProfile.entitlements diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Info.plist b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Info.plist similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/Info.plist rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Info.plist diff --git a/frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/MainFlutterWindow.swift b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/MainFlutterWindow.swift similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/macos/Runner/MainFlutterWindow.swift rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/MainFlutterWindow.swift diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Release.entitlements b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Release.entitlements similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/macos/Runner/Release.entitlements rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/macos/Runner/Release.entitlements diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/example/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/pubspec.yaml new file mode 100644 index 0000000000000..8c7793d7cc878 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/pubspec.yaml @@ -0,0 +1,27 @@ +name: flowy_infra_ui_example +description: Demonstrates how to use the flowy_infra_ui plugin. + +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +environment: + flutter: ">=3.22.0" + sdk: ">=3.1.5 <4.0.0" + +dependencies: + flutter: + sdk: flutter + + flowy_infra_ui: + path: ../ + + cupertino_icons: ^1.0.2 + provider: + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^3.0.1 + +flutter: + uses-material-design: true diff --git a/frontend/app_flowy/packages/flowy_sdk/example/test/widget_test.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/test/widget_test.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/test/widget_test.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/test/widget_test.dart diff --git a/frontend/app_flowy/packages/appflowy_editor/example/web/favicon.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/favicon.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/web/favicon.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/favicon.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-192.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/icons/Icon-192.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-192.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/icons/Icon-192.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-512.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/icons/Icon-512.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-512.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/icons/Icon-512.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-maskable-192.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/icons/Icon-maskable-192.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-maskable-192.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/icons/Icon-maskable-192.png diff --git a/frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-maskable-512.png b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/icons/Icon-maskable-512.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-maskable-512.png rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/icons/Icon-maskable-512.png diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/web/index.html b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/index.html similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/web/index.html rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/index.html diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/web/manifest.json b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/manifest.json similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/web/manifest.json rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/web/manifest.json diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/.gitignore b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/.gitignore similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/windows/.gitignore rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/.gitignore diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/CMakeLists.txt b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/windows/CMakeLists.txt rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/CMakeLists.txt diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/flutter/CMakeLists.txt b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/flutter/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/windows/flutter/CMakeLists.txt rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/flutter/CMakeLists.txt diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/CMakeLists.txt b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/CMakeLists.txt rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/CMakeLists.txt diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/Runner.rc b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/Runner.rc similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/Runner.rc rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/Runner.rc diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/flutter_window.cpp b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/flutter_window.cpp similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/windows/runner/flutter_window.cpp rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/flutter_window.cpp diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/flutter_window.h b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/flutter_window.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/windows/runner/flutter_window.h rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/flutter_window.h diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/main.cpp b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/main.cpp similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/main.cpp rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/main.cpp diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/resource.h b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/resource.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/windows/runner/resource.h rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/resource.h diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/resources/app_icon.ico b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/resources/app_icon.ico similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/windows/runner/resources/app_icon.ico rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/resources/app_icon.ico diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/runner.exe.manifest b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/runner.exe.manifest similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/windows/runner/runner.exe.manifest rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/runner.exe.manifest diff --git a/frontend/app_flowy/packages/flowy_sdk/example/windows/runner/utils.cpp b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/utils.cpp similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/example/windows/runner/utils.cpp rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/utils.cpp diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/utils.h b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/utils.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/windows/runner/utils.h rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/utils.h diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/win32_window.cpp b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/win32_window.cpp similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/windows/runner/win32_window.cpp rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/win32_window.cpp diff --git a/frontend/app_flowy/packages/appflowy_popover/example/windows/runner/win32_window.h b/frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/win32_window.h similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/windows/runner/win32_window.h rename to frontend/appflowy_flutter/packages/flowy_infra_ui/example/windows/runner/win32_window.h diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/.gitignore b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/.gitignore rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/.gitignore diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/.metadata b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/.metadata similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/.metadata rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/.metadata diff --git a/frontend/app_flowy/packages/flowy_sdk/CHANGELOG.md b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/CHANGELOG.md similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/CHANGELOG.md rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/CHANGELOG.md diff --git a/frontend/app_flowy/packages/flowy_sdk/LICENSE b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/LICENSE similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/LICENSE rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/LICENSE diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/README.md b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/README.md similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/README.md rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/README.md diff --git a/frontend/app_flowy/packages/flowy_infra/analysis_options.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/analysis_options.yaml similarity index 100% rename from frontend/app_flowy/packages/flowy_infra/analysis_options.yaml rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/analysis_options.yaml diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/flowy_infra_ui_platform_interface.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/flowy_infra_ui_platform_interface.dart similarity index 78% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/flowy_infra_ui_platform_interface.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/flowy_infra_ui_platform_interface.dart index 9c578834075f8..717d8e125da92 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/flowy_infra_ui_platform_interface.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/flowy_infra_ui_platform_interface.dart @@ -18,10 +18,12 @@ abstract class FlowyInfraUIPlatform extends PlatformInterface { } Stream get onKeyboardVisibilityChange { - throw UnimplementedError('`onKeyboardChange` should be overridden by subclass.'); + throw UnimplementedError( + '`onKeyboardChange` should be overridden by subclass.'); } Future getPlatformVersion() { - throw UnimplementedError('`getPlatformVersion` should be overridden by subclass.'); + throw UnimplementedError( + '`getPlatformVersion` should be overridden by subclass.'); } } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/src/method_channel_flowy_infra_ui.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/src/method_channel_flowy_infra_ui.dart new file mode 100644 index 0000000000000..7062155e2306a --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/lib/src/method_channel_flowy_infra_ui.dart @@ -0,0 +1,27 @@ +import 'package:flowy_infra_ui_platform_interface/flowy_infra_ui_platform_interface.dart'; +import 'package:flutter/services.dart'; + +// ignore_for_file: constant_identifier_names +const INFRA_UI_METHOD_CHANNEL_NAME = 'flowy_infra_ui_method'; +const INFRA_UI_KEYBOARD_EVENT_CHANNEL_NAME = 'flowy_infra_ui_event/keyboard'; +const INFRA_UI_METHOD_GET_PLATFORM_VERSION = 'getPlatformVersion'; + +class MethodChannelFlowyInfraUI extends FlowyInfraUIPlatform { + final MethodChannel _methodChannel = + const MethodChannel(INFRA_UI_METHOD_CHANNEL_NAME); + final EventChannel _keyboardChannel = + const EventChannel(INFRA_UI_KEYBOARD_EVENT_CHANNEL_NAME); + + late final Stream _onKeyboardVisibilityChange = + _keyboardChannel.receiveBroadcastStream().map((event) => event as bool); + + @override + Stream get onKeyboardVisibilityChange => _onKeyboardVisibilityChange; + + @override + Future getPlatformVersion() async { + String? version = await _methodChannel + .invokeMethod(INFRA_UI_METHOD_GET_PLATFORM_VERSION); + return version ?? 'unknow'; + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml new file mode 100644 index 0000000000000..f2e3eb87491de --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml @@ -0,0 +1,21 @@ +name: flowy_infra_ui_platform_interface +description: A new Flutter package project. +version: 0.0.1 +homepage: + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + + plugin_platform_interface: ^2.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.1 + +flutter: \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/test/flowy_infra_ui_platform_interface_test.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/test/flowy_infra_ui_platform_interface_test.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/test/flowy_infra_ui_platform_interface_test.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/test/flowy_infra_ui_platform_interface_test.dart diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/.gitignore b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/.gitignore rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/.gitignore diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/.metadata b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/.metadata similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/.metadata rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/.metadata diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/CHANGELOG.md b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/CHANGELOG.md new file mode 100644 index 0000000000000..41cc7d8192ecf --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/LICENSE b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/LICENSE new file mode 100644 index 0000000000000..ba75c69f7f217 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/README.md b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/README.md similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/README.md rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/README.md diff --git a/frontend/app_flowy/packages/flowy_infra_ui/analysis_options.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/analysis_options.yaml similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/analysis_options.yaml rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/analysis_options.yaml diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/lib/flowy_infra_ui_web.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/lib/flowy_infra_ui_web.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/lib/flowy_infra_ui_web.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/lib/flowy_infra_ui_web.dart diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml new file mode 100644 index 0000000000000..bbdac0d2e401b --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml @@ -0,0 +1,28 @@ +name: flowy_infra_ui_web +description: A new Flutter package project. +version: 0.0.1 +homepage: +publish_to: none + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter_web_plugins: + sdk: flutter + + flowy_infra_ui_platform_interface: + path: ../flowy_infra_ui_platform_interface + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.1 + +flutter: + plugin: + platforms: + web: + pluginClass: FlowyInfraUIPlugin + fileName: flowy_infra_ui_web.dart \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/test/flowy_infra_ui_web_test.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/test/flowy_infra_ui_web_test.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/test/flowy_infra_ui_web_test.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/flowy_infra_ui_web/test/flowy_infra_ui_web_test.dart diff --git a/frontend/app_flowy/packages/flowy_infra_ui/ios/.gitignore b/frontend/appflowy_flutter/packages/flowy_infra_ui/ios/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/ios/.gitignore rename to frontend/appflowy_flutter/packages/flowy_infra_ui/ios/.gitignore diff --git a/frontend/app_flowy/packages/flowy_sdk/ios/Assets/.gitkeep b/frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Assets/.gitkeep similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/ios/Assets/.gitkeep rename to frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Assets/.gitkeep diff --git a/frontend/app_flowy/packages/flowy_infra_ui/ios/Classes/Event/KeyboardEventHandler.swift b/frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/Event/KeyboardEventHandler.swift similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/ios/Classes/Event/KeyboardEventHandler.swift rename to frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/Event/KeyboardEventHandler.swift diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.h b/frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.h new file mode 100644 index 0000000000000..85c878d9fd4a0 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface FlowyInfraUIPlugin : NSObject +@end diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.m b/frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.m new file mode 100644 index 0000000000000..58f19ce5161f6 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/FlowyInfraUIPlugin.m @@ -0,0 +1,15 @@ +#import "FlowyInfraUIPlugin.h" +#if __has_include() +#import +#else +// Support project import fallback if the generated compatibility header +// is not copied when this plugin is created as a library. +// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 +#import "flowy_infra_ui-Swift.h" +#endif + +@implementation FlowyInfraUIPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftFlowyInfraUIPlugin registerWithRegistrar:registrar]; +} +@end diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/SwiftFlowyInfraUIPlugin.swift b/frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/SwiftFlowyInfraUIPlugin.swift new file mode 100644 index 0000000000000..3e295388e7027 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/ios/Classes/SwiftFlowyInfraUIPlugin.swift @@ -0,0 +1,14 @@ +import Flutter +import UIKit + +public class SwiftFlowyInfraUIPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "flowy_infra_ui", binaryMessenger: registrar.messenger()) + let instance = SwiftFlowyInfraUIPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + result("iOS " + UIDevice.current.systemVersion) + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/ios/flowy_infra_ui.podspec b/frontend/appflowy_flutter/packages/flowy_infra_ui/ios/flowy_infra_ui.podspec similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/ios/flowy_infra_ui.podspec rename to frontend/appflowy_flutter/packages/flowy_infra_ui/ios/flowy_infra_ui.podspec diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/basis.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/basis.dart new file mode 100644 index 0000000000000..7bbcbf09499c2 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/basis.dart @@ -0,0 +1,3 @@ +// MARK: - Shared Builder +typedef IndexedCallback = void Function(int index); +typedef IndexedValueCallback = void Function(T value, int index); diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/flowy_infra_ui.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/flowy_infra_ui.dart new file mode 100644 index 0000000000000..e1f58189b18ba --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/flowy_infra_ui.dart @@ -0,0 +1,23 @@ +// Basis +export '/widget/flowy_tooltip.dart'; +export '/widget/separated_flex.dart'; +export '/widget/spacing.dart'; +export 'basis.dart'; +export 'src/flowy_overlay/appflowy_popover.dart'; +export 'src/flowy_overlay/flowy_dialog.dart'; +// Overlay +export 'src/flowy_overlay/flowy_overlay.dart'; +export 'src/flowy_overlay/list_overlay.dart'; +export 'src/flowy_overlay/option_overlay.dart'; +// Keyboard +export 'src/keyboard/keyboard_visibility_detector.dart'; +export 'style_widget/button.dart'; +export 'style_widget/color_picker.dart'; +export 'style_widget/divider.dart'; +export 'style_widget/icon_button.dart'; +export 'style_widget/primary_rounded_button.dart'; +export 'style_widget/scrollbar.dart'; +export 'style_widget/scrolling/styled_list.dart'; +export 'style_widget/scrolling/styled_scroll_bar.dart'; +export 'style_widget/text.dart'; +export 'style_widget/text_field.dart'; diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/flowy_infra_ui_web.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/flowy_infra_ui_web.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/flowy_infra_ui_web.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/flowy_infra_ui_web.dart diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart new file mode 100644 index 0000000000000..190d840a41a0a --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart @@ -0,0 +1,192 @@ +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; +import 'package:flutter/material.dart'; + +export 'package:appflowy_popover/appflowy_popover.dart'; + +class AppFlowyPopover extends StatelessWidget { + const AppFlowyPopover({ + super.key, + required this.child, + required this.popupBuilder, + this.direction = PopoverDirection.rightWithTopAligned, + this.onOpen, + this.onClose, + this.canClose, + this.constraints = const BoxConstraints(maxWidth: 240, maxHeight: 600), + this.mutex, + this.triggerActions = PopoverTriggerFlags.click, + this.offset, + this.controller, + this.asBarrier = false, + this.margin = const EdgeInsets.all(6), + this.windowPadding = const EdgeInsets.all(8.0), + this.clickHandler = PopoverClickHandler.listener, + this.skipTraversal = false, + this.decorationColor, + this.borderRadius, + this.animationDuration = const Duration(), + this.slideDistance = 5.0, + this.beginScaleFactor = 0.9, + this.endScaleFactor = 1.0, + this.beginOpacity = 0.0, + this.endOpacity = 1.0, + this.showAtCursor = false, + }); + + final Widget child; + final PopoverController? controller; + final Widget Function(BuildContext context) popupBuilder; + final PopoverDirection direction; + final int triggerActions; + final BoxConstraints constraints; + final VoidCallback? onOpen; + final VoidCallback? onClose; + final Future Function()? canClose; + final PopoverMutex? mutex; + final Offset? offset; + final bool asBarrier; + final EdgeInsets margin; + final EdgeInsets windowPadding; + final Color? decorationColor; + final BorderRadius? borderRadius; + final Duration animationDuration; + final double slideDistance; + final double beginScaleFactor; + final double endScaleFactor; + final double beginOpacity; + final double endOpacity; + + /// The widget that will be used to trigger the popover. + /// + /// Why do we need this? + /// Because if the parent widget of the popover is GestureDetector, + /// the conflict won't be resolve by using Listener, we want these two gestures exclusive. + final PopoverClickHandler clickHandler; + + /// If true the popover will not participate in focus traversal. + /// + final bool skipTraversal; + + /// Whether the popover should be shown at the cursor position. + /// If true, the [offset] will be ignored. + /// + /// This only works when using [PopoverClickHandler.listener] as the click handler. + /// + /// Alternatively for having a normal popover, and use the cursor position only on + /// secondary click, consider showing the popover programatically with [PopoverController.showAt]. + /// + final bool showAtCursor; + + @override + Widget build(BuildContext context) { + return Popover( + controller: controller, + animationDuration: animationDuration, + slideDistance: slideDistance, + beginScaleFactor: beginScaleFactor, + endScaleFactor: endScaleFactor, + beginOpacity: beginOpacity, + endOpacity: endOpacity, + onOpen: onOpen, + onClose: onClose, + canClose: canClose, + direction: direction, + mutex: mutex, + asBarrier: asBarrier, + triggerActions: triggerActions, + windowPadding: windowPadding, + offset: offset, + clickHandler: clickHandler, + skipTraversal: skipTraversal, + popupBuilder: (context) => _PopoverContainer( + constraints: constraints, + margin: margin, + decorationColor: decorationColor, + borderRadius: borderRadius, + child: popupBuilder(context), + ), + showAtCursor: showAtCursor, + child: child, + ); + } +} + +class _PopoverContainer extends StatelessWidget { + const _PopoverContainer({ + this.decorationColor, + this.borderRadius, + required this.child, + required this.margin, + required this.constraints, + }); + + final Widget child; + final BoxConstraints constraints; + final EdgeInsets margin; + final Color? decorationColor; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: Container( + padding: margin, + decoration: context.getPopoverDecoration( + color: decorationColor, + borderRadius: borderRadius, + ), + constraints: constraints, + child: child, + ), + ); + } +} + +extension on BuildContext { + /// The decoration of the popover. + /// + /// Don't customize the entire decoration of the popover, + /// use the built-in popoverDecoration instead and ask the designer before changing it. + ShapeDecoration getPopoverDecoration({ + Color? color, + BorderRadius? borderRadius, + }) { + final borderColor = Theme.of(this).brightness == Brightness.light + ? ColorSchemeConstants.lightBorderColor + : ColorSchemeConstants.darkBorderColor; + final shadows = [ + const BoxShadow( + color: Color(0x0A1F2329), + blurRadius: 24, + offset: Offset(0, 8), + spreadRadius: 8, + ), + const BoxShadow( + color: Color(0x0A1F2329), + blurRadius: 12, + offset: Offset(0, 6), + spreadRadius: 0, + ), + const BoxShadow( + color: Color(0x0F1F2329), + blurRadius: 8, + offset: Offset(0, 4), + spreadRadius: -8, + ) + ]; + return ShapeDecoration( + color: color ?? Theme.of(this).cardColor, + shape: RoundedRectangleBorder( + side: BorderSide( + width: 1, + strokeAlign: BorderSide.strokeAlignOutside, + color: color != Colors.transparent ? borderColor : color!, + ), + borderRadius: borderRadius ?? BorderRadius.circular(10), + ), + shadows: shadows, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart new file mode 100644 index 0000000000000..a5a51a16e6fac --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_dialog.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +const _overlayContainerPadding = EdgeInsets.symmetric(vertical: 12); +const overlayContainerMaxWidth = 760.0; +const overlayContainerMinWidth = 320.0; +const _defaultInsetPadding = + EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0); + +class FlowyDialog extends StatelessWidget { + const FlowyDialog({ + super.key, + required this.child, + this.title, + this.shape, + this.constraints, + this.padding = _overlayContainerPadding, + this.backgroundColor, + this.expandHeight = true, + this.alignment, + this.insetPadding, + this.width, + }); + + final Widget? title; + final ShapeBorder? shape; + final Widget child; + final BoxConstraints? constraints; + final EdgeInsets padding; + final Color? backgroundColor; + final bool expandHeight; + + // Position of the Dialog + final Alignment? alignment; + + // Inset of the Dialog + final EdgeInsets? insetPadding; + + final double? width; + + @override + Widget build(BuildContext context) { + final windowSize = MediaQuery.of(context).size; + final size = windowSize * 0.7; + + return SimpleDialog( + alignment: alignment, + insetPadding: insetPadding ?? _defaultInsetPadding, + contentPadding: EdgeInsets.zero, + backgroundColor: backgroundColor ?? Theme.of(context).cardColor, + title: title, + shape: shape ?? + RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + clipBehavior: Clip.antiAliasWithSaveLayer, + children: [ + Material( + type: MaterialType.transparency, + child: Container( + height: expandHeight ? size.height : null, + width: width ?? size.width, + constraints: constraints, + child: child, + ), + ) + ], + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart similarity index 90% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart index fc18de2a293e1..ef03bbc3bddf0 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart @@ -1,10 +1,12 @@ // ignore_for_file: unused_element import 'dart:ui'; -import 'package:flowy_infra_ui/src/flowy_overlay/layout.dart'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flowy_infra_ui/src/flowy_overlay/layout.dart'; + /// Specifies how overlay are anchored to the SourceWidget enum AnchorDirection { // Corner aligned with a corner of the SourceWidget @@ -66,25 +68,25 @@ class FlowyOverlayStyle { final GlobalKey _key = GlobalKey(); /// Invoke this method in app generation process -TransitionBuilder overlayManagerBuilder() { - return (context, child) { - assert(child != null, 'Child can\'t be null.'); - return FlowyOverlay(key: _key, child: child!); - }; +Widget overlayManagerBuilder(BuildContext context, Widget? child) { + assert(child != null, 'Child can\'t be null.'); + return FlowyOverlay(key: _key, child: child!); } -abstract class FlowyOverlayDelegate { +abstract mixin class FlowyOverlayDelegate { bool asBarrier() => false; void didRemove() => {}; } class FlowyOverlay extends StatefulWidget { - const FlowyOverlay({Key? key, required this.child}) : super(key: key); + const FlowyOverlay({super.key, required this.child}); final Widget child; - static FlowyOverlayState of(BuildContext context, - {bool rootOverlay = false}) { + static FlowyOverlayState of( + BuildContext context, { + bool rootOverlay = false, + }) { FlowyOverlayState? state = maybeOf(context, rootOverlay: rootOverlay); assert(() { if (state == null) { @@ -97,8 +99,10 @@ class FlowyOverlay extends StatefulWidget { return state!; } - static FlowyOverlayState? maybeOf(BuildContext context, - {bool rootOverlay = false}) { + static FlowyOverlayState? maybeOf( + BuildContext context, { + bool rootOverlay = false, + }) { FlowyOverlayState? state; if (rootOverlay) { state = context.findRootAncestorStateOfType(); @@ -108,10 +112,11 @@ class FlowyOverlay extends StatefulWidget { return state; } - static void show( - {required BuildContext context, - required Widget Function(BuildContext context) builder}) { - showDialog( + static Future show({ + required BuildContext context, + required WidgetBuilder builder, + }) async { + await showDialog( context: context, builder: builder, ); @@ -274,7 +279,6 @@ class FlowyOverlayState extends State { OverlapBehaviour? overlapBehaviour, FlowyOverlayDelegate? delegate, }) { - debugPrint("Show overlay: $identifier"); Widget overlay = widget; final offset = anchorOffset ?? Offset.zero; final focusNode = FocusNode(); @@ -289,7 +293,7 @@ class FlowyOverlayState extends State { RenderObject renderObject = anchorContext.findRenderObject()!; assert( renderObject is RenderBox, - 'Unexpected non-RenderBox render object caught.', + 'Unexpecteded non-RenderBox render object caught.', ); final renderBox = renderObject as RenderBox; targetAnchorPosition = renderBox.localToGlobal(Offset.zero); @@ -311,11 +315,11 @@ class FlowyOverlayState extends State { ), child: Focus( focusNode: focusNode, - onKey: (node, event) { + onKeyEvent: (node, event) { KeyEventResult result = KeyEventResult.ignored; for (final ShortcutActivator activator in _keyboardShortcutBindings.keys) { - if (activator.accepts(event, RawKeyboard.instance)) { + if (activator.accepts(event, HardwareKeyboard.instance)) { _keyboardShortcutBindings[activator]!.call(identifier); result = KeyEventResult.handled; } @@ -338,18 +342,17 @@ class FlowyOverlayState extends State { @override void initState() { + super.initState(); _keyboardShortcutBindings.addAll({ - LogicalKeySet(LogicalKeyboardKey.escape): (identifier) { - remove(identifier); - }, + LogicalKeySet(LogicalKeyboardKey.escape): (identifier) => + remove(identifier), }); - super.initState(); } @override Widget build(BuildContext context) { final overlays = _overlayList.map((item) { - var widget = item.widget; + Widget widget = item.widget; // requestFocus will cause the children weird focus behaviors. // item.focusNode.requestFocus(); @@ -387,15 +390,11 @@ class FlowyOverlayState extends State { return MaterialApp( theme: Theme.of(context), debugShowCheckedModeBanner: false, - home: Stack( - children: children..addAll(overlays), - ), + home: Stack(children: children..addAll(overlays)), ); } - void _handleTapOnBackground() { - removeAll(); - } + void _handleTapOnBackground() => removeAll(); Widget? _renderBackground(List overlays) { Widget? child; diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_popover_layout.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_popover_layout.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_popover_layout.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_popover_layout.dart diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/layout.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/layout.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/layout.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/layout.dart diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/list_overlay.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/list_overlay.dart similarity index 91% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/list_overlay.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/list_overlay.dart index a7e7523dd9b16..2b61839ac349b 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/list_overlay.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/list_overlay.dart @@ -2,9 +2,7 @@ import 'dart:math'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/style_widget/decoration.dart'; -import 'package:provider/provider.dart'; class ListOverlayFooter { Widget widget; @@ -19,13 +17,13 @@ class ListOverlayFooter { class ListOverlay extends StatelessWidget { const ListOverlay({ - Key? key, + super.key, required this.itemBuilder, this.itemCount = 0, this.controller, this.constraints = const BoxConstraints(), this.footer, - }) : super(key: key); + }); final IndexedWidgetBuilder itemBuilder; final int itemCount; @@ -119,20 +117,18 @@ class OverlayContainer extends StatelessWidget { required this.child, this.constraints, this.padding = overlayContainerPadding, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { - final theme = - context.watch() ?? AppTheme.fromType(ThemeType.light); return Material( type: MaterialType.transparency, child: Container( padding: padding, decoration: FlowyDecoration.decoration( - theme.surface, - theme.shadowColor.withOpacity(0.15), + Theme.of(context).colorScheme.surface, + Theme.of(context).colorScheme.shadow.withOpacity(0.15), ), constraints: constraints, child: child, diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/option_overlay.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/option_overlay.dart similarity index 90% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/option_overlay.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/option_overlay.dart index 09c28459b1e3c..5248665c410c0 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/option_overlay.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/option_overlay.dart @@ -10,11 +10,11 @@ class OptionItem { class OptionOverlay extends StatelessWidget { const OptionOverlay({ - Key? key, + super.key, required this.items, this.onHover, this.onTap, - }) : super(key: key); + }); final List items; final IndexedValueCallback? onHover; @@ -47,12 +47,14 @@ class OptionOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - final List<_OptionListItem> listItems = items.map((e) => _OptionListItem(e)).toList(); + final List<_OptionListItem> listItems = + items.map((e) => _OptionListItem(e)).toList(); return ListOverlay( itemBuilder: (context, index) { return MouseRegion( cursor: SystemMouseCursors.click, - onHover: onHover != null ? (_) => onHover!(items[index], index) : null, + onHover: + onHover != null ? (_) => onHover!(items[index], index) : null, child: GestureDetector( onTap: onTap != null ? () => onTap!(items[index], index) : null, child: listItems[index], @@ -67,8 +69,8 @@ class OptionOverlay extends StatelessWidget { class _OptionListItem extends StatelessWidget { const _OptionListItem( this.value, { - Key? key, - }) : super(key: key); + super.key, + }); final T value; diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/focus/auto_unfocus_overlay.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/focus/auto_unfocus_overlay.dart similarity index 93% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/src/focus/auto_unfocus_overlay.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/focus/auto_unfocus_overlay.dart index 45faebc07d901..148a812910db2 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/focus/auto_unfocus_overlay.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/focus/auto_unfocus_overlay.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; class AutoUnfocus extends StatelessWidget { const AutoUnfocus({ - Key? key, + super.key, required this.child, - }) : super(key: key); + }); final Widget child; diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/keyboard/keyboard_visibility_detector.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/keyboard/keyboard_visibility_detector.dart similarity index 93% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/src/keyboard/keyboard_visibility_detector.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/keyboard/keyboard_visibility_detector.dart index 643ddd94b1d3b..cbe9a0790828d 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/keyboard/keyboard_visibility_detector.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/keyboard/keyboard_visibility_detector.dart @@ -5,10 +5,10 @@ import 'package:flutter/material.dart'; class KeyboardVisibilityDetector extends StatefulWidget { const KeyboardVisibilityDetector({ - Key? key, + super.key, required this.child, this.onKeyboardVisibilityChange, - }) : super(key: key); + }); final Widget child; final void Function(bool)? onKeyboardVisibilityChange; @@ -57,10 +57,9 @@ class _KeyboardVisibilityDetectorState class _KeyboardVisibilityDetectorInheritedWidget extends InheritedWidget { const _KeyboardVisibilityDetectorInheritedWidget({ - Key? key, required this.isKeyboardVisible, - required Widget child, - }) : super(key: key, child: child); + required super.child, + }); final bool isKeyboardVisible; diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/bar_title.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/bar_title.dart similarity index 88% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/bar_title.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/bar_title.dart index 47cd1cd8e9c33..cc54a8ba008f9 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/bar_title.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/bar_title.dart @@ -4,9 +4,9 @@ class FlowyBarTitle extends StatelessWidget { final String title; const FlowyBarTitle({ - Key? key, + super.key, required this.title, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart new file mode 100644 index 0000000000000..0e5b656a35853 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart @@ -0,0 +1,558 @@ +import 'dart:io'; + +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; + +class FlowyIconTextButton extends StatelessWidget { + final Widget Function(bool onHover) textBuilder; + final VoidCallback? onTap; + final VoidCallback? onSecondaryTap; + final void Function(bool)? onHover; + final EdgeInsets? margin; + final Widget? Function(bool onHover)? leftIconBuilder; + final Widget? Function(bool onHover)? rightIconBuilder; + final Color? hoverColor; + final bool isSelected; + final BorderRadius? radius; + final BoxDecoration? decoration; + final bool useIntrinsicWidth; + final bool disable; + final double disableOpacity; + final Size? leftIconSize; + final bool expandText; + final MainAxisAlignment mainAxisAlignment; + final bool showDefaultBoxDecorationOnMobile; + final double iconPadding; + final bool expand; + final Color? borderColor; + final bool resetHoverOnRebuild; + + const FlowyIconTextButton({ + super.key, + required this.textBuilder, + this.onTap, + this.onSecondaryTap, + this.onHover, + this.margin, + this.leftIconBuilder, + this.rightIconBuilder, + this.hoverColor, + this.isSelected = false, + this.radius, + this.decoration, + this.useIntrinsicWidth = false, + this.disable = false, + this.disableOpacity = 0.5, + this.leftIconSize = const Size.square(16), + this.expandText = true, + this.mainAxisAlignment = MainAxisAlignment.center, + this.showDefaultBoxDecorationOnMobile = false, + this.iconPadding = 6, + this.expand = false, + this.borderColor, + this.resetHoverOnRebuild = true, + }); + + @override + Widget build(BuildContext context) { + final color = hoverColor ?? Theme.of(context).colorScheme.secondary; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: disable ? null : onTap, + onSecondaryTap: disable ? null : onSecondaryTap, + child: FlowyHover( + resetHoverOnRebuild: resetHoverOnRebuild, + cursor: + disable ? SystemMouseCursors.forbidden : SystemMouseCursors.click, + style: HoverStyle( + borderRadius: radius ?? Corners.s6Border, + hoverColor: color, + borderColor: borderColor ?? Colors.transparent, + ), + onHover: disable ? null : onHover, + isSelected: () => isSelected, + builder: (context, onHover) => _render(context, onHover), + ), + ); + } + + Widget _render(BuildContext context, bool onHover) { + final List children = []; + + final Widget? leftIcon = leftIconBuilder?.call(onHover); + if (leftIcon != null) { + children.add( + SizedBox.fromSize( + size: leftIconSize, + child: leftIcon, + ), + ); + children.add(HSpace(iconPadding)); + } + + if (expandText) { + children.add(Expanded(child: textBuilder(onHover))); + } else { + children.add(textBuilder(onHover)); + } + + final Widget? rightIcon = rightIconBuilder?.call(onHover); + if (rightIcon != null) { + children.add(HSpace(iconPadding)); + // No need to define the size of rightIcon. Just use its intrinsic width + children.add(rightIcon); + } + + Widget child = Row( + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, + children: children, + ); + + if (useIntrinsicWidth) { + child = IntrinsicWidth(child: child); + } + + final decoration = this.decoration ?? + (showDefaultBoxDecorationOnMobile && + (Platform.isIOS || Platform.isAndroid) + ? BoxDecoration( + border: Border.all( + color: borderColor ?? + Theme.of(context).colorScheme.surfaceContainerHighest, + width: 1.0, + )) + : null); + + return Container( + decoration: decoration, + child: Padding( + padding: + margin ?? const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: child, + ), + ); + } +} + +class FlowyButton extends StatelessWidget { + final Widget text; + final VoidCallback? onTap; + final VoidCallback? onSecondaryTap; + final void Function(bool)? onHover; + final EdgeInsetsGeometry? margin; + final Widget? leftIcon; + final Widget? rightIcon; + final Color? hoverColor; + final bool isSelected; + final BorderRadius? radius; + final BoxDecoration? decoration; + final bool useIntrinsicWidth; + final bool disable; + final double disableOpacity; + final Size? leftIconSize; + final bool expandText; + final MainAxisAlignment mainAxisAlignment; + final bool showDefaultBoxDecorationOnMobile; + final double iconPadding; + final bool expand; + final Color? borderColor; + final Color? backgroundColor; + final bool resetHoverOnRebuild; + + const FlowyButton({ + super.key, + required this.text, + this.onTap, + this.onSecondaryTap, + this.onHover, + this.margin, + this.leftIcon, + this.rightIcon, + this.hoverColor, + this.isSelected = false, + this.radius, + this.decoration, + this.useIntrinsicWidth = false, + this.disable = false, + this.disableOpacity = 0.5, + this.leftIconSize = const Size.square(16), + this.expandText = true, + this.mainAxisAlignment = MainAxisAlignment.center, + this.showDefaultBoxDecorationOnMobile = false, + this.iconPadding = 6, + this.expand = false, + this.borderColor, + this.backgroundColor, + this.resetHoverOnRebuild = true, + }); + + @override + Widget build(BuildContext context) { + final color = hoverColor ?? Theme.of(context).colorScheme.secondary; + final alpha = (255 * disableOpacity).toInt(); + color.withAlpha(alpha); + + if (Platform.isIOS || Platform.isAndroid) { + return InkWell( + splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, + onTap: disable ? null : onTap, + onSecondaryTap: disable ? null : onSecondaryTap, + borderRadius: radius ?? Corners.s6Border, + child: _render(context), + ); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: disable ? null : onTap, + onSecondaryTap: disable ? null : onSecondaryTap, + child: FlowyHover( + resetHoverOnRebuild: resetHoverOnRebuild, + cursor: + disable ? SystemMouseCursors.forbidden : SystemMouseCursors.click, + style: HoverStyle( + borderRadius: radius ?? Corners.s6Border, + hoverColor: color, + borderColor: borderColor ?? Colors.transparent, + backgroundColor: backgroundColor ?? Colors.transparent, + ), + onHover: disable ? null : onHover, + isSelected: () => isSelected, + builder: (context, onHover) => _render(context), + ), + ); + } + + Widget _render(BuildContext context) { + final List children = []; + + if (leftIcon != null) { + children.add( + SizedBox.fromSize( + size: leftIconSize, + child: leftIcon!, + ), + ); + children.add(HSpace(iconPadding)); + } + + if (expandText) { + children.add(Expanded(child: text)); + } else { + children.add(text); + } + + if (rightIcon != null) { + children.add(HSpace(iconPadding)); + // No need to define the size of rightIcon. Just use its intrinsic width + children.add(rightIcon!); + } + + Widget child = Row( + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, + children: children, + ); + + if (useIntrinsicWidth) { + child = IntrinsicWidth(child: child); + } + + var decoration = this.decoration; + + if (decoration == null && + (showDefaultBoxDecorationOnMobile && + (Platform.isIOS || Platform.isAndroid))) { + decoration = BoxDecoration( + color: backgroundColor ?? Theme.of(context).colorScheme.surface, + ); + } + + if (decoration == null && (Platform.isIOS || Platform.isAndroid)) { + if (showDefaultBoxDecorationOnMobile) { + decoration = BoxDecoration( + border: Border.all( + color: borderColor ?? Theme.of(context).colorScheme.outline, + width: 1.0, + ), + borderRadius: radius, + ); + } else if (backgroundColor != null) { + decoration = BoxDecoration( + color: backgroundColor, + borderRadius: radius, + ); + } + } + + return Container( + decoration: decoration, + child: Padding( + padding: margin ?? + const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + child: child, + ), + ); + } +} + +class FlowyTextButton extends StatelessWidget { + const FlowyTextButton( + this.text, { + super.key, + this.onPressed, + this.fontSize, + this.fontColor, + this.fontHoverColor, + this.overflow = TextOverflow.ellipsis, + this.fontWeight, + this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + this.hoverColor, + this.fillColor, + this.heading, + this.radius, + this.mainAxisAlignment = MainAxisAlignment.start, + this.tooltip, + this.constraints = const BoxConstraints(minWidth: 0.0, minHeight: 0.0), + this.decoration, + this.fontFamily, + this.isDangerous = false, + this.borderColor, + this.lineHeight, + }); + + factory FlowyTextButton.primary({ + required BuildContext context, + required String text, + VoidCallback? onPressed, + }) => + FlowyTextButton( + text, + constraints: const BoxConstraints(minHeight: 32), + fillColor: Theme.of(context).colorScheme.primary, + hoverColor: const Color(0xFF005483), + fontColor: Theme.of(context).colorScheme.onPrimary, + fontHoverColor: Colors.white, + onPressed: onPressed, + ); + + factory FlowyTextButton.secondary({ + required BuildContext context, + required String text, + VoidCallback? onPressed, + }) => + FlowyTextButton( + text, + constraints: const BoxConstraints(minHeight: 32), + fillColor: Colors.transparent, + hoverColor: Theme.of(context).colorScheme.primary, + fontColor: Theme.of(context).colorScheme.primary, + borderColor: Theme.of(context).colorScheme.primary, + fontHoverColor: Colors.white, + onPressed: onPressed, + ); + + final String text; + final FontWeight? fontWeight; + final Color? fontColor; + final Color? fontHoverColor; + final double? fontSize; + final TextOverflow overflow; + + final VoidCallback? onPressed; + final EdgeInsets padding; + final Widget? heading; + final Color? hoverColor; + final Color? fillColor; + final BorderRadius? radius; + final MainAxisAlignment mainAxisAlignment; + final String? tooltip; + final BoxConstraints constraints; + + final TextDecoration? decoration; + + final String? fontFamily; + final bool isDangerous; + final Color? borderColor; + final double? lineHeight; + + @override + Widget build(BuildContext context) { + List children = []; + if (heading != null) { + children.add(heading!); + children.add(const HSpace(8)); + } + children.add(Text( + text, + overflow: overflow, + textAlign: TextAlign.center, + )); + + Widget child = Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: mainAxisAlignment, + children: children, + ); + + child = ConstrainedBox( + constraints: constraints, + child: TextButton( + onPressed: onPressed, + focusNode: FocusNode(skipTraversal: onPressed == null), + style: ButtonStyle( + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + splashFactory: NoSplash.splashFactory, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: WidgetStateProperty.all(padding), + elevation: WidgetStateProperty.all(0), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + side: BorderSide( + color: borderColor ?? + (isDangerous + ? Theme.of(context).colorScheme.error + : Colors.transparent), + ), + borderRadius: radius ?? Corners.s6Border, + ), + ), + textStyle: WidgetStateProperty.all( + Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: fontWeight ?? FontWeight.w500, + fontSize: fontSize, + color: fontColor ?? Theme.of(context).colorScheme.onPrimary, + decoration: decoration, + fontFamily: fontFamily, + height: lineHeight ?? 1.1, + ), + ), + backgroundColor: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.hovered)) { + return hoverColor ?? + (isDangerous + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.secondary); + } + + return fillColor ?? + (isDangerous + ? Colors.transparent + : Theme.of(context).colorScheme.secondaryContainer); + }, + ), + foregroundColor: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.hovered)) { + return fontHoverColor ?? + (fontColor ?? Theme.of(context).colorScheme.onSurface); + } + + return fontColor ?? Theme.of(context).colorScheme.onSurface; + }, + ), + ), + child: child, + ), + ); + + if (tooltip != null) { + child = FlowyTooltip(message: tooltip!, child: child); + } + + if (onPressed == null) { + child = ExcludeFocus(child: child); + } + + return child; + } +} + +class FlowyRichTextButton extends StatelessWidget { + const FlowyRichTextButton( + this.text, { + super.key, + this.onPressed, + this.overflow = TextOverflow.ellipsis, + this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + this.hoverColor, + this.fillColor, + this.heading, + this.radius, + this.mainAxisAlignment = MainAxisAlignment.start, + this.tooltip, + this.constraints = const BoxConstraints(minWidth: 58.0, minHeight: 30.0), + this.decoration, + }); + + final InlineSpan text; + final TextOverflow overflow; + + final VoidCallback? onPressed; + final EdgeInsets padding; + final Widget? heading; + final Color? hoverColor; + final Color? fillColor; + final BorderRadius? radius; + final MainAxisAlignment mainAxisAlignment; + final String? tooltip; + final BoxConstraints constraints; + + final TextDecoration? decoration; + + @override + Widget build(BuildContext context) { + List children = []; + if (heading != null) { + children.add(heading!); + children.add(const HSpace(6)); + } + children.add( + RichText(text: text, overflow: overflow, textAlign: TextAlign.center), + ); + + Widget child = Padding( + padding: padding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: mainAxisAlignment, + children: children, + ), + ); + + child = RawMaterialButton( + hoverElevation: 0, + highlightElevation: 0, + shape: RoundedRectangleBorder(borderRadius: radius ?? Corners.s6Border), + fillColor: fillColor ?? Theme.of(context).colorScheme.secondaryContainer, + hoverColor: hoverColor ?? Theme.of(context).colorScheme.secondary, + focusColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + elevation: 0, + constraints: constraints, + onPressed: () {}, + child: child, + ); + + child = IgnoreParentGestureWidget(onPress: onPressed, child: child); + + if (tooltip != null) { + child = FlowyTooltip(message: tooltip!, child: child); + } + + return child; + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/close_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/close_button.dart similarity index 88% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/close_button.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/close_button.dart index 371ee123ed81e..94b1d3d40eea0 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/close_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/close_button.dart @@ -4,9 +4,9 @@ class FlowyCloseButton extends StatelessWidget { final VoidCallback? onPressed; const FlowyCloseButton({ - Key? key, + super.key, this.onPressed, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/color_picker.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/color_picker.dart new file mode 100644 index 0000000000000..1f8329e04122a --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/color_picker.dart @@ -0,0 +1,106 @@ +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_svg/flowy_svg.dart'; +import 'package:flutter/material.dart'; + +class FlowyColorOption { + const FlowyColorOption({ + required this.color, + required this.i18n, + required this.id, + }); + + final Color color; + final String i18n; + final String id; +} + +class FlowyColorPicker extends StatelessWidget { + final List colors; + final Color? selected; + final Function(FlowyColorOption option, int index)? onTap; + final double separatorSize; + final double iconSize; + final double itemHeight; + final Border? border; + + const FlowyColorPicker({ + super.key, + required this.colors, + this.selected, + this.onTap, + this.separatorSize = 4, + this.iconSize = 16, + this.itemHeight = 32, + this.border, + }); + + @override + Widget build(BuildContext context) { + return ListView.separated( + shrinkWrap: true, + separatorBuilder: (context, index) { + return VSpace(separatorSize); + }, + itemCount: colors.length, + physics: StyledScrollPhysics(), + itemBuilder: (BuildContext context, int index) { + return _buildColorOption(colors[index], index); + }, + ); + } + + Widget _buildColorOption( + FlowyColorOption option, + int i, + ) { + Widget? checkmark; + if (selected == option.color) { + checkmark = const FlowySvg(FlowySvgData("grid/checkmark")); + } + + final colorIcon = ColorOptionIcon( + color: option.color, + iconSize: iconSize, + ); + + return SizedBox( + height: itemHeight, + child: FlowyButton( + text: FlowyText(option.i18n), + leftIcon: colorIcon, + rightIcon: checkmark, + iconPadding: 10, + onTap: () { + onTap?.call(option, i); + }, + ), + ); + } +} + +class ColorOptionIcon extends StatelessWidget { + const ColorOptionIcon({ + super.key, + required this.color, + this.iconSize = 16.0, + }); + + final Color color; + final double iconSize; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: iconSize, + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: color == Colors.transparent + ? Border.all(color: const Color(0xFFCFD3D9)) + : null, + ), + ), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/container.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/container.dart similarity index 89% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/container.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/container.dart index fc91998c1fe82..32f9809f685da 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/container.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/container.dart @@ -14,7 +14,7 @@ class FlowyContainer extends StatelessWidget { final BoxBorder? border; const FlowyContainer(this.color, - {Key? key, + {super.key, this.borderRadius, this.shadows, this.child, @@ -23,8 +23,7 @@ class FlowyContainer extends StatelessWidget { this.align, this.margin, this.duration, - this.border}) - : super(key: key); + this.border}); @override Widget build(BuildContext context) { @@ -33,7 +32,7 @@ class FlowyContainer extends StatelessWidget { height: height, margin: margin, alignment: align, - duration: duration ?? Durations.medium, + duration: duration ?? FlowyDurations.medium, decoration: BoxDecoration( color: color, borderRadius: borderRadius, diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/decoration.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/decoration.dart similarity index 77% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/decoration.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/decoration.dart index 1a4b96ecb53b9..a3f80cb41c5b9 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/decoration.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/decoration.dart @@ -7,10 +7,12 @@ class FlowyDecoration { double spreadRadius = 0, double blurRadius = 20, Offset offset = Offset.zero, + double borderRadius = 6, + BoxBorder? border, }) { return BoxDecoration( color: boxColor, - borderRadius: const BorderRadius.all(Radius.circular(6)), + borderRadius: BorderRadius.all(Radius.circular(borderRadius)), boxShadow: [ BoxShadow( color: boxShadow, @@ -19,6 +21,7 @@ class FlowyDecoration { offset: offset, ), ], + border: border, ); } } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/divider.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/divider.dart new file mode 100644 index 0000000000000..7f4b630386f27 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/divider.dart @@ -0,0 +1,23 @@ +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +class FlowyDivider extends StatelessWidget { + const FlowyDivider({ + super.key, + this.padding, + }); + + final EdgeInsets? padding; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding ?? EdgeInsets.zero, + child: Divider( + height: 1.0, + thickness: 1.0, + color: AFThemeExtension.of(context).borderColor, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/extension.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/extension.dart new file mode 100644 index 0000000000000..cf2369407815a --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/extension.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +export 'package:styled_widget/styled_widget.dart'; + +class TopBorder extends StatelessWidget { + const TopBorder({ + super.key, + this.width = 1.0, + this.color = Colors.grey, + required this.child, + }); + + final Widget child; + final double width; + final Color color; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide(width: width, color: color), + ), + ), + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart new file mode 100644 index 0000000000000..b9e97860f77fd --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/hover.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; + +typedef HoverBuilder = Widget Function(BuildContext context, bool onHover); + +class FlowyHover extends StatefulWidget { + final HoverStyle? style; + final HoverBuilder? builder; + final Widget? child; + + final bool Function()? isSelected; + final void Function(bool)? onHover; + final MouseCursor? cursor; + + /// Reset the hover state when the parent widget get rebuild. + /// Default to true. + final bool resetHoverOnRebuild; + + /// Determined whether the [builder] should get called when onEnter/onExit + /// happened + /// + /// [FlowyHover] show hover when [MouseRegion]'s onEnter get called + /// [FlowyHover] hide hover when [MouseRegion]'s onExit get called + /// + final bool Function()? buildWhenOnHover; + + const FlowyHover({ + super.key, + this.builder, + this.child, + this.style, + this.isSelected, + this.onHover, + this.cursor, + this.resetHoverOnRebuild = true, + this.buildWhenOnHover, + }); + + @override + State createState() => _FlowyHoverState(); +} + +class _FlowyHoverState extends State { + bool _onHover = false; + + @override + void didUpdateWidget(covariant FlowyHover oldWidget) { + if (widget.resetHoverOnRebuild) { + // Reset the _onHover to false when the parent widget get rebuild. + _onHover = false; + } + + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: widget.cursor != null ? widget.cursor! : SystemMouseCursors.click, + opaque: false, + onHover: (_) => _setOnHover(true), + onEnter: (_) => _setOnHover(true), + onExit: (_) => _setOnHover(false), + child: FlowyHoverContainer( + style: widget.style ?? + HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary), + applyStyle: _onHover || (widget.isSelected?.call() ?? false), + child: widget.child ?? widget.builder!(context, _onHover), + ), + ); + } + + void _setOnHover(bool isHovering) { + if (isHovering == _onHover) return; + + if (widget.buildWhenOnHover?.call() ?? true) { + setState(() => _onHover = isHovering); + if (widget.onHover != null) { + widget.onHover!(isHovering); + } + } + } +} + +class HoverStyle { + final Color borderColor; + final double borderWidth; + final Color? hoverColor; + final Color? foregroundColorOnHover; + final BorderRadius borderRadius; + final EdgeInsets contentMargin; + final Color backgroundColor; + + const HoverStyle({ + this.borderColor = Colors.transparent, + this.borderWidth = 0, + this.borderRadius = const BorderRadius.all(Radius.circular(6)), + this.contentMargin = EdgeInsets.zero, + this.backgroundColor = Colors.transparent, + this.hoverColor, + this.foregroundColorOnHover, + }); + + const HoverStyle.transparent({ + this.borderColor = Colors.transparent, + this.borderWidth = 0, + this.borderRadius = const BorderRadius.all(Radius.circular(6)), + this.contentMargin = EdgeInsets.zero, + this.backgroundColor = Colors.transparent, + this.foregroundColorOnHover, + }) : hoverColor = Colors.transparent; +} + +class FlowyHoverContainer extends StatelessWidget { + final HoverStyle style; + final Widget child; + final bool applyStyle; + + const FlowyHoverContainer({ + super.key, + required this.child, + required this.style, + this.applyStyle = false, + }); + + @override + Widget build(BuildContext context) { + final hoverBorder = Border.all( + color: style.borderColor, + width: style.borderWidth, + ); + + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final iconTheme = theme.iconTheme; + // override text's theme with foregroundColorOnHover when it is hovered + final hoverTheme = theme.copyWith( + textTheme: textTheme.copyWith( + bodyMedium: textTheme.bodyMedium?.copyWith( + color: style.foregroundColorOnHover ?? theme.colorScheme.onSurface, + ), + ), + iconTheme: iconTheme.copyWith( + color: style.foregroundColorOnHover ?? theme.colorScheme.onSurface, + ), + ); + + return Container( + margin: style.contentMargin, + decoration: BoxDecoration( + border: hoverBorder, + color: applyStyle + ? style.hoverColor ?? Theme.of(context).colorScheme.secondary + : style.backgroundColor, + borderRadius: style.borderRadius, + ), + child: Theme( + data: applyStyle ? hoverTheme : Theme.of(context), + child: child, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart new file mode 100644 index 0000000000000..cb3605fe7e170 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart @@ -0,0 +1,120 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_svg/flowy_svg.dart'; + +class FlowyIconButton extends StatelessWidget { + final double width; + final double? height; + final Widget icon; + final VoidCallback? onPressed; + final Color? fillColor; + final Color? hoverColor; + final Color? iconColorOnHover; + final EdgeInsets iconPadding; + final BorderRadius? radius; + final String? tooltipText; + final InlineSpan? richTooltipText; + final bool preferBelow; + final BoxDecoration? decoration; + final bool? isSelected; + + const FlowyIconButton({ + super.key, + this.width = 30, + this.height, + this.onPressed, + this.fillColor = Colors.transparent, + this.hoverColor, + this.iconColorOnHover, + this.iconPadding = EdgeInsets.zero, + this.radius, + this.decoration, + this.tooltipText, + this.richTooltipText, + this.preferBelow = true, + this.isSelected, + required this.icon, + }) : assert((richTooltipText != null && tooltipText == null) || + (richTooltipText == null && tooltipText != null) || + (richTooltipText == null && tooltipText == null)); + + @override + Widget build(BuildContext context) { + Widget child = icon; + final size = Size(width, height ?? width); + + final tooltipMessage = + tooltipText == null && richTooltipText == null ? '' : tooltipText; + + assert(size.width > iconPadding.horizontal); + assert(size.height > iconPadding.vertical); + + child = Padding( + padding: iconPadding, + child: Center(child: child), + ); + + if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { + child = FlowyHover( + isSelected: isSelected != null ? () => isSelected! : null, + style: HoverStyle( + hoverColor: hoverColor, + foregroundColorOnHover: + iconColorOnHover ?? Theme.of(context).iconTheme.color, + borderRadius: radius ?? Corners.s6Border + //Do not set background here. Use [fillColor] instead. + ), + resetHoverOnRebuild: false, + child: child, + ); + } + + return Container( + constraints: BoxConstraints.tightFor( + width: size.width, + height: size.height, + ), + decoration: decoration, + child: FlowyTooltip( + preferBelow: preferBelow, + message: tooltipMessage, + richMessage: richTooltipText, + child: RawMaterialButton( + clipBehavior: Clip.antiAlias, + hoverElevation: 0, + highlightElevation: 0, + shape: + RoundedRectangleBorder(borderRadius: radius ?? Corners.s6Border), + fillColor: fillColor, + hoverColor: Colors.transparent, + focusColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + elevation: 0, + onPressed: onPressed, + child: child, + ), + ), + ); + } +} + +class FlowyDropdownButton extends StatelessWidget { + const FlowyDropdownButton({super.key, this.onPressed}); + + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return FlowyIconButton( + width: 16, + onPressed: onPressed, + icon: const FlowySvg(FlowySvgData("home/drop_down_show")), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/image_icon.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/image_icon.dart similarity index 78% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/image_icon.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/image_icon.dart index 6c3810fb7b29a..f0c979b5cf134 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/image_icon.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/image_icon.dart @@ -6,8 +6,7 @@ class FlowyImageIcon extends StatelessWidget { final Color? color; final double? size; - const FlowyImageIcon(this.image, {Key? key, this.color, this.size}) - : super(key: key); + const FlowyImageIcon(this.image, {super.key, this.color, this.size}); @override Widget build(BuildContext context) { diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/primary_rounded_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/primary_rounded_button.dart new file mode 100644 index 0000000000000..c227d8b8efc37 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/primary_rounded_button.dart @@ -0,0 +1,105 @@ +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class PrimaryRoundedButton extends StatelessWidget { + const PrimaryRoundedButton({ + super.key, + required this.text, + this.fontSize, + this.fontWeight, + this.color, + this.radius, + this.margin, + this.onTap, + this.hoverColor, + this.backgroundColor, + this.useIntrinsicWidth = true, + this.lineHeight, + this.figmaLineHeight, + this.leftIcon, + this.textColor, + }); + + final String text; + final double? fontSize; + final FontWeight? fontWeight; + final Color? color; + final double? radius; + final EdgeInsets? margin; + final VoidCallback? onTap; + final Color? hoverColor; + final Color? backgroundColor; + final bool useIntrinsicWidth; + final double? lineHeight; + final double? figmaLineHeight; + final Widget? leftIcon; + final Color? textColor; + + @override + Widget build(BuildContext context) { + return FlowyButton( + useIntrinsicWidth: useIntrinsicWidth, + leftIcon: leftIcon, + text: FlowyText( + text, + fontSize: fontSize ?? 14.0, + fontWeight: fontWeight ?? FontWeight.w500, + lineHeight: lineHeight ?? 1.0, + figmaLineHeight: figmaLineHeight, + color: textColor ?? Theme.of(context).colorScheme.onPrimary, + textAlign: TextAlign.center, + ), + margin: margin ?? const EdgeInsets.symmetric(horizontal: 14.0), + backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.primary, + hoverColor: + hoverColor ?? Theme.of(context).colorScheme.primary.withOpacity(0.9), + radius: BorderRadius.circular(radius ?? 10.0), + onTap: onTap, + ); + } +} + +class OutlinedRoundedButton extends StatelessWidget { + const OutlinedRoundedButton({ + super.key, + required this.text, + this.onTap, + this.margin, + this.radius, + }); + + final String text; + final VoidCallback? onTap; + final EdgeInsets? margin; + final double? radius; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: Theme.of(context).brightness == Brightness.light + ? const BorderSide(color: Color(0x1E14171B)) + : const BorderSide(color: Colors.white10), + borderRadius: BorderRadius.circular(radius ?? 8), + ), + ), + child: FlowyButton( + useIntrinsicWidth: true, + margin: margin ?? + const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 9.0, + ), + radius: BorderRadius.circular(radius ?? 8), + text: FlowyText.regular( + text, + lineHeight: 1.0, + textAlign: TextAlign.center, + ), + onTap: onTap, + ), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/progress_indicator.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/progress_indicator.dart similarity index 91% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/progress_indicator.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/progress_indicator.dart index 8eb2a120471b4..ccf6608dd4a98 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/progress_indicator.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/progress_indicator.dart @@ -13,7 +13,7 @@ List _kDefaultRainbowColors = const [ // CircularProgressIndicator() class FlowyProgressIndicator extends StatelessWidget { - const FlowyProgressIndicator({Key? key}) : super(key: key); + const FlowyProgressIndicator({super.key}); @override Widget build(BuildContext context) { diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrollbar.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrollbar.dart new file mode 100644 index 0000000000000..01111293ecf62 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrollbar.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class FlowyScrollbar extends StatefulWidget { + const FlowyScrollbar({ + super.key, + this.controller, + required this.child, + }); + + final ScrollController? controller; + final Widget child; + + @override + State createState() => _FlowyScrollbarState(); +} + +class _FlowyScrollbarState extends State { + final ValueNotifier isHovered = ValueNotifier(false); + + @override + void dispose() { + isHovered.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => isHovered.value = true, + onExit: (_) => isHovered.value = false, + child: ValueListenableBuilder( + valueListenable: isHovered, + builder: (context, isHovered, child) { + return Scrollbar( + thumbVisibility: isHovered, + // the radius should be fixed to 12 + radius: const Radius.circular(12), + controller: widget.controller, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + scrollbars: false, + ), + child: child!, + ), + ); + }, + child: widget.child, + ), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_list.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_list.dart similarity index 77% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_list.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_list.dart index 196d0e1848c05..92381cff444ec 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_list.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_list.dart @@ -18,7 +18,7 @@ class StyledListView extends StatefulWidget { final IndexedWidgetBuilder itemBuilder; StyledListView({ - Key? key, + super.key, required this.itemBuilder, required this.itemCount, this.itemExtent, @@ -26,7 +26,7 @@ class StyledListView extends StatefulWidget { this.padding, this.barSize, this.scrollbarPadding, - }) : super(key: key) { + }) { assert(itemExtent != 0, 'Item extent should never be 0, null is ok.'); } @@ -36,13 +36,7 @@ class StyledListView extends StatefulWidget { /// State is public so this can easily be controlled externally class StyledListViewState extends State { - late ScrollController scrollController; - - @override - void initState() { - scrollController = ScrollController(); - super.initState(); - } + final scrollController = ScrollController(); @override void dispose() { @@ -50,15 +44,6 @@ class StyledListViewState extends State { super.dispose(); } - @override - void didUpdateWidget(StyledListView oldWidget) { - if (oldWidget.itemCount != widget.itemCount || - oldWidget.itemExtent != widget.itemExtent) { - setState(() {}); - } - super.didUpdateWidget(oldWidget); - } - @override Widget build(BuildContext context) { final contentSize = (widget.itemCount ?? 0.0) * (widget.itemExtent ?? 00.0); @@ -66,7 +51,7 @@ class StyledListViewState extends State { contentSize: contentSize, axis: widget.axis, controller: scrollController, - barSize: widget.barSize ?? 12, + barSize: widget.barSize ?? 8, scrollbarPadding: widget.scrollbarPadding, child: ListView.builder( padding: widget.padding, @@ -75,7 +60,7 @@ class StyledListViewState extends State { controller: scrollController, itemExtent: widget.itemExtent, itemCount: widget.itemCount, - itemBuilder: (c, i) => widget.itemBuilder(c, i), + itemBuilder: widget.itemBuilder, ), ); return listContent; diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart new file mode 100644 index 0000000000000..ece1801098b43 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart @@ -0,0 +1,285 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:async/async.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/widget/mouse_hover_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class StyledScrollbar extends StatefulWidget { + const StyledScrollbar({ + super.key, + this.size, + required this.axis, + required this.controller, + this.onDrag, + this.contentSize, + this.showTrack = false, + this.autoHideScrollbar = true, + this.handleColor, + this.trackColor, + }); + + final double? size; + final Axis axis; + final ScrollController controller; + final Function(double)? onDrag; + final bool showTrack; + final bool autoHideScrollbar; + final Color? handleColor; + final Color? trackColor; + + // ignore: todo + // TODO: Remove contentHeight if we can fix this issue + // https://stackoverflow.com/questions/60855712/flutter-how-to-force-scrollcontroller-to-recalculate-position-maxextents + final double? contentSize; + + @override + ScrollbarState createState() => ScrollbarState(); +} + +class ScrollbarState extends State { + double _viewExtent = 100; + CancelableOperation? _hideScrollbarOperation; + bool hideHandler = false; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onScrollChanged); + widget.controller.position.isScrollingNotifier + .addListener(_hideScrollbarInTime); + } + + @override + void dispose() { + if (widget.controller.hasClients) { + widget.controller.removeListener(_onScrollChanged); + widget.controller.position.isScrollingNotifier + .removeListener(_hideScrollbarInTime); + } + super.dispose(); + } + + void _onScrollChanged() => setState(() {}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (_, BoxConstraints constraints) { + double maxExtent; + final double? contentSize = widget.contentSize; + + switch (widget.axis) { + case Axis.vertical: + // Use supplied contentSize if we have it, otherwise just fallback to maxScrollExtents + if (contentSize != null && contentSize > 0) { + maxExtent = contentSize - constraints.maxHeight; + } else { + maxExtent = widget.controller.position.maxScrollExtent; + } + + _viewExtent = constraints.maxHeight; + + break; + case Axis.horizontal: + // Use supplied contentSize if we have it, otherwise just fallback to maxScrollExtents + if (contentSize != null && contentSize > 0) { + maxExtent = contentSize - constraints.maxWidth; + } else { + maxExtent = widget.controller.position.maxScrollExtent; + } + _viewExtent = constraints.maxWidth; + + break; + } + + final contentExtent = maxExtent + _viewExtent; + // Calculate the alignment for the handle, this is a value between 0 and 1, + // it automatically takes the handle size into acct + // ignore: omit_local_variable_types + double handleAlignment = + maxExtent == 0 ? 0 : widget.controller.offset / maxExtent; + + // Convert handle alignment from [0, 1] to [-1, 1] + handleAlignment *= 2.0; + handleAlignment -= 1.0; + + // Calculate handleSize by comparing the total content size to our viewport + double handleExtent = _viewExtent; + if (contentExtent > _viewExtent) { + // Make sure handle is never small than the minSize + handleExtent = max(60, _viewExtent * _viewExtent / contentExtent); + } + + // Hide the handle if content is < the viewExtent + var showHandle = hideHandler + ? false + : contentExtent > _viewExtent && contentExtent > 0; + + // Track color + var trackColor = widget.trackColor ?? + (Theme.of(context).brightness == Brightness.dark + ? AFThemeExtension.of(context).lightGreyHover + : AFThemeExtension.of(context).greyHover); + + // Layout the stack, it just contains a child, and + return Stack( + children: [ + /// TRACK, thin strip, aligned along the end of the parent + if (widget.showTrack) + Align( + alignment: const Alignment(1, 1), + child: Container( + color: trackColor, + width: widget.axis == Axis.vertical + ? widget.size + : double.infinity, + height: widget.axis == Axis.horizontal + ? widget.size + : double.infinity, + ), + ), + + /// HANDLE - Clickable shape that changes scrollController when dragged + Align( + // Use calculated alignment to position handle from -1 to 1, let Alignment do the rest of the work + alignment: Alignment( + widget.axis == Axis.vertical ? 1 : handleAlignment, + widget.axis == Axis.horizontal ? 1 : handleAlignment, + ), + child: GestureDetector( + onVerticalDragUpdate: _handleVerticalDrag, + onHorizontalDragUpdate: _handleHorizontalDrag, + // HANDLE SHAPE + child: MouseHoverBuilder( + builder: (_, isHovered) { + final handleColor = + Theme.of(context).scrollbarTheme.thumbColor?.resolve( + isHovered ? {WidgetState.dragged} : {}, + ); + return Container( + width: widget.axis == Axis.vertical + ? widget.size + : handleExtent, + height: widget.axis == Axis.horizontal + ? widget.size + : handleExtent, + decoration: BoxDecoration( + color: handleColor, + borderRadius: Corners.s3Border, + ), + ); + }, + ), + ), + ) + ], + ).opacity(showHandle ? 1.0 : 0.0, animate: true); + }, + ); + } + + void _hideScrollbarInTime() { + if (!mounted || !widget.autoHideScrollbar) return; + + _hideScrollbarOperation?.cancel(); + + if (!widget.controller.position.isScrollingNotifier.value) { + _hideScrollbarOperation = CancelableOperation.fromFuture( + Future.delayed(const Duration(seconds: 2)), + ).then((_) { + hideHandler = true; + if (mounted) { + setState(() {}); + } + }); + } else { + hideHandler = false; + } + } + + void _handleHorizontalDrag(DragUpdateDetails details) { + var pos = widget.controller.offset; + var pxRatio = (widget.controller.position.maxScrollExtent + _viewExtent) / + _viewExtent; + widget.controller.jumpTo((pos + details.delta.dx * pxRatio) + .clamp(0.0, widget.controller.position.maxScrollExtent)); + widget.onDrag?.call(details.delta.dx); + } + + void _handleVerticalDrag(DragUpdateDetails details) { + var pos = widget.controller.offset; + var pxRatio = (widget.controller.position.maxScrollExtent + _viewExtent) / + _viewExtent; + widget.controller.jumpTo((pos + details.delta.dy * pxRatio) + .clamp(0.0, widget.controller.position.maxScrollExtent)); + widget.onDrag?.call(details.delta.dy); + } +} + +class ScrollbarListStack extends StatelessWidget { + const ScrollbarListStack({ + super.key, + required this.barSize, + required this.axis, + required this.child, + required this.controller, + this.contentSize, + this.scrollbarPadding, + this.handleColor, + this.autoHideScrollbar = true, + this.trackColor, + this.showTrack = false, + this.includeInsets = true, + }); + + final double barSize; + final Axis axis; + final Widget child; + final ScrollController controller; + final double? contentSize; + final EdgeInsets? scrollbarPadding; + final Color? handleColor; + final Color? trackColor; + final bool showTrack; + final bool autoHideScrollbar; + final bool includeInsets; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + /// Wrap with a bit of padding on the right or bottom to make room for the scrollbar + Padding( + padding: !includeInsets + ? EdgeInsets.zero + : EdgeInsets.only( + right: axis == Axis.vertical ? barSize + Insets.m : 0, + bottom: axis == Axis.horizontal ? barSize + Insets.m : 0, + ), + child: child, + ), + + /// Display the scrollbar + Padding( + padding: scrollbarPadding ?? EdgeInsets.zero, + child: StyledScrollbar( + size: barSize, + axis: axis, + controller: controller, + contentSize: contentSize, + trackColor: trackColor, + handleColor: handleColor, + autoHideScrollbar: autoHideScrollbar, + showTrack: showTrack, + ), + ) + // The animate will be used by the children that are using styled_widget. + .animate(const Duration(milliseconds: 250), Curves.easeOut), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart new file mode 100644 index 0000000000000..b1b7c8afc7001 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; + +import 'styled_list.dart'; +import 'styled_scroll_bar.dart'; + +class StyledSingleChildScrollView extends StatefulWidget { + const StyledSingleChildScrollView({ + super.key, + required this.child, + this.contentSize, + this.axis = Axis.vertical, + this.trackColor, + this.handleColor, + this.controller, + this.scrollbarPadding, + this.barSize = 8, + this.autoHideScrollbar = true, + this.includeInsets = true, + }); + + final Widget? child; + final double? contentSize; + final Axis axis; + final Color? trackColor; + final Color? handleColor; + final ScrollController? controller; + final EdgeInsets? scrollbarPadding; + final double barSize; + final bool autoHideScrollbar; + final bool includeInsets; + + @override + State createState() => + StyledSingleChildScrollViewState(); +} + +class StyledSingleChildScrollViewState + extends State { + late final ScrollController scrollController = + widget.controller ?? ScrollController(); + + @override + void dispose() { + if (widget.controller == null) { + scrollController.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScrollbarListStack( + autoHideScrollbar: widget.autoHideScrollbar, + contentSize: widget.contentSize, + axis: widget.axis, + controller: scrollController, + scrollbarPadding: widget.scrollbarPadding, + barSize: widget.barSize, + trackColor: widget.trackColor, + handleColor: widget.handleColor, + includeInsets: widget.includeInsets, + child: SingleChildScrollView( + scrollDirection: widget.axis, + physics: StyledScrollPhysics(), + controller: scrollController, + child: widget.child, + ), + ); + } +} + +class StyledCustomScrollView extends StatefulWidget { + const StyledCustomScrollView({ + super.key, + this.axis = Axis.vertical, + this.trackColor, + this.handleColor, + this.verticalController, + this.slivers = const [], + this.barSize = 8, + }); + + final Axis axis; + final Color? trackColor; + final Color? handleColor; + final ScrollController? verticalController; + final List slivers; + final double barSize; + + @override + StyledCustomScrollViewState createState() => StyledCustomScrollViewState(); +} + +class StyledCustomScrollViewState extends State { + late final ScrollController controller = + widget.verticalController ?? ScrollController(); + + @override + Widget build(BuildContext context) { + var child = ScrollConfiguration( + behavior: const ScrollBehavior().copyWith(scrollbars: false), + child: CustomScrollView( + scrollDirection: widget.axis, + physics: StyledScrollPhysics(), + controller: controller, + slivers: widget.slivers, + ), + ); + + return ScrollbarListStack( + axis: widget.axis, + controller: controller, + barSize: widget.barSize, + trackColor: widget.trackColor, + handleColor: widget.handleColor, + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/snap_bar.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/snap_bar.dart new file mode 100644 index 0000000000000..c889496f1710b --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/snap_bar.dart @@ -0,0 +1,27 @@ +import 'dart:io'; + +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +void showSnapBar(BuildContext context, String title, {VoidCallback? onClosed}) { + ScaffoldMessenger.of(context).clearSnackBars(); + + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + duration: const Duration(milliseconds: 8000), + content: FlowyText( + title, + maxLines: 2, + fontSize: + (Platform.isLinux || Platform.isWindows || Platform.isMacOS) + ? 14 + : 12, + ), + ), + ) + .closed + .then((value) => onClosed?.call()); +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart new file mode 100644 index 0000000000000..76e9eefbc2d3b --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text.dart @@ -0,0 +1,260 @@ +import 'dart:io'; + +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class FlowyText extends StatelessWidget { + final String text; + final TextOverflow? overflow; + final double? fontSize; + final FontWeight? fontWeight; + final TextAlign? textAlign; + final int? maxLines; + final Color? color; + final TextDecoration? decoration; + final Color? decorationColor; + final double? decorationThickness; + final bool selectable; + final String? fontFamily; + final List? fallbackFontFamily; + final bool withTooltip; + final StrutStyle? strutStyle; + final bool isEmoji; + + /// this is used to control the line height in Flutter. + final double? lineHeight; + + /// this is used to control the line height from Figma. + final double? figmaLineHeight; + + final bool optimizeEmojiAlign; + + const FlowyText( + this.text, { + super.key, + this.overflow = TextOverflow.clip, + this.fontSize, + this.fontWeight, + this.textAlign, + this.color, + this.maxLines = 1, + this.decoration, + this.decorationColor, + this.selectable = false, + this.fontFamily, + this.fallbackFontFamily, + // // https://api.flutter.dev/flutter/painting/TextStyle/height.html + this.lineHeight, + this.figmaLineHeight, + this.withTooltip = false, + this.isEmoji = false, + this.strutStyle, + this.optimizeEmojiAlign = false, + this.decorationThickness, + }); + + FlowyText.small( + this.text, { + super.key, + this.overflow, + this.color, + this.textAlign, + this.maxLines = 1, + this.decoration, + this.decorationColor, + this.selectable = false, + this.fontFamily, + this.fallbackFontFamily, + this.lineHeight, + this.withTooltip = false, + this.isEmoji = false, + this.strutStyle, + this.figmaLineHeight, + this.optimizeEmojiAlign = false, + this.decorationThickness, + }) : fontWeight = FontWeight.w400, + fontSize = (Platform.isIOS || Platform.isAndroid) ? 14 : 12; + + const FlowyText.regular( + this.text, { + super.key, + this.fontSize, + this.overflow, + this.color, + this.textAlign, + this.maxLines = 1, + this.decoration, + this.decorationColor, + this.selectable = false, + this.fontFamily, + this.fallbackFontFamily, + this.lineHeight, + this.withTooltip = false, + this.isEmoji = false, + this.strutStyle, + this.figmaLineHeight, + this.optimizeEmojiAlign = false, + this.decorationThickness, + }) : fontWeight = FontWeight.w400; + + const FlowyText.medium( + this.text, { + super.key, + this.fontSize, + this.overflow, + this.color, + this.textAlign, + this.maxLines = 1, + this.decoration, + this.decorationColor, + this.selectable = false, + this.fontFamily, + this.fallbackFontFamily, + this.lineHeight, + this.withTooltip = false, + this.isEmoji = false, + this.strutStyle, + this.figmaLineHeight, + this.optimizeEmojiAlign = false, + this.decorationThickness, + }) : fontWeight = FontWeight.w500; + + const FlowyText.semibold( + this.text, { + super.key, + this.fontSize, + this.overflow, + this.color, + this.textAlign, + this.maxLines = 1, + this.decoration, + this.decorationColor, + this.selectable = false, + this.fontFamily, + this.fallbackFontFamily, + this.lineHeight, + this.withTooltip = false, + this.isEmoji = false, + this.strutStyle, + this.figmaLineHeight, + this.optimizeEmojiAlign = false, + this.decorationThickness, + }) : fontWeight = FontWeight.w600; + + // Some emojis are not supported on Linux and Android, fallback to noto color emoji + const FlowyText.emoji( + this.text, { + super.key, + this.fontSize, + this.overflow, + this.color, + this.textAlign = TextAlign.center, + this.maxLines = 1, + this.decoration, + this.decorationColor, + this.selectable = false, + this.lineHeight, + this.withTooltip = false, + this.strutStyle = const StrutStyle(forceStrutHeight: true), + this.isEmoji = true, + this.fontFamily, + this.figmaLineHeight, + this.optimizeEmojiAlign = false, + this.decorationThickness, + }) : fontWeight = FontWeight.w400, + fallbackFontFamily = null; + + @override + Widget build(BuildContext context) { + Widget child; + + var fontFamily = this.fontFamily; + var fallbackFontFamily = this.fallbackFontFamily; + var fontSize = + this.fontSize ?? Theme.of(context).textTheme.bodyMedium!.fontSize!; + if (isEmoji && _useNotoColorEmoji) { + fontFamily = _loadEmojiFontFamilyIfNeeded(); + if (fontFamily != null && fallbackFontFamily == null) { + fallbackFontFamily = [fontFamily]; + } + } + + double? lineHeight; + // use figma line height as first priority + if (figmaLineHeight != null) { + lineHeight = figmaLineHeight! / fontSize; + } else if (this.lineHeight != null) { + lineHeight = this.lineHeight!; + } + + if (isEmoji && (_useNotoColorEmoji || Platform.isWindows)) { + const scaleFactor = 0.9; + fontSize *= scaleFactor; + if (lineHeight != null) { + lineHeight /= scaleFactor; + } + } + + final textStyle = Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: fontSize, + fontWeight: fontWeight, + color: color, + decoration: decoration, + decorationColor: decorationColor, + decorationThickness: decorationThickness, + fontFamily: fontFamily, + fontFamilyFallback: fallbackFontFamily, + height: lineHeight, + leadingDistribution: isEmoji && optimizeEmojiAlign + ? TextLeadingDistribution.even + : null, + ); + + if (selectable) { + child = IntrinsicHeight( + child: SelectableText( + text, + maxLines: maxLines, + textAlign: textAlign, + style: textStyle, + ), + ); + } else { + child = Text( + text, + maxLines: maxLines, + textAlign: textAlign, + overflow: overflow ?? TextOverflow.clip, + style: textStyle, + strutStyle: !isEmoji || (isEmoji && optimizeEmojiAlign) + ? StrutStyle.fromTextStyle( + textStyle, + forceStrutHeight: true, + leadingDistribution: TextLeadingDistribution.even, + height: lineHeight, + ) + : null, + ); + } + + if (withTooltip) { + child = FlowyTooltip( + message: text, + child: child, + ); + } + + return child; + } + + String? _loadEmojiFontFamilyIfNeeded() { + if (_useNotoColorEmoji) { + return GoogleFonts.notoColorEmoji().fontFamily; + } + + return null; + } + + bool get _useNotoColorEmoji => Platform.isLinux; +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart new file mode 100644 index 0000000000000..c4f72f226170f --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_field.dart @@ -0,0 +1,257 @@ +import 'dart:async'; + +import 'package:flowy_infra/size.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class FlowyTextField extends StatefulWidget { + final String? hintText; + final String? text; + final TextStyle? textStyle; + final void Function(String)? onChanged; + final void Function()? onEditingComplete; + final void Function(String)? onSubmitted; + final void Function()? onCanceled; + final FocusNode? focusNode; + final bool autoFocus; + final int? maxLength; + final TextEditingController? controller; + final bool autoClearWhenDone; + final bool submitOnLeave; + final Duration? debounceDuration; + final String? errorText; + final Widget? error; + final int? maxLines; + final bool showCounter; + final Widget? prefixIcon; + final Widget? suffixIcon; + final BoxConstraints? prefixIconConstraints; + final BoxConstraints? suffixIconConstraints; + final BoxConstraints? hintTextConstraints; + final TextStyle? hintStyle; + final InputDecoration? decoration; + final TextAlignVertical? textAlignVertical; + final TextInputAction? textInputAction; + final TextInputType? keyboardType; + final List? inputFormatters; + final bool obscureText; + final bool isDense; + final bool readOnly; + final Color? enableBorderColor; + final BorderRadius? borderRadius; + final void Function()? onTap; + final Function(PointerDownEvent)? onTapOutside; + + const FlowyTextField({ + super.key, + this.hintText = "", + this.text, + this.textStyle, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onCanceled, + this.focusNode, + this.autoFocus = true, + this.maxLength, + this.controller, + this.autoClearWhenDone = false, + this.submitOnLeave = false, + this.debounceDuration, + this.errorText, + this.error, + this.maxLines = 1, + this.showCounter = true, + this.prefixIcon, + this.suffixIcon, + this.prefixIconConstraints, + this.suffixIconConstraints, + this.hintTextConstraints, + this.hintStyle, + this.decoration, + this.textAlignVertical, + this.textInputAction, + this.keyboardType = TextInputType.multiline, + this.inputFormatters, + this.obscureText = false, + this.isDense = true, + this.readOnly = false, + this.enableBorderColor, + this.borderRadius, + this.onTap, + this.onTapOutside, + }); + + @override + State createState() => FlowyTextFieldState(); +} + +class FlowyTextFieldState extends State { + late FocusNode focusNode; + late TextEditingController controller; + Timer? _debounceOnChanged; + + @override + void initState() { + super.initState(); + + focusNode = widget.focusNode ?? FocusNode(); + focusNode.addListener(notifyDidEndEditing); + + controller = widget.controller ?? TextEditingController(); + + if (widget.text != null) { + controller.text = widget.text!; + } + + if (widget.autoFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + if (widget.controller == null) { + controller.selection = TextSelection.fromPosition( + TextPosition(offset: controller.text.length), + ); + } + }); + } + } + + @override + void dispose() { + focusNode.removeListener(notifyDidEndEditing); + if (widget.focusNode == null) { + focusNode.dispose(); + } + if (widget.controller == null) { + controller.dispose(); + } + _debounceOnChanged?.cancel(); + super.dispose(); + } + + void _debounceOnChangedText(Duration duration, String text) { + _debounceOnChanged?.cancel(); + _debounceOnChanged = Timer(duration, () async { + if (mounted) { + _onChanged(text); + } + }); + } + + void _onChanged(String text) { + widget.onChanged?.call(text); + setState(() {}); + } + + void _onSubmitted(String text) { + widget.onSubmitted?.call(text); + if (widget.autoClearWhenDone) { + controller.clear(); + } + } + + @override + Widget build(BuildContext context) { + return TextField( + readOnly: widget.readOnly, + controller: controller, + focusNode: focusNode, + onChanged: (text) { + if (widget.debounceDuration != null) { + _debounceOnChangedText(widget.debounceDuration!, text); + } else { + _onChanged(text); + } + }, + onSubmitted: _onSubmitted, + onEditingComplete: widget.onEditingComplete, + onTap: widget.onTap, + onTapOutside: widget.onTapOutside, + minLines: 1, + maxLines: widget.maxLines, + maxLength: widget.maxLength, + maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds, + style: widget.textStyle ?? Theme.of(context).textTheme.bodySmall, + textAlignVertical: widget.textAlignVertical ?? TextAlignVertical.center, + keyboardType: widget.keyboardType, + inputFormatters: widget.inputFormatters, + obscureText: widget.obscureText, + decoration: widget.decoration ?? + InputDecoration( + constraints: widget.hintTextConstraints ?? + BoxConstraints( + maxHeight: widget.errorText?.isEmpty ?? true ? 32 : 58, + ), + contentPadding: EdgeInsets.symmetric( + horizontal: widget.isDense ? 12 : 18, + vertical: + (widget.maxLines == null || widget.maxLines! > 1) ? 12 : 0, + ), + enabledBorder: OutlineInputBorder( + borderRadius: widget.borderRadius ?? Corners.s8Border, + borderSide: BorderSide( + color: widget.enableBorderColor ?? + Theme.of(context).colorScheme.outline, + ), + ), + isDense: false, + hintText: widget.hintText, + errorText: widget.errorText, + error: widget.error, + errorStyle: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(color: Theme.of(context).colorScheme.error), + hintStyle: widget.hintStyle ?? + Theme.of(context) + .textTheme + .bodySmall! + .copyWith(color: Theme.of(context).hintColor), + suffixText: widget.showCounter ? _suffixText() : "", + counterText: "", + focusedBorder: OutlineInputBorder( + borderRadius: widget.borderRadius ?? Corners.s8Border, + borderSide: BorderSide( + color: widget.readOnly + ? widget.enableBorderColor ?? + Theme.of(context).colorScheme.outline + : Theme.of(context).colorScheme.primary, + ), + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + borderRadius: widget.borderRadius ?? Corners.s8Border, + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + borderRadius: widget.borderRadius ?? Corners.s8Border, + ), + prefixIcon: widget.prefixIcon, + suffixIcon: widget.suffixIcon, + prefixIconConstraints: widget.prefixIconConstraints, + suffixIconConstraints: widget.suffixIconConstraints, + ), + ); + } + + void notifyDidEndEditing() { + if (!focusNode.hasFocus) { + if (controller.text.isNotEmpty && widget.submitOnLeave) { + widget.onSubmitted?.call(controller.text); + } else { + widget.onCanceled?.call(); + } + } + } + + String? _suffixText() { + if (widget.maxLength != null) { + return ' ${controller.text.length}/${widget.maxLength}'; + } + return null; + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart new file mode 100644 index 0000000000000..bc391daf9ef72 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/text_input.dart @@ -0,0 +1,389 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flowy_infra/size.dart'; + +class FlowyFormTextInput extends StatelessWidget { + static EdgeInsets kDefaultTextInputPadding = const EdgeInsets.only(bottom: 2); + + final String? label; + final bool? autoFocus; + final String? initialValue; + final String? hintText; + final EdgeInsets? contentPadding; + final TextStyle? textStyle; + final TextAlign textAlign; + final int? maxLines; + final int? maxLength; + final bool showCounter; + final TextEditingController? controller; + final TextCapitalization? capitalization; + final Function(String)? onChanged; + final Function()? onEditingComplete; + final Function(bool)? onFocusChanged; + final Function(FocusNode)? onFocusCreated; + + const FlowyFormTextInput({ + super.key, + this.label, + this.autoFocus, + this.initialValue, + this.onChanged, + this.onEditingComplete, + this.hintText, + this.onFocusChanged, + this.onFocusCreated, + this.controller, + this.contentPadding, + this.capitalization, + this.textStyle, + this.textAlign = TextAlign.center, + this.maxLines, + this.maxLength, + this.showCounter = true, + }); + + @override + Widget build(BuildContext context) { + return StyledSearchTextInput( + capitalization: capitalization, + label: label, + autoFocus: autoFocus, + initialValue: initialValue, + onChanged: onChanged, + onFocusCreated: onFocusCreated, + style: textStyle ?? Theme.of(context).textTheme.bodyMedium, + textAlign: textAlign, + onEditingComplete: onEditingComplete, + onFocusChanged: onFocusChanged, + controller: controller, + maxLines: maxLines, + maxLength: maxLength, + showCounter: showCounter, + contentPadding: contentPadding ?? kDefaultTextInputPadding, + hintText: hintText, + hintStyle: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Theme.of(context).hintColor.withOpacity(0.7)), + isDense: true, + inputBorder: const ThinUnderlineBorder( + borderSide: BorderSide(width: 5, color: Colors.red), + ), + ); + } +} + +class StyledSearchTextInput extends StatefulWidget { + final String? label; + final TextStyle? style; + final TextAlign textAlign; + final EdgeInsets? contentPadding; + final bool? autoFocus; + final bool? obscureText; + final IconData? icon; + final String? initialValue; + final int? maxLines; + final int? maxLength; + final bool showCounter; + final TextEditingController? controller; + final TextCapitalization? capitalization; + final TextInputType? type; + final bool? enabled; + final bool? autoValidate; + final bool? enableSuggestions; + final bool? autoCorrect; + final bool isDense; + final String? errorText; + final String? hintText; + final TextStyle? hintStyle; + final Widget? prefixIcon; + final Widget? suffixIcon; + final InputDecoration? inputDecoration; + final InputBorder? inputBorder; + + final Function(String)? onChanged; + final Function()? onEditingComplete; + final Function()? onEditingCancel; + final Function(bool)? onFocusChanged; + final Function(FocusNode)? onFocusCreated; + final Function(String)? onFieldSubmitted; + final Function(String?)? onSaved; + final VoidCallback? onTap; + + const StyledSearchTextInput({ + super.key, + this.label, + this.autoFocus = false, + this.obscureText = false, + this.type = TextInputType.text, + this.textAlign = TextAlign.center, + this.icon, + this.initialValue = '', + this.controller, + this.enabled, + this.autoValidate = false, + this.enableSuggestions = true, + this.autoCorrect = true, + this.isDense = false, + this.errorText, + this.style, + this.contentPadding, + this.prefixIcon, + this.suffixIcon, + this.inputDecoration, + this.onChanged, + this.onEditingComplete, + this.onEditingCancel, + this.onFocusChanged, + this.onFocusCreated, + this.onFieldSubmitted, + this.onSaved, + this.onTap, + this.hintText, + this.hintStyle, + this.capitalization, + this.maxLines, + this.maxLength, + this.showCounter = false, + this.inputBorder, + }); + + @override + StyledSearchTextInputState createState() => StyledSearchTextInputState(); +} + +class StyledSearchTextInputState extends State { + late TextEditingController _controller; + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _controller = + widget.controller ?? TextEditingController(text: widget.initialValue); + _focusNode = FocusNode( + debugLabel: widget.label, + canRequestFocus: true, + onKeyEvent: (node, event) { + if (event.logicalKey == LogicalKeyboardKey.escape) { + widget.onEditingCancel?.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + ); + // Listen for focus out events + _focusNode.addListener(_onFocusChanged); + widget.onFocusCreated?.call(_focusNode); + if (widget.autoFocus ?? false) { + scheduleMicrotask(() => _focusNode.requestFocus()); + } + } + + void _onFocusChanged() => widget.onFocusChanged?.call(_focusNode.hasFocus); + + @override + void dispose() { + if (widget.controller == null) { + _controller.dispose(); + } + _focusNode.removeListener(_onFocusChanged); + _focusNode.dispose(); + super.dispose(); + } + + void clear() => _controller.clear(); + + String get text => _controller.text; + + set text(String value) => _controller.text = value; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric(vertical: Insets.sm), + child: TextFormField( + onChanged: widget.onChanged, + onEditingComplete: widget.onEditingComplete, + onFieldSubmitted: widget.onFieldSubmitted, + onSaved: widget.onSaved, + onTap: widget.onTap, + autofocus: widget.autoFocus ?? false, + focusNode: _focusNode, + keyboardType: widget.type, + obscureText: widget.obscureText ?? false, + autocorrect: widget.autoCorrect ?? false, + enableSuggestions: widget.enableSuggestions ?? false, + style: widget.style ?? Theme.of(context).textTheme.bodyMedium, + cursorColor: Theme.of(context).colorScheme.primary, + controller: _controller, + showCursor: true, + enabled: widget.enabled, + maxLines: widget.maxLines, + maxLength: widget.maxLength, + textCapitalization: widget.capitalization ?? TextCapitalization.none, + textAlign: widget.textAlign, + decoration: widget.inputDecoration ?? + InputDecoration( + prefixIcon: widget.prefixIcon, + suffixIcon: widget.suffixIcon, + counterText: "", + suffixText: widget.showCounter ? _suffixText() : "", + contentPadding: widget.contentPadding ?? EdgeInsets.all(Insets.m), + border: widget.inputBorder ?? + const OutlineInputBorder(borderSide: BorderSide.none), + isDense: widget.isDense, + icon: widget.icon == null ? null : Icon(widget.icon), + errorText: widget.errorText, + errorMaxLines: 2, + hintText: widget.hintText, + hintStyle: widget.hintStyle ?? + Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Theme.of(context).hintColor), + labelText: widget.label, + ), + ), + ); + } + + String? _suffixText() { + if (widget.controller != null && widget.maxLength != null) { + return ' ${widget.controller!.text.length}/${widget.maxLength}'; + } + return null; + } +} + +class ThinUnderlineBorder extends InputBorder { + /// Creates an underline border for an [InputDecorator]. + /// + /// The [borderSide] parameter defaults to [BorderSide.none] (it must not be + /// null). Applications typically do not specify a [borderSide] parameter + /// because the input decorator substitutes its own, using [copyWith], based + /// on the current theme and [InputDecorator.isFocused]. + /// + /// The [borderRadius] parameter defaults to a value where the top left + /// and right corners have a circular radius of 4.0. The [borderRadius] + /// parameter must not be null. + const ThinUnderlineBorder({ + super.borderSide = const BorderSide(), + this.borderRadius = const BorderRadius.only( + topLeft: Radius.circular(4.0), + topRight: Radius.circular(4.0), + ), + }); + + /// The radii of the border's rounded rectangle corners. + /// + /// When this border is used with a filled input decorator, see + /// [InputDecoration.filled], the border radius defines the shape + /// of the background fill as well as the bottom left and right + /// edges of the underline itself. + /// + /// By default the top right and top left corners have a circular radius + /// of 4.0. + final BorderRadius borderRadius; + + @override + bool get isOutline => false; + + @override + UnderlineInputBorder copyWith({ + BorderSide? borderSide, + BorderRadius? borderRadius, + }) { + return UnderlineInputBorder( + borderSide: borderSide ?? this.borderSide, + borderRadius: borderRadius ?? this.borderRadius, + ); + } + + @override + EdgeInsetsGeometry get dimensions => + EdgeInsets.only(bottom: borderSide.width); + + @override + UnderlineInputBorder scale(double t) => + UnderlineInputBorder(borderSide: borderSide.scale(t)); + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + return Path() + ..addRect(Rect.fromLTWH(rect.left, rect.top, rect.width, + math.max(0.0, rect.height - borderSide.width))); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + return Path()..addRRect(borderRadius.resolve(textDirection).toRRect(rect)); + } + + @override + ShapeBorder? lerpFrom(ShapeBorder? a, double t) { + if (a is UnderlineInputBorder) { + final newBorderRadius = + BorderRadius.lerp(a.borderRadius, borderRadius, t); + + if (newBorderRadius != null) { + return UnderlineInputBorder( + borderSide: BorderSide.lerp(a.borderSide, borderSide, t), + borderRadius: newBorderRadius, + ); + } + } + return super.lerpFrom(a, t); + } + + @override + ShapeBorder? lerpTo(ShapeBorder? b, double t) { + if (b is UnderlineInputBorder) { + final newBorderRadius = + BorderRadius.lerp(b.borderRadius, borderRadius, t); + if (newBorderRadius != null) { + return UnderlineInputBorder( + borderSide: BorderSide.lerp(borderSide, b.borderSide, t), + borderRadius: newBorderRadius, + ); + } + } + return super.lerpTo(b, t); + } + + /// Draw a horizontal line at the bottom of [rect]. + /// + /// The [borderSide] defines the line's color and weight. The `textDirection` + /// `gap` and `textDirection` parameters are ignored. + /// @override + + @override + void paint( + Canvas canvas, + Rect rect, { + double? gapStart, + double gapExtent = 0.0, + double gapPercentage = 0.0, + TextDirection? textDirection, + }) { + if (borderRadius.bottomLeft != Radius.zero || + borderRadius.bottomRight != Radius.zero) { + canvas.clipPath(getOuterPath(rect, textDirection: textDirection)); + } + canvas.drawLine(rect.bottomLeft, rect.bottomRight, borderSide.toPaint()); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is InputBorder && other.borderSide == borderSide; + } + + @override + int get hashCode => borderSide.hashCode; +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart new file mode 100644 index 0000000000000..8087c712fe3c5 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart @@ -0,0 +1,143 @@ +import 'package:flowy_infra/size.dart'; +import 'package:flutter/material.dart'; + +class BaseStyledButton extends StatefulWidget { + final Widget child; + final VoidCallback? onPressed; + final Function(bool)? onFocusChanged; + final Function(bool)? onHighlightChanged; + final Color? bgColor; + final Color? focusColor; + final Color? hoverColor; + final Color? highlightColor; + final EdgeInsets? contentPadding; + final double? minWidth; + final double? minHeight; + final BorderRadius? borderRadius; + final bool useBtnText; + final bool autoFocus; + + final ShapeBorder? shape; + + final Color outlineColor; + + const BaseStyledButton({ + super.key, + required this.child, + this.onPressed, + this.onFocusChanged, + this.onHighlightChanged, + this.bgColor, + this.focusColor, + this.contentPadding, + this.minWidth, + this.minHeight, + this.borderRadius, + this.hoverColor, + this.highlightColor, + this.shape, + this.useBtnText = true, + this.autoFocus = false, + this.outlineColor = Colors.transparent, + }); + + @override + State createState() => BaseStyledBtnState(); +} + +class BaseStyledBtnState extends State { + late FocusNode _focusNode; + bool _isFocused = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: '', canRequestFocus: true); + _focusNode.addListener(_onFocusChanged); + } + + void _onFocusChanged() { + if (_focusNode.hasFocus != _isFocused) { + setState(() => _isFocused = _focusNode.hasFocus); + widget.onFocusChanged?.call(_isFocused); + } + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChanged); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: widget.bgColor ?? Theme.of(context).colorScheme.surface, + borderRadius: widget.borderRadius ?? Corners.s10Border, + boxShadow: _isFocused + ? [ + BoxShadow( + color: Theme.of(context).colorScheme.shadow, + offset: Offset.zero, + blurRadius: 8.0, + spreadRadius: 0.0, + ), + BoxShadow( + color: + widget.bgColor ?? Theme.of(context).colorScheme.surface, + offset: Offset.zero, + blurRadius: 8.0, + spreadRadius: -4.0, + ), + ] + : [], + ), + foregroundDecoration: _isFocused + ? ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide( + width: 1.8, + color: Theme.of(context).colorScheme.outline, + ), + borderRadius: widget.borderRadius ?? Corners.s10Border, + ), + ) + : null, + child: RawMaterialButton( + focusNode: _focusNode, + autofocus: widget.autoFocus, + textStyle: + widget.useBtnText ? Theme.of(context).textTheme.bodyMedium : null, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + // visualDensity: VisualDensity.compact, + splashColor: Colors.transparent, + mouseCursor: SystemMouseCursors.click, + elevation: 0, + hoverElevation: 0, + highlightElevation: 0, + focusElevation: 0, + fillColor: Colors.transparent, + hoverColor: widget.hoverColor ?? Colors.transparent, + highlightColor: widget.highlightColor ?? Colors.transparent, + focusColor: widget.focusColor ?? Colors.grey.withOpacity(0.35), + constraints: BoxConstraints( + minHeight: widget.minHeight ?? 0, minWidth: widget.minWidth ?? 0), + onPressed: widget.onPressed, + shape: widget.shape ?? + RoundedRectangleBorder( + side: BorderSide(color: widget.outlineColor, width: 1.5), + borderRadius: widget.borderRadius ?? Corners.s10Border, + ), + child: Opacity( + opacity: widget.onPressed != null ? 1 : .7, + child: Padding( + padding: widget.contentPadding ?? EdgeInsets.all(Insets.m), + child: widget.child, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart new file mode 100644 index 0000000000000..e2fbd49db3333 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart @@ -0,0 +1,55 @@ +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +import 'base_styled_button.dart'; +import 'secondary_button.dart'; + +class PrimaryTextButton extends StatelessWidget { + final String label; + final VoidCallback? onPressed; + final TextButtonMode mode; + + const PrimaryTextButton(this.label, + {super.key, this.onPressed, this.mode = TextButtonMode.big}); + + @override + Widget build(BuildContext context) { + return PrimaryButton( + mode: mode, + onPressed: onPressed, + child: FlowyText.regular( + label, + color: Theme.of(context).colorScheme.onPrimary, + ), + ); + } +} + +class PrimaryButton extends StatelessWidget { + const PrimaryButton({ + super.key, + required this.child, + this.onPressed, + this.mode = TextButtonMode.big, + this.backgroundColor, + }); + + final Widget child; + final VoidCallback? onPressed; + final TextButtonMode mode; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + return BaseStyledButton( + minWidth: mode.size.width, + minHeight: mode.size.height, + contentPadding: const EdgeInsets.symmetric(horizontal: 6), + bgColor: backgroundColor ?? Theme.of(context).colorScheme.primary, + hoverColor: Theme.of(context).colorScheme.primaryContainer, + borderRadius: mode.borderRadius, + onPressed: onPressed, + child: child, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart new file mode 100644 index 0000000000000..def795ed4469c --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart @@ -0,0 +1,93 @@ +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flowy_infra/size.dart'; + +import 'base_styled_button.dart'; + +enum TextButtonMode { + normal, + big, + small; + + Size get size { + switch (this) { + case TextButtonMode.normal: + return const Size(80, 32); + case TextButtonMode.big: + return const Size(100, 40); + case TextButtonMode.small: + return const Size(100, 30); + } + } + + BorderRadius get borderRadius { + switch (this) { + case TextButtonMode.normal: + return Corners.s8Border; + case TextButtonMode.big: + return Corners.s12Border; + case TextButtonMode.small: + return Corners.s6Border; + } + } +} + +class SecondaryTextButton extends StatelessWidget { + const SecondaryTextButton( + this.label, { + super.key, + this.onPressed, + this.textColor, + this.outlineColor, + this.mode = TextButtonMode.normal, + }); + + final String label; + final VoidCallback? onPressed; + final TextButtonMode mode; + final Color? textColor; + final Color? outlineColor; + + @override + Widget build(BuildContext context) { + return SecondaryButton( + mode: mode, + onPressed: onPressed, + outlineColor: outlineColor, + child: FlowyText.regular( + label, + color: textColor ?? Theme.of(context).colorScheme.primary, + ), + ); + } +} + +class SecondaryButton extends StatelessWidget { + const SecondaryButton({ + super.key, + required this.child, + this.onPressed, + this.outlineColor, + this.mode = TextButtonMode.normal, + }); + + final Widget child; + final VoidCallback? onPressed; + final TextButtonMode mode; + final Color? outlineColor; + + @override + Widget build(BuildContext context) { + final size = mode.size; + return BaseStyledButton( + minWidth: size.width, + minHeight: size.height, + contentPadding: EdgeInsets.zero, + bgColor: Colors.transparent, + outlineColor: outlineColor ?? Theme.of(context).colorScheme.primary, + borderRadius: mode.borderRadius, + onPressed: onPressed, + child: child, + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/constraint_flex_view.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/constraint_flex_view.dart similarity index 91% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/widget/constraint_flex_view.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/constraint_flex_view.dart index 8fb60fda1dd6f..63a5dd5f609d8 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/constraint_flex_view.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/constraint_flex_view.dart @@ -7,11 +7,10 @@ class ConstrainedFlexView extends StatelessWidget { final EdgeInsets scrollPadding; const ConstrainedFlexView(this.minSize, - {Key? key, + {super.key, required this.child, this.axis = Axis.horizontal, - this.scrollPadding = EdgeInsets.zero}) - : super(key: key); + this.scrollPadding = EdgeInsets.zero}); bool get isHz => axis == Axis.horizontal; diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/dialog/dialog_size.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/dialog_size.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/widget/dialog/dialog_size.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/dialog_size.dart diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart similarity index 84% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart index 5cf16416493d9..e29f778d8476b 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/dialog/styled_dialogs.dart @@ -1,20 +1,20 @@ -import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/widget/dialog/dialog_size.dart'; +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'dart:ui'; + +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; +import 'package:flowy_infra_ui/widget/dialog/dialog_size.dart'; extension IntoDialog on Widget { Future show(BuildContext context) async { FocusNode dialogFocusNode = FocusNode(); await Dialogs.show( - RawKeyboardListener( + child: KeyboardListener( focusNode: dialogFocusNode, - onKey: (value) { - if (value.isKeyPressed(LogicalKeyboardKey.escape)) { + onKeyEvent: (event) { + if (event.logicalKey == LogicalKeyboardKey.escape) { Navigator.of(context).pop(); } }, @@ -37,7 +37,7 @@ class StyledDialog extends StatelessWidget { final bool shrinkWrap; const StyledDialog({ - Key? key, + super.key, required this.child, this.maxWidth, this.maxHeight, @@ -46,35 +46,36 @@ class StyledDialog extends StatelessWidget { this.bgColor, this.borderRadius = const BorderRadius.all(Radius.circular(6)), this.shrinkWrap = true, - }) : super(key: key); + }); @override Widget build(BuildContext context) { - final theme = context.watch(); - Widget innerContent = Container( - padding: padding ?? EdgeInsets.all(Insets.lGutter), - color: bgColor ?? theme.shader7, + padding: padding ?? + EdgeInsets.symmetric(horizontal: Insets.xxl, vertical: Insets.xl), + color: bgColor ?? Theme.of(context).colorScheme.surface, child: child, ); if (shrinkWrap) { - innerContent = - IntrinsicWidth(child: IntrinsicHeight(child: innerContent)); + innerContent = IntrinsicWidth( + child: IntrinsicHeight( + child: innerContent, + )); } return FocusTraversalGroup( child: Container( margin: margin ?? EdgeInsets.all(Insets.sm * 2), alignment: Alignment.center, - child: Container( + child: ConstrainedBox( constraints: BoxConstraints( minWidth: DialogSize.minDialogWidth, maxHeight: maxHeight ?? double.infinity, maxWidth: maxWidth ?? double.infinity, ), child: ClipRRect( - borderRadius: borderRadius, + borderRadius: borderRadius ?? BorderRadius.zero, child: SingleChildScrollView( physics: StyledScrollPhysics(), //https://medium.com/saugo360/https-medium-com-saugo360-flutter-using-overlay-to-display-floating-widgets-2e6d0e8decb9 @@ -91,7 +92,8 @@ class StyledDialog extends StatelessWidget { } class Dialogs { - static Future show(Widget child, BuildContext context) async { + static Future show(BuildContext context, + {required Widget child}) async { return await Navigator.of(context).push( StyledDialogRoute( barrier: DialogBarrier(color: Colors.black.withOpacity(0.4)), @@ -108,13 +110,14 @@ class DialogBarrier { String label; Color color; bool dismissible; - ImageFilter filter; + ImageFilter? filter; DialogBarrier({ this.dismissible = true, this.color = Colors.transparent, this.label = '', - }) : filter = ImageFilter.blur(sigmaX: 4, sigmaY: 4); + this.filter, + }); } class StyledDialogRoute extends PopupRoute { @@ -126,11 +129,11 @@ class StyledDialogRoute extends PopupRoute { required this.barrier, Duration transitionDuration = const Duration(milliseconds: 300), RouteTransitionsBuilder? transitionBuilder, - RouteSettings? settings, + super.settings, }) : _pageBuilder = pageBuilder, _transitionDuration = transitionDuration, _transitionBuilder = transitionBuilder, - super(settings: settings, filter: barrier.filter); + super(filter: barrier.filter); @override bool get barrierDismissible { diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart new file mode 100644 index 0000000000000..d395873bd7a63 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/error_page.dart @@ -0,0 +1,270 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_svg/flowy_svg.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class FlowyErrorPage extends StatelessWidget { + factory FlowyErrorPage.error( + Error e, { + required String howToFix, + Key? key, + List? actions, + }) => + FlowyErrorPage._( + e.toString(), + stackTrace: e.stackTrace?.toString(), + howToFix: howToFix, + key: key, + actions: actions, + ); + + factory FlowyErrorPage.message( + String message, { + required String howToFix, + String? stackTrace, + Key? key, + List? actions, + }) => + FlowyErrorPage._( + message, + key: key, + stackTrace: stackTrace, + howToFix: howToFix, + actions: actions, + ); + + factory FlowyErrorPage.exception( + Exception e, { + required String howToFix, + String? stackTrace, + Key? key, + List? actions, + }) => + FlowyErrorPage._( + e.toString(), + stackTrace: stackTrace, + key: key, + howToFix: howToFix, + actions: actions, + ); + + const FlowyErrorPage._( + this.message, { + required this.howToFix, + this.stackTrace, + super.key, + this.actions, + }); + + static const _titleFontSize = 24.0; + static const _titleToMessagePadding = 8.0; + + final List? actions; + final String howToFix; + final String message; + final String? stackTrace; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const FlowyText.medium( + "AppFlowy Error", + fontSize: _titleFontSize, + ), + const SizedBox(height: _titleToMessagePadding), + Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) async { + await Clipboard.setData(ClipboardData(text: message)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + content: FlowyText( + 'Message copied to clipboard', + fontSize: kIsWeb || !Platform.isIOS && !Platform.isAndroid + ? 14 + : 12, + ), + ), + ); + } + }, + child: FlowyHover( + style: HoverStyle( + backgroundColor: + Theme.of(context).colorScheme.tertiaryContainer, + ), + cursor: SystemMouseCursors.click, + child: FlowyTooltip( + message: 'Click to copy message', + child: Padding( + padding: const EdgeInsets.all(4), + child: FlowyText.semibold(message, maxLines: 10), + ), + ), + ), + ), + const SizedBox(height: _titleToMessagePadding), + FlowyText.regular(howToFix, maxLines: 10), + const SizedBox(height: _titleToMessagePadding), + GitHubRedirectButton( + title: 'Unexpected error', + message: message, + stackTrace: stackTrace, + ), + const SizedBox(height: _titleToMessagePadding), + if (stackTrace != null) StackTracePreview(stackTrace!), + if (actions != null) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: actions!, + ), + ], + ), + ); + } +} + +class StackTracePreview extends StatelessWidget { + const StackTracePreview( + this.stackTrace, { + super.key, + }); + + final String stackTrace; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 350, + maxWidth: 450, + ), + child: Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + clipBehavior: Clip.antiAlias, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + const Align( + alignment: Alignment.centerLeft, + child: FlowyText.semibold( + "Stack Trace", + ), + ), + Container( + height: 120, + padding: const EdgeInsets.symmetric(vertical: 8), + child: SingleChildScrollView( + child: Text( + stackTrace, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + Align( + alignment: Alignment.centerRight, + child: FlowyButton( + hoverColor: AFThemeExtension.of(context).onBackground, + text: const FlowyText( + "Copy", + ), + useIntrinsicWidth: true, + onTap: () => Clipboard.setData( + ClipboardData(text: stackTrace), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class GitHubRedirectButton extends StatelessWidget { + const GitHubRedirectButton({ + super.key, + this.title, + this.message, + this.stackTrace, + }); + + final String? title; + final String? message; + final String? stackTrace; + + static const _height = 32.0; + + Uri get _gitHubNewBugUri => Uri( + scheme: 'https', + host: 'github.com', + path: '/AppFlowy-IO/AppFlowy/issues/new', + query: + 'assignees=&labels=&projects=&template=bug_report.yaml&os=$_platform&title=%5BBug%5D+$title&context=$_contextString', + ); + + String get _contextString { + if (message == null && stackTrace == null) { + return ''; + } + + String msg = ""; + if (message != null) { + msg += 'Error message:%0A```%0A$message%0A```%0A'; + } + + if (stackTrace != null) { + msg += 'StackTrace:%0A```%0A$stackTrace%0A```%0A'; + } + + return msg; + } + + String get _platform { + if (kIsWeb) { + return 'Web'; + } + + return Platform.operatingSystem; + } + + @override + Widget build(BuildContext context) { + return FlowyButton( + leftIconSize: const Size.square(_height), + text: const FlowyText( + "AppFlowy", + ), + useIntrinsicWidth: true, + leftIcon: const Padding( + padding: EdgeInsets.all(4.0), + child: FlowySvg(FlowySvgData('login/github-mark')), + ), + onTap: () async { + if (await canLaunchUrl(_gitHubNewBugUri)) { + await launchUrl(_gitHubNewBugUri); + } + }, + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart new file mode 100644 index 0000000000000..c4c3263d39c7e --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +const _tooltipWaitDuration = Duration(milliseconds: 300); + +class FlowyTooltip extends StatelessWidget { + const FlowyTooltip({ + super.key, + this.message, + this.richMessage, + this.preferBelow, + this.margin, + this.verticalOffset, + this.child, + }); + + final String? message; + final InlineSpan? richMessage; + final bool? preferBelow; + final EdgeInsetsGeometry? margin; + final Widget? child; + final double? verticalOffset; + + @override + Widget build(BuildContext context) { + if (message == null && richMessage == null) { + return child ?? const SizedBox.shrink(); + } + + return Tooltip( + margin: margin, + verticalOffset: verticalOffset ?? 16.0, + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + decoration: BoxDecoration( + color: context.tooltipBackgroundColor(), + borderRadius: BorderRadius.circular(10.0), + ), + waitDuration: _tooltipWaitDuration, + message: message, + textStyle: message != null ? context.tooltipTextStyle() : null, + richMessage: richMessage, + preferBelow: preferBelow, + child: child, + ); + } +} + +extension FlowyToolTipExtension on BuildContext { + double tooltipFontSize() => 14.0; + double tooltipHeight({double? fontSize}) => + 20.0 / (fontSize ?? tooltipFontSize()); + Color tooltipFontColor() => Theme.of(this).brightness == Brightness.light + ? Colors.white + : Colors.black; + + TextStyle? tooltipTextStyle({Color? fontColor, double? fontSize}) { + return Theme.of(this).textTheme.bodyMedium?.copyWith( + color: fontColor ?? tooltipFontColor(), + fontSize: fontSize ?? tooltipFontSize(), + fontWeight: FontWeight.w400, + height: tooltipHeight(fontSize: fontSize), + leadingDistribution: TextLeadingDistribution.even, + ); + } + + TextStyle? tooltipHintTextStyle({double? fontSize}) => tooltipTextStyle( + fontColor: tooltipFontColor().withOpacity(0.7), + fontSize: fontSize, + ); + + Color tooltipBackgroundColor() => + Theme.of(this).brightness == Brightness.light + ? const Color(0xFF1D2129) + : const Color(0xE5E5E5E5); +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/ignore_parent_gesture.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/ignore_parent_gesture.dart new file mode 100644 index 0000000000000..664d5cd492580 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/ignore_parent_gesture.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class IgnoreParentGestureWidget extends StatelessWidget { + const IgnoreParentGestureWidget({ + super.key, + required this.child, + this.onPress, + }); + + final Widget child; + final VoidCallback? onPress; + + @override + Widget build(BuildContext context) { + // https://docs.flutter.dev/development/ui/advanced/gestures#gesture-disambiguation + // https://github.com/AppFlowy-IO/AppFlowy/issues/1290 + return Listener( + onPointerDown: (event) { + onPress?.call(); + }, + onPointerSignal: (event) {}, + onPointerMove: (event) {}, + onPointerUp: (event) {}, + onPointerHover: (event) {}, + onPointerPanZoomStart: (event) {}, + onPointerPanZoomUpdate: (event) {}, + onPointerPanZoomEnd: (event) {}, + child: child, + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/mouse_hover_builder.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/mouse_hover_builder.dart similarity index 89% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/widget/mouse_hover_builder.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/mouse_hover_builder.dart index 81529ba16ca1e..dab9a4640dedd 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/mouse_hover_builder.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/mouse_hover_builder.dart @@ -6,8 +6,7 @@ class MouseHoverBuilder extends StatefulWidget { final bool isClickable; const MouseHoverBuilder( - {Key? key, required this.builder, this.isClickable = false}) - : super(key: key); + {super.key, required this.builder, this.isClickable = false}); final HoverBuilder builder; diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart new file mode 100644 index 0000000000000..0fa181a1dd01d --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_button.dart @@ -0,0 +1,95 @@ +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flutter/material.dart'; + +class RoundedTextButton extends StatelessWidget { + final VoidCallback? onPressed; + final String? title; + final double? width; + final double? height; + final BorderRadius? borderRadius; + final Color borderColor; + final Color? fillColor; + final Color? hoverColor; + final Color? textColor; + final double? fontSize; + final FontWeight? fontWeight; + final EdgeInsets padding; + + const RoundedTextButton({ + super.key, + this.onPressed, + this.title, + this.width, + this.height, + this.borderRadius, + this.borderColor = Colors.transparent, + this.fillColor, + this.hoverColor, + this.textColor, + this.fontSize, + this.fontWeight, + this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + }); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints( + minWidth: 10, + maxWidth: width ?? double.infinity, + minHeight: 10, + maxHeight: height ?? 60, + ), + child: SizedBox.expand( + child: FlowyTextButton( + title ?? '', + fontWeight: fontWeight, + onPressed: onPressed, + fontSize: fontSize, + mainAxisAlignment: MainAxisAlignment.center, + radius: borderRadius ?? Corners.s6Border, + fontColor: textColor ?? Theme.of(context).colorScheme.onPrimary, + fillColor: fillColor ?? Theme.of(context).colorScheme.primary, + hoverColor: + hoverColor ?? Theme.of(context).colorScheme.primaryContainer, + padding: padding, + ), + ), + ); + } +} + +class RoundedImageButton extends StatelessWidget { + final VoidCallback? press; + final double size; + final BorderRadius borderRadius; + final Color borderColor; + final Color color; + final Widget child; + + const RoundedImageButton({ + super.key, + this.press, + required this.size, + this.borderRadius = BorderRadius.zero, + this.borderColor = Colors.transparent, + this.color = Colors.transparent, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size, + height: size, + child: TextButton( + onPressed: press, + style: ButtonStyle( + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: borderRadius))), + child: child, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_input_field.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_input_field.dart new file mode 100644 index 0000000000000..de5e3061fd27c --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/rounded_input_field.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/time/duration.dart'; +import 'package:flowy_infra_ui/widget/rounded_button.dart'; + +class RoundedInputField extends StatefulWidget { + final String? hintText; + final bool obscureText; + final Widget? obscureIcon; + final Widget? obscureHideIcon; + final Color? normalBorderColor; + final Color? errorBorderColor; + final Color? cursorColor; + final Color? focusBorderColor; + final String errorText; + final TextStyle? style; + final ValueChanged? onChanged; + final Function(String)? onEditingComplete; + final String? initialValue; + final EdgeInsets margin; + final EdgeInsets padding; + final EdgeInsets contentPadding; + final double height; + final FocusNode? focusNode; + final TextEditingController? controller; + final bool autoFocus; + final int? maxLength; + final Function(String)? onFieldSubmitted; + + const RoundedInputField({ + super.key, + this.hintText, + this.errorText = "", + this.initialValue, + this.obscureText = false, + this.obscureIcon, + this.obscureHideIcon, + this.onChanged, + this.onEditingComplete, + this.normalBorderColor, + this.errorBorderColor, + this.focusBorderColor, + this.cursorColor, + this.style, + this.margin = EdgeInsets.zero, + this.padding = EdgeInsets.zero, + this.contentPadding = const EdgeInsets.symmetric(horizontal: 10), + this.height = 48, + this.focusNode, + this.controller, + this.autoFocus = false, + this.maxLength, + this.onFieldSubmitted, + }); + + @override + State createState() => _RoundedInputFieldState(); +} + +class _RoundedInputFieldState extends State { + String inputText = ""; + bool obscureText = false; + + @override + void initState() { + super.initState(); + obscureText = widget.obscureText; + inputText = widget.controller != null + ? widget.controller!.text + : widget.initialValue ?? ""; + } + + String? _suffixText() => widget.maxLength != null + ? ' ${widget.controller!.text.length}/${widget.maxLength}' + : null; + + @override + Widget build(BuildContext context) { + Color borderColor = + widget.normalBorderColor ?? Theme.of(context).colorScheme.outline; + Color focusBorderColor = + widget.focusBorderColor ?? Theme.of(context).colorScheme.primary; + + if (widget.errorText.isNotEmpty) { + borderColor = Theme.of(context).colorScheme.error; + focusBorderColor = borderColor; + } + + List children = [ + Container( + margin: widget.margin, + padding: widget.padding, + height: widget.height, + child: TextFormField( + controller: widget.controller, + initialValue: widget.initialValue, + focusNode: widget.focusNode, + autofocus: widget.autoFocus, + maxLength: widget.maxLength, + maxLengthEnforcement: + MaxLengthEnforcement.truncateAfterCompositionEnds, + onFieldSubmitted: widget.onFieldSubmitted, + onChanged: (value) { + inputText = value; + if (widget.onChanged != null) { + widget.onChanged!(value); + } + setState(() {}); + }, + onEditingComplete: () { + if (widget.onEditingComplete != null) { + widget.onEditingComplete!(inputText); + } + }, + cursorColor: + widget.cursorColor ?? Theme.of(context).colorScheme.primary, + obscureText: obscureText, + style: widget.style ?? Theme.of(context).textTheme.bodyMedium, + decoration: InputDecoration( + contentPadding: widget.contentPadding, + hintText: widget.hintText, + hintStyle: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(color: Theme.of(context).hintColor), + suffixText: _suffixText(), + counterText: "", + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: borderColor, width: 1.0), + borderRadius: Corners.s10Border, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: focusBorderColor, width: 1.0), + borderRadius: Corners.s10Border, + ), + suffixIcon: obscureIcon(), + ), + ), + ), + ]; + + if (widget.errorText.isNotEmpty) { + children.add( + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + widget.errorText, + style: widget.style, + ), + ), + ), + ); + } + + return AnimatedSize( + duration: .4.seconds, + curve: Curves.easeInOut, + child: Column(children: children), + ); + } + + Widget? obscureIcon() { + if (widget.obscureText == false) { + return null; + } + + const double iconWidth = 16; + if (inputText.isEmpty) { + return SizedBox.fromSize(size: const Size.square(iconWidth)); + } + + assert(widget.obscureIcon != null && widget.obscureHideIcon != null); + final icon = obscureText ? widget.obscureIcon! : widget.obscureHideIcon!; + + return RoundedImageButton( + size: iconWidth, + press: () => setState(() => obscureText = !obscureText), + child: icon, + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/route/animation.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/route/animation.dart similarity index 96% rename from frontend/app_flowy/packages/flowy_infra_ui/lib/widget/route/animation.dart rename to frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/route/animation.dart index e0f328afc9939..257c7c0cf6415 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/route/animation.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/route/animation.dart @@ -8,9 +8,10 @@ class PageRoutes { static const Curve kDefaultEaseFwd = Curves.easeOut; static const Curve kDefaultEaseReverse = Curves.easeOut; - static Route fade(PageBuilder pageBuilder, + static Route fade(PageBuilder pageBuilder, RouteSettings? settings, [double duration = kDefaultDuration]) { return PageRouteBuilder( + settings: settings, transitionDuration: Duration(milliseconds: (duration * 1000).round()), pageBuilder: (context, animation, secondaryAnimation) => pageBuilder(), transitionsBuilder: (context, animation, secondaryAnimation, child) { diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/separated_flex.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/separated_flex.dart new file mode 100644 index 0000000000000..c59c15e73ab94 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/separated_flex.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +typedef SeparatorBuilder = Widget Function(); + +Widget _defaultColumnSeparatorBuilder() => const Divider(); +Widget _defaultRowSeparatorBuilder() => const VerticalDivider(); + +class SeparatedColumn extends Column { + SeparatedColumn({ + super.key, + super.mainAxisAlignment, + super.crossAxisAlignment, + super.mainAxisSize, + super.textBaseline, + super.textDirection, + super.verticalDirection, + SeparatorBuilder separatorBuilder = _defaultColumnSeparatorBuilder, + required List children, + }) : super(children: _insertSeparators(children, separatorBuilder)); +} + +class SeparatedRow extends Row { + SeparatedRow({ + super.key, + super.mainAxisAlignment, + super.crossAxisAlignment, + super.mainAxisSize, + super.textBaseline, + super.textDirection, + super.verticalDirection, + SeparatorBuilder separatorBuilder = _defaultRowSeparatorBuilder, + required List children, + }) : super(children: _insertSeparators(children, separatorBuilder)); +} + +List _insertSeparators( + List children, + SeparatorBuilder separatorBuilder, +) { + if (children.length < 2) { + return children; + } + + List newChildren = []; + for (int i = 0; i < children.length - 1; i++) { + newChildren.add(children[i]); + newChildren.add(separatorBuilder()); + } + return newChildren..add(children.last); +} diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/spacing.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/spacing.dart new file mode 100644 index 0000000000000..1abb5b8fdb68a --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/spacing.dart @@ -0,0 +1,63 @@ +import 'package:flutter/cupertino.dart'; + +class Space extends StatelessWidget { + final double width; + final double height; + + const Space(this.width, this.height, {super.key}); + + @override + Widget build(BuildContext context) => SizedBox(width: width, height: height); +} + +class VSpace extends StatelessWidget { + const VSpace( + this.size, { + super.key, + this.color, + }); + + final double size; + final Color? color; + + @override + Widget build(BuildContext context) { + if (color != null) { + return SizedBox( + height: size, + width: double.infinity, + child: ColoredBox( + color: color!, + ), + ); + } else { + return Space(0, size); + } + } +} + +class HSpace extends StatelessWidget { + const HSpace( + this.size, { + super.key, + this.color, + }); + + final double size; + final Color? color; + + @override + Widget build(BuildContext context) { + if (color != null) { + return SizedBox( + height: double.infinity, + width: size, + child: ColoredBox( + color: color!, + ), + ); + } else { + return Space(size, 0); + } + } +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/linux/CMakeLists.txt b/frontend/appflowy_flutter/packages/flowy_infra_ui/linux/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/linux/CMakeLists.txt rename to frontend/appflowy_flutter/packages/flowy_infra_ui/linux/CMakeLists.txt diff --git a/frontend/app_flowy/packages/flowy_infra_ui/linux/flowy_infra_u_i_plugin.cc b/frontend/appflowy_flutter/packages/flowy_infra_ui/linux/flowy_infra_u_i_plugin.cc similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/linux/flowy_infra_u_i_plugin.cc rename to frontend/appflowy_flutter/packages/flowy_infra_ui/linux/flowy_infra_u_i_plugin.cc diff --git a/frontend/app_flowy/packages/flowy_infra_ui/linux/flowy_infra_ui_plugin.cc b/frontend/appflowy_flutter/packages/flowy_infra_ui/linux/flowy_infra_ui_plugin.cc similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/linux/flowy_infra_ui_plugin.cc rename to frontend/appflowy_flutter/packages/flowy_infra_ui/linux/flowy_infra_ui_plugin.cc diff --git a/frontend/app_flowy/packages/flowy_infra_ui/linux/include/flowy_infra_ui/flowy_infra_u_i_plugin.h b/frontend/appflowy_flutter/packages/flowy_infra_ui/linux/include/flowy_infra_ui/flowy_infra_u_i_plugin.h similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/linux/include/flowy_infra_ui/flowy_infra_u_i_plugin.h rename to frontend/appflowy_flutter/packages/flowy_infra_ui/linux/include/flowy_infra_ui/flowy_infra_u_i_plugin.h diff --git a/frontend/app_flowy/packages/flowy_infra_ui/linux/include/flowy_infra_ui/flowy_infra_ui_plugin.h b/frontend/appflowy_flutter/packages/flowy_infra_ui/linux/include/flowy_infra_ui/flowy_infra_ui_plugin.h similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/linux/include/flowy_infra_ui/flowy_infra_ui_plugin.h rename to frontend/appflowy_flutter/packages/flowy_infra_ui/linux/include/flowy_infra_ui/flowy_infra_ui_plugin.h diff --git a/frontend/app_flowy/packages/flowy_infra_ui/macos/Classes/FlowyInfraUiPlugin.swift b/frontend/appflowy_flutter/packages/flowy_infra_ui/macos/Classes/FlowyInfraUiPlugin.swift similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/macos/Classes/FlowyInfraUiPlugin.swift rename to frontend/appflowy_flutter/packages/flowy_infra_ui/macos/Classes/FlowyInfraUiPlugin.swift diff --git a/frontend/app_flowy/packages/flowy_infra_ui/macos/flowy_infra_ui.podspec b/frontend/appflowy_flutter/packages/flowy_infra_ui/macos/flowy_infra_ui.podspec similarity index 96% rename from frontend/app_flowy/packages/flowy_infra_ui/macos/flowy_infra_ui.podspec rename to frontend/appflowy_flutter/packages/flowy_infra_ui/macos/flowy_infra_ui.podspec index 35b105dbeaaad..83c93b09b1a3f 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/macos/flowy_infra_ui.podspec +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/macos/flowy_infra_ui.podspec @@ -16,7 +16,7 @@ A new flutter plugin project. s.source_files = 'Classes/**/*' s.dependency 'FlutterMacOS' - s.platform = :osx, '10.11' + s.platform = :osx, '10.13' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } s.swift_version = '5.0' end diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml new file mode 100644 index 0000000000000..62cb26d4e0859 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/pubspec.yaml @@ -0,0 +1,57 @@ +name: flowy_infra_ui +description: A new flutter plugin project. +version: 0.0.1 +homepage: https://appflowy.io +publish_to: "none" + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.10.1" + +dependencies: + flutter: + sdk: flutter + + # Thirdparty packages + + styled_widget: ^0.4.1 + animations: ^2.0.7 + loading_indicator: ^3.1.0 + async: + url_launcher: ^6.1.11 + google_fonts: ^6.1.0 + + # Federated Platform Interface + flowy_infra_ui_platform_interface: + path: flowy_infra_ui_platform_interface + appflowy_popover: + path: ../appflowy_popover + flowy_infra: + path: ../flowy_infra + flowy_svg: + path: ../flowy_svg + +dev_dependencies: + build_runner: ^2.4.9 + provider: ^6.0.5 + flutter_test: + sdk: flutter + flutter_lints: ^3.0.1 + +flutter: + plugin: + platforms: + # TODO: uncomment android part will fail the Linux build process, will resolve later + # android: + # package: com.example.flowy_infra_ui + # pluginClass: FlowyInfraUIPlugin + ios: + pluginClass: FlowyInfraUIPlugin + macos: + pluginClass: FlowyInfraUIPlugin + windows: + pluginClass: FlowyInfraUIPlugin + linux: + pluginClass: FlowyInfraUIPlugin + web: + default_package: flowy_infra_ui_web diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/test/flowy_infra_ui_test.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/test/flowy_infra_ui_test.dart new file mode 100644 index 0000000000000..0f6c29f573b46 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/test/flowy_infra_ui_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const MethodChannel channel = MethodChannel('flowy_infra_ui'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return '42'; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + null, + ); + }); + + test('getPlatformVersion', () async {}); +} diff --git a/frontend/app_flowy/packages/flowy_sdk/windows/.gitignore b/frontend/appflowy_flutter/packages/flowy_infra_ui/windows/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_sdk/windows/.gitignore rename to frontend/appflowy_flutter/packages/flowy_infra_ui/windows/.gitignore diff --git a/frontend/app_flowy/packages/flowy_infra_ui/windows/CMakeLists.txt b/frontend/appflowy_flutter/packages/flowy_infra_ui/windows/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/windows/CMakeLists.txt rename to frontend/appflowy_flutter/packages/flowy_infra_ui/windows/CMakeLists.txt diff --git a/frontend/app_flowy/packages/flowy_infra_ui/windows/flowy_infra_ui_plugin.cpp b/frontend/appflowy_flutter/packages/flowy_infra_ui/windows/flowy_infra_ui_plugin.cpp similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/windows/flowy_infra_ui_plugin.cpp rename to frontend/appflowy_flutter/packages/flowy_infra_ui/windows/flowy_infra_ui_plugin.cpp diff --git a/frontend/app_flowy/packages/flowy_infra_ui/windows/include/flowy_infra_ui/flowy_infra_u_i_plugin.h b/frontend/appflowy_flutter/packages/flowy_infra_ui/windows/include/flowy_infra_ui/flowy_infra_u_i_plugin.h similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/windows/include/flowy_infra_ui/flowy_infra_u_i_plugin.h rename to frontend/appflowy_flutter/packages/flowy_infra_ui/windows/include/flowy_infra_ui/flowy_infra_u_i_plugin.h diff --git a/frontend/app_flowy/packages/flowy_infra_ui/windows/include/flowy_infra_ui/flowy_infra_ui_plugin.h b/frontend/appflowy_flutter/packages/flowy_infra_ui/windows/include/flowy_infra_ui/flowy_infra_ui_plugin.h similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/windows/include/flowy_infra_ui/flowy_infra_ui_plugin.h rename to frontend/appflowy_flutter/packages/flowy_infra_ui/windows/include/flowy_infra_ui/flowy_infra_ui_plugin.h diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/bug_report.md b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000000..50a4c7b8b7b24 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: "fix: " +labels: bug +--- + +**Description** + +A clear and concise description of what the bug is. + +**Steps To Reproduce** + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected Behavior** + +A clear and concise description of what you expected to happen. + +**Screenshots** + +If applicable, add screenshots to help explain your problem. + +**Additional Context** + +Add any other context about the problem here. diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/build.md b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/build.md new file mode 100644 index 0000000000000..0cf8e62cdbe29 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/build.md @@ -0,0 +1,14 @@ +--- +name: Build System +about: Changes that affect the build system or external dependencies +title: "build: " +labels: build +--- + +**Description** + +Describe what changes need to be done to the build system and why. + +**Requirements** + +- [ ] The build system is passing diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/chore.md b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/chore.md new file mode 100644 index 0000000000000..498ebfd82128e --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/chore.md @@ -0,0 +1,14 @@ +--- +name: Chore +about: Other changes that don't modify src or test files +title: "chore: " +labels: chore +--- + +**Description** + +Clearly describe what change is needed and why. If this changes code then please use another issue type. + +**Requirements** + +- [ ] No functional changes to the code diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/ci.md b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/ci.md new file mode 100644 index 0000000000000..fa2dd9e2d0a35 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/ci.md @@ -0,0 +1,14 @@ +--- +name: Continuous Integration +about: Changes to the CI configuration files and scripts +title: "ci: " +labels: ci +--- + +**Description** + +Describe what changes need to be done to the ci/cd system and why. + +**Requirements** + +- [ ] The ci system is passing diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/config.yml b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000..ec4bb386bcf8a --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/documentation.md b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000000000..f494a4d98b2b9 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,14 @@ +--- +name: Documentation +about: Improve the documentation so all collaborators have a common understanding +title: "docs: " +labels: documentation +--- + +**Description** + +Clearly describe what documentation you are looking to add or improve. + +**Requirements** + +- [ ] Requirements go here diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/feature_request.md b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000000..ddd2fcca97fed --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature Request +about: A new feature to be added to the project +title: "feat: " +labels: feature +--- + +**Description** + +Clearly describe what you are looking to add. The more context the better. + +**Requirements** + +- [ ] Checklist of requirements to be fulfilled + +**Additional Context** + +Add any other context or screenshots about the feature request go here. diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/performance.md b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/performance.md new file mode 100644 index 0000000000000..699b8d45f2713 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/performance.md @@ -0,0 +1,14 @@ +--- +name: Performance Update +about: A code change that improves performance +title: "perf: " +labels: performance +--- + +**Description** + +Clearly describe what code needs to be changed and what the performance impact is going to be. Bonus point's if you can tie this directly to user experience. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/refactor.md b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/refactor.md new file mode 100644 index 0000000000000..1626c57047b9f --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/refactor.md @@ -0,0 +1,14 @@ +--- +name: Refactor +about: A code change that neither fixes a bug nor adds a feature +title: "refactor: " +labels: refactor +--- + +**Description** + +Clearly describe what needs to be refactored and why. Please provide links to related issues (bugs or upcoming features) in order to help prioritize. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/revert.md b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/revert.md new file mode 100644 index 0000000000000..9d121dc56f838 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/revert.md @@ -0,0 +1,16 @@ +--- +name: Revert Commit +about: Reverts a previous commit +title: "revert: " +labels: revert +--- + +**Description** + +Provide a link to a PR/Commit that you are looking to revert and why. + +**Requirements** + +- [ ] Change has been reverted +- [ ] No change in test coverage has happened +- [ ] A new ticket is created for any follow on work that needs to happen diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/style.md b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/style.md new file mode 100644 index 0000000000000..02244a7bddf57 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/style.md @@ -0,0 +1,14 @@ +--- +name: Style Changes +about: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc) +title: "style: " +labels: style +--- + +**Description** + +Clearly describe what you are looking to change and why. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/test.md b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/test.md new file mode 100644 index 0000000000000..431a7ea764e92 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/ISSUE_TEMPLATE/test.md @@ -0,0 +1,14 @@ +--- +name: Test +about: Adding missing tests or correcting existing tests +title: "test: " +labels: test +--- + +**Description** + +List out the tests that need to be added or changed. Please also include any information as to why this was not covered in the past. + +**Requirements** + +- [ ] There is no drop in test coverage. diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/PULL_REQUEST_TEMPLATE.md b/frontend/appflowy_flutter/packages/flowy_svg/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000000..116993637f56a --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ + + +## Status + +**READY/IN DEVELOPMENT/HOLD** + +## Description + + + +## Type of Change + + + +- [ ] ✨ New feature (non-breaking change which adds functionality) +- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue) +- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change) +- [ ] 🧹 Code refactor +- [ ] ✅ Build configuration change +- [ ] 📝 Documentation +- [ ] 🗑️ Chore diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/cspell.json b/frontend/appflowy_flutter/packages/flowy_svg/.github/cspell.json new file mode 100644 index 0000000000000..29ea2f8e4bf12 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/cspell.json @@ -0,0 +1,21 @@ +{ + "version": "0.2", + "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", + "dictionaries": ["vgv_allowed", "vgv_forbidden"], + "dictionaryDefinitions": [ + { + "name": "vgv_allowed", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt", + "description": "Allowed VGV Spellings" + }, + { + "name": "vgv_forbidden", + "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt", + "description": "Forbidden VGV Spellings" + } + ], + "useGitignore": true, + "words": [ + "flowy_svg" + ] +} diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/dependabot.yaml b/frontend/appflowy_flutter/packages/flowy_svg/.github/dependabot.yaml new file mode 100644 index 0000000000000..63b035cdead3a --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/dependabot.yaml @@ -0,0 +1,11 @@ +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "daily" diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.github/workflows/main.yaml b/frontend/appflowy_flutter/packages/flowy_svg/.github/workflows/main.yaml new file mode 100644 index 0000000000000..cf84703df49aa --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.github/workflows/main.yaml @@ -0,0 +1,25 @@ +name: ci + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + branches: + - main + +jobs: + semantic_pull_request: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@v1 + + spell-check: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 + with: + includes: "**/*.md" + modified_files_only: false + + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 + with: + flutter_channel: stable diff --git a/frontend/appflowy_flutter/packages/flowy_svg/.gitignore b/frontend/appflowy_flutter/packages/flowy_svg/.gitignore new file mode 100644 index 0000000000000..8e19df2d9dccc --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VSCode related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +pubspec.lock + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Test related +coverage \ No newline at end of file diff --git a/frontend/appflowy_flutter/packages/flowy_svg/analysis_options.yaml b/frontend/appflowy_flutter/packages/flowy_svg/analysis_options.yaml new file mode 100644 index 0000000000000..543c78a7d412c --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/analysis_options.yaml @@ -0,0 +1 @@ +linter: diff --git a/frontend/appflowy_flutter/packages/flowy_svg/bin/flowy_svg.dart b/frontend/appflowy_flutter/packages/flowy_svg/bin/flowy_svg.dart new file mode 100644 index 0000000000000..cbf114156de0a --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/bin/flowy_svg.dart @@ -0,0 +1,284 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:path/path.dart' as path; + +import 'options.dart'; + +const languageKeywords = [ + 'abstract', + 'else', + 'import', + 'show', + 'as', + 'enum', + 'static', + 'assert', + 'export', + 'interface', + 'super', + 'async', + 'extends', + 'is', + 'switch', + 'await', + 'extension', + 'late', + 'sync', + 'base', + 'external', + 'library', + 'this', + 'break', + 'factory', + 'mixin', + 'throw', + 'case', + 'false', + 'new', + 'true', + 'catch', + 'final', + 'variable', + 'null', + 'try', + 'class', + 'final', + 'class', + 'on', + 'typedef', + 'const', + 'finally', + 'operator', + 'var', + 'continue', + 'for', + 'part', + 'void', + 'covariant', + 'Function', + 'required', + 'when', + 'default', + 'get', + 'rethrow', + 'while', + 'deferred', + 'hide', + 'return', + 'with', + 'do', + 'if', + 'sealed', + 'yield', + 'dynamic', + 'implements', + 'set', +]; + +void main(List args) { + if (_isHelpCommand(args)) { + _printHelperDisplay(); + } else { + generateSvgData(_generateOption(args)); + } +} + +bool _isHelpCommand(List args) { + return args.length == 1 && (args[0] == '--help' || args[0] == '-h'); +} + +void _printHelperDisplay() { + final parser = _generateArgParser(null); + log(parser.usage); +} + +Options _generateOption(List args) { + final generateOptions = Options(); + _generateArgParser(generateOptions).parse(args); + return generateOptions; +} + +ArgParser _generateArgParser(Options? generateOptions) { + final parser = ArgParser() + ..addOption( + 'source-dir', + abbr: 'S', + defaultsTo: '/assets/flowy_icons', + callback: (String? x) => generateOptions!.sourceDir = x, + help: 'Folder containing localization files', + ) + ..addOption( + 'output-dir', + abbr: 'O', + defaultsTo: '/lib/generated', + callback: (String? x) => generateOptions!.outputDir = x, + help: 'Output folder stores for the generated file', + ) + ..addOption( + 'name', + abbr: 'N', + defaultsTo: 'flowy_svgs.g.dart', + callback: (String? x) => generateOptions!.outputFile = x, + help: 'The name of the output file that this tool will generate', + ); + + return parser; +} + +Directory source(Options options) => Directory( + [ + Directory.current.path, + Directory.fromUri( + Uri.file( + options.sourceDir!, + windows: Platform.isWindows, + ), + ).path, + ].join(), + ); + +File output(Options options) => File( + [ + Directory.current.path, + Directory.fromUri( + Uri.file(options.outputDir!, windows: Platform.isWindows), + ).path, + Platform.pathSeparator, + File.fromUri( + Uri.file( + options.outputFile!, + windows: Platform.isWindows, + ), + ).path, + ].join(), + ); + +/// generates the svg data +Future generateSvgData(Options options) async { + // the source directory that this is targeting + final src = source(options); + + // the output directory that this is targeting + final out = output(options); + + var files = await dirContents(src); + files = files.where((f) => f.path.contains('.svg')).toList(); + + await generate(files, out, options); +} + +/// List the contents of the directory +Future> dirContents(Directory dir) { + final files = []; + final completer = Completer>(); + + dir.list(recursive: true).listen( + files.add, + onDone: () => completer.complete(files), + ); + return completer.future; +} + +/// Generate the abstract class for the FlowySvg data. +Future generate( + List files, + File output, + Options options, +) async { + final generated = File(output.path); + + // create the output file if it doesn't exist + if (!generated.existsSync()) { + generated.createSync(recursive: true); + } + + // content of the generated file + final builder = StringBuffer()..writeln(prelude); + files.whereType().forEach( + (element) => builder.writeln(lineFor(element, options)), + ); + builder.writeln(postlude); + + generated.writeAsStringSync(builder.toString()); +} + +String lineFor(File file, Options options) { + final name = varNameFor(file, options); + return " static const $name = FlowySvgData('${pathFor(file)}');"; +} + +String pathFor(File file) { + final relative = path.relative(file.path, from: Directory.current.path); + final uri = Uri.file(relative); + return uri.toFilePath(windows: false); +} + +String varNameFor(File file, Options options) { + final from = source(options).path; + + final relative = Uri.file(path.relative(file.path, from: from)); + + final parts = relative.pathSegments; + + final cleaned = parts.map(clean).toList(); + + var simplified = cleaned.reversed + // join all cleaned path segments with an underscore + .join('_') + // there are some cases where the segment contains a dart reserved keyword + // in this case, the path will be suffixed with an underscore which means + // there will be a double underscore, so we have to replace the double + // underscore with one underscore + .replaceAll(RegExp('_+'), '_'); + + // rename icon based on relative path folder name (16x, 24x, etc.) + for (final key in sizeMap.keys) { + simplified = simplified.replaceAll(key, sizeMap[key]!); + } + + return simplified; +} + +const sizeMap = {r'$16x': 's', r'$24x': 'm', r'$32x': 'lg', r'$40x': 'xl'}; + +/// cleans the path segment before rejoining the path into a variable name +String clean(String segment) { + final cleaned = segment + // replace all dashes with underscores (dash is invalid in + // a variable name) + .replaceAll('-', '_') + // replace all spaces with an underscore + .replaceAll(RegExp(r'\s+'), '_') + // replace all file extensions with an empty string + .replaceAll(RegExp(r'\.[^.]*$'), '') + // convert everything to lower case + .toLowerCase(); + + if (languageKeywords.contains(cleaned)) { + return '${cleaned}_'; + } else if (cleaned.startsWith(RegExp('[0-9]'))) { + return '\$$cleaned'; + } + return cleaned; +} + +/// The prelude for the generated file +const prelude = ''' +// DO NOT EDIT. This code is generated by the flowy_svg script + +// import the widget with from this package +import 'package:flowy_svg/flowy_svg.dart'; + +// export as convenience to the programmer +export 'package:flowy_svg/flowy_svg.dart'; + +/// A class to easily list all the svgs in the app +class FlowySvgs {'''; + +/// The postlude for the generated file +const postlude = ''' +} +'''; diff --git a/frontend/appflowy_flutter/packages/flowy_svg/bin/options.dart b/frontend/appflowy_flutter/packages/flowy_svg/bin/options.dart new file mode 100644 index 0000000000000..d3476975d4cd1 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/bin/options.dart @@ -0,0 +1,21 @@ +/// The options for the command line tool +class Options { + /// The source directory which the tool will use to generate the output file + String? sourceDir; + + /// The output directory which the tool will use to output the file(s) + String? outputDir; + + /// The name of the file that will be generated + String? outputFile; + + @override + String toString() { + return ''' +Options: + sourceDir: $sourceDir + outputDir: $outputDir + name: $outputFile +'''; + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_svg/coverage_badge.svg b/frontend/appflowy_flutter/packages/flowy_svg/coverage_badge.svg new file mode 100644 index 0000000000000..499e98ce2faff --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/frontend/appflowy_flutter/packages/flowy_svg/lib/flowy_svg.dart b/frontend/appflowy_flutter/packages/flowy_svg/lib/flowy_svg.dart new file mode 100644 index 0000000000000..8e32c3e97c31f --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/lib/flowy_svg.dart @@ -0,0 +1,4 @@ +/// A Flutter package to generate Dart code for SVG files. +library flowy_svg; + +export 'src/flowy_svg.dart'; diff --git a/frontend/appflowy_flutter/packages/flowy_svg/lib/src/flowy_svg.dart b/frontend/appflowy_flutter/packages/flowy_svg/lib/src/flowy_svg.dart new file mode 100644 index 0000000000000..cba112dc2bfb6 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/lib/src/flowy_svg.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +/// The class for FlowySvgData that the code generator will implement +class FlowySvgData { + /// The svg data + const FlowySvgData( + this.path, + ); + + /// The path to the svg data in appflowy assets/images + final String path; +} + +/// For icon that needs to change color when it is on hovered +/// +/// Get the hover color from ThemeData +class FlowySvg extends StatelessWidget { + /// Construct a FlowySvg Widget + const FlowySvg( + this.svg, { + super.key, + this.size, + this.color, + this.blendMode = BlendMode.srcIn, + this.opacity, + this.svgString, + }); + + /// Construct a FlowySvg Widget from a string + factory FlowySvg.string( + String svgString, { + Key? key, + Size? size, + Color? color, + BlendMode? blendMode = BlendMode.srcIn, + double? opacity, + }) { + return FlowySvg( + const FlowySvgData(''), + key: key, + size: size, + color: color, + blendMode: blendMode, + opacity: opacity, + svgString: svgString, + ); + } + + /// The data for the flowy svg. Will be generated by the generator in this + /// package within bin/flowy_svg.dart + final FlowySvgData svg; + + /// The size of the svg + final Size? size; + + /// The svg string + final String? svgString; + + /// The color of the svg. + /// + /// This property will not be applied to the underlying svg widget if the + /// blend mode is null, but the blend mode defaults to [BlendMode.srcIn] + /// if it is not explicitly set to null. + final Color? color; + + /// The blend mode applied to the svg. + /// + /// If the blend mode is null then the icon color will not be applied. + /// Set both the icon color and blendMode in order to apply color to the + /// svg widget. + final BlendMode? blendMode; + + /// The opacity of the svg + /// + /// if null then use the opacity of the iconColor + final double? opacity; + + @override + Widget build(BuildContext context) { + Color? iconColor = color ?? Theme.of(context).iconTheme.color; + if (opacity != null) { + iconColor = iconColor?.withOpacity(opacity!); + } + + final textScaleFactor = MediaQuery.textScalerOf(context).scale(1); + + final Widget svg; + + if (svgString != null) { + svg = SvgPicture.string( + svgString!, + width: size?.width, + height: size?.height, + colorFilter: iconColor != null && blendMode != null + ? ColorFilter.mode( + iconColor, + blendMode!, + ) + : null, + ); + } else { + svg = SvgPicture.asset( + _normalized(), + width: size?.width, + height: size?.height, + colorFilter: iconColor != null && blendMode != null + ? ColorFilter.mode( + iconColor, + blendMode!, + ) + : null, + ); + } + + return Transform.scale( + scale: textScaleFactor, + child: SizedBox( + width: size?.width, + height: size?.height, + child: svg, + ), + ); + } + + /// If the SVG's path does not start with `assets/`, it is + /// normalized and directed to `assets/images/` + /// + /// If the SVG does not end with `.svg`, then we append the file extension + /// + String _normalized() { + var path = svg.path; + + if (!path.toLowerCase().startsWith('assets/')) { + path = 'assets/images/$path'; + } + + if (!path.toLowerCase().endsWith('.svg')) { + path = '$path.svg'; + } + + return path; + } +} diff --git a/frontend/appflowy_flutter/packages/flowy_svg/pubspec.yaml b/frontend/appflowy_flutter/packages/flowy_svg/pubspec.yaml new file mode 100644 index 0000000000000..5f012d50147f7 --- /dev/null +++ b/frontend/appflowy_flutter/packages/flowy_svg/pubspec.yaml @@ -0,0 +1,18 @@ +name: flowy_svg +description: AppFlowy Svgs +version: 0.1.0+1 +publish_to: none + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: 3.10.0 + +dependencies: + args: ^2.4.2 + flutter: + sdk: flutter + flutter_svg: ^2.0.7 + path: ^1.8.3 + +dev_dependencies: + very_good_analysis: ^5.0.0 diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock new file mode 100644 index 0000000000000..87a5128960f89 --- /dev/null +++ b/frontend/appflowy_flutter/pubspec.lock @@ -0,0 +1,2407 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + animations: + dependency: transitive + description: + name: animations + sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb + url: "https://pub.dev" + source: hosted + version: "2.0.11" + any_date: + dependency: "direct main" + description: + name: any_date + sha256: "3981efcc15edd1673bcfc1aec298cc6079029fbffb3734c7eae8ceeb878f911e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "3ced568a5d9e309e99af71285666f1f3117bddd0bd5b3317979dccc1a40cada4" + url: "https://pub.dev" + source: hosted + version: "3.5.1" + appflowy_backend: + dependency: "direct main" + description: + path: "packages/appflowy_backend" + relative: true + source: path + version: "0.0.1" + appflowy_board: + dependency: "direct main" + description: + path: "." + ref: "5517c8704c0dbeaeda5601e9baadb4cc2b29990d" + resolved-ref: "5517c8704c0dbeaeda5601e9baadb4cc2b29990d" + url: "https://github.com/AppFlowy-IO/appflowy-board.git" + source: git + version: "0.1.2" + appflowy_editor: + dependency: "direct main" + description: + path: "." + ref: c68e5f6 + resolved-ref: c68e5f6c585205083e27e875b822656425b2853f + url: "https://github.com/AppFlowy-IO/appflowy-editor.git" + source: git + version: "4.0.0" + appflowy_editor_plugins: + dependency: "direct main" + description: + path: "packages/appflowy_editor_plugins" + ref: "8047c21" + resolved-ref: "8047c21868273d544684522eb61e4ac2d2041409" + url: "https://github.com/AppFlowy-IO/AppFlowy-plugins.git" + source: git + version: "0.0.6" + appflowy_popover: + dependency: "direct main" + description: + path: "packages/appflowy_popover" + relative: true + source: path + version: "0.0.1" + appflowy_result: + dependency: "direct main" + description: + path: "packages/appflowy_result" + relative: true + source: path + version: "0.0.1" + archive: + dependency: "direct main" + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + auto_size_text_field: + dependency: "direct main" + description: + name: auto_size_text_field + sha256: "41c90b2270e38edc6ce5c02e5a17737a863e65e246bdfc94565a38f3ec399144" + url: "https://pub.dev" + source: hosted + version: "2.2.4" + avatar_stack: + dependency: "direct main" + description: + name: avatar_stack + sha256: e4a1576f7478add964bbb8aa5e530db39288fbbf81c30c4fb4b81162dd68aa49 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: ab180ce22c6555d77d45f0178a523669db67f95856e3378259ef2ffeb43e6003 + url: "https://pub.dev" + source: hosted + version: "2.2.8" + bidi: + dependency: transitive + description: + name: bidi + sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d" + url: "https://pub.dev" + source: hosted + version: "2.0.12" + bitsdojo_window: + dependency: "direct main" + description: + name: bitsdojo_window + sha256: "88ef7765dafe52d97d7a3684960fb5d003e3151e662c18645c1641c22b873195" + url: "https://pub.dev" + source: hosted + version: "0.1.6" + bitsdojo_window_linux: + dependency: transitive + description: + name: bitsdojo_window_linux + sha256: "9519c0614f98be733e0b1b7cb15b827007886f6fe36a4fb62cf3d35b9dd578ab" + url: "https://pub.dev" + source: hosted + version: "0.1.4" + bitsdojo_window_macos: + dependency: transitive + description: + name: bitsdojo_window_macos + sha256: f7c5be82e74568c68c5b8449e2c5d8fd12ec195ecd70745a7b9c0f802bb0268f + url: "https://pub.dev" + source: hosted + version: "0.1.4" + bitsdojo_window_platform_interface: + dependency: transitive + description: + name: bitsdojo_window_platform_interface + sha256: "65daa015a0c6dba749bdd35a0f092e7a8ba8b0766aa0480eb3ef808086f6e27c" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + bitsdojo_window_windows: + dependency: transitive + description: + name: bitsdojo_window_windows + sha256: fa982cf61ede53f483e50b257344a1c250af231a3cdc93a7064dd6dc0d720b68 + url: "https://pub.dev" + source: hosted + version: "0.1.6" + bloc: + dependency: "direct main" + description: + name: bloc + sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + url: "https://pub.dev" + source: hosted + version: "8.1.4" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2" + url: "https://pub.dev" + source: hosted + version: "9.1.7" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + url: "https://pub.dev" + source: hosted + version: "1.1.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "644dc98a0f179b872f612d3eb627924b578897c629788e858157fa5e704ca0c7" + url: "https://pub.dev" + source: hosted + version: "2.4.11" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: e3c79f69a64bdfcd8a776a3c28db4eb6e3fb5356d013ae5eb2e52007706d5dbe + url: "https://pub.dev" + source: hosted + version: "7.3.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + url: "https://pub.dev" + source: hosted + version: "8.9.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + calendar_view: + dependency: "direct main" + description: + path: "." + ref: "6fe0c98" + resolved-ref: "6fe0c989289b077569858d5472f3f7ec05b7746f" + url: "https://github.com/Xazin/flutter_calendar_view" + source: git + version: "1.0.5" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + url: "https://pub.dev" + source: hosted + version: "4.10.0" + collection: + dependency: "direct main" + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "224a77051d52a11fbad53dd57827594d3bd24f945af28bd70bab376d68d437f0" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a + url: "https://pub.dev" + source: hosted + version: "1.2.4" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: c1fb2dce3c0085f39dc72668e85f8e0210ec7de05345821ff58530567df345a5 + url: "https://pub.dev" + source: hosted + version: "1.9.2" + cross_cache: + dependency: transitive + description: + name: cross_cache + sha256: ed30348320a7fefe4195c26cfcbabc76b7108ce3d364c4dd7c1b1c681a4cfe28 + url: "https://pub.dev" + source: hosted + version: "0.0.2" + cross_file: + dependency: "direct main" + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 + url: "https://pub.dev" + source: hosted + version: "3.0.5" + csslib: + dependency: transitive + description: + name: csslib + sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + url: "https://pub.dev" + source: hosted + version: "2.3.6" + dbus: + dependency: transitive + description: + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + defer_pointer: + dependency: "direct main" + description: + name: defer_pointer + sha256: d69e6f8c1d0f052d2616cc1db3782e0ea73f42e4c6f6122fd1a548dfe79faf02 + url: "https://pub.dev" + source: hosted + version: "0.0.2" + desktop_drop: + dependency: "direct main" + description: + name: desktop_drop + sha256: d55a010fe46c8e8fcff4ea4b451a9ff84a162217bdb3b2a0aa1479776205e15d + url: "https://pub.dev" + source: hosted + version: "0.4.4" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 + url: "https://pub.dev" + source: hosted + version: "10.1.2" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + diffutil_dart: + dependency: "direct main" + description: + name: diffutil_dart + sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + dio: + dependency: transitive + description: + name: dio + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + dotted_border: + dependency: "direct main" + description: + name: dotted_border + sha256: "108837e11848ca776c53b30bc870086f84b62ed6e01c503ed976e8f8c7df9c04" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + easy_debounce: + dependency: transitive + description: + name: easy_debounce + sha256: f082609cfb8f37defb9e37fc28bc978c6712dedf08d4c5a26f820fa10165a236 + url: "https://pub.dev" + source: hosted + version: "2.0.3" + easy_localization: + dependency: "direct main" + description: + name: easy_localization + sha256: fa59bcdbbb911a764aa6acf96bbb6fa7a5cf8234354fc45ec1a43a0349ef0201 + url: "https://pub.dev" + source: hosted + version: "3.0.7" + easy_logger: + dependency: transitive + description: + name: easy_logger + sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7 + url: "https://pub.dev" + source: hosted + version: "0.0.2" + envied: + dependency: "direct main" + description: + name: envied + sha256: bbff9c76120e4dc5e2e36a46690cf0a26feb65e7765633f4e8d916bcd173a450 + url: "https://pub.dev" + source: hosted + version: "0.5.4+1" + envied_generator: + dependency: "direct dev" + description: + name: envied_generator + sha256: "517b70de08d13dcd40e97b4e5347e216a0b1c75c99e704f3c85c0474a392d14a" + url: "https://pub.dev" + source: hosted + version: "0.5.4+1" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" + expandable: + dependency: "direct main" + description: + name: expandable + sha256: "9604d612d4d1146dafa96c6d8eec9c2ff0994658d6d09fed720ab788c7f5afc2" + url: "https://pub.dev" + source: hosted + version: "5.0.1" + extended_text_field: + dependency: "direct main" + description: + name: extended_text_field + sha256: "954c7eea1e82728a742f7ddf09b9a51cef087d4f52b716ba88cb3eb78ccd7c6e" + url: "https://pub.dev" + source: hosted + version: "15.0.0" + extended_text_library: + dependency: "direct main" + description: + name: extended_text_library + sha256: "55d09098ec56fab0d9a8a68950ca0bbf2efa1327937f7cec6af6dfa066234829" + url: "https://pub.dev" + source: hosted + version: "12.0.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: "direct main" + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + file_picker: + dependency: transitive + description: + name: file_picker + sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12" + url: "https://pub.dev" + source: hosted + version: "8.1.2" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" + url: "https://pub.dev" + source: hosted + version: "0.9.2+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: f42eacb83b318e183b1ae24eead1373ab1334084404c8c16e0354f9a3e55d385 + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "2ad726953f6e8affbc4df8dc78b77c3b4a060967a291e528ef72ae846c60fb69" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + fixnum: + dependency: "direct main" + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flex_color_picker: + dependency: "direct main" + description: + name: flex_color_picker + sha256: "809af4ec82ede3b140ed0219b97d548de99e47aa4b99b14a10f705a2dbbcba5e" + url: "https://pub.dev" + source: hosted + version: "3.5.1" + flex_seed_scheme: + dependency: transitive + description: + name: flex_seed_scheme + sha256: "7d97ba5c20f0e5cb1e3e2c17c865e1f797d129de31fc1f75d2dcce9470d6373c" + url: "https://pub.dev" + source: hosted + version: "3.3.0" + flowy_infra: + dependency: "direct main" + description: + path: "packages/flowy_infra" + relative: true + source: path + version: "0.0.1" + flowy_infra_ui: + dependency: "direct main" + description: + path: "packages/flowy_infra_ui" + relative: true + source: path + version: "0.0.1" + flowy_infra_ui_platform_interface: + dependency: transitive + description: + path: "packages/flowy_infra_ui/flowy_infra_ui_platform_interface" + relative: true + source: path + version: "0.0.1" + flowy_svg: + dependency: "direct main" + description: + path: "packages/flowy_svg" + relative: true + source: path + version: "0.1.0+1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7c8a6594a9252dad30cc2ef16e33270b6248c4dedc3b3d06c86c4f3f4dc05ae5" + url: "https://pub.dev" + source: hosted + version: "4.5.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a + url: "https://pub.dev" + source: hosted + version: "8.1.6" + flutter_cache_manager: + dependency: "direct main" + description: + path: flutter_cache_manager + ref: HEAD + resolved-ref: fbab857b1b1d209240a146d32f496379b9f62276 + url: "https://github.com/LucasXu0/flutter_cache_manager.git" + source: git + version: "3.3.1" + flutter_chat_core: + dependency: "direct main" + description: + name: flutter_chat_core + sha256: "14557aaac7c71b80c279eca41781d214853940cf01727934c742b5845c42dd1e" + url: "https://pub.dev" + source: hosted + version: "0.0.2" + flutter_chat_types: + dependency: transitive + description: + name: flutter_chat_types + sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9 + url: "https://pub.dev" + source: hosted + version: "3.6.2" + flutter_chat_ui: + dependency: "direct main" + description: + name: flutter_chat_ui + sha256: "2afd22eaebaf0f6ec8425048921479c3dd1a229604015dca05b174c6e8e44292" + url: "https://pub.dev" + source: hosted + version: "2.0.0-dev.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_emoji_mart: + dependency: "direct main" + description: + path: "." + ref: "8a9fa49" + resolved-ref: "8a9fa491cb3b86baf78b0a33c2c37a29d1cae028" + url: "https://github.com/LucasXu0/emoji_mart.git" + source: git + version: "1.0.2" + flutter_highlight: + dependency: transitive + description: + name: flutter_highlight + sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + flutter_link_previewer: + dependency: transitive + description: + name: flutter_link_previewer + sha256: "007069e60f42419fb59872beb7a3cc3ea21e9f1bdff5d40239f376fa62ca9f20" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + flutter_linkify: + dependency: transitive + description: + name: flutter_linkify + sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_math_fork: + dependency: "direct main" + description: + name: flutter_math_fork + sha256: "284bab89b2fbf1bc3a0baf13d011c1dd324d004e35d177626b77f2fc056366ac" + url: "https://pub.dev" + source: hosted + version: "0.7.3" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "9ee02950848f61c4129af3d6ec84a1cfc0e47931abc746b03e7a3bc3e8ff6eda" + url: "https://pub.dev" + source: hosted + version: "2.0.22" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "02750b545c01ff4d8e9bbe8f27a7731aa3778402506c67daa1de7f5fc3f4befe" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + flutter_slidable: + dependency: "direct main" + description: + name: flutter_slidable + sha256: "2c5611c0b44e20d180e4342318e1bbc28b0a44ad2c442f5df16962606fd3e8e3" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + flutter_staggered_grid_view: + dependency: "direct main" + description: + name: flutter_staggered_grid_view + sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + flutter_sticky_header: + dependency: transitive + description: + name: flutter_sticky_header + sha256: "017f398fbb45a589e01491861ca20eb6570a763fd9f3888165a978e11248c709" + url: "https://pub.dev" + source: hosted + version: "0.6.5" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "7b4ca6cf3304575fe9c8ec64813c8d02ee41d2afe60bcfe0678bcb5375d596a2" + url: "https://pub.dev" + source: hosted + version: "2.0.10+1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_tex: + dependency: "direct main" + description: + name: flutter_tex + sha256: "816074d8a49dd2301704aaf481f9b052b935b8790018a88b69a60d003d5e89e4" + url: "https://pub.dev" + source: hosted + version: "4.0.9" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + sha256: "95f349437aeebe524ef7d6c9bde3e6b4772717cf46a0eb6a3ceaddc740b297cc" + url: "https://pub.dev" + source: hosted + version: "8.2.8" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1 + url: "https://pub.dev" + source: hosted + version: "2.5.2" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 + url: "https://pub.dev" + source: hosted + version: "7.7.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "2ddb88e9ad56ae15ee144ed10e33886777eb5ca2509a914850a5faa7b52ff459" + url: "https://pub.dev" + source: hosted + version: "14.2.7" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" + highlight: + dependency: "direct main" + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + hive: + dependency: transitive + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hotkey_manager: + dependency: "direct main" + description: + name: hotkey_manager + sha256: "8aaa0aeaca7015b8c561a58d02eb7ebba95e93357fc9540398c5751ee24afd7c" + url: "https://pub.dev" + source: hosted + version: "0.1.8" + html: + dependency: transitive + description: + name: html + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" + source: hosted + version: "0.15.4" + http: + dependency: "direct main" + description: + name: http + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + iconsax_flutter: + dependency: transitive + description: + name: iconsax_flutter + sha256: "95b65699da8ea98f87c5d232f06b0debaaf1ec1332b697e4d90969ec9a93037d" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + image: + dependency: transitive + description: + name: image + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + url: "https://pub.dev" + source: hosted + version: "4.3.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "8c5abf0dcc24fe6e8e0b4a5c0b51a5cf30cefdf6407a3213dae61edc75a70f56" + url: "https://pub.dev" + source: hosted + version: "0.8.12+12" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "65d94623e15372c5c51bebbcb820848d7bcb323836e12dfdba60b5d3a8b39e50" + url: "https://pub.dev" + source: hosted + version: "3.0.5" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "6703696ad49f5c3c8356d576d7ace84d1faf459afb07accbb0fae780753ff447" + url: "https://pub.dev" + source: hosted + version: "0.8.12" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + irondash_engine_context: + dependency: transitive + description: + name: irondash_engine_context + sha256: cd7b769db11a2b5243b037c8a9b1ecaef02e1ae27a2d909ffa78c1dad747bb10 + url: "https://pub.dev" + source: hosted + version: "0.5.4" + irondash_message_channel: + dependency: transitive + description: + name: irondash_message_channel + sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060 + url: "https://pub.dev" + source: hosted + version: "0.7.0" + isolates: + dependency: transitive + description: + name: isolates + sha256: ce89e4141b27b877326d3715be2dceac7a7ba89f3229785816d2d318a75ddf28 + url: "https://pub.dev" + source: hosted + version: "3.0.3+8" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b + url: "https://pub.dev" + source: hosted + version: "6.8.0" + keyboard_height_plugin: + dependency: "direct main" + description: + name: keyboard_height_plugin + sha256: "3a51c8ebb43465ebe0b3bad17f3b6d945421e58011f3f5a08134afe69a3d775f" + url: "https://pub.dev" + source: hosted + version: "0.1.5" + leak_tracker: + dependency: "direct main" + description: + name: leak_tracker + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + url: "https://pub.dev" + source: hosted + version: "10.0.4" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + linked_scroll_controller: + dependency: "direct main" + description: + name: linked_scroll_controller + sha256: e6020062bcf4ffc907ee7fd090fa971e65d8dfaac3c62baf601a3ced0b37986a + url: "https://pub.dev" + source: hosted + version: "0.2.0" + linkify: + dependency: transitive + description: + name: linkify + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + lint: + dependency: transitive + description: + name: lint + sha256: "4a539aa34ec5721a2c7574ae2ca0336738ea4adc2a34887d54b7596310b33c85" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + lints: + dependency: transitive + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + loading_indicator: + dependency: transitive + description: + name: loading_indicator + sha256: a101ffb2aa3e646137d7810bfa90b50525dd3f72c01235b6df7491cf6af6f284 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + local_notifier: + dependency: "direct main" + description: + name: local_notifier + sha256: f6cfc933c6fbc961f4e52b5c880f68e41b2d3cd29aad557cc654fd211093a025 + url: "https://pub.dev" + source: hosted + version: "0.1.6" + logger: + dependency: transitive + description: + name: logger + sha256: "697d067c60c20999686a0add96cf6aba723b3aa1f83ecf806a8097231529ec32" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + markdown: + dependency: "direct main" + description: + name: markdown + sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + url: "https://pub.dev" + source: hosted + version: "7.2.2" + markdown_widget: + dependency: "direct main" + description: + name: markdown_widget + sha256: "216dced98962d7699a265344624bc280489d739654585ee881c95563a3252fac" + url: "https://pub.dev" + source: hosted + version: "2.3.2+6" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + meta: + dependency: transitive + description: + name: meta + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + mime: + dependency: "direct main" + description: + name: mime + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + mockito: + dependency: transitive + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + nanoid: + dependency: "direct main" + description: + name: nanoid + sha256: be3f8752d9046c825df2f3914195151eb876f3ad64b9d833dd0b799b77b8759e + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + numerus: + dependency: "direct main" + description: + name: numerus + sha256: "49cd96fe774dd1f574fc9117ed67e8a2b06a612f723e87ef3119456a7729d837" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + open_filex: + dependency: "direct main" + description: + name: open_filex + sha256: ba425ea49affd0a98a234aa9344b9ea5d4c4f7625a1377961eae9fe194c3d523 + url: "https://pub.dev" + source: hosted + version: "4.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: a75164ade98cb7d24cfd0a13c6408927c6b217fa60dee5a7ff5c116a58f28918 + url: "https://pub.dev" + source: hosted + version: "8.0.2" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + path: + dependency: "direct main" + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_drawing: + dependency: transitive + description: + name: path_drawing + sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977 + url: "https://pub.dev" + source: hosted + version: "1.0.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf + url: "https://pub.dev" + source: hosted + version: "1.0.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 + url: "https://pub.dev" + source: hosted + version: "2.1.4" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "6f01f8e37ec30b07bc424b4deabac37cacb1bc7e2e515ad74486039918a37eb7" + url: "https://pub.dev" + source: hosted + version: "2.2.10" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + url: "https://pub.dev" + source: hosted + version: "2.4.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + pausable_timer: + dependency: transitive + description: + name: pausable_timer + sha256: "6ef1a95441ec3439de6fb63f39a011b67e693198e7dae14e20675c3c00e86074" + url: "https://pub.dev" + source: hosted + version: "3.1.0+3" + pdf: + dependency: transitive + description: + name: pdf + sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07" + url: "https://pub.dev" + source: hosted + version: "3.11.1" + percent_indicator: + dependency: "direct main" + description: + name: percent_indicator + sha256: c37099ad833a883c9d71782321cb65c3a848c21b6939b6185f0ff6640d05814c + url: "https://pub.dev" + source: hosted + version: "4.2.3" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + url: "https://pub.dev" + source: hosted + version: "11.3.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "76e4ab092c1b240d31177bb64d2b0bea43f43d0e23541ec866151b9f7b2490fa" + url: "https://pub.dev" + source: hosted + version: "12.0.12" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 + url: "https://pub.dev" + source: hosted + version: "9.4.5" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: af26edbbb1f2674af65a8f4b56e1a6f526156bc273d0e65dd8075fab51c78851 + url: "https://pub.dev" + source: hosted + version: "0.1.3+2" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + pixel_snap: + dependency: transitive + description: + name: pixel_snap + sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0" + url: "https://pub.dev" + source: hosted + version: "0.1.5" + platform: + dependency: transitive + description: + name: platform + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + plugin_platform_interface: + dependency: "direct dev" + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + process: + dependency: transitive + description: + name: process + sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + protobuf: + dependency: "direct main" + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + reorderable_tabbar: + dependency: "direct main" + description: + name: reorderable_tabbar + sha256: dd19d7b6f60f0dec4be02ba0a2c860f9acbe5a392cb8b5b8c1417cbfcbfe923f + url: "https://pub.dev" + source: hosted + version: "1.0.6" + reorderables: + dependency: "direct main" + description: + name: reorderables + sha256: "004a886e4878df1ee27321831c838bc1c976311f4ca6a74ce7d561e506540a77" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + run_with_network_images: + dependency: "direct dev" + description: + name: run_with_network_images + sha256: "8bf2de4e5120ab24037eda09596408938aa8f5b09f6afabd49683bd01c7baa36" + url: "https://pub.dev" + source: hosted + version: "0.0.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" + source: hosted + version: "0.27.7" + scaled_app: + dependency: "direct main" + description: + name: scaled_app + sha256: a2ad9f22cf2200a5ce455b59c5ea7bfb09a84acfc52452d1db54f4958c99d76a + url: "https://pub.dev" + source: hosted + version: "2.3.0" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: "6ee02c8a1158e6dae7ca430da79436e3b1c9563c8cf02f524af997c201ac2b90" + url: "https://pub.dev" + source: hosted + version: "0.1.9" + scroll_to_index: + dependency: "direct main" + description: + name: scroll_to_index + sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + scrollable_positioned_list: + dependency: "direct main" + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" + sentry: + dependency: "direct main" + description: + name: sentry + sha256: "1af8308298977259430d118ab25be8e1dda626cdefa1e6ce869073d530d39271" + url: "https://pub.dev" + source: hosted + version: "8.8.0" + sentry_flutter: + dependency: "direct main" + description: + name: sentry_flutter + sha256: "18fe4d125c2d529bd6127200f0d2895768266a8c60b4fb50b2086fd97e1a4ab2" + url: "https://pub.dev" + source: hosted + version: "8.8.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "468c43f285207c84bcabf5737f33b914ceb8eb38398b91e5e3ad1698d1b72a52" + url: "https://pub.dev" + source: hosted + version: "10.0.2" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "6ababf341050edff57da8b6990f11f4e99eaba837865e2e6defe16d039619db5" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: c4b35f6cb8f63c147312c054ce7c2254c8066745125264f0c88739c417fc9d9f + url: "https://pub.dev" + source: hosted + version: "2.5.2" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sheet: + dependency: "direct main" + description: + path: sheet + ref: e44458d + resolved-ref: e44458d2359565324e117bb3d41da04f5e60362e + url: "https://github.com/jamesblasco/modal_bottom_sheet" + source: git + version: "1.0.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" + sized_context: + dependency: "direct main" + description: + name: sized_context + sha256: "9921e6c09e018132c3e1c6a18e14febbc1cc5c87a200d64ff7578cb49991f6e7" + url: "https://pub.dev" + source: hosted + version: "1.0.0+4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + sliver_tools: + dependency: transitive + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + url: "https://pub.dev" + source: hosted + version: "1.3.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d + url: "https://pub.dev" + source: hosted + version: "2.3.3+1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + string_validator: + dependency: "direct main" + description: + name: string_validator + sha256: a278d038104aa2df15d0e09c47cb39a49f907260732067d0034dc2f2e4e2ac94 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + styled_widget: + dependency: "direct main" + description: + name: styled_widget + sha256: "4d439802919b6ccf10d1488798656da8804633b03012682dd1c8ca70a084aa84" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + super_clipboard: + dependency: "direct main" + description: + name: super_clipboard + sha256: cfeb142360fac67e0da1ca339accb892eb790c6528a218a008eef1709d96ed0f + url: "https://pub.dev" + source: hosted + version: "0.8.22" + super_native_extensions: + dependency: transitive + description: + name: super_native_extensions + sha256: "6a7cfb7d212da7023b86fb99c736081e9c2cd982265d15dc5fe6381a32dbc875" + url: "https://pub.dev" + source: hosted + version: "0.8.22" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + synchronized: + dependency: "direct main" + description: + name: synchronized + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + url: "https://pub.dev" + source: hosted + version: "3.1.0+1" + tab_indicator_styler: + dependency: transitive + description: + name: tab_indicator_styler + sha256: "9e7e90367e20f71f3882fc6578fdcced35ab1c66ab20fcb623cdcc20d2796c76" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: "4ca32b2fc919452c9974abd4c6ea611a63e33b9e4f0b8c38dba3ac1f4a6549d1" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + url: "https://pub.dev" + source: hosted + version: "1.25.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + test_core: + dependency: transitive + description: + name: test_core + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + time: + dependency: "direct main" + description: + name: time + sha256: ad8e018a6c9db36cb917a031853a1aae49467a93e0d464683e029537d848c221 + url: "https://pub.dev" + source: hosted + version: "2.1.4" + timing: + dependency: transitive + description: + name: timing + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + toastification: + dependency: "direct main" + description: + name: toastification + sha256: "441adf261f03b82db7067cba349756f70e9e2c0b7276bcba856210742f85f394" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + universal_html: + dependency: transitive + description: + name: universal_html + sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" + url: "https://pub.dev" + source: hosted + version: "2.2.4" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + universal_platform: + dependency: "direct main" + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + unsplash_client: + dependency: "direct main" + description: + name: unsplash_client + sha256: "9827f4c1036b7a6ac8cb3f404ac179df7441eee69371d9b17f181817fe502fd7" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: f0c73347dfcfa5b3db8bc06e1502668265d39c08f310c29bff4e28eea9699f79 + url: "https://pub.dev" + source: hosted + version: "6.3.9" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: e2b9622b4007f97f504cd64c0128309dfb978ae66adbe944125ed9e1750f06af + url: "https://pub.dev" + source: hosted + version: "3.2.0" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + url_launcher_platform_interface: + dependency: "direct dev" + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + url_protocol: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "77a84201ed8ca50082f4248f3a373d053b1c0462" + url: "https://github.com/LucasXu0/flutter_url_protocol.git" + source: git + version: "1.0.0" + uuid: + dependency: "direct overridden" + description: + name: uuid + sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77 + url: "https://pub.dev" + source: hosted + version: "4.5.0" + value_layout_builder: + dependency: transitive + description: + name: value_layout_builder + sha256: "98202ec1807e94ac72725b7f0d15027afde513c55c69ff3f41bcfccb950831bc" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "32c3c684e02f9bc0afb0ae0aa653337a2fe022e8ab064bcd7ffda27a74e288e3" + url: "https://pub.dev" + source: hosted + version: "1.1.11+1" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da + url: "https://pub.dev" + source: hosted + version: "1.1.11+1" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81" + url: "https://pub.dev" + source: hosted + version: "1.1.11+1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + visibility_detector: + dependency: transitive + description: + name: visibility_detector + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 + url: "https://pub.dev" + source: hosted + version: "0.4.0+2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + url: "https://pub.dev" + source: hosted + version: "14.2.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: "6869c8786d179f929144b4a1f86e09ac0eddfe475984951ea6c634774c16b522" + url: "https://pub.dev" + source: hosted + version: "4.8.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: ed021f27ae621bc97a6019fb601ab16331a3db4bf8afa305e9f6689bdb3edced + url: "https://pub.dev" + source: hosted + version: "3.16.8" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: d937581d6e558908d7ae3dc1989c4f87b786891ab47bb9df7de548a151779d8d + url: "https://pub.dev" + source: hosted + version: "2.10.0" + webview_flutter_plus: + dependency: transitive + description: + name: webview_flutter_plus + sha256: "57ec757eada4e23bfb015f5d5f84a45108cb2c29b1e77e23956768cd5e0c8468" + url: "https://pub.dev" + source: hosted + version: "0.4.7" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "9c62cc46fa4f2d41e10ab81014c1de470a6c6f26051a2de32111b2ee55287feb" + url: "https://pub.dev" + source: hosted + version: "3.14.0" + win32: + dependency: transitive + description: + name: win32 + sha256: "68d1e89a91ed61ad9c370f9f8b6effed9ae5e0ede22a270bdfa6daf79fc2290a" + url: "https://pub.dev" + source: hosted + version: "5.5.4" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "723b7f851e5724c55409bb3d5a32b203b3afe8587eaf5dafb93a5fed8ecda0d6" + url: "https://pub.dev" + source: hosted + version: "1.1.4" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "8699323b30da4cdbe2aa2e7c9de567a6abd8a97d9a5c850a3c86dcd0b34bbfbf" + url: "https://pub.dev" + source: hosted + version: "0.3.9" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.4.0 <4.0.0" + flutter: ">=3.22.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml new file mode 100644 index 0000000000000..759c16e4740bb --- /dev/null +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -0,0 +1,255 @@ +name: appflowy +description: Bring projects, wikis, and teams together with AI. AppFlowy is an + AI collaborative workspace where you achieve more without losing control of + your data. The best open source alternative to Notion. +publish_to: "none" + +version: 0.7.9 + +environment: + flutter: ">=3.22.0" + sdk: ">=3.3.0 <4.0.0" + +dependencies: + any_date: ^1.0.4 + app_links: ^3.5.0 + appflowy_backend: + path: packages/appflowy_backend + appflowy_board: + git: + url: https://github.com/AppFlowy-IO/appflowy-board.git + ref: 5517c8704c0dbeaeda5601e9baadb4cc2b29990d + appflowy_editor: + appflowy_editor_plugins: + appflowy_popover: + path: packages/appflowy_popover + appflowy_result: + path: packages/appflowy_result + + archive: ^3.4.10 + auto_size_text_field: ^2.2.3 + avatar_stack: ^1.2.0 + + # BitsDojo Window for Windows + bitsdojo_window: ^0.1.6 + bloc: ^8.1.2 + cached_network_image: ^3.3.0 + calendar_view: + git: + url: https://github.com/Xazin/flutter_calendar_view + ref: "6fe0c98" + collection: ^1.17.1 + connectivity_plus: ^5.0.2 + cross_file: ^0.3.4+1 + + # Desktop Drop uses Cross File (XFile) data type + defer_pointer: ^0.0.2 + desktop_drop: ^0.4.4 + device_info_plus: + diffutil_dart: ^4.0.1 + dotted_border: ^2.0.0+3 + easy_localization: ^3.0.2 + envied: ^0.5.2 + equatable: ^2.0.5 + expandable: ^5.0.1 + extended_text_field: ^15.0.0 + extended_text_library: ^12.0.0 + file: ^7.0.0 + fixnum: ^1.1.0 + flex_color_picker: ^3.5.1 + flowy_infra: + path: packages/flowy_infra + flowy_infra_ui: + path: packages/flowy_infra_ui + flowy_svg: + path: packages/flowy_svg + flutter: + sdk: flutter + flutter_animate: ^4.5.0 + flutter_bloc: ^8.1.3 + flutter_cache_manager: ^3.3.1 + flutter_chat_core: ^0.0.2 + flutter_chat_ui: 2.0.0-dev.1 + flutter_emoji_mart: + git: + url: https://github.com/LucasXu0/emoji_mart.git + ref: "8a9fa49" + flutter_math_fork: ^0.7.3 + flutter_slidable: ^3.0.0 + + flutter_staggered_grid_view: ^0.7.0 + flutter_tex: ^4.0.9 + fluttertoast: ^8.2.6 + freezed_annotation: ^2.2.0 + get_it: ^7.6.0 + go_router: ^14.2.0 + google_fonts: ^6.1.0 + highlight: ^0.7.0 + hive_flutter: ^1.1.0 + hotkey_manager: ^0.1.7 + http: ^1.0.0 + image_picker: ^1.0.4 + + # third party packages + intl: ^0.19.0 + json_annotation: ^4.8.1 + keyboard_height_plugin: ^0.1.5 + leak_tracker: ^10.0.0 + linked_scroll_controller: ^0.2.0 + + # Notifications + # TODO: Consider implementing custom package + # to gather notification handling for all platforms + local_notifier: ^0.1.5 + markdown: + markdown_widget: ^2.3.2+6 + mime: ^1.0.6 + nanoid: ^1.0.0 + numerus: ^2.1.2 + + # Used to open local files on Mobile + open_filex: ^4.5.0 + package_info_plus: ^8.0.2 + path: ^1.8.3 + path_provider: ^2.0.15 + percent_indicator: ^4.2.3 + permission_handler: ^11.3.1 + protobuf: ^3.1.0 + provider: ^6.0.5 + reorderable_tabbar: ^1.0.6 + reorderables: ^0.6.0 + scaled_app: ^2.3.0 + scroll_to_index: ^3.0.1 + scrollable_positioned_list: ^0.3.8 + sentry: 8.8.0 + sentry_flutter: 8.8.0 + share_plus: ^10.0.2 + shared_preferences: ^2.2.2 + sheet: + sized_context: ^1.0.0+4 + string_validator: ^1.0.0 + styled_widget: ^0.4.1 + super_clipboard: ^0.8.4 + synchronized: ^3.1.0+1 + table_calendar: ^3.0.9 + time: ^2.1.3 + + toastification: ^2.0.0 + universal_platform: ^1.1.0 + unsplash_client: ^2.1.1 + url_launcher: ^6.1.11 + url_protocol: + + # Window Manager for MacOS and Linux + window_manager: ^0.3.9 + +dev_dependencies: + bloc_test: ^9.1.2 + build_runner: ^2.4.9 + envied_generator: ^0.5.2 + flutter_lints: ^4.0.0 + + flutter_test: + sdk: flutter + freezed: ^2.4.7 + integration_test: + sdk: flutter + json_serializable: ^6.7.1 + + mocktail: ^1.0.1 + + plugin_platform_interface: any + run_with_network_images: ^0.0.1 + url_launcher_platform_interface: any + +dependency_overrides: + http: ^1.0.0 + device_info_plus: ^10.1.0 + + url_protocol: + git: + url: https://github.com/LucasXu0/flutter_url_protocol.git + commit: 737681d + + appflowy_editor: + git: + url: https://github.com/AppFlowy-IO/appflowy-editor.git + ref: "c68e5f6" + + appflowy_editor_plugins: + git: + url: https://github.com/AppFlowy-IO/AppFlowy-plugins.git + path: "packages/appflowy_editor_plugins" + ref: "8047c21" + + sheet: + git: + url: https://github.com/jamesblasco/modal_bottom_sheet + ref: e44458d + path: sheet + + uuid: ^4.4.0 + + flutter_cache_manager: + git: + url: https://github.com/LucasXu0/flutter_cache_manager.git + commit: fbab857b1b1d209240a146d32f496379b9f62276 + path: flutter_cache_manager + +flutter: + generate: true + uses-material-design: true + + fonts: + - family: FlowyIconData + fonts: + - asset: assets/fonts/FlowyIconData.ttf + - family: Poppins + fonts: + - asset: assets/google_fonts/Poppins/Poppins-ExtraLight.ttf + weight: 100 + - asset: assets/google_fonts/Poppins/Poppins-Thin.ttf + weight: 200 + - asset: assets/google_fonts/Poppins/Poppins-Light.ttf + weight: 300 + - asset: assets/google_fonts/Poppins/Poppins-Regular.ttf + weight: 400 + - asset: assets/google_fonts/Poppins/Poppins-Medium.ttf + weight: 500 + - asset: assets/google_fonts/Poppins/Poppins-SemiBold.ttf + weight: 600 + - asset: assets/google_fonts/Poppins/Poppins-Bold.ttf + weight: 700 + - asset: assets/google_fonts/Poppins/Poppins-Black.ttf + weight: 800 + - asset: assets/google_fonts/Poppins/Poppins-ExtraBold.ttf + weight: 900 + - family: RobotoMono + fonts: + - asset: assets/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf + - asset: assets/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf + style: italic + + # To add assets to your application, add an assets section, like this: + assets: + - assets/images/ + - assets/images/appearance/ + - assets/images/built_in_cover_images/ + - assets/flowy_icons/ + - assets/flowy_icons/16x/ + - assets/flowy_icons/24x/ + - assets/flowy_icons/32x/ + - assets/flowy_icons/40x/ + - assets/images/emoji/ + - assets/images/login/ + - assets/translations/ + - assets/icons/icons.json + + # The following assets will be excluded in release. + # BEGIN: EXCLUDE_IN_RELEASE + - assets/test/workspaces/ + - assets/test/images/ + - assets/template/ + - assets/test/workspaces/markdowns/ + - assets/test/workspaces/database/ + # END: EXCLUDE_IN_RELEASE diff --git a/frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart new file mode 100644 index 0000000000000..d9cd57757ee03 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/appearance_test.dart @@ -0,0 +1,86 @@ +import 'package:appflowy/user/application/user_settings_service.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../util.dart'; + +void main() { + // ignore: unused_local_variable + late AppFlowyUnitTest context; + setUpAll(() async { + context = await AppFlowyUnitTest.ensureInitialized(); + }); + + group('$AppearanceSettingsCubit', () { + late AppearanceSettingsPB appearanceSetting; + late DateTimeSettingsPB dateTimeSettings; + + setUp(() async { + appearanceSetting = + await UserSettingsBackendService().getAppearanceSetting(); + dateTimeSettings = + await UserSettingsBackendService().getDateTimeSettings(); + await blocResponseFuture(); + }); + + blocTest( + 'default theme', + build: () => AppearanceSettingsCubit( + appearanceSetting, + dateTimeSettings, + AppTheme.fallback, + ), + verify: (bloc) { + expect(bloc.state.font, defaultFontFamily); + expect(bloc.state.themeMode, ThemeMode.system); + }, + ); + + blocTest( + 'save key/value', + build: () => AppearanceSettingsCubit( + appearanceSetting, + dateTimeSettings, + AppTheme.fallback, + ), + act: (bloc) { + bloc.setKeyValue("123", "456"); + }, + verify: (bloc) { + expect(bloc.getValue("123"), "456"); + }, + ); + + blocTest( + 'remove key/value', + build: () => AppearanceSettingsCubit( + appearanceSetting, + dateTimeSettings, + AppTheme.fallback, + ), + act: (bloc) { + bloc.setKeyValue("123", null); + }, + verify: (bloc) { + expect(bloc.getValue("123"), null); + }, + ); + + blocTest( + 'initial state uses fallback theme', + build: () => AppearanceSettingsCubit( + appearanceSetting, + dateTimeSettings, + AppTheme.fallback, + ), + verify: (bloc) { + expect(bloc.state.appTheme.themeName, AppTheme.fallback.themeName); + }, + ); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart new file mode 100644 index 0000000000000..2f369cd0cf81a --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/app_setting_test/document_appearance_test.dart @@ -0,0 +1,64 @@ +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + group('DocumentAppearanceCubit', () { + late SharedPreferences preferences; + late DocumentAppearanceCubit cubit; + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + }); + + setUp(() async { + preferences = await SharedPreferences.getInstance(); + cubit = DocumentAppearanceCubit(); + }); + + tearDown(() async { + await preferences.clear(); + await cubit.close(); + }); + + test('Initial state', () { + expect(cubit.state.fontSize, 16.0); + expect(cubit.state.fontFamily, defaultFontFamily); + }); + + test('Fetch document appearance from SharedPreferences', () async { + await preferences.setDouble(KVKeys.kDocumentAppearanceFontSize, 18.0); + await preferences.setString( + KVKeys.kDocumentAppearanceFontFamily, + 'Arial', + ); + + await cubit.fetch(); + + expect(cubit.state.fontSize, 18.0); + expect(cubit.state.fontFamily, 'Arial'); + }); + + test('Sync font size to SharedPreferences', () async { + await cubit.syncFontSize(20.0); + + final fontSize = + preferences.getDouble(KVKeys.kDocumentAppearanceFontSize); + expect(fontSize, 20.0); + expect(cubit.state.fontSize, 20.0); + }); + + test('Sync font family to SharedPreferences', () async { + await cubit.syncFontFamily('Helvetica'); + + final fontFamily = + preferences.getString(KVKeys.kDocumentAppearanceFontFamily); + expect(fontFamily, 'Helvetica'); + expect(cubit.state.fontFamily, 'Helvetica'); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/ask_ai_test/ask_ai_action_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/ask_ai_test/ask_ai_action_bloc_test.dart new file mode 100644 index 0000000000000..8c6f798656b32 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/ask_ai_test/ask_ai_action_bloc_test.dart @@ -0,0 +1,322 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/ai_client.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/error.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../util.dart'; + +const _aiResponse = 'UPDATED:'; + +class _MockAIRepository extends Mock implements AIRepository { + @override + Future streamCompletion({ + required String text, + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) onProcess, + required Future Function() onEnd, + required void Function(AIError error) onError, + }) async { + await onStart(); + final lines = text.split('\n\n'); + for (var i = 0; i < lines.length; i++) { + await onProcess('$_aiResponse ${lines[i]}\n\n'); + } + await onEnd(); + } +} + +class _MockAIRepositoryLess extends Mock implements AIRepository { + @override + Future streamCompletion({ + required String text, + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) onProcess, + required Future Function() onEnd, + required void Function(AIError error) onError, + }) async { + await onStart(); + // only return 1 line. + await onProcess('Hello World'); + await onEnd(); + } +} + +class _MockAIRepositoryMore extends Mock implements AIRepository { + @override + Future streamCompletion({ + required String text, + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) onProcess, + required Future Function() onEnd, + required void Function(AIError error) onError, + }) async { + await onStart(); + // return 10 lines + for (var i = 0; i < 10; i++) { + await onProcess('Hello World\n\n'); + } + await onEnd(); + } +} + +class _MockErrorRepository extends Mock implements AIRepository { + @override + Future streamCompletion({ + required String text, + required CompletionTypePB completionType, + required Future Function() onStart, + required Future Function(String text) onProcess, + required Future Function() onEnd, + required void Function(AIError error) onError, + }) async { + await onStart(); + onError( + const AIError( + message: 'Error', + code: AIErrorCode.aiResponseLimitExceeded, + ), + ); + } +} + +void main() { + group('AskAIActionBloc: ', () { + const text1 = '1. Select text to style using the toolbar menu.'; + const text2 = '2. Discover more styling options in Aa.'; + const text3 = + '3. AppFlowy empowers you to beautifully and effortlessly style your content.'; + + blocTest( + 'send request before the bloc is initialized', + build: () { + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + ], + ), + ); + final editorState = EditorState(document: document); + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + + final node = askAINode( + action: AskAIAction.makeItLonger, + content: [text1, text2, text3].join('\n'), + ); + return AskAIActionBloc( + node: node, + editorState: editorState, + action: AskAIAction.makeItLonger, + enableLogging: false, + ); + }, + act: (bloc) { + bloc.add(AskAIEvent.initial(Future.value(_MockAIRepository()))); + bloc.add(const AskAIEvent.rewrite()); + }, + expect: () => [ + isA() + .having((s) => s.loading, 'loading', true) + .having((s) => s.result, 'result', isEmpty), + isA() + .having((s) => s.loading, 'loading', false) + .having((s) => s.result, 'result', isNotEmpty) + .having((s) => s.result, 'result', contains('UPDATED:')), + isA().having((s) => s.loading, 'loading', false), + ], + ); + + blocTest( + 'exceed the ai response limit', + build: () { + const text1 = '1. Select text to style using the toolbar menu.'; + const text2 = '2. Discover more styling options in Aa.'; + const text3 = + '3. AppFlowy empowers you to beautifully and effortlessly style your content.'; + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + ], + ), + ); + final editorState = EditorState(document: document); + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + + final node = askAINode( + action: AskAIAction.makeItLonger, + content: [text1, text2, text3].join('\n'), + ); + return AskAIActionBloc( + node: node, + editorState: editorState, + action: AskAIAction.makeItLonger, + enableLogging: false, + ); + }, + act: (bloc) { + bloc.add(AskAIEvent.initial(Future.value(_MockErrorRepository()))); + bloc.add(const AskAIEvent.rewrite()); + }, + expect: () => [ + isA() + .having((s) => s.loading, 'loading', true) + .having((s) => s.result, 'result', isEmpty), + isA() + .having((s) => s.requestError, 'requestError', isNotNull) + .having( + (s) => s.requestError?.code, + 'requestError.code', + AIErrorCode.aiResponseLimitExceeded, + ), + ], + ); + + test('summary - the result contains the same number of paragraphs', + () async { + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + ], + ), + ); + final editorState = EditorState(document: document); + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + + final node = askAINode( + action: AskAIAction.makeItLonger, + content: [text1, text2, text3].join('\n\n'), + ); + final bloc = AskAIActionBloc( + node: node, + editorState: editorState, + action: AskAIAction.summarize, + enableLogging: false, + ); + bloc.add(AskAIEvent.initial(Future.value(_MockAIRepository()))); + await blocResponseFuture(); + bloc.add(const AskAIEvent.started()); + await blocResponseFuture(); + bloc.add(const AskAIEvent.replace()); + await blocResponseFuture(); + expect(editorState.document.root.children.length, 3); + expect( + editorState.getNodeAtPath([0])!.delta!.toPlainText(), + '$_aiResponse $text1', + ); + expect( + editorState.getNodeAtPath([1])!.delta!.toPlainText(), + '$_aiResponse $text2', + ); + expect( + editorState.getNodeAtPath([2])!.delta!.toPlainText(), + '$_aiResponse $text3', + ); + }); + + test('summary - the result less than the original text', () async { + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + ], + ), + ); + final editorState = EditorState(document: document); + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + + final node = askAINode( + action: AskAIAction.makeItLonger, + content: [text1, text2, text3].join('\n'), + ); + final bloc = AskAIActionBloc( + node: node, + editorState: editorState, + action: AskAIAction.summarize, + enableLogging: false, + ); + bloc.add(AskAIEvent.initial(Future.value(_MockAIRepositoryLess()))); + await blocResponseFuture(); + bloc.add(const AskAIEvent.started()); + await blocResponseFuture(); + bloc.add(const AskAIEvent.replace()); + await blocResponseFuture(); + expect(editorState.document.root.children.length, 1); + expect( + editorState.getNodeAtPath([0])!.delta!.toPlainText(), + 'Hello World', + ); + }); + + test('summary - the result more than the original text', () async { + final document = Document( + root: pageNode( + children: [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + ], + ), + ); + final editorState = EditorState(document: document); + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text3.length), + ); + + final node = askAINode( + action: AskAIAction.makeItLonger, + content: [text1, text2, text3].join('\n'), + ); + final bloc = AskAIActionBloc( + node: node, + editorState: editorState, + action: AskAIAction.summarize, + enableLogging: false, + ); + bloc.add(AskAIEvent.initial(Future.value(_MockAIRepositoryMore()))); + await blocResponseFuture(); + bloc.add(const AskAIEvent.started()); + await blocResponseFuture(); + bloc.add(const AskAIEvent.replace()); + await blocResponseFuture(); + expect(editorState.document.root.children.length, 10); + for (var i = 0; i < 10; i++) { + expect( + editorState.getNodeAtPath([i])!.delta!.toPlainText(), + 'Hello World', + ); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/create_card_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/create_card_test.dart new file mode 100644 index 0000000000000..4f855d4fb3101 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/create_card_test.dart @@ -0,0 +1,57 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'util.dart'; + +void main() { + late AppFlowyBoardTest boardTest; + + setUpAll(() async { + boardTest = await AppFlowyBoardTest.ensureInitialized(); + }); + + test('create kanban baord card', () async { + final context = await boardTest.createTestBoard(); + final databaseController = DatabaseController(view: context.gridView); + final boardBloc = BoardBloc( + databaseController: databaseController, + )..add(const BoardEvent.initial()); + await boardResponseFuture(); + + List groupIds = boardBloc.state.maybeMap( + orElse: () => const [], + ready: (value) => value.groupIds, + ); + String lastGroupId = groupIds.last; + + // the group at index 3 is the 'No status' group; + assert(boardBloc.groupControllers[lastGroupId]!.group.rows.isEmpty); + assert( + groupIds.length == 4, + 'but receive ${groupIds.length}', + ); + + boardBloc.add( + BoardEvent.createRow( + groupIds[3], + OrderObjectPositionTypePB.End, + null, + null, + ), + ); + await boardResponseFuture(); + + groupIds = boardBloc.state.maybeMap( + orElse: () => [], + ready: (value) => value.groupIds, + ); + lastGroupId = groupIds.last; + + assert( + boardBloc.groupControllers[lastGroupId]!.group.rows.length == 1, + 'but receive ${boardBloc.groupControllers[lastGroupId]!.group.rows.length}', + ); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/create_or_edit_field_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/create_or_edit_field_test.dart new file mode 100644 index 0000000000000..9131446347f88 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/create_or_edit_field_test.dart @@ -0,0 +1,80 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'util.dart'; + +void main() { + late AppFlowyBoardTest boardTest; + + setUpAll(() async { + boardTest = await AppFlowyBoardTest.ensureInitialized(); + }); + + test('create build-in kanban board test', () async { + final context = await boardTest.createTestBoard(); + final boardBloc = BoardBloc( + databaseController: DatabaseController(view: context.gridView), + )..add(const BoardEvent.initial()); + await boardResponseFuture(); + + assert(boardBloc.groupControllers.values.length == 4); + assert(context.fieldContexts.length == 2); + }); + + test('edit kanban board field name test', () async { + final context = await boardTest.createTestBoard(); + final boardBloc = BoardBloc( + databaseController: DatabaseController(view: context.gridView), + )..add(const BoardEvent.initial()); + await boardResponseFuture(); + + final fieldInfo = context.singleSelectFieldContext(); + + final editorBloc = FieldEditorBloc( + viewId: context.gridView.id, + fieldInfo: fieldInfo, + fieldController: context.fieldController, + isNew: false, + ); + await boardResponseFuture(); + + editorBloc.add(const FieldEditorEvent.renameField('Hello world')); + await boardResponseFuture(); + + // assert the groups were not changed + assert( + boardBloc.groupControllers.values.length == 4, + "Expected 4, but receive ${boardBloc.groupControllers.values.length}", + ); + + assert( + context.fieldContexts.length == 2, + "Expected 2, but receive ${context.fieldContexts.length}", + ); + }); + + test('create a new field in kanban board test', () async { + final context = await boardTest.createTestBoard(); + final boardBloc = BoardBloc( + databaseController: DatabaseController(view: context.gridView), + )..add(const BoardEvent.initial()); + await boardResponseFuture(); + + await context.createField(FieldType.Checkbox); + await boardResponseFuture(); + final checkboxField = context.fieldContexts.last.field; + assert(checkboxField.fieldType == FieldType.Checkbox); + + assert( + boardBloc.groupControllers.values.length == 4, + "Expected 4, but receive ${boardBloc.groupControllers.values.length}", + ); + + assert( + context.fieldContexts.length == 3, + "Expected 3, but receive ${context.fieldContexts.length}", + ); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_checkbox_field_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_checkbox_field_test.dart new file mode 100644 index 0000000000000..ad4d96f5eb15d --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_checkbox_field_test.dart @@ -0,0 +1,49 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/setting/group_bloc.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'util.dart'; + +void main() { + late AppFlowyBoardTest boardTest; + + setUpAll(() async { + boardTest = await AppFlowyBoardTest.ensureInitialized(); + }); + + // Group by checkbox field + test('group by checkbox field test', () async { + final context = await boardTest.createTestBoard(); + final boardBloc = BoardBloc( + databaseController: DatabaseController(view: context.gridView), + )..add(const BoardEvent.initial()); + await boardResponseFuture(); + + // assert the initial values + assert(boardBloc.groupControllers.values.length == 4); + assert(context.fieldContexts.length == 2); + + // create checkbox field + await context.createField(FieldType.Checkbox); + await boardResponseFuture(); + assert(context.fieldContexts.length == 3); + + // set group by checkbox + final checkboxField = context.fieldContexts.last.field; + final gridGroupBloc = DatabaseGroupBloc( + viewId: context.gridView.id, + databaseController: context.databaseController, + )..add(const DatabaseGroupEvent.initial()); + gridGroupBloc.add( + DatabaseGroupEvent.setGroupByField( + checkboxField.id, + checkboxField.fieldType, + ), + ); + await boardResponseFuture(); + + assert(boardBloc.groupControllers.values.length == 2); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_date_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_date_test.dart new file mode 100644 index 0000000000000..92998f9cc0899 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_date_test.dart @@ -0,0 +1,115 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/setting/group_bloc.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; + +import 'util.dart'; + +void main() { + late AppFlowyBoardTest boardTest; + + setUpAll(() async { + boardTest = await AppFlowyBoardTest.ensureInitialized(); + }); + + test('group by date field test', () async { + final context = await boardTest.createTestBoard(); + final boardBloc = BoardBloc( + databaseController: DatabaseController(view: context.gridView), + )..add(const BoardEvent.initial()); + await boardResponseFuture(); + + // assert the initial values + assert(boardBloc.groupControllers.values.length == 4); + assert(context.fieldContexts.length == 2); + + await context.createField(FieldType.DateTime); + await boardResponseFuture(); + assert(context.fieldContexts.length == 3); + + final dateField = context.fieldContexts.last.field; + final cellController = context.makeCellControllerFromFieldId(dateField.id) + as DateCellController; + final bloc = DateCellEditorBloc( + cellController: cellController, + reminderBloc: getIt(), + ); + await boardResponseFuture(); + + bloc.add(DateCellEditorEvent.updateDateTime(DateTime.now())); + await boardResponseFuture(); + + final gridGroupBloc = DatabaseGroupBloc( + viewId: context.gridView.id, + databaseController: context.databaseController, + )..add(const DatabaseGroupEvent.initial()); + gridGroupBloc.add( + DatabaseGroupEvent.setGroupByField( + dateField.id, + dateField.fieldType, + ), + ); + await boardResponseFuture(); + + assert(boardBloc.groupControllers.values.length == 2); + assert( + boardBloc.boardController.groupDatas.last.headerData.groupName == + LocaleKeys.board_dateCondition_today.tr(), + ); + }); + + test('group by date field with condition', () async { + final context = await boardTest.createTestBoard(); + final boardBloc = BoardBloc( + databaseController: DatabaseController(view: context.gridView), + )..add(const BoardEvent.initial()); + await boardResponseFuture(); + + // assert the initial values + assert(boardBloc.groupControllers.values.length == 4); + assert(context.fieldContexts.length == 2); + + await context.createField(FieldType.DateTime); + await boardResponseFuture(); + assert(context.fieldContexts.length == 3); + + final dateField = context.fieldContexts.last.field; + final cellController = context.makeCellControllerFromFieldId(dateField.id) + as DateCellController; + final bloc = DateCellEditorBloc( + cellController: cellController, + reminderBloc: getIt(), + ); + await boardResponseFuture(); + + bloc.add(DateCellEditorEvent.updateDateTime(DateTime.now())); + await boardResponseFuture(); + + final gridGroupBloc = DatabaseGroupBloc( + viewId: context.gridView.id, + databaseController: context.databaseController, + )..add(const DatabaseGroupEvent.initial()); + final settingContent = DateGroupConfigurationPB() + ..condition = DateConditionPB.Year; + gridGroupBloc.add( + DatabaseGroupEvent.setGroupByField( + dateField.id, + dateField.fieldType, + settingContent.writeToBuffer(), + ), + ); + await boardResponseFuture(); + + assert(boardBloc.groupControllers.values.length == 2); + assert( + boardBloc.boardController.groupDatas.last.headerData.groupName == "2024", + ); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart new file mode 100644 index 0000000000000..e2a794b3c4f07 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_multi_select_field_test.dart @@ -0,0 +1,101 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/setting/group_bloc.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'util.dart'; + +void main() { + late AppFlowyBoardTest boardTest; + + setUpAll(() async { + boardTest = await AppFlowyBoardTest.ensureInitialized(); + }); + + test('no status group name test', () async { + final context = await boardTest.createTestBoard(); + + // create multi-select field + await context.createField(FieldType.MultiSelect); + await boardResponseFuture(); + assert(context.fieldContexts.length == 3); + final multiSelectField = context.fieldContexts.last.field; + + // set grouped by the new multi-select field" + final gridGroupBloc = DatabaseGroupBloc( + viewId: context.gridView.id, + databaseController: context.databaseController, + )..add(const DatabaseGroupEvent.initial()); + await boardResponseFuture(); + + gridGroupBloc.add( + DatabaseGroupEvent.setGroupByField( + multiSelectField.id, + multiSelectField.fieldType, + ), + ); + await boardResponseFuture(); + + // assert only have the 'No status' group + final boardBloc = BoardBloc( + databaseController: DatabaseController(view: context.gridView), + )..add(const BoardEvent.initial()); + await boardResponseFuture(); + assert( + boardBloc.groupControllers.values.length == 1, + "Expected 1, but receive ${boardBloc.groupControllers.values.length}", + ); + }); + + test('group by multi select with no options test', () async { + final context = await boardTest.createTestBoard(); + + // create multi-select field + await context.createField(FieldType.MultiSelect); + await boardResponseFuture(); + assert(context.fieldContexts.length == 3); + final multiSelectField = context.fieldContexts.last.field; + + // Create options + final cellController = + context.makeCellControllerFromFieldId(multiSelectField.id) + as SelectOptionCellController; + + final bloc = SelectOptionCellEditorBloc(cellController: cellController); + await boardResponseFuture(); + bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await boardResponseFuture(); + bloc.add(const SelectOptionCellEditorEvent.filterOption("B")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await boardResponseFuture(); + + // set grouped by the new multi-select field" + final gridGroupBloc = DatabaseGroupBloc( + viewId: context.gridView.id, + databaseController: context.databaseController, + )..add(const DatabaseGroupEvent.initial()); + await boardResponseFuture(); + + gridGroupBloc.add( + DatabaseGroupEvent.setGroupByField( + multiSelectField.id, + multiSelectField.fieldType, + ), + ); + await boardResponseFuture(); + + // assert there are only three group + final boardBloc = BoardBloc( + databaseController: DatabaseController(view: context.gridView), + )..add(const BoardEvent.initial()); + await boardResponseFuture(); + assert( + boardBloc.groupControllers.values.length == 3, + "Expected 3, but receive ${boardBloc.groupControllers.values.length}", + ); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_unsupport_field_test.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_unsupport_field_test.dart new file mode 100644 index 0000000000000..c68338b424442 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/group_by_unsupport_field_test.dart @@ -0,0 +1,54 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; +import 'package:appflowy/plugins/database/board/application/board_bloc.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'util.dart'; + +void main() { + late AppFlowyBoardTest boardTest; + late FieldEditorBloc editorBloc; + late BoardTestContext context; + + setUpAll(() async { + boardTest = await AppFlowyBoardTest.ensureInitialized(); + context = await boardTest.createTestBoard(); + final fieldInfo = context.singleSelectFieldContext(); + editorBloc = context.makeFieldEditor( + fieldInfo: fieldInfo, + ); + + await boardResponseFuture(); + }); + + group('Group with not support grouping field', () { + blocTest( + "switch to text field", + build: () => editorBloc, + wait: boardResponseDuration(), + act: (bloc) async { + bloc.add(const FieldEditorEvent.switchFieldType(FieldType.RichText)); + }, + verify: (bloc) { + assert(bloc.state.field.fieldType == FieldType.RichText); + }, + ); + blocTest( + 'assert the number of groups is 1', + build: () => BoardBloc( + databaseController: DatabaseController(view: context.gridView), + )..add( + const BoardEvent.initial(), + ), + wait: boardResponseDuration(), + verify: (bloc) { + assert( + bloc.groupControllers.values.length == 1, + "Expected 1, but receive ${bloc.groupControllers.values.length}", + ); + }, + ); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart b/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart new file mode 100644 index 0000000000000..eb9cdcd4434f3 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/board_test/util.dart @@ -0,0 +1,122 @@ +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/board/board.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; + +import '../../util.dart'; +import '../grid_test/util.dart'; + +class AppFlowyBoardTest { + AppFlowyBoardTest({required this.unitTest}); + + final AppFlowyUnitTest unitTest; + + static Future ensureInitialized() async { + final inner = await AppFlowyUnitTest.ensureInitialized(); + return AppFlowyBoardTest(unitTest: inner); + } + + Future createTestBoard() async { + final app = await unitTest.createWorkspace(); + final builder = BoardPluginBuilder(); + return ViewBackendService.createView( + parentViewId: app.id, + name: "Test Board", + layoutType: builder.layoutType, + openAfterCreate: true, + ).then((result) { + return result.fold( + (view) async { + final context = BoardTestContext( + view, + DatabaseController(view: view), + ); + final result = await context._boardDataController.open(); + result.fold((l) => null, (r) => throw Exception(r)); + return context; + }, + (error) { + throw Exception(); + }, + ); + }); + } +} + +Future boardResponseFuture() { + return Future.delayed(boardResponseDuration()); +} + +Duration boardResponseDuration({int milliseconds = 2000}) { + return Duration(milliseconds: milliseconds); +} + +class BoardTestContext { + BoardTestContext(this.gridView, this._boardDataController); + + final ViewPB gridView; + final DatabaseController _boardDataController; + + List get rowInfos { + return _boardDataController.rowCache.rowInfos; + } + + List get fieldContexts => fieldController.fieldInfos; + + FieldController get fieldController { + return _boardDataController.fieldController; + } + + DatabaseController get databaseController => _boardDataController; + + FieldEditorBloc makeFieldEditor({ + required FieldInfo fieldInfo, + }) => + FieldEditorBloc( + viewId: databaseController.viewId, + fieldController: fieldController, + fieldInfo: fieldInfo, + isNew: false, + ); + + CellController makeCellControllerFromFieldId(String fieldId) { + return makeCellController( + _boardDataController, + CellContext(fieldId: fieldId, rowId: rowInfos.last.rowId), + ); + } + + Future createField(FieldType fieldType) async { + final editorBloc = + await createFieldEditor(databaseController: _boardDataController); + await gridResponseFuture(); + editorBloc.add(FieldEditorEvent.switchFieldType(fieldType)); + await gridResponseFuture(); + return Future(() => editorBloc); + } + + FieldInfo singleSelectFieldContext() { + final fieldInfo = fieldContexts + .firstWhere((element) => element.fieldType == FieldType.SingleSelect); + return fieldInfo; + } + + FieldInfo textFieldContext() { + final fieldInfo = fieldContexts + .firstWhere((element) => element.fieldType == FieldType.RichText); + return fieldInfo; + } + + FieldInfo checkboxFieldContext() { + final fieldInfo = fieldContexts + .firstWhere((element) => element.fieldType == FieldType.Checkbox); + return fieldInfo; + } +} diff --git a/frontend/appflowy_flutter/test/bloc_test/chat_test/chat_load_message_test.dart b/frontend/appflowy_flutter/test/bloc_test/chat_test/chat_load_message_test.dart new file mode 100644 index 0000000000000..134a429a6bcf2 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/chat_test/chat_load_message_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'util.dart'; + +void main() { + // ignore: unused_local_variable + late AppFlowyChatTest chatTest; + + setUpAll(() async { + chatTest = await AppFlowyChatTest.ensureInitialized(); + }); + + test('send message', () async { + // final context = await chatTest.createChat(); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/chat_test/util.dart b/frontend/appflowy_flutter/test/bloc_test/chat_test/util.dart new file mode 100644 index 0000000000000..29d98416b56e5 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/chat_test/util.dart @@ -0,0 +1,44 @@ +import 'package:appflowy/plugins/ai_chat/chat.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; + +import '../../util.dart'; + +class AppFlowyChatTest { + AppFlowyChatTest({required this.unitTest}); + + final AppFlowyUnitTest unitTest; + + static Future ensureInitialized() async { + final inner = await AppFlowyUnitTest.ensureInitialized(); + return AppFlowyChatTest(unitTest: inner); + } + + Future createChat() async { + final app = await unitTest.createWorkspace(); + final builder = AIChatPluginBuilder(); + return ViewBackendService.createView( + parentViewId: app.id, + name: "Test Chat", + layoutType: builder.layoutType, + openAfterCreate: true, + ).then((result) { + return result.fold( + (view) async { + return view; + }, + (error) { + throw Exception(); + }, + ); + }); + } +} + +Future boardResponseFuture() { + return Future.delayed(boardResponseDuration()); +} + +Duration boardResponseDuration({int milliseconds = 200}) { + return Duration(milliseconds: milliseconds); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/checklist_cell_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/checklist_cell_bloc_test.dart new file mode 100644 index 0000000000000..441ffea556274 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/checklist_cell_bloc_test.dart @@ -0,0 +1,154 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../util.dart'; + +void main() { + late AppFlowyGridTest cellTest; + + setUpAll(() async { + cellTest = await AppFlowyGridTest.ensureInitialized(); + }); + + group('checklist cell bloc:', () { + late GridTestContext context; + late ChecklistCellController cellController; + + setUp(() async { + context = await cellTest.makeDefaultTestGrid(); + await FieldBackendService.createField( + viewId: context.viewId, + fieldType: FieldType.Checklist, + ); + await gridResponseFuture(); + final fieldIndex = context.fieldController.fieldInfos + .indexWhere((field) => field.fieldType == FieldType.Checklist); + cellController = context.makeGridCellController(fieldIndex, 0).as(); + }); + + test('create tasks', () async { + final bloc = ChecklistCellBloc(cellController: cellController); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 0); + + bloc.add(const ChecklistCellEvent.createNewTask("B")); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 1); + + bloc.add(const ChecklistCellEvent.createNewTask("A", index: 0)); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 2); + expect(bloc.state.tasks.first.data.name, "A"); + expect(bloc.state.tasks.last.data.name, "B"); + }); + + test('rename task', () async { + final bloc = ChecklistCellBloc(cellController: cellController); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 0); + + bloc.add(const ChecklistCellEvent.createNewTask("B")); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 1); + expect(bloc.state.tasks.first.data.name, "B"); + + bloc.add( + ChecklistCellEvent.updateTaskName(bloc.state.tasks.first.data, "A"), + ); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 1); + expect(bloc.state.tasks.first.data.name, "A"); + }); + + test('select task', () async { + final bloc = ChecklistCellBloc(cellController: cellController); + await gridResponseFuture(); + + bloc.add(const ChecklistCellEvent.createNewTask("A")); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 1); + expect(bloc.state.tasks.first.isSelected, false); + + bloc.add(const ChecklistCellEvent.selectTask('A')); + await gridResponseFuture(); + + expect(bloc.state.tasks.first.isSelected, false); + + bloc.add( + ChecklistCellEvent.selectTask(bloc.state.tasks.first.data.id), + ); + await gridResponseFuture(); + + expect(bloc.state.tasks.first.isSelected, true); + }); + + test('delete task', () async { + final bloc = ChecklistCellBloc(cellController: cellController); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 0); + + bloc.add(const ChecklistCellEvent.createNewTask("A")); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 1); + expect(bloc.state.tasks.first.isSelected, false); + + bloc.add(const ChecklistCellEvent.deleteTask('A')); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 1); + + bloc.add( + ChecklistCellEvent.deleteTask(bloc.state.tasks.first.data.id), + ); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 0); + }); + + test('reorder task', () async { + final bloc = ChecklistCellBloc(cellController: cellController); + await gridResponseFuture(); + + bloc.add(const ChecklistCellEvent.createNewTask("A")); + await gridResponseFuture(); + bloc.add(const ChecklistCellEvent.createNewTask("B")); + await gridResponseFuture(); + bloc.add(const ChecklistCellEvent.createNewTask("C")); + await gridResponseFuture(); + bloc.add(const ChecklistCellEvent.createNewTask("D")); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 4); + + bloc.add(const ChecklistCellEvent.reorderTask(0, 2)); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 4); + expect(bloc.state.tasks[0].data.name, "B"); + expect(bloc.state.tasks[1].data.name, "A"); + expect(bloc.state.tasks[2].data.name, "C"); + expect(bloc.state.tasks[3].data.name, "D"); + + bloc.add(const ChecklistCellEvent.reorderTask(3, 1)); + await gridResponseFuture(); + + expect(bloc.state.tasks.length, 4); + expect(bloc.state.tasks[0].data.name, "B"); + expect(bloc.state.tasks[1].data.name, "D"); + expect(bloc.state.tasks[2].data.name, "A"); + expect(bloc.state.tasks[3].data.name, "C"); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/date_cell_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/date_cell_bloc_test.dart new file mode 100644 index 0000000000000..71a4dcb3e6c3c --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/date_cell_bloc_test.dart @@ -0,0 +1,391 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_editor_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:time/time.dart'; + +import '../util.dart'; + +void main() { + late AppFlowyGridTest cellTest; + + setUpAll(() async { + cellTest = await AppFlowyGridTest.ensureInitialized(); + }); + + group('date time cell bloc:', () { + late GridTestContext context; + late DateCellController cellController; + + setUp(() async { + context = await cellTest.makeDefaultTestGrid(); + await FieldBackendService.createField( + viewId: context.viewId, + fieldType: FieldType.DateTime, + ); + await gridResponseFuture(); + final fieldIndex = context.fieldController.fieldInfos + .indexWhere((field) => field.fieldType == FieldType.DateTime); + cellController = context.makeGridCellController(fieldIndex, 0).as(); + }); + + test('select date', () async { + final reminderBloc = ReminderBloc(); + final bloc = DateCellEditorBloc( + cellController: cellController, + reminderBloc: reminderBloc, + ); + await gridResponseFuture(); + + expect(bloc.state.dateTime, null); + expect(bloc.state.endDateTime, null); + expect(bloc.state.includeTime, false); + expect(bloc.state.isRange, false); + + final now = DateTime.now(); + bloc.add(DateCellEditorEvent.updateDateTime(now)); + await gridResponseFuture(); + + expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); + }); + + test('include time', () async { + final reminderBloc = ReminderBloc(); + final bloc = DateCellEditorBloc( + cellController: cellController, + reminderBloc: reminderBloc, + ); + await gridResponseFuture(); + + final now = DateTime.now(); + bloc.add(DateCellEditorEvent.setIncludeTime(true, now, null)); + await gridResponseFuture(); + + expect(bloc.state.includeTime, true); + expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); + expect(bloc.state.endDateTime, null); + + bloc.add(const DateCellEditorEvent.setIncludeTime(false, null, null)); + await gridResponseFuture(); + + expect(bloc.state.includeTime, false); + expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); + expect(bloc.state.endDateTime, null); + }); + + test('end time basic', () async { + final reminderBloc = ReminderBloc(); + final bloc = DateCellEditorBloc( + cellController: cellController, + reminderBloc: reminderBloc, + ); + await gridResponseFuture(); + + expect(bloc.state.isRange, false); + expect(bloc.state.dateTime, null); + expect(bloc.state.endDateTime, null); + + final now = DateTime.now(); + bloc.add(DateCellEditorEvent.updateDateTime(now)); + await gridResponseFuture(); + + expect(bloc.state.isRange, false); + expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); + expect(bloc.state.endDateTime, null); + + bloc.add(const DateCellEditorEvent.setIsRange(true, null, null)); + await gridResponseFuture(); + + expect(bloc.state.isRange, true); + expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); + expect(bloc.state.endDateTime!.isAtSameMinuteAs(now), true); + + bloc.add(const DateCellEditorEvent.setIsRange(false, null, null)); + await gridResponseFuture(); + + expect(bloc.state.isRange, false); + expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); + expect(bloc.state.endDateTime, null); + }); + + test('end time from empty', () async { + final reminderBloc = ReminderBloc(); + final bloc = DateCellEditorBloc( + cellController: cellController, + reminderBloc: reminderBloc, + ); + await gridResponseFuture(); + + expect(bloc.state.isRange, false); + expect(bloc.state.dateTime, null); + expect(bloc.state.endDateTime, null); + + final now = DateTime.now(); + bloc.add(DateCellEditorEvent.setIsRange(true, now, now)); + await gridResponseFuture(); + + expect(bloc.state.isRange, true); + expect(bloc.state.dateTime!.isAtSameDayAs(now), true); + expect(bloc.state.endDateTime!.isAtSameDayAs(now), true); + + bloc.add(const DateCellEditorEvent.setIsRange(false, null, null)); + await gridResponseFuture(); + + expect(bloc.state.isRange, false); + expect(bloc.state.dateTime!.isAtSameDayAs(now), true); + expect(bloc.state.endDateTime, null); + }); + + test('end time unexpected null', () async { + final reminderBloc = ReminderBloc(); + final bloc = DateCellEditorBloc( + cellController: cellController, + reminderBloc: reminderBloc, + ); + await gridResponseFuture(); + + expect(bloc.state.isRange, false); + expect(bloc.state.dateTime, null); + expect(bloc.state.endDateTime, null); + + final now = DateTime.now(); + // pass in unexpected null as end date time + bloc.add(DateCellEditorEvent.setIsRange(true, now, null)); + await gridResponseFuture(); + + // no changes + expect(bloc.state.isRange, false); + expect(bloc.state.dateTime, null); + expect(bloc.state.endDateTime, null); + }); + + test('end time unexpected end', () async { + final reminderBloc = ReminderBloc(); + final bloc = DateCellEditorBloc( + cellController: cellController, + reminderBloc: reminderBloc, + ); + await gridResponseFuture(); + + expect(bloc.state.isRange, false); + expect(bloc.state.dateTime, null); + expect(bloc.state.endDateTime, null); + + final now = DateTime.now(); + bloc.add(DateCellEditorEvent.setIsRange(true, now, now)); + await gridResponseFuture(); + + bloc.add(DateCellEditorEvent.setIsRange(false, now, now)); + await gridResponseFuture(); + + // no change + expect(bloc.state.isRange, true); + expect(bloc.state.dateTime!.isAtSameDayAs(now), true); + expect(bloc.state.endDateTime!.isAtSameDayAs(now), true); + }); + + test('clear date', () async { + final reminderBloc = ReminderBloc(); + final bloc = DateCellEditorBloc( + cellController: cellController, + reminderBloc: reminderBloc, + ); + await gridResponseFuture(); + + final now = DateTime.now(); + bloc.add(DateCellEditorEvent.setIsRange(true, now, now)); + await gridResponseFuture(); + bloc.add(DateCellEditorEvent.setIncludeTime(true, now, now)); + await gridResponseFuture(); + + expect(bloc.state.isRange, true); + expect(bloc.state.includeTime, true); + expect(bloc.state.dateTime!.isAtSameMinuteAs(now), true); + expect(bloc.state.endDateTime!.isAtSameMinuteAs(now), true); + + bloc.add(const DateCellEditorEvent.clearDate()); + await gridResponseFuture(); + + expect(bloc.state.dateTime, null); + expect(bloc.state.endDateTime, null); + expect(bloc.state.includeTime, false); + expect(bloc.state.isRange, false); + }); + + test('set date format', () async { + final reminderBloc = ReminderBloc(); + final bloc = DateCellEditorBloc( + cellController: cellController, + reminderBloc: reminderBloc, + ); + await gridResponseFuture(); + + expect( + bloc.state.dateTypeOptionPB.dateFormat, + DateFormatPB.Friendly, + ); + expect( + bloc.state.dateTypeOptionPB.timeFormat, + TimeFormatPB.TwentyFourHour, + ); + + bloc.add( + const DateCellEditorEvent.setDateFormat(DateFormatPB.ISO), + ); + await gridResponseFuture(); + expect( + bloc.state.dateTypeOptionPB.dateFormat, + DateFormatPB.ISO, + ); + + bloc.add( + const DateCellEditorEvent.setTimeFormat(TimeFormatPB.TwelveHour), + ); + await gridResponseFuture(); + expect( + bloc.state.dateTypeOptionPB.timeFormat, + TimeFormatPB.TwelveHour, + ); + }); + + test('set reminder option', () async { + final reminderBloc = ReminderBloc(); + final bloc = DateCellEditorBloc( + cellController: cellController, + reminderBloc: reminderBloc, + ); + await gridResponseFuture(); + + expect(reminderBloc.state.reminders.length, 0); + + final now = DateTime.now(); + final threeDaysFromToday = DateTime(now.year, now.month, now.day + 3); + final fourDaysFromToday = DateTime(now.year, now.month, now.day + 4); + final fiveDaysFromToday = DateTime(now.year, now.month, now.day + 5); + + bloc.add(DateCellEditorEvent.updateDateTime(threeDaysFromToday)); + await gridResponseFuture(); + + bloc.add( + const DateCellEditorEvent.setReminderOption( + ReminderOption.onDayOfEvent, + ), + ); + await gridResponseFuture(); + + expect(reminderBloc.state.reminders.length, 1); + expect( + reminderBloc.state.reminders.first.scheduledAt, + Int64( + threeDaysFromToday + .add(const Duration(hours: 9)) + .millisecondsSinceEpoch ~/ + 1000, + ), + ); + + reminderBloc.add(const ReminderEvent.refresh()); + await gridResponseFuture(); + + expect(reminderBloc.state.reminders.length, 1); + expect( + reminderBloc.state.reminders.first.scheduledAt, + Int64( + threeDaysFromToday + .add(const Duration(hours: 9)) + .millisecondsSinceEpoch ~/ + 1000, + ), + ); + + bloc.add(DateCellEditorEvent.updateDateTime(fourDaysFromToday)); + await gridResponseFuture(); + expect(reminderBloc.state.reminders.length, 1); + expect( + reminderBloc.state.reminders.first.scheduledAt, + Int64( + fourDaysFromToday + .add(const Duration(hours: 9)) + .millisecondsSinceEpoch ~/ + 1000, + ), + ); + + bloc.add(DateCellEditorEvent.updateDateTime(fiveDaysFromToday)); + await gridResponseFuture(); + reminderBloc.add(const ReminderEvent.refresh()); + await gridResponseFuture(); + expect(reminderBloc.state.reminders.length, 1); + expect( + reminderBloc.state.reminders.first.scheduledAt, + Int64( + fiveDaysFromToday + .add(const Duration(hours: 9)) + .millisecondsSinceEpoch ~/ + 1000, + ), + ); + + bloc.add( + const DateCellEditorEvent.setReminderOption( + ReminderOption.twoDaysBefore, + ), + ); + await gridResponseFuture(); + expect(reminderBloc.state.reminders.length, 1); + expect( + reminderBloc.state.reminders.first.scheduledAt, + Int64( + threeDaysFromToday + .add(const Duration(hours: 9)) + .millisecondsSinceEpoch ~/ + 1000, + ), + ); + + bloc.add( + const DateCellEditorEvent.setReminderOption(ReminderOption.none), + ); + await gridResponseFuture(); + expect(reminderBloc.state.reminders.length, 0); + reminderBloc.add(const ReminderEvent.refresh()); + await gridResponseFuture(); + expect(reminderBloc.state.reminders.length, 0); + }); + + test('set reminder option from empty', () async { + final reminderBloc = ReminderBloc(); + final bloc = DateCellEditorBloc( + cellController: cellController, + reminderBloc: reminderBloc, + ); + await gridResponseFuture(); + + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + bloc.add( + const DateCellEditorEvent.setReminderOption( + ReminderOption.onDayOfEvent, + ), + ); + await gridResponseFuture(); + + expect(bloc.state.dateTime, today); + expect(reminderBloc.state.reminders.length, 1); + expect( + reminderBloc.state.reminders.first.scheduledAt, + Int64( + today.add(const Duration(hours: 9)).millisecondsSinceEpoch ~/ 1000, + ), + ); + + bloc.add(const DateCellEditorEvent.clearDate()); + await gridResponseFuture(); + expect(reminderBloc.state.reminders.length, 0); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/select_option_cell_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/select_option_cell_test.dart new file mode 100644 index 0000000000000..4e15d3aaa730e --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/select_option_cell_test.dart @@ -0,0 +1,217 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_editor_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/select_option_entities.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../util.dart'; + +void main() { + late AppFlowyGridTest cellTest; + + setUpAll(() async { + cellTest = await AppFlowyGridTest.ensureInitialized(); + }); + + group('select cell bloc:', () { + late GridTestContext context; + late SelectOptionCellController cellController; + + setUp(() async { + context = await cellTest.makeDefaultTestGrid(); + await RowBackendService.createRow(viewId: context.viewId); + final fieldIndex = context.fieldController.fieldInfos + .indexWhere((field) => field.fieldType == FieldType.SingleSelect); + cellController = context.makeGridCellController(fieldIndex, 0).as(); + }); + + test('create options', () async { + final bloc = SelectOptionCellEditorBloc(cellController: cellController); + await gridResponseFuture(); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await gridResponseFuture(); + + expect(bloc.state.options.length, 1); + expect(bloc.state.options[0].name, "A"); + }); + + test('update options', () async { + final bloc = SelectOptionCellEditorBloc(cellController: cellController); + await gridResponseFuture(); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await gridResponseFuture(); + + final SelectOptionPB optionUpdate = bloc.state.options[0] + ..color = SelectOptionColorPB.Aqua + ..name = "B"; + bloc.add(SelectOptionCellEditorEvent.updateOption(optionUpdate)); + + expect(bloc.state.options.length, 1); + expect(bloc.state.options[0].name, "B"); + expect(bloc.state.options[0].color, SelectOptionColorPB.Aqua); + }); + + test('delete options', () async { + final bloc = SelectOptionCellEditorBloc(cellController: cellController); + await gridResponseFuture(); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await gridResponseFuture(); + assert( + bloc.state.options.length == 1, + "Expect 1 but receive ${bloc.state.options.length}, Options: ${bloc.state.options}", + ); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("B")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await gridResponseFuture(); + assert( + bloc.state.options.length == 2, + "Expect 2 but receive ${bloc.state.options.length}, Options: ${bloc.state.options}", + ); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("C")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await gridResponseFuture(); + assert( + bloc.state.options.length == 3, + "Expect 3 but receive ${bloc.state.options.length}. Options: ${bloc.state.options}", + ); + + bloc.add(SelectOptionCellEditorEvent.deleteOption(bloc.state.options[0])); + await gridResponseFuture(); + assert( + bloc.state.options.length == 2, + "Expect 2 but receive ${bloc.state.options.length}. Options: ${bloc.state.options}", + ); + + bloc.add(const SelectOptionCellEditorEvent.deleteAllOptions()); + await gridResponseFuture(); + + assert( + bloc.state.options.isEmpty, + "Expect empty but receive ${bloc.state.options.length}. Options: ${bloc.state.options}", + ); + }); + + test('select/unselect option', () async { + final bloc = SelectOptionCellEditorBloc(cellController: cellController); + await gridResponseFuture(); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await gridResponseFuture(); + + final optionId = bloc.state.options[0].id; + bloc.add(SelectOptionCellEditorEvent.unselectOption(optionId)); + await gridResponseFuture(); + assert(bloc.state.selectedOptions.isEmpty); + + bloc.add(SelectOptionCellEditorEvent.selectOption(optionId)); + await gridResponseFuture(); + + assert(bloc.state.selectedOptions.length == 1); + expect(bloc.state.selectedOptions[0].name, "A"); + }); + + test('select an option or create one', () async { + final bloc = SelectOptionCellEditorBloc(cellController: cellController); + await gridResponseFuture(); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await gridResponseFuture(); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("B")); + bloc.add(const SelectOptionCellEditorEvent.submitTextField()); + await gridResponseFuture(); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); + bloc.add(const SelectOptionCellEditorEvent.submitTextField()); + await gridResponseFuture(); + + expect(bloc.state.selectedOptions.length, 1); + expect(bloc.state.options.length, 1); + expect(bloc.state.selectedOptions[0].name, "A"); + }); + + test('select multiple options', () async { + final bloc = SelectOptionCellEditorBloc(cellController: cellController); + await gridResponseFuture(); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("A")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await gridResponseFuture(); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("B")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await gridResponseFuture(); + + bloc.add( + const SelectOptionCellEditorEvent.selectMultipleOptions( + ["A", "B", "C"], + "x", + ), + ); + await gridResponseFuture(); + + assert(bloc.state.selectedOptions.length == 1); + expect(bloc.state.selectedOptions[0].name, "A"); + expect(bloc.filter, "x"); + }); + + test('filter options', () async { + final bloc = SelectOptionCellEditorBloc(cellController: cellController); + await gridResponseFuture(); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("abcd")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await gridResponseFuture(); + expect( + bloc.state.options.length, + 1, + reason: "Options: ${bloc.state.options}", + ); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("aaaa")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await gridResponseFuture(); + expect( + bloc.state.options.length, + 2, + reason: "Options: ${bloc.state.options}", + ); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("defg")); + bloc.add(const SelectOptionCellEditorEvent.createOption()); + await gridResponseFuture(); + expect( + bloc.state.options.length, + 3, + reason: "Options: ${bloc.state.options}", + ); + + bloc.add(const SelectOptionCellEditorEvent.filterOption("a")); + await gridResponseFuture(); + + expect( + bloc.state.options.length, + 2, + reason: "Options: ${bloc.state.options}", + ); + expect( + bloc.allOptions.length, + 3, + reason: "Options: ${bloc.state.options}", + ); + expect(bloc.state.createSelectOptionSuggestion!.name, "a"); + expect(bloc.filter, "a"); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/text_cell_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/text_cell_bloc_test.dart new file mode 100644 index 0000000000000..bcd39265d038d --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/cell/text_cell_bloc_test.dart @@ -0,0 +1,109 @@ +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/row/row_service.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/domain/field_settings_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../util.dart'; + +void main() { + late AppFlowyGridTest cellTest; + + setUpAll(() async { + cellTest = await AppFlowyGridTest.ensureInitialized(); + }); + + group('text cell bloc:', () { + late GridTestContext context; + late TextCellController cellController; + + setUp(() async { + context = await cellTest.makeDefaultTestGrid(); + await RowBackendService.createRow(viewId: context.viewId); + final fieldIndex = context.fieldController.fieldInfos + .indexWhere((field) => field.fieldType == FieldType.RichText); + cellController = context.makeGridCellController(fieldIndex, 0).as(); + }); + + test('update text', () async { + final bloc = TextCellBloc(cellController: cellController); + await gridResponseFuture(); + + expect(bloc.state.content, ""); + + bloc.add(const TextCellEvent.updateText("A")); + await gridResponseFuture(milliseconds: 600); + + expect(bloc.state.content, "A"); + }); + + test('non-primary text field emoji and hasDocument', () async { + final primaryBloc = TextCellBloc(cellController: cellController); + expect(primaryBloc.state.emoji == null, false); + expect(primaryBloc.state.hasDocument == null, false); + + await primaryBloc.close(); + + await FieldBackendService.createField( + viewId: context.viewId, + fieldName: "Second", + ); + await gridResponseFuture(); + final fieldIndex = context.fieldController.fieldInfos.indexWhere( + (field) => field.fieldType == FieldType.RichText && !field.isPrimary, + ); + cellController = context.makeGridCellController(fieldIndex, 0).as(); + final nonPrimaryBloc = TextCellBloc(cellController: cellController); + await gridResponseFuture(); + + expect(nonPrimaryBloc.state.emoji == null, true); + expect(nonPrimaryBloc.state.hasDocument == null, true); + }); + + test('update wrap cell content', () async { + final bloc = TextCellBloc(cellController: cellController); + await gridResponseFuture(); + + expect(bloc.state.wrap, true); + + await FieldSettingsBackendService( + viewId: context.viewId, + ).updateFieldSettings( + fieldId: cellController.fieldId, + wrapCellContent: false, + ); + await gridResponseFuture(); + + expect(bloc.state.wrap, false); + }); + + test('update emoji', () async { + final bloc = TextCellBloc(cellController: cellController); + await gridResponseFuture(); + + expect(bloc.state.emoji!.value, ""); + + await RowBackendService(viewId: context.viewId) + .updateMeta(rowId: cellController.rowId, iconURL: "dummy"); + await gridResponseFuture(); + + expect(bloc.state.emoji!.value, "dummy"); + }); + + test('update document data', () async { + // This is so fake? + final bloc = TextCellBloc(cellController: cellController); + await gridResponseFuture(); + + expect(bloc.state.hasDocument!.value, false); + + await RowBackendService(viewId: context.viewId) + .updateMeta(rowId: cellController.rowId, isDocumentEmpty: false); + await gridResponseFuture(); + + expect(bloc.state.hasDocument!.value, true); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/field/field_cell_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/field/field_cell_bloc_test.dart new file mode 100644 index 0000000000000..a1fd6d35ccb50 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/field/field_cell_bloc_test.dart @@ -0,0 +1,55 @@ +import 'package:appflowy/plugins/database/application/field/field_cell_bloc.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../util.dart'; + +void main() { + late AppFlowyGridTest gridTest; + + setUpAll(() async { + gridTest = await AppFlowyGridTest.ensureInitialized(); + }); + + group('field cell bloc:', () { + late GridTestContext context; + late double width; + + setUp(() async { + context = await gridTest.makeDefaultTestGrid(); + }); + + blocTest( + 'update field width', + build: () => FieldCellBloc( + fieldInfo: context.fieldController.fieldInfos[0], + viewId: context.viewId, + ), + act: (bloc) { + width = bloc.state.width; + bloc.add(const FieldCellEvent.onResizeStart()); + bloc.add(const FieldCellEvent.startUpdateWidth(100)); + bloc.add(const FieldCellEvent.endUpdateWidth()); + }, + verify: (bloc) { + expect(bloc.state.width, width + 100); + }, + ); + + blocTest( + 'field width should not be less than 50px', + build: () => FieldCellBloc( + viewId: context.viewId, + fieldInfo: context.fieldController.fieldInfos[0], + ), + act: (bloc) { + bloc.add(const FieldCellEvent.onResizeStart()); + bloc.add(const FieldCellEvent.startUpdateWidth(-110)); + bloc.add(const FieldCellEvent.endUpdateWidth()); + }, + verify: (bloc) { + expect(bloc.state.width, 50); + }, + ); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/field/field_editor_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/field/field_editor_bloc_test.dart new file mode 100644 index 0000000000000..c46ea5532b37a --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/field/field_editor_bloc_test.dart @@ -0,0 +1,132 @@ +import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; +import 'package:appflowy/plugins/database/application/field/type_option/type_option_data_parser.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nanoid/nanoid.dart'; + +import '../util.dart'; + +void main() { + late AppFlowyGridTest gridTest; + + setUpAll(() async { + gridTest = await AppFlowyGridTest.ensureInitialized(); + }); + + group('field editor bloc:', () { + late GridTestContext context; + late FieldEditorBloc editorBloc; + + setUp(() async { + context = await gridTest.makeDefaultTestGrid(); + final fieldInfo = context.fieldController.fieldInfos + .firstWhere((field) => field.fieldType == FieldType.SingleSelect); + editorBloc = FieldEditorBloc( + viewId: context.viewId, + fieldController: context.fieldController, + fieldInfo: fieldInfo, + isNew: false, + ); + }); + + test('rename field', () async { + expect(editorBloc.state.field.name, equals("Type")); + + editorBloc.add(const FieldEditorEvent.renameField('Hello world')); + + await gridResponseFuture(); + expect(editorBloc.state.field.name, equals("Hello world")); + }); + + test('edit icon', () async { + expect(editorBloc.state.field.icon, equals("")); + + editorBloc.add(const FieldEditorEvent.updateIcon('emoji/smiley-face')); + + await gridResponseFuture(); + expect(editorBloc.state.field.icon, equals("emoji/smiley-face")); + + editorBloc.add(const FieldEditorEvent.updateIcon("")); + + await gridResponseFuture(); + expect(editorBloc.state.field.icon, equals("")); + }); + + test('switch to text field', () async { + expect(editorBloc.state.field.fieldType, equals(FieldType.SingleSelect)); + + editorBloc.add( + const FieldEditorEvent.switchFieldType(FieldType.RichText), + ); + await gridResponseFuture(); + + expect(editorBloc.state.field.fieldType, equals(FieldType.RichText)); + }); + + test('update field type option', () async { + final selectOption = SelectOptionPB() + ..id = nanoid(4) + ..color = SelectOptionColorPB.Lime + ..name = "New option"; + final typeOptionData = SingleSelectTypeOptionPB() + ..options.addAll([selectOption]); + + editorBloc.add( + FieldEditorEvent.updateTypeOption(typeOptionData.writeToBuffer()), + ); + await gridResponseFuture(); + + final actual = SingleSelectTypeOptionDataParser() + .fromBuffer(editorBloc.state.field.field.typeOptionData); + + expect(actual, equals(typeOptionData)); + }); + + test('update visibility', () async { + expect( + editorBloc.state.field.visibility, + equals(FieldVisibility.AlwaysShown), + ); + + editorBloc.add(const FieldEditorEvent.toggleFieldVisibility()); + await gridResponseFuture(); + + expect( + editorBloc.state.field.visibility, + equals(FieldVisibility.AlwaysHidden), + ); + }); + + test('update wrap cell', () async { + expect( + editorBloc.state.field.wrapCellContent, + equals(true), + ); + + editorBloc.add(const FieldEditorEvent.toggleWrapCellContent()); + await gridResponseFuture(); + + expect( + editorBloc.state.field.wrapCellContent, + equals(false), + ); + }); + + test('insert left and right', () async { + expect( + context.fieldController.fieldInfos.length, + equals(3), + ); + + editorBloc.add(const FieldEditorEvent.insertLeft()); + await gridResponseFuture(); + editorBloc.add(const FieldEditorEvent.insertRight()); + await gridResponseFuture(); + + expect( + context.fieldController.fieldInfos.length, + equals(5), + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_editor_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_editor_bloc_test.dart new file mode 100644 index 0000000000000..f5068282a9277 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_editor_bloc_test.dart @@ -0,0 +1,192 @@ +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/domain/filter_service.dart'; +import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../util.dart'; + +void main() { + late AppFlowyGridTest gridTest; + + setUpAll(() async { + gridTest = await AppFlowyGridTest.ensureInitialized(); + }); + + group('filter editor bloc:', () { + late GridTestContext context; + late FilterEditorBloc filterBloc; + + setUp(() async { + context = await gridTest.makeDefaultTestGrid(); + filterBloc = FilterEditorBloc( + viewId: context.viewId, + fieldController: context.fieldController, + ); + }); + + FieldInfo getFirstFieldByType(FieldType fieldType) { + return context.fieldController.fieldInfos + .firstWhere((field) => field.fieldType == fieldType); + } + + test('create filter', () async { + expect(filterBloc.state.filters.length, equals(0)); + expect(filterBloc.state.fields.length, equals(3)); + + // through domain directly + final textField = getFirstFieldByType(FieldType.RichText); + final service = FilterBackendService(viewId: context.viewId); + await service.insertTextFilter( + fieldId: textField.id, + condition: TextFilterConditionPB.TextIsEmpty, + content: "", + ); + await gridResponseFuture(); + expect(filterBloc.state.filters.length, equals(1)); + expect(filterBloc.state.fields.length, equals(3)); + + // through bloc event + final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); + filterBloc.add(FilterEditorEvent.createFilter(selectOptionField)); + await gridResponseFuture(); + expect(filterBloc.state.filters.length, equals(2)); + expect(filterBloc.state.filters.first.fieldId, equals(textField.id)); + expect(filterBloc.state.filters[1].fieldId, equals(selectOptionField.id)); + + final filter = filterBloc.state.filters.first as TextFilter; + expect(filter.condition, equals(TextFilterConditionPB.TextIsEmpty)); + expect(filter.content, equals("")); + final filter2 = filterBloc.state.filters[1] as SelectOptionFilter; + expect(filter2.condition, equals(SelectOptionFilterConditionPB.OptionIs)); + expect(filter2.optionIds.length, equals(0)); + expect(filterBloc.state.fields.length, equals(3)); + }); + + test('change filtering field', () async { + final textField = getFirstFieldByType(FieldType.RichText); + final selectField = getFirstFieldByType(FieldType.Checkbox); + filterBloc.add(FilterEditorEvent.createFilter(textField)); + await gridResponseFuture(); + expect(filterBloc.state.filters.length, equals(1)); + expect(filterBloc.state.fields.length, equals(3)); + expect( + filterBloc.state.filters.first.fieldType, + equals(FieldType.RichText), + ); + + final filter = filterBloc.state.filters.first; + filterBloc.add( + FilterEditorEvent.changeFilteringField(filter.filterId, selectField), + ); + await gridResponseFuture(); + expect(filterBloc.state.filters.length, equals(1)); + expect( + filterBloc.state.filters.first.fieldType, + equals(FieldType.Checkbox), + ); + expect(filterBloc.state.fields.length, equals(3)); + }); + + test('delete filter', () async { + final textField = getFirstFieldByType(FieldType.RichText); + filterBloc.add(FilterEditorEvent.createFilter(textField)); + await gridResponseFuture(); + expect(filterBloc.state.filters.length, equals(1)); + expect(filterBloc.state.fields.length, equals(3)); + + final filter = filterBloc.state.filters.first; + filterBloc.add(FilterEditorEvent.deleteFilter(filter.filterId)); + await gridResponseFuture(); + expect(filterBloc.state.filters.length, equals(0)); + expect(filterBloc.state.fields.length, equals(3)); + }); + + test('update filter', () async { + final service = FilterBackendService(viewId: context.viewId); + final textField = getFirstFieldByType(FieldType.RichText); + + // Create filter + await service.insertTextFilter( + fieldId: textField.id, + condition: TextFilterConditionPB.TextIsEmpty, + content: "", + ); + await gridResponseFuture(); + TextFilter filter = filterBloc.state.filters.first as TextFilter; + expect(filter.condition, equals(TextFilterConditionPB.TextIsEmpty)); + + final textFilter = context.fieldController.filters.first; + + // Update the existing filter + await service.insertTextFilter( + fieldId: textField.id, + filterId: textFilter.filterId, + condition: TextFilterConditionPB.TextIs, + content: "ABC", + ); + await gridResponseFuture(); + filter = filterBloc.state.filters.first as TextFilter; + expect(filter.condition, equals(TextFilterConditionPB.TextIs)); + expect(filter.content, equals("ABC")); + }); + + test('update filtering field\'s name', () async { + final textField = getFirstFieldByType(FieldType.RichText); + filterBloc.add(FilterEditorEvent.createFilter(textField)); + await gridResponseFuture(); + expect(filterBloc.state.filters.length, equals(1)); + + expect(filterBloc.state.fields.length, equals(3)); + expect(filterBloc.state.fields.first.name, equals("Name")); + + // edit field + await FieldBackendService( + viewId: context.viewId, + fieldId: textField.id, + ).updateField(name: "New Name"); + await gridResponseFuture(); + expect(filterBloc.state.fields.length, equals(3)); + expect(filterBloc.state.fields.first.name, equals("New Name")); + }); + + test('update field type', () async { + final checkboxField = getFirstFieldByType(FieldType.Checkbox); + filterBloc.add(FilterEditorEvent.createFilter(checkboxField)); + await gridResponseFuture(); + expect(filterBloc.state.filters.length, equals(1)); + + // edit field + await FieldBackendService( + viewId: context.viewId, + fieldId: checkboxField.id, + ).updateType(fieldType: FieldType.DateTime); + await gridResponseFuture(); + + // filter is removed + expect(filterBloc.state.filters.length, equals(0)); + expect(filterBloc.state.fields.length, equals(3)); + expect(filterBloc.state.fields[2].fieldType, FieldType.DateTime); + }); + + test('update filter field', () async { + final checkboxField = getFirstFieldByType(FieldType.Checkbox); + filterBloc.add(FilterEditorEvent.createFilter(checkboxField)); + await gridResponseFuture(); + expect(filterBloc.state.filters.length, equals(1)); + + // edit field + await FieldBackendService( + viewId: context.viewId, + fieldId: checkboxField.id, + ).updateField(name: "HERRO"); + await gridResponseFuture(); + + expect(filterBloc.state.filters.length, equals(1)); + expect(filterBloc.state.fields.length, equals(3)); + expect(filterBloc.state.fields[2].name, "HERRO"); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_entities_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_entities_test.dart new file mode 100644 index 0000000000000..12afca48f8bb1 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/filter/filter_entities_test.dart @@ -0,0 +1,602 @@ +import 'dart:typed_data'; + +import 'package:appflowy/plugins/database/application/field/filter_entities.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('parsing filter entities:', () { + FilterPB createFilterPB( + FieldType fieldType, + Uint8List data, + ) { + return FilterPB( + id: "FT", + filterType: FilterType.Data, + data: FilterDataPB( + fieldId: "FD", + fieldType: fieldType, + data: data, + ), + ); + } + + test('text', () async { + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.RichText, + TextFilterPB( + condition: TextFilterConditionPB.TextContains, + content: "c", + ).writeToBuffer(), + ), + ), + equals( + TextFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.RichText, + condition: TextFilterConditionPB.TextContains, + content: "c", + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.RichText, + TextFilterPB( + condition: TextFilterConditionPB.TextContains, + content: "", + ).writeToBuffer(), + ), + ), + equals( + TextFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.RichText, + condition: TextFilterConditionPB.TextContains, + content: "", + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.RichText, + TextFilterPB( + condition: TextFilterConditionPB.TextIsEmpty, + content: "", + ).writeToBuffer(), + ), + ), + equals( + TextFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.RichText, + condition: TextFilterConditionPB.TextIsEmpty, + content: "", + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.RichText, + TextFilterPB( + condition: TextFilterConditionPB.TextIsEmpty, + content: "", + ).writeToBuffer(), + ), + ), + equals( + TextFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.RichText, + condition: TextFilterConditionPB.TextIsEmpty, + content: "c", + ), + ), + ); + }); + + test('number', () async { + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.Number, + NumberFilterPB( + condition: NumberFilterConditionPB.GreaterThan, + content: "", + ).writeToBuffer(), + ), + ), + equals( + NumberFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.Number, + condition: NumberFilterConditionPB.GreaterThan, + content: "", + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.Number, + NumberFilterPB( + condition: NumberFilterConditionPB.GreaterThan, + content: "123", + ).writeToBuffer(), + ), + ), + equals( + NumberFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.Number, + condition: NumberFilterConditionPB.GreaterThan, + content: "123", + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.Number, + NumberFilterPB( + condition: NumberFilterConditionPB.NumberIsEmpty, + content: "", + ).writeToBuffer(), + ), + ), + equals( + NumberFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.Number, + condition: NumberFilterConditionPB.NumberIsEmpty, + content: "", + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.Number, + NumberFilterPB( + condition: NumberFilterConditionPB.NumberIsEmpty, + content: "", + ).writeToBuffer(), + ), + ), + equals( + NumberFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.Number, + condition: NumberFilterConditionPB.NumberIsEmpty, + content: "123", + ), + ), + ); + }); + + test('checkbox', () async { + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.Checkbox, + CheckboxFilterPB( + condition: CheckboxFilterConditionPB.IsChecked, + ).writeToBuffer(), + ), + ), + equals( + const CheckboxFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.Checkbox, + condition: CheckboxFilterConditionPB.IsChecked, + ), + ), + ); + }); + + test('checklist', () async { + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.Checklist, + ChecklistFilterPB( + condition: ChecklistFilterConditionPB.IsComplete, + ).writeToBuffer(), + ), + ), + equals( + const ChecklistFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.Checklist, + condition: ChecklistFilterConditionPB.IsComplete, + ), + ), + ); + }); + + test('single select option', () async { + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.SingleSelect, + SelectOptionFilterPB( + condition: SelectOptionFilterConditionPB.OptionIs, + optionIds: [], + ).writeToBuffer(), + ), + ), + equals( + SelectOptionFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.SingleSelect, + condition: SelectOptionFilterConditionPB.OptionIs, + optionIds: const [], + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.SingleSelect, + SelectOptionFilterPB( + condition: SelectOptionFilterConditionPB.OptionIs, + optionIds: ['a'], + ).writeToBuffer(), + ), + ), + equals( + SelectOptionFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.SingleSelect, + condition: SelectOptionFilterConditionPB.OptionIs, + optionIds: const ['a'], + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.SingleSelect, + SelectOptionFilterPB( + condition: SelectOptionFilterConditionPB.OptionIs, + optionIds: ['a', 'b'], + ).writeToBuffer(), + ), + ), + equals( + SelectOptionFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.SingleSelect, + condition: SelectOptionFilterConditionPB.OptionIs, + optionIds: const ['a', 'b'], + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.SingleSelect, + SelectOptionFilterPB( + condition: SelectOptionFilterConditionPB.OptionIsEmpty, + optionIds: [], + ).writeToBuffer(), + ), + ), + equals( + SelectOptionFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.SingleSelect, + condition: SelectOptionFilterConditionPB.OptionIsEmpty, + optionIds: const [], + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.SingleSelect, + SelectOptionFilterPB( + condition: SelectOptionFilterConditionPB.OptionIsEmpty, + optionIds: ['a'], + ).writeToBuffer(), + ), + ), + equals( + SelectOptionFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.SingleSelect, + condition: SelectOptionFilterConditionPB.OptionIsEmpty, + optionIds: const [], + ), + ), + ); + }); + + test('multi select option', () async { + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.MultiSelect, + SelectOptionFilterPB( + condition: SelectOptionFilterConditionPB.OptionContains, + optionIds: [], + ).writeToBuffer(), + ), + ), + equals( + SelectOptionFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.MultiSelect, + condition: SelectOptionFilterConditionPB.OptionContains, + optionIds: const [], + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.MultiSelect, + SelectOptionFilterPB( + condition: SelectOptionFilterConditionPB.OptionContains, + optionIds: ['a'], + ).writeToBuffer(), + ), + ), + equals( + SelectOptionFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.MultiSelect, + condition: SelectOptionFilterConditionPB.OptionContains, + optionIds: const ['a'], + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.MultiSelect, + SelectOptionFilterPB( + condition: SelectOptionFilterConditionPB.OptionContains, + optionIds: ['a', 'b'], + ).writeToBuffer(), + ), + ), + equals( + SelectOptionFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.MultiSelect, + condition: SelectOptionFilterConditionPB.OptionContains, + optionIds: const ['a', 'b'], + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.MultiSelect, + SelectOptionFilterPB( + condition: SelectOptionFilterConditionPB.OptionIs, + optionIds: ['a', 'b'], + ).writeToBuffer(), + ), + ), + equals( + SelectOptionFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.MultiSelect, + condition: SelectOptionFilterConditionPB.OptionIs, + optionIds: const ['a', 'b'], + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.MultiSelect, + SelectOptionFilterPB( + condition: SelectOptionFilterConditionPB.OptionIsEmpty, + optionIds: [], + ).writeToBuffer(), + ), + ), + equals( + SelectOptionFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.MultiSelect, + condition: SelectOptionFilterConditionPB.OptionIsEmpty, + optionIds: const [], + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.MultiSelect, + SelectOptionFilterPB( + condition: SelectOptionFilterConditionPB.OptionIsEmpty, + optionIds: ['a'], + ).writeToBuffer(), + ), + ), + equals( + SelectOptionFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.MultiSelect, + condition: SelectOptionFilterConditionPB.OptionIsEmpty, + optionIds: const [], + ), + ), + ); + }); + + test('date time', () { + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.DateTime, + DateFilterPB( + condition: DateFilterConditionPB.DateStartsOn, + timestamp: Int64(5), + ).writeToBuffer(), + ), + ), + equals( + DateTimeFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.DateTime, + condition: DateFilterConditionPB.DateStartsOn, + timestamp: DateTime.fromMillisecondsSinceEpoch(5 * 1000), + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.DateTime, + DateFilterPB( + condition: DateFilterConditionPB.DateStartsOn, + ).writeToBuffer(), + ), + ), + equals( + DateTimeFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.DateTime, + condition: DateFilterConditionPB.DateStartsOn, + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.DateTime, + DateFilterPB( + condition: DateFilterConditionPB.DateStartsOn, + start: Int64(5), + ).writeToBuffer(), + ), + ), + equals( + DateTimeFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.DateTime, + condition: DateFilterConditionPB.DateStartsOn, + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.DateTime, + DateFilterPB( + condition: DateFilterConditionPB.DateEndsBetween, + start: Int64(5), + ).writeToBuffer(), + ), + ), + equals( + DateTimeFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.DateTime, + condition: DateFilterConditionPB.DateEndsBetween, + start: DateTime.fromMillisecondsSinceEpoch(5 * 1000), + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.DateTime, + DateFilterPB( + condition: DateFilterConditionPB.DateEndIsNotEmpty, + ).writeToBuffer(), + ), + ), + equals( + DateTimeFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.DateTime, + condition: DateFilterConditionPB.DateEndIsNotEmpty, + ), + ), + ); + + expect( + DatabaseFilter.fromPB( + createFilterPB( + FieldType.DateTime, + DateFilterPB( + condition: DateFilterConditionPB.DateEndIsNotEmpty, + start: Int64(5), + end: Int64(5), + timestamp: Int64(5), + ).writeToBuffer(), + ), + ), + equals( + DateTimeFilter( + filterId: "FT", + fieldId: "FD", + fieldType: FieldType.DateTime, + condition: DateFilterConditionPB.DateEndIsNotEmpty, + ), + ), + ); + }); + }); + + // group('write to buffer', () { + // test('text', () {}); + // }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/grid_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/grid_bloc_test.dart new file mode 100644 index 0000000000000..8af1a4b7e1d35 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/grid_bloc_test.dart @@ -0,0 +1,80 @@ +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'util.dart'; + +void main() { + late AppFlowyGridTest gridTest; + + setUpAll(() async { + gridTest = await AppFlowyGridTest.ensureInitialized(); + }); + + group('Edit Grid:', () { + late GridTestContext context; + + setUp(() async { + context = await gridTest.makeDefaultTestGrid(); + }); + + // The initial number of rows is 3 for each grid + // We create one row so we expect 4 rows + blocTest( + "create a row", + build: () => GridBloc( + view: context.view, + databaseController: DatabaseController(view: context.view), + )..add(const GridEvent.initial()), + act: (bloc) => bloc.add(const GridEvent.createRow()), + wait: gridResponseDuration(), + verify: (bloc) { + expect(bloc.state.rowInfos.length, equals(4)); + }, + ); + + blocTest( + "delete the last row", + build: () => GridBloc( + view: context.view, + databaseController: DatabaseController(view: context.view), + )..add(const GridEvent.initial()), + act: (bloc) async { + await gridResponseFuture(); + bloc.add(GridEvent.deleteRow(bloc.state.rowInfos.last)); + }, + wait: gridResponseDuration(), + verify: (bloc) { + expect(bloc.state.rowInfos.length, equals(2)); + }, + ); + + String? firstId; + String? secondId; + String? thirdId; + + blocTest( + 'reorder rows', + build: () => GridBloc( + view: context.view, + databaseController: DatabaseController(view: context.view), + )..add(const GridEvent.initial()), + act: (bloc) async { + await gridResponseFuture(); + + firstId = bloc.state.rowInfos[0].rowId; + secondId = bloc.state.rowInfos[1].rowId; + thirdId = bloc.state.rowInfos[2].rowId; + + bloc.add(const GridEvent.moveRow(0, 2)); + }, + wait: gridResponseDuration(), + verify: (bloc) { + expect(secondId, equals(bloc.state.rowInfos[0].rowId)); + expect(thirdId, equals(bloc.state.rowInfos[1].rowId)); + expect(firstId, equals(bloc.state.rowInfos[2].rowId)); + }, + ); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/sort/sort_editor_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/sort/sort_editor_bloc_test.dart new file mode 100644 index 0000000000000..a0abebd214b5b --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/sort/sort_editor_bloc_test.dart @@ -0,0 +1,204 @@ +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../util.dart'; + +void main() { + late AppFlowyGridTest gridTest; + + setUpAll(() async { + gridTest = await AppFlowyGridTest.ensureInitialized(); + }); + + group('sort editor bloc:', () { + late GridTestContext context; + late SortEditorBloc sortBloc; + + setUp(() async { + context = await gridTest.makeDefaultTestGrid(); + sortBloc = SortEditorBloc( + viewId: context.viewId, + fieldController: context.fieldController, + ); + }); + + FieldInfo getFirstFieldByType(FieldType fieldType) { + return context.fieldController.fieldInfos + .firstWhere((field) => field.fieldType == fieldType); + } + + test('create sort', () async { + expect(sortBloc.state.sorts.length, equals(0)); + expect(sortBloc.state.creatableFields.length, equals(3)); + expect(sortBloc.state.allFields.length, equals(3)); + + final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); + sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); + await gridResponseFuture(); + expect(sortBloc.state.sorts.length, 1); + expect(sortBloc.state.sorts.first.fieldId, selectOptionField.id); + expect( + sortBloc.state.sorts.first.condition, + SortConditionPB.Ascending, + ); + expect(sortBloc.state.creatableFields.length, equals(2)); + expect(sortBloc.state.allFields.length, equals(3)); + }); + + test('change sort field', () async { + final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); + sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); + await gridResponseFuture(); + + expect( + sortBloc.state.creatableFields + .map((e) => e.id) + .contains(selectOptionField.id), + false, + ); + + final checkboxField = getFirstFieldByType(FieldType.Checkbox); + sortBloc.add( + SortEditorEvent.editSort( + sortId: sortBloc.state.sorts.first.sortId, + fieldId: checkboxField.id, + ), + ); + await gridResponseFuture(); + + expect(sortBloc.state.creatableFields.length, equals(2)); + expect( + sortBloc.state.creatableFields + .map((e) => e.id) + .contains(checkboxField.id), + false, + ); + }); + + test('update sort direction', () async { + final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); + sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); + await gridResponseFuture(); + + expect( + sortBloc.state.sorts.first.condition, + SortConditionPB.Ascending, + ); + + sortBloc.add( + SortEditorEvent.editSort( + sortId: sortBloc.state.sorts.first.sortId, + condition: SortConditionPB.Descending, + ), + ); + await gridResponseFuture(); + + expect( + sortBloc.state.sorts.first.condition, + SortConditionPB.Descending, + ); + }); + + test('reorder sorts', () async { + final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); + final checkboxField = getFirstFieldByType(FieldType.Checkbox); + sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); + await gridResponseFuture(); + sortBloc.add(SortEditorEvent.createSort(fieldId: checkboxField.id)); + await gridResponseFuture(); + + expect(sortBloc.state.sorts[0].fieldId, selectOptionField.id); + expect(sortBloc.state.sorts[1].fieldId, checkboxField.id); + expect(sortBloc.state.creatableFields.length, equals(1)); + expect(sortBloc.state.allFields.length, equals(3)); + + sortBloc.add( + const SortEditorEvent.reorderSort(0, 2), + ); + await gridResponseFuture(); + + expect(sortBloc.state.sorts[0].fieldId, checkboxField.id); + expect(sortBloc.state.sorts[1].fieldId, selectOptionField.id); + expect(sortBloc.state.creatableFields.length, equals(1)); + expect(sortBloc.state.allFields.length, equals(3)); + }); + + test('delete sort', () async { + final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); + sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); + await gridResponseFuture(); + + expect(sortBloc.state.sorts.length, 1); + + sortBloc.add( + SortEditorEvent.deleteSort(sortBloc.state.sorts.first.sortId), + ); + await gridResponseFuture(); + + expect(sortBloc.state.sorts.length, 0); + expect(sortBloc.state.creatableFields.length, equals(3)); + expect(sortBloc.state.allFields.length, equals(3)); + }); + + test('delete all sorts', () async { + final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); + final checkboxField = getFirstFieldByType(FieldType.Checkbox); + sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); + await gridResponseFuture(); + sortBloc.add(SortEditorEvent.createSort(fieldId: checkboxField.id)); + await gridResponseFuture(); + + expect(sortBloc.state.sorts.length, 2); + + sortBloc.add(const SortEditorEvent.deleteAllSorts()); + await gridResponseFuture(); + + expect(sortBloc.state.sorts.length, 0); + expect(sortBloc.state.creatableFields.length, equals(3)); + expect(sortBloc.state.allFields.length, equals(3)); + }); + + test('update sort field', () async { + final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); + sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); + await gridResponseFuture(); + + expect(sortBloc.state.sorts.length, equals(1)); + + // edit field + await FieldBackendService( + viewId: context.viewId, + fieldId: selectOptionField.id, + ).updateField(name: "HERRO"); + await gridResponseFuture(); + + expect(sortBloc.state.sorts.length, equals(1)); + expect(sortBloc.state.allFields[1].name, "HERRO"); + + expect(sortBloc.state.creatableFields.length, equals(2)); + expect(sortBloc.state.allFields.length, equals(3)); + }); + + test('delete sorting field', () async { + final selectOptionField = getFirstFieldByType(FieldType.SingleSelect); + sortBloc.add(SortEditorEvent.createSort(fieldId: selectOptionField.id)); + await gridResponseFuture(); + + expect(sortBloc.state.sorts.length, equals(1)); + + // edit field + await FieldBackendService( + viewId: context.viewId, + fieldId: selectOptionField.id, + ).delete(); + await gridResponseFuture(); + + expect(sortBloc.state.sorts.length, equals(0)); + expect(sortBloc.state.creatableFields.length, equals(2)); + expect(sortBloc.state.allFields.length, equals(2)); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart b/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart new file mode 100644 index 0000000000000..e80ff1cc4b7bb --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/grid_test/util.dart @@ -0,0 +1,157 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_controller.dart'; +import 'package:appflowy/plugins/database/application/field/field_editor_bloc.dart'; +import 'package:appflowy/plugins/database/domain/field_service.dart'; +import 'package:appflowy/plugins/database/application/row/row_cache.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/workspace/application/settings/share/import_service.dart'; +import 'package:appflowy/workspace/application/view/view_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:flutter/services.dart'; + +import '../../util.dart'; + +const v020GridFileName = "v020.afdb"; +const v069GridFileName = "v069.afdb"; + +class GridTestContext { + GridTestContext(this.view, this.databaseController); + + final ViewPB view; + final DatabaseController databaseController; + + String get viewId => view.id; + + List get rowInfos { + return databaseController.rowCache.rowInfos; + } + + FieldController get fieldController => databaseController.fieldController; + + Future createField(FieldType fieldType) async { + final editorBloc = + await createFieldEditor(databaseController: databaseController); + await gridResponseFuture(); + editorBloc.add(FieldEditorEvent.switchFieldType(fieldType)); + await gridResponseFuture(); + return editorBloc; + } + + CellController makeGridCellController(int fieldIndex, int rowIndex) { + return makeCellController( + databaseController, + CellContext( + fieldId: fieldController.fieldInfos[fieldIndex].id, + rowId: rowInfos[rowIndex].rowId, + ), + ).as(); + } +} + +Future createFieldEditor({ + required DatabaseController databaseController, +}) async { + final result = await FieldBackendService.createField( + viewId: databaseController.viewId, + ); + await gridResponseFuture(); + return result.fold( + (field) { + return FieldEditorBloc( + viewId: databaseController.viewId, + fieldController: databaseController.fieldController, + fieldInfo: databaseController.fieldController.getField(field.id)!, + isNew: true, + ); + }, + (err) => throw Exception(err), + ); +} + +/// Create a empty Grid for test +class AppFlowyGridTest { + AppFlowyGridTest({required this.unitTest}); + + final AppFlowyUnitTest unitTest; + + static Future ensureInitialized() async { + final inner = await AppFlowyUnitTest.ensureInitialized(); + return AppFlowyGridTest(unitTest: inner); + } + + Future makeDefaultTestGrid() async { + final workspace = await unitTest.createWorkspace(); + final context = await ViewBackendService.createView( + parentViewId: workspace.id, + name: "Test Grid", + layoutType: ViewLayoutPB.Grid, + openAfterCreate: true, + ).fold( + (view) async { + final databaseController = DatabaseController(view: view); + await databaseController + .open() + .fold((l) => null, (r) => throw Exception(r)); + return GridTestContext( + view, + databaseController, + ); + }, + (error) => throw Exception(), + ); + + return context; + } + + Future makeTestGridFromImportedData( + String fileName, + ) async { + final workspace = await unitTest.createWorkspace(); + + // Don't use the p.join to build the path that used in loadString. It + // is not working on windows. + final data = await rootBundle + .loadString("assets/test/workspaces/database/$fileName"); + + final context = await ImportBackendService.importPages( + workspace.id, + [ + ImportItemPayloadPB() + ..name = fileName + ..data = utf8.encode(data) + ..viewLayout = ViewLayoutPB.Grid + ..importType = ImportTypePB.AFDatabase, + ], + ).fold( + (views) async { + final view = views.items.first; + final databaseController = DatabaseController(view: view); + await databaseController + .open() + .fold((l) => null, (r) => throw Exception(r)); + return GridTestContext( + view, + databaseController, + ); + }, + (err) => throw Exception(), + ); + + return context; + } +} + +Future gridResponseFuture({int milliseconds = 300}) { + return Future.delayed( + gridResponseDuration(milliseconds: milliseconds), + ); +} + +Duration gridResponseDuration({int milliseconds = 300}) { + return Duration(milliseconds: milliseconds); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/home_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/home_bloc_test.dart new file mode 100644 index 0000000000000..6a34bc68d060a --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/home_bloc_test.dart @@ -0,0 +1,61 @@ +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/workspace/application/home/home_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../util.dart'; + +void main() { + late AppFlowyUnitTest testContext; + setUpAll(() async { + testContext = await AppFlowyUnitTest.ensureInitialized(); + }); + + test('init home screen', () async { + final workspaceSetting = await FolderEventGetCurrentWorkspaceSetting() + .send() + .then((result) => result.fold((l) => l, (r) => throw Exception())); + await blocResponseFuture(); + + final homeBloc = HomeBloc(workspaceSetting)..add(const HomeEvent.initial()); + await blocResponseFuture(); + + assert(homeBloc.state.workspaceSetting.hasLatestView()); + }); + + test('open the document', () async { + final workspaceSetting = await FolderEventGetCurrentWorkspaceSetting() + .send() + .then((result) => result.fold((l) => l, (r) => throw Exception())); + await blocResponseFuture(); + + final homeBloc = HomeBloc(workspaceSetting)..add(const HomeEvent.initial()); + await blocResponseFuture(); + + final app = await testContext.createWorkspace(); + final appBloc = ViewBloc(view: app)..add(const ViewEvent.initial()); + assert(appBloc.state.lastCreatedView == null); + + appBloc.add( + const ViewEvent.createView( + "New document", + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(); + + assert(appBloc.state.lastCreatedView != null); + final latestView = appBloc.state.lastCreatedView!; + final _ = DocumentBloc(documentId: latestView.id) + ..add(const DocumentEvent.initial()); + + await FolderEventSetLatestView(ViewIdPB(value: latestView.id)).send(); + await blocResponseFuture(); + + final actual = homeBloc.state.workspaceSetting.latestView.id; + assert(actual == latestView.id); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/sidebar_section_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/sidebar_section_bloc_test.dart new file mode 100644 index 0000000000000..75ade70a87bfc --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/sidebar_section_bloc_test.dart @@ -0,0 +1,57 @@ +import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../util.dart'; + +void main() { + late AppFlowyUnitTest testContext; + setUpAll(() async { + testContext = await AppFlowyUnitTest.ensureInitialized(); + }); + + test('assert initial apps is the build-in app', () async { + final menuBloc = SidebarSectionsBloc() + ..add( + SidebarSectionsEvent.initial( + testContext.userProfile, + testContext.currentWorkspace.id, + ), + ); + + await blocResponseFuture(); + + assert(menuBloc.state.section.publicViews.length == 1); + assert(menuBloc.state.section.privateViews.isEmpty); + }); + + test('create views', () async { + final menuBloc = SidebarSectionsBloc() + ..add( + SidebarSectionsEvent.initial( + testContext.userProfile, + testContext.currentWorkspace.id, + ), + ); + await blocResponseFuture(); + + final names = ['View 1', 'View 2', 'View 3']; + for (final name in names) { + menuBloc.add( + SidebarSectionsEvent.createRootViewInSection( + name: name, + index: 0, + viewSection: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(); + } + + final reversedNames = names.reversed.toList(); + for (var i = 0; i < names.length; i++) { + assert( + menuBloc.state.section.publicViews[i].name == reversedNames[i], + ); + } + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/trash_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/trash_bloc_test.dart new file mode 100644 index 0000000000000..eed8f177b214c --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/trash_bloc_test.dart @@ -0,0 +1,115 @@ +import 'package:appflowy/plugins/trash/application/trash_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../util.dart'; + +class TrashTestContext { + TrashTestContext(this.unitTest); + + late ViewPB view; + late ViewBloc viewBloc; + late List allViews; + final AppFlowyUnitTest unitTest; + + Future initialize() async { + view = await unitTest.createWorkspace(); + viewBloc = ViewBloc(view: view)..add(const ViewEvent.initial()); + await blocResponseFuture(); + + viewBloc.add( + const ViewEvent.createView( + "Document 1", + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(millisecond: 300); + + viewBloc.add( + const ViewEvent.createView( + "Document 2", + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(millisecond: 300); + + viewBloc.add( + const ViewEvent.createView( + "Document 3", + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(millisecond: 300); + + allViews = [...viewBloc.state.view.childViews]; + assert(allViews.length == 3, 'but receive ${allViews.length}'); + } +} + +void main() { + late AppFlowyUnitTest unitTest; + setUpAll(() async { + unitTest = await AppFlowyUnitTest.ensureInitialized(); + }); + + // 1. Create three views + // 2. Delete a view and check the state + // 3. Delete all views and check the state + // 4. Put back a view + // 5. Put back all views + + group('trash test: ', () { + test('delete a view', () async { + final context = TrashTestContext(unitTest); + await context.initialize(); + final trashBloc = TrashBloc()..add(const TrashEvent.initial()); + await blocResponseFuture(); + + // delete a view + final deletedView = context.viewBloc.state.view.childViews[0]; + final deleteViewBloc = ViewBloc(view: deletedView) + ..add(const ViewEvent.initial()); + await blocResponseFuture(); + deleteViewBloc.add(const ViewEvent.delete()); + await blocResponseFuture(millisecond: 1000); + assert(context.viewBloc.state.view.childViews.length == 2); + assert(trashBloc.state.objects.length == 1); + assert(trashBloc.state.objects.first.id == deletedView.id); + + // put back + trashBloc.add(TrashEvent.putback(deletedView.id)); + await blocResponseFuture(millisecond: 1000); + assert(context.viewBloc.state.view.childViews.length == 3); + assert(trashBloc.state.objects.isEmpty); + + // delete all views + for (final view in context.allViews) { + final deleteViewBloc = ViewBloc(view: view) + ..add(const ViewEvent.initial()); + await blocResponseFuture(); + deleteViewBloc.add(const ViewEvent.delete()); + await blocResponseFuture(millisecond: 1000); + } + expect(trashBloc.state.objects[0].id, context.allViews[0].id); + expect(trashBloc.state.objects[1].id, context.allViews[1].id); + expect(trashBloc.state.objects[2].id, context.allViews[2].id); + + // delete a view permanently + trashBloc.add(TrashEvent.delete(trashBloc.state.objects[0])); + await blocResponseFuture(millisecond: 1000); + expect(trashBloc.state.objects.length, 2); + + // delete all view permanently + trashBloc.add(const TrashEvent.deleteAll()); + await blocResponseFuture(millisecond: 1000); + assert( + trashBloc.state.objects.isEmpty, + "but receive ${trashBloc.state.objects.length}", + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart b/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart new file mode 100644 index 0000000000000..d6d0351414408 --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/home_test/view_bloc_test.dart @@ -0,0 +1,232 @@ +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../util.dart'; + +void main() { + const name = 'Hello world'; + + late AppFlowyUnitTest testContext; + + setUpAll(() async { + testContext = await AppFlowyUnitTest.ensureInitialized(); + }); + + Future createTestViewBloc() async { + final view = await testContext.createWorkspace(); + final viewBloc = ViewBloc(view: view) + ..add( + const ViewEvent.initial(), + ); + await blocResponseFuture(); + return viewBloc; + } + + test('rename view test', () async { + final viewBloc = await createTestViewBloc(); + viewBloc.add(const ViewEvent.rename(name)); + await blocResponseFuture(); + expect(viewBloc.state.view.name, name); + }); + + test('duplicate view test', () async { + final viewBloc = await createTestViewBloc(); + // create a nested view + viewBloc.add( + const ViewEvent.createView( + name, + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(); + expect(viewBloc.state.view.childViews.length, 1); + final childViewBloc = ViewBloc(view: viewBloc.state.view.childViews.first) + ..add( + const ViewEvent.initial(), + ); + childViewBloc.add(const ViewEvent.duplicate()); + await blocResponseFuture(millisecond: 1000); + expect(viewBloc.state.view.childViews.length, 2); + }); + + test('delete view test', () async { + final viewBloc = await createTestViewBloc(); + viewBloc.add( + const ViewEvent.createView( + name, + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(); + expect(viewBloc.state.view.childViews.length, 1); + final childViewBloc = ViewBloc(view: viewBloc.state.view.childViews.first) + ..add( + const ViewEvent.initial(), + ); + await blocResponseFuture(); + childViewBloc.add(const ViewEvent.delete()); + await blocResponseFuture(); + assert(viewBloc.state.view.childViews.isEmpty); + }); + + test('create nested view test', () async { + final viewBloc = await createTestViewBloc(); + viewBloc.add( + const ViewEvent.createView( + 'Document 1', + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(); + final document1Bloc = ViewBloc(view: viewBloc.state.view.childViews.first) + ..add( + const ViewEvent.initial(), + ); + await blocResponseFuture(); + const name = 'Document 1 - 1'; + document1Bloc.add( + const ViewEvent.createView( + 'Document 1 - 1', + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(); + expect(document1Bloc.state.view.childViews.length, 1); + expect(document1Bloc.state.view.childViews.first.name, name); + }); + + test('create documents in order', () async { + final viewBloc = await createTestViewBloc(); + final names = ['1', '2', '3']; + for (final name in names) { + viewBloc.add( + ViewEvent.createView( + name, + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(millisecond: 400); + } + + expect(viewBloc.state.view.childViews.length, 3); + for (var i = 0; i < names.length; i++) { + expect(viewBloc.state.view.childViews[i].name, names[i]); + } + }); + + test('open latest view test', () async { + final viewBloc = await createTestViewBloc(); + expect(viewBloc.state.lastCreatedView, isNull); + + viewBloc.add( + const ViewEvent.createView( + '1', + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(); + expect( + viewBloc.state.lastCreatedView!.id, + viewBloc.state.view.childViews.last.id, + ); + expect( + viewBloc.state.lastCreatedView!.name, + '1', + ); + + viewBloc.add( + const ViewEvent.createView( + '2', + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(); + expect( + viewBloc.state.lastCreatedView!.name, + '2', + ); + }); + + test('open latest document test', () async { + const name1 = 'document'; + final viewBloc = await createTestViewBloc(); + viewBloc.add( + const ViewEvent.createView( + name1, + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(); + final document = viewBloc.state.lastCreatedView!; + assert(document.name == name1); + + const gird = 'grid'; + viewBloc.add( + const ViewEvent.createView( + gird, + ViewLayoutPB.Document, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(); + assert(viewBloc.state.lastCreatedView!.name == gird); + + var workspaceSetting = + await FolderEventGetCurrentWorkspaceSetting().send().then( + (result) => result.fold( + (l) => l, + (r) => throw Exception(), + ), + ); + workspaceSetting.latestView.id == viewBloc.state.lastCreatedView!.id; + + // ignore: unused_local_variable + final documentBloc = DocumentBloc(documentId: document.id) + ..add( + const DocumentEvent.initial(), + ); + await blocResponseFuture(); + + workspaceSetting = + await FolderEventGetCurrentWorkspaceSetting().send().then( + (result) => result.fold( + (l) => l, + (r) => throw Exception(), + ), + ); + workspaceSetting.latestView.id == document.id; + }); + + test('create views', () async { + final viewBloc = await createTestViewBloc(); + const layouts = ViewLayoutPB.values; + for (var i = 0; i < layouts.length; i++) { + final layout = layouts[i]; + if (layout == ViewLayoutPB.Chat) { + continue; + } + viewBloc.add( + ViewEvent.createView( + 'Test $layout', + layout, + section: ViewSectionPB.Public, + ), + ); + await blocResponseFuture(millisecond: 1000); + expect(viewBloc.state.view.childViews.length, i + 1); + expect(viewBloc.state.view.childViews.last.name, 'Test $layout'); + expect(viewBloc.state.view.childViews.last.layout, layout); + } + }); +} diff --git a/frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart b/frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart new file mode 100644 index 0000000000000..ce57c61bd7daf --- /dev/null +++ b/frontend/appflowy_flutter/test/bloc_test/shortcuts_test/shortcuts_cubit_test.dart @@ -0,0 +1,164 @@ +import 'dart:ffi'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart'; +import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +// ignore: depend_on_referenced_packages +import 'package:mocktail/mocktail.dart'; + +class MockSettingsShortcutService extends Mock + implements SettingsShortcutService {} + +void main() { + group("ShortcutsCubit", () { + late SettingsShortcutService service; + late ShortcutsCubit shortcutsCubit; + + setUp(() async { + service = MockSettingsShortcutService(); + when( + () => service.saveAllShortcuts(any()), + ).thenAnswer((_) async => true); + when( + () => service.getCustomizeShortcuts(), + ).thenAnswer((_) async => []); + when( + () => service.updateCommandShortcuts(any(), any()), + ).thenAnswer((_) async => Void); + + shortcutsCubit = ShortcutsCubit(service); + }); + + test('initial state is correct', () { + final shortcutsCubit = ShortcutsCubit(service); + expect(shortcutsCubit.state, const ShortcutsState()); + }); + + group('fetchShortcuts', () { + blocTest( + 'calls getCustomizeShortcuts() once', + build: () => shortcutsCubit, + act: (cubit) => cubit.fetchShortcuts(), + verify: (_) { + verify(() => service.getCustomizeShortcuts()).called(1); + }, + ); + + blocTest( + 'emits [updating, failure] when getCustomizeShortcuts() throws', + setUp: () { + when( + () => service.getCustomizeShortcuts(), + ).thenThrow(Exception('oops')); + }, + build: () => shortcutsCubit, + act: (cubit) => cubit.fetchShortcuts(), + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.failure), + ], + ); + + blocTest( + 'emits [updating, success] when getCustomizeShortcuts() returns shortcuts', + build: () => shortcutsCubit, + act: (cubit) => cubit.fetchShortcuts(), + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.success) + .having( + (w) => w.commandShortcutEvents, + 'shortcuts', + commandShortcutEvents, + ), + ], + ); + }); + + group('updateShortcut', () { + blocTest( + 'calls saveAllShortcuts() once', + build: () => shortcutsCubit, + act: (cubit) => cubit.updateAllShortcuts(), + verify: (_) { + verify(() => service.saveAllShortcuts(any())).called(1); + }, + ); + + blocTest( + 'emits [updating, failure] when saveAllShortcuts() throws', + setUp: () { + when( + () => service.saveAllShortcuts(any()), + ).thenThrow(Exception('oops')); + }, + build: () => shortcutsCubit, + act: (cubit) => cubit.updateAllShortcuts(), + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.failure), + ], + ); + + blocTest( + 'emits [updating, success] when saveAllShortcuts() is successful', + build: () => shortcutsCubit, + act: (cubit) => cubit.updateAllShortcuts(), + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.success), + ], + ); + }); + + group('resetToDefault', () { + blocTest( + 'calls saveAllShortcuts() once', + build: () => shortcutsCubit, + act: (cubit) => cubit.resetToDefault(), + verify: (_) { + verify(() => service.saveAllShortcuts(any())).called(1); + verify(() => service.getCustomizeShortcuts()).called(1); + }, + ); + + blocTest( + 'emits [updating, failure] when saveAllShortcuts() throws', + setUp: () { + when( + () => service.saveAllShortcuts(any()), + ).thenThrow(Exception('oops')); + }, + build: () => shortcutsCubit, + act: (cubit) => cubit.resetToDefault(), + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.failure), + ], + ); + + blocTest( + 'emits [updating, success] when getCustomizeShortcuts() returns shortcuts', + build: () => shortcutsCubit, + act: (cubit) => cubit.resetToDefault(), + expect: () => [ + const ShortcutsState(status: ShortcutsStatus.updating), + isA() + .having((w) => w.status, 'status', ShortcutsStatus.success) + .having( + (w) => w.commandShortcutEvents, + 'shortcuts', + commandShortcutEvents, + ), + ], + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart b/frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart new file mode 100644 index 0000000000000..2ccc3dad7ab60 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/util/levenshtein.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Levenshtein distance between identical strings', () { + final distance = levenshtein('abc', 'abc'); + expect(distance, 0); + }); + + test('Levenshtein distance between strings of different lengths', () { + final distance = levenshtein('kitten', 'sitting'); + expect(distance, 3); + }); + + test('Levenshtein distance between case-insensitive strings', () { + final distance = levenshtein('Hello', 'hello', caseSensitive: false); + expect(distance, 0); + }); + + test('Levenshtein distance between strings with substitutions', () { + final distance = levenshtein('kitten', 'smtten'); + expect(distance, 2); + }); + + test('Levenshtein distance between strings with deletions', () { + final distance = levenshtein('kitten', 'kiten'); + expect(distance, 1); + }); + + test('Levenshtein distance between strings with insertions', () { + final distance = levenshtein('kitten', 'kitxten'); + expect(distance, 1); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/document/option_menu/block_action_option_cubit_test.dart b/frontend/appflowy_flutter/test/unit_test/document/option_menu/block_action_option_cubit_test.dart new file mode 100644 index 0000000000000..413d830d73760 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/document/option_menu/block_action_option_cubit_test.dart @@ -0,0 +1,81 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('block action option cubit:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test('delete blocks', () async { + const text = 'paragraph'; + final document = Document.blank() + ..insert([ + 0, + ], [ + paragraphNode(text: text), + paragraphNode(text: text), + paragraphNode(text: text), + ]); + + final editorState = EditorState(document: document); + final cubit = BlockActionOptionCubit( + editorState: editorState, + blockComponentBuilder: {}, + ); + + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text.length), + ); + editorState.selectionType = SelectionType.block; + + await cubit.handleAction(OptionAction.delete, document.nodeAtPath([0])!); + + // all the nodes should be deleted + expect(document.root.children, isEmpty); + + editorState.dispose(); + }); + + test('duplicate blocks', () async { + const text = 'paragraph'; + final document = Document.blank() + ..insert([ + 0, + ], [ + paragraphNode(text: text), + paragraphNode(text: text), + paragraphNode(text: text), + ]); + + final editorState = EditorState(document: document); + final cubit = BlockActionOptionCubit( + editorState: editorState, + blockComponentBuilder: {}, + ); + + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [2], offset: text.length), + ); + editorState.selectionType = SelectionType.block; + + await cubit.handleAction( + OptionAction.duplicate, + document.nodeAtPath([0])!, + ); + + expect(document.root.children, hasLength(6)); + + editorState.dispose(); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/document/shortcuts/toggle_list_shortcut_test.dart b/frontend/appflowy_flutter/test/unit_test/document/shortcuts/toggle_list_shortcut_test.dart new file mode 100644 index 0000000000000..fa84293a33345 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/document/shortcuts/toggle_list_shortcut_test.dart @@ -0,0 +1,114 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('toggle list shortcut:', () { + Document createDocument(List nodes) { + final document = Document.blank(); + document.insert([0], nodes); + return document; + } + + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + testWidgets('> + #', (tester) async { + const heading1 = '>Heading 1'; + const paragraph1 = 'paragraph 1'; + const paragraph2 = 'paragraph 2'; + + final document = createDocument([ + headingNode(level: 1, text: heading1), + paragraphNode(text: paragraph1), + paragraphNode(text: paragraph2), + ]); + + final editorState = EditorState(document: document); + editorState.selection = Selection.collapsed( + Position(path: [0], offset: 1), + ); + + final result = await formatGreaterToToggleList.execute(editorState); + expect(result, true); + + expect(editorState.document.root.children.length, 1); + final node = editorState.document.root.children[0]; + expect(node.type, ToggleListBlockKeys.type); + expect(node.attributes[ToggleListBlockKeys.level], 1); + expect(node.delta!.toPlainText(), 'Heading 1'); + expect(node.children.length, 2); + expect(node.children[0].delta!.toPlainText(), paragraph1); + expect(node.children[1].delta!.toPlainText(), paragraph2); + + editorState.dispose(); + }); + + testWidgets('convert block contains children to toggle list', + (tester) async { + const paragraph1 = '>paragraph 1'; + const paragraph1_1 = 'paragraph 1.1'; + const paragraph1_2 = 'paragraph 1.2'; + + final document = createDocument([ + paragraphNode( + text: paragraph1, + children: [ + paragraphNode(text: paragraph1_1), + paragraphNode(text: paragraph1_2), + ], + ), + ]); + + final editorState = EditorState(document: document); + editorState.selection = Selection.collapsed( + Position(path: [0], offset: 1), + ); + + final result = await formatGreaterToToggleList.execute(editorState); + expect(result, true); + + expect(editorState.document.root.children.length, 1); + final node = editorState.document.root.children[0]; + expect(node.type, ToggleListBlockKeys.type); + expect(node.delta!.toPlainText(), 'paragraph 1'); + expect(node.children.length, 2); + expect(node.children[0].delta!.toPlainText(), paragraph1_1); + expect(node.children[1].delta!.toPlainText(), paragraph1_2); + + editorState.dispose(); + }); + + testWidgets('press the enter key in empty toggle list', (tester) async { + const text = 'AppFlowy'; + + final document = createDocument([ + toggleListBlockNode(text: text, collapsed: true), + ]); + + final editorState = EditorState(document: document); + editorState.selection = Selection.collapsed( + Position(path: [0], offset: text.length), + ); + + // simulate the enter key press + final result = await insertChildNodeInsideToggleList.execute(editorState); + expect(result, true); + + final nodes = editorState.document.root.children; + expect(nodes.length, 2); + for (var i = 0; i < nodes.length; i++) { + final node = nodes[i]; + expect(node.type, ToggleListBlockKeys.type); + } + + editorState.dispose(); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart b/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart new file mode 100644 index 0000000000000..ed60e53ae7082 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/document/text_robot/markdown_text_robot_test.dart @@ -0,0 +1,582 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/markdown_text_robot.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('markdown text robot:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + Future testLiveRefresh( + List texts, { + required void Function(EditorState) expect, + }) async { + final editorState = EditorState.blank(); + editorState.selection = Selection.collapsed(Position(path: [0])); + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + markdownTextRobot.start(); + for (final text in texts) { + await markdownTextRobot.appendMarkdownText(text); + // mock the delay of the text robot + await Future.delayed(const Duration(milliseconds: 10)); + } + await markdownTextRobot.stop(); + + expect(editorState); + } + + test('parse markdown text (1)', () async { + final editorState = EditorState.blank(); + editorState.selection = Selection.collapsed(Position(path: [0])); + final markdownTextRobot = MarkdownTextRobot( + editorState: editorState, + ); + + markdownTextRobot.start(); + await markdownTextRobot.appendMarkdownText(_sample1); + await markdownTextRobot.stop(); + + final nodes = editorState.document.root.children; + // 4 from the sample, 1 from the original empty paragraph node + expect(nodes.length, 5); + + final n1 = nodes[0]; + expect(n1.delta!.toPlainText(), 'The Curious Cat'); + expect(n1.type, HeadingBlockKeys.type); + + final n2 = nodes[1]; + expect(n2.type, ParagraphBlockKeys.type); + expect(n2.delta!.toJson(), [ + {'insert': 'Once upon a time in a '}, + { + 'insert': 'quiet village', + 'attributes': {'bold': true}, + }, + {'insert': ', there lived a curious cat named '}, + { + 'insert': 'Whiskers', + 'attributes': {'italic': true}, + }, + {'insert': '. Unlike other cats, Whiskers had a passion for '}, + { + 'insert': 'exploration', + 'attributes': {'bold': true}, + }, + { + 'insert': + '. Every day, he\'d wander through the village, discovering hidden spots and making new friends with the local animals.', + }, + ]); + + final n3 = nodes[2]; + expect(n3.type, ParagraphBlockKeys.type); + expect(n3.delta!.toJson(), [ + {'insert': 'One sunny morning, Whiskers stumbled upon a mysterious '}, + { + 'insert': 'wooden box', + 'attributes': {'bold': true}, + }, + {'insert': ' behind the old barn. It was covered in '}, + { + 'insert': 'vines and dust', + 'attributes': {'italic': true}, + }, + { + 'insert': + '. Intrigued, he nudged it open with his paw and found a collection of ancient maps. These maps led to secret trails around the village.', + }, + ]); + + final n4 = nodes[3]; + expect(n4.type, ParagraphBlockKeys.type); + expect(n4.delta!.toJson(), [ + { + 'insert': + 'Whiskers became the village\'s hero, guiding everyone on exciting adventures.', + }, + ]); + }); + + // Live refresh - Partial sample + // ## The Decision + // - Aria found an ancient map in her grandmother's attic. + // - The map hinted at a mystical place known as the Enchanted Forest. + // - Legends spoke of the forest as a realm where dreams came to life. + test('live refresh (2)', () async { + await testLiveRefresh( + _liveRefreshSample2, + expect: (editorState) { + final nodes = editorState.document.root.children; + expect(nodes.length, 5); + + final n1 = nodes[0]; + expect(n1.type, HeadingBlockKeys.type); + expect(n1.delta!.toPlainText(), 'The Decision'); + + final n2 = nodes[1]; + expect(n2.type, BulletedListBlockKeys.type); + expect( + n2.delta!.toPlainText(), + 'Aria found an ancient map in her grandmother\'s attic.', + ); + + final n3 = nodes[2]; + expect(n3.type, BulletedListBlockKeys.type); + expect( + n3.delta!.toPlainText(), + 'The map hinted at a mystical place known as the Enchanted Forest.', + ); + + final n4 = nodes[3]; + expect(n4.type, BulletedListBlockKeys.type); + expect( + n4.delta!.toPlainText(), + 'Legends spoke of the forest as a realm where dreams came to life.', + ); + }, + ); + }); + + // Partial sample + // ## The Preparation + // Before embarking on her journey, Aria prepared meticulously: + // 1. Gather Supplies + // - A sturdy backpack + // - A compass and a map + // - Provisions for the week + // 2. Seek Guidance + // - Visited the village elder for advice + // - Listened to tales of past adventurers + // 3. Sharpen Skills + // - Practiced archery and swordsmanship + // - Enhanced survival skills + test('live refresh (3)', () async { + await testLiveRefresh( + _liveRefreshSample3, + expect: (editorState) { + final nodes = editorState.document.root.children; + expect(nodes.length, 6); + + final n1 = nodes[0]; + expect(n1.type, HeadingBlockKeys.type); + expect(n1.delta!.toPlainText(), 'The Preparation'); + + final n2 = nodes[1]; + expect(n2.type, ParagraphBlockKeys.type); + expect( + n2.delta!.toPlainText(), + 'Before embarking on her journey, Aria prepared meticulously:', + ); + + final n3 = nodes[2]; + expect(n3.type, NumberedListBlockKeys.type); + expect( + n3.delta!.toPlainText(), + 'Gather Supplies', + ); + + final n3c1 = n3.children[0]; + expect(n3c1.type, BulletedListBlockKeys.type); + expect(n3c1.delta!.toPlainText(), 'A sturdy backpack'); + + final n3c2 = n3.children[1]; + expect(n3c2.type, BulletedListBlockKeys.type); + expect(n3c2.delta!.toPlainText(), 'A compass and a map'); + + final n3c3 = n3.children[2]; + expect(n3c3.type, BulletedListBlockKeys.type); + expect(n3c3.delta!.toPlainText(), 'Provisions for the week'); + + final n4 = nodes[3]; + expect(n4.type, NumberedListBlockKeys.type); + expect(n4.delta!.toPlainText(), 'Seek Guidance'); + + final n4c1 = n4.children[0]; + expect(n4c1.type, BulletedListBlockKeys.type); + expect( + n4c1.delta!.toPlainText(), + 'Visited the village elder for advice', + ); + + final n4c2 = n4.children[1]; + expect(n4c2.type, BulletedListBlockKeys.type); + expect( + n4c2.delta!.toPlainText(), + 'Listened to tales of past adventurers', + ); + + final n5 = nodes[4]; + expect(n5.type, NumberedListBlockKeys.type); + expect( + n5.delta!.toPlainText(), + 'Sharpen Skills', + ); + + final n5c1 = n5.children[0]; + expect(n5c1.type, BulletedListBlockKeys.type); + expect( + n5c1.delta!.toPlainText(), + 'Practiced archery and swordsmanship', + ); + + final n5c2 = n5.children[1]; + expect(n5c2.type, BulletedListBlockKeys.type); + expect( + n5c2.delta!.toPlainText(), + 'Enhanced survival skills', + ); + }, + ); + }); + + // Partial sample + // Sure, let's provide an alternative Rust implementation for the Two Sum problem, focusing on clarity and efficiency but with a slightly different approach: + // ```rust + // fn two_sum(nums: &[i32], target: i32) -> Vec<(usize, usize)> { + // let mut results = Vec::new(); + // let mut map = std::collections::HashMap::new(); + // + // for (i, &num) in nums.iter().enumerate() { + // let complement = target - num; + // if let Some(&j) = map.get(&complement) { + // results.push((j, i)); + // } + // map.insert(num, i); + // } + // + // results + // } + // + // fn main() { + // let nums = vec![2, 7, 11, 15]; + // let target = 9; + // + // let pairs = two_sum(&nums, target); + // if pairs.is_empty() { + // println!("No two sum solution found"); + // } else { + // for (i, j) in pairs { + // println!("Indices: {}, {}", i, j); + // } + // } + // } + // ``` + test('live refresh (4)', () async { + await testLiveRefresh( + _liveRefreshSample4, + expect: (editorState) { + final nodes = editorState.document.root.children; + expect(nodes.length, 3); + + final n1 = nodes[0]; + expect(n1.type, ParagraphBlockKeys.type); + expect( + n1.delta!.toPlainText(), + '''Sure, let's provide an alternative Rust implementation for the Two Sum problem, focusing on clarity and efficiency but with a slightly different approach:''', + ); + + final n2 = nodes[1]; + expect(n2.type, CodeBlockKeys.type); + expect( + n2.delta!.toPlainText(), + isNotEmpty, + ); + expect(n2.attributes[CodeBlockKeys.language], 'rust'); + }, + ); + }); + }); +} + +const _sample1 = '''# The Curious Cat + +Once upon a time in a **quiet village**, there lived a curious cat named *Whiskers*. Unlike other cats, Whiskers had a passion for **exploration**. Every day, he'd wander through the village, discovering hidden spots and making new friends with the local animals. + +One sunny morning, Whiskers stumbled upon a mysterious **wooden box** behind the old barn. It was covered in _vines and dust_. Intrigued, he nudged it open with his paw and found a collection of ancient maps. These maps led to secret trails around the village. + +Whiskers became the village's hero, guiding everyone on exciting adventures.'''; + +const _liveRefreshSample2 = [ + "##", + " The", + " Decision", + "\n\n", + "-", + " Ar", + "ia", + " found", + " an", + " ancient map", + " in her grandmother", + "'s attic", + ".\n", + "-", + " The map", + " hinted at", + " a", + " mystical", + " place", + " known", + " as", + " the", + " En", + "ch", + "anted", + " Forest", + ".\n", + "-", + " Legends", + " spoke", + " of", + " the", + " forest", + " as", + " a realm", + " where dreams", + " came", + " to", + " life", + ".\n\n", +]; + +const _liveRefreshSample3 = [ + "##", + " The", + " Preparation\n\n", + "Before", + " embarking", + " on", + " her", + " journey", + ", Aria prepared", + " meticulously:\n\n", + "1", + ".", + " **", + "Gather", + " Supplies**", + " \n", + " ", + " -", + " A", + " sturdy", + " backpack", + "\n", + " ", + " -", + " A", + " compass", + " and", + " a map", + "\n ", + " -", + " Pro", + "visions", + " for", + " the", + " week", + "\n\n", + "2", + ".", + " **", + "Seek", + " Guidance", + "**", + " \n", + " ", + " -", + " Vis", + "ited", + " the", + " village", + " elder for advice", + "\n", + " -", + " List", + "ened", + " to", + " tales", + " of past", + " advent", + "urers", + "\n\n", + "3", + ".", + " **", + "Shar", + "pen", + " Skills", + "**", + " \n", + " ", + " -", + " Pract", + "iced", + " arch", + "ery", + " and", + " swordsmanship", + "\n ", + " -", + " Enhanced", + " survival skills", +]; + +const _liveRefreshSample4 = [ + "Sure", + ", let's", + " provide an", + " alternative Rust", + " implementation for the Two", + " Sum", + " problem", + ",", + " focusing", + " on", + " clarity", + " and efficiency", + " but with", + " a slightly", + " different approach", + ":\n\n", + "```", + "rust", + "\nfn two", + "_sum", + "(nums", + ": &[", + "i", + "32", + "],", + " target", + ":", + " i", + "32", + ")", + " ->", + " Vec", + "<(usize", + ", usize", + ")>", + " {\n", + " ", + " let", + " mut results", + " = Vec::", + "new", + "();\n", + " ", + " let mut", + " map", + " =", + " std::collections", + "::", + "HashMap", + "::", + "new", + "();\n\n ", + " for (", + "i,", + " &num", + ") in", + " nums.iter", + "().enumer", + "ate()", + " {\n let", + " complement", + " = target", + " - num", + ";\n", + " ", + " if", + " let", + " Some(&", + "j)", + " =", + " map", + ".get(&", + "complement", + ") {\n", + " results", + ".push((", + "j", + ",", + " i));\n }\n", + " ", + " map", + ".insert", + "(num", + ", i", + ");\n", + " ", + " }\n\n ", + " results\n", + "}\n\n", + "fn", + " main()", + " {\n", + " ", + " let", + " nums", + " =", + " vec![2, ", + "7", + ",", + " 11, 15];\n", + " let", + " target", + " =", + " ", + "9", + ";\n\n", + " ", + " let", + " pairs", + " = two", + "_sum", + "(&", + "nums", + ",", + " target);\n", + " ", + " if", + " pairs", + ".is", + "_empty()", + " {\n ", + " println", + "!(\"", + "No", + " two", + " sum solution", + " found\");\n", + " ", + " }", + " else {\n for", + " (", + "i", + ", j", + ") in", + " pairs {\n", + " println", + "!(\"Indices", + ":", + " {},", + " {}\",", + " i", + ",", + " j", + ");\n ", + " }\n ", + " }\n}\n", + "```\n\n", +]; diff --git a/frontend/appflowy_flutter/test/unit_test/document/text_robot/text_robot_test.dart b/frontend/appflowy_flutter/test/unit_test/document/text_robot/text_robot_test.dart new file mode 100644 index 0000000000000..0186d36c7d8eb --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/document/text_robot/text_robot_test.dart @@ -0,0 +1,549 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('text robot:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test('auto insert text with sentence mode (1)', () async { + final editorState = EditorState.blank(); + editorState.selection = Selection.collapsed(Position(path: [0])); + final textRobot = TextRobot( + editorState: editorState, + ); + for (final text in _sample1) { + await textRobot.autoInsertTextSync( + text, + separator: r'\n\n', + inputType: TextRobotInputType.sentence, + delay: Duration.zero, + ); + } + + final p1 = editorState.document.nodeAtPath([0])!.delta!.toPlainText(); + final p2 = editorState.document.nodeAtPath([1])!.delta!.toPlainText(); + final p3 = editorState.document.nodeAtPath([2])!.delta!.toPlainText(); + + expect( + p1, + 'In a quaint village nestled between rolling hills, a young girl named Elara discovered a hidden garden. She stumbled upon it while chasing a mischievous rabbit through a narrow, winding path. ', + ); + expect( + p2, + 'The garden was a vibrant oasis, brimming with colorful flowers and whispering trees. Elara felt an inexplicable connection to the place, as if it held secrets from a forgotten time. ', + ); + expect( + p3, + 'Determined to uncover its mysteries, she visited daily, unraveling tales of ancient magic and wisdom. The garden transformed her spirit, teaching her the importance of harmony and the beauty of nature\'s wonders.', + ); + }); + + test('auto insert text with sentence mode (2)', () async { + final editorState = EditorState.blank(); + editorState.selection = Selection.collapsed(Position(path: [0])); + final textRobot = TextRobot( + editorState: editorState, + ); + + var breakCount = 0; + for (final text in _sample2) { + if (text.contains('\n\n')) { + breakCount++; + } + await textRobot.autoInsertTextSync( + text, + separator: r'\n\n', + inputType: TextRobotInputType.sentence, + delay: Duration.zero, + ); + } + + final len = editorState.document.root.children.length; + expect(len, breakCount + 1); + expect(len, 7); + + final p1 = editorState.document.nodeAtPath([0])!.delta!.toPlainText(); + final p2 = editorState.document.nodeAtPath([1])!.delta!.toPlainText(); + final p3 = editorState.document.nodeAtPath([2])!.delta!.toPlainText(); + final p4 = editorState.document.nodeAtPath([3])!.delta!.toPlainText(); + final p5 = editorState.document.nodeAtPath([4])!.delta!.toPlainText(); + final p6 = editorState.document.nodeAtPath([5])!.delta!.toPlainText(); + final p7 = editorState.document.nodeAtPath([6])!.delta!.toPlainText(); + + expect( + p1, + 'Once upon a time in the small, whimsical village of Greenhollow, nestled between rolling hills and lush forests, there lived a young girl named Elara. Unlike the other villagers, Elara had a unique gift: she could communicate with animals. This extraordinary ability made her both a beloved and mysterious figure in Greenhollow.', + ); + expect( + p2, + 'One crisp autumn morning, as golden leaves danced in the breeze, Elara heard a distressed call from the forest. Following the sound, she discovered a young fox trapped in a hunter\'s snare. With gentle hands and a calming voice, she freed the frightened creature, who introduced himself as Rufus. Grateful for her help, Rufus promised to assist Elara whenever she needed.', + ); + expect( + p3, + 'Word of Elara\'s kindness spread among the forest animals, and soon she found herself surrounded by a diverse group of animal friends, from wise old owls to playful otters. Together, they shared stories, solved problems, and looked out for one another.', + ); + expect( + p4, + 'One day, the village faced an unexpected threat: a severe drought that threatened their crops and water supply. The villagers grew anxious, unsure of how to cope with the impending scarcity. Elara, determined to help, turned to her animal friends for guidance.', + ); + expect( + p5, + 'The animals led Elara to a hidden spring deep within the forest, a source of fresh water unknown to the villagers. With Rufus\'s clever planning and the otters\' help in directing the flow, they managed to channel the spring water to the village, saving the crops and quenching the villagers\' thirst.', + ); + expect( + p6, + 'Grateful and amazed, the villagers hailed Elara as a hero. They came to understand the importance of living harmoniously with nature and the wonders that could be achieved through kindness and cooperation.', + ); + expect( + p7, + 'From that day on, Greenhollow thrived as a community where humans and animals lived together in harmony, cherishing the bonds that Elara had helped forge. And whenever challenges arose, the villagers knew they could rely on Elara and her extraordinary friends to guide them through, ensuring that the spirit of unity and compassion always prevailed.', + ); + }); + }); +} + +final _sample1 = [ + "In", + " a quaint", + " village", + " nestled", + " between", + " rolling", + " hills", + ",", + " a", + " young", + " girl", + " named", + " El", + "ara discovered", + " a hidden", + " garden", + ".", + " She stumbled", + " upon", + " it", + " while", + " chasing", + " a", + " misch", + "iev", + "ous rabbit", + " through", + " a", + " narrow,", + " winding path", + ".", + " \n\n", + "The", + " garden", + " was", + " a", + " vibrant", + " oasis", + ",", + " br", + "imming with", + " colorful", + " flowers", + " and whisper", + "ing", + " trees", + ".", + " El", + "ara", + " felt", + " an inexp", + "licable", + " connection", + " to", + " the", + " place,", + " as", + " if", + " it held", + " secrets", + " from", + " a", + " forgotten", + " time", + ".", + " \n\n", + "Determ", + "ined to", + " uncover", + " its", + " mysteries", + ",", + " she", + " visited", + " daily,", + " unravel", + "ing", + " tales", + " of", + " ancient", + " magic", + " and", + " wisdom", + ".", + " The", + " garden transformed", + " her", + " spirit", + ", teaching", + " her the", + " importance of harmony and", + " the", + " beauty", + " of", + " nature", + "'s wonders.", +]; + +final _sample2 = [ + "Once", + " upon", + " a", + " time", + " in", + " the small", + ",", + " whimsical", + " village", + " of", + " Green", + "h", + "ollow", + ",", + " nestled", + " between", + " rolling hills", + " and", + " lush", + " forests", + ",", + " there", + " lived", + " a young", + " girl", + " named", + " Elara.", + " Unlike the", + " other", + " villagers", + ",", + " El", + "ara", + " had", + " a unique", + " gift", + ":", + " she could", + " communicate", + " with", + " animals", + ".", + " This", + " extraordinary", + " ability", + " made", + " her both a", + " beloved", + " and", + " mysterious", + " figure", + " in", + " Green", + "h", + "ollow", + ".\n\n", + "One", + " crisp", + " autumn", + " morning,", + " as", + " golden", + " leaves", + " danced", + " in", + " the", + " breeze", + ", El", + "ara heard", + " a distressed", + " call", + " from", + " the", + " forest", + ".", + " Following", + " the", + " sound", + ",", + " she", + " discovered", + " a", + " young", + " fox", + " trapped", + " in", + " a", + " hunter's", + " snare", + ".", + " With", + " gentle", + " hands", + " and", + " a", + " calming", + " voice", + ",", + " she", + " freed", + " the", + " frightened", + " creature", + ", who", + " introduced", + " himself", + " as Ruf", + "us.", + " Gr", + "ateful", + " for", + " her", + " help", + ",", + " Rufus promised", + " to assist", + " Elara", + " whenever", + " she", + " needed.\n\n", + "Word", + " of", + " Elara", + "'s kindness", + " spread among", + " the forest", + " animals", + ",", + " and soon", + " she", + " found", + " herself", + " surrounded", + " by", + " a", + " diverse", + " group", + " of", + " animal", + " friends", + ",", + " from", + " wise", + " old ow", + "ls to playful", + " ot", + "ters.", + " Together,", + " they", + " shared stories", + ",", + " solved problems", + ",", + " and", + " looked", + " out", + " for", + " one", + " another", + ".\n\n", + "One", + " day", + ", the village faced", + " an unexpected", + " threat", + ":", + " a", + " severe", + " drought", + " that", + " threatened", + " their", + " crops", + " and", + " water supply", + ".", + " The", + " villagers", + " grew", + " anxious", + ",", + " unsure", + " of", + " how to", + " cope", + " with", + " the", + " impending", + " scarcity", + ".", + " El", + "ara", + ",", + " determined", + " to", + " help", + ",", + " turned", + " to her", + " animal friends", + " for", + " guidance", + ".\n\nThe", + " animals", + " led", + " El", + "ara", + " to", + " a", + " hidden", + " spring", + " deep", + " within", + " the forest,", + " a source", + " of", + " fresh", + " water unknown", + " to the", + " villagers", + ".", + " With", + " Ruf", + "us's", + " clever planning", + " and the", + " ot", + "ters", + "'", + " help", + " in directing", + " the", + " flow", + ",", + " they", + " managed", + " to", + " channel the", + " spring", + " water", + " to", + " the", + " village,", + " saving the", + " crops", + " and", + " quenching", + " the", + " villagers", + "'", + " thirst", + ".\n\n", + "Gr", + "ateful and", + " amazed,", + " the", + " villagers", + " hailed El", + "ara as", + " a", + " hero", + ".", + " They", + " came", + " to", + " understand the", + " importance", + " of living", + " harmon", + "iously", + " with", + " nature", + " and", + " the", + " wonders", + " that", + " could", + " be", + " achieved", + " through kindness", + " and cooperation", + ".\n\nFrom", + " that day", + " on", + ",", + " Greenh", + "ollow", + " thr", + "ived", + " as", + " a", + " community", + " where", + " humans", + " and", + " animals", + " lived together", + " in", + " harmony", + ",", + " cher", + "ishing", + " the", + " bonds that", + " El", + "ara", + " had", + " helped", + " forge", + ".", + " And whenever", + " challenges arose", + ", the", + " villagers", + " knew", + " they", + " could", + " rely on", + " El", + "ara and", + " her", + " extraordinary", + " friends", + " to", + " guide them", + " through", + ",", + " ensuring", + " that", + " the", + " spirit", + " of", + " unity", + " and", + " compassion", + " always prevailed.", +]; diff --git a/frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart b/frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart new file mode 100644 index 0000000000000..387375c286331 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart @@ -0,0 +1,916 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('turn into:', () { + Document createDocument(List nodes) { + final document = Document.blank(); + document.insert([0], nodes); + return document; + } + + Future checkTurnInto( + Document document, + String originalType, + String originalText, { + Selection? selection, + String? toType, + int? level, + void Function(EditorState editorState, Node node)? afterTurnInto, + }) async { + final editorState = EditorState(document: document); + final cubit = BlockActionOptionCubit( + editorState: editorState, + blockComponentBuilder: {}, + ); + + final types = toType == null + ? EditorOptionActionType.turnInto.supportTypes + : [toType]; + for (final type in types) { + if (type == originalType || type == SubPageBlockKeys.type) { + continue; + } + + editorState.selectionType = SelectionType.block; + editorState.selection = + selection ?? Selection.collapsed(Position(path: [0])); + + final node = editorState.getNodeAtPath([0])!; + expect(node.type, originalType); + final result = await cubit.turnIntoBlock(type, node, level: level); + expect(result, true); + final newNode = editorState.getNodeAtPath([0])!; + expect(newNode.type, type); + expect(newNode.delta!.toPlainText(), originalText); + afterTurnInto?.call( + editorState, + newNode, + ); + + // turn it back the originalType for the next test + editorState.selectionType = SelectionType.block; + editorState.selection = selection ?? + Selection.collapsed( + Position(path: [0]), + ); + await cubit.turnIntoBlock( + originalType, + newNode, + ); + expect(result, true); + } + } + + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test('from heading to another blocks', () async { + const text = 'Heading 1'; + final document = createDocument([headingNode(level: 1, text: text)]); + await checkTurnInto(document, HeadingBlockKeys.type, text); + }); + + test('from paragraph to another blocks', () async { + const text = 'Paragraph'; + final document = createDocument([paragraphNode(text: text)]); + await checkTurnInto( + document, + ParagraphBlockKeys.type, + text, + ); + }); + + test('from quote list to another blocks', () async { + const text = 'Quote'; + final document = createDocument([ + quoteNode( + delta: Delta()..insert(text), + ), + ]); + await checkTurnInto( + document, + QuoteBlockKeys.type, + text, + ); + }); + + test('from todo list to another blocks', () async { + const text = 'Todo'; + final document = createDocument([ + todoListNode( + checked: false, + text: text, + ), + ]); + await checkTurnInto( + document, + TodoListBlockKeys.type, + text, + ); + }); + + test('from bulleted list to another blocks', () async { + const text = 'bulleted list'; + final document = createDocument([ + bulletedListNode( + text: text, + ), + ]); + await checkTurnInto( + document, + BulletedListBlockKeys.type, + text, + ); + }); + + test('from numbered list to another blocks', () async { + const text = 'numbered list'; + final document = createDocument([ + numberedListNode( + delta: Delta()..insert(text), + ), + ]); + await checkTurnInto( + document, + NumberedListBlockKeys.type, + text, + ); + }); + + test('from callout to another blocks', () async { + const text = 'callout'; + final document = createDocument([ + calloutNode( + delta: Delta()..insert(text), + ), + ]); + await checkTurnInto( + document, + CalloutBlockKeys.type, + text, + ); + }); + + for (final type in [ + HeadingBlockKeys.type, + QuoteBlockKeys.type, + CalloutBlockKeys.type, + ]) { + test('from nested bulleted list to $type', () async { + const text = 'bulleted list'; + const nestedText1 = 'nested bulleted list 1'; + const nestedText2 = 'nested bulleted list 2'; + const nestedText3 = 'nested bulleted list 3'; + final document = createDocument([ + bulletedListNode( + text: text, + children: [ + bulletedListNode( + text: nestedText1, + ), + bulletedListNode( + text: nestedText2, + ), + bulletedListNode( + text: nestedText3, + ), + ], + ), + ]); + await checkTurnInto( + document, + BulletedListBlockKeys.type, + text, + toType: type, + afterTurnInto: (editorState, node) { + expect(node.type, type); + expect(node.children.length, 0); + expect(node.delta!.toPlainText(), text); + + expect(editorState.document.root.children.length, 4); + expect( + editorState.document.root.children[1].type, + BulletedListBlockKeys.type, + ); + expect( + editorState.document.root.children[1].delta!.toPlainText(), + nestedText1, + ); + expect( + editorState.document.root.children[2].type, + BulletedListBlockKeys.type, + ); + expect( + editorState.document.root.children[2].delta!.toPlainText(), + nestedText2, + ); + expect( + editorState.document.root.children[3].type, + BulletedListBlockKeys.type, + ); + expect( + editorState.document.root.children[3].delta!.toPlainText(), + nestedText3, + ); + }, + ); + }); + } + + for (final type in [ + HeadingBlockKeys.type, + QuoteBlockKeys.type, + CalloutBlockKeys.type, + ]) { + test('from nested numbered list to $type', () async { + const text = 'numbered list'; + const nestedText1 = 'nested numbered list 1'; + const nestedText2 = 'nested numbered list 2'; + const nestedText3 = 'nested numbered list 3'; + final document = createDocument([ + numberedListNode( + delta: Delta()..insert(text), + children: [ + numberedListNode( + delta: Delta()..insert(nestedText1), + ), + numberedListNode( + delta: Delta()..insert(nestedText2), + ), + numberedListNode( + delta: Delta()..insert(nestedText3), + ), + ], + ), + ]); + await checkTurnInto( + document, + NumberedListBlockKeys.type, + text, + toType: type, + afterTurnInto: (editorState, node) { + expect(node.type, type); + expect(node.children.length, 0); + expect(node.delta!.toPlainText(), text); + + expect(editorState.document.root.children.length, 4); + expect( + editorState.document.root.children[1].type, + NumberedListBlockKeys.type, + ); + expect( + editorState.document.root.children[1].delta!.toPlainText(), + nestedText1, + ); + expect( + editorState.document.root.children[2].type, + NumberedListBlockKeys.type, + ); + expect( + editorState.document.root.children[2].delta!.toPlainText(), + nestedText2, + ); + expect( + editorState.document.root.children[3].type, + NumberedListBlockKeys.type, + ); + expect( + editorState.document.root.children[3].delta!.toPlainText(), + nestedText3, + ); + }, + ); + }); + } + + for (final type in [ + HeadingBlockKeys.type, + QuoteBlockKeys.type, + CalloutBlockKeys.type, + ]) { + // numbered list, bulleted list, todo list + // before + // - numbered list 1 + // - nested list 1 + // - bulleted list 2 + // - nested list 2 + // - todo list 3 + // - nested list 3 + // after + // - heading 1 + // - nested list 1 + // - heading 2 + // - nested list 2 + // - heading 3 + // - nested list 3 + test('from nested mixed list to $type', () async { + const text1 = 'numbered list 1'; + const text2 = 'bulleted list 2'; + const text3 = 'todo list 3'; + const nestedText1 = 'nested list 1'; + const nestedText2 = 'nested list 2'; + const nestedText3 = 'nested list 3'; + final document = createDocument([ + numberedListNode( + delta: Delta()..insert(text1), + children: [ + numberedListNode( + delta: Delta()..insert(nestedText1), + ), + ], + ), + bulletedListNode( + delta: Delta()..insert(text2), + children: [ + bulletedListNode( + delta: Delta()..insert(nestedText2), + ), + ], + ), + todoListNode( + checked: false, + text: text3, + children: [ + todoListNode( + checked: false, + text: nestedText3, + ), + ], + ), + ]); + await checkTurnInto( + document, + NumberedListBlockKeys.type, + text1, + toType: type, + selection: Selection( + start: Position(path: [0]), + end: Position(path: [2]), + ), + afterTurnInto: (editorState, node) { + final nodes = editorState.document.root.children; + expect(nodes.length, 6); + final texts = [ + text1, + nestedText1, + text2, + nestedText2, + text3, + nestedText3, + ]; + final types = [ + type, + NumberedListBlockKeys.type, + type, + BulletedListBlockKeys.type, + type, + TodoListBlockKeys.type, + ]; + for (var i = 0; i < 6; i++) { + expect(nodes[i].type, types[i]); + expect(nodes[i].children.length, 0); + expect(nodes[i].delta!.toPlainText(), texts[i]); + } + }, + ); + }); + } + + for (final type in [ + ParagraphBlockKeys.type, + BulletedListBlockKeys.type, + NumberedListBlockKeys.type, + TodoListBlockKeys.type, + ]) { + // numbered list, bulleted list, todo list + // before + // - numbered list 1 + // - nested list 1 + // - bulleted list 2 + // - nested list 2 + // - todo list 3 + // - nested list 3 + // after + // - new_list_type + // - nested list 1 + // - new_list_type + // - nested list 2 + // - new_list_type + // - nested list 3 + test('from nested mixed list to $type', () async { + const text1 = 'numbered list 1'; + const text2 = 'bulleted list 2'; + const text3 = 'todo list 3'; + const nestedText1 = 'nested list 1'; + const nestedText2 = 'nested list 2'; + const nestedText3 = 'nested list 3'; + final document = createDocument([ + numberedListNode( + delta: Delta()..insert(text1), + children: [ + numberedListNode( + delta: Delta()..insert(nestedText1), + ), + ], + ), + bulletedListNode( + delta: Delta()..insert(text2), + children: [ + bulletedListNode( + delta: Delta()..insert(nestedText2), + ), + ], + ), + todoListNode( + checked: false, + text: text3, + children: [ + todoListNode( + checked: false, + text: nestedText3, + ), + ], + ), + ]); + await checkTurnInto( + document, + NumberedListBlockKeys.type, + text1, + toType: type, + selection: Selection( + start: Position(path: [0]), + end: Position(path: [2]), + ), + afterTurnInto: (editorState, node) { + final nodes = editorState.document.root.children; + expect(nodes.length, 3); + final texts = [ + text1, + text2, + text3, + ]; + final nestedTexts = [ + nestedText1, + nestedText2, + nestedText3, + ]; + final types = [ + NumberedListBlockKeys.type, + BulletedListBlockKeys.type, + TodoListBlockKeys.type, + ]; + for (var i = 0; i < 3; i++) { + expect(nodes[i].type, type); + expect(nodes[i].children.length, 1); + expect(nodes[i].delta!.toPlainText(), texts[i]); + expect(nodes[i].children[0].type, types[i]); + expect(nodes[i].children[0].delta!.toPlainText(), nestedTexts[i]); + } + }, + ); + }); + } + + test('undo, redo', () async { + const text1 = 'numbered list 1'; + const nestedText1 = 'nested list 1'; + final document = createDocument([ + numberedListNode( + delta: Delta()..insert(text1), + children: [ + numberedListNode( + delta: Delta()..insert(nestedText1), + ), + ], + ), + ]); + await checkTurnInto( + document, + NumberedListBlockKeys.type, + text1, + toType: HeadingBlockKeys.type, + afterTurnInto: (editorState, node) { + expect(editorState.document.root.children.length, 2); + editorState.selection = Selection.collapsed( + Position(path: [0]), + ); + KeyEventResult result = undoCommand.execute(editorState); + expect(result, KeyEventResult.handled); + expect(editorState.document.root.children.length, 1); + editorState.selection = Selection.collapsed( + Position(path: [0]), + ); + result = redoCommand.execute(editorState); + expect(result, KeyEventResult.handled); + expect(editorState.document.root.children.length, 2); + }, + ); + }); + + test('calculate selection when turn into', () { + // Example: + // - bulleted list item 1 + // - bulleted list item 1-1 + // - bulleted list item 1-2 + // - bulleted list item 2 + // - bulleted list item 2-1 + // - bulleted list item 2-2 + // - bulleted list item 3 + // - bulleted list item 3-1 + // - bulleted list item 3-2 + const text = 'bulleted list'; + const nestedText = 'nested bulleted list'; + final document = createDocument([ + bulletedListNode( + text: '$text 1', + children: [ + bulletedListNode(text: '$nestedText 1-1'), + bulletedListNode(text: '$nestedText 1-2'), + ], + ), + bulletedListNode( + text: '$text 2', + children: [ + bulletedListNode(text: '$nestedText 2-1'), + bulletedListNode(text: '$nestedText 2-2'), + ], + ), + bulletedListNode( + text: '$text 3', + children: [ + bulletedListNode(text: '$nestedText 3-1'), + bulletedListNode(text: '$nestedText 3-2'), + ], + ), + ]); + final editorState = EditorState(document: document); + final cubit = BlockActionOptionCubit( + editorState: editorState, + blockComponentBuilder: {}, + ); + + // case 1: collapsed selection and the selection is in the top level + // and tap the turn into button at the [0] + final selection1 = Selection.collapsed( + Position(path: [0], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([0])!, + selection1, + ), + selection1, + ); + + // case 2: collapsed selection and the selection is in the nested level + // and tap the turn into button at the [0] + final selection2 = Selection.collapsed( + Position(path: [0, 0], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([0])!, + selection2, + ), + Selection.collapsed(Position(path: [0])), + ); + + // case 3, collapsed selection and the selection is in the nested level + // and tap the turn into button at the [0, 0] + final selection3 = Selection.collapsed( + Position(path: [0, 0], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([0, 0])!, + selection3, + ), + selection3, + ); + + // case 4, not collapsed selection and the selection is in the top level + // and tap the turn into button at the [0] + final selection4 = Selection( + start: Position(path: [0], offset: 1), + end: Position(path: [1], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([0])!, + selection4, + ), + selection4, + ); + + // case 5, not collapsed selection and the selection is in the nested level + // and tap the turn into button at the [0] + final selection5 = Selection( + start: Position(path: [0, 0], offset: 1), + end: Position(path: [0, 1], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([0])!, + selection5, + ), + Selection.collapsed(Position(path: [0])), + ); + + // case 6, not collapsed selection and the selection is in the nested level + // and tap the turn into button at the [0, 0] + final selection6 = Selection( + start: Position(path: [0, 0], offset: 1), + end: Position(path: [0, 1], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([0])!, + selection6, + ), + Selection.collapsed(Position(path: [0])), + ); + + // case 7, multiple blocks selection, and tap the turn into button of one of the selected nodes + final selection7 = Selection( + start: Position(path: [0], offset: 1), + end: Position(path: [2], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([1])!, + selection7, + ), + selection7, + ); + + // case 8, multiple blocks selection, and tap the turn into button of one of the non-selected nodes + final selection8 = Selection( + start: Position(path: [0], offset: 1), + end: Position(path: [1], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([2])!, + selection8, + ), + Selection.collapsed(Position(path: [2])), + ); + }); + + group('turn into toggle list', () { + const heading1 = 'heading 1'; + const heading2 = 'heading 2'; + const heading3 = 'heading 3'; + const paragraph1 = 'paragraph 1'; + const paragraph2 = 'paragraph 2'; + const paragraph3 = 'paragraph 3'; + + test('turn heading 1 block to toggle heading 1 block', () async { + // before + // # Heading 1 + // paragraph 1 + // paragraph 2 + // paragraph 3 + + // after + // > # Heading 1 + // paragraph 1 + // paragraph 2 + // paragraph 3 + final document = createDocument([ + headingNode(level: 1, text: heading1), + paragraphNode(text: paragraph1), + paragraphNode(text: paragraph2), + paragraphNode(text: paragraph3), + ]); + + await checkTurnInto( + document, + HeadingBlockKeys.type, + heading1, + selection: Selection.collapsed(Position(path: [0])), + toType: ToggleListBlockKeys.type, + level: 1, + afterTurnInto: (editorState, node) { + expect(editorState.document.root.children.length, 1); + expect(node.type, ToggleListBlockKeys.type); + expect(node.attributes[ToggleListBlockKeys.level], 1); + expect(node.children.length, 3); + for (var i = 0; i < 3; i++) { + expect(node.children[i].type, ParagraphBlockKeys.type); + expect( + node.children[i].delta!.toPlainText(), + [paragraph1, paragraph2, paragraph3][i], + ); + } + + // test undo together + final result = undoCommand.execute(editorState); + expect(result, KeyEventResult.handled); + expect(editorState.document.root.children.length, 4); + }, + ); + }); + + test('turn toggle heading 1 block to heading 1 block', () async { + // before + // > # Heading 1 + // paragraph 1 + // paragraph 2 + // paragraph 3 + + // after + // # Heading 1 + // paragraph 1 + // paragraph 2 + // paragraph 3 + final document = createDocument([ + toggleHeadingNode( + text: heading1, + children: [ + paragraphNode(text: paragraph1), + paragraphNode(text: paragraph2), + paragraphNode(text: paragraph3), + ], + ), + ]); + + await checkTurnInto( + document, + ToggleListBlockKeys.type, + heading1, + selection: Selection.collapsed( + Position(path: [0]), + ), + toType: HeadingBlockKeys.type, + level: 1, + afterTurnInto: (editorState, node) { + expect(editorState.document.root.children.length, 4); + expect(node.type, HeadingBlockKeys.type); + expect(node.attributes[HeadingBlockKeys.level], 1); + expect(node.children.length, 0); + for (var i = 1; i <= 3; i++) { + final node = editorState.getNodeAtPath([i])!; + expect(node.type, ParagraphBlockKeys.type); + expect( + node.delta!.toPlainText(), + [paragraph1, paragraph2, paragraph3][i - 1], + ); + } + + // test undo together + editorState.selection = Selection.collapsed( + Position(path: [0]), + ); + final result = undoCommand.execute(editorState); + expect(result, KeyEventResult.handled); + expect(editorState.document.root.children.length, 1); + final afterNode = editorState.getNodeAtPath([0])!; + expect(afterNode.type, ToggleListBlockKeys.type); + expect(afterNode.attributes[ToggleListBlockKeys.level], 1); + expect(afterNode.children.length, 3); + }, + ); + }); + + test('turn heading 2 block to toggle heading 2 block - case 1', () async { + // before + // ## Heading 2 + // paragraph 1 + // ### Heading 3 + // paragraph 2 + // # Heading 1 <- the heading 1 block will not be converted + // paragraph 3 + + // after + // > ## Heading 2 + // paragraph 1 + // ## Heading 2 + // paragraph 2 + // # Heading 1 + // paragraph 3 + final document = createDocument([ + headingNode(level: 2, text: heading2), + paragraphNode(text: paragraph1), + headingNode(level: 3, text: heading3), + paragraphNode(text: paragraph2), + headingNode(level: 1, text: heading1), + paragraphNode(text: paragraph3), + ]); + + await checkTurnInto( + document, + HeadingBlockKeys.type, + heading2, + selection: Selection.collapsed( + Position(path: [0]), + ), + toType: ToggleListBlockKeys.type, + level: 2, + afterTurnInto: (editorState, node) { + expect(editorState.document.root.children.length, 3); + expect(node.type, ToggleListBlockKeys.type); + expect(node.attributes[ToggleListBlockKeys.level], 2); + expect(node.children.length, 3); + expect(node.children[0].delta!.toPlainText(), paragraph1); + expect(node.children[1].delta!.toPlainText(), heading3); + expect(node.children[2].delta!.toPlainText(), paragraph2); + + // the heading 1 block will not be converted + final heading1Node = editorState.getNodeAtPath([1])!; + expect(heading1Node.type, HeadingBlockKeys.type); + expect(heading1Node.attributes[HeadingBlockKeys.level], 1); + expect(heading1Node.delta!.toPlainText(), heading1); + + final paragraph3Node = editorState.getNodeAtPath([2])!; + expect(paragraph3Node.type, ParagraphBlockKeys.type); + expect(paragraph3Node.delta!.toPlainText(), paragraph3); + + // test undo together + final result = undoCommand.execute(editorState); + expect(result, KeyEventResult.handled); + expect(editorState.document.root.children.length, 6); + }, + ); + }); + + test('turn heading 2 block to toggle heading 2 block - case 2', () async { + // before + // ## Heading 2 + // paragraph 1 + // ## Heading 2 <- the heading 2 block will not be converted + // paragraph 2 + // # Heading 1 <- the heading 1 block will not be converted + // paragraph 3 + + // after + // > ## Heading 2 + // paragraph 1 + // ## Heading 2 + // paragraph 2 + // # Heading 1 + // paragraph 3 + final document = createDocument([ + headingNode(level: 2, text: heading2), + paragraphNode(text: paragraph1), + headingNode(level: 2, text: heading2), + paragraphNode(text: paragraph2), + headingNode(level: 1, text: heading1), + paragraphNode(text: paragraph3), + ]); + + await checkTurnInto( + document, + HeadingBlockKeys.type, + heading2, + selection: Selection.collapsed( + Position(path: [0]), + ), + toType: ToggleListBlockKeys.type, + level: 2, + afterTurnInto: (editorState, node) { + expect(editorState.document.root.children.length, 5); + expect(node.type, ToggleListBlockKeys.type); + expect(node.attributes[ToggleListBlockKeys.level], 2); + expect(node.children.length, 1); + expect(node.children[0].delta!.toPlainText(), paragraph1); + + final heading2Node = editorState.getNodeAtPath([1])!; + expect(heading2Node.type, HeadingBlockKeys.type); + expect(heading2Node.attributes[HeadingBlockKeys.level], 2); + expect(heading2Node.delta!.toPlainText(), heading2); + + final paragraph2Node = editorState.getNodeAtPath([2])!; + expect(paragraph2Node.type, ParagraphBlockKeys.type); + expect(paragraph2Node.delta!.toPlainText(), paragraph2); + + // the heading 1 block will not be converted + final heading1Node = editorState.getNodeAtPath([3])!; + expect(heading1Node.type, HeadingBlockKeys.type); + expect(heading1Node.attributes[HeadingBlockKeys.level], 1); + expect(heading1Node.delta!.toPlainText(), heading1); + + final paragraph3Node = editorState.getNodeAtPath([4])!; + expect(paragraph3Node.type, ParagraphBlockKeys.type); + expect(paragraph3Node.delta!.toPlainText(), paragraph3); + + // test undo together + final result = undoCommand.execute(editorState); + expect(result, KeyEventResult.handled); + expect(editorState.document.root.children.length, 6); + }, + ); + }); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/editor/editor_drop_test.dart b/frontend/appflowy_flutter/test/unit_test/editor/editor_drop_test.dart new file mode 100644 index 0000000000000..b17588674cdce --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/editor/editor_drop_test.dart @@ -0,0 +1,90 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../util.dart'; + +void main() { + setUpAll(() async { + await AppFlowyUnitTest.ensureInitialized(); + }); + + group('drop images and files in EditorState', () { + test('dropImages on same path as paragraph node ', () async { + final editorState = EditorState( + document: Document.blank(withInitialText: true), + ); + + expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); + + const dropPath = [0]; + + const imagePath = 'assets/test/images/sample.jpeg'; + final imageFile = XFile(imagePath); + await editorState.dropImages(dropPath, [imageFile], 'documentId', true); + + final node = editorState.getNodeAtPath(dropPath); + expect(node, isNotNull); + expect(node!.type, CustomImageBlockKeys.type); + expect(editorState.getNodeAtPath([1])!.type, ParagraphBlockKeys.type); + }); + + test('dropImages should insert image node on empty path', () async { + final editorState = EditorState( + document: Document.blank(withInitialText: true), + ); + + expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); + + const dropPath = [1]; + + const imagePath = 'assets/test/images/sample.jpeg'; + final imageFile = XFile(imagePath); + await editorState.dropImages(dropPath, [imageFile], 'documentId', true); + + final node = editorState.getNodeAtPath(dropPath); + expect(node, isNotNull); + expect(node!.type, CustomImageBlockKeys.type); + expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); + expect(editorState.getNodeAtPath([2]), null); + }); + + test('dropFiles on same path as paragraph node ', () async { + final editorState = EditorState( + document: Document.blank(withInitialText: true), + ); + + expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); + + const dropPath = [0]; + + const filePath = 'assets/test/images/sample.jpeg'; + final file = XFile(filePath); + await editorState.dropFiles(dropPath, [file], 'documentId', true); + + final node = editorState.getNodeAtPath(dropPath); + expect(node, isNotNull); + expect(node!.type, FileBlockKeys.type); + expect(editorState.getNodeAtPath([1])!.type, ParagraphBlockKeys.type); + }); + + test('dropFiles should insert file node on empty path', () async { + final editorState = EditorState( + document: Document.blank(withInitialText: true), + ); + const dropPath = [1]; + + const filePath = 'assets/test/images/sample.jpeg'; + final file = XFile(filePath); + await editorState.dropFiles(dropPath, [file], 'documentId', true); + + final node = editorState.getNodeAtPath(dropPath); + expect(node, isNotNull); + expect(node!.type, FileBlockKeys.type); + expect(editorState.getNodeAtPath([0])!.type, ParagraphBlockKeys.type); + expect(editorState.getNodeAtPath([2]), null); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/editor/editor_migration_test.dart b/frontend/appflowy_flutter/test/unit_test/editor/editor_migration_test.dart new file mode 100644 index 0000000000000..9fe1f7bc241ab --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/editor/editor_migration_test.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/migration/editor_migration.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('editor migration, from v0.1.x to 0.2', () { + test('migrate readme', () async { + final readme = await rootBundle.loadString('assets/template/readme.json'); + final oldDocument = DocumentV0.fromJson(json.decode(readme)); + final document = EditorMigration.migrateDocument(readme); + expect(document.root.type, 'page'); + expect(oldDocument.root.children.length, document.root.children.length); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart b/frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart new file mode 100644 index 0000000000000..4da21cc4a8000 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/editor/editor_style_test.dart @@ -0,0 +1,45 @@ +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_style.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockDocumentAppearanceCubit extends Mock + implements DocumentAppearanceCubit {} + +class MockBuildContext extends Mock implements BuildContext {} + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + group('EditorStyleCustomizer', () { + late EditorStyleCustomizer editorStyleCustomizer; + late MockBuildContext mockBuildContext; + + setUp(() { + mockBuildContext = MockBuildContext(); + editorStyleCustomizer = EditorStyleCustomizer( + context: mockBuildContext, + padding: EdgeInsets.zero, + ); + }); + + test('baseTextStyle should return the expected TextStyle', () { + const fontFamily = 'Roboto'; + final result = editorStyleCustomizer.baseTextStyle(fontFamily); + expect(result, isA()); + expect(result.fontFamily, 'Roboto_regular'); + }); + + test( + 'baseTextStyle should return the null TextStyle when an exception occurs', + () { + const garbage = 'Garbage'; + final result = editorStyleCustomizer.baseTextStyle(garbage); + expect(result, isA()); + expect( + result.fontFamily, + null, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/editor/share_markdown_test.dart b/frontend/appflowy_flutter/test/unit_test/editor/share_markdown_test.dart new file mode 100644 index 0000000000000..d064e55ec70a2 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/editor/share_markdown_test.dart @@ -0,0 +1,195 @@ +import 'dart:convert'; + +import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('share markdown', () { + test('math equation', () { + const text = ''' +{ + "document":{ + "type":"page", + "children":[ + { + "type":"math_equation", + "data":{ + "math_equation":"E = MC^2" + } + } + ] + } +} +'''; + final document = Document.fromJson( + Map.from(json.decode(text)), + ); + final result = documentToMarkdown( + document, + customParsers: [ + const MathEquationNodeParser(), + ], + ); + expect(result, r'$$E = MC^2$$'); + }); + + test('code block', () { + const text = ''' +{ + "document":{ + "type":"page", + "children":[ + { + "type":"code", + "data":{ + "delta": [ + { + "insert": "Some Code" + } + ] + } + } + ] + } +} +'''; + final document = Document.fromJson( + Map.from(json.decode(text)), + ); + final result = documentToMarkdown( + document, + customParsers: [ + const CodeBlockNodeParser(), + ], + ); + expect(result, '```\nSome Code\n```'); + }); + + test('divider', () { + const text = ''' +{ + "document":{ + "type":"page", + "children":[ + { + "type":"divider" + } + ] + } +} +'''; + final document = Document.fromJson( + Map.from(json.decode(text)), + ); + final result = documentToMarkdown( + document, + customParsers: [ + const DividerNodeParser(), + ], + ); + expect(result, '---\n'); + }); + + test('callout', () { + const text = ''' +{ + "document":{ + "type":"page", + "children":[ + { + "type":"callout", + "data":{ + "icon": "😁", + "delta": [ + { + "insert": "Callout" + } + ] + } + } + ] + } +} +'''; + final document = Document.fromJson( + Map.from(json.decode(text)), + ); + final result = documentToMarkdown( + document, + customParsers: [ + const CalloutNodeParser(), + ], + ); + expect(result, '''> 😁 +> Callout + +'''); + }); + + test('toggle list', () { + const text = ''' +{ + "document":{ + "type":"page", + "children":[ + { + "type":"toggle_list", + "data":{ + "delta": [ + { + "insert": "Toggle list" + } + ] + } + } + ] + } +} +'''; + final document = Document.fromJson( + Map.from(json.decode(text)), + ); + final result = documentToMarkdown( + document, + customParsers: [ + const ToggleListNodeParser(), + ], + ); + expect(result, '- Toggle list\n'); + }); + + test('custom image', () { + const image = + 'https://images.unsplash.com/photo-1694984121999-36d30b67f391?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxlZGl0b3JpYWwtZmVlZHwzfHx8ZW58MHx8fHx8&auto=format&fit=crop&w=800&q=60'; + const text = ''' +{ + "document":{ + "type":"page", + "children":[ + { + "type":"image", + "data":{ + "url": "$image" + } + } + ] + } +} +'''; + final document = Document.fromJson( + Map.from(json.decode(text)), + ); + final result = documentToMarkdown( + document, + customParsers: [ + const CustomImageNodeParser(), + ], + ); + expect( + result, + '![]($image)\n', + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart b/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart new file mode 100644 index 0000000000000..4a7457a43bd85 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/editor/transaction_adapter_test.dart @@ -0,0 +1,294 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/document/application/document_service.dart'; +import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart'; +import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('TransactionAdapter:', () { + test('toBlockAction insert node with children operation', () { + final editorState = EditorState.blank(); + + final transaction = editorState.transaction; + transaction.insertNode( + [0], + paragraphNode( + children: [ + paragraphNode(text: '1', children: [paragraphNode(text: '1.1')]), + paragraphNode(text: '2'), + paragraphNode(text: '3', children: [paragraphNode(text: '3.1')]), + paragraphNode(text: '4'), + ], + ), + ); + + expect(transaction.operations.length, 1); + expect(transaction.operations[0] is InsertOperation, true); + + final actions = transaction.operations[0].toBlockAction(editorState, ''); + + expect(actions.length, 7); + for (final action in actions) { + expect(action.blockActionPB.action, BlockActionTypePB.Insert); + } + + expect( + actions[0].blockActionPB.payload.parentId, + editorState.document.root.id, + reason: '0 - parent id', + ); + expect( + actions[0].blockActionPB.payload.prevId, + '', + reason: '0 - prev id', + ); + expect( + actions[1].blockActionPB.payload.parentId, + actions[0].blockActionPB.payload.block.id, + reason: '1 - parent id', + ); + expect( + actions[1].blockActionPB.payload.prevId, + '', + reason: '1 - prev id', + ); + expect( + actions[2].blockActionPB.payload.parentId, + actions[1].blockActionPB.payload.block.id, + reason: '2 - parent id', + ); + expect( + actions[2].blockActionPB.payload.prevId, + '', + reason: '2 - prev id', + ); + expect( + actions[3].blockActionPB.payload.parentId, + actions[0].blockActionPB.payload.block.id, + reason: '3 - parent id', + ); + expect( + actions[3].blockActionPB.payload.prevId, + actions[1].blockActionPB.payload.block.id, + reason: '3 - prev id', + ); + expect( + actions[4].blockActionPB.payload.parentId, + actions[0].blockActionPB.payload.block.id, + reason: '4 - parent id', + ); + expect( + actions[4].blockActionPB.payload.prevId, + actions[3].blockActionPB.payload.block.id, + reason: '4 - prev id', + ); + expect( + actions[5].blockActionPB.payload.parentId, + actions[4].blockActionPB.payload.block.id, + reason: '5 - parent id', + ); + expect( + actions[5].blockActionPB.payload.prevId, + '', + reason: '5 - prev id', + ); + expect( + actions[6].blockActionPB.payload.parentId, + actions[0].blockActionPB.payload.block.id, + reason: '6 - parent id', + ); + expect( + actions[6].blockActionPB.payload.prevId, + actions[4].blockActionPB.payload.block.id, + reason: '6 - prev id', + ); + }); + + test('toBlockAction insert node before all children nodes', () { + final document = Document( + root: Node( + type: 'page', + children: [ + paragraphNode(children: [paragraphNode(text: '1')]), + ], + ), + ); + final editorState = EditorState(document: document); + + final transaction = editorState.transaction; + transaction.insertNodes([0, 0], [paragraphNode(), paragraphNode()]); + + expect(transaction.operations.length, 1); + expect(transaction.operations[0] is InsertOperation, true); + + final actions = transaction.operations[0].toBlockAction(editorState, ''); + + expect(actions.length, 2); + for (final action in actions) { + expect(action.blockActionPB.action, BlockActionTypePB.Insert); + } + + expect( + actions[0].blockActionPB.payload.parentId, + editorState.document.root.children.first.id, + reason: '0 - parent id', + ); + expect( + actions[0].blockActionPB.payload.prevId, + '', + reason: '0 - prev id', + ); + expect( + actions[1].blockActionPB.payload.parentId, + editorState.document.root.children.first.id, + reason: '1 - parent id', + ); + expect( + actions[1].blockActionPB.payload.prevId, + actions[0].blockActionPB.payload.block.id, + reason: '1 - prev id', + ); + }); + + test('update the external id and external type', () async { + // create a node without external id and external type + // the editing this node, the adapter should generate a new action + // to assign a new external id and external type. + final node = bulletedListNode(text: 'Hello'); + final document = Document( + root: pageNode( + children: [ + node, + ], + ), + ); + + final transactionAdapter = TransactionAdapter( + documentId: '', + documentService: DocumentService(), + ); + + final editorState = EditorState( + document: document, + ); + + final completer = Completer(); + editorState.transactionStream.listen((event) { + final time = event.$1; + if (time == TransactionTime.before) { + final actions = transactionAdapter.transactionToBlockActions( + event.$2, + editorState, + ); + final textActions = + transactionAdapter.filterTextDeltaActions(actions); + final blockActions = transactionAdapter.filterBlockActions(actions); + expect(textActions.length, 1); + expect(blockActions.length, 1); + + // check text operation + final textAction = textActions.first; + final textId = textAction.textDeltaPayloadPB?.textId; + { + expect(textAction.textDeltaType, TextDeltaType.create); + + expect(textId, isNotEmpty); + final delta = textAction.textDeltaPayloadPB?.delta; + expect(delta, equals('[{"insert":"HelloWorld"}]')); + } + + // check block operation + { + final blockAction = blockActions.first; + expect(blockAction.action, BlockActionTypePB.Update); + expect(blockAction.payload.block.id, node.id); + expect( + blockAction.payload.block.externalId, + textId, + ); + expect(blockAction.payload.block.externalType, kExternalTextType); + } + } else if (time == TransactionTime.after) { + completer.complete(); + } + }); + + await editorState.insertText( + 5, + 'World', + node: node, + ); + await completer.future; + }); + + test('use delta from prev attributes if current delta is null', () async { + final node = todoListNode( + checked: false, + delta: Delta()..insert('AppFlowy'), + ); + final document = Document( + root: pageNode( + children: [ + node, + ], + ), + ); + final transactionAdapter = TransactionAdapter( + documentId: '', + documentService: DocumentService(), + ); + + final editorState = EditorState( + document: document, + ); + + final completer = Completer(); + editorState.transactionStream.listen((event) { + final time = event.$1; + if (time == TransactionTime.before) { + final actions = transactionAdapter.transactionToBlockActions( + event.$2, + editorState, + ); + final textActions = + transactionAdapter.filterTextDeltaActions(actions); + final blockActions = transactionAdapter.filterBlockActions(actions); + expect(textActions.length, 1); + expect(blockActions.length, 1); + + // check text operation + final textAction = textActions.first; + final textId = textAction.textDeltaPayloadPB?.textId; + { + expect(textAction.textDeltaType, TextDeltaType.create); + + expect(textId, isNotEmpty); + final delta = textAction.textDeltaPayloadPB?.delta; + expect(delta, equals('[{"insert":"AppFlowy"}]')); + } + + // check block operation + { + final blockAction = blockActions.first; + expect(blockAction.action, BlockActionTypePB.Update); + expect(blockAction.payload.block.id, node.id); + expect( + blockAction.payload.block.externalId, + textId, + ); + expect(blockAction.payload.block.externalType, kExternalTextType); + } + } else if (time == TransactionTime.after) { + completer.complete(); + } + }); + + final transaction = editorState.transaction; + transaction.updateNode(node, {TodoListBlockKeys.checked: true}); + await editorState.apply(transaction); + await completer.future; + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/markdown/markdown_parser_test.dart b/frontend/appflowy_flutter/test/unit_test/markdown/markdown_parser_test.dart new file mode 100644 index 0000000000000..70775612e2a7e --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/markdown/markdown_parser_test.dart @@ -0,0 +1,37 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('export markdown to document', () { + test('file block', () async { + final document = Document.blank() + ..insert( + [0], + [ + fileNode( + name: 'file.txt', + url: 'https://file.com', + ), + ], + ); + final markdown = customDocumentToMarkdown(document); + expect(markdown, '[file.txt](https://file.com)\n'); + }); + + test('link preview', () { + final document = Document.blank() + ..insert( + [0], + [linkPreviewNode(url: 'https://www.link_preview.com')], + ); + final markdown = customDocumentToMarkdown(document); + expect( + markdown, + '[https://www.link_preview.com](https://www.link_preview.com)\n', + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/select_option_split_text_input.dart b/frontend/appflowy_flutter/test/unit_test/select_option_split_text_input.dart new file mode 100644 index 0000000000000..c13212df690ae --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/select_option_split_text_input.dart @@ -0,0 +1,48 @@ +import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_text_field.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const textSeparators = [',']; + + group('split input unit test', () { + test('empty input', () { + var (submitted, remainder) = splitInput(' ', textSeparators); + expect(submitted, []); + expect(remainder, ''); + + (submitted, remainder) = splitInput(', , , ', textSeparators); + expect(submitted, []); + expect(remainder, ''); + }); + + test('simple input', () { + var (submitted, remainder) = splitInput('exampleTag', textSeparators); + expect(submitted, []); + expect(remainder, 'exampleTag'); + + (submitted, remainder) = + splitInput('tag with longer name', textSeparators); + expect(submitted, []); + expect(remainder, 'tag with longer name'); + + (submitted, remainder) = splitInput('trailing space ', textSeparators); + expect(submitted, []); + expect(remainder, 'trailing space '); + }); + + test('input with commas', () { + var (submitted, remainder) = splitInput('a, b, c', textSeparators); + expect(submitted, ['a', 'b']); + expect(remainder, 'c'); + + (submitted, remainder) = splitInput('a, b, c, ', textSeparators); + expect(submitted, ['a', 'b', 'c']); + expect(remainder, ''); + + (submitted, remainder) = + splitInput(',tag 1 ,2nd tag, third tag ', textSeparators); + expect(submitted, ['tag 1', '2nd tag']); + expect(remainder, 'third tag '); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/settings/shortcuts/settings_shortcut_service_test.dart b/frontend/appflowy_flutter/test/unit_test/settings/shortcuts/settings_shortcut_service_test.dart new file mode 100644 index 0000000000000..3aea2755f4a8d --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/settings/shortcuts/settings_shortcut_service_test.dart @@ -0,0 +1,170 @@ +import 'dart:convert'; +import 'dart:io' show File; + +import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; +import 'package:appflowy/workspace/application/settings/shortcuts/shortcuts_model.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +// ignore: depend_on_referenced_packages +import 'package:file/memory.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late SettingsShortcutService service; + late File mockFile; + String shortcutsJson = ''; + + setUp(() async { + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + mockFile = await fileSystem.file("shortcuts.json").create(recursive: true); + service = SettingsShortcutService(file: mockFile); + shortcutsJson = """{ + "commandShortcuts":[ + { + "key":"move the cursor upward", + "command":"alt+arrow up" + }, + { + "key":"move the cursor backward one character", + "command":"alt+arrow left" + }, + { + "key":"move the cursor downward", + "command":"alt+arrow down" + } + ] +}"""; + }); + + group("Settings Shortcut Service", () { + test( + "returns default standard shortcuts if file is empty", + () async { + expect(await service.getCustomizeShortcuts(), []); + }, + ); + + test('returns updated shortcut event list from json', () { + final commandShortcuts = service.getShortcutsFromJson(shortcutsJson); + + final cursorUpShortcut = commandShortcuts + .firstWhere((el) => el.key == "move the cursor upward"); + + final cursorDownShortcut = commandShortcuts + .firstWhere((el) => el.key == "move the cursor downward"); + + expect( + commandShortcuts.length, + 3, + ); + expect(cursorUpShortcut.command, "alt+arrow up"); + expect(cursorDownShortcut.command, "alt+arrow down"); + }); + + test( + "saveAllShortcuts saves shortcuts", + () async { + //updating one of standard command shortcut events. + final currentCommandShortcuts = standardCommandShortcutEvents; + const kKey = "scroll one page down"; + const oldCommand = "page down"; + const newCommand = "alt+page down"; + final commandShortcutEvent = currentCommandShortcuts + .firstWhere((element) => element.key == kKey); + + expect(commandShortcutEvent.command, oldCommand); + + //updating the command. + commandShortcutEvent.updateCommand( + command: newCommand, + ); + + //saving the updated shortcuts + await service.saveAllShortcuts(currentCommandShortcuts); + + //reading from the mock file the saved shortcut list. + final savedDataInFile = await mockFile.readAsString(); + + //Check if the lists where properly converted to JSON and saved. + final shortcuts = EditorShortcuts( + commandShortcuts: + currentCommandShortcuts.toCommandShortcutModelList(), + ); + + expect(jsonEncode(shortcuts.toJson()), savedDataInFile); + + //now checking if the modified command of "move the cursor upward" is "arrow up" + final newCommandShortcuts = + service.getShortcutsFromJson(savedDataInFile); + + final updatedCommandEvent = + newCommandShortcuts.firstWhere((el) => el.key == kKey); + + expect(updatedCommandEvent.command, newCommand); + }, + ); + + test('load shortcuts from file', () async { + //updating one of standard command shortcut event. + const kKey = "scroll one page up"; + const oldCommand = "page up"; + const newCommand = "alt+page up"; + final currentCommandShortcuts = standardCommandShortcutEvents; + final commandShortcutEvent = + currentCommandShortcuts.firstWhere((element) => element.key == kKey); + + expect(commandShortcutEvent.command, oldCommand); + + //updating the command. + commandShortcutEvent.updateCommand(command: newCommand); + + //saving the updated shortcuts + await service.saveAllShortcuts(currentCommandShortcuts); + + //now directly fetching the shortcuts from loadShortcuts + final commandShortcuts = await service.getCustomizeShortcuts(); + expect( + commandShortcuts, + currentCommandShortcuts.toCommandShortcutModelList(), + ); + + final updatedCommandEvent = + commandShortcuts.firstWhere((el) => el.key == kKey); + + expect(updatedCommandEvent.command, newCommand); + }); + + test('updateCommandShortcuts works properly', () async { + //updating one of standard command shortcut event. + const kKey = "move the cursor backward one character"; + const oldCommand = "arrow left"; + const newCommand = "alt+arrow left"; + final currentCommandShortcuts = standardCommandShortcutEvents; + + //check if the current shortcut event's key is set to old command. + final currentCommandEvent = + currentCommandShortcuts.firstWhere((el) => el.key == kKey); + + expect(currentCommandEvent.command, oldCommand); + + final commandShortcutModelList = + EditorShortcuts.fromJson(jsonDecode(shortcutsJson)).commandShortcuts; + + //now calling the updateCommandShortcuts method + await service.updateCommandShortcuts( + currentCommandShortcuts, + commandShortcutModelList, + ); + + //check if the shortcut event's key is updated. + final updatedCommandEvent = + currentCommandShortcuts.firstWhere((el) => el.key == kKey); + + expect(updatedCommandEvent.command, newCommand); + }); + }); +} + +extension on List { + List toCommandShortcutModelList() => + map((e) => CommandShortcutModel.fromCommandEvent(e)).toList(); +} diff --git a/frontend/appflowy_flutter/test/unit_test/settings/theme_missing_keys_test.dart b/frontend/appflowy_flutter/test/unit_test/settings/theme_missing_keys_test.dart new file mode 100644 index 0000000000000..646ce7fbac3e0 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/settings/theme_missing_keys_test.dart @@ -0,0 +1,32 @@ +import 'package:flowy_infra/colorscheme/colorscheme.dart'; +import 'package:flowy_infra/colorscheme/default_colorscheme.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Theme missing keys', () { + test('no missing keys', () { + const colorScheme = DefaultColorScheme.light(); + final toJson = colorScheme.toJson(); + + expect(toJson.containsKey('surface'), true); + + final missingKeys = FlowyColorScheme.getMissingKeys(toJson); + expect(missingKeys.isEmpty, true); + }); + + test('missing surface and bg2', () { + const colorScheme = DefaultColorScheme.light(); + final toJson = colorScheme.toJson() + ..remove('surface') + ..remove('bg2'); + + expect(toJson.containsKey('surface'), false); + expect(toJson.containsKey('bg2'), false); + + final missingKeys = FlowyColorScheme.getMissingKeys(toJson); + expect(missingKeys.length, 2); + expect(missingKeys.contains('surface'), true); + expect(missingKeys.contains('bg2'), true); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_contente_operation_test.dart b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_contente_operation_test.dart new file mode 100644 index 0000000000000..57b8319d06202 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_contente_operation_test.dart @@ -0,0 +1,216 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'simple_table_test_helper.dart'; + +void main() { + group('Simple table content operation:', () { + void setupDependencyInjection() { + getIt.registerSingleton(ClipboardService()); + } + + setUpAll(() { + Log.shared.disableLog = true; + + setupDependencyInjection(); + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test('clear content at row 1', () async { + const defaultContent = 'default content'; + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + defaultContent: defaultContent, + ); + await editorState.clearContentAtRowIndex( + tableNode: tableNode, + rowIndex: 0, + ); + for (var i = 0; i < tableNode.rowLength; i++) { + for (var j = 0; j < tableNode.columnLength; j++) { + expect( + tableNode + .getTableCellNode(rowIndex: i, columnIndex: j) + ?.children + .first + .delta + ?.toPlainText(), + i == 0 ? '' : defaultContent, + ); + } + } + }); + + test('clear content at row 3', () async { + const defaultContent = 'default content'; + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + defaultContent: defaultContent, + ); + await editorState.clearContentAtRowIndex( + tableNode: tableNode, + rowIndex: 2, + ); + for (var i = 0; i < tableNode.rowLength; i++) { + for (var j = 0; j < tableNode.columnLength; j++) { + expect( + tableNode + .getTableCellNode(rowIndex: i, columnIndex: j) + ?.children + .first + .delta + ?.toPlainText(), + i == 2 ? '' : defaultContent, + ); + } + } + }); + + test('clear content at column 1', () async { + const defaultContent = 'default content'; + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + defaultContent: defaultContent, + ); + await editorState.clearContentAtColumnIndex( + tableNode: tableNode, + columnIndex: 0, + ); + for (var i = 0; i < tableNode.rowLength; i++) { + for (var j = 0; j < tableNode.columnLength; j++) { + expect( + tableNode + .getTableCellNode(rowIndex: i, columnIndex: j) + ?.children + .first + .delta + ?.toPlainText(), + j == 0 ? '' : defaultContent, + ); + } + } + }); + + test('clear content at column 4', () async { + const defaultContent = 'default content'; + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + defaultContent: defaultContent, + ); + await editorState.clearContentAtColumnIndex( + tableNode: tableNode, + columnIndex: 3, + ); + for (var i = 0; i < tableNode.rowLength; i++) { + for (var j = 0; j < tableNode.columnLength; j++) { + expect( + tableNode + .getTableCellNode(rowIndex: i, columnIndex: j) + ?.children + .first + .delta + ?.toPlainText(), + j == 3 ? '' : defaultContent, + ); + } + } + }); + + test('copy row 1-2', () async { + const rowCount = 2; + const columnCount = 3; + + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: rowCount, + columnCount: columnCount, + contentBuilder: (rowIndex, columnIndex) => + 'row $rowIndex, column $columnIndex', + ); + + for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) { + final data = await editorState.copyRow( + tableNode: tableNode, + rowIndex: rowIndex, + ); + expect(data, isNotNull); + expect( + data?.plainText, + 'row $rowIndex, column 0\nrow $rowIndex, column 1\nrow $rowIndex, column 2', + ); + } + }); + + test('copy column 1-2', () async { + const rowCount = 2; + const columnCount = 3; + + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: rowCount, + columnCount: columnCount, + contentBuilder: (rowIndex, columnIndex) => + 'row $rowIndex, column $columnIndex', + ); + + for (var columnIndex = 0; columnIndex < columnCount; columnIndex++) { + final data = await editorState.copyColumn( + tableNode: tableNode, + columnIndex: columnIndex, + ); + expect(data, isNotNull); + expect( + data?.plainText, + 'row 0, column $columnIndex\nrow 1, column $columnIndex', + ); + } + }); + + test('cut row 1-2', () async { + const rowCount = 2; + const columnCount = 3; + + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: rowCount, + columnCount: columnCount, + contentBuilder: (rowIndex, columnIndex) => + 'row $rowIndex, column $columnIndex', + ); + + for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) { + final data = await editorState.copyRow( + tableNode: tableNode, + rowIndex: rowIndex, + clearContent: true, + ); + expect(data, isNotNull); + expect( + data?.plainText, + 'row $rowIndex, column 0\nrow $rowIndex, column 1\nrow $rowIndex, column 2', + ); + } + + for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) { + for (var columnIndex = 0; columnIndex < columnCount; columnIndex++) { + expect( + tableNode + .getTableCellNode(rowIndex: rowIndex, columnIndex: columnIndex) + ?.children + .first + .delta + ?.toPlainText(), + '', + ); + } + } + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_delete_operation_test.dart b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_delete_operation_test.dart new file mode 100644 index 0000000000000..c9c1be83792f2 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_delete_operation_test.dart @@ -0,0 +1,174 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'simple_table_test_helper.dart'; + +void main() { + group('Simple table delete operation:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test('delete 2 rows in table', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + await editorState.deleteRowInTable(tableNode, 0); + await editorState.deleteRowInTable(tableNode, 0); + expect(tableNode.rowLength, 1); + expect(tableNode.columnLength, 4); + }); + + test('delete 2 columns in table', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + await editorState.deleteColumnInTable(tableNode, 0); + await editorState.deleteColumnInTable(tableNode, 0); + expect(tableNode.rowLength, 3); + expect(tableNode.columnLength, 2); + }); + + test('delete a row and a column in table', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + await editorState.deleteColumnInTable(tableNode, 0); + await editorState.deleteRowInTable(tableNode, 0); + expect(tableNode.rowLength, 2); + expect(tableNode.columnLength, 3); + }); + + test('delete a row with background and align (1)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + // delete the row 1 + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 1, columnIndex: 0); + await editorState.updateRowBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + expect(tableCellNode.rowColors, { + '1': '0xFF0000FF', + }); + await editorState.updateRowAlign( + tableCellNode: tableCellNode, + align: TableAlign.center, + ); + expect(tableNode.rowAligns, { + '1': TableAlign.center.key, + }); + await editorState.deleteRowInTable(tableNode, 1); + expect(tableNode.rowLength, 2); + expect(tableNode.columnLength, 4); + expect(tableCellNode.rowColors, {}); + expect(tableNode.rowAligns, {}); + }); + + test('delete a row with background and align (2)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + // delete the row 1 + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 1, columnIndex: 0); + await editorState.updateRowBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + expect(tableCellNode.rowColors, { + '1': '0xFF0000FF', + }); + await editorState.updateRowAlign( + tableCellNode: tableCellNode, + align: TableAlign.center, + ); + expect(tableNode.rowAligns, { + '1': TableAlign.center.key, + }); + await editorState.deleteRowInTable(tableNode, 0); + expect(tableNode.rowLength, 2); + expect(tableNode.columnLength, 4); + expect(tableCellNode.rowColors, { + '0': '0xFF0000FF', + }); + expect(tableNode.rowAligns, { + '0': TableAlign.center.key, + }); + }); + + test('delete a column with background and align (1)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + // delete the column 1 + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); + await editorState.updateColumnBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + expect(tableCellNode.columnColors, { + '1': '0xFF0000FF', + }); + await editorState.updateColumnAlign( + tableCellNode: tableCellNode, + align: TableAlign.center, + ); + expect(tableNode.columnAligns, { + '1': TableAlign.center.key, + }); + await editorState.deleteColumnInTable(tableNode, 1); + expect(tableNode.rowLength, 3); + expect(tableNode.columnLength, 3); + expect(tableCellNode.columnColors, {}); + expect(tableNode.columnAligns, {}); + }); + + test('delete a column with background (2)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + // delete the column 1 + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); + await editorState.updateColumnBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + expect(tableCellNode.columnColors, { + '1': '0xFF0000FF', + }); + await editorState.updateColumnAlign( + tableCellNode: tableCellNode, + align: TableAlign.center, + ); + expect(tableNode.columnAligns, { + '1': TableAlign.center.key, + }); + await editorState.deleteColumnInTable(tableNode, 0); + expect(tableNode.rowLength, 3); + expect(tableNode.columnLength, 3); + expect(tableCellNode.columnColors, { + '0': '0xFF0000FF', + }); + expect(tableNode.columnAligns, { + '0': TableAlign.center.key, + }); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_duplicate_operation_test.dart b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_duplicate_operation_test.dart new file mode 100644 index 0000000000000..123310538addf --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_duplicate_operation_test.dart @@ -0,0 +1,165 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'simple_table_test_helper.dart'; + +void main() { + group('Simple table delete operation:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test('duplicate a row', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + await editorState.duplicateRowInTable(tableNode, 0); + expect(tableNode.rowLength, 4); + expect(tableNode.columnLength, 4); + }); + + test('duplicate a column', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + await editorState.duplicateColumnInTable(tableNode, 0); + expect(tableNode.rowLength, 3); + expect(tableNode.columnLength, 5); + }); + + test('duplicate a row with background and align (1)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + // duplicate the row 1 + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 1, columnIndex: 0); + await editorState.updateRowBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + expect(tableCellNode.rowColors, { + '1': '0xFF0000FF', + }); + await editorState.updateRowAlign( + tableCellNode: tableCellNode, + align: TableAlign.center, + ); + expect(tableNode.rowAligns, { + '1': TableAlign.center.key, + }); + await editorState.duplicateRowInTable(tableNode, 1); + expect(tableCellNode.rowColors, { + '1': '0xFF0000FF', + '2': '0xFF0000FF', + }); + expect(tableNode.rowAligns, { + '1': TableAlign.center.key, + '2': TableAlign.center.key, + }); + }); + + test('duplicate a row with background and align (2)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + // duplicate the row 1 + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 1, columnIndex: 0); + await editorState.updateRowBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + expect(tableCellNode.rowColors, { + '1': '0xFF0000FF', + }); + await editorState.updateRowAlign( + tableCellNode: tableCellNode, + align: TableAlign.center, + ); + expect(tableNode.rowAligns, { + '1': TableAlign.center.key, + }); + await editorState.duplicateRowInTable(tableNode, 2); + expect(tableCellNode.rowColors, { + '1': '0xFF0000FF', + }); + expect(tableNode.rowAligns, { + '1': TableAlign.center.key, + }); + }); + + test('duplicate a column with background and align (1)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + // duplicate the column 1 + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); + await editorState.updateColumnBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + await editorState.updateColumnAlign( + tableCellNode: tableCellNode, + align: TableAlign.center, + ); + expect(tableNode.columnColors, { + '1': '0xFF0000FF', + }); + expect(tableNode.columnAligns, { + '1': TableAlign.center.key, + }); + await editorState.duplicateColumnInTable(tableNode, 1); + expect(tableCellNode.columnColors, { + '1': '0xFF0000FF', + '2': '0xFF0000FF', + }); + expect(tableNode.columnAligns, { + '1': TableAlign.center.key, + '2': TableAlign.center.key, + }); + }); + + test('duplicate a column with background and align (2)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 4, + ); + // duplicate the column 1 + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 0, columnIndex: 1); + await editorState.updateColumnBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + await editorState.updateColumnAlign( + tableCellNode: tableCellNode, + align: TableAlign.center, + ); + expect(tableNode.columnColors, { + '1': '0xFF0000FF', + }); + expect(tableNode.columnAligns, { + '1': TableAlign.center.key, + }); + await editorState.duplicateColumnInTable(tableNode, 2); + expect(tableCellNode.columnColors, { + '1': '0xFF0000FF', + }); + expect(tableNode.columnAligns, { + '1': TableAlign.center.key, + }); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_header_operation_test.dart b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_header_operation_test.dart new file mode 100644 index 0000000000000..1f0707cc0d16b --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_header_operation_test.dart @@ -0,0 +1,59 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'simple_table_test_helper.dart'; + +void main() { + group('Simple table header operation:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test('enable header column in table', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + // default is not header column + expect(tableNode.isHeaderColumnEnabled, false); + await editorState.toggleEnableHeaderColumn( + tableNode: tableNode, + enable: true, + ); + expect(tableNode.isHeaderColumnEnabled, true); + await editorState.toggleEnableHeaderColumn( + tableNode: tableNode, + enable: false, + ); + expect(tableNode.isHeaderColumnEnabled, false); + expect(tableNode.rowLength, 2); + expect(tableNode.columnLength, 3); + }); + + test('enable header row in table', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + // default is not header row + expect(tableNode.isHeaderRowEnabled, false); + await editorState.toggleEnableHeaderRow( + tableNode: tableNode, + enable: true, + ); + expect(tableNode.isHeaderRowEnabled, true); + await editorState.toggleEnableHeaderRow( + tableNode: tableNode, + enable: false, + ); + expect(tableNode.isHeaderRowEnabled, false); + expect(tableNode.rowLength, 2); + expect(tableNode.columnLength, 3); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_insert_operation_test.dart b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_insert_operation_test.dart new file mode 100644 index 0000000000000..c84615ac4280c --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_insert_operation_test.dart @@ -0,0 +1,194 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'simple_table_test_helper.dart'; + +void main() { + group('Simple table insert operation:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test('add 2 rows in table', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + await editorState.addRowInTable(tableNode); + await editorState.addRowInTable(tableNode); + expect(tableNode.rowLength, 4); + expect(tableNode.columnLength, 3); + }); + + test('add 2 columns in table', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + await editorState.addColumnInTable(tableNode); + await editorState.addColumnInTable(tableNode); + expect(tableNode.rowLength, 2); + expect(tableNode.columnLength, 5); + }); + + test('add 2 rows and 2 columns in table', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + await editorState.addColumnAndRowInTable(tableNode); + await editorState.addColumnAndRowInTable(tableNode); + expect(tableNode.rowLength, 4); + expect(tableNode.columnLength, 5); + }); + + test('insert a row at the first position in table', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + await editorState.insertRowInTable(tableNode, 0); + expect(tableNode.rowLength, 3); + expect(tableNode.columnLength, 3); + }); + + test('insert a column at the first position in table', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + await editorState.insertColumnInTable(tableNode, 0); + expect(tableNode.columnLength, 4); + expect(tableNode.rowLength, 2); + }); + + test('insert a row with background and align (1)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + // insert the row at the first position + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 0, columnIndex: 0); + await editorState.updateRowBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + expect(tableNode.rowColors, { + '0': '0xFF0000FF', + }); + await editorState.updateRowAlign( + tableCellNode: tableCellNode, + align: TableAlign.center, + ); + expect(tableNode.rowAligns, { + '0': TableAlign.center.key, + }); + await editorState.insertRowInTable(tableNode, 0); + expect(tableNode.rowColors, { + '1': '0xFF0000FF', + }); + expect(tableNode.rowAligns, { + '1': TableAlign.center.key, + }); + }); + + test('insert a row with background and align (2)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + // insert the row at the first position + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 0, columnIndex: 0); + await editorState.updateRowBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + expect(tableNode.rowColors, { + '0': '0xFF0000FF', + }); + await editorState.updateRowAlign( + tableCellNode: tableCellNode, + align: TableAlign.center, + ); + expect(tableNode.rowAligns, { + '0': TableAlign.center.key, + }); + await editorState.insertRowInTable(tableNode, 1); + expect(tableNode.rowColors, { + '0': '0xFF0000FF', + }); + expect(tableNode.rowAligns, { + '0': TableAlign.center.key, + }); + }); + + test('insert a column with background and align (1)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + // insert the column at the first position + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 0, columnIndex: 0); + await editorState.updateColumnBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + await editorState.updateColumnAlign( + tableCellNode: tableCellNode, + align: TableAlign.center, + ); + expect(tableNode.columnColors, { + '0': '0xFF0000FF', + }); + expect(tableNode.columnAligns, { + '0': TableAlign.center.key, + }); + await editorState.insertColumnInTable(tableNode, 0); + expect(tableNode.columnColors, { + '1': '0xFF0000FF', + }); + expect(tableNode.columnAligns, { + '1': TableAlign.center.key, + }); + }); + + test('insert a column with background and align (1)', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + // insert the column at the first position + final tableCellNode = + tableNode.getTableCellNode(rowIndex: 0, columnIndex: 0); + await editorState.updateColumnBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + await editorState.updateColumnAlign( + tableCellNode: tableCellNode, + align: TableAlign.center, + ); + expect(tableNode.columnColors, { + '0': '0xFF0000FF', + }); + expect(tableNode.columnAligns, { + '0': TableAlign.center.key, + }); + await editorState.insertColumnInTable(tableNode, 1); + expect(tableNode.columnColors, { + '0': '0xFF0000FF', + }); + expect(tableNode.columnAligns, { + '0': TableAlign.center.key, + }); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_markdown_test.dart b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_markdown_test.dart new file mode 100644 index 0000000000000..403795887357f --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_markdown_test.dart @@ -0,0 +1,162 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Simple table markdown:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test('convert simple table to markdown (1)', () async { + final tableNode = createSimpleTableBlockNode( + columnCount: 7, + rowCount: 11, + contentBuilder: (rowIndex, columnIndex) => + _sampleContents[rowIndex][columnIndex], + ); + final markdown = const SimpleTableNodeParser().transform( + tableNode, + null, + ); + expect(markdown, + '''|Index|Customer Id|First Name|Last Name|Company|City|Country| +|---|---|---|---|---|---|---| +|1|DD37Cf93aecA6Dc|Sheryl|Baxter|Rasmussen Group|East Leonard|Chile| +|2|1Ef7b82A4CAAD10|Preston|Lozano|Vega-Gentry|East Jimmychester|Djibouti| +|3|6F94879bDAfE5a6|Roy|Berry|Murillo-Perry|Isabelborough|Antigua and Barbuda| +|4|5Cef8BFA16c5e3c|Linda|Olsen|Dominguez, Mcmillan and Donovan|Bensonview|Dominican Republic| +|5|053d585Ab6b3159|Joanna|Bender|Martin, Lang and Andrade|West Priscilla|Slovakia (Slovak Republic)| +|6|2d08FB17EE273F4|Aimee|Downs|Steele Group|Chavezborough|Bosnia and Herzegovina| +|7|EAd384DfDbBf77|Darren|Peck|Lester, Woodard and Mitchell|Lake Ana|Pitcairn Islands| +|8|0e04AFde9f225dE|Brett|Mullen|Sanford, Davenport and Giles|Kimport|Bulgaria| +|9|C2dE4dEEc489ae0|Sheryl|Meyers|Browning-Simon|Robersonstad|Cyprus| +|10|8C2811a503C7c5a|Michelle|Gallagher|Beck-Hendrix|Elaineberg|Timor-Leste| +'''); + }); + + test('convert markdown to simple table (1)', () async { + final document = customMarkdownToDocument(_sampleMarkdown1); + expect(document, isNotNull); + final tableNode = document.nodeAtPath([0])!; + expect(tableNode, isNotNull); + expect(tableNode.type, equals(SimpleTableBlockKeys.type)); + expect(tableNode.rowLength, equals(4)); + expect(tableNode.columnLength, equals(4)); + }); + }); +} + +const _sampleContents = >[ + [ + "Index", + "Customer Id", + "First Name", + "Last Name", + "Company", + "City", + "Country", + ], + [ + "1", + "DD37Cf93aecA6Dc", + "Sheryl", + "Baxter", + "Rasmussen Group", + "East Leonard", + "Chile", + ], + [ + "2", + "1Ef7b82A4CAAD10", + "Preston", + "Lozano", + "Vega-Gentry", + "East Jimmychester", + "Djibouti", + ], + [ + "3", + "6F94879bDAfE5a6", + "Roy", + "Berry", + "Murillo-Perry", + "Isabelborough", + "Antigua and Barbuda", + ], + [ + "4", + "5Cef8BFA16c5e3c", + "Linda", + "Olsen", + "Dominguez, Mcmillan and Donovan", + "Bensonview", + "Dominican Republic", + ], + [ + "5", + "053d585Ab6b3159", + "Joanna", + "Bender", + "Martin, Lang and Andrade", + "West Priscilla", + "Slovakia (Slovak Republic)", + ], + [ + "6", + "2d08FB17EE273F4", + "Aimee", + "Downs", + "Steele Group", + "Chavezborough", + "Bosnia and Herzegovina", + ], + [ + "7", + "EAd384DfDbBf77", + "Darren", + "Peck", + "Lester, Woodard and Mitchell", + "Lake Ana", + "Pitcairn Islands", + ], + [ + "8", + "0e04AFde9f225dE", + "Brett", + "Mullen", + "Sanford, Davenport and Giles", + "Kimport", + "Bulgaria", + ], + [ + "9", + "C2dE4dEEc489ae0", + "Sheryl", + "Meyers", + "Browning-Simon", + "Robersonstad", + "Cyprus", + ], + [ + "10", + "8C2811a503C7c5a", + "Michelle", + "Gallagher", + "Beck-Hendrix", + "Elaineberg", + "Timor-Leste", + ], +]; + +const _sampleMarkdown1 = '''|A|B|C|| +|---|---|---|---| +|D|E|F|| +|1|2|3|| +||||| +'''; diff --git a/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_reorder_operation_test.dart b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_reorder_operation_test.dart new file mode 100644 index 0000000000000..703a63b40d595 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_reorder_operation_test.dart @@ -0,0 +1,335 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'simple_table_test_helper.dart'; + +void main() { + group('Simple table reorder operation:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + group('reorder column', () { + test('reorder column from index 1 to index 2', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 4, + columnCount: 3, + contentBuilder: (rowIndex, columnIndex) => + 'cell $rowIndex-$columnIndex', + ); + await editorState.reorderColumn(tableNode, fromIndex: 1, toIndex: 2); + expect(tableNode.columnLength, 3); + expect(tableNode.rowLength, 4); + expect( + tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0), + 'cell 0-0', + ); + expect( + tableNode.getTableCellContent(rowIndex: 0, columnIndex: 1), + 'cell 0-2', + ); + expect( + tableNode.getTableCellContent(rowIndex: 0, columnIndex: 2), + 'cell 0-1', + ); + }); + + test('reorder column from index 2 to index 0', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 4, + columnCount: 3, + contentBuilder: (rowIndex, columnIndex) => + 'cell $rowIndex-$columnIndex', + ); + await editorState.reorderColumn(tableNode, fromIndex: 2, toIndex: 0); + expect(tableNode.columnLength, 3); + expect(tableNode.rowLength, 4); + expect( + tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0), + 'cell 0-2', + ); + expect( + tableNode.getTableCellContent(rowIndex: 0, columnIndex: 1), + 'cell 0-0', + ); + expect( + tableNode.getTableCellContent(rowIndex: 0, columnIndex: 2), + 'cell 0-1', + ); + }); + + test('reorder column with same index', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 4, + columnCount: 3, + contentBuilder: (rowIndex, columnIndex) => + 'cell $rowIndex-$columnIndex', + ); + await editorState.reorderColumn(tableNode, fromIndex: 1, toIndex: 1); + expect(tableNode.columnLength, 3); + expect(tableNode.rowLength, 4); + expect( + tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0), + 'cell 0-0', + ); + expect( + tableNode.getTableCellContent(rowIndex: 0, columnIndex: 1), + 'cell 0-1', + ); + expect( + tableNode.getTableCellContent(rowIndex: 0, columnIndex: 2), + 'cell 0-2', + ); + }); + + test( + 'reorder column from index 0 to index 2 with align/color/width attributes (1)', + () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 4, + columnCount: 3, + contentBuilder: (rowIndex, columnIndex) => + 'cell $rowIndex-$columnIndex', + ); + + // before reorder + // Column 0: align: right, color: 0xFF0000, width: 100 + // Column 1: align: center, color: 0x00FF00, width: 150 + // Column 2: align: left, color: 0x0000FF, width: 200 + await updateTableColumnAttributes( + editorState, + tableNode, + columnIndex: 0, + align: TableAlign.right, + color: '#FF0000', + width: 100, + ); + await updateTableColumnAttributes( + editorState, + tableNode, + columnIndex: 1, + align: TableAlign.center, + color: '#00FF00', + width: 150, + ); + await updateTableColumnAttributes( + editorState, + tableNode, + columnIndex: 2, + align: TableAlign.left, + color: '#0000FF', + width: 200, + ); + + // after reorder + // Column 0: align: center, color: 0x00FF00, width: 150 + // Column 1: align: left, color: 0x0000FF, width: 200 + // Column 2: align: right, color: 0xFF0000, width: 100 + await editorState.reorderColumn(tableNode, fromIndex: 0, toIndex: 2); + expect(tableNode.columnLength, 3); + expect(tableNode.rowLength, 4); + + expect(tableNode.columnAligns, { + "0": TableAlign.center.key, + "1": TableAlign.left.key, + "2": TableAlign.right.key, + }); + expect(tableNode.columnColors, { + "0": '#00FF00', + "1": '#0000FF', + "2": '#FF0000', + }); + expect(tableNode.columnWidths, { + "0": 150, + "1": 200, + "2": 100, + }); + }); + + test( + 'reorder column from index 0 to index 2 and reorder it back to index 0', + () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + contentBuilder: (rowIndex, columnIndex) => + 'cell $rowIndex-$columnIndex', + ); + + // before reorder + // Column 0: null + // Column 1: align: center, color: 0x0000FF, width: 200 + // Column 2: align: right, color: 0x0000FF, width: 250 + await updateTableColumnAttributes( + editorState, + tableNode, + columnIndex: 1, + align: TableAlign.center, + color: '#FF0000', + width: 200, + ); + await updateTableColumnAttributes( + editorState, + tableNode, + columnIndex: 2, + align: TableAlign.right, + color: '#0000FF', + width: 250, + ); + + // move column from index 0 to index 2 + await editorState.reorderColumn(tableNode, fromIndex: 0, toIndex: 2); + // move column from index 2 to index 0 + await editorState.reorderColumn(tableNode, fromIndex: 2, toIndex: 0); + expect(tableNode.columnLength, 3); + expect(tableNode.rowLength, 2); + + expect(tableNode.columnAligns, { + "1": TableAlign.center.key, + "2": TableAlign.right.key, + }); + expect(tableNode.columnColors, { + "1": '#FF0000', + "2": '#0000FF', + }); + expect(tableNode.columnWidths, { + "1": 200, + "2": 250, + }); + }); + }); + + group('reorder row', () { + test('reorder row from index 1 to index 2', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 2, + contentBuilder: (rowIndex, columnIndex) => + 'cell $rowIndex-$columnIndex', + ); + await editorState.reorderRow(tableNode, fromIndex: 1, toIndex: 2); + expect(tableNode.columnLength, 2); + expect(tableNode.rowLength, 3); + expect( + tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0), + 'cell 0-0', + ); + expect( + tableNode.getTableCellContent(rowIndex: 1, columnIndex: 0), + 'cell 2-0', + ); + expect( + tableNode.getTableCellContent(rowIndex: 2, columnIndex: 0), + 'cell 1-0', + ); + }); + + test('reorder row from index 2 to index 0', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 2, + contentBuilder: (rowIndex, columnIndex) => + 'cell $rowIndex-$columnIndex', + ); + await editorState.reorderRow(tableNode, fromIndex: 2, toIndex: 0); + expect(tableNode.columnLength, 2); + expect(tableNode.rowLength, 3); + expect( + tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0), + 'cell 2-0', + ); + expect( + tableNode.getTableCellContent(rowIndex: 1, columnIndex: 0), + 'cell 0-0', + ); + expect( + tableNode.getTableCellContent(rowIndex: 2, columnIndex: 0), + 'cell 1-0', + ); + }); + + test('reorder row with same', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 2, + contentBuilder: (rowIndex, columnIndex) => + 'cell $rowIndex-$columnIndex', + ); + await editorState.reorderRow(tableNode, fromIndex: 1, toIndex: 1); + expect(tableNode.columnLength, 2); + expect(tableNode.rowLength, 3); + expect( + tableNode.getTableCellContent(rowIndex: 0, columnIndex: 0), + 'cell 0-0', + ); + expect( + tableNode.getTableCellContent(rowIndex: 1, columnIndex: 0), + 'cell 1-0', + ); + expect( + tableNode.getTableCellContent(rowIndex: 2, columnIndex: 0), + 'cell 2-0', + ); + }); + + test('reorder row from index 0 to index 2 with align/color attributes', + () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 3, + columnCount: 2, + contentBuilder: (rowIndex, columnIndex) => + 'cell $rowIndex-$columnIndex', + ); + + // before reorder + // Row 0: align: right, color: 0xFF0000 + // Row 1: align: center, color: 0x00FF00 + // Row 2: align: left, color: 0x0000FF + await updateTableRowAttributes( + editorState, + tableNode, + rowIndex: 0, + align: TableAlign.right, + color: '#FF0000', + ); + await updateTableRowAttributes( + editorState, + tableNode, + rowIndex: 1, + align: TableAlign.center, + color: '#00FF00', + ); + await updateTableRowAttributes( + editorState, + tableNode, + rowIndex: 2, + align: TableAlign.left, + color: '#0000FF', + ); + + // after reorder + // Row 0: align: center, color: 0x00FF00 + // Row 1: align: left, color: 0x0000FF + // Row 2: align: right, color: 0xFF0000 + await editorState.reorderRow(tableNode, fromIndex: 0, toIndex: 2); + expect(tableNode.columnLength, 2); + expect(tableNode.rowLength, 3); + expect(tableNode.rowAligns, { + "0": TableAlign.center.key, + "1": TableAlign.left.key, + "2": TableAlign.right.key, + }); + expect(tableNode.rowColors, { + "0": '#00FF00', + "1": '#0000FF', + "2": '#FF0000', + }); + }); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_style_operation_test.dart b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_style_operation_test.dart new file mode 100644 index 0000000000000..940f03711ae20 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_style_operation_test.dart @@ -0,0 +1,197 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'simple_table_test_helper.dart'; + +void main() { + group('Simple table style operation:', () { + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test('update column width in memory', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + // check the default column width + expect(tableNode.columnWidths, isEmpty); + final tableCellNode = tableNode.getTableCellNode( + rowIndex: 0, + columnIndex: 0, + ); + await editorState.updateColumnWidthInMemory( + tableCellNode: tableCellNode!, + deltaX: 100, + ); + expect(tableNode.columnWidths, { + '0': SimpleTableConstants.defaultColumnWidth + 100, + }); + + // set the width less than the minimum column width + await editorState.updateColumnWidthInMemory( + tableCellNode: tableCellNode, + deltaX: -1000, + ); + expect(tableNode.columnWidths, { + '0': SimpleTableConstants.minimumColumnWidth, + }); + }); + + test('update column width', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + expect(tableNode.columnWidths, isEmpty); + + for (var i = 0; i < tableNode.columnLength; i++) { + final tableCellNode = tableNode.getTableCellNode( + rowIndex: 0, + columnIndex: i, + ); + await editorState.updateColumnWidth( + tableCellNode: tableCellNode!, + width: 100, + ); + } + expect(tableNode.columnWidths, { + '0': 100, + '1': 100, + '2': 100, + }); + + // set the width less than the minimum column width + for (var i = 0; i < tableNode.columnLength; i++) { + final tableCellNode = tableNode.getTableCellNode( + rowIndex: 0, + columnIndex: i, + ); + await editorState.updateColumnWidth( + tableCellNode: tableCellNode!, + width: -1000, + ); + } + expect(tableNode.columnWidths, { + '0': SimpleTableConstants.minimumColumnWidth, + '1': SimpleTableConstants.minimumColumnWidth, + '2': SimpleTableConstants.minimumColumnWidth, + }); + }); + + test('update column align', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + for (var i = 0; i < tableNode.columnLength; i++) { + final tableCellNode = tableNode.getTableCellNode( + rowIndex: 0, + columnIndex: i, + ); + await editorState.updateColumnAlign( + tableCellNode: tableCellNode!, + align: TableAlign.center, + ); + } + expect(tableNode.columnAligns, { + '0': TableAlign.center.key, + '1': TableAlign.center.key, + '2': TableAlign.center.key, + }); + }); + + test('update row align', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + + for (var i = 0; i < tableNode.rowLength; i++) { + final tableCellNode = tableNode.getTableCellNode( + rowIndex: i, + columnIndex: 0, + ); + await editorState.updateRowAlign( + tableCellNode: tableCellNode!, + align: TableAlign.center, + ); + } + + expect(tableNode.rowAligns, { + '0': TableAlign.center.key, + '1': TableAlign.center.key, + }); + }); + + test('update column background color', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + + for (var i = 0; i < tableNode.columnLength; i++) { + final tableCellNode = tableNode.getTableCellNode( + rowIndex: 0, + columnIndex: i, + ); + await editorState.updateColumnBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + } + expect(tableNode.columnColors, { + '0': '0xFF0000FF', + '1': '0xFF0000FF', + '2': '0xFF0000FF', + }); + }); + + test('update row background color', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + + for (var i = 0; i < tableNode.rowLength; i++) { + final tableCellNode = tableNode.getTableCellNode( + rowIndex: i, + columnIndex: 0, + ); + await editorState.updateRowBackgroundColor( + tableCellNode: tableCellNode!, + color: '0xFF0000FF', + ); + } + + expect(tableNode.rowColors, { + '0': '0xFF0000FF', + '1': '0xFF0000FF', + }); + }); + + test('update table align', () async { + final (editorState, tableNode) = createEditorStateAndTable( + rowCount: 2, + columnCount: 3, + ); + + for (final align in [ + TableAlign.center, + TableAlign.right, + TableAlign.left, + ]) { + await editorState.updateTableAlign( + tableNode: tableNode, + align: align, + ); + expect(tableNode.tableAlign, align); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_test_helper.dart b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_test_helper.dart new file mode 100644 index 0000000000000..e190925bee7f7 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/simple_table/simple_table_test_helper.dart @@ -0,0 +1,86 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +(EditorState editorState, Node tableNode) createEditorStateAndTable({ + required int rowCount, + required int columnCount, + String? defaultContent, + String Function(int rowIndex, int columnIndex)? contentBuilder, +}) { + final document = Document.blank() + ..insert( + [0], + [ + createSimpleTableBlockNode( + columnCount: columnCount, + rowCount: rowCount, + defaultContent: defaultContent, + contentBuilder: contentBuilder, + ), + ], + ); + final editorState = EditorState(document: document); + return (editorState, document.nodeAtPath([0])!); +} + +Future updateTableColumnAttributes( + EditorState editorState, + Node tableNode, { + required int columnIndex, + TableAlign? align, + String? color, + double? width, +}) async { + final cell = tableNode.getTableCellNode( + rowIndex: 0, + columnIndex: columnIndex, + )!; + + if (align != null) { + await editorState.updateColumnAlign( + tableCellNode: cell, + align: align, + ); + } + + if (color != null) { + await editorState.updateColumnBackgroundColor( + tableCellNode: cell, + color: color, + ); + } + + if (width != null) { + await editorState.updateColumnWidth( + tableCellNode: cell, + width: width, + ); + } +} + +Future updateTableRowAttributes( + EditorState editorState, + Node tableNode, { + required int rowIndex, + TableAlign? align, + String? color, +}) async { + final cell = tableNode.getTableCellNode( + rowIndex: rowIndex, + columnIndex: 0, + )!; + + if (align != null) { + await editorState.updateRowAlign( + tableCellNode: cell, + align: align, + ); + } + + if (color != null) { + await editorState.updateRowBackgroundColor( + tableCellNode: cell, + color: color, + ); + } +} diff --git a/frontend/appflowy_flutter/test/unit_test/theme/theme_test.dart b/frontend/appflowy_flutter/test/unit_test/theme/theme_test.dart new file mode 100644 index 0000000000000..5563c93c15e64 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/theme/theme_test.dart @@ -0,0 +1,81 @@ +import 'package:flowy_infra/colorscheme/colorscheme.dart'; +import 'package:flowy_infra/plugins/service/location_service.dart'; +import 'package:flowy_infra/plugins/service/models/flowy_dynamic_plugin.dart'; +import 'package:flowy_infra/plugins/service/plugin_service.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class MockPluginService implements FlowyPluginService { + @override + Future addPlugin(FlowyDynamicPlugin plugin) => + throw UnimplementedError(); + + @override + Future lookup({required String name}) => + throw UnimplementedError(); + + @override + Future get plugins async => const Iterable.empty(); + + @override + void setLocation(PluginLocationService locationService) => + throw UnimplementedError(); +} + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + group('AppTheme', () { + test('fallback theme', () { + const theme = AppTheme.fallback; + + expect(theme.builtIn, true); + expect(theme.themeName, BuiltInTheme.defaultTheme); + expect(theme.lightTheme, isA()); + expect(theme.darkTheme, isA()); + }); + + test('built-in themes', () { + final themes = AppTheme.builtins; + + expect(themes, isNotEmpty); + for (final theme in themes) { + expect(theme.builtIn, true); + expect( + theme.themeName, + anyOf([ + BuiltInTheme.defaultTheme, + BuiltInTheme.dandelion, + BuiltInTheme.lavender, + BuiltInTheme.lemonade, + ]), + ); + expect(theme.lightTheme, isA()); + expect(theme.darkTheme, isA()); + } + }); + + test('fromName returns existing theme', () async { + final theme = await AppTheme.fromName( + BuiltInTheme.defaultTheme, + pluginService: MockPluginService(), + ); + + expect(theme, isNotNull); + expect(theme.builtIn, true); + expect(theme.themeName, BuiltInTheme.defaultTheme); + expect(theme.lightTheme, isA()); + expect(theme.darkTheme, isA()); + }); + + test('fromName throws error for non-existent theme', () async { + expect( + () async => AppTheme.fromName( + 'bogus', + pluginService: MockPluginService(), + ), + throwsArgumentError, + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/url_launcher/url_launcher_test.dart b/frontend/appflowy_flutter/test/unit_test/url_launcher/url_launcher_test.dart new file mode 100644 index 0000000000000..feac569127cdd --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/url_launcher/url_launcher_test.dart @@ -0,0 +1,19 @@ +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('url launcher unit test', () { + test('launch local uri', () async { + const localUris = [ + 'file://path/to/file.txt', + '/path/to/file.txt', + 'C:\\path\\to\\file.txt', + '../path/to/file.txt', + ]; + for (final uri in localUris) { + final result = localPathRegex.hasMatch(uri); + expect(result, true); + } + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/util/recent_icons_test.dart b/frontend/appflowy_flutter/test/unit_test/util/recent_icons_test.dart new file mode 100644 index 0000000000000..9212467d84fcb --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/util/recent_icons_test.dart @@ -0,0 +1,93 @@ +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon.dart'; +import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart'; +import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + getIt.registerFactory(() => DartKeyValue()); + Log.shared.disableLog = true; + }); + + test('putEmoji', () async { + List emojiIds = await RecentIcons.getEmojiIds(); + assert(emojiIds.isEmpty); + + await RecentIcons.putEmoji('1'); + emojiIds = await RecentIcons.getEmojiIds(); + assert(emojiIds.equals(['1'])); + + await RecentIcons.putEmoji('2'); + assert(emojiIds.equals(['2', '1'])); + + await RecentIcons.putEmoji('1'); + emojiIds = await RecentIcons.getEmojiIds(); + assert(emojiIds.equals(['1', '2'])); + + for (var i = 0; i < RecentIcons.maxLength; ++i) { + await RecentIcons.putEmoji('${i + 100}'); + } + emojiIds = await RecentIcons.getEmojiIds(); + assert(emojiIds.length == RecentIcons.maxLength); + assert( + emojiIds.equals( + List.generate(RecentIcons.maxLength, (i) => '${i + 100}') + .reversed + .toList(), + ), + ); + }); + + test('putIcons', () async { + List icons = await RecentIcons.getIcons(); + assert(icons.isEmpty); + await loadIconGroups(); + final groups = kIconGroups!; + final List localIcons = []; + for (final e in groups) { + localIcons.addAll(e.icons); + } + + bool equalIcon(Icon a, Icon b) => + a.name == b.name && + a.keywords.equals(b.keywords) && + a.content == b.content; + + await RecentIcons.putIcon(localIcons.first); + icons = await RecentIcons.getIcons(); + assert(icons.length == 1); + assert(equalIcon(icons.first, localIcons.first)); + + await RecentIcons.putIcon(localIcons[1]); + icons = await RecentIcons.getIcons(); + assert(icons.length == 2); + assert(equalIcon(icons[0], localIcons[1])); + assert(equalIcon(icons[1], localIcons[0])); + + await RecentIcons.putIcon(localIcons.first); + icons = await RecentIcons.getIcons(); + assert(icons.length == 2); + assert(equalIcon(icons[1], localIcons[1])); + assert(equalIcon(icons[0], localIcons[0])); + + for (var i = 0; i < RecentIcons.maxLength; ++i) { + await RecentIcons.putIcon(localIcons[10 + i]); + } + + icons = await RecentIcons.getIcons(); + assert(icons.length == RecentIcons.maxLength); + + for (var i = 0; i < RecentIcons.maxLength; ++i) { + assert( + equalIcon(icons[RecentIcons.maxLength - i - 1], localIcons[10 + i]), + ); + } + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/util/time.dart b/frontend/appflowy_flutter/test/unit_test/util/time.dart new file mode 100644 index 0000000000000..ca4f2b823090d --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/util/time.dart @@ -0,0 +1,24 @@ +import 'package:appflowy/util/time.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('parseTime should parse time string to minutes', () { + expect(parseTime('10'), 10); + expect(parseTime('70m'), 70); + expect(parseTime('4h 20m'), 260); + expect(parseTime('1h 80m'), 140); + expect(parseTime('asffsa2h3m'), null); + expect(parseTime('2h3m'), null); + expect(parseTime('blah'), null); + expect(parseTime('10a'), null); + expect(parseTime('2h'), 120); + }); + + test('formatTime should format time minutes to formatted string', () { + expect(formatTime(5), "5m"); + expect(formatTime(75), "1h 15m"); + expect(formatTime(120), "2h"); + expect(formatTime(-50), ""); + expect(formatTime(0), "0m"); + }); +} diff --git a/frontend/appflowy_flutter/test/util.dart b/frontend/appflowy_flutter/test/util.dart new file mode 100644 index 0000000000000..c4f2a21c647b8 --- /dev/null +++ b/frontend/appflowy_flutter/test/util.dart @@ -0,0 +1,109 @@ +import 'package:appflowy/startup/launch_configuration.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/application/workspace/workspace_service.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppFlowyUnitTest { + late UserProfilePB userProfile; + late UserBackendService userService; + late WorkspaceService workspaceService; + late WorkspacePB workspace; + + static Future ensureInitialized() async { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + _pathProviderInitialized(); + + await FlowyRunner.run( + AppFlowyApplicationUnitTest(), + IntegrationMode.unitTest, + ); + + final test = AppFlowyUnitTest(); + await test._signIn(); + await test._loadWorkspace(); + + await test._initialServices(); + return test; + } + + Future _signIn() async { + final authService = getIt(); + const password = "AppFlowy123@"; + final uid = uuid(); + final userEmail = "$uid@appflowy.io"; + final result = await authService.signUp( + name: "TestUser", + password: password, + email: userEmail, + ); + result.fold( + (user) { + userProfile = user; + userService = UserBackendService(userId: userProfile.id); + }, + (error) { + assert(false, 'Error: $error'); + }, + ); + } + + WorkspacePB get currentWorkspace => workspace; + Future _loadWorkspace() async { + final result = await UserBackendService.getCurrentWorkspace(); + result.fold( + (value) => workspace = value, + (error) { + throw Exception(error); + }, + ); + } + + Future _initialServices() async { + workspaceService = WorkspaceService(workspaceId: currentWorkspace.id); + } + + Future createWorkspace() async { + final result = await workspaceService.createView( + name: "Test App", + viewSection: ViewSectionPB.Public, + ); + return result.fold( + (app) => app, + (error) => throw Exception(error), + ); + } +} + +void _pathProviderInitialized() { + const MethodChannel channel = + MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return '.'; + }); +} + +class AppFlowyApplicationUnitTest implements EntryPoint { + @override + Widget create(LaunchConfiguration config) { + return const SizedBox.shrink(); + } +} + +Future blocResponseFuture({int millisecond = 200}) { + return Future.delayed(Duration(milliseconds: millisecond)); +} + +Duration blocResponseDuration({int milliseconds = 200}) { + return Duration(milliseconds: milliseconds); +} diff --git a/frontend/appflowy_flutter/test/widget_test/confirm_dialog_test.dart b/frontend/appflowy_flutter/test/widget_test/confirm_dialog_test.dart new file mode 100644 index 0000000000000..4458d588ccd96 --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/confirm_dialog_test.dart @@ -0,0 +1,91 @@ +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../integration_test/shared/util.dart'; +import 'test_material_app.dart'; + +class _ConfirmPopupMock extends Mock { + void confirm(); +} + +void main() { + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + EasyLocalization.logger.enableLevels = []; + await EasyLocalization.ensureInitialized(); + }); + + Widget buildDialog(VoidCallback onConfirm) { + return Builder( + builder: (context) { + return TextButton( + child: const Text(""), + onPressed: () { + showDialog( + context: context, + builder: (_) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: ConfirmPopup( + description: "desc", + title: "title", + onConfirm: onConfirm, + ), + ); + }, + ); + }, + ); + }, + ); + } + + testWidgets('confirm dialog shortcut events', (tester) async { + final callback = _ConfirmPopupMock(); + + // escape + await tester.pumpWidget( + WidgetTestApp( + child: buildDialog(callback.confirm), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + + expect(find.byType(ConfirmPopup), findsOneWidget); + + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + verifyNever(() => callback.confirm()); + + verifyNever(() => callback.confirm()); + expect(find.byType(ConfirmPopup), findsNothing); + + // enter + await tester.pumpWidget( + WidgetTestApp( + child: buildDialog(callback.confirm), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + + expect(find.byType(ConfirmPopup), findsOneWidget); + + await tester.simulateKeyEvent(LogicalKeyboardKey.enter); + verify(() => callback.confirm()).called(1); + + verifyNever(() => callback.confirm()); + expect(find.byType(ConfirmPopup), findsNothing); + }); +} diff --git a/frontend/appflowy_flutter/test/widget_test/date_picker_test.dart b/frontend/appflowy_flutter/test/widget_test/date_picker_test.dart new file mode 100644 index 0000000000000..8e9e916aa71db --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/date_picker_test.dart @@ -0,0 +1,913 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/appflowy_date_picker_base.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_time_text_field.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/end_time_button.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:table_calendar/table_calendar.dart'; +import 'package:time/time.dart'; + +import '../../integration_test/shared/util.dart'; +import 'test_material_app.dart'; + +const _mockDatePickerDelay = Duration(milliseconds: 200); + +class _DatePickerDataStub { + _DatePickerDataStub({ + required this.dateTime, + required this.endDateTime, + required this.includeTime, + required this.isRange, + }); + + _DatePickerDataStub.empty() + : dateTime = null, + endDateTime = null, + includeTime = false, + isRange = false; + + DateTime? dateTime; + DateTime? endDateTime; + bool includeTime; + bool isRange; +} + +class _MockDatePicker extends StatefulWidget { + const _MockDatePicker({ + this.data, + this.dateFormat, + this.timeFormat, + }); + + final _DatePickerDataStub? data; + final DateFormatPB? dateFormat; + final TimeFormatPB? timeFormat; + + @override + State<_MockDatePicker> createState() => _MockDatePickerState(); +} + +class _MockDatePickerState extends State<_MockDatePicker> { + late final _DatePickerDataStub data; + late DateFormatPB dateFormat; + late TimeFormatPB timeFormat; + + @override + void initState() { + super.initState(); + data = widget.data ?? _DatePickerDataStub.empty(); + dateFormat = widget.dateFormat ?? DateFormatPB.Friendly; + timeFormat = widget.timeFormat ?? TimeFormatPB.TwelveHour; + } + + void updateDateFormat(DateFormatPB dateFormat) async { + setState(() { + this.dateFormat = dateFormat; + }); + } + + void updateTimeFormat(TimeFormatPB timeFormat) async { + setState(() { + this.timeFormat = timeFormat; + }); + } + + void updateDateCellData({ + required DateTime? dateTime, + required DateTime? endDateTime, + required bool isRange, + required bool includeTime, + }) { + setState(() { + data.dateTime = dateTime; + data.endDateTime = endDateTime; + data.includeTime = includeTime; + data.isRange = isRange; + }); + } + + @override + Widget build(BuildContext context) { + return DesktopAppFlowyDatePicker( + dateTime: data.dateTime, + endDateTime: data.endDateTime, + includeTime: data.includeTime, + isRange: data.isRange, + dateFormat: dateFormat, + timeFormat: timeFormat, + onDaySelected: (date) async { + await Future.delayed(_mockDatePickerDelay); + setState(() { + data.dateTime = date; + }); + }, + onRangeSelected: (start, end) async { + await Future.delayed(_mockDatePickerDelay); + setState(() { + data.dateTime = start; + data.endDateTime = end; + }); + }, + onIncludeTimeChanged: (value, dateTime, endDateTime) async { + await Future.delayed(_mockDatePickerDelay); + setState(() { + data.includeTime = value; + if (dateTime != null) { + data.dateTime = dateTime; + } + if (endDateTime != null) { + data.endDateTime = endDateTime; + } + }); + }, + onIsRangeChanged: (value, dateTime, endDateTime) async { + await Future.delayed(_mockDatePickerDelay); + setState(() { + data.isRange = value; + if (dateTime != null) { + data.dateTime = dateTime; + } + if (endDateTime != null) { + data.endDateTime = endDateTime; + } + }); + }, + ); + } +} + +void main() { + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + EasyLocalization.logger.enableLevels = []; + await EasyLocalization.ensureInitialized(); + }); + + Finder dayInDatePicker(int day) { + final findCalendar = find.byType(TableCalendar); + final findDay = find.text(day.toString()); + + return find.descendant( + of: findCalendar, + matching: findDay, + ); + } + + DateTime getLastMonth(DateTime date) { + if (date.month == 1) { + return DateTime(date.year - 1, 12); + } else { + return DateTime(date.year, date.month - 1); + } + } + + _MockDatePickerState getMockState(WidgetTester tester) => + tester.state<_MockDatePickerState>(find.byType(_MockDatePicker)); + + AppFlowyDatePickerState getAfState(WidgetTester tester) => + tester.state( + find.byType(DesktopAppFlowyDatePicker), + ); + + group('AppFlowy date picker:', () { + testWidgets('default state', (tester) async { + await tester.pumpWidget( + const WidgetTestApp( + child: _MockDatePicker(), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(DesktopAppFlowyDatePicker), findsOneWidget); + expect( + find.byWidgetPredicate( + (w) => w is DateTimeTextField && w.dateTime == null, + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate((w) => w is DatePicker && w.selectedDay == null), + findsOneWidget, + ); + expect( + find.byWidgetPredicate((w) => w is IncludeTimeButton && !w.includeTime), + findsOneWidget, + ); + expect( + find.byWidgetPredicate((w) => w is EndTimeButton && !w.isRange), + findsOneWidget, + ); + }); + + testWidgets('passed in state', (tester) async { + await tester.pumpWidget( + WidgetTestApp( + child: _MockDatePicker( + data: _DatePickerDataStub( + dateTime: DateTime(2024, 10, 12, 13), + endDateTime: DateTime(2024, 10, 14, 5), + includeTime: true, + isRange: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DesktopAppFlowyDatePicker), findsOneWidget); + expect(find.byType(DateTimeTextField), findsNWidgets(2)); + expect(find.byType(DatePicker), findsOneWidget); + expect( + find.byWidgetPredicate((w) => w is IncludeTimeButton && w.includeTime), + findsOneWidget, + ); + expect( + find.byWidgetPredicate((w) => w is EndTimeButton && w.isRange), + findsOneWidget, + ); + final afState = getAfState(tester); + expect(afState.focusedDateTime, DateTime(2024, 10, 12, 13)); + }); + + testWidgets('date and time formats', (tester) async { + final date = DateTime(2024, 10, 12, 13); + await tester.pumpWidget( + WidgetTestApp( + child: _MockDatePicker( + dateFormat: DateFormatPB.Friendly, + timeFormat: TimeFormatPB.TwelveHour, + data: _DatePickerDataStub( + dateTime: date, + endDateTime: null, + includeTime: true, + isRange: false, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final dateText = find.descendant( + of: find.byKey(const ValueKey('date_time_text_field_date')), + matching: + find.text(DateFormat(DateFormatPB.Friendly.pattern).format(date)), + ); + expect(dateText, findsOneWidget); + + final timeText = find.descendant( + of: find.byKey(const ValueKey('date_time_text_field_time')), + matching: + find.text(DateFormat(TimeFormatPB.TwelveHour.pattern).format(date)), + ); + expect(timeText, findsOneWidget); + + _MockDatePickerState mockState = getMockState(tester); + mockState.updateDateFormat(DateFormatPB.US); + await tester.pumpAndSettle(); + final dateText2 = find.descendant( + of: find.byKey(const ValueKey('date_time_text_field_date')), + matching: find.text(DateFormat(DateFormatPB.US.pattern).format(date)), + ); + expect(dateText2, findsOneWidget); + + mockState = getMockState(tester); + mockState.updateTimeFormat(TimeFormatPB.TwentyFourHour); + await tester.pumpAndSettle(); + final timeText2 = find.descendant( + of: find.byKey(const ValueKey('date_time_text_field_time')), + matching: find + .text(DateFormat(TimeFormatPB.TwentyFourHour.pattern).format(date)), + ); + expect(timeText2, findsOneWidget); + }); + + testWidgets('page turn buttons', (tester) async { + await tester.pumpWidget( + const WidgetTestApp( + child: _MockDatePicker(), + ), + ); + await tester.pumpAndSettle(); + + final now = DateTime.now(); + expect( + find.text(DateFormat.yMMMM().format(now)), + findsOneWidget, + ); + + final lastMonth = getLastMonth(now); + await tester.tap(find.byFlowySvg(FlowySvgs.arrow_left_s)); + await tester.pumpAndSettle(); + expect( + find.text(DateFormat.yMMMM().format(lastMonth)), + findsOneWidget, + ); + + await tester.tap(find.byFlowySvg(FlowySvgs.arrow_right_s)); + await tester.pumpAndSettle(); + expect( + find.text(DateFormat.yMMMM().format(now)), + findsOneWidget, + ); + }); + + testWidgets('select date', (tester) async { + await tester.pumpWidget( + const WidgetTestApp( + child: _MockDatePicker(), + ), + ); + await tester.pumpAndSettle(); + + final now = DateTime.now(); + final third = dayInDatePicker(3).first; + await tester.tap(third); + await tester.pump(); + + DateTime expected = DateTime(now.year, now.month, 3); + + AppFlowyDatePickerState afState = getAfState(tester); + _MockDatePickerState mockState = getMockState(tester); + expect(afState.dateTime, expected); + expect(mockState.data.dateTime, null); + + await tester.pumpAndSettle(); + mockState = getMockState(tester); + expect(mockState.data.dateTime, expected); + + final firstOfNextMonth = dayInDatePicker(1); + + // for certain months, the first of next month isn't shown + if (firstOfNextMonth.allCandidates.length == 2) { + await tester.tap(firstOfNextMonth); + await tester.pumpAndSettle(); + + expected = DateTime(now.year, now.month + 1); + afState = getAfState(tester); + expect(afState.dateTime, expected); + expect(afState.focusedDateTime, expected); + } + }); + + testWidgets('select date range', (tester) async { + await tester.pumpWidget( + WidgetTestApp( + child: _MockDatePicker( + data: _DatePickerDataStub( + dateTime: null, + endDateTime: null, + includeTime: false, + isRange: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + AppFlowyDatePickerState afState = getAfState(tester); + _MockDatePickerState mockState = getMockState(tester); + expect(afState.startDateTime, null); + expect(afState.endDateTime, null); + expect(mockState.data.dateTime, null); + expect(mockState.data.endDateTime, null); + + // 3-10 + final now = DateTime.now(); + final third = dayInDatePicker(3).first; + await tester.tap(third); + await tester.pumpAndSettle(); + + final expectedStart = DateTime(now.year, now.month, 3); + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.startDateTime, expectedStart); + expect(afState.endDateTime, null); + expect(mockState.data.dateTime, null); + expect(mockState.data.endDateTime, null); + + final tenth = dayInDatePicker(10).first; + await tester.tap(tenth); + await tester.pump(); + + final expectedEnd = DateTime(now.year, now.month, 10); + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.startDateTime, expectedStart); + expect(afState.endDateTime, expectedEnd); + expect(mockState.data.dateTime, null); + expect(mockState.data.endDateTime, null); + + await tester.pumpAndSettle(); + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.startDateTime, expectedStart); + expect(afState.endDateTime, expectedEnd); + expect(mockState.data.dateTime, expectedStart); + expect(mockState.data.endDateTime, expectedEnd); + + // 7-18, backwards + final eighteenth = dayInDatePicker(18).first; + await tester.tap(eighteenth); + await tester.pumpAndSettle(); + + final expectedEnd2 = DateTime(now.year, now.month, 18); + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.startDateTime, expectedEnd2); + expect(afState.endDateTime, null); + expect(mockState.data.dateTime, expectedStart); + expect(mockState.data.endDateTime, expectedEnd); + + final seventh = dayInDatePicker(7).first; + await tester.tap(seventh); + await tester.pump(); + + final expectedStart2 = DateTime(now.year, now.month, 7); + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.startDateTime, expectedStart2); + expect(afState.endDateTime, expectedEnd2); + expect(mockState.data.dateTime, expectedStart); + expect(mockState.data.endDateTime, expectedEnd); + + await tester.pumpAndSettle(); + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.startDateTime, expectedStart2); + expect(afState.endDateTime, expectedEnd2); + expect(mockState.data.dateTime, expectedStart2); + expect(mockState.data.endDateTime, expectedEnd2); + }); + + testWidgets('select date range after toggling is range', (tester) async { + final now = DateTime.now(); + final fourteenthDateTime = DateTime(now.year, now.month, 14); + + await tester.pumpWidget( + WidgetTestApp( + child: _MockDatePicker( + data: _DatePickerDataStub( + dateTime: fourteenthDateTime, + endDateTime: null, + includeTime: false, + isRange: false, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + AppFlowyDatePickerState afState = getAfState(tester); + _MockDatePickerState mockState = getMockState(tester); + expect(afState.dateTime, fourteenthDateTime); + expect(afState.startDateTime, null); + expect(afState.endDateTime, null); + expect(afState.justChangedIsRange, false); + + await tester.tap( + find.descendant( + of: find.byType(EndTimeButton), + matching: find.byType(Toggle), + ), + ); + await tester.pump(); + + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.isRange, true); + expect(afState.dateTime, fourteenthDateTime); + expect(afState.startDateTime, fourteenthDateTime); + expect(afState.endDateTime, fourteenthDateTime); + expect(afState.justChangedIsRange, true); + expect(mockState.data.isRange, false); + expect(mockState.data.dateTime, fourteenthDateTime); + expect(mockState.data.endDateTime, null); + + await tester.pumpAndSettle(); + + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.isRange, true); + expect(afState.dateTime, fourteenthDateTime); + expect(afState.startDateTime, fourteenthDateTime); + expect(afState.endDateTime, fourteenthDateTime); + expect(afState.justChangedIsRange, true); + expect(mockState.data.isRange, true); + expect(mockState.data.dateTime, fourteenthDateTime); + expect(mockState.data.endDateTime, fourteenthDateTime); + + final twentyFirst = dayInDatePicker(21).first; + await tester.tap(twentyFirst); + await tester.pumpAndSettle(); + + final expected = DateTime(now.year, now.month, 21); + + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.dateTime, fourteenthDateTime); + expect(afState.startDateTime, fourteenthDateTime); + expect(afState.endDateTime, expected); + expect(afState.justChangedIsRange, false); + expect(mockState.data.dateTime, fourteenthDateTime); + expect(mockState.data.endDateTime, expected); + expect(mockState.data.isRange, true); + }); + + testWidgets('include time and modify', (tester) async { + final now = DateTime.now(); + final fourteenthDateTime = now.copyWith(day: 14); + + await tester.pumpWidget( + WidgetTestApp( + child: _MockDatePicker( + data: _DatePickerDataStub( + dateTime: DateTime( + fourteenthDateTime.year, + fourteenthDateTime.month, + fourteenthDateTime.day, + ), + endDateTime: null, + includeTime: false, + isRange: false, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + AppFlowyDatePickerState afState = getAfState(tester); + _MockDatePickerState mockState = getMockState(tester); + expect(afState.dateTime!.isAtSameDayAs(fourteenthDateTime), true); + expect(afState.dateTime!.isAtSameMinuteAs(fourteenthDateTime), false); + expect(afState.startDateTime, null); + expect(afState.endDateTime, null); + expect(afState.includeTime, false); + + await tester.tap( + find.descendant( + of: find.byType(IncludeTimeButton), + matching: find.byType(Toggle), + ), + ); + await tester.pump(); + + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.dateTime!.isAtSameMinuteAs(fourteenthDateTime), true); + expect(afState.includeTime, true); + expect( + mockState.data.dateTime!.isAtSameDayAs(fourteenthDateTime), + true, + ); + expect( + mockState.data.dateTime!.isAtSameMinuteAs(fourteenthDateTime), + false, + ); + expect(mockState.data.includeTime, false); + + await tester.pumpAndSettle(300.milliseconds); + mockState = getMockState(tester); + expect( + mockState.data.dateTime!.isAtSameMinuteAs(fourteenthDateTime), + true, + ); + expect(mockState.data.includeTime, true); + + final timeField = find.byKey(const ValueKey('date_time_text_field_time')); + await tester.enterText(timeField, "1"); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(300.milliseconds); + + DateTime expected = DateTime( + fourteenthDateTime.year, + fourteenthDateTime.month, + fourteenthDateTime.day, + 1, + ); + + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.dateTime, expected); + expect(mockState.data.dateTime, expected); + + final dateText = find.descendant( + of: find.byKey(const ValueKey('date_time_text_field_date')), + matching: find + .text(DateFormat(DateFormatPB.Friendly.pattern).format(expected)), + ); + expect(dateText, findsOneWidget); + final timeText = find.descendant( + of: find.byKey(const ValueKey('date_time_text_field_time')), + matching: find + .text(DateFormat(TimeFormatPB.TwelveHour.pattern).format(expected)), + ); + expect(timeText, findsOneWidget); + + final third = dayInDatePicker(3).first; + await tester.tap(third); + await tester.pumpAndSettle(); + + expected = DateTime( + fourteenthDateTime.year, + fourteenthDateTime.month, + 3, + 1, + ); + + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.dateTime, expected); + expect(mockState.data.dateTime, expected); + }); + + testWidgets( + 'turn on include time, turn on end date, then select date range', + (tester) async { + final fourteenth = DateTime(2024, 10, 14); + + await tester.pumpWidget( + WidgetTestApp( + child: _MockDatePicker( + data: _DatePickerDataStub( + dateTime: fourteenth, + endDateTime: null, + includeTime: false, + isRange: false, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.descendant( + of: find.byType(EndTimeButton), + matching: find.byType(Toggle), + ), + ); + await tester.pumpAndSettle(); + + final now = DateTime.now(); + await tester.tap( + find.descendant( + of: find.byType(IncludeTimeButton), + matching: find.byType(Toggle), + ), + ); + await tester.pumpAndSettle(); + + final third = dayInDatePicker(21).first; + await tester.tap(third); + await tester.pumpAndSettle(); + + final afState = getAfState(tester); + final mockState = getMockState(tester); + final expectedTime = Duration(hours: now.hour, minutes: now.minute); + final expectedStart = fourteenth.add(expectedTime); + final expectedEnd = fourteenth.copyWith(day: 21).add(expectedTime); + expect(afState.justChangedIsRange, false); + expect(afState.includeTime, true); + expect(afState.isRange, true); + expect(afState.dateTime, expectedStart); + expect(afState.startDateTime, expectedStart); + expect(afState.endDateTime, expectedEnd); + expect(mockState.data.dateTime, expectedStart); + expect(mockState.data.endDateTime, expectedEnd); + expect(mockState.data.isRange, true); + }, + ); + + testWidgets('edit text field causes start and end to get swapped', + (tester) async { + final fourteenth = DateTime(2024, 10, 14, 1); + + await tester.pumpWidget( + WidgetTestApp( + child: _MockDatePicker( + data: _DatePickerDataStub( + dateTime: fourteenth, + endDateTime: fourteenth, + includeTime: true, + isRange: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.text( + DateFormat(DateFormatPB.Friendly.pattern).format(fourteenth), + ), + findsNWidgets(2), + ); + + final dateTextField = find.descendant( + of: find.byKey(const ValueKey('date_time_text_field')), + matching: find.byKey(const ValueKey('date_time_text_field_date')), + ); + expect(dateTextField, findsOneWidget); + await tester.enterText(dateTextField, "Nov 30, 2024"); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + final bday = DateTime(2024, 11, 30, 1); + + expect( + find.descendant( + of: find.byKey(const ValueKey('date_time_text_field')), + matching: find.text( + DateFormat(DateFormatPB.Friendly.pattern).format(fourteenth), + ), + ), + findsOneWidget, + ); + + expect( + find.descendant( + of: find.byKey(const ValueKey('end_date_time_text_field')), + matching: find.text( + DateFormat(DateFormatPB.Friendly.pattern).format(bday), + ), + ), + findsOneWidget, + ); + + final mockState = getMockState(tester); + expect(mockState.data.dateTime, fourteenth); + expect(mockState.data.endDateTime, bday); + }); + + testWidgets( + 'select start date with calendar and then enter end date with keyboard', + (tester) async { + final fourteenth = DateTime(2024, 10, 14, 1); + + await tester.pumpWidget( + WidgetTestApp( + child: _MockDatePicker( + data: _DatePickerDataStub( + dateTime: fourteenth, + endDateTime: fourteenth, + includeTime: true, + isRange: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final third = dayInDatePicker(3).first; + await tester.tap(third); + await tester.pumpAndSettle(); + + final start = DateTime(2024, 10, 3, 1); + + AppFlowyDatePickerState afState = getAfState(tester); + _MockDatePickerState mockState = getMockState(tester); + expect(afState.dateTime, start); + expect(afState.startDateTime, start); + expect(afState.endDateTime, null); + expect(mockState.data.dateTime, fourteenth); + expect(mockState.data.endDateTime, fourteenth); + expect(mockState.data.isRange, true); + + final dateTextField = find.descendant( + of: find.byKey(const ValueKey('end_date_time_text_field')), + matching: find.byKey(const ValueKey('date_time_text_field_date')), + ); + expect(dateTextField, findsOneWidget); + await tester.enterText(dateTextField, "Oct 18, 2024"); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + final end = DateTime(2024, 10, 18, 1); + + expect( + find.descendant( + of: find.byKey(const ValueKey('date_time_text_field')), + matching: find.text( + DateFormat(DateFormatPB.Friendly.pattern).format(start), + ), + ), + findsOneWidget, + ); + + expect( + find.descendant( + of: find.byKey(const ValueKey('end_date_time_text_field')), + matching: find.text( + DateFormat(DateFormatPB.Friendly.pattern).format(end), + ), + ), + findsOneWidget, + ); + + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.dateTime, start); + expect(afState.startDateTime, start); + expect(afState.endDateTime, end); + expect(mockState.data.dateTime, start); + expect(mockState.data.endDateTime, end); + + // make sure click counter was reset + final twentyFifth = dayInDatePicker(25).first; + final expected = DateTime(2024, 10, 25, 1); + await tester.tap(twentyFifth); + await tester.pumpAndSettle(); + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.dateTime, expected); + expect(afState.startDateTime, expected); + expect(afState.endDateTime, null); + expect(mockState.data.dateTime, start); + expect(mockState.data.endDateTime, end); + }); + + testWidgets('same as above but enter time', (tester) async { + final fourteenth = DateTime(2024, 10, 14, 1); + + await tester.pumpWidget( + WidgetTestApp( + child: _MockDatePicker( + data: _DatePickerDataStub( + dateTime: fourteenth, + endDateTime: fourteenth, + includeTime: true, + isRange: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final third = dayInDatePicker(3).first; + await tester.tap(third); + await tester.pumpAndSettle(); + + final start = DateTime(2024, 10, 3, 1); + + final dateTextField = find.descendant( + of: find.byKey(const ValueKey('end_date_time_text_field')), + matching: find.byKey(const ValueKey('date_time_text_field_time')), + ); + expect(dateTextField, findsOneWidget); + await tester.enterText(dateTextField, "15:00"); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byKey(const ValueKey('date_time_text_field')), + matching: find.text( + DateFormat(DateFormatPB.Friendly.pattern).format(start), + ), + ), + findsOneWidget, + ); + + expect( + find.descendant( + of: find.byKey(const ValueKey('end_date_time_text_field')), + matching: find.text("15:00"), + ), + findsNothing, + ); + + AppFlowyDatePickerState afState = getAfState(tester); + _MockDatePickerState mockState = getMockState(tester); + expect(afState.dateTime, start); + expect(afState.startDateTime, start); + expect(afState.endDateTime, null); + expect(mockState.data.dateTime, fourteenth); + expect(mockState.data.endDateTime, fourteenth); + + // select for real now + final twentyFifth = dayInDatePicker(25).first; + final expected = DateTime(2024, 10, 25, 1); + await tester.tap(twentyFifth); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + afState = getAfState(tester); + mockState = getMockState(tester); + expect(afState.dateTime, start); + expect(afState.startDateTime, start); + expect(afState.endDateTime, expected); + expect(mockState.data.dateTime, start); + expect(mockState.data.endDateTime, expected); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/widget_test/direction_setting_test.dart b/frontend/appflowy_flutter/test/widget_test/direction_setting_test.dart new file mode 100644 index 0000000000000..d83706f06855d --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/direction_setting_test.dart @@ -0,0 +1,158 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/user/application/user_settings_service.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_radio_select.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../util.dart'; + +class MockAppearanceSettingsBloc + extends MockBloc + implements AppearanceSettingsCubit {} + +class MockDocumentAppearanceCubit extends Mock + implements DocumentAppearanceCubit {} + +class MockDocumentAppearance extends Mock implements DocumentAppearance {} + +void main() { + late AppearanceSettingsPB appearanceSettings; + late DateTimeSettingsPB dateTimeSettings; + + setUp(() async { + await AppFlowyUnitTest.ensureInitialized(); + appearanceSettings = + await UserSettingsBackendService().getAppearanceSetting(); + dateTimeSettings = await UserSettingsBackendService().getDateTimeSettings(); + }); + + testWidgets('TextDirectionSelect update default text direction setting', + (WidgetTester tester) async { + final appearanceSettingsState = AppearanceSettingsState.initial( + AppTheme.fallback, + appearanceSettings.themeMode, + appearanceSettings.font, + appearanceSettings.layoutDirection, + appearanceSettings.textDirection, + appearanceSettings.enableRtlToolbarItems, + appearanceSettings.locale, + appearanceSettings.isMenuCollapsed, + appearanceSettings.menuOffset, + dateTimeSettings.dateFormat, + dateTimeSettings.timeFormat, + dateTimeSettings.timezoneId, + appearanceSettings.documentSetting.cursorColor.isEmpty + ? null + : Color( + int.parse(appearanceSettings.documentSetting.cursorColor), + ), + appearanceSettings.documentSetting.selectionColor.isEmpty + ? null + : Color( + int.parse( + appearanceSettings.documentSetting.selectionColor, + ), + ), + 1.0, + ); + final mockAppearanceSettingsBloc = MockAppearanceSettingsBloc(); + when(() => mockAppearanceSettingsBloc.state).thenReturn( + appearanceSettingsState, + ); + + final mockDocumentAppearanceCubit = MockDocumentAppearanceCubit(); + when(() => mockDocumentAppearanceCubit.stream).thenAnswer( + (_) => Stream.fromIterable([MockDocumentAppearance()]), + ); + + await tester.pumpWidget( + MultiBlocProvider( + providers: [ + BlocProvider.value( + value: mockAppearanceSettingsBloc, + ), + BlocProvider.value( + value: mockDocumentAppearanceCubit, + ), + ], + child: MaterialApp( + theme: appearanceSettingsState.lightTheme, + home: MultiBlocProvider( + providers: [ + BlocProvider.value( + value: mockAppearanceSettingsBloc, + ), + BlocProvider.value( + value: mockDocumentAppearanceCubit, + ), + ], + child: const Scaffold( + body: TextDirectionSelect(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.text( + LocaleKeys.settings_workspacePage_textDirection_leftToRight.tr(), + ), + findsOne, + ); + expect( + find.text( + LocaleKeys.settings_workspacePage_textDirection_rightToLeft.tr(), + ), + findsOne, + ); + expect( + find.text( + LocaleKeys.settings_workspacePage_textDirection_auto.tr(), + ), + findsOne, + ); + + final radioSelectFinder = + find.byType(SettingsRadioSelect); + expect(radioSelectFinder, findsOne); + + when( + () => mockAppearanceSettingsBloc.setTextDirection( + any(), + ), + ).thenAnswer((_) async => {}); + when( + () => mockDocumentAppearanceCubit.syncDefaultTextDirection( + any(), + ), + ).thenAnswer((_) async {}); + + final radioSelect = tester.widget(radioSelectFinder) + as SettingsRadioSelect; + final rtlSelect = radioSelect.items + .firstWhere((select) => select.value == AppFlowyTextDirection.rtl); + radioSelect.onChanged(rtlSelect); + + verify( + () => mockAppearanceSettingsBloc.setTextDirection( + any(), + ), + ).called(1); + verify( + () => mockDocumentAppearanceCubit.syncDefaultTextDirection( + any(), + ), + ).called(1); + }); +} diff --git a/frontend/appflowy_flutter/test/widget_test/select_option_text_field_test.dart b/frontend/appflowy_flutter/test/widget_test/select_option_text_field_test.dart new file mode 100644 index 0000000000000..b1e2e4ccea6a2 --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/select_option_text_field_test.dart @@ -0,0 +1,69 @@ +import 'dart:collection'; + +import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_text_field.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../bloc_test/grid_test/util.dart'; + +void main() { + setUpAll(() { + AppFlowyGridTest.ensureInitialized(); + }); + + group('text_field.dart', () { + String submit = ''; + String remainder = ''; + List select = []; + + final textController = TextEditingController(); + + final textField = SelectOptionTextField( + options: const [], + selectedOptionMap: LinkedHashMap(), + distanceToText: 0.0, + onSubmitted: () => submit = textController.text, + onPaste: (options, remaining) { + remainder = remaining; + select = options; + }, + onRemove: (_) {}, + newText: (text) => remainder = text, + textSeparators: const [','], + textController: textController, + focusNode: FocusNode(), + ); + + testWidgets('SelectOptionTextField callback outputs', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: textField, + ), + ), + ); + + // test that the input field exists + expect(find.byType(TextField), findsOneWidget); + + // simulate normal input + await tester.enterText(find.byType(TextField), 'abcd'); + expect(remainder, 'abcd'); + + await tester.enterText(find.byType(TextField), ' '); + expect(remainder, ''); + + // test submit functionality (aka pressing enter) + await tester.enterText(find.byType(TextField), 'an option'); + await tester.testTextInput.receiveAction(TextInputAction.done); + expect(submit, 'an option'); + + // test inputs containing commas + await tester.enterText(find.byType(TextField), 'a a, bbbb , c'); + expect(remainder, 'c'); + expect(select, ['a a', 'bbbb']); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/widget_test/spae_cion_test.dart b/frontend/appflowy_flutter/test/widget_test/spae_cion_test.dart new file mode 100644 index 0000000000000..491afdbf77ade --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/spae_cion_test.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; + +import 'package:appflowy/workspace/application/view/view_ext.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('space_icon.dart', () { + testWidgets('space icon is empty', (WidgetTester tester) async { + final emptySpaceIcon = { + ViewExtKeys.spaceIconKey: '', + ViewExtKeys.spaceIconColorKey: '', + }; + final space = ViewPB( + name: 'test', + extra: jsonEncode(emptySpaceIcon), + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SpaceIcon(dimension: 22, space: space), + ), + ), + ); + + // test that the input field exists + expect(find.byType(SpaceIcon), findsOneWidget); + + // use the first character of page name as icon + expect(find.text('T'), findsOneWidget); + }); + + testWidgets('space icon is null', (WidgetTester tester) async { + final emptySpaceIcon = { + ViewExtKeys.spaceIconKey: null, + ViewExtKeys.spaceIconColorKey: null, + }; + final space = ViewPB( + name: 'test', + extra: jsonEncode(emptySpaceIcon), + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SpaceIcon(dimension: 22, space: space), + ), + ), + ); + + expect(find.byType(SpaceIcon), findsOneWidget); + + // use the first character of page name as icon + expect(find.text('T'), findsOneWidget); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/widget_test/test_asset_bundle.dart b/frontend/appflowy_flutter/test/widget_test/test_asset_bundle.dart new file mode 100644 index 0000000000000..ddcabff61f163 --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/test_asset_bundle.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// TestAssetBundle is required in order to avoid issues with large assets +/// +/// ref: https://medium.com/@sardox/flutter-test-and-randomly-missing-assets-in-goldens-ea959cdd336a +/// +/// "If your AssetManifest.json file exceeds 10kb, it will be +/// loaded with isolate that (most likely) will cause your +/// test to finish before assets are loaded so goldens will +/// get empty assets." +/// +class TestAssetBundle extends CachingAssetBundle { + @override + Future loadString(String key, {bool cache = true}) async { + // overriding this method to avoid limit of 10KB per asset + try { + final data = await load(key); + return utf8.decode(data.buffer.asUint8List()); + } catch (err) { + throw FlutterError('Unable to load asset: $key'); + } + } + + @override + Future load(String key) async => rootBundle.load(key); +} + +final testAssetBundle = TestAssetBundle(); + +/// Loads from our custom asset bundle +class TestBundleAssetLoader extends AssetLoader { + const TestBundleAssetLoader(); + + String getLocalePath(String basePath, Locale locale) { + return '$basePath/${locale.toStringWithSeparator(separator: "-")}.json'; + } + + @override + Future> load(String path, Locale locale) async { + final localePath = getLocalePath(path, locale); + return json.decode(await testAssetBundle.loadString(localePath)); + } +} diff --git a/frontend/appflowy_flutter/test/widget_test/test_material_app.dart b/frontend/appflowy_flutter/test/widget_test/test_material_app.dart new file mode 100644 index 0000000000000..ecb06b97e2f4f --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/test_material_app.dart @@ -0,0 +1,75 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; + +import 'test_asset_bundle.dart'; + +class WidgetTestApp extends StatelessWidget { + const WidgetTestApp({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context) { + return EasyLocalization( + supportedLocales: const [Locale('en')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + useFallbackTranslations: true, + saveLocale: false, + assetLoader: const TestBundleAssetLoader(), + child: Builder( + builder: (context) => MaterialApp( + supportedLocales: const [Locale('en')], + locale: const Locale('en'), + localizationsDelegates: context.localizationDelegates, + theme: ThemeData.light().copyWith( + extensions: const [ + AFThemeExtension( + warning: Colors.transparent, + success: Colors.transparent, + tint1: Colors.transparent, + tint2: Colors.transparent, + tint3: Colors.transparent, + tint4: Colors.transparent, + tint5: Colors.transparent, + tint6: Colors.transparent, + tint7: Colors.transparent, + tint8: Colors.transparent, + tint9: Colors.transparent, + textColor: Colors.transparent, + secondaryTextColor: Colors.transparent, + strongText: Colors.transparent, + greyHover: Colors.transparent, + greySelect: Colors.transparent, + lightGreyHover: Colors.transparent, + toggleOffFill: Colors.transparent, + progressBarBGColor: Colors.transparent, + toggleButtonBGColor: Colors.transparent, + calendarWeekendBGColor: Colors.transparent, + gridRowCountColor: Colors.transparent, + code: TextStyle(), + callout: TextStyle(), + calloutBGColor: Colors.transparent, + tableCellBGColor: Colors.transparent, + caption: TextStyle(), + onBackground: Colors.transparent, + background: Colors.transparent, + borderColor: Colors.transparent, + scrollbarColor: Colors.transparent, + scrollbarHoverColor: Colors.transparent, + lightIconColor: Colors.transparent, + ), + ], + ), + home: Scaffold( + body: child, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart b/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart new file mode 100644 index 0000000000000..19c36a8b59aae --- /dev/null +++ b/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAppearanceSettingsCubit extends Mock + implements AppearanceSettingsCubit {} + +class MockDocumentAppearanceCubit extends Mock + implements DocumentAppearanceCubit {} + +class MockAppearanceSettingsState extends Mock + implements AppearanceSettingsState {} + +class MockDocumentAppearance extends Mock implements DocumentAppearance {} + +void main() { + late MockAppearanceSettingsCubit appearanceSettingsCubit; + late MockDocumentAppearanceCubit documentAppearanceCubit; + + setUp(() { + appearanceSettingsCubit = MockAppearanceSettingsCubit(); + when(() => appearanceSettingsCubit.stream).thenAnswer( + (_) => Stream.fromIterable([MockAppearanceSettingsState()]), + ); + documentAppearanceCubit = MockDocumentAppearanceCubit(); + when(() => documentAppearanceCubit.stream).thenAnswer( + (_) => Stream.fromIterable([MockDocumentAppearance()]), + ); + }); + + testWidgets('ThemeFontFamilySetting updates font family on selection', + (WidgetTester tester) async { + await tester.pumpWidget( + MultiBlocProvider( + providers: [ + BlocProvider.value( + value: appearanceSettingsCubit, + ), + BlocProvider.value( + value: documentAppearanceCubit, + ), + ], + child: MaterialApp( + home: MultiBlocProvider( + providers: [ + BlocProvider.value( + value: appearanceSettingsCubit, + ), + BlocProvider.value( + value: documentAppearanceCubit, + ), + ], + child: const Scaffold( + body: ThemeFontFamilySetting( + currentFontFamily: defaultFontFamily, + ), + ), + ), + ), + ), + ); + + final popover = find.byType(AppFlowyPopover); + await tester.tap(popover); + await tester.pumpAndSettle(); + + // Verify the initial font family + expect( + find.text(LocaleKeys.settings_appearance_fontFamily_defaultFont.tr()), + findsAtLeastNWidgets(1), + ); + when(() => appearanceSettingsCubit.setFontFamily(any())) + .thenAnswer((_) async {}); + verifyNever(() => appearanceSettingsCubit.setFontFamily(any())); + when(() => documentAppearanceCubit.syncFontFamily(any())) + .thenAnswer((_) async {}); + verifyNever(() => documentAppearanceCubit.syncFontFamily(any())); + + // Tap on a different font family + final abel = find.textContaining('Abel'); + await tester.tap(abel); + await tester.pumpAndSettle(); + + // Verify that the font family is updated + verify(() => appearanceSettingsCubit.setFontFamily(any())) + .called(1); + verify(() => documentAppearanceCubit.syncFontFamily(any())) + .called(1); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_popover/example/web/favicon.png b/frontend/appflowy_flutter/web/favicon.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/web/favicon.png rename to frontend/appflowy_flutter/web/favicon.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/web/icons/Icon-192.png b/frontend/appflowy_flutter/web/icons/Icon-192.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/web/icons/Icon-192.png rename to frontend/appflowy_flutter/web/icons/Icon-192.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/web/icons/Icon-512.png b/frontend/appflowy_flutter/web/icons/Icon-512.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/web/icons/Icon-512.png rename to frontend/appflowy_flutter/web/icons/Icon-512.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/web/icons/Icon-maskable-192.png b/frontend/appflowy_flutter/web/icons/Icon-maskable-192.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/web/icons/Icon-maskable-192.png rename to frontend/appflowy_flutter/web/icons/Icon-maskable-192.png diff --git a/frontend/app_flowy/packages/appflowy_popover/example/web/icons/Icon-maskable-512.png b/frontend/appflowy_flutter/web/icons/Icon-maskable-512.png similarity index 100% rename from frontend/app_flowy/packages/appflowy_popover/example/web/icons/Icon-maskable-512.png rename to frontend/appflowy_flutter/web/icons/Icon-maskable-512.png diff --git a/frontend/appflowy_flutter/web/index.html b/frontend/appflowy_flutter/web/index.html new file mode 100644 index 0000000000000..5ce9ee11a8030 --- /dev/null +++ b/frontend/appflowy_flutter/web/index.html @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + appflowy_flutter + + + + + + + diff --git a/frontend/appflowy_flutter/web/manifest.json b/frontend/appflowy_flutter/web/manifest.json new file mode 100644 index 0000000000000..d3a5afc3dff50 --- /dev/null +++ b/frontend/appflowy_flutter/web/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "appflowy_flutter", + "short_name": "appflowy_flutter", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/frontend/app_flowy/windows/.gitignore b/frontend/appflowy_flutter/windows/.gitignore similarity index 100% rename from frontend/app_flowy/windows/.gitignore rename to frontend/appflowy_flutter/windows/.gitignore diff --git a/frontend/appflowy_flutter/windows/CMakeLists.txt b/frontend/appflowy_flutter/windows/CMakeLists.txt new file mode 100644 index 0000000000000..5be6e64915809 --- /dev/null +++ b/frontend/appflowy_flutter/windows/CMakeLists.txt @@ -0,0 +1,101 @@ +cmake_minimum_required(VERSION 3.14) +project(appflowy_flutter LANGUAGES CXX) + +set(BINARY_NAME "AppFlowy") + +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Configure build options. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) + +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() + +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") + +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) + +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(DART_FFI_DIR "${CMAKE_INSTALL_PREFIX}") +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${DART_FFI_DLL}" DESTINATION "${DART_FFI_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/frontend/appflowy_flutter/windows/flutter/CMakeLists.txt b/frontend/appflowy_flutter/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000000000..c1a3afe6394e5 --- /dev/null +++ b/frontend/appflowy_flutter/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(DART_FFI_DLL "${CMAKE_CURRENT_SOURCE_DIR}/dart_ffi/dart_ffi.dll" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/frontend/appflowy_flutter/windows/runner/CMakeLists.txt b/frontend/appflowy_flutter/windows/runner/CMakeLists.txt new file mode 100644 index 0000000000000..17411a8ab8eb7 --- /dev/null +++ b/frontend/appflowy_flutter/windows/runner/CMakeLists.txt @@ -0,0 +1,39 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/frontend/appflowy_flutter/windows/runner/Runner.rc b/frontend/appflowy_flutter/windows/runner/Runner.rc new file mode 100644 index 0000000000000..77795cde10abb --- /dev/null +++ b/frontend/appflowy_flutter/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "io.appflowy" "\0" + VALUE "FileDescription", "AppFlowy" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "AppFlowy" "\0" + VALUE "LegalCopyright", "Copyright (C) 2024 io.appflowy. All rights reserved." "\0" + VALUE "OriginalFilename", "AppFlowy.exe" "\0" + VALUE "ProductName", "AppFlowy" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/frontend/appflowy_flutter/windows/runner/flutter_window.cpp b/frontend/appflowy_flutter/windows/runner/flutter_window.cpp new file mode 100644 index 0000000000000..955ee3038f988 --- /dev/null +++ b/frontend/appflowy_flutter/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/flutter_window.h b/frontend/appflowy_flutter/windows/runner/flutter_window.h similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/flutter_window.h rename to frontend/appflowy_flutter/windows/runner/flutter_window.h diff --git a/frontend/appflowy_flutter/windows/runner/main.cpp b/frontend/appflowy_flutter/windows/runner/main.cpp new file mode 100644 index 0000000000000..b1fff72b84efe --- /dev/null +++ b/frontend/appflowy_flutter/windows/runner/main.cpp @@ -0,0 +1,67 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +#include +auto bdw = bitsdojo_window_configure(BDW_CUSTOM_FRAME | BDW_HIDE_ON_STARTUP); + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + HANDLE hMutexInstance = CreateMutex(NULL, TRUE, L"AppFlowyMutex"); + HWND handle = FindWindowA(NULL, "AppFlowy"); + + if (GetLastError() == ERROR_ALREADY_EXISTS) { + flutter::DartProject project(L"data"); + std::vector command_line_arguments = GetCommandLineArguments(); + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + FlutterWindow window(project); + if (window.SendAppLinkToInstance(L"AppFlowy")) { + return false; + } + + WINDOWPLACEMENT place = {sizeof(WINDOWPLACEMENT)}; + GetWindowPlacement(handle, &place); + ShowWindow(handle, SW_NORMAL); + return 0; + } + + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + + if (!window.Create(L"AppFlowy", origin, size)) { + return EXIT_FAILURE; + } + + window.Show(); + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + ReleaseMutex(hMutexInstance); + return EXIT_SUCCESS; +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/resource.h b/frontend/appflowy_flutter/windows/runner/resource.h similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/resource.h rename to frontend/appflowy_flutter/windows/runner/resource.h diff --git a/frontend/app_flowy/windows/runner/resources/app_icon.ico b/frontend/appflowy_flutter/windows/runner/resources/app_icon.ico similarity index 100% rename from frontend/app_flowy/windows/runner/resources/app_icon.ico rename to frontend/appflowy_flutter/windows/runner/resources/app_icon.ico diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/runner.exe.manifest b/frontend/appflowy_flutter/windows/runner/runner.exe.manifest similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/runner.exe.manifest rename to frontend/appflowy_flutter/windows/runner/runner.exe.manifest diff --git a/frontend/app_flowy/windows/runner/utils.cpp b/frontend/appflowy_flutter/windows/runner/utils.cpp similarity index 100% rename from frontend/app_flowy/windows/runner/utils.cpp rename to frontend/appflowy_flutter/windows/runner/utils.cpp diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/utils.h b/frontend/appflowy_flutter/windows/runner/utils.h similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/example/windows/runner/utils.h rename to frontend/appflowy_flutter/windows/runner/utils.h diff --git a/frontend/appflowy_flutter/windows/runner/win32_window.cpp b/frontend/appflowy_flutter/windows/runner/win32_window.cpp new file mode 100644 index 0000000000000..2f78196d35324 --- /dev/null +++ b/frontend/appflowy_flutter/windows/runner/win32_window.cpp @@ -0,0 +1,332 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +#include "app_links/app_links_plugin_c_api.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + if (SendAppLinkToInstance(title)) + { + return false; + } + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} + +bool Win32Window::SendAppLinkToInstance(const std::wstring &title) +{ + // Find our exact window + HWND hwnd = ::FindWindow(kWindowClassName, title.c_str()); + + if (hwnd) + { + // Dispatch new link to current window + SendAppLink(hwnd); + + // (Optional) Restore our window to front in same state + WINDOWPLACEMENT place = {sizeof(WINDOWPLACEMENT)}; + GetWindowPlacement(hwnd, &place); + + switch (place.showCmd) + { + case SW_SHOWMAXIMIZED: + ShowWindow(hwnd, SW_SHOWMAXIMIZED); + break; + case SW_SHOWMINIMIZED: + ShowWindow(hwnd, SW_RESTORE); + break; + default: + ShowWindow(hwnd, SW_NORMAL); + break; + } + + SetWindowPos(0, HWND_TOP, 0, 0, 0, 0, SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE); + SetForegroundWindow(hwnd); + + // Window has been found, don't create another one. + return true; + } + + return false; +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/windows/runner/win32_window.h b/frontend/appflowy_flutter/windows/runner/win32_window.h new file mode 100644 index 0000000000000..fae0d8a74153c --- /dev/null +++ b/frontend/appflowy_flutter/windows/runner/win32_window.h @@ -0,0 +1,106 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + // Dispatches link if any. + // This method enables our app to be with a single instance too. + bool SendAppLinkToInstance(const std::wstring &title); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/frontend/appflowy_tauri/.eslintignore b/frontend/appflowy_tauri/.eslintignore new file mode 100644 index 0000000000000..e0ff6748349c2 --- /dev/null +++ b/frontend/appflowy_tauri/.eslintignore @@ -0,0 +1,7 @@ +src/services +src/styles +node_modules/ +dist/ +src-tauri/ +.eslintrc.cjs +tsconfig.json \ No newline at end of file diff --git a/frontend/appflowy_tauri/.eslintrc.cjs b/frontend/appflowy_tauri/.eslintrc.cjs new file mode 100644 index 0000000000000..a1160f0bd3f56 --- /dev/null +++ b/frontend/appflowy_tauri/.eslintrc.cjs @@ -0,0 +1,73 @@ +module.exports = { + // https://eslint.org/docs/latest/use/configure/configuration-files + env: { + browser: true, + es6: true, + node: true, + }, + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + sourceType: 'module', + tsconfigRootDir: __dirname, + extraFileExtensions: ['.json'], + }, + plugins: ['@typescript-eslint', "react-hooks"], + rules: { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", + '@typescript-eslint/adjacent-overload-signatures': 'error', + '@typescript-eslint/no-empty-function': 'error', + '@typescript-eslint/no-empty-interface': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-namespace': 'error', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/no-redeclare': 'error', + '@typescript-eslint/prefer-for-of': 'error', + '@typescript-eslint/triple-slash-reference': 'error', + '@typescript-eslint/unified-signatures': 'error', + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': 'off', + 'constructor-super': 'error', + eqeqeq: ['error', 'always'], + 'no-cond-assign': 'error', + 'no-duplicate-case': 'error', + 'no-duplicate-imports': 'error', + 'no-empty': [ + 'error', + { + allowEmptyCatch: true, + }, + ], + 'no-invalid-this': 'error', + 'no-new-wrappers': 'error', + 'no-param-reassign': 'error', + 'no-sequences': 'error', + 'no-throw-literal': 'error', + 'no-unsafe-finally': 'error', + 'no-unused-labels': 'error', + 'no-var': 'error', + 'no-void': 'off', + 'prefer-const': 'error', + 'prefer-spread': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + } + ], + 'padding-line-between-statements': [ + "error", + { blankLine: "always", prev: ["const", "let", "var"], next: "*"}, + { blankLine: "any", prev: ["const", "let", "var"], next: ["const", "let", "var"]}, + { blankLine: "always", prev: "import", next: "*" }, + { blankLine: "any", prev: "import", next: "import" }, + { blankLine: "always", prev: "block-like", next: "*" }, + { blankLine: "always", prev: "block", next: "*" }, + + ] + }, + ignorePatterns: ['src/**/*.test.ts', '**/__tests__/**/*.json', 'package.json'] +}; diff --git a/frontend/appflowy_tauri/.gitignore b/frontend/appflowy_tauri/.gitignore new file mode 100644 index 0000000000000..32a3d59bc297a --- /dev/null +++ b/frontend/appflowy_tauri/.gitignore @@ -0,0 +1,33 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +**/src/services/backend/models/ +**/src/services/backend/events/ +**/src/appflowy_app/i18n/translations/ + +coverage +**/AppFlowy-Collab + +.env \ No newline at end of file diff --git a/frontend/appflowy_tauri/.prettierignore b/frontend/appflowy_tauri/.prettierignore new file mode 100644 index 0000000000000..d515c1c2f21ae --- /dev/null +++ b/frontend/appflowy_tauri/.prettierignore @@ -0,0 +1,19 @@ +.DS_Store +node_modules +/build +/public +/.svelte-kit +/package +/.vscode +.env +.env.* +!.env.example + +# rust and generated ts code +/src-tauri +/src/services + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/frontend/appflowy_tauri/.prettierrc.cjs b/frontend/appflowy_tauri/.prettierrc.cjs new file mode 100644 index 0000000000000..f283db53a2db5 --- /dev/null +++ b/frontend/appflowy_tauri/.prettierrc.cjs @@ -0,0 +1,20 @@ +module.exports = { + arrowParens: 'always', + bracketSpacing: true, + endOfLine: 'lf', + htmlWhitespaceSensitivity: 'css', + insertPragma: false, + jsxBracketSameLine: false, + jsxSingleQuote: true, + printWidth: 121, + plugins: [require('prettier-plugin-tailwindcss')], + proseWrap: 'preserve', + quoteProps: 'as-needed', + requirePragma: false, + semi: true, + singleQuote: true, + tabWidth: 2, + trailingComma: 'es5', + useTabs: false, + vueIndentScriptAndStyle: false, +}; diff --git a/frontend/appflowy_tauri/.vscode/extensions.json b/frontend/appflowy_tauri/.vscode/extensions.json new file mode 100644 index 0000000000000..24d7cc6de8e0b --- /dev/null +++ b/frontend/appflowy_tauri/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] +} diff --git a/frontend/appflowy_tauri/README.md b/frontend/appflowy_tauri/README.md new file mode 100644 index 0000000000000..102e366893d0e --- /dev/null +++ b/frontend/appflowy_tauri/README.md @@ -0,0 +1,7 @@ +# Tauri + React + Typescript + +This template should help get you started developing with Tauri, React and Typescript in Vite. + +## Recommended IDE Setup + +- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) diff --git a/frontend/appflowy_tauri/index.html b/frontend/appflowy_tauri/index.html new file mode 100644 index 0000000000000..4983fb648ba9b --- /dev/null +++ b/frontend/appflowy_tauri/index.html @@ -0,0 +1,14 @@ + + + + + + + AppFlowy: The Open Source Alternative To Notion + + + +
+ + + diff --git a/frontend/appflowy_tauri/jest.config.cjs b/frontend/appflowy_tauri/jest.config.cjs new file mode 100644 index 0000000000000..493947816504f --- /dev/null +++ b/frontend/appflowy_tauri/jest.config.cjs @@ -0,0 +1,21 @@ +const { compilerOptions } = require('./tsconfig.json'); +const { pathsToModuleNameMapper } = require("ts-jest"); +const esModules = ["lodash-es", "nanoid"].join("|"); + +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + modulePaths: [compilerOptions.baseUrl], + moduleNameMapper: { + ...pathsToModuleNameMapper(compilerOptions.paths), + "^lodash-es(/(.*)|$)": "lodash$1", + "^nanoid(/(.*)|$)": "nanoid$1", + }, + "transform": { + "(.*)/node_modules/nanoid/.+\\.(j|t)sx?$": "ts-jest" + }, + "transformIgnorePatterns": [`/node_modules/(?!${esModules})`], + "testRegex": "(/__tests__/.*\.(test|spec))\\.(jsx?|tsx?)$", +}; \ No newline at end of file diff --git a/frontend/appflowy_tauri/package.json b/frontend/appflowy_tauri/package.json new file mode 100644 index 0000000000000..30c79787715ab --- /dev/null +++ b/frontend/appflowy_tauri/package.json @@ -0,0 +1,126 @@ +{ + "name": "appflowy_tauri", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "pnpm sync:i18n && tsc && vite build", + "preview": "vite preview", + "format": "prettier --write .", + "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", + "test:errors": "pnpm sync:i18n && tsc --noEmit && eslint --ext .js,.ts,.tsx .", + "test:prettier": "pnpm prettier --list-different src", + "tauri:clean": "cargo make --cwd .. tauri_clean", + "tauri:dev": "pnpm sync:i18n && tauri dev", + "sync:i18n": "node scripts/i18n/index.cjs", + "css:variables": "node style-dictionary/config.cjs", + "test": "jest" + }, + "dependencies": { + "@emoji-mart/data": "^1.1.2", + "@emoji-mart/react": "^1.1.1", + "@emotion/react": "^11.10.6", + "@emotion/styled": "^11.10.6", + "@mui/icons-material": "^5.11.11", + "@mui/material": "^5.11.12", + "@mui/system": "^5.14.4", + "@mui/x-date-pickers-pro": "^6.18.2", + "@reduxjs/toolkit": "2.0.0", + "@slate-yjs/core": "^1.0.2", + "@tauri-apps/api": "^1.2.0", + "@types/react-swipeable-views": "^0.13.4", + "dayjs": "^1.11.9", + "emoji-mart": "^5.5.2", + "emoji-regex": "^10.2.1", + "events": "^3.3.0", + "google-protobuf": "^3.15.12", + "i18next": "^22.4.10", + "i18next-browser-languagedetector": "^7.0.1", + "i18next-resources-to-backend": "^1.1.4", + "is-hotkey": "^0.2.0", + "jest": "^29.5.0", + "js-base64": "^3.7.5", + "katex": "^0.16.7", + "lodash-es": "^4.17.21", + "nanoid": "^4.0.0", + "prismjs": "^1.29.0", + "protoc-gen-ts": "0.8.7", + "quill": "^1.3.7", + "quill-delta": "^5.1.0", + "react": "^18.2.0", + "react-beautiful-dnd": "^13.1.1", + "react-big-calendar": "^1.8.5", + "react-color": "^2.19.3", + "react-custom-scrollbars": "^4.2.1", + "react-datepicker": "^4.23.0", + "react-dom": "^18.2.0", + "react-error-boundary": "^3.1.4", + "react-hot-toast": "^2.4.1", + "react-i18next": "^12.2.0", + "react-katex": "^3.0.1", + "react-redux": "^8.0.5", + "react-router-dom": "^6.8.0", + "react-swipeable-views": "^0.14.0", + "react-transition-group": "^4.4.5", + "react-virtualized-auto-sizer": "^1.0.20", + "react-vtree": "^2.0.4", + "react-window": "^1.8.10", + "react18-input-otp": "^1.1.2", + "redux": "^4.2.1", + "rxjs": "^7.8.0", + "sass": "^1.70.0", + "slate": "^0.101.4", + "slate-history": "^0.100.0", + "slate-react": "^0.101.3", + "ts-results": "^3.3.0", + "unsplash-js": "^7.0.19", + "utf8": "^3.0.0", + "valtio": "^1.12.1", + "yjs": "^13.5.51" + }, + "devDependencies": { + "@svgr/plugin-svgo": "^8.0.1", + "@tauri-apps/cli": "^1.5.6", + "@types/google-protobuf": "^3.15.12", + "@types/is-hotkey": "^0.1.7", + "@types/jest": "^29.5.3", + "@types/katex": "^0.16.0", + "@types/lodash-es": "^4.17.11", + "@types/node": "^18.7.10", + "@types/prismjs": "^1.26.0", + "@types/quill": "^2.0.10", + "@types/react": "^18.0.15", + "@types/react-beautiful-dnd": "^13.1.3", + "@types/react-color": "^3.0.6", + "@types/react-custom-scrollbars": "^4.0.13", + "@types/react-datepicker": "^4.19.3", + "@types/react-dom": "^18.0.6", + "@types/react-katex": "^3.0.0", + "@types/react-transition-group": "^4.4.6", + "@types/react-window": "^1.8.8", + "@types/utf8": "^3.0.1", + "@types/uuid": "^9.0.1", + "@typescript-eslint/eslint-plugin": "^5.51.0", + "@typescript-eslint/parser": "^5.51.0", + "@vitejs/plugin-react": "^3.0.0", + "autoprefixer": "^10.4.13", + "babel-jest": "^29.6.2", + "eslint": "^8.34.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "jest-environment-jsdom": "^29.6.2", + "postcss": "^8.4.21", + "prettier": "2.8.4", + "prettier-plugin-tailwindcss": "^0.2.2", + "style-dictionary": "^3.8.0", + "tailwindcss": "^3.2.7", + "ts-jest": "^29.1.1", + "ts-node-dev": "^2.0.0", + "tsconfig-paths-jest": "^0.0.1", + "typescript": "^4.6.4", + "uuid": "^9.0.0", + "vite": "^4.0.0", + "vite-plugin-svgr": "^3.2.0" + } +} diff --git a/frontend/appflowy_tauri/pnpm-lock.yaml b/frontend/appflowy_tauri/pnpm-lock.yaml new file mode 100644 index 0000000000000..d670b8b312483 --- /dev/null +++ b/frontend/appflowy_tauri/pnpm-lock.yaml @@ -0,0 +1,7264 @@ +lockfileVersion: '6.0' + +dependencies: + '@emoji-mart/data': + specifier: ^1.1.2 + version: 1.1.2 + '@emoji-mart/react': + specifier: ^1.1.1 + version: 1.1.1(emoji-mart@5.5.2)(react@18.2.0) + '@emotion/react': + specifier: ^11.10.6 + version: 11.11.0(@types/react@18.2.6)(react@18.2.0) + '@emotion/styled': + specifier: ^11.10.6 + version: 11.11.0(@emotion/react@11.11.0)(@types/react@18.2.6)(react@18.2.0) + '@mui/icons-material': + specifier: ^5.11.11 + version: 5.11.16(@mui/material@5.13.0)(@types/react@18.2.6)(react@18.2.0) + '@mui/material': + specifier: ^5.11.12 + version: 5.13.0(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0) + '@mui/system': + specifier: ^5.14.4 + version: 5.14.4(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react@18.2.0) + '@mui/x-date-pickers-pro': + specifier: ^6.18.2 + version: 6.18.2(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@mui/material@5.13.0)(@mui/system@5.14.4)(@types/react@18.2.6)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0) + '@reduxjs/toolkit': + specifier: 2.0.0 + version: 2.0.0(react-redux@8.0.5)(react@18.2.0) + '@slate-yjs/core': + specifier: ^1.0.2 + version: 1.0.2(slate@0.101.4)(yjs@13.6.1) + '@tauri-apps/api': + specifier: ^1.2.0 + version: 1.3.0 + '@types/react-swipeable-views': + specifier: ^0.13.4 + version: 0.13.4 + dayjs: + specifier: ^1.11.9 + version: 1.11.9 + emoji-mart: + specifier: ^5.5.2 + version: 5.5.2 + emoji-regex: + specifier: ^10.2.1 + version: 10.2.1 + events: + specifier: ^3.3.0 + version: 3.3.0 + google-protobuf: + specifier: ^3.15.12 + version: 3.21.2 + i18next: + specifier: ^22.4.10 + version: 22.4.15 + i18next-browser-languagedetector: + specifier: ^7.0.1 + version: 7.0.1 + i18next-resources-to-backend: + specifier: ^1.1.4 + version: 1.1.4 + is-hotkey: + specifier: ^0.2.0 + version: 0.2.0 + jest: + specifier: ^29.5.0 + version: 29.5.0(@types/node@18.16.9) + js-base64: + specifier: ^3.7.5 + version: 3.7.5 + katex: + specifier: ^0.16.7 + version: 0.16.7 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 + nanoid: + specifier: ^4.0.0 + version: 4.0.2 + prismjs: + specifier: ^1.29.0 + version: 1.29.0 + protoc-gen-ts: + specifier: 0.8.7 + version: 0.8.7 + quill: + specifier: ^1.3.7 + version: 1.3.7 + quill-delta: + specifier: ^5.1.0 + version: 5.1.0 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-beautiful-dnd: + specifier: ^13.1.1 + version: 13.1.1(react-dom@18.2.0)(react@18.2.0) + react-big-calendar: + specifier: ^1.8.5 + version: 1.8.5(react-dom@18.2.0)(react@18.2.0) + react-color: + specifier: ^2.19.3 + version: 2.19.3(react@18.2.0) + react-custom-scrollbars: + specifier: ^4.2.1 + version: 4.2.1(react-dom@18.2.0)(react@18.2.0) + react-datepicker: + specifier: ^4.23.0 + version: 4.23.0(react-dom@18.2.0)(react@18.2.0) + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + react-error-boundary: + specifier: ^3.1.4 + version: 3.1.4(react@18.2.0) + react-hot-toast: + specifier: ^2.4.1 + version: 2.4.1(csstype@3.1.2)(react-dom@18.2.0)(react@18.2.0) + react-i18next: + specifier: ^12.2.0 + version: 12.2.2(i18next@22.4.15)(react-dom@18.2.0)(react@18.2.0) + react-katex: + specifier: ^3.0.1 + version: 3.0.1(prop-types@15.8.1)(react@18.2.0) + react-redux: + specifier: ^8.0.5 + version: 8.0.5(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1) + react-router-dom: + specifier: ^6.8.0 + version: 6.11.1(react-dom@18.2.0)(react@18.2.0) + react-swipeable-views: + specifier: ^0.14.0 + version: 0.14.0(react@18.2.0) + react-transition-group: + specifier: ^4.4.5 + version: 4.4.5(react-dom@18.2.0)(react@18.2.0) + react-virtualized-auto-sizer: + specifier: ^1.0.20 + version: 1.0.20(react-dom@18.2.0)(react@18.2.0) + react-vtree: + specifier: ^2.0.4 + version: 2.0.4(@types/react-window@1.8.8)(react-dom@18.2.0)(react-window@1.8.10)(react@18.2.0) + react-window: + specifier: ^1.8.10 + version: 1.8.10(react-dom@18.2.0)(react@18.2.0) + react18-input-otp: + specifier: ^1.1.2 + version: 1.1.3(react-dom@18.2.0)(react@18.2.0) + redux: + specifier: ^4.2.1 + version: 4.2.1 + rxjs: + specifier: ^7.8.0 + version: 7.8.1 + sass: + specifier: ^1.70.0 + version: 1.70.0 + slate: + specifier: ^0.101.4 + version: 0.101.4 + slate-history: + specifier: ^0.100.0 + version: 0.100.0(slate@0.101.4) + slate-react: + specifier: ^0.101.3 + version: 0.101.3(react-dom@18.2.0)(react@18.2.0)(slate@0.101.4) + ts-results: + specifier: ^3.3.0 + version: 3.3.0 + unsplash-js: + specifier: ^7.0.19 + version: 7.0.19 + utf8: + specifier: ^3.0.0 + version: 3.0.0 + valtio: + specifier: ^1.12.1 + version: 1.12.1(@types/react@18.2.6)(react@18.2.0) + yjs: + specifier: ^13.5.51 + version: 13.6.1 + +devDependencies: + '@svgr/plugin-svgo': + specifier: ^8.0.1 + version: 8.0.1(@svgr/core@7.0.0) + '@tauri-apps/cli': + specifier: ^1.5.6 + version: 1.5.6 + '@types/google-protobuf': + specifier: ^3.15.12 + version: 3.15.12 + '@types/is-hotkey': + specifier: ^0.1.7 + version: 0.1.7 + '@types/jest': + specifier: ^29.5.3 + version: 29.5.3 + '@types/katex': + specifier: ^0.16.0 + version: 0.16.0 + '@types/lodash-es': + specifier: ^4.17.11 + version: 4.17.11 + '@types/node': + specifier: ^18.7.10 + version: 18.16.9 + '@types/prismjs': + specifier: ^1.26.0 + version: 1.26.0 + '@types/quill': + specifier: ^2.0.10 + version: 2.0.10 + '@types/react': + specifier: ^18.0.15 + version: 18.2.6 + '@types/react-beautiful-dnd': + specifier: ^13.1.3 + version: 13.1.4 + '@types/react-color': + specifier: ^3.0.6 + version: 3.0.6 + '@types/react-custom-scrollbars': + specifier: ^4.0.13 + version: 4.0.13 + '@types/react-datepicker': + specifier: ^4.19.3 + version: 4.19.3(react-dom@18.2.0)(react@18.2.0) + '@types/react-dom': + specifier: ^18.0.6 + version: 18.2.4 + '@types/react-katex': + specifier: ^3.0.0 + version: 3.0.0 + '@types/react-transition-group': + specifier: ^4.4.6 + version: 4.4.6 + '@types/react-window': + specifier: ^1.8.8 + version: 1.8.8 + '@types/utf8': + specifier: ^3.0.1 + version: 3.0.1 + '@types/uuid': + specifier: ^9.0.1 + version: 9.0.1 + '@typescript-eslint/eslint-plugin': + specifier: ^5.51.0 + version: 5.59.5(@typescript-eslint/parser@5.59.5)(eslint@8.40.0)(typescript@4.9.5) + '@typescript-eslint/parser': + specifier: ^5.51.0 + version: 5.59.5(eslint@8.40.0)(typescript@4.9.5) + '@vitejs/plugin-react': + specifier: ^3.0.0 + version: 3.1.0(vite@4.3.5) + autoprefixer: + specifier: ^10.4.13 + version: 10.4.14(postcss@8.4.23) + babel-jest: + specifier: ^29.6.2 + version: 29.6.2(@babel/core@7.21.8) + eslint: + specifier: ^8.34.0 + version: 8.40.0 + eslint-plugin-react: + specifier: ^7.32.2 + version: 7.32.2(eslint@8.40.0) + eslint-plugin-react-hooks: + specifier: ^4.6.0 + version: 4.6.0(eslint@8.40.0) + jest-environment-jsdom: + specifier: ^29.6.2 + version: 29.6.2 + postcss: + specifier: ^8.4.21 + version: 8.4.23 + prettier: + specifier: 2.8.4 + version: 2.8.4 + prettier-plugin-tailwindcss: + specifier: ^0.2.2 + version: 0.2.8(prettier@2.8.4) + style-dictionary: + specifier: ^3.8.0 + version: 3.8.0 + tailwindcss: + specifier: ^3.2.7 + version: 3.3.2 + ts-jest: + specifier: ^29.1.1 + version: 29.1.1(@babel/core@7.21.8)(babel-jest@29.6.2)(jest@29.5.0)(typescript@4.9.5) + ts-node-dev: + specifier: ^2.0.0 + version: 2.0.0(@types/node@18.16.9)(typescript@4.9.5) + tsconfig-paths-jest: + specifier: ^0.0.1 + version: 0.0.1 + typescript: + specifier: ^4.6.4 + version: 4.9.5 + uuid: + specifier: ^9.0.0 + version: 9.0.0 + vite: + specifier: ^4.0.0 + version: 4.3.5(@types/node@18.16.9)(sass@1.70.0) + vite-plugin-svgr: + specifier: ^3.2.0 + version: 3.2.0(vite@4.3.5) + +packages: + + /@alloc/quick-lru@5.2.0: + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + dev: true + + /@ampproject/remapping@2.2.1: + resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.18 + + /@babel/code-frame@7.21.4: + resolution: {integrity: sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.18.6 + + /@babel/code-frame@7.23.5: + resolution: {integrity: sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.23.4 + chalk: 2.4.2 + + /@babel/compat-data@7.21.7: + resolution: {integrity: sha512-KYMqFYTaenzMK4yUtf4EW9wc4N9ef80FsbMtkwool5zpwl4YrT1SdWYSTRcT94KO4hannogdS+LxY7L+arP3gA==} + engines: {node: '>=6.9.0'} + + /@babel/core@7.21.8: + resolution: {integrity: sha512-YeM22Sondbo523Sz0+CirSPnbj9bG3P0CdHcBZdqUuaeOaYEFbOLoGU7lebvGP6P5J/WE9wOn7u7C4J9HvS1xQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.2.1 + '@babel/code-frame': 7.21.4 + '@babel/generator': 7.21.5 + '@babel/helper-compilation-targets': 7.21.5(@babel/core@7.21.8) + '@babel/helper-module-transforms': 7.21.5 + '@babel/helpers': 7.21.5 + '@babel/parser': 7.21.8 + '@babel/template': 7.20.7 + '@babel/traverse': 7.23.7 + '@babel/types': 7.21.5 + convert-source-map: 1.9.0 + debug: 4.3.4 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + + /@babel/generator@7.21.5: + resolution: {integrity: sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.21.5 + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.18 + jsesc: 2.5.2 + + /@babel/generator@7.23.6: + resolution: {integrity: sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.6 + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.18 + jsesc: 2.5.2 + + /@babel/helper-compilation-targets@7.21.5(@babel/core@7.21.8): + resolution: {integrity: sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/compat-data': 7.21.7 + '@babel/core': 7.21.8 + '@babel/helper-validator-option': 7.21.0 + browserslist: 4.21.5 + lru-cache: 5.1.1 + semver: 6.3.0 + + /@babel/helper-environment-visitor@7.21.5: + resolution: {integrity: sha512-IYl4gZ3ETsWocUWgsFZLM5i1BYx9SoemminVEXadgLBa9TdeorzgLKm8wWLA6J1N/kT3Kch8XIk1laNzYoHKvQ==} + engines: {node: '>=6.9.0'} + + /@babel/helper-environment-visitor@7.22.20: + resolution: {integrity: sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==} + engines: {node: '>=6.9.0'} + + /@babel/helper-function-name@7.23.0: + resolution: {integrity: sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.22.15 + '@babel/types': 7.23.6 + + /@babel/helper-hoist-variables@7.22.5: + resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.6 + + /@babel/helper-module-imports@7.21.4: + resolution: {integrity: sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.21.5 + + /@babel/helper-module-transforms@7.21.5: + resolution: {integrity: sha512-bI2Z9zBGY2q5yMHoBvJ2a9iX3ZOAzJPm7Q8Yz6YeoUjU/Cvhmi2G4QyTNyPBqqXSgTjUxRg3L0xV45HvkNWWBw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-environment-visitor': 7.21.5 + '@babel/helper-module-imports': 7.21.4 + '@babel/helper-simple-access': 7.21.5 + '@babel/helper-split-export-declaration': 7.18.6 + '@babel/helper-validator-identifier': 7.19.1 + '@babel/template': 7.20.7 + '@babel/traverse': 7.23.7 + '@babel/types': 7.21.5 + transitivePeerDependencies: + - supports-color + + /@babel/helper-plugin-utils@7.21.5: + resolution: {integrity: sha512-0WDaIlXKOX/3KfBK/dwP1oQGiPh6rjMkT7HIRv7i5RR2VUMwrx5ZL0dwBkKx7+SW1zwNdgjHd34IMk5ZjTeHVg==} + engines: {node: '>=6.9.0'} + + /@babel/helper-simple-access@7.21.5: + resolution: {integrity: sha512-ENPDAMC1wAjR0uaCUwliBdiSl1KBJAVnMTzXqi64c2MG8MPR6ii4qf7bSXDqSFbr4W6W028/rf5ivoHop5/mkg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.21.5 + + /@babel/helper-split-export-declaration@7.18.6: + resolution: {integrity: sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.21.5 + + /@babel/helper-split-export-declaration@7.22.6: + resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.6 + + /@babel/helper-string-parser@7.21.5: + resolution: {integrity: sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w==} + engines: {node: '>=6.9.0'} + + /@babel/helper-string-parser@7.23.4: + resolution: {integrity: sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-identifier@7.19.1: + resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-identifier@7.22.20: + resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-option@7.21.0: + resolution: {integrity: sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==} + engines: {node: '>=6.9.0'} + + /@babel/helpers@7.21.5: + resolution: {integrity: sha512-BSY+JSlHxOmGsPTydUkPf1MdMQ3M81x5xGCOVgWM3G8XH77sJ292Y2oqcp0CbbgxhqBuI46iUz1tT7hqP7EfgA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.20.7 + '@babel/traverse': 7.23.7 + '@babel/types': 7.21.5 + transitivePeerDependencies: + - supports-color + + /@babel/highlight@7.18.6: + resolution: {integrity: sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.19.1 + chalk: 2.4.2 + js-tokens: 4.0.0 + + /@babel/highlight@7.23.4: + resolution: {integrity: sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.22.20 + chalk: 2.4.2 + js-tokens: 4.0.0 + + /@babel/parser@7.21.8: + resolution: {integrity: sha512-6zavDGdzG3gUqAdWvlLFfk+36RilI+Pwyuuh7HItyeScCWP3k6i8vKclAQ0bM/0y/Kz/xiwvxhMv9MgTJP5gmA==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.21.5 + + /@babel/parser@7.23.6: + resolution: {integrity: sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.23.6 + + /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.21.8): + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.21.8): + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.21.8): + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.21.8): + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.21.8): + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-syntax-jsx@7.21.4(@babel/core@7.21.8): + resolution: {integrity: sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.21.8): + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.21.8): + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.21.8): + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.21.8): + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.21.8): + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.21.8): + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.21.8): + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-syntax-typescript@7.21.4(@babel/core@7.21.8): + resolution: {integrity: sha512-xz0D39NvhQn4t4RNsHmDnnsaQizIlUkdtYvLs8La1BlfjQ6JEwxkJGeqJMW2tAXx+q6H+WFuUTXNdYVpEya0YA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + + /@babel/plugin-transform-react-jsx-self@7.21.0(@babel/core@7.21.8): + resolution: {integrity: sha512-f/Eq+79JEu+KUANFks9UZCcvydOOGMgF7jBrcwjHa5jTZD8JivnhCJYvmlhR/WTXBWonDExPoW0eO/CR4QJirA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/plugin-transform-react-jsx-source@7.19.6(@babel/core@7.21.8): + resolution: {integrity: sha512-RpAi004QyMNisst/pvSanoRdJ4q+jMCWyk9zdw/CyLB9j8RXEahodR6l2GyttDRyEVWZtbN+TpLiHJ3t34LbsQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + + /@babel/runtime@7.0.0: + resolution: {integrity: sha512-7hGhzlcmg01CvH1EHdSPVXYX1aJ8KCEyz6I9xYIi/asDtzBPMyMhVibhM/K6g/5qnKBwjZtp10bNZIEFTRW1MA==} + dependencies: + regenerator-runtime: 0.12.1 + dev: false + + /@babel/runtime@7.21.5: + resolution: {integrity: sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.13.11 + dev: false + + /@babel/runtime@7.22.10: + resolution: {integrity: sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.0 + dev: false + + /@babel/runtime@7.23.4: + resolution: {integrity: sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.0 + + /@babel/template@7.20.7: + resolution: {integrity: sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.21.4 + '@babel/parser': 7.21.8 + '@babel/types': 7.21.5 + + /@babel/template@7.22.15: + resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.23.5 + '@babel/parser': 7.23.6 + '@babel/types': 7.23.6 + + /@babel/traverse@7.23.7: + resolution: {integrity: sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.23.5 + '@babel/generator': 7.23.6 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-hoist-variables': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/parser': 7.23.6 + '@babel/types': 7.23.6 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + /@babel/types@7.21.5: + resolution: {integrity: sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.21.5 + '@babel/helper-validator-identifier': 7.19.1 + to-fast-properties: 2.0.0 + + /@babel/types@7.23.6: + resolution: {integrity: sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.23.4 + '@babel/helper-validator-identifier': 7.22.20 + to-fast-properties: 2.0.0 + + /@bcoe/v8-coverage@0.2.3: + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + /@cspotcode/source-map-support@0.8.1: + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + dev: true + + /@emoji-mart/data@1.1.2: + resolution: {integrity: sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg==} + dev: false + + /@emoji-mart/react@1.1.1(emoji-mart@5.5.2)(react@18.2.0): + resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==} + peerDependencies: + emoji-mart: ^5.2 + react: ^16.8 || ^17 || ^18 + dependencies: + emoji-mart: 5.5.2 + react: 18.2.0 + dev: false + + /@emotion/babel-plugin@11.11.0: + resolution: {integrity: sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==} + dependencies: + '@babel/helper-module-imports': 7.21.4 + '@babel/runtime': 7.21.5 + '@emotion/hash': 0.9.1 + '@emotion/memoize': 0.8.1 + '@emotion/serialize': 1.1.2 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + dev: false + + /@emotion/cache@11.11.0: + resolution: {integrity: sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==} + dependencies: + '@emotion/memoize': 0.8.1 + '@emotion/sheet': 1.2.2 + '@emotion/utils': 1.2.1 + '@emotion/weak-memoize': 0.3.1 + stylis: 4.2.0 + dev: false + + /@emotion/hash@0.9.1: + resolution: {integrity: sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==} + dev: false + + /@emotion/is-prop-valid@1.2.1: + resolution: {integrity: sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==} + dependencies: + '@emotion/memoize': 0.8.1 + dev: false + + /@emotion/memoize@0.8.1: + resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==} + dev: false + + /@emotion/react@11.11.0(@types/react@18.2.6)(react@18.2.0): + resolution: {integrity: sha512-ZSK3ZJsNkwfjT3JpDAWJZlrGD81Z3ytNDsxw1LKq1o+xkmO5pnWfr6gmCC8gHEFf3nSSX/09YrG67jybNPxSUw==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.21.5 + '@emotion/babel-plugin': 11.11.0 + '@emotion/cache': 11.11.0 + '@emotion/serialize': 1.1.2 + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) + '@emotion/utils': 1.2.1 + '@emotion/weak-memoize': 0.3.1 + '@types/react': 18.2.6 + hoist-non-react-statics: 3.3.2 + react: 18.2.0 + dev: false + + /@emotion/serialize@1.1.2: + resolution: {integrity: sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==} + dependencies: + '@emotion/hash': 0.9.1 + '@emotion/memoize': 0.8.1 + '@emotion/unitless': 0.8.1 + '@emotion/utils': 1.2.1 + csstype: 3.1.2 + dev: false + + /@emotion/sheet@1.2.2: + resolution: {integrity: sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==} + dev: false + + /@emotion/styled@11.11.0(@emotion/react@11.11.0)(@types/react@18.2.6)(react@18.2.0): + resolution: {integrity: sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.21.5 + '@emotion/babel-plugin': 11.11.0 + '@emotion/is-prop-valid': 1.2.1 + '@emotion/react': 11.11.0(@types/react@18.2.6)(react@18.2.0) + '@emotion/serialize': 1.1.2 + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) + '@emotion/utils': 1.2.1 + '@types/react': 18.2.6 + react: 18.2.0 + dev: false + + /@emotion/unitless@0.8.1: + resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} + dev: false + + /@emotion/use-insertion-effect-with-fallbacks@1.0.1(react@18.2.0): + resolution: {integrity: sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.2.0 + dev: false + + /@emotion/utils@1.2.1: + resolution: {integrity: sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==} + dev: false + + /@emotion/weak-memoize@0.3.1: + resolution: {integrity: sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==} + dev: false + + /@esbuild/android-arm64@0.17.19: + resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.17.19: + resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.17.19: + resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.17.19: + resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.17.19: + resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.17.19: + resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.17.19: + resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.17.19: + resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.17.19: + resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.17.19: + resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.17.19: + resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.17.19: + resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.17.19: + resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.17.19: + resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.17.19: + resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.17.19: + resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.17.19: + resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.17.19: + resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.17.19: + resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.17.19: + resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.17.19: + resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.17.19: + resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@eslint-community/eslint-utils@4.4.0(eslint@8.40.0): + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + dependencies: + eslint: 8.40.0 + eslint-visitor-keys: 3.4.1 + dev: true + + /@eslint-community/regexpp@4.5.1: + resolution: {integrity: sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + dev: true + + /@eslint/eslintrc@2.0.3: + resolution: {integrity: sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + ajv: 6.12.6 + debug: 4.3.4 + espree: 9.5.2 + globals: 13.20.0 + ignore: 5.2.4 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@eslint/js@8.40.0: + resolution: {integrity: sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@floating-ui/core@1.5.0: + resolution: {integrity: sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==} + dependencies: + '@floating-ui/utils': 0.1.6 + dev: false + + /@floating-ui/dom@1.5.3: + resolution: {integrity: sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==} + dependencies: + '@floating-ui/core': 1.5.0 + '@floating-ui/utils': 0.1.6 + dev: false + + /@floating-ui/react-dom@2.0.4(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/dom': 1.5.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@floating-ui/utils@0.1.6: + resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==} + dev: false + + /@humanwhocodes/config-array@0.11.8: + resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==} + engines: {node: '>=10.10.0'} + dependencies: + '@humanwhocodes/object-schema': 1.2.1 + debug: 4.3.4 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@humanwhocodes/module-importer@1.0.1: + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + dev: true + + /@humanwhocodes/object-schema@1.2.1: + resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} + dev: true + + /@icons/material@0.2.4(react@18.2.0): + resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==} + peerDependencies: + react: '*' + dependencies: + react: 18.2.0 + dev: false + + /@istanbuljs/load-nyc-config@1.1.0: + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + + /@istanbuljs/schema@0.1.3: + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + /@jest/console@29.5.0: + resolution: {integrity: sha512-NEpkObxPwyw/XxZVLPmAGKE89IQRp4puc6IQRPru6JKd1M3fW9v1xM1AnzIJE65hbCkzQAdnL8P47e9hzhiYLQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.5.0 + '@types/node': 18.16.9 + chalk: 4.1.2 + jest-message-util: 29.5.0 + jest-util: 29.5.0 + slash: 3.0.0 + + /@jest/core@29.5.0: + resolution: {integrity: sha512-28UzQc7ulUrOQw1IsN/kv1QES3q2kkbl/wGslyhAclqZ/8cMdB5M68BffkIdSJgKBUt50d3hbwJ92XESlE7LiQ==} + 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 + dependencies: + '@jest/console': 29.5.0 + '@jest/reporters': 29.5.0 + '@jest/test-result': 29.5.0 + '@jest/transform': 29.5.0 + '@jest/types': 29.5.0 + '@types/node': 18.16.9 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.8.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.5.0 + jest-config: 29.5.0(@types/node@18.16.9) + jest-haste-map: 29.5.0 + jest-message-util: 29.5.0 + jest-regex-util: 29.4.3 + jest-resolve: 29.5.0 + jest-resolve-dependencies: 29.5.0 + jest-runner: 29.5.0 + jest-runtime: 29.5.0 + jest-snapshot: 29.5.0 + jest-util: 29.5.0 + jest-validate: 29.5.0 + jest-watcher: 29.5.0 + micromatch: 4.0.5 + pretty-format: 29.5.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - supports-color + - ts-node + + /@jest/environment@29.5.0: + resolution: {integrity: sha512-5FXw2+wD29YU1d4I2htpRX7jYnAyTRjP2CsXQdo9SAM8g3ifxWPSV0HnClSn71xwctr0U3oZIIH+dtbfmnbXVQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/fake-timers': 29.5.0 + '@jest/types': 29.5.0 + '@types/node': 18.16.9 + jest-mock: 29.5.0 + + /@jest/environment@29.6.4: + resolution: {integrity: sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/fake-timers': 29.6.4 + '@jest/types': 29.6.3 + '@types/node': 18.16.9 + jest-mock: 29.6.3 + + /@jest/expect-utils@29.5.0: + resolution: {integrity: sha512-fmKzsidoXQT2KwnrwE0SQq3uj8Z763vzR8LnLBwC2qYWEFpjX8daRsk6rHUM1QvNlEW/UJXNXm59ztmJJWs2Mg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + jest-get-type: 29.4.3 + + /@jest/expect@29.5.0: + resolution: {integrity: sha512-PueDR2HGihN3ciUNGr4uelropW7rqUfTiOn+8u0leg/42UhblPxHkfoh0Ruu3I9Y1962P3u2DY4+h7GVTSVU6g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + expect: 29.5.0 + jest-snapshot: 29.5.0 + transitivePeerDependencies: + - supports-color + + /@jest/fake-timers@29.5.0: + resolution: {integrity: sha512-9ARvuAAQcBwDAqOnglWq2zwNIRUDtk/SCkp/ToGEhFv5r86K21l+VEs0qNTaXtyiY0lEePl3kylijSYJQqdbDg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.5.0 + '@sinonjs/fake-timers': 10.1.0 + '@types/node': 18.16.9 + jest-message-util: 29.5.0 + jest-mock: 29.5.0 + jest-util: 29.5.0 + + /@jest/fake-timers@29.6.4: + resolution: {integrity: sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.1.0 + '@types/node': 18.16.9 + jest-message-util: 29.6.3 + jest-mock: 29.6.3 + jest-util: 29.6.3 + + /@jest/globals@29.5.0: + resolution: {integrity: sha512-S02y0qMWGihdzNbUiqSAiKSpSozSuHX5UYc7QbnHP+D9Lyw8DgGGCinrN9uSuHPeKgSSzvPom2q1nAtBvUsvPQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.5.0 + '@jest/expect': 29.5.0 + '@jest/types': 29.5.0 + jest-mock: 29.5.0 + transitivePeerDependencies: + - supports-color + + /@jest/reporters@29.5.0: + resolution: {integrity: sha512-D05STXqj/M8bP9hQNSICtPqz97u7ffGzZu+9XLucXhkOFBqKcXe04JLZOgIekOxdb73MAoBUFnqvf7MCpKk5OA==} + 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 + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.5.0 + '@jest/test-result': 29.5.0 + '@jest/transform': 29.5.0 + '@jest/types': 29.5.0 + '@jridgewell/trace-mapping': 0.3.18 + '@types/node': 18.16.9 + chalk: 4.1.2 + collect-v8-coverage: 1.0.1 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.0 + istanbul-lib-instrument: 5.2.1 + istanbul-lib-report: 3.0.0 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.5 + jest-message-util: 29.5.0 + jest-util: 29.5.0 + jest-worker: 29.5.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.1.0 + transitivePeerDependencies: + - supports-color + + /@jest/schemas@29.4.3: + resolution: {integrity: sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@sinclair/typebox': 0.25.24 + + /@jest/schemas@29.6.3: + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@sinclair/typebox': 0.27.8 + + /@jest/source-map@29.4.3: + resolution: {integrity: sha512-qyt/mb6rLyd9j1jUts4EQncvS6Yy3PM9HghnNv86QBlV+zdL2inCdK1tuVlL+J+lpiw2BI67qXOrX3UurBqQ1w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jridgewell/trace-mapping': 0.3.18 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + /@jest/test-result@29.5.0: + resolution: {integrity: sha512-fGl4rfitnbfLsrfx1uUpDEESS7zM8JdgZgOCQuxQvL1Sn/I6ijeAVQWGfXI9zb1i9Mzo495cIpVZhA0yr60PkQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/console': 29.5.0 + '@jest/types': 29.5.0 + '@types/istanbul-lib-coverage': 2.0.4 + collect-v8-coverage: 1.0.1 + + /@jest/test-sequencer@29.5.0: + resolution: {integrity: sha512-yPafQEcKjkSfDXyvtgiV4pevSeyuA6MQr6ZIdVkWJly9vkqjnFfcfhRQqpD5whjoU8EORki752xQmjaqoFjzMQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/test-result': 29.5.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.6.4 + slash: 3.0.0 + + /@jest/transform@29.5.0: + resolution: {integrity: sha512-8vbeZWqLJOvHaDfeMuoHITGKSz5qWc9u04lnWrQE3VyuSw604PzQM824ZeX9XSjUCeDiE3GuxZe5UKa8J61NQw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/core': 7.21.8 + '@jest/types': 29.5.0 + '@jridgewell/trace-mapping': 0.3.18 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.5.0 + jest-regex-util: 29.4.3 + jest-util: 29.5.0 + micromatch: 4.0.5 + pirates: 4.0.5 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + /@jest/transform@29.6.4: + resolution: {integrity: sha512-8thgRSiXUqtr/pPGY/OsyHuMjGyhVnWrFAwoxmIemlBuiMyU1WFs0tXoNxzcr4A4uErs/ABre76SGmrr5ab/AA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/core': 7.21.8 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.18 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.6.4 + jest-regex-util: 29.6.3 + jest-util: 29.6.3 + micromatch: 4.0.5 + pirates: 4.0.5 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + /@jest/types@29.5.0: + resolution: {integrity: sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.4.3 + '@types/istanbul-lib-coverage': 2.0.4 + '@types/istanbul-reports': 3.0.1 + '@types/node': 18.16.9 + '@types/yargs': 17.0.24 + chalk: 4.1.2 + + /@jest/types@29.6.3: + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.4 + '@types/istanbul-reports': 3.0.1 + '@types/node': 18.16.9 + '@types/yargs': 17.0.24 + chalk: 4.1.2 + + /@jridgewell/gen-mapping@0.3.3: + resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.18 + + /@jridgewell/resolve-uri@3.1.0: + resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} + engines: {node: '>=6.0.0'} + + /@jridgewell/set-array@1.1.2: + resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} + engines: {node: '>=6.0.0'} + + /@jridgewell/sourcemap-codec@1.4.14: + resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} + + /@jridgewell/sourcemap-codec@1.4.15: + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + + /@jridgewell/trace-mapping@0.3.18: + resolution: {integrity: sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==} + dependencies: + '@jridgewell/resolve-uri': 3.1.0 + '@jridgewell/sourcemap-codec': 1.4.14 + + /@jridgewell/trace-mapping@0.3.9: + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.0 + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /@juggle/resize-observer@3.4.0: + resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + dev: false + + /@mui/base@5.0.0-beta.0(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ap+juKvt8R8n3cBqd/pGtZydQ4v2I/hgJKnvJRGjpSh3RvsvnDHO4rXov8MHQlH6VqpOekwgilFLGxMZjNTucA==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.21.5 + '@emotion/is-prop-valid': 1.2.1 + '@mui/types': 7.2.4(@types/react@18.2.6) + '@mui/utils': 5.12.3(react@18.2.0) + '@popperjs/core': 2.11.7 + '@types/react': 18.2.6 + clsx: 1.2.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 18.2.0 + dev: false + + /@mui/base@5.0.0-beta.24(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-bKt2pUADHGQtqWDZ8nvL2Lvg2GNJyd/ZUgZAJoYzRgmnxBL9j36MSlS3+exEdYkikcnvVafcBtD904RypFKb0w==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.4 + '@floating-ui/react-dom': 2.0.4(react-dom@18.2.0)(react@18.2.0) + '@mui/types': 7.2.9(@types/react@18.2.6) + '@mui/utils': 5.14.18(@types/react@18.2.6)(react@18.2.0) + '@popperjs/core': 2.11.8 + '@types/react': 18.2.6 + clsx: 2.0.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@mui/core-downloads-tracker@5.13.0: + resolution: {integrity: sha512-5nXz2k8Rv2ZjtQY6kXirJVyn2+ODaQuAJmXSJtLDUQDKWp3PFUj6j3bILqR0JGOs9R5ejgwz3crLKsl6GwjwkQ==} + dev: false + + /@mui/icons-material@5.11.16(@mui/material@5.13.0)(@types/react@18.2.6)(react@18.2.0): + resolution: {integrity: sha512-oKkx9z9Kwg40NtcIajF9uOXhxiyTZrrm9nmIJ4UjkU2IdHpd4QVLbCc/5hZN/y0C6qzi2Zlxyr9TGddQx2vx2A==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@mui/material': ^5.0.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.21.5 + '@mui/material': 5.13.0(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.6 + react: 18.2.0 + dev: false + + /@mui/material@5.13.0(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ckS+9tCpAzpdJdaTF+btF0b6mF9wbXg/EVKtnoAWYi0UKXoXBAVvEUMNpLGA5xdpCdf+A6fPbVUEHs9TsfU+Yw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.21.5 + '@emotion/react': 11.11.0(@types/react@18.2.6)(react@18.2.0) + '@emotion/styled': 11.11.0(@emotion/react@11.11.0)(@types/react@18.2.6)(react@18.2.0) + '@mui/base': 5.0.0-beta.0(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0) + '@mui/core-downloads-tracker': 5.13.0 + '@mui/system': 5.14.4(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react@18.2.0) + '@mui/types': 7.2.4(@types/react@18.2.6) + '@mui/utils': 5.12.3(react@18.2.0) + '@types/react': 18.2.6 + '@types/react-transition-group': 4.4.6 + clsx: 1.2.1 + csstype: 3.1.2 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 18.2.0 + react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) + dev: false + + /@mui/private-theming@5.14.4(@types/react@18.2.6)(react@18.2.0): + resolution: {integrity: sha512-ISXsHDiQ3z1XA4IuKn+iXDWvDjcz/UcQBiFZqtdoIsEBt8CB7wgdQf3LwcwqO81dl5ofg/vNQBEnXuKfZHrnYA==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.22.10 + '@mui/utils': 5.14.4(react@18.2.0) + '@types/react': 18.2.6 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@mui/styled-engine@5.13.2(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(react@18.2.0): + resolution: {integrity: sha512-VCYCU6xVtXOrIN8lcbuPmoG+u7FYuOERG++fpY74hPpEWkyFQG97F+/XfTQVYzlR2m7nPjnwVUgATcTCMEaMvw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + dependencies: + '@babel/runtime': 7.22.10 + '@emotion/cache': 11.11.0 + '@emotion/react': 11.11.0(@types/react@18.2.6)(react@18.2.0) + '@emotion/styled': 11.11.0(@emotion/react@11.11.0)(@types/react@18.2.6)(react@18.2.0) + csstype: 3.1.2 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@mui/system@5.14.4(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react@18.2.0): + resolution: {integrity: sha512-oPgfWS97QNfHcDBapdkZIs4G5i85BJt69Hp6wbXF6s7vi3Evcmhdk8AbCRW6n0sX4vTj8oe0mh0RIm1G2A1KDA==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.22.10 + '@emotion/react': 11.11.0(@types/react@18.2.6)(react@18.2.0) + '@emotion/styled': 11.11.0(@emotion/react@11.11.0)(@types/react@18.2.6)(react@18.2.0) + '@mui/private-theming': 5.14.4(@types/react@18.2.6)(react@18.2.0) + '@mui/styled-engine': 5.13.2(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(react@18.2.0) + '@mui/types': 7.2.4(@types/react@18.2.6) + '@mui/utils': 5.14.4(react@18.2.0) + '@types/react': 18.2.6 + clsx: 2.0.0 + csstype: 3.1.2 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@mui/types@7.2.4(@types/react@18.2.6): + resolution: {integrity: sha512-LBcwa8rN84bKF+f5sDyku42w1NTxaPgPyYKODsh01U1fVstTClbUoSA96oyRBnSNyEiAVjKm6Gwx9vjR+xyqHA==} + peerDependencies: + '@types/react': '*' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.6 + dev: false + + /@mui/types@7.2.9(@types/react@18.2.6): + resolution: {integrity: sha512-k1lN/PolaRZfNsRdAqXtcR71sTnv3z/VCCGPxU8HfdftDkzi335MdJ6scZxvofMAd/K/9EbzCZTFBmlNpQVdCg==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.6 + dev: false + + /@mui/utils@5.12.3(react@18.2.0): + resolution: {integrity: sha512-D/Z4Ub3MRl7HiUccid7sQYclTr24TqUAQFFlxHQF8FR177BrCTQ0JJZom7EqYjZCdXhwnSkOj2ph685MSKNtIA==} + engines: {node: '>=12.0.0'} + peerDependencies: + react: ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.21.5 + '@types/prop-types': 15.7.5 + '@types/react-is': 17.0.4 + prop-types: 15.8.1 + react: 18.2.0 + react-is: 18.2.0 + dev: false + + /@mui/utils@5.14.18(@types/react@18.2.6)(react@18.2.0): + resolution: {integrity: sha512-HZDRsJtEZ7WMSnrHV9uwScGze4wM/Y+u6pDVo+grUjt5yXzn+wI8QX/JwTHh9YSw/WpnUL80mJJjgCnWj2VrzQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.4 + '@types/prop-types': 15.7.11 + '@types/react': 18.2.6 + prop-types: 15.8.1 + react: 18.2.0 + react-is: 18.2.0 + dev: false + + /@mui/utils@5.14.4(react@18.2.0): + resolution: {integrity: sha512-4ANV0txPD3x0IcTCSEHKDWnsutg1K3m6Vz5IckkbLXVYu17oOZCVUdOKsb/txUmaCd0v0PmSRe5PW+Mlvns5dQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + react: ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.22.10 + '@types/prop-types': 15.7.5 + '@types/react-is': 18.2.1 + prop-types: 15.8.1 + react: 18.2.0 + react-is: 18.2.0 + dev: false + + /@mui/x-date-pickers-pro@6.18.2(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@mui/material@5.13.0)(@mui/system@5.14.4)(@types/react@18.2.6)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-8lEVEOtCQssKWel4Ey1pRulGPXUQ73TnkHKzHWsjdv03FjiUs3eYB+Ej0Uk5yWPmsqlShWhOzOlOGDpzsYJsUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.9.0 + '@emotion/styled': ^11.8.1 + '@mui/material': ^5.8.6 + '@mui/system': ^5.8.0 + date-fns: ^2.25.0 + date-fns-jalali: ^2.13.0-0 + dayjs: ^1.10.7 + luxon: ^3.0.2 + moment: ^2.29.4 + moment-hijri: ^2.1.2 + moment-jalaali: ^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + date-fns: + optional: true + date-fns-jalali: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + moment-hijri: + optional: true + moment-jalaali: + optional: true + dependencies: + '@babel/runtime': 7.23.4 + '@emotion/react': 11.11.0(@types/react@18.2.6)(react@18.2.0) + '@emotion/styled': 11.11.0(@emotion/react@11.11.0)(@types/react@18.2.6)(react@18.2.0) + '@mui/base': 5.0.0-beta.24(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0) + '@mui/material': 5.13.0(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0) + '@mui/system': 5.14.4(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react@18.2.0) + '@mui/utils': 5.14.18(@types/react@18.2.6)(react@18.2.0) + '@mui/x-date-pickers': 6.18.2(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@mui/material@5.13.0)(@mui/system@5.14.4)(@types/react@18.2.6)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0) + '@mui/x-license-pro': 6.10.2(@types/react@18.2.6)(react@18.2.0) + clsx: 2.0.0 + dayjs: 1.11.9 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + dev: false + + /@mui/x-date-pickers@6.18.2(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@mui/material@5.13.0)(@mui/system@5.14.4)(@types/react@18.2.6)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-HJq4uoFQSu5isa/mesWw2BKh8KBRYUQb+KaSlVlWfJNgP3YhPvWZ6yqCNYyxOAiPMxb0n3nBjS9ErO27OHjFMA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.9.0 + '@emotion/styled': ^11.8.1 + '@mui/material': ^5.8.6 + '@mui/system': ^5.8.0 + date-fns: ^2.25.0 + date-fns-jalali: ^2.13.0-0 + dayjs: ^1.10.7 + luxon: ^3.0.2 + moment: ^2.29.4 + moment-hijri: ^2.1.2 + moment-jalaali: ^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + date-fns: + optional: true + date-fns-jalali: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + moment-hijri: + optional: true + moment-jalaali: + optional: true + dependencies: + '@babel/runtime': 7.23.4 + '@emotion/react': 11.11.0(@types/react@18.2.6)(react@18.2.0) + '@emotion/styled': 11.11.0(@emotion/react@11.11.0)(@types/react@18.2.6)(react@18.2.0) + '@mui/base': 5.0.0-beta.24(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0) + '@mui/material': 5.13.0(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0) + '@mui/system': 5.14.4(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@types/react@18.2.6)(react@18.2.0) + '@mui/utils': 5.14.18(@types/react@18.2.6)(react@18.2.0) + '@types/react-transition-group': 4.4.9 + clsx: 2.0.0 + dayjs: 1.11.9 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + dev: false + + /@mui/x-license-pro@6.10.2(@types/react@18.2.6)(react@18.2.0): + resolution: {integrity: sha512-Baw3shilU+eHgU+QYKNPFUKvfS5rSyNJ98pQx02E0gKA22hWp/XAt88K1qUfUMPlkPpvg/uci6gviQSSLZkuKw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.23.4 + '@mui/utils': 5.14.18(@types/react@18.2.6)(react@18.2.0) + react: 18.2.0 + transitivePeerDependencies: + - '@types/react' + dev: false + + /@nodelib/fs.scandir@2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + dev: true + + /@nodelib/fs.stat@2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + dev: true + + /@nodelib/fs.walk@1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.15.0 + dev: true + + /@popperjs/core@2.11.7: + resolution: {integrity: sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==} + dev: false + + /@popperjs/core@2.11.8: + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + /@reduxjs/toolkit@2.0.0(react-redux@8.0.5)(react@18.2.0): + resolution: {integrity: sha512-Kq/a+aO28adYdPoNEu9p800MYPKoUc0tlkYfv035Ief9J7MPq8JvmT7UdpYhvXsoMtOdt567KwZjc9H3Rf8yjg==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + dependencies: + immer: 10.0.3 + react: 18.2.0 + react-redux: 8.0.5(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1) + redux: 5.0.0 + redux-thunk: 3.1.0(redux@5.0.0) + reselect: 5.0.1 + dev: false + + /@remix-run/router@1.6.1: + resolution: {integrity: sha512-YUkWj+xs0oOzBe74OgErsuR3wVn+efrFhXBWrit50kOiED+pvQe2r6MWY0iJMQU/mSVKxvNzL4ZaYvjdX+G7ZA==} + engines: {node: '>=14'} + dev: false + + /@restart/hooks@0.4.15(react@18.2.0): + resolution: {integrity: sha512-cZFXYTxbpzYcieq/mBwSyXgqnGMHoBVh3J7MU0CCoIB4NRZxV9/TuwTBAaLMqpNhC3zTPMCgkQ5Ey07L02Xmcw==} + peerDependencies: + react: '>=16.8.0' + dependencies: + dequal: 2.0.3 + react: 18.2.0 + dev: false + + /@rollup/pluginutils@5.0.2: + resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.1 + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + + /@sinclair/typebox@0.25.24: + resolution: {integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==} + + /@sinclair/typebox@0.27.8: + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + /@sinonjs/commons@3.0.0: + resolution: {integrity: sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==} + dependencies: + type-detect: 4.0.8 + + /@sinonjs/fake-timers@10.1.0: + resolution: {integrity: sha512-w1qd368vtrwttm1PRJWPW1QHlbmHrVDGs1eBH/jZvRPUFS4MNXV9Q33EQdjOdeAxZ7O8+3wM7zxztm2nfUSyKw==} + dependencies: + '@sinonjs/commons': 3.0.0 + + /@slate-yjs/core@1.0.2(slate@0.101.4)(yjs@13.6.1): + resolution: {integrity: sha512-X0hLFJbQu9c1ItWBaNuEn0pqcXYK76KCp8C4Gvy/VaTQVMo1VgAb2WiiJ0Je/AyuIYEPPSTNVOcyrGHwgA7e6Q==} + peerDependencies: + slate: '>=0.70.0' + yjs: ^13.5.29 + dependencies: + slate: 0.101.4 + y-protocols: 1.0.6(yjs@13.6.1) + yjs: 13.6.1 + dev: false + + /@svgr/babel-plugin-add-jsx-attribute@7.0.0(@babel/core@7.21.8): + resolution: {integrity: sha512-khWbXesWIP9v8HuKCl2NU2HNAyqpSQ/vkIl36Nbn4HIwEYSRWL0H7Gs6idJdha2DkpFDWlsqMELvoCE8lfFY6Q==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + dev: true + + /@svgr/babel-plugin-remove-jsx-attribute@7.0.0(@babel/core@7.21.8): + resolution: {integrity: sha512-iiZaIvb3H/c7d3TH2HBeK91uI2rMhZNwnsIrvd7ZwGLkFw6mmunOCoVnjdYua662MqGFxlN9xTq4fv9hgR4VXQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + dev: true + + /@svgr/babel-plugin-remove-jsx-empty-expression@7.0.0(@babel/core@7.21.8): + resolution: {integrity: sha512-sQQmyo+qegBx8DfFc04PFmIO1FP1MHI1/QEpzcIcclo5OAISsOJPW76ZIs0bDyO/DBSJEa/tDa1W26pVtt0FRw==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + dev: true + + /@svgr/babel-plugin-replace-jsx-attribute-value@7.0.0(@babel/core@7.21.8): + resolution: {integrity: sha512-i6MaAqIZXDOJeikJuzocByBf8zO+meLwfQ/qMHIjCcvpnfvWf82PFvredEZElErB5glQFJa2KVKk8N2xV6tRRA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + dev: true + + /@svgr/babel-plugin-svg-dynamic-title@7.0.0(@babel/core@7.21.8): + resolution: {integrity: sha512-BoVSh6ge3SLLpKC0pmmN9DFlqgFy4NxNgdZNLPNJWBUU7TQpDWeBuyVuDW88iXydb5Cv0ReC+ffa5h3VrKfk1w==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + dev: true + + /@svgr/babel-plugin-svg-em-dimensions@7.0.0(@babel/core@7.21.8): + resolution: {integrity: sha512-tNDcBa+hYn0gO+GkP/AuNKdVtMufVhU9fdzu+vUQsR18RIJ9RWe7h/pSBY338RO08wArntwbDk5WhQBmhf2PaA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + dev: true + + /@svgr/babel-plugin-transform-react-native-svg@7.0.0(@babel/core@7.21.8): + resolution: {integrity: sha512-qw54u8ljCJYL2KtBOjI5z7Nzg8LnSvQOP5hPKj77H4VQL4+HdKbAT5pnkkZLmHKYwzsIHSYKXxHouD8zZamCFQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + dev: true + + /@svgr/babel-plugin-transform-svg-component@7.0.0(@babel/core@7.21.8): + resolution: {integrity: sha512-CcFECkDj98daOg9jE3Bh3uyD9kzevCAnZ+UtzG6+BQG/jOQ2OA3jHnX6iG4G1MCJkUQFnUvEv33NvQfqrb/F3A==} + engines: {node: '>=12'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + dev: true + + /@svgr/babel-preset@7.0.0(@babel/core@7.21.8): + resolution: {integrity: sha512-EX/NHeFa30j5UjldQGVQikuuQNHUdGmbh9kEpBKofGUtF0GUPJ4T4rhoYiqDAOmBOxojyot36JIFiDUHUK1ilQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.8 + '@svgr/babel-plugin-add-jsx-attribute': 7.0.0(@babel/core@7.21.8) + '@svgr/babel-plugin-remove-jsx-attribute': 7.0.0(@babel/core@7.21.8) + '@svgr/babel-plugin-remove-jsx-empty-expression': 7.0.0(@babel/core@7.21.8) + '@svgr/babel-plugin-replace-jsx-attribute-value': 7.0.0(@babel/core@7.21.8) + '@svgr/babel-plugin-svg-dynamic-title': 7.0.0(@babel/core@7.21.8) + '@svgr/babel-plugin-svg-em-dimensions': 7.0.0(@babel/core@7.21.8) + '@svgr/babel-plugin-transform-react-native-svg': 7.0.0(@babel/core@7.21.8) + '@svgr/babel-plugin-transform-svg-component': 7.0.0(@babel/core@7.21.8) + dev: true + + /@svgr/core@7.0.0: + resolution: {integrity: sha512-ztAoxkaKhRVloa3XydohgQQCb0/8x9T63yXovpmHzKMkHO6pkjdsIAWKOS4bE95P/2quVh1NtjSKlMRNzSBffw==} + engines: {node: '>=14'} + dependencies: + '@babel/core': 7.21.8 + '@svgr/babel-preset': 7.0.0(@babel/core@7.21.8) + camelcase: 6.3.0 + cosmiconfig: 8.2.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@svgr/hast-util-to-babel-ast@7.0.0: + resolution: {integrity: sha512-42Ej9sDDEmsJKjrfQ1PHmiDiHagh/u9AHO9QWbeNx4KmD9yS5d1XHmXUNINfUcykAU+4431Cn+k6Vn5mWBYimQ==} + engines: {node: '>=14'} + dependencies: + '@babel/types': 7.21.5 + entities: 4.5.0 + dev: true + + /@svgr/plugin-jsx@7.0.0: + resolution: {integrity: sha512-SWlTpPQmBUtLKxXWgpv8syzqIU8XgFRvyhfkam2So8b3BE0OS0HPe5UfmlJ2KIC+a7dpuuYovPR2WAQuSyMoPw==} + engines: {node: '>=14'} + dependencies: + '@babel/core': 7.21.8 + '@svgr/babel-preset': 7.0.0(@babel/core@7.21.8) + '@svgr/hast-util-to-babel-ast': 7.0.0 + svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color + dev: true + + /@svgr/plugin-svgo@8.0.1(@svgr/core@7.0.0): + resolution: {integrity: sha512-29OJ1QmJgnohQHDAgAuY2h21xWD6TZiXji+hnx+W635RiXTAlHTbjrZDktfqzkN0bOeQEtNe+xgq73/XeWFfSg==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + dependencies: + '@svgr/core': 7.0.0 + cosmiconfig: 8.2.0 + deepmerge: 4.3.1 + svgo: 3.0.2 + dev: true + + /@tauri-apps/api@1.3.0: + resolution: {integrity: sha512-AH+3FonkKZNtfRtGrObY38PrzEj4d+1emCbwNGu0V2ENbXjlLHMZQlUh+Bhu/CRmjaIwZMGJ3yFvWaZZgTHoog==} + engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'} + dev: false + + /@tauri-apps/cli-darwin-arm64@1.5.6: + resolution: {integrity: sha512-NNvG3XLtciCMsBahbDNUEvq184VZmOveTGOuy0So2R33b/6FDkuWaSgWZsR1mISpOuP034htQYW0VITCLelfqg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-darwin-x64@1.5.6: + resolution: {integrity: sha512-nkiqmtUQw3N1j4WoVjv81q6zWuZFhBLya/RNGUL94oafORloOZoSY0uTZJAoeieb3Y1YK0rCHSDl02MyV2Fi4A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-linux-arm-gnueabihf@1.5.6: + resolution: {integrity: sha512-z6SPx+axZexmWXTIVPNs4Tg7FtvdJl9EKxYN6JPjOmDZcqA13iyqWBQal2DA/GMZ1Xqo3vyJf6EoEaKaliymPQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-linux-arm64-gnu@1.5.6: + resolution: {integrity: sha512-QuQjMQmpsCbzBrmtQiG4uhnfAbdFx3nzm+9LtqjuZlurc12+Mj5MTgqQ3AOwQedH3f7C+KlvbqD2AdXpwTg7VA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-linux-arm64-musl@1.5.6: + resolution: {integrity: sha512-8j5dH3odweFeom7bRGlfzDApWVOT4jIq8/214Wl+JeiNVehouIBo9lZGeghZBH3XKFRwEvU23i7sRVjuh2s8mg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-linux-x64-gnu@1.5.6: + resolution: {integrity: sha512-gbFHYHfdEGW0ffk8SigDsoXks6USpilF6wR0nqB/JbWzbzFR/sBuLVNQlJl1RKNakyJHu+lsFxGy0fcTdoX8xA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-linux-x64-musl@1.5.6: + resolution: {integrity: sha512-9v688ogoLkeFYQNgqiSErfhTreLUd8B3prIBSYUt+x4+5Kcw91zWvIh+VSxL1n3KCGGsM7cuXhkGPaxwlEh1ug==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-win32-arm64-msvc@1.5.6: + resolution: {integrity: sha512-DRNDXFNZb6y5IZrw+lhTTA9l4wbzO4TNRBAlHAiXUrH+pRFZ/ZJtv5WEuAj9ocVSahVw2NaK5Yaold4NPAxHog==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-win32-ia32-msvc@1.5.6: + resolution: {integrity: sha512-oUYKNR/IZjF4fsOzRpw0xesl2lOjhsQEyWlgbpT25T83EU113Xgck9UjtI7xemNI/OPCv1tPiaM1e7/ABdg5iA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-win32-x64-msvc@1.5.6: + resolution: {integrity: sha512-RmEf1os9C8//uq2hbjXi7Vgz9ne7798ZxqemAZdUwo1pv3oLVZSz1/IvZmUHPdy2e6zSeySqWu1D0Y3QRNN+dg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli@1.5.6: + resolution: {integrity: sha512-k4Y19oVCnt7WZb2TnDzLqfs7o98Jq0tUoVMv+JQSzuRDJqaVu2xMBZ8dYplEn+EccdR5SOMyzaLBJWu38TVK1A==} + engines: {node: '>= 10'} + hasBin: true + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 1.5.6 + '@tauri-apps/cli-darwin-x64': 1.5.6 + '@tauri-apps/cli-linux-arm-gnueabihf': 1.5.6 + '@tauri-apps/cli-linux-arm64-gnu': 1.5.6 + '@tauri-apps/cli-linux-arm64-musl': 1.5.6 + '@tauri-apps/cli-linux-x64-gnu': 1.5.6 + '@tauri-apps/cli-linux-x64-musl': 1.5.6 + '@tauri-apps/cli-win32-arm64-msvc': 1.5.6 + '@tauri-apps/cli-win32-ia32-msvc': 1.5.6 + '@tauri-apps/cli-win32-x64-msvc': 1.5.6 + dev: true + + /@tootallnate/once@2.0.0: + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + dev: true + + /@trysound/sax@0.2.0: + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + dev: true + + /@tsconfig/node10@1.0.9: + resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} + dev: true + + /@tsconfig/node12@1.0.11: + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + dev: true + + /@tsconfig/node14@1.0.3: + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + dev: true + + /@tsconfig/node16@1.0.4: + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + dev: true + + /@types/babel__core@7.20.0: + resolution: {integrity: sha512-+n8dL/9GWblDO0iU6eZAwEIJVr5DWigtle+Q6HLOrh/pdbXOhOtqzq8VPPE2zvNJzSKY4vH/z3iT3tn0A3ypiQ==} + dependencies: + '@babel/parser': 7.21.8 + '@babel/types': 7.21.5 + '@types/babel__generator': 7.6.4 + '@types/babel__template': 7.4.1 + '@types/babel__traverse': 7.18.5 + + /@types/babel__generator@7.6.4: + resolution: {integrity: sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==} + dependencies: + '@babel/types': 7.21.5 + + /@types/babel__template@7.4.1: + resolution: {integrity: sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==} + dependencies: + '@babel/parser': 7.21.8 + '@babel/types': 7.21.5 + + /@types/babel__traverse@7.18.5: + resolution: {integrity: sha512-enCvTL8m/EHS/zIvJno9nE+ndYPh1/oNFzRYRmtUqJICG2VnCSBzMLW5VN2KCQU91f23tsNKR8v7VJJQMatl7Q==} + dependencies: + '@babel/types': 7.21.5 + + /@types/estree@1.0.1: + resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} + dev: true + + /@types/google-protobuf@3.15.12: + resolution: {integrity: sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==} + dev: true + + /@types/graceful-fs@4.1.6: + resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==} + dependencies: + '@types/node': 18.16.9 + + /@types/hoist-non-react-statics@3.3.1: + resolution: {integrity: sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==} + dependencies: + '@types/react': 18.2.6 + hoist-non-react-statics: 3.3.2 + dev: false + + /@types/is-hotkey@0.1.10: + resolution: {integrity: sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ==} + dev: false + + /@types/is-hotkey@0.1.7: + resolution: {integrity: sha512-yB5C7zcOM7idwYZZ1wKQ3pTfjA9BbvFqRWvKB46GFddxnJtHwi/b9y84ykQtxQPg5qhdpg4Q/kWU3EGoCTmLzQ==} + dev: true + + /@types/istanbul-lib-coverage@2.0.4: + resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==} + + /@types/istanbul-lib-report@3.0.0: + resolution: {integrity: sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==} + dependencies: + '@types/istanbul-lib-coverage': 2.0.4 + + /@types/istanbul-reports@3.0.1: + resolution: {integrity: sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==} + dependencies: + '@types/istanbul-lib-report': 3.0.0 + + /@types/jest@29.5.3: + resolution: {integrity: sha512-1Nq7YrO/vJE/FYnqYyw0FS8LdrjExSgIiHyKg7xPpn+yi8Q4huZryKnkJatN1ZRH89Kw2v33/8ZMB7DuZeSLlA==} + dependencies: + expect: 29.5.0 + pretty-format: 29.5.0 + dev: true + + /@types/jsdom@20.0.1: + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + dependencies: + '@types/node': 18.16.9 + '@types/tough-cookie': 4.0.2 + parse5: 7.1.2 + dev: true + + /@types/json-schema@7.0.11: + resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} + dev: true + + /@types/katex@0.16.0: + resolution: {integrity: sha512-hz+S3nV6Mym5xPbT9fnO8dDhBFQguMYpY0Ipxv06JMi1ORgnEM4M1ymWDUhUNer3ElLmT583opRo4RzxKmh9jw==} + dev: true + + /@types/lodash-es@4.17.11: + resolution: {integrity: sha512-eCw8FYAWHt2DDl77s+AMLLzPn310LKohruumpucZI4oOFJkIgnlaJcy23OKMJxx4r9PeTF13Gv6w+jqjWQaYUg==} + dependencies: + '@types/lodash': 4.14.194 + dev: true + + /@types/lodash@4.14.194: + resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==} + dev: true + + /@types/lodash@4.14.202: + resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} + dev: false + + /@types/node@18.16.9: + resolution: {integrity: sha512-IeB32oIV4oGArLrd7znD2rkHQ6EDCM+2Sr76dJnrHwv9OHBTTM6nuDLK9bmikXzPa0ZlWMWtRGo/Uw4mrzQedA==} + + /@types/parse-json@4.0.0: + resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} + dev: false + + /@types/prettier@2.7.2: + resolution: {integrity: sha512-KufADq8uQqo1pYKVIYzfKbJfBAc0sOeXqGbFaSpv8MRmC/zXgowNZmFcbngndGk922QDmOASEXUZCaY48gs4cg==} + + /@types/prismjs@1.26.0: + resolution: {integrity: sha512-ZTaqn/qSqUuAq1YwvOFQfVW1AR/oQJlLSZVustdjwI+GZ8kr0MSHBj0tsXPW1EqHubx50gtBEjbPGsdZwQwCjQ==} + dev: true + + /@types/prop-types@15.7.11: + resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} + dev: false + + /@types/prop-types@15.7.5: + resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} + + /@types/quill@2.0.10: + resolution: {integrity: sha512-L6OHONEj2v4NRbWQOsn7j1N0SyzhRR3M4g1M6j/uuIwIsIW2ShWHhwbqNvH8hSmVktzqu0lITfdnqVOQ4qkrhA==} + dependencies: + parchment: 1.1.4 + quill-delta: 4.2.2 + dev: true + + /@types/react-beautiful-dnd@13.1.4: + resolution: {integrity: sha512-4bIBdzOr0aavN+88q3C7Pgz+xkb7tz3whORYrmSj77wfVEMfiWiooIwVWFR7KM2e+uGTe5BVrXqSfb0aHeflJA==} + dependencies: + '@types/react': 18.2.6 + dev: true + + /@types/react-color@3.0.6: + resolution: {integrity: sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==} + dependencies: + '@types/react': 18.2.6 + '@types/reactcss': 1.2.6 + dev: true + + /@types/react-custom-scrollbars@4.0.13: + resolution: {integrity: sha512-t+15reWgAE1jXlrhaZoxjuH/SQf+EG0rzAzSCzTIkSiP5CDT7KhoExNPwIa6uUxtPkjc3gdW/ry7GetLEwCfGA==} + dependencies: + '@types/react': 18.2.6 + dev: true + + /@types/react-datepicker@4.19.3(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-85F9eKWu9fGiD9r4KVVMPYAdkJJswR3Wci9PvqplmB6T+D+VbUqPeKtifg96NZ4nEhufjehW+SX4JLrEWVplWw==} + dependencies: + '@popperjs/core': 2.11.8 + '@types/react': 18.2.6 + date-fns: 2.30.0 + react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0) + transitivePeerDependencies: + - react + - react-dom + dev: true + + /@types/react-dom@18.2.4: + resolution: {integrity: sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==} + dependencies: + '@types/react': 18.2.6 + + /@types/react-is@17.0.4: + resolution: {integrity: sha512-FLzd0K9pnaEvKz4D1vYxK9JmgQPiGk1lu23o1kqGsLeT0iPbRSF7b76+S5T9fD8aRa0B8bY7I/3DebEj+1ysBA==} + dependencies: + '@types/react': 17.0.59 + dev: false + + /@types/react-is@18.2.1: + resolution: {integrity: sha512-wyUkmaaSZEzFZivD8F2ftSyAfk6L+DfFliVj/mYdOXbVjRcS87fQJLTnhk6dRZPuJjI+9g6RZJO4PNCngUrmyw==} + dependencies: + '@types/react': 18.2.6 + dev: false + + /@types/react-katex@3.0.0: + resolution: {integrity: sha512-AiHHXh71a2M7Z6z1wj6iA23SkiRF9r0neHUdu8zjU/cT3MyLxDefYHbcceKhV/gjDEZgF3YaiNHyPNtoGUjPvg==} + dependencies: + '@types/react': 18.2.6 + dev: true + + /@types/react-redux@7.1.25: + resolution: {integrity: sha512-bAGh4e+w5D8dajd6InASVIyCo4pZLJ66oLb80F9OBLO1gKESbZcRCJpTT6uLXX+HAB57zw1WTdwJdAsewuTweg==} + dependencies: + '@types/hoist-non-react-statics': 3.3.1 + '@types/react': 18.2.6 + hoist-non-react-statics: 3.3.2 + redux: 4.2.1 + dev: false + + /@types/react-swipeable-views@0.13.4: + resolution: {integrity: sha512-hQV9Oq6oa+9HKdnGd43xkckElwf5dThOiegtQxqE7qX761oHhxnZO07fz6IsKSnUy9J3tzlRQBu3sNyvC8+kYw==} + dependencies: + '@types/react': 18.2.6 + dev: false + + /@types/react-transition-group@4.4.6: + resolution: {integrity: sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==} + dependencies: + '@types/react': 18.2.6 + + /@types/react-transition-group@4.4.9: + resolution: {integrity: sha512-ZVNmWumUIh5NhH8aMD9CR2hdW0fNuYInlocZHaZ+dgk/1K49j1w/HoAuK1ki+pgscQrOFRTlXeoURtuzEkV3dg==} + dependencies: + '@types/react': 18.2.6 + dev: false + + /@types/react-window@1.8.8: + resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==} + dependencies: + '@types/react': 18.2.6 + + /@types/react@17.0.59: + resolution: {integrity: sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g==} + dependencies: + '@types/prop-types': 15.7.5 + '@types/scheduler': 0.16.3 + csstype: 3.1.2 + dev: false + + /@types/react@18.2.6: + resolution: {integrity: sha512-wRZClXn//zxCFW+ye/D2qY65UsYP1Fpex2YXorHc8awoNamkMZSvBxwxdYVInsHOZZd2Ppq8isnSzJL5Mpf8OA==} + dependencies: + '@types/prop-types': 15.7.5 + '@types/scheduler': 0.16.3 + csstype: 3.1.2 + + /@types/reactcss@1.2.6: + resolution: {integrity: sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg==} + dependencies: + '@types/react': 18.2.6 + dev: true + + /@types/scheduler@0.16.3: + resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} + + /@types/semver@7.5.0: + resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} + dev: true + + /@types/stack-utils@2.0.1: + resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} + + /@types/strip-bom@3.0.0: + resolution: {integrity: sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==} + dev: true + + /@types/strip-json-comments@0.0.30: + resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} + dev: true + + /@types/tough-cookie@4.0.2: + resolution: {integrity: sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==} + dev: true + + /@types/use-sync-external-store@0.0.3: + resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==} + dev: false + + /@types/utf8@3.0.1: + resolution: {integrity: sha512-1EkWuw7rT3BMz2HpmcEOr/HL61mWNA6Ulr/KdbXR9AI0A55wD4Qfv8hizd8Q1DnknSIzzDvQmvvY/guvX7jjZA==} + dev: true + + /@types/uuid@9.0.1: + resolution: {integrity: sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==} + dev: true + + /@types/warning@3.0.3: + resolution: {integrity: sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==} + dev: false + + /@types/yargs-parser@21.0.0: + resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} + + /@types/yargs@17.0.24: + resolution: {integrity: sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==} + dependencies: + '@types/yargs-parser': 21.0.0 + + /@typescript-eslint/eslint-plugin@5.59.5(@typescript-eslint/parser@5.59.5)(eslint@8.40.0)(typescript@4.9.5): + resolution: {integrity: sha512-feA9xbVRWJZor+AnLNAr7A8JRWeZqHUf4T9tlP+TN04b05pFVhO5eN7/O93Y/1OUlLMHKbnJisgDURs/qvtqdg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + '@typescript-eslint/parser': ^5.0.0 + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@eslint-community/regexpp': 4.5.1 + '@typescript-eslint/parser': 5.59.5(eslint@8.40.0)(typescript@4.9.5) + '@typescript-eslint/scope-manager': 5.59.5 + '@typescript-eslint/type-utils': 5.59.5(eslint@8.40.0)(typescript@4.9.5) + '@typescript-eslint/utils': 5.59.5(eslint@8.40.0)(typescript@4.9.5) + debug: 4.3.4 + eslint: 8.40.0 + grapheme-splitter: 1.0.4 + ignore: 5.2.4 + natural-compare-lite: 1.4.0 + semver: 7.5.1 + tsutils: 3.21.0(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/parser@5.59.5(eslint@8.40.0)(typescript@4.9.5): + resolution: {integrity: sha512-NJXQC4MRnF9N9yWqQE2/KLRSOLvrrlZb48NGVfBa+RuPMN6B7ZcK5jZOvhuygv4D64fRKnZI4L4p8+M+rfeQuw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/scope-manager': 5.59.5 + '@typescript-eslint/types': 5.59.5 + '@typescript-eslint/typescript-estree': 5.59.5(typescript@4.9.5) + debug: 4.3.4 + eslint: 8.40.0 + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/scope-manager@5.59.5: + resolution: {integrity: sha512-jVecWwnkX6ZgutF+DovbBJirZcAxgxC0EOHYt/niMROf8p4PwxxG32Qdhj/iIQQIuOflLjNkxoXyArkcIP7C3A==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.59.5 + '@typescript-eslint/visitor-keys': 5.59.5 + dev: true + + /@typescript-eslint/type-utils@5.59.5(eslint@8.40.0)(typescript@4.9.5): + resolution: {integrity: sha512-4eyhS7oGym67/pSxA2mmNq7X164oqDYNnZCUayBwJZIRVvKpBCMBzFnFxjeoDeShjtO6RQBHBuwybuX3POnDqg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '*' + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/typescript-estree': 5.59.5(typescript@4.9.5) + '@typescript-eslint/utils': 5.59.5(eslint@8.40.0)(typescript@4.9.5) + debug: 4.3.4 + eslint: 8.40.0 + tsutils: 3.21.0(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/types@5.59.5: + resolution: {integrity: sha512-xkfRPHbqSH4Ggx4eHRIO/eGL8XL4Ysb4woL8c87YuAo8Md7AUjyWKa9YMwTL519SyDPrfEgKdewjkxNCVeJW7w==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@typescript-eslint/typescript-estree@5.59.5(typescript@4.9.5): + resolution: {integrity: sha512-+XXdLN2CZLZcD/mO7mQtJMvCkzRfmODbeSKuMY/yXbGkzvA9rJyDY5qDYNoiz2kP/dmyAxXquL2BvLQLJFPQIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 5.59.5 + '@typescript-eslint/visitor-keys': 5.59.5 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.5.1 + tsutils: 3.21.0(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/utils@5.59.5(eslint@8.40.0)(typescript@4.9.5): + resolution: {integrity: sha512-sCEHOiw+RbyTii9c3/qN74hYDPNORb8yWCoPLmB7BIflhplJ65u2PBpdRla12e3SSTJ2erRkPjz7ngLHhUegxA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.40.0) + '@types/json-schema': 7.0.11 + '@types/semver': 7.5.0 + '@typescript-eslint/scope-manager': 5.59.5 + '@typescript-eslint/types': 5.59.5 + '@typescript-eslint/typescript-estree': 5.59.5(typescript@4.9.5) + eslint: 8.40.0 + eslint-scope: 5.1.1 + semver: 7.5.1 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@typescript-eslint/visitor-keys@5.59.5: + resolution: {integrity: sha512-qL+Oz+dbeBRTeyJTIy0eniD3uvqU7x+y1QceBismZ41hd4aBSRh8UAw4pZP0+XzLuPZmx4raNMq/I+59W2lXKA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.59.5 + eslint-visitor-keys: 3.4.1 + dev: true + + /@vitejs/plugin-react@3.1.0(vite@4.3.5): + resolution: {integrity: sha512-AfgcRL8ZBhAlc3BFdigClmTUMISmmzHn7sB2h9U1odvc5U/MjWXsAaz18b/WoppUTDBzxOJwo2VdClfUcItu9g==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.1.0-beta.0 + dependencies: + '@babel/core': 7.21.8 + '@babel/plugin-transform-react-jsx-self': 7.21.0(@babel/core@7.21.8) + '@babel/plugin-transform-react-jsx-source': 7.19.6(@babel/core@7.21.8) + magic-string: 0.27.0 + react-refresh: 0.14.0 + vite: 4.3.5(@types/node@18.16.9)(sass@1.70.0) + transitivePeerDependencies: + - supports-color + dev: true + + /abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + dev: true + + /acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + dependencies: + acorn: 8.8.2 + acorn-walk: 8.2.0 + dev: true + + /acorn-jsx@5.3.2(acorn@8.8.2): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.8.2 + dev: true + + /acorn-walk@8.2.0: + resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} + engines: {node: '>=0.4.0'} + dev: true + + /acorn@8.8.2: + resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /add-px-to-style@1.0.0: + resolution: {integrity: sha512-YMyxSlXpPjD8uWekCQGuN40lV4bnZagUwqa2m/uFv1z/tNImSk9fnXVMUI5qwME/zzI3MMQRvjZ+69zyfSSyew==} + dev: false + + /agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + + /ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + dev: true + + /ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.21.3 + + /ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + /ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + dependencies: + color-convert: 1.9.3 + + /ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + + /ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + /any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + dev: true + + /anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + /arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + dev: true + + /arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + dev: true + + /argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + dependencies: + sprintf-js: 1.0.3 + + /argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + dev: true + + /array-buffer-byte-length@1.0.0: + resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} + dependencies: + call-bind: 1.0.2 + is-array-buffer: 3.0.2 + dev: true + + /array-includes@3.1.6: + resolution: {integrity: sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + get-intrinsic: 1.2.1 + is-string: 1.0.7 + dev: true + + /array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + dev: true + + /array.prototype.flatmap@1.3.1: + resolution: {integrity: sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + es-shim-unscopables: 1.0.0 + dev: true + + /array.prototype.tosorted@1.1.1: + resolution: {integrity: sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + es-shim-unscopables: 1.0.0 + get-intrinsic: 1.2.1 + dev: true + + /asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + dev: true + + /autoprefixer@10.4.14(postcss@8.4.23): + resolution: {integrity: sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + dependencies: + browserslist: 4.21.5 + caniuse-lite: 1.0.30001487 + fraction.js: 4.2.0 + normalize-range: 0.1.2 + picocolors: 1.0.0 + postcss: 8.4.23 + postcss-value-parser: 4.2.0 + dev: true + + /available-typed-arrays@1.0.5: + resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} + engines: {node: '>= 0.4'} + dev: true + + /babel-jest@29.6.2(@babel/core@7.21.8): + resolution: {integrity: sha512-BYCzImLos6J3BH/+HvUCHG1dTf2MzmAB4jaVxHV+29RZLjR29XuYTmsf2sdDwkrb+FczkGo3kOhE7ga6sI0P4A==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + dependencies: + '@babel/core': 7.21.8 + '@jest/transform': 29.6.4 + '@types/babel__core': 7.20.0 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.5.0(@babel/core@7.21.8) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + /babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + dependencies: + '@babel/helper-plugin-utils': 7.21.5 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + /babel-plugin-jest-hoist@29.5.0: + resolution: {integrity: sha512-zSuuuAlTMT4mzLj2nPnUm6fsE6270vdOfnpbJ+RmruU75UhLFvL0N2NgI7xpeS7NaB6hGqmd5pVpGTDYvi4Q3w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/template': 7.20.7 + '@babel/types': 7.21.5 + '@types/babel__core': 7.20.0 + '@types/babel__traverse': 7.18.5 + + /babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + dependencies: + '@babel/runtime': 7.23.4 + cosmiconfig: 7.1.0 + resolve: 1.22.2 + dev: false + + /babel-preset-current-node-syntax@1.0.1(@babel/core@7.21.8): + resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.21.8 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.21.8) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.21.8) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.21.8) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.21.8) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.21.8) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.21.8) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.21.8) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.21.8) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.21.8) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.21.8) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.21.8) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.21.8) + + /babel-preset-jest@29.5.0(@babel/core@7.21.8): + resolution: {integrity: sha512-JOMloxOqdiBSxMAzjRaH023/vvcaSaec49zvg+2LmNsktC7ei39LTJGw02J+9uUtTZUq6xbLyJ4dxe9sSmIuAg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.21.8 + babel-plugin-jest-hoist: 29.5.0 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.21.8) + + /balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + /binary-extensions@2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + + /boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + dev: true + + /brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + /braces@3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + + /browserslist@4.21.5: + resolution: {integrity: sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001487 + electron-to-chromium: 1.4.394 + node-releases: 2.0.10 + update-browserslist-db: 1.0.11(browserslist@4.21.5) + + /bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + dependencies: + fast-json-stable-stringify: 2.1.0 + dev: true + + /bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + dependencies: + node-int64: 0.4.0 + + /buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + /call-bind@1.0.2: + resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} + dependencies: + function-bind: 1.1.1 + get-intrinsic: 1.2.1 + + /callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + /camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + dependencies: + pascal-case: 3.1.2 + tslib: 2.5.0 + dev: true + + /camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + dev: true + + /camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + /camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + /caniuse-lite@1.0.30001487: + resolution: {integrity: sha512-83564Z3yWGqXsh2vaH/mhXfEM0wX+NlBCm1jYHOb97TrTWJEmPTccZgeLTPBUUb0PNVo+oomb7wkimZBIERClA==} + + /capital-case@1.0.4: + resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} + dependencies: + no-case: 3.0.4 + tslib: 2.5.0 + upper-case-first: 2.0.2 + dev: true + + /chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + /chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + /change-case@4.1.2: + resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} + dependencies: + camel-case: 4.1.2 + capital-case: 1.0.4 + constant-case: 3.0.4 + dot-case: 3.0.4 + header-case: 2.0.4 + no-case: 3.0.4 + param-case: 3.0.4 + pascal-case: 3.1.2 + path-case: 3.0.4 + sentence-case: 3.0.4 + snake-case: 3.0.4 + tslib: 2.5.0 + dev: true + + /char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + /chokidar@3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.2 + + /ci-info@3.8.0: + resolution: {integrity: sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==} + engines: {node: '>=8'} + + /cjs-module-lexer@1.2.2: + resolution: {integrity: sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==} + + /classnames@2.3.2: + resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==} + dev: false + + /cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + /clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + dev: false + + /clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + dev: false + + /clsx@2.0.0: + resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} + engines: {node: '>=6'} + dev: false + + /co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + /collect-v8-coverage@1.0.1: + resolution: {integrity: sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==} + + /color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + dependencies: + color-name: 1.1.3 + + /color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + + /color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + /color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + /combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + dependencies: + delayed-stream: 1.0.0 + dev: true + + /commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + dev: true + + /commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + dev: true + + /commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + /compute-scroll-into-view@3.1.0: + resolution: {integrity: sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==} + dev: false + + /concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + /constant-case@3.0.4: + resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} + dependencies: + no-case: 3.0.4 + tslib: 2.5.0 + upper-case: 2.0.2 + dev: true + + /convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + /convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + /cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + dependencies: + '@types/parse-json': 4.0.0 + import-fresh: 3.3.0 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + dev: false + + /cosmiconfig@8.2.0: + resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==} + engines: {node: '>=14'} + dependencies: + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + dev: true + + /create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + dev: true + + /cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + /css-box-model@1.2.1: + resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} + dependencies: + tiny-invariant: 1.3.1 + dev: false + + /css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + dev: true + + /css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.0.2 + dev: true + + /css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.0.2 + dev: true + + /css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + dev: true + + /cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + dependencies: + css-tree: 2.2.1 + dev: true + + /cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + dev: true + + /cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + dev: true + + /cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + dependencies: + cssom: 0.3.8 + dev: true + + /csstype@3.1.2: + resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} + + /data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + dev: true + + /date-arithmetic@4.1.0: + resolution: {integrity: sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==} + dev: false + + /date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + dependencies: + '@babel/runtime': 7.23.4 + + /dayjs@1.11.9: + resolution: {integrity: sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==} + dev: false + + /debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + + /decimal.js@10.4.3: + resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + dev: true + + /dedent@0.7.0: + resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} + + /deep-equal@1.1.1: + resolution: {integrity: sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==} + dependencies: + is-arguments: 1.1.1 + is-date-object: 1.0.5 + is-regex: 1.1.4 + object-is: 1.1.5 + object-keys: 1.1.1 + regexp.prototype.flags: 1.5.0 + dev: false + + /deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dev: true + + /deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + /define-properties@1.2.0: + resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==} + engines: {node: '>= 0.4'} + dependencies: + has-property-descriptors: 1.0.0 + object-keys: 1.1.1 + + /delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dev: true + + /dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + dev: false + + /derive-valtio@0.1.0(valtio@1.12.1): + resolution: {integrity: sha512-OCg2UsLbXK7GmmpzMXhYkdO64vhJ1ROUUGaTFyHjVwEdMEcTTRj7W1TxLbSBxdY8QLBPCcp66MTyaSy0RpO17A==} + peerDependencies: + valtio: '*' + dependencies: + valtio: 1.12.1(@types/react@18.2.6)(react@18.2.0) + dev: false + + /detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + /didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dev: true + + /diff-sequences@29.4.3: + resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + /diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + dev: true + + /dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dependencies: + path-type: 4.0.0 + dev: true + + /direction@1.0.4: + resolution: {integrity: sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==} + hasBin: true + dev: false + + /dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dev: true + + /doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /dom-css@2.1.0: + resolution: {integrity: sha512-w9kU7FAbaSh3QKijL6n59ofAhkkmMJ31GclJIz/vyQdjogfyxcB6Zf8CZyibOERI5o0Hxz30VmJS7+7r5fEj2Q==} + dependencies: + add-px-to-style: 1.0.0 + prefix-style: 2.0.1 + to-camel-case: 1.0.0 + dev: false + + /dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dependencies: + '@babel/runtime': 7.23.4 + csstype: 3.1.2 + dev: false + + /dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + dev: true + + /domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + dev: true + + /domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + dependencies: + webidl-conversions: 7.0.0 + dev: true + + /domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + dev: true + + /domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dev: true + + /dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dependencies: + no-case: 3.0.4 + tslib: 2.5.0 + dev: true + + /dynamic-dedupe@0.3.0: + resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==} + dependencies: + xtend: 4.0.2 + dev: true + + /electron-to-chromium@1.4.394: + resolution: {integrity: sha512-0IbC2cfr8w5LxTz+nmn2cJTGafsK9iauV2r5A5scfzyovqLrxuLoxOHE5OBobP3oVIggJT+0JfKnw9sm87c8Hw==} + + /emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + /emoji-mart@5.5.2: + resolution: {integrity: sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==} + dev: false + + /emoji-regex@10.2.1: + resolution: {integrity: sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==} + dev: false + + /emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: true + + /error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + dependencies: + is-arrayish: 0.2.1 + + /es-abstract@1.21.2: + resolution: {integrity: sha512-y/B5POM2iBnIxCiernH1G7rC9qQoM77lLIMQLuob0zhp8C56Po81+2Nj0WFKnd0pNReDTnkYryc+zhOzpEIROg==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.0 + available-typed-arrays: 1.0.5 + call-bind: 1.0.2 + es-set-tostringtag: 2.0.1 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.5 + get-intrinsic: 1.2.1 + get-symbol-description: 1.0.0 + globalthis: 1.0.3 + gopd: 1.0.1 + has: 1.0.3 + has-property-descriptors: 1.0.0 + has-proto: 1.0.1 + has-symbols: 1.0.3 + internal-slot: 1.0.5 + is-array-buffer: 3.0.2 + is-callable: 1.2.7 + is-negative-zero: 2.0.2 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.2 + is-string: 1.0.7 + is-typed-array: 1.1.10 + is-weakref: 1.0.2 + object-inspect: 1.12.3 + object-keys: 1.1.1 + object.assign: 4.1.4 + regexp.prototype.flags: 1.5.0 + safe-regex-test: 1.0.0 + string.prototype.trim: 1.2.7 + string.prototype.trimend: 1.0.6 + string.prototype.trimstart: 1.0.6 + typed-array-length: 1.0.4 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.9 + dev: true + + /es-set-tostringtag@2.0.1: + resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.1 + has: 1.0.3 + has-tostringtag: 1.0.0 + dev: true + + /es-shim-unscopables@1.0.0: + resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==} + dependencies: + has: 1.0.3 + dev: true + + /es-to-primitive@1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + engines: {node: '>= 0.4'} + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + dev: true + + /esbuild@0.17.19: + resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.17.19 + '@esbuild/android-arm64': 0.17.19 + '@esbuild/android-x64': 0.17.19 + '@esbuild/darwin-arm64': 0.17.19 + '@esbuild/darwin-x64': 0.17.19 + '@esbuild/freebsd-arm64': 0.17.19 + '@esbuild/freebsd-x64': 0.17.19 + '@esbuild/linux-arm': 0.17.19 + '@esbuild/linux-arm64': 0.17.19 + '@esbuild/linux-ia32': 0.17.19 + '@esbuild/linux-loong64': 0.17.19 + '@esbuild/linux-mips64el': 0.17.19 + '@esbuild/linux-ppc64': 0.17.19 + '@esbuild/linux-riscv64': 0.17.19 + '@esbuild/linux-s390x': 0.17.19 + '@esbuild/linux-x64': 0.17.19 + '@esbuild/netbsd-x64': 0.17.19 + '@esbuild/openbsd-x64': 0.17.19 + '@esbuild/sunos-x64': 0.17.19 + '@esbuild/win32-arm64': 0.17.19 + '@esbuild/win32-ia32': 0.17.19 + '@esbuild/win32-x64': 0.17.19 + dev: true + + /escalade@3.1.1: + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + + /escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + /escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + /escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + /escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + dev: true + + /eslint-plugin-react-hooks@4.6.0(eslint@8.40.0): + resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + dependencies: + eslint: 8.40.0 + dev: true + + /eslint-plugin-react@7.32.2(eslint@8.40.0): + resolution: {integrity: sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + dependencies: + array-includes: 3.1.6 + array.prototype.flatmap: 1.3.1 + array.prototype.tosorted: 1.1.1 + doctrine: 2.1.0 + eslint: 8.40.0 + estraverse: 5.3.0 + jsx-ast-utils: 3.3.3 + minimatch: 3.1.2 + object.entries: 1.1.6 + object.fromentries: 2.0.6 + object.hasown: 1.1.2 + object.values: 1.1.6 + prop-types: 15.8.1 + resolve: 2.0.0-next.4 + semver: 6.3.0 + string.prototype.matchall: 4.0.8 + dev: true + + /eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + dev: true + + /eslint-scope@7.2.0: + resolution: {integrity: sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + dev: true + + /eslint-visitor-keys@3.4.1: + resolution: {integrity: sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /eslint@8.40.0: + resolution: {integrity: sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + hasBin: true + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.40.0) + '@eslint-community/regexpp': 4.5.1 + '@eslint/eslintrc': 2.0.3 + '@eslint/js': 8.40.0 + '@humanwhocodes/config-array': 0.11.8 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.4 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.0 + eslint-visitor-keys: 3.4.1 + espree: 9.5.2 + esquery: 1.5.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.20.0 + grapheme-splitter: 1.0.4 + ignore: 5.2.4 + import-fresh: 3.3.0 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-sdsl: 4.4.0 + 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.1 + strip-ansi: 6.0.1 + strip-json-comments: 3.1.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + dev: true + + /espree@9.5.2: + resolution: {integrity: sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + acorn: 8.8.2 + acorn-jsx: 5.3.2(acorn@8.8.2) + eslint-visitor-keys: 3.4.1 + dev: true + + /esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + /esquery@1.5.0: + resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + engines: {node: '>=0.10'} + dependencies: + estraverse: 5.3.0 + dev: true + + /esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + dependencies: + estraverse: 5.3.0 + dev: true + + /estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + dev: true + + /estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + dev: true + + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + + /esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + dev: true + + /eventemitter3@2.0.3: + resolution: {integrity: sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==} + dev: false + + /events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + dev: false + + /execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + /exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + /expect@29.5.0: + resolution: {integrity: sha512-yM7xqUrCO2JdpFo4XpM82t+PJBFybdqoQuJLDGeDX2ij8NZzqRHyu3Hp188/JX7SWqud+7t4MUdvcgGBICMHZg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/expect-utils': 29.5.0 + jest-get-type: 29.4.3 + jest-matcher-utils: 29.5.0 + jest-message-util: 29.5.0 + jest-util: 29.5.0 + + /extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + dev: false + + /fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + dev: true + + /fast-diff@1.1.2: + resolution: {integrity: sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==} + dev: false + + /fast-diff@1.2.0: + resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==} + dev: true + + /fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + dev: false + + /fast-glob@3.2.12: + resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + dev: true + + /fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + /fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + dev: true + + /fastq@1.15.0: + resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} + dependencies: + reusify: 1.0.4 + dev: true + + /fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + dependencies: + bser: 2.1.1 + + /file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flat-cache: 3.0.4 + dev: true + + /fill-range@7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + + /find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + dev: false + + /find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + /find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + dev: true + + /flat-cache@3.0.4: + resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flatted: 3.2.7 + rimraf: 3.0.2 + dev: true + + /flatted@3.2.7: + resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} + dev: true + + /for-each@0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + dependencies: + is-callable: 1.2.7 + dev: true + + /form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: true + + /fraction.js@4.2.0: + resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} + dev: true + + /fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.0 + dev: true + + /fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + /fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + optional: true + + /function-bind@1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + + /function.prototype.name@1.1.5: + resolution: {integrity: sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + functions-have-names: 1.2.3 + dev: true + + /functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + /gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + /get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + /get-intrinsic@1.2.1: + resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==} + dependencies: + function-bind: 1.1.1 + has: 1.0.3 + has-proto: 1.0.1 + has-symbols: 1.0.3 + + /get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + /get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + /get-symbol-description@1.0.0: + resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + dev: true + + /glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + + /glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob@7.1.6: + resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + /globalize@0.1.1: + resolution: {integrity: sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA==} + dev: false + + /globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + /globals@13.20.0: + resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.20.2 + dev: true + + /globalthis@1.0.3: + resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==} + engines: {node: '>= 0.4'} + dependencies: + define-properties: 1.2.0 + dev: true + + /globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.2.12 + ignore: 5.2.4 + merge2: 1.4.1 + slash: 3.0.0 + dev: true + + /goober@2.1.13(csstype@3.1.2): + resolution: {integrity: sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==} + peerDependencies: + csstype: ^3.0.10 + dependencies: + csstype: 3.1.2 + dev: false + + /google-protobuf@3.21.2: + resolution: {integrity: sha512-3MSOYFO5U9mPGikIYCzK0SaThypfGgS6bHqrUGXG3DPHCrb+txNqeEcns1W0lkGfk0rCyNXm7xB9rMxnCiZOoA==} + dev: false + + /gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + dependencies: + get-intrinsic: 1.2.1 + dev: true + + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + /grapheme-splitter@1.0.4: + resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + dev: true + + /has-bigints@1.0.2: + resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} + dev: true + + /has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + /has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + /has-property-descriptors@1.0.0: + resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==} + dependencies: + get-intrinsic: 1.2.1 + + /has-proto@1.0.1: + resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==} + engines: {node: '>= 0.4'} + + /has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + /has-tostringtag@1.0.0: + resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + + /has@1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + + /header-case@2.0.4: + resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} + dependencies: + capital-case: 1.0.4 + tslib: 2.5.0 + dev: true + + /hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + dependencies: + react-is: 16.13.1 + dev: false + + /html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + dependencies: + whatwg-encoding: 2.0.0 + dev: true + + /html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + /html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + dependencies: + void-elements: 3.1.0 + dev: false + + /http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + + /https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + + /human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + /i18next-browser-languagedetector@7.0.1: + resolution: {integrity: sha512-Pa5kFwaczXJAeHE56CHG2aWzFBMJNUNghf0Pm4SwSrEMps/PTKqW90EYWlIvhuYStf3Sn1K0vw+gH3+TLdkH1g==} + dependencies: + '@babel/runtime': 7.21.5 + dev: false + + /i18next-resources-to-backend@1.1.4: + resolution: {integrity: sha512-hMyr9AOmIea17AOaVe1srNxK/l3mbk81P7Uf3fdcjlw3ehZy3UNTd0OP3EEi6yu4J02kf9jzhCcjokz6AFlEOg==} + dependencies: + '@babel/runtime': 7.21.5 + dev: false + + /i18next@22.4.15: + resolution: {integrity: sha512-yYudtbFrrmWKLEhl6jvKUYyYunj4bTBCe2qIUYAxbXoPusY7YmdwPvOE6fx6UIfWvmlbCWDItr7wIs8KEBZ5Zg==} + dependencies: + '@babel/runtime': 7.21.5 + dev: false + + /iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: true + + /ignore@5.2.4: + resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} + engines: {node: '>= 4'} + dev: true + + /immer@10.0.3: + resolution: {integrity: sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==} + dev: false + + /immutable@4.3.4: + resolution: {integrity: sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==} + + /import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + /import-local@3.1.0: + resolution: {integrity: sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==} + engines: {node: '>=8'} + hasBin: true + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + /imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + /inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + /internal-slot@1.0.5: + resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.1 + has: 1.0.3 + side-channel: 1.0.4 + dev: true + + /invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + dependencies: + loose-envify: 1.4.0 + dev: false + + /is-arguments@1.1.1: + resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + dev: false + + /is-array-buffer@3.0.2: + resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + is-typed-array: 1.1.10 + dev: true + + /is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + /is-bigint@1.0.4: + resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} + dependencies: + has-bigints: 1.0.2 + dev: true + + /is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + + /is-boolean-object@1.1.2: + resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + dev: true + + /is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + dev: true + + /is-core-module@2.12.0: + resolution: {integrity: sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==} + dependencies: + has: 1.0.3 + + /is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + + /is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + /is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + /is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + /is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + + /is-hotkey@0.2.0: + resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==} + dev: false + + /is-negative-zero@2.0.2: + resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} + engines: {node: '>= 0.4'} + dev: true + + /is-number-object@1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + dev: true + + /is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + /is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + dev: true + + /is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + dev: false + + /is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + dev: true + + /is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + + /is-shared-array-buffer@1.0.2: + resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==} + dependencies: + call-bind: 1.0.2 + dev: true + + /is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + /is-string@1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + dev: true + + /is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + dev: true + + /is-typed-array@1.1.10: + resolution: {integrity: sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.5 + call-bind: 1.0.2 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.0 + dev: true + + /is-weakref@1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + dependencies: + call-bind: 1.0.2 + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + /isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + dev: false + + /istanbul-lib-coverage@3.2.0: + resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==} + engines: {node: '>=8'} + + /istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + dependencies: + '@babel/core': 7.21.8 + '@babel/parser': 7.21.8 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.0 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + + /istanbul-lib-report@3.0.0: + resolution: {integrity: sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==} + engines: {node: '>=8'} + dependencies: + istanbul-lib-coverage: 3.2.0 + make-dir: 3.1.0 + supports-color: 7.2.0 + + /istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + dependencies: + debug: 4.3.4 + istanbul-lib-coverage: 3.2.0 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + /istanbul-reports@3.1.5: + resolution: {integrity: sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==} + engines: {node: '>=8'} + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.0 + + /jest-changed-files@29.5.0: + resolution: {integrity: sha512-IFG34IUMUaNBIxjQXF/iu7g6EcdMrGRRxaUSw92I/2g2YC6vCdTltl4nHvt7Ci5nSJwXIkCu8Ka1DKF+X7Z1Ag==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + execa: 5.1.1 + p-limit: 3.1.0 + + /jest-circus@29.5.0: + resolution: {integrity: sha512-gq/ongqeQKAplVxqJmbeUOJJKkW3dDNPY8PjhJ5G0lBRvu0e3EWGxGy5cI4LAGA7gV2UHCtWBI4EMXK8c9nQKA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.6.4 + '@jest/expect': 29.5.0 + '@jest/test-result': 29.5.0 + '@jest/types': 29.6.3 + '@types/node': 18.16.9 + chalk: 4.1.2 + co: 4.6.0 + dedent: 0.7.0 + is-generator-fn: 2.1.0 + jest-each: 29.5.0 + jest-matcher-utils: 29.5.0 + jest-message-util: 29.6.3 + jest-runtime: 29.5.0 + jest-snapshot: 29.5.0 + jest-util: 29.6.3 + p-limit: 3.1.0 + pretty-format: 29.5.0 + pure-rand: 6.0.2 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - supports-color + + /jest-cli@29.5.0(@types/node@18.16.9): + resolution: {integrity: sha512-L1KcP1l4HtfwdxXNFCL5bmUbLQiKrakMUriBEcc1Vfz6gx31ORKdreuWvmQVBit+1ss9NNR3yxjwfwzZNdQXJw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 29.5.0 + '@jest/test-result': 29.5.0 + '@jest/types': 29.5.0 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + import-local: 3.1.0 + jest-config: 29.5.0(@types/node@18.16.9) + jest-util: 29.5.0 + jest-validate: 29.5.0 + prompts: 2.4.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - supports-color + - ts-node + + /jest-config@29.5.0(@types/node@18.16.9): + resolution: {integrity: sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA==} + 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 + dependencies: + '@babel/core': 7.21.8 + '@jest/test-sequencer': 29.5.0 + '@jest/types': 29.5.0 + '@types/node': 18.16.9 + babel-jest: 29.6.2(@babel/core@7.21.8) + chalk: 4.1.2 + ci-info: 3.8.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.5.0 + jest-environment-node: 29.5.0 + jest-get-type: 29.4.3 + jest-regex-util: 29.4.3 + jest-resolve: 29.5.0 + jest-runner: 29.5.0 + jest-util: 29.5.0 + jest-validate: 29.5.0 + micromatch: 4.0.5 + parse-json: 5.2.0 + pretty-format: 29.5.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + /jest-diff@29.5.0: + resolution: {integrity: sha512-LtxijLLZBduXnHSniy0WMdaHjmQnt3g5sa16W4p0HqukYTTsyTW3GD1q41TyGl5YFXj/5B2U6dlh5FM1LIMgxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + diff-sequences: 29.4.3 + jest-get-type: 29.4.3 + pretty-format: 29.5.0 + + /jest-docblock@29.4.3: + resolution: {integrity: sha512-fzdTftThczeSD9nZ3fzA/4KkHtnmllawWrXO69vtI+L9WjEIuXWs4AmyME7lN5hU7dB0sHhuPfcKofRsUb/2Fg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + detect-newline: 3.1.0 + + /jest-each@29.5.0: + resolution: {integrity: sha512-HM5kIJ1BTnVt+DQZ2ALp3rzXEl+g726csObrW/jpEGl+CDSSQpOJJX2KE/vEg8cxcMXdyEPu6U4QX5eruQv5hA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.4.3 + jest-util: 29.6.3 + pretty-format: 29.5.0 + + /jest-environment-jsdom@29.6.2: + resolution: {integrity: sha512-7oa/+266AAEgkzae8i1awNEfTfjwawWKLpiw2XesZmaoVVj9u9t8JOYx18cG29rbPNtkUlZ8V4b5Jb36y/VxoQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + '@jest/environment': 29.6.4 + '@jest/fake-timers': 29.6.4 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 18.16.9 + jest-mock: 29.6.3 + jest-util: 29.6.3 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + + /jest-environment-node@29.5.0: + resolution: {integrity: sha512-ExxuIK/+yQ+6PRGaHkKewYtg6hto2uGCgvKdb2nfJfKXgZ17DfXjvbZ+jA1Qt9A8EQSfPnt5FKIfnOO3u1h9qw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.6.4 + '@jest/fake-timers': 29.6.4 + '@jest/types': 29.6.3 + '@types/node': 18.16.9 + jest-mock: 29.6.3 + jest-util: 29.6.3 + + /jest-get-type@29.4.3: + resolution: {integrity: sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + /jest-haste-map@29.5.0: + resolution: {integrity: sha512-IspOPnnBro8YfVYSw6yDRKh/TiCdRngjxeacCps1cQ9cgVN6+10JUcuJ1EabrgYLOATsIAigxA0rLR9x/YlrSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.5.0 + '@types/graceful-fs': 4.1.6 + '@types/node': 18.16.9 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.4.3 + jest-util: 29.5.0 + jest-worker: 29.5.0 + micromatch: 4.0.5 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.2 + + /jest-haste-map@29.6.4: + resolution: {integrity: sha512-12Ad+VNTDHxKf7k+M65sviyynRoZYuL1/GTuhEVb8RYsNSNln71nANRb/faSyWvx0j+gHcivChXHIoMJrGYjog==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.6 + '@types/node': 18.16.9 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.6.3 + jest-worker: 29.6.4 + micromatch: 4.0.5 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.2 + + /jest-leak-detector@29.5.0: + resolution: {integrity: sha512-u9YdeeVnghBUtpN5mVxjID7KbkKE1QU4f6uUwuxiY0vYRi9BUCLKlPEZfDGR67ofdFmDz9oPAy2G92Ujrntmow==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + jest-get-type: 29.4.3 + pretty-format: 29.5.0 + + /jest-matcher-utils@29.5.0: + resolution: {integrity: sha512-lecRtgm/rjIK0CQ7LPQwzCs2VwW6WAahA55YBuI+xqmhm7LAaxokSB8C97yJeYyT+HvQkH741StzpU41wohhWw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + jest-diff: 29.5.0 + jest-get-type: 29.4.3 + pretty-format: 29.5.0 + + /jest-message-util@29.5.0: + resolution: {integrity: sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/code-frame': 7.21.4 + '@jest/types': 29.5.0 + '@types/stack-utils': 2.0.1 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.5 + pretty-format: 29.5.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + /jest-message-util@29.6.3: + resolution: {integrity: sha512-FtzaEEHzjDpQp51HX4UMkPZjy46ati4T5pEMyM6Ik48ztu4T9LQplZ6OsimHx7EuM9dfEh5HJa6D3trEftu3dA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/code-frame': 7.21.4 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.1 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.5 + pretty-format: 29.6.3 + slash: 3.0.0 + stack-utils: 2.0.6 + + /jest-mock@29.5.0: + resolution: {integrity: sha512-GqOzvdWDE4fAV2bWQLQCkujxYWL7RxjCnj71b5VhDAGOevB3qj3Ovg26A5NI84ZpODxyzaozXLOh2NCgkbvyaw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.5.0 + '@types/node': 18.16.9 + jest-util: 29.5.0 + + /jest-mock@29.6.3: + resolution: {integrity: sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/node': 18.16.9 + jest-util: 29.6.3 + + /jest-pnp-resolver@1.2.3(jest-resolve@29.5.0): + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + dependencies: + jest-resolve: 29.5.0 + + /jest-regex-util@29.4.3: + resolution: {integrity: sha512-O4FglZaMmWXbGHSQInfXewIsd1LMn9p3ZXB/6r4FOkyhX2/iP/soMG98jGvk/A3HAN78+5VWcBGO0BJAPRh4kg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + /jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + /jest-resolve-dependencies@29.5.0: + resolution: {integrity: sha512-sjV3GFr0hDJMBpYeUuGduP+YeCRbd7S/ck6IvL3kQ9cpySYKqcqhdLLC2rFwrcL7tz5vYibomBrsFYWkIGGjOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + jest-regex-util: 29.4.3 + jest-snapshot: 29.5.0 + transitivePeerDependencies: + - supports-color + + /jest-resolve@29.5.0: + resolution: {integrity: sha512-1TzxJ37FQq7J10jPtQjcc+MkCkE3GBpBecsSUWJ0qZNJpmg6m0D9/7II03yJulm3H/fvVjgqLh/k2eYg+ui52w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.5.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.5.0) + jest-util: 29.5.0 + jest-validate: 29.5.0 + resolve: 1.22.2 + resolve.exports: 2.0.2 + slash: 3.0.0 + + /jest-runner@29.5.0: + resolution: {integrity: sha512-m7b6ypERhFghJsslMLhydaXBiLf7+jXy8FwGRHO3BGV1mcQpPbwiqiKUR2zU2NJuNeMenJmlFZCsIqzJCTeGLQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/console': 29.5.0 + '@jest/environment': 29.5.0 + '@jest/test-result': 29.5.0 + '@jest/transform': 29.5.0 + '@jest/types': 29.5.0 + '@types/node': 18.16.9 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.4.3 + jest-environment-node: 29.5.0 + jest-haste-map: 29.5.0 + jest-leak-detector: 29.5.0 + jest-message-util: 29.5.0 + jest-resolve: 29.5.0 + jest-runtime: 29.5.0 + jest-util: 29.5.0 + jest-watcher: 29.5.0 + jest-worker: 29.5.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + /jest-runtime@29.5.0: + resolution: {integrity: sha512-1Hr6Hh7bAgXQP+pln3homOiEZtCDZFqwmle7Ew2j8OlbkIu6uE3Y/etJQG8MLQs3Zy90xrp2C0BRrtPHG4zryw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.5.0 + '@jest/fake-timers': 29.5.0 + '@jest/globals': 29.5.0 + '@jest/source-map': 29.4.3 + '@jest/test-result': 29.5.0 + '@jest/transform': 29.5.0 + '@jest/types': 29.5.0 + '@types/node': 18.16.9 + chalk: 4.1.2 + cjs-module-lexer: 1.2.2 + collect-v8-coverage: 1.0.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.5.0 + jest-message-util: 29.5.0 + jest-mock: 29.5.0 + jest-regex-util: 29.4.3 + jest-resolve: 29.5.0 + jest-snapshot: 29.5.0 + jest-util: 29.5.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + /jest-snapshot@29.5.0: + resolution: {integrity: sha512-x7Wolra5V0tt3wRs3/ts3S6ciSQVypgGQlJpz2rsdQYoUKxMxPNaoHMGJN6qAuPJqS+2iQ1ZUn5kl7HCyls84g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/core': 7.21.8 + '@babel/generator': 7.21.5 + '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.21.8) + '@babel/plugin-syntax-typescript': 7.21.4(@babel/core@7.21.8) + '@babel/traverse': 7.23.7 + '@babel/types': 7.21.5 + '@jest/expect-utils': 29.5.0 + '@jest/transform': 29.5.0 + '@jest/types': 29.5.0 + '@types/babel__traverse': 7.18.5 + '@types/prettier': 2.7.2 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.21.8) + chalk: 4.1.2 + expect: 29.5.0 + graceful-fs: 4.2.11 + jest-diff: 29.5.0 + jest-get-type: 29.4.3 + jest-matcher-utils: 29.5.0 + jest-message-util: 29.5.0 + jest-util: 29.5.0 + natural-compare: 1.4.0 + pretty-format: 29.5.0 + semver: 7.5.1 + transitivePeerDependencies: + - supports-color + + /jest-util@29.5.0: + resolution: {integrity: sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.5.0 + '@types/node': 18.16.9 + chalk: 4.1.2 + ci-info: 3.8.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + /jest-util@29.6.3: + resolution: {integrity: sha512-QUjna/xSy4B32fzcKTSz1w7YYzgiHrjjJjevdRf61HYk998R5vVMMNmrHESYZVDS5DSWs+1srPLPKxXPkeSDOA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/node': 18.16.9 + chalk: 4.1.2 + ci-info: 3.8.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + /jest-validate@29.5.0: + resolution: {integrity: sha512-pC26etNIi+y3HV8A+tUGr/lph9B18GnzSRAkPaaZJIE1eFdiYm6/CewuiJQ8/RlfHd1u/8Ioi8/sJ+CmbA+zAQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.5.0 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.4.3 + leven: 3.1.0 + pretty-format: 29.5.0 + + /jest-watcher@29.5.0: + resolution: {integrity: sha512-KmTojKcapuqYrKDpRwfqcQ3zjMlwu27SYext9pt4GlF5FUgB+7XE1mcCnSm6a4uUpFyQIkb6ZhzZvHl+jiBCiA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/test-result': 29.5.0 + '@jest/types': 29.5.0 + '@types/node': 18.16.9 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.5.0 + string-length: 4.0.2 + + /jest-worker@29.5.0: + resolution: {integrity: sha512-NcrQnevGoSp4b5kg+akIpthoAFHxPBcb5P6mYPY0fUNT+sSvmtu6jlkEle3anczUKIKEbMxFimk9oTP/tpIPgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@types/node': 18.16.9 + jest-util: 29.5.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + /jest-worker@29.6.4: + resolution: {integrity: sha512-6dpvFV4WjcWbDVGgHTWo/aupl8/LbBx2NSKfiwqf79xC/yeJjKHT1+StcKy/2KTmW16hE68ccKVOtXf+WZGz7Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@types/node': 18.16.9 + jest-util: 29.6.3 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + /jest@29.5.0(@types/node@18.16.9): + resolution: {integrity: sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 29.5.0 + '@jest/types': 29.5.0 + import-local: 3.1.0 + jest-cli: 29.5.0(@types/node@18.16.9) + transitivePeerDependencies: + - '@types/node' + - supports-color + - ts-node + + /jiti@1.18.2: + resolution: {integrity: sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==} + hasBin: true + dev: true + + /js-base64@3.7.5: + resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==} + dev: false + + /js-sdsl@4.4.0: + resolution: {integrity: sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==} + dev: true + + /js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + /js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + /js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + dependencies: + argparse: 2.0.1 + dev: true + + /jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + abab: 2.0.6 + acorn: 8.8.2 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.4.3 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.0 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.7 + parse5: 7.1.2 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.3 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.14.1 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + + /jsesc@2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + + /json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + /json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + dev: true + + /json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + dev: true + + /json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + /jsonc-parser@3.2.0: + resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} + dev: true + + /jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + dependencies: + universalify: 2.0.0 + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + + /jsx-ast-utils@3.3.3: + resolution: {integrity: sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==} + engines: {node: '>=4.0'} + dependencies: + array-includes: 3.1.6 + object.assign: 4.1.4 + dev: true + + /katex@0.16.7: + resolution: {integrity: sha512-Xk9C6oGKRwJTfqfIbtr0Kes9OSv6IFsuhFGc7tW4urlpMJtuh+7YhzU6YEG9n8gmWKcMAFzkp7nr+r69kV0zrA==} + hasBin: true + dependencies: + commander: 8.3.0 + dev: false + + /keycode@2.2.1: + resolution: {integrity: sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==} + dev: false + + /kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + /leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + /levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + + /lib0@0.2.74: + resolution: {integrity: sha512-roj9i46/JwG5ik5KNTkxP2IytlnrssAkD/OhlAVtE+GqectrdkfR+pttszVLrOzMDeXNs1MPt6yo66MUolWSiA==} + engines: {node: '>=14'} + hasBin: true + dependencies: + isomorphic.js: 0.2.5 + dev: false + + /lib0@0.2.88: + resolution: {integrity: sha512-KyroiEvCeZcZEMx5Ys+b4u4eEBbA1ch7XUaBhYpwa/nPMrzTjUhI4RfcytmQfYoTBPcdyx+FX6WFNIoNuJzJfQ==} + engines: {node: '>=16'} + hasBin: true + dependencies: + isomorphic.js: 0.2.5 + dev: false + + /lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + dev: true + + /lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + /locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + dependencies: + p-locate: 4.1.0 + + /locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + dependencies: + p-locate: 5.0.0 + dev: true + + /lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + dev: false + + /lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + /lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + + /lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + dev: true + + /lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + dev: true + + /lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + /loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + + /lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + dependencies: + tslib: 2.5.0 + dev: true + + /lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + dependencies: + yallist: 3.1.1 + + /lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 + + /luxon@3.4.4: + resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==} + engines: {node: '>=12'} + dev: false + + /magic-string@0.27.0: + resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + dependencies: + semver: 6.3.0 + + /make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + dev: true + + /makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + dependencies: + tmpl: 1.0.5 + + /material-colors@1.2.6: + resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} + dev: false + + /mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + dev: true + + /mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + dev: true + + /memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + dev: false + + /memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + dev: false + + /merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + /merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + dev: true + + /micromatch@4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: true + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: true + + /mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + /minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: true + + /mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + dev: true + + /moment-timezone@0.5.44: + resolution: {integrity: sha512-nv3YpzI/8lkQn0U6RkLd+f0W/zy/JnoR5/EyPz/dNkPTBjA2jNLCVxaiQ8QpeLymhSZvX0wCL5s27NQWdOPwAw==} + dependencies: + moment: 2.30.1 + dev: false + + /moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + dev: false + + /ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + /mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + dev: true + + /nanoid@3.3.6: + resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /nanoid@4.0.2: + resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==} + engines: {node: ^14 || ^16 || >=18} + hasBin: true + dev: false + + /natural-compare-lite@1.4.0: + resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} + dev: true + + /natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + /no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + dependencies: + lower-case: 2.0.2 + tslib: 2.5.0 + dev: true + + /node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + /node-releases@2.0.10: + resolution: {integrity: sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==} + + /normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + /normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + dev: true + + /npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + dependencies: + path-key: 3.1.1 + + /nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + dependencies: + boolbase: 1.0.0 + dev: true + + /nwsapi@2.2.7: + resolution: {integrity: sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==} + dev: true + + /object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + /object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + dev: true + + /object-inspect@1.12.3: + resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} + dev: true + + /object-is@1.1.5: + resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + dev: false + + /object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + /object.assign@4.1.4: + resolution: {integrity: sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + has-symbols: 1.0.3 + object-keys: 1.1.1 + dev: true + + /object.entries@1.1.6: + resolution: {integrity: sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + dev: true + + /object.fromentries@2.0.6: + resolution: {integrity: sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + dev: true + + /object.hasown@1.1.2: + resolution: {integrity: sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==} + dependencies: + define-properties: 1.2.0 + es-abstract: 1.21.2 + dev: true + + /object.values@1.1.6: + resolution: {integrity: sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + dev: true + + /once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + dependencies: + wrappy: 1.0.2 + + /onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + dependencies: + mimic-fn: 2.1.0 + + /optionator@0.9.1: + resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==} + engines: {node: '>= 0.8.0'} + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.3 + dev: true + + /p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + dependencies: + p-try: 2.2.0 + + /p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + dependencies: + yocto-queue: 0.1.0 + + /p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + dependencies: + p-limit: 2.3.0 + + /p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + dependencies: + p-limit: 3.1.0 + dev: true + + /p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + /param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + dependencies: + dot-case: 3.0.4 + tslib: 2.5.0 + dev: true + + /parchment@1.1.4: + resolution: {integrity: sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==} + + /parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + dependencies: + callsites: 3.1.0 + + /parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + dependencies: + '@babel/code-frame': 7.21.4 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + /parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + dependencies: + entities: 4.5.0 + dev: true + + /pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + dependencies: + no-case: 3.0.4 + tslib: 2.5.0 + dev: true + + /path-case@3.0.4: + resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} + dependencies: + dot-case: 3.0.4 + tslib: 2.5.0 + dev: true + + /path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + /path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + /path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + /path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + /performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + dev: false + + /picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + + /picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + /pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + dev: true + + /pirates@4.0.5: + resolution: {integrity: sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==} + engines: {node: '>= 6'} + + /pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + dependencies: + find-up: 4.1.0 + + /postcss-import@15.1.0(postcss@8.4.23): + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + dependencies: + postcss: 8.4.23 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.2 + dev: true + + /postcss-js@4.0.1(postcss@8.4.23): + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.23 + dev: true + + /postcss-load-config@4.0.1(postcss@8.4.23): + resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + dependencies: + lilconfig: 2.1.0 + postcss: 8.4.23 + yaml: 2.2.2 + dev: true + + /postcss-nested@6.0.1(postcss@8.4.23): + resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + dependencies: + postcss: 8.4.23 + postcss-selector-parser: 6.0.12 + dev: true + + /postcss-selector-parser@6.0.12: + resolution: {integrity: sha512-NdxGCAZdRrwVI1sy59+Wzrh+pMMHxapGnpfenDVlMEXoOcvt4pGE0JLK9YY2F5dLxcFYA/YbVQKhcGU+FtSYQg==} + engines: {node: '>=4'} + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + dev: true + + /postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + dev: true + + /postcss@8.4.23: + resolution: {integrity: sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.6 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + + /prefix-style@2.0.1: + resolution: {integrity: sha512-gdr1MBNVT0drzTq95CbSNdsrBDoHGlb2aDJP/FoY+1e+jSDPOb1Cv554gH2MGiSr2WTcXi/zu+NaFzfcHQkfBQ==} + dev: false + + /prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + dev: true + + /prettier-plugin-tailwindcss@0.2.8(prettier@2.8.4): + resolution: {integrity: sha512-KgPcEnJeIijlMjsA6WwYgRs5rh3/q76oInqtMXBA/EMcamrcYJpyhtRhyX1ayT9hnHlHTuO8sIifHF10WuSDKg==} + engines: {node: '>=12.17.0'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@shufo/prettier-plugin-blade': '*' + '@trivago/prettier-plugin-sort-imports': '*' + prettier: '>=2.2.0' + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-import-sort: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-style-order: '*' + prettier-plugin-svelte: '*' + prettier-plugin-twig-melody: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@shufo/prettier-plugin-blade': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-import-sort: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-style-order: + optional: true + prettier-plugin-svelte: + optional: true + prettier-plugin-twig-melody: + optional: true + dependencies: + prettier: 2.8.4 + dev: true + + /prettier@2.8.4: + resolution: {integrity: sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw==} + engines: {node: '>=10.13.0'} + hasBin: true + dev: true + + /pretty-format@29.5.0: + resolution: {integrity: sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.4.3 + ansi-styles: 5.2.0 + react-is: 18.2.0 + + /pretty-format@29.6.3: + resolution: {integrity: sha512-ZsBgjVhFAj5KeK+nHfF1305/By3lechHQSMWCTl8iHSbfOm2TN5nHEtFc/+W7fAyUeCs2n5iow72gld4gW0xDw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.2.0 + + /prismjs@1.29.0: + resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} + engines: {node: '>=6'} + dev: false + + /prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + /prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + /protoc-gen-ts@0.8.7: + resolution: {integrity: sha512-jr4VJey2J9LVYCV7EVyVe53g1VMw28cCmYJhBe5e3YX5wiyiDwgxWxeDf9oTqAe4P1bN/YGAkW2jhlH8LohwiQ==} + hasBin: true + dev: false + + /proxy-compare@2.5.1: + resolution: {integrity: sha512-oyfc0Tx87Cpwva5ZXezSp5V9vht1c7dZBhvuV/y3ctkgMVUmiAGDVeeB0dKhGSyT0v1ZTEQYpe/RXlBVBNuCLA==} + dev: false + + /psl@1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + dev: true + + /punycode@2.3.0: + resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} + engines: {node: '>=6'} + dev: true + + /pure-rand@6.0.2: + resolution: {integrity: sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ==} + + /querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + dev: true + + /queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + dev: true + + /quill-delta@3.6.3: + resolution: {integrity: sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==} + engines: {node: '>=0.10'} + dependencies: + deep-equal: 1.1.1 + extend: 3.0.2 + fast-diff: 1.1.2 + dev: false + + /quill-delta@4.2.2: + resolution: {integrity: sha512-qjbn82b/yJzOjstBgkhtBjN2TNK+ZHP/BgUQO+j6bRhWQQdmj2lH6hXG7+nwwLF41Xgn//7/83lxs9n2BkTtTg==} + dependencies: + fast-diff: 1.2.0 + lodash.clonedeep: 4.5.0 + lodash.isequal: 4.5.0 + dev: true + + /quill-delta@5.1.0: + resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==} + engines: {node: '>= 12.0.0'} + dependencies: + fast-diff: 1.3.0 + lodash.clonedeep: 4.5.0 + lodash.isequal: 4.5.0 + dev: false + + /quill@1.3.7: + resolution: {integrity: sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==} + dependencies: + clone: 2.1.2 + deep-equal: 1.1.1 + eventemitter3: 2.0.3 + extend: 3.0.2 + parchment: 1.1.4 + quill-delta: 3.6.3 + dev: false + + /raf-schd@4.0.3: + resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} + dev: false + + /raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + dependencies: + performance-now: 2.1.0 + dev: false + + /react-beautiful-dnd@13.1.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==} + peerDependencies: + react: ^16.8.5 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.21.5 + css-box-model: 1.2.1 + memoize-one: 5.2.1 + raf-schd: 4.0.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-redux: 7.2.9(react-dom@18.2.0)(react@18.2.0) + redux: 4.2.1 + use-memo-one: 1.1.3(react@18.2.0) + transitivePeerDependencies: + - react-native + dev: false + + /react-big-calendar@1.8.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-cra8WPfoTSQthFfqxi0k9xm/Shv5jWSw19LkNzpSJcnQhP6XGes/eJjd8P8g/iwaJjXIWPpg3+HB5wO5wabRyA==} + peerDependencies: + react: ^16.14.0 || ^17 || ^18 + react-dom: ^16.14.0 || ^17 || ^18 + dependencies: + '@babel/runtime': 7.23.4 + clsx: 1.2.1 + date-arithmetic: 4.1.0 + dayjs: 1.11.9 + dom-helpers: 5.2.1 + globalize: 0.1.1 + invariant: 2.2.4 + lodash: 4.17.21 + lodash-es: 4.17.21 + luxon: 3.4.4 + memoize-one: 6.0.0 + moment: 2.30.1 + moment-timezone: 0.5.44 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-overlays: 5.2.1(react-dom@18.2.0)(react@18.2.0) + uncontrollable: 7.2.1(react@18.2.0) + dev: false + + /react-color@2.19.3(react@18.2.0): + resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==} + peerDependencies: + react: '*' + dependencies: + '@icons/material': 0.2.4(react@18.2.0) + lodash: 4.17.21 + lodash-es: 4.17.21 + material-colors: 1.2.6 + prop-types: 15.8.1 + react: 18.2.0 + reactcss: 1.2.3(react@18.2.0) + tinycolor2: 1.6.0 + dev: false + + /react-custom-scrollbars@4.2.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-VtJTUvZ7kPh/auZWIbBRceGPkE30XBYe+HktFxuMWBR2eVQQ+Ur6yFJMoaYcNpyGq22uYJ9Wx4UAEcC0K+LNPQ==} + peerDependencies: + react: ^0.14.0 || ^15.0.0 || ^16.0.0 + react-dom: ^0.14.0 || ^15.0.0 || ^16.0.0 + dependencies: + dom-css: 2.1.0 + prop-types: 15.8.1 + raf: 3.4.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-datepicker@4.23.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-w+msqlOZ14v6H1UknTKtZw/dw9naFMgAOspf59eY130gWpvy5dvKj/bgsFICDdvxB7PtKWxDcbGlAqCloY1d2A==} + peerDependencies: + react: ^16.9.0 || ^17 || ^18 + react-dom: ^16.9.0 || ^17 || ^18 + dependencies: + '@popperjs/core': 2.11.8 + classnames: 2.3.2 + date-fns: 2.30.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-onclickoutside: 6.13.0(react-dom@18.2.0)(react@18.2.0) + react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0) + dev: false + + /react-dom@18.2.0(react@18.2.0): + resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + peerDependencies: + react: ^18.2.0 + dependencies: + loose-envify: 1.4.0 + react: 18.2.0 + scheduler: 0.23.0 + + /react-error-boundary@3.1.4(react@18.2.0): + resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.21.5 + react: 18.2.0 + dev: false + + /react-event-listener@0.6.6(react@18.2.0): + resolution: {integrity: sha512-+hCNqfy7o9wvO6UgjqFmBzARJS7qrNoda0VqzvOuioEpoEXKutiKuv92dSz6kP7rYLmyHPyYNLesi5t/aH1gfw==} + peerDependencies: + react: ^16.3.0 + dependencies: + '@babel/runtime': 7.23.4 + prop-types: 15.8.1 + react: 18.2.0 + warning: 4.0.3 + dev: false + + /react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + /react-hot-toast@2.4.1(csstype@3.1.2)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + dependencies: + goober: 2.1.13(csstype@3.1.2) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - csstype + dev: false + + /react-i18next@12.2.2(i18next@22.4.15)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-KBB6buBmVKXUWNxXHdnthp+38gPyBT46hJCAIQ8rX19NFL/m2ahte2KARfIDf2tMnSAL7wwck6eDOd/9zn6aFg==} + peerDependencies: + i18next: '>= 19.0.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + dependencies: + '@babel/runtime': 7.21.5 + html-parse-stringify: 3.0.1 + i18next: 22.4.15 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + /react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + dev: false + + /react-is@18.2.0: + resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + + /react-katex@3.0.1(prop-types@15.8.1)(react@18.2.0): + resolution: {integrity: sha512-wIUW1fU5dHlkKvq4POfDkHruQsYp3fM8xNb/jnc8dnQ+nNCnaj0sx5pw7E6UyuEdLRyFKK0HZjmXBo+AtXXy0A==} + peerDependencies: + prop-types: ^15.8.1 + react: '>=15.3.2 <=18' + dependencies: + katex: 0.16.7 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /react-lifecycles-compat@3.0.4: + resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} + dev: false + + /react-onclickoutside@6.13.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ty8So6tcUpIb+ZE+1HAhbLROvAIJYyJe/1vRrrcmW+jLsaM+/powDRqxzo6hSh9CuRZGSL1Q8mvcF5WRD93a0A==} + peerDependencies: + react: ^15.5.x || ^16.x || ^17.x || ^18.x + react-dom: ^15.5.x || ^16.x || ^17.x || ^18.x + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-overlays@5.2.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==} + peerDependencies: + react: '>=16.3.0' + react-dom: '>=16.3.0' + dependencies: + '@babel/runtime': 7.23.4 + '@popperjs/core': 2.11.8 + '@restart/hooks': 0.4.15(react@18.2.0) + '@types/warning': 3.0.3 + dom-helpers: 5.2.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + uncontrollable: 7.2.1(react@18.2.0) + warning: 4.0.3 + dev: false + + /react-popper@2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==} + peerDependencies: + '@popperjs/core': ^2.0.0 + react: ^16.8.0 || ^17 || ^18 + react-dom: ^16.8.0 || ^17 || ^18 + dependencies: + '@popperjs/core': 2.11.8 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-fast-compare: 3.2.2 + warning: 4.0.3 + + /react-redux@7.2.9(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==} + peerDependencies: + react: ^16.8.3 || ^17 || ^18 + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + dependencies: + '@babel/runtime': 7.23.4 + '@types/react-redux': 7.1.25 + hoist-non-react-statics: 3.3.2 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 17.0.2 + dev: false + + /react-redux@8.0.5(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1): + resolution: {integrity: sha512-Q2f6fCKxPFpkXt1qNRZdEDLlScsDWyrgSj0mliK59qU6W5gvBiKkdMEG2lJzhd1rCctf0hb6EtePPLZ2e0m1uw==} + peerDependencies: + '@types/react': ^16.8 || ^17.0 || ^18.0 + '@types/react-dom': ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + react-native: '>=0.59' + redux: ^4 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + react-dom: + optional: true + react-native: + optional: true + redux: + optional: true + dependencies: + '@babel/runtime': 7.21.5 + '@types/hoist-non-react-statics': 3.3.1 + '@types/react': 18.2.6 + '@types/react-dom': 18.2.4 + '@types/use-sync-external-store': 0.0.3 + hoist-non-react-statics: 3.3.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 18.2.0 + redux: 4.2.1 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false + + /react-refresh@0.14.0: + resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} + engines: {node: '>=0.10.0'} + dev: true + + /react-router-dom@6.11.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-dPC2MhoPeTQ1YUOt5uIK376SMNWbwUxYRWk2ZmTT4fZfwlOvabF8uduRKKJIyfkCZvMgiF0GSCQckmkGGijIrg==} + engines: {node: '>=14'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + dependencies: + '@remix-run/router': 1.6.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-router: 6.11.1(react@18.2.0) + dev: false + + /react-router@6.11.1(react@18.2.0): + resolution: {integrity: sha512-OZINSdjJ2WgvAi7hgNLazrEV8SGn6xrKA+MkJe9wVDMZ3zQ6fdJocUjpCUCI0cNrelWjcvon0S/QK/j0NzL3KA==} + engines: {node: '>=14'} + peerDependencies: + react: '>=16.8' + dependencies: + '@remix-run/router': 1.6.1 + react: 18.2.0 + dev: false + + /react-swipeable-views-core@0.14.0: + resolution: {integrity: sha512-0W/e9uPweNEOSPjmYtuKSC/SvKKg1sfo+WtPdnxeLF3t2L82h7jjszuOHz9C23fzkvLfdgkaOmcbAxE9w2GEjA==} + engines: {node: '>=6.0.0'} + dependencies: + '@babel/runtime': 7.0.0 + warning: 4.0.3 + dev: false + + /react-swipeable-views-utils@0.14.0(react@18.2.0): + resolution: {integrity: sha512-W+fXBOsDqgFK1/g7MzRMVcDurp3LqO3ksC8UgInh2P/tKgb5DusuuB1geKHFc6o1wKl+4oyER4Zh3Lxmr8xbXA==} + engines: {node: '>=6.0.0'} + dependencies: + '@babel/runtime': 7.0.0 + keycode: 2.2.1 + prop-types: 15.8.1 + react-event-listener: 0.6.6(react@18.2.0) + react-swipeable-views-core: 0.14.0 + shallow-equal: 1.2.1 + transitivePeerDependencies: + - react + dev: false + + /react-swipeable-views@0.14.0(react@18.2.0): + resolution: {integrity: sha512-wrTT6bi2nC3JbmyNAsPXffUXLn0DVT9SbbcFr36gKpbaCgEp7rX/OFxsu5hPc/NBsUhHyoSRGvwqJNNrWTwCww==} + engines: {node: '>=6.0.0'} + peerDependencies: + react: ^15.3.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@babel/runtime': 7.0.0 + prop-types: 15.8.1 + react: 18.2.0 + react-swipeable-views-core: 0.14.0 + react-swipeable-views-utils: 0.14.0(react@18.2.0) + warning: 4.0.3 + dev: false + + /react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + dependencies: + '@babel/runtime': 7.21.5 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-virtualized-auto-sizer@1.0.20(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-OdIyHwj4S4wyhbKHOKM1wLSj/UDXm839Z3Cvfg2a9j+He6yDa6i5p0qQvEiCnyQlGO/HyfSnigQwuxvYalaAXA==} + peerDependencies: + react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc + react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-vtree@2.0.4(@types/react-window@1.8.8)(react-dom@18.2.0)(react-window@1.8.10)(react@18.2.0): + resolution: {integrity: sha512-UOld0VqyAZrryF06K753X4bcEVN6/wW831exvVlMZeZAVHk9KXnlHs4rpqDAeoiBgUwJqoW/rtn0hwsokRRxPA==} + peerDependencies: + '@types/react-window': ^1.8.2 + react: ^16.13.1 + react-dom: ^16.13.1 + react-window: ^1.8.5 + dependencies: + '@babel/runtime': 7.23.4 + '@types/react-window': 1.8.8 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-window: 1.8.10(react-dom@18.2.0)(react@18.2.0) + dev: false + + /react-window@1.8.10(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==} + engines: {node: '>8.0.0'} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.23.4 + memoize-one: 5.2.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react18-input-otp@1.1.3(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-55dZMVX61In2ngUhA4Fv0NMY4j5RZjxrJaSOAnJGJmkAhxKB6puVHYEmipyy2+W2CPydFF7pv+0NKzPUA03EVg==} + peerDependencies: + react: 16.2.0 - 18 + react-dom: 16.2.0 - 18 + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + + /reactcss@1.2.3(react@18.2.0): + resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==} + peerDependencies: + react: '*' + dependencies: + lodash: 4.17.21 + react: 18.2.0 + dev: false + + /read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + dependencies: + pify: 2.3.0 + dev: true + + /readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + + /redux-thunk@3.1.0(redux@5.0.0): + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + dependencies: + redux: 5.0.0 + dev: false + + /redux@4.2.1: + resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + dependencies: + '@babel/runtime': 7.21.5 + dev: false + + /redux@5.0.0: + resolution: {integrity: sha512-blLIYmYetpZMET6Q6uCY7Jtl/Im5OBldy+vNPauA8vvsdqyt66oep4EUpAMWNHauTC6xa9JuRPhRB72rY82QGA==} + dev: false + + /regenerator-runtime@0.12.1: + resolution: {integrity: sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==} + dev: false + + /regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + dev: false + + /regenerator-runtime@0.14.0: + resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==} + + /regexp.prototype.flags@1.5.0: + resolution: {integrity: sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + functions-have-names: 1.2.3 + + /require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + /requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + dev: true + + /reselect@5.0.1: + resolution: {integrity: sha512-D72j2ubjgHpvuCiORWkOUxndHJrxDaSolheiz5CO+roz8ka97/4msh2E8F5qay4GawR5vzBt5MkbDHT+Rdy/Wg==} + dev: false + + /resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + dependencies: + resolve-from: 5.0.0 + + /resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + /resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + /resolve.exports@2.0.2: + resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} + engines: {node: '>=10'} + + /resolve@1.22.2: + resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} + hasBin: true + dependencies: + is-core-module: 2.12.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + /resolve@2.0.0-next.4: + resolution: {integrity: sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==} + hasBin: true + dependencies: + is-core-module: 2.12.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + dev: true + + /rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rollup@3.21.7: + resolution: {integrity: sha512-KXPaEuR8FfUoK2uHwNjxTmJ18ApyvD6zJpYv9FOJSqLStmt6xOY84l1IjK2dSolQmoXknrhEFRaPRgOPdqCT5w==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + dev: true + + /rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + dependencies: + tslib: 2.5.0 + dev: false + + /safe-regex-test@1.0.0: + resolution: {integrity: sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + is-regex: 1.1.4 + dev: true + + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + dev: true + + /sass@1.70.0: + resolution: {integrity: sha512-uUxNQ3zAHeAx5nRFskBnrWzDUJrrvpCPD5FNAoRvTi0WwremlheES3tg+56PaVtCs5QDRX5CBLxxKMDJMEa1WQ==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + chokidar: 3.5.3 + immutable: 4.3.4 + source-map-js: 1.0.2 + + /saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + dependencies: + xmlchars: 2.2.0 + dev: true + + /scheduler@0.23.0: + resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} + dependencies: + loose-envify: 1.4.0 + + /scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + dependencies: + compute-scroll-into-view: 3.1.0 + dev: false + + /semver@6.3.0: + resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} + hasBin: true + + /semver@7.5.1: + resolution: {integrity: sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + + /semver@7.5.4: + resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: true + + /sentence-case@3.0.4: + resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} + dependencies: + no-case: 3.0.4 + tslib: 2.5.0 + upper-case-first: 2.0.2 + dev: true + + /shallow-equal@1.2.1: + resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} + dev: false + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + /side-channel@1.0.4: + resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + object-inspect: 1.12.3 + dev: true + + /signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + /sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + /slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + /slate-history@0.100.0(slate@0.101.4): + resolution: {integrity: sha512-x5rUuWLNtH97hs9PrFovGgt3Qc5zkTm/5mcUB+0NR/TK923eLax4HsL6xACLHMs245nI6aJElyM1y6hN0y5W/Q==} + peerDependencies: + slate: '>=0.65.3' + dependencies: + is-plain-object: 5.0.0 + slate: 0.101.4 + dev: false + + /slate-react@0.101.3(react-dom@18.2.0)(react@18.2.0)(slate@0.101.4): + resolution: {integrity: sha512-KMXK9FLeS7HYhhoVcI8SUi4Qp1I9C1lTQ2EgbPH95sVXfH/vq+hbhurEGIGCe0VQ9Opj4rSKJIv/g7De1+nJMA==} + peerDependencies: + react: '>=18.2.0' + react-dom: '>=18.2.0' + slate: '>=0.99.0' + dependencies: + '@juggle/resize-observer': 3.4.0 + '@types/is-hotkey': 0.1.10 + '@types/lodash': 4.14.202 + direction: 1.0.4 + is-hotkey: 0.2.0 + is-plain-object: 5.0.0 + lodash: 4.17.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + scroll-into-view-if-needed: 3.1.0 + slate: 0.101.4 + tiny-invariant: 1.3.1 + dev: false + + /slate@0.101.4: + resolution: {integrity: sha512-8LazZrNDsYFKDg1wpb0HouAfX5Pw/UmOZ/vIrtqD2GSCDZvraOkV2nVJ9Ery8kIlsU1jeybwgcaCy4KkVwfvEg==} + dependencies: + immer: 10.0.3 + is-plain-object: 5.0.0 + tiny-warning: 1.0.3 + dev: false + + /snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + dependencies: + dot-case: 3.0.4 + tslib: 2.5.0 + dev: true + + /source-map-js@1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + + /source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + /source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + dev: false + + /source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + /sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + /stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + dependencies: + escape-string-regexp: 2.0.0 + + /string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + /string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + /string.prototype.matchall@4.0.8: + resolution: {integrity: sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + get-intrinsic: 1.2.1 + has-symbols: 1.0.3 + internal-slot: 1.0.5 + regexp.prototype.flags: 1.5.0 + side-channel: 1.0.4 + dev: true + + /string.prototype.trim@1.2.7: + resolution: {integrity: sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + dev: true + + /string.prototype.trimend@1.0.6: + resolution: {integrity: sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + dev: true + + /string.prototype.trimstart@1.0.6: + resolution: {integrity: sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + dev: true + + /strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + dependencies: + ansi-regex: 5.0.1 + + /strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + dev: true + + /strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + /strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + /strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + dev: true + + /strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + /style-dictionary@3.8.0: + resolution: {integrity: sha512-wHlB/f5eO3mDcYv6WtOz6gvQC477jBKrwuIXe+PtHskTCBsJdAOvL8hCquczJxDui2TnwpeNE+2msK91JJomZg==} + engines: {node: '>=12.0.0'} + hasBin: true + dependencies: + chalk: 4.1.2 + change-case: 4.1.2 + commander: 8.3.0 + fs-extra: 10.1.0 + glob: 7.2.3 + json5: 2.2.3 + jsonc-parser: 3.2.0 + lodash: 4.17.21 + tinycolor2: 1.6.0 + dev: true + + /stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + dev: false + + /sucrase@3.32.0: + resolution: {integrity: sha512-ydQOU34rpSyj2TGyz4D2p8rbktIOZ8QY9s+DGLvFU1i5pWJE8vkpruCjGCMHsdXwnD7JDcS+noSwM/a7zyNFDQ==} + engines: {node: '>=8'} + hasBin: true + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + commander: 4.1.1 + glob: 7.1.6 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.5 + ts-interface-checker: 0.1.13 + dev: true + + /supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + dependencies: + has-flag: 3.0.0 + + /supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + + /supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + dependencies: + has-flag: 4.0.0 + + /supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + /svg-parser@2.0.4: + resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + dev: true + + /svgo@3.0.2: + resolution: {integrity: sha512-Z706C1U2pb1+JGP48fbazf3KxHrWOsLme6Rv7imFBn5EnuanDW1GPaA/P1/dvObE670JDePC3mnj0k0B7P0jjQ==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 5.1.0 + css-tree: 2.3.1 + csso: 5.0.5 + picocolors: 1.0.0 + dev: true + + /symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + dev: true + + /tailwindcss@3.3.2: + resolution: {integrity: sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.5.3 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.2.12 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.18.2 + lilconfig: 2.1.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.23 + postcss-import: 15.1.0(postcss@8.4.23) + postcss-js: 4.0.1(postcss@8.4.23) + postcss-load-config: 4.0.1(postcss@8.4.23) + postcss-nested: 6.0.1(postcss@8.4.23) + postcss-selector-parser: 6.0.12 + postcss-value-parser: 4.2.0 + resolve: 1.22.2 + sucrase: 3.32.0 + transitivePeerDependencies: + - ts-node + dev: true + + /test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + /text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + dev: true + + /thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + dependencies: + thenify: 3.3.1 + dev: true + + /thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + dependencies: + any-promise: 1.3.0 + dev: true + + /tiny-invariant@1.3.1: + resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} + dev: false + + /tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + dev: false + + /tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + + /tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + /to-camel-case@1.0.0: + resolution: {integrity: sha512-nD8pQi5H34kyu1QDMFjzEIYqk0xa9Alt6ZfrdEMuHCFOfTLhDG5pgTu/aAM9Wt9lXILwlXmWP43b8sav0GNE8Q==} + dependencies: + to-space-case: 1.0.0 + dev: false + + /to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + + /to-no-case@1.0.2: + resolution: {integrity: sha512-Z3g735FxuZY8rodxV4gH7LxClE4H0hTIyHNIHdk+vpQxjLm0cwnKXq/OFVZ76SOQmto7txVcwSCwkU5kqp+FKg==} + dev: false + + /to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + + /to-space-case@1.0.0: + resolution: {integrity: sha512-rLdvwXZ39VOn1IxGL3V6ZstoTbwLRckQmn/U8ZDLuWwIXNpuZDhQ3AiRUlhTbOXFVE9C+dR51wM0CBDhk31VcA==} + dependencies: + to-no-case: 1.0.2 + dev: false + + /tough-cookie@4.1.3: + resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==} + engines: {node: '>=6'} + dependencies: + psl: 1.9.0 + punycode: 2.3.0 + universalify: 0.2.0 + url-parse: 1.5.10 + dev: true + + /tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + dependencies: + punycode: 2.3.0 + dev: true + + /tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + dev: true + + /ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + dev: true + + /ts-jest@29.1.1(@babel/core@7.21.8)(babel-jest@29.6.2)(jest@29.5.0)(typescript@4.9.5): + resolution: {integrity: sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/types': ^29.0.0 + babel-jest: ^29.0.0 + esbuild: '*' + jest: ^29.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + dependencies: + '@babel/core': 7.21.8 + babel-jest: 29.6.2(@babel/core@7.21.8) + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + jest: 29.5.0(@types/node@18.16.9) + jest-util: 29.5.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.5.4 + typescript: 4.9.5 + yargs-parser: 21.1.1 + dev: true + + /ts-node-dev@2.0.0(@types/node@18.16.9)(typescript@4.9.5): + resolution: {integrity: sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==} + engines: {node: '>=0.8.0'} + hasBin: true + peerDependencies: + node-notifier: '*' + typescript: '*' + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + chokidar: 3.5.3 + dynamic-dedupe: 0.3.0 + minimist: 1.2.8 + mkdirp: 1.0.4 + resolve: 1.22.2 + rimraf: 2.7.1 + source-map-support: 0.5.13 + tree-kill: 1.2.2 + ts-node: 10.9.1(@types/node@18.16.9)(typescript@4.9.5) + tsconfig: 7.0.0 + typescript: 4.9.5 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + dev: true + + /ts-node@10.9.1(@types/node@18.16.9)(typescript@4.9.5): + resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.9 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 18.16.9 + acorn: 8.8.2 + acorn-walk: 8.2.0 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 4.9.5 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + dev: true + + /ts-results@3.3.0: + resolution: {integrity: sha512-FWqxGX2NHp5oCyaMd96o2y2uMQmSu8Dey6kvyuFdRJ2AzfmWo3kWa4UsPlCGlfQ/qu03m09ZZtppMoY8EMHuiA==} + dev: false + + /tsconfig-paths-jest@0.0.1: + resolution: {integrity: sha512-YKhUKqbteklNppC2NqL7dv1cWF8eEobgHVD5kjF1y9Q4ocqpBiaDlYslQ9eMhtbqIPRrA68RIEXqknEjlxdwxw==} + dev: true + + /tsconfig@7.0.0: + resolution: {integrity: sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==} + dependencies: + '@types/strip-bom': 3.0.0 + '@types/strip-json-comments': 0.0.30 + strip-bom: 3.0.0 + strip-json-comments: 2.0.1 + dev: true + + /tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + dev: true + + /tslib@2.5.0: + resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==} + + /tsutils@3.21.0(typescript@4.9.5): + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + 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' + dependencies: + tslib: 1.14.1 + typescript: 4.9.5 + dev: true + + /type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + dev: true + + /type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + /type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + dev: true + + /type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + /typed-array-length@1.0.4: + resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} + dependencies: + call-bind: 1.0.2 + for-each: 0.3.3 + is-typed-array: 1.1.10 + dev: true + + /typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + + /unbox-primitive@1.0.2: + resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + dependencies: + call-bind: 1.0.2 + has-bigints: 1.0.2 + has-symbols: 1.0.3 + which-boxed-primitive: 1.0.2 + dev: true + + /uncontrollable@7.2.1(react@18.2.0): + resolution: {integrity: sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==} + peerDependencies: + react: '>=15.0.0' + dependencies: + '@babel/runtime': 7.23.4 + '@types/react': 18.2.6 + invariant: 2.2.4 + react: 18.2.0 + react-lifecycles-compat: 3.0.4 + dev: false + + /universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + dev: true + + /universalify@2.0.0: + resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} + engines: {node: '>= 10.0.0'} + dev: true + + /unsplash-js@7.0.19: + resolution: {integrity: sha512-j6qT2floy5Q2g2d939FJpwey1yw/GpQecFiSouyJtsHQPj3oqmqq3K4rI+GF8vU1zwGCT7ZwIGQd2dtCQLjYJw==} + engines: {node: '>=10'} + dev: false + + /update-browserslist-db@1.0.11(browserslist@4.21.5): + resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.21.5 + escalade: 3.1.1 + picocolors: 1.0.0 + + /upper-case-first@2.0.2: + resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} + dependencies: + tslib: 2.5.0 + dev: true + + /upper-case@2.0.2: + resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} + dependencies: + tslib: 2.5.0 + dev: true + + /uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + dependencies: + punycode: 2.3.0 + dev: true + + /url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + dev: true + + /use-memo-one@1.1.3(react@18.2.0): + resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + + /use-sync-external-store@1.2.0(react@18.2.0): + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + + /utf8@3.0.0: + resolution: {integrity: sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==} + dev: false + + /util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + dev: true + + /uuid@9.0.0: + resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} + hasBin: true + dev: true + + /v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + dev: true + + /v8-to-istanbul@9.1.0: + resolution: {integrity: sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==} + engines: {node: '>=10.12.0'} + dependencies: + '@jridgewell/trace-mapping': 0.3.18 + '@types/istanbul-lib-coverage': 2.0.4 + convert-source-map: 1.9.0 + + /valtio@1.12.1(@types/react@18.2.6)(react@18.2.0): + resolution: {integrity: sha512-R0V4H86Xi2Pp7pmxN/EtV4Q6jr6PMN3t1IwxEvKUp6160r8FimvPh941oWyeK1iec/DTsh9Jb3Q+GputMS8SYg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=16.8' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + dependencies: + '@types/react': 18.2.6 + derive-valtio: 0.1.0(valtio@1.12.1) + proxy-compare: 2.5.1 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false + + /vite-plugin-svgr@3.2.0(vite@4.3.5): + resolution: {integrity: sha512-Uvq6niTvhqJU6ga78qLKBFJSDvxWhOnyfQSoKpDPMAGxJPo5S3+9hyjExE5YDj6Lpa4uaLkGc1cBgxXov+LjSw==} + peerDependencies: + vite: ^2.6.0 || 3 || 4 + dependencies: + '@rollup/pluginutils': 5.0.2 + '@svgr/core': 7.0.0 + '@svgr/plugin-jsx': 7.0.0 + vite: 4.3.5(@types/node@18.16.9)(sass@1.70.0) + transitivePeerDependencies: + - rollup + - supports-color + dev: true + + /vite@4.3.5(@types/node@18.16.9)(sass@1.70.0): + resolution: {integrity: sha512-0gEnL9wiRFxgz40o/i/eTBwm+NEbpUeTWhzKrZDSdKm6nplj+z4lKz8ANDgildxHm47Vg8EUia0aicKbawUVVA==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 18.16.9 + esbuild: 0.17.19 + postcss: 8.4.23 + rollup: 3.21.7 + sass: 1.70.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + dev: false + + /w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + dependencies: + xml-name-validator: 4.0.0 + dev: true + + /walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + dependencies: + makeerror: 1.0.12 + + /warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + dependencies: + loose-envify: 1.4.0 + + /webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + dev: true + + /whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + dependencies: + iconv-lite: 0.6.3 + dev: true + + /whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + dev: true + + /whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + dev: true + + /which-boxed-primitive@1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + dependencies: + is-bigint: 1.0.4 + is-boolean-object: 1.1.2 + is-number-object: 1.0.7 + is-string: 1.0.7 + is-symbol: 1.0.4 + dev: true + + /which-typed-array@1.1.9: + resolution: {integrity: sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.5 + call-bind: 1.0.2 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.0 + is-typed-array: 1.1.10 + dev: true + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + + /word-wrap@1.2.3: + resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} + engines: {node: '>=0.10.0'} + dev: true + + /wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + /wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + /write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + /ws@8.14.1: + resolution: {integrity: sha512-4OOseMUq8AzRBI/7SLMUwO+FEDnguetSk7KMb1sHwvF2w2Wv5Hoj0nlifx8vtGsftE/jWHojPy8sMMzYLJ2G/A==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + + /xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + dev: true + + /xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true + + /xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + dev: true + + /y-protocols@1.0.6(yjs@13.6.1): + resolution: {integrity: sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + dependencies: + lib0: 0.2.88 + yjs: 13.6.1 + dev: false + + /y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + /yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + /yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + /yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + dev: false + + /yaml@2.2.2: + resolution: {integrity: sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==} + engines: {node: '>= 14'} + dev: true + + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + /yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + 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.8 + yargs-parser: 21.1.1 + + /yjs@13.6.1: + resolution: {integrity: sha512-IyyHL+/v9N2S4YLSjGHMa0vMAfFxq8RDG5Nvb77raTTHJPweU3L/fRlqw6ElZvZUuHWnax3ufHR0Tx0ntfG63Q==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + dependencies: + lib0: 0.2.74 + dev: false + + /yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + dev: true + + /yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false diff --git a/frontend/appflowy_tauri/postcss.config.cjs b/frontend/appflowy_tauri/postcss.config.cjs new file mode 100644 index 0000000000000..12a703d900da8 --- /dev/null +++ b/frontend/appflowy_tauri/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/OFL.txt b/frontend/appflowy_tauri/public/google_fonts/Poppins/OFL.txt new file mode 100644 index 0000000000000..246c977c9f2ba --- /dev/null +++ b/frontend/appflowy_tauri/public/google_fonts/Poppins/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Black.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Black.ttf new file mode 100644 index 0000000000000..71c0f995ee643 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Black.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BlackItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BlackItalic.ttf new file mode 100644 index 0000000000000..7aeb58bd1b943 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BlackItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Bold.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Bold.ttf new file mode 100644 index 0000000000000..00559eeb290fb Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Bold.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BoldItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BoldItalic.ttf new file mode 100644 index 0000000000000..e61e8e88bdc98 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-BoldItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBold.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBold.ttf new file mode 100644 index 0000000000000..df7093608a7eb Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBold.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBoldItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBoldItalic.ttf new file mode 100644 index 0000000000000..14d2b375dc0c2 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraBoldItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLight.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLight.ttf new file mode 100644 index 0000000000000..e76ec69a650f1 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLight.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLightItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLightItalic.ttf new file mode 100644 index 0000000000000..89513d94693ae Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ExtraLightItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Italic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Italic.ttf new file mode 100644 index 0000000000000..12b7b3c40b5c8 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Italic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Light.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Light.ttf new file mode 100644 index 0000000000000..bc36bcc2427a8 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Light.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-LightItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-LightItalic.ttf new file mode 100644 index 0000000000000..9e70be6a9ef05 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-LightItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Medium.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Medium.ttf new file mode 100644 index 0000000000000..6bcdcc27f22e0 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Medium.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-MediumItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-MediumItalic.ttf new file mode 100644 index 0000000000000..be67410fd0a5a Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-MediumItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Regular.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Regular.ttf new file mode 100644 index 0000000000000..9f0c71b70a496 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Regular.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBold.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBold.ttf new file mode 100644 index 0000000000000..74c726e32781b Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBold.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBoldItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBoldItalic.ttf new file mode 100644 index 0000000000000..3e6c942233c69 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-SemiBoldItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Thin.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Thin.ttf new file mode 100644 index 0000000000000..03e736613a750 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-Thin.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ThinItalic.ttf b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ThinItalic.ttf new file mode 100644 index 0000000000000..e26db5dd3dbab Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Poppins/Poppins-ThinItalic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/LICENSE.txt b/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/LICENSE.txt new file mode 100644 index 0000000000000..75b52484ea471 --- /dev/null +++ b/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf b/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf new file mode 100644 index 0000000000000..61e5303325a1b Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Italic.ttf differ diff --git a/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf b/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf new file mode 100644 index 0000000000000..6df2b25360309 Binary files /dev/null and b/frontend/appflowy_tauri/public/google_fonts/Roboto_Mono/RobotoMono-Regular.ttf differ diff --git a/frontend/appflowy_tauri/public/launch_splash.jpg b/frontend/appflowy_tauri/public/launch_splash.jpg new file mode 100644 index 0000000000000..7e3bb9cee6c59 Binary files /dev/null and b/frontend/appflowy_tauri/public/launch_splash.jpg differ diff --git a/frontend/appflowy_tauri/public/tauri.svg b/frontend/appflowy_tauri/public/tauri.svg new file mode 100644 index 0000000000000..31b62c92804b7 --- /dev/null +++ b/frontend/appflowy_tauri/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/appflowy_tauri/public/vite.svg b/frontend/appflowy_tauri/public/vite.svg new file mode 100644 index 0000000000000..e7b8dfb1b2a60 --- /dev/null +++ b/frontend/appflowy_tauri/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/appflowy_tauri/scripts/i18n/index.cjs b/frontend/appflowy_tauri/scripts/i18n/index.cjs new file mode 100644 index 0000000000000..c3789e0c56f7a --- /dev/null +++ b/frontend/appflowy_tauri/scripts/i18n/index.cjs @@ -0,0 +1,63 @@ +const languages = [ + 'ar-SA', + 'ca-ES', + 'de-DE', + 'en', + 'es-VE', + 'eu-ES', + 'fr-FR', + 'hu-HU', + 'id-ID', + 'it-IT', + 'ja-JP', + 'ko-KR', + 'pl-PL', + 'pt-BR', + 'pt-PT', + 'ru-RU', + 'sv-SE', + 'th-TH', + 'tr-TR', + 'zh-CN', + 'zh-TW', +]; + +const fs = require('fs'); +languages.forEach(language => { + const json = require(`../../../resources/translations/${language}.json`); + const outputJSON = flattenJSON(json); + const output = JSON.stringify(outputJSON); + const isExistDir = fs.existsSync('./src/appflowy_app/i18n/translations'); + if (!isExistDir) { + fs.mkdirSync('./src/appflowy_app/i18n/translations'); + } + fs.writeFile(`./src/appflowy_app/i18n/translations/${language}.json`, new Uint8Array(Buffer.from(output)), (res) => { + if (res) { + console.error(res); + } + }) +}); + + +function flattenJSON(obj, prefix = '') { + let result = {}; + const pluralsKey = ["one", "other", "few", "many", "two", "zero"]; + + for (let key in obj) { + if (typeof obj[key] === 'object' && obj[key] !== null) { + + const nestedKeys = flattenJSON(obj[key], `${prefix}${key}.`); + result = { ...result, ...nestedKeys }; + } else { + let newKey = `${prefix}${key}`; + let replaceChar = '{' + if (pluralsKey.includes(key)) { + newKey = `${prefix.slice(0, -1)}_${key}`; + } + result[newKey] = obj[key].replaceAll('{', '{{').replaceAll('}', '}}'); + } + } + + return result; +} + diff --git a/frontend/appflowy_tauri/scripts/update_version.cjs b/frontend/appflowy_tauri/scripts/update_version.cjs new file mode 100644 index 0000000000000..498b8c3e4f413 --- /dev/null +++ b/frontend/appflowy_tauri/scripts/update_version.cjs @@ -0,0 +1,31 @@ +const fs = require('fs'); +const path = require('path'); + +if (process.argv.length < 3) { + console.error('Usage: node update-tauri-version.js '); + process.exit(1); +} + +const newVersion = process.argv[2]; + +const tauriConfigPath = path.join(__dirname, '../src-tauri', 'tauri.conf.json'); + +fs.readFile(tauriConfigPath, 'utf8', (err, data) => { + if (err) { + console.error('Error reading tauri.conf.json:', err); + return; + } + + const config = JSON.parse(data); + + config.package.version = newVersion; + + fs.writeFile(tauriConfigPath, JSON.stringify(config, null, 2), 'utf8', (err) => { + if (err) { + console.error('Error writing tauri.conf.json:', err); + return; + } + + console.log(`Tauri version updated to ${newVersion} successfully.`); + }); +}); diff --git a/frontend/appflowy_tauri/src-tauri/.cargo/config.toml b/frontend/appflowy_tauri/src-tauri/.cargo/config.toml new file mode 100644 index 0000000000000..bff29e6e175be --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["--cfg", "tokio_unstable"] diff --git a/frontend/appflowy_tauri/src-tauri/.gitignore b/frontend/appflowy_tauri/src-tauri/.gitignore new file mode 100644 index 0000000000000..61e1bdd46ac23 --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/.gitignore @@ -0,0 +1,4 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ +.env diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock new file mode 100644 index 0000000000000..ad7462c11c4cf --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -0,0 +1,9223 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "accessory" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850bb534b9dc04744fbbb71d30ad6d25a7e4cf6dc33e223c81ef3a92ebab4e0b" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "addr2line" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76fd60b23679b7d19bd066031410fb7e458ccc5e958eb5c325888ce4baedc97" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209b47e8954a928e1d72e86eca7000ebb6655fe1436d33eefc2201cad027e237" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "again" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05802a5ad4d172eaf796f7047b42d0af9db513585d16d4169660a21613d34b93" +dependencies = [ + "log", + "rand 0.7.3", + "wasm-timer", +] + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom 0.2.10", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a" +dependencies = [ + "cfg-if", + "getrandom 0.2.10", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +dependencies = [ + "memchr", +] + +[[package]] +name = "aho-corasick" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" +dependencies = [ + "memchr", +] + +[[package]] +name = "allo-isolate" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b6d794345b06592d0ebeed8e477e41b71e5a0a49df4fc0e4184d5938b99509" +dependencies = [ + "atomic", + "pin-project", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" + +[[package]] +name = "app-error" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "bincode", + "getrandom 0.2.10", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "tokio", + "tsify", + "url", + "uuid", + "wasm-bindgen", +] + +[[package]] +name = "appflowy-ai-client" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "bytes", + "futures", + "pin-project", + "serde", + "serde_json", + "serde_repr", + "thiserror", +] + +[[package]] +name = "appflowy-local-ai" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=6f064efe232268f8d396edbb4b84d57fbb640f13#6f064efe232268f8d396edbb4b84d57fbb640f13" +dependencies = [ + "anyhow", + "appflowy-plugin", + "bytes", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "zip 2.2.0", + "zip-extensions", +] + +[[package]] +name = "appflowy-plugin" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=6f064efe232268f8d396edbb4b84d57fbb640f13#6f064efe232268f8d396edbb4b84d57fbb640f13" +dependencies = [ + "anyhow", + "cfg-if", + "crossbeam-utils", + "log", + "once_cell", + "parking_lot 0.12.1", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "xattr 1.3.1", +] + +[[package]] +name = "appflowy_tauri" +version = "0.0.0" +dependencies = [ + "bytes", + "dotenv", + "flowy-ai", + "flowy-config", + "flowy-core", + "flowy-date", + "flowy-document", + "flowy-error", + "flowy-notification", + "flowy-search", + "flowy-user", + "lib-dispatch", + "semver", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-deep-link", + "tauri-utils", + "tracing", + "uuid", +] + +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "arrayvec" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" + +[[package]] +name = "async-compression" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103db485efc3e41214fe4fda9f3dbeae2eb9082f48fd236e6095627a9422066e" +dependencies = [ + "bzip2", + "deflate64", + "flate2", + "futures-core", + "futures-io", + "memchr", + "pin-project-lite", + "xz2", + "zstd 0.13.2", + "zstd-safe 7.2.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "async-trait" +version = "0.1.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "async_zip" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52" +dependencies = [ + "async-compression", + "chrono", + "crc32fast", + "futures-lite", + "pin-project", + "thiserror", + "tokio", + "tokio-util", +] + +[[package]] +name = "atk" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3d816ce6f0e2909a96830d6911c2aff044370b1ef92d7f267b43bae5addedd" +dependencies = [ + "atk-sys", + "bitflags 1.3.2", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58aeb089fb698e06db8089971c7ee317ab9644bade33383f63631437b03aafb6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.1.1", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic_refcell" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79d6dc922a2792b006573f60b2648076355daeae5ce9cb59507e5908c9625d31" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233d376d6d185f2a3093e58f283f60f880315b6c60075b01f36b3b85154564ca" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide 0.6.2", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.69.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" +dependencies = [ + "bitflags 2.4.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.47", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" + +[[package]] +name = "bitpacking" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c1d3e2bfd8d06048a179f7b17afc3188effa10385e7b00dc65af6aae732ea92" +dependencies = [ + "crunchy", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d4d6dafc1a3bb54687538972158f07b2c948bc57d5890df22c0739098b3028" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4918709cc4dd777ad2b6303ed03cb37f3ca0ccede8c1b0d28ac6db8f4710e0" +dependencies = [ + "once_cell", + "proc-macro-crate 2.0.2", + "proc-macro2", + "quote", + "syn 2.0.47", + "syn_derive", +] + +[[package]] +name = "brotli" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516074a47ef4bce09577a3b379392300159ce5b1ba2e501ff1c819950066100f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a246e68bb43f6cd9db24bea052a53e40405417c5fb372e3d1a8a7f770a564ef5" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytecheck" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6372023ac861f6e6dc89c8344a8f398fb42aaba2b5dbc649ca0c0e9dbcb627" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ec4c6f261935ad534c0c22dbef2201b45918860eb1c574b972bd213a76af61" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +dependencies = [ + "serde", +] + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "cairo-rs" +version = "0.15.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c76ee391b03d35510d9fa917357c7f1855bd9a6659c95a1b392e33f49b3369bc" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "glib", + "libc", + "thiserror", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c55d429bef56ac9172d25fecb85dc8068307d17acd74b377866b7a1ef25d3c8" +dependencies = [ + "glib-sys", + "libc", + "system-deps 6.1.1", +] + +[[package]] +name = "cargo_toml" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599aa35200ffff8f04c1925aa1acc92fa2e08874379ef42e210a80e527e60838" +dependencies = [ + "serde", + "toml 0.7.5", +] + +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +dependencies = [ + "jobserver", +] + +[[package]] +name = "census" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3431df59f28accaf4cb4eed4a9acc66bea3f3c3753aa6cdc2f024174ef232af7" +dependencies = [ + "smallvec", +] + +[[package]] +name = "cfg-expr" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "215c0072ecc28f92eeb0eea38ba63ddfcb65c2828c46311d646f1a3ff5f9841c" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets 0.52.0", +] + +[[package]] +name = "chrono-tz" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58549f1842da3080ce63002102d5bc954c7bc843d4f47818e642abdc36253552" +dependencies = [ + "chrono", + "chrono-tz-build 0.0.2", + "phf 0.10.1", +] + +[[package]] +name = "chrono-tz" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9cc2b23599e6d7479755f3594285efb3f74a1bdca7a7374948bc831e23a552" +dependencies = [ + "chrono", + "chrono-tz-build 0.1.0", + "phf 0.11.2", +] + +[[package]] +name = "chrono-tz" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6dd8046d00723a59a2f8c5f295c515b9bb9a331ee4f8f3d4dd49e428acd3b6" +dependencies = [ + "chrono", + "chrono-tz-build 0.4.0", + "phf 0.11.2", +] + +[[package]] +name = "chrono-tz-build" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db058d493fb2f65f41861bfed7e3fe6335264a9f0f92710cab5bdf01fef09069" +dependencies = [ + "parse-zoneinfo", + "phf 0.10.1", + "phf_codegen 0.10.0", +] + +[[package]] +name = "chrono-tz-build" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9998fb9f7e9b2111641485bf8beb32f92945f97f92a3d061f744cfef335f751" +dependencies = [ + "parse-zoneinfo", + "phf 0.11.2", + "phf_codegen 0.11.2", +] + +[[package]] +name = "chrono-tz-build" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94fea34d77a245229e7746bd2beb786cd2a896f306ff491fb8cecb3074b10a7" +dependencies = [ + "parse-zoneinfo", + "phf_codegen 0.11.2", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c688fc74432808e3eb684cae8830a86be1d66a2bd58e1f248ed0960a590baf6f" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "client-api" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "again", + "anyhow", + "app-error", + "arc-swap", + "async-trait", + "base64 0.22.1", + "bincode", + "brotli", + "bytes", + "chrono", + "client-api-entity", + "client-websocket", + "collab", + "collab-rt-entity", + "collab-rt-protocol", + "futures", + "futures-core", + "futures-util", + "getrandom 0.2.10", + "gotrue", + "infra", + "lazy_static", + "md5", + "mime", + "mime_guess", + "parking_lot 0.12.1", + "percent-encoding", + "pin-project", + "prost 0.13.3", + "rayon", + "reqwest", + "scraper 0.17.1", + "semver", + "serde", + "serde_json", + "serde_repr", + "serde_urlencoded", + "shared-entity", + "thiserror", + "tokio", + "tokio-retry", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "uuid", + "wasm-bindgen-futures", + "yrs", + "zstd 0.13.2", +] + +[[package]] +name = "client-api-entity" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "collab-entity", + "collab-rt-entity", + "database-entity", + "gotrue-entity", + "shared-entity", + "uuid", +] + +[[package]] +name = "client-websocket" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "futures-channel", + "futures-util", + "http", + "httparse", + "js-sys", + "percent-encoding", + "thiserror", + "tokio", + "tokio-tungstenite", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "cmd_lib" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba0f413777386d37f85afa5242f277a7b461905254c1af3c339d4af06800f62" +dependencies = [ + "cmd_lib_macros", + "faccess", + "lazy_static", + "log", + "os_pipe", +] + +[[package]] +name = "cmd_lib_macros" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e66605092ff6c6e37e0246601ae6c3f62dc1880e0599359b5f303497c112dc0" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "931d3837c286f56e3c58423ce4eba12d08db2374461a785c86f672b08b5650d6" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "collab" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "arc-swap", + "async-trait", + "bincode", + "bytes", + "chrono", + "js-sys", + "lazy_static", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "unicode-segmentation", + "web-sys", + "yrs", +] + +[[package]] +name = "collab-database" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "chrono-tz 0.10.0", + "collab", + "collab-entity", + "csv", + "dashmap 5.5.3", + "fancy-regex 0.13.0", + "futures", + "getrandom 0.2.10", + "iana-time-zone", + "js-sys", + "lazy_static", + "nanoid", + "percent-encoding", + "rayon", + "rust_decimal", + "rusty-money", + "serde", + "serde_json", + "serde_repr", + "sha2", + "strum", + "strum_macros 0.25.2", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "uuid", + "yrs", +] + +[[package]] +name = "collab-document" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "arc-swap", + "collab", + "collab-entity", + "getrandom 0.2.10", + "markdown", + "nanoid", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "uuid", +] + +[[package]] +name = "collab-entity" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "bytes", + "collab", + "getrandom 0.2.10", + "prost 0.13.3", + "prost-build", + "protoc-bin-vendored", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "uuid", + "walkdir", +] + +[[package]] +name = "collab-folder" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "arc-swap", + "chrono", + "collab", + "collab-entity", + "dashmap 5.5.3", + "getrandom 0.2.10", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "uuid", +] + +[[package]] +name = "collab-importer" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "async-recursion", + "async-trait", + "async_zip", + "base64 0.22.1", + "chrono", + "collab", + "collab-database", + "collab-document", + "collab-entity", + "collab-folder", + "csv", + "fancy-regex 0.13.0", + "futures", + "futures-lite", + "futures-util", + "fxhash", + "hex", + "markdown", + "percent-encoding", + "rayon", + "sanitize-filename", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", + "walkdir", + "zip 0.6.6", +] + +[[package]] +name = "collab-integrate" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "async-trait", + "collab", + "collab-database", + "collab-document", + "collab-entity", + "collab-folder", + "collab-plugins", + "collab-user", + "futures", + "lib-infra", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "collab-plugins" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "bincode", + "bytes", + "chrono", + "collab", + "collab-entity", + "futures", + "futures-util", + "getrandom 0.2.10", + "indexed_db_futures", + "js-sys", + "lazy_static", + "rand 0.8.5", + "rocksdb", + "serde", + "serde_json", + "similar 2.2.1", + "smallvec", + "thiserror", + "tokio", + "tokio-retry", + "tokio-stream", + "tracing", + "tracing-wasm", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yrs", +] + +[[package]] +name = "collab-rt-entity" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "bincode", + "bytes", + "chrono", + "client-websocket", + "collab", + "collab-entity", + "collab-rt-protocol", + "database-entity", + "prost 0.13.3", + "prost-build", + "protoc-bin-vendored", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "tokio-tungstenite", + "yrs", +] + +[[package]] +name = "collab-rt-protocol" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "async-trait", + "bincode", + "collab", + "collab-entity", + "serde", + "thiserror", + "tokio", + "tracing", + "yrs", +] + +[[package]] +name = "collab-user" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "collab", + "collab-entity", + "getrandom 0.2.10", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3993e6445baa160675931ec041a5e03ca84b9c6e32a056150d3aa2bdda0a1f45" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "regex", + "terminal_size", + "unicode-width", + "winapi", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "constant_time_eq" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "387461abbc748185c3a6e1673d826918b450b87ff22639429c694619a83b6cf6" +dependencies = [ + "cookie", + "idna 0.3.0", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb142d41022986c1d8ff29103a1411c8a3dfad3552f87a4f8dc50d61d4f4e33" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e69e28e9f7f77debdedbaafa2866e1de9ba56df55a8bd7cfc724c25a09987c" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef" +dependencies = [ + "cfg-if", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7" +dependencies = [ + "autocfg", + "cfg-if", + "crossbeam-utils", + "memoffset", + "scopeguard", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa 0.4.8", + "matches", + "phf 0.8.0", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa 1.0.6", + "phf 0.8.0", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.47", +] + +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa 1.0.6", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d2b3721e861707777e3195b0158f950ae6dc4a27e4d02ff9f67e3eb3de199e" +dependencies = [ + "quote", + "syn 2.0.47", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "darling" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0558d22a7b463ed0241e993f76f09f30b126687447751a8638587b864e4b3944" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8bfa2e259f8ee1ce5e97824a3c55ec4404a0d772ca7fa96bf19f0752a046eb" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 2.0.47", +] + +[[package]] +name = "darling_macro" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a358ff9f12ec09c3e61fef9b5a9902623a695a46a917b07f269bff1445611a" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.3", + "lock_api", + "once_cell", + "parking_lot_core 0.9.8", +] + +[[package]] +name = "dashmap" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804c8821570c3f8b70230c2ba75ffa5c0f9a4189b9a432b6656c536712acae28" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.3", + "lock_api", + "once_cell", + "parking_lot_core 0.9.8", +] + +[[package]] +name = "data-encoding" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" + +[[package]] +name = "database-entity" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "app-error", + "appflowy-ai-client", + "bincode", + "bytes", + "chrono", + "collab-entity", + "infra", + "prost 0.13.3", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "tracing", + "uuid", + "validator 0.19.0", +] + +[[package]] +name = "date_time_parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0521d96e513670773ac503e5f5239178c3aef16cffda1e77a3cdbdbe993fb5a" +dependencies = [ + "chrono", + "regex", +] + +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + +[[package]] +name = "delegate-display" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a85201f233142ac819bbf6226e36d0b5e129a47bd325084674261c82d4cd66" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", +] + +[[package]] +name = "deunicode" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850878694b7933ca4c9569d30a34b55031b9b139ee1fc7b94a527c4ef960d690" + +[[package]] +name = "diesel" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c6fcf842f17f8c78ecf7c81d75c5ce84436b41ee07e03f490fbb5f5a8731d8" +dependencies = [ + "chrono", + "diesel_derives", + "libsqlite3-sys", + "r2d2", + "serde_json", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8337737574f55a468005a83499da720f20c65586241ffea339db9ecdfd2b44" +dependencies = [ + "diesel_table_macro_syntax", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "diesel_migrations" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6036b3f0120c5961381b570ee20a02432d7e2d27ea60de9578799cf9156914ac" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc5557efc453706fed5e4fa85006fe9817c224c3f480a34c7e5959fd700921c5" +dependencies = [ + "syn 2.0.47", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "downcast-rs" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" + +[[package]] +name = "dtoa" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65d09067bfacaa79114679b279d7f5885b53295b1e2cfb4e79c8e4bd3d633169" + +[[package]] +name = "dtoa-short" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbaceec3c6e4211c79e7b1800fb9680527106beb2f9c51904a3210c03a448c74" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" + +[[package]] +name = "dyn-clone" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b0cf012f1230e43cd00ebb729c6bb58707ecfa8ad08b52ef3a4ccd2697fc30" + +[[package]] +name = "ego-tree" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a68a4904193147e0a8dec3314640e6db742afd5f6e634f428a6af230d9b3591" + +[[package]] +name = "either" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" + +[[package]] +name = "embed-resource" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80663502655af01a2902dff3f06869330782267924bf1788410b74edcd93770a" +dependencies = [ + "cc", + "rustc_version", + "toml 0.7.5", + "vswhom", + "winreg 0.11.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[package]] +name = "encoding_rs" +version = "0.8.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071a31f4ee85403370b58aca746f01041ede6f0da2730960ad001edc2b71b394" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88bffebc5d80432c9b140ee17875ff173a8ab62faad5b257da912bd2f6c1c0a1" + +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "faccess" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ae66425802d6a903e268ae1a08b8c38ba143520f227a205edf4e9c7e3e26d5" +dependencies = [ + "bitflags 1.3.2", + "libc", + "winapi", +] + +[[package]] +name = "fancy-regex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0678ab2d46fa5195aaf59ad034c083d351377d4af57f3e073c074d0da3e3c766" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata 0.4.7", + "regex-syntax 0.8.4", +] + +[[package]] +name = "fancy_constructor" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f71f317e4af73b2f8f608fac190c52eac4b1879d2145df1db2fe48881ca69435" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "fastdivide" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25c7df09945d65ea8d70b3321547ed414bbc540aad5bac6883d021b970f35b04" + +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +dependencies = [ + "getrandom 0.2.10", +] + +[[package]] +name = "fdeflate" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d329bdeac514ee06249dabc27877490f17f5d371ec693360768b838e19f3ae10" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.4.1", + "windows-sys 0.52.0", +] + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" +dependencies = [ + "crc32fast", + "miniz_oxide 0.7.1", +] + +[[package]] +name = "flowy-ai" +version = "0.1.0" +dependencies = [ + "allo-isolate", + "anyhow", + "appflowy-local-ai", + "appflowy-plugin", + "arc-swap", + "base64 0.21.5", + "bytes", + "dashmap 6.0.1", + "flowy-ai-pub", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-notification", + "flowy-sqlite", + "flowy-storage-pub", + "futures", + "futures-util", + "lib-dispatch", + "lib-infra", + "log", + "md5", + "notify", + "pin-project", + "protobuf", + "reqwest", + "serde", + "serde_json", + "sha2", + "strum_macros 0.21.1", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "uuid", + "validator 0.18.1", + "zip 2.2.0", + "zip-extensions", +] + +[[package]] +name = "flowy-ai-pub" +version = "0.1.0" +dependencies = [ + "bytes", + "client-api", + "flowy-error", + "futures", + "lib-infra", + "serde_json", +] + +[[package]] +name = "flowy-ast" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "flowy-codegen" +version = "0.1.0" +dependencies = [ + "cmd_lib", + "console", + "fancy-regex 0.10.0", + "flowy-ast", + "itertools 0.10.5", + "lazy_static", + "log", + "phf 0.8.0", + "protoc-bin-vendored", + "protoc-rust", + "quote", + "serde", + "serde_json", + "similar 1.3.0", + "syn 1.0.109", + "tera", + "toml 0.5.11", + "walkdir", +] + +[[package]] +name = "flowy-config" +version = "0.1.0" +dependencies = [ + "bytes", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-sqlite", + "lib-dispatch", + "protobuf", + "strum_macros 0.21.1", +] + +[[package]] +name = "flowy-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "appflowy-local-ai", + "arc-swap", + "base64 0.21.5", + "bytes", + "client-api", + "collab", + "collab-entity", + "collab-folder", + "collab-integrate", + "collab-plugins", + "dashmap 6.0.1", + "diesel", + "flowy-ai", + "flowy-ai-pub", + "flowy-config", + "flowy-database-pub", + "flowy-database2", + "flowy-date", + "flowy-document", + "flowy-document-pub", + "flowy-error", + "flowy-folder", + "flowy-folder-pub", + "flowy-search", + "flowy-search-pub", + "flowy-server", + "flowy-server-pub", + "flowy-sqlite", + "flowy-storage", + "flowy-storage-pub", + "flowy-user", + "flowy-user-pub", + "futures", + "futures-core", + "lib-dispatch", + "lib-infra", + "lib-log", + "semver", + "serde", + "serde_json", + "serde_repr", + "sysinfo", + "tokio", + "tokio-stream", + "tracing", + "uuid", + "walkdir", +] + +[[package]] +name = "flowy-database-pub" +version = "0.1.0" +dependencies = [ + "anyhow", + "client-api", + "collab", + "collab-entity", + "flowy-error", + "lib-infra", +] + +[[package]] +name = "flowy-database2" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "async-stream", + "async-trait", + "bytes", + "chrono", + "chrono-tz 0.8.2", + "collab", + "collab-database", + "collab-entity", + "collab-integrate", + "collab-plugins", + "csv", + "dashmap 6.0.1", + "fancy-regex 0.11.0", + "flowy-codegen", + "flowy-database-pub", + "flowy-derive", + "flowy-error", + "flowy-notification", + "futures", + "indexmap 2.1.0", + "lazy_static", + "lib-dispatch", + "lib-infra", + "moka", + "nanoid", + "protobuf", + "rayon", + "rust_decimal", + "rusty-money", + "serde", + "serde_json", + "serde_repr", + "strum", + "strum_macros 0.25.2", + "tokio", + "tokio-util", + "tracing", + "url", + "validator 0.18.1", +] + +[[package]] +name = "flowy-date" +version = "0.1.0" +dependencies = [ + "bytes", + "chrono", + "date_time_parser", + "fancy-regex 0.11.0", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "lib-dispatch", + "protobuf", + "strum_macros 0.21.1", + "tracing", +] + +[[package]] +name = "flowy-derive" +version = "0.1.0" +dependencies = [ + "dashmap 6.0.1", + "flowy-ast", + "flowy-codegen", + "lazy_static", + "proc-macro2", + "quote", + "serde_json", + "syn 1.0.109", + "walkdir", +] + +[[package]] +name = "flowy-document" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "collab", + "collab-document", + "collab-entity", + "collab-integrate", + "collab-plugins", + "dashmap 6.0.1", + "flowy-codegen", + "flowy-derive", + "flowy-document-pub", + "flowy-error", + "flowy-notification", + "flowy-storage-pub", + "futures", + "getrandom 0.2.10", + "indexmap 2.1.0", + "lib-dispatch", + "lib-infra", + "nanoid", + "protobuf", + "scraper 0.18.1", + "serde", + "serde_json", + "strum_macros 0.21.1", + "tokio", + "tokio-stream", + "tracing", + "uuid", + "validator 0.18.1", +] + +[[package]] +name = "flowy-document-pub" +version = "0.1.0" +dependencies = [ + "anyhow", + "collab", + "collab-document", + "flowy-error", + "lib-infra", +] + +[[package]] +name = "flowy-encrypt" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "base64 0.21.5", + "getrandom 0.2.10", + "hmac", + "pbkdf2 0.12.2", + "rand 0.8.5", + "sha2", +] + +[[package]] +name = "flowy-error" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "client-api", + "collab", + "collab-database", + "collab-document", + "collab-folder", + "collab-plugins", + "fancy-regex 0.11.0", + "flowy-codegen", + "flowy-derive", + "flowy-sqlite", + "lib-dispatch", + "protobuf", + "r2d2", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "tantivy", + "thiserror", + "tokio", + "url", + "validator 0.18.1", +] + +[[package]] +name = "flowy-folder" +version = "0.1.0" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "chrono", + "client-api", + "collab", + "collab-document", + "collab-entity", + "collab-folder", + "collab-integrate", + "collab-plugins", + "dashmap 6.0.1", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-folder-pub", + "flowy-notification", + "flowy-search-pub", + "flowy-sqlite", + "futures", + "lazy_static", + "lib-dispatch", + "lib-infra", + "nanoid", + "protobuf", + "regex", + "serde", + "serde_json", + "strum_macros 0.21.1", + "tokio", + "tokio-stream", + "tracing", + "unicode-segmentation", + "uuid", + "validator 0.18.1", +] + +[[package]] +name = "flowy-folder-pub" +version = "0.1.0" +dependencies = [ + "anyhow", + "client-api", + "collab", + "collab-entity", + "collab-folder", + "flowy-error", + "lib-infra", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "flowy-notification" +version = "0.1.0" +dependencies = [ + "bytes", + "dashmap 6.0.1", + "flowy-codegen", + "flowy-derive", + "lazy_static", + "lib-dispatch", + "protobuf", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "flowy-search" +version = "0.1.0" +dependencies = [ + "async-stream", + "bytes", + "collab", + "collab-folder", + "diesel", + "diesel_derives", + "diesel_migrations", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-folder", + "flowy-notification", + "flowy-search-pub", + "flowy-sqlite", + "flowy-user", + "futures", + "lib-dispatch", + "lib-infra", + "protobuf", + "serde", + "serde_json", + "strsim 0.11.0", + "strum_macros 0.26.1", + "tantivy", + "tempfile", + "tokio", + "tracing", + "validator 0.18.1", +] + +[[package]] +name = "flowy-search-pub" +version = "0.1.0" +dependencies = [ + "client-api", + "collab", + "collab-folder", + "flowy-error", + "futures", + "lib-infra", +] + +[[package]] +name = "flowy-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "bytes", + "chrono", + "client-api", + "collab", + "collab-database", + "collab-document", + "collab-entity", + "collab-folder", + "collab-plugins", + "collab-user", + "dashmap 6.0.1", + "flowy-ai-pub", + "flowy-database-pub", + "flowy-document-pub", + "flowy-encrypt", + "flowy-error", + "flowy-folder-pub", + "flowy-search-pub", + "flowy-server-pub", + "flowy-storage", + "flowy-storage-pub", + "flowy-user-pub", + "futures", + "futures-util", + "hex", + "hyper", + "lazy_static", + "lib-dispatch", + "lib-infra", + "mime_guess", + "postgrest", + "rand 0.8.5", + "reqwest", + "semver", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-retry", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "uuid", + "yrs", +] + +[[package]] +name = "flowy-server-pub" +version = "0.1.0" +dependencies = [ + "flowy-error", + "serde", + "serde_repr", +] + +[[package]] +name = "flowy-sqlite" +version = "0.1.0" +dependencies = [ + "anyhow", + "diesel", + "diesel_derives", + "diesel_migrations", + "libsqlite3-sys", + "r2d2", + "scheduled-thread-pool", + "serde", + "serde_json", + "thiserror", + "tracing", +] + +[[package]] +name = "flowy-storage" +version = "0.1.0" +dependencies = [ + "allo-isolate", + "anyhow", + "async-trait", + "bytes", + "chrono", + "collab-importer", + "dashmap 6.0.1", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-notification", + "flowy-sqlite", + "flowy-storage-pub", + "futures-util", + "fxhash", + "lib-dispatch", + "lib-infra", + "mime_guess", + "protobuf", + "serde", + "serde_json", + "strum_macros 0.25.2", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "flowy-storage-pub" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "client-api-entity", + "flowy-error", + "lib-infra", + "mime", + "mime_guess", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "flowy-user" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "base64 0.21.5", + "bytes", + "chrono", + "client-api", + "collab", + "collab-database", + "collab-document", + "collab-entity", + "collab-folder", + "collab-integrate", + "collab-plugins", + "collab-user", + "dashmap 6.0.1", + "diesel", + "diesel_derives", + "fancy-regex 0.11.0", + "flowy-codegen", + "flowy-derive", + "flowy-encrypt", + "flowy-error", + "flowy-folder-pub", + "flowy-notification", + "flowy-server-pub", + "flowy-sqlite", + "flowy-user-pub", + "lazy_static", + "lib-dispatch", + "lib-infra", + "once_cell", + "protobuf", + "rayon", + "semver", + "serde", + "serde_json", + "serde_repr", + "strum", + "strum_macros 0.25.2", + "tokio", + "tokio-stream", + "tracing", + "unicode-segmentation", + "uuid", + "validator 0.18.1", +] + +[[package]] +name = "flowy-user-pub" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.21.5", + "chrono", + "client-api", + "collab", + "collab-entity", + "collab-folder", + "flowy-error", + "flowy-folder-pub", + "lib-infra", + "serde", + "serde_json", + "serde_repr", + "tokio", + "tokio-stream", + "tracing", + "uuid", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs4" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" +dependencies = [ + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-lite" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e05c1f572ab0e1f15be94217f0dc29088c248b14f792a5ff0af0d84bcda9e8" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38dd9cc8b099cceecdf41375bb6d481b1b5a7cd5cd603e10a69a9383f8619a" +dependencies = [ + "bitflags 1.3.2", + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140b2f5378256527150350a8346dbdb08fadc13453a7a2d73aecd5fab3c402a7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.1.1", +] + +[[package]] +name = "gdk-sys" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e7a08c1e8f06f4177fb7e51a777b8c1689f743a7bc11ea91d44d2226073a88" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps 6.1.1", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca49a59ad8cfdf36ef7330fe7bdfbe1d34323220cc16a0de2679ee773aee2c2" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps 6.1.1", +] + +[[package]] +name = "gdkx11-sys" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b7f8c7a84b407aa9b143877e267e848ff34106578b64d1e0a24bf550716178" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps 6.1.1", + "x11", +] + +[[package]] +name = "generator" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" +dependencies = [ + "cc", + "libc", + "log", + "rustversion", + "windows 0.48.0", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gimli" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" + +[[package]] +name = "gio" +version = "0.15.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68fdbc90312d462781a395f7a16d96a2b379bb6ef8cd6310a2df272771c4283b" +dependencies = [ + "bitflags 1.3.2", + "futures-channel", + "futures-core", + "futures-io", + "gio-sys", + "glib", + "libc", + "once_cell", + "thiserror", +] + +[[package]] +name = "gio-sys" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32157a475271e2c4a023382e9cab31c4584ee30a97da41d3c4e9fdd605abcf8d" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.1.1", + "winapi", +] + +[[package]] +name = "glib" +version = "0.15.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb0306fbad0ab5428b0ca674a23893db909a98582969c9b537be4ced78c505d" +dependencies = [ + "bitflags 1.3.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "once_cell", + "smallvec", + "thiserror", +] + +[[package]] +name = "glib-macros" +version = "0.15.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10c6ae9f6fa26f4fb2ac16b528d138d971ead56141de489f8111e259b9df3c4a" +dependencies = [ + "anyhow", + "heck 0.4.1", + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "glib-sys" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4b192f8e65e9cf76cbf4ea71fa8e3be4a0e18ffe3d68b8da6836974cc5bad4" +dependencies = [ + "libc", + "system-deps 6.1.1", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "globset" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029d74589adefde59de1a0c4f4732695c32805624aec7b68d91503d4dba79afc" +dependencies = [ + "aho-corasick 0.7.20", + "bstr", + "fnv", + "log", + "regex", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "gloo-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gobject-sys" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d57ce44246becd17153bd035ab4d32cfee096a657fc01f2231c9278378d1e0a" +dependencies = [ + "glib-sys", + "libc", + "system-deps 6.1.1", +] + +[[package]] +name = "gotrue" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "futures-util", + "getrandom 0.2.10", + "gotrue-entity", + "infra", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "gotrue-entity" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "app-error", + "chrono", + "jsonwebtoken", + "lazy_static", + "serde", + "serde_json", +] + +[[package]] +name = "gtk" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e3004a2d5d6d8b5057d2b57b3712c9529b62e82c77f25c1fecde1fd5c23bd0" +dependencies = [ + "atk", + "bitflags 1.3.2", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "once_cell", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5bc2f0587cba247f60246a0ca11fe25fb733eabc3de12d1965fc07efab87c84" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps 6.1.1", +] + +[[package]] +name = "gtk3-macros" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "684c0456c086e8e7e9af73ec5b84e35938df394712054550e81558d21c44ab0d" +dependencies = [ + "anyhow", + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "h2" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ec8491ebaf99c8eaa73058b045fe58073cd6be7f596ac993ced0b0a0c01049" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap 1.9.3", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.6", +] + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash 0.8.6", + "allocator-api2", +] + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hermit-abi" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5c13fb08e5d4dfc151ee5e88bae63f7773d61852f3bdc73c9f4b9e1bde03148" +dependencies = [ + "log", + "mac", + "markup5ever 0.10.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "html5ever" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +dependencies = [ + "log", + "mac", + "markup5ever 0.11.0", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "htmlescape" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" + +[[package]] +name = "http" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +dependencies = [ + "bytes", + "fnv", + "itoa 1.0.6", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "hyper" +version = "0.14.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa 1.0.6", + "pin-project-lite", + "socket2 0.4.9", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0646026eb1b3eea4cd9ba47912ea5ce9cc07713d105b1a14698f4e6433d348b7" +dependencies = [ + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3804960be0bb5e4edb1e1ad67afd321a9ecfd875c3e65c099468fd2717d7cae" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe7873dab538a9a44ad79ede1faf5f30d49f9a5c883ddbab48bce81b64b7492" +dependencies = [ + "globset", + "lazy_static", + "log", + "memchr", + "regex", + "same-file", + "thread_local", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527909aa81e20ac3a44803521443a765550f09b5130c2c2fa1ea59c2f8f50a3a" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-rational", + "num-traits", +] + +[[package]] +name = "indexed_db_futures" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0704b71f13f81b5933d791abf2de26b33c40935143985220299a357721166706" +dependencies = [ + "accessory", + "cfg-if", + "delegate-display", + "fancy_constructor", + "js-sys", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown 0.14.3", + "serde", +] + +[[package]] +name = "infer" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f551f8c3a39f68f986517db0d1759de85881894fdc7db798bd2a9df9cb04b7fc" +dependencies = [ + "cfb", +] + +[[package]] +name = "infra" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "bytes", + "futures", + "pin-project", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "validator 0.19.0", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "interprocess" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f2533f3be42fffe3b5e63b71aeca416c1c3bc33e4e27be018521e76b1f38fb" +dependencies = [ + "cfg-if", + "libc", + "rustc_version", + "to_method", + "winapi", +] + +[[package]] +name = "ipnet" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "itoa" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" + +[[package]] +name = "javascriptcore-rs" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf053e7843f2812ff03ef5afe34bb9c06ffee120385caad4f6b9967fcd37d41c" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "905fbb87419c5cde6e3269537e4ea7d46431f3008c5d057e915ef3f115e7793c" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 5.0.0", +] + +[[package]] +name = "jni" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "039022cdf4d7b1cf548d31f60ae783138e5fd42013f6271049d7df7afadef96c" +dependencies = [ + "cesu8", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1d36f1235bc969acba30b7f5990b864423a6068a10f7c90ae8f0112e3a59d1" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ff1e1486799e3f64129f8ccad108b38290df9cd7015cd31bed17239f0789d6" +dependencies = [ + "serde", + "serde_json", + "thiserror", + "treediff", +] + +[[package]] +name = "jsonwebtoken" +version = "8.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" +dependencies = [ + "base64 0.21.5", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "kqueue" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "kuchiki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ea8e9c6e031377cff82ee3001dc8026cdf431ed4e2e6b51f98ab8c73484a358" +dependencies = [ + "cssparser 0.27.2", + "html5ever 0.25.2", + "matches", + "selectors 0.22.0", +] + +[[package]] +name = "kuchikiki" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8" +dependencies = [ + "cssparser 0.27.2", + "html5ever 0.26.0", + "indexmap 1.9.3", + "matches", + "selectors 0.22.0", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "levenshtein_automata" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" + +[[package]] +name = "lib-dispatch" +version = "0.1.0" +dependencies = [ + "bincode", + "bytes", + "derivative", + "dyn-clone", + "futures", + "futures-channel", + "futures-core", + "futures-util", + "getrandom 0.2.10", + "nanoid", + "pin-project", + "protobuf", + "serde", + "serde_json", + "serde_repr", + "thread-id", + "tokio", + "tracing", + "validator 0.18.1", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "lib-infra" +version = "0.1.0" +dependencies = [ + "allo-isolate", + "anyhow", + "async-trait", + "atomic_refcell", + "bytes", + "cfg-if", + "chrono", + "futures", + "futures-core", + "futures-util", + "md5", + "pin-project", + "tempfile", + "tokio", + "tracing", + "validator 0.18.1", + "walkdir", + "zip 2.2.0", +] + +[[package]] +name = "lib-log" +version = "0.1.0" +dependencies = [ + "chrono", + "lazy_static", + "lib-infra", + "serde", + "serde_json", + "tracing", + "tracing-appender", + "tracing-bunyan-formatter", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "libc" +version = "0.2.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libm" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" + +[[package]] +name = "librocksdb-sys" +version = "0.16.0+8.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3d60bc059831dc1c83903fb45c103f75db65c5a7bf22272764d9cc683e348c" +dependencies = [ + "bindgen", + "bzip2-sys", + "cc", + "glob", + "libc", + "libz-sys", + "zstd-sys", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ee889ecc9568871456d42f603d6a0ce59ff328d291063a45cbdf0036baf6db" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "line-wrap" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9" +dependencies = [ + "safemem", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "lock_api" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + +[[package]] +name = "loom" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" +dependencies = [ + "hashbrown 0.14.3", +] + +[[package]] +name = "lz4_flex" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "912b45c753ff5f7f5208307e8ace7d2a2e30d024e26d3509f3dce546c044ce15" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "macroific" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05c00ac596022625d01047c421a0d97d7f09a18e429187b341c201cb631b9dd" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "macroific_macro", +] + +[[package]] +name = "macroific_attr_parse" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd94d5da95b30ae6e10621ad02340909346ad91661f3f8c0f2b62345e46a2f67" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "macroific_core" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13198c120864097a565ccb3ff947672d969932b7975ebd4085732c9f09435e55" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "macroific_macro" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c9853143cbed7f1e41dc39fee95f9b361bec65c8dc2a01bf609be01b61f5ae" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markdown" +version = "1.0.0-alpha.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6491e6c702bf7e3b24e769d800746d5f2c06a6c6a2db7992612e0f429029e81" +dependencies = [ + "unicode-id", +] + +[[package]] +name = "markup5ever" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a24f40fb03852d1cdd84330cddcaf98e9ec08a7b7768e952fad3b4cf048ec8fd" +dependencies = [ + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +dependencies = [ + "log", + "phf 0.10.1", + "phf_codegen 0.10.0", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "measure_time" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56220900f1a0923789ecd6bf25fbae8af3b2f1ff3e9e297fc9b6b8674dd4d852" +dependencies = [ + "instant", + "log", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memmap2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe751422e4a8caa417e13c3ea66452215d7d63e19e604f4980461212f3ae1322" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "migrations_internals" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f23f71580015254b020e856feac3df5878c2c7a8812297edd6c0a485ac9dada" +dependencies = [ + "serde", + "toml 0.7.5", +] + +[[package]] +name = "migrations_macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cce3325ac70e67bbab5bd837a31cae01f1a6db64e0e744a33cb03a543469ef08" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" +dependencies = [ + "adler", +] + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "moka" +version = "0.12.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cf62eb4dd975d2dde76432fb1075c49e3ee2331cf36f1f8fd4b66550d32b6f" +dependencies = [ + "async-lock", + "async-trait", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "event-listener", + "futures-util", + "once_cell", + "parking_lot 0.12.1", + "quanta", + "rustc_version", + "smallvec", + "tagptr", + "thiserror", + "triomphe", + "uuid", +] + +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + +[[package]] +name = "murmurhash32" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9380db4c04d219ac5c51d14996bbf2c2e9a15229771b53f8671eb6c83cf44df" + +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand 0.8.5", +] + +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2032c77e030ddee34a6787a64166008da93f6a352b629261d0fee232b8742dd4" +dependencies = [ + "bitflags 1.3.2", + "jni-sys", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e5a6ae77c8ee183dcbbba6150e2e6b9f3f4196a7666c02a715a95692ec1fa97" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.4.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c71324e4180d0899963fc83d9d241ac39e699609fc1025a850aadac8257459" + +[[package]] +name = "objc2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.30.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b4680b86d9cfafba8fc491dc9b6df26b68cf40e9e6cd73909194759a63c385" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "oneshot" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6640c6bda7731b1fdbab747981a0f896dd1fedaf9f4a53fa237a04a84431f4" +dependencies = [ + "loom", +] + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "open" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2078c0039e6a54a0c42c28faa984e115fb4c2d5bf2208f77d1961002df8576f8" +dependencies = [ + "pathdiff", + "windows-sys 0.42.0", +] + +[[package]] +name = "openssl" +version = "0.10.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "345df152bc43501c5eb9e4654ff05f794effb78d4efe3d53abc158baddc0703d" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-src" +version = "111.27.0+1.1.1v" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06e8f197c82d7511c5b014030c9b1efeda40d7d5f99d23b4ceed3524a5e63f02" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374533b0e45f3a7ced10fcaeccca020e66656bc03dac384f852e4e5a7a8104a6" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "os_pipe" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb233f06c2307e1f5ce2ecad9f8121cffbbee2c95428f44ea85222e460d0d213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "ownedbytes" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a059efb063b8f425b948e042e6b9bd85edfe60e913630ed727b23e2dfcc558" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "pango" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e4045548659aee5313bde6c582b0d83a627b7904dd20dc2d9ef0895d414e4f" +dependencies = [ + "bitflags 1.3.2", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2a00081cde4661982ed91d80ef437c20eacaf6aa1a5962c0279ae194662c3aa" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.1.1", +] + +[[package]] +name = "parking" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.8", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.3.5", + "smallvec", + "windows-targets 0.48.0", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" +dependencies = [ + "regex", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" +dependencies = [ + "base64 0.13.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pest" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f73935e4d55e2abf7f130186537b19e7a4abc886a0252380b59248af473a3fc9" +dependencies = [ + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef623c9bbfa0eedf5a0efba11a5ee83209c326653ca31ff019bec3a95bfff2b" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e8cba4ec22bada7fc55ffe51e2deb6a0e0db2d0b7ab0b103acc80d2510c190" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "pest_meta" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01f71cb40bd8bb94232df14b946909e14660e33fc05db3e50ae2a82d7ea0ca0" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9" +dependencies = [ + "fixedbitset", + "indexmap 2.1.0", +] + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_macros 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros 0.11.2", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared 0.11.2", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", + "uncased", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be57f64e946e500c8ee36ef6331845d40a93055567ec57e8fae13efd33759b95" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" + +[[package]] +name = "plist" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd9647b268a3d3e14ff09c23201133a62589c658db02bb7388c7246aafe0590" +dependencies = [ + "base64 0.21.5", + "indexmap 1.9.3", + "line-wrap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59871cc5b6cce7eaccca5a802b4173377a1c2ba90654246789a8fa2334426d11" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.7.1", +] + +[[package]] +name = "polyval" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "postgrest" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e66400cb23a379592bc8c8bdc9adda652eef4a969b74ab78454a8e8c11330c2b" +dependencies = [ + "reqwest", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9825a04601d60621feed79c4e6b56d65db77cdca55cef43b46b0de1096d1c282" +dependencies = [ + "proc-macro2", + "syn 2.0.47", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.11", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "907a61bd0f64c2f29cd1cf1dc34d05176426a3f504a78010f08416ddb7b13708" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c289cda302b98a28d40c8b3b90498d6e526dd24ac2ecea73e4e491685b94a" +dependencies = [ + "bytes", + "prost-derive 0.12.3", +] + +[[package]] +name = "prost" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b0487d90e047de87f984913713b85c601c05609aad5b0df4b4573fbf69aa13f" +dependencies = [ + "bytes", + "prost-derive 0.13.3", +] + +[[package]] +name = "prost-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c55e02e35260070b6f716a2423c2ff1c3bb1642ddca6f99e1f26d06268a0e2d2" +dependencies = [ + "bytes", + "heck 0.4.1", + "itertools 0.10.5", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost 0.12.3", + "prost-types", + "regex", + "syn 2.0.47", + "tempfile", + "which", +] + +[[package]] +name = "prost-derive" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "prost-derive" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9552f850d5f0964a4e4d0bf306459ac29323ddfbae05e35a7c0d35cb0803cc5" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "prost-types" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "193898f59edcf43c26227dcd4c8427f00d99d61e95dcde58dabd49fa291d470e" +dependencies = [ + "prost 0.12.3", +] + +[[package]] +name = "protobuf" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" + +[[package]] +name = "protobuf-codegen" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "033460afb75cf755fcfc16dfaed20b86468082a2ea24e05ac35ab4a099a017d6" +dependencies = [ + "protobuf", +] + +[[package]] +name = "protoc" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0218039c514f9e14a5060742ecd50427f8ac4f85a6dc58f2ddb806e318c55ee" +dependencies = [ + "log", + "which", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ca8623e5633e298ad1f917d8be0a44bcf406bf3cde3b80e63003e49a3f27d" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb9fc9cce84c8694b6ea01cc6296617b288b703719b725b8c9c65f7c5874435" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d2a07dcf7173a04d49974930ccbfb7fd4d74df30ecfc8762cf2f895a094516" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54fef0b04fcacba64d1d80eed74a20356d96847da8497a59b0a0a436c9165b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8782f2ce7d43a9a5c74ea4936f001e9e8442205c244f7a3d4286bd4c37bc924" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5de656c7ee83f08e0ae5b81792ccfdc1d04e7876b1d9a38e6876a9e09e02537" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9653c3ed92974e34c5a6e0a510864dab979760481714c172e0a34e437cb98804" + +[[package]] +name = "protoc-rust" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f8a182bb17c485f20bdc4274a8c39000a61024cfe461c799b50fec77267838" +dependencies = [ + "protobuf", + "protobuf-codegen", + "protoc", + "tempfile", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "publicsuffix" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a8c1bda5ae1af7f99a2962e49df150414a43d62404644d98dd5c3a93d07457" +dependencies = [ + "idna 0.3.0", + "psl-types", +] + +[[package]] +name = "quanta" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5167a477619228a0b284fac2674e3c388cba90631d7b7de620e6f1fcd08da5" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi 0.11.0+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + +[[package]] +name = "quick-xml" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5e73202a820a31f8a0ee32ada5e21029c81fd9e3ebf668a40832e4219d9d1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot 0.12.1", + "scheduled-thread-pool", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.10", +] + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-cpuid" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9ee317cfe3fbd54b36a511efc1edd42e216903c9cd575e686dd68a2ba90d8d" +dependencies = [ + "bitflags 2.4.0", +] + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom 0.2.10", + "redox_syscall 0.2.16", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" +dependencies = [ + "aho-corasick 1.0.2", + "memchr", + "regex-automata 0.4.7", + "regex-syntax 0.8.4", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick 1.0.2", + "memchr", + "regex-syntax 0.8.4", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" + +[[package]] +name = "rend" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581008d2099240d37fb08d77ad713bcaec2c4d89d50b5b21a8bb1996bbab68ab" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.5", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", + "winreg 0.50.0", +] + +[[package]] +name = "rfd" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0149778bd99b6959285b0933288206090c50e2327f47a9c463bfdbf45c8823ea" +dependencies = [ + "block", + "dispatch", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "lazy_static", + "log", + "objc", + "objc-foundation", + "objc_id", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.37.0", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "rkyv" +version = "0.7.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200c8230b013893c0b2d6213d6ec64ed2b9be2e0e016682b7224ff82cff5c58" +dependencies = [ + "bitvec", + "bytecheck", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e06b915b5c230a17d7a736d1e2e63ee753c256a8614ef3f5147b13a4f5541d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rocksdb" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd13e55d6d7b8cd0ea569161127567cd587676c99f4472f779a0279aa60a7a7" +dependencies = [ + "libc", + "librocksdb-sys", +] + +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rust_decimal" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b082d80e3e3cc52b2ed634388d436fe1f4de6af5786cc2de9ba9737527bdf555" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rust_decimal_macros" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca5c398d85f83b9a44de754a2048625a8c5eafcf070da7b8f116b685e2f6608" +dependencies = [ + "quote", + "rust_decimal", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca" +dependencies = [ + "bitflags 2.4.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d194b56d58803a43635bdc398cd17e383d6f71f9182b9a192c127ca42494a59b" +dependencies = [ + "base64 0.21.5", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d93931baf2d282fff8d3a532bbfd7653f734643161b87e3e01e59a04439bf0d" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" + +[[package]] +name = "rusty-money" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b28f881005eac7ad8d46b6f075da5f322bd7f4f83a38720fc069694ddadd683" +dependencies = [ + "rust_decimal", + "rust_decimal_macros", +] + +[[package]] +name = "ryu" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" + +[[package]] +name = "safemem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sanitize-filename" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ed72fbaf78e6f2d41744923916966c4fbe3d7c74e3037a8ee482f1115572603" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "schannel" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "713cfb06c7059f3588fb8044c0fad1d09e3c01d225e25b9220dbfdcf16dbb1b3" +dependencies = [ + "windows-sys 0.42.0", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot 0.12.1", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "scraper" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c95a930e03325234c18c7071fd2b60118307e025d6fff3e12745ffbf63a3d29c" +dependencies = [ + "ahash 0.8.6", + "cssparser 0.31.2", + "ego-tree", + "getopts", + "html5ever 0.26.0", + "once_cell", + "selectors 0.25.0", + "smallvec", + "tendril", +] + +[[package]] +name = "scraper" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585480e3719b311b78a573db1c9d9c4c1f8010c2dee4cc59c2efe58ea4dbc3e1" +dependencies = [ + "ahash 0.8.6", + "cssparser 0.31.2", + "ego-tree", + "getopts", + "html5ever 0.26.0", + "once_cell", + "selectors 0.25.0", + "tendril", +] + +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f51d0c0d83bec45f16480d0ce0058397a69e48fcdc52d1dc8855fb68acbd31a7" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" +dependencies = [ + "bitflags 1.3.2", + "cssparser 0.27.2", + "derive_more", + "fxhash", + "log", + "matches", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc 0.1.1", + "smallvec", + "thin-slice", +] + +[[package]] +name = "selectors" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" +dependencies = [ + "bitflags 2.4.0", + "cssparser 0.31.2", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.10.1", + "phf_codegen 0.10.0", + "precomputed-hash", + "servo_arc 0.3.0", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "serde_derive_internals" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e578a843d40b4189a4d66bba51d7684f57da5bd7c304c64e14bd63efbef49509" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "serde_json" +version = "1.0.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +dependencies = [ + "itoa 1.0.6", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2e6b945e9d3df726b65d6ee24060aff8e3533d431f677a9695db04eff9dfdb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "serde_spanned" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96426c9936fd7a0124915f9185ea1d20aa9445cc9821142f0a73bc9207a2e186" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa 1.0.6", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f02d8aa6e3c385bf084924f660ce2a3a6bd333ba55b35e8590b321f35d88513" +dependencies = [ + "base64 0.21.5", + "chrono", + "hex", + "indexmap 1.9.3", + "serde", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc7d5d3932fb12ce722ee5e64dd38c504efba37567f0c402f6ca728c3b8b070" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9823f2d3b6a81d98228151fdeaf848206a7855a7a042bbf9bf870449a66cafb" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74064874e9f6a15f04c1f3cb627902d0e6b410abbf36668afa873c61889f1763" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "servo_arc" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "servo_arc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared-entity" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "app-error", + "appflowy-ai-client", + "bytes", + "chrono", + "collab-entity", + "database-entity", + "futures", + "gotrue-entity", + "infra", + "log", + "pin-project", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "tracing", + "uuid", + "validator 0.19.0", +] + +[[package]] +name = "shlex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simdutf8" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" + +[[package]] +name = "similar" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad1d488a557b235fc46dae55512ffbfc429d2482b08b4d9435ab07384ca8aec" + +[[package]] +name = "similar" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420acb44afdae038210c99e69aae24109f32f15500aa708e81d46c9f29d55fcf" + +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "siphasher" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de" + +[[package]] +name = "sketches-ddsketch" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" +dependencies = [ + "serde", +] + +[[package]] +name = "slab" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" +dependencies = [ + "autocfg", +] + +[[package]] +name = "slug" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bc762e6a4b6c6fcaade73e77f9ebc6991b676f88bb2358bddb56560f073373" +dependencies = [ + "deunicode", +] + +[[package]] +name = "smallstr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b1aefdf380735ff8ded0b15f31aab05daf1f70216c01c02a12926badd1df9d" +dependencies = [ + "smallvec", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "soup2" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b4d76501d8ba387cf0fefbe055c3e0a59891d09f0f995ae4e4b16f6b60f3c0" +dependencies = [ + "bitflags 1.3.2", + "gio", + "glib", + "libc", + "once_cell", + "soup2-sys", +] + +[[package]] +name = "soup2-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009ef427103fcb17f802871647a7fa6c60cbb654b4c4e4c0ac60a31c5f6dc9cf" +dependencies = [ + "bitflags 1.3.2", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps 5.0.0", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "state" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe866e1e51e8260c9eed836a042a5e7f6726bb2b411dffeaa712e19c388f23b" +dependencies = [ + "loom", +] + +[[package]] +name = "string_cache" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot 0.12.1", + "phf_shared 0.10.0", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" + +[[package]] +name = "strum_macros" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d06aaeeee809dbc59eb4556183dd927df67db1540de5be8d3ec0b6636358a5ec" +dependencies = [ + "heck 0.3.3", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "strum_macros" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8d03b598d3d0fff69bf533ee3ef19b8eeb342729596df84bcc7e1f96ec4059" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.47", +] + +[[package]] +name = "strum_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a3417fc93d76740d974a01654a09777cb500428cc874ca9f45edfe0c4d4cd18" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.47", +] + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1726efe18f42ae774cc644f330953a5e7b3c3003d3edcecf18850fe9d4dd9afb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn_derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1329189c02ff984e9736652b1631330da25eaa6bc639089ed4915d25446cbe7b" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "sysinfo" +version = "0.30.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fb4f3438c8f6389c864e61221cbc97e9bca98b4daf39a5beb7bea660f528bb2" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "rayon", + "windows 0.52.0", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18db855554db7bd0e73e06cf7ba3df39f97812cb11d3f75e71c39bf45171797e" +dependencies = [ + "cfg-expr 0.9.1", + "heck 0.3.3", + "pkg-config", + "toml 0.5.11", + "version-compare 0.0.11", +] + +[[package]] +name = "system-deps" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30c2de8a4d8f4b823d634affc9cd2a74ec98c53a756f317e529a48046cbf71f3" +dependencies = [ + "cfg-expr 0.15.3", + "heck 0.4.1", + "pkg-config", + "toml 0.7.5", + "version-compare 0.1.1", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tantivy" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d0582f186c0a6d55655d24543f15e43607299425c5ad8352c242b914b31856" +dependencies = [ + "aho-corasick 1.0.2", + "arc-swap", + "base64 0.22.1", + "bitpacking", + "byteorder", + "census", + "crc32fast", + "crossbeam-channel", + "downcast-rs", + "fastdivide", + "fnv", + "fs4", + "htmlescape", + "itertools 0.12.1", + "levenshtein_automata", + "log", + "lru", + "lz4_flex", + "measure_time", + "memmap2", + "num_cpus", + "once_cell", + "oneshot", + "rayon", + "regex", + "rust-stemmers", + "rustc-hash", + "serde", + "serde_json", + "sketches-ddsketch", + "smallvec", + "tantivy-bitpacker", + "tantivy-columnar", + "tantivy-common", + "tantivy-fst", + "tantivy-query-grammar", + "tantivy-stacker", + "tantivy-tokenizer-api", + "tempfile", + "thiserror", + "time", + "uuid", + "winapi", +] + +[[package]] +name = "tantivy-bitpacker" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284899c2325d6832203ac6ff5891b297fc5239c3dc754c5bc1977855b23c10df" +dependencies = [ + "bitpacking", +] + +[[package]] +name = "tantivy-columnar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12722224ffbe346c7fec3275c699e508fd0d4710e629e933d5736ec524a1f44e" +dependencies = [ + "downcast-rs", + "fastdivide", + "itertools 0.12.1", + "serde", + "tantivy-bitpacker", + "tantivy-common", + "tantivy-sstable", + "tantivy-stacker", +] + +[[package]] +name = "tantivy-common" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8019e3cabcfd20a1380b491e13ff42f57bb38bf97c3d5fa5c07e50816e0621f4" +dependencies = [ + "async-trait", + "byteorder", + "ownedbytes", + "serde", + "time", +] + +[[package]] +name = "tantivy-fst" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" +dependencies = [ + "byteorder", + "regex-syntax 0.8.4", + "utf8-ranges", +] + +[[package]] +name = "tantivy-query-grammar" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82" +dependencies = [ + "nom", +] + +[[package]] +name = "tantivy-sstable" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c69578242e8e9fc989119f522ba5b49a38ac20f576fc778035b96cc94f41f98e" +dependencies = [ + "tantivy-bitpacker", + "tantivy-common", + "tantivy-fst", + "zstd 0.13.2", +] + +[[package]] +name = "tantivy-stacker" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56d6ff5591fc332739b3ce7035b57995a3ce29a93ffd6012660e0949c956ea8" +dependencies = [ + "murmurhash32", + "rand_distr", + "tantivy-common", +] + +[[package]] +name = "tantivy-tokenizer-api" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0dcade25819a89cfe6f17d932c9cedff11989936bf6dd4f336d50392053b04" +dependencies = [ + "serde", +] + +[[package]] +name = "tao" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6d198e01085564cea63e976ad1566c1ba2c2e4cc79578e35d9f05521505e31" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "cc", + "cocoa", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch", + "gdk", + "gdk-pixbuf", + "gdk-sys", + "gdkwayland-sys", + "gdkx11-sys", + "gio", + "glib", + "glib-sys", + "gtk", + "image", + "instant", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc", + "once_cell", + "parking_lot 0.12.1", + "png", + "raw-window-handle", + "scopeguard", + "serde", + "tao-macros", + "unicode-segmentation", + "uuid", + "windows 0.39.0", + "windows-implement", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b27a4bcc5eb524658234589bdffc7e7bfb996dbae6ce9393bfd39cb4159b445" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b55807c0344e1e6c04d7c965f5289c39a8d94ae23ed5c0b57aabac549f871c6" +dependencies = [ + "filetime", + "libc", + "xattr 0.2.3", +] + +[[package]] +name = "target-lexicon" +version = "0.12.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1c7f239eb94671427157bd93b3694320f3668d4e1eff08c7285366fd777fac" + +[[package]] +name = "tauri" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bfe673cf125ef364d6f56b15e8ce7537d9ca7e4dae1cf6fbbdeed2e024db3d9" +dependencies = [ + "anyhow", + "cocoa", + "dirs-next", + "embed_plist", + "encoding_rs", + "flate2", + "futures-util", + "glib", + "glob", + "gtk", + "heck 0.4.1", + "http", + "ignore", + "objc", + "once_cell", + "open", + "percent-encoding", + "rand 0.8.5", + "raw-window-handle", + "regex", + "rfd", + "semver", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "state", + "tar", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "tempfile", + "thiserror", + "tokio", + "url", + "uuid", + "webkit2gtk", + "webview2-com", + "windows 0.39.0", +] + +[[package]] +name = "tauri-build" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defbfc551bd38ab997e5f8e458f87396d2559d05ce32095076ad6c30f7fc5f9c" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs-next", + "heck 0.4.1", + "json-patch", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3475e55acec0b4a50fb96435f19631fb58cbcd31923e1a213de5c382536bbb" +dependencies = [ + "base64 0.21.5", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "regex", + "semver", + "serde", + "serde_json", + "sha2", + "tauri-utils", + "thiserror", + "time", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613740228de92d9196b795ac455091d3a5fbdac2654abb8bb07d010b62ab43af" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 1.0.109", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin-deep-link" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4536f5f6602e8fdfaa7b3b185076c2a0704f8eb7015f4e58461eb483ec3ed1f8" +dependencies = [ + "dirs", + "interprocess", + "log", + "objc2", + "once_cell", + "tauri-utils", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + +[[package]] +name = "tauri-runtime" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f8e9e53e00e9f41212c115749e87d5cd2a9eebccafca77a19722eeecd56d43" +dependencies = [ + "gtk", + "http", + "http-range", + "rand 0.8.5", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror", + "url", + "uuid", + "webview2-com", + "windows 0.39.0", +] + +[[package]] +name = "tauri-runtime-wry" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8141d72b6b65f2008911e9ef5b98a68d1e3413b7a1464e8f85eb3673bb19a895" +dependencies = [ + "cocoa", + "gtk", + "percent-encoding", + "rand 0.8.5", + "raw-window-handle", + "tauri-runtime", + "tauri-utils", + "uuid", + "webkit2gtk", + "webview2-com", + "windows 0.39.0", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece74810b1d3d44f29f732a7ae09a63183d63949bbdd59c61f8ed2a1b70150db" +dependencies = [ + "brotli", + "ctor", + "dunce", + "glob", + "heck 0.4.1", + "html5ever 0.26.0", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.2", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "serde_with", + "thiserror", + "url", + "walkdir", + "windows-version", +] + +[[package]] +name = "tauri-winres" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5993dc129e544393574288923d1ec447c857f3f644187f4fbf7d9a875fbfc4fb" +dependencies = [ + "embed-resource", + "toml 0.7.5", +] + +[[package]] +name = "tempfile" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa" +dependencies = [ + "cfg-if", + "fastrand", + "redox_syscall 0.4.1", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "tera" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ab29bb4f3e256ae6ad5c3e2775aa1f8829f2c0c101fc407bfd3a6df15c60c5" +dependencies = [ + "chrono", + "chrono-tz 0.6.1", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.5", + "regex", + "serde", + "serde_json", + "slug", + "thread_local", + "unic-segment", +] + +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "thin-slice" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" + +[[package]] +name = "thiserror" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "thread-id" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fbf4c9d56b320106cd64fd024dadfa0be7cb4706725fc44a7d7ce952d820c1" +dependencies = [ + "libc", + "redox_syscall 0.1.57", + "winapi", +] + +[[package]] +name = "thread_local" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180" +dependencies = [ + "once_cell", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa 1.0.6", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "to_method" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8" + +[[package]] +name = "tokio" +version = "1.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot 0.12.1", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.5.5", + "tokio-macros", + "tracing", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-retry" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f57eb36ecbe0fc510036adff84824dd3c24bb781e21bfa67b69d556aa85214f" +dependencies = [ + "pin-project", + "rand 0.8.5", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "hashbrown 0.14.3", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebafdf5ad1220cb59e7d17cf4d2c72015297b75b19a10472f99b89225089240" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.19.11", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "266f016b7f039eec8a1a80dfe6156b633d208b9fccca5e4db1d6775b0c4e34a7" +dependencies = [ + "indexmap 2.1.0", + "serde", + "serde_spanned", + "toml_datetime", + "winnow 0.4.7", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.1.0", + "toml_datetime", + "winnow 0.5.40", +] + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "tracing-bunyan-formatter" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d637245a0d8774bd48df6482e086c59a8b5348a910c3b0579354045a9d82411" +dependencies = [ + "ahash 0.8.6", + "gethostname", + "log", + "serde", + "serde_json", + "time", + "tracing", + "tracing-core", + "tracing-log 0.1.3", + "tracing-subscriber", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" +dependencies = [ + "lazy_static", + "log", + "tracing-core", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log 0.2.0", + "tracing-serde", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[package]] +name = "treediff" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52984d277bdf2a751072b5df30ec0377febdb02f7696d64c2d7d54630bac4303" +dependencies = [ + "serde_json", +] + +[[package]] +name = "triomphe" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859eb650cfee7434994602c3a68b25d77ad9e68c8a6cd491616ef86661382eb3" + +[[package]] +name = "try-lock" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" + +[[package]] +name = "tsify" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b26cf145f2f3b9ff84e182c448eaf05468e247f148cf3d2a7d67d78ff023a0" +dependencies = [ + "gloo-utils", + "serde", + "serde_json", + "tsify-macros", + "wasm-bindgen", +] + +[[package]] +name = "tsify-macros" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a94b0f0954b3e59bfc2c246b4c8574390d94a4ad4ad246aaf2fb07d7dfd3b47" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.47", +] + +[[package]] +name = "tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" + +[[package]] +name = "ucd-trie" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" + +[[package]] +name = "uncased" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b9bc53168a4be7402ab86c3aad243a84dd7381d09be0eddc81280c1da95ca68" +dependencies = [ + "version_check", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23" +dependencies = [ + "unic-ucd-segment", +] + +[[package]] +name = "unic-ucd-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + +[[package]] +name = "unicode-id" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1b6def86329695390197b82c1e244a54a131ceb66c996f2088a3876e2ae083f" + +[[package]] +name = "unicode-ident" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna 0.5.0", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8-ranges" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +dependencies = [ + "getrandom 0.2.10", + "serde", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db79c75af171630a3148bd3e6d7c4f42b6a9a014c2945bc5ed0020cbb8d9478e" +dependencies = [ + "idna 0.5.0", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive 0.18.2", +] + +[[package]] +name = "validator" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0b4a29d8709210980a09379f27ee31549b73292c87ab9899beee1c0d3be6303" +dependencies = [ + "idna 1.0.3", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive 0.19.0", +] + +[[package]] +name = "validator_derive" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df0bcf92720c40105ac4b2dda2a4ea3aa717d4d6a862cc217da653a4bd5c6b10" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "validator_derive" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac855a2ce6f843beb229757e6e570a42e837bcb15e5f449dd48d5747d41bf77" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c18c859eead79d8b95d09e4678566e8d70105c4e7b251f707a03df32442661b" + +[[package]] +name = "version-compare" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b17ae1f6c8a2b28506cd96d412eebf83b4a0ff2cbefeeb952f2f9dfa44ba18" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1223296a201415c7fad14792dbefaace9bd52b62d33453ade1c5b5f07555406" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcdc935b63408d58a32f8cc9738a0bffd8f05cc7c002086c6ef20b7312ad9dcd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.47", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde2032aeb86bdfaecc8b261eef3cba735cc426c1f3a3416d1e0791be95fc461" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e4c238561b2d428924c49815533a8b9121c664599558a5d9ec51f8a1740a999" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae1abb6806dc1ad9e560ed242107c0f6c84335f1749dd4e8ddb012ebd5e25a7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d91413b1c31d7539ba5ef2451af3f0b833a005eb27a631cec32bc0635a8602b" + +[[package]] +name = "wasm-streams" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-timer" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.11.2", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8f859735e4a452aeb28c6c56a852967a8a76c8eb1cc32dbf931ad28a13d6370" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup2", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d76ca6ecc47aeba01ec61e480139dda143796abcae6f83bcddf50d6b5b1dcf3" +dependencies = [ + "atk-sys", + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pango-sys", + "pkg-config", + "soup2-sys", + "system-deps 6.1.1", +] + +[[package]] +name = "webpki-roots" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc" + +[[package]] +name = "webview2-com" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a769c9f1a64a8734bde70caafac2b96cada12cd4aefa49196b3a386b8b4178" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.39.0", + "windows-implement", +] + +[[package]] +name = "webview2-com-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaebe196c01691db62e9e4ca52c5ef1e4fd837dcae27dae3ada599b5a8fd05ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "webview2-com-sys" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac48ef20ddf657755fdcda8dfed2a7b4fc7e4581acce6fe9b88c3d64f29dee7" +dependencies = [ + "regex", + "serde", + "serde_json", + "thiserror", + "windows 0.39.0", + "windows-bindgen", + "windows-metadata", +] + +[[package]] +name = "which" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269" +dependencies = [ + "either", + "libc", + "once_cell", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57b543186b344cc61c85b5aab0d2e3adf4e0f99bc076eff9aa5927bcc0b8a647" +dependencies = [ + "windows_aarch64_msvc 0.37.0", + "windows_i686_gnu 0.37.0", + "windows_i686_msvc 0.37.0", + "windows_x86_64_gnu 0.37.0", + "windows_x86_64_msvc 0.37.0", +] + +[[package]] +name = "windows" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c4bd0a50ac6020f65184721f758dba47bb9fbc2133df715ec74a237b26794a" +dependencies = [ + "windows-implement", + "windows_aarch64_msvc 0.39.0", + "windows_i686_gnu 0.39.0", + "windows_i686_msvc 0.39.0", + "windows_x86_64_gnu 0.39.0", + "windows_x86_64_msvc 0.39.0", +] + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.0", +] + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-bindgen" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68003dbd0e38abc0fb85b939240f4bce37c43a5981d3df37ccbaaa981b47cb41" +dependencies = [ + "windows-metadata", + "windows-tokens", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-implement" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba01f98f509cb5dc05f4e5fc95e535f78260f15fea8fe1a8abdd08f774f1cee7" +dependencies = [ + "syn 1.0.109", + "windows-tokens", +] + +[[package]] +name = "windows-metadata" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ee5e275231f07c6e240d14f34e1b635bf1faa1c76c57cfd59a5cdb9848e4278" + +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", +] + +[[package]] +name = "windows-tokens" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f838de2fe15fe6bac988e74b798f26499a8b21a9d97edec321e79b28d1d7f597" + +[[package]] +name = "windows-version" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75aa004c988e080ad34aff5739c39d0312f4684699d6d71fc8a198d057b8b9b4" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7711666096bd4096ffa835238905bb33fb87267910e154b18b44eaabb340f2" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + +[[package]] +name = "windows_i686_gnu" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1" + +[[package]] +name = "windows_i686_gnu" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "763fc57100a5f7042e3057e7e8d9bdd7860d330070251a73d003563a3bb49e1b" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + +[[package]] +name = "windows_i686_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c" + +[[package]] +name = "windows_i686_msvc" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bc7cbfe58828921e10a9f446fcaaf649204dcfe6c1ddd712c5eebae6bda1106" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6868c165637d653ae1e8dc4d82c25d4f97dd6605eaa8d784b5c6e0ab2a252b65" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e4d40883ae9cae962787ca76ba76390ffa29214667a111db9e0a1ad8377e809" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "winnow" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca0ace3845f0d96209f0375e6d367e3eb87eb65d27d445bdc9f1843a26f39448" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a1a57ff50e9b408431e8f97d5456f2807f8eb2a2cd79b06068fc87f8ecf189" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "wry" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a70547e8f9d85da0f5af609143f7bde3ac7457a6e1073104d9b73d6c5ac744" +dependencies = [ + "base64 0.13.1", + "block", + "cocoa", + "core-graphics", + "crossbeam-channel", + "dunce", + "gdk", + "gio", + "glib", + "gtk", + "html5ever 0.25.2", + "http", + "kuchiki", + "libc", + "log", + "objc", + "objc_id", + "once_cell", + "serde", + "serde_json", + "sha2", + "soup2", + "tao", + "thiserror", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.39.0", + "windows-implement", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc" +dependencies = [ + "libc", +] + +[[package]] +name = "xattr" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f" +dependencies = [ + "libc", + "linux-raw-sys", + "rustix", +] + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", + "synstructure", +] + +[[package]] +name = "yrs" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81de5913bca29f43a1d12ca92a7b39a2945e9420e01602a7563917c7bfc60f70" +dependencies = [ + "arc-swap", + "async-lock", + "async-trait", + "dashmap 6.0.1", + "fastrand", + "serde", + "serde_json", + "smallstr", + "smallvec", + "thiserror", +] + +[[package]] +name = "zerocopy" +version = "0.7.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e97e415490559a91254a2979b4829267a57d2fcd741a98eee8b722fb57289aa0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7e48ccf166952882ca8bd778a43502c64f33bf94c12ebe2a7f08e5a0f6689f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.47", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq 0.1.5", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2 0.11.0", + "sha1", + "time", + "zstd 0.11.2+zstd.1.5.2", +] + +[[package]] +name = "zip" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc5e4288ea4057ae23afc69a4472434a87a2495cafce6632fd1c4ec9f5cf3494" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq 0.3.0", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "hmac", + "indexmap 2.1.0", + "lzma-rs", + "memchr", + "pbkdf2 0.12.2", + "rand 0.8.5", + "sha1", + "thiserror", + "time", + "zeroize", + "zopfli", + "zstd 0.13.2", +] + +[[package]] +name = "zip-extensions" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb0a99499b3497d765525c5d05e3ade9ca4a731c184365c19472c3fd6ba86341" +dependencies = [ + "zip 2.2.0", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe 5.0.2+zstd.1.5.2", +] + +[[package]] +name = "zstd" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" +dependencies = [ + "zstd-safe 7.2.0", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-safe" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa556e971e7b568dc775c136fc9de8c779b1c2fc3a63defaafadffdbd3181afa" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.12+zstd.1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a4e40c320c3cb459d9a9ff6de98cff88f4751ee9275d140e2be94a2b74e4c13" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml new file mode 100644 index 0000000000000..982c06a2e96c6 --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -0,0 +1,137 @@ +[package] +name = "appflowy_tauri" +version = "0.0.0" +description = "A Tauri App" +authors = ["you"] +license = "" +repository = "" +edition = "2021" +rust-version = "1.57" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[build-dependencies] +tauri-build = { version = "1.5", features = [] } + +[workspace.dependencies] +anyhow = "1.0" +tracing = "0.1.40" +bytes = "1.5.0" +serde = "1.0" +serde_json = "1.0.108" +protobuf = { version = "2.28.0" } +diesel = { version = "2.1.0", features = [ + "sqlite", + "chrono", + "r2d2", + "serde_json", +] } +uuid = { version = "1.5.0", features = ["serde", "v4"] } +serde_repr = "0.1" +parking_lot = "0.12" +futures = "0.3.29" +tokio = "1.34.0" +tokio-stream = "0.1.14" +async-trait = "0.1.74" +chrono = { version = "0.4.31", default-features = false, features = ["clock"] } +zip = "2.2.0" +yrs = "0.19.1" +# Please use the following script to update collab. +# Working directory: frontend +# +# To update the commit ID, run: +# scripts/tool/update_collab_rev.sh new_rev_id +# +# To switch to the local path, run: +# scripts/tool/update_collab_source.sh +# ⚠️⚠️⚠️️ +collab = { version = "0.2" } +collab-entity = { version = "0.2" } +collab-folder = { version = "0.2" } +collab-document = { version = "0.2" } +collab-database = { version = "0.2" } +collab-plugins = { version = "0.2" } +collab-user = { version = "0.2" } +collab-importer = { version = "0.1" } + +# Please using the following command to update the revision id +# Current directory: frontend +# Run the script: +# scripts/tool/update_client_api_rev.sh new_rev_id +# ⚠️⚠️⚠️️ +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ea131f0baab67defe7591067357eced490072372" } + +[dependencies] +serde_json.workspace = true +serde.workspace = true +tauri = { version = "1.5", features = [ + "dialog-all", + "clipboard-all", + "fs-all", + "shell-open", +] } +tauri-utils = "1.5.2" +bytes.workspace = true +tracing.workspace = true +lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = [ + "use_serde", +] } +flowy-core = { path = "../../rust-lib/flowy-core", features = ["ts"] } +flowy-user = { path = "../../rust-lib/flowy-user", features = ["tauri_ts"] } +flowy-config = { path = "../../rust-lib/flowy-config", features = ["tauri_ts"] } +flowy-date = { path = "../../rust-lib/flowy-date", features = ["tauri_ts"] } +flowy-ai = { path = "../../rust-lib/flowy-ai", features = ["tauri_ts"] } +flowy-error = { path = "../../rust-lib/flowy-error", features = [ + "impl_from_sqlite", + "impl_from_dispatch_error", + "impl_from_appflowy_cloud", + "impl_from_reqwest", + "impl_from_serde", + "tauri_ts", +] } +flowy-search = { path = "../../rust-lib/flowy-search", features = ["tauri_ts"] } +flowy-document = { path = "../../rust-lib/flowy-document", features = [ + "tauri_ts", +] } +flowy-notification = { path = "../../rust-lib/flowy-notification", features = [ + "tauri_ts", +] } + +uuid = "1.5.0" +tauri-plugin-deep-link = "0.1.2" +dotenv = "0.15.0" +semver = "1.0.23" + +[features] +# by default Tauri runs in production mode +# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL +default = ["custom-protocol"] +# this feature is used used for production builds where `devPath` points to the filesystem +# DO NOT remove this +custom-protocol = ["tauri/custom-protocol"] + +[patch.crates-io] +# Please use the following script to update collab. +# Working directory: frontend +# +# To update the commit ID, run: +# scripts/tool/update_collab_rev.sh new_rev_id +# +# To switch to the local path, run: +# scripts/tool/update_collab_source.sh +# ⚠️⚠️⚠️️ +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } + +# Working directory: frontend +# To update the commit ID, run: +# scripts/tool/update_local_ai_rev.sh new_rev_id +# ⚠️⚠️⚠️️ +appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "6f064efe232268f8d396edbb4b84d57fbb640f13" } +appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "6f064efe232268f8d396edbb4b84d57fbb640f13" } diff --git a/frontend/appflowy_tauri/src-tauri/Info.plist b/frontend/appflowy_tauri/src-tauri/Info.plist new file mode 100644 index 0000000000000..25b430c049f36 --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/Info.plist @@ -0,0 +1,19 @@ + + + + + + CFBundleURLTypes + + + CFBundleURLName + + appflowy-flutter + CFBundleURLSchemes + + appflowy-flutter + + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src-tauri/build.rs b/frontend/appflowy_tauri/src-tauri/build.rs new file mode 100644 index 0000000000000..d860e1e6a7cac --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/frontend/appflowy_tauri/src-tauri/env.development b/frontend/appflowy_tauri/src-tauri/env.development new file mode 100644 index 0000000000000..188835e3d05be --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/env.development @@ -0,0 +1,4 @@ +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_BASE_URL=https://test.appflowy.cloud +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_WS_BASE_URL=wss://test.appflowy.cloud/ws/v1 +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_GOTRUE_URL=https://test.appflowy.cloud/gotrue +APPFLOWY_CLOUD_ENV_CLOUD_TYPE=2 diff --git a/frontend/appflowy_tauri/src-tauri/env.production b/frontend/appflowy_tauri/src-tauri/env.production new file mode 100644 index 0000000000000..b03c328b84eac --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/env.production @@ -0,0 +1,4 @@ +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_BASE_URL=https://beta.appflowy.cloud +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_WS_BASE_URL=wss://beta.appflowy.cloud/ws/v1 +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_GOTRUE_URL=https://beta.appflowy.cloud/gotrue +APPFLOWY_CLOUD_ENV_CLOUD_TYPE=2 diff --git a/frontend/appflowy_tauri/src-tauri/icons/128x128.png b/frontend/appflowy_tauri/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000..3a51041313f50 Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/128x128.png differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/128x128@2x.png b/frontend/appflowy_tauri/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000..9076de3a4b004 Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/128x128@2x.png differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/32x32.png b/frontend/appflowy_tauri/src-tauri/icons/32x32.png new file mode 100644 index 0000000000000..6ae6683fefb97 Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/32x32.png differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square107x107Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000000000..b08dcf7d21ab9 Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/Square107x107Logo.png differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square142x142Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000000000..f3e437b76e43e Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/Square142x142Logo.png differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square150x150Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000000000..6a1dc04864d95 Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/Square150x150Logo.png differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square284x284Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000000000..2f2d9d6fe630c Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/Square284x284Logo.png differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square30x30Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000000000..46e3802c0bd4a Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/Square30x30Logo.png differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square310x310Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000000000..230b1abe58c88 Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/Square310x310Logo.png differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square44x44Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000000000..ad188037a377e Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/Square44x44Logo.png differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square71x71Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000000000..ceae9ad1bb025 Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/Square71x71Logo.png differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/Square89x89Logo.png b/frontend/appflowy_tauri/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000000000..123dcea650fb2 Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/Square89x89Logo.png differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/StoreLogo.png b/frontend/appflowy_tauri/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000000000..d7906c3c03478 Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/StoreLogo.png differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/icon.icns b/frontend/appflowy_tauri/src-tauri/icons/icon.icns new file mode 100644 index 0000000000000..74b585f25dfbd Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/icon.icns differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/icon.ico b/frontend/appflowy_tauri/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000..cd9ad402d1c9d Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/icon.ico differ diff --git a/frontend/appflowy_tauri/src-tauri/icons/icon.png b/frontend/appflowy_tauri/src-tauri/icons/icon.png new file mode 100644 index 0000000000000..7cc3853d67255 Binary files /dev/null and b/frontend/appflowy_tauri/src-tauri/icons/icon.png differ diff --git a/frontend/appflowy_tauri/src-tauri/rust-toolchain.toml b/frontend/appflowy_tauri/src-tauri/rust-toolchain.toml new file mode 100644 index 0000000000000..6f14058b2e28e --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.77.2" diff --git a/frontend/appflowy_tauri/src-tauri/rustfmt.toml b/frontend/appflowy_tauri/src-tauri/rustfmt.toml new file mode 100644 index 0000000000000..5cb0d67ee549e --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/rustfmt.toml @@ -0,0 +1,12 @@ +# https://rust-lang.github.io/rustfmt/?version=master&search= +max_width = 100 +tab_spaces = 2 +newline_style = "Auto" +match_block_trailing_comma = true +use_field_init_shorthand = true +use_try_shorthand = true +reorder_imports = true +reorder_modules = true +remove_nested_parens = true +merge_derives = true +edition = "2021" \ No newline at end of file diff --git a/frontend/appflowy_tauri/src-tauri/src/init.rs b/frontend/appflowy_tauri/src-tauri/src/init.rs new file mode 100644 index 0000000000000..4903e1fe343e1 --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/src/init.rs @@ -0,0 +1,78 @@ +use dotenv::dotenv; +use flowy_core::config::AppFlowyCoreConfig; +use flowy_core::{AppFlowyCore, DEFAULT_NAME}; +use lib_dispatch::runtime::AFPluginRuntime; +use std::sync::Mutex; + +pub fn read_env() { + dotenv().ok(); + + let env = if cfg!(debug_assertions) { + include_str!("../env.development") + } else { + include_str!("../env.production") + }; + + for line in env.lines() { + if let Some((key, value)) = line.split_once('=') { + // Check if the environment variable is not already set in the system + let current_value = std::env::var(key).unwrap_or_default(); + if current_value.is_empty() { + std::env::set_var(key, value); + } + } + } +} + +pub(crate) fn init_appflowy_core() -> MutexAppFlowyCore { + let config_json = include_str!("../tauri.conf.json"); + let config: tauri_utils::config::Config = serde_json::from_str(config_json).unwrap(); + + let app_version = config + .package + .version + .clone() + .map(|v| v.to_string()) + .unwrap_or_else(|| "0.5.8".to_string()); + let app_version = + semver::Version::parse(&app_version).unwrap_or_else(|_| semver::Version::new(0, 5, 8)); + let mut data_path = tauri::api::path::app_local_data_dir(&config).unwrap(); + if cfg!(debug_assertions) { + data_path.push("data_dev"); + } else { + data_path.push("data"); + } + + let custom_application_path = data_path.to_str().unwrap().to_string(); + let application_path = data_path.to_str().unwrap().to_string(); + let device_id = uuid::Uuid::new_v4().to_string(); + + read_env(); + std::env::set_var("RUST_LOG", "trace"); + + let config = AppFlowyCoreConfig::new( + app_version, + custom_application_path, + application_path, + device_id, + "tauri".to_string(), + DEFAULT_NAME.to_string(), + ) + .log_filter("trace", vec!["appflowy_tauri".to_string()]); + + let runtime = Arc::new(AFPluginRuntime::new().unwrap()); + let cloned_runtime = runtime.clone(); + runtime.block_on(async move { + MutexAppFlowyCore::new(AppFlowyCore::new(config, cloned_runtime, None).await) + }) +} + +pub struct MutexAppFlowyCore(pub Arc>); + +impl MutexAppFlowyCore { + fn new(appflowy_core: AppFlowyCore) -> Self { + Self(Arc::new(Mutex::new(appflowy_core))) + } +} +unsafe impl Sync for MutexAppFlowyCore {} +unsafe impl Send for MutexAppFlowyCore {} diff --git a/frontend/appflowy_tauri/src-tauri/src/main.rs b/frontend/appflowy_tauri/src-tauri/src/main.rs new file mode 100644 index 0000000000000..5f12d1be81126 --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/src/main.rs @@ -0,0 +1,72 @@ +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +#[allow(dead_code)] +pub const DEEP_LINK_SCHEME: &str = "appflowy-flutter"; +pub const OPEN_DEEP_LINK: &str = "open_deep_link"; + +mod init; +mod notification; +mod request; + +use crate::init::init_appflowy_core; +use crate::request::invoke_request; +use flowy_notification::{register_notification_sender, unregister_all_notification_sender}; +use notification::*; +use tauri::Manager; + +extern crate dotenv; + +fn main() { + tauri_plugin_deep_link::prepare(DEEP_LINK_SCHEME); + + let flowy_core = init_appflowy_core(); + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![invoke_request]) + .manage(flowy_core) + .on_window_event(|_window_event| {}) + .on_menu_event(|_menu| {}) + .on_page_load(|window, _payload| { + let app_handler = window.app_handle(); + // Make sure hot reload won't register the notification sender twice + unregister_all_notification_sender(); + register_notification_sender(TSNotificationSender::new(app_handler.clone())); + // tauri::async_runtime::spawn(async move {}); + + window.listen_global(AF_EVENT, move |event| { + on_event(app_handler.clone(), event); + }); + }) + .setup(|_app| { + let splashscreen_window = _app.get_window("splashscreen").unwrap(); + let window = _app.get_window("main").unwrap(); + let handle = _app.handle(); + + // we perform the initialization code on a new task so the app doesn't freeze + tauri::async_runtime::spawn(async move { + // initialize your app here instead of sleeping :) + std::thread::sleep(std::time::Duration::from_secs(2)); + + // After it's done, close the splashscreen and display the main window + splashscreen_window.close().unwrap(); + window.show().unwrap(); + // If you need macOS support this must be called in .setup() ! + // Otherwise this could be called right after prepare() but then you don't have access to tauri APIs + // On macOS You still have to install a .app bundle you got from tauri build --debug for this to work! + tauri_plugin_deep_link::register( + DEEP_LINK_SCHEME, + move |request| { + dbg!(&request); + handle.emit_all(OPEN_DEEP_LINK, request).unwrap(); + }, + ) + .unwrap(/* If listening to the scheme is optional for your app, you don't want to unwrap here. */); + }); + + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/frontend/appflowy_tauri/src-tauri/src/notification.rs b/frontend/appflowy_tauri/src-tauri/src/notification.rs new file mode 100644 index 0000000000000..b42541edeccd7 --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/src/notification.rs @@ -0,0 +1,35 @@ +use flowy_notification::entities::SubscribeObject; +use flowy_notification::NotificationSender; +use serde::Serialize; +use tauri::{AppHandle, Event, Manager, Wry}; + +#[allow(dead_code)] +pub const AF_EVENT: &str = "af-event"; +pub const AF_NOTIFICATION: &str = "af-notification"; + +#[tracing::instrument(level = "trace")] +pub fn on_event(app_handler: AppHandle, event: Event) {} + +#[allow(dead_code)] +pub fn send_notification(app_handler: AppHandle, payload: P) { + app_handler.emit_all(AF_NOTIFICATION, payload).unwrap(); +} + +pub struct TSNotificationSender { + handler: AppHandle, +} + +impl TSNotificationSender { + pub fn new(handler: AppHandle) -> Self { + Self { handler } + } +} + +impl NotificationSender for TSNotificationSender { + fn send_subject(&self, subject: SubscribeObject) -> Result<(), String> { + self + .handler + .emit_all(AF_NOTIFICATION, subject) + .map_err(|e| format!("{:?}", e)) + } +} diff --git a/frontend/appflowy_tauri/src-tauri/src/request.rs b/frontend/appflowy_tauri/src-tauri/src/request.rs new file mode 100644 index 0000000000000..ff69a438c9c19 --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/src/request.rs @@ -0,0 +1,45 @@ +use crate::init::MutexAppFlowyCore; +use lib_dispatch::prelude::{ + AFPluginDispatcher, AFPluginEventResponse, AFPluginRequest, StatusCode, +}; +use tauri::{AppHandle, Manager, State, Wry}; + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct AFTauriRequest { + ty: String, + payload: Vec, +} + +impl std::convert::From for AFPluginRequest { + fn from(event: AFTauriRequest) -> Self { + AFPluginRequest::new(event.ty).payload(event.payload) + } +} + +#[derive(Clone, serde::Serialize)] +pub struct AFTauriResponse { + code: StatusCode, + payload: Vec, +} + +impl std::convert::From for AFTauriResponse { + fn from(response: AFPluginEventResponse) -> Self { + Self { + code: response.status_code, + payload: response.payload.to_vec(), + } + } +} + +// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command +#[tauri::command] +pub async fn invoke_request( + request: AFTauriRequest, + app_handler: AppHandle, +) -> AFTauriResponse { + let request: AFPluginRequest = request.into(); + let state: State = app_handler.state(); + let dispatcher = state.0.lock().unwrap().dispatcher(); + let response = AFPluginDispatcher::sync_send(dispatcher, request); + response.into() +} diff --git a/frontend/appflowy_tauri/src-tauri/tauri.conf.json b/frontend/appflowy_tauri/src-tauri/tauri.conf.json new file mode 100644 index 0000000000000..11dd7c206c463 --- /dev/null +++ b/frontend/appflowy_tauri/src-tauri/tauri.conf.json @@ -0,0 +1,113 @@ +{ + "build": { + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "pnpm run build", + "devPath": "http://localhost:1420", + "distDir": "../dist", + "withGlobalTauri": false + }, + "package": { + "productName": "AppFlowy", + "version": "0.0.1" + }, + "tauri": { + "allowlist": { + "all": false, + "shell": { + "all": false, + "open": true + }, + "fs": { + "all": true, + "scope": [ + "$APPLOCALDATA/**" + ], + "readFile": true, + "writeFile": true, + "readDir": true, + "copyFile": true, + "createDir": true, + "removeDir": true, + "removeFile": true, + "renameFile": true, + "exists": true + }, + "clipboard": { + "all": true, + "writeText": true, + "readText": true + }, + "dialog": { + "all": true, + "ask": true, + "confirm": true, + "message": true, + "open": true, + "save": true + } + }, + "bundle": { + "active": true, + "category": "DeveloperTool", + "copyright": "", + "deb": { + "depends": [] + }, + "externalBin": [], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "identifier": "com.appflowy.tauri", + "longDescription": "", + "macOS": { + "entitlements": null, + "exceptionDomain": "", + "frameworks": [], + "providerShortName": null, + "signingIdentity": null, + "minimumSystemVersion": "10.15.0" + }, + "resources": [], + "shortDescription": "", + "targets": "all", + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "" + } + }, + "security": { + "csp": null + }, + "updater": { + "active": false + }, + "windows": [ + { + "fileDropEnabled": false, + "fullscreen": false, + "height": 800, + "resizable": true, + "title": "AppFlowy", + "width": 1200, + "minWidth": 800, + "minHeight": 600, + "visible": false, + "label": "main" + }, + { + "height": 300, + "width": 549, + "decorations": false, + "url": "launch_splash.jpg", + "label": "splashscreen", + "center": true, + "visible": true + } + ] + } +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/@types/i18next.d.ts b/frontend/appflowy_tauri/src/appflowy_app/@types/i18next.d.ts new file mode 100644 index 0000000000000..6adbb4a5124a0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/@types/i18next.d.ts @@ -0,0 +1,8 @@ +import resources from './resources'; + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: 'translation'; + resources: typeof resources; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/@types/resources.ts b/frontend/appflowy_tauri/src/appflowy_app/@types/resources.ts new file mode 100644 index 0000000000000..479f05f013d5b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/@types/resources.ts @@ -0,0 +1,7 @@ +import translation from '$app/i18n/translations/en.json'; + +const resources = { + translation, +} as const; + +export default resources; diff --git a/frontend/appflowy_tauri/src/appflowy_app/App.tsx b/frontend/appflowy_tauri/src/appflowy_app/App.tsx new file mode 100644 index 0000000000000..938173734159c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/App.tsx @@ -0,0 +1,25 @@ +import { BrowserRouter } from 'react-router-dom'; + +import { Provider } from 'react-redux'; +import { store } from './stores/store'; + +import { ErrorHandlerPage } from './components/error/ErrorHandlerPage'; +import '$app/i18n/config'; + +import { ErrorBoundary } from 'react-error-boundary'; + +import AppMain from '$app/AppMain'; + +const App = () => { + return ( + + + + + + + + ); +}; + +export default App; diff --git a/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts new file mode 100644 index 0000000000000..9c46b8ab38343 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts @@ -0,0 +1,69 @@ +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { useEffect, useMemo } from 'react'; +import { currentUserActions, LoginState } from '$app_reducers/current-user/slice'; +import { Theme as ThemeType, ThemeMode } from '$app/stores/reducers/current-user/slice'; +import { createTheme } from '@mui/material/styles'; +import { getDesignTokens } from '$app/utils/mui'; +import { useTranslation } from 'react-i18next'; +import { UserService } from '$app/application/user/user.service'; + +export function useUserSetting() { + const dispatch = useAppDispatch(); + const { i18n } = useTranslation(); + const loginState = useAppSelector((state) => state.currentUser.loginState); + + const { themeMode = ThemeMode.System, theme: themeType = ThemeType.Default } = useAppSelector((state) => { + return { + themeMode: state.currentUser.userSetting.themeMode, + theme: state.currentUser.userSetting.theme, + }; + }); + + const isDark = + themeMode === ThemeMode.Dark || + (themeMode === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches); + + useEffect(() => { + if (loginState !== LoginState.Success && loginState !== undefined) return; + void (async () => { + const settings = await UserService.getAppearanceSetting(); + + if (!settings) return; + dispatch(currentUserActions.setUserSetting(settings)); + await i18n.changeLanguage(settings.language); + })(); + }, [dispatch, i18n, loginState]); + + useEffect(() => { + const html = document.documentElement; + + html?.setAttribute('data-dark-mode', String(isDark)); + }, [isDark]); + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleSystemThemeChange = () => { + if (themeMode !== ThemeMode.System) return; + dispatch( + currentUserActions.setUserSetting({ + isDark: mediaQuery.matches, + }) + ); + }; + + mediaQuery.addEventListener('change', handleSystemThemeChange); + + return () => { + mediaQuery.removeEventListener('change', handleSystemThemeChange); + }; + }, [dispatch, themeMode]); + + const muiTheme = useMemo(() => createTheme(getDesignTokens(isDark)), [isDark]); + + return { + muiTheme, + themeMode, + themeType, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx b/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx new file mode 100644 index 0000000000000..76bdb167b0523 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Route, Routes } from 'react-router-dom'; +import { ProtectedRoutes } from '$app/components/auth/ProtectedRoutes'; +import { DatabasePage } from '$app/views/DatabasePage'; + +import { ThemeProvider } from '@mui/material'; +import { useUserSetting } from '$app/AppMain.hooks'; +import TrashPage from '$app/views/TrashPage'; +import DocumentPage from '$app/views/DocumentPage'; +import { Toaster } from 'react-hot-toast'; +import AppFlowyDevTool from '$app/components/_shared/devtool/AppFlowyDevTool'; + +function AppMain() { + const { muiTheme } = useUserSetting(); + + return ( + + + }> + } /> + } /> + } /> + + + + {process.env.NODE_ENV === 'development' && } + + ); +} + +export default AppMain; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_listeners.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_listeners.ts new file mode 100644 index 0000000000000..c5c94daebc805 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_listeners.ts @@ -0,0 +1,48 @@ +import { Database } from '$app/application/database'; +import { getCell } from './cell_service'; + +export function didDeleteCells({ database, rowId, fieldId }: { database: Database; rowId?: string; fieldId?: string }) { + const ids = Object.keys(database.cells); + + ids.forEach((id) => { + const cell = database.cells[id]; + + if (rowId && cell.rowId !== rowId) return; + if (fieldId && cell.fieldId !== fieldId) return; + + delete database.cells[id]; + }); +} + +export async function didUpdateCells({ + viewId, + database, + rowId, + fieldId, +}: { + viewId: string; + database: Database; + rowId?: string; + fieldId?: string; +}) { + const field = database.fields.find((field) => field.id === fieldId); + + if (!field) { + delete database.cells[`${rowId}:${fieldId}`]; + return; + } + + const ids = Object.keys(database.cells); + + ids.forEach((id) => { + const cell = database.cells[id]; + + if (rowId && cell.rowId !== rowId) return; + if (fieldId && cell.fieldId !== fieldId) return; + + void getCell(viewId, cell.rowId, cell.fieldId, field.type).then((data) => { + // cache cell + database.cells[id] = data; + }); + }); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_service.ts new file mode 100644 index 0000000000000..950f5becb3bfc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_service.ts @@ -0,0 +1,140 @@ +import { + CellIdPB, + CellChangesetPB, + SelectOptionCellChangesetPB, + ChecklistCellDataChangesetPB, + DateCellChangesetPB, + FieldType, +} from '../../../../services/backend'; +import { + DatabaseEventGetCell, + DatabaseEventUpdateCell, + DatabaseEventUpdateSelectOptionCell, + DatabaseEventUpdateChecklistCell, + DatabaseEventUpdateDateCell, +} from '@/services/backend/events/flowy-database2'; +import { SelectOption } from '../field'; +import { Cell, pbToCell } from './cell_types'; + +export async function getCell(viewId: string, rowId: string, fieldId: string, fieldType?: FieldType): Promise { + const payload = CellIdPB.fromObject({ + view_id: viewId, + row_id: rowId, + field_id: fieldId, + }); + + const result = await DatabaseEventGetCell(payload); + + if (result.ok === false) { + return Promise.reject(result.val); + } + + const value = result.val; + + return pbToCell(value, fieldType); +} + +export async function updateCell(viewId: string, rowId: string, fieldId: string, changeset: string): Promise { + const payload = CellChangesetPB.fromObject({ + view_id: viewId, + row_id: rowId, + field_id: fieldId, + cell_changeset: changeset, + }); + + const result = await DatabaseEventUpdateCell(payload); + + return result.unwrap(); +} + +export async function updateSelectCell( + viewId: string, + rowId: string, + fieldId: string, + data: { + insertOptionIds?: string[]; + deleteOptionIds?: string[]; + } +): Promise { + const payload = SelectOptionCellChangesetPB.fromObject({ + cell_identifier: { + view_id: viewId, + row_id: rowId, + field_id: fieldId, + }, + insert_option_ids: data.insertOptionIds, + delete_option_ids: data.deleteOptionIds, + }); + + const result = await DatabaseEventUpdateSelectOptionCell(payload); + + return result.unwrap(); +} + +export async function updateChecklistCell( + viewId: string, + rowId: string, + fieldId: string, + data: { + insertOptions?: string[]; + selectedOptionIds?: string[]; + deleteOptionIds?: string[]; + updateOptions?: Partial[]; + } +): Promise { + const payload = ChecklistCellDataChangesetPB.fromObject({ + view_id: viewId, + row_id: rowId, + field_id: fieldId, + insert_options: data.insertOptions, + selected_option_ids: data.selectedOptionIds, + delete_option_ids: data.deleteOptionIds, + update_options: data.updateOptions, + }); + + const result = await DatabaseEventUpdateChecklistCell(payload); + + return result.unwrap(); +} + +export async function updateDateCell( + viewId: string, + rowId: string, + fieldId: string, + data: { + // 10-digit timestamp + date?: number; + // time string in format HH:mm + time?: string; + // 10-digit timestamp + endDate?: number; + // time string in format HH:mm + endTime?: string; + includeTime?: boolean; + clearFlag?: boolean; + isRange?: boolean; + } +): Promise { + const payload = DateCellChangesetPB.fromObject({ + cell_id: { + view_id: viewId, + row_id: rowId, + field_id: fieldId, + }, + date: data.date, + time: data.time, + include_time: data.includeTime, + clear_flag: data.clearFlag, + end_date: data.endDate, + end_time: data.endTime, + is_range: data.isRange, + }); + + const result = await DatabaseEventUpdateDateCell(payload); + + if (!result.ok) { + return Promise.reject(typeof result.val.msg === 'string' ? result.val.msg : 'Unknown error'); + } + + return result.val; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_types.ts new file mode 100644 index 0000000000000..f36f68ad8b64d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_types.ts @@ -0,0 +1,163 @@ +import { + CellPB, + CheckboxCellDataPB, + ChecklistCellDataPB, + DateCellDataPB, + FieldType, + SelectOptionCellDataPB, + TimestampCellDataPB, + URLCellDataPB, +} from '../../../../services/backend'; +import { SelectOption, pbToSelectOption } from '../field/select_option/select_option_types'; + +export interface Cell { + rowId: string; + fieldId: string; + fieldType: FieldType; + data: unknown; +} + +export interface TextCell extends Cell { + fieldType: FieldType.RichText; + data: string; +} + +export interface NumberCell extends Cell { + fieldType: FieldType.Number; + data: string; +} + +export interface CheckboxCell extends Cell { + fieldType: FieldType.Checkbox; + data: boolean; +} + +export interface UrlCell extends Cell { + fieldType: FieldType.URL; + data: string; +} + +export interface SelectCell extends Cell { + fieldType: FieldType.SingleSelect | FieldType.MultiSelect; + data: SelectCellData; +} + +export interface SelectCellData { + selectedOptionIds?: string[]; +} + +export interface DateTimeCell extends Cell { + fieldType: FieldType.DateTime; + data: DateTimeCellData; +} + +export interface TimeStampCell extends Cell { + fieldType: FieldType.LastEditedTime | FieldType.CreatedTime; + data: TimestampCellData; +} + +export interface DateTimeCellData { + date?: string; + time?: string; + timestamp?: number; + includeTime?: boolean; + endDate?: string; + endTime?: string; + endTimestamp?: number; + isRange?: boolean; +} + +export interface TimestampCellData { + dataTime?: string; + timestamp?: number; +} + +export interface ChecklistCell extends Cell { + fieldType: FieldType.Checklist; + data: ChecklistCellData; +} + +export interface ChecklistCellData { + /** + * link to [SelectOption's id property]{@link SelectOption#id}. + */ + selectedOptions?: string[]; + percentage?: number; + options?: SelectOption[]; +} + +export type UndeterminedCell = + | TextCell + | NumberCell + | DateTimeCell + | SelectCell + | CheckboxCell + | UrlCell + | ChecklistCell; + +const pbToCheckboxCellData = (pb: CheckboxCellDataPB): boolean => ( + pb.is_checked +); + +const pbToDateTimeCellData = (pb: DateCellDataPB): DateTimeCellData => ({ + date: pb.date, + time: pb.time, + timestamp: pb.timestamp, + includeTime: pb.include_time, + endDate: pb.end_date, + endTime: pb.end_time, + endTimestamp: pb.end_timestamp, + isRange: pb.is_range, +}); + +const pbToTimestampCellData = (pb: TimestampCellDataPB): TimestampCellData => ({ + dataTime: pb.date_time, + timestamp: pb.timestamp, +}); + +export const pbToSelectCellData = (pb: SelectOptionCellDataPB): SelectCellData => { + return { + selectedOptionIds: pb.select_options.map((option) => option.id), + }; +}; + +const pbToURLCellData = (pb: URLCellDataPB): string => ( + pb.content +); + +export const pbToChecklistCellData = (pb: ChecklistCellDataPB): ChecklistCellData => ({ + selectedOptions: pb.selected_options.map(({ id }) => id), + percentage: pb.percentage, + options: pb.options.map(pbToSelectOption), +}); + +function bytesToCellData(bytes: Uint8Array, fieldType: FieldType) { + switch (fieldType) { + case FieldType.RichText: + case FieldType.Number: + return new TextDecoder().decode(bytes); + case FieldType.Checkbox: + return pbToCheckboxCellData(CheckboxCellDataPB.deserialize(bytes)); + case FieldType.DateTime: + return pbToDateTimeCellData(DateCellDataPB.deserialize(bytes)); + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + return pbToTimestampCellData(TimestampCellDataPB.deserialize(bytes)); + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return pbToSelectCellData(SelectOptionCellDataPB.deserialize(bytes)); + case FieldType.URL: + return pbToURLCellData(URLCellDataPB.deserialize(bytes)); + case FieldType.Checklist: + return pbToChecklistCellData(ChecklistCellDataPB.deserialize(bytes)); + } +} + +export const pbToCell = (pb: CellPB, fieldType: FieldType = pb.field_type): Cell => { + return { + rowId: pb.row_id, + fieldId: pb.field_id, + fieldType: fieldType, + data: bytesToCellData(pb.data, fieldType), + }; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/index.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/index.ts new file mode 100644 index 0000000000000..bc6bdc441791d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/index.ts @@ -0,0 +1,3 @@ +export * from './cell_types'; +export * as cellService from './cell_service'; +export * as cellListeners from './cell_listeners'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_service.ts new file mode 100644 index 0000000000000..74ebfb1df06d3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_service.ts @@ -0,0 +1,84 @@ +import { DatabaseViewIdPB } from '@/services/backend'; +import { + DatabaseEventGetDatabase, + DatabaseEventGetDatabaseId, + DatabaseEventGetDatabaseSetting, +} from '@/services/backend/events/flowy-database2'; +import { fieldService } from '../field'; +import { pbToFilter } from '../filter'; +import { groupService, pbToGroupSetting } from '../group'; +import { pbToRowMeta } from '../row'; +import { pbToSort } from '../sort'; +import { Database } from './database_types'; + +export async function getDatabaseId(viewId: string): Promise { + const payload = DatabaseViewIdPB.fromObject({ value: viewId }); + + const result = await DatabaseEventGetDatabaseId(payload); + + return result.map((value) => value.value).unwrap(); +} + +export async function getDatabase(viewId: string) { + const payload = DatabaseViewIdPB.fromObject({ + value: viewId, + }); + + const result = await DatabaseEventGetDatabase(payload); + + if (!result.ok) return Promise.reject('Failed to get database'); + + return result + .map((value) => { + return { + id: value.id, + isLinked: value.is_linked, + layoutType: value.layout_type, + fieldIds: value.fields.map((field) => field.field_id), + rowMetas: value.rows.map(pbToRowMeta), + }; + }) + .unwrap(); +} + +export async function getDatabaseSetting(viewId: string) { + const payload = DatabaseViewIdPB.fromObject({ + value: viewId, + }); + + const result = await DatabaseEventGetDatabaseSetting(payload); + + return result + .map((value) => { + return { + filters: value.filters.items.map(pbToFilter), + sorts: value.sorts.items.map(pbToSort), + groupSettings: value.group_settings.items.map(pbToGroupSetting), + }; + }) + .unwrap(); +} + +export async function openDatabase(viewId: string): Promise { + const { id, isLinked, layoutType, fieldIds, rowMetas } = await getDatabase(viewId); + + const { filters, sorts, groupSettings } = await getDatabaseSetting(viewId); + + const { fields, typeOptions } = await fieldService.getFields(viewId, fieldIds); + + const groups = await groupService.getGroups(viewId); + + return { + id, + isLinked, + layoutType, + fields, + rowMetas, + filters, + sorts, + groups, + groupSettings, + typeOptions, + cells: {}, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_types.ts new file mode 100644 index 0000000000000..627cd94013392 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_types.ts @@ -0,0 +1,21 @@ +import { DatabaseLayoutPB } from '@/services/backend'; +import { Field, UndeterminedTypeOptionData } from '../field'; +import { Filter } from '../filter'; +import { GroupSetting, Group } from '../group'; +import { RowMeta } from '../row'; +import { Sort } from '../sort'; +import { Cell } from '../cell'; + +export interface Database { + id: string; + isLinked: boolean; + layoutType: DatabaseLayoutPB; + fields: Field[]; + rowMetas: RowMeta[]; + filters: Filter[]; + sorts: Sort[]; + groupSettings: GroupSetting[]; + groups: Group[]; + typeOptions: Record; + cells: Record; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/database/index.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/database/index.ts new file mode 100644 index 0000000000000..e656d98287ffa --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/database/index.ts @@ -0,0 +1,2 @@ +export * from './database_types'; +export * as databaseService from './database_service'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/database_view/database_view_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/database_view/database_view_service.ts new file mode 100644 index 0000000000000..87d99d9b75fd6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/database_view/database_view_service.ts @@ -0,0 +1,75 @@ +import { CreateViewPayloadPB, RepeatedViewIdPB, UpdateViewPayloadPB, ViewIdPB, ViewLayoutPB } from '@/services/backend'; +import { + FolderEventCreateView, + FolderEventDeleteView, + FolderEventGetView, + FolderEventUpdateView, +} from '@/services/backend/events/flowy-folder'; +import { databaseService } from '../database'; +import { Page, parserViewPBToPage } from '$app_reducers/pages/slice'; + +export async function getDatabaseViews(viewId: string): Promise { + const payload = ViewIdPB.fromObject({ value: viewId }); + + const result = await FolderEventGetView(payload); + + if (result.ok) { + return [parserViewPBToPage(result.val), ...result.val.child_views.map(parserViewPBToPage)]; + } + + return Promise.reject(result.val); +} + +export async function createDatabaseView( + viewId: string, + layout: ViewLayoutPB, + name: string, + databaseId?: string +): Promise { + const payload = CreateViewPayloadPB.fromObject({ + parent_view_id: viewId, + name, + layout, + meta: { + database_id: databaseId || (await databaseService.getDatabaseId(viewId)), + }, + }); + + const result = await FolderEventCreateView(payload); + + if (result.ok) { + return parserViewPBToPage(result.val); + } + + return Promise.reject(result.err); +} + +export async function updateView(viewId: string, view: { name?: string; layout?: ViewLayoutPB }): Promise { + const payload = UpdateViewPayloadPB.fromObject({ + view_id: viewId, + name: view.name, + layout: view.layout, + }); + + const result = await FolderEventUpdateView(payload); + + if (result.ok) { + return parserViewPBToPage(result.val); + } + + return Promise.reject(result.err); +} + +export async function deleteView(viewId: string): Promise { + const payload = RepeatedViewIdPB.fromObject({ + items: [viewId], + }); + + const result = await FolderEventDeleteView(payload); + + if (result.ok) { + return; + } + + return Promise.reject(result.err); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/database_view/index.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/database_view/index.ts new file mode 100644 index 0000000000000..b2a6e1a5f1cee --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/database_view/index.ts @@ -0,0 +1 @@ +export * as databaseViewService from './database_view_service'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/field_listeners.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/field_listeners.ts new file mode 100644 index 0000000000000..ef36daa20cbb2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/field_listeners.ts @@ -0,0 +1,40 @@ +import { DatabaseFieldChangesetPB, FieldSettingsPB, FieldVisibility } from '@/services/backend'; +import { Database, fieldService } from '$app/application/database'; +import { didDeleteCells, didUpdateCells } from '$app/application/database/cell/cell_listeners'; + +export function didUpdateFieldSettings(database: Database, settings: FieldSettingsPB) { + const { field_id: fieldId, visibility, width } = settings; + const field = database.fields.find((field) => field.id === fieldId); + + if (!field) return; + field.visibility = visibility; + field.width = width; + // delete cells if field is hidden + if (visibility === FieldVisibility.AlwaysHidden) { + didDeleteCells({ database, fieldId }); + } +} + +export async function didUpdateFields(viewId: string, database: Database, changeset: DatabaseFieldChangesetPB) { + const { fields, typeOptions } = await fieldService.getFields(viewId); + + database.fields = fields; + const deletedFieldIds = Object.keys(changeset.deleted_fields); + const updatedFieldIds = changeset.updated_fields.map((field) => field.id); + + Object.assign(database.typeOptions, typeOptions); + deletedFieldIds.forEach( + (fieldId) => { + // delete cache cells + didDeleteCells({ database, fieldId }); + // delete cache type options + delete database.typeOptions[fieldId]; + }, + [database.typeOptions] + ); + + updatedFieldIds.forEach((fieldId) => { + // delete cache cells + void didUpdateCells({ viewId, database, fieldId }); + }); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/field_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/field_service.ts new file mode 100644 index 0000000000000..219aeb3ea50f5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/field_service.ts @@ -0,0 +1,215 @@ +import { + CreateFieldPayloadPB, + DeleteFieldPayloadPB, + DuplicateFieldPayloadPB, + FieldChangesetPB, + FieldType, + GetFieldPayloadPB, + MoveFieldPayloadPB, + RepeatedFieldIdPB, + UpdateFieldTypePayloadPB, + FieldSettingsChangesetPB, + FieldVisibility, + DatabaseViewIdPB, + OrderObjectPositionTypePB, +} from '@/services/backend'; +import { + DatabaseEventDuplicateField, + DatabaseEventUpdateField, + DatabaseEventUpdateFieldType, + DatabaseEventMoveField, + DatabaseEventGetFields, + DatabaseEventDeleteField, + DatabaseEventCreateField, + DatabaseEventUpdateFieldSettings, + DatabaseEventGetAllFieldSettings, +} from '@/services/backend/events/flowy-database2'; +import { Field, pbToField } from './field_types'; +import { bytesToTypeOption } from './type_option'; +import { Database } from '$app/application/database'; + +export async function getFields( + viewId: string, + fieldIds?: string[] +): Promise<{ + fields: Field[]; + typeOptions: Database['typeOptions']; +}> { + const payload = GetFieldPayloadPB.fromObject({ + view_id: viewId, + field_ids: fieldIds + ? RepeatedFieldIdPB.fromObject({ + items: fieldIds.map((fieldId) => ({ field_id: fieldId })), + }) + : undefined, + }); + + const result = await DatabaseEventGetFields(payload); + + const getSettingsPayload = DatabaseViewIdPB.fromObject({ + value: viewId, + }); + + const settings = await DatabaseEventGetAllFieldSettings(getSettingsPayload); + + if (settings.ok === false || result.ok === false) { + return Promise.reject('Failed to get fields'); + } + + const typeOptions: Database['typeOptions'] = {}; + + const fields = await Promise.all( + result.val.items.map(async (item) => { + const setting = settings.val.items.find((setting) => setting.field_id === item.id); + + const field = pbToField(item); + + const typeOption = bytesToTypeOption(item.type_option_data, item.field_type); + + if (typeOption) { + typeOptions[item.id] = typeOption; + } + + return { + ...field, + visibility: setting?.visibility, + width: setting?.width, + }; + }) + ); + + return { fields, typeOptions }; +} + +export async function createField({ + viewId, + targetFieldId, + fieldPosition, + fieldType, + data, +}: { + viewId: string; + targetFieldId?: string; + fieldPosition?: OrderObjectPositionTypePB; + fieldType?: FieldType; + data?: Uint8Array; +}): Promise { + const payload = CreateFieldPayloadPB.fromObject({ + view_id: viewId, + field_type: fieldType, + type_option_data: data, + field_position: { + position: fieldPosition, + object_id: targetFieldId, + }, + }); + + const result = await DatabaseEventCreateField(payload); + + if (result.ok === false) { + return Promise.reject('Failed to create field'); + } + + return pbToField(result.val); +} + +export async function duplicateField(viewId: string, fieldId: string): Promise { + const payload = DuplicateFieldPayloadPB.fromObject({ + view_id: viewId, + field_id: fieldId, + }); + + const result = await DatabaseEventDuplicateField(payload); + + if (result.ok === false) { + return Promise.reject('Failed to duplicate field'); + } + + return result.val; +} + +export async function updateField( + viewId: string, + fieldId: string, + data: { + name?: string; + desc?: string; + } +): Promise { + const payload = FieldChangesetPB.fromObject({ + view_id: viewId, + field_id: fieldId, + ...data, + }); + + const result = await DatabaseEventUpdateField(payload); + + return result.unwrap(); +} + +export async function updateFieldType(viewId: string, fieldId: string, fieldType: FieldType): Promise { + const payload = UpdateFieldTypePayloadPB.fromObject({ + view_id: viewId, + field_id: fieldId, + field_type: fieldType, + }); + + const result = await DatabaseEventUpdateFieldType(payload); + + return result.unwrap(); +} + +export async function moveField(viewId: string, fromFieldId: string, toFieldId: string): Promise { + const payload = MoveFieldPayloadPB.fromObject({ + view_id: viewId, + from_field_id: fromFieldId, + to_field_id: toFieldId, + }); + + const result = await DatabaseEventMoveField(payload); + + return result.unwrap(); +} + +export async function deleteField(viewId: string, fieldId: string): Promise { + const payload = DeleteFieldPayloadPB.fromObject({ + view_id: viewId, + field_id: fieldId, + }); + + const result = await DatabaseEventDeleteField(payload); + + return result.unwrap(); +} + +export async function updateFieldSetting( + viewId: string, + fieldId: string, + settings: { + visibility?: FieldVisibility; + width?: number; + } +): Promise { + const payload = FieldSettingsChangesetPB.fromObject({ + view_id: viewId, + field_id: fieldId, + ...settings, + }); + + const result = await DatabaseEventUpdateFieldSettings(payload); + + if (result.ok === false) { + return Promise.reject('Failed to update field settings'); + } + + return result.val; +} + +export const reorderFields = (list: Field[], startIndex: number, endIndex: number) => { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + + result.splice(endIndex, 0, removed); + + return result; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/field_types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/field_types.ts new file mode 100644 index 0000000000000..00e7e02d4e91f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/field_types.ts @@ -0,0 +1,51 @@ +import { FieldPB, FieldType, FieldVisibility } from '@/services/backend'; + +export interface Field { + id: string; + name: string; + type: FieldType; + visibility?: FieldVisibility; + width?: number; + isPrimary: boolean; +} + +export interface NumberField extends Field { + type: FieldType.Number; +} + +export interface DateTimeField extends Field { + type: FieldType.DateTime; +} + +export interface LastEditedTimeField extends Field { + type: FieldType.LastEditedTime; +} + +export interface CreatedTimeField extends Field { + type: FieldType.CreatedTime; +} + +export type UndeterminedDateField = DateTimeField | CreatedTimeField | LastEditedTimeField; + +export interface SelectField extends Field { + type: FieldType.SingleSelect | FieldType.MultiSelect; +} + +export interface ChecklistField extends Field { + type: FieldType.Checklist; +} + +export interface DateTimeField extends Field { + type: FieldType.DateTime; +} + +export type UndeterminedField = NumberField | DateTimeField | SelectField | Field; + +export const pbToField = (pb: FieldPB): Field => { + return { + id: pb.id, + name: pb.name, + type: pb.field_type, + isPrimary: pb.is_primary, + }; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/index.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/index.ts new file mode 100644 index 0000000000000..fa993023e111f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/index.ts @@ -0,0 +1,5 @@ +export * from './select_option'; +export * from './type_option'; +export * from './field_types'; +export * as fieldService from './field_service'; +export * as fieldListeners from './field_listeners'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/select_option/index.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/select_option/index.ts new file mode 100644 index 0000000000000..f0b9e58852542 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/select_option/index.ts @@ -0,0 +1,2 @@ +export * from './select_option_types'; +export * as selectOptionService from './select_option_service'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/select_option/select_option_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/select_option/select_option_service.ts new file mode 100644 index 0000000000000..5757b8185d8f0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/select_option/select_option_service.ts @@ -0,0 +1,58 @@ +import { CreateSelectOptionPayloadPB, RepeatedSelectOptionPayload } from '@/services/backend'; +import { + DatabaseEventCreateSelectOption, + DatabaseEventInsertOrUpdateSelectOption, + DatabaseEventDeleteSelectOption, +} from '@/services/backend/events/flowy-database2'; +import { pbToSelectOption, SelectOption } from './select_option_types'; + +export async function createSelectOption(viewId: string, fieldId: string, optionName: string): Promise { + const payload = CreateSelectOptionPayloadPB.fromObject({ + view_id: viewId, + field_id: fieldId, + option_name: optionName, + }); + + const result = await DatabaseEventCreateSelectOption(payload); + + return result.map(pbToSelectOption).unwrap(); +} + +/** + * @param [rowId] If pass the rowId, the cell will select this option after insert or update. + */ +export async function insertOrUpdateSelectOption( + viewId: string, + fieldId: string, + items: Partial[], + rowId?: string +): Promise { + const payload = RepeatedSelectOptionPayload.fromObject({ + view_id: viewId, + field_id: fieldId, + row_id: rowId, + items: items, + }); + + const result = await DatabaseEventInsertOrUpdateSelectOption(payload); + + return result.unwrap(); +} + +export async function deleteSelectOption( + viewId: string, + fieldId: string, + items: Partial[], + rowId?: string +): Promise { + const payload = RepeatedSelectOptionPayload.fromObject({ + view_id: viewId, + field_id: fieldId, + row_id: rowId, + items, + }); + + const result = await DatabaseEventDeleteSelectOption(payload); + + return result.unwrap(); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/select_option/select_option_types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/select_option/select_option_types.ts new file mode 100644 index 0000000000000..ec36639a7c6f8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/select_option/select_option_types.ts @@ -0,0 +1,15 @@ +import { SelectOptionColorPB, SelectOptionPB } from '@/services/backend'; + +export interface SelectOption { + id: string; + name: string; + color: SelectOptionColorPB; +} + +export function pbToSelectOption(pb: SelectOptionPB): SelectOption { + return { + id: pb.id, + name: pb.name, + color: pb.color, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/type_option/index.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/type_option/index.ts new file mode 100644 index 0000000000000..d0b9122d907ae --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/type_option/index.ts @@ -0,0 +1,2 @@ +export * from './type_option_types'; +export * from './type_option_service'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/type_option/type_option_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/type_option/type_option_service.ts new file mode 100644 index 0000000000000..90a0dd3106675 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/type_option/type_option_service.ts @@ -0,0 +1,26 @@ +import { FieldType, TypeOptionChangesetPB } from '@/services/backend'; +import { + DatabaseEventUpdateFieldTypeOption, +} from '@/services/backend/events/flowy-database2'; +import { UndeterminedTypeOptionData, typeOptionDataToPB } from './type_option_types'; + +export async function updateTypeOption( + viewId: string, + fieldId: string, + fieldType: FieldType, + data: UndeterminedTypeOptionData +) { + const payload = TypeOptionChangesetPB.fromObject({ + view_id: viewId, + field_id: fieldId, + type_option_data: typeOptionDataToPB(data, fieldType)?.serialize(), + }); + + const result = await DatabaseEventUpdateFieldTypeOption(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + return; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/field/type_option/type_option_types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/type_option/type_option_types.ts new file mode 100644 index 0000000000000..57de7b828cb89 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/field/type_option/type_option_types.ts @@ -0,0 +1,148 @@ +import { + CheckboxTypeOptionPB, + DateFormatPB, + FieldType, + MultiSelectTypeOptionPB, + NumberFormatPB, + NumberTypeOptionPB, + RichTextTypeOptionPB, + SingleSelectTypeOptionPB, + TimeFormatPB, + ChecklistTypeOptionPB, + DateTypeOptionPB, + TimestampTypeOptionPB, +} from '@/services/backend'; +import { pbToSelectOption, SelectOption } from '../select_option'; + +export interface TextTypeOption { + data?: string; +} + +export interface NumberTypeOption { + format?: NumberFormatPB; + scale?: number; + symbol?: string; + name?: string; +} + +export interface DateTimeTypeOption { + dateFormat?: DateFormatPB; + timeFormat?: TimeFormatPB; + timezoneId?: string; +} +export interface TimeStampTypeOption extends DateTimeTypeOption { + includeTime?: boolean; + fieldType?: FieldType; +} + +export interface SelectTypeOption { + options?: SelectOption[]; + disableColor?: boolean; +} + +export interface CheckboxTypeOption { + isSelected?: boolean; +} + +export interface ChecklistTypeOption { + config?: string; +} + +export type UndeterminedTypeOptionData = + | TextTypeOption + | NumberTypeOption + | SelectTypeOption + | CheckboxTypeOption + | ChecklistTypeOption + | DateTimeTypeOption + | TimeStampTypeOption; + +export function typeOptionDataToPB(data: UndeterminedTypeOptionData, fieldType: FieldType) { + switch (fieldType) { + case FieldType.Number: + return NumberTypeOptionPB.fromObject(data as NumberTypeOption); + case FieldType.DateTime: + return dateTimeTypeOptionToPB(data as DateTimeTypeOption); + case FieldType.CreatedTime: + case FieldType.LastEditedTime: + return timestampTypeOptionToPB(data as TimeStampTypeOption); + + default: + return null; + } +} + +function dateTimeTypeOptionToPB(data: DateTimeTypeOption): DateTypeOptionPB { + return DateTypeOptionPB.fromObject({ + time_format: data.timeFormat, + date_format: data.dateFormat, + timezone_id: data.timezoneId, + }); +} + +function timestampTypeOptionToPB(data: TimeStampTypeOption): TimestampTypeOptionPB { + return TimestampTypeOptionPB.fromObject({ + include_time: data.includeTime, + date_format: data.dateFormat, + time_format: data.timeFormat, + field_type: data.fieldType, + }); +} + +function pbToSelectTypeOption(pb: SingleSelectTypeOptionPB | MultiSelectTypeOptionPB): SelectTypeOption { + return { + options: pb.options?.map(pbToSelectOption), + disableColor: pb.disable_color, + }; +} + +function pbToCheckboxTypeOption(pb: CheckboxTypeOptionPB): CheckboxTypeOption { + return { + isSelected: pb.dummy_field, + }; +} + +function pbToChecklistTypeOption(pb: ChecklistTypeOptionPB): ChecklistTypeOption { + return { + config: pb.config, + }; +} + +function pbToDateTypeOption(pb: DateTypeOptionPB): DateTimeTypeOption { + return { + dateFormat: pb.date_format, + timezoneId: pb.timezone_id, + timeFormat: pb.time_format, + }; +} + +function pbToTimeStampTypeOption(pb: TimestampTypeOptionPB): TimeStampTypeOption { + return { + includeTime: pb.include_time, + dateFormat: pb.date_format, + timeFormat: pb.time_format, + fieldType: pb.field_type, + }; +} + +export function bytesToTypeOption(data: Uint8Array, fieldType: FieldType) { + switch (fieldType) { + case FieldType.RichText: + return RichTextTypeOptionPB.deserialize(data).toObject() as TextTypeOption; + case FieldType.Number: + return NumberTypeOptionPB.deserialize(data).toObject() as NumberTypeOption; + case FieldType.SingleSelect: + return pbToSelectTypeOption(SingleSelectTypeOptionPB.deserialize(data)); + case FieldType.MultiSelect: + return pbToSelectTypeOption(MultiSelectTypeOptionPB.deserialize(data)); + case FieldType.Checkbox: + return pbToCheckboxTypeOption(CheckboxTypeOptionPB.deserialize(data)); + case FieldType.Checklist: + return pbToChecklistTypeOption(ChecklistTypeOptionPB.deserialize(data)); + case FieldType.DateTime: + return pbToDateTypeOption(DateTypeOptionPB.deserialize(data)); + case FieldType.CreatedTime: + case FieldType.LastEditedTime: + return pbToTimeStampTypeOption(TimestampTypeOptionPB.deserialize(data)); + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_data.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_data.ts new file mode 100644 index 0000000000000..72526b577f46c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_data.ts @@ -0,0 +1,42 @@ +import { + CheckboxFilterConditionPB, + ChecklistFilterConditionPB, + FieldType, + NumberFilterConditionPB, + SelectOptionFilterConditionPB, + TextFilterConditionPB, +} from '@/services/backend'; +import { UndeterminedFilter } from '$app/application/database'; + +export function getDefaultFilter(fieldType: FieldType): UndeterminedFilter['data'] | undefined { + switch (fieldType) { + case FieldType.RichText: + case FieldType.URL: + return { + condition: TextFilterConditionPB.TextContains, + content: '', + }; + case FieldType.Number: + return { + condition: NumberFilterConditionPB.NumberIsNotEmpty, + }; + case FieldType.Checkbox: + return { + condition: CheckboxFilterConditionPB.IsUnChecked, + }; + case FieldType.Checklist: + return { + condition: ChecklistFilterConditionPB.IsIncomplete, + }; + case FieldType.SingleSelect: + return { + condition: SelectOptionFilterConditionPB.OptionIs, + }; + case FieldType.MultiSelect: + return { + condition: SelectOptionFilterConditionPB.OptionContains, + }; + default: + return; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_listeners.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_listeners.ts new file mode 100644 index 0000000000000..323f8dac82297 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_listeners.ts @@ -0,0 +1,8 @@ +import { Database, pbToFilter } from '$app/application/database'; +import { FilterChangesetNotificationPB } from '@/services/backend'; + +export const didUpdateFilter = (database: Database, changeset: FilterChangesetNotificationPB) => { + const filters = changeset.filters.items.map((pb) => pbToFilter(pb)); + + database.filters = filters; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_service.ts new file mode 100644 index 0000000000000..6283763d28112 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_service.ts @@ -0,0 +1,88 @@ +import { + DatabaseEventGetAllFilters, + DatabaseEventUpdateDatabaseSetting, + DatabaseSettingChangesetPB, + DatabaseViewIdPB, + FieldType, + FilterPB, +} from '@/services/backend/events/flowy-database2'; +import { Filter, filterDataToPB, UndeterminedFilter } from './filter_types'; + +export async function getAllFilters(viewId: string): Promise { + const payload = DatabaseViewIdPB.fromObject({ value: viewId }); + + const result = await DatabaseEventGetAllFilters(payload); + + return result.map((value) => value.items).unwrap(); +} + +export async function insertFilter({ + viewId, + fieldId, + fieldType, + data, +}: { + viewId: string; + fieldId: string; + fieldType: FieldType; + data?: UndeterminedFilter['data']; +}): Promise { + const payload = DatabaseSettingChangesetPB.fromObject({ + view_id: viewId, + insert_filter: { + data: { + field_id: fieldId, + field_type: fieldType, + data: data ? filterDataToPB(data, fieldType)?.serialize() : undefined, + }, + }, + }); + + const result = await DatabaseEventUpdateDatabaseSetting(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + return result.val; +} + +export async function updateFilter(viewId: string, filter: UndeterminedFilter): Promise { + const payload = DatabaseSettingChangesetPB.fromObject({ + view_id: viewId, + update_filter_data: { + filter_id: filter.id, + data: { + field_id: filter.fieldId, + field_type: filter.fieldType, + data: filterDataToPB(filter.data, filter.fieldType)?.serialize(), + }, + }, + }); + + const result = await DatabaseEventUpdateDatabaseSetting(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + return result.val; +} + +export async function deleteFilter(viewId: string, filter: Omit): Promise { + const payload = DatabaseSettingChangesetPB.fromObject({ + view_id: viewId, + delete_filter: { + filter_id: filter.id, + field_id: filter.fieldId, + }, + }); + + const result = await DatabaseEventUpdateDatabaseSetting(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + return result.val; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_types.ts new file mode 100644 index 0000000000000..f9f80985e57be --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_types.ts @@ -0,0 +1,202 @@ +import { + CheckboxFilterConditionPB, + CheckboxFilterPB, + FieldType, + FilterPB, + NumberFilterConditionPB, + NumberFilterPB, + SelectOptionFilterConditionPB, + SelectOptionFilterPB, + TextFilterConditionPB, + TextFilterPB, + ChecklistFilterConditionPB, + ChecklistFilterPB, + DateFilterConditionPB, + DateFilterPB, +} from '@/services/backend'; + +export interface Filter { + id: string; + fieldId: string; + fieldType: FieldType; + data: unknown; +} + +export interface TextFilter extends Filter { + fieldType: FieldType.RichText; + data: TextFilterData; +} + +export interface TextFilterData { + condition: TextFilterConditionPB; + content?: string; +} + +export interface SelectFilter extends Filter { + fieldType: FieldType.SingleSelect | FieldType.MultiSelect; + data: SelectFilterData; +} + +export interface NumberFilter extends Filter { + fieldType: FieldType.Number; + data: NumberFilterData; +} + +export interface CheckboxFilter extends Filter { + fieldType: FieldType.Checkbox; + data: CheckboxFilterData; +} + +export interface CheckboxFilterData { + condition?: CheckboxFilterConditionPB; +} + +export interface ChecklistFilter extends Filter { + fieldType: FieldType.Checklist; + data: ChecklistFilterData; +} + +export interface DateFilter extends Filter { + fieldType: FieldType.DateTime | FieldType.CreatedTime | FieldType.LastEditedTime; + data: DateFilterData; +} + +export interface ChecklistFilterData { + condition?: ChecklistFilterConditionPB; +} + +export interface SelectFilterData { + condition?: SelectOptionFilterConditionPB; + optionIds?: string[]; +} + +export interface NumberFilterData { + condition: NumberFilterConditionPB; + content?: string; +} + +export interface DateFilterData { + condition: DateFilterConditionPB; + start?: number; + end?: number; + timestamp?: number; +} + +export type UndeterminedFilter = + | TextFilter + | SelectFilter + | NumberFilter + | CheckboxFilter + | ChecklistFilter + | DateFilter; + +export function filterDataToPB(data: UndeterminedFilter['data'], fieldType: FieldType) { + switch (fieldType) { + case FieldType.RichText: + case FieldType.URL: + return TextFilterPB.fromObject({ + condition: (data as TextFilterData).condition, + content: (data as TextFilterData).content, + }); + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return SelectOptionFilterPB.fromObject({ + condition: (data as SelectFilterData).condition, + option_ids: (data as SelectFilterData).optionIds, + }); + case FieldType.Number: + return NumberFilterPB.fromObject({ + condition: (data as NumberFilterData).condition, + content: (data as NumberFilterData).content, + }); + case FieldType.Checkbox: + return CheckboxFilterPB.fromObject({ + condition: (data as CheckboxFilterData).condition, + }); + case FieldType.Checklist: + return ChecklistFilterPB.fromObject({ + condition: (data as ChecklistFilterData).condition, + }); + case FieldType.DateTime: + case FieldType.CreatedTime: + case FieldType.LastEditedTime: + return DateFilterPB.fromObject({ + condition: (data as DateFilterData).condition, + start: (data as DateFilterData).start, + end: (data as DateFilterData).end, + timestamp: (data as DateFilterData).timestamp, + }); + } +} + +export function pbToTextFilterData(pb: TextFilterPB): TextFilterData { + return { + condition: pb.condition, + content: pb.content, + }; +} + +export function pbToSelectFilterData(pb: SelectOptionFilterPB): SelectFilterData { + return { + condition: pb.condition, + optionIds: pb.option_ids, + }; +} + +export function pbToNumberFilterData(pb: NumberFilterPB): NumberFilterData { + return { + condition: pb.condition, + content: pb.content, + }; +} + +export function pbToCheckboxFilterData(pb: CheckboxFilterPB): CheckboxFilterData { + return { + condition: pb.condition, + }; +} + +export function pbToChecklistFilterData(pb: ChecklistFilterPB): ChecklistFilterData { + return { + condition: pb.condition, + }; +} + +export function pbToDateFilterData(pb: DateFilterPB): DateFilterData { + return { + condition: pb.condition, + start: pb.start, + end: pb.end, + timestamp: pb.timestamp, + }; +} + +export function bytesToFilterData(bytes: Uint8Array, fieldType: FieldType) { + switch (fieldType) { + case FieldType.RichText: + case FieldType.URL: + return pbToTextFilterData(TextFilterPB.deserialize(bytes)); + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return pbToSelectFilterData(SelectOptionFilterPB.deserialize(bytes)); + case FieldType.Number: + return pbToNumberFilterData(NumberFilterPB.deserialize(bytes)); + case FieldType.Checkbox: + return pbToCheckboxFilterData(CheckboxFilterPB.deserialize(bytes)); + case FieldType.Checklist: + return pbToChecklistFilterData(ChecklistFilterPB.deserialize(bytes)); + case FieldType.DateTime: + case FieldType.CreatedTime: + case FieldType.LastEditedTime: + return pbToDateFilterData(DateFilterPB.deserialize(bytes)); + } +} + +export function pbToFilter(pb: FilterPB): Filter { + return { + id: pb.id, + fieldId: pb.data.field_id, + fieldType: pb.data.field_type, + data: bytesToFilterData(pb.data.data, pb.data.field_type), + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/index.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/index.ts new file mode 100644 index 0000000000000..ac10d27d0a127 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/index.ts @@ -0,0 +1,3 @@ +export * from './filter_types'; +export * as filterService from './filter_service'; +export * as filterListeners from './filter_listeners'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/group/group_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/group/group_service.ts new file mode 100644 index 0000000000000..24f24d65ec6c1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/group/group_service.ts @@ -0,0 +1,64 @@ +import { + DatabaseViewIdPB, + GroupByFieldPayloadPB, + MoveGroupPayloadPB, + UpdateGroupPB, +} from '@/services/backend'; +import { + DatabaseEventGetGroups, + DatabaseEventMoveGroup, + DatabaseEventSetGroupByField, + DatabaseEventUpdateGroup, +} from '@/services/backend/events/flowy-database2'; +import { Group, pbToGroup } from './group_types'; + +export async function getGroups(viewId: string): Promise { + const payload = DatabaseViewIdPB.fromObject({ value: viewId }); + + const result = await DatabaseEventGetGroups(payload); + + return result.map(value => value.items.map(pbToGroup)).unwrap(); +} + +export async function setGroupByField(viewId: string, fieldId: string): Promise { + const payload = GroupByFieldPayloadPB.fromObject({ + view_id: viewId, + field_id: fieldId, + }); + + const result = await DatabaseEventSetGroupByField(payload); + + return result.unwrap(); +} + +export async function updateGroup( + viewId: string, + group: { + id: string, + name?: string, + visible?: boolean, + }, +): Promise { + const payload = UpdateGroupPB.fromObject({ + view_id: viewId, + group_id: group.id, + name: group.name, + visible: group.visible, + }); + + const result = await DatabaseEventUpdateGroup(payload); + + return result.unwrap(); +} + +export async function moveGroup(viewId: string, fromGroupId: string, toGroupId: string): Promise { + const payload = MoveGroupPayloadPB.fromObject({ + view_id: viewId, + from_group_id: fromGroupId, + to_group_id: toGroupId, + }); + + const result = await DatabaseEventMoveGroup(payload); + + return result.unwrap(); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/group/group_types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/group/group_types.ts new file mode 100644 index 0000000000000..b75ecc0bd477a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/group/group_types.ts @@ -0,0 +1,32 @@ +import { GroupPB, GroupSettingPB } from '@/services/backend'; +import { pbToRowMeta, RowMeta } from '../row'; + +export interface GroupSetting { + id: string; + fieldId: string; +} + +export interface Group { + id: string; + isDefault: boolean; + isVisible: boolean; + fieldId: string; + rows: RowMeta[]; +} + +export function pbToGroup(pb: GroupPB): Group { + return { + id: pb.group_id, + isDefault: pb.is_default, + isVisible: pb.is_visible, + fieldId: pb.field_id, + rows: pb.rows.map(pbToRowMeta), + }; +} + +export function pbToGroupSetting(pb: GroupSettingPB): GroupSetting { + return { + id: pb.id, + fieldId: pb.field_id, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/group/index.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/group/index.ts new file mode 100644 index 0000000000000..bb872d6677a79 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/group/index.ts @@ -0,0 +1,2 @@ +export * from './group_types'; +export * as groupService from './group_service'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/index.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/index.ts new file mode 100644 index 0000000000000..f44da5b857096 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/index.ts @@ -0,0 +1,8 @@ +export * from './cell'; +export * from './database'; +export * from './database_view'; +export * from './field'; +export * from './filter'; +export * from './group'; +export * from './row'; +export * from './sort'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/index.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/index.ts new file mode 100644 index 0000000000000..69260223ef4d9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/index.ts @@ -0,0 +1,3 @@ +export * from './row_types'; +export * as rowService from './row_service'; +export * as rowListeners from './row_listeners'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_listeners.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_listeners.ts new file mode 100644 index 0000000000000..e8a638403e379 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_listeners.ts @@ -0,0 +1,95 @@ +import { ReorderAllRowsPB, ReorderSingleRowPB, RowsChangePB, RowsVisibilityChangePB } from '@/services/backend'; +import { Database } from '../database'; +import { pbToRowMeta, RowMeta } from './row_types'; +import { didDeleteCells } from '$app/application/database/cell/cell_listeners'; +import { getDatabase } from '$app/application/database/database/database_service'; + +const deleteRowsFromChangeset = (database: Database, changeset: RowsChangePB) => { + changeset.deleted_rows.forEach((rowId) => { + const index = database.rowMetas.findIndex((row) => row.id === rowId); + + if (index !== -1) { + database.rowMetas.splice(index, 1); + // delete cells + didDeleteCells({ database, rowId }); + } + }); +}; + +const updateRowsFromChangeset = (database: Database, changeset: RowsChangePB) => { + changeset.updated_rows.forEach(({ row_id: rowId, row_meta: rowMetaPB }) => { + const found = database.rowMetas.find((rowMeta) => rowMeta.id === rowId); + + if (found) { + Object.assign(found, rowMetaPB ? pbToRowMeta(rowMetaPB) : {}); + } + }); +}; + +export const didUpdateViewRows = async (viewId: string, database: Database, changeset: RowsChangePB) => { + if (changeset.inserted_rows.length > 0) { + const { rowMetas } = await getDatabase(viewId); + + database.rowMetas = rowMetas; + return; + } + + deleteRowsFromChangeset(database, changeset); + updateRowsFromChangeset(database, changeset); +}; + +export const didReorderRows = (database: Database, changeset: ReorderAllRowsPB) => { + const rowById = database.rowMetas.reduce>((prev, cur) => { + prev[cur.id] = cur; + return prev; + }, {}); + + database.rowMetas = changeset.row_orders.map((rowId) => rowById[rowId]); +}; + +export const didReorderSingleRow = (database: Database, changeset: ReorderSingleRowPB) => { + const { row_id: rowId, new_index: newIndex } = changeset; + + const oldIndex = database.rowMetas.findIndex((rowMeta) => rowMeta.id === rowId); + + if (oldIndex !== -1) { + database.rowMetas.splice(newIndex, 0, database.rowMetas.splice(oldIndex, 1)[0]); + } +}; + +export const didUpdateViewRowsVisibility = async ( + viewId: string, + database: Database, + changeset: RowsVisibilityChangePB +) => { + const { invisible_rows, visible_rows } = changeset; + + let reFetchRows = false; + + for (const rowId of invisible_rows) { + const rowMeta = database.rowMetas.find((rowMeta) => rowMeta.id === rowId); + + if (rowMeta) { + rowMeta.isHidden = true; + } + } + + for (const insertedRow of visible_rows) { + const rowMeta = database.rowMetas.find((rowMeta) => rowMeta.id === insertedRow.row_meta.id); + + if (rowMeta) { + rowMeta.isHidden = false; + } else { + reFetchRows = true; + break; + } + } + + if (reFetchRows) { + const { rowMetas } = await getDatabase(viewId); + + database.rowMetas = rowMetas; + + await didUpdateViewRowsVisibility(viewId, database, changeset); + } +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts new file mode 100644 index 0000000000000..7993f709b73c0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_service.ts @@ -0,0 +1,129 @@ +import { + CreateRowPayloadPB, + MoveGroupRowPayloadPB, + MoveRowPayloadPB, + OrderObjectPositionTypePB, + RepeatedRowIdPB, + RowIdPB, + UpdateRowMetaChangesetPB, +} from '@/services/backend'; +import { + DatabaseEventCreateRow, + DatabaseEventDeleteRows, + DatabaseEventDuplicateRow, + DatabaseEventGetRowMeta, + DatabaseEventMoveGroupRow, + DatabaseEventMoveRow, + DatabaseEventUpdateRowMeta, +} from '@/services/backend/events/flowy-database2'; +import { pbToRowMeta, RowMeta } from './row_types'; + +export async function createRow(viewId: string, params?: { + position?: OrderObjectPositionTypePB; + rowId?: string; + groupId?: string; + data?: Record; +}): Promise { + const payload = CreateRowPayloadPB.fromObject({ + view_id: viewId, + row_position: { + position: params?.position, + object_id: params?.rowId, + }, + group_id: params?.groupId, + data: params?.data, + }); + + const result = await DatabaseEventCreateRow(payload); + + return result.map(pbToRowMeta).unwrap(); +} + +export async function duplicateRow(viewId: string, rowId: string, groupId?: string): Promise { + const payload = RowIdPB.fromObject({ + view_id: viewId, + row_id: rowId, + group_id: groupId, + }); + + const result = await DatabaseEventDuplicateRow(payload); + + return result.unwrap(); +} + +export async function deleteRow(viewId: string, rowId: string, groupId?: string): Promise { + const payload = RepeatedRowIdPB.fromObject({ + view_id: viewId, + row_ids: [rowId], + }); + + const result = await DatabaseEventDeleteRows(payload); + + return result.unwrap(); +} + +export async function moveRow(viewId: string, fromRowId: string, toRowId: string): Promise { + const payload = MoveRowPayloadPB.fromObject({ + view_id: viewId, + from_row_id: fromRowId, + to_row_id: toRowId, + }); + + const result = await DatabaseEventMoveRow(payload); + + return result.unwrap(); +} + +/** + * Move the row from one group to another group + * + * @param fromRowId + * @param toGroupId + * @param toRowId used to locate the moving row location. + * @returns + */ +export async function moveGroupRow(viewId: string, fromRowId: string, toGroupId: string, toRowId?: string): Promise { + const payload = MoveGroupRowPayloadPB.fromObject({ + view_id: viewId, + from_row_id: fromRowId, + to_group_id: toGroupId, + to_row_id: toRowId, + }); + + const result = await DatabaseEventMoveGroupRow(payload); + + return result.unwrap(); +} + + +export async function getRowMeta(viewId: string, rowId: string, groupId?: string): Promise { + const payload = RowIdPB.fromObject({ + view_id: viewId, + row_id: rowId, + group_id: groupId, + }); + + const result = await DatabaseEventGetRowMeta(payload); + + return result.map(pbToRowMeta).unwrap(); +} + +export async function updateRowMeta( + viewId: string, + rowId: string, + meta: { + iconUrl?: string; + coverUrl?: string; + }, +): Promise { + const payload = UpdateRowMetaChangesetPB.fromObject({ + view_id: viewId, + id: rowId, + icon_url: meta.iconUrl, + cover_url: meta.coverUrl, + }); + + const result = await DatabaseEventUpdateRowMeta(payload); + + return result.unwrap(); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_types.ts new file mode 100644 index 0000000000000..1b964a6bb5481 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_types.ts @@ -0,0 +1,29 @@ +import { RowMetaPB } from '@/services/backend'; + +export interface RowMeta { + id: string; + documentId?: string; + icon?: string; + cover?: string; + isHidden?: boolean; +} + +export function pbToRowMeta(pb: RowMetaPB): RowMeta { + const rowMeta: RowMeta = { + id: pb.id, + }; + + if (pb.document_id) { + rowMeta.documentId = pb.document_id; + } + + if (pb.icon) { + rowMeta.icon = pb.icon; + } + + if (pb.cover) { + rowMeta.cover = pb.cover; + } + + return rowMeta; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/index.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/index.ts new file mode 100644 index 0000000000000..6c7d4bd60a02c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/index.ts @@ -0,0 +1,3 @@ +export * from './sort_types'; +export * as sortService from './sort_service'; +export * as sortListeners from './sort_listeners'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/sort_listeners.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/sort_listeners.ts new file mode 100644 index 0000000000000..808c62e0d2eb9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/sort_listeners.ts @@ -0,0 +1,35 @@ +import { SortChangesetNotificationPB } from '@/services/backend'; +import { Database } from '../database'; +import { pbToSort } from './sort_types'; + +const deleteSortsFromChange = (database: Database, changeset: SortChangesetNotificationPB) => { + const deleteIds = changeset.delete_sorts.map(sort => sort.id); + + if (deleteIds.length) { + database.sorts = database.sorts.filter(sort => !deleteIds.includes(sort.id)); + } +}; + +const insertSortsFromChange = (database: Database, changeset: SortChangesetNotificationPB) => { + changeset.insert_sorts.forEach(sortPB => { + database.sorts.push(pbToSort(sortPB.sort)); + }); +}; + +const updateSortsFromChange = (database: Database, changeset: SortChangesetNotificationPB) => { + changeset.update_sorts.forEach(sortPB => { + const found = database.sorts.find(sort => sort.id === sortPB.id); + + if (found) { + const newSort = pbToSort(sortPB); + + Object.assign(found, newSort); + } + }); +}; + +export const didUpdateSort = (database: Database, changeset: SortChangesetNotificationPB) => { + deleteSortsFromChange(database, changeset); + insertSortsFromChange(database, changeset); + updateSortsFromChange(database, changeset); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/sort_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/sort_service.ts new file mode 100644 index 0000000000000..2546ec780cc61 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/sort_service.ts @@ -0,0 +1,73 @@ +import { + DatabaseViewIdPB, + DatabaseSettingChangesetPB, +} from '@/services/backend'; +import { + DatabaseEventDeleteAllSorts, + DatabaseEventGetAllSorts, DatabaseEventUpdateDatabaseSetting, +} from '@/services/backend/events/flowy-database2'; +import { pbToSort, Sort } from './sort_types'; + +export async function getAllSorts(viewId: string): Promise { + const payload = DatabaseViewIdPB.fromObject({ + value: viewId, + }); + + const result = await DatabaseEventGetAllSorts(payload); + + return result.map(value => value.items.map(pbToSort)).unwrap(); +} + +export async function insertSort(viewId: string, sort: Omit): Promise { + const payload = DatabaseSettingChangesetPB.fromObject({ + view_id: viewId, + update_sort: { + view_id: viewId, + field_id: sort.fieldId, + condition: sort.condition, + }, + }); + + const result = await DatabaseEventUpdateDatabaseSetting(payload); + + return result.unwrap(); +} + +export async function updateSort(viewId: string, sort: Sort): Promise { + const payload = DatabaseSettingChangesetPB.fromObject({ + view_id: viewId, + update_sort: { + view_id: viewId, + sort_id: sort.id, + field_id: sort.fieldId, + condition: sort.condition, + }, + }); + + const result = await DatabaseEventUpdateDatabaseSetting(payload); + + return result.unwrap(); +} + +export async function deleteSort(viewId: string, sort: Sort): Promise { + const payload = DatabaseSettingChangesetPB.fromObject({ + view_id: viewId, + delete_sort: { + view_id: viewId, + sort_id: sort.id, + }, + }); + + const result = await DatabaseEventUpdateDatabaseSetting(payload); + + return result.unwrap(); +} + +export async function deleteAllSorts(viewId: string): Promise { + const payload = DatabaseViewIdPB.fromObject({ + value: viewId, + }); + const result = await DatabaseEventDeleteAllSorts(payload); + + return result.unwrap(); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/sort_types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/sort_types.ts new file mode 100644 index 0000000000000..a8089878d1ce8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/sort/sort_types.ts @@ -0,0 +1,15 @@ +import { SortConditionPB, SortPB } from '@/services/backend'; + +export interface Sort { + id: string; + fieldId: string; + condition: SortConditionPB; +} + +export function pbToSort(pb: SortPB): Sort { + return { + id: pb.id, + fieldId: pb.field_id, + condition: pb.condition, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.service.ts new file mode 100644 index 0000000000000..0db128ec7ac51 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.service.ts @@ -0,0 +1,284 @@ +import { + ApplyActionPayloadPB, + BlockActionPB, + BlockPB, + CloseDocumentPayloadPB, + ConvertDataToJsonPayloadPB, + ConvertDocumentPayloadPB, + InputType, + OpenDocumentPayloadPB, + TextDeltaPayloadPB, +} from '@/services/backend'; +import { + DocumentEventApplyAction, + DocumentEventApplyTextDeltaEvent, + DocumentEventCloseDocument, + DocumentEventConvertDataToJSON, + DocumentEventConvertDocument, + DocumentEventOpenDocument, +} from '@/services/backend/events/flowy-document'; +import get from 'lodash-es/get'; +import { EditorData, EditorNodeType } from '$app/application/document/document.types'; +import { Log } from '$app/utils/log'; +import { Op } from 'quill-delta'; +import { Element, Text } from 'slate'; +import { generateId, getInlinesWithDelta } from '$app/components/editor/provider/utils/convert'; +import { CustomEditor } from '$app/components/editor/command'; +import { LIST_TYPES } from '$app/components/editor/command/tab'; + +export function blockPB2Node(block: BlockPB) { + let data = {}; + + try { + data = JSON.parse(block.data); + } catch { + Log.error('[Document Open] json parse error', block.data); + } + + return { + id: block.id, + type: block.ty as EditorNodeType, + parent: block.parent_id, + children: block.children_id, + data, + externalId: block.external_id, + externalType: block.external_type, + }; +} + +export const BLOCK_MAP_NAME = 'blocks'; +export const META_NAME = 'meta'; +export const CHILDREN_MAP_NAME = 'children_map'; + +export const TEXT_MAP_NAME = 'text_map'; +export async function openDocument(docId: string): Promise { + const payload = OpenDocumentPayloadPB.fromObject({ + document_id: docId, + }); + + const result = await DocumentEventOpenDocument(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + const documentDataPB = result.val; + + if (!documentDataPB) { + return Promise.reject('documentDataPB is null'); + } + + const data: EditorData = { + viewId: docId, + rootId: documentDataPB.page_id, + nodeMap: {}, + childrenMap: {}, + relativeMap: {}, + deltaMap: {}, + externalIdMap: {}, + }; + + get(documentDataPB, BLOCK_MAP_NAME).forEach((block) => { + Object.assign(data.nodeMap, { + [block.id]: blockPB2Node(block), + }); + data.relativeMap[block.children_id] = block.id; + if (block.external_id) { + data.externalIdMap[block.external_id] = block.id; + } + }); + + get(documentDataPB, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => { + const blockId = data.relativeMap[key]; + + data.childrenMap[blockId] = child.children; + }); + + get(documentDataPB, [META_NAME, TEXT_MAP_NAME]).forEach((delta, key) => { + const blockId = data.externalIdMap[key]; + + data.deltaMap[blockId] = delta ? JSON.parse(delta) : []; + }); + + return data; +} + +export async function closeDocument(docId: string) { + const payload = CloseDocumentPayloadPB.fromObject({ + document_id: docId, + }); + + const result = await DocumentEventCloseDocument(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + return result.val; +} + +export async function applyActions(docId: string, actions: ReturnType[]) { + if (actions.length === 0) return; + const payload = ApplyActionPayloadPB.fromObject({ + document_id: docId, + actions: actions, + }); + + const result = await DocumentEventApplyAction(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + return result.val; +} + +export async function applyText(docId: string, textId: string, delta: string) { + const payload = TextDeltaPayloadPB.fromObject({ + document_id: docId, + text_id: textId, + delta: delta, + }); + + const res = await DocumentEventApplyTextDeltaEvent(payload); + + if (!res.ok) { + return Promise.reject(res.val); + } + + return res.val; +} + +export async function getClipboardData( + docId: string, + range: { + start: { + blockId: string; + index: number; + length: number; + }; + end?: { + blockId: string; + index: number; + length: number; + }; + } +) { + const payload = ConvertDocumentPayloadPB.fromObject({ + range: { + start: { + block_id: range.start.blockId, + index: range.start.index, + length: range.start.length, + }, + end: range.end + ? { + block_id: range.end.blockId, + index: range.end.index, + length: range.end.length, + } + : undefined, + }, + document_id: docId, + parse_types: { + json: true, + html: true, + text: true, + }, + }); + + const result = await DocumentEventConvertDocument(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + return { + html: result.val.html, + text: result.val.text, + json: result.val.json, + }; +} + +export async function convertBlockToJson(data: string, type: InputType) { + const payload = ConvertDataToJsonPayloadPB.fromObject({ + data, + input_type: type, + }); + + const result = await DocumentEventConvertDataToJSON(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + try { + const block = JSON.parse(result.val.json); + + return flattenBlockJson(block); + } catch (e) { + return Promise.reject(e); + } +} + +interface BlockJSON { + type: string; + children: BlockJSON[]; + data: { + [key: string]: boolean | string | number | undefined; + } & { + delta?: Op[]; + }; +} + +function flattenBlockJson(block: BlockJSON) { + const traverse = (block: BlockJSON) => { + const { delta, ...data } = block.data; + + const slateNode: Element = { + type: block.type, + data: data, + children: [], + blockId: generateId(), + }; + const isEmbed = CustomEditor.isEmbedNode(slateNode); + + const textNode: { + type: EditorNodeType.Text; + children: (Text | Element)[]; + textId: string; + } | null = !isEmbed + ? { + type: EditorNodeType.Text, + children: [{ text: '' }], + textId: generateId(), + } + : null; + + if (delta && textNode) { + textNode.children = getInlinesWithDelta(delta); + } + + slateNode.children = block.children.map((child) => traverse(child)); + + if (textNode) { + const texts = CustomEditor.getNodeTextContent(textNode); + + if (texts && !LIST_TYPES.includes(block.type as EditorNodeType) && slateNode.type !== EditorNodeType.Page) { + slateNode.children.unshift(textNode); + } else if (texts) { + slateNode.children.unshift({ + type: EditorNodeType.Paragraph, + children: [textNode], + blockId: generateId(), + }); + } + } + + return slateNode; + }; + + const root = traverse(block); + + return root.children; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts new file mode 100644 index 0000000000000..e6eb1d6923864 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts @@ -0,0 +1,242 @@ +import { Op } from 'quill-delta'; +import { HTMLAttributes } from 'react'; +import { Element } from 'slate'; +import { ViewIconTypePB, ViewLayoutPB } from '@/services/backend'; +import { PageCover } from '$app_reducers/pages/slice'; +import * as Y from 'yjs'; + +export interface EditorNode { + id: string; + type: EditorNodeType; + parent?: string | null; + data?: BlockData; + children?: string; + externalId?: string; + externalType?: string; +} + +export interface TextNode extends Element { + type: EditorNodeType.Text; + textId: string; + blockId: string; +} + +export interface PageNode extends Element { + type: EditorNodeType.Page; +} +export interface ParagraphNode extends Element { + type: EditorNodeType.Paragraph; +} + +export type BlockData = { + [key: string]: string | boolean | number | undefined; + font_color?: string; + bg_color?: string; +}; + +export interface HeadingNode extends Element { + blockId: string; + type: EditorNodeType.HeadingBlock; + data: { + level: number; + } & BlockData; +} + +export interface GridNode extends Element { + blockId: string; + type: EditorNodeType.GridBlock; + data: { + viewId?: string; + } & BlockData; +} + +export interface TodoListNode extends Element { + blockId: string; + type: EditorNodeType.TodoListBlock; + data: { + checked: boolean; + } & BlockData; +} + +export interface CodeNode extends Element { + blockId: string; + type: EditorNodeType.CodeBlock; + data: { + language: string; + } & BlockData; +} + +export interface QuoteNode extends Element { + blockId: string; + type: EditorNodeType.QuoteBlock; +} + +export interface NumberedListNode extends Element { + type: EditorNodeType.NumberedListBlock; + blockId: string; + data: { + number?: number; + } & BlockData; +} + +export interface BulletedListNode extends Element { + type: EditorNodeType.BulletedListBlock; + blockId: string; +} + +export interface ToggleListNode extends Element { + type: EditorNodeType.ToggleListBlock; + blockId: string; + data: { + collapsed: boolean; + } & BlockData; +} + +export interface DividerNode extends Element { + type: EditorNodeType.DividerBlock; + blockId: string; +} + +export interface CalloutNode extends Element { + type: EditorNodeType.CalloutBlock; + blockId: string; + data: { + icon: string; + } & BlockData; +} + +export interface MathEquationNode extends Element { + type: EditorNodeType.EquationBlock; + blockId: string; + data: { + formula?: string; + } & BlockData; +} + +export enum ImageType { + Local = 0, + Internal = 1, + External = 2, +} + +export interface ImageNode extends Element { + type: EditorNodeType.ImageBlock; + blockId: string; + data: { + url?: string; + width?: number; + image_type?: ImageType; + height?: number; + } & BlockData; +} + +export interface FormulaNode extends Element { + type: EditorInlineNodeType.Formula; + data: string; +} + +export interface MentionNode extends Element { + type: EditorInlineNodeType.Mention; + data: Mention; +} + +export interface EditorData { + viewId: string; + rootId: string; + // key: block's id, value: block + nodeMap: Record; + // key: block's children id, value: block's id + childrenMap: Record; + // key: block's children id, value: block's id + relativeMap: Record; + // key: block's externalId, value: delta + deltaMap: Record; + // key: block's externalId, value: block's id + externalIdMap: Record; +} + +export interface MentionPage { + id: string; + name: string; + layout: ViewLayoutPB; + parentId: string; + icon?: { + ty: ViewIconTypePB; + value: string; + }; +} + +export interface EditorProps { + title?: string; + cover?: PageCover; + onTitleChange?: (title: string) => void; + onCoverChange?: (cover?: PageCover) => void; + showTitle?: boolean; + id: string; + disableFocus?: boolean; +} + +export interface LocalEditorProps { + disableFocus?: boolean; + sharedType: Y.XmlText; + id: string; + caretColor?: string; +} + +export enum EditorNodeType { + Text = 'text', + Paragraph = 'paragraph', + Page = 'page', + HeadingBlock = 'heading', + TodoListBlock = 'todo_list', + BulletedListBlock = 'bulleted_list', + NumberedListBlock = 'numbered_list', + ToggleListBlock = 'toggle_list', + CodeBlock = 'code', + EquationBlock = 'math_equation', + QuoteBlock = 'quote', + CalloutBlock = 'callout', + DividerBlock = 'divider', + ImageBlock = 'image', + GridBlock = 'grid', +} + +export enum EditorInlineNodeType { + Mention = 'mention', + Formula = 'formula', +} + +export const inlineNodeTypes: (string | EditorInlineNodeType)[] = [ + EditorInlineNodeType.Mention, + EditorInlineNodeType.Formula, +]; + +export interface EditorElementProps extends HTMLAttributes { + node: T; +} + +export enum EditorMarkFormat { + Bold = 'bold', + Italic = 'italic', + Underline = 'underline', + StrikeThrough = 'strikethrough', + Code = 'code', + Href = 'href', + FontColor = 'font_color', + BgColor = 'bg_color', + Align = 'align', +} + +export enum MentionType { + PageRef = 'page', + Date = 'date', +} + +export interface Mention { + // inline page ref id + page_id?: string; + // reminder date ref id + date?: string; + + type: MentionType; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/folder/page.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/folder/page.service.ts new file mode 100644 index 0000000000000..7d988b9866d57 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/folder/page.service.ts @@ -0,0 +1,166 @@ +import { Page, PageIcon, parserViewPBToPage } from '$app_reducers/pages/slice'; +import { + CreateOrphanViewPayloadPB, + CreateViewPayloadPB, + MoveNestedViewPayloadPB, + RepeatedViewIdPB, + UpdateViewIconPayloadPB, + UpdateViewPayloadPB, + ViewIconPB, + ViewIdPB, + ViewPB, +} from '@/services/backend'; +import { + FolderEventCreateOrphanView, + FolderEventCreateView, + FolderEventDeleteView, + FolderEventDuplicateView, + FolderEventGetView, + FolderEventMoveNestedView, + FolderEventUpdateView, + FolderEventUpdateViewIcon, + FolderEventSetLatestView, +} from '@/services/backend/events/flowy-folder'; + +export async function getPage(id: string) { + const payload = new ViewIdPB({ + value: id, + }); + + const result = await FolderEventGetView(payload); + + if (result.ok) { + return parserViewPBToPage(result.val); + } + + return Promise.reject(result.val); +} + +export const createOrphanPage = async ( + params: ReturnType +): Promise => { + const payload = CreateOrphanViewPayloadPB.fromObject(params); + + const result = await FolderEventCreateOrphanView(payload); + + if (result.ok) { + return parserViewPBToPage(result.val); + } + + return Promise.reject(result.val); +}; + +export const duplicatePage = async (page: Page) => { + const payload = ViewPB.fromObject(page); + + const result = await FolderEventDuplicateView(payload); + + if (result.ok) { + return result.val; + } + + return Promise.reject(result.err); +}; + +export const deletePage = async (id: string) => { + const payload = new RepeatedViewIdPB({ + items: [id], + }); + + const result = await FolderEventDeleteView(payload); + + if (result.ok) { + return result.val; + } + + return Promise.reject(result.err); +}; + +export const createPage = async (params: ReturnType): Promise => { + const payload = CreateViewPayloadPB.fromObject(params); + + const result = await FolderEventCreateView(payload); + + if (result.ok) { + return result.val.id; + } + + return Promise.reject(result.err); +}; + +export const movePage = async (params: ReturnType) => { + const payload = new MoveNestedViewPayloadPB(params); + + const result = await FolderEventMoveNestedView(payload); + + if (result.ok) { + return result.val; + } + + return Promise.reject(result.err); +}; + +export const getChildPages = async (id: string): Promise => { + const payload = new ViewIdPB({ + value: id, + }); + + const result = await FolderEventGetView(payload); + + if (result.ok) { + return result.val.child_views.map(parserViewPBToPage); + } + + return []; +}; + +export const updatePage = async (page: { id: string } & Partial) => { + const payload = new UpdateViewPayloadPB(); + + payload.view_id = page.id; + if (page.name !== undefined) { + payload.name = page.name; + } + + const result = await FolderEventUpdateView(payload); + + if (result.ok) { + return result.val.toObject(); + } + + return Promise.reject(result.err); +}; + +export const updatePageIcon = async (viewId: string, icon?: PageIcon) => { + const payload = new UpdateViewIconPayloadPB({ + view_id: viewId, + icon: icon + ? new ViewIconPB({ + ty: icon.ty, + value: icon.value, + }) + : undefined, + }); + + const result = await FolderEventUpdateViewIcon(payload); + + if (result.ok) { + return result.val; + } + + return Promise.reject(result.err); +}; + +export async function setLatestOpenedPage(id: string) { + const payload = new ViewIdPB({ + value: id, + }); + + const res = await FolderEventSetLatestView(payload); + + if (res.ok) { + return res.val; + } + + return Promise.reject(res.err); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/folder/trash.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/folder/trash.service.ts new file mode 100644 index 0000000000000..dfbe742ca0fe0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/folder/trash.service.ts @@ -0,0 +1,68 @@ +import { + FolderEventListTrashItems, + FolderEventPermanentlyDeleteAllTrashItem, + FolderEventPermanentlyDeleteTrashItem, + FolderEventRecoverAllTrashItems, + FolderEventRestoreTrashItem, + RepeatedTrashIdPB, + TrashIdPB, +} from '@/services/backend/events/flowy-folder'; + +export const getTrash = async () => { + const res = await FolderEventListTrashItems(); + + if (res.ok) { + return res.val.items; + } + + return []; +}; + +export const putback = async (id: string) => { + const payload = new TrashIdPB({ + id, + }); + + const res = await FolderEventRestoreTrashItem(payload); + + if (res.ok) { + return res.val; + } + + return Promise.reject(res.err); +}; + +export const deleteTrashItem = async (ids: string[]) => { + const items = ids.map((id) => new TrashIdPB({ id })); + const payload = new RepeatedTrashIdPB({ + items, + }); + + const res = await FolderEventPermanentlyDeleteTrashItem(payload); + + if (res.ok) { + return res.val; + } + + return Promise.reject(res.err); +}; + +export const deleteAll = async () => { + const res = await FolderEventPermanentlyDeleteAllTrashItem(); + + if (res.ok) { + return res.val; + } + + return Promise.reject(res.err); +}; + +export const restoreAll = async () => { + const res = await FolderEventRecoverAllTrashItems(); + + if (res.ok) { + return res.val; + } + + return Promise.reject(res.err); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/folder/workspace.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/folder/workspace.service.ts new file mode 100644 index 0000000000000..fe066b73770de --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/folder/workspace.service.ts @@ -0,0 +1,141 @@ +import { parserViewPBToPage } from '$app_reducers/pages/slice'; +import { + ChangeWorkspaceIconPB, + CreateViewPayloadPB, + GetWorkspaceViewPB, + RenameWorkspacePB, + UserWorkspaceIdPB, + WorkspaceIdPB, +} from '@/services/backend'; +import { + FolderEventCreateView, + FolderEventDeleteWorkspace, + FolderEventGetCurrentWorkspaceSetting, + FolderEventReadCurrentWorkspace, + FolderEventReadWorkspaceViews, +} from '@/services/backend/events/flowy-folder'; +import { + UserEventChangeWorkspaceIcon, + UserEventGetAllWorkspace, + UserEventOpenWorkspace, + UserEventRenameWorkspace, +} from '@/services/backend/events/flowy-user'; + +export async function openWorkspace(id: string) { + const payload = new UserWorkspaceIdPB({ + workspace_id: id, + }); + + const result = await UserEventOpenWorkspace(payload); + + if (result.ok) { + return result.val; + } + + return Promise.reject(result.err); +} + +export async function deleteWorkspace(id: string) { + const payload = new WorkspaceIdPB({ + value: id, + }); + + const result = await FolderEventDeleteWorkspace(payload); + + if (result.ok) { + return result.val; + } + + return Promise.reject(result.err); +} + +export async function getWorkspaceChildViews(id: string) { + const payload = new GetWorkspaceViewPB({ + value: id, + }); + + const result = await FolderEventReadWorkspaceViews(payload); + + if (result.ok) { + return result.val.items.map(parserViewPBToPage); + } + + return []; +} + +export async function getWorkspaces() { + const result = await UserEventGetAllWorkspace(); + + if (result.ok) { + return result.val.items.map((workspace) => ({ + id: workspace.workspace_id, + name: workspace.name, + })); + } + + return []; +} + +export async function getCurrentWorkspaceSetting() { + const res = await FolderEventGetCurrentWorkspaceSetting(); + + if (res.ok) { + return res.val; + } + + return; +} + +export async function getCurrentWorkspace() { + const result = await FolderEventReadCurrentWorkspace(); + + if (result.ok) { + return result.val.id; + } + + return null; +} + +export async function createCurrentWorkspaceChildView( + params: ReturnType +) { + const payload = CreateViewPayloadPB.fromObject(params); + + const result = await FolderEventCreateView(payload); + + if (result.ok) { + return result.val; + } + + return Promise.reject(result.err); +} + +export async function renameWorkspace(id: string, name: string) { + const payload = new RenameWorkspacePB({ + workspace_id: id, + new_name: name, + }); + + const result = await UserEventRenameWorkspace(payload); + + if (result.ok) { + return result.val; + } + + return Promise.reject(result.err); +} + +export async function changeWorkspaceIcon(id: string, icon: string) { + const payload = new ChangeWorkspaceIconPB({ + workspace_id: id, + new_icon: icon, + }); + + const result = await UserEventChangeWorkspaceIcon(payload); + + if (result.ok) { + return result.val; + } + + return Promise.reject(result.err); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts b/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts new file mode 100644 index 0000000000000..c63a5d9823b18 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts @@ -0,0 +1,157 @@ +import { listen } from '@tauri-apps/api/event'; +import { SubscribeObject } from '@/services/backend/models/flowy-notification'; +import { + DatabaseFieldChangesetPB, + DatabaseNotification, + DocEventPB, + DocumentNotification, + FieldPB, + FieldSettingsPB, + FilterChangesetNotificationPB, + GroupChangesPB, + GroupRowsNotificationPB, + ReorderAllRowsPB, + ReorderSingleRowPB, + RowsChangePB, + RowsVisibilityChangePB, + SortChangesetNotificationPB, + UserNotification, + UserProfilePB, + FolderNotification, + RepeatedViewPB, + ViewPB, + RepeatedTrashPB, + ChildViewUpdatePB, + WorkspacePB, +} from '@/services/backend'; +import { AsyncQueue } from '$app/utils/async_queue'; + +const Notification = { + [DatabaseNotification.DidUpdateViewRowsVisibility]: RowsVisibilityChangePB, + [DatabaseNotification.DidUpdateViewRows]: RowsChangePB, + [DatabaseNotification.DidReorderRows]: ReorderAllRowsPB, + [DatabaseNotification.DidReorderSingleRow]: ReorderSingleRowPB, + [DatabaseNotification.DidUpdateFields]: DatabaseFieldChangesetPB, + [DatabaseNotification.DidGroupByField]: GroupChangesPB, + [DatabaseNotification.DidUpdateNumOfGroups]: GroupChangesPB, + [DatabaseNotification.DidUpdateGroupRow]: GroupRowsNotificationPB, + [DatabaseNotification.DidUpdateField]: FieldPB, + [DatabaseNotification.DidUpdateCell]: null, + [DatabaseNotification.DidUpdateSort]: SortChangesetNotificationPB, + [DatabaseNotification.DidUpdateFieldSettings]: FieldSettingsPB, + [DatabaseNotification.DidUpdateFilter]: FilterChangesetNotificationPB, + [DocumentNotification.DidReceiveUpdate]: DocEventPB, + [FolderNotification.DidUpdateWorkspace]: WorkspacePB, + [FolderNotification.DidUpdateWorkspaceViews]: RepeatedViewPB, + [FolderNotification.DidUpdateView]: ViewPB, + [FolderNotification.DidUpdateChildViews]: ChildViewUpdatePB, + [FolderNotification.DidUpdateTrash]: RepeatedTrashPB, + [UserNotification.DidUpdateUserProfile]: UserProfilePB, +}; + +type NotificationMap = typeof Notification; +export type NotificationEnum = keyof NotificationMap; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type NullableInstanceType any) | null> = K extends abstract new ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any +) => // eslint-disable-next-line @typescript-eslint/no-explicit-any +any + ? InstanceType + : void; +export type NotificationHandler = ( + result: NullableInstanceType +) => void | Promise; + +/** + * Subscribes to a set of notifications. + * + * This function subscribes to notifications defined by the `NotificationEnum` and + * calls the appropriate `NotificationHandler` when each type of notification is received. + * + * @param {Object} callbacks - An object containing handlers for various notification types. + * Each key is a `NotificationEnum` value, and the corresponding value is a `NotificationHandler` function. + * + * @param {Object} [options] - Optional settings for the subscription. + * @param {string} [options.id] - An optional ID. If provided, only notifications with a matching ID will be processed. + * + * @returns {Promise<() => void>} A Promise that resolves to an unsubscribe function. + * + * @example + * subscribeNotifications({ + * [DatabaseNotification.DidUpdateField]: (result) => { + * if (result.err) { + * // process error + * return; + * } + * + * console.log(result.val); // result.val is FieldPB + * }, + * [DatabaseNotification.DidReorderRows]: (result) => { + * if (result.err) { + * // process error + * return; + * } + * + * console.log(result.val); // result.val is ReorderAllRowsPB + * }, + * }, { id: '123' }) + * .then(unsubscribe => { + * // Do something + * // ... + * // To unsubscribe, call `unsubscribe()` + * }); + * + * @throws {Error} Throws an error if unable to subscribe. + */ +export function subscribeNotifications( + callbacks: { + [K in NotificationEnum]?: NotificationHandler; + }, + options?: { id?: string | number } +): Promise<() => void> { + const handler = async (subject: SubscribeObject) => { + const { id, ty } = subject; + + if (options?.id !== undefined && id !== options.id) { + return; + } + + const notification = ty as NotificationEnum; + const pb = Notification[notification]; + const callback = callbacks[notification] as NotificationHandler; + + if (pb === undefined || !callback) { + return; + } + + if (subject.has_error) { + // const error = FlowyError.deserialize(subject.error); + return; + } else { + const { payload } = subject; + + if (pb) { + await callback(pb.deserialize(payload)); + } else { + await callback(); + } + } + }; + + const queue = new AsyncQueue(handler); + + return listen>('af-notification', (event) => { + const subject = SubscribeObject.fromObject(event.payload); + + queue.enqueue(subject); + }); +} + +export function subscribeNotification( + notification: K, + callback: NotificationHandler, + options?: { id?: string } +): Promise<() => void> { + return subscribeNotifications({ [notification]: callback }, options); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/user/auth.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/user/auth.service.ts new file mode 100644 index 0000000000000..ec258abc87e99 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/user/auth.service.ts @@ -0,0 +1,92 @@ +import { + SignUpPayloadPB, + OauthProviderPB, + ProviderTypePB, + OauthSignInPB, + AuthenticatorPB, + SignInPayloadPB, +} from '@/services/backend'; +import { + UserEventSignOut, + UserEventSignUp, + UserEventGetOauthURLWithProvider, + UserEventOauthSignIn, + UserEventSignInWithEmailPassword, +} from '@/services/backend/events/flowy-user'; +import { Log } from '$app/utils/log'; + +export const AuthService = { + getOAuthURL: async (provider: ProviderTypePB) => { + const providerDataRes = await UserEventGetOauthURLWithProvider( + OauthProviderPB.fromObject({ + provider, + }) + ); + + if (!providerDataRes.ok) { + Log.error(providerDataRes.val.msg); + throw new Error(providerDataRes.val.msg); + } + + const providerData = providerDataRes.val; + + return providerData.oauth_url; + }, + + signInWithOAuth: async ({ uri, deviceId }: { uri: string; deviceId: string }) => { + const payload = OauthSignInPB.fromObject({ + authenticator: AuthenticatorPB.AppFlowyCloud, + map: { + sign_in_url: uri, + device_id: deviceId, + }, + }); + + const res = await UserEventOauthSignIn(payload); + + if (!res.ok) { + Log.error(res.val.msg); + throw new Error(res.val.msg); + } + + return res.val; + }, + + signUp: async (params: { deviceId: string; name: string; email: string; password: string }) => { + const payload = SignUpPayloadPB.fromObject({ + name: params.name, + email: params.email, + password: params.password, + device_id: params.deviceId, + }); + + const res = await UserEventSignUp(payload); + + if (!res.ok) { + Log.error(res.val.msg); + throw new Error(res.val.msg); + } + + return res.val; + }, + + signOut: () => { + return UserEventSignOut(); + }, + + signIn: async (email: string, password: string) => { + const payload = SignInPayloadPB.fromObject({ + email, + password, + }); + + const res = await UserEventSignInWithEmailPassword(payload); + + if (!res.ok) { + Log.error(res.val.msg); + throw new Error(res.val.msg); + } + + return res.val; + }, +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/user/user.service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/user/user.service.ts new file mode 100644 index 0000000000000..ec64fb810c603 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/user/user.service.ts @@ -0,0 +1,68 @@ +import { Theme, ThemeMode, UserSetting } from '$app_reducers/current-user/slice'; +import { AppearanceSettingsPB, UpdateUserProfilePayloadPB } from '@/services/backend'; +import { + UserEventGetAppearanceSetting, + UserEventGetUserProfile, + UserEventSetAppearanceSetting, + UserEventUpdateUserProfile, +} from '@/services/backend/events/flowy-user'; + +export const UserService = { + getAppearanceSetting: async (): Promise | undefined> => { + const appearanceSetting = await UserEventGetAppearanceSetting(); + + if (appearanceSetting.ok) { + const res = appearanceSetting.val; + const { locale, theme = Theme.Default, theme_mode = ThemeMode.Light } = res; + let language = 'en'; + + if (locale.language_code && locale.country_code) { + language = `${locale.language_code}-${locale.country_code}`; + } else if (locale.language_code) { + language = locale.language_code; + } + + return { + themeMode: theme_mode, + theme: theme as Theme, + language: language, + }; + } + + return; + }, + + setAppearanceSetting: async (params: ReturnType) => { + const payload = AppearanceSettingsPB.fromObject(params); + + const res = await UserEventSetAppearanceSetting(payload); + + if (res.ok) { + return res.val; + } + + return Promise.reject(res.err); + }, + + getUserProfile: async () => { + const res = await UserEventGetUserProfile(); + + if (res.ok) { + return res.val; + } + + return; + }, + + updateUserProfile: async (params: ReturnType) => { + const payload = UpdateUserProfilePayloadPB.fromObject(params); + + const res = await UserEventUpdateUserProfile(payload); + + if (res.ok) { + return res.val; + } + + return Promise.reject(res.err); + }, +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/add.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/add.svg new file mode 100644 index 0000000000000..049be05cecf25 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/add.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app_flowy/assets/images/editor/Align/center.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/align-center.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/Align/center.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/align-center.svg diff --git a/frontend/app_flowy/assets/images/editor/Align/left.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/align-left.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/Align/left.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/align-left.svg diff --git a/frontend/app_flowy/assets/images/editor/Align/right.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/align-right.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/Align/right.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/align-right.svg diff --git a/frontend/app_flowy/assets/images/editor/Arrow/left.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/arrow-left.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/Arrow/left.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/arrow-left.svg diff --git a/frontend/app_flowy/assets/images/editor/Arrow/right.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/arrow-right.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/Arrow/right.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/arrow-right.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/board.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/board.svg new file mode 100644 index 0000000000000..0bb0e3fabe560 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/board.svg @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/frontend/app_flowy/assets/images/editor/bold.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/bold.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/bold.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/bold.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/close.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/close.svg new file mode 100644 index 0000000000000..b519b419c0bcb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/copy.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/copy.svg new file mode 100644 index 0000000000000..e21e6cb082c12 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/dark-logo.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/dark-logo.svg new file mode 100644 index 0000000000000..80d8c4132e98d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/dark-logo.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/checkbox-check.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/checkbox-check.svg new file mode 100644 index 0000000000000..15632e4ea62d5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/database/checkbox-check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/assets/images/editor/editor_uncheck.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/checkbox-uncheck.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/editor_uncheck.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/database/checkbox-uncheck.svg diff --git a/frontend/app_flowy/assets/images/editor/attach.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-attach.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/attach.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-attach.svg diff --git a/frontend/app_flowy/assets/images/editor/checkbox.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-checkbox.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/checkbox.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-checkbox.svg diff --git a/frontend/app_flowy/assets/images/editor/checklist.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-checklist.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/checklist.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-checklist.svg diff --git a/frontend/app_flowy/assets/images/editor/date.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-date.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/date.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-date.svg diff --git a/frontend/app_flowy/assets/images/editor/time.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-last-edited-time.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/time.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-last-edited-time.svg diff --git a/frontend/app_flowy/assets/images/editor/bullet_list.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-multi-select.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/bullet_list.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-multi-select.svg diff --git a/frontend/app_flowy/assets/images/editor/numbers.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-number.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/numbers.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-number.svg diff --git a/frontend/app_flowy/assets/images/editor/person.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-person.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/person.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-person.svg diff --git a/frontend/app_flowy/assets/images/editor/Icons 16/Relation.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-relation.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/Icons 16/Relation.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-relation.svg diff --git a/frontend/app_flowy/assets/images/editor/status.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-single-select.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/status.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-single-select.svg diff --git a/frontend/app_flowy/assets/images/editor/text.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-text.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/text.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-text.svg diff --git a/frontend/app_flowy/assets/images/grid/field/url.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-url.svg similarity index 100% rename from frontend/app_flowy/assets/images/grid/field/url.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/database/field-type-url.svg diff --git a/frontend/app_flowy/assets/images/grid/field/date.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/date.svg similarity index 100% rename from frontend/app_flowy/assets/images/grid/field/date.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/date.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/delete.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/delete.svg new file mode 100644 index 0000000000000..9e5163679844d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/delete.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/details.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/details.svg new file mode 100644 index 0000000000000..22c6830916496 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/details.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/document.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/document.svg new file mode 100644 index 0000000000000..b00e1cfb38c9d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/document.svg @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/frontend/app_flowy/assets/images/editor/drag_element.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/drag.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/drag_element.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/drag.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/dropdown.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/dropdown.svg new file mode 100644 index 0000000000000..95e4964b5379b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/dropdown.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/edit.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/edit.svg new file mode 100644 index 0000000000000..ae9328711464e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/edit.svg @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/eye_close.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/eye_close.svg new file mode 100644 index 0000000000000..116c715ca8efb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/eye_close.svg @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/eye_open.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/eye_open.svg new file mode 100644 index 0000000000000..fa3017c04da36 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/eye_open.svg @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/grid.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/grid.svg new file mode 100644 index 0000000000000..c397af8130113 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/app_flowy/assets/images/editor/H1.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/h1.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/H1.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/h1.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/h2.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/h2.svg new file mode 100644 index 0000000000000..7449c57391efa --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/h2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/h3.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/h3.svg new file mode 100644 index 0000000000000..0976945974be5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/h3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/hide-menu.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/hide-menu.svg new file mode 100644 index 0000000000000..ce88af8ea7c69 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/hide-menu.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/hide.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/hide.svg new file mode 100644 index 0000000000000..22001ef65dd7c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/hide.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg new file mode 100644 index 0000000000000..0739605066ab0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/images/default_cover.jpg b/frontend/appflowy_tauri/src/appflowy_app/assets/images/default_cover.jpg new file mode 100644 index 0000000000000..aeaa6a0f29b2d Binary files /dev/null and b/frontend/appflowy_tauri/src/appflowy_app/assets/images/default_cover.jpg differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/information.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/information.svg new file mode 100644 index 0000000000000..37ca4d58379f1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/information.svg @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/frontend/app_flowy/assets/images/editor/inline_block.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/inline-code.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/inline_block.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/inline-code.svg diff --git a/frontend/app_flowy/assets/images/editor/italic.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/italic.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/italic.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/italic.svg diff --git a/frontend/app_flowy/assets/images/editor/left.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/left.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/left.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/left.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/light-logo.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/light-logo.svg new file mode 100644 index 0000000000000..f5cd761ba77c8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/light-logo.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/link.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/link.svg similarity index 100% rename from frontend/app_flowy/packages/appflowy_editor/assets/images/link.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/link.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/list-dropdown.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/list-dropdown.svg new file mode 100644 index 0000000000000..4a8424c5f8e1e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/list-dropdown.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/assets/images/grid/field/multi_select.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/list.svg similarity index 100% rename from frontend/app_flowy/assets/images/grid/field/multi_select.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/list.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/logo.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/logo.svg new file mode 100644 index 0000000000000..b1ac8d66fb664 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app_flowy/assets/images/editor/persoin_1.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/mention.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/persoin_1.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/mention.svg diff --git a/frontend/app_flowy/assets/images/editor/more.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/more.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/more.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/more.svg diff --git a/frontend/app_flowy/assets/images/grid/field/numbers.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/numbers.svg similarity index 100% rename from frontend/app_flowy/assets/images/grid/field/numbers.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/numbers.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/open.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/open.svg new file mode 100644 index 0000000000000..b443c8b9931e7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/open.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/app_flowy/assets/images/editor/quote.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/quote.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/quote.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/quote.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/react.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/react.svg new file mode 100644 index 0000000000000..6c87de9bb3358 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/app_flowy/assets/images/editor/right.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/right.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/right.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/right.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/search.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/search.svg new file mode 100644 index 0000000000000..a8a92df509a02 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/select-check.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/select-check.svg new file mode 100644 index 0000000000000..05caec861a5dc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/select-check.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/settings.svg new file mode 100644 index 0000000000000..92140a3c2317a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/account.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/account.svg new file mode 100644 index 0000000000000..fddfca7575e30 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/account.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/check_circle.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/check_circle.svg new file mode 100644 index 0000000000000..c6fa56067bcaf --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/check_circle.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/dark.png b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/dark.png new file mode 100644 index 0000000000000..15a2db5eb8d0b Binary files /dev/null and b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/dark.png differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/discord.png b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/discord.png new file mode 100644 index 0000000000000..f71e68c6ed819 Binary files /dev/null and b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/discord.png differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/github.png b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/github.png new file mode 100644 index 0000000000000..597883b7a3e24 Binary files /dev/null and b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/github.png differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/google.png b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/google.png new file mode 100644 index 0000000000000..60032628a8a9e Binary files /dev/null and b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/google.png differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/light.png b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/light.png new file mode 100644 index 0000000000000..09b2d9c4755f0 Binary files /dev/null and b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/light.png differ diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/settings/workplace.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/workplace.svg new file mode 100644 index 0000000000000..2076ea3e2c347 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/settings/workplace.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/app_flowy/assets/images/editor/show_menu.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/show-menu.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/show_menu.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/show-menu.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/sort.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/sort.svg new file mode 100644 index 0000000000000..e3b6a49a565ed --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/sort.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/app_flowy/assets/images/editor/strikethrough.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/strikethrough.svg similarity index 100% rename from frontend/app_flowy/assets/images/editor/strikethrough.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/strikethrough.svg diff --git a/frontend/app_flowy/assets/images/grid/field/text.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/text.svg similarity index 100% rename from frontend/app_flowy/assets/images/grid/field/text.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/text.svg diff --git a/frontend/app_flowy/assets/images/grid/field/checkbox.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/todo-list.svg similarity index 100% rename from frontend/app_flowy/assets/images/grid/field/checkbox.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/todo-list.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/underline.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/underline.svg new file mode 100644 index 0000000000000..f5d53f0ec2207 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/underline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/up.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/up.svg new file mode 100644 index 0000000000000..bd8f3067d3170 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/up.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/ProfileAvatar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/ProfileAvatar.tsx new file mode 100644 index 0000000000000..1248882238fcf --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/ProfileAvatar.tsx @@ -0,0 +1,33 @@ +import { stringToColor, stringToShortName } from '$app/utils/avatar'; +import { Avatar } from '@mui/material'; +import { useAppSelector } from '$app/stores/store'; + +export const ProfileAvatar = ({ + onClick, + className, + width, + height, +}: { + onClick?: (e: React.MouseEvent) => void; + width?: number; + height?: number; + className?: string; +}) => { + const { displayName = 'Me', iconUrl } = useAppSelector((state) => state.currentUser); + + return ( + + {iconUrl ? iconUrl : stringToShortName(displayName)} + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/WorkplaceAvatar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/WorkplaceAvatar.tsx new file mode 100644 index 0000000000000..079342b528ec8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/WorkplaceAvatar.tsx @@ -0,0 +1,34 @@ +import { Avatar } from '@mui/material'; +import { stringToColor, stringToShortName } from '$app/utils/avatar'; + +export const WorkplaceAvatar = ({ + workplaceName, + icon, + onClick, + width, + height, + className, +}: { + workplaceName: string; + width: number; + height: number; + className?: string; + icon?: string; + onClick?: (e: React.MouseEvent) => void; +}) => { + return ( + + {icon ? icon : stringToShortName(workplaceName)} + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/index.ts new file mode 100644 index 0000000000000..772056737ac7c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/avatar/index.ts @@ -0,0 +1,2 @@ +export * from './WorkplaceAvatar'; +export * from './ProfileAvatar'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/DeleteConfirmDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/DeleteConfirmDialog.tsx new file mode 100644 index 0000000000000..058335d30c0c0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/DeleteConfirmDialog.tsx @@ -0,0 +1,77 @@ +import React, { useCallback } from 'react'; +import DialogContent from '@mui/material/DialogContent'; +import { Button, DialogProps } from '@mui/material'; +import Dialog from '@mui/material/Dialog'; +import { useTranslation } from 'react-i18next'; +import { Log } from '$app/utils/log'; + +interface Props extends DialogProps { + open: boolean; + title: string; + subtitle?: string; + onOk?: () => Promise; + onClose: () => void; + onCancel?: () => void; + okText?: string; + cancelText?: string; + container?: HTMLElement | null; +} + +function DeleteConfirmDialog({ open, title, onOk, onCancel, onClose, okText, cancelText, container, ...props }: Props) { + const { t } = useTranslation(); + + const onDone = useCallback(async () => { + try { + await onOk?.(); + onClose(); + } catch (e) { + Log.error(e); + } + }, [onClose, onOk]); + + return ( + { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + void onDone(); + } + }} + onMouseDown={(e) => e.stopPropagation()} + open={open} + onClose={onClose} + {...props} + > + + {title} +
+ + +
+
+
+ ); +} + +export default DeleteConfirmDialog; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/RenameDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/RenameDialog.tsx new file mode 100644 index 0000000000000..cb8b7a80ed552 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/RenameDialog.tsx @@ -0,0 +1,81 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import Dialog from '@mui/material/Dialog'; +import { useTranslation } from 'react-i18next'; +import TextField from '@mui/material/TextField'; +import { Button, DialogActions, Divider } from '@mui/material'; + +function RenameDialog({ + defaultValue, + open, + onClose, + onOk, +}: { + defaultValue: string; + open: boolean; + onClose: () => void; + onOk: (val: string) => Promise; +}) { + const { t } = useTranslation(); + const [value, setValue] = useState(defaultValue); + const [error, setError] = useState(false); + + useEffect(() => { + setValue(defaultValue); + setError(false); + }, [defaultValue]); + + const onDone = useCallback(async () => { + try { + await onOk(value); + onClose(); + } catch (e) { + setError(true); + } + }, [onClose, onOk, value]); + + return ( + e.stopPropagation()} open={open} onClose={onClose}> + {t('menuAppHeader.renameDialog')} + + { + e.stopPropagation(); + if (e.key === 'Enter') { + e.preventDefault(); + void onDone(); + } + + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }} + onChange={(e) => { + setValue(e.target.value); + }} + margin='dense' + fullWidth + variant='standard' + /> + + + + + + + + ); +} + +export default RenameDialog; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/AppFlowyDevTool.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/AppFlowyDevTool.tsx new file mode 100644 index 0000000000000..5d3ed1e3decf7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/AppFlowyDevTool.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import SpeedDial from '@mui/material/SpeedDial'; +import SpeedDialIcon from '@mui/material/SpeedDialIcon'; +import SpeedDialAction from '@mui/material/SpeedDialAction'; +import { useMemo } from 'react'; +import { CloseOutlined, BuildOutlined, LoginOutlined, VisibilityOff } from '@mui/icons-material'; +import ManualSignInDialog from '$app/components/_shared/devtool/ManualSignInDialog'; +import { Portal } from '@mui/material'; + +function AppFlowyDevTool() { + const [openManualSignIn, setOpenManualSignIn] = React.useState(false); + const [hidden, setHidden] = React.useState(false); + const actions = useMemo( + () => [ + { + icon: , + name: 'Manual SignIn', + onClick: () => { + setOpenManualSignIn(true); + }, + }, + { + icon: , + name: 'Hide Dev Tool', + onClick: () => { + setHidden(true); + }, + }, + ], + [] + ); + + return ( + + + + ); +} + +export default AppFlowyDevTool; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/ManualSignInDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/ManualSignInDialog.tsx new file mode 100644 index 0000000000000..364b334a075cb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/devtool/ManualSignInDialog.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { CircularProgress, DialogActions, DialogProps, Tab, Tabs, TextareaAutosize } from '@mui/material'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import Button from '@mui/material/Button'; +import { useAuth } from '$app/components/auth/auth.hooks'; +import TextField from '@mui/material/TextField'; + +function ManualSignInDialog(props: DialogProps) { + const [uri, setUri] = React.useState(''); + const [loading, setLoading] = React.useState(false); + const { signInWithOAuth, signInWithEmailPassword } = useAuth(); + const [tab, setTab] = React.useState(0); + const [email, setEmail] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [domain, setDomain] = React.useState(''); + const handleSignIn = async () => { + setLoading(true); + try { + if (tab === 1) { + if (!email || !password) return; + await signInWithEmailPassword(email, password, domain); + } else { + await signInWithOAuth(uri); + } + } finally { + setLoading(false); + } + + props?.onClose?.({}, 'backdropClick'); + }; + + return ( + { + if (e.key === 'Enter') { + e.preventDefault(); + void handleSignIn(); + } + }} + > + + { + setTab(value); + }} + > + + + + {tab === 1 ? ( +
+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + setDomain(e.target.value)} + /> +
+ ) : ( + { + setUri(e.target.value); + }} + /> + )} +
+ + + + +
+ ); +} + +export default ManualSignInDialog; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag_block/drag.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag_block/drag.hooks.ts new file mode 100644 index 0000000000000..85f0507fff5f6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag_block/drag.hooks.ts @@ -0,0 +1,87 @@ +import React from 'react'; + +interface Props { + dragId?: string; + onEnd?: (result: { dragId: string; position: 'before' | 'after' | 'inside' }) => void; +} + +function calcPosition(targetRect: DOMRect, clientY: number) { + const top = targetRect.top + targetRect.height / 3; + const bottom = targetRect.bottom - targetRect.height / 3; + + if (clientY < top) return 'before'; + if (clientY > bottom) return 'after'; + return 'inside'; +} + +export function useDrag(props: Props) { + const { dragId, onEnd } = props; + const [isDraggingOver, setIsDraggingOver] = React.useState(false); + const [isDragging, setIsDragging] = React.useState(false); + const [dropPosition, setDropPosition] = React.useState<'before' | 'after' | 'inside'>(); + const onDrop = (e: React.DragEvent) => { + e.stopPropagation(); + setIsDraggingOver(false); + setIsDragging(false); + setDropPosition(undefined); + const currentTarget = e.currentTarget; + + if (currentTarget.parentElement?.closest(`[data-drop-enabled="false"]`)) return; + if (currentTarget.closest(`[data-dragging="true"]`)) return; + const dragId = e.dataTransfer.getData('dragId'); + const targetRect = currentTarget.getBoundingClientRect(); + const { clientY } = e; + + const position = calcPosition(targetRect, clientY); + + onEnd && onEnd({ dragId, position }); + }; + + const onDragOver = (e: React.DragEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (isDragging) return; + const currentTarget = e.currentTarget; + + if (currentTarget.parentElement?.closest(`[data-drop-enabled="false"]`)) return; + if (currentTarget.closest(`[data-dragging="true"]`)) return; + setIsDraggingOver(true); + const targetRect = currentTarget.getBoundingClientRect(); + const { clientY } = e; + const position = calcPosition(targetRect, clientY); + + setDropPosition(position); + }; + + const onDragLeave = (e: React.DragEvent) => { + e.stopPropagation(); + setIsDraggingOver(false); + setDropPosition(undefined); + }; + + const onDragStart = (e: React.DragEvent) => { + if (!dragId) return; + e.stopPropagation(); + e.dataTransfer.setData('dragId', dragId); + e.dataTransfer.effectAllowed = 'move'; + setIsDragging(true); + }; + + const onDragEnd = (e: React.DragEvent) => { + e.stopPropagation(); + setIsDragging(false); + setIsDraggingOver(false); + setDropPosition(undefined); + }; + + return { + onDrop, + onDragOver, + onDragLeave, + onDragStart, + isDraggingOver, + isDragging, + onDragEnd, + dropPosition, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag_block/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag_block/index.ts new file mode 100644 index 0000000000000..e0cb540f75606 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag_block/index.ts @@ -0,0 +1 @@ +export * from './drag.hooks'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.hooks.ts new file mode 100644 index 0000000000000..af82c82df58ec --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.hooks.ts @@ -0,0 +1,165 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import emojiData, { EmojiMartData } from '@emoji-mart/data'; +import { init, FrequentlyUsed, getEmojiDataFromNative, Store } from 'emoji-mart'; + +import { PopoverProps } from '@mui/material/Popover'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import chunk from 'lodash-es/chunk'; + +export const EMOJI_SIZE = 32; + +export const PER_ROW_EMOJI_COUNT = 13; + +export const MAX_FREQUENTLY_ROW_COUNT = 2; + +export interface EmojiCategory { + id: string; + emojis: Emoji[]; +} + +interface Emoji { + id: string; + name: string; + native: string; +} + +export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: string) => void }) { + const [searchValue, setSearchValue] = useState(''); + const [emojiCategories, setEmojiCategories] = useState([]); + const [skin, setSkin] = useState(() => { + return Number(Store.get('skin')) || 0; + }); + + const onSkinChange = useCallback((val: number) => { + setSkin(val); + Store.set('skin', String(val)); + }, []); + + const loadEmojiData = useCallback( + async (searchVal?: string) => { + const { emojis, categories } = emojiData as EmojiMartData; + + const filteredCategories = categories + .map((category) => { + const { id, emojis: categoryEmojis } = category; + + return { + id, + emojis: categoryEmojis + .filter((emojiId) => { + const emoji = emojis[emojiId]; + + if (!searchVal) return true; + return filterSearchValue(emoji, searchVal); + }) + .map((emojiId) => { + const emoji = emojis[emojiId]; + const { name, skins } = emoji; + + return { + id: emojiId, + name, + native: skins[skin] ? skins[skin].native : skins[0].native, + }; + }), + }; + }) + .filter((category) => category.emojis.length > 0); + + setEmojiCategories(filteredCategories); + }, + [skin] + ); + + useEffect(() => { + void (async () => { + await init({ data: emojiData, maxFrequentRows: MAX_FREQUENTLY_ROW_COUNT, perLine: PER_ROW_EMOJI_COUNT }); + await loadEmojiData(); + })(); + }, [loadEmojiData]); + + useEffect(() => { + void loadEmojiData(searchValue); + }, [loadEmojiData, searchValue]); + + const onSelect = useCallback( + async (native: string) => { + onEmojiSelect(native); + if (!native) { + return; + } + + const data = await getEmojiDataFromNative(native); + + FrequentlyUsed.add(data); + }, + [onEmojiSelect] + ); + + return { + emojiCategories, + setSearchValue, + searchValue, + onSelect, + onSkinChange, + skin, + }; +} + +export function useSelectSkinPopoverProps(): PopoverProps & { + onOpen: (event: React.MouseEvent) => void; + onClose: () => void; +} { + const [anchorEl, setAnchorEl] = useState(undefined); + const onOpen = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + const onClose = useCallback(() => { + setAnchorEl(undefined); + }, []); + const open = Boolean(anchorEl); + const anchorOrigin = { vertical: 'bottom', horizontal: 'center' } as PopoverOrigin; + const transformOrigin = { vertical: 'top', horizontal: 'center' } as PopoverOrigin; + + return { + anchorEl, + onOpen, + onClose, + open, + anchorOrigin, + transformOrigin, + }; +} + +function filterSearchValue(emoji: emojiData.Emoji, searchValue: string) { + const { name, keywords } = emoji; + const searchValueLowerCase = searchValue.toLowerCase(); + + return ( + name.toLowerCase().includes(searchValueLowerCase) || + (keywords && keywords.some((keyword) => keyword.toLowerCase().includes(searchValueLowerCase))) + ); +} + +export function getRowsWithCategories(emojiCategories: EmojiCategory[], rowSize: number) { + const rows: { + id: string; + type: 'category' | 'emojis'; + emojis?: Emoji[]; + }[] = []; + + emojiCategories.forEach((category) => { + rows.push({ + id: category.id, + type: 'category', + }); + chunk(category.emojis, rowSize).forEach((chunk, index) => { + rows.push({ + type: 'emojis', + emojis: chunk, + id: `${category.id}-${index}`, + }); + }); + }); + return rows; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.tsx new file mode 100644 index 0000000000000..b8dcb3f6c73a9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import { useLoadEmojiData } from './EmojiPicker.hooks'; +import EmojiPickerHeader from './EmojiPickerHeader'; +import EmojiPickerCategories from './EmojiPickerCategories'; + +interface Props { + onEmojiSelect: (emoji: string) => void; + onEscape?: () => void; + defaultEmoji?: string; +} + +function EmojiPicker({ defaultEmoji, onEscape, ...props }: Props) { + const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props); + + return ( +
+ + +
+ ); +} + +export default EmojiPicker; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerCategories.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerCategories.tsx new file mode 100644 index 0000000000000..eefea8db11725 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerCategories.tsx @@ -0,0 +1,354 @@ +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { + EMOJI_SIZE, + EmojiCategory, + getRowsWithCategories, + PER_ROW_EMOJI_COUNT, +} from '$app/components/_shared/emoji_picker/EmojiPicker.hooks'; +import { FixedSizeList } from 'react-window'; +import { useTranslation } from 'react-i18next'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { getDistanceEdge, inView } from '$app/components/_shared/keyboard_navigation/utils'; + +function EmojiPickerCategories({ + emojiCategories, + onEmojiSelect, + onEscape, + defaultEmoji, +}: { + emojiCategories: EmojiCategory[]; + onEmojiSelect: (emoji: string) => void; + onEscape?: () => void; + defaultEmoji?: string; +}) { + const scrollRef = React.useRef(null); + const { t } = useTranslation(); + const [selectCell, setSelectCell] = React.useState({ + row: 1, + column: 0, + }); + const rows = useMemo(() => { + return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT); + }, [emojiCategories]); + const mouseY = useRef(null); + const mouseX = useRef(null); + + const ref = React.useRef(null); + + const getCategoryName = useCallback( + (id: string) => { + const i18nName: Record = { + frequent: t('emoji.categories.frequentlyUsed'), + people: t('emoji.categories.people'), + nature: t('emoji.categories.nature'), + foods: t('emoji.categories.food'), + activity: t('emoji.categories.activities'), + places: t('emoji.categories.places'), + objects: t('emoji.categories.objects'), + symbols: t('emoji.categories.symbols'), + flags: t('emoji.categories.flags'), + }; + + return i18nName[id]; + }, + [t] + ); + + useEffect(() => { + scrollRef.current?.scrollTo({ + top: 0, + }); + + setSelectCell({ + row: 1, + column: 0, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows]); + + const renderRow = useCallback( + ({ index, style }: { index: number; style: React.CSSProperties }) => { + const item = rows[index]; + + return ( +
+ {item.type === 'category' ? ( +
{getCategoryName(item.id)}
+ ) : null} +
+ {item.emojis?.map((emoji, columnIndex) => { + const isSelected = selectCell.row === index && selectCell.column === columnIndex; + + const isDefaultEmoji = defaultEmoji === emoji.native; + + return ( +
{ + onEmojiSelect(emoji.native); + }} + onMouseMove={(e) => { + mouseY.current = e.clientY; + mouseX.current = e.clientX; + }} + onMouseEnter={(e) => { + if (mouseY.current === null || mouseY.current !== e.clientY || mouseX.current !== e.clientX) { + setSelectCell({ + row: index, + column: columnIndex, + }); + } + + mouseX.current = e.clientX; + mouseY.current = e.clientY; + }} + className={`flex cursor-pointer items-center justify-center rounded hover:bg-fill-list-hover ${ + isSelected ? 'bg-fill-list-hover' : 'hover:bg-transparent' + } ${isDefaultEmoji ? 'bg-fill-list-active' : ''}`} + > + {emoji.native} +
+ ); + })} +
+
+ ); + }, + [defaultEmoji, getCategoryName, onEmojiSelect, rows, selectCell.column, selectCell.row] + ); + + const getNewColumnIndex = useCallback( + (rowIndex: number, columnIndex: number): number => { + const row = rows[rowIndex]; + const length = row.emojis?.length; + let newColumnIndex = columnIndex; + + if (length && length <= columnIndex) { + newColumnIndex = length - 1 || 0; + } + + return newColumnIndex; + }, + [rows] + ); + + const findNextRow = useCallback( + (rowIndex: number, columnIndex: number): { row: number; column: number } => { + const rowLength = rows.length; + let nextRowIndex = rowIndex + 1; + + if (nextRowIndex >= rowLength - 1) { + nextRowIndex = rowLength - 1; + } else if (rows[nextRowIndex].type === 'category') { + nextRowIndex = findNextRow(nextRowIndex, columnIndex).row; + } + + const newColumnIndex = getNewColumnIndex(nextRowIndex, columnIndex); + + return { + row: nextRowIndex, + column: newColumnIndex, + }; + }, + [getNewColumnIndex, rows] + ); + + const findPrevRow = useCallback( + (rowIndex: number, columnIndex: number): { row: number; column: number } => { + let prevRowIndex = rowIndex - 1; + + if (prevRowIndex < 1) { + prevRowIndex = 1; + } else if (rows[prevRowIndex].type === 'category') { + prevRowIndex = findPrevRow(prevRowIndex, columnIndex).row; + } + + const newColumnIndex = getNewColumnIndex(prevRowIndex, columnIndex); + + return { + row: prevRowIndex, + column: newColumnIndex, + }; + }, + [getNewColumnIndex, rows] + ); + + const findPrevCell = useCallback( + (row: number, column: number): { row: number; column: number } => { + const prevColumn = column - 1; + + if (prevColumn < 0) { + const prevRow = findPrevRow(row, column).row; + + if (prevRow === row) return { row, column }; + const length = rows[prevRow].emojis?.length || 0; + + return { + row: prevRow, + column: length > 0 ? length - 1 : 0, + }; + } + + return { + row, + column: prevColumn, + }; + }, + [findPrevRow, rows] + ); + + const findNextCell = useCallback( + (row: number, column: number): { row: number; column: number } => { + const nextColumn = column + 1; + + const rowLength = rows[row].emojis?.length || 0; + + if (nextColumn >= rowLength) { + const nextRow = findNextRow(row, column).row; + + if (nextRow === row) return { row, column }; + return { + row: nextRow, + column: 0, + }; + } + + return { + row, + column: nextColumn, + }; + }, + [findNextRow, rows] + ); + + useEffect(() => { + if (!selectCell || !scrollRef.current) return; + const emojiKey = rows[selectCell.row]?.emojis?.[selectCell.column]?.id; + const emojiDom = document.querySelector(`[data-key="${emojiKey}"]`); + + if (emojiDom && !inView(emojiDom as HTMLElement, scrollRef.current as HTMLElement)) { + const distance = getDistanceEdge(emojiDom as HTMLElement, scrollRef.current as HTMLElement); + + scrollRef.current?.scrollTo({ + top: scrollRef.current?.scrollTop + distance, + }); + } + }, [selectCell, rows]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + e.stopPropagation(); + + switch (e.key) { + case 'Escape': + e.preventDefault(); + onEscape?.(); + break; + case 'ArrowUp': { + e.preventDefault(); + + setSelectCell(findPrevRow(selectCell.row, selectCell.column)); + + break; + } + + case 'ArrowDown': { + e.preventDefault(); + + setSelectCell(findNextRow(selectCell.row, selectCell.column)); + + break; + } + + case 'ArrowLeft': { + e.preventDefault(); + + const prevCell = findPrevCell(selectCell.row, selectCell.column); + + setSelectCell(prevCell); + break; + } + + case 'ArrowRight': { + e.preventDefault(); + + const nextCell = findNextCell(selectCell.row, selectCell.column); + + setSelectCell(nextCell); + break; + } + + case 'Enter': { + e.preventDefault(); + const currentRow = rows[selectCell.row]; + const emoji = currentRow.emojis?.[selectCell.column]; + + if (emoji) { + onEmojiSelect(emoji.native); + } + + break; + } + + default: + break; + } + }, + [ + findNextCell, + findPrevCell, + findPrevRow, + findNextRow, + onEmojiSelect, + onEscape, + rows, + selectCell.column, + selectCell.row, + ] + ); + + useEffect(() => { + const focusElement = document.querySelector('.emoji-picker .search-emoji-input') as HTMLInputElement; + + const parentElement = ref.current?.parentElement; + + focusElement?.addEventListener('keydown', handleKeyDown); + parentElement?.addEventListener('keydown', handleKeyDown); + return () => { + focusElement?.removeEventListener('keydown', handleKeyDown); + parentElement?.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown]); + + return ( +
+ + {({ height, width }: { height: number; width: number }) => ( + + {renderRow} + + )} + +
+ ); +} + +export default EmojiPickerCategories; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerHeader.tsx new file mode 100644 index 0000000000000..177ac2e7a0391 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerHeader.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { Box, IconButton } from '@mui/material'; +import { Circle, DeleteOutlineRounded, SearchOutlined } from '@mui/icons-material'; +import TextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; +import { randomEmoji } from '$app/utils/emoji'; +import ShuffleIcon from '@mui/icons-material/Shuffle'; +import Popover from '@mui/material/Popover'; +import { useSelectSkinPopoverProps } from '$app/components/_shared/emoji_picker/EmojiPicker.hooks'; +import { useTranslation } from 'react-i18next'; + +const skinTones = [ + { + value: 0, + color: '#ffc93a', + }, + { + color: '#ffdab7', + value: 1, + }, + { + color: '#e7b98f', + value: 2, + }, + { + color: '#c88c61', + value: 3, + }, + { + color: '#a46134', + value: 4, + }, + { + color: '#5d4437', + value: 5, + }, +]; + +interface Props { + onEmojiSelect: (emoji: string) => void; + skin: number; + onSkinSelect: (skin: number) => void; + searchValue: string; + onSearchChange: (value: string) => void; +} + +function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchChange, skin }: Props) { + const { onOpen, ...popoverProps } = useSelectSkinPopoverProps(); + const { t } = useTranslation(); + + return ( +
+
+ + + { + onSearchChange(e.target.value); + }} + autoFocus={true} + autoCorrect={'off'} + autoComplete={'off'} + spellCheck={false} + className={'search-emoji-input'} + placeholder={t('search.label')} + variant='standard' + /> + + +
+ { + const emoji = randomEmoji(); + + onEmojiSelect(emoji); + }} + > + + +
+
+ +
+ + + +
+
+ +
+ { + onEmojiSelect(''); + }} + > + + +
+
+
+ +
+ {skinTones.map((skinTone) => ( +
+ { + onSkinSelect(skinTone.value); + popoverProps.onClose?.(); + }} + > + + +
+ ))} +
+
+
+ ); +} + +export default EmojiPickerHeader; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/error_boundary/withError.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/error_boundary/withError.tsx new file mode 100644 index 0000000000000..9b9ba159fb510 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/error_boundary/withError.tsx @@ -0,0 +1,16 @@ +import { ErrorBoundary } from 'react-error-boundary'; +import { Log } from '$app/utils/log'; +import { Alert } from '@mui/material'; + +export default function withErrorBoundary(WrappedComponent: React.ComponentType) { + return (props: T) => ( + { + Log.error(e); + }} + fallback={Something went wrong} + > + + + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/EmbedLink.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/EmbedLink.tsx new file mode 100644 index 0000000000000..34a99007ade9b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/EmbedLink.tsx @@ -0,0 +1,70 @@ +import React, { useCallback, useState } from 'react'; +import TextField from '@mui/material/TextField'; +import { useTranslation } from 'react-i18next'; +import Button from '@mui/material/Button'; + +const urlPattern = /^(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm|.webp|.svg)(\?[^\s[",><]*)?$/; + +export function EmbedLink({ + onDone, + onEscape, + defaultLink, +}: { + defaultLink?: string; + onDone?: (value: string) => void; + onEscape?: () => void; +}) { + const { t } = useTranslation(); + + const [value, setValue] = useState(defaultLink ?? ''); + const [error, setError] = useState(false); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + + setValue(value); + setError(!urlPattern.test(value)); + }, + [setValue, setError] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !error && value) { + e.preventDefault(); + e.stopPropagation(); + onDone?.(value); + } + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onEscape?.(); + } + }, + [error, onDone, onEscape, value] + ); + + return ( +
+ + +
+ ); +} + +export default EmbedLink; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/LocalImage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/LocalImage.tsx new file mode 100644 index 0000000000000..d94e5f2889f42 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/LocalImage.tsx @@ -0,0 +1,62 @@ +import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; +import { CircularProgress } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { ErrorOutline } from '@mui/icons-material'; + +export const LocalImage = forwardRef< + HTMLImageElement, + { + renderErrorNode?: () => React.ReactElement | null; + } & React.ImgHTMLAttributes +>((localImageProps, ref) => { + const { src, renderErrorNode, ...props } = localImageProps; + const imageRef = useRef(null); + const { t } = useTranslation(); + const [imageURL, setImageURL] = useState(''); + const [loading, setLoading] = useState(true); + const [isError, setIsError] = useState(false); + const loadLocalImage = useCallback(async () => { + if (!src) return; + setLoading(true); + setIsError(false); + const { readBinaryFile, BaseDirectory } = await import('@tauri-apps/api/fs'); + + try { + const svg = src.endsWith('.svg'); + + const buffer = await readBinaryFile(src, { dir: BaseDirectory.AppLocalData }); + const blob = new Blob([buffer], { type: svg ? 'image/svg+xml' : 'image' }); + + setImageURL(URL.createObjectURL(blob)); + } catch (e) { + setIsError(true); + } + + setLoading(false); + }, [src]); + + useEffect(() => { + void loadLocalImage(); + }, [loadLocalImage]); + + if (loading) { + return ( +
+ + {t('editor.loading')}... +
+ ); + } + + if (isError) { + if (renderErrorNode) return renderErrorNode(); + return ( +
+ +
{t('editor.imageLoadFailed')}
+
+ ); + } + + return {'local; +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Unsplash.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Unsplash.tsx new file mode 100644 index 0000000000000..01da8323b9909 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Unsplash.tsx @@ -0,0 +1,154 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { createApi } from 'unsplash-js'; +import TextField from '@mui/material/TextField'; +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import debounce from 'lodash-es/debounce'; +import { CircularProgress } from '@mui/material'; +import { open } from '@tauri-apps/api/shell'; + +const unsplash = createApi({ + accessKey: '1WxD1JpMOUX86lZKKob4Ca0LMZPyO2rUmAgjpWm9Ids', +}); + +const SEARCH_DEBOUNCE_TIME = 500; + +export function Unsplash({ onDone, onEscape }: { onDone?: (value: string) => void; onEscape?: () => void }) { + const { t } = useTranslation(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [photos, setPhotos] = useState< + { + thumb: string; + regular: string; + alt: string | null; + id: string; + user: { + name: string; + link: string; + }; + }[] + >([]); + const [searchValue, setSearchValue] = useState(''); + + const handleChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + + setSearchValue(value); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onEscape?.(); + } + }, + [onEscape] + ); + + const debounceSearchPhotos = useMemo(() => { + return debounce(async (searchValue: string) => { + const request = searchValue + ? unsplash.search.getPhotos({ query: searchValue ?? undefined, perPage: 32 }) + : unsplash.photos.list({ perPage: 32 }); + + setError(''); + setLoading(true); + await request.then((result) => { + if (result.errors) { + setError(result.errors[0]); + } else { + setPhotos( + result.response.results.map((photo) => ({ + id: photo.id, + thumb: photo.urls.thumb, + regular: photo.urls.regular, + alt: photo.alt_description, + user: { + name: photo.user.name, + link: photo.user.links.html, + }, + })) + ); + } + + setLoading(false); + }); + }, SEARCH_DEBOUNCE_TIME); + }, []); + + useEffect(() => { + void debounceSearchPhotos(searchValue); + return () => { + debounceSearchPhotos.cancel(); + }; + }, [debounceSearchPhotos, searchValue]); + + return ( +
+ + + {loading ? ( +
+ +
{t('editor.loading')}
+
+ ) : error ? ( + + {error} + + ) : ( +
+ {photos.length > 0 ? ( + <> +
+ {photos.map((photo) => ( +
+ { + onDone?.(photo.regular); + }} + src={photo.thumb} + alt={photo.alt ?? ''} + className={'h-20 w-32 rounded object-cover hover:opacity-80'} + /> +
+ by{' '} + { + void open(photo.user.link); + }} + className={'underline hover:text-function-info'} + > + {photo.user.name} + +
+
+ ))} +
+ + {t('findAndReplace.searchMore')} + + + ) : ( + + {t('findAndReplace.noResult')} + + )} +
+ )} +
+ ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadImage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadImage.tsx new file mode 100644 index 0000000000000..d39da68caf921 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadImage.tsx @@ -0,0 +1,93 @@ +import React, { useCallback } from 'react'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import CloudUploadIcon from '@mui/icons-material/CloudUploadOutlined'; +import { notify } from '$app/components/_shared/notify'; +import { isTauri } from '$app/utils/env'; +import { getFileName, IMAGE_DIR, ALLOWED_IMAGE_EXTENSIONS, MAX_IMAGE_SIZE } from '$app/utils/upload_image'; + +export function UploadImage({ onDone }: { onDone?: (url: string) => void }) { + const { t } = useTranslation(); + + const checkTauriFile = useCallback( + async (url: string) => { + // const { readBinaryFile } = await import('@tauri-apps/api/fs'); + // const buffer = await readBinaryFile(url); + // const blob = new Blob([buffer]); + // if (blob.size > MAX_IMAGE_SIZE) { + // notify.error(t('document.imageBlock.error.invalidImageSize')); + // return false; + // } + + return true; + }, + [t] + ); + + const uploadTauriLocalImage = useCallback( + async (url: string) => { + const { copyFile, BaseDirectory, exists, createDir } = await import('@tauri-apps/api/fs'); + + const checked = await checkTauriFile(url); + + if (!checked) return; + + try { + const existDir = await exists(IMAGE_DIR, { dir: BaseDirectory.AppLocalData }); + + if (!existDir) { + await createDir(IMAGE_DIR, { dir: BaseDirectory.AppLocalData }); + } + + const filename = getFileName(url); + + await copyFile(url, `${IMAGE_DIR}/${filename}`, { dir: BaseDirectory.AppLocalData }); + const newUrl = `${IMAGE_DIR}/${filename}`; + + onDone?.(newUrl); + } catch (e) { + notify.error(t('document.plugins.image.imageUploadFailed')); + } + }, + [checkTauriFile, onDone, t] + ); + + const handleClickUpload = useCallback(async () => { + if (!isTauri()) return; + const { open } = await import('@tauri-apps/api/dialog'); + + const url = await open({ + multiple: false, + directory: false, + filters: [ + { + name: 'Image', + extensions: ALLOWED_IMAGE_EXTENSIONS, + }, + ], + }); + + if (!url || typeof url !== 'string') return; + + await uploadTauriLocalImage(url); + }, [uploadTauriLocalImage]); + + return ( +
+ +
+ ); +} + +export default UploadImage; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadTabs.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadTabs.tsx new file mode 100644 index 0000000000000..fb65c709cecbf --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadTabs.tsx @@ -0,0 +1,128 @@ +import React, { SyntheticEvent, useCallback, useState } from 'react'; +import Popover, { PopoverProps } from '@mui/material/Popover'; +import { TabPanel, ViewTab, ViewTabs } from '$app/components/database/components/tab_bar/ViewTabs'; +import SwipeableViews from 'react-swipeable-views'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; + +export enum TAB_KEY { + Colors = 'colors', + UPLOAD = 'upload', + EMBED_LINK = 'embed_link', + UNSPLASH = 'unsplash', +} + +export type TabOption = { + key: TAB_KEY; + label: string; + Component: React.ComponentType<{ + onDone?: (value: string) => void; + onEscape?: () => void; + }>; + onDone?: (value: string) => void; +}; + +export function UploadTabs({ + tabOptions, + popoverProps, + containerStyle, + extra, +}: { + containerStyle?: React.CSSProperties; + tabOptions: TabOption[]; + popoverProps?: PopoverProps; + extra?: React.ReactNode; +}) { + const [tabValue, setTabValue] = useState(() => { + return tabOptions[0].key; + }); + + const handleTabChange = useCallback((_: SyntheticEvent, newValue: string) => { + setTabValue(newValue as TAB_KEY); + }, []); + + const selectedIndex = tabOptions.findIndex((tab) => tab.key === tabValue); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + e.stopPropagation(); + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + popoverProps?.onClose?.({}, 'escapeKeyDown'); + } + + if (e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + setTabValue((prev) => { + const currentIndex = tabOptions.findIndex((tab) => tab.key === prev); + let nextIndex = currentIndex + 1; + + if (e.shiftKey) { + nextIndex = currentIndex - 1; + } + + return tabOptions[nextIndex % tabOptions.length]?.key ?? tabOptions[0].key; + }); + } + }, + [popoverProps, tabOptions] + ); + + return ( + +
+
+ + {tabOptions.map((tab) => { + const { key, label } = tab; + + return ; + })} + + {extra} +
+ +
+ + {tabOptions.map((tab, index) => { + const { key, Component, onDone } = tab; + + return ( + + popoverProps?.onClose?.({}, 'escapeKeyDown')} /> + + ); + })} + +
+
+
+ ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/index.ts new file mode 100644 index 0000000000000..28673cae5f6da --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/index.ts @@ -0,0 +1,5 @@ +export * from './Unsplash'; +export * from './UploadImage'; +export * from './EmbedLink'; +export * from './UploadTabs'; +export * from './LocalImage'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/katex_math/KatexMath.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/katex_math/KatexMath.tsx new file mode 100644 index 0000000000000..e6c7cac5ed89f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/katex_math/KatexMath.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import 'katex/dist/katex.min.css'; +import { BlockMath, InlineMath } from 'react-katex'; +import './index.css'; + +function KatexMath({ latex, isInline = false }: { latex: string; isInline?: boolean }) { + return isInline ? ( + + ) : ( + { + return ( +
+ {error.name}: {error.message} +
+ ); + }} + > + {latex} +
+ ); +} + +export default KatexMath; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/katex_math/index.css b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/katex_math/index.css new file mode 100644 index 0000000000000..d127dc343b59b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/katex_math/index.css @@ -0,0 +1,4 @@ + +.katex-html { + white-space: normal; +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/KeyboardNavigation.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/KeyboardNavigation.tsx new file mode 100644 index 0000000000000..7db90c4e8f6ee --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/KeyboardNavigation.tsx @@ -0,0 +1,317 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { MenuItem, Typography } from '@mui/material'; +import { scrollIntoView } from '$app/components/_shared/keyboard_navigation/utils'; +import { useTranslation } from 'react-i18next'; + +/** + * The option of the keyboard navigation + * the options will be flattened + * - key: the key of the option + * - content: the content of the option + * - children: the children of the option + */ +export interface KeyboardNavigationOption { + key: T; + content?: React.ReactNode; + children?: KeyboardNavigationOption[]; + disabled?: boolean; +} + +/** + * - scrollRef: the scrollable element + * - focusRef: the element to focus when the keyboard navigation is disabled + * - options: the options to navigate + * - onSelected: called when an option is selected(hovered) + * - onConfirm: called when an option is confirmed + * - onEscape: called when the escape key is pressed + * - onPressRight: called when the right arrow is pressed + * - onPressLeft: called when the left arrow key is pressed + * - disableFocus: disable the focus on the keyboard navigation + * - disableSelect: disable selecting an option when the options are initialized + * - onKeyDown: called when a key is pressed + * - defaultFocusedKey: the default focused key + * - onFocus: called when the keyboard navigation is focused + * - onBlur: called when the keyboard navigation is blurred + */ +export interface KeyboardNavigationProps { + scrollRef?: React.RefObject; + focusRef?: React.RefObject; + options: KeyboardNavigationOption[]; + onSelected?: (optionKey: T) => void; + onConfirm?: (optionKey: T) => void; + onEscape?: () => void; + onPressRight?: (optionKey: T) => void; + onPressLeft?: (optionKey: T) => void; + disableFocus?: boolean; + disableSelect?: boolean; + onKeyDown?: (e: KeyboardEvent) => void; + defaultFocusedKey?: T; + onFocus?: () => void; + onBlur?: () => void; + itemClassName?: string; + itemStyle?: React.CSSProperties; + renderNoResult?: () => React.ReactNode; +} + +function KeyboardNavigation({ + defaultFocusedKey, + onPressRight, + onPressLeft, + onEscape, + onConfirm, + scrollRef, + options, + onSelected, + focusRef, + disableFocus = false, + onKeyDown: onPropsKeyDown, + disableSelect = false, + onBlur, + onFocus, + itemClassName, + itemStyle, + renderNoResult, +}: KeyboardNavigationProps) { + const { t } = useTranslation(); + const ref = useRef(null); + const mouseY = useRef(null); + const defaultKeyRef = useRef(defaultFocusedKey); + // flatten the options + const flattenOptions = useMemo(() => { + return options.flatMap((group) => { + if (group.children) { + return group.children; + } + + return [group]; + }); + }, [options]); + + const [focusedKey, setFocusedKey] = useState(); + + const firstOptionKey = useMemo(() => { + if (disableSelect) return; + const firstOption = flattenOptions.find((option) => !option.disabled); + + return firstOption?.key; + }, [flattenOptions, disableSelect]); + + // set the default focused key when the options are initialized + useEffect(() => { + if (defaultKeyRef.current) { + setFocusedKey(defaultKeyRef.current); + defaultKeyRef.current = undefined; + return; + } + + setFocusedKey(firstOptionKey); + }, [firstOptionKey]); + + // call the onSelected callback when the focused key is changed + useEffect(() => { + if (focusedKey === undefined) return; + onSelected?.(focusedKey); + + const scrollElement = scrollRef?.current; + + if (!scrollElement) return; + + const dom = ref.current?.querySelector(`[data-key="${focusedKey}"]`); + + if (!dom) return; + // scroll the focused option into view + requestAnimationFrame(() => { + scrollIntoView(dom as HTMLDivElement, scrollElement); + }); + }, [focusedKey, onSelected, scrollRef]); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + onPropsKeyDown?.(e); + e.stopPropagation(); + const key = e.key; + + if (key === 'Tab') { + e.preventDefault(); + return; + } + + if (key === 'Escape') { + e.preventDefault(); + onEscape?.(); + return; + } + + if (focusedKey === undefined) return; + const focusedIndex = flattenOptions.findIndex((option) => option?.key === focusedKey); + const nextIndex = (focusedIndex + 1) % flattenOptions.length; + const prevIndex = (focusedIndex - 1 + flattenOptions.length) % flattenOptions.length; + + switch (key) { + // move the focus to the previous option + case 'ArrowUp': { + e.preventDefault(); + + const prevKey = flattenOptions[prevIndex]?.key; + + setFocusedKey(prevKey); + + break; + } + + // move the focus to the next option + case 'ArrowDown': { + e.preventDefault(); + const nextKey = flattenOptions[nextIndex]?.key; + + setFocusedKey(nextKey); + break; + } + + case 'ArrowRight': + if (onPressRight) { + e.preventDefault(); + onPressRight(focusedKey); + } + + break; + case 'ArrowLeft': + if (onPressLeft) { + e.preventDefault(); + onPressLeft(focusedKey); + } + + break; + // confirm the focused option + case 'Enter': { + e.preventDefault(); + const disabled = flattenOptions[focusedIndex]?.disabled; + + if (!disabled) { + onConfirm?.(focusedKey); + } + + break; + } + + default: + break; + } + }, + [flattenOptions, focusedKey, onConfirm, onEscape, onPressLeft, onPressRight, onPropsKeyDown] + ); + + const renderOption = useCallback( + (option: KeyboardNavigationOption, index: number) => { + const hasChildren = option.children; + + const isFocused = focusedKey === option.key; + + return ( +
+ {hasChildren ? ( + // render the group name + option.content &&
{option.content}
+ ) : ( + // render the option + { + mouseY.current = e.clientY; + }} + onMouseEnter={(e) => { + onFocus?.(); + if (mouseY.current === null || mouseY.current !== e.clientY) { + setFocusedKey(option.key); + } + + mouseY.current = e.clientY; + }} + onClick={() => { + setFocusedKey(option.key); + if (!option.disabled) { + onConfirm?.(option.key); + } + }} + selected={isFocused} + style={itemStyle} + className={`ml-0 flex w-full items-center justify-start rounded-none px-2 py-1 text-xs ${ + !isFocused ? 'hover:bg-transparent' : '' + } ${itemClassName ?? ''}`} + > + {option.content} + + )} + + {option.children?.map((child, childIndex) => { + return renderOption(child, index + childIndex); + })} +
+ ); + }, + [itemClassName, focusedKey, onConfirm, onFocus, itemStyle] + ); + + useEffect(() => { + const element = ref.current; + + if (!disableFocus && element) { + element.focus(); + element.addEventListener('keydown', onKeyDown); + + return () => { + element.removeEventListener('keydown', onKeyDown); + }; + } else { + let element: HTMLElement | null | undefined = focusRef?.current; + + if (!element) { + element = document.activeElement as HTMLElement; + } + + element?.addEventListener('keydown', onKeyDown); + return () => { + element?.removeEventListener('keydown', onKeyDown); + }; + } + }, [disableFocus, onKeyDown, focusRef]); + + return ( +
{ + e.stopPropagation(); + + onFocus?.(); + }} + onBlur={(e) => { + e.stopPropagation(); + + const target = e.relatedTarget as HTMLElement; + + if (target?.closest('.keyboard-navigation')) { + return; + } + + onBlur?.(); + }} + autoFocus={!disableFocus} + className={'keyboard-navigation flex w-full flex-col gap-1 outline-none'} + ref={ref} + > + {options.length > 0 ? ( + options.map(renderOption) + ) : renderNoResult ? ( + renderNoResult() + ) : ( + + {t('findAndReplace.noResult')} + + )} +
+ ); +} + +export default KeyboardNavigation; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/utils.ts new file mode 100644 index 0000000000000..621143869b3e2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/utils.ts @@ -0,0 +1,32 @@ +export function inView(dom: HTMLElement, container: HTMLElement) { + const domRect = dom.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + if (!domRect || !containerRect) return true; + + return domRect?.bottom <= containerRect?.bottom && domRect?.top >= containerRect?.top; +} + +export function getDistanceEdge(dom: HTMLElement, container: HTMLElement) { + const domRect = dom.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + if (!domRect || !containerRect) return 0; + + const distanceTop = domRect?.top - containerRect?.top; + const distanceBottom = domRect?.bottom - containerRect?.bottom; + + return Math.abs(distanceTop) < Math.abs(distanceBottom) ? distanceTop : distanceBottom; +} + +export function scrollIntoView(dom: HTMLElement, container: HTMLElement) { + const isDomInView = inView(dom, container); + + if (isDomInView) return; + + dom.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/notify/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/notify/index.ts new file mode 100644 index 0000000000000..1086cabdfd7a3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/notify/index.ts @@ -0,0 +1,27 @@ +import toast from 'react-hot-toast'; + +const commonOptions = { + style: { + background: 'var(--bg-base)', + color: 'var(--text-title)', + shadows: 'var(--shadow)', + }, +}; + +export const notify = { + success: (message: string) => { + toast.success(message, commonOptions); + }, + error: (message: string) => { + toast.error(message, commonOptions); + }, + loading: (message: string) => { + toast.loading(message, commonOptions); + }, + info: (message: string) => { + toast(message, commonOptions); + }, + clear: () => { + toast.dismiss(); + }, +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts new file mode 100644 index 0000000000000..0fc1b5e61ef4d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts @@ -0,0 +1,237 @@ +import { useState, useEffect, useCallback } from 'react'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; + +interface PopoverPosition { + anchorOrigin: PopoverOrigin; + transformOrigin: PopoverOrigin; + paperWidth: number; + paperHeight: number; + isEntered: boolean; + anchorPosition?: { left: number; top: number }; +} + +interface UsePopoverAutoPositionProps { + anchorEl?: HTMLElement | null; + anchorPosition?: { left: number; top: number; height: number }; + initialAnchorOrigin?: PopoverOrigin; + initialTransformOrigin?: PopoverOrigin; + initialPaperWidth: number; + initialPaperHeight: number; + marginThreshold?: number; + open: boolean; + anchorSize?: { width: number; height: number }; +} + +const minPaperWidth = 80; +const minPaperHeight = 120; + +function getOffsetLeft( + rect: { + height: number; + width: number; + }, + paperWidth: number, + horizontal: number | 'center' | 'left' | 'right', + transformHorizontal: number | 'center' | 'left' | 'right' +) { + let offset = 0; + + if (typeof horizontal === 'number') { + offset = horizontal; + } else if (horizontal === 'center') { + offset = rect.width / 2; + } else if (horizontal === 'right') { + offset = rect.width; + } + + if (transformHorizontal === 'center') { + offset -= paperWidth / 2; + } else if (transformHorizontal === 'right') { + offset -= paperWidth; + } + + return offset; +} + +function getOffsetTop( + rect: { + height: number; + width: number; + }, + papertHeight: number, + vertical: number | 'center' | 'bottom' | 'top', + transformVertical: number | 'center' | 'bottom' | 'top' +) { + let offset = 0; + + if (typeof vertical === 'number') { + offset = vertical; + } else if (vertical === 'center') { + offset = rect.height / 2; + } else if (vertical === 'bottom') { + offset = rect.height; + } + + if (transformVertical === 'center') { + offset -= papertHeight / 2; + } else if (transformVertical === 'bottom') { + offset -= papertHeight; + } + + return offset; +} + +const defaultAnchorOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'left', +}; + +const defaultTransformOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'left', +}; + +const usePopoverAutoPosition = ({ + anchorEl, + anchorPosition, + initialAnchorOrigin = defaultAnchorOrigin, + initialTransformOrigin = defaultTransformOrigin, + initialPaperWidth, + initialPaperHeight, + marginThreshold = 16, + open, +}: UsePopoverAutoPositionProps): PopoverPosition & { + calculateAnchorSize: () => void; +} => { + const [position, setPosition] = useState({ + anchorOrigin: initialAnchorOrigin, + transformOrigin: initialTransformOrigin, + paperWidth: initialPaperWidth, + paperHeight: initialPaperHeight, + anchorPosition, + isEntered: false, + }); + + const calculateAnchorSize = useCallback(() => { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + const getAnchorOffset = () => { + if (anchorPosition) { + return { + ...anchorPosition, + width: 0, + }; + } + + return anchorEl ? anchorEl.getBoundingClientRect() : undefined; + }; + + const anchorRect = getAnchorOffset(); + + if (!anchorRect) return; + let newPaperWidth = initialPaperWidth; + let newPaperHeight = initialPaperHeight; + const newAnchorPosition = { + top: anchorRect.top, + left: anchorRect.left, + }; + + // calculate new paper width + const newLeft = + anchorRect.left + + getOffsetLeft(anchorRect, newPaperWidth, initialAnchorOrigin.horizontal, initialTransformOrigin.horizontal); + const newTop = + anchorRect.top + + getOffsetTop(anchorRect, newPaperHeight, initialAnchorOrigin.vertical, initialTransformOrigin.vertical); + + let isExceedViewportRight = false; + let isExceedViewportBottom = false; + let isExceedViewportLeft = false; + let isExceedViewportTop = false; + + // Check if exceed viewport right + if (newLeft + newPaperWidth > viewportWidth - marginThreshold) { + isExceedViewportRight = true; + // Check if exceed viewport left + if (newLeft - newPaperWidth < marginThreshold) { + isExceedViewportLeft = true; + newPaperWidth = Math.max(minPaperWidth, Math.min(newPaperWidth, viewportWidth - newLeft - marginThreshold)); + } + } + + // Check if exceed viewport bottom + if (newTop + newPaperHeight > viewportHeight - marginThreshold) { + isExceedViewportBottom = true; + // Check if exceed viewport top + if (newTop - newPaperHeight < marginThreshold) { + isExceedViewportTop = true; + newPaperHeight = Math.max(minPaperHeight, Math.min(newPaperHeight, viewportHeight - newTop - marginThreshold)); + } + } + + const newPosition = { + anchorOrigin: { ...initialAnchorOrigin }, + transformOrigin: { ...initialTransformOrigin }, + paperWidth: newPaperWidth, + paperHeight: newPaperHeight, + anchorPosition: newAnchorPosition, + }; + + // If exceed viewport, adjust anchor origin and transform origin + if (!isExceedViewportRight && !isExceedViewportLeft) { + if (isExceedViewportBottom && !isExceedViewportTop) { + newPosition.anchorOrigin.vertical = 'top'; + newPosition.transformOrigin.vertical = 'bottom'; + } else if (!isExceedViewportBottom && isExceedViewportTop) { + newPosition.anchorOrigin.vertical = 'bottom'; + newPosition.transformOrigin.vertical = 'top'; + } + } else if (!isExceedViewportBottom && !isExceedViewportTop) { + if (isExceedViewportRight && !isExceedViewportLeft) { + newPosition.anchorOrigin.horizontal = 'left'; + newPosition.transformOrigin.horizontal = 'right'; + } else if (!isExceedViewportRight && isExceedViewportLeft) { + newPosition.anchorOrigin.horizontal = 'right'; + newPosition.transformOrigin.horizontal = 'left'; + } + } + + // anchorPosition is top-left of the anchor element, so we need to adjust it to avoid overlap with the anchor element + if (newPosition.anchorOrigin.vertical === 'bottom' && newPosition.transformOrigin.vertical === 'top') { + newPosition.anchorPosition.top += anchorRect.height; + } + + if ( + isExceedViewportTop && + isExceedViewportBottom && + newPosition.anchorOrigin.vertical === 'top' && + newPosition.transformOrigin.vertical === 'bottom' + ) { + newPosition.paperHeight = newPaperHeight - anchorRect.height; + } + + // Set new position and set isEntered to true + setPosition({ ...newPosition, isEntered: true }); + }, [ + initialAnchorOrigin, + initialTransformOrigin, + initialPaperWidth, + initialPaperHeight, + marginThreshold, + anchorEl, + anchorPosition, + ]); + + useEffect(() => { + if (!open) return; + calculateAnchorSize(); + }, [open, calculateAnchorSize]); + + return { + ...position, + calculateAnchorSize, + }; +}; + +export default usePopoverAutoPosition; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/utils.ts new file mode 100644 index 0000000000000..dccaf2f4d4e2b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/utils.ts @@ -0,0 +1,45 @@ +import { PopoverOrigin } from '@mui/material/Popover/Popover'; + +export function getOffsetTop(rect: DOMRect, vertical: number | 'center' | 'bottom' | 'top') { + let offset = 0; + + if (typeof vertical === 'number') { + offset = vertical; + } else if (vertical === 'center') { + offset = rect.height / 2; + } else if (vertical === 'bottom') { + offset = rect.height; + } + + return offset; +} + +export function getOffsetLeft(rect: DOMRect, horizontal: number | 'center' | 'left' | 'right') { + let offset = 0; + + if (typeof horizontal === 'number') { + offset = horizontal; + } else if (horizontal === 'center') { + offset = rect.width / 2; + } else if (horizontal === 'right') { + offset = rect.width; + } + + return offset; +} + +export function getAnchorOffset(anchorElement: HTMLElement, anchorOrigin: PopoverOrigin) { + const anchorRect = anchorElement.getBoundingClientRect(); + + return { + top: anchorRect.top + getOffsetTop(anchorRect, anchorOrigin.vertical), + left: anchorRect.left + getOffsetLeft(anchorRect, anchorOrigin.horizontal), + }; +} + +export function getTransformOrigin(elemRect: DOMRect, transformOrigin: PopoverOrigin) { + return { + vertical: getOffsetTop(elemRect, transformOrigin.vertical), + horizontal: getOffsetLeft(elemRect, transformOrigin.horizontal), + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/AFScroller.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/AFScroller.tsx new file mode 100644 index 0000000000000..0527b6cc26431 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/AFScroller.tsx @@ -0,0 +1,55 @@ +import { Scrollbars } from 'react-custom-scrollbars'; +import React from 'react'; + +export interface AFScrollerProps { + children: React.ReactNode; + overflowXHidden?: boolean; + overflowYHidden?: boolean; + className?: string; + style?: React.CSSProperties; +} +export const AFScroller = ({ style, children, overflowXHidden, overflowYHidden, className }: AFScrollerProps) => { + return ( +
} + renderThumbVertical={(props) =>
} + {...(overflowXHidden && { + renderTrackHorizontal: (props) => ( +
+ ), + })} + {...(overflowYHidden && { + renderTrackVertical: (props) => ( +
+ ), + })} + style={style} + renderView={(props) => ( +
+ )} + > + {children} + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/index.ts new file mode 100644 index 0000000000000..7a740a5bb0fe1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/scroller/index.ts @@ -0,0 +1 @@ +export * from './AFScroller'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewBanner.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewBanner.tsx new file mode 100644 index 0000000000000..95e44ae9c2258 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewBanner.tsx @@ -0,0 +1,53 @@ +import ViewIconGroup from '$app/components/_shared/view_title/ViewIconGroup'; +import { PageCover, PageIcon } from '$app_reducers/pages/slice'; +import ViewIcon from '$app/components/_shared/view_title/ViewIcon'; +import { ViewCover } from '$app/components/_shared/view_title/cover'; + +function ViewBanner({ + icon, + hover, + onUpdateIcon, + showCover, + cover, + onUpdateCover, +}: { + icon?: PageIcon; + hover: boolean; + onUpdateIcon: (icon: string) => void; + showCover: boolean; + cover?: PageCover; + onUpdateCover?: (cover?: PageCover) => void; +}) { + return ( +
+ {showCover && cover && } + +
+
+ +
+
+ +
+
+
+ ); +} + +export default ViewBanner; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIcon.tsx new file mode 100644 index 0000000000000..009548df5389a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIcon.tsx @@ -0,0 +1,62 @@ +import React, { useCallback, useState } from 'react'; +import Popover from '@mui/material/Popover'; +import EmojiPicker from '$app/components/_shared/emoji_picker/EmojiPicker'; +import { PageIcon } from '$app_reducers/pages/slice'; + +function ViewIcon({ icon, onUpdateIcon }: { icon?: PageIcon; onUpdateIcon: (icon: string) => void }) { + const [anchorPosition, setAnchorPosition] = useState<{ + top: number; + left: number; + }>(); + + const open = Boolean(anchorPosition); + const onOpen = useCallback((event: React.MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + + setAnchorPosition({ + top: rect.top + rect.height, + left: rect.left, + }); + }, []); + + const onEmojiSelect = useCallback( + (emoji: string) => { + onUpdateIcon(emoji); + if (!emoji) { + setAnchorPosition(undefined); + } + }, + [onUpdateIcon] + ); + + if (!icon) return null; + return ( + <> +
+
+ {icon.value} +
+
+ {open && ( + setAnchorPosition(undefined)} + > + { + setAnchorPosition(undefined); + }} + onEmojiSelect={onEmojiSelect} + /> + + )} + + ); +} + +export default ViewIcon; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIconGroup.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIconGroup.tsx new file mode 100644 index 0000000000000..54256f8eb1fde --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIconGroup.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from 'react-i18next'; +import { CoverType, PageCover, PageIcon } from '$app_reducers/pages/slice'; +import React, { useCallback } from 'react'; +import { randomEmoji } from '$app/utils/emoji'; +import { EmojiEmotionsOutlined } from '@mui/icons-material'; +import Button from '@mui/material/Button'; +import { ReactComponent as ImageIcon } from '$app/assets/image.svg'; +import { ImageType } from '$app/application/document/document.types'; + +interface Props { + icon?: PageIcon; + onUpdateIcon: (icon: string) => void; + showCover: boolean; + cover?: PageCover; + onUpdateCover?: (cover: PageCover) => void; +} + +const defaultCover = { + cover_selection_type: CoverType.Asset, + cover_selection: 'app_flowy_abstract_cover_2.jpeg', + image_type: ImageType.Internal, +}; + +function ViewIconGroup({ icon, onUpdateIcon, showCover, cover, onUpdateCover }: Props) { + const { t } = useTranslation(); + + const showAddIcon = !icon?.value; + + const showAddCover = !cover && showCover; + + const onAddIcon = useCallback(() => { + const emoji = randomEmoji(); + + onUpdateIcon(emoji); + }, [onUpdateIcon]); + + const onAddCover = useCallback(() => { + onUpdateCover?.(defaultCover); + }, [onUpdateCover]); + + return ( +
+ {showAddIcon && ( + + )} + {showAddCover && ( + + )} +
+ ); +} + +export default ViewIconGroup; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitle.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitle.tsx new file mode 100644 index 0000000000000..8d81b6d4b7e6a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitle.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import ViewBanner from '$app/components/_shared/view_title/ViewBanner'; +import { Page, PageCover, PageIcon } from '$app_reducers/pages/slice'; +import { ViewIconTypePB } from '@/services/backend'; +import ViewTitleInput from '$app/components/_shared/view_title/ViewTitleInput'; + +interface Props { + view: Page; + showTitle?: boolean; + onTitleChange?: (title: string) => void; + onUpdateIcon?: (icon: PageIcon) => void; + forceHover?: boolean; + showCover?: boolean; + onUpdateCover?: (cover?: PageCover) => void; +} + +function ViewTitle({ + view, + forceHover = false, + onTitleChange, + showTitle = true, + onUpdateIcon: onUpdateIconProp, + showCover = false, + onUpdateCover, +}: Props) { + const [hover, setHover] = useState(false); + const [icon, setIcon] = useState(view.icon); + + useEffect(() => { + setIcon(view.icon); + }, [view.icon]); + + const onUpdateIcon = useCallback( + (icon: string) => { + const newIcon = { + value: icon, + ty: ViewIconTypePB.Emoji, + }; + + setIcon(newIcon); + onUpdateIconProp?.(newIcon); + }, + [onUpdateIconProp] + ); + + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + > + + {showTitle && ( +
+ +
+ )} +
+ ); +} + +export default ViewTitle; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitleInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitleInput.tsx new file mode 100644 index 0000000000000..2c69bb4d760f3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewTitleInput.tsx @@ -0,0 +1,31 @@ +import React, { FormEventHandler, memo, useCallback, useRef } from 'react'; +import { TextareaAutosize } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +function ViewTitleInput({ value, onChange }: { value: string; onChange?: (value: string) => void }) { + const { t } = useTranslation(); + const textareaRef = useRef(null); + + const onTitleChange: FormEventHandler = useCallback( + (e) => { + const value = e.currentTarget.value; + + onChange?.(value); + }, + [onChange] + ); + + return ( + + ); +} + +export default memo(ViewTitleInput); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/Colors.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/Colors.tsx new file mode 100644 index 0000000000000..78b8bbcc4634c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/Colors.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { colorMap } from '$app/utils/color'; + +const colors = Object.entries(colorMap); + +function Colors({ onDone }: { onDone?: (value: string) => void }) { + return ( +
+ {colors.map(([name, value]) => ( +
onDone?.(name)} + /> + ))} +
+ ); +} + +export default Colors; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/CoverPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/CoverPopover.tsx new file mode 100644 index 0000000000000..bd8c178380342 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/CoverPopover.tsx @@ -0,0 +1,112 @@ +import React, { useMemo } from 'react'; +import { CoverType, PageCover } from '$app_reducers/pages/slice'; +import { PopoverOrigin } from '@mui/material/Popover'; +import { EmbedLink, Unsplash, UploadTabs, TabOption, TAB_KEY, UploadImage } from '$app/components/_shared/image_upload'; +import { useTranslation } from 'react-i18next'; +import Colors from '$app/components/_shared/view_title/cover/Colors'; +import { ImageType } from '$app/application/document/document.types'; +import Button from '@mui/material/Button'; + +const initialOrigin: { + anchorOrigin: PopoverOrigin; + transformOrigin: PopoverOrigin; +} = { + anchorOrigin: { + vertical: 'bottom', + horizontal: 'center', + }, + transformOrigin: { + vertical: 'top', + horizontal: 'center', + }, +}; + +function CoverPopover({ + anchorEl, + open, + onClose, + onUpdateCover, + onRemoveCover, +}: { + anchorEl: HTMLElement | null; + open: boolean; + onClose: () => void; + onUpdateCover?: (cover?: PageCover) => void; + onRemoveCover?: () => void; +}) { + const { t } = useTranslation(); + const tabOptions: TabOption[] = useMemo(() => { + return [ + { + label: t('document.plugins.cover.colors'), + key: TAB_KEY.Colors, + Component: Colors, + onDone: (value: string) => { + onUpdateCover?.({ + cover_selection_type: CoverType.Color, + cover_selection: value, + image_type: ImageType.Internal, + }); + }, + }, + { + label: t('button.upload'), + key: TAB_KEY.UPLOAD, + Component: UploadImage, + onDone: (value: string) => { + onUpdateCover?.({ + cover_selection_type: CoverType.Image, + cover_selection: value, + image_type: ImageType.Local, + }); + onClose(); + }, + }, + { + label: t('document.imageBlock.embedLink.label'), + key: TAB_KEY.EMBED_LINK, + Component: EmbedLink, + onDone: (value: string) => { + onUpdateCover?.({ + cover_selection_type: CoverType.Image, + cover_selection: value, + image_type: ImageType.External, + }); + onClose(); + }, + }, + { + key: TAB_KEY.UNSPLASH, + label: t('document.imageBlock.unsplash.label'), + Component: Unsplash, + onDone: (value: string) => { + onUpdateCover?.({ + cover_selection_type: CoverType.Image, + cover_selection: value, + image_type: ImageType.External, + }); + }, + }, + ]; + }, [onClose, onUpdateCover, t]); + + return ( + + {t('button.remove')} + + } + /> + ); +} + +export default CoverPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCover.tsx new file mode 100644 index 0000000000000..f207e078861c8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCover.tsx @@ -0,0 +1,80 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { CoverType, PageCover } from '$app_reducers/pages/slice'; +import { renderColor } from '$app/utils/color'; +import ViewCoverActions from '$app/components/_shared/view_title/cover/ViewCoverActions'; +import CoverPopover from '$app/components/_shared/view_title/cover/CoverPopover'; +import DefaultImage from '$app/assets/images/default_cover.jpg'; +import { ImageType } from '$app/application/document/document.types'; +import { LocalImage } from '$app/components/_shared/image_upload'; + +export function ViewCover({ cover, onUpdateCover }: { cover: PageCover; onUpdateCover?: (cover?: PageCover) => void }) { + const { + cover_selection_type: type, + cover_selection: value = '', + image_type: source, + } = useMemo(() => cover || {}, [cover]); + const [showAction, setShowAction] = useState(false); + const actionRef = useRef(null); + const [showPopover, setShowPopover] = useState(false); + + const renderCoverColor = useCallback((color: string) => { + return ( +
+ ); + }, []); + + const renderCoverImage = useCallback((url: string) => { + return {''}; + }, []); + + const handleRemoveCover = useCallback(() => { + onUpdateCover?.(null); + }, [onUpdateCover]); + + const handleClickChange = useCallback(() => { + setShowPopover(true); + }, []); + + return ( +
{ + setShowAction(true); + }} + onMouseLeave={() => { + setShowAction(false); + }} + className={'relative flex h-[255px] w-full'} + > + {source === ImageType.Local ? ( + + ) : ( + <> + {type === CoverType.Asset ? renderCoverImage(DefaultImage) : null} + {type === CoverType.Color ? renderCoverColor(value) : null} + {type === CoverType.Image ? renderCoverImage(value) : null} + + )} + + + {showPopover && ( + setShowPopover(false)} + anchorEl={actionRef.current} + onUpdateCover={onUpdateCover} + onRemoveCover={handleRemoveCover} + /> + )} +
+ ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCoverActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCoverActions.tsx new file mode 100644 index 0000000000000..fbf8063f44dec --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/ViewCoverActions.tsx @@ -0,0 +1,44 @@ +import React, { forwardRef } from 'react'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg'; + +function ViewCoverActions( + { show, onRemove, onClickChange }: { show: boolean; onRemove: () => void; onClickChange: () => void }, + ref: React.ForwardedRef +) { + const { t } = useTranslation(); + + return ( +
+
+
+ +
+
+
+ ); +} + +export default forwardRef(ViewCoverActions); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/index.ts new file mode 100644 index 0000000000000..8df50bb41e5f5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/cover/index.ts @@ -0,0 +1 @@ +export * from './ViewCover'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/LoginButtonGroup.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/auth/LoginButtonGroup.tsx new file mode 100644 index 0000000000000..481b80a532961 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/auth/LoginButtonGroup.tsx @@ -0,0 +1,51 @@ +import Button from '@mui/material/Button'; +import GoogleIcon from '$app/assets/settings/google.png'; +import GithubIcon from '$app/assets/settings/github.png'; +import DiscordIcon from '$app/assets/settings/discord.png'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '$app/components/auth/auth.hooks'; +import { ProviderTypePB } from '@/services/backend'; + +export const LoginButtonGroup = () => { + const { t } = useTranslation(); + + const { signIn } = useAuth(); + + return ( +
+ + + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx new file mode 100644 index 0000000000000..523f0b518897e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx @@ -0,0 +1,127 @@ +import { Outlet } from 'react-router-dom'; +import { useAuth } from './auth.hooks'; +import Layout from '$app/components/layout/Layout'; +import { useCallback, useEffect, useState } from 'react'; +import { Welcome } from '$app/components/auth/Welcome'; +import { isTauri } from '$app/utils/env'; +import { notify } from '$app/components/_shared/notify'; +import { currentUserActions, LoginState } from '$app_reducers/current-user/slice'; +import { CircularProgress, Portal } from '@mui/material'; +import { ReactComponent as Logo } from '$app/assets/logo.svg'; +import { useAppDispatch } from '$app/stores/store'; + +export const ProtectedRoutes = () => { + const { currentUser, checkUser, subscribeToUser, signInWithOAuth } = useAuth(); + const dispatch = useAppDispatch(); + + const isLoading = currentUser?.loginState === LoginState.Loading; + + const [checked, setChecked] = useState(false); + + const checkUserStatus = useCallback(async () => { + await checkUser(); + setChecked(true); + }, [checkUser]); + + useEffect(() => { + void checkUserStatus(); + }, [checkUserStatus]); + + useEffect(() => { + if (currentUser.isAuthenticated) { + return subscribeToUser(); + } + }, [currentUser.isAuthenticated, subscribeToUser]); + + const onDeepLink = useCallback(async () => { + if (!isTauri()) return; + const { event } = await import('@tauri-apps/api'); + + // On macOS You still have to install a .app bundle you got from tauri build --debug for this to work! + return await event.listen('open_deep_link', async (e) => { + const payload = e.payload as string; + + const [, hash] = payload.split('//#'); + const obj = parseHash(hash); + + if (!obj.access_token) { + notify.error('Failed to sign in, the access token is missing'); + dispatch(currentUserActions.setLoginState(LoginState.Error)); + return; + } + + try { + await signInWithOAuth(payload); + } catch (e) { + notify.error('Failed to sign in, please try again'); + } + }); + }, [dispatch, signInWithOAuth]); + + useEffect(() => { + void onDeepLink(); + }, [onDeepLink]); + + return ( +
+ {checked ? ( + + ) : ( +
+ +
+ )} + + {isLoading && } +
+ ); +}; + +const StartLoading = () => { + const dispatch = useAppDispatch(); + + useEffect(() => { + const preventDefault = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + dispatch(currentUserActions.resetLoginState()); + } + }; + + document.addEventListener('keydown', preventDefault, true); + + return () => { + document.removeEventListener('keydown', preventDefault, true); + }; + }, [dispatch]); + return ( + +
+ +
+
+ ); +}; + +const SplashScreen = ({ isAuthenticated }: { isAuthenticated: boolean }) => { + if (isAuthenticated) { + return ( + + + + ); + } else { + return ; + } +}; + +function parseHash(hash: string) { + const hashParams = new URLSearchParams(hash); + const hashObject: Record = {}; + + for (const [key, value] of hashParams) { + hashObject[key] = value; + } + + return hashObject; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/Welcome.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/auth/Welcome.tsx new file mode 100644 index 0000000000000..eadcf08c21833 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/auth/Welcome.tsx @@ -0,0 +1,55 @@ +import { ReactComponent as AppflowyLogo } from '$app/assets/logo.svg'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import { LoginButtonGroup } from '$app/components/auth/LoginButtonGroup'; +import { useAuth } from '$app/components/auth/auth.hooks'; +import { Log } from '$app/utils/log'; + +export const Welcome = () => { + const { signInAsAnonymous } = useAuth(); + const { t } = useTranslation(); + + return ( + <> +
e.preventDefault()} method='POST'> +
+
+ +
+ +
+ + {t('welcomeTo')} {t('appName')} + +
+ +
+ +
+
+ {t('signIn.or')} +
+
+
+ +
+
+
+ + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts new file mode 100644 index 0000000000000..89b7388e64389 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts @@ -0,0 +1,186 @@ +import { currentUserActions, LoginState, parseWorkspaceSettingPBToSetting } from '$app_reducers/current-user/slice'; +import { AuthenticatorPB, ProviderTypePB, UserNotification, UserProfilePB } from '@/services/backend/events/flowy-user'; +import { UserService } from '$app/application/user/user.service'; +import { AuthService } from '$app/application/user/auth.service'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { getCurrentWorkspaceSetting } from '$app/application/folder/workspace.service'; +import { useCallback } from 'react'; +import { subscribeNotifications } from '$app/application/notification'; +import { nanoid } from 'nanoid'; +import { open } from '@tauri-apps/api/shell'; + +export const useAuth = () => { + const dispatch = useAppDispatch(); + const currentUser = useAppSelector((state) => state.currentUser); + + // Subscribe to user update events + const subscribeToUser = useCallback(() => { + const unsubscribePromise = subscribeNotifications({ + [UserNotification.DidUpdateUserProfile]: async (changeset) => { + dispatch( + currentUserActions.updateUser({ + email: changeset.email, + displayName: changeset.name, + iconUrl: changeset.icon_url, + }) + ); + }, + }); + + return () => { + void unsubscribePromise.then((fn) => fn()); + }; + }, [dispatch]); + + const setUser = useCallback( + async (userProfile?: Partial) => { + if (!userProfile) return; + + const workspaceSetting = await getCurrentWorkspaceSetting(); + + const isLocal = userProfile.authenticator === AuthenticatorPB.Local; + + dispatch( + currentUserActions.updateUser({ + id: userProfile.id, + token: userProfile.token, + email: userProfile.email, + displayName: userProfile.name, + iconUrl: userProfile.icon_url, + isAuthenticated: true, + workspaceSetting: workspaceSetting ? parseWorkspaceSettingPBToSetting(workspaceSetting) : undefined, + isLocal, + }) + ); + }, + [dispatch] + ); + + // Check if the user is authenticated + const checkUser = useCallback(async () => { + const userProfile = await UserService.getUserProfile(); + + await setUser(userProfile); + + return userProfile; + }, [setUser]); + + const register = useCallback( + async (email: string, password: string, name: string): Promise => { + const deviceId = currentUser?.deviceId ?? nanoid(8); + const userProfile = await AuthService.signUp({ deviceId, email, password, name }); + + await setUser(userProfile); + + return userProfile; + }, + [setUser, currentUser?.deviceId] + ); + + const logout = useCallback(async () => { + await AuthService.signOut(); + dispatch(currentUserActions.logout()); + }, [dispatch]); + + const signInAsAnonymous = useCallback(async () => { + const fakeEmail = nanoid(8) + '@appflowy.io'; + const fakePassword = 'AppFlowy123@'; + const fakeName = 'Me'; + + await register(fakeEmail, fakePassword, fakeName); + }, [register]); + + const signIn = useCallback( + async (provider: ProviderTypePB) => { + dispatch(currentUserActions.setLoginState(LoginState.Loading)); + try { + const url = await AuthService.getOAuthURL(provider); + + await open(url); + } catch { + dispatch(currentUserActions.setLoginState(LoginState.Error)); + } + }, + [dispatch] + ); + + const signInWithOAuth = useCallback( + async (uri: string) => { + dispatch(currentUserActions.setLoginState(LoginState.Loading)); + try { + const deviceId = currentUser?.deviceId ?? nanoid(8); + + await AuthService.signInWithOAuth({ uri, deviceId }); + const userProfile = await UserService.getUserProfile(); + + await setUser(userProfile); + + return userProfile; + } catch (e) { + dispatch(currentUserActions.setLoginState(LoginState.Error)); + return Promise.reject(e); + } + }, + [dispatch, currentUser?.deviceId, setUser] + ); + + // Only for development purposes + const signInWithEmailPassword = useCallback( + async (email: string, password: string, domain?: string) => { + dispatch(currentUserActions.setLoginState(LoginState.Loading)); + + try { + const response = await fetch( + `https://${domain ? domain : 'test.appflowy.cloud'}/gotrue/token?grant_type=password`, + { + method: 'POST', + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: JSON.stringify({ + email, + password, + }), + } + ); + + const data = await response.json(); + + let uri = `appflowy-flutter://#`; + const params: string[] = []; + + Object.keys(data).forEach((key) => { + if (typeof data[key] === 'object') { + return; + } + + params.push(`${key}=${data[key]}`); + }); + uri += params.join('&'); + + return signInWithOAuth(uri); + } catch (e) { + dispatch(currentUserActions.setLoginState(LoginState.Error)); + return Promise.reject(e); + } + }, + [dispatch, signInWithOAuth] + ); + + return { + currentUser, + checkUser, + register, + logout, + subscribeToUser, + signInAsAnonymous, + signIn, + signInWithOAuth, + signInWithEmailPassword, + }; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts new file mode 100644 index 0000000000000..2597c158a137a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts @@ -0,0 +1,204 @@ +import { createContext, useCallback, useContext, useEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { proxy, useSnapshot } from 'valtio'; + +import { DatabaseLayoutPB, DatabaseNotification, FieldVisibility } from '@/services/backend'; +import { subscribeNotifications } from '$app/application/notification'; +import { + Cell, + Database, + databaseService, + cellListeners, + fieldListeners, + rowListeners, + sortListeners, + filterListeners, +} from '$app/application/database'; + +export function useSelectDatabaseView({ viewId }: { viewId?: string }) { + const key = 'v'; + const [searchParams, setSearchParams] = useSearchParams(); + + const selectedViewId = useMemo(() => searchParams.get(key) || viewId, [searchParams, viewId]); + + const onChange = useCallback( + (value: string) => { + setSearchParams({ [key]: value }); + }, + [setSearchParams] + ); + + return { + selectedViewId, + onChange, + }; +} + +const DatabaseContext = createContext({ + id: '', + isLinked: false, + layoutType: DatabaseLayoutPB.Grid, + fields: [], + rowMetas: [], + filters: [], + sorts: [], + groupSettings: [], + groups: [], + typeOptions: {}, + cells: {}, +}); + +export const DatabaseProvider = DatabaseContext.Provider; + +export const useDatabase = () => useSnapshot(useContext(DatabaseContext)); + +export const useSelectorCell = (rowId: string, fieldId: string) => { + const database = useContext(DatabaseContext); + const cells = useSnapshot(database.cells); + + return cells[`${rowId}:${fieldId}`]; +}; + +export const useDispatchCell = () => { + const database = useContext(DatabaseContext); + + const setCell = useCallback( + (cell: Cell) => { + const id = `${cell.rowId}:${cell.fieldId}`; + + database.cells[id] = cell; + }, + [database] + ); + + const deleteCells = useCallback( + ({ rowId, fieldId }: { rowId: string; fieldId?: string }) => { + cellListeners.didDeleteCells({ database, rowId, fieldId }); + }, + [database] + ); + + return { + deleteCells, + setCell, + }; +}; + +export const useDatabaseSorts = () => { + const context = useContext(DatabaseContext); + + return useSnapshot(context.sorts); +}; + +export const useSortsCount = () => { + const { sorts } = useDatabase(); + + return sorts?.length; +}; + +export const useFiltersCount = () => { + const { filters, fields } = useDatabase(); + + // filter fields: if the field is deleted, it will not be displayed + return useMemo( + () => filters?.map((filter) => fields.find((field) => field.id === filter.fieldId)).filter(Boolean).length, + [filters, fields] + ); +}; + +export function useStaticTypeOption(fieldId: string) { + const context = useContext(DatabaseContext); + const typeOptions = context.typeOptions; + + return typeOptions[fieldId] as T; +} + +export function useTypeOption(fieldId: string) { + const context = useContext(DatabaseContext); + const typeOptions = useSnapshot(context.typeOptions); + + return typeOptions[fieldId] as T; +} + +export const useDatabaseVisibilityRows = () => { + const { rowMetas } = useDatabase(); + + return useMemo(() => rowMetas.filter((row) => row && !row.isHidden), [rowMetas]); +}; + +export const useDatabaseVisibilityFields = () => { + const database = useDatabase(); + + return useMemo( + () => database.fields.filter((field) => field.visibility !== FieldVisibility.AlwaysHidden), + [database.fields] + ); +}; + +export const useConnectDatabase = (viewId: string) => { + const database = useMemo(() => { + const proxyDatabase = proxy({ + id: '', + isLinked: false, + layoutType: DatabaseLayoutPB.Grid, + fields: [], + rowMetas: [], + filters: [], + sorts: [], + groupSettings: [], + groups: [], + typeOptions: {}, + cells: {}, + }); + + void databaseService.openDatabase(viewId).then((value) => Object.assign(proxyDatabase, value)); + + return proxyDatabase; + }, [viewId]); + + useEffect(() => { + const unsubscribePromise = subscribeNotifications( + { + [DatabaseNotification.DidUpdateFields]: async (changeset) => { + await fieldListeners.didUpdateFields(viewId, database, changeset); + }, + [DatabaseNotification.DidUpdateFieldSettings]: (changeset) => { + fieldListeners.didUpdateFieldSettings(database, changeset); + }, + [DatabaseNotification.DidUpdateViewRows]: async (changeset) => { + await rowListeners.didUpdateViewRows(viewId, database, changeset); + }, + [DatabaseNotification.DidReorderRows]: (changeset) => { + rowListeners.didReorderRows(database, changeset); + }, + [DatabaseNotification.DidReorderSingleRow]: (changeset) => { + rowListeners.didReorderSingleRow(database, changeset); + }, + + [DatabaseNotification.DidUpdateSort]: (changeset) => { + sortListeners.didUpdateSort(database, changeset); + }, + + [DatabaseNotification.DidUpdateFilter]: (changeset) => { + filterListeners.didUpdateFilter(database, changeset); + }, + [DatabaseNotification.DidUpdateViewRowsVisibility]: async (changeset) => { + await rowListeners.didUpdateViewRowsVisibility(viewId, database, changeset); + }, + }, + { id: viewId } + ); + + return () => void unsubscribePromise.then((unsubscribe) => unsubscribe()); + }, [viewId, database]); + + return database; +}; + +const DatabaseRenderedContext = createContext<(viewId: string) => void>(() => { + return; +}); + +export const DatabaseRenderedProvider = DatabaseRenderedContext.Provider; + +export const useDatabaseRendered = () => useContext(DatabaseRenderedContext); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx new file mode 100644 index 0000000000000..d5e7bba45ba96 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx @@ -0,0 +1,202 @@ +import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useViewId } from '$app/hooks/ViewId.hooks'; +import { databaseViewService } from '$app/application/database'; +import { DatabaseTabBar } from './components'; +import { DatabaseLoader } from './DatabaseLoader'; +import { DatabaseView } from './DatabaseView'; +import { DatabaseCollection } from './components/database_settings'; +import SwipeableViews from 'react-swipeable-views'; +import { TabPanel } from '$app/components/database/components/tab_bar/ViewTabs'; +import DatabaseSettings from '$app/components/database/components/database_settings/DatabaseSettings'; +import { Portal } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { ErrorCode, FolderNotification } from '@/services/backend'; +import ExpandRecordModal from '$app/components/database/components/edit_record/ExpandRecordModal'; +import { subscribeNotifications } from '$app/application/notification'; +import { Page } from '$app_reducers/pages/slice'; +import { getPage } from '$app/application/folder/page.service'; +import './database.scss'; + +interface Props { + selectedViewId?: string; + setSelectedViewId?: (viewId: string) => void; +} + +export const Database = forwardRef(({ selectedViewId, setSelectedViewId }, ref) => { + const innerRef = useRef(); + const databaseRef = (ref ?? innerRef) as React.MutableRefObject; + const viewId = useViewId(); + const [settingDom, setSettingDom] = useState(null); + + const [page, setPage] = useState(null); + const { t } = useTranslation(); + const [notFound, setNotFound] = useState(false); + const [childViews, setChildViews] = useState([]); + const [editRecordRowId, setEditRecordRowId] = useState(null); + const [openCollections, setOpenCollections] = useState([]); + + const handleResetDatabaseViews = useCallback(async (viewId: string) => { + await databaseViewService + .getDatabaseViews(viewId) + .then((value) => { + setChildViews(value); + }) + .catch((err) => { + if (err.code === ErrorCode.RecordNotFound) { + setNotFound(true); + } + }); + }, []); + + const handleGetPage = useCallback(async () => { + try { + const page = await getPage(viewId); + + setPage(page); + } catch (e) { + setNotFound(true); + } + }, [viewId]); + + const parentId = page?.parentId; + + useEffect(() => { + void handleGetPage(); + void handleResetDatabaseViews(viewId); + const unsubscribePromise = subscribeNotifications({ + [FolderNotification.DidUpdateView]: (changeset) => { + if (changeset.parent_view_id !== viewId && changeset.id !== viewId) return; + setChildViews((prev) => { + const index = prev.findIndex((view) => view.id === changeset.id); + + if (index === -1) { + return prev; + } + + const newViews = [...prev]; + + newViews[index] = { + ...newViews[index], + name: changeset.name, + }; + + return newViews; + }); + }, + [FolderNotification.DidUpdateChildViews]: (changeset) => { + if (changeset.parent_view_id !== viewId && changeset.parent_view_id !== parentId) return; + if (changeset.create_child_views.length === 0 && changeset.delete_child_views.length === 0) { + return; + } + + void handleResetDatabaseViews(viewId); + }, + }); + + return () => void unsubscribePromise.then((unsubscribe) => unsubscribe()); + }, [handleGetPage, handleResetDatabaseViews, viewId, parentId]); + + const value = useMemo(() => { + return Math.max( + 0, + childViews.findIndex((view) => view.id === (selectedViewId ?? viewId)) + ); + }, [childViews, selectedViewId, viewId]); + + const onToggleCollection = useCallback( + (id: string, forceOpen?: boolean) => { + if (forceOpen) { + setOpenCollections((prev) => { + if (prev.includes(id)) { + return prev; + } + + return [...prev, id]; + }); + return; + } + + if (openCollections.includes(id)) { + setOpenCollections((prev) => prev.filter((item) => item !== id)); + } else { + setOpenCollections((prev) => [...prev, id]); + } + }, + [openCollections, setOpenCollections] + ); + + const onEditRecord = useCallback( + (rowId: string) => { + setEditRecordRowId(rowId); + }, + [setEditRecordRowId] + ); + + if (notFound) { + return ( +
+

{t('deletePagePrompt.text')}

+
+ ); + } + + return ( +
+ + + {childViews.map((view, index) => ( + + + {selectedViewId === view.id && ( + <> + {settingDom && ( + + onToggleCollection(view.id, forceOpen)} + /> + + )} + + + {editRecordRowId && ( + { + setEditRecordRowId(null); + }} + /> + )} + + )} + + + + + ))} + +
+ ); +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseLoader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseLoader.tsx new file mode 100644 index 0000000000000..b0aeab10a21af --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseLoader.tsx @@ -0,0 +1,18 @@ +import { FC, PropsWithChildren } from 'react'; +import { ViewIdProvider } from '$app/hooks'; +import { DatabaseProvider, useConnectDatabase } from './Database.hooks'; + +export interface DatabaseLoaderProps { + viewId: string; +} + +export const DatabaseLoader: FC> = ({ viewId, children }) => { + const database = useConnectDatabase(viewId); + + return ( + + {/* Make sure that the viewId is current */} + {children} + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseTitle.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseTitle.tsx new file mode 100644 index 0000000000000..cd94947d8d145 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseTitle.tsx @@ -0,0 +1,32 @@ +import { FormEventHandler, useCallback } from 'react'; +import { useViewId } from '$app/hooks'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { updatePageName } from '$app_reducers/pages/async_actions'; +import { useTranslation } from 'react-i18next'; + +export const DatabaseTitle = () => { + const viewId = useViewId(); + const { t } = useTranslation(); + const pageName = useAppSelector((state) => state.pages.pageMap[viewId]?.name || ''); + const dispatch = useAppDispatch(); + + const handleInput = useCallback( + (event) => { + const newTitle = (event.target as HTMLInputElement).value; + + void dispatch(updatePageName({ id: viewId, name: newTitle })); + }, + [viewId, dispatch] + ); + + return ( +
+ +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseView.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseView.tsx new file mode 100644 index 0000000000000..98b16d5feaa22 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/DatabaseView.tsx @@ -0,0 +1,23 @@ +import { DatabaseLayoutPB } from '@/services/backend'; +import { FC } from 'react'; +import { useDatabase } from './Database.hooks'; +import { Grid } from './grid'; +import { Board } from './board'; +import { Calendar } from './calendar'; + +export const DatabaseView: FC<{ + onEditRecord: (rowId: string) => void; +}> = (props) => { + const { layoutType } = useDatabase(); + + switch (layoutType) { + case DatabaseLayoutPB.Grid: + return ; + case DatabaseLayoutPB.Board: + return ; + case DatabaseLayoutPB.Calendar: + return ; + default: + return null; + } +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/CellText.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/CellText.tsx new file mode 100644 index 0000000000000..01666121cdd14 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/CellText.tsx @@ -0,0 +1,17 @@ +import React, { HTMLAttributes, PropsWithChildren } from 'react'; + +export interface CellTextProps { + className?: string; +} + +export const CellText = React.forwardRef>>( + function CellText(props, ref) { + const { children, className, ...other } = props; + + return ( +
+ {children} +
+ ); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/LinearProgressWithLabel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/LinearProgressWithLabel.tsx new file mode 100644 index 0000000000000..7d4c0d1811898 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/LinearProgressWithLabel.tsx @@ -0,0 +1,47 @@ +import React, { useMemo } from 'react'; + +function LinearProgressWithLabel({ + value, + count, + selectedCount, +}: { + value: number; + count: number; + selectedCount: number; +}) { + const result = useMemo(() => `${Math.round(value * 100)}%`, [value]); + + const options = useMemo(() => { + return Array.from({ length: count }, (_, i) => ({ + id: i, + checked: i < selectedCount, + })); + }, [count, selectedCount]); + + const isSplit = count < 6; + + return ( +
+
+ {options.map((option) => ( + + ))} +
+
{result}
+
+ ); +} + +export default LinearProgressWithLabel; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/constants.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/constants.ts new file mode 100644 index 0000000000000..fd1aab7a37904 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/constants.ts @@ -0,0 +1,9 @@ +export enum DragType { + Row = 'row', + Field = 'field', +} + +export enum DropPosition { + Before = 0, + After = 1, +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/dnd.context.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/dnd.context.ts new file mode 100644 index 0000000000000..8954dc733a32f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/dnd.context.ts @@ -0,0 +1,17 @@ +import { createContext } from 'react'; +import { proxy } from 'valtio'; + +export interface DragItem> { + type: string; + data: T; +} + +export interface DndContextDescriptor { + dragging: DragItem | null, +} + +const defaultDndContext: DndContextDescriptor = proxy({ + dragging: null, +}); + +export const DndContext = createContext(defaultDndContext); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/drag.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/drag.hooks.ts new file mode 100644 index 0000000000000..ce8afe6f3193e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/drag.hooks.ts @@ -0,0 +1,114 @@ +import { DragEventHandler, useCallback, useContext, useMemo, useRef, useState } from 'react'; +import { DndContext } from './dnd.context'; +import { autoScrollOnEdge, EdgeGap, getScrollParent, ScrollDirection } from './utils'; + +export interface UseDraggableOptions { + type: string; + effectAllowed?: DataTransfer['effectAllowed']; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: Record; + disabled?: boolean; + scrollOnEdge?: { + direction?: ScrollDirection; + getScrollElement?: () => HTMLElement | null; + edgeGap?: number | Partial; + }; +} + +export const useDraggable = ({ + type, + effectAllowed = 'copyMove', + data, + disabled, + scrollOnEdge, +}: UseDraggableOptions) => { + const scrollDirection = scrollOnEdge?.direction; + const edgeGap = scrollOnEdge?.edgeGap; + + const context = useContext(DndContext); + const typeRef = useRef(type); + const dataRef = useRef(data); + const previewRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + typeRef.current = type; + dataRef.current = data; + + const setPreviewRef = useCallback((previewElement: null | Element) => { + previewRef.current = previewElement; + }, []); + + const attributes: { + draggable?: boolean; + } = useMemo(() => { + if (disabled) { + return {}; + } + + return { + draggable: true, + }; + }, [disabled]); + + const onDragStart = useCallback( + (event) => { + setIsDragging(true); + context.dragging = { + type: typeRef.current, + data: dataRef.current ?? {}, + }; + + const { dataTransfer } = event; + const previewNode = previewRef.current; + + dataTransfer.effectAllowed = effectAllowed; + + if (previewNode) { + const { clientX, clientY } = event; + const rect = previewNode.getBoundingClientRect(); + + dataTransfer.setDragImage(previewNode, clientX - rect.x, clientY - rect.y); + } + + if (scrollDirection === undefined) { + return; + } + + const scrollParent: HTMLElement | null = + scrollOnEdge?.getScrollElement?.() ?? getScrollParent(event.target as HTMLElement, scrollDirection); + + if (scrollParent) { + autoScrollOnEdge({ + element: scrollParent, + direction: scrollDirection, + edgeGap, + }); + } + }, + [context, effectAllowed, scrollDirection, scrollOnEdge, edgeGap] + ); + + const onDragEnd = useCallback(() => { + setIsDragging(false); + context.dragging = null; + }, [context]); + + const listeners: { + onDragStart?: DragEventHandler; + onDragEnd?: DragEventHandler; + } = useMemo( + () => ({ + onDragStart, + onDragEnd, + }), + [onDragStart, onDragEnd] + ); + + return { + isDragging, + previewRef, + attributes, + listeners, + setPreviewRef, + }; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/drop.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/drop.hooks.ts new file mode 100644 index 0000000000000..7b3d79aeb2915 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/drop.hooks.ts @@ -0,0 +1,88 @@ +import { DragEventHandler, useContext, useState, useMemo, useCallback } from 'react'; +import { useSnapshot } from 'valtio'; +import { DragItem, DndContext } from './dnd.context'; + +interface UseDroppableOptions { + accept: string; + dropEffect?: DataTransfer['dropEffect']; + disabled?: boolean; + onDragOver?: DragEventHandler, + onDrop?: (data: DragItem) => void; +} + +export const useDroppable = ({ + accept, + dropEffect = 'move', + disabled, + onDragOver: handleDragOver, + onDrop: handleDrop, +}: UseDroppableOptions) => { + const dndContext = useContext(DndContext); + const dndSnapshot = useSnapshot(dndContext); + + const [ dragOver, setDragOver ] = useState(false); + const canDrop = useMemo( + () => !disabled && dndSnapshot.dragging?.type === accept, + [ disabled, accept, dndSnapshot.dragging?.type ], + ); + const isOver = useMemo(()=> canDrop && dragOver, [ canDrop, dragOver ]); + + const onDragEnter = useCallback((event) => { + if (!canDrop) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = dropEffect; + + setDragOver(true); + }, [ canDrop, dropEffect ]); + + const onDragOver = useCallback((event) => { + if (!canDrop) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = dropEffect; + + setDragOver(true); + handleDragOver?.(event); + }, [ canDrop, dropEffect, handleDragOver ]); + + const onDragLeave = useCallback(() => { + if (!canDrop) { + return; + } + + setDragOver(false); + }, [ canDrop ]); + + const onDrop = useCallback(() => { + if (!canDrop) { + return; + } + + const dragging = dndSnapshot.dragging; + + if (!dragging) { + return; + } + + setDragOver(false); + handleDrop?.(dragging); + }, [ canDrop, dndSnapshot.dragging, handleDrop ]); + + const listeners = useMemo(() => ({ + onDragEnter, + onDragOver, + onDragLeave, + onDrop, + }), [ onDragEnter, onDragOver, onDragLeave, onDrop ]); + + return { + isOver, + canDrop, + listeners, + }; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/index.ts new file mode 100644 index 0000000000000..8688534359330 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/index.ts @@ -0,0 +1,7 @@ +export * from './dnd.context'; +export * from './drag.hooks'; +export * from './drop.hooks'; +export { + ScrollDirection, + Edge, +} from './utils'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/utils.ts new file mode 100644 index 0000000000000..3aafa6f77c67b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/utils.ts @@ -0,0 +1,170 @@ +import { interval } from '$app/utils/tool'; + +export enum Edge { + Top = 'top', + Bottom = 'bottom', + Left = 'left', + Right = 'right', +} + +export enum ScrollDirection { + Horizontal = 'horizontal', + Vertical = 'vertical', +} + +export interface EdgeGap { + top: number; + bottom: number; + left: number; + right: number; +} + +export const isReachEdge = (element: Element, edge: Edge) => { + switch (edge) { + case Edge.Left: + return element.scrollLeft === 0; + case Edge.Right: + return element.scrollLeft + element.clientWidth === element.scrollWidth; + case Edge.Top: + return element.scrollTop === 0; + case Edge.Bottom: + return element.scrollTop + element.clientHeight === element.scrollHeight; + default: + return true; + } +}; + +export const scrollBy = (element: Element, edge: Edge, offset: number) => { + let step = offset; + let prop = edge; + + if (edge === Edge.Left || edge === Edge.Top) { + step = -offset; + } else if (edge === Edge.Right) { + prop = Edge.Left; + } else if (edge === Edge.Bottom) { + prop = Edge.Top; + } + + element.scrollBy({ [prop]: step }); +}; + +export const scrollElement = (element: Element, edge: Edge, offset: number) => { + if (isReachEdge(element, edge)) { + return; + } + + scrollBy(element, edge, offset); +}; + +export const calculateLeaveEdge = ( + { x: mouseX, y: mouseY }: { x: number; y: number }, + rect: DOMRect, + gaps: EdgeGap, + direction: ScrollDirection +) => { + if (direction === ScrollDirection.Horizontal) { + if (mouseX - rect.left < gaps.left) { + return Edge.Left; + } + + if (rect.right - mouseX < gaps.right) { + return Edge.Right; + } + } + + if (direction === ScrollDirection.Vertical) { + if (mouseY - rect.top < gaps.top) { + return Edge.Top; + } + + if (rect.bottom - mouseY < gaps.bottom) { + return Edge.Bottom; + } + } + + return null; +}; + +export const getScrollParent = (element: HTMLElement | null, direction: ScrollDirection): HTMLElement | null => { + if (element === null) { + return null; + } + + if (direction === ScrollDirection.Horizontal && element.scrollWidth > element.clientWidth) { + return element; + } + + if (direction === ScrollDirection.Vertical && element.scrollHeight > element.clientHeight) { + return element; + } + + return getScrollParent(element.parentElement, direction); +}; + +export interface AutoScrollOnEdgeOptions { + element: HTMLElement; + direction: ScrollDirection; + edgeGap?: number | Partial; + step?: number; +} + +const defaultEdgeGap = 30; + +export const autoScrollOnEdge = ({ element, direction, edgeGap, step = 8 }: AutoScrollOnEdgeOptions) => { + const gaps = + typeof edgeGap === 'number' + ? { + top: edgeGap, + bottom: edgeGap, + left: edgeGap, + right: edgeGap, + } + : { + top: defaultEdgeGap, + bottom: defaultEdgeGap, + left: defaultEdgeGap, + right: defaultEdgeGap, + ...edgeGap, + }; + + const keepScroll = interval(scrollElement, 8); + + let leaveEdge: Edge | null = null; + + const onDragOver = (event: DragEvent) => { + const rect = element.getBoundingClientRect(); + + leaveEdge = calculateLeaveEdge({ x: event.clientX, y: event.clientY }, rect, gaps, direction); + + if (leaveEdge) { + keepScroll(element, leaveEdge, step); + } else { + keepScroll.cancel(); + } + }; + + const onDragLeave = () => { + if (!leaveEdge) { + return; + } + + keepScroll(element, leaveEdge, step * 2); + }; + + const cleanup = () => { + keepScroll.cancel(); + + element.removeEventListener('dragover', onDragOver); + element.removeEventListener('dragleave', onDragLeave); + + document.removeEventListener('dragend', cleanup); + }; + + element.addEventListener('dragover', onDragOver); + element.addEventListener('dragleave', onDragLeave); + + document.addEventListener('dragend', cleanup); + + return cleanup; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/index.ts new file mode 100644 index 0000000000000..6bfa1f812b919 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/index.ts @@ -0,0 +1,5 @@ +export * from './constants'; + +export * from './dnd'; + +export * from './CellText'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/board/Board.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/board/Board.tsx new file mode 100644 index 0000000000000..790f841701ae8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/board/Board.tsx @@ -0,0 +1,5 @@ +import { FC } from 'react'; + +export const Board: FC = () => { + return null; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/board/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/board/index.ts new file mode 100644 index 0000000000000..9294d869ce7c6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/board/index.ts @@ -0,0 +1 @@ +export * from './Board'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/calendar/Calendar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/calendar/Calendar.tsx new file mode 100644 index 0000000000000..b8473fda255c7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/calendar/Calendar.tsx @@ -0,0 +1,5 @@ +import { FC } from 'react'; + +export const Calendar: FC = () => { + return null; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/calendar/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/calendar/index.ts new file mode 100644 index 0000000000000..a7233805922bd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/calendar/index.ts @@ -0,0 +1 @@ +export * from './Calendar'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/Cell.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/Cell.hooks.ts new file mode 100644 index 0000000000000..76bba7b1524ad --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/Cell.hooks.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useState } from 'react'; +import { DatabaseNotification } from '@/services/backend'; +import { useNotification, useViewId } from '$app/hooks'; +import { cellService, Cell, Field } from '$app/application/database'; +import { useDispatchCell, useSelectorCell } from '$app/components/database'; + +export const useCell = (rowId: string, field: Field) => { + const viewId = useViewId(); + const { setCell } = useDispatchCell(); + const [loading, setLoading] = useState(false); + const cell = useSelectorCell(rowId, field.id); + + const fetchCell = useCallback(() => { + setLoading(true); + void cellService.getCell(viewId, rowId, field.id, field.type).then((data) => { + // cache cell + setCell(data); + setLoading(false); + }); + }, [viewId, rowId, field.id, field.type, setCell]); + + useEffect(() => { + // fetch cell if not cached + if (!cell && !loading) { + // fetch cell in next tick to avoid blocking + const timeout = setTimeout(fetchCell, 0); + + return () => { + clearTimeout(timeout); + }; + } + }, [fetchCell, cell, loading, rowId, field.id]); + + useNotification(DatabaseNotification.DidUpdateCell, fetchCell, { id: `${rowId}:${field.id}` }); + + return cell; +}; + +export const useInputCell = (cell?: Cell) => { + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(''); + const viewId = useViewId(); + const updateCell = useCallback(() => { + if (!cell) return; + const { rowId, fieldId } = cell; + + if (editing) { + if (value !== cell.data) { + void cellService.updateCell(viewId, rowId, fieldId, value); + } + + setEditing(false); + } + }, [cell, editing, value, viewId]); + + return { + updateCell, + editing, + setEditing, + value, + setValue, + }; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/Cell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/Cell.tsx new file mode 100644 index 0000000000000..a092a1d75a147 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/Cell.tsx @@ -0,0 +1,65 @@ +import React, { FC, HTMLAttributes } from 'react'; +import { FieldType } from '@/services/backend'; + +import { Cell as CellType, Field } from '$app/application/database'; +import { useCell } from './Cell.hooks'; +import { TextCell } from './TextCell'; +import { SelectCell } from './SelectCell'; +import { CheckboxCell } from './CheckboxCell'; +import NumberCell from '$app/components/database/components/cell/NumberCell'; +import URLCell from '$app/components/database/components/cell/URLCell'; +import ChecklistCell from '$app/components/database/components/cell/ChecklistCell'; +import DateTimeCell from '$app/components/database/components/cell/DateTimeCell'; +import TimestampCell from '$app/components/database/components/cell/TimestampCell'; + +export interface CellProps extends HTMLAttributes { + rowId: string; + field: Field; + icon?: string; + placeholder?: string; +} + +export interface CellComponentProps extends CellProps { + cell: CellType; +} + +const getCellComponent = (fieldType: FieldType) => { + switch (fieldType) { + case FieldType.RichText: + return TextCell as FC; + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return SelectCell as FC; + case FieldType.Checkbox: + return CheckboxCell as FC; + case FieldType.Checklist: + return ChecklistCell as FC; + case FieldType.Number: + return NumberCell as FC; + case FieldType.URL: + return URLCell as FC; + case FieldType.DateTime: + return DateTimeCell as FC; + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + return TimestampCell as FC; + default: + return null; + } +}; + +export const Cell: FC = ({ rowId, field, ...props }) => { + const cell = useCell(rowId, field); + + const Component = getCellComponent(field.type); + + if (!cell) { + return
; + } + + if (!Component) { + return null; + } + + return ; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/CheckboxCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/CheckboxCell.tsx new file mode 100644 index 0000000000000..f6f3dcf0d20bf --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/CheckboxCell.tsx @@ -0,0 +1,26 @@ +import React, { FC, useCallback } from 'react'; +import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg'; +import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg'; +import { useViewId } from '$app/hooks'; +import { cellService, CheckboxCell as CheckboxCellType, Field } from '$app/application/database'; + +export const CheckboxCell: FC<{ + field: Field; + cell: CheckboxCellType; +}> = ({ field, cell }) => { + const viewId = useViewId(); + const checked = cell.data; + + const handleClick = useCallback(() => { + void cellService.updateCell(viewId, cell.rowId, field.id, !checked ? 'Yes' : 'No'); + }, [viewId, cell, field.id, checked]); + + return ( +
+ {checked ? : } +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/ChecklistCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/ChecklistCell.tsx new file mode 100644 index 0000000000000..5ecac431c4804 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/ChecklistCell.tsx @@ -0,0 +1,74 @@ +import React, { useState, Suspense, useMemo } from 'react'; +import { ChecklistCell as ChecklistCellType, ChecklistField } from '$app/application/database'; +import ChecklistCellActions from '$app/components/database/components/field_types/checklist/ChecklistCellActions'; +import LinearProgressWithLabel from '$app/components/database/_shared/LinearProgressWithLabel'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; + +interface Props { + field: ChecklistField; + cell: ChecklistCellType; + placeholder?: string; +} + +const initialAnchorOrigin: PopoverOrigin = { + vertical: 'bottom', + horizontal: 'left', +}; + +const initialTransformOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'left', +}; + +function ChecklistCell({ cell, placeholder }: Props) { + const value = cell?.data.percentage ?? 0; + const options = useMemo(() => cell?.data.options ?? [], [cell?.data.options]); + const selectedOptions = useMemo(() => cell?.data.selectedOptions ?? [], [cell?.data.selectedOptions]); + const [anchorEl, setAnchorEl] = useState(undefined); + const open = Boolean(anchorEl); + const handleClick = (e: React.MouseEvent) => { + setAnchorEl(e.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(undefined); + }; + + const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ + initialPaperWidth: 369, + initialPaperHeight: 300, + anchorEl, + initialAnchorOrigin, + initialTransformOrigin, + open, + }); + + return ( + <> +
+ {options.length > 0 ? ( + + ) : ( +
{placeholder}
+ )} +
+ + {open && ( + + )} + + + ); +} + +export default ChecklistCell; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/DateTimeCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/DateTimeCell.tsx new file mode 100644 index 0000000000000..aea4f7984903b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/DateTimeCell.tsx @@ -0,0 +1,125 @@ +import React, { Suspense, useRef, useState, useMemo, useEffect } from 'react'; +import { DateTimeCell as DateTimeCellType, DateTimeField } from '$app/application/database'; +import DateTimeCellActions from '$app/components/database/components/field_types/date/DateTimeCellActions'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; + +interface Props { + field: DateTimeField; + cell: DateTimeCellType; + placeholder?: string; +} + +const initialAnchorOrigin: PopoverOrigin = { + vertical: 'bottom', + horizontal: 'left', +}; + +const initialTransformOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'left', +}; + +function DateTimeCell({ field, cell, placeholder }: Props) { + const isRange = cell.data.isRange; + const includeTime = cell.data.includeTime; + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const handleClose = () => { + setOpen(false); + }; + + const handleClick = () => { + setOpen(true); + }; + + const content = useMemo(() => { + const { date, time, endDate, endTime } = cell.data; + + if (date) { + return ( + <> + {date} + {includeTime && time ? ' ' + time : ''} + {isRange && endDate ? ' - ' + endDate : ''} + {isRange && includeTime && endTime ? ' ' + endTime : ''} + + ); + } + + return
{placeholder}
; + }, [cell, includeTime, isRange, placeholder]); + + const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered, calculateAnchorSize } = + usePopoverAutoPosition({ + initialPaperWidth: 248, + initialPaperHeight: 500, + anchorEl: ref.current, + initialAnchorOrigin, + initialTransformOrigin, + open, + marginThreshold: 34, + }); + + useEffect(() => { + if (!open) return; + + const anchorEl = ref.current; + + const parent = anchorEl?.parentElement?.parentElement; + + if (!anchorEl || !parent) return; + + let timeout: NodeJS.Timeout; + const handleObserve = () => { + anchorEl.scrollIntoView({ block: 'nearest' }); + + timeout = setTimeout(() => { + calculateAnchorSize(); + }, 200); + }; + + const observer = new MutationObserver(handleObserve); + + observer.observe(parent, { + childList: true, + subtree: true, + }); + return () => { + observer.disconnect(); + clearTimeout(timeout); + }; + }, [calculateAnchorSize, open]); + + return ( + <> +
+ {content} +
+ + {open && ( + + )} + + + ); +} + +export default DateTimeCell; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/NumberCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/NumberCell.tsx new file mode 100644 index 0000000000000..727e78de3f47c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/NumberCell.tsx @@ -0,0 +1,50 @@ +import React, { Suspense, useCallback, useMemo, useRef } from 'react'; +import { Field, NumberCell as NumberCellType } from '$app/application/database'; +import { CellText } from '$app/components/database/_shared'; +import EditNumberCellInput from '$app/components/database/components/field_types/number/EditNumberCellInput'; +import { useInputCell } from '$app/components/database/components/cell/Cell.hooks'; + +interface Props { + field: Field; + cell: NumberCellType; + placeholder?: string; +} + +function NumberCell({ field, cell, placeholder }: Props) { + const cellRef = useRef(null); + const { value, editing, updateCell, setEditing, setValue } = useInputCell(cell); + const content = useMemo(() => { + if (typeof cell.data === 'string' && cell.data) { + return cell.data; + } + + return
{placeholder}
; + }, [cell, placeholder]); + + const handleClick = useCallback(() => { + setValue(cell.data); + setEditing(true); + }, [cell, setEditing, setValue]); + + return ( + <> + +
{content}
+
+ + {editing && ( + + )} + + + ); +} + +export default NumberCell; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/SelectCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/SelectCell.tsx new file mode 100644 index 0000000000000..a951ddd9e4b6c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/SelectCell.tsx @@ -0,0 +1,108 @@ +import { FC, useCallback, useMemo, useState, Suspense, lazy } from 'react'; +import { MenuProps } from '@mui/material'; +import { SelectField, SelectCell as SelectCellType, SelectTypeOption } from '$app/application/database'; +import { Tag } from '../field_types/select/Tag'; +import { useTypeOption } from '$app/components/database'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import Popover from '@mui/material/Popover'; + +const initialAnchorOrigin: PopoverOrigin = { + vertical: 'bottom', + horizontal: 'center', +}; + +const initialTransformOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'center', +}; +const SelectCellActions = lazy( + () => import('$app/components/database/components/field_types/select/select_cell_actions/SelectCellActions') +); +const menuProps: Partial = { + classes: { + list: 'py-5', + }, +}; + +export const SelectCell: FC<{ + field: SelectField; + cell: SelectCellType; + placeholder?: string; +}> = ({ field, cell, placeholder }) => { + const [anchorEl, setAnchorEl] = useState(null); + const selectedIds = useMemo(() => cell.data?.selectedOptionIds ?? [], [cell]); + const open = Boolean(anchorEl); + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + const typeOption = useTypeOption(field.id); + + const renderSelectedOptions = useCallback( + (selected: string[]) => + selected + .map((id) => typeOption.options?.find((option) => option.id === id)) + .map((option) => option && ), + [typeOption] + ); + + const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ + initialPaperWidth: 369, + initialPaperHeight: 400, + anchorEl, + initialAnchorOrigin, + initialTransformOrigin, + open, + }); + + return ( +
+
{ + setAnchorEl(e.currentTarget); + }} + className={'flex h-full w-full cursor-pointer items-center gap-2 overflow-x-hidden px-2 py-1'} + > + {selectedIds.length === 0 ? ( +
{placeholder}
+ ) : ( + renderSelectedOptions(selectedIds) + )} +
+ + {open ? ( + { + const isInput = (e.target as Element).closest('input'); + + if (isInput) return; + + e.preventDefault(); + e.stopPropagation(); + }} + > + + + ) : null} + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/TextCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/TextCell.tsx new file mode 100644 index 0000000000000..38927d744b2c3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/TextCell.tsx @@ -0,0 +1,59 @@ +import { FC, FormEventHandler, Suspense, lazy, useCallback, useRef, useMemo } from 'react'; +import { TextCell as TextCellType } from '$app/application/database'; +import { CellText } from '../../_shared'; +import { useInputCell } from '$app/components/database/components/cell/Cell.hooks'; + +const EditTextCellInput = lazy(() => import('$app/components/database/components/field_types/text/EditTextCellInput')); + +interface TextCellProps { + cell: TextCellType; + placeholder?: string; +} +export const TextCell: FC = ({ placeholder, cell }) => { + const cellRef = useRef(null); + const { value, editing, updateCell, setEditing, setValue } = useInputCell(cell); + const handleClose = () => { + if (!cell) return; + updateCell(); + }; + + const handleClick = useCallback(() => { + if (!cell) return; + setValue(cell.data); + setEditing(true); + }, [cell, setEditing, setValue]); + + const handleInput = useCallback>( + (event) => { + setValue((event.target as HTMLTextAreaElement).value); + }, + [setValue] + ); + + const content = useMemo(() => { + if (cell && typeof cell.data === 'string' && cell.data) { + return cell.data; + } + + return
{placeholder}
; + }, [cell, placeholder]); + + return ( + <> + + {content} + + + {editing && ( + + )} + + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/TimestampCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/TimestampCell.tsx new file mode 100644 index 0000000000000..5889a13915b97 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/TimestampCell.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { CreatedTimeField, LastEditedTimeField, TimeStampCell } from '$app/application/database'; + +interface Props { + field: LastEditedTimeField | CreatedTimeField; + cell: TimeStampCell; +} + +function TimestampCell({ cell }: Props) { + return
{cell.data.dataTime}
; +} + +export default TimestampCell; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/URLCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/URLCell.tsx new file mode 100644 index 0000000000000..592850ada1dab --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/URLCell.tsx @@ -0,0 +1,81 @@ +import React, { FormEventHandler, lazy, Suspense, useCallback, useMemo, useRef } from 'react'; +import { useInputCell } from '$app/components/database/components/cell/Cell.hooks'; +import { Field, UrlCell as URLCellType } from '$app/application/database'; +import { CellText } from '$app/components/database/_shared'; +import { openUrl } from '$app/utils/open_url'; + +const EditTextCellInput = lazy(() => import('$app/components/database/components/field_types/text/EditTextCellInput')); + +interface Props { + field: Field; + cell: URLCellType; + placeholder?: string; +} + +function UrlCell({ field, cell, placeholder }: Props) { + const cellRef = useRef(null); + const { value, editing, updateCell, setEditing, setValue } = useInputCell(cell); + const handleClick = useCallback(() => { + setValue(cell.data); + setEditing(true); + }, [cell, setEditing, setValue]); + + const handleClose = () => { + updateCell(); + }; + + const handleInput = useCallback>( + (event) => { + setValue((event.target as HTMLTextAreaElement).value); + }, + [setValue] + ); + + const content = useMemo(() => { + if (cell.data) { + return ( + { + e.stopPropagation(); + openUrl(cell.data); + }} + target={'_blank'} + className={'cursor-pointer text-content-blue-400 underline'} + > + {cell.data} + + ); + } + + return
{placeholder}
; + }, [cell, placeholder]); + + return ( + <> + + {content} + + + {editing && ( + + )} + + + ); +} + +export default UrlCell; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/index.ts new file mode 100644 index 0000000000000..24409763405c2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/index.ts @@ -0,0 +1 @@ +export * from './Cell'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx new file mode 100644 index 0000000000000..0fe9fb6d5b3b3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx @@ -0,0 +1,18 @@ +import { Sorts } from '../sort'; +import Filters from '../filter/Filters'; +import React from 'react'; + +interface Props { + open: boolean; +} + +export const DatabaseCollection = ({ open }: Props) => { + return ( +
+
+ + +
+
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx new file mode 100644 index 0000000000000..ea1378eab88d5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; +import { TextButton } from '$app/components/database/components/tab_bar/TextButton'; +import { useTranslation } from 'react-i18next'; + +import SortSettings from '$app/components/database/components/database_settings/SortSettings'; +import SettingsMenu from '$app/components/database/components/database_settings/SettingsMenu'; +import FilterSettings from '$app/components/database/components/database_settings/FilterSettings'; + +interface Props { + onToggleCollection: (forceOpen?: boolean) => void; +} + +function DatabaseSettings(props: Props) { + const { t } = useTranslation(); + const [settingAnchorEl, setSettingAnchorEl] = useState(null); + + return ( +
+ + + setSettingAnchorEl(e.currentTarget)}> + {t('settings.title')} + + setSettingAnchorEl(null)} + /> +
+ ); +} + +export default DatabaseSettings; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/FilterSettings.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/FilterSettings.tsx new file mode 100644 index 0000000000000..d0b89208d01b2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/FilterSettings.tsx @@ -0,0 +1,46 @@ +import React, { useState } from 'react'; +import { TextButton } from '$app/components/database/components/tab_bar/TextButton'; +import { useTranslation } from 'react-i18next'; +import { useFiltersCount } from '$app/components/database'; +import FilterFieldsMenu from '$app/components/database/components/filter/FilterFieldsMenu'; + +function FilterSettings({ onToggleCollection }: { onToggleCollection: (forceOpen?: boolean) => void }) { + const { t } = useTranslation(); + const filtersCount = useFiltersCount(); + const highlight = filtersCount > 0; + const [filterAnchorEl, setFilterAnchorEl] = useState(null); + const open = Boolean(filterAnchorEl); + + const handleClick = (e: React.MouseEvent) => { + if (highlight) { + onToggleCollection(); + return; + } + + setFilterAnchorEl(e.currentTarget); + }; + + return ( + <> + + {t('grid.settings.filter')} + + onToggleCollection(true)} + open={open} + anchorEl={filterAnchorEl} + onClose={() => setFilterAnchorEl(null)} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + /> + + ); +} + +export default FilterSettings; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx new file mode 100644 index 0000000000000..af2bbce21827a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react'; +import { useDatabase } from '$app/components/database'; +import { Field as FieldType, fieldService } from '$app/application/database'; +import { Property } from '$app/components/database/components/property'; +import { FieldVisibility } from '@/services/backend'; +import { ReactComponent as EyeOpen } from '$app/assets/eye_open.svg'; +import { ReactComponent as EyeClosed } from '$app/assets/eye_close.svg'; +import { IconButton, MenuItem } from '@mui/material'; +import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'; +import { useViewId } from '$app/hooks'; +import { ReactComponent as DragSvg } from '$app/assets/drag.svg'; + +interface PropertiesProps { + onItemClick: (field: FieldType) => void; +} +function Properties({ onItemClick }: PropertiesProps) { + const { fields } = useDatabase(); + const [state, setState] = useState(fields as FieldType[]); + const viewId = useViewId(); + const [menuPropertyId, setMenuPropertyId] = useState(); + + useEffect(() => { + setState(fields as FieldType[]); + }, [fields]); + + const handleOnDragEnd = async (result: DropResult) => { + const { destination, draggableId, source } = result; + const oldIndex = source.index; + const newIndex = destination?.index; + + if (oldIndex === newIndex) { + return; + } + + if (newIndex === undefined || newIndex === null) { + return; + } + + const newId = fields[newIndex ?? 0].id; + + const newProperties = fieldService.reorderFields(fields as FieldType[], oldIndex, newIndex ?? 0); + + setState(newProperties); + + await fieldService.moveField(viewId, draggableId, newId ?? ''); + }; + + return ( + + + {(dropProvided) => ( +
+ {state.map((field, index) => ( + + {(provided) => { + return ( + { + setMenuPropertyId(field.id); + }} + key={field.id} + > + + + +
+ { + setMenuPropertyId(undefined); + }} + menuOpened={menuPropertyId === field.id} + field={field} + /> +
+ + { + e.stopPropagation(); + onItemClick(field); + }} + className={'ml-2'} + > + {field.visibility !== FieldVisibility.AlwaysHidden ? : } + +
+ ); + }} +
+ ))} + {dropProvided.placeholder} +
+ )} +
+
+ ); +} + +export default Properties; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx new file mode 100644 index 0000000000000..c6a9d244f0df8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx @@ -0,0 +1,98 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Menu, MenuProps, Popover } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import Properties from '$app/components/database/components/database_settings/Properties'; +import { Field } from '$app/application/database'; +import { FieldVisibility } from '@/services/backend'; +import { updateFieldSetting } from '$app/application/database/field/field_service'; +import { useViewId } from '$app/hooks'; +import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; + +type SettingsMenuProps = MenuProps; + +function SettingsMenu(props: SettingsMenuProps) { + const viewId = useViewId(); + const ref = useRef(null); + const { t } = useTranslation(); + const [propertiesAnchorElPosition, setPropertiesAnchorElPosition] = useState< + | undefined + | { + top: number; + left: number; + } + >(undefined); + + const openProperties = Boolean(propertiesAnchorElPosition); + + const togglePropertyVisibility = async (field: Field) => { + let visibility = field.visibility; + + if (visibility === FieldVisibility.AlwaysHidden) { + visibility = FieldVisibility.AlwaysShown; + } else { + visibility = FieldVisibility.AlwaysHidden; + } + + await updateFieldSetting(viewId, field.id, { + visibility, + }); + }; + + const options = useMemo(() => { + return [{ key: 'properties', content:
{t('grid.settings.properties')}
}]; + }, [t]); + + const onConfirm = useCallback( + (optionKey: string) => { + if (optionKey === 'properties') { + const target = ref.current?.querySelector(`[data-key=${optionKey}]`) as HTMLElement; + const rect = target.getBoundingClientRect(); + + setPropertiesAnchorElPosition({ + top: rect.top, + left: rect.left + rect.width, + }); + props.onClose?.({}, 'backdropClick'); + } + }, + [props] + ); + + return ( + <> + + { + props.onClose?.({}, 'escapeKeyDown'); + }} + options={options} + /> + + { + setPropertiesAnchorElPosition(undefined); + }} + anchorReference={'anchorPosition'} + anchorPosition={propertiesAnchorElPosition} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} + > + + + + ); +} + +export default SettingsMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SortSettings.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SortSettings.tsx new file mode 100644 index 0000000000000..7f978120df764 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SortSettings.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDatabase } from '$app/components/database'; +import { TextButton } from '$app/components/database/components/tab_bar/TextButton'; +import SortFieldsMenu from '$app/components/database/components/sort/SortFieldsMenu'; + +interface Props { + onToggleCollection: (forceOpen?: boolean) => void; +} + +function SortSettings({ onToggleCollection }: Props) { + const { t } = useTranslation(); + const { sorts } = useDatabase(); + + const highlight = sorts && sorts.length > 0; + + const [sortAnchorEl, setSortAnchorEl] = React.useState(null); + const open = Boolean(sortAnchorEl); + const handleClick = (event: React.MouseEvent) => { + if (highlight) { + onToggleCollection(); + return; + } + + setSortAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setSortAnchorEl(null); + }; + + return ( + <> + + {t('grid.settings.sort')} + + onToggleCollection(true)} + open={open} + anchorEl={sortAnchorEl} + onClose={handleClose} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + /> + + ); +} + +export default SortSettings; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/index.ts new file mode 100644 index 0000000000000..efb89af437c46 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/index.ts @@ -0,0 +1 @@ +export * from './DatabaseCollection'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/EditRecord.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/EditRecord.tsx new file mode 100644 index 0000000000000..13f29a7dfcaa8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/EditRecord.tsx @@ -0,0 +1,63 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import RecordDocument from '$app/components/database/components/edit_record/RecordDocument'; +import RecordHeader from '$app/components/database/components/edit_record/RecordHeader'; +import { Page } from '$app_reducers/pages/slice'; +import { ErrorCode, ViewLayoutPB } from '@/services/backend'; +import { Log } from '$app/utils/log'; +import { useDatabase } from '$app/components/database'; +import { createOrphanPage, getPage } from '$app/application/folder/page.service'; + +interface Props { + rowId: string; +} + +function EditRecord({ rowId }: Props) { + const { rowMetas } = useDatabase(); + const row = useMemo(() => { + return rowMetas.find((row) => row.id === rowId); + }, [rowMetas, rowId]); + const [page, setPage] = useState(null); + const id = row?.documentId; + + const loadPage = useCallback(async () => { + if (!id) return; + + try { + const page = await getPage(id); + + setPage(page); + } catch (e) { + // Record not found + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (e.code === ErrorCode.RecordNotFound) { + try { + const page = await createOrphanPage({ + view_id: id, + name: '', + layout: ViewLayoutPB.Document, + }); + + setPage(page); + } catch (e) { + Log.error(e); + } + } + } + }, [id]); + + useEffect(() => { + void loadPage(); + }, [loadPage]); + + if (!id || !page) return null; + + return ( + <> + + + + ); +} + +export default EditRecord; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/ExpandRecordModal.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/ExpandRecordModal.tsx new file mode 100644 index 0000000000000..7056cd353deb1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/ExpandRecordModal.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { DialogProps, IconButton, Portal } from '@mui/material'; +import Dialog from '@mui/material/Dialog'; +import { ReactComponent as DetailsIcon } from '$app/assets/details.svg'; +import RecordActions from '$app/components/database/components/edit_record/RecordActions'; +import EditRecord from '$app/components/database/components/edit_record/EditRecord'; +import { AFScroller } from '$app/components/_shared/scroller'; + +interface Props extends DialogProps { + rowId: string; +} + +function ExpandRecordModal({ open, onClose, rowId }: Props) { + const [detailAnchorEl, setDetailAnchorEl] = useState(null); + + return ( + + e.stopPropagation()} + open={open} + onClose={onClose} + PaperProps={{ + className: 'h-[calc(100%-144px)] w-[80%] max-w-[960px] overflow-visible', + }} + > + + + + { + setDetailAnchorEl(e.currentTarget); + }} + > + + + + { + onClose?.({}, 'escapeKeyDown'); + }} + onClose={() => setDetailAnchorEl(null)} + /> + + ); +} + +export default ExpandRecordModal; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordActions.tsx new file mode 100644 index 0000000000000..412c1a953e6b8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordActions.tsx @@ -0,0 +1,63 @@ +import React, { useCallback } from 'react'; +import { Icon, Menu, MenuProps } from '@mui/material'; +import { ReactComponent as DelSvg } from '$app/assets/delete.svg'; +import { ReactComponent as CopySvg } from '$app/assets/copy.svg'; +import { useTranslation } from 'react-i18next'; +import { rowService } from '$app/application/database'; +import { useViewId } from '$app/hooks'; +import MenuItem from '@mui/material/MenuItem'; + +interface Props extends MenuProps { + rowId: string; + onEscape?: () => void; + onClose?: () => void; +} +function RecordActions({ anchorEl, open, onEscape, onClose, rowId }: Props) { + const viewId = useViewId(); + const { t } = useTranslation(); + + const handleDelRow = useCallback(() => { + void rowService.deleteRow(viewId, rowId); + onEscape?.(); + }, [viewId, rowId, onEscape]); + + const handleDuplicateRow = useCallback(() => { + void rowService.duplicateRow(viewId, rowId); + onEscape?.(); + }, [viewId, rowId, onEscape]); + + const menuOptions = [ + { + label: t('grid.row.duplicate'), + icon: , + onClick: handleDuplicateRow, + }, + + { + label: t('grid.row.delete'), + icon: , + onClick: handleDelRow, + divider: true, + }, + ]; + + return ( + + {menuOptions.map((option) => ( + { + option.onClick(); + onClose?.(); + onEscape?.(); + }} + > + {option.icon} + {option.label} + + ))} + + ); +} + +export default RecordActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx new file mode 100644 index 0000000000000..653f3c5944305 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Editor from '$app/components/editor/Editor'; + +interface Props { + documentId: string; +} + +function RecordDocument({ documentId }: Props) { + return ; +} + +export default RecordDocument; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx new file mode 100644 index 0000000000000..d2381ec165dae --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx @@ -0,0 +1,39 @@ +import React, { useEffect, useRef } from 'react'; +import RecordTitle from '$app/components/database/components/edit_record/RecordTitle'; +import RecordProperties from '$app/components/database/components/edit_record/record_properties/RecordProperties'; +import { Divider } from '@mui/material'; +import { RowMeta } from '$app/application/database'; +import { Page } from '$app_reducers/pages/slice'; + +interface Props { + page: Page | null; + row: RowMeta; +} +function RecordHeader({ page, row }: Props) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + + if (!el) return; + + const preventSelectionTrigger = (e: MouseEvent) => { + e.stopPropagation(); + }; + + el.addEventListener('mousedown', preventSelectionTrigger); + return () => { + el.removeEventListener('mousedown', preventSelectionTrigger); + }; + }, []); + + return ( +
+ + + +
+ ); +} + +export default RecordHeader; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordTitle.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordTitle.tsx new file mode 100644 index 0000000000000..c2f195aee2410 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordTitle.tsx @@ -0,0 +1,71 @@ +import React, { useCallback, useMemo } from 'react'; +import { Page, PageIcon } from '$app_reducers/pages/slice'; +import ViewTitle from '$app/components/_shared/view_title/ViewTitle'; +import { ViewIconTypePB } from '@/services/backend'; +import { useViewId } from '$app/hooks'; +import { updateRowMeta } from '$app/application/database/row/row_service'; +import { cellService, Field, RowMeta, TextCell } from '$app/application/database'; +import { useDatabase } from '$app/components/database'; +import { useCell } from '$app/components/database/components/cell/Cell.hooks'; + +interface Props { + page: Page | null; + row: RowMeta; +} + +function RecordTitle({ row, page }: Props) { + const { fields } = useDatabase(); + const field = useMemo(() => { + return fields.find((field) => field.isPrimary) as Field; + }, [fields]); + const rowId = row.id; + const cell = useCell(rowId, field) as TextCell; + const title = cell.data; + + const viewId = useViewId(); + + const onTitleChange = useCallback( + async (title: string) => { + try { + await cellService.updateCell(viewId, rowId, field.id, title); + } catch (e) { + // toast.error('Failed to update title'); + } + }, + [field.id, rowId, viewId] + ); + + const onUpdateIcon = useCallback( + async (icon: PageIcon) => { + try { + await updateRowMeta(viewId, rowId, { iconUrl: icon.value }); + } catch (e) { + // toast.error('Failed to update icon'); + } + }, + [rowId, viewId] + ); + + return ( +
+ {page && ( + + )} +
+ ); +} + +export default React.memo(RecordTitle); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/Property.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/Property.tsx new file mode 100644 index 0000000000000..279ee13f6838b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/Property.tsx @@ -0,0 +1,55 @@ +import React, { HTMLAttributes } from 'react'; +import PropertyName from '$app/components/database/components/edit_record/record_properties/PropertyName'; +import PropertyValue from '$app/components/database/components/edit_record/record_properties/PropertyValue'; +import { Field } from '$app/application/database'; +import { IconButton, Tooltip } from '@mui/material'; +import { ReactComponent as DragSvg } from '$app/assets/drag.svg'; +import { useTranslation } from 'react-i18next'; + +interface Props extends HTMLAttributes { + field: Field; + rowId: string; + ishovered: boolean; + onHover: (id: string | null) => void; + menuOpened?: boolean; + onOpenMenu?: () => void; + onCloseMenu?: () => void; +} + +function Property( + { field, rowId, ishovered, onHover, menuOpened, onCloseMenu, onOpenMenu, ...props }: Props, + ref: React.ForwardedRef +) { + const { t } = useTranslation(); + + return ( + <> +
{ + onHover(field.id); + }} + onMouseLeave={() => { + onHover(null); + }} + className={'relative flex items-start gap-6 rounded hover:bg-content-blue-50'} + key={field.id} + {...props} + > + + + {ishovered && ( +
+ + + + + +
+ )} +
+ + ); +} + +export default React.forwardRef(Property); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyList.tsx new file mode 100644 index 0000000000000..138c7543fd771 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyList.tsx @@ -0,0 +1,55 @@ +import React, { HTMLAttributes, useState } from 'react'; +import { Field } from '$app/application/database'; +import Property from '$app/components/database/components/edit_record/record_properties/Property'; +import { Draggable } from 'react-beautiful-dnd'; + +interface Props extends HTMLAttributes { + properties: Field[]; + rowId: string; + placeholderNode?: React.ReactNode; + openMenuPropertyId?: string; + setOpenMenuPropertyId?: (id?: string) => void; +} + +function PropertyList( + { properties, rowId, placeholderNode, openMenuPropertyId, setOpenMenuPropertyId, ...props }: Props, + ref: React.ForwardedRef +) { + const [hoverId, setHoverId] = useState(null); + + return ( +
+ {properties.map((field, index) => { + return ( + + {(provided) => { + return ( + { + setOpenMenuPropertyId?.(field.id); + }} + onCloseMenu={() => { + if (openMenuPropertyId === field.id) { + setOpenMenuPropertyId?.(undefined); + } + }} + /> + ); + }} + + ); + })} + {placeholderNode} +
+ ); +} + +export default React.forwardRef(PropertyList); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyName.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyName.tsx new file mode 100644 index 0000000000000..e7de3f1fb0090 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyName.tsx @@ -0,0 +1,32 @@ +import React, { useRef } from 'react'; +import { Property } from '$app/components/database/components/property'; +import { Field as FieldType } from '$app/application/database'; + +interface Props { + field: FieldType; + menuOpened?: boolean; + onOpenMenu?: () => void; + onCloseMenu?: () => void; +} +function PropertyName({ field, menuOpened = false, onOpenMenu, onCloseMenu }: Props) { + const ref = useRef(null); + + return ( + <> +
{ + e.stopPropagation(); + e.preventDefault(); + onOpenMenu?.(); + }} + className={'flex min-h-[36px] w-[200px] cursor-pointer items-center'} + onClick={onOpenMenu} + > + +
+ + ); +} + +export default PropertyName; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyValue.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyValue.tsx new file mode 100644 index 0000000000000..4bb33e7f05373 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyValue.tsx @@ -0,0 +1,33 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Cell } from '$app/components/database/components'; +import { Field } from '$app/application/database'; +import { useTranslation } from 'react-i18next'; + +function PropertyValue(props: { rowId: string; field: Field }) { + const { t } = useTranslation(); + const ref = useRef(null); + const [width, setWidth] = useState(props.field.width); + + useEffect(() => { + const el = ref.current; + + if (!el) return; + const width = el.getBoundingClientRect().width; + + setWidth(width); + }, []); + return ( +
+ +
+ ); +} + +export default React.memo(PropertyValue); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/RecordProperties.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/RecordProperties.tsx new file mode 100644 index 0000000000000..16fc1226154e5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/RecordProperties.tsx @@ -0,0 +1,97 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Field, fieldService, RowMeta } from '$app/application/database'; +import { useDatabase } from '$app/components/database'; +import { FieldVisibility } from '@/services/backend'; + +import PropertyList from '$app/components/database/components/edit_record/record_properties/PropertyList'; +import NewProperty from '$app/components/database/components/property/NewProperty'; +import { useViewId } from '$app/hooks'; +import { DragDropContext, Droppable, DropResult, OnDragEndResponder } from 'react-beautiful-dnd'; +import SwitchPropertiesVisible from '$app/components/database/components/edit_record/record_properties/SwitchPropertiesVisible'; + +interface Props { + row: RowMeta; +} + +function RecordProperties({ row }: Props) { + const viewId = useViewId(); + const { fields } = useDatabase(); + const fieldId = useMemo(() => { + return fields.find((field) => field.isPrimary)?.id; + }, [fields]); + const rowId = row.id; + const [openMenuPropertyId, setOpenMenuPropertyId] = useState(undefined); + const [showHiddenFields, setShowHiddenFields] = useState(false); + + const properties = useMemo(() => { + return fields.filter((field) => { + // exclude the current field, because it's already displayed in the title + // filter out hidden fields if the user doesn't want to see them + return field.id !== fieldId && (showHiddenFields || field.visibility !== FieldVisibility.AlwaysHidden); + }); + }, [fieldId, fields, showHiddenFields]); + + const hiddenFieldsCount = useMemo(() => { + return fields.filter((field) => { + return field.visibility === FieldVisibility.AlwaysHidden; + }).length; + }, [fields]); + + const [state, setState] = useState(properties); + + useEffect(() => { + setState(properties); + }, [properties]); + + // move the field in the state + const handleOnDragEnd: OnDragEndResponder = useCallback( + async (result: DropResult) => { + const { destination, draggableId, source } = result; + const newIndex = destination?.index; + const oldIndex = source.index; + + if (newIndex === undefined || newIndex === null) { + return; + } + + const newId = properties[newIndex ?? 0].id; + + // reorder the properties synchronously to avoid flickering + const newProperties = fieldService.reorderFields(properties, oldIndex, newIndex ?? 0); + + setState(newProperties); + + await fieldService.moveField(viewId, draggableId, newId); + }, + [properties, viewId] + ); + + return ( +
+ + + {(dropProvided) => ( + + )} + + + + + +
+ ); +} + +export default RecordProperties; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/SwitchPropertiesVisible.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/SwitchPropertiesVisible.tsx new file mode 100644 index 0000000000000..f8937bbf211cd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/SwitchPropertiesVisible.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as EyeClosedSvg } from '$app/assets/eye_close.svg'; +import { ReactComponent as EyeOpenSvg } from '$app/assets/eye_open.svg'; + +function SwitchPropertiesVisible({ + hiddenFieldsCount, + showHiddenFields, + setShowHiddenFields, +}: { + hiddenFieldsCount: number; + showHiddenFields: boolean; + setShowHiddenFields: (showHiddenFields: boolean) => void; +}) { + const { t } = useTranslation(); + + return hiddenFieldsCount > 0 ? ( + + ) : null; +} + +export default SwitchPropertiesVisible; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/AddNewOption.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/AddNewOption.tsx new file mode 100644 index 0000000000000..027936d280249 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/AddNewOption.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { updateChecklistCell } from '$app/application/database/cell/cell_service'; +import { useViewId } from '$app/hooks'; +import { Button } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +function AddNewOption({ + rowId, + fieldId, + onClose, + onFocus, +}: { + rowId: string; + fieldId: string; + onClose: () => void; + onFocus: () => void; +}) { + const { t } = useTranslation(); + const [value, setValue] = useState(''); + const viewId = useViewId(); + const createOption = async () => { + await updateChecklistCell(viewId, rowId, fieldId, { + insertOptions: [value], + }); + setValue(''); + }; + + return ( +
+ { + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + void createOption(); + return; + } + + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + onClose(); + return; + } + }} + value={value} + spellCheck={false} + onChange={(e) => { + setValue(e.target.value); + }} + /> + +
+ ); +} + +export default AddNewOption; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistCellActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistCellActions.tsx new file mode 100644 index 0000000000000..61583c474611e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistCellActions.tsx @@ -0,0 +1,76 @@ +import React, { useState } from 'react'; +import Popover, { PopoverProps } from '@mui/material/Popover'; +import { Divider } from '@mui/material'; +import { ChecklistCell as ChecklistCellType } from '$app/application/database'; +import ChecklistItem from '$app/components/database/components/field_types/checklist/ChecklistItem'; +import AddNewOption from '$app/components/database/components/field_types/checklist/AddNewOption'; +import LinearProgressWithLabel from '$app/components/database/_shared/LinearProgressWithLabel'; + +function ChecklistCellActions({ + cell, + maxHeight, + maxWidth, + ...props +}: PopoverProps & { + cell: ChecklistCellType; + maxWidth?: number; + maxHeight?: number; +}) { + const { fieldId, rowId } = cell; + const { percentage, selectedOptions = [], options = [] } = cell.data; + + const [focusedId, setFocusedId] = useState(null); + + return ( + +
+ {options.length > 0 && ( + <> +
+ +
+
+ {options?.map((option) => { + return ( + setFocusedId(option.id)} + onClose={() => props.onClose?.({}, 'escapeKeyDown')} + checked={selectedOptions?.includes(option.id) || false} + /> + ); + })} +
+ + + + )} + + { + setFocusedId(null); + }} + onClose={() => props.onClose?.({}, 'escapeKeyDown')} + fieldId={fieldId} + rowId={rowId} + /> +
+
+ ); +} + +export default ChecklistCellActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistItem.tsx new file mode 100644 index 0000000000000..5c6a55fa60c04 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistItem.tsx @@ -0,0 +1,117 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { SelectOption } from '$app/application/database'; +import { IconButton } from '@mui/material'; +import { updateChecklistCell } from '$app/application/database/cell/cell_service'; +import { useViewId } from '$app/hooks'; +import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg'; +import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg'; +import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg'; +import isHotkey from 'is-hotkey'; +import debounce from 'lodash-es/debounce'; +import { useTranslation } from 'react-i18next'; + +const DELAY_CHANGE = 200; + +function ChecklistItem({ + checked, + option, + rowId, + fieldId, + onClose, + isSelected, + onFocus, +}: { + checked: boolean; + option: SelectOption; + rowId: string; + fieldId: string; + onClose: () => void; + isSelected: boolean; + onFocus: () => void; +}) { + const inputRef = React.useRef(null); + const { t } = useTranslation(); + const [value, setValue] = useState(option.name); + const viewId = useViewId(); + const updateText = useCallback(async () => { + await updateChecklistCell(viewId, rowId, fieldId, { + updateOptions: [ + { + ...option, + name: value, + }, + ], + }); + }, [fieldId, option, rowId, value, viewId]); + + const onCheckedChange = useMemo(() => { + return debounce( + () => + updateChecklistCell(viewId, rowId, fieldId, { + selectedOptionIds: [option.id], + }), + DELAY_CHANGE + ); + }, [fieldId, option.id, rowId, viewId]); + + const deleteOption = useCallback(async () => { + await updateChecklistCell(viewId, rowId, fieldId, { + deleteOptionIds: [option.id], + }); + }, [fieldId, option.id, rowId, viewId]); + + return ( +
+
+ {checked ? : } +
+ + { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + void updateText(); + onClose(); + return; + } + + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + void updateText(); + if (isHotkey('mod+enter', e)) { + void onCheckedChange(); + } + + return; + } + }} + spellCheck={false} + onChange={(e) => { + setValue(e.target.value); + }} + /> +
+ + + +
+
+ ); +} + +export default ChecklistItem; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/LinearProgressWithLabel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/LinearProgressWithLabel.tsx new file mode 100644 index 0000000000000..e8b9c95a44dbb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/LinearProgressWithLabel.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import LinearProgress, { LinearProgressProps } from '@mui/material/LinearProgress'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; + +export function LinearProgressWithLabel(props: LinearProgressProps & { value: number }) { + return ( + + + + + + {`${Math.round(props.value * 100)}%`} + + + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/CustomCalendar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/CustomCalendar.tsx new file mode 100644 index 0000000000000..4a36498bd2f06 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/CustomCalendar.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from 'react'; +import DatePicker, { ReactDatePickerCustomHeaderProps } from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { ReactComponent as LeftSvg } from '$app/assets/arrow-left.svg'; +import { ReactComponent as RightSvg } from '$app/assets/arrow-right.svg'; +import { IconButton } from '@mui/material'; +import './calendar.scss'; + +function CustomCalendar({ + handleChange, + isRange, + timestamp, + endTimestamp, +}: { + handleChange: (params: { date?: number; endDate?: number }) => void; + isRange: boolean; + timestamp?: number; + endTimestamp?: number; +}) { + const [startDate, setStartDate] = useState(() => { + if (!timestamp) return null; + return new Date(timestamp * 1000); + }); + const [endDate, setEndDate] = useState(() => { + if (!endTimestamp) return null; + return new Date(endTimestamp * 1000); + }); + + useEffect(() => { + if (!isRange || !endTimestamp) return; + setEndDate(new Date(endTimestamp * 1000)); + }, [isRange, endTimestamp]); + + useEffect(() => { + if (!timestamp) return; + setStartDate(new Date(timestamp * 1000)); + }, [timestamp]); + + return ( +
+ { + return ( +
+
+ {dayjs(props.date).format('MMMM YYYY')} +
+ +
+ + + + + + +
+
+ ); + }} + selected={startDate} + onChange={(dates) => { + if (!dates) return; + if (isRange && Array.isArray(dates)) { + let start = dates[0] as Date; + let end = dates[1] as Date; + + if (!end && start && startDate && endDate) { + const currentTime = start.getTime(); + const startTimeStamp = startDate.getTime(); + const endTimeStamp = endDate.getTime(); + const isGreaterThanStart = currentTime > startTimeStamp; + const isGreaterThanEnd = currentTime > endTimeStamp; + const isLessThanStart = currentTime < startTimeStamp; + const isLessThanEnd = currentTime < endTimeStamp; + const isEqualsStart = currentTime === startTimeStamp; + const isEqualsEnd = currentTime === endTimeStamp; + + if ((isGreaterThanStart && isLessThanEnd) || isGreaterThanEnd) { + end = start; + start = startDate; + } else if (isEqualsStart || isEqualsEnd) { + end = start; + } else if (isLessThanStart) { + end = endDate; + } + } + + setStartDate(start); + setEndDate(end); + if (!start || !end) return; + handleChange({ + date: start.getTime() / 1000, + endDate: end.getTime() / 1000, + }); + } else { + const date = dates as Date; + + setStartDate(date); + handleChange({ + date: date.getTime() / 1000, + }); + } + }} + startDate={isRange ? startDate : null} + endDate={isRange ? endDate : null} + selectsRange={isRange} + inline + /> +
+ ); +} + +export default CustomCalendar; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateFormat.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateFormat.tsx new file mode 100644 index 0000000000000..fd5ba57889e90 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateFormat.tsx @@ -0,0 +1,96 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { MenuItem, Menu } from '@mui/material'; +import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; +import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; + +import { DateFormatPB } from '@/services/backend'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; + +interface Props { + value: DateFormatPB; + onChange: (value: DateFormatPB) => void; +} + +function DateFormat({ value, onChange }: Props) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const renderOptionContent = useCallback( + (option: DateFormatPB, title: string) => { + return ( +
+
{title}
+ {value === option && } +
+ ); + }, + [value] + ); + + const options: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: DateFormatPB.Friendly, + content: renderOptionContent(DateFormatPB.Friendly, t('grid.field.dateFormatFriendly')), + }, + { + key: DateFormatPB.ISO, + content: renderOptionContent(DateFormatPB.ISO, t('grid.field.dateFormatISO')), + }, + { + key: DateFormatPB.US, + content: renderOptionContent(DateFormatPB.US, t('grid.field.dateFormatUS')), + }, + { + key: DateFormatPB.Local, + content: renderOptionContent(DateFormatPB.Local, t('grid.field.dateFormatLocal')), + }, + { + key: DateFormatPB.DayMonthYear, + content: renderOptionContent(DateFormatPB.DayMonthYear, t('grid.field.dateFormatDayMonthYear')), + }, + ]; + }, [renderOptionContent, t]); + + const handleClick = (option: DateFormatPB) => { + onChange(option); + setOpen(false); + }; + + return ( + <> + setOpen(true)} + > + {t('grid.field.dateFormat')} + + + setOpen(false)} + > + { + setOpen(false); + }} + disableFocus={true} + options={options} + onConfirm={handleClick} + /> + + + ); +} + +export default DateFormat; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeCellActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeCellActions.tsx new file mode 100644 index 0000000000000..78e3129d4ff77 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeCellActions.tsx @@ -0,0 +1,197 @@ +import React, { useCallback, useMemo } from 'react'; +import Popover, { PopoverProps } from '@mui/material/Popover'; +import { DateTimeCell, DateTimeField, DateTimeTypeOption } from '$app/application/database'; +import { useViewId } from '$app/hooks'; +import { useTranslation } from 'react-i18next'; +import { updateDateCell } from '$app/application/database/cell/cell_service'; +import { Divider, MenuItem, MenuList } from '@mui/material'; +import dayjs from 'dayjs'; +import RangeSwitch from '$app/components/database/components/field_types/date/RangeSwitch'; +import CustomCalendar from '$app/components/database/components/field_types/date/CustomCalendar'; +import IncludeTimeSwitch from '$app/components/database/components/field_types/date/IncludeTimeSwitch'; +import DateTimeFormatSelect from '$app/components/database/components/field_types/date/DateTimeFormatSelect'; +import DateTimeSet from '$app/components/database/components/field_types/date/DateTimeSet'; +import { useTypeOption } from '$app/components/database'; +import { getDateFormat, getTimeFormat } from '$app/components/database/components/field_types/date/utils'; +import { notify } from '$app/components/_shared/notify'; + +function DateTimeCellActions({ + cell, + field, + maxWidth, + maxHeight, + ...props +}: PopoverProps & { + field: DateTimeField; + cell: DateTimeCell; + maxWidth?: number; + maxHeight?: number; +}) { + const typeOption = useTypeOption(field.id); + + const timeFormat = useMemo(() => { + return getTimeFormat(typeOption.timeFormat); + }, [typeOption.timeFormat]); + + const dateFormat = useMemo(() => { + return getDateFormat(typeOption.dateFormat); + }, [typeOption.dateFormat]); + + const { includeTime } = cell.data; + + const timestamp = useMemo(() => cell.data.timestamp || undefined, [cell.data.timestamp]); + const endTimestamp = useMemo(() => cell.data.endTimestamp || undefined, [cell.data.endTimestamp]); + const time = useMemo(() => cell.data.time || undefined, [cell.data.time]); + const endTime = useMemo(() => cell.data.endTime || undefined, [cell.data.endTime]); + + const viewId = useViewId(); + const { t } = useTranslation(); + + const handleChange = useCallback( + async (params: { + includeTime?: boolean; + date?: number; + endDate?: number; + time?: string; + endTime?: string; + isRange?: boolean; + clearFlag?: boolean; + }) => { + try { + const isRange = params.isRange ?? cell.data.isRange; + + const data = { + date: params.date ?? timestamp, + endDate: isRange ? params.endDate ?? endTimestamp : undefined, + time: params.time ?? time, + endTime: isRange ? params.endTime ?? endTime : undefined, + includeTime: params.includeTime ?? includeTime, + isRange, + clearFlag: params.clearFlag, + }; + + // if isRange and date is greater than endDate, swap date and endDate + if ( + data.isRange && + data.date && + data.endDate && + dayjs(dayjs.unix(data.date).format('YYYY/MM/DD ') + data.time).unix() > + dayjs(dayjs.unix(data.endDate).format('YYYY/MM/DD ') + data.endTime).unix() + ) { + if (params.date || params.time) { + data.endDate = data.date; + data.endTime = data.time; + } + + if (params.endDate || params.endTime) { + data.date = data.endDate; + data.time = data.endTime; + } + } + + await updateDateCell(viewId, cell.rowId, cell.fieldId, data); + } catch (e) { + notify.error(String(e)); + } + }, + [cell, endTime, endTimestamp, includeTime, time, timestamp, viewId] + ); + + const isRange = cell.data.isRange || false; + + return ( + { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} + > +
+ + + + + +
+ { + void handleChange({ + isRange: val, + // reset endTime when isRange is changed + endTime: time, + endDate: timestamp, + }); + }} + checked={isRange} + /> + { + void handleChange({ + includeTime: val, + // reset time when includeTime is changed + time: val ? dayjs().format(timeFormat) : undefined, + endTime: val && isRange ? dayjs().format(timeFormat) : undefined, + }); + }} + checked={includeTime} + /> +
+ + + + + + { + await handleChange({ + isRange: false, + includeTime: false, + }); + await handleChange({ + clearFlag: true, + }); + + props.onClose?.({}, 'backdropClick'); + }} + > + {t('grid.field.clearDate')} + + +
+
+ ); +} + +export default DateTimeCellActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFieldActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFieldActions.tsx new file mode 100644 index 0000000000000..6c4b41a494beb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFieldActions.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { UndeterminedDateField } from '$app/application/database'; +import DateTimeFormat from '$app/components/database/components/field_types/date/DateTimeFormat'; +import { Divider } from '@mui/material'; + +function DateTimeFieldActions({ field }: { field: UndeterminedDateField }) { + return ( + <> +
+ +
+ + + ); +} + +export default DateTimeFieldActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormat.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormat.tsx new file mode 100644 index 0000000000000..0107997c24d72 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormat.tsx @@ -0,0 +1,75 @@ +import React, { useCallback } from 'react'; +import DateFormat from '$app/components/database/components/field_types/date/DateFormat'; +import TimeFormat from '$app/components/database/components/field_types/date/TimeFormat'; +import { TimeStampTypeOption, UndeterminedDateField, updateTypeOption } from '$app/application/database'; +import { DateFormatPB, FieldType, TimeFormatPB } from '@/services/backend'; +import { useViewId } from '$app/hooks'; +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; +import IncludeTimeSwitch from '$app/components/database/components/field_types/date/IncludeTimeSwitch'; +import { useTypeOption } from '$app/components/database'; + +interface Props { + field: UndeterminedDateField; + showLabel?: boolean; +} + +function DateTimeFormat({ field, showLabel = true }: Props) { + const viewId = useViewId(); + const { t } = useTranslation(); + const showIncludeTime = field.type === FieldType.CreatedTime || field.type === FieldType.LastEditedTime; + const typeOption = useTypeOption(field.id); + const { timeFormat = TimeFormatPB.TwentyFourHour, dateFormat = DateFormatPB.Friendly, includeTime } = typeOption; + const handleChange = useCallback( + async (params: { timeFormat?: TimeFormatPB; dateFormat?: DateFormatPB; includeTime?: boolean }) => { + try { + await updateTypeOption(viewId, field.id, field.type, { + timeFormat: params.timeFormat ?? timeFormat, + dateFormat: params.dateFormat ?? dateFormat, + includeTime: params.includeTime ?? includeTime, + fieldType: field.type, + }); + } catch (e) { + // toast.error(e.message); + } + }, + [dateFormat, field.id, field.type, includeTime, timeFormat, viewId] + ); + + return ( +
+ {showLabel && ( + + {t('grid.field.format')} + + )} + + { + void handleChange({ dateFormat: val }); + }} + /> + { + void handleChange({ timeFormat: val }); + }} + /> + + {showIncludeTime && ( +
+ { + void handleChange({ includeTime: checked }); + }} + /> +
+ )} +
+ ); +} + +export default DateTimeFormat; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormatSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormatSelect.tsx new file mode 100644 index 0000000000000..f0393139b0964 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormatSelect.tsx @@ -0,0 +1,55 @@ +import React, { useState, useRef } from 'react'; +import { Menu, MenuItem } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { DateTimeField } from '$app/application/database'; +import DateTimeFormat from '$app/components/database/components/field_types/date/DateTimeFormat'; +import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; + +interface Props { + field: DateTimeField; +} + +function DateTimeFormatSelect({ field }: Props) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + return ( + <> + setOpen(true)} className={'text-xs font-medium'}> +
+ {t('grid.field.dateFormat')} & {t('grid.field.timeFormat')} +
+ +
+ { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + setOpen(false); + } + }} + onClose={() => setOpen(false)} + MenuListProps={{ + className: 'px-2', + }} + > + + + + ); +} + +export default DateTimeFormatSelect; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeInput.tsx new file mode 100644 index 0000000000000..82080b7d25841 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeInput.tsx @@ -0,0 +1,86 @@ +import React, { useMemo } from 'react'; +import { DateField, TimeField } from '@mui/x-date-pickers-pro'; +import dayjs from 'dayjs'; +import { Divider } from '@mui/material'; +import debounce from 'lodash-es/debounce'; + +interface Props { + onChange: (params: { date?: number; time?: string }) => void; + date?: number; + time?: string; + timeFormat: string; + dateFormat: string; + includeTime?: boolean; +} + +const sx = { + '& .MuiOutlinedInput-notchedOutline': { + border: 'none', + }, + '& .MuiOutlinedInput-input': { + padding: '0', + }, +}; + +function DateTimeInput({ includeTime, dateFormat, timeFormat, ...props }: Props) { + const date = useMemo(() => { + return props.date ? dayjs.unix(props.date) : undefined; + }, [props.date]); + + const time = useMemo(() => { + return props.time ? dayjs(dayjs().format('YYYY/MM/DD ') + props.time) : undefined; + }, [props.time]); + + const debounceOnChange = useMemo(() => { + return debounce(props.onChange, 500); + }, [props.onChange]); + + return ( +
+ { + if (!date) return; + debounceOnChange({ + date: date.unix(), + }); + }} + inputProps={{ + className: 'text-[12px]', + }} + format={dateFormat} + size={'small'} + sx={sx} + className={'flex-1 pl-2'} + /> + + {includeTime && ( + <> + + { + if (!time) return; + debounceOnChange({ + time: time.format(timeFormat), + }); + }} + format={timeFormat} + size={'small'} + sx={sx} + className={'w-[70px] pl-1'} + /> + + )} +
+ ); +} + +export default DateTimeInput; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeSet.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeSet.tsx new file mode 100644 index 0000000000000..8e86b952d118f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeSet.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { LocalizationProvider } from '@mui/x-date-pickers-pro'; +import { AdapterDayjs } from '@mui/x-date-pickers-pro/AdapterDayjs'; +import DateTimeInput from '$app/components/database/components/field_types/date/DateTimeInput'; + +interface Props { + onChange: (params: { date?: number; endDate?: number; time?: string; endTime?: string }) => void; + date?: number; + endDate?: number; + time?: string; + endTime?: string; + isRange?: boolean; + timeFormat: string; + dateFormat: string; + includeTime?: boolean; +} +function DateTimeSet({ onChange, date, endDate, time, endTime, isRange, timeFormat, dateFormat, includeTime }: Props) { + return ( +
+ + { + onChange({ + date, + time, + }); + }} + date={date} + time={time} + timeFormat={timeFormat} + dateFormat={dateFormat} + includeTime={includeTime} + /> + {isRange && ( + { + onChange({ + endDate: date, + endTime: time, + }); + }} + timeFormat={timeFormat} + dateFormat={dateFormat} + includeTime={includeTime} + /> + )} + +
+ ); +} + +export default DateTimeSet; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/IncludeTimeSwitch.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/IncludeTimeSwitch.tsx new file mode 100644 index 0000000000000..f40e179ae4229 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/IncludeTimeSwitch.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Switch, SwitchProps } from '@mui/material'; +import { ReactComponent as TimeSvg } from '$app/assets/database/field-type-last-edited-time.svg'; +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; + +function IncludeTimeSwitch({ + checked, + onIncludeTimeChange, + ...props +}: SwitchProps & { + onIncludeTimeChange: (checked: boolean) => void; +}) { + const { t } = useTranslation(); + const handleChange = (event: React.ChangeEvent) => { + onIncludeTimeChange(event.target.checked); + }; + + return ( +
+
+ + {t('grid.field.includeTime')} +
+ +
+ ); +} + +export default IncludeTimeSwitch; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/RangeSwitch.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/RangeSwitch.tsx new file mode 100644 index 0000000000000..76431af5fae30 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/RangeSwitch.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Switch, SwitchProps } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import { ReactComponent as DateSvg } from '$app/assets/database/field-type-date.svg'; + +function RangeSwitch({ + checked, + onIsRangeChange, + ...props +}: SwitchProps & { + onIsRangeChange: (checked: boolean) => void; +}) { + const { t } = useTranslation(); + const handleChange = (event: React.ChangeEvent) => { + onIsRangeChange(event.target.checked); + }; + + return ( +
+
+ + {t('grid.field.isRange')} +
+ +
+ ); +} + +export default RangeSwitch; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/TimeFormat.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/TimeFormat.tsx new file mode 100644 index 0000000000000..89a9ad17566a7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/TimeFormat.tsx @@ -0,0 +1,82 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { TimeFormatPB } from '@/services/backend'; +import { useTranslation } from 'react-i18next'; +import { Menu, MenuItem } from '@mui/material'; +import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; +import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; + +interface Props { + value: TimeFormatPB; + onChange: (value: TimeFormatPB) => void; +} +function TimeFormat({ value, onChange }: Props) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const renderOptionContent = useCallback( + (option: TimeFormatPB, title: string) => { + return ( +
+
{title}
+ {value === option && } +
+ ); + }, + [value] + ); + + const options: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: TimeFormatPB.TwelveHour, + content: renderOptionContent(TimeFormatPB.TwelveHour, t('grid.field.timeFormatTwelveHour')), + }, + { + key: TimeFormatPB.TwentyFourHour, + content: renderOptionContent(TimeFormatPB.TwentyFourHour, t('grid.field.timeFormatTwentyFourHour')), + }, + ]; + }, [renderOptionContent, t]); + + const handleClick = (option: TimeFormatPB) => { + onChange(option); + setOpen(false); + }; + + return ( + <> + setOpen(true)} + > + {t('grid.field.timeFormat')} + + + setOpen(false)} + > + { + setOpen(false); + }} + disableFocus={true} + options={options} + onConfirm={handleClick} + /> + + + ); +} + +export default TimeFormat; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/calendar.scss b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/calendar.scss new file mode 100644 index 0000000000000..257467ed2411f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/calendar.scss @@ -0,0 +1,82 @@ + +.react-datepicker__month-container { + width: 100%; + border-radius: 0; +} +.react-datepicker__header { + border-radius: 0; + background: transparent; + border-bottom: 0; + +} +.react-datepicker__day-names { + border: none; +} +.react-datepicker__day-name { + color: var(--text-caption); +} +.react-datepicker__month { + border: none; +} + +.react-datepicker__day { + border: none; + color: var(--text-title); + border-radius: 100%; +} +.react-datepicker__day:hover { + border-radius: 100%; + background: var(--fill-default); + color: var(--content-on-fill); +} +.react-datepicker__day--outside-month { + color: var(--text-caption); +} +.react-datepicker__day--in-range { + background: var(--fill-hover); + color: var(--content-on-fill); +} + + +.react-datepicker__day--today { + border: 1px solid var(--fill-default); + color: var(--text-title); + border-radius: 100%; + background: transparent; + font-weight: 500; + +} + +.react-datepicker__day--today:hover{ + background: var(--fill-default); + color: var(--content-on-fill); +} + +.react-datepicker__day--in-selecting-range, .react-datepicker__day--today.react-datepicker__day--in-range { + background: var(--fill-hover); + color: var(--content-on-fill); + border-color: transparent; +} + +.react-datepicker__day--keyboard-selected { + background: transparent; +} + + +.react-datepicker__day--range-start, .react-datepicker__day--range-end, .react-datepicker__day--selected { + &.react-datepicker__day--today { + background: var(--fill-default); + color: var(--content-on-fill); + } + background: var(--fill-default) !important; + color: var(--content-on-fill); +} + +.react-datepicker__day--range-start, .react-datepicker__day--range-end, .react-datepicker__day--selected:hover { + background: var(--fill-default); + color: var(--content-on-fill); +} + +.react-swipeable-view-container { + height: 100%; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/utils.ts new file mode 100644 index 0000000000000..129e84c4e7cf9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/utils.ts @@ -0,0 +1,29 @@ +import { DateFormatPB, TimeFormatPB } from '@/services/backend'; + +export function getTimeFormat(timeFormat?: TimeFormatPB) { + switch (timeFormat) { + case TimeFormatPB.TwelveHour: + return 'h:mm A'; + case TimeFormatPB.TwentyFourHour: + return 'HH:mm'; + default: + return 'HH:mm'; + } +} + +export function getDateFormat(dateFormat?: DateFormatPB) { + switch (dateFormat) { + case DateFormatPB.Friendly: + return 'MMM DD, YYYY'; + case DateFormatPB.ISO: + return 'YYYY-MMM-DD'; + case DateFormatPB.US: + return 'YYYY/MMM/DD'; + case DateFormatPB.Local: + return 'MMM/DD/YYYY'; + case DateFormatPB.DayMonthYear: + return 'DD/MMM/YYYY'; + default: + return 'YYYY-MMM-DD'; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/EditNumberCellInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/EditNumberCellInput.tsx new file mode 100644 index 0000000000000..d2b538a7e1667 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/EditNumberCellInput.tsx @@ -0,0 +1,68 @@ +import React, { useCallback } from 'react'; +import { Popover } from '@mui/material'; +import InputBase from '@mui/material/InputBase'; + +function EditNumberCellInput({ + editing, + anchorEl, + width, + onClose, + value, + onChange, +}: { + editing: boolean; + anchorEl: HTMLDivElement | null; + width: number | undefined; + onClose: () => void; + value: string; + onChange: (value: string) => void; +}) { + const handleInput = (e: React.FormEvent) => { + const value = (e.target as HTMLInputElement).value; + + onChange(value); + }; + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + onClose(); + } + }, + [onClose] + ); + + return ( + + + + ); +} + +export default EditNumberCellInput; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFieldActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFieldActions.tsx new file mode 100644 index 0000000000000..eceb12880466d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFieldActions.tsx @@ -0,0 +1,34 @@ +import React, { useCallback } from 'react'; +import { NumberField, NumberTypeOption, updateTypeOption } from '$app/application/database'; +import { Divider } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import NumberFormatSelect from '$app/components/database/components/field_types/number/NumberFormatSelect'; +import { NumberFormatPB } from '@/services/backend'; +import { useViewId } from '$app/hooks'; +import { useTypeOption } from '$app/components/database'; + +function NumberFieldActions({ field }: { field: NumberField }) { + const viewId = useViewId(); + const { t } = useTranslation(); + const typeOption = useTypeOption(field.id); + const onChange = useCallback( + async (value: NumberFormatPB) => { + await updateTypeOption(viewId, field.id, field.type, { + format: value, + }); + }, + [field.id, field.type, viewId] + ); + + return ( + <> +
+
{t('grid.field.format')}
+ +
+ + + ); +} + +export default NumberFieldActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatMenu.tsx new file mode 100644 index 0000000000000..0f9be6a21ae16 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatMenu.tsx @@ -0,0 +1,64 @@ +import React, { useCallback, useMemo, useRef } from 'react'; +import { NumberFormatPB } from '@/services/backend'; +import { Menu, MenuProps } from '@mui/material'; +import { formats, formatText } from '$app/components/database/components/field_types/number/const'; +import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; + +function NumberFormatMenu({ + value, + onChangeFormat, + ...props +}: MenuProps & { + value: NumberFormatPB; + onChangeFormat: (value: NumberFormatPB) => void; +}) { + const scrollRef = useRef(null); + const onConfirm = useCallback( + (format: NumberFormatPB) => { + onChangeFormat(format); + props.onClose?.({}, 'backdropClick'); + }, + [onChangeFormat, props] + ); + + const renderContent = useCallback( + (format: NumberFormatPB) => { + return ( + <> + {formatText(format)} + {value === format && } + + ); + }, + [value] + ); + + const options: KeyboardNavigationOption[] = useMemo( + () => + formats.map((format) => ({ + key: format.value as NumberFormatPB, + content: renderContent(format.value as NumberFormatPB), + })), + [renderContent] + ); + + return ( + +
+ props.onClose?.({}, 'escapeKeyDown')} + /> +
+
+ ); +} + +export default NumberFormatMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatSelect.tsx new file mode 100644 index 0000000000000..5a02c6759b482 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatSelect.tsx @@ -0,0 +1,49 @@ +import React, { useRef, useState } from 'react'; +import { MenuItem } from '@mui/material'; +import { NumberFormatPB } from '@/services/backend'; +import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; +import { formatText } from '$app/components/database/components/field_types/number/const'; +import NumberFormatMenu from '$app/components/database/components/field_types/number/NumberFormatMenu'; + +function NumberFormatSelect({ value, onChange }: { value: NumberFormatPB; onChange: (value: NumberFormatPB) => void }) { + const ref = useRef(null); + const [expanded, setExpanded] = useState(false); + + return ( + <> + { + setExpanded(!expanded); + }} + className={'flex w-full justify-between rounded-none'} + > +
{formatText(value)}
+ +
+ setExpanded(false)} + onChangeFormat={onChange} + /> + + ); +} + +export default NumberFormatSelect; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/const.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/const.ts new file mode 100644 index 0000000000000..38621cb114a76 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/const.ts @@ -0,0 +1,14 @@ +import { NumberFormatPB } from '@/services/backend'; + +export const formats = Object.entries(NumberFormatPB) + .filter(([, value]) => typeof value !== 'string') + .map(([key, value]) => { + return { + key, + value, + }; + }); + +export const formatText = (format: NumberFormatPB) => { + return formats.find((item) => item.value === format)?.key; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionModifyMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionModifyMenu.tsx new file mode 100644 index 0000000000000..6c6cf37aaeace --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionModifyMenu.tsx @@ -0,0 +1,165 @@ +import { FC, useMemo, useRef, useState } from 'react'; +import { Divider, ListSubheader, MenuItem, MenuList, MenuProps, OutlinedInput } from '@mui/material'; +import { SelectOptionColorPB } from '@/services/backend'; +import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg'; +import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; +import { SelectOption } from '$app/application/database'; +import { SelectOptionColorMap, SelectOptionColorTextMap } from './constants'; +import Button from '@mui/material/Button'; +import { + deleteSelectOption, + insertOrUpdateSelectOption, +} from '$app/application/database/field/select_option/select_option_service'; +import { useViewId } from '$app/hooks'; +import Popover from '@mui/material/Popover'; +import debounce from 'lodash-es/debounce'; +import { useTranslation } from 'react-i18next'; + +interface SelectOptionMenuProps { + fieldId: string; + option: SelectOption; + MenuProps: MenuProps; +} + +const Colors = [ + SelectOptionColorPB.Purple, + SelectOptionColorPB.Pink, + SelectOptionColorPB.LightPink, + SelectOptionColorPB.Orange, + SelectOptionColorPB.Yellow, + SelectOptionColorPB.Lime, + SelectOptionColorPB.Green, + SelectOptionColorPB.Aqua, + SelectOptionColorPB.Blue, +]; + +export const SelectOptionModifyMenu: FC = ({ fieldId, option, MenuProps: menuProps }) => { + const { t } = useTranslation(); + const [tagName, setTagName] = useState(option.name); + const viewId = useViewId(); + const inputRef = useRef(null); + const updateColor = async (color: SelectOptionColorPB) => { + await insertOrUpdateSelectOption(viewId, fieldId, [ + { + ...option, + color, + }, + ]); + }; + + const updateName = useMemo(() => { + return debounce(async (tagName) => { + if (tagName === option.name) return; + + await insertOrUpdateSelectOption(viewId, fieldId, [ + { + ...option, + name: tagName, + }, + ]); + }, 500); + }, [option, viewId, fieldId]); + + const onClose = () => { + menuProps.onClose?.({}, 'backdropClick'); + }; + + const deleteOption = async () => { + await deleteSelectOption(viewId, fieldId, [option]); + onClose(); + }; + + return ( + { + e.stopPropagation(); + }} + onClose={onClose} + onMouseDown={(e) => { + const isInput = inputRef.current?.contains(e.target as Node); + + if (isInput) return; + e.preventDefault(); + e.stopPropagation(); + }} + > + + { + setTagName(e.target.value); + void updateName(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + void updateName(tagName); + onClose(); + } + }} + onClick={(e) => { + e.stopPropagation(); + }} + onMouseDown={(e) => { + e.stopPropagation(); + }} + autoFocus={true} + placeholder={t('grid.selectOption.tagName')} + size='small' + /> + +
+ +
+ + + {t('grid.selectOption.colorPanelTitle')} + + {Colors.map((color) => ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onClick={(e) => { + e.preventDefault(); + void updateColor(color); + }} + key={color} + value={color} + className={'px-1.5'} + > + + {t(`grid.selectOption.${SelectOptionColorTextMap[color]}`)} + {option.color === color && } + + ))} + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/Tag.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/Tag.tsx new file mode 100644 index 0000000000000..3e4677d57d1fe --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/Tag.tsx @@ -0,0 +1,27 @@ +import { FC } from 'react'; +import { Chip, ChipProps } from '@mui/material'; +import { SelectOptionColorPB } from '@/services/backend'; +import { SelectOptionColorMap } from './constants'; + +export interface TagProps extends Omit { + color?: SelectOptionColorPB | ChipProps['color']; +} + +export const Tag: FC = ({ color, classes, ...props }) => { + + return ( + + ) +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/constants.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/constants.ts new file mode 100644 index 0000000000000..58a42f7dadefc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/constants.ts @@ -0,0 +1,25 @@ +import { SelectOptionColorPB } from '@/services/backend'; + +export const SelectOptionColorMap = { + [SelectOptionColorPB.Purple]: 'bg-tint-purple', + [SelectOptionColorPB.Pink]: 'bg-tint-pink', + [SelectOptionColorPB.LightPink]: 'bg-tint-red', + [SelectOptionColorPB.Orange]: 'bg-tint-orange', + [SelectOptionColorPB.Yellow]: 'bg-tint-yellow', + [SelectOptionColorPB.Lime]: 'bg-tint-lime', + [SelectOptionColorPB.Green]: 'bg-tint-green', + [SelectOptionColorPB.Aqua]: 'bg-tint-aqua', + [SelectOptionColorPB.Blue]: 'bg-tint-blue', +}; + +export const SelectOptionColorTextMap = { + [SelectOptionColorPB.Purple]: 'purpleColor', + [SelectOptionColorPB.Pink]: 'pinkColor', + [SelectOptionColorPB.LightPink]: 'lightPinkColor', + [SelectOptionColorPB.Orange]: 'orangeColor', + [SelectOptionColorPB.Yellow]: 'yellowColor', + [SelectOptionColorPB.Lime]: 'limeColor', + [SelectOptionColorPB.Green]: 'greenColor', + [SelectOptionColorPB.Aqua]: 'aquaColor', + [SelectOptionColorPB.Blue]: 'blueColor', +} as const; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SearchInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SearchInput.tsx new file mode 100644 index 0000000000000..5c8acb47590c1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SearchInput.tsx @@ -0,0 +1,38 @@ +import React, { FormEvent, useCallback } from 'react'; +import { OutlinedInput } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +function SearchInput({ + setNewOptionName, + newOptionName, + inputRef, +}: { + newOptionName: string; + setNewOptionName: (value: string) => void; + inputRef?: React.RefObject; +}) { + const { t } = useTranslation(); + const handleInput = useCallback( + (event: FormEvent) => { + const value = (event.target as HTMLInputElement).value; + + setNewOptionName(value); + }, + [setNewOptionName] + ); + + return ( + + ); +} + +export default SearchInput; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectCellActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectCellActions.tsx new file mode 100644 index 0000000000000..e2cd27019fa73 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectCellActions.tsx @@ -0,0 +1,162 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { SelectOptionItem } from '$app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem'; +import { cellService, SelectCell as SelectCellType, SelectField, SelectTypeOption } from '$app/application/database'; +import { useViewId } from '$app/hooks'; +import { + createSelectOption, + insertOrUpdateSelectOption, +} from '$app/application/database/field/select_option/select_option_service'; +import { FieldType } from '@/services/backend'; +import { useTypeOption } from '$app/components/database'; +import SearchInput from './SearchInput'; +import { useTranslation } from 'react-i18next'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { Tag } from '$app/components/database/components/field_types/select/Tag'; + +const CREATE_OPTION_KEY = 'createOption'; + +function SelectCellActions({ + field, + cell, + onUpdated, + onClose, +}: { + field: SelectField; + cell: SelectCellType; + onUpdated?: () => void; + onClose?: () => void; +}) { + const { t } = useTranslation(); + const rowId = cell?.rowId; + const viewId = useViewId(); + const typeOption = useTypeOption(field.id); + const options = useMemo(() => typeOption.options ?? [], [typeOption.options]); + const scrollRef = useRef(null); + const inputRef = useRef(null); + const selectedOptionIds = useMemo(() => cell?.data?.selectedOptionIds ?? [], [cell]); + const [newOptionName, setNewOptionName] = useState(''); + + const filteredOptions: KeyboardNavigationOption[] = useMemo(() => { + const result = options + .filter((option) => { + return option.name.toLowerCase().includes(newOptionName.toLowerCase()); + }) + .map((option) => ({ + key: option.id, + content: ( + + ), + })); + + if (result.length === 0 && newOptionName) { + result.push({ + key: CREATE_OPTION_KEY, + content: , + }); + } + + return result; + }, [newOptionName, options, selectedOptionIds, cell?.fieldId]); + + const shouldCreateOption = filteredOptions.length === 1 && filteredOptions[0].key === 'createOption'; + + const updateCell = useCallback( + async (optionIds: string[]) => { + if (!cell || !rowId) return; + const deleteOptionIds = selectedOptionIds?.filter((id) => optionIds.find((cur) => cur === id) === undefined); + + await cellService.updateSelectCell(viewId, rowId, field.id, { + insertOptionIds: optionIds, + deleteOptionIds, + }); + onUpdated?.(); + }, + [cell, field.id, onUpdated, rowId, selectedOptionIds, viewId] + ); + + const createOption = useCallback(async () => { + const option = await createSelectOption(viewId, field.id, newOptionName); + + if (!option) return; + await insertOrUpdateSelectOption(viewId, field.id, [option]); + setNewOptionName(''); + return option; + }, [viewId, field.id, newOptionName]); + + const onConfirm = useCallback( + async (key: string) => { + let optionId = key; + + if (key === CREATE_OPTION_KEY) { + const option = await createOption(); + + optionId = option?.id || ''; + } + + if (!optionId) return; + + if (field.type === FieldType.SingleSelect) { + const newOptionIds = [optionId]; + + if (selectedOptionIds?.includes(optionId)) { + newOptionIds.pop(); + } + + void updateCell(newOptionIds); + return; + } + + let newOptionIds = []; + + if (!selectedOptionIds) { + newOptionIds.push(optionId); + } else { + const isSelected = selectedOptionIds.includes(optionId); + + if (isSelected) { + newOptionIds = selectedOptionIds.filter((id) => id !== optionId); + } else { + newOptionIds = [...selectedOptionIds, optionId]; + } + } + + void updateCell(newOptionIds); + }, + [createOption, field.type, selectedOptionIds, updateCell] + ); + + return ( +
+ + + {filteredOptions.length > 0 && ( +
+ {shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')} +
+ )} + +
+ null} + /> +
+
+ ); +} + +export default SelectCellActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem.tsx new file mode 100644 index 0000000000000..2a855a4085da2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem.tsx @@ -0,0 +1,55 @@ +import { FC, MouseEventHandler, useCallback, useRef, useState } from 'react'; +import { IconButton } from '@mui/material'; +import { ReactComponent as DetailsSvg } from '$app/assets/details.svg'; +import { SelectOption } from '$app/application/database'; +import { SelectOptionModifyMenu } from '../SelectOptionModifyMenu'; +import { Tag } from '../Tag'; +import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; + +export interface SelectOptionItemProps { + option: SelectOption; + fieldId: string; + isSelected?: boolean; +} + +export const SelectOptionItem: FC = ({ isSelected, fieldId, option }) => { + const [open, setOpen] = useState(false); + const anchorEl = useRef(null); + const [hovered, setHovered] = useState(false); + const handleClick = useCallback>((event) => { + event.stopPropagation(); + setOpen(true); + }, []); + + return ( + <> +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > +
+ +
+ {isSelected && !hovered && } + {hovered && ( + + + + )} +
+ {open && ( + setOpen(false), + }} + /> + )} + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/AddAnOption.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/AddAnOption.tsx new file mode 100644 index 0000000000000..0fb180bb08562 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/AddAnOption.tsx @@ -0,0 +1,76 @@ +import React, { useMemo, useState } from 'react'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { OutlinedInput } from '@mui/material'; +import { + createSelectOption, + insertOrUpdateSelectOption, +} from '$app/application/database/field/select_option/select_option_service'; +import { useViewId } from '$app/hooks'; +import { SelectOption } from '$app/application/database'; +import { notify } from '$app/components/_shared/notify'; + +function AddAnOption({ fieldId, options }: { fieldId: string; options: SelectOption[] }) { + const viewId = useViewId(); + const { t } = useTranslation(); + const [edit, setEdit] = useState(false); + const [newOptionName, setNewOptionName] = useState(''); + const exitEdit = () => { + setNewOptionName(''); + setEdit(false); + }; + + const isOptionExist = useMemo(() => { + return options.some((option) => option.name === newOptionName); + }, [options, newOptionName]); + + const createOption = async () => { + if (!newOptionName) return; + if (isOptionExist) { + notify.error(t('grid.field.optionAlreadyExist')); + return; + } + + const option = await createSelectOption(viewId, fieldId, newOptionName); + + if (!option) return; + await insertOrUpdateSelectOption(viewId, fieldId, [option]); + setNewOptionName(''); + }; + + return edit ? ( + { + setNewOptionName(e.target.value); + }} + value={newOptionName} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + void createOption(); + return; + } + + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + exitEdit(); + } + }} + className={'mx-2 mb-1'} + placeholder={t('grid.selectOption.typeANewOption')} + size='small' + /> + ) : ( + + ); +} + +export default AddAnOption; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Option.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Option.tsx new file mode 100644 index 0000000000000..ad363d4a1d124 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Option.tsx @@ -0,0 +1,47 @@ +import React, { useRef, useState } from 'react'; +import { ReactComponent as MoreIcon } from '$app/assets/more.svg'; +import { SelectOption } from '$app/application/database'; +// import { ReactComponent as DragIcon } from '$app/assets/drag.svg'; + +import { SelectOptionModifyMenu } from '$app/components/database/components/field_types/select/SelectOptionModifyMenu'; +import Button from '@mui/material/Button'; +import { SelectOptionColorMap } from '$app/components/database/components/field_types/select/constants'; + +function Option({ option, fieldId }: { option: SelectOption; fieldId: string }) { + const [expanded, setExpanded] = useState(false); + const ref = useRef(null); + + return ( + <> + + setExpanded(false), + open: expanded, + transformOrigin: { + vertical: 'center', + horizontal: 'left', + }, + anchorOrigin: { vertical: 'center', horizontal: 'right' }, + }} + option={option} + /> + + ); +} + +export default Option; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Options.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Options.tsx new file mode 100644 index 0000000000000..4e062362637ba --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Options.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { SelectOption } from '$app/application/database'; +import Option from './Option'; + +interface Props { + options: SelectOption[]; + fieldId: string; +} +function Options({ options, fieldId }: Props) { + return ( +
+ {options.map((option) => { + return
+ ); +} + +export default Options; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/SelectFieldActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/SelectFieldActions.tsx new file mode 100644 index 0000000000000..a3d51ceb60d27 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/SelectFieldActions.tsx @@ -0,0 +1,26 @@ +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import AddAnOption from '$app/components/database/components/field_types/select/select_field_actions/AddAnOption'; +import Options from '$app/components/database/components/field_types/select/select_field_actions/Options'; +import { SelectField, SelectTypeOption } from '$app/application/database'; +import { Divider } from '@mui/material'; +import { useTypeOption } from '$app/components/database'; + +function SelectFieldActions({ field }: { field: SelectField }) { + const typeOption = useTypeOption(field.id); + const options = useMemo(() => typeOption.options ?? [], [typeOption.options]); + const { t } = useTranslation(); + + return ( + <> +
+
{t('grid.field.optionTitle')}
+ + +
+ + + ); +} + +export default SelectFieldActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/text/EditTextCellInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/text/EditTextCellInput.tsx new file mode 100644 index 0000000000000..005d185c8f175 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/text/EditTextCellInput.tsx @@ -0,0 +1,69 @@ +import React, { useCallback } from 'react'; +import { Popover, TextareaAutosize } from '@mui/material'; + +interface Props { + editing: boolean; + anchorEl: HTMLDivElement | null; + onClose: () => void; + text: string; + onInput: (event: React.FormEvent) => void; +} + +function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props) { + const handleEnter = (e: React.KeyboardEvent) => { + const shift = e.shiftKey; + + // If shift is pressed, allow the user to enter a new line, otherwise close the popover + if (!shift && e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }; + + const setRef = useCallback((e: HTMLTextAreaElement | null) => { + if (!e) return; + const selectionStart = e.value.length; + + e.setSelectionRange(selectionStart, selectionStart); + }, []); + + return ( + { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + onClose(); + } + }} + > + + + ); +} + +export default EditTextCellInput; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/ConditionSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/ConditionSelect.tsx new file mode 100644 index 0000000000000..0ca6c42a860c0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/ConditionSelect.tsx @@ -0,0 +1,78 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; +import Popover from '@mui/material/Popover'; + +function ConditionSelect({ + conditions, + value, + onChange, +}: { + conditions: { + value: number; + text: string; + }[]; + value: number; + onChange: (condition: number) => void; +}) { + const [anchorEl, setAnchorEl] = useState(null); + const options: KeyboardNavigationOption[] = useMemo(() => { + return conditions.map((condition) => { + return { + key: condition.value, + content: condition.text, + }; + }); + }, [conditions]); + + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + const onConfirm = useCallback( + (key: number) => { + onChange(key); + }, + [onChange] + ); + + const valueText = useMemo(() => { + return conditions.find((condition) => condition.value === value)?.text; + }, [conditions, value]); + + const open = Boolean(anchorEl); + + return ( +
+
{ + setAnchorEl(e.currentTarget); + }} + className={'flex cursor-pointer select-none items-center gap-2 py-2 text-xs'} + > +
{valueText}
+ +
+ + + +
+ ); +} + +export default ConditionSelect; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx new file mode 100644 index 0000000000000..fdd7bccb5b225 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx @@ -0,0 +1,200 @@ +import React, { FC, useMemo, useState } from 'react'; +import { + CheckboxFilterData, + ChecklistFilterData, + DateFilterData, + Field as FieldData, + Filter as FilterType, + NumberFilterData, + SelectFilterData, + TextFilterData, + UndeterminedFilter, +} from '$app/application/database'; +import { Chip, Popover } from '@mui/material'; +import { Property } from '$app/components/database/components/property'; +import { ReactComponent as DropDownSvg } from '$app/assets/dropdown.svg'; +import TextFilter from './text_filter/TextFilter'; +import { CheckboxFilterConditionPB, ChecklistFilterConditionPB, FieldType } from '@/services/backend'; +import FilterActions from '$app/components/database/components/filter/FilterActions'; +import { updateFilter } from '$app/application/database/filter/filter_service'; +import { useViewId } from '$app/hooks'; +import SelectFilter from './select_filter/SelectFilter'; + +import DateFilter from '$app/components/database/components/filter/date_filter/DateFilter'; +import FilterConditionSelect from '$app/components/database/components/filter/FilterConditionSelect'; +import TextFilterValue from '$app/components/database/components/filter/text_filter/TextFilterValue'; +import SelectFilterValue from '$app/components/database/components/filter/select_filter/SelectFilterValue'; +import NumberFilterValue from '$app/components/database/components/filter/number_filter/NumberFilterValue'; +import { useTranslation } from 'react-i18next'; +import DateFilterValue from '$app/components/database/components/filter/date_filter/DateFilterValue'; + +interface Props { + filter: FilterType; + field: FieldData; +} + +interface FilterComponentProps { + filter: FilterType; + field: FieldData; + onChange: (data: UndeterminedFilter['data']) => void; + onClose?: () => void; +} + +type FilterComponent = FC; +const getFilterComponent = (field: FieldData) => { + switch (field.type) { + case FieldType.RichText: + case FieldType.URL: + case FieldType.Number: + return TextFilter as FilterComponent; + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return SelectFilter as FilterComponent; + + case FieldType.DateTime: + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + return DateFilter as FilterComponent; + default: + return null; + } +}; + +function Filter({ filter, field }: Props) { + const viewId = useViewId(); + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const handleClick = (e: React.MouseEvent) => { + setAnchorEl(e.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const onDataChange = async (data: UndeterminedFilter['data']) => { + const newFilter = { + ...filter, + data: { + ...(filter.data || {}), + ...data, + }, + } as UndeterminedFilter; + + try { + await updateFilter(viewId, newFilter); + } catch (e) { + // toast.error(e.message); + } + }; + + const Component = getFilterComponent(field); + + const condition = useMemo(() => { + switch (field.type) { + case FieldType.RichText: + case FieldType.URL: + return (filter.data as TextFilterData).condition; + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return (filter.data as SelectFilterData).condition; + case FieldType.Number: + return (filter.data as NumberFilterData).condition; + case FieldType.Checkbox: + return (filter.data as CheckboxFilterData).condition; + case FieldType.Checklist: + return (filter.data as ChecklistFilterData).condition; + case FieldType.DateTime: + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + return (filter.data as DateFilterData).condition; + default: + return; + } + }, [field, filter]); + + const conditionValue = useMemo(() => { + switch (field.type) { + case FieldType.RichText: + case FieldType.URL: + return ; + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return ; + case FieldType.Number: + return ; + case FieldType.Checkbox: + return (filter.data as CheckboxFilterData).condition === CheckboxFilterConditionPB.IsChecked + ? t('grid.checkboxFilter.isChecked') + : t('grid.checkboxFilter.isUnchecked'); + case FieldType.Checklist: + return (filter.data as ChecklistFilterData).condition === ChecklistFilterConditionPB.IsComplete + ? t('grid.checklistFilter.isComplete') + : t('grid.checklistFilter.isIncomplted'); + case FieldType.DateTime: + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + return ; + default: + return ''; + } + }, [field.id, field.type, filter.data, t]); + + return ( + <> + + + {conditionValue} + +
+ } + onClick={handleClick} + /> + {condition !== undefined && open && ( + { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + handleClose(); + } + }} + > +
+ { + void onDataChange({ + condition, + }); + }} + /> + +
+ {Component && } +
+ )} + + ); +} + +export default Filter; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx new file mode 100644 index 0000000000000..ebc9e8982c670 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx @@ -0,0 +1,84 @@ +import React, { useMemo, useState } from 'react'; +import { IconButton, Menu } from '@mui/material'; +import { ReactComponent as MoreSvg } from '$app/assets/details.svg'; +import { Filter } from '$app/application/database'; +import { useTranslation } from 'react-i18next'; +import { deleteFilter } from '$app/application/database/filter/filter_service'; +import { useViewId } from '$app/hooks'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; + +function FilterActions({ filter }: { filter: Filter }) { + const viewId = useViewId(); + const { t } = useTranslation(); + const [disableSelect, setDisableSelect] = useState(true); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const onClose = () => { + setDisableSelect(true); + setAnchorEl(null); + }; + + const onDelete = async () => { + try { + await deleteFilter(viewId, filter); + } catch (e) { + // toast.error(e.message); + } + + setDisableSelect(true); + }; + + const options: KeyboardNavigationOption[] = useMemo( + () => [ + { + key: 'delete', + content: t('grid.settings.deleteFilter'), + }, + ], + [t] + ); + + return ( + <> + { + setAnchorEl(e.currentTarget); + }} + className={'mx-2 my-1.5'} + > + + + {open && ( + { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }} + keepMounted={false} + open={open} + anchorEl={anchorEl} + onClose={onClose} + > + { + if (e.key === 'ArrowDown') { + setDisableSelect(false); + } + }} + disableSelect={disableSelect} + options={options} + onConfirm={onDelete} + onEscape={onClose} + /> + + )} + + ); +} + +export default FilterActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterConditionSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterConditionSelect.tsx new file mode 100644 index 0000000000000..8b793942daa5f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterConditionSelect.tsx @@ -0,0 +1,224 @@ +import React, { useMemo } from 'react'; +import ConditionSelect from './ConditionSelect'; +import { + CheckboxFilterConditionPB, + ChecklistFilterConditionPB, + DateFilterConditionPB, + FieldType, + NumberFilterConditionPB, + SelectOptionFilterConditionPB, + TextFilterConditionPB, +} from '@/services/backend'; + +import { useTranslation } from 'react-i18next'; + +function FilterConditionSelect({ + name, + condition, + fieldType, + onChange, +}: { + name: string; + condition: number; + fieldType: FieldType; + onChange: (condition: number) => void; +}) { + const { t } = useTranslation(); + const conditions = useMemo(() => { + switch (fieldType) { + case FieldType.RichText: + case FieldType.URL: + return [ + { + value: TextFilterConditionPB.TextContains, + text: t('grid.textFilter.contains'), + }, + { + value: TextFilterConditionPB.TextDoesNotContain, + text: t('grid.textFilter.doesNotContain'), + }, + { + value: TextFilterConditionPB.TextStartsWith, + text: t('grid.textFilter.startWith'), + }, + { + value: TextFilterConditionPB.TextEndsWith, + text: t('grid.textFilter.endsWith'), + }, + { + value: TextFilterConditionPB.TextIs, + text: t('grid.textFilter.is'), + }, + { + value: TextFilterConditionPB.TextIsNot, + text: t('grid.textFilter.isNot'), + }, + { + value: TextFilterConditionPB.TextIsEmpty, + text: t('grid.textFilter.isEmpty'), + }, + { + value: TextFilterConditionPB.TextIsNotEmpty, + text: t('grid.textFilter.isNotEmpty'), + }, + ]; + case FieldType.SingleSelect: + return [ + { + value: SelectOptionFilterConditionPB.OptionIs, + text: t('grid.selectOptionFilter.is'), + }, + { + value: SelectOptionFilterConditionPB.OptionIsNot, + text: t('grid.selectOptionFilter.isNot'), + }, + { + value: SelectOptionFilterConditionPB.OptionIsEmpty, + text: t('grid.selectOptionFilter.isEmpty'), + }, + { + value: SelectOptionFilterConditionPB.OptionIsNotEmpty, + text: t('grid.selectOptionFilter.isNotEmpty'), + }, + ]; + case FieldType.MultiSelect: + return [ + { + value: SelectOptionFilterConditionPB.OptionIs, + text: t('grid.selectOptionFilter.is'), + }, + { + value: SelectOptionFilterConditionPB.OptionIsNot, + text: t('grid.selectOptionFilter.isNot'), + }, + { + value: SelectOptionFilterConditionPB.OptionContains, + text: t('grid.selectOptionFilter.contains'), + }, + { + value: SelectOptionFilterConditionPB.OptionDoesNotContain, + text: t('grid.selectOptionFilter.doesNotContain'), + }, + { + value: SelectOptionFilterConditionPB.OptionIsEmpty, + text: t('grid.selectOptionFilter.isEmpty'), + }, + { + value: SelectOptionFilterConditionPB.OptionIsNotEmpty, + text: t('grid.selectOptionFilter.isNotEmpty'), + }, + ]; + case FieldType.Number: + return [ + { + value: NumberFilterConditionPB.Equal, + text: '=', + }, + { + value: NumberFilterConditionPB.NotEqual, + text: '!=', + }, + { + value: NumberFilterConditionPB.GreaterThan, + text: '>', + }, + { + value: NumberFilterConditionPB.LessThan, + text: '<', + }, + { + value: NumberFilterConditionPB.GreaterThanOrEqualTo, + text: '>=', + }, + { + value: NumberFilterConditionPB.LessThanOrEqualTo, + text: '<=', + }, + { + value: NumberFilterConditionPB.NumberIsEmpty, + text: t('grid.textFilter.isEmpty'), + }, + { + value: NumberFilterConditionPB.NumberIsNotEmpty, + text: t('grid.textFilter.isNotEmpty'), + }, + ]; + case FieldType.Checkbox: + return [ + { + value: CheckboxFilterConditionPB.IsChecked, + text: t('grid.checkboxFilter.isChecked'), + }, + { + value: CheckboxFilterConditionPB.IsUnChecked, + text: t('grid.checkboxFilter.isUnchecked'), + }, + ]; + case FieldType.Checklist: + return [ + { + value: ChecklistFilterConditionPB.IsComplete, + text: t('grid.checklistFilter.isComplete'), + }, + { + value: ChecklistFilterConditionPB.IsIncomplete, + text: t('grid.checklistFilter.isIncomplted'), + }, + ]; + case FieldType.DateTime: + case FieldType.LastEditedTime: + case FieldType.CreatedTime: + return [ + { + value: DateFilterConditionPB.DateIs, + text: t('grid.dateFilter.is'), + }, + { + value: DateFilterConditionPB.DateBefore, + text: t('grid.dateFilter.before'), + }, + { + value: DateFilterConditionPB.DateAfter, + text: t('grid.dateFilter.after'), + }, + { + value: DateFilterConditionPB.DateOnOrBefore, + text: t('grid.dateFilter.onOrBefore'), + }, + { + value: DateFilterConditionPB.DateOnOrAfter, + text: t('grid.dateFilter.onOrAfter'), + }, + { + value: DateFilterConditionPB.DateWithIn, + text: t('grid.dateFilter.between'), + }, + { + value: DateFilterConditionPB.DateIsEmpty, + text: t('grid.dateFilter.empty'), + }, + { + value: DateFilterConditionPB.DateIsNotEmpty, + text: t('grid.dateFilter.notEmpty'), + }, + ]; + default: + return []; + } + }, [fieldType, t]); + + return ( +
+
{name}
+ { + onChange(e); + }} + value={condition} + /> +
+ ); +} + +export default FilterConditionSelect; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx new file mode 100644 index 0000000000000..e161badbf8e05 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx @@ -0,0 +1,59 @@ +import React, { useCallback } from 'react'; +import { MenuProps } from '@mui/material'; +import PropertiesList from '$app/components/database/components/property/PropertiesList'; +import { Field } from '$app/application/database'; +import { useViewId } from '$app/hooks'; +import { useTranslation } from 'react-i18next'; +import { insertFilter } from '$app/application/database/filter/filter_service'; +import { getDefaultFilter } from '$app/application/database/filter/filter_data'; +import Popover from '@mui/material/Popover'; + +function FilterFieldsMenu({ + onInserted, + ...props +}: MenuProps & { + onInserted?: () => void; +}) { + const viewId = useViewId(); + const { t } = useTranslation(); + + const addFilter = useCallback( + async (field: Field) => { + const filterData = getDefaultFilter(field.type); + + await insertFilter({ + viewId, + fieldId: field.id, + fieldType: field.type, + data: filterData, + }); + props.onClose?.({}, 'backdropClick'); + onInserted?.(); + }, + [props, viewId, onInserted] + ); + + return ( + { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} + {...props} + > + { + props.onClose?.({}, 'escapeKeyDown'); + }} + showSearch + searchPlaceholder={t('grid.settings.filterBy')} + onItemClick={addFilter} + /> + + ); +} + +export default FilterFieldsMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx new file mode 100644 index 0000000000000..860ce9f69f566 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx @@ -0,0 +1,51 @@ +import React, { useMemo, useState } from 'react'; +import Filter from '$app/components/database/components/filter/Filter'; +import Button from '@mui/material/Button'; +import FilterFieldsMenu from '$app/components/database/components/filter/FilterFieldsMenu'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { useDatabase } from '$app/components/database'; + +function Filters() { + const { t } = useTranslation(); + const { filters, fields } = useDatabase(); + + const options = useMemo(() => { + return filters.map((filter) => { + const field = fields.find((field) => field.id === filter.fieldId); + + return { + filter, + field, + }; + }); + }, [filters, fields]); + + const [filterAnchorEl, setFilterAnchorEl] = useState(null); + const openAddFilterMenu = Boolean(filterAnchorEl); + + const handleClick = (e: React.MouseEvent) => { + setFilterAnchorEl(e.currentTarget); + }; + + return ( +
+ {options.map(({ filter, field }) => (field ? : null))} + + setFilterAnchorEl(null)} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + /> +
+ ); +} + +export default Filters; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/date_filter/DateFilter.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/date_filter/DateFilter.tsx new file mode 100644 index 0000000000000..5c96d42b96544 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/date_filter/DateFilter.tsx @@ -0,0 +1,94 @@ +import React, { useMemo } from 'react'; +import { + DateFilter as DateFilterType, + DateFilterData, + DateTimeField, + DateTimeTypeOption, +} from '$app/application/database'; +import { DateFilterConditionPB } from '@/services/backend'; +import CustomCalendar from '$app/components/database/components/field_types/date/CustomCalendar'; +import DateTimeSet from '$app/components/database/components/field_types/date/DateTimeSet'; +import { useTypeOption } from '$app/components/database'; +import { getDateFormat, getTimeFormat } from '$app/components/database/components/field_types/date/utils'; + +interface Props { + filter: DateFilterType; + field: DateTimeField; + onChange: (filterData: DateFilterData) => void; +} + +function DateFilter({ filter, field, onChange }: Props) { + const typeOption = useTypeOption(field.id); + + const showCalendar = + filter.data.condition !== DateFilterConditionPB.DateIsEmpty && + filter.data.condition !== DateFilterConditionPB.DateIsNotEmpty; + + const condition = filter.data.condition; + const isRange = condition === DateFilterConditionPB.DateWithIn; + const timestamp = useMemo(() => { + if (isRange) { + return filter.data.start; + } + + return filter.data.timestamp; + }, [filter.data.start, filter.data.timestamp, isRange]); + + const endTimestamp = useMemo(() => { + if (isRange) { + return filter.data.end; + } + + return; + }, [filter.data.end, isRange]); + + const timeFormat = useMemo(() => { + return getTimeFormat(typeOption.timeFormat); + }, [typeOption.timeFormat]); + + const dateFormat = useMemo(() => { + return getDateFormat(typeOption.dateFormat); + }, [typeOption.dateFormat]); + + return ( +
+ {showCalendar && ( + <> +
+ { + onChange({ + condition, + timestamp: date, + start: endDate ? date : undefined, + end: endDate, + }); + }} + date={timestamp} + endDate={endTimestamp} + timeFormat={timeFormat} + dateFormat={dateFormat} + includeTime={false} + isRange={isRange} + /> +
+ { + onChange({ + condition, + timestamp: date, + start: endDate ? date : undefined, + end: endDate, + }); + }} + isRange={isRange} + timestamp={timestamp} + endTimestamp={endTimestamp} + /> + + )} +
+ ); +} + +export default DateFilter; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/date_filter/DateFilterValue.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/date_filter/DateFilterValue.tsx new file mode 100644 index 0000000000000..dd75d25852f16 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/date_filter/DateFilterValue.tsx @@ -0,0 +1,52 @@ +import React, { useMemo } from 'react'; +import { DateFilterData } from '$app/application/database'; +import { useTranslation } from 'react-i18next'; +import dayjs from 'dayjs'; +import { DateFilterConditionPB } from '@/services/backend'; + +function DateFilterValue({ data }: { data: DateFilterData }) { + const { t } = useTranslation(); + + const value = useMemo(() => { + if (!data.timestamp) return ''; + + let startStr = ''; + let endStr = ''; + + if (data.start) { + const end = data.end ?? data.start; + const moreThanOneYear = dayjs.unix(end).diff(dayjs.unix(data.start), 'year') > 1; + const format = moreThanOneYear ? 'MMM D, YYYY' : 'MMM D'; + + startStr = dayjs.unix(data.start).format(format); + endStr = dayjs.unix(end).format(format); + } + + const timestamp = dayjs.unix(data.timestamp).format('MMM D'); + + switch (data.condition) { + case DateFilterConditionPB.DateIs: + return `: ${timestamp}`; + case DateFilterConditionPB.DateBefore: + return `: ${t('grid.dateFilter.choicechipPrefix.before')} ${timestamp}`; + case DateFilterConditionPB.DateAfter: + return `: ${t('grid.dateFilter.choicechipPrefix.after')} ${timestamp}`; + case DateFilterConditionPB.DateOnOrBefore: + return `: ${t('grid.dateFilter.choicechipPrefix.onOrBefore')} ${timestamp}`; + case DateFilterConditionPB.DateOnOrAfter: + return `: ${t('grid.dateFilter.choicechipPrefix.onOrAfter')} ${timestamp}`; + case DateFilterConditionPB.DateWithIn: + return `: ${startStr} - ${endStr}`; + case DateFilterConditionPB.DateIsEmpty: + return `: ${t('grid.dateFilter.choicechipPrefix.isEmpty')}`; + case DateFilterConditionPB.DateIsNotEmpty: + return `: ${t('grid.dateFilter.choicechipPrefix.isNotEmpty')}`; + default: + return ''; + } + }, [data, t]); + + return <>{value}; +} + +export default DateFilterValue; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/number_filter/NumberFilterValue.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/number_filter/NumberFilterValue.tsx new file mode 100644 index 0000000000000..658ef13d69817 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/number_filter/NumberFilterValue.tsx @@ -0,0 +1,39 @@ +import React, { useMemo } from 'react'; +import { NumberFilterData } from '$app/application/database'; +import { NumberFilterConditionPB } from '@/services/backend'; +import { useTranslation } from 'react-i18next'; + +function NumberFilterValue({ data }: { data: NumberFilterData }) { + const { t } = useTranslation(); + + const value = useMemo(() => { + if (!data.content) { + return ''; + } + + const content = parseInt(data.content); + + switch (data.condition) { + case NumberFilterConditionPB.Equal: + return `= ${content}`; + case NumberFilterConditionPB.NotEqual: + return `!= ${content}`; + case NumberFilterConditionPB.GreaterThan: + return `> ${content}`; + case NumberFilterConditionPB.GreaterThanOrEqualTo: + return `>= ${content}`; + case NumberFilterConditionPB.LessThan: + return `< ${content}`; + case NumberFilterConditionPB.LessThanOrEqualTo: + return `<= ${content}`; + case NumberFilterConditionPB.NumberIsEmpty: + return t('grid.textFilter.isEmpty'); + case NumberFilterConditionPB.NumberIsNotEmpty: + return t('grid.textFilter.isNotEmpty'); + } + }, [data.condition, data.content, t]); + + return <>{value}; +} + +export default NumberFilterValue; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilter.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilter.tsx new file mode 100644 index 0000000000000..bd1d1f239adbc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilter.tsx @@ -0,0 +1,92 @@ +import React, { useMemo, useRef } from 'react'; +import { + SelectField, + SelectFilter as SelectFilterType, + SelectFilterData, + SelectTypeOption, +} from '$app/application/database'; +import { Tag } from '$app/components/database/components/field_types/select/Tag'; +import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; +import { SelectOptionFilterConditionPB } from '@/services/backend'; +import { useTypeOption } from '$app/components/database'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; + +interface Props { + filter: SelectFilterType; + field: SelectField; + onChange: (filterData: SelectFilterData) => void; + onClose?: () => void; +} + +function SelectFilter({ onClose, filter, field, onChange }: Props) { + const scrollRef = useRef(null); + const condition = filter.data.condition; + const typeOption = useTypeOption(field.id); + const options: KeyboardNavigationOption[] = useMemo(() => { + return ( + typeOption?.options?.map((option) => { + return { + key: option.id, + content: ( +
+ + {filter.data.optionIds?.includes(option.id) && } +
+ ), + }; + }) ?? [] + ); + }, [filter.data.optionIds, typeOption?.options]); + + const showOptions = + options.length > 0 && + condition !== SelectOptionFilterConditionPB.OptionIsEmpty && + condition !== SelectOptionFilterConditionPB.OptionIsNotEmpty; + + const handleChange = ({ + condition, + optionIds, + }: { + condition?: SelectFilterData['condition']; + optionIds?: SelectFilterData['optionIds']; + }) => { + onChange({ + condition: condition ?? filter.data.condition, + optionIds: optionIds ?? filter.data.optionIds, + }); + }; + + const handleSelectOption = (optionId: string) => { + const prev = filter.data.optionIds; + let newOptionIds = []; + + if (!prev) { + newOptionIds.push(optionId); + } else { + const isSelected = prev.includes(optionId); + + if (isSelected) { + newOptionIds = prev.filter((id) => id !== optionId); + } else { + newOptionIds = [...prev, optionId]; + } + } + + handleChange({ + condition, + optionIds: newOptionIds, + }); + }; + + if (!showOptions) return null; + + return ( +
+ +
+ ); +} + +export default SelectFilter; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilterValue.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilterValue.tsx new file mode 100644 index 0000000000000..72576deae1c99 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/select_filter/SelectFilterValue.tsx @@ -0,0 +1,38 @@ +import React, { useMemo } from 'react'; +import { SelectFilterData, SelectTypeOption } from '$app/application/database'; +import { useStaticTypeOption } from '$app/components/database'; +import { useTranslation } from 'react-i18next'; +import { SelectOptionFilterConditionPB } from '@/services/backend'; + +function SelectFilterValue({ data, fieldId }: { data: SelectFilterData; fieldId: string }) { + const typeOption = useStaticTypeOption(fieldId); + const { t } = useTranslation(); + const value = useMemo(() => { + if (!data.optionIds?.length) return ''; + + const options = data.optionIds + .map((optionId) => { + const option = typeOption?.options?.find((option) => option.id === optionId); + + return option?.name; + }) + .join(', '); + + switch (data.condition) { + case SelectOptionFilterConditionPB.OptionIs: + return `: ${options}`; + case SelectOptionFilterConditionPB.OptionIsNot: + return `: ${t('grid.textFilter.choicechipPrefix.isNot')} ${options}`; + case SelectOptionFilterConditionPB.OptionIsEmpty: + return `: ${t('grid.textFilter.choicechipPrefix.isEmpty')}`; + case SelectOptionFilterConditionPB.OptionIsNotEmpty: + return `: ${t('grid.textFilter.choicechipPrefix.isNotEmpty')}`; + default: + return ''; + } + }, [data.condition, data.optionIds, t, typeOption?.options]); + + return <>{value}; +} + +export default SelectFilterValue; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilter.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilter.tsx new file mode 100644 index 0000000000000..0c7eab6e05c85 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilter.tsx @@ -0,0 +1,50 @@ +import React, { useMemo, useState } from 'react'; +import { TextFilter as TextFilterType, TextFilterData } from '$app/application/database'; +import { TextField } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { TextFilterConditionPB } from '@/services/backend'; +import debounce from 'lodash-es/debounce'; + +interface Props { + filter: TextFilterType; + onChange: (filterData: TextFilterData) => void; +} + +const DELAY = 500; + +function TextFilter({ filter, onChange }: Props) { + const { t } = useTranslation(); + const [content, setContext] = useState(filter.data.content); + const condition = filter.data.condition; + const showField = + condition !== TextFilterConditionPB.TextIsEmpty && condition !== TextFilterConditionPB.TextIsNotEmpty; + + const onConditionChange = useMemo(() => { + return debounce((content: string) => { + onChange({ + content, + condition, + }); + }, DELAY); + }, [condition, onChange]); + + if (!showField) return null; + return ( + { + setContext(e.target.value); + onConditionChange(e.target.value ?? ''); + }} + /> + ); +} + +export default TextFilter; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilterValue.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilterValue.tsx new file mode 100644 index 0000000000000..5718a3e2b8be2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilterValue.tsx @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; +import { TextFilterData } from '$app/application/database'; +import { TextFilterConditionPB } from '@/services/backend'; +import { useTranslation } from 'react-i18next'; + +function TextFilterValue({ data }: { data: TextFilterData }) { + const { t } = useTranslation(); + + const value = useMemo(() => { + if (!data.content) return ''; + switch (data.condition) { + case TextFilterConditionPB.TextContains: + case TextFilterConditionPB.TextIs: + return `: ${data.content}`; + case TextFilterConditionPB.TextDoesNotContain: + case TextFilterConditionPB.TextIsNot: + return `: ${t('grid.textFilter.choicechipPrefix.isNot')} ${data.content}`; + case TextFilterConditionPB.TextStartsWith: + return `: ${t('grid.textFilter.choicechipPrefix.startWith')} ${data.content}`; + case TextFilterConditionPB.TextEndsWith: + return `: ${t('grid.textFilter.choicechipPrefix.endWith')} ${data.content}`; + case TextFilterConditionPB.TextIsEmpty: + return `: ${t('grid.textFilter.choicechipPrefix.isEmpty')}`; + case TextFilterConditionPB.TextIsNotEmpty: + return `: ${t('grid.textFilter.choicechipPrefix.isNotEmpty')}`; + default: + return ''; + } + }, [t, data]); + + return <>{value}; +} + +export default TextFilterValue; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/index.ts new file mode 100644 index 0000000000000..7da8d0eab4b1c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/index.ts @@ -0,0 +1,2 @@ +export * from './tab_bar'; +export * from './cell'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/NewProperty.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/NewProperty.tsx new file mode 100644 index 0000000000000..bb71befa8d0e2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/NewProperty.tsx @@ -0,0 +1,41 @@ +import React, { useCallback } from 'react'; +import { fieldService } from '$app/application/database'; +import { FieldType } from '@/services/backend'; +import { useTranslation } from 'react-i18next'; +import Button from '@mui/material/Button'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { useViewId } from '$app/hooks'; + +interface NewPropertyProps { + onInserted?: (id: string) => void; +} +function NewProperty({ onInserted }: NewPropertyProps) { + const viewId = useViewId(); + const { t } = useTranslation(); + + const handleClick = useCallback(async () => { + try { + const field = await fieldService.createField({ + viewId, + fieldType: FieldType.RichText, + }); + + onInserted?.(field.id); + } catch (e) { + // toast.error(t('grid.field.newPropertyFail')); + } + }, [onInserted, viewId]); + + return ( + + ); +} + +export default NewProperty; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertiesList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertiesList.tsx new file mode 100644 index 0000000000000..a9865c467f4b6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertiesList.tsx @@ -0,0 +1,92 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { OutlinedInput } from '@mui/material'; +import { Property } from '$app/components/database/components/property/Property'; +import { Field as FieldType } from '$app/application/database'; +import { useDatabase } from '$app/components/database'; +import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; + +interface FieldListProps { + searchPlaceholder?: string; + showSearch?: boolean; + onItemClick?: (field: FieldType) => void; + onClose?: () => void; +} + +function PropertiesList({ onClose, showSearch, onItemClick, searchPlaceholder }: FieldListProps) { + const { fields } = useDatabase(); + const [fieldsResult, setFieldsResult] = useState(fields as FieldType[]); + + const onInputChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + const result = fields.filter((field) => field.name.toLowerCase().includes(value.toLowerCase())); + + setFieldsResult(result); + }, + [fields] + ); + + const inputRef = useRef(null); + + const searchInput = useMemo(() => { + return showSearch ? ( +
+ +
+ ) : null; + }, [onInputChange, searchPlaceholder, showSearch]); + + const scrollRef = useRef(null); + + const options = useMemo(() => { + return fieldsResult.map((field) => { + return { + key: field.id, + content: ( +
+ +
+ ), + }; + }); + }, [fieldsResult]); + + const onConfirm = useCallback( + (key: string) => { + const field = fields.find((field) => field.id === key); + + onItemClick?.(field as FieldType); + }, + [fields, onItemClick] + ); + + return ( +
+ {searchInput} +
+ +
+
+ ); +} + +export default PropertiesList; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/Property.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/Property.tsx new file mode 100644 index 0000000000000..3091ba4ea169c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/Property.tsx @@ -0,0 +1,95 @@ +import { FC, useEffect, useRef, useState } from 'react'; +import { Field as FieldType } from '$app/application/database'; +import { ProppertyTypeSvg } from './property_type/ProppertyTypeSvg'; +import { PropertyMenu } from '$app/components/database/components/property/PropertyMenu'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; + +export interface FieldProps { + field: FieldType; + menuOpened?: boolean; + onOpenMenu?: (id: string) => void; + onCloseMenu?: (id: string) => void; + className?: string; +} + +const initialAnchorOrigin: PopoverOrigin = { + vertical: 'bottom', + horizontal: 'right', +}; + +const initialTransformOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'center', +}; + +export const Property: FC = ({ field, onCloseMenu, className, menuOpened }) => { + const ref = useRef(null); + const [anchorPosition, setAnchorPosition] = useState< + | { + top: number; + left: number; + height: number; + } + | undefined + >(undefined); + + const open = Boolean(anchorPosition && menuOpened); + + useEffect(() => { + if (menuOpened) { + const rect = ref.current?.getBoundingClientRect(); + + if (rect) { + setAnchorPosition({ + top: rect.top + 28, + left: rect.left, + height: rect.height, + }); + return; + } + } + + setAnchorPosition(undefined); + }, [menuOpened]); + + const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ + initialPaperWidth: 300, + initialPaperHeight: 400, + anchorPosition, + initialAnchorOrigin, + initialTransformOrigin, + open, + }); + + return ( + <> +
+ + {field.name} +
+ + {open && ( + { + onCloseMenu?.(field.id); + }} + transformOrigin={transformOrigin} + anchorOrigin={anchorOrigin} + PaperProps={{ + style: { + maxHeight: paperHeight, + width: paperWidth, + height: 'auto', + }, + className: 'flex h-full flex-col overflow-hidden', + }} + anchorPosition={anchorPosition} + anchorReference={'anchorPosition'} + /> + )} + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyActions.tsx new file mode 100644 index 0000000000000..b3199409962e7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyActions.tsx @@ -0,0 +1,271 @@ +import React, { RefObject, useCallback, useMemo, useState } from 'react'; + +import { ReactComponent as EditSvg } from '$app/assets/edit.svg'; +import { ReactComponent as HideSvg } from '$app/assets/hide.svg'; +import { ReactComponent as ShowSvg } from '$app/assets/eye_open.svg'; + +import { ReactComponent as CopySvg } from '$app/assets/copy.svg'; +import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg'; +import { ReactComponent as LeftSvg } from '$app/assets/left.svg'; +import { ReactComponent as RightSvg } from '$app/assets/right.svg'; +import { useViewId } from '$app/hooks'; +import { fieldService } from '$app/application/database'; +import { OrderObjectPositionTypePB, FieldVisibility } from '@/services/backend'; +import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog'; +import { useTranslation } from 'react-i18next'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { notify } from 'src/appflowy_app/components/_shared/notify'; + +export enum FieldAction { + EditProperty, + Hide, + Show, + Duplicate, + Delete, + InsertLeft, + InsertRight, +} + +const FieldActionSvgMap = { + [FieldAction.EditProperty]: EditSvg, + [FieldAction.Hide]: HideSvg, + [FieldAction.Show]: ShowSvg, + [FieldAction.Duplicate]: CopySvg, + [FieldAction.Delete]: DeleteSvg, + [FieldAction.InsertLeft]: LeftSvg, + [FieldAction.InsertRight]: RightSvg, +}; + +const defaultActions: FieldAction[] = [ + FieldAction.EditProperty, + FieldAction.InsertLeft, + FieldAction.InsertRight, + FieldAction.Hide, + FieldAction.Duplicate, + FieldAction.Delete, +]; + +// prevent default actions for primary fields +const primaryPreventDefaultActions = [FieldAction.Hide, FieldAction.Delete, FieldAction.Duplicate]; + +interface PropertyActionsProps { + fieldId: string; + actions?: FieldAction[]; + isPrimary?: boolean; + inputRef?: RefObject; + onClose?: () => void; + onMenuItemClick?: (action: FieldAction, newFieldId?: string) => void; +} + +function PropertyActions({ + onClose, + inputRef, + fieldId, + onMenuItemClick, + isPrimary, + actions = defaultActions, +}: PropertyActionsProps) { + const viewId = useViewId(); + const { t } = useTranslation(); + const [openConfirm, setOpenConfirm] = useState(false); + const [focusMenu, setFocusMenu] = useState(false); + const menuTextMap = useMemo( + () => ({ + [FieldAction.EditProperty]: t('grid.field.editProperty'), + [FieldAction.Hide]: t('grid.field.hide'), + [FieldAction.Show]: t('grid.field.show'), + [FieldAction.Duplicate]: t('grid.field.duplicate'), + [FieldAction.Delete]: t('grid.field.delete'), + [FieldAction.InsertLeft]: t('grid.field.insertLeft'), + [FieldAction.InsertRight]: t('grid.field.insertRight'), + }), + [t] + ); + + const handleOpenConfirm = () => { + setOpenConfirm(true); + }; + + const handleMenuItemClick = async (action: FieldAction) => { + const preventDefault = isPrimary && primaryPreventDefaultActions.includes(action); + + if (preventDefault) { + return; + } + + switch (action) { + case FieldAction.EditProperty: + break; + case FieldAction.InsertLeft: + case FieldAction.InsertRight: { + const fieldPosition = + action === FieldAction.InsertLeft ? OrderObjectPositionTypePB.Before : OrderObjectPositionTypePB.After; + + const field = await fieldService.createField({ + viewId, + fieldPosition, + targetFieldId: fieldId, + }); + + onMenuItemClick?.(action, field.id); + return; + } + + case FieldAction.Hide: + await fieldService.updateFieldSetting(viewId, fieldId, { + visibility: FieldVisibility.AlwaysHidden, + }); + break; + case FieldAction.Show: + await fieldService.updateFieldSetting(viewId, fieldId, { + visibility: FieldVisibility.AlwaysShown, + }); + break; + case FieldAction.Duplicate: + await fieldService.duplicateField(viewId, fieldId); + break; + case FieldAction.Delete: + handleOpenConfirm(); + return; + } + + onMenuItemClick?.(action); + }; + + const renderActionContent = useCallback((item: { text: string; Icon: React.FC> }) => { + const { Icon, text } = item; + + return ( +
+ +
{text}
+
+ ); + }, []); + + const options: KeyboardNavigationOption[] = useMemo( + () => + [ + { + key: FieldAction.EditProperty, + content: renderActionContent({ + text: menuTextMap[FieldAction.EditProperty], + Icon: FieldActionSvgMap[FieldAction.EditProperty], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.EditProperty), + }, + { + key: FieldAction.InsertLeft, + content: renderActionContent({ + text: menuTextMap[FieldAction.InsertLeft], + Icon: FieldActionSvgMap[FieldAction.InsertLeft], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.InsertLeft), + }, + { + key: FieldAction.InsertRight, + content: renderActionContent({ + text: menuTextMap[FieldAction.InsertRight], + Icon: FieldActionSvgMap[FieldAction.InsertRight], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.InsertRight), + }, + { + key: FieldAction.Hide, + content: renderActionContent({ + text: menuTextMap[FieldAction.Hide], + Icon: FieldActionSvgMap[FieldAction.Hide], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.Hide), + }, + { + key: FieldAction.Show, + content: renderActionContent({ + text: menuTextMap[FieldAction.Show], + Icon: FieldActionSvgMap[FieldAction.Show], + }), + }, + { + key: FieldAction.Duplicate, + content: renderActionContent({ + text: menuTextMap[FieldAction.Duplicate], + Icon: FieldActionSvgMap[FieldAction.Duplicate], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.Duplicate), + }, + { + key: FieldAction.Delete, + content: renderActionContent({ + text: menuTextMap[FieldAction.Delete], + Icon: FieldActionSvgMap[FieldAction.Delete], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.Delete), + }, + ].filter((option) => actions.includes(option.key)), + [renderActionContent, menuTextMap, isPrimary, actions] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const isTab = e.key === 'Tab'; + + if (!focusMenu && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + e.stopPropagation(); + notify.clear(); + notify.info(`Press Tab to focus on the menu`); + return; + } + + if (isTab) { + e.preventDefault(); + e.stopPropagation(); + if (focusMenu) { + inputRef?.current?.focus(); + setFocusMenu(false); + } else { + inputRef?.current?.blur(); + setFocusMenu(true); + } + + return; + } + }, + [focusMenu, inputRef] + ); + + return ( + <> + { + setFocusMenu(true); + }} + onBlur={() => { + setFocusMenu(false); + }} + onKeyDown={handleKeyDown} + onConfirm={handleMenuItemClick} + /> + { + await fieldService.deleteField(viewId, fieldId); + }} + onClose={() => { + setOpenConfirm(false); + onClose?.(); + }} + /> + + ); +} + +export default PropertyActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyMenu.tsx new file mode 100644 index 0000000000000..55b314b821692 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyMenu.tsx @@ -0,0 +1,89 @@ +import { Divider } from '@mui/material'; +import { FC, useCallback, useMemo, useRef } from 'react'; +import { useViewId } from '$app/hooks'; +import { Field, fieldService } from '$app/application/database'; +import PropertyTypeMenuExtension from '$app/components/database/components/property/property_type/PropertyTypeMenuExtension'; +import PropertyTypeSelect from '$app/components/database/components/property/property_type/PropertyTypeSelect'; +import { FieldType, FieldVisibility } from '@/services/backend'; +import { Log } from '$app/utils/log'; +import Popover, { PopoverProps } from '@mui/material/Popover'; +import PropertyNameInput from '$app/components/database/components/property/PropertyNameInput'; +import PropertyActions, { FieldAction } from '$app/components/database/components/property/PropertyActions'; + +export interface GridFieldMenuProps extends PopoverProps { + field: Field; +} + +export const PropertyMenu: FC = ({ field, ...props }) => { + const viewId = useViewId(); + const inputRef = useRef(null); + + const isPrimary = field.isPrimary; + const actions = useMemo(() => { + const keys = [FieldAction.Duplicate, FieldAction.Delete]; + + if (field.visibility === FieldVisibility.AlwaysHidden) { + keys.unshift(FieldAction.Show); + } else { + keys.unshift(FieldAction.Hide); + } + + return keys; + }, [field.visibility]); + + const onUpdateFieldType = useCallback( + async (type: FieldType) => { + try { + await fieldService.updateFieldType(viewId, field.id, type); + } catch (e) { + // TODO + Log.error(`change field ${field.id} type from '${field.type}' to ${type} fail`, e); + } + }, + [viewId, field] + ); + + return ( + e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} + onMouseDown={(e) => { + const isInput = inputRef.current?.contains(e.target as Node); + + if (isInput) return; + + e.stopPropagation(); + e.preventDefault(); + }} + {...props} + > + +
+ {!isPrimary && ( +
+ + +
+ )} + + props.onClose?.({}, 'backdropClick')} + isPrimary={isPrimary} + actions={actions} + onMenuItemClick={() => { + props.onClose?.({}, 'backdropClick'); + }} + fieldId={field.id} + /> +
+
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyNameInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyNameInput.tsx new file mode 100644 index 0000000000000..4e20531335234 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyNameInput.tsx @@ -0,0 +1,49 @@ +import React, { ChangeEventHandler, useCallback, useMemo, useState } from 'react'; +import { useViewId } from '$app/hooks'; +import { fieldService } from '$app/application/database'; +import { Log } from '$app/utils/log'; +import TextField from '@mui/material/TextField'; +import debounce from 'lodash-es/debounce'; + +const PropertyNameInput = React.forwardRef(({ id, name }, ref) => { + const viewId = useViewId(); + const [inputtingName, setInputtingName] = useState(name); + + const handleSubmit = useCallback( + async (newName: string) => { + if (newName !== name) { + try { + await fieldService.updateField(viewId, id, { + name: newName, + }); + } catch (e) { + // TODO + Log.error(`change field ${id} name from '${name}' to ${newName} fail`, e); + } + } + }, + [viewId, id, name] + ); + + const debouncedHandleSubmit = useMemo(() => debounce(handleSubmit, 500), [handleSubmit]); + const handleInput = useCallback>( + (e) => { + setInputtingName(e.target.value); + void debouncedHandleSubmit(e.target.value); + }, + [debouncedHandleSubmit] + ); + + return ( + + ); +}); + +export default PropertyNameInput; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertySelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertySelect.tsx new file mode 100644 index 0000000000000..0741bbc05b3be --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertySelect.tsx @@ -0,0 +1,84 @@ +import { FC, useCallback, useMemo, useRef, useState } from 'react'; +import { Field as FieldType } from '$app/application/database'; +import { useDatabase } from '../../Database.hooks'; +import { Property } from './Property'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { ReactComponent as DropDownSvg } from '$app/assets/more.svg'; +import Popover from '@mui/material/Popover'; + +export interface FieldSelectProps { + onChange?: (field: FieldType | undefined) => void; + value?: string; +} + +export const PropertySelect: FC = ({ value, onChange }) => { + const { fields } = useDatabase(); + + const scrollRef = useRef(null); + const ref = useRef(null); + const [open, setOpen] = useState(false); + const handleClose = () => { + setOpen(false); + }; + + const options: KeyboardNavigationOption[] = useMemo( + () => + fields.map((field) => { + return { + key: field.id, + content: , + }; + }), + [fields] + ); + + const onConfirm = useCallback( + (optionKey: string) => { + onChange?.(fields.find((field) => field.id === optionKey)); + }, + [onChange, fields] + ); + + const selectedField = useMemo(() => fields.find((field) => field.id === value), [fields, value]); + + return ( + <> +
{ + setOpen(true); + }} + > +
{selectedField ? : null}
+ +
+ {open && ( + +
+ +
+
+ )} + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/index.ts new file mode 100644 index 0000000000000..0b338836d6ba7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/index.ts @@ -0,0 +1,4 @@ +export * from './Property'; +export * from './PropertySelect'; +export * from './property_type/PropertyTypeText'; +export * from './property_type/ProppertyTypeSvg'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenu.tsx new file mode 100644 index 0000000000000..e3021249ee61b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenu.tsx @@ -0,0 +1,121 @@ +import { Menu, MenuProps } from '@mui/material'; +import { FC, useCallback, useMemo } from 'react'; +import { FieldType } from '@/services/backend'; +import { PropertyTypeText, ProppertyTypeSvg } from '$app/components/database/components/property'; +import { Field } from '$app/application/database'; +import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import Typography from '@mui/material/Typography'; + +export const PropertyTypeMenu: FC< + MenuProps & { + field: Field; + onClickItem?: (type: FieldType) => void; + } +> = ({ field, onClickItem, ...props }) => { + const PopoverClasses = useMemo( + () => ({ + ...props.PopoverClasses, + paper: ['w-56', props.PopoverClasses?.paper].join(' '), + }), + [props.PopoverClasses] + ); + + const renderGroupContent = useCallback((title: string) => { + return ( + + {title} + + ); + }, []); + + const renderContent = useCallback( + (type: FieldType) => { + return ( + <> + + + + + {type === field.type && } + + ); + }, + [field.type] + ); + + const options: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: 100, + content: renderGroupContent('Basic'), + children: [ + { + key: FieldType.RichText, + content: renderContent(FieldType.RichText), + }, + { + key: FieldType.Number, + content: renderContent(FieldType.Number), + }, + { + key: FieldType.SingleSelect, + content: renderContent(FieldType.SingleSelect), + }, + { + key: FieldType.MultiSelect, + content: renderContent(FieldType.MultiSelect), + }, + { + key: FieldType.DateTime, + content: renderContent(FieldType.DateTime), + }, + { + key: FieldType.Checkbox, + content: renderContent(FieldType.Checkbox), + }, + { + key: FieldType.Checklist, + content: renderContent(FieldType.Checklist), + }, + { + key: FieldType.URL, + content: renderContent(FieldType.URL), + }, + ], + }, + { + key: 101, + content:
, + children: [], + }, + { + key: 102, + content: renderGroupContent('Advanced'), + children: [ + { + key: FieldType.LastEditedTime, + content: renderContent(FieldType.LastEditedTime), + }, + { + key: FieldType.CreatedTime, + content: renderContent(FieldType.CreatedTime), + }, + ], + }, + ]; + }, [renderContent, renderGroupContent]); + + return ( + + props?.onClose?.({}, 'escapeKeyDown')} + options={options} + disableFocus={true} + onConfirm={onClickItem} + /> + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenuExtension.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenuExtension.tsx new file mode 100644 index 0000000000000..b45b670757d72 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenuExtension.tsx @@ -0,0 +1,26 @@ +import React, { useMemo } from 'react'; +import { FieldType } from '@/services/backend'; +import { DateTimeField, Field, NumberField, SelectField } from '$app/application/database'; +import SelectFieldActions from '$app/components/database/components/field_types/select/select_field_actions/SelectFieldActions'; +import NumberFieldActions from '$app/components/database/components/field_types/number/NumberFieldActions'; +import DateTimeFieldActions from '$app/components/database/components/field_types/date/DateTimeFieldActions'; + +function PropertyTypeMenuExtension({ field }: { field: Field }) { + return useMemo(() => { + switch (field.type) { + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return ; + case FieldType.Number: + return ; + case FieldType.DateTime: + case FieldType.CreatedTime: + case FieldType.LastEditedTime: + return ; + default: + return null; + } + }, [field]); +} + +export default PropertyTypeMenuExtension; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeSelect.tsx new file mode 100644 index 0000000000000..28d62b82c6823 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeSelect.tsx @@ -0,0 +1,59 @@ +import React, { useRef, useState } from 'react'; +import { ProppertyTypeSvg } from '$app/components/database/components/property/property_type/ProppertyTypeSvg'; +import { MenuItem } from '@mui/material'; +import { Field } from '$app/application/database'; +import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; +import { PropertyTypeMenu } from '$app/components/database/components/property/property_type/PropertyTypeMenu'; +import { FieldType } from '@/services/backend'; +import { PropertyTypeText } from '$app/components/database/components/property/property_type/PropertyTypeText'; + +interface Props { + field: Field; + onUpdateFieldType: (type: FieldType) => void; +} +function PropertyTypeSelect({ field, onUpdateFieldType }: Props) { + const [expanded, setExpanded] = useState(false); + const ref = useRef(null); + + return ( +
+ { + setExpanded(!expanded); + }} + className={'mx-0 rounded-none px-0'} + > +
+ + + + + +
+
+ {expanded && ( + { + setExpanded(false); + }} + /> + )} +
+ ); +} + +export default PropertyTypeSelect; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeText.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeText.tsx new file mode 100644 index 0000000000000..daae232fde21a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeText.tsx @@ -0,0 +1,27 @@ +import { FieldType } from '@/services/backend'; +import { useTranslation } from 'react-i18next'; +import { useMemo } from 'react'; + +export const PropertyTypeText = ({ type }: { type: FieldType }) => { + const { t } = useTranslation(); + + const text = useMemo(() => { + const map = { + [FieldType.RichText]: t('grid.field.textFieldName'), + [FieldType.Number]: t('grid.field.numberFieldName'), + [FieldType.DateTime]: t('grid.field.dateFieldName'), + [FieldType.SingleSelect]: t('grid.field.singleSelectFieldName'), + [FieldType.MultiSelect]: t('grid.field.multiSelectFieldName'), + [FieldType.Checkbox]: t('grid.field.checkboxFieldName'), + [FieldType.URL]: t('grid.field.urlFieldName'), + [FieldType.Checklist]: t('grid.field.checklistFieldName'), + [FieldType.LastEditedTime]: t('grid.field.updatedAtFieldName'), + [FieldType.CreatedTime]: t('grid.field.createdAtFieldName'), + [FieldType.Relation]: t('grid.field.relationFieldName'), + }; + + return map[type] || 'unknown'; + }, [t, type]); + + return
{text}
; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/ProppertyTypeSvg.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/ProppertyTypeSvg.tsx new file mode 100644 index 0000000000000..7ee4e6f83d254 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/ProppertyTypeSvg.tsx @@ -0,0 +1,32 @@ +import { FC, memo } from 'react'; +import { FieldType } from '@/services/backend'; +import { ReactComponent as TextSvg } from '$app/assets/database/field-type-text.svg'; +import { ReactComponent as NumberSvg } from '$app/assets/database/field-type-number.svg'; +import { ReactComponent as DateSvg } from '$app/assets/database/field-type-date.svg'; +import { ReactComponent as SingleSelectSvg } from '$app/assets/database/field-type-single-select.svg'; +import { ReactComponent as MultiSelectSvg } from '$app/assets/database/field-type-multi-select.svg'; +import { ReactComponent as ChecklistSvg } from '$app/assets/database/field-type-checklist.svg'; +import { ReactComponent as CheckboxSvg } from '$app/assets/database/field-type-checkbox.svg'; +import { ReactComponent as URLSvg } from '$app/assets/database/field-type-url.svg'; +import { ReactComponent as LastEditedTimeSvg } from '$app/assets/database/field-type-last-edited-time.svg'; +import { ReactComponent as RelationSvg } from '$app/assets/database/field-type-relation.svg'; + +export const FieldTypeSvgMap: Record>> = { + [FieldType.RichText]: TextSvg, + [FieldType.Number]: NumberSvg, + [FieldType.DateTime]: DateSvg, + [FieldType.SingleSelect]: SingleSelectSvg, + [FieldType.MultiSelect]: MultiSelectSvg, + [FieldType.Checkbox]: CheckboxSvg, + [FieldType.URL]: URLSvg, + [FieldType.Checklist]: ChecklistSvg, + [FieldType.LastEditedTime]: LastEditedTimeSvg, + [FieldType.CreatedTime]: LastEditedTimeSvg, + [FieldType.Relation]: RelationSvg, +}; + +export const ProppertyTypeSvg: FC<{ type: FieldType; className?: string }> = memo(({ type, ...props }) => { + const Svg = FieldTypeSvgMap[type]; + + return ; +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortConditionSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortConditionSelect.tsx new file mode 100644 index 0000000000000..fdb508cb8f180 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortConditionSelect.tsx @@ -0,0 +1,78 @@ +import { FC, useMemo, useRef, useState } from 'react'; +import { SortConditionPB } from '@/services/backend'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { Popover } from '@mui/material'; +import { ReactComponent as DropDownSvg } from '$app/assets/more.svg'; +import { useTranslation } from 'react-i18next'; + +export const SortConditionSelect: FC<{ + onChange?: (value: SortConditionPB) => void; + value?: SortConditionPB; +}> = ({ onChange, value }) => { + const { t } = useTranslation(); + const ref = useRef(null); + const [open, setOpen] = useState(false); + const handleClose = () => { + setOpen(false); + }; + + const options: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: SortConditionPB.Ascending, + content: t('grid.sort.ascending'), + }, + { + key: SortConditionPB.Descending, + content: t('grid.sort.descending'), + }, + ]; + }, [t]); + + const onConfirm = (optionKey: SortConditionPB) => { + onChange?.(optionKey); + handleClose(); + }; + + const selectedField = useMemo(() => options.find((option) => option.key === value), [options, value]); + + return ( + <> +
{ + setOpen(true); + }} + > +
{selectedField?.content}
+ +
+ {open && ( + +
+ +
+
+ )} + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx new file mode 100644 index 0000000000000..724c28467a293 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx @@ -0,0 +1,53 @@ +import React, { FC, useCallback } from 'react'; +import { MenuProps } from '@mui/material'; +import PropertiesList from '$app/components/database/components/property/PropertiesList'; +import { Field, sortService } from '$app/application/database'; +import { SortConditionPB } from '@/services/backend'; +import { useTranslation } from 'react-i18next'; +import { useViewId } from '$app/hooks'; +import Popover from '@mui/material/Popover'; + +const SortFieldsMenu: FC< + MenuProps & { + onInserted?: () => void; + } +> = ({ onInserted, ...props }) => { + const { t } = useTranslation(); + const viewId = useViewId(); + const addSort = useCallback( + async (field: Field) => { + await sortService.insertSort(viewId, { + fieldId: field.id, + condition: SortConditionPB.Ascending, + }); + props.onClose?.({}, 'backdropClick'); + onInserted?.(); + }, + [props, viewId, onInserted] + ); + + return ( + { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} + keepMounted={false} + {...props} + > + { + props.onClose?.({}, 'escapeKeyDown'); + }} + showSearch={true} + onItemClick={addSort} + searchPlaceholder={t('grid.settings.sortBy')} + /> + + ); +}; + +export default SortFieldsMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx new file mode 100644 index 0000000000000..fe1074bbdefaa --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx @@ -0,0 +1,55 @@ +import { IconButton, Stack } from '@mui/material'; +import { FC, useCallback } from 'react'; +import { ReactComponent as CloseSvg } from '$app/assets/close.svg'; +import { Field, Sort, sortService } from '$app/application/database'; +import { PropertySelect } from '../property'; +import { SortConditionSelect } from './SortConditionSelect'; +import { useViewId } from '@/appflowy_app/hooks'; +import { SortConditionPB } from '@/services/backend'; + +export interface SortItemProps { + className?: string; + sort: Sort; +} + +export const SortItem: FC = ({ className, sort }) => { + const viewId = useViewId(); + + const handleFieldChange = useCallback( + (field: Field | undefined) => { + if (field) { + void sortService.updateSort(viewId, { + ...sort, + fieldId: field.id, + }); + } + }, + [viewId, sort] + ); + + const handleConditionChange = useCallback( + (value: SortConditionPB) => { + void sortService.updateSort(viewId, { + ...sort, + condition: value, + }); + }, + [viewId, sort] + ); + + const handleClick = useCallback(() => { + void sortService.deleteSort(viewId, sort); + }, [viewId, sort]); + + return ( + + + +
+ + + +
+
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx new file mode 100644 index 0000000000000..88df70b2e4b4a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx @@ -0,0 +1,90 @@ +import { Menu, MenuProps } from '@mui/material'; +import { FC, MouseEventHandler, useCallback, useState } from 'react'; +import { useViewId } from '$app/hooks'; +import { sortService } from '$app/application/database'; +import { useDatabaseSorts } from '../../Database.hooks'; +import { SortItem } from './SortItem'; + +import { useTranslation } from 'react-i18next'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg'; +import SortFieldsMenu from '$app/components/database/components/sort/SortFieldsMenu'; +import Button from '@mui/material/Button'; + +export const SortMenu: FC = (props) => { + const { onClose } = props; + const { t } = useTranslation(); + const viewId = useViewId(); + const sorts = useDatabaseSorts(); + const [anchorEl, setAnchorEl] = useState(null); + const openFieldListMenu = Boolean(anchorEl); + const handleClick = useCallback>((event) => { + setAnchorEl(event.currentTarget); + }, []); + + const deleteAllSorts = useCallback(() => { + void sortService.deleteAllSorts(viewId); + onClose?.({}, 'backdropClick'); + }, [viewId, onClose]); + + return ( + <> + { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} + keepMounted={false} + MenuListProps={{ + className: 'py-1 w-[360px]', + }} + {...props} + onClose={onClose} + > +
+
+ {sorts.map((sort) => ( + + ))} +
+ +
+ + +
+
+
+ + { + setAnchorEl(null); + }} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + /> + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/Sorts.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/Sorts.tsx new file mode 100644 index 0000000000000..7a4fa57a6fe1c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/Sorts.tsx @@ -0,0 +1,45 @@ +import { Chip, Divider } from '@mui/material'; +import React, { MouseEventHandler, useCallback, useEffect, useState } from 'react'; +import { SortMenu } from './SortMenu'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as SortSvg } from '$app/assets/sort.svg'; +import { ReactComponent as DropDownSvg } from '$app/assets/dropdown.svg'; +import { useDatabase } from '$app/components/database'; + +export const Sorts = () => { + const { t } = useTranslation(); + const { sorts } = useDatabase(); + + const showSorts = sorts && sorts.length > 0; + const [anchorEl, setAnchorEl] = useState(null); + + const handleClick = useCallback>((event) => { + setAnchorEl(event.currentTarget); + }, []); + + const label = ( +
+ + {t('grid.settings.sort')} + +
+ ); + + const menuOpen = Boolean(anchorEl); + + useEffect(() => { + if (!showSorts) { + setAnchorEl(null); + } + }, [showSorts]); + + if (!showSorts) return null; + + return ( +
+ + + setAnchorEl(null)} /> +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/index.ts new file mode 100644 index 0000000000000..e64dba3a6e757 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/index.ts @@ -0,0 +1,2 @@ +export * from './SortMenu'; +export * from './Sorts'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/AddViewBtn.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/AddViewBtn.tsx new file mode 100644 index 0000000000000..717bf1eb18ac5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/AddViewBtn.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { IconButton } from '@mui/material'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { useTranslation } from 'react-i18next'; +import { ViewLayoutPB } from '@/services/backend'; +import { createDatabaseView } from '$app/application/database/database_view/database_view_service'; + +function AddViewBtn({ pageId, onCreated }: { pageId: string; onCreated: (id: string) => void }) { + const { t } = useTranslation(); + const onClick = async () => { + try { + const view = await createDatabaseView(pageId, ViewLayoutPB.Grid, t('editor.table')); + + onCreated(view.id); + } catch (e) { + console.error(e); + } + }; + + return ( +
+ + + +
+ ); +} + +export default AddViewBtn; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/DatabaseTabBar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/DatabaseTabBar.tsx new file mode 100644 index 0000000000000..f7375e0c70a48 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/DatabaseTabBar.tsx @@ -0,0 +1,110 @@ +import { forwardRef, FunctionComponent, SVGProps, useEffect, useMemo, useState } from 'react'; +import { ViewTabs, ViewTab } from './ViewTabs'; +import { useTranslation } from 'react-i18next'; +import AddViewBtn from '$app/components/database/components/tab_bar/AddViewBtn'; +import { ViewLayoutPB } from '@/services/backend'; +import { ReactComponent as GridSvg } from '$app/assets/grid.svg'; +import { ReactComponent as BoardSvg } from '$app/assets/board.svg'; +import { ReactComponent as DocumentSvg } from '$app/assets/document.svg'; +import ViewActions from '$app/components/database/components/tab_bar/ViewActions'; +import { Page } from '$app_reducers/pages/slice'; + +export interface DatabaseTabBarProps { + childViews: Page[]; + selectedViewId?: string; + setSelectedViewId?: (viewId: string) => void; + pageId: string; +} + +const DatabaseIcons: { + [key in ViewLayoutPB]: FunctionComponent & { title?: string | undefined }>; +} = { + [ViewLayoutPB.Document]: DocumentSvg, + [ViewLayoutPB.Grid]: GridSvg, + [ViewLayoutPB.Board]: BoardSvg, + [ViewLayoutPB.Calendar]: GridSvg, +}; + +export const DatabaseTabBar = forwardRef( + ({ pageId, childViews, selectedViewId, setSelectedViewId }, ref) => { + const { t } = useTranslation(); + const [contextMenuAnchorEl, setContextMenuAnchorEl] = useState(null); + const [contextMenuView, setContextMenuView] = useState(null); + const open = Boolean(contextMenuAnchorEl); + + const handleChange = (_: React.SyntheticEvent, newValue: string) => { + setSelectedViewId?.(newValue); + }; + + useEffect(() => { + if (selectedViewId === undefined && childViews.length > 0) { + setSelectedViewId?.(childViews[0].id); + } + }, [selectedViewId, setSelectedViewId, childViews]); + + const openMenu = (view: Page) => { + return (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenuView(view); + setContextMenuAnchorEl(e.currentTarget); + }; + }; + + const isSelected = useMemo( + () => childViews.some((view) => view.id === selectedViewId), + [childViews, selectedViewId] + ); + + if (childViews.length === 0) return null; + return ( +
+
+ + {childViews.map((view) => { + const Icon = DatabaseIcons[view.layout]; + + return ( + } + iconPosition='start' + color='inherit' + label={view.name || t('grid.title.placeholder')} + value={view.id} + /> + ); + })} + + setSelectedViewId?.(id)} /> +
+ {open && contextMenuView && ( + { + setContextMenuAnchorEl(null); + setContextMenuView(null); + }} + /> + )} +
+ ); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/TextButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/TextButton.tsx new file mode 100644 index 0000000000000..60dfaa2e537f7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/TextButton.tsx @@ -0,0 +1,9 @@ +import { Button, ButtonProps, styled } from '@mui/material'; + +export const TextButton = styled(Button)(() => ({ + padding: '2px 6px', + fontSize: '0.75rem', + lineHeight: '1rem', + fontWeight: 400, + minWidth: 'unset', +})); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/ViewActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/ViewActions.tsx new file mode 100644 index 0000000000000..be545e51e3f9d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/ViewActions.tsx @@ -0,0 +1,103 @@ +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg'; +import { ReactComponent as EditSvg } from '$app/assets/edit.svg'; +import { deleteView } from '$app/application/database/database_view/database_view_service'; +import { MenuProps, Menu } from '@mui/material'; +import RenameDialog from '$app/components/_shared/confirm_dialog/RenameDialog'; +import { Page } from '$app_reducers/pages/slice'; +import { useAppDispatch } from '$app/stores/store'; +import { updatePageName } from '$app_reducers/pages/async_actions'; +import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; + +enum ViewAction { + Rename, + Delete, +} + +function ViewActions({ view, pageId, ...props }: { pageId: string; view: Page } & MenuProps) { + const { t } = useTranslation(); + const viewId = view.id; + const dispatch = useAppDispatch(); + const [openRenameDialog, setOpenRenameDialog] = useState(false); + const renderContent = useCallback((title: string, Icon: React.FC>) => { + return ( +
+ +
{title}
+
+ ); + }, []); + + const onConfirm = useCallback( + async (key: ViewAction) => { + switch (key) { + case ViewAction.Rename: + setOpenRenameDialog(true); + break; + case ViewAction.Delete: + try { + await deleteView(viewId); + props.onClose?.({}, 'backdropClick'); + } catch (e) { + // toast.error(t('error.deleteView')); + } + + break; + default: + break; + } + }, + [viewId, props] + ); + const options = [ + { + key: ViewAction.Rename, + content: renderContent(t('button.rename'), EditSvg), + }, + + { + key: ViewAction.Delete, + content: renderContent(t('button.delete'), DeleteSvg), + disabled: viewId === pageId, + }, + ]; + + return ( + <> + + { + props.onClose?.({}, 'escapeKeyDown'); + }} + /> + + {openRenameDialog && ( + setOpenRenameDialog(false)} + onOk={async (val) => { + try { + await dispatch( + updatePageName({ + id: viewId, + name: val, + immediate: true, + }) + ); + setOpenRenameDialog(false); + props.onClose?.({}, 'backdropClick'); + } catch (e) { + // toast.error(t('error.renameView')); + } + }} + defaultValue={view.name} + /> + )} + + ); +} + +export default ViewActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/ViewTabs.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/ViewTabs.tsx new file mode 100644 index 0000000000000..e2ff336c73a8a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/ViewTabs.tsx @@ -0,0 +1,48 @@ +import { styled, Tab, TabProps, Tabs, TabsProps } from '@mui/material'; +import { HTMLAttributes } from 'react'; + +export const ViewTabs = styled((props: TabsProps) => )({ + minHeight: '28px', + + '& .MuiTabs-scroller': { + paddingBottom: '2px', + }, +}); + +export const ViewTab = styled((props: TabProps) => )({ + padding: '6px 12px', + minHeight: '28px', + fontSize: '12px', + lineHeight: '16px', + minWidth: 'unset', + margin: '4px 0', + + '&.Mui-selected': { + color: 'inherit', + }, +}); + +interface TabPanelProps extends HTMLAttributes { + children?: React.ReactNode; + index: number; + value: number; +} + +export function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + const isActivated = value === index; + + return ( + + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/index.ts new file mode 100644 index 0000000000000..fc0c62963e860 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/tab_bar/index.ts @@ -0,0 +1 @@ +export * from './DatabaseTabBar'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/database.scss b/frontend/appflowy_tauri/src/appflowy_app/components/database/database.scss new file mode 100644 index 0000000000000..492ff2a713ceb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/database.scss @@ -0,0 +1,19 @@ +.database-collection { + ::-webkit-scrollbar { + width: 0px; + height: 0px; + } +} + +.checklist-item { + @apply my-1; + .delete-option-button { + display: none; + } + &:hover, &.selected { + background-color: var(--fill-list-hover); + .delete-option-button { + display: block; + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/Grid.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/Grid.tsx new file mode 100644 index 0000000000000..beb90c66dc4bb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/Grid.tsx @@ -0,0 +1,6 @@ +import { FC } from 'react'; +import { GridTable, GridTableProps } from './grid_table'; + +export const Grid: FC = (props) => { + return ; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/constants.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/constants.ts new file mode 100644 index 0000000000000..eadfadaa89557 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/constants.ts @@ -0,0 +1,86 @@ +import { Field, RowMeta } from '$app/application/database'; + +export const GridCalculateCountHeight = 40; + +export const GRID_ACTIONS_WIDTH = 64; + +export const DEFAULT_FIELD_WIDTH = 150; + +export enum RenderRowType { + Fields = 'fields', + Row = 'row', + NewRow = 'new-row', + CalculateRow = 'calculate-row', +} + +export interface CalculateRenderRow { + type: RenderRowType.CalculateRow; +} + +export interface FieldRenderRow { + type: RenderRowType.Fields; +} + +export interface CellRenderRow { + type: RenderRowType.Row; + data: { + meta: RowMeta; + }; +} + +export interface NewRenderRow { + type: RenderRowType.NewRow; + data: { + groupId?: string; + }; +} + +export type RenderRow = FieldRenderRow | CellRenderRow | NewRenderRow | CalculateRenderRow; + +export const fieldsToColumns = (fields: Field[]): GridColumn[] => { + return [ + { + type: GridColumnType.Action, + width: GRID_ACTIONS_WIDTH, + }, + ...fields.map((field) => ({ + field, + width: field.width || DEFAULT_FIELD_WIDTH, + type: GridColumnType.Field, + })), + { + type: GridColumnType.NewProperty, + width: DEFAULT_FIELD_WIDTH, + }, + ]; +}; + +export const rowMetasToRenderRow = (rowMetas: RowMeta[]): RenderRow[] => { + return [ + ...rowMetas.map((rowMeta) => ({ + type: RenderRowType.Row, + data: { + meta: rowMeta, + }, + })), + { + type: RenderRowType.NewRow, + data: {}, + }, + { + type: RenderRowType.CalculateRow, + }, + ]; +}; + +export enum GridColumnType { + Action, + Field, + NewProperty, +} + +export interface GridColumn { + field?: Field; + width: number; + type: GridColumnType; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_calculate/GridCalculate.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_calculate/GridCalculate.tsx new file mode 100644 index 0000000000000..beed71fca4551 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_calculate/GridCalculate.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useDatabaseVisibilityRows } from '$app/components/database'; +import { Field } from '$app/application/database'; +import { DEFAULT_FIELD_WIDTH, GRID_ACTIONS_WIDTH } from '$app/components/database/grid/constants'; +import { useTranslation } from 'react-i18next'; + +interface Props { + field: Field; + index: number; + getContainerRef?: () => React.RefObject; +} + +export function GridCalculate({ field, index }: Props) { + const rowMetas = useDatabaseVisibilityRows(); + const count = rowMetas.length; + const width = index === 0 ? GRID_ACTIONS_WIDTH : field.width ?? DEFAULT_FIELD_WIDTH; + const { t } = useTranslation(); + + return ( +
+ {field.isPrimary ? ( + <> + {t('grid.calculationTypeLabel.count')} + {count} + + ) : null} +
+ ); +} + +export default GridCalculate; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_calculate/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_calculate/index.ts new file mode 100644 index 0000000000000..2bd3b71b1eb01 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_calculate/index.ts @@ -0,0 +1 @@ +export * from './GridCalculate'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_cell/GridCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_cell/GridCell.tsx new file mode 100644 index 0000000000000..042ba1777d044 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_cell/GridCell.tsx @@ -0,0 +1,66 @@ +import React, { CSSProperties, memo } from 'react'; +import { GridColumn, RenderRow, RenderRowType } from '../constants'; +import GridNewRow from '$app/components/database/grid/grid_new_row/GridNewRow'; +import { GridCalculate } from '$app/components/database/grid/grid_calculate'; +import { areEqual } from 'react-window'; +import { Cell } from '$app/components/database/components'; +import { PrimaryCell } from '$app/components/database/grid/grid_cell'; + +const getRenderRowKey = (row: RenderRow) => { + if (row.type === RenderRowType.Row) { + return `row:${row.data.meta.id}`; + } + + return row.type; +}; + +interface GridCellProps { + row: RenderRow; + column: GridColumn; + columnIndex: number; + style: CSSProperties; + onEditRecord?: (rowId: string) => void; + getContainerRef?: () => React.RefObject; +} + +export const GridCell = memo(({ row, column, columnIndex, style, onEditRecord, getContainerRef }: GridCellProps) => { + const key = getRenderRowKey(row); + + const field = column.field; + + if (!field) return
; + + switch (row.type) { + case RenderRowType.Row: { + const { id: rowId, icon: rowIcon } = row.data.meta; + const renderRowCell = ; + + return ( +
+ {field.isPrimary ? ( + + {renderRowCell} + + ) : ( + renderRowCell + )} +
+ ); + } + + case RenderRowType.NewRow: + return ( +
+ +
+ ); + case RenderRowType.CalculateRow: + return ( +
+ +
+ ); + default: + return null; + } +}, areEqual); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_cell/PrimaryCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_cell/PrimaryCell.tsx new file mode 100644 index 0000000000000..b9a734de7b02a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_cell/PrimaryCell.tsx @@ -0,0 +1,51 @@ +import React, { Suspense, useMemo, useRef } from 'react'; +import { ReactComponent as OpenIcon } from '$app/assets/open.svg'; +import { IconButton } from '@mui/material'; + +import { useGridTableHoverState } from '$app/components/database/grid/grid_row_actions'; + +export function PrimaryCell({ + onEditRecord, + icon, + getContainerRef, + rowId, + children, +}: { + rowId: string; + icon?: string; + onEditRecord?: (rowId: string) => void; + getContainerRef?: () => React.RefObject; + children?: React.ReactNode; +}) { + const cellRef = useRef(null); + + const containerRef = getContainerRef?.(); + const { hoverRowId } = useGridTableHoverState(containerRef); + + const showExpandIcon = useMemo(() => { + return hoverRowId === rowId; + }, [hoverRowId, rowId]); + + return ( +
+ {icon &&
{icon}
} + {children} + + {showExpandIcon && ( +
+ onEditRecord?.(rowId)} className={'h-6 w-6 text-sm'}> + + +
+ )} +
+
+ ); +} + +export default PrimaryCell; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_cell/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_cell/index.ts new file mode 100644 index 0000000000000..949d5054bfd2d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_cell/index.ts @@ -0,0 +1,2 @@ +export * from './GridCell'; +export * from './PrimaryCell'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridField.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridField.tsx new file mode 100644 index 0000000000000..3c3921abf7660 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridField.tsx @@ -0,0 +1,179 @@ +import { Button } from '@mui/material'; +import { DragEventHandler, FC, HTMLAttributes, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useViewId } from '$app/hooks'; +import { DragItem, DropPosition, DragType, useDraggable, useDroppable, ScrollDirection } from '../../_shared'; +import { fieldService, Field } from '$app/application/database'; +import { Property } from '$app/components/database/components/property'; +import { GridResizer, GridFieldMenu } from '$app/components/database/grid/grid_field'; +import { areEqual } from 'react-window'; +import { useOpenMenu } from '$app/components/database/grid/grid_sticky_header/GridStickyHeader.hooks'; +import throttle from 'lodash-es/throttle'; + +export interface GridFieldProps extends HTMLAttributes { + field: Field; + onOpenMenu?: (id: string) => void; + onCloseMenu?: (id: string) => void; + resizeColumnWidth?: (width: number) => void; + getScrollElement?: () => HTMLElement | null; +} + +export const GridField: FC = memo( + ({ getScrollElement, resizeColumnWidth, onOpenMenu, onCloseMenu, field, ...props }) => { + const menuOpened = useOpenMenu(field.id); + const viewId = useViewId(); + const [propertyMenuOpened, setPropertyMenuOpened] = useState(false); + const [dropPosition, setDropPosition] = useState(DropPosition.Before); + + const draggingData = useMemo( + () => ({ + field, + }), + [field] + ); + + const { isDragging, attributes, listeners, setPreviewRef, previewRef } = useDraggable({ + type: DragType.Field, + data: draggingData, + scrollOnEdge: { + direction: ScrollDirection.Horizontal, + getScrollElement, + edgeGap: 80, + }, + }); + + const onDragOver = useMemo(() => { + return throttle((event) => { + const element = previewRef.current; + + if (!element) { + return; + } + + const { left, right } = element.getBoundingClientRect(); + const middle = (left + right) / 2; + + setDropPosition(event.clientX < middle ? DropPosition.Before : DropPosition.After); + }, 20); + }, [previewRef]); + + const onDrop = useCallback( + ({ data }: DragItem) => { + const dragField = data.field as Field; + + if (dragField.id === field.id) { + return; + } + + void fieldService.moveField(viewId, dragField.id, field.id); + }, + [viewId, field] + ); + + const { isOver, listeners: dropListeners } = useDroppable({ + accept: DragType.Field, + disabled: isDragging, + onDragOver, + onDrop, + }); + + const [menuAnchorPosition, setMenuAnchorPosition] = useState< + | { + top: number; + left: number; + } + | undefined + >(undefined); + + const open = Boolean(menuAnchorPosition) && menuOpened; + + const handleClick = useCallback(() => { + onOpenMenu?.(field.id); + }, [onOpenMenu, field.id]); + + const handleMenuClose = useCallback(() => { + onCloseMenu?.(field.id); + }, [onCloseMenu, field.id]); + + useEffect(() => { + if (!menuOpened) { + setMenuAnchorPosition(undefined); + return; + } + + const anchorElement = previewRef.current; + + if (!anchorElement) { + setMenuAnchorPosition(undefined); + return; + } + + anchorElement.scrollIntoView({ block: 'nearest' }); + + const rect = anchorElement.getBoundingClientRect(); + + setMenuAnchorPosition({ + top: rect.top + rect.height, + left: rect.left, + }); + }, [menuOpened, previewRef]); + + const handlePropertyMenuOpen = useCallback(() => { + setPropertyMenuOpened(true); + }, []); + + const handlePropertyMenuClose = useCallback(() => { + setPropertyMenuOpened(false); + }, []); + + return ( +
+ + {open && ( + + )} +
+ ); + }, + areEqual +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridFieldMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridFieldMenu.tsx new file mode 100644 index 0000000000000..1407fe30c2f80 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridFieldMenu.tsx @@ -0,0 +1,59 @@ +import React, { useRef } from 'react'; +import Popover, { PopoverProps } from '@mui/material/Popover'; +import { Field } from '$app/application/database'; +import PropertyNameInput from '$app/components/database/components/property/PropertyNameInput'; +import { MenuList } from '@mui/material'; +import PropertyActions, { FieldAction } from '$app/components/database/components/property/PropertyActions'; + +interface Props extends PopoverProps { + field: Field; + onOpenPropertyMenu?: () => void; + onOpenMenu?: (fieldId: string) => void; +} + +export function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, onClose, ...props }: Props) { + const inputRef = useRef(null); + + return ( + e.stopPropagation()} + {...props} + onClose={onClose} + keepMounted={false} + onMouseDown={(e) => { + const isInput = inputRef.current?.contains(e.target as Node); + + if (isInput) return; + + e.stopPropagation(); + e.preventDefault(); + }} + > + + + onClose?.({}, 'backdropClick')} + onMenuItemClick={(action, newFieldId?: string) => { + if (action === FieldAction.EditProperty) { + onOpenPropertyMenu?.(); + } else if (newFieldId && (action === FieldAction.InsertLeft || action === FieldAction.InsertRight)) { + onOpenMenu?.(newFieldId); + } + + onClose?.({}, 'backdropClick'); + }} + fieldId={field.id} + /> + + + ); +} + +export default GridFieldMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridNewField.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridNewField.tsx new file mode 100644 index 0000000000000..d0b739298a2f3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridNewField.tsx @@ -0,0 +1,35 @@ +import React, { useCallback } from 'react'; +import { useViewId } from '$app/hooks'; +import { useTranslation } from 'react-i18next'; +import { fieldService } from '$app/application/database'; +import { FieldType } from '@/services/backend'; +import Button from '@mui/material/Button'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; + +export function GridNewField({ onInserted }: { onInserted?: (id: string) => void }) { + const viewId = useViewId(); + const { t } = useTranslation(); + + const handleClick = useCallback(async () => { + try { + const field = await fieldService.createField({ + viewId, + fieldType: FieldType.RichText, + }); + + onInserted?.(field.id); + } catch (e) { + // toast.error(t('grid.field.newPropertyFail')); + } + }, [onInserted, viewId]); + + return ( + <> + + + ); +} + +export default GridNewField; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridResizer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridResizer.tsx new file mode 100644 index 0000000000000..12aef74996133 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridResizer.tsx @@ -0,0 +1,84 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { Field, fieldService } from '$app/application/database'; +import { useViewId } from '$app/hooks'; + +interface GridResizerProps { + field: Field; + onWidthChange?: (width: number) => void; +} + +const minWidth = 150; + +export function GridResizer({ field, onWidthChange }: GridResizerProps) { + const viewId = useViewId(); + const fieldId = field.id; + const width = field.width || 0; + const [isResizing, setIsResizing] = useState(false); + const [hover, setHover] = useState(false); + const startX = useRef(0); + const newWidthRef = useRef(width); + const onResize = useCallback( + (e: MouseEvent) => { + const diff = e.clientX - startX.current; + const newWidth = width + diff; + + if (newWidth < minWidth) { + return; + } + + newWidthRef.current = newWidth; + onWidthChange?.(newWidth); + }, + [width, onWidthChange] + ); + + const onResizeEnd = useCallback(() => { + setIsResizing(false); + + void fieldService.updateFieldSetting(viewId, fieldId, { + width: newWidthRef.current, + }); + document.removeEventListener('mousemove', onResize); + document.removeEventListener('mouseup', onResizeEnd); + }, [fieldId, onResize, viewId]); + + const onResizeStart = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + startX.current = e.clientX; + setIsResizing(true); + document.addEventListener('mousemove', onResize); + document.addEventListener('mouseup', onResizeEnd); + }, + [onResize, onResizeEnd] + ); + + return ( +
{ + e.stopPropagation(); + }} + onMouseEnter={() => { + setHover(true); + }} + onMouseLeave={() => { + setHover(false); + }} + style={{ + right: `-3px`, + }} + className={'absolute top-0 z-10 h-full cursor-col-resize'} + > +
+
+ ); +} + +export default GridResizer; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/index.ts new file mode 100644 index 0000000000000..384ee2af3bc33 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/index.ts @@ -0,0 +1,4 @@ +export * from './GridField'; +export * from './GridFieldMenu'; +export * from './GridNewField'; +export * from './GridResizer'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_new_row/GridNewRow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_new_row/GridNewRow.tsx new file mode 100644 index 0000000000000..4dc70e21dcdd5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_new_row/GridNewRow.tsx @@ -0,0 +1,68 @@ +import React, { useCallback } from 'react'; +import { rowService } from '$app/application/database'; +import { useViewId } from '$app/hooks'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { useTranslation } from 'react-i18next'; + +interface Props { + index: number; + groupId?: string; + getContainerRef?: () => React.RefObject; +} + +const CSS_HIGHLIGHT_PROPERTY = 'bg-content-blue-50'; + +function GridNewRow({ index, groupId, getContainerRef }: Props) { + const viewId = useViewId(); + + const { t } = useTranslation(); + const handleClick = useCallback(() => { + void rowService.createRow(viewId, { + groupId, + }); + }, [viewId, groupId]); + + const toggleCssProperty = useCallback( + (status: boolean) => { + const container = getContainerRef?.()?.current; + + if (!container) return; + + const newRowCells = container.querySelectorAll('.grid-new-row'); + + newRowCells.forEach((cell) => { + if (status) { + cell.classList.add(CSS_HIGHLIGHT_PROPERTY); + } else { + cell.classList.remove(CSS_HIGHLIGHT_PROPERTY); + } + }); + }, + [getContainerRef] + ); + + return ( +
{ + toggleCssProperty(true); + }} + onMouseLeave={() => { + toggleCssProperty(false); + }} + onClick={handleClick} + className={'grid-new-row flex grow cursor-pointer text-text-title'} + > + + + {t('grid.row.newRow')} + +
+ ); +} + +export default GridNewRow; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_overlay/GridTableOverlay.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_overlay/GridTableOverlay.tsx new file mode 100644 index 0000000000000..07ece5dec204c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_overlay/GridTableOverlay.tsx @@ -0,0 +1,75 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + GridRowContextMenu, + GridRowActions, + useGridTableHoverState, +} from '$app/components/database/grid/grid_row_actions'; +import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog'; +import { useTranslation } from 'react-i18next'; + +function GridTableOverlay({ + containerRef, + getScrollElement, +}: { + containerRef: React.MutableRefObject; + getScrollElement: () => HTMLDivElement | null; +}) { + const [hoverRowTop, setHoverRowTop] = useState(); + + const { t } = useTranslation(); + const [openConfirm, setOpenConfirm] = useState(false); + const [confirmModalProps, setConfirmModalProps] = useState< + | { + onOk: () => Promise; + onCancel: () => void; + } + | undefined + >(undefined); + + const { hoverRowId } = useGridTableHoverState(containerRef); + + const handleOpenConfirm = useCallback((onOk: () => Promise, onCancel: () => void) => { + setOpenConfirm(true); + setConfirmModalProps({ onOk, onCancel }); + }, []); + + useEffect(() => { + const container = containerRef.current; + + if (!container) return; + + const cell = container.querySelector(`[data-key="row:${hoverRowId}"]`); + + if (!cell) return; + const top = (cell as HTMLDivElement).style.top; + + setHoverRowTop(top); + }, [containerRef, hoverRowId]); + + return ( +
+ + + {openConfirm && ( + { + setOpenConfirm(false); + }} + {...confirmModalProps} + /> + )} +
+ ); +} + +export default GridTableOverlay; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.hooks.ts new file mode 100644 index 0000000000000..a4251c9ed5bbc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.hooks.ts @@ -0,0 +1,244 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useViewId } from '$app/hooks'; +import { rowService } from '$app/application/database'; +import { autoScrollOnEdge, ScrollDirection } from '$app/components/database/_shared/dnd/utils'; +import { useSortsCount } from '$app/components/database'; +import { deleteAllSorts } from '$app/application/database/sort/sort_service'; + +export function getCellsWithRowId(rowId: string, container: HTMLDivElement) { + return Array.from(container.querySelectorAll(`[data-key^="row:${rowId}"]`)); +} + +const SELECTED_ROW_CSS_PROPERTY = 'bg-content-blue-50'; + +export function toggleProperty( + container: HTMLDivElement, + rowId: string, + status: boolean, + property = SELECTED_ROW_CSS_PROPERTY +) { + const rowColumns = getCellsWithRowId(rowId, container); + + rowColumns.forEach((column, index) => { + if (index === 0) return; + if (status) { + column.classList.add(property); + } else { + column.classList.remove(property); + } + }); +} + +function createVirtualDragElement(rowId: string, container: HTMLDivElement) { + const cells = getCellsWithRowId(rowId, container); + + const cell = cells[0] as HTMLDivElement; + + if (!cell) return null; + + const row = document.createElement('div'); + + row.style.display = 'flex'; + row.style.position = 'absolute'; + row.style.top = cell.style.top; + const left = Number(cell.style.left.split('px')[0]) + 64; + + row.style.left = `${left}px`; + row.style.background = 'var(--content-blue-50)'; + cells.forEach((cell) => { + const node = cell.cloneNode(true) as HTMLDivElement; + + if (!node.classList.contains('grid-cell')) return; + + node.style.top = ''; + node.style.position = ''; + node.style.left = ''; + node.style.width = (cell as HTMLDivElement).style.width; + node.style.height = (cell as HTMLDivElement).style.height; + node.className = 'flex items-center border-r border-b border-divider-line opacity-50'; + row.appendChild(node); + }); + + cell.parentElement?.appendChild(row); + return row; +} + +export function useDraggableGridRow( + rowId: string, + containerRef: React.RefObject, + getScrollElement: () => HTMLDivElement | null, + onOpenConfirm: (onOk: () => Promise, onCancel: () => void) => void +) { + const viewId = useViewId(); + const sortsCount = useSortsCount(); + + const [isDragging, setIsDragging] = useState(false); + const dropRowIdRef = useRef(undefined); + const previewRef = useRef(); + + const onDragStart = useCallback( + (e: React.DragEvent) => { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.dropEffect = 'move'; + const container = containerRef.current; + + if (container) { + const row = createVirtualDragElement(rowId, container); + + if (row) { + previewRef.current = row; + e.dataTransfer.setDragImage(row, 0, 0); + } + } + + const scrollParent = getScrollElement(); + + if (scrollParent) { + autoScrollOnEdge({ + element: scrollParent, + direction: ScrollDirection.Vertical, + edgeGap: 20, + }); + } + + setIsDragging(true); + }, + [containerRef, rowId, getScrollElement] + ); + + const moveRowTo = useCallback( + async (toRowId: string) => { + return rowService.moveRow(viewId, rowId, toRowId); + }, + [viewId, rowId] + ); + + useEffect(() => { + if (!isDragging) { + if (previewRef.current) { + const row = previewRef.current; + + previewRef.current = undefined; + row?.remove(); + } + + return; + } + + const container = containerRef.current; + + if (!container) { + return; + } + + const onDragOver = (e: DragEvent) => { + e.preventDefault(); + const target = e.target as HTMLElement; + const cell = target.closest('[data-key]'); + const rowId = cell?.getAttribute('data-key')?.split(':')[1]; + + const oldRowId = dropRowIdRef.current; + + if (oldRowId) { + toggleProperty(container, oldRowId, false); + } + + if (!rowId) return; + + const rowColumns = getCellsWithRowId(rowId, container); + + dropRowIdRef.current = rowId; + if (!rowColumns.length) return; + + toggleProperty(container, rowId, true); + }; + + const onDragEnd = () => { + const oldRowId = dropRowIdRef.current; + + if (oldRowId) { + toggleProperty(container, oldRowId, false); + } + + dropRowIdRef.current = undefined; + setIsDragging(false); + }; + + const onDrop = async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + const dropRowId = dropRowIdRef.current; + + toggleProperty(container, rowId, false); + if (dropRowId) { + if (sortsCount > 0) { + onOpenConfirm( + async () => { + await deleteAllSorts(viewId); + await moveRowTo(dropRowId); + }, + () => { + void moveRowTo(dropRowId); + } + ); + } else { + void moveRowTo(dropRowId); + } + + toggleProperty(container, dropRowId, false); + } + + setIsDragging(false); + container.removeEventListener('dragover', onDragOver); + container.removeEventListener('dragend', onDragEnd); + container.removeEventListener('drop', onDrop); + }; + + container.addEventListener('dragover', onDragOver); + container.addEventListener('dragend', onDragEnd); + container.addEventListener('drop', onDrop); + }, [isDragging, containerRef, moveRowTo, onOpenConfirm, rowId, sortsCount, viewId]); + + return { + isDragging, + onDragStart, + }; +} + +export function useGridTableHoverState(containerRef?: React.RefObject) { + const [hoverRowId, setHoverRowId] = useState(undefined); + + useEffect(() => { + const container = containerRef?.current; + + if (!container) return; + const onMouseMove = (e: MouseEvent) => { + const target = e.target as HTMLElement; + const cell = target.closest('[data-key]'); + + if (!cell) { + return; + } + + const hoverRowId = cell.getAttribute('data-key')?.split(':')[1]; + + setHoverRowId(hoverRowId); + }; + + const onMouseLeave = () => { + setHoverRowId(undefined); + }; + + container.addEventListener('mousemove', onMouseMove); + container.addEventListener('mouseleave', onMouseLeave); + + return () => { + container.removeEventListener('mousemove', onMouseMove); + container.removeEventListener('mouseleave', onMouseLeave); + }; + }, [containerRef]); + + return { + hoverRowId, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.tsx new file mode 100644 index 0000000000000..f4b39e25617dd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.tsx @@ -0,0 +1,135 @@ +import React, { useCallback, useState } from 'react'; +import { IconButton, Tooltip } from '@mui/material'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { GRID_ACTIONS_WIDTH } from '$app/components/database/grid/constants'; +import { rowService } from '$app/application/database'; +import { useViewId } from '$app/hooks'; +import { GridRowDragButton, GridRowMenu, toggleProperty } from '$app/components/database/grid/grid_row_actions'; +import { OrderObjectPositionTypePB } from '@/services/backend'; +import { useSortsCount } from '$app/components/database'; +import { useTranslation } from 'react-i18next'; +import { deleteAllSorts } from '$app/application/database/sort/sort_service'; + +export function GridRowActions({ + rowId, + rowTop, + containerRef, + getScrollElement, + onOpenConfirm, +}: { + onOpenConfirm: (onOk: () => Promise, onCancel: () => void) => void; + rowId?: string; + rowTop?: string; + containerRef: React.MutableRefObject; + getScrollElement: () => HTMLDivElement | null; +}) { + const { t } = useTranslation(); + const viewId = useViewId(); + const sortsCount = useSortsCount(); + const [menuRowId, setMenuRowId] = useState(undefined); + const [menuPosition, setMenuPosition] = useState< + | { + top: number; + left: number; + } + | undefined + >(undefined); + + const openMenu = Boolean(menuPosition); + + const handleCloseMenu = useCallback(() => { + setMenuPosition(undefined); + if (containerRef.current && menuRowId) { + toggleProperty(containerRef.current, menuRowId, false); + } + }, [containerRef, menuRowId]); + + const handleInsertRecordBelow = useCallback( + async (rowId: string) => { + await rowService.createRow(viewId, { + position: OrderObjectPositionTypePB.After, + rowId: rowId, + }); + handleCloseMenu(); + }, + [viewId, handleCloseMenu] + ); + + const handleOpenMenu = (e: React.MouseEvent) => { + const target = e.target as HTMLButtonElement; + const rect = target.getBoundingClientRect(); + + if (containerRef.current && rowId) { + toggleProperty(containerRef.current, rowId, true); + } + + setMenuRowId(rowId); + setMenuPosition({ + top: rect.top + rect.height / 2, + left: rect.left + rect.width, + }); + }; + + return ( + <> + {rowId && rowTop && ( +
+ + { + if (sortsCount > 0) { + onOpenConfirm( + async () => { + await deleteAllSorts(viewId); + void handleInsertRecordBelow(rowId); + }, + () => { + void handleInsertRecordBelow(rowId); + } + ); + } else { + void handleInsertRecordBelow(rowId); + } + }} + > + + + + +
+ )} + {menuRowId && ( + + )} + + ); +} + +export default GridRowActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowContextMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowContextMenu.tsx new file mode 100644 index 0000000000000..a93188ddc42ca --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowContextMenu.tsx @@ -0,0 +1,72 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import GridRowMenu from './GridRowMenu'; +import { toggleProperty } from './GridRowActions.hooks'; + +export function GridRowContextMenu({ + containerRef, + hoverRowId, + onOpenConfirm, +}: { + hoverRowId?: string; + onOpenConfirm: (onOk: () => Promise, onCancel: () => void) => void; + containerRef: React.MutableRefObject; +}) { + const [position, setPosition] = useState<{ left: number; top: number } | undefined>(); + + const [rowId, setRowId] = useState(); + + const isContextMenuOpen = useMemo(() => { + return !!position; + }, [position]); + + const closeContextMenu = useCallback(() => { + setPosition(undefined); + const container = containerRef.current; + + if (!container || !rowId) return; + toggleProperty(container, rowId, false); + // setRowId(undefined); + }, [rowId, containerRef]); + + const openContextMenu = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const container = containerRef.current; + + if (!container || !hoverRowId) return; + toggleProperty(container, hoverRowId, true); + setRowId(hoverRowId); + setPosition({ + left: event.clientX, + top: event.clientY, + }); + }, + [containerRef, hoverRowId] + ); + + useEffect(() => { + const container = containerRef.current; + + if (!container) { + return; + } + + container.addEventListener('contextmenu', openContextMenu); + return () => { + container.removeEventListener('contextmenu', openContextMenu); + }; + }, [containerRef, openContextMenu]); + + return rowId ? ( + + ) : null; +} + +export default GridRowContextMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowDragButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowDragButton.tsx new file mode 100644 index 0000000000000..0790e481838fd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowDragButton.tsx @@ -0,0 +1,59 @@ +import React, { useEffect, useState } from 'react'; +import { useDraggableGridRow } from './GridRowActions.hooks'; +import { IconButton, Tooltip } from '@mui/material'; +import { ReactComponent as DragSvg } from '$app/assets/drag.svg'; +import { useTranslation } from 'react-i18next'; + +export function GridRowDragButton({ + rowId, + containerRef, + onClick, + getScrollElement, + onOpenConfirm, +}: { + onOpenConfirm: (onOk: () => Promise, onCancel: () => void) => void; + rowId: string; + onClick?: (e: React.MouseEvent) => void; + containerRef: React.MutableRefObject; + getScrollElement: () => HTMLDivElement | null; +}) { + const { t } = useTranslation(); + + const [openTooltip, setOpenTooltip] = useState(false); + const { onDragStart, isDragging } = useDraggableGridRow(rowId, containerRef, getScrollElement, onOpenConfirm); + + useEffect(() => { + if (isDragging) { + setOpenTooltip(false); + } + }, [isDragging]); + + return ( + <> + { + setOpenTooltip(true); + }} + onClose={() => { + setOpenTooltip(false); + }} + placement='top' + disableInteractive={true} + title={t('grid.row.dragAndClick')} + > + + + + + + ); +} + +export default GridRowDragButton; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowMenu.tsx new file mode 100644 index 0000000000000..2190e8739b608 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowMenu.tsx @@ -0,0 +1,160 @@ +import React, { useCallback, useMemo } from 'react'; +import { ReactComponent as UpSvg } from '$app/assets/up.svg'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { ReactComponent as DelSvg } from '$app/assets/delete.svg'; +import { ReactComponent as CopySvg } from '$app/assets/copy.svg'; +import Popover, { PopoverProps } from '@mui/material/Popover'; +import { useViewId } from '$app/hooks'; +import { useTranslation } from 'react-i18next'; +import { rowService } from '$app/application/database'; +import { OrderObjectPositionTypePB } from '@/services/backend'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { useSortsCount } from '$app/components/database'; +import { deleteAllSorts } from '$app/application/database/sort/sort_service'; + +enum RowAction { + InsertAbove, + InsertBelow, + Duplicate, + Delete, +} +interface Props extends PopoverProps { + rowId: string; + onOpenConfirm?: (onOk: () => Promise, onCancel: () => void) => void; +} + +export function GridRowMenu({ onOpenConfirm, rowId, onClose, ...props }: Props) { + const viewId = useViewId(); + const sortsCount = useSortsCount(); + + const { t } = useTranslation(); + + const handleInsertRecordBelow = useCallback(() => { + void rowService.createRow(viewId, { + position: OrderObjectPositionTypePB.After, + rowId: rowId, + }); + }, [viewId, rowId]); + + const handleInsertRecordAbove = useCallback(() => { + void rowService.createRow(viewId, { + position: OrderObjectPositionTypePB.Before, + rowId: rowId, + }); + }, [rowId, viewId]); + + const handleDelRow = useCallback(() => { + void rowService.deleteRow(viewId, rowId); + }, [viewId, rowId]); + + const handleDuplicateRow = useCallback(() => { + void rowService.duplicateRow(viewId, rowId); + }, [viewId, rowId]); + + const renderContent = useCallback((title: string, Icon: React.FC>) => { + return ( +
+ +
{title}
+
+ ); + }, []); + + const handleAction = useCallback( + (confirmKey?: RowAction) => { + switch (confirmKey) { + case RowAction.InsertAbove: + handleInsertRecordAbove(); + break; + case RowAction.InsertBelow: + handleInsertRecordBelow(); + break; + case RowAction.Duplicate: + handleDuplicateRow(); + break; + case RowAction.Delete: + handleDelRow(); + break; + default: + break; + } + }, + [handleDelRow, handleDuplicateRow, handleInsertRecordAbove, handleInsertRecordBelow] + ); + + const onConfirm = useCallback( + (key: RowAction) => { + if (sortsCount > 0) { + onOpenConfirm?.( + async () => { + await deleteAllSorts(viewId); + handleAction(key); + }, + () => { + handleAction(key); + } + ); + } else { + handleAction(key); + } + + onClose?.({}, 'backdropClick'); + }, + [handleAction, onClose, onOpenConfirm, sortsCount, viewId] + ); + + const options: KeyboardNavigationOption[] = useMemo( + () => [ + { + key: RowAction.InsertAbove, + content: renderContent(t('grid.row.insertRecordAbove'), UpSvg), + }, + { + key: RowAction.InsertBelow, + content: renderContent(t('grid.row.insertRecordBelow'), AddSvg), + }, + { + key: RowAction.Duplicate, + content: renderContent(t('grid.row.duplicate'), CopySvg), + }, + + { + key: 100, + content:
, + children: [], + }, + { + key: RowAction.Delete, + content: renderContent(t('grid.row.delete'), DelSvg), + }, + ], + [renderContent, t] + ); + + return ( + <> + +
+ { + onClose?.({}, 'escapeKeyDown'); + }} + /> +
+
+ + ); +} + +export default GridRowMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/index.ts new file mode 100644 index 0000000000000..fb50b6248c0f4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/index.ts @@ -0,0 +1,5 @@ +export * from './GridRowActions.hooks'; +export * from './GridRowActions'; +export * from './GridRowContextMenu'; +export * from './GridRowDragButton'; +export * from './GridRowMenu'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.hooks.ts new file mode 100644 index 0000000000000..ac5c0688b97fe --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.hooks.ts @@ -0,0 +1,9 @@ +import { createContext, useContext } from 'react'; + +export const OpenMenuContext = createContext(null); + +export const useOpenMenu = (id: string) => { + const context = useContext(OpenMenuContext); + + return context === id; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.tsx new file mode 100644 index 0000000000000..e9d01508b1fd0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.tsx @@ -0,0 +1,112 @@ +import React, { useCallback, useState } from 'react'; +import { GridChildComponentProps, GridOnScrollProps, VariableSizeGrid as Grid } from 'react-window'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { useGridColumn } from '$app/components/database/grid/grid_table'; +import { GridField } from 'src/appflowy_app/components/database/grid/grid_field'; +import NewProperty from '$app/components/database/components/property/NewProperty'; +import { GridColumn, GridColumnType, RenderRow } from '$app/components/database/grid/constants'; +import { OpenMenuContext } from '$app/components/database/grid/grid_sticky_header/GridStickyHeader.hooks'; + +const GridStickyHeader = React.forwardRef< + Grid | null, + { + columns: GridColumn[]; + getScrollElement?: () => HTMLDivElement | null; + onScroll?: (props: GridOnScrollProps) => void; + } +>(({ onScroll, columns, getScrollElement }, ref) => { + const { columnWidth, resizeColumnWidth } = useGridColumn( + columns, + ref as React.MutableRefObject | null> + ); + + const [openMenuId, setOpenMenuId] = useState(null); + + const handleOpenMenu = useCallback((id: string) => { + setOpenMenuId(id); + }, []); + + const handleCloseMenu = useCallback((id: string) => { + setOpenMenuId((prev) => { + if (prev === id) { + return null; + } + + return prev; + }); + }, []); + + const Cell = useCallback( + ({ columnIndex, style, data }: GridChildComponentProps) => { + const column = data[columnIndex]; + + if (!column || column.type === GridColumnType.Action) return
; + if (column.type === GridColumnType.NewProperty) { + const width = (style.width || 0) as number; + + return ( +
+ +
+ ); + } + + const field = column.field; + + if (!field) return
; + + return ( + resizeColumnWidth(columnIndex, width)} + field={field} + getScrollElement={getScrollElement} + /> + ); + }, + [handleCloseMenu, handleOpenMenu, resizeColumnWidth, getScrollElement] + ); + + return ( + + + {({ height, width }: { height: number; width: number }) => { + return ( + 36} + rowCount={1} + columnCount={columns.length} + columnWidth={columnWidth} + ref={ref} + onScroll={onScroll} + itemData={columns} + style={{ overscrollBehavior: 'none' }} + > + {Cell} + + ); + }} + + + ); +}); + +export default GridStickyHeader; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.hooks.ts new file mode 100644 index 0000000000000..0d676f3bb2557 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.hooks.ts @@ -0,0 +1,67 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { DEFAULT_FIELD_WIDTH, GRID_ACTIONS_WIDTH, GridColumn, RenderRow } from '$app/components/database/grid/constants'; +import { VariableSizeGrid as Grid } from 'react-window'; + +export function useGridRow() { + const rowHeight = useCallback(() => { + return 36; + }, []); + + return { + rowHeight, + }; +} + +export function useGridColumn( + columns: GridColumn[], + ref: React.RefObject | null> +) { + const [columnWidths, setColumnWidths] = useState([]); + + useEffect(() => { + setColumnWidths( + columns.map((field, index) => (index === 0 ? GRID_ACTIONS_WIDTH : field.width || DEFAULT_FIELD_WIDTH)) + ); + ref.current?.resetAfterColumnIndex(0); + }, [columns, ref]); + + const resizeColumnWidth = useCallback( + (index: number, width: number) => { + setColumnWidths((columnWidths) => { + if (columnWidths[index] === width) { + return columnWidths; + } + + const newColumnWidths = [...columnWidths]; + + newColumnWidths[index] = width; + + return newColumnWidths; + }); + + if (ref.current) { + ref.current.resetAfterColumnIndex(index); + } + }, + [ref] + ); + + const columnWidth = useCallback( + (index: number) => { + if (index === 0) return GRID_ACTIONS_WIDTH; + return columnWidths[index] || DEFAULT_FIELD_WIDTH; + }, + [columnWidths] + ); + + return { + columnWidth, + resizeColumnWidth, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.tsx new file mode 100644 index 0000000000000..0cd17d6a056a5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.tsx @@ -0,0 +1,161 @@ +import React, { FC, useCallback, useMemo, useRef } from 'react'; +import { RowMeta } from '$app/application/database'; +import { useDatabaseRendered, useDatabaseVisibilityFields, useDatabaseVisibilityRows } from '../../Database.hooks'; +import { fieldsToColumns, GridColumn, RenderRow, RenderRowType, rowMetasToRenderRow } from '../constants'; +import { CircularProgress } from '@mui/material'; +import { GridChildComponentProps, GridOnScrollProps, VariableSizeGrid as Grid } from 'react-window'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { GridCell } from 'src/appflowy_app/components/database/grid/grid_cell'; +import { useGridColumn, useGridRow } from './GridTable.hooks'; +import GridStickyHeader from '$app/components/database/grid/grid_sticky_header/GridStickyHeader'; +import GridTableOverlay from '$app/components/database/grid/grid_overlay/GridTableOverlay'; +import ReactDOM from 'react-dom'; +import { useViewId } from '$app/hooks'; + +export interface GridTableProps { + onEditRecord: (rowId: string) => void; +} + +export const GridTable: FC = React.memo(({ onEditRecord }) => { + const rowMetas = useDatabaseVisibilityRows(); + const fields = useDatabaseVisibilityFields(); + const renderRows = useMemo(() => rowMetasToRenderRow(rowMetas as RowMeta[]), [rowMetas]); + const columns = useMemo(() => fieldsToColumns(fields), [fields]); + const ref = useRef< + Grid<{ + columns: GridColumn[]; + renderRows: RenderRow[]; + }> + >(null); + const { columnWidth } = useGridColumn( + columns, + ref as React.MutableRefObject | null> + ); + const viewId = useViewId(); + const { rowHeight } = useGridRow(); + const onRendered = useDatabaseRendered(); + + const getItemKey = useCallback( + ({ columnIndex, rowIndex }: { columnIndex: number; rowIndex: number }) => { + const row = renderRows[rowIndex]; + const column = columns[columnIndex]; + + const field = column.field; + + if (row.type === RenderRowType.Row) { + if (field) { + return `${row.data.meta.id}:${field.id}`; + } + + return `${row.data.meta.id}:${column.type}`; + } + + if (field) { + return `${row.type}:${field.id}`; + } + + return `${row.type}:${column.type}`; + }, + [columns, renderRows] + ); + + const getContainerRef = useCallback(() => { + return containerRef; + }, []); + + const Cell = useCallback( + ({ columnIndex, rowIndex, style, data }: GridChildComponentProps) => { + const row = data.renderRows[rowIndex]; + const column = data.columns[columnIndex]; + + return ( + + ); + }, + [getContainerRef, onEditRecord] + ); + + const staticGrid = useRef | null>(null); + + const onScroll = useCallback(({ scrollLeft, scrollUpdateWasRequested }: GridOnScrollProps) => { + if (!scrollUpdateWasRequested) { + staticGrid.current?.scrollTo({ scrollLeft, scrollTop: 0 }); + } + }, []); + + const onHeaderScroll = useCallback(({ scrollLeft }: GridOnScrollProps) => { + ref.current?.scrollTo({ scrollLeft }); + }, []); + + const containerRef = useRef(null); + const scrollElementRef = useRef(null); + + const getScrollElement = useCallback(() => { + return scrollElementRef.current; + }, []); + + return ( +
+ {fields.length === 0 && ( +
+ +
+ )} +
+ +
+ +
+ + {({ height, width }: { height: number; width: number }) => ( + { + scrollElementRef.current = el; + onRendered(viewId); + }} + innerRef={containerRef} + > + {Cell} + + )} + + {containerRef.current + ? ReactDOM.createPortal( + , + containerRef.current + ) + : null} +
+
+ ); +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/index.ts new file mode 100644 index 0000000000000..dfdb9b7949551 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/index.ts @@ -0,0 +1,2 @@ +export * from './GridTable'; +export * from './GridTable.hooks'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/index.ts new file mode 100644 index 0000000000000..762542e7cb464 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/index.ts @@ -0,0 +1 @@ +export * from './Grid'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/index.ts new file mode 100644 index 0000000000000..42a6f3159281c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/index.ts @@ -0,0 +1,3 @@ +export * from './Database.hooks'; +export * from './Database'; +export * from './DatabaseTitle'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx new file mode 100644 index 0000000000000..079a6fd75fc5d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx @@ -0,0 +1,53 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import Editor from '$app/components/editor/Editor'; +import { DocumentHeader } from 'src/appflowy_app/components/document/document_header'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { updatePageName } from '$app_reducers/pages/async_actions'; +import { PageCover } from '$app_reducers/pages/slice'; + +export function Document({ id }: { id: string }) { + const page = useAppSelector((state) => state.pages.pageMap[id]); + + const [cover, setCover] = useState(undefined); + const dispatch = useAppDispatch(); + + const onTitleChange = useCallback( + (newTitle: string) => { + void dispatch( + updatePageName({ + id, + name: newTitle, + }) + ); + }, + [dispatch, id] + ); + + const view = useMemo(() => { + return { + ...page, + cover, + }; + }, [page, cover]); + + useEffect(() => { + return () => { + setCover(undefined); + }; + }, [id]); + + if (!page) return null; + + return ( +
+ +
+
+ +
+
+
+ ); +} + +export default Document; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx new file mode 100644 index 0000000000000..f6e8736c546c0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx @@ -0,0 +1,60 @@ +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { Page, PageCover, PageIcon } from '$app_reducers/pages/slice'; +import ViewTitle from '$app/components/_shared/view_title/ViewTitle'; +import { updatePageIcon } from '$app/application/folder/page.service'; + +interface DocumentHeaderProps { + page: Page; + onUpdateCover: (cover?: PageCover) => void; +} + +export function DocumentHeader({ page, onUpdateCover }: DocumentHeaderProps) { + const pageId = page.id; + const ref = useRef(null); + + const [forceHover, setForceHover] = useState(false); + const onUpdateIcon = useCallback( + async (icon: PageIcon) => { + await updatePageIcon(pageId, icon.value ? icon : undefined); + }, + [pageId] + ); + + useEffect(() => { + const parent = ref.current?.parentElement; + + if (!parent) return; + + const documentDom = parent.querySelector('.appflowy-editor') as HTMLElement; + + if (!documentDom) return; + + const handleMouseMove = (e: MouseEvent) => { + const isMoveInTitle = Boolean(e.target instanceof HTMLElement && e.target.closest('.document-title')); + const isMoveInHeader = Boolean(e.target instanceof HTMLElement && e.target.closest('.document-header')); + + setForceHover(isMoveInTitle || isMoveInHeader); + }; + + documentDom.addEventListener('mousemove', handleMouseMove); + return () => { + documentDom.removeEventListener('mousemove', handleMouseMove); + }; + }, []); + + if (!page) return null; + return ( +
+ +
+ ); +} + +export default memo(DocumentHeader); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/index.ts new file mode 100644 index 0000000000000..00f48716bf1cc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/index.ts @@ -0,0 +1 @@ +export * from './DocumentHeader'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/index.ts new file mode 100644 index 0000000000000..a844aa51ad603 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/index.ts @@ -0,0 +1 @@ +export * from './Document'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.hooks.ts new file mode 100644 index 0000000000000..1fc25346d2ab1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.hooks.ts @@ -0,0 +1,9 @@ +import { createContext, useContext } from 'react'; + +export const EditorIdContext = createContext(''); + +export const EditorIdProvider = EditorIdContext.Provider; + +export function useEditorId() { + return useContext(EditorIdContext); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx new file mode 100644 index 0000000000000..879dc5f9c0467 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx @@ -0,0 +1,19 @@ +import React, { memo } from 'react'; +import { EditorProps } from '../../application/document/document.types'; + +import { CollaborativeEditor } from '$app/components/editor/components/editor'; +import { EditorIdProvider } from '$app/components/editor/Editor.hooks'; +import './editor.scss'; +import withErrorBoundary from '$app/components/_shared/error_boundary/withError'; + +export function Editor(props: EditorProps) { + return ( +
+ + + +
+ ); +} + +export default withErrorBoundary(memo(Editor)); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts new file mode 100644 index 0000000000000..04a2e7c0f1710 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts @@ -0,0 +1,95 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Element, Element as SlateElement, NodeEntry, Range, Transforms } from 'slate'; +import { EditorInlineNodeType, FormulaNode } from '$app/application/document/document.types'; + +export function insertFormula(editor: ReactEditor, formula?: string) { + if (editor.selection) { + wrapFormula(editor, formula); + } +} + +export function updateFormula(editor: ReactEditor, formula: string) { + if (isFormulaActive(editor)) { + Transforms.delete(editor); + wrapFormula(editor, formula); + } +} + +export function deleteFormula(editor: ReactEditor) { + if (isFormulaActive(editor)) { + Transforms.delete(editor); + } +} + +export function wrapFormula(editor: ReactEditor, formula?: string) { + if (isFormulaActive(editor)) { + unwrapFormula(editor); + } + + const { selection } = editor; + + if (!selection) return; + const isCollapsed = selection && Range.isCollapsed(selection); + + const data = formula || editor.string(selection); + const formulaElement = { + type: EditorInlineNodeType.Formula, + data, + children: [ + { + text: '$', + }, + ], + }; + + if (!isCollapsed) { + Transforms.delete(editor); + } + + Transforms.insertNodes(editor, formulaElement, { + select: true, + }); + + const path = editor.selection?.anchor.path; + + if (path) { + editor.select(path); + } +} + +export function unwrapFormula(editor: ReactEditor) { + const [match] = Editor.nodes(editor, { + match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === EditorInlineNodeType.Formula, + }); + + if (!match) return; + + const [node, path] = match as NodeEntry; + const formula = node.data; + const range = Editor.range(editor, match[1]); + const beforePoint = Editor.before(editor, path, { unit: 'character' }); + + Transforms.select(editor, range); + Transforms.delete(editor); + + Transforms.insertText(editor, formula); + + if (!beforePoint) return; + Transforms.select(editor, { + anchor: beforePoint, + focus: { + ...beforePoint, + offset: beforePoint.offset + formula.length, + }, + }); +} + +export function isFormulaActive(editor: ReactEditor) { + const [match] = editor.nodes({ + match: (n) => { + return !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorInlineNodeType.Formula; + }, + }); + + return Boolean(match); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts new file mode 100644 index 0000000000000..557b91f936e33 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts @@ -0,0 +1,715 @@ +import { ReactEditor } from 'slate-react'; +import { + Editor, + Element, + Node, + NodeEntry, + Point, + Range, + Transforms, + Location, + Path, + EditorBeforeOptions, + Text, + addMark, +} from 'slate'; +import { LIST_TYPES, tabBackward, tabForward } from '$app/components/editor/command/tab'; +import { getAllMarks, isMarkActive, removeMarks, toggleMark } from '$app/components/editor/command/mark'; +import { + deleteFormula, + insertFormula, + isFormulaActive, + unwrapFormula, + updateFormula, +} from '$app/components/editor/command/formula'; +import { + EditorInlineNodeType, + EditorNodeType, + CalloutNode, + Mention, + TodoListNode, + ToggleListNode, + inlineNodeTypes, + FormulaNode, + ImageNode, + EditorMarkFormat, +} from '$app/application/document/document.types'; +import cloneDeep from 'lodash-es/cloneDeep'; +import { generateId } from '$app/components/editor/provider/utils/convert'; +import { YjsEditor } from '@slate-yjs/core'; + +export const EmbedTypes: string[] = [ + EditorNodeType.DividerBlock, + EditorNodeType.EquationBlock, + EditorNodeType.GridBlock, + EditorNodeType.ImageBlock, +]; + +export const CustomEditor = { + getBlock: (editor: ReactEditor, at?: Location): NodeEntry | undefined => { + return Editor.above(editor, { + at, + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined, + }); + }, + + isInlineNode: (editor: ReactEditor, point: Point): boolean => { + return Boolean( + editor.above({ + at: point, + match: (n) => { + return !Editor.isEditor(n) && Element.isElement(n) && inlineNodeTypes.includes(n.type as EditorInlineNodeType); + }, + }) + ); + }, + + beforeIsInlineNode: (editor: ReactEditor, at: Location, opts?: EditorBeforeOptions): boolean => { + const beforePoint = Editor.before(editor, at, opts); + + if (!beforePoint) return false; + return CustomEditor.isInlineNode(editor, beforePoint); + }, + + afterIsInlineNode: (editor: ReactEditor, at: Location, opts?: EditorBeforeOptions): boolean => { + const afterPoint = Editor.after(editor, at, opts); + + if (!afterPoint) return false; + return CustomEditor.isInlineNode(editor, afterPoint); + }, + + /** + * judge if the selection is multiple block + * @param editor + * @param filterEmptyEndSelection if the filterEmptyEndSelection is true, the function will filter the empty end selection + */ + isMultipleBlockSelected: (editor: ReactEditor, filterEmptyEndSelection?: boolean): boolean => { + const { selection } = editor; + + if (!selection) return false; + + if (Range.isCollapsed(selection)) return false; + const start = Range.start(selection); + const end = Range.end(selection); + const isBackward = Range.isBackward(selection); + const startBlock = CustomEditor.getBlock(editor, start); + const endBlock = CustomEditor.getBlock(editor, end); + + if (!startBlock || !endBlock) return false; + + const [, startPath] = startBlock; + const [, endPath] = endBlock; + + const isSomePath = Path.equals(startPath, endPath); + + // if the start and end path is the same, return false + if (isSomePath) { + return false; + } + + if (!filterEmptyEndSelection) { + return true; + } + + // The end point is at the start of the end block + const focusEndStart = Point.equals(end, editor.start(endPath)); + + if (!focusEndStart) { + return true; + } + + // find the previous block + const previous = editor.previous({ + at: endPath, + match: (n) => Element.isElement(n) && n.blockId !== undefined, + }); + + if (!previous) { + return true; + } + + // backward selection + const newEnd = editor.end(editor.range(previous[1])); + + editor.select({ + anchor: isBackward ? newEnd : start, + focus: isBackward ? start : newEnd, + }); + + return false; + }, + + /** + * turn the current block to a new block + * 1. clone the current block to a new block + * 2. lift the children of the current block if the current block doesn't allow has children + * 3. remove the old block + * 4. insert the new block + * @param editor + * @param newProperties + */ + turnToBlock: (editor: ReactEditor, newProperties: Partial) => { + const selection = editor.selection; + + if (!selection) return; + const match = CustomEditor.getBlock(editor); + + if (!match) return; + + const [node, path] = match as NodeEntry; + + const cloneNode = CustomEditor.cloneBlock(editor, node); + + Object.assign(cloneNode, newProperties); + cloneNode.data = { + ...(node.data || {}), + ...(newProperties.data || {}), + }; + + const isEmbed = editor.isEmbed(cloneNode); + + if (isEmbed) { + editor.splitNodes({ + always: true, + }); + cloneNode.children = []; + + Transforms.removeNodes(editor, { + at: path, + }); + Transforms.insertNodes(editor, cloneNode, { at: path }); + return cloneNode; + } + + const isListType = LIST_TYPES.includes(cloneNode.type as EditorNodeType); + + // if node doesn't allow has children, lift the children before insert the new node and remove the old node + if (!isListType) { + const [textNode, ...children] = cloneNode.children; + + const length = children.length; + + for (let i = 0; i < length; i++) { + editor.liftNodes({ + at: [...path, length - i], + }); + } + + cloneNode.children = [textNode]; + } + + Transforms.removeNodes(editor, { + at: path, + }); + + Transforms.insertNodes(editor, cloneNode, { at: path }); + if (selection) { + editor.select(selection); + } + + return cloneNode; + }, + tabForward, + tabBackward, + toggleMark, + removeMarks, + isMarkActive, + isFormulaActive, + insertFormula, + updateFormula, + deleteFormula, + toggleFormula: (editor: ReactEditor) => { + if (isFormulaActive(editor)) { + unwrapFormula(editor); + } else { + insertFormula(editor); + } + }, + + isBlockActive(editor: ReactEditor, format?: string) { + const match = CustomEditor.getBlock(editor); + + if (match && format !== undefined) { + return match[0].type === format; + } + + return !!match; + }, + + toggleAlign(editor: ReactEditor, format: string) { + const isIncludeRoot = CustomEditor.selectionIncludeRoot(editor); + + if (isIncludeRoot) return; + + const matchNodes = Array.from( + Editor.nodes(editor, { + // Note: we need to select the text node instead of the element node, otherwise the parent node will be selected + match: (n) => Element.isElement(n) && n.type === EditorNodeType.Text, + }) + ); + + if (!matchNodes) return; + + matchNodes.forEach((match) => { + const [, textPath] = match as NodeEntry; + const [node] = editor.parent(textPath) as NodeEntry< + Element & { + data: { + align?: string; + }; + } + >; + const path = ReactEditor.findPath(editor, node); + + const data = (node.data as { align?: string }) || {}; + const newProperties = { + data: { + ...data, + align: data.align === format ? undefined : format, + }, + } as Partial; + + Transforms.setNodes(editor, newProperties, { at: path }); + }); + }, + + getAlign(editor: ReactEditor) { + const match = CustomEditor.getBlock(editor); + + if (!match) return undefined; + + const [node] = match as NodeEntry; + + return (node.data as { align?: string })?.align; + }, + + isInlineActive(editor: ReactEditor) { + const [match] = editor.nodes({ + match: (n) => { + return !Editor.isEditor(n) && Element.isElement(n) && inlineNodeTypes.includes(n.type as EditorInlineNodeType); + }, + }); + + return !!match; + }, + + formulaActiveNode(editor: ReactEditor) { + const [match] = editor.nodes({ + match: (n) => { + return !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorInlineNodeType.Formula; + }, + }); + + return match ? (match as NodeEntry) : undefined; + }, + + isMentionActive(editor: ReactEditor) { + const [match] = editor.nodes({ + match: (n) => { + return !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorInlineNodeType.Mention; + }, + }); + + return Boolean(match); + }, + + insertMention(editor: ReactEditor, mention: Mention) { + const mentionElement = [ + { + type: EditorInlineNodeType.Mention, + children: [{ text: '$' }], + data: { + ...mention, + }, + }, + ]; + + Transforms.insertNodes(editor, mentionElement, { + select: true, + }); + + editor.collapse({ + edge: 'end', + }); + }, + + toggleTodo(editor: ReactEditor, at?: Location) { + const selection = at || editor.selection; + + if (!selection) return; + + const nodes = Array.from( + editor.nodes({ + at: selection, + match: (n) => Element.isElement(n) && n.type === EditorNodeType.TodoListBlock, + }) + ); + + const matchUnChecked = nodes.some(([node]) => { + return !(node as TodoListNode).data.checked; + }); + + const checked = Boolean(matchUnChecked); + + nodes.forEach(([node, path]) => { + const data = (node as TodoListNode).data || {}; + const newProperties = { + data: { + ...data, + checked: checked, + }, + } as Partial; + + Transforms.setNodes(editor, newProperties, { at: path }); + }); + }, + + toggleToggleList(editor: ReactEditor, node: ToggleListNode) { + const collapsed = node.data.collapsed; + const path = ReactEditor.findPath(editor, node); + const data = node.data || {}; + const newProperties = { + data: { + ...data, + collapsed: !collapsed, + }, + } as Partial; + + const selectMatch = Editor.above(editor, { + match: (n) => Element.isElement(n) && n.blockId !== undefined, + }); + + Transforms.setNodes(editor, newProperties, { at: path }); + + if (selectMatch) { + const [selectNode] = selectMatch; + const selectNodePath = ReactEditor.findPath(editor, selectNode); + + if (Path.isAncestor(path, selectNodePath)) { + editor.select(path); + editor.collapse({ + edge: 'start', + }); + } + } + }, + + setCalloutIcon(editor: ReactEditor, node: CalloutNode, newIcon: string) { + const path = ReactEditor.findPath(editor, node); + const data = node.data || {}; + const newProperties = { + data: { + ...data, + icon: newIcon, + }, + } as Partial; + + Transforms.setNodes(editor, newProperties, { at: path }); + }, + + setMathEquationBlockFormula(editor: ReactEditor, node: Element, newFormula: string) { + const path = ReactEditor.findPath(editor, node); + const data = node.data || {}; + const newProperties = { + data: { + ...data, + formula: newFormula, + }, + } as Partial; + + Transforms.setNodes(editor, newProperties, { at: path }); + }, + + setGridBlockViewId(editor: ReactEditor, node: Element, newViewId: string) { + const path = ReactEditor.findPath(editor, node); + const data = node.data || {}; + const newProperties = { + data: { + ...data, + viewId: newViewId, + }, + } as Partial; + + Transforms.setNodes(editor, newProperties, { at: path }); + }, + + setImageBlockData(editor: ReactEditor, node: Element, newData: ImageNode['data']) { + const path = ReactEditor.findPath(editor, node); + const data = node.data || {}; + const newProperties = { + data: { + ...data, + ...newData, + }, + } as Partial; + + Transforms.setNodes(editor, newProperties, { at: path }); + }, + + cloneBlock(editor: ReactEditor, block: Element): Element { + const cloneNode: Element = { + ...cloneDeep(block), + blockId: generateId(), + type: block.type === EditorNodeType.Page ? EditorNodeType.Paragraph : block.type, + children: [], + }; + const isEmbed = editor.isEmbed(cloneNode); + + if (isEmbed) { + return cloneNode; + } + + const [firstTextNode, ...children] = block.children as Element[]; + + const textNode = + firstTextNode && firstTextNode.type === EditorNodeType.Text + ? { + textId: generateId(), + type: EditorNodeType.Text, + children: cloneDeep(firstTextNode.children), + } + : undefined; + + if (textNode) { + cloneNode.children.push(textNode); + } + + const cloneChildren = children.map((child) => { + return CustomEditor.cloneBlock(editor, child); + }); + + cloneNode.children.push(...cloneChildren); + + return cloneNode; + }, + + duplicateNode(editor: ReactEditor, node: Element) { + const cloneNode = CustomEditor.cloneBlock(editor, node); + + const path = ReactEditor.findPath(editor, node); + + const nextPath = Path.next(path); + + Transforms.insertNodes(editor, cloneNode, { at: nextPath }); + return cloneNode; + }, + + deleteNode(editor: ReactEditor, node: Node) { + const path = ReactEditor.findPath(editor, node); + + Transforms.removeNodes(editor, { + at: path, + }); + editor.collapse({ + edge: 'start', + }); + }, + + getBlockType: (editor: ReactEditor) => { + const match = CustomEditor.getBlock(editor); + + if (!match) return null; + + const [node] = match as NodeEntry; + + return node.type as EditorNodeType; + }, + + selectionIncludeRoot: (editor: ReactEditor) => { + const [match] = Editor.nodes(editor, { + match: (n) => Element.isElement(n) && n.blockId !== undefined && n.type === EditorNodeType.Page, + }); + + return Boolean(match); + }, + + isCodeBlock: (editor: ReactEditor) => { + return CustomEditor.getBlockType(editor) === EditorNodeType.CodeBlock; + }, + + insertEmptyLine: (editor: ReactEditor & YjsEditor, path: Path) => { + editor.insertNode( + { + type: EditorNodeType.Paragraph, + data: {}, + blockId: generateId(), + children: [ + { + type: EditorNodeType.Text, + textId: generateId(), + children: [ + { + text: '', + }, + ], + }, + ], + }, + { + select: true, + at: path, + } + ); + ReactEditor.focus(editor); + Transforms.move(editor); + }, + + insertEmptyLineAtEnd: (editor: ReactEditor & YjsEditor) => { + CustomEditor.insertEmptyLine(editor, [editor.children.length]); + }, + + focusAtStartOfBlock(editor: ReactEditor) { + const { selection } = editor; + + if (selection && Range.isCollapsed(selection)) { + const match = CustomEditor.getBlock(editor); + const [, path] = match as NodeEntry; + const start = Editor.start(editor, path); + + return match && Point.equals(selection.anchor, start); + } + + return false; + }, + + setBlockColor( + editor: ReactEditor, + node: Element, + data: { + font_color?: string; + bg_color?: string; + } + ) { + const path = ReactEditor.findPath(editor, node); + + const nodeData = node.data || {}; + const newProperties = { + data: { + ...nodeData, + ...data, + }, + } as Partial; + + Transforms.setNodes(editor, newProperties, { at: path }); + editor.select(path); + }, + + deleteAllText(editor: ReactEditor, node: Element) { + const [textNode] = (node.children || []) as Element[]; + const hasTextNode = textNode && textNode.type === EditorNodeType.Text; + + if (!hasTextNode) return; + const path = ReactEditor.findPath(editor, textNode); + const textLength = editor.string(path).length; + const start = Editor.start(editor, path); + + for (let i = 0; i < textLength; i++) { + editor.select(start); + editor.deleteForward('character'); + } + }, + + getNodeText: (editor: ReactEditor, node: Element) => { + const [textNode] = (node.children || []) as Element[]; + const hasTextNode = textNode && textNode.type === EditorNodeType.Text; + + if (!hasTextNode) return ''; + + const path = ReactEditor.findPath(editor, textNode); + + return editor.string(path); + }, + + isEmptyText: (editor: ReactEditor, node: Element) => { + const [textNode] = (node.children || []) as Element[]; + const hasTextNode = textNode && textNode.type === EditorNodeType.Text; + + if (!hasTextNode) return false; + + return editor.isEmpty(textNode); + }, + + includeInlineBlocks: (editor: ReactEditor) => { + const [match] = Editor.nodes(editor, { + match: (n) => Element.isElement(n) && editor.isInline(n), + }); + + return Boolean(match); + }, + + getNodeTextContent(node: Node): string { + if (Element.isElement(node) && node.type === EditorInlineNodeType.Formula) { + return (node as FormulaNode).data || ''; + } + + if (Text.isText(node)) { + return node.text || ''; + } + + return node.children.map((n) => CustomEditor.getNodeTextContent(n)).join(''); + }, + + isEmbedNode(node: Element): boolean { + return EmbedTypes.includes(node.type); + }, + + getListLevel(editor: ReactEditor, type: EditorNodeType, path: Path) { + let level = 0; + let currentPath = path; + + while (currentPath.length > 0) { + const parent = editor.parent(currentPath); + + if (!parent) { + break; + } + + const [parentNode, parentPath] = parent as NodeEntry; + + if (parentNode.type !== type) { + break; + } + + level += 1; + currentPath = parentPath; + } + + return level; + }, + + getLinks(editor: ReactEditor): string[] { + const marks = getAllMarks(editor); + + if (!marks) return []; + + return Object.entries(marks) + .filter(([key]) => key === 'href') + .map(([_, val]) => val as string); + }, + + extendLineBackward(editor: ReactEditor) { + Transforms.move(editor, { + unit: 'line', + edge: 'focus', + reverse: true, + }); + }, + + extendLineForward(editor: ReactEditor) { + Transforms.move(editor, { unit: 'line', edge: 'focus' }); + }, + + insertPlainText(editor: ReactEditor, text: string) { + const [appendText, ...lines] = text.split('\n'); + + editor.insertText(appendText); + lines.forEach((line) => { + editor.insertBreak(); + editor.insertText(line); + }); + }, + + highlight(editor: ReactEditor) { + addMark(editor, EditorMarkFormat.BgColor, 'appflowy_them_color_tint5'); + }, +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts new file mode 100644 index 0000000000000..649eaca5647e7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts @@ -0,0 +1,137 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Text, Range, Element } from 'slate'; +import { EditorInlineNodeType, EditorMarkFormat } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command/index'; + +export function toggleMark( + editor: ReactEditor, + mark: { + key: EditorMarkFormat; + value: string | boolean; + } +) { + if (CustomEditor.selectionIncludeRoot(editor)) { + return; + } + + const { key, value } = mark; + + const isActive = isMarkActive(editor, key); + + if (isActive || !value) { + Editor.removeMark(editor, key as string); + } else if (value) { + Editor.addMark(editor, key as string, value); + } +} + +/** + * Check if the every text in the selection has the mark. + * @param editor + * @param format + */ +export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat | EditorInlineNodeType) { + const selection = editor.selection; + + if (!selection) return false; + + const isExpanded = Range.isExpanded(selection); + + if (isExpanded) { + const texts = getSelectionTexts(editor); + + return texts.every((node) => { + const { text, ...attributes } = node; + + if (!text) return true; + return Boolean((attributes as Record)[format]); + }); + } + + const marks = Editor.marks(editor) as Record | null; + + return marks ? !!marks[format] : false; +} + +export function getSelectionTexts(editor: ReactEditor) { + const selection = editor.selection; + + if (!selection) return []; + + const texts: Text[] = []; + + const isExpanded = Range.isExpanded(selection); + + if (isExpanded) { + let anchor = Range.start(selection); + const focus = Range.end(selection); + const isEnd = Editor.isEnd(editor, anchor, anchor.path); + + if (isEnd) { + const after = Editor.after(editor, anchor); + + if (after) { + anchor = after; + } + } + + Array.from( + Editor.nodes(editor, { + at: { + anchor, + focus, + }, + }) + ).forEach((match) => { + const node = match[0] as Element; + + if (Text.isText(node)) { + texts.push(node); + } else if (Editor.isInline(editor, node)) { + texts.push(...(node.children as Text[])); + } + }); + } + + return texts; +} + +/** + * Get all marks in the current selection. + * @param editor + */ +export function getAllMarks(editor: ReactEditor) { + const selection = editor.selection; + + if (!selection) return null; + + const isExpanded = Range.isExpanded(selection); + + if (isExpanded) { + const texts = getSelectionTexts(editor); + + const marks: Record = {}; + + texts.forEach((node) => { + Object.entries(node).forEach(([key, value]) => { + if (key !== 'text') { + marks[key] = value; + } + }); + }); + + return marks; + } + + return Editor.marks(editor) as Record | null; +} + +export function removeMarks(editor: ReactEditor) { + const marks = getAllMarks(editor); + + if (!marks) return; + + for (const key in marks) { + Editor.removeMark(editor, key); + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts new file mode 100644 index 0000000000000..819596f92f9db --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts @@ -0,0 +1,82 @@ +import { Path, Element, NodeEntry } from 'slate'; +import { ReactEditor } from 'slate-react'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command/index'; + +export const LIST_TYPES = [ + EditorNodeType.NumberedListBlock, + EditorNodeType.BulletedListBlock, + EditorNodeType.TodoListBlock, + EditorNodeType.ToggleListBlock, + EditorNodeType.QuoteBlock, + EditorNodeType.Paragraph, +]; + +/** + * Indent the current list item + * Conditions: + * 1. The current node must be a list item + * 2. The previous node must be a list + * 3. The previous node must be the same level as the current node + * Result: + * 1. The current node will be the child of the previous node + * 2. The current node will be indented + * 3. The children of the current node will be moved to the children of the previous node + * @param editor + */ +export function tabForward(editor: ReactEditor) { + const match = CustomEditor.getBlock(editor); + + if (!match) return; + + const [node, path] = match as NodeEntry; + + const hasPrevious = Path.hasPrevious(path); + + if (!hasPrevious) return; + + const previousPath = Path.previous(path); + + const previous = editor.node(previousPath); + const [previousNode] = previous as NodeEntry; + + if (!previousNode) return; + + const type = previousNode.type as EditorNodeType; + + if (type === EditorNodeType.Page) return; + // the previous node is not a list + if (!LIST_TYPES.includes(type)) return; + + const toPath = [...previousPath, previousNode.children.length]; + + editor.moveNodes({ + at: path, + to: toPath, + }); + + const length = node.children.length; + + for (let i = length - 1; i > 0; i--) { + editor.liftNodes({ + at: [...toPath, i], + }); + } +} + +export function tabBackward(editor: ReactEditor) { + const match = CustomEditor.getBlock(editor); + + if (!match) return; + + const [node, path] = match as NodeEntry; + + const depth = path.length; + + if (node.type === EditorNodeType.Page) return; + + if (depth === 1) return; + editor.liftNodes({ + at: path, + }); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/Placeholder.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/Placeholder.tsx new file mode 100644 index 0000000000000..d9a60f09aded4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/Placeholder.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Element } from 'slate'; +import PlaceholderContent from '$app/components/editor/components/blocks/_shared/PlaceholderContent'; + +function Placeholder({ node, isEmpty }: { node: Element; isEmpty: boolean }) { + if (!isEmpty) { + return null; + } + + return ; +} + +export default React.memo(Placeholder); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx new file mode 100644 index 0000000000000..91645e0051f3c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx @@ -0,0 +1,130 @@ +import React, { CSSProperties, useEffect, useMemo, useState } from 'react'; +import { ReactEditor, useSelected, useSlate } from 'slate-react'; +import { Editor, Element, Range } from 'slate'; +import { EditorNodeType, HeadingNode } from '$app/application/document/document.types'; +import { useTranslation } from 'react-i18next'; + +function PlaceholderContent({ node, ...attributes }: { node: Element; className?: string; style?: CSSProperties }) { + const { t } = useTranslation(); + const editor = useSlate(); + const selected = useSelected() && !!editor.selection && Range.isCollapsed(editor.selection); + const [isComposing, setIsComposing] = useState(false); + const block = useMemo(() => { + const path = ReactEditor.findPath(editor, node); + const match = Editor.above(editor, { + match: (n) => { + return !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined; + }, + at: path, + }); + + if (!match) return null; + + return match[0] as Element; + }, [editor, node]); + + const className = useMemo(() => { + return `text-placeholder select-none ${attributes.className ?? ''}`; + }, [attributes.className]); + + const unSelectedPlaceholder = useMemo(() => { + switch (block?.type) { + case EditorNodeType.Paragraph: { + if (editor.children.length === 1) { + return t('editor.slashPlaceHolder'); + } + + return ''; + } + + case EditorNodeType.ToggleListBlock: + return t('blockPlaceholders.bulletList'); + case EditorNodeType.QuoteBlock: + return t('blockPlaceholders.quote'); + case EditorNodeType.TodoListBlock: + return t('blockPlaceholders.todoList'); + case EditorNodeType.NumberedListBlock: + return t('blockPlaceholders.numberList'); + case EditorNodeType.BulletedListBlock: + return t('blockPlaceholders.bulletList'); + case EditorNodeType.HeadingBlock: { + const level = (block as HeadingNode).data.level; + + switch (level) { + case 1: + return t('editor.mobileHeading1'); + case 2: + return t('editor.mobileHeading2'); + case 3: + return t('editor.mobileHeading3'); + default: + return ''; + } + } + + case EditorNodeType.Page: + return t('document.title.placeholder'); + case EditorNodeType.CalloutBlock: + case EditorNodeType.CodeBlock: + return t('editor.typeSomething'); + default: + return ''; + } + }, [block, t, editor.children.length]); + + const selectedPlaceholder = useMemo(() => { + switch (block?.type) { + case EditorNodeType.HeadingBlock: + return unSelectedPlaceholder; + case EditorNodeType.Page: + return t('document.title.placeholder'); + case EditorNodeType.GridBlock: + case EditorNodeType.EquationBlock: + case EditorNodeType.CodeBlock: + case EditorNodeType.DividerBlock: + return ''; + + default: + return t('editor.slashPlaceHolder'); + } + }, [block?.type, t, unSelectedPlaceholder]); + + useEffect(() => { + if (!selected) return; + + const handleCompositionStart = () => { + setIsComposing(true); + }; + + const handleCompositionEnd = () => { + setIsComposing(false); + }; + + const editorDom = ReactEditor.toDOMNode(editor, editor); + + // placeholder should be hidden when composing + editorDom.addEventListener('compositionstart', handleCompositionStart); + editorDom.addEventListener('compositionend', handleCompositionEnd); + editorDom.addEventListener('compositionupdate', handleCompositionStart); + return () => { + editorDom.removeEventListener('compositionstart', handleCompositionStart); + editorDom.removeEventListener('compositionend', handleCompositionEnd); + editorDom.removeEventListener('compositionupdate', handleCompositionStart); + }; + }, [editor, selected]); + + if (isComposing) { + return null; + } + + return ( + + ); +} + +export default PlaceholderContent; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/unSupportBlock.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/unSupportBlock.tsx new file mode 100644 index 0000000000000..9e9e4fcb384e4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/unSupportBlock.tsx @@ -0,0 +1,12 @@ +import React, { forwardRef } from 'react'; +import { Alert } from '@mui/material'; + +export const UnSupportBlock = forwardRef((_, ref) => { + return ( +
+ +
+ ); +}); + +export default UnSupportBlock; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedList.tsx new file mode 100644 index 0000000000000..41fce1c9dc7e9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedList.tsx @@ -0,0 +1,14 @@ +import React, { forwardRef, memo } from 'react'; +import { EditorElementProps, BulletedListNode } from '$app/application/document/document.types'; + +export const BulletedList = memo( + forwardRef>( + ({ node: _, children, className, ...attributes }, ref) => { + return ( +
+ {children} +
+ ); + } + ) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx new file mode 100644 index 0000000000000..ea0de80f55d92 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx @@ -0,0 +1,51 @@ +import React, { useMemo } from 'react'; +import { BulletedListNode } from '$app/application/document/document.types'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; + +enum Letter { + Disc, + Circle, + Square, +} + +function BulletedListIcon({ block, className }: { block: BulletedListNode; className: string }) { + const staticEditor = useSlateStatic(); + const path = ReactEditor.findPath(staticEditor, block); + + const letter = useMemo(() => { + const level = CustomEditor.getListLevel(staticEditor, block.type, path); + + if (level % 3 === 0) { + return Letter.Disc; + } else if (level % 3 === 1) { + return Letter.Circle; + } else { + return Letter.Square; + } + }, [block.type, staticEditor, path]); + + const dataLetter = useMemo(() => { + switch (letter) { + case Letter.Disc: + return '•'; + case Letter.Circle: + return '◦'; + case Letter.Square: + return '▪'; + } + }, [letter]); + + return ( + { + e.preventDefault(); + }} + data-letter={dataLetter} + contentEditable={false} + className={`${className} bulleted-icon flex min-w-[24px] justify-center pr-1 font-medium`} + /> + ); +} + +export default BulletedListIcon; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/index.ts new file mode 100644 index 0000000000000..2095dff308552 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/index.ts @@ -0,0 +1 @@ +export * from './BulletedList'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx new file mode 100644 index 0000000000000..a20300bbc25b5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx @@ -0,0 +1,20 @@ +import React, { forwardRef, memo } from 'react'; +import { EditorElementProps, CalloutNode } from '$app/application/document/document.types'; +import CalloutIcon from '$app/components/editor/components/blocks/callout/CalloutIcon'; + +export const Callout = memo( + forwardRef>(({ node, children, ...attributes }, ref) => { + return ( + <> +
+ +
+
+
+ {children} +
+
+ + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/CalloutIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/CalloutIcon.tsx new file mode 100644 index 0000000000000..e9bba448a7b3d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/CalloutIcon.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { IconButton } from '@mui/material'; +import { CalloutNode } from '$app/application/document/document.types'; +import EmojiPicker from '$app/components/_shared/emoji_picker/EmojiPicker'; +import Popover from '@mui/material/Popover'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; + +function CalloutIcon({ node }: { node: CalloutNode }) { + const ref = useRef(null); + const [open, setOpen] = useState(false); + const editor = useSlateStatic(); + + const handleClose = useCallback(() => { + setOpen(false); + const path = ReactEditor.findPath(editor, node); + + ReactEditor.focus(editor); + editor.select(path); + editor.collapse({ + edge: 'start', + }); + }, [editor, node]); + const handleEmojiSelect = useCallback( + (emoji: string) => { + CustomEditor.setCalloutIcon(editor, node, emoji); + handleClose(); + }, + [editor, node, handleClose] + ); + + return ( + <> + { + setOpen(true); + }} + className={`h-8 w-8 p-1`} + > + {node.data.icon} + + {open && ( + + + + )} + + ); +} + +export default React.memo(CalloutIcon); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/index.ts new file mode 100644 index 0000000000000..4ca74e4be8ead --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/index.ts @@ -0,0 +1 @@ +export * from './Callout'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.hooks.ts new file mode 100644 index 0000000000000..0b043f4579587 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.hooks.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { Element as SlateElement, Transforms } from 'slate'; +import { CodeNode } from '$app/application/document/document.types'; + +export function useCodeBlock(node: CodeNode) { + const language = node.data.language; + const editor = useSlateStatic() as ReactEditor; + const handleChangeLanguage = useCallback( + (newLang: string) => { + const path = ReactEditor.findPath(editor, node); + const newProperties = { + data: { + language: newLang, + }, + } as Partial; + + Transforms.setNodes(editor, newProperties, { at: path }); + }, + [editor, node] + ); + + return { + language, + handleChangeLanguage, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.tsx new file mode 100644 index 0000000000000..7fe7b205f4da6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.tsx @@ -0,0 +1,39 @@ +import { forwardRef, memo, useCallback } from 'react'; +import { EditorElementProps, CodeNode } from '$app/application/document/document.types'; +import LanguageSelect from './SelectLanguage'; + +import { useCodeBlock } from '$app/components/editor/components/blocks/code/Code.hooks'; +import { ReactEditor, useSlateStatic } from 'slate-react'; + +export const Code = memo( + forwardRef>(({ node, children, ...attributes }, ref) => { + const { language, handleChangeLanguage } = useCodeBlock(node); + + const editor = useSlateStatic(); + const onBlur = useCallback(() => { + const path = ReactEditor.findPath(editor, node); + + ReactEditor.focus(editor); + editor.select(path); + editor.collapse({ + edge: 'start', + }); + }, [editor, node]); + + return ( + <> +
+ +
+
+
+            {children}
+          
+
+ + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx new file mode 100644 index 0000000000000..4805233e1dc7f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx @@ -0,0 +1,168 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { TextField, Popover } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { supportLanguage } from './constants'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; + +const initialOrigin: { + transformOrigin: PopoverOrigin; + anchorOrigin: PopoverOrigin; +} = { + transformOrigin: { + vertical: 'top', + horizontal: 'left', + }, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'left', + }, +}; + +function SelectLanguage({ + language = 'json', + onChangeLanguage, + onBlur, +}: { + language: string; + onChangeLanguage: (language: string) => void; + onBlur?: () => void; +}) { + const { t } = useTranslation(); + const ref = useRef(null); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + + const searchRef = useRef(null); + const scrollRef = useRef(null); + const options: KeyboardNavigationOption[] = useMemo(() => { + return supportLanguage + .map((item) => ({ + key: item.id, + content: item.title, + })) + .filter((item) => { + return item.content?.toLowerCase().includes(search.toLowerCase()); + }); + }, [search]); + + const handleClose = useCallback(() => { + setOpen(false); + setSearch(''); + }, []); + + const handleConfirm = useCallback( + (key: string) => { + onChangeLanguage(key); + handleClose(); + }, + [onChangeLanguage, handleClose] + ); + + useEffect(() => { + const element = ref.current; + + if (!element) return; + const handleKeyDown = (e: KeyboardEvent) => { + e.stopPropagation(); + e.preventDefault(); + + if (e.key === 'Enter') { + setOpen(true); + return; + } + + onBlur?.(); + }; + + element.addEventListener('keydown', handleKeyDown); + + return () => { + element.removeEventListener('keydown', handleKeyDown); + }; + }, [onBlur]); + + const { paperHeight, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ + initialPaperWidth: 200, + initialPaperHeight: 220, + anchorEl: ref.current, + initialAnchorOrigin: initialOrigin.anchorOrigin, + initialTransformOrigin: initialOrigin.transformOrigin, + open, + }); + + return ( + <> + { + setOpen(true); + }} + InputProps={{ + readOnly: true, + }} + placeholder={t('document.codeBlock.language.placeholder')} + label={t('document.codeBlock.language.label')} + /> + + {open && ( + +
+ setSearch(e.target.value)} + size={'small'} + autoFocus={true} + variant={'standard'} + className={'px-2 text-xs'} + placeholder={t('search.label')} + /> +
+ +
+
+
+ )} + + ); +} + +export default SelectLanguage; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/constants.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/constants.ts new file mode 100644 index 0000000000000..dee71624db911 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/constants.ts @@ -0,0 +1,154 @@ +export const supportLanguage = [ + { + id: 'bash', + title: 'Bash', + }, + { + id: 'basic', + title: 'Basic', + }, + { + id: 'c', + title: 'C', + }, + { + id: 'clojure', + title: 'Clojure', + }, + { + id: 'cpp', + title: 'C++', + }, + { + id: 'cs', + title: 'CS', + }, + { + id: 'css', + title: 'CSS', + }, + { + id: 'dart', + title: 'Dart', + }, + { + id: 'elixir', + title: 'Elixir', + }, + { + id: 'elm', + title: 'Elm', + }, + { + id: 'erlang', + title: 'Erlang', + }, + { + id: 'fortran', + title: 'Fortran', + }, + { + id: 'go', + title: 'Go', + }, + { + id: 'graphql', + title: 'GraphQL', + }, + { + id: 'haskell', + title: 'Haskell', + }, + { + id: 'java', + title: 'Java', + }, + { + id: 'javascript', + title: 'JavaScript', + }, + { + id: 'json', + title: 'JSON', + }, + { + id: 'kotlin', + title: 'Kotlin', + }, + { + id: 'lisp', + title: 'Lisp', + }, + { + id: 'lua', + title: 'Lua', + }, + { + id: 'markdown', + title: 'Markdown', + }, + { + id: 'matlab', + title: 'Matlab', + }, + { + id: 'ocaml', + title: 'OCaml', + }, + { + id: 'perl', + title: 'Perl', + }, + { + id: 'php', + title: 'PHP', + }, + { + id: 'powershell', + title: 'Powershell', + }, + { + id: 'python', + title: 'Python', + }, + { + id: 'r', + title: 'R', + }, + { + id: 'ruby', + title: 'Ruby', + }, + { + id: 'rust', + title: 'Rust', + }, + { + id: 'scala', + title: 'Scala', + }, + { + id: 'shell', + title: 'Shell', + }, + { + id: 'sql', + title: 'SQL', + }, + { + id: 'swift', + title: 'Swift', + }, + { + id: 'typescript', + title: 'TypeScript', + }, + { + id: 'xml', + title: 'XML', + }, + { + id: 'yaml', + title: 'YAML', + }, +]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/index.ts new file mode 100644 index 0000000000000..c3aa9443d1dd1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/index.ts @@ -0,0 +1 @@ +export * from './Code'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/utils.ts new file mode 100644 index 0000000000000..52eeebc8c4c1e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/utils.ts @@ -0,0 +1,132 @@ +import Prism from 'prismjs'; + +import 'prismjs/components/prism-bash'; +import 'prismjs/components/prism-basic'; +import 'prismjs/components/prism-c'; +import 'prismjs/components/prism-clojure'; +import 'prismjs/components/prism-cpp'; +import 'prismjs/components/prism-csp'; +import 'prismjs/components/prism-css'; +import 'prismjs/components/prism-dart'; +import 'prismjs/components/prism-elixir'; +import 'prismjs/components/prism-elm'; +import 'prismjs/components/prism-erlang'; +import 'prismjs/components/prism-fortran'; +import 'prismjs/components/prism-go'; +import 'prismjs/components/prism-graphql'; +import 'prismjs/components/prism-haskell'; +import 'prismjs/components/prism-java'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/components/prism-json'; +import 'prismjs/components/prism-kotlin'; +import 'prismjs/components/prism-lisp'; +import 'prismjs/components/prism-lua'; +import 'prismjs/components/prism-markdown'; +import 'prismjs/components/prism-matlab'; +import 'prismjs/components/prism-ocaml'; +import 'prismjs/components/prism-perl'; +import 'prismjs/components/prism-php'; +import 'prismjs/components/prism-powershell'; +import 'prismjs/components/prism-python'; +import 'prismjs/components/prism-r'; +import 'prismjs/components/prism-ruby'; +import 'prismjs/components/prism-rust'; +import 'prismjs/components/prism-scala'; +import 'prismjs/components/prism-shell-session'; +import 'prismjs/components/prism-sql'; +import 'prismjs/components/prism-swift'; +import 'prismjs/components/prism-typescript'; +import 'prismjs/components/prism-xml-doc'; +import 'prismjs/components/prism-yaml'; + +import { BaseRange, NodeEntry, Text, Path } from 'slate'; + +const push_string = ( + token: string | Prism.Token, + path: Path, + start: number, + ranges: BaseRange[], + token_type = 'text' +) => { + let newStart = start; + + ranges.push({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prism_token: token_type, + anchor: { path, offset: newStart }, + focus: { path, offset: newStart + token.length }, + }); + newStart += token.length; + return newStart; +}; + +// This recurses through the Prism.tokenizes result and creates stylized ranges based on the token type +const recurseTokenize = ( + token: string | Prism.Token, + path: Path, + ranges: BaseRange[], + start: number, + parent_tag?: string +) => { + // Uses the parent's token type if a Token only has a string as its content + if (typeof token === 'string') { + return push_string(token, path, start, ranges, parent_tag); + } + + if ('content' in token) { + if (token.content instanceof Array) { + // Calls recurseTokenize on nested Tokens in content + let newStart = start; + + for (const subToken of token.content) { + newStart = recurseTokenize(subToken, path, ranges, newStart, token.type) || 0; + } + + return newStart; + } + + return push_string(token.content, path, start, ranges, token.type); + } +}; + +function switchCodeTheme(isDark: boolean) { + const link = document.getElementById('prism-css'); + + if (link) { + document.head.removeChild(link); + } + + const newLink = document.createElement('link'); + + newLink.rel = 'stylesheet'; + newLink.href = isDark + ? 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.24.1/themes/prism-dark.min.css' + : 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.24.1/themes/prism.min.css'; + newLink.id = 'prism-css'; + document.head.appendChild(newLink); +} + +export const decorateCode = ([node, path]: NodeEntry, language: string, isDark: boolean) => { + switchCodeTheme(isDark); + + const ranges: BaseRange[] = []; + + if (!Text.isText(node)) { + return ranges; + } + + try { + const tokens = Prism.tokenize(node.text, Prism.languages[language]); + + let start = 0; + + for (const token of tokens) { + start = recurseTokenize(token, path, ranges, start) || 0; + } + + return ranges; + } catch { + return ranges; + } +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseEmpty.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseEmpty.tsx new file mode 100644 index 0000000000000..a0f50016e1de4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseEmpty.tsx @@ -0,0 +1,42 @@ +import React, { useCallback, useRef } from 'react'; +import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder'; + +import { GridNode } from '$app/application/document/document.types'; +import { useTranslation } from 'react-i18next'; + +import Drawer from '$app/components/editor/components/blocks/database/Drawer'; + +function DatabaseEmpty({ node }: { node: GridNode }) { + const { t } = useTranslation(); + const ref = useRef(null); + + const [open, setOpen] = React.useState(false); + + const toggleDrawer = useCallback((open: boolean) => { + return (e: React.MouseEvent | KeyboardEvent | React.FocusEvent) => { + e.stopPropagation(); + setOpen(open); + }; + }, []); + + return ( +
+ +
{t('document.plugins.database.noDataSource')}
+
+ + {t('document.plugins.database.selectADataSource')} + + {t('document.plugins.database.toContinue')} +
+ + +
+ ); +} + +export default React.memo(DatabaseEmpty); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.hooks.ts new file mode 100644 index 0000000000000..543b9900ca5e8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.hooks.ts @@ -0,0 +1,26 @@ +import { useAppSelector } from '$app/stores/store'; +import { ViewLayoutPB } from '@/services/backend'; + +export function useLoadDatabaseList({ searchText, layout }: { searchText: string; layout: ViewLayoutPB }) { + const list = useAppSelector((state) => { + const workspaces = state.workspace.workspaces.map((item) => item.id) ?? []; + + return Object.values(state.pages.pageMap).filter((page) => { + if (page.layout !== layout) return false; + const parentId = page.parentId; + + if (!parentId) return false; + + const parent = state.pages.pageMap[parentId]; + const parentLayout = parent?.layout; + + if (!workspaces.includes(parentId) && parentLayout !== ViewLayoutPB.Document) return false; + + return page.name.toLowerCase().includes(searchText.toLowerCase()); + }); + }); + + return { + list, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx new file mode 100644 index 0000000000000..5d06a13c06463 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx @@ -0,0 +1,104 @@ +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TextField } from '@mui/material'; +import { useLoadDatabaseList } from '$app/components/editor/components/blocks/database/DatabaseList.hooks'; +import { ViewLayoutPB } from '@/services/backend'; +import { ReactComponent as GridSvg } from '$app/assets/grid.svg'; +import { useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; +import { GridNode } from '$app/application/document/document.types'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { Page } from '$app_reducers/pages/slice'; + +function DatabaseList({ + node, + toggleDrawer, +}: { + node: GridNode; + toggleDrawer: (open: boolean) => (e: React.MouseEvent | KeyboardEvent | React.FocusEvent) => void; +}) { + const scrollRef = React.useRef(null); + + const inputRef = React.useRef(null); + const editor = useSlateStatic(); + const { t } = useTranslation(); + const [searchText, setSearchText] = React.useState(''); + const { list } = useLoadDatabaseList({ + searchText: searchText || '', + layout: ViewLayoutPB.Grid, + }); + + const renderItem = useCallback( + (item: Page) => { + return ( +
+ +
{item.name.trim() || t('menuAppHeader.defaultNewPageName')}
+
+ ); + }, + [t] + ); + + const options: KeyboardNavigationOption[] = useMemo(() => { + return list.map((item) => { + return { + key: item.id, + content: renderItem(item), + }; + }); + }, [list, renderItem]); + + const handleSelected = useCallback( + (id: string) => { + CustomEditor.setGridBlockViewId(editor, node, id); + }, + [editor, node] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + toggleDrawer(false)(e); + } + }, + [toggleDrawer] + ); + + return ( +
+ { + setSearchText((e.currentTarget as HTMLInputElement).value); + }} + inputProps={{ + className: 'py-2 text-sm', + }} + placeholder={t('document.plugins.database.linkToDatabase')} + /> +
+ +
+
+ ); +} + +export default DatabaseList; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/Drawer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/Drawer.tsx new file mode 100644 index 0000000000000..54c0005027df2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/Drawer.tsx @@ -0,0 +1,73 @@ +import React, { useCallback } from 'react'; +import { Button, IconButton } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { createGrid } from '$app/components/editor/components/blocks/database/utils'; +import { CustomEditor } from '$app/components/editor/command'; +import { useSlateStatic } from 'slate-react'; +import { useEditorId } from '$app/components/editor/Editor.hooks'; +import { GridNode } from '$app/application/document/document.types'; +import { ReactComponent as CloseSvg } from '$app/assets/close.svg'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import DatabaseList from '$app/components/editor/components/blocks/database/DatabaseList'; + +function Drawer({ + open, + toggleDrawer, + node, +}: { + open: boolean; + toggleDrawer: (open: boolean) => (e: React.MouseEvent | KeyboardEvent | React.FocusEvent) => void; + node: GridNode; +}) { + const editor = useSlateStatic(); + const id = useEditorId(); + const { t } = useTranslation(); + const handleCreateGrid = useCallback(async () => { + const gridId = await createGrid(id); + + CustomEditor.setGridBlockViewId(editor, node, gridId); + }, [id, editor, node]); + + return ( +
{ + e.stopPropagation(); + }} + className={'absolute right-0 top-0 h-full transform overflow-hidden'} + style={{ + width: open ? '250px' : '0px', + transition: 'width 0.3s ease-in-out', + }} + onMouseDown={(e) => { + const isInput = (e.target as HTMLElement).closest('input'); + + if (isInput) return; + e.stopPropagation(); + e.preventDefault(); + }} + > +
+
+
{t('document.plugins.database.selectDataSource')}
+ + + +
+
+ {open && } +
+ +
+ +
+
+
+ ); +} + +export default Drawer; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridBlock.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridBlock.tsx new file mode 100644 index 0000000000000..936da9c2c8e22 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridBlock.tsx @@ -0,0 +1,31 @@ +import React, { forwardRef, memo } from 'react'; +import { EditorElementProps, GridNode } from '$app/application/document/document.types'; + +import GridView from '$app/components/editor/components/blocks/database/GridView'; +import DatabaseEmpty from '$app/components/editor/components/blocks/database/DatabaseEmpty'; +import { useSelected } from 'slate-react'; + +export const GridBlock = memo( + forwardRef>(({ node, children, className = '', ...attributes }, ref) => { + const viewId = node.data.viewId; + const selected = useSelected(); + + return ( +
+
+ {children} +
+
+ {viewId ? : } +
+
+ ); + }) +); + +export default GridBlock; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx new file mode 100644 index 0000000000000..695482bbd8818 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx @@ -0,0 +1,71 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Database, DatabaseRenderedProvider } from '$app/components/database'; +import { ViewIdProvider } from '$app/hooks'; + +function GridView({ viewId }: { viewId: string }) { + const [selectedViewId, onChangeSelectedViewId] = useState(viewId); + + const ref = useRef(null); + + const [rendered, setRendered] = useState<{ viewId: string; rendered: boolean } | undefined>(undefined); + + // delegate wheel event to layout when grid is scrolled to top or bottom + useEffect(() => { + const element = ref.current; + + const viewId = rendered?.viewId; + + if (!viewId || !element) { + return; + } + + const gridScroller = element.querySelector(`[data-view-id="${viewId}"] .grid-scroll-container`) as HTMLDivElement; + + const scrollLayout = gridScroller?.closest('.appflowy-scroll-container') as HTMLDivElement; + + if (!gridScroller || !scrollLayout) { + return; + } + + const onWheel = (event: WheelEvent) => { + const deltaY = event.deltaY; + const deltaX = event.deltaX; + + if (Math.abs(deltaX) > 8) { + return; + } + + const { scrollTop, scrollHeight, clientHeight } = gridScroller; + + const atTop = deltaY < 0 && scrollTop === 0; + const atBottom = deltaY > 0 && scrollTop + clientHeight >= scrollHeight; + + // if at top or bottom, prevent default to allow layout to scroll + if (atTop || atBottom) { + scrollLayout.scrollTop += deltaY; + } + }; + + gridScroller.addEventListener('wheel', onWheel, { passive: false }); + return () => { + gridScroller.removeEventListener('wheel', onWheel); + }; + }, [rendered]); + + const onRendered = useCallback((viewId: string) => { + setRendered({ + viewId, + rendered: true, + }); + }, []); + + return ( + + + + + + ); +} + +export default React.memo(GridView); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/index.ts new file mode 100644 index 0000000000000..986343f9df576 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/index.ts @@ -0,0 +1 @@ +export * from './GridBlock'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/utils.ts new file mode 100644 index 0000000000000..032502b415b09 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/utils.ts @@ -0,0 +1,12 @@ +import { ViewLayoutPB } from '@/services/backend'; +import { createPage } from '$app/application/folder/page.service'; + +export async function createGrid(pageId: string) { + const newViewId = await createPage({ + layout: ViewLayoutPB.Grid, + name: '', + parent_view_id: pageId, + }); + + return newViewId; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx new file mode 100644 index 0000000000000..d7d475199bfaa --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx @@ -0,0 +1,28 @@ +import React, { forwardRef, memo, useMemo } from 'react'; +import { EditorElementProps, DividerNode as DividerNodeType } from '$app/application/document/document.types'; +import { useSelected } from 'slate-react'; + +export const DividerNode = memo( + forwardRef>( + ({ node: _node, children: children, ...attributes }, ref) => { + const selected = useSelected(); + + const className = useMemo(() => { + return `${attributes.className ?? ''} divider-node relative w-full rounded ${ + selected ? 'bg-content-blue-100' : '' + }`; + }, [attributes.className, selected]); + + return ( +
+
+
+
+
+ {children} +
+
+ ); + } + ) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/index.ts new file mode 100644 index 0000000000000..8f6141749a4d3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/index.ts @@ -0,0 +1 @@ +export * from './DividerNode'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/Heading.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/Heading.tsx new file mode 100644 index 0000000000000..4d23069c4668b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/Heading.tsx @@ -0,0 +1,18 @@ +import React, { forwardRef, memo } from 'react'; +import { EditorElementProps, HeadingNode } from '$app/application/document/document.types'; +import { getHeadingCssProperty } from '$app/components/editor/plugins/utils'; + +export const Heading = memo( + forwardRef>(({ node, children, ...attributes }, ref) => { + const level = node.data.level; + const fontSizeCssProperty = getHeadingCssProperty(level); + + const className = `${attributes.className ?? ''} ${fontSizeCssProperty}`; + + return ( +
+ {children} +
+ ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/index.ts new file mode 100644 index 0000000000000..6406e7b07f496 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/index.ts @@ -0,0 +1 @@ +export * from './Heading'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageActions.tsx new file mode 100644 index 0000000000000..b3d3575af21ca --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageActions.tsx @@ -0,0 +1,163 @@ +import React, { useMemo, useState } from 'react'; +import { ImageNode } from '$app/application/document/document.types'; +import { ReactComponent as CopyIcon } from '$app/assets/copy.svg'; +import { ReactComponent as AlignLeftIcon } from '$app/assets/align-left.svg'; +import { ReactComponent as AlignCenterIcon } from '$app/assets/align-center.svg'; +import { ReactComponent as AlignRightIcon } from '$app/assets/align-right.svg'; +import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg'; +import { useTranslation } from 'react-i18next'; +import { IconButton } from '@mui/material'; +import { notify } from '$app/components/_shared/notify'; +import { CustomEditor } from '$app/components/editor/command'; +import { useSlateStatic } from 'slate-react'; +import Popover from '@mui/material/Popover'; +import Tooltip from '@mui/material/Tooltip'; + +enum ImageAction { + Copy = 'copy', + AlignLeft = 'left', + AlignCenter = 'center', + AlignRight = 'right', + Delete = 'delete', +} + +function ImageActions({ node }: { node: ImageNode }) { + const { t } = useTranslation(); + const align = node.data.align; + const editor = useSlateStatic(); + const [alignAnchorEl, setAlignAnchorEl] = useState(null); + const alignOptions = useMemo(() => { + return [ + { + key: ImageAction.AlignLeft, + Icon: AlignLeftIcon, + onClick: () => { + CustomEditor.setImageBlockData(editor, node, { align: 'left' }); + setAlignAnchorEl(null); + }, + }, + { + key: ImageAction.AlignCenter, + Icon: AlignCenterIcon, + onClick: () => { + CustomEditor.setImageBlockData(editor, node, { align: 'center' }); + setAlignAnchorEl(null); + }, + }, + { + key: ImageAction.AlignRight, + Icon: AlignRightIcon, + onClick: () => { + CustomEditor.setImageBlockData(editor, node, { align: 'right' }); + setAlignAnchorEl(null); + }, + }, + ]; + }, [editor, node]); + const options = useMemo(() => { + return [ + { + key: ImageAction.Copy, + Icon: CopyIcon, + tooltip: t('button.copyLink'), + onClick: () => { + if (!node.data.url) return; + void navigator.clipboard.writeText(node.data.url); + notify.success(t('message.copy.success')); + }, + }, + (!align || align === 'left') && { + key: ImageAction.AlignLeft, + Icon: AlignLeftIcon, + tooltip: t('button.align'), + onClick: (e: React.MouseEvent) => { + setAlignAnchorEl(e.currentTarget); + }, + }, + align === 'center' && { + key: ImageAction.AlignCenter, + Icon: AlignCenterIcon, + tooltip: t('button.align'), + onClick: (e: React.MouseEvent) => { + setAlignAnchorEl(e.currentTarget); + }, + }, + align === 'right' && { + key: ImageAction.AlignRight, + Icon: AlignRightIcon, + tooltip: t('button.align'), + onClick: (e: React.MouseEvent) => { + setAlignAnchorEl(e.currentTarget); + }, + }, + { + key: ImageAction.Delete, + Icon: DeleteIcon, + tooltip: t('button.delete'), + onClick: () => { + CustomEditor.deleteNode(editor, node); + }, + }, + ].filter(Boolean) as { + key: ImageAction; + Icon: React.FC>; + tooltip: string; + onClick: (e: React.MouseEvent) => void; + }[]; + }, [align, node, t, editor]); + + return ( +
+ {options.map((option) => { + const { key, Icon, tooltip, onClick } = option; + + return ( + + + + + + ); + })} + {!!alignAnchorEl && ( + setAlignAnchorEl(null)} + > + {alignOptions.map((option) => { + const { key, Icon, onClick } = option; + + return ( + + + + ); + })} + + )} +
+ ); +} + +export default ImageActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageBlock.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageBlock.tsx new file mode 100644 index 0000000000000..661eb3e3deb51 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageBlock.tsx @@ -0,0 +1,49 @@ +import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react'; +import { EditorElementProps, ImageNode } from '$app/application/document/document.types'; +import { ReactEditor, useSelected, useSlateStatic } from 'slate-react'; +import ImageRender from '$app/components/editor/components/blocks/image/ImageRender'; +import ImageEmpty from '$app/components/editor/components/blocks/image/ImageEmpty'; + +export const ImageBlock = memo( + forwardRef>(({ node, children, className, ...attributes }, ref) => { + const selected = useSelected(); + const { url, align } = useMemo(() => node.data || {}, [node.data]); + const containerRef = useRef(null); + const editor = useSlateStatic(); + const onFocusNode = useCallback(() => { + ReactEditor.focus(editor); + const path = ReactEditor.findPath(editor, node); + + editor.select(path); + }, [editor, node]); + + return ( +
{ + if (!selected) onFocusNode(); + }} + className={`${className} image-block relative w-full cursor-pointer py-1`} + > +
+ {children} +
+
+ {url ? ( + + ) : ( + + )} +
+
+ ); + }) +); + +export default ImageBlock; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageEmpty.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageEmpty.tsx new file mode 100644 index 0000000000000..e0b649939e18c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageEmpty.tsx @@ -0,0 +1,63 @@ +import React, { useEffect } from 'react'; +import { ReactComponent as ImageIcon } from '$app/assets/image.svg'; +import { useTranslation } from 'react-i18next'; +import UploadPopover from '$app/components/editor/components/blocks/image/UploadPopover'; +import { EditorNodeType, ImageNode } from '$app/application/document/document.types'; +import { useEditorBlockDispatch, useEditorBlockState } from '$app/components/editor/stores/block'; + +function ImageEmpty({ + containerRef, + onEscape, + node, +}: { + containerRef: React.RefObject; + onEscape: () => void; + node: ImageNode; +}) { + const { t } = useTranslation(); + const state = useEditorBlockState(EditorNodeType.ImageBlock); + const open = Boolean(state?.popoverOpen && state?.blockId === node.blockId && containerRef.current); + const { openPopover, closePopover } = useEditorBlockDispatch(); + + useEffect(() => { + const container = containerRef.current; + + if (!container) { + return; + } + + const handleClick = () => { + openPopover(EditorNodeType.ImageBlock, node.blockId); + }; + + container.addEventListener('click', handleClick); + return () => { + container.removeEventListener('click', handleClick); + }; + }, [containerRef, node.blockId, openPopover]); + return ( + <> +
+ + {t('document.plugins.image.addAnImage')} +
+ {open && ( + { + closePopover(EditorNodeType.ImageBlock); + onEscape(); + }} + /> + )} + + ); +} + +export default ImageEmpty; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageRender.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageRender.tsx new file mode 100644 index 0000000000000..07310b05be028 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageRender.tsx @@ -0,0 +1,136 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ImageNode, ImageType } from '$app/application/document/document.types'; +import { useTranslation } from 'react-i18next'; +import { CircularProgress } from '@mui/material'; +import { ErrorOutline } from '@mui/icons-material'; +import ImageResizer from '$app/components/editor/components/blocks/image/ImageResizer'; +import { CustomEditor } from '$app/components/editor/command'; +import { useSlateStatic } from 'slate-react'; +import ImageActions from '$app/components/editor/components/blocks/image/ImageActions'; +import { LocalImage } from '$app/components/_shared/image_upload'; +import debounce from 'lodash-es/debounce'; + +const MIN_WIDTH = 100; + +const DELAY = 300; + +function ImageRender({ selected, node }: { selected: boolean; node: ImageNode }) { + const [loading, setLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + const imgRef = useRef(null); + const editor = useSlateStatic(); + const { url = '', width: imageWidth, image_type: source } = useMemo(() => node.data || {}, [node.data]); + const { t } = useTranslation(); + const blockId = node.blockId; + + const [showActions, setShowActions] = useState(false); + const [initialWidth, setInitialWidth] = useState(null); + const [newWidth, setNewWidth] = useState(imageWidth ?? null); + + const debounceSubmitWidth = useMemo(() => { + return debounce((newWidth: number) => { + CustomEditor.setImageBlockData(editor, node, { + width: newWidth, + }); + }, DELAY); + }, [editor, node]); + + const handleWidthChange = useCallback( + (newWidth: number) => { + setNewWidth(newWidth); + debounceSubmitWidth(newWidth); + }, + [debounceSubmitWidth] + ); + + useEffect(() => { + if (!loading && !hasError && initialWidth === null && imgRef.current) { + setInitialWidth(imgRef.current.offsetWidth); + } + }, [hasError, initialWidth, loading]); + const imageProps: React.ImgHTMLAttributes = useMemo(() => { + return { + style: { width: loading || hasError ? '0' : newWidth ?? '100%', opacity: selected ? 0.8 : 1 }, + className: 'object-cover', + ref: imgRef, + src: url, + draggable: false, + onLoad: () => { + setHasError(false); + setLoading(false); + }, + onError: () => { + setHasError(true); + setLoading(false); + }, + }; + }, [url, newWidth, loading, hasError, selected]); + + const renderErrorNode = useCallback(() => { + return ( +
+ +
{t('editor.imageLoadFailed')}
+
+ ); + }, [t]); + + if (!url) return null; + + return ( +
{ + setShowActions(true); + }} + onMouseLeave={() => { + setShowActions(false); + }} + style={{ + minWidth: MIN_WIDTH, + width: 'fit-content', + }} + className={`image-render relative min-h-[48px] ${ + hasError || (loading && source !== ImageType.Local) ? 'w-full' : '' + }`} + > + {source === ImageType.Local ? ( + { + setHasError(true); + return null; + }} + loading={'lazy'} + /> + ) : ( + {`image-${blockId}`} + )} + + {initialWidth && ( + <> + + + + )} + {showActions && } + {hasError ? ( + renderErrorNode() + ) : loading && source !== ImageType.Local ? ( +
+ +
{t('editor.loading')}
+
+ ) : null} +
+ ); +} + +export default ImageRender; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageResizer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageResizer.tsx new file mode 100644 index 0000000000000..e0d272acf32b0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageResizer.tsx @@ -0,0 +1,61 @@ +import React, { useCallback, useRef } from 'react'; + +function ImageResizer({ + minWidth, + width, + onWidthChange, + isLeft, +}: { + isLeft?: boolean; + minWidth: number; + width: number; + onWidthChange: (newWidth: number) => void; +}) { + const originalWidth = useRef(width); + const startX = useRef(0); + + const onResize = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + const diff = isLeft ? startX.current - e.clientX : e.clientX - startX.current; + const newWidth = originalWidth.current + diff; + + if (newWidth < minWidth) { + return; + } + + onWidthChange(newWidth); + }, + [isLeft, minWidth, onWidthChange] + ); + + const onResizeEnd = useCallback(() => { + document.removeEventListener('mousemove', onResize); + document.removeEventListener('mouseup', onResizeEnd); + }, [onResize]); + + const onResizeStart = useCallback( + (e: React.MouseEvent) => { + startX.current = e.clientX; + originalWidth.current = width; + document.addEventListener('mousemove', onResize); + document.addEventListener('mouseup', onResizeEnd); + }, + [onResize, onResizeEnd, width] + ); + + return ( +
+
+
+ ); +} + +export default ImageResizer; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/UploadPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/UploadPopover.tsx new file mode 100644 index 0000000000000..0aff9fb0cc229 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/UploadPopover.tsx @@ -0,0 +1,112 @@ +import React, { useMemo } from 'react'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; + +import { useTranslation } from 'react-i18next'; +import { EmbedLink, Unsplash, UploadTabs, TabOption, TAB_KEY, UploadImage } from '$app/components/_shared/image_upload'; +import { CustomEditor } from '$app/components/editor/command'; +import { useSlateStatic } from 'slate-react'; +import { ImageNode, ImageType } from '$app/application/document/document.types'; + +const initialOrigin: { + transformOrigin: PopoverOrigin; + anchorOrigin: PopoverOrigin; +} = { + transformOrigin: { + vertical: 'top', + horizontal: 'center', + }, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'center', + }, +}; + +function UploadPopover({ + open, + anchorEl, + onClose, + node, +}: { + open: boolean; + anchorEl: HTMLDivElement | null; + onClose: () => void; + node: ImageNode; +}) { + const editor = useSlateStatic(); + + const { t } = useTranslation(); + + const { transformOrigin, anchorOrigin, isEntered, paperHeight, paperWidth } = usePopoverAutoPosition({ + initialPaperWidth: 433, + initialPaperHeight: 300, + anchorEl, + initialAnchorOrigin: initialOrigin.anchorOrigin, + initialTransformOrigin: initialOrigin.transformOrigin, + open, + }); + + const tabOptions: TabOption[] = useMemo(() => { + return [ + { + label: t('button.upload'), + key: TAB_KEY.UPLOAD, + Component: UploadImage, + onDone: (link: string) => { + CustomEditor.setImageBlockData(editor, node, { + url: link, + image_type: ImageType.Local, + }); + onClose(); + }, + }, + { + label: t('document.imageBlock.embedLink.label'), + key: TAB_KEY.EMBED_LINK, + Component: EmbedLink, + onDone: (link: string) => { + CustomEditor.setImageBlockData(editor, node, { + url: link, + image_type: ImageType.External, + }); + onClose(); + }, + }, + { + key: TAB_KEY.UNSPLASH, + label: t('document.imageBlock.unsplash.label'), + Component: Unsplash, + onDone: (link: string) => { + CustomEditor.setImageBlockData(editor, node, { + url: link, + image_type: ImageType.External, + }); + onClose(); + }, + }, + ]; + }, [editor, node, onClose, t]); + + return ( + { + e.stopPropagation(); + }, + }} + containerStyle={{ + maxWidth: paperWidth, + maxHeight: paperHeight, + overflow: 'hidden', + }} + tabOptions={tabOptions} + /> + ); +} + +export default UploadPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/index.ts new file mode 100644 index 0000000000000..73c3003a92514 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/index.ts @@ -0,0 +1 @@ +export * from './ImageBlock'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx new file mode 100644 index 0000000000000..f44158bdf29b8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx @@ -0,0 +1,165 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import Popover from '@mui/material/Popover'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import { TextareaAutosize } from '@mui/material'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import { CustomEditor } from '$app/components/editor/command'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { MathEquationNode } from '$app/application/document/document.types'; +import katex from 'katex'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; + +const initialOrigin: { + transformOrigin: PopoverOrigin; + anchorOrigin: PopoverOrigin; +} = { + transformOrigin: { + vertical: 'top', + horizontal: 'center', + }, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'center', + }, +}; + +function EditPopover({ + open, + anchorEl, + onClose, + node, +}: { + open: boolean; + node: MathEquationNode; + anchorEl: HTMLDivElement | null; + onClose: () => void; +}) { + const editor = useSlateStatic(); + + const [error, setError] = useState<{ + name: string; + message: string; + } | null>(null); + const { t } = useTranslation(); + const [value, setValue] = useState(node.data.formula || ''); + const onInput = (event: React.FormEvent) => { + setValue(event.currentTarget.value); + }; + + const handleClose = useCallback(() => { + onClose(); + if (!node) return; + ReactEditor.focus(editor); + const path = ReactEditor.findPath(editor, node); + + editor.select(path); + }, [onClose, editor, node]); + + const handleDone = () => { + if (!node || error) return; + if (value !== node.data.formula) { + CustomEditor.setMathEquationBlockFormula(editor, node, value); + } + + handleClose(); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + const shift = e.shiftKey; + + // If shift is pressed, allow the user to enter a new line, otherwise close the popover + if (!shift && e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleDone(); + } + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + handleClose(); + } + }; + + useEffect(() => { + try { + katex.render(value, document.createElement('div')); + setError(null); + } catch (e) { + setError( + e as { + name: string; + message: string; + } + ); + } + }, [value]); + + const { transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ + initialPaperWidth: 300, + initialPaperHeight: 170, + anchorEl, + initialAnchorOrigin: initialOrigin.anchorOrigin, + initialTransformOrigin: initialOrigin.transformOrigin, + open, + }); + + return ( + { + e.stopPropagation(); + }} + onKeyDown={onKeyDown} + > +
+ + + {error && ( +
+ {error.name}: {error.message} +
+ )} + +
+ + +
+
+
+ ); +} + +export default EditPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx new file mode 100644 index 0000000000000..ee441be624115 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx @@ -0,0 +1,88 @@ +import { forwardRef, memo, useEffect, useRef } from 'react'; +import { EditorElementProps, EditorNodeType, MathEquationNode } from '$app/application/document/document.types'; +import KatexMath from '$app/components/_shared/katex_math/KatexMath'; +import { useTranslation } from 'react-i18next'; +import { FunctionsOutlined } from '@mui/icons-material'; +import EditPopover from '$app/components/editor/components/blocks/math_equation/EditPopover'; +import { ReactEditor, useSelected, useSlateStatic } from 'slate-react'; +import { useEditorBlockDispatch, useEditorBlockState } from '$app/components/editor/stores/block'; + +export const MathEquation = memo( + forwardRef>( + ({ node, children, className, ...attributes }, ref) => { + const formula = node.data.formula; + const { t } = useTranslation(); + const containerRef = useRef(null); + const { openPopover, closePopover } = useEditorBlockDispatch(); + const state = useEditorBlockState(EditorNodeType.EquationBlock); + const open = Boolean(state?.popoverOpen && state?.blockId === node.blockId && containerRef.current); + + const selected = useSelected(); + + const editor = useSlateStatic(); + + useEffect(() => { + const slateDom = ReactEditor.toDOMNode(editor, editor); + + if (!slateDom) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + openPopover(EditorNodeType.EquationBlock, node.blockId); + } + }; + + if (selected) { + slateDom.addEventListener('keydown', handleKeyDown); + } + + return () => { + slateDom.removeEventListener('keydown', handleKeyDown); + }; + }, [editor, node.blockId, openPopover, selected]); + + return ( + <> +
{ + openPopover(EditorNodeType.EquationBlock, node.blockId); + }} + className={`${className} math-equation-block relative w-full cursor-pointer py-2`} + > +
+ {formula ? ( + + ) : ( +
+ + {t('document.plugins.mathEquation.addMathEquation')} +
+ )} +
+
+ {children} +
+
+ {open && ( + { + closePopover(EditorNodeType.EquationBlock); + }} + node={node} + open={open} + anchorEl={containerRef.current} + /> + )} + + ); + } + ) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/index.ts new file mode 100644 index 0000000000000..ae6eb70209211 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/index.ts @@ -0,0 +1 @@ +export * from './MathEquation'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberListIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberListIcon.tsx new file mode 100644 index 0000000000000..888b46c98082a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberListIcon.tsx @@ -0,0 +1,87 @@ +import React, { useMemo } from 'react'; +import { ReactEditor, useSlate, useSlateStatic } from 'slate-react'; +import { Element, Path } from 'slate'; +import { NumberedListNode } from '$app/application/document/document.types'; +import { letterize, romanize } from '$app/utils/list'; +import { CustomEditor } from '$app/components/editor/command'; + +enum Letter { + Number = 'number', + Letter = 'letter', + Roman = 'roman', +} + +function getLetterNumber(index: number, letter: Letter) { + if (letter === Letter.Number) { + return index; + } else if (letter === Letter.Letter) { + return letterize(index); + } else { + return romanize(index); + } +} + +function NumberListIcon({ block, className }: { block: NumberedListNode; className: string }) { + const editor = useSlate(); + const staticEditor = useSlateStatic(); + + const path = ReactEditor.findPath(editor, block); + const index = useMemo(() => { + let index = 1; + + let topNode; + let prevPath = Path.previous(path); + + while (prevPath) { + const prev = editor.node(prevPath); + + const prevNode = prev[0] as Element; + + if (prevNode.type === block.type) { + index += 1; + topNode = prevNode; + } else { + break; + } + + prevPath = Path.previous(prevPath); + } + + if (!topNode) { + return Number(block.data?.number ?? 1); + } + + const startIndex = (topNode as NumberedListNode).data?.number ?? 1; + + return index + Number(startIndex) - 1; + }, [editor, block, path]); + + const letter = useMemo(() => { + const level = CustomEditor.getListLevel(staticEditor, block.type, path); + + if (level % 3 === 0) { + return Letter.Number; + } else if (level % 3 === 1) { + return Letter.Letter; + } else { + return Letter.Roman; + } + }, [block.type, staticEditor, path]); + + const dataNumber = useMemo(() => { + return getLetterNumber(index, letter); + }, [index, letter]); + + return ( + { + e.preventDefault(); + }} + contentEditable={false} + data-number={dataNumber} + className={`${className} numbered-icon flex w-[24px] min-w-[24px] justify-center pr-1 font-medium`} + /> + ); +} + +export default NumberListIcon; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberedList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberedList.tsx new file mode 100644 index 0000000000000..f3e34e15711ba --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberedList.tsx @@ -0,0 +1,14 @@ +import React, { forwardRef, memo } from 'react'; +import { EditorElementProps, NumberedListNode } from '$app/application/document/document.types'; + +export const NumberedList = memo( + forwardRef>( + ({ node: _, children, className, ...attributes }, ref) => { + return ( +
+ {children} +
+ ); + } + ) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/index.ts new file mode 100644 index 0000000000000..6e985ae25b66e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/index.ts @@ -0,0 +1 @@ +export * from './NumberedList'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/Page.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/Page.tsx new file mode 100644 index 0000000000000..f93cb897bab4f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/Page.tsx @@ -0,0 +1,18 @@ +import React, { forwardRef, memo, useMemo } from 'react'; +import { EditorElementProps, PageNode } from '$app/application/document/document.types'; + +export const Page = memo( + forwardRef>(({ node: _, children, ...attributes }, ref) => { + const className = useMemo(() => { + return `${attributes.className ?? ''} document-title pb-3 text-5xl font-bold`; + }, [attributes.className]); + + return ( +
+ {children} +
+ ); + }) +); + +export default Page; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/index.ts new file mode 100644 index 0000000000000..d9925d7520dc1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/page/index.ts @@ -0,0 +1 @@ +export * from './Page'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/Paragraph.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/Paragraph.tsx new file mode 100644 index 0000000000000..96524db23978e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/Paragraph.tsx @@ -0,0 +1,14 @@ +import React, { forwardRef, memo } from 'react'; +import { EditorElementProps, ParagraphNode } from '$app/application/document/document.types'; + +export const Paragraph = memo( + forwardRef>(({ node: _, children, ...attributes }, ref) => { + { + return ( +
+ {children} +
+ ); + } + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/index.ts new file mode 100644 index 0000000000000..01752c914cb54 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/index.ts @@ -0,0 +1 @@ +export * from './Paragraph'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/Quote.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/Quote.tsx new file mode 100644 index 0000000000000..5afc35289b591 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/Quote.tsx @@ -0,0 +1,16 @@ +import React, { forwardRef, memo, useMemo } from 'react'; +import { EditorElementProps, QuoteNode } from '$app/application/document/document.types'; + +export const QuoteList = memo( + forwardRef>(({ node: _, children, ...attributes }, ref) => { + const className = useMemo(() => { + return `flex w-full flex-col ml-3 border-l-[4px] border-fill-default pl-2 ${attributes.className ?? ''}`; + }, [attributes.className]); + + return ( +
+ {children} +
+ ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/index.ts new file mode 100644 index 0000000000000..c88e677a5388a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/index.ts @@ -0,0 +1 @@ +export * from './Quote'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/StartIcon.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/StartIcon.hooks.tsx new file mode 100644 index 0000000000000..acf16581f408f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/StartIcon.hooks.tsx @@ -0,0 +1,46 @@ +import React, { FC, useCallback, useMemo } from 'react'; +import { EditorNodeType, TextNode } from '$app/application/document/document.types'; +import { ReactEditor, useSlate } from 'slate-react'; +import { Editor, Element } from 'slate'; +import CheckboxIcon from '$app/components/editor/components/blocks/todo_list/CheckboxIcon'; +import ToggleIcon from '$app/components/editor/components/blocks/toggle_list/ToggleIcon'; +import NumberListIcon from '$app/components/editor/components/blocks/numbered_list/NumberListIcon'; +import BulletedListIcon from '$app/components/editor/components/blocks/bulleted_list/BulletedListIcon'; + +export function useStartIcon(node: TextNode) { + const editor = useSlate(); + const path = ReactEditor.findPath(editor, node); + const block = Editor.parent(editor, path)?.[0] as Element | null; + + const Component = useMemo(() => { + if (!Element.isElement(block)) { + return null; + } + + switch (block.type) { + case EditorNodeType.TodoListBlock: + return CheckboxIcon; + case EditorNodeType.ToggleListBlock: + return ToggleIcon; + case EditorNodeType.NumberedListBlock: + return NumberListIcon; + case EditorNodeType.BulletedListBlock: + return BulletedListIcon; + default: + return null; + } + }, [block]) as FC<{ block: Element; className: string }> | null; + + const renderIcon = useCallback(() => { + if (!Component || !block) { + return null; + } + + return ; + }, [Component, block]); + + return { + hasStartIcon: !!Component, + renderIcon, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx new file mode 100644 index 0000000000000..768524394e438 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx @@ -0,0 +1,29 @@ +import React, { forwardRef, memo } from 'react'; +import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder'; +import { EditorElementProps, TextNode } from '$app/application/document/document.types'; +import { useSlateStatic } from 'slate-react'; +import { useStartIcon } from '$app/components/editor/components/blocks/text/StartIcon.hooks'; + +export const Text = memo( + forwardRef>(({ node, children, className, ...attributes }, ref) => { + const editor = useSlateStatic(); + const { hasStartIcon, renderIcon } = useStartIcon(node); + const isEmpty = editor.isEmpty(node); + + return ( + + {renderIcon()} + + {children} + + ); + }) +); + +export default Text; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/index.ts new file mode 100644 index 0000000000000..b0c76af0b02d4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/index.ts @@ -0,0 +1 @@ +export * from './Text'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/CheckboxIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/CheckboxIcon.tsx new file mode 100644 index 0000000000000..d98990c88694f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/CheckboxIcon.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react'; +import { TodoListNode } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { Location } from 'slate'; +import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg'; +import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg'; + +function CheckboxIcon({ block, className }: { block: TodoListNode; className: string }) { + const editor = useSlateStatic(); + const { checked } = block.data; + + const toggleTodo = useCallback( + (e: React.MouseEvent) => { + const path = ReactEditor.findPath(editor, block); + const start = editor.start(path); + let at: Location = start; + + if (e.shiftKey) { + const end = editor.end(path); + + at = { + anchor: start, + focus: end, + }; + } + + CustomEditor.toggleTodo(editor, at); + }, + [editor, block] + ); + + return ( + { + e.preventDefault(); + }} + className={`${className} cursor-pointer pr-1 text-xl text-fill-default`} + > + {checked ? : } + + ); +} + +export default CheckboxIcon; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/TodoList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/TodoList.tsx new file mode 100644 index 0000000000000..c662c48153472 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/TodoList.tsx @@ -0,0 +1,19 @@ +import React, { forwardRef, memo, useMemo } from 'react'; +import { EditorElementProps, TodoListNode } from '$app/application/document/document.types'; + +export const TodoList = memo( + forwardRef>(({ node, children, ...attributes }, ref) => { + const { checked = false } = useMemo(() => node.data || {}, [node.data]); + const className = useMemo(() => { + return `flex w-full flex-col ${checked ? 'checked' : ''} ${attributes.className ?? ''}`; + }, [attributes.className, checked]); + + return ( + <> +
+ {children} +
+ + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/index.ts new file mode 100644 index 0000000000000..f239f43459ea3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleIcon.tsx new file mode 100644 index 0000000000000..ad27822cb562c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleIcon.tsx @@ -0,0 +1,30 @@ +import React, { useCallback } from 'react'; +import { CustomEditor } from '$app/components/editor/command'; +import { useSlateStatic } from 'slate-react'; +import { ToggleListNode } from '$app/application/document/document.types'; +import { ReactComponent as RightSvg } from '$app/assets/more.svg'; + +function ToggleIcon({ block, className }: { block: ToggleListNode; className: string }) { + const editor = useSlateStatic(); + const { collapsed } = block.data; + + const toggleToggleList = useCallback(() => { + CustomEditor.toggleToggleList(editor, block); + }, [editor, block]); + + return ( + { + e.preventDefault(); + }} + className={`${className} cursor-pointer pr-1 text-xl hover:text-fill-default`} + > + {collapsed ? : } + + ); +} + +export default ToggleIcon; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx new file mode 100644 index 0000000000000..809f3b750d1f7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx @@ -0,0 +1,17 @@ +import React, { forwardRef, memo, useMemo } from 'react'; +import { EditorElementProps, ToggleListNode } from '$app/application/document/document.types'; + +export const ToggleList = memo( + forwardRef>(({ node, children, ...attributes }, ref) => { + const { collapsed } = useMemo(() => node.data || {}, [node.data]); + const className = `${attributes.className ?? ''} flex w-full flex-col ${collapsed ? 'collapsed' : ''}`; + + return ( + <> +
+ {children} +
+ + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/index.ts new file mode 100644 index 0000000000000..833bdb5210916 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/index.ts @@ -0,0 +1 @@ +export * from './ToggleList'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx new file mode 100644 index 0000000000000..2526df895ec83 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx @@ -0,0 +1,93 @@ +import { memo, useEffect, useMemo, useState } from 'react'; + +import Editor from '$app/components/editor/components/editor/Editor'; +import { EditorProps } from '$app/application/document/document.types'; +import { Provider } from '$app/components/editor/provider'; +import { YXmlText } from 'yjs/dist/src/types/YXmlText'; +import { getInsertTarget, getYTarget } from '$app/components/editor/provider/utils/relation'; +import isEqual from 'lodash-es/isEqual'; + +export const CollaborativeEditor = memo( + ({ id, title, cover, showTitle = true, onTitleChange, onCoverChange, ...props }: EditorProps) => { + const [sharedType, setSharedType] = useState(null); + const provider = useMemo(() => { + setSharedType(null); + + return new Provider(id); + }, [id]); + + const root = useMemo(() => { + if (!showTitle || !sharedType || !sharedType.doc) return null; + + return getYTarget(sharedType?.doc, [0]); + }, [sharedType, showTitle]); + + const rootText = useMemo(() => { + if (!root) return null; + return getInsertTarget(root, [0]); + }, [root]); + + useEffect(() => { + if (!rootText || rootText.toString() === title) return; + + if (rootText.length > 0) { + rootText.delete(0, rootText.length); + } + + rootText.insert(0, title || ''); + }, [title, rootText]); + + useEffect(() => { + if (!root) return; + + const originalCover = root.getAttribute('data')?.cover; + + if (cover === undefined) return; + if (isEqual(originalCover, cover)) return; + root.setAttribute('data', { cover: cover ? cover : undefined }); + }, [cover, root]); + + useEffect(() => { + if (!root) return; + const rootId = root.getAttribute('blockId'); + + if (!rootId) return; + + const getCover = () => { + const data = root.getAttribute('data'); + + onCoverChange?.(data?.cover); + }; + + getCover(); + const onChange = () => { + onTitleChange?.(root.toString()); + getCover(); + }; + + root.observeDeep(onChange); + return () => root.unobserveDeep(onChange); + }, [onTitleChange, root, onCoverChange]); + + useEffect(() => { + provider.connect(); + + const handleConnected = () => { + setSharedType(provider.sharedType); + }; + + provider.on('ready', handleConnected); + void provider.initialDocument(showTitle); + return () => { + provider.off('ready', handleConnected); + provider.disconnect(); + }; + }, [provider, showTitle]); + + if (!sharedType || id !== provider.id) { + return null; + } + + return ; + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx new file mode 100644 index 0000000000000..b0bbe0eb288ea --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx @@ -0,0 +1,42 @@ +import React, { ComponentProps, useCallback } from 'react'; +import { Editable, useSlate } from 'slate-react'; +import Element from './Element'; +import { Leaf } from './Leaf'; +import { useShortcuts } from '$app/components/editor/plugins/shortcuts'; +import { useInlineKeyDown } from '$app/components/editor/components/editor/Editor.hooks'; + +type CustomEditableProps = Omit, 'renderElement' | 'renderLeaf'> & + Partial, 'renderElement' | 'renderLeaf'>> & { + disableFocus?: boolean; + }; + +export function CustomEditable({ + renderElement = Element, + disableFocus = false, + renderLeaf = Leaf, + ...props +}: CustomEditableProps) { + const editor = useSlate(); + const { onKeyDown: onShortcutsKeyDown } = useShortcuts(editor); + const withInlineKeyDown = useInlineKeyDown(editor); + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + withInlineKeyDown(event); + onShortcutsKeyDown(event); + }, + [onShortcutsKeyDown, withInlineKeyDown] + ); + + return ( + + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts new file mode 100644 index 0000000000000..f2443ba44b923 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts @@ -0,0 +1,137 @@ +import { KeyboardEvent, useCallback, useEffect, useMemo } from 'react'; + +import { BaseRange, createEditor, Editor, Element, NodeEntry, Range, Transforms } from 'slate'; +import { ReactEditor, withReact } from 'slate-react'; +import { withBlockPlugins } from '$app/components/editor/plugins/withBlockPlugins'; +import { withInlines } from '$app/components/editor/components/inline_nodes'; +import { withYHistory, withYjs, YjsEditor } from '@slate-yjs/core'; +import * as Y from 'yjs'; +import { CustomEditor } from '$app/components/editor/command'; +import { CodeNode, EditorNodeType } from '$app/application/document/document.types'; +import { decorateCode } from '$app/components/editor/components/blocks/code/utils'; +import { withMarkdown } from '$app/components/editor/plugins/shortcuts'; +import { createHotkey, HOT_KEY_NAME } from '$app/utils/hotkeys'; + +export function useEditor(sharedType: Y.XmlText) { + const editor = useMemo(() => { + if (!sharedType) return null; + const e = withMarkdown(withBlockPlugins(withInlines(withReact(withYHistory(withYjs(createEditor(), sharedType)))))); + + // Ensure editor always has at least 1 valid child + const { normalizeNode } = e; + + e.normalizeNode = (entry) => { + const [node] = entry; + + if (!Editor.isEditor(node) || node.children.length > 0) { + return normalizeNode(entry); + } + + // Ensure editor always has at least 1 valid child + CustomEditor.insertEmptyLineAtEnd(e as ReactEditor & YjsEditor); + }; + + return e; + }, [sharedType]) as ReactEditor & YjsEditor; + + const initialValue = useMemo(() => { + return []; + }, []); + + // Connect editor in useEffect to comply with concurrent mode requirements. + useEffect(() => { + YjsEditor.connect(editor); + return () => { + YjsEditor.disconnect(editor); + }; + }, [editor]); + + const handleOnClickEnd = useCallback(() => { + const path = [editor.children.length - 1]; + const node = Editor.node(editor, path) as NodeEntry; + const latestNodeIsEmpty = CustomEditor.isEmptyText(editor, node[0]); + + if (latestNodeIsEmpty) { + ReactEditor.focus(editor); + editor.select(path); + editor.collapse({ + edge: 'end', + }); + + return; + } + + CustomEditor.insertEmptyLineAtEnd(editor); + }, [editor]); + + return { + editor, + initialValue, + handleOnClickEnd, + }; +} + +export function useDecorateCodeHighlight(editor: ReactEditor) { + return useCallback( + (entry: NodeEntry): BaseRange[] => { + const path = entry[1]; + + const blockEntry = editor.above({ + at: path, + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined, + }); + + if (!blockEntry) return []; + + const block = blockEntry[0] as CodeNode; + + if (block.type === EditorNodeType.CodeBlock) { + const language = block.data.language; + + return decorateCode(entry, language, false); + } + + return []; + }, + [editor] + ); +} + +export function useInlineKeyDown(editor: ReactEditor) { + return useCallback( + (e: KeyboardEvent) => { + const selection = editor.selection; + + // Default left/right behavior is unit:'character'. + // This fails to distinguish between two cursor positions, such as + // foo vs foo. + // Here we modify the behavior to unit:'offset'. + // This lets the user step into and out of the inline without stepping over characters. + // You may wish to customize this further to only use unit:'offset' in specific cases. + if (selection && Range.isCollapsed(selection)) { + const { nativeEvent } = e; + + if ( + createHotkey(HOT_KEY_NAME.LEFT)(nativeEvent) && + CustomEditor.beforeIsInlineNode(editor, selection, { + unit: 'offset', + }) + ) { + e.preventDefault(); + Transforms.move(editor, { unit: 'offset', reverse: true }); + return; + } + + if ( + createHotkey(HOT_KEY_NAME.RIGHT)(nativeEvent) && + CustomEditor.afterIsInlineNode(editor, selection, { unit: 'offset' }) + ) { + e.preventDefault(); + Transforms.move(editor, { unit: 'offset' }); + return; + } + } + }, + [editor] + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx new file mode 100644 index 0000000000000..d87dbe3f3518f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx @@ -0,0 +1,78 @@ +import React, { useCallback } from 'react'; +import { useDecorateCodeHighlight, useEditor } from '$app/components/editor/components/editor/Editor.hooks'; +import { Slate } from 'slate-react'; +import { CustomEditable } from '$app/components/editor/components/editor/CustomEditable'; +import { SelectionToolbar } from '$app/components/editor/components/tools/selection_toolbar'; +import { BlockActionsToolbar } from '$app/components/editor/components/tools/block_actions'; + +import { CircularProgress } from '@mui/material'; +import { NodeEntry } from 'slate'; +import { + DecorateStateProvider, + EditorSelectedBlockProvider, + useInitialEditorState, + SlashStateProvider, + EditorInlineBlockStateProvider, +} from '$app/components/editor/stores'; +import CommandPanel from '../tools/command_panel/CommandPanel'; +import { EditorBlockStateProvider } from '$app/components/editor/stores/block'; +import { LocalEditorProps } from '$app/application/document/document.types'; + +function Editor({ sharedType, disableFocus, caretColor = 'var(--text-title)' }: LocalEditorProps) { + const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType); + const decorateCodeHighlight = useDecorateCodeHighlight(editor); + + const { + selectedBlocks, + decorate: decorateCustomRange, + decorateState, + slashState, + inlineBlockState, + blockState, + } = useInitialEditorState(editor); + + const decorate = useCallback( + (entry: NodeEntry) => { + const codeRanges = decorateCodeHighlight(entry); + const customRanges = decorateCustomRange(entry); + + return [...codeRanges, ...customRanges]; + }, + [decorateCodeHighlight, decorateCustomRange] + ); + + if (editor.sharedRoot.length === 0) { + return ; + } + + return ( + + + + + + + + + + + +
+ + + + + + + ); +} + +export default Editor; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.hooks.ts new file mode 100644 index 0000000000000..bf7045705d635 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.hooks.ts @@ -0,0 +1,30 @@ +import { Element } from 'slate'; +import { useContext, useEffect, useMemo } from 'react'; +import { useSnapshot } from 'valtio'; +import { useSelected } from 'slate-react'; + +import { EditorSelectedBlockContext } from '$app/components/editor/stores/selected'; + +export function useElementState(element: Element) { + const blockId = element.blockId; + const selectedBlockContext = useContext(EditorSelectedBlockContext); + const selected = useSelected(); + + useEffect(() => { + if (!blockId) return; + + if (!selected) { + selectedBlockContext.delete(blockId); + } + }, [blockId, selected, selectedBlockContext]); + + const selectedBlockIds = useSnapshot(selectedBlockContext); + const blockSelected = useMemo(() => { + if (!blockId) return false; + return selectedBlockIds.has(blockId); + }, [blockId, selectedBlockIds]); + + return { + blockSelected, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx new file mode 100644 index 0000000000000..1824d8a590126 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx @@ -0,0 +1,132 @@ +import React, { FC, HTMLAttributes, useMemo } from 'react'; +import { RenderElementProps, useSlateStatic } from 'slate-react'; +import { + BlockData, + EditorElementProps, + EditorInlineNodeType, + EditorNodeType, + TextNode, +} from '$app/application/document/document.types'; +import { Paragraph } from '$app/components/editor/components/blocks/paragraph'; +import { Heading } from '$app/components/editor/components/blocks/heading'; +import { TodoList } from '$app/components/editor/components/blocks/todo_list'; +import { Code } from '$app/components/editor/components/blocks/code'; +import { QuoteList } from '$app/components/editor/components/blocks/quote'; +import { NumberedList } from '$app/components/editor/components/blocks/numbered_list'; +import { BulletedList } from '$app/components/editor/components/blocks/bulleted_list'; +import { DividerNode } from '$app/components/editor/components/blocks/divider'; +import { InlineFormula } from '$app/components/editor/components/inline_nodes/inline_formula'; +import { ToggleList } from '$app/components/editor/components/blocks/toggle_list'; +import { Callout } from '$app/components/editor/components/blocks/callout'; +import { Mention } from '$app/components/editor/components/inline_nodes/mention'; +import { GridBlock } from '$app/components/editor/components/blocks/database'; +import { MathEquation } from '$app/components/editor/components/blocks/math_equation'; +import { ImageBlock } from '$app/components/editor/components/blocks/image'; + +import { Text as TextComponent } from '../blocks/text'; +import { Page } from '../blocks/page'; +import { useElementState } from '$app/components/editor/components/editor/Element.hooks'; +import UnSupportBlock from '$app/components/editor/components/blocks/_shared/unSupportBlock'; +import { renderColor } from '$app/utils/color'; + +function Element({ element, attributes, children }: RenderElementProps) { + const node = element; + + const InlineComponent = useMemo(() => { + switch (node.type) { + case EditorInlineNodeType.Formula: + return InlineFormula; + case EditorInlineNodeType.Mention: + return Mention; + default: + return null; + } + }, [node.type]) as FC; + + const Component = useMemo(() => { + switch (node.type) { + case EditorNodeType.Page: + return Page; + case EditorNodeType.HeadingBlock: + return Heading; + case EditorNodeType.TodoListBlock: + return TodoList; + case EditorNodeType.Paragraph: + return Paragraph; + case EditorNodeType.CodeBlock: + return Code; + case EditorNodeType.QuoteBlock: + return QuoteList; + case EditorNodeType.NumberedListBlock: + return NumberedList; + case EditorNodeType.BulletedListBlock: + return BulletedList; + case EditorNodeType.DividerBlock: + return DividerNode; + case EditorNodeType.ToggleListBlock: + return ToggleList; + case EditorNodeType.CalloutBlock: + return Callout; + case EditorNodeType.GridBlock: + return GridBlock; + case EditorNodeType.EquationBlock: + return MathEquation; + case EditorNodeType.ImageBlock: + return ImageBlock; + default: + return UnSupportBlock; + } + }, [node.type]) as FC>; + + const editor = useSlateStatic(); + const { blockSelected } = useElementState(node); + const isEmbed = editor.isEmbed(node); + + const className = useMemo(() => { + const align = + ( + node.data as { + align: 'left' | 'center' | 'right'; + } + )?.align || 'left'; + + return `block-element flex rounded ${align ? `block-align-${align}` : ''} ${ + blockSelected && !isEmbed ? 'bg-content-blue-100' : '' + }`; + }, [node.data, blockSelected, isEmbed]); + + const style = useMemo(() => { + const data = (node.data as BlockData) || {}; + + return { + backgroundColor: data.bg_color ? renderColor(data.bg_color) : undefined, + color: data.font_color ? renderColor(data.font_color) : undefined, + }; + }, [node.data]); + + if (InlineComponent) { + return ( + + {children} + + ); + } + + if (node.type === EditorNodeType.Text) { + return ( + + {children} + + ); + } + + return ( +
+ + {children} + +
+ ); +} + +export default Element; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Leaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Leaf.tsx new file mode 100644 index 0000000000000..188ac333611ad --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Leaf.tsx @@ -0,0 +1,59 @@ +import React, { CSSProperties } from 'react'; +import { RenderLeafProps } from 'slate-react'; +import { Link } from '$app/components/editor/components/inline_nodes/link'; +import { renderColor } from '$app/utils/color'; + +export function Leaf({ attributes, children, leaf }: RenderLeafProps) { + let newChildren = children; + + const classList = [leaf.prism_token, leaf.prism_token && 'token', leaf.class_name].filter(Boolean); + + if (leaf.code) { + newChildren = ( + + {newChildren} + + ); + } + + if (leaf.underline) { + newChildren = {newChildren}; + } + + if (leaf.strikethrough) { + newChildren = {newChildren}; + } + + if (leaf.italic) { + newChildren = {newChildren}; + } + + if (leaf.bold) { + newChildren = {newChildren}; + } + + const style: CSSProperties = {}; + + if (leaf.font_color) { + style['color'] = renderColor(leaf.font_color); + } + + if (leaf.bg_color) { + style['backgroundColor'] = renderColor(leaf.bg_color); + } + + if (leaf.href) { + newChildren = {newChildren}; + } + + return ( + + {newChildren} + + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/index.ts new file mode 100644 index 0000000000000..c0c3c728d12c7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/index.ts @@ -0,0 +1,3 @@ +export * from './CollaborativeEditor'; +export * from './Editor'; +export { useDecorateCodeHighlight } from '$app/components/editor/components/editor/Editor.hooks'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/utils.ts new file mode 100644 index 0000000000000..cc6960cdf4882 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/utils.ts @@ -0,0 +1,31 @@ +import { BasePoint, Editor, Transforms } from 'slate'; +import { ReactEditor } from 'slate-react'; + +export function getNodePath(editor: ReactEditor, target: HTMLElement) { + const slateNode = ReactEditor.toSlateNode(editor, target); + const path = ReactEditor.findPath(editor, slateNode); + + return path; +} + +export function moveCursorToNodeEnd(editor: ReactEditor, target: HTMLElement) { + const path = getNodePath(editor, target); + const afterPath = Editor.after(editor, path); + + ReactEditor.focus(editor); + + if (afterPath) { + const afterStart = Editor.start(editor, afterPath); + + moveCursorToPoint(editor, afterStart); + } else { + const beforeEnd = Editor.end(editor, path); + + moveCursorToPoint(editor, beforeEnd); + } +} + +export function moveCursorToPoint(editor: ReactEditor, point: BasePoint) { + ReactEditor.focus(editor); + Transforms.select(editor, point); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/InlineChromiumBugfix.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/InlineChromiumBugfix.tsx new file mode 100644 index 0000000000000..fb32eb18a99ef --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/InlineChromiumBugfix.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +// Put this at the start and end of an inline component to work around this Chromium bug: +// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 + +export const InlineChromiumBugfix = ({ className }: { className?: string }) => ( + + {String.fromCodePoint(160) /* Non-breaking space */} + +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/index.ts new file mode 100644 index 0000000000000..29f27984f71e9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/index.ts @@ -0,0 +1,2 @@ +export * from './withInline'; +export * from './inline_formula'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover.tsx new file mode 100644 index 0000000000000..c60d7af40e231 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover.tsx @@ -0,0 +1,87 @@ +import React, { useState } from 'react'; + +import Popover from '@mui/material/Popover'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import { useTranslation } from 'react-i18next'; +import TextField from '@mui/material/TextField'; +import { IconButton } from '@mui/material'; +import { ReactComponent as SelectCheck } from '$app/assets/select-check.svg'; +import { ReactComponent as Clear } from '$app/assets/delete.svg'; +import Tooltip from '@mui/material/Tooltip'; + +function FormulaEditPopover({ + defaultText, + open, + anchorEl, + onClose, + onDone, + onClear, +}: { + defaultText: string; + open: boolean; + anchorEl: HTMLElement | null; + onClose: () => void; + onClear: () => void; + onDone: (formula: string) => void; +}) { + const [text, setText] = useState(defaultText); + const { t } = useTranslation(); + + return ( + +
+ setText(e.target.value)} + fullWidth={true} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Enter') { + e.preventDefault(); + onDone(text); + } + + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + + if (e.key === 'Tab') { + e.preventDefault(); + } + }} + /> + + onDone(text)}> + + + + + + + + +
+
+ ); +} + +export default FormulaEditPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf.tsx new file mode 100644 index 0000000000000..324d273ab3bc3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import KatexMath from '$app/components/_shared/katex_math/KatexMath'; + +function FormulaLeaf({ formula, children }: { formula: string; children: React.ReactNode }) { + return ( + + + + + + {children} + + ); +} + +export default FormulaLeaf; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx new file mode 100644 index 0000000000000..204047304fbf6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx @@ -0,0 +1,133 @@ +import React, { forwardRef, memo, useCallback, MouseEvent, useRef, useEffect } from 'react'; +import { ReactEditor, useSelected, useSlate } from 'slate-react'; +import { Editor, Range, Transforms } from 'slate'; +import { EditorElementProps, FormulaNode } from '$app/application/document/document.types'; +import FormulaLeaf from '$app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf'; +import FormulaEditPopover from '$app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover'; +import { getNodePath, moveCursorToNodeEnd } from '$app/components/editor/components/editor/utils'; +import { CustomEditor } from '$app/components/editor/command'; +import { useEditorInlineBlockState } from '$app/components/editor/stores'; +import { InlineChromiumBugfix } from '$app/components/editor/components/inline_nodes/InlineChromiumBugfix'; + +export const InlineFormula = memo( + forwardRef>(({ node, children, ...attributes }, ref) => { + const editor = useSlate(); + const formula = node.data; + const { popoverOpen = false, setRange, openPopover, closePopover } = useEditorInlineBlockState('formula'); + const anchor = useRef(null); + const selected = useSelected(); + const open = Boolean(popoverOpen && selected); + + const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); + + useEffect(() => { + if (selected && isCollapsed && !open) { + const afterPoint = editor.selection ? editor.after(editor.selection) : undefined; + + const afterStart = afterPoint ? Editor.start(editor, afterPoint) : undefined; + + if (afterStart) { + editor.select(afterStart); + } + } + }, [editor, isCollapsed, selected, open]); + + const handleClick = useCallback( + (e: MouseEvent) => { + const target = e.currentTarget; + const path = getNodePath(editor, target); + + setRange(path); + openPopover(); + }, + [editor, openPopover, setRange] + ); + + const handleEditPopoverClose = useCallback(() => { + closePopover(); + if (anchor.current === null) { + return; + } + + moveCursorToNodeEnd(editor, anchor.current); + }, [closePopover, editor]); + + const selectNode = useCallback(() => { + if (anchor.current === null) { + return; + } + + const path = getNodePath(editor, anchor.current); + + ReactEditor.focus(editor); + Transforms.select(editor, path); + }, [editor]); + + const onClear = useCallback(() => { + selectNode(); + CustomEditor.toggleFormula(editor); + closePopover(); + }, [selectNode, closePopover, editor]); + + const onDone = useCallback( + (newFormula: string) => { + selectNode(); + if (newFormula === '' && anchor.current) { + const path = getNodePath(editor, anchor.current); + const point = editor.before(path); + + CustomEditor.deleteFormula(editor); + closePopover(); + if (point) { + ReactEditor.focus(editor); + editor.select(point); + } + + return; + } else { + CustomEditor.updateFormula(editor, newFormula); + handleEditPopoverClose(); + } + }, + [closePopover, editor, handleEditPopoverClose, selectNode] + ); + + return ( + <> + { + anchor.current = el; + if (ref) { + if (typeof ref === 'function') { + ref(el); + } else { + ref.current = el; + } + } + }} + contentEditable={false} + onDoubleClick={handleClick} + onClick={handleClick} + className={`${attributes.className ?? ''} formula-inline relative cursor-pointer rounded px-1 py-0.5 ${ + selected ? 'selected' : '' + }`} + > + + {children} + + + {open && ( + + )} + + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/index.ts new file mode 100644 index 0000000000000..5643ae8943652 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/index.ts @@ -0,0 +1,2 @@ +export * from './InlineFormula'; +export * from './FormulaLeaf'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/Link.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/Link.tsx new file mode 100644 index 0000000000000..09095480dcd10 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/Link.tsx @@ -0,0 +1,48 @@ +import { memo, useCallback, useRef } from 'react'; +import { ReactEditor, useSlate } from 'slate-react'; +import { getNodePath } from '$app/components/editor/components/editor/utils'; +import { Transforms, Text } from 'slate'; +import { useDecorateDispatch } from '$app/components/editor/stores'; + +export const Link = memo(({ children }: { leaf: Text; children: React.ReactNode }) => { + const { add: addDecorate } = useDecorateDispatch(); + + const editor = useSlate(); + + const ref = useRef(null); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (ref.current === null) { + return; + } + + const path = getNodePath(editor, ref.current); + + ReactEditor.focus(editor); + Transforms.select(editor, path); + + if (!editor.selection) return; + addDecorate({ + range: editor.selection, + class_name: 'bg-content-blue-100 rounded', + type: 'link', + }); + }, + [addDecorate, editor] + ); + + return ( + <> + + {children} + + + ); +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx new file mode 100644 index 0000000000000..af62a7b28ff40 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx @@ -0,0 +1,179 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Typography from '@mui/material/Typography'; +import { addMark, removeMark } from 'slate'; +import { EditorMarkFormat } from '$app/application/document/document.types'; +import { notify } from 'src/appflowy_app/components/_shared/notify'; +import { CustomEditor } from '$app/components/editor/command'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import { ReactComponent as RemoveSvg } from '$app/assets/delete.svg'; +import { ReactComponent as LinkSvg } from '$app/assets/link.svg'; +import { ReactComponent as CopySvg } from '$app/assets/copy.svg'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import isHotkey from 'is-hotkey'; +import LinkEditInput from '$app/components/editor/components/inline_nodes/link/LinkEditInput'; +import { openUrl, isUrl } from '$app/utils/open_url'; + +function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaultHref: string }) { + const editor = useSlateStatic(); + const { t } = useTranslation(); + const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Href); + + const [focusMenu, setFocusMenu] = useState(false); + const scrollRef = useRef(null); + const inputRef = useRef(null); + const [link, setLink] = useState(defaultHref); + + const setNodeMark = useCallback(() => { + if (link === '') { + removeMark(editor, EditorMarkFormat.Href); + } else { + addMark(editor, EditorMarkFormat.Href, link); + } + }, [editor, link]); + + const removeNodeMark = useCallback(() => { + onClose(); + editor.removeMark(EditorMarkFormat.Href); + }, [editor, onClose]); + + useEffect(() => { + const input = inputRef.current; + + if (!input) return; + + let isComposing = false; + + const handleCompositionUpdate = () => { + isComposing = true; + }; + + const handleCompositionEnd = () => { + isComposing = false; + }; + + const handleKeyDown = (e: KeyboardEvent) => { + e.stopPropagation(); + + if (e.key === 'Enter') { + e.preventDefault(); + if (isUrl(link)) { + onClose(); + setNodeMark(); + } + + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + return; + } + + if (e.key === 'Tab') { + e.preventDefault(); + setFocusMenu(true); + return; + } + + if (!isComposing && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + notify.clear(); + notify.info(`Press Tab to focus on the menu`); + return; + } + }; + + input.addEventListener('compositionstart', handleCompositionUpdate); + input.addEventListener('compositionend', handleCompositionEnd); + input.addEventListener('compositionupdate', handleCompositionUpdate); + input.addEventListener('keydown', handleKeyDown); + return () => { + input.removeEventListener('keydown', handleKeyDown); + input.removeEventListener('compositionstart', handleCompositionUpdate); + input.removeEventListener('compositionend', handleCompositionEnd); + input.removeEventListener('compositionupdate', handleCompositionUpdate); + }; + }, [link, onClose, setNodeMark]); + + const onConfirm = useCallback( + (key: string) => { + if (key === 'open') { + openUrl(link); + } else if (key === 'copy') { + void navigator.clipboard.writeText(link); + notify.success(t('message.copy.success')); + } else if (key === 'remove') { + removeNodeMark(); + } + }, + [link, removeNodeMark, t] + ); + + const renderOption = useCallback((icon: React.ReactNode, label: string) => { + return ( +
+ {icon} +
{label}
+
+ ); + }, []); + + const editOptions: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: 'open', + disabled: !isUrl(link), + content: renderOption(, t('editor.openLink')), + }, + { + key: 'copy', + content: renderOption(, t('editor.copyLink')), + }, + { + key: 'remove', + content: renderOption(, t('editor.removeLink')), + }, + ]; + }, [link, renderOption, t]); + + return ( + <> + {!isActivated && ( + {t('editor.addYourLink')} + )} + + +
+ {isActivated && ( + { + setFocusMenu(true); + }} + onBlur={() => { + setFocusMenu(false); + }} + disableSelect={!focusMenu} + onEscape={onClose} + onKeyDown={(e) => { + e.stopPropagation(); + if (isHotkey('Tab', e)) { + e.preventDefault(); + setFocusMenu(false); + inputRef.current?.focus(); + } + }} + /> + )} +
+ + ); +} + +export default LinkEditContent; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx new file mode 100644 index 0000000000000..6e9a0bb497f80 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx @@ -0,0 +1,47 @@ +import React, { useEffect, useState } from 'react'; +import { TextField } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { isUrl } from '$app/utils/open_url'; + +function LinkEditInput({ + link, + setLink, + inputRef, +}: { + link: string; + setLink: (link: string) => void; + inputRef: React.RefObject; +}) { + const { t } = useTranslation(); + const [error, setError] = useState(null); + + useEffect(() => { + if (isUrl(link)) { + setError(null); + return; + } + + setError(t('editor.incorrectLink')); + }, [link, t]); + + return ( +
+ setLink(e.target.value)} + spellCheck={false} + inputRef={inputRef} + className={'my-1 p-0'} + placeholder={'https://example.com'} + fullWidth={true} + /> + + ); +} + +export default LinkEditInput; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditPopover.tsx new file mode 100644 index 0000000000000..2a5e3630da17c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditPopover.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import Popover from '@mui/material/Popover'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; + +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import LinkEditContent from '$app/components/editor/components/inline_nodes/link/LinkEditContent'; + +const initialAnchorOrigin: PopoverOrigin = { + vertical: 'bottom', + horizontal: 'center', +}; + +const initialTransformOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'center', +}; + +export function LinkEditPopover({ + defaultHref, + open, + onClose, + anchorPosition, + anchorReference, +}: { + defaultHref: string; + open: boolean; + onClose: () => void; + anchorPosition?: { top: number; left: number; height: number }; + anchorReference?: 'anchorPosition' | 'anchorEl'; +}) { + const { + paperHeight, + anchorPosition: newAnchorPosition, + transformOrigin, + anchorOrigin, + } = usePopoverAutoPosition({ + anchorPosition, + open, + initialAnchorOrigin, + initialTransformOrigin, + initialPaperWidth: 340, + initialPaperHeight: 200, + }); + + return ( + { + onClose(); + }} + transformOrigin={transformOrigin} + anchorOrigin={anchorOrigin} + onMouseDown={(e) => e.stopPropagation()} + > +
+ +
+
+ ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/index.ts new file mode 100644 index 0000000000000..295683a3bcd31 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/index.ts @@ -0,0 +1,3 @@ +export * from './Link'; + +export * from './LinkEditPopover'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/Mention.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/Mention.tsx new file mode 100644 index 0000000000000..7511147ad09c4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/Mention.tsx @@ -0,0 +1,18 @@ +import React, { forwardRef, memo } from 'react'; +import { EditorElementProps, MentionNode } from '$app/application/document/document.types'; + +import MentionLeaf from '$app/components/editor/components/inline_nodes/mention/MentionLeaf'; +import { InlineChromiumBugfix } from '$app/components/editor/components/inline_nodes/InlineChromiumBugfix'; + +export const Mention = memo( + forwardRef>(({ node, children, ...attributes }, ref) => { + return ( + + + {children} + + + + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx new file mode 100644 index 0000000000000..10def395c5507 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx @@ -0,0 +1,141 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Mention, MentionPage } from '$app/application/document/document.types'; +import { ReactComponent as DocumentSvg } from '$app/assets/document.svg'; +import { useTranslation } from 'react-i18next'; +import { getPage } from '$app/application/folder/page.service'; +import { useSelected, useSlate } from 'slate-react'; +import { ReactComponent as EyeClose } from '$app/assets/eye_close.svg'; +import { notify } from 'src/appflowy_app/components/_shared/notify'; +import { subscribeNotifications } from '$app/application/notification'; +import { FolderNotification } from '@/services/backend'; +import { Editor, Range } from 'slate'; +import { useAppDispatch } from '$app/stores/store'; +import { openPage } from '$app_reducers/pages/async_actions'; + +export function MentionLeaf({ mention }: { mention: Mention }) { + const { t } = useTranslation(); + const [page, setPage] = useState(null); + const [error, setError] = useState(false); + const editor = useSlate(); + const selected = useSelected(); + const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (selected && isCollapsed && page) { + const afterPoint = editor.selection ? editor.after(editor.selection) : undefined; + + const afterStart = afterPoint ? Editor.start(editor, afterPoint) : undefined; + + if (afterStart) { + editor.select(afterStart); + } + } + }, [editor, isCollapsed, selected, page]); + + const loadPage = useCallback(async () => { + setError(true); + // keep old field for backward compatibility + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const pageId = mention.page_id ?? mention.page; + + if (!pageId) return; + try { + const page = await getPage(pageId); + + setPage(page); + setError(false); + } catch { + setPage(null); + setError(true); + } + }, [mention]); + + useEffect(() => { + void loadPage(); + }, [loadPage]); + + const handleOpenPage = useCallback(() => { + if (!page) { + notify.error(t('document.mention.deletedContent')); + return; + } + + void dispatch(openPage(page.id)); + }, [page, dispatch, t]); + + useEffect(() => { + if (!page) return; + const unsubscribePromise = subscribeNotifications( + { + [FolderNotification.DidUpdateView]: (changeset) => { + setPage((prev) => { + if (!prev) { + return prev; + } + + return { + ...prev, + name: changeset.name, + }; + }); + }, + }, + { + id: page.id, + } + ); + + return () => void unsubscribePromise.then((unsubscribe) => unsubscribe()); + }, [page]); + + useEffect(() => { + const parentId = page?.parentId; + + if (!parentId) return; + + const unsubscribePromise = subscribeNotifications( + { + [FolderNotification.DidUpdateChildViews]: (changeset) => { + if (changeset.delete_child_views.includes(page.id)) { + setPage(null); + setError(true); + } + }, + }, + { + id: parentId, + } + ); + + return () => void unsubscribePromise.then((unsubscribe) => unsubscribe()); + }, [page]); + + return ( + + {error ? ( + <> + + {t('document.mention.deleted')} + + ) : ( + page && ( + <> + {page.icon?.value || } + {page.name.trim() || t('menuAppHeader.defaultNewPageName')} + + ) + )} + + ); +} + +export default MentionLeaf; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/index.ts new file mode 100644 index 0000000000000..d3ee18034d830 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/index.ts @@ -0,0 +1 @@ +export * from './Mention'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/withInline.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/withInline.ts new file mode 100644 index 0000000000000..2859c1f0a85a6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/withInline.ts @@ -0,0 +1,31 @@ +import { ReactEditor } from 'slate-react'; +import { EditorInlineNodeType, inlineNodeTypes } from '$app/application/document/document.types'; +import { Element } from 'slate'; + +export function withInlines(editor: ReactEditor) { + const { isInline, isElementReadOnly, isSelectable, isVoid, markableVoid } = editor; + + const matchInlineType = (element: Element) => { + return inlineNodeTypes.includes(element.type as EditorInlineNodeType); + }; + + editor.isInline = (element) => { + return matchInlineType(element) || isInline(element); + }; + + editor.isVoid = (element) => { + return matchInlineType(element) || isVoid(element); + }; + + editor.markableVoid = (element) => { + return matchInlineType(element) || markableVoid(element); + }; + + editor.isElementReadOnly = (element) => + inlineNodeTypes.includes(element.type as EditorInlineNodeType) || isElementReadOnly(element); + + editor.isSelectable = (element) => + !inlineNodeTypes.includes(element.type as EditorInlineNodeType) && isSelectable(element); + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/ColorPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/ColorPicker.tsx new file mode 100644 index 0000000000000..41dea96f1e5ed --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/ColorPicker.tsx @@ -0,0 +1,174 @@ +import React, { useCallback, useRef, useMemo } from 'react'; +import Typography from '@mui/material/Typography'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { useTranslation } from 'react-i18next'; +import { TitleOutlined } from '@mui/icons-material'; +import { EditorMarkFormat } from '$app/application/document/document.types'; +import { ColorEnum, renderColor } from '$app/utils/color'; + +export interface ColorPickerProps { + onChange?: (format: EditorMarkFormat.FontColor | EditorMarkFormat.BgColor, color: string) => void; + onEscape?: () => void; + disableFocus?: boolean; +} +export function ColorPicker({ onEscape, onChange, disableFocus }: ColorPickerProps) { + const { t } = useTranslation(); + + const ref = useRef(null); + + const handleColorChange = useCallback( + (key: string) => { + const [format, , color = ''] = key.split('-'); + const formatKey = format === 'font' ? EditorMarkFormat.FontColor : EditorMarkFormat.BgColor; + + onChange?.(formatKey, color); + }, + [onChange] + ); + + const renderColorItem = useCallback( + (name: string, color: string, backgroundColor?: string) => { + return ( +
{ + handleColorChange(backgroundColor ? backgroundColor : color); + }} + className={'flex w-full cursor-pointer items-center justify-center gap-2'} + > +
+ +
+
{name}
+
+ ); + }, + [handleColorChange] + ); + + const colors: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: 'font_color', + content: ( + + {t('editor.textColor')} + + ), + children: [ + { + key: 'font-default', + content: renderColorItem(t('editor.fontColorDefault'), ''), + }, + { + key: `font-gray-rgb(120, 119, 116)`, + content: renderColorItem(t('editor.fontColorGray'), 'rgb(120, 119, 116)'), + }, + { + key: 'font-brown-rgb(159, 107, 83)', + content: renderColorItem(t('editor.fontColorBrown'), 'rgb(159, 107, 83)'), + }, + { + key: 'font-orange-rgb(217, 115, 13)', + content: renderColorItem(t('editor.fontColorOrange'), 'rgb(217, 115, 13)'), + }, + { + key: 'font-yellow-rgb(203, 145, 47)', + content: renderColorItem(t('editor.fontColorYellow'), 'rgb(203, 145, 47)'), + }, + { + key: 'font-green-rgb(68, 131, 97)', + content: renderColorItem(t('editor.fontColorGreen'), 'rgb(68, 131, 97)'), + }, + { + key: 'font-blue-rgb(51, 126, 169)', + content: renderColorItem(t('editor.fontColorBlue'), 'rgb(51, 126, 169)'), + }, + { + key: 'font-purple-rgb(144, 101, 176)', + content: renderColorItem(t('editor.fontColorPurple'), 'rgb(144, 101, 176)'), + }, + { + key: 'font-pink-rgb(193, 76, 138)', + content: renderColorItem(t('editor.fontColorPink'), 'rgb(193, 76, 138)'), + }, + { + key: 'font-red-rgb(212, 76, 71)', + content: renderColorItem(t('editor.fontColorRed'), 'rgb(212, 76, 71)'), + }, + ], + }, + { + key: 'bg_color', + content: ( + + {t('editor.backgroundColor')} + + ), + children: [ + { + key: 'bg-default', + content: renderColorItem(t('editor.backgroundColorDefault'), '', ''), + }, + { + key: `bg-lime-${ColorEnum.Lime}`, + content: renderColorItem(t('editor.backgroundColorLime'), '', ColorEnum.Lime), + }, + { + key: `bg-aqua-${ColorEnum.Aqua}`, + content: renderColorItem(t('editor.backgroundColorAqua'), '', ColorEnum.Aqua), + }, + { + key: `bg-orange-${ColorEnum.Orange}`, + content: renderColorItem(t('editor.backgroundColorOrange'), '', ColorEnum.Orange), + }, + { + key: `bg-yellow-${ColorEnum.Yellow}`, + content: renderColorItem(t('editor.backgroundColorYellow'), '', ColorEnum.Yellow), + }, + { + key: `bg-green-${ColorEnum.Green}`, + content: renderColorItem(t('editor.backgroundColorGreen'), '', ColorEnum.Green), + }, + { + key: `bg-blue-${ColorEnum.Blue}`, + content: renderColorItem(t('editor.backgroundColorBlue'), '', ColorEnum.Blue), + }, + { + key: `bg-purple-${ColorEnum.Purple}`, + content: renderColorItem(t('editor.backgroundColorPurple'), '', ColorEnum.Purple), + }, + { + key: `bg-pink-${ColorEnum.Pink}`, + content: renderColorItem(t('editor.backgroundColorPink'), '', ColorEnum.Pink), + }, + { + key: `bg-red-${ColorEnum.LightPink}`, + content: renderColorItem(t('editor.backgroundColorRed'), '', ColorEnum.LightPink), + }, + ], + }, + ]; + }, [renderColorItem, t]); + + return ( +
+ +
+ ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/CustomColorPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/CustomColorPicker.tsx new file mode 100644 index 0000000000000..ae463a2ff3690 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/CustomColorPicker.tsx @@ -0,0 +1,27 @@ +import React, { useState } from 'react'; +import Popover, { PopoverProps } from '@mui/material/Popover'; +import { Color, SketchPicker } from 'react-color'; + +import { Divider } from '@mui/material'; + +export function CustomColorPicker({ + onColorChange, + ...props +}: { + onColorChange?: (color: string) => void; +} & PopoverProps) { + const [color, setColor] = useState(); + + return ( + + { + setColor(color.rgb); + onColorChange?.(color.hex); + }} + color={color} + /> + + + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/index.ts new file mode 100644 index 0000000000000..00e212aa7fc57 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/_shared/index.ts @@ -0,0 +1,2 @@ +export * from './CustomColorPicker'; +export * from './ColorPicker'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/AddBlockBelow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/AddBlockBelow.tsx new file mode 100644 index 0000000000000..eb2675bc718b2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/AddBlockBelow.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { ReactEditor, useSlate } from 'slate-react'; +import { IconButton, Tooltip } from '@mui/material'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { useTranslation } from 'react-i18next'; +import { Element, Path } from 'slate'; +import { CustomEditor } from '$app/components/editor/command'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { YjsEditor } from '@slate-yjs/core'; +import { useSlashState } from '$app/components/editor/stores'; + +function AddBlockBelow({ node }: { node?: Element }) { + const { t } = useTranslation(); + const editor = useSlate(); + const { setOpen: setSlashOpen } = useSlashState(); + + const handleAddBelow = () => { + if (!node) return; + ReactEditor.focus(editor); + + const nodePath = ReactEditor.findPath(editor, node); + const nextPath = Path.next(nodePath); + + editor.select(nodePath); + + if (editor.isSelectable(node)) { + editor.collapse({ + edge: 'start', + }); + } + + const isEmptyNode = CustomEditor.isEmptyText(editor, node); + + // if the node is not a paragraph, or it is not empty, insert a new empty line + if (node.type !== EditorNodeType.Paragraph || !isEmptyNode) { + CustomEditor.insertEmptyLine(editor as ReactEditor & YjsEditor, nextPath); + editor.select(nextPath); + } + + requestAnimationFrame(() => { + setSlashOpen(true); + }); + }; + + return ( + <> + + + + + + + ); +} + +export default AddBlockBelow; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActions.tsx new file mode 100644 index 0000000000000..f5833e538b7d4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActions.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { Element } from 'slate'; +import AddBlockBelow from '$app/components/editor/components/tools/block_actions/AddBlockBelow'; +import { ReactComponent as DragSvg } from '$app/assets/drag.svg'; +import { IconButton, Tooltip } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +export function BlockActions({ + node, + onClickDrag, +}: { + node?: Element; + onClickDrag: (e: React.MouseEvent) => void; +}) { + const { t } = useTranslation(); + + return ( + <> + + + + + + + + ); +} + +export default BlockActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts new file mode 100644 index 0000000000000..bc1086dde9878 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts @@ -0,0 +1,146 @@ +import { RefObject, useCallback, useEffect, useState } from 'react'; +import { ReactEditor, useSlate } from 'slate-react'; +import { findEventNode, getBlockActionsPosition } from '$app/components/editor/components/tools/block_actions/utils'; +import { Element, Editor, Range } from 'slate'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { Log } from '$app/utils/log'; + +export function useBlockActionsToolbar(ref: RefObject, contextMenuVisible: boolean) { + const editor = useSlate(); + const [node, setNode] = useState(null); + + const recalculatePosition = useCallback( + (blockElement: HTMLElement) => { + const { top, left } = getBlockActionsPosition(editor, blockElement); + + const slateEditorDom = ReactEditor.toDOMNode(editor, editor); + + if (!ref.current) return; + + ref.current.style.top = `${top + slateEditorDom.offsetTop}px`; + ref.current.style.left = `${left + slateEditorDom.offsetLeft - 64}px`; + }, + [editor, ref] + ); + + const close = useCallback(() => { + const el = ref.current; + + if (!el) return; + + el.style.opacity = '0'; + el.style.pointerEvents = 'none'; + setNode(null); + }, [ref]); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + const el = ref.current; + + if (!el) return; + + const target = e.target as HTMLElement; + + if (target.closest(`[contenteditable="false"]`)) { + return; + } + + let range: Range | null = null; + let node; + + try { + range = ReactEditor.findEventRange(editor, e); + } catch { + const editorDom = ReactEditor.toDOMNode(editor, editor); + const rect = editorDom.getBoundingClientRect(); + const isOverLeftBoundary = e.clientX < rect.left + 64; + const isOverRightBoundary = e.clientX > rect.right - 64; + let newX = e.clientX; + + if (isOverLeftBoundary) { + newX = rect.left + 64; + } + + if (isOverRightBoundary) { + newX = rect.right - 64; + } + + node = findEventNode(editor, { + x: newX, + y: e.clientY, + }); + } + + if (!range && !node) { + Log.warn('No range and node found'); + return; + } else if (range) { + const match = editor.above({ + match: (n) => { + return !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined; + }, + at: range, + }); + + if (!match) { + close(); + return; + } + + node = match[0] as Element; + } + + if (!node) { + close(); + return; + } + + if (node.type === EditorNodeType.Page) return; + const blockElement = ReactEditor.toDOMNode(editor, node); + + if (!blockElement) return; + recalculatePosition(blockElement); + el.style.opacity = '1'; + el.style.pointerEvents = 'auto'; + const slateNode = ReactEditor.toSlateNode(editor, blockElement) as Element; + + setNode(slateNode); + }; + + const dom = ReactEditor.toDOMNode(editor, editor); + + if (!contextMenuVisible) { + dom.addEventListener('mousemove', handleMouseMove); + dom.parentElement?.addEventListener('mouseleave', close); + } + + return () => { + dom.removeEventListener('mousemove', handleMouseMove); + dom.parentElement?.removeEventListener('mouseleave', close); + }; + }, [close, editor, contextMenuVisible, ref, recalculatePosition]); + + useEffect(() => { + let observer: MutationObserver | null = null; + + if (node) { + const dom = ReactEditor.toDOMNode(editor, node); + + if (dom.parentElement) { + observer = new MutationObserver(close); + + observer.observe(dom.parentElement, { + childList: true, + }); + } + } + + return () => { + observer?.disconnect(); + }; + }, [close, editor, node]); + + return { + node: node?.type === EditorNodeType.Page ? null : node, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.tsx new file mode 100644 index 0000000000000..729b4df144226 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.tsx @@ -0,0 +1,141 @@ +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { useBlockActionsToolbar } from './BlockActionsToolbar.hooks'; +import BlockActions from '$app/components/editor/components/tools/block_actions/BlockActions'; + +import { getBlockCssProperty } from '$app/components/editor/components/tools/block_actions/utils'; +import BlockOperationMenu from '$app/components/editor/components/tools/block_actions/BlockOperationMenu'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { PopoverProps } from '@mui/material/Popover'; + +import { EditorSelectedBlockContext } from '$app/components/editor/stores/selected'; +import withErrorBoundary from '$app/components/_shared/error_boundary/withError'; +import { CustomEditor } from '$app/components/editor/command'; +import isEqual from 'lodash-es/isEqual'; +import { Range } from 'slate'; + +const Toolbar = () => { + const ref = useRef(null); + const [openContextMenu, setOpenContextMenu] = useState(false); + const { node } = useBlockActionsToolbar(ref, openContextMenu); + const cssProperty = node && getBlockCssProperty(node); + const selectedBlockContext = useContext(EditorSelectedBlockContext); + const popoverPropsRef = useRef | undefined>(undefined); + const editor = useSlateStatic(); + + const handleOpen = useCallback(() => { + if (!node || !node.blockId) return; + setOpenContextMenu(true); + const path = ReactEditor.findPath(editor, node); + + editor.select(path); + selectedBlockContext.clear(); + selectedBlockContext.add(node.blockId); + }, [editor, node, selectedBlockContext]); + + const handleClose = useCallback(() => { + setOpenContextMenu(false); + selectedBlockContext.clear(); + }, [selectedBlockContext]); + + useEffect(() => { + if (!node) return; + const nodeDom = ReactEditor.toDOMNode(editor, node); + const onContextMenu = (e: MouseEvent) => { + const { clientX, clientY } = e; + + e.stopPropagation(); + + const { selection } = editor; + + const editorRange = ReactEditor.findEventRange(editor, e); + + if (!editorRange || !selection) return; + + const rangeBlock = CustomEditor.getBlock(editor, editorRange); + const selectedBlock = CustomEditor.getBlock(editor, selection); + + if ( + Range.intersection(selection, editorRange) || + (rangeBlock && selectedBlock && isEqual(rangeBlock[1], selectedBlock[1])) + ) { + const windowSelection = window.getSelection(); + const range = windowSelection?.rangeCount ? windowSelection?.getRangeAt(0) : null; + const isCollapsed = windowSelection?.isCollapsed; + + if (windowSelection && !isCollapsed) { + if (range && range.endOffset === 0 && range.startContainer !== range.endContainer) { + const newRange = range.cloneRange(); + + newRange.setEnd(range.startContainer, range.startOffset); + windowSelection.removeAllRanges(); + windowSelection.addRange(newRange); + } + } + + return; + } + + e.preventDefault(); + + popoverPropsRef.current = { + transformOrigin: { + vertical: 'top', + horizontal: 'left', + }, + anchorReference: 'anchorPosition', + anchorPosition: { + top: clientY, + left: clientX, + }, + }; + + handleOpen(); + }; + + nodeDom.addEventListener('contextmenu', onContextMenu); + + return () => { + nodeDom.removeEventListener('contextmenu', onContextMenu); + }; + }, [editor, handleOpen, node]); + return ( + <> +
+ {/* Ensure the toolbar in middle */} +
$
+ { + ) => { + const target = e.currentTarget; + const rect = target.getBoundingClientRect(); + + popoverPropsRef.current = { + transformOrigin: { + vertical: 'center', + horizontal: 'right', + }, + anchorReference: 'anchorPosition', + anchorPosition: { + top: rect.top + rect.height / 2, + left: rect.left, + }, + }; + + handleOpen(); + }} + /> + } +
+ {node && openContextMenu && ( + + )} + + ); +}; + +export const BlockActionsToolbar = withErrorBoundary(Toolbar); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx new file mode 100644 index 0000000000000..ade981750346a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx @@ -0,0 +1,209 @@ +import React, { useCallback, useMemo } from 'react'; +import Popover, { PopoverProps } from '@mui/material/Popover'; +import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg'; +import { ReactComponent as CopySvg } from '$app/assets/copy.svg'; +import { useTranslation } from 'react-i18next'; +import { Divider } from '@mui/material'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import { Element, Path } from 'slate'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { Color } from '$app/components/editor/components/tools/block_actions/color'; +import { getModifier } from '$app/utils/hotkeys'; + +import isHotkey from 'is-hotkey'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { EditorSelectedBlockContext } from '$app/components/editor/stores/selected'; + +export const canSetColorBlocks: EditorNodeType[] = [ + EditorNodeType.Paragraph, + EditorNodeType.HeadingBlock, + EditorNodeType.TodoListBlock, + EditorNodeType.BulletedListBlock, + EditorNodeType.NumberedListBlock, + EditorNodeType.ToggleListBlock, + EditorNodeType.QuoteBlock, + EditorNodeType.CalloutBlock, +]; + +export function BlockOperationMenu({ + node, + ...props +}: { + node: Element; +} & PopoverProps) { + const editor = useSlateStatic(); + const { t } = useTranslation(); + + const canSetColor = useMemo(() => { + return canSetColorBlocks.includes(node.type as EditorNodeType); + }, [node]); + const selectedBlockContext = React.useContext(EditorSelectedBlockContext); + const [openColorMenu, setOpenColorMenu] = React.useState(false); + const ref = React.useRef(null); + const handleClose = useCallback(() => { + props.onClose?.({}, 'backdropClick'); + ReactEditor.focus(editor); + try { + const path = ReactEditor.findPath(editor, node); + + editor.select(path); + } catch (e) { + // do nothing + } + + editor.collapse({ + edge: 'start', + }); + }, [editor, node, props]); + + const onConfirm = useCallback( + (optionKey: string) => { + switch (optionKey) { + case 'delete': { + CustomEditor.deleteNode(editor, node); + break; + } + + case 'duplicate': { + const path = ReactEditor.findPath(editor, node); + const newNode = CustomEditor.duplicateNode(editor, node); + + handleClose(); + + const newBlockId = newNode.blockId; + + if (!newBlockId) return; + requestAnimationFrame(() => { + selectedBlockContext.clear(); + selectedBlockContext.add(newBlockId); + const nextPath = Path.next(path); + + editor.select(nextPath); + editor.collapse({ + edge: 'start', + }); + }); + return; + } + + case 'color': { + setOpenColorMenu(true); + return; + } + } + + handleClose(); + }, + [editor, handleClose, node, selectedBlockContext] + ); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const options: KeyboardNavigationOption[] = useMemo( + () => + [ + { + key: 'block-operation', + children: [ + { + key: 'delete', + content: ( +
+ +
{t('button.delete')}
+
{'Del'}
+
+ ), + }, + { + key: 'duplicate', + content: ( +
+ +
{t('button.duplicate')}
+
{`${getModifier()} + D`}
+
+ ), + }, + ], + }, + canSetColor && { + key: 'color', + content: , + children: [ + { + key: 'color', + content: ( + { + setOpenColorMenu(false); + }} + openPicker={openColorMenu} + onOpenPicker={() => setOpenColorMenu(true)} + /> + ), + }, + ], + }, + ].filter(Boolean), + [node, canSetColor, openColorMenu, t] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + e.stopPropagation(); + if (isHotkey('mod+d', e)) { + e.preventDefault(); + onConfirm('duplicate'); + } + + if (isHotkey('del', e) || isHotkey('backspace', e)) { + e.preventDefault(); + onConfirm('delete'); + } + }, + [onConfirm] + ); + + return ( + e.stopPropagation()} + {...props} + onClose={handleClose} + > +
+ { + if (key === 'color') { + onConfirm(key); + } else { + handleClose(); + } + }} + options={options} + scrollRef={ref} + onEscape={handleClose} + onConfirm={onConfirm} + /> +
+
+ ); +} + +export default BlockOperationMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/color/Color.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/color/Color.tsx new file mode 100644 index 0000000000000..499ab95c76843 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/color/Color.tsx @@ -0,0 +1,90 @@ +import React, { useCallback, useRef } from 'react'; +import { Element } from 'slate'; +import { Popover } from '@mui/material'; +import ColorLensOutlinedIcon from '@mui/icons-material/ColorLensOutlined'; +import { useTranslation } from 'react-i18next'; +import { ColorPicker } from '$app/components/editor/components/tools/_shared'; +import { CustomEditor } from '$app/components/editor/command'; +import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; +import { useSlateStatic } from 'slate-react'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; + +const initialOrigin: { + transformOrigin?: PopoverOrigin; + anchorOrigin?: PopoverOrigin; +} = { + anchorOrigin: { + vertical: 'center', + horizontal: 'right', + }, + transformOrigin: { + vertical: 'center', + horizontal: 'left', + }, +}; + +export function Color({ + node, + openPicker, + onOpenPicker, + onClosePicker, +}: { + node: Element & { + data?: { + font_color?: string; + bg_color?: string; + }; + }; + openPicker?: boolean; + onOpenPicker?: () => void; + onClosePicker?: () => void; +}) { + const { t } = useTranslation(); + + const editor = useSlateStatic(); + + const ref = useRef(null); + + const onColorChange = useCallback( + (format: 'font_color' | 'bg_color', color: string) => { + CustomEditor.setBlockColor(editor, node, { + [format]: color, + }); + onClosePicker?.(); + }, + [editor, node, onClosePicker] + ); + + return ( + <> +
+ +
{t('editor.color')}
+ +
+ {openPicker && ( + { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + if (e.key === 'Escape' || e.key === 'ArrowLeft') { + e.preventDefault(); + onClosePicker?.(); + } + }} + onClick={(e) => e.stopPropagation()} + anchorEl={ref.current} + onClose={onClosePicker} + > +
+ +
+
+ )} + + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/color/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/color/index.ts new file mode 100644 index 0000000000000..0fd619bc8672c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/color/index.ts @@ -0,0 +1 @@ +export * from './Color'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/index.ts new file mode 100644 index 0000000000000..e8b87721c22f1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/index.ts @@ -0,0 +1,2 @@ +export * from './BlockActions'; +export * from './BlockActionsToolbar'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts new file mode 100644 index 0000000000000..b63afe9dc141b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts @@ -0,0 +1,58 @@ +import { ReactEditor } from 'slate-react'; +import { getEditorDomNode, getHeadingCssProperty } from '$app/components/editor/plugins/utils'; +import { Element } from 'slate'; +import { EditorNodeType, HeadingNode } from '$app/application/document/document.types'; + +export function getBlockActionsPosition(editor: ReactEditor, blockElement: HTMLElement) { + const editorDom = getEditorDomNode(editor); + const editorDomRect = editorDom.getBoundingClientRect(); + const blockDomRect = blockElement.getBoundingClientRect(); + + const relativeTop = blockDomRect.top - editorDomRect.top; + const relativeLeft = blockDomRect.left - editorDomRect.left; + + return { + top: relativeTop, + left: relativeLeft, + }; +} + +export function getBlockCssProperty(node: Element) { + switch (node.type) { + case EditorNodeType.HeadingBlock: + return `${getHeadingCssProperty((node as HeadingNode).data.level)} mt-1`; + case EditorNodeType.CodeBlock: + case EditorNodeType.CalloutBlock: + case EditorNodeType.EquationBlock: + case EditorNodeType.GridBlock: + return 'my-3'; + case EditorNodeType.DividerBlock: + return 'my-0'; + default: + return 'mt-1'; + } +} + +/** + * @param editor + * @param e + */ +export function findEventNode( + editor: ReactEditor, + { + x, + y, + }: { + x: number; + y: number; + } +) { + const element = document.elementFromPoint(x, y); + const nodeDom = element?.closest('[data-block-type]'); + + if (nodeDom) { + return ReactEditor.toSlateNode(editor, nodeDom) as Element; + } + + return null; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/Command.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/Command.hooks.ts new file mode 100644 index 0000000000000..633d09349de1e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/Command.hooks.ts @@ -0,0 +1,259 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { getPanelPosition } from '$app/components/editor/components/tools/command_panel/utils'; +import { useSlate } from 'slate-react'; +import { PopoverPreventBlurProps } from '$app/components/editor/components/tools/popover'; +import { PopoverProps } from '@mui/material/Popover'; + +import { Editor, Point, Range, Transforms } from 'slate'; +import { CustomEditor } from '$app/components/editor/command'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import { useSlashState } from '$app/components/editor/stores'; + +export enum EditorCommand { + Mention = '@', + SlashCommand = '/', +} + +export const PanelPopoverProps: Partial = { + ...PopoverPreventBlurProps, + anchorReference: 'anchorPosition', +}; + +const commands = Object.values(EditorCommand); + +export interface PanelProps { + anchorPosition?: { left: number; top: number; height: number }; + closePanel: (deleteText?: boolean) => void; + searchText: string; + openPanel: () => void; +} + +export function useCommandPanel() { + const editor = useSlate(); + const { open: slashOpen, setOpen: setSlashOpen } = useSlashState(); + const [command, setCommand] = useState(undefined); + const [anchorPosition, setAnchorPosition] = useState< + | { + top: number; + left: number; + height: number; + } + | undefined + >(undefined); + const startPoint = useRef(); + const endPoint = useRef(); + const open = Boolean(anchorPosition); + const [searchText, setSearchText] = useState(''); + + const closePanel = useCallback( + (deleteText?: boolean) => { + if (deleteText && startPoint.current && endPoint.current) { + const anchor = { + path: startPoint.current.path, + offset: startPoint.current.offset > 0 ? startPoint.current.offset - 1 : 0, + }; + const focus = { + path: endPoint.current.path, + offset: endPoint.current.offset, + }; + + if (!Point.equals(anchor, focus)) { + Transforms.delete(editor, { + at: { + anchor, + focus, + }, + }); + } + } + + setSlashOpen(false); + setCommand(undefined); + setAnchorPosition(undefined); + setSearchText(''); + }, + [editor, setSlashOpen] + ); + + const setPosition = useCallback( + (position?: { left: number; top: number; height: number }) => { + if (!position) { + closePanel(false); + return; + } + + const nodeEntry = CustomEditor.getBlock(editor); + + if (!nodeEntry) return; + + setAnchorPosition(position); + }, + [closePanel, editor] + ); + + const openPanel = useCallback(() => { + const position = getPanelPosition(editor); + + if (position && editor.selection) { + startPoint.current = Editor.start(editor, editor.selection); + endPoint.current = Editor.end(editor, editor.selection); + setPosition(position); + } else { + setPosition(undefined); + } + }, [editor, setPosition]); + + useEffect(() => { + if (!slashOpen && command === EditorCommand.SlashCommand) { + closePanel(); + return; + } + + if (slashOpen && !open) { + setCommand(EditorCommand.SlashCommand); + openPanel(); + return; + } + }, [slashOpen, closePanel, command, open, openPanel]); + /** + * listen to editor insertText and deleteBackward event + */ + useEffect(() => { + const { insertText } = editor; + + /** + * insertText: when insert char at after space or at start of element, show the panel + * open condition: + * 1. open is false + * 2. current block is not code block + * 3. current selection is not include root + * 4. current selection is collapsed + * 5. insert char is command char + * 6. before text is empty or end with space + * --------- start ----------------- + * | - selection point + * @ - panel char + * _ - space + * - - other text + * -------- open panel ---------------- + * ---_@|--- => insert text is panel char and before text is end with space, open the panel + * @|--- => insert text is panel char and before text is empty, open the panel + */ + editor.insertText = (text, opts) => { + if (open || CustomEditor.isCodeBlock(editor) || CustomEditor.selectionIncludeRoot(editor)) { + insertText(text, opts); + return; + } + + const { selection } = editor; + + const command = commands.find((c) => text.endsWith(c)); + const endOfPanelChar = !!command; + + if (command === EditorCommand.SlashCommand) { + setSlashOpen(true); + } + + setCommand(command); + if (!selection || !endOfPanelChar || !Range.isCollapsed(selection)) { + insertText(text, opts); + return; + } + + const block = CustomEditor.getBlock(editor); + const path = block ? block[1] : []; + const { anchor } = selection; + const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) }) + text.slice(0, -1); + // show the panel when insert char at after space or at start of element + const showPanel = !beforeText || beforeText.endsWith(' '); + + insertText(text, opts); + + if (!showPanel) return; + openPanel(); + }; + + return () => { + editor.insertText = insertText; + }; + }, [open, editor, openPanel, setSlashOpen]); + + /** + * listen to editor onChange event + */ + useEffect(() => { + const { onChange } = editor; + + if (!open) return; + + /** + * onChange: when selection change, update the search text or close the panel + * --------- start ----------------- + * | - selection point + * @ - panel char + * __ - search text + * - - other text + * -------- close panel ---------------- + * --|@--- => selection is backward to start point, close the panel + * ---@__-|--- => selection is forward to end point, close the panel + * -------- update search text ---------------- + * ---@__|--- + * ---@_|_--- => selection is forward to start point and backward to end point, update the search text + * ---@|__--- + * --------- end ----------------- + */ + editor.onChange = (...args) => { + if (!editor.selection || !startPoint.current || !endPoint.current) return; + onChange(...args); + const isSelectionChange = editor.operations.every((op) => op.type === 'set_selection'); + const currentPoint = Editor.end(editor, editor.selection); + const isBackward = currentPoint.offset < startPoint.current.offset; + + if (isBackward) { + closePanel(false); + return; + } + + if (!isSelectionChange) { + if (currentPoint.offset > endPoint.current?.offset) { + endPoint.current = currentPoint; + } + + const text = Editor.string(editor, { + anchor: startPoint.current, + focus: endPoint.current, + }); + + setSearchText(text); + } else { + const isForward = currentPoint.offset > endPoint.current.offset; + + if (isForward) { + closePanel(false); + } + } + }; + + return () => { + editor.onChange = onChange; + }; + }, [open, editor, closePanel]); + + return { + anchorPosition, + closePanel, + searchText, + openPanel, + command, + }; +} + +export const initialTransformOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'left', +}; + +export const initialAnchorOrigin: PopoverOrigin = { + vertical: 'bottom', + horizontal: 'right', +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/CommandPanel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/CommandPanel.tsx new file mode 100644 index 0000000000000..db58e2deca574 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/CommandPanel.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { SlashCommandPanel } from '$app/components/editor/components/tools/command_panel/slash_command_panel'; +import { MentionPanel } from '$app/components/editor/components/tools/command_panel/mention_panel'; +import { EditorCommand, useCommandPanel } from '$app/components/editor/components/tools/command_panel/Command.hooks'; +import withErrorBoundary from '$app/components/_shared/error_boundary/withError'; + +function CommandPanel() { + const { anchorPosition, searchText, openPanel, closePanel, command } = useCommandPanel(); + + const Component = command === EditorCommand.SlashCommand ? SlashCommandPanel : MentionPanel; + + return ( + + ); +} + +export default withErrorBoundary(CommandPanel); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/index.ts new file mode 100644 index 0000000000000..cf07c7d996d01 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/index.ts @@ -0,0 +1,2 @@ +export * from './mention_panel'; +export * from './slash_command_panel'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.tsx new file mode 100644 index 0000000000000..5d83870719212 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.tsx @@ -0,0 +1,114 @@ +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlate } from 'slate-react'; +import { MentionPage, MentionType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; +import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { ReactComponent as DocumentSvg } from '$app/assets/document.svg'; +// import dayjs from 'dayjs'; + +// enum DateKey { +// Today = 'today', +// Tomorrow = 'tomorrow', +// } +export function useMentionPanel({ + closePanel, + pages, +}: { + pages: MentionPage[]; + closePanel: (deleteText?: boolean) => void; +}) { + const { t } = useTranslation(); + const editor = useSlate(); + + const onConfirm = useCallback( + (key: string) => { + const [, id] = key.split(','); + + closePanel(true); + CustomEditor.insertMention(editor, { + page_id: id, + type: MentionType.PageRef, + }); + }, + [closePanel, editor] + ); + + const renderPage = useCallback( + (page: MentionPage) => { + return { + key: `${MentionType.PageRef},${page.id}`, + content: ( +
+
{page.icon?.value || }
+ +
{page.name.trim() || t('menuAppHeader.defaultNewPageName')}
+
+ ), + }; + }, + [t] + ); + + // const renderDate = useCallback(() => { + // return [ + // { + // key: DateKey.Today, + // content: ( + //
+ // {t('relativeDates.today')} -{' '} + // {dayjs().format('MMM D, YYYY')} + //
+ // ), + // + // children: [], + // }, + // { + // key: DateKey.Tomorrow, + // content: ( + //
+ // {t('relativeDates.tomorrow')} + //
+ // ), + // children: [], + // }, + // ]; + // }, [t]); + + const options: KeyboardNavigationOption[] = useMemo(() => { + return [ + // { + // key: MentionType.Date, + // content:
{t('editor.date')}
, + // children: renderDate(), + // }, + { + key: 'divider', + content:
, + children: [], + }, + + { + key: MentionType.PageRef, + content:
{t('document.mention.page.label')}
, + children: + pages.length > 0 + ? pages.map(renderPage) + : [ + { + key: 'noPage', + content: ( +
{t('findAndReplace.noResult')}
+ ), + children: [], + }, + ], + }, + ]; + }, [pages, renderPage, t]); + + return { + options, + onConfirm, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx new file mode 100644 index 0000000000000..6ca022557914c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx @@ -0,0 +1,81 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + initialAnchorOrigin, + initialTransformOrigin, + PanelPopoverProps, + PanelProps, +} from '$app/components/editor/components/tools/command_panel/Command.hooks'; +import Popover from '@mui/material/Popover'; + +import MentionPanelContent from '$app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; +import { useAppSelector } from '$app/stores/store'; +import { MentionPage } from '$app/application/document/document.types'; + +export function MentionPanel({ anchorPosition, closePanel, searchText }: PanelProps) { + const ref = useRef(null); + const pagesMap = useAppSelector((state) => state.pages.pageMap); + + const pagesRef = useRef([]); + const [recentPages, setPages] = useState([]); + + const loadPages = useCallback(async () => { + const pages = Object.values(pagesMap); + + pagesRef.current = pages; + setPages(pages); + }, [pagesMap]); + + useEffect(() => { + void loadPages(); + }, [loadPages]); + + useEffect(() => { + if (!searchText) { + setPages(pagesRef.current); + return; + } + + const filteredPages = pagesRef.current.filter((page) => { + return page.name.toLowerCase().includes(searchText.toLowerCase()); + }); + + setPages(filteredPages); + }, [searchText]); + const open = Boolean(anchorPosition); + + const { + paperHeight, + anchorPosition: newAnchorPosition, + paperWidth, + transformOrigin, + anchorOrigin, + isEntered, + } = usePopoverAutoPosition({ + initialPaperWidth: 300, + initialPaperHeight: 360, + anchorPosition, + initialTransformOrigin, + initialAnchorOrigin, + open, + }); + + return ( +
+ {open && ( + closePanel(false)} + > + + + )} +
+ ); +} + +export default MentionPanel; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx new file mode 100644 index 0000000000000..36b00ca2b658d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx @@ -0,0 +1,45 @@ +import React, { useRef } from 'react'; +import { useMentionPanel } from '$app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks'; + +import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { MentionPage } from '$app/application/document/document.types'; + +function MentionPanelContent({ + closePanel, + pages, + maxHeight, + width, +}: { + closePanel: (deleteText?: boolean) => void; + pages: MentionPage[]; + maxHeight: number; + width: number; +}) { + const scrollRef = useRef(null); + + const { options, onConfirm } = useMentionPanel({ + closePanel, + pages, + }); + + return ( +
+ +
+ ); +} + +export default MentionPanelContent; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/index.ts new file mode 100644 index 0000000000000..bfca34ef9af51 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/index.ts @@ -0,0 +1,2 @@ +export * from './MentionPanel'; +export * from './MentionPanelContent'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx new file mode 100644 index 0000000000000..c2d9445b56a5f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx @@ -0,0 +1,245 @@ +import { EditorNodeType } from '$app/application/document/document.types'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactEditor, useSlate } from 'slate-react'; +import { Path } from 'slate'; +import { getBlock } from '$app/components/editor/plugins/utils'; +import { ReactComponent as TextIcon } from '$app/assets/text.svg'; +import { ReactComponent as TodoListIcon } from '$app/assets/todo-list.svg'; +import { ReactComponent as Heading1Icon } from '$app/assets/h1.svg'; +import { ReactComponent as Heading2Icon } from '$app/assets/h2.svg'; +import { ReactComponent as Heading3Icon } from '$app/assets/h3.svg'; +import { ReactComponent as BulletedListIcon } from '$app/assets/list.svg'; +import { ReactComponent as NumberedListIcon } from '$app/assets/numbers.svg'; +import { ReactComponent as QuoteIcon } from '$app/assets/quote.svg'; +import { ReactComponent as ToggleListIcon } from '$app/assets/show-menu.svg'; +import { ReactComponent as GridIcon } from '$app/assets/grid.svg'; +import { ReactComponent as ImageIcon } from '$app/assets/image.svg'; +import { DataObjectOutlined, FunctionsOutlined, HorizontalRuleOutlined, MenuBookOutlined } from '@mui/icons-material'; +import { CustomEditor } from '$app/components/editor/command'; +import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { YjsEditor } from '@slate-yjs/core'; +import { useEditorBlockDispatch } from '$app/components/editor/stores/block'; +import { + headingTypes, + headingTypeToLevelMap, + reorderSlashOptions, + SlashAliases, + SlashCommandPanelTab, + slashOptionGroup, + slashOptionMapToEditorNodeType, + SlashOptionType, +} from '$app/components/editor/components/tools/command_panel/slash_command_panel/const'; + +export function useSlashCommandPanel({ + searchText, + closePanel, +}: { + searchText: string; + closePanel: (deleteText?: boolean) => void; +}) { + const { openPopover } = useEditorBlockDispatch(); + const { t } = useTranslation(); + const editor = useSlate(); + const onConfirm = useCallback( + (type: SlashOptionType) => { + const node = getBlock(editor); + + if (!node) return; + + const nodeType = slashOptionMapToEditorNodeType[type]; + + if (!nodeType) return; + + const data = {}; + + if (headingTypes.includes(type)) { + Object.assign(data, { + level: headingTypeToLevelMap[type], + }); + } + + if (nodeType === EditorNodeType.CalloutBlock) { + Object.assign(data, { + icon: '📌', + }); + } + + if (nodeType === EditorNodeType.CodeBlock) { + Object.assign(data, { + language: 'json', + }); + } + + if (nodeType === EditorNodeType.ImageBlock) { + Object.assign(data, { + url: '', + }); + } + + closePanel(true); + + const newNode = getBlock(editor); + const block = CustomEditor.getBlock(editor); + + const path = block ? block[1] : null; + + if (!newNode || !path) return; + + const isEmpty = CustomEditor.isEmptyText(editor, newNode); + + if (!isEmpty) { + const nextPath = Path.next(path); + + CustomEditor.insertEmptyLine(editor as ReactEditor & YjsEditor, nextPath); + editor.select(nextPath); + } + + const turnIntoBlock = CustomEditor.turnToBlock(editor, { + type: nodeType, + data, + }); + + setTimeout(() => { + if (turnIntoBlock && turnIntoBlock.blockId) { + if (turnIntoBlock.type === EditorNodeType.ImageBlock || turnIntoBlock.type === EditorNodeType.EquationBlock) { + openPopover(turnIntoBlock.type, turnIntoBlock.blockId); + } + } + }, 0); + }, + [editor, closePanel, openPopover] + ); + + const typeToLabelIconMap = useMemo(() => { + return { + [SlashOptionType.Paragraph]: { + label: t('editor.text'), + Icon: TextIcon, + }, + [SlashOptionType.TodoList]: { + label: t('editor.checkbox'), + Icon: TodoListIcon, + }, + [SlashOptionType.Heading1]: { + label: t('editor.heading1'), + Icon: Heading1Icon, + }, + [SlashOptionType.Heading2]: { + label: t('editor.heading2'), + Icon: Heading2Icon, + }, + [SlashOptionType.Heading3]: { + label: t('editor.heading3'), + Icon: Heading3Icon, + }, + [SlashOptionType.BulletedList]: { + label: t('editor.bulletedList'), + Icon: BulletedListIcon, + }, + [SlashOptionType.NumberedList]: { + label: t('editor.numberedList'), + Icon: NumberedListIcon, + }, + [SlashOptionType.Quote]: { + label: t('editor.quote'), + Icon: QuoteIcon, + }, + [SlashOptionType.ToggleList]: { + label: t('document.plugins.toggleList'), + Icon: ToggleListIcon, + }, + [SlashOptionType.Divider]: { + label: t('editor.divider'), + Icon: HorizontalRuleOutlined, + }, + [SlashOptionType.Callout]: { + label: t('document.plugins.callout'), + Icon: MenuBookOutlined, + }, + [SlashOptionType.Code]: { + label: t('document.selectionMenu.codeBlock'), + Icon: DataObjectOutlined, + }, + [SlashOptionType.Grid]: { + label: t('grid.menuName'), + Icon: GridIcon, + }, + + [SlashOptionType.MathEquation]: { + label: t('document.plugins.mathEquation.name'), + Icon: FunctionsOutlined, + }, + [SlashOptionType.Image]: { + label: t('editor.image'), + Icon: ImageIcon, + }, + }; + }, [t]); + + const groupTypeToLabelMap = useMemo(() => { + return { + [SlashCommandPanelTab.BASIC]: 'Basic', + [SlashCommandPanelTab.ADVANCED]: 'Advanced', + [SlashCommandPanelTab.MEDIA]: 'Media', + [SlashCommandPanelTab.DATABASE]: 'Database', + }; + }, []); + + const renderOptionContent = useCallback( + (type: SlashOptionType) => { + const Icon = typeToLabelIconMap[type].Icon; + + return ( +
+
+ +
+ +
{typeToLabelIconMap[type].label}
+
+ ); + }, + [typeToLabelIconMap] + ); + + const options: KeyboardNavigationOption[] = useMemo(() => { + return slashOptionGroup + .map((group) => { + return { + key: group.key, + content:
{groupTypeToLabelMap[group.key]}
, + children: group.options + + .map((type) => { + return { + key: type, + content: renderOptionContent(type), + }; + }) + .filter((option) => { + if (!searchText) return true; + const label = typeToLabelIconMap[option.key].label; + + let newSearchText = searchText; + + if (searchText.startsWith('/')) { + newSearchText = searchText.slice(1); + } + + return ( + label.toLowerCase().includes(newSearchText.toLowerCase()) || + SlashAliases[option.key].some((alias) => alias.startsWith(newSearchText.toLowerCase())) + ); + }) + .sort(reorderSlashOptions(searchText)), + }; + }) + .filter((group) => group.children.length > 0); + }, [searchText, groupTypeToLabelMap, typeToLabelIconMap, renderOptionContent]); + + return { + options, + onConfirm, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.tsx new file mode 100644 index 0000000000000..b09af97b39ee2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.tsx @@ -0,0 +1,73 @@ +import React, { useCallback, useRef } from 'react'; +import { + initialAnchorOrigin, + initialTransformOrigin, + PanelPopoverProps, + PanelProps, +} from '$app/components/editor/components/tools/command_panel/Command.hooks'; +import Popover from '@mui/material/Popover'; +import SlashCommandPanelContent from '$app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent'; +import { useSlate } from 'slate-react'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; + +export function SlashCommandPanel({ anchorPosition, closePanel, searchText }: PanelProps) { + const ref = useRef(null); + const editor = useSlate(); + + const open = Boolean(anchorPosition); + + const handleClose = useCallback( + (deleteText?: boolean) => { + closePanel(deleteText); + }, + [closePanel] + ); + + const { + paperHeight, + paperWidth, + anchorPosition: newAnchorPosition, + transformOrigin, + anchorOrigin, + isEntered, + } = usePopoverAutoPosition({ + initialPaperWidth: 220, + initialPaperHeight: 360, + anchorPosition, + initialTransformOrigin, + initialAnchorOrigin, + open, + }); + + return ( +
+ {open && ( + { + const selection = editor.selection; + + handleClose(false); + + if (selection) { + editor.select(selection); + } + }} + > + + + )} +
+ ); +} + +export default SlashCommandPanel; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent.tsx new file mode 100644 index 0000000000000..256e82f8118b6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useRef } from 'react'; +import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { useSlashCommandPanel } from '$app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks'; +import { useSlateStatic } from 'slate-react'; +import { SlashOptionType } from '$app/components/editor/components/tools/command_panel/slash_command_panel/const'; + +const noResultBuffer = 2; + +function SlashCommandPanelContent({ + closePanel, + searchText, + maxHeight, + width, +}: { + closePanel: (deleteText?: boolean) => void; + searchText: string; + maxHeight: number; + width: number; +}) { + const scrollRef = useRef(null); + + const { options, onConfirm } = useSlashCommandPanel({ + closePanel, + searchText, + }); + + // Used to keep track of how many times the user has typed and not found any result + const noResultCount = useRef(0); + + const editor = useSlateStatic(); + + useEffect(() => { + const { insertText, deleteBackward } = editor; + + editor.insertText = (text, opts) => { + // close panel if track of no result is greater than buffer + if (noResultCount.current >= noResultBuffer) { + closePanel(false); + } + + if (options.length === 0) { + noResultCount.current += 1; + } + + insertText(text, opts); + }; + + editor.deleteBackward = (unit) => { + // reset no result count + if (noResultCount.current > 0) { + noResultCount.current -= 1; + } + + // close panel if no text + if (!searchText) { + closePanel(true); + return; + } + + deleteBackward(unit); + }; + + return () => { + editor.insertText = insertText; + editor.deleteBackward = deleteBackward; + }; + }, [closePanel, editor, searchText, options.length]); + + return ( +
+ onConfirm(key as SlashOptionType)} + options={options} + disableFocus={true} + /> +
+ ); +} + +export default SlashCommandPanelContent; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/const.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/const.ts new file mode 100644 index 0000000000000..7dfaa2b4a03fd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/const.ts @@ -0,0 +1,174 @@ +import { EditorNodeType } from '$app/application/document/document.types'; + +export enum SlashCommandPanelTab { + BASIC = 'basic', + MEDIA = 'media', + DATABASE = 'database', + ADVANCED = 'advanced', +} + +export enum SlashOptionType { + Paragraph, + TodoList, + Heading1, + Heading2, + Heading3, + BulletedList, + NumberedList, + Quote, + ToggleList, + Divider, + Callout, + Code, + Grid, + MathEquation, + Image, +} + +export const slashOptionGroup = [ + { + key: SlashCommandPanelTab.BASIC, + options: [ + SlashOptionType.Paragraph, + SlashOptionType.TodoList, + SlashOptionType.Heading1, + SlashOptionType.Heading2, + SlashOptionType.Heading3, + SlashOptionType.BulletedList, + SlashOptionType.NumberedList, + SlashOptionType.Quote, + SlashOptionType.ToggleList, + SlashOptionType.Divider, + SlashOptionType.Callout, + ], + }, + { + key: SlashCommandPanelTab.MEDIA, + options: [SlashOptionType.Code, SlashOptionType.Image], + }, + { + key: SlashCommandPanelTab.DATABASE, + options: [SlashOptionType.Grid], + }, + { + key: SlashCommandPanelTab.ADVANCED, + options: [SlashOptionType.MathEquation], + }, +]; +export const slashOptionMapToEditorNodeType = { + [SlashOptionType.Paragraph]: EditorNodeType.Paragraph, + [SlashOptionType.TodoList]: EditorNodeType.TodoListBlock, + [SlashOptionType.Heading1]: EditorNodeType.HeadingBlock, + [SlashOptionType.Heading2]: EditorNodeType.HeadingBlock, + [SlashOptionType.Heading3]: EditorNodeType.HeadingBlock, + [SlashOptionType.BulletedList]: EditorNodeType.BulletedListBlock, + [SlashOptionType.NumberedList]: EditorNodeType.NumberedListBlock, + [SlashOptionType.Quote]: EditorNodeType.QuoteBlock, + [SlashOptionType.ToggleList]: EditorNodeType.ToggleListBlock, + [SlashOptionType.Divider]: EditorNodeType.DividerBlock, + [SlashOptionType.Callout]: EditorNodeType.CalloutBlock, + [SlashOptionType.Code]: EditorNodeType.CodeBlock, + [SlashOptionType.Grid]: EditorNodeType.GridBlock, + [SlashOptionType.MathEquation]: EditorNodeType.EquationBlock, + [SlashOptionType.Image]: EditorNodeType.ImageBlock, +}; +export const headingTypeToLevelMap: Record = { + [SlashOptionType.Heading1]: 1, + [SlashOptionType.Heading2]: 2, + [SlashOptionType.Heading3]: 3, +}; +export const headingTypes = [SlashOptionType.Heading1, SlashOptionType.Heading2, SlashOptionType.Heading3]; + +export const SlashAliases = { + [SlashOptionType.Paragraph]: ['paragraph', 'text', 'block', 'textblock'], + [SlashOptionType.TodoList]: [ + 'list', + 'todo', + 'todolist', + 'checkbox', + 'block', + 'todoblock', + 'checkboxblock', + 'todolistblock', + ], + [SlashOptionType.Heading1]: ['h1', 'heading1', 'block', 'headingblock', 'h1block'], + [SlashOptionType.Heading2]: ['h2', 'heading2', 'block', 'headingblock', 'h2block'], + [SlashOptionType.Heading3]: ['h3', 'heading3', 'block', 'headingblock', 'h3block'], + [SlashOptionType.BulletedList]: [ + 'list', + 'bulleted', + 'block', + 'bulletedlist', + 'bulletedblock', + 'listblock', + 'bulletedlistblock', + 'bulletelist', + ], + [SlashOptionType.NumberedList]: [ + 'list', + 'numbered', + 'block', + 'numberedlist', + 'numberedblock', + 'listblock', + 'numberedlistblock', + 'numberlist', + ], + [SlashOptionType.Quote]: ['quote', 'block', 'quoteblock'], + [SlashOptionType.ToggleList]: ['list', 'toggle', 'block', 'togglelist', 'toggleblock', 'listblock', 'togglelistblock'], + [SlashOptionType.Divider]: ['divider', 'hr', 'block', 'dividerblock', 'line', 'lineblock'], + [SlashOptionType.Callout]: ['callout', 'info', 'block', 'calloutblock'], + [SlashOptionType.Code]: ['code', 'code', 'block', 'codeblock', 'media'], + [SlashOptionType.Grid]: ['grid', 'table', 'block', 'gridblock', 'database'], + [SlashOptionType.MathEquation]: [ + 'math', + 'equation', + 'block', + 'mathblock', + 'mathequation', + 'mathequationblock', + 'advanced', + ], + [SlashOptionType.Image]: ['img', 'image', 'block', 'imageblock', 'media'], +}; + +export const reorderSlashOptions = (searchText: string) => { + return ( + a: { + key: SlashOptionType; + }, + b: { + key: SlashOptionType; + } + ) => { + const compareIndex = (option: SlashOptionType) => { + const aliases = SlashAliases[option]; + + if (aliases) { + for (const alias of aliases) { + if (alias.startsWith(searchText)) { + return -1; + } + } + } + + return 0; + }; + + const compareLength = (option: SlashOptionType) => { + const aliases = SlashAliases[option]; + + if (aliases) { + for (const alias of aliases) { + if (alias.length < searchText.length) { + return -1; + } + } + } + + return 0; + }; + + return compareIndex(a.key) - compareIndex(b.key) || compareLength(a.key) - compareLength(b.key); + }; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/index.ts new file mode 100644 index 0000000000000..688a6ffb7de7e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/index.ts @@ -0,0 +1,2 @@ +export * from './SlashCommandPanel'; +export * from './SlashCommandPanelContent'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/utils.ts new file mode 100644 index 0000000000000..65a095dc583de --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/utils.ts @@ -0,0 +1,31 @@ +import { ReactEditor } from 'slate-react'; + +export function getPanelPosition(editor: ReactEditor) { + const { selection } = editor; + + const isFocused = ReactEditor.isFocused(editor); + + if (!selection || !isFocused) { + return null; + } + + const domSelection = window.getSelection(); + const rangeCount = domSelection?.rangeCount; + + if (!rangeCount) return null; + + const domRange = rangeCount > 0 ? domSelection.getRangeAt(0) : undefined; + + const rect = domRange?.getBoundingClientRect(); + + if (!rect) return null; + const nodeDom = domSelection.anchorNode?.parentElement?.closest('.text-element'); + const height = (nodeDom?.getBoundingClientRect().height ?? 0) + 8; + + return { + ...rect, + height, + top: rect.top, + left: rect.left, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/popover.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/popover.ts new file mode 100644 index 0000000000000..2b6a715baa2ae --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/popover.ts @@ -0,0 +1,34 @@ +import { PopoverProps } from '@mui/material/Popover'; + +export const PopoverCommonProps: Partial = { + keepMounted: false, + disableAutoFocus: true, + disableEnforceFocus: true, + disableRestoreFocus: true, +}; + +export const PopoverPreventBlurProps: Partial = { + ...PopoverCommonProps, + + onMouseDown: (e) => { + // prevent editor blur + e.preventDefault(); + e.stopPropagation(); + }, +}; + +export const PopoverNoBackdropProps: Partial = { + ...PopoverCommonProps, + sx: { + pointerEvents: 'none', + }, + PaperProps: { + style: { + pointerEvents: 'auto', + }, + }, + onMouseDown: (e) => { + // prevent editor blur + e.stopPropagation(); + }, +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx new file mode 100644 index 0000000000000..9d7c19b999a3f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx @@ -0,0 +1,73 @@ +import React from 'react'; + +import { Paragraph } from '$app/components/editor/components/tools/selection_toolbar/actions/paragraph'; +import { Heading } from '$app/components/editor/components/tools/selection_toolbar/actions/heading'; +import { Divider } from '@mui/material'; +import { Bold } from '$app/components/editor/components/tools/selection_toolbar/actions/bold'; +import { Italic } from '$app/components/editor/components/tools/selection_toolbar/actions/italic'; +import { Underline } from '$app/components/editor/components/tools/selection_toolbar/actions/underline'; +import { StrikeThrough } from '$app/components/editor/components/tools/selection_toolbar/actions/strikethrough'; +import { InlineCode } from '$app/components/editor/components/tools/selection_toolbar/actions/inline_code'; +import { Formula } from '$app/components/editor/components/tools/selection_toolbar/actions/formula'; +import { TodoList } from '$app/components/editor/components/tools/selection_toolbar/actions/todo_list'; +import { Quote } from '$app/components/editor/components/tools/selection_toolbar/actions/quote'; +import { ToggleList } from '$app/components/editor/components/tools/selection_toolbar/actions/toggle_list'; +import { BulletedList } from '$app/components/editor/components/tools/selection_toolbar/actions/bulleted_list'; +import { NumberedList } from '$app/components/editor/components/tools/selection_toolbar/actions/numbered_list'; +import { Href, LinkActions } from '$app/components/editor/components/tools/selection_toolbar/actions/href'; +import { Align } from '$app/components/editor/components/tools/selection_toolbar/actions/align'; +import { Color } from '$app/components/editor/components/tools/selection_toolbar/actions/color'; + +function SelectionActions({ + isAcrossBlocks, + storeSelection, + restoreSelection, + isIncludeRoot, +}: { + storeSelection: () => void; + restoreSelection: () => void; + isAcrossBlocks: boolean; + visible: boolean; + isIncludeRoot: boolean; +}) { + if (isIncludeRoot) return null; + return ( +
+ {!isAcrossBlocks && ( + <> + + + + + )} + + + + + + {!isAcrossBlocks && ( + <> + + + + )} + + {!isAcrossBlocks && ( + <> + + + + + + + + )} + {!isAcrossBlocks && } + + + +
+ ); +} + +export default SelectionActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts new file mode 100644 index 0000000000000..58834db6d592d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts @@ -0,0 +1,239 @@ +import { ReactEditor, useFocused, useSlate } from 'slate-react'; +import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { getSelectionPosition } from '$app/components/editor/components/tools/selection_toolbar/utils'; +import debounce from 'lodash-es/debounce'; +import { CustomEditor } from '$app/components/editor/command'; +import { BaseRange, Range as SlateRange } from 'slate'; +import { useDecorateDispatch } from '$app/components/editor/stores/decorate'; + +const DELAY = 300; + +export function useSelectionToolbar(ref: MutableRefObject) { + const editor = useSlate() as ReactEditor; + const isDraggingRef = useRef(false); + const [isAcrossBlocks, setIsAcrossBlocks] = useState(false); + const [visible, setVisible] = useState(false); + const isFocusedEditor = useFocused(); + const isIncludeRoot = CustomEditor.selectionIncludeRoot(editor); + + // paint the selection when the editor is blurred + const { add: addDecorate, clear: clearDecorate, getStaticState } = useDecorateDispatch(); + + // Restore selection after the editor is focused + const restoreSelection = useCallback(() => { + const decorateState = getStaticState(); + + if (!decorateState) return; + + editor.select({ + ...decorateState.range, + }); + + clearDecorate(); + ReactEditor.focus(editor); + }, [getStaticState, clearDecorate, editor]); + + // Store selection when the editor is blurred + const storeSelection = useCallback(() => { + addDecorate({ + range: editor.selection as BaseRange, + class_name: 'bg-content-blue-100', + }); + }, [addDecorate, editor]); + + const closeToolbar = useCallback(() => { + const el = ref.current; + + if (!el) { + return; + } + + restoreSelection(); + + setVisible(false); + el.style.opacity = '0'; + el.style.pointerEvents = 'none'; + }, [ref, restoreSelection]); + + const recalculatePosition = useCallback(() => { + const el = ref.current; + + if (!el) { + return; + } + + const position = getSelectionPosition(editor); + + if (!position) { + closeToolbar(); + return; + } + + const slateEditorDom = ReactEditor.toDOMNode(editor, editor); + + setVisible(true); + el.style.opacity = '1'; + + // if dragging, disable pointer events + if (isDraggingRef.current) { + el.style.pointerEvents = 'none'; + } else { + el.style.pointerEvents = 'auto'; + } + + // If toolbar is out of editor, move it to the top + el.style.top = `${position.top + slateEditorDom.offsetTop - el.offsetHeight}px`; + + const left = position.left + slateEditorDom.offsetLeft; + + // If toolbar is out of editor, move it to the left edge of the editor + if (left < 0) { + el.style.left = '0'; + return; + } + + const right = left + el.offsetWidth; + + // If toolbar is out of editor, move the right edge to the right edge of the editor + if (right > slateEditorDom.offsetWidth) { + el.style.left = `${slateEditorDom.offsetWidth - el.offsetWidth}px`; + return; + } + + el.style.left = `${left}px`; + }, [closeToolbar, editor, ref]); + + const debounceRecalculatePosition = useMemo(() => debounce(recalculatePosition, DELAY), [recalculatePosition]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const decorateState = getStaticState(); + + if (decorateState) { + setIsAcrossBlocks(false); + return; + } + + const { selection } = editor; + + const close = () => { + debounceRecalculatePosition.cancel(); + closeToolbar(); + }; + + if (isIncludeRoot || !isFocusedEditor || !selection || SlateRange.isCollapsed(selection)) { + close(); + return; + } + + // There has a bug which the text of selection is empty when the selection include inline blocks + const isEmptyText = !CustomEditor.includeInlineBlocks(editor) && editor.string(selection) === ''; + + if (isEmptyText) { + close(); + return; + } + + setIsAcrossBlocks(CustomEditor.isMultipleBlockSelected(editor, true)); + debounceRecalculatePosition(); + }); + + // Update drag status + useEffect(() => { + const el = ReactEditor.toDOMNode(editor, editor); + + const toolbar = ref.current; + + if (!el || !toolbar) { + return; + } + + const onMouseDown = () => { + isDraggingRef.current = true; + }; + + const onMouseUp = () => { + if (visible) { + toolbar.style.pointerEvents = 'auto'; + } + + isDraggingRef.current = false; + }; + + el.addEventListener('mousedown', onMouseDown); + document.addEventListener('mouseup', onMouseUp); + + return () => { + el.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('mouseup', onMouseUp); + }; + }, [visible, editor, ref]); + + // Close toolbar when press ESC + useEffect(() => { + const slateEditorDom = ReactEditor.toDOMNode(editor, editor); + const onKeyDown = (e: KeyboardEvent) => { + // Close toolbar when press ESC + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + editor.collapse({ + edge: 'end', + }); + debounceRecalculatePosition.cancel(); + closeToolbar(); + } + }; + + if (visible) { + slateEditorDom.addEventListener('keydown', onKeyDown); + } else { + slateEditorDom.removeEventListener('keydown', onKeyDown); + } + + return () => { + slateEditorDom.removeEventListener('keydown', onKeyDown); + }; + }, [closeToolbar, debounceRecalculatePosition, editor, visible]); + + // Recalculate position when the scroll container is scrolled + useEffect(() => { + const slateEditorDom = ReactEditor.toDOMNode(editor, editor); + const scrollContainer = slateEditorDom.closest('.appflowy-scroll-container'); + + if (!visible) return; + if (!scrollContainer) return; + const handleScroll = () => { + if (isDraggingRef.current) return; + + const domSelection = window.getSelection(); + const rangeCount = domSelection?.rangeCount; + + if (!rangeCount) return null; + + const domRange = rangeCount > 0 ? domSelection.getRangeAt(0) : undefined; + + const rangeRect = domRange?.getBoundingClientRect(); + + // Stop calculating when the range is out of the window + if (!rangeRect?.bottom || rangeRect.bottom < 0) { + return; + } + + recalculatePosition(); + }; + + scrollContainer.addEventListener('scroll', handleScroll); + return () => { + scrollContainer.removeEventListener('scroll', handleScroll); + }; + }, [visible, editor, recalculatePosition]); + + return { + visible, + restoreSelection, + storeSelection, + isAcrossBlocks, + isIncludeRoot, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx new file mode 100644 index 0000000000000..d4ca9c9de0fc4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx @@ -0,0 +1,33 @@ +import React, { memo, useRef } from 'react'; +import { useSelectionToolbar } from '$app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks'; +import SelectionActions from '$app/components/editor/components/tools/selection_toolbar/SelectionActions'; +import withErrorBoundary from '$app/components/_shared/error_boundary/withError'; + +const Toolbar = memo(() => { + const ref = useRef(null); + + const { visible, restoreSelection, storeSelection, isAcrossBlocks, isIncludeRoot } = useSelectionToolbar(ref); + + return ( +
{ + // prevent toolbar from taking focus away from editor + e.preventDefault(); + }} + > + +
+ ); +}); + +export const SelectionToolbar = withErrorBoundary(Toolbar); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton.tsx new file mode 100644 index 0000000000000..3f86d1eab9e57 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton.tsx @@ -0,0 +1,35 @@ +import React, { forwardRef } from 'react'; +import IconButton, { IconButtonProps } from '@mui/material/IconButton'; +import { Tooltip } from '@mui/material'; + +const ActionButton = forwardRef< + HTMLButtonElement, + { + tooltip: string | React.ReactNode; + onClick?: (e: React.MouseEvent) => void; + children: React.ReactNode; + active?: boolean; + } & IconButtonProps +>(({ tooltip, onClick, disabled, children, active, className, ...props }, ref) => { + return ( + + + {children} + + + ); +}); + +export default ActionButton; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/align/Align.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/align/Align.tsx new file mode 100644 index 0000000000000..23917e146b315 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/align/Align.tsx @@ -0,0 +1,98 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import Tooltip from '@mui/material/Tooltip'; +import { ReactComponent as AlignLeftSvg } from '$app/assets/align-left.svg'; +import { ReactComponent as AlignCenterSvg } from '$app/assets/align-center.svg'; +import { ReactComponent as AlignRightSvg } from '$app/assets/align-right.svg'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { useTranslation } from 'react-i18next'; +import { CustomEditor } from '$app/components/editor/command'; +import { useSlateStatic } from 'slate-react'; +import { IconButton } from '@mui/material'; +import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; + +export function Align() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const align = CustomEditor.getAlign(editor); + const [open, setOpen] = useState(false); + + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + const handleOpen = useCallback(() => { + setOpen(true); + }, []); + + const Icon = useMemo(() => { + switch (align) { + case 'left': + return AlignLeftSvg; + case 'center': + return AlignCenterSvg; + case 'right': + return AlignRightSvg; + default: + return AlignLeftSvg; + } + }, [align]); + + const toggleAlign = useCallback( + (align: string) => { + return () => { + CustomEditor.toggleAlign(editor, align); + handleClose(); + }; + }, + [editor, handleClose] + ); + + const getAlignIcon = useCallback((key: string) => { + switch (key) { + case 'left': + return ; + case 'center': + return ; + case 'right': + return ; + default: + return ; + } + }, []); + + return ( + + {['left', 'center', 'right'].map((key) => { + return ( + + {getAlignIcon(key)} + + ); + })} +
+ } + > + +
+ + +
+
+ + ); +} + +export default Align; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/align/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/align/index.ts new file mode 100644 index 0000000000000..6cba19d7bd7cb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/align/index.ts @@ -0,0 +1 @@ +export * from './Align'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bold/Bold.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bold/Bold.tsx new file mode 100644 index 0000000000000..22be9f970abb7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bold/Bold.tsx @@ -0,0 +1,39 @@ +import React, { useCallback, useMemo } from 'react'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; +import { ReactComponent as BoldSvg } from '$app/assets/bold.svg'; +import { EditorMarkFormat } from '$app/application/document/document.types'; +import { createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys'; + +export function Bold() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Bold); + + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.BOLD), []); + const onClick = useCallback(() => { + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Bold, + value: true, + }); + }, [editor]); + + return ( + +
{t('toolbar.bold')}
+
{modifier}
+ + } + > + +
+ ); +} + +export default Bold; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bold/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bold/index.ts new file mode 100644 index 0000000000000..6ef457faaab7d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bold/index.ts @@ -0,0 +1 @@ +export * from './Bold'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bulleted_list/BulletedList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bulleted_list/BulletedList.tsx new file mode 100644 index 0000000000000..f35f2aeeead0b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bulleted_list/BulletedList.tsx @@ -0,0 +1,27 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { ReactComponent as BulletedListSvg } from '$app/assets/list.svg'; + +export function BulletedList() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.BulletedListBlock); + + const onClick = useCallback(() => { + CustomEditor.turnToBlock(editor, { + type: EditorNodeType.BulletedListBlock, + }); + }, [editor]); + + return ( + + + + ); +} + +export default BulletedList; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bulleted_list/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bulleted_list/index.ts new file mode 100644 index 0000000000000..2095dff308552 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/bulleted_list/index.ts @@ -0,0 +1 @@ +export * from './BulletedList'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/Color.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/Color.tsx new file mode 100644 index 0000000000000..60c44423bdd69 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/Color.tsx @@ -0,0 +1,53 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { CustomEditor } from '$app/components/editor/command'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import ColorLensOutlinedIcon from '@mui/icons-material/ColorLensOutlined'; +import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; +import { EditorMarkFormat } from '$app/application/document/document.types'; +import debounce from 'lodash-es/debounce'; +import ColorPopover from './ColorPopover'; + +export function Color(_: { onOpen?: () => void; onClose?: () => void }) { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const isActivated = + CustomEditor.isMarkActive(editor, EditorMarkFormat.FontColor) || + CustomEditor.isMarkActive(editor, EditorMarkFormat.BgColor); + const debouncedClose = useMemo( + () => + debounce(() => { + setOpen(false); + }, 200), + [] + ); + + const handleOpen = useCallback(() => { + debouncedClose.cancel(); + setOpen(true); + }, [debouncedClose]); + + return ( + <> + +
+ + +
+
+ {open && ref.current && ( + + )} + + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/ColorPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/ColorPopover.tsx new file mode 100644 index 0000000000000..9f007dc7b5f94 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/ColorPopover.tsx @@ -0,0 +1,92 @@ +import React, { useCallback } from 'react'; +import { PopoverNoBackdropProps } from '$app/components/editor/components/tools/popover'; +import { ColorPicker } from '$app/components/editor/components/tools/_shared'; +import { Popover } from '@mui/material'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import { EditorMarkFormat } from '$app/application/document/document.types'; +import { addMark, removeMark } from 'slate'; +import { useSlateStatic } from 'slate-react'; +import { DebouncedFunc } from 'lodash-es/debounce'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; + +const initialOrigin: { + transformOrigin?: PopoverOrigin; + anchorOrigin?: PopoverOrigin; +} = { + anchorOrigin: { + vertical: 'bottom', + horizontal: 'center', + }, + transformOrigin: { + vertical: 'top', + horizontal: 'center', + }, +}; + +function ColorPopover({ + open, + anchorEl, + debounceClose, +}: { + open: boolean; + onOpen: () => void; + anchorEl: HTMLButtonElement | null; + debounceClose: DebouncedFunc<() => void>; +}) { + const editor = useSlateStatic(); + const handleChange = useCallback( + (format: EditorMarkFormat.FontColor | EditorMarkFormat.BgColor, color: string) => { + if (color) { + addMark(editor, format, color); + } else { + removeMark(editor, format); + } + }, + [editor] + ); + + const { paperHeight, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ + initialPaperWidth: 200, + initialPaperHeight: 420, + anchorEl, + initialAnchorOrigin: initialOrigin.anchorOrigin, + initialTransformOrigin: initialOrigin.transformOrigin, + open, + }); + + return ( + { + e.stopPropagation(); + if (e.key === 'Escape') { + debounceClose(); + } + }} + onMouseDown={(e) => { + e.preventDefault(); + }} + onMouseEnter={() => { + debounceClose.cancel(); + }} + onMouseLeave={debounceClose} + > + + + ); +} + +export default ColorPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/index.ts new file mode 100644 index 0000000000000..0fd619bc8672c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/color/index.ts @@ -0,0 +1 @@ +export * from './Color'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/formula/Formula.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/formula/Formula.tsx new file mode 100644 index 0000000000000..c7bfc113526ac --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/formula/Formula.tsx @@ -0,0 +1,51 @@ +import React, { useCallback } from 'react'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; +import Functions from '@mui/icons-material/Functions'; +import { useEditorInlineBlockState } from '$app/components/editor/stores'; + +export function Formula() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const isActivatedMention = CustomEditor.isMentionActive(editor); + + const formulaMatch = CustomEditor.formulaActiveNode(editor); + const isActivated = !isActivatedMention && CustomEditor.isFormulaActive(editor); + + const { setRange, openPopover } = useEditorInlineBlockState('formula'); + const onClick = useCallback(() => { + let selection = editor.selection; + + if (!selection) return; + if (formulaMatch) { + selection = editor.range(formulaMatch[1]); + editor.select(selection); + } else { + CustomEditor.toggleFormula(editor); + } + + requestAnimationFrame(() => { + const selection = editor.selection; + + if (!selection) return; + + setRange(selection); + openPopover(); + }); + }, [editor, formulaMatch, setRange, openPopover]); + + return ( + + + + ); +} + +export default Formula; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/formula/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/formula/index.ts new file mode 100644 index 0000000000000..dc4ad2cd03737 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/formula/index.ts @@ -0,0 +1 @@ +export * from './Formula'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/heading/Heading.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/heading/Heading.tsx new file mode 100644 index 0000000000000..1fc639c41fc94 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/heading/Heading.tsx @@ -0,0 +1,56 @@ +import React, { useCallback } from 'react'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { ReactComponent as Heading1Svg } from '$app/assets/h1.svg'; +import { ReactComponent as Heading2Svg } from '$app/assets/h2.svg'; +import { ReactComponent as Heading3Svg } from '$app/assets/h3.svg'; +import { useTranslation } from 'react-i18next'; +import { CustomEditor } from '$app/components/editor/command'; +import { EditorNodeType, HeadingNode } from '$app/application/document/document.types'; +import { useSlateStatic } from 'slate-react'; +import { getBlock } from '$app/components/editor/plugins/utils'; + +export function Heading() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const toHeading = useCallback( + (level: number) => { + return () => { + CustomEditor.turnToBlock(editor, { + type: EditorNodeType.HeadingBlock, + data: { + level, + }, + }); + }; + }, + [editor] + ); + + const isActivated = useCallback( + (level: number) => { + const node = getBlock(editor) as HeadingNode; + + if (!node) return false; + const isBlock = CustomEditor.isBlockActive(editor, EditorNodeType.HeadingBlock); + + return isBlock && node.data.level === level; + }, + [editor] + ); + + return ( +
+ + + + + + + + + +
+ ); +} + +export default Heading; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/heading/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/heading/index.ts new file mode 100644 index 0000000000000..6406e7b07f496 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/heading/index.ts @@ -0,0 +1 @@ +export * from './Heading'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/Href.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/Href.tsx new file mode 100644 index 0000000000000..e7412a909e4d4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/Href.tsx @@ -0,0 +1,47 @@ +import React, { useCallback, useMemo } from 'react'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; +import { ReactComponent as LinkSvg } from '$app/assets/link.svg'; +import { EditorMarkFormat } from '$app/application/document/document.types'; +import { useDecorateDispatch } from '$app/components/editor/stores'; +import { getModifier } from '$app/utils/hotkeys'; + +export function Href() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const isActivatedInline = CustomEditor.isInlineActive(editor); + const isActivated = !isActivatedInline && CustomEditor.isMarkActive(editor, EditorMarkFormat.Href); + + const { add: addDecorate } = useDecorateDispatch(); + const onClick = useCallback(() => { + if (!editor.selection) return; + addDecorate({ + range: editor.selection, + class_name: 'bg-content-blue-100 rounded', + type: 'link', + }); + }, [addDecorate, editor]); + + const tooltip = useMemo(() => { + const modifier = getModifier(); + + return ( + <> +
{t('editor.link')}
+
{`${modifier} + K`}
+ + ); + }, [t]); + + return ( + <> + + + + + ); +} + +export default Href; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/LinkActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/LinkActions.tsx new file mode 100644 index 0000000000000..b77a2490516af --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/LinkActions.tsx @@ -0,0 +1,59 @@ +import React, { useCallback, useMemo } from 'react'; +import { useDecorateDispatch, useDecorateState } from '$app/components/editor/stores'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { Editor } from 'slate'; +import { LinkEditPopover } from '$app/components/editor/components/inline_nodes/link'; + +export function LinkActions() { + const editor = useSlateStatic(); + const decorateState = useDecorateState('link'); + const openEditPopover = !!decorateState; + const { clear: clearDecorate } = useDecorateDispatch(); + + const anchorPosition = useMemo(() => { + const range = decorateState?.range; + + if (!range) return; + + const domRange = ReactEditor.toDOMRange(editor, range); + + const rect = domRange.getBoundingClientRect(); + + return { + top: rect.top, + left: rect.left, + height: rect.height, + }; + }, [decorateState?.range, editor]); + + const defaultHref = useMemo(() => { + const range = decorateState?.range; + + if (!range) return ''; + + const marks = Editor.marks(editor); + + return marks?.href || Editor.string(editor, range); + }, [decorateState?.range, editor]); + + const handleEditPopoverClose = useCallback(() => { + const range = decorateState?.range; + + clearDecorate(); + if (range) { + ReactEditor.focus(editor); + editor.select(range); + } + }, [clearDecorate, decorateState?.range, editor]); + + if (!openEditPopover) return null; + return ( + + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/index.ts new file mode 100644 index 0000000000000..9a7210c1404d9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/index.ts @@ -0,0 +1,2 @@ +export * from './Href'; +export * from './LinkActions'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx new file mode 100644 index 0000000000000..3cf9c7ed858b0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx @@ -0,0 +1,39 @@ +import React, { useCallback, useMemo } from 'react'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; +import { ReactComponent as CodeSvg } from '$app/assets/inline-code.svg'; +import { EditorMarkFormat } from '$app/application/document/document.types'; +import { createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys'; + +export function InlineCode() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Code); + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.CODE), []); + + const onClick = useCallback(() => { + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Code, + value: true, + }); + }, [editor]); + + return ( + +
{t('editor.embedCode')}
+
{modifier}
+ + } + > + +
+ ); +} + +export default InlineCode; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/index.ts new file mode 100644 index 0000000000000..9a4c4930c761f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/index.ts @@ -0,0 +1 @@ +export * from './InlineCode'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/italic/Italic.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/italic/Italic.tsx new file mode 100644 index 0000000000000..89fff40e6f594 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/italic/Italic.tsx @@ -0,0 +1,39 @@ +import React, { useCallback, useMemo } from 'react'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; +import { ReactComponent as ItalicSvg } from '$app/assets/italic.svg'; +import { EditorMarkFormat } from '$app/application/document/document.types'; +import { createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys'; + +export function Italic() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Italic); + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.ITALIC), []); + + const onClick = useCallback(() => { + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Italic, + value: true, + }); + }, [editor]); + + return ( + +
{t('toolbar.italic')}
+
{modifier}
+ + } + > + +
+ ); +} + +export default Italic; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/italic/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/italic/index.ts new file mode 100644 index 0000000000000..70bb069b60f41 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/italic/index.ts @@ -0,0 +1 @@ +export * from './Italic'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/NumberedList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/NumberedList.tsx new file mode 100644 index 0000000000000..006247ca8b9b4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/NumberedList.tsx @@ -0,0 +1,33 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { ReactComponent as NumberedListSvg } from '$app/assets/numbers.svg'; + +export function NumberedList() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.NumberedListBlock); + + const onClick = useCallback(() => { + let type = EditorNodeType.NumberedListBlock; + + if (isActivated) { + type = EditorNodeType.Paragraph; + } + + CustomEditor.turnToBlock(editor, { + type, + }); + }, [editor, isActivated]); + + return ( + + + + ); +} + +export default NumberedList; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/index.ts new file mode 100644 index 0000000000000..6e985ae25b66e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/index.ts @@ -0,0 +1 @@ +export * from './NumberedList'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/paragraph/Paragraph.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/paragraph/Paragraph.tsx new file mode 100644 index 0000000000000..1ac5610787121 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/paragraph/Paragraph.tsx @@ -0,0 +1,33 @@ +import React, { useCallback } from 'react'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { useTranslation } from 'react-i18next'; +import { getBlock } from '$app/components/editor/plugins/utils'; +import { CustomEditor } from '$app/components/editor/command'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { useSlateStatic } from 'slate-react'; +import { ReactComponent as ParagraphSvg } from '$app/assets/text.svg'; + +export function Paragraph() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + + const onClick = useCallback(() => { + const node = getBlock(editor); + + if (!node) return; + + CustomEditor.turnToBlock(editor, { + type: EditorNodeType.Paragraph, + }); + }, [editor]); + + const isActive = CustomEditor.isBlockActive(editor, EditorNodeType.Paragraph); + + return ( + + + + ); +} + +export default Paragraph; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/paragraph/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/paragraph/index.ts new file mode 100644 index 0000000000000..01752c914cb54 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/paragraph/index.ts @@ -0,0 +1 @@ +export * from './Paragraph'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/Quote.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/Quote.tsx new file mode 100644 index 0000000000000..29ad0de104f81 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/Quote.tsx @@ -0,0 +1,33 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { ReactComponent as QuoteSvg } from '$app/assets/quote.svg'; + +export function Quote() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.QuoteBlock); + + const onClick = useCallback(() => { + let type = EditorNodeType.QuoteBlock; + + if (isActivated) { + type = EditorNodeType.Paragraph; + } + + CustomEditor.turnToBlock(editor, { + type, + }); + }, [editor, isActivated]); + + return ( + + + + ); +} + +export default Quote; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/index.ts new file mode 100644 index 0000000000000..c88e677a5388a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/index.ts @@ -0,0 +1 @@ +export * from './Quote'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/strikethrough/StrikeThrough.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/strikethrough/StrikeThrough.tsx new file mode 100644 index 0000000000000..325f6ac55a5a8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/strikethrough/StrikeThrough.tsx @@ -0,0 +1,39 @@ +import React, { useCallback, useMemo } from 'react'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; +import { ReactComponent as StrikeThroughSvg } from '$app/assets/strikethrough.svg'; +import { EditorMarkFormat } from '$app/application/document/document.types'; +import { createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys'; + +export function StrikeThrough() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.StrikeThrough); + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.STRIKETHROUGH), []); + + const onClick = useCallback(() => { + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.StrikeThrough, + value: true, + }); + }, [editor]); + + return ( + +
{t('editor.strikethrough')}
+
{modifier}
+ + } + > + +
+ ); +} + +export default StrikeThrough; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/strikethrough/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/strikethrough/index.ts new file mode 100644 index 0000000000000..f8314d16e370a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/strikethrough/index.ts @@ -0,0 +1 @@ +export * from './StrikeThrough'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/TodoList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/TodoList.tsx new file mode 100644 index 0000000000000..cd576edafada7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/TodoList.tsx @@ -0,0 +1,37 @@ +import React, { useCallback } from 'react'; +import { useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { ReactComponent as TodoListSvg } from '$app/assets/todo-list.svg'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { useTranslation } from 'react-i18next'; + +export function TodoList() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + + const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.TodoListBlock); + + const onClick = useCallback(() => { + let type = EditorNodeType.TodoListBlock; + + if (isActivated) { + type = EditorNodeType.Paragraph; + } + + CustomEditor.turnToBlock(editor, { + type, + data: { + checked: false, + }, + }); + }, [editor, isActivated]); + + return ( + + + + ); +} + +export default TodoList; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/index.ts new file mode 100644 index 0000000000000..f239f43459ea3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/ToggleList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/ToggleList.tsx new file mode 100644 index 0000000000000..4d8265298883b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/ToggleList.tsx @@ -0,0 +1,36 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; +import { EditorNodeType } from '$app/application/document/document.types'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { ReactComponent as ToggleListSvg } from '$app/assets/show-menu.svg'; + +export function ToggleList() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.ToggleListBlock); + + const onClick = useCallback(() => { + let type = EditorNodeType.ToggleListBlock; + + if (isActivated) { + type = EditorNodeType.Paragraph; + } + + CustomEditor.turnToBlock(editor, { + type, + data: { + collapsed: false, + }, + }); + }, [editor, isActivated]); + + return ( + + + + ); +} + +export default ToggleList; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/index.ts new file mode 100644 index 0000000000000..833bdb5210916 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/index.ts @@ -0,0 +1 @@ +export * from './ToggleList'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/underline/Underline.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/underline/Underline.tsx new file mode 100644 index 0000000000000..b0df70e30e414 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/underline/Underline.tsx @@ -0,0 +1,39 @@ +import React, { useCallback, useMemo } from 'react'; +import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; +import { ReactComponent as UnderlineSvg } from '$app/assets/underline.svg'; +import { EditorMarkFormat } from '$app/application/document/document.types'; +import { createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys'; + +export function Underline() { + const { t } = useTranslation(); + const editor = useSlateStatic(); + const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Underline); + const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.UNDERLINE), []); + + const onClick = useCallback(() => { + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Underline, + value: true, + }); + }, [editor]); + + return ( + +
{t('editor.underline')}
+
{modifier}
+ + } + > + +
+ ); +} + +export default Underline; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/underline/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/underline/index.ts new file mode 100644 index 0000000000000..a1d53a4384e82 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/underline/index.ts @@ -0,0 +1 @@ +export * from './Underline'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/index.ts new file mode 100644 index 0000000000000..a6ced3f248413 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/index.ts @@ -0,0 +1 @@ +export * from './SelectionToolbar'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/utils.ts new file mode 100644 index 0000000000000..178da73df4fd6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/utils.ts @@ -0,0 +1,40 @@ +import { ReactEditor } from 'slate-react'; +import { getEditorDomNode } from '$app/components/editor/plugins/utils'; + +export function getSelectionPosition(editor: ReactEditor) { + const domSelection = window.getSelection(); + const rangeCount = domSelection?.rangeCount; + + if (!rangeCount) return null; + + const domRange = rangeCount > 0 ? domSelection.getRangeAt(0) : undefined; + + const rect = domRange?.getBoundingClientRect(); + + let newRect; + + const domNode = getEditorDomNode(editor); + const domNodeRect = domNode.getBoundingClientRect(); + + // the default height of the toolbar is 30px + const gap = 106; + + if (rect) { + let relativeDomTop = rect.top - domNodeRect.top; + const relativeDomLeft = rect.left - domNodeRect.left; + + // if the range is above the window, move the toolbar to the bottom of range + if (rect.top < gap) { + relativeDomTop = -domNodeRect.top + gap; + } + + newRect = { + top: relativeDomTop, + left: relativeDomLeft, + width: rect.width, + height: rect.height, + }; + } + + return newRect; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss b/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss new file mode 100644 index 0000000000000..271dd36cdac81 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss @@ -0,0 +1,231 @@ + +.block-element { + @apply my-[4px]; + +} + +.block-element .block-element { + @apply mb-0; + margin-left: 24px; +} + +.block-element.block-align-left { + > div > .text-element { + text-align: left; + justify-content: flex-start; + } +} +.block-element.block-align-right { + > div > .text-element { + text-align: right; + justify-content: flex-end; + } +} +.block-element.block-align-center { + > div > .text-element { + text-align: center; + justify-content: center; + } + +} + + +.block-element[data-block-type="todo_list"] .checked > .text-element { + text-decoration: line-through; + color: var(--text-caption); +} + +.block-element .collapsed .block-element { + display: none !important; +} + +[role=textbox] { + .text-element { + &::selection { + @apply bg-transparent; + } + } +} + + + +span[data-slate-placeholder="true"]:not(.inline-block-content) { + @apply text-text-placeholder; + opacity: 1 !important; +} + + +[role="textbox"] { + ::selection { + @apply bg-content-blue-100; + } + .text-content { + &::selection { + @apply bg-transparent; + } + &.selected { + @apply bg-content-blue-100; + } + span { + &::selection { + @apply bg-content-blue-100; + } + } + } +} + + +[data-dark-mode="true"] [role="textbox"]{ + ::selection { + background-color: #1e79a2; + } + + .text-content { + &::selection { + @apply bg-transparent; + } + &.selected { + background-color: #1e79a2; + } + span { + &::selection { + background-color: #1e79a2; + } + } + } +} + + +.text-content, [data-dark-mode="true"] .text-content { + @apply min-w-[1px]; + &.empty-text { + span { + &::selection { + @apply bg-transparent; + } + } + } +} + +.text-element:has(.text-placeholder), .divider-node, [data-dark-mode="true"] .text-element:has(.text-placeholder), [data-dark-mode="true"] .divider-node { + ::selection { + @apply bg-transparent; + } +} + +.text-placeholder { + @apply absolute left-[5px] transform -translate-y-1/2 pointer-events-none select-none whitespace-nowrap; + &:after { + @apply text-text-placeholder absolute top-0; + content: (attr(placeholder)); + } +} + +.block-align-center { + .text-placeholder { + @apply left-[calc(50%+1px)]; + &:after { + @apply left-0; + } + } + .has-start-icon .text-placeholder { + @apply left-[calc(50%+13px)]; + &:after { + @apply left-0; + } + } + +} + +.block-align-left { + .text-placeholder { + &:after { + @apply left-0; + } + } + .has-start-icon .text-placeholder { + &:after { + @apply left-[24px]; + } + } +} + +.block-align-right { + + .text-placeholder { + + @apply relative w-fit h-0 order-2; + &:after { + @apply relative w-fit top-1/2 left-[-6px]; + } + } + .text-content { + @apply order-1; + } + + .has-start-icon .text-placeholder { + &:after { + @apply left-[-6px]; + } + } +} + + +.formula-inline { + &.selected { + @apply rounded bg-content-blue-100; + } +} + +.bulleted-icon { + &:after { + content: attr(data-letter); + } +} + +.numbered-icon { + &:after { + content: attr(data-number) "."; + } +} + + +.grid-block .grid-scroll-container::-webkit-scrollbar { + width: 0; + height: 0; +} + +.image-render { + .image-resizer { + @apply absolute w-[10px] top-0 z-10 flex h-full cursor-col-resize items-center justify-end; + .resize-handle { + @apply h-1/4 w-1/2 transform transition-all duration-500 select-none rounded-full border border-white opacity-0; + background: var(--fill-toolbar); + } + } + &:hover { + .image-resizer{ + .resize-handle { + @apply opacity-90; + } + } + } +} + + +.image-block, .math-equation-block, [data-dark-mode="true"] .image-block, [data-dark-mode="true"] .math-equation-block { + ::selection { + @apply bg-transparent; + } + &:hover { + .container-bg { + background: var(--content-blue-100) !important; + } + } +} + +.mention-inline { + &:hover { + @apply bg-fill-list-active rounded; + } +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/index.ts new file mode 100644 index 0000000000000..8b7c4c267aa62 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/index.ts @@ -0,0 +1 @@ +export * from './Editor'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/constants.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/constants.ts new file mode 100644 index 0000000000000..03e441b1c3e4b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/constants.ts @@ -0,0 +1,3 @@ +import { EditorNodeType } from '$app/application/document/document.types'; + +export const SOFT_BREAK_TYPES = [EditorNodeType.CalloutBlock, EditorNodeType.CodeBlock]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/index.ts new file mode 100644 index 0000000000000..bf2b09a1c304d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/index.ts @@ -0,0 +1,2 @@ +export * from './withCopy'; +export * from './withPasted'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/utils.ts new file mode 100644 index 0000000000000..cb377fece424f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/utils.ts @@ -0,0 +1,311 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Node, Location, Range, Path, Element, Text, Transforms, NodeEntry } from 'slate'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; +import { LIST_TYPES } from '$app/components/editor/command/tab'; + +/** + * Rewrite the insertFragment function to avoid the empty node(doesn't have text node) in the fragment + + * @param editor + * @param fragment + * @param options + */ +export function insertFragment( + editor: ReactEditor, + fragment: (Text | Element)[], + options: { + at?: Location; + hanging?: boolean; + voids?: boolean; + } = {} +) { + Editor.withoutNormalizing(editor, () => { + const { hanging = false, voids = false } = options; + let { at = getDefaultInsertLocation(editor) } = options; + + if (!fragment.length) { + return; + } + + if (Range.isRange(at)) { + if (!hanging) { + at = Editor.unhangRange(editor, at, { voids }); + } + + if (Range.isCollapsed(at)) { + at = at.anchor; + } else { + const [, end] = Range.edges(at); + + if (!voids && Editor.void(editor, { at: end })) { + return; + } + + const pointRef = Editor.pointRef(editor, end); + + Transforms.delete(editor, { at }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + at = pointRef.unref()!; + } + } else if (Path.isPath(at)) { + at = Editor.start(editor, at); + } + + if (!voids && Editor.void(editor, { at })) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const blockMatch = Editor.above(editor, { + match: (n) => Element.isElement(n) && Editor.isBlock(editor, n) && n.blockId !== undefined, + at, + voids, + })!; + const [block, blockPath] = blockMatch as NodeEntry; + + const isEmbedBlock = Element.isElement(block) && editor.isEmbed(block); + const isPageBlock = Element.isElement(block) && block.type === EditorNodeType.Page; + const isBlockStart = Editor.isStart(editor, at, blockPath); + const isBlockEnd = Editor.isEnd(editor, at, blockPath); + const isBlockEmpty = isBlockStart && isBlockEnd; + + if (isEmbedBlock) { + insertOnEmbedBlock(editor, fragment, blockPath); + return; + } + + if (isBlockEmpty && !isPageBlock) { + const node = fragment[0] as Element; + + if (block.type !== EditorNodeType.Paragraph) { + node.type = block.type; + node.data = { + ...(node.data || {}), + ...(block.data || {}), + }; + } + + insertOnEmptyBlock(editor, fragment, blockPath); + return; + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const fragmentRoot: Node = { + children: fragment, + }; + const [, firstPath] = Node.first(fragmentRoot, []); + const [, lastPath] = Node.last(fragmentRoot, []); + const sameBlock = Path.equals(firstPath.slice(0, -1), lastPath.slice(0, -1)); + + if (sameBlock) { + insertTexts( + editor, + isPageBlock + ? ({ + children: [ + { + text: CustomEditor.getNodeTextContent(fragmentRoot), + }, + ], + } as Node) + : fragmentRoot, + at + ); + return; + } + + const isListTypeBlock = LIST_TYPES.includes(block.type as EditorNodeType); + const [, ...blockChildren] = block.children; + + const blockEnd = editor.end([...blockPath, 0]); + const afterRange: Range = { anchor: at, focus: blockEnd }; + + const afterTexts = getTexts(editor, { + children: editor.fragment(afterRange), + } as Node) as (Text | Element)[]; + + Transforms.delete(editor, { at: afterRange }); + + const { startTexts, startChildren, middles } = getFragmentGroup(editor, fragment); + + insertNodes( + editor, + isPageBlock + ? [ + { + text: CustomEditor.getNodeTextContent({ + children: startTexts, + } as Node), + }, + ] + : startTexts, + { + at, + } + ); + + if (isPageBlock) { + insertNodes(editor, [...startChildren, ...middles], { + at: Path.next(blockPath), + select: true, + }); + } else { + if (blockChildren.length > 0) { + const path = [...blockPath, 1]; + + insertNodes(editor, [...startChildren, ...middles], { + at: path, + select: true, + }); + } else { + const newMiddle = [...middles]; + + if (isListTypeBlock) { + const path = [...blockPath, 1]; + + insertNodes(editor, startChildren, { + at: path, + select: newMiddle.length === 0, + }); + } else { + newMiddle.unshift(...startChildren); + } + + insertNodes(editor, newMiddle, { + at: Path.next(blockPath), + select: true, + }); + } + } + + const { selection } = editor; + + if (!selection) return; + + insertNodes(editor, afterTexts, { + at: selection, + }); + }); +} + +function getFragmentGroup(editor: ReactEditor, fragment: Node[]) { + const startTexts = []; + const startChildren = []; + const middles = []; + + const [firstNode, ...otherNodes] = fragment; + const [firstNodeText, ...firstNodeChildren] = (firstNode as Element).children as Element[]; + + startTexts.push(...firstNodeText.children); + startChildren.push(...firstNodeChildren); + + for (const node of otherNodes) { + if (Element.isElement(node) && node.blockId !== undefined) { + middles.push(node); + } + } + + return { + startTexts, + startChildren, + middles, + }; +} + +function getTexts(editor: ReactEditor, fragment: Node) { + const matches = []; + const matcher = ([n]: NodeEntry) => Text.isText(n) || (Element.isElement(n) && editor.isInline(n)); + + for (const entry of Node.nodes(fragment, { pass: matcher })) { + if (matcher(entry)) { + matches.push(entry[0]); + } + } + + return matches; +} + +function insertTexts(editor: ReactEditor, fragmentRoot: Node, at: Location) { + const matches = getTexts(editor, fragmentRoot); + + insertNodes(editor, matches, { + at, + select: true, + }); +} + +function insertOnEmptyBlock(editor: ReactEditor, fragment: Node[], blockPath: Path) { + editor.removeNodes({ + at: blockPath, + }); + + insertNodes(editor, fragment, { + at: blockPath, + select: true, + }); +} + +function insertOnEmbedBlock(editor: ReactEditor, fragment: Node[], blockPath: Path) { + insertNodes(editor, fragment, { + at: Path.next(blockPath), + select: true, + }); +} + +function insertNodes(editor: ReactEditor, nodes: Node[], options: { at?: Location; select?: boolean } = {}) { + try { + Transforms.insertNodes(editor, nodes, options); + } catch (e) { + try { + editor.move({ + distance: 1, + unit: 'line', + }); + } catch (e) { + // do nothing + } + } +} + +/** + * Copy Code from slate/src/utils/get-default-insert-location.ts + * Get the default location to insert content into the editor. + * By default, use the selection as the target location. But if there is + * no selection, insert at the end of the document since that is such a + * common use case when inserting from a non-selected state. + */ +export const getDefaultInsertLocation = (editor: Editor): Location => { + if (editor.selection) { + return editor.selection; + } else if (editor.children.length > 0) { + return Editor.end(editor, []); + } else { + return [0]; + } +}; + +export function transFragment(editor: ReactEditor, fragment: Node[]) { + // flatten the fragment to avoid the empty node(doesn't have text node) in the fragment + const flatMap = (node: Node): Node[] => { + const isInputElement = + !Editor.isEditor(node) && Element.isElement(node) && node.blockId !== undefined && !editor.isEmbed(node); + + if ( + isInputElement && + node.children?.length > 0 && + Element.isElement(node.children[0]) && + node.children[0].type !== EditorNodeType.Text + ) { + return node.children.flatMap((child) => flatMap(child)); + } + + return [node]; + }; + + const fragmentFlatMap = fragment?.flatMap(flatMap); + + // clone the node to avoid the duplicated block id + return fragmentFlatMap.map((item) => CustomEditor.cloneBlock(editor, item as Element)); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withCopy.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withCopy.ts new file mode 100644 index 0000000000000..c0daab0a8f95b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withCopy.ts @@ -0,0 +1,40 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Element, Range } from 'slate'; + +export function withCopy(editor: ReactEditor) { + const { setFragmentData } = editor; + + editor.setFragmentData = (...args) => { + if (!editor.selection) { + setFragmentData(...args); + return; + } + + // selection is collapsed and the node is an embed, we need to set the data manually + if (Range.isCollapsed(editor.selection)) { + const match = Editor.above(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined, + }); + const node = match ? (match[0] as Element) : undefined; + + if (node && editor.isEmbed(node)) { + const fragment = editor.getFragment(); + + if (fragment.length > 0) { + const data = args[0]; + const string = JSON.stringify(fragment); + const encoded = window.btoa(encodeURIComponent(string)); + + const dom = ReactEditor.toDOMNode(editor, node); + + data.setData(`application/x-slate-fragment`, encoded); + data.setData(`text/html`, dom.innerHTML); + } + } + } + + setFragmentData(...args); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withPasted.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withPasted.ts new file mode 100644 index 0000000000000..2266ff41c78e6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/copyPasted/withPasted.ts @@ -0,0 +1,59 @@ +import { ReactEditor } from 'slate-react'; +import { insertFragment, transFragment } from './utils'; +import { convertBlockToJson } from '$app/application/document/document.service'; +import { InputType } from '@/services/backend'; +import { CustomEditor } from '$app/components/editor/command'; +import { Log } from '$app/utils/log'; + +export function withPasted(editor: ReactEditor) { + const { insertData } = editor; + + editor.insertData = (data) => { + const fragment = data.getData('application/x-slate-fragment'); + + if (fragment) { + insertData(data); + return; + } + + const html = data.getData('text/html'); + const text = data.getData('text/plain'); + + if (!html && !text) { + insertData(data); + return; + } + + void (async () => { + try { + const nodes = await convertBlockToJson(html, InputType.Html); + + const htmlTransNoText = nodes.every((node) => { + return CustomEditor.getNodeTextContent(node).length === 0; + }); + + if (!htmlTransNoText) { + return editor.insertFragment(nodes); + } + } catch (e) { + Log.warn('pasted html error', e); + // ignore + } + + if (text) { + const nodes = await convertBlockToJson(text, InputType.PlainText); + + editor.insertFragment(nodes); + return; + } + })(); + }; + + editor.insertFragment = (fragment, options = {}) => { + const clonedFragment = transFragment(editor, fragment); + + insertFragment(editor, clonedFragment, options); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts new file mode 100644 index 0000000000000..0292784ba546c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts @@ -0,0 +1,2 @@ +export * from './shortcuts.hooks'; +export * from './withMarkdown'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/markdown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/markdown.ts new file mode 100644 index 0000000000000..59ff0a859327f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/markdown.ts @@ -0,0 +1,172 @@ +export type MarkdownRegex = { + [key in MarkdownShortcuts]: { + pattern: RegExp; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: Record; + }[]; +}; + +export type TriggerHotKey = { + [key in MarkdownShortcuts]: string[]; +}; + +export enum MarkdownShortcuts { + Bold, + Italic, + StrikeThrough, + Code, + Equation, + /** block */ + Heading, + BlockQuote, + CodeBlock, + Divider, + /** list */ + BulletedList, + NumberedList, + TodoList, + ToggleList, +} + +const defaultMarkdownRegex: MarkdownRegex = { + [MarkdownShortcuts.Heading]: [ + { + pattern: /^#{1,6}$/, + }, + ], + [MarkdownShortcuts.Bold]: [ + { + pattern: /(\*\*|__)(.*?)(\*\*|__)$/, + }, + ], + [MarkdownShortcuts.Italic]: [ + { + pattern: /([*_])(.*?)([*_])$/, + }, + ], + [MarkdownShortcuts.StrikeThrough]: [ + { + pattern: /(~~)(.*?)(~~)$/, + }, + { + pattern: /(~)(.*?)(~)$/, + }, + ], + [MarkdownShortcuts.Code]: [ + { + pattern: /(`)(.*?)(`)$/, + }, + ], + [MarkdownShortcuts.Equation]: [ + { + pattern: /(\$)(.*?)(\$)$/, + data: { + formula: '', + }, + }, + ], + [MarkdownShortcuts.BlockQuote]: [ + { + pattern: /^([”“"])$/, + }, + ], + [MarkdownShortcuts.CodeBlock]: [ + { + pattern: /^(`{2,})$/, + data: { + language: 'json', + }, + }, + ], + [MarkdownShortcuts.Divider]: [ + { + pattern: /^(([-*]){2,})$/, + }, + ], + + [MarkdownShortcuts.BulletedList]: [ + { + pattern: /^([*\-+])$/, + }, + ], + [MarkdownShortcuts.NumberedList]: [ + { + pattern: /^(\d+)\.$/, + }, + ], + [MarkdownShortcuts.TodoList]: [ + { + pattern: /^(-)?\[ ]$/, + data: { + checked: false, + }, + }, + { + pattern: /^(-)?\[x]$/, + data: { + checked: true, + }, + }, + { + pattern: /^(-)?\[]$/, + data: { + checked: false, + }, + }, + ], + [MarkdownShortcuts.ToggleList]: [ + { + pattern: /^>$/, + data: { + collapsed: false, + }, + }, + ], +}; + +export const defaultTriggerChar: TriggerHotKey = { + [MarkdownShortcuts.Heading]: [' '], + [MarkdownShortcuts.Bold]: ['*', '_'], + [MarkdownShortcuts.Italic]: ['*', '_'], + [MarkdownShortcuts.StrikeThrough]: ['~'], + [MarkdownShortcuts.Code]: ['`'], + [MarkdownShortcuts.BlockQuote]: [' '], + [MarkdownShortcuts.CodeBlock]: ['`'], + [MarkdownShortcuts.Divider]: ['-', '*'], + [MarkdownShortcuts.Equation]: ['$'], + [MarkdownShortcuts.BulletedList]: [' '], + [MarkdownShortcuts.NumberedList]: [' '], + [MarkdownShortcuts.TodoList]: [' '], + [MarkdownShortcuts.ToggleList]: [' '], +}; + +export function isTriggerChar(char: string) { + return Object.values(defaultTriggerChar).some((trigger) => trigger.includes(char)); +} + +export function whatShortcutTrigger(char: string): MarkdownShortcuts[] | null { + const isTrigger = isTriggerChar(char); + + if (!isTrigger) { + return null; + } + + const shortcuts = Object.keys(defaultTriggerChar).map((key) => Number(key) as MarkdownShortcuts); + + return shortcuts.filter((shortcut) => defaultTriggerChar[shortcut].includes(char)); +} + +export function getRegex(shortcut: MarkdownShortcuts) { + return defaultMarkdownRegex[shortcut]; +} + +export function whatShortcutsMatch(text: string) { + const shortcuts = Object.keys(defaultMarkdownRegex).map((key) => Number(key) as MarkdownShortcuts); + + return shortcuts.filter((shortcut) => { + const regexes = defaultMarkdownRegex[shortcut]; + + return regexes.some((regex) => regex.pattern.test(text)); + }); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts new file mode 100644 index 0000000000000..45d61f847cfe3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts @@ -0,0 +1,349 @@ +import { ReactEditor } from 'slate-react'; +import { useCallback, KeyboardEvent } from 'react'; +import { EditorMarkFormat, EditorNodeType, ToggleListNode } from '$app/application/document/document.types'; +import { getBlock } from '$app/components/editor/plugins/utils'; +import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants'; +import { CustomEditor } from '$app/components/editor/command'; +import { createHotkey, HOT_KEY_NAME } from '$app/utils/hotkeys'; +import { openUrl } from '$app/utils/open_url'; +import { Range } from 'slate'; +import { readText } from '@tauri-apps/api/clipboard'; +import { useDecorateDispatch } from '$app/components/editor/stores'; + +function getScrollContainer(editor: ReactEditor) { + const editorDom = ReactEditor.toDOMNode(editor, editor); + + return editorDom.closest('.appflowy-scroll-container') as HTMLDivElement; +} + +export function useShortcuts(editor: ReactEditor) { + const { add: addDecorate } = useDecorateDispatch(); + + const formatLink = useCallback(() => { + const { selection } = editor; + + if (!selection || Range.isCollapsed(selection)) return; + + const isIncludeRoot = CustomEditor.selectionIncludeRoot(editor); + + if (isIncludeRoot) return; + + const isActivatedInline = CustomEditor.isInlineActive(editor); + + if (isActivatedInline) return; + + addDecorate({ + range: selection, + class_name: 'bg-content-blue-100 rounded', + type: 'link', + }); + }, [addDecorate, editor]); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + const event = e.nativeEvent; + const hasEditableTarget = ReactEditor.hasEditableTarget(editor, event.target); + + if (!hasEditableTarget) return; + + const node = getBlock(editor); + + const { selection } = editor; + const isExpanded = selection && Range.isExpanded(selection); + + switch (true) { + /** + * Select all: Mod+A + * Default behavior: Select all text in the editor + * Special case for select all in code block: Only select all text in code block + */ + case createHotkey(HOT_KEY_NAME.SELECT_ALL)(event): + if (node && node.type === EditorNodeType.CodeBlock) { + e.preventDefault(); + const path = ReactEditor.findPath(editor, node); + + editor.select(path); + } + + break; + /** + * Escape: Esc + * Default behavior: Deselect editor + */ + case createHotkey(HOT_KEY_NAME.ESCAPE)(event): + editor.deselect(); + break; + /** + * Indent block: Tab + * Default behavior: Indent block + */ + case createHotkey(HOT_KEY_NAME.INDENT_BLOCK)(event): + e.preventDefault(); + if (SOFT_BREAK_TYPES.includes(node?.type as EditorNodeType)) { + editor.insertText('\t'); + break; + } + + CustomEditor.tabForward(editor); + break; + /** + * Outdent block: Shift+Tab + * Default behavior: Outdent block + */ + case createHotkey(HOT_KEY_NAME.OUTDENT_BLOCK)(event): + e.preventDefault(); + CustomEditor.tabBackward(editor); + break; + /** + * Split block: Enter + * Default behavior: Split block + * Special case for soft break types: Insert \n + */ + case createHotkey(HOT_KEY_NAME.SPLIT_BLOCK)(event): + if (SOFT_BREAK_TYPES.includes(node?.type as EditorNodeType)) { + e.preventDefault(); + editor.insertText('\n'); + } + + break; + /** + * Insert soft break: Shift+Enter + * Default behavior: Insert \n + * Special case for soft break types: Split block + */ + case createHotkey(HOT_KEY_NAME.INSERT_SOFT_BREAK)(event): + e.preventDefault(); + if (node && SOFT_BREAK_TYPES.includes(node.type as EditorNodeType)) { + editor.splitNodes({ + always: true, + }); + } else { + editor.insertText('\n'); + } + + break; + /** + * Toggle todo: Shift+Enter + * Default behavior: Toggle todo + * Special case for toggle list block: Toggle collapse + */ + case createHotkey(HOT_KEY_NAME.TOGGLE_TODO)(event): + case createHotkey(HOT_KEY_NAME.TOGGLE_COLLAPSE)(event): + e.preventDefault(); + if (node && node.type === EditorNodeType.ToggleListBlock) { + CustomEditor.toggleToggleList(editor, node as ToggleListNode); + } else { + CustomEditor.toggleTodo(editor); + } + + break; + /** + * Backspace: Backspace / Shift+Backspace + * Default behavior: Delete backward + */ + case createHotkey(HOT_KEY_NAME.BACKSPACE)(event): + e.stopPropagation(); + break; + /** + * Open link: Alt + enter + * Default behavior: Open one link in selection + */ + case createHotkey(HOT_KEY_NAME.OPEN_LINK)(event): { + if (!isExpanded) break; + e.preventDefault(); + const links = CustomEditor.getLinks(editor); + + if (links.length === 0) break; + openUrl(links[0]); + break; + } + + /** + * Open links: Alt + Shift + enter + * Default behavior: Open all links in selection + */ + case createHotkey(HOT_KEY_NAME.OPEN_LINKS)(event): { + if (!isExpanded) break; + e.preventDefault(); + const links = CustomEditor.getLinks(editor); + + if (links.length === 0) break; + links.forEach((link) => openUrl(link)); + break; + } + + /** + * Extend line backward: Opt + Shift + right + * Default behavior: Extend line backward + */ + case createHotkey(HOT_KEY_NAME.EXTEND_LINE_BACKWARD)(event): + e.preventDefault(); + CustomEditor.extendLineBackward(editor); + break; + /** + * Extend line forward: Opt + Shift + left + */ + case createHotkey(HOT_KEY_NAME.EXTEND_LINE_FORWARD)(event): + e.preventDefault(); + CustomEditor.extendLineForward(editor); + break; + + /** + * Paste: Mod + Shift + V + * Default behavior: Paste plain text + */ + case createHotkey(HOT_KEY_NAME.PASTE_PLAIN_TEXT)(event): + e.preventDefault(); + void (async () => { + const text = await readText(); + + if (!text) return; + CustomEditor.insertPlainText(editor, text); + })(); + + break; + /** + * Highlight: Mod + Shift + H + * Default behavior: Highlight selected text + */ + case createHotkey(HOT_KEY_NAME.HIGH_LIGHT)(event): + e.preventDefault(); + CustomEditor.highlight(editor); + break; + /** + * Extend document backward: Mod + Shift + Up + * Don't prevent default behavior + * Default behavior: Extend document backward + */ + case createHotkey(HOT_KEY_NAME.EXTEND_DOCUMENT_BACKWARD)(event): + editor.collapse({ edge: 'start' }); + break; + /** + * Extend document forward: Mod + Shift + Down + * Don't prevent default behavior + * Default behavior: Extend document forward + */ + case createHotkey(HOT_KEY_NAME.EXTEND_DOCUMENT_FORWARD)(event): + editor.collapse({ edge: 'end' }); + break; + + /** + * Scroll to top: Home + * Default behavior: Scroll to top + */ + case createHotkey(HOT_KEY_NAME.SCROLL_TO_TOP)(event): { + const scrollContainer = getScrollContainer(editor); + + scrollContainer.scrollTo({ + top: 0, + }); + break; + } + + /** + * Scroll to bottom: End + * Default behavior: Scroll to bottom + */ + case createHotkey(HOT_KEY_NAME.SCROLL_TO_BOTTOM)(event): { + const scrollContainer = getScrollContainer(editor); + + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + }); + break; + } + + /** + * Align left: Control + Shift + L + * Default behavior: Align left + */ + case createHotkey(HOT_KEY_NAME.ALIGN_LEFT)(event): + e.preventDefault(); + CustomEditor.toggleAlign(editor, 'left'); + break; + /** + * Align center: Control + Shift + E + */ + case createHotkey(HOT_KEY_NAME.ALIGN_CENTER)(event): + e.preventDefault(); + CustomEditor.toggleAlign(editor, 'center'); + break; + /** + * Align right: Control + Shift + R + */ + case createHotkey(HOT_KEY_NAME.ALIGN_RIGHT)(event): + e.preventDefault(); + CustomEditor.toggleAlign(editor, 'right'); + break; + /** + * Bold: Mod + B + */ + case createHotkey(HOT_KEY_NAME.BOLD)(event): + e.preventDefault(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Bold, + value: true, + }); + break; + /** + * Italic: Mod + I + */ + case createHotkey(HOT_KEY_NAME.ITALIC)(event): + e.preventDefault(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Italic, + value: true, + }); + break; + /** + * Underline: Mod + U + */ + case createHotkey(HOT_KEY_NAME.UNDERLINE)(event): + e.preventDefault(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Underline, + value: true, + }); + break; + /** + * Strikethrough: Mod + Shift + S / Mod + Shift + X + */ + case createHotkey(HOT_KEY_NAME.STRIKETHROUGH)(event): + e.preventDefault(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.StrikeThrough, + value: true, + }); + break; + /** + * Code: Mod + E + */ + case createHotkey(HOT_KEY_NAME.CODE)(event): + e.preventDefault(); + CustomEditor.toggleMark(editor, { + key: EditorMarkFormat.Code, + value: true, + }); + break; + /** + * Format link: Mod + K + */ + case createHotkey(HOT_KEY_NAME.FORMAT_LINK)(event): + formatLink(); + break; + + case createHotkey(HOT_KEY_NAME.FIND_REPLACE)(event): + console.log('find replace'); + break; + + default: + break; + } + }, + [formatLink, editor] + ); + + return { + onKeyDown, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdown.ts new file mode 100644 index 0000000000000..fd7801204ceb1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdown.ts @@ -0,0 +1,239 @@ +import { Range, Element, Editor, NodeEntry, Path } from 'slate'; +import { ReactEditor } from 'slate-react'; +import { + defaultTriggerChar, + getRegex, + MarkdownShortcuts, + whatShortcutsMatch, + whatShortcutTrigger, +} from '$app/components/editor/plugins/shortcuts/markdown'; +import { CustomEditor } from '$app/components/editor/command'; +import { EditorMarkFormat, EditorNodeType } from '$app/application/document/document.types'; +import isEqual from 'lodash-es/isEqual'; + +export const withMarkdown = (editor: ReactEditor) => { + const { insertText } = editor; + + editor.insertText = (char) => { + const { selection } = editor; + + insertText(char); + if (!selection || !Range.isCollapsed(selection)) { + return; + } + + const triggerShortcuts = whatShortcutTrigger(char); + + if (!triggerShortcuts) { + return; + } + + const match = CustomEditor.getBlock(editor); + const [node, path] = match as NodeEntry; + + let prevIsNumberedList = false; + + try { + const prevPath = Path.previous(path); + const prev = editor.node(prevPath) as NodeEntry; + + prevIsNumberedList = prev && prev[0].type === EditorNodeType.NumberedListBlock; + } catch (e) { + // do nothing + } + + const start = Editor.start(editor, path); + const beforeRange = { anchor: start, focus: selection.anchor }; + const beforeText = Editor.string(editor, beforeRange); + + const removeBeforeText = (beforeRange: Range) => { + editor.deleteBackward('character'); + editor.delete({ + at: beforeRange, + }); + }; + + const matchBlockShortcuts = whatShortcutsMatch(beforeText); + + for (const shortcut of matchBlockShortcuts) { + const block = whichBlock(shortcut, beforeText); + + // if the block shortcut is matched, remove the before text and turn to the block + // then return + if (block && defaultTriggerChar[shortcut].includes(char)) { + // Don't turn to the block condition + // 1. Heading should be able to co-exist with number list + if (block.type === EditorNodeType.NumberedListBlock && node.type === EditorNodeType.HeadingBlock) { + return; + } + + // 2. If the block is the same type, and data is the same + if (block.type === node.type && isEqual(block.data || {}, node.data || {})) { + return; + } + + // 3. If the block is number list, and the previous block is also number list + if (block.type === EditorNodeType.NumberedListBlock && prevIsNumberedList) { + return; + } + + removeBeforeText(beforeRange); + CustomEditor.turnToBlock(editor, block); + + return; + } + } + + // get the range that matches the mark shortcuts + const markRange = { + anchor: Editor.start(editor, selection.anchor.path), + focus: selection.focus, + }; + const rangeText = Editor.string(editor, markRange) + char; + + if (!rangeText) return; + + // inputting a character that is start of a mark + const isStartTyping = rangeText.indexOf(char) === rangeText.lastIndexOf(char); + + if (isStartTyping) return; + + // if the range text includes a double character mark, and the last one is not finished + const doubleCharNotFinish = + ['*', '_', '~'].includes(char) && + rangeText.indexOf(`${char}${char}`) > -1 && + rangeText.indexOf(`${char}${char}`) === rangeText.lastIndexOf(`${char}${char}`); + + if (doubleCharNotFinish) return; + + const matchMarkShortcuts = whatShortcutsMatch(rangeText); + + for (const shortcut of matchMarkShortcuts) { + const item = getRegex(shortcut).find((p) => p.pattern.test(rangeText)); + const execArr = item?.pattern?.exec(rangeText); + + const removeText = execArr ? execArr[0] : ''; + + const text = execArr ? execArr[2]?.replaceAll(char, '') : ''; + + if (text) { + const index = rangeText.indexOf(removeText); + const removeRange = { + anchor: { + path: markRange.anchor.path, + offset: markRange.anchor.offset + index, + }, + focus: { + path: markRange.anchor.path, + offset: markRange.anchor.offset + index + removeText.length, + }, + }; + + removeBeforeText(removeRange); + insertMark(editor, shortcut, text); + return; + } + } + }; + + return editor; +}; + +function whichBlock(shortcut: MarkdownShortcuts, beforeText: string) { + switch (shortcut) { + case MarkdownShortcuts.Heading: + return { + type: EditorNodeType.HeadingBlock, + data: { + level: beforeText.length, + }, + }; + case MarkdownShortcuts.CodeBlock: + return { + type: EditorNodeType.CodeBlock, + data: { + language: 'json', + }, + }; + case MarkdownShortcuts.BulletedList: + return { + type: EditorNodeType.BulletedListBlock, + data: {}, + }; + case MarkdownShortcuts.NumberedList: + return { + type: EditorNodeType.NumberedListBlock, + data: { + number: Number(beforeText.split('.')[0]) ?? 1, + }, + }; + case MarkdownShortcuts.TodoList: + return { + type: EditorNodeType.TodoListBlock, + data: { + checked: beforeText.includes('[x]'), + }, + }; + case MarkdownShortcuts.BlockQuote: + return { + type: EditorNodeType.QuoteBlock, + data: {}, + }; + case MarkdownShortcuts.Divider: + return { + type: EditorNodeType.DividerBlock, + data: {}, + }; + + case MarkdownShortcuts.ToggleList: + return { + type: EditorNodeType.ToggleListBlock, + data: { + collapsed: false, + }, + }; + + default: + return null; + } +} + +function insertMark(editor: ReactEditor, shortcut: MarkdownShortcuts, text: string) { + switch (shortcut) { + case MarkdownShortcuts.Bold: + case MarkdownShortcuts.Italic: + case MarkdownShortcuts.StrikeThrough: + case MarkdownShortcuts.Code: { + const textNode = { + text, + }; + const attributes = { + [MarkdownShortcuts.Bold]: { + [EditorMarkFormat.Bold]: true, + }, + [MarkdownShortcuts.Italic]: { + [EditorMarkFormat.Italic]: true, + }, + [MarkdownShortcuts.StrikeThrough]: { + [EditorMarkFormat.StrikeThrough]: true, + }, + [MarkdownShortcuts.Code]: { + [EditorMarkFormat.Code]: true, + }, + }; + + Object.assign(textNode, attributes[shortcut]); + + editor.insertNodes(textNode); + return; + } + + case MarkdownShortcuts.Equation: { + CustomEditor.insertFormula(editor, text); + return; + } + + default: + return null; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts new file mode 100644 index 0000000000000..62e3ad945a61a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts @@ -0,0 +1,38 @@ +import { Element, NodeEntry } from 'slate'; +import { ReactEditor } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; + +export function getHeadingCssProperty(level: number) { + switch (level) { + case 1: + return 'text-3xl pt-[10px] pb-[8px] font-bold'; + case 2: + return 'text-2xl pt-[8px] pb-[6px] font-bold'; + case 3: + return 'text-xl pt-[4px] font-bold'; + case 4: + return 'text-lg pt-[4px] font-bold'; + case 5: + return 'text-base pt-[4px] font-bold'; + case 6: + return 'text-sm pt-[4px] font-bold'; + default: + return ''; + } +} + +export function getBlock(editor: ReactEditor) { + const match = CustomEditor.getBlock(editor); + + if (match) { + const [node] = match as NodeEntry; + + return node; + } + + return; +} + +export function getEditorDomNode(editor: ReactEditor) { + return ReactEditor.toDOMNode(editor, editor); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDelete.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDelete.ts new file mode 100644 index 0000000000000..0bcd0965a9816 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDelete.ts @@ -0,0 +1,228 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Element, NodeEntry, Path, Range } from 'slate'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; + +/** + * Delete backward. + * | -> cursor + * + * ------------------- delete backward to its previous sibling lift all children + * 1 1|2 + * |2 3 + * 3 => delete backward => 4 + * 4 5 + * 5 + * ------------------- delete backward to its parent and lift all children + * 1 1|2 + * |2 3 + * 3 => delete backward => 4 + * 4 5 + * 5 + * ------------------- outdent the node if the node has no children + * 1 1 + * 2 2 + * |3 |3 + * 4 => delete backward => 4 + * @param editor + */ +export function withBlockDelete(editor: ReactEditor) { + const { deleteBackward, deleteFragment, mergeNodes } = editor; + + editor.deleteBackward = (unit) => { + const match = CustomEditor.getBlock(editor); + + if (!match || !CustomEditor.focusAtStartOfBlock(editor)) { + deleteBackward(unit); + return; + } + + const [node, path] = match; + + const isEmbed = editor.isEmbed(node); + + if (isEmbed) { + CustomEditor.deleteNode(editor, node); + return; + } + + const previous = editor.previous({ + at: path, + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined, + }); + const [previousNode] = previous || [undefined, undefined]; + + const previousIsPage = previousNode && Element.isElement(previousNode) && previousNode.type === EditorNodeType.Page; + + // merge to document title + if (previousIsPage) { + const textNodePath = [...path, 0]; + const [textNode] = editor.node(textNodePath); + const text = CustomEditor.getNodeTextContent(textNode); + + // clear all attributes + editor.select(textNodePath); + CustomEditor.removeMarks(editor); + editor.insertText(text); + + editor.move({ + distance: text.length, + reverse: true, + }); + } + + // if the current node is not a paragraph, convert it to a paragraph(except code block and callout block) + if ( + ![EditorNodeType.Paragraph, EditorNodeType.CalloutBlock, EditorNodeType.CodeBlock].includes( + node.type as EditorNodeType + ) && + node.type !== EditorNodeType.Page + ) { + CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph }); + return; + } + + const next = editor.next({ + at: path, + }); + + if (!next && path.length > 1) { + CustomEditor.tabBackward(editor); + return; + } + + const length = node.children.length; + + for (let i = length - 1; i > 0; i--) { + editor.liftNodes({ + at: [...path, i], + }); + } + + // if previous node is an embed, merge the current node to another node which is not an embed + if (Element.isElement(previousNode) && editor.isEmbed(previousNode)) { + const previousTextMatch = editor.previous({ + at: path, + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.textId !== undefined, + }); + + if (!previousTextMatch) { + deleteBackward(unit); + return; + } + + const previousTextPath = previousTextMatch[1]; + const textNode = node.children[0] as Element; + + const at = Editor.end(editor, previousTextPath); + + editor.select(at); + editor.insertNodes(textNode.children, { + at, + }); + + editor.removeNodes({ + at: path, + }); + return; + } + + deleteBackward(unit); + }; + + editor.deleteFragment = (...args) => { + beforeDeleteToDocumentTitle(editor); + + deleteFragment(...args); + }; + + editor.mergeNodes = (options) => { + mergeNodes(options); + if (!editor.selection || !options?.at) return; + const nextPath = findNextPath(editor, editor.selection.anchor.path); + + const [nextNode] = editor.node(nextPath); + + if (Element.isElement(nextNode) && nextNode.blockId !== undefined && nextNode.children.length === 0) { + editor.removeNodes({ + at: nextPath, + }); + } + + return; + }; + + return editor; +} + +function beforeDeleteToDocumentTitle(editor: ReactEditor) { + if (!editor.selection) return; + if (Range.isCollapsed(editor.selection)) return; + const start = Range.start(editor.selection); + const end = Range.end(editor.selection); + const startNode = editor.above({ + at: start, + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorNodeType.Page, + }); + + const endNode = editor.above({ + at: end, + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined, + }); + + const startNodeIsPage = !!startNode; + + if (!startNodeIsPage || !endNode) return; + const [node, path] = endNode as NodeEntry; + const selectedText = editor.string({ + anchor: { + path, + offset: 0, + }, + focus: end, + }); + + const nodeChildren = node.children; + const nodeChildrenLength = nodeChildren.length; + + for (let i = nodeChildrenLength - 1; i > 0; i--) { + editor.liftNodes({ + at: [...path, i], + }); + } + + const textNodePath = [...path, 0]; + const [textNode] = editor.node(textNodePath); + const text = CustomEditor.getNodeTextContent(textNode); + + // clear all attributes + editor.select([...path, 0]); + CustomEditor.removeMarks(editor); + editor.insertText(text); + editor.move({ + distance: text.length - selectedText.length, + reverse: true, + }); + editor.select({ + anchor: start, + focus: editor.selection.focus, + }); +} + +function findNextPath(editor: ReactEditor, path: Path): Path { + if (path.length === 0) return path; + const parentPath = Path.parent(path); + + try { + const nextPath = Path.next(path); + const [nextNode] = Editor.node(editor, nextPath); + + if (Element.isElement(nextNode) && nextNode.blockId !== undefined) { + return nextPath; + } + } catch (e) { + // ignore + } + + return findNextPath(editor, parentPath); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts new file mode 100644 index 0000000000000..b6f8da0e564c9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts @@ -0,0 +1,94 @@ +import { ReactEditor } from 'slate-react'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; +import { Path, Transforms } from 'slate'; +import { YjsEditor } from '@slate-yjs/core'; +import { generateId } from '$app/components/editor/provider/utils/convert'; + +export function withBlockInsertBreak(editor: ReactEditor) { + const { insertBreak } = editor; + + editor.insertBreak = (...args) => { + const block = CustomEditor.getBlock(editor); + + if (!block) return insertBreak(...args); + + const [node, path] = block; + + const isEmbed = editor.isEmbed(node); + + const nextPath = Path.next(path); + + if (isEmbed) { + CustomEditor.insertEmptyLine(editor as ReactEditor & YjsEditor, nextPath); + editor.select(nextPath); + return; + } + + const type = node.type as EditorNodeType; + + const isBeginning = CustomEditor.focusAtStartOfBlock(editor); + + const isEmpty = CustomEditor.isEmptyText(editor, node); + + if (isEmpty) { + const depth = path.length; + let hasNextNode = false; + + try { + hasNextNode = Boolean(editor.node(nextPath)); + } catch (e) { + // do nothing + } + + // if the node is empty and the depth is greater than 1, tab backward + if (depth > 1 && !hasNextNode) { + CustomEditor.tabBackward(editor); + return; + } + + // if the node is empty, convert it to a paragraph + if (type !== EditorNodeType.Paragraph && type !== EditorNodeType.Page) { + CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph }); + return; + } + } else if (isBeginning) { + // insert line below the current block + const newNodeType = [ + EditorNodeType.TodoListBlock, + EditorNodeType.BulletedListBlock, + EditorNodeType.NumberedListBlock, + ].includes(type) + ? type + : EditorNodeType.Paragraph; + + Transforms.insertNodes( + editor, + { + type: newNodeType, + data: node.data ?? {}, + blockId: generateId(), + children: [ + { + type: EditorNodeType.Text, + textId: generateId(), + children: [ + { + text: '', + }, + ], + }, + ], + }, + { + at: path, + } + ); + return; + } + + insertBreak(...args); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockMove.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockMove.ts new file mode 100644 index 0000000000000..814c6e73331a6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockMove.ts @@ -0,0 +1,134 @@ +import { ReactEditor } from 'slate-react'; +import { generateId } from '$app/components/editor/provider/utils/convert'; +import { Editor, Element, Location, NodeEntry, Path, Node, Transforms } from 'slate'; +import { EditorNodeType } from '$app/application/document/document.types'; + +const matchPath = (editor: Editor, path: Path): ((node: Node) => boolean) => { + const [node] = Editor.node(editor, path); + + return (n) => { + return n === node; + }; +}; + +export function withBlockMove(editor: ReactEditor) { + const { moveNodes } = editor; + + editor.moveNodes = (args) => { + const { to } = args; + + moveNodes(args); + + replaceId(editor, to); + }; + + editor.liftNodes = (args = {}) => { + Editor.withoutNormalizing(editor, () => { + const { at = editor.selection, mode = 'lowest', voids = false } = args; + let { match } = args; + + if (!match) { + match = Path.isPath(at) + ? matchPath(editor, at) + : (n) => Element.isElement(n) && Editor.isBlock(editor, n) && n.blockId !== undefined; + } + + if (!at) { + return; + } + + const matches = Editor.nodes(editor, { at, match, mode, voids }); + + const pathRefs = Array.from(matches, ([, p]) => { + return Editor.pathRef(editor, p); + }); + + for (const pathRef of pathRefs) { + const path = pathRef.unref(); + + if (!path) return; + if (path.length < 2) { + throw new Error(`Cannot lift node at a path [${path}] because it has a depth of less than \`2\`.`); + } + + const parentNodeEntry = Editor.node(editor, Path.parent(path)); + const [parent, parentPath] = parentNodeEntry as NodeEntry; + const index = path[path.length - 1]; + const { length } = parent.children; + + if (length === 1) { + const toPath = Path.next(parentPath); + + Transforms.moveNodes(editor, { at: path, to: toPath, voids }); + Transforms.removeNodes(editor, { at: parentPath, voids }); + } else if (index === 0) { + Transforms.moveNodes(editor, { at: path, to: parentPath, voids }); + } else if (index === length - 1) { + const toPath = Path.next(parentPath); + + Transforms.moveNodes(editor, { at: path, to: toPath, voids }); + } else { + const toPath = Path.next(parentPath); + + const node = parent.children[index] as Element; + const nodeChildrenLength = node.children.length; + + for (let i = length - 1; i > index; i--) { + Transforms.moveNodes(editor, { + at: [...parentPath, i], + to: [...path, nodeChildrenLength], + mode: 'all', + }); + } + + Transforms.moveNodes(editor, { at: path, to: toPath, voids }); + } + } + }); + }; + + return editor; +} + +function replaceId(editor: Editor, at?: Location) { + const newBlockId = generateId(); + const newTextId = generateId(); + + const selection = editor.selection; + + const location = at || selection; + + if (!location) return; + + const [node, path] = editor.node(location) as NodeEntry; + + if (node.blockId === undefined) { + return; + } + + const [textNode, ...children] = node.children as Element[]; + + editor.setNodes( + { + blockId: newBlockId, + }, + { + at, + } + ); + + if (textNode && textNode.type === EditorNodeType.Text) { + editor.setNodes( + { + textId: newTextId, + }, + { + at: [...path, 0], + } + ); + } + + children.forEach((_, index) => { + replaceId(editor, [...path, index + 1]); + }); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts new file mode 100644 index 0000000000000..1e9fc7f1052e1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts @@ -0,0 +1,30 @@ +import { ReactEditor } from 'slate-react'; + +import { withBlockDelete } from '$app/components/editor/plugins/withBlockDelete'; +import { withBlockInsertBreak } from '$app/components/editor/plugins/withBlockInsertBreak'; +import { withSplitNodes } from '$app/components/editor/plugins/withSplitNodes'; +import { withPasted, withCopy } from '$app/components/editor/plugins/copyPasted'; +import { withBlockMove } from '$app/components/editor/plugins/withBlockMove'; +import { CustomEditor } from '$app/components/editor/command'; + +export function withBlockPlugins(editor: ReactEditor) { + const { isElementReadOnly, isEmpty, isSelectable } = editor; + + editor.isElementReadOnly = (element) => { + return CustomEditor.isEmbedNode(element) || isElementReadOnly(element); + }; + + editor.isEmbed = (element) => { + return CustomEditor.isEmbedNode(element); + }; + + editor.isSelectable = (element) => { + return !CustomEditor.isEmbedNode(element) && isSelectable(element); + }; + + editor.isEmpty = (element) => { + return !CustomEditor.isEmbedNode(element) && isEmpty(element); + }; + + return withPasted(withBlockMove(withSplitNodes(withBlockInsertBreak(withBlockDelete(withCopy(editor)))))); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts new file mode 100644 index 0000000000000..eee7dd92d08c9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts @@ -0,0 +1,139 @@ +import { ReactEditor } from 'slate-react'; +import { Transforms, Editor, Element, NodeEntry, Path, Range } from 'slate'; +import { EditorNodeType, ToggleListNode } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; +import { generateId } from '$app/components/editor/provider/utils/convert'; +import cloneDeep from 'lodash-es/cloneDeep'; +import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants'; + +/** + * Split nodes. + * split text node into two text nodes, and wrap the second text node with a new block node. + * + * Split to the first child condition: + * 1. block type is toggle list block, and the block is not collapsed. + * + * Split to the next sibling condition: + * 1. block type is toggle list block, and the block is collapsed. + * 2. block type is other block type. + * + * Split to a paragraph node: (otherwise split to the same block type) + * 1. block type is heading block. + * 2. block type is quote block. + * 3. block type is page. + * 4. block type is code block and callout block. + * 5. block type is paragraph. + * + * @param editor + */ +export function withSplitNodes(editor: ReactEditor) { + const { splitNodes } = editor; + + editor.splitNodes = (...args) => { + const isInsertBreak = args.length === 1 && JSON.stringify(args[0]) === JSON.stringify({ always: true }); + + if (!isInsertBreak) { + splitNodes(...args); + return; + } + + const selection = editor.selection; + + const isCollapsed = selection && Range.isCollapsed(selection); + + if (!isCollapsed) { + editor.deleteFragment({ direction: 'backward' }); + } + + const match = CustomEditor.getBlock(editor); + + if (!match) { + splitNodes(...args); + return; + } + + const [node, path] = match; + const nodeType = node.type as EditorNodeType; + + const newBlockId = generateId(); + const newTextId = generateId(); + + splitNodes(...args); + + const matchTextNode = editor.above({ + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type === EditorNodeType.Text, + }); + + if (!matchTextNode) return; + const [textNode, textNodePath] = matchTextNode as NodeEntry; + + editor.removeNodes({ + at: textNodePath, + }); + + const newNodeType = [ + EditorNodeType.HeadingBlock, + EditorNodeType.QuoteBlock, + EditorNodeType.Page, + ...SOFT_BREAK_TYPES, + ].includes(node.type as EditorNodeType) + ? EditorNodeType.Paragraph + : node.type; + + const newNode: Element = { + type: newNodeType, + data: {}, + blockId: newBlockId, + children: [ + { + ...cloneDeep(textNode), + textId: newTextId, + }, + ], + }; + let newNodePath; + + if (nodeType === EditorNodeType.ToggleListBlock) { + const collapsed = (node as ToggleListNode).data.collapsed; + + if (!collapsed) { + newNode.type = EditorNodeType.Paragraph; + newNodePath = textNodePath; + } else { + newNode.type = EditorNodeType.ToggleListBlock; + newNodePath = Path.next(path); + } + + Transforms.insertNodes(editor, newNode, { + at: newNodePath, + }); + + editor.select(newNodePath); + + CustomEditor.removeMarks(editor); + editor.collapse({ + edge: 'start', + }); + return; + } + + newNodePath = textNodePath; + + Transforms.insertNodes(editor, newNode, { + at: newNodePath, + }); + + editor.select(newNodePath); + editor.collapse({ + edge: 'start', + }); + + editor.liftNodes({ + at: newNodePath, + }); + + CustomEditor.removeMarks(editor); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts new file mode 100644 index 0000000000000..026ee5722294b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts @@ -0,0 +1,124 @@ +import { applyActions } from './utils/mockBackendService'; +import { generateId } from '$app/components/editor/provider/utils/convert'; +import { Provider } from '$app/components/editor/provider'; +import * as Y from 'yjs'; +import { BlockActionTypePB } from '@/services/backend'; +import { + generateFormulaInsertTextOp, + generateInsertTextOp, + genersteMentionInsertTextOp, +} from '$app/components/editor/provider/__tests__/utils/convert'; + +describe('Transform events to actions', () => { + let provider: Provider; + beforeEach(() => { + provider = new Provider(generateId()); + provider.initialDocument(true); + provider.connect(); + applyActions.mockClear(); + }); + + afterEach(() => { + provider.disconnect(); + }); + + test('should transform insert event to insert action', () => { + const sharedType = provider.sharedType; + + const insertTextOp = generateInsertTextOp('insert text'); + + sharedType?.applyDelta([{ retain: 2 }, insertTextOp]); + + const actions = applyActions.mock.calls[0][1]; + expect(actions).toHaveLength(2); + const textId = actions[0].payload.text_id; + expect(actions[0].action).toBe(BlockActionTypePB.InsertText); + expect(actions[0].payload.delta).toBe('[{"insert":"insert text"}]'); + expect(actions[1].action).toBe(BlockActionTypePB.Insert); + expect(actions[1].payload.block.ty).toBe('paragraph'); + expect(actions[1].payload.block.parent_id).toBe('3EzeCrtxlh'); + expect(actions[1].payload.block.children_id).not.toBeNull(); + expect(actions[1].payload.block.external_id).toBe(textId); + expect(actions[1].payload.parent_id).toBe('3EzeCrtxlh'); + expect(actions[1].payload.prev_id).toBe('2qonPRrNTO'); + }); + + test('should transform delete event to delete action', () => { + const sharedType = provider.sharedType; + + sharedType?.doc?.transact(() => { + sharedType?.applyDelta([{ retain: 4 }, { delete: 1 }]); + }); + + const actions = applyActions.mock.calls[0][1]; + expect(actions).toHaveLength(1); + expect(actions[0].action).toBe(BlockActionTypePB.Delete); + expect(actions[0].payload.block.id).toBe('Fn4KACkt1i'); + }); + + test('should transform update event to update action', () => { + const sharedType = provider.sharedType; + + const yText = sharedType?.toDelta()[4].insert as Y.XmlText; + sharedType?.doc?.transact(() => { + yText.setAttribute('data', { + checked: true, + }); + }); + + const actions = applyActions.mock.calls[0][1]; + expect(actions).toHaveLength(1); + expect(actions[0].action).toBe(BlockActionTypePB.Update); + expect(actions[0].payload.block.id).toBe('Fn4KACkt1i'); + expect(actions[0].payload.block.data).toBe('{"checked":true}'); + }); + + test('should transform apply delta event to apply delta action (insert text)', () => { + const sharedType = provider.sharedType; + + const blockYText = sharedType?.toDelta()[4].insert as Y.XmlText; + const textYText = blockYText.toDelta()[0].insert as Y.XmlText; + sharedType?.doc?.transact(() => { + textYText.applyDelta([{ retain: 1 }, { insert: 'apply delta' }]); + }); + const textId = textYText.getAttribute('textId'); + + const actions = applyActions.mock.calls[0][1]; + expect(actions).toHaveLength(1); + expect(actions[0].action).toBe(BlockActionTypePB.ApplyTextDelta); + expect(actions[0].payload.text_id).toBe(textId); + expect(actions[0].payload.delta).toBe('[{"retain":1},{"insert":"apply delta"}]'); + }); + + test('should transform apply delta event to apply delta action: insert mention', () => { + const sharedType = provider.sharedType; + + const blockYText = sharedType?.toDelta()[4].insert as Y.XmlText; + const yText = blockYText.toDelta()[0].insert as Y.XmlText; + sharedType?.doc?.transact(() => { + yText.applyDelta([{ retain: 1 }, genersteMentionInsertTextOp()]); + }); + + const actions = applyActions.mock.calls[0][1]; + expect(actions).toHaveLength(1); + expect(actions[0].action).toBe(BlockActionTypePB.ApplyTextDelta); + expect(actions[0].payload.delta).toBe('[{"retain":1},{"insert":"@","attributes":{"mention":{"page":"page_id"}}}]'); + }); + + test('should transform apply delta event to apply delta action: insert formula', () => { + const sharedType = provider.sharedType; + + const blockYText = sharedType?.toDelta()[4].insert as Y.XmlText; + const yText = blockYText.toDelta()[0].insert as Y.XmlText; + sharedType?.doc?.transact(() => { + yText.applyDelta([{ retain: 1 }, generateFormulaInsertTextOp()]); + }); + + const actions = applyActions.mock.calls[0][1]; + expect(actions).toHaveLength(1); + expect(actions[0].action).toBe(BlockActionTypePB.ApplyTextDelta); + expect(actions[0].payload.delta).toBe('[{"retain":1},{"insert":"= 1 + 1","attributes":{"formula":true}}]'); + }); +}); + +export {}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts new file mode 100644 index 0000000000000..0937d265edc09 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts @@ -0,0 +1,43 @@ +import { applyActions } from './utils/mockBackendService'; + +import { Provider } from '$app/components/editor/provider'; +import { generateId } from '$app/components/editor/provider/utils/convert'; +import { generateInsertTextOp } from '$app/components/editor/provider/__tests__/utils/convert'; + +export {}; + +describe('Provider connected', () => { + let provider: Provider; + + beforeEach(() => { + provider = new Provider(generateId()); + provider.initialDocument(true); + provider.connect(); + applyActions.mockClear(); + }); + + afterEach(() => { + provider.disconnect(); + }); + + test('should initial document', () => { + const sharedType = provider.sharedType; + expect(sharedType).not.toBeNull(); + expect(sharedType?.length).toBe(25); + expect(sharedType?.getAttribute('blockId')).toBe('3EzeCrtxlh'); + }); + + test('should send actions when the local changed', () => { + const sharedType = provider.sharedType; + + const parentId = sharedType?.getAttribute('blockId') as string; + const insertTextOp = generateInsertTextOp(''); + + sharedType?.applyDelta([{ retain: 2 }, insertTextOp]); + + expect(sharedType?.length).toBe(26); + expect(applyActions).toBeCalledTimes(1); + }); +}); + +export {}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/read_me.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/read_me.ts new file mode 100644 index 0000000000000..adb85f2bfd76d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/read_me.ts @@ -0,0 +1,437 @@ +export default { + viewId: '04acbc17-2265-4e4d-ac80-392bdc81379f', + rootId: '3EzeCrtxlh', + nodeMap: { + '692ooXzoV-': { + id: '692ooXzoV-', + type: 'todo_list', + parent: '3EzeCrtxlh', + children: '4yIcpjxFTQ', + data: { checked: false }, + externalId: '9L10h3UZ7J', + externalType: 'text', + }, + gCjs671FiD: { + id: 'gCjs671FiD', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'Bj4M6midh3', + data: {}, + externalId: 'uGR_eATq2B', + externalType: 'text', + }, + whGVOpFJzA: { + id: 'whGVOpFJzA', + type: 'code', + parent: '3EzeCrtxlh', + children: 'eYqZSaUSrF', + data: { language: 'rust' }, + externalId: '2H8dnFhOsJ', + externalType: 'text', + }, + PeUTr8lpaW: { + id: 'PeUTr8lpaW', + type: 'divider', + parent: '3EzeCrtxlh', + children: '7gXZ4anHxc', + data: {}, + externalId: '', + externalType: '', + }, + aRUJ8rTJR9: { + id: 'aRUJ8rTJR9', + type: 'quote', + parent: '3EzeCrtxlh', + children: '877leNxAdX', + data: {}, + externalId: 'Qdn9CIuCJb', + externalType: 'text', + }, + '5OZNiernqA': { + id: '5OZNiernqA', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'rnWU0OMG2D', + data: {}, + externalId: '7szeShePbX', + externalType: 'text', + }, + '6okS7LcJz6': { + id: '6okS7LcJz6', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'o2VwjuyMqD', + data: {}, + externalId: 'T7KYfpQzkC', + externalType: 'text', + }, + '2qonPRrNTO': { + id: '2qonPRrNTO', + type: 'heading', + parent: '3EzeCrtxlh', + children: 'EwyNm_pVG3', + data: { level: 1 }, + externalId: 'zatA8Lta9U', + externalType: 'text', + }, + G6SPYNOXyd: { + id: 'G6SPYNOXyd', + type: 'heading', + parent: '3EzeCrtxlh', + children: 'dMAT_mdB3t', + data: { level: 2 }, + externalId: 'EZJU9Ks-XL', + externalType: 'text', + }, + 'i-7TRjZWn4': { + id: 'i-7TRjZWn4', + type: 'todo_list', + parent: '3EzeCrtxlh', + children: '3NsAmPWBLT', + data: { checked: true }, + externalId: 'rG9KOmfyQc', + externalType: 'text', + }, + mAce5pJ5iN: { + id: 'mAce5pJ5iN', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'PavtRGeb_I', + data: {}, + externalId: 'UUBk3lnHDj', + externalType: 'text', + }, + ViWXVLgaux: { + id: 'ViWXVLgaux', + type: 'numbered_list', + parent: '3EzeCrtxlh', + children: 'osPbZZroOQ', + data: {}, + externalId: 'OkO9CIoWYX', + externalType: 'text', + }, + VrW0GWtvmq: { + id: 'VrW0GWtvmq', + type: 'todo_list', + parent: '3EzeCrtxlh', + children: 'hFX8bE3MQ6', + data: { checked: false }, + externalId: 'wBzhBx7bcM', + externalType: 'text', + }, + YzM4q9vJgy: { + id: 'YzM4q9vJgy', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'mlDk334eJ5', + data: {}, + externalId: 'wIXo3cMKpn', + externalType: 'text', + }, + okBQecghDx: { + id: 'okBQecghDx', + type: 'todo_list', + parent: '3EzeCrtxlh', + children: 'r7U-ocUQEj', + data: { checked: false }, + externalId: '8T-1vemF9G', + externalType: 'text', + }, + qJcR2SnePa: { + id: 'qJcR2SnePa', + type: 'callout', + parent: '3EzeCrtxlh', + children: 'X8-C5rdFkI', + data: { icon: '🥰' }, + externalId: 'o3mqcqjEvX', + externalType: 'text', + }, + q8trFTc21J: { + id: 'q8trFTc21J', + type: 'numbered_list', + parent: '3EzeCrtxlh', + children: 'Ye_KrA1Zqb', + data: {}, + externalId: 'E-XQYK1KGP', + externalType: 'text', + }, + '-7trMtJMEt': { + id: '-7trMtJMEt', + type: 'numbered_list', + parent: '3EzeCrtxlh', + children: '5-RQuN9654', + data: {}, + externalId: 'meKXZh3E_1', + externalType: 'text', + }, + Fn4KACkt1i: { + id: 'Fn4KACkt1i', + type: 'todo_list', + parent: '3EzeCrtxlh', + children: 'MM6vCgc7RC', + data: { checked: false }, + externalId: 'v0XWYu0w3F', + externalType: 'text', + }, + TTU0eUzM4G: { + id: 'TTU0eUzM4G', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'Jo1ix-lCgZ', + data: {}, + externalId: 'IRpzcwVaU4', + externalType: 'text', + }, + '6QZZccBfnT': { + id: '6QZZccBfnT', + type: 'heading', + parent: '3EzeCrtxlh', + children: 'bdI2gAQB-G', + data: { level: 2 }, + externalId: 'J-oMw2g2_D', + externalType: 'text', + }, + sZT5qbJvLX: { + id: 'sZT5qbJvLX', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'yR1_d6lPtR', + data: {}, + externalId: 'anA255zYMV', + externalType: 'text', + }, + '3EzeCrtxlh': { + id: '3EzeCrtxlh', + type: 'page', + parent: '', + children: 'ChrjyUcqp5', + data: {}, + externalId: 'bGaty5Tv88', + externalType: 'text', + }, + 'b3G76VM-nh': { + id: 'b3G76VM-nh', + type: 'heading', + parent: '3EzeCrtxlh', + children: '7PDV2Ev8pz', + data: { level: 2 }, + externalId: 'baM4S6ohnQ', + externalType: 'text', + }, + CxPil0324P: { + id: 'CxPil0324P', + type: 'todo_list', + parent: '3EzeCrtxlh', + children: 'qJkq_FYLux', + data: { checked: false }, + externalId: 'LGUrob79hg', + externalType: 'text', + }, + }, + childrenMap: { + '-7trMtJMEt': [], + okBQecghDx: [], + PeUTr8lpaW: [], + '6QZZccBfnT': [], + aRUJ8rTJR9: [], + G6SPYNOXyd: [], + VrW0GWtvmq: [], + 'i-7TRjZWn4': [], + '6okS7LcJz6': [], + Fn4KACkt1i: [], + qJcR2SnePa: [], + sZT5qbJvLX: [], + '2qonPRrNTO': [], + CxPil0324P: [], + YzM4q9vJgy: [], + mAce5pJ5iN: [], + gCjs671FiD: [], + q8trFTc21J: [], + whGVOpFJzA: [], + '5OZNiernqA': [], + 'b3G76VM-nh': [], + TTU0eUzM4G: [], + '3EzeCrtxlh': [ + '2qonPRrNTO', + 'b3G76VM-nh', + 'CxPil0324P', + 'Fn4KACkt1i', + 'okBQecghDx', + 'VrW0GWtvmq', + 'i-7TRjZWn4', + '692ooXzoV-', + 'sZT5qbJvLX', + 'PeUTr8lpaW', + 'YzM4q9vJgy', + 'G6SPYNOXyd', + '-7trMtJMEt', + 'q8trFTc21J', + 'ViWXVLgaux', + 'whGVOpFJzA', + 'mAce5pJ5iN', + '6QZZccBfnT', + 'aRUJ8rTJR9', + 'gCjs671FiD', + 'qJcR2SnePa', + '5OZNiernqA', + 'TTU0eUzM4G', + '6okS7LcJz6', + ], + ViWXVLgaux: [], + '692ooXzoV-': [], + }, + relativeMap: { + '4yIcpjxFTQ': '692ooXzoV-', + Bj4M6midh3: 'gCjs671FiD', + eYqZSaUSrF: 'whGVOpFJzA', + '7gXZ4anHxc': 'PeUTr8lpaW', + '877leNxAdX': 'aRUJ8rTJR9', + rnWU0OMG2D: '5OZNiernqA', + o2VwjuyMqD: '6okS7LcJz6', + EwyNm_pVG3: '2qonPRrNTO', + dMAT_mdB3t: 'G6SPYNOXyd', + '3NsAmPWBLT': 'i-7TRjZWn4', + PavtRGeb_I: 'mAce5pJ5iN', + osPbZZroOQ: 'ViWXVLgaux', + hFX8bE3MQ6: 'VrW0GWtvmq', + mlDk334eJ5: 'YzM4q9vJgy', + 'r7U-ocUQEj': 'okBQecghDx', + 'X8-C5rdFkI': 'qJcR2SnePa', + Ye_KrA1Zqb: 'q8trFTc21J', + '5-RQuN9654': '-7trMtJMEt', + MM6vCgc7RC: 'Fn4KACkt1i', + 'Jo1ix-lCgZ': 'TTU0eUzM4G', + 'bdI2gAQB-G': '6QZZccBfnT', + yR1_d6lPtR: 'sZT5qbJvLX', + ChrjyUcqp5: '3EzeCrtxlh', + '7PDV2Ev8pz': 'b3G76VM-nh', + qJkq_FYLux: 'CxPil0324P', + }, + deltaMap: { + gCjs671FiD: [], + G6SPYNOXyd: [{ insert: 'Keyboard shortcuts, markdown, and code block' }], + VrW0GWtvmq: [ + { insert: 'Type ' }, + { insert: '/', attributes: { code: true } }, + { insert: ' followed by ' }, + { insert: '/bullet', attributes: { code: true } }, + { insert: ' or ' }, + { insert: '/num', attributes: { code: true } }, + { insert: ' to create a list.', attributes: { code: false } }, + ], + '-7trMtJMEt': [ + { insert: 'Keyboard shortcuts ' }, + { + insert: 'guide', + attributes: { href: 'https://appflowy.gitbook.io/docs/essential-documentation/shortcuts' }, + }, + ], + okBQecghDx: [ + { insert: 'As soon as you type ' }, + { insert: '/', attributes: { font_color: '0xff00b5ff', code: true } }, + { insert: ' a menu will pop up. Select ' }, + { insert: 'different types', attributes: { bg_color: '0x4d9c27b0' } }, + { insert: ' of content blocks you can add.' }, + ], + qJcR2SnePa: [ + { insert: '\nLike AppFlowy? Follow us:\n' }, + { insert: 'GitHub', attributes: { href: 'https://github.com/AppFlowy-IO/AppFlowy' } }, + { insert: '\n' }, + { insert: 'Twitter', attributes: { href: 'https://twitter.com/appflowy' } }, + { insert: ': @appflowy\n' }, + { insert: 'Newsletter', attributes: { href: 'https://blog-appflowy.ghost.io/' } }, + { insert: '\n' }, + ], + whGVOpFJzA: [ + { + insert: + '// This is the main function.\nfn main() {\n // Print text to the console.\n println!("Hello World!");\n}', + }, + ], + YzM4q9vJgy: [], + '692ooXzoV-': [ + { insert: 'Click ' }, + { insert: '+', attributes: { code: true } }, + { insert: ' next to any page title in the sidebar to ' }, + { insert: 'quickly', attributes: { font_color: '0xff8427e0' } }, + { insert: ' add a new subpage, ' }, + { insert: 'Document', attributes: { code: true } }, + { insert: ', ', attributes: { code: false } }, + { insert: 'Grid', attributes: { code: true } }, + { insert: ', or ', attributes: { code: false } }, + { insert: 'Kanban Board', attributes: { code: true } }, + { insert: '.', attributes: { code: false } }, + ], + q8trFTc21J: [ + { insert: 'Markdown ' }, + { + insert: 'reference', + attributes: { href: 'https://appflowy.gitbook.io/docs/essential-documentation/markdown' }, + }, + ], + ViWXVLgaux: [ + { insert: 'Type ' }, + { insert: '/code', attributes: { code: true } }, + { insert: ' to insert a code block', attributes: { code: false } }, + ], + sZT5qbJvLX: [], + '6QZZccBfnT': [{ insert: 'Have a question❓' }], + '5OZNiernqA': [], + '6okS7LcJz6': [], + mAce5pJ5iN: [], + aRUJ8rTJR9: [ + { insert: 'Click ' }, + { insert: '?', attributes: { code: true } }, + { insert: ' at the bottom right for help and support.' }, + ], + Fn4KACkt1i: [ + { insert: 'Highlight ', attributes: { bg_color: '0x4dffeb3b' } }, + { insert: 'any text, and use the editing menu to ' }, + { insert: 'style', attributes: { italic: true } }, + { insert: ' ' }, + { insert: 'your', attributes: { bold: true } }, + { insert: ' ' }, + { insert: 'writing', attributes: { underline: true } }, + { insert: ' ' }, + { insert: 'however', attributes: { code: true } }, + { insert: ' you ' }, + { insert: 'like.', attributes: { strikethrough: true } }, + ], + '3EzeCrtxlh': [], + '2qonPRrNTO': [{ insert: 'Welcome to AppFlowy!' }], + 'b3G76VM-nh': [{ insert: 'Here are the basics' }], + TTU0eUzM4G: [], + CxPil0324P: [{ insert: 'Click anywhere and just start typing.' }], + 'i-7TRjZWn4': [ + { insert: 'Click ' }, + { insert: '+ New Page ', attributes: { code: true } }, + { insert: 'button at the bottom of your sidebar to add a new page.' }, + ], + }, + externalIdMap: { + '9L10h3UZ7J': '692ooXzoV-', + uGR_eATq2B: 'gCjs671FiD', + '2H8dnFhOsJ': 'whGVOpFJzA', + Qdn9CIuCJb: 'aRUJ8rTJR9', + '7szeShePbX': '5OZNiernqA', + T7KYfpQzkC: '6okS7LcJz6', + zatA8Lta9U: '2qonPRrNTO', + 'EZJU9Ks-XL': 'G6SPYNOXyd', + rG9KOmfyQc: 'i-7TRjZWn4', + UUBk3lnHDj: 'mAce5pJ5iN', + OkO9CIoWYX: 'ViWXVLgaux', + wBzhBx7bcM: 'VrW0GWtvmq', + wIXo3cMKpn: 'YzM4q9vJgy', + '8T-1vemF9G': 'okBQecghDx', + o3mqcqjEvX: 'qJcR2SnePa', + 'E-XQYK1KGP': 'q8trFTc21J', + meKXZh3E_1: '-7trMtJMEt', + v0XWYu0w3F: 'Fn4KACkt1i', + IRpzcwVaU4: 'TTU0eUzM4G', + 'J-oMw2g2_D': '6QZZccBfnT', + anA255zYMV: 'sZT5qbJvLX', + bGaty5Tv88: '3EzeCrtxlh', + baM4S6ohnQ: 'b3G76VM-nh', + LGUrob79hg: 'CxPil0324P', + }, +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/convert.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/convert.ts new file mode 100644 index 0000000000000..028fff74197c5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/convert.ts @@ -0,0 +1,76 @@ +/** + * @jest-environment jsdom + */ +import { slateNodesToInsertDelta } from '@slate-yjs/core'; +import * as Y from 'yjs'; +import { generateId } from '$app/components/editor/provider/utils/convert'; + +export function slateElementToYText({ + children, + ...attributes +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +}) { + const yElement = new Y.XmlText(); + + Object.entries(attributes).forEach(([key, value]) => { + yElement.setAttribute(key, value); + }); + yElement.applyDelta(slateNodesToInsertDelta(children), { + sanitize: false, + }); + return yElement; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function generateInsertTextOp(text: string) { + const insertYText = slateElementToYText({ + children: [ + { + type: 'text', + textId: generateId(), + children: [ + { + text, + }, + ], + }, + ], + type: 'paragraph', + data: {}, + blockId: generateId(), + }); + + return { + insert: insertYText, + }; +} + +export function genersteMentionInsertTextOp() { + const mentionYText = slateElementToYText({ + children: [{ text: '@' }], + type: 'mention', + data: { + page: 'page_id', + }, + }); + + return { + insert: mentionYText, + }; +} + +export function generateFormulaInsertTextOp() { + const formulaYText = slateElementToYText({ + children: [{ text: '= 1 + 1' }], + type: 'formula', + data: true, + }); + + return { + insert: formulaYText, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/mockBackendService.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/mockBackendService.ts new file mode 100644 index 0000000000000..3bd7646268048 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/mockBackendService.ts @@ -0,0 +1,21 @@ +import read_me from '$app/components/editor/provider/__tests__/read_me'; + +const applyActions = jest.fn().mockReturnValue(Promise.resolve()); + +jest.mock('$app/application/notification', () => { + return { + subscribeNotification: jest.fn().mockReturnValue(Promise.resolve(() => ({}))), + }; +}); + +jest.mock('nanoid', () => ({ nanoid: jest.fn().mockReturnValue(String(Math.random())) })); + +jest.mock('$app/application/document/document.service', () => { + return { + openDocument: jest.fn().mockReturnValue(Promise.resolve(read_me)), + applyActions, + closeDocument: jest.fn().mockReturnValue(Promise.resolve()), + }; +}); + +export { applyActions }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/data_client.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/data_client.ts new file mode 100644 index 0000000000000..bf0ea2c2a764f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/data_client.ts @@ -0,0 +1,74 @@ +import { applyActions, closeDocument, openDocument } from '$app/application/document/document.service'; +import { slateNodesToInsertDelta } from '@slate-yjs/core'; +import { convertToSlateValue } from '$app/components/editor/provider/utils/convert'; +import { EventEmitter } from 'events'; +import { BlockActionPB, DocEventPB, DocumentNotification } from '@/services/backend'; +import { AsyncQueue } from '$app/utils/async_queue'; +import { subscribeNotification } from '$app/application/notification'; +import { YDelta } from '$app/components/editor/provider/types/y_event'; +import { DocEvent2YDelta } from '$app/components/editor/provider/utils/delta'; + +export class DataClient extends EventEmitter { + private queue: AsyncQueue[]>; + private unsubscribe: Promise<() => void>; + public rootId?: string; + + constructor(private id: string) { + super(); + this.queue = new AsyncQueue(this.sendActions); + this.unsubscribe = subscribeNotification(DocumentNotification.DidReceiveUpdate, this.sendMessage); + + this.on('update', this.handleReceiveMessage); + } + + public disconnect() { + this.off('update', this.handleReceiveMessage); + void closeDocument(this.id); + void this.unsubscribe.then((unsubscribe) => unsubscribe()); + } + + public async getInsertDelta(includeRoot = true) { + const data = await openDocument(this.id); + + this.rootId = data.rootId; + + const slateValue = convertToSlateValue(data, includeRoot); + + return slateNodesToInsertDelta(slateValue); + } + + public on(event: 'change', listener: (events: YDelta) => void): this; + public on(event: 'update', listener: (actions: ReturnType[]) => void): this; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public on(event: string, listener: (...args: any[]) => void): this { + return super.on(event, listener); + } + + public off(event: 'change', listener: (events: YDelta) => void): this; + public off(event: 'update', listener: (actions: ReturnType[]) => void): this; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public off(event: string, listener: (...args: any[]) => void): this { + return super.off(event, listener); + } + + public emit(event: 'change', events: YDelta): boolean; + public emit(event: 'update', actions: ReturnType[]): boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public emit(event: string, ...args: any[]): boolean { + return super.emit(event, ...args); + } + + private sendMessage = (docEvent: DocEventPB) => { + // transform events to ops + this.emit('change', DocEvent2YDelta(docEvent)); + }; + + private handleReceiveMessage = (actions: ReturnType[]) => { + this.queue.enqueue(actions); + }; + + private sendActions = async (actions: ReturnType[]) => { + if (!actions.length) return; + await applyActions(this.id, actions); + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/index.ts new file mode 100644 index 0000000000000..03be03e588e4a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/index.ts @@ -0,0 +1 @@ +export * from './provider'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts new file mode 100644 index 0000000000000..727b33ec69d19 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts @@ -0,0 +1,74 @@ +import * as Y from 'yjs'; + +import { DataClient } from '$app/components/editor/provider/data_client'; +import { YDelta } from '$app/components/editor/provider/types/y_event'; +import { YEvents2BlockActions } from '$app/components/editor/provider/utils/action'; +import { EventEmitter } from 'events'; + +const REMOTE_ORIGIN = 'remote'; + +export class Provider extends EventEmitter { + document: Y.Doc = new Y.Doc(); + sharedType: Y.XmlText | null = null; + dataClient: DataClient; + // get origin data after document updated + backupDoc: Y.Doc = new Y.Doc(); + constructor(public id: string) { + super(); + this.dataClient = new DataClient(id); + this.document.on('update', this.documentUpdate); + } + + initialDocument = async (includeRoot = true) => { + const sharedType = this.document.get('sharedType', Y.XmlText) as Y.XmlText; + // Load the initial value into the yjs document + const delta = await this.dataClient.getInsertDelta(includeRoot); + + sharedType.applyDelta(delta); + + const rootId = this.dataClient.rootId as string; + const root = delta[0].insert as Y.XmlText; + const data = root.getAttribute('data'); + + sharedType.setAttribute('blockId', rootId); + sharedType.setAttribute('data', data); + + this.sharedType = sharedType; + this.sharedType?.observeDeep(this.onChange); + this.emit('ready'); + }; + + connect() { + this.dataClient.on('change', this.onRemoteChange); + return; + } + + disconnect() { + this.dataClient.off('change', this.onRemoteChange); + this.dataClient.disconnect(); + this.sharedType?.unobserveDeep(this.onChange); + this.sharedType = null; + } + + onChange = (events: Y.YEvent[], transaction: Y.Transaction) => { + if (transaction.origin === REMOTE_ORIGIN) { + return; + } + + if (!this.sharedType || !events.length) return; + // transform events to actions + this.dataClient.emit('update', YEvents2BlockActions(this.backupDoc, events)); + }; + + onRemoteChange = (delta: YDelta) => { + if (!delta.length) return; + + this.document.transact(() => { + this.sharedType?.applyDelta(delta); + }, REMOTE_ORIGIN); + }; + + documentUpdate = (update: Uint8Array) => { + Y.applyUpdate(this.backupDoc, update); + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/types/y_event.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/types/y_event.ts new file mode 100644 index 0000000000000..36ec97aa39add --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/types/y_event.ts @@ -0,0 +1,12 @@ +import { YXmlText } from 'yjs/dist/src/types/YXmlText'; + +export interface YOp { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + insert?: string | object | any[] | YXmlText | undefined; + retain?: number | undefined; + delete?: number | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + attributes?: { [p: string]: any } | undefined; +} + +export type YDelta = YOp[]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts new file mode 100644 index 0000000000000..447a8f95f9139 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts @@ -0,0 +1,301 @@ +import * as Y from 'yjs'; +import { BlockActionPB, BlockActionTypePB } from '@/services/backend'; +import { generateId } from '$app/components/editor/provider/utils/convert'; +import { YDelta2Delta } from '$app/components/editor/provider/utils/delta'; +import { YDelta } from '$app/components/editor/provider/types/y_event'; +import { getInsertTarget, getYTarget } from '$app/components/editor/provider/utils/relation'; +import { EditorInlineNodeType, EditorNodeType } from '$app/application/document/document.types'; +import { Log } from '$app/utils/log'; + +export function YEvents2BlockActions( + backupDoc: Readonly, + events: Y.YEvent[] +): ReturnType[] { + const actions: ReturnType[] = []; + + events.forEach((event) => { + const eventActions = YEvent2BlockActions(backupDoc, event); + + if (eventActions.length === 0) return; + + actions.push(...eventActions); + }); + + return actions; +} + +export function YEvent2BlockActions( + backupDoc: Readonly, + event: Y.YEvent +): ReturnType[] { + const { target: yXmlText, keys, delta, path } = event; + const isBlockEvent = !!yXmlText.getAttribute('blockId'); + const sharedType = backupDoc.get('sharedType', Y.XmlText) as Readonly; + const rootId = sharedType.getAttribute('blockId'); + + const backupTarget = getYTarget(backupDoc, path) as Readonly; + const actions = []; + + if ([EditorInlineNodeType.Formula, EditorInlineNodeType.Mention].includes(yXmlText.getAttribute('type'))) { + const parentYXmlText = yXmlText.parent as Y.XmlText; + const parentDelta = parentYXmlText.toDelta() as YDelta; + const index = parentDelta.findIndex((op) => op.insert === yXmlText); + const ops = YDelta2Delta(parentDelta); + + const retainIndex = ops.reduce((acc, op, currentIndex) => { + if (currentIndex < index) { + return acc + (op.insert as string).length ?? 0; + } + + return acc; + }, 0); + + const newDelta = [ + { + retain: retainIndex, + }, + ...delta, + ]; + + actions.push(...generateApplyTextActions(parentYXmlText, newDelta)); + } + + if (yXmlText.getAttribute('type') === 'text') { + actions.push(...textOps2BlockActions(rootId, yXmlText, delta)); + } + + if (keys.size > 0) { + actions.push(...dataOps2BlockActions(yXmlText, keys)); + } + + if (isBlockEvent) { + actions.push(...blockOps2BlockActions(backupTarget, delta)); + } + + return actions; +} + +function textOps2BlockActions( + rootId: string, + yXmlText: Y.XmlText, + ops: YDelta +): ReturnType[] { + if (ops.length === 0) return []; + const blockYXmlText = yXmlText.parent as Y.XmlText; + const blockId = blockYXmlText.getAttribute('blockId'); + + if (blockId === rootId) { + return []; + } + + return generateApplyTextActions(yXmlText, ops); +} + +function dataOps2BlockActions( + yXmlText: Y.XmlText, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + keys: Map +) { + const dataUpdated = keys.has('data'); + + if (!dataUpdated) return []; + const data = yXmlText.getAttribute('data'); + + return generateUpdateActions(yXmlText, { + data, + }); +} + +function blockOps2BlockActions( + blockYXmlText: Readonly, + ops: YDelta +): ReturnType[] { + const actions: ReturnType[] = []; + + let index = 0; + + ops.forEach((op) => { + if (op.insert) { + if (op.insert instanceof Y.XmlText) { + const insertYXmlText = op.insert; + const blockId = insertYXmlText.getAttribute('blockId'); + const textId = insertYXmlText.getAttribute('textId'); + + if (!blockId && !textId) { + throw new Error('blockId and textId is not exist'); + } + + if (blockId) { + actions.push(...generateInsertBlockActions(insertYXmlText)); + index += 1; + } + + if (textId) { + const target = getInsertTarget(blockYXmlText, [0]); + + if (target) { + const length = target.length; + + const delta = [{ delete: length }, ...insertYXmlText.toDelta()]; + + // restore textId + insertYXmlText.setAttribute('textId', target.getAttribute('textId')); + actions.push(...generateApplyTextActions(target, delta)); + } + } + } + } else if (op.retain) { + index += op.retain; + } else if (op.delete) { + let i = 0; + + for (; i < op.delete; i++) { + const target = getInsertTarget(blockYXmlText, [i + index]); + + if (target && target !== blockYXmlText) { + const deletedId = target.getAttribute('blockId') as string; + + if (deletedId) { + actions.push( + ...generateDeleteBlockActions({ + ids: [deletedId], + }) + ); + } else { + Log.error('blockOps2BlockActions', 'deletedId is not exist'); + } + } + } + + index += i; + } + }); + + return actions; +} + +export function generateUpdateActions( + yXmlText: Y.XmlText, + { + data, + }: { + data?: Record; + external_id?: string; + } +) { + const id = yXmlText.getAttribute('blockId'); + const parentId = yXmlText.getAttribute('parentId'); + + return [ + { + action: BlockActionTypePB.Update, + payload: { + block: { + id, + data: JSON.stringify(data), + }, + parent_id: parentId, + }, + }, + ]; +} + +export function generateApplyTextActions(yXmlText: Y.XmlText, delta: YDelta) { + const externalId = yXmlText.getAttribute('textId'); + + if (!externalId) return []; + + const deltaString = JSON.stringify(YDelta2Delta(delta)); + + return [ + { + action: BlockActionTypePB.ApplyTextDelta, + payload: { + text_id: externalId, + delta: deltaString, + }, + }, + ]; +} + +export function generateDeleteBlockActions({ ids }: { ids: string[] }) { + return ids.map((id) => ({ + action: BlockActionTypePB.Delete, + payload: { + block: { + id, + }, + parent_id: '', + }, + })); +} + +export function generateInsertTextActions(insertYXmlText: Y.XmlText) { + const textId = insertYXmlText.getAttribute('textId'); + const delta = YDelta2Delta(insertYXmlText.toDelta()); + + return [ + { + action: BlockActionTypePB.InsertText, + payload: { + text_id: textId, + delta: JSON.stringify(delta), + }, + }, + ]; +} + +export function generateInsertBlockActions( + insertYXmlText: Y.XmlText +): ReturnType[] { + const childrenId = generateId(); + + const [textInsert, ...childrenInserts] = (insertYXmlText.toDelta() as YDelta).map((op) => op.insert); + const textInsertActions = textInsert instanceof Y.XmlText ? generateInsertTextActions(textInsert) : []; + const externalId = textInsertActions[0]?.payload.text_id; + const prev = insertYXmlText.prevSibling; + const prevId = prev ? prev.getAttribute('blockId') : null; + const parentId = (insertYXmlText.parent as Y.XmlText).getAttribute('blockId'); + + const data = insertYXmlText.getAttribute('data'); + const type = insertYXmlText.getAttribute('type'); + const id = insertYXmlText.getAttribute('blockId'); + + if (!id) { + Log.error('generateInsertBlockActions', 'id is not exist'); + return []; + } + + if (!type || type === 'text' || Object.values(EditorNodeType).indexOf(type) === -1) { + Log.error('generateInsertBlockActions', 'type is error: ' + type); + return []; + } + + const actions: ReturnType[] = [ + ...textInsertActions, + { + action: BlockActionTypePB.Insert, + payload: { + block: { + id, + data: JSON.stringify(data), + ty: type, + parent_id: parentId, + children_id: childrenId, + external_id: externalId, + external_type: externalId ? 'text' : undefined, + }, + prev_id: prevId, + parent_id: parentId, + }, + }, + ]; + + childrenInserts.forEach((insert) => { + if (insert instanceof Y.XmlText) { + actions.push(...generateInsertBlockActions(insert)); + } + }); + + return actions; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts new file mode 100644 index 0000000000000..b4da4b3ca7946 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts @@ -0,0 +1,154 @@ +import { nanoid } from 'nanoid'; +import { EditorData, EditorInlineNodeType, Mention } from '$app/application/document/document.types'; +import { Element, Text } from 'slate'; +import { Op } from 'quill-delta'; + +export function generateId() { + return nanoid(10); +} + +export function transformToInlineElement(op: Op): Element[] { + const attributes = op.attributes; + + if (!attributes) return []; + const { formula, mention, ...attrs } = attributes; + + if (formula) { + const texts = (op.insert as string).split(''); + + return texts.map((text) => { + return { + type: EditorInlineNodeType.Formula, + data: formula, + children: [ + { + text, + ...attrs, + }, + ], + }; + }); + } + + if (mention) { + const texts = (op.insert as string).split(''); + + return texts.map((text) => { + return { + type: EditorInlineNodeType.Mention, + children: [ + { + text, + ...attrs, + }, + ], + data: { + ...(mention as Mention), + }, + }; + }); + } + + return []; +} + +export function getInlinesWithDelta(delta?: Op[]): (Text | Element)[] { + const newDelta: (Text | Element)[] = []; + + if (!delta || !delta.length) + return [ + { + text: '', + }, + ]; + + delta.forEach((op) => { + const matchInlines = transformToInlineElement(op); + + if (matchInlines.length > 0) { + newDelta.push(...matchInlines); + return; + } + + if (op.attributes) { + if ('font_color' in op.attributes && op.attributes['font_color'] === '') { + delete op.attributes['font_color']; + } + + if ('bg_color' in op.attributes && op.attributes['bg_color'] === '') { + delete op.attributes['bg_color']; + } + + if ('code' in op.attributes && !op.attributes['code']) { + delete op.attributes['code']; + } + } + + newDelta.push({ + text: op.insert as string, + ...op.attributes, + }); + }); + + return newDelta; +} + +export function convertToSlateValue(data: EditorData, includeRoot: boolean): Element[] { + const traverse = (id: string, isRoot = false) => { + const node = data.nodeMap[id]; + const delta = data.deltaMap[id]; + + const slateNode: Element = { + type: node.type, + data: node.data, + children: [], + blockId: id, + }; + + const textNode: Element | null = + !isRoot && node.externalId + ? { + type: 'text', + children: [], + textId: node.externalId, + } + : null; + + const inlineNodes = getInlinesWithDelta(delta); + + textNode?.children.push(...inlineNodes); + + const children = data.childrenMap[id]; + + slateNode.children = children.map((childId) => traverse(childId)); + if (textNode) { + slateNode.children.unshift(textNode); + } + + return slateNode; + }; + + const rootId = data.rootId; + + const root = traverse(rootId, true); + + const nodes = root.children as Element[]; + + if (includeRoot) { + nodes.unshift({ + ...root, + children: [ + { + type: 'text', + children: [ + { + text: '', + }, + ], + }, + ], + }); + } + + return nodes; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/delta.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/delta.ts new file mode 100644 index 0000000000000..630b6fbdf5aa9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/delta.ts @@ -0,0 +1,54 @@ +import { YDelta, YOp } from '$app/components/editor/provider/types/y_event'; +import { Op } from 'quill-delta'; +import * as Y from 'yjs'; +import { inlineNodeTypes } from '$app/application/document/document.types'; +import { DocEventPB } from '@/services/backend'; + +export function YDelta2Delta(yDelta: YDelta): Op[] { + const ops: Op[] = []; + + yDelta.forEach((op) => { + if (op.insert instanceof Y.XmlText) { + const type = op.insert.getAttribute('type'); + + if (inlineNodeTypes.includes(type)) { + ops.push(...YInlineOp2Op(op)); + return; + } + } + + ops.push(op as Op); + }); + return ops; +} + +export function YInlineOp2Op(yOp: YOp): Op[] { + if (!(yOp.insert instanceof Y.XmlText)) { + return [ + { + insert: yOp.insert as string, + attributes: yOp.attributes, + }, + ]; + } + + const type = yOp.insert.getAttribute('type'); + const data = yOp.insert.getAttribute('data'); + + const delta = yOp.insert.toDelta() as Op[]; + + return delta.map((op) => ({ + insert: op.insert, + + attributes: { + [type]: data, + ...op.attributes, + }, + })); +} + +export function DocEvent2YDelta(events: DocEventPB): YDelta { + if (!events.is_remote) return []; + + return []; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/relation.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/relation.ts new file mode 100644 index 0000000000000..72b2e126df35a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/relation.ts @@ -0,0 +1,24 @@ +import * as Y from 'yjs'; + +export function getInsertTarget(root: Y.XmlText, path: (string | number)[]): Y.XmlText { + const delta = root.toDelta(); + const index = path[0]; + + const current = delta[index]; + + if (current && current.insert instanceof Y.XmlText) { + if (path.length === 1) { + return current.insert; + } + + return getInsertTarget(current.insert, path.slice(1)); + } + + return root; +} + +export function getYTarget(doc: Y.Doc, path: (string | number)[]) { + const sharedType = doc.get('sharedType', Y.XmlText) as Y.XmlText; + + return getInsertTarget(sharedType, path); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/block.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/block.ts new file mode 100644 index 0000000000000..00992964fb10d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/block.ts @@ -0,0 +1,70 @@ +import { createContext, useCallback, useContext, useMemo } from 'react'; +import { proxy, useSnapshot } from 'valtio'; +import { EditorNodeType } from '$app/application/document/document.types'; + +export interface EditorBlockState { + [EditorNodeType.ImageBlock]: { + popoverOpen: boolean; + blockId?: string; + }; + [EditorNodeType.EquationBlock]: { + popoverOpen: boolean; + blockId?: string; + }; +} + +const initialState = { + [EditorNodeType.ImageBlock]: { + popoverOpen: false, + blockId: undefined, + }, + [EditorNodeType.EquationBlock]: { + popoverOpen: false, + blockId: undefined, + }, +}; + +export const EditorBlockStateContext = createContext(initialState); + +export const EditorBlockStateProvider = EditorBlockStateContext.Provider; + +export function useEditorInitialBlockState() { + const state = useMemo(() => { + return proxy({ + ...initialState, + }); + }, []); + + return state; +} + +export function useEditorBlockState(key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock) { + const context = useContext(EditorBlockStateContext); + + return useSnapshot(context[key]); +} + +export function useEditorBlockDispatch() { + const context = useContext(EditorBlockStateContext); + + const openPopover = useCallback( + (key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock, blockId: string) => { + context[key].popoverOpen = true; + context[key].blockId = blockId; + }, + [context] + ); + + const closePopover = useCallback( + (key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock) => { + context[key].popoverOpen = false; + context[key].blockId = undefined; + }, + [context] + ); + + return { + openPopover, + closePopover, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/decorate.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/decorate.ts new file mode 100644 index 0000000000000..078296aade8bc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/decorate.ts @@ -0,0 +1,89 @@ +import { createContext, useCallback, useContext, useMemo } from 'react'; +import { BaseRange, Editor, NodeEntry, Range } from 'slate'; +import { proxySet } from 'valtio/utils'; +import { useSnapshot } from 'valtio'; +import { ReactEditor } from 'slate-react'; + +export const DecorateStateContext = createContext< + Set<{ + range: BaseRange; + class_name: string; + type?: 'link'; + }> +>(new Set()); +export const DecorateStateProvider = DecorateStateContext.Provider; + +export function useInitialDecorateState(editor: ReactEditor) { + const decorateState = useMemo( + () => + proxySet<{ + range: BaseRange; + class_name: string; + }>([]), + [] + ); + + const ranges = useSnapshot(decorateState); + + const decorate = useCallback( + ([, path]: NodeEntry): BaseRange[] => { + const highlightRanges: (Range & { + class_name: string; + })[] = []; + + ranges.forEach((state) => { + const intersection = Range.intersection(state.range, Editor.range(editor, path)); + + if (intersection) { + highlightRanges.push({ + ...intersection, + class_name: state.class_name, + }); + } + }); + + return highlightRanges; + }, + [editor, ranges] + ); + + return { + decorate, + decorateState, + }; +} + +export function useDecorateState(type?: 'link') { + const context = useContext(DecorateStateContext); + + const state = useSnapshot(context); + + return useMemo(() => { + return Array.from(state).find((s) => !type || s.type === type); + }, [state, type]); +} + +export function useDecorateDispatch() { + const context = useContext(DecorateStateContext); + + const getStaticState = useCallback(() => { + return Array.from(context)[0]; + }, [context]); + + const add = useCallback( + (state: { range: BaseRange; class_name: string; type?: 'link' }) => { + context.add(state); + }, + [context] + ); + + const clear = useCallback(() => { + context.clear(); + }, [context]); + + return { + add, + clear, + getStaticState, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts new file mode 100644 index 0000000000000..22f0bb81be2df --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts @@ -0,0 +1,28 @@ +import { ReactEditor } from 'slate-react'; +import { useInitialDecorateState } from '$app/components/editor/stores/decorate'; +import { useInitialSelectedBlocks } from '$app/components/editor/stores/selected'; +import { useInitialSlashState } from '$app/components/editor/stores/slash'; +import { useInitialEditorInlineBlockState } from '$app/components/editor/stores/inline_node'; +import { useEditorInitialBlockState } from '$app/components/editor/stores/block'; + +export * from './decorate'; +export * from './selected'; +export * from './slash'; +export * from './inline_node'; + +export function useInitialEditorState(editor: ReactEditor) { + const { decorate, decorateState } = useInitialDecorateState(editor); + const selectedBlocks = useInitialSelectedBlocks(editor); + const slashState = useInitialSlashState(); + const inlineBlockState = useInitialEditorInlineBlockState(); + const blockState = useEditorInitialBlockState(); + + return { + selectedBlocks, + decorate, + decorateState, + slashState, + inlineBlockState, + blockState, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/inline_node.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/inline_node.ts new file mode 100644 index 0000000000000..6607a546d88de --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/inline_node.ts @@ -0,0 +1,60 @@ +import { createContext, useCallback, useContext, useMemo } from 'react'; +import { BaseRange, Path } from 'slate'; +import { proxy, useSnapshot } from 'valtio'; + +export interface EditorInlineBlockState { + formula: { + popoverOpen: boolean; + range?: BaseRange; + }; +} +const initialState = { + formula: { + popoverOpen: false, + range: undefined, + }, +}; + +export const EditorInlineBlockStateContext = createContext(initialState); + +export const EditorInlineBlockStateProvider = EditorInlineBlockStateContext.Provider; + +export function useInitialEditorInlineBlockState() { + const state = useMemo(() => { + return proxy({ + ...initialState, + }); + }, []); + + return state; +} + +export function useEditorInlineBlockState(key: 'formula') { + const context = useContext(EditorInlineBlockStateContext); + + const state = useSnapshot(context[key]); + + const openPopover = useCallback(() => { + context[key].popoverOpen = true; + }, [context, key]); + + const closePopover = useCallback(() => { + context[key].popoverOpen = false; + }, [context, key]); + + const setRange = useCallback( + (at: BaseRange | Path) => { + const range = Path.isPath(at) ? { anchor: at, focus: at } : at; + + context[key].range = range as BaseRange; + }, + [context, key] + ); + + return { + ...state, + openPopover, + closePopover, + setRange, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts new file mode 100644 index 0000000000000..803f474723545 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts @@ -0,0 +1,58 @@ +import { createContext, useEffect, useMemo, useState } from 'react'; +import { proxySet, subscribeKey } from 'valtio/utils'; +import { ReactEditor } from 'slate-react'; +import { Element } from 'slate'; + +export function useInitialSelectedBlocks(editor: ReactEditor) { + const selectedBlocks = useMemo(() => proxySet([]), []); + const [selectedLength, setSelectedLength] = useState(0); + + subscribeKey(selectedBlocks, 'size', (v) => setSelectedLength(v)); + + useEffect(() => { + const { onChange } = editor; + + const onKeydown = (e: KeyboardEvent) => { + if (!ReactEditor.isFocused(editor) && selectedLength > 0) { + e.preventDefault(); + e.stopPropagation(); + const selectedBlockId = selectedBlocks.values().next().value; + const [selectedBlock] = editor.nodes({ + at: [], + match: (n) => Element.isElement(n) && n.blockId === selectedBlockId, + }); + const [, path] = selectedBlock; + + editor.select(path); + ReactEditor.focus(editor); + } + }; + + if (selectedLength > 0) { + editor.onChange = (...args) => { + const isSelectionChange = editor.operations.every((arg) => arg.type === 'set_selection'); + + if (isSelectionChange) { + selectedBlocks.clear(); + } + + onChange(...args); + }; + + document.addEventListener('keydown', onKeydown); + } else { + editor.onChange = onChange; + document.removeEventListener('keydown', onKeydown); + } + + return () => { + editor.onChange = onChange; + document.removeEventListener('keydown', onKeydown); + }; + }, [editor, selectedBlocks, selectedLength]); + + return selectedBlocks; +} + +export const EditorSelectedBlockContext = createContext>(new Set()); +export const EditorSelectedBlockProvider = EditorSelectedBlockContext.Provider; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/slash.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/slash.ts new file mode 100644 index 0000000000000..13e9447d0c4f6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/slash.ts @@ -0,0 +1,30 @@ +import { createContext, useCallback, useContext, useMemo } from 'react'; +import { proxyMap } from 'valtio/utils'; +import { useSnapshot } from 'valtio'; + +export const SlashStateContext = createContext>(new Map()); +export const SlashStateProvider = SlashStateContext.Provider; + +export function useInitialSlashState() { + const state = useMemo(() => proxyMap([['open', false]]), []); + + return state; +} + +export function useSlashState() { + const context = useContext(SlashStateContext); + const state = useSnapshot(context); + const open = state.get('open'); + + const setOpen = useCallback( + (open: boolean) => { + context.set('open', open); + }, + [context] + ); + + return { + open, + setOpen, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/error/Error.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/error/Error.hooks.ts new file mode 100644 index 0000000000000..ceaa5a51a00a8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/error/Error.hooks.ts @@ -0,0 +1,39 @@ +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { errorActions } from '$app_reducers/error/slice'; +import { useCallback, useEffect, useState } from 'react'; + +export const useError = (e: Error) => { + const dispatch = useAppDispatch(); + const error = useAppSelector((state) => state.error); + const [errorMessage, setErrorMessage] = useState(''); + const [displayError, setDisplayError] = useState(false); + + useEffect(() => { + setDisplayError(error.display); + setErrorMessage(error.message); + }, [error]); + + const showError = useCallback( + (msg: string) => { + dispatch(errorActions.showError(msg)); + }, + [dispatch] + ); + + useEffect(() => { + if (e) { + showError(e.message); + } + }, [e, showError]); + + const hideError = () => { + dispatch(errorActions.hideError()); + }; + + return { + showError, + hideError, + errorMessage, + displayError, + }; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/error/ErrorHandlerPage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/error/ErrorHandlerPage.tsx new file mode 100644 index 0000000000000..1bb15f2ca3c2c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/error/ErrorHandlerPage.tsx @@ -0,0 +1,8 @@ +import { useError } from './Error.hooks'; +import { ErrorModal } from './ErrorModal'; + +export const ErrorHandlerPage = ({ error }: { error: Error }) => { + const { hideError, errorMessage, displayError } = useError(error); + + return displayError ? : <>; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/error/ErrorModal.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/error/ErrorModal.tsx new file mode 100644 index 0000000000000..6da2ee96d01df --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/error/ErrorModal.tsx @@ -0,0 +1,26 @@ +import { ReactComponent as InformationSvg } from '$app/assets/information.svg'; +import { ReactComponent as CloseSvg } from '$app/assets/close.svg'; + +export const ErrorModal = ({ message, onClose }: { message: string; onClose: () => void }) => { + return ( +
+
+ +
+ +
+

Oops.. something went wrong

+

{message}

+
+
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/index.ts new file mode 100644 index 0000000000000..cb0ff5c3b541f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/FooterPanel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/FooterPanel.tsx new file mode 100644 index 0000000000000..4f468f546175d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/FooterPanel.tsx @@ -0,0 +1,12 @@ +export const FooterPanel = () => { + return ( +
+
+ © 2024 AppFlowy. GitHub +
+ {/*
*/} + {/* */} + {/*
*/} +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.hooks.ts new file mode 100644 index 0000000000000..807c1e68111d3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.hooks.ts @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; +import { createHotkey, HOT_KEY_NAME } from '$app/utils/hotkeys'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { currentUserActions, ThemeMode } from '$app_reducers/current-user/slice'; +import { UserService } from '$app/application/user/user.service'; +import { sidebarActions } from '$app_reducers/sidebar/slice'; + +export function useShortcuts() { + const dispatch = useAppDispatch(); + const userSettingState = useAppSelector((state) => state.currentUser.userSetting); + const { isDark } = userSettingState; + + const switchThemeMode = useCallback(() => { + const newSetting = { + themeMode: isDark ? ThemeMode.Light : ThemeMode.Dark, + isDark: !isDark, + }; + + dispatch(currentUserActions.setUserSetting(newSetting)); + void UserService.setAppearanceSetting({ + theme_mode: newSetting.themeMode, + }); + }, [dispatch, isDark]); + + const toggleSidebar = useCallback(() => { + dispatch(sidebarActions.toggleCollapse()); + }, [dispatch]); + + return useCallback( + (e: KeyboardEvent) => { + switch (true) { + /** + * Toggle theme: Mod+L + * Switch between light and dark theme + */ + case createHotkey(HOT_KEY_NAME.TOGGLE_THEME)(e): + switchThemeMode(); + break; + /** + * Toggle sidebar: Mod+. (period) + * Prevent the default behavior of the browser (Exit full screen) + * Collapse or expand the sidebar + */ + case createHotkey(HOT_KEY_NAME.TOGGLE_SIDEBAR)(e): + e.preventDefault(); + toggleSidebar(); + break; + default: + break; + } + }, + [toggleSidebar, switchThemeMode] + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx new file mode 100644 index 0000000000000..509aa388cf6e5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx @@ -0,0 +1,66 @@ +import React, { ReactNode, useEffect, useMemo } from 'react'; +import SideBar from '$app/components/layout/side_bar/SideBar'; +import TopBar from '$app/components/layout/top_bar/TopBar'; +import { useAppSelector } from '$app/stores/store'; +import './layout.scss'; +import { AFScroller } from '../_shared/scroller'; +import { useNavigate } from 'react-router-dom'; +import { pageTypeMap } from '$app_reducers/pages/slice'; +import { useShortcuts } from '$app/components/layout/Layout.hooks'; + +function Layout({ children }: { children: ReactNode }) { + const { isCollapsed, width } = useAppSelector((state) => state.sidebar); + const currentUser = useAppSelector((state) => state.currentUser); + const navigate = useNavigate(); + const { id: latestOpenViewId, layout } = useMemo( + () => + currentUser?.workspaceSetting?.latestView || { + id: undefined, + layout: undefined, + }, + [currentUser?.workspaceSetting?.latestView] + ); + + const onKeyDown = useShortcuts(); + + useEffect(() => { + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + }; + }, [onKeyDown]); + + useEffect(() => { + if (latestOpenViewId) { + const pageType = pageTypeMap[layout]; + + navigate(`/page/${pageType}/${latestOpenViewId}`); + } + }, [latestOpenViewId, navigate, layout]); + return ( + <> +
+ +
+ + + {children} + +
+
+ + ); +} + +export default Layout; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx new file mode 100644 index 0000000000000..ec9e990cdba40 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx @@ -0,0 +1,64 @@ +import React, { useCallback } from 'react'; +import { useLoadExpandedPages } from '$app/components/layout/bread_crumb/Breadcrumb.hooks'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Link from '@mui/material/Link'; +import Typography from '@mui/material/Typography'; +import { Page } from '$app_reducers/pages/slice'; +import { useTranslation } from 'react-i18next'; +import { getPageIcon } from '$app/hooks/page.hooks'; +import { useAppDispatch } from '$app/stores/store'; +import { openPage } from '$app_reducers/pages/async_actions'; + +function Breadcrumb() { + const { t } = useTranslation(); + const { isTrash, pagePath, currentPage } = useLoadExpandedPages(); + const dispatch = useAppDispatch(); + + const navigateToPage = useCallback( + (page: Page) => { + void dispatch(openPage(page.id)); + }, + [dispatch] + ); + + if (!currentPage) { + if (isTrash) { + return {t('trash.text')}; + } + + return null; + } + + return ( + + {pagePath?.map((page: Page, index) => { + if (index === pagePath.length - 1) { + return ( +
+
{getPageIcon(page)}
+ {page.name.trim() || t('menuAppHeader.defaultNewPageName')} +
+ ); + } + + return ( + { + navigateToPage(page); + }} + > +
{getPageIcon(page)}
+ + {page.name.trim() || t('menuAppHeader.defaultNewPageName')} + + ); + })} +
+ ); +} + +export default Breadcrumb; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/Breadcrumb.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/Breadcrumb.hooks.ts new file mode 100644 index 0000000000000..f2bec915d9a9f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/Breadcrumb.hooks.ts @@ -0,0 +1,38 @@ +import { useAppSelector } from '$app/stores/store'; +import { useMemo } from 'react'; +import { useParams, useLocation } from 'react-router-dom'; +import { Page } from '$app_reducers/pages/slice'; + +export function useLoadExpandedPages() { + const params = useParams(); + const location = useLocation(); + const isTrash = useMemo(() => location.pathname.includes('trash'), [location.pathname]); + const currentPageId = params.id; + const currentPage = useAppSelector((state) => (currentPageId ? state.pages.pageMap[currentPageId] : undefined)); + + const pagePath = useAppSelector((state) => { + const result: Page[] = []; + + if (!currentPage) return result; + + const findParent = (page: Page) => { + if (!page.parentId) return; + const parent = state.pages.pageMap[page.parentId]; + + if (parent) { + result.unshift(parent); + findParent(parent); + } + }; + + findParent(currentPage); + result.push(currentPage); + return result; + }); + + return { + pagePath, + currentPage, + isTrash, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx new file mode 100644 index 0000000000000..87662a99bbc4d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx @@ -0,0 +1,37 @@ +import React, { useCallback, useMemo } from 'react'; +import { IconButton, Tooltip } from '@mui/material'; + +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { sidebarActions } from '$app_reducers/sidebar/slice'; +import { ReactComponent as ShowMenuIcon } from '$app/assets/show-menu.svg'; +import { useTranslation } from 'react-i18next'; +import { createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys'; + +function CollapseMenuButton() { + const isCollapsed = useAppSelector((state) => state.sidebar.isCollapsed); + const dispatch = useAppDispatch(); + const handleClick = useCallback(() => { + dispatch(sidebarActions.toggleCollapse()); + }, [dispatch]); + + const { t } = useTranslation(); + + const title = useMemo(() => { + return ( +
+
{isCollapsed ? t('sideBar.openSidebar') : t('sideBar.closeSidebar')}
+
{createHotKeyLabel(HOT_KEY_NAME.TOGGLE_SIDEBAR)}
+
+ ); + }, [isCollapsed, t]); + + return ( + + + + + + ); +} + +export default CollapseMenuButton; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/layout.scss b/frontend/appflowy_tauri/src/appflowy_app/components/layout/layout.scss new file mode 100644 index 0000000000000..43f4f5589273c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/layout.scss @@ -0,0 +1,81 @@ + + +.sketch-picker { + background-color: var(--bg-body) !important; + border-color: transparent !important; + box-shadow: none !important; +} +.sketch-picker .flexbox-fix { + border-color: var(--line-divider) !important; +} +.sketch-picker [id^='rc-editable-input'] { + background-color: var(--bg-body) !important; + border-color: var(--line-divider) !important; + color: var(--text-title) !important; + box-shadow: var(--line-border) 0px 0px 0px 1px inset !important; +} + +.appflowy-date-picker-calendar { + width: 100%; + +} + +.grid-sticky-header::-webkit-scrollbar { + width: 0; + height: 0; +} +.grid-scroll-container::-webkit-scrollbar { + width: 0; + height: 0; +} + + +.appflowy-scroll-container { + &::-webkit-scrollbar { + width: 0; + } +} + +.appflowy-scrollbar-thumb-horizontal, .appflowy-scrollbar-thumb-vertical { + background-color: var(--scrollbar-thumb); + border-radius: 4px; + opacity: 60%; +} + +.workspaces { + ::-webkit-scrollbar { + width: 0px; + } +} + + + +.MuiPopover-root, .MuiPaper-root { + ::-webkit-scrollbar { + width: 0; + height: 0; + } +} + +.view-icon { + &:hover { + background-color: rgba(156, 156, 156, 0.20); + } +} + +.theme-mode-item { + @apply relative flex h-[72px] w-[88px] cursor-pointer items-end justify-end rounded border hover:shadow; + background: linear-gradient(150.74deg, rgba(231, 231, 231, 0) 17.95%, #C5C5C5 95.51%); +} + +[data-dark-mode="true"] { + .theme-mode-item { + background: linear-gradient(150.74deg, rgba(128, 125, 125, 0) 17.95%, #4d4d4d 95.51%); + } +} + +.document-header { + .view-banner { + @apply items-center; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/AddButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/AddButton.tsx new file mode 100644 index 0000000000000..1387f16f4d928 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/AddButton.tsx @@ -0,0 +1,65 @@ +import React, { useCallback, useMemo } from 'react'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as DocumentSvg } from '$app/assets/document.svg'; +import { ReactComponent as GridSvg } from '$app/assets/grid.svg'; +import { ViewLayoutPB } from '@/services/backend'; +import OperationMenu from '$app/components/layout/nested_page/OperationMenu'; + +function AddButton({ + isHovering, + setHovering, + onAddPage, +}: { + isHovering: boolean; + setHovering: (hovering: boolean) => void; + onAddPage: (layout: ViewLayoutPB) => void; +}) { + const { t } = useTranslation(); + + const onConfirm = useCallback( + (key: string) => { + switch (key) { + case 'document': + onAddPage(ViewLayoutPB.Document); + break; + case 'grid': + onAddPage(ViewLayoutPB.Grid); + break; + default: + break; + } + }, + [onAddPage] + ); + + const options = useMemo( + () => [ + { + key: 'document', + title: t('document.menuName'), + icon: , + }, + { + key: 'grid', + title: t('grid.menuName'), + icon: , + }, + ], + [t] + ); + + return ( + + + + ); +} + +export default AddButton; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/DeleteDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/DeleteDialog.tsx new file mode 100644 index 0000000000000..4af8a2f2f14a4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/DeleteDialog.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ViewLayoutPB } from '@/services/backend'; +import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog'; + +function DeleteDialog({ + layout, + open, + onClose, + onOk, +}: { + layout: ViewLayoutPB; + open: boolean; + onClose: () => void; + onOk: () => Promise; +}) { + const { t } = useTranslation(); + + const pageType = { + [ViewLayoutPB.Document]: t('document.menuName'), + [ViewLayoutPB.Grid]: t('grid.menuName'), + [ViewLayoutPB.Board]: t('board.menuName'), + [ViewLayoutPB.Calendar]: t('calendar.menuName'), + }[layout]; + + return ( + + ); +} + +export default DeleteDialog; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx new file mode 100644 index 0000000000000..94a86655ac17a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx @@ -0,0 +1,130 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ReactComponent as DetailsSvg } from '$app/assets/details.svg'; +import { ReactComponent as EditSvg } from '$app/assets/edit.svg'; +import { ReactComponent as CopySvg } from '$app/assets/copy.svg'; +import { ReactComponent as TrashSvg } from '$app/assets/delete.svg'; + +import RenameDialog from '../../_shared/confirm_dialog/RenameDialog'; +import { Page } from '$app_reducers/pages/slice'; +import DeleteDialog from '$app/components/layout/nested_page/DeleteDialog'; +import OperationMenu from '$app/components/layout/nested_page/OperationMenu'; +import { getModifier } from '$app/utils/hotkeys'; +import isHotkey from 'is-hotkey'; + +function MoreButton({ + onDelete, + onDuplicate, + onRename, + page, + isHovering, + setHovering, +}: { + isHovering: boolean; + setHovering: (hovering: boolean) => void; + onDelete: () => Promise; + onDuplicate: () => Promise; + onRename: (newName: string) => Promise; + page: Page; +}) { + const [renameDialogOpen, setRenameDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const { t } = useTranslation(); + + const onConfirm = useCallback( + (key: string) => { + switch (key) { + case 'rename': + setRenameDialogOpen(true); + break; + case 'delete': + setDeleteDialogOpen(true); + break; + case 'duplicate': + void onDuplicate(); + + break; + default: + break; + } + }, + [onDuplicate] + ); + + const options = useMemo( + () => [ + { + title: t('button.rename'), + icon: , + key: 'rename', + }, + { + key: 'delete', + title: t('button.delete'), + icon: , + caption: 'Del', + }, + { + key: 'duplicate', + title: t('button.duplicate'), + icon: , + caption: `${getModifier()}+D`, + }, + ], + [t] + ); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (isHotkey('del', e) || isHotkey('backspace', e)) { + e.preventDefault(); + e.stopPropagation(); + onConfirm('delete'); + return; + } + + if (isHotkey('mod+d', e)) { + e.stopPropagation(); + onConfirm('duplicate'); + return; + } + }, + [onConfirm] + ); + + return ( + <> + + + + + { + setDeleteDialogOpen(false); + }} + onOk={onDelete} + /> + {renameDialogOpen && ( + setRenameDialogOpen(false)} + onOk={onRename} + /> + )} + + ); +} + +export default MoreButton; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.hooks.ts new file mode 100644 index 0000000000000..d43499e801e34 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.hooks.ts @@ -0,0 +1,147 @@ +import { useCallback, useEffect } from 'react'; +import { pagesActions, parserViewPBToPage } from '$app_reducers/pages/slice'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { FolderNotification, ViewLayoutPB } from '@/services/backend'; +import { useParams } from 'react-router-dom'; +import { openPage, updatePageName } from '$app_reducers/pages/async_actions'; +import { createPage, deletePage, duplicatePage, getChildPages } from '$app/application/folder/page.service'; +import { subscribeNotifications } from '$app/application/notification'; + +export function useLoadChildPages(pageId: string) { + const dispatch = useAppDispatch(); + const childPages = useAppSelector((state) => state.pages.relationMap[pageId]); + const collapsed = useAppSelector((state) => !state.pages.expandedIdMap[pageId]); + const toggleCollapsed = useCallback(() => { + if (collapsed) { + dispatch(pagesActions.expandPage(pageId)); + } else { + dispatch(pagesActions.collapsePage(pageId)); + } + }, [dispatch, pageId, collapsed]); + + const loadPageChildren = useCallback( + async (pageId: string) => { + const childPages = await getChildPages(pageId); + + dispatch( + pagesActions.addChildPages({ + id: pageId, + childPages, + }) + ); + }, + [dispatch] + ); + + useEffect(() => { + void loadPageChildren(pageId); + }, [loadPageChildren, pageId]); + + useEffect(() => { + const unsubscribePromise = subscribeNotifications( + { + [FolderNotification.DidUpdateView]: async (payload) => { + const childViews = payload.child_views; + + if (childViews.length === 0) { + return; + } + + dispatch( + pagesActions.addChildPages({ + id: pageId, + childPages: childViews.map(parserViewPBToPage), + }) + ); + }, + [FolderNotification.DidUpdateChildViews]: async (payload) => { + if (payload.delete_child_views.length === 0 && payload.create_child_views.length === 0) { + return; + } + + void loadPageChildren(pageId); + }, + }, + { + id: pageId, + } + ); + + return () => void unsubscribePromise.then((unsubscribe) => unsubscribe()); + }, [pageId, loadPageChildren, dispatch]); + + return { + toggleCollapsed, + collapsed, + childPages, + }; +} + +export function usePageActions(pageId: string) { + const page = useAppSelector((state) => state.pages.pageMap[pageId]); + const dispatch = useAppDispatch(); + const params = useParams(); + const currentPageId = params.id; + + const onPageClick = useCallback(() => { + void dispatch(openPage(pageId)); + }, [dispatch, pageId]); + + const onAddPage = useCallback( + async (layout: ViewLayoutPB) => { + const newViewId = await createPage({ + layout, + name: '', + parent_view_id: pageId, + }); + + dispatch( + pagesActions.addPage({ + page: { + id: newViewId, + parentId: pageId, + layout, + name: '', + }, + isLast: true, + }) + ); + + dispatch(pagesActions.expandPage(pageId)); + await dispatch(openPage(newViewId)); + }, + [dispatch, pageId] + ); + + const onDeletePage = useCallback(async () => { + if (currentPageId === pageId) { + dispatch(pagesActions.setTrashSnackbar(true)); + } + + await deletePage(pageId); + dispatch(pagesActions.deletePages([pageId])); + }, [dispatch, pageId, currentPageId]); + + const onDuplicatePage = useCallback(async () => { + await duplicatePage(page); + }, [page]); + + const onRenamePage = useCallback( + async (name: string) => { + await dispatch(updatePageName({ id: pageId, name })); + }, + [dispatch, pageId] + ); + + return { + onAddPage, + onPageClick, + onRenamePage, + onDeletePage, + onDuplicatePage, + }; +} + +export function useSelectedPage(pageId: string) { + return useParams().id === pageId; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.tsx new file mode 100644 index 0000000000000..e423f05517645 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPage.tsx @@ -0,0 +1,126 @@ +import React, { useCallback, useMemo } from 'react'; +import Collapse from '@mui/material/Collapse'; +import { TransitionGroup } from 'react-transition-group'; +import NestedPageTitle from '$app/components/layout/nested_page/NestedPageTitle'; +import { useLoadChildPages, usePageActions } from '$app/components/layout/nested_page/NestedPage.hooks'; +import { useDrag } from 'src/appflowy_app/components/_shared/drag_block'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { movePageThunk } from '$app_reducers/pages/async_actions'; +import { ViewLayoutPB } from '@/services/backend'; + +function NestedPage({ pageId }: { pageId: string }) { + const { toggleCollapsed, collapsed, childPages } = useLoadChildPages(pageId); + const { onAddPage, onPageClick, onDeletePage, onDuplicatePage, onRenamePage } = usePageActions(pageId); + const dispatch = useAppDispatch(); + const { page, parentLayout } = useAppSelector((state) => { + const page = state.pages.pageMap[pageId]; + const parent = state.pages.pageMap[page?.parentId || '']; + + return { + page, + parentLayout: parent?.layout, + }; + }); + + const disableChildren = useAppSelector((state) => { + if (!page) return true; + const layout = state.pages.pageMap[page.parentId]?.layout; + + return !(layout === undefined || layout === ViewLayoutPB.Document); + }); + const children = useMemo(() => { + if (disableChildren) { + return []; + } + + return collapsed ? [] : childPages; + }, [collapsed, childPages, disableChildren]); + + const onDragFinished = useCallback( + (result: { dragId: string; position: 'before' | 'after' | 'inside' }) => { + const { dragId, position } = result; + + if (dragId === pageId) return; + if (position === 'inside' && page?.layout !== ViewLayoutPB.Document) return; + void dispatch( + movePageThunk({ + sourceId: dragId, + targetId: pageId, + insertType: position, + }) + ); + }, + [dispatch, page?.layout, pageId] + ); + + const { onDrop, dropPosition, onDragOver, onDragLeave, onDragStart, onDragEnd, isDraggingOver, isDragging } = useDrag({ + onEnd: onDragFinished, + dragId: pageId, + }); + + const className = useMemo(() => { + const defaultClassName = 'relative flex-1 select-none flex flex-col w-full'; + + if (isDragging) { + return `${defaultClassName} opacity-40`; + } + + if (isDraggingOver && dropPosition === 'inside' && page?.layout === ViewLayoutPB.Document) { + if (dropPosition === 'inside') { + return `${defaultClassName} bg-content-blue-100`; + } + } else { + return defaultClassName; + } + }, [dropPosition, isDragging, isDraggingOver, page?.layout]); + + // Only allow dragging if the parent layout is undefined or a document + const draggable = parentLayout === undefined || parentLayout === ViewLayoutPB.Document; + + return ( +
+
+ { + onPageClick(); + }} + onAddPage={onAddPage} + onDuplicate={onDuplicatePage} + onDelete={onDeletePage} + onRename={onRenamePage} + collapsed={collapsed} + toggleCollapsed={toggleCollapsed} + pageId={pageId} + /> +
+ + {children?.map((pageId) => ( + + + + ))} + +
+
+ ); +} + +export default React.memo(NestedPage); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPageTitle.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPageTitle.tsx new file mode 100644 index 0000000000000..948aedcae2a64 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPageTitle.tsx @@ -0,0 +1,102 @@ +import React, { useMemo, useState } from 'react'; +import { useAppSelector } from '$app/stores/store'; +import AddButton from './AddButton'; +import MoreButton from './MoreButton'; +import { ViewLayoutPB } from '@/services/backend'; +import { useSelectedPage } from '$app/components/layout/nested_page/NestedPage.hooks'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as MoreIcon } from '$app/assets/more.svg'; +import { IconButton } from '@mui/material'; +import { Page } from '$app_reducers/pages/slice'; +import { getPageIcon } from '$app/hooks/page.hooks'; + +function NestedPageTitle({ + pageId, + collapsed, + toggleCollapsed, + onAddPage, + onClick, + onDelete, + onDuplicate, + onRename, +}: { + pageId: string; + collapsed: boolean; + toggleCollapsed: () => void; + onAddPage: (layout: ViewLayoutPB) => void; + onClick: () => void; + onDelete: () => Promise; + onDuplicate: () => Promise; + onRename: (newName: string) => Promise; +}) { + const { t } = useTranslation(); + const page = useAppSelector((state) => { + return state.pages.pageMap[pageId] as Page | undefined; + }); + const disableChildren = useAppSelector((state) => { + if (!page) return true; + const layout = state.pages.pageMap[page.parentId]?.layout; + + return !(layout === undefined || layout === ViewLayoutPB.Document); + }); + + const [isHovering, setIsHovering] = useState(false); + const isSelected = useSelectedPage(pageId); + + const pageIcon = useMemo(() => (page ? getPageIcon(page) : null), [page]); + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > +
+
+ {disableChildren ? ( +
+ ) : ( + { + e.stopPropagation(); + toggleCollapsed(); + }} + style={{ + transform: collapsed ? 'rotate(0deg)' : 'rotate(90deg)', + }} + > + + + )} + + {pageIcon} + +
+ {page?.name.trim() || t('menuAppHeader.defaultNewPageName')} +
+
+
e.stopPropagation()} className={'min:w-14 flex items-center justify-end px-2'}> + {page?.layout === ViewLayoutPB.Document && ( + + )} + {page && ( + + )} +
+
+
+ ); +} + +export default NestedPageTitle; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/OperationMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/OperationMenu.tsx new file mode 100644 index 0000000000000..cef1d3307c1d4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/OperationMenu.tsx @@ -0,0 +1,103 @@ +import React, { ReactNode, useCallback, useMemo, useState } from 'react'; +import { IconButton } from '@mui/material'; +import Popover from '@mui/material/Popover'; +import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import Tooltip from '@mui/material/Tooltip'; + +function OperationMenu({ + options, + onConfirm, + isHovering, + setHovering, + children, + tooltip, + onKeyDown, +}: { + isHovering: boolean; + setHovering: (hovering: boolean) => void; + options: { + key: string; + title: string; + icon: React.ReactNode; + caption?: string; + }[]; + children: React.ReactNode; + onConfirm: (key: string) => void; + tooltip: string; + onKeyDown?: (e: KeyboardEvent) => void; +}) { + const [anchorEl, setAnchorEl] = useState(null); + const renderItem = useCallback((title: string, icon: ReactNode, caption?: string) => { + return ( +
+ {icon} +
{title}
+
{caption || ''}
+
+ ); + }, []); + + const handleClose = useCallback(() => { + setAnchorEl(null); + setHovering(false); + }, [setHovering]); + + const optionList = useMemo(() => { + return options.map((option) => { + return { + key: option.key, + content: renderItem(option.title, option.icon, option.caption), + }; + }); + }, [options, renderItem]); + + const open = Boolean(anchorEl); + + const handleConfirm = useCallback( + (key: string) => { + onConfirm(key); + handleClose(); + }, + [handleClose, onConfirm] + ); + + return ( + <> + + { + setAnchorEl(e.currentTarget); + }} + className={`${!isHovering ? 'invisible' : ''} text-icon-primary`} + > + {children} + + + + + + + + ); +} + +export default OperationMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/share/Share.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/share/Share.hooks.ts new file mode 100644 index 0000000000000..b281706848d0e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/share/Share.hooks.ts @@ -0,0 +1,12 @@ +import { useParams } from 'react-router-dom'; + +export function useShareConfig() { + const params = useParams(); + const id = params.id; + + const showShareButton = !!id; + + return { + showShareButton, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/share/Share.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/share/Share.tsx new file mode 100644 index 0000000000000..1a10cb08e623e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/share/Share.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import Button from '@mui/material/Button'; +import { useShareConfig } from '$app/components/layout/share/Share.hooks'; + +function ShareButton() { + const { showShareButton } = useShareConfig(); + const { t } = useTranslation(); + + if (!showShareButton) return null; + return ; +} + +export default ShareButton; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/Resizer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/Resizer.tsx new file mode 100644 index 0000000000000..639d5283e0dfb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/Resizer.tsx @@ -0,0 +1,55 @@ +import React, { useCallback, useRef } from 'react'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { sidebarActions } from '$app_reducers/sidebar/slice'; + +const minSidebarWidth = 200; + +function Resizer() { + const dispatch = useAppDispatch(); + const width = useAppSelector((state) => state.sidebar.width); + const startX = useRef(0); + const onResize = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + const diff = e.clientX - startX.current; + const newWidth = width + diff; + + if (newWidth < minSidebarWidth) { + return; + } + + dispatch(sidebarActions.changeWidth(newWidth)); + }, + [dispatch, width] + ); + + const onResizeEnd = useCallback(() => { + dispatch(sidebarActions.stopResizing()); + document.removeEventListener('mousemove', onResize); + document.removeEventListener('mouseup', onResizeEnd); + }, [onResize, dispatch]); + + const onResizeStart = useCallback( + (e: React.MouseEvent) => { + startX.current = e.clientX; + dispatch(sidebarActions.startResizing()); + document.addEventListener('mousemove', onResize); + document.addEventListener('mouseup', onResizeEnd); + }, + [onResize, onResizeEnd, dispatch] + ); + + return ( +
+
+
+ ); +} + +export default React.memo(Resizer); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/SideBar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/SideBar.tsx new file mode 100644 index 0000000000000..5cdbfb125be83 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/SideBar.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useRef } from 'react'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { ReactComponent as AppflowyLogoDark } from '$app/assets/dark-logo.svg'; +import { ReactComponent as AppflowyLogoLight } from '$app/assets/light-logo.svg'; +import CollapseMenuButton from '$app/components/layout/collapse_menu_button/CollapseMenuButton'; +import Resizer from '$app/components/layout/side_bar/Resizer'; +import UserInfo from '$app/components/layout/side_bar/UserInfo'; +import WorkspaceManager from '$app/components/layout/workspace_manager/WorkspaceManager'; +import { ThemeMode } from '$app_reducers/current-user/slice'; +import { sidebarActions } from '$app_reducers/sidebar/slice'; + +function SideBar() { + const { isCollapsed, width, isResizing } = useAppSelector((state) => state.sidebar); + const dispatch = useAppDispatch(); + + const themeMode = useAppSelector((state) => state.currentUser?.userSetting?.themeMode); + const isDark = + themeMode === ThemeMode.Dark || + (themeMode === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches); + + const lastCollapsedRef = useRef(isCollapsed); + + useEffect(() => { + const handleResize = () => { + const width = window.innerWidth; + + if (width <= 800 && !isCollapsed) { + lastCollapsedRef.current = false; + dispatch(sidebarActions.setCollapse(true)); + } else if (width > 800 && !lastCollapsedRef.current) { + lastCollapsedRef.current = true; + dispatch(sidebarActions.setCollapse(false)); + } + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [dispatch, isCollapsed]); + return ( + <> +
+
+
+ {isDark ? ( + + ) : ( + + )} + +
+
+ +
+
+ +
+
+
+ + + ); +} + +export default SideBar; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/UserInfo.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/UserInfo.tsx new file mode 100644 index 0000000000000..62763c670e9ee --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/UserInfo.tsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import { useAppSelector } from '$app/stores/store'; +import { IconButton } from '@mui/material'; +import { ReactComponent as SettingIcon } from '$app/assets/settings.svg'; +import Tooltip from '@mui/material/Tooltip'; +import { useTranslation } from 'react-i18next'; +import { SettingsDialog } from '$app/components/settings/SettingsDialog'; +import { ProfileAvatar } from '$app/components/_shared/avatar'; + +function UserInfo() { + const currentUser = useAppSelector((state) => state.currentUser); + const [showUserSetting, setShowUserSetting] = useState(false); + + const { t } = useTranslation(); + + return ( + <> +
+
+ + {currentUser.displayName} +
+ + + { + setShowUserSetting(!showUserSetting); + }} + > + + + +
+ + {showUserSetting && setShowUserSetting(false)} />} + + ); +} + +export default UserInfo; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/DeletePageSnackbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/DeletePageSnackbar.tsx new file mode 100644 index 0000000000000..f5638362b9e8e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/DeletePageSnackbar.tsx @@ -0,0 +1,104 @@ +import React, { useEffect } from 'react'; +import { Alert, Snackbar } from '@mui/material'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { useParams } from 'react-router-dom'; +import { pagesActions } from '$app_reducers/pages/slice'; +import Slide, { SlideProps } from '@mui/material/Slide'; +import { useTranslation } from 'react-i18next'; +import Button from '@mui/material/Button'; +import { useTrashActions } from '$app/components/trash/Trash.hooks'; +import { openPage } from '$app_reducers/pages/async_actions'; + +function SlideTransition(props: SlideProps) { + return ; +} + +function DeletePageSnackbar() { + const firstViewId = useAppSelector((state) => { + const workspaceId = state.workspace.currentWorkspaceId; + const children = workspaceId ? state.pages.relationMap[workspaceId] : undefined; + + if (!children) return null; + + return children[0]; + }); + + const showTrashSnackbar = useAppSelector((state) => state.pages.showTrashSnackbar); + const dispatch = useAppDispatch(); + const { onPutback, onDelete } = useTrashActions(); + const { id } = useParams(); + + const { t } = useTranslation(); + + useEffect(() => { + dispatch(pagesActions.setTrashSnackbar(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const handleBack = () => { + if (firstViewId) { + void dispatch(openPage(firstViewId)); + } + }; + + const handleClose = (toBack = true) => { + dispatch(pagesActions.setTrashSnackbar(false)); + if (toBack) { + handleBack(); + } + }; + + const handleRestore = () => { + if (!id) return; + void onPutback(id); + handleClose(false); + }; + + const handleDelete = () => { + if (!id) return; + void onDelete([id]); + + if (!firstViewId) { + handleClose(false); + return; + } + + handleBack(); + }; + + return ( + + handleClose()} + severity='info' + variant='standard' + sx={{ + width: '100%', + '.MuiAlert-action': { + padding: 0, + }, + }} + > +
+ {t('deletePagePrompt.text')} + + +
+
+
+ ); +} + +export default DeletePageSnackbar; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/FontSizeConfig.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/FontSizeConfig.tsx new file mode 100644 index 0000000000000..4b439cc3fbd1b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/FontSizeConfig.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ButtonGroup, Divider } from '@mui/material'; +import Button from '@mui/material/Button'; + +function FontSizeConfig() { + const { t } = useTranslation(); + + return ( + <> +
+
{t('moreAction.fontSize')}
+
+ + + + + +
+
+ + + ); +} + +export default FontSizeConfig; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreButton.tsx new file mode 100644 index 0000000000000..d37d1bf060cf9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreButton.tsx @@ -0,0 +1,32 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Drawer, IconButton } from '@mui/material'; +import { ReactComponent as Details2Svg } from '$app/assets/details.svg'; +import Tooltip from '@mui/material/Tooltip'; +import MoreOptions from '$app/components/layout/top_bar/MoreOptions'; +import { useMoreOptionsConfig } from '$app/components/layout/top_bar/MoreOptions.hooks'; + +function MoreButton() { + const { t } = useTranslation(); + const [open, setOpen] = React.useState(false); + const toggleDrawer = useCallback((open: boolean) => { + setOpen(open); + }, []); + const { showMoreButton } = useMoreOptionsConfig(); + + if (!showMoreButton) return null; + return ( + <> + + toggleDrawer(true)} className={'text-icon-primary'}> + + + + toggleDrawer(false)}> + + + + ); +} + +export default MoreButton; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreOptions.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreOptions.hooks.ts new file mode 100644 index 0000000000000..63f1173885ed4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreOptions.hooks.ts @@ -0,0 +1,30 @@ +import { useLocation } from 'react-router-dom'; +import { useMemo } from 'react'; + +export function useMoreOptionsConfig() { + const location = useLocation(); + + const { type, pageType } = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, type, pageType, id] = location.pathname.split('/'); + + return { + type, + pageType, + id, + }; + }, [location.pathname]); + + const showMoreButton = useMemo(() => { + return type === 'page'; + }, [type]); + + const showStyleOptions = useMemo(() => { + return type === 'page' && pageType === 'document'; + }, [pageType, type]); + + return { + showMoreButton, + showStyleOptions, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreOptions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreOptions.tsx new file mode 100644 index 0000000000000..7f77259212805 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/MoreOptions.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import FontSizeConfig from '$app/components/layout/top_bar/FontSizeConfig'; +import { useMoreOptionsConfig } from '$app/components/layout/top_bar/MoreOptions.hooks'; + +function MoreOptions() { + const { showStyleOptions } = useMoreOptionsConfig(); + + return
{showStyleOptions && }
; +} + +export default MoreOptions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/TopBar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/TopBar.tsx new file mode 100644 index 0000000000000..173bf86caba0f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/TopBar.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import CollapseMenuButton from '$app/components/layout/collapse_menu_button/CollapseMenuButton'; +import { useAppSelector } from '$app/stores/store'; +import Breadcrumb from '$app/components/layout/bread_crumb/BreadCrumb'; +import DeletePageSnackbar from '$app/components/layout/top_bar/DeletePageSnackbar'; + +function TopBar() { + const sidebarIsCollapsed = useAppSelector((state) => state.sidebar.isCollapsed); + + return ( +
+ {sidebarIsCollapsed && ( +
+ +
+ )} +
+
+ +
+
+ +
+ ); +} + +export default TopBar; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/NestedPages.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/NestedPages.tsx new file mode 100644 index 0000000000000..ec3335b6b325f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/NestedPages.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { useAppSelector } from '$app/stores/store'; +import NestedPage from '$app/components/layout/nested_page/NestedPage'; + +function WorkspaceNestedPages({ workspaceId }: { workspaceId: string }) { + const pageIds = useAppSelector((state) => { + return state.pages.relationMap[workspaceId]; + }); + + return ( +
+ {pageIds?.map((pageId) => ( + + ))} +
+ ); +} + +export default WorkspaceNestedPages; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/NewPageButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/NewPageButton.tsx new file mode 100644 index 0000000000000..537b7d2d9a2e5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/NewPageButton.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useWorkspaceActions } from '$app/components/layout/workspace_manager/Workspace.hooks'; +import Button from '@mui/material/Button'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; + +function NewPageButton({ workspaceId }: { workspaceId: string }) { + const { t } = useTranslation(); + const { newPage } = useWorkspaceActions(workspaceId); + + return ( +
+
+ } + className={'flex w-full items-center justify-start text-xs hover:bg-transparent hover:text-fill-default'} + > + {t('newPageText')} + +
+ ); +} + +export default NewPageButton; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/TrashButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/TrashButton.tsx new file mode 100644 index 0000000000000..984ed6f67f68f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/TrashButton.tsx @@ -0,0 +1,43 @@ +import React, { useCallback } from 'react'; +import { ReactComponent as TrashSvg } from '$app/assets/delete.svg'; +import { useTranslation } from 'react-i18next'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useDrag } from 'src/appflowy_app/components/_shared/drag_block'; +import { deletePage } from '$app/application/folder/page.service'; + +function TrashButton() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const currentPathType = useLocation().pathname.split('/')[1]; + const navigateToTrash = () => { + navigate('/trash'); + }; + + const selected = currentPathType === 'trash'; + + const onEnd = useCallback((result: { dragId: string; position: 'before' | 'after' | 'inside' }) => { + void deletePage(result.dragId); + }, []); + + const { onDrop, onDragOver, onDragLeave, isDraggingOver } = useDrag({ + onEnd, + }); + + return ( +
+ + {t('trash.text')} +
+ ); +} + +export default TrashButton; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.hooks.ts new file mode 100644 index 0000000000000..86bca45ada926 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.hooks.ts @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { workspaceActions, WorkspaceItem } from '$app_reducers/workspace/slice'; +import { Page, pagesActions, parserViewPBToPage } from '$app_reducers/pages/slice'; +import { subscribeNotifications } from '$app/application/notification'; +import { FolderNotification, ViewLayoutPB } from '@/services/backend'; +import * as workspaceService from '$app/application/folder/workspace.service'; +import { createCurrentWorkspaceChildView } from '$app/application/folder/workspace.service'; +import { openPage } from '$app_reducers/pages/async_actions'; + +export function useLoadWorkspaces() { + const dispatch = useAppDispatch(); + const { workspaces, currentWorkspaceId } = useAppSelector((state) => state.workspace); + + const currentWorkspace = useMemo(() => { + return workspaces.find((workspace) => workspace.id === currentWorkspaceId); + }, [workspaces, currentWorkspaceId]); + + const initializeWorkspaces = useCallback(async () => { + const workspaces = await workspaceService.getWorkspaces(); + + const currentWorkspaceId = await workspaceService.getCurrentWorkspace(); + + dispatch( + workspaceActions.initWorkspaces({ + workspaces, + currentWorkspaceId, + }) + ); + }, [dispatch]); + + return { + workspaces, + currentWorkspace, + initializeWorkspaces, + }; +} + +export function useLoadWorkspace(workspace: WorkspaceItem) { + const { id } = workspace; + const dispatch = useAppDispatch(); + + const openWorkspace = useCallback(async () => { + await workspaceService.openWorkspace(id); + }, [id]); + + const deleteWorkspace = useCallback(async () => { + await workspaceService.deleteWorkspace(id); + }, [id]); + + const onChildPagesChanged = useCallback( + (childPages: Page[]) => { + dispatch( + pagesActions.addChildPages({ + id, + childPages, + }) + ); + }, + [dispatch, id] + ); + + const initializeWorkspace = useCallback(async () => { + const childPages = await workspaceService.getWorkspaceChildViews(id); + + dispatch( + pagesActions.addChildPages({ + id, + childPages, + }) + ); + }, [dispatch, id]); + + useEffect(() => { + void (async () => { + await initializeWorkspace(); + })(); + }, [initializeWorkspace]); + + useEffect(() => { + const unsubscribePromise = subscribeNotifications( + { + [FolderNotification.DidUpdateWorkspace]: async (changeset) => { + dispatch( + workspaceActions.updateWorkspace({ + id: String(changeset.id), + name: changeset.name, + icon: changeset.icon_url, + }) + ); + }, + [FolderNotification.DidUpdateWorkspaceViews]: async (changeset) => { + const res = changeset.items; + + onChildPagesChanged(res.map(parserViewPBToPage)); + }, + }, + { id } + ); + + return () => void unsubscribePromise.then((unsubscribe) => unsubscribe()); + }, [dispatch, id, onChildPagesChanged]); + + return { + openWorkspace, + deleteWorkspace, + }; +} + +export function useWorkspaceActions(workspaceId: string) { + const dispatch = useAppDispatch(); + const newPage = useCallback(async () => { + const { id } = await createCurrentWorkspaceChildView({ + name: '', + layout: ViewLayoutPB.Document, + parent_view_id: workspaceId, + }); + + dispatch( + pagesActions.addPage({ + page: { + id: id, + parentId: workspaceId, + layout: ViewLayoutPB.Document, + name: '', + }, + isLast: true, + }) + ); + void dispatch(openPage(id)); + }, [dispatch, workspaceId]); + + return { + newPage, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx new file mode 100644 index 0000000000000..24fc7be91e647 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import { WorkspaceItem } from '$app_reducers/workspace/slice'; +import NestedViews from '$app/components/layout/workspace_manager/NestedPages'; +import { useLoadWorkspace, useWorkspaceActions } from '$app/components/layout/workspace_manager/Workspace.hooks'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as AddIcon } from '$app/assets/add.svg'; +import { IconButton } from '@mui/material'; +import Tooltip from '@mui/material/Tooltip'; +import { WorkplaceAvatar } from '$app/components/_shared/avatar'; + +function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: boolean }) { + useLoadWorkspace(workspace); + const { t } = useTranslation(); + const { newPage } = useWorkspaceActions(workspace.id); + const [showPages, setShowPages] = useState(true); + const [showAdd, setShowAdd] = useState(false); + + return ( + <> +
+
{ + e.stopPropagation(); + e.preventDefault(); + setShowPages((prev) => { + return !prev; + }); + }} + onMouseEnter={() => { + setShowAdd(true); + }} + onMouseLeave={() => { + setShowAdd(false); + }} + className={'mt-2 flex h-[22px] w-full cursor-pointer select-none items-center justify-between px-4'} + > + +
+ {!workspace.name ? ( + t('sideBar.personal') + ) : ( + <> + + {workspace.name} + + )} +
+
+ {showAdd && ( + + { + e.stopPropagation(); + void newPage(); + }} + size={'small'} + > + + + + )} +
+ +
+ +
+
+ + ); +} + +export default Workspace; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/WorkspaceManager.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/WorkspaceManager.tsx new file mode 100644 index 0000000000000..083dd61ec3fca --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/WorkspaceManager.tsx @@ -0,0 +1,38 @@ +import React, { useEffect } from 'react'; +import NewPageButton from '$app/components/layout/workspace_manager/NewPageButton'; +import { useLoadWorkspaces } from '$app/components/layout/workspace_manager/Workspace.hooks'; +import Workspace from './Workspace'; +import TrashButton from '$app/components/layout/workspace_manager/TrashButton'; +import { useAppSelector } from '@/appflowy_app/stores/store'; +import { LoginState } from '$app_reducers/current-user/slice'; +import { AFScroller } from '$app/components/_shared/scroller'; + +function WorkspaceManager() { + const { workspaces, currentWorkspace, initializeWorkspaces } = useLoadWorkspaces(); + + const loginState = useAppSelector((state) => state.currentUser.loginState); + + useEffect(() => { + if (loginState === LoginState.Success || loginState === undefined) { + void initializeWorkspaces(); + } + }, [initializeWorkspaces, loginState]); + + return ( +
+ +
+ {workspaces.map((workspace) => ( + + ))} +
+
+
+ +
+ {currentWorkspace && } +
+ ); +} + +export default WorkspaceManager; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/Login.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/Login.tsx new file mode 100644 index 0000000000000..d5ecc4bc0ce98 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/Login.tsx @@ -0,0 +1,22 @@ +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; +import Button from '@mui/material/Button'; +import { LoginButtonGroup } from '$app/components/auth/LoginButtonGroup'; + +export const Login = ({ onBack }: { onBack?: () => void }) => { + const { t } = useTranslation(); + + return ( +
+ + {t('button.login')} + +
+ + +
+
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/Settings.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/Settings.tsx new file mode 100644 index 0000000000000..1d9f3c0cd9dbe --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/Settings.tsx @@ -0,0 +1,92 @@ +import { useMemo, useState } from 'react'; +import { Box, Tab, Tabs } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { MyAccount } from '$app/components/settings/my_account'; +import { ReactComponent as AccountIcon } from '$app/assets/settings/account.svg'; +import { ReactComponent as WorkplaceIcon } from '$app/assets/settings/workplace.svg'; +import { Workplace } from '$app/components/settings/workplace'; +import { SettingsRoutes } from '$app/components/settings/workplace/const'; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +export const Settings = ({ onForward }: { onForward: (route: SettingsRoutes) => void }) => { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState(0); + + const tabOptions = useMemo(() => { + return [ + { + label: t('newSettings.myAccount.title'), + Icon: AccountIcon, + Component: MyAccount, + }, + { + label: t('newSettings.workplace.name'), + Icon: WorkplaceIcon, + Component: Workplace, + }, + ]; + }, [t]); + + const handleChangeTab = (event: React.SyntheticEvent, newValue: number) => { + setActiveTab(newValue); + }; + + return ( + + + {tabOptions.map((tab, index) => ( + + + {tab.label} +
+ } + onClick={() => setActiveTab(index)} + sx={{ '&.Mui-selected': { borderColor: 'transparent', backgroundColor: 'var(--fill-list-active)' } }} + /> + ))} + + {tabOptions.map((tab, index) => ( + + + + ))} + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/SettingsDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/SettingsDialog.tsx new file mode 100644 index 0000000000000..b53f8a6002d8a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/SettingsDialog.tsx @@ -0,0 +1,108 @@ +/** + * @figmaUrl https://www.figma.com/file/MF5CWlOzBRuGHp45zAXyUH/Appflowy%3A-Desktop-Settings?type=design&node-id=100%3A2119&mode=design&t=4Wb0Zg5NOFO36kOf-1 + */ + +import Dialog, { DialogProps } from '@mui/material/Dialog'; +import { Settings } from '$app/components/settings/Settings'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import DialogTitle from '@mui/material/DialogTitle'; +import { IconButton } from '@mui/material'; +import { ReactComponent as CloseIcon } from '$app/assets/close.svg'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as UpIcon } from '$app/assets/up.svg'; +import { SettingsRoutes } from '$app/components/settings/workplace/const'; +import DialogContent from '@mui/material/DialogContent'; +import { Login } from '$app/components/settings/Login'; +import SwipeableViews from 'react-swipeable-views'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { currentUserActions, LoginState } from '$app_reducers/current-user/slice'; + +export const SettingsDialog = (props: DialogProps) => { + const dispatch = useAppDispatch(); + const [routes, setRoutes] = useState([]); + const loginState = useAppSelector((state) => state.currentUser.loginState); + const lastLoginStateRef = useRef(loginState); + const { t } = useTranslation(); + const handleForward = useCallback((route: SettingsRoutes) => { + setRoutes((prev) => { + return [...prev, route]; + }); + }, []); + + const handleBack = useCallback(() => { + setRoutes((prevState) => { + return prevState.slice(0, -1); + }); + dispatch(currentUserActions.resetLoginState()); + }, [dispatch]); + + const handleClose = useCallback(() => { + dispatch(currentUserActions.resetLoginState()); + props?.onClose?.({}, 'backdropClick'); + }, [dispatch, props]); + + const currentRoute = routes[routes.length - 1]; + + useEffect(() => { + if (lastLoginStateRef.current === LoginState.Loading && loginState === LoginState.Success) { + handleClose(); + return; + } + + lastLoginStateRef.current = loginState; + }, [loginState, handleClose]); + + return ( + { + if (e.key === 'Escape') { + e.preventDefault(); + } + }} + scroll={'paper'} + > + + + + + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/settings/index.ts new file mode 100644 index 0000000000000..0f0a2c23f4e33 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/index.ts @@ -0,0 +1 @@ +export * from './my_account'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/AccountLogin.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/AccountLogin.tsx new file mode 100644 index 0000000000000..05b375c920936 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/AccountLogin.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import { Divider } from '@mui/material'; +import { DeleteAccount } from '$app/components/settings/my_account/DeleteAccount'; +import { SettingsRoutes } from '$app/components/settings/workplace/const'; +import { useAuth } from '$app/components/auth/auth.hooks'; + +export const AccountLogin = ({ onForward }: { onForward?: (route: SettingsRoutes) => void }) => { + const { t } = useTranslation(); + const { currentUser, logout } = useAuth(); + + const isLocal = currentUser.isLocal; + + return ( + <> +
+ + {t('newSettings.myAccount.accountLogin')} + + + + +
+ + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccount.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccount.tsx new file mode 100644 index 0000000000000..82a909180e23b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccount.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import { DeleteAccountDialog } from '$app/components/settings/my_account/DeleteAccountDialog'; + +export const DeleteAccount = () => { + const { t } = useTranslation(); + + const [openDialog, setOpenDialog] = useState(false); + + return ( +
+
+ + {t('newSettings.myAccount.deleteAccount.title')} + + + {t('newSettings.myAccount.deleteAccount.subtitle')} + +
+
+ +
+ { + setOpenDialog(false); + }} + /> +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccountDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccountDialog.tsx new file mode 100644 index 0000000000000..2f8cc37258ff1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/DeleteAccountDialog.tsx @@ -0,0 +1,50 @@ +import Dialog, { DialogProps } from '@mui/material/Dialog'; +import { useTranslation } from 'react-i18next'; +import DialogTitle from '@mui/material/DialogTitle'; +import { DialogActions, DialogContentText, IconButton } from '@mui/material'; +import Button from '@mui/material/Button'; +import DialogContent from '@mui/material/DialogContent'; +import { ReactComponent as CloseIcon } from '$app/assets/close.svg'; + +export const DeleteAccountDialog = (props: DialogProps) => { + const { t } = useTranslation(); + + const handleClose = () => { + props?.onClose?.({}, 'backdropClick'); + }; + + const handleOk = () => { + //123 + }; + + return ( + + {t('newSettings.myAccount.deleteAccount.dialogTitle')} + + {t('newSettings.myAccount.deleteAccount.dialogContent1')} + {t('newSettings.myAccount.deleteAccount.dialogContent2')} + + +
+ +
+
+ +
+
+ + + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/MyAccount.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/MyAccount.tsx new file mode 100644 index 0000000000000..b3a315994bd9c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/MyAccount.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import { Profile } from './Profile'; +import { AccountLogin } from './AccountLogin'; +import { Divider } from '@mui/material'; +import { SettingsRoutes } from '$app/components/settings/workplace/const'; + +export const MyAccount = ({ onForward }: { onForward?: (route: SettingsRoutes) => void }) => { + const { t } = useTranslation(); + + return ( +
+ + {t('newSettings.myAccount.title')} + + + {t('newSettings.myAccount.subtitle')} + + + + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/Profile.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/Profile.tsx new file mode 100644 index 0000000000000..2ac672b0e5483 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/Profile.tsx @@ -0,0 +1,180 @@ +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; +import { IconButton, InputAdornment, OutlinedInput } from '@mui/material'; +import { useAppSelector } from '$app/stores/store'; +import React, { useState } from 'react'; +import { ReactComponent as CheckIcon } from '$app/assets/select-check.svg'; +import { ReactComponent as CloseIcon } from '$app/assets/close.svg'; +import { ReactComponent as EditIcon } from '$app/assets/edit.svg'; + +import Tooltip from '@mui/material/Tooltip'; +import { UserService } from '$app/application/user/user.service'; +import { notify } from '$app/components/_shared/notify'; +import { ProfileAvatar } from '$app/components/_shared/avatar'; +import Popover from '@mui/material/Popover'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import EmojiPicker from '$app/components/_shared/emoji_picker/EmojiPicker'; +import Button from '@mui/material/Button'; + +export const Profile = () => { + const { displayName, id } = useAppSelector((state) => state.currentUser); + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useState(false); + const [newName, setNewName] = useState(displayName ?? 'Me'); + const [error, setError] = useState(false); + const [emojiPickerAnchor, setEmojiPickerAnchor] = useState(null); + const openEmojiPicker = Boolean(emojiPickerAnchor); + const handleSave = async () => { + setError(false); + if (!newName) { + setError(true); + return; + } + + if (newName === displayName) { + setIsEditing(false); + return; + } + + try { + await UserService.updateUserProfile({ + id, + name: newName, + }); + setIsEditing(false); + } catch { + setError(true); + notify.error(t('newSettings.myAccount.updateNameError')); + } + }; + + const handleEmojiSelect = async (emoji: string) => { + try { + await UserService.updateUserProfile({ + id, + icon_url: emoji, + }); + } catch { + notify.error(t('newSettings.myAccount.updateIconError')); + } + }; + + const handleCancel = () => { + setNewName(displayName ?? 'Me'); + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + void handleSave(); + } + + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + handleCancel(); + } + }; + + return ( +
+ + {t('newSettings.myAccount.profileLabel')} + +
+ + +
+ {isEditing ? ( + setNewName(e.target.value)} + spellCheck={false} + autoFocus={true} + autoCorrect={'off'} + autoCapitalize={'off'} + fullWidth + endAdornment={ + +
+ + + + + + + + + + +
+
+ } + sx={{ + '&.MuiOutlinedInput-root': { + borderRadius: '8px', + }, + }} + placeholder={t('newSettings.myAccount.profileNamePlaceholder')} + value={newName} + /> + ) : ( + + {newName} + + setIsEditing(true)} size={'small'} className={'ml-1'}> + + + + + )} +
+
+ {openEmojiPicker && ( + { + setEmojiPickerAnchor(null); + }} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + > + { + setEmojiPickerAnchor(null); + }} + onEmojiSelect={handleEmojiSelect} + /> + + )} +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/index.ts new file mode 100644 index 0000000000000..d923fcefce11a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/my_account/index.ts @@ -0,0 +1 @@ +export * from './MyAccount'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Appearance.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Appearance.tsx new file mode 100644 index 0000000000000..1dc8581dae184 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Appearance.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from 'react-i18next'; +import { ThemeModeSwitch } from '$app/components/settings/workplace/appearance/ThemeModeSwitch'; +import Typography from '@mui/material/Typography'; +import { Divider } from '@mui/material'; +import { LanguageSetting } from '$app/components/settings/workplace/appearance/LanguageSetting'; + +export const Appearance = () => { + const { t } = useTranslation(); + + return ( + <> + + {t('newSettings.workplace.appearance.name')} + + + + + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Workplace.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Workplace.tsx new file mode 100644 index 0000000000000..8af69eec51182 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/Workplace.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import { WorkplaceDisplay } from '$app/components/settings/workplace/WorkplaceDisplay'; +import { Divider } from '@mui/material'; +import { Appearance } from '$app/components/settings/workplace/Appearance'; + +export const Workplace = () => { + const { t } = useTranslation(); + + return ( +
+ + {t('newSettings.workplace.title')} + + + {t('newSettings.workplace.subtitle')} + + + + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/WorkplaceDisplay.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/WorkplaceDisplay.tsx new file mode 100644 index 0000000000000..3a71c5f07010e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/WorkplaceDisplay.tsx @@ -0,0 +1,155 @@ +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import { Divider, OutlinedInput } from '@mui/material'; +import React, { useMemo, useState } from 'react'; +import Button from '@mui/material/Button'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { changeWorkspaceIcon, renameWorkspace } from '$app/application/folder/workspace.service'; +import { notify } from '$app/components/_shared/notify'; +import { WorkplaceAvatar } from '$app/components/_shared/avatar'; +import Popover from '@mui/material/Popover'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import EmojiPicker from '$app/components/_shared/emoji_picker/EmojiPicker'; +import { workspaceActions } from '$app_reducers/workspace/slice'; +import debounce from 'lodash-es/debounce'; + +export const WorkplaceDisplay = () => { + const { t } = useTranslation(); + const isLocal = useAppSelector((state) => state.currentUser.isLocal); + const { workspaces, currentWorkspaceId } = useAppSelector((state) => state.workspace); + const workspace = useMemo( + () => workspaces.find((workspace) => workspace.id === currentWorkspaceId), + [workspaces, currentWorkspaceId] + ); + const [name, setName] = useState(workspace?.name ?? ''); + const [emojiPickerAnchor, setEmojiPickerAnchor] = useState(null); + const openEmojiPicker = Boolean(emojiPickerAnchor); + const dispatch = useAppDispatch(); + + const debounceUpdateWorkspace = useMemo(() => { + return debounce(async ({ id, name, icon }: { id: string; name?: string; icon?: string }) => { + if (!id || !name) return; + + if (icon) { + try { + await changeWorkspaceIcon(id, icon); + } catch { + notify.error(t('newSettings.workplace.updateIconError')); + } + } + + if (name) { + try { + await renameWorkspace(id, name); + } catch { + notify.error(t('newSettings.workplace.renameError')); + } + } + }, 500); + }, [t]); + + const handleSave = async () => { + if (!workspace || !name) return; + dispatch(workspaceActions.updateWorkspace({ id: workspace.id, name })); + + await debounceUpdateWorkspace({ id: workspace.id, name }); + }; + + const handleEmojiSelect = async (icon: string) => { + if (!workspace) return; + dispatch(workspaceActions.updateWorkspace({ id: workspace.id, icon })); + + await debounceUpdateWorkspace({ id: workspace.id, icon }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + void handleSave(); + } + }; + + return ( +
+ + {t('newSettings.workplace.workplaceName')} + +
+
+ setName(e.target.value)} + sx={{ + '&.MuiOutlinedInput-root': { + borderRadius: '8px', + }, + }} + placeholder={t('newSettings.workplace.workplaceNamePlaceholder')} + value={name} + /> +
+ +
+ + + {t('newSettings.workplace.workplaceIcon')} + + + {t('newSettings.workplace.workplaceIconSubtitle')} + + + {openEmojiPicker && ( + { + setEmojiPickerAnchor(null); + }} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + > + { + setEmojiPickerAnchor(null); + }} + onEmojiSelect={handleEmojiSelect} + /> + + )} +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/LanguageSetting.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/LanguageSetting.tsx new file mode 100644 index 0000000000000..41a42bd011abe --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/LanguageSetting.tsx @@ -0,0 +1,115 @@ +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import React, { useCallback } from 'react'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { currentUserActions } from '$app_reducers/current-user/slice'; +import { UserService } from '$app/application/user/user.service'; + +const languages = [ + { + key: 'ar-SA', + title: 'العربية', + }, + { key: 'ca-ES', title: 'Català' }, + { key: 'de-DE', title: 'Deutsch' }, + { key: 'en', title: 'English' }, + { key: 'es-VE', title: 'Español (Venezuela)' }, + { key: 'eu-ES', title: 'Español' }, + { key: 'fr-FR', title: 'Français' }, + { key: 'hu-HU', title: 'Magyar' }, + { key: 'id-ID', title: 'Bahasa Indonesia' }, + { key: 'it-IT', title: 'Italiano' }, + { key: 'ja-JP', title: '日本語' }, + { key: 'ko-KR', title: '한국어' }, + { key: 'pl-PL', title: 'Polski' }, + { key: 'pt-BR', title: 'Português' }, + { key: 'pt-PT', title: 'Português' }, + { key: 'ru-RU', title: 'Русский' }, + { key: 'sv', title: 'Svenska' }, + { key: 'th-TH', title: 'ไทย' }, + { key: 'tr-TR', title: 'Türkçe' }, + { key: 'zh-CN', title: '简体中文' }, + { key: 'zh-TW', title: '繁體中文' }, +]; + +export const LanguageSetting = () => { + const { t, i18n } = useTranslation(); + const userSettingState = useAppSelector((state) => state.currentUser.userSetting); + const dispatch = useAppDispatch(); + const selectedLanguage = userSettingState.language; + + const [hoverKey, setHoverKey] = React.useState(null); + + const handleChange = useCallback( + (language: string) => { + const newSetting = { ...userSettingState, language }; + + dispatch(currentUserActions.setUserSetting(newSetting)); + const newLanguage = newSetting.language || 'en'; + + void UserService.setAppearanceSetting({ + theme: newSetting.theme, + theme_mode: newSetting.themeMode, + locale: { + language_code: newLanguage.split('-')[0], + country_code: newLanguage.split('-')[1], + }, + }); + }, + [dispatch, userSettingState] + ); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + } + }, []); + + return ( + <> + + {t('newSettings.workplace.appearance.language')} + + + + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/ThemeModeSwitch.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/ThemeModeSwitch.tsx new file mode 100644 index 0000000000000..34fdb8e598a1b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/appearance/ThemeModeSwitch.tsx @@ -0,0 +1,93 @@ +import { useTranslation } from 'react-i18next'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { useCallback, useMemo } from 'react'; +import { ThemeModePB } from '@/services/backend'; +import darkSrc from '$app/assets/settings/dark.png'; +import lightSrc from '$app/assets/settings/light.png'; +import { currentUserActions, ThemeMode } from '$app_reducers/current-user/slice'; +import { UserService } from '$app/application/user/user.service'; +import { ReactComponent as CheckCircle } from '$app/assets/settings/check_circle.svg'; + +export const ThemeModeSwitch = () => { + const { t } = useTranslation(); + const userSettingState = useAppSelector((state) => state.currentUser.userSetting); + const dispatch = useAppDispatch(); + + const selectedMode = userSettingState.themeMode; + const themeModes = useMemo(() => { + return [ + { + name: t('newSettings.workplace.appearance.themeMode.auto'), + value: ThemeModePB.System, + img: ( +
+ + +
+ ), + }, + { + name: t('newSettings.workplace.appearance.themeMode.light'), + value: ThemeModePB.Light, + img: , + }, + { + name: t('newSettings.workplace.appearance.themeMode.dark'), + value: ThemeModePB.Dark, + img: , + }, + ]; + }, [t]); + + const handleChange = useCallback( + (newValue: ThemeModePB) => { + const newSetting = { + ...userSettingState, + ...{ + themeMode: newValue, + isDark: + newValue === ThemeMode.Dark || + (newValue === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches), + }, + }; + + dispatch(currentUserActions.setUserSetting(newSetting)); + + void UserService.setAppearanceSetting({ + theme_mode: newSetting.themeMode, + }); + }, + [dispatch, userSettingState] + ); + + const renderThemeModeItem = useCallback( + (option: { name: string; value: ThemeModePB; img: JSX.Element }) => { + return ( +
handleChange(option.value)} + className={'flex cursor-pointer flex-col items-center gap-2'} + > +
+ {option.img} + +
+
{option.name}
+
+ ); + }, + [handleChange, selectedMode] + ); + + return
{themeModes.map((mode) => renderThemeModeItem(mode))}
; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/const.ts b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/const.ts new file mode 100644 index 0000000000000..075e2744a5c42 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/const.ts @@ -0,0 +1,3 @@ +export enum SettingsRoutes { + LOGIN = 'login', +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/index.ts new file mode 100644 index 0000000000000..a64592ac8b737 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/settings/workplace/index.ts @@ -0,0 +1 @@ +export * from './Workplace'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts new file mode 100644 index 0000000000000..b6748614b8305 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAppDispatch, useAppSelector } from '@/appflowy_app/stores/store'; +import { trashActions, trashPBToTrash } from '$app_reducers/trash/slice'; +import { subscribeNotifications } from '$app/application/notification'; +import { FolderNotification } from '@/services/backend'; +import { deleteTrashItem, getTrash, putback, deleteAll, restoreAll } from '$app/application/folder/trash.service'; + +export function useLoadTrash() { + const trash = useAppSelector((state) => state.trash.list); + const dispatch = useAppDispatch(); + + const initializeTrash = useCallback(async () => { + const trash = await getTrash(); + + dispatch(trashActions.initTrash(trash.map(trashPBToTrash))); + }, [dispatch]); + + useEffect(() => { + void initializeTrash(); + }, [initializeTrash]); + + useEffect(() => { + const unsubscribePromise = subscribeNotifications({ + [FolderNotification.DidUpdateTrash]: async (changeset) => { + dispatch(trashActions.onTrashChanged(changeset.items.map(trashPBToTrash))); + }, + }); + + return () => { + void unsubscribePromise.then((fn) => fn()); + }; + }, [dispatch]); + + return { + trash, + }; +} + +export function useTrashActions() { + const [restoreAllDialogOpen, setRestoreAllDialogOpen] = useState(false); + const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const [deleteId, setDeleteId] = useState(''); + + const onClickRestoreAll = () => { + setRestoreAllDialogOpen(true); + }; + + const onClickDeleteAll = () => { + setDeleteAllDialogOpen(true); + }; + + const closeDialog = () => { + setRestoreAllDialogOpen(false); + setDeleteAllDialogOpen(false); + setDeleteDialogOpen(false); + }; + + const onClickDelete = (id: string) => { + setDeleteId(id); + setDeleteDialogOpen(true); + }; + + return { + onClickDelete, + deleteDialogOpen, + deleteId, + onPutback: putback, + onDelete: deleteTrashItem, + onDeleteAll: deleteAll, + onRestoreAll: restoreAll, + onClickRestoreAll, + onClickDeleteAll, + restoreAllDialogOpen, + deleteAllDialogOpen, + closeDialog, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx new file mode 100644 index 0000000000000..f10848dc9b35b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx @@ -0,0 +1,87 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import Button from '@mui/material/Button'; +import { DeleteOutline, RestoreOutlined } from '@mui/icons-material'; +import { useLoadTrash, useTrashActions } from '$app/components/trash/Trash.hooks'; +import { List } from '@mui/material'; +import TrashItem from '$app/components/trash/TrashItem'; +import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog'; + +function Trash() { + const { t } = useTranslation(); + const { trash } = useLoadTrash(); + const { + onPutback, + onDelete, + onClickRestoreAll, + onClickDeleteAll, + restoreAllDialogOpen, + deleteAllDialogOpen, + onRestoreAll, + onDeleteAll, + closeDialog, + deleteDialogOpen, + deleteId, + onClickDelete, + } = useTrashActions(); + const [hoverId, setHoverId] = useState(''); + + return ( +
+
+
{t('trash.text')}
+
+ + +
+
+
+
{t('trash.pageHeader.fileName')}
+
{t('trash.pageHeader.lastModified')}
+
{t('trash.pageHeader.created')}
+
+
+ + {trash.map((item) => ( + + ))} + + + + onDelete([deleteId])} + onClose={closeDialog} + /> +
+ ); +} + +export default Trash; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx new file mode 100644 index 0000000000000..d266005612d6b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import dayjs from 'dayjs'; +import { IconButton, ListItem } from '@mui/material'; +import { DeleteOutline, RestoreOutlined } from '@mui/icons-material'; +import Tooltip from '@mui/material/Tooltip'; +import { useTranslation } from 'react-i18next'; +import { Trash } from '$app_reducers/trash/slice'; + +function TrashItem({ + item, + hoverId, + setHoverId, + onDelete, + onPutback, +}: { + setHoverId: (id: string) => void; + item: Trash; + hoverId: string; + onPutback: (id: string) => void; + onDelete: (id: string) => void; +}) { + const { t } = useTranslation(); + + return ( + { + setHoverId(item.id); + }} + onMouseLeave={() => { + setHoverId(''); + }} + key={item.id} + style={{ + paddingInline: 0, + }} + > +
+
+ {item.name.trim() || t('menuAppHeader.defaultNewPageName')} +
+
{dayjs.unix(item.modifiedTime).format('MM/DD/YYYY hh:mm A')}
+
{dayjs.unix(item.createTime).format('MM/DD/YYYY hh:mm A')}
+
+ + onPutback(item.id)} className={'mr-2'}> + + + + + onDelete(item.id)}> + + + +
+
+
+ ); +} + +export default TrashItem; diff --git a/frontend/appflowy_tauri/src/appflowy_app/hooks/ViewId.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/hooks/ViewId.hooks.ts new file mode 100644 index 0000000000000..6711ece8c862e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/hooks/ViewId.hooks.ts @@ -0,0 +1,6 @@ +import { createContext, useContext } from 'react'; + +const ViewIdContext = createContext(''); + +export const ViewIdProvider = ViewIdContext.Provider; +export const useViewId = () => useContext(ViewIdContext); diff --git a/frontend/appflowy_tauri/src/appflowy_app/hooks/index.ts b/frontend/appflowy_tauri/src/appflowy_app/hooks/index.ts new file mode 100644 index 0000000000000..c29ddd04aa3ac --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './notification.hooks'; +export * from './ViewId.hooks'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts new file mode 100644 index 0000000000000..f8669852d3aaa --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts @@ -0,0 +1,20 @@ +/* eslint-disable no-redeclare */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useEffect } from 'react'; +import { NotificationEnum, NotificationHandler, subscribeNotification } from '$app/application/notification'; + +export function useNotification( + notification: K, + callback: NotificationHandler, + options: { id?: string } +): void { + const { id } = options; + + useEffect(() => { + const unsubscribePromise = subscribeNotification(notification, callback, { id }); + + return () => { + void unsubscribePromise.then((fn) => fn()); + }; + }, [callback, id, notification]); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/hooks/page.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/hooks/page.hooks.tsx new file mode 100644 index 0000000000000..49e01e75c0a76 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/hooks/page.hooks.tsx @@ -0,0 +1,26 @@ +import { ViewLayoutPB } from '@/services/backend'; +import React from 'react'; +import { Page } from '$app_reducers/pages/slice'; +import { ReactComponent as DocumentIcon } from '$app/assets/document.svg'; +import { ReactComponent as GridIcon } from '$app/assets/grid.svg'; +import { ReactComponent as BoardIcon } from '$app/assets/board.svg'; +import { ReactComponent as CalendarIcon } from '$app/assets/date.svg'; + +export function getPageIcon(page: Page) { + if (page.icon) { + return page.icon.value; + } + + switch (page.layout) { + case ViewLayoutPB.Document: + return ; + case ViewLayoutPB.Grid: + return ; + case ViewLayoutPB.Board: + return ; + case ViewLayoutPB.Calendar: + return ; + default: + return null; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/i18n/config.ts b/frontend/appflowy_tauri/src/appflowy_app/i18n/config.ts new file mode 100644 index 0000000000000..d36dba3bb20b4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/i18n/config.ts @@ -0,0 +1,15 @@ +import i18next from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import { initReactI18next } from 'react-i18next'; +import resourcesToBackend from 'i18next-resources-to-backend'; + +void i18next + .use(resourcesToBackend((language: string) => import(`./translations/${language}.json`))) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + lng: 'en', + defaultNS: 'translation', + debug: false, + fallbackLng: 'en', + }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/slate-editor.d.ts b/frontend/appflowy_tauri/src/appflowy_app/slate-editor.d.ts new file mode 100644 index 0000000000000..26b713e3335c9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/slate-editor.d.ts @@ -0,0 +1,44 @@ +import { ReactEditor } from 'slate-react'; + +interface EditorInlineAttributes { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + font_color?: string; + bg_color?: string; + href?: string; + code?: boolean; + formula?: string; + prism_token?: string; + class_name?: string; + mention?: { + type: string; + // inline page ref id + page?: string; + // reminder date ref id + date?: string; + }; +} + +type CustomElement = { + children: (CustomText | CustomElement)[]; + type: string; + data?: unknown; + blockId?: string; + textId?: string; +}; + +type CustomText = { text: string } & EditorInlineAttributes; + +declare module 'slate' { + interface CustomTypes { + Editor: BaseEditor & ReactEditor; + Element: CustomElement; + Text: CustomText; + } + + interface BaseEditor { + isEmbed: (element: CustomElement) => boolean; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts new file mode 100644 index 0000000000000..464b7428a3398 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts @@ -0,0 +1,99 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { WorkspaceSettingPB } from '@/services/backend/models/flowy-folder/workspace'; +import { ThemeModePB as ThemeMode } from '@/services/backend'; +import { Page, parserViewPBToPage } from '$app_reducers/pages/slice'; + +export { ThemeMode }; + +export interface UserSetting { + theme?: Theme; + themeMode?: ThemeMode; + language?: string; + isDark?: boolean; +} + +export enum Theme { + Default = 'default', + Dandelion = 'dandelion', + Lavender = 'lavender', +} + +export enum LoginState { + Loading = 'loading', + Success = 'success', + Error = 'error', +} + +export interface UserWorkspaceSetting { + workspaceId: string; + latestView?: Page; + hasLatestView: boolean; +} + +export function parseWorkspaceSettingPBToSetting(workspaceSetting: WorkspaceSettingPB): UserWorkspaceSetting { + return { + workspaceId: workspaceSetting.workspace_id, + latestView: workspaceSetting.latest_view ? parserViewPBToPage(workspaceSetting.latest_view) : undefined, + hasLatestView: !!workspaceSetting.latest_view, + }; +} + +export interface ICurrentUser { + id?: number; + deviceId?: string; + displayName?: string; + email?: string; + token?: string; + iconUrl?: string; + isAuthenticated: boolean; + workspaceSetting?: UserWorkspaceSetting; + userSetting: UserSetting; + isLocal: boolean; + loginState?: LoginState; +} + +const initialState: ICurrentUser | null = { + isAuthenticated: false, + userSetting: {}, + isLocal: true, +}; + +export const currentUserSlice = createSlice({ + name: 'currentUser', + initialState: initialState, + reducers: { + updateUser: (state, action: PayloadAction>) => { + return { + ...state, + ...action.payload, + loginState: LoginState.Success, + }; + }, + logout: () => { + return initialState; + }, + setUserSetting: (state, action: PayloadAction>) => { + state.userSetting = { + ...state.userSetting, + ...action.payload, + }; + }, + + setLoginState: (state, action: PayloadAction) => { + state.loginState = action.payload; + }, + + resetLoginState: (state) => { + state.loginState = undefined; + }, + + setLatestView: (state, action: PayloadAction) => { + if (state.workspaceSetting) { + state.workspaceSetting.latestView = action.payload; + state.workspaceSetting.hasLatestView = true; + } + }, + }, +}); + +export const currentUserActions = currentUserSlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/error/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/error/slice.ts new file mode 100644 index 0000000000000..9b47df7777485 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/error/slice.ts @@ -0,0 +1,32 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface IErrorOptions { + display: boolean; + message: string; +} + +const initialState: IErrorOptions = { + display: false, + message: '', +}; + +export const errorSlice = createSlice({ + name: 'error', + initialState: initialState, + reducers: { + showError(state, action: PayloadAction) { + return { + display: true, + message: action.payload, + }; + }, + hideError() { + return { + display: false, + message: '', + }; + }, + }, +}); + +export const errorActions = errorSlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts new file mode 100644 index 0000000000000..90014c1e7f4cf --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts @@ -0,0 +1,106 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { RootState } from '$app/stores/store'; +import { pagesActions } from '$app_reducers/pages/slice'; +import { movePage, setLatestOpenedPage, updatePage } from '$app/application/folder/page.service'; +import debounce from 'lodash-es/debounce'; +import { currentUserActions } from '$app_reducers/current-user/slice'; + +export const movePageThunk = createAsyncThunk( + 'pages/movePage', + async ( + payload: { + sourceId: string; + targetId: string; + insertType: 'before' | 'after' | 'inside'; + }, + thunkAPI + ) => { + const { sourceId, targetId, insertType } = payload; + const { getState, dispatch } = thunkAPI; + const { pageMap, relationMap } = (getState() as RootState).pages; + const sourcePage = pageMap[sourceId]; + const targetPage = pageMap[targetId]; + + if (!sourcePage || !targetPage) return; + const sourceParentId = sourcePage.parentId; + const targetParentId = targetPage.parentId; + + if (!sourceParentId || !targetParentId) return; + + const targetParentChildren = relationMap[targetParentId] || []; + const targetIndex = targetParentChildren.indexOf(targetId); + + if (targetIndex < 0) return; + + let prevId, parentId; + + if (insertType === 'before') { + const prevIndex = targetIndex - 1; + + parentId = targetParentId; + if (prevIndex >= 0) { + prevId = targetParentChildren[prevIndex]; + } + } else if (insertType === 'after') { + prevId = targetId; + parentId = targetParentId; + } else { + const targetChildren = relationMap[targetId] || []; + + parentId = targetId; + if (targetChildren.length > 0) { + prevId = targetChildren[targetChildren.length - 1]; + } + } + + dispatch(pagesActions.movePage({ id: sourceId, newParentId: parentId, prevId })); + + await movePage({ + view_id: sourceId, + new_parent_id: parentId, + prev_view_id: prevId, + }); + } +); + +const debounceUpdateName = debounce(updatePage, 1000); + +export const updatePageName = createAsyncThunk( + 'pages/updateName', + async (payload: { id: string; name: string; immediate?: boolean }, thunkAPI) => { + const { dispatch, getState } = thunkAPI; + const { pageMap } = (getState() as RootState).pages; + const { id, name, immediate } = payload; + const page = pageMap[id]; + + if (name === page.name) return; + + dispatch( + pagesActions.onPageChanged({ + ...page, + name, + }) + ); + + if (immediate) { + await updatePage({ id, name }); + } else { + await debounceUpdateName({ + id, + name, + }); + } + } +); + +export const openPage = createAsyncThunk('pages/openPage', async (id: string, thunkAPI) => { + const { dispatch, getState } = thunkAPI; + const { pageMap } = (getState() as RootState).pages; + + const page = pageMap[id]; + + if (!page) return; + + dispatch(currentUserActions.setLatestView(page)); + await setLatestOpenedPage(id); +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts new file mode 100644 index 0000000000000..dbf313ecc1c80 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts @@ -0,0 +1,223 @@ +import { ViewIconTypePB, ViewLayoutPB, ViewPB } from '@/services/backend'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import isEqual from 'lodash-es/isEqual'; +import { ImageType } from '$app/application/document/document.types'; +import { Nullable } from 'unsplash-js/dist/helpers/typescript'; + +export const pageTypeMap = { + [ViewLayoutPB.Document]: 'document', + [ViewLayoutPB.Board]: 'board', + [ViewLayoutPB.Grid]: 'grid', + [ViewLayoutPB.Calendar]: 'calendar', +}; +export interface Page { + id: string; + parentId: string; + name: string; + layout: ViewLayoutPB; + icon?: PageIcon; + cover?: PageCover; +} + +export interface PageIcon { + ty: ViewIconTypePB; + value: string; +} + +export enum CoverType { + Color = 'CoverType.color', + Image = 'CoverType.file', + Asset = 'CoverType.asset', +} +export type PageCover = Nullable<{ + image_type?: ImageType; + cover_selection_type?: CoverType; + cover_selection?: string; +}>; + +export function parserViewPBToPage(view: ViewPB): Page { + const icon = view.icon; + + return { + id: view.id, + name: view.name, + parentId: view.parent_view_id, + layout: view.layout, + icon: icon + ? { + ty: icon.ty, + value: icon.value, + } + : undefined, + }; +} + +export interface PageState { + pageMap: Record; + relationMap: Record; + expandedIdMap: Record; + showTrashSnackbar: boolean; +} + +export const initialState: PageState = { + pageMap: {}, + relationMap: {}, + expandedIdMap: getExpandedPageIds().reduce((acc, id) => { + acc[id] = true; + return acc; + }, {} as Record), + showTrashSnackbar: false, +}; + +export const pagesSlice = createSlice({ + name: 'pages', + initialState, + reducers: { + addChildPages( + state, + action: PayloadAction<{ + childPages: Page[]; + id: string; + }> + ) { + const { childPages, id } = action.payload; + const pageMap: Record = {}; + + const children: string[] = []; + + childPages.forEach((page) => { + pageMap[page.id] = page; + children.push(page.id); + }); + + state.pageMap = { + ...state.pageMap, + ...pageMap, + }; + state.relationMap[id] = children; + }, + + onPageChanged(state, action: PayloadAction) { + const page = action.payload; + + if (!isEqual(state.pageMap[page.id], page)) { + state.pageMap[page.id] = page; + } + }, + + addPage( + state, + action: PayloadAction<{ + page: Page; + isLast?: boolean; + prevId?: string; + }> + ) { + const { page, prevId, isLast } = action.payload; + + state.pageMap[page.id] = page; + state.relationMap[page.id] = []; + + const parentId = page.parentId; + + if (isLast) { + state.relationMap[parentId]?.push(page.id); + } else { + const index = prevId ? state.relationMap[parentId]?.indexOf(prevId) ?? -1 : -1; + + state.relationMap[parentId]?.splice(index + 1, 0, page.id); + } + }, + + deletePages(state, action: PayloadAction) { + const ids = action.payload; + + ids.forEach((id) => { + const parentId = state.pageMap[id].parentId; + const parentChildren = state.relationMap[parentId]; + + state.relationMap[parentId] = parentChildren && parentChildren.filter((childId) => childId !== id); + delete state.relationMap[id]; + delete state.expandedIdMap[id]; + delete state.pageMap[id]; + }); + }, + + duplicatePage( + state, + action: PayloadAction<{ + id: string; + newId: string; + }> + ) { + const { id, newId } = action.payload; + const page = state.pageMap[id]; + const newPage = { ...page, id: newId }; + + state.pageMap[newPage.id] = newPage; + + const index = state.relationMap[page.parentId]?.indexOf(id); + + state.relationMap[page.parentId]?.splice(index ?? 0, 0, newId); + }, + + movePage( + state, + action: PayloadAction<{ + id: string; + newParentId: string; + prevId?: string; + }> + ) { + const { id, newParentId, prevId } = action.payload; + const parentId = state.pageMap[id].parentId; + const parentChildren = state.relationMap[parentId]; + + const index = parentChildren?.indexOf(id) ?? -1; + + if (index > -1) { + state.relationMap[parentId]?.splice(index, 1); + } + + state.pageMap[id].parentId = newParentId; + const newParentChildren = state.relationMap[newParentId] || []; + const prevIndex = prevId ? newParentChildren.indexOf(prevId) : -1; + + state.relationMap[newParentId]?.splice(prevIndex + 1, 0, id); + }, + + expandPage(state, action: PayloadAction) { + const id = action.payload; + + state.expandedIdMap[id] = true; + const ids = Object.keys(state.expandedIdMap).filter((id) => state.expandedIdMap[id]); + + storeExpandedPageIds(ids); + }, + + collapsePage(state, action: PayloadAction) { + const id = action.payload; + + state.expandedIdMap[id] = false; + const ids = Object.keys(state.expandedIdMap).filter((id) => state.expandedIdMap[id]); + + storeExpandedPageIds(ids); + }, + + setTrashSnackbar(state, action: PayloadAction) { + state.showTrashSnackbar = action.payload; + }, + }, +}); + +export const pagesActions = pagesSlice.actions; + +function storeExpandedPageIds(expandedPageIds: string[]) { + localStorage.setItem('expandedPageIds', JSON.stringify(expandedPageIds)); +} + +function getExpandedPageIds(): string[] { + const expandedPageIds = localStorage.getItem('expandedPageIds'); + + return expandedPageIds ? JSON.parse(expandedPageIds) : []; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts new file mode 100644 index 0000000000000..fae1d59214984 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts @@ -0,0 +1,37 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface SidebarState { + isCollapsed: boolean; + width: number; + isResizing: boolean; +} + +const initialState: SidebarState = { + isCollapsed: false, + width: 250, + isResizing: false, +}; + +export const sidebarSlice = createSlice({ + name: 'sidebar', + initialState: initialState, + reducers: { + toggleCollapse(state) { + state.isCollapsed = !state.isCollapsed; + }, + setCollapse(state, action: PayloadAction) { + state.isCollapsed = action.payload; + }, + changeWidth(state, action: PayloadAction) { + state.width = action.payload; + }, + startResizing(state) { + state.isResizing = true; + }, + stopResizing(state) { + state.isResizing = false; + }, + }, +}); + +export const sidebarActions = sidebarSlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/trash/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/trash/slice.ts new file mode 100644 index 0000000000000..98d850f6fe2dc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/trash/slice.ts @@ -0,0 +1,41 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { TrashPB } from '@/services/backend'; + +export interface Trash { + id: string; + name: string; + modifiedTime: number; + createTime: number; +} + +export function trashPBToTrash(trash: TrashPB) { + return { + id: trash.id, + name: trash.name, + modifiedTime: trash.modified_time, + createTime: trash.create_time, + }; +} + +interface TrashState { + list: Trash[]; +} + +const initialState: TrashState = { + list: [], +}; + +export const trashSlice = createSlice({ + name: 'trash', + initialState, + reducers: { + initTrash: (state, action: PayloadAction) => { + state.list = action.payload; + }, + onTrashChanged: (state, action: PayloadAction) => { + state.list = action.payload; + }, + }, +}); + +export const trashActions = trashSlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts new file mode 100644 index 0000000000000..d071de846e91d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts @@ -0,0 +1,46 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface WorkspaceItem { + id: string; + name: string; + icon?: string; +} + +interface WorkspaceState { + workspaces: WorkspaceItem[]; + currentWorkspaceId: string | null; +} + +const initialState: WorkspaceState = { + workspaces: [], + currentWorkspaceId: null, +}; + +export const workspaceSlice = createSlice({ + name: 'workspace', + initialState, + reducers: { + initWorkspaces: ( + state, + action: PayloadAction<{ + workspaces: WorkspaceItem[]; + currentWorkspaceId: string | null; + }> + ) => { + return action.payload; + }, + + updateWorkspace: (state, action: PayloadAction>) => { + const index = state.workspaces.findIndex((workspace) => workspace.id === action.payload.id); + + if (index !== -1) { + state.workspaces[index] = { + ...state.workspaces[index], + ...action.payload, + }; + } + }, + }, +}); + +export const workspaceActions = workspaceSlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts new file mode 100644 index 0000000000000..269f46884c9fc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts @@ -0,0 +1,51 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import { + configureStore, + createListenerMiddleware, + TypedStartListening, + TypedAddListener, + ListenerEffectAPI, + addListener, +} from '@reduxjs/toolkit'; +import { pagesSlice } from './reducers/pages/slice'; +import { currentUserSlice } from './reducers/current-user/slice'; +import { workspaceSlice } from './reducers/workspace/slice'; +import { errorSlice } from './reducers/error/slice'; +import { sidebarSlice } from '$app_reducers/sidebar/slice'; +import { trashSlice } from '$app_reducers/trash/slice'; + +const listenerMiddlewareInstance = createListenerMiddleware({ + onError: () => console.error, +}); + +const store = configureStore({ + reducer: { + [pagesSlice.name]: pagesSlice.reducer, + [currentUserSlice.name]: currentUserSlice.reducer, + [workspaceSlice.name]: workspaceSlice.reducer, + [errorSlice.name]: errorSlice.reducer, + [sidebarSlice.name]: sidebarSlice.reducer, + [trashSlice.name]: trashSlice.reducer, + }, + middleware: (gDM) => gDM({ serializableCheck: false }).prepend(listenerMiddlewareInstance.middleware), +}); + +export { store }; + +// Infer the `RootState` and `AppDispatch` types from the store itself +export type RootState = ReturnType; +// @see https://redux-toolkit.js.org/usage/usage-with-typescript#getting-the-dispatch-type +export type AppDispatch = typeof store.dispatch; + +export type AppListenerEffectAPI = ListenerEffectAPI; + +// @see https://redux-toolkit.js.org/api/createListenerMiddleware#typescript-usage +export type AppStartListening = TypedStartListening; +export type AppAddListener = TypedAddListener; + +export const startAppListening = listenerMiddlewareInstance.startListening as AppStartListening; +export const addAppListener = addListener as AppAddListener; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/async_queue.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/async_queue.ts new file mode 100644 index 0000000000000..7e673506de0bb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/async_queue.ts @@ -0,0 +1,51 @@ +import { Log } from '$app/utils/log'; + +export class AsyncQueue { + private queue: T[] = []; + private isProcessing = false; + private executeFunction: (item: T) => Promise; + + constructor(executeFunction: (item: T) => Promise) { + this.executeFunction = executeFunction; + } + + enqueue(item: T): void { + this.queue.push(item); + this.processQueue(); + } + + private processQueue(): void { + if (this.isProcessing || this.queue.length === 0) { + return; + } + + const item = this.queue.shift(); + + if (!item) { + return; + } + + this.isProcessing = true; + + const executeFn = async (item: T) => { + try { + await this.processItem(item); + } catch (error) { + Log.error('queue processing error:', error); + } finally { + this.isProcessing = false; + this.processQueue(); + } + }; + + void executeFn(item); + } + + private async processItem(item: T): Promise { + try { + await this.executeFunction(item); + } catch (error) { + Log.error('queue processing error:', error); + } + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/avatar.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/avatar.ts new file mode 100644 index 0000000000000..a9a752c57965d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/avatar.ts @@ -0,0 +1,26 @@ +export function stringToColor(string: string) { + let hash = 0; + let i; + + /* eslint-disable no-bitwise */ + for (i = 0; i < string.length; i += 1) { + hash = string.charCodeAt(i) + ((hash << 5) - hash); + } + + let color = '#'; + + for (i = 0; i < 3; i += 1) { + const value = (hash >> (i * 8)) & 0xff; + + color += `00${value.toString(16)}`.slice(-2); + } + /* eslint-enable no-bitwise */ + + return color; +} + +export function stringToShortName(string: string) { + const [firstName, lastName = ''] = string.split(' '); + + return `${firstName.charAt(0)}${lastName.charAt(0)}`; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/change_notifier.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/change_notifier.ts new file mode 100644 index 0000000000000..57d9f2a3704cc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/change_notifier.ts @@ -0,0 +1,30 @@ +import { Observable, Subject } from 'rxjs'; + +export class ChangeNotifier { + private isUnsubscribe = false; + private subject = new Subject(); + + notify(value: T) { + this.subject.next(value); + } + + get observer(): Observable | null { + if (this.isUnsubscribe) { + return null; + } + + return this.subject.asObservable(); + } + + // Unsubscribe the subject might cause [UnsubscribedError] error if there is + // ongoing Observable execution. + // + // Maybe you should use the [Subscription] that returned when call subscribe on + // [Observable] to unsubscribe. + unsubscribe = () => { + if (!this.isUnsubscribe) { + this.isUnsubscribe = true; + this.subject.unsubscribe(); + } + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/color.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/color.ts new file mode 100644 index 0000000000000..025c8c45ed324 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/color.ts @@ -0,0 +1,50 @@ +export enum ColorEnum { + Purple = 'appflowy_them_color_tint1', + Pink = 'appflowy_them_color_tint2', + LightPink = 'appflowy_them_color_tint3', + Orange = 'appflowy_them_color_tint4', + Yellow = 'appflowy_them_color_tint5', + Lime = 'appflowy_them_color_tint6', + Green = 'appflowy_them_color_tint7', + Aqua = 'appflowy_them_color_tint8', + Blue = 'appflowy_them_color_tint9', +} + +export const colorMap = { + [ColorEnum.Purple]: 'var(--tint-purple)', + [ColorEnum.Pink]: 'var(--tint-pink)', + [ColorEnum.LightPink]: 'var(--tint-red)', + [ColorEnum.Orange]: 'var(--tint-orange)', + [ColorEnum.Yellow]: 'var(--tint-yellow)', + [ColorEnum.Lime]: 'var(--tint-lime)', + [ColorEnum.Green]: 'var(--tint-green)', + [ColorEnum.Aqua]: 'var(--tint-aqua)', + [ColorEnum.Blue]: 'var(--tint-blue)', +}; + +// Convert ARGB to RGBA +// Flutter uses ARGB, but CSS uses RGBA +function argbToRgba(color: string): string { + const hex = color.replace(/^#|0x/, ''); + + const hasAlpha = hex.length === 8; + + if (!hasAlpha) { + return color.replace('0x', '#'); + } + + const r = parseInt(hex.slice(2, 4), 16); + const g = parseInt(hex.slice(4, 6), 16); + const b = parseInt(hex.slice(6, 8), 16); + const a = hasAlpha ? parseInt(hex.slice(0, 2), 16) / 255 : 1; + + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + +export function renderColor(color: string) { + if (colorMap[color as ColorEnum]) { + return colorMap[color as ColorEnum]; + } + + return argbToRgba(color); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/emoji.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/emoji.ts new file mode 100644 index 0000000000000..8d5adb5df6339 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/emoji.ts @@ -0,0 +1,9 @@ +import emojiData, { EmojiMartData } from '@emoji-mart/data'; + +export const randomEmoji = (skin = 0) => { + const emojis = (emojiData as EmojiMartData).emojis; + const keys = Object.keys(emojis); + const randomKey = keys[Math.floor(Math.random() * keys.length)]; + + return emojis[randomKey].skins[skin].native; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/env.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/env.ts new file mode 100644 index 0000000000000..064dc042aa7d3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/env.ts @@ -0,0 +1,11 @@ +export function isApple() { + return typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent); +} + +export function isTauri() { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const isTauri = window.__TAURI__; + + return isTauri; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts new file mode 100644 index 0000000000000..20aa05db27039 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts @@ -0,0 +1,134 @@ +import isHotkey from 'is-hotkey'; + +export const isMac = () => { + return navigator.userAgent.includes('Mac OS X'); +}; + +const MODIFIERS = { + control: 'Ctrl', + meta: '⌘', +}; + +export const getModifier = () => { + return isMac() ? MODIFIERS.meta : MODIFIERS.control; +}; + +export enum HOT_KEY_NAME { + LEFT = 'left', + RIGHT = 'right', + SELECT_ALL = 'select-all', + ESCAPE = 'escape', + ALIGN_LEFT = 'align-left', + ALIGN_CENTER = 'align-center', + ALIGN_RIGHT = 'align-right', + BOLD = 'bold', + ITALIC = 'italic', + UNDERLINE = 'underline', + STRIKETHROUGH = 'strikethrough', + CODE = 'code', + TOGGLE_TODO = 'toggle-todo', + TOGGLE_COLLAPSE = 'toggle-collapse', + INDENT_BLOCK = 'indent-block', + OUTDENT_BLOCK = 'outdent-block', + INSERT_SOFT_BREAK = 'insert-soft-break', + SPLIT_BLOCK = 'split-block', + BACKSPACE = 'backspace', + OPEN_LINK = 'open-link', + OPEN_LINKS = 'open-links', + EXTEND_LINE_BACKWARD = 'extend-line-backward', + EXTEND_LINE_FORWARD = 'extend-line-forward', + PASTE = 'paste', + PASTE_PLAIN_TEXT = 'paste-plain-text', + HIGH_LIGHT = 'high-light', + EXTEND_DOCUMENT_BACKWARD = 'extend-document-backward', + EXTEND_DOCUMENT_FORWARD = 'extend-document-forward', + SCROLL_TO_TOP = 'scroll-to-top', + SCROLL_TO_BOTTOM = 'scroll-to-bottom', + FORMAT_LINK = 'format-link', + FIND_REPLACE = 'find-replace', + /** + * Navigation + */ + TOGGLE_THEME = 'toggle-theme', + TOGGLE_SIDEBAR = 'toggle-sidebar', +} + +const defaultHotKeys = { + [HOT_KEY_NAME.ALIGN_LEFT]: ['control+shift+l'], + [HOT_KEY_NAME.ALIGN_CENTER]: ['control+shift+e'], + [HOT_KEY_NAME.ALIGN_RIGHT]: ['control+shift+r'], + [HOT_KEY_NAME.BOLD]: ['mod+b'], + [HOT_KEY_NAME.ITALIC]: ['mod+i'], + [HOT_KEY_NAME.UNDERLINE]: ['mod+u'], + [HOT_KEY_NAME.STRIKETHROUGH]: ['mod+shift+s', 'mod+shift+x'], + [HOT_KEY_NAME.CODE]: ['mod+e'], + [HOT_KEY_NAME.TOGGLE_TODO]: ['mod+enter'], + [HOT_KEY_NAME.TOGGLE_COLLAPSE]: ['mod+enter'], + [HOT_KEY_NAME.SELECT_ALL]: ['mod+a'], + [HOT_KEY_NAME.ESCAPE]: ['esc'], + [HOT_KEY_NAME.INDENT_BLOCK]: ['tab'], + [HOT_KEY_NAME.OUTDENT_BLOCK]: ['shift+tab'], + [HOT_KEY_NAME.SPLIT_BLOCK]: ['enter'], + [HOT_KEY_NAME.INSERT_SOFT_BREAK]: ['shift+enter'], + [HOT_KEY_NAME.BACKSPACE]: ['backspace', 'shift+backspace'], + [HOT_KEY_NAME.OPEN_LINK]: ['opt+enter'], + [HOT_KEY_NAME.OPEN_LINKS]: ['opt+shift+enter'], + [HOT_KEY_NAME.EXTEND_LINE_BACKWARD]: ['opt+shift+left'], + [HOT_KEY_NAME.EXTEND_LINE_FORWARD]: ['opt+shift+right'], + [HOT_KEY_NAME.PASTE]: ['mod+v'], + [HOT_KEY_NAME.PASTE_PLAIN_TEXT]: ['mod+shift+v'], + [HOT_KEY_NAME.HIGH_LIGHT]: ['mod+shift+h'], + [HOT_KEY_NAME.EXTEND_DOCUMENT_BACKWARD]: ['mod+shift+up'], + [HOT_KEY_NAME.EXTEND_DOCUMENT_FORWARD]: ['mod+shift+down'], + [HOT_KEY_NAME.SCROLL_TO_TOP]: ['home'], + [HOT_KEY_NAME.SCROLL_TO_BOTTOM]: ['end'], + [HOT_KEY_NAME.TOGGLE_THEME]: ['mod+shift+l'], + [HOT_KEY_NAME.TOGGLE_SIDEBAR]: ['mod+.'], + [HOT_KEY_NAME.FORMAT_LINK]: ['mod+k'], + [HOT_KEY_NAME.LEFT]: ['left'], + [HOT_KEY_NAME.RIGHT]: ['right'], + [HOT_KEY_NAME.FIND_REPLACE]: ['mod+f'], +}; + +const replaceModifier = (hotkey: string) => { + return hotkey.replace('mod', getModifier()).replace('control', 'ctrl'); +}; + +/** + * Create a hotkey checker. + * @example trigger strike through when user press "Cmd + Shift + S" or "Cmd + Shift + X" + * @param hotkeyName + * @param customHotKeys + */ +export const createHotkey = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record) => { + const keys = customHotKeys || defaultHotKeys; + const hotkeys = keys[hotkeyName]; + + return (event: KeyboardEvent) => { + return hotkeys.some((hotkey) => { + return isHotkey(hotkey, event); + }); + }; +}; + +/** + * Create a hotkey label. + * eg. "Ctrl + B / ⌘ + B" + * @param hotkeyName + * @param customHotKeys + */ +export const createHotKeyLabel = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record) => { + const keys = customHotKeys || defaultHotKeys; + const hotkeys = keys[hotkeyName].map((key) => replaceModifier(key)); + + return hotkeys + .map((hotkey) => + hotkey + .split('+') + .map((key) => { + return key === ' ' ? 'Space' : key.charAt(0).toUpperCase() + key.slice(1); + }) + .join(' + ') + ) + .join(' / '); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/list.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/list.ts new file mode 100644 index 0000000000000..6e5d22ccda741 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/list.ts @@ -0,0 +1,45 @@ +const romanMap: [number, string][] = [ + [1000, 'M'], + [900, 'CM'], + [500, 'D'], + [400, 'CD'], + [100, 'C'], + [90, 'XC'], + [50, 'L'], + [40, 'XL'], + [10, 'X'], + [9, 'IX'], + [5, 'V'], + [4, 'IV'], + [1, 'I'], +]; + +export function romanize(num: number): string { + let result = ''; + let nextNum = num; + + for (const [value, symbol] of romanMap) { + const count = Math.floor(nextNum / value); + + nextNum -= value * count; + result += symbol.repeat(count); + if (nextNum === 0) break; + } + + return result; +} + +export function letterize(num: number): string { + let nextNum = num; + let letters = ''; + + while (nextNum > 0) { + nextNum--; + const letter = String.fromCharCode((nextNum % 26) + 'a'.charCodeAt(0)); + + letters = letter + letters; + nextNum = Math.floor(nextNum / 26); + } + + return letters; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/log.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/log.ts new file mode 100644 index 0000000000000..daccf21d0af7e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/log.ts @@ -0,0 +1,20 @@ +export class Log { + static error(...msg: unknown[]) { + console.error(...msg); + } + static info(...msg: unknown[]) { + console.info(...msg); + } + + static debug(...msg: unknown[]) { + console.debug(...msg); + } + + static trace(...msg: unknown[]) { + console.trace(...msg); + } + + static warn(...msg: unknown[]) { + console.warn(...msg); + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/mui.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/mui.ts new file mode 100644 index 0000000000000..94e2cf94d5000 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/mui.ts @@ -0,0 +1,168 @@ +import { ThemeOptions } from '@mui/material'; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +export const getDesignTokens = (isDark: boolean): ThemeOptions => { + return { + typography: { + fontFamily: ['Poppins'].join(','), + fontSize: 12, + button: { + textTransform: 'none', + }, + }, + components: { + MuiMenuItem: { + defaultProps: { + sx: { + '&.Mui-selected.Mui-focusVisible': { + backgroundColor: 'var(--fill-list-hover)', + }, + '&.Mui-focusVisible': { + backgroundColor: 'unset', + }, + }, + }, + }, + MuiIconButton: { + styleOverrides: { + root: { + '&:hover': { + backgroundColor: 'var(--fill-list-hover)', + }, + borderRadius: '4px', + padding: '2px', + }, + }, + }, + MuiButton: { + styleOverrides: { + contained: { + color: 'var(--content-on-fill)', + boxShadow: 'var(--shadow)', + }, + containedPrimary: { + '&:hover': { + backgroundColor: 'var(--fill-default)', + }, + }, + containedInherit: { + color: 'var(--text-title)', + backgroundColor: isDark ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.4)', + '&:hover': { + backgroundColor: 'var(--bg-body)', + boxShadow: 'var(--shadow)', + }, + }, + outlinedInherit: { + color: 'var(--text-title)', + borderColor: 'var(--line-border)', + '&:hover': { + boxShadow: 'var(--shadow)', + }, + }, + }, + }, + MuiButtonBase: { + defaultProps: { + sx: { + '&.Mui-selected:hover': { + backgroundColor: 'var(--fill-list-hover)', + }, + }, + }, + styleOverrides: { + root: { + '&:hover': { + backgroundColor: 'var(--fill-list-hover)', + }, + '&:active': { + backgroundColor: 'var(--fill-list-hover)', + }, + borderRadius: '4px', + padding: '2px', + boxShadow: 'none', + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: 'none', + }, + }, + }, + MuiDialog: { + defaultProps: { + sx: { + '& .MuiBackdrop-root': { + backgroundColor: 'var(--bg-mask)', + }, + }, + }, + }, + + MuiTooltip: { + styleOverrides: { + arrow: { + color: 'var(--bg-tips)', + }, + tooltip: { + backgroundColor: 'var(--bg-tips)', + color: 'var(--text-title)', + fontSize: '0.85rem', + borderRadius: '8px', + fontWeight: 400, + }, + }, + }, + MuiInputBase: { + styleOverrides: { + input: { + backgroundColor: 'transparent !important', + }, + }, + }, + MuiDivider: { + styleOverrides: { + root: { + borderColor: 'var(--line-divider)', + }, + }, + }, + }, + palette: { + mode: isDark ? 'dark' : 'light', + primary: { + main: '#00BCF0', + dark: '#00BCF0', + }, + error: { + main: '#FB006D', + dark: '#D32772', + }, + warning: { + main: '#FFC107', + dark: '#E9B320', + }, + info: { + main: '#00BCF0', + dark: '#2E9DBB', + }, + success: { + main: '#66CF80', + dark: '#3BA856', + }, + text: { + primary: isDark ? '#E2E9F2' : '#333333', + secondary: isDark ? '#7B8A9D' : '#828282', + disabled: isDark ? '#363D49' : '#F2F2F2', + }, + divider: isDark ? '#59647A' : '#BDBDBD', + background: { + default: isDark ? '#1A202C' : '#FFFFFF', + paper: isDark ? '#1A202C' : '#FFFFFF', + }, + }, + }; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts new file mode 100644 index 0000000000000..d854be521162f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts @@ -0,0 +1,23 @@ +import { open as openWindow } from '@tauri-apps/api/shell'; + +const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})(\S*)*\/?(\?[=&\w.%-]*)?(#[\w.\-!~*'()]*)?$/; +const ipPattern = /^(https?:\/\/)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(:\d{1,5})?$/; + +export function isUrl(str: string) { + return urlPattern.test(str) || ipPattern.test(str); +} + +export function openUrl(str: string) { + if (isUrl(str)) { + const linkPrefix = ['http://', 'https://', 'file://', 'ftp://', 'ftps://', 'mailto:']; + + if (linkPrefix.some((prefix) => str.startsWith(prefix))) { + void openWindow(str); + } else { + void openWindow('https://' + str); + } + } else { + // open google search + void openWindow('https://www.google.com/search?q=' + encodeURIComponent(str)); + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts new file mode 100644 index 0000000000000..afcd7a32b4ba3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/tool.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Creates an interval that repeatedly calls the given function with a specified delay. + * + * @param {Function} fn - The function to be called repeatedly. + * @param {number} [delay] - The delay between function calls in milliseconds. + * @param {Object} [options] - Additional options for the interval. + * @param {boolean} [options.immediate] - Whether to immediately call the function when the interval is created. Default is true. + * + * @return {Function} - The function that runs the interval. + * @return {Function.cancel} - A method to cancel the interval. + * + * @example + * const log = interval((message) => console.log(message), 1000); + * + * log('foo'); // prints 'foo' every second. + * + * log('bar'); // change to prints 'bar' every second. + * + * log.cancel(); // stops the interval. + */ +export function interval any = (...args: any[]) => any>( + fn: T, + delay?: number, + options?: { immediate?: boolean } +): T & { cancel: () => void } { + const { immediate = true } = options || {}; + let intervalId: NodeJS.Timer | null = null; + let parameters: any[] = []; + + function run(...args: Parameters) { + parameters = args; + + if (intervalId !== null) { + return; + } + + immediate && fn.apply(undefined, parameters); + intervalId = setInterval(() => { + fn.apply(undefined, parameters); + }, delay); + } + + function cancel() { + if (intervalId === null) { + return; + } + + clearInterval(intervalId); + intervalId = null; + parameters = []; + } + + run.cancel = cancel; + return run as T & { cancel: () => void }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/upload_image.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/upload_image.ts new file mode 100644 index 0000000000000..22213ac8b37be --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/upload_image.ts @@ -0,0 +1,9 @@ +export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB +export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp']; +export const IMAGE_DIR = 'images'; + +export function getFileName(url: string) { + const [...parts] = url.split('/'); + + return parts.pop() ?? url; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx b/frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx new file mode 100644 index 0000000000000..004fc4355bd95 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/views/DatabasePage.tsx @@ -0,0 +1,24 @@ +import { useParams } from 'react-router-dom'; +import { ViewIdProvider } from '$app/hooks'; +import { Database, DatabaseTitle, useSelectDatabaseView } from '../components/database'; + +export const DatabasePage = () => { + const viewId = useParams().id; + + const { selectedViewId, onChange } = useSelectDatabaseView({ + viewId, + }); + + if (!viewId) { + return null; + } + + return ( +
+ + + + +
+ ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx new file mode 100644 index 0000000000000..03ba493c10550 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { Document } from '$app/components/document'; + +function DocumentPage() { + const params = useParams(); + + const documentId = params.id; + + if (!documentId) return null; + return ; +} + +export default DocumentPage; diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/TrashPage.tsx b/frontend/appflowy_tauri/src/appflowy_app/views/TrashPage.tsx new file mode 100644 index 0000000000000..78baa9872d543 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/views/TrashPage.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Trash from '$app/components/trash/Trash'; + +function TrashPage() { + return ( +
+ +
+ ); +} + +export default TrashPage; diff --git a/frontend/appflowy_tauri/src/appflowy_app/vite-env.d.ts b/frontend/appflowy_tauri/src/appflowy_app/vite-env.d.ts new file mode 100644 index 0000000000000..b1f45c7866694 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/frontend/appflowy_tauri/src/main.tsx b/frontend/appflowy_tauri/src/main.tsx new file mode 100644 index 0000000000000..e53dc96c435e9 --- /dev/null +++ b/frontend/appflowy_tauri/src/main.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './appflowy_app/App'; +import './styles/tailwind.css'; +import './styles/font.css'; +import './styles/template.css'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(); diff --git a/frontend/appflowy_tauri/src/services/backend/index.ts b/frontend/appflowy_tauri/src/services/backend/index.ts new file mode 100644 index 0000000000000..3e02ff7183a73 --- /dev/null +++ b/frontend/appflowy_tauri/src/services/backend/index.ts @@ -0,0 +1,9 @@ +export * from "./models/flowy-user"; +export * from "./models/flowy-database2"; +export * from "./models/flowy-folder"; +export * from "./models/flowy-document"; +export * from "./models/flowy-error"; +export * from "./models/flowy-config"; +export * from "./models/flowy-date"; +export * from "./models/flowy-search"; +export * from "./models/flowy-storage"; diff --git a/frontend/appflowy_tauri/src/styles/font.css b/frontend/appflowy_tauri/src/styles/font.css new file mode 100644 index 0000000000000..514b5a6e38929 --- /dev/null +++ b/frontend/appflowy_tauri/src/styles/font.css @@ -0,0 +1,125 @@ +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-Thin.ttf') format('truetype'); + font-weight: 100; + font-style: normal; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-ThinItalic.ttf') format('truetype'); + font-weight: 100; + font-style: italic; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-ExtraLight.ttf') format('truetype'); + font-weight: 200; + font-style: normal; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-ExtraLightItalic.ttf') format('truetype'); + font-weight: 200; + font-style: italic; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-Light.ttf') format('truetype'); + font-weight: 300; + font-style: normal; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-LightItalic.ttf') format('truetype'); + font-weight: 300; + font-style: italic; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-Italic.ttf') format('truetype'); + font-weight: 400; + font-style: italic; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-Medium.ttf') format('truetype'); + font-weight: 500; + font-style: normal; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-MediumItalic.ttf') format('truetype'); + font-weight: 500; + font-style: italic; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-SemiBold.ttf') format('truetype'); + font-weight: 600; + font-style: normal; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-SemiBoldItalic.ttf') format('truetype'); + font-weight: 600; + font-style: italic; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-Bold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-BoldItalic.ttf') format('truetype'); + font-weight: 700; + font-style: italic; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-ExtraBold.ttf') format('truetype'); + font-weight: 800; + font-style: normal; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-ExtraBoldItalic.ttf') format('truetype'); + font-weight: 800; + font-style: italic; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-Black.ttf') format('truetype'); + font-weight: 900; + font-style: normal; +} + +@font-face { + font-family: 'Poppins'; + src: url('/google_fonts/Poppins/Poppins-BlackItalic.ttf') format('truetype'); + font-weight: 900; + font-style: italic; +} diff --git a/frontend/appflowy_tauri/src/styles/tailwind.css b/frontend/appflowy_tauri/src/styles/tailwind.css new file mode 100644 index 0000000000000..b5c61c956711f --- /dev/null +++ b/frontend/appflowy_tauri/src/styles/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/frontend/appflowy_tauri/src/styles/template.css b/frontend/appflowy_tauri/src/styles/template.css new file mode 100644 index 0000000000000..1bff6bdc76bc8 --- /dev/null +++ b/frontend/appflowy_tauri/src/styles/template.css @@ -0,0 +1,60 @@ +@import './variables/index.css'; + +* { + margin: 0; + padding: 0; +} + +/* stop body from scrolling */ +html, +body { + margin: 0; + height: 100%; + overflow: hidden; +} +[contenteditable] { + -webkit-tap-highlight-color: transparent; +} +input, +textarea { + outline: 0; + background: transparent; +} + +body { + font-family: Poppins, serif; +} + +::-webkit-scrollbar { + width: 8px; +} + + + +:root[data-dark-mode=true] body { + scrollbar-color: #fff var(--bg-body); +} + +body { + scrollbar-track-color: var(--bg-body); + scrollbar-shadow-color: var(--bg-body); +} + + +.btn { + @apply rounded-xl border border-line-divider px-4 py-3; +} + +.btn-primary { + @apply bg-fill-default text-text-title hover:bg-fill-list-hover; +} + +.input { + @apply rounded-xl border border-line-divider px-[18px] py-[14px] text-sm; +} + + +th { + @apply text-left font-normal; +} + diff --git a/frontend/appflowy_tauri/src/styles/variables/dark.variables.css b/frontend/appflowy_tauri/src/styles/variables/dark.variables.css new file mode 100644 index 0000000000000..ca7544687b4bb --- /dev/null +++ b/frontend/appflowy_tauri/src/styles/variables/dark.variables.css @@ -0,0 +1,121 @@ +/** +* Do not edit directly +* Generated on Tue, 19 Mar 2024 03:48:58 GMT +* Generated from $pnpm css:variables +*/ + +:root[data-dark-mode=true] { + --base-light-neutral-50: #f9fafd; + --base-light-neutral-100: #edeef2; + --base-light-neutral-200: #e2e4eb; + --base-light-neutral-300: #f2f2f2; + --base-light-neutral-400: #e0e0e0; + --base-light-neutral-500: #bdbdbd; + --base-light-neutral-600: #828282; + --base-light-neutral-700: #4f4f4f; + --base-light-neutral-800: #333333; + --base-light-neutral-900: #1f2329; + --base-light-neutral-1000: #000000; + --base-light-neutral-00: #ffffff; + --base-light-blue-50: #f2fcff; + --base-light-blue-100: #e0f8ff; + --base-light-blue-200: #a6ecff; + --base-light-blue-300: #52d1f4; + --base-light-blue-400: #00bcf0; + --base-light-blue-500: #05ade2; + --base-light-blue-600: #009fd1; + --base-light-color-deep-red: #fb006d; + --base-light-color-deep-yellow: #ffd667; + --base-light-color-deep-green: #66cf80; + --base-light-color-deep-blue: #00bcf0; + --base-light-color-light-purple: #e8e0ff; + --base-light-color-light-pink: #ffe7ee; + --base-light-color-light-orange: #ffefe3; + --base-light-color-light-yellow: #fff2cd; + --base-light-color-light-lime: #f5ffdc; + --base-light-color-light-green: #ddffd6; + --base-light-color-light-aqua: #defff1; + --base-light-color-light-blue: #e1fbff; + --base-light-color-light-red: #ffdddd; + --base-black-neutral-100: #252F41; + --base-black-neutral-200: #313c51; + --base-black-neutral-300: #3c4557; + --base-black-neutral-400: #525A69; + --base-black-neutral-500: #59647a; + --base-black-neutral-600: #87A0BF; + --base-black-neutral-700: #99a6b8; + --base-black-neutral-800: #e2e9f2; + --base-black-neutral-900: #eff4fb; + --base-black-neutral-1000: #ffffff; + --base-black-neutral-n50: #232b38; + --base-black-neutral-n00: #1a202c; + --base-black-blue-50: #232b38; + --base-black-blue-100: #005174; + --base-black-blue-200: #a6ecff; + --base-black-blue-300: #52d1f4; + --base-black-blue-400: #00bcf0; + --base-black-blue-500: #05ade2; + --base-black-blue-600: #009fd1; + --base-black-color-deep-red: #d32772; + --base-black-color-deep-yellow: #e9b320; + --base-black-color-deep-green: #3ba856; + --base-black-color-deep-blue: #2e9dbb; + --base-black-color-light-purple: #4D4078; + --base-black-color-light-blue: #2C3B58; + --base-black-color-light-green: #3C5133; + --base-black-color-light-yellow: #695E3E; + --base-black-color-light-pink: #5E3C5E; + --base-black-color-light-red: #56363F; + --base-black-color-light-aqua: #1B3849; + --base-black-color-light-lime: #394027; + --base-black-color-light-orange: #5E3C3C; + --base-else-brand: #2c144b; + --text-title: #e2e9f2; + --text-caption: #87A0BF; + --text-placeholder: #3c4557; + --text-link-default: #00bcf0; + --text-link-hover: #52d1f4; + --text-link-pressed: #009fd1; + --text-link-disabled: #005174; + --icon-primary: #e2e9f2; + --icon-secondary: #59647a; + --icon-disabled: #525A69; + --icon-on-toolbar: white; + --line-border: #59647a; + --line-divider: #252F41; + --line-on-toolbar: #99a6b8; + --fill-default: #00bcf0; + --fill-hover: #005174; + --fill-toolbar: #0F111C; + --fill-selector: #232b38; + --fill-list-active: #3c4557; + --fill-list-hover: #005174; + --content-blue-400: #00bcf0; + --content-blue-300: #52d1f4; + --content-blue-600: #009fd1; + --content-blue-100: #005174; + --content-on-fill: #1a202c; + --content-on-tag: #99a6b8; + --content-blue-50: #232b38; + --bg-body: #1a202c; + --bg-base: #232b38; + --bg-mask: rgba(0,0,0,0.7); + --bg-tips: #005174; + --bg-brand: #2c144b; + --function-error: #d32772; + --function-warning: #e9b320; + --function-success: #3ba856; + --function-info: #2e9dbb; + --tint-red: #56363F; + --tint-green: #3C5133; + --tint-purple: #4D4078; + --tint-blue: #2C3B58; + --tint-yellow: #695E3E; + --tint-pink: #5E3C5E; + --tint-lime: #394027; + --tint-aqua: #1B3849; + --tint-orange: #5E3C3C; + --shadow: 0px 0px 25px 0px rgba(0,0,0,0.3); + --scrollbar-track: #252F41; + --scrollbar-thumb: #3c4557; +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/styles/variables/index.css b/frontend/appflowy_tauri/src/styles/variables/index.css new file mode 100644 index 0000000000000..08d6a948f1808 --- /dev/null +++ b/frontend/appflowy_tauri/src/styles/variables/index.css @@ -0,0 +1,7 @@ +@import "./light.variables.css"; +@import "./dark.variables.css"; + +:root { + /* resize popover shadow */ + --shadow-resize-popover: 0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12); +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/styles/variables/light.variables.css b/frontend/appflowy_tauri/src/styles/variables/light.variables.css new file mode 100644 index 0000000000000..26acc76f0a788 --- /dev/null +++ b/frontend/appflowy_tauri/src/styles/variables/light.variables.css @@ -0,0 +1,124 @@ +/** +* Do not edit directly +* Generated on Tue, 19 Mar 2024 03:48:58 GMT +* Generated from $pnpm css:variables +*/ + +:root { + --base-light-neutral-50: #f9fafd; + --base-light-neutral-100: #edeef2; + --base-light-neutral-200: #e2e4eb; + --base-light-neutral-300: #f2f2f2; + --base-light-neutral-400: #e0e0e0; + --base-light-neutral-500: #bdbdbd; + --base-light-neutral-600: #828282; + --base-light-neutral-700: #4f4f4f; + --base-light-neutral-800: #333333; + --base-light-neutral-900: #1f2329; + --base-light-neutral-1000: #000000; + --base-light-neutral-00: #ffffff; + --base-light-blue-50: #f2fcff; + --base-light-blue-100: #e0f8ff; + --base-light-blue-200: #a6ecff; + --base-light-blue-300: #52d1f4; + --base-light-blue-400: #00bcf0; + --base-light-blue-500: #05ade2; + --base-light-blue-600: #009fd1; + --base-light-color-deep-red: #fb006d; + --base-light-color-deep-yellow: #ffd667; + --base-light-color-deep-green: #66cf80; + --base-light-color-deep-blue: #00bcf0; + --base-light-color-light-purple: #e8e0ff; + --base-light-color-light-pink: #ffe7ee; + --base-light-color-light-orange: #ffefe3; + --base-light-color-light-yellow: #fff2cd; + --base-light-color-light-lime: #f5ffdc; + --base-light-color-light-green: #ddffd6; + --base-light-color-light-aqua: #defff1; + --base-light-color-light-blue: #e1fbff; + --base-light-color-light-red: #ffdddd; + --base-black-neutral-100: #252F41; + --base-black-neutral-200: #313c51; + --base-black-neutral-300: #3c4557; + --base-black-neutral-400: #525A69; + --base-black-neutral-500: #59647a; + --base-black-neutral-600: #87A0BF; + --base-black-neutral-700: #99a6b8; + --base-black-neutral-800: #e2e9f2; + --base-black-neutral-900: #eff4fb; + --base-black-neutral-1000: #ffffff; + --base-black-neutral-n50: #232b38; + --base-black-neutral-n00: #1a202c; + --base-black-blue-50: #232b38; + --base-black-blue-100: #005174; + --base-black-blue-200: #a6ecff; + --base-black-blue-300: #52d1f4; + --base-black-blue-400: #00bcf0; + --base-black-blue-500: #05ade2; + --base-black-blue-600: #009fd1; + --base-black-color-deep-red: #d32772; + --base-black-color-deep-yellow: #e9b320; + --base-black-color-deep-green: #3ba856; + --base-black-color-deep-blue: #2e9dbb; + --base-black-color-light-purple: #4D4078; + --base-black-color-light-blue: #2C3B58; + --base-black-color-light-green: #3C5133; + --base-black-color-light-yellow: #695E3E; + --base-black-color-light-pink: #5E3C5E; + --base-black-color-light-red: #56363F; + --base-black-color-light-aqua: #1B3849; + --base-black-color-light-lime: #394027; + --base-black-color-light-orange: #5E3C3C; + --base-else-brand: #2c144b; + --text-title: #333333; + --text-caption: #828282; + --text-placeholder: #bdbdbd; + --text-disabled: #e0e0e0; + --text-link-default: #00bcf0; + --text-link-hover: #52d1f4; + --text-link-pressed: #009fd1; + --text-link-disabled: #e0f8ff; + --icon-primary: #333333; + --icon-secondary: #59647a; + --icon-disabled: #e0e0e0; + --icon-on-toolbar: #ffffff; + --line-border: #bdbdbd; + --line-divider: #edeef2; + --line-on-toolbar: #4f4f4f; + --fill-toolbar: #333333; + --fill-default: #00bcf0; + --fill-hover: #52d1f4; + --fill-pressed: #009fd1; + --fill-active: #e0f8ff; + --fill-list-hover: #e0f8ff; + --fill-list-active: #edeef2; + --content-blue-400: #00bcf0; + --content-blue-300: #52d1f4; + --content-blue-600: #009fd1; + --content-blue-100: #e0f8ff; + --content-blue-50: #f2fcff; + --content-on-fill-hover: #00bcf0; + --content-on-fill: #ffffff; + --content-on-tag: #4f4f4f; + --bg-body: #ffffff; + --bg-base: #f9fafd; + --bg-mask: rgba(0,0,0,0.55); + --bg-tips: #e0f8ff; + --bg-brand: #2c144b; + --function-error: #fb006d; + --function-waring: #ffd667; + --function-success: #66cf80; + --function-info: #00bcf0; + --tint-purple: #e8e0ff; + --tint-pink: #ffe7ee; + --tint-red: #ffdddd; + --tint-lime: #f5ffdc; + --tint-green: #ddffd6; + --tint-aqua: #defff1; + --tint-blue: #e1fbff; + --tint-orange: #ffefe3; + --tint-yellow: #fff2cd; + --shadow: 0px 0px 10px 0px rgba(0,0,0,0.1); + --scrollbar-thumb: #bdbdbd; + --scrollbar-track: #edeef2; +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/tests/helpers/init.ts b/frontend/appflowy_tauri/src/tests/helpers/init.ts new file mode 100644 index 0000000000000..cb0ff5c3b541f --- /dev/null +++ b/frontend/appflowy_tauri/src/tests/helpers/init.ts @@ -0,0 +1 @@ +export {}; diff --git a/frontend/appflowy_tauri/style-dictionary/config.cjs b/frontend/appflowy_tauri/style-dictionary/config.cjs new file mode 100644 index 0000000000000..10d7084060925 --- /dev/null +++ b/frontend/appflowy_tauri/style-dictionary/config.cjs @@ -0,0 +1,114 @@ +const StyleDictionary = require('style-dictionary'); +const fs = require('fs'); +const path = require('path'); + +// Add comment header to generated files +StyleDictionary.registerFormat({ + name: 'css/variables', + formatter: function(dictionary, config) { + const header = `/**\n` + '* Do not edit directly\n' + `* Generated on ${new Date().toUTCString()}\n` + `* Generated from $pnpm css:variables \n` + `*/\n\n`; + const allProperties = dictionary.allProperties; + const properties = allProperties.map(prop => { + const { name, value } = prop; + return ` --${name}: ${value};` + }).join('\n'); + // generate tailwind config + generateTailwindConfig(allProperties); + return header + `:root${this.selector} {\n${properties}\n}` + } +}); + +// expand shadow tokens into a single string +StyleDictionary.registerTransform({ + name: 'shadow/spreadShadow', + type: 'value', + matcher: function (prop) { + return prop.type === 'boxShadow'; + }, + transformer: function (prop) { + // destructure shadow values from original token value + const { x, y, blur, spread, color } = prop.original.value; + + return `${x}px ${y}px ${blur}px ${spread}px ${color}`; + }, +}); + +const transforms = ['attribute/cti', 'name/cti/kebab', 'shadow/spreadShadow']; + +// Generate Light CSS variables +StyleDictionary.extend({ + source: ['./style-dictionary/tokens/base.json', './style-dictionary/tokens/light.json'], + platforms: { + css: { + transformGroup: 'css', + buildPath: './src/styles/variables/', + files: [ + { + format: 'css/variables', + destination: 'light.variables.css', + selector: '', + options: { + outputReferences: true + } + }, + ], + transforms, + }, + }, +}).buildAllPlatforms(); + +// Generate Dark CSS variables +StyleDictionary.extend({ + source: ['./style-dictionary/tokens/base.json', './style-dictionary/tokens/dark.json'], + platforms: { + css: { + transformGroup: 'css', + buildPath: './src/styles/variables/', + files: [ + { + format: 'css/variables', + destination: 'dark.variables.css', + selector: '[data-dark-mode=true]', + }, + ], + transforms, + }, + }, +}).buildAllPlatforms(); + + +function set(obj, path, value) { + const lastKey = path.pop(); + const lastObj = path.reduce((obj, key) => + obj[key] = obj[key] || {}, + obj); + lastObj[lastKey] = value; +} + +function writeFile (file, data) { + const header = `/**\n` + '* Do not edit directly\n' + `* Generated on ${new Date().toUTCString()}\n` + `* Generated from $pnpm css:variables \n` + `*/\n\n`; + const exportString = `module.exports = ${JSON.stringify(data, null, 2)}`; + fs.writeFileSync(path.join(__dirname, file), header + exportString); +} + +function generateTailwindConfig(allProperties) { + const tailwindColors = {}; + const tailwindBoxShadow = {}; + allProperties.forEach(prop => { + const { path, type, name, value } = prop; + if (path[0] === 'Base') { + return; + } + if (type === 'color') { + if (name.includes('fill')) { + console.log(prop); + } + set(tailwindColors, path, `var(--${name})`); + } + if (type === 'boxShadow') { + set(tailwindBoxShadow, ['md'], `var(--${name})`); + } + }); + writeFile('./tailwind/colors.cjs', tailwindColors); + writeFile('./tailwind/box-shadow.cjs', tailwindBoxShadow); +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/style-dictionary/tailwind/box-shadow.cjs b/frontend/appflowy_tauri/style-dictionary/tailwind/box-shadow.cjs new file mode 100644 index 0000000000000..e9d8024320c17 --- /dev/null +++ b/frontend/appflowy_tauri/style-dictionary/tailwind/box-shadow.cjs @@ -0,0 +1,9 @@ +/** +* Do not edit directly +* Generated on Tue, 19 Mar 2024 03:48:58 GMT +* Generated from $pnpm css:variables +*/ + +module.exports = { + "md": "var(--shadow)" +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/style-dictionary/tailwind/colors.cjs b/frontend/appflowy_tauri/style-dictionary/tailwind/colors.cjs new file mode 100644 index 0000000000000..bfa25fa56f485 --- /dev/null +++ b/frontend/appflowy_tauri/style-dictionary/tailwind/colors.cjs @@ -0,0 +1,75 @@ +/** +* Do not edit directly +* Generated on Tue, 19 Mar 2024 03:48:58 GMT +* Generated from $pnpm css:variables +*/ + +module.exports = { + "text": { + "title": "var(--text-title)", + "caption": "var(--text-caption)", + "placeholder": "var(--text-placeholder)", + "link-default": "var(--text-link-default)", + "link-hover": "var(--text-link-hover)", + "link-pressed": "var(--text-link-pressed)", + "link-disabled": "var(--text-link-disabled)" + }, + "icon": { + "primary": "var(--icon-primary)", + "secondary": "var(--icon-secondary)", + "disabled": "var(--icon-disabled)", + "on-toolbar": "var(--icon-on-toolbar)" + }, + "line": { + "border": "var(--line-border)", + "divider": "var(--line-divider)", + "on-toolbar": "var(--line-on-toolbar)" + }, + "fill": { + "default": "var(--fill-default)", + "hover": "var(--fill-hover)", + "toolbar": "var(--fill-toolbar)", + "selector": "var(--fill-selector)", + "list": { + "active": "var(--fill-list-active)", + "hover": "var(--fill-list-hover)" + } + }, + "content": { + "blue-400": "var(--content-blue-400)", + "blue-300": "var(--content-blue-300)", + "blue-600": "var(--content-blue-600)", + "blue-100": "var(--content-blue-100)", + "on-fill": "var(--content-on-fill)", + "on-tag": "var(--content-on-tag)", + "blue-50": "var(--content-blue-50)" + }, + "bg": { + "body": "var(--bg-body)", + "base": "var(--bg-base)", + "mask": "var(--bg-mask)", + "tips": "var(--bg-tips)", + "brand": "var(--bg-brand)" + }, + "function": { + "error": "var(--function-error)", + "warning": "var(--function-warning)", + "success": "var(--function-success)", + "info": "var(--function-info)" + }, + "tint": { + "red": "var(--tint-red)", + "green": "var(--tint-green)", + "purple": "var(--tint-purple)", + "blue": "var(--tint-blue)", + "yellow": "var(--tint-yellow)", + "pink": "var(--tint-pink)", + "lime": "var(--tint-lime)", + "aqua": "var(--tint-aqua)", + "orange": "var(--tint-orange)" + }, + "scrollbar": { + "track": "var(--scrollbar-track)", + "thumb": "var(--scrollbar-thumb)" + } +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/style-dictionary/tokens/base.json b/frontend/appflowy_tauri/style-dictionary/tokens/base.json new file mode 100644 index 0000000000000..fb58a867b1ab6 --- /dev/null +++ b/frontend/appflowy_tauri/style-dictionary/tokens/base.json @@ -0,0 +1,290 @@ +{ + "Base": { + "Light": { + "neutral": { + "50": { + "value": "#f9fafd", + "type": "color" + }, + "100": { + "value": "#dadbdd", + "type": "color" + }, + "200": { + "value": "#e2e4eb", + "type": "color" + }, + "300": { + "value": "#f2f2f2", + "type": "color" + }, + "400": { + "value": "#e0e0e0", + "type": "color" + }, + "500": { + "value": "#bdbdbd", + "type": "color" + }, + "600": { + "value": "#828282", + "type": "color" + }, + "700": { + "value": "#4f4f4f", + "type": "color" + }, + "800": { + "value": "#333333", + "type": "color" + }, + "900": { + "value": "#1f2329", + "type": "color" + }, + "1000": { + "value": "#000000", + "type": "color" + }, + "00": { + "value": "#ffffff", + "type": "color" + } + }, + "blue": { + "50": { + "value": "#f2fcff", + "type": "color" + }, + "100": { + "value": "#e0f8ff", + "type": "color" + }, + "200": { + "value": "#a6ecff", + "type": "color" + }, + "300": { + "value": "#52d1f4", + "type": "color" + }, + "400": { + "value": "#00bcf0", + "type": "color" + }, + "500": { + "value": "#05ade2", + "type": "color" + }, + "600": { + "value": "#009fd1", + "type": "color" + } + }, + "color": { + "deep": { + "red": { + "value": "#fb006d", + "type": "color" + }, + "yellow": { + "value": "#ffd667", + "type": "color" + }, + "green": { + "value": "#66cf80", + "type": "color" + }, + "blue": { + "value": "#00bcf0", + "type": "color" + } + }, + "light": { + "purple": { + "value": "#e8e0ff", + "type": "color" + }, + "pink": { + "value": "#ffe7ee", + "type": "color" + }, + "orange": { + "value": "#ffefe3", + "type": "color" + }, + "yellow": { + "value": "#fff2cd", + "type": "color" + }, + "lime": { + "value": "#f5ffdc", + "type": "color" + }, + "green": { + "value": "#ddffd6", + "type": "color" + }, + "aqua": { + "value": "#defff1", + "type": "color" + }, + "blue": { + "value": "#e1fbff", + "type": "color" + }, + "red": { + "value": "#ffdddd", + "type": "color" + } + } + } + }, + "black": { + "neutral": { + "100": { + "value": "#252F41", + "type": "color" + }, + "200": { + "value": "#313c51", + "type": "color" + }, + "300": { + "value": "#3c4557", + "type": "color" + }, + "400": { + "value": "#525A69", + "type": "color" + }, + "500": { + "value": "#59647a", + "type": "color" + }, + "600": { + "value": "#87A0BF", + "type": "color" + }, + "700": { + "value": "#99a6b8", + "type": "color" + }, + "800": { + "value": "#e2e9f2", + "type": "color" + }, + "900": { + "value": "#eff4fb", + "type": "color" + }, + "1000": { + "value": "#ffffff", + "type": "color" + }, + "N50": { + "value": "#232b38", + "type": "color" + }, + "N00": { + "value": "#1a202c", + "type": "color" + } + }, + "blue": { + "50": { + "value": "#232b38", + "type": "color" + }, + "100": { + "value": "#005174", + "type": "color" + }, + "200": { + "value": "#a6ecff", + "type": "color" + }, + "300": { + "value": "#52d1f4", + "type": "color" + }, + "400": { + "value": "#00bcf0", + "type": "color" + }, + "500": { + "value": "#05ade2", + "type": "color" + }, + "600": { + "value": "#009fd1", + "type": "color" + } + }, + "color": { + "deep": { + "red": { + "value": "#d32772", + "type": "color" + }, + "yellow": { + "value": "#e9b320", + "type": "color" + }, + "green": { + "value": "#3ba856", + "type": "color" + }, + "blue": { + "value": "#2e9dbb", + "type": "color" + } + }, + "light": { + "purple": { + "value": "#4D4078", + "type": "color" + }, + "blue": { + "value": "#2C3B58", + "type": "color" + }, + "green": { + "value": "#3C5133", + "type": "color" + }, + "yellow": { + "value": "#695E3E", + "type": "color" + }, + "pink": { + "value": "#5E3C5E", + "type": "color" + }, + "red": { + "value": "#56363F", + "type": "color" + }, + "aqua": { + "value": "#1B3849", + "type": "color" + }, + "lime": { + "value": "#394027", + "type": "color" + }, + "orange": { + "value": "#5E3C3C", + "type": "color" + } + } + } + }, + "else": { + "brand": { + "value": "#2c144b", + "type": "color" + } + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/style-dictionary/tokens/dark.json b/frontend/appflowy_tauri/style-dictionary/tokens/dark.json new file mode 100644 index 0000000000000..c67af7c9ec704 --- /dev/null +++ b/frontend/appflowy_tauri/style-dictionary/tokens/dark.json @@ -0,0 +1,221 @@ +{ + "text": { + "title": { + "value": "{Base.black.neutral.800}", + "type": "color" + }, + "caption": { + "value": "{Base.black.neutral.600}", + "type": "color" + }, + "placeholder": { + "value": "{Base.black.neutral.300}", + "type": "color" + }, + "link-default": { + "value": "{Base.black.blue.400}", + "type": "color" + }, + "link-hover": { + "value": "{Base.black.blue.300}", + "type": "color" + }, + "link-pressed": { + "value": "{Base.black.blue.600}", + "type": "color" + }, + "link-disabled": { + "value": "{Base.black.blue.100}", + "type": "color" + } + }, + "icon": { + "primary": { + "value": "{Base.black.neutral.800}", + "type": "color" + }, + "secondary": { + "value": "{Base.black.neutral.500}", + "type": "color" + }, + "disabled": { + "value": "{Base.black.neutral.400}", + "type": "color" + }, + "on-toolbar": { + "value": "white", + "type": "color" + } + }, + "line": { + "border": { + "value": "{Base.black.neutral.500}", + "type": "color" + }, + "divider": { + "value": "{Base.black.neutral.100}", + "type": "color" + }, + "on-toolbar": { + "value": "{Base.black.neutral.700}", + "type": "color" + } + }, + "fill": { + "default": { + "value": "{Base.black.blue.400}", + "type": "color" + }, + "hover": { + "value": "{Base.black.blue.100}", + "type": "color" + }, + "toolbar": { + "value": "#0F111C", + "type": "color" + }, + "selector": { + "value": "{Base.black.blue.50}", + "type": "color" + }, + "list": { + "active": { + "value": "{Base.black.neutral.300}", + "type": "color" + }, + "hover": { + "value": "{Base.black.blue.100}", + "type": "color" + } + } + }, + "content": { + "blue-400": { + "value": "{Base.black.blue.400}", + "type": "color" + }, + "blue-300": { + "value": "{Base.black.blue.300}", + "type": "color" + }, + "blue-600": { + "value": "{Base.black.blue.600}", + "type": "color" + }, + "blue-100": { + "value": "{Base.black.blue.100}", + "type": "color" + }, + "on-fill": { + "value": "{Base.black.neutral.N00}", + "type": "color" + }, + "on-tag": { + "value": "{Base.black.neutral.700}", + "type": "color" + }, + "blue-50": { + "value": "{Base.black.blue.50}", + "type": "color" + } + }, + "bg": { + "body": { + "value": "{Base.black.neutral.N00}", + "type": "color" + }, + "base": { + "value": "{Base.black.blue.50}", + "type": "color" + }, + "mask": { + "value": "rgba(0,0,0,0.7)", + "type": "color" + }, + "tips": { + "value": "{Base.black.blue.100}", + "type": "color" + }, + "brand": { + "value": "{Base.else.brand}", + "type": "color" + } + }, + "function": { + "error": { + "value": "{Base.black.color.deep.red}", + "type": "color" + }, + "warning": { + "value": "{Base.black.color.deep.yellow}", + "type": "color" + }, + "success": { + "value": "#3ba856", + "type": "color" + }, + "info": { + "value": "#2e9dbb", + "type": "color" + } + }, + "tint": { + "red": { + "value": "{Base.black.color.light.red}", + "type": "color" + }, + "green": { + "value": "{Base.black.color.light.green}", + "type": "color" + }, + "purple": { + "value": "{Base.black.color.light.purple}", + "type": "color" + }, + "blue": { + "value": "{Base.black.color.light.blue}", + "type": "color" + }, + "yellow": { + "value": "{Base.black.color.light.yellow}", + "type": "color" + }, + "pink": { + "value": "{Base.black.color.light.pink}", + "type": "color" + }, + "lime": { + "value": "{Base.black.color.light.lime}", + "type": "color" + }, + "aqua": { + "value": "{Base.black.color.light.aqua}", + "type": "color" + }, + "orange": { + "value": "{Base.black.color.light.orange}", + "type": "color" + } + }, + "shadow": { + "value": { + "x": "0", + "y": "0", + "blur": "25", + "spread": "0", + "color": "rgba(0,0,0,0.3)", + "type": "innerShadow" + }, + "type": "boxShadow" + }, + "scrollbar": { + "track": { + "value": "{Base.black.neutral.100}", + "type": "color" + }, + "thumb": { + "value": "{Base.black.neutral.300}", + "type": "color" + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/style-dictionary/tokens/light.json b/frontend/appflowy_tauri/style-dictionary/tokens/light.json new file mode 100644 index 0000000000000..173f3d35aafb3 --- /dev/null +++ b/frontend/appflowy_tauri/style-dictionary/tokens/light.json @@ -0,0 +1,233 @@ +{ + "text": { + "title": { + "value": "{Base.Light.neutral.800}", + "type": "color" + }, + "caption": { + "value": "{Base.Light.neutral.600}", + "type": "color" + }, + "placeholder": { + "value": "{Base.Light.neutral.500}", + "type": "color" + }, + "disabled": { + "value": "{Base.Light.neutral.400}", + "type": "color" + }, + "link-default": { + "value": "{Base.Light.blue.400}", + "type": "color" + }, + "link-hover": { + "value": "{Base.Light.blue.300}", + "type": "color" + }, + "link-pressed": { + "value": "{Base.Light.blue.600}", + "type": "color" + }, + "link-disabled": { + "value": "{Base.Light.blue.100}", + "type": "color" + } + }, + "icon": { + "primary": { + "value": "{Base.Light.neutral.800}", + "type": "color" + }, + "secondary": { + "value": "{Base.black.neutral.500}", + "type": "color" + }, + "disabled": { + "value": "{Base.Light.neutral.400}", + "type": "color" + }, + "on-toolbar": { + "value": "{Base.Light.neutral.00}", + "type": "color" + } + }, + "line": { + "border": { + "value": "{Base.Light.neutral.500}", + "type": "color" + }, + "divider": { + "value": "{Base.Light.neutral.100}", + "type": "color" + }, + "on-toolbar": { + "value": "{Base.Light.neutral.700}", + "type": "color" + } + }, + "fill": { + "toolbar": { + "value": "{Base.Light.neutral.800}", + "type": "color" + }, + "default": { + "value": "{Base.Light.blue.400}", + "type": "color" + }, + "hover": { + "value": "{Base.Light.blue.300}", + "type": "color" + }, + "pressed": { + "value": "{Base.Light.blue.600}", + "type": "color" + }, + "active": { + "value": "{Base.Light.blue.100}", + "type": "color" + }, + "list": { + "hover": { + "value": "{Base.Light.blue.100}", + "type": "color" + }, + "active": { + "value": "{Base.Light.neutral.100}", + "type": "color" + } + } + }, + "content": { + "blue-400": { + "value": "{Base.Light.blue.400}", + "type": "color" + }, + "blue-300": { + "value": "{Base.Light.blue.300}", + "type": "color" + }, + "blue-600": { + "value": "{Base.Light.blue.600}", + "type": "color" + }, + "blue-100": { + "value": "{Base.Light.blue.100}", + "type": "color" + }, + "blue-50": { + "value": "{Base.Light.blue.50}", + "type": "color" + }, + "on-fill-hover": { + "value": "{Base.Light.blue.400}", + "type": "color" + }, + "on-fill": { + "value": "{Base.Light.neutral.00}", + "type": "color" + }, + "on-tag": { + "value": "{Base.Light.neutral.700}", + "type": "color" + } + }, + "bg": { + "body": { + "value": "{Base.Light.neutral.00}", + "type": "color" + }, + "base": { + "value": "{Base.Light.neutral.50}", + "type": "color" + }, + "mask": { + "value": "rgba(0,0,0,0.55)", + "type": "color" + }, + "tips": { + "value": "{Base.Light.blue.100}", + "type": "color" + }, + "brand": { + "value": "{Base.else.brand}", + "type": "color" + } + }, + "function": { + "error": { + "value": "{Base.Light.color.deep.red}", + "type": "color" + }, + "waring": { + "value": "{Base.Light.color.deep.yellow}", + "type": "color" + }, + "success": { + "value": "{Base.Light.color.deep.green}", + "type": "color" + }, + "info": { + "value": "{Base.Light.color.deep.blue}", + "type": "color" + } + }, + "tint": { + "purple": { + "value": "{Base.Light.color.light.purple}", + "type": "color" + }, + "pink": { + "value": "{Base.Light.color.light.pink}", + "type": "color" + }, + "red": { + "value": "{Base.Light.color.light.red}", + "type": "color" + }, + "lime": { + "value": "{Base.Light.color.light.lime}", + "type": "color" + }, + "green": { + "value": "{Base.Light.color.light.green}", + "type": "color" + }, + "aqua": { + "value": "{Base.Light.color.light.aqua}", + "type": "color" + }, + "blue": { + "value": "{Base.Light.color.light.blue}", + "type": "color" + }, + "orange": { + "value": "{Base.Light.color.light.orange}", + "type": "color" + }, + "yellow": { + "value": "{Base.Light.color.light.yellow}", + "type": "color" + } + }, + "shadow": { + "value": { + "x": "0", + "y": "0", + "blur": "10", + "spread": "0", + "color": "rgba(0,0,0,0.1)", + "type": "dropShadow" + }, + "type": "boxShadow" + }, + "scrollbar": { + "thumb": { + "value": "{Base.Light.neutral.500}", + "type": "color" + }, + "track": { + "value": "{Base.Light.neutral.100}", + "type": "color" + } + } +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/tailwind.config.cjs b/frontend/appflowy_tauri/tailwind.config.cjs new file mode 100644 index 0000000000000..06390d938f697 --- /dev/null +++ b/frontend/appflowy_tauri/tailwind.config.cjs @@ -0,0 +1,20 @@ +const colors = require('./style-dictionary/tailwind/colors.cjs'); +const boxShadow = require('./style-dictionary/tailwind/box-shadow.cjs'); + +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './index.html', + './src/**/*.{js,ts,jsx,tsx}', + './node_modules/react-tailwindcss-datepicker/dist/index.esm.js', + ], + important: '#body', + darkMode: 'class', + theme: { + extend: { + colors, + boxShadow, + }, + }, + plugins: [], +}; diff --git a/frontend/appflowy_tauri/tsconfig.json b/frontend/appflowy_tauri/tsconfig.json new file mode 100644 index 0000000000000..63b15b603949c --- /dev/null +++ b/frontend/appflowy_tauri/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["node", "jest"], + "baseUrl": "./", + "paths": { + "@/*": ["src/*"], + "$app/*": ["src/appflowy_app/*"], + "$app_reducers/*": ["src/appflowy_app/stores/reducers/*"], + "src/*": ["src/*"] + } + }, + "include": ["src", "vite.config.ts"], + "exclude": ["node_modules"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/appflowy_tauri/tsconfig.node.json b/frontend/appflowy_tauri/tsconfig.node.json new file mode 100644 index 0000000000000..9d31e2aed93c8 --- /dev/null +++ b/frontend/appflowy_tauri/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/appflowy_tauri/vite.config.ts b/frontend/appflowy_tauri/vite.config.ts new file mode 100644 index 0000000000000..b571cc40dea72 --- /dev/null +++ b/frontend/appflowy_tauri/vite.config.ts @@ -0,0 +1,70 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import svgr from 'vite-plugin-svgr'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react(), + svgr({ + svgrOptions: { + prettier: false, + plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'], + icon: true, + svgoConfig: { + multipass: true, + plugins: [ + { + name: 'preset-default', + params: { + overrides: { + removeViewBox: false, + }, + }, + }, + ], + }, + svgProps: { + role: 'img', + }, + replaceAttrValues: { + '#333': 'currentColor', + }, + }, + }), + ], + + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // prevent vite from obscuring rust errors + clearScreen: false, + // tauri expects a fixed port, fail if that port is not available + server: { + port: 1420, + strictPort: true, + watch: { + ignored: ['**/__tests__/**'], + }, + }, + // to make use of `TAURI_DEBUG` and other env variables + // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand + envPrefix: ['VITE_', 'TAURI_'], + build: { + // Tauri supports es2021 + target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari13', + // don't minify for debug builds + minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, + // produce sourcemaps for debug builds + sourcemap: !!process.env.TAURI_DEBUG, + }, + resolve: { + alias: [ + { find: 'src/', replacement: `${__dirname}/src/` }, + { find: '@/', replacement: `${__dirname}/src/` }, + { find: '$app/', replacement: `${__dirname}/src/appflowy_app/` }, + { find: '$app_reducers/', replacement: `${__dirname}/src/appflowy_app/stores/reducers/` }, + ], + }, + optimizeDeps: { + include: ['@mui/material/Tooltip'], + }, +}); diff --git a/frontend/appflowy_tauri/webdriver/selenium/package.json b/frontend/appflowy_tauri/webdriver/selenium/package.json new file mode 100644 index 0000000000000..78bbd20aad794 --- /dev/null +++ b/frontend/appflowy_tauri/webdriver/selenium/package.json @@ -0,0 +1,13 @@ +{ + "name": "selenium", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "mocha" + }, + "dependencies": { + "chai": "^4.3.4", + "mocha": "^9.0.3", + "selenium-webdriver": "^4.0.0-beta.4" + } +} diff --git a/frontend/appflowy_tauri/webdriver/selenium/test/test.cjs b/frontend/appflowy_tauri/webdriver/selenium/test/test.cjs new file mode 100644 index 0000000000000..7a57bdbbaf482 --- /dev/null +++ b/frontend/appflowy_tauri/webdriver/selenium/test/test.cjs @@ -0,0 +1,76 @@ +const os = require("os"); +const path = require("path"); +const { expect } = require("chai"); +const { spawn, spawnSync } = require("child_process"); +const { Builder, By, Capabilities, until } = require("selenium-webdriver"); +const { elementIsVisible, elementLocated } = require("selenium-webdriver/lib/until.js"); + +// create the path to the expected application binary +const application = path.resolve( + __dirname, + "..", + "..", + "..", + "src-tauri", + "target", + "release", + "appflowy_tauri" +); + +// keep track of the webdriver instance we create +let driver; + +// keep track of the tauri-driver process we start +let tauriDriver; + +before(async function() { + // set timeout to 2 minutes to allow the program to build if it needs to + this.timeout(120000); + + // ensure the program has been built + spawnSync("cargo", ["build", "--release"]); + + // start tauri-driver + tauriDriver = spawn( + path.resolve(os.homedir(), ".cargo", "bin", "tauri-driver"), + [], + { stdio: [null, process.stdout, process.stderr] } + ); + + const capabilities = new Capabilities(); + capabilities.set("tauri:options", { application }); + capabilities.setBrowserName("wry"); + + // start the webdriver client + driver = await new Builder() + .withCapabilities(capabilities) + .usingServer("http://localhost:4444/") + .build(); +}); + +after(async function() { + // stop the webdriver session + await driver.quit(); + + // kill the tauri-driver process + tauriDriver.kill(); +}); + +describe("AppFlowy Unit Test", () => { + it("should find get started button", async () => { + // should sign out if already sign in + const getStartedButton = await driver.wait(until.elementLocated(By.xpath("//*[@id=\"root\"]/form/div/div[3]"))); + getStartedButton.click(); + }); + + it("should get sign out button", async (done) => { + // const optionButton = await driver.wait(until.elementLocated(By.css('*[test-id=option-button]'))); + // const optionButton = await driver.wait(until.elementLocated(By.id('option-button'))); + // const optionButton = await driver.wait(until.elementLocated(By.css('[aria-label=option]'))); + + // Currently, only the find className is work + const optionButton = await driver.wait(until.elementLocated(By.className("relative h-8 w-8"))); + optionButton.click(); + await new Promise((resolve) => setTimeout(resolve, 4000)); + }); +}); diff --git a/frontend/appflowy_web_app/.eslintignore b/frontend/appflowy_web_app/.eslintignore new file mode 100644 index 0000000000000..b921919753b74 --- /dev/null +++ b/frontend/appflowy_web_app/.eslintignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +src-tauri/ +.eslintrc.cjs +tsconfig.json +**/backend/** +vite.config.ts +**/*.cy.tsx +*.config.ts +coverage/ \ No newline at end of file diff --git a/frontend/appflowy_web_app/.eslintignore.web b/frontend/appflowy_web_app/.eslintignore.web new file mode 100644 index 0000000000000..44dcf6dda244e --- /dev/null +++ b/frontend/appflowy_web_app/.eslintignore.web @@ -0,0 +1,8 @@ +node_modules/ +dist/ +src-tauri/ +.eslintrc.cjs +tsconfig.json +src/application/services/tauri-services/ +vite.config.ts +coverage/ \ No newline at end of file diff --git a/frontend/appflowy_web_app/.eslintrc.cjs b/frontend/appflowy_web_app/.eslintrc.cjs new file mode 100644 index 0000000000000..be02b0d02261f --- /dev/null +++ b/frontend/appflowy_web_app/.eslintrc.cjs @@ -0,0 +1,73 @@ +module.exports = { + // https://eslint.org/docs/latest/use/configure/configuration-files + env: { + browser: true, + es6: true, + node: true, + }, + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + sourceType: 'module', + tsconfigRootDir: __dirname, + extraFileExtensions: ['.json'], + }, + plugins: ['@typescript-eslint', 'react-hooks'], + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', + '@typescript-eslint/adjacent-overload-signatures': 'error', + '@typescript-eslint/no-empty-function': 'error', + '@typescript-eslint/no-empty-interface': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-namespace': 'error', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/no-redeclare': 'error', + '@typescript-eslint/prefer-for-of': 'error', + '@typescript-eslint/triple-slash-reference': 'error', + '@typescript-eslint/unified-signatures': 'error', + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': 'off', + 'constructor-super': 'error', + eqeqeq: ['error', 'always'], + 'no-cond-assign': 'error', + 'no-duplicate-case': 'error', + 'no-duplicate-imports': 'error', + 'no-empty': [ + 'error', + { + allowEmptyCatch: true, + }, + ], + 'no-invalid-this': 'error', + 'no-new-wrappers': 'error', + 'no-param-reassign': 'error', + 'no-sequences': 'error', + 'no-throw-literal': 'error', + 'no-unsafe-finally': 'error', + 'no-unused-labels': 'error', + 'no-var': 'error', + 'no-void': 'off', + 'prefer-const': 'error', + 'prefer-spread': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + }, + ], + 'padding-line-between-statements': [ + 'error', + { blankLine: 'always', prev: ['const', 'let', 'var'], next: '*' }, + { blankLine: 'any', prev: ['const', 'let', 'var'], next: ['const', 'let', 'var'] }, + { blankLine: 'always', prev: 'import', next: '*' }, + { blankLine: 'any', prev: 'import', next: 'import' }, + { blankLine: 'always', prev: 'block-like', next: '*' }, + { blankLine: 'always', prev: 'block', next: '*' }, + + ], + }, + ignorePatterns: ['src/**/*.test.ts', '**/__tests__/**/*.json', 'package.json', '__mocks__/*.ts'], +}; diff --git a/frontend/appflowy_web_app/.gitignore b/frontend/appflowy_web_app/.gitignore new file mode 100644 index 0000000000000..1b38f28edf11a --- /dev/null +++ b/frontend/appflowy_web_app/.gitignore @@ -0,0 +1,37 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist/** +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +src/@types/translations/*.json + + +src/application/services/tauri-services/backend/models/ +src/application/services/tauri-services/backend/events/ + +.env + +coverage +.nyc_output + +cypress/snapshots/**/__diff_output__/ diff --git a/frontend/appflowy_web_app/.nycrc b/frontend/appflowy_web_app/.nycrc new file mode 100644 index 0000000000000..dc571c1abbfbb --- /dev/null +++ b/frontend/appflowy_web_app/.nycrc @@ -0,0 +1,23 @@ +{ + "all": true, + "extends": "@istanbuljs/nyc-config-babel", + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ], + "exclude": [ + "cypress/**/*.*", + "**/*.d.ts", + "**/*.cy.tsx", + "**/*.cy.ts" + ], + "reporter": [ + "text", + "html", + "text-summary", + "json", + "lcov" + ], + "temp-dir": "coverage/.nyc_output", + "report-dir": "coverage/cypress" +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/.prettierrc.cjs b/frontend/appflowy_web_app/.prettierrc.cjs new file mode 100644 index 0000000000000..f283db53a2db5 --- /dev/null +++ b/frontend/appflowy_web_app/.prettierrc.cjs @@ -0,0 +1,20 @@ +module.exports = { + arrowParens: 'always', + bracketSpacing: true, + endOfLine: 'lf', + htmlWhitespaceSensitivity: 'css', + insertPragma: false, + jsxBracketSameLine: false, + jsxSingleQuote: true, + printWidth: 121, + plugins: [require('prettier-plugin-tailwindcss')], + proseWrap: 'preserve', + quoteProps: 'as-needed', + requirePragma: false, + semi: true, + singleQuote: true, + tabWidth: 2, + trailingComma: 'es5', + useTabs: false, + vueIndentScriptAndStyle: false, +}; diff --git a/frontend/appflowy_web_app/README.md b/frontend/appflowy_web_app/README.md new file mode 100644 index 0000000000000..30777f7abb258 --- /dev/null +++ b/frontend/appflowy_web_app/README.md @@ -0,0 +1,163 @@ +
+
+

AppFlowy Web

+
+ + + + + +
+ +## 🌟 Introduction + +Welcome to the AppFlowy Web project! This project aims to bring the powerful features of AppFlowy to the web. Whether +you're a developer looking to contribute or a user eager to try out the latest features, this guide will help you get +started. + +AppFlowy Web is built with the following technologies: + +- **React**: A JavaScript library for building user interfaces. +- **TypeScript**: A typed superset of JavaScript that compiles to plain JavaScript. +- **Bun**: A fast all-in-one JavaScript runtime. +- **Nginx**: A high-performance web server. +- **Docker**: A platform to develop, ship, and run applications in containers. + +### Resource Sharing + +To maintain consistency across different platforms, the Web project shares i18n translation files and Icons with the +Flutter project. This ensures a unified user experience and reduces duplication of effort in maintaining these +resources. + +- **i18n Translation Files**: The translation files are shared to provide a consistent localization experience across + both Web and Flutter applications. The path to the translation files is `frontend/resources/translations/`. + + > The translation files are stored in JSON format and contain translations for different languages. The files are + named according to the language code (e.g., `en.json` for English, `es.json` for Spanish, etc.). + +- **Icons**: The icon set used in the Web project is the same as the one used in the Flutter project, ensuring visual + consistency. The icons are stored in the `frontend/resources/flowy_icons/` directory. + +Let's dive in and get the project up and running! 🚀 + +## 🛠 Getting Started + +### Prerequisites + +Before you begin, make sure you have the following installed on your system: + +- [Node.js](https://nodejs.org/) (v18.6.0) 🌳 +- [pnpm](https://pnpm.io/) (package manager) 📦 +- [Jest](https://jestjs.io/) (testing framework) 🃏 +- [Cypress](https://www.cypress.io/) (end-to-end testing) 🧪 + +### Clone the Repository + +First, clone the repository to your local machine: + +```bash +git clone https://github.com/AppFlowy-IO/AppFlowy.git +cd frontend/appflowy_web_app +``` + +### Install Dependencies + +Install the required dependencies using pnpm: + +```bash +## ensure you have pnpm installed, if not run the following command +# npm install -g pnpm@8.5.0 + +pnpm install +``` + +### Configure Environment Variables + +Create a `.env` file in the root of the project and add the following environment variables: + +```bash +AF_BASE_URL=http://localhost:8080 +AF_GOTRUE_URL=http://localhost:9999 +AF_WS_URL=ws://localhost:8080/ws/v1 +``` + +### Start the Development Server + +To start the development server, run the following command: + +```bash +pnpm run dev +``` + +### 🚀 Building for Production(Optional) + +if you want to run the production build, use the following commands + +```bash +pnpm run build +pnpm run start +``` + +This will start the application in development mode. Open http://localhost:3000 to view it in the browser. + +## 🧪 Running Tests + +### Unit Tests + +We use **Jest** for running unit tests. To run the tests, use the following command: + +```bash +pnpm run test:unit +``` + +This will execute all the unit tests in the project and provide a summary of the results. ✅ + +### Components Tests + +We use **Cypress** for end-to-end testing. To run the Cypress tests, use the following command: + +```bash +pnpm run cypress:open +``` + +This will open the Cypress Test Runner where you can run your end-to-end tests. 🧪 + +Alternatively, to run Cypress tests in the headless mode, use: + +```bash +pnpm run test:components +``` + +Both commands will provide detailed test results and generate a code coverage report. + +## 🔄 Development Workflow + +### Linting + +To maintain code quality, we use **ESLint**. To run the linter and fix any linting errors, use the following command: + +```bash +pnpm run lint +``` + +## 🚀 Production Deployment + +Our production deployment process is automated using GitHub Actions. The process involves: + +1. **Setting up an AWS EC2 instance**: We use an EC2 instance to host the application. +2. **Installing Docker and Docker Compose**: Docker is installed on the AWS instance. +3. **Configuring SSH Access**: SSH access is set up with a user and password. +4. **Preparing Project Configuration**: We configure `Dockerfile`, `nginx.conf`, and `server.cjs` in the web project. +5. **Using GitHub Actions**: We use the easingthemes/ssh-deploy@main action to deploy the project to the remote server. + +The deployment steps include building the Docker image and running the Docker container with the necessary port +mappings: + +```bash +docker build -t appflowy-web-app . +docker rm -f appflowy-web-app || true +docker run -d -p 80:80 -p 443:443 --name appflowy-web-app appflowy-web-app +``` + +The Web server runs on Bun. For more details about Bun, please refer to the [Bun documentation](https://bun.sh/). + diff --git a/frontend/appflowy_web_app/__mocks__/nanoid.ts b/frontend/appflowy_web_app/__mocks__/nanoid.ts new file mode 100644 index 0000000000000..f001b10d5a04b --- /dev/null +++ b/frontend/appflowy_web_app/__mocks__/nanoid.ts @@ -0,0 +1,10 @@ +const generateRandomId = (length: number): string => { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)); + } + return result; +}; + +export const nanoid = jest.fn(() => generateRandomId(8)); diff --git a/frontend/appflowy_web_app/cypress.config.ts b/frontend/appflowy_web_app/cypress.config.ts new file mode 100644 index 0000000000000..212d1edca7857 --- /dev/null +++ b/frontend/appflowy_web_app/cypress.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'cypress'; +import registerCodeCoverageTasks from '@cypress/code-coverage/task'; + +import { addMatchImageSnapshotPlugin } from 'cypress-image-snapshot/plugin'; + +export default defineConfig({ + env: { + codeCoverage: { + exclude: ['cypress/**/*.*', '**/__tests__/**/*.*', '**/*.test.*'], + }, + }, + watchForFileChanges: false, + component: { + devServer: { + framework: 'react', + bundler: 'vite', + }, + setupNodeEvents (on, config) { + registerCodeCoverageTasks(on, config); + addMatchImageSnapshotPlugin(on, config); + return config; + }, + supportFile: 'cypress/support/component.ts', + }, + retries: { + // Configure retry attempts for `cypress run` + // Default is 0 + runMode: 10, + // Configure retry attempts for `cypress open` + // Default is 0 + openMode: 0, + }, +}); diff --git a/frontend/appflowy_web_app/cypress/fixtures/database/4c658817-20db-4f56-b7f9-0637a22dfeb6.json b/frontend/appflowy_web_app/cypress/fixtures/database/4c658817-20db-4f56-b7f9-0637a22dfeb6.json new file mode 100644 index 0000000000000..f2dc3ef4fb6d7 --- /dev/null +++ b/frontend/appflowy_web_app/cypress/fixtures/database/4c658817-20db-4f56-b7f9-0637a22dfeb6.json @@ -0,0 +1 @@ +{"data":{"state_vector":[65,128,137,148,150,4,39,132,238,182,192,14,5,134,200,133,143,5,2,135,173,169,205,15,4,137,227,133,241,2,170,1,140,242,215,248,4,35,141,132,223,206,14,5,142,215,187,158,14,10,146,198,138,224,6,18,149,154,146,112,20,150,194,135,131,8,12,154,253,168,186,13,6,157,197,217,249,6,3,158,173,179,170,6,81,160,159,229,236,10,34,162,129,240,225,15,19,165,237,195,173,1,8,168,211,203,155,8,88,171,216,132,162,10,97,174,158,229,225,9,2,175,150,167,163,14,4,174,182,200,164,11,2,177,178,255,174,1,38,178,161,242,226,13,154,1,174,250,146,158,5,2,180,149,168,150,13,10,180,132,165,192,8,1,182,201,218,189,1,12,182,139,168,140,5,36,183,238,200,180,5,6,185,145,225,175,8,157,3,186,204,138,236,4,118,187,163,190,240,15,89,187,159,219,213,8,2,188,252,160,180,14,5,191,215,204,166,13,12,192,183,207,147,14,43,193,174,143,180,7,18,193,140,213,146,2,134,1,200,168,240,223,7,2,201,191,253,157,12,2,200,156,140,203,9,2,202,170,215,178,7,24,203,248,208,163,4,4,206,242,242,141,13,95,209,142,245,200,15,183,5,210,221,238,195,8,20,211,189,178,91,80,211,235,145,81,16,216,247,253,206,7,2,219,179,165,244,8,4,224,218,133,236,10,13,227,170,238,211,14,16,227,250,198,245,13,5,229,168,135,118,243,4,234,232,155,212,3,4,246,154,200,238,10,11,247,149,251,192,4,4,248,220,249,231,6,29,247,187,192,242,6,6,250,147,239,143,1,2,252,220,241,227,14,60,253,149,229,85,14,253,223,254,206,11,2,252,240,184,224,14,24],"doc_state":[65,79,187,163,190,240,15,0,39,0,137,227,133,241,2,3,36,101,52,49,48,55,52,55,98,45,53,102,50,102,45,52,53,97,48,45,98,50,102,55,45,56,57,48,97,100,51,48,48,49,51,53,53,1,40,0,187,163,190,240,15,0,2,105,100,1,119,36,101,52,49,48,55,52,55,98,45,53,102,50,102,45,52,53,97,48,45,98,50,102,55,45,56,57,48,97,100,51,48,48,49,51,53,53,40,0,187,163,190,240,15,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,187,163,190,240,15,0,4,110,97,109,101,1,119,5,66,111,97,114,100,40,0,187,163,190,240,15,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,0,0,0,0,33,0,187,163,190,240,15,0,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,187,163,190,240,15,0,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,39,0,187,163,190,240,15,6,1,49,1,40,0,187,163,190,240,15,7,21,104,105,100,101,95,117,110,103,114,111,117,112,101,100,95,99,111,108,117,109,110,1,121,40,0,187,163,190,240,15,7,22,99,111,108,108,97,112,115,101,95,104,105,100,100,101,110,95,103,114,111,117,112,115,1,121,40,0,187,163,190,240,15,0,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,1,39,0,187,163,190,240,15,0,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,187,163,190,240,15,0,7,102,105,108,116,101,114,115,0,39,0,187,163,190,240,15,0,6,103,114,111,117,112,115,0,39,0,187,163,190,240,15,0,5,115,111,114,116,115,0,39,0,187,163,190,240,15,0,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,187,163,190,240,15,15,4,118,1,2,105,100,119,6,89,53,52,81,73,115,118,1,2,105,100,119,6,70,114,115,115,74,100,118,1,2,105,100,119,6,89,80,102,105,50,109,118,1,2,105,100,119,6,84,102,117,121,104,84,39,0,187,163,190,240,15,0,10,114,111,119,95,111,114,100,101,114,115,0,8,0,187,163,190,240,15,20,3,118,2,2,105,100,119,36,50,102,57,52,52,50,50,48,45,57,102,52,53,45,52,48,100,57,45,57,54,98,53,45,101,56,99,48,56,56,56,100,97,102,55,99,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,49,48,52,55,102,50,100,48,45,51,55,53,55,45,52,55,57,57,45,98,99,102,50,45,101,56,102,57,55,52,54,52,100,50,98,53,118,2,2,105,100,119,36,100,100,54,99,56,100,49,51,45,52,56,54,55,45,52,49,99,54,45,56,53,57,57,45,98,56,56,56,51,53,48,102,53,50,101,101,6,104,101,105,103,104,116,125,60,161,187,163,190,240,15,5,1,7,0,187,163,190,240,15,13,1,33,0,187,163,190,240,15,25,8,102,105,101,108,100,95,105,100,1,33,0,187,163,190,240,15,25,2,116,121,1,33,0,187,163,190,240,15,25,7,99,111,110,116,101,110,116,1,33,0,187,163,190,240,15,25,2,105,100,1,33,0,187,163,190,240,15,25,6,103,114,111,117,112,115,1,161,187,163,190,240,15,24,1,161,187,163,190,240,15,30,1,0,1,161,187,163,190,240,15,26,1,161,187,163,190,240,15,29,1,161,187,163,190,240,15,27,1,161,187,163,190,240,15,28,1,39,0,137,227,133,241,2,3,36,50,49,52,51,101,57,53,100,45,53,100,99,98,45,52,101,48,102,45,98,98,50,99,45,53,48,57,52,52,101,54,101,48,49,57,102,1,40,0,187,163,190,240,15,38,2,105,100,1,119,36,50,49,52,51,101,57,53,100,45,53,100,99,98,45,52,101,48,102,45,98,98,50,99,45,53,48,57,52,52,101,54,101,48,49,57,102,40,0,187,163,190,240,15,38,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,187,163,190,240,15,38,4,110,97,109,101,1,119,8,67,97,108,101,110,100,97,114,40,0,187,163,190,240,15,38,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,0,0,0,0,33,0,187,163,190,240,15,38,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,187,163,190,240,15,38,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,39,0,187,163,190,240,15,44,1,50,1,40,0,187,163,190,240,15,45,8,102,105,101,108,100,95,105,100,1,119,6,106,87,101,95,116,54,40,0,187,163,190,240,15,45,13,115,104,111,119,95,119,101,101,107,101,110,100,115,1,120,40,0,187,163,190,240,15,45,17,115,104,111,119,95,119,101,101,107,95,110,117,109,98,101,114,115,1,120,40,0,187,163,190,240,15,45,17,102,105,114,115,116,95,100,97,121,95,111,102,95,119,101,101,107,1,122,0,0,0,0,0,0,0,0,40,0,187,163,190,240,15,45,9,108,97,121,111,117,116,95,116,121,1,122,0,0,0,0,0,0,0,0,40,0,187,163,190,240,15,38,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,2,39,0,187,163,190,240,15,38,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,187,163,190,240,15,38,7,102,105,108,116,101,114,115,0,39,0,187,163,190,240,15,38,6,103,114,111,117,112,115,0,39,0,187,163,190,240,15,38,5,115,111,114,116,115,0,39,0,187,163,190,240,15,38,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,187,163,190,240,15,56,4,118,1,2,105,100,119,6,89,53,52,81,73,115,118,1,2,105,100,119,6,70,114,115,115,74,100,118,1,2,105,100,119,6,89,80,102,105,50,109,118,1,2,105,100,119,6,84,102,117,121,104,84,39,0,187,163,190,240,15,38,10,114,111,119,95,111,114,100,101,114,115,0,8,0,187,163,190,240,15,61,3,118,2,2,105,100,119,36,50,102,57,52,52,50,50,48,45,57,102,52,53,45,52,48,100,57,45,57,54,98,53,45,101,56,99,48,56,56,56,100,97,102,55,99,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,49,48,52,55,102,50,100,48,45,51,55,53,55,45,52,55,57,57,45,98,99,102,50,45,101,56,102,57,55,52,54,52,100,50,98,53,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,100,100,54,99,56,100,49,51,45,52,56,54,55,45,52,49,99,54,45,56,53,57,57,45,98,56,56,56,51,53,48,102,53,50,101,101,161,187,163,190,240,15,31,1,136,187,163,190,240,15,19,1,118,1,2,105,100,119,6,106,87,101,95,116,54,39,0,187,163,190,240,15,11,6,106,87,101,95,116,54,1,40,0,187,163,190,240,15,67,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,137,227,133,241,2,67,1,136,137,227,133,241,2,68,1,118,1,2,105,100,119,6,106,87,101,95,116,54,39,0,137,227,133,241,2,43,6,106,87,101,95,116,54,1,40,0,187,163,190,240,15,71,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,161,187,163,190,240,15,43,1,136,187,163,190,240,15,60,1,118,1,2,105,100,119,6,106,87,101,95,116,54,39,0,187,163,190,240,15,52,6,106,87,101,95,116,54,1,40,0,187,163,190,240,15,75,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,39,0,137,227,133,241,2,2,6,106,87,101,95,116,54,1,40,0,187,163,190,240,15,77,2,105,100,1,119,6,106,87,101,95,116,54,40,0,187,163,190,240,15,77,4,110,97,109,101,1,119,4,68,97,116,101,40,0,187,163,190,240,15,77,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,178,115,33,0,187,163,190,240,15,77,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,187,163,190,240,15,77,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,187,163,190,240,15,77,2,116,121,1,39,0,187,163,190,240,15,77,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,187,163,190,240,15,84,1,50,1,40,0,187,163,190,240,15,85,11,116,105,109,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,1,40,0,187,163,190,240,15,85,11,116,105,109,101,122,111,110,101,95,105,100,1,119,0,40,0,187,163,190,240,15,85,11,100,97,116,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,3,1,162,129,240,225,15,0,161,219,179,165,244,8,3,19,1,135,173,169,205,15,0,161,175,150,167,163,14,3,4,155,5,209,142,245,200,15,0,161,229,168,135,118,214,4,1,161,229,168,135,118,202,4,1,161,209,142,245,200,15,0,1,161,229,168,135,118,204,4,1,161,229,168,135,118,216,4,1,161,229,168,135,118,218,4,1,161,229,168,135,118,217,4,1,161,229,168,135,118,215,4,1,161,209,142,245,200,15,2,1,161,209,142,245,200,15,4,1,161,209,142,245,200,15,7,1,161,209,142,245,200,15,5,1,161,209,142,245,200,15,6,1,161,209,142,245,200,15,8,1,161,209,142,245,200,15,9,1,161,209,142,245,200,15,11,1,161,209,142,245,200,15,12,1,161,209,142,245,200,15,10,1,161,209,142,245,200,15,13,1,161,209,142,245,200,15,1,1,161,209,142,245,200,15,18,1,161,209,142,245,200,15,3,1,161,209,142,245,200,15,15,1,161,209,142,245,200,15,17,1,161,209,142,245,200,15,14,1,161,209,142,245,200,15,16,1,161,209,142,245,200,15,20,1,161,209,142,245,200,15,24,1,161,209,142,245,200,15,25,1,161,209,142,245,200,15,22,1,161,209,142,245,200,15,23,1,161,209,142,245,200,15,26,1,161,209,142,245,200,15,29,1,161,209,142,245,200,15,28,1,161,209,142,245,200,15,27,1,161,209,142,245,200,15,30,1,161,209,142,245,200,15,31,1,161,209,142,245,200,15,19,1,161,209,142,245,200,15,36,1,161,209,142,245,200,15,21,1,161,209,142,245,200,15,32,1,161,209,142,245,200,15,33,1,161,209,142,245,200,15,34,1,161,209,142,245,200,15,35,1,161,209,142,245,200,15,38,1,161,209,142,245,200,15,43,1,161,209,142,245,200,15,42,1,161,209,142,245,200,15,41,1,161,209,142,245,200,15,40,1,161,209,142,245,200,15,44,1,161,209,142,245,200,15,47,1,161,209,142,245,200,15,48,1,161,209,142,245,200,15,45,1,161,209,142,245,200,15,46,1,161,209,142,245,200,15,49,1,168,209,142,245,200,15,37,1,119,4,84,101,120,116,161,209,142,245,200,15,54,1,168,209,142,245,200,15,39,1,122,0,0,0,0,0,0,0,1,161,209,142,245,200,15,53,1,161,209,142,245,200,15,52,1,161,209,142,245,200,15,50,1,161,209,142,245,200,15,51,1,161,209,142,245,200,15,56,1,161,209,142,245,200,15,60,1,161,209,142,245,200,15,58,1,161,209,142,245,200,15,61,1,161,209,142,245,200,15,59,1,168,209,142,245,200,15,62,1,122,0,0,0,0,102,65,132,91,168,209,142,245,200,15,65,1,122,0,0,0,0,0,0,0,36,168,209,142,245,200,15,64,1,122,0,0,0,0,0,0,0,0,168,209,142,245,200,15,63,1,119,0,168,209,142,245,200,15,66,1,119,0,161,168,211,203,155,8,0,1,161,137,227,133,241,2,18,1,161,209,142,245,200,15,72,1,161,137,227,133,241,2,22,1,161,168,211,203,155,8,1,1,161,209,142,245,200,15,74,1,161,209,142,245,200,15,76,1,161,209,142,245,200,15,77,1,161,209,142,245,200,15,78,1,161,182,201,218,189,1,4,1,161,177,178,255,174,1,2,1,0,3,161,177,178,255,174,1,7,1,161,177,178,255,174,1,6,1,161,177,178,255,174,1,1,1,161,177,178,255,174,1,5,1,161,209,142,245,200,15,79,1,161,209,142,245,200,15,73,1,161,209,142,245,200,15,90,1,161,209,142,245,200,15,75,1,161,209,142,245,200,15,80,1,161,209,142,245,200,15,92,1,161,209,142,245,200,15,94,1,161,209,142,245,200,15,95,1,161,209,142,245,200,15,96,1,161,209,142,245,200,15,97,1,161,209,142,245,200,15,91,1,161,209,142,245,200,15,99,1,161,209,142,245,200,15,93,1,161,209,142,245,200,15,98,1,161,209,142,245,200,15,101,1,161,209,142,245,200,15,103,1,161,209,142,245,200,15,104,1,161,209,142,245,200,15,105,1,161,209,142,245,200,15,106,1,161,209,142,245,200,15,100,1,161,209,142,245,200,15,108,1,161,209,142,245,200,15,102,1,161,209,142,245,200,15,107,1,161,209,142,245,200,15,110,1,161,209,142,245,200,15,112,1,161,209,142,245,200,15,113,1,161,209,142,245,200,15,114,1,161,209,142,245,200,15,115,1,161,209,142,245,200,15,109,1,161,209,142,245,200,15,117,1,161,209,142,245,200,15,111,1,161,209,142,245,200,15,116,1,161,209,142,245,200,15,119,1,161,209,142,245,200,15,121,1,161,209,142,245,200,15,122,1,161,209,142,245,200,15,123,1,161,209,142,245,200,15,81,1,168,209,142,245,200,15,89,1,119,6,70,114,115,115,74,100,168,209,142,245,200,15,87,1,119,0,168,209,142,245,200,15,88,1,119,8,103,58,95,51,55,82,110,115,168,209,142,245,200,15,86,1,122,0,0,0,0,0,0,0,3,167,209,142,245,200,15,82,0,8,0,209,142,245,200,15,131,1,4,118,2,7,118,105,115,105,98,108,101,120,2,105,100,119,6,70,114,115,115,74,100,118,2,2,105,100,119,4,120,90,48,51,7,118,105,115,105,98,108,101,120,118,2,2,105,100,119,36,48,52,102,52,55,48,51,55,45,49,56,54,97,45,52,56,55,102,45,98,54,56,101,45,102,49,98,102,97,48,102,101,54,54,53,101,7,118,105,115,105,98,108,101,120,118,2,7,118,105,115,105,98,108,101,120,2,105,100,119,36,54,49,50,100,50,99,51,98,45,56,50,98,99,45,52,55,51,98,45,98,49,52,53,45,55,102,53,55,49,56,54,101,51,102,55,101,161,209,142,245,200,15,124,1,161,209,142,245,200,15,118,1,161,209,142,245,200,15,136,1,1,161,209,142,245,200,15,120,1,161,209,142,245,200,15,125,1,161,209,142,245,200,15,138,1,1,161,209,142,245,200,15,140,1,1,161,209,142,245,200,15,141,1,1,161,209,142,245,200,15,142,1,1,161,209,142,245,200,15,143,1,1,161,209,142,245,200,15,137,1,1,161,209,142,245,200,15,145,1,1,161,209,142,245,200,15,139,1,1,161,209,142,245,200,15,144,1,1,161,209,142,245,200,15,147,1,1,161,209,142,245,200,15,149,1,1,161,209,142,245,200,15,150,1,1,161,209,142,245,200,15,151,1,1,161,209,142,245,200,15,152,1,1,161,209,142,245,200,15,146,1,1,161,209,142,245,200,15,154,1,1,161,209,142,245,200,15,148,1,1,161,209,142,245,200,15,153,1,1,161,209,142,245,200,15,156,1,1,161,209,142,245,200,15,158,1,1,161,209,142,245,200,15,159,1,1,161,209,142,245,200,15,160,1,1,161,209,142,245,200,15,161,1,1,168,209,142,245,200,15,155,1,1,119,4,84,121,112,101,161,209,142,245,200,15,163,1,1,168,209,142,245,200,15,157,1,1,122,0,0,0,0,0,0,0,3,161,209,142,245,200,15,162,1,1,161,209,142,245,200,15,165,1,1,161,209,142,245,200,15,167,1,1,168,209,142,245,200,15,168,1,1,122,0,0,0,0,102,65,140,43,168,209,142,245,200,15,169,1,1,119,227,1,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,120,90,48,51,34,44,34,110,97,109,101,34,58,34,55,55,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,44,123,34,105,100,34,58,34,48,52,102,52,55,48,51,55,45,49,56,54,97,45,52,56,55,102,45,98,54,56,101,45,102,49,98,102,97,48,102,101,54,54,53,101,34,44,34,110,97,109,101,34,58,34,57,57,57,34,44,34,99,111,108,111,114,34,58,34,80,105,110,107,34,125,44,123,34,105,100,34,58,34,54,49,50,100,50,99,51,98,45,56,50,98,99,45,52,55,51,98,45,98,49,52,53,45,55,102,53,55,49,56,54,101,51,102,55,101,34,44,34,110,97,109,101,34,58,34,49,48,48,48,34,44,34,99,111,108,111,114,34,58,34,80,105,110,107,34,125,93,44,34,100,105,115,97,98,108,101,95,99,111,108,111,114,34,58,102,97,108,115,101,125,161,137,227,133,241,2,162,1,1,161,137,227,133,241,2,160,1,1,161,209,142,245,200,15,172,1,1,161,137,227,133,241,2,164,1,1,39,0,137,227,133,241,2,165,1,1,52,1,33,0,209,142,245,200,15,176,1,7,99,111,110,116,101,110,116,1,161,209,142,245,200,15,174,1,1,40,0,137,227,133,241,2,166,1,7,99,111,110,116,101,110,116,1,119,36,123,34,111,112,116,105,111,110,115,34,58,91,93,44,34,100,105,115,97,98,108,101,95,99,111,108,111,114,34,58,102,97,108,115,101,125,161,182,201,218,189,1,6,1,161,209,142,245,200,15,126,1,161,209,142,245,200,15,178,1,1,161,209,142,245,200,15,177,1,1,161,209,142,245,200,15,182,1,1,161,209,142,245,200,15,173,1,1,161,209,142,245,200,15,184,1,1,161,209,142,245,200,15,175,1,1,161,209,142,245,200,15,183,1,1,161,209,142,245,200,15,186,1,1,161,209,142,245,200,15,188,1,1,161,209,142,245,200,15,189,1,1,161,209,142,245,200,15,190,1,1,161,209,142,245,200,15,191,1,1,161,209,142,245,200,15,185,1,1,161,209,142,245,200,15,193,1,1,161,209,142,245,200,15,187,1,1,161,209,142,245,200,15,192,1,1,161,209,142,245,200,15,195,1,1,161,209,142,245,200,15,197,1,1,161,209,142,245,200,15,198,1,1,161,209,142,245,200,15,199,1,1,161,209,142,245,200,15,200,1,1,161,209,142,245,200,15,194,1,1,161,209,142,245,200,15,202,1,1,161,209,142,245,200,15,196,1,1,161,209,142,245,200,15,201,1,1,161,209,142,245,200,15,204,1,1,161,209,142,245,200,15,206,1,1,161,209,142,245,200,15,207,1,1,161,209,142,245,200,15,208,1,1,161,209,142,245,200,15,209,1,1,161,209,142,245,200,15,203,1,1,161,209,142,245,200,15,211,1,1,161,209,142,245,200,15,205,1,1,161,209,142,245,200,15,210,1,1,161,209,142,245,200,15,213,1,1,161,209,142,245,200,15,215,1,1,161,209,142,245,200,15,216,1,1,161,209,142,245,200,15,217,1,1,161,209,142,245,200,15,218,1,1,161,209,142,245,200,15,212,1,1,161,209,142,245,200,15,220,1,1,161,209,142,245,200,15,214,1,1,161,209,142,245,200,15,219,1,1,161,209,142,245,200,15,222,1,1,161,209,142,245,200,15,224,1,1,161,209,142,245,200,15,225,1,1,161,209,142,245,200,15,226,1,1,161,209,142,245,200,15,227,1,1,161,209,142,245,200,15,221,1,1,161,209,142,245,200,15,229,1,1,161,209,142,245,200,15,223,1,1,161,209,142,245,200,15,228,1,1,161,209,142,245,200,15,231,1,1,161,209,142,245,200,15,233,1,1,161,209,142,245,200,15,234,1,1,161,209,142,245,200,15,235,1,1,161,209,142,245,200,15,236,1,1,161,209,142,245,200,15,230,1,1,161,209,142,245,200,15,238,1,1,161,209,142,245,200,15,232,1,1,161,209,142,245,200,15,237,1,1,161,209,142,245,200,15,240,1,1,161,209,142,245,200,15,242,1,1,161,209,142,245,200,15,243,1,1,161,209,142,245,200,15,244,1,1,161,209,142,245,200,15,245,1,1,161,209,142,245,200,15,239,1,1,161,209,142,245,200,15,247,1,1,161,209,142,245,200,15,241,1,1,161,209,142,245,200,15,246,1,1,161,209,142,245,200,15,249,1,1,161,209,142,245,200,15,251,1,1,161,209,142,245,200,15,252,1,1,161,209,142,245,200,15,253,1,1,161,209,142,245,200,15,254,1,1,161,209,142,245,200,15,248,1,1,161,209,142,245,200,15,128,2,1,161,209,142,245,200,15,250,1,1,161,209,142,245,200,15,255,1,1,161,209,142,245,200,15,130,2,1,161,209,142,245,200,15,132,2,1,161,209,142,245,200,15,133,2,1,161,209,142,245,200,15,134,2,1,161,209,142,245,200,15,135,2,1,161,209,142,245,200,15,129,2,1,161,209,142,245,200,15,137,2,1,161,209,142,245,200,15,131,2,1,161,209,142,245,200,15,136,2,1,161,209,142,245,200,15,139,2,1,161,209,142,245,200,15,141,2,1,161,209,142,245,200,15,142,2,1,161,209,142,245,200,15,143,2,1,161,209,142,245,200,15,144,2,1,161,209,142,245,200,15,138,2,1,161,209,142,245,200,15,146,2,1,161,209,142,245,200,15,140,2,1,161,209,142,245,200,15,145,2,1,161,209,142,245,200,15,148,2,1,161,209,142,245,200,15,150,2,1,161,209,142,245,200,15,151,2,1,161,209,142,245,200,15,152,2,1,161,209,142,245,200,15,153,2,1,161,209,142,245,200,15,147,2,1,161,209,142,245,200,15,155,2,1,161,209,142,245,200,15,149,2,1,161,209,142,245,200,15,154,2,1,161,209,142,245,200,15,157,2,1,161,209,142,245,200,15,159,2,1,161,209,142,245,200,15,160,2,1,161,209,142,245,200,15,161,2,1,161,209,142,245,200,15,162,2,1,161,209,142,245,200,15,156,2,1,161,209,142,245,200,15,164,2,1,161,209,142,245,200,15,158,2,1,161,209,142,245,200,15,163,2,1,161,209,142,245,200,15,166,2,1,161,209,142,245,200,15,168,2,1,161,209,142,245,200,15,169,2,1,161,209,142,245,200,15,170,2,1,161,209,142,245,200,15,171,2,1,161,209,142,245,200,15,165,2,1,161,209,142,245,200,15,173,2,1,161,209,142,245,200,15,167,2,1,161,209,142,245,200,15,172,2,1,161,209,142,245,200,15,175,2,1,161,209,142,245,200,15,177,2,1,161,209,142,245,200,15,178,2,1,161,209,142,245,200,15,179,2,1,161,209,142,245,200,15,180,2,1,161,209,142,245,200,15,174,2,1,161,209,142,245,200,15,182,2,1,161,209,142,245,200,15,176,2,1,161,209,142,245,200,15,181,2,1,161,209,142,245,200,15,184,2,1,161,209,142,245,200,15,186,2,1,161,209,142,245,200,15,187,2,1,161,209,142,245,200,15,188,2,1,161,209,142,245,200,15,189,2,1,161,209,142,245,200,15,183,2,1,161,209,142,245,200,15,191,2,1,161,209,142,245,200,15,185,2,1,161,209,142,245,200,15,190,2,1,161,209,142,245,200,15,193,2,1,161,209,142,245,200,15,195,2,1,161,209,142,245,200,15,196,2,1,161,209,142,245,200,15,197,2,1,161,209,142,245,200,15,198,2,1,161,209,142,245,200,15,192,2,1,161,209,142,245,200,15,200,2,1,161,209,142,245,200,15,194,2,1,161,209,142,245,200,15,199,2,1,161,209,142,245,200,15,202,2,1,161,209,142,245,200,15,204,2,1,161,209,142,245,200,15,205,2,1,161,209,142,245,200,15,206,2,1,161,209,142,245,200,15,207,2,1,161,209,142,245,200,15,201,2,1,161,209,142,245,200,15,209,2,1,161,209,142,245,200,15,203,2,1,161,209,142,245,200,15,208,2,1,161,209,142,245,200,15,211,2,1,161,209,142,245,200,15,213,2,1,161,209,142,245,200,15,214,2,1,161,209,142,245,200,15,215,2,1,161,209,142,245,200,15,216,2,1,161,209,142,245,200,15,210,2,1,161,209,142,245,200,15,218,2,1,161,209,142,245,200,15,212,2,1,161,209,142,245,200,15,217,2,1,161,209,142,245,200,15,220,2,1,161,209,142,245,200,15,222,2,1,161,209,142,245,200,15,223,2,1,161,209,142,245,200,15,224,2,1,161,209,142,245,200,15,225,2,1,161,209,142,245,200,15,219,2,1,161,209,142,245,200,15,227,2,1,161,209,142,245,200,15,221,2,1,161,209,142,245,200,15,226,2,1,161,209,142,245,200,15,229,2,1,161,209,142,245,200,15,231,2,1,161,209,142,245,200,15,232,2,1,161,209,142,245,200,15,233,2,1,161,209,142,245,200,15,234,2,1,161,209,142,245,200,15,228,2,1,161,209,142,245,200,15,236,2,1,161,209,142,245,200,15,230,2,1,161,209,142,245,200,15,235,2,1,161,209,142,245,200,15,238,2,1,161,209,142,245,200,15,240,2,1,161,209,142,245,200,15,241,2,1,161,209,142,245,200,15,242,2,1,161,209,142,245,200,15,243,2,1,161,209,142,245,200,15,237,2,1,161,209,142,245,200,15,245,2,1,161,209,142,245,200,15,239,2,1,161,209,142,245,200,15,244,2,1,161,209,142,245,200,15,247,2,1,161,209,142,245,200,15,249,2,1,161,209,142,245,200,15,250,2,1,161,209,142,245,200,15,251,2,1,161,209,142,245,200,15,252,2,1,161,209,142,245,200,15,246,2,1,161,209,142,245,200,15,254,2,1,161,209,142,245,200,15,248,2,1,161,209,142,245,200,15,253,2,1,161,209,142,245,200,15,128,3,1,161,209,142,245,200,15,130,3,1,161,209,142,245,200,15,131,3,1,161,209,142,245,200,15,132,3,1,161,209,142,245,200,15,133,3,1,161,209,142,245,200,15,255,2,1,161,209,142,245,200,15,135,3,1,161,209,142,245,200,15,129,3,1,161,209,142,245,200,15,134,3,1,161,209,142,245,200,15,137,3,1,161,209,142,245,200,15,139,3,1,161,209,142,245,200,15,140,3,1,161,209,142,245,200,15,141,3,1,161,209,142,245,200,15,142,3,1,161,209,142,245,200,15,136,3,1,161,209,142,245,200,15,144,3,1,161,209,142,245,200,15,138,3,1,161,209,142,245,200,15,143,3,1,161,209,142,245,200,15,146,3,1,161,209,142,245,200,15,148,3,1,161,209,142,245,200,15,149,3,1,161,209,142,245,200,15,150,3,1,161,209,142,245,200,15,151,3,1,161,209,142,245,200,15,145,3,1,161,209,142,245,200,15,153,3,1,168,209,142,245,200,15,147,3,1,122,0,0,0,0,0,0,0,4,161,209,142,245,200,15,152,3,1,161,209,142,245,200,15,155,3,1,161,209,142,245,200,15,157,3,1,161,209,142,245,200,15,158,3,1,168,209,142,245,200,15,159,3,1,119,205,5,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,50,100,54,48,51,48,99,51,45,57,55,49,101,45,52,100,52,53,45,98,53,55,48,45,100,101,57,50,102,100,101,97,100,97,101,54,34,44,34,110,97,109,101,34,58,34,103,104,106,116,117,105,107,34,44,34,99,111,108,111,114,34,58,34,71,114,101,101,110,34,125,44,123,34,105,100,34,58,34,102,99,100,54,101,102,56,99,45,56,99,100,54,45,52,49,98,51,45,57,50,52,53,45,57,57,56,57,51,49,100,52,57,97,49,54,34,44,34,110,97,109,101,34,58,34,103,104,106,34,44,34,99,111,108,111,114,34,58,34,80,105,110,107,34,125,44,123,34,105,100,34,58,34,49,99,52,102,53,52,54,57,45,54,101,49,49,45,52,55,48,51,45,57,48,56,54,45,101,98,98,50,51,57,49,53,100,53,100,56,34,44,34,110,97,109,101,34,58,34,111,111,111,34,44,34,99,111,108,111,114,34,58,34,76,105,109,101,34,125,44,123,34,105,100,34,58,34,57,100,48,48,56,50,51,97,45,100,57,101,50,45,52,102,98,55,45,98,100,98,54,45,99,97,102,54,101,98,99,54,99,49,50,51,34,44,34,110,97,109,101,34,58,34,104,106,107,34,44,34,99,111,108,111,114,34,58,34,76,105,103,104,116,80,105,110,107,34,125,44,123,34,105,100,34,58,34,48,52,48,102,98,48,98,102,45,50,101,100,97,45,52,99,97,51,45,56,54,99,97,45,53,98,57,49,98,55,48,50,102,101,49,54,34,44,34,110,97,109,101,34,58,34,110,106,107,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,44,123,34,105,100,34,58,34,52,49,57,50,51,51,57,51,45,102,55,99,51,45,52,50,51,53,45,98,54,49,51,45,102,57,97,101,56,52,102,102,53,56,56,57,34,44,34,110,97,109,101,34,58,34,107,107,107,34,44,34,99,111,108,111,114,34,58,34,66,108,117,101,34,125,44,123,34,105,100,34,58,34,56,51,51,50,99,52,56,51,45,102,56,57,99,45,52,48,53,55,45,57,101,99,57,45,101,50,53,53,56,54,53,48,52,52,51,56,34,44,34,110,97,109,101,34,58,34,98,110,109,34,44,34,99,111,108,111,114,34,58,34,89,101,108,108,111,119,34,125,44,123,34,105,100,34,58,34,52,53,53,98,100,49,56,51,45,54,54,57,102,45,52,98,49,55,45,56,99,56,57,45,56,102,56,53,48,102,102,50,48,51,54,52,34,44,34,110,97,109,101,34,58,34,118,110,109,34,44,34,99,111,108,111,114,34,58,34,79,114,97,110,103,101,34,125,44,123,34,105,100,34,58,34,57,97,102,51,49,102,100,53,45,98,54,53,52,45,52,54,54,54,45,98,101,101,57,45,101,50,52,55,49,51,55,50,53,49,102,53,34,44,34,110,97,109,101,34,58,34,106,106,109,34,44,34,99,111,108,111,114,34,58,34,65,113,117,97,34,125,93,44,34,100,105,115,97,98,108,101,95,99,111,108,111,114,34,58,102,97,108,115,101,125,161,209,142,245,200,15,160,3,1,161,209,142,245,200,15,154,3,1,161,209,142,245,200,15,162,3,1,161,209,142,245,200,15,163,3,1,161,209,142,245,200,15,164,3,1,161,209,142,245,200,15,165,3,1,161,209,142,245,200,15,166,3,1,161,209,142,245,200,15,167,3,1,161,209,142,245,200,15,168,3,1,161,209,142,245,200,15,169,3,1,161,209,142,245,200,15,170,3,1,161,209,142,245,200,15,171,3,1,161,209,142,245,200,15,172,3,1,161,209,142,245,200,15,173,3,1,161,209,142,245,200,15,174,3,1,161,209,142,245,200,15,175,3,1,161,209,142,245,200,15,176,3,1,161,209,142,245,200,15,177,3,1,168,209,142,245,200,15,178,3,1,122,0,0,0,0,102,65,147,48,168,209,142,245,200,15,179,3,1,119,12,109,117,108,116,105,32,115,101,108,101,99,116,161,209,142,245,200,15,181,1,1,136,187,163,190,240,15,66,1,118,1,2,105,100,119,6,55,75,88,95,99,120,39,0,187,163,190,240,15,11,6,55,75,88,95,99,120,1,40,0,209,142,245,200,15,184,3,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,182,201,218,189,1,10,1,136,168,211,203,155,8,63,1,118,1,2,105,100,119,6,55,75,88,95,99,120,39,0,137,227,133,241,2,92,6,55,75,88,95,99,120,1,40,0,209,142,245,200,15,188,3,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,182,201,218,189,1,8,1,136,168,211,203,155,8,71,1,118,1,2,105,100,119,6,55,75,88,95,99,120,39,0,168,211,203,155,8,18,6,55,75,88,95,99,120,1,40,0,209,142,245,200,15,192,3,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,161,209,142,245,200,15,180,1,1,136,187,163,190,240,15,70,1,118,1,2,105,100,119,6,55,75,88,95,99,120,39,0,137,227,133,241,2,43,6,55,75,88,95,99,120,1,40,0,209,142,245,200,15,196,3,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,161,182,201,218,189,1,2,1,136,168,211,203,155,8,67,1,118,1,2,105,100,119,6,55,75,88,95,99,120,39,0,137,227,133,241,2,133,1,6,55,75,88,95,99,120,1,40,0,209,142,245,200,15,200,3,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,182,201,218,189,1,0,1,136,187,163,190,240,15,74,1,118,1,2,105,100,119,6,55,75,88,95,99,120,39,0,187,163,190,240,15,52,6,55,75,88,95,99,120,1,40,0,209,142,245,200,15,204,3,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,39,0,137,227,133,241,2,2,6,55,75,88,95,99,120,1,40,0,209,142,245,200,15,206,3,2,105,100,1,119,6,55,75,88,95,99,120,33,0,209,142,245,200,15,206,3,4,110,97,109,101,1,40,0,209,142,245,200,15,206,3,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,67,23,60,33,0,209,142,245,200,15,206,3,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,209,142,245,200,15,206,3,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,209,142,245,200,15,206,3,2,116,121,1,39,0,209,142,245,200,15,206,3,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,209,142,245,200,15,213,3,1,57,1,33,0,209,142,245,200,15,214,3,11,100,97,116,101,95,102,111,114,109,97,116,1,33,0,209,142,245,200,15,214,3,12,105,110,99,108,117,100,101,95,116,105,109,101,1,33,0,209,142,245,200,15,214,3,10,102,105,101,108,100,95,116,121,112,101,1,33,0,209,142,245,200,15,214,3,11,116,105,109,101,95,102,111,114,109,97,116,1,161,209,142,245,200,15,182,3,1,136,209,142,245,200,15,183,3,1,118,1,2,105,100,119,6,76,99,121,68,75,106,39,0,187,163,190,240,15,11,6,76,99,121,68,75,106,1,40,0,209,142,245,200,15,221,3,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,209,142,245,200,15,186,3,1,136,209,142,245,200,15,187,3,1,118,1,2,105,100,119,6,76,99,121,68,75,106,39,0,137,227,133,241,2,92,6,76,99,121,68,75,106,1,40,0,209,142,245,200,15,225,3,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,209,142,245,200,15,190,3,1,136,209,142,245,200,15,191,3,1,118,1,2,105,100,119,6,76,99,121,68,75,106,39,0,168,211,203,155,8,18,6,76,99,121,68,75,106,1,40,0,209,142,245,200,15,229,3,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,161,209,142,245,200,15,194,3,1,136,209,142,245,200,15,195,3,1,118,1,2,105,100,119,6,76,99,121,68,75,106,39,0,137,227,133,241,2,43,6,76,99,121,68,75,106,1,40,0,209,142,245,200,15,233,3,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,161,209,142,245,200,15,198,3,1,136,209,142,245,200,15,199,3,1,118,1,2,105,100,119,6,76,99,121,68,75,106,39,0,137,227,133,241,2,133,1,6,76,99,121,68,75,106,1,40,0,209,142,245,200,15,237,3,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,209,142,245,200,15,202,3,1,136,209,142,245,200,15,203,3,1,118,1,2,105,100,119,6,76,99,121,68,75,106,39,0,187,163,190,240,15,52,6,76,99,121,68,75,106,1,40,0,209,142,245,200,15,241,3,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,39,0,137,227,133,241,2,2,6,76,99,121,68,75,106,1,40,0,209,142,245,200,15,243,3,2,105,100,1,119,6,76,99,121,68,75,106,40,0,209,142,245,200,15,243,3,4,110,97,109,101,1,119,13,76,97,115,116,32,109,111,100,105,102,105,101,100,40,0,209,142,245,200,15,243,3,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,67,23,66,40,0,209,142,245,200,15,243,3,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,67,23,66,40,0,209,142,245,200,15,243,3,10,105,115,95,112,114,105,109,97,114,121,1,121,40,0,209,142,245,200,15,243,3,2,116,121,1,122,0,0,0,0,0,0,0,8,39,0,209,142,245,200,15,243,3,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,209,142,245,200,15,250,3,1,56,1,40,0,209,142,245,200,15,251,3,11,116,105,109,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,0,40,0,209,142,245,200,15,251,3,11,100,97,116,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,0,40,0,209,142,245,200,15,251,3,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,0,40,0,209,142,245,200,15,251,3,12,105,110,99,108,117,100,101,95,116,105,109,101,1,121,161,209,142,245,200,15,210,3,1,161,209,142,245,200,15,208,3,1,161,209,142,245,200,15,128,4,1,161,209,142,245,200,15,212,3,1,161,209,142,245,200,15,217,3,1,161,209,142,245,200,15,215,3,1,161,209,142,245,200,15,216,3,1,161,209,142,245,200,15,218,3,1,161,209,142,245,200,15,130,4,1,161,209,142,245,200,15,132,4,1,161,209,142,245,200,15,134,4,1,161,209,142,245,200,15,135,4,1,161,209,142,245,200,15,133,4,1,161,209,142,245,200,15,136,4,1,161,209,142,245,200,15,140,4,1,161,209,142,245,200,15,138,4,1,161,209,142,245,200,15,139,4,1,161,209,142,245,200,15,137,4,1,161,209,142,245,200,15,141,4,1,161,209,142,245,200,15,129,4,1,161,209,142,245,200,15,146,4,1,161,209,142,245,200,15,131,4,1,161,209,142,245,200,15,143,4,1,161,209,142,245,200,15,145,4,1,161,209,142,245,200,15,144,4,1,161,209,142,245,200,15,142,4,1,161,209,142,245,200,15,148,4,1,161,209,142,245,200,15,153,4,1,161,209,142,245,200,15,152,4,1,161,209,142,245,200,15,150,4,1,161,209,142,245,200,15,151,4,1,161,209,142,245,200,15,154,4,1,161,209,142,245,200,15,155,4,1,161,209,142,245,200,15,157,4,1,161,209,142,245,200,15,156,4,1,161,209,142,245,200,15,158,4,1,161,209,142,245,200,15,159,4,1,161,209,142,245,200,15,147,4,1,161,209,142,245,200,15,164,4,1,161,209,142,245,200,15,149,4,1,161,209,142,245,200,15,163,4,1,161,209,142,245,200,15,162,4,1,161,209,142,245,200,15,160,4,1,161,209,142,245,200,15,161,4,1,161,209,142,245,200,15,166,4,1,161,209,142,245,200,15,171,4,1,161,209,142,245,200,15,170,4,1,161,209,142,245,200,15,168,4,1,161,209,142,245,200,15,169,4,1,161,209,142,245,200,15,172,4,1,161,209,142,245,200,15,176,4,1,161,209,142,245,200,15,174,4,1,161,209,142,245,200,15,175,4,1,161,209,142,245,200,15,173,4,1,161,209,142,245,200,15,177,4,1,168,209,142,245,200,15,165,4,1,119,10,67,114,101,97,116,101,100,32,97,116,161,209,142,245,200,15,182,4,1,168,209,142,245,200,15,167,4,1,122,0,0,0,0,0,0,0,9,161,209,142,245,200,15,181,4,1,161,209,142,245,200,15,180,4,1,161,209,142,245,200,15,178,4,1,161,209,142,245,200,15,179,4,1,161,209,142,245,200,15,184,4,1,161,209,142,245,200,15,186,4,1,161,209,142,245,200,15,189,4,1,161,209,142,245,200,15,188,4,1,161,209,142,245,200,15,187,4,1,168,209,142,245,200,15,190,4,1,122,0,0,0,0,102,67,41,222,168,209,142,245,200,15,192,4,1,122,0,0,0,0,0,0,0,4,168,209,142,245,200,15,191,4,1,121,168,209,142,245,200,15,194,4,1,122,0,0,0,0,0,0,0,0,168,209,142,245,200,15,193,4,1,122,0,0,0,0,0,0,0,0,161,209,142,245,200,15,219,3,1,136,209,142,245,200,15,220,3,1,118,1,2,105,100,119,6,120,69,81,65,111,75,39,0,187,163,190,240,15,11,6,120,69,81,65,111,75,1,40,0,209,142,245,200,15,202,4,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,209,142,245,200,15,223,3,1,136,209,142,245,200,15,224,3,1,118,1,2,105,100,119,6,120,69,81,65,111,75,39,0,137,227,133,241,2,92,6,120,69,81,65,111,75,1,40,0,209,142,245,200,15,206,4,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,209,142,245,200,15,227,3,1,136,209,142,245,200,15,228,3,1,118,1,2,105,100,119,6,120,69,81,65,111,75,39,0,168,211,203,155,8,18,6,120,69,81,65,111,75,1,40,0,209,142,245,200,15,210,4,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,161,209,142,245,200,15,231,3,1,136,209,142,245,200,15,232,3,1,118,1,2,105,100,119,6,120,69,81,65,111,75,39,0,137,227,133,241,2,43,6,120,69,81,65,111,75,1,40,0,209,142,245,200,15,214,4,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,161,209,142,245,200,15,235,3,1,136,209,142,245,200,15,236,3,1,118,1,2,105,100,119,6,120,69,81,65,111,75,39,0,137,227,133,241,2,133,1,6,120,69,81,65,111,75,1,40,0,209,142,245,200,15,218,4,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,209,142,245,200,15,239,3,1,136,209,142,245,200,15,240,3,1,118,1,2,105,100,119,6,120,69,81,65,111,75,39,0,187,163,190,240,15,52,6,120,69,81,65,111,75,1,40,0,209,142,245,200,15,222,4,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,39,0,137,227,133,241,2,2,6,120,69,81,65,111,75,1,40,0,209,142,245,200,15,224,4,2,105,100,1,119,6,120,69,81,65,111,75,40,0,209,142,245,200,15,224,4,4,110,97,109,101,1,119,9,67,104,101,99,107,108,105,115,116,40,0,209,142,245,200,15,224,4,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,67,49,249,40,0,209,142,245,200,15,224,4,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,67,49,249,40,0,209,142,245,200,15,224,4,10,105,115,95,112,114,105,109,97,114,121,1,121,40,0,209,142,245,200,15,224,4,2,116,121,1,122,0,0,0,0,0,0,0,7,39,0,209,142,245,200,15,224,4,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,209,142,245,200,15,231,4,1,55,1,161,227,250,198,245,13,0,1,1,0,137,227,133,241,2,58,1,0,3,161,209,142,245,200,15,233,4,1,129,209,142,245,200,15,234,4,1,0,3,161,209,142,245,200,15,238,4,1,0,3,161,209,142,245,200,15,243,4,1,0,3,161,209,142,245,200,15,247,4,3,129,209,142,245,200,15,239,4,1,0,3,161,209,142,245,200,15,253,4,1,129,209,142,245,200,15,254,4,1,0,3,161,209,142,245,200,15,130,5,1,129,209,142,245,200,15,131,5,1,0,3,161,209,142,245,200,15,135,5,4,129,209,142,245,200,15,136,5,1,0,3,161,146,198,138,224,6,15,1,136,182,201,218,189,1,5,1,118,2,2,105,100,119,36,52,98,53,54,48,99,50,100,45,51,102,51,57,45,52,48,56,54,45,97,97,51,100,45,99,50,53,57,48,100,49,50,57,56,53,48,6,104,101,105,103,104,116,125,60,161,209,142,245,200,15,204,4,1,136,182,201,218,189,1,11,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,52,98,53,54,48,99,50,100,45,51,102,51,57,45,52,48,56,54,45,97,97,51,100,45,99,50,53,57,48,100,49,50,57,56,53,48,161,209,142,245,200,15,208,4,1,136,182,201,218,189,1,9,1,118,2,2,105,100,119,36,52,98,53,54,48,99,50,100,45,51,102,51,57,45,52,48,56,54,45,97,97,51,100,45,99,50,53,57,48,100,49,50,57,56,53,48,6,104,101,105,103,104,116,125,60,161,209,142,245,200,15,143,5,1,136,182,201,218,189,1,7,1,118,2,2,105,100,119,36,52,98,53,54,48,99,50,100,45,51,102,51,57,45,52,48,56,54,45,97,97,51,100,45,99,50,53,57,48,100,49,50,57,56,53,48,6,104,101,105,103,104,116,125,60,161,209,142,245,200,15,216,4,1,136,182,201,218,189,1,3,1,118,2,2,105,100,119,36,52,98,53,54,48,99,50,100,45,51,102,51,57,45,52,48,56,54,45,97,97,51,100,45,99,50,53,57,48,100,49,50,57,56,53,48,6,104,101,105,103,104,116,125,60,161,209,142,245,200,15,220,4,1,136,182,201,218,189,1,1,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,52,98,53,54,48,99,50,100,45,51,102,51,57,45,52,48,56,54,45,97,97,51,100,45,99,50,53,57,48,100,49,50,57,56,53,48,161,209,142,245,200,15,154,5,1,161,177,178,255,174,1,12,1,161,177,178,255,174,1,11,1,161,177,178,255,174,1,14,1,161,177,178,255,174,1,13,1,161,209,142,245,200,15,160,5,1,161,177,178,255,174,1,24,1,161,177,178,255,174,1,23,1,161,177,178,255,174,1,25,1,161,177,178,255,174,1,22,1,161,209,142,245,200,15,165,5,1,168,227,250,198,245,13,2,1,119,6,89,80,102,105,50,109,168,227,250,198,245,13,3,1,119,1,56,168,227,250,198,245,13,4,1,119,6,80,78,49,51,122,82,168,227,250,198,245,13,1,1,122,0,0,0,0,0,0,0,5,161,209,142,245,200,15,170,5,1,168,177,178,255,174,1,34,1,119,6,117,106,117,122,75,103,168,177,178,255,174,1,37,1,119,1,54,168,177,178,255,174,1,35,1,122,0,0,0,0,0,0,0,7,168,177,178,255,174,1,36,1,119,6,115,111,118,85,116,69,161,209,142,245,200,15,175,5,3,25,252,220,241,227,14,0,161,227,250,198,245,13,0,1,1,0,137,227,133,241,2,58,1,0,3,161,252,220,241,227,14,0,1,129,252,220,241,227,14,1,1,0,3,161,252,220,241,227,14,5,3,129,252,220,241,227,14,6,1,0,3,161,252,220,241,227,14,12,1,129,252,220,241,227,14,13,1,0,3,161,252,220,241,227,14,17,1,1,0,137,227,133,241,2,56,1,0,6,161,252,220,241,227,14,22,1,129,252,220,241,227,14,23,1,0,6,129,252,220,241,227,14,31,1,0,6,161,252,220,241,227,14,30,1,129,252,220,241,227,14,38,1,0,6,129,252,220,241,227,14,46,1,0,6,1,252,240,184,224,14,0,161,246,154,200,238,10,10,24,1,227,170,238,211,14,0,161,135,173,169,205,15,3,16,1,141,132,223,206,14,0,161,132,238,182,192,14,4,5,1,132,238,182,192,14,0,161,203,248,208,163,4,3,5,5,188,252,160,180,14,0,161,185,145,225,175,8,235,2,1,135,209,142,245,200,15,144,5,1,40,0,188,252,160,180,14,1,8,102,105,101,108,100,95,105,100,1,119,6,89,53,52,81,73,115,40,0,188,252,160,180,14,1,9,99,111,110,100,105,116,105,111,110,1,122,0,0,0,0,0,0,0,0,40,0,188,252,160,180,14,1,2,105,100,1,119,8,115,58,104,97,52,74,106,113,1,175,150,167,163,14,0,161,247,187,192,242,6,5,4,2,142,215,187,158,14,0,161,149,154,146,112,15,6,161,142,215,187,158,14,5,4,1,192,183,207,147,14,0,161,182,139,168,140,5,35,43,5,227,250,198,245,13,0,161,146,198,138,224,6,14,1,161,177,178,255,174,1,29,1,161,177,178,255,174,1,31,1,161,177,178,255,174,1,30,1,161,177,178,255,174,1,28,1,49,178,161,242,226,13,0,161,171,216,132,162,10,92,1,129,185,145,225,175,8,150,3,1,0,6,129,178,161,242,226,13,1,1,0,6,129,178,161,242,226,13,8,1,0,6,129,178,161,242,226,13,15,1,0,6,129,178,161,242,226,13,22,1,0,6,129,178,161,242,226,13,29,1,0,6,161,178,161,242,226,13,0,1,129,178,161,242,226,13,36,1,0,6,129,178,161,242,226,13,44,1,0,6,129,178,161,242,226,13,51,1,0,6,129,178,161,242,226,13,58,1,0,6,129,178,161,242,226,13,65,1,0,6,161,178,161,242,226,13,43,1,129,178,161,242,226,13,72,1,0,6,129,178,161,242,226,13,80,1,0,6,129,178,161,242,226,13,87,1,0,6,129,178,161,242,226,13,94,1,0,6,161,178,161,242,226,13,79,1,129,178,161,242,226,13,101,1,0,6,129,178,161,242,226,13,109,1,0,6,129,178,161,242,226,13,116,1,0,6,161,178,161,242,226,13,108,1,129,178,161,242,226,13,123,1,0,6,129,178,161,242,226,13,131,1,1,0,6,161,178,161,242,226,13,130,1,1,129,178,161,242,226,13,138,1,1,0,6,168,178,161,242,226,13,145,1,1,122,0,0,0,0,102,77,81,51,1,154,253,168,186,13,0,161,162,129,240,225,15,18,6,1,191,215,204,166,13,0,161,210,221,238,195,8,19,12,2,180,149,168,150,13,0,161,165,237,195,173,1,7,1,161,142,215,187,158,14,9,9,1,206,242,242,141,13,0,161,180,132,165,192,8,0,95,1,201,191,253,157,12,0,161,187,159,219,213,8,1,2,1,253,223,254,206,11,0,161,174,158,229,225,9,1,2,1,174,182,200,164,11,0,161,210,221,238,195,8,19,2,1,246,154,200,238,10,0,161,192,183,207,147,14,42,11,1,160,159,229,236,10,0,161,193,174,143,180,7,17,34,1,224,218,133,236,10,0,161,247,149,251,192,4,3,13,76,171,216,132,162,10,0,39,0,137,227,133,241,2,3,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,1,40,0,171,216,132,162,10,0,2,105,100,1,119,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,40,0,171,216,132,162,10,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,171,216,132,162,10,0,4,110,97,109,101,1,119,14,66,111,97,114,100,32,99,104,101,99,107,98,111,120,40,0,171,216,132,162,10,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,0,0,0,0,33,0,171,216,132,162,10,0,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,171,216,132,162,10,0,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,39,0,171,216,132,162,10,6,1,49,1,40,0,171,216,132,162,10,7,22,99,111,108,108,97,112,115,101,95,104,105,100,100,101,110,95,103,114,111,117,112,115,1,121,40,0,171,216,132,162,10,7,21,104,105,100,101,95,117,110,103,114,111,117,112,101,100,95,99,111,108,117,109,110,1,121,40,0,171,216,132,162,10,0,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,1,39,0,171,216,132,162,10,0,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,171,216,132,162,10,0,7,102,105,108,116,101,114,115,0,39,0,171,216,132,162,10,0,6,103,114,111,117,112,115,0,39,0,171,216,132,162,10,0,5,115,111,114,116,115,0,39,0,171,216,132,162,10,0,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,171,216,132,162,10,15,12,118,1,2,105,100,119,6,89,53,52,81,73,115,118,1,2,105,100,119,6,70,114,115,115,74,100,118,1,2,105,100,119,6,89,80,102,105,50,109,118,1,2,105,100,119,6,84,102,117,121,104,84,118,1,2,105,100,119,6,115,111,118,85,116,69,118,1,2,105,100,119,6,54,76,70,72,66,54,118,1,2,105,100,119,6,86,89,52,50,103,49,118,1,2,105,100,119,6,106,87,101,95,116,54,118,1,2,105,100,119,6,55,75,88,95,99,120,118,1,2,105,100,119,6,76,99,121,68,75,106,118,1,2,105,100,119,6,120,69,81,65,111,75,118,1,2,105,100,119,6,52,57,85,69,86,53,39,0,171,216,132,162,10,0,10,114,111,119,95,111,114,100,101,114,115,0,8,0,171,216,132,162,10,28,8,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,50,102,57,52,52,50,50,48,45,57,102,52,53,45,52,48,100,57,45,57,54,98,53,45,101,56,99,48,56,56,56,100,97,102,55,99,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,49,48,52,55,102,50,100,48,45,51,55,53,55,45,52,55,57,57,45,98,99,102,50,45,101,56,102,57,55,52,54,52,100,50,98,53,118,2,2,105,100,119,36,100,100,54,99,56,100,49,51,45,52,56,54,55,45,52,49,99,54,45,56,53,57,57,45,98,56,56,56,51,53,48,102,53,50,101,101,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,49,99,98,57,49,102,97,50,45,54,51,56,100,45,52,48,100,54,45,97,55,99,52,45,51,57,52,102,48,100,56,98,49,57,49,51,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,51,49,56,97,97,52,49,53,45,57,50,97,101,45,52,56,57,97,45,97,49,52,102,45,97,50,52,54,57,50,97,50,101,102,97,54,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,99,99,100,49,55,101,48,45,100,55,56,98,45,52,52,101,50,45,97,102,100,49,45,49,98,102,55,99,99,52,57,99,98,53,54,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,48,49,54,48,101,53,56,55,45,52,49,102,52,45,52,51,57,49,45,97,98,98,51,45,100,51,50,50,98,53,50,51,101,100,98,50,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,52,98,53,54,48,99,50,100,45,51,102,51,57,45,52,48,56,54,45,97,97,51,100,45,99,50,53,57,48,100,49,50,57,56,53,48,161,171,216,132,162,10,5,1,7,0,171,216,132,162,10,13,1,33,0,171,216,132,162,10,38,6,103,114,111,117,112,115,1,33,0,171,216,132,162,10,38,2,116,121,1,33,0,171,216,132,162,10,38,7,99,111,110,116,101,110,116,1,33,0,171,216,132,162,10,38,8,102,105,101,108,100,95,105,100,1,33,0,171,216,132,162,10,38,2,105,100,1,161,171,216,132,162,10,37,1,168,171,216,132,162,10,41,1,119,0,167,171,216,132,162,10,39,0,8,0,171,216,132,162,10,46,4,118,2,2,105,100,119,6,70,114,115,115,74,100,7,118,105,115,105,98,108,101,120,118,2,2,105,100,119,4,120,90,48,51,7,118,105,115,105,98,108,101,120,118,2,2,105,100,119,36,48,52,102,52,55,48,51,55,45,49,56,54,97,45,52,56,55,102,45,98,54,56,101,45,102,49,98,102,97,48,102,101,54,54,53,101,7,118,105,115,105,98,108,101,120,118,2,7,118,105,115,105,98,108,101,120,2,105,100,119,36,54,49,50,100,50,99,51,98,45,56,50,98,99,45,52,55,51,98,45,98,49,52,53,45,55,102,53,55,49,56,54,101,51,102,55,101,168,171,216,132,162,10,40,1,122,0,0,0,0,0,0,0,3,168,171,216,132,162,10,42,1,119,6,70,114,115,115,74,100,168,171,216,132,162,10,43,1,119,8,103,58,102,104,55,54,48,95,161,185,145,225,175,8,189,2,1,136,209,142,245,200,15,151,5,1,118,2,2,105,100,119,36,51,97,97,100,99,99,52,49,45,52,98,52,100,45,52,53,55,48,45,97,53,100,101,45,48,54,101,98,101,51,102,52,54,48,101,99,6,104,101,105,103,104,116,125,60,161,185,145,225,175,8,233,2,1,136,209,142,245,200,15,149,5,1,118,2,2,105,100,119,36,51,97,97,100,99,99,52,49,45,52,98,52,100,45,52,53,55,48,45,97,53,100,101,45,48,54,101,98,101,51,102,52,54,48,101,99,6,104,101,105,103,104,116,125,60,161,171,216,132,162,10,44,1,136,171,216,132,162,10,36,1,118,2,2,105,100,119,36,51,97,97,100,99,99,52,49,45,52,98,52,100,45,52,53,55,48,45,97,53,100,101,45,48,54,101,98,101,51,102,52,54,48,101,99,6,104,101,105,103,104,116,125,60,161,188,252,160,180,14,0,1,136,209,142,245,200,15,155,5,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,97,97,100,99,99,52,49,45,52,98,52,100,45,52,53,55,48,45,97,53,100,101,45,48,54,101,98,101,51,102,52,54,48,101,99,161,185,145,225,175,8,234,2,1,136,209,142,245,200,15,159,5,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,97,97,100,99,99,52,49,45,52,98,52,100,45,52,53,55,48,45,97,53,100,101,45,48,54,101,98,101,51,102,52,54,48,101,99,161,185,145,225,175,8,197,2,1,136,209,142,245,200,15,157,5,1,118,2,2,105,100,119,36,51,97,97,100,99,99,52,49,45,52,98,52,100,45,52,53,55,48,45,97,53,100,101,45,48,54,101,98,101,51,102,52,54,48,101,99,6,104,101,105,103,104,116,125,60,161,185,145,225,175,8,181,2,1,136,209,142,245,200,15,153,5,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,97,97,100,99,99,52,49,45,52,98,52,100,45,52,53,55,48,45,97,53,100,101,45,48,54,101,98,101,51,102,52,54,48,101,99,161,171,216,132,162,10,60,1,161,209,142,245,200,15,167,5,1,161,209,142,245,200,15,169,5,1,161,209,142,245,200,15,166,5,1,161,209,142,245,200,15,168,5,1,168,171,216,132,162,10,54,1,122,0,0,0,0,102,75,60,209,136,171,216,132,162,10,55,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,56,56,102,97,51,54,98,50,45,54,100,55,50,45,52,52,100,101,45,98,48,100,102,45,100,51,98,50,101,54,100,55,52,52,100,54,168,171,216,132,162,10,56,1,122,0,0,0,0,102,75,60,209,136,171,216,132,162,10,57,1,118,2,2,105,100,119,36,56,56,102,97,51,54,98,50,45,54,100,55,50,45,52,52,100,101,45,98,48,100,102,45,100,51,98,50,101,54,100,55,52,52,100,54,6,104,101,105,103,104,116,125,60,168,171,216,132,162,10,58,1,122,0,0,0,0,102,75,60,209,136,171,216,132,162,10,59,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,56,56,102,97,51,54,98,50,45,54,100,55,50,45,52,52,100,101,45,98,48,100,102,45,100,51,98,50,101,54,100,55,52,52,100,54,161,171,216,132,162,10,68,1,136,171,216,132,162,10,61,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,56,56,102,97,51,54,98,50,45,54,100,55,50,45,52,52,100,101,45,98,48,100,102,45,100,51,98,50,101,54,100,55,52,52,100,54,168,171,216,132,162,10,62,1,122,0,0,0,0,102,75,60,209,136,171,216,132,162,10,63,1,118,2,2,105,100,119,36,56,56,102,97,51,54,98,50,45,54,100,55,50,45,52,52,100,101,45,98,48,100,102,45,100,51,98,50,101,54,100,55,52,52,100,54,6,104,101,105,103,104,116,125,60,168,171,216,132,162,10,64,1,122,0,0,0,0,102,75,60,209,136,171,216,132,162,10,65,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,56,56,102,97,51,54,98,50,45,54,100,55,50,45,52,52,100,101,45,98,48,100,102,45,100,51,98,50,101,54,100,55,52,52,100,54,168,171,216,132,162,10,66,1,122,0,0,0,0,102,75,60,209,136,171,216,132,162,10,67,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,56,56,102,97,51,54,98,50,45,54,100,55,50,45,52,52,100,101,45,98,48,100,102,45,100,51,98,50,101,54,100,55,52,52,100,54,161,171,216,132,162,10,79,1,168,171,216,132,162,10,70,1,119,1,57,168,171,216,132,162,10,72,1,119,6,70,114,115,115,74,100,168,171,216,132,162,10,71,1,122,0,0,0,0,0,0,0,7,168,171,216,132,162,10,69,1,119,6,67,101,97,68,98,122,161,171,216,132,162,10,87,1,168,209,142,245,200,15,161,5,1,119,6,121,81,77,51,67,56,168,209,142,245,200,15,164,5,1,119,6,89,53,52,81,73,115,168,209,142,245,200,15,163,5,1,119,2,49,48,168,209,142,245,200,15,162,5,1,122,0,0,0,0,0,0,0,5,1,174,158,229,225,9,0,161,227,170,238,211,14,15,2,1,200,156,140,203,9,0,161,250,147,239,143,1,1,2,1,219,179,165,244,8,0,161,140,242,215,248,4,34,4,1,187,159,219,213,8,0,161,200,168,240,223,7,1,2,1,210,221,238,195,8,0,161,211,235,145,81,15,20,1,180,132,165,192,8,0,161,211,189,178,91,79,1,163,1,185,145,225,175,8,0,161,252,220,241,227,14,45,1,129,252,220,241,227,14,53,1,0,6,129,185,145,225,175,8,1,1,0,6,129,185,145,225,175,8,8,1,0,6,161,185,145,225,175,8,0,1,129,185,145,225,175,8,15,1,0,6,129,185,145,225,175,8,23,1,0,6,129,185,145,225,175,8,30,1,0,6,129,185,145,225,175,8,37,1,0,6,161,185,145,225,175,8,22,1,129,185,145,225,175,8,44,1,0,6,129,185,145,225,175,8,52,1,0,6,129,185,145,225,175,8,59,1,0,6,129,185,145,225,175,8,66,1,0,6,129,185,145,225,175,8,73,1,0,6,161,185,145,225,175,8,51,1,129,185,145,225,175,8,80,1,0,6,129,185,145,225,175,8,88,1,0,6,129,185,145,225,175,8,95,1,0,6,129,185,145,225,175,8,102,1,0,6,129,185,145,225,175,8,109,1,0,6,129,185,145,225,175,8,116,1,0,6,161,209,142,245,200,15,182,5,1,129,185,145,225,175,8,123,1,0,6,129,185,145,225,175,8,131,1,1,0,6,129,185,145,225,175,8,138,1,1,0,6,129,185,145,225,175,8,145,1,1,0,6,129,185,145,225,175,8,152,1,1,0,6,129,185,145,225,175,8,159,1,1,0,6,161,185,145,225,175,8,130,1,1,129,185,145,225,175,8,166,1,1,0,6,129,185,145,225,175,8,174,1,1,0,6,129,185,145,225,175,8,181,1,1,0,6,129,185,145,225,175,8,188,1,1,0,6,129,185,145,225,175,8,195,1,1,0,6,129,185,145,225,175,8,202,1,1,0,6,161,185,145,225,175,8,173,1,1,129,185,145,225,175,8,209,1,1,0,6,129,185,145,225,175,8,217,1,1,0,6,129,185,145,225,175,8,224,1,1,0,6,129,185,145,225,175,8,231,1,1,0,6,129,185,145,225,175,8,238,1,1,0,6,129,185,145,225,175,8,245,1,1,0,6,161,185,145,225,175,8,216,1,1,129,185,145,225,175,8,252,1,1,0,6,129,185,145,225,175,8,132,2,1,0,6,129,185,145,225,175,8,139,2,1,0,6,129,185,145,225,175,8,146,2,1,0,6,129,185,145,225,175,8,153,2,1,0,6,129,185,145,225,175,8,160,2,1,0,6,129,185,145,225,175,8,167,2,1,0,6,161,209,142,245,200,15,152,5,1,136,209,142,245,200,15,209,4,1,118,1,2,105,100,119,6,52,57,85,69,86,53,39,0,168,211,203,155,8,18,6,52,57,85,69,86,53,1,40,0,185,145,225,175,8,183,2,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,161,185,145,225,175,8,131,2,1,136,209,142,245,200,15,213,4,1,118,1,2,105,100,119,6,52,57,85,69,86,53,39,0,137,227,133,241,2,43,6,52,57,85,69,86,53,1,40,0,185,145,225,175,8,187,2,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,161,209,142,245,200,15,150,5,1,136,209,142,245,200,15,205,4,1,118,1,2,105,100,119,6,52,57,85,69,86,53,39,0,137,227,133,241,2,92,6,52,57,85,69,86,53,1,40,0,185,145,225,175,8,191,2,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,209,142,245,200,15,148,5,1,136,209,142,245,200,15,201,4,1,118,1,2,105,100,119,6,52,57,85,69,86,53,39,0,187,163,190,240,15,11,6,52,57,85,69,86,53,1,40,0,185,145,225,175,8,195,2,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,209,142,245,200,15,156,5,1,136,209,142,245,200,15,217,4,1,118,1,2,105,100,119,6,52,57,85,69,86,53,39,0,137,227,133,241,2,133,1,6,52,57,85,69,86,53,1,40,0,185,145,225,175,8,199,2,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,209,142,245,200,15,158,5,1,136,209,142,245,200,15,221,4,1,118,1,2,105,100,119,6,52,57,85,69,86,53,39,0,187,163,190,240,15,52,6,52,57,85,69,86,53,1,40,0,185,145,225,175,8,203,2,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,39,0,137,227,133,241,2,2,6,52,57,85,69,86,53,1,40,0,185,145,225,175,8,205,2,2,105,100,1,119,6,52,57,85,69,86,53,33,0,185,145,225,175,8,205,2,4,110,97,109,101,1,40,0,185,145,225,175,8,205,2,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,69,129,177,33,0,185,145,225,175,8,205,2,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,185,145,225,175,8,205,2,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,185,145,225,175,8,205,2,2,116,121,1,39,0,185,145,225,175,8,205,2,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,185,145,225,175,8,212,2,1,48,1,40,0,185,145,225,175,8,213,2,4,100,97,116,97,1,119,0,161,185,145,225,175,8,209,2,1,161,185,145,225,175,8,207,2,1,161,185,145,225,175,8,215,2,1,161,185,145,225,175,8,216,2,1,161,185,145,225,175,8,217,2,1,161,185,145,225,175,8,218,2,1,161,185,145,225,175,8,219,2,1,168,185,145,225,175,8,220,2,1,119,4,116,105,109,101,161,185,145,225,175,8,221,2,1,168,185,145,225,175,8,211,2,1,122,0,0,0,0,0,0,0,2,39,0,185,145,225,175,8,212,2,1,50,1,40,0,185,145,225,175,8,225,2,11,116,105,109,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,1,40,0,185,145,225,175,8,225,2,11,100,97,116,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,3,40,0,185,145,225,175,8,225,2,11,116,105,109,101,122,111,110,101,95,105,100,1,119,0,168,185,145,225,175,8,223,2,1,122,0,0,0,0,102,69,129,187,40,0,185,145,225,175,8,213,2,11,116,105,109,101,122,111,110,101,95,105,100,1,119,0,40,0,185,145,225,175,8,213,2,11,100,97,116,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,3,40,0,185,145,225,175,8,213,2,11,116,105,109,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,1,161,185,145,225,175,8,193,2,1,161,185,145,225,175,8,201,2,1,161,185,145,225,175,8,185,2,1,129,185,145,225,175,8,174,2,1,0,6,129,185,145,225,175,8,236,2,1,0,6,129,185,145,225,175,8,243,2,1,0,6,129,185,145,225,175,8,250,2,1,0,6,129,185,145,225,175,8,129,3,1,0,6,129,185,145,225,175,8,136,3,1,0,6,129,185,145,225,175,8,143,3,1,0,6,81,168,211,203,155,8,0,161,137,227,133,241,2,20,1,161,137,227,133,241,2,25,1,161,137,227,133,241,2,150,1,1,168,137,227,133,241,2,115,1,119,6,70,114,115,115,74,100,168,137,227,133,241,2,113,1,119,8,103,58,107,56,113,69,117,118,168,137,227,133,241,2,116,1,122,0,0,0,0,0,0,0,3,168,137,227,133,241,2,114,1,119,0,167,137,227,133,241,2,117,0,8,0,168,211,203,155,8,7,2,118,2,2,105,100,119,6,70,114,115,115,74,100,7,118,105,115,105,98,108,101,120,118,2,7,118,105,115,105,98,108,101,120,2,105,100,119,4,120,90,48,51,39,0,137,227,133,241,2,3,36,55,101,98,54,57,55,99,100,45,54,97,53,53,45,52,48,98,98,45,57,54,97,99,45,48,100,52,97,51,98,99,57,50,52,98,50,1,40,0,168,211,203,155,8,10,2,105,100,1,119,36,55,101,98,54,57,55,99,100,45,54,97,53,53,45,52,48,98,98,45,57,54,97,99,45,48,100,52,97,51,98,99,57,50,52,98,50,40,0,168,211,203,155,8,10,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,168,211,203,155,8,10,4,110,97,109,101,1,119,4,71,114,105,100,40,0,168,211,203,155,8,10,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,178,5,33,0,168,211,203,155,8,10,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,168,211,203,155,8,10,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,40,0,168,211,203,155,8,10,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,0,39,0,168,211,203,155,8,10,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,168,211,203,155,8,10,7,102,105,108,116,101,114,115,0,39,0,168,211,203,155,8,10,6,103,114,111,117,112,115,0,39,0,168,211,203,155,8,10,5,115,111,114,116,115,0,39,0,168,211,203,155,8,10,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,168,211,203,155,8,22,5,118,1,2,105,100,119,6,89,53,52,81,73,115,118,1,2,105,100,119,6,70,114,115,115,74,100,118,1,2,105,100,119,6,89,80,102,105,50,109,118,1,2,105,100,119,6,84,102,117,121,104,84,118,1,2,105,100,119,6,115,111,118,85,116,69,39,0,168,211,203,155,8,10,10,114,111,119,95,111,114,100,101,114,115,0,8,0,168,211,203,155,8,28,3,118,2,2,105,100,119,36,50,102,57,52,52,50,50,48,45,57,102,52,53,45,52,48,100,57,45,57,54,98,53,45,101,56,99,48,56,56,56,100,97,102,55,99,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,49,48,52,55,102,50,100,48,45,51,55,53,55,45,52,55,57,57,45,98,99,102,50,45,101,56,102,57,55,52,54,52,100,50,98,53,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,100,100,54,99,56,100,49,51,45,52,56,54,55,45,52,49,99,54,45,56,53,57,57,45,98,56,56,56,51,53,48,102,53,50,101,101,161,137,227,133,241,2,154,1,1,40,0,137,227,133,241,2,69,5,119,105,100,116,104,1,122,0,0,0,0,0,0,0,150,40,0,137,227,133,241,2,69,4,119,114,97,112,1,121,168,137,227,133,241,2,70,1,122,0,0,0,0,0,0,0,2,161,168,211,203,155,8,2,1,136,137,227,133,241,2,151,1,1,118,1,2,105,100,119,6,54,76,70,72,66,54,39,0,137,227,133,241,2,92,6,54,76,70,72,66,54,1,40,0,168,211,203,155,8,38,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,137,227,133,241,2,146,1,1,136,137,227,133,241,2,147,1,1,118,1,2,105,100,119,6,54,76,70,72,66,54,39,0,137,227,133,241,2,133,1,6,54,76,70,72,66,54,1,40,0,168,211,203,155,8,42,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,168,211,203,155,8,15,1,136,168,211,203,155,8,27,1,118,1,2,105,100,119,6,54,76,70,72,66,54,39,0,168,211,203,155,8,18,6,54,76,70,72,66,54,1,40,0,168,211,203,155,8,46,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,161,168,211,203,155,8,32,1,136,137,227,133,241,2,155,1,1,118,1,2,105,100,119,6,54,76,70,72,66,54,39,0,137,227,133,241,2,43,6,54,76,70,72,66,54,1,40,0,168,211,203,155,8,50,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,137,227,133,241,2,2,6,54,76,70,72,66,54,1,40,0,168,211,203,155,8,52,2,105,100,1,119,6,54,76,70,72,66,54,33,0,168,211,203,155,8,52,4,110,97,109,101,1,40,0,168,211,203,155,8,52,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,230,211,33,0,168,211,203,155,8,52,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,168,211,203,155,8,52,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,168,211,203,155,8,52,2,116,121,1,39,0,168,211,203,155,8,52,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,168,211,203,155,8,59,1,48,1,40,0,168,211,203,155,8,60,4,100,97,116,97,1,119,0,161,168,211,203,155,8,36,1,136,168,211,203,155,8,37,1,118,1,2,105,100,119,6,86,89,52,50,103,49,39,0,137,227,133,241,2,92,6,86,89,52,50,103,49,1,40,0,168,211,203,155,8,64,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,168,211,203,155,8,40,1,136,168,211,203,155,8,41,1,118,1,2,105,100,119,6,86,89,52,50,103,49,39,0,137,227,133,241,2,133,1,6,86,89,52,50,103,49,1,40,0,168,211,203,155,8,68,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,168,211,203,155,8,44,1,136,168,211,203,155,8,45,1,118,1,2,105,100,119,6,86,89,52,50,103,49,39,0,168,211,203,155,8,18,6,86,89,52,50,103,49,1,40,0,168,211,203,155,8,72,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,161,168,211,203,155,8,48,1,136,168,211,203,155,8,49,1,118,1,2,105,100,119,6,86,89,52,50,103,49,39,0,137,227,133,241,2,43,6,86,89,52,50,103,49,1,40,0,168,211,203,155,8,76,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,137,227,133,241,2,2,6,86,89,52,50,103,49,1,40,0,168,211,203,155,8,78,2,105,100,1,119,6,86,89,52,50,103,49,33,0,168,211,203,155,8,78,4,110,97,109,101,1,40,0,168,211,203,155,8,78,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,230,213,33,0,168,211,203,155,8,78,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,168,211,203,155,8,78,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,168,211,203,155,8,78,2,116,121,1,39,0,168,211,203,155,8,78,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,168,211,203,155,8,85,1,48,1,40,0,168,211,203,155,8,86,4,100,97,116,97,1,119,0,1,150,194,135,131,8,0,161,206,242,242,141,13,94,12,1,200,168,240,223,7,0,161,180,149,168,150,13,9,2,1,216,247,253,206,7,0,161,141,132,223,206,14,4,2,1,193,174,143,180,7,0,161,150,194,135,131,8,11,18,1,202,170,215,178,7,0,161,191,215,204,166,13,11,24,1,157,197,217,249,6,0,161,224,218,133,236,10,12,3,1,247,187,192,242,6,0,161,134,200,133,143,5,1,6,1,248,220,249,231,6,0,161,200,156,140,203,9,1,29,18,146,198,138,224,6,0,161,137,227,133,241,2,162,1,1,161,137,227,133,241,2,169,1,1,161,137,227,133,241,2,168,1,1,161,137,227,133,241,2,167,1,1,161,146,198,138,224,6,0,1,168,146,198,138,224,6,2,1,122,0,0,0,0,0,0,0,1,168,146,198,138,224,6,1,1,122,0,0,0,0,0,0,0,3,168,146,198,138,224,6,3,1,119,0,161,187,163,190,240,15,81,1,168,187,163,190,240,15,83,1,122,0,0,0,0,0,0,0,10,39,0,187,163,190,240,15,84,2,49,48,1,33,0,146,198,138,224,6,10,11,100,97,116,97,98,97,115,101,95,105,100,1,161,146,198,138,224,6,8,1,40,0,187,163,190,240,15,85,11,100,97,116,97,98,97,115,101,95,105,100,1,119,0,161,234,232,155,212,3,0,1,161,177,178,255,174,1,0,1,168,146,198,138,224,6,12,1,122,0,0,0,0,102,67,52,219,168,146,198,138,224,6,11,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,1,158,173,179,170,6,0,161,183,238,200,180,5,5,81,1,183,238,200,180,5,0,161,160,159,229,236,10,33,6,2,174,250,146,158,5,0,161,216,247,253,206,7,1,1,168,174,250,146,158,5,0,1,122,0,0,0,0,102,88,107,140,1,134,200,133,143,5,0,161,201,191,253,157,12,1,2,1,182,139,168,140,5,0,161,253,223,254,206,11,1,36,1,140,242,215,248,4,0,161,157,197,217,249,6,2,35,2,186,204,138,236,4,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,112,161,186,204,138,236,4,111,6,1,247,149,251,192,4,0,161,248,220,249,231,6,28,4,1,203,248,208,163,4,0,161,202,170,215,178,7,23,4,1,128,137,148,150,4,0,161,154,253,168,186,13,5,39,4,234,232,155,212,3,0,161,177,178,255,174,1,32,1,168,137,227,133,241,2,54,1,122,0,0,0,0,0,0,0,150,168,137,227,133,241,2,55,1,120,168,137,227,133,241,2,53,1,122,0,0,0,0,0,0,0,0,156,1,137,227,133,241,2,0,39,1,4,100,97,116,97,8,100,97,116,97,98,97,115,101,1,40,0,137,227,133,241,2,0,2,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,39,0,137,227,133,241,2,0,6,102,105,101,108,100,115,1,39,0,137,227,133,241,2,0,5,118,105,101,119,115,1,39,0,137,227,133,241,2,0,5,109,101,116,97,115,1,40,0,137,227,133,241,2,4,3,105,105,100,1,119,36,55,100,50,49,52,56,102,99,45,99,97,99,101,45,52,52,53,50,45,57,99,53,99,45,57,54,101,53,50,101,54,98,102,56,98,53,39,0,137,227,133,241,2,2,6,89,53,52,81,73,115,1,40,0,137,227,133,241,2,6,2,105,100,1,119,6,89,53,52,81,73,115,40,0,137,227,133,241,2,6,4,110,97,109,101,1,119,4,78,97,109,101,40,0,137,227,133,241,2,6,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,108,138,40,0,137,227,133,241,2,6,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,48,108,138,40,0,137,227,133,241,2,6,10,105,115,95,112,114,105,109,97,114,121,1,120,40,0,137,227,133,241,2,6,2,116,121,1,122,0,0,0,0,0,0,0,0,39,0,137,227,133,241,2,6,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,137,227,133,241,2,13,1,48,1,40,0,137,227,133,241,2,14,4,100,97,116,97,1,119,0,39,0,137,227,133,241,2,2,6,70,114,115,115,74,100,1,40,0,137,227,133,241,2,16,2,105,100,1,119,6,70,114,115,115,74,100,33,0,137,227,133,241,2,16,4,110,97,109,101,1,40,0,137,227,133,241,2,16,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,108,138,33,0,137,227,133,241,2,16,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,137,227,133,241,2,16,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,137,227,133,241,2,16,2,116,121,1,39,0,137,227,133,241,2,16,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,137,227,133,241,2,23,1,51,1,33,0,137,227,133,241,2,24,7,99,111,110,116,101,110,116,1,39,0,137,227,133,241,2,2,6,89,80,102,105,50,109,1,40,0,137,227,133,241,2,26,2,105,100,1,119,6,89,80,102,105,50,109,40,0,137,227,133,241,2,26,4,110,97,109,101,1,119,4,68,111,110,101,40,0,137,227,133,241,2,26,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,108,138,40,0,137,227,133,241,2,26,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,48,108,138,40,0,137,227,133,241,2,26,10,105,115,95,112,114,105,109,97,114,121,1,121,40,0,137,227,133,241,2,26,2,116,121,1,122,0,0,0,0,0,0,0,5,39,0,137,227,133,241,2,26,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,137,227,133,241,2,33,1,53,1,39,0,137,227,133,241,2,3,36,55,100,50,49,52,56,102,99,45,99,97,99,101,45,52,52,53,50,45,57,99,53,99,45,57,54,101,53,50,101,54,98,102,56,98,53,1,40,0,137,227,133,241,2,35,2,105,100,1,119,36,55,100,50,49,52,56,102,99,45,99,97,99,101,45,52,52,53,50,45,57,99,53,99,45,57,54,101,53,50,101,54,98,102,56,98,53,40,0,137,227,133,241,2,35,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,137,227,133,241,2,35,4,110,97,109,101,1,119,8,85,110,116,105,116,108,101,100,40,0,137,227,133,241,2,35,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,108,138,33,0,137,227,133,241,2,35,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,137,227,133,241,2,35,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,40,0,137,227,133,241,2,35,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,0,39,0,137,227,133,241,2,35,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,137,227,133,241,2,43,6,70,114,115,115,74,100,1,40,0,137,227,133,241,2,44,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,40,0,137,227,133,241,2,44,4,119,114,97,112,1,121,40,0,137,227,133,241,2,44,5,119,105,100,116,104,1,122,0,0,0,0,0,0,0,150,39,0,137,227,133,241,2,43,6,89,80,102,105,50,109,1,40,0,137,227,133,241,2,48,5,119,105,100,116,104,1,122,0,0,0,0,0,0,0,150,40,0,137,227,133,241,2,48,4,119,114,97,112,1,121,40,0,137,227,133,241,2,48,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,137,227,133,241,2,43,6,89,53,52,81,73,115,1,33,0,137,227,133,241,2,52,10,118,105,115,105,98,105,108,105,116,121,1,33,0,137,227,133,241,2,52,5,119,105,100,116,104,1,33,0,137,227,133,241,2,52,4,119,114,97,112,1,39,0,137,227,133,241,2,35,7,102,105,108,116,101,114,115,0,39,0,137,227,133,241,2,35,6,103,114,111,117,112,115,0,39,0,137,227,133,241,2,35,5,115,111,114,116,115,0,39,0,137,227,133,241,2,35,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,137,227,133,241,2,59,3,118,1,2,105,100,119,6,89,53,52,81,73,115,118,1,2,105,100,119,6,70,114,115,115,74,100,118,1,2,105,100,119,6,89,80,102,105,50,109,39,0,137,227,133,241,2,35,10,114,111,119,95,111,114,100,101,114,115,0,8,0,137,227,133,241,2,63,3,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,50,102,57,52,52,50,50,48,45,57,102,52,53,45,52,48,100,57,45,57,54,98,53,45,101,56,99,48,56,56,56,100,97,102,55,99,118,2,2,105,100,119,36,49,48,52,55,102,50,100,48,45,51,55,53,55,45,52,55,57,57,45,98,99,102,50,45,101,56,102,57,55,52,54,52,100,50,98,53,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,100,100,54,99,56,100,49,51,45,52,56,54,55,45,52,49,99,54,45,56,53,57,57,45,98,56,56,56,51,53,48,102,53,50,101,101,161,137,227,133,241,2,40,1,136,137,227,133,241,2,62,1,118,1,2,105,100,119,6,84,102,117,121,104,84,39,0,137,227,133,241,2,43,6,84,102,117,121,104,84,1,33,0,137,227,133,241,2,69,10,118,105,115,105,98,105,108,105,116,121,1,39,0,137,227,133,241,2,2,6,84,102,117,121,104,84,1,40,0,137,227,133,241,2,71,2,105,100,1,119,6,84,102,117,121,104,84,40,0,137,227,133,241,2,71,4,110,97,109,101,1,119,4,84,101,120,116,40,0,137,227,133,241,2,71,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,111,178,40,0,137,227,133,241,2,71,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,48,111,178,40,0,137,227,133,241,2,71,10,105,115,95,112,114,105,109,97,114,121,1,121,40,0,137,227,133,241,2,71,2,116,121,1,122,0,0,0,0,0,0,0,0,39,0,137,227,133,241,2,71,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,137,227,133,241,2,78,1,48,1,40,0,137,227,133,241,2,79,4,100,97,116,97,1,119,0,39,0,137,227,133,241,2,3,36,101,57,55,56,55,55,102,53,45,99,51,54,53,45,52,48,50,53,45,57,101,54,97,45,101,53,57,48,99,52,98,49,57,100,98,98,1,40,0,137,227,133,241,2,81,2,105,100,1,119,36,101,57,55,56,55,55,102,53,45,99,51,54,53,45,52,48,50,53,45,57,101,54,97,45,101,53,57,48,99,52,98,49,57,100,98,98,40,0,137,227,133,241,2,81,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,137,227,133,241,2,81,4,110,97,109,101,1,119,5,66,111,97,114,100,40,0,137,227,133,241,2,81,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,177,159,33,0,137,227,133,241,2,81,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,137,227,133,241,2,81,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,39,0,137,227,133,241,2,87,1,49,1,40,0,137,227,133,241,2,88,21,104,105,100,101,95,117,110,103,114,111,117,112,101,100,95,99,111,108,117,109,110,1,121,40,0,137,227,133,241,2,88,22,99,111,108,108,97,112,115,101,95,104,105,100,100,101,110,95,103,114,111,117,112,115,1,121,40,0,137,227,133,241,2,81,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,1,39,0,137,227,133,241,2,81,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,137,227,133,241,2,81,7,102,105,108,116,101,114,115,0,39,0,137,227,133,241,2,81,6,103,114,111,117,112,115,0,39,0,137,227,133,241,2,81,5,115,111,114,116,115,0,39,0,137,227,133,241,2,81,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,137,227,133,241,2,96,4,118,1,2,105,100,119,6,89,53,52,81,73,115,118,1,2,105,100,119,6,70,114,115,115,74,100,118,1,2,105,100,119,6,89,80,102,105,50,109,118,1,2,105,100,119,6,84,102,117,121,104,84,39,0,137,227,133,241,2,81,10,114,111,119,95,111,114,100,101,114,115,0,8,0,137,227,133,241,2,101,3,118,2,2,105,100,119,36,50,102,57,52,52,50,50,48,45,57,102,52,53,45,52,48,100,57,45,57,54,98,53,45,101,56,99,48,56,56,56,100,97,102,55,99,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,49,48,52,55,102,50,100,48,45,51,55,53,55,45,52,55,57,57,45,98,99,102,50,45,101,56,102,57,55,52,54,52,100,50,98,53,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,100,100,54,99,56,100,49,51,45,52,56,54,55,45,52,49,99,54,45,56,53,57,57,45,98,56,56,56,51,53,48,102,53,50,101,101,161,137,227,133,241,2,86,1,7,0,137,227,133,241,2,94,1,33,0,137,227,133,241,2,106,2,105,100,1,33,0,137,227,133,241,2,106,6,103,114,111,117,112,115,1,33,0,137,227,133,241,2,106,7,99,111,110,116,101,110,116,1,33,0,137,227,133,241,2,106,8,102,105,101,108,100,95,105,100,1,33,0,137,227,133,241,2,106,2,116,121,1,161,137,227,133,241,2,105,1,161,137,227,133,241,2,107,1,161,137,227,133,241,2,109,1,161,137,227,133,241,2,110,1,161,137,227,133,241,2,111,1,161,137,227,133,241,2,108,1,0,1,39,0,137,227,133,241,2,3,36,102,48,99,53,57,57,50,49,45,48,52,101,101,45,52,57,55,49,45,57,57,53,99,45,55,57,98,55,102,100,56,99,48,48,101,50,1,40,0,137,227,133,241,2,119,2,105,100,1,119,36,102,48,99,53,57,57,50,49,45,48,52,101,101,45,52,57,55,49,45,57,57,53,99,45,55,57,98,55,102,100,56,99,48,48,101,50,40,0,137,227,133,241,2,119,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,137,227,133,241,2,119,4,110,97,109,101,1,119,8,67,97,108,101,110,100,97,114,40,0,137,227,133,241,2,119,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,177,162,33,0,137,227,133,241,2,119,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,137,227,133,241,2,119,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,39,0,137,227,133,241,2,125,1,50,1,40,0,137,227,133,241,2,126,9,108,97,121,111,117,116,95,116,121,1,122,0,0,0,0,0,0,0,0,40,0,137,227,133,241,2,126,17,102,105,114,115,116,95,100,97,121,95,111,102,95,119,101,101,107,1,122,0,0,0,0,0,0,0,0,40,0,137,227,133,241,2,126,8,102,105,101,108,100,95,105,100,1,119,6,115,111,118,85,116,69,40,0,137,227,133,241,2,126,13,115,104,111,119,95,119,101,101,107,101,110,100,115,1,120,40,0,137,227,133,241,2,126,17,115,104,111,119,95,119,101,101,107,95,110,117,109,98,101,114,115,1,120,40,0,137,227,133,241,2,119,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,2,39,0,137,227,133,241,2,119,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,137,227,133,241,2,119,7,102,105,108,116,101,114,115,0,39,0,137,227,133,241,2,119,6,103,114,111,117,112,115,0,39,0,137,227,133,241,2,119,5,115,111,114,116,115,0,39,0,137,227,133,241,2,119,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,137,227,133,241,2,137,1,4,118,1,2,105,100,119,6,89,53,52,81,73,115,118,1,2,105,100,119,6,70,114,115,115,74,100,118,1,2,105,100,119,6,89,80,102,105,50,109,118,1,2,105,100,119,6,84,102,117,121,104,84,39,0,137,227,133,241,2,119,10,114,111,119,95,111,114,100,101,114,115,0,8,0,137,227,133,241,2,142,1,3,118,2,2,105,100,119,36,50,102,57,52,52,50,50,48,45,57,102,52,53,45,52,48,100,57,45,57,54,98,53,45,101,56,99,48,56,56,56,100,97,102,55,99,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,49,48,52,55,102,50,100,48,45,51,55,53,55,45,52,55,57,57,45,98,99,102,50,45,101,56,102,57,55,52,54,52,100,50,98,53,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,100,100,54,99,56,100,49,51,45,52,56,54,55,45,52,49,99,54,45,56,53,57,57,45,98,56,56,56,51,53,48,102,53,50,101,101,161,137,227,133,241,2,124,1,136,137,227,133,241,2,141,1,1,118,1,2,105,100,119,6,115,111,118,85,116,69,39,0,137,227,133,241,2,133,1,6,115,111,118,85,116,69,1,40,0,137,227,133,241,2,148,1,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,137,227,133,241,2,112,1,136,137,227,133,241,2,100,1,118,1,2,105,100,119,6,115,111,118,85,116,69,39,0,137,227,133,241,2,92,6,115,111,118,85,116,69,1,40,0,137,227,133,241,2,152,1,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,137,227,133,241,2,67,1,136,137,227,133,241,2,68,1,118,1,2,105,100,119,6,115,111,118,85,116,69,39,0,137,227,133,241,2,43,6,115,111,118,85,116,69,1,40,0,137,227,133,241,2,156,1,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,137,227,133,241,2,2,6,115,111,118,85,116,69,1,40,0,137,227,133,241,2,158,1,2,105,100,1,119,6,115,111,118,85,116,69,33,0,137,227,133,241,2,158,1,4,110,97,109,101,1,40,0,137,227,133,241,2,158,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,177,162,33,0,137,227,133,241,2,158,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,137,227,133,241,2,158,1,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,137,227,133,241,2,158,1,2,116,121,1,39,0,137,227,133,241,2,158,1,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,137,227,133,241,2,165,1,1,50,1,33,0,137,227,133,241,2,166,1,11,116,105,109,101,122,111,110,101,95,105,100,1,33,0,137,227,133,241,2,166,1,11,116,105,109,101,95,102,111,114,109,97,116,1,33,0,137,227,133,241,2,166,1,11,100,97,116,101,95,102,111,114,109,97,116,1,71,193,140,213,146,2,0,39,0,137,227,133,241,2,3,36,49,51,53,54,49,53,102,97,45,54,54,102,55,45,52,52,53,49,45,57,98,53,52,45,100,55,101,57,57,52,52,53,102,99,97,52,1,40,0,193,140,213,146,2,0,2,105,100,1,119,36,49,51,53,54,49,53,102,97,45,54,54,102,55,45,52,52,53,49,45,57,98,53,52,45,100,55,101,57,57,52,52,53,102,99,97,52,40,0,193,140,213,146,2,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,193,140,213,146,2,0,4,110,97,109,101,1,119,12,86,105,101,119,32,111,102,32,71,114,105,100,40,0,193,140,213,146,2,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,0,0,0,0,40,0,193,140,213,146,2,0,11,109,111,100,105,102,105,101,100,95,97,116,1,122,0,0,0,0,0,0,0,0,39,0,193,140,213,146,2,0,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,40,0,193,140,213,146,2,0,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,0,39,0,193,140,213,146,2,0,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,193,140,213,146,2,0,7,102,105,108,116,101,114,115,0,39,0,193,140,213,146,2,0,6,103,114,111,117,112,115,0,39,0,193,140,213,146,2,0,5,115,111,114,116,115,0,39,0,193,140,213,146,2,0,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,193,140,213,146,2,12,12,118,1,2,105,100,119,6,89,53,52,81,73,115,118,1,2,105,100,119,6,70,114,115,115,74,100,118,1,2,105,100,119,6,89,80,102,105,50,109,118,1,2,105,100,119,6,84,102,117,121,104,84,118,1,2,105,100,119,6,115,111,118,85,116,69,118,1,2,105,100,119,6,54,76,70,72,66,54,118,1,2,105,100,119,6,86,89,52,50,103,49,118,1,2,105,100,119,6,106,87,101,95,116,54,118,1,2,105,100,119,6,55,75,88,95,99,120,118,1,2,105,100,119,6,76,99,121,68,75,106,118,1,2,105,100,119,6,120,69,81,65,111,75,118,1,2,105,100,119,6,52,57,85,69,86,53,39,0,193,140,213,146,2,0,10,114,111,119,95,111,114,100,101,114,115,0,8,0,193,140,213,146,2,25,10,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,50,102,57,52,52,50,50,48,45,57,102,52,53,45,52,48,100,57,45,57,54,98,53,45,101,56,99,48,56,56,56,100,97,102,55,99,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,49,48,52,55,102,50,100,48,45,51,55,53,55,45,52,55,57,57,45,98,99,102,50,45,101,56,102,57,55,52,54,52,100,50,98,53,118,2,2,105,100,119,36,100,100,54,99,56,100,49,51,45,52,56,54,55,45,52,49,99,54,45,56,53,57,57,45,98,56,56,56,51,53,48,102,53,50,101,101,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,49,99,98,57,49,102,97,50,45,54,51,56,100,45,52,48,100,54,45,97,55,99,52,45,51,57,52,102,48,100,56,98,49,57,49,51,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,49,56,97,97,52,49,53,45,57,50,97,101,45,52,56,57,97,45,97,49,52,102,45,97,50,52,54,57,50,97,50,101,102,97,54,118,2,2,105,100,119,36,51,99,99,100,49,55,101,48,45,100,55,56,98,45,52,52,101,50,45,97,102,100,49,45,49,98,102,55,99,99,52,57,99,98,53,54,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,48,49,54,48,101,53,56,55,45,52,49,102,52,45,52,51,57,49,45,97,98,98,51,45,100,51,50,50,98,53,50,51,101,100,98,50,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,52,98,53,54,48,99,50,100,45,51,102,51,57,45,52,48,56,54,45,97,97,51,100,45,99,50,53,57,48,100,49,50,57,56,53,48,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,51,97,97,100,99,99,52,49,45,52,98,52,100,45,52,53,55,48,45,97,53,100,101,45,48,54,101,98,101,51,102,52,54,48,101,99,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,56,56,102,97,51,54,98,50,45,54,100,55,50,45,52,52,100,101,45,98,48,100,102,45,100,51,98,50,101,54,100,55,52,52,100,54,6,104,101,105,103,104,116,125,60,39,0,137,227,133,241,2,3,36,98,52,101,55,55,50,48,51,45,53,99,56,98,45,52,56,100,102,45,98,98,99,53,45,50,101,49,49,52,51,101,98,48,101,54,49,1,40,0,193,140,213,146,2,36,2,105,100,1,119,36,98,52,101,55,55,50,48,51,45,53,99,56,98,45,52,56,100,102,45,98,98,99,53,45,50,101,49,49,52,51,101,98,48,101,54,49,40,0,193,140,213,146,2,36,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,193,140,213,146,2,36,4,110,97,109,101,1,119,22,86,105,101,119,32,111,102,32,66,111,97,114,100,32,99,104,101,99,107,98,111,120,40,0,193,140,213,146,2,36,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,0,0,0,0,33,0,193,140,213,146,2,36,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,193,140,213,146,2,36,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,39,0,193,140,213,146,2,42,1,49,1,40,0,193,140,213,146,2,43,21,104,105,100,101,95,117,110,103,114,111,117,112,101,100,95,99,111,108,117,109,110,1,121,40,0,193,140,213,146,2,43,22,99,111,108,108,97,112,115,101,95,104,105,100,100,101,110,95,103,114,111,117,112,115,1,121,40,0,193,140,213,146,2,36,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,1,39,0,193,140,213,146,2,36,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,193,140,213,146,2,36,7,102,105,108,116,101,114,115,0,39,0,193,140,213,146,2,36,6,103,114,111,117,112,115,0,39,0,193,140,213,146,2,36,5,115,111,114,116,115,0,39,0,193,140,213,146,2,36,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,193,140,213,146,2,51,12,118,1,2,105,100,119,6,89,53,52,81,73,115,118,1,2,105,100,119,6,70,114,115,115,74,100,118,1,2,105,100,119,6,89,80,102,105,50,109,118,1,2,105,100,119,6,84,102,117,121,104,84,118,1,2,105,100,119,6,115,111,118,85,116,69,118,1,2,105,100,119,6,54,76,70,72,66,54,118,1,2,105,100,119,6,86,89,52,50,103,49,118,1,2,105,100,119,6,106,87,101,95,116,54,118,1,2,105,100,119,6,55,75,88,95,99,120,118,1,2,105,100,119,6,76,99,121,68,75,106,118,1,2,105,100,119,6,120,69,81,65,111,75,118,1,2,105,100,119,6,52,57,85,69,86,53,39,0,193,140,213,146,2,36,10,114,111,119,95,111,114,100,101,114,115,0,8,0,193,140,213,146,2,64,10,118,2,2,105,100,119,36,50,102,57,52,52,50,50,48,45,57,102,52,53,45,52,48,100,57,45,57,54,98,53,45,101,56,99,48,56,56,56,100,97,102,55,99,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,49,48,52,55,102,50,100,48,45,51,55,53,55,45,52,55,57,57,45,98,99,102,50,45,101,56,102,57,55,52,54,52,100,50,98,53,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,100,100,54,99,56,100,49,51,45,52,56,54,55,45,52,49,99,54,45,56,53,57,57,45,98,56,56,56,51,53,48,102,53,50,101,101,118,2,2,105,100,119,36,49,99,98,57,49,102,97,50,45,54,51,56,100,45,52,48,100,54,45,97,55,99,52,45,51,57,52,102,48,100,56,98,49,57,49,51,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,51,49,56,97,97,52,49,53,45,57,50,97,101,45,52,56,57,97,45,97,49,52,102,45,97,50,52,54,57,50,97,50,101,102,97,54,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,99,99,100,49,55,101,48,45,100,55,56,98,45,52,52,101,50,45,97,102,100,49,45,49,98,102,55,99,99,52,57,99,98,53,54,118,2,2,105,100,119,36,48,49,54,48,101,53,56,55,45,52,49,102,52,45,52,51,57,49,45,97,98,98,51,45,100,51,50,50,98,53,50,51,101,100,98,50,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,52,98,53,54,48,99,50,100,45,51,102,51,57,45,52,48,56,54,45,97,97,51,100,45,99,50,53,57,48,100,49,50,57,56,53,48,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,97,97,100,99,99,52,49,45,52,98,52,100,45,52,53,55,48,45,97,53,100,101,45,48,54,101,98,101,51,102,52,54,48,101,99,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,56,56,102,97,51,54,98,50,45,54,100,55,50,45,52,52,100,101,45,98,48,100,102,45,100,51,98,50,101,54,100,55,52,52,100,54,161,193,140,213,146,2,41,1,7,0,193,140,213,146,2,49,1,33,0,193,140,213,146,2,76,6,103,114,111,117,112,115,1,33,0,193,140,213,146,2,76,2,116,121,1,33,0,193,140,213,146,2,76,8,102,105,101,108,100,95,105,100,1,33,0,193,140,213,146,2,76,2,105,100,1,33,0,193,140,213,146,2,76,7,99,111,110,116,101,110,116,1,168,193,140,213,146,2,75,1,122,0,0,0,0,102,79,7,25,168,193,140,213,146,2,79,1,119,6,70,114,115,115,74,100,168,193,140,213,146,2,78,1,122,0,0,0,0,0,0,0,3,168,193,140,213,146,2,80,1,119,8,103,58,105,88,95,87,48,73,167,193,140,213,146,2,77,0,8,0,193,140,213,146,2,86,4,118,2,2,105,100,119,6,70,114,115,115,74,100,7,118,105,115,105,98,108,101,120,118,2,7,118,105,115,105,98,108,101,120,2,105,100,119,4,120,90,48,51,118,2,7,118,105,115,105,98,108,101,120,2,105,100,119,36,48,52,102,52,55,48,51,55,45,49,56,54,97,45,52,56,55,102,45,98,54,56,101,45,102,49,98,102,97,48,102,101,54,54,53,101,118,2,7,118,105,115,105,98,108,101,120,2,105,100,119,36,54,49,50,100,50,99,51,98,45,56,50,98,99,45,52,55,51,98,45,98,49,52,53,45,55,102,53,55,49,56,54,101,51,102,55,101,168,193,140,213,146,2,81,1,119,0,39,0,137,227,133,241,2,3,36,97,54,97,102,51,49,49,102,45,99,98,99,56,45,52,50,99,50,45,98,56,48,49,45,55,49,49,53,54,49,57,99,51,55,55,54,1,40,0,193,140,213,146,2,92,2,105,100,1,119,36,97,54,97,102,51,49,49,102,45,99,98,99,56,45,52,50,99,50,45,98,56,48,49,45,55,49,49,53,54,49,57,99,51,55,55,54,40,0,193,140,213,146,2,92,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,193,140,213,146,2,92,4,110,97,109,101,1,119,16,86,105,101,119,32,111,102,32,67,97,108,101,110,100,97,114,40,0,193,140,213,146,2,92,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,0,0,0,0,40,0,193,140,213,146,2,92,11,109,111,100,105,102,105,101,100,95,97,116,1,122,0,0,0,0,0,0,0,0,39,0,193,140,213,146,2,92,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,39,0,193,140,213,146,2,98,1,50,1,40,0,193,140,213,146,2,99,17,102,105,114,115,116,95,100,97,121,95,111,102,95,119,101,101,107,1,122,0,0,0,0,0,0,0,0,40,0,193,140,213,146,2,99,8,102,105,101,108,100,95,105,100,1,119,6,52,57,85,69,86,53,40,0,193,140,213,146,2,99,9,108,97,121,111,117,116,95,116,121,1,122,0,0,0,0,0,0,0,0,40,0,193,140,213,146,2,99,13,115,104,111,119,95,119,101,101,107,101,110,100,115,1,120,40,0,193,140,213,146,2,99,17,115,104,111,119,95,119,101,101,107,95,110,117,109,98,101,114,115,1,120,40,0,193,140,213,146,2,92,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,2,39,0,193,140,213,146,2,92,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,193,140,213,146,2,92,7,102,105,108,116,101,114,115,0,39,0,193,140,213,146,2,92,6,103,114,111,117,112,115,0,39,0,193,140,213,146,2,92,5,115,111,114,116,115,0,39,0,193,140,213,146,2,92,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,193,140,213,146,2,110,12,118,1,2,105,100,119,6,89,53,52,81,73,115,118,1,2,105,100,119,6,70,114,115,115,74,100,118,1,2,105,100,119,6,89,80,102,105,50,109,118,1,2,105,100,119,6,84,102,117,121,104,84,118,1,2,105,100,119,6,115,111,118,85,116,69,118,1,2,105,100,119,6,54,76,70,72,66,54,118,1,2,105,100,119,6,86,89,52,50,103,49,118,1,2,105,100,119,6,106,87,101,95,116,54,118,1,2,105,100,119,6,55,75,88,95,99,120,118,1,2,105,100,119,6,76,99,121,68,75,106,118,1,2,105,100,119,6,120,69,81,65,111,75,118,1,2,105,100,119,6,52,57,85,69,86,53,39,0,193,140,213,146,2,92,10,114,111,119,95,111,114,100,101,114,115,0,8,0,193,140,213,146,2,123,10,118,2,2,105,100,119,36,50,102,57,52,52,50,50,48,45,57,102,52,53,45,52,48,100,57,45,57,54,98,53,45,101,56,99,48,56,56,56,100,97,102,55,99,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,49,48,52,55,102,50,100,48,45,51,55,53,55,45,52,55,57,57,45,98,99,102,50,45,101,56,102,57,55,52,54,52,100,50,98,53,118,2,2,105,100,119,36,100,100,54,99,56,100,49,51,45,52,56,54,55,45,52,49,99,54,45,56,53,57,57,45,98,56,56,56,51,53,48,102,53,50,101,101,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,49,99,98,57,49,102,97,50,45,54,51,56,100,45,52,48,100,54,45,97,55,99,52,45,51,57,52,102,48,100,56,98,49,57,49,51,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,49,56,97,97,52,49,53,45,57,50,97,101,45,52,56,57,97,45,97,49,52,102,45,97,50,52,54,57,50,97,50,101,102,97,54,118,2,2,105,100,119,36,51,99,99,100,49,55,101,48,45,100,55,56,98,45,52,52,101,50,45,97,102,100,49,45,49,98,102,55,99,99,52,57,99,98,53,54,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,48,49,54,48,101,53,56,55,45,52,49,102,52,45,52,51,57,49,45,97,98,98,51,45,100,51,50,50,98,53,50,51,101,100,98,50,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,52,98,53,54,48,99,50,100,45,51,102,51,57,45,52,48,56,54,45,97,97,51,100,45,99,50,53,57,48,100,49,50,57,56,53,48,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,97,97,100,99,99,52,49,45,52,98,52,100,45,52,53,55,48,45,97,53,100,101,45,48,54,101,98,101,51,102,52,54,48,101,99,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,56,56,102,97,51,54,98,50,45,54,100,55,50,45,52,52,100,101,45,98,48,100,102,45,100,51,98,50,101,54,100,55,52,52,100,54,12,182,201,218,189,1,0,161,229,168,135,118,231,4,1,136,229,168,135,118,232,4,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,48,49,54,48,101,53,56,55,45,52,49,102,52,45,52,51,57,49,45,97,98,98,51,45,100,51,50,50,98,53,50,51,101,100,98,50,161,229,168,135,118,237,4,1,136,229,168,135,118,238,4,1,118,2,2,105,100,119,36,48,49,54,48,101,53,56,55,45,52,49,102,52,45,52,51,57,49,45,97,98,98,51,45,100,51,50,50,98,53,50,51,101,100,98,50,6,104,101,105,103,104,116,125,60,161,229,168,135,118,235,4,1,136,229,168,135,118,236,4,1,118,2,2,105,100,119,36,48,49,54,48,101,53,56,55,45,52,49,102,52,45,52,51,57,49,45,97,98,98,51,45,100,51,50,50,98,53,50,51,101,100,98,50,6,104,101,105,103,104,116,125,60,161,229,168,135,118,233,4,1,136,229,168,135,118,234,4,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,48,49,54,48,101,53,56,55,45,52,49,102,52,45,52,51,57,49,45,97,98,98,51,45,100,51,50,50,98,53,50,51,101,100,98,50,161,229,168,135,118,241,4,1,136,229,168,135,118,242,4,1,118,2,2,105,100,119,36,48,49,54,48,101,53,56,55,45,52,49,102,52,45,52,51,57,49,45,97,98,98,51,45,100,51,50,50,98,53,50,51,101,100,98,50,6,104,101,105,103,104,116,125,60,161,229,168,135,118,239,4,1,136,229,168,135,118,240,4,1,118,2,2,105,100,119,36,48,49,54,48,101,53,56,55,45,52,49,102,52,45,52,51,57,49,45,97,98,98,51,45,100,51,50,50,98,53,50,51,101,100,98,50,6,104,101,105,103,104,116,125,60,37,177,178,255,174,1,0,161,187,163,190,240,15,65,1,161,187,163,190,240,15,35,1,161,187,163,190,240,15,32,1,0,2,161,187,163,190,240,15,34,1,161,187,163,190,240,15,37,1,161,187,163,190,240,15,36,1,161,187,163,190,240,15,69,1,39,0,137,227,133,241,2,35,12,99,97,108,99,117,108,97,116,105,111,110,115,0,7,0,177,178,255,174,1,9,1,33,0,177,178,255,174,1,10,2,116,121,1,33,0,177,178,255,174,1,10,2,105,100,1,33,0,177,178,255,174,1,10,8,102,105,101,108,100,95,105,100,1,33,0,177,178,255,174,1,10,17,99,97,108,99,117,108,97,116,105,111,110,95,118,97,108,117,101,1,161,177,178,255,174,1,8,1,135,177,178,255,174,1,10,1,33,0,177,178,255,174,1,16,8,102,105,101,108,100,95,105,100,1,33,0,177,178,255,174,1,16,2,105,100,1,33,0,177,178,255,174,1,16,2,116,121,1,33,0,177,178,255,174,1,16,17,99,97,108,99,117,108,97,116,105,111,110,95,118,97,108,117,101,1,161,177,178,255,174,1,15,1,161,177,178,255,174,1,20,1,161,177,178,255,174,1,18,1,161,177,178,255,174,1,19,1,161,177,178,255,174,1,17,1,161,177,178,255,174,1,21,1,135,177,178,255,174,1,16,1,33,0,177,178,255,174,1,27,2,105,100,1,33,0,177,178,255,174,1,27,2,116,121,1,33,0,177,178,255,174,1,27,17,99,97,108,99,117,108,97,116,105,111,110,95,118,97,108,117,101,1,33,0,177,178,255,174,1,27,8,102,105,101,108,100,95,105,100,1,161,177,178,255,174,1,26,1,135,177,178,255,174,1,27,1,33,0,177,178,255,174,1,33,2,105,100,1,33,0,177,178,255,174,1,33,2,116,121,1,33,0,177,178,255,174,1,33,8,102,105,101,108,100,95,105,100,1,33,0,177,178,255,174,1,33,17,99,97,108,99,117,108,97,116,105,111,110,95,118,97,108,117,101,1,1,165,237,195,173,1,0,161,253,149,229,85,13,8,1,250,147,239,143,1,0,161,158,173,179,170,6,80,2,243,4,229,168,135,118,0,161,168,211,203,155,8,56,1,168,168,211,203,155,8,54,1,119,4,84,101,120,116,161,229,168,135,118,0,1,168,168,211,203,155,8,58,1,122,0,0,0,0,0,0,0,6,39,0,168,211,203,155,8,59,1,54,1,40,0,229,168,135,118,4,3,117,114,108,1,119,0,40,0,229,168,135,118,4,7,99,111,110,116,101,110,116,1,119,0,168,229,168,135,118,2,1,122,0,0,0,0,102,60,204,0,40,0,168,211,203,155,8,60,7,99,111,110,116,101,110,116,1,119,0,40,0,168,211,203,155,8,60,3,117,114,108,1,119,0,161,177,178,255,174,1,0,1,161,187,163,190,240,15,69,1,161,168,211,203,155,8,82,1,161,168,211,203,155,8,80,1,161,229,168,135,118,12,1,161,168,211,203,155,8,84,1,39,0,168,211,203,155,8,85,1,49,1,33,0,229,168,135,118,16,5,115,99,97,108,101,1,33,0,229,168,135,118,16,4,110,97,109,101,1,33,0,229,168,135,118,16,6,102,111,114,109,97,116,1,33,0,229,168,135,118,16,6,115,121,109,98,111,108,1,161,229,168,135,118,14,1,40,0,168,211,203,155,8,86,4,110,97,109,101,1,119,6,78,117,109,98,101,114,40,0,168,211,203,155,8,86,6,115,121,109,98,111,108,1,119,3,82,85,66,40,0,168,211,203,155,8,86,6,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,0,40,0,168,211,203,155,8,86,5,115,99,97,108,101,1,122,0,0,0,0,0,0,0,0,161,229,168,135,118,10,1,161,229,168,135,118,11,1,161,229,168,135,118,21,1,161,229,168,135,118,17,1,161,229,168,135,118,19,1,161,229,168,135,118,20,1,161,229,168,135,118,18,1,161,229,168,135,118,28,1,161,229,168,135,118,13,1,161,229,168,135,118,33,1,161,229,168,135,118,15,1,161,229,168,135,118,32,1,161,229,168,135,118,30,1,161,229,168,135,118,31,1,161,229,168,135,118,29,1,161,229,168,135,118,35,1,161,229,168,135,118,37,1,161,229,168,135,118,39,1,161,229,168,135,118,38,1,161,229,168,135,118,40,1,161,229,168,135,118,41,1,161,229,168,135,118,44,1,161,229,168,135,118,45,1,161,229,168,135,118,42,1,161,229,168,135,118,43,1,161,187,163,190,240,15,73,1,136,187,163,190,240,15,64,1,118,2,2,105,100,119,36,49,99,98,57,49,102,97,50,45,54,51,56,100,45,52,48,100,54,45,97,55,99,52,45,51,57,52,102,48,100,56,98,49,57,49,51,6,104,101,105,103,104,116,125,60,161,229,168,135,118,27,1,136,137,227,133,241,2,66,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,49,99,98,57,49,102,97,50,45,54,51,56,100,45,52,48,100,54,45,97,55,99,52,45,51,57,52,102,48,100,56,98,49,57,49,51,161,229,168,135,118,26,1,136,187,163,190,240,15,23,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,49,99,98,57,49,102,97,50,45,54,51,56,100,45,52,48,100,54,45,97,55,99,52,45,51,57,52,102,48,100,56,98,49,57,49,51,161,168,211,203,155,8,66,1,136,137,227,133,241,2,145,1,1,118,2,2,105,100,119,36,49,99,98,57,49,102,97,50,45,54,51,56,100,45,52,48,100,54,45,97,55,99,52,45,51,57,52,102,48,100,56,98,49,57,49,51,6,104,101,105,103,104,116,125,60,161,168,211,203,155,8,62,1,136,137,227,133,241,2,104,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,49,99,98,57,49,102,97,50,45,54,51,56,100,45,52,48,100,54,45,97,55,99,52,45,51,57,52,102,48,100,56,98,49,57,49,51,161,168,211,203,155,8,70,1,136,168,211,203,155,8,31,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,49,99,98,57,49,102,97,50,45,54,51,56,100,45,52,48,100,54,45,97,55,99,52,45,51,57,52,102,48,100,56,98,49,57,49,51,161,229,168,135,118,46,1,161,229,168,135,118,34,1,161,229,168,135,118,63,1,161,229,168,135,118,36,1,161,229,168,135,118,50,1,161,229,168,135,118,47,1,161,229,168,135,118,49,1,161,229,168,135,118,48,1,161,229,168,135,118,65,1,161,229,168,135,118,69,1,161,229,168,135,118,68,1,161,229,168,135,118,67,1,161,229,168,135,118,70,1,161,229,168,135,118,71,1,161,229,168,135,118,74,1,161,229,168,135,118,73,1,161,229,168,135,118,72,1,161,229,168,135,118,75,1,161,229,168,135,118,76,1,161,229,168,135,118,64,1,161,229,168,135,118,81,1,161,229,168,135,118,66,1,161,229,168,135,118,79,1,161,229,168,135,118,80,1,161,229,168,135,118,78,1,161,229,168,135,118,77,1,161,229,168,135,118,83,1,161,229,168,135,118,88,1,161,229,168,135,118,86,1,161,229,168,135,118,87,1,161,229,168,135,118,85,1,161,229,168,135,118,89,1,161,229,168,135,118,91,1,161,229,168,135,118,93,1,161,229,168,135,118,92,1,161,229,168,135,118,90,1,161,229,168,135,118,94,1,161,229,168,135,118,82,1,161,229,168,135,118,99,1,161,229,168,135,118,84,1,161,229,168,135,118,97,1,161,229,168,135,118,95,1,161,229,168,135,118,96,1,161,229,168,135,118,98,1,161,229,168,135,118,101,1,161,229,168,135,118,104,1,161,229,168,135,118,106,1,161,229,168,135,118,105,1,161,229,168,135,118,103,1,161,229,168,135,118,107,1,161,229,168,135,118,111,1,161,229,168,135,118,109,1,161,229,168,135,118,110,1,161,229,168,135,118,108,1,161,229,168,135,118,112,1,161,229,168,135,118,100,1,161,229,168,135,118,117,1,161,229,168,135,118,102,1,161,229,168,135,118,113,1,161,229,168,135,118,115,1,161,229,168,135,118,116,1,161,229,168,135,118,114,1,161,229,168,135,118,119,1,161,229,168,135,118,122,1,161,229,168,135,118,123,1,161,229,168,135,118,121,1,161,229,168,135,118,124,1,161,229,168,135,118,125,1,161,229,168,135,118,128,1,1,161,229,168,135,118,126,1,161,229,168,135,118,129,1,1,161,229,168,135,118,127,1,161,229,168,135,118,130,1,1,161,229,168,135,118,118,1,161,229,168,135,118,135,1,1,161,229,168,135,118,120,1,161,229,168,135,118,131,1,1,161,229,168,135,118,133,1,1,161,229,168,135,118,132,1,1,161,229,168,135,118,134,1,1,161,229,168,135,118,137,1,1,161,229,168,135,118,140,1,1,161,229,168,135,118,139,1,1,161,229,168,135,118,142,1,1,161,229,168,135,118,141,1,1,161,229,168,135,118,143,1,1,161,229,168,135,118,145,1,1,161,229,168,135,118,146,1,1,161,229,168,135,118,147,1,1,161,229,168,135,118,144,1,1,161,229,168,135,118,148,1,1,161,229,168,135,118,136,1,1,161,229,168,135,118,153,1,1,161,229,168,135,118,138,1,1,161,229,168,135,118,149,1,1,161,229,168,135,118,151,1,1,161,229,168,135,118,150,1,1,161,229,168,135,118,152,1,1,161,229,168,135,118,155,1,1,161,229,168,135,118,160,1,1,161,229,168,135,118,159,1,1,161,229,168,135,118,158,1,1,161,229,168,135,118,157,1,1,161,229,168,135,118,161,1,1,161,229,168,135,118,165,1,1,161,229,168,135,118,162,1,1,161,229,168,135,118,164,1,1,161,229,168,135,118,163,1,1,161,229,168,135,118,166,1,1,161,229,168,135,118,154,1,1,161,229,168,135,118,171,1,1,161,229,168,135,118,156,1,1,161,229,168,135,118,170,1,1,161,229,168,135,118,168,1,1,161,229,168,135,118,167,1,1,161,229,168,135,118,169,1,1,161,229,168,135,118,173,1,1,161,229,168,135,118,175,1,1,161,229,168,135,118,176,1,1,161,229,168,135,118,177,1,1,161,229,168,135,118,178,1,1,161,229,168,135,118,179,1,1,161,229,168,135,118,182,1,1,161,229,168,135,118,180,1,1,161,229,168,135,118,183,1,1,161,229,168,135,118,181,1,1,161,229,168,135,118,184,1,1,161,229,168,135,118,172,1,1,161,229,168,135,118,189,1,1,161,229,168,135,118,174,1,1,161,229,168,135,118,185,1,1,161,229,168,135,118,186,1,1,161,229,168,135,118,188,1,1,161,229,168,135,118,187,1,1,161,229,168,135,118,191,1,1,161,229,168,135,118,194,1,1,161,229,168,135,118,195,1,1,161,229,168,135,118,196,1,1,161,229,168,135,118,193,1,1,161,229,168,135,118,197,1,1,161,229,168,135,118,198,1,1,161,229,168,135,118,200,1,1,161,229,168,135,118,201,1,1,161,229,168,135,118,199,1,1,161,229,168,135,118,202,1,1,161,229,168,135,118,190,1,1,161,229,168,135,118,207,1,1,161,229,168,135,118,192,1,1,161,229,168,135,118,204,1,1,161,229,168,135,118,203,1,1,161,229,168,135,118,205,1,1,161,229,168,135,118,206,1,1,161,229,168,135,118,209,1,1,161,229,168,135,118,212,1,1,161,229,168,135,118,213,1,1,161,229,168,135,118,214,1,1,161,229,168,135,118,211,1,1,161,229,168,135,118,215,1,1,161,229,168,135,118,217,1,1,161,229,168,135,118,218,1,1,161,229,168,135,118,219,1,1,161,229,168,135,118,216,1,1,161,229,168,135,118,220,1,1,161,229,168,135,118,208,1,1,161,229,168,135,118,225,1,1,161,229,168,135,118,210,1,1,161,229,168,135,118,223,1,1,161,229,168,135,118,222,1,1,161,229,168,135,118,221,1,1,161,229,168,135,118,224,1,1,161,229,168,135,118,227,1,1,161,229,168,135,118,229,1,1,161,229,168,135,118,231,1,1,161,229,168,135,118,230,1,1,161,229,168,135,118,232,1,1,161,229,168,135,118,233,1,1,161,229,168,135,118,234,1,1,161,229,168,135,118,237,1,1,161,229,168,135,118,235,1,1,161,229,168,135,118,236,1,1,161,229,168,135,118,238,1,1,161,229,168,135,118,226,1,1,161,229,168,135,118,243,1,1,161,229,168,135,118,228,1,1,161,229,168,135,118,242,1,1,161,229,168,135,118,241,1,1,161,229,168,135,118,239,1,1,161,229,168,135,118,240,1,1,161,229,168,135,118,245,1,1,161,229,168,135,118,249,1,1,161,229,168,135,118,247,1,1,161,229,168,135,118,248,1,1,161,229,168,135,118,250,1,1,161,229,168,135,118,251,1,1,161,229,168,135,118,252,1,1,161,229,168,135,118,253,1,1,161,229,168,135,118,254,1,1,161,229,168,135,118,255,1,1,161,229,168,135,118,128,2,1,161,229,168,135,118,244,1,1,161,229,168,135,118,133,2,1,161,229,168,135,118,246,1,1,161,229,168,135,118,129,2,1,161,229,168,135,118,130,2,1,161,229,168,135,118,132,2,1,161,229,168,135,118,131,2,1,161,229,168,135,118,135,2,1,161,229,168,135,118,138,2,1,161,229,168,135,118,137,2,1,161,229,168,135,118,140,2,1,161,229,168,135,118,139,2,1,161,229,168,135,118,141,2,1,161,229,168,135,118,142,2,1,161,229,168,135,118,143,2,1,161,229,168,135,118,145,2,1,161,229,168,135,118,144,2,1,161,229,168,135,118,146,2,1,161,229,168,135,118,134,2,1,161,229,168,135,118,151,2,1,161,229,168,135,118,136,2,1,161,229,168,135,118,150,2,1,161,229,168,135,118,147,2,1,161,229,168,135,118,148,2,1,161,229,168,135,118,149,2,1,161,229,168,135,118,153,2,1,161,229,168,135,118,157,2,1,161,229,168,135,118,156,2,1,161,229,168,135,118,158,2,1,161,229,168,135,118,155,2,1,161,229,168,135,118,159,2,1,161,229,168,135,118,161,2,1,161,229,168,135,118,163,2,1,161,229,168,135,118,160,2,1,161,229,168,135,118,162,2,1,161,229,168,135,118,164,2,1,161,229,168,135,118,152,2,1,161,229,168,135,118,169,2,1,161,229,168,135,118,154,2,1,161,229,168,135,118,166,2,1,161,229,168,135,118,165,2,1,161,229,168,135,118,168,2,1,161,229,168,135,118,167,2,1,161,229,168,135,118,171,2,1,161,229,168,135,118,173,2,1,161,229,168,135,118,174,2,1,161,229,168,135,118,176,2,1,161,229,168,135,118,175,2,1,161,229,168,135,118,177,2,1,161,229,168,135,118,179,2,1,161,229,168,135,118,178,2,1,161,229,168,135,118,181,2,1,161,229,168,135,118,180,2,1,161,229,168,135,118,182,2,1,161,229,168,135,118,170,2,1,161,229,168,135,118,187,2,1,161,229,168,135,118,172,2,1,161,229,168,135,118,186,2,1,161,229,168,135,118,185,2,1,161,229,168,135,118,184,2,1,161,229,168,135,118,183,2,1,161,229,168,135,118,189,2,1,161,229,168,135,118,194,2,1,161,229,168,135,118,193,2,1,161,229,168,135,118,192,2,1,161,229,168,135,118,191,2,1,161,229,168,135,118,195,2,1,161,229,168,135,118,197,2,1,161,229,168,135,118,196,2,1,161,229,168,135,118,198,2,1,161,229,168,135,118,199,2,1,161,229,168,135,118,200,2,1,161,229,168,135,118,188,2,1,161,229,168,135,118,205,2,1,161,229,168,135,118,190,2,1,161,229,168,135,118,203,2,1,161,229,168,135,118,204,2,1,161,229,168,135,118,202,2,1,161,229,168,135,118,201,2,1,161,229,168,135,118,207,2,1,161,229,168,135,118,210,2,1,161,229,168,135,118,209,2,1,161,229,168,135,118,211,2,1,161,229,168,135,118,212,2,1,161,229,168,135,118,213,2,1,161,229,168,135,118,216,2,1,161,229,168,135,118,217,2,1,161,229,168,135,118,215,2,1,161,229,168,135,118,214,2,1,161,229,168,135,118,218,2,1,161,229,168,135,118,206,2,1,161,229,168,135,118,223,2,1,161,229,168,135,118,208,2,1,161,229,168,135,118,219,2,1,161,229,168,135,118,221,2,1,161,229,168,135,118,220,2,1,161,229,168,135,118,222,2,1,161,229,168,135,118,225,2,1,161,229,168,135,118,229,2,1,161,229,168,135,118,230,2,1,161,229,168,135,118,227,2,1,161,229,168,135,118,228,2,1,161,229,168,135,118,231,2,1,161,229,168,135,118,232,2,1,161,229,168,135,118,235,2,1,161,229,168,135,118,233,2,1,161,229,168,135,118,234,2,1,161,229,168,135,118,236,2,1,161,229,168,135,118,224,2,1,161,229,168,135,118,241,2,1,161,229,168,135,118,226,2,1,161,229,168,135,118,239,2,1,161,229,168,135,118,240,2,1,161,229,168,135,118,238,2,1,161,229,168,135,118,237,2,1,161,229,168,135,118,243,2,1,161,229,168,135,118,246,2,1,161,229,168,135,118,247,2,1,161,229,168,135,118,245,2,1,161,229,168,135,118,248,2,1,161,229,168,135,118,249,2,1,161,229,168,135,118,251,2,1,161,229,168,135,118,252,2,1,161,229,168,135,118,253,2,1,161,229,168,135,118,250,2,1,161,229,168,135,118,254,2,1,161,229,168,135,118,242,2,1,161,229,168,135,118,131,3,1,161,229,168,135,118,244,2,1,161,229,168,135,118,255,2,1,161,229,168,135,118,129,3,1,161,229,168,135,118,130,3,1,161,229,168,135,118,128,3,1,161,229,168,135,118,133,3,1,161,229,168,135,118,135,3,1,161,229,168,135,118,138,3,1,161,229,168,135,118,137,3,1,161,229,168,135,118,136,3,1,161,229,168,135,118,139,3,1,161,229,168,135,118,143,3,1,161,229,168,135,118,140,3,1,161,229,168,135,118,141,3,1,161,229,168,135,118,142,3,1,161,229,168,135,118,144,3,1,161,229,168,135,118,132,3,1,161,229,168,135,118,149,3,1,161,229,168,135,118,134,3,1,161,229,168,135,118,147,3,1,161,229,168,135,118,145,3,1,161,229,168,135,118,148,3,1,161,229,168,135,118,146,3,1,161,229,168,135,118,151,3,1,161,229,168,135,118,155,3,1,161,229,168,135,118,153,3,1,161,229,168,135,118,154,3,1,161,229,168,135,118,156,3,1,161,229,168,135,118,157,3,1,161,229,168,135,118,159,3,1,161,229,168,135,118,160,3,1,161,229,168,135,118,158,3,1,161,229,168,135,118,161,3,1,161,229,168,135,118,162,3,1,161,229,168,135,118,150,3,1,161,229,168,135,118,167,3,1,161,229,168,135,118,152,3,1,161,229,168,135,118,163,3,1,161,229,168,135,118,164,3,1,161,229,168,135,118,166,3,1,161,229,168,135,118,165,3,1,161,229,168,135,118,169,3,1,161,229,168,135,118,173,3,1,161,229,168,135,118,171,3,1,161,229,168,135,118,174,3,1,161,229,168,135,118,172,3,1,161,229,168,135,118,175,3,1,161,229,168,135,118,179,3,1,161,229,168,135,118,177,3,1,161,229,168,135,118,176,3,1,161,229,168,135,118,178,3,1,161,229,168,135,118,180,3,1,161,229,168,135,118,168,3,1,161,229,168,135,118,185,3,1,161,229,168,135,118,170,3,1,161,229,168,135,118,181,3,1,161,229,168,135,118,183,3,1,161,229,168,135,118,184,3,1,161,229,168,135,118,182,3,1,161,229,168,135,118,187,3,1,161,229,168,135,118,191,3,1,161,229,168,135,118,189,3,1,161,229,168,135,118,190,3,1,161,229,168,135,118,192,3,1,161,229,168,135,118,193,3,1,161,229,168,135,118,194,3,1,161,229,168,135,118,197,3,1,161,229,168,135,118,196,3,1,161,229,168,135,118,195,3,1,161,229,168,135,118,198,3,1,161,229,168,135,118,186,3,1,161,229,168,135,118,203,3,1,161,229,168,135,118,188,3,1,161,229,168,135,118,202,3,1,161,229,168,135,118,199,3,1,161,229,168,135,118,200,3,1,161,229,168,135,118,201,3,1,161,229,168,135,118,205,3,1,161,229,168,135,118,208,3,1,161,229,168,135,118,209,3,1,161,229,168,135,118,210,3,1,161,229,168,135,118,207,3,1,161,229,168,135,118,211,3,1,161,229,168,135,118,212,3,1,161,229,168,135,118,215,3,1,161,229,168,135,118,213,3,1,161,229,168,135,118,214,3,1,161,229,168,135,118,216,3,1,161,229,168,135,118,204,3,1,161,229,168,135,118,221,3,1,161,229,168,135,118,206,3,1,161,229,168,135,118,218,3,1,161,229,168,135,118,219,3,1,161,229,168,135,118,217,3,1,161,229,168,135,118,220,3,1,161,229,168,135,118,223,3,1,161,229,168,135,118,225,3,1,161,229,168,135,118,227,3,1,161,229,168,135,118,228,3,1,161,229,168,135,118,226,3,1,161,229,168,135,118,229,3,1,161,229,168,135,118,230,3,1,161,229,168,135,118,233,3,1,161,229,168,135,118,231,3,1,161,229,168,135,118,232,3,1,161,229,168,135,118,234,3,1,161,229,168,135,118,222,3,1,161,229,168,135,118,239,3,1,161,229,168,135,118,224,3,1,161,229,168,135,118,238,3,1,161,229,168,135,118,236,3,1,161,229,168,135,118,235,3,1,161,229,168,135,118,237,3,1,161,229,168,135,118,241,3,1,161,229,168,135,118,244,3,1,161,229,168,135,118,243,3,1,161,229,168,135,118,245,3,1,161,229,168,135,118,246,3,1,161,229,168,135,118,247,3,1,161,229,168,135,118,250,3,1,161,229,168,135,118,249,3,1,161,229,168,135,118,248,3,1,161,229,168,135,118,251,3,1,161,229,168,135,118,252,3,1,161,229,168,135,118,240,3,1,161,229,168,135,118,129,4,1,161,229,168,135,118,242,3,1,161,229,168,135,118,254,3,1,161,229,168,135,118,255,3,1,161,229,168,135,118,128,4,1,161,229,168,135,118,253,3,1,161,229,168,135,118,131,4,1,161,229,168,135,118,134,4,1,161,229,168,135,118,136,4,1,161,229,168,135,118,135,4,1,161,229,168,135,118,133,4,1,161,229,168,135,118,137,4,1,161,229,168,135,118,139,4,1,161,229,168,135,118,140,4,1,161,229,168,135,118,141,4,1,161,229,168,135,118,138,4,1,161,229,168,135,118,142,4,1,161,229,168,135,118,130,4,1,161,229,168,135,118,147,4,1,161,229,168,135,118,132,4,1,161,229,168,135,118,143,4,1,161,229,168,135,118,145,4,1,161,229,168,135,118,146,4,1,161,229,168,135,118,144,4,1,161,229,168,135,118,149,4,1,161,229,168,135,118,151,4,1,161,229,168,135,118,152,4,1,161,229,168,135,118,153,4,1,161,229,168,135,118,154,4,1,161,229,168,135,118,155,4,1,161,229,168,135,118,158,4,1,161,229,168,135,118,157,4,1,161,229,168,135,118,156,4,1,161,229,168,135,118,159,4,1,161,229,168,135,118,160,4,1,161,229,168,135,118,148,4,1,161,229,168,135,118,165,4,1,161,229,168,135,118,150,4,1,161,229,168,135,118,162,4,1,161,229,168,135,118,164,4,1,161,229,168,135,118,163,4,1,161,229,168,135,118,161,4,1,161,229,168,135,118,167,4,1,161,229,168,135,118,169,4,1,161,229,168,135,118,171,4,1,161,229,168,135,118,170,4,1,161,229,168,135,118,172,4,1,161,229,168,135,118,173,4,1,161,229,168,135,118,176,4,1,161,229,168,135,118,177,4,1,161,229,168,135,118,174,4,1,161,229,168,135,118,175,4,1,161,229,168,135,118,178,4,1,161,229,168,135,118,166,4,1,161,229,168,135,118,183,4,1,161,229,168,135,118,168,4,1,161,229,168,135,118,181,4,1,161,229,168,135,118,180,4,1,161,229,168,135,118,182,4,1,161,229,168,135,118,179,4,1,161,229,168,135,118,185,4,1,161,229,168,135,118,188,4,1,161,229,168,135,118,189,4,1,161,229,168,135,118,187,4,1,161,229,168,135,118,190,4,1,161,229,168,135,118,191,4,1,161,229,168,135,118,194,4,1,161,229,168,135,118,192,4,1,161,229,168,135,118,193,4,1,161,229,168,135,118,195,4,1,161,229,168,135,118,196,4,1,161,229,168,135,118,184,4,1,161,229,168,135,118,201,4,1,161,229,168,135,118,186,4,1,161,229,168,135,118,199,4,1,161,229,168,135,118,200,4,1,161,229,168,135,118,198,4,1,161,229,168,135,118,197,4,1,161,229,168,135,118,203,4,1,161,229,168,135,118,207,4,1,161,229,168,135,118,205,4,1,161,229,168,135,118,208,4,1,161,229,168,135,118,206,4,1,161,229,168,135,118,209,4,1,161,229,168,135,118,213,4,1,161,229,168,135,118,212,4,1,161,229,168,135,118,210,4,1,161,229,168,135,118,211,4,1,161,229,168,135,118,51,1,136,229,168,135,118,52,1,118,2,2,105,100,119,36,51,49,56,97,97,52,49,53,45,57,50,97,101,45,52,56,57,97,45,97,49,52,102,45,97,50,52,54,57,50,97,50,101,102,97,54,6,104,101,105,103,104,116,125,60,161,229,168,135,118,53,1,136,229,168,135,118,54,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,49,56,97,97,52,49,53,45,57,50,97,101,45,52,56,57,97,45,97,49,52,102,45,97,50,52,54,57,50,97,50,101,102,97,54,161,229,168,135,118,55,1,136,229,168,135,118,56,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,49,56,97,97,52,49,53,45,57,50,97,101,45,52,56,57,97,45,97,49,52,102,45,97,50,52,54,57,50,97,50,101,102,97,54,161,229,168,135,118,57,1,136,229,168,135,118,58,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,49,56,97,97,52,49,53,45,57,50,97,101,45,52,56,57,97,45,97,49,52,102,45,97,50,52,54,57,50,97,50,101,102,97,54,161,229,168,135,118,59,1,136,229,168,135,118,60,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,49,56,97,97,52,49,53,45,57,50,97,101,45,52,56,57,97,45,97,49,52,102,45,97,50,52,54,57,50,97,50,101,102,97,54,161,229,168,135,118,61,1,136,229,168,135,118,62,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,49,56,97,97,52,49,53,45,57,50,97,101,45,52,56,57,97,45,97,49,52,102,45,97,50,52,54,57,50,97,50,101,102,97,54,161,229,168,135,118,219,4,1,136,229,168,135,118,220,4,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,99,99,100,49,55,101,48,45,100,55,56,98,45,52,52,101,50,45,97,102,100,49,45,49,98,102,55,99,99,52,57,99,98,53,54,161,229,168,135,118,221,4,1,136,229,168,135,118,222,4,1,118,2,2,105,100,119,36,51,99,99,100,49,55,101,48,45,100,55,56,98,45,52,52,101,50,45,97,102,100,49,45,49,98,102,55,99,99,52,57,99,98,53,54,6,104,101,105,103,104,116,125,60,161,229,168,135,118,223,4,1,136,229,168,135,118,224,4,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,99,99,100,49,55,101,48,45,100,55,56,98,45,52,52,101,50,45,97,102,100,49,45,49,98,102,55,99,99,52,57,99,98,53,54,161,229,168,135,118,225,4,1,136,229,168,135,118,226,4,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,99,99,100,49,55,101,48,45,100,55,56,98,45,52,52,101,50,45,97,102,100,49,45,49,98,102,55,99,99,52,57,99,98,53,54,161,229,168,135,118,227,4,1,136,229,168,135,118,228,4,1,118,2,2,105,100,119,36,51,99,99,100,49,55,101,48,45,100,55,56,98,45,52,52,101,50,45,97,102,100,49,45,49,98,102,55,99,99,52,57,99,98,53,54,6,104,101,105,103,104,116,125,60,161,229,168,135,118,229,4,1,136,229,168,135,118,230,4,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,99,99,100,49,55,101,48,45,100,55,56,98,45,52,52,101,50,45,97,102,100,49,45,49,98,102,55,99,99,52,57,99,98,53,54,2,149,154,146,112,0,161,186,204,138,236,4,111,1,161,186,204,138,236,4,117,19,1,211,189,178,91,0,161,252,240,184,224,14,23,80,1,253,149,229,85,0,161,142,215,187,158,14,5,14,1,211,235,145,81,0,161,128,137,148,150,4,38,16,65,128,137,148,150,4,1,0,39,132,238,182,192,14,1,0,5,134,200,133,143,5,1,0,2,135,173,169,205,15,1,0,4,137,227,133,241,2,19,18,1,20,1,22,1,25,1,40,1,53,3,67,1,70,1,86,1,105,1,107,12,124,1,146,1,1,150,1,1,154,1,1,160,1,1,162,1,1,164,1,1,167,1,3,140,242,215,248,4,1,0,35,141,132,223,206,14,1,0,5,142,215,187,158,14,1,0,10,146,198,138,224,6,4,0,5,8,1,11,2,14,2,149,154,146,112,1,0,20,150,194,135,131,8,1,0,12,154,253,168,186,13,1,0,6,157,197,217,249,6,1,0,3,158,173,179,170,6,1,0,81,160,159,229,236,10,1,0,34,162,129,240,225,15,1,0,19,165,237,195,173,1,1,0,8,168,211,203,155,8,17,0,3,15,1,32,1,36,1,40,1,44,1,48,1,54,1,56,1,58,1,62,1,66,1,70,1,74,1,80,1,82,1,84,1,171,216,132,162,10,14,5,1,37,1,39,6,54,1,56,1,58,1,60,1,62,1,64,1,66,1,68,5,79,1,87,1,92,1,174,158,229,225,9,1,0,2,175,150,167,163,14,1,0,4,174,182,200,164,11,1,0,2,177,178,255,174,1,5,0,9,11,5,17,10,28,5,34,4,178,161,242,226,13,1,0,153,1,174,250,146,158,5,1,0,1,180,149,168,150,13,1,0,10,180,132,165,192,8,1,0,1,182,201,218,189,1,6,0,1,2,1,4,1,6,1,8,1,10,1,182,139,168,140,5,1,0,36,183,238,200,180,5,1,0,6,185,145,225,175,8,12,0,182,2,185,2,1,189,2,1,193,2,1,197,2,1,201,2,1,207,2,1,209,2,1,211,2,1,215,2,7,223,2,1,233,2,52,186,204,138,236,4,1,0,118,187,163,190,240,15,9,5,1,24,1,26,12,43,1,65,1,69,1,73,1,81,1,83,1,187,159,219,213,8,1,0,2,188,252,160,180,14,1,0,1,191,215,204,166,13,1,0,12,192,183,207,147,14,1,0,43,193,174,143,180,7,1,0,18,193,140,213,146,2,3,41,1,75,1,77,5,200,168,240,223,7,1,0,2,201,191,253,157,12,1,0,2,200,156,140,203,9,1,0,2,202,170,215,178,7,1,0,24,203,248,208,163,4,1,0,4,206,242,242,141,13,1,0,95,209,142,245,200,15,45,0,55,56,1,58,9,72,55,136,1,28,165,1,1,167,1,3,172,1,4,177,1,2,180,1,232,1,157,3,4,162,3,18,182,3,1,186,3,1,190,3,1,194,3,1,198,3,1,202,3,1,208,3,1,210,3,1,212,3,1,215,3,5,223,3,1,227,3,1,231,3,1,235,3,1,239,3,1,128,4,55,184,4,1,186,4,9,200,4,1,204,4,1,208,4,1,212,4,1,216,4,1,220,4,1,233,4,44,150,5,1,152,5,1,154,5,1,156,5,1,158,5,1,160,5,11,175,5,1,180,5,3,210,221,238,195,8,1,0,20,211,189,178,91,1,0,80,211,235,145,81,1,0,16,216,247,253,206,7,1,0,2,219,179,165,244,8,1,0,4,224,218,133,236,10,1,0,13,227,170,238,211,14,1,0,16,227,250,198,245,13,1,0,5,229,168,135,118,22,0,1,2,1,10,6,17,5,26,26,53,1,55,1,57,1,59,1,61,1,63,157,4,221,4,1,223,4,1,225,4,1,227,4,1,229,4,1,231,4,1,233,4,1,235,4,1,237,4,1,239,4,1,241,4,1,234,232,155,212,3,1,0,1,246,154,200,238,10,1,0,11,247,149,251,192,4,1,0,4,248,220,249,231,6,1,0,29,247,187,192,242,6,1,0,6,250,147,239,143,1,1,0,2,252,220,241,227,14,1,0,60,253,149,229,85,1,0,14,253,223,254,206,11,1,0,2,252,240,184,224,14,1,0,24],"version":0,"object_id":"4c658817-20db-4f56-b7f9-0637a22dfeb6"},"code":0,"message":"Operation completed successfully."} \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/fixtures/database/87bc006e-c1eb-47fd-9ac6-e39b17956369.json b/frontend/appflowy_web_app/cypress/fixtures/database/87bc006e-c1eb-47fd-9ac6-e39b17956369.json new file mode 100644 index 0000000000000..474a10765c2ba --- /dev/null +++ b/frontend/appflowy_web_app/cypress/fixtures/database/87bc006e-c1eb-47fd-9ac6-e39b17956369.json @@ -0,0 +1 @@ +{"data":{"state_vector":[2,144,224,143,199,14,16,201,175,140,129,8,161,6],"doc_state":[2,2,144,224,143,199,14,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,15,168,144,224,143,199,14,14,1,122,0,0,0,0,102,97,139,106,230,3,201,175,140,129,8,0,39,1,4,100,97,116,97,8,100,97,116,97,98,97,115,101,1,40,0,201,175,140,129,8,0,2,105,100,1,119,36,56,55,98,99,48,48,54,101,45,99,49,101,98,45,52,55,102,100,45,57,97,99,54,45,101,51,57,98,49,55,57,53,54,51,54,57,39,0,201,175,140,129,8,0,6,102,105,101,108,100,115,1,39,0,201,175,140,129,8,0,5,118,105,101,119,115,1,39,0,201,175,140,129,8,0,5,109,101,116,97,115,1,40,0,201,175,140,129,8,4,3,105,105,100,1,119,36,55,102,50,51,51,98,101,52,45,49,98,52,100,45,52,54,98,50,45,98,99,102,99,45,102,51,52,49,98,56,100,55,53,50,54,55,39,0,201,175,140,129,8,2,6,77,67,57,90,97,69,1,40,0,201,175,140,129,8,6,2,105,100,1,119,6,77,67,57,90,97,69,40,0,201,175,140,129,8,6,4,110,97,109,101,1,119,4,78,97,109,101,40,0,201,175,140,129,8,6,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,63,40,0,201,175,140,129,8,6,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,97,115,63,40,0,201,175,140,129,8,6,10,105,115,95,112,114,105,109,97,114,121,1,120,40,0,201,175,140,129,8,6,2,116,121,1,122,0,0,0,0,0,0,0,0,39,0,201,175,140,129,8,6,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,201,175,140,129,8,13,1,48,1,40,0,201,175,140,129,8,14,4,100,97,116,97,1,119,0,39,0,201,175,140,129,8,2,6,53,69,90,81,65,87,1,40,0,201,175,140,129,8,16,2,105,100,1,119,6,53,69,90,81,65,87,40,0,201,175,140,129,8,16,4,110,97,109,101,1,119,4,84,121,112,101,40,0,201,175,140,129,8,16,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,63,33,0,201,175,140,129,8,16,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,201,175,140,129,8,16,10,105,115,95,112,114,105,109,97,114,121,1,121,40,0,201,175,140,129,8,16,2,116,121,1,122,0,0,0,0,0,0,0,3,39,0,201,175,140,129,8,16,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,201,175,140,129,8,23,1,51,1,33,0,201,175,140,129,8,24,7,99,111,110,116,101,110,116,1,39,0,201,175,140,129,8,2,6,108,73,72,113,101,57,1,40,0,201,175,140,129,8,26,2,105,100,1,119,6,108,73,72,113,101,57,40,0,201,175,140,129,8,26,4,110,97,109,101,1,119,4,68,111,110,101,40,0,201,175,140,129,8,26,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,63,40,0,201,175,140,129,8,26,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,97,115,63,40,0,201,175,140,129,8,26,10,105,115,95,112,114,105,109,97,114,121,1,121,40,0,201,175,140,129,8,26,2,116,121,1,122,0,0,0,0,0,0,0,5,39,0,201,175,140,129,8,26,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,201,175,140,129,8,33,1,53,1,39,0,201,175,140,129,8,3,36,55,102,50,51,51,98,101,52,45,49,98,52,100,45,52,54,98,50,45,98,99,102,99,45,102,51,52,49,98,56,100,55,53,50,54,55,1,40,0,201,175,140,129,8,35,2,105,100,1,119,36,55,102,50,51,51,98,101,52,45,49,98,52,100,45,52,54,98,50,45,98,99,102,99,45,102,51,52,49,98,56,100,55,53,50,54,55,40,0,201,175,140,129,8,35,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,56,55,98,99,48,48,54,101,45,99,49,101,98,45,52,55,102,100,45,57,97,99,54,45,101,51,57,98,49,55,57,53,54,51,54,57,40,0,201,175,140,129,8,35,4,110,97,109,101,1,119,8,85,110,116,105,116,108,101,100,40,0,201,175,140,129,8,35,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,63,33,0,201,175,140,129,8,35,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,201,175,140,129,8,35,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,40,0,201,175,140,129,8,35,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,0,39,0,201,175,140,129,8,35,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,201,175,140,129,8,43,6,108,73,72,113,101,57,1,40,0,201,175,140,129,8,44,4,119,114,97,112,1,120,40,0,201,175,140,129,8,44,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,40,0,201,175,140,129,8,44,5,119,105,100,116,104,1,122,0,0,0,0,0,0,0,150,39,0,201,175,140,129,8,43,6,77,67,57,90,97,69,1,40,0,201,175,140,129,8,48,5,119,105,100,116,104,1,122,0,0,0,0,0,0,0,150,40,0,201,175,140,129,8,48,4,119,114,97,112,1,120,40,0,201,175,140,129,8,48,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,201,175,140,129,8,43,6,53,69,90,81,65,87,1,40,0,201,175,140,129,8,52,4,119,114,97,112,1,120,40,0,201,175,140,129,8,52,5,119,105,100,116,104,1,122,0,0,0,0,0,0,0,150,40,0,201,175,140,129,8,52,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,201,175,140,129,8,35,7,102,105,108,116,101,114,115,0,39,0,201,175,140,129,8,35,6,103,114,111,117,112,115,0,39,0,201,175,140,129,8,35,5,115,111,114,116,115,0,39,0,201,175,140,129,8,35,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,201,175,140,129,8,59,3,118,1,2,105,100,119,6,77,67,57,90,97,69,118,1,2,105,100,119,6,53,69,90,81,65,87,118,1,2,105,100,119,6,108,73,72,113,101,57,39,0,201,175,140,129,8,35,10,114,111,119,95,111,114,100,101,114,115,0,8,0,201,175,140,129,8,63,3,118,2,2,105,100,119,36,49,49,49,49,98,49,52,54,45,52,99,54,99,45,52,102,99,54,45,57,53,101,49,45,55,48,99,50,52,54,49,52,55,102,56,102,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,51,101,99,55,98,55,54,99,45,54,56,99,57,45,52,50,55,57,45,57,98,51,51,45,50,51,54,53,51,50,49,101,97,102,52,49,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,57,99,100,101,55,99,49,53,45,51,52,55,99,45,52,52,55,97,45,57,101,97,49,45,55,54,98,99,51,97,56,100,52,101,57,54,161,201,175,140,129,8,40,1,136,201,175,140,129,8,62,1,118,1,2,105,100,119,6,111,121,80,121,97,117,39,0,201,175,140,129,8,43,6,111,121,80,121,97,117,1,40,0,201,175,140,129,8,69,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,201,175,140,129,8,2,6,111,121,80,121,97,117,1,40,0,201,175,140,129,8,71,2,105,100,1,119,6,111,121,80,121,97,117,33,0,201,175,140,129,8,71,4,110,97,109,101,1,40,0,201,175,140,129,8,71,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,77,33,0,201,175,140,129,8,71,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,201,175,140,129,8,71,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,201,175,140,129,8,71,2,116,121,1,39,0,201,175,140,129,8,71,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,201,175,140,129,8,78,1,48,1,40,0,201,175,140,129,8,79,4,100,97,116,97,1,119,0,161,201,175,140,129,8,75,1,168,201,175,140,129,8,77,1,122,0,0,0,0,0,0,0,1,39,0,201,175,140,129,8,78,1,49,1,40,0,201,175,140,129,8,83,6,115,121,109,98,111,108,1,119,3,82,85,66,40,0,201,175,140,129,8,83,6,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,0,40,0,201,175,140,129,8,83,5,115,99,97,108,101,1,122,0,0,0,0,0,0,0,0,40,0,201,175,140,129,8,83,4,110,97,109,101,1,119,6,78,117,109,98,101,114,161,201,175,140,129,8,81,1,40,0,201,175,140,129,8,79,6,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,0,40,0,201,175,140,129,8,79,5,115,99,97,108,101,1,122,0,0,0,0,0,0,0,0,40,0,201,175,140,129,8,79,6,115,121,109,98,111,108,1,119,3,82,85,66,40,0,201,175,140,129,8,79,4,110,97,109,101,1,119,6,78,117,109,98,101,114,161,201,175,140,129,8,67,2,136,201,175,140,129,8,68,1,118,1,2,105,100,119,6,102,116,73,53,52,121,39,0,201,175,140,129,8,43,6,102,116,73,53,52,121,1,40,0,201,175,140,129,8,96,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,201,175,140,129,8,2,6,102,116,73,53,52,121,1,40,0,201,175,140,129,8,98,2,105,100,1,119,6,102,116,73,53,52,121,33,0,201,175,140,129,8,98,4,110,97,109,101,1,40,0,201,175,140,129,8,98,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,82,33,0,201,175,140,129,8,98,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,201,175,140,129,8,98,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,201,175,140,129,8,98,2,116,121,1,39,0,201,175,140,129,8,98,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,201,175,140,129,8,105,1,48,1,40,0,201,175,140,129,8,106,4,100,97,116,97,1,119,0,161,201,175,140,129,8,102,1,168,201,175,140,129,8,104,1,122,0,0,0,0,0,0,0,4,39,0,201,175,140,129,8,105,1,52,1,33,0,201,175,140,129,8,110,7,99,111,110,116,101,110,116,1,161,201,175,140,129,8,108,1,40,0,201,175,140,129,8,106,7,99,111,110,116,101,110,116,1,119,36,123,34,111,112,116,105,111,110,115,34,58,91,93,44,34,100,105,115,97,98,108,101,95,99,111,108,111,114,34,58,102,97,108,115,101,125,161,201,175,140,129,8,94,1,161,201,175,140,129,8,112,1,161,201,175,140,129,8,111,1,161,201,175,140,129,8,115,1,168,201,175,140,129,8,116,1,119,131,1,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,104,57,106,100,34,44,34,110,97,109,101,34,58,34,111,112,116,105,111,110,45,50,34,44,34,99,111,108,111,114,34,58,34,80,105,110,107,34,125,44,123,34,105,100,34,58,34,111,95,66,104,34,44,34,110,97,109,101,34,58,34,111,112,116,105,111,110,45,49,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,93,44,34,100,105,115,97,98,108,101,95,99,111,108,111,114,34,58,102,97,108,115,101,125,161,201,175,140,129,8,20,1,161,201,175,140,129,8,25,1,168,201,175,140,129,8,119,1,122,0,0,0,0,102,97,115,111,168,201,175,140,129,8,120,1,119,145,1,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,71,102,87,50,34,44,34,110,97,109,101,34,58,34,115,105,110,103,108,101,45,111,112,116,105,111,110,45,50,34,44,34,99,111,108,111,114,34,58,34,80,105,110,107,34,125,44,123,34,105,100,34,58,34,111,102,70,102,34,44,34,110,97,109,101,34,58,34,115,105,110,103,108,101,45,111,112,116,105,111,110,45,49,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,93,44,34,100,105,115,97,98,108,101,95,99,111,108,111,114,34,58,102,97,108,115,101,125,161,201,175,140,129,8,88,1,161,201,175,140,129,8,73,1,161,201,175,140,129,8,123,1,161,201,175,140,129,8,124,1,161,201,175,140,129,8,125,1,161,201,175,140,129,8,126,1,161,201,175,140,129,8,127,1,161,201,175,140,129,8,128,1,1,161,201,175,140,129,8,129,1,1,161,201,175,140,129,8,130,1,1,168,201,175,140,129,8,131,1,1,122,0,0,0,0,102,97,115,117,168,201,175,140,129,8,132,1,1,119,6,110,117,109,98,101,114,161,201,175,140,129,8,117,1,161,201,175,140,129,8,100,1,161,201,175,140,129,8,135,1,1,161,201,175,140,129,8,136,1,1,161,201,175,140,129,8,137,1,1,161,201,175,140,129,8,138,1,1,161,201,175,140,129,8,139,1,1,161,201,175,140,129,8,140,1,1,161,201,175,140,129,8,141,1,1,161,201,175,140,129,8,142,1,1,161,201,175,140,129,8,143,1,1,161,201,175,140,129,8,144,1,1,161,201,175,140,129,8,145,1,1,161,201,175,140,129,8,146,1,1,161,201,175,140,129,8,147,1,1,161,201,175,140,129,8,148,1,1,161,201,175,140,129,8,149,1,1,161,201,175,140,129,8,150,1,1,168,201,175,140,129,8,151,1,1,122,0,0,0,0,102,97,115,124,168,201,175,140,129,8,152,1,1,119,10,109,117,108,116,105,32,116,121,112,101,161,201,175,140,129,8,114,1,136,201,175,140,129,8,95,1,118,1,2,105,100,119,6,87,120,110,102,109,110,39,0,201,175,140,129,8,43,6,87,120,110,102,109,110,1,40,0,201,175,140,129,8,157,1,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,201,175,140,129,8,2,6,87,120,110,102,109,110,1,40,0,201,175,140,129,8,159,1,2,105,100,1,119,6,87,120,110,102,109,110,33,0,201,175,140,129,8,159,1,4,110,97,109,101,1,40,0,201,175,140,129,8,159,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,126,33,0,201,175,140,129,8,159,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,201,175,140,129,8,159,1,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,201,175,140,129,8,159,1,2,116,121,1,39,0,201,175,140,129,8,159,1,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,201,175,140,129,8,166,1,1,48,1,40,0,201,175,140,129,8,167,1,4,100,97,116,97,1,119,0,161,201,175,140,129,8,163,1,1,168,201,175,140,129,8,165,1,1,122,0,0,0,0,0,0,0,2,39,0,201,175,140,129,8,166,1,1,50,1,40,0,201,175,140,129,8,171,1,11,116,105,109,101,122,111,110,101,95,105,100,1,119,0,40,0,201,175,140,129,8,171,1,11,100,97,116,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,3,40,0,201,175,140,129,8,171,1,11,116,105,109,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,1,161,201,175,140,129,8,169,1,1,40,0,201,175,140,129,8,167,1,11,116,105,109,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,1,40,0,201,175,140,129,8,167,1,11,100,97,116,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,3,40,0,201,175,140,129,8,167,1,11,116,105,109,101,122,111,110,101,95,105,100,1,119,0,161,201,175,140,129,8,155,1,1,161,201,175,140,129,8,175,1,1,161,201,175,140,129,8,161,1,1,161,201,175,140,129,8,180,1,1,161,201,175,140,129,8,181,1,1,161,201,175,140,129,8,182,1,1,161,201,175,140,129,8,183,1,1,161,201,175,140,129,8,184,1,1,161,201,175,140,129,8,185,1,1,161,201,175,140,129,8,186,1,1,161,201,175,140,129,8,187,1,1,161,201,175,140,129,8,188,1,1,161,201,175,140,129,8,189,1,1,161,201,175,140,129,8,190,1,1,161,201,175,140,129,8,191,1,1,168,201,175,140,129,8,192,1,1,122,0,0,0,0,102,97,115,132,168,201,175,140,129,8,193,1,1,119,4,68,97,116,101,161,201,175,140,129,8,179,1,1,129,201,175,140,129,8,156,1,1,33,0,201,175,140,129,8,43,6,67,108,105,104,117,89,1,0,1,33,0,201,175,140,129,8,2,6,67,108,105,104,117,89,1,0,36,161,201,175,140,129,8,196,1,1,136,201,175,140,129,8,197,1,1,118,1,2,105,100,119,6,84,79,87,83,70,104,39,0,201,175,140,129,8,43,6,84,79,87,83,70,104,1,40,0,201,175,140,129,8,239,1,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,201,175,140,129,8,2,6,84,79,87,83,70,104,1,40,0,201,175,140,129,8,241,1,2,105,100,1,119,6,84,79,87,83,70,104,33,0,201,175,140,129,8,241,1,4,110,97,109,101,1,40,0,201,175,140,129,8,241,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,147,33,0,201,175,140,129,8,241,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,201,175,140,129,8,241,1,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,201,175,140,129,8,241,1,2,116,121,1,39,0,201,175,140,129,8,241,1,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,201,175,140,129,8,248,1,1,48,1,40,0,201,175,140,129,8,249,1,4,100,97,116,97,1,119,0,161,201,175,140,129,8,245,1,1,168,201,175,140,129,8,247,1,1,122,0,0,0,0,0,0,0,7,39,0,201,175,140,129,8,248,1,1,55,1,161,201,175,140,129,8,237,1,1,136,201,175,140,129,8,238,1,1,118,1,2,105,100,119,6,45,81,77,51,70,50,39,0,201,175,140,129,8,43,6,45,81,77,51,70,50,1,40,0,201,175,140,129,8,128,2,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,201,175,140,129,8,2,6,45,81,77,51,70,50,1,40,0,201,175,140,129,8,130,2,2,105,100,1,119,6,45,81,77,51,70,50,33,0,201,175,140,129,8,130,2,4,110,97,109,101,1,40,0,201,175,140,129,8,130,2,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,154,33,0,201,175,140,129,8,130,2,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,201,175,140,129,8,130,2,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,201,175,140,129,8,130,2,2,116,121,1,39,0,201,175,140,129,8,130,2,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,201,175,140,129,8,137,2,1,48,1,40,0,201,175,140,129,8,138,2,4,100,97,116,97,1,119,0,161,201,175,140,129,8,134,2,1,168,201,175,140,129,8,136,2,1,122,0,0,0,0,0,0,0,6,39,0,201,175,140,129,8,137,2,1,54,1,40,0,201,175,140,129,8,142,2,7,99,111,110,116,101,110,116,1,119,0,40,0,201,175,140,129,8,142,2,3,117,114,108,1,119,0,161,201,175,140,129,8,140,2,1,40,0,201,175,140,129,8,138,2,3,117,114,108,1,119,0,40,0,201,175,140,129,8,138,2,7,99,111,110,116,101,110,116,1,119,0,161,201,175,140,129,8,254,1,1,161,201,175,140,129,8,145,2,1,161,201,175,140,129,8,132,2,1,161,201,175,140,129,8,149,2,1,161,201,175,140,129,8,150,2,1,161,201,175,140,129,8,151,2,1,161,201,175,140,129,8,152,2,1,161,201,175,140,129,8,153,2,1,161,201,175,140,129,8,154,2,1,161,201,175,140,129,8,155,2,1,161,201,175,140,129,8,156,2,1,161,201,175,140,129,8,157,2,1,161,201,175,140,129,8,158,2,1,168,201,175,140,129,8,159,2,1,122,0,0,0,0,102,97,115,160,168,201,175,140,129,8,160,2,1,119,3,117,114,108,161,201,175,140,129,8,251,1,1,161,201,175,140,129,8,243,1,1,161,201,175,140,129,8,163,2,1,161,201,175,140,129,8,164,2,1,161,201,175,140,129,8,165,2,1,161,201,175,140,129,8,166,2,1,161,201,175,140,129,8,167,2,1,161,201,175,140,129,8,168,2,1,161,201,175,140,129,8,169,2,1,161,201,175,140,129,8,170,2,1,161,201,175,140,129,8,171,2,1,161,201,175,140,129,8,172,2,1,161,201,175,140,129,8,173,2,1,161,201,175,140,129,8,174,2,1,161,201,175,140,129,8,175,2,1,161,201,175,140,129,8,176,2,1,168,201,175,140,129,8,177,2,1,122,0,0,0,0,102,97,115,164,168,201,175,140,129,8,178,2,1,119,9,67,104,101,99,107,108,105,115,116,161,201,175,140,129,8,148,2,1,129,201,175,140,129,8,255,1,1,33,0,201,175,140,129,8,43,6,121,53,95,75,84,100,1,0,1,33,0,201,175,140,129,8,2,6,121,53,95,75,84,100,1,0,9,161,201,175,140,129,8,181,2,3,1,0,201,175,140,129,8,56,1,0,6,161,201,175,140,129,8,197,2,1,129,201,175,140,129,8,198,2,1,0,6,161,201,175,140,129,8,205,2,1,129,201,175,140,129,8,206,2,1,0,6,161,201,175,140,129,8,213,2,1,129,201,175,140,129,8,214,2,1,0,6,129,201,175,140,129,8,222,2,1,0,6,161,201,175,140,129,8,221,2,1,129,201,175,140,129,8,229,2,1,0,6,129,201,175,140,129,8,237,2,1,0,6,161,201,175,140,129,8,236,2,1,129,201,175,140,129,8,244,2,1,0,6,129,201,175,140,129,8,252,2,1,0,6,129,201,175,140,129,8,131,3,1,0,6,161,201,175,140,129,8,251,2,2,129,201,175,140,129,8,138,3,1,0,6,129,201,175,140,129,8,147,3,1,0,6,129,201,175,140,129,8,154,3,1,0,6,161,201,175,140,129,8,146,3,1,129,201,175,140,129,8,161,3,1,0,6,129,201,175,140,129,8,169,3,1,0,6,129,201,175,140,129,8,176,3,1,0,6,129,201,175,140,129,8,183,3,1,0,6,161,201,175,140,129,8,168,3,1,129,201,175,140,129,8,190,3,1,0,6,129,201,175,140,129,8,198,3,1,0,6,129,201,175,140,129,8,205,3,1,0,6,129,201,175,140,129,8,212,3,1,0,6,161,201,175,140,129,8,197,3,1,129,201,175,140,129,8,219,3,1,0,6,129,201,175,140,129,8,227,3,1,0,6,129,201,175,140,129,8,234,3,1,0,6,129,201,175,140,129,8,241,3,1,0,6,161,201,175,140,129,8,226,3,1,129,201,175,140,129,8,248,3,1,0,6,129,201,175,140,129,8,128,4,1,0,6,129,201,175,140,129,8,135,4,1,0,6,129,201,175,140,129,8,142,4,1,0,6,161,201,175,140,129,8,255,3,1,129,201,175,140,129,8,149,4,1,0,6,129,201,175,140,129,8,157,4,1,0,6,129,201,175,140,129,8,164,4,1,0,6,129,201,175,140,129,8,171,4,1,0,6,129,201,175,140,129,8,178,4,1,0,6,161,201,175,140,129,8,156,4,1,129,201,175,140,129,8,185,4,1,0,6,129,201,175,140,129,8,193,4,1,0,6,129,201,175,140,129,8,200,4,1,0,6,129,201,175,140,129,8,207,4,1,0,6,129,201,175,140,129,8,214,4,1,0,6,161,201,175,140,129,8,192,4,1,129,201,175,140,129,8,221,4,1,0,6,129,201,175,140,129,8,229,4,1,0,6,129,201,175,140,129,8,236,4,1,0,6,129,201,175,140,129,8,243,4,1,0,6,129,201,175,140,129,8,250,4,1,0,6,161,201,175,140,129,8,228,4,1,129,201,175,140,129,8,129,5,1,0,6,129,201,175,140,129,8,137,5,1,0,6,129,201,175,140,129,8,144,5,1,0,6,129,201,175,140,129,8,151,5,1,0,6,129,201,175,140,129,8,158,5,1,0,6,129,201,175,140,129,8,165,5,1,0,6,161,201,175,140,129,8,136,5,1,135,201,175,140,129,8,172,5,1,40,0,201,175,140,129,8,180,5,2,105,100,1,119,6,112,85,95,77,67,70,40,0,201,175,140,129,8,180,5,7,99,111,110,116,101,110,116,1,119,3,49,50,51,40,0,201,175,140,129,8,180,5,8,102,105,101,108,100,95,105,100,1,119,6,77,67,57,90,97,69,40,0,201,175,140,129,8,180,5,2,116,121,1,122,0,0,0,0,0,0,0,0,40,0,201,175,140,129,8,180,5,11,102,105,108,116,101,114,95,116,121,112,101,1,122,0,0,0,0,0,0,0,2,40,0,201,175,140,129,8,180,5,9,99,111,110,100,105,116,105,111,110,1,122,0,0,0,0,0,0,0,2,135,201,175,140,129,8,180,5,1,40,0,201,175,140,129,8,187,5,2,105,100,1,119,6,115,120,80,56,104,79,40,0,201,175,140,129,8,187,5,11,102,105,108,116,101,114,95,116,121,112,101,1,122,0,0,0,0,0,0,0,2,40,0,201,175,140,129,8,187,5,2,116,121,1,122,0,0,0,0,0,0,0,3,40,0,201,175,140,129,8,187,5,9,99,111,110,100,105,116,105,111,110,1,122,0,0,0,0,0,0,0,5,40,0,201,175,140,129,8,187,5,8,102,105,101,108,100,95,105,100,1,119,6,53,69,90,81,65,87,40,0,201,175,140,129,8,187,5,7,99,111,110,116,101,110,116,1,119,0,135,201,175,140,129,8,187,5,1,40,0,201,175,140,129,8,194,5,2,105,100,1,119,6,90,76,109,68,81,87,40,0,201,175,140,129,8,194,5,8,102,105,101,108,100,95,105,100,1,119,6,108,73,72,113,101,57,40,0,201,175,140,129,8,194,5,11,102,105,108,116,101,114,95,116,121,112,101,1,122,0,0,0,0,0,0,0,2,40,0,201,175,140,129,8,194,5,2,116,121,1,122,0,0,0,0,0,0,0,5,40,0,201,175,140,129,8,194,5,9,99,111,110,100,105,116,105,111,110,1,122,0,0,0,0,0,0,0,0,40,0,201,175,140,129,8,194,5,7,99,111,110,116,101,110,116,1,119,0,135,201,175,140,129,8,194,5,1,40,0,201,175,140,129,8,201,5,7,99,111,110,116,101,110,116,1,119,3,54,48,48,40,0,201,175,140,129,8,201,5,2,105,100,1,119,6,108,52,83,54,119,71,40,0,201,175,140,129,8,201,5,11,102,105,108,116,101,114,95,116,121,112,101,1,122,0,0,0,0,0,0,0,2,40,0,201,175,140,129,8,201,5,8,102,105,101,108,100,95,105,100,1,119,6,111,121,80,121,97,117,40,0,201,175,140,129,8,201,5,9,99,111,110,100,105,116,105,111,110,1,122,0,0,0,0,0,0,0,2,40,0,201,175,140,129,8,201,5,2,116,121,1,122,0,0,0,0,0,0,0,1,135,201,175,140,129,8,201,5,1,40,0,201,175,140,129,8,208,5,11,102,105,108,116,101,114,95,116,121,112,101,1,122,0,0,0,0,0,0,0,2,40,0,201,175,140,129,8,208,5,9,99,111,110,100,105,116,105,111,110,1,122,0,0,0,0,0,0,0,3,40,0,201,175,140,129,8,208,5,2,116,121,1,122,0,0,0,0,0,0,0,4,40,0,201,175,140,129,8,208,5,7,99,111,110,116,101,110,116,1,119,4,111,95,66,104,40,0,201,175,140,129,8,208,5,2,105,100,1,119,6,103,108,89,79,49,55,40,0,201,175,140,129,8,208,5,8,102,105,101,108,100,95,105,100,1,119,6,102,116,73,53,52,121,135,201,175,140,129,8,208,5,1,40,0,201,175,140,129,8,215,5,11,102,105,108,116,101,114,95,116,121,112,101,1,122,0,0,0,0,0,0,0,2,40,0,201,175,140,129,8,215,5,8,102,105,101,108,100,95,105,100,1,119,6,84,79,87,83,70,104,40,0,201,175,140,129,8,215,5,2,116,121,1,122,0,0,0,0,0,0,0,7,40,0,201,175,140,129,8,215,5,9,99,111,110,100,105,116,105,111,110,1,122,0,0,0,0,0,0,0,0,40,0,201,175,140,129,8,215,5,7,99,111,110,116,101,110,116,1,119,0,40,0,201,175,140,129,8,215,5,2,105,100,1,119,6,122,109,79,103,122,80,161,201,175,140,129,8,179,5,1,136,201,175,140,129,8,66,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,49,54,100,97,48,102,54,56,45,102,52,49,52,45,52,99,53,57,45,57,53,101,98,45,51,98,52,53,98,52,98,54,49,100,99,51,161,201,175,140,129,8,222,5,1,136,201,175,140,129,8,223,5,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,98,53,101,102,56,50,52,45,52,55,53,99,45,52,56,52,56,45,97,99,102,102,45,52,49,56,101,50,53,57,97,51,100,53,51,161,201,175,140,129,8,224,5,1,136,201,175,140,129,8,225,5,1,118,2,2,105,100,119,36,57,101,53,101,102,101,100,48,45,54,50,50,48,45,52,56,98,101,45,56,55,48,52,45,100,56,101,99,48,49,54,54,55,57,54,99,6,104,101,105,103,104,116,125,60,161,201,175,140,129,8,226,5,1,129,201,175,140,129,8,227,5,1,161,201,175,140,129,8,228,5,1,129,201,175,140,129,8,229,5,1,161,201,175,140,129,8,230,5,1,129,201,175,140,129,8,231,5,1,39,0,201,175,140,129,8,3,36,97,55,51,52,97,48,54,56,45,101,55,51,100,45,52,98,52,98,45,56,53,51,99,45,52,100,97,102,102,101,97,51,56,57,99,48,1,40,0,201,175,140,129,8,234,5,2,105,100,1,119,36,97,55,51,52,97,48,54,56,45,101,55,51,100,45,52,98,52,98,45,56,53,51,99,45,52,100,97,102,102,101,97,51,56,57,99,48,40,0,201,175,140,129,8,234,5,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,56,55,98,99,48,48,54,101,45,99,49,101,98,45,52,55,102,100,45,57,97,99,54,45,101,51,57,98,49,55,57,53,54,51,54,57,40,0,201,175,140,129,8,234,5,4,110,97,109,101,1,119,4,71,114,105,100,40,0,201,175,140,129,8,234,5,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,0,0,0,0,33,0,201,175,140,129,8,234,5,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,201,175,140,129,8,234,5,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,40,0,201,175,140,129,8,234,5,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,0,39,0,201,175,140,129,8,234,5,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,201,175,140,129,8,234,5,7,102,105,108,116,101,114,115,0,39,0,201,175,140,129,8,234,5,6,103,114,111,117,112,115,0,39,0,201,175,140,129,8,234,5,5,115,111,114,116,115,0,39,0,201,175,140,129,8,234,5,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,201,175,140,129,8,246,5,8,118,1,2,105,100,119,6,77,67,57,90,97,69,118,1,2,105,100,119,6,53,69,90,81,65,87,118,1,2,105,100,119,6,108,73,72,113,101,57,118,1,2,105,100,119,6,111,121,80,121,97,117,118,1,2,105,100,119,6,102,116,73,53,52,121,118,1,2,105,100,119,6,87,120,110,102,109,110,118,1,2,105,100,119,6,84,79,87,83,70,104,118,1,2,105,100,119,6,45,81,77,51,70,50,39,0,201,175,140,129,8,234,5,10,114,111,119,95,111,114,100,101,114,115,0,8,0,201,175,140,129,8,255,5,6,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,49,49,49,49,98,49,52,54,45,52,99,54,99,45,52,102,99,54,45,57,53,101,49,45,55,48,99,50,52,54,49,52,55,102,56,102,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,51,101,99,55,98,55,54,99,45,54,56,99,57,45,52,50,55,57,45,57,98,51,51,45,50,51,54,53,51,50,49,101,97,102,52,49,118,2,2,105,100,119,36,57,99,100,101,55,99,49,53,45,51,52,55,99,45,52,52,55,97,45,57,101,97,49,45,55,54,98,99,51,97,56,100,52,101,57,54,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,49,54,100,97,48,102,54,56,45,102,52,49,52,45,52,99,53,57,45,57,53,101,98,45,51,98,52,53,98,52,98,54,49,100,99,51,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,51,98,53,101,102,56,50,52,45,52,55,53,99,45,52,56,52,56,45,97,99,102,102,45,52,49,56,101,50,53,57,97,51,100,53,51,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,57,101,53,101,102,101,100,48,45,54,50,50,48,45,52,56,98,101,45,56,55,48,52,45,100,56,101,99,48,49,54,54,55,57,54,99,6,104,101,105,103,104,116,125,60,129,201,175,140,129,8,133,6,3,161,201,175,140,129,8,232,5,1,161,201,175,140,129,8,239,5,1,161,201,175,140,129,8,137,6,1,161,201,175,140,129,8,138,6,1,161,201,175,140,129,8,139,6,1,161,201,175,140,129,8,140,6,1,161,201,175,140,129,8,141,6,1,7,0,201,175,140,129,8,58,1,40,0,201,175,140,129,8,144,6,2,105,100,1,119,8,115,58,57,78,84,103,95,117,40,0,201,175,140,129,8,144,6,9,99,111,110,100,105,116,105,111,110,1,122,0,0,0,0,0,0,0,0,40,0,201,175,140,129,8,144,6,8,102,105,101,108,100,95,105,100,1,119,6,111,121,80,121,97,117,161,201,175,140,129,8,143,6,1,136,201,175,140,129,8,233,5,1,118,2,2,105,100,119,36,50,52,50,52,57,54,56,57,45,99,97,100,52,45,52,101,53,51,45,56,99,53,101,45,102,57,101,97,101,99,57,98,102,53,53,56,6,104,101,105,103,104,116,125,60,168,201,175,140,129,8,142,6,1,122,0,0,0,0,102,97,116,208,136,201,175,140,129,8,136,6,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,50,52,50,52,57,54,56,57,45,99,97,100,52,45,52,101,53,51,45,56,99,53,101,45,102,57,101,97,101,99,57,98,102,53,53,56,161,201,175,140,129,8,148,6,1,135,201,175,140,129,8,144,6,1,33,0,201,175,140,129,8,153,6,2,105,100,1,33,0,201,175,140,129,8,153,6,8,102,105,101,108,100,95,105,100,1,33,0,201,175,140,129,8,153,6,9,99,111,110,100,105,116,105,111,110,1,168,201,175,140,129,8,152,6,1,122,0,0,0,0,102,97,139,96,168,201,175,140,129,8,154,6,1,119,8,115,58,105,108,55,118,85,50,168,201,175,140,129,8,155,6,1,119,6,77,67,57,90,97,69,168,201,175,140,129,8,156,6,1,122,0,0,0,0,0,0,0,1,2,144,224,143,199,14,1,0,15,201,175,140,129,8,49,20,1,25,1,40,1,67,1,73,1,75,1,77,1,81,1,88,1,93,2,100,1,102,1,104,1,108,1,111,2,114,4,119,2,123,10,135,1,18,155,1,1,161,1,1,163,1,1,165,1,1,169,1,1,175,1,1,179,1,15,196,1,42,243,1,1,245,1,1,247,1,1,251,1,1,254,1,1,132,2,1,134,2,1,136,2,1,140,2,1,145,2,1,148,2,13,163,2,16,181,2,255,2,222,5,1,224,5,1,226,5,1,228,5,6,239,5,1,134,6,10,148,6,1,152,6,1,154,6,3],"version":0,"object_id":"87bc006e-c1eb-47fd-9ac6-e39b17956369"},"code":0,"message":"Operation completed successfully."} \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/fixtures/database/ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d.json b/frontend/appflowy_web_app/cypress/fixtures/database/ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d.json new file mode 100644 index 0000000000000..ceebd01573390 --- /dev/null +++ b/frontend/appflowy_web_app/cypress/fixtures/database/ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d.json @@ -0,0 +1 @@ +{"data":{"state_vector":[16,225,154,253,156,5,2,195,240,220,252,7,5,230,172,170,202,7,8,135,161,218,171,7,6,139,153,229,238,6,2,237,201,168,43,16,238,188,221,160,14,2,244,240,200,227,1,33,181,255,217,196,15,125,212,226,138,162,13,39,151,225,131,140,9,2,248,251,128,198,10,7,149,205,253,206,14,12,251,237,143,129,13,7,158,192,169,36,18,190,203,155,67,2],"doc_state":[16,102,181,255,217,196,15,0,39,1,4,100,97,116,97,8,100,97,116,97,98,97,115,101,1,40,0,181,255,217,196,15,0,2,105,100,1,119,36,97,100,55,100,99,52,53,98,45,52,52,98,53,45,52,57,56,102,45,98,102,97,50,45,48,102,52,51,98,102,48,53,99,99,48,100,39,0,181,255,217,196,15,0,6,102,105,101,108,100,115,1,39,0,181,255,217,196,15,0,5,118,105,101,119,115,1,39,0,181,255,217,196,15,0,5,109,101,116,97,115,1,40,0,181,255,217,196,15,4,3,105,105,100,1,119,36,48,99,101,49,51,52,49,53,45,54,99,99,101,45,52,52,57,55,45,57,52,99,54,45,52,55,53,97,100,57,54,99,50,52,57,101,39,0,181,255,217,196,15,2,6,51,111,45,90,115,109,1,40,0,181,255,217,196,15,6,2,105,100,1,119,6,51,111,45,90,115,109,40,0,181,255,217,196,15,6,4,110,97,109,101,1,119,11,68,101,115,99,114,105,112,116,105,111,110,40,0,181,255,217,196,15,6,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,39,162,40,0,181,255,217,196,15,6,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,76,39,162,40,0,181,255,217,196,15,6,10,105,115,95,112,114,105,109,97,114,121,1,120,40,0,181,255,217,196,15,6,2,116,121,1,122,0,0,0,0,0,0,0,0,39,0,181,255,217,196,15,6,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,181,255,217,196,15,13,1,48,1,40,0,181,255,217,196,15,14,4,100,97,116,97,1,119,0,33,0,181,255,217,196,15,2,6,121,52,52,50,48,119,1,0,9,39,0,181,255,217,196,15,3,36,48,99,101,49,51,52,49,53,45,54,99,99,101,45,52,52,57,55,45,57,52,99,54,45,52,55,53,97,100,57,54,99,50,52,57,101,1,40,0,181,255,217,196,15,26,2,105,100,1,119,36,48,99,101,49,51,52,49,53,45,54,99,99,101,45,52,52,57,55,45,57,52,99,54,45,52,55,53,97,100,57,54,99,50,52,57,101,40,0,181,255,217,196,15,26,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,97,100,55,100,99,52,53,98,45,52,52,98,53,45,52,57,56,102,45,98,102,97,50,45,48,102,52,51,98,102,48,53,99,99,48,100,40,0,181,255,217,196,15,26,4,110,97,109,101,1,119,8,85,110,116,105,116,108,101,100,40,0,181,255,217,196,15,26,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,39,162,33,0,181,255,217,196,15,26,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,181,255,217,196,15,26,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,39,0,181,255,217,196,15,32,1,49,1,40,0,181,255,217,196,15,33,21,104,105,100,101,95,117,110,103,114,111,117,112,101,100,95,99,111,108,117,109,110,1,121,40,0,181,255,217,196,15,33,22,99,111,108,108,97,112,115,101,95,104,105,100,100,101,110,95,103,114,111,117,112,115,1,121,40,0,181,255,217,196,15,26,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,1,39,0,181,255,217,196,15,26,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,181,255,217,196,15,37,6,51,111,45,90,115,109,1,40,0,181,255,217,196,15,38,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,40,0,181,255,217,196,15,38,5,119,105,100,116,104,1,122,0,0,0,0,0,0,0,150,40,0,181,255,217,196,15,38,4,119,114,97,112,1,120,33,0,181,255,217,196,15,37,6,121,52,52,50,48,119,1,0,3,39,0,181,255,217,196,15,26,7,102,105,108,116,101,114,115,0,39,0,181,255,217,196,15,26,6,103,114,111,117,112,115,0,39,0,181,255,217,196,15,26,5,115,111,114,116,115,0,39,0,181,255,217,196,15,26,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,181,255,217,196,15,49,1,118,1,2,105,100,119,6,51,111,45,90,115,109,129,181,255,217,196,15,50,1,39,0,181,255,217,196,15,26,10,114,111,119,95,111,114,100,101,114,115,0,8,0,181,255,217,196,15,52,3,118,2,2,105,100,119,36,50,48,56,100,50,52,56,102,45,53,99,48,56,45,52,98,101,53,45,97,48,50,50,45,101,48,97,57,55,99,50,100,55,48,53,101,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,100,51,50,101,52,56,97,52,45,99,102,48,100,45,52,56,97,56,45,57,53,57,57,45,53,51,51,57,97,56,49,53,56,99,53,48,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,56,101,56,55,56,51,48,55,45,97,98,49,101,45,52,50,101,53,45,56,57,55,98,45,56,97,51,101,97,55,56,97,52,53,49,53,161,181,255,217,196,15,31,1,7,0,181,255,217,196,15,47,1,33,0,181,255,217,196,15,57,6,103,114,111,117,112,115,1,33,0,181,255,217,196,15,57,8,102,105,101,108,100,95,105,100,1,33,0,181,255,217,196,15,57,2,116,121,1,33,0,181,255,217,196,15,57,7,99,111,110,116,101,110,116,1,33,0,181,255,217,196,15,57,2,105,100,1,161,181,255,217,196,15,56,1,161,181,255,217,196,15,58,1,0,4,161,181,255,217,196,15,62,1,161,181,255,217,196,15,60,1,161,181,255,217,196,15,61,1,161,181,255,217,196,15,59,1,39,0,181,255,217,196,15,3,36,101,52,99,56,57,52,50,49,45,49,50,98,50,45,52,100,48,50,45,56,54,51,100,45,50,48,57,52,57,101,101,99,57,50,55,49,1,40,0,181,255,217,196,15,73,2,105,100,1,119,36,101,52,99,56,57,52,50,49,45,49,50,98,50,45,52,100,48,50,45,56,54,51,100,45,50,48,57,52,57,101,101,99,57,50,55,49,40,0,181,255,217,196,15,73,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,97,100,55,100,99,52,53,98,45,52,52,98,53,45,52,57,56,102,45,98,102,97,50,45,48,102,52,51,98,102,48,53,99,99,48,100,40,0,181,255,217,196,15,73,4,110,97,109,101,1,119,8,85,110,116,105,116,108,101,100,40,0,181,255,217,196,15,73,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,0,0,0,0,33,0,181,255,217,196,15,73,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,181,255,217,196,15,73,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,40,0,181,255,217,196,15,73,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,0,39,0,181,255,217,196,15,73,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,181,255,217,196,15,73,7,102,105,108,116,101,114,115,0,39,0,181,255,217,196,15,73,6,103,114,111,117,112,115,0,39,0,181,255,217,196,15,73,5,115,111,114,116,115,0,39,0,181,255,217,196,15,73,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,181,255,217,196,15,85,1,118,1,2,105,100,119,6,51,111,45,90,115,109,129,181,255,217,196,15,86,1,39,0,181,255,217,196,15,73,10,114,111,119,95,111,114,100,101,114,115,0,8,0,181,255,217,196,15,88,3,118,2,2,105,100,119,36,50,48,56,100,50,52,56,102,45,53,99,48,56,45,52,98,101,53,45,97,48,50,50,45,101,48,97,57,55,99,50,100,55,48,53,101,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,100,51,50,101,52,56,97,52,45,99,102,48,100,45,52,56,97,56,45,57,53,57,57,45,53,51,51,57,97,56,49,53,56,99,53,48,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,56,101,56,55,56,51,48,55,45,97,98,49,101,45,52,50,101,53,45,56,57,55,98,45,56,97,51,101,97,55,56,97,52,53,49,53,161,181,255,217,196,15,78,1,161,181,255,217,196,15,63,2,161,181,255,217,196,15,92,1,168,181,255,217,196,15,95,1,122,0,0,0,0,102,76,39,194,136,181,255,217,196,15,87,1,118,1,2,105,100,119,6,81,51,56,55,119,49,39,0,181,255,217,196,15,81,6,81,51,56,55,119,49,1,40,0,181,255,217,196,15,98,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,161,181,255,217,196,15,94,1,136,181,255,217,196,15,51,1,118,1,2,105,100,119,6,81,51,56,55,119,49,39,0,181,255,217,196,15,37,6,81,51,56,55,119,49,1,40,0,181,255,217,196,15,102,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,39,0,181,255,217,196,15,2,6,81,51,56,55,119,49,1,40,0,181,255,217,196,15,104,2,105,100,1,119,6,81,51,56,55,119,49,40,0,181,255,217,196,15,104,4,110,97,109,101,1,119,8,67,104,101,99,107,98,111,120,40,0,181,255,217,196,15,104,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,39,194,40,0,181,255,217,196,15,104,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,76,39,194,40,0,181,255,217,196,15,104,10,105,115,95,112,114,105,109,97,114,121,1,121,40,0,181,255,217,196,15,104,2,116,121,1,122,0,0,0,0,0,0,0,5,39,0,181,255,217,196,15,104,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,181,255,217,196,15,111,1,53,1,168,181,255,217,196,15,100,1,122,0,0,0,0,102,76,39,200,168,181,255,217,196,15,69,1,119,8,103,58,85,113,84,54,68,80,168,181,255,217,196,15,70,1,122,0,0,0,0,0,0,0,3,168,181,255,217,196,15,72,1,119,6,121,52,52,50,48,119,168,181,255,217,196,15,71,1,119,0,167,181,255,217,196,15,64,0,8,0,181,255,217,196,15,118,6,118,2,2,105,100,119,6,121,52,52,50,48,119,7,118,105,115,105,98,108,101,120,118,2,2,105,100,119,4,117,76,117,51,7,118,105,115,105,98,108,101,120,118,2,7,118,105,115,105,98,108,101,120,2,105,100,119,4,73,113,105,73,118,2,2,105,100,119,4,82,69,88,119,7,118,105,115,105,98,108,101,120,118,2,7,118,105,115,105,98,108,101,120,2,105,100,119,3,89,101,115,118,2,2,105,100,119,2,78,111,7,118,105,115,105,98,108,101,120,1,149,205,253,206,14,0,161,135,161,218,171,7,5,12,1,238,188,221,160,14,0,161,149,205,253,206,14,11,2,1,212,226,138,162,13,0,161,251,237,143,129,13,6,39,1,251,237,143,129,13,0,161,248,251,128,198,10,6,7,1,248,251,128,198,10,0,161,151,225,131,140,9,1,7,1,151,225,131,140,9,0,161,244,240,200,227,1,32,2,1,195,240,220,252,7,0,161,139,153,229,238,6,1,5,2,230,172,170,202,7,0,161,195,240,220,252,7,4,7,168,230,172,170,202,7,6,1,122,0,0,0,0,102,88,25,34,1,135,161,218,171,7,0,161,225,154,253,156,5,1,6,1,139,153,229,238,6,0,161,158,192,169,36,17,2,1,225,154,253,156,5,0,161,237,201,168,43,15,2,1,244,240,200,227,1,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,33,1,190,203,155,67,0,161,135,161,218,171,7,5,2,1,237,201,168,43,0,161,212,226,138,162,13,38,16,1,158,192,169,36,0,161,238,188,221,160,14,1,18,16,225,154,253,156,5,1,0,2,195,240,220,252,7,1,0,5,230,172,170,202,7,1,0,7,135,161,218,171,7,1,0,6,139,153,229,238,6,1,0,2,237,201,168,43,1,0,16,238,188,221,160,14,1,0,2,244,240,200,227,1,1,0,33,181,255,217,196,15,10,16,10,31,1,42,4,51,1,56,1,58,15,78,1,87,1,92,4,100,1,212,226,138,162,13,1,0,39,151,225,131,140,9,1,0,2,248,251,128,198,10,1,0,7,149,205,253,206,14,1,0,12,251,237,143,129,13,1,0,7,158,192,169,36,1,0,18,190,203,155,67,1,0,2],"version":0,"object_id":"ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d"},"code":0,"message":"Operation completed successfully."} \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/fixtures/database/ce267d12-3b61-4ebb-bb03-d65272f5f817.json b/frontend/appflowy_web_app/cypress/fixtures/database/ce267d12-3b61-4ebb-bb03-d65272f5f817.json new file mode 100644 index 0000000000000..428ca72d5b8dc --- /dev/null +++ b/frontend/appflowy_web_app/cypress/fixtures/database/ce267d12-3b61-4ebb-bb03-d65272f5f817.json @@ -0,0 +1 @@ +{"data":{"state_vector":[20,162,178,170,161,1,6,131,222,171,184,9,39,130,208,239,179,11,2,133,224,179,154,9,2,231,138,159,208,10,2,231,217,162,139,1,5,170,249,160,147,7,21,139,202,180,177,14,33,236,192,251,208,2,9,204,220,240,227,3,212,1,145,151,150,143,2,7,146,214,128,188,1,65,243,175,215,198,3,7,212,178,171,164,8,14,149,159,177,202,15,2,149,178,144,155,15,8,247,242,142,226,14,6,249,247,244,162,15,37,219,228,172,146,1,10,251,157,254,151,3,107],"doc_state":[20,1,149,159,177,202,15,0,161,170,249,160,147,7,20,2,22,249,247,244,162,15,0,39,0,251,157,254,151,3,3,36,50,98,102,53,48,99,48,51,45,102,52,49,102,45,52,51,54,51,45,98,53,98,49,45,49,48,49,50,49,54,97,54,99,53,99,99,1,40,0,249,247,244,162,15,0,2,105,100,1,119,36,50,98,102,53,48,99,48,51,45,102,52,49,102,45,52,51,54,51,45,98,53,98,49,45,49,48,49,50,49,54,97,54,99,53,99,99,40,0,249,247,244,162,15,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,99,101,50,54,55,100,49,50,45,51,98,54,49,45,52,101,98,98,45,98,98,48,51,45,100,54,53,50,55,50,102,53,102,56,49,55,40,0,249,247,244,162,15,0,4,110,97,109,101,1,119,16,86,105,101,119,32,111,102,32,67,97,108,101,110,100,97,114,40,0,249,247,244,162,15,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,0,0,0,0,40,0,249,247,244,162,15,0,11,109,111,100,105,102,105,101,100,95,97,116,1,122,0,0,0,0,0,0,0,0,39,0,249,247,244,162,15,0,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,39,0,249,247,244,162,15,6,1,50,1,40,0,249,247,244,162,15,7,9,108,97,121,111,117,116,95,116,121,1,122,0,0,0,0,0,0,0,0,40,0,249,247,244,162,15,7,17,115,104,111,119,95,119,101,101,107,95,110,117,109,98,101,114,115,1,120,40,0,249,247,244,162,15,7,17,102,105,114,115,116,95,100,97,121,95,111,102,95,119,101,101,107,1,122,0,0,0,0,0,0,0,0,40,0,249,247,244,162,15,7,13,115,104,111,119,95,119,101,101,107,101,110,100,115,1,120,40,0,249,247,244,162,15,7,8,102,105,101,108,100,95,105,100,1,119,6,71,115,66,65,97,76,40,0,249,247,244,162,15,0,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,2,39,0,249,247,244,162,15,0,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,249,247,244,162,15,0,7,102,105,108,116,101,114,115,0,39,0,249,247,244,162,15,0,6,103,114,111,117,112,115,0,39,0,249,247,244,162,15,0,5,115,111,114,116,115,0,39,0,249,247,244,162,15,0,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,249,247,244,162,15,18,11,118,1,2,105,100,119,6,72,95,74,113,85,76,118,1,2,105,100,119,6,55,85,107,117,54,82,118,1,2,105,100,119,6,95,82,45,112,104,105,118,1,2,105,100,119,6,99,78,53,98,120,74,118,1,2,105,100,119,6,71,115,66,65,97,76,118,1,2,105,100,119,6,71,79,80,107,116,118,118,1,2,105,100,119,6,70,99,112,109,80,101,118,1,2,105,100,119,6,112,70,120,57,67,45,118,1,2,105,100,119,6,101,49,98,55,48,88,118,1,2,105,100,119,6,80,78,113,89,102,76,118,1,2,105,100,119,6,75,71,50,113,74,65,39,0,249,247,244,162,15,0,10,114,111,119,95,111,114,100,101,114,115,0,8,0,249,247,244,162,15,30,6,118,2,2,105,100,119,36,55,55,49,55,48,55,57,98,45,48,53,98,54,45,52,97,48,97,45,56,101,101,52,45,52,56,55,51,57,102,98,102,51,97,53,50,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,97,55,51,54,55,52,97,101,45,51,51,48,49,45,52,53,97,51,45,98,56,48,49,45,51,102,49,50,101,54,102,99,98,53,54,54,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,50,49,53,48,99,102,102,54,45,102,102,56,48,45,52,51,51,52,45,56,99,56,97,45,57,52,101,56,50,97,54,52,51,55,57,97,6,104,101,105,103,104,116,125,60,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,53,49,99,102,48,57,48,54,45,97,100,52,54,45,52,100,97,101,45,97,51,98,57,45,50,101,48,48,51,102,56,51,54,56,99,49,118,2,2,105,100,119,36,97,48,48,101,99,102,55,56,45,97,56,50,51,45,52,51,102,49,45,98,53,52,50,45,101,100,48,55,49,51,57,52,97,55,49,55,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,57,50,97,50,49,51,55,101,45,98,48,48,98,45,52,51,56,56,45,56,53,49,102,45,97,48,101,102,99,51,100,101,55,99,97,51,6,104,101,105,103,104,116,125,60,2,149,178,144,155,15,0,161,231,217,162,139,1,4,7,168,149,178,144,155,15,6,1,122,0,0,0,0,102,88,25,34,1,247,242,142,226,14,0,161,243,175,215,198,3,6,6,1,139,202,180,177,14,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,33,1,130,208,239,179,11,0,161,247,242,142,226,14,5,2,1,231,138,159,208,10,0,161,219,228,172,146,1,9,2,1,131,222,171,184,9,0,161,146,214,128,188,1,64,39,1,133,224,179,154,9,0,161,231,138,159,208,10,1,2,1,212,178,171,164,8,0,161,162,178,170,161,1,5,14,1,170,249,160,147,7,0,161,133,224,179,154,9,1,21,204,1,204,220,240,227,3,0,161,251,157,254,151,3,91,1,136,251,157,254,151,3,74,1,118,2,2,105,100,119,36,97,55,51,54,55,52,97,101,45,51,51,48,49,45,52,53,97,51,45,98,56,48,49,45,51,102,49,50,101,54,102,99,98,53,54,54,6,104,101,105,103,104,116,125,60,161,204,220,240,227,3,0,1,136,204,220,240,227,3,1,1,118,2,2,105,100,119,36,50,49,53,48,99,102,102,54,45,102,102,56,48,45,52,51,51,52,45,56,99,56,97,45,57,52,101,56,50,97,54,52,51,55,57,97,6,104,101,105,103,104,116,125,60,161,204,220,240,227,3,2,1,136,204,220,240,227,3,3,1,118,2,2,105,100,119,36,53,49,99,102,48,57,48,54,45,97,100,52,54,45,52,100,97,101,45,97,51,98,57,45,50,101,48,48,51,102,56,51,54,56,99,49,6,104,101,105,103,104,116,125,60,161,204,220,240,227,3,4,1,136,204,220,240,227,3,5,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,97,48,48,101,99,102,55,56,45,97,56,50,51,45,52,51,102,49,45,98,53,52,50,45,101,100,48,55,49,51,57,52,97,55,49,55,39,0,251,157,254,151,3,3,36,54,54,97,54,102,51,98,99,45,99,55,56,102,45,52,102,55,52,45,97,48,57,101,45,48,56,100,52,55,49,55,98,102,49,102,100,1,40,0,204,220,240,227,3,8,2,105,100,1,119,36,54,54,97,54,102,51,98,99,45,99,55,56,102,45,52,102,55,52,45,97,48,57,101,45,48,56,100,52,55,49,55,98,102,49,102,100,40,0,204,220,240,227,3,8,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,99,101,50,54,55,100,49,50,45,51,98,54,49,45,52,101,98,98,45,98,98,48,51,45,100,54,53,50,55,50,102,53,102,56,49,55,40,0,204,220,240,227,3,8,4,110,97,109,101,1,119,4,71,114,105,100,40,0,204,220,240,227,3,8,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,0,0,0,0,33,0,204,220,240,227,3,8,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,204,220,240,227,3,8,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,40,0,204,220,240,227,3,8,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,0,39,0,204,220,240,227,3,8,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,204,220,240,227,3,8,7,102,105,108,116,101,114,115,0,39,0,204,220,240,227,3,8,6,103,114,111,117,112,115,0,39,0,204,220,240,227,3,8,5,115,111,114,116,115,0,39,0,204,220,240,227,3,8,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,204,220,240,227,3,20,5,118,1,2,105,100,119,6,72,95,74,113,85,76,118,1,2,105,100,119,6,55,85,107,117,54,82,118,1,2,105,100,119,6,95,82,45,112,104,105,118,1,2,105,100,119,6,99,78,53,98,120,74,118,1,2,105,100,119,6,71,115,66,65,97,76,39,0,204,220,240,227,3,8,10,114,111,119,95,111,114,100,101,114,115,0,8,0,204,220,240,227,3,26,5,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,55,55,49,55,48,55,57,98,45,48,53,98,54,45,52,97,48,97,45,56,101,101,52,45,52,56,55,51,57,102,98,102,51,97,53,50,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,97,55,51,54,55,52,97,101,45,51,51,48,49,45,52,53,97,51,45,98,56,48,49,45,51,102,49,50,101,54,102,99,98,53,54,54,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,50,49,53,48,99,102,102,54,45,102,102,56,48,45,52,51,51,52,45,56,99,56,97,45,57,52,101,56,50,97,54,52,51,55,57,97,118,2,2,105,100,119,36,53,49,99,102,48,57,48,54,45,97,100,52,54,45,52,100,97,101,45,97,51,98,57,45,50,101,48,48,51,102,56,51,54,56,99,49,6,104,101,105,103,104,116,125,60,118,2,2,105,100,119,36,97,48,48,101,99,102,55,56,45,97,56,50,51,45,52,51,102,49,45,98,53,52,50,45,101,100,48,55,49,51,57,52,97,55,49,55,6,104,101,105,103,104,116,125,60,161,251,157,254,151,3,85,1,168,251,157,254,151,3,87,1,122,0,0,0,0,0,0,0,6,39,0,251,157,254,151,3,88,1,54,1,40,0,204,220,240,227,3,34,7,99,111,110,116,101,110,116,1,119,0,40,0,204,220,240,227,3,34,3,117,114,108,1,119,0,168,204,220,240,227,3,32,1,122,0,0,0,0,102,77,165,82,40,0,251,157,254,151,3,89,7,99,111,110,116,101,110,116,1,119,0,40,0,251,157,254,151,3,89,3,117,114,108,1,119,0,161,204,220,240,227,3,6,1,161,204,220,240,227,3,13,1,161,204,220,240,227,3,40,1,136,251,157,254,151,3,92,1,118,1,2,105,100,119,6,71,79,80,107,116,118,39,0,251,157,254,151,3,52,6,71,79,80,107,116,118,1,40,0,204,220,240,227,3,44,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,204,220,240,227,3,41,1,136,204,220,240,227,3,25,1,118,1,2,105,100,119,6,71,79,80,107,116,118,39,0,204,220,240,227,3,16,6,71,79,80,107,116,118,1,40,0,204,220,240,227,3,48,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,251,157,254,151,3,2,6,71,79,80,107,116,118,1,40,0,204,220,240,227,3,50,2,105,100,1,119,6,71,79,80,107,116,118,40,0,204,220,240,227,3,50,4,110,97,109,101,1,119,4,84,101,120,116,40,0,204,220,240,227,3,50,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,99,33,0,204,220,240,227,3,50,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,204,220,240,227,3,50,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,204,220,240,227,3,50,2,116,121,1,39,0,204,220,240,227,3,50,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,204,220,240,227,3,57,1,48,1,40,0,204,220,240,227,3,58,4,100,97,116,97,1,119,0,161,204,220,240,227,3,54,1,168,204,220,240,227,3,56,1,122,0,0,0,0,0,0,0,3,39,0,204,220,240,227,3,57,1,51,1,33,0,204,220,240,227,3,62,7,99,111,110,116,101,110,116,1,161,204,220,240,227,3,60,1,40,0,204,220,240,227,3,58,7,99,111,110,116,101,110,116,1,119,36,123,34,111,112,116,105,111,110,115,34,58,91,93,44,34,100,105,115,97,98,108,101,95,99,111,108,111,114,34,58,102,97,108,115,101,125,161,204,220,240,227,3,42,1,161,204,220,240,227,3,46,1,168,251,157,254,151,3,75,1,122,0,0,0,0,102,77,165,108,168,251,157,254,151,3,76,1,119,121,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,110,103,110,85,34,44,34,110,97,109,101,34,58,34,49,49,49,34,44,34,99,111,108,111,114,34,58,34,80,105,110,107,34,125,44,123,34,105,100,34,58,34,73,73,66,100,34,44,34,110,97,109,101,34,58,34,49,50,50,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,93,44,34,100,105,115,97,98,108,101,95,99,111,108,111,114,34,58,102,97,108,115,101,125,161,204,220,240,227,3,64,1,161,204,220,240,227,3,63,1,168,204,220,240,227,3,70,1,122,0,0,0,0,102,77,165,117,168,204,220,240,227,3,71,1,119,121,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,89,101,75,100,34,44,34,110,97,109,101,34,58,34,51,50,49,34,44,34,99,111,108,111,114,34,58,34,80,105,110,107,34,125,44,123,34,105,100,34,58,34,104,77,109,67,34,44,34,110,97,109,101,34,58,34,49,50,51,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,93,44,34,100,105,115,97,98,108,101,95,99,111,108,111,114,34,58,102,97,108,115,101,125,161,204,220,240,227,3,66,1,136,204,220,240,227,3,43,1,118,1,2,105,100,119,6,70,99,112,109,80,101,39,0,251,157,254,151,3,52,6,70,99,112,109,80,101,1,40,0,204,220,240,227,3,76,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,204,220,240,227,3,67,1,136,204,220,240,227,3,47,1,118,1,2,105,100,119,6,70,99,112,109,80,101,39,0,204,220,240,227,3,16,6,70,99,112,109,80,101,1,40,0,204,220,240,227,3,80,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,251,157,254,151,3,2,6,70,99,112,109,80,101,1,40,0,204,220,240,227,3,82,2,105,100,1,119,6,70,99,112,109,80,101,40,0,204,220,240,227,3,82,4,110,97,109,101,1,119,4,84,101,120,116,40,0,204,220,240,227,3,82,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,120,33,0,204,220,240,227,3,82,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,204,220,240,227,3,82,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,204,220,240,227,3,82,2,116,121,1,39,0,204,220,240,227,3,82,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,204,220,240,227,3,89,1,48,1,40,0,204,220,240,227,3,90,4,100,97,116,97,1,119,0,168,204,220,240,227,3,86,1,122,0,0,0,0,102,77,165,125,168,204,220,240,227,3,88,1,122,0,0,0,0,0,0,0,5,39,0,204,220,240,227,3,89,1,53,1,161,204,220,240,227,3,74,1,136,204,220,240,227,3,75,1,118,1,2,105,100,119,6,112,70,120,57,67,45,39,0,251,157,254,151,3,52,6,112,70,120,57,67,45,1,40,0,204,220,240,227,3,97,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,204,220,240,227,3,78,1,136,204,220,240,227,3,79,1,118,1,2,105,100,119,6,112,70,120,57,67,45,39,0,204,220,240,227,3,16,6,112,70,120,57,67,45,1,40,0,204,220,240,227,3,101,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,251,157,254,151,3,2,6,112,70,120,57,67,45,1,40,0,204,220,240,227,3,103,2,105,100,1,119,6,112,70,120,57,67,45,40,0,204,220,240,227,3,103,4,110,97,109,101,1,119,4,84,101,120,116,40,0,204,220,240,227,3,103,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,129,33,0,204,220,240,227,3,103,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,204,220,240,227,3,103,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,204,220,240,227,3,103,2,116,121,1,39,0,204,220,240,227,3,103,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,204,220,240,227,3,110,1,48,1,40,0,204,220,240,227,3,111,4,100,97,116,97,1,119,0,168,204,220,240,227,3,107,1,122,0,0,0,0,102,77,165,135,168,204,220,240,227,3,109,1,122,0,0,0,0,0,0,0,7,39,0,204,220,240,227,3,110,1,55,1,161,204,220,240,227,3,95,1,136,204,220,240,227,3,96,1,118,1,2,105,100,119,6,101,49,98,55,48,88,39,0,251,157,254,151,3,52,6,101,49,98,55,48,88,1,40,0,204,220,240,227,3,118,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,204,220,240,227,3,99,1,136,204,220,240,227,3,100,1,118,1,2,105,100,119,6,101,49,98,55,48,88,39,0,204,220,240,227,3,16,6,101,49,98,55,48,88,1,40,0,204,220,240,227,3,122,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,251,157,254,151,3,2,6,101,49,98,55,48,88,1,40,0,204,220,240,227,3,124,2,105,100,1,119,6,101,49,98,55,48,88,40,0,204,220,240,227,3,124,4,110,97,109,101,1,119,4,84,101,120,116,40,0,204,220,240,227,3,124,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,151,33,0,204,220,240,227,3,124,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,204,220,240,227,3,124,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,204,220,240,227,3,124,2,116,121,1,39,0,204,220,240,227,3,124,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,204,220,240,227,3,131,1,1,48,1,40,0,204,220,240,227,3,132,1,4,100,97,116,97,1,119,0,161,204,220,240,227,3,128,1,1,168,204,220,240,227,3,130,1,1,122,0,0,0,0,0,0,0,8,39,0,204,220,240,227,3,131,1,1,56,1,40,0,204,220,240,227,3,136,1,11,100,97,116,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,3,40,0,204,220,240,227,3,136,1,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,8,40,0,204,220,240,227,3,136,1,11,116,105,109,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,1,40,0,204,220,240,227,3,136,1,12,105,110,99,108,117,100,101,95,116,105,109,101,1,120,168,204,220,240,227,3,134,1,1,122,0,0,0,0,102,77,165,161,40,0,204,220,240,227,3,132,1,11,116,105,109,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,1,40,0,204,220,240,227,3,132,1,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,8,40,0,204,220,240,227,3,132,1,12,105,110,99,108,117,100,101,95,116,105,109,101,1,120,40,0,204,220,240,227,3,132,1,11,100,97,116,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,3,161,204,220,240,227,3,116,1,161,204,220,240,227,3,120,1,161,204,220,240,227,3,146,1,1,136,204,220,240,227,3,117,1,118,1,2,105,100,119,6,80,78,113,89,102,76,39,0,251,157,254,151,3,52,6,80,78,113,89,102,76,1,40,0,204,220,240,227,3,150,1,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,204,220,240,227,3,147,1,1,136,204,220,240,227,3,121,1,118,1,2,105,100,119,6,80,78,113,89,102,76,39,0,204,220,240,227,3,16,6,80,78,113,89,102,76,1,40,0,204,220,240,227,3,154,1,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,251,157,254,151,3,2,6,80,78,113,89,102,76,1,40,0,204,220,240,227,3,156,1,2,105,100,1,119,6,80,78,113,89,102,76,40,0,204,220,240,227,3,156,1,4,110,97,109,101,1,119,4,84,101,120,116,40,0,204,220,240,227,3,156,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,164,33,0,204,220,240,227,3,156,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,204,220,240,227,3,156,1,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,204,220,240,227,3,156,1,2,116,121,1,39,0,204,220,240,227,3,156,1,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,204,220,240,227,3,163,1,1,48,1,40,0,204,220,240,227,3,164,1,4,100,97,116,97,1,119,0,161,204,220,240,227,3,160,1,1,168,204,220,240,227,3,162,1,1,122,0,0,0,0,0,0,0,9,39,0,204,220,240,227,3,163,1,1,57,1,40,0,204,220,240,227,3,168,1,12,105,110,99,108,117,100,101,95,116,105,109,101,1,120,40,0,204,220,240,227,3,168,1,11,116,105,109,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,1,40,0,204,220,240,227,3,168,1,11,100,97,116,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,3,40,0,204,220,240,227,3,168,1,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,9,168,204,220,240,227,3,166,1,1,122,0,0,0,0,102,77,165,166,40,0,204,220,240,227,3,164,1,11,116,105,109,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,1,40,0,204,220,240,227,3,164,1,11,100,97,116,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,3,40,0,204,220,240,227,3,164,1,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,9,40,0,204,220,240,227,3,164,1,12,105,110,99,108,117,100,101,95,116,105,109,101,1,120,161,204,220,240,227,3,148,1,1,161,204,220,240,227,3,152,1,1,161,204,220,240,227,3,178,1,1,136,204,220,240,227,3,149,1,1,118,1,2,105,100,119,6,75,71,50,113,74,65,39,0,251,157,254,151,3,52,6,75,71,50,113,74,65,1,40,0,204,220,240,227,3,182,1,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,161,204,220,240,227,3,179,1,1,136,204,220,240,227,3,153,1,1,118,1,2,105,100,119,6,75,71,50,113,74,65,39,0,204,220,240,227,3,16,6,75,71,50,113,74,65,1,40,0,204,220,240,227,3,186,1,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,39,0,251,157,254,151,3,2,6,75,71,50,113,74,65,1,40,0,204,220,240,227,3,188,1,2,105,100,1,119,6,75,71,50,113,74,65,40,0,204,220,240,227,3,188,1,4,110,97,109,101,1,119,4,84,101,120,116,40,0,204,220,240,227,3,188,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,168,33,0,204,220,240,227,3,188,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,204,220,240,227,3,188,1,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,204,220,240,227,3,188,1,2,116,121,1,39,0,204,220,240,227,3,188,1,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,204,220,240,227,3,195,1,1,48,1,40,0,204,220,240,227,3,196,1,4,100,97,116,97,1,119,0,161,204,220,240,227,3,192,1,1,168,204,220,240,227,3,194,1,1,122,0,0,0,0,0,0,0,10,39,0,204,220,240,227,3,195,1,2,49,48,1,33,0,204,220,240,227,3,200,1,11,100,97,116,97,98,97,115,101,95,105,100,1,161,204,220,240,227,3,198,1,1,40,0,204,220,240,227,3,196,1,11,100,97,116,97,98,97,115,101,95,105,100,1,119,0,161,204,220,240,227,3,180,1,1,161,204,220,240,227,3,184,1,1,168,204,220,240,227,3,202,1,1,122,0,0,0,0,102,77,165,173,168,204,220,240,227,3,201,1,1,119,36,97,100,55,100,99,52,53,98,45,52,52,98,53,45,52,57,56,102,45,98,102,97,50,45,48,102,52,51,98,102,48,53,99,99,48,100,168,204,220,240,227,3,204,1,1,122,0,0,0,0,102,77,187,135,136,204,220,240,227,3,7,1,118,2,6,104,101,105,103,104,116,125,60,2,105,100,119,36,57,50,97,50,49,51,55,101,45,98,48,48,98,45,52,51,56,56,45,56,53,49,102,45,97,48,101,102,99,51,100,101,55,99,97,51,168,204,220,240,227,3,205,1,1,122,0,0,0,0,102,77,187,135,136,204,220,240,227,3,31,1,118,2,2,105,100,119,36,57,50,97,50,49,51,55,101,45,98,48,48,98,45,52,51,56,56,45,56,53,49,102,45,97,48,101,102,99,51,100,101,55,99,97,51,6,104,101,105,103,104,116,125,60,1,243,175,215,198,3,0,161,236,192,251,208,2,8,7,105,251,157,254,151,3,0,39,1,4,100,97,116,97,8,100,97,116,97,98,97,115,101,1,40,0,251,157,254,151,3,0,2,105,100,1,119,36,99,101,50,54,55,100,49,50,45,51,98,54,49,45,52,101,98,98,45,98,98,48,51,45,100,54,53,50,55,50,102,53,102,56,49,55,39,0,251,157,254,151,3,0,6,102,105,101,108,100,115,1,39,0,251,157,254,151,3,0,5,118,105,101,119,115,1,39,0,251,157,254,151,3,0,5,109,101,116,97,115,1,40,0,251,157,254,151,3,4,3,105,105,100,1,119,36,101,101,51,97,101,56,99,101,45,57,53,57,97,45,52,100,102,51,45,56,55,51,52,45,52,48,98,53,51,53,102,102,56,56,101,51,39,0,251,157,254,151,3,2,6,72,95,74,113,85,76,1,40,0,251,157,254,151,3,6,2,105,100,1,119,6,72,95,74,113,85,76,40,0,251,157,254,151,3,6,4,110,97,109,101,1,119,5,84,105,116,108,101,40,0,251,157,254,151,3,6,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,101,231,40,0,251,157,254,151,3,6,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,76,101,231,40,0,251,157,254,151,3,6,10,105,115,95,112,114,105,109,97,114,121,1,120,40,0,251,157,254,151,3,6,2,116,121,1,122,0,0,0,0,0,0,0,0,39,0,251,157,254,151,3,6,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,251,157,254,151,3,13,1,48,1,40,0,251,157,254,151,3,14,4,100,97,116,97,1,119,0,39,0,251,157,254,151,3,2,6,55,85,107,117,54,82,1,40,0,251,157,254,151,3,16,2,105,100,1,119,6,55,85,107,117,54,82,40,0,251,157,254,151,3,16,4,110,97,109,101,1,119,4,68,97,116,101,40,0,251,157,254,151,3,16,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,101,231,40,0,251,157,254,151,3,16,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,76,101,231,40,0,251,157,254,151,3,16,10,105,115,95,112,114,105,109,97,114,121,1,121,40,0,251,157,254,151,3,16,2,116,121,1,122,0,0,0,0,0,0,0,2,39,0,251,157,254,151,3,16,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,251,157,254,151,3,23,1,50,1,40,0,251,157,254,151,3,24,11,100,97,116,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,3,40,0,251,157,254,151,3,24,11,116,105,109,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,1,40,0,251,157,254,151,3,24,11,116,105,109,101,122,111,110,101,95,105,100,1,119,0,39,0,251,157,254,151,3,2,6,95,82,45,112,104,105,1,40,0,251,157,254,151,3,28,2,105,100,1,119,6,95,82,45,112,104,105,40,0,251,157,254,151,3,28,4,110,97,109,101,1,119,4,84,97,103,115,40,0,251,157,254,151,3,28,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,101,231,33,0,251,157,254,151,3,28,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,251,157,254,151,3,28,10,105,115,95,112,114,105,109,97,114,121,1,121,40,0,251,157,254,151,3,28,2,116,121,1,122,0,0,0,0,0,0,0,4,39,0,251,157,254,151,3,28,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,251,157,254,151,3,35,1,52,1,33,0,251,157,254,151,3,36,7,99,111,110,116,101,110,116,1,39,0,251,157,254,151,3,3,36,101,101,51,97,101,56,99,101,45,57,53,57,97,45,52,100,102,51,45,56,55,51,52,45,52,48,98,53,51,53,102,102,56,56,101,51,1,40,0,251,157,254,151,3,38,2,105,100,1,119,36,101,101,51,97,101,56,99,101,45,57,53,57,97,45,52,100,102,51,45,56,55,51,52,45,52,48,98,53,51,53,102,102,56,56,101,51,40,0,251,157,254,151,3,38,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,99,101,50,54,55,100,49,50,45,51,98,54,49,45,52,101,98,98,45,98,98,48,51,45,100,54,53,50,55,50,102,53,102,56,49,55,40,0,251,157,254,151,3,38,4,110,97,109,101,1,119,8,85,110,116,105,116,108,101,100,40,0,251,157,254,151,3,38,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,101,231,33,0,251,157,254,151,3,38,11,109,111,100,105,102,105,101,100,95,97,116,1,39,0,251,157,254,151,3,38,15,108,97,121,111,117,116,95,115,101,116,116,105,110,103,115,1,39,0,251,157,254,151,3,44,1,50,1,40,0,251,157,254,151,3,45,13,115,104,111,119,95,119,101,101,107,101,110,100,115,1,120,40,0,251,157,254,151,3,45,9,108,97,121,111,117,116,95,116,121,1,122,0,0,0,0,0,0,0,0,40,0,251,157,254,151,3,45,8,102,105,101,108,100,95,105,100,1,119,6,55,85,107,117,54,82,40,0,251,157,254,151,3,45,17,102,105,114,115,116,95,100,97,121,95,111,102,95,119,101,101,107,1,122,0,0,0,0,0,0,0,0,40,0,251,157,254,151,3,45,17,115,104,111,119,95,119,101,101,107,95,110,117,109,98,101,114,115,1,120,40,0,251,157,254,151,3,38,6,108,97,121,111,117,116,1,122,0,0,0,0,0,0,0,2,39,0,251,157,254,151,3,38,14,102,105,101,108,100,95,115,101,116,116,105,110,103,115,1,39,0,251,157,254,151,3,52,6,72,95,74,113,85,76,1,40,0,251,157,254,151,3,53,5,119,105,100,116,104,1,122,0,0,0,0,0,0,0,150,40,0,251,157,254,151,3,53,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,0,40,0,251,157,254,151,3,53,4,119,114,97,112,1,120,39,0,251,157,254,151,3,52,6,55,85,107,117,54,82,1,40,0,251,157,254,151,3,57,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,40,0,251,157,254,151,3,57,5,119,105,100,116,104,1,122,0,0,0,0,0,0,0,150,40,0,251,157,254,151,3,57,4,119,114,97,112,1,120,39,0,251,157,254,151,3,52,6,95,82,45,112,104,105,1,40,0,251,157,254,151,3,61,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,40,0,251,157,254,151,3,61,5,119,105,100,116,104,1,122,0,0,0,0,0,0,0,150,40,0,251,157,254,151,3,61,4,119,114,97,112,1,120,39,0,251,157,254,151,3,38,7,102,105,108,116,101,114,115,0,39,0,251,157,254,151,3,38,6,103,114,111,117,112,115,0,39,0,251,157,254,151,3,38,5,115,111,114,116,115,0,39,0,251,157,254,151,3,38,12,102,105,101,108,100,95,111,114,100,101,114,115,0,8,0,251,157,254,151,3,68,3,118,1,2,105,100,119,6,72,95,74,113,85,76,118,1,2,105,100,119,6,55,85,107,117,54,82,118,1,2,105,100,119,6,95,82,45,112,104,105,39,0,251,157,254,151,3,38,10,114,111,119,95,111,114,100,101,114,115,0,161,251,157,254,151,3,43,1,8,0,251,157,254,151,3,72,1,118,2,2,105,100,119,36,55,55,49,55,48,55,57,98,45,48,53,98,54,45,52,97,48,97,45,56,101,101,52,45,52,56,55,51,57,102,98,102,51,97,53,50,6,104,101,105,103,104,116,125,60,161,251,157,254,151,3,32,1,161,251,157,254,151,3,37,1,161,251,157,254,151,3,73,1,136,251,157,254,151,3,71,1,118,1,2,105,100,119,6,99,78,53,98,120,74,39,0,251,157,254,151,3,52,6,99,78,53,98,120,74,1,40,0,251,157,254,151,3,79,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,39,0,251,157,254,151,3,2,6,99,78,53,98,120,74,1,40,0,251,157,254,151,3,81,2,105,100,1,119,6,99,78,53,98,120,74,40,0,251,157,254,151,3,81,4,110,97,109,101,1,119,4,84,101,120,116,40,0,251,157,254,151,3,81,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,102,8,33,0,251,157,254,151,3,81,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,40,0,251,157,254,151,3,81,10,105,115,95,112,114,105,109,97,114,121,1,121,33,0,251,157,254,151,3,81,2,116,121,1,39,0,251,157,254,151,3,81,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,251,157,254,151,3,88,1,48,1,40,0,251,157,254,151,3,89,4,100,97,116,97,1,119,0,161,251,157,254,151,3,77,1,136,251,157,254,151,3,78,1,118,1,2,105,100,119,6,71,115,66,65,97,76,39,0,251,157,254,151,3,52,6,71,115,66,65,97,76,1,40,0,251,157,254,151,3,93,10,118,105,115,105,98,105,108,105,116,121,1,122,0,0,0,0,0,0,0,1,39,0,251,157,254,151,3,2,6,71,115,66,65,97,76,1,40,0,251,157,254,151,3,95,2,105,100,1,119,6,71,115,66,65,97,76,40,0,251,157,254,151,3,95,4,110,97,109,101,1,119,4,68,97,116,101,40,0,251,157,254,151,3,95,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,102,24,40,0,251,157,254,151,3,95,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,76,102,24,40,0,251,157,254,151,3,95,10,105,115,95,112,114,105,109,97,114,121,1,121,40,0,251,157,254,151,3,95,2,116,121,1,122,0,0,0,0,0,0,0,2,39,0,251,157,254,151,3,95,11,116,121,112,101,95,111,112,116,105,111,110,1,39,0,251,157,254,151,3,102,1,50,1,40,0,251,157,254,151,3,103,11,116,105,109,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,0,40,0,251,157,254,151,3,103,11,116,105,109,101,122,111,110,101,95,105,100,1,119,0,40,0,251,157,254,151,3,103,11,100,97,116,101,95,102,111,114,109,97,116,1,122,0,0,0,0,0,0,0,1,1,236,192,251,208,2,0,161,145,151,150,143,2,6,9,1,145,151,150,143,2,0,161,131,222,171,184,9,38,7,1,146,214,128,188,1,0,161,212,178,171,164,8,13,65,1,162,178,170,161,1,0,161,139,202,180,177,14,32,6,2,219,228,172,146,1,0,161,247,242,142,226,14,5,1,161,130,208,239,179,11,1,9,1,231,217,162,139,1,0,161,149,159,177,202,15,1,5,19,130,208,239,179,11,1,0,2,162,178,170,161,1,1,0,6,131,222,171,184,9,1,0,39,133,224,179,154,9,1,0,2,231,217,162,139,1,1,0,5,231,138,159,208,10,1,0,2,170,249,160,147,7,1,0,21,139,202,180,177,14,1,0,33,236,192,251,208,2,1,0,9,204,220,240,227,3,39,0,1,2,1,4,1,6,1,13,1,32,1,40,3,46,1,54,1,56,1,60,1,63,2,66,2,70,2,74,1,78,1,86,1,88,1,95,1,99,1,107,1,109,1,116,1,120,1,128,1,1,130,1,1,134,1,1,146,1,3,152,1,1,160,1,1,162,1,1,166,1,1,178,1,3,184,1,1,192,1,1,194,1,1,198,1,1,201,1,2,204,1,2,145,151,150,143,2,1,0,7,146,214,128,188,1,1,0,65,243,175,215,198,3,1,0,7,212,178,171,164,8,1,0,14,149,159,177,202,15,1,0,2,149,178,144,155,15,1,0,7,247,242,142,226,14,1,0,6,219,228,172,146,1,1,0,10,251,157,254,151,3,8,32,1,37,1,43,1,73,1,75,3,85,1,87,1,91,1],"version":0,"object_id":"ce267d12-3b61-4ebb-bb03-d65272f5f817"},"code":0,"message":"Operation completed successfully."} \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/fixtures/database/rows/4c658817-20db-4f56-b7f9-0637a22dfeb6.json b/frontend/appflowy_web_app/cypress/fixtures/database/rows/4c658817-20db-4f56-b7f9-0637a22dfeb6.json new file mode 100644 index 0000000000000..a55622fdfb4a5 --- /dev/null +++ b/frontend/appflowy_web_app/cypress/fixtures/database/rows/4c658817-20db-4f56-b7f9-0637a22dfeb6.json @@ -0,0 +1 @@ +{"2f944220-9f45-40d9-96b5-e8c0888daf7c":[58,1,230,232,236,161,15,0,161,147,212,241,172,2,1,6,1,144,227,205,159,15,0,161,150,141,187,97,5,10,17,186,193,182,130,15,0,161,237,231,215,147,4,0,1,39,0,128,159,198,124,8,6,115,111,118,85,116,69,1,40,0,186,193,182,130,15,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,67,9,22,40,0,186,193,182,130,15,1,4,100,97,116,97,1,119,0,40,0,186,193,182,130,15,1,13,101,110,100,95,116,105,109,101,115,116,97,109,112,1,119,0,40,0,186,193,182,130,15,1,12,105,110,99,108,117,100,101,95,116,105,109,101,1,121,40,0,186,193,182,130,15,1,8,105,115,95,114,97,110,103,101,1,121,40,0,186,193,182,130,15,1,11,114,101,109,105,110,100,101,114,95,105,100,1,119,0,40,0,186,193,182,130,15,1,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,2,40,0,186,193,182,130,15,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,67,9,22,161,186,193,182,130,15,0,1,39,0,128,159,198,124,8,6,106,87,101,95,116,54,1,40,0,186,193,182,130,15,11,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,67,52,223,39,0,186,193,182,130,15,11,4,100,97,116,97,0,8,0,186,193,182,130,15,13,1,119,36,50,102,57,52,52,50,50,48,45,57,102,52,53,45,52,48,100,57,45,57,54,98,53,45,101,56,99,48,56,56,56,100,97,102,55,99,40,0,186,193,182,130,15,11,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,10,40,0,186,193,182,130,15,11,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,67,52,223,1,209,188,177,215,14,0,161,205,239,215,19,1,26,1,167,253,145,211,14,0,161,171,194,204,160,8,3,6,1,170,128,188,181,14,0,161,156,191,219,249,8,1,2,1,131,157,176,150,14,0,161,147,200,155,248,11,63,8,1,229,227,137,140,13,0,161,149,252,241,115,5,5,1,159,212,134,166,12,0,161,134,132,140,164,1,3,10,1,195,193,152,153,12,0,161,222,161,195,148,2,1,24,1,133,166,132,140,12,0,161,159,241,200,168,2,3,5,1,147,200,155,248,11,0,161,254,149,155,218,7,0,64,5,192,252,137,204,11,0,161,139,151,195,245,8,6,1,161,139,151,195,245,8,8,2,168,192,252,137,204,11,0,1,121,161,192,252,137,204,11,2,1,168,192,252,137,204,11,4,1,122,0,0,0,0,102,78,233,162,1,192,211,236,177,11,0,161,245,221,232,242,6,22,8,2,147,146,137,224,10,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,113,161,147,146,137,224,10,112,6,19,213,209,142,213,10,0,161,237,231,215,147,4,0,1,39,0,128,159,198,124,8,6,54,76,70,72,66,54,1,40,0,213,209,142,213,10,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,60,204,6,33,0,213,209,142,213,10,1,10,102,105,101,108,100,95,116,121,112,101,1,33,0,213,209,142,213,10,1,4,100,97,116,97,1,33,0,213,209,142,213,10,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,213,209,142,213,10,0,1,168,213,209,142,213,10,3,1,122,0,0,0,0,0,0,0,6,168,213,209,142,213,10,4,1,119,11,97,112,112,102,108,111,119,121,46,105,111,168,213,209,142,213,10,5,1,122,0,0,0,0,102,60,204,7,161,213,209,142,213,10,6,1,33,0,128,159,198,124,8,6,106,87,101,95,116,54,1,0,8,161,213,209,142,213,10,10,1,0,7,161,213,209,142,213,10,20,1,0,7,161,213,209,142,213,10,28,1,0,7,1,252,175,185,165,10,0,161,157,218,228,199,8,1,2,1,252,249,181,162,10,0,161,135,190,197,222,2,11,6,1,234,188,148,129,10,0,161,131,157,176,150,14,7,10,1,243,149,144,209,9,0,161,151,148,151,141,4,7,19,4,171,231,189,153,9,0,161,235,147,219,255,2,26,1,0,4,161,171,231,189,153,9,0,1,0,5,1,156,191,219,249,8,0,161,238,201,163,245,7,15,2,6,139,151,195,245,8,0,33,0,128,159,198,124,1,36,49,101,100,98,98,102,101,100,45,101,52,51,54,45,53,98,55,51,45,56,49,98,101,45,56,54,98,55,98,50,57,57,49,102,98,49,1,161,186,193,182,130,15,10,2,168,139,151,195,245,8,0,1,119,4,240,159,143,175,161,139,151,195,245,8,2,2,33,0,128,159,198,124,1,36,57,97,53,50,49,56,100,102,45,53,99,54,57,45,53,50,99,54,45,56,102,48,49,45,48,52,102,51,50,52,56,51,49,100,53,51,1,161,139,151,195,245,8,5,2,1,157,218,228,199,8,0,161,225,218,138,252,4,1,2,1,188,146,237,189,8,0,161,230,232,236,161,15,5,4,1,171,194,204,160,8,0,161,195,193,152,153,12,23,4,1,243,186,209,152,8,0,161,167,253,145,211,14,5,2,1,153,186,129,131,8,0,161,243,149,144,209,9,18,19,1,238,201,163,245,7,0,161,195,136,140,158,7,3,16,1,254,149,155,218,7,0,161,153,186,129,131,8,18,1,1,195,136,140,158,7,0,161,188,146,237,189,8,3,4,1,245,221,232,242,6,0,161,170,128,188,181,14,1,23,1,252,139,187,215,6,0,161,229,227,137,140,13,4,8,1,253,240,216,178,6,0,161,134,225,192,253,1,38,11,3,180,238,233,229,5,0,161,147,146,137,224,10,112,1,161,147,146,137,224,10,118,15,161,180,238,233,229,5,15,4,2,185,193,252,188,5,0,161,133,166,132,140,12,4,6,168,185,193,252,188,5,5,1,122,0,0,0,0,102,88,25,34,1,225,218,138,252,4,0,161,129,245,221,210,4,5,2,1,129,245,221,210,4,0,161,252,139,187,215,6,7,6,1,175,245,211,160,4,0,161,250,139,140,49,23,2,6,237,231,215,147,4,0,161,128,159,198,124,9,1,39,0,128,159,198,124,8,6,70,114,115,115,74,100,1,40,0,237,231,215,147,4,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,177,241,40,0,237,231,215,147,4,1,4,100,97,116,97,1,119,4,120,90,48,51,40,0,237,231,215,147,4,1,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,40,0,237,231,215,147,4,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,48,177,241,1,151,148,151,141,4,0,161,192,211,236,177,11,7,8,1,230,189,235,175,3,0,161,243,186,209,152,8,1,34,1,169,180,242,165,3,0,161,159,212,134,166,12,9,6,1,157,197,206,156,3,0,161,252,249,181,162,10,5,29,27,235,147,219,255,2,0,161,213,209,142,213,10,36,1,39,0,128,159,198,124,8,6,86,89,52,50,103,49,1,40,0,235,147,219,255,2,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,128,254,33,0,235,147,219,255,2,1,4,100,97,116,97,1,33,0,235,147,219,255,2,1,10,102,105,101,108,100,95,116,121,112,101,1,33,0,235,147,219,255,2,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,235,147,219,255,2,0,1,168,235,147,219,255,2,3,1,119,13,49,46,57,57,57,57,57,57,57,57,57,57,57,168,235,147,219,255,2,4,1,122,0,0,0,0,0,0,0,1,168,235,147,219,255,2,5,1,122,0,0,0,0,102,65,129,93,161,235,147,219,255,2,6,1,33,0,128,159,198,124,8,6,115,111,118,85,116,69,1,0,4,161,235,147,219,255,2,10,1,39,0,128,159,198,124,8,6,120,69,81,65,111,75,1,40,0,235,147,219,255,2,17,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,67,49,251,33,0,235,147,219,255,2,17,10,102,105,101,108,100,95,116,121,112,101,1,33,0,235,147,219,255,2,17,4,100,97,116,97,1,33,0,235,147,219,255,2,17,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,235,147,219,255,2,16,1,161,235,147,219,255,2,20,1,161,235,147,219,255,2,19,1,161,235,147,219,255,2,21,1,161,235,147,219,255,2,22,1,168,235,147,219,255,2,23,1,119,83,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,111,84,120,83,34,44,34,110,97,109,101,34,58,34,56,56,57,57,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,93,44,34,115,101,108,101,99,116,101,100,95,111,112,116,105,111,110,95,105,100,115,34,58,91,93,125,168,235,147,219,255,2,24,1,122,0,0,0,0,0,0,0,7,168,235,147,219,255,2,25,1,122,0,0,0,0,102,67,49,255,1,135,190,197,222,2,0,161,234,188,148,129,10,9,12,1,242,204,147,190,2,0,161,150,141,187,97,5,2,1,147,212,241,172,2,0,161,252,175,185,165,10,1,2,1,159,241,200,168,2,0,161,209,188,177,215,14,25,4,1,222,161,195,148,2,0,161,213,141,134,218,1,3,2,1,134,225,192,253,1,0,161,169,180,242,165,3,5,39,1,213,141,134,218,1,0,161,157,197,206,156,3,28,4,1,134,132,140,164,1,0,161,230,189,235,175,3,33,4,15,128,159,198,124,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,128,159,198,124,0,2,105,100,1,119,36,50,102,57,52,52,50,50,48,45,57,102,52,53,45,52,48,100,57,45,57,54,98,53,45,101,56,99,48,56,56,56,100,97,102,55,99,40,0,128,159,198,124,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,128,159,198,124,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,128,159,198,124,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,108,138,33,0,128,159,198,124,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,128,159,198,124,0,5,99,101,108,108,115,1,161,128,159,198,124,7,1,39,0,128,159,198,124,8,6,89,53,52,81,73,115,1,40,0,128,159,198,124,10,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,111,158,40,0,128,159,198,124,10,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,0,40,0,128,159,198,124,10,4,100,97,116,97,1,119,3,49,50,51,40,0,128,159,198,124,10,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,48,111,158,2,149,252,241,115,0,161,180,238,233,229,5,15,1,161,180,238,233,229,5,19,9,1,150,141,187,97,0,161,175,245,211,160,4,1,6,1,250,139,140,49,0,161,253,240,216,178,6,10,24,1,205,239,215,19,0,161,144,227,205,159,15,9,2,58,128,159,198,124,2,7,1,9,1,129,245,221,210,4,1,0,6,254,149,155,218,7,1,0,1,131,157,176,150,14,1,0,8,133,166,132,140,12,1,0,5,134,132,140,164,1,1,0,4,135,190,197,222,2,1,0,12,134,225,192,253,1,1,0,39,139,151,195,245,8,2,0,3,4,5,144,227,205,159,15,1,0,10,147,146,137,224,10,1,0,119,147,212,241,172,2,1,0,2,149,252,241,115,1,0,10,147,200,155,248,11,1,0,64,151,148,151,141,4,1,0,8,150,141,187,97,1,0,6,153,186,129,131,8,1,0,19,156,191,219,249,8,1,0,2,157,197,206,156,3,1,0,29,157,218,228,199,8,1,0,2,159,212,134,166,12,1,0,10,159,241,200,168,2,1,0,4,167,253,145,211,14,1,0,6,169,180,242,165,3,1,0,6,170,128,188,181,14,1,0,2,171,194,204,160,8,1,0,4,171,231,189,153,9,1,0,11,175,245,211,160,4,1,0,2,180,238,233,229,5,1,0,20,185,193,252,188,5,1,0,6,186,193,182,130,15,2,0,1,10,1,188,146,237,189,8,1,0,4,192,211,236,177,11,1,0,8,192,252,137,204,11,2,0,3,4,1,195,136,140,158,7,1,0,4,195,193,152,153,12,1,0,24,205,239,215,19,1,0,2,209,188,177,215,14,1,0,26,213,141,134,218,1,1,0,4,213,209,142,213,10,3,0,1,3,4,10,34,222,161,195,148,2,1,0,2,225,218,138,252,4,1,0,2,229,227,137,140,13,1,0,5,230,232,236,161,15,1,0,6,230,189,235,175,3,1,0,34,234,188,148,129,10,1,0,10,235,147,219,255,2,4,0,1,3,4,10,7,19,8,237,231,215,147,4,1,0,1,238,201,163,245,7,1,0,16,242,204,147,190,2,1,0,2,243,149,144,209,9,1,0,19,243,186,209,152,8,1,0,2,245,221,232,242,6,1,0,23,250,139,140,49,1,0,24,252,175,185,165,10,1,0,2,252,249,181,162,10,1,0,6,253,240,216,178,6,1,0,11,252,139,187,215,6,1,0,8],"318aa415-92ae-489a-a14f-a24692a2efa6":[34,16,133,247,247,224,15,0,161,204,206,244,208,8,26,1,39,0,204,206,244,208,8,9,6,70,114,115,115,74,100,1,40,0,133,247,247,224,15,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,140,55,40,0,133,247,247,224,15,1,4,100,97,116,97,1,119,36,48,52,102,52,55,48,51,55,45,49,56,54,97,45,52,56,55,102,45,98,54,56,101,45,102,49,98,102,97,48,102,101,54,54,53,101,40,0,133,247,247,224,15,1,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,40,0,133,247,247,224,15,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,65,140,55,161,133,247,247,224,15,0,1,39,0,204,206,244,208,8,9,6,115,111,118,85,116,69,1,40,0,133,247,247,224,15,7,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,147,65,33,0,133,247,247,224,15,7,4,100,97,116,97,1,33,0,133,247,247,224,15,7,10,102,105,101,108,100,95,116,121,112,101,1,33,0,133,247,247,224,15,7,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,133,247,247,224,15,6,1,168,133,247,247,224,15,10,1,122,0,0,0,0,0,0,0,4,168,133,247,247,224,15,9,1,119,73,57,100,48,48,56,50,51,97,45,100,57,101,50,45,52,102,98,55,45,98,100,98,54,45,99,97,102,54,101,98,99,54,99,49,50,51,44,49,99,52,102,53,52,54,57,45,54,101,49,49,45,52,55,48,51,45,57,48,56,54,45,101,98,98,50,51,57,49,53,100,53,100,56,168,133,247,247,224,15,11,1,122,0,0,0,0,102,65,147,66,1,143,148,196,184,15,0,161,241,201,182,143,5,8,2,1,246,154,152,188,14,0,161,200,145,163,182,5,11,6,1,145,225,236,177,14,0,161,160,131,157,175,8,3,2,1,252,203,148,253,13,0,161,170,153,233,132,10,11,26,1,142,228,130,255,12,0,161,143,148,196,184,15,1,6,1,182,246,158,246,11,0,161,246,154,152,188,14,5,29,2,186,166,174,226,11,0,161,212,236,181,165,9,4,6,168,186,166,174,226,11,5,1,122,0,0,0,0,102,88,25,34,1,159,139,140,218,11,0,161,173,216,200,210,1,23,4,1,214,215,253,213,11,0,161,226,183,173,212,7,23,1,1,200,218,157,187,11,0,161,248,139,142,248,1,1,35,1,145,236,232,195,10,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,8,1,214,213,255,190,10,0,161,222,208,153,250,3,38,7,1,233,137,131,159,10,0,161,145,236,232,195,10,7,10,1,215,229,183,133,10,0,161,214,215,253,213,11,0,48,1,190,196,251,132,10,0,161,200,218,157,187,11,34,4,1,170,153,233,132,10,0,161,142,228,130,255,12,5,12,1,218,242,205,188,9,0,161,190,196,251,132,10,3,10,1,212,236,181,165,9,0,161,176,202,194,187,3,2,5,34,204,206,244,208,8,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,204,206,244,208,8,0,2,105,100,1,119,36,51,49,56,97,97,52,49,53,45,57,50,97,101,45,52,56,57,97,45,97,49,52,102,45,97,50,52,54,57,50,97,50,101,102,97,54,40,0,204,206,244,208,8,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,204,206,244,208,8,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,204,206,244,208,8,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,204,206,244,208,8,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,247,11,33,0,204,206,244,208,8,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,204,206,244,208,8,0,5,99,101,108,108,115,1,161,204,206,244,208,8,8,1,39,0,204,206,244,208,8,9,6,86,89,52,50,103,49,1,40,0,204,206,244,208,8,11,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,247,15,40,0,204,206,244,208,8,11,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,1,40,0,204,206,244,208,8,11,4,100,97,116,97,1,119,3,54,54,54,40,0,204,206,244,208,8,11,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,61,247,15,161,204,206,244,208,8,10,1,39,0,204,206,244,208,8,9,6,106,87,101,95,116,54,1,40,0,204,206,244,208,8,17,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,247,83,33,0,204,206,244,208,8,17,13,101,110,100,95,116,105,109,101,115,116,97,109,112,1,33,0,204,206,244,208,8,17,8,105,115,95,114,97,110,103,101,1,33,0,204,206,244,208,8,17,11,114,101,109,105,110,100,101,114,95,105,100,1,33,0,204,206,244,208,8,17,4,100,97,116,97,1,33,0,204,206,244,208,8,17,10,102,105,101,108,100,95,116,121,112,101,1,33,0,204,206,244,208,8,17,12,105,110,99,108,117,100,101,95,116,105,109,101,1,33,0,204,206,244,208,8,17,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,204,206,244,208,8,16,1,168,204,206,244,208,8,19,1,119,0,161,204,206,244,208,8,23,1,168,204,206,244,208,8,24,1,121,168,204,206,244,208,8,21,1,119,21,51,50,78,50,75,100,121,72,114,104,84,55,83,99,54,76,106,78,90,100,51,161,204,206,244,208,8,22,1,168,204,206,244,208,8,20,1,121,161,204,206,244,208,8,25,1,1,217,249,214,203,8,0,161,142,228,130,255,12,5,2,1,228,182,208,185,8,0,161,227,143,130,249,4,7,10,1,160,131,157,175,8,0,161,182,246,158,246,11,28,4,1,226,183,173,212,7,0,161,233,137,131,159,10,9,24,1,200,145,163,182,5,0,161,228,182,208,185,8,9,12,1,241,201,182,143,5,0,161,214,213,255,190,10,6,9,1,227,143,130,249,4,0,161,215,229,183,133,10,47,8,1,160,249,160,227,4,0,161,159,139,140,218,11,3,6,1,222,208,153,250,3,0,161,189,166,144,23,5,39,1,176,202,194,187,3,0,161,252,203,148,253,13,25,3,1,248,139,142,248,1,0,161,160,249,160,227,4,5,2,1,173,216,200,210,1,0,161,145,225,236,177,14,1,24,5,128,181,139,77,0,168,133,247,247,224,15,12,1,122,0,0,0,0,102,67,57,178,168,204,206,244,208,8,28,1,122,0,0,0,0,0,0,0,10,167,204,206,244,208,8,31,0,8,0,128,181,139,77,2,1,119,36,50,102,57,52,52,50,50,48,45,57,102,52,53,45,52,48,100,57,45,57,54,98,53,45,101,56,99,48,56,56,56,100,97,102,55,99,168,204,206,244,208,8,33,1,122,0,0,0,0,102,67,57,178,1,189,166,144,23,0,161,218,242,205,188,9,9,6,33,133,247,247,224,15,3,0,1,6,1,9,4,200,145,163,182,5,1,0,12,200,218,157,187,11,1,0,35,204,206,244,208,8,7,8,1,10,1,16,1,19,8,28,1,31,1,33,1,142,228,130,255,12,1,0,6,143,148,196,184,15,1,0,2,145,236,232,195,10,1,0,8,145,225,236,177,14,1,0,2,212,236,181,165,9,1,0,5,214,215,253,213,11,1,0,1,215,229,183,133,10,1,0,48,214,213,255,190,10,1,0,7,217,249,214,203,8,1,0,2,218,242,205,188,9,1,0,10,222,208,153,250,3,1,0,39,159,139,140,218,11,1,0,4,160,131,157,175,8,1,0,4,160,249,160,227,4,1,0,6,226,183,173,212,7,1,0,24,227,143,130,249,4,1,0,8,228,182,208,185,8,1,0,10,233,137,131,159,10,1,0,10,170,153,233,132,10,1,0,12,173,216,200,210,1,1,0,24,176,202,194,187,3,1,0,3,241,201,182,143,5,1,0,9,246,154,152,188,14,1,0,6,182,246,158,246,11,1,0,29,248,139,142,248,1,1,0,2,186,166,174,226,11,1,0,6,252,203,148,253,13,1,0,26,189,166,144,23,1,0,6,190,196,251,132,10,1,0,4],"dd6c8d13-4867-41c6-8599-b888350f52ee":[53,1,197,130,149,248,15,0,161,158,234,217,249,6,4,8,1,235,218,250,209,15,0,161,228,141,195,172,1,28,3,1,186,200,234,175,15,0,161,147,160,184,220,9,5,12,1,253,249,233,173,15,0,161,142,253,173,141,11,1,2,1,145,176,227,152,15,0,161,227,177,139,177,7,6,2,1,213,199,181,135,15,0,161,155,234,245,236,5,17,39,9,129,162,211,229,14,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,129,162,211,229,14,0,2,105,100,1,119,36,100,100,54,99,56,100,49,51,45,52,56,54,55,45,52,49,99,54,45,56,53,57,57,45,98,56,56,56,51,53,48,102,53,50,101,101,40,0,129,162,211,229,14,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,129,162,211,229,14,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,129,162,211,229,14,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,108,138,33,0,129,162,211,229,14,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,129,162,211,229,14,0,5,99,101,108,108,115,1,1,185,146,147,208,14,0,161,222,239,172,216,10,3,2,1,250,188,188,173,14,0,161,147,160,184,220,9,5,2,1,177,253,176,145,14,0,161,139,215,211,140,4,29,1,1,153,153,172,165,13,0,161,168,180,153,248,6,23,4,2,185,249,173,251,12,0,161,155,214,174,214,9,111,16,161,185,249,173,251,12,15,4,1,138,141,245,198,12,0,161,240,197,174,164,3,5,4,1,163,150,249,138,12,0,161,174,167,159,184,4,3,9,1,225,225,173,133,12,0,161,205,245,156,144,3,7,10,1,240,245,224,143,11,0,161,163,150,249,138,12,8,6,1,142,253,173,141,11,0,161,140,141,210,196,7,15,2,1,222,239,172,216,10,0,161,158,239,153,203,9,25,4,1,160,254,254,214,10,0,161,224,134,128,190,4,5,2,1,242,190,222,204,10,0,161,213,199,181,135,15,38,8,3,212,192,173,181,10,0,161,185,249,173,251,12,15,1,161,185,249,173,251,12,19,5,161,212,192,173,181,10,5,4,1,201,249,157,231,9,0,161,240,245,224,143,11,5,39,1,147,160,184,220,9,0,161,179,235,169,194,8,1,6,1,155,214,174,214,9,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,118,1,158,239,153,203,9,0,161,220,226,182,197,1,5,26,38,150,189,211,186,9,0,161,182,238,220,247,3,24,1,39,0,129,162,211,229,14,8,6,70,114,115,115,74,100,1,40,0,150,189,211,186,9,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,140,50,33,0,150,189,211,186,9,1,4,100,97,116,97,1,33,0,150,189,211,186,9,1,10,102,105,101,108,100,95,116,121,112,101,1,33,0,150,189,211,186,9,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,150,189,211,186,9,0,1,161,150,189,211,186,9,4,1,161,150,189,211,186,9,3,1,161,150,189,211,186,9,5,1,161,150,189,211,186,9,6,1,168,150,189,211,186,9,7,1,122,0,0,0,0,0,0,0,3,168,150,189,211,186,9,8,1,119,36,54,49,50,100,50,99,51,98,45,56,50,98,99,45,52,55,51,98,45,98,49,52,53,45,55,102,53,55,49,56,54,101,51,102,55,101,168,150,189,211,186,9,9,1,122,0,0,0,0,102,65,140,51,161,150,189,211,186,9,10,1,39,0,129,162,211,229,14,8,6,115,111,118,85,116,69,1,40,0,150,189,211,186,9,15,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,147,58,33,0,150,189,211,186,9,15,4,100,97,116,97,1,33,0,150,189,211,186,9,15,10,102,105,101,108,100,95,116,121,112,101,1,33,0,150,189,211,186,9,15,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,150,189,211,186,9,14,1,161,150,189,211,186,9,17,1,161,150,189,211,186,9,18,1,161,150,189,211,186,9,19,1,161,150,189,211,186,9,20,1,161,150,189,211,186,9,22,1,161,150,189,211,186,9,21,1,161,150,189,211,186,9,23,1,161,150,189,211,186,9,24,1,168,150,189,211,186,9,25,1,122,0,0,0,0,0,0,0,4,168,150,189,211,186,9,26,1,119,147,1,49,99,52,102,53,52,54,57,45,54,101,49,49,45,52,55,48,51,45,57,48,56,54,45,101,98,98,50,51,57,49,53,100,53,100,56,44,57,100,48,48,56,50,51,97,45,100,57,101,50,45,52,102,98,55,45,98,100,98,54,45,99,97,102,54,101,98,99,54,99,49,50,51,44,48,52,48,102,98,48,98,102,45,50,101,100,97,45,52,99,97,51,45,56,54,99,97,45,53,98,57,49,98,55,48,50,102,101,49,54,44,52,49,57,50,51,51,57,51,45,102,55,99,51,45,52,50,51,53,45,98,54,49,51,45,102,57,97,101,56,52,102,102,53,56,56,57,168,150,189,211,186,9,27,1,122,0,0,0,0,102,65,147,60,161,150,189,211,186,9,28,1,39,0,129,162,211,229,14,8,6,89,80,102,105,50,109,1,40,0,150,189,211,186,9,33,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,147,206,40,0,150,189,211,186,9,33,4,100,97,116,97,1,119,3,89,101,115,40,0,150,189,211,186,9,33,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,5,40,0,150,189,211,186,9,33,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,65,147,206,2,141,231,246,230,8,0,161,229,240,171,135,7,4,1,168,141,231,246,230,8,0,1,122,0,0,0,0,102,88,25,35,1,179,235,169,194,8,0,161,137,254,221,87,15,2,1,219,210,242,162,8,0,161,235,218,250,209,15,2,5,1,140,141,210,196,7,0,161,222,168,212,145,3,3,16,1,227,177,139,177,7,0,161,153,153,172,165,13,3,7,1,229,240,171,135,7,0,161,219,210,242,162,8,4,5,1,158,234,217,249,6,0,161,212,192,173,181,10,5,5,1,168,180,153,248,6,0,161,185,146,147,208,14,1,24,1,195,153,238,185,6,0,161,145,176,227,152,15,1,35,1,144,169,186,164,6,0,161,160,254,254,214,10,1,2,1,155,234,245,236,5,0,161,253,249,233,173,15,1,18,1,202,211,156,221,5,0,161,177,253,176,145,14,0,62,1,250,181,208,232,4,0,161,144,169,186,164,6,1,2,2,224,134,128,190,4,0,161,197,130,149,248,15,7,1,161,212,192,173,181,10,9,5,1,174,167,159,184,4,0,161,195,153,238,185,6,34,4,1,139,215,211,140,4,0,161,152,171,228,253,2,9,30,34,182,238,220,247,3,0,161,129,162,211,229,14,7,1,39,0,129,162,211,229,14,8,6,86,89,52,50,103,49,1,40,0,182,238,220,247,3,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,144,110,33,0,182,238,220,247,3,1,10,102,105,101,108,100,95,116,121,112,101,1,33,0,182,238,220,247,3,1,4,100,97,116,97,1,33,0,182,238,220,247,3,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,182,238,220,247,3,0,1,168,182,238,220,247,3,4,1,119,4,56,56,56,57,168,182,238,220,247,3,3,1,122,0,0,0,0,0,0,0,1,168,182,238,220,247,3,5,1,122,0,0,0,0,102,61,145,39,161,182,238,220,247,3,6,1,39,0,129,162,211,229,14,8,6,54,76,70,72,66,54,1,40,0,182,238,220,247,3,11,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,145,93,33,0,182,238,220,247,3,11,10,102,105,101,108,100,95,116,121,112,101,1,33,0,182,238,220,247,3,11,4,100,97,116,97,1,33,0,182,238,220,247,3,11,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,182,238,220,247,3,10,1,161,182,238,220,247,3,13,1,161,182,238,220,247,3,14,1,161,182,238,220,247,3,15,1,161,182,238,220,247,3,16,1,168,182,238,220,247,3,18,1,119,9,98,97,105,100,117,46,99,111,109,168,182,238,220,247,3,17,1,122,0,0,0,0,0,0,0,6,168,182,238,220,247,3,19,1,122,0,0,0,0,102,61,145,95,161,182,238,220,247,3,20,1,39,0,129,162,211,229,14,8,6,106,87,101,95,116,54,1,40,0,182,238,220,247,3,25,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,247,78,33,0,182,238,220,247,3,25,10,102,105,101,108,100,95,116,121,112,101,1,40,0,182,238,220,247,3,25,13,101,110,100,95,116,105,109,101,115,116,97,109,112,1,119,0,33,0,182,238,220,247,3,25,4,100,97,116,97,1,40,0,182,238,220,247,3,25,8,105,115,95,114,97,110,103,101,1,121,40,0,182,238,220,247,3,25,11,114,101,109,105,110,100,101,114,95,105,100,1,119,0,40,0,182,238,220,247,3,25,12,105,110,99,108,117,100,101,95,116,105,109,101,1,121,33,0,182,238,220,247,3,25,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,1,240,197,174,164,3,0,161,141,225,222,162,2,1,6,1,222,168,212,145,3,0,161,138,141,245,198,12,3,4,1,205,245,156,144,3,0,161,202,211,156,221,5,61,8,1,152,171,228,253,2,0,161,242,190,222,204,10,7,10,1,141,225,222,162,2,0,161,250,181,208,232,4,1,2,5,160,193,167,129,2,0,168,150,189,211,186,9,32,1,122,0,0,0,0,102,67,57,110,168,182,238,220,247,3,27,1,122,0,0,0,0,0,0,0,10,167,182,238,220,247,3,29,0,8,0,160,193,167,129,2,2,1,119,36,51,49,56,97,97,52,49,53,45,57,50,97,101,45,52,56,57,97,45,97,49,52,102,45,97,50,52,54,57,50,97,50,101,102,97,54,168,182,238,220,247,3,33,1,122,0,0,0,0,102,67,57,110,1,220,226,182,197,1,0,161,130,157,172,36,11,6,1,228,141,195,172,1,0,161,186,200,234,175,15,11,29,1,137,254,221,87,0,161,201,249,157,231,9,38,16,1,130,157,172,36,0,161,225,225,173,133,12,9,12,52,129,162,211,229,14,1,7,1,130,157,172,36,1,0,12,195,153,238,185,6,1,0,35,197,130,149,248,15,1,0,8,201,249,157,231,9,1,0,39,138,141,245,198,12,1,0,4,139,215,211,140,4,1,0,30,140,141,210,196,7,1,0,16,205,245,156,144,3,1,0,8,141,225,222,162,2,1,0,2,142,253,173,141,11,1,0,2,144,169,186,164,6,1,0,2,145,176,227,152,15,1,0,2,202,211,156,221,5,1,0,62,137,254,221,87,1,0,16,212,192,173,181,10,1,0,10,213,199,181,135,15,1,0,39,147,160,184,220,9,1,0,6,150,189,211,186,9,5,0,1,3,8,14,1,17,12,32,1,152,171,228,253,2,1,0,10,153,153,172,165,13,1,0,4,141,231,246,230,8,1,0,1,155,214,174,214,9,1,0,118,220,226,182,197,1,1,0,6,155,234,245,236,5,1,0,18,158,239,153,203,9,1,0,26,158,234,217,249,6,1,0,5,224,134,128,190,4,1,0,6,160,254,254,214,10,1,0,2,225,225,173,133,12,1,0,10,222,168,212,145,3,1,0,4,222,239,172,216,10,1,0,4,227,177,139,177,7,1,0,7,163,150,249,138,12,1,0,9,228,141,195,172,1,1,0,29,168,180,153,248,6,1,0,24,219,210,242,162,8,1,0,5,229,240,171,135,7,1,0,5,235,218,250,209,15,1,0,3,174,167,159,184,4,1,0,4,240,197,174,164,3,1,0,6,177,253,176,145,14,1,0,1,242,190,222,204,10,1,0,8,240,245,224,143,11,1,0,6,179,235,169,194,8,1,0,2,182,238,220,247,3,8,0,1,3,4,10,1,13,8,24,1,27,1,29,1,33,1,185,249,173,251,12,1,0,20,250,181,208,232,4,1,0,2,185,146,147,208,14,1,0,2,186,200,234,175,15,1,0,12,253,249,233,173,15,1,0,2,250,188,188,173,14,1,0,2],"0160e587-41f4-4391-abb3-d322b523edb2":[34,1,169,243,176,173,14,0,161,136,210,233,249,3,12,10,1,149,233,213,204,13,0,161,200,193,211,175,13,1,26,1,200,193,211,175,13,0,161,171,146,234,226,2,9,2,1,176,222,150,172,13,0,161,189,136,144,179,10,1,6,1,174,136,245,243,12,0,161,144,251,151,19,5,39,1,145,167,143,155,12,0,161,132,147,219,143,3,6,9,2,204,172,143,149,12,0,161,148,160,235,175,1,4,7,168,204,172,143,149,12,6,1,122,0,0,0,0,102,88,25,35,28,156,179,144,186,11,0,161,214,221,216,208,2,10,1,39,0,214,221,216,208,2,9,6,70,114,115,115,74,100,1,40,0,156,179,144,186,11,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,140,60,33,0,156,179,144,186,11,1,10,102,105,101,108,100,95,116,121,112,101,1,33,0,156,179,144,186,11,1,4,100,97,116,97,1,33,0,156,179,144,186,11,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,156,179,144,186,11,0,1,168,156,179,144,186,11,4,1,119,36,48,52,102,52,55,48,51,55,45,49,56,54,97,45,52,56,55,102,45,98,54,56,101,45,102,49,98,102,97,48,102,101,54,54,53,101,168,156,179,144,186,11,3,1,122,0,0,0,0,0,0,0,3,168,156,179,144,186,11,5,1,122,0,0,0,0,102,65,140,110,161,156,179,144,186,11,6,1,39,0,214,221,216,208,2,9,6,115,111,118,85,116,69,1,40,0,156,179,144,186,11,11,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,147,72,33,0,156,179,144,186,11,11,10,102,105,101,108,100,95,116,121,112,101,1,33,0,156,179,144,186,11,11,4,100,97,116,97,1,33,0,156,179,144,186,11,11,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,156,179,144,186,11,10,1,161,156,179,144,186,11,14,1,161,156,179,144,186,11,13,1,161,156,179,144,186,11,15,1,161,156,179,144,186,11,16,1,161,156,179,144,186,11,17,1,161,156,179,144,186,11,18,1,161,156,179,144,186,11,19,1,168,156,179,144,186,11,20,1,122,0,0,0,0,102,65,147,75,168,156,179,144,186,11,21,1,119,73,102,99,100,54,101,102,56,99,45,56,99,100,54,45,52,49,98,51,45,57,50,52,53,45,57,57,56,57,51,49,100,52,57,97,49,54,44,57,100,48,48,56,50,51,97,45,100,57,101,50,45,52,102,98,55,45,98,100,98,54,45,99,97,102,54,101,98,99,54,99,49,50,51,168,156,179,144,186,11,22,1,122,0,0,0,0,0,0,0,4,168,156,179,144,186,11,23,1,122,0,0,0,0,102,65,147,75,1,146,233,137,149,11,0,161,216,226,128,250,2,9,12,1,132,204,135,146,11,0,161,246,211,137,45,5,26,1,189,136,144,179,10,0,161,145,167,143,155,12,8,2,1,190,135,128,165,10,0,161,180,231,185,129,2,3,10,1,213,169,224,237,9,0,161,244,148,242,155,4,3,6,1,227,133,204,217,9,0,161,176,222,150,172,13,5,2,1,251,233,161,212,7,0,161,213,169,224,237,9,5,3,1,255,171,204,197,7,0,161,248,180,185,229,2,3,2,1,249,211,146,138,7,0,161,149,233,213,204,13,25,3,1,203,203,186,212,6,0,161,132,132,171,141,1,29,1,1,197,235,146,149,5,0,161,251,233,161,212,7,2,34,1,187,192,226,180,4,0,161,241,252,150,189,1,47,8,1,244,148,242,155,4,0,161,176,135,229,160,1,23,4,1,136,210,233,249,3,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,13,1,132,147,219,143,3,0,161,174,136,245,243,12,38,7,1,216,226,128,250,2,0,161,187,192,226,180,4,7,10,1,248,180,185,229,2,0,161,132,204,135,146,11,25,4,2,171,146,234,226,2,0,161,176,222,150,172,13,5,1,161,227,133,204,217,9,1,9,16,214,221,216,208,2,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,214,221,216,208,2,0,2,105,100,1,119,36,48,49,54,48,101,53,56,55,45,52,49,102,52,45,52,51,57,49,45,97,98,98,51,45,100,51,50,50,98,53,50,51,101,100,98,50,40,0,214,221,216,208,2,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,214,221,216,208,2,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,214,221,216,208,2,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,214,221,216,208,2,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,247,182,33,0,214,221,216,208,2,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,214,221,216,208,2,0,5,99,101,108,108,115,1,161,214,221,216,208,2,8,1,39,0,214,221,216,208,2,9,6,86,89,52,50,103,49,1,40,0,214,221,216,208,2,11,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,247,198,40,0,214,221,216,208,2,11,4,100,97,116,97,1,119,3,56,56,56,40,0,214,221,216,208,2,11,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,1,40,0,214,221,216,208,2,11,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,61,247,198,1,180,231,185,129,2,0,161,197,235,146,149,5,33,4,1,241,252,150,189,1,0,161,203,203,186,212,6,0,48,1,148,160,235,175,1,0,161,249,211,146,138,7,2,5,1,176,135,229,160,1,0,161,255,171,204,197,7,1,24,1,132,132,171,141,1,0,161,169,243,176,173,14,9,30,1,246,211,137,45,0,161,146,233,137,149,11,11,6,1,144,251,151,19,0,161,190,135,128,165,10,9,6,34,132,132,171,141,1,1,0,30,132,204,135,146,11,1,0,26,197,235,146,149,5,1,0,34,132,147,219,143,3,1,0,7,136,210,233,249,3,1,0,13,200,193,211,175,13,1,0,2,203,203,186,212,6,1,0,1,204,172,143,149,12,1,0,7,144,251,151,19,1,0,6,145,167,143,155,12,1,0,9,146,233,137,149,11,1,0,12,148,160,235,175,1,1,0,5,213,169,224,237,9,1,0,6,149,233,213,204,13,1,0,26,214,221,216,208,2,2,8,1,10,1,216,226,128,250,2,1,0,10,156,179,144,186,11,4,0,1,3,4,10,1,13,11,227,133,204,217,9,1,0,2,169,243,176,173,14,1,0,10,171,146,234,226,2,1,0,10,174,136,245,243,12,1,0,39,176,222,150,172,13,1,0,6,176,135,229,160,1,1,0,24,241,252,150,189,1,1,0,48,244,148,242,155,4,1,0,4,180,231,185,129,2,1,0,4,246,211,137,45,1,0,6,248,180,185,229,2,1,0,4,249,211,146,138,7,1,0,3,187,192,226,180,4,1,0,8,251,233,161,212,7,1,0,3,189,136,144,179,10,1,0,2,190,135,128,165,10,1,0,10,255,171,204,197,7,1,0,2],"1cb91fa2-638d-40d6-a7c4-394f0d8b1913":[36,1,238,137,157,247,15,0,161,221,232,159,196,3,14,29,1,203,245,161,202,15,0,161,188,205,187,245,3,38,7,1,187,170,199,190,15,0,161,167,237,232,169,3,9,2,1,170,171,213,133,15,0,161,183,186,132,201,5,8,6,1,201,227,232,191,14,0,161,181,253,171,218,8,3,2,54,176,143,254,225,13,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,176,143,254,225,13,0,2,105,100,1,119,36,49,99,98,57,49,102,97,50,45,54,51,56,100,45,52,48,100,54,45,97,55,99,52,45,51,57,52,102,48,100,56,98,49,57,49,51,40,0,176,143,254,225,13,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,176,143,254,225,13,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,176,143,254,225,13,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,176,143,254,225,13,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,145,9,33,0,176,143,254,225,13,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,176,143,254,225,13,0,5,99,101,108,108,115,1,161,176,143,254,225,13,8,1,39,0,176,143,254,225,13,9,6,86,89,52,50,103,49,1,40,0,176,143,254,225,13,11,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,208,16,33,0,176,143,254,225,13,11,10,102,105,101,108,100,95,116,121,112,101,1,33,0,176,143,254,225,13,11,4,100,97,116,97,1,33,0,176,143,254,225,13,11,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,176,143,254,225,13,10,1,161,176,143,254,225,13,13,1,161,176,143,254,225,13,14,1,161,176,143,254,225,13,15,1,161,176,143,254,225,13,16,1,39,0,176,143,254,225,13,9,6,106,87,101,95,116,54,1,40,0,176,143,254,225,13,21,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,247,108,33,0,176,143,254,225,13,21,13,101,110,100,95,116,105,109,101,115,116,97,109,112,1,33,0,176,143,254,225,13,21,12,105,110,99,108,117,100,101,95,116,105,109,101,1,33,0,176,143,254,225,13,21,10,102,105,101,108,100,95,116,121,112,101,1,33,0,176,143,254,225,13,21,8,105,115,95,114,97,110,103,101,1,33,0,176,143,254,225,13,21,11,114,101,109,105,110,100,101,114,95,105,100,1,33,0,176,143,254,225,13,21,4,100,97,116,97,1,33,0,176,143,254,225,13,21,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,176,143,254,225,13,20,1,161,176,143,254,225,13,28,1,161,176,143,254,225,13,25,1,161,176,143,254,225,13,23,1,161,176,143,254,225,13,24,1,161,176,143,254,225,13,26,1,161,176,143,254,225,13,27,1,161,176,143,254,225,13,29,1,161,176,143,254,225,13,30,1,161,176,143,254,225,13,32,1,161,176,143,254,225,13,33,1,161,176,143,254,225,13,31,1,161,176,143,254,225,13,34,1,161,176,143,254,225,13,35,1,161,176,143,254,225,13,36,1,161,176,143,254,225,13,37,1,161,176,143,254,225,13,38,1,168,176,143,254,225,13,39,1,122,0,0,0,0,0,0,0,2,168,176,143,254,225,13,41,1,119,10,49,55,49,54,50,48,49,48,55,48,168,176,143,254,225,13,42,1,121,168,176,143,254,225,13,43,1,120,168,176,143,254,225,13,44,1,119,0,168,176,143,254,225,13,40,1,119,10,49,55,49,54,53,52,54,54,55,48,168,176,143,254,225,13,45,1,122,0,0,0,0,102,61,247,110,1,130,240,184,196,13,0,161,178,163,133,192,10,8,2,1,153,175,162,242,12,0,161,252,192,199,162,9,2,5,1,153,220,165,207,12,0,161,249,139,227,143,7,23,4,1,166,194,251,203,12,0,161,215,220,249,203,2,5,2,1,152,210,190,161,12,0,161,190,172,167,219,5,7,10,6,175,192,222,199,11,0,161,176,143,254,225,13,46,1,39,0,176,143,254,225,13,9,6,89,80,102,105,50,109,1,40,0,175,192,222,199,11,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,250,101,40,0,175,192,222,199,11,1,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,5,40,0,175,192,222,199,11,1,4,100,97,116,97,1,119,3,89,101,115,40,0,175,192,222,199,11,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,61,250,101,1,184,246,254,146,11,0,161,182,191,217,143,3,5,12,1,215,244,255,242,10,0,161,187,170,199,190,15,1,24,1,178,163,133,192,10,0,161,203,245,161,202,15,6,9,1,170,181,130,222,9,0,161,168,209,143,134,8,0,48,1,252,192,199,162,9,0,161,215,244,255,242,10,23,3,1,185,162,192,153,9,0,161,179,139,229,135,7,11,6,1,181,253,171,218,8,0,161,155,177,225,165,7,25,4,1,168,209,143,134,8,0,161,238,137,157,247,15,28,1,1,155,177,225,165,7,0,161,185,162,192,153,9,5,26,1,249,139,227,143,7,0,161,201,227,232,191,14,1,24,1,179,139,229,135,7,0,161,152,210,190,161,12,9,12,1,234,179,204,154,6,0,161,208,165,135,137,5,5,2,1,190,172,167,219,5,0,161,170,181,130,222,9,47,8,1,183,186,132,201,5,0,161,224,212,129,14,3,9,1,208,165,135,137,5,0,161,153,220,165,207,12,3,6,1,244,213,208,134,5,0,161,234,179,204,154,6,1,34,1,188,205,187,245,3,0,161,170,171,213,133,15,5,39,20,130,147,164,198,3,0,161,175,192,222,199,11,0,1,168,176,143,254,225,13,18,1,119,9,48,46,53,54,54,54,54,54,54,168,176,143,254,225,13,17,1,122,0,0,0,0,0,0,0,1,168,176,143,254,225,13,19,1,122,0,0,0,0,102,65,130,1,161,130,147,164,198,3,0,1,39,0,176,143,254,225,13,9,6,70,114,115,115,74,100,1,40,0,130,147,164,198,3,5,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,140,53,40,0,130,147,164,198,3,5,4,100,97,116,97,1,119,4,120,90,48,51,40,0,130,147,164,198,3,5,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,40,0,130,147,164,198,3,5,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,65,140,53,161,130,147,164,198,3,4,1,39,0,176,143,254,225,13,9,6,115,111,118,85,116,69,1,40,0,130,147,164,198,3,11,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,147,63,33,0,130,147,164,198,3,11,4,100,97,116,97,1,33,0,130,147,164,198,3,11,10,102,105,101,108,100,95,116,121,112,101,1,33,0,130,147,164,198,3,11,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,168,130,147,164,198,3,10,1,122,0,0,0,0,102,65,147,221,168,130,147,164,198,3,14,1,122,0,0,0,0,0,0,0,4,168,130,147,164,198,3,13,1,119,73,56,51,51,50,99,52,56,51,45,102,56,57,99,45,52,48,53,55,45,57,101,99,57,45,101,50,53,53,56,54,53,48,52,52,51,56,44,48,52,48,102,98,48,98,102,45,50,101,100,97,45,52,99,97,51,45,56,54,99,97,45,53,98,57,49,98,55,48,50,102,101,49,54,168,130,147,164,198,3,15,1,122,0,0,0,0,102,65,147,221,1,221,232,159,196,3,0,161,184,246,254,146,11,11,15,2,167,237,232,169,3,0,161,215,220,249,203,2,5,1,161,166,194,251,203,12,1,9,1,182,191,217,143,3,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,6,1,215,220,249,203,2,0,161,130,240,184,196,13,1,6,2,235,238,250,238,1,0,161,153,175,162,242,12,4,6,168,235,238,250,238,1,5,1,122,0,0,0,0,102,88,25,34,1,224,212,129,14,0,161,244,213,208,134,5,33,4,36,130,240,184,196,13,1,0,2,130,147,164,198,3,4,0,1,4,1,10,1,13,3,201,227,232,191,14,1,0,2,203,245,161,202,15,1,0,7,208,165,135,137,5,1,0,6,215,220,249,203,2,1,0,6,152,210,190,161,12,1,0,10,153,220,165,207,12,1,0,4,215,244,255,242,10,1,0,24,155,177,225,165,7,1,0,26,153,175,162,242,12,1,0,5,221,232,159,196,3,1,0,15,224,212,129,14,1,0,4,166,194,251,203,12,1,0,2,167,237,232,169,3,1,0,10,168,209,143,134,8,1,0,1,170,181,130,222,9,1,0,48,234,179,204,154,6,1,0,2,170,171,213,133,15,1,0,6,235,238,250,238,1,1,0,6,238,137,157,247,15,1,0,29,175,192,222,199,11,1,0,1,176,143,254,225,13,4,8,1,10,1,13,8,23,24,178,163,133,192,10,1,0,9,179,139,229,135,7,1,0,12,244,213,208,134,5,1,0,34,181,253,171,218,8,1,0,4,182,191,217,143,3,1,0,6,183,186,132,201,5,1,0,9,184,246,254,146,11,1,0,12,249,139,227,143,7,1,0,24,185,162,192,153,9,1,0,6,187,170,199,190,15,1,0,2,188,205,187,245,3,1,0,39,252,192,199,162,9,1,0,3,190,172,167,219,5,1,0,8],"3ccd17e0-d78b-44e2-afd1-1bf7cc49cb56":[34,1,206,233,228,195,15,0,161,170,189,188,208,5,5,2,1,253,134,198,190,15,0,161,193,228,168,220,13,3,2,1,153,141,151,158,15,0,161,222,235,157,159,14,8,6,1,148,232,153,164,14,0,161,136,255,156,248,4,24,3,1,222,235,157,159,14,0,161,252,145,253,197,3,3,9,1,182,194,169,138,14,0,161,169,138,231,172,3,5,2,1,193,228,168,220,13,0,161,155,223,214,152,11,25,4,2,215,249,231,218,13,0,161,145,163,160,202,11,4,6,168,215,249,231,218,13,5,1,122,0,0,0,0,102,88,25,35,1,194,189,155,132,13,0,161,144,133,245,185,2,23,1,1,226,198,237,226,12,0,161,158,166,203,46,23,4,1,137,217,190,128,12,0,161,133,194,167,165,11,38,7,42,214,218,172,227,11,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,214,218,172,227,11,0,2,105,100,1,119,36,51,99,99,100,49,55,101,48,45,100,55,56,98,45,52,52,101,50,45,97,102,100,49,45,49,98,102,55,99,99,52,57,99,98,53,54,40,0,214,218,172,227,11,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,214,218,172,227,11,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,214,218,172,227,11,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,214,218,172,227,11,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,247,32,33,0,214,218,172,227,11,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,214,218,172,227,11,0,5,99,101,108,108,115,1,161,214,218,172,227,11,8,1,39,0,214,218,172,227,11,9,6,86,89,52,50,103,49,1,40,0,214,218,172,227,11,11,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,247,36,40,0,214,218,172,227,11,11,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,1,40,0,214,218,172,227,11,11,4,100,97,116,97,1,119,3,55,55,55,40,0,214,218,172,227,11,11,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,61,247,36,161,214,218,172,227,11,10,1,39,0,214,218,172,227,11,9,6,106,87,101,95,116,54,1,40,0,214,218,172,227,11,17,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,247,92,33,0,214,218,172,227,11,17,12,105,110,99,108,117,100,101,95,116,105,109,101,1,33,0,214,218,172,227,11,17,8,105,115,95,114,97,110,103,101,1,33,0,214,218,172,227,11,17,13,101,110,100,95,116,105,109,101,115,116,97,109,112,1,33,0,214,218,172,227,11,17,4,100,97,116,97,1,33,0,214,218,172,227,11,17,10,102,105,101,108,100,95,116,121,112,101,1,33,0,214,218,172,227,11,17,11,114,101,109,105,110,100,101,114,95,105,100,1,33,0,214,218,172,227,11,17,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,214,218,172,227,11,16,1,161,214,218,172,227,11,21,1,161,214,218,172,227,11,23,1,161,214,218,172,227,11,19,1,161,214,218,172,227,11,20,1,161,214,218,172,227,11,24,1,161,214,218,172,227,11,22,1,161,214,218,172,227,11,25,1,161,214,218,172,227,11,26,1,168,214,218,172,227,11,27,1,119,0,168,214,218,172,227,11,32,1,119,10,49,55,49,53,57,52,49,56,48,48,168,214,218,172,227,11,29,1,120,168,214,218,172,227,11,28,1,122,0,0,0,0,0,0,0,2,168,214,218,172,227,11,30,1,121,168,214,218,172,227,11,31,1,119,21,71,99,83,71,68,56,119,81,82,81,90,116,68,101,122,109,115,122,73,97,74,168,214,218,172,227,11,33,1,122,0,0,0,0,102,61,247,101,1,179,151,213,226,11,0,161,142,245,238,213,8,11,6,1,145,163,160,202,11,0,161,148,232,153,164,14,2,5,1,133,194,167,165,11,0,161,153,141,151,158,15,5,39,1,155,223,214,152,11,0,161,179,151,213,226,11,5,26,1,182,179,204,141,10,0,161,148,207,232,183,3,11,10,1,142,245,238,213,8,0,161,179,170,225,17,9,12,6,158,225,233,217,7,0,161,214,218,172,227,11,34,1,39,0,214,218,172,227,11,9,6,89,80,102,105,50,109,1,40,0,158,225,233,217,7,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,250,105,40,0,158,225,233,217,7,1,4,100,97,116,97,1,119,3,89,101,115,40,0,158,225,233,217,7,1,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,5,40,0,158,225,233,217,7,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,61,250,105,1,158,231,208,154,7,0,161,148,249,197,139,5,8,2,1,170,189,188,208,5,0,161,226,198,237,226,12,3,6,2,184,177,221,199,5,0,161,169,138,231,172,3,5,1,161,182,194,169,138,14,1,11,1,148,249,197,139,5,0,161,137,217,190,128,12,6,9,1,136,255,156,248,4,0,161,184,177,221,199,5,11,25,16,208,242,145,212,4,0,161,158,225,233,217,7,0,1,39,0,214,218,172,227,11,9,6,70,114,115,115,74,100,1,40,0,208,242,145,212,4,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,140,57,40,0,208,242,145,212,4,1,4,100,97,116,97,1,119,36,54,49,50,100,50,99,51,98,45,56,50,98,99,45,52,55,51,98,45,98,49,52,53,45,55,102,53,55,49,56,54,101,51,102,55,101,40,0,208,242,145,212,4,1,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,40,0,208,242,145,212,4,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,65,140,57,161,208,242,145,212,4,0,1,39,0,214,218,172,227,11,9,6,115,111,118,85,116,69,1,40,0,208,242,145,212,4,7,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,147,68,33,0,208,242,145,212,4,7,10,102,105,101,108,100,95,116,121,112,101,1,33,0,208,242,145,212,4,7,4,100,97,116,97,1,33,0,208,242,145,212,4,7,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,168,208,242,145,212,4,6,1,122,0,0,0,0,102,65,147,69,168,208,242,145,212,4,9,1,122,0,0,0,0,0,0,0,4,168,208,242,145,212,4,10,1,119,73,52,53,53,98,100,49,56,51,45,54,54,57,102,45,52,98,49,55,45,56,99,56,57,45,56,102,56,53,48,102,102,50,48,51,54,52,44,57,97,102,51,49,102,100,53,45,98,54,53,52,45,52,54,54,54,45,98,101,101,57,45,101,50,52,55,49,51,55,50,53,49,102,53,168,208,242,145,212,4,11,1,122,0,0,0,0,102,65,147,69,1,252,145,253,197,3,0,161,222,146,227,251,2,33,4,1,148,207,232,183,3,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,12,1,169,138,231,172,3,0,161,158,231,208,154,7,1,6,1,222,146,227,251,2,0,161,206,233,228,195,15,1,34,1,144,133,245,185,2,0,161,182,179,204,141,10,9,24,1,198,237,231,132,2,0,161,194,189,155,132,13,0,48,1,153,149,181,61,0,161,198,237,231,132,2,47,8,1,158,166,203,46,0,161,253,134,198,190,15,1,24,1,179,170,225,17,0,161,153,149,181,61,7,10,34,193,228,168,220,13,1,0,4,194,189,155,132,13,1,0,1,133,194,167,165,11,1,0,39,198,237,231,132,2,1,0,48,136,255,156,248,4,1,0,25,137,217,190,128,12,1,0,7,142,245,238,213,8,1,0,12,206,233,228,195,15,1,0,2,144,133,245,185,2,1,0,24,145,163,160,202,11,1,0,5,208,242,145,212,4,3,0,1,6,1,9,3,148,207,232,183,3,1,0,12,148,249,197,139,5,1,0,9,148,232,153,164,14,1,0,3,214,218,172,227,11,4,8,1,10,1,16,1,19,16,215,249,231,218,13,1,0,6,153,149,181,61,1,0,8,153,141,151,158,15,1,0,6,155,223,214,152,11,1,0,26,158,166,203,46,1,0,24,222,146,227,251,2,1,0,34,158,225,233,217,7,1,0,1,222,235,157,159,14,1,0,9,226,198,237,226,12,1,0,4,158,231,208,154,7,1,0,2,169,138,231,172,3,1,0,6,170,189,188,208,5,1,0,6,179,170,225,17,1,0,10,179,151,213,226,11,1,0,6,182,194,169,138,14,1,0,2,182,179,204,141,10,1,0,10,184,177,221,199,5,1,0,12,252,145,253,197,3,1,0,4,253,134,198,190,15,1,0,2],"4b560c2d-3f39-4086-aa3d-c2590d129850":[23,1,171,144,240,132,15,0,161,177,185,133,253,10,8,6,1,132,180,178,130,15,0,161,209,233,132,156,1,11,28,1,156,220,236,216,13,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,17,1,208,149,239,158,11,0,161,168,150,210,229,5,33,4,1,177,185,133,253,10,0,161,208,149,239,158,11,3,9,1,207,140,163,157,10,0,161,213,152,246,243,7,6,9,1,228,231,188,223,9,0,161,230,228,200,164,7,5,2,1,160,143,150,180,9,0,161,244,180,255,255,8,1,6,1,244,180,255,255,8,0,161,207,140,163,157,10,8,2,1,213,152,246,243,7,0,161,141,170,152,151,3,38,7,1,183,242,197,235,7,0,161,160,143,150,180,9,5,2,10,159,171,231,213,7,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,159,171,231,213,7,0,2,105,100,1,119,36,52,98,53,54,48,99,50,100,45,51,102,51,57,45,52,48,56,54,45,97,97,51,100,45,99,50,53,57,48,100,49,50,57,56,53,48,40,0,159,171,231,213,7,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,159,171,231,213,7,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,159,171,231,213,7,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,159,171,231,213,7,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,69,115,240,40,0,159,171,231,213,7,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,69,115,240,39,0,159,171,231,213,7,0,5,99,101,108,108,115,1,1,240,167,210,197,7,0,161,156,220,236,216,13,16,2,1,230,228,200,164,7,0,161,151,235,176,134,1,3,6,2,246,239,129,224,6,0,161,185,162,129,222,6,4,6,168,246,239,129,224,6,5,1,122,0,0,0,0,102,88,25,34,1,185,162,129,222,6,0,161,209,145,212,136,2,2,5,1,209,227,201,148,6,0,161,254,152,161,193,3,1,24,1,168,150,210,229,5,0,161,228,231,188,223,9,1,34,1,254,152,161,193,3,0,161,240,167,210,197,7,1,2,1,141,170,152,151,3,0,161,171,144,240,132,15,5,39,1,209,145,212,136,2,0,161,132,180,178,130,15,27,3,2,209,233,132,156,1,0,161,160,143,150,180,9,5,1,161,183,242,197,235,7,1,11,1,151,235,176,134,1,0,161,209,227,201,148,6,23,4,22,160,143,150,180,9,1,0,6,228,231,188,223,9,1,0,2,132,180,178,130,15,1,0,28,230,228,200,164,7,1,0,6,168,150,210,229,5,1,0,34,171,144,240,132,15,1,0,6,141,170,152,151,3,1,0,39,207,140,163,157,10,1,0,9,240,167,210,197,7,1,0,2,209,227,201,148,6,1,0,24,208,149,239,158,11,1,0,4,177,185,133,253,10,1,0,9,244,180,255,255,8,1,0,2,213,152,246,243,7,1,0,7,209,233,132,156,1,1,0,12,151,235,176,134,1,1,0,4,183,242,197,235,7,1,0,2,209,145,212,136,2,1,0,3,185,162,129,222,6,1,0,5,246,239,129,224,6,1,0,6,156,220,236,216,13,1,0,17,254,152,161,193,3,1,0,2],"88fa36b2-6d72-44de-b0df-d3b2e6d744d6":[18,1,177,156,240,229,15,0,161,147,222,174,199,5,34,4,1,224,147,154,227,15,0,161,247,228,241,157,4,5,2,1,236,146,204,211,15,0,161,177,156,240,229,15,3,10,1,254,245,134,175,14,0,161,194,254,163,142,12,1,5,1,184,249,249,169,13,0,161,245,165,251,164,5,11,25,81,221,254,206,225,12,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,221,254,206,225,12,0,2,105,100,1,119,36,56,56,102,97,51,54,98,50,45,54,100,55,50,45,52,52,100,101,45,98,48,100,102,45,100,51,98,50,101,54,100,55,52,52,100,54,40,0,221,254,206,225,12,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,221,254,206,225,12,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,221,254,206,225,12,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,221,254,206,225,12,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,75,60,209,33,0,221,254,206,225,12,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,221,254,206,225,12,0,5,99,101,108,108,115,1,39,0,221,254,206,225,12,9,6,70,114,115,115,74,100,1,40,0,221,254,206,225,12,10,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,40,0,221,254,206,225,12,10,4,100,97,116,97,1,119,36,48,52,102,52,55,48,51,55,45,49,56,54,97,45,52,56,55,102,45,98,54,56,101,45,102,49,98,102,97,48,102,101,54,54,53,101,161,221,254,206,225,12,8,1,39,0,221,254,206,225,12,9,6,84,102,117,121,104,84,1,40,0,221,254,206,225,12,14,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,75,61,9,40,0,221,254,206,225,12,14,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,0,40,0,221,254,206,225,12,14,4,100,97,116,97,1,119,27,229,150,157,228,186,134,229,165,189,229,164,154,233,133,146,231,157,161,229,144,167,231,157,161,229,144,167,40,0,221,254,206,225,12,14,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,75,61,9,161,221,254,206,225,12,13,1,39,0,221,254,206,225,12,9,6,52,57,85,69,86,53,1,40,0,221,254,206,225,12,20,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,75,61,9,33,0,221,254,206,225,12,20,4,100,97,116,97,1,33,0,221,254,206,225,12,20,12,105,110,99,108,117,100,101,95,116,105,109,101,1,33,0,221,254,206,225,12,20,8,105,115,95,114,97,110,103,101,1,33,0,221,254,206,225,12,20,11,114,101,109,105,110,100,101,114,95,105,100,1,33,0,221,254,206,225,12,20,10,102,105,101,108,100,95,116,121,112,101,1,33,0,221,254,206,225,12,20,13,101,110,100,95,116,105,109,101,115,116,97,109,112,1,33,0,221,254,206,225,12,20,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,221,254,206,225,12,19,1,161,221,254,206,225,12,23,1,161,221,254,206,225,12,22,1,161,221,254,206,225,12,26,1,161,221,254,206,225,12,27,1,161,221,254,206,225,12,24,1,161,221,254,206,225,12,25,1,161,221,254,206,225,12,28,1,161,221,254,206,225,12,29,1,161,221,254,206,225,12,31,1,161,221,254,206,225,12,34,1,161,221,254,206,225,12,33,1,161,221,254,206,225,12,30,1,161,221,254,206,225,12,32,1,161,221,254,206,225,12,35,1,161,221,254,206,225,12,36,1,161,221,254,206,225,12,37,1,161,221,254,206,225,12,41,1,161,221,254,206,225,12,40,1,161,221,254,206,225,12,43,1,161,221,254,206,225,12,42,1,161,221,254,206,225,12,38,1,161,221,254,206,225,12,39,1,161,221,254,206,225,12,44,1,161,221,254,206,225,12,45,1,161,221,254,206,225,12,48,1,161,221,254,206,225,12,49,1,161,221,254,206,225,12,47,1,161,221,254,206,225,12,50,1,161,221,254,206,225,12,46,1,161,221,254,206,225,12,51,1,161,221,254,206,225,12,52,1,161,221,254,206,225,12,53,1,168,221,254,206,225,12,55,1,122,0,0,0,0,0,0,0,2,168,221,254,206,225,12,56,1,119,0,168,221,254,206,225,12,59,1,121,168,221,254,206,225,12,54,1,119,0,168,221,254,206,225,12,57,1,119,10,49,55,49,54,54,51,56,56,49,52,168,221,254,206,225,12,58,1,121,168,221,254,206,225,12,60,1,122,0,0,0,0,102,75,61,9,161,221,254,206,225,12,61,1,39,0,221,254,206,225,12,9,6,120,69,81,65,111,75,1,40,0,221,254,206,225,12,70,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,75,61,9,40,0,221,254,206,225,12,70,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,7,40,0,221,254,206,225,12,70,4,100,97,116,97,1,119,79,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,109,100,117,67,34,44,34,110,97,109,101,34,58,34,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,93,44,34,115,101,108,101,99,116,101,100,95,111,112,116,105,111,110,95,105,100,115,34,58,91,93,125,40,0,221,254,206,225,12,70,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,75,61,9,168,221,254,206,225,12,69,1,122,0,0,0,0,102,75,66,138,39,0,221,254,206,225,12,9,6,89,53,52,81,73,115,1,40,0,221,254,206,225,12,76,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,75,66,138,40,0,221,254,206,225,12,76,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,0,40,0,221,254,206,225,12,76,4,100,97,116,97,1,119,9,232,129,154,232,129,154,228,188,154,40,0,221,254,206,225,12,76,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,75,66,138,1,194,254,163,142,12,0,161,184,249,249,169,13,24,2,1,169,227,206,252,11,0,161,246,235,254,152,6,12,3,1,139,151,193,189,8,0,161,211,169,247,209,2,8,2,1,195,158,149,248,6,0,161,236,146,204,211,15,9,6,1,246,235,254,152,6,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,13,1,147,222,174,199,5,0,161,169,227,206,252,11,2,35,2,245,165,251,164,5,0,161,247,228,241,157,4,5,1,161,224,147,154,227,15,1,11,1,247,228,241,157,4,0,161,139,151,193,189,8,1,6,1,143,134,194,128,4,0,161,195,158,149,248,6,5,39,1,211,169,247,209,2,0,161,168,233,136,186,2,6,9,1,168,233,136,186,2,0,161,143,134,194,128,4,38,7,2,187,223,143,158,1,0,161,254,245,134,175,14,4,6,168,187,223,143,158,1,5,1,122,0,0,0,0,102,88,25,35,18,224,147,154,227,15,1,0,2,194,254,163,142,12,1,0,2,195,158,149,248,6,1,0,6,168,233,136,186,2,1,0,7,169,227,206,252,11,1,0,3,139,151,193,189,8,1,0,2,236,146,204,211,15,1,0,10,143,134,194,128,4,1,0,39,177,156,240,229,15,1,0,4,147,222,174,199,5,1,0,35,211,169,247,209,2,1,0,9,245,165,251,164,5,1,0,12,246,235,254,152,6,1,0,13,247,228,241,157,4,1,0,6,184,249,249,169,13,1,0,25,187,223,143,158,1,1,0,6,221,254,206,225,12,5,8,1,13,1,19,1,22,40,69,1,254,245,134,175,14,1,0,5],"1047f2d0-3757-4799-bcf2-e8f97464d2b5":[56,1,136,227,164,244,15,0,161,217,223,147,169,3,9,24,1,255,191,221,240,15,0,161,194,230,250,156,15,1,6,1,250,198,240,231,15,0,161,225,252,149,175,6,15,2,1,212,143,130,218,15,0,161,134,233,204,201,4,1,5,1,203,248,239,208,15,0,161,244,182,169,180,5,5,4,1,157,234,181,165,15,0,161,181,209,194,135,15,0,65,1,194,230,250,156,15,0,161,233,156,145,228,3,25,2,1,147,237,217,153,15,0,161,203,130,173,226,5,11,26,1,181,209,194,135,15,0,161,136,227,164,244,15,23,1,1,177,145,243,240,13,0,161,233,249,213,131,12,1,25,1,238,159,247,242,12,0,161,203,248,239,208,15,3,4,1,219,150,244,224,12,0,161,250,198,240,231,15,1,2,1,131,245,207,191,12,0,161,199,180,231,144,7,7,6,1,189,250,148,144,12,0,161,177,145,243,240,13,24,4,1,233,249,213,131,12,0,161,253,161,181,242,3,3,2,16,232,166,159,250,11,0,161,201,208,178,205,1,0,1,39,0,217,152,170,150,10,8,6,70,114,115,115,74,100,1,40,0,232,166,159,250,11,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,140,47,40,0,232,166,159,250,11,1,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,40,0,232,166,159,250,11,1,4,100,97,116,97,1,119,36,48,52,102,52,55,48,51,55,45,49,56,54,97,45,52,56,55,102,45,98,54,56,101,45,102,49,98,102,97,48,102,101,54,54,53,101,40,0,232,166,159,250,11,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,65,140,47,161,232,166,159,250,11,0,1,39,0,217,152,170,150,10,8,6,115,111,118,85,116,69,1,40,0,232,166,159,250,11,7,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,65,147,55,33,0,232,166,159,250,11,7,10,102,105,101,108,100,95,116,121,112,101,1,33,0,232,166,159,250,11,7,4,100,97,116,97,1,33,0,232,166,159,250,11,7,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,232,166,159,250,11,6,1,168,232,166,159,250,11,10,1,119,73,50,100,54,48,51,48,99,51,45,57,55,49,101,45,52,100,52,53,45,98,53,55,48,45,100,101,57,50,102,100,101,97,100,97,101,54,44,102,99,100,54,101,102,56,99,45,56,99,100,54,45,52,49,98,51,45,57,50,52,53,45,57,57,56,57,51,49,100,52,57,97,49,54,168,232,166,159,250,11,9,1,122,0,0,0,0,0,0,0,4,168,232,166,159,250,11,11,1,122,0,0,0,0,102,65,147,55,1,196,171,189,241,11,0,161,226,147,204,180,8,1,2,1,146,223,210,202,11,0,161,219,150,244,224,12,1,68,1,226,144,128,160,11,0,161,255,191,221,240,15,5,2,5,140,145,178,142,11,0,40,0,217,152,170,150,10,1,36,53,101,48,55,56,53,50,97,45,102,98,53,100,45,53,49,97,52,45,57,98,48,97,45,98,99,100,53,99,54,52,102,57,49,53,100,1,119,6,226,152,152,239,184,143,161,227,133,179,139,3,0,2,40,0,217,152,170,150,10,1,36,54,53,50,51,98,51,50,55,45,100,100,55,49,45,53,97,53,97,45,56,100,98,56,45,102,99,100,98,97,56,100,101,48,57,97,50,1,121,161,140,145,178,142,11,2,1,168,140,145,178,142,11,4,1,122,0,0,0,0,102,78,229,130,1,201,229,189,239,10,0,161,204,184,157,221,9,7,10,9,217,152,170,150,10,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,217,152,170,150,10,0,2,105,100,1,119,36,49,48,52,55,102,50,100,48,45,51,55,53,55,45,52,55,57,57,45,98,99,102,50,45,101,56,102,57,55,52,54,52,100,50,98,53,40,0,217,152,170,150,10,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,217,152,170,150,10,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,217,152,170,150,10,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,48,108,138,33,0,217,152,170,150,10,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,217,152,170,150,10,0,5,99,101,108,108,115,1,1,174,133,132,239,9,0,161,252,135,150,213,5,1,2,1,204,184,157,221,9,0,161,157,234,181,165,15,64,8,1,228,231,180,195,9,0,161,228,255,253,171,9,5,5,1,228,255,253,171,9,0,161,147,193,234,210,6,15,10,66,216,221,232,222,8,0,161,217,152,170,150,10,7,1,39,0,217,152,170,150,10,8,6,54,76,70,72,66,54,1,40,0,216,221,232,222,8,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,60,201,79,33,0,216,221,232,222,8,1,4,100,97,116,97,1,33,0,216,221,232,222,8,1,10,102,105,101,108,100,95,116,121,112,101,1,33,0,216,221,232,222,8,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,216,221,232,222,8,0,1,39,0,217,152,170,150,10,8,6,89,53,52,81,73,115,1,40,0,216,221,232,222,8,7,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,60,201,92,33,0,216,221,232,222,8,7,10,102,105,101,108,100,95,116,121,112,101,1,33,0,216,221,232,222,8,7,4,100,97,116,97,1,33,0,216,221,232,222,8,7,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,216,221,232,222,8,6,1,161,216,221,232,222,8,9,1,161,216,221,232,222,8,10,1,161,216,221,232,222,8,11,1,161,216,221,232,222,8,12,1,161,216,221,232,222,8,13,1,161,216,221,232,222,8,14,1,161,216,221,232,222,8,15,1,161,216,221,232,222,8,16,1,161,216,221,232,222,8,18,1,161,216,221,232,222,8,17,1,161,216,221,232,222,8,19,1,161,216,221,232,222,8,20,1,161,216,221,232,222,8,21,1,161,216,221,232,222,8,22,1,161,216,221,232,222,8,23,1,161,216,221,232,222,8,24,1,161,216,221,232,222,8,25,1,161,216,221,232,222,8,26,1,161,216,221,232,222,8,27,1,161,216,221,232,222,8,28,1,168,216,221,232,222,8,30,1,122,0,0,0,0,0,0,0,0,168,216,221,232,222,8,29,1,119,72,106,115,106,115,104,100,104,100,104,98,32,115,104,115,104,115,104,104,115,104,115,104,115,106,115,106,115,106,115,106,32,117,115,105,115,105,115,106,115,106,230,128,157,230,128,157,229,167,144,32,85,231,155,190,232,174,176,229,190,151,229,176,177,232,161,140,229,147,136,229,147,136,168,216,221,232,222,8,31,1,122,0,0,0,0,102,60,201,101,161,216,221,232,222,8,32,1,161,216,221,232,222,8,4,1,161,216,221,232,222,8,3,1,161,216,221,232,222,8,5,1,161,216,221,232,222,8,36,1,161,216,221,232,222,8,38,1,161,216,221,232,222,8,37,1,161,216,221,232,222,8,39,1,161,216,221,232,222,8,40,1,168,216,221,232,222,8,42,1,122,0,0,0,0,0,0,0,6,168,216,221,232,222,8,41,1,119,6,104,97,104,97,104,97,168,216,221,232,222,8,43,1,122,0,0,0,0,102,60,204,16,161,216,221,232,222,8,44,1,39,0,217,152,170,150,10,8,6,86,89,52,50,103,49,1,40,0,216,221,232,222,8,49,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,144,108,33,0,216,221,232,222,8,49,10,102,105,101,108,100,95,116,121,112,101,1,33,0,216,221,232,222,8,49,4,100,97,116,97,1,33,0,216,221,232,222,8,49,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,216,221,232,222,8,48,1,161,216,221,232,222,8,51,1,161,216,221,232,222,8,52,1,161,216,221,232,222,8,53,1,161,216,221,232,222,8,54,1,161,216,221,232,222,8,55,1,161,216,221,232,222,8,56,1,161,216,221,232,222,8,57,1,161,216,221,232,222,8,58,1,168,216,221,232,222,8,59,1,122,0,0,0,0,0,0,0,1,168,216,221,232,222,8,60,1,119,6,54,54,54,48,48,48,168,216,221,232,222,8,61,1,122,0,0,0,0,102,61,145,64,1,226,147,204,180,8,0,161,131,245,207,191,12,5,2,1,210,134,148,169,8,0,161,242,252,231,244,2,11,6,1,249,253,252,152,8,0,161,146,223,210,202,11,67,49,1,171,244,155,131,8,0,161,249,253,252,152,8,48,9,1,155,170,193,254,7,0,161,211,253,225,214,2,33,4,1,199,180,231,144,7,0,161,228,231,180,195,9,4,8,1,147,193,234,210,6,0,161,206,133,235,151,4,111,20,1,225,252,149,175,6,0,161,238,159,247,242,12,3,16,3,149,130,129,246,5,0,161,217,152,170,150,10,7,1,33,0,217,152,170,150,10,8,6,89,53,52,81,73,115,1,0,4,2,203,130,173,226,5,0,161,255,191,221,240,15,5,1,161,226,144,128,160,11,1,11,1,252,135,150,213,5,0,161,196,171,189,241,11,1,2,7,233,143,142,198,5,0,161,149,130,129,246,5,0,1,39,0,217,152,170,150,10,8,6,106,87,101,95,116,54,1,40,0,233,143,142,198,5,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,67,54,21,39,0,233,143,142,198,5,1,4,100,97,116,97,0,8,0,233,143,142,198,5,3,1,119,36,49,48,52,55,102,50,100,48,45,51,55,53,55,45,52,55,57,57,45,98,99,102,50,45,101,56,102,57,55,52,54,52,100,50,98,53,40,0,233,143,142,198,5,1,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,10,40,0,233,143,142,198,5,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,67,54,21,1,244,182,169,180,5,0,161,174,133,132,239,9,1,6,1,211,218,202,203,4,0,161,253,204,235,17,8,6,1,134,233,204,201,4,0,161,147,237,217,153,15,25,2,1,206,133,235,151,4,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,118,1,253,161,181,242,3,0,161,180,216,208,192,2,25,4,1,233,156,145,228,3,0,161,173,195,221,80,38,26,1,217,223,147,169,3,0,161,171,244,155,131,8,8,10,2,134,177,139,145,3,0,161,212,143,130,218,15,4,7,168,134,177,139,145,3,6,1,122,0,0,0,0,102,88,25,34,4,227,133,179,139,3,0,161,232,166,159,250,11,12,1,168,201,208,178,205,1,4,1,119,3,89,101,115,168,201,208,178,205,1,3,1,122,0,0,0,0,0,0,0,5,168,201,208,178,205,1,5,1,122,0,0,0,0,102,67,57,98,1,190,184,172,134,3,0,161,189,250,148,144,12,3,7,1,242,252,231,244,2,0,161,201,229,189,239,10,9,12,1,211,253,225,214,2,0,161,143,144,136,34,1,34,1,180,216,208,192,2,0,161,210,134,148,169,8,5,26,6,201,208,178,205,1,0,161,216,221,232,222,8,62,1,39,0,217,152,170,150,10,8,6,89,80,102,105,50,109,1,40,0,201,208,178,205,1,1,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,61,250,104,33,0,201,208,178,205,1,1,10,102,105,101,108,100,95,116,121,112,101,1,33,0,201,208,178,205,1,1,4,100,97,116,97,1,33,0,201,208,178,205,1,1,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,1,173,195,221,80,0,161,211,218,202,203,4,5,39,1,143,144,136,34,0,161,190,184,172,134,3,6,2,1,253,204,235,17,0,161,155,170,193,254,7,3,9,56,189,250,148,144,12,1,0,4,190,184,172,134,3,1,0,7,194,230,250,156,15,1,0,2,131,245,207,191,12,1,0,6,196,171,189,241,11,1,0,2,134,233,204,201,4,1,0,2,199,180,231,144,7,1,0,8,136,227,164,244,15,1,0,24,201,229,189,239,10,1,0,10,201,208,178,205,1,2,0,1,3,3,203,248,239,208,15,1,0,4,204,184,157,221,9,1,0,8,203,130,173,226,5,1,0,12,206,133,235,151,4,1,0,118,143,144,136,34,1,0,2,140,145,178,142,11,2,1,2,4,1,134,177,139,145,3,1,0,7,146,223,210,202,11,1,0,68,147,193,234,210,6,1,0,20,210,134,148,169,8,1,0,6,211,253,225,214,2,1,0,34,211,218,202,203,4,1,0,6,147,237,217,153,15,1,0,26,212,143,130,218,15,1,0,5,217,223,147,169,3,1,0,10,217,152,170,150,10,1,7,1,219,150,244,224,12,1,0,2,155,170,193,254,7,1,0,4,157,234,181,165,15,1,0,65,216,221,232,222,8,6,0,1,3,4,9,24,36,9,48,1,51,12,149,130,129,246,5,1,0,6,225,252,149,175,6,1,0,16,226,147,204,180,8,1,0,2,226,144,128,160,11,1,0,2,228,255,253,171,9,1,0,10,228,231,180,195,9,1,0,5,227,133,179,139,3,1,0,1,232,166,159,250,11,3,0,1,6,1,9,4,233,249,213,131,12,1,0,2,233,156,145,228,3,1,0,26,171,244,155,131,8,1,0,9,233,143,142,198,5,1,0,1,173,195,221,80,1,0,39,238,159,247,242,12,1,0,4,174,133,132,239,9,1,0,2,177,145,243,240,13,1,0,25,242,252,231,244,2,1,0,12,244,182,169,180,5,1,0,6,181,209,194,135,15,1,0,1,180,216,208,192,2,1,0,26,249,253,252,152,8,1,0,49,250,198,240,231,15,1,0,2,252,135,150,213,5,1,0,2,253,204,235,17,1,0,9,253,161,181,242,3,1,0,4,255,191,221,240,15,1,0,6],"3aadcc41-4b4d-4570-a5de-06ebe3f460ec":[19,1,198,208,139,248,15,0,161,232,249,153,165,14,34,4,2,178,201,178,226,15,0,161,249,156,177,132,11,4,6,168,178,201,178,226,15,5,1,122,0,0,0,0,102,88,25,35,1,217,221,136,169,15,0,161,247,190,164,130,3,1,6,1,239,247,162,248,14,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,8,1,130,223,137,178,14,0,161,238,205,207,218,4,8,6,1,232,249,153,165,14,0,161,187,231,222,18,1,35,1,214,231,164,137,11,0,161,217,221,136,169,15,5,2,1,249,156,177,132,11,0,161,163,196,200,219,4,1,5,1,238,165,252,166,9,0,161,192,192,155,154,2,1,25,1,157,244,237,240,7,0,161,129,142,211,195,2,6,9,1,163,196,200,219,4,0,161,238,165,252,166,9,24,2,1,238,205,207,218,4,0,161,198,208,139,248,15,3,9,1,220,144,217,197,4,0,161,130,223,137,178,14,5,39,2,176,222,138,182,3,0,161,217,221,136,169,15,5,1,161,214,231,164,137,11,1,9,19,217,192,185,135,3,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,217,192,185,135,3,0,2,105,100,1,119,36,51,97,97,100,99,99,52,49,45,52,98,52,100,45,52,53,55,48,45,97,53,100,101,45,48,54,101,98,101,51,102,52,54,48,101,99,40,0,217,192,185,135,3,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,52,99,54,53,56,56,49,55,45,50,48,100,98,45,52,102,53,54,45,98,55,102,57,45,48,54,51,55,97,50,50,100,102,101,98,54,40,0,217,192,185,135,3,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,217,192,185,135,3,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,217,192,185,135,3,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,75,60,195,33,0,217,192,185,135,3,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,217,192,185,135,3,0,5,99,101,108,108,115,1,39,0,217,192,185,135,3,9,6,70,114,115,115,74,100,1,40,0,217,192,185,135,3,10,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,40,0,217,192,185,135,3,10,4,100,97,116,97,1,119,36,48,52,102,52,55,48,51,55,45,49,56,54,97,45,52,56,55,102,45,98,54,56,101,45,102,49,98,102,97,48,102,101,54,54,53,101,168,217,192,185,135,3,8,1,122,0,0,0,0,102,75,60,207,39,0,217,192,185,135,3,9,6,84,102,117,121,104,84,1,40,0,217,192,185,135,3,14,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,75,60,207,40,0,217,192,185,135,3,14,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,0,40,0,217,192,185,135,3,14,4,100,97,116,97,1,119,18,229,147,136,229,147,136,229,147,136,229,147,136,229,147,136,229,147,136,40,0,217,192,185,135,3,14,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,75,60,207,1,247,190,164,130,3,0,161,157,244,237,240,7,8,2,1,129,142,211,195,2,0,161,220,144,217,197,4,38,7,1,192,192,155,154,2,0,161,176,222,138,182,3,9,2,1,187,231,222,18,0,161,239,247,162,248,14,7,2,19,192,192,155,154,2,1,0,2,129,142,211,195,2,1,0,7,130,223,137,178,14,1,0,6,163,196,200,219,4,1,0,2,198,208,139,248,15,1,0,4,232,249,153,165,14,1,0,35,238,165,252,166,9,1,0,25,238,205,207,218,4,1,0,9,176,222,138,182,3,1,0,10,239,247,162,248,14,1,0,8,178,201,178,226,15,1,0,6,214,231,164,137,11,1,0,2,247,190,164,130,3,1,0,2,217,221,136,169,15,1,0,6,249,156,177,132,11,1,0,5,187,231,222,18,1,0,2,220,144,217,197,4,1,0,39,157,244,237,240,7,1,0,9,217,192,185,135,3,1,8,1]} \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/fixtures/database/rows/87bc006e-c1eb-47fd-9ac6-e39b17956369.json b/frontend/appflowy_web_app/cypress/fixtures/database/rows/87bc006e-c1eb-47fd-9ac6-e39b17956369.json new file mode 100644 index 0000000000000..4eefa98010608 --- /dev/null +++ b/frontend/appflowy_web_app/cypress/fixtures/database/rows/87bc006e-c1eb-47fd-9ac6-e39b17956369.json @@ -0,0 +1 @@ +{"9cde7c15-347c-447a-9ea1-76bc3a8d4e96":[2,10,179,237,201,251,15,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,179,237,201,251,15,0,2,105,100,1,119,36,57,99,100,101,55,99,49,53,45,51,52,55,99,45,52,52,55,97,45,57,101,97,49,45,55,54,98,99,51,97,56,100,52,101,57,54,40,0,179,237,201,251,15,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,56,55,98,99,48,48,54,101,45,99,49,101,98,45,52,55,102,100,45,57,97,99,54,45,101,51,57,98,49,55,57,53,54,51,54,57,40,0,179,237,201,251,15,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,179,237,201,251,15,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,179,237,201,251,15,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,63,40,0,179,237,201,251,15,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,97,115,63,39,0,179,237,201,251,15,0,5,99,101,108,108,115,1,2,181,140,221,245,11,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,5,168,181,140,221,245,11,4,1,122,0,0,0,0,102,97,116,211,1,181,140,221,245,11,1,0,5],"16da0f68-f414-4c59-95eb-3b45b4b61dc3":[2,38,162,144,245,250,8,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,162,144,245,250,8,0,2,105,100,1,119,36,49,54,100,97,48,102,54,56,45,102,52,49,52,45,52,99,53,57,45,57,53,101,98,45,51,98,52,53,98,52,98,54,49,100,99,51,40,0,162,144,245,250,8,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,56,55,98,99,48,48,54,101,45,99,49,101,98,45,52,55,102,100,45,57,97,99,54,45,101,51,57,98,49,55,57,53,54,51,54,57,40,0,162,144,245,250,8,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,162,144,245,250,8,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,162,144,245,250,8,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,234,33,0,162,144,245,250,8,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,162,144,245,250,8,0,5,99,101,108,108,115,1,39,0,162,144,245,250,8,9,6,77,67,57,90,97,69,1,40,0,162,144,245,250,8,10,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,0,40,0,162,144,245,250,8,10,4,100,97,116,97,1,119,3,49,50,51,39,0,162,144,245,250,8,9,6,108,73,72,113,101,57,1,40,0,162,144,245,250,8,13,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,5,40,0,162,144,245,250,8,13,4,100,97,116,97,1,119,3,89,101,115,39,0,162,144,245,250,8,9,6,111,121,80,121,97,117,1,40,0,162,144,245,250,8,16,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,1,40,0,162,144,245,250,8,16,4,100,97,116,97,1,119,3,54,48,49,39,0,162,144,245,250,8,9,6,53,69,90,81,65,87,1,40,0,162,144,245,250,8,19,4,100,97,116,97,1,119,4,71,102,87,50,40,0,162,144,245,250,8,19,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,161,162,144,245,250,8,8,1,39,0,162,144,245,250,8,9,6,102,116,73,53,52,121,1,40,0,162,144,245,250,8,23,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,116,46,40,0,162,144,245,250,8,23,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,4,40,0,162,144,245,250,8,23,4,100,97,116,97,1,119,4,104,57,106,100,40,0,162,144,245,250,8,23,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,97,116,46,161,162,144,245,250,8,22,1,39,0,162,144,245,250,8,9,6,84,79,87,83,70,104,1,40,0,162,144,245,250,8,29,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,116,61,33,0,162,144,245,250,8,29,4,100,97,116,97,1,33,0,162,144,245,250,8,29,10,102,105,101,108,100,95,116,121,112,101,1,33,0,162,144,245,250,8,29,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,168,162,144,245,250,8,28,1,122,0,0,0,0,102,97,116,63,168,162,144,245,250,8,31,1,119,88,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,99,55,88,104,34,44,34,110,97,109,101,34,58,34,49,49,49,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,93,44,34,115,101,108,101,99,116,101,100,95,111,112,116,105,111,110,95,105,100,115,34,58,91,34,99,55,88,104,34,93,125,168,162,144,245,250,8,32,1,122,0,0,0,0,0,0,0,7,168,162,144,245,250,8,33,1,122,0,0,0,0,102,97,116,63,2,161,140,129,164,8,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,5,168,161,140,129,164,8,4,1,122,0,0,0,0,102,97,116,213,2,161,140,129,164,8,1,0,5,162,144,245,250,8,4,8,1,22,1,28,1,31,3],"9e5efed0-6220-48be-8704-d8ec0166796c":[2,2,144,209,245,144,10,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,5,168,144,209,245,144,10,4,1,122,0,0,0,0,102,97,116,215,56,246,244,204,133,9,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,246,244,204,133,9,0,2,105,100,1,119,36,57,101,53,101,102,101,100,48,45,54,50,50,48,45,52,56,98,101,45,56,55,48,52,45,100,56,101,99,48,49,54,54,55,57,54,99,40,0,246,244,204,133,9,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,56,55,98,99,48,48,54,101,45,99,49,101,98,45,52,55,102,100,45,57,97,99,54,45,101,51,57,98,49,55,57,53,54,51,54,57,40,0,246,244,204,133,9,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,246,244,204,133,9,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,246,244,204,133,9,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,238,33,0,246,244,204,133,9,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,246,244,204,133,9,0,5,99,101,108,108,115,1,39,0,246,244,204,133,9,9,6,108,73,72,113,101,57,1,40,0,246,244,204,133,9,10,4,100,97,116,97,1,119,3,89,101,115,40,0,246,244,204,133,9,10,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,5,39,0,246,244,204,133,9,9,6,77,67,57,90,97,69,1,33,0,246,244,204,133,9,13,10,102,105,101,108,100,95,116,121,112,101,1,33,0,246,244,204,133,9,13,4,100,97,116,97,1,39,0,246,244,204,133,9,9,6,111,121,80,121,97,117,1,33,0,246,244,204,133,9,16,10,102,105,101,108,100,95,116,121,112,101,1,33,0,246,244,204,133,9,16,4,100,97,116,97,1,39,0,246,244,204,133,9,9,6,53,69,90,81,65,87,1,40,0,246,244,204,133,9,19,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,40,0,246,244,204,133,9,19,4,100,97,116,97,1,119,4,71,102,87,50,161,246,244,204,133,9,8,1,40,0,246,244,204,133,9,16,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,116,17,168,246,244,204,133,9,18,1,119,3,54,48,51,168,246,244,204,133,9,17,1,122,0,0,0,0,0,0,0,1,40,0,246,244,204,133,9,16,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,97,116,17,161,246,244,204,133,9,22,1,40,0,246,244,204,133,9,13,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,116,39,168,246,244,204,133,9,14,1,122,0,0,0,0,0,0,0,0,168,246,244,204,133,9,15,1,119,7,49,50,51,57,57,48,48,40,0,246,244,204,133,9,13,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,97,116,39,161,246,244,204,133,9,27,1,39,0,246,244,204,133,9,9,6,102,116,73,53,52,121,1,40,0,246,244,204,133,9,33,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,116,51,40,0,246,244,204,133,9,33,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,4,40,0,246,244,204,133,9,33,4,100,97,116,97,1,119,4,104,57,106,100,40,0,246,244,204,133,9,33,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,97,116,51,161,246,244,204,133,9,32,1,39,0,246,244,204,133,9,9,6,84,79,87,83,70,104,1,40,0,246,244,204,133,9,39,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,116,71,33,0,246,244,204,133,9,39,4,100,97,116,97,1,33,0,246,244,204,133,9,39,10,102,105,101,108,100,95,116,121,112,101,1,33,0,246,244,204,133,9,39,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,246,244,204,133,9,38,1,161,246,244,204,133,9,42,1,161,246,244,204,133,9,41,1,161,246,244,204,133,9,43,1,161,246,244,204,133,9,44,1,161,246,244,204,133,9,46,1,161,246,244,204,133,9,45,1,161,246,244,204,133,9,47,1,168,246,244,204,133,9,48,1,122,0,0,0,0,102,97,116,75,168,246,244,204,133,9,50,1,122,0,0,0,0,0,0,0,7,168,246,244,204,133,9,49,1,119,135,1,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,102,104,112,70,34,44,34,110,97,109,101,34,58,34,51,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,44,123,34,105,100,34,58,34,111,105,110,85,34,44,34,110,97,109,101,34,58,34,54,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,93,44,34,115,101,108,101,99,116,101,100,95,111,112,116,105,111,110,95,105,100,115,34,58,91,34,102,104,112,70,34,44,34,111,105,110,85,34,93,125,168,246,244,204,133,9,51,1,122,0,0,0,0,102,97,116,75,2,144,209,245,144,10,1,0,5,246,244,204,133,9,8,8,1,14,2,17,2,22,1,27,1,32,1,38,1,41,11],"3b5ef824-475c-4848-acff-418e259a3d53":[2,2,219,196,219,154,10,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,5,168,219,196,219,154,10,4,1,122,0,0,0,0,102,97,116,208,60,150,233,209,1,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,150,233,209,1,0,2,105,100,1,119,36,51,98,53,101,102,56,50,52,45,52,55,53,99,45,52,56,52,56,45,97,99,102,102,45,52,49,56,101,50,53,57,97,51,100,53,51,40,0,150,233,209,1,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,56,55,98,99,48,48,54,101,45,99,49,101,98,45,52,55,102,100,45,57,97,99,54,45,101,51,57,98,49,55,57,53,54,51,54,57,40,0,150,233,209,1,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,150,233,209,1,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,150,233,209,1,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,237,33,0,150,233,209,1,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,150,233,209,1,0,5,99,101,108,108,115,1,39,0,150,233,209,1,9,6,111,121,80,121,97,117,1,33,0,150,233,209,1,10,10,102,105,101,108,100,95,116,121,112,101,1,33,0,150,233,209,1,10,4,100,97,116,97,1,39,0,150,233,209,1,9,6,53,69,90,81,65,87,1,40,0,150,233,209,1,13,4,100,97,116,97,1,119,4,71,102,87,50,40,0,150,233,209,1,13,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,39,0,150,233,209,1,9,6,77,67,57,90,97,69,1,33,0,150,233,209,1,16,10,102,105,101,108,100,95,116,121,112,101,1,33,0,150,233,209,1,16,4,100,97,116,97,1,39,0,150,233,209,1,9,6,108,73,72,113,101,57,1,40,0,150,233,209,1,19,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,5,40,0,150,233,209,1,19,4,100,97,116,97,1,119,3,89,101,115,161,150,233,209,1,8,1,40,0,150,233,209,1,10,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,116,15,161,150,233,209,1,12,1,161,150,233,209,1,11,1,33,0,150,233,209,1,10,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,150,233,209,1,22,1,40,0,150,233,209,1,16,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,116,31,168,150,233,209,1,17,1,122,0,0,0,0,0,0,0,0,168,150,233,209,1,18,1,119,6,49,50,51,53,54,55,40,0,150,233,209,1,16,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,97,116,31,161,150,233,209,1,27,1,39,0,150,233,209,1,9,6,102,116,73,53,52,121,1,40,0,150,233,209,1,33,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,116,49,40,0,150,233,209,1,33,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,4,40,0,150,233,209,1,33,4,100,97,116,97,1,119,4,104,57,106,100,40,0,150,233,209,1,33,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,97,116,49,161,150,233,209,1,32,1,39,0,150,233,209,1,9,6,84,79,87,83,70,104,1,40,0,150,233,209,1,39,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,116,65,33,0,150,233,209,1,39,10,102,105,101,108,100,95,116,121,112,101,1,33,0,150,233,209,1,39,4,100,97,116,97,1,33,0,150,233,209,1,39,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,150,233,209,1,38,1,161,150,233,209,1,42,1,161,150,233,209,1,41,1,161,150,233,209,1,43,1,161,150,233,209,1,44,1,161,150,233,209,1,46,1,161,150,233,209,1,45,1,161,150,233,209,1,47,1,161,150,233,209,1,48,1,168,150,233,209,1,49,1,122,0,0,0,0,0,0,0,7,168,150,233,209,1,50,1,119,135,1,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,45,106,68,117,34,44,34,110,97,109,101,34,58,34,50,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,44,123,34,105,100,34,58,34,76,57,87,81,34,44,34,110,97,109,101,34,58,34,51,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,93,44,34,115,101,108,101,99,116,101,100,95,111,112,116,105,111,110,95,105,100,115,34,58,91,34,45,106,68,117,34,44,34,76,57,87,81,34,93,125,168,150,233,209,1,51,1,122,0,0,0,0,102,97,116,68,168,150,233,209,1,52,1,122,0,0,0,0,102,97,116,93,168,150,233,209,1,25,1,122,0,0,0,0,0,0,0,1,168,150,233,209,1,24,1,119,3,54,48,55,168,150,233,209,1,26,1,122,0,0,0,0,102,97,116,93,2,150,233,209,1,8,8,1,11,2,17,2,22,1,24,4,32,1,38,1,41,12,219,196,219,154,10,1,0,5],"24249689-cad4-4e53-8c5e-f9eaec9bf558":[2,10,242,202,217,154,13,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,242,202,217,154,13,0,2,105,100,1,119,36,50,52,50,52,57,54,56,57,45,99,97,100,52,45,52,101,53,51,45,56,99,53,101,45,102,57,101,97,101,99,57,98,102,53,53,56,40,0,242,202,217,154,13,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,56,55,98,99,48,48,54,101,45,99,49,101,98,45,52,55,102,100,45,57,97,99,54,45,101,51,57,98,49,55,57,53,54,51,54,57,40,0,242,202,217,154,13,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,242,202,217,154,13,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,242,202,217,154,13,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,116,208,40,0,242,202,217,154,13,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,97,116,208,39,0,242,202,217,154,13,0,5,99,101,108,108,115,1,2,243,240,149,193,10,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,5,168,243,240,149,193,10,4,1,122,0,0,0,0,102,97,116,218,1,243,240,149,193,10,1,0,5],"1111b146-4c6c-4fc6-95e1-70c246147f8f":[2,10,165,220,194,235,14,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,165,220,194,235,14,0,2,105,100,1,119,36,49,49,49,49,98,49,52,54,45,52,99,54,99,45,52,102,99,54,45,57,53,101,49,45,55,48,99,50,52,54,49,52,55,102,56,102,40,0,165,220,194,235,14,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,56,55,98,99,48,48,54,101,45,99,49,101,98,45,52,55,102,100,45,57,97,99,54,45,101,51,57,98,49,55,57,53,54,51,54,57,40,0,165,220,194,235,14,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,165,220,194,235,14,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,165,220,194,235,14,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,63,40,0,165,220,194,235,14,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,97,115,63,39,0,165,220,194,235,14,0,5,99,101,108,108,115,1,2,250,172,218,249,7,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,5,168,250,172,218,249,7,4,1,122,0,0,0,0,102,97,116,211,1,250,172,218,249,7,1,0,5],"3ec7b76c-68c9-4279-9b33-2365321eaf41":[2,10,243,212,210,152,3,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,243,212,210,152,3,0,2,105,100,1,119,36,51,101,99,55,98,55,54,99,45,54,56,99,57,45,52,50,55,57,45,57,98,51,51,45,50,51,54,53,51,50,49,101,97,102,52,49,40,0,243,212,210,152,3,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,56,55,98,99,48,48,54,101,45,99,49,101,98,45,52,55,102,100,45,57,97,99,54,45,101,51,57,98,49,55,57,53,54,51,54,57,40,0,243,212,210,152,3,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,243,212,210,152,3,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,243,212,210,152,3,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,97,115,63,40,0,243,212,210,152,3,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,97,115,63,39,0,243,212,210,152,3,0,5,99,101,108,108,115,1,2,160,128,198,202,2,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,5,168,160,128,198,202,2,4,1,122,0,0,0,0,102,97,116,210,1,160,128,198,202,2,1,0,5]} \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/fixtures/database/rows/ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d.json b/frontend/appflowy_web_app/cypress/fixtures/database/rows/ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d.json new file mode 100644 index 0000000000000..10fc50a811dcb --- /dev/null +++ b/frontend/appflowy_web_app/cypress/fixtures/database/rows/ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d.json @@ -0,0 +1 @@ +{"208d248f-5c08-4be5-a022-e0a97c2d705e":[16,1,162,212,253,234,14,0,161,166,231,212,218,8,3,39,1,245,198,128,205,14,0,161,233,140,128,164,8,5,2,1,165,222,139,132,12,0,161,128,181,233,166,8,1,7,1,179,227,145,238,11,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,10,2,213,228,161,169,9,0,161,233,140,128,164,8,5,1,161,245,198,128,205,14,1,9,2,185,222,141,169,9,0,161,140,225,231,182,6,2,4,168,185,222,141,169,9,3,1,122,0,0,0,0,102,88,52,85,1,138,182,251,229,8,0,161,162,212,253,234,14,38,7,1,166,231,212,218,8,0,161,165,222,139,132,12,6,4,1,128,181,233,166,8,0,161,179,227,145,238,11,9,2,1,233,140,128,164,8,0,161,221,230,177,144,4,1,6,1,239,245,240,149,8,0,161,157,238,145,201,3,1,2,1,140,225,231,182,6,0,161,239,245,240,149,8,1,3,1,246,148,237,174,6,0,161,138,182,251,229,8,6,5,16,221,174,135,220,5,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,221,174,135,220,5,0,2,105,100,1,119,36,50,48,56,100,50,52,56,102,45,53,99,48,56,45,52,98,101,53,45,97,48,50,50,45,101,48,97,57,55,99,50,100,55,48,53,101,40,0,221,174,135,220,5,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,97,100,55,100,99,52,53,98,45,52,52,98,53,45,52,57,56,102,45,98,102,97,50,45,48,102,52,51,98,102,48,53,99,99,48,100,40,0,221,174,135,220,5,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,221,174,135,220,5,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,221,174,135,220,5,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,39,162,40,0,221,174,135,220,5,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,76,39,162,39,0,221,174,135,220,5,0,5,99,101,108,108,115,1,39,0,221,174,135,220,5,9,6,121,52,52,50,48,119,1,40,0,221,174,135,220,5,10,4,100,97,116,97,1,119,4,117,76,117,51,40,0,221,174,135,220,5,10,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,39,0,221,174,135,220,5,9,6,51,111,45,90,115,109,1,40,0,221,174,135,220,5,13,4,100,97,116,97,1,119,6,67,97,114,100,32,49,40,0,221,174,135,220,5,13,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,0,1,221,230,177,144,4,0,161,246,148,237,174,6,4,2,1,157,238,145,201,3,0,161,213,228,161,169,9,9,2,15,128,181,233,166,8,1,0,2,162,212,253,234,14,1,0,39,165,222,139,132,12,1,0,7,166,231,212,218,8,1,0,4,233,140,128,164,8,1,0,6,138,182,251,229,8,1,0,7,140,225,231,182,6,1,0,3,239,245,240,149,8,1,0,2,179,227,145,238,11,1,0,10,245,198,128,205,14,1,0,2,246,148,237,174,6,1,0,5,213,228,161,169,9,1,0,10,185,222,141,169,9,1,0,4,221,230,177,144,4,1,0,2,157,238,145,201,3,1,0,2]} \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/fixtures/database/rows/ce267d12-3b61-4ebb-bb03-d65272f5f817.json b/frontend/appflowy_web_app/cypress/fixtures/database/rows/ce267d12-3b61-4ebb-bb03-d65272f5f817.json new file mode 100644 index 0000000000000..9820d03b24826 --- /dev/null +++ b/frontend/appflowy_web_app/cypress/fixtures/database/rows/ce267d12-3b61-4ebb-bb03-d65272f5f817.json @@ -0,0 +1 @@ +{"a00ecf78-a823-43f1-b542-ed071394a717":[14,1,235,137,137,244,15,0,161,252,139,206,213,10,1,2,1,133,172,162,242,15,0,161,137,192,210,179,15,1,2,2,186,176,205,207,15,0,161,196,230,218,150,10,3,2,168,186,176,205,207,15,1,1,122,0,0,0,0,102,88,31,89,1,137,192,210,179,15,0,161,235,137,137,244,15,1,2,1,245,193,213,231,13,0,161,153,225,207,224,1,1,2,1,246,177,194,222,13,0,161,133,172,162,242,15,1,6,1,205,151,244,151,13,0,161,246,177,194,222,13,5,12,23,239,227,232,242,10,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,239,227,232,242,10,0,2,105,100,1,119,36,97,48,48,101,99,102,55,56,45,97,56,50,51,45,52,51,102,49,45,98,53,52,50,45,101,100,48,55,49,51,57,52,97,55,49,55,40,0,239,227,232,242,10,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,99,101,50,54,55,100,49,50,45,51,98,54,49,45,52,101,98,98,45,98,98,48,51,45,100,54,53,50,55,50,102,53,102,56,49,55,40,0,239,227,232,242,10,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,239,227,232,242,10,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,239,227,232,242,10,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,121,252,33,0,239,227,232,242,10,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,239,227,232,242,10,0,5,99,101,108,108,115,1,39,0,239,227,232,242,10,9,6,55,85,107,117,54,82,1,40,0,239,227,232,242,10,10,4,100,97,116,97,1,119,10,49,55,49,54,51,48,55,50,48,48,40,0,239,227,232,242,10,10,13,101,110,100,95,116,105,109,101,115,116,97,109,112,1,119,0,40,0,239,227,232,242,10,10,12,105,110,99,108,117,100,101,95,116,105,109,101,1,121,40,0,239,227,232,242,10,10,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,2,40,0,239,227,232,242,10,10,8,105,115,95,114,97,110,103,101,1,121,40,0,239,227,232,242,10,10,11,114,101,109,105,110,100,101,114,95,105,100,1,119,0,168,239,227,232,242,10,8,1,122,0,0,0,0,102,77,124,84,39,0,239,227,232,242,10,9,6,72,95,74,113,85,76,1,40,0,239,227,232,242,10,18,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,124,84,40,0,239,227,232,242,10,18,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,0,40,0,239,227,232,242,10,18,4,100,97,116,97,1,119,5,49,49,49,49,49,40,0,239,227,232,242,10,18,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,124,84,1,252,139,206,213,10,0,161,155,206,144,191,3,37,2,1,222,138,222,196,10,0,161,246,177,194,222,13,5,2,1,196,230,218,150,10,0,161,245,193,213,231,13,1,4,1,239,171,159,202,8,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,15,1,155,206,144,191,3,0,161,239,171,159,202,8,14,38,1,153,225,207,224,1,0,161,205,151,244,151,13,11,2,14,235,137,137,244,15,1,0,2,205,151,244,151,13,1,0,12,239,171,159,202,8,1,0,15,196,230,218,150,10,1,0,4,245,193,213,231,13,1,0,2,246,177,194,222,13,1,0,6,133,172,162,242,15,1,0,2,137,192,210,179,15,1,0,2,186,176,205,207,15,1,0,2,153,225,207,224,1,1,0,2,155,206,144,191,3,1,0,38,252,139,206,213,10,1,0,2,222,138,222,196,10,1,0,2,239,227,232,242,10,1,8,1],"a73674ae-3301-45a3-b801-3f12e6fcb566":[16,1,216,136,201,234,15,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,14,8,151,154,187,181,15,0,161,167,192,205,202,8,78,1,161,167,192,205,202,8,54,1,161,167,192,205,202,8,53,1,161,167,192,205,202,8,55,1,168,151,154,187,181,15,0,1,122,0,0,0,0,102,80,8,134,168,151,154,187,181,15,2,1,119,2,104,105,168,151,154,187,181,15,1,1,122,0,0,0,0,0,0,0,0,168,151,154,187,181,15,3,1,122,0,0,0,0,102,80,8,134,1,225,161,205,165,15,0,161,236,244,246,235,2,1,4,1,232,241,163,254,12,0,161,216,136,201,234,15,13,28,1,243,242,150,150,12,0,161,198,181,227,192,1,1,2,1,170,212,149,201,11,0,161,232,241,163,254,12,27,39,1,203,244,164,187,11,0,161,189,165,195,186,4,11,6,2,225,242,132,222,9,0,161,225,161,205,165,15,3,2,168,225,242,132,222,9,1,1,122,0,0,0,0,102,88,31,91,82,167,192,205,202,8,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,167,192,205,202,8,0,2,105,100,1,119,36,97,55,51,54,55,52,97,101,45,51,51,48,49,45,52,53,97,51,45,98,56,48,49,45,51,102,49,50,101,54,102,99,98,53,54,54,40,0,167,192,205,202,8,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,99,101,50,54,55,100,49,50,45,51,98,54,49,45,52,101,98,98,45,98,98,48,51,45,100,54,53,50,55,50,102,53,102,56,49,55,40,0,167,192,205,202,8,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,167,192,205,202,8,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,167,192,205,202,8,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,88,135,33,0,167,192,205,202,8,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,167,192,205,202,8,0,5,99,101,108,108,115,1,39,0,167,192,205,202,8,9,6,55,85,107,117,54,82,1,33,0,167,192,205,202,8,10,10,102,105,101,108,100,95,116,121,112,101,1,33,0,167,192,205,202,8,10,12,105,110,99,108,117,100,101,95,116,105,109,101,1,33,0,167,192,205,202,8,10,8,105,115,95,114,97,110,103,101,1,33,0,167,192,205,202,8,10,11,114,101,109,105,110,100,101,114,95,105,100,1,33,0,167,192,205,202,8,10,13,101,110,100,95,116,105,109,101,115,116,97,109,112,1,33,0,167,192,205,202,8,10,4,100,97,116,97,1,161,167,192,205,202,8,8,1,39,0,167,192,205,202,8,9,6,95,82,45,112,104,105,1,40,0,167,192,205,202,8,18,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,88,139,40,0,167,192,205,202,8,18,4,100,97,116,97,1,119,4,73,73,66,100,40,0,167,192,205,202,8,18,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,4,40,0,167,192,205,202,8,18,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,88,139,161,167,192,205,202,8,17,1,40,0,167,192,205,202,8,10,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,88,142,168,167,192,205,202,8,13,1,121,168,167,192,205,202,8,11,1,122,0,0,0,0,0,0,0,2,168,167,192,205,202,8,15,1,119,0,168,167,192,205,202,8,14,1,119,0,168,167,192,205,202,8,16,1,119,10,49,55,49,54,54,48,52,49,55,52,168,167,192,205,202,8,12,1,121,40,0,167,192,205,202,8,10,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,88,142,161,167,192,205,202,8,23,1,39,0,167,192,205,202,8,9,6,71,115,66,65,97,76,1,40,0,167,192,205,202,8,33,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,88,150,33,0,167,192,205,202,8,33,8,105,115,95,114,97,110,103,101,1,33,0,167,192,205,202,8,33,11,114,101,109,105,110,100,101,114,95,105,100,1,33,0,167,192,205,202,8,33,10,102,105,101,108,100,95,116,121,112,101,1,33,0,167,192,205,202,8,33,4,100,97,116,97,1,33,0,167,192,205,202,8,33,13,101,110,100,95,116,105,109,101,115,116,97,109,112,1,33,0,167,192,205,202,8,33,12,105,110,99,108,117,100,101,95,116,105,109,101,1,33,0,167,192,205,202,8,33,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,167,192,205,202,8,32,1,168,167,192,205,202,8,39,1,119,0,168,167,192,205,202,8,38,1,119,10,49,55,49,55,49,50,50,53,56,51,168,167,192,205,202,8,37,1,122,0,0,0,0,0,0,0,2,168,167,192,205,202,8,40,1,121,168,167,192,205,202,8,35,1,121,168,167,192,205,202,8,36,1,119,0,168,167,192,205,202,8,41,1,122,0,0,0,0,102,77,88,151,161,167,192,205,202,8,42,1,39,0,167,192,205,202,8,9,6,72,95,74,113,85,76,1,40,0,167,192,205,202,8,51,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,75,33,0,167,192,205,202,8,51,4,100,97,116,97,1,33,0,167,192,205,202,8,51,10,102,105,101,108,100,95,116,121,112,101,1,33,0,167,192,205,202,8,51,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,167,192,205,202,8,50,1,39,0,167,192,205,202,8,9,6,99,78,53,98,120,74,1,40,0,167,192,205,202,8,57,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,89,40,0,167,192,205,202,8,57,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,6,40,0,167,192,205,202,8,57,4,100,97,116,97,1,119,3,49,50,51,40,0,167,192,205,202,8,57,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,165,89,161,167,192,205,202,8,56,1,39,0,167,192,205,202,8,9,6,71,79,80,107,116,118,1,40,0,167,192,205,202,8,63,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,117,40,0,167,192,205,202,8,63,4,100,97,116,97,1,119,4,89,101,75,100,40,0,167,192,205,202,8,63,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,40,0,167,192,205,202,8,63,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,165,117,161,167,192,205,202,8,62,1,39,0,167,192,205,202,8,9,6,112,70,120,57,67,45,1,40,0,167,192,205,202,8,69,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,142,33,0,167,192,205,202,8,69,10,102,105,101,108,100,95,116,121,112,101,1,33,0,167,192,205,202,8,69,4,100,97,116,97,1,33,0,167,192,205,202,8,69,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,167,192,205,202,8,68,1,161,167,192,205,202,8,71,1,161,167,192,205,202,8,72,1,161,167,192,205,202,8,73,1,161,167,192,205,202,8,74,1,168,167,192,205,202,8,75,1,122,0,0,0,0,0,0,0,7,168,167,192,205,202,8,76,1,119,134,1,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,99,72,80,113,34,44,34,110,97,109,101,34,58,34,51,51,51,51,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,44,123,34,105,100,34,58,34,74,106,52,74,34,44,34,110,97,109,101,34,58,34,51,51,51,51,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,93,44,34,115,101,108,101,99,116,101,100,95,111,112,116,105,111,110,95,105,100,115,34,58,91,34,99,72,80,113,34,93,125,168,167,192,205,202,8,77,1,122,0,0,0,0,102,77,165,145,1,170,185,193,131,8,0,161,210,149,158,230,4,5,2,1,194,218,151,236,5,0,161,170,212,149,201,11,38,2,1,210,149,158,230,4,0,161,143,148,251,251,1,1,6,2,189,165,195,186,4,0,161,210,149,158,230,4,5,1,161,170,185,193,131,8,1,11,1,236,244,246,235,2,0,161,203,244,164,187,11,5,2,1,143,148,251,251,1,0,161,243,242,150,150,12,1,2,1,198,181,227,192,1,0,161,194,218,151,236,5,1,2,16,225,161,205,165,15,1,0,4,194,218,151,236,5,1,0,2,225,242,132,222,9,1,0,2,198,181,227,192,1,1,0,2,167,192,205,202,8,10,8,1,11,7,23,1,32,1,35,8,50,1,53,4,62,1,68,1,71,8,232,241,163,254,12,1,0,28,170,212,149,201,11,1,0,39,170,185,193,131,8,1,0,2,236,244,246,235,2,1,0,2,203,244,164,187,11,1,0,6,143,148,251,251,1,1,0,2,210,149,158,230,4,1,0,6,243,242,150,150,12,1,0,2,151,154,187,181,15,1,0,4,216,136,201,234,15,1,0,14,189,165,195,186,4,1,0,12],"51cf0906-ad46-4dae-a3b9-2e003f8368c1":[15,1,196,176,146,143,13,0,161,211,131,137,205,5,5,12,1,175,214,229,215,11,0,161,164,251,162,159,11,1,4,1,167,131,238,183,11,0,161,218,233,217,251,6,1,2,1,164,251,162,159,11,0,161,135,135,213,129,7,1,2,1,252,183,246,136,10,0,161,211,131,137,205,5,5,2,1,184,218,170,237,8,0,161,226,213,154,133,6,7,16,1,253,213,204,144,8,0,161,254,207,234,185,3,1,2,1,135,135,213,129,7,0,161,196,176,146,143,13,11,2,1,218,233,217,251,6,0,161,213,133,230,230,2,38,2,1,226,213,154,133,6,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,8,1,211,131,137,205,5,0,161,253,213,204,144,8,1,6,2,205,251,166,251,4,0,161,175,214,229,215,11,3,2,168,205,251,166,251,4,1,1,122,0,0,0,0,102,88,31,89,30,239,237,241,245,4,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,239,237,241,245,4,0,2,105,100,1,119,36,53,49,99,102,48,57,48,54,45,97,100,52,54,45,52,100,97,101,45,97,51,98,57,45,50,101,48,48,51,102,56,51,54,56,99,49,40,0,239,237,241,245,4,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,99,101,50,54,55,100,49,50,45,51,98,54,49,45,52,101,98,98,45,98,98,48,51,45,100,54,53,50,55,50,102,53,102,56,49,55,40,0,239,237,241,245,4,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,239,237,241,245,4,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,239,237,241,245,4,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,95,173,33,0,239,237,241,245,4,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,239,237,241,245,4,0,5,99,101,108,108,115,1,39,0,239,237,241,245,4,9,6,55,85,107,117,54,82,1,40,0,239,237,241,245,4,10,12,105,110,99,108,117,100,101,95,116,105,109,101,1,121,40,0,239,237,241,245,4,10,11,114,101,109,105,110,100,101,114,95,105,100,1,119,0,40,0,239,237,241,245,4,10,4,100,97,116,97,1,119,10,49,55,49,54,51,48,55,50,48,48,40,0,239,237,241,245,4,10,8,105,115,95,114,97,110,103,101,1,121,40,0,239,237,241,245,4,10,13,101,110,100,95,116,105,109,101,115,116,97,109,112,1,119,0,40,0,239,237,241,245,4,10,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,2,161,239,237,241,245,4,8,1,39,0,239,237,241,245,4,9,6,72,95,74,113,85,76,1,40,0,239,237,241,245,4,18,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,79,40,0,239,237,241,245,4,18,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,0,40,0,239,237,241,245,4,18,4,100,97,116,97,1,119,2,48,48,40,0,239,237,241,245,4,18,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,165,79,168,239,237,241,245,4,17,1,122,0,0,0,0,102,77,165,181,39,0,239,237,241,245,4,9,6,75,71,50,113,74,65,1,40,0,239,237,241,245,4,24,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,181,40,0,239,237,241,245,4,24,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,10,39,0,239,237,241,245,4,24,4,100,97,116,97,0,8,0,239,237,241,245,4,27,1,119,36,100,51,50,101,52,56,97,52,45,99,102,48,100,45,52,56,97,56,45,57,53,57,57,45,53,51,51,57,97,56,49,53,56,99,53,48,40,0,239,237,241,245,4,24,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,165,181,1,254,207,234,185,3,0,161,167,131,238,183,11,1,2,1,213,133,230,230,2,0,161,184,218,170,237,8,15,39,15,226,213,154,133,6,1,0,8,196,176,146,143,13,1,0,12,164,251,162,159,11,1,0,2,135,135,213,129,7,1,0,2,167,131,238,183,11,1,0,2,205,251,166,251,4,1,0,2,175,214,229,215,11,1,0,4,239,237,241,245,4,2,8,1,17,1,211,131,137,205,5,1,0,6,213,133,230,230,2,1,0,39,184,218,170,237,8,1,0,16,218,233,217,251,6,1,0,2,252,183,246,136,10,1,0,2,253,213,204,144,8,1,0,2,254,207,234,185,3,1,0,2],"92a2137e-b00b-4388-851f-a0efc3de7ca3":[13,1,238,246,169,231,15,0,161,163,149,186,140,1,3,2,1,148,143,229,148,15,0,161,170,241,252,142,10,38,2,1,142,159,154,239,14,0,161,199,176,167,174,6,5,12,1,237,148,148,223,13,0,161,222,150,248,170,3,2,4,1,195,215,232,135,13,0,161,199,176,167,174,6,5,2,1,170,241,252,142,10,0,161,132,169,228,37,13,39,26,227,145,252,193,9,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,227,145,252,193,9,0,2,105,100,1,119,36,57,50,97,50,49,51,55,101,45,98,48,48,98,45,52,51,56,56,45,56,53,49,102,45,97,48,101,102,99,51,100,101,55,99,97,51,40,0,227,145,252,193,9,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,99,101,50,54,55,100,49,50,45,51,98,54,49,45,52,101,98,98,45,98,98,48,51,45,100,54,53,50,55,50,102,53,102,56,49,55,40,0,227,145,252,193,9,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,227,145,252,193,9,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,227,145,252,193,9,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,187,135,33,0,227,145,252,193,9,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,227,145,252,193,9,0,5,99,101,108,108,115,1,161,227,145,252,193,9,8,1,39,0,227,145,252,193,9,9,6,72,95,74,113,85,76,1,40,0,227,145,252,193,9,11,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,187,143,33,0,227,145,252,193,9,11,10,102,105,101,108,100,95,116,121,112,101,1,33,0,227,145,252,193,9,11,4,100,97,116,97,1,33,0,227,145,252,193,9,11,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,227,145,252,193,9,10,1,168,227,145,252,193,9,14,1,119,7,57,57,57,57,57,50,50,168,227,145,252,193,9,13,1,122,0,0,0,0,0,0,0,0,168,227,145,252,193,9,15,1,122,0,0,0,0,102,77,187,221,168,227,145,252,193,9,16,1,122,0,0,0,0,102,77,187,222,39,0,227,145,252,193,9,9,6,95,82,45,112,104,105,1,40,0,227,145,252,193,9,21,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,187,222,40,0,227,145,252,193,9,21,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,4,40,0,227,145,252,193,9,21,4,100,97,116,97,1,119,4,73,73,66,100,40,0,227,145,252,193,9,21,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,187,222,1,199,176,167,174,6,0,161,238,246,169,231,15,1,6,1,166,146,188,210,5,0,161,142,159,154,239,14,11,2,1,222,150,248,170,3,0,161,166,146,188,210,5,1,3,2,149,229,205,200,1,0,161,237,148,148,223,13,3,2,168,149,229,205,200,1,1,1,122,0,0,0,0,102,88,31,89,1,163,149,186,140,1,0,161,148,143,229,148,15,1,4,1,132,169,228,37,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,14,13,238,246,169,231,15,1,0,2,195,215,232,135,13,1,0,2,163,149,186,140,1,1,0,4,132,169,228,37,1,0,14,148,143,229,148,15,1,0,2,199,176,167,174,6,1,0,6,166,146,188,210,5,1,0,2,227,145,252,193,9,3,8,1,10,1,13,4,170,241,252,142,10,1,0,39,149,229,205,200,1,1,0,2,237,148,148,223,13,1,0,4,222,150,248,170,3,1,0,3,142,159,154,239,14,1,0,12],"2150cff6-ff80-4334-8c8a-94e82a64379a":[15,35,184,224,238,246,15,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,184,224,238,246,15,0,2,105,100,1,119,36,50,49,53,48,99,102,102,54,45,102,102,56,48,45,52,51,51,52,45,56,99,56,97,45,57,52,101,56,50,97,54,52,51,55,57,97,40,0,184,224,238,246,15,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,99,101,50,54,55,100,49,50,45,51,98,54,49,45,52,101,98,98,45,98,98,48,51,45,100,54,53,50,55,50,102,53,102,56,49,55,40,0,184,224,238,246,15,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,184,224,238,246,15,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,184,224,238,246,15,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,95,172,33,0,184,224,238,246,15,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,184,224,238,246,15,0,5,99,101,108,108,115,1,39,0,184,224,238,246,15,9,6,55,85,107,117,54,82,1,40,0,184,224,238,246,15,10,4,100,97,116,97,1,119,10,49,55,49,54,51,48,55,50,48,48,40,0,184,224,238,246,15,10,11,114,101,109,105,110,100,101,114,95,105,100,1,119,0,40,0,184,224,238,246,15,10,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,2,40,0,184,224,238,246,15,10,12,105,110,99,108,117,100,101,95,116,105,109,101,1,121,40,0,184,224,238,246,15,10,8,105,115,95,114,97,110,103,101,1,121,40,0,184,224,238,246,15,10,13,101,110,100,95,116,105,109,101,115,116,97,109,112,1,119,0,161,184,224,238,246,15,8,1,39,0,184,224,238,246,15,9,6,72,95,74,113,85,76,1,40,0,184,224,238,246,15,18,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,76,40,0,184,224,238,246,15,18,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,0,40,0,184,224,238,246,15,18,4,100,97,116,97,1,119,3,104,105,49,40,0,184,224,238,246,15,18,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,165,76,161,184,224,238,246,15,17,1,39,0,184,224,238,246,15,9,6,70,99,112,109,80,101,1,40,0,184,224,238,246,15,24,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,127,40,0,184,224,238,246,15,24,4,100,97,116,97,1,119,3,89,101,115,40,0,184,224,238,246,15,24,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,5,40,0,184,224,238,246,15,24,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,165,127,168,184,224,238,246,15,23,1,122,0,0,0,0,102,77,165,148,39,0,184,224,238,246,15,9,6,112,70,120,57,67,45,1,40,0,184,224,238,246,15,30,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,148,40,0,184,224,238,246,15,30,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,7,40,0,184,224,238,246,15,30,4,100,97,116,97,1,119,82,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,119,122,107,74,34,44,34,110,97,109,101,34,58,34,53,53,53,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,93,44,34,115,101,108,101,99,116,101,100,95,111,112,116,105,111,110,95,105,100,115,34,58,91,93,125,40,0,184,224,238,246,15,30,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,165,148,2,205,147,147,241,14,0,161,239,184,147,146,4,3,2,168,205,147,147,241,14,1,1,122,0,0,0,0,102,88,31,91,1,246,235,197,205,14,0,161,185,248,189,241,10,1,2,1,194,245,173,211,11,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,8,1,185,248,189,241,10,0,161,241,200,244,209,10,1,2,1,176,221,143,219,10,0,161,194,245,173,211,11,7,21,1,241,200,244,209,10,0,161,166,226,191,247,8,1,2,1,166,226,191,247,8,0,161,240,207,252,192,1,38,2,1,186,199,157,206,8,0,161,203,254,130,163,1,1,2,1,185,239,230,135,7,0,161,189,129,252,252,2,5,2,2,174,181,215,227,6,0,161,189,129,252,252,2,5,1,161,185,239,230,135,7,1,11,1,239,184,147,146,4,0,161,186,199,157,206,8,1,4,1,189,129,252,252,2,0,161,246,235,197,205,14,1,6,1,240,207,252,192,1,0,161,176,221,143,219,10,20,39,1,203,254,130,163,1,0,161,174,181,215,227,6,11,2,15,194,245,173,211,11,1,0,8,166,226,191,247,8,1,0,2,203,254,130,163,1,1,0,2,205,147,147,241,14,1,0,2,174,181,215,227,6,1,0,12,239,184,147,146,4,1,0,4,176,221,143,219,10,1,0,21,240,207,252,192,1,1,0,39,241,200,244,209,10,1,0,2,246,235,197,205,14,1,0,2,184,224,238,246,15,3,8,1,17,1,23,1,185,248,189,241,10,1,0,2,185,239,230,135,7,1,0,2,186,199,157,206,8,1,0,2,189,129,252,252,2,1,0,6],"7717079b-05b6-4a0a-8ee4-48739fbf3a52":[18,1,238,246,246,209,14,0,161,222,139,223,157,3,30,6,1,242,233,195,179,14,0,161,147,233,229,181,2,9,2,1,176,198,177,177,14,0,161,242,233,195,179,14,1,2,1,171,249,223,240,13,0,161,176,198,177,177,14,1,2,1,189,169,216,163,13,0,161,229,212,189,183,1,3,2,1,241,188,132,177,11,0,161,171,249,223,240,13,1,5,2,176,157,175,239,9,0,161,241,188,132,177,11,4,2,168,176,157,175,239,9,1,1,122,0,0,0,0,102,88,31,91,1,244,197,233,193,9,0,161,189,169,216,163,13,1,6,1,231,233,173,168,9,0,161,206,211,220,252,6,33,40,1,206,211,220,252,6,0,161,243,207,130,177,3,8,34,54,237,203,168,145,4,0,161,145,187,128,129,2,39,1,40,0,145,187,128,129,2,10,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,89,162,161,145,187,128,129,2,13,1,161,145,187,128,129,2,11,1,161,145,187,128,129,2,15,1,161,145,187,128,129,2,12,1,161,145,187,128,129,2,14,1,161,145,187,128,129,2,16,1,33,0,145,187,128,129,2,10,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,237,203,168,145,4,0,1,168,237,203,168,145,4,6,1,119,10,49,55,49,54,52,51,49,54,53,49,168,237,203,168,145,4,4,1,121,168,237,203,168,145,4,7,1,120,168,237,203,168,145,4,2,1,119,0,168,237,203,168,145,4,3,1,122,0,0,0,0,0,0,0,2,168,237,203,168,145,4,5,1,119,10,49,55,49,54,51,52,53,50,53,49,168,237,203,168,145,4,8,1,122,0,0,0,0,102,77,89,163,161,237,203,168,145,4,9,1,168,145,187,128,129,2,27,1,122,0,0,0,0,0,0,0,6,168,145,187,128,129,2,26,1,119,11,97,112,112,102,108,111,119,121,46,105,111,168,145,187,128,129,2,28,1,122,0,0,0,0,102,77,165,88,161,237,203,168,145,4,17,1,168,145,187,128,129,2,20,1,119,9,73,73,66,100,44,110,103,110,85,168,145,187,128,129,2,21,1,122,0,0,0,0,0,0,0,4,168,145,187,128,129,2,22,1,122,0,0,0,0,102,77,165,108,161,237,203,168,145,4,21,1,39,0,145,187,128,129,2,9,6,71,79,80,107,116,118,1,40,0,237,203,168,145,4,26,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,114,40,0,237,203,168,145,4,26,4,100,97,116,97,1,119,4,104,77,109,67,40,0,237,203,168,145,4,26,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,3,40,0,237,203,168,145,4,26,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,165,114,161,237,203,168,145,4,25,1,39,0,145,187,128,129,2,9,6,70,99,112,109,80,101,1,40,0,237,203,168,145,4,32,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,126,40,0,237,203,168,145,4,32,4,100,97,116,97,1,119,3,89,101,115,40,0,237,203,168,145,4,32,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,5,40,0,237,203,168,145,4,32,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,165,126,161,237,203,168,145,4,31,1,39,0,145,187,128,129,2,9,6,112,70,120,57,67,45,1,40,0,237,203,168,145,4,38,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,137,33,0,237,203,168,145,4,38,10,102,105,101,108,100,95,116,121,112,101,1,33,0,237,203,168,145,4,38,4,100,97,116,97,1,33,0,237,203,168,145,4,38,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,237,203,168,145,4,37,1,168,237,203,168,145,4,40,1,122,0,0,0,0,0,0,0,7,168,237,203,168,145,4,41,1,119,88,123,34,111,112,116,105,111,110,115,34,58,91,123,34,105,100,34,58,34,106,97,56,104,34,44,34,110,97,109,101,34,58,34,49,50,51,34,44,34,99,111,108,111,114,34,58,34,80,117,114,112,108,101,34,125,93,44,34,115,101,108,101,99,116,101,100,95,111,112,116,105,111,110,95,105,100,115,34,58,91,34,106,97,56,104,34,93,125,168,237,203,168,145,4,42,1,122,0,0,0,0,102,77,165,139,168,237,203,168,145,4,43,1,122,0,0,0,0,102,77,165,176,39,0,145,187,128,129,2,9,6,75,71,50,113,74,65,1,40,0,237,203,168,145,4,48,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,77,165,176,40,0,237,203,168,145,4,48,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,10,39,0,237,203,168,145,4,48,4,100,97,116,97,0,8,0,237,203,168,145,4,51,1,119,36,50,48,56,100,50,52,56,102,45,53,99,48,56,45,52,98,101,53,45,97,48,50,50,45,101,48,97,57,55,99,50,100,55,48,53,101,40,0,237,203,168,145,4,48,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,77,165,176,1,235,139,213,209,3,0,161,231,233,173,168,9,39,2,1,243,207,130,177,3,0,161,238,246,246,209,14,5,9,1,222,139,223,157,3,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,31,1,147,233,229,181,2,0,161,244,197,233,193,9,5,10,45,145,187,128,129,2,0,39,1,4,100,97,116,97,4,100,97,116,97,1,39,1,4,100,97,116,97,4,109,101,116,97,1,39,1,4,100,97,116,97,7,99,111,109,109,101,110,116,0,40,0,145,187,128,129,2,0,2,105,100,1,119,36,55,55,49,55,48,55,57,98,45,48,53,98,54,45,52,97,48,97,45,56,101,101,52,45,52,56,55,51,57,102,98,102,51,97,53,50,40,0,145,187,128,129,2,0,11,100,97,116,97,98,97,115,101,95,105,100,1,119,36,99,101,50,54,55,100,49,50,45,51,98,54,49,45,52,101,98,98,45,98,98,48,51,45,100,54,53,50,55,50,102,53,102,56,49,55,40,0,145,187,128,129,2,0,6,104,101,105,103,104,116,1,122,0,0,0,0,0,0,0,60,40,0,145,187,128,129,2,0,10,118,105,115,105,98,105,108,105,116,121,1,120,40,0,145,187,128,129,2,0,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,101,247,33,0,145,187,128,129,2,0,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,39,0,145,187,128,129,2,0,5,99,101,108,108,115,1,39,0,145,187,128,129,2,9,6,55,85,107,117,54,82,1,33,0,145,187,128,129,2,10,10,102,105,101,108,100,95,116,121,112,101,1,33,0,145,187,128,129,2,10,4,100,97,116,97,1,33,0,145,187,128,129,2,10,11,114,101,109,105,110,100,101,114,95,105,100,1,33,0,145,187,128,129,2,10,13,101,110,100,95,116,105,109,101,115,116,97,109,112,1,33,0,145,187,128,129,2,10,12,105,110,99,108,117,100,101,95,116,105,109,101,1,33,0,145,187,128,129,2,10,8,105,115,95,114,97,110,103,101,1,161,145,187,128,129,2,8,1,39,0,145,187,128,129,2,9,6,95,82,45,112,104,105,1,40,0,145,187,128,129,2,18,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,101,255,33,0,145,187,128,129,2,18,4,100,97,116,97,1,33,0,145,187,128,129,2,18,10,102,105,101,108,100,95,116,121,112,101,1,33,0,145,187,128,129,2,18,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,145,187,128,129,2,17,1,39,0,145,187,128,129,2,9,6,99,78,53,98,120,74,1,40,0,145,187,128,129,2,24,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,102,14,33,0,145,187,128,129,2,24,4,100,97,116,97,1,33,0,145,187,128,129,2,24,10,102,105,101,108,100,95,116,121,112,101,1,33,0,145,187,128,129,2,24,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,161,145,187,128,129,2,23,1,39,0,145,187,128,129,2,9,6,71,115,66,65,97,76,1,40,0,145,187,128,129,2,30,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,102,25,40,0,145,187,128,129,2,30,8,105,115,95,114,97,110,103,101,1,121,40,0,145,187,128,129,2,30,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,2,40,0,145,187,128,129,2,30,12,105,110,99,108,117,100,101,95,116,105,109,101,1,121,40,0,145,187,128,129,2,30,4,100,97,116,97,1,119,10,49,55,49,54,52,53,53,55,48,53,40,0,145,187,128,129,2,30,11,114,101,109,105,110,100,101,114,95,105,100,1,119,0,40,0,145,187,128,129,2,30,13,101,110,100,95,116,105,109,101,115,116,97,109,112,1,119,0,40,0,145,187,128,129,2,30,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,76,102,25,161,145,187,128,129,2,29,1,39,0,145,187,128,129,2,9,6,72,95,74,113,85,76,1,40,0,145,187,128,129,2,40,10,99,114,101,97,116,101,100,95,97,116,1,122,0,0,0,0,102,76,102,32,40,0,145,187,128,129,2,40,10,102,105,101,108,100,95,116,121,112,101,1,122,0,0,0,0,0,0,0,0,40,0,145,187,128,129,2,40,4,100,97,116,97,1,119,5,119,111,114,108,100,40,0,145,187,128,129,2,40,13,108,97,115,116,95,109,111,100,105,102,105,101,100,1,122,0,0,0,0,102,76,102,32,1,229,212,189,183,1,0,161,235,139,213,209,3,1,4,1,222,172,192,75,0,161,244,197,233,193,9,5,2,18,229,212,189,183,1,1,0,4,231,233,173,168,9,1,0,40,235,139,213,209,3,1,0,2,171,249,223,240,13,1,0,2,237,203,168,145,4,8,0,1,2,8,17,1,21,1,25,1,31,1,37,1,40,4,238,246,246,209,14,1,0,6,206,211,220,252,6,1,0,34,176,198,177,177,14,1,0,2,241,188,132,177,11,1,0,5,242,233,195,179,14,1,0,2,243,207,130,177,3,1,0,9,244,197,233,193,9,1,0,6,147,233,229,181,2,1,0,10,145,187,128,129,2,5,8,1,11,7,20,4,26,4,39,1,176,157,175,239,9,1,0,2,189,169,216,163,13,1,0,2,222,139,223,157,3,1,0,31,222,172,192,75,1,0,2]} \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/fixtures/editor/blocks/paragraph.json b/frontend/appflowy_web_app/cypress/fixtures/editor/blocks/paragraph.json new file mode 100644 index 0000000000000..044d21a34248e --- /dev/null +++ b/frontend/appflowy_web_app/cypress/fixtures/editor/blocks/paragraph.json @@ -0,0 +1,104 @@ +[ + { + "type": "paragraph", + "data": {}, + "children": [], + "text": [ + { + "insert": "This is a paragraph block with multiple lines.", + "attributes": { + "bold": true + } + }, + { + "insert": "It has multiple lines of text.", + "attributes": { + "italic": true, + "underline": true, + "strikethrough": true, + "font_color": "#ff0000", + "bg_color": "#00ff00" + } + } + ] + }, + { + "type": "paragraph", + "data": {}, + "children": [], + "text": [ + { + "insert": "inline code", + "attributes": { + "code": true + } + }, + { + "insert": "link", + "attributes": { + "href": "https://example.com" + } + }, + { + "insert": "diff font", + "attributes": { + "font_family": "monospace" + } + } + ] + }, + { + "type": "paragraph", + "data": {}, + "text": [ + { + "insert": "This is a nested block." + } + ], + "children": [ + { + "type": "paragraph", + "data": {}, + "text": [ + { + "insert": "This is a nested block." + } + ], + "children": [ + { + "type": "paragraph", + "data": {}, + "text": [ + { + "insert": "This is a nested block." + } + ], + "children": [ + { + "type": "paragraph", + "data": {}, + "text": [ + { + "insert": "This is a nested block." + } + ], + "children": [ + { + "type": "paragraph", + "data": {}, + "text": [ + { + "insert": "This is a nested block." + } + ], + "children": [] + } + ] + } + ] + } + ] + } + ] + } +] \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/fixtures/full_doc.json b/frontend/appflowy_web_app/cypress/fixtures/full_doc.json new file mode 100644 index 0000000000000..c4eabdadc4afa --- /dev/null +++ b/frontend/appflowy_web_app/cypress/fixtures/full_doc.json @@ -0,0 +1 @@ +{"data":{"state_vector":[74,131,182,180,202,12,53,132,236,218,251,9,14,131,159,159,151,1,72,131,128,202,229,9,1,135,182,134,178,8,51,136,172,186,168,4,182,6,136,199,176,231,9,40,133,181,204,218,3,50,140,167,201,161,14,10,141,151,160,163,4,24,142,211,188,164,13,15,141,178,210,127,3,145,224,235,133,7,3,146,209,153,247,13,186,1,146,216,250,133,2,180,1,146,175,139,236,2,199,1,150,152,188,203,6,20,151,234,142,238,11,27,150,216,171,142,3,188,8,153,236,182,220,1,4,151,254,242,152,9,145,1,155,213,159,176,1,10,161,234,157,145,5,7,164,202,219,213,10,122,165,131,171,211,15,20,168,215,223,235,2,56,171,236,222,251,5,252,4,172,254,181,239,1,15,174,203,157,214,7,6,176,238,158,139,14,175,2,177,239,218,225,4,3,178,187,245,161,14,11,180,189,170,253,8,12,181,150,190,222,14,95,181,156,253,158,6,5,183,182,135,14,227,2,184,146,243,216,14,7,185,164,169,62,90,183,213,134,255,8,28,190,183,139,210,2,110,192,246,139,213,2,35,192,187,174,206,8,223,5,194,228,144,71,76,195,254,251,180,11,58,197,205,192,233,12,9,198,223,206,159,1,145,2,198,234,131,228,11,50,199,130,209,189,2,141,8,204,195,206,156,1,153,9,206,214,243,86,178,1,207,210,187,205,12,8,208,203,223,226,9,81,207,231,154,196,9,3,217,168,198,159,4,7,218,255,204,32,21,219,200,174,197,9,25,220,225,223,240,3,60,223,215,172,155,15,5,224,159,166,178,15,30,226,167,254,250,5,13,227,211,144,195,8,12,228,242,134,215,15,12,229,154,194,35,178,1,226,235,133,189,11,8,236,158,128,159,2,4,237,140,187,206,2,21,236,253,128,205,3,9,239,239,208,251,10,17,240,179,157,219,7,4,241,147,239,232,6,4,238,153,239,204,9,49,243,138,171,183,10,252,1,245,181,155,135,2,23,247,212,219,208,10,46],"doc_state":[74,9,228,242,134,215,15,0,39,0,204,195,206,156,1,4,6,109,86,80,71,80,99,2,4,0,228,242,134,215,15,0,4,104,106,107,100,161,172,254,181,239,1,14,1,132,228,242,134,215,15,4,1,56,161,228,242,134,215,15,5,1,132,228,242,134,215,15,6,1,56,161,228,242,134,215,15,7,1,132,228,242,134,215,15,8,1,56,161,228,242,134,215,15,9,1,18,165,131,171,211,15,0,129,155,213,159,176,1,6,2,161,155,213,159,176,1,7,1,161,155,213,159,176,1,8,1,161,155,213,159,176,1,9,1,161,165,131,171,211,15,2,1,161,165,131,171,211,15,3,1,161,165,131,171,211,15,4,1,161,165,131,171,211,15,5,1,161,165,131,171,211,15,6,1,161,165,131,171,211,15,7,1,129,165,131,171,211,15,1,2,161,165,131,171,211,15,8,1,161,165,131,171,211,15,9,1,161,165,131,171,211,15,10,1,129,165,131,171,211,15,12,1,161,165,131,171,211,15,13,1,161,165,131,171,211,15,14,1,161,165,131,171,211,15,15,1,28,224,159,166,178,15,0,129,165,131,171,211,15,16,1,161,197,205,192,233,12,6,1,161,197,205,192,233,12,7,1,161,197,205,192,233,12,8,1,161,224,159,166,178,15,1,1,161,224,159,166,178,15,2,1,161,224,159,166,178,15,3,1,129,224,159,166,178,15,0,1,161,224,159,166,178,15,4,1,161,224,159,166,178,15,5,1,161,224,159,166,178,15,6,1,129,224,159,166,178,15,7,2,161,224,159,166,178,15,8,1,161,224,159,166,178,15,9,1,161,224,159,166,178,15,10,1,161,224,159,166,178,15,13,1,161,224,159,166,178,15,14,1,161,224,159,166,178,15,15,1,161,224,159,166,178,15,16,1,161,224,159,166,178,15,17,1,161,224,159,166,178,15,18,1,161,224,159,166,178,15,19,1,161,224,159,166,178,15,20,1,161,224,159,166,178,15,21,1,129,224,159,166,178,15,12,2,161,224,159,166,178,15,22,1,161,224,159,166,178,15,23,1,161,224,159,166,178,15,24,1,1,223,215,172,155,15,0,161,185,164,169,62,89,5,71,181,150,190,222,14,0,39,0,204,195,206,156,1,4,6,68,81,108,56,102,54,2,1,0,181,150,190,222,14,0,2,0,6,39,0,204,195,206,156,1,4,6,110,114,88,86,119,98,2,33,0,204,195,206,156,1,1,6,114,119,110,108,70,75,1,0,7,33,0,204,195,206,156,1,3,6,54,105,119,67,105,57,1,193,199,130,209,189,2,191,5,199,130,209,189,2,176,6,1,39,0,204,195,206,156,1,4,6,76,95,120,101,104,45,2,33,0,204,195,206,156,1,1,6,118,52,75,115,74,51,1,0,7,33,0,204,195,206,156,1,3,6,77,54,85,88,53,66,1,193,199,130,209,189,2,191,5,181,150,190,222,14,19,1,39,0,204,195,206,156,1,4,6,105,82,99,102,107,49,2,33,0,204,195,206,156,1,1,6,70,101,106,82,116,48,1,0,7,33,0,204,195,206,156,1,3,6,108,75,113,56,70,69,1,129,199,130,209,189,2,156,6,1,39,0,204,195,206,156,1,4,6,69,114,74,53,80,51,2,39,0,204,195,206,156,1,1,6,115,115,117,107,51,70,1,40,0,181,150,190,222,14,43,2,105,100,1,119,6,115,115,117,107,51,70,40,0,181,150,190,222,14,43,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,181,150,190,222,14,43,6,112,97,114,101,110,116,1,119,6,78,99,104,45,81,78,40,0,181,150,190,222,14,43,8,99,104,105,108,100,114,101,110,1,119,6,118,108,89,79,54,57,40,0,181,150,190,222,14,43,4,100,97,116,97,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,40,0,181,150,190,222,14,43,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,181,150,190,222,14,43,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,118,108,89,79,54,57,0,136,199,130,209,189,2,176,6,1,119,6,115,115,117,107,51,70,39,0,204,195,206,156,1,4,6,98,66,87,54,98,51,2,39,0,204,195,206,156,1,1,6,80,71,48,76,73,113,1,40,0,181,150,190,222,14,54,2,105,100,1,119,6,80,71,48,76,73,113,40,0,181,150,190,222,14,54,2,116,121,1,119,11,116,111,103,103,108,101,95,108,105,115,116,40,0,181,150,190,222,14,54,6,112,97,114,101,110,116,1,119,6,78,99,104,45,81,78,40,0,181,150,190,222,14,54,8,99,104,105,108,100,114,101,110,1,119,6,79,69,102,100,51,114,33,0,181,150,190,222,14,54,4,100,97,116,97,1,40,0,181,150,190,222,14,54,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,181,150,190,222,14,54,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,79,69,102,100,51,114,0,136,181,150,190,222,14,52,1,119,6,80,71,48,76,73,113,4,0,181,150,190,222,14,53,1,49,161,181,150,190,222,14,59,1,132,181,150,190,222,14,64,1,49,161,181,150,190,222,14,65,1,132,181,150,190,222,14,66,1,49,161,181,150,190,222,14,67,1,132,181,150,190,222,14,68,1,49,161,181,150,190,222,14,69,1,132,181,150,190,222,14,70,1,49,161,181,150,190,222,14,71,1,39,0,204,195,206,156,1,4,6,75,77,105,49,106,114,2,39,0,204,195,206,156,1,1,6,49,116,120,121,68,99,1,40,0,181,150,190,222,14,75,2,105,100,1,119,6,49,116,120,121,68,99,40,0,181,150,190,222,14,75,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,181,150,190,222,14,75,6,112,97,114,101,110,116,1,119,6,80,71,48,76,73,113,40,0,181,150,190,222,14,75,8,99,104,105,108,100,114,101,110,1,119,6,111,67,65,71,120,67,33,0,181,150,190,222,14,75,4,100,97,116,97,1,40,0,181,150,190,222,14,75,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,181,150,190,222,14,75,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,111,67,65,71,120,67,0,8,0,181,150,190,222,14,62,1,119,6,49,116,120,121,68,99,161,181,150,190,222,14,73,1,4,0,181,150,190,222,14,74,1,54,161,181,150,190,222,14,80,1,132,181,150,190,222,14,86,1,54,161,181,150,190,222,14,87,1,132,181,150,190,222,14,88,1,54,161,181,150,190,222,14,89,1,132,181,150,190,222,14,90,1,54,168,181,150,190,222,14,91,1,119,29,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,54,54,54,54,34,125,93,125,168,181,150,190,222,14,85,1,119,47,123,34,99,111,108,108,97,112,115,101,100,34,58,116,114,117,101,44,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,49,49,49,49,49,34,125,93,125,7,184,146,243,216,14,0,129,217,168,198,159,4,3,1,161,142,211,188,164,13,12,1,161,142,211,188,164,13,13,1,161,142,211,188,164,13,14,1,161,184,146,243,216,14,1,1,161,184,146,243,216,14,2,1,161,184,146,243,216,14,3,1,5,178,187,245,161,14,0,39,0,204,195,206,156,1,4,6,95,104,88,73,115,119,2,4,0,178,187,245,161,14,0,13,229,144,140,228,184,128,228,184,170,106,106,106,57,161,198,223,206,159,1,124,1,132,178,187,245,161,14,7,1,57,161,178,187,245,161,14,8,1,5,140,167,201,161,14,0,0,4,129,204,195,206,156,1,245,5,3,161,198,223,206,159,1,89,1,161,198,223,206,159,1,90,1,161,198,223,206,159,1,91,1,1,176,238,158,139,14,0,161,206,214,243,86,177,1,175,2,1,146,209,153,247,13,0,161,131,159,159,151,1,67,186,1,15,142,211,188,164,13,0,161,217,168,198,159,4,4,1,161,217,168,198,159,4,5,1,161,217,168,198,159,4,6,1,161,142,211,188,164,13,0,1,161,142,211,188,164,13,1,1,161,142,211,188,164,13,2,1,161,142,211,188,164,13,3,1,161,142,211,188,164,13,4,1,161,142,211,188,164,13,5,1,161,142,211,188,164,13,6,1,161,142,211,188,164,13,7,1,161,142,211,188,164,13,8,1,161,142,211,188,164,13,9,1,161,142,211,188,164,13,10,1,161,142,211,188,164,13,11,1,9,197,205,192,233,12,0,161,165,131,171,211,15,17,1,161,165,131,171,211,15,18,1,161,165,131,171,211,15,19,1,161,197,205,192,233,12,0,1,161,197,205,192,233,12,1,1,161,197,205,192,233,12,2,1,161,197,205,192,233,12,3,1,161,197,205,192,233,12,4,1,161,197,205,192,233,12,5,1,1,207,210,187,205,12,0,161,208,203,223,226,9,76,8,47,131,182,180,202,12,0,129,184,146,243,216,14,0,1,161,184,146,243,216,14,4,1,161,184,146,243,216,14,5,1,161,184,146,243,216,14,6,1,129,131,182,180,202,12,0,2,161,131,182,180,202,12,1,1,161,131,182,180,202,12,2,1,161,131,182,180,202,12,3,1,161,131,182,180,202,12,6,1,161,131,182,180,202,12,7,1,161,131,182,180,202,12,8,1,161,131,182,180,202,12,9,1,161,131,182,180,202,12,10,1,161,131,182,180,202,12,11,1,161,131,182,180,202,12,12,1,161,131,182,180,202,12,13,1,161,131,182,180,202,12,14,1,129,131,182,180,202,12,5,3,161,131,182,180,202,12,15,1,161,131,182,180,202,12,16,1,161,131,182,180,202,12,17,1,161,131,182,180,202,12,21,1,161,131,182,180,202,12,22,1,161,131,182,180,202,12,23,1,161,131,182,180,202,12,24,1,161,131,182,180,202,12,25,1,161,131,182,180,202,12,26,1,161,131,182,180,202,12,27,1,161,131,182,180,202,12,28,1,161,131,182,180,202,12,29,1,129,131,182,180,202,12,20,4,161,131,182,180,202,12,30,1,161,131,182,180,202,12,31,1,161,131,182,180,202,12,32,1,161,131,182,180,202,12,37,1,161,131,182,180,202,12,38,1,161,131,182,180,202,12,39,1,129,131,182,180,202,12,36,1,161,131,182,180,202,12,40,1,161,131,182,180,202,12,41,1,161,131,182,180,202,12,42,1,161,131,182,180,202,12,44,1,161,131,182,180,202,12,45,1,161,131,182,180,202,12,46,1,161,131,182,180,202,12,47,1,161,131,182,180,202,12,48,1,161,131,182,180,202,12,49,1,1,151,234,142,238,11,0,161,229,154,194,35,177,1,27,1,198,234,131,228,11,0,161,236,253,128,205,3,8,50,2,226,235,133,189,11,0,161,145,224,235,133,7,2,7,168,226,235,133,189,11,6,1,122,0,0,0,0,102,88,73,73,1,195,254,251,180,11,0,161,183,182,135,14,224,2,58,1,239,239,208,251,10,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,17,81,164,202,219,213,10,0,39,0,204,195,206,156,1,4,6,103,67,116,99,89,115,2,39,0,204,195,206,156,1,4,6,102,105,108,83,57,100,2,4,0,164,202,219,213,10,1,10,116,111,100,111,32,108,105,115,116,32,134,164,202,219,213,10,11,7,109,101,110,116,105,111,110,51,123,34,116,121,112,101,34,58,34,100,97,116,101,34,44,34,100,97,116,101,34,58,34,50,48,50,52,45,48,52,45,49,56,84,49,52,58,50,53,58,51,50,46,52,53,55,50,55,55,34,125,132,164,202,219,213,10,12,1,36,134,164,202,219,213,10,13,7,109,101,110,116,105,111,110,4,110,117,108,108,132,164,202,219,213,10,14,4,109,101,110,116,33,0,204,195,206,156,1,1,6,88,55,78,102,76,50,1,0,7,33,0,204,195,206,156,1,3,6,112,56,66,76,122,103,1,193,198,223,206,159,1,135,1,199,130,209,189,2,60,1,168,199,130,209,189,2,140,8,1,119,161,1,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,116,111,100,111,32,108,105,115,116,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,109,101,110,116,105,111,110,34,58,123,34,100,97,116,101,34,58,34,50,48,50,52,45,48,52,45,49,56,84,49,52,58,50,53,58,51,50,46,52,53,55,50,55,55,34,44,34,116,121,112,101,34,58,34,100,97,116,101,34,125,125,44,34,105,110,115,101,114,116,34,58,34,36,34,125,44,123,34,105,110,115,101,114,116,34,58,34,109,101,110,116,34,125,93,44,34,99,104,101,99,107,101,100,34,58,102,97,108,115,101,125,39,0,204,195,206,156,1,4,6,66,120,115,95,114,76,2,33,0,204,195,206,156,1,1,6,111,56,54,77,119,121,1,0,7,33,0,204,195,206,156,1,3,6,67,89,84,109,67,89,1,193,198,223,206,159,1,135,1,164,202,219,213,10,28,1,4,0,164,202,219,213,10,30,1,35,0,1,39,0,204,195,206,156,1,4,6,109,113,102,117,86,95,2,33,0,204,195,206,156,1,1,6,84,100,115,87,90,75,1,0,7,33,0,204,195,206,156,1,3,6,49,115,106,52,120,74,1,193,198,223,206,159,1,135,1,164,202,219,213,10,40,1,4,0,164,202,219,213,10,43,1,49,0,1,132,164,202,219,213,10,54,1,50,0,1,132,164,202,219,213,10,56,1,51,0,1,132,164,202,219,213,10,58,1,32,0,1,129,164,202,219,213,10,60,1,0,1,134,164,202,219,213,10,62,7,109,101,110,116,105,111,110,51,123,34,100,97,116,101,34,58,34,50,48,50,52,45,48,52,45,49,57,84,49,54,58,49,51,58,52,57,46,52,49,49,49,54,53,34,44,34,116,121,112,101,34,58,34,100,97,116,101,34,125,132,164,202,219,213,10,64,1,36,134,164,202,219,213,10,65,7,109,101,110,116,105,111,110,4,110,117,108,108,0,1,39,0,204,195,206,156,1,4,6,103,83,52,80,113,73,2,4,0,164,202,219,213,10,68,4,49,50,51,32,134,164,202,219,213,10,72,7,109,101,110,116,105,111,110,51,123,34,116,121,112,101,34,58,34,100,97,116,101,34,44,34,100,97,116,101,34,58,34,50,48,50,52,45,48,52,45,49,57,84,49,54,58,49,51,58,52,57,46,52,49,49,49,54,53,34,125,132,164,202,219,213,10,73,1,36,134,164,202,219,213,10,74,7,109,101,110,116,105,111,110,4,110,117,108,108,132,164,202,219,213,10,75,1,32,0,1,129,164,202,219,213,10,76,1,0,1,161,204,195,206,156,1,155,1,1,161,204,195,206,156,1,156,1,1,161,204,195,206,156,1,157,1,1,0,1,132,164,202,219,213,10,78,1,32,0,1,132,164,202,219,213,10,84,1,101,0,1,132,164,202,219,213,10,86,1,114,0,1,132,164,202,219,213,10,88,1,32,0,1,132,164,202,219,213,10,90,1,32,0,1,68,164,202,219,213,10,69,1,35,0,1,68,164,202,219,213,10,94,1,35,0,1,39,0,204,195,206,156,1,4,6,120,115,71,80,56,122,2,4,0,164,202,219,213,10,98,4,49,50,51,32,134,164,202,219,213,10,102,7,109,101,110,116,105,111,110,51,123,34,100,97,116,101,34,58,34,50,48,50,52,45,48,52,45,49,57,84,49,54,58,49,51,58,52,57,46,52,49,49,49,54,53,34,44,34,116,121,112,101,34,58,34,100,97,116,101,34,125,132,164,202,219,213,10,103,1,36,134,164,202,219,213,10,104,7,109,101,110,116,105,111,110,4,110,117,108,108,132,164,202,219,213,10,105,6,32,32,101,114,32,32,39,0,204,195,206,156,1,1,6,106,97,80,87,115,68,1,40,0,164,202,219,213,10,112,2,105,100,1,119,6,106,97,80,87,115,68,40,0,164,202,219,213,10,112,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,164,202,219,213,10,112,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,164,202,219,213,10,112,8,99,104,105,108,100,114,101,110,1,119,6,106,75,88,90,122,73,33,0,164,202,219,213,10,112,4,100,97,116,97,1,40,0,164,202,219,213,10,112,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,164,202,219,213,10,112,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,106,75,88,90,122,73,0,200,198,223,206,159,1,135,1,164,202,219,213,10,53,1,119,6,106,97,80,87,115,68,1,247,212,219,208,10,0,161,132,236,218,251,9,13,46,240,1,243,138,171,183,10,0,161,150,216,171,142,3,178,5,1,161,150,216,171,142,3,190,5,1,161,150,216,171,142,3,200,5,1,161,150,216,171,142,3,187,6,1,161,150,216,171,142,3,188,6,1,161,150,216,171,142,3,189,6,1,161,150,216,171,142,3,190,6,2,161,150,216,171,142,3,251,5,1,161,150,216,171,142,3,144,6,1,161,150,216,171,142,3,164,6,1,161,243,138,171,183,10,7,1,161,243,138,171,183,10,0,1,161,243,138,171,183,10,1,1,161,243,138,171,183,10,2,1,161,243,138,171,183,10,8,1,161,243,138,171,183,10,9,1,161,243,138,171,183,10,10,1,161,243,138,171,183,10,11,1,161,243,138,171,183,10,3,1,161,243,138,171,183,10,4,1,161,243,138,171,183,10,5,1,161,243,138,171,183,10,18,2,161,243,138,171,183,10,12,1,161,243,138,171,183,10,13,1,161,243,138,171,183,10,14,1,161,243,138,171,183,10,19,1,161,243,138,171,183,10,20,1,161,243,138,171,183,10,21,1,161,243,138,171,183,10,23,1,161,243,138,171,183,10,15,1,161,243,138,171,183,10,16,1,161,243,138,171,183,10,17,1,161,243,138,171,183,10,30,2,161,243,138,171,183,10,24,1,161,243,138,171,183,10,25,1,161,243,138,171,183,10,26,1,161,243,138,171,183,10,27,1,161,243,138,171,183,10,28,1,161,243,138,171,183,10,29,1,161,243,138,171,183,10,35,1,161,243,138,171,183,10,31,1,161,243,138,171,183,10,32,1,161,243,138,171,183,10,33,1,161,243,138,171,183,10,42,2,161,243,138,171,183,10,36,1,161,243,138,171,183,10,37,1,161,243,138,171,183,10,38,1,161,243,138,171,183,10,43,1,161,243,138,171,183,10,44,1,161,243,138,171,183,10,45,1,161,243,138,171,183,10,47,1,161,243,138,171,183,10,39,1,161,243,138,171,183,10,40,1,161,243,138,171,183,10,41,1,161,243,138,171,183,10,54,2,161,243,138,171,183,10,48,1,161,243,138,171,183,10,49,1,161,243,138,171,183,10,50,1,161,243,138,171,183,10,55,1,161,243,138,171,183,10,56,1,161,243,138,171,183,10,57,1,161,243,138,171,183,10,59,1,161,243,138,171,183,10,51,1,161,243,138,171,183,10,52,1,161,243,138,171,183,10,53,1,161,243,138,171,183,10,66,2,161,243,138,171,183,10,60,1,161,243,138,171,183,10,61,1,161,243,138,171,183,10,62,1,161,243,138,171,183,10,63,1,161,243,138,171,183,10,64,1,161,243,138,171,183,10,65,1,161,243,138,171,183,10,71,2,161,243,138,171,183,10,67,1,161,243,138,171,183,10,68,1,161,243,138,171,183,10,69,1,161,243,138,171,183,10,79,1,161,243,138,171,183,10,72,1,161,243,138,171,183,10,73,1,161,243,138,171,183,10,74,1,161,243,138,171,183,10,75,1,161,243,138,171,183,10,76,1,161,243,138,171,183,10,77,1,161,243,138,171,183,10,83,1,161,243,138,171,183,10,80,1,161,243,138,171,183,10,81,1,161,243,138,171,183,10,82,1,161,243,138,171,183,10,90,2,161,146,216,250,133,2,12,1,161,146,216,250,133,2,13,1,161,146,216,250,133,2,14,1,161,146,216,250,133,2,17,1,161,146,216,250,133,2,21,1,161,146,216,250,133,2,22,1,161,146,216,250,133,2,23,1,161,243,138,171,183,10,99,1,161,146,216,250,133,2,18,1,161,146,216,250,133,2,19,1,161,146,216,250,133,2,20,1,161,243,138,171,183,10,103,1,161,146,216,250,133,2,24,1,161,146,216,250,133,2,25,1,161,146,216,250,133,2,26,1,161,146,216,250,133,2,35,1,161,146,216,250,133,2,28,1,161,146,216,250,133,2,29,1,161,146,216,250,133,2,30,1,161,243,138,171,183,10,111,1,161,146,216,250,133,2,32,1,161,146,216,250,133,2,33,1,161,146,216,250,133,2,34,1,161,243,138,171,183,10,115,1,161,146,216,250,133,2,36,1,161,146,216,250,133,2,37,1,161,146,216,250,133,2,38,1,161,146,216,250,133,2,40,1,161,146,216,250,133,2,41,1,161,146,216,250,133,2,42,1,161,146,216,250,133,2,47,1,161,146,216,250,133,2,44,1,161,146,216,250,133,2,45,1,161,146,216,250,133,2,46,1,161,243,138,171,183,10,126,2,161,146,216,250,133,2,48,1,161,146,216,250,133,2,49,1,161,146,216,250,133,2,50,1,161,146,216,250,133,2,59,1,161,146,216,250,133,2,51,1,161,146,216,250,133,2,52,1,161,146,216,250,133,2,53,1,161,243,138,171,183,10,135,1,1,161,146,216,250,133,2,56,1,161,146,216,250,133,2,57,1,161,146,216,250,133,2,58,1,161,243,138,171,183,10,139,1,1,161,146,216,250,133,2,60,1,161,146,216,250,133,2,61,1,161,146,216,250,133,2,62,1,161,146,216,250,133,2,71,1,161,146,216,250,133,2,64,1,161,146,216,250,133,2,65,1,161,146,216,250,133,2,66,1,161,243,138,171,183,10,147,1,1,161,146,216,250,133,2,68,1,161,146,216,250,133,2,69,1,161,146,216,250,133,2,70,1,161,243,138,171,183,10,151,1,1,161,146,216,250,133,2,72,1,161,146,216,250,133,2,73,1,161,146,216,250,133,2,74,1,161,146,216,250,133,2,83,1,161,146,216,250,133,2,76,1,161,146,216,250,133,2,77,1,161,146,216,250,133,2,78,1,161,243,138,171,183,10,159,1,1,161,146,216,250,133,2,80,1,161,146,216,250,133,2,81,1,161,146,216,250,133,2,82,1,161,243,138,171,183,10,163,1,1,161,146,216,250,133,2,84,1,161,146,216,250,133,2,85,1,161,146,216,250,133,2,86,1,161,146,216,250,133,2,95,1,161,146,216,250,133,2,92,1,161,146,216,250,133,2,93,1,161,146,216,250,133,2,94,1,161,243,138,171,183,10,171,1,1,161,146,216,250,133,2,88,1,161,146,216,250,133,2,89,1,161,146,216,250,133,2,90,1,161,243,138,171,183,10,175,1,1,161,146,216,250,133,2,96,1,161,146,216,250,133,2,97,1,161,146,216,250,133,2,98,1,161,146,216,250,133,2,107,1,161,146,216,250,133,2,104,1,161,146,216,250,133,2,105,1,161,146,216,250,133,2,106,1,161,243,138,171,183,10,183,1,1,161,146,216,250,133,2,100,1,161,146,216,250,133,2,101,1,161,146,216,250,133,2,102,1,161,243,138,171,183,10,187,1,1,161,146,216,250,133,2,108,1,161,146,216,250,133,2,109,1,161,146,216,250,133,2,110,1,161,146,216,250,133,2,119,1,161,146,216,250,133,2,112,1,161,146,216,250,133,2,113,1,161,146,216,250,133,2,114,1,161,243,138,171,183,10,195,1,1,161,146,216,250,133,2,116,1,161,146,216,250,133,2,117,1,161,146,216,250,133,2,118,1,161,243,138,171,183,10,199,1,1,161,146,216,250,133,2,120,1,161,146,216,250,133,2,121,1,161,146,216,250,133,2,122,1,161,146,216,250,133,2,131,1,2,161,146,216,250,133,2,124,1,161,146,216,250,133,2,125,1,161,146,216,250,133,2,126,1,161,146,216,250,133,2,128,1,1,161,146,216,250,133,2,129,1,1,161,146,216,250,133,2,130,1,1,161,243,138,171,183,10,208,1,1,161,146,216,250,133,2,132,1,1,161,146,216,250,133,2,133,1,1,161,146,216,250,133,2,134,1,1,161,146,216,250,133,2,143,1,1,161,146,216,250,133,2,136,1,1,161,146,216,250,133,2,137,1,1,161,146,216,250,133,2,138,1,1,161,243,138,171,183,10,219,1,1,161,146,216,250,133,2,140,1,1,161,146,216,250,133,2,141,1,1,161,146,216,250,133,2,142,1,1,161,243,138,171,183,10,223,1,1,161,146,216,250,133,2,144,1,1,161,146,216,250,133,2,145,1,1,161,146,216,250,133,2,146,1,1,161,146,216,250,133,2,155,1,1,161,146,216,250,133,2,148,1,1,161,146,216,250,133,2,149,1,1,161,146,216,250,133,2,150,1,1,161,243,138,171,183,10,231,1,1,161,146,216,250,133,2,152,1,1,161,146,216,250,133,2,153,1,1,161,146,216,250,133,2,154,1,1,161,243,138,171,183,10,235,1,1,161,146,216,250,133,2,156,1,1,161,146,216,250,133,2,157,1,1,161,146,216,250,133,2,158,1,1,161,146,216,250,133,2,167,1,3,161,146,216,250,133,2,160,1,1,161,146,216,250,133,2,161,1,1,161,146,216,250,133,2,162,1,1,161,146,216,250,133,2,164,1,1,161,146,216,250,133,2,165,1,1,161,146,216,250,133,2,166,1,1,1,132,236,218,251,9,0,161,218,255,204,32,20,14,34,136,199,176,231,9,0,39,0,204,195,206,156,1,1,6,74,52,82,97,73,114,1,40,0,136,199,176,231,9,0,2,105,100,1,119,6,74,52,82,97,73,114,40,0,136,199,176,231,9,0,2,116,121,1,119,4,103,114,105,100,40,0,136,199,176,231,9,0,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,136,199,176,231,9,0,8,99,104,105,108,100,114,101,110,1,119,6,86,95,76,83,51,101,40,0,136,199,176,231,9,0,4,100,97,116,97,1,119,101,123,34,118,105,101,119,95,105,100,34,58,34,49,51,53,54,49,53,102,97,45,54,54,102,55,45,52,52,53,49,45,57,98,53,52,45,100,55,101,57,57,52,52,53,102,99,97,52,34,44,34,112,97,114,101,110,116,95,105,100,34,58,34,55,100,50,49,52,56,102,99,45,99,97,99,101,45,52,52,53,50,45,57,99,53,99,45,57,54,101,53,50,101,54,98,102,56,98,53,34,125,40,0,136,199,176,231,9,0,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,136,199,176,231,9,0,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,86,95,76,83,51,101,0,200,204,195,206,156,1,252,1,204,195,206,156,1,253,1,1,119,6,74,52,82,97,73,114,39,0,204,195,206,156,1,1,6,115,74,113,109,112,57,1,40,0,136,199,176,231,9,10,2,105,100,1,119,6,115,74,113,109,112,57,40,0,136,199,176,231,9,10,2,116,121,1,119,5,98,111,97,114,100,40,0,136,199,176,231,9,10,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,136,199,176,231,9,10,8,99,104,105,108,100,114,101,110,1,119,6,87,71,71,122,72,118,40,0,136,199,176,231,9,10,4,100,97,116,97,1,119,101,123,34,112,97,114,101,110,116,95,105,100,34,58,34,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,34,44,34,118,105,101,119,95,105,100,34,58,34,98,52,101,55,55,50,48,51,45,53,99,56,98,45,52,56,100,102,45,98,98,99,53,45,50,101,49,49,52,51,101,98,48,101,54,49,34,125,40,0,136,199,176,231,9,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,136,199,176,231,9,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,87,71,71,122,72,118,0,200,136,199,176,231,9,9,204,195,206,156,1,253,1,1,119,6,115,74,113,109,112,57,33,0,204,195,206,156,1,1,6,98,118,111,52,85,121,1,0,7,33,0,204,195,206,156,1,3,6,81,122,68,56,119,121,1,193,136,199,176,231,9,19,204,195,206,156,1,253,1,1,39,0,204,195,206,156,1,1,6,71,57,106,76,66,79,1,40,0,136,199,176,231,9,30,2,105,100,1,119,6,71,57,106,76,66,79,40,0,136,199,176,231,9,30,2,116,121,1,119,8,99,97,108,101,110,100,97,114,40,0,136,199,176,231,9,30,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,136,199,176,231,9,30,8,99,104,105,108,100,114,101,110,1,119,6,122,51,54,102,102,100,40,0,136,199,176,231,9,30,4,100,97,116,97,1,119,101,123,34,118,105,101,119,95,105,100,34,58,34,50,98,102,53,48,99,48,51,45,102,52,49,102,45,52,51,54,51,45,98,53,98,49,45,49,48,49,50,49,54,97,54,99,53,99,99,34,44,34,112,97,114,101,110,116,95,105,100,34,58,34,101,101,51,97,101,56,99,101,45,57,53,57,97,45,52,100,102,51,45,56,55,51,52,45,52,48,98,53,51,53,102,102,56,56,101,51,34,125,40,0,136,199,176,231,9,30,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,136,199,176,231,9,30,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,122,51,54,102,102,100,0,200,136,199,176,231,9,29,204,195,206,156,1,253,1,1,119,6,71,57,106,76,66,79,1,131,128,202,229,9,0,161,243,138,171,183,10,245,1,1,1,208,203,223,226,9,0,161,146,209,153,247,13,185,1,81,31,238,153,239,204,9,0,161,183,213,134,255,8,25,1,161,183,213,134,255,8,26,1,161,183,213,134,255,8,27,1,132,183,213,134,255,8,24,1,100,161,238,153,239,204,9,0,1,161,238,153,239,204,9,1,1,161,238,153,239,204,9,2,1,132,238,153,239,204,9,3,1,55,161,238,153,239,204,9,4,1,161,238,153,239,204,9,5,1,161,238,153,239,204,9,6,1,132,238,153,239,204,9,7,1,55,168,238,153,239,204,9,8,1,119,133,1,123,34,100,101,108,116,97,34,58,91,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,109,101,110,116,105,111,110,34,58,123,34,112,97,103,101,95,105,100,34,58,34,100,48,52,57,54,51,50,52,45,53,53,55,48,45,52,48,48,54,45,98,52,101,97,45,100,98,55,53,49,54,100,50,49,50,102,100,34,44,34,116,121,112,101,34,58,34,112,97,103,101,34,125,125,44,34,105,110,115,101,114,116,34,58,34,36,34,125,44,123,34,105,110,115,101,114,116,34,58,34,100,55,55,34,125,93,125,168,238,153,239,204,9,9,1,119,10,107,106,48,68,49,121,121,88,78,119,168,238,153,239,204,9,10,1,119,4,116,101,120,116,39,0,204,195,206,156,1,4,6,111,97,103,82,55,77,2,6,0,238,153,239,204,9,15,4,104,114,101,102,13,34,97,112,112,102,108,111,119,121,46,105,111,34,132,238,153,239,204,9,16,11,97,112,112,102,108,111,119,121,46,105,111,134,238,153,239,204,9,27,4,104,114,101,102,4,110,117,108,108,132,238,153,239,204,9,28,1,32,161,151,254,242,152,9,90,1,132,238,153,239,204,9,29,1,49,161,238,153,239,204,9,30,1,39,0,204,195,206,156,1,4,6,53,101,83,117,83,45,2,6,0,238,153,239,204,9,33,4,104,114,101,102,13,34,49,57,50,46,49,54,56,46,49,46,50,34,132,238,153,239,204,9,34,9,99,111,110,116,101,110,116,32,49,134,238,153,239,204,9,43,4,104,114,101,102,4,110,117,108,108,132,238,153,239,204,9,44,1,32,161,151,254,242,152,9,134,1,1,132,238,153,239,204,9,45,1,50,161,238,153,239,204,9,46,1,1,219,200,174,197,9,0,161,161,234,157,145,5,6,25,3,207,231,154,196,9,0,161,204,195,206,156,1,209,1,1,161,204,195,206,156,1,210,1,1,161,204,195,206,156,1,211,1,1,118,151,254,242,152,9,0,39,0,204,195,206,156,1,4,6,65,119,80,77,53,56,2,6,0,151,254,242,152,9,0,7,109,101,110,116,105,111,110,64,123,34,116,121,112,101,34,58,34,112,97,103,101,34,44,34,112,97,103,101,95,105,100,34,58,34,100,100,98,57,51,98,97,55,45,48,54,99,55,45,52,49,55,54,45,57,56,50,97,45,100,55,52,50,51,101,48,57,98,52,52,49,34,125,132,151,254,242,152,9,1,1,36,134,151,254,242,152,9,2,7,109,101,110,116,105,111,110,4,110,117,108,108,132,151,254,242,152,9,3,1,104,161,220,225,223,240,3,59,1,129,151,254,242,152,9,4,2,161,151,254,242,152,9,5,1,129,151,254,242,152,9,7,2,161,151,254,242,152,9,8,1,129,151,254,242,152,9,10,1,132,151,254,242,152,9,12,1,104,161,151,254,242,152,9,11,1,196,151,254,242,152,9,4,151,254,242,152,9,6,2,104,104,161,151,254,242,152,9,14,1,132,151,254,242,152,9,13,1,32,161,151,254,242,152,9,17,1,129,151,254,242,152,9,18,1,161,151,254,242,152,9,19,1,134,151,254,242,152,9,20,7,109,101,110,116,105,111,110,123,123,34,100,97,116,101,34,58,34,50,48,50,52,45,48,52,45,50,53,84,49,55,58,49,50,58,52,51,46,52,51,57,57,48,57,34,44,34,114,101,109,105,110,100,101,114,95,105,100,34,58,34,118,108,95,45,105,57,52,99,82,103,69,85,115,112,84,111,81,95,115,68,86,34,44,34,116,121,112,101,34,58,34,100,97,116,101,34,44,34,114,101,109,105,110,100,101,114,95,111,112,116,105,111,110,34,58,34,97,116,84,105,109,101,79,102,69,118,101,110,116,34,125,132,151,254,242,152,9,22,1,36,134,151,254,242,152,9,23,7,109,101,110,116,105,111,110,4,110,117,108,108,168,151,254,242,152,9,21,1,119,171,2,123,34,100,101,108,116,97,34,58,91,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,109,101,110,116,105,111,110,34,58,123,34,112,97,103,101,95,105,100,34,58,34,100,100,98,57,51,98,97,55,45,48,54,99,55,45,52,49,55,54,45,57,56,50,97,45,100,55,52,50,51,101,48,57,98,52,52,49,34,44,34,116,121,112,101,34,58,34,112,97,103,101,34,125,125,44,34,105,110,115,101,114,116,34,58,34,36,34,125,44,123,34,105,110,115,101,114,116,34,58,34,104,104,104,104,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,109,101,110,116,105,111,110,34,58,123,34,100,97,116,101,34,58,34,50,48,50,52,45,48,52,45,50,53,84,49,55,58,49,50,58,52,51,46,52,51,57,57,48,57,34,44,34,114,101,109,105,110,100,101,114,95,105,100,34,58,34,118,108,95,45,105,57,52,99,82,103,69,85,115,112,84,111,81,95,115,68,86,34,44,34,114,101,109,105,110,100,101,114,95,111,112,116,105,111,110,34,58,34,97,116,84,105,109,101,79,102,69,118,101,110,116,34,44,34,116,121,112,101,34,58,34,100,97,116,101,34,125,125,44,34,105,110,115,101,114,116,34,58,34,36,34,125,93,125,39,0,204,195,206,156,1,4,6,107,74,118,98,69,107,2,39,0,204,195,206,156,1,1,6,112,71,75,102,71,113,1,40,0,151,254,242,152,9,27,2,105,100,1,119,6,112,71,75,102,71,113,40,0,151,254,242,152,9,27,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,151,254,242,152,9,27,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,151,254,242,152,9,27,8,99,104,105,108,100,114,101,110,1,119,6,54,97,84,68,85,107,33,0,151,254,242,152,9,27,4,100,97,116,97,1,40,0,151,254,242,152,9,27,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,151,254,242,152,9,27,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,54,97,84,68,85,107,0,200,220,225,223,240,3,45,204,195,206,156,1,251,1,1,119,6,112,71,75,102,71,113,1,0,151,254,242,152,9,26,1,161,151,254,242,152,9,32,1,129,151,254,242,152,9,37,1,161,151,254,242,152,9,38,1,129,151,254,242,152,9,39,1,161,151,254,242,152,9,40,1,129,151,254,242,152,9,41,1,161,151,254,242,152,9,42,1,129,151,254,242,152,9,43,1,161,151,254,242,152,9,44,1,65,151,254,242,152,9,37,6,198,151,254,242,152,9,52,151,254,242,152,9,37,4,104,114,101,102,4,110,117,108,108,161,151,254,242,152,9,46,1,65,151,254,242,152,9,47,1,161,151,254,242,152,9,54,1,193,151,254,242,152,9,55,151,254,242,152,9,47,1,161,151,254,242,152,9,56,1,193,151,254,242,152,9,57,151,254,242,152,9,47,1,161,151,254,242,152,9,58,1,193,151,254,242,152,9,59,151,254,242,152,9,47,1,161,151,254,242,152,9,60,1,193,151,254,242,152,9,61,151,254,242,152,9,47,1,161,151,254,242,152,9,62,1,193,151,254,242,152,9,63,151,254,242,152,9,47,1,161,151,254,242,152,9,64,1,193,151,254,242,152,9,65,151,254,242,152,9,47,1,161,151,254,242,152,9,66,1,193,151,254,242,152,9,67,151,254,242,152,9,47,1,161,151,254,242,152,9,68,1,193,151,254,242,152,9,69,151,254,242,152,9,47,1,161,151,254,242,152,9,70,1,193,151,254,242,152,9,71,151,254,242,152,9,47,1,161,151,254,242,152,9,72,1,193,151,254,242,152,9,73,151,254,242,152,9,47,1,161,151,254,242,152,9,74,1,70,151,254,242,152,9,55,4,104,114,101,102,13,34,97,112,112,102,108,111,119,121,46,105,111,34,196,151,254,242,152,9,77,151,254,242,152,9,55,11,97,112,112,102,108,111,119,121,46,105,111,193,151,254,242,152,9,88,151,254,242,152,9,55,1,161,151,254,242,152,9,76,1,39,0,204,195,206,156,1,4,6,88,82,74,89,90,53,2,39,0,204,195,206,156,1,1,6,72,77,49,70,106,86,1,40,0,151,254,242,152,9,92,2,105,100,1,119,6,72,77,49,70,106,86,40,0,151,254,242,152,9,92,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,151,254,242,152,9,92,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,151,254,242,152,9,92,8,99,104,105,108,100,114,101,110,1,119,6,95,45,107,102,121,108,33,0,151,254,242,152,9,92,4,100,97,116,97,1,40,0,151,254,242,152,9,92,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,151,254,242,152,9,92,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,95,45,107,102,121,108,0,200,151,254,242,152,9,36,204,195,206,156,1,251,1,1,119,6,72,77,49,70,106,86,1,0,151,254,242,152,9,91,1,161,151,254,242,152,9,97,2,129,151,254,242,152,9,102,1,161,151,254,242,152,9,104,1,129,151,254,242,152,9,105,1,161,151,254,242,152,9,106,1,129,151,254,242,152,9,107,1,161,151,254,242,152,9,108,1,129,151,254,242,152,9,109,1,161,151,254,242,152,9,110,1,129,151,254,242,152,9,111,1,161,151,254,242,152,9,112,1,129,151,254,242,152,9,113,1,161,151,254,242,152,9,114,1,129,151,254,242,152,9,115,1,161,151,254,242,152,9,116,1,129,151,254,242,152,9,117,1,161,151,254,242,152,9,118,1,129,151,254,242,152,9,119,1,161,151,254,242,152,9,120,1,198,151,254,242,152,9,102,151,254,242,152,9,105,4,104,114,101,102,13,34,49,57,50,46,49,54,56,46,49,46,50,34,196,151,254,242,152,9,123,151,254,242,152,9,105,9,99,111,110,116,101,110,116,32,49,198,151,254,242,152,9,132,1,151,254,242,152,9,105,4,104,114,101,102,4,110,117,108,108,161,151,254,242,152,9,122,1,129,204,195,206,156,1,131,4,1,161,204,195,206,156,1,56,1,161,204,195,206,156,1,57,1,161,204,195,206,156,1,58,1,161,151,254,242,152,9,136,1,1,161,151,254,242,152,9,137,1,1,161,151,254,242,152,9,138,1,1,168,151,254,242,152,9,139,1,1,119,128,1,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,67,108,105,99,107,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,63,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,97,116,32,116,104,101,32,98,111,116,116,111,109,32,114,105,103,104,116,32,102,111,114,32,104,101,108,112,32,97,110,100,32,115,117,112,112,111,114,116,34,125,93,125,168,151,254,242,152,9,140,1,1,119,10,119,86,82,81,117,71,111,121,116,48,168,151,254,242,152,9,141,1,1,119,4,116,101,120,116,28,183,213,134,255,8,0,129,220,225,223,240,3,6,1,161,220,225,223,240,3,7,1,161,220,225,223,240,3,8,1,161,220,225,223,240,3,9,1,129,183,213,134,255,8,0,1,161,183,213,134,255,8,1,1,161,183,213,134,255,8,2,1,161,183,213,134,255,8,3,1,129,183,213,134,255,8,4,1,161,183,213,134,255,8,5,1,161,183,213,134,255,8,6,1,161,183,213,134,255,8,7,1,129,183,213,134,255,8,8,1,161,183,213,134,255,8,9,1,161,183,213,134,255,8,10,1,161,183,213,134,255,8,11,1,129,183,213,134,255,8,12,1,161,183,213,134,255,8,13,1,161,183,213,134,255,8,14,1,161,183,213,134,255,8,15,1,129,183,213,134,255,8,16,1,161,183,213,134,255,8,17,1,161,183,213,134,255,8,18,1,161,183,213,134,255,8,19,1,129,183,213,134,255,8,20,1,161,183,213,134,255,8,21,1,161,183,213,134,255,8,22,1,161,183,213,134,255,8,23,1,12,180,189,170,253,8,0,168,146,175,139,236,2,0,1,119,68,123,34,119,105,100,116,104,34,58,49,53,48,46,56,53,53,52,54,56,55,53,44,34,104,101,105,103,104,116,34,58,52,54,46,48,44,34,114,111,119,80,111,115,105,116,105,111,110,34,58,48,44,34,99,111,108,80,111,115,105,116,105,111,110,34,58,48,125,168,146,175,139,236,2,1,1,119,68,123,34,99,111,108,80,111,115,105,116,105,111,110,34,58,49,44,34,119,105,100,116,104,34,58,49,48,53,46,48,53,48,55,56,49,50,53,44,34,114,111,119,80,111,115,105,116,105,111,110,34,58,48,44,34,104,101,105,103,104,116,34,58,52,54,46,48,125,168,146,175,139,236,2,2,1,119,60,123,34,99,111,108,80,111,115,105,116,105,111,110,34,58,50,44,34,104,101,105,103,104,116,34,58,52,54,46,48,44,34,114,111,119,80,111,115,105,116,105,111,110,34,58,48,44,34,119,105,100,116,104,34,58,56,48,46,48,125,168,146,175,139,236,2,3,1,119,68,123,34,119,105,100,116,104,34,58,49,53,48,46,56,53,53,52,54,56,55,53,44,34,104,101,105,103,104,116,34,58,52,54,46,48,44,34,114,111,119,80,111,115,105,116,105,111,110,34,58,49,44,34,99,111,108,80,111,115,105,116,105,111,110,34,58,48,125,168,146,175,139,236,2,4,1,119,68,123,34,114,111,119,80,111,115,105,116,105,111,110,34,58,49,44,34,99,111,108,80,111,115,105,116,105,111,110,34,58,49,44,34,119,105,100,116,104,34,58,49,48,53,46,48,53,48,55,56,49,50,53,44,34,104,101,105,103,104,116,34,58,52,54,46,48,125,168,146,175,139,236,2,5,1,119,60,123,34,119,105,100,116,104,34,58,56,48,46,48,44,34,104,101,105,103,104,116,34,58,52,54,46,48,44,34,99,111,108,80,111,115,105,116,105,111,110,34,58,50,44,34,114,111,119,80,111,115,105,116,105,111,110,34,58,49,125,161,146,175,139,236,2,11,1,168,146,175,139,236,2,7,1,119,68,123,34,99,111,108,80,111,115,105,116,105,111,110,34,58,48,44,34,104,101,105,103,104,116,34,58,52,54,46,48,44,34,114,111,119,80,111,115,105,116,105,111,110,34,58,50,44,34,119,105,100,116,104,34,58,49,53,48,46,56,53,53,52,54,56,55,53,125,168,146,175,139,236,2,8,1,119,68,123,34,104,101,105,103,104,116,34,58,52,54,46,48,44,34,114,111,119,80,111,115,105,116,105,111,110,34,58,50,44,34,119,105,100,116,104,34,58,49,48,53,46,48,53,48,55,56,49,50,53,44,34,99,111,108,80,111,115,105,116,105,111,110,34,58,49,125,168,146,175,139,236,2,9,1,119,60,123,34,119,105,100,116,104,34,58,56,48,46,48,44,34,114,111,119,80,111,115,105,116,105,111,110,34,58,50,44,34,104,101,105,103,104,116,34,58,52,54,46,48,44,34,99,111,108,80,111,115,105,116,105,111,110,34,58,50,125,161,180,189,170,253,8,6,1,168,180,189,170,253,8,10,1,119,114,123,34,99,111,108,77,105,110,105,109,117,109,87,105,100,116,104,34,58,52,48,46,48,44,34,114,111,119,68,101,102,97,117,108,116,72,101,105,103,104,116,34,58,52,48,46,48,44,34,99,111,108,115,76,101,110,34,58,51,44,34,114,111,119,115,76,101,110,34,58,51,44,34,99,111,108,68,101,102,97,117,108,116,87,105,100,116,104,34,58,56,48,46,48,44,34,99,111,108,115,72,101,105,103,104,116,34,58,49,52,54,46,48,125,21,192,187,174,206,8,0,39,0,204,195,206,156,1,4,6,72,101,110,107,82,107,2,161,150,216,171,142,3,187,8,1,1,0,192,187,174,206,8,0,3,129,192,187,174,206,8,4,15,129,192,187,174,206,8,19,45,129,192,187,174,206,8,64,10,134,192,187,174,206,8,74,11,102,111,110,116,95,102,97,109,105,108,121,4,110,117,108,108,129,192,187,174,206,8,75,110,161,192,187,174,206,8,1,1,65,192,187,174,206,8,2,5,193,192,187,174,206,8,4,192,187,174,206,8,5,14,193,192,187,174,206,8,19,192,187,174,206,8,20,44,193,192,187,174,206,8,64,192,187,174,206,8,65,9,193,192,187,174,206,8,74,192,187,174,206,8,75,110,161,192,187,174,206,8,186,1,2,193,192,187,174,206,8,240,2,192,187,174,206,8,75,180,1,161,192,187,174,206,8,242,2,1,70,192,187,174,206,8,187,1,11,102,111,110,116,95,102,97,109,105,108,121,15,34,65,68,76,97,77,32,68,105,115,112,108,97,121,34,196,192,187,174,206,8,168,4,192,187,174,206,8,187,1,180,1,108,111,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,76,101,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,198,192,187,174,206,8,220,5,192,187,174,206,8,187,1,11,102,111,110,116,95,102,97,109,105,108,121,4,110,117,108,108,161,192,187,174,206,8,167,4,1,11,227,211,144,195,8,0,161,243,138,171,183,10,240,1,1,161,243,138,171,183,10,241,1,1,161,243,138,171,183,10,242,1,1,161,194,228,144,71,4,1,161,194,228,144,71,5,1,161,194,228,144,71,6,1,161,220,225,223,240,3,34,1,161,220,225,223,240,3,30,1,161,220,225,223,240,3,31,1,161,220,225,223,240,3,32,1,161,227,211,144,195,8,6,2,9,135,182,134,178,8,0,168,141,151,160,163,4,21,1,119,147,2,123,34,99,111,118,101,114,95,115,101,108,101,99,116,105,111,110,95,116,121,112,101,34,58,34,67,111,118,101,114,84,121,112,101,46,102,105,108,101,34,44,34,99,111,118,101,114,95,115,101,108,101,99,116,105,111,110,34,58,34,104,116,116,112,115,58,47,47,105,109,97,103,101,115,46,117,110,115,112,108,97,115,104,46,99,111,109,47,112,104,111,116,111,45,49,55,49,52,53,48,56,56,54,50,55,56,56,45,52,52,101,52,53,99,52,51,49,53,100,48,63,99,114,111,112,61,101,110,116,114,111,112,121,38,99,115,61,116,105,110,121,115,114,103,98,38,102,105,116,61,109,97,120,38,102,109,61,106,112,103,38,105,120,105,100,61,77,51,119,49,77,84,69,49,77,122,100,56,77,72,119,120,102,72,74,104,98,109,82,118,98,88,120,56,102,72,120,56,102,72,120,56,102,68,69,51,77,84,89,51,78,122,103,121,77,84,108,56,38,105,120,108,105,98,61,114,98,45,52,46,48,46,51,38,113,61,56,48,38,119,61,49,48,56,48,34,44,34,105,109,97,103,101,95,116,121,112,101,34,58,34,49,34,44,34,100,101,108,116,97,34,58,91,93,125,168,141,151,160,163,4,22,1,119,10,112,70,113,76,55,45,79,83,121,86,168,141,151,160,163,4,23,1,119,4,116,101,120,116,70,204,195,206,156,1,209,5,11,102,111,110,116,95,102,97,109,105,108,121,15,34,65,68,76,97,32,77,68,105,115,112,108,97,121,34,196,135,182,134,178,8,3,204,195,206,156,1,209,5,55,67,108,105,99,107,32,97,110,121,119,104,101,114,101,32,97,110,100,32,106,117,115,116,32,115,116,97,114,116,32,116,121,112,105,110,103,229,147,136,229,147,136,229,147,136,46,229,176,177,229,135,160,229,174,182,198,135,182,134,178,8,46,204,195,206,156,1,209,5,11,102,111,110,116,95,102,97,109,105,108,121,4,110,117,108,108,168,140,167,201,161,14,7,1,119,141,1,123,34,100,101,108,116,97,34,58,91,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,102,111,110,116,95,102,97,109,105,108,121,34,58,34,65,68,76,97,32,77,68,105,115,112,108,97,121,34,125,44,34,105,110,115,101,114,116,34,58,34,67,108,105,99,107,32,97,110,121,119,104,101,114,101,32,97,110,100,32,106,117,115,116,32,115,116,97,114,116,32,116,121,112,105,110,103,229,147,136,229,147,136,229,147,136,46,229,176,177,229,135,160,229,174,182,34,125,93,44,34,99,104,101,99,107,101,100,34,58,102,97,108,115,101,125,168,140,167,201,161,14,8,1,119,10,119,79,108,117,99,85,55,51,73,76,168,140,167,201,161,14,9,1,119,4,116,101,120,116,1,240,179,157,219,7,0,161,174,203,157,214,7,5,4,1,174,203,157,214,7,0,161,153,236,182,220,1,3,6,1,145,224,235,133,7,0,161,223,215,172,155,15,4,3,1,241,147,239,232,6,0,161,245,181,155,135,2,22,4,1,150,152,188,203,6,0,161,192,246,139,213,2,34,20,2,181,156,253,158,6,0,161,198,223,206,159,1,175,1,4,168,181,156,253,158,6,3,1,119,231,1,123,34,117,114,108,34,58,34,104,116,116,112,115,58,47,47,105,109,97,103,101,115,46,117,110,115,112,108,97,115,104,46,99,111,109,47,112,104,111,116,111,45,49,55,49,50,51,48,51,55,48,48,56,51,50,45,53,55,100,50,98,50,98,57,49,54,98,56,63,99,114,111,112,61,101,110,116,114,111,112,121,38,99,115,61,116,105,110,121,115,114,103,98,38,102,105,116,61,109,97,120,38,102,109,61,106,112,103,38,105,120,105,100,61,77,51,119,49,77,84,69,49,77,122,100,56,77,72,119,120,102,72,74,104,98,109,82,118,98,88,120,56,102,72,120,56,102,72,120,56,102,68,69,51,77,84,77,121,78,84,107,122,78,84,100,56,38,105,120,108,105,98,61,114,98,45,52,46,48,46,51,38,113,61,56,48,38,119,61,49,48,56,48,34,44,34,119,105,100,116,104,34,58,52,50,56,46,49,57,53,51,49,50,53,44,34,97,108,105,103,110,34,58,34,114,105,103,104,116,34,125,220,2,171,236,222,251,5,0,198,204,195,206,156,1,205,4,204,195,206,156,1,206,4,4,98,111,108,100,4,116,114,117,101,196,171,236,222,251,5,0,204,195,206,156,1,206,4,4,112,97,103,101,198,171,236,222,251,5,4,204,195,206,156,1,206,4,4,98,111,108,100,4,110,117,108,108,168,204,195,206,156,1,119,1,119,222,1,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,67,108,105,99,107,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,43,32,78,101,119,32,80,97,103,101,32,34,125,44,123,34,105,110,115,101,114,116,34,58,34,98,117,116,116,111,110,32,97,116,32,116,104,101,32,98,111,116,116,111,109,32,111,102,32,121,111,117,114,32,115,105,100,101,98,97,114,32,116,111,32,97,100,100,32,97,32,110,101,119,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,98,111,108,100,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,112,97,103,101,34,125,44,123,34,105,110,115,101,114,116,34,58,34,46,34,125,93,44,34,99,104,101,99,107,101,100,34,58,116,114,117,101,125,168,204,195,206,156,1,120,1,119,10,122,77,121,109,67,97,118,83,107,102,168,204,195,206,156,1,121,1,119,4,116,101,120,116,193,204,195,206,156,1,129,6,204,195,206,156,1,130,6,5,198,171,236,222,251,5,13,204,195,206,156,1,130,6,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,161,204,195,206,156,1,11,1,161,204,195,206,156,1,12,1,161,204,195,206,156,1,13,1,161,171,236,222,251,5,15,1,161,171,236,222,251,5,16,1,161,171,236,222,251,5,17,1,193,204,195,206,156,1,129,6,171,236,222,251,5,9,5,198,171,236,222,251,5,25,171,236,222,251,5,9,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,18,1,161,171,236,222,251,5,19,1,161,171,236,222,251,5,20,1,193,204,195,206,156,1,129,6,171,236,222,251,5,21,5,198,171,236,222,251,5,34,171,236,222,251,5,21,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,27,1,161,171,236,222,251,5,28,1,161,171,236,222,251,5,29,1,198,204,195,206,156,1,129,6,171,236,222,251,5,30,10,102,111,110,116,95,99,111,108,111,114,12,34,48,120,102,102,100,98,51,54,51,54,34,193,171,236,222,251,5,39,171,236,222,251,5,30,4,198,171,236,222,251,5,43,171,236,222,251,5,30,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,36,1,161,171,236,222,251,5,37,1,161,171,236,222,251,5,38,1,193,171,236,222,251,5,39,171,236,222,251,5,40,5,198,171,236,222,251,5,52,171,236,222,251,5,40,8,98,103,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,45,1,161,171,236,222,251,5,46,1,161,171,236,222,251,5,47,1,193,171,236,222,251,5,39,171,236,222,251,5,48,5,198,171,236,222,251,5,61,171,236,222,251,5,48,8,98,103,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,54,1,161,171,236,222,251,5,55,1,161,171,236,222,251,5,56,1,198,171,236,222,251,5,39,171,236,222,251,5,57,8,98,103,95,99,111,108,111,114,12,34,48,120,102,102,102,102,100,97,101,54,34,196,171,236,222,251,5,66,171,236,222,251,5,57,4,110,101,120,116,198,171,236,222,251,5,70,171,236,222,251,5,57,8,98,103,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,63,1,161,171,236,222,251,5,64,1,161,171,236,222,251,5,65,1,198,204,195,206,156,1,180,6,204,195,206,156,1,181,6,9,117,110,100,101,114,108,105,110,101,4,116,114,117,101,193,171,236,222,251,5,75,204,195,206,156,1,181,6,3,198,171,236,222,251,5,78,204,195,206,156,1,181,6,9,117,110,100,101,114,108,105,110,101,4,110,117,108,108,161,171,236,222,251,5,72,1,161,171,236,222,251,5,73,1,161,171,236,222,251,5,74,1,198,171,236,222,251,5,75,171,236,222,251,5,76,13,115,116,114,105,107,101,116,104,114,111,117,103,104,4,116,114,117,101,193,171,236,222,251,5,83,171,236,222,251,5,76,3,198,171,236,222,251,5,86,171,236,222,251,5,76,13,115,116,114,105,107,101,116,104,114,111,117,103,104,4,110,117,108,108,161,171,236,222,251,5,80,1,161,171,236,222,251,5,81,1,161,171,236,222,251,5,82,1,198,171,236,222,251,5,83,171,236,222,251,5,84,6,105,116,97,108,105,99,4,116,114,117,101,193,171,236,222,251,5,91,171,236,222,251,5,84,3,198,171,236,222,251,5,94,171,236,222,251,5,84,6,105,116,97,108,105,99,4,110,117,108,108,161,171,236,222,251,5,88,1,161,171,236,222,251,5,89,1,161,171,236,222,251,5,90,1,196,171,236,222,251,5,94,171,236,222,251,5,95,9,230,140,168,233,161,191,230,137,147,161,171,236,222,251,5,96,1,161,171,236,222,251,5,97,1,161,171,236,222,251,5,98,1,39,0,204,195,206,156,1,4,6,68,89,98,118,73,66,2,33,0,204,195,206,156,1,1,6,109,57,74,107,49,75,1,0,7,33,0,204,195,206,156,1,3,6,78,76,50,70,103,95,1,193,204,195,206,156,1,238,1,204,195,206,156,1,239,1,1,168,171,236,222,251,5,102,1,119,204,5,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,67,108,105,99,107,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,43,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,98,103,95,99,111,108,111,114,34,58,34,48,120,102,102,102,102,100,97,101,54,34,44,34,102,111,110,116,95,99,111,108,111,114,34,58,34,48,120,102,102,100,98,51,54,51,54,34,125,44,34,105,110,115,101,114,116,34,58,34,110,101,120,116,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,116,111,32,97,110,121,32,112,97,103,101,32,116,105,116,108,101,32,105,110,32,116,104,101,32,115,105,100,101,98,97,114,32,116,111,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,102,111,110,116,95,99,111,108,111,114,34,58,34,48,120,102,102,56,52,50,55,101,48,34,125,44,34,105,110,115,101,114,116,34,58,34,113,117,105,99,107,108,121,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,105,116,97,108,105,99,34,58,116,114,117,101,44,34,115,116,114,105,107,101,116,104,114,111,117,103,104,34,58,116,114,117,101,44,34,117,110,100,101,114,108,105,110,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,230,140,168,233,161,191,230,137,147,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,97,32,110,101,119,32,115,117,98,112,97,103,101,44,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,68,111,99,117,109,101,110,116,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,102,97,108,115,101,125,44,34,105,110,115,101,114,116,34,58,34,44,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,71,114,105,100,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,102,97,108,115,101,125,44,34,105,110,115,101,114,116,34,58,34,44,32,111,114,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,75,97,110,98,97,110,32,66,111,97,114,100,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,102,97,108,115,101,125,44,34,105,110,115,101,114,116,34,58,34,46,34,125,93,44,34,99,104,101,99,107,101,100,34,58,102,97,108,115,101,125,168,171,236,222,251,5,103,1,119,10,98,113,76,109,98,57,111,45,109,109,168,171,236,222,251,5,104,1,119,4,116,101,120,116,39,0,204,195,206,156,1,4,6,68,53,45,65,82,65,2,33,0,204,195,206,156,1,1,6,89,87,119,55,87,53,1,0,7,33,0,204,195,206,156,1,3,6,85,68,79,77,112,98,1,1,0,204,195,206,156,1,14,1,39,0,204,195,206,156,1,4,6,107,90,52,97,119,69,2,33,0,204,195,206,156,1,1,6,108,79,120,55,95,83,1,0,7,33,0,204,195,206,156,1,3,6,50,109,115,116,117,104,1,193,171,236,222,251,5,115,204,195,206,156,1,239,1,1,39,0,204,195,206,156,1,4,6,52,98,104,66,88,113,2,33,0,204,195,206,156,1,1,6,121,105,115,116,115,72,1,0,7,33,0,204,195,206,156,1,3,6,53,67,71,119,50,122,1,129,171,236,222,251,5,129,1,1,39,0,204,195,206,156,1,4,6,87,105,113,49,48,95,2,33,0,204,195,206,156,1,1,6,71,121,98,79,49,81,1,0,7,33,0,204,195,206,156,1,3,6,66,90,69,117,90,106,1,193,171,236,222,251,5,129,1,171,236,222,251,5,151,1,1,39,0,204,195,206,156,1,4,6,106,101,65,53,90,85,2,39,0,204,195,206,156,1,1,6,102,67,65,65,81,117,1,40,0,171,236,222,251,5,164,1,2,105,100,1,119,6,102,67,65,65,81,117,40,0,171,236,222,251,5,164,1,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,171,236,222,251,5,164,1,6,112,97,114,101,110,116,1,119,10,77,48,104,84,99,67,120,66,88,82,40,0,171,236,222,251,5,164,1,8,99,104,105,108,100,114,101,110,1,119,6,52,74,88,112,120,108,33,0,171,236,222,251,5,164,1,4,100,97,116,97,1,40,0,171,236,222,251,5,164,1,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,171,236,222,251,5,164,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,52,74,88,112,120,108,0,200,171,236,222,251,5,129,1,171,236,222,251,5,162,1,1,119,6,102,67,65,65,81,117,4,0,171,236,222,251,5,163,1,6,228,189,147,233,170,140,129,171,236,222,251,5,175,1,1,161,171,236,222,251,5,169,1,1,198,171,236,222,251,5,175,1,171,236,222,251,5,176,1,9,117,110,100,101,114,108,105,110,101,4,116,114,117,101,193,171,236,222,251,5,178,1,171,236,222,251,5,176,1,1,198,171,236,222,251,5,179,1,171,236,222,251,5,176,1,9,117,110,100,101,114,108,105,110,101,4,110,117,108,108,161,171,236,222,251,5,177,1,1,198,171,236,222,251,5,178,1,171,236,222,251,5,179,1,13,115,116,114,105,107,101,116,104,114,111,117,103,104,4,116,114,117,101,196,171,236,222,251,5,182,1,171,236,222,251,5,179,1,3,228,184,128,198,171,236,222,251,5,183,1,171,236,222,251,5,179,1,13,115,116,114,105,107,101,116,104,114,111,117,103,104,4,110,117,108,108,168,171,236,222,251,5,181,1,1,119,101,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,228,189,147,233,170,140,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,115,116,114,105,107,101,116,104,114,111,117,103,104,34,58,116,114,117,101,44,34,117,110,100,101,114,108,105,110,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,228,184,128,34,125,93,125,39,0,204,195,206,156,1,4,6,82,74,108,100,72,67,2,33,0,204,195,206,156,1,1,6,53,106,100,78,87,117,1,0,7,33,0,204,195,206,156,1,3,6,67,106,107,76,57,108,1,129,171,236,222,251,5,151,1,1,4,0,171,236,222,251,5,186,1,4,103,104,104,104,0,1,39,0,204,195,206,156,1,4,6,70,117,117,52,113,66,2,4,0,171,236,222,251,5,202,1,4,103,104,104,104,33,0,204,195,206,156,1,1,6,121,112,70,119,50,69,1,0,7,33,0,204,195,206,156,1,3,6,71,77,54,72,55,119,1,193,171,236,222,251,5,151,1,171,236,222,251,5,196,1,1,39,0,204,195,206,156,1,4,6,118,113,76,104,110,70,2,4,0,171,236,222,251,5,217,1,4,103,104,104,104,33,0,204,195,206,156,1,1,6,73,57,73,75,116,115,1,0,7,33,0,204,195,206,156,1,3,6,77,114,102,81,106,87,1,193,171,236,222,251,5,140,1,204,195,206,156,1,239,1,1,39,0,204,195,206,156,1,4,6,89,50,52,104,77,55,2,4,0,171,236,222,251,5,232,1,4,103,104,104,104,33,0,204,195,206,156,1,1,6,107,110,74,87,104,48,1,0,7,33,0,204,195,206,156,1,3,6,55,99,67,68,120,77,1,129,171,236,222,251,5,196,1,1,0,7,39,0,204,195,206,156,1,4,6,109,98,56,104,95,45,2,4,0,171,236,222,251,5,254,1,4,103,104,104,104,33,0,204,195,206,156,1,1,6,103,75,73,116,90,101,1,0,7,33,0,204,195,206,156,1,3,6,118,115,54,50,82,105,1,193,171,236,222,251,5,196,1,171,236,222,251,5,246,1,1,39,0,204,195,206,156,1,4,6,105,67,98,56,102,56,2,4,0,171,236,222,251,5,141,2,4,103,104,104,104,33,0,204,195,206,156,1,1,6,88,113,72,53,122,82,1,0,7,33,0,204,195,206,156,1,3,6,73,111,48,108,119,67,1,193,171,236,222,251,5,196,1,171,236,222,251,5,140,2,1,39,0,204,195,206,156,1,4,6,82,89,116,67,111,86,2,4,0,171,236,222,251,5,156,2,4,103,104,104,104,39,0,204,195,206,156,1,1,6,49,120,78,111,50,76,1,40,0,171,236,222,251,5,161,2,2,105,100,1,119,6,49,120,78,111,50,76,40,0,171,236,222,251,5,161,2,2,116,121,1,119,5,113,117,111,116,101,40,0,171,236,222,251,5,161,2,6,112,97,114,101,110,116,1,119,10,77,48,104,84,99,67,120,66,88,82,40,0,171,236,222,251,5,161,2,8,99,104,105,108,100,114,101,110,1,119,6,67,85,68,115,45,70,33,0,171,236,222,251,5,161,2,4,100,97,116,97,1,40,0,171,236,222,251,5,161,2,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,171,236,222,251,5,161,2,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,67,85,68,115,45,70,0,200,171,236,222,251,5,196,1,171,236,222,251,5,155,2,1,119,6,49,120,78,111,50,76,39,0,204,195,206,156,1,4,6,122,112,90,112,49,101,2,33,0,204,195,206,156,1,1,6,109,65,112,48,84,75,1,0,7,33,0,204,195,206,156,1,3,6,117,95,113,85,121,95,1,129,171,236,222,251,5,246,1,1,4,0,171,236,222,251,5,171,2,4,104,106,106,106,0,1,39,0,204,195,206,156,1,4,6,79,88,120,110,65,84,2,4,0,171,236,222,251,5,187,2,4,104,106,106,106,33,0,204,195,206,156,1,1,6,109,70,99,75,110,81,1,0,7,33,0,204,195,206,156,1,3,6,72,52,122,104,56,95,1,193,171,236,222,251,5,246,1,171,236,222,251,5,181,2,1,39,0,204,195,206,156,1,4,6,90,97,54,99,87,113,2,4,0,171,236,222,251,5,202,2,4,104,106,106,106,33,0,204,195,206,156,1,1,6,50,110,113,71,98,75,1,0,7,33,0,204,195,206,156,1,3,6,95,69,69,76,53,109,1,193,171,236,222,251,5,246,1,171,236,222,251,5,201,2,1,39,0,204,195,206,156,1,4,6,95,88,107,52,56,116,2,4,0,171,236,222,251,5,217,2,4,104,106,106,106,33,0,204,195,206,156,1,1,6,68,99,97,85,109,79,1,0,7,33,0,204,195,206,156,1,3,6,120,69,54,111,108,48,1,193,171,236,222,251,5,231,1,204,195,206,156,1,239,1,1,39,0,204,195,206,156,1,4,6,82,106,87,98,56,111,2,4,0,171,236,222,251,5,232,2,4,104,106,106,106,33,0,204,195,206,156,1,1,6,87,98,82,107,87,69,1,0,7,33,0,204,195,206,156,1,3,6,89,103,114,112,81,55,1,129,171,236,222,251,5,181,2,1,39,0,204,195,206,156,1,4,6,112,107,78,57,112,83,2,4,0,171,236,222,251,5,247,2,4,104,106,106,106,33,0,204,195,206,156,1,1,6,79,98,74,118,76,57,1,0,7,33,0,204,195,206,156,1,3,6,97,79,100,49,121,82,1,193,171,236,222,251,5,181,2,171,236,222,251,5,246,2,1,39,0,204,195,206,156,1,4,6,99,51,100,115,103,56,2,4,0,171,236,222,251,5,134,3,4,104,106,106,106,33,0,204,195,206,156,1,1,6,109,119,79,85,85,87,1,0,7,33,0,204,195,206,156,1,3,6,98,70,74,53,121,122,1,193,171,236,222,251,5,181,2,171,236,222,251,5,133,3,1,39,0,204,195,206,156,1,4,6,104,82,53,106,71,79,2,1,0,171,236,222,251,5,149,3,4,33,0,204,195,206,156,1,1,6,73,77,51,71,72,50,1,0,7,33,0,204,195,206,156,1,3,6,119,85,111,112,97,102,1,193,171,236,222,251,5,181,2,171,236,222,251,5,148,3,1,0,4,39,0,204,195,206,156,1,4,6,74,66,108,114,84,51,2,33,0,204,195,206,156,1,1,6,76,105,86,56,56,104,1,0,7,33,0,204,195,206,156,1,3,6,112,106,107,107,53,97,1,193,171,236,222,251,5,181,2,171,236,222,251,5,163,3,1,1,0,171,236,222,251,5,168,3,1,0,2,129,171,236,222,251,5,179,3,1,0,1,196,171,236,222,251,5,179,3,171,236,222,251,5,182,3,3,226,128,148,0,1,39,0,204,195,206,156,1,4,6,89,108,67,77,99,111,2,39,0,204,195,206,156,1,1,6,112,69,80,105,117,115,1,40,0,171,236,222,251,5,187,3,2,105,100,1,119,6,112,69,80,105,117,115,40,0,171,236,222,251,5,187,3,2,116,121,1,119,7,100,105,118,105,100,101,114,40,0,171,236,222,251,5,187,3,6,112,97,114,101,110,116,1,119,10,77,48,104,84,99,67,120,66,88,82,40,0,171,236,222,251,5,187,3,8,99,104,105,108,100,114,101,110,1,119,6,49,45,49,107,111,85,40,0,171,236,222,251,5,187,3,4,100,97,116,97,1,119,2,123,125,40,0,171,236,222,251,5,187,3,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,171,236,222,251,5,187,3,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,49,45,49,107,111,85,0,200,171,236,222,251,5,181,2,171,236,222,251,5,178,3,1,119,6,112,69,80,105,117,115,39,0,204,195,206,156,1,1,6,95,65,78,99,110,51,1,40,0,171,236,222,251,5,197,3,2,105,100,1,119,6,95,65,78,99,110,51,40,0,171,236,222,251,5,197,3,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,171,236,222,251,5,197,3,6,112,97,114,101,110,116,1,119,10,77,48,104,84,99,67,120,66,88,82,40,0,171,236,222,251,5,197,3,8,99,104,105,108,100,114,101,110,1,119,6,84,45,117,87,109,83,33,0,171,236,222,251,5,197,3,4,100,97,116,97,1,40,0,171,236,222,251,5,197,3,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,171,236,222,251,5,197,3,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,84,45,117,87,109,83,0,136,171,236,222,251,5,246,2,1,119,6,95,65,78,99,110,51,4,0,171,236,222,251,5,186,3,1,54,161,171,236,222,251,5,202,3,1,132,171,236,222,251,5,207,3,1,54,161,171,236,222,251,5,208,3,1,132,171,236,222,251,5,209,3,1,54,161,171,236,222,251,5,210,3,1,132,171,236,222,251,5,211,3,1,57,168,171,236,222,251,5,212,3,1,119,29,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,54,54,54,57,34,125,93,125,39,0,204,195,206,156,1,1,6,79,77,79,95,52,106,1,40,0,171,236,222,251,5,215,3,2,105,100,1,119,6,79,77,79,95,52,106,40,0,171,236,222,251,5,215,3,2,116,121,1,119,7,100,105,118,105,100,101,114,40,0,171,236,222,251,5,215,3,6,112,97,114,101,110,116,1,119,10,77,48,104,84,99,67,120,66,88,82,40,0,171,236,222,251,5,215,3,8,99,104,105,108,100,114,101,110,1,119,6,99,107,78,65,119,105,40,0,171,236,222,251,5,215,3,4,100,97,116,97,1,119,2,123,125,40,0,171,236,222,251,5,215,3,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,171,236,222,251,5,215,3,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,99,107,78,65,119,105,0,200,171,236,222,251,5,246,2,171,236,222,251,5,206,3,1,119,6,79,77,79,95,52,106,39,0,204,195,206,156,1,4,6,45,80,118,95,90,87,2,4,0,171,236,222,251,5,225,3,4,103,104,104,104,39,0,204,195,206,156,1,4,6,87,105,54,69,77,70,2,4,0,171,236,222,251,5,230,3,4,54,54,54,57,39,0,204,195,206,156,1,1,6,122,78,116,118,66,84,1,40,0,171,236,222,251,5,235,3,2,105,100,1,119,6,122,78,116,118,66,84,40,0,171,236,222,251,5,235,3,2,116,121,1,119,5,113,117,111,116,101,40,0,171,236,222,251,5,235,3,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,171,236,222,251,5,235,3,8,99,104,105,108,100,114,101,110,1,119,6,105,85,75,111,98,79,33,0,171,236,222,251,5,235,3,4,100,97,116,97,1,40,0,171,236,222,251,5,235,3,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,171,236,222,251,5,235,3,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,105,85,75,111,98,79,0,200,171,236,222,251,5,231,2,204,195,206,156,1,239,1,1,119,6,122,78,116,118,66,84,33,0,204,195,206,156,1,1,6,57,104,76,90,119,104,1,0,7,33,0,204,195,206,156,1,3,6,120,113,118,100,85,73,1,193,171,236,222,251,5,244,3,204,195,206,156,1,239,1,1,39,0,204,195,206,156,1,4,6,75,122,88,45,65,53,2,1,0,171,236,222,251,5,255,3,4,39,0,204,195,206,156,1,1,6,49,51,100,105,49,69,1,40,0,171,236,222,251,5,132,4,2,105,100,1,119,6,49,51,100,105,49,69,40,0,171,236,222,251,5,132,4,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,171,236,222,251,5,132,4,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,171,236,222,251,5,132,4,8,99,104,105,108,100,114,101,110,1,119,6,106,54,115,52,103,109,33,0,171,236,222,251,5,132,4,4,100,97,116,97,1,40,0,171,236,222,251,5,132,4,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,171,236,222,251,5,132,4,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,106,54,115,52,103,109,0,200,171,236,222,251,5,244,3,171,236,222,251,5,254,3,1,119,6,49,51,100,105,49,69,161,171,236,222,251,5,137,4,2,65,171,236,222,251,5,128,4,5,198,171,236,222,251,5,148,4,171,236,222,251,5,128,4,8,98,103,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,143,4,1,65,171,236,222,251,5,144,4,5,198,171,236,222,251,5,155,4,171,236,222,251,5,144,4,8,98,103,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,150,4,1,65,171,236,222,251,5,151,4,5,198,171,236,222,251,5,162,4,171,236,222,251,5,151,4,8,98,103,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,157,4,1,65,171,236,222,251,5,158,4,5,198,171,236,222,251,5,169,4,171,236,222,251,5,158,4,8,98,103,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,164,4,1,65,171,236,222,251,5,165,4,5,198,171,236,222,251,5,176,4,171,236,222,251,5,165,4,8,98,103,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,171,4,1,65,171,236,222,251,5,172,4,5,198,171,236,222,251,5,183,4,171,236,222,251,5,172,4,8,98,103,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,178,4,1,65,171,236,222,251,5,179,4,5,198,171,236,222,251,5,190,4,171,236,222,251,5,179,4,8,98,103,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,185,4,1,65,171,236,222,251,5,186,4,5,198,171,236,222,251,5,197,4,171,236,222,251,5,186,4,8,98,103,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,192,4,1,70,171,236,222,251,5,193,4,8,98,103,95,99,111,108,111,114,12,34,48,120,102,102,102,102,100,97,101,54,34,193,171,236,222,251,5,200,4,171,236,222,251,5,193,4,4,198,171,236,222,251,5,204,4,171,236,222,251,5,193,4,8,98,103,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,199,4,2,193,171,236,222,251,5,200,4,171,236,222,251,5,201,4,5,198,171,236,222,251,5,212,4,171,236,222,251,5,201,4,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,207,4,1,193,171,236,222,251,5,200,4,171,236,222,251,5,208,4,5,198,171,236,222,251,5,219,4,171,236,222,251,5,208,4,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,214,4,1,193,171,236,222,251,5,200,4,171,236,222,251,5,215,4,5,198,171,236,222,251,5,226,4,171,236,222,251,5,215,4,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,221,4,1,193,171,236,222,251,5,200,4,171,236,222,251,5,222,4,5,198,171,236,222,251,5,233,4,171,236,222,251,5,222,4,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,228,4,1,193,171,236,222,251,5,200,4,171,236,222,251,5,229,4,5,198,171,236,222,251,5,240,4,171,236,222,251,5,229,4,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,161,171,236,222,251,5,235,4,1,70,171,236,222,251,5,200,4,10,102,111,110,116,95,99,111,108,111,114,12,34,48,120,102,102,101,97,56,102,48,54,34,198,171,236,222,251,5,243,4,171,236,222,251,5,200,4,8,98,103,95,99,111,108,111,114,12,34,48,120,102,102,97,55,100,102,52,97,34,196,171,236,222,251,5,244,4,171,236,222,251,5,200,4,4,54,54,54,57,193,171,236,222,251,5,248,4,171,236,222,251,5,200,4,1,198,171,236,222,251,5,249,4,171,236,222,251,5,200,4,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,168,171,236,222,251,5,242,4,1,119,110,123,34,99,104,101,99,107,101,100,34,58,102,97,108,115,101,44,34,100,101,108,116,97,34,58,91,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,98,103,95,99,111,108,111,114,34,58,34,48,120,102,102,97,55,100,102,52,97,34,44,34,102,111,110,116,95,99,111,108,111,114,34,58,34,48,120,102,102,101,97,56,102,48,54,34,125,44,34,105,110,115,101,114,116,34,58,34,54,54,54,57,34,125,93,125,7,226,167,254,250,5,0,39,0,204,195,206,156,1,4,6,72,97,87,55,95,104,2,4,0,226,167,254,250,5,0,13,229,144,140,228,184,128,228,184,170,106,106,106,56,161,198,223,206,159,1,124,1,132,226,167,254,250,5,7,1,56,161,226,167,254,250,5,8,1,132,226,167,254,250,5,9,1,56,161,226,167,254,250,5,10,1,1,161,234,157,145,5,0,161,247,212,219,208,10,45,7,3,177,239,218,225,4,0,161,207,231,154,196,9,0,1,161,207,231,154,196,9,1,1,161,207,231,154,196,9,2,1,1,136,172,186,168,4,0,161,176,238,158,139,14,174,2,182,6,24,141,151,160,163,4,0,161,177,239,218,225,4,0,1,161,177,239,218,225,4,1,1,161,177,239,218,225,4,2,1,161,141,151,160,163,4,0,1,161,141,151,160,163,4,1,1,161,141,151,160,163,4,2,1,161,141,151,160,163,4,3,1,161,141,151,160,163,4,4,1,161,141,151,160,163,4,5,1,161,141,151,160,163,4,6,1,161,141,151,160,163,4,7,1,161,141,151,160,163,4,8,1,161,141,151,160,163,4,9,1,161,141,151,160,163,4,10,1,161,141,151,160,163,4,11,1,161,141,151,160,163,4,12,1,161,141,151,160,163,4,13,1,161,141,151,160,163,4,14,1,161,141,151,160,163,4,15,1,161,141,151,160,163,4,16,1,161,141,151,160,163,4,17,1,161,141,151,160,163,4,18,1,161,141,151,160,163,4,19,1,161,141,151,160,163,4,20,1,4,217,168,198,159,4,0,129,204,195,206,156,1,154,7,4,161,204,195,206,156,1,227,1,1,161,204,195,206,156,1,228,1,1,161,204,195,206,156,1,229,1,1,51,220,225,223,240,3,0,1,0,204,195,206,156,1,132,4,1,161,204,195,206,156,1,83,1,161,204,195,206,156,1,84,1,161,204,195,206,156,1,85,1,134,220,225,223,240,3,0,7,109,101,110,116,105,111,110,64,123,34,112,97,103,101,95,105,100,34,58,34,100,48,52,57,54,51,50,52,45,53,53,55,48,45,52,48,48,54,45,98,52,101,97,45,100,98,55,53,49,54,100,50,49,50,102,100,34,44,34,116,121,112,101,34,58,34,112,97,103,101,34,125,132,220,225,223,240,3,4,1,36,134,220,225,223,240,3,5,7,109,101,110,116,105,111,110,4,110,117,108,108,161,220,225,223,240,3,1,1,161,220,225,223,240,3,2,1,161,220,225,223,240,3,3,1,39,0,204,195,206,156,1,4,6,50,108,103,80,119,50,2,33,0,204,195,206,156,1,1,6,115,120,112,66,109,100,1,0,7,33,0,204,195,206,156,1,3,6,52,97,66,55,99,78,1,193,204,195,206,156,1,250,1,204,195,206,156,1,251,1,1,1,0,220,225,223,240,3,10,1,0,2,129,220,225,223,240,3,21,1,0,2,161,243,138,171,183,10,249,1,1,161,243,138,171,183,10,250,1,1,161,243,138,171,183,10,251,1,1,161,220,225,223,240,3,27,1,161,220,225,223,240,3,28,1,161,220,225,223,240,3,29,1,161,194,228,144,71,7,2,39,0,204,195,206,156,1,4,6,48,102,55,71,122,95,2,39,0,204,195,206,156,1,1,6,54,118,84,69,79,115,1,40,0,220,225,223,240,3,36,2,105,100,1,119,6,54,118,84,69,79,115,40,0,220,225,223,240,3,36,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,220,225,223,240,3,36,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,220,225,223,240,3,36,8,99,104,105,108,100,114,101,110,1,119,6,106,107,73,81,115,57,33,0,220,225,223,240,3,36,4,100,97,116,97,1,40,0,220,225,223,240,3,36,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,220,225,223,240,3,36,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,106,107,73,81,115,57,0,200,220,225,223,240,3,20,204,195,206,156,1,251,1,1,119,6,54,118,84,69,79,115,1,0,220,225,223,240,3,35,1,161,220,225,223,240,3,41,1,134,220,225,223,240,3,46,7,109,101,110,116,105,111,110,64,123,34,112,97,103,101,95,105,100,34,58,34,52,52,51,53,101,53,55,98,45,99,50,54,51,45,52,101,55,102,45,97,52,51,53,45,50,48,56,55,57,97,54,50,101,54,100,97,34,44,34,116,121,112,101,34,58,34,112,97,103,101,34,125,132,220,225,223,240,3,48,1,36,134,220,225,223,240,3,49,7,109,101,110,116,105,111,110,4,110,117,108,108,161,220,225,223,240,3,47,1,39,0,204,195,206,156,1,4,6,111,89,48,54,98,73,2,161,220,225,223,240,3,51,1,1,0,220,225,223,240,3,52,1,161,220,225,223,240,3,53,1,134,220,225,223,240,3,54,7,109,101,110,116,105,111,110,64,123,34,112,97,103,101,95,105,100,34,58,34,100,100,98,57,51,98,97,55,45,48,54,99,55,45,52,49,55,54,45,57,56,50,97,45,100,55,52,50,51,101,48,57,98,52,52,49,34,44,34,116,121,112,101,34,58,34,112,97,103,101,34,125,132,220,225,223,240,3,56,1,36,134,220,225,223,240,3,57,7,109,101,110,116,105,111,110,4,110,117,108,108,161,220,225,223,240,3,55,1,29,133,181,204,218,3,0,39,0,204,195,206,156,1,4,6,118,122,72,105,108,97,2,6,0,133,181,204,218,3,0,4,104,114,101,102,13,34,49,57,50,46,49,54,56,46,49,46,50,34,132,133,181,204,218,3,1,9,99,111,110,116,101,110,116,32,49,134,133,181,204,218,3,10,4,104,114,101,102,4,110,117,108,108,132,133,181,204,218,3,11,3,32,50,32,161,238,153,239,204,9,48,1,132,133,181,204,218,3,14,1,97,161,133,181,204,218,3,15,1,129,133,181,204,218,3,16,1,132,133,181,204,218,3,18,1,112,161,133,181,204,218,3,17,1,196,133,181,204,218,3,16,133,181,204,218,3,18,1,112,161,133,181,204,218,3,20,1,132,133,181,204,218,3,19,1,102,161,133,181,204,218,3,22,1,132,133,181,204,218,3,23,1,108,161,133,181,204,218,3,24,1,132,133,181,204,218,3,25,1,111,161,133,181,204,218,3,26,1,132,133,181,204,218,3,27,1,119,161,133,181,204,218,3,28,1,132,133,181,204,218,3,29,1,121,168,133,181,204,218,3,30,1,119,95,123,34,100,101,108,116,97,34,58,91,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,104,114,101,102,34,58,34,49,57,50,46,49,54,56,46,49,46,50,34,125,44,34,105,110,115,101,114,116,34,58,34,99,111,110,116,101,110,116,32,49,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,50,32,97,112,112,102,108,111,119,121,34,125,93,125,39,0,204,195,206,156,1,4,6,68,50,72,75,72,71,2,6,0,133,181,204,218,3,33,4,104,114,101,102,22,34,97,112,112,102,108,111,119,121,46,105,111,47,100,111,119,110,108,111,97,100,34,132,133,181,204,218,3,34,11,97,112,112,102,108,111,119,121,46,105,111,134,133,181,204,218,3,45,4,104,114,101,102,4,110,117,108,108,132,133,181,204,218,3,46,2,32,49,168,238,153,239,204,9,32,1,119,97,123,34,100,101,108,116,97,34,58,91,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,104,114,101,102,34,58,34,97,112,112,102,108,111,119,121,46,105,111,47,100,111,119,110,108,111,97,100,34,125,44,34,105,110,115,101,114,116,34,58,34,97,112,112,102,108,111,119,121,46,105,111,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,49,34,125,93,125,1,236,253,128,205,3,0,161,240,179,157,219,7,3,9,215,6,150,216,171,142,3,0,161,199,130,209,189,2,177,3,6,161,164,202,219,213,10,80,1,161,164,202,219,213,10,81,1,161,164,202,219,213,10,82,1,161,150,216,171,142,3,6,1,161,150,216,171,142,3,7,1,161,150,216,171,142,3,8,1,129,204,195,206,156,1,207,5,1,161,150,216,171,142,3,9,1,161,150,216,171,142,3,10,1,161,150,216,171,142,3,11,1,129,150,216,171,142,3,12,1,161,150,216,171,142,3,13,1,161,150,216,171,142,3,14,1,161,150,216,171,142,3,15,1,161,150,216,171,142,3,17,1,161,150,216,171,142,3,18,1,161,150,216,171,142,3,19,1,161,150,216,171,142,3,20,1,161,150,216,171,142,3,21,1,161,150,216,171,142,3,22,1,129,150,216,171,142,3,16,1,161,150,216,171,142,3,23,1,161,150,216,171,142,3,24,1,161,150,216,171,142,3,25,1,129,150,216,171,142,3,26,1,161,150,216,171,142,3,27,1,161,150,216,171,142,3,28,1,161,150,216,171,142,3,29,1,129,150,216,171,142,3,30,1,161,150,216,171,142,3,31,1,161,150,216,171,142,3,32,1,161,150,216,171,142,3,33,1,129,150,216,171,142,3,34,1,161,150,216,171,142,3,35,1,161,150,216,171,142,3,36,1,161,150,216,171,142,3,37,1,129,150,216,171,142,3,38,1,161,150,216,171,142,3,39,1,161,150,216,171,142,3,40,1,161,150,216,171,142,3,41,1,129,150,216,171,142,3,42,1,161,150,216,171,142,3,43,1,161,150,216,171,142,3,44,1,161,150,216,171,142,3,45,1,129,150,216,171,142,3,46,1,161,150,216,171,142,3,47,1,161,150,216,171,142,3,48,1,161,150,216,171,142,3,49,1,129,150,216,171,142,3,50,1,161,150,216,171,142,3,51,1,161,150,216,171,142,3,52,1,161,150,216,171,142,3,53,1,129,150,216,171,142,3,54,1,161,150,216,171,142,3,55,1,161,150,216,171,142,3,56,1,161,150,216,171,142,3,57,1,129,150,216,171,142,3,58,1,161,150,216,171,142,3,59,1,161,150,216,171,142,3,60,1,161,150,216,171,142,3,61,1,129,150,216,171,142,3,62,1,161,150,216,171,142,3,63,1,161,150,216,171,142,3,64,1,161,150,216,171,142,3,65,1,129,150,216,171,142,3,66,1,161,150,216,171,142,3,67,1,161,150,216,171,142,3,68,1,161,150,216,171,142,3,69,1,129,150,216,171,142,3,70,1,161,150,216,171,142,3,71,1,161,150,216,171,142,3,72,1,161,150,216,171,142,3,73,1,129,150,216,171,142,3,74,1,161,150,216,171,142,3,75,1,161,150,216,171,142,3,76,1,161,150,216,171,142,3,77,1,129,150,216,171,142,3,78,1,161,150,216,171,142,3,79,1,161,150,216,171,142,3,80,1,161,150,216,171,142,3,81,1,129,150,216,171,142,3,82,1,161,150,216,171,142,3,83,1,161,150,216,171,142,3,84,1,161,150,216,171,142,3,85,1,129,150,216,171,142,3,86,1,161,150,216,171,142,3,87,1,161,150,216,171,142,3,88,1,161,150,216,171,142,3,89,1,129,150,216,171,142,3,90,1,161,150,216,171,142,3,91,1,161,150,216,171,142,3,92,1,161,150,216,171,142,3,93,1,129,150,216,171,142,3,94,1,161,150,216,171,142,3,95,1,161,150,216,171,142,3,96,1,161,150,216,171,142,3,97,1,129,150,216,171,142,3,98,1,161,150,216,171,142,3,99,1,161,150,216,171,142,3,100,1,161,150,216,171,142,3,101,1,129,150,216,171,142,3,102,1,161,150,216,171,142,3,103,1,161,150,216,171,142,3,104,1,161,150,216,171,142,3,105,1,129,150,216,171,142,3,106,1,161,150,216,171,142,3,107,1,161,150,216,171,142,3,108,1,161,150,216,171,142,3,109,1,129,150,216,171,142,3,110,1,161,150,216,171,142,3,111,1,161,150,216,171,142,3,112,1,161,150,216,171,142,3,113,1,129,150,216,171,142,3,114,1,161,150,216,171,142,3,115,1,161,150,216,171,142,3,116,1,161,150,216,171,142,3,117,1,129,150,216,171,142,3,118,1,161,150,216,171,142,3,119,1,161,150,216,171,142,3,120,1,161,150,216,171,142,3,121,1,129,150,216,171,142,3,122,1,161,150,216,171,142,3,123,1,161,150,216,171,142,3,124,1,161,150,216,171,142,3,125,1,129,150,216,171,142,3,126,1,161,150,216,171,142,3,127,1,161,150,216,171,142,3,128,1,1,161,150,216,171,142,3,129,1,1,129,150,216,171,142,3,130,1,1,161,150,216,171,142,3,131,1,1,161,150,216,171,142,3,132,1,1,161,150,216,171,142,3,133,1,1,129,150,216,171,142,3,134,1,1,161,150,216,171,142,3,135,1,1,161,150,216,171,142,3,136,1,1,161,150,216,171,142,3,137,1,1,193,150,216,171,142,3,134,1,150,216,171,142,3,138,1,1,161,150,216,171,142,3,139,1,1,161,150,216,171,142,3,140,1,1,161,150,216,171,142,3,141,1,1,193,150,216,171,142,3,142,1,150,216,171,142,3,138,1,1,161,150,216,171,142,3,143,1,1,161,150,216,171,142,3,144,1,1,161,150,216,171,142,3,145,1,1,193,150,216,171,142,3,146,1,150,216,171,142,3,138,1,1,161,150,216,171,142,3,147,1,1,161,150,216,171,142,3,148,1,1,161,150,216,171,142,3,149,1,1,193,150,216,171,142,3,150,1,150,216,171,142,3,138,1,1,161,150,216,171,142,3,151,1,1,161,150,216,171,142,3,152,1,1,161,150,216,171,142,3,153,1,1,193,150,216,171,142,3,154,1,150,216,171,142,3,138,1,1,161,150,216,171,142,3,155,1,1,161,150,216,171,142,3,156,1,1,161,150,216,171,142,3,157,1,1,193,150,216,171,142,3,158,1,150,216,171,142,3,138,1,1,161,150,216,171,142,3,159,1,1,161,150,216,171,142,3,160,1,1,161,150,216,171,142,3,161,1,1,193,150,216,171,142,3,162,1,150,216,171,142,3,138,1,1,161,150,216,171,142,3,163,1,1,161,150,216,171,142,3,164,1,1,161,150,216,171,142,3,165,1,1,193,150,216,171,142,3,166,1,150,216,171,142,3,138,1,1,161,150,216,171,142,3,167,1,1,161,150,216,171,142,3,168,1,1,161,150,216,171,142,3,169,1,1,193,150,216,171,142,3,170,1,150,216,171,142,3,138,1,1,161,150,216,171,142,3,171,1,1,161,150,216,171,142,3,172,1,1,161,150,216,171,142,3,173,1,1,193,150,216,171,142,3,174,1,150,216,171,142,3,138,1,1,161,150,216,171,142,3,175,1,1,161,150,216,171,142,3,176,1,1,161,150,216,171,142,3,177,1,1,193,150,216,171,142,3,178,1,150,216,171,142,3,138,1,1,161,150,216,171,142,3,179,1,1,161,150,216,171,142,3,180,1,1,161,150,216,171,142,3,181,1,1,193,150,216,171,142,3,182,1,150,216,171,142,3,138,1,1,161,150,216,171,142,3,183,1,1,161,150,216,171,142,3,184,1,1,161,150,216,171,142,3,185,1,1,129,150,216,171,142,3,138,1,4,161,150,216,171,142,3,187,1,1,161,150,216,171,142,3,188,1,1,161,150,216,171,142,3,189,1,1,129,150,216,171,142,3,193,1,1,161,150,216,171,142,3,194,1,1,161,150,216,171,142,3,195,1,1,161,150,216,171,142,3,196,1,1,161,150,216,171,142,3,198,1,1,161,150,216,171,142,3,199,1,1,161,150,216,171,142,3,200,1,1,161,150,216,171,142,3,201,1,1,161,150,216,171,142,3,202,1,1,161,150,216,171,142,3,203,1,1,161,150,216,171,142,3,204,1,1,161,150,216,171,142,3,205,1,1,161,150,216,171,142,3,206,1,1,129,150,216,171,142,3,197,1,1,161,150,216,171,142,3,207,1,1,161,150,216,171,142,3,208,1,1,161,150,216,171,142,3,209,1,1,129,150,216,171,142,3,210,1,1,161,150,216,171,142,3,211,1,1,161,150,216,171,142,3,212,1,1,161,150,216,171,142,3,213,1,1,129,150,216,171,142,3,214,1,1,161,150,216,171,142,3,215,1,1,161,150,216,171,142,3,216,1,1,161,150,216,171,142,3,217,1,1,129,150,216,171,142,3,218,1,1,161,150,216,171,142,3,219,1,1,161,150,216,171,142,3,220,1,1,161,150,216,171,142,3,221,1,1,129,150,216,171,142,3,222,1,1,161,150,216,171,142,3,223,1,1,161,150,216,171,142,3,224,1,1,161,150,216,171,142,3,225,1,1,129,150,216,171,142,3,226,1,1,161,150,216,171,142,3,227,1,1,161,150,216,171,142,3,228,1,1,161,150,216,171,142,3,229,1,1,129,150,216,171,142,3,230,1,1,161,150,216,171,142,3,231,1,1,161,150,216,171,142,3,232,1,1,161,150,216,171,142,3,233,1,1,129,150,216,171,142,3,234,1,1,161,150,216,171,142,3,235,1,1,161,150,216,171,142,3,236,1,1,161,150,216,171,142,3,237,1,1,129,150,216,171,142,3,238,1,1,161,150,216,171,142,3,239,1,1,161,150,216,171,142,3,240,1,1,161,150,216,171,142,3,241,1,1,129,150,216,171,142,3,242,1,1,161,150,216,171,142,3,243,1,1,161,150,216,171,142,3,244,1,1,161,150,216,171,142,3,245,1,1,129,150,216,171,142,3,246,1,1,161,150,216,171,142,3,247,1,1,161,150,216,171,142,3,248,1,1,161,150,216,171,142,3,249,1,1,129,150,216,171,142,3,250,1,1,161,150,216,171,142,3,251,1,1,161,150,216,171,142,3,252,1,1,161,150,216,171,142,3,253,1,1,129,150,216,171,142,3,254,1,1,161,150,216,171,142,3,255,1,1,161,150,216,171,142,3,128,2,1,161,150,216,171,142,3,129,2,1,129,150,216,171,142,3,130,2,1,161,150,216,171,142,3,131,2,1,161,150,216,171,142,3,132,2,1,161,150,216,171,142,3,133,2,1,129,150,216,171,142,3,134,2,1,161,150,216,171,142,3,135,2,1,161,150,216,171,142,3,136,2,1,161,150,216,171,142,3,137,2,1,129,150,216,171,142,3,138,2,1,161,150,216,171,142,3,139,2,1,161,150,216,171,142,3,140,2,1,161,150,216,171,142,3,141,2,1,161,150,216,171,142,3,143,2,1,161,150,216,171,142,3,144,2,1,161,150,216,171,142,3,145,2,1,129,150,216,171,142,3,142,2,1,161,150,216,171,142,3,146,2,1,161,150,216,171,142,3,147,2,1,161,150,216,171,142,3,148,2,1,161,150,216,171,142,3,150,2,1,161,150,216,171,142,3,151,2,1,161,150,216,171,142,3,152,2,1,161,150,216,171,142,3,153,2,1,161,150,216,171,142,3,154,2,1,161,150,216,171,142,3,155,2,1,161,150,216,171,142,3,156,2,1,161,150,216,171,142,3,157,2,1,161,150,216,171,142,3,158,2,1,129,150,216,171,142,3,149,2,1,161,150,216,171,142,3,159,2,1,161,150,216,171,142,3,160,2,1,161,150,216,171,142,3,161,2,1,129,150,216,171,142,3,162,2,1,161,150,216,171,142,3,163,2,1,161,150,216,171,142,3,164,2,1,161,150,216,171,142,3,165,2,1,129,150,216,171,142,3,166,2,1,161,150,216,171,142,3,167,2,1,161,150,216,171,142,3,168,2,1,161,150,216,171,142,3,169,2,1,129,150,216,171,142,3,170,2,1,161,150,216,171,142,3,171,2,1,161,150,216,171,142,3,172,2,1,161,150,216,171,142,3,173,2,1,129,150,216,171,142,3,174,2,1,161,150,216,171,142,3,175,2,1,161,150,216,171,142,3,176,2,1,161,150,216,171,142,3,177,2,1,129,150,216,171,142,3,178,2,1,161,150,216,171,142,3,179,2,1,161,150,216,171,142,3,180,2,1,161,150,216,171,142,3,181,2,1,129,150,216,171,142,3,182,2,1,161,150,216,171,142,3,183,2,1,161,150,216,171,142,3,184,2,1,161,150,216,171,142,3,185,2,1,129,150,216,171,142,3,186,2,1,161,150,216,171,142,3,187,2,1,161,150,216,171,142,3,188,2,1,161,150,216,171,142,3,189,2,1,129,150,216,171,142,3,190,2,1,161,150,216,171,142,3,191,2,1,161,150,216,171,142,3,192,2,1,161,150,216,171,142,3,193,2,1,129,150,216,171,142,3,194,2,1,161,150,216,171,142,3,195,2,1,161,150,216,171,142,3,196,2,1,161,150,216,171,142,3,197,2,1,129,150,216,171,142,3,198,2,1,161,150,216,171,142,3,199,2,1,161,150,216,171,142,3,200,2,1,161,150,216,171,142,3,201,2,1,193,150,216,171,142,3,198,2,150,216,171,142,3,202,2,1,161,150,216,171,142,3,203,2,1,161,150,216,171,142,3,204,2,1,161,150,216,171,142,3,205,2,1,193,150,216,171,142,3,206,2,150,216,171,142,3,202,2,1,161,150,216,171,142,3,207,2,1,161,150,216,171,142,3,208,2,1,161,150,216,171,142,3,209,2,1,193,150,216,171,142,3,206,2,150,216,171,142,3,210,2,2,161,150,216,171,142,3,211,2,1,161,150,216,171,142,3,212,2,1,161,150,216,171,142,3,213,2,1,193,150,216,171,142,3,215,2,150,216,171,142,3,210,2,1,161,150,216,171,142,3,216,2,1,161,150,216,171,142,3,217,2,1,161,150,216,171,142,3,218,2,1,193,150,216,171,142,3,219,2,150,216,171,142,3,210,2,1,161,150,216,171,142,3,220,2,1,161,150,216,171,142,3,221,2,1,161,150,216,171,142,3,222,2,1,193,150,216,171,142,3,223,2,150,216,171,142,3,210,2,1,161,150,216,171,142,3,224,2,1,161,150,216,171,142,3,225,2,1,161,150,216,171,142,3,226,2,1,193,150,216,171,142,3,227,2,150,216,171,142,3,210,2,1,161,150,216,171,142,3,228,2,1,161,150,216,171,142,3,229,2,1,161,150,216,171,142,3,230,2,1,193,150,216,171,142,3,231,2,150,216,171,142,3,210,2,1,161,150,216,171,142,3,232,2,1,161,150,216,171,142,3,233,2,1,161,150,216,171,142,3,234,2,1,193,150,216,171,142,3,235,2,150,216,171,142,3,210,2,1,161,150,216,171,142,3,236,2,1,161,150,216,171,142,3,237,2,1,161,150,216,171,142,3,238,2,1,193,150,216,171,142,3,239,2,150,216,171,142,3,210,2,1,161,150,216,171,142,3,240,2,1,161,150,216,171,142,3,241,2,1,161,150,216,171,142,3,242,2,1,193,150,216,171,142,3,243,2,150,216,171,142,3,210,2,1,161,150,216,171,142,3,244,2,1,161,150,216,171,142,3,245,2,1,161,150,216,171,142,3,246,2,1,193,150,216,171,142,3,247,2,150,216,171,142,3,210,2,1,161,150,216,171,142,3,248,2,1,161,150,216,171,142,3,249,2,1,161,150,216,171,142,3,250,2,1,193,150,216,171,142,3,247,2,150,216,171,142,3,251,2,1,161,150,216,171,142,3,252,2,1,161,150,216,171,142,3,253,2,1,161,150,216,171,142,3,254,2,1,193,150,216,171,142,3,255,2,150,216,171,142,3,251,2,1,161,150,216,171,142,3,128,3,1,161,150,216,171,142,3,129,3,1,161,150,216,171,142,3,130,3,1,193,150,216,171,142,3,255,2,150,216,171,142,3,131,3,1,161,150,216,171,142,3,132,3,1,161,150,216,171,142,3,133,3,1,161,150,216,171,142,3,134,3,1,193,150,216,171,142,3,135,3,150,216,171,142,3,131,3,1,161,150,216,171,142,3,136,3,1,161,150,216,171,142,3,137,3,1,161,150,216,171,142,3,138,3,1,193,150,216,171,142,3,139,3,150,216,171,142,3,131,3,1,161,150,216,171,142,3,140,3,1,161,150,216,171,142,3,141,3,1,161,150,216,171,142,3,142,3,1,161,150,216,171,142,3,144,3,1,161,150,216,171,142,3,145,3,1,161,150,216,171,142,3,146,3,1,193,204,195,206,156,1,130,5,204,195,206,156,1,131,5,1,161,150,216,171,142,3,147,3,1,161,150,216,171,142,3,148,3,1,161,150,216,171,142,3,149,3,1,129,150,216,171,142,3,202,2,1,161,150,216,171,142,3,151,3,1,161,150,216,171,142,3,152,3,1,161,150,216,171,142,3,153,3,1,129,150,216,171,142,3,154,3,1,161,150,216,171,142,3,155,3,1,161,150,216,171,142,3,156,3,1,161,150,216,171,142,3,157,3,1,161,150,216,171,142,3,159,3,1,161,150,216,171,142,3,160,3,1,161,150,216,171,142,3,161,3,1,161,150,216,171,142,3,162,3,1,161,150,216,171,142,3,163,3,1,161,150,216,171,142,3,164,3,1,161,150,216,171,142,3,165,3,1,161,150,216,171,142,3,166,3,1,161,150,216,171,142,3,167,3,1,132,150,216,171,142,3,158,3,1,102,161,150,216,171,142,3,168,3,1,161,150,216,171,142,3,169,3,1,161,150,216,171,142,3,170,3,1,132,150,216,171,142,3,171,3,1,117,161,150,216,171,142,3,172,3,1,161,150,216,171,142,3,173,3,1,161,150,216,171,142,3,174,3,1,132,150,216,171,142,3,175,3,1,110,161,150,216,171,142,3,176,3,1,161,150,216,171,142,3,177,3,1,161,150,216,171,142,3,178,3,1,132,150,216,171,142,3,179,3,1,99,161,150,216,171,142,3,180,3,1,161,150,216,171,142,3,181,3,1,161,150,216,171,142,3,182,3,1,132,150,216,171,142,3,183,3,1,116,161,150,216,171,142,3,184,3,1,161,150,216,171,142,3,185,3,1,161,150,216,171,142,3,186,3,1,132,150,216,171,142,3,187,3,1,105,161,150,216,171,142,3,188,3,1,161,150,216,171,142,3,189,3,1,161,150,216,171,142,3,190,3,1,132,150,216,171,142,3,191,3,1,111,161,150,216,171,142,3,192,3,1,161,150,216,171,142,3,193,3,1,161,150,216,171,142,3,194,3,1,132,150,216,171,142,3,195,3,1,110,161,150,216,171,142,3,196,3,1,161,150,216,171,142,3,197,3,1,161,150,216,171,142,3,198,3,1,132,150,216,171,142,3,199,3,1,32,161,150,216,171,142,3,200,3,1,161,150,216,171,142,3,201,3,1,161,150,216,171,142,3,202,3,1,132,150,216,171,142,3,203,3,1,109,161,150,216,171,142,3,204,3,1,161,150,216,171,142,3,205,3,1,161,150,216,171,142,3,206,3,1,132,150,216,171,142,3,207,3,1,97,161,150,216,171,142,3,208,3,1,161,150,216,171,142,3,209,3,1,161,150,216,171,142,3,210,3,1,132,150,216,171,142,3,211,3,1,105,161,150,216,171,142,3,212,3,1,161,150,216,171,142,3,213,3,1,161,150,216,171,142,3,214,3,1,132,150,216,171,142,3,215,3,1,110,161,150,216,171,142,3,216,3,1,161,150,216,171,142,3,217,3,1,161,150,216,171,142,3,218,3,1,132,150,216,171,142,3,219,3,1,40,161,150,216,171,142,3,220,3,1,161,150,216,171,142,3,221,3,1,161,150,216,171,142,3,222,3,1,132,150,216,171,142,3,223,3,1,41,161,150,216,171,142,3,224,3,1,161,150,216,171,142,3,225,3,1,161,150,216,171,142,3,226,3,1,132,150,216,171,142,3,227,3,1,32,161,150,216,171,142,3,228,3,1,161,150,216,171,142,3,229,3,1,161,150,216,171,142,3,230,3,1,132,150,216,171,142,3,231,3,1,123,161,150,216,171,142,3,232,3,1,161,150,216,171,142,3,233,3,1,161,150,216,171,142,3,234,3,1,132,150,216,171,142,3,235,3,1,125,161,150,216,171,142,3,236,3,1,161,150,216,171,142,3,237,3,1,161,150,216,171,142,3,238,3,1,196,150,216,171,142,3,235,3,150,216,171,142,3,239,3,1,10,161,150,216,171,142,3,240,3,1,161,150,216,171,142,3,241,3,1,161,150,216,171,142,3,242,3,1,196,150,216,171,142,3,243,3,150,216,171,142,3,239,3,1,10,161,150,216,171,142,3,244,3,1,161,150,216,171,142,3,245,3,1,161,150,216,171,142,3,246,3,1,196,150,216,171,142,3,243,3,150,216,171,142,3,247,3,2,32,32,161,150,216,171,142,3,248,3,1,161,150,216,171,142,3,249,3,1,161,150,216,171,142,3,250,3,1,196,150,216,171,142,3,252,3,150,216,171,142,3,247,3,1,99,161,150,216,171,142,3,253,3,1,161,150,216,171,142,3,254,3,1,161,150,216,171,142,3,255,3,1,196,150,216,171,142,3,128,4,150,216,171,142,3,247,3,1,111,161,150,216,171,142,3,129,4,1,161,150,216,171,142,3,130,4,1,161,150,216,171,142,3,131,4,1,196,150,216,171,142,3,132,4,150,216,171,142,3,247,3,1,110,161,150,216,171,142,3,133,4,1,161,150,216,171,142,3,134,4,1,161,150,216,171,142,3,135,4,1,196,150,216,171,142,3,136,4,150,216,171,142,3,247,3,1,115,161,150,216,171,142,3,137,4,1,161,150,216,171,142,3,138,4,1,161,150,216,171,142,3,139,4,1,196,150,216,171,142,3,140,4,150,216,171,142,3,247,3,1,111,161,150,216,171,142,3,141,4,1,161,150,216,171,142,3,142,4,1,161,150,216,171,142,3,143,4,1,196,150,216,171,142,3,144,4,150,216,171,142,3,247,3,1,108,161,150,216,171,142,3,145,4,1,161,150,216,171,142,3,146,4,1,161,150,216,171,142,3,147,4,1,196,150,216,171,142,3,148,4,150,216,171,142,3,247,3,1,101,161,150,216,171,142,3,149,4,1,161,150,216,171,142,3,150,4,1,161,150,216,171,142,3,151,4,1,196,150,216,171,142,3,152,4,150,216,171,142,3,247,3,1,46,161,150,216,171,142,3,153,4,1,161,150,216,171,142,3,154,4,1,161,150,216,171,142,3,155,4,1,196,150,216,171,142,3,156,4,150,216,171,142,3,247,3,1,108,161,150,216,171,142,3,157,4,1,161,150,216,171,142,3,158,4,1,161,150,216,171,142,3,159,4,1,196,150,216,171,142,3,160,4,150,216,171,142,3,247,3,1,111,161,150,216,171,142,3,161,4,1,161,150,216,171,142,3,162,4,1,161,150,216,171,142,3,163,4,1,196,150,216,171,142,3,164,4,150,216,171,142,3,247,3,1,103,161,150,216,171,142,3,165,4,1,161,150,216,171,142,3,166,4,1,161,150,216,171,142,3,167,4,1,196,150,216,171,142,3,168,4,150,216,171,142,3,247,3,1,40,161,150,216,171,142,3,169,4,1,161,150,216,171,142,3,170,4,1,161,150,216,171,142,3,171,4,1,196,150,216,171,142,3,172,4,150,216,171,142,3,247,3,1,41,161,150,216,171,142,3,173,4,1,161,150,216,171,142,3,174,4,1,161,150,216,171,142,3,175,4,1,196,150,216,171,142,3,172,4,150,216,171,142,3,176,4,1,34,161,150,216,171,142,3,177,4,1,161,150,216,171,142,3,178,4,1,161,150,216,171,142,3,179,4,1,196,150,216,171,142,3,180,4,150,216,171,142,3,176,4,1,34,161,150,216,171,142,3,181,4,1,161,150,216,171,142,3,182,4,1,161,150,216,171,142,3,183,4,1,196,150,216,171,142,3,180,4,150,216,171,142,3,184,4,1,72,161,150,216,171,142,3,185,4,1,161,150,216,171,142,3,186,4,1,161,150,216,171,142,3,187,4,1,196,150,216,171,142,3,188,4,150,216,171,142,3,184,4,1,101,161,150,216,171,142,3,189,4,1,161,150,216,171,142,3,190,4,1,161,150,216,171,142,3,191,4,1,196,150,216,171,142,3,192,4,150,216,171,142,3,184,4,1,108,161,150,216,171,142,3,193,4,1,161,150,216,171,142,3,194,4,1,161,150,216,171,142,3,195,4,1,196,150,216,171,142,3,196,4,150,216,171,142,3,184,4,1,108,161,150,216,171,142,3,197,4,1,161,150,216,171,142,3,198,4,1,161,150,216,171,142,3,199,4,1,196,150,216,171,142,3,200,4,150,216,171,142,3,184,4,1,111,161,150,216,171,142,3,201,4,1,161,150,216,171,142,3,202,4,1,161,150,216,171,142,3,203,4,1,196,150,216,171,142,3,204,4,150,216,171,142,3,184,4,1,32,161,150,216,171,142,3,205,4,1,161,150,216,171,142,3,206,4,1,161,150,216,171,142,3,207,4,1,196,150,216,171,142,3,208,4,150,216,171,142,3,184,4,1,87,161,150,216,171,142,3,209,4,1,161,150,216,171,142,3,210,4,1,161,150,216,171,142,3,211,4,1,196,150,216,171,142,3,212,4,150,216,171,142,3,184,4,1,111,161,150,216,171,142,3,213,4,1,161,150,216,171,142,3,214,4,1,161,150,216,171,142,3,215,4,1,196,150,216,171,142,3,216,4,150,216,171,142,3,184,4,1,114,161,150,216,171,142,3,217,4,1,161,150,216,171,142,3,218,4,1,161,150,216,171,142,3,219,4,1,196,150,216,171,142,3,220,4,150,216,171,142,3,184,4,1,108,161,150,216,171,142,3,221,4,1,161,150,216,171,142,3,222,4,1,161,150,216,171,142,3,223,4,1,196,150,216,171,142,3,224,4,150,216,171,142,3,184,4,1,100,161,150,216,171,142,3,225,4,1,161,150,216,171,142,3,226,4,1,161,150,216,171,142,3,227,4,1,193,150,216,171,142,3,228,4,150,216,171,142,3,184,4,1,161,150,216,171,142,3,229,4,1,161,150,216,171,142,3,230,4,1,161,150,216,171,142,3,231,4,1,168,150,216,171,142,3,233,4,1,119,132,1,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,47,47,32,84,104,105,115,32,105,115,32,116,104,101,32,109,97,105,110,32,102,117,110,99,116,105,111,110,46,92,110,102,117,110,99,116,105,111,110,32,109,97,105,110,40,41,32,123,92,110,32,32,99,111,110,115,111,108,101,46,108,111,103,40,92,34,72,101,108,108,111,32,87,111,114,108,100,92,34,41,92,110,125,34,125,93,44,34,108,97,110,103,117,97,103,101,34,58,34,74,97,118,97,115,99,114,105,112,116,34,125,168,150,216,171,142,3,234,4,1,119,10,49,112,115,100,67,122,97,87,104,49,168,150,216,171,142,3,235,4,1,119,4,116,101,120,116,39,0,204,195,206,156,1,4,6,48,100,51,52,82,50,2,39,0,204,195,206,156,1,4,6,45,71,115,81,49,95,2,4,0,150,216,171,142,3,240,4,4,49,50,51,32,134,150,216,171,142,3,244,4,7,109,101,110,116,105,111,110,51,123,34,100,97,116,101,34,58,34,50,48,50,52,45,48,52,45,49,57,84,49,54,58,49,51,58,52,57,46,52,49,49,49,54,53,34,44,34,116,121,112,101,34,58,34,100,97,116,101,34,125,132,150,216,171,142,3,245,4,1,36,134,150,216,171,142,3,246,4,7,109,101,110,116,105,111,110,4,110,117,108,108,132,150,216,171,142,3,247,4,6,32,32,101,114,32,32,33,0,204,195,206,156,1,1,6,49,71,114,87,76,99,1,0,7,33,0,204,195,206,156,1,3,6,72,105,104,101,52,114,1,193,164,202,219,213,10,28,199,130,209,189,2,60,1,168,164,202,219,213,10,117,1,119,151,1,123,34,108,101,118,101,108,34,58,50,44,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,49,50,51,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,109,101,110,116,105,111,110,34,58,123,34,100,97,116,101,34,58,34,50,48,50,52,45,48,52,45,49,57,84,49,54,58,49,51,58,52,57,46,52,49,49,49,54,53,34,44,34,116,121,112,101,34,58,34,100,97,116,101,34,125,125,44,34,105,110,115,101,114,116,34,58,34,36,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,32,101,114,32,32,34,125,93,125,4,0,150,216,171,142,3,239,4,1,35,0,1,132,150,216,171,142,3,137,5,1,35,0,1,132,150,216,171,142,3,139,5,1,35,0,1,39,0,204,195,206,156,1,4,6,115,99,68,45,119,114,2,39,0,204,195,206,156,1,1,6,67,72,115,77,79,98,1,40,0,150,216,171,142,3,144,5,2,105,100,1,119,6,67,72,115,77,79,98,40,0,150,216,171,142,3,144,5,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,150,216,171,142,3,144,5,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,150,216,171,142,3,144,5,8,99,104,105,108,100,114,101,110,1,119,6,97,107,121,80,104,45,33,0,150,216,171,142,3,144,5,4,100,97,116,97,1,40,0,150,216,171,142,3,144,5,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,150,216,171,142,3,144,5,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,97,107,121,80,104,45,0,200,164,202,219,213,10,28,150,216,171,142,3,135,5,1,119,6,67,72,115,77,79,98,4,0,150,216,171,142,3,143,5,1,49,161,150,216,171,142,3,149,5,1,132,150,216,171,142,3,154,5,1,50,161,150,216,171,142,3,155,5,1,132,150,216,171,142,3,156,5,1,51,161,150,216,171,142,3,157,5,1,168,150,216,171,142,3,5,1,119,11,123,34,100,101,112,116,104,34,58,54,125,39,0,204,195,206,156,1,4,6,112,79,69,118,75,110,2,39,0,204,195,206,156,1,4,6,80,49,49,121,116,108,2,4,0,150,216,171,142,3,162,5,3,49,50,51,33,0,204,195,206,156,1,1,6,97,79,72,54,79,66,1,0,7,33,0,204,195,206,156,1,3,6,105,89,77,80,45,116,1,193,150,216,171,142,3,135,5,199,130,209,189,2,60,1,161,150,216,171,142,3,159,5,1,168,150,216,171,142,3,176,5,1,119,38,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,49,50,51,34,125,93,44,34,108,101,118,101,108,34,58,51,125,161,199,130,209,189,2,212,3,1,161,199,130,209,189,2,232,3,1,161,199,130,209,189,2,159,4,1,161,150,216,171,142,3,179,5,1,161,199,130,209,189,2,144,4,1,161,150,216,171,142,3,181,5,1,161,150,216,171,142,3,182,5,1,161,150,216,171,142,3,180,5,2,161,150,216,171,142,3,183,5,1,161,150,216,171,142,3,184,5,1,161,150,216,171,142,3,186,5,1,161,199,130,209,189,2,252,3,1,161,150,216,171,142,3,188,5,1,161,150,216,171,142,3,189,5,1,39,0,204,195,206,156,1,4,6,70,76,85,90,75,54,2,39,0,204,195,206,156,1,4,6,69,53,84,66,120,118,2,39,0,204,195,206,156,1,1,6,103,79,106,51,90,68,1,40,0,150,216,171,142,3,195,5,2,105,100,1,119,6,103,79,106,51,90,68,40,0,150,216,171,142,3,195,5,2,116,121,1,119,10,116,97,98,108,101,47,99,101,108,108,40,0,150,216,171,142,3,195,5,6,112,97,114,101,110,116,1,119,6,120,49,100,100,111,87,40,0,150,216,171,142,3,195,5,8,99,104,105,108,100,114,101,110,1,119,6,116,51,112,87,101,56,33,0,150,216,171,142,3,195,5,4,100,97,116,97,1,40,0,150,216,171,142,3,195,5,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,150,216,171,142,3,195,5,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,116,51,112,87,101,56,0,136,199,130,209,189,2,148,4,1,119,6,103,79,106,51,90,68,39,0,204,195,206,156,1,1,6,88,56,80,113,67,120,1,40,0,150,216,171,142,3,205,5,2,105,100,1,119,6,88,56,80,113,67,120,40,0,150,216,171,142,3,205,5,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,150,216,171,142,3,205,5,6,112,97,114,101,110,116,1,119,6,103,79,106,51,90,68,40,0,150,216,171,142,3,205,5,8,99,104,105,108,100,114,101,110,1,119,6,76,55,82,84,78,106,33,0,150,216,171,142,3,205,5,4,100,97,116,97,1,40,0,150,216,171,142,3,205,5,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,150,216,171,142,3,205,5,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,76,55,82,84,78,106,0,8,0,150,216,171,142,3,203,5,1,119,6,88,56,80,113,67,120,39,0,204,195,206,156,1,1,6,74,48,69,48,67,66,1,40,0,150,216,171,142,3,215,5,2,105,100,1,119,6,74,48,69,48,67,66,40,0,150,216,171,142,3,215,5,2,116,121,1,119,10,116,97,98,108,101,47,99,101,108,108,40,0,150,216,171,142,3,215,5,6,112,97,114,101,110,116,1,119,6,120,49,100,100,111,87,40,0,150,216,171,142,3,215,5,8,99,104,105,108,100,114,101,110,1,119,6,73,120,120,49,82,88,33,0,150,216,171,142,3,215,5,4,100,97,116,97,1,40,0,150,216,171,142,3,215,5,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,150,216,171,142,3,215,5,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,73,120,120,49,82,88,0,136,150,216,171,142,3,204,5,1,119,6,74,48,69,48,67,66,39,0,204,195,206,156,1,1,6,75,78,45,115,87,88,1,40,0,150,216,171,142,3,225,5,2,105,100,1,119,6,75,78,45,115,87,88,40,0,150,216,171,142,3,225,5,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,150,216,171,142,3,225,5,6,112,97,114,101,110,116,1,119,6,74,48,69,48,67,66,40,0,150,216,171,142,3,225,5,8,99,104,105,108,100,114,101,110,1,119,6,75,115,100,83,116,74,33,0,150,216,171,142,3,225,5,4,100,97,116,97,1,40,0,150,216,171,142,3,225,5,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,150,216,171,142,3,225,5,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,75,115,100,83,116,74,0,8,0,150,216,171,142,3,223,5,1,119,6,75,78,45,115,87,88,161,150,216,171,142,3,192,5,1,4,0,150,216,171,142,3,193,5,1,55,161,150,216,171,142,3,210,5,1,132,150,216,171,142,3,236,5,1,55,161,150,216,171,142,3,237,5,1,132,150,216,171,142,3,238,5,1,55,161,150,216,171,142,3,239,5,1,39,0,204,195,206,156,1,4,6,71,52,107,110,49,95,2,39,0,204,195,206,156,1,4,6,98,52,97,80,80,53,2,39,0,204,195,206,156,1,4,6,80,111,114,82,81,79,2,161,150,216,171,142,3,235,5,1,39,0,204,195,206,156,1,1,6,80,53,88,89,98,115,1,40,0,150,216,171,142,3,246,5,2,105,100,1,119,6,80,53,88,89,98,115,40,0,150,216,171,142,3,246,5,2,116,121,1,119,10,116,97,98,108,101,47,99,101,108,108,40,0,150,216,171,142,3,246,5,6,112,97,114,101,110,116,1,119,6,120,49,100,100,111,87,40,0,150,216,171,142,3,246,5,8,99,104,105,108,100,114,101,110,1,119,6,89,55,81,88,109,84,33,0,150,216,171,142,3,246,5,4,100,97,116,97,1,40,0,150,216,171,142,3,246,5,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,150,216,171,142,3,246,5,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,89,55,81,88,109,84,0,200,199,130,209,189,2,236,3,199,130,209,189,2,128,4,1,119,6,80,53,88,89,98,115,39,0,204,195,206,156,1,1,6,57,49,85,122,51,51,1,40,0,150,216,171,142,3,128,6,2,105,100,1,119,6,57,49,85,122,51,51,40,0,150,216,171,142,3,128,6,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,150,216,171,142,3,128,6,6,112,97,114,101,110,116,1,119,6,80,53,88,89,98,115,40,0,150,216,171,142,3,128,6,8,99,104,105,108,100,114,101,110,1,119,6,88,73,86,101,114,105,33,0,150,216,171,142,3,128,6,4,100,97,116,97,1,40,0,150,216,171,142,3,128,6,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,150,216,171,142,3,128,6,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,88,73,86,101,114,105,0,8,0,150,216,171,142,3,254,5,1,119,6,57,49,85,122,51,51,161,150,216,171,142,3,245,5,1,39,0,204,195,206,156,1,1,6,97,105,73,55,114,78,1,40,0,150,216,171,142,3,139,6,2,105,100,1,119,6,97,105,73,55,114,78,40,0,150,216,171,142,3,139,6,2,116,121,1,119,10,116,97,98,108,101,47,99,101,108,108,40,0,150,216,171,142,3,139,6,6,112,97,114,101,110,116,1,119,6,120,49,100,100,111,87,40,0,150,216,171,142,3,139,6,8,99,104,105,108,100,114,101,110,1,119,6,70,83,77,57,101,119,33,0,150,216,171,142,3,139,6,4,100,97,116,97,1,40,0,150,216,171,142,3,139,6,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,150,216,171,142,3,139,6,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,70,83,77,57,101,119,0,200,199,130,209,189,2,148,4,150,216,171,142,3,204,5,1,119,6,97,105,73,55,114,78,39,0,204,195,206,156,1,1,6,48,117,114,80,56,120,1,40,0,150,216,171,142,3,149,6,2,105,100,1,119,6,48,117,114,80,56,120,40,0,150,216,171,142,3,149,6,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,150,216,171,142,3,149,6,6,112,97,114,101,110,116,1,119,6,97,105,73,55,114,78,40,0,150,216,171,142,3,149,6,8,99,104,105,108,100,114,101,110,1,119,6,98,90,114,57,106,101,33,0,150,216,171,142,3,149,6,4,100,97,116,97,1,40,0,150,216,171,142,3,149,6,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,150,216,171,142,3,149,6,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,98,90,114,57,106,101,0,8,0,150,216,171,142,3,147,6,1,119,6,48,117,114,80,56,120,39,0,204,195,206,156,1,1,6,100,114,110,97,115,68,1,40,0,150,216,171,142,3,159,6,2,105,100,1,119,6,100,114,110,97,115,68,40,0,150,216,171,142,3,159,6,2,116,121,1,119,10,116,97,98,108,101,47,99,101,108,108,40,0,150,216,171,142,3,159,6,6,112,97,114,101,110,116,1,119,6,120,49,100,100,111,87,40,0,150,216,171,142,3,159,6,8,99,104,105,108,100,114,101,110,1,119,6,76,114,45,49,81,54,33,0,150,216,171,142,3,159,6,4,100,97,116,97,1,40,0,150,216,171,142,3,159,6,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,150,216,171,142,3,159,6,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,76,114,45,49,81,54,0,136,150,216,171,142,3,224,5,1,119,6,100,114,110,97,115,68,39,0,204,195,206,156,1,1,6,72,69,79,54,86,72,1,40,0,150,216,171,142,3,169,6,2,105,100,1,119,6,72,69,79,54,86,72,40,0,150,216,171,142,3,169,6,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,150,216,171,142,3,169,6,6,112,97,114,101,110,116,1,119,6,100,114,110,97,115,68,40,0,150,216,171,142,3,169,6,8,99,104,105,108,100,114,101,110,1,119,6,71,118,57,87,108,76,33,0,150,216,171,142,3,169,6,4,100,97,116,97,1,40,0,150,216,171,142,3,169,6,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,150,216,171,142,3,169,6,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,71,118,57,87,108,76,0,8,0,150,216,171,142,3,167,6,1,119,6,72,69,79,54,86,72,4,0,150,216,171,142,3,242,5,1,49,161,150,216,171,142,3,133,6,1,132,150,216,171,142,3,179,6,1,48,161,150,216,171,142,3,180,6,1,132,150,216,171,142,3,181,6,1,49,161,150,216,171,142,3,182,6,1,132,150,216,171,142,3,183,6,1,48,161,150,216,171,142,3,184,6,1,161,150,216,171,142,3,187,5,1,161,150,216,171,142,3,191,5,1,161,150,216,171,142,3,220,5,1,161,150,216,171,142,3,138,6,1,39,0,204,195,206,156,1,4,6,54,73,68,55,103,118,2,4,0,150,216,171,142,3,191,6,1,49,168,199,130,209,189,2,169,4,1,119,26,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,49,34,125,93,125,39,0,204,195,206,156,1,4,6,77,84,68,68,110,107,2,4,0,150,216,171,142,3,194,6,1,50,168,199,130,209,189,2,242,3,1,119,26,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,50,34,125,93,125,39,0,204,195,206,156,1,4,6,88,108,87,102,45,70,2,4,0,150,216,171,142,3,197,6,1,51,168,150,216,171,142,3,186,6,1,119,26,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,51,34,125,93,125,39,0,204,195,206,156,1,4,6,57,98,65,107,106,109,2,4,0,150,216,171,142,3,200,6,1,52,168,199,130,209,189,2,134,4,1,119,26,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,52,34,125,93,125,39,0,204,195,206,156,1,4,6,99,112,85,122,107,121,2,4,0,150,216,171,142,3,203,6,1,53,168,199,130,209,189,2,177,4,1,119,26,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,53,34,125,93,125,39,0,204,195,206,156,1,4,6,81,102,72,107,77,77,2,4,0,150,216,171,142,3,206,6,1,54,168,150,216,171,142,3,154,6,1,119,26,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,54,34,125,93,125,39,0,204,195,206,156,1,4,6,119,113,82,74,112,76,2,4,0,150,216,171,142,3,209,6,1,55,168,150,216,171,142,3,241,5,1,119,26,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,55,34,125,93,125,39,0,204,195,206,156,1,4,6,69,70,82,71,121,80,2,4,0,150,216,171,142,3,212,6,1,56,168,150,216,171,142,3,230,5,1,119,26,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,56,34,125,93,125,39,0,204,195,206,156,1,4,6,104,109,90,99,72,101,2,1,0,150,216,171,142,3,215,6,1,161,150,216,171,142,3,174,6,2,132,150,216,171,142,3,216,6,1,57,168,150,216,171,142,3,218,6,1,119,26,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,57,34,125,93,125,39,0,204,195,206,156,1,4,6,56,117,111,87,49,119,2,4,0,150,216,171,142,3,221,6,4,119,105,116,104,168,199,130,209,189,2,146,3,1,119,61,123,34,97,108,105,103,110,34,58,34,114,105,103,104,116,34,44,34,99,104,101,99,107,101,100,34,58,102,97,108,115,101,44,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,119,105,116,104,34,125,93,125,39,0,204,195,206,156,1,4,6,109,55,80,110,81,54,2,4,0,150,216,171,142,3,227,6,3,108,111,110,129,150,216,171,142,3,230,6,14,132,150,216,171,142,3,244,6,44,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,134,150,216,171,142,3,160,7,11,102,111,110,116,95,102,97,109,105,108,121,22,34,65,68,76,97,77,68,105,115,112,108,97,121,95,114,101,103,117,108,97,114,34,132,150,216,171,142,3,161,7,9,116,101,120,116,110,103,32,116,101,134,150,216,171,142,3,170,7,11,102,111,110,116,95,102,97,109,105,108,121,4,110,117,108,108,132,150,216,171,142,3,171,7,16,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,129,150,216,171,142,3,187,7,6,132,150,216,171,142,3,193,7,92,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,161,199,130,209,189,2,213,2,1,196,150,216,171,142,3,187,7,150,216,171,142,3,188,7,1,76,161,150,216,171,142,3,158,8,1,196,150,216,171,142,3,193,7,150,216,171,142,3,194,7,1,101,161,150,216,171,142,3,160,8,1,70,204,195,206,156,1,135,7,11,102,111,110,116,95,102,97,109,105,108,121,22,34,65,98,114,105,108,70,97,116,102,97,99,101,95,114,101,103,117,108,97,114,34,193,150,216,171,142,3,163,8,204,195,206,156,1,135,7,3,198,150,216,171,142,3,166,8,204,195,206,156,1,135,7,11,102,111,110,116,95,102,97,109,105,108,121,4,110,117,108,108,161,199,130,209,189,2,25,1,161,199,130,209,189,2,26,1,161,199,130,209,189,2,27,1,198,150,216,171,142,3,230,6,150,216,171,142,3,231,6,11,102,111,110,116,95,102,97,109,105,108,121,22,34,65,68,76,97,77,68,105,115,112,108,97,121,95,114,101,103,117,108,97,114,34,196,150,216,171,142,3,171,8,150,216,171,142,3,231,6,14,103,32,116,101,120,116,110,103,32,116,101,120,116,110,198,150,216,171,142,3,185,8,150,216,171,142,3,231,6,11,102,111,110,116,95,102,97,109,105,108,121,4,110,117,108,108,161,150,216,171,142,3,162,8,1,16,146,175,139,236,2,0,161,227,211,144,195,8,0,1,161,227,211,144,195,8,1,1,161,227,211,144,195,8,2,1,161,227,211,144,195,8,3,1,161,227,211,144,195,8,4,1,161,227,211,144,195,8,5,1,161,227,211,144,195,8,11,1,161,227,211,144,195,8,7,1,161,227,211,144,195,8,8,1,161,227,211,144,195,8,9,1,161,146,175,139,236,2,6,2,39,0,204,195,206,156,1,4,6,66,66,65,103,65,56,2,6,0,146,175,139,236,2,12,11,102,111,110,116,95,102,97,109,105,108,121,15,34,65,68,76,97,77,32,68,105,115,112,108,97,121,34,132,146,175,139,236,2,13,183,1,108,111,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,76,101,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,107,107,109,134,146,175,139,236,2,196,1,11,102,111,110,116,95,102,97,109,105,108,121,4,110,117,108,108,168,192,187,174,206,8,222,5,1,119,141,2,123,34,99,104,101,99,107,101,100,34,58,102,97,108,115,101,44,34,100,101,108,116,97,34,58,91,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,102,111,110,116,95,102,97,109,105,108,121,34,58,34,65,68,76,97,77,32,68,105,115,112,108,97,121,34,125,44,34,105,110,115,101,114,116,34,58,34,108,111,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,76,101,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,107,107,109,34,125,93,125,20,168,215,223,235,2,0,161,150,216,171,142,3,168,8,1,161,150,216,171,142,3,169,8,1,161,150,216,171,142,3,170,8,1,196,150,216,171,142,3,166,8,150,216,171,142,3,167,8,3,87,101,108,132,224,159,166,178,15,26,16,99,111,109,101,32,116,111,32,65,112,112,70,108,111,119,121,39,0,204,195,206,156,1,4,6,74,98,104,77,53,50,2,4,0,168,215,223,235,2,22,20,72,101,114,101,32,97,114,101,32,116,104,101,32,98,97,115,105,99,115,32,168,168,215,223,235,2,0,1,119,120,123,34,100,101,108,116,97,34,58,91,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,102,111,110,116,95,102,97,109,105,108,121,34,58,34,65,98,114,105,108,70,97,116,102,97,99,101,95,114,101,103,117,108,97,114,34,125,44,34,105,110,115,101,114,116,34,58,34,87,101,108,34,125,44,123,34,105,110,115,101,114,116,34,58,34,99,111,109,101,32,116,111,32,65,112,112,70,108,111,119,121,34,125,93,44,34,108,101,118,101,108,34,58,49,125,168,168,215,223,235,2,1,1,119,10,119,88,107,79,72,81,49,50,99,111,168,168,215,223,235,2,2,1,119,4,116,101,120,116,167,204,195,206,156,1,213,1,1,40,0,168,215,223,235,2,46,2,105,100,1,119,10,97,115,74,118,54,70,114,65,82,97,40,0,168,215,223,235,2,46,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,168,215,223,235,2,46,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,168,215,223,235,2,46,8,99,104,105,108,100,114,101,110,1,119,6,51,107,67,87,106,70,40,0,168,215,223,235,2,46,4,100,97,116,97,1,119,55,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,72,101,114,101,32,97,114,101,32,116,104,101,32,98,97,115,105,99,115,32,34,125,93,44,34,108,101,118,101,108,34,58,50,125,40,0,168,215,223,235,2,46,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,168,215,223,235,2,46,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,51,107,67,87,106,70,0,200,204,195,206,156,1,232,1,199,130,209,189,2,181,3,1,119,10,97,115,74,118,54,70,114,65,82,97,1,192,246,139,213,2,0,161,239,239,208,251,10,16,35,1,190,183,139,210,2,0,161,241,147,239,232,6,3,110,2,237,140,187,206,2,0,161,190,183,139,210,2,109,17,161,237,140,187,206,2,16,4,137,6,199,130,209,189,2,0,39,0,204,195,206,156,1,4,6,88,116,53,112,118,55,2,4,0,199,130,209,189,2,0,9,229,144,140,228,184,128,228,184,170,129,199,130,209,189,2,3,5,161,178,187,245,161,14,10,6,132,199,130,209,189,2,8,1,110,161,199,130,209,189,2,14,1,132,199,130,209,189,2,15,1,105,168,199,130,209,189,2,16,1,119,36,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,229,144,140,228,184,128,228,184,170,110,105,34,125,93,125,161,224,159,166,178,15,27,1,161,224,159,166,178,15,28,1,161,224,159,166,178,15,29,1,161,199,130,209,189,2,19,1,161,199,130,209,189,2,20,1,161,199,130,209,189,2,21,1,161,199,130,209,189,2,22,1,161,199,130,209,189,2,23,1,161,199,130,209,189,2,24,1,0,3,39,0,204,195,206,156,1,4,6,82,74,97,73,54,107,2,4,0,199,130,209,189,2,31,1,116,161,228,242,134,215,15,11,1,132,199,130,209,189,2,32,1,111,161,199,130,209,189,2,33,1,132,199,130,209,189,2,34,1,100,161,199,130,209,189,2,35,1,132,199,130,209,189,2,36,1,111,161,199,130,209,189,2,37,1,132,199,130,209,189,2,38,1,32,161,199,130,209,189,2,39,1,132,199,130,209,189,2,40,1,108,161,199,130,209,189,2,41,1,132,199,130,209,189,2,42,1,105,161,199,130,209,189,2,43,1,132,199,130,209,189,2,44,1,115,161,199,130,209,189,2,45,1,132,199,130,209,189,2,46,1,116,161,199,130,209,189,2,47,1,39,0,204,195,206,156,1,4,6,55,80,118,106,121,81,2,39,0,204,195,206,156,1,1,6,71,118,88,50,102,110,1,40,0,199,130,209,189,2,51,2,105,100,1,119,6,71,118,88,50,102,110,40,0,199,130,209,189,2,51,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,199,130,209,189,2,51,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,199,130,209,189,2,51,8,99,104,105,108,100,114,101,110,1,119,6,111,112,68,102,54,95,33,0,199,130,209,189,2,51,4,100,97,116,97,1,40,0,199,130,209,189,2,51,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,51,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,111,112,68,102,54,95,0,200,198,223,206,159,1,135,1,198,223,206,159,1,116,1,119,6,71,118,88,50,102,110,161,199,130,209,189,2,49,1,4,0,199,130,209,189,2,50,1,99,161,199,130,209,189,2,56,1,132,199,130,209,189,2,62,1,104,161,199,130,209,189,2,63,1,132,199,130,209,189,2,64,1,101,161,199,130,209,189,2,65,1,132,199,130,209,189,2,66,1,99,161,199,130,209,189,2,67,1,132,199,130,209,189,2,68,1,107,161,199,130,209,189,2,69,1,132,199,130,209,189,2,70,1,101,161,199,130,209,189,2,71,1,132,199,130,209,189,2,72,1,100,161,199,130,209,189,2,73,1,132,199,130,209,189,2,74,1,32,161,199,130,209,189,2,75,1,132,199,130,209,189,2,76,1,116,161,199,130,209,189,2,77,1,132,199,130,209,189,2,78,1,111,161,199,130,209,189,2,79,1,132,199,130,209,189,2,80,1,100,161,199,130,209,189,2,81,1,132,199,130,209,189,2,82,1,111,161,199,130,209,189,2,83,1,132,199,130,209,189,2,84,1,32,161,199,130,209,189,2,85,1,132,199,130,209,189,2,86,1,108,161,199,130,209,189,2,87,1,132,199,130,209,189,2,88,1,105,161,199,130,209,189,2,89,1,132,199,130,209,189,2,90,1,115,161,199,130,209,189,2,91,1,132,199,130,209,189,2,92,1,116,161,199,130,209,189,2,93,1,39,0,204,195,206,156,1,4,6,117,115,117,45,118,111,2,39,0,204,195,206,156,1,1,6,86,90,80,95,77,113,1,40,0,199,130,209,189,2,97,2,105,100,1,119,6,86,90,80,95,77,113,40,0,199,130,209,189,2,97,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,199,130,209,189,2,97,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,199,130,209,189,2,97,8,99,104,105,108,100,114,101,110,1,119,6,54,55,76,56,102,50,33,0,199,130,209,189,2,97,4,100,97,116,97,1,40,0,199,130,209,189,2,97,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,97,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,54,55,76,56,102,50,0,200,199,130,209,189,2,60,198,223,206,159,1,116,1,119,6,86,90,80,95,77,113,161,199,130,209,189,2,95,1,4,0,199,130,209,189,2,96,1,108,161,199,130,209,189,2,102,1,132,199,130,209,189,2,108,1,111,161,199,130,209,189,2,109,1,132,199,130,209,189,2,110,1,110,161,199,130,209,189,2,111,1,132,199,130,209,189,2,112,1,103,161,199,130,209,189,2,113,1,132,199,130,209,189,2,114,1,32,161,199,130,209,189,2,115,1,132,199,130,209,189,2,116,1,116,161,199,130,209,189,2,117,1,132,199,130,209,189,2,118,1,101,161,199,130,209,189,2,119,1,132,199,130,209,189,2,120,1,120,161,199,130,209,189,2,121,1,132,199,130,209,189,2,122,1,116,161,199,130,209,189,2,123,1,129,199,130,209,189,2,124,1,161,199,130,209,189,2,125,2,132,199,130,209,189,2,126,7,110,103,32,116,101,120,116,161,199,130,209,189,2,128,1,1,132,199,130,209,189,2,135,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,136,1,1,132,199,130,209,189,2,143,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,144,1,1,132,199,130,209,189,2,151,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,152,1,1,132,199,130,209,189,2,159,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,160,1,1,132,199,130,209,189,2,167,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,168,1,1,132,199,130,209,189,2,175,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,176,1,1,132,199,130,209,189,2,183,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,184,1,1,132,199,130,209,189,2,191,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,192,1,1,132,199,130,209,189,2,199,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,200,1,1,132,199,130,209,189,2,207,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,208,1,1,132,199,130,209,189,2,215,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,216,1,1,132,199,130,209,189,2,223,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,224,1,1,132,199,130,209,189,2,231,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,232,1,1,132,199,130,209,189,2,239,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,240,1,1,132,199,130,209,189,2,247,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,248,1,1,132,199,130,209,189,2,255,1,7,110,103,32,116,101,120,116,161,199,130,209,189,2,128,2,1,132,199,130,209,189,2,135,2,7,110,103,32,116,101,120,116,161,199,130,209,189,2,136,2,1,132,199,130,209,189,2,143,2,7,110,103,32,116,101,120,116,161,199,130,209,189,2,144,2,1,132,199,130,209,189,2,151,2,7,110,103,32,116,101,120,116,161,199,130,209,189,2,152,2,1,132,199,130,209,189,2,159,2,7,110,103,32,116,101,120,116,161,199,130,209,189,2,160,2,1,132,199,130,209,189,2,167,2,7,110,103,32,116,101,120,116,161,199,130,209,189,2,168,2,1,132,199,130,209,189,2,175,2,7,110,103,32,116,101,120,116,161,199,130,209,189,2,176,2,1,132,199,130,209,189,2,183,2,7,110,103,32,116,101,120,116,161,199,130,209,189,2,184,2,1,132,199,130,209,189,2,191,2,7,110,103,32,116,101,120,116,161,199,130,209,189,2,192,2,1,161,199,130,209,189,2,107,1,39,0,204,195,206,156,1,4,6,55,74,97,87,111,56,2,39,0,204,195,206,156,1,1,6,54,87,56,99,101,88,1,40,0,199,130,209,189,2,203,2,2,105,100,1,119,6,54,87,56,99,101,88,40,0,199,130,209,189,2,203,2,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,199,130,209,189,2,203,2,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,199,130,209,189,2,203,2,8,99,104,105,108,100,114,101,110,1,119,6,105,55,111,99,51,56,33,0,199,130,209,189,2,203,2,4,100,97,116,97,1,40,0,199,130,209,189,2,203,2,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,203,2,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,105,55,111,99,51,56,0,200,199,130,209,189,2,106,198,223,206,159,1,116,1,119,6,54,87,56,99,101,88,161,199,130,209,189,2,200,2,1,132,199,130,209,189,2,94,1,32,161,199,130,209,189,2,201,2,1,129,199,130,209,189,2,214,2,1,161,199,130,209,189,2,215,2,1,129,199,130,209,189,2,216,2,1,161,199,130,209,189,2,217,2,1,129,199,130,209,189,2,218,2,1,161,199,130,209,189,2,219,2,1,129,199,130,209,189,2,220,2,1,161,199,130,209,189,2,221,2,5,129,199,130,209,189,2,222,2,1,161,199,130,209,189,2,227,2,1,129,199,130,209,189,2,228,2,1,161,199,130,209,189,2,229,2,1,129,199,130,209,189,2,230,2,1,161,199,130,209,189,2,231,2,1,129,199,130,209,189,2,232,2,1,161,199,130,209,189,2,233,2,1,129,199,130,209,189,2,234,2,1,161,199,130,209,189,2,235,2,1,129,199,130,209,189,2,236,2,1,161,199,130,209,189,2,237,2,1,129,199,130,209,189,2,238,2,1,161,199,130,209,189,2,239,2,1,134,199,130,209,189,2,240,2,7,102,111,114,109,117,108,97,9,34,102,111,114,109,117,108,97,34,132,199,130,209,189,2,242,2,1,36,134,199,130,209,189,2,243,2,7,102,111,114,109,117,108,97,4,110,117,108,108,168,199,130,209,189,2,241,2,1,119,108,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,99,104,101,99,107,101,100,32,116,111,100,111,32,108,105,115,116,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,102,111,114,109,117,108,97,34,58,34,102,111,114,109,117,108,97,34,125,44,34,105,110,115,101,114,116,34,58,34,36,34,125,93,44,34,99,104,101,99,107,101,100,34,58,116,114,117,101,125,1,0,199,130,209,189,2,202,2,1,161,199,130,209,189,2,208,2,1,129,199,130,209,189,2,246,2,1,161,199,130,209,189,2,247,2,1,129,199,130,209,189,2,248,2,1,161,199,130,209,189,2,249,2,1,129,199,130,209,189,2,250,2,1,161,199,130,209,189,2,251,2,1,129,199,130,209,189,2,252,2,1,161,199,130,209,189,2,253,2,1,129,199,130,209,189,2,254,2,1,161,199,130,209,189,2,255,2,1,129,199,130,209,189,2,128,3,1,161,199,130,209,189,2,129,3,8,132,199,130,209,189,2,130,3,1,119,161,199,130,209,189,2,138,3,1,132,199,130,209,189,2,139,3,1,105,161,199,130,209,189,2,140,3,1,132,199,130,209,189,2,141,3,1,116,161,199,130,209,189,2,142,3,1,132,199,130,209,189,2,143,3,1,104,161,199,130,209,189,2,144,3,1,39,0,204,195,206,156,1,4,6,55,99,74,88,114,112,2,39,0,204,195,206,156,1,1,6,86,111,54,70,109,81,1,40,0,199,130,209,189,2,148,3,2,105,100,1,119,6,86,111,54,70,109,81,40,0,199,130,209,189,2,148,3,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,199,130,209,189,2,148,3,6,112,97,114,101,110,116,1,119,10,77,48,104,84,99,67,120,66,88,82,40,0,199,130,209,189,2,148,3,8,99,104,105,108,100,114,101,110,1,119,6,106,82,78,118,55,111,33,0,199,130,209,189,2,148,3,4,100,97,116,97,1,40,0,199,130,209,189,2,148,3,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,148,3,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,106,82,78,118,55,111,0,136,171,236,222,251,5,206,3,1,119,6,86,111,54,70,109,81,4,0,199,130,209,189,2,147,3,4,240,159,152,131,168,199,130,209,189,2,153,3,1,119,29,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,240,159,152,131,34,125,93,125,39,0,204,195,206,156,1,4,6,103,89,121,119,78,121,2,33,0,204,195,206,156,1,1,6,84,67,99,110,98,71,1,0,7,33,0,204,195,206,156,1,3,6,82,117,68,55,67,100,1,193,204,195,206,156,1,232,1,198,223,206,159,1,149,1,1,39,0,204,195,206,156,1,1,6,101,77,66,121,99,80,1,40,0,199,130,209,189,2,172,3,2,105,100,1,119,6,101,77,66,121,99,80,40,0,199,130,209,189,2,172,3,2,116,121,1,119,7,111,117,116,108,105,110,101,40,0,199,130,209,189,2,172,3,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,199,130,209,189,2,172,3,8,99,104,105,108,100,114,101,110,1,119,6,112,72,116,98,67,52,33,0,199,130,209,189,2,172,3,4,100,97,116,97,1,40,0,199,130,209,189,2,172,3,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,172,3,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,112,72,116,98,67,52,0,200,204,195,206,156,1,232,1,199,130,209,189,2,171,3,1,119,6,101,77,66,121,99,80,39,0,204,195,206,156,1,4,6,95,97,88,90,88,80,2,33,0,204,195,206,156,1,1,6,81,89,119,70,83,48,1,0,7,33,0,204,195,206,156,1,3,6,88,110,80,104,117,89,1,193,199,130,209,189,2,171,3,198,223,206,159,1,149,1,1,39,0,204,195,206,156,1,4,6,110,90,88,77,89,67,2,39,0,204,195,206,156,1,4,6,85,99,120,53,52,69,2,39,0,204,195,206,156,1,4,6,69,82,71,102,107,88,2,39,0,204,195,206,156,1,4,6,71,108,50,57,116,102,2,39,0,204,195,206,156,1,1,6,120,49,100,100,111,87,1,40,0,199,130,209,189,2,197,3,2,105,100,1,119,6,120,49,100,100,111,87,40,0,199,130,209,189,2,197,3,2,116,121,1,119,5,116,97,98,108,101,40,0,199,130,209,189,2,197,3,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,199,130,209,189,2,197,3,8,99,104,105,108,100,114,101,110,1,119,6,100,49,110,86,107,119,33,0,199,130,209,189,2,197,3,4,100,97,116,97,1,40,0,199,130,209,189,2,197,3,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,197,3,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,100,49,110,86,107,119,0,200,199,130,209,189,2,171,3,199,130,209,189,2,192,3,1,119,6,120,49,100,100,111,87,39,0,204,195,206,156,1,1,6,121,57,72,73,118,95,1,40,0,199,130,209,189,2,207,3,2,105,100,1,119,6,121,57,72,73,118,95,40,0,199,130,209,189,2,207,3,2,116,121,1,119,10,116,97,98,108,101,47,99,101,108,108,40,0,199,130,209,189,2,207,3,6,112,97,114,101,110,116,1,119,6,120,49,100,100,111,87,40,0,199,130,209,189,2,207,3,8,99,104,105,108,100,114,101,110,1,119,6,78,104,69,49,119,116,33,0,199,130,209,189,2,207,3,4,100,97,116,97,1,40,0,199,130,209,189,2,207,3,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,207,3,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,78,104,69,49,119,116,0,8,0,199,130,209,189,2,205,3,1,119,6,121,57,72,73,118,95,39,0,204,195,206,156,1,1,6,48,83,82,103,66,118,1,40,0,199,130,209,189,2,217,3,2,105,100,1,119,6,48,83,82,103,66,118,40,0,199,130,209,189,2,217,3,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,199,130,209,189,2,217,3,6,112,97,114,101,110,116,1,119,6,121,57,72,73,118,95,40,0,199,130,209,189,2,217,3,8,99,104,105,108,100,114,101,110,1,119,6,107,108,100,67,117,111,33,0,199,130,209,189,2,217,3,4,100,97,116,97,1,40,0,199,130,209,189,2,217,3,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,217,3,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,107,108,100,67,117,111,0,8,0,199,130,209,189,2,215,3,1,119,6,48,83,82,103,66,118,39,0,204,195,206,156,1,1,6,95,90,90,78,53,99,1,40,0,199,130,209,189,2,227,3,2,105,100,1,119,6,95,90,90,78,53,99,40,0,199,130,209,189,2,227,3,2,116,121,1,119,10,116,97,98,108,101,47,99,101,108,108,40,0,199,130,209,189,2,227,3,6,112,97,114,101,110,116,1,119,6,120,49,100,100,111,87,40,0,199,130,209,189,2,227,3,8,99,104,105,108,100,114,101,110,1,119,6,103,89,69,98,121,107,33,0,199,130,209,189,2,227,3,4,100,97,116,97,1,40,0,199,130,209,189,2,227,3,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,227,3,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,103,89,69,98,121,107,0,136,199,130,209,189,2,216,3,1,119,6,95,90,90,78,53,99,39,0,204,195,206,156,1,1,6,77,106,74,57,74,76,1,40,0,199,130,209,189,2,237,3,2,105,100,1,119,6,77,106,74,57,74,76,40,0,199,130,209,189,2,237,3,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,199,130,209,189,2,237,3,6,112,97,114,101,110,116,1,119,6,95,90,90,78,53,99,40,0,199,130,209,189,2,237,3,8,99,104,105,108,100,114,101,110,1,119,6,95,102,81,84,95,110,33,0,199,130,209,189,2,237,3,4,100,97,116,97,1,40,0,199,130,209,189,2,237,3,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,237,3,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,95,102,81,84,95,110,0,8,0,199,130,209,189,2,235,3,1,119,6,77,106,74,57,74,76,39,0,204,195,206,156,1,1,6,78,77,45,104,67,70,1,40,0,199,130,209,189,2,247,3,2,105,100,1,119,6,78,77,45,104,67,70,40,0,199,130,209,189,2,247,3,2,116,121,1,119,10,116,97,98,108,101,47,99,101,108,108,40,0,199,130,209,189,2,247,3,6,112,97,114,101,110,116,1,119,6,120,49,100,100,111,87,40,0,199,130,209,189,2,247,3,8,99,104,105,108,100,114,101,110,1,119,6,117,118,68,83,80,101,33,0,199,130,209,189,2,247,3,4,100,97,116,97,1,40,0,199,130,209,189,2,247,3,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,247,3,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,117,118,68,83,80,101,0,136,199,130,209,189,2,236,3,1,119,6,78,77,45,104,67,70,39,0,204,195,206,156,1,1,6,69,70,66,45,52,82,1,40,0,199,130,209,189,2,129,4,2,105,100,1,119,6,69,70,66,45,52,82,40,0,199,130,209,189,2,129,4,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,199,130,209,189,2,129,4,6,112,97,114,101,110,116,1,119,6,78,77,45,104,67,70,40,0,199,130,209,189,2,129,4,8,99,104,105,108,100,114,101,110,1,119,6,81,81,77,50,48,66,33,0,199,130,209,189,2,129,4,4,100,97,116,97,1,40,0,199,130,209,189,2,129,4,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,129,4,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,81,81,77,50,48,66,0,8,0,199,130,209,189,2,255,3,1,119,6,69,70,66,45,52,82,39,0,204,195,206,156,1,1,6,98,100,95,105,68,101,1,40,0,199,130,209,189,2,139,4,2,105,100,1,119,6,98,100,95,105,68,101,40,0,199,130,209,189,2,139,4,2,116,121,1,119,10,116,97,98,108,101,47,99,101,108,108,40,0,199,130,209,189,2,139,4,6,112,97,114,101,110,116,1,119,6,120,49,100,100,111,87,40,0,199,130,209,189,2,139,4,8,99,104,105,108,100,114,101,110,1,119,6,105,80,89,69,52,56,33,0,199,130,209,189,2,139,4,4,100,97,116,97,1,40,0,199,130,209,189,2,139,4,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,139,4,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,105,80,89,69,52,56,0,136,199,130,209,189,2,128,4,1,119,6,98,100,95,105,68,101,39,0,204,195,206,156,1,1,6,55,51,88,69,103,80,1,40,0,199,130,209,189,2,149,4,2,105,100,1,119,6,55,51,88,69,103,80,40,0,199,130,209,189,2,149,4,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,199,130,209,189,2,149,4,6,112,97,114,101,110,116,1,119,6,98,100,95,105,68,101,40,0,199,130,209,189,2,149,4,8,99,104,105,108,100,114,101,110,1,119,6,115,45,80,102,105,89,33,0,199,130,209,189,2,149,4,4,100,97,116,97,1,40,0,199,130,209,189,2,149,4,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,149,4,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,115,45,80,102,105,89,0,8,0,199,130,209,189,2,147,4,1,119,6,55,51,88,69,103,80,161,199,130,209,189,2,202,3,1,4,0,199,130,209,189,2,193,3,1,56,161,199,130,209,189,2,222,3,1,132,199,130,209,189,2,160,4,1,56,161,199,130,209,189,2,161,4,1,132,199,130,209,189,2,162,4,1,56,161,199,130,209,189,2,163,4,1,132,199,130,209,189,2,164,4,1,56,161,199,130,209,189,2,165,4,1,132,199,130,209,189,2,166,4,1,56,161,199,130,209,189,2,167,4,1,4,0,199,130,209,189,2,196,3,1,57,161,199,130,209,189,2,154,4,1,132,199,130,209,189,2,170,4,1,57,161,199,130,209,189,2,171,4,1,132,199,130,209,189,2,172,4,1,57,161,199,130,209,189,2,173,4,1,132,199,130,209,189,2,174,4,1,57,161,199,130,209,189,2,175,4,1,0,4,39,0,204,195,206,156,1,4,6,56,85,53,118,100,78,2,39,0,204,195,206,156,1,1,6,78,99,104,45,81,78,1,40,0,199,130,209,189,2,183,4,2,105,100,1,119,6,78,99,104,45,81,78,40,0,199,130,209,189,2,183,4,2,116,121,1,119,13,98,117,108,108,101,116,101,100,95,108,105,115,116,40,0,199,130,209,189,2,183,4,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,199,130,209,189,2,183,4,8,99,104,105,108,100,114,101,110,1,119,6,122,97,90,84,55,68,33,0,199,130,209,189,2,183,4,4,100,97,116,97,1,40,0,199,130,209,189,2,183,4,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,183,4,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,122,97,90,84,55,68,0,200,204,195,206,156,1,246,1,204,195,206,156,1,247,1,1,119,6,78,99,104,45,81,78,1,0,199,130,209,189,2,182,4,1,161,199,130,209,189,2,188,4,1,129,199,130,209,189,2,193,4,1,161,199,130,209,189,2,194,4,1,129,199,130,209,189,2,195,4,1,161,199,130,209,189,2,196,4,1,39,0,204,195,206,156,1,4,6,75,102,57,98,106,87,2,33,0,204,195,206,156,1,1,6,119,73,53,75,113,116,1,0,7,33,0,204,195,206,156,1,3,6,115,76,56,78,88,117,1,193,204,195,206,156,1,247,1,204,195,206,156,1,248,1,1,39,0,204,195,206,156,1,4,6,48,72,111,66,111,70,2,39,0,204,195,206,156,1,1,6,84,67,90,121,70,52,1,40,0,199,130,209,189,2,211,4,2,105,100,1,119,6,84,67,90,121,70,52,40,0,199,130,209,189,2,211,4,2,116,121,1,119,13,98,117,108,108,101,116,101,100,95,108,105,115,116,40,0,199,130,209,189,2,211,4,6,112,97,114,101,110,116,1,119,6,78,99,104,45,81,78,40,0,199,130,209,189,2,211,4,8,99,104,105,108,100,114,101,110,1,119,6,108,99,89,77,103,95,33,0,199,130,209,189,2,211,4,4,100,97,116,97,1,40,0,199,130,209,189,2,211,4,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,211,4,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,108,99,89,77,103,95,0,8,0,199,130,209,189,2,191,4,1,119,6,84,67,90,121,70,52,1,0,199,130,209,189,2,210,4,1,161,199,130,209,189,2,216,4,1,129,199,130,209,189,2,221,4,1,161,199,130,209,189,2,222,4,1,129,199,130,209,189,2,223,4,1,161,199,130,209,189,2,224,4,1,39,0,204,195,206,156,1,4,6,108,73,54,101,68,85,2,33,0,204,195,206,156,1,1,6,67,118,56,72,55,83,1,0,7,33,0,204,195,206,156,1,3,6,107,119,71,100,66,65,1,129,199,130,209,189,2,220,4,1,39,0,204,195,206,156,1,4,6,57,83,80,71,121,88,2,39,0,204,195,206,156,1,1,6,52,119,120,102,90,72,1,40,0,199,130,209,189,2,239,4,2,105,100,1,119,6,52,119,120,102,90,72,40,0,199,130,209,189,2,239,4,2,116,121,1,119,13,98,117,108,108,101,116,101,100,95,108,105,115,116,40,0,199,130,209,189,2,239,4,6,112,97,114,101,110,116,1,119,6,84,67,90,121,70,52,40,0,199,130,209,189,2,239,4,8,99,104,105,108,100,114,101,110,1,119,6,118,103,105,70,69,106,33,0,199,130,209,189,2,239,4,4,100,97,116,97,1,40,0,199,130,209,189,2,239,4,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,239,4,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,118,103,105,70,69,106,0,8,0,199,130,209,189,2,219,4,1,119,6,52,119,120,102,90,72,1,0,199,130,209,189,2,238,4,1,161,199,130,209,189,2,244,4,1,129,199,130,209,189,2,249,4,1,161,199,130,209,189,2,250,4,1,129,199,130,209,189,2,251,4,1,161,199,130,209,189,2,252,4,1,39,0,204,195,206,156,1,4,6,106,81,55,52,49,100,2,39,0,204,195,206,156,1,1,6,109,102,89,53,57,121,1,40,0,199,130,209,189,2,128,5,2,105,100,1,119,6,109,102,89,53,57,121,40,0,199,130,209,189,2,128,5,2,116,121,1,119,13,98,117,108,108,101,116,101,100,95,108,105,115,116,40,0,199,130,209,189,2,128,5,6,112,97,114,101,110,116,1,119,6,84,67,90,121,70,52,40,0,199,130,209,189,2,128,5,8,99,104,105,108,100,114,101,110,1,119,6,71,116,121,76,66,108,33,0,199,130,209,189,2,128,5,4,100,97,116,97,1,40,0,199,130,209,189,2,128,5,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,128,5,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,71,116,121,76,66,108,0,136,199,130,209,189,2,248,4,1,119,6,109,102,89,53,57,121,1,0,199,130,209,189,2,255,4,1,161,199,130,209,189,2,133,5,1,129,199,130,209,189,2,138,5,1,161,199,130,209,189,2,139,5,1,129,199,130,209,189,2,140,5,1,161,199,130,209,189,2,141,5,1,39,0,204,195,206,156,1,4,6,99,82,52,54,74,83,2,33,0,204,195,206,156,1,1,6,95,95,82,90,106,107,1,0,7,33,0,204,195,206,156,1,3,6,117,113,87,99,122,50,1,129,199,130,209,189,2,137,5,1,4,0,199,130,209,189,2,144,5,1,49,0,1,132,199,130,209,189,2,155,5,1,50,0,1,132,199,130,209,189,2,157,5,1,51,0,1,39,0,204,195,206,156,1,4,6,84,108,76,116,78,119,2,1,0,199,130,209,189,2,161,5,3,39,0,204,195,206,156,1,1,6,87,57,68,108,99,56,1,40,0,199,130,209,189,2,165,5,2,105,100,1,119,6,87,57,68,108,99,56,40,0,199,130,209,189,2,165,5,2,116,121,1,119,13,98,117,108,108,101,116,101,100,95,108,105,115,116,40,0,199,130,209,189,2,165,5,6,112,97,114,101,110,116,1,119,6,78,99,104,45,81,78,40,0,199,130,209,189,2,165,5,8,99,104,105,108,100,114,101,110,1,119,6,71,113,89,119,74,81,33,0,199,130,209,189,2,165,5,4,100,97,116,97,1,40,0,199,130,209,189,2,165,5,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,165,5,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,71,113,89,119,74,81,0,136,199,130,209,189,2,237,4,1,119,6,87,57,68,108,99,56,129,199,130,209,189,2,164,5,1,161,199,130,209,189,2,170,5,1,129,199,130,209,189,2,175,5,1,161,199,130,209,189,2,176,5,1,129,199,130,209,189,2,177,5,1,161,199,130,209,189,2,178,5,1,39,0,204,195,206,156,1,4,6,105,45,118,52,52,66,2,33,0,204,195,206,156,1,1,6,116,72,104,110,105,69,1,0,7,33,0,204,195,206,156,1,3,6,79,69,107,76,69,106,1,129,199,130,209,189,2,174,5,1,1,0,199,130,209,189,2,181,5,1,0,1,129,199,130,209,189,2,192,5,1,0,3,132,199,130,209,189,2,194,5,1,62,0,1,39,0,204,195,206,156,1,4,6,76,115,116,55,78,103,2,39,0,204,195,206,156,1,1,6,113,90,76,56,88,88,1,40,0,199,130,209,189,2,201,5,2,105,100,1,119,6,113,90,76,56,88,88,40,0,199,130,209,189,2,201,5,2,116,121,1,119,11,116,111,103,103,108,101,95,108,105,115,116,40,0,199,130,209,189,2,201,5,6,112,97,114,101,110,116,1,119,6,78,99,104,45,81,78,40,0,199,130,209,189,2,201,5,8,99,104,105,108,100,114,101,110,1,119,6,49,98,68,104,69,101,33,0,199,130,209,189,2,201,5,4,100,97,116,97,1,40,0,199,130,209,189,2,201,5,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,201,5,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,49,98,68,104,69,101,0,200,199,130,209,189,2,174,5,199,130,209,189,2,191,5,1,119,6,113,90,76,56,88,88,1,0,199,130,209,189,2,200,5,1,161,199,130,209,189,2,206,5,1,129,199,130,209,189,2,211,5,1,161,199,130,209,189,2,212,5,1,129,199,130,209,189,2,213,5,1,161,199,130,209,189,2,214,5,1,39,0,204,195,206,156,1,4,6,88,103,107,99,56,110,2,39,0,204,195,206,156,1,1,6,105,66,98,109,87,48,1,40,0,199,130,209,189,2,218,5,2,105,100,1,119,6,105,66,98,109,87,48,40,0,199,130,209,189,2,218,5,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,199,130,209,189,2,218,5,6,112,97,114,101,110,116,1,119,6,113,90,76,56,88,88,40,0,199,130,209,189,2,218,5,8,99,104,105,108,100,114,101,110,1,119,6,72,95,104,114,75,71,33,0,199,130,209,189,2,218,5,4,100,97,116,97,1,40,0,199,130,209,189,2,218,5,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,218,5,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,72,95,104,114,75,71,0,8,0,199,130,209,189,2,209,5,1,119,6,105,66,98,109,87,48,161,199,130,209,189,2,216,5,1,1,0,199,130,209,189,2,217,5,1,161,199,130,209,189,2,223,5,1,129,199,130,209,189,2,229,5,1,161,199,130,209,189,2,230,5,1,129,199,130,209,189,2,231,5,1,161,199,130,209,189,2,232,5,1,39,0,204,195,206,156,1,4,6,52,90,104,112,86,73,2,33,0,204,195,206,156,1,1,6,78,79,116,108,71,74,1,0,7,33,0,204,195,206,156,1,3,6,52,107,104,115,81,48,1,129,199,130,209,189,2,227,5,1,39,0,204,195,206,156,1,4,6,74,98,106,103,98,105,2,39,0,204,195,206,156,1,1,6,55,71,119,105,74,83,1,40,0,199,130,209,189,2,247,5,2,105,100,1,119,6,55,71,119,105,74,83,40,0,199,130,209,189,2,247,5,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,199,130,209,189,2,247,5,6,112,97,114,101,110,116,1,119,6,105,66,98,109,87,48,40,0,199,130,209,189,2,247,5,8,99,104,105,108,100,114,101,110,1,119,6,99,53,87,50,53,102,33,0,199,130,209,189,2,247,5,4,100,97,116,97,1,40,0,199,130,209,189,2,247,5,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,247,5,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,99,53,87,50,53,102,0,8,0,199,130,209,189,2,226,5,1,119,6,55,71,119,105,74,83,1,0,199,130,209,189,2,246,5,1,161,199,130,209,189,2,252,5,1,129,199,130,209,189,2,129,6,1,161,199,130,209,189,2,130,6,1,129,199,130,209,189,2,131,6,1,161,199,130,209,189,2,132,6,1,39,0,204,195,206,156,1,4,6,118,87,56,68,45,102,2,33,0,204,195,206,156,1,1,6,99,88,73,114,105,45,1,0,7,33,0,204,195,206,156,1,3,6,122,70,104,98,74,88,1,129,199,130,209,189,2,128,6,1,39,0,204,195,206,156,1,4,6,86,65,80,82,86,55,2,33,0,204,195,206,156,1,1,6,99,72,102,57,114,111,1,0,7,33,0,204,195,206,156,1,3,6,70,112,56,103,98,56,1,129,199,130,209,189,2,245,5,1,4,0,199,130,209,189,2,146,6,1,49,0,1,132,199,130,209,189,2,157,6,1,50,0,1,132,199,130,209,189,2,159,6,1,51,0,1,39,0,204,195,206,156,1,4,6,95,70,68,79,103,89,2,4,0,199,130,209,189,2,163,6,3,49,50,51,33,0,204,195,206,156,1,1,6,84,69,81,71,120,89,1,0,7,33,0,204,195,206,156,1,3,6,72,120,102,70,78,49,1,129,199,130,209,189,2,191,5,1,68,199,130,209,189,2,193,4,1,98,161,199,130,209,189,2,198,4,1,132,199,130,209,189,2,197,4,1,117,161,199,130,209,189,2,178,6,1,129,199,130,209,189,2,179,6,1,132,199,130,209,189,2,181,6,1,108,161,199,130,209,189,2,180,6,2,132,199,130,209,189,2,182,6,1,108,161,199,130,209,189,2,184,6,1,132,199,130,209,189,2,185,6,1,101,161,199,130,209,189,2,186,6,1,132,199,130,209,189,2,187,6,1,116,161,199,130,209,189,2,188,6,1,132,199,130,209,189,2,189,6,1,101,161,199,130,209,189,2,190,6,1,132,199,130,209,189,2,191,6,1,100,161,199,130,209,189,2,192,6,1,132,199,130,209,189,2,193,6,1,32,161,199,130,209,189,2,194,6,1,132,199,130,209,189,2,195,6,1,108,161,199,130,209,189,2,196,6,1,132,199,130,209,189,2,197,6,1,105,161,199,130,209,189,2,198,6,1,132,199,130,209,189,2,199,6,1,115,161,199,130,209,189,2,200,6,1,132,199,130,209,189,2,201,6,1,116,168,199,130,209,189,2,202,6,1,119,38,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,98,117,108,108,101,116,101,100,32,108,105,115,116,34,125,93,125,68,199,130,209,189,2,221,4,1,99,161,199,130,209,189,2,226,4,1,132,199,130,209,189,2,225,4,1,104,161,199,130,209,189,2,206,6,1,132,199,130,209,189,2,207,6,1,105,161,199,130,209,189,2,208,6,1,132,199,130,209,189,2,209,6,1,108,161,199,130,209,189,2,210,6,1,132,199,130,209,189,2,211,6,1,100,161,199,130,209,189,2,212,6,1,129,199,130,209,189,2,213,6,1,161,199,130,209,189,2,214,6,2,132,199,130,209,189,2,215,6,1,45,161,199,130,209,189,2,217,6,1,132,199,130,209,189,2,218,6,1,49,168,199,130,209,189,2,219,6,1,119,32,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,99,104,105,108,100,45,49,34,125,93,125,68,199,130,209,189,2,249,4,1,99,161,199,130,209,189,2,254,4,1,132,199,130,209,189,2,253,4,1,104,161,199,130,209,189,2,223,6,1,132,199,130,209,189,2,224,6,1,105,161,199,130,209,189,2,225,6,1,132,199,130,209,189,2,226,6,1,108,161,199,130,209,189,2,227,6,1,132,199,130,209,189,2,228,6,1,100,161,199,130,209,189,2,229,6,1,132,199,130,209,189,2,230,6,1,45,161,199,130,209,189,2,231,6,1,132,199,130,209,189,2,232,6,1,49,161,199,130,209,189,2,233,6,1,132,199,130,209,189,2,234,6,1,45,161,199,130,209,189,2,235,6,1,132,199,130,209,189,2,236,6,1,49,168,199,130,209,189,2,237,6,1,119,34,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,99,104,105,108,100,45,49,45,49,34,125,93,125,68,199,130,209,189,2,138,5,1,99,161,199,130,209,189,2,143,5,1,132,199,130,209,189,2,142,5,1,104,161,199,130,209,189,2,241,6,1,132,199,130,209,189,2,242,6,1,105,161,199,130,209,189,2,243,6,1,132,199,130,209,189,2,244,6,1,108,161,199,130,209,189,2,245,6,1,132,199,130,209,189,2,246,6,1,100,161,199,130,209,189,2,247,6,1,132,199,130,209,189,2,248,6,1,45,161,199,130,209,189,2,249,6,1,132,199,130,209,189,2,250,6,1,49,161,199,130,209,189,2,251,6,1,132,199,130,209,189,2,252,6,1,45,161,199,130,209,189,2,253,6,1,132,199,130,209,189,2,254,6,1,50,168,199,130,209,189,2,255,6,1,119,34,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,99,104,105,108,100,45,49,45,50,34,125,93,125,68,199,130,209,189,2,162,5,1,99,161,199,130,209,189,2,180,5,1,132,199,130,209,189,2,179,5,1,104,161,199,130,209,189,2,131,7,1,132,199,130,209,189,2,132,7,1,105,161,199,130,209,189,2,133,7,1,132,199,130,209,189,2,134,7,1,108,161,199,130,209,189,2,135,7,1,132,199,130,209,189,2,136,7,1,100,161,199,130,209,189,2,137,7,1,132,199,130,209,189,2,138,7,1,45,161,199,130,209,189,2,139,7,1,132,199,130,209,189,2,140,7,1,50,168,199,130,209,189,2,141,7,1,119,32,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,99,104,105,108,100,45,50,34,125,93,125,65,199,130,209,189,2,211,5,1,161,199,130,209,189,2,228,5,1,129,199,130,209,189,2,215,5,1,161,199,130,209,189,2,145,7,1,129,199,130,209,189,2,146,7,1,161,199,130,209,189,2,147,7,1,129,199,130,209,189,2,148,7,1,161,199,130,209,189,2,149,7,1,129,199,130,209,189,2,150,7,1,161,199,130,209,189,2,151,7,1,129,199,130,209,189,2,152,7,1,161,199,130,209,189,2,153,7,1,129,199,130,209,189,2,154,7,1,161,199,130,209,189,2,155,7,8,132,199,130,209,189,2,156,7,1,116,161,199,130,209,189,2,164,7,1,132,199,130,209,189,2,165,7,1,111,161,199,130,209,189,2,166,7,1,132,199,130,209,189,2,167,7,1,103,161,199,130,209,189,2,168,7,1,132,199,130,209,189,2,169,7,1,103,161,199,130,209,189,2,170,7,1,132,199,130,209,189,2,171,7,1,108,161,199,130,209,189,2,172,7,1,132,199,130,209,189,2,173,7,1,101,161,199,130,209,189,2,174,7,1,132,199,130,209,189,2,175,7,1,32,161,199,130,209,189,2,176,7,1,132,199,130,209,189,2,177,7,1,108,161,199,130,209,189,2,178,7,1,132,199,130,209,189,2,179,7,1,105,161,199,130,209,189,2,180,7,1,132,199,130,209,189,2,181,7,1,115,161,199,130,209,189,2,182,7,1,132,199,130,209,189,2,183,7,1,116,168,199,130,209,189,2,184,7,1,119,54,123,34,99,111,108,108,97,112,115,101,100,34,58,102,97,108,115,101,44,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,116,111,103,103,108,101,32,108,105,115,116,34,125,93,125,68,199,130,209,189,2,229,5,1,99,161,199,130,209,189,2,234,5,1,132,199,130,209,189,2,233,5,1,104,161,199,130,209,189,2,188,7,1,132,199,130,209,189,2,189,7,1,105,161,199,130,209,189,2,190,7,1,132,199,130,209,189,2,191,7,1,108,161,199,130,209,189,2,192,7,1,132,199,130,209,189,2,193,7,1,100,161,199,130,209,189,2,194,7,1,132,199,130,209,189,2,195,7,1,45,161,199,130,209,189,2,196,7,1,129,199,130,209,189,2,197,7,1,161,199,130,209,189,2,198,7,2,132,199,130,209,189,2,199,7,1,49,168,199,130,209,189,2,201,7,1,119,32,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,99,104,105,108,100,45,49,34,125,93,125,68,199,130,209,189,2,129,6,1,99,161,199,130,209,189,2,134,6,1,132,199,130,209,189,2,133,6,1,104,161,199,130,209,189,2,205,7,1,132,199,130,209,189,2,206,7,1,105,161,199,130,209,189,2,207,7,1,132,199,130,209,189,2,208,7,1,108,161,199,130,209,189,2,209,7,1,132,199,130,209,189,2,210,7,1,100,161,199,130,209,189,2,211,7,1,132,199,130,209,189,2,212,7,1,45,161,199,130,209,189,2,213,7,1,132,199,130,209,189,2,214,7,1,49,161,199,130,209,189,2,215,7,1,129,199,130,209,189,2,216,7,1,161,199,130,209,189,2,217,7,1,129,199,130,209,189,2,218,7,1,161,199,130,209,189,2,219,7,3,129,199,130,209,189,2,220,7,1,161,199,130,209,189,2,223,7,1,129,199,130,209,189,2,224,7,1,161,199,130,209,189,2,225,7,3,132,199,130,209,189,2,226,7,1,45,161,199,130,209,189,2,229,7,1,132,199,130,209,189,2,230,7,1,49,168,199,130,209,189,2,231,7,1,119,34,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,99,104,105,108,100,45,49,45,49,34,125,93,125,39,0,204,195,206,156,1,4,6,55,88,55,105,70,103,2,39,0,204,195,206,156,1,1,6,101,79,68,109,108,65,1,40,0,199,130,209,189,2,235,7,2,105,100,1,119,6,101,79,68,109,108,65,40,0,199,130,209,189,2,235,7,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,199,130,209,189,2,235,7,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,199,130,209,189,2,235,7,8,99,104,105,108,100,114,101,110,1,119,6,109,112,74,69,74,90,33,0,199,130,209,189,2,235,7,4,100,97,116,97,1,40,0,199,130,209,189,2,235,7,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,199,130,209,189,2,235,7,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,109,112,74,69,74,90,0,200,204,195,206,156,1,242,1,204,195,206,156,1,243,1,1,119,6,101,79,68,109,108,65,168,204,195,206,156,1,164,1,1,119,79,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,75,101,121,98,111,97,114,100,32,115,104,111,114,116,99,117,116,115,44,32,109,97,114,107,100,111,119,110,44,32,97,110,100,32,99,111,100,101,32,98,108,111,99,107,34,125,93,44,34,108,101,118,101,108,34,58,50,125,168,204,195,206,156,1,165,1,1,119,10,97,98,100,49,105,117,71,81,109,68,168,204,195,206,156,1,166,1,1,119,4,116,101,120,116,1,0,199,130,209,189,2,234,7,1,161,199,130,209,189,2,240,7,1,168,199,130,209,189,2,249,7,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,132,199,130,209,189,2,48,1,32,161,199,130,209,189,2,61,1,129,199,130,209,189,2,251,7,1,161,199,130,209,189,2,252,7,1,134,199,130,209,189,2,253,7,7,109,101,110,116,105,111,110,51,123,34,116,121,112,101,34,58,34,100,97,116,101,34,44,34,100,97,116,101,34,58,34,50,48,50,52,45,48,52,45,49,56,84,49,52,58,50,53,58,51,50,46,52,53,55,50,55,55,34,125,132,199,130,209,189,2,255,7,1,36,134,199,130,209,189,2,128,8,7,109,101,110,116,105,111,110,4,110,117,108,108,161,199,130,209,189,2,254,7,1,132,199,130,209,189,2,129,8,1,109,161,199,130,209,189,2,130,8,1,132,199,130,209,189,2,131,8,1,101,161,199,130,209,189,2,132,8,1,132,199,130,209,189,2,133,8,1,110,161,199,130,209,189,2,134,8,1,129,199,130,209,189,2,135,8,1,132,199,130,209,189,2,137,8,1,116,161,199,130,209,189,2,136,8,2,1,236,158,128,159,2,0,161,219,200,174,197,9,24,4,1,245,181,155,135,2,0,161,151,234,142,238,11,26,23,176,1,146,216,250,133,2,0,161,243,138,171,183,10,60,1,161,243,138,171,183,10,61,1,161,243,138,171,183,10,62,1,161,243,138,171,183,10,71,1,161,243,138,171,183,10,63,1,161,243,138,171,183,10,64,1,161,243,138,171,183,10,65,1,161,146,216,250,133,2,3,1,161,243,138,171,183,10,67,1,161,243,138,171,183,10,68,1,161,243,138,171,183,10,69,1,161,146,216,250,133,2,7,1,161,243,138,171,183,10,84,1,161,243,138,171,183,10,85,1,161,243,138,171,183,10,86,1,161,243,138,171,183,10,95,3,161,243,138,171,183,10,91,1,161,243,138,171,183,10,92,1,161,243,138,171,183,10,93,1,161,243,138,171,183,10,87,1,161,243,138,171,183,10,88,1,161,243,138,171,183,10,89,1,161,243,138,171,183,10,96,1,161,243,138,171,183,10,97,1,161,243,138,171,183,10,98,1,161,243,138,171,183,10,107,1,161,243,138,171,183,10,100,1,161,243,138,171,183,10,101,1,161,243,138,171,183,10,102,1,161,146,216,250,133,2,27,1,161,243,138,171,183,10,104,1,161,243,138,171,183,10,105,1,161,243,138,171,183,10,106,1,161,146,216,250,133,2,31,1,161,243,138,171,183,10,108,1,161,243,138,171,183,10,109,1,161,243,138,171,183,10,110,1,161,243,138,171,183,10,119,1,161,243,138,171,183,10,112,1,161,243,138,171,183,10,113,1,161,243,138,171,183,10,114,1,161,146,216,250,133,2,39,1,161,243,138,171,183,10,116,1,161,243,138,171,183,10,117,1,161,243,138,171,183,10,118,1,161,146,216,250,133,2,43,1,161,243,138,171,183,10,120,1,161,243,138,171,183,10,121,1,161,243,138,171,183,10,122,1,161,243,138,171,183,10,123,1,161,243,138,171,183,10,124,1,161,243,138,171,183,10,125,1,161,243,138,171,183,10,131,1,2,161,243,138,171,183,10,127,1,161,243,138,171,183,10,128,1,1,161,243,138,171,183,10,129,1,1,161,146,216,250,133,2,55,1,161,243,138,171,183,10,132,1,1,161,243,138,171,183,10,133,1,1,161,243,138,171,183,10,134,1,1,161,243,138,171,183,10,143,1,1,161,243,138,171,183,10,136,1,1,161,243,138,171,183,10,137,1,1,161,243,138,171,183,10,138,1,1,161,146,216,250,133,2,63,1,161,243,138,171,183,10,140,1,1,161,243,138,171,183,10,141,1,1,161,243,138,171,183,10,142,1,1,161,146,216,250,133,2,67,1,161,243,138,171,183,10,144,1,1,161,243,138,171,183,10,145,1,1,161,243,138,171,183,10,146,1,1,161,243,138,171,183,10,155,1,1,161,243,138,171,183,10,148,1,1,161,243,138,171,183,10,149,1,1,161,243,138,171,183,10,150,1,1,161,146,216,250,133,2,75,1,161,243,138,171,183,10,152,1,1,161,243,138,171,183,10,153,1,1,161,243,138,171,183,10,154,1,1,161,146,216,250,133,2,79,1,161,243,138,171,183,10,156,1,1,161,243,138,171,183,10,157,1,1,161,243,138,171,183,10,158,1,1,161,243,138,171,183,10,167,1,1,161,243,138,171,183,10,160,1,1,161,243,138,171,183,10,161,1,1,161,243,138,171,183,10,162,1,1,161,146,216,250,133,2,87,1,161,243,138,171,183,10,164,1,1,161,243,138,171,183,10,165,1,1,161,243,138,171,183,10,166,1,1,161,146,216,250,133,2,91,1,161,243,138,171,183,10,168,1,1,161,243,138,171,183,10,169,1,1,161,243,138,171,183,10,170,1,1,161,243,138,171,183,10,179,1,1,161,243,138,171,183,10,176,1,1,161,243,138,171,183,10,177,1,1,161,243,138,171,183,10,178,1,1,161,146,216,250,133,2,99,1,161,243,138,171,183,10,172,1,1,161,243,138,171,183,10,173,1,1,161,243,138,171,183,10,174,1,1,161,146,216,250,133,2,103,1,161,243,138,171,183,10,180,1,1,161,243,138,171,183,10,181,1,1,161,243,138,171,183,10,182,1,1,161,243,138,171,183,10,191,1,1,161,243,138,171,183,10,188,1,1,161,243,138,171,183,10,189,1,1,161,243,138,171,183,10,190,1,1,161,146,216,250,133,2,111,1,161,243,138,171,183,10,184,1,1,161,243,138,171,183,10,185,1,1,161,243,138,171,183,10,186,1,1,161,146,216,250,133,2,115,1,161,243,138,171,183,10,192,1,1,161,243,138,171,183,10,193,1,1,161,243,138,171,183,10,194,1,1,161,243,138,171,183,10,203,1,1,161,243,138,171,183,10,196,1,1,161,243,138,171,183,10,197,1,1,161,243,138,171,183,10,198,1,1,161,146,216,250,133,2,123,1,161,243,138,171,183,10,200,1,1,161,243,138,171,183,10,201,1,1,161,243,138,171,183,10,202,1,1,161,146,216,250,133,2,127,1,161,243,138,171,183,10,204,1,1,161,243,138,171,183,10,205,1,1,161,243,138,171,183,10,206,1,1,161,243,138,171,183,10,215,1,1,161,243,138,171,183,10,209,1,1,161,243,138,171,183,10,210,1,1,161,243,138,171,183,10,211,1,1,161,146,216,250,133,2,135,1,1,161,243,138,171,183,10,212,1,1,161,243,138,171,183,10,213,1,1,161,243,138,171,183,10,214,1,1,161,146,216,250,133,2,139,1,1,161,243,138,171,183,10,216,1,1,161,243,138,171,183,10,217,1,1,161,243,138,171,183,10,218,1,1,161,243,138,171,183,10,227,1,1,161,243,138,171,183,10,220,1,1,161,243,138,171,183,10,221,1,1,161,243,138,171,183,10,222,1,1,161,146,216,250,133,2,147,1,1,161,243,138,171,183,10,224,1,1,161,243,138,171,183,10,225,1,1,161,243,138,171,183,10,226,1,1,161,146,216,250,133,2,151,1,1,161,243,138,171,183,10,228,1,1,161,243,138,171,183,10,229,1,1,161,243,138,171,183,10,230,1,1,161,243,138,171,183,10,239,1,1,161,243,138,171,183,10,232,1,1,161,243,138,171,183,10,233,1,1,161,243,138,171,183,10,234,1,1,161,146,216,250,133,2,159,1,1,161,243,138,171,183,10,236,1,1,161,243,138,171,183,10,237,1,1,161,243,138,171,183,10,238,1,1,161,146,216,250,133,2,163,1,1,161,146,216,250,133,2,156,1,1,161,146,216,250,133,2,157,1,1,161,146,216,250,133,2,158,1,1,161,146,216,250,133,2,160,1,1,161,146,216,250,133,2,161,1,1,161,146,216,250,133,2,162,1,1,161,146,216,250,133,2,167,1,1,161,146,216,250,133,2,164,1,1,161,146,216,250,133,2,165,1,1,161,146,216,250,133,2,166,1,1,161,146,216,250,133,2,174,1,2,9,172,254,181,239,1,0,39,0,204,195,206,156,1,4,6,108,45,56,109,101,45,2,4,0,172,254,181,239,1,0,4,104,106,107,100,161,198,223,206,159,1,153,1,1,132,172,254,181,239,1,4,2,39,100,161,172,254,181,239,1,5,1,132,172,254,181,239,1,7,2,39,100,161,172,254,181,239,1,8,1,132,172,254,181,239,1,10,2,39,100,161,172,254,181,239,1,11,1,1,153,236,182,220,1,0,161,195,254,251,180,11,57,4,10,155,213,159,176,1,0,161,131,182,180,202,12,50,1,161,131,182,180,202,12,51,1,161,131,182,180,202,12,52,1,161,155,213,159,176,1,0,1,161,155,213,159,176,1,1,1,161,155,213,159,176,1,2,1,129,131,182,180,202,12,43,1,161,155,213,159,176,1,3,1,161,155,213,159,176,1,4,1,161,155,213,159,176,1,5,1,179,1,198,223,206,159,1,0,39,0,204,195,206,156,1,4,6,57,70,53,89,108,75,2,4,0,198,223,206,159,1,0,13,103,104,104,104,229,143,145,230,140,165,229,165,189,168,171,236,222,251,5,166,2,1,119,38,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,103,104,104,104,229,143,145,230,140,165,229,165,189,34,125,93,125,39,0,204,195,206,156,1,4,6,89,50,51,82,99,105,2,4,0,198,223,206,159,1,9,12,229,185,178,230,180,187,229,147,136,229,147,136,168,171,236,222,251,5,240,3,1,119,37,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,229,185,178,230,180,187,229,147,136,229,147,136,34,125,93,125,0,3,39,0,204,195,206,156,1,4,6,72,90,117,111,112,102,2,33,0,204,195,206,156,1,1,6,67,102,80,66,48,85,1,0,7,33,0,204,195,206,156,1,3,6,81,117,121,48,102,66,1,193,171,236,222,251,5,196,1,171,236,222,251,5,170,2,1,1,0,198,223,206,159,1,18,3,0,2,129,198,223,206,159,1,31,1,0,4,39,0,204,195,206,156,1,4,6,48,82,103,55,103,55,2,33,0,204,195,206,156,1,1,6,72,51,76,88,97,79,1,0,7,33,0,204,195,206,156,1,3,6,75,83,56,80,116,80,1,193,171,236,222,251,5,196,1,198,223,206,159,1,28,1,39,0,204,195,206,156,1,4,6,105,90,51,118,76,100,2,33,0,204,195,206,156,1,1,6,121,102,76,72,69,119,1,0,7,33,0,204,195,206,156,1,3,6,48,80,108,53,77,98,1,193,171,236,222,251,5,196,1,198,223,206,159,1,49,1,39,0,204,195,206,156,1,4,6,72,97,76,66,45,86,2,33,0,204,195,206,156,1,1,6,98,65,77,76,51,82,1,0,7,33,0,204,195,206,156,1,3,6,81,83,99,52,51,111,1,193,171,236,222,251,5,196,1,198,223,206,159,1,60,1,39,0,204,195,206,156,1,4,6,98,86,122,115,102,101,2,39,0,204,195,206,156,1,1,6,52,90,113,105,51,76,1,40,0,198,223,206,159,1,73,2,105,100,1,119,6,52,90,113,105,51,76,40,0,198,223,206,159,1,73,2,116,121,1,119,5,113,117,111,116,101,40,0,198,223,206,159,1,73,6,112,97,114,101,110,116,1,119,10,77,48,104,84,99,67,120,66,88,82,40,0,198,223,206,159,1,73,8,99,104,105,108,100,114,101,110,1,119,6,98,50,103,102,70,95,33,0,198,223,206,159,1,73,4,100,97,116,97,1,40,0,198,223,206,159,1,73,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,198,223,206,159,1,73,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,98,50,103,102,70,95,0,200,171,236,222,251,5,196,1,198,223,206,159,1,71,1,119,6,52,90,113,105,51,76,4,0,198,223,206,159,1,72,6,231,155,145,230,142,167,168,198,223,206,159,1,78,1,119,31,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,231,155,145,230,142,167,34,125,93,125,193,204,195,206,156,1,244,5,204,195,206,156,1,245,5,3,161,204,195,206,156,1,137,1,1,161,204,195,206,156,1,138,1,1,161,204,195,206,156,1,139,1,1,39,0,204,195,206,156,1,4,6,50,101,101,116,51,53,2,33,0,204,195,206,156,1,1,6,77,103,77,119,109,49,1,0,7,33,0,204,195,206,156,1,3,6,52,76,51,66,86,49,1,193,204,195,206,156,1,232,1,204,195,206,156,1,233,1,1,0,3,39,0,204,195,206,156,1,4,6,100,87,119,54,116,114,2,39,0,204,195,206,156,1,1,6,77,89,55,45,90,70,1,40,0,198,223,206,159,1,107,2,105,100,1,119,6,77,89,55,45,90,70,40,0,198,223,206,159,1,107,2,116,121,1,119,5,113,117,111,116,101,40,0,198,223,206,159,1,107,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,198,223,206,159,1,107,8,99,104,105,108,100,114,101,110,1,119,6,112,88,122,66,110,100,33,0,198,223,206,159,1,107,4,100,97,116,97,1,40,0,198,223,206,159,1,107,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,198,223,206,159,1,107,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,112,88,122,66,110,100,0,200,204,195,206,156,1,232,1,198,223,206,159,1,102,1,119,6,77,89,55,45,90,70,4,0,198,223,206,159,1,106,9,229,144,140,228,184,128,228,184,170,161,198,223,206,159,1,112,1,132,198,223,206,159,1,119,3,106,106,106,161,198,223,206,159,1,120,1,39,0,204,195,206,156,1,4,6,71,121,120,95,72,54,2,33,0,204,195,206,156,1,1,6,83,101,74,81,114,75,1,0,7,33,0,204,195,206,156,1,3,6,122,116,99,78,71,87,1,193,204,195,206,156,1,232,1,198,223,206,159,1,116,1,0,3,39,0,204,195,206,156,1,4,6,51,107,108,102,97,80,2,39,0,204,195,206,156,1,1,6,85,72,48,53,51,70,1,40,0,198,223,206,159,1,140,1,2,105,100,1,119,6,85,72,48,53,51,70,40,0,198,223,206,159,1,140,1,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,198,223,206,159,1,140,1,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,198,223,206,159,1,140,1,8,99,104,105,108,100,114,101,110,1,119,6,52,75,90,73,113,76,33,0,198,223,206,159,1,140,1,4,100,97,116,97,1,40,0,198,223,206,159,1,140,1,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,198,223,206,159,1,140,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,52,75,90,73,113,76,0,200,204,195,206,156,1,232,1,198,223,206,159,1,135,1,1,119,6,85,72,48,53,51,70,4,0,198,223,206,159,1,139,1,3,104,106,107,161,198,223,206,159,1,145,1,1,39,0,204,195,206,156,1,1,6,114,78,78,65,105,82,1,40,0,198,223,206,159,1,154,1,2,105,100,1,119,6,114,78,78,65,105,82,40,0,198,223,206,159,1,154,1,2,116,121,1,119,13,109,97,116,104,95,101,113,117,97,116,105,111,110,40,0,198,223,206,159,1,154,1,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,198,223,206,159,1,154,1,8,99,104,105,108,100,114,101,110,1,119,6,69,82,69,45,78,66,33,0,198,223,206,159,1,154,1,4,100,97,116,97,1,40,0,198,223,206,159,1,154,1,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,198,223,206,159,1,154,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,69,82,69,45,78,66,0,200,204,195,206,156,1,240,1,204,195,206,156,1,241,1,1,119,6,114,78,78,65,105,82,168,198,223,206,159,1,159,1,1,119,24,123,34,102,111,114,109,117,108,97,34,58,34,105,231,156,139,231,187,143,230,181,142,34,125,39,0,204,195,206,156,1,1,6,68,114,122,68,111,83,1,40,0,198,223,206,159,1,165,1,2,105,100,1,119,6,68,114,122,68,111,83,40,0,198,223,206,159,1,165,1,2,116,121,1,119,5,105,109,97,103,101,40,0,198,223,206,159,1,165,1,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,198,223,206,159,1,165,1,8,99,104,105,108,100,114,101,110,1,119,6,57,68,97,108,108,97,33,0,198,223,206,159,1,165,1,4,100,97,116,97,1,40,0,198,223,206,159,1,165,1,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,198,223,206,159,1,165,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,57,68,97,108,108,97,0,200,204,195,206,156,1,251,1,204,195,206,156,1,252,1,1,119,6,68,114,122,68,111,83,161,198,223,206,159,1,170,1,1,39,0,204,195,206,156,1,4,6,102,80,55,52,75,113,2,33,0,204,195,206,156,1,1,6,84,120,69,107,78,52,1,0,7,33,0,204,195,206,156,1,3,6,104,109,65,56,45,115,1,193,204,195,206,156,1,244,1,204,195,206,156,1,245,1,1,39,0,204,195,206,156,1,4,6,118,105,52,104,122,104,2,39,0,204,195,206,156,1,1,6,95,98,119,81,76,101,1,40,0,198,223,206,159,1,188,1,2,105,100,1,119,6,95,98,119,81,76,101,40,0,198,223,206,159,1,188,1,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,198,223,206,159,1,188,1,6,112,97,114,101,110,116,1,119,10,101,110,68,45,73,83,100,100,99,55,40,0,198,223,206,159,1,188,1,8,99,104,105,108,100,114,101,110,1,119,6,104,102,109,108,88,52,33,0,198,223,206,159,1,188,1,4,100,97,116,97,1,40,0,198,223,206,159,1,188,1,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,198,223,206,159,1,188,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,104,102,109,108,88,52,0,8,0,204,195,206,156,1,23,1,119,6,95,98,119,81,76,101,4,0,198,223,206,159,1,187,1,3,105,106,106,168,198,223,206,159,1,193,1,1,119,28,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,105,106,106,34,125,93,125,39,0,204,195,206,156,1,4,6,55,83,79,113,80,69,2,33,0,204,195,206,156,1,1,6,75,119,55,52,104,73,1,0,7,33,0,204,195,206,156,1,3,6,80,82,74,72,65,95,1,129,198,223,206,159,1,197,1,1,39,0,204,195,206,156,1,4,6,78,97,78,121,113,76,2,39,0,204,195,206,156,1,1,6,72,90,88,98,113,104,1,40,0,198,223,206,159,1,214,1,2,105,100,1,119,6,72,90,88,98,113,104,40,0,198,223,206,159,1,214,1,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,198,223,206,159,1,214,1,6,112,97,114,101,110,116,1,119,6,95,98,119,81,76,101,40,0,198,223,206,159,1,214,1,8,99,104,105,108,100,114,101,110,1,119,6,110,98,72,85,90,106,33,0,198,223,206,159,1,214,1,4,100,97,116,97,1,40,0,198,223,206,159,1,214,1,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,198,223,206,159,1,214,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,110,98,72,85,90,106,0,8,0,198,223,206,159,1,196,1,1,119,6,72,90,88,98,113,104,4,0,198,223,206,159,1,213,1,4,106,107,110,98,168,198,223,206,159,1,219,1,1,119,29,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,106,107,110,98,34,125,93,125,39,0,204,195,206,156,1,4,6,57,56,55,97,106,50,2,33,0,204,195,206,156,1,1,6,110,117,56,75,122,68,1,0,7,33,0,204,195,206,156,1,3,6,85,56,79,113,105,78,1,129,198,223,206,159,1,223,1,1,39,0,204,195,206,156,1,4,6,88,116,82,99,45,53,2,39,0,204,195,206,156,1,1,6,88,52,88,118,49,84,1,40,0,198,223,206,159,1,241,1,2,105,100,1,119,6,88,52,88,118,49,84,40,0,198,223,206,159,1,241,1,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,198,223,206,159,1,241,1,6,112,97,114,101,110,116,1,119,6,72,90,88,98,113,104,40,0,198,223,206,159,1,241,1,8,99,104,105,108,100,114,101,110,1,119,6,119,77,90,48,100,71,33,0,198,223,206,159,1,241,1,4,100,97,116,97,1,40,0,198,223,206,159,1,241,1,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,198,223,206,159,1,241,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,119,77,90,48,100,71,0,8,0,198,223,206,159,1,222,1,1,119,6,88,52,88,118,49,84,4,0,198,223,206,159,1,240,1,6,232,191,155,230,173,165,161,198,223,206,159,1,246,1,1,132,198,223,206,159,1,252,1,6,230,156,186,228,188,154,168,198,223,206,159,1,253,1,1,119,37,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,232,191,155,230,173,165,230,156,186,228,188,154,34,125,93,125,39,0,204,195,206,156,1,4,6,121,85,99,121,82,100,2,39,0,204,195,206,156,1,1,6,100,121,76,82,53,100,1,40,0,198,223,206,159,1,130,2,2,105,100,1,119,6,100,121,76,82,53,100,40,0,198,223,206,159,1,130,2,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,198,223,206,159,1,130,2,6,112,97,114,101,110,116,1,119,6,72,90,88,98,113,104,40,0,198,223,206,159,1,130,2,8,99,104,105,108,100,114,101,110,1,119,6,55,89,79,70,48,116,33,0,198,223,206,159,1,130,2,4,100,97,116,97,1,40,0,198,223,206,159,1,130,2,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,198,223,206,159,1,130,2,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,55,89,79,70,48,116,0,136,198,223,206,159,1,250,1,1,119,6,100,121,76,82,53,100,4,0,198,223,206,159,1,129,2,12,230,150,164,230,150,164,232,174,161,232,190,131,168,198,223,206,159,1,135,2,1,119,37,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,230,150,164,230,150,164,232,174,161,232,190,131,34,125,93,125,231,2,204,195,206,156,1,0,39,1,4,100,97,116,97,8,100,111,99,117,109,101,110,116,1,39,0,204,195,206,156,1,0,6,98,108,111,99,107,115,1,39,0,204,195,206,156,1,0,4,109,101,116,97,1,39,0,204,195,206,156,1,2,12,99,104,105,108,100,114,101,110,95,109,97,112,1,39,0,204,195,206,156,1,2,8,116,101,120,116,95,109,97,112,1,40,0,204,195,206,156,1,0,7,112,97,103,101,95,105,100,1,119,10,109,54,120,76,118,72,89,48,76,107,39,0,204,195,206,156,1,1,10,77,48,104,84,99,67,120,66,88,82,1,40,0,204,195,206,156,1,6,2,105,100,1,119,10,77,48,104,84,99,67,120,66,88,82,40,0,204,195,206,156,1,6,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,204,195,206,156,1,6,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,6,8,99,104,105,108,100,114,101,110,1,119,10,49,87,78,107,89,75,118,109,105,50,33,0,204,195,206,156,1,6,4,100,97,116,97,1,33,0,204,195,206,156,1,6,11,101,120,116,101,114,110,97,108,95,105,100,1,33,0,204,195,206,156,1,6,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,39,0,204,195,206,156,1,3,10,49,87,78,107,89,75,118,109,105,50,0,39,0,204,195,206,156,1,1,10,101,110,68,45,73,83,100,100,99,55,1,40,0,204,195,206,156,1,15,2,105,100,1,119,10,101,110,68,45,73,83,100,100,99,55,40,0,204,195,206,156,1,15,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,204,195,206,156,1,15,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,15,8,99,104,105,108,100,114,101,110,1,119,10,103,106,110,76,109,66,89,118,68,65,40,0,204,195,206,156,1,15,4,100,97,116,97,1,119,2,123,125,40,0,204,195,206,156,1,15,11,101,120,116,101,114,110,97,108,95,105,100,1,119,10,102,56,54,108,88,117,88,74,101,54,40,0,204,195,206,156,1,15,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,119,4,116,101,120,116,39,0,204,195,206,156,1,3,10,103,106,110,76,109,66,89,118,68,65,0,39,0,204,195,206,156,1,1,10,113,115,110,89,82,48,74,72,74,56,1,40,0,204,195,206,156,1,24,2,105,100,1,119,10,113,115,110,89,82,48,74,72,74,56,40,0,204,195,206,156,1,24,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,204,195,206,156,1,24,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,24,8,99,104,105,108,100,114,101,110,1,119,10,116,79,53,122,78,78,73,82,69,100,40,0,204,195,206,156,1,24,4,100,97,116,97,1,119,17,123,34,99,104,101,99,107,101,100,34,58,102,97,108,115,101,125,40,0,204,195,206,156,1,24,11,101,120,116,101,114,110,97,108,95,105,100,1,119,10,51,72,115,115,121,121,66,84,57,50,40,0,204,195,206,156,1,24,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,119,4,116,101,120,116,39,0,204,195,206,156,1,3,10,116,79,53,122,78,78,73,82,69,100,0,39,0,204,195,206,156,1,1,10,75,54,50,76,100,101,119,53,95,121,1,40,0,204,195,206,156,1,33,2,105,100,1,119,10,75,54,50,76,100,101,119,53,95,121,40,0,204,195,206,156,1,33,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,204,195,206,156,1,33,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,33,8,99,104,105,108,100,114,101,110,1,119,10,57,118,109,120,98,73,71,120,109,73,40,0,204,195,206,156,1,33,4,100,97,116,97,1,119,11,123,34,108,101,118,101,108,34,58,50,125,40,0,204,195,206,156,1,33,11,101,120,116,101,114,110,97,108,95,105,100,1,119,10,72,116,114,88,117,57,102,65,95,107,40,0,204,195,206,156,1,33,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,119,4,116,101,120,116,39,0,204,195,206,156,1,3,10,57,118,109,120,98,73,71,120,109,73,0,39,0,204,195,206,156,1,1,10,117,51,120,66,95,83,69,116,53,68,1,40,0,204,195,206,156,1,42,2,105,100,1,119,10,117,51,120,66,95,83,69,116,53,68,40,0,204,195,206,156,1,42,2,116,121,1,119,7,99,97,108,108,111,117,116,40,0,204,195,206,156,1,42,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,42,8,99,104,105,108,100,114,101,110,1,119,10,50,88,118,55,52,84,105,73,70,108,40,0,204,195,206,156,1,42,4,100,97,116,97,1,119,15,123,34,105,99,111,110,34,58,34,240,159,165,176,34,125,40,0,204,195,206,156,1,42,11,101,120,116,101,114,110,97,108,95,105,100,1,119,10,108,119,101,104,75,79,117,78,68,67,40,0,204,195,206,156,1,42,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,119,4,116,101,120,116,39,0,204,195,206,156,1,3,10,50,88,118,55,52,84,105,73,70,108,0,39,0,204,195,206,156,1,1,10,78,73,76,105,97,84,121,72,108,112,1,40,0,204,195,206,156,1,51,2,105,100,1,119,10,78,73,76,105,97,84,121,72,108,112,40,0,204,195,206,156,1,51,2,116,121,1,119,5,113,117,111,116,101,40,0,204,195,206,156,1,51,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,51,8,99,104,105,108,100,114,101,110,1,119,10,109,82,95,75,65,57,45,108,110,78,33,0,204,195,206,156,1,51,4,100,97,116,97,1,33,0,204,195,206,156,1,51,11,101,120,116,101,114,110,97,108,95,105,100,1,33,0,204,195,206,156,1,51,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,39,0,204,195,206,156,1,3,10,109,82,95,75,65,57,45,108,110,78,0,33,0,204,195,206,156,1,1,10,99,108,78,111,66,75,99,119,73,82,1,0,7,33,0,204,195,206,156,1,3,10,117,117,65,100,55,95,119,72,72,106,1,39,0,204,195,206,156,1,1,10,78,89,54,108,121,101,57,108,88,51,1,40,0,204,195,206,156,1,69,2,105,100,1,119,10,78,89,54,108,121,101,57,108,88,51,40,0,204,195,206,156,1,69,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,204,195,206,156,1,69,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,69,8,99,104,105,108,100,114,101,110,1,119,10,108,77,72,53,73,113,54,77,68,78,40,0,204,195,206,156,1,69,4,100,97,116,97,1,119,17,123,34,99,104,101,99,107,101,100,34,58,102,97,108,115,101,125,40,0,204,195,206,156,1,69,11,101,120,116,101,114,110,97,108,95,105,100,1,119,10,109,69,119,56,90,66,102,95,100,68,40,0,204,195,206,156,1,69,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,119,4,116,101,120,116,39,0,204,195,206,156,1,3,10,108,77,72,53,73,113,54,77,68,78,0,39,0,204,195,206,156,1,1,10,101,104,73,115,79,74,69,114,55,73,1,40,0,204,195,206,156,1,78,2,105,100,1,119,10,101,104,73,115,79,74,69,114,55,73,40,0,204,195,206,156,1,78,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,204,195,206,156,1,78,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,78,8,99,104,105,108,100,114,101,110,1,119,10,56,116,67,52,100,103,121,98,57,55,33,0,204,195,206,156,1,78,4,100,97,116,97,1,33,0,204,195,206,156,1,78,11,101,120,116,101,114,110,97,108,95,105,100,1,33,0,204,195,206,156,1,78,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,39,0,204,195,206,156,1,3,10,56,116,67,52,100,103,121,98,57,55,0,39,0,204,195,206,156,1,1,10,68,90,114,95,72,118,106,65,78,107,1,40,0,204,195,206,156,1,87,2,105,100,1,119,10,68,90,114,95,72,118,106,65,78,107,40,0,204,195,206,156,1,87,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,204,195,206,156,1,87,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,87,8,99,104,105,108,100,114,101,110,1,119,10,119,95,65,55,90,114,77,89,86,122,40,0,204,195,206,156,1,87,4,100,97,116,97,1,119,17,123,34,99,104,101,99,107,101,100,34,58,102,97,108,115,101,125,40,0,204,195,206,156,1,87,11,101,120,116,101,114,110,97,108,95,105,100,1,119,10,54,74,108,118,72,71,53,111,120,90,40,0,204,195,206,156,1,87,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,119,4,116,101,120,116,39,0,204,195,206,156,1,3,10,119,95,65,55,90,114,77,89,86,122,0,33,0,204,195,206,156,1,1,10,105,90,113,50,95,68,72,49,50,69,1,0,7,33,0,204,195,206,156,1,3,10,119,54,53,71,114,77,54,109,119,69,1,39,0,204,195,206,156,1,1,10,48,105,122,109,122,95,86,65,55,70,1,40,0,204,195,206,156,1,105,2,105,100,1,119,10,48,105,122,109,122,95,86,65,55,70,40,0,204,195,206,156,1,105,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,204,195,206,156,1,105,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,105,8,99,104,105,108,100,114,101,110,1,119,10,65,90,49,50,53,79,88,51,65,97,40,0,204,195,206,156,1,105,4,100,97,116,97,1,119,2,123,125,40,0,204,195,206,156,1,105,11,101,120,116,101,114,110,97,108,95,105,100,1,119,10,109,73,73,113,81,111,118,74,105,101,40,0,204,195,206,156,1,105,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,119,4,116,101,120,116,39,0,204,195,206,156,1,3,10,65,90,49,50,53,79,88,51,65,97,0,39,0,204,195,206,156,1,1,10,55,107,121,57,118,72,100,98,90,90,1,40,0,204,195,206,156,1,114,2,105,100,1,119,10,55,107,121,57,118,72,100,98,90,90,40,0,204,195,206,156,1,114,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,204,195,206,156,1,114,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,114,8,99,104,105,108,100,114,101,110,1,119,10,118,122,73,48,69,73,102,97,111,55,33,0,204,195,206,156,1,114,4,100,97,116,97,1,33,0,204,195,206,156,1,114,11,101,120,116,101,114,110,97,108,95,105,100,1,33,0,204,195,206,156,1,114,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,39,0,204,195,206,156,1,3,10,118,122,73,48,69,73,102,97,111,55,0,39,0,204,195,206,156,1,1,10,76,77,51,100,74,90,103,105,119,106,1,40,0,204,195,206,156,1,123,2,105,100,1,119,10,76,77,51,100,74,90,103,105,119,106,40,0,204,195,206,156,1,123,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,204,195,206,156,1,123,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,123,8,99,104,105,108,100,114,101,110,1,119,10,65,49,72,80,70,85,72,104,51,86,40,0,204,195,206,156,1,123,4,100,97,116,97,1,119,2,123,125,40,0,204,195,206,156,1,123,11,101,120,116,101,114,110,97,108,95,105,100,1,119,10,120,116,103,85,69,74,52,104,81,95,40,0,204,195,206,156,1,123,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,119,4,116,101,120,116,39,0,204,195,206,156,1,3,10,65,49,72,80,70,85,72,104,51,86,0,39,0,204,195,206,156,1,1,10,109,73,66,54,73,106,49,57,52,77,1,40,0,204,195,206,156,1,132,1,2,105,100,1,119,10,109,73,66,54,73,106,49,57,52,77,40,0,204,195,206,156,1,132,1,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,204,195,206,156,1,132,1,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,132,1,8,99,104,105,108,100,114,101,110,1,119,10,121,56,100,54,52,108,75,54,81,109,33,0,204,195,206,156,1,132,1,4,100,97,116,97,1,33,0,204,195,206,156,1,132,1,11,101,120,116,101,114,110,97,108,95,105,100,1,33,0,204,195,206,156,1,132,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,39,0,204,195,206,156,1,3,10,121,56,100,54,52,108,75,54,81,109,0,39,0,204,195,206,156,1,1,10,109,79,82,56,99,51,71,108,104,101,1,40,0,204,195,206,156,1,141,1,2,105,100,1,119,10,109,79,82,56,99,51,71,108,104,101,40,0,204,195,206,156,1,141,1,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,204,195,206,156,1,141,1,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,141,1,8,99,104,105,108,100,114,101,110,1,119,10,75,50,75,54,117,121,80,56,108,65,40,0,204,195,206,156,1,141,1,4,100,97,116,97,1,119,2,123,125,40,0,204,195,206,156,1,141,1,11,101,120,116,101,114,110,97,108,95,105,100,1,119,10,52,97,84,122,117,113,66,107,110,70,40,0,204,195,206,156,1,141,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,119,4,116,101,120,116,39,0,204,195,206,156,1,3,10,75,50,75,54,117,121,80,56,108,65,0,39,0,204,195,206,156,1,1,10,118,110,69,86,85,50,114,57,65,88,1,40,0,204,195,206,156,1,150,1,2,105,100,1,119,10,118,110,69,86,85,50,114,57,65,88,40,0,204,195,206,156,1,150,1,2,116,121,1,119,4,99,111,100,101,40,0,204,195,206,156,1,150,1,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,150,1,8,99,104,105,108,100,114,101,110,1,119,10,75,119,115,101,107,79,85,115,115,57,33,0,204,195,206,156,1,150,1,4,100,97,116,97,1,33,0,204,195,206,156,1,150,1,11,101,120,116,101,114,110,97,108,95,105,100,1,33,0,204,195,206,156,1,150,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,39,0,204,195,206,156,1,3,10,75,119,115,101,107,79,85,115,115,57,0,39,0,204,195,206,156,1,1,10,104,87,121,95,110,110,79,73,101,108,1,40,0,204,195,206,156,1,159,1,2,105,100,1,119,10,104,87,121,95,110,110,79,73,101,108,40,0,204,195,206,156,1,159,1,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,204,195,206,156,1,159,1,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,159,1,8,99,104,105,108,100,114,101,110,1,119,10,95,74,97,104,108,70,88,117,82,109,33,0,204,195,206,156,1,159,1,4,100,97,116,97,1,33,0,204,195,206,156,1,159,1,11,101,120,116,101,114,110,97,108,95,105,100,1,33,0,204,195,206,156,1,159,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,39,0,204,195,206,156,1,3,10,95,74,97,104,108,70,88,117,82,109,0,33,0,204,195,206,156,1,1,10,71,45,117,115,79,56,75,107,81,81,1,0,7,33,0,204,195,206,156,1,3,10,56,112,113,84,95,112,120,118,65,78,1,33,0,204,195,206,156,1,1,10,87,114,65,73,121,89,90,76,79,110,1,0,7,33,0,204,195,206,156,1,3,10,89,100,82,106,88,106,109,55,118,114,1,33,0,204,195,206,156,1,1,10,95,90,102,110,119,90,114,87,68,105,1,0,7,33,0,204,195,206,156,1,3,10,49,86,117,68,73,110,45,56,100,114,1,39,0,204,195,206,156,1,1,10,82,50,56,82,106,69,66,70,99,71,1,40,0,204,195,206,156,1,195,1,2,105,100,1,119,10,82,50,56,82,106,69,66,70,99,71,40,0,204,195,206,156,1,195,1,2,116,121,1,119,7,100,105,118,105,100,101,114,40,0,204,195,206,156,1,195,1,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,195,1,8,99,104,105,108,100,114,101,110,1,119,10,119,115,98,50,74,101,113,52,87,71,40,0,204,195,206,156,1,195,1,4,100,97,116,97,1,119,2,123,125,40,0,204,195,206,156,1,195,1,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,204,195,206,156,1,195,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,10,119,115,98,50,74,101,113,52,87,71,0,39,0,204,195,206,156,1,1,10,109,54,120,76,118,72,89,48,76,107,1,40,0,204,195,206,156,1,204,1,2,105,100,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,204,1,2,116,121,1,119,4,112,97,103,101,40,0,204,195,206,156,1,204,1,6,112,97,114,101,110,116,1,119,0,40,0,204,195,206,156,1,204,1,8,99,104,105,108,100,114,101,110,1,119,10,120,68,48,121,90,73,118,109,51,115,33,0,204,195,206,156,1,204,1,4,100,97,116,97,1,33,0,204,195,206,156,1,204,1,11,101,120,116,101,114,110,97,108,95,105,100,1,33,0,204,195,206,156,1,204,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,39,0,204,195,206,156,1,3,10,120,68,48,121,90,73,118,109,51,115,0,33,0,204,195,206,156,1,1,10,97,115,74,118,54,70,114,65,82,97,1,0,7,33,0,204,195,206,156,1,3,10,68,75,70,79,99,81,75,54,52,72,1,39,0,204,195,206,156,1,1,10,119,70,86,108,107,88,117,108,104,74,1,40,0,204,195,206,156,1,222,1,2,105,100,1,119,10,119,70,86,108,107,88,117,108,104,74,40,0,204,195,206,156,1,222,1,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,204,195,206,156,1,222,1,6,112,97,114,101,110,116,1,119,10,109,54,120,76,118,72,89,48,76,107,40,0,204,195,206,156,1,222,1,8,99,104,105,108,100,114,101,110,1,119,10,69,113,72,71,75,105,54,115,68,53,33,0,204,195,206,156,1,222,1,4,100,97,116,97,1,33,0,204,195,206,156,1,222,1,11,101,120,116,101,114,110,97,108,95,105,100,1,33,0,204,195,206,156,1,222,1,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,39,0,204,195,206,156,1,3,10,69,113,72,71,75,105,54,115,68,53,0,8,0,204,195,206,156,1,212,1,1,119,10,119,70,86,108,107,88,117,108,104,74,129,204,195,206,156,1,231,1,1,136,204,195,206,156,1,232,1,6,119,10,109,73,66,54,73,106,49,57,52,77,119,10,78,89,54,108,121,101,57,108,88,51,119,10,68,90,114,95,72,118,106,65,78,107,119,10,113,115,110,89,82,48,74,72,74,56,119,10,55,107,121,57,118,72,100,98,90,90,119,10,77,48,104,84,99,67,120,66,88,82,129,204,195,206,156,1,238,1,1,136,204,195,206,156,1,239,1,1,119,10,82,50,56,82,106,69,66,70,99,71,129,204,195,206,156,1,240,1,1,136,204,195,206,156,1,241,1,1,119,10,104,87,121,95,110,110,79,73,101,108,136,204,195,206,156,1,242,1,2,119,10,48,105,122,109,122,95,86,65,55,70,119,10,101,110,68,45,73,83,100,100,99,55,136,204,195,206,156,1,244,1,2,119,10,109,79,82,56,99,51,71,108,104,101,119,10,118,110,69,86,85,50,114,57,65,88,129,204,195,206,156,1,246,1,1,136,204,195,206,156,1,247,1,3,119,10,75,54,50,76,100,101,119,53,95,121,119,10,78,73,76,105,97,84,121,72,108,112,119,10,101,104,73,115,79,74,69,114,55,73,136,204,195,206,156,1,250,1,1,119,10,117,51,120,66,95,83,69,116,53,68,129,204,195,206,156,1,251,1,1,136,204,195,206,156,1,252,1,1,119,10,76,77,51,100,74,90,103,105,119,106,129,204,195,206,156,1,253,1,1,39,0,204,195,206,156,1,4,10,97,98,100,49,105,117,71,81,109,68,2,4,0,204,195,206,156,1,255,1,44,75,101,121,98,111,97,114,100,32,115,104,111,114,116,99,117,116,115,44,32,109,97,114,107,100,111,119,110,44,32,97,110,100,32,99,111,100,101,32,98,108,111,99,107,39,0,204,195,206,156,1,4,10,54,74,108,118,72,71,53,111,120,90,2,4,0,204,195,206,156,1,172,2,20,65,115,32,115,111,111,110,32,97,115,32,121,111,117,32,116,121,112,101,32,134,204,195,206,156,1,192,2,10,102,111,110,116,95,99,111,108,111,114,12,34,48,120,102,102,48,48,98,53,102,102,34,134,204,195,206,156,1,193,2,4,99,111,100,101,4,116,114,117,101,132,204,195,206,156,1,194,2,1,47,134,204,195,206,156,1,195,2,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,134,204,195,206,156,1,196,2,4,99,111,100,101,4,110,117,108,108,132,204,195,206,156,1,197,2,28,32,97,32,109,101,110,117,32,119,105,108,108,32,112,111,112,32,117,112,46,32,83,101,108,101,99,116,32,134,204,195,206,156,1,225,2,8,98,103,95,99,111,108,111,114,12,34,48,120,52,100,57,99,50,55,98,48,34,132,204,195,206,156,1,226,2,15,100,105,102,102,101,114,101,110,116,32,116,121,112,101,115,134,204,195,206,156,1,241,2,8,98,103,95,99,111,108,111,114,4,110,117,108,108,132,204,195,206,156,1,242,2,31,32,111,102,32,99,111,110,116,101,110,116,32,98,108,111,99,107,115,32,121,111,117,32,99,97,110,32,97,100,100,46,39,0,204,195,206,156,1,4,10,51,72,115,115,121,121,66,84,57,50,2,4,0,204,195,206,156,1,146,3,5,84,121,112,101,32,134,204,195,206,156,1,151,3,4,99,111,100,101,4,116,114,117,101,132,204,195,206,156,1,152,3,1,47,134,204,195,206,156,1,153,3,4,99,111,100,101,4,110,117,108,108,132,204,195,206,156,1,154,3,13,32,102,111,108,108,111,119,101,100,32,98,121,32,134,204,195,206,156,1,167,3,4,99,111,100,101,4,116,114,117,101,132,204,195,206,156,1,168,3,7,47,98,117,108,108,101,116,134,204,195,206,156,1,175,3,4,99,111,100,101,4,110,117,108,108,132,204,195,206,156,1,176,3,4,32,111,114,32,134,204,195,206,156,1,180,3,4,99,111,100,101,4,116,114,117,101,132,204,195,206,156,1,181,3,4,47,110,117,109,134,204,195,206,156,1,185,3,4,99,111,100,101,4,110,117,108,108,198,204,195,206,156,1,185,3,204,195,206,156,1,186,3,4,99,111,100,101,5,102,97,108,115,101,196,204,195,206,156,1,187,3,204,195,206,156,1,186,3,18,32,116,111,32,99,114,101,97,116,101,32,97,32,108,105,115,116,46,198,204,195,206,156,1,205,3,204,195,206,156,1,186,3,4,99,111,100,101,4,116,114,117,101,33,0,204,195,206,156,1,4,10,84,82,53,102,106,82,122,115,114,105,1,39,0,204,195,206,156,1,4,10,119,86,82,81,117,71,111,121,116,48,2,4,0,204,195,206,156,1,208,3,6,67,108,105,99,107,32,134,204,195,206,156,1,214,3,4,99,111,100,101,4,116,114,117,101,132,204,195,206,156,1,215,3,1,63,134,204,195,206,156,1,216,3,4,99,111,100,101,4,110,117,108,108,132,204,195,206,156,1,217,3,41,32,97,116,32,116,104,101,32,98,111,116,116,111,109,32,114,105,103,104,116,32,102,111,114,32,104,101,108,112,32,97,110,100,32,115,117,112,112,111,114,116,129,204,195,206,156,1,130,4,1,39,0,204,195,206,156,1,4,10,107,106,48,68,49,121,121,88,78,119,2,39,0,204,195,206,156,1,4,10,120,116,103,85,69,74,52,104,81,95,2,39,0,204,195,206,156,1,4,10,112,70,113,76,55,45,79,83,121,86,2,33,0,204,195,206,156,1,4,10,102,114,97,74,99,70,55,54,70,99,1,39,0,204,195,206,156,1,4,10,122,77,121,109,67,97,118,83,107,102,2,4,0,204,195,206,156,1,136,4,6,67,108,105,99,107,32,134,204,195,206,156,1,142,4,4,99,111,100,101,4,116,114,117,101,132,204,195,206,156,1,143,4,11,43,32,78,101,119,32,80,97,103,101,32,134,204,195,206,156,1,154,4,4,99,111,100,101,4,110,117,108,108,132,204,195,206,156,1,155,4,50,98,117,116,116,111,110,32,97,116,32,116,104,101,32,98,111,116,116,111,109,32,111,102,32,121,111,117,114,32,115,105,100,101,98,97,114,32,116,111,32,97,100,100,32,97,32,110,101,119,32,129,204,195,206,156,1,205,4,4,132,204,195,206,156,1,209,4,1,46,39,0,204,195,206,156,1,4,10,72,116,114,88,117,57,102,65,95,107,2,4,0,204,195,206,156,1,211,4,18,72,97,118,101,32,97,32,113,117,101,115,116,105,111,110,226,157,147,39,0,204,195,206,156,1,4,10,49,112,115,100,67,122,97,87,104,49,2,4,0,204,195,206,156,1,228,4,30,47,47,32,84,104,105,115,32,105,115,32,116,104,101,32,109,97,105,110,32,102,117,110,99,116,105,111,110,46,10,129,204,195,206,156,1,130,5,77,39,0,204,195,206,156,1,4,10,119,79,108,117,99,85,55,51,73,76,2,1,0,204,195,206,156,1,208,5,36,129,204,195,206,156,1,244,5,1,33,0,204,195,206,156,1,4,10,69,72,117,95,67,112,120,53,67,103,1,39,0,204,195,206,156,1,4,10,98,113,76,109,98,57,111,45,109,109,2,4,0,204,195,206,156,1,247,5,6,67,108,105,99,107,32,134,204,195,206,156,1,253,5,4,99,111,100,101,4,116,114,117,101,132,204,195,206,156,1,254,5,1,43,134,204,195,206,156,1,255,5,4,99,111,100,101,4,110,117,108,108,132,204,195,206,156,1,128,6,1,32,129,204,195,206,156,1,129,6,4,132,204,195,206,156,1,133,6,37,32,116,111,32,97,110,121,32,112,97,103,101,32,116,105,116,108,101,32,105,110,32,116,104,101,32,115,105,100,101,98,97,114,32,116,111,32,134,204,195,206,156,1,170,6,10,102,111,110,116,95,99,111,108,111,114,12,34,48,120,102,102,56,52,50,55,101,48,34,132,204,195,206,156,1,171,6,7,113,117,105,99,107,108,121,134,204,195,206,156,1,178,6,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,132,204,195,206,156,1,179,6,1,32,129,204,195,206,156,1,180,6,3,132,204,195,206,156,1,183,6,16,32,97,32,110,101,119,32,115,117,98,112,97,103,101,44,32,134,204,195,206,156,1,199,6,4,99,111,100,101,4,116,114,117,101,132,204,195,206,156,1,200,6,8,68,111,99,117,109,101,110,116,134,204,195,206,156,1,208,6,4,99,111,100,101,4,110,117,108,108,198,204,195,206,156,1,208,6,204,195,206,156,1,209,6,4,99,111,100,101,5,102,97,108,115,101,196,204,195,206,156,1,210,6,204,195,206,156,1,209,6,2,44,32,198,204,195,206,156,1,212,6,204,195,206,156,1,209,6,4,99,111,100,101,4,116,114,117,101,196,204,195,206,156,1,213,6,204,195,206,156,1,209,6,4,71,114,105,100,198,204,195,206,156,1,217,6,204,195,206,156,1,209,6,4,99,111,100,101,5,102,97,108,115,101,196,204,195,206,156,1,218,6,204,195,206,156,1,209,6,5,44,32,111,114,32,198,204,195,206,156,1,223,6,204,195,206,156,1,209,6,4,99,111,100,101,4,116,114,117,101,196,204,195,206,156,1,224,6,204,195,206,156,1,209,6,12,75,97,110,98,97,110,32,66,111,97,114,100,198,204,195,206,156,1,236,6,204,195,206,156,1,209,6,4,99,111,100,101,5,102,97,108,115,101,196,204,195,206,156,1,237,6,204,195,206,156,1,209,6,1,46,198,204,195,206,156,1,238,6,204,195,206,156,1,209,6,4,99,111,100,101,4,116,114,117,101,39,0,204,195,206,156,1,4,10,102,56,54,108,88,117,88,74,101,54,2,4,0,204,195,206,156,1,240,6,9,77,97,114,107,100,111,119,110,32,134,204,195,206,156,1,249,6,4,104,114,101,102,67,34,104,116,116,112,115,58,47,47,97,112,112,102,108,111,119,121,46,103,105,116,98,111,111,107,46,105,111,47,100,111,99,115,47,101,115,115,101,110,116,105,97,108,45,100,111,99,117,109,101,110,116,97,116,105,111,110,47,109,97,114,107,100,111,119,110,34,132,204,195,206,156,1,250,6,9,114,101,102,101,114,101,110,99,101,134,204,195,206,156,1,131,7,4,104,114,101,102,4,110,117,108,108,33,0,204,195,206,156,1,4,10,89,74,119,52,70,81,88,106,110,84,1,39,0,204,195,206,156,1,4,10,119,88,107,79,72,81,49,50,99,111,2,1,0,204,195,206,156,1,134,7,20,33,0,204,195,206,156,1,4,10,65,108,73,86,97,121,54,119,80,104,1,0,19,39,0,204,195,206,156,1,4,10,109,69,119,56,90,66,102,95,100,68,2,6,0,204,195,206,156,1,175,7,8,98,103,95,99,111,108,111,114,12,34,48,120,52,100,102,102,101,98,51,98,34,132,204,195,206,156,1,176,7,10,72,105,103,104,108,105,103,104,116,32,134,204,195,206,156,1,186,7,8,98,103,95,99,111,108,111,114,4,110,117,108,108,132,204,195,206,156,1,187,7,38,97,110,121,32,116,101,120,116,44,32,97,110,100,32,117,115,101,32,116,104,101,32,101,100,105,116,105,110,103,32,109,101,110,117,32,116,111,32,134,204,195,206,156,1,225,7,6,105,116,97,108,105,99,4,116,114,117,101,132,204,195,206,156,1,226,7,5,115,116,121,108,101,134,204,195,206,156,1,231,7,6,105,116,97,108,105,99,4,110,117,108,108,132,204,195,206,156,1,232,7,1,32,134,204,195,206,156,1,233,7,4,98,111,108,100,4,116,114,117,101,132,204,195,206,156,1,234,7,4,121,111,117,114,134,204,195,206,156,1,238,7,4,98,111,108,100,4,110,117,108,108,132,204,195,206,156,1,239,7,1,32,134,204,195,206,156,1,240,7,9,117,110,100,101,114,108,105,110,101,4,116,114,117,101,132,204,195,206,156,1,241,7,7,119,114,105,116,105,110,103,134,204,195,206,156,1,248,7,9,117,110,100,101,114,108,105,110,101,4,110,117,108,108,132,204,195,206,156,1,249,7,1,32,134,204,195,206,156,1,250,7,4,99,111,100,101,4,116,114,117,101,132,204,195,206,156,1,251,7,7,104,111,119,101,118,101,114,134,204,195,206,156,1,130,8,4,99,111,100,101,4,110,117,108,108,132,204,195,206,156,1,131,8,5,32,121,111,117,32,134,204,195,206,156,1,136,8,13,115,116,114,105,107,101,116,104,114,111,117,103,104,4,116,114,117,101,132,204,195,206,156,1,137,8,5,108,105,107,101,46,134,204,195,206,156,1,142,8,13,115,116,114,105,107,101,116,104,114,111,117,103,104,4,110,117,108,108,33,0,204,195,206,156,1,4,10,98,122,103,70,79,75,118,99,117,89,1,39,0,204,195,206,156,1,4,10,52,97,84,122,117,113,66,107,110,70,2,4,0,204,195,206,156,1,145,8,5,84,121,112,101,32,134,204,195,206,156,1,150,8,4,99,111,100,101,4,116,114,117,101,132,204,195,206,156,1,151,8,5,47,99,111,100,101,134,204,195,206,156,1,156,8,4,99,111,100,101,4,110,117,108,108,198,204,195,206,156,1,156,8,204,195,206,156,1,157,8,4,99,111,100,101,5,102,97,108,115,101,196,204,195,206,156,1,158,8,204,195,206,156,1,157,8,23,32,116,111,32,105,110,115,101,114,116,32,97,32,99,111,100,101,32,98,108,111,99,107,198,204,195,206,156,1,181,8,204,195,206,156,1,157,8,4,99,111,100,101,4,116,114,117,101,39,0,204,195,206,156,1,4,10,108,119,101,104,75,79,117,78,68,67,2,4,0,204,195,206,156,1,183,8,27,10,76,105,107,101,32,65,112,112,70,108,111,119,121,63,32,70,111,108,108,111,119,32,117,115,58,10,134,204,195,206,156,1,210,8,4,104,114,101,102,41,34,104,116,116,112,115,58,47,47,103,105,116,104,117,98,46,99,111,109,47,65,112,112,70,108,111,119,121,45,73,79,47,65,112,112,70,108,111,119,121,34,132,204,195,206,156,1,211,8,6,71,105,116,72,117,98,134,204,195,206,156,1,217,8,4,104,114,101,102,4,110,117,108,108,132,204,195,206,156,1,218,8,1,10,134,204,195,206,156,1,219,8,4,104,114,101,102,30,34,104,116,116,112,115,58,47,47,116,119,105,116,116,101,114,46,99,111,109,47,97,112,112,102,108,111,119,121,34,132,204,195,206,156,1,220,8,7,84,119,105,116,116,101,114,134,204,195,206,156,1,227,8,4,104,114,101,102,4,110,117,108,108,132,204,195,206,156,1,228,8,12,58,32,64,97,112,112,102,108,111,119,121,10,134,204,195,206,156,1,240,8,4,104,114,101,102,33,34,104,116,116,112,115,58,47,47,98,108,111,103,45,97,112,112,102,108,111,119,121,46,103,104,111,115,116,46,105,111,47,34,132,204,195,206,156,1,241,8,10,78,101,119,115,108,101,116,116,101,114,134,204,195,206,156,1,251,8,4,104,114,101,102,4,110,117,108,108,132,204,195,206,156,1,252,8,1,10,39,0,204,195,206,156,1,4,10,109,73,73,113,81,111,118,74,105,101,2,4,0,204,195,206,156,1,254,8,19,75,101,121,98,111,97,114,100,32,115,104,111,114,116,99,117,116,115,32,134,204,195,206,156,1,145,9,4,104,114,101,102,68,34,104,116,116,112,115,58,47,47,97,112,112,102,108,111,119,121,46,103,105,116,98,111,111,107,46,105,111,47,100,111,99,115,47,101,115,115,101,110,116,105,97,108,45,100,111,99,117,109,101,110,116,97,116,105,111,110,47,115,104,111,114,116,99,117,116,115,34,132,204,195,206,156,1,146,9,5,103,117,105,100,101,134,204,195,206,156,1,151,9,4,104,114,101,102,4,110,117,108,108,2,131,159,159,151,1,0,161,237,140,187,206,2,16,1,161,237,140,187,206,2,20,71,1,141,178,210,127,0,0,3,1,206,214,243,86,0,161,236,158,128,159,2,3,178,1,62,194,228,144,71,0,161,243,138,171,183,10,246,1,1,161,243,138,171,183,10,247,1,1,161,243,138,171,183,10,248,1,1,161,131,128,202,229,9,0,1,161,194,228,144,71,0,1,161,194,228,144,71,1,1,161,194,228,144,71,2,1,161,194,228,144,71,3,1,39,0,204,195,206,156,1,4,6,114,114,111,103,100,98,2,33,0,204,195,206,156,1,1,6,85,95,66,110,68,101,1,0,7,33,0,204,195,206,156,1,3,6,79,70,89,50,114,113,1,193,199,130,209,189,2,174,5,199,130,209,189,2,210,5,1,1,0,194,228,144,71,8,1,0,1,129,194,228,144,71,19,1,0,3,39,0,204,195,206,156,1,4,6,103,109,54,79,74,117,2,33,0,204,195,206,156,1,1,6,114,78,78,121,56,74,1,0,7,33,0,204,195,206,156,1,3,6,88,101,115,97,82,119,1,193,199,130,209,189,2,174,5,194,228,144,71,18,1,4,0,194,228,144,71,25,1,62,0,1,39,0,204,195,206,156,1,4,6,99,82,86,69,118,53,2,39,0,204,195,206,156,1,1,6,109,55,85,85,85,68,1,40,0,194,228,144,71,39,2,105,100,1,119,6,109,55,85,85,85,68,40,0,194,228,144,71,39,2,116,121,1,119,11,116,111,103,103,108,101,95,108,105,115,116,40,0,194,228,144,71,39,6,112,97,114,101,110,116,1,119,6,78,99,104,45,81,78,40,0,194,228,144,71,39,8,99,104,105,108,100,114,101,110,1,119,6,120,53,79,107,74,71,33,0,194,228,144,71,39,4,100,97,116,97,1,40,0,194,228,144,71,39,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,194,228,144,71,39,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,120,53,79,107,74,71,0,200,199,130,209,189,2,174,5,194,228,144,71,35,1,119,6,109,55,85,85,85,68,4,0,194,228,144,71,38,1,49,161,194,228,144,71,44,1,132,194,228,144,71,49,1,50,161,194,228,144,71,50,1,132,194,228,144,71,51,1,51,161,194,228,144,71,52,1,39,0,204,195,206,156,1,4,6,105,72,102,106,109,56,2,39,0,204,195,206,156,1,1,6,73,121,89,76,77,104,1,40,0,194,228,144,71,56,2,105,100,1,119,6,73,121,89,76,77,104,40,0,194,228,144,71,56,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,194,228,144,71,56,6,112,97,114,101,110,116,1,119,6,109,55,85,85,85,68,40,0,194,228,144,71,56,8,99,104,105,108,100,114,101,110,1,119,6,79,76,111,88,102,98,33,0,194,228,144,71,56,4,100,97,116,97,1,40,0,194,228,144,71,56,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,194,228,144,71,56,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,204,195,206,156,1,3,6,79,76,111,88,102,98,0,8,0,194,228,144,71,47,1,119,6,73,121,89,76,77,104,161,194,228,144,71,54,1,4,0,194,228,144,71,55,1,52,161,194,228,144,71,61,1,132,194,228,144,71,67,1,52,161,194,228,144,71,68,1,132,194,228,144,71,69,1,52,161,194,228,144,71,70,1,132,194,228,144,71,71,1,52,168,194,228,144,71,72,1,119,29,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,52,52,52,52,34,125,93,125,168,194,228,144,71,66,1,119,45,123,34,99,111,108,108,97,112,115,101,100,34,58,116,114,117,101,44,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,49,50,51,34,125,93,125,1,185,164,169,62,0,161,198,234,131,228,11,49,90,1,229,154,194,35,0,161,136,172,186,168,4,181,6,178,1,1,218,255,204,32,0,161,150,152,188,203,6,19,21,1,183,182,135,14,0,161,207,210,187,205,12,7,227,2,73,131,159,159,151,1,1,0,72,132,236,218,251,9,1,0,14,131,182,180,202,12,1,0,53,131,128,202,229,9,1,0,1,133,181,204,218,3,8,15,1,17,2,20,1,22,1,24,1,26,1,28,1,30,1,136,172,186,168,4,1,0,182,6,136,199,176,231,9,1,20,10,140,167,201,161,14,1,0,10,141,151,160,163,4,1,0,24,142,211,188,164,13,1,0,15,141,178,210,127,1,0,3,145,224,235,133,7,1,0,3,146,209,153,247,13,1,0,186,1,146,216,250,133,2,1,0,180,1,146,175,139,236,2,1,0,12,150,152,188,203,6,1,0,20,151,234,142,238,11,1,0,27,150,216,171,142,3,87,0,171,3,172,3,3,176,3,3,180,3,3,184,3,3,188,3,3,192,3,3,196,3,3,200,3,3,204,3,3,208,3,3,212,3,3,216,3,3,220,3,3,224,3,3,228,3,3,232,3,3,236,3,3,240,3,3,244,3,3,248,3,3,253,3,3,129,4,3,133,4,3,137,4,3,141,4,3,145,4,3,149,4,3,153,4,3,157,4,3,161,4,3,165,4,3,169,4,3,173,4,3,177,4,3,181,4,3,185,4,3,189,4,3,193,4,3,197,4,3,201,4,3,205,4,3,209,4,3,213,4,3,217,4,3,221,4,3,225,4,3,229,4,7,254,4,10,138,5,1,140,5,1,142,5,1,149,5,1,155,5,1,157,5,1,159,5,1,166,5,11,178,5,15,200,5,1,210,5,1,220,5,1,230,5,1,235,5,1,237,5,1,239,5,1,241,5,1,245,5,1,251,5,1,133,6,1,138,6,1,144,6,1,154,6,1,164,6,1,174,6,1,180,6,1,182,6,1,184,6,1,186,6,5,216,6,3,231,6,14,188,7,6,158,8,1,160,8,1,162,8,1,164,8,3,168,8,3,187,8,1,153,236,182,220,1,1,0,4,151,254,242,152,9,11,5,8,14,1,17,1,19,3,32,1,37,16,54,23,89,2,97,1,102,21,134,1,8,155,213,159,176,1,1,0,10,161,234,157,145,5,1,0,7,164,202,219,213,10,18,19,10,31,10,42,1,44,10,55,1,57,1,59,1,61,3,67,1,77,7,85,1,87,1,89,1,91,1,93,1,95,1,97,1,117,1,165,131,171,211,15,1,0,20,168,215,223,235,2,1,0,3,171,236,222,251,5,69,9,5,15,11,27,8,36,3,40,4,45,8,54,8,63,3,72,3,76,3,80,3,84,3,88,3,92,3,96,3,102,3,106,10,120,10,131,1,10,142,1,10,153,1,10,169,1,1,176,1,2,179,1,1,181,1,1,187,1,10,201,1,1,207,1,10,222,1,10,237,1,17,131,2,10,146,2,10,166,2,1,172,2,10,186,2,1,192,2,10,207,2,10,222,2,10,237,2,10,252,2,10,139,3,10,150,3,18,169,3,15,185,3,1,202,3,1,208,3,1,210,3,1,212,3,1,240,3,1,245,3,10,128,4,4,137,4,1,142,4,7,150,4,6,157,4,6,164,4,6,171,4,6,178,4,6,185,4,6,192,4,6,199,4,1,201,4,4,206,4,7,214,4,6,221,4,6,228,4,6,235,4,6,242,4,1,249,4,1,172,254,181,239,1,4,5,1,8,1,11,1,14,1,174,203,157,214,7,1,0,6,176,238,158,139,14,1,0,175,2,177,239,218,225,4,1,0,3,178,187,245,161,14,2,8,1,10,1,180,189,170,253,8,2,6,1,10,1,181,150,190,222,14,15,1,8,10,10,21,10,32,10,59,1,65,1,67,1,69,1,71,1,73,1,80,1,85,1,87,1,89,1,91,1,181,156,253,158,6,1,0,4,183,182,135,14,1,0,227,2,184,146,243,216,14,1,0,7,185,164,169,62,1,0,90,183,213,134,255,8,1,0,28,190,183,139,210,2,1,0,110,192,246,139,213,2,1,0,35,192,187,174,206,8,3,1,74,76,220,3,222,5,1,194,228,144,71,13,0,8,9,16,26,10,37,1,44,1,50,1,52,1,54,1,61,1,66,1,68,1,70,1,72,1,195,254,251,180,11,1,0,58,197,205,192,233,12,1,0,9,198,223,206,159,1,25,15,3,19,20,40,10,51,10,62,10,78,1,86,6,93,13,112,1,120,1,124,1,126,13,145,1,1,153,1,1,159,1,1,170,1,1,175,1,1,177,1,10,193,1,1,203,1,10,219,1,1,230,1,10,246,1,1,253,1,1,135,2,1,198,234,131,228,11,1,0,50,199,130,209,189,2,203,1,4,11,16,1,19,12,33,1,35,1,37,1,39,1,41,1,43,1,45,1,47,1,49,1,56,1,61,1,63,1,65,1,67,1,69,1,71,1,73,1,75,1,77,1,79,1,81,1,83,1,85,1,87,1,89,1,91,1,93,1,95,1,102,1,107,1,109,1,111,1,113,1,115,1,117,1,119,1,121,1,123,1,125,4,136,1,1,144,1,1,152,1,1,160,1,1,168,1,1,176,1,1,184,1,1,192,1,1,200,1,1,208,1,1,216,1,1,224,1,1,232,1,1,240,1,1,248,1,1,128,2,1,136,2,1,144,2,1,152,2,1,160,2,1,168,2,1,176,2,1,184,2,1,192,2,1,200,2,2,208,2,1,213,2,1,215,2,27,246,2,21,140,3,1,142,3,1,144,3,1,146,3,1,153,3,1,162,3,10,177,3,1,183,3,10,202,3,1,212,3,1,222,3,1,232,3,1,242,3,1,252,3,1,134,4,1,144,4,1,154,4,1,159,4,1,161,4,1,163,4,1,165,4,1,167,4,1,169,4,1,171,4,1,173,4,1,175,4,1,177,4,5,188,4,1,193,4,6,200,4,10,216,4,1,221,4,6,228,4,10,244,4,1,249,4,6,133,5,1,138,5,6,145,5,10,156,5,1,158,5,1,160,5,1,162,5,3,170,5,1,175,5,6,182,5,16,199,5,1,206,5,1,211,5,6,223,5,1,228,5,7,236,5,10,252,5,1,129,6,6,136,6,10,147,6,10,158,6,1,160,6,1,162,6,1,167,6,10,178,6,1,180,6,2,183,6,2,186,6,1,188,6,1,190,6,1,192,6,1,194,6,1,196,6,1,198,6,1,200,6,1,202,6,1,206,6,1,208,6,1,210,6,1,212,6,1,214,6,4,219,6,1,223,6,1,225,6,1,227,6,1,229,6,1,231,6,1,233,6,1,235,6,1,237,6,1,241,6,1,243,6,1,245,6,1,247,6,1,249,6,1,251,6,1,253,6,1,255,6,1,131,7,1,133,7,1,135,7,1,137,7,1,139,7,1,141,7,1,144,7,21,166,7,1,168,7,1,170,7,1,172,7,1,174,7,1,176,7,1,178,7,1,180,7,1,182,7,1,184,7,1,188,7,1,190,7,1,192,7,1,194,7,1,196,7,1,198,7,4,205,7,1,207,7,1,209,7,1,211,7,1,213,7,1,215,7,1,217,7,13,231,7,1,240,7,1,248,7,2,252,7,3,130,8,1,132,8,1,134,8,1,136,8,2,139,8,2,204,195,206,156,1,30,11,3,56,3,60,9,83,3,96,9,119,3,137,1,3,155,1,3,164,1,3,168,1,27,209,1,3,213,1,9,227,1,3,232,1,1,239,1,1,241,1,1,247,1,1,252,1,1,254,1,1,207,3,1,131,4,1,135,4,1,206,4,4,131,5,77,209,5,38,130,6,4,181,6,3,133,7,1,135,7,40,144,8,1,206,214,243,86,1,0,178,1,207,210,187,205,12,1,0,8,208,203,223,226,9,1,0,81,207,231,154,196,9,1,0,3,217,168,198,159,4,1,0,7,218,255,204,32,1,0,21,219,200,174,197,9,1,0,25,220,225,223,240,3,8,0,4,7,3,11,24,41,1,46,2,51,1,53,3,59,1,223,215,172,155,15,1,0,5,224,159,166,178,15,1,0,30,226,167,254,250,5,3,8,1,10,1,12,1,227,211,144,195,8,1,0,12,228,242,134,215,15,4,5,1,7,1,9,1,11,1,229,154,194,35,1,0,178,1,226,235,133,189,11,1,0,7,236,158,128,159,2,1,0,4,237,140,187,206,2,1,0,21,236,253,128,205,3,1,0,9,239,239,208,251,10,1,0,17,240,179,157,219,7,1,0,4,241,147,239,232,6,1,0,4,238,153,239,204,9,7,0,3,4,3,8,3,30,1,32,1,46,1,48,1,243,138,171,183,10,1,0,252,1,245,181,155,135,2,1,0,23,247,212,219,208,10,1,0,46],"version":0,"object_id":"26d5c8c1-1c66-459c-bc6c-f4da1a663348"},"code":0,"message":"Operation completed successfully."} \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/fixtures/simple_doc.json b/frontend/appflowy_web_app/cypress/fixtures/simple_doc.json new file mode 100644 index 0000000000000..97bd9c99b5b95 --- /dev/null +++ b/frontend/appflowy_web_app/cypress/fixtures/simple_doc.json @@ -0,0 +1 @@ +{"data":{"state_vector":[5,200,244,136,224,7,3,178,246,186,209,6,72,147,128,159,145,14,26,195,133,217,18,167,13,156,139,194,87,4],"doc_state":[5,26,147,128,159,145,14,0,39,1,4,100,97,116,97,8,100,111,99,117,109,101,110,116,1,39,0,147,128,159,145,14,0,6,98,108,111,99,107,115,1,39,0,147,128,159,145,14,0,4,109,101,116,97,1,39,0,147,128,159,145,14,2,12,99,104,105,108,100,114,101,110,95,109,97,112,1,39,0,147,128,159,145,14,2,8,116,101,120,116,95,109,97,112,1,40,0,147,128,159,145,14,0,7,112,97,103,101,95,105,100,1,119,10,85,86,79,107,81,88,110,117,86,114,39,0,147,128,159,145,14,1,10,51,77,108,48,104,78,110,102,79,82,1,40,0,147,128,159,145,14,6,2,105,100,1,119,10,51,77,108,48,104,78,110,102,79,82,40,0,147,128,159,145,14,6,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,147,128,159,145,14,6,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,147,128,159,145,14,6,8,99,104,105,108,100,114,101,110,1,119,10,73,121,84,67,107,48,105,52,113,114,33,0,147,128,159,145,14,6,4,100,97,116,97,1,33,0,147,128,159,145,14,6,11,101,120,116,101,114,110,97,108,95,105,100,1,33,0,147,128,159,145,14,6,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,39,0,147,128,159,145,14,3,10,73,121,84,67,107,48,105,52,113,114,0,39,0,147,128,159,145,14,1,10,85,86,79,107,81,88,110,117,86,114,1,40,0,147,128,159,145,14,15,2,105,100,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,147,128,159,145,14,15,2,116,121,1,119,4,112,97,103,101,40,0,147,128,159,145,14,15,6,112,97,114,101,110,116,1,119,0,40,0,147,128,159,145,14,15,8,99,104,105,108,100,114,101,110,1,119,10,67,102,118,66,115,66,84,122,83,105,40,0,147,128,159,145,14,15,4,100,97,116,97,1,119,2,123,125,40,0,147,128,159,145,14,15,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,147,128,159,145,14,15,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,10,67,102,118,66,115,66,84,122,83,105,0,8,0,147,128,159,145,14,23,1,119,10,51,77,108,48,104,78,110,102,79,82,39,0,147,128,159,145,14,4,10,84,97,119,48,120,69,66,121,65,83,2,1,200,244,136,224,7,0,33,1,4,109,101,116,97,12,108,97,115,116,95,115,121,110,99,95,97,116,3,1,178,246,186,209,6,0,161,200,244,136,224,7,2,72,2,156,139,194,87,0,161,178,246,186,209,6,71,3,168,156,139,194,87,2,1,122,0,0,0,0,102,34,168,95,251,5,195,133,217,18,0,4,0,147,128,159,145,14,25,2,85,73,168,147,128,159,145,14,11,1,119,27,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,85,73,34,125,93,125,168,147,128,159,145,14,12,1,119,10,84,97,119,48,120,69,66,121,65,83,168,147,128,159,145,14,13,1,119,4,116,101,120,116,39,0,147,128,159,145,14,4,6,120,52,56,106,57,65,2,39,0,147,128,159,145,14,1,6,53,77,89,104,51,105,1,40,0,195,133,217,18,6,2,105,100,1,119,6,53,77,89,104,51,105,40,0,195,133,217,18,6,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,6,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,6,8,99,104,105,108,100,114,101,110,1,119,6,75,79,49,111,105,114,33,0,195,133,217,18,6,4,100,97,116,97,1,40,0,195,133,217,18,6,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,6,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,75,79,49,111,105,114,0,136,147,128,159,145,14,24,1,119,6,53,77,89,104,51,105,4,0,195,133,217,18,5,3,111,111,111,168,195,133,217,18,11,1,119,28,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,111,111,111,34,125,93,125,39,0,147,128,159,145,14,4,6,73,72,56,87,97,118,2,33,0,147,128,159,145,14,1,6,85,81,88,116,105,65,1,0,7,33,0,147,128,159,145,14,3,6,65,122,48,88,110,77,1,129,195,133,217,18,15,1,39,0,147,128,159,145,14,4,6,82,122,107,73,79,49,2,39,0,147,128,159,145,14,1,6,69,79,113,57,79,119,1,40,0,195,133,217,18,32,2,105,100,1,119,6,69,79,113,57,79,119,40,0,195,133,217,18,32,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,195,133,217,18,32,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,32,8,99,104,105,108,100,114,101,110,1,119,6,105,80,115,106,50,65,33,0,195,133,217,18,32,4,100,97,116,97,1,40,0,195,133,217,18,32,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,32,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,105,80,115,106,50,65,0,200,195,133,217,18,15,195,133,217,18,30,1,119,6,69,79,113,57,79,119,4,0,195,133,217,18,31,6,232,191,155,233,151,168,161,195,133,217,18,37,1,39,0,147,128,159,145,14,4,6,95,56,78,114,97,97,2,39,0,147,128,159,145,14,1,6,86,73,50,122,54,78,1,40,0,195,133,217,18,46,2,105,100,1,119,6,86,73,50,122,54,78,40,0,195,133,217,18,46,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,195,133,217,18,46,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,46,8,99,104,105,108,100,114,101,110,1,119,6,56,118,75,112,112,71,33,0,195,133,217,18,46,4,100,97,116,97,1,40,0,195,133,217,18,46,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,46,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,56,118,75,112,112,71,0,136,195,133,217,18,30,1,119,6,86,73,50,122,54,78,168,195,133,217,18,44,1,119,47,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,232,191,155,233,151,168,34,125,93,44,34,99,104,101,99,107,101,100,34,58,102,97,108,115,101,125,4,0,195,133,217,18,45,9,229,147,136,229,147,136,229,147,136,161,195,133,217,18,51,1,39,0,147,128,159,145,14,4,6,82,119,103,79,71,104,2,33,0,147,128,159,145,14,1,6,57,82,83,84,76,77,1,0,7,33,0,147,128,159,145,14,3,6,51,85,73,116,84,78,1,129,195,133,217,18,55,1,168,195,133,217,18,60,1,119,50,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,229,147,136,229,147,136,229,147,136,34,125,93,44,34,99,104,101,99,107,101,100,34,58,102,97,108,115,101,125,39,0,147,128,159,145,14,4,6,114,77,45,67,67,70,2,39,0,147,128,159,145,14,1,6,112,116,116,106,121,52,1,40,0,195,133,217,18,74,2,105,100,1,119,6,112,116,116,106,121,52,40,0,195,133,217,18,74,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,195,133,217,18,74,6,112,97,114,101,110,116,1,119,6,86,73,50,122,54,78,40,0,195,133,217,18,74,8,99,104,105,108,100,114,101,110,1,119,6,110,57,101,88,110,75,33,0,195,133,217,18,74,4,100,97,116,97,1,40,0,195,133,217,18,74,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,74,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,110,57,101,88,110,75,0,8,0,195,133,217,18,54,1,119,6,112,116,116,106,121,52,4,0,195,133,217,18,73,12,229,129,165,229,186,183,233,130,163,232,190,185,161,195,133,217,18,79,1,39,0,147,128,159,145,14,4,6,79,86,73,85,113,85,2,33,0,147,128,159,145,14,1,6,55,90,111,73,74,109,1,0,7,33,0,147,128,159,145,14,3,6,117,57,107,50,68,78,1,129,195,133,217,18,71,1,39,0,147,128,159,145,14,4,6,85,111,95,84,114,107,2,4,0,195,133,217,18,100,11,49,50,51,32,36,32,32,101,114,32,32,39,0,147,128,159,145,14,4,6,99,102,56,95,106,100,2,4,0,195,133,217,18,112,3,49,50,51,39,0,147,128,159,145,14,4,6,53,87,72,110,88,75,2,4,0,195,133,217,18,116,19,99,104,101,99,107,101,100,32,116,111,100,111,32,108,105,115,116,32,36,39,0,147,128,159,145,14,4,6,45,107,95,95,66,113,2,39,0,147,128,159,145,14,4,6,95,67,82,55,75,53,2,4,0,195,133,217,18,137,1,180,1,108,111,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,76,101,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,39,0,147,128,159,145,14,4,6,122,79,53,86,113,78,2,39,0,147,128,159,145,14,4,6,50,51,68,78,97,74,2,4,0,195,133,217,18,191,2,4,119,105,116,104,39,0,147,128,159,145,14,4,6,95,56,114,66,75,71,2,39,0,147,128,159,145,14,4,6,67,54,45,78,116,106,2,4,0,195,133,217,18,197,2,11,229,144,140,228,184,128,228,184,170,110,105,39,0,147,128,159,145,14,4,6,120,83,103,122,75,55,2,4,0,195,133,217,18,203,2,55,67,108,105,99,107,32,97,110,121,119,104,101,114,101,32,97,110,100,32,106,117,115,116,32,115,116,97,114,116,32,116,121,112,105,110,103,229,147,136,229,147,136,229,147,136,46,229,176,177,229,135,160,229,174,182,39,0,147,128,159,145,14,4,6,53,118,101,50,119,103,2,39,0,147,128,159,145,14,4,6,84,86,66,74,86,72,2,6,0,195,133,217,18,248,2,8,98,103,95,99,111,108,111,114,12,34,48,120,98,51,102,102,101,98,51,98,34,132,195,133,217,18,249,2,10,72,105,103,104,108,105,103,104,116,32,134,195,133,217,18,131,3,8,98,103,95,99,111,108,111,114,4,110,117,108,108,132,195,133,217,18,132,3,38,97,110,121,32,116,101,120,116,44,32,97,110,100,32,117,115,101,32,116,104,101,32,101,100,105,116,105,110,103,32,109,101,110,117,32,116,111,32,134,195,133,217,18,170,3,6,105,116,97,108,105,99,4,116,114,117,101,132,195,133,217,18,171,3,5,115,116,121,108,101,134,195,133,217,18,176,3,6,105,116,97,108,105,99,4,110,117,108,108,132,195,133,217,18,177,3,1,32,134,195,133,217,18,178,3,4,98,111,108,100,4,116,114,117,101,132,195,133,217,18,179,3,4,121,111,117,114,134,195,133,217,18,183,3,4,98,111,108,100,4,110,117,108,108,132,195,133,217,18,184,3,1,32,134,195,133,217,18,185,3,9,117,110,100,101,114,108,105,110,101,4,116,114,117,101,132,195,133,217,18,186,3,7,119,114,105,116,105,110,103,134,195,133,217,18,193,3,9,117,110,100,101,114,108,105,110,101,4,110,117,108,108,132,195,133,217,18,194,3,1,32,134,195,133,217,18,195,3,4,99,111,100,101,4,116,114,117,101,132,195,133,217,18,196,3,7,104,111,119,101,118,101,114,134,195,133,217,18,203,3,4,99,111,100,101,4,110,117,108,108,132,195,133,217,18,204,3,5,32,121,111,117,32,134,195,133,217,18,209,3,13,115,116,114,105,107,101,116,104,114,111,117,103,104,4,116,114,117,101,132,195,133,217,18,210,3,5,108,105,107,101,46,134,195,133,217,18,215,3,13,115,116,114,105,107,101,116,104,114,111,117,103,104,4,110,117,108,108,39,0,147,128,159,145,14,4,6,109,119,113,108,50,67,2,39,0,147,128,159,145,14,4,6,67,82,79,71,73,119,2,4,0,195,133,217,18,218,3,20,65,115,32,115,111,111,110,32,97,115,32,121,111,117,32,116,121,112,101,32,134,195,133,217,18,238,3,10,102,111,110,116,95,99,111,108,111,114,11,34,48,120,49,48,48,98,53,102,102,34,132,195,133,217,18,239,3,1,47,134,195,133,217,18,240,3,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,132,195,133,217,18,241,3,28,32,97,32,109,101,110,117,32,119,105,108,108,32,112,111,112,32,117,112,46,32,83,101,108,101,99,116,32,134,195,133,217,18,141,4,8,98,103,95,99,111,108,111,114,12,34,48,120,98,51,57,99,50,55,98,48,34,132,195,133,217,18,142,4,15,100,105,102,102,101,114,101,110,116,32,116,121,112,101,115,134,195,133,217,18,157,4,8,98,103,95,99,111,108,111,114,4,110,117,108,108,132,195,133,217,18,158,4,31,32,111,102,32,99,111,110,116,101,110,116,32,98,108,111,99,107,115,32,121,111,117,32,99,97,110,32,97,100,100,46,39,0,147,128,159,145,14,4,6,53,72,118,84,75,51,2,39,0,147,128,159,145,14,4,6,90,79,118,81,105,51,2,4,0,195,133,217,18,191,4,5,84,121,112,101,32,134,195,133,217,18,196,4,4,99,111,100,101,4,116,114,117,101,132,195,133,217,18,197,4,1,47,134,195,133,217,18,198,4,4,99,111,100,101,4,110,117,108,108,132,195,133,217,18,199,4,13,32,102,111,108,108,111,119,101,100,32,98,121,32,134,195,133,217,18,212,4,4,99,111,100,101,4,116,114,117,101,132,195,133,217,18,213,4,7,47,98,117,108,108,101,116,134,195,133,217,18,220,4,4,99,111,100,101,4,110,117,108,108,132,195,133,217,18,221,4,4,32,111,114,32,134,195,133,217,18,225,4,4,99,111,100,101,4,116,114,117,101,132,195,133,217,18,226,4,22,47,110,117,109,32,116,111,32,99,114,101,97,116,101,32,97,32,108,105,115,116,46,134,195,133,217,18,248,4,4,99,111,100,101,4,110,117,108,108,39,0,147,128,159,145,14,4,6,79,90,115,66,78,49,2,39,0,147,128,159,145,14,4,6,81,116,69,74,118,51,2,4,0,195,133,217,18,251,4,6,67,108,105,99,107,32,134,195,133,217,18,129,5,4,99,111,100,101,4,116,114,117,101,132,195,133,217,18,130,5,11,43,32,78,101,119,32,80,97,103,101,32,134,195,133,217,18,141,5,4,99,111,100,101,4,110,117,108,108,132,195,133,217,18,142,5,50,98,117,116,116,111,110,32,97,116,32,116,104,101,32,98,111,116,116,111,109,32,111,102,32,121,111,117,114,32,115,105,100,101,98,97,114,32,116,111,32,97,100,100,32,97,32,110,101,119,32,134,195,133,217,18,192,5,4,98,111,108,100,4,116,114,117,101,132,195,133,217,18,193,5,4,112,97,103,101,134,195,133,217,18,197,5,4,98,111,108,100,4,110,117,108,108,132,195,133,217,18,198,5,1,46,39,0,147,128,159,145,14,4,6,84,100,107,119,102,104,2,39,0,147,128,159,145,14,4,6,90,70,108,73,71,121,2,4,0,195,133,217,18,201,5,6,67,108,105,99,107,32,134,195,133,217,18,207,5,4,99,111,100,101,4,116,114,117,101,132,195,133,217,18,208,5,1,43,134,195,133,217,18,209,5,4,99,111,100,101,4,110,117,108,108,132,195,133,217,18,210,5,1,32,134,195,133,217,18,211,5,10,102,111,110,116,95,99,111,108,111,114,11,34,48,120,49,100,98,51,54,51,54,34,134,195,133,217,18,212,5,8,98,103,95,99,111,108,111,114,11,34,48,120,49,102,102,100,97,101,54,34,132,195,133,217,18,213,5,4,110,101,120,116,134,195,133,217,18,217,5,8,98,103,95,99,111,108,111,114,4,110,117,108,108,134,195,133,217,18,218,5,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,132,195,133,217,18,219,5,37,32,116,111,32,97,110,121,32,112,97,103,101,32,116,105,116,108,101,32,105,110,32,116,104,101,32,115,105,100,101,98,97,114,32,116,111,32,134,195,133,217,18,128,6,10,102,111,110,116,95,99,111,108,111,114,11,34,48,120,49,56,52,50,55,101,48,34,132,195,133,217,18,129,6,7,113,117,105,99,107,108,121,134,195,133,217,18,136,6,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,132,195,133,217,18,137,6,1,32,134,195,133,217,18,138,6,6,105,116,97,108,105,99,4,116,114,117,101,134,195,133,217,18,139,6,9,117,110,100,101,114,108,105,110,101,4,116,114,117,101,134,195,133,217,18,140,6,13,115,116,114,105,107,101,116,104,114,111,117,103,104,4,116,114,117,101,132,195,133,217,18,141,6,9,230,140,168,233,161,191,230,137,147,134,195,133,217,18,144,6,9,117,110,100,101,114,108,105,110,101,4,110,117,108,108,134,195,133,217,18,145,6,6,105,116,97,108,105,99,4,110,117,108,108,134,195,133,217,18,146,6,13,115,116,114,105,107,101,116,104,114,111,117,103,104,4,110,117,108,108,132,195,133,217,18,147,6,16,32,97,32,110,101,119,32,115,117,98,112,97,103,101,44,32,134,195,133,217,18,163,6,4,99,111,100,101,4,116,114,117,101,132,195,133,217,18,164,6,32,68,111,99,117,109,101,110,116,44,32,71,114,105,100,44,32,111,114,32,75,97,110,98,97,110,32,66,111,97,114,100,46,134,195,133,217,18,196,6,4,99,111,100,101,4,110,117,108,108,39,0,147,128,159,145,14,4,6,51,55,75,112,109,74,2,39,0,147,128,159,145,14,4,6,114,49,67,51,121,66,2,4,0,195,133,217,18,199,6,6,228,189,147,233,170,140,134,195,133,217,18,201,6,13,115,116,114,105,107,101,116,104,114,111,117,103,104,4,116,114,117,101,134,195,133,217,18,202,6,9,117,110,100,101,114,108,105,110,101,4,116,114,117,101,132,195,133,217,18,203,6,3,228,184,128,134,195,133,217,18,204,6,9,117,110,100,101,114,108,105,110,101,4,110,117,108,108,134,195,133,217,18,205,6,13,115,116,114,105,107,101,116,104,114,111,117,103,104,4,110,117,108,108,39,0,147,128,159,145,14,4,6,104,48,112,77,45,68,2,4,0,195,133,217,18,207,6,6,231,155,145,230,142,167,39,0,147,128,159,145,14,4,6,88,85,57,77,122,75,2,4,0,195,133,217,18,210,6,13,103,104,104,104,229,143,145,230,140,165,229,165,189,39,0,147,128,159,145,14,4,6,88,101,101,105,77,89,2,4,0,195,133,217,18,218,6,4,54,54,54,57,39,0,147,128,159,145,14,4,6,111,121,69,56,121,53,2,4,0,195,133,217,18,223,6,4,240,159,152,131,39,0,147,128,159,145,14,4,6,80,69,50,72,56,68,2,4,0,195,133,217,18,226,6,12,229,185,178,230,180,187,229,147,136,229,147,136,39,0,147,128,159,145,14,4,6,72,80,78,114,99,102,2,6,0,195,133,217,18,231,6,8,98,103,95,99,111,108,111,114,11,34,48,120,49,97,55,100,102,52,97,34,134,195,133,217,18,232,6,10,102,111,110,116,95,99,111,108,111,114,11,34,48,120,49,101,97,56,102,48,54,34,132,195,133,217,18,233,6,4,54,54,54,57,134,195,133,217,18,237,6,10,102,111,110,116,95,99,111,108,111,114,4,110,117,108,108,134,195,133,217,18,238,6,8,98,103,95,99,111,108,111,114,4,110,117,108,108,39,0,147,128,159,145,14,4,6,55,81,111,111,73,112,2,39,0,147,128,159,145,14,4,6,120,117,78,48,102,83,2,4,0,195,133,217,18,241,6,44,75,101,121,98,111,97,114,100,32,115,104,111,114,116,99,117,116,115,44,32,109,97,114,107,100,111,119,110,44,32,97,110,100,32,99,111,100,101,32,98,108,111,99,107,39,0,147,128,159,145,14,4,6,108,107,118,90,121,113,2,4,0,195,133,217,18,158,7,19,75,101,121,98,111,97,114,100,32,115,104,111,114,116,99,117,116,115,32,134,195,133,217,18,177,7,4,104,114,101,102,68,34,104,116,116,112,115,58,47,47,97,112,112,102,108,111,119,121,46,103,105,116,98,111,111,107,46,105,111,47,100,111,99,115,47,101,115,115,101,110,116,105,97,108,45,100,111,99,117,109,101,110,116,97,116,105,111,110,47,115,104,111,114,116,99,117,116,115,34,132,195,133,217,18,178,7,5,103,117,105,100,101,134,195,133,217,18,183,7,4,104,114,101,102,4,110,117,108,108,39,0,147,128,159,145,14,4,6,97,50,90,104,55,55,2,4,0,195,133,217,18,185,7,9,77,97,114,107,100,111,119,110,32,134,195,133,217,18,194,7,4,104,114,101,102,67,34,104,116,116,112,115,58,47,47,97,112,112,102,108,111,119,121,46,103,105,116,98,111,111,107,46,105,111,47,100,111,99,115,47,101,115,115,101,110,116,105,97,108,45,100,111,99,117,109,101,110,116,97,116,105,111,110,47,109,97,114,107,100,111,119,110,34,132,195,133,217,18,195,7,9,114,101,102,101,114,101,110,99,101,134,195,133,217,18,204,7,4,104,114,101,102,4,110,117,108,108,39,0,147,128,159,145,14,4,6,122,122,70,106,54,119,2,4,0,195,133,217,18,206,7,3,105,106,106,39,0,147,128,159,145,14,4,6,72,85,89,49,86,115,2,4,0,195,133,217,18,210,7,4,106,107,110,98,39,0,147,128,159,145,14,4,6,102,79,45,120,115,55,2,4,0,195,133,217,18,215,7,12,232,191,155,230,173,165,230,156,186,228,188,154,39,0,147,128,159,145,14,4,6,51,110,110,101,106,112,2,4,0,195,133,217,18,220,7,12,230,150,164,230,150,164,232,174,161,232,190,131,39,0,147,128,159,145,14,4,6,109,54,68,111,117,70,2,4,0,195,133,217,18,225,7,5,84,121,112,101,32,134,195,133,217,18,230,7,4,99,111,100,101,4,116,114,117,101,132,195,133,217,18,231,7,28,47,99,111,100,101,32,116,111,32,105,110,115,101,114,116,32,97,32,99,111,100,101,32,98,108,111,99,107,134,195,133,217,18,131,8,4,99,111,100,101,4,110,117,108,108,39,0,147,128,159,145,14,4,6,51,119,76,72,119,80,2,4,0,195,133,217,18,133,8,13,98,117,108,108,101,116,101,100,32,108,105,115,116,39,0,147,128,159,145,14,4,6,73,57,55,106,83,103,2,4,0,195,133,217,18,147,8,7,99,104,105,108,100,45,49,39,0,147,128,159,145,14,4,6,114,55,104,73,74,95,2,4,0,195,133,217,18,155,8,9,99,104,105,108,100,45,49,45,49,39,0,147,128,159,145,14,4,6,53,74,52,110,52,56,2,4,0,195,133,217,18,165,8,9,99,104,105,108,100,45,49,45,50,39,0,147,128,159,145,14,4,6,82,105,78,75,118,55,2,4,0,195,133,217,18,175,8,7,99,104,105,108,100,45,50,39,0,147,128,159,145,14,4,6,57,119,57,113,66,45,2,4,0,195,133,217,18,183,8,3,49,50,51,39,0,147,128,159,145,14,4,6,84,81,109,75,119,97,2,4,0,195,133,217,18,187,8,18,72,97,118,101,32,97,32,113,117,101,115,116,105,111,110,226,157,147,39,0,147,128,159,145,14,4,6,50,83,115,67,101,65,2,4,0,195,133,217,18,204,8,6,67,108,105,99,107,32,134,195,133,217,18,210,8,4,99,111,100,101,4,116,114,117,101,132,195,133,217,18,211,8,1,63,134,195,133,217,18,212,8,4,99,111,100,101,4,110,117,108,108,132,195,133,217,18,213,8,42,32,97,116,32,116,104,101,32,98,111,116,116,111,109,32,114,105,103,104,116,32,102,111,114,32,104,101,108,112,32,97,110,100,32,115,117,112,112,111,114,116,46,39,0,147,128,159,145,14,1,6,118,71,89,57,89,100,1,40,0,195,133,217,18,128,9,2,105,100,1,119,6,118,71,89,57,89,100,40,0,195,133,217,18,128,9,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,195,133,217,18,128,9,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,128,9,8,99,104,105,108,100,114,101,110,1,119,6,122,55,68,52,54,115,40,0,195,133,217,18,128,9,4,100,97,116,97,1,119,46,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,49,50,51,32,36,32,32,101,114,32,32,34,125,93,44,34,108,101,118,101,108,34,58,50,125,40,0,195,133,217,18,128,9,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,128,9,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,122,55,68,52,54,115,0,200,195,133,217,18,71,195,133,217,18,99,1,119,6,118,71,89,57,89,100,39,0,147,128,159,145,14,1,6,115,45,78,113,116,119,1,40,0,195,133,217,18,138,9,2,105,100,1,119,6,115,45,78,113,116,119,40,0,195,133,217,18,138,9,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,195,133,217,18,138,9,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,138,9,8,99,104,105,108,100,114,101,110,1,119,6,85,65,51,119,110,54,40,0,195,133,217,18,138,9,4,100,97,116,97,1,119,38,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,49,50,51,34,125,93,44,34,108,101,118,101,108,34,58,51,125,40,0,195,133,217,18,138,9,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,138,9,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,85,65,51,119,110,54,0,200,195,133,217,18,137,9,195,133,217,18,99,1,119,6,115,45,78,113,116,119,39,0,147,128,159,145,14,1,6,75,55,102,84,65,65,1,40,0,195,133,217,18,148,9,2,105,100,1,119,6,75,55,102,84,65,65,40,0,195,133,217,18,148,9,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,148,9,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,148,9,8,99,104,105,108,100,114,101,110,1,119,6,88,112,113,70,83,99,40,0,195,133,217,18,148,9,4,100,97,116,97,1,119,44,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,99,104,101,99,107,101,100,32,116,111,100,111,32,108,105,115,116,32,36,34,125,93,125,40,0,195,133,217,18,148,9,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,148,9,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,88,112,113,70,83,99,0,200,195,133,217,18,147,9,195,133,217,18,99,1,119,6,75,55,102,84,65,65,39,0,147,128,159,145,14,1,6,113,84,87,120,77,103,1,40,0,195,133,217,18,158,9,2,105,100,1,119,6,113,84,87,120,77,103,40,0,195,133,217,18,158,9,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,158,9,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,158,9,8,99,104,105,108,100,114,101,110,1,119,6,115,116,70,77,88,66,40,0,195,133,217,18,158,9,4,100,97,116,97,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,40,0,195,133,217,18,158,9,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,158,9,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,115,116,70,77,88,66,0,200,195,133,217,18,157,9,195,133,217,18,99,1,119,6,113,84,87,120,77,103,39,0,147,128,159,145,14,1,6,79,116,113,105,98,55,1,40,0,195,133,217,18,168,9,2,105,100,1,119,6,79,116,113,105,98,55,40,0,195,133,217,18,168,9,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,168,9,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,168,9,8,99,104,105,108,100,114,101,110,1,119,6,53,56,45,56,70,76,40,0,195,133,217,18,168,9,4,100,97,116,97,1,119,205,1,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,108,111,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,76,101,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,110,103,32,116,101,120,116,34,125,93,125,40,0,195,133,217,18,168,9,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,168,9,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,53,56,45,56,70,76,0,200,195,133,217,18,167,9,195,133,217,18,99,1,119,6,79,116,113,105,98,55,39,0,147,128,159,145,14,1,6,120,87,45,65,56,86,1,40,0,195,133,217,18,178,9,2,105,100,1,119,6,120,87,45,65,56,86,40,0,195,133,217,18,178,9,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,178,9,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,178,9,8,99,104,105,108,100,114,101,110,1,119,6,103,84,78,70,76,73,40,0,195,133,217,18,178,9,4,100,97,116,97,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,40,0,195,133,217,18,178,9,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,178,9,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,103,84,78,70,76,73,0,200,195,133,217,18,177,9,195,133,217,18,99,1,119,6,120,87,45,65,56,86,39,0,147,128,159,145,14,1,6,112,109,117,76,121,114,1,40,0,195,133,217,18,188,9,2,105,100,1,119,6,112,109,117,76,121,114,40,0,195,133,217,18,188,9,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,188,9,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,188,9,8,99,104,105,108,100,114,101,110,1,119,6,100,121,111,70,45,65,40,0,195,133,217,18,188,9,4,100,97,116,97,1,119,29,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,119,105,116,104,34,125,93,125,40,0,195,133,217,18,188,9,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,188,9,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,100,121,111,70,45,65,0,200,195,133,217,18,187,9,195,133,217,18,99,1,119,6,112,109,117,76,121,114,39,0,147,128,159,145,14,1,6,88,87,120,55,57,115,1,40,0,195,133,217,18,198,9,2,105,100,1,119,6,88,87,120,55,57,115,40,0,195,133,217,18,198,9,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,198,9,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,198,9,8,99,104,105,108,100,114,101,110,1,119,6,88,67,102,53,75,117,40,0,195,133,217,18,198,9,4,100,97,116,97,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,40,0,195,133,217,18,198,9,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,198,9,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,88,67,102,53,75,117,0,200,195,133,217,18,197,9,195,133,217,18,99,1,119,6,88,87,120,55,57,115,39,0,147,128,159,145,14,1,6,87,50,72,99,77,83,1,40,0,195,133,217,18,208,9,2,105,100,1,119,6,87,50,72,99,77,83,40,0,195,133,217,18,208,9,2,116,121,1,119,5,113,117,111,116,101,40,0,195,133,217,18,208,9,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,208,9,8,99,104,105,108,100,114,101,110,1,119,6,76,82,68,50,65,86,40,0,195,133,217,18,208,9,4,100,97,116,97,1,119,36,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,229,144,140,228,184,128,228,184,170,110,105,34,125,93,125,40,0,195,133,217,18,208,9,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,208,9,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,76,82,68,50,65,86,0,200,195,133,217,18,207,9,195,133,217,18,99,1,119,6,87,50,72,99,77,83,39,0,147,128,159,145,14,1,6,114,45,105,49,57,106,1,40,0,195,133,217,18,218,9,2,105,100,1,119,6,114,45,105,49,57,106,40,0,195,133,217,18,218,9,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,218,9,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,218,9,8,99,104,105,108,100,114,101,110,1,119,6,76,97,68,83,112,65,40,0,195,133,217,18,218,9,4,100,97,116,97,1,119,80,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,67,108,105,99,107,32,97,110,121,119,104,101,114,101,32,97,110,100,32,106,117,115,116,32,115,116,97,114,116,32,116,121,112,105,110,103,229,147,136,229,147,136,229,147,136,46,229,176,177,229,135,160,229,174,182,34,125,93,125,40,0,195,133,217,18,218,9,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,218,9,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,76,97,68,83,112,65,0,200,195,133,217,18,217,9,195,133,217,18,99,1,119,6,114,45,105,49,57,106,39,0,147,128,159,145,14,1,6,45,77,89,115,65,114,1,40,0,195,133,217,18,228,9,2,105,100,1,119,6,45,77,89,115,65,114,40,0,195,133,217,18,228,9,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,228,9,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,228,9,8,99,104,105,108,100,114,101,110,1,119,6,70,122,111,65,105,114,40,0,195,133,217,18,228,9,4,100,97,116,97,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,40,0,195,133,217,18,228,9,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,228,9,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,70,122,111,65,105,114,0,200,195,133,217,18,227,9,195,133,217,18,99,1,119,6,45,77,89,115,65,114,39,0,147,128,159,145,14,1,6,53,99,99,77,84,71,1,40,0,195,133,217,18,238,9,2,105,100,1,119,6,53,99,99,77,84,71,40,0,195,133,217,18,238,9,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,238,9,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,238,9,8,99,104,105,108,100,114,101,110,1,119,6,105,85,97,67,74,107,40,0,195,133,217,18,238,9,4,100,97,116,97,1,119,183,3,123,34,100,101,108,116,97,34,58,91,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,98,103,95,99,111,108,111,114,34,58,34,48,120,98,51,102,102,101,98,51,98,34,125,44,34,105,110,115,101,114,116,34,58,34,72,105,103,104,108,105,103,104,116,32,34,125,44,123,34,105,110,115,101,114,116,34,58,34,97,110,121,32,116,101,120,116,44,32,97,110,100,32,117,115,101,32,116,104,101,32,101,100,105,116,105,110,103,32,109,101,110,117,32,116,111,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,105,116,97,108,105,99,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,115,116,121,108,101,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,98,111,108,100,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,121,111,117,114,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,117,110,100,101,114,108,105,110,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,119,114,105,116,105,110,103,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,104,111,119,101,118,101,114,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,121,111,117,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,115,116,114,105,107,101,116,104,114,111,117,103,104,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,108,105,107,101,46,34,125,93,125,40,0,195,133,217,18,238,9,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,238,9,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,105,85,97,67,74,107,0,200,195,133,217,18,237,9,195,133,217,18,99,1,119,6,53,99,99,77,84,71,39,0,147,128,159,145,14,1,6,57,88,75,76,69,115,1,40,0,195,133,217,18,248,9,2,105,100,1,119,6,57,88,75,76,69,115,40,0,195,133,217,18,248,9,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,248,9,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,248,9,8,99,104,105,108,100,114,101,110,1,119,6,82,116,101,71,70,116,40,0,195,133,217,18,248,9,4,100,97,116,97,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,40,0,195,133,217,18,248,9,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,248,9,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,82,116,101,71,70,116,0,200,195,133,217,18,247,9,195,133,217,18,99,1,119,6,57,88,75,76,69,115,39,0,147,128,159,145,14,1,6,45,99,53,69,50,113,1,40,0,195,133,217,18,130,10,2,105,100,1,119,6,45,99,53,69,50,113,40,0,195,133,217,18,130,10,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,130,10,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,130,10,8,99,104,105,108,100,114,101,110,1,119,6,90,52,77,78,84,95,40,0,195,133,217,18,130,10,4,100,97,116,97,1,119,255,1,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,65,115,32,115,111,111,110,32,97,115,32,121,111,117,32,116,121,112,101,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,102,111,110,116,95,99,111,108,111,114,34,58,34,48,120,49,48,48,98,53,102,102,34,125,44,34,105,110,115,101,114,116,34,58,34,47,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,97,32,109,101,110,117,32,119,105,108,108,32,112,111,112,32,117,112,46,32,83,101,108,101,99,116,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,98,103,95,99,111,108,111,114,34,58,34,48,120,98,51,57,99,50,55,98,48,34,125,44,34,105,110,115,101,114,116,34,58,34,100,105,102,102,101,114,101,110,116,32,116,121,112,101,115,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,111,102,32,99,111,110,116,101,110,116,32,98,108,111,99,107,115,32,121,111,117,32,99,97,110,32,97,100,100,46,34,125,93,125,40,0,195,133,217,18,130,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,130,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,90,52,77,78,84,95,0,200,195,133,217,18,129,10,195,133,217,18,99,1,119,6,45,99,53,69,50,113,39,0,147,128,159,145,14,1,6,108,73,53,65,67,48,1,40,0,195,133,217,18,140,10,2,105,100,1,119,6,108,73,53,65,67,48,40,0,195,133,217,18,140,10,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,140,10,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,140,10,8,99,104,105,108,100,114,101,110,1,119,6,57,86,108,107,82,87,40,0,195,133,217,18,140,10,4,100,97,116,97,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,40,0,195,133,217,18,140,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,140,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,57,86,108,107,82,87,0,200,195,133,217,18,139,10,195,133,217,18,99,1,119,6,108,73,53,65,67,48,39,0,147,128,159,145,14,1,6,115,98,73,99,106,103,1,40,0,195,133,217,18,150,10,2,105,100,1,119,6,115,98,73,99,106,103,40,0,195,133,217,18,150,10,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,150,10,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,150,10,8,99,104,105,108,100,114,101,110,1,119,6,56,119,111,119,68,50,40,0,195,133,217,18,150,10,4,100,97,116,97,1,119,228,1,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,84,121,112,101,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,47,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,102,111,108,108,111,119,101,100,32,98,121,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,47,98,117,108,108,101,116,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,111,114,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,47,110,117,109,32,116,111,32,99,114,101,97,116,101,32,97,32,108,105,115,116,46,34,125,93,125,40,0,195,133,217,18,150,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,150,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,56,119,111,119,68,50,0,200,195,133,217,18,149,10,195,133,217,18,99,1,119,6,115,98,73,99,106,103,39,0,147,128,159,145,14,1,6,65,72,89,121,80,85,1,40,0,195,133,217,18,160,10,2,105,100,1,119,6,65,72,89,121,80,85,40,0,195,133,217,18,160,10,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,160,10,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,160,10,8,99,104,105,108,100,114,101,110,1,119,6,70,68,79,70,76,77,40,0,195,133,217,18,160,10,4,100,97,116,97,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,40,0,195,133,217,18,160,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,160,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,70,68,79,70,76,77,0,200,195,133,217,18,159,10,195,133,217,18,99,1,119,6,65,72,89,121,80,85,39,0,147,128,159,145,14,1,6,72,110,55,49,119,97,1,40,0,195,133,217,18,170,10,2,105,100,1,119,6,72,110,55,49,119,97,40,0,195,133,217,18,170,10,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,170,10,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,170,10,8,99,104,105,108,100,114,101,110,1,119,6,80,55,68,49,81,54,40,0,195,133,217,18,170,10,4,100,97,116,97,1,119,207,1,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,67,108,105,99,107,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,43,32,78,101,119,32,80,97,103,101,32,34,125,44,123,34,105,110,115,101,114,116,34,58,34,98,117,116,116,111,110,32,97,116,32,116,104,101,32,98,111,116,116,111,109,32,111,102,32,121,111,117,114,32,115,105,100,101,98,97,114,32,116,111,32,97,100,100,32,97,32,110,101,119,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,98,111,108,100,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,112,97,103,101,34,125,44,123,34,105,110,115,101,114,116,34,58,34,46,34,125,93,125,40,0,195,133,217,18,170,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,170,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,80,55,68,49,81,54,0,200,195,133,217,18,169,10,195,133,217,18,99,1,119,6,72,110,55,49,119,97,39,0,147,128,159,145,14,1,6,56,120,67,119,110,83,1,40,0,195,133,217,18,180,10,2,105,100,1,119,6,56,120,67,119,110,83,40,0,195,133,217,18,180,10,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,180,10,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,180,10,8,99,104,105,108,100,114,101,110,1,119,6,114,116,113,68,113,100,40,0,195,133,217,18,180,10,4,100,97,116,97,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,40,0,195,133,217,18,180,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,180,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,114,116,113,68,113,100,0,200,195,133,217,18,179,10,195,133,217,18,99,1,119,6,56,120,67,119,110,83,39,0,147,128,159,145,14,1,6,67,79,113,95,50,67,1,40,0,195,133,217,18,190,10,2,105,100,1,119,6,67,79,113,95,50,67,40,0,195,133,217,18,190,10,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,190,10,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,190,10,8,99,104,105,108,100,114,101,110,1,119,6,109,54,56,82,52,55,40,0,195,133,217,18,190,10,4,100,97,116,97,1,119,233,3,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,67,108,105,99,107,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,43,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,98,103,95,99,111,108,111,114,34,58,34,48,120,49,102,102,100,97,101,54,34,44,34,102,111,110,116,95,99,111,108,111,114,34,58,34,48,120,49,100,98,51,54,51,54,34,125,44,34,105,110,115,101,114,116,34,58,34,110,101,120,116,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,116,111,32,97,110,121,32,112,97,103,101,32,116,105,116,108,101,32,105,110,32,116,104,101,32,115,105,100,101,98,97,114,32,116,111,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,102,111,110,116,95,99,111,108,111,114,34,58,34,48,120,49,56,52,50,55,101,48,34,125,44,34,105,110,115,101,114,116,34,58,34,113,117,105,99,107,108,121,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,105,116,97,108,105,99,34,58,116,114,117,101,44,34,115,116,114,105,107,101,116,104,114,111,117,103,104,34,58,116,114,117,101,44,34,117,110,100,101,114,108,105,110,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,230,140,168,233,161,191,230,137,147,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,97,32,110,101,119,32,115,117,98,112,97,103,101,44,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,68,111,99,117,109,101,110,116,44,32,71,114,105,100,44,32,111,114,32,75,97,110,98,97,110,32,66,111,97,114,100,46,34,125,93,125,40,0,195,133,217,18,190,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,190,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,109,54,56,82,52,55,0,200,195,133,217,18,189,10,195,133,217,18,99,1,119,6,67,79,113,95,50,67,39,0,147,128,159,145,14,1,6,114,71,120,87,45,114,1,40,0,195,133,217,18,200,10,2,105,100,1,119,6,114,71,120,87,45,114,40,0,195,133,217,18,200,10,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,200,10,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,200,10,8,99,104,105,108,100,114,101,110,1,119,6,112,82,70,53,54,68,40,0,195,133,217,18,200,10,4,100,97,116,97,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,40,0,195,133,217,18,200,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,200,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,112,82,70,53,54,68,0,200,195,133,217,18,199,10,195,133,217,18,99,1,119,6,114,71,120,87,45,114,39,0,147,128,159,145,14,1,6,102,48,55,89,71,81,1,40,0,195,133,217,18,210,10,2,105,100,1,119,6,102,48,55,89,71,81,40,0,195,133,217,18,210,10,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,210,10,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,210,10,8,99,104,105,108,100,114,101,110,1,119,6,78,56,98,56,111,65,40,0,195,133,217,18,210,10,4,100,97,116,97,1,119,101,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,228,189,147,233,170,140,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,115,116,114,105,107,101,116,104,114,111,117,103,104,34,58,116,114,117,101,44,34,117,110,100,101,114,108,105,110,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,228,184,128,34,125,93,125,40,0,195,133,217,18,210,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,210,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,78,56,98,56,111,65,0,200,195,133,217,18,209,10,195,133,217,18,99,1,119,6,102,48,55,89,71,81,39,0,147,128,159,145,14,1,6,68,73,122,109,100,87,1,40,0,195,133,217,18,220,10,2,105,100,1,119,6,68,73,122,109,100,87,40,0,195,133,217,18,220,10,2,116,121,1,119,5,113,117,111,116,101,40,0,195,133,217,18,220,10,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,220,10,8,99,104,105,108,100,114,101,110,1,119,6,55,114,82,118,114,89,40,0,195,133,217,18,220,10,4,100,97,116,97,1,119,31,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,231,155,145,230,142,167,34,125,93,125,40,0,195,133,217,18,220,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,220,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,55,114,82,118,114,89,0,200,195,133,217,18,219,10,195,133,217,18,99,1,119,6,68,73,122,109,100,87,39,0,147,128,159,145,14,1,6,88,104,87,98,66,48,1,40,0,195,133,217,18,230,10,2,105,100,1,119,6,88,104,87,98,66,48,40,0,195,133,217,18,230,10,2,116,121,1,119,5,113,117,111,116,101,40,0,195,133,217,18,230,10,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,230,10,8,99,104,105,108,100,114,101,110,1,119,6,69,110,114,122,69,100,40,0,195,133,217,18,230,10,4,100,97,116,97,1,119,38,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,103,104,104,104,229,143,145,230,140,165,229,165,189,34,125,93,125,40,0,195,133,217,18,230,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,230,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,69,110,114,122,69,100,0,200,195,133,217,18,229,10,195,133,217,18,99,1,119,6,88,104,87,98,66,48,39,0,147,128,159,145,14,1,6,117,122,54,88,89,118,1,40,0,195,133,217,18,240,10,2,105,100,1,119,6,117,122,54,88,89,118,40,0,195,133,217,18,240,10,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,240,10,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,240,10,8,99,104,105,108,100,114,101,110,1,119,6,80,72,76,53,98,110,40,0,195,133,217,18,240,10,4,100,97,116,97,1,119,29,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,54,54,54,57,34,125,93,125,40,0,195,133,217,18,240,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,240,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,80,72,76,53,98,110,0,200,195,133,217,18,239,10,195,133,217,18,99,1,119,6,117,122,54,88,89,118,39,0,147,128,159,145,14,1,6,51,110,100,90,103,51,1,40,0,195,133,217,18,250,10,2,105,100,1,119,6,51,110,100,90,103,51,40,0,195,133,217,18,250,10,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,250,10,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,250,10,8,99,104,105,108,100,114,101,110,1,119,6,79,120,115,115,72,77,40,0,195,133,217,18,250,10,4,100,97,116,97,1,119,29,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,240,159,152,131,34,125,93,125,40,0,195,133,217,18,250,10,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,250,10,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,79,120,115,115,72,77,0,200,195,133,217,18,249,10,195,133,217,18,99,1,119,6,51,110,100,90,103,51,39,0,147,128,159,145,14,1,6,110,103,95,69,109,50,1,40,0,195,133,217,18,132,11,2,105,100,1,119,6,110,103,95,69,109,50,40,0,195,133,217,18,132,11,2,116,121,1,119,5,113,117,111,116,101,40,0,195,133,217,18,132,11,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,132,11,8,99,104,105,108,100,114,101,110,1,119,6,102,107,66,48,107,107,40,0,195,133,217,18,132,11,4,100,97,116,97,1,119,37,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,229,185,178,230,180,187,229,147,136,229,147,136,34,125,93,125,40,0,195,133,217,18,132,11,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,132,11,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,102,107,66,48,107,107,0,200,195,133,217,18,131,11,195,133,217,18,99,1,119,6,110,103,95,69,109,50,39,0,147,128,159,145,14,1,6,102,84,86,120,101,99,1,40,0,195,133,217,18,142,11,2,105,100,1,119,6,102,84,86,120,101,99,40,0,195,133,217,18,142,11,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,142,11,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,142,11,8,99,104,105,108,100,114,101,110,1,119,6,99,107,80,105,100,83,40,0,195,133,217,18,142,11,4,100,97,116,97,1,119,92,123,34,100,101,108,116,97,34,58,91,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,98,103,95,99,111,108,111,114,34,58,34,48,120,49,97,55,100,102,52,97,34,44,34,102,111,110,116,95,99,111,108,111,114,34,58,34,48,120,49,101,97,56,102,48,54,34,125,44,34,105,110,115,101,114,116,34,58,34,54,54,54,57,34,125,93,125,40,0,195,133,217,18,142,11,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,142,11,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,99,107,80,105,100,83,0,200,195,133,217,18,141,11,195,133,217,18,99,1,119,6,102,84,86,120,101,99,39,0,147,128,159,145,14,1,6,111,56,70,84,77,117,1,40,0,195,133,217,18,152,11,2,105,100,1,119,6,111,56,70,84,77,117,40,0,195,133,217,18,152,11,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,152,11,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,152,11,8,99,104,105,108,100,114,101,110,1,119,6,108,119,100,112,111,55,40,0,195,133,217,18,152,11,4,100,97,116,97,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,40,0,195,133,217,18,152,11,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,152,11,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,108,119,100,112,111,55,0,200,195,133,217,18,151,11,195,133,217,18,99,1,119,6,111,56,70,84,77,117,39,0,147,128,159,145,14,1,6,82,74,81,67,50,82,1,40,0,195,133,217,18,162,11,2,105,100,1,119,6,82,74,81,67,50,82,40,0,195,133,217,18,162,11,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,195,133,217,18,162,11,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,162,11,8,99,104,105,108,100,114,101,110,1,119,6,119,97,87,79,66,52,40,0,195,133,217,18,162,11,4,100,97,116,97,1,119,79,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,75,101,121,98,111,97,114,100,32,115,104,111,114,116,99,117,116,115,44,32,109,97,114,107,100,111,119,110,44,32,97,110,100,32,99,111,100,101,32,98,108,111,99,107,34,125,93,44,34,108,101,118,101,108,34,58,50,125,40,0,195,133,217,18,162,11,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,162,11,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,119,97,87,79,66,52,0,200,195,133,217,18,161,11,195,133,217,18,99,1,119,6,82,74,81,67,50,82,39,0,147,128,159,145,14,1,6,117,70,54,49,55,109,1,40,0,195,133,217,18,172,11,2,105,100,1,119,6,117,70,54,49,55,109,40,0,195,133,217,18,172,11,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,195,133,217,18,172,11,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,172,11,8,99,104,105,108,100,114,101,110,1,119,6,55,118,110,77,69,78,40,0,195,133,217,18,172,11,4,100,97,116,97,1,119,154,1,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,75,101,121,98,111,97,114,100,32,115,104,111,114,116,99,117,116,115,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,104,114,101,102,34,58,34,104,116,116,112,115,58,47,47,97,112,112,102,108,111,119,121,46,103,105,116,98,111,111,107,46,105,111,47,100,111,99,115,47,101,115,115,101,110,116,105,97,108,45,100,111,99,117,109,101,110,116,97,116,105,111,110,47,115,104,111,114,116,99,117,116,115,34,125,44,34,105,110,115,101,114,116,34,58,34,103,117,105,100,101,34,125,93,125,40,0,195,133,217,18,172,11,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,172,11,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,55,118,110,77,69,78,0,200,195,133,217,18,171,11,195,133,217,18,99,1,119,6,117,70,54,49,55,109,39,0,147,128,159,145,14,1,6,76,87,83,67,73,57,1,40,0,195,133,217,18,182,11,2,105,100,1,119,6,76,87,83,67,73,57,40,0,195,133,217,18,182,11,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,195,133,217,18,182,11,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,182,11,8,99,104,105,108,100,114,101,110,1,119,6,111,67,89,117,74,57,40,0,195,133,217,18,182,11,4,100,97,116,97,1,119,147,1,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,77,97,114,107,100,111,119,110,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,104,114,101,102,34,58,34,104,116,116,112,115,58,47,47,97,112,112,102,108,111,119,121,46,103,105,116,98,111,111,107,46,105,111,47,100,111,99,115,47,101,115,115,101,110,116,105,97,108,45,100,111,99,117,109,101,110,116,97,116,105,111,110,47,109,97,114,107,100,111,119,110,34,125,44,34,105,110,115,101,114,116,34,58,34,114,101,102,101,114,101,110,99,101,34,125,93,125,40,0,195,133,217,18,182,11,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,182,11,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,111,67,89,117,74,57,0,200,195,133,217,18,181,11,195,133,217,18,99,1,119,6,76,87,83,67,73,57,39,0,147,128,159,145,14,1,6,110,98,54,83,103,101,1,40,0,195,133,217,18,192,11,2,105,100,1,119,6,110,98,54,83,103,101,40,0,195,133,217,18,192,11,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,195,133,217,18,192,11,6,112,97,114,101,110,116,1,119,6,76,87,83,67,73,57,40,0,195,133,217,18,192,11,8,99,104,105,108,100,114,101,110,1,119,6,105,87,100,115,72,89,40,0,195,133,217,18,192,11,4,100,97,116,97,1,119,28,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,105,106,106,34,125,93,125,40,0,195,133,217,18,192,11,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,192,11,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,105,87,100,115,72,89,0,8,0,195,133,217,18,190,11,1,119,6,110,98,54,83,103,101,39,0,147,128,159,145,14,1,6,77,73,113,111,117,90,1,40,0,195,133,217,18,202,11,2,105,100,1,119,6,77,73,113,111,117,90,40,0,195,133,217,18,202,11,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,195,133,217,18,202,11,6,112,97,114,101,110,116,1,119,6,110,98,54,83,103,101,40,0,195,133,217,18,202,11,8,99,104,105,108,100,114,101,110,1,119,6,88,65,95,122,90,115,40,0,195,133,217,18,202,11,4,100,97,116,97,1,119,29,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,106,107,110,98,34,125,93,125,40,0,195,133,217,18,202,11,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,202,11,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,88,65,95,122,90,115,0,8,0,195,133,217,18,200,11,1,119,6,77,73,113,111,117,90,39,0,147,128,159,145,14,1,6,98,112,45,97,121,53,1,40,0,195,133,217,18,212,11,2,105,100,1,119,6,98,112,45,97,121,53,40,0,195,133,217,18,212,11,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,195,133,217,18,212,11,6,112,97,114,101,110,116,1,119,6,77,73,113,111,117,90,40,0,195,133,217,18,212,11,8,99,104,105,108,100,114,101,110,1,119,6,73,84,120,118,112,57,40,0,195,133,217,18,212,11,4,100,97,116,97,1,119,37,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,232,191,155,230,173,165,230,156,186,228,188,154,34,125,93,125,40,0,195,133,217,18,212,11,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,212,11,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,73,84,120,118,112,57,0,8,0,195,133,217,18,210,11,1,119,6,98,112,45,97,121,53,39,0,147,128,159,145,14,1,6,56,121,102,112,65,112,1,40,0,195,133,217,18,222,11,2,105,100,1,119,6,56,121,102,112,65,112,40,0,195,133,217,18,222,11,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,195,133,217,18,222,11,6,112,97,114,101,110,116,1,119,6,77,73,113,111,117,90,40,0,195,133,217,18,222,11,8,99,104,105,108,100,114,101,110,1,119,6,45,109,54,99,76,105,40,0,195,133,217,18,222,11,4,100,97,116,97,1,119,37,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,230,150,164,230,150,164,232,174,161,232,190,131,34,125,93,125,40,0,195,133,217,18,222,11,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,222,11,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,45,109,54,99,76,105,0,136,195,133,217,18,221,11,1,119,6,56,121,102,112,65,112,39,0,147,128,159,145,14,1,6,111,109,54,99,122,66,1,40,0,195,133,217,18,232,11,2,105,100,1,119,6,111,109,54,99,122,66,40,0,195,133,217,18,232,11,2,116,121,1,119,13,110,117,109,98,101,114,101,100,95,108,105,115,116,40,0,195,133,217,18,232,11,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,232,11,8,99,104,105,108,100,114,101,110,1,119,6,68,82,102,101,121,118,40,0,195,133,217,18,232,11,4,100,97,116,97,1,119,99,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,84,121,112,101,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,47,99,111,100,101,32,116,111,32,105,110,115,101,114,116,32,97,32,99,111,100,101,32,98,108,111,99,107,34,125,93,125,40,0,195,133,217,18,232,11,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,232,11,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,68,82,102,101,121,118,0,200,195,133,217,18,191,11,195,133,217,18,99,1,119,6,111,109,54,99,122,66,39,0,147,128,159,145,14,1,6,65,120,74,79,67,97,1,40,0,195,133,217,18,242,11,2,105,100,1,119,6,65,120,74,79,67,97,40,0,195,133,217,18,242,11,2,116,121,1,119,13,98,117,108,108,101,116,101,100,95,108,105,115,116,40,0,195,133,217,18,242,11,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,242,11,8,99,104,105,108,100,114,101,110,1,119,6,49,52,84,74,100,51,40,0,195,133,217,18,242,11,4,100,97,116,97,1,119,38,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,98,117,108,108,101,116,101,100,32,108,105,115,116,34,125,93,125,40,0,195,133,217,18,242,11,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,242,11,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,49,52,84,74,100,51,0,200,195,133,217,18,241,11,195,133,217,18,99,1,119,6,65,120,74,79,67,97,39,0,147,128,159,145,14,1,6,48,55,67,110,83,97,1,40,0,195,133,217,18,252,11,2,105,100,1,119,6,48,55,67,110,83,97,40,0,195,133,217,18,252,11,2,116,121,1,119,13,98,117,108,108,101,116,101,100,95,108,105,115,116,40,0,195,133,217,18,252,11,6,112,97,114,101,110,116,1,119,6,65,120,74,79,67,97,40,0,195,133,217,18,252,11,8,99,104,105,108,100,114,101,110,1,119,6,89,108,74,119,83,49,40,0,195,133,217,18,252,11,4,100,97,116,97,1,119,32,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,99,104,105,108,100,45,49,34,125,93,125,40,0,195,133,217,18,252,11,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,252,11,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,89,108,74,119,83,49,0,8,0,195,133,217,18,250,11,1,119,6,48,55,67,110,83,97,39,0,147,128,159,145,14,1,6,95,57,76,55,68,108,1,40,0,195,133,217,18,134,12,2,105,100,1,119,6,95,57,76,55,68,108,40,0,195,133,217,18,134,12,2,116,121,1,119,13,98,117,108,108,101,116,101,100,95,108,105,115,116,40,0,195,133,217,18,134,12,6,112,97,114,101,110,116,1,119,6,48,55,67,110,83,97,40,0,195,133,217,18,134,12,8,99,104,105,108,100,114,101,110,1,119,6,68,90,49,112,114,48,40,0,195,133,217,18,134,12,4,100,97,116,97,1,119,34,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,99,104,105,108,100,45,49,45,49,34,125,93,125,40,0,195,133,217,18,134,12,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,134,12,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,68,90,49,112,114,48,0,8,0,195,133,217,18,132,12,1,119,6,95,57,76,55,68,108,39,0,147,128,159,145,14,1,6,118,109,65,70,53,76,1,40,0,195,133,217,18,144,12,2,105,100,1,119,6,118,109,65,70,53,76,40,0,195,133,217,18,144,12,2,116,121,1,119,13,98,117,108,108,101,116,101,100,95,108,105,115,116,40,0,195,133,217,18,144,12,6,112,97,114,101,110,116,1,119,6,48,55,67,110,83,97,40,0,195,133,217,18,144,12,8,99,104,105,108,100,114,101,110,1,119,6,118,87,53,73,57,111,40,0,195,133,217,18,144,12,4,100,97,116,97,1,119,34,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,99,104,105,108,100,45,49,45,50,34,125,93,125,40,0,195,133,217,18,144,12,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,144,12,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,118,87,53,73,57,111,0,136,195,133,217,18,143,12,1,119,6,118,109,65,70,53,76,39,0,147,128,159,145,14,1,6,121,55,78,87,55,99,1,40,0,195,133,217,18,154,12,2,105,100,1,119,6,121,55,78,87,55,99,40,0,195,133,217,18,154,12,2,116,121,1,119,13,98,117,108,108,101,116,101,100,95,108,105,115,116,40,0,195,133,217,18,154,12,6,112,97,114,101,110,116,1,119,6,65,120,74,79,67,97,40,0,195,133,217,18,154,12,8,99,104,105,108,100,114,101,110,1,119,6,79,51,103,80,85,76,40,0,195,133,217,18,154,12,4,100,97,116,97,1,119,32,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,99,104,105,108,100,45,50,34,125,93,125,40,0,195,133,217,18,154,12,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,154,12,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,79,51,103,80,85,76,0,136,195,133,217,18,133,12,1,119,6,121,55,78,87,55,99,39,0,147,128,159,145,14,1,6,83,102,48,120,100,54,1,40,0,195,133,217,18,164,12,2,105,100,1,119,6,83,102,48,120,100,54,40,0,195,133,217,18,164,12,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,164,12,6,112,97,114,101,110,116,1,119,6,65,120,74,79,67,97,40,0,195,133,217,18,164,12,8,99,104,105,108,100,114,101,110,1,119,6,78,76,97,67,89,115,33,0,195,133,217,18,164,12,4,100,97,116,97,1,40,0,195,133,217,18,164,12,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,164,12,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,78,76,97,67,89,115,0,136,195,133,217,18,163,12,1,119,6,83,102,48,120,100,54,39,0,147,128,159,145,14,1,6,54,104,67,112,68,80,1,40,0,195,133,217,18,174,12,2,105,100,1,119,6,54,104,67,112,68,80,40,0,195,133,217,18,174,12,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,195,133,217,18,174,12,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,174,12,8,99,104,105,108,100,114,101,110,1,119,6,57,77,71,74,107,73,40,0,195,133,217,18,174,12,4,100,97,116,97,1,119,53,123,34,108,101,118,101,108,34,58,50,44,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,72,97,118,101,32,97,32,113,117,101,115,116,105,111,110,226,157,147,34,125,93,125,40,0,195,133,217,18,174,12,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,174,12,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,57,77,71,74,107,73,0,200,195,133,217,18,251,11,195,133,217,18,99,1,119,6,54,104,67,112,68,80,39,0,147,128,159,145,14,1,6,90,68,65,45,49,122,1,40,0,195,133,217,18,184,12,2,105,100,1,119,6,90,68,65,45,49,122,40,0,195,133,217,18,184,12,2,116,121,1,119,5,113,117,111,116,101,40,0,195,133,217,18,184,12,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,184,12,8,99,104,105,108,100,114,101,110,1,119,6,122,50,122,106,95,53,40,0,195,133,217,18,184,12,4,100,97,116,97,1,119,129,1,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,67,108,105,99,107,32,34,125,44,123,34,97,116,116,114,105,98,117,116,101,115,34,58,123,34,99,111,100,101,34,58,116,114,117,101,125,44,34,105,110,115,101,114,116,34,58,34,63,34,125,44,123,34,105,110,115,101,114,116,34,58,34,32,97,116,32,116,104,101,32,98,111,116,116,111,109,32,114,105,103,104,116,32,102,111,114,32,104,101,108,112,32,97,110,100,32,115,117,112,112,111,114,116,46,34,125,93,125,40,0,195,133,217,18,184,12,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,184,12,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,122,50,122,106,95,53,0,200,195,133,217,18,183,12,195,133,217,18,99,1,119,6,90,68,65,45,49,122,39,0,147,128,159,145,14,4,6,110,66,48,103,72,72,2,4,0,195,133,217,18,194,12,15,229,129,165,229,186,183,233,130,163,232,190,185,85,73,105,168,195,133,217,18,88,1,119,56,123,34,99,104,101,99,107,101,100,34,58,102,97,108,115,101,44,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,229,129,165,229,186,183,233,130,163,232,190,185,85,73,105,34,125,93,125,39,0,147,128,159,145,14,4,6,112,119,95,118,45,79,2,33,0,147,128,159,145,14,1,6,102,77,98,109,66,116,1,0,7,33,0,147,128,159,145,14,3,6,122,104,84,113,87,83,1,129,195,133,217,18,99,1,39,0,147,128,159,145,14,1,6,69,120,118,84,102,57,1,40,0,195,133,217,18,214,12,2,105,100,1,119,6,69,120,118,84,102,57,40,0,195,133,217,18,214,12,2,116,121,1,119,5,105,109,97,103,101,40,0,195,133,217,18,214,12,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,214,12,8,99,104,105,108,100,114,101,110,1,119,6,57,87,71,65,49,95,33,0,195,133,217,18,214,12,4,100,97,116,97,1,40,0,195,133,217,18,214,12,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,214,12,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,57,87,71,65,49,95,0,200,195,133,217,18,99,195,133,217,18,213,12,1,119,6,69,120,118,84,102,57,39,0,147,128,159,145,14,4,6,119,48,80,67,108,103,2,39,0,147,128,159,145,14,1,6,102,100,85,89,106,108,1,40,0,195,133,217,18,225,12,2,105,100,1,119,6,102,100,85,89,106,108,40,0,195,133,217,18,225,12,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,225,12,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,225,12,8,99,104,105,108,100,114,101,110,1,119,6,68,107,98,56,50,87,40,0,195,133,217,18,225,12,4,100,97,116,97,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,40,0,195,133,217,18,225,12,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,225,12,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,68,107,98,56,50,87,0,136,195,133,217,18,213,12,1,119,6,102,100,85,89,106,108,168,195,133,217,18,219,12,1,119,212,1,123,34,117,114,108,34,58,34,104,116,116,112,115,58,47,47,105,109,97,103,101,115,46,117,110,115,112,108,97,115,104,46,99,111,109,47,112,104,111,116,111,45,49,55,49,51,48,57,56,48,57,56,56,51,51,45,102,52,53,100,49,56,48,99,97,57,97,50,63,99,114,111,112,61,101,110,116,114,111,112,121,38,99,115,61,116,105,110,121,115,114,103,98,38,102,105,116,61,109,97,120,38,102,109,61,106,112,103,38,105,120,105,100,61,77,51,119,49,77,84,69,49,77,122,100,56,77,72,119,120,102,72,74,104,98,109,82,118,98,88,120,56,102,72,120,56,102,72,120,56,102,68,69,51,77,84,77,49,78,68,73,51,78,84,74,56,38,105,120,108,105,98,61,114,98,45,52,46,48,46,51,38,113,61,56,48,38,119,61,49,48,56,48,34,44,34,97,108,105,103,110,34,58,34,99,101,110,116,101,114,34,125,39,0,147,128,159,145,14,4,6,70,80,88,99,109,117,2,39,0,147,128,159,145,14,1,6,109,99,66,107,98,115,1,40,0,195,133,217,18,237,12,2,105,100,1,119,6,109,99,66,107,98,115,40,0,195,133,217,18,237,12,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,195,133,217,18,237,12,6,112,97,114,101,110,116,1,119,10,85,86,79,107,81,88,110,117,86,114,40,0,195,133,217,18,237,12,8,99,104,105,108,100,114,101,110,1,119,6,113,49,74,97,114,53,40,0,195,133,217,18,237,12,4,100,97,116,97,1,119,12,123,34,100,101,108,116,97,34,58,91,93,125,40,0,195,133,217,18,237,12,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,195,133,217,18,237,12,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,147,128,159,145,14,3,6,113,49,74,97,114,53,0,136,195,133,217,18,234,12,1,119,6,109,99,66,107,98,115,39,0,147,128,159,145,14,4,6,66,87,74,50,81,114,2,4,0,195,133,217,18,247,12,2,49,50,168,195,133,217,18,169,12,1,119,27,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,49,50,34,125,93,125,39,0,147,128,159,145,14,4,6,116,83,114,83,65,45,2,33,0,147,128,159,145,14,1,6,118,71,79,68,118,57,1,0,7,33,0,147,128,159,145,14,3,6,66,122,109,101,85,67,1,129,195,133,217,18,173,12,1,39,0,147,128,159,145,14,4,6,56,82,84,70,84,106,2,33,0,147,128,159,145,14,1,6,100,48,101,105,48,99,1,0,7,33,0,147,128,159,145,14,3,6,55,103,88,67,83,76,1,193,195,133,217,18,251,11,195,133,217,18,183,12,1,39,0,147,128,159,145,14,4,6,86,49,69,99,77,120,2,33,0,147,128,159,145,14,1,6,57,82,76,118,75,83,1,0,7,33,0,147,128,159,145,14,3,6,100,84,102,75,114,54,1,129,195,133,217,18,133,13,1,39,0,147,128,159,145,14,4,6,95,90,85,102,102,106,2,33,0,147,128,159,145,14,1,6,78,102,79,67,79,76,1,0,7,33,0,147,128,159,145,14,3,6,66,52,111,107,80,118,1,193,195,133,217,18,144,13,195,133,217,18,183,12,1,5,200,244,136,224,7,1,0,3,178,246,186,209,6,1,0,72,147,128,159,145,14,1,11,3,195,133,217,18,17,11,1,21,10,37,1,44,1,51,1,60,1,62,10,79,1,88,1,90,10,169,12,1,204,12,10,219,12,1,252,12,10,135,13,10,146,13,10,157,13,10,156,139,194,87,1,0,3],"version":0},"code":0,"message":"Operation completed successfully."} \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/support/commands.ts b/frontend/appflowy_web_app/cypress/support/commands.ts new file mode 100644 index 0000000000000..1b5199b01a104 --- /dev/null +++ b/frontend/appflowy_web_app/cypress/support/commands.ts @@ -0,0 +1,33 @@ +/// +// *********************************************** +// This example commands.ts shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +// + +Cypress.Commands.add('mockAPI', () => { + // Mock the API +}); + +export {}; diff --git a/frontend/appflowy_web_app/cypress/support/component-index.html b/frontend/appflowy_web_app/cypress/support/component-index.html new file mode 100644 index 0000000000000..1633d91f21071 --- /dev/null +++ b/frontend/appflowy_web_app/cypress/support/component-index.html @@ -0,0 +1,15 @@ + + + + + + + Components App + + + + + +
+ + \ No newline at end of file diff --git a/frontend/appflowy_web_app/cypress/support/component.ts b/frontend/appflowy_web_app/cypress/support/component.ts new file mode 100644 index 0000000000000..84df06273fe3f --- /dev/null +++ b/frontend/appflowy_web_app/cypress/support/component.ts @@ -0,0 +1,188 @@ +// *********************************************************** +// This example support/component.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** +import { addMatchImageSnapshotCommand } from 'cypress-image-snapshot/command'; +import 'cypress-real-events'; + +// Import commands.js using ES2015 syntax: +import '@cypress/code-coverage/support'; +import './commands'; +import './document'; + +// Alternatively you can use CommonJS syntax: +// require('./commands') + +import { mount } from 'cypress/react18'; + +// Augment the Cypress namespace to include type definitions for +// your custom command. +// Alternatively, can be defined in cypress/support/component.d.ts +// with a at the top of your spec. +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + mount: typeof mount; + mockAPI: () => void; + mockDatabase: () => void; + mockCurrentWorkspace: () => void; + mockGetWorkspaceDatabases: () => void; + mockDocument: (id: string) => void; + clickOutside: () => void; + getTestingSelector: (testId: string) => Chainable>; + selectText: (text: string) => void; + + selectMultipleText: (texts: string[]) => void; + } + } +} + +Cypress.Commands.add('mount', mount); + +Cypress.Commands.add('getTestingSelector', (testId: string) => { + return cy.get(`[data-testid="${testId}"]`); +}); + +Cypress.Commands.add('clickOutside', () => { + cy.document().then((doc) => { + // [0, 0] is the top left corner of the window + const x = 0; + const y = 0; + + const evt = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + clientX: x, + clientY: y, + }); + + // Dispatch the event + doc.elementFromPoint(x, y)?.dispatchEvent(evt); + }); +}); + +function mergeRanges (ranges: Range[]): Range | null { + if (ranges.length === 0) return null; + + const mergedRange = ranges[0].cloneRange(); + + for (let i = 1; i < ranges.length; i++) { + if (ranges[i].compareBoundaryPoints(Range.START_TO_START, mergedRange) < 0) { + mergedRange.setStart(ranges[i].startContainer, ranges[i].startOffset); + } + + if (ranges[i].compareBoundaryPoints(Range.END_TO_END, mergedRange) > 0) { + mergedRange.setEnd(ranges[i].endContainer, ranges[i].endOffset); + } + } + + return mergedRange; +} + +Cypress.Commands.add('selectMultipleText', (texts: string[]) => { + const ranges: Range[] = []; + + cy.window().then((win) => { + const promises = texts.map((text) => { + return new Cypress.Promise((resolve) => { + cy.contains(text).then(($el) => { + if (!$el) { + throw new Error(`The text "${text}" was not found in the document`); + } + + const el = $el[0] as HTMLElement; + const document = el.ownerDocument; + const range = document.createRange(); + + const fullText = el.textContent || ''; + const startIndex = fullText.indexOf(text); + const endIndex = startIndex + text.length; + + if (startIndex !== -1 && endIndex !== -1) { + range.setStart(el.firstChild as Node, startIndex); + range.setEnd(el.firstChild as Node, endIndex); + ranges.push(range); + } else { + throw new Error(`The text "${text}" was not found in the element`); + } + + resolve(); + }); + }); + }); + + void Cypress.Promise.all(promises).then(() => { + const selection = win.getSelection(); + + if (selection) { + const mergedRange = mergeRanges(ranges); + + selection.removeAllRanges(); + if (mergedRange) { + selection.addRange(mergedRange); + + } + } + + cy.document().trigger('mouseup'); + cy.document().trigger('selectionchange'); + }); + }); +}); +Cypress.Commands.add('selectText', (text: string) => { + cy.contains(text).then(($el) => { + if (!$el) { + throw new Error(`The text "${text}" was not found in the document`); + } + + const el = $el[0] as HTMLElement; + const document = el.ownerDocument; + + const range = document.createRange(); + + range.selectNodeContents(el); + + const fullText = el.textContent || ''; + const startIndex = fullText.indexOf(text); + const endIndex = startIndex + text.length; + + if (startIndex !== -1 && endIndex !== -1) { + range.setStart(el.firstChild as HTMLElement, startIndex); + range.setEnd(el.firstChild as HTMLElement, endIndex); + + const selection = document.getSelection() as Selection; + + selection.removeAllRanges(); + selection.addRange(range); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + $el.trigger('mouseup'); + cy.document().trigger('selectionchange'); + } else { + throw new Error(`The text "${text}" was not found in the element`); + } + }); +}); + +// Example use: +// cy.mount() + +addMatchImageSnapshotCommand({ + failureThreshold: 0.03, // 允许 3% 的像素差异 + failureThresholdType: 'percent', + customDiffConfig: { threshold: 0.1 }, + capture: 'viewport', +}); diff --git a/frontend/appflowy_web_app/cypress/support/document.ts b/frontend/appflowy_web_app/cypress/support/document.ts new file mode 100644 index 0000000000000..6edbf118728fd --- /dev/null +++ b/frontend/appflowy_web_app/cypress/support/document.ts @@ -0,0 +1,147 @@ +import { BlockId, BlockType, YBlocks, YChildrenMap, YjsEditorKey, YTextMap } from '@/application/types'; +import { nanoid } from 'nanoid'; +import { Op } from 'quill-delta'; +import * as Y from 'yjs'; + +export interface FromBlockJSON { + type: string; + children: FromBlockJSON[]; + data: Record; + text: Op[]; +} + +export class DocumentTest { + public doc: Y.Doc; + + private blocks: YBlocks; + + private childrenMap: YChildrenMap; + + private textMap: YTextMap; + + private pageId: string; + + constructor () { + const doc = new Y.Doc(); + + this.doc = doc; + const collab = doc.getMap(YjsEditorKey.data_section); + const document = new Y.Map(); + const blocks = new Y.Map() as YBlocks; + const pageId = nanoid(8); + const meta = new Y.Map(); + const childrenMap = new Y.Map() as YChildrenMap; + const textMap = new Y.Map() as YTextMap; + + const block = new Y.Map(); + + block.set(YjsEditorKey.block_id, pageId); + block.set(YjsEditorKey.block_type, BlockType.Page); + block.set(YjsEditorKey.block_children, pageId); + block.set(YjsEditorKey.block_external_id, pageId); + block.set(YjsEditorKey.block_external_type, YjsEditorKey.text); + block.set(YjsEditorKey.block_data, ''); + blocks.set(pageId, block); + + document.set(YjsEditorKey.page_id, pageId); + document.set(YjsEditorKey.blocks, blocks); + document.set(YjsEditorKey.meta, meta); + meta.set(YjsEditorKey.children_map, childrenMap); + meta.set(YjsEditorKey.text_map, textMap); + collab.set(YjsEditorKey.document, document); + + this.blocks = blocks; + this.childrenMap = childrenMap; + this.textMap = textMap; + this.pageId = pageId; + } + + insertParagraph (text: string) { + const blockId = nanoid(8); + const block = new Y.Map(); + + block.set(YjsEditorKey.block_id, blockId); + block.set(YjsEditorKey.block_type, BlockType.Paragraph); + block.set(YjsEditorKey.block_children, blockId); + block.set(YjsEditorKey.block_external_id, blockId); + block.set(YjsEditorKey.block_external_type, YjsEditorKey.text); + block.set(YjsEditorKey.block_parent, this.pageId); + block.set(YjsEditorKey.block_data, ''); + this.blocks.set(blockId, block); + const pageChildren = this.childrenMap.get(this.pageId) ?? new Y.Array(); + + pageChildren.push([blockId]); + this.childrenMap.set(this.pageId, pageChildren); + + const blockText = new Y.Text(); + + blockText.insert(0, text); + this.textMap.set(blockId, blockText); + + return blockText; + } + + fromJSON (json: FromBlockJSON[]) { + + this.fromJSONChildren(json, this.pageId); + + return this.doc; + } + + private fromJSONChildren (children: FromBlockJSON[], parentId: BlockId) { + const parentChildren = this.childrenMap.get(parentId) ?? new Y.Array(); + + for (const child of children) { + const blockId = nanoid(8); + const block = new Y.Map(); + + block.set(YjsEditorKey.block_id, blockId); + block.set(YjsEditorKey.block_type, child.type); + block.set(YjsEditorKey.block_children, blockId); + block.set(YjsEditorKey.block_external_id, blockId); + block.set(YjsEditorKey.block_external_type, YjsEditorKey.text); + block.set(YjsEditorKey.block_parent, parentId); + block.set(YjsEditorKey.block_data, JSON.stringify(child.data)); + this.blocks.set(blockId, block); + + parentChildren.push([blockId]); + if (!this.childrenMap.has(parentId)) { + this.childrenMap.set(parentId, parentChildren); + } + + const blockText = new Y.Text(); + + blockText.applyDelta(child.text); + + this.textMap.set(blockId, blockText); + + const blockChildren = new Y.Array(); + + this.childrenMap.set(blockId, blockChildren); + + this.fromJSONChildren(child.children, blockId); + } + } + + toJSON () { + return this.toJSONChildren(this.pageId); + } + + private toJSONChildren (parentId: BlockId): FromBlockJSON[] { + const parentChildren = this.childrenMap.get(parentId) ?? []; + const children = []; + + for (const childId of parentChildren) { + const child = this.blocks.get(childId); + + children.push({ + type: child.get(YjsEditorKey.block_type), + data: JSON.parse(child.get(YjsEditorKey.block_data)), + text: this.textMap.get(childId).toDelta(), + children: this.toJSONChildren(childId), + }); + } + + return children; + } +} diff --git a/frontend/appflowy_web_app/deploy/Dockerfile b/frontend/appflowy_web_app/deploy/Dockerfile new file mode 100644 index 0000000000000..85f9d33b2873e --- /dev/null +++ b/frontend/appflowy_web_app/deploy/Dockerfile @@ -0,0 +1,32 @@ +FROM oven/bun:latest + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y nginx supervisor + +RUN bun install cheerio pino pino-pretty + +COPY . . + +COPY supervisord.conf /app/supervisord.conf + +RUN addgroup --system nginx && \ + adduser --system --no-create-home --disabled-login --ingroup nginx nginx + +RUN apt-get clean && rm -rf /var/lib/apt/lists/* + + +COPY dist /usr/share/nginx/html/ + +COPY nginx.conf /etc/nginx/nginx.conf + +COPY start.sh /app/start.sh + +RUN chmod +x /app/start.sh +RUN chmod +x /app/supervisord.conf + + +EXPOSE 80 + +CMD ["supervisord", "-c", "/app/supervisord.conf"] \ No newline at end of file diff --git a/frontend/appflowy_web_app/deploy/deploy.sh b/frontend/appflowy_web_app/deploy/deploy.sh new file mode 100644 index 0000000000000..772862c2467b6 --- /dev/null +++ b/frontend/appflowy_web_app/deploy/deploy.sh @@ -0,0 +1,28 @@ +if [ -z "$1" ]; then + echo "No port number provided" + exit 1 +fi + +PORT=$1 + +echo "Starting deployment on port $PORT" + +rm -rf deploy + +tar -xzf build-output.tar.gz + +rm -rf build-output.tar.gz + +mv dist deploy/dist + +mv .env deploy/.env + +cd deploy + +docker system prune -f + +docker build -t appflowy-web-app-"$PORT" . + +docker rm -f appflowy-web-app-"$PORT" || true + +docker run -d --env-file .env -p "$PORT":80 --restart always --name appflowy-web-app-"$PORT" appflowy-web-app-"$PORT" \ No newline at end of file diff --git a/frontend/appflowy_web_app/deploy/nginx.conf b/frontend/appflowy_web_app/deploy/nginx.conf new file mode 100644 index 0000000000000..11a8744ad9336 --- /dev/null +++ b/frontend/appflowy_web_app/deploy/nginx.conf @@ -0,0 +1,101 @@ +# nginx.conf +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + gzip on; + + gzip_static on; + + gzip_http_version 1.0; + + gzip_comp_level 5; + + gzip_vary on; + + gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/javascript application/wasm; + + # Existing server block for HTTP + server { + listen 80; + server_name localhost; + + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + location /static/ { + root /usr/share/nginx/html; + expires 30d; + access_log off; + + } + + location /appflowy.svg { + root /usr/share/nginx/html; + expires 30d; + access_log off; + } + + location /appflowy.ico { + root /usr/share/nginx/html; + expires 30d; + access_log off; + } + + location /og-image.png { + root /usr/share/nginx/html; + expires 30d; + access_log off; + } + + location /covers/ { + root /usr/share/nginx/html; + expires 30d; + access_log off; + } + + location /af_icons/ { + root /usr/share/nginx/html; + expires 30d; + access_log off; + } + + error_page 404 /404.html; + location = /404.html { + root /usr/share/nginx/html; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + + } +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/deploy/server.ts b/frontend/appflowy_web_app/deploy/server.ts new file mode 100644 index 0000000000000..db79e5c1fb7c4 --- /dev/null +++ b/frontend/appflowy_web_app/deploy/server.ts @@ -0,0 +1,255 @@ +import path from 'path'; +import * as fs from 'fs'; +import pino from 'pino'; +import { type CheerioAPI, load } from 'cheerio'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +import { fetch } from 'bun'; + +const distDir = path.join(__dirname, 'dist'); +const indexPath = path.join(distDir, 'index.html'); +const baseURL = process.env.AF_BASE_URL as string; +const defaultSite = 'https://appflowy.io'; + +const setOrUpdateMetaTag = ($: CheerioAPI, selector: string, attribute: string, content: string) => { + if ($(selector).length === 0) { + $('head').append(``); + } else { + $(selector).attr('content', content); + } +}; + +const logger = pino({ + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + destination: `${__dirname}/pino-logger.log`, + }, + }, + level: 'info', +}); + +const logRequestTimer = (req: Request) => { + const start = Date.now(); + const pathname = new URL(req.url).pathname; + + logger.info(`Incoming request: ${pathname}`); + return () => { + const duration = Date.now() - start; + + logger.info(`Request for ${pathname} took ${duration}ms`); + }; +}; + +const fetchMetaData = async (namespace: string, publishName?: string) => { + let url = `${baseURL}/api/workspace/published/${namespace}`; + + if (publishName) { + url += `/${publishName}`; + } + + logger.info(`Fetching meta data from ${url}`); + try { + const response = await fetch(url, { + verbose: true, + }); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + return response.json(); + } catch (error) { + logger.error(`Error fetching meta data ${error}`); + return null; + } +}; + +const createServer = async (req: Request) => { + const timer = logRequestTimer(req); + const reqUrl = new URL(req.url); + const hostname = req.headers.get('host'); + + logger.info(`Request URL: ${hostname}${reqUrl.pathname}`); + + if (reqUrl.pathname === '/') { + timer(); + return new Response(null, { + status: 302, + headers: { + Location: '/app', + }, + }); + } + + if (['/after-payment', '/login', '/as-template', '/app', '/accept-invitation', '/import'].some(item => reqUrl.pathname.startsWith(item))) { + timer(); + const htmlData = fs.readFileSync(indexPath, 'utf8'); + const $ = load(htmlData); + + let title, description; + + if (reqUrl.pathname === '/after-payment') { + title = 'Payment Success | AppFlowy'; + description = 'Payment success on AppFlowy'; + } + + if (reqUrl.pathname === '/login') { + title = 'Login | AppFlowy'; + description = 'Login to AppFlowy'; + } + + if (title) $('title').text(title); + if (description) setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description); + + return new Response($.html(), { + headers: { 'Content-Type': 'text/html' }, + }); + } + + const [namespace, publishName] = reqUrl.pathname.slice(1).split('/'); + + logger.info(`Namespace: ${namespace}, Publish Name: ${publishName}`); + + if (req.method === 'GET') { + if (namespace === '') { + timer(); + return new Response(null, { + status: 302, + headers: { + Location: defaultSite, + }, + }); + } + + let metaData; + + try { + const data = await fetchMetaData(namespace, publishName); + + if (publishName) { + metaData = data; + } else { + + const publishInfo = data?.data?.info; + + if (publishInfo) { + const newURL = `/${encodeURIComponent(publishInfo.namespace)}/${encodeURIComponent(publishInfo.publish_name)}`; + + logger.info(`Redirecting to default page in: ${JSON.stringify(publishInfo)}`); + timer(); + return new Response(null, { + status: 302, + headers: { + Location: newURL, + }, + }); + } + } + } catch (error) { + logger.error(`Error fetching meta data: ${error}`); + } + + const htmlData = fs.readFileSync(indexPath, 'utf8'); + const $ = load(htmlData); + + const description = 'Write, share, and publish docs quickly on AppFlowy.\nGet started for free.'; + let title = 'AppFlowy'; + const url = `https://${hostname}${reqUrl.pathname}`; + let image = '/og-image.png'; + let favicon = '/appflowy.ico'; + + try { + if (metaData && metaData.view) { + const view = metaData.view; + const emoji = view.icon?.ty === 0 && view.icon?.value; + const titleList = []; + + if (emoji) { + const emojiCode = emoji.codePointAt(0).toString(16); // Convert emoji to hex code + const baseUrl = 'https://raw.githubusercontent.com/googlefonts/noto-emoji/main/svg/emoji_u'; + + favicon = `${baseUrl}${emojiCode}.svg`; + } + + if (view.name) { + titleList.push(view.name); + titleList.push('|'); + } + + titleList.push('AppFlowy'); + title = titleList.join(' '); + + try { + const cover = view.extra ? JSON.parse(view.extra)?.cover : null; + + if (cover) { + if (['unsplash', 'custom'].includes(cover.type)) { + image = cover.value; + } else if (cover.type === 'built_in') { + image = `/covers/m_cover_image_${cover.value}.png`; + } + } + } catch (_) { + // Do nothing + } + } + } catch (error) { + logger.error(`Error injecting meta data: ${error}`); + } + + $('title').text(title); + $('link[rel="icon"]').attr('href', favicon); + $('link[rel="canonical"]').attr('href', url); + setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description); + setOrUpdateMetaTag($, 'meta[property="og:title"]', 'property', title); + setOrUpdateMetaTag($, 'meta[property="og:description"]', 'property', description); + setOrUpdateMetaTag($, 'meta[property="og:image"]', 'property', image); + setOrUpdateMetaTag($, 'meta[property="og:url"]', 'property', url); + setOrUpdateMetaTag($, 'meta[property="og:site_name"]', 'property', 'AppFlowy'); + setOrUpdateMetaTag($, 'meta[property="og:type"]', 'property', 'website'); + setOrUpdateMetaTag($, 'meta[name="twitter:card"]', 'name', 'summary_large_image'); + setOrUpdateMetaTag($, 'meta[name="twitter:title"]', 'name', title); + setOrUpdateMetaTag($, 'meta[name="twitter:description"]', 'name', description); + setOrUpdateMetaTag($, 'meta[name="twitter:image"]', 'name', image); + setOrUpdateMetaTag($, 'meta[name="twitter:site"]', 'name', '@appflowy'); + + timer(); + return new Response($.html(), { + headers: { 'Content-Type': 'text/html' }, + }); + } else { + timer(); + logger.error({ message: 'Method not allowed', method: req.method }); + return new Response('Method not allowed', { status: 405 }); + } +}; + +declare const Bun: { + serve: (options: { port: number; fetch: typeof createServer; error: (err: Error) => Response }) => void; +}; + +const start = () => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + Bun.serve({ + port: 3000, + fetch: createServer, + error: (err) => { + logger.error(`Internal Server Error: ${err}`); + return new Response('Internal Server Error', { status: 500 }); + }, + }); + logger.info('Server is running on port 3000'); + logger.info(`Base URL: ${baseURL}`); + } catch (err) { + logger.error(err); + process.exit(1); + } +}; + +start(); + +export {}; diff --git a/frontend/appflowy_web_app/deploy/start.sh b/frontend/appflowy_web_app/deploy/start.sh new file mode 100644 index 0000000000000..eba0f53018c7b --- /dev/null +++ b/frontend/appflowy_web_app/deploy/start.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + + + +# Start the nginx server +service nginx start + +# Start the frontend server +bun run server.ts + +tail -f /dev/null + diff --git a/frontend/appflowy_web_app/deploy/supervisord.conf b/frontend/appflowy_web_app/deploy/supervisord.conf new file mode 100644 index 0000000000000..1484fd39e5760 --- /dev/null +++ b/frontend/appflowy_web_app/deploy/supervisord.conf @@ -0,0 +1,9 @@ +[supervisord] +nodaemon=true + +[program:bun] +command=sh /app/start.sh +autostart=true +autorestart=true +stderr_logfile=/var/log/bun.err.log +stdout_logfile=/var/log/bun.out.log diff --git a/frontend/appflowy_web_app/index.html b/frontend/appflowy_web_app/index.html new file mode 100644 index 0000000000000..cbb2c55e60bfd --- /dev/null +++ b/frontend/appflowy_web_app/index.html @@ -0,0 +1,115 @@ + + + + + + + + AppFlowy + + + + + + + + + + + + + + <%- cdnLinks %> + + + +
+ + + + + + + diff --git a/frontend/appflowy_web_app/jest.config.cjs b/frontend/appflowy_web_app/jest.config.cjs new file mode 100644 index 0000000000000..211176e5ee87b --- /dev/null +++ b/frontend/appflowy_web_app/jest.config.cjs @@ -0,0 +1,42 @@ +const { compilerOptions } = require('./tsconfig.json'); +const { pathsToModuleNameMapper } = require('ts-jest'); +const esModules = ['lodash-es', 'nanoid'].join('|'); + +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + roots: [''], + modulePaths: [compilerOptions.baseUrl], + moduleNameMapper: { + ...pathsToModuleNameMapper(compilerOptions.paths), + '^lodash-es(/(.*)|$)': 'lodash$1', + '^nanoid(/(.*)|$)': 'nanoid$1', + '^dayjs$': '/node_modules/dayjs/dayjs.min.js', + }, + 'transform': { + '^.+\\.(j|t)sx?$': 'ts-jest', + '(.*)/node_modules/nanoid/.+\\.(j|t)sx?$': 'ts-jest', + }, + 'transformIgnorePatterns': [`/node_modules/(?!${esModules})`], + testMatch: ['**/*.test.ts', '**/*.test.tsx'], + coverageDirectory: '/coverage/jest', + collectCoverage: true, + coverageProvider: 'v8', + coveragePathIgnorePatterns: [ + '/cypress/', + '/coverage/', + '/node_modules/', + '/__tests__/', + '/__mocks__/', + '/__fixtures__/', + '/__helpers__/', + '/__utils__/', + '/__constants__/', + '/__types__/', + '/__mocks__/', + '/__stubs__/', + '/__fixtures__/', + '/application/folder-yjs/', + ], +}; \ No newline at end of file diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json new file mode 100644 index 0000000000000..e2a7ad574a8b3 --- /dev/null +++ b/frontend/appflowy_web_app/package.json @@ -0,0 +1,192 @@ +{ + "name": "appflowy_web_app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "pnpm run sync:i18n && vite", + "dev:tauri": "pnpm run sync:i18n && vite", + "build": "pnpm run sync:i18n && vite build", + "build:tauri": "vite build", + "lint:tauri": "pnpm run sync:i18n && tsc --noEmit && eslint --ext .js,.ts,.tsx . --ignore-path .eslintignore", + "lint": "pnpm run sync:i18n && tsc --noEmit --project tsconfig.web.json && eslint --ext .js,.ts,.tsx . --ignore-path .eslintignore.web", + "start": "vite preview --port 3000", + "tauri:dev": "tauri dev", + "css:variables": "node scripts/generateTailwindColors.cjs", + "sync:i18n": "node scripts/i18n.cjs", + "link:client-api": "rm -rf node_modules/.vite && node scripts/create-symlink.cjs", + "analyze": "cross-env ANALYZE_MODE=true vite build", + "cypress:open": "cypress open", + "test": "pnpm run test:unit && pnpm run test:components", + "test:components": "cypress run --component --browser chrome --headless", + "test:unit": "jest --coverage", + "test:cy": "cypress run", + "coverage": "pnpm run test:unit && pnpm run test:components" + }, + "dependencies": { + "@atlaskit/primitives": "^5.5.3", + "@emoji-mart/data": "^1.1.2", + "@emoji-mart/react": "^1.1.1", + "@emotion/react": "^11.10.6", + "@emotion/styled": "^11.10.6", + "@jest/globals": "^29.7.0", + "@mui/icons-material": "^5.11.11", + "@mui/material": "6.0.0-alpha.2", + "@mui/x-date-pickers-pro": "^6.18.2", + "@reduxjs/toolkit": "2.0.0", + "@slate-yjs/core": "^1.0.2", + "@tauri-apps/api": "^1.5.3", + "@types/react-swipeable-views": "^0.13.4", + "async-retry": "^1.3.3", + "axios": "^1.6.8", + "colorthief": "^2.4.0", + "dayjs": "^1.11.9", + "decimal.js": "^10.4.3", + "dexie": "^4.0.7", + "dexie-react-hooks": "^1.1.7", + "emoji-mart": "^5.5.2", + "emoji-regex": "^10.2.1", + "escape-string-regexp": "^5.0.0", + "events": "^3.3.0", + "google-protobuf": "^3.15.12", + "hast-util-to-mdast": "^10.1.0", + "highlight.js": "^11.10.0", + "html-parse-stringify": "^3.0.1", + "i18next": "^22.4.10", + "i18next-browser-languagedetector": "^7.0.1", + "i18next-resources-to-backend": "^1.1.4", + "is-hotkey": "^0.2.0", + "jest": "^29.5.0", + "js-base64": "^3.7.5", + "js-md5": "^0.8.3", + "katex": "^0.16.7", + "lightgallery": "^2.7.2", + "lodash-es": "^4.17.21", + "nanoid": "^4.0.0", + "notistack": "^3.0.1", + "numeral": "^2.0.6", + "prismjs": "^1.29.0", + "protoc-gen-ts": "0.8.7", + "quill": "^1.3.7", + "quill-delta": "^5.1.0", + "react": "^18.2.0", + "react-beautiful-dnd": "^13.1.1", + "react-big-calendar": "^1.8.5", + "react-color": "^2.19.3", + "react-custom-scrollbars": "^4.2.1", + "react-custom-scrollbars-2": "^4.5.0", + "react-datepicker": "^4.23.0", + "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.13", + "react-helmet": "^6.1.0", + "react-hook-form": "^7.52.2", + "react-hot-toast": "^2.4.1", + "react-i18next": "^14.1.0", + "react-katex": "^3.0.1", + "react-measure": "^2.5.2", + "react-redux": "^8.0.5", + "react-router-dom": "^6.22.3", + "react-swipeable-views": "^0.14.0", + "react-transition-group": "^4.4.5", + "react-virtualized-auto-sizer": "^1.0.20", + "react-vtree": "^2.0.4", + "react-window": "^1.8.10", + "react-zoom-pan-pinch": "^3.6.1", + "react18-input-otp": "^1.1.2", + "redux": "^4.2.1", + "rehype-parse": "^9.0.1", + "rxjs": "^7.8.0", + "sass": "^1.70.0", + "slate": "^0.101.4", + "slate-history": "^0.100.0", + "slate-react": "^0.101.3", + "smooth-scroll-into-view-if-needed": "^2.0.2", + "ts-results": "^3.3.0", + "unified": "^11.0.5", + "unist": "^0.0.1", + "unsplash-js": "^7.0.19", + "utf8": "^3.0.0", + "validator": "^13.11.0", + "vite-plugin-wasm": "^3.3.0", + "y-indexeddb": "9.0.12", + "yjs": "14.0.0-1" + }, + "devDependencies": { + "@babel/preset-env": "^7.24.7", + "@babel/preset-react": "^7.24.7", + "@babel/preset-typescript": "^7.24.7", + "@cypress/code-coverage": "^3.12.39", + "@istanbuljs/nyc-config-babel": "^3.0.0", + "@istanbuljs/nyc-config-typescript": "^1.0.2", + "@svgr/plugin-svgo": "^8.0.1", + "@tauri-apps/cli": "^1.5.11", + "@testing-library/react": "^16.0.0", + "@types/cypress-image-snapshot": "^3.1.9", + "@types/google-protobuf": "^3.15.12", + "@types/is-hotkey": "^0.1.7", + "@types/jest": "^29.5.3", + "@types/katex": "^0.16.0", + "@types/lodash-es": "^4.17.11", + "@types/node": "^20.11.30", + "@types/numeral": "^2.0.5", + "@types/prismjs": "^1.26.0", + "@types/quill": "^2.0.10", + "@types/react": "^18.2.66", + "@types/react-beautiful-dnd": "^13.1.3", + "@types/react-big-calendar": "^1.8.9", + "@types/react-color": "^3.0.6", + "@types/react-custom-scrollbars": "^4.0.13", + "@types/react-datepicker": "^4.19.3", + "@types/react-dom": "^18.2.22", + "@types/react-helmet": "^6.1.11", + "@types/react-katex": "^3.0.0", + "@types/react-measure": "^2.0.12", + "@types/react-transition-group": "^4.4.6", + "@types/react-window": "^1.8.8", + "@types/utf8": "^3.0.1", + "@types/uuid": "^9.0.1", + "@types/validator": "^13.11.9", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.13", + "axios-mock-adapter": "^2.0.0", + "babel-jest": "^29.6.2", + "chalk": "^4.1.2", + "cheerio": "1.0.0-rc.12", + "cross-env": "^7.0.3", + "cypress": "^13.7.2", + "cypress-image-snapshot": "^4.0.1", + "cypress-real-events": "^1.13.0", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "istanbul-lib-coverage": "^3.2.2", + "jest-environment-jsdom": "^29.6.2", + "jest-node-exports-resolver": "^1.1.6", + "nyc": "^15.1.0", + "pino": "^9.2.0", + "pino-pretty": "^11.2.1", + "postcss": "^8.4.21", + "prettier": "2.8.4", + "prettier-plugin-tailwindcss": "^0.2.2", + "rollup-plugin-visualizer": "^5.12.0", + "style-dictionary": "^3.9.2", + "tailwindcss": "^3.2.7", + "ts-jest": "^29.1.1", + "ts-node-dev": "^2.0.0", + "tsconfig-paths-jest": "^0.0.1", + "typescript": "4.9.5", + "uuid": "^9.0.0", + "vite": "^5.2.0", + "vite-plugin-compression2": "^1.0.0", + "vite-plugin-externals": "^0.6.2", + "vite-plugin-html": "^3.2.2", + "vite-plugin-importer": "^0.2.5", + "vite-plugin-istanbul": "^6.0.2", + "vite-plugin-svgr": "^3.2.0", + "vite-plugin-terminal": "^1.2.0", + "vite-plugin-total-bundle-size": "^1.0.7" + } +} diff --git a/frontend/appflowy_web_app/pnpm-lock.yaml b/frontend/appflowy_web_app/pnpm-lock.yaml new file mode 100644 index 0000000000000..768b5b6d2649f --- /dev/null +++ b/frontend/appflowy_web_app/pnpm-lock.yaml @@ -0,0 +1,12614 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + '@atlaskit/primitives': + specifier: ^5.5.3 + version: 5.7.0(@types/react@18.2.66)(react@18.2.0) + '@emoji-mart/data': + specifier: ^1.1.2 + version: 1.2.1 + '@emoji-mart/react': + specifier: ^1.1.1 + version: 1.1.1(emoji-mart@5.6.0)(react@18.2.0) + '@emotion/react': + specifier: ^11.10.6 + version: 11.11.4(@types/react@18.2.66)(react@18.2.0) + '@emotion/styled': + specifier: ^11.10.6 + version: 11.11.5(@emotion/react@11.11.4)(@types/react@18.2.66)(react@18.2.0) + '@jest/globals': + specifier: ^29.7.0 + version: 29.7.0 + '@mui/icons-material': + specifier: ^5.11.11 + version: 5.15.18(@mui/material@6.0.0-alpha.2)(@types/react@18.2.66)(react@18.2.0) + '@mui/material': + specifier: 6.0.0-alpha.2 + version: 6.0.0-alpha.2(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@mui/x-date-pickers-pro': + specifier: ^6.18.2 + version: 6.20.0(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@mui/material@6.0.0-alpha.2)(@mui/system@5.15.15)(@types/react@18.2.66)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0) + '@reduxjs/toolkit': + specifier: 2.0.0 + version: 2.0.0(react-redux@8.1.3)(react@18.2.0) + '@slate-yjs/core': + specifier: ^1.0.2 + version: 1.0.2(slate@0.101.5)(yjs@14.0.0-1) + '@tauri-apps/api': + specifier: ^1.5.3 + version: 1.5.6 + '@types/react-swipeable-views': + specifier: ^0.13.4 + version: 0.13.5 + async-retry: + specifier: ^1.3.3 + version: 1.3.3 + axios: + specifier: ^1.6.8 + version: 1.7.2 + colorthief: + specifier: ^2.4.0 + version: 2.4.0 + dayjs: + specifier: ^1.11.9 + version: 1.11.9 + decimal.js: + specifier: ^10.4.3 + version: 10.4.3 + dexie: + specifier: ^4.0.7 + version: 4.0.7 + dexie-react-hooks: + specifier: ^1.1.7 + version: 1.1.7(@types/react@18.2.66)(dexie@4.0.7)(react@18.2.0) + emoji-mart: + specifier: ^5.5.2 + version: 5.6.0 + emoji-regex: + specifier: ^10.2.1 + version: 10.3.0 + escape-string-regexp: + specifier: ^5.0.0 + version: 5.0.0 + events: + specifier: ^3.3.0 + version: 3.3.0 + google-protobuf: + specifier: ^3.15.12 + version: 3.21.2 + hast-util-to-mdast: + specifier: ^10.1.0 + version: 10.1.0 + highlight.js: + specifier: ^11.10.0 + version: 11.10.0 + html-parse-stringify: + specifier: ^3.0.1 + version: 3.0.1 + i18next: + specifier: ^22.4.10 + version: 22.5.1 + i18next-browser-languagedetector: + specifier: ^7.0.1 + version: 7.2.1 + i18next-resources-to-backend: + specifier: ^1.1.4 + version: 1.2.1 + is-hotkey: + specifier: ^0.2.0 + version: 0.2.0 + jest: + specifier: ^29.5.0 + version: 29.5.0(@types/node@20.11.30) + js-base64: + specifier: ^3.7.5 + version: 3.7.7 + js-md5: + specifier: ^0.8.3 + version: 0.8.3 + katex: + specifier: ^0.16.7 + version: 0.16.10 + lightgallery: + specifier: ^2.7.2 + version: 2.7.2 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 + nanoid: + specifier: ^4.0.0 + version: 4.0.2 + notistack: + specifier: ^3.0.1 + version: 3.0.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0) + numeral: + specifier: ^2.0.6 + version: 2.0.6 + prismjs: + specifier: ^1.29.0 + version: 1.29.0 + protoc-gen-ts: + specifier: 0.8.7 + version: 0.8.7 + quill: + specifier: ^1.3.7 + version: 1.3.7 + quill-delta: + specifier: ^5.1.0 + version: 5.1.0 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-beautiful-dnd: + specifier: ^13.1.1 + version: 13.1.1(react-dom@18.2.0)(react@18.2.0) + react-big-calendar: + specifier: ^1.8.5 + version: 1.12.2(react-dom@18.2.0)(react@18.2.0) + react-color: + specifier: ^2.19.3 + version: 2.19.3(react@18.2.0) + react-custom-scrollbars: + specifier: ^4.2.1 + version: 4.2.1(react-dom@18.2.0)(react@18.2.0) + react-custom-scrollbars-2: + specifier: ^4.5.0 + version: 4.5.0(react-dom@18.2.0)(react@18.2.0) + react-datepicker: + specifier: ^4.23.0 + version: 4.25.0(react-dom@18.2.0)(react@18.2.0) + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + react-error-boundary: + specifier: ^4.0.13 + version: 4.0.13(react@18.2.0) + react-helmet: + specifier: ^6.1.0 + version: 6.1.0(react@18.2.0) + react-hook-form: + specifier: ^7.52.2 + version: 7.52.2(react@18.2.0) + react-hot-toast: + specifier: ^2.4.1 + version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0) + react-i18next: + specifier: ^14.1.0 + version: 14.1.2(i18next@22.5.1)(react-dom@18.2.0)(react@18.2.0) + react-katex: + specifier: ^3.0.1 + version: 3.0.1(prop-types@15.8.1)(react@18.2.0) + react-measure: + specifier: ^2.5.2 + version: 2.5.2(react-dom@18.2.0)(react@18.2.0) + react-redux: + specifier: ^8.0.5 + version: 8.1.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1) + react-router-dom: + specifier: ^6.22.3 + version: 6.23.1(react-dom@18.2.0)(react@18.2.0) + react-swipeable-views: + specifier: ^0.14.0 + version: 0.14.0(react@18.2.0) + react-transition-group: + specifier: ^4.4.5 + version: 4.4.5(react-dom@18.2.0)(react@18.2.0) + react-virtualized-auto-sizer: + specifier: ^1.0.20 + version: 1.0.24(react-dom@18.2.0)(react@18.2.0) + react-vtree: + specifier: ^2.0.4 + version: 2.0.4(@types/react-window@1.8.8)(react-dom@18.2.0)(react-window@1.8.10)(react@18.2.0) + react-window: + specifier: ^1.8.10 + version: 1.8.10(react-dom@18.2.0)(react@18.2.0) + react-zoom-pan-pinch: + specifier: ^3.6.1 + version: 3.6.1(react-dom@18.2.0)(react@18.2.0) + react18-input-otp: + specifier: ^1.1.2 + version: 1.1.4(react-dom@18.2.0)(react@18.2.0) + redux: + specifier: ^4.2.1 + version: 4.2.1 + rehype-parse: + specifier: ^9.0.1 + version: 9.0.1 + rxjs: + specifier: ^7.8.0 + version: 7.8.0 + sass: + specifier: ^1.70.0 + version: 1.77.2 + slate: + specifier: ^0.101.4 + version: 0.101.5 + slate-history: + specifier: ^0.100.0 + version: 0.100.0(slate@0.101.5) + slate-react: + specifier: ^0.101.3 + version: 0.101.6(react-dom@18.2.0)(react@18.2.0)(slate@0.101.5) + smooth-scroll-into-view-if-needed: + specifier: ^2.0.2 + version: 2.0.2 + ts-results: + specifier: ^3.3.0 + version: 3.3.0 + unified: + specifier: ^11.0.5 + version: 11.0.5 + unist: + specifier: ^0.0.1 + version: 0.0.1 + unsplash-js: + specifier: ^7.0.19 + version: 7.0.19 + utf8: + specifier: ^3.0.0 + version: 3.0.0 + validator: + specifier: ^13.11.0 + version: 13.12.0 + vite-plugin-wasm: + specifier: ^3.3.0 + version: 3.3.0(vite@5.2.0) + y-indexeddb: + specifier: 9.0.12 + version: 9.0.12(yjs@14.0.0-1) + yjs: + specifier: 14.0.0-1 + version: 14.0.0-1 + +devDependencies: + '@babel/preset-env': + specifier: ^7.24.7 + version: 7.24.7(@babel/core@7.24.3) + '@babel/preset-react': + specifier: ^7.24.7 + version: 7.24.7(@babel/core@7.24.3) + '@babel/preset-typescript': + specifier: ^7.24.7 + version: 7.24.7(@babel/core@7.24.3) + '@cypress/code-coverage': + specifier: ^3.12.39 + version: 3.12.39(@babel/core@7.24.3)(@babel/preset-env@7.24.7)(babel-loader@9.1.3)(cypress@13.7.2)(webpack@5.91.0) + '@istanbuljs/nyc-config-babel': + specifier: ^3.0.0 + version: 3.0.0(@babel/register@7.24.6)(babel-plugin-istanbul@6.1.1) + '@istanbuljs/nyc-config-typescript': + specifier: ^1.0.2 + version: 1.0.2(nyc@15.1.0) + '@svgr/plugin-svgo': + specifier: ^8.0.1 + version: 8.0.1(@svgr/core@8.1.0)(typescript@4.9.5) + '@tauri-apps/cli': + specifier: ^1.5.11 + version: 1.5.11 + '@testing-library/react': + specifier: ^16.0.0 + version: 16.0.0(@testing-library/dom@10.1.0)(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@types/cypress-image-snapshot': + specifier: ^3.1.9 + version: 3.1.9 + '@types/google-protobuf': + specifier: ^3.15.12 + version: 3.15.12 + '@types/is-hotkey': + specifier: ^0.1.7 + version: 0.1.7 + '@types/jest': + specifier: ^29.5.3 + version: 29.5.3 + '@types/katex': + specifier: ^0.16.0 + version: 0.16.0 + '@types/lodash-es': + specifier: ^4.17.11 + version: 4.17.11 + '@types/node': + specifier: ^20.11.30 + version: 20.11.30 + '@types/numeral': + specifier: ^2.0.5 + version: 2.0.5 + '@types/prismjs': + specifier: ^1.26.0 + version: 1.26.0 + '@types/quill': + specifier: ^2.0.10 + version: 2.0.10 + '@types/react': + specifier: ^18.2.66 + version: 18.2.66 + '@types/react-beautiful-dnd': + specifier: ^13.1.3 + version: 13.1.3 + '@types/react-big-calendar': + specifier: ^1.8.9 + version: 1.8.9 + '@types/react-color': + specifier: ^3.0.6 + version: 3.0.6 + '@types/react-custom-scrollbars': + specifier: ^4.0.13 + version: 4.0.13 + '@types/react-datepicker': + specifier: ^4.19.3 + version: 4.19.3(react-dom@18.2.0)(react@18.2.0) + '@types/react-dom': + specifier: ^18.2.22 + version: 18.2.22 + '@types/react-helmet': + specifier: ^6.1.11 + version: 6.1.11 + '@types/react-katex': + specifier: ^3.0.0 + version: 3.0.0 + '@types/react-measure': + specifier: ^2.0.12 + version: 2.0.12 + '@types/react-transition-group': + specifier: ^4.4.6 + version: 4.4.6 + '@types/react-window': + specifier: ^1.8.8 + version: 1.8.8 + '@types/utf8': + specifier: ^3.0.1 + version: 3.0.1 + '@types/uuid': + specifier: ^9.0.1 + version: 9.0.1 + '@types/validator': + specifier: ^13.11.9 + version: 13.11.9 + '@typescript-eslint/eslint-plugin': + specifier: ^7.2.0 + version: 7.2.0(@typescript-eslint/parser@7.2.0)(eslint@8.57.0)(typescript@4.9.5) + '@typescript-eslint/parser': + specifier: ^7.2.0 + version: 7.2.0(eslint@8.57.0)(typescript@4.9.5) + '@vitejs/plugin-react': + specifier: ^4.2.1 + version: 4.2.1(vite@5.2.0) + autoprefixer: + specifier: ^10.4.13 + version: 10.4.13(postcss@8.4.21) + axios-mock-adapter: + specifier: ^2.0.0 + version: 2.0.0(axios@1.7.2) + babel-jest: + specifier: ^29.6.2 + version: 29.6.2(@babel/core@7.24.3) + chalk: + specifier: ^4.1.2 + version: 4.1.2 + cheerio: + specifier: 1.0.0-rc.12 + version: 1.0.0-rc.12 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + cypress: + specifier: ^13.7.2 + version: 13.7.2 + cypress-image-snapshot: + specifier: ^4.0.1 + version: 4.0.1(cypress@13.7.2)(jest@29.5.0) + cypress-real-events: + specifier: ^1.13.0 + version: 1.13.0(cypress@13.7.2) + eslint: + specifier: ^8.57.0 + version: 8.57.0 + eslint-plugin-react: + specifier: ^7.32.2 + version: 7.32.2(eslint@8.57.0) + eslint-plugin-react-hooks: + specifier: ^4.6.0 + version: 4.6.0(eslint@8.57.0) + eslint-plugin-react-refresh: + specifier: ^0.4.6 + version: 0.4.6(eslint@8.57.0) + istanbul-lib-coverage: + specifier: ^3.2.2 + version: 3.2.2 + jest-environment-jsdom: + specifier: ^29.6.2 + version: 29.6.2 + jest-node-exports-resolver: + specifier: ^1.1.6 + version: 1.1.6 + nyc: + specifier: ^15.1.0 + version: 15.1.0 + pino: + specifier: ^9.2.0 + version: 9.2.0 + pino-pretty: + specifier: ^11.2.1 + version: 11.2.1 + postcss: + specifier: ^8.4.21 + version: 8.4.21 + prettier: + specifier: 2.8.4 + version: 2.8.4 + prettier-plugin-tailwindcss: + specifier: ^0.2.2 + version: 0.2.2(prettier@2.8.4) + rollup-plugin-visualizer: + specifier: ^5.12.0 + version: 5.12.0 + style-dictionary: + specifier: ^3.9.2 + version: 3.9.2 + tailwindcss: + specifier: ^3.2.7 + version: 3.2.7(postcss@8.4.21) + ts-jest: + specifier: ^29.1.1 + version: 29.1.1(@babel/core@7.24.3)(babel-jest@29.6.2)(jest@29.5.0)(typescript@4.9.5) + ts-node-dev: + specifier: ^2.0.0 + version: 2.0.0(@types/node@20.11.30)(typescript@4.9.5) + tsconfig-paths-jest: + specifier: ^0.0.1 + version: 0.0.1 + typescript: + specifier: 4.9.5 + version: 4.9.5 + uuid: + specifier: ^9.0.0 + version: 9.0.0 + vite: + specifier: ^5.2.0 + version: 5.2.0(@types/node@20.11.30)(sass@1.77.2) + vite-plugin-compression2: + specifier: ^1.0.0 + version: 1.0.0 + vite-plugin-externals: + specifier: ^0.6.2 + version: 0.6.2(vite@5.2.0) + vite-plugin-html: + specifier: ^3.2.2 + version: 3.2.2(vite@5.2.0) + vite-plugin-importer: + specifier: ^0.2.5 + version: 0.2.5 + vite-plugin-istanbul: + specifier: ^6.0.2 + version: 6.0.2(vite@5.2.0) + vite-plugin-svgr: + specifier: ^3.2.0 + version: 3.2.0(typescript@4.9.5)(vite@5.2.0) + vite-plugin-terminal: + specifier: ^1.2.0 + version: 1.2.0(vite@5.2.0) + vite-plugin-total-bundle-size: + specifier: ^1.0.7 + version: 1.0.7(vite@5.2.0) + +packages: + + /@aashutoshrathi/word-wrap@1.2.6: + resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} + engines: {node: '>=0.10.0'} + dev: true + + /@ampproject/remapping@2.3.0: + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + + /@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0): + resolution: {integrity: sha512-iO6+hIp09dF4iAZQarVz3vKY1kM5Ij5CExYcK9jgc2q+OH8nv8n+BPFeJTdzGOGopmbUZn5Opj9pYQvge1Gr4Q==} + peerDependencies: + react: ^16.8.0 + dependencies: + react: 18.2.0 + tslib: 2.6.2 + dev: false + + /@atlaskit/analytics-next@9.3.0(react@18.2.0): + resolution: {integrity: sha512-mR5CndP92k2gFl8sWu4DJZZEpEQ4bnp5Z3fWCZE1oySiOKK8iM+KzKH4FMCaSUGOhWW6/5VeuXCcXvaogaAmsA==} + peerDependencies: + react: ^16.8.0 + dependencies: + '@atlaskit/analytics-next-stable-react-context': 1.0.1(react@18.2.0) + '@atlaskit/platform-feature-flags': 0.2.5 + '@babel/runtime': 7.24.1 + prop-types: 15.8.1 + react: 18.2.0 + use-memo-one: 1.1.3(react@18.2.0) + dev: false + + /@atlaskit/app-provider@1.3.2(react@18.2.0): + resolution: {integrity: sha512-tvyMNrydTyu5yJK78zUjqbJwgNRdW5nQ31imWFav5PvWXwc36lfGiTXRX/JIxJNBC3rBJ0gLAyrrb9YMzyWcTw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ~18.2.0 + dependencies: + '@atlaskit/tokens': 1.49.1(react@18.2.0) + '@babel/runtime': 7.24.1 + bind-event-listener: 3.0.0 + react: 18.2.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@atlaskit/css@0.1.0(react@18.2.0): + resolution: {integrity: sha512-FQfiLoYJrwTYhjSpa+RA8omPAPlJ5rl0OCJ0NAkMXRGx1o8ItNBW5EcRBwW0wUHaBOZ4oFS5EUshk185E/G/zQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ~18.2.0 + dependencies: + '@atlaskit/tokens': 1.49.1(react@18.2.0) + '@babel/runtime': 7.24.1 + '@compiled/react': 0.17.1(react@18.2.0) + react: 18.2.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@atlaskit/ds-lib@2.3.1(react@18.2.0): + resolution: {integrity: sha512-DVUE3hYLhdEZy4NnsxqiCqKC5Ym3CM/DGRQlnSPcABFNL0N0FfTXso3pLpkJnMZBtEnd2pn13mPJ2VQlSISRuw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ~18.2.0 + dependencies: + '@babel/runtime': 7.24.1 + bind-event-listener: 3.0.0 + react: 18.2.0 + dev: false + + /@atlaskit/interaction-context@2.1.4(react@18.2.0): + resolution: {integrity: sha512-MTuHN8wLYBPADE83Q+9KF5BcKyMW9/FkmA+lB/XnHwYIL86sMPzMSTM0DPG7crq/JI0JM0jlyY3Xzz0Aba7G+A==} + peerDependencies: + react: ^16.8.0 + dependencies: + '@babel/runtime': 7.24.1 + react: 18.2.0 + dev: false + + /@atlaskit/platform-feature-flags@0.2.5: + resolution: {integrity: sha512-0fD2aDxn2mE59D4acUhVib+YF2HDYuuPH50aYwpQdcV/CsVkAaJsMKy8WhWSulcRFeMYp72kfIfdy0qGdRB7Uw==} + dependencies: + '@babel/runtime': 7.24.6 + dev: false + + /@atlaskit/primitives@5.7.0(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-eCLyHN1BllNpwqA2YqCmYpqwoiNVcW3R6bHrpKmsW8uvPE/+Bd45hOiPwvCPJUPyK1ZNMfnkegWKkoxcmjMYIQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@atlaskit/analytics-next': 9.3.0(react@18.2.0) + '@atlaskit/app-provider': 1.3.2(react@18.2.0) + '@atlaskit/css': 0.1.0(react@18.2.0) + '@atlaskit/ds-lib': 2.3.1(react@18.2.0) + '@atlaskit/interaction-context': 2.1.4(react@18.2.0) + '@atlaskit/tokens': 1.49.1(react@18.2.0) + '@atlaskit/visually-hidden': 1.3.0(@types/react@18.2.66)(react@18.2.0) + '@babel/runtime': 7.24.1 + '@emotion/react': 11.11.4(@types/react@18.2.66)(react@18.2.0) + '@emotion/serialize': 1.1.4 + bind-event-listener: 3.0.0 + react: 18.2.0 + tiny-invariant: 1.3.3 + transitivePeerDependencies: + - '@types/react' + - supports-color + dev: false + + /@atlaskit/tokens@1.49.1(react@18.2.0): + resolution: {integrity: sha512-3SuhRMPUTU6b+nv0zVoGsNoqrUMtwQ/4iBbKhwaylRITanFxlxBwzW8XCCnn4sp1S2JupiT5BksI0h6jRoKN9Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ~18.2.0 + dependencies: + '@atlaskit/ds-lib': 2.3.1(react@18.2.0) + '@atlaskit/platform-feature-flags': 0.2.5 + '@babel/runtime': 7.24.1 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + bind-event-listener: 3.0.0 + react: 18.2.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@atlaskit/visually-hidden@1.3.0(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-iOHCxRnhNV3gnqOHuyLOnsFibfHpr1T28XUPYZjtN9bDQbn1GdSDYLoIHnLK+2enqdILirsuUWi93mWM3dCCwg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ~18.2.0 + dependencies: + '@babel/runtime': 7.24.1 + '@emotion/react': 11.11.4(@types/react@18.2.66)(react@18.2.0) + react: 18.2.0 + transitivePeerDependencies: + - '@types/react' + dev: false + + /@babel/code-frame@7.24.2: + resolution: {integrity: sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.24.2 + picocolors: 1.0.0 + + /@babel/code-frame@7.24.7: + resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.24.7 + picocolors: 1.0.0 + dev: true + + /@babel/compat-data@7.24.1: + resolution: {integrity: sha512-Pc65opHDliVpRHuKfzI+gSA4zcgr65O4cl64fFJIWEEh8JoHIHh0Oez1Eo8Arz8zq/JhgKodQaxEwUPRtZylVA==} + engines: {node: '>=6.9.0'} + + /@babel/compat-data@7.24.7: + resolution: {integrity: sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/core@7.24.3: + resolution: {integrity: sha512-5FcvN1JHw2sHJChotgx8Ek0lyuh4kCKelgMTTqhYJJtloNvUfpAFMeNQUtdlIaktwrSV9LtCdqwk48wL2wBacQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.24.2 + '@babel/generator': 7.24.1 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.3) + '@babel/helpers': 7.24.1 + '@babel/parser': 7.24.1 + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + convert-source-map: 2.0.0 + debug: 4.3.4(supports-color@8.1.1) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + /@babel/generator@7.24.1: + resolution: {integrity: sha512-DfCRfZsBcrPEHUfuBMgbJ1Ut01Y/itOs+hY2nFLgqsqXd52/iSiVq5TITtUasIUgm+IIKdY2/1I7auiQOEeC9A==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 2.5.2 + + /@babel/generator@7.24.7: + resolution: {integrity: sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.7 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 2.5.2 + dev: true + + /@babel/helper-annotate-as-pure@7.24.7: + resolution: {integrity: sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.7 + dev: true + + /@babel/helper-builder-binary-assignment-operator-visitor@7.24.7: + resolution: {integrity: sha512-xZeCVVdwb4MsDBkkyZ64tReWYrLRHlMN72vP7Bdm3OUOuyFZExhsHUUnuWnm2/XOlAJzR0LfPpB56WXZn0X/lA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.24.7 + '@babel/types': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-compilation-targets@7.23.6: + resolution: {integrity: sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/compat-data': 7.24.1 + '@babel/helper-validator-option': 7.23.5 + browserslist: 4.23.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + /@babel/helper-compilation-targets@7.24.7: + resolution: {integrity: sha512-ctSdRHBi20qWOfy27RUb4Fhp07KSJ3sXcuSvTrXrc4aG8NSYDo1ici3Vhg9bg69y5bj0Mr1lh0aeEgTvc12rMg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/compat-data': 7.24.7 + '@babel/helper-validator-option': 7.24.7 + browserslist: 4.23.0 + lru-cache: 5.1.1 + semver: 6.3.1 + dev: true + + /@babel/helper-create-class-features-plugin@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-kTkaDl7c9vO80zeX1rJxnuRpEsD5tA81yh11X1gQo+PhSti3JS+7qeZo9U4RHobKRiFPKaGK3svUAeb8D0Q7eg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-function-name': 7.24.7 + '@babel/helper-member-expression-to-functions': 7.24.7 + '@babel/helper-optimise-call-expression': 7.24.7 + '@babel/helper-replace-supers': 7.24.7(@babel/core@7.24.3) + '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 + '@babel/helper-split-export-declaration': 7.24.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-create-regexp-features-plugin@7.24.6(@babel/core@7.24.3): + resolution: {integrity: sha512-C875lFBIWWwyv6MHZUG9HmRrlTDgOsLWZfYR0nW69gaKJNe0/Mpxx5r0EID2ZdHQkdUmQo2t0uNckTL08/1BgA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-annotate-as-pure': 7.24.7 + regexpu-core: 5.3.2 + semver: 6.3.1 + dev: true + + /@babel/helper-create-regexp-features-plugin@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-03TCmXy2FtXJEZfbXDTSqq1fRJArk7lX9DOFC/47VthYcxyIOx+eXQmdo6DOQvrbpIix+KfXwvuXdFDZHxt+rA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-annotate-as-pure': 7.24.7 + regexpu-core: 5.3.2 + semver: 6.3.1 + dev: true + + /@babel/helper-define-polyfill-provider@0.6.2(@babel/core@7.24.3): + resolution: {integrity: sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-compilation-targets': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + debug: 4.3.4(supports-color@8.1.1) + lodash.debounce: 4.0.8 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-environment-visitor@7.22.20: + resolution: {integrity: sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==} + engines: {node: '>=6.9.0'} + + /@babel/helper-environment-visitor@7.24.7: + resolution: {integrity: sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.7 + dev: true + + /@babel/helper-function-name@7.23.0: + resolution: {integrity: sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.24.0 + '@babel/types': 7.24.0 + + /@babel/helper-function-name@7.24.7: + resolution: {integrity: sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.24.7 + '@babel/types': 7.24.7 + dev: true + + /@babel/helper-hoist-variables@7.22.5: + resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + + /@babel/helper-hoist-variables@7.24.7: + resolution: {integrity: sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.7 + dev: true + + /@babel/helper-member-expression-to-functions@7.24.7: + resolution: {integrity: sha512-LGeMaf5JN4hAT471eJdBs/GK1DoYIJ5GCtZN/EsL6KUiiDZOvO/eKE11AMZJa2zP4zk4qe9V2O/hxAmkRc8p6w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.24.7 + '@babel/types': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-module-imports@7.24.3: + resolution: {integrity: sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + + /@babel/helper-module-imports@7.24.7: + resolution: {integrity: sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.24.7 + '@babel/types': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-module-transforms@7.23.3(@babel/core@7.24.3): + resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-module-imports': 7.24.3 + '@babel/helper-simple-access': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/helper-validator-identifier': 7.22.20 + + /@babel/helper-module-transforms@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-module-imports': 7.24.7 + '@babel/helper-simple-access': 7.24.7 + '@babel/helper-split-export-declaration': 7.24.7 + '@babel/helper-validator-identifier': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-optimise-call-expression@7.24.7: + resolution: {integrity: sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.7 + dev: true + + /@babel/helper-plugin-utils@7.24.0: + resolution: {integrity: sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==} + engines: {node: '>=6.9.0'} + + /@babel/helper-plugin-utils@7.24.7: + resolution: {integrity: sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==} + engines: {node: '>=6.9.0'} + + /@babel/helper-remap-async-to-generator@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-9pKLcTlZ92hNZMQfGCHImUpDOlAgkkpqalWEeftW5FBya75k8Li2ilerxkM/uBEj01iBZXcCIB/bwvDYgWyibA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-wrap-function': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-replace-supers@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-qTAxxBM81VEyoAY0TtLrx1oAEJc09ZK67Q9ljQToqCnA+55eNwCORaxlKyu+rNfX86o8OXRUSNUnrtsAZXM9sg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-member-expression-to-functions': 7.24.7 + '@babel/helper-optimise-call-expression': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-simple-access@7.22.5: + resolution: {integrity: sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + + /@babel/helper-simple-access@7.24.7: + resolution: {integrity: sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.24.7 + '@babel/types': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-skip-transparent-expression-wrappers@7.24.7: + resolution: {integrity: sha512-IO+DLT3LQUElMbpzlatRASEyQtfhSE0+m465v++3jyyXeBTBUjtVZg28/gHeV5mrTJqvEKhKroBGAvhW+qPHiQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.24.7 + '@babel/types': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-split-export-declaration@7.22.6: + resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + + /@babel/helper-split-export-declaration@7.24.7: + resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.7 + dev: true + + /@babel/helper-string-parser@7.24.1: + resolution: {integrity: sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==} + engines: {node: '>=6.9.0'} + + /@babel/helper-string-parser@7.24.6: + resolution: {integrity: sha512-WdJjwMEkmBicq5T9fm/cHND3+UlFa2Yj8ALLgmoSQAJZysYbBjw+azChSGPN4DSPLXOcooGRvDwZWMcF/mLO2Q==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-string-parser@7.24.7: + resolution: {integrity: sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-identifier@7.22.20: + resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-identifier@7.24.7: + resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-option@7.23.5: + resolution: {integrity: sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-option@7.24.7: + resolution: {integrity: sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-wrap-function@7.24.7: + resolution: {integrity: sha512-N9JIYk3TD+1vq/wn77YnJOqMtfWhNewNE+DJV4puD2X7Ew9J4JvrzrFDfTfyv5EgEXVy9/Wt8QiOErzEmv5Ifw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-function-name': 7.24.7 + '@babel/template': 7.24.7 + '@babel/traverse': 7.24.7 + '@babel/types': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helpers@7.24.1: + resolution: {integrity: sha512-BpU09QqEe6ZCHuIHFphEFgvNSrubve1FtyMton26ekZ85gRGi6LrTF7zArARp2YvyFxloeiRmtSCq5sjh1WqIg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + transitivePeerDependencies: + - supports-color + + /@babel/highlight@7.24.2: + resolution: {integrity: sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.22.20 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.0.0 + + /@babel/highlight@7.24.7: + resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.24.7 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.0.0 + dev: true + + /@babel/parser@7.24.1: + resolution: {integrity: sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.24.0 + + /@babel/parser@7.24.7: + resolution: {integrity: sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.24.7 + dev: true + + /@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-TiT1ss81W80eQsN+722OaeQMY/G4yTb4G9JrqeiDADs3N8lbPMGldWi9x8tyqCW5NLx1Jh2AvkE6r6QvEltMMQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-unaQgZ/iRu/By6tsjMZzpeBZjChYfLYry6HrEXPoz3KmfF0sVBQ1l8zKMQ4xRGLWVsjuvB8nQfjNP/DcfEOCsg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-+izXIbke1T33mY4MSNnrqhPXDz01WYhEf3yF5NbnUtkiNnm+XBZJl3kNfoK6NKmYlz/D07+l2GWVK/QfDkNCuQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 + '@babel/plugin-transform-optional-chaining': 7.24.7(@babel/core@7.24.3) + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-utA4HuR6F4Vvcr+o4DnjL8fCOlgRFGbeeBEGNg3ZTrLFw6VWG5XmUrvcQ0FjIYMU2ST4XcR2Wsp7t9qOAPnxMg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.24.3): + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.24.3): + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + + /@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.24.3): + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + + /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.24.3): + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + + /@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.24.3): + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.24.3): + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.24.3): + resolution: {integrity: sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-import-assertions@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-Ec3NRUMoi8gskrkBe3fNmEQfxDvY8bgfQpz6jlk/41kX9eUjvpyqWU7PBP/pLAvMaSQjbMNKJmvX57jP+M6bPg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-import-attributes@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-hbX+lKKeUMGihnK8nvKqmXBInriT3GVjzXKFriV3YC6APGxMbP8RZNFwy91+hocLXq90Mta+HshoB31802bb8A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.24.3): + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + + /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.24.3): + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + + /@babel/plugin-syntax-jsx@7.24.1(@babel/core@7.24.3): + resolution: {integrity: sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + + /@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.24.3): + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + + /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.24.3): + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + + /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.24.3): + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + + /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.24.3): + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + + /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.24.3): + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + + /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.24.3): + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + + /@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.24.3): + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.24.3): + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + + /@babel/plugin-syntax-typescript@7.24.1(@babel/core@7.24.3): + resolution: {integrity: sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + + /@babel/plugin-syntax-typescript@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.24.3): + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-create-regexp-features-plugin': 7.24.6(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-arrow-functions@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-Dt9LQs6iEY++gXUwY03DNFat5C2NbO48jj+j/bSAz6b3HgPs39qcPiYt77fDObIcFwj3/C2ICX9YMwGflUoSHQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-async-generator-functions@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-o+iF77e3u7ZS4AoAuJvapz9Fm001PuD2V3Lp6OSE4FYQke+cSewYtnek+THqGRWyQloRCyvWL1OkyfNEl9vr/g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-remap-async-to-generator': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.3) + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-async-to-generator@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-SQY01PcJfmQ+4Ash7NE+rpbLFbmqA2GPIgqzxfFTL4t1FKRq4zTms/7htKpoCUI9OcFYgzqfmCdH53s6/jn5fA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-module-imports': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-remap-async-to-generator': 7.24.7(@babel/core@7.24.3) + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-block-scoped-functions@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-yO7RAz6EsVQDaBH18IDJcMB1HnrUn2FJ/Jslc/WtPPWcjhpUJXU/rjbwmluzp7v/ZzWcEhTMXELnnsz8djWDwQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-block-scoping@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-Nd5CvgMbWc+oWzBsuaMcbwjJWAcp5qzrbg69SZdHSP7AMY0AbWFqFO0WTFCA1jxhMCwodRwvRec8k0QUbZk7RQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-class-properties@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-vKbfawVYayKcSeSR5YYzzyXvsDFWU2mD8U5TFeXtbCPLFUqe7GyCgvO6XDHzje862ODrOwy6WCPmKeWHbCFJ4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-create-class-features-plugin': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-class-static-block@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-HMXK3WbBPpZQufbMG4B46A90PkuuhN9vBCb5T8+VAHqvAqvcLi+2cKoukcpmUYkszLhScU3l1iudhrks3DggRQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-create-class-features-plugin': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.24.3) + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-classes@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-CFbbBigp8ln4FU6Bpy6g7sE8B/WmCmzvivzUC6xDAdWVsjYTXijpuuGJmYkAaoWAzcItGKT3IOAbxRItZ5HTjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-compilation-targets': 7.24.7 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-function-name': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-replace-supers': 7.24.7(@babel/core@7.24.3) + '@babel/helper-split-export-declaration': 7.24.7 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-computed-properties@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-25cS7v+707Gu6Ds2oY6tCkUwsJ9YIDbggd9+cu9jzzDgiNq7hR/8dkzxWfKWnTic26vsI3EsCXNd4iEB6e8esQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/template': 7.24.7 + dev: true + + /@babel/plugin-transform-destructuring@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-19eJO/8kdCQ9zISOf+SEUJM/bAUIsvY3YDnXZTupUCQ8LgrWnsG/gFB9dvXqdXnRXMAM8fvt7b0CBKQHNGy1mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-dotall-regex@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-ZOA3W+1RRTSWvyqcMJDLqbchh7U4NRGqwRfFSVbOLS/ePIP4vHB5e8T8eXcuqyN1QkgKyj5wuW0lcS85v4CrSw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-duplicate-keys@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-JdYfXyCRihAe46jUIliuL2/s0x0wObgwwiGxw/UbgJBr20gQBThrokO4nYKgWkD7uBaqM7+9x5TU7NkExZJyzw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-dynamic-import@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-sc3X26PhZQDb3JhORmakcbvkeInvxz+A8oda99lj7J60QRuPZvNAk9wQlTBS1ZynelDrDmTU4pw1tyc5d5ZMUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.24.3) + dev: true + + /@babel/plugin-transform-exponentiation-operator@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-Rqe/vSc9OYgDajNIK35u7ot+KeCoetqQYFXM4Epf7M7ez3lWlOjrDjrwMei6caCVhfdw+mIKD4cgdGNy5JQotQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-builder-binary-assignment-operator-visitor': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-export-namespace-from@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-v0K9uNYsPL3oXZ/7F9NNIbAj2jv1whUEtyA6aujhekLs56R++JDQuzRcP2/z4WX5Vg/c5lE9uWZA0/iUoFhLTA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.24.3) + dev: true + + /@babel/plugin-transform-for-of@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-wo9ogrDG1ITTTBsy46oGiN1dS9A7MROBTcYsfS8DtsImMkHk9JXJ3EWQM6X2SUw4x80uGPlwj0o00Uoc6nEE3g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-function-name@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-U9FcnA821YoILngSmYkW6FjyQe2TyZD5pHt4EVIhmcTkrJw/3KqcrRSxuOo5tFZJi7TE19iDyI1u+weTI7bn2w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-compilation-targets': 7.24.7 + '@babel/helper-function-name': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-json-strings@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-2yFnBGDvRuxAaE/f0vfBKvtnvvqU8tGpMHqMNpTN2oWMKIR3NqFkjaAgGwawhqK/pIN2T3XdjGPdaG0vDhOBGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.3) + dev: true + + /@babel/plugin-transform-literals@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-vcwCbb4HDH+hWi8Pqenwnjy+UiklO4Kt1vfspcQYFhJdpthSnW8XvWGyDZWKNVrVbVViI/S7K9PDJZiUmP2fYQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-logical-assignment-operators@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-4D2tpwlQ1odXmTEIFWy9ELJcZHqrStlzK/dAOWYyxX3zT0iXQB6banjgeOJQXzEc4S0E0a5A+hahxPaEFYftsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.3) + dev: true + + /@babel/plugin-transform-member-expression-literals@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-T/hRC1uqrzXMKLQ6UCwMT85S3EvqaBXDGf0FaMf4446Qx9vKwlghvee0+uuZcDUCZU5RuNi4781UQ7R308zzBw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-modules-amd@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-9+pB1qxV3vs/8Hdmz/CulFB8w2tuu6EB94JZFsjdqxQokwGa9Unap7Bo2gGBGIvPmDIVvQrom7r5m/TCDMURhg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-module-transforms': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-modules-commonjs@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-iFI8GDxtevHJ/Z22J5xQpVqFLlMNstcLXh994xifFwxxGslr2ZXXLWgtBeLctOD63UFDArdvN6Tg8RFw+aEmjQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-module-transforms': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-simple-access': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-modules-systemjs@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-GYQE0tW7YoaN13qFh3O1NCY4MPkUiAH3fiF7UcV/I3ajmDKEdG3l+UOcbAm4zUE3gnvUU+Eni7XrVKo9eO9auw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-hoist-variables': 7.24.7 + '@babel/helper-module-transforms': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-validator-identifier': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-modules-umd@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-3aytQvqJ/h9z4g8AsKPLvD4Zqi2qT+L3j7XoFFu1XBlZWEl2/1kWnhmAbxpLgPrHSY0M6UA02jyTiwUVtiKR6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-module-transforms': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-named-capturing-groups-regex@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-/jr7h/EWeJtk1U/uz2jlsCioHkZk1JJZVcc8oQsJ1dUlaJD83f4/6Zeh2aHt9BIFokHIsSeDfhUmju0+1GPd6g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-new-target@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-RNKwfRIXg4Ls/8mMTza5oPF5RkOW8Wy/WgMAp1/F1yZ8mMbtwXW+HDoJiOsagWrAhI5f57Vncrmr9XeT4CVapA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-nullish-coalescing-operator@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-Ts7xQVk1OEocqzm8rHMXHlxvsfZ0cEF2yomUqpKENHWMF4zKk175Y4q8H5knJes6PgYad50uuRmt3UJuhBw8pQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.3) + dev: true + + /@babel/plugin-transform-numeric-separator@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-e6q1TiVUzvH9KRvicuxdBTUj4AdKSRwzIyFFnfnezpCfP2/7Qmbb8qbU2j7GODbl4JMkblitCQjKYUaX/qkkwA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.3) + dev: true + + /@babel/plugin-transform-object-rest-spread@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-4QrHAr0aXQCEFni2q4DqKLD31n2DL+RxcwnNjDFkSG0eNQ/xCavnRkfCUjsyqGC2OviNJvZOF/mQqZBw7i2C5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-compilation-targets': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-transform-parameters': 7.24.7(@babel/core@7.24.3) + dev: true + + /@babel/plugin-transform-object-super@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-A/vVLwN6lBrMFmMDmPPz0jnE6ZGx7Jq7d6sT/Ev4H65RER6pZ+kczlf1DthF5N0qaPHBsI7UXiE8Zy66nmAovg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-replace-supers': 7.24.7(@babel/core@7.24.3) + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-optional-catch-binding@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-uLEndKqP5BfBbC/5jTwPxLh9kqPWWgzN/f8w6UwAIirAEqiIVJWWY312X72Eub09g5KF9+Zn7+hT7sDxmhRuKA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.3) + dev: true + + /@babel/plugin-transform-optional-chaining@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-tK+0N9yd4j+x/4hxF3F0e0fu/VdcxU18y5SevtyM/PCFlQvXbR0Zmlo2eBrKtVipGNFzpq56o8WsIIKcJFUCRQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.3) + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-parameters@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-yGWW5Rr+sQOhK0Ot8hjDJuxU3XLRQGflvT4lhlSY0DFvdb3TwKaY26CJzHtYllU0vT9j58hc37ndFPsqT1SrzA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-private-methods@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-COTCOkG2hn4JKGEKBADkA8WNb35TGkkRbI5iT845dB+NyqgO8Hn+ajPbSnIQznneJTa3d30scb6iz/DhH8GsJQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-create-class-features-plugin': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-private-property-in-object@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-9z76mxwnwFxMyxZWEgdgECQglF2Q7cFLm0kMf8pGwt+GSJsY0cONKj/UuO4bOH0w/uAel3ekS4ra5CEAyJRmDA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-create-class-features-plugin': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.24.3) + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-property-literals@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-EMi4MLQSHfd2nrCqQEWxFdha2gBCqU4ZcCng4WBGZ5CJL4bBRW0ptdqqDdeirGZcpALazVVNJqRmsO8/+oNCBA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-react-display-name@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-H/Snz9PFxKsS1JLI4dJLtnJgCJRoo0AUm3chP6NYr+9En1JMKloheEiLIhlp5MDVznWo+H3AAC1Mc8lmUEpsgg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-react-jsx-development@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-QG9EnzoGn+Qar7rxuW+ZOsbWOt56FvvI93xInqsZDC5fsekx1AlIO4KIJ5M+D0p0SqSH156EpmZyXq630B8OlQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/plugin-transform-react-jsx': 7.24.7(@babel/core@7.24.3) + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-react-jsx-self@7.24.1(@babel/core@7.24.3): + resolution: {integrity: sha512-kDJgnPujTmAZ/9q2CN4m2/lRsUUPDvsG3+tSHWUJIzMGTt5U/b/fwWd3RO3n+5mjLrsBrVa5eKFRVSQbi3dF1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-react-jsx-source@7.24.1(@babel/core@7.24.3): + resolution: {integrity: sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-react-jsx@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-+Dj06GDZEFRYvclU6k4bme55GKBEWUmByM/eoKuqg4zTNQHiApWRhQph5fxQB2wAEFvRzL1tOEj1RJ19wJrhoA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-module-imports': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.24.3) + '@babel/types': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-react-pure-annotations@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-PLgBVk3fzbmEjBJ/u8kFzOqS9tUeDjiaWud/rRym/yjCo/M9cASPlnrd2ZmmZpQT40fOOrvR8jh+n8jikrOhNA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-regenerator@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-lq3fvXPdimDrlg6LWBoqj+r/DEWgONuwjuOuQCSYgRroXDH/IdM1C0IZf59fL5cHLpjEH/O6opIRBbqv7ELnuA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + regenerator-transform: 0.15.2 + dev: true + + /@babel/plugin-transform-reserved-words@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-0DUq0pHcPKbjFZCfTss/pGkYMfy3vFWydkUBd9r0GHpIyfs2eCDENvqadMycRS9wZCXR41wucAfJHJmwA0UmoQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-shorthand-properties@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-KsDsevZMDsigzbA09+vacnLpmPH4aWjcZjXdyFKGzpplxhbeB4wYtury3vglQkg6KM/xEPKt73eCjPPf1PgXBA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-spread@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-x96oO0I09dgMDxJaANcRyD4ellXFLLiWhuwDxKZX5g2rWP1bTPkBSwCYv96VDXVT1bD9aPj8tppr5ITIh8hBng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-sticky-regex@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-kHPSIJc9v24zEml5geKg9Mjx5ULpfncj0wRpYtxbvKyTtHCYDkVE3aHQ03FrpEo4gEe2vrJJS1Y9CJTaThA52g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-template-literals@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-AfDTQmClklHCOLxtGoP7HkeMw56k1/bTQjwsfhL6pppo/M4TOBSq+jjBUBLmV/4oeFg4GWMavIl44ZeCtmmZTw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-typeof-symbol@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-VtR8hDy7YLB7+Pet9IarXjg/zgCMSF+1mNS/EQEiEaUPoFXCVsHG64SIxcaaI2zJgRiv+YmgaQESUfWAdbjzgg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-typescript@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-iLD3UNkgx2n/HrjBesVbYX6j0yqn/sJktvbtKKgcaLIQ4bTTQ8obAypc1VpyHPD2y4Phh9zHOaAt8e/L14wCpw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-create-class-features-plugin': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + '@babel/plugin-syntax-typescript': 7.24.7(@babel/core@7.24.3) + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-unicode-escapes@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-U3ap1gm5+4edc2Q/P+9VrBNhGkfnf+8ZqppY71Bo/pzZmXhhLdqgaUl6cuB07O1+AQJtCLfaOmswiNbSQ9ivhw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-unicode-property-regex@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-uH2O4OV5M9FZYQrwc7NdVmMxQJOCCzFeYudlZSzUAHRFeOujQefa92E74TQDVskNHCzOXoigEuoyzHDhaEaK5w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-unicode-regex@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-hlQ96MBZSAXUq7ltkjtu3FJCCSMx/j629ns3hA3pXnBXjanNP0LHi+JpPeA81zaWgVK1VGH95Xuy7u0RyQ8kMg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-transform-unicode-sets-regex@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-2G8aAvF4wy1w/AGZkemprdGMRg5o6zPNhbHVImRz3lss55TYCBd6xStN19rt8XJHq20sqV0JbyWjOWwQRwV/wg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.24.3) + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/preset-env@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-1YZNsc+y6cTvWlDHidMBsQZrZfEFjRIo/BZCT906PMdzOyXtSLTgqGdrpcuTDCXyd11Am5uQULtDIcCfnTc8fQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/compat-data': 7.24.7 + '@babel/core': 7.24.3 + '@babel/helper-compilation-targets': 7.24.7 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-validator-option': 7.24.7 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.24.3) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.3) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.24.3) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.24.3) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-syntax-import-assertions': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-syntax-import-attributes': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.3) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.3) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.3) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.24.3) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.3) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.24.3) + '@babel/plugin-transform-arrow-functions': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-async-generator-functions': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-async-to-generator': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-block-scoped-functions': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-block-scoping': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-class-properties': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-class-static-block': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-classes': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-computed-properties': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-destructuring': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-dotall-regex': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-duplicate-keys': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-dynamic-import': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-exponentiation-operator': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-export-namespace-from': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-for-of': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-function-name': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-json-strings': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-literals': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-logical-assignment-operators': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-member-expression-literals': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-modules-amd': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-modules-commonjs': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-modules-systemjs': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-modules-umd': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-named-capturing-groups-regex': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-new-target': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-nullish-coalescing-operator': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-numeric-separator': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-object-rest-spread': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-object-super': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-optional-catch-binding': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-optional-chaining': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-parameters': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-private-methods': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-private-property-in-object': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-property-literals': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-regenerator': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-reserved-words': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-shorthand-properties': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-spread': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-sticky-regex': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-template-literals': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-typeof-symbol': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-unicode-escapes': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-unicode-property-regex': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-unicode-regex': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-unicode-sets-regex': 7.24.7(@babel/core@7.24.3) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.24.3) + babel-plugin-polyfill-corejs2: 0.4.11(@babel/core@7.24.3) + babel-plugin-polyfill-corejs3: 0.10.4(@babel/core@7.24.3) + babel-plugin-polyfill-regenerator: 0.6.2(@babel/core@7.24.3) + core-js-compat: 3.37.1 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.24.3): + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/types': 7.24.6 + esutils: 2.0.3 + dev: true + + /@babel/preset-react@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-AAH4lEkpmzFWrGVlHaxJB7RLH21uPQ9+He+eFLWHmF9IuFQVugz8eAsamaW0DXRrTfco5zj1wWtpdcXJUOfsag==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-validator-option': 7.24.7 + '@babel/plugin-transform-react-display-name': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-react-jsx': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-react-jsx-development': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-react-pure-annotations': 7.24.7(@babel/core@7.24.3) + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/preset-typescript@7.24.7(@babel/core@7.24.3): + resolution: {integrity: sha512-SyXRe3OdWwIwalxDg5UtJnJQO+YPcTfwiIY2B0Xlddh9o7jpWLvv8X1RthIeDOxQ+O1ML5BLPCONToObyVQVuQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-validator-option': 7.24.7 + '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-modules-commonjs': 7.24.7(@babel/core@7.24.3) + '@babel/plugin-transform-typescript': 7.24.7(@babel/core@7.24.3) + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/register@7.24.6(@babel/core@7.24.3): + resolution: {integrity: sha512-WSuFCc2wCqMeXkz/i3yfAAsxwWflEgbVkZzivgAmXl/MxrXeoYFZOOPllbC8R8WTF7u61wSRQtDVZ1879cdu6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + clone-deep: 4.0.1 + find-cache-dir: 2.1.0 + make-dir: 2.1.0 + pirates: 4.0.6 + source-map-support: 0.5.21 + dev: true + + /@babel/regjsgen@0.8.0: + resolution: {integrity: sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==} + dev: true + + /@babel/runtime@7.0.0: + resolution: {integrity: sha512-7hGhzlcmg01CvH1EHdSPVXYX1aJ8KCEyz6I9xYIi/asDtzBPMyMhVibhM/K6g/5qnKBwjZtp10bNZIEFTRW1MA==} + dependencies: + regenerator-runtime: 0.12.1 + dev: false + + /@babel/runtime@7.24.1: + resolution: {integrity: sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + + /@babel/runtime@7.24.6: + resolution: {integrity: sha512-Ja18XcETdEl5mzzACGd+DKgaGJzPTCow7EglgwTmHdwokzDFYh/MHua6lU6DV/hjF2IaOJ4oX2nqnjG7RElKOw==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + + /@babel/template@7.24.0: + resolution: {integrity: sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/parser': 7.24.1 + '@babel/types': 7.24.0 + + /@babel/template@7.24.7: + resolution: {integrity: sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/parser': 7.24.7 + '@babel/types': 7.24.7 + dev: true + + /@babel/traverse@7.24.1: + resolution: {integrity: sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/generator': 7.24.1 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-hoist-variables': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/parser': 7.24.1 + '@babel/types': 7.24.0 + debug: 4.3.4(supports-color@8.1.1) + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + /@babel/traverse@7.24.7: + resolution: {integrity: sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/generator': 7.24.7 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-function-name': 7.24.7 + '@babel/helper-hoist-variables': 7.24.7 + '@babel/helper-split-export-declaration': 7.24.7 + '@babel/parser': 7.24.7 + '@babel/types': 7.24.7 + debug: 4.3.4(supports-color@8.1.1) + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/types@7.24.0: + resolution: {integrity: sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.24.1 + '@babel/helper-validator-identifier': 7.22.20 + to-fast-properties: 2.0.0 + + /@babel/types@7.24.6: + resolution: {integrity: sha512-WaMsgi6Q8zMgMth93GvWPXkhAIEobfsIkLTacoVZoK1J0CevIPGYY2Vo5YvJGqyHqXM6P4ppOYGsIRU8MM9pFQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.24.6 + '@babel/helper-validator-identifier': 7.24.7 + to-fast-properties: 2.0.0 + dev: true + + /@babel/types@7.24.7: + resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.24.7 + '@babel/helper-validator-identifier': 7.24.7 + to-fast-properties: 2.0.0 + dev: true + + /@bcoe/v8-coverage@0.2.3: + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + /@colors/colors@1.5.0: + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + requiresBuild: true + dev: true + optional: true + + /@compiled/react@0.17.1(react@18.2.0): + resolution: {integrity: sha512-1CzTOrwNHOUmz9QGYHv8R8J6ejUyaNYiaUN6/dIM0Wu3G5CIam0KgsqvRikfGPrTtBfAQYMmdI9ytzxUKYwJrg==} + peerDependencies: + react: '>= 16.12.0' + dependencies: + csstype: 3.1.3 + react: 18.2.0 + dev: false + + /@cspotcode/source-map-support@0.8.1: + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + dev: true + + /@cypress/code-coverage@3.12.39(@babel/core@7.24.3)(@babel/preset-env@7.24.7)(babel-loader@9.1.3)(cypress@13.7.2)(webpack@5.91.0): + resolution: {integrity: sha512-ja7I/GRmkSAW9e3O7pideWcNUEHao0WT6sRyXQEURoxkJUASJssJ7Kb/bd3eMYmkUCiD5CRFqWR5BGF4mWVaUw==} + peerDependencies: + '@babel/core': ^7.0.1 + '@babel/preset-env': ^7.0.0 + babel-loader: ^8.3 || ^9 + cypress: '*' + webpack: ^4 || ^5 + dependencies: + '@babel/core': 7.24.3 + '@babel/preset-env': 7.24.7(@babel/core@7.24.3) + '@cypress/webpack-preprocessor': 6.0.1(@babel/core@7.24.3)(@babel/preset-env@7.24.7)(babel-loader@9.1.3)(webpack@5.91.0) + babel-loader: 9.1.3(@babel/core@7.24.3)(webpack@5.91.0) + chalk: 4.1.2 + cypress: 13.7.2 + dayjs: 1.11.10 + debug: 4.3.4(supports-color@8.1.1) + execa: 4.1.0 + globby: 11.1.0 + istanbul-lib-coverage: 3.2.2 + js-yaml: 4.1.0 + nyc: 15.1.0 + webpack: 5.91.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@cypress/request@3.0.1: + resolution: {integrity: sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==} + engines: {node: '>= 6'} + dependencies: + aws-sign2: 0.7.0 + aws4: 1.12.0 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.3.3 + http-signature: 1.3.6 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + performance-now: 2.1.0 + qs: 6.10.4 + safe-buffer: 5.1.2 + tough-cookie: 4.1.3 + tunnel-agent: 0.6.0 + uuid: 8.3.2 + dev: true + + /@cypress/webpack-preprocessor@6.0.1(@babel/core@7.24.3)(@babel/preset-env@7.24.7)(babel-loader@9.1.3)(webpack@5.91.0): + resolution: {integrity: sha512-WVNeFVSnFKxE3WZNRIriduTgqJRpevaiJIPlfqYTTzfXRD7X1Pv4woDE+G4caPV9bJqVKmVFiwzrXMRNeJxpxA==} + peerDependencies: + '@babel/core': ^7.0.1 + '@babel/preset-env': ^7.0.0 + babel-loader: ^8.3 || ^9 + webpack: ^4 || ^5 + dependencies: + '@babel/core': 7.24.3 + '@babel/preset-env': 7.24.7(@babel/core@7.24.3) + babel-loader: 9.1.3(@babel/core@7.24.3)(webpack@5.91.0) + bluebird: 3.7.1 + debug: 4.3.4(supports-color@8.1.1) + lodash: 4.17.21 + webpack: 5.91.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@cypress/xvfb@1.2.4(supports-color@8.1.1): + resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==} + dependencies: + debug: 3.2.7(supports-color@8.1.1) + lodash.once: 4.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@emoji-mart/data@1.2.1: + resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} + dev: false + + /@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@18.2.0): + resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==} + peerDependencies: + emoji-mart: ^5.2 + react: ^16.8 || ^17 || ^18 + dependencies: + emoji-mart: 5.6.0 + react: 18.2.0 + dev: false + + /@emotion/babel-plugin@11.11.0: + resolution: {integrity: sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==} + dependencies: + '@babel/helper-module-imports': 7.24.3 + '@babel/runtime': 7.24.1 + '@emotion/hash': 0.9.1 + '@emotion/memoize': 0.8.1 + '@emotion/serialize': 1.1.4 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + dev: false + + /@emotion/cache@11.11.0: + resolution: {integrity: sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==} + dependencies: + '@emotion/memoize': 0.8.1 + '@emotion/sheet': 1.2.2 + '@emotion/utils': 1.2.1 + '@emotion/weak-memoize': 0.3.1 + stylis: 4.2.0 + dev: false + + /@emotion/hash@0.9.1: + resolution: {integrity: sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==} + dev: false + + /@emotion/is-prop-valid@1.2.2: + resolution: {integrity: sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==} + dependencies: + '@emotion/memoize': 0.8.1 + dev: false + + /@emotion/memoize@0.8.1: + resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==} + dev: false + + /@emotion/react@11.11.4(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@emotion/babel-plugin': 11.11.0 + '@emotion/cache': 11.11.0 + '@emotion/serialize': 1.1.4 + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) + '@emotion/utils': 1.2.1 + '@emotion/weak-memoize': 0.3.1 + '@types/react': 18.2.66 + hoist-non-react-statics: 3.3.2 + react: 18.2.0 + dev: false + + /@emotion/serialize@1.1.4: + resolution: {integrity: sha512-RIN04MBT8g+FnDwgvIUi8czvr1LU1alUMI05LekWB5DGyTm8cCBMCRpq3GqaiyEDRptEXOyXnvZ58GZYu4kBxQ==} + dependencies: + '@emotion/hash': 0.9.1 + '@emotion/memoize': 0.8.1 + '@emotion/unitless': 0.8.1 + '@emotion/utils': 1.2.1 + csstype: 3.1.3 + dev: false + + /@emotion/sheet@1.2.2: + resolution: {integrity: sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==} + dev: false + + /@emotion/styled@11.11.5(@emotion/react@11.11.4)(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-/ZjjnaNKvuMPxcIiUkf/9SHoG4Q196DRl1w82hQ3WCsjo1IUR8uaGWrC6a87CrYAW0Kb/pK7hk8BnLgLRi9KoQ==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@emotion/babel-plugin': 11.11.0 + '@emotion/is-prop-valid': 1.2.2 + '@emotion/react': 11.11.4(@types/react@18.2.66)(react@18.2.0) + '@emotion/serialize': 1.1.4 + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) + '@emotion/utils': 1.2.1 + '@types/react': 18.2.66 + react: 18.2.0 + dev: false + + /@emotion/unitless@0.8.1: + resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} + dev: false + + /@emotion/use-insertion-effect-with-fallbacks@1.0.1(react@18.2.0): + resolution: {integrity: sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.2.0 + dev: false + + /@emotion/utils@1.2.1: + resolution: {integrity: sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==} + dev: false + + /@emotion/weak-memoize@0.3.1: + resolution: {integrity: sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==} + dev: false + + /@esbuild/aix-ppc64@0.20.2: + resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + optional: true + + /@esbuild/android-arm64@0.20.2: + resolution: {integrity: sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + optional: true + + /@esbuild/android-arm@0.20.2: + resolution: {integrity: sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + optional: true + + /@esbuild/android-x64@0.20.2: + resolution: {integrity: sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + optional: true + + /@esbuild/darwin-arm64@0.20.2: + resolution: {integrity: sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optional: true + + /@esbuild/darwin-x64@0.20.2: + resolution: {integrity: sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + optional: true + + /@esbuild/freebsd-arm64@0.20.2: + resolution: {integrity: sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + optional: true + + /@esbuild/freebsd-x64@0.20.2: + resolution: {integrity: sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + optional: true + + /@esbuild/linux-arm64@0.20.2: + resolution: {integrity: sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-arm@0.20.2: + resolution: {integrity: sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-ia32@0.20.2: + resolution: {integrity: sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-loong64@0.20.2: + resolution: {integrity: sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-mips64el@0.20.2: + resolution: {integrity: sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-ppc64@0.20.2: + resolution: {integrity: sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-riscv64@0.20.2: + resolution: {integrity: sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-s390x@0.20.2: + resolution: {integrity: sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-x64@0.20.2: + resolution: {integrity: sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/netbsd-x64@0.20.2: + resolution: {integrity: sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + optional: true + + /@esbuild/openbsd-x64@0.20.2: + resolution: {integrity: sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + optional: true + + /@esbuild/sunos-x64@0.20.2: + resolution: {integrity: sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + optional: true + + /@esbuild/win32-arm64@0.20.2: + resolution: {integrity: sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + optional: true + + /@esbuild/win32-ia32@0.20.2: + resolution: {integrity: sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + optional: true + + /@esbuild/win32-x64@0.20.2: + resolution: {integrity: sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + optional: true + + /@eslint-community/eslint-utils@4.4.0(eslint@8.57.0): + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + dependencies: + eslint: 8.57.0 + eslint-visitor-keys: 3.4.3 + dev: true + + /@eslint-community/regexpp@4.10.0: + resolution: {integrity: sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + dev: true + + /@eslint/eslintrc@2.1.4: + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + ajv: 6.12.6 + debug: 4.3.4(supports-color@8.1.1) + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.1 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@eslint/js@8.57.0: + resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@floating-ui/core@1.6.2: + resolution: {integrity: sha512-+2XpQV9LLZeanU4ZevzRnGFg2neDeKHgFLjP6YLW+tly0IvrhqT4u8enLGjLH3qeh85g19xY5rsAusfwTdn5lg==} + dependencies: + '@floating-ui/utils': 0.2.2 + dev: false + + /@floating-ui/dom@1.6.5: + resolution: {integrity: sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==} + dependencies: + '@floating-ui/core': 1.6.2 + '@floating-ui/utils': 0.2.2 + dev: false + + /@floating-ui/react-dom@2.1.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-lNzj5EQmEKn5FFKc04+zasr09h/uX8RtJRNj5gUXsSQIXHVWTVh+hVAg1vOMCexkX8EgvemMvIFpQfkosnVNyA==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/dom': 1.6.5 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@floating-ui/utils@0.2.2: + resolution: {integrity: sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==} + dev: false + + /@humanwhocodes/config-array@0.11.14: + resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} + engines: {node: '>=10.10.0'} + dependencies: + '@humanwhocodes/object-schema': 2.0.2 + debug: 4.3.4(supports-color@8.1.1) + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@humanwhocodes/module-importer@1.0.1: + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + dev: true + + /@humanwhocodes/object-schema@2.0.2: + resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + dev: true + + /@icons/material@0.2.4(react@18.2.0): + resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==} + peerDependencies: + react: '*' + dependencies: + react: 18.2.0 + dev: false + + /@isaacs/cliui@8.0.2: + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + dependencies: + string-width: 5.1.2 + string-width-cjs: /string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: /strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: /wrap-ansi@7.0.0 + dev: true + + /@istanbuljs/load-nyc-config@1.1.0: + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + + /@istanbuljs/nyc-config-babel@3.0.0(@babel/register@7.24.6)(babel-plugin-istanbul@6.1.1): + resolution: {integrity: sha512-mPnSPXfTRWCzYsT64PnuPlce6/hGMCdVVMgU2FenXipbUd+FDwUlqlTihXxpxWzcNVOp8M+L1t/kIcgoC8A7hg==} + engines: {node: '>=8'} + peerDependencies: + '@babel/register': '*' + babel-plugin-istanbul: '>=5' + dependencies: + '@babel/register': 7.24.6(@babel/core@7.24.3) + babel-plugin-istanbul: 6.1.1 + dev: true + + /@istanbuljs/nyc-config-typescript@1.0.2(nyc@15.1.0): + resolution: {integrity: sha512-iKGIyMoyJuFnJRSVTZ78POIRvNnwZaWIf8vG4ZS3rQq58MMDrqEX2nnzx0R28V2X8JvmKYiqY9FP2hlJsm8A0w==} + engines: {node: '>=8'} + peerDependencies: + nyc: '>=15' + dependencies: + '@istanbuljs/schema': 0.1.3 + nyc: 15.1.0 + dev: true + + /@istanbuljs/schema@0.1.3: + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + /@jest/console@29.7.0: + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.11.30 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + /@jest/core@29.7.0: + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + 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 + 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': 20.11.30 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@20.11.30) + 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.5 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + /@jest/environment@29.7.0: + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.11.30 + jest-mock: 29.7.0 + + /@jest/expect-utils@29.7.0: + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + jest-get-type: 29.6.3 + + /@jest/expect@29.7.0: + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + /@jest/fake-timers@29.7.0: + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 20.11.30 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + /@jest/globals@29.7.0: + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + /@jest/reporters@29.7.0: + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + 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 + 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.25 + '@types/node': 20.11.30 + chalk: 4.1.2 + collect-v8-coverage: 1.0.2 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.7 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.2.0 + transitivePeerDependencies: + - supports-color + + /@jest/schemas@29.6.3: + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@sinclair/typebox': 0.27.8 + + /@jest/source-map@29.6.3: + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + /@jest/test-result@29.7.0: + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.2 + + /@jest/test-sequencer@29.7.0: + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + /@jest/transform@29.7.0: + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/core': 7.24.3 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.25 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.5 + pirates: 4.0.6 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + /@jest/types@29.6.3: + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.11.30 + '@types/yargs': 17.0.32 + chalk: 4.1.2 + + /@jridgewell/gen-mapping@0.3.5: + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.25 + + /@jridgewell/resolve-uri@3.1.2: + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + /@jridgewell/set-array@1.2.1: + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + /@jridgewell/source-map@0.3.6: + resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + dev: true + + /@jridgewell/sourcemap-codec@1.4.15: + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + + /@jridgewell/trace-mapping@0.3.25: + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + + /@jridgewell/trace-mapping@0.3.9: + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /@juggle/resize-observer@3.4.0: + resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + dev: false + + /@lokesh.dhakar/quantize@1.3.0: + resolution: {integrity: sha512-4KBSyaMj65d8A+2vnzLxtHFu4OmBU4IKO0yLxZ171Itdf9jGV4w+WbG7VsKts2jUdRkFSzsZqpZOz6hTB3qGAw==} + dev: false + + /@mui/base@5.0.0-beta.40(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@floating-ui/react-dom': 2.1.0(react-dom@18.2.0)(react@18.2.0) + '@mui/types': 7.2.14(@types/react@18.2.66) + '@mui/utils': 5.15.14(@types/react@18.2.66)(react@18.2.0) + '@popperjs/core': 2.11.8 + '@types/react': 18.2.66 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@mui/base@5.0.0-beta.42(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-fWRiUJVCHCPF+mxd5drn08bY2qRw3jj5f1SSQdUXmaJ/yKpk23ys8MgLO2KGVTRtbks/+ctRfgffGPbXifj0Ug==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.6 + '@floating-ui/react-dom': 2.1.0(react-dom@18.2.0)(react@18.2.0) + '@mui/types': 7.2.14(@types/react@18.2.66) + '@mui/utils': 6.0.0-alpha.8(@types/react@18.2.66)(react@18.2.0) + '@popperjs/core': 2.11.8 + '@types/react': 18.2.66 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@mui/core-downloads-tracker@6.0.0-dev.240424162023-9968b4889d: + resolution: {integrity: sha512-doh3M3U7HUGSBIWGe1yvesSbfDguMRjP0N09ogWSBM2hovXAlgULhMgcRTepAZLLwfRxFII0bCohq6B9NqoKuw==} + dev: false + + /@mui/icons-material@5.15.18(@mui/material@6.0.0-alpha.2)(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-jGhyw02TSLM0NgW+MDQRLLRUD/K4eN9rlK2pTBTL1OtzyZmQ8nB060zK1wA0b7cVrIiG+zyrRmNAvGWXwm2N9Q==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@mui/material': ^5.0.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@mui/material': 6.0.0-alpha.2(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.66 + react: 18.2.0 + dev: false + + /@mui/material@6.0.0-alpha.2(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-p1GpE1a7dQTns0yp0anSNX/Bh1xafTdUCt0roTyqEuL/3hCBKTURE/9/CDttwwQ+Q8oDm5KcsdtXJXJh1ts6Kw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.6 + '@emotion/react': 11.11.4(@types/react@18.2.66)(react@18.2.0) + '@emotion/styled': 11.11.5(@emotion/react@11.11.4)(@types/react@18.2.66)(react@18.2.0) + '@mui/base': 5.0.0-beta.42(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@mui/core-downloads-tracker': 6.0.0-dev.240424162023-9968b4889d + '@mui/system': 6.0.0-dev.240424162023-9968b4889d(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.2.66)(react@18.2.0) + '@mui/types': 7.2.14(@types/react@18.2.66) + '@mui/utils': 6.0.0-alpha.8(@types/react@18.2.66)(react@18.2.0) + '@types/react': 18.2.66 + '@types/react-transition-group': 4.4.10 + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 18.2.0 + react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) + dev: false + + /@mui/private-theming@5.15.14(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.6 + '@mui/utils': 5.15.14(@types/react@18.2.66)(react@18.2.0) + '@types/react': 18.2.66 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@mui/private-theming@6.0.0-alpha.8(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-0iN+hK/OZTaiVfjFYDgWEc/frRB7Z1hfBsSJBniM4KPZnrdeHIArP+3TdYzRT0avh30O2KNkBNk0GG95BnUVEg==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.6 + '@mui/utils': 6.0.0-alpha.8(@types/react@18.2.66)(react@18.2.0) + '@types/react': 18.2.66 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@mui/styled-engine@5.15.14(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(react@18.2.0): + resolution: {integrity: sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + dependencies: + '@babel/runtime': 7.24.6 + '@emotion/cache': 11.11.0 + '@emotion/react': 11.11.4(@types/react@18.2.66)(react@18.2.0) + '@emotion/styled': 11.11.5(@emotion/react@11.11.4)(@types/react@18.2.66)(react@18.2.0) + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@mui/styled-engine@6.0.0-alpha.8(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(react@18.2.0): + resolution: {integrity: sha512-7zJYgbjZRQpGN1SGmLDOgRpJZB26JjPSeqml5m+jA4wAsIONm2im+GHfki4nE3ay0uj1S555OMeNpaQ+sG9LkA==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + dependencies: + '@babel/runtime': 7.24.6 + '@emotion/cache': 11.11.0 + '@emotion/react': 11.11.4(@types/react@18.2.66)(react@18.2.0) + '@emotion/styled': 11.11.5(@emotion/react@11.11.4)(@types/react@18.2.66)(react@18.2.0) + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@mui/system@5.15.15(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-aulox6N1dnu5PABsfxVGOZffDVmlxPOVgj56HrUnJE8MCSh8lOvvkd47cebIVQQYAjpwieXQXiDPj5pwM40jTQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.6 + '@emotion/react': 11.11.4(@types/react@18.2.66)(react@18.2.0) + '@emotion/styled': 11.11.5(@emotion/react@11.11.4)(@types/react@18.2.66)(react@18.2.0) + '@mui/private-theming': 5.15.14(@types/react@18.2.66)(react@18.2.0) + '@mui/styled-engine': 5.15.14(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(react@18.2.0) + '@mui/types': 7.2.14(@types/react@18.2.66) + '@mui/utils': 5.15.14(@types/react@18.2.66)(react@18.2.0) + '@types/react': 18.2.66 + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@mui/system@6.0.0-dev.240424162023-9968b4889d(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-Y3yCFUHN1xMK62hJJBqzZb1YQvHNaHc7JUX01eU6QTPojtIbGMF2jCOP/EQw77/byahNbxeLoAIQx10F0IR3Rw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.6 + '@emotion/react': 11.11.4(@types/react@18.2.66)(react@18.2.0) + '@emotion/styled': 11.11.5(@emotion/react@11.11.4)(@types/react@18.2.66)(react@18.2.0) + '@mui/private-theming': 6.0.0-alpha.8(@types/react@18.2.66)(react@18.2.0) + '@mui/styled-engine': 6.0.0-alpha.8(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(react@18.2.0) + '@mui/types': 7.2.14(@types/react@18.2.66) + '@mui/utils': 6.0.0-alpha.8(@types/react@18.2.66)(react@18.2.0) + '@types/react': 18.2.66 + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@mui/types@7.2.14(@types/react@18.2.66): + resolution: {integrity: sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.66 + dev: false + + /@mui/utils@5.15.14(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@types/prop-types': 15.7.12 + '@types/react': 18.2.66 + prop-types: 15.8.1 + react: 18.2.0 + react-is: 18.2.0 + dev: false + + /@mui/utils@6.0.0-alpha.8(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-X5lg0bh8B6uYt/0HXV+t82HXLTOVFEKcIBmIbJ5El1h9ykXaRTenr8mORxt5UC5w9DHFhkRoI8XiM5qyDuSJVw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.6 + '@types/prop-types': 15.7.12 + '@types/react': 18.2.66 + prop-types: 15.8.1 + react: 18.2.0 + react-is: 18.2.0 + dev: false + + /@mui/x-date-pickers-pro@6.20.0(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@mui/material@6.0.0-alpha.2)(@mui/system@5.15.15)(@types/react@18.2.66)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-lXbO8xCKRvIKgu2R8EAGhYwN1BkMS9GxTeinKZg5lCGvxTrd5pErQ4xpOxYVQq7wNLphDiY7I/Xf88VekKTNLQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.9.0 + '@emotion/styled': ^11.8.1 + '@mui/material': ^5.8.6 + '@mui/system': ^5.8.0 + date-fns: ^2.25.0 || ^3.2.0 + date-fns-jalali: ^2.13.0-0 + dayjs: ^1.10.7 + luxon: ^3.0.2 + moment: ^2.29.4 + moment-hijri: ^2.1.2 + moment-jalaali: ^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + date-fns: + optional: true + date-fns-jalali: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + moment-hijri: + optional: true + moment-jalaali: + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@emotion/react': 11.11.4(@types/react@18.2.66)(react@18.2.0) + '@emotion/styled': 11.11.5(@emotion/react@11.11.4)(@types/react@18.2.66)(react@18.2.0) + '@mui/base': 5.0.0-beta.40(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@mui/material': 6.0.0-alpha.2(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@mui/system': 5.15.15(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.2.66)(react@18.2.0) + '@mui/utils': 5.15.14(@types/react@18.2.66)(react@18.2.0) + '@mui/x-date-pickers': 6.20.0(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@mui/material@6.0.0-alpha.2)(@mui/system@5.15.15)(@types/react@18.2.66)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0) + '@mui/x-license-pro': 6.10.2(@types/react@18.2.66)(react@18.2.0) + clsx: 2.1.1 + dayjs: 1.11.9 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + dev: false + + /@mui/x-date-pickers@6.20.0(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@mui/material@6.0.0-alpha.2)(@mui/system@5.15.15)(@types/react@18.2.66)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-q/x3rNmPYMXnx75+3s9pQb1YDtws9y5bwxpxeB3EW88oCp33eS7bvJpeuoCA1LzW/PpVfIRhi5RCyAvrEeTL7Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.9.0 + '@emotion/styled': ^11.8.1 + '@mui/material': ^5.8.6 + '@mui/system': ^5.8.0 + date-fns: ^2.25.0 || ^3.2.0 + date-fns-jalali: ^2.13.0-0 + dayjs: ^1.10.7 + luxon: ^3.0.2 + moment: ^2.29.4 + moment-hijri: ^2.1.2 + moment-jalaali: ^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + date-fns: + optional: true + date-fns-jalali: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + moment-hijri: + optional: true + moment-jalaali: + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@emotion/react': 11.11.4(@types/react@18.2.66)(react@18.2.0) + '@emotion/styled': 11.11.5(@emotion/react@11.11.4)(@types/react@18.2.66)(react@18.2.0) + '@mui/base': 5.0.0-beta.40(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@mui/material': 6.0.0-alpha.2(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@mui/system': 5.15.15(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.2.66)(react@18.2.0) + '@mui/utils': 5.15.14(@types/react@18.2.66)(react@18.2.0) + '@types/react-transition-group': 4.4.10 + clsx: 2.1.1 + dayjs: 1.11.9 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + dev: false + + /@mui/x-license-pro@6.10.2(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-Baw3shilU+eHgU+QYKNPFUKvfS5rSyNJ98pQx02E0gKA22hWp/XAt88K1qUfUMPlkPpvg/uci6gviQSSLZkuKw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.24.1 + '@mui/utils': 5.15.14(@types/react@18.2.66)(react@18.2.0) + react: 18.2.0 + transitivePeerDependencies: + - '@types/react' + dev: false + + /@nodelib/fs.scandir@2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + dev: true + + /@nodelib/fs.stat@2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + dev: true + + /@nodelib/fs.walk@1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + dev: true + + /@pkgjs/parseargs@0.11.0: + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + requiresBuild: true + dev: true + optional: true + + /@polka/url@1.0.0-next.25: + resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==} + dev: true + + /@popperjs/core@2.11.8: + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + /@reduxjs/toolkit@2.0.0(react-redux@8.1.3)(react@18.2.0): + resolution: {integrity: sha512-Kq/a+aO28adYdPoNEu9p800MYPKoUc0tlkYfv035Ief9J7MPq8JvmT7UdpYhvXsoMtOdt567KwZjc9H3Rf8yjg==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + dependencies: + immer: 10.1.1 + react: 18.2.0 + react-redux: 8.1.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1) + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.0 + dev: false + + /@remix-run/router@1.16.1: + resolution: {integrity: sha512-es2g3dq6Nb07iFxGk5GuHN20RwBZOsuDQN7izWIisUcv9r+d2C5jQxqmgkdebXgReWfiyUabcki6Fg77mSNrig==} + engines: {node: '>=14.0.0'} + dev: false + + /@restart/hooks@0.4.16(react@18.2.0): + resolution: {integrity: sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==} + peerDependencies: + react: '>=16.8.0' + dependencies: + dequal: 2.0.3 + react: 18.2.0 + dev: false + + /@rollup/plugin-strip@3.0.4: + resolution: {integrity: sha512-LDRV49ZaavxUo2YoKKMQjCxzCxugu1rCPQa0lDYBOWLj6vtzBMr8DcoJjsmg+s450RbKbe3qI9ZLaSO+O1oNbg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@rollup/pluginutils': 5.1.0 + estree-walker: 2.0.2 + magic-string: 0.30.8 + dev: true + + /@rollup/pluginutils@4.2.1: + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} + engines: {node: '>= 8.0.0'} + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + + /@rollup/pluginutils@5.1.0: + resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.5 + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + + /@rollup/rollup-android-arm-eabi@4.13.2: + resolution: {integrity: sha512-3XFIDKWMFZrMnao1mJhnOT1h2g0169Os848NhhmGweEcfJ4rCi+3yMCOLG4zA61rbJdkcrM/DjVZm9Hg5p5w7g==} + cpu: [arm] + os: [android] + requiresBuild: true + optional: true + + /@rollup/rollup-android-arm64@4.13.2: + resolution: {integrity: sha512-GdxxXbAuM7Y/YQM9/TwwP+L0omeE/lJAR1J+olu36c3LqqZEBdsIWeQ91KBe6nxwOnb06Xh7JS2U5ooWU5/LgQ==} + cpu: [arm64] + os: [android] + requiresBuild: true + optional: true + + /@rollup/rollup-darwin-arm64@4.13.2: + resolution: {integrity: sha512-mCMlpzlBgOTdaFs83I4XRr8wNPveJiJX1RLfv4hggyIVhfB5mJfN4P8Z6yKh+oE4Luz+qq1P3kVdWrCKcMYrrA==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optional: true + + /@rollup/rollup-darwin-x64@4.13.2: + resolution: {integrity: sha512-yUoEvnH0FBef/NbB1u6d3HNGyruAKnN74LrPAfDQL3O32e3k3OSfLrPgSJmgb3PJrBZWfPyt6m4ZhAFa2nZp2A==} + cpu: [x64] + os: [darwin] + requiresBuild: true + optional: true + + /@rollup/rollup-linux-arm-gnueabihf@4.13.2: + resolution: {integrity: sha512-GYbLs5ErswU/Xs7aGXqzc3RrdEjKdmoCrgzhJWyFL0r5fL3qd1NPcDKDowDnmcoSiGJeU68/Vy+OMUluRxPiLQ==} + cpu: [arm] + os: [linux] + requiresBuild: true + optional: true + + /@rollup/rollup-linux-arm64-gnu@4.13.2: + resolution: {integrity: sha512-L1+D8/wqGnKQIlh4Zre9i4R4b4noxzH5DDciyahX4oOz62CphY7WDWqJoQ66zNR4oScLNOqQJfNSIAe/6TPUmQ==} + cpu: [arm64] + os: [linux] + requiresBuild: true + optional: true + + /@rollup/rollup-linux-arm64-musl@4.13.2: + resolution: {integrity: sha512-tK5eoKFkXdz6vjfkSTCupUzCo40xueTOiOO6PeEIadlNBkadH1wNOH8ILCPIl8by/Gmb5AGAeQOFeLev7iZDOA==} + cpu: [arm64] + os: [linux] + requiresBuild: true + optional: true + + /@rollup/rollup-linux-powerpc64le-gnu@4.13.2: + resolution: {integrity: sha512-zvXvAUGGEYi6tYhcDmb9wlOckVbuD+7z3mzInCSTACJ4DQrdSLPNUeDIcAQW39M3q6PDquqLWu7pnO39uSMRzQ==} + cpu: [ppc64le] + os: [linux] + requiresBuild: true + optional: true + + /@rollup/rollup-linux-riscv64-gnu@4.13.2: + resolution: {integrity: sha512-C3GSKvMtdudHCN5HdmAMSRYR2kkhgdOfye4w0xzyii7lebVr4riCgmM6lRiSCnJn2w1Xz7ZZzHKuLrjx5620kw==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + optional: true + + /@rollup/rollup-linux-s390x-gnu@4.13.2: + resolution: {integrity: sha512-l4U0KDFwzD36j7HdfJ5/TveEQ1fUTjFFQP5qIt9gBqBgu1G8/kCaq5Ok05kd5TG9F8Lltf3MoYsUMw3rNlJ0Yg==} + cpu: [s390x] + os: [linux] + requiresBuild: true + optional: true + + /@rollup/rollup-linux-x64-gnu@4.13.2: + resolution: {integrity: sha512-xXMLUAMzrtsvh3cZ448vbXqlUa7ZL8z0MwHp63K2IIID2+DeP5iWIT6g1SN7hg1VxPzqx0xZdiDM9l4n9LRU1A==} + cpu: [x64] + os: [linux] + requiresBuild: true + optional: true + + /@rollup/rollup-linux-x64-musl@4.13.2: + resolution: {integrity: sha512-M/JYAWickafUijWPai4ehrjzVPKRCyDb1SLuO+ZyPfoXgeCEAlgPkNXewFZx0zcnoIe3ay4UjXIMdXQXOZXWqA==} + cpu: [x64] + os: [linux] + requiresBuild: true + optional: true + + /@rollup/rollup-win32-arm64-msvc@4.13.2: + resolution: {integrity: sha512-2YWwoVg9KRkIKaXSh0mz3NmfurpmYoBBTAXA9qt7VXk0Xy12PoOP40EFuau+ajgALbbhi4uTj3tSG3tVseCjuA==} + cpu: [arm64] + os: [win32] + requiresBuild: true + optional: true + + /@rollup/rollup-win32-ia32-msvc@4.13.2: + resolution: {integrity: sha512-2FSsE9aQ6OWD20E498NYKEQLneShWes0NGMPQwxWOdws35qQXH+FplabOSP5zEe1pVjurSDOGEVCE2agFwSEsw==} + cpu: [ia32] + os: [win32] + requiresBuild: true + optional: true + + /@rollup/rollup-win32-x64-msvc@4.13.2: + resolution: {integrity: sha512-7h7J2nokcdPePdKykd8wtc8QqqkqxIrUz7MHj6aNr8waBRU//NLDVnNjQnqQO6fqtjrtCdftpbTuOKAyrAQETQ==} + cpu: [x64] + os: [win32] + requiresBuild: true + optional: true + + /@sinclair/typebox@0.27.8: + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + /@sinonjs/commons@3.0.1: + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + dependencies: + type-detect: 4.0.8 + + /@sinonjs/fake-timers@10.3.0: + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + dependencies: + '@sinonjs/commons': 3.0.1 + + /@slate-yjs/core@1.0.2(slate@0.101.5)(yjs@14.0.0-1): + resolution: {integrity: sha512-X0hLFJbQu9c1ItWBaNuEn0pqcXYK76KCp8C4Gvy/VaTQVMo1VgAb2WiiJ0Je/AyuIYEPPSTNVOcyrGHwgA7e6Q==} + peerDependencies: + slate: '>=0.70.0' + yjs: ^13.5.29 + dependencies: + slate: 0.101.5 + y-protocols: 1.0.6(yjs@14.0.0-1) + yjs: 14.0.0-1 + dev: false + + /@svgr/babel-plugin-add-jsx-attribute@7.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-khWbXesWIP9v8HuKCl2NU2HNAyqpSQ/vkIl36Nbn4HIwEYSRWL0H7Gs6idJdha2DkpFDWlsqMELvoCE8lfFY6Q==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-remove-jsx-attribute@7.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-iiZaIvb3H/c7d3TH2HBeK91uI2rMhZNwnsIrvd7ZwGLkFw6mmunOCoVnjdYua662MqGFxlN9xTq4fv9hgR4VXQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-remove-jsx-empty-expression@7.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-sQQmyo+qegBx8DfFc04PFmIO1FP1MHI1/QEpzcIcclo5OAISsOJPW76ZIs0bDyO/DBSJEa/tDa1W26pVtt0FRw==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-replace-jsx-attribute-value@7.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-i6MaAqIZXDOJeikJuzocByBf8zO+meLwfQ/qMHIjCcvpnfvWf82PFvredEZElErB5glQFJa2KVKk8N2xV6tRRA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-svg-dynamic-title@7.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-BoVSh6ge3SLLpKC0pmmN9DFlqgFy4NxNgdZNLPNJWBUU7TQpDWeBuyVuDW88iXydb5Cv0ReC+ffa5h3VrKfk1w==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-svg-em-dimensions@7.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-tNDcBa+hYn0gO+GkP/AuNKdVtMufVhU9fdzu+vUQsR18RIJ9RWe7h/pSBY338RO08wArntwbDk5WhQBmhf2PaA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-transform-react-native-svg@7.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-qw54u8ljCJYL2KtBOjI5z7Nzg8LnSvQOP5hPKj77H4VQL4+HdKbAT5pnkkZLmHKYwzsIHSYKXxHouD8zZamCFQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.24.3): + resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-transform-svg-component@7.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-CcFECkDj98daOg9jE3Bh3uyD9kzevCAnZ+UtzG6+BQG/jOQ2OA3jHnX6iG4G1MCJkUQFnUvEv33NvQfqrb/F3A==} + engines: {node: '>=12'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==} + engines: {node: '>=12'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + dev: true + + /@svgr/babel-preset@7.0.0(@babel/core@7.24.3): + resolution: {integrity: sha512-EX/NHeFa30j5UjldQGVQikuuQNHUdGmbh9kEpBKofGUtF0GUPJ4T4rhoYiqDAOmBOxojyot36JIFiDUHUK1ilQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@svgr/babel-plugin-add-jsx-attribute': 7.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-remove-jsx-attribute': 7.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-remove-jsx-empty-expression': 7.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-replace-jsx-attribute-value': 7.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-svg-dynamic-title': 7.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-svg-em-dimensions': 7.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-transform-react-native-svg': 7.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-transform-svg-component': 7.0.0(@babel/core@7.24.3) + dev: true + + /@svgr/babel-preset@8.1.0(@babel/core@7.24.3): + resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.3 + '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.24.3) + '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.24.3) + dev: true + + /@svgr/core@7.0.0(typescript@4.9.5): + resolution: {integrity: sha512-ztAoxkaKhRVloa3XydohgQQCb0/8x9T63yXovpmHzKMkHO6pkjdsIAWKOS4bE95P/2quVh1NtjSKlMRNzSBffw==} + engines: {node: '>=14'} + dependencies: + '@babel/core': 7.24.3 + '@svgr/babel-preset': 7.0.0(@babel/core@7.24.3) + camelcase: 6.3.0 + cosmiconfig: 8.3.6(typescript@4.9.5) + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@svgr/core@8.1.0(typescript@4.9.5): + resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} + engines: {node: '>=14'} + dependencies: + '@babel/core': 7.24.3 + '@svgr/babel-preset': 8.1.0(@babel/core@7.24.3) + camelcase: 6.3.0 + cosmiconfig: 8.3.6(typescript@4.9.5) + snake-case: 3.0.4 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@svgr/hast-util-to-babel-ast@7.0.0: + resolution: {integrity: sha512-42Ej9sDDEmsJKjrfQ1PHmiDiHagh/u9AHO9QWbeNx4KmD9yS5d1XHmXUNINfUcykAU+4431Cn+k6Vn5mWBYimQ==} + engines: {node: '>=14'} + dependencies: + '@babel/types': 7.24.0 + entities: 4.5.0 + dev: true + + /@svgr/plugin-jsx@7.0.0: + resolution: {integrity: sha512-SWlTpPQmBUtLKxXWgpv8syzqIU8XgFRvyhfkam2So8b3BE0OS0HPe5UfmlJ2KIC+a7dpuuYovPR2WAQuSyMoPw==} + engines: {node: '>=14'} + dependencies: + '@babel/core': 7.24.3 + '@svgr/babel-preset': 7.0.0(@babel/core@7.24.3) + '@svgr/hast-util-to-babel-ast': 7.0.0 + svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color + dev: true + + /@svgr/plugin-svgo@8.0.1(@svgr/core@8.1.0)(typescript@4.9.5): + resolution: {integrity: sha512-29OJ1QmJgnohQHDAgAuY2h21xWD6TZiXji+hnx+W635RiXTAlHTbjrZDktfqzkN0bOeQEtNe+xgq73/XeWFfSg==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + dependencies: + '@svgr/core': 8.1.0(typescript@4.9.5) + cosmiconfig: 8.3.6(typescript@4.9.5) + deepmerge: 4.3.1 + svgo: 3.2.0 + transitivePeerDependencies: + - typescript + dev: true + + /@tauri-apps/api@1.5.6: + resolution: {integrity: sha512-LH5ToovAHnDVe5Qa9f/+jW28I6DeMhos8bNDtBOmmnaDpPmJmYLyHdeDblAWWWYc7KKRDg9/66vMuKyq0WIeFA==} + engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'} + dev: false + + /@tauri-apps/cli-darwin-arm64@1.5.11: + resolution: {integrity: sha512-2NLSglDb5VfvTbMtmOKWyD+oaL/e8Z/ZZGovHtUFyUSFRabdXc6cZOlcD1BhFvYkHqm+TqGaz5qtPR5UbqDs8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-darwin-x64@1.5.11: + resolution: {integrity: sha512-/RQllHiJRH2fJOCudtZlaUIjofkHzP3zZgxi71ZUm7Fy80smU5TDfwpwOvB0wSVh0g/ciDjMArCSTo0MRvL+ag==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-linux-arm-gnueabihf@1.5.11: + resolution: {integrity: sha512-IlBuBPKmMm+a5LLUEK6a21UGr9ZYd6zKuKLq6IGM4tVweQa8Sf2kP2Nqs74dMGIUrLmMs0vuqdURpykQg+z4NQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-linux-arm64-gnu@1.5.11: + resolution: {integrity: sha512-w+k1bNHCU/GbmXshtAhyTwqosThUDmCEFLU4Zkin1vl2fuAtQry2RN7thfcJFepblUGL/J7yh3Q/0+BCjtspKQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-linux-arm64-musl@1.5.11: + resolution: {integrity: sha512-PN6/dl+OfYQ/qrAy4HRAfksJ2AyWQYn2IA/2Wwpaa7SDRz2+hzwTQkvajuvy0sQ5L2WCG7ymFYRYMbpC6Hk9Pg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-linux-x64-gnu@1.5.11: + resolution: {integrity: sha512-MTVXLi89Nj7Apcvjezw92m7ZqIDKT5SFKZtVPCg6RoLUBTzko/BQoXYIRWmdoz2pgkHDUHgO2OMJ8oKzzddXbw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-linux-x64-musl@1.5.11: + resolution: {integrity: sha512-kwzAjqFpz7rvTs7WGZLy/a5nS5t15QKr3E9FG95MNF0exTl3d29YoAUAe1Mn0mOSrTJ9Z+vYYAcI/QdcsGBP+w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-win32-arm64-msvc@1.5.11: + resolution: {integrity: sha512-L+5NZ/rHrSUrMxjj6YpFYCXp6wHnq8c8SfDTBOX8dO8x+5283/vftb4vvuGIsLS4UwUFXFnLt3XQr44n84E67Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-win32-ia32-msvc@1.5.11: + resolution: {integrity: sha512-oVlD9IVewrY0lZzTdb71kNXkjdgMqFq+ohb67YsJb4Rf7o8A9DTlFds1XLCe3joqLMm4M+gvBKD7YnGIdxQ9vA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli-win32-x64-msvc@1.5.11: + resolution: {integrity: sha512-1CexcqUFCis5ypUIMOKllxUBrna09McbftWENgvVXMfA+SP+yPDPAVb8fIvUcdTIwR/yHJwcIucmTB4anww4vg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@tauri-apps/cli@1.5.11: + resolution: {integrity: sha512-B475D7phZrq5sZ3kDABH4g2mEoUIHtnIO+r4ZGAAfsjMbZCwXxR/jlMGTEL+VO3YzjpF7gQe38IzB4vLBbVppw==} + engines: {node: '>= 10'} + hasBin: true + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 1.5.11 + '@tauri-apps/cli-darwin-x64': 1.5.11 + '@tauri-apps/cli-linux-arm-gnueabihf': 1.5.11 + '@tauri-apps/cli-linux-arm64-gnu': 1.5.11 + '@tauri-apps/cli-linux-arm64-musl': 1.5.11 + '@tauri-apps/cli-linux-x64-gnu': 1.5.11 + '@tauri-apps/cli-linux-x64-musl': 1.5.11 + '@tauri-apps/cli-win32-arm64-msvc': 1.5.11 + '@tauri-apps/cli-win32-ia32-msvc': 1.5.11 + '@tauri-apps/cli-win32-x64-msvc': 1.5.11 + dev: true + + /@testing-library/dom@10.1.0: + resolution: {integrity: sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==} + engines: {node: '>=18'} + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/runtime': 7.24.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + dev: true + + /@testing-library/react@16.0.0(@testing-library/dom@10.1.0)(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-guuxUKRWQ+FgNX0h0NS0FIq3Q3uLtWVpBzcLOggmfMoUpgBnzBzvLLd4fbm6yS8ydJd94cIfY4yP9qUQjM2KwQ==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 + '@types/react-dom': ^18.0.0 + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.6 + '@testing-library/dom': 10.1.0 + '@types/react': 18.2.66 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: true + + /@tootallnate/once@2.0.0: + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + dev: true + + /@trysound/sax@0.2.0: + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + dev: true + + /@tsconfig/node10@1.0.11: + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + dev: true + + /@tsconfig/node12@1.0.11: + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + dev: true + + /@tsconfig/node14@1.0.3: + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + dev: true + + /@tsconfig/node16@1.0.4: + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + dev: true + + /@types/aria-query@5.0.4: + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + dev: true + + /@types/babel__core@7.20.5: + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + dependencies: + '@babel/parser': 7.24.1 + '@babel/types': 7.24.0 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.5 + + /@types/babel__generator@7.6.8: + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + dependencies: + '@babel/types': 7.24.0 + + /@types/babel__template@7.4.4: + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + dependencies: + '@babel/parser': 7.24.1 + '@babel/types': 7.24.0 + + /@types/babel__traverse@7.20.5: + resolution: {integrity: sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==} + dependencies: + '@babel/types': 7.24.0 + + /@types/cypress-image-snapshot@3.1.9: + resolution: {integrity: sha512-vcFgB8AfuM0dtupCHAGgapUbjRNWmMDtpzzpXz5gexWOHW4y/n10XRi5/y5Xpi0Ztku+X4dnRVMqQa5IDcy3Ig==} + dev: true + + /@types/date-arithmetic@4.1.4: + resolution: {integrity: sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw==} + dev: true + + /@types/eslint-scope@3.7.7: + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + dependencies: + '@types/eslint': 8.56.10 + '@types/estree': 1.0.5 + dev: true + + /@types/eslint@8.56.10: + resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==} + dependencies: + '@types/estree': 1.0.5 + '@types/json-schema': 7.0.15 + dev: true + + /@types/estree@1.0.5: + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + + /@types/google-protobuf@3.15.12: + resolution: {integrity: sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==} + dev: true + + /@types/graceful-fs@4.1.9: + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + dependencies: + '@types/node': 20.11.30 + + /@types/hast@3.0.4: + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + dependencies: + '@types/unist': 3.0.3 + dev: false + + /@types/hoist-non-react-statics@3.3.5: + resolution: {integrity: sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==} + dependencies: + '@types/react': 18.2.66 + hoist-non-react-statics: 3.3.2 + dev: false + + /@types/is-hotkey@0.1.10: + resolution: {integrity: sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ==} + dev: false + + /@types/is-hotkey@0.1.7: + resolution: {integrity: sha512-yB5C7zcOM7idwYZZ1wKQ3pTfjA9BbvFqRWvKB46GFddxnJtHwi/b9y84ykQtxQPg5qhdpg4Q/kWU3EGoCTmLzQ==} + dev: true + + /@types/istanbul-lib-coverage@2.0.6: + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + /@types/istanbul-lib-report@3.0.3: + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + /@types/istanbul-reports@3.0.4: + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + /@types/jest@29.5.3: + resolution: {integrity: sha512-1Nq7YrO/vJE/FYnqYyw0FS8LdrjExSgIiHyKg7xPpn+yi8Q4huZryKnkJatN1ZRH89Kw2v33/8ZMB7DuZeSLlA==} + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + dev: true + + /@types/jsdom@20.0.1: + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + dependencies: + '@types/node': 20.11.30 + '@types/tough-cookie': 4.0.5 + parse5: 7.1.2 + dev: true + + /@types/json-schema@7.0.15: + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + dev: true + + /@types/katex@0.16.0: + resolution: {integrity: sha512-hz+S3nV6Mym5xPbT9fnO8dDhBFQguMYpY0Ipxv06JMi1ORgnEM4M1ymWDUhUNer3ElLmT583opRo4RzxKmh9jw==} + dev: true + + /@types/lodash-es@4.17.11: + resolution: {integrity: sha512-eCw8FYAWHt2DDl77s+AMLLzPn310LKohruumpucZI4oOFJkIgnlaJcy23OKMJxx4r9PeTF13Gv6w+jqjWQaYUg==} + dependencies: + '@types/lodash': 4.17.0 + dev: true + + /@types/lodash@4.17.0: + resolution: {integrity: sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==} + + /@types/mdast@4.0.4: + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + dependencies: + '@types/unist': 3.0.3 + dev: false + + /@types/node@20.11.30: + resolution: {integrity: sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==} + dependencies: + undici-types: 5.26.5 + + /@types/numeral@2.0.5: + resolution: {integrity: sha512-kH8I7OSSwQu9DS9JYdFWbuvhVzvFRoCPCkGxNwoGgaPeDfEPJlcxNvEOypZhQ3XXHsGbfIuYcxcJxKUfJHnRfw==} + dev: true + + /@types/parse-json@4.0.2: + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + dev: false + + /@types/prismjs@1.26.0: + resolution: {integrity: sha512-ZTaqn/qSqUuAq1YwvOFQfVW1AR/oQJlLSZVustdjwI+GZ8kr0MSHBj0tsXPW1EqHubx50gtBEjbPGsdZwQwCjQ==} + dev: true + + /@types/prop-types@15.7.12: + resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} + + /@types/quill@2.0.10: + resolution: {integrity: sha512-L6OHONEj2v4NRbWQOsn7j1N0SyzhRR3M4g1M6j/uuIwIsIW2ShWHhwbqNvH8hSmVktzqu0lITfdnqVOQ4qkrhA==} + dependencies: + parchment: 1.1.4 + quill-delta: 4.2.2 + dev: true + + /@types/react-beautiful-dnd@13.1.3: + resolution: {integrity: sha512-BNdmvONKtsrZq3AGrujECQrIn8cDT+fZsxBLXuX3YWY/nHfZinUFx4W88eS0rkcXzuLbXpKOsu/1WCMPMLEpPg==} + dependencies: + '@types/react': 18.2.66 + dev: true + + /@types/react-big-calendar@1.8.9: + resolution: {integrity: sha512-HIHLUxR3PzWHrFdZ00VnCMvDjAh5uzlL0vMC2b7tL3bKaAJsqq9T8h+x0GVeDbZfMfHAd1cs5tZBhVvourNJXQ==} + dependencies: + '@types/date-arithmetic': 4.1.4 + '@types/prop-types': 15.7.12 + '@types/react': 18.2.66 + dev: true + + /@types/react-color@3.0.6: + resolution: {integrity: sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==} + dependencies: + '@types/react': 18.2.66 + '@types/reactcss': 1.2.12 + dev: true + + /@types/react-custom-scrollbars@4.0.13: + resolution: {integrity: sha512-t+15reWgAE1jXlrhaZoxjuH/SQf+EG0rzAzSCzTIkSiP5CDT7KhoExNPwIa6uUxtPkjc3gdW/ry7GetLEwCfGA==} + dependencies: + '@types/react': 18.2.66 + dev: true + + /@types/react-datepicker@4.19.3(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-85F9eKWu9fGiD9r4KVVMPYAdkJJswR3Wci9PvqplmB6T+D+VbUqPeKtifg96NZ4nEhufjehW+SX4JLrEWVplWw==} + dependencies: + '@popperjs/core': 2.11.8 + '@types/react': 18.2.66 + date-fns: 2.30.0 + react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0) + transitivePeerDependencies: + - react + - react-dom + dev: true + + /@types/react-dom@18.2.22: + resolution: {integrity: sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ==} + dependencies: + '@types/react': 18.2.66 + + /@types/react-helmet@6.1.11: + resolution: {integrity: sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==} + dependencies: + '@types/react': 18.2.66 + dev: true + + /@types/react-katex@3.0.0: + resolution: {integrity: sha512-AiHHXh71a2M7Z6z1wj6iA23SkiRF9r0neHUdu8zjU/cT3MyLxDefYHbcceKhV/gjDEZgF3YaiNHyPNtoGUjPvg==} + dependencies: + '@types/react': 18.2.66 + dev: true + + /@types/react-measure@2.0.12: + resolution: {integrity: sha512-Y6V11CH6bU7RhqrIdENPwEUZlPXhfXNGylMNnGwq5TAEs2wDoBA3kSVVM/EQ8u72sz5r9ja+7W8M8PIVcS841Q==} + dependencies: + '@types/react': 18.2.66 + dev: true + + /@types/react-redux@7.1.33: + resolution: {integrity: sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==} + dependencies: + '@types/hoist-non-react-statics': 3.3.5 + '@types/react': 18.2.66 + hoist-non-react-statics: 3.3.2 + redux: 4.2.1 + dev: false + + /@types/react-swipeable-views@0.13.5: + resolution: {integrity: sha512-ni6WjO7gBq2xB2Y/ZiRdQOgjGOxIik5ow2s7xKieDq8DxsXTdV46jJslSBVK2yoIJHf6mG3uqNTwxwgzbXRRzg==} + dependencies: + '@types/react': 18.2.66 + dev: false + + /@types/react-transition-group@4.4.10: + resolution: {integrity: sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==} + dependencies: + '@types/react': 18.2.66 + dev: false + + /@types/react-transition-group@4.4.6: + resolution: {integrity: sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==} + dependencies: + '@types/react': 18.2.66 + dev: true + + /@types/react-window@1.8.8: + resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==} + dependencies: + '@types/react': 18.2.66 + + /@types/react@18.2.66: + resolution: {integrity: sha512-OYTmMI4UigXeFMF/j4uv0lBBEbongSgptPrHBxqME44h9+yNov+oL6Z3ocJKo0WyXR84sQUNeyIp9MRfckvZpg==} + dependencies: + '@types/prop-types': 15.7.12 + '@types/scheduler': 0.23.0 + csstype: 3.1.3 + + /@types/reactcss@1.2.12: + resolution: {integrity: sha512-BrXUQ86/wbbFiZv8h/Q1/Q1XOsaHneYmCb/tHe9+M8XBAAUc2EHfdY0DY22ZZjVSaXr5ix7j+zsqO2eGZub8lQ==} + dependencies: + '@types/react': 18.2.66 + dev: true + + /@types/scheduler@0.23.0: + resolution: {integrity: sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==} + + /@types/semver@7.5.8: + resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} + dev: true + + /@types/sinonjs__fake-timers@8.1.1: + resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==} + dev: true + + /@types/sizzle@2.3.8: + resolution: {integrity: sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==} + dev: true + + /@types/stack-utils@2.0.3: + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + /@types/strip-bom@3.0.0: + resolution: {integrity: sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==} + dev: true + + /@types/strip-json-comments@0.0.30: + resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} + dev: true + + /@types/tough-cookie@4.0.5: + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + dev: true + + /@types/unist@3.0.3: + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + dev: false + + /@types/use-sync-external-store@0.0.3: + resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==} + dev: false + + /@types/utf8@3.0.1: + resolution: {integrity: sha512-1EkWuw7rT3BMz2HpmcEOr/HL61mWNA6Ulr/KdbXR9AI0A55wD4Qfv8hizd8Q1DnknSIzzDvQmvvY/guvX7jjZA==} + dev: true + + /@types/uuid@9.0.1: + resolution: {integrity: sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==} + dev: true + + /@types/validator@13.11.9: + resolution: {integrity: sha512-FCTsikRozryfayPuiI46QzH3fnrOoctTjvOYZkho9BTFLCOZ2rgZJHMOVgCOfttjPJcgOx52EpkY0CMfy87MIw==} + dev: true + + /@types/warning@3.0.3: + resolution: {integrity: sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==} + dev: false + + /@types/yargs-parser@21.0.3: + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + /@types/yargs@17.0.32: + resolution: {integrity: sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==} + dependencies: + '@types/yargs-parser': 21.0.3 + + /@types/yauzl@2.10.3: + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + requiresBuild: true + dependencies: + '@types/node': 20.11.30 + dev: true + optional: true + + /@typescript-eslint/eslint-plugin@7.2.0(@typescript-eslint/parser@7.2.0)(eslint@8.57.0)(typescript@4.9.5): + resolution: {integrity: sha512-mdekAHOqS9UjlmyF/LSs6AIEvfceV749GFxoBAjwAv0nkevfKHWQFDMcBZWUiIC5ft6ePWivXoS36aKQ0Cy3sw==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@eslint-community/regexpp': 4.10.0 + '@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@4.9.5) + '@typescript-eslint/scope-manager': 7.2.0 + '@typescript-eslint/type-utils': 7.2.0(eslint@8.57.0)(typescript@4.9.5) + '@typescript-eslint/utils': 7.2.0(eslint@8.57.0)(typescript@4.9.5) + '@typescript-eslint/visitor-keys': 7.2.0 + debug: 4.3.4(supports-color@8.1.1) + eslint: 8.57.0 + graphemer: 1.4.0 + ignore: 5.3.1 + natural-compare: 1.4.0 + semver: 7.6.0 + ts-api-utils: 1.3.0(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@4.9.5): + resolution: {integrity: sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/scope-manager': 7.2.0 + '@typescript-eslint/types': 7.2.0 + '@typescript-eslint/typescript-estree': 7.2.0(typescript@4.9.5) + '@typescript-eslint/visitor-keys': 7.2.0 + debug: 4.3.4(supports-color@8.1.1) + eslint: 8.57.0 + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/scope-manager@7.2.0: + resolution: {integrity: sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==} + engines: {node: ^16.0.0 || >=18.0.0} + dependencies: + '@typescript-eslint/types': 7.2.0 + '@typescript-eslint/visitor-keys': 7.2.0 + dev: true + + /@typescript-eslint/type-utils@7.2.0(eslint@8.57.0)(typescript@4.9.5): + resolution: {integrity: sha512-xHi51adBHo9O9330J8GQYQwrKBqbIPJGZZVQTHHmy200hvkLZFWJIFtAG/7IYTWUyun6DE6w5InDReePJYJlJA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/typescript-estree': 7.2.0(typescript@4.9.5) + '@typescript-eslint/utils': 7.2.0(eslint@8.57.0)(typescript@4.9.5) + debug: 4.3.4(supports-color@8.1.1) + eslint: 8.57.0 + ts-api-utils: 1.3.0(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/types@7.2.0: + resolution: {integrity: sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==} + engines: {node: ^16.0.0 || >=18.0.0} + dev: true + + /@typescript-eslint/typescript-estree@7.2.0(typescript@4.9.5): + resolution: {integrity: sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 7.2.0 + '@typescript-eslint/visitor-keys': 7.2.0 + debug: 4.3.4(supports-color@8.1.1) + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.6.0 + ts-api-utils: 1.3.0(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/utils@7.2.0(eslint@8.57.0)(typescript@4.9.5): + resolution: {integrity: sha512-YfHpnMAGb1Eekpm3XRK8hcMwGLGsnT6L+7b2XyRv6ouDuJU1tZir1GS2i0+VXRatMwSI1/UfcyPe53ADkU+IuA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^8.56.0 + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@types/json-schema': 7.0.15 + '@types/semver': 7.5.8 + '@typescript-eslint/scope-manager': 7.2.0 + '@typescript-eslint/types': 7.2.0 + '@typescript-eslint/typescript-estree': 7.2.0(typescript@4.9.5) + eslint: 8.57.0 + semver: 7.6.0 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@typescript-eslint/visitor-keys@7.2.0: + resolution: {integrity: sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==} + engines: {node: ^16.0.0 || >=18.0.0} + dependencies: + '@typescript-eslint/types': 7.2.0 + eslint-visitor-keys: 3.4.3 + dev: true + + /@ungap/structured-clone@1.2.0: + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + + /@vitejs/plugin-react@4.2.1(vite@5.2.0): + resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/plugin-transform-react-jsx-self': 7.24.1(@babel/core@7.24.3) + '@babel/plugin-transform-react-jsx-source': 7.24.1(@babel/core@7.24.3) + '@types/babel__core': 7.20.5 + react-refresh: 0.14.0 + vite: 5.2.0(@types/node@20.11.30)(sass@1.77.2) + transitivePeerDependencies: + - supports-color + dev: true + + /@webassemblyjs/ast@1.12.1: + resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==} + dependencies: + '@webassemblyjs/helper-numbers': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + dev: true + + /@webassemblyjs/floating-point-hex-parser@1.11.6: + resolution: {integrity: sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==} + dev: true + + /@webassemblyjs/helper-api-error@1.11.6: + resolution: {integrity: sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==} + dev: true + + /@webassemblyjs/helper-buffer@1.12.1: + resolution: {integrity: sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==} + dev: true + + /@webassemblyjs/helper-numbers@1.11.6: + resolution: {integrity: sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==} + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.11.6 + '@webassemblyjs/helper-api-error': 1.11.6 + '@xtuc/long': 4.2.2 + dev: true + + /@webassemblyjs/helper-wasm-bytecode@1.11.6: + resolution: {integrity: sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==} + dev: true + + /@webassemblyjs/helper-wasm-section@1.12.1: + resolution: {integrity: sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/wasm-gen': 1.12.1 + dev: true + + /@webassemblyjs/ieee754@1.11.6: + resolution: {integrity: sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==} + dependencies: + '@xtuc/ieee754': 1.2.0 + dev: true + + /@webassemblyjs/leb128@1.11.6: + resolution: {integrity: sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==} + dependencies: + '@xtuc/long': 4.2.2 + dev: true + + /@webassemblyjs/utf8@1.11.6: + resolution: {integrity: sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==} + dev: true + + /@webassemblyjs/wasm-edit@1.12.1: + resolution: {integrity: sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/helper-wasm-section': 1.12.1 + '@webassemblyjs/wasm-gen': 1.12.1 + '@webassemblyjs/wasm-opt': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + '@webassemblyjs/wast-printer': 1.12.1 + dev: true + + /@webassemblyjs/wasm-gen@1.12.1: + resolution: {integrity: sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/ieee754': 1.11.6 + '@webassemblyjs/leb128': 1.11.6 + '@webassemblyjs/utf8': 1.11.6 + dev: true + + /@webassemblyjs/wasm-opt@1.12.1: + resolution: {integrity: sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/wasm-gen': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + dev: true + + /@webassemblyjs/wasm-parser@1.12.1: + resolution: {integrity: sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-api-error': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/ieee754': 1.11.6 + '@webassemblyjs/leb128': 1.11.6 + '@webassemblyjs/utf8': 1.11.6 + dev: true + + /@webassemblyjs/wast-printer@1.12.1: + resolution: {integrity: sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@xtuc/long': 4.2.2 + dev: true + + /@xmldom/xmldom@0.8.10: + resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} + engines: {node: '>=10.0.0'} + dev: true + + /@xtuc/ieee754@1.2.0: + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + dev: true + + /@xtuc/long@4.2.2: + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + dev: true + + /abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + dev: true + + /abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + dependencies: + event-target-shim: 5.0.1 + dev: true + + /acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + dependencies: + acorn: 8.11.3 + acorn-walk: 8.3.2 + dev: true + + /acorn-import-assertions@1.9.0(acorn@8.11.3): + resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} + peerDependencies: + acorn: ^8 + dependencies: + acorn: 8.11.3 + dev: true + + /acorn-jsx@5.3.2(acorn@8.11.3): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.11.3 + dev: true + + /acorn-node@1.8.2: + resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==} + dependencies: + acorn: 7.4.1 + acorn-walk: 7.2.0 + xtend: 4.0.2 + dev: true + + /acorn-walk@7.2.0: + resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==} + engines: {node: '>=0.4.0'} + dev: true + + /acorn-walk@8.3.2: + resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} + engines: {node: '>=0.4.0'} + dev: true + + /acorn@7.4.1: + resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /acorn@8.11.3: + resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /add-px-to-style@1.0.0: + resolution: {integrity: sha512-YMyxSlXpPjD8uWekCQGuN40lV4bnZagUwqa2m/uFv1z/tNImSk9fnXVMUI5qwME/zzI3MMQRvjZ+69zyfSSyew==} + dev: false + + /agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.3.4(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + dev: true + + /aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + dev: true + + /ajv-formats@2.1.1(ajv@8.14.0): + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + dependencies: + ajv: 8.14.0 + dev: true + + /ajv-keywords@3.5.2(ajv@6.12.6): + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + dependencies: + ajv: 6.12.6 + dev: true + + /ajv-keywords@5.1.0(ajv@8.14.0): + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + dependencies: + ajv: 8.14.0 + fast-deep-equal: 3.1.3 + dev: true + + /ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + /ajv@8.14.0: + resolution: {integrity: sha512-oYs1UUtO97ZO2lJ4bwnWeQW8/zvOIQLGKcvPTsWmvc2SYgBb+upuNS5NxoLaMU4h8Ju3Nbj6Cq8mD2LQoqVKFA==} + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + dev: true + + /ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + dev: true + + /ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.21.3 + + /ansi-regex@2.1.1: + resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} + engines: {node: '>=0.10.0'} + dev: true + + /ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + /ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + dev: true + + /ansi-styles@2.2.1: + resolution: {integrity: sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==} + engines: {node: '>=0.10.0'} + dev: true + + /ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + dependencies: + color-convert: 1.9.3 + + /ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + + /ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + /ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + dev: true + + /anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + /app-path@3.3.0: + resolution: {integrity: sha512-EAgEXkdcxH1cgEePOSsmUtw9ItPl0KTxnh/pj9ZbhvbKbij9x0oX6PWpGnorDr0DS5AosLgoa5n3T/hZmKQpYA==} + engines: {node: '>=8'} + dependencies: + execa: 1.0.0 + dev: true + + /append-transform@2.0.0: + resolution: {integrity: sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==} + engines: {node: '>=8'} + dependencies: + default-require-extensions: 3.0.1 + dev: true + + /arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + dev: true + + /archy@1.0.0: + resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==} + dev: true + + /arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + dev: true + + /arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + dev: true + + /argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + dependencies: + sprintf-js: 1.0.3 + + /argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + dev: true + + /aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + dependencies: + dequal: 2.0.3 + dev: true + + /array-buffer-byte-length@1.0.1: + resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + is-array-buffer: 3.0.4 + dev: true + + /array-includes@3.1.8: + resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + get-intrinsic: 1.2.4 + is-string: 1.0.7 + dev: true + + /array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + dev: true + + /array.prototype.flat@1.3.2: + resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 + dev: true + + /array.prototype.flatmap@1.3.2: + resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 + dev: true + + /array.prototype.tosorted@1.1.3: + resolution: {integrity: sha512-/DdH4TiTmOKzyQbp/eadcCVexiCb36xJg7HshYOYJnNZFDj33GEv0P7GxsynpShhq4OLYJzbGcBDkLsDt7MnNg==} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-shim-unscopables: 1.0.2 + dev: true + + /arraybuffer.prototype.slice@1.0.3: + resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + is-array-buffer: 3.0.4 + is-shared-array-buffer: 1.0.3 + dev: true + + /asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + dependencies: + safer-buffer: 2.1.2 + + /assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + /astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + dev: true + + /async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + dependencies: + retry: 0.13.1 + dev: false + + /async@3.2.5: + resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} + dev: true + + /asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + /at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + dev: true + + /atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + dev: true + + /autoprefixer@10.4.13(postcss@8.4.21): + resolution: {integrity: sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + dependencies: + browserslist: 4.23.0 + caniuse-lite: 1.0.30001603 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.0.0 + postcss: 8.4.21 + postcss-value-parser: 4.2.0 + dev: true + + /available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + dependencies: + possible-typed-array-names: 1.0.0 + dev: true + + /aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + /aws4@1.12.0: + resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} + + /axios-mock-adapter@2.0.0(axios@1.7.2): + resolution: {integrity: sha512-D/K0J5Zm6KvaMTnsWrBQZWLzKN9GxUFZEa0mx2qeEHXDeTugCoplWehy8y36dj5vuSjhe1u/Dol8cZ8lzzmDew==} + peerDependencies: + axios: '>= 0.17.0' + dependencies: + axios: 1.7.2 + fast-deep-equal: 3.1.3 + is-buffer: 2.0.5 + dev: true + + /axios@1.7.2: + resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==} + dependencies: + follow-redirects: 1.15.6 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + /b4a@1.6.6: + resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==} + dev: true + + /babel-jest@29.6.2(@babel/core@7.24.3): + resolution: {integrity: sha512-BYCzImLos6J3BH/+HvUCHG1dTf2MzmAB4jaVxHV+29RZLjR29XuYTmsf2sdDwkrb+FczkGo3kOhE7ga6sI0P4A==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + dependencies: + '@babel/core': 7.24.3 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.24.3) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-jest@29.7.0(@babel/core@7.24.3): + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + dependencies: + '@babel/core': 7.24.3 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.24.3) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + /babel-loader@9.1.3(@babel/core@7.24.3)(webpack@5.91.0): + resolution: {integrity: sha512-xG3ST4DglodGf8qSwv0MdeWLhrDsw/32QMdTO5T1ZIp9gQur0HkCyFs7Awskr10JKXFXwpAhiCuYX5oGXnRGbw==} + engines: {node: '>= 14.15.0'} + peerDependencies: + '@babel/core': ^7.12.0 + webpack: '>=5' + dependencies: + '@babel/core': 7.24.3 + find-cache-dir: 4.0.0 + schema-utils: 4.2.0 + webpack: 5.91.0 + dev: true + + /babel-plugin-import@1.13.8: + resolution: {integrity: sha512-36babpjra5m3gca44V6tSTomeBlPA7cHUynrE2WiQIm3rEGD9xy28MKsx5IdO45EbnpJY7Jrgd00C6Dwt/l/2Q==} + dependencies: + '@babel/helper-module-imports': 7.24.3 + dev: true + + /babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + dependencies: + '@babel/helper-plugin-utils': 7.24.0 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + /babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/template': 7.24.0 + '@babel/types': 7.24.0 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.20.5 + + /babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + dependencies: + '@babel/runtime': 7.24.6 + cosmiconfig: 7.1.0 + resolve: 1.22.8 + dev: false + + /babel-plugin-polyfill-corejs2@0.4.11(@babel/core@7.24.3): + resolution: {integrity: sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/compat-data': 7.24.7 + '@babel/core': 7.24.3 + '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.24.3) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-plugin-polyfill-corejs3@0.10.4(@babel/core@7.24.3): + resolution: {integrity: sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.24.3) + core-js-compat: 3.37.1 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-plugin-polyfill-regenerator@0.6.2(@babel/core@7.24.3): + resolution: {integrity: sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.24.3) + transitivePeerDependencies: + - supports-color + dev: true + + /babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.3): + resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.3) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.24.3) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.3) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.3) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.3) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.3) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.3) + + /babel-preset-jest@29.6.3(@babel/core@7.24.3): + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.3 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.3) + + /bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + dev: false + + /balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + /bare-events@2.2.2: + resolution: {integrity: sha512-h7z00dWdG0PYOQEvChhOSWvOfkIKsdZGkWr083FgN/HyoQuebSew/cgirYqh9SCuy/hRvxc5Vy6Fw8xAmYHLkQ==} + requiresBuild: true + dev: true + optional: true + + /base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + dev: true + + /bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + dependencies: + tweetnacl: 0.14.5 + + /binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + /bind-event-listener@3.0.0: + resolution: {integrity: sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==} + dev: false + + /blob-util@2.0.2: + resolution: {integrity: sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==} + dev: true + + /bluebird@3.7.1: + resolution: {integrity: sha512-DdmyoGCleJnkbp3nkbxTLJ18rjDsE4yCggEwKNXkeV123sPNfOCYeDoeuOY+F2FrSjO1YXcTU+dsy96KMy+gcg==} + dev: true + + /bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + dev: true + + /boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + dev: true + + /brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + /brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + + /braces@3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + + /browserify-zlib@0.1.4: + resolution: {integrity: sha512-19OEpq7vWgsH6WkvkBJQDFvJS1uPcbFOQ4v9CU839dO+ZZXUZO6XpE6hNCqvlIIj+4fZvRiJ6DsAQ382GwiyTQ==} + dependencies: + pako: 0.2.9 + dev: true + + /browserslist@4.23.0: + resolution: {integrity: sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001603 + electron-to-chromium: 1.4.722 + node-releases: 2.0.14 + update-browserslist-db: 1.0.13(browserslist@4.23.0) + + /bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + dependencies: + fast-json-stable-stringify: 2.1.0 + dev: true + + /bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + dependencies: + node-int64: 0.4.0 + + /buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + dev: true + + /buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + /buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: true + + /buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: true + + /cachedir@2.4.0: + resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} + engines: {node: '>=6'} + dev: true + + /caching-transform@4.0.0: + resolution: {integrity: sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==} + engines: {node: '>=8'} + dependencies: + hasha: 5.2.2 + make-dir: 3.1.0 + package-hash: 4.0.0 + write-file-atomic: 3.0.3 + dev: true + + /call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + set-function-length: 1.2.2 + + /callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + /camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + dependencies: + pascal-case: 3.1.2 + tslib: 2.6.2 + dev: true + + /camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + dev: true + + /camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + /camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + /caniuse-lite@1.0.30001603: + resolution: {integrity: sha512-iL2iSS0eDILMb9n5yKQoTBim9jMZ0Yrk8g0N9K7UzYyWnfIKzXBZD5ngpM37ZcL/cv0Mli8XtVMRYMQAfFpi5Q==} + + /capital-case@1.0.4: + resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} + dependencies: + no-case: 3.0.4 + tslib: 2.6.2 + upper-case-first: 2.0.2 + dev: true + + /caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + + /ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + dev: false + + /chalk@1.1.3: + resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} + engines: {node: '>=0.10.0'} + dependencies: + ansi-styles: 2.2.1 + escape-string-regexp: 1.0.5 + has-ansi: 2.0.0 + strip-ansi: 3.0.1 + supports-color: 2.0.0 + dev: true + + /chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + /chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + /chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + dev: true + + /change-case@4.1.2: + resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} + dependencies: + camel-case: 4.1.2 + capital-case: 1.0.4 + constant-case: 3.0.4 + dot-case: 3.0.4 + header-case: 2.0.4 + no-case: 3.0.4 + param-case: 3.0.4 + pascal-case: 3.1.2 + path-case: 3.0.4 + sentence-case: 3.0.4 + snake-case: 3.0.4 + tslib: 2.6.2 + dev: true + + /char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + /character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + dev: false + + /character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + dev: false + + /check-more-types@2.24.0: + resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==} + engines: {node: '>= 0.8.0'} + dev: true + + /cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + dev: true + + /cheerio@1.0.0-rc.12: + resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} + engines: {node: '>= 6'} + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.1.0 + htmlparser2: 8.0.2 + parse5: 7.1.2 + parse5-htmlparser2-tree-adapter: 7.0.0 + dev: true + + /chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + /chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + dev: true + + /ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + /cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + + /classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + dev: false + + /clean-css@5.3.3: + resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} + engines: {node: '>= 10.0'} + dependencies: + source-map: 0.6.1 + dev: true + + /clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + dev: true + + /cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + dependencies: + restore-cursor: 3.1.0 + dev: true + + /cli-table3@0.6.4: + resolution: {integrity: sha512-Lm3L0p+/npIQWNIiyF/nAn7T5dnOwR3xNTHXYEBFBFVPXzCVNZ5lqEC/1eo/EVfpDsQ1I+TX4ORPQgp+UI0CRw==} + engines: {node: 10.* || >= 12.*} + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + dev: true + + /cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + dev: true + + /cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + dev: true + + /cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + /clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + dev: true + + /clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + dev: false + + /clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + dev: false + + /clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + dev: false + + /co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + /collect-v8-coverage@1.0.2: + resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} + + /color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + dependencies: + color-name: 1.1.3 + + /color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + + /color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + /color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + /colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + dev: true + + /colorthief@2.4.0: + resolution: {integrity: sha512-0U48RGNRo5fVO+yusBwgp+d3augWSorXabnqXUu9SabEhCpCgZJEUjUTTI41OOBBYuMMxawa3177POT6qLfLeQ==} + dependencies: + '@lokesh.dhakar/quantize': 1.3.0 + get-pixels: 3.3.3 + dev: false + + /combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + dependencies: + delayed-stream: 1.0.0 + + /comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + dev: false + + /commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + dev: true + + /commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + dev: true + + /commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + dev: true + + /commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + /common-path-prefix@3.0.0: + resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} + dev: true + + /common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + dev: true + + /commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + dev: true + + /compute-scroll-into-view@3.1.0: + resolution: {integrity: sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==} + dev: false + + /concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + /connect-history-api-fallback@1.6.0: + resolution: {integrity: sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==} + engines: {node: '>=0.8'} + dev: true + + /consola@2.15.3: + resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} + dev: true + + /constant-case@3.0.4: + resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} + dependencies: + no-case: 3.0.4 + tslib: 2.6.2 + upper-case: 2.0.2 + dev: true + + /convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + /convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + /core-js-compat@3.37.1: + resolution: {integrity: sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==} + dependencies: + browserslist: 4.23.0 + dev: true + + /core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + /core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + dev: true + + /cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.0 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + dev: false + + /cosmiconfig@8.3.6(typescript@4.9.5): + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + typescript: 4.9.5 + dev: true + + /create-jest@29.7.0(@types/node@20.11.30): + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@20.11.30) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + /create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + dev: true + + /cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + dependencies: + cross-spawn: 7.0.3 + dev: true + + /cross-spawn@6.0.5: + resolution: {integrity: sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==} + engines: {node: '>=4.8'} + dependencies: + nice-try: 1.0.5 + path-key: 2.0.1 + semver: 5.7.2 + shebang-command: 1.2.0 + which: 1.3.1 + dev: true + + /cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + /css-box-model@1.2.1: + resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} + dependencies: + tiny-invariant: 1.3.3 + dev: false + + /css-select@4.3.0: + resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 4.3.1 + domutils: 2.8.0 + nth-check: 2.1.1 + dev: true + + /css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + dev: true + + /css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.0 + dev: true + + /css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.0 + dev: true + + /css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + dev: true + + /cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + dependencies: + css-tree: 2.2.1 + dev: true + + /cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + dev: true + + /cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + dev: true + + /cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + dependencies: + cssom: 0.3.8 + dev: true + + /csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + /cwise-compiler@1.1.3: + resolution: {integrity: sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==} + dependencies: + uniq: 1.0.1 + dev: false + + /cypress-image-snapshot@4.0.1(cypress@13.7.2)(jest@29.5.0): + resolution: {integrity: sha512-PBpnhX/XItlx3/DAk5ozsXQHUi72exybBNH5Mpqj1DVmjq+S5Jd9WE5CRa4q5q0zuMZb2V2VpXHth6MjFpgj9Q==} + engines: {node: '>=8'} + peerDependencies: + cypress: ^4.5.0 + dependencies: + chalk: 2.4.2 + cypress: 13.7.2 + fs-extra: 7.0.1 + glob: 7.2.3 + jest-image-snapshot: 4.2.0(jest@29.5.0) + pkg-dir: 3.0.0 + term-img: 4.1.0 + transitivePeerDependencies: + - jest + dev: true + + /cypress-real-events@1.13.0(cypress@13.7.2): + resolution: {integrity: sha512-LoejtK+dyZ1jaT8wGT5oASTPfsNV8/ClRp99ruN60oPj8cBJYod80iJDyNwfPAu4GCxTXOhhAv9FO65Hpwt6Hg==} + peerDependencies: + cypress: ^4.x || ^5.x || ^6.x || ^7.x || ^8.x || ^9.x || ^10.x || ^11.x || ^12.x || ^13.x + dependencies: + cypress: 13.7.2 + dev: true + + /cypress@13.7.2: + resolution: {integrity: sha512-FF5hFI5wlRIHY8urLZjJjj/YvfCBrRpglbZCLr/cYcL9MdDe0+5usa8kTIrDHthlEc9lwihbkb5dmwqBDNS2yw==} + engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} + hasBin: true + requiresBuild: true + dependencies: + '@cypress/request': 3.0.1 + '@cypress/xvfb': 1.2.4(supports-color@8.1.1) + '@types/sinonjs__fake-timers': 8.1.1 + '@types/sizzle': 2.3.8 + arch: 2.2.0 + blob-util: 2.0.2 + bluebird: 3.7.2 + buffer: 5.7.1 + cachedir: 2.4.0 + chalk: 4.1.2 + check-more-types: 2.24.0 + cli-cursor: 3.1.0 + cli-table3: 0.6.4 + commander: 6.2.1 + common-tags: 1.8.2 + dayjs: 1.11.9 + debug: 4.3.4(supports-color@8.1.1) + enquirer: 2.4.1 + eventemitter2: 6.4.7 + execa: 4.1.0 + executable: 4.1.1 + extract-zip: 2.0.1(supports-color@8.1.1) + figures: 3.2.0 + fs-extra: 9.1.0 + getos: 3.2.1 + is-ci: 3.0.1 + is-installed-globally: 0.4.0 + lazy-ass: 1.6.0 + listr2: 3.14.0(enquirer@2.4.1) + lodash: 4.17.21 + log-symbols: 4.1.0 + minimist: 1.2.8 + ospath: 1.2.2 + pretty-bytes: 5.6.0 + process: 0.11.10 + proxy-from-env: 1.0.0 + request-progress: 3.0.0 + semver: 7.6.0 + supports-color: 8.1.1 + tmp: 0.2.3 + untildify: 4.0.0 + yauzl: 2.10.0 + dev: true + + /dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + dependencies: + assert-plus: 1.0.0 + + /data-uri-to-buffer@0.0.3: + resolution: {integrity: sha512-Cp+jOa8QJef5nXS5hU7M1DWzXPEIoVR3kbV0dQuVGwROZg8bGf1DcCnkmajBTnvghTtSNMUdRrPjgaT6ZQucbw==} + dev: false + + /data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + dev: true + + /data-view-buffer@1.0.1: + resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + dev: true + + /data-view-byte-length@1.0.1: + resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + dev: true + + /data-view-byte-offset@1.0.0: + resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + dev: true + + /date-arithmetic@4.1.0: + resolution: {integrity: sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==} + dev: false + + /date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + dependencies: + '@babel/runtime': 7.24.1 + + /dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dev: true + + /dayjs@1.11.10: + resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} + dev: true + + /dayjs@1.11.9: + resolution: {integrity: sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==} + + /debug@3.2.7(supports-color@8.1.1): + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + supports-color: 8.1.1 + dev: true + + /debug@4.3.4(supports-color@8.1.1): + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + supports-color: 8.1.1 + + /decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + dev: true + + /decimal.js@10.4.3: + resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + + /dedent@1.5.1: + resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + /deep-equal@1.1.2: + resolution: {integrity: sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==} + engines: {node: '>= 0.4'} + dependencies: + is-arguments: 1.1.1 + is-date-object: 1.0.5 + is-regex: 1.1.4 + object-is: 1.1.6 + object-keys: 1.1.1 + regexp.prototype.flags: 1.5.2 + dev: false + + /deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dev: true + + /deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + /default-require-extensions@3.0.1: + resolution: {integrity: sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==} + engines: {node: '>=8'} + dependencies: + strip-bom: 4.0.0 + dev: true + + /define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 + + /define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + dev: true + + /define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + /defined@1.0.1: + resolution: {integrity: sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==} + dev: true + + /delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + /dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + /detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + /detective@5.2.1: + resolution: {integrity: sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==} + engines: {node: '>=0.8.0'} + hasBin: true + dependencies: + acorn-node: 1.8.2 + defined: 1.0.1 + minimist: 1.2.8 + dev: true + + /devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dependencies: + dequal: 2.0.3 + dev: false + + /dexie-react-hooks@1.1.7(@types/react@18.2.66)(dexie@4.0.7)(react@18.2.0): + resolution: {integrity: sha512-Lwv5W0Hk+uOW3kGnsU9GZoR1er1B7WQ5DSdonoNG+focTNeJbHW6vi6nBoX534VKI3/uwHebYzSw1fwY6a7mTw==} + peerDependencies: + '@types/react': '>=16' + dexie: ^3.2 || ^4.0.1-alpha + react: '>=16' + dependencies: + '@types/react': 18.2.66 + dexie: 4.0.7 + react: 18.2.0 + dev: false + + /dexie@4.0.7: + resolution: {integrity: sha512-M+Lo6rk4pekIfrc2T0o2tvVJwL6EAAM/B78DNfb8aaxFVoI1f8/rz5KTxuAnApkwqTSuxx7T5t0RKH7qprapGg==} + dev: false + + /didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dev: true + + /diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + /diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + dev: true + + /dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dependencies: + path-type: 4.0.0 + dev: true + + /direction@1.0.4: + resolution: {integrity: sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==} + hasBin: true + dev: false + + /dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dev: true + + /doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dev: true + + /dom-css@2.1.0: + resolution: {integrity: sha512-w9kU7FAbaSh3QKijL6n59ofAhkkmMJ31GclJIz/vyQdjogfyxcB6Zf8CZyibOERI5o0Hxz30VmJS7+7r5fEj2Q==} + dependencies: + add-px-to-style: 1.0.0 + prefix-style: 2.0.1 + to-camel-case: 1.0.0 + dev: false + + /dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dependencies: + '@babel/runtime': 7.24.1 + csstype: 3.1.3 + dev: false + + /dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + dev: true + + /dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + dev: true + + /domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + dev: true + + /domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + dependencies: + webidl-conversions: 7.0.0 + dev: true + + /domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + dev: true + + /domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + dev: true + + /domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + dev: true + + /domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dev: true + + /dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dependencies: + no-case: 3.0.4 + tslib: 2.6.2 + dev: true + + /dotenv-expand@8.0.3: + resolution: {integrity: sha512-SErOMvge0ZUyWd5B0NXMQlDkN+8r+HhVUsxgOO7IoPDOdDRD2JjExpN6y3KnFR66jsJMwSn1pqIivhU5rcJiNg==} + engines: {node: '>=12'} + dev: true + + /dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + dev: true + + /duplexify@3.7.1: + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} + dependencies: + end-of-stream: 1.4.4 + inherits: 2.0.4 + readable-stream: 2.3.8 + stream-shift: 1.0.3 + dev: true + + /dynamic-dedupe@0.3.0: + resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==} + dependencies: + xtend: 4.0.2 + dev: true + + /eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + dev: true + + /ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + + /ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + dependencies: + jake: 10.9.2 + dev: true + + /electron-to-chromium@1.4.722: + resolution: {integrity: sha512-5nLE0TWFFpZ80Crhtp4pIp8LXCztjYX41yUcV6b+bKR2PqzjskTMOOlBi1VjBHlvHwS+4gar7kNKOrsbsewEZQ==} + + /emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + /emoji-mart@5.6.0: + resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} + dev: false + + /emoji-regex@10.3.0: + resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==} + dev: false + + /emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + /emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + dev: true + + /end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + dependencies: + once: 1.4.0 + dev: true + + /enhanced-resolve@5.16.1: + resolution: {integrity: sha512-4U5pNsuDl0EhuZpq46M5xPslstkviJuhrdobaRDBk2Jy2KO37FDAJl4lb2KlNabxT0m4MTK2UHNrsAcphE8nyw==} + engines: {node: '>=10.13.0'} + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + dev: true + + /enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + dev: true + + /entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + dev: true + + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + /error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + dependencies: + is-arrayish: 0.2.1 + + /es-abstract@1.23.3: + resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.1 + arraybuffer.prototype.slice: 1.0.3 + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + data-view-buffer: 1.0.1 + data-view-byte-length: 1.0.1 + data-view-byte-offset: 1.0.0 + es-define-property: 1.0.0 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-set-tostringtag: 2.0.3 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.6 + get-intrinsic: 1.2.4 + get-symbol-description: 1.0.2 + globalthis: 1.0.3 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + internal-slot: 1.0.7 + is-array-buffer: 3.0.4 + is-callable: 1.2.7 + is-data-view: 1.0.1 + is-negative-zero: 2.0.3 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + is-string: 1.0.7 + is-typed-array: 1.1.13 + is-weakref: 1.0.2 + object-inspect: 1.13.1 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + safe-array-concat: 1.1.2 + safe-regex-test: 1.0.3 + string.prototype.trim: 1.2.9 + string.prototype.trimend: 1.0.8 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.2 + typed-array-byte-length: 1.0.1 + typed-array-byte-offset: 1.0.2 + typed-array-length: 1.0.6 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.15 + dev: true + + /es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.4 + + /es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + /es-module-lexer@0.4.1: + resolution: {integrity: sha512-ooYciCUtfw6/d2w56UVeqHPcoCFAiJdz5XOkYpv/Txl1HMUozpXjz/2RIQgqwKdXNDPSF1W7mJCFse3G+HDyAA==} + dev: true + + /es-module-lexer@1.5.3: + resolution: {integrity: sha512-i1gCgmR9dCl6Vil6UKPI/trA69s08g/syhiDK9TG0Nf1RJjjFI+AzoWW7sPufzkgYAn861skuCwJa0pIIHYxvg==} + dev: true + + /es-object-atoms@1.0.0: + resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + dev: true + + /es-set-tostringtag@2.0.3: + resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.4 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + dev: true + + /es-shim-unscopables@1.0.2: + resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} + dependencies: + hasown: 2.0.2 + dev: true + + /es-to-primitive@1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + engines: {node: '>= 0.4'} + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + dev: true + + /es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + dev: true + + /esbuild@0.20.2: + resolution: {integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.20.2 + '@esbuild/android-arm': 0.20.2 + '@esbuild/android-arm64': 0.20.2 + '@esbuild/android-x64': 0.20.2 + '@esbuild/darwin-arm64': 0.20.2 + '@esbuild/darwin-x64': 0.20.2 + '@esbuild/freebsd-arm64': 0.20.2 + '@esbuild/freebsd-x64': 0.20.2 + '@esbuild/linux-arm': 0.20.2 + '@esbuild/linux-arm64': 0.20.2 + '@esbuild/linux-ia32': 0.20.2 + '@esbuild/linux-loong64': 0.20.2 + '@esbuild/linux-mips64el': 0.20.2 + '@esbuild/linux-ppc64': 0.20.2 + '@esbuild/linux-riscv64': 0.20.2 + '@esbuild/linux-s390x': 0.20.2 + '@esbuild/linux-x64': 0.20.2 + '@esbuild/netbsd-x64': 0.20.2 + '@esbuild/openbsd-x64': 0.20.2 + '@esbuild/sunos-x64': 0.20.2 + '@esbuild/win32-arm64': 0.20.2 + '@esbuild/win32-ia32': 0.20.2 + '@esbuild/win32-x64': 0.20.2 + + /escalade@3.1.2: + resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} + engines: {node: '>=6'} + + /escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + /escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + /escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + /escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + dev: false + + /escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + dev: true + + /eslint-plugin-react-hooks@4.6.0(eslint@8.57.0): + resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + dependencies: + eslint: 8.57.0 + dev: true + + /eslint-plugin-react-refresh@0.4.6(eslint@8.57.0): + resolution: {integrity: sha512-NjGXdm7zgcKRkKMua34qVO9doI7VOxZ6ancSvBELJSSoX97jyndXcSoa8XBh69JoB31dNz3EEzlMcizZl7LaMA==} + peerDependencies: + eslint: '>=7' + dependencies: + eslint: 8.57.0 + dev: true + + /eslint-plugin-react@7.32.2(eslint@8.57.0): + resolution: {integrity: sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + dependencies: + array-includes: 3.1.8 + array.prototype.flatmap: 1.3.2 + array.prototype.tosorted: 1.1.3 + doctrine: 2.1.0 + eslint: 8.57.0 + estraverse: 5.3.0 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.8 + object.fromentries: 2.0.8 + object.hasown: 1.1.4 + object.values: 1.2.0 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.11 + dev: true + + /eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + dev: true + + /eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + dev: true + + /eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /eslint-visitor-keys@4.0.0: + resolution: {integrity: sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + dev: true + + /eslint@8.57.0: + resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + hasBin: true + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/regexpp': 4.10.0 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.0 + '@humanwhocodes/config-array': 0.11.14 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.2.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.4(supports-color@8.1.1) + 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.5.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.1 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + 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 + transitivePeerDependencies: + - supports-color + dev: true + + /espree@10.0.1: + resolution: {integrity: sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + dependencies: + acorn: 8.11.3 + acorn-jsx: 5.3.2(acorn@8.11.3) + eslint-visitor-keys: 4.0.0 + dev: true + + /espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + acorn: 8.11.3 + acorn-jsx: 5.3.2(acorn@8.11.3) + eslint-visitor-keys: 3.4.3 + dev: true + + /esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + /esquery@1.5.0: + resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + engines: {node: '>=0.10'} + dependencies: + estraverse: 5.3.0 + dev: true + + /esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + dependencies: + estraverse: 5.3.0 + dev: true + + /estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + dev: true + + /estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + dev: true + + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + + /esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + dev: true + + /event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + dev: true + + /eventemitter2@6.4.7: + resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==} + dev: true + + /eventemitter3@2.0.3: + resolution: {integrity: sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==} + dev: false + + /events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + /execa@1.0.0: + resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} + engines: {node: '>=6'} + dependencies: + cross-spawn: 6.0.5 + get-stream: 4.1.0 + is-stream: 1.1.0 + npm-run-path: 2.0.2 + p-finally: 1.0.0 + signal-exit: 3.0.7 + strip-eof: 1.0.0 + dev: true + + /execa@4.1.0: + resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} + engines: {node: '>=10'} + dependencies: + cross-spawn: 7.0.3 + get-stream: 5.2.0 + human-signals: 1.1.1 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + dev: true + + /execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + /executable@4.1.1: + resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} + engines: {node: '>=4'} + dependencies: + pify: 2.3.0 + dev: true + + /exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + /expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + 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 + + /extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + /extract-zip@2.0.1(supports-color@8.1.1): + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + dependencies: + debug: 4.3.4(supports-color@8.1.1) + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + dev: true + + /extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + + /fast-copy@3.0.2: + resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} + dev: true + + /fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + /fast-diff@1.1.2: + resolution: {integrity: sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==} + dev: false + + /fast-diff@1.2.0: + resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==} + dev: true + + /fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + dev: false + + /fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + dev: true + + /fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + dev: true + + /fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + /fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + dev: true + + /fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} + engines: {node: '>=6'} + dev: true + + /fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + dev: true + + /fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + dependencies: + reusify: 1.0.4 + dev: true + + /fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + dependencies: + bser: 2.1.1 + + /fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + dependencies: + pend: 1.2.0 + dev: true + + /figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + dependencies: + escape-string-regexp: 1.0.5 + dev: true + + /file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flat-cache: 3.2.0 + dev: true + + /filelist@1.0.4: + resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + dependencies: + minimatch: 5.1.6 + dev: true + + /fill-range@7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + + /find-cache-dir@2.1.0: + resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} + engines: {node: '>=6'} + dependencies: + commondir: 1.0.1 + make-dir: 2.1.0 + pkg-dir: 3.0.0 + dev: true + + /find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + dev: true + + /find-cache-dir@4.0.0: + resolution: {integrity: sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==} + engines: {node: '>=14.16'} + dependencies: + common-path-prefix: 3.0.0 + pkg-dir: 7.0.0 + dev: true + + /find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + dev: false + + /find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + dependencies: + locate-path: 3.0.0 + dev: true + + /find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + /find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + dev: true + + /find-up@6.3.0: + resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + dev: true + + /flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + rimraf: 3.0.2 + dev: true + + /flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + dev: true + + /follow-redirects@1.15.6: + resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + /for-each@0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + dependencies: + is-callable: 1.2.7 + dev: true + + /foreground-child@2.0.0: + resolution: {integrity: sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==} + engines: {node: '>=8.0.0'} + dependencies: + cross-spawn: 7.0.3 + signal-exit: 3.0.7 + dev: true + + /foreground-child@3.1.1: + resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} + engines: {node: '>=14'} + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + dev: true + + /forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + + /form-data@2.3.3: + resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} + engines: {node: '>= 0.12'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + + /form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + + /fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + dev: true + + /fromentries@1.3.2: + resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==} + dev: true + + /fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + dev: true + + /fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + dev: true + + /fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + dev: true + + /fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + optional: true + + /function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + /function.prototype.name@1.1.6: + resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + functions-have-names: 1.2.3 + dev: true + + /functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + /gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + /get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + /get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + + /get-node-dimensions@1.2.1: + resolution: {integrity: sha512-2MSPMu7S1iOTL+BOa6K1S62hB2zUAYNF/lV0gSVlOaacd087lc6nR1H1r0e3B1CerTo+RceOmi1iJW+vp21xcQ==} + dev: false + + /get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + /get-pixels@3.3.3: + resolution: {integrity: sha512-5kyGBn90i9tSMUVHTqkgCHsoWoR+/lGbl4yC83Gefyr0HLIhgSWEx/2F/3YgsZ7UpYNuM6pDhDK7zebrUJ5nXg==} + dependencies: + data-uri-to-buffer: 0.0.3 + jpeg-js: 0.4.4 + mime-types: 2.1.35 + ndarray: 1.0.19 + ndarray-pack: 1.2.1 + node-bitmap: 0.0.1 + omggif: 1.0.10 + parse-data-uri: 0.2.0 + pngjs: 3.4.0 + request: 2.88.2 + through: 2.3.8 + dev: false + + /get-stdin@5.0.1: + resolution: {integrity: sha512-jZV7n6jGE3Gt7fgSTJoz91Ak5MuTLwMwkoYdjxuJ/AmjIsE1UC03y/IWkZCQGEvVNS9qoRNwy5BCqxImv0FVeA==} + engines: {node: '>=0.12.0'} + dev: true + + /get-stream@4.1.0: + resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} + engines: {node: '>=6'} + dependencies: + pump: 3.0.0 + dev: true + + /get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + dependencies: + pump: 3.0.0 + dev: true + + /get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + /get-symbol-description@1.0.2: + resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + dev: true + + /getos@3.2.1: + resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==} + dependencies: + async: 3.2.5 + dev: true + + /getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + dependencies: + assert-plus: 1.0.0 + + /glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + + /glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + dev: true + + /glob@10.3.12: + resolution: {integrity: sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + dependencies: + foreground-child: 3.1.1 + jackspeak: 2.3.6 + minimatch: 9.0.4 + minipass: 7.0.4 + path-scurry: 1.10.2 + dev: true + + /glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + /global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} + dependencies: + ini: 2.0.0 + dev: true + + /globalize@0.1.1: + resolution: {integrity: sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA==} + dev: false + + /globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + /globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.20.2 + dev: true + + /globalthis@1.0.3: + resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==} + engines: {node: '>= 0.4'} + dependencies: + define-properties: 1.2.1 + dev: true + + /globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.1 + merge2: 1.4.1 + slash: 3.0.0 + dev: true + + /glur@1.1.2: + resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==} + dev: true + + /goober@2.1.14(csstype@3.1.3): + resolution: {integrity: sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==} + peerDependencies: + csstype: ^3.0.10 + dependencies: + csstype: 3.1.3 + dev: false + + /google-protobuf@3.21.2: + resolution: {integrity: sha512-3MSOYFO5U9mPGikIYCzK0SaThypfGgS6bHqrUGXG3DPHCrb+txNqeEcns1W0lkGfk0rCyNXm7xB9rMxnCiZOoA==} + dev: false + + /gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + dependencies: + get-intrinsic: 1.2.4 + + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + /graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + dev: true + + /gunzip-maybe@1.4.2: + resolution: {integrity: sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==} + hasBin: true + dependencies: + browserify-zlib: 0.1.4 + is-deflate: 1.0.0 + is-gzip: 1.0.0 + peek-stream: 1.1.3 + pumpify: 1.5.1 + through2: 2.0.5 + dev: true + + /har-schema@2.0.0: + resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} + engines: {node: '>=4'} + dev: false + + /har-validator@5.1.5: + resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} + engines: {node: '>=6'} + deprecated: this library is no longer supported + dependencies: + ajv: 6.12.6 + har-schema: 2.0.0 + dev: false + + /has-ansi@2.0.0: + resolution: {integrity: sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==} + engines: {node: '>=0.10.0'} + dependencies: + ansi-regex: 2.1.1 + dev: true + + /has-bigints@1.0.2: + resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} + dev: true + + /has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + /has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + /has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + dependencies: + es-define-property: 1.0.0 + + /has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + + /has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + /has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + + /hasha@5.2.2: + resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==} + engines: {node: '>=8'} + dependencies: + is-stream: 2.0.1 + type-fest: 0.8.1 + dev: true + + /hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 + + /hast-util-embedded@3.0.0: + resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + dependencies: + '@types/hast': 3.0.4 + hast-util-is-element: 3.0.0 + dev: false + + /hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.1 + parse5: 7.1.2 + vfile: 6.0.3 + vfile-message: 4.0.2 + dev: false + + /hast-util-from-parse5@8.0.1: + resolution: {integrity: sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==} + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 8.0.0 + property-information: 6.5.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + dev: false + + /hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + dependencies: + '@types/hast': 3.0.4 + dev: false + + /hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + dependencies: + '@types/hast': 3.0.4 + dev: false + + /hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + dependencies: + '@types/hast': 3.0.4 + dev: false + + /hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.0 + dev: false + + /hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + dependencies: + '@types/hast': 3.0.4 + dev: false + + /hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + dev: false + + /hast-util-to-html@9.0.3: + resolution: {integrity: sha512-M17uBDzMJ9RPCqLMO92gNNUDuBSq10a25SDBI08iCCxmorf4Yy6sYHK57n9WAbRAAaU+DuR4W6GN9K4DFZesYg==} + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + dev: false + + /hast-util-to-mdast@10.1.0: + resolution: {integrity: sha512-DsL/SvCK9V7+vfc6SLQ+vKIyBDXTk2KLSbfBYkH4zeF/uR1yBajHRhkzuaUSGOB1WJSTieJBdHwxlC+HLKvZZw==} + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.2.0 + hast-util-phrasing: 3.0.1 + hast-util-to-html: 9.0.3 + hast-util-to-text: 4.0.2 + hast-util-whitespace: 3.0.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-hast: 13.2.0 + mdast-util-to-string: 4.0.0 + rehype-minify-whitespace: 6.0.2 + trim-trailing-lines: 2.1.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + dev: false + + /hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + dev: false + + /hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + dependencies: + '@types/hast': 3.0.4 + dev: false + + /hastscript@8.0.0: + resolution: {integrity: sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw==} + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + dev: false + + /he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + dev: true + + /header-case@2.0.4: + resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} + dependencies: + capital-case: 1.0.4 + tslib: 2.6.2 + dev: true + + /help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + dev: true + + /highlight.js@11.10.0: + resolution: {integrity: sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==} + engines: {node: '>=12.0.0'} + dev: false + + /hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + dependencies: + react-is: 16.13.1 + dev: false + + /html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + dependencies: + whatwg-encoding: 2.0.0 + dev: true + + /html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + /html-minifier-terser@6.1.0: + resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==} + engines: {node: '>=12'} + hasBin: true + dependencies: + camel-case: 4.1.2 + clean-css: 5.3.3 + commander: 8.3.0 + he: 1.2.0 + param-case: 3.0.4 + relateurl: 0.2.7 + terser: 5.31.0 + dev: true + + /html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + dependencies: + void-elements: 3.1.0 + dev: false + + /html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + dev: false + + /htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + dev: true + + /http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.3.4(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + dev: true + + /http-signature@1.2.0: + resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} + engines: {node: '>=0.8', npm: '>=1.3.7'} + dependencies: + assert-plus: 1.0.0 + jsprim: 1.4.2 + sshpk: 1.18.0 + dev: false + + /http-signature@1.3.6: + resolution: {integrity: sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==} + engines: {node: '>=0.10'} + dependencies: + assert-plus: 1.0.0 + jsprim: 2.0.2 + sshpk: 1.18.0 + dev: true + + /https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.3.4(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + dev: true + + /human-signals@1.1.1: + resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} + engines: {node: '>=8.12.0'} + dev: true + + /human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + /i18next-browser-languagedetector@7.2.1: + resolution: {integrity: sha512-h/pM34bcH6tbz8WgGXcmWauNpQupCGr25XPp9cZwZInR9XHSjIFDYp1SIok7zSPsTOMxdvuLyu86V+g2Kycnfw==} + dependencies: + '@babel/runtime': 7.24.1 + dev: false + + /i18next-resources-to-backend@1.2.1: + resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==} + dependencies: + '@babel/runtime': 7.24.1 + dev: false + + /i18next@22.5.1: + resolution: {integrity: sha512-8TGPgM3pAD+VRsMtUMNknRz3kzqwp/gPALrWMsDnmC1mKqJwpWyooQRLMcbTwq8z8YwSmuj+ZYvc+xCuEpkssA==} + dependencies: + '@babel/runtime': 7.24.1 + dev: false + + /iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: true + + /ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + dev: true + + /ignore@5.3.1: + resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} + engines: {node: '>= 4'} + dev: true + + /immer@10.1.1: + resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} + dev: false + + /immutable@4.3.6: + resolution: {integrity: sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==} + + /import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + /import-local@3.1.0: + resolution: {integrity: sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==} + engines: {node: '>=8'} + hasBin: true + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + /imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + /indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + dev: true + + /inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + /ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + dev: true + + /internal-slot@1.0.7: + resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.0.6 + dev: true + + /invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + dependencies: + loose-envify: 1.4.0 + dev: false + + /iota-array@1.0.0: + resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==} + dev: false + + /is-arguments@1.1.1: + resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + dev: false + + /is-array-buffer@3.0.4: + resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + dev: true + + /is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + /is-bigint@1.0.4: + resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} + dependencies: + has-bigints: 1.0.2 + dev: true + + /is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.3.0 + + /is-boolean-object@1.1.2: + resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + dev: true + + /is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + dev: false + + /is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + dev: true + + /is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + dev: true + + /is-ci@3.0.1: + resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} + hasBin: true + dependencies: + ci-info: 3.9.0 + dev: true + + /is-core-module@2.13.1: + resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} + dependencies: + hasown: 2.0.2 + + /is-data-view@1.0.1: + resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} + engines: {node: '>= 0.4'} + dependencies: + is-typed-array: 1.1.13 + dev: true + + /is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.2 + + /is-deflate@1.0.0: + resolution: {integrity: sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ==} + dev: true + + /is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + dev: true + + /is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + /is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + /is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + /is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + + /is-gzip@1.0.0: + resolution: {integrity: sha512-rcfALRIb1YewtnksfRIHGcIY93QnK8BIQ/2c9yDYcG/Y6+vRoJuTWBmmSEbyLLYtXm7q35pHOHbZFQBaLrhlWQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-hotkey@0.2.0: + resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==} + dev: false + + /is-installed-globally@0.4.0: + resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} + engines: {node: '>=10'} + dependencies: + global-dirs: 3.0.1 + is-path-inside: 3.0.3 + dev: true + + /is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + dev: true + + /is-number-object@1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.2 + dev: true + + /is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + /is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + dev: true + + /is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + dev: false + + /is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + dependencies: + isobject: 3.0.1 + dev: true + + /is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + dev: false + + /is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + dev: true + + /is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + /is-shared-array-buffer@1.0.3: + resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + dev: true + + /is-stream@1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + /is-string@1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.2 + dev: true + + /is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + dev: true + + /is-typed-array@1.1.13: + resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} + engines: {node: '>= 0.4'} + dependencies: + which-typed-array: 1.1.15 + dev: true + + /is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + + /is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + dev: true + + /is-weakref@1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + dependencies: + call-bind: 1.0.7 + dev: true + + /is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + dev: true + + /is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + dependencies: + is-docker: 2.2.1 + dev: true + + /isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + dev: true + + /isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + /isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + dev: true + + /isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + dev: false + + /isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + + /istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + /istanbul-lib-hook@3.0.0: + resolution: {integrity: sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==} + engines: {node: '>=8'} + dependencies: + append-transform: 2.0.0 + dev: true + + /istanbul-lib-instrument@4.0.3: + resolution: {integrity: sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==} + engines: {node: '>=8'} + dependencies: + '@babel/core': 7.24.3 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + dependencies: + '@babel/core': 7.24.3 + '@babel/parser': 7.24.1 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + /istanbul-lib-instrument@6.0.2: + resolution: {integrity: sha512-1WUsZ9R1lA0HtBSohTkm39WTPlNKSJ5iFk7UwqXkBLoHQT+hfqPsfsTDVuZdKGaBwn7din9bS7SsnoAr943hvw==} + engines: {node: '>=10'} + dependencies: + '@babel/core': 7.24.3 + '@babel/parser': 7.24.1 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.6.0 + transitivePeerDependencies: + - supports-color + + /istanbul-lib-processinfo@2.0.3: + resolution: {integrity: sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==} + engines: {node: '>=8'} + dependencies: + archy: 1.0.0 + cross-spawn: 7.0.3 + istanbul-lib-coverage: 3.2.2 + p-map: 3.0.0 + rimraf: 3.0.2 + uuid: 8.3.2 + dev: true + + /istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + /istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + dependencies: + debug: 4.3.4(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + /istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + /iterm2-version@4.2.0: + resolution: {integrity: sha512-IoiNVk4SMPu6uTcK+1nA5QaHNok2BMDLjSl5UomrOixe5g4GkylhPwuiGdw00ysSCrXAKNMfFTu+u/Lk5f6OLQ==} + engines: {node: '>=8'} + dependencies: + app-path: 3.3.0 + plist: 3.1.0 + dev: true + + /jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + dev: true + + /jake@10.9.2: + resolution: {integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==} + engines: {node: '>=10'} + hasBin: true + dependencies: + async: 3.2.5 + chalk: 4.1.2 + filelist: 1.0.4 + minimatch: 3.1.2 + dev: true + + /jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + /jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.11.30 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.5.1 + is-generator-fn: 2.1.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.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + /jest-cli@29.7.0(@types/node@20.11.30): + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@20.11.30) + exit: 0.1.2 + import-local: 3.1.0 + jest-config: 29.7.0(@types/node@20.11.30) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + /jest-config@29.7.0(@types/node@20.11.30): + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + 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 + dependencies: + '@babel/core': 7.24.3 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.11.30 + babel-jest: 29.7.0(@babel/core@7.24.3) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + 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.5 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + /jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + /jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + detect-newline: 3.1.0 + + /jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + /jest-environment-jsdom@29.6.2: + resolution: {integrity: sha512-7oa/+266AAEgkzae8i1awNEfTfjwawWKLpiw2XesZmaoVVj9u9t8JOYx18cG29rbPNtkUlZ8V4b5Jb36y/VxoQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 20.11.30 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + + /jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.11.30 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + /jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + /jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 20.11.30 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.5 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + /jest-image-snapshot@4.2.0(jest@29.5.0): + resolution: {integrity: sha512-6aAqv2wtfOgxiJeBayBCqHo1zX+A12SUNNzo7rIxiXh6W6xYVu8QyHWkada8HeRi+QUTHddp0O0Xa6kmQr+xbQ==} + engines: {node: '>= 10.14.2'} + peerDependencies: + jest: '>=20 <=26' + dependencies: + chalk: 1.1.3 + get-stdin: 5.0.1 + glur: 1.1.2 + jest: 29.5.0(@types/node@20.11.30) + lodash: 4.17.21 + mkdirp: 0.5.6 + pixelmatch: 5.3.0 + pngjs: 3.4.0 + rimraf: 2.7.1 + ssim.js: 3.5.0 + dev: true + + /jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + /jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + /jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/code-frame': 7.24.2 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.5 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + /jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.11.30 + jest-util: 29.7.0 + + /jest-node-exports-resolver@1.1.6: + resolution: {integrity: sha512-NU412Qcb6WSRetCyEGMCC7IWHzO12LhSKaF1s9cyfM+EOYs4YN2gcNUT8hgu22X0oPFYNwLSPevgstBgLbD9ig==} + dev: true + + /jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + dependencies: + jest-resolve: 29.7.0 + + /jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + /jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + /jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.8 + resolve.exports: 2.0.2 + slash: 3.0.0 + + /jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + 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': 20.11.30 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + 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 + transitivePeerDependencies: + - supports-color + + /jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + 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': 20.11.30 + chalk: 4.1.2 + cjs-module-lexer: 1.2.3 + collect-v8-coverage: 1.0.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + 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 + transitivePeerDependencies: + - supports-color + + /jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/core': 7.24.3 + '@babel/generator': 7.24.1 + '@babel/plugin-syntax-jsx': 7.24.1(@babel/core@7.24.3) + '@babel/plugin-syntax-typescript': 7.24.1(@babel/core@7.24.3) + '@babel/types': 7.24.0 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.3) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + 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.6.0 + transitivePeerDependencies: + - supports-color + + /jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.11.30 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + /jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + /jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.11.30 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + /jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/node': 20.11.30 + merge-stream: 2.0.0 + supports-color: 8.1.1 + dev: true + + /jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@types/node': 20.11.30 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + /jest@29.5.0(@types/node@20.11.30): + resolution: {integrity: sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.1.0 + jest-cli: 29.7.0(@types/node@20.11.30) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + /joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + dev: true + + /jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + dev: false + + /js-base64@3.7.7: + resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} + dev: false + + /js-md5@0.8.3: + resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + dev: false + + /js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + /js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + /js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + dependencies: + argparse: 2.0.1 + dev: true + + /jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + + /jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + abab: 2.0.6 + acorn: 8.11.3 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.4.3 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.0 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.7 + parse5: 7.1.2 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.3 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.16.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + + /jsesc@0.5.0: + resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} + hasBin: true + dev: true + + /jsesc@2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + + /json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + dev: true + + /json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + /json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + /json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + dev: true + + /json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + /json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + dev: true + + /json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + /json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + /jsonc-parser@3.2.1: + resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} + dev: true + + /jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + + /jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + + /jsprim@1.4.2: + resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} + engines: {node: '>=0.6.0'} + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + dev: false + + /jsprim@2.0.2: + resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} + engines: {'0': node >=0.6.0} + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + dev: true + + /jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + dependencies: + array-includes: 3.1.8 + array.prototype.flat: 1.3.2 + object.assign: 4.1.5 + object.values: 1.2.0 + dev: true + + /katex@0.16.10: + resolution: {integrity: sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==} + hasBin: true + dependencies: + commander: 8.3.0 + dev: false + + /keycode@2.2.1: + resolution: {integrity: sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==} + dev: false + + /keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + dependencies: + json-buffer: 3.0.1 + dev: true + + /kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + dev: true + + /kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + /kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + dev: true + + /lazy-ass@1.6.0: + resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==} + engines: {node: '> 0.8'} + dev: true + + /leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + /levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + + /lib0@0.2.94: + resolution: {integrity: sha512-hZ3p54jL4Wpu7IOg26uC7dnEWiMyNlUrb9KoG7+xYs45WkQwpVvKFndVq2+pqLYKe1u8Fp3+zAfZHVvTK34PvQ==} + engines: {node: '>=16'} + hasBin: true + dependencies: + isomorphic.js: 0.2.5 + dev: false + + /lightgallery@2.7.2: + resolution: {integrity: sha512-Ewdcg9UPDqV0HGZeD7wNE4uYejwH2u0fMo5VAr6GHzlPYlhItJvjhLTR0cL0V1HjhMsH39PAom9iv69ewitLWw==} + engines: {node: '>=6.0.0'} + dev: false + + /lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + dev: true + + /lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + /listr2@3.14.0(enquirer@2.4.1): + resolution: {integrity: sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==} + engines: {node: '>=10.0.0'} + peerDependencies: + enquirer: '>= 2.3.0 < 3' + peerDependenciesMeta: + enquirer: + optional: true + dependencies: + cli-truncate: 2.1.0 + colorette: 2.0.20 + enquirer: 2.4.1 + log-update: 4.0.0 + p-map: 4.0.0 + rfdc: 1.3.1 + rxjs: 7.8.0 + through: 2.3.8 + wrap-ansi: 7.0.0 + dev: true + + /loader-runner@4.3.0: + resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} + engines: {node: '>=6.11.5'} + dev: true + + /locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + dev: true + + /locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + dependencies: + p-locate: 4.1.0 + + /locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + dependencies: + p-locate: 5.0.0 + dev: true + + /locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + p-locate: 6.0.0 + dev: true + + /lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + dev: false + + /lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + /lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + dev: true + + /lodash.flattendeep@4.4.0: + resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} + dev: true + + /lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + + /lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + dev: true + + /lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + dev: true + + /lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + dev: true + + /lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + /log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + dev: true + + /log-update@4.0.0: + resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==} + engines: {node: '>=10'} + dependencies: + ansi-escapes: 4.3.2 + cli-cursor: 3.1.0 + slice-ansi: 4.0.0 + wrap-ansi: 6.2.0 + dev: true + + /loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + + /lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + dependencies: + tslib: 2.6.2 + dev: true + + /lru-cache@10.2.0: + resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} + engines: {node: 14 || >=16.14} + dev: true + + /lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + dependencies: + yallist: 3.1.1 + + /lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 + + /luxon@3.4.4: + resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==} + engines: {node: '>=12'} + dev: false + + /lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + dev: true + + /magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + dependencies: + sourcemap-codec: 1.4.8 + dev: true + + /magic-string@0.30.8: + resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + dependencies: + pify: 4.0.1 + semver: 5.7.2 + dev: true + + /make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + dependencies: + semver: 6.3.1 + dev: true + + /make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + dependencies: + semver: 7.6.0 + + /make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + dev: true + + /makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + dependencies: + tmpl: 1.0.5 + + /material-colors@1.2.6: + resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} + dev: false + + /mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.0 + dev: false + + /mdast-util-to-hast@13.2.0: + resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.2.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.0 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + dev: false + + /mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + dependencies: + '@types/mdast': 4.0.4 + dev: false + + /mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + dev: true + + /mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + dev: true + + /memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + dev: false + + /memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + dev: false + + /merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + /merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + dev: true + + /micromark-util-character@2.1.0: + resolution: {integrity: sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==} + dependencies: + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + dev: false + + /micromark-util-encode@2.0.0: + resolution: {integrity: sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==} + dev: false + + /micromark-util-sanitize-uri@2.0.0: + resolution: {integrity: sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==} + dependencies: + micromark-util-character: 2.1.0 + micromark-util-encode: 2.0.0 + micromark-util-symbol: 2.0.0 + dev: false + + /micromark-util-symbol@2.0.0: + resolution: {integrity: sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==} + dev: false + + /micromark-util-types@2.0.0: + resolution: {integrity: sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==} + dev: false + + /micromatch@4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + + /mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + /minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + + /minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: true + + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + + /minimatch@9.0.4: + resolution: {integrity: sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: true + + /minipass@7.0.4: + resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} + engines: {node: '>=16 || 14 >=14.17'} + dev: true + + /mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: true + + /mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + dev: true + + /moment-timezone@0.5.45: + resolution: {integrity: sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==} + dependencies: + moment: 2.30.1 + dev: false + + /moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + dev: false + + /mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + dev: true + + /ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + /nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + /nanoid@4.0.2: + resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==} + engines: {node: ^14 || ^16 || >=18} + hasBin: true + dev: false + + /natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + /ndarray-pack@1.2.1: + resolution: {integrity: sha512-51cECUJMT0rUZNQa09EoKsnFeDL4x2dHRT0VR5U2H5ZgEcm95ZDWcMA5JShroXjHOejmAD/fg8+H+OvUnVXz2g==} + dependencies: + cwise-compiler: 1.1.3 + ndarray: 1.0.19 + dev: false + + /ndarray@1.0.19: + resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==} + dependencies: + iota-array: 1.0.0 + is-buffer: 1.1.6 + dev: false + + /neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + dev: true + + /nice-try@1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + dev: true + + /no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + dependencies: + lower-case: 2.0.2 + tslib: 2.6.2 + dev: true + + /node-bitmap@0.0.1: + resolution: {integrity: sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA==} + engines: {node: '>=v0.6.5'} + dev: false + + /node-html-parser@5.4.2: + resolution: {integrity: sha512-RaBPP3+51hPne/OolXxcz89iYvQvKOydaqoePpOgXcrOKZhjVIzmpKZz+Hd/RBO2/zN2q6CNJhQzucVz+u3Jyw==} + dependencies: + css-select: 4.3.0 + he: 1.2.0 + dev: true + + /node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + /node-preload@0.2.1: + resolution: {integrity: sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==} + engines: {node: '>=8'} + dependencies: + process-on-spawn: 1.0.0 + dev: true + + /node-releases@2.0.14: + resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + + /normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + /normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + dev: true + + /notistack@3.0.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==} + engines: {node: '>=12.0.0', npm: '>=6.0.0'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + clsx: 1.2.1 + goober: 2.1.14(csstype@3.1.3) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - csstype + dev: false + + /npm-run-path@2.0.2: + resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} + engines: {node: '>=4'} + dependencies: + path-key: 2.0.1 + dev: true + + /npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + dependencies: + path-key: 3.1.1 + + /nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + dependencies: + boolbase: 1.0.0 + dev: true + + /numeral@2.0.6: + resolution: {integrity: sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==} + dev: false + + /nwsapi@2.2.7: + resolution: {integrity: sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==} + dev: true + + /nyc@15.1.0: + resolution: {integrity: sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==} + engines: {node: '>=8.9'} + hasBin: true + dependencies: + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + caching-transform: 4.0.0 + convert-source-map: 1.9.0 + decamelize: 1.2.0 + find-cache-dir: 3.3.2 + find-up: 4.1.0 + foreground-child: 2.0.0 + get-package-type: 0.1.0 + glob: 7.2.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-hook: 3.0.0 + istanbul-lib-instrument: 4.0.3 + istanbul-lib-processinfo: 2.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.7 + make-dir: 3.1.0 + node-preload: 0.2.1 + p-map: 3.0.0 + process-on-spawn: 1.0.0 + resolve-from: 5.0.0 + rimraf: 3.0.2 + signal-exit: 3.0.7 + spawn-wrap: 2.0.0 + test-exclude: 6.0.0 + yargs: 15.4.1 + transitivePeerDependencies: + - supports-color + dev: true + + /oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + dev: false + + /object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + /object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + dev: true + + /object-inspect@1.13.1: + resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} + dev: true + + /object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + dev: false + + /object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + /object.assign@4.1.5: + resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + has-symbols: 1.0.3 + object-keys: 1.1.1 + dev: true + + /object.entries@1.1.8: + resolution: {integrity: sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + dev: true + + /object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + dev: true + + /object.hasown@1.1.4: + resolution: {integrity: sha512-FZ9LZt9/RHzGySlBARE3VF+gE26TxR38SdmqOqliuTnl9wrKulaQs+4dee1V+Io8VfxqzAfHu6YuRgUy8OHoTg==} + engines: {node: '>= 0.4'} + dependencies: + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + dev: true + + /object.values@1.2.0: + resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + dev: true + + /omggif@1.0.10: + resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} + dev: false + + /on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + dev: true + + /once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + dependencies: + wrappy: 1.0.2 + + /onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + dependencies: + mimic-fn: 2.1.0 + + /open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + dev: true + + /optionator@0.9.3: + resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} + engines: {node: '>= 0.8.0'} + dependencies: + '@aashutoshrathi/word-wrap': 1.2.6 + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + + /ospath@1.2.2: + resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} + dev: true + + /p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + dev: true + + /p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + dependencies: + p-try: 2.2.0 + + /p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + dependencies: + yocto-queue: 0.1.0 + + /p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + yocto-queue: 1.0.0 + dev: true + + /p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + dependencies: + p-limit: 2.3.0 + dev: true + + /p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + dependencies: + p-limit: 2.3.0 + + /p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + dependencies: + p-limit: 3.1.0 + dev: true + + /p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + p-limit: 4.0.0 + dev: true + + /p-map@3.0.0: + resolution: {integrity: sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==} + engines: {node: '>=8'} + dependencies: + aggregate-error: 3.1.0 + dev: true + + /p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + dependencies: + aggregate-error: 3.1.0 + dev: true + + /p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + /package-hash@4.0.0: + resolution: {integrity: sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==} + engines: {node: '>=8'} + dependencies: + graceful-fs: 4.2.11 + hasha: 5.2.2 + lodash.flattendeep: 4.4.0 + release-zalgo: 1.0.0 + dev: true + + /pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + dev: true + + /param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + dependencies: + dot-case: 3.0.4 + tslib: 2.6.2 + dev: true + + /parchment@1.1.4: + resolution: {integrity: sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==} + + /parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + dependencies: + callsites: 3.1.0 + + /parse-data-uri@0.2.0: + resolution: {integrity: sha512-uOtts8NqDcaCt1rIsO3VFDRsAfgE4c6osG4d9z3l4dCBlxYFzni6Di/oNU270SDrjkfZuUvLZx1rxMyqh46Y9w==} + dependencies: + data-uri-to-buffer: 0.0.3 + dev: false + + /parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + dependencies: + '@babel/code-frame': 7.24.2 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + /parse5-htmlparser2-tree-adapter@7.0.0: + resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==} + dependencies: + domhandler: 5.0.3 + parse5: 7.1.2 + dev: true + + /parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + dependencies: + entities: 4.5.0 + + /pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + dependencies: + no-case: 3.0.4 + tslib: 2.6.2 + dev: true + + /path-case@3.0.4: + resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} + dependencies: + dot-case: 3.0.4 + tslib: 2.6.2 + dev: true + + /path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + dev: true + + /path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + /path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: true + + /path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + /path-key@2.0.1: + resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} + engines: {node: '>=4'} + dev: true + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + /path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + /path-scurry@1.10.2: + resolution: {integrity: sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + lru-cache: 10.2.0 + minipass: 7.0.4 + dev: true + + /path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + /pathe@0.2.0: + resolution: {integrity: sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw==} + dev: true + + /peek-stream@1.1.3: + resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} + dependencies: + buffer-from: 1.1.2 + duplexify: 3.7.1 + through2: 2.0.5 + dev: true + + /pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + dev: true + + /performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + + /picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + + /picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + /pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + dev: true + + /pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + dev: true + + /pino-abstract-transport@1.2.0: + resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==} + dependencies: + readable-stream: 4.5.2 + split2: 4.2.0 + dev: true + + /pino-pretty@11.2.1: + resolution: {integrity: sha512-O05NuD9tkRasFRWVaF/uHLOvoRDFD7tb5VMertr78rbsYFjYp48Vg3477EshVAF5eZaEw+OpDl/tu+B0R5o+7g==} + hasBin: true + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 3.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 1.2.0 + pump: 3.0.0 + readable-stream: 4.5.2 + secure-json-parse: 2.7.0 + sonic-boom: 4.0.1 + strip-json-comments: 3.1.1 + dev: true + + /pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + dev: true + + /pino@9.2.0: + resolution: {integrity: sha512-g3/hpwfujK5a4oVbaefoJxezLzsDgLcNJeITvC6yrfwYeT9la+edCK42j5QpEQSQCZgTKapXvnQIdgZwvRaZug==} + hasBin: true + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 1.2.0 + pino-std-serializers: 7.0.0 + process-warning: 3.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.4.3 + sonic-boom: 4.0.1 + thread-stream: 3.1.0 + dev: true + + /pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + + /pixelmatch@5.3.0: + resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} + hasBin: true + dependencies: + pngjs: 6.0.0 + dev: true + + /pkg-dir@3.0.0: + resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} + engines: {node: '>=6'} + dependencies: + find-up: 3.0.0 + dev: true + + /pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + dependencies: + find-up: 4.1.0 + + /pkg-dir@7.0.0: + resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} + engines: {node: '>=14.16'} + dependencies: + find-up: 6.3.0 + dev: true + + /plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + dependencies: + '@xmldom/xmldom': 0.8.10 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + dev: true + + /pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + + /pngjs@6.0.0: + resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} + engines: {node: '>=12.13.0'} + dev: true + + /possible-typed-array-names@1.0.0: + resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} + engines: {node: '>= 0.4'} + dev: true + + /postcss-import@14.1.0(postcss@8.4.21): + resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==} + engines: {node: '>=10.0.0'} + peerDependencies: + postcss: ^8.0.0 + dependencies: + postcss: 8.4.21 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.8 + dev: true + + /postcss-js@4.0.1(postcss@8.4.21): + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.21 + dev: true + + /postcss-load-config@3.1.4(postcss@8.4.21): + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + dependencies: + lilconfig: 2.1.0 + postcss: 8.4.21 + yaml: 1.10.2 + dev: true + + /postcss-nested@6.0.0(postcss@8.4.21): + resolution: {integrity: sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + dependencies: + postcss: 8.4.21 + postcss-selector-parser: 6.0.16 + dev: true + + /postcss-selector-parser@6.0.16: + resolution: {integrity: sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==} + engines: {node: '>=4'} + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + dev: true + + /postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + dev: true + + /postcss@8.4.21: + resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.0 + source-map-js: 1.2.0 + dev: true + + /postcss@8.4.38: + resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.0 + source-map-js: 1.2.0 + + /prefix-style@2.0.1: + resolution: {integrity: sha512-gdr1MBNVT0drzTq95CbSNdsrBDoHGlb2aDJP/FoY+1e+jSDPOb1Cv554gH2MGiSr2WTcXi/zu+NaFzfcHQkfBQ==} + dev: false + + /prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + dev: true + + /prettier-plugin-tailwindcss@0.2.2(prettier@2.8.4): + resolution: {integrity: sha512-5RjUbWRe305pUpc48MosoIp6uxZvZxrM6GyOgsbGLTce+ehePKNm7ziW2dLG2air9aXbGuXlHVSQQw4Lbosq3w==} + engines: {node: '>=12.17.0'} + peerDependencies: + '@prettier/plugin-php': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@shufo/prettier-plugin-blade': '*' + '@trivago/prettier-plugin-sort-imports': '*' + prettier: '>=2.2.0' + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-import-sort: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-style-order: '*' + prettier-plugin-svelte: '*' + prettier-plugin-twig-melody: '*' + peerDependenciesMeta: + '@prettier/plugin-php': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@shufo/prettier-plugin-blade': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-import-sort: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-style-order: + optional: true + prettier-plugin-svelte: + optional: true + prettier-plugin-twig-melody: + optional: true + dependencies: + prettier: 2.8.4 + dev: true + + /prettier@2.8.4: + resolution: {integrity: sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw==} + engines: {node: '>=10.13.0'} + hasBin: true + dev: true + + /pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + dev: true + + /pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + dev: true + + /pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.2.0 + + /prismjs@1.29.0: + resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} + engines: {node: '>=6'} + dev: false + + /process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + dev: true + + /process-on-spawn@1.0.0: + resolution: {integrity: sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==} + engines: {node: '>=8'} + dependencies: + fromentries: 1.3.2 + dev: true + + /process-warning@3.0.0: + resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} + dev: true + + /process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + dev: true + + /prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + /prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + /property-information@6.5.0: + resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} + dev: false + + /protoc-gen-ts@0.8.7: + resolution: {integrity: sha512-jr4VJey2J9LVYCV7EVyVe53g1VMw28cCmYJhBe5e3YX5wiyiDwgxWxeDf9oTqAe4P1bN/YGAkW2jhlH8LohwiQ==} + hasBin: true + dev: false + + /proxy-from-env@1.0.0: + resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} + dev: true + + /proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + /psl@1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + + /pump@2.0.1: + resolution: {integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==} + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + dev: true + + /pump@3.0.0: + resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + dev: true + + /pumpify@1.5.1: + resolution: {integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==} + dependencies: + duplexify: 3.7.1 + inherits: 2.0.4 + pump: 2.0.1 + dev: true + + /punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + /pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + /qs@6.10.4: + resolution: {integrity: sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==} + engines: {node: '>=0.6'} + dependencies: + side-channel: 1.0.6 + dev: true + + /qs@6.5.3: + resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==} + engines: {node: '>=0.6'} + dev: false + + /querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + dev: true + + /queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + dev: true + + /queue-tick@1.0.1: + resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} + dev: true + + /quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + dev: true + + /quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + dev: true + + /quill-delta@3.6.3: + resolution: {integrity: sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==} + engines: {node: '>=0.10'} + dependencies: + deep-equal: 1.1.2 + extend: 3.0.2 + fast-diff: 1.1.2 + dev: false + + /quill-delta@4.2.2: + resolution: {integrity: sha512-qjbn82b/yJzOjstBgkhtBjN2TNK+ZHP/BgUQO+j6bRhWQQdmj2lH6hXG7+nwwLF41Xgn//7/83lxs9n2BkTtTg==} + dependencies: + fast-diff: 1.2.0 + lodash.clonedeep: 4.5.0 + lodash.isequal: 4.5.0 + dev: true + + /quill-delta@5.1.0: + resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==} + engines: {node: '>= 12.0.0'} + dependencies: + fast-diff: 1.3.0 + lodash.clonedeep: 4.5.0 + lodash.isequal: 4.5.0 + dev: false + + /quill@1.3.7: + resolution: {integrity: sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==} + dependencies: + clone: 2.1.2 + deep-equal: 1.1.2 + eventemitter3: 2.0.3 + extend: 3.0.2 + parchment: 1.1.4 + quill-delta: 3.6.3 + dev: false + + /raf-schd@4.0.3: + resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} + dev: false + + /raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + dependencies: + performance-now: 2.1.0 + dev: false + + /randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + dependencies: + safe-buffer: 5.2.1 + dev: true + + /react-beautiful-dnd@13.1.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==} + peerDependencies: + react: ^16.8.5 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.24.1 + css-box-model: 1.2.1 + memoize-one: 5.2.1 + raf-schd: 4.0.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-redux: 7.2.9(react-dom@18.2.0)(react@18.2.0) + redux: 4.2.1 + use-memo-one: 1.1.3(react@18.2.0) + transitivePeerDependencies: + - react-native + dev: false + + /react-big-calendar@1.12.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-cPVcwH5V1YiC6QKaV4afvpuZ2DtP8+TocnZY98nGodqq8bfjVDiP3Ch+TewBZzj9mg7JbewHdufDZXZBqQl1lw==} + peerDependencies: + react: ^16.14.0 || ^17 || ^18 + react-dom: ^16.14.0 || ^17 || ^18 + dependencies: + '@babel/runtime': 7.24.1 + clsx: 1.2.1 + date-arithmetic: 4.1.0 + dayjs: 1.11.9 + dom-helpers: 5.2.1 + globalize: 0.1.1 + invariant: 2.2.4 + lodash: 4.17.21 + lodash-es: 4.17.21 + luxon: 3.4.4 + memoize-one: 6.0.0 + moment: 2.30.1 + moment-timezone: 0.5.45 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-overlays: 5.2.1(react-dom@18.2.0)(react@18.2.0) + uncontrollable: 7.2.1(react@18.2.0) + dev: false + + /react-color@2.19.3(react@18.2.0): + resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==} + peerDependencies: + react: '*' + dependencies: + '@icons/material': 0.2.4(react@18.2.0) + lodash: 4.17.21 + lodash-es: 4.17.21 + material-colors: 1.2.6 + prop-types: 15.8.1 + react: 18.2.0 + reactcss: 1.2.3(react@18.2.0) + tinycolor2: 1.6.0 + dev: false + + /react-custom-scrollbars-2@4.5.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-/z0nWAeXfMDr4+OXReTpYd1Atq9kkn4oI3qxq3iMXGQx1EEfwETSqB8HTAvg1X7dEqcCachbny1DRNGlqX5bDQ==} + peerDependencies: + react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + dom-css: 2.1.0 + prop-types: 15.8.1 + raf: 3.4.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-custom-scrollbars@4.2.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-VtJTUvZ7kPh/auZWIbBRceGPkE30XBYe+HktFxuMWBR2eVQQ+Ur6yFJMoaYcNpyGq22uYJ9Wx4UAEcC0K+LNPQ==} + peerDependencies: + react: ^0.14.0 || ^15.0.0 || ^16.0.0 + react-dom: ^0.14.0 || ^15.0.0 || ^16.0.0 + dependencies: + dom-css: 2.1.0 + prop-types: 15.8.1 + raf: 3.4.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-datepicker@4.25.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-zB7CSi44SJ0sqo8hUQ3BF1saE/knn7u25qEMTO1CQGofY1VAKahO8k9drZtp0cfW1DMfoYLR3uSY1/uMvbEzbg==} + peerDependencies: + react: ^16.9.0 || ^17 || ^18 + react-dom: ^16.9.0 || ^17 || ^18 + dependencies: + '@popperjs/core': 2.11.8 + classnames: 2.5.1 + date-fns: 2.30.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-onclickoutside: 6.13.1(react-dom@18.2.0)(react@18.2.0) + react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0) + dev: false + + /react-dom@18.2.0(react@18.2.0): + resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + peerDependencies: + react: ^18.2.0 + dependencies: + loose-envify: 1.4.0 + react: 18.2.0 + scheduler: 0.23.0 + + /react-error-boundary@4.0.13(react@18.2.0): + resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.24.1 + react: 18.2.0 + dev: false + + /react-event-listener@0.6.6(react@18.2.0): + resolution: {integrity: sha512-+hCNqfy7o9wvO6UgjqFmBzARJS7qrNoda0VqzvOuioEpoEXKutiKuv92dSz6kP7rYLmyHPyYNLesi5t/aH1gfw==} + peerDependencies: + react: ^16.3.0 + dependencies: + '@babel/runtime': 7.24.6 + prop-types: 15.8.1 + react: 18.2.0 + warning: 4.0.3 + dev: false + + /react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + /react-helmet@6.1.0(react@18.2.0): + resolution: {integrity: sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==} + peerDependencies: + react: '>=16.3.0' + dependencies: + object-assign: 4.1.1 + prop-types: 15.8.1 + react: 18.2.0 + react-fast-compare: 3.2.2 + react-side-effect: 2.1.2(react@18.2.0) + dev: false + + /react-hook-form@7.52.2(react@18.2.0): + resolution: {integrity: sha512-pqfPEbERnxxiNMPd0bzmt1tuaPcVccywFDpyk2uV5xCIBphHV5T8SVnX9/o3kplPE1zzKt77+YIoq+EMwJp56A==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + dependencies: + react: 18.2.0 + dev: false + + /react-hot-toast@2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + dependencies: + goober: 2.1.14(csstype@3.1.3) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - csstype + dev: false + + /react-i18next@14.1.2(i18next@22.5.1)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-FSIcJy6oauJbGEXfhUgVeLzvWBhIBIS+/9c6Lj4niwKZyGaGb4V4vUbATXSlsHJDXXB+ociNxqFNiFuV1gmoqg==} + peerDependencies: + i18next: '>= 23.2.3' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + dependencies: + '@babel/runtime': 7.24.1 + html-parse-stringify: 3.0.1 + i18next: 22.5.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + /react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + /react-is@18.2.0: + resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + + /react-katex@3.0.1(prop-types@15.8.1)(react@18.2.0): + resolution: {integrity: sha512-wIUW1fU5dHlkKvq4POfDkHruQsYp3fM8xNb/jnc8dnQ+nNCnaj0sx5pw7E6UyuEdLRyFKK0HZjmXBo+AtXXy0A==} + peerDependencies: + prop-types: ^15.8.1 + react: '>=15.3.2 <=18' + dependencies: + katex: 0.16.10 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /react-lifecycles-compat@3.0.4: + resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} + dev: false + + /react-measure@2.5.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-M+rpbTLWJ3FD6FXvYV6YEGvQ5tMayQ3fGrZhRPHrE9bVlBYfDCLuDcgNttYfk8IqfOI03jz6cbpqMRTUclQnaA==} + peerDependencies: + react: '>0.13.0' + react-dom: '>0.13.0' + dependencies: + '@babel/runtime': 7.24.1 + get-node-dimensions: 1.2.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + resize-observer-polyfill: 1.5.1 + dev: false + + /react-onclickoutside@6.13.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-LdrrxK/Yh9zbBQdFbMTXPp3dTSN9B+9YJQucdDu3JNKRrbdU+H+/TVONJoWtOwy4II8Sqf1y/DTI6w/vGPYW0w==} + peerDependencies: + react: ^15.5.x || ^16.x || ^17.x || ^18.x + react-dom: ^15.5.x || ^16.x || ^17.x || ^18.x + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-overlays@5.2.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==} + peerDependencies: + react: '>=16.3.0' + react-dom: '>=16.3.0' + dependencies: + '@babel/runtime': 7.24.1 + '@popperjs/core': 2.11.8 + '@restart/hooks': 0.4.16(react@18.2.0) + '@types/warning': 3.0.3 + dom-helpers: 5.2.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + uncontrollable: 7.2.1(react@18.2.0) + warning: 4.0.3 + dev: false + + /react-popper@2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==} + peerDependencies: + '@popperjs/core': ^2.0.0 + react: ^16.8.0 || ^17 || ^18 + react-dom: ^16.8.0 || ^17 || ^18 + dependencies: + '@popperjs/core': 2.11.8 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-fast-compare: 3.2.2 + warning: 4.0.3 + + /react-redux@7.2.9(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==} + peerDependencies: + react: ^16.8.3 || ^17 || ^18 + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@types/react-redux': 7.1.33 + hoist-non-react-statics: 3.3.2 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 17.0.2 + dev: false + + /react-redux@8.1.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1): + resolution: {integrity: sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw==} + peerDependencies: + '@types/react': ^16.8 || ^17.0 || ^18.0 + '@types/react-dom': ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + react-native: '>=0.59' + redux: ^4 || ^5.0.0-beta.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + react-dom: + optional: true + react-native: + optional: true + redux: + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@types/hoist-non-react-statics': 3.3.5 + '@types/react': 18.2.66 + '@types/react-dom': 18.2.22 + '@types/use-sync-external-store': 0.0.3 + hoist-non-react-statics: 3.3.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 18.2.0 + redux: 4.2.1 + use-sync-external-store: 1.2.2(react@18.2.0) + dev: false + + /react-refresh@0.14.0: + resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} + engines: {node: '>=0.10.0'} + dev: true + + /react-router-dom@6.23.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-utP+K+aSTtEdbWpC+4gxhdlPFwuEfDKq8ZrPFU65bbRJY+l706qjR7yaidBpo3MSeA/fzwbXWbKBI6ftOnP3OQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + dependencies: + '@remix-run/router': 1.16.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-router: 6.23.1(react@18.2.0) + dev: false + + /react-router@6.23.1(react@18.2.0): + resolution: {integrity: sha512-fzcOaRF69uvqbbM7OhvQyBTFDVrrGlsFdS3AL+1KfIBtGETibHzi3FkoTRyiDJnWNc2VxrfvR+657ROHjaNjqQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + dependencies: + '@remix-run/router': 1.16.1 + react: 18.2.0 + dev: false + + /react-side-effect@2.1.2(react@18.2.0): + resolution: {integrity: sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==} + peerDependencies: + react: ^16.3.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + + /react-swipeable-views-core@0.14.0: + resolution: {integrity: sha512-0W/e9uPweNEOSPjmYtuKSC/SvKKg1sfo+WtPdnxeLF3t2L82h7jjszuOHz9C23fzkvLfdgkaOmcbAxE9w2GEjA==} + engines: {node: '>=6.0.0'} + dependencies: + '@babel/runtime': 7.0.0 + warning: 4.0.3 + dev: false + + /react-swipeable-views-utils@0.14.0(react@18.2.0): + resolution: {integrity: sha512-W+fXBOsDqgFK1/g7MzRMVcDurp3LqO3ksC8UgInh2P/tKgb5DusuuB1geKHFc6o1wKl+4oyER4Zh3Lxmr8xbXA==} + engines: {node: '>=6.0.0'} + dependencies: + '@babel/runtime': 7.0.0 + keycode: 2.2.1 + prop-types: 15.8.1 + react-event-listener: 0.6.6(react@18.2.0) + react-swipeable-views-core: 0.14.0 + shallow-equal: 1.2.1 + transitivePeerDependencies: + - react + dev: false + + /react-swipeable-views@0.14.0(react@18.2.0): + resolution: {integrity: sha512-wrTT6bi2nC3JbmyNAsPXffUXLn0DVT9SbbcFr36gKpbaCgEp7rX/OFxsu5hPc/NBsUhHyoSRGvwqJNNrWTwCww==} + engines: {node: '>=6.0.0'} + peerDependencies: + react: ^15.3.0 || ^16.0.0 || ^17.0.0 + dependencies: + '@babel/runtime': 7.0.0 + prop-types: 15.8.1 + react: 18.2.0 + react-swipeable-views-core: 0.14.0 + react-swipeable-views-utils: 0.14.0(react@18.2.0) + warning: 4.0.3 + dev: false + + /react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + dependencies: + '@babel/runtime': 7.24.1 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-virtualized-auto-sizer@1.0.24(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-3kCn7N9NEb3FlvJrSHWGQ4iVl+ydQObq2fHMn12i5wbtm74zHOPhz/i64OL3c1S1vi9i2GXtZqNqUJTQ+BnNfg==} + peerDependencies: + react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 + react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-vtree@2.0.4(@types/react-window@1.8.8)(react-dom@18.2.0)(react-window@1.8.10)(react@18.2.0): + resolution: {integrity: sha512-UOld0VqyAZrryF06K753X4bcEVN6/wW831exvVlMZeZAVHk9KXnlHs4rpqDAeoiBgUwJqoW/rtn0hwsokRRxPA==} + peerDependencies: + '@types/react-window': ^1.8.2 + react: ^16.13.1 + react-dom: ^16.13.1 + react-window: ^1.8.5 + dependencies: + '@babel/runtime': 7.24.1 + '@types/react-window': 1.8.8 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-window: 1.8.10(react-dom@18.2.0)(react@18.2.0) + dev: false + + /react-window@1.8.10(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==} + engines: {node: '>8.0.0'} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.24.1 + memoize-one: 5.2.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-zoom-pan-pinch@3.6.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-SdPqdk7QDSV7u/WulkFOi+cnza8rEZ0XX4ZpeH7vx3UZEg7DoyuAy3MCmm+BWv/idPQL2Oe73VoC0EhfCN+sZQ==} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + react: '*' + react-dom: '*' + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react18-input-otp@1.1.4(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-35xvmTeuPWIxd0Z0Opx4z3OoMaTmKN4ubirQCx1YMZiNoe+2h1hsOSUco4aKPlGXWZCtXrfOFieAh46vqiK9mA==} + peerDependencies: + react: 16.2.0 - 18 + react-dom: 16.2.0 - 18 + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + + /reactcss@1.2.3(react@18.2.0): + resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==} + peerDependencies: + react: '*' + dependencies: + lodash: 4.17.21 + react: 18.2.0 + dev: false + + /read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + dependencies: + pify: 2.3.0 + dev: true + + /readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + dev: true + + /readable-stream@4.5.2: + resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + dev: true + + /readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + + /real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + dev: true + + /redux-thunk@3.1.0(redux@5.0.1): + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + dependencies: + redux: 5.0.1 + dev: false + + /redux@4.2.1: + resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + dependencies: + '@babel/runtime': 7.24.1 + dev: false + + /redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + dev: false + + /regenerate-unicode-properties@10.1.1: + resolution: {integrity: sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==} + engines: {node: '>=4'} + dependencies: + regenerate: 1.4.2 + dev: true + + /regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + dev: true + + /regenerator-runtime@0.12.1: + resolution: {integrity: sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==} + dev: false + + /regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + /regenerator-transform@0.15.2: + resolution: {integrity: sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==} + dependencies: + '@babel/runtime': 7.24.6 + dev: true + + /regexp.prototype.flags@1.5.2: + resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-errors: 1.3.0 + set-function-name: 2.0.2 + + /regexpu-core@5.3.2: + resolution: {integrity: sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==} + engines: {node: '>=4'} + dependencies: + '@babel/regjsgen': 0.8.0 + regenerate: 1.4.2 + regenerate-unicode-properties: 10.1.1 + regjsparser: 0.9.1 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.1.0 + dev: true + + /regjsparser@0.9.1: + resolution: {integrity: sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==} + hasBin: true + dependencies: + jsesc: 0.5.0 + dev: true + + /rehype-minify-whitespace@6.0.2: + resolution: {integrity: sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw==} + dependencies: + '@types/hast': 3.0.4 + hast-util-minify-whitespace: 1.0.1 + dev: false + + /rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + dev: false + + /relateurl@0.2.7: + resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} + engines: {node: '>= 0.10'} + dev: true + + /release-zalgo@1.0.0: + resolution: {integrity: sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==} + engines: {node: '>=4'} + dependencies: + es6-error: 4.1.1 + dev: true + + /request-progress@3.0.0: + resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} + dependencies: + throttleit: 1.0.1 + dev: true + + /request@2.88.2: + resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} + engines: {node: '>= 6'} + deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 + dependencies: + aws-sign2: 0.7.0 + aws4: 1.12.0 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.3.3 + har-validator: 5.1.5 + http-signature: 1.2.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + oauth-sign: 0.9.0 + performance-now: 2.1.0 + qs: 6.5.3 + safe-buffer: 5.1.2 + tough-cookie: 2.5.0 + tunnel-agent: 0.6.0 + uuid: 3.4.0 + dev: false + + /require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + /require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + dev: true + + /require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + dev: true + + /requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + dev: true + + /reselect@5.1.0: + resolution: {integrity: sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg==} + dev: false + + /resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + dev: false + + /resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + dependencies: + resolve-from: 5.0.0 + + /resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + /resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + /resolve.exports@2.0.2: + resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} + engines: {node: '>=10'} + + /resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + dependencies: + is-core-module: 2.13.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + /resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + dependencies: + is-core-module: 2.13.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + dev: true + + /retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + dev: false + + /reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + dev: true + + /rfdc@1.3.1: + resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==} + dev: true + + /rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rollup-plugin-visualizer@5.12.0: + resolution: {integrity: sha512-8/NU9jXcHRs7Nnj07PF2o4gjxmm9lXIrZ8r175bT9dK8qoLlvKTwRMArRCMgpMGlq8CTLugRvEmyMeMXIU2pNQ==} + engines: {node: '>=14'} + hasBin: true + peerDependencies: + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rollup: + optional: true + dependencies: + open: 8.4.2 + picomatch: 2.3.1 + source-map: 0.7.4 + yargs: 17.7.2 + dev: true + + /rollup@4.13.2: + resolution: {integrity: sha512-MIlLgsdMprDBXC+4hsPgzWUasLO9CE4zOkj/u6j+Z6j5A4zRY+CtiXAdJyPtgCsc42g658Aeh1DlrdVEJhsL2g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + dependencies: + '@types/estree': 1.0.5 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.13.2 + '@rollup/rollup-android-arm64': 4.13.2 + '@rollup/rollup-darwin-arm64': 4.13.2 + '@rollup/rollup-darwin-x64': 4.13.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.13.2 + '@rollup/rollup-linux-arm64-gnu': 4.13.2 + '@rollup/rollup-linux-arm64-musl': 4.13.2 + '@rollup/rollup-linux-powerpc64le-gnu': 4.13.2 + '@rollup/rollup-linux-riscv64-gnu': 4.13.2 + '@rollup/rollup-linux-s390x-gnu': 4.13.2 + '@rollup/rollup-linux-x64-gnu': 4.13.2 + '@rollup/rollup-linux-x64-musl': 4.13.2 + '@rollup/rollup-win32-arm64-msvc': 4.13.2 + '@rollup/rollup-win32-ia32-msvc': 4.13.2 + '@rollup/rollup-win32-x64-msvc': 4.13.2 + fsevents: 2.3.3 + + /run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + dev: true + + /rxjs@7.8.0: + resolution: {integrity: sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==} + dependencies: + tslib: 2.6.2 + + /safe-array-concat@1.1.2: + resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} + engines: {node: '>=0.4'} + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + isarray: 2.0.5 + dev: true + + /safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + /safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + dev: true + + /safe-regex-test@1.0.3: + resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-regex: 1.1.4 + dev: true + + /safe-stable-stringify@2.4.3: + resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==} + engines: {node: '>=10'} + dev: true + + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + /sass@1.77.2: + resolution: {integrity: sha512-eb4GZt1C3avsX3heBNlrc7I09nyT00IUuo4eFhAbeXWU2fvA7oXI53SxODVAA+zgZCk9aunAZgO+losjR3fAwA==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + chokidar: 3.6.0 + immutable: 4.3.6 + source-map-js: 1.2.0 + + /saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + dependencies: + xmlchars: 2.2.0 + dev: true + + /scheduler@0.23.0: + resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} + dependencies: + loose-envify: 1.4.0 + + /schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + dev: true + + /schema-utils@4.2.0: + resolution: {integrity: sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==} + engines: {node: '>= 12.13.0'} + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.14.0 + ajv-formats: 2.1.1(ajv@8.14.0) + ajv-keywords: 5.1.0(ajv@8.14.0) + dev: true + + /scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + dependencies: + compute-scroll-into-view: 3.1.0 + dev: false + + /secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + dev: true + + /semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + dev: true + + /semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + /semver@7.6.0: + resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + + /sentence-case@3.0.4: + resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} + dependencies: + no-case: 3.0.4 + tslib: 2.6.2 + upper-case-first: 2.0.2 + dev: true + + /serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + dependencies: + randombytes: 2.1.0 + dev: true + + /set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + dev: true + + /set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + 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 + + /set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + /shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + dependencies: + kind-of: 6.0.3 + dev: true + + /shallow-equal@1.2.1: + resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} + dev: false + + /shebang-command@1.2.0: + resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} + engines: {node: '>=0.10.0'} + dependencies: + shebang-regex: 1.0.0 + dev: true + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + + /shebang-regex@1.0.0: + resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} + engines: {node: '>=0.10.0'} + dev: true + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + /side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + object-inspect: 1.13.1 + dev: true + + /signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + dev: true + + /sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + dependencies: + '@polka/url': 1.0.0-next.25 + mrmime: 2.0.0 + totalist: 3.0.1 + dev: true + + /sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + /slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + /slate-history@0.100.0(slate@0.101.5): + resolution: {integrity: sha512-x5rUuWLNtH97hs9PrFovGgt3Qc5zkTm/5mcUB+0NR/TK923eLax4HsL6xACLHMs245nI6aJElyM1y6hN0y5W/Q==} + peerDependencies: + slate: '>=0.65.3' + dependencies: + is-plain-object: 5.0.0 + slate: 0.101.5 + dev: false + + /slate-react@0.101.6(react-dom@18.2.0)(react@18.2.0)(slate@0.101.5): + resolution: {integrity: sha512-aMtp9FY127hKWTkCcTBonfKIwKJC2ESPqFdw2o/RuOk3RMQRwsWay8XTOHx8OBGOHanI2fsKaTAPF5zxOLA1Qg==} + peerDependencies: + react: '>=18.2.0' + react-dom: '>=18.2.0' + slate: '>=0.99.0' + dependencies: + '@juggle/resize-observer': 3.4.0 + '@types/is-hotkey': 0.1.10 + '@types/lodash': 4.17.0 + direction: 1.0.4 + is-hotkey: 0.2.0 + is-plain-object: 5.0.0 + lodash: 4.17.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + scroll-into-view-if-needed: 3.1.0 + slate: 0.101.5 + tiny-invariant: 1.3.1 + dev: false + + /slate@0.101.5: + resolution: {integrity: sha512-ZZt1ia8ayRqxtpILRMi2a4MfdvwdTu64CorxTVq9vNSd0GQ/t3YDkze6wKjdeUtENmBlq5wNIDInZbx38Hfu5Q==} + dependencies: + immer: 10.1.1 + is-plain-object: 5.0.0 + tiny-warning: 1.0.3 + dev: false + + /slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + dev: true + + /slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + dev: true + + /smooth-scroll-into-view-if-needed@2.0.2: + resolution: {integrity: sha512-z54WzUSlM+xHHvJu3lMIsh+1d1kA4vaakcAtQvqzeGJ5Ffau7EKjpRrMHh1/OBo5zyU2h30ZYEt77vWmPHqg7Q==} + dependencies: + scroll-into-view-if-needed: 3.1.0 + dev: false + + /snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + dependencies: + dot-case: 3.0.4 + tslib: 2.6.2 + dev: true + + /sonic-boom@4.0.1: + resolution: {integrity: sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ==} + dependencies: + atomic-sleep: 1.0.0 + dev: true + + /source-map-js@1.2.0: + resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} + engines: {node: '>=0.10.0'} + + /source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + /source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + dev: true + + /source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + dev: false + + /source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + /source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + dev: true + + /sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + dev: true + + /space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + dev: false + + /spawn-wrap@2.0.0: + resolution: {integrity: sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==} + engines: {node: '>=8'} + dependencies: + foreground-child: 2.0.0 + is-windows: 1.0.2 + make-dir: 3.1.0 + rimraf: 3.0.2 + signal-exit: 3.0.7 + which: 2.0.2 + dev: true + + /split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + dev: true + + /sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + /sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + + /ssim.js@3.5.0: + resolution: {integrity: sha512-Aj6Jl2z6oDmgYFFbQqK7fght19bXdOxY7Tj03nF+03M9gCBAjeIiO8/PlEGMfKDwYpw4q6iBqVq2YuREorGg/g==} + dev: true + + /stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + dependencies: + escape-string-regexp: 2.0.0 + + /stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + dev: true + + /streamx@2.16.1: + resolution: {integrity: sha512-m9QYj6WygWyWa3H1YY69amr4nVgy61xfjys7xO7kviL5rfIEc2naf+ewFiOA+aEJD7y0JO3h2GoiUv4TDwEGzQ==} + dependencies: + fast-fifo: 1.3.2 + queue-tick: 1.0.1 + optionalDependencies: + bare-events: 2.2.2 + dev: true + + /string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + /string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + /string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + dev: true + + /string.prototype.matchall@4.0.11: + resolution: {integrity: sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-symbols: 1.0.3 + internal-slot: 1.0.7 + regexp.prototype.flags: 1.5.2 + set-function-name: 2.0.2 + side-channel: 1.0.6 + dev: true + + /string.prototype.trim@1.2.9: + resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + dev: true + + /string.prototype.trimend@1.0.8: + resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + dev: true + + /string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + dev: true + + /string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + dependencies: + safe-buffer: 5.1.2 + dev: true + + /string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + dependencies: + safe-buffer: 5.2.1 + dev: true + + /stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + dev: false + + /strip-ansi@3.0.1: + resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} + engines: {node: '>=0.10.0'} + dependencies: + ansi-regex: 2.1.1 + dev: true + + /strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + dependencies: + ansi-regex: 5.0.1 + + /strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + dependencies: + ansi-regex: 6.0.1 + dev: true + + /strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + dev: true + + /strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + /strip-eof@1.0.0: + resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} + engines: {node: '>=0.10.0'} + dev: true + + /strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + /strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + dev: true + + /strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + /style-dictionary@3.9.2: + resolution: {integrity: sha512-M2pcQ6hyRtqHOh+NyT6T05R3pD/gwNpuhREBKvxC1En0vyywx+9Wy9nXWT1SZ9ePzv1vAo65ItnpA16tT9ZUCg==} + engines: {node: '>=12.0.0'} + hasBin: true + dependencies: + chalk: 4.1.2 + change-case: 4.1.2 + commander: 8.3.0 + fs-extra: 10.1.0 + glob: 10.3.12 + json5: 2.2.3 + jsonc-parser: 3.2.1 + lodash: 4.17.21 + tinycolor2: 1.6.0 + dev: true + + /stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + dev: false + + /supports-color@2.0.0: + resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==} + engines: {node: '>=0.8.0'} + dev: true + + /supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + dependencies: + has-flag: 3.0.0 + + /supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + + /supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + dependencies: + has-flag: 4.0.0 + + /supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + /svg-parser@2.0.4: + resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + dev: true + + /svgo@3.2.0: + resolution: {integrity: sha512-4PP6CMW/V7l/GmKRKzsLR8xxjdHTV4IMvhTnpuHwwBazSIlw5W/5SmPjN8Dwyt7lKbSJrRDgp4t9ph0HgChFBQ==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 5.1.0 + css-tree: 2.3.1 + css-what: 6.1.0 + csso: 5.0.5 + picocolors: 1.0.0 + dev: true + + /symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + dev: true + + /tailwindcss@3.2.7(postcss@8.4.21): + resolution: {integrity: sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==} + engines: {node: '>=12.13.0'} + hasBin: true + peerDependencies: + postcss: ^8.0.9 + dependencies: + arg: 5.0.2 + chokidar: 3.6.0 + color-name: 1.1.4 + detective: 5.2.1 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.2 + glob-parent: 6.0.2 + is-glob: 4.0.3 + lilconfig: 2.1.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.21 + postcss-import: 14.1.0(postcss@8.4.21) + postcss-js: 4.0.1(postcss@8.4.21) + postcss-load-config: 3.1.4(postcss@8.4.21) + postcss-nested: 6.0.0(postcss@8.4.21) + postcss-selector-parser: 6.0.16 + postcss-value-parser: 4.2.0 + quick-lru: 5.1.1 + resolve: 1.22.8 + transitivePeerDependencies: + - ts-node + dev: true + + /tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + dev: true + + /tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + dependencies: + b4a: 1.6.6 + fast-fifo: 1.3.2 + streamx: 2.16.1 + dev: true + + /term-img@4.1.0: + resolution: {integrity: sha512-DFpBhaF5j+2f7kheKFc1ajsAUUDGOaNPpKPtiIMxlbfud6mvfFZuWGnTRpaujUa5J7yl6cIw/h6nyr4mSsENPg==} + engines: {node: '>=8'} + dependencies: + ansi-escapes: 4.3.2 + iterm2-version: 4.2.0 + dev: true + + /terser-webpack-plugin@5.3.10(webpack@5.91.0): + resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.31.0 + webpack: 5.91.0 + dev: true + + /terser@5.31.0: + resolution: {integrity: sha512-Q1JFAoUKE5IMfI4Z/lkE/E6+SwgzO+x4tq4v1AyBLRj8VSYvRO6A/rQrPg1yud4g0En9EKI1TvFRF2tQFcoUkg==} + engines: {node: '>=10'} + hasBin: true + dependencies: + '@jridgewell/source-map': 0.3.6 + acorn: 8.11.3 + commander: 2.20.3 + source-map-support: 0.5.21 + dev: true + + /test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + /text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + dev: true + + /thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + dependencies: + real-require: 0.2.0 + dev: true + + /throttleit@1.0.1: + resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==} + dev: true + + /through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 + dev: true + + /through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + /tiny-invariant@1.3.1: + resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} + dev: false + + /tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + dev: false + + /tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + dev: false + + /tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + + /tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + dev: true + + /tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + /to-camel-case@1.0.0: + resolution: {integrity: sha512-nD8pQi5H34kyu1QDMFjzEIYqk0xa9Alt6ZfrdEMuHCFOfTLhDG5pgTu/aAM9Wt9lXILwlXmWP43b8sav0GNE8Q==} + dependencies: + to-space-case: 1.0.0 + dev: false + + /to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + + /to-no-case@1.0.2: + resolution: {integrity: sha512-Z3g735FxuZY8rodxV4gH7LxClE4H0hTIyHNIHdk+vpQxjLm0cwnKXq/OFVZ76SOQmto7txVcwSCwkU5kqp+FKg==} + dev: false + + /to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + + /to-space-case@1.0.0: + resolution: {integrity: sha512-rLdvwXZ39VOn1IxGL3V6ZstoTbwLRckQmn/U8ZDLuWwIXNpuZDhQ3AiRUlhTbOXFVE9C+dR51wM0CBDhk31VcA==} + dependencies: + to-no-case: 1.0.2 + dev: false + + /totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + dev: true + + /tough-cookie@2.5.0: + resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} + engines: {node: '>=0.8'} + dependencies: + psl: 1.9.0 + punycode: 2.3.1 + dev: false + + /tough-cookie@4.1.3: + resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==} + engines: {node: '>=6'} + dependencies: + psl: 1.9.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + dev: true + + /tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + dependencies: + punycode: 2.3.1 + dev: true + + /tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + dev: true + + /trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + dev: false + + /trim-trailing-lines@2.1.0: + resolution: {integrity: sha512-5UR5Biq4VlVOtzqkm2AZlgvSlDJtME46uV0br0gENbwN4l5+mMKT4b9gJKqWtuL2zAIqajGJGuvbCbcAJUZqBg==} + dev: false + + /trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + dev: false + + /ts-api-utils@1.3.0(typescript@4.9.5): + resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + dependencies: + typescript: 4.9.5 + dev: true + + /ts-jest@29.1.1(@babel/core@7.24.3)(babel-jest@29.6.2)(jest@29.5.0)(typescript@4.9.5): + resolution: {integrity: sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/types': ^29.0.0 + babel-jest: ^29.0.0 + esbuild: '*' + jest: ^29.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + dependencies: + '@babel/core': 7.24.3 + babel-jest: 29.6.2(@babel/core@7.24.3) + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + jest: 29.5.0(@types/node@20.11.30) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.6.0 + typescript: 4.9.5 + yargs-parser: 21.1.1 + dev: true + + /ts-node-dev@2.0.0(@types/node@20.11.30)(typescript@4.9.5): + resolution: {integrity: sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==} + engines: {node: '>=0.8.0'} + hasBin: true + peerDependencies: + node-notifier: '*' + typescript: '*' + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + chokidar: 3.6.0 + dynamic-dedupe: 0.3.0 + minimist: 1.2.8 + mkdirp: 1.0.4 + resolve: 1.22.8 + rimraf: 2.7.1 + source-map-support: 0.5.21 + tree-kill: 1.2.2 + ts-node: 10.9.2(@types/node@20.11.30)(typescript@4.9.5) + tsconfig: 7.0.0 + typescript: 4.9.5 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + dev: true + + /ts-node@10.9.2(@types/node@20.11.30)(typescript@4.9.5): + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.11.30 + acorn: 8.11.3 + acorn-walk: 8.3.2 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 4.9.5 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + dev: true + + /ts-results@3.3.0: + resolution: {integrity: sha512-FWqxGX2NHp5oCyaMd96o2y2uMQmSu8Dey6kvyuFdRJ2AzfmWo3kWa4UsPlCGlfQ/qu03m09ZZtppMoY8EMHuiA==} + dev: false + + /tsconfig-paths-jest@0.0.1: + resolution: {integrity: sha512-YKhUKqbteklNppC2NqL7dv1cWF8eEobgHVD5kjF1y9Q4ocqpBiaDlYslQ9eMhtbqIPRrA68RIEXqknEjlxdwxw==} + dev: true + + /tsconfig@7.0.0: + resolution: {integrity: sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==} + dependencies: + '@types/strip-bom': 3.0.0 + '@types/strip-json-comments': 0.0.30 + strip-bom: 3.0.0 + strip-json-comments: 2.0.1 + dev: true + + /tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + + /tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + dependencies: + safe-buffer: 5.1.2 + + /tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + /type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + dev: true + + /type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + /type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + dev: true + + /type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + /type-fest@0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + dev: true + + /typed-array-buffer@1.0.2: + resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-typed-array: 1.1.13 + dev: true + + /typed-array-byte-length@1.0.1: + resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + dev: true + + /typed-array-byte-offset@1.0.2: + resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + dev: true + + /typed-array-length@1.0.6: + resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + possible-typed-array-names: 1.0.0 + dev: true + + /typedarray-to-buffer@3.1.5: + resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + dependencies: + is-typedarray: 1.0.0 + dev: true + + /typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + + /ufo@1.5.3: + resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} + dev: true + + /unbox-primitive@1.0.2: + resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + dependencies: + call-bind: 1.0.7 + has-bigints: 1.0.2 + has-symbols: 1.0.3 + which-boxed-primitive: 1.0.2 + dev: true + + /uncontrollable@7.2.1(react@18.2.0): + resolution: {integrity: sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==} + peerDependencies: + react: '>=15.0.0' + dependencies: + '@babel/runtime': 7.24.1 + '@types/react': 18.2.66 + invariant: 2.2.4 + react: 18.2.0 + react-lifecycles-compat: 3.0.4 + dev: false + + /undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + /unicode-canonical-property-names-ecmascript@2.0.0: + resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} + engines: {node: '>=4'} + dev: true + + /unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.0 + unicode-property-aliases-ecmascript: 2.1.0 + dev: true + + /unicode-match-property-value-ecmascript@2.1.0: + resolution: {integrity: sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==} + engines: {node: '>=4'} + dev: true + + /unicode-property-aliases-ecmascript@2.1.0: + resolution: {integrity: sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==} + engines: {node: '>=4'} + dev: true + + /unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + dev: false + + /uniq@1.0.1: + resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==} + dev: false + + /unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + dev: false + + /unist-util-is@6.0.0: + resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + dependencies: + '@types/unist': 3.0.3 + dev: false + + /unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + dependencies: + '@types/unist': 3.0.3 + dev: false + + /unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + dependencies: + '@types/unist': 3.0.3 + dev: false + + /unist-util-visit-parents@6.0.1: + resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + dev: false + + /unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + dev: false + + /unist@0.0.1: + resolution: {integrity: sha512-bnzuF8b6d47WubA4a5yLqFbuZz/v/NS6eRwUIdOaDmsqzwTlyv8yS1g3M7ISdtBQrigPD3qKK87Cu7zhEfCF3A==} + deprecated: Use @types/unist instead + dev: false + + /universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + dev: true + + /universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + dev: true + + /universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + dev: true + + /unsplash-js@7.0.19: + resolution: {integrity: sha512-j6qT2floy5Q2g2d939FJpwey1yw/GpQecFiSouyJtsHQPj3oqmqq3K4rI+GF8vU1zwGCT7ZwIGQd2dtCQLjYJw==} + engines: {node: '>=10'} + dev: false + + /untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + dev: true + + /update-browserslist-db@1.0.13(browserslist@4.23.0): + resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.23.0 + escalade: 3.1.2 + picocolors: 1.0.0 + + /upper-case-first@2.0.2: + resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} + dependencies: + tslib: 2.6.2 + dev: true + + /upper-case@2.0.2: + resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} + dependencies: + tslib: 2.6.2 + dev: true + + /uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + dependencies: + punycode: 2.3.1 + + /url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + dev: true + + /use-memo-one@1.1.3(react@18.2.0): + resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + + /use-sync-external-store@1.2.2(react@18.2.0): + resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + + /utf8@3.0.0: + resolution: {integrity: sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==} + dev: false + + /util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + dev: true + + /uuid@3.4.0: + resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} + deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. + hasBin: true + dev: false + + /uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + dev: true + + /uuid@9.0.0: + resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} + hasBin: true + dev: true + + /v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + dev: true + + /v8-to-istanbul@9.2.0: + resolution: {integrity: sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==} + engines: {node: '>=10.12.0'} + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + /validator@13.12.0: + resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==} + engines: {node: '>= 0.10'} + dev: false + + /verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + + /vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + dev: false + + /vfile-message@4.0.2: + resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + dev: false + + /vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.2 + dev: false + + /vite-plugin-compression2@1.0.0: + resolution: {integrity: sha512-42XNp6FjxE0JIecxj1fdi770pLhYm3MJhBUAod9EszTgDg9C4LDOgBzWcj/0K52KfJrpRXwUsWV6kqTDuoCfLA==} + dependencies: + '@rollup/pluginutils': 5.1.0 + gunzip-maybe: 1.4.2 + tar-stream: 3.1.7 + transitivePeerDependencies: + - rollup + dev: true + + /vite-plugin-externals@0.6.2(vite@5.2.0): + resolution: {integrity: sha512-R5oVY8xDJjLXLTs2XDYzvYbc/RTZuIwOx2xcFbYf+/VXB6eJuatDgt8jzQ7kZ+IrgwQhe6tU8U2fTyy72C25CQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: '>=2.0.0' + dependencies: + acorn: 8.11.3 + es-module-lexer: 0.4.1 + fs-extra: 10.1.0 + magic-string: 0.25.9 + vite: 5.2.0(@types/node@20.11.30)(sass@1.77.2) + dev: true + + /vite-plugin-html@3.2.2(vite@5.2.0): + resolution: {integrity: sha512-vb9C9kcdzcIo/Oc3CLZVS03dL5pDlOFuhGlZYDCJ840BhWl/0nGeZWf3Qy7NlOayscY4Cm/QRgULCQkEZige5Q==} + peerDependencies: + vite: '>=2.0.0' + dependencies: + '@rollup/pluginutils': 4.2.1 + colorette: 2.0.20 + connect-history-api-fallback: 1.6.0 + consola: 2.15.3 + dotenv: 16.4.5 + dotenv-expand: 8.0.3 + ejs: 3.1.10 + fast-glob: 3.3.2 + fs-extra: 10.1.0 + html-minifier-terser: 6.1.0 + node-html-parser: 5.4.2 + pathe: 0.2.0 + vite: 5.2.0(@types/node@20.11.30)(sass@1.77.2) + dev: true + + /vite-plugin-importer@0.2.5: + resolution: {integrity: sha512-6OtqJmVwnfw8+B4OIh7pIdXs+jLkN7g5PIqmZdpgrMYjIFMiZrcMB1zlyUQSTokKGC90KwXviO/lq1hcUBUG3Q==} + dependencies: + '@babel/core': 7.24.3 + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.3) + babel-plugin-import: 1.13.8 + transitivePeerDependencies: + - supports-color + dev: true + + /vite-plugin-istanbul@6.0.2(vite@5.2.0): + resolution: {integrity: sha512-0/sKwjEEIwbEyl43xX7onX3dIbMJAsigNsKyyVPalG1oRFo5jn3qkJbS2PUfp9wrr3piy1eT6qRoeeum2p4B2A==} + peerDependencies: + vite: '>=4 <=6' + dependencies: + '@istanbuljs/load-nyc-config': 1.1.0 + espree: 10.0.1 + istanbul-lib-instrument: 6.0.2 + picocolors: 1.0.0 + source-map: 0.7.4 + test-exclude: 6.0.0 + vite: 5.2.0(@types/node@20.11.30)(sass@1.77.2) + transitivePeerDependencies: + - supports-color + dev: true + + /vite-plugin-svgr@3.2.0(typescript@4.9.5)(vite@5.2.0): + resolution: {integrity: sha512-Uvq6niTvhqJU6ga78qLKBFJSDvxWhOnyfQSoKpDPMAGxJPo5S3+9hyjExE5YDj6Lpa4uaLkGc1cBgxXov+LjSw==} + peerDependencies: + vite: ^2.6.0 || 3 || 4 + dependencies: + '@rollup/pluginutils': 5.1.0 + '@svgr/core': 7.0.0(typescript@4.9.5) + '@svgr/plugin-jsx': 7.0.0 + vite: 5.2.0(@types/node@20.11.30)(sass@1.77.2) + transitivePeerDependencies: + - rollup + - supports-color + - typescript + dev: true + + /vite-plugin-terminal@1.2.0(vite@5.2.0): + resolution: {integrity: sha512-IIw1V+IySth8xlrGmH4U7YmfTp681vTzYpa7b8A3KNCJ2oW1BGPPwW8tSz6BQTvSgbRmrP/9NsBLsfXkN4e8sA==} + engines: {node: '>=14'} + peerDependencies: + vite: ^2.0.0||^3.0.0||^4.0.0||^5.0.0 + dependencies: + '@rollup/plugin-strip': 3.0.4 + debug: 4.3.4(supports-color@8.1.1) + kolorist: 1.8.0 + sirv: 2.0.4 + ufo: 1.5.3 + vite: 5.2.0(@types/node@20.11.30)(sass@1.77.2) + transitivePeerDependencies: + - rollup + - supports-color + dev: true + + /vite-plugin-total-bundle-size@1.0.7(vite@5.2.0): + resolution: {integrity: sha512-ritAi5hRcuNonHP1wquvzqkZHGpOqRpWiMoEQQDJ3DLYuuVAS3THKyIGv7QSGig5nT+xuMYTLUamBu3Legaipg==} + peerDependencies: + vite: '>=5.0.0' + dependencies: + chalk: 5.3.0 + vite: 5.2.0(@types/node@20.11.30)(sass@1.77.2) + dev: true + + /vite-plugin-wasm@3.3.0(vite@5.2.0): + resolution: {integrity: sha512-tVhz6w+W9MVsOCHzxo6SSMSswCeIw4HTrXEi6qL3IRzATl83jl09JVO1djBqPSwfjgnpVHNLYcaMbaDX5WB/pg==} + peerDependencies: + vite: ^2 || ^3 || ^4 || ^5 + dependencies: + vite: 5.2.0(@types/node@20.11.30)(sass@1.77.2) + dev: false + + /vite@5.2.0(@types/node@20.11.30)(sass@1.77.2): + resolution: {integrity: sha512-xMSLJNEjNk/3DJRgWlPADDwaU9AgYRodDH2t6oENhJnIlmU9Hx1Q6VpjyXua/JdMw1WJRbnAgHJ9xgET9gnIAg==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 20.11.30 + esbuild: 0.20.2 + postcss: 8.4.38 + rollup: 4.13.2 + sass: 1.77.2 + optionalDependencies: + fsevents: 2.3.3 + + /void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + dev: false + + /w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + dependencies: + xml-name-validator: 4.0.0 + dev: true + + /walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + dependencies: + makeerror: 1.0.12 + + /warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + dependencies: + loose-envify: 1.4.0 + + /watchpack@2.4.1: + resolution: {integrity: sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==} + engines: {node: '>=10.13.0'} + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + dev: true + + /web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + dev: false + + /webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + dev: true + + /webpack-sources@3.2.3: + resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} + engines: {node: '>=10.13.0'} + dev: true + + /webpack@5.91.0: + resolution: {integrity: sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.5 + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/wasm-edit': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + acorn: 8.11.3 + acorn-import-assertions: 1.9.0(acorn@8.11.3) + browserslist: 4.23.0 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.16.1 + es-module-lexer: 1.5.3 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.10(webpack@5.91.0) + watchpack: 2.4.1 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + dev: true + + /whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + dependencies: + iconv-lite: 0.6.3 + dev: true + + /whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + dev: true + + /whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + dev: true + + /which-boxed-primitive@1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + dependencies: + is-bigint: 1.0.4 + is-boolean-object: 1.1.2 + is-number-object: 1.0.7 + is-string: 1.0.7 + is-symbol: 1.0.4 + dev: true + + /which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + dev: true + + /which-typed-array@1.1.15: + resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.2 + dev: true + + /which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + + /wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + /wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + dev: true + + /wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + /write-file-atomic@3.0.3: + resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} + dependencies: + imurmurhash: 0.1.4 + is-typedarray: 1.0.0 + signal-exit: 3.0.7 + typedarray-to-buffer: 3.1.5 + dev: true + + /write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + /ws@8.16.0: + resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + + /xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + dev: true + + /xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + dev: true + + /xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true + + /xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + dev: true + + /y-indexeddb@9.0.12(yjs@14.0.0-1): + resolution: {integrity: sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + dependencies: + lib0: 0.2.94 + yjs: 14.0.0-1 + dev: false + + /y-protocols@1.0.6(yjs@14.0.0-1): + resolution: {integrity: sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + dependencies: + lib0: 0.2.94 + yjs: 14.0.0-1 + dev: false + + /y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + dev: true + + /y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + /yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + /yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + /yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + /yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + dev: true + + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + /yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + dev: true + + /yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + dependencies: + cliui: 8.0.1 + escalade: 3.1.2 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + /yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + dev: true + + /yjs@14.0.0-1: + resolution: {integrity: sha512-w0iJlEx+XvkvPkdBH0L8pb4Da2DvTEA7UdDl/dOFCQfA0siT4cUtbJ8LfoiliH2juYFqdIoqxbScHakKBiIv0g==} + requiresBuild: true + dependencies: + lib0: 0.2.94 + dev: false + + /yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + dev: true + + /yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + /yocto-queue@1.0.0: + resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} + engines: {node: '>=12.20'} + dev: true + + /zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + dev: false diff --git a/frontend/appflowy_web_app/postcss.config.cjs b/frontend/appflowy_web_app/postcss.config.cjs new file mode 100644 index 0000000000000..12a703d900da8 --- /dev/null +++ b/frontend/appflowy_web_app/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-chip-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-chip-spark.svg new file mode 100644 index 0000000000000..57bb666ba95d3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-chip-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-cloud-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-cloud-spark.svg new file mode 100644 index 0000000000000..385aaf5a03635 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-cloud-spark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-edit-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-edit-spark.svg new file mode 100644 index 0000000000000..96fddfc558523 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-edit-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-email-generator-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-email-generator-spark.svg new file mode 100644 index 0000000000000..8238d694428d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-email-generator-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-gaming-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-gaming-spark.svg new file mode 100644 index 0000000000000..a74a6eabb088e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-gaming-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-landscape-image-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-landscape-image-spark.svg new file mode 100644 index 0000000000000..0759443d47d93 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-landscape-image-spark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-music-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-music-spark.svg new file mode 100644 index 0000000000000..98adcabbe6dc7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-music-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-portrait-image-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-portrait-image-spark.svg new file mode 100644 index 0000000000000..ebd118dd629e8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-portrait-image-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-variation-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-variation-spark.svg new file mode 100644 index 0000000000000..c4400c215a17a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-generate-variation-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-navigation-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-navigation-spark.svg new file mode 100644 index 0000000000000..943e8354bda28 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-navigation-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-network-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-network-spark.svg new file mode 100644 index 0000000000000..ec21b6dc5207c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-network-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-prompt-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-prompt-spark.svg new file mode 100644 index 0000000000000..ecfcb6ad63fd1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-prompt-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-redo-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-redo-spark.svg new file mode 100644 index 0000000000000..d67e5e3f1fcc2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-redo-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-science-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-science-spark.svg new file mode 100644 index 0000000000000..e9a0af9957b8a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-science-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-settings-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-settings-spark.svg new file mode 100644 index 0000000000000..c0a1d6588b907 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-settings-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-technology-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-technology-spark.svg new file mode 100644 index 0000000000000..27b27f152aa57 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-technology-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-upscale-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-upscale-spark.svg new file mode 100644 index 0000000000000..91975ee23c370 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-upscale-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-vehicle-spark-1.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-vehicle-spark-1.svg new file mode 100644 index 0000000000000..48f3eda8dd687 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/ai-vehicle-spark-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/artificial-intelligence-spark.svg b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/artificial-intelligence-spark.svg new file mode 100644 index 0000000000000..c4c7907937a02 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/artificial_intelligence/artificial-intelligence-spark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/VPN-connection.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/VPN-connection.svg new file mode 100644 index 0000000000000..c8f7a2fcb0dd8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/VPN-connection.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/adobe.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/adobe.svg new file mode 100644 index 0000000000000..877de4e09492b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/adobe.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/alt.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/alt.svg new file mode 100644 index 0000000000000..6e08a0f9a347b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/alt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/amazon.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/amazon.svg new file mode 100644 index 0000000000000..d02e8a27c2eab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/amazon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/android.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/android.svg new file mode 100644 index 0000000000000..acc983a1a7680 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/android.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/app-store.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/app-store.svg new file mode 100644 index 0000000000000..59f17cc19752d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/app-store.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/apple.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/apple.svg new file mode 100644 index 0000000000000..94bfcf6cecf6d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/apple.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/asterisk-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/asterisk-1.svg new file mode 100644 index 0000000000000..c6b49a655d80a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/asterisk-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-alert-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-alert-1.svg new file mode 100644 index 0000000000000..dbfd40fefbdf5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-alert-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-charging.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-charging.svg new file mode 100644 index 0000000000000..22aa568e4beb1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-charging.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-empty-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-empty-1.svg new file mode 100644 index 0000000000000..d64921afe274b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-empty-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-empty-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-empty-2.svg new file mode 100644 index 0000000000000..d7bac48cc846c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-empty-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-full-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-full-1.svg new file mode 100644 index 0000000000000..4c7e68f5b5063 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-full-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-low-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-low-1.svg new file mode 100644 index 0000000000000..6524eb33001e4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-low-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-medium-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-medium-1.svg new file mode 100644 index 0000000000000..4620aa3da4c53 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/battery-medium-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth-disabled.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth-disabled.svg new file mode 100644 index 0000000000000..47487db565231 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth-disabled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth-searching.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth-searching.svg new file mode 100644 index 0000000000000..4535898788fd0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth-searching.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth.svg new file mode 100644 index 0000000000000..281960065f817 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/bluetooth.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/browser-wifi.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/browser-wifi.svg new file mode 100644 index 0000000000000..a81eccedf237a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/browser-wifi.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/chrome.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/chrome.svg new file mode 100644 index 0000000000000..56fc7af710f26 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/chrome.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/command.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/command.svg new file mode 100644 index 0000000000000..367a6e61170ad --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/command.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-chip-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-chip-1.svg new file mode 100644 index 0000000000000..2a70e75274cf9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-chip-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-chip-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-chip-2.svg new file mode 100644 index 0000000000000..0ef3150becafb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-chip-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-pc-desktop.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-pc-desktop.svg new file mode 100644 index 0000000000000..98bea051965ed --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/computer-pc-desktop.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/controller-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/controller-1.svg new file mode 100644 index 0000000000000..bf7bab0923767 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/controller-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/controller-wireless.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/controller-wireless.svg new file mode 100644 index 0000000000000..53045de283a51 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/controller-wireless.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/controller.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/controller.svg new file mode 100644 index 0000000000000..87ba8122dbeb9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/controller.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/cursor-click.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/cursor-click.svg new file mode 100644 index 0000000000000..2ca4ede8d0016 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/cursor-click.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/cyborg-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/cyborg-2.svg new file mode 100644 index 0000000000000..f90dbd9ce303d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/cyborg-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/cyborg.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/cyborg.svg new file mode 100644 index 0000000000000..cbdb10ea877de --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/cyborg.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-check.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-check.svg new file mode 100644 index 0000000000000..462f928903bed --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-lock.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-lock.svg new file mode 100644 index 0000000000000..60ee0c76ba167 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-lock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-refresh.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-refresh.svg new file mode 100644 index 0000000000000..0aeb96c4996f7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-refresh.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-remove.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-remove.svg new file mode 100644 index 0000000000000..d4e9971017d1d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-remove.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-server-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-server-1.svg new file mode 100644 index 0000000000000..0ca3030d20466 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-server-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-server-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-server-2.svg new file mode 100644 index 0000000000000..15196de131c3f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-server-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-setting.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-setting.svg new file mode 100644 index 0000000000000..ec6b34e6c10d2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-setting.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database-subtract-2-raid-storage-code-disk-programming-database-array-hard-disc-minus.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-subtract-2-raid-storage-code-disk-programming-database-array-hard-disc-minus.svg new file mode 100644 index 0000000000000..e0c0e75c52374 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database-subtract-2-raid-storage-code-disk-programming-database-array-hard-disc-minus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/database.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/database.svg new file mode 100644 index 0000000000000..31f57ca895dcf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/database.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/delete-keyboard.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/delete-keyboard.svg new file mode 100644 index 0000000000000..cb25e3d0b7993 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/delete-keyboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-chat.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-chat.svg new file mode 100644 index 0000000000000..774d3464f0bce --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-chat.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-check.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-check.svg new file mode 100644 index 0000000000000..3f2f30e2e81bf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-code.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-code.svg new file mode 100644 index 0000000000000..a4f0873ffc9a0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-code.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-delete.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-delete.svg new file mode 100644 index 0000000000000..45038bb01ae0a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-delete.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-dollar.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-dollar.svg new file mode 100644 index 0000000000000..161a456ba04f0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-dollar.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-emoji.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-emoji.svg new file mode 100644 index 0000000000000..dd4cadfd51cf6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-emoji.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-favorite-star.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-favorite-star.svg new file mode 100644 index 0000000000000..276cc7833e3df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-favorite-star.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-game.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-game.svg new file mode 100644 index 0000000000000..fa98bc4d46181 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-game.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-help.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-help.svg new file mode 100644 index 0000000000000..d651603e718b0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/desktop-help.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/device-database-encryption-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/device-database-encryption-1.svg new file mode 100644 index 0000000000000..230e5f79a15cc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/device-database-encryption-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/discord.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/discord.svg new file mode 100644 index 0000000000000..2cb14a8e6ce81 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/discord.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/drone.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/drone.svg new file mode 100644 index 0000000000000..8ad4a4f775338 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/drone.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/dropbox.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/dropbox.svg new file mode 100644 index 0000000000000..89f0cf0b8edcc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/dropbox.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/eject.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/eject.svg new file mode 100644 index 0000000000000..acea3c2839ba4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/eject.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/electric-cord-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/electric-cord-1.svg new file mode 100644 index 0000000000000..ef4bae5915996 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/electric-cord-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/electric-cord-3.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/electric-cord-3.svg new file mode 100644 index 0000000000000..59a85fabda463 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/electric-cord-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/facebook-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/facebook-1.svg new file mode 100644 index 0000000000000..7687d0331af2f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/facebook-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/figma.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/figma.svg new file mode 100644 index 0000000000000..316aacd34eb52 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/figma.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/floppy-disk.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/floppy-disk.svg new file mode 100644 index 0000000000000..be1351ba032cb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/floppy-disk.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/gmail.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/gmail.svg new file mode 100644 index 0000000000000..ce9a3c7d360dc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/gmail.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/google-drive.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/google-drive.svg new file mode 100644 index 0000000000000..521fe55ad84ed --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/google-drive.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/google.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/google.svg new file mode 100644 index 0000000000000..624af07bbb7a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/google.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held-tablet-drawing.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held-tablet-drawing.svg new file mode 100644 index 0000000000000..c9117d691654c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held-tablet-drawing.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held-tablet-writing.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held-tablet-writing.svg new file mode 100644 index 0000000000000..d619e9d69af6c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held-tablet-writing.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held.svg new file mode 100644 index 0000000000000..2cff3d5e043db --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/hand-held.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/hard-disk.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/hard-disk.svg new file mode 100644 index 0000000000000..46a25c5d5a876 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/hard-disk.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/hard-drive-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/hard-drive-1.svg new file mode 100644 index 0000000000000..929887c741724 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/hard-drive-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/instagram.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/instagram.svg new file mode 100644 index 0000000000000..2a0750b273d88 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/instagram.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard-virtual.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard-virtual.svg new file mode 100644 index 0000000000000..914dddf994627 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard-virtual.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard-wireless-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard-wireless-2.svg new file mode 100644 index 0000000000000..c3fb38cc92455 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard-wireless-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard.svg new file mode 100644 index 0000000000000..9a32238860793 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/keyboard.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/laptop-charging.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/laptop-charging.svg new file mode 100644 index 0000000000000..bbc233360c379 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/laptop-charging.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/linkedin.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/linkedin.svg new file mode 100644 index 0000000000000..6ed8fd3d8ccec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/linkedin.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/local-storage-folder.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/local-storage-folder.svg new file mode 100644 index 0000000000000..cb0673ab60fde --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/local-storage-folder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/meta.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/meta.svg new file mode 100644 index 0000000000000..d0937137b632b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/meta.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse-wireless-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse-wireless-1.svg new file mode 100644 index 0000000000000..697fb76677e27 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse-wireless-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse-wireless.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse-wireless.svg new file mode 100644 index 0000000000000..a2c554d6fb1e3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse-wireless.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse.svg new file mode 100644 index 0000000000000..972f69c52f58b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/mouse.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/netflix.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/netflix.svg new file mode 100644 index 0000000000000..6691a310864c3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/netflix.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/network.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/network.svg new file mode 100644 index 0000000000000..d33f91d839e2f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/network.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/next.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/next.svg new file mode 100644 index 0000000000000..1c68f75e7e08a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/next.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/paypal.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/paypal.svg new file mode 100644 index 0000000000000..e366f8e86e364 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/paypal.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/play-store.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/play-store.svg new file mode 100644 index 0000000000000..c84f1ca4c12be --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/play-store.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/printer.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/printer.svg new file mode 100644 index 0000000000000..79eefa06a404f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/printer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/return-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/return-2.svg new file mode 100644 index 0000000000000..45666d72b4f0f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/return-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-1.svg new file mode 100644 index 0000000000000..e5007b7f5b739 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-2.svg new file mode 100644 index 0000000000000..4b87d934f4728 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-curve.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-curve.svg new file mode 100644 index 0000000000000..dc6418c205217 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/screen-curve.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/screensaver-monitor-wallpaper.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/screensaver-monitor-wallpaper.svg new file mode 100644 index 0000000000000..cc0431f192a63 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/screensaver-monitor-wallpaper.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/shift.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/shift.svg new file mode 100644 index 0000000000000..3dfc9de387a60 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/shift.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/shredder.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/shredder.svg new file mode 100644 index 0000000000000..f6c9f4bffa77f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/shredder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/signal-loading.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/signal-loading.svg new file mode 100644 index 0000000000000..d04c5d1c44bf6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/signal-loading.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/slack.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/slack.svg new file mode 100644 index 0000000000000..266a0018c4fcc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/slack.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/spotify.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/spotify.svg new file mode 100644 index 0000000000000..f0f0365ae8606 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/spotify.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/telegram.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/telegram.svg new file mode 100644 index 0000000000000..4bccbe1779e7f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/telegram.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/tiktok.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/tiktok.svg new file mode 100644 index 0000000000000..8f03d36c6b933 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/tiktok.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/tinder.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/tinder.svg new file mode 100644 index 0000000000000..ca0e251a7fdb1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/tinder.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/twitter.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/twitter.svg new file mode 100644 index 0000000000000..f8e13c447cc13 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/twitter.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/usb-drive.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/usb-drive.svg new file mode 100644 index 0000000000000..417555a5c3276 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/usb-drive.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/virtual-reality.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/virtual-reality.svg new file mode 100644 index 0000000000000..6521aa766132f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/virtual-reality.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/voice-mail-off.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/voice-mail-off.svg new file mode 100644 index 0000000000000..175d036b30c10 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/voice-mail-off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/voice-mail.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/voice-mail.svg new file mode 100644 index 0000000000000..78d4bd13b5d79 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/voice-mail.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-1.svg new file mode 100644 index 0000000000000..54039f5b8e757 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-2.svg new file mode 100644 index 0000000000000..87f6e84bf831c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-charging.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-charging.svg new file mode 100644 index 0000000000000..95bf9a6a1930c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-charging.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-heartbeat-monitor-1.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-heartbeat-monitor-1.svg new file mode 100644 index 0000000000000..7e0a9419ed73b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-heartbeat-monitor-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-heartbeat-monitor-2.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-heartbeat-monitor-2.svg new file mode 100644 index 0000000000000..575a4cdaf14f3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-heartbeat-monitor-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-menu.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-menu.svg new file mode 100644 index 0000000000000..f79637bcd5927 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-time.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-time.svg new file mode 100644 index 0000000000000..7b3145988dced --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/watch-circle-time.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video-circle.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video-circle.svg new file mode 100644 index 0000000000000..d583495165e8f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video-off.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video-off.svg new file mode 100644 index 0000000000000..9750416e9941d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video-off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video.svg new file mode 100644 index 0000000000000..30407900c1cdc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam-video.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam.svg new file mode 100644 index 0000000000000..67007be1ac4bb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/webcam.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/whatsapp.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/whatsapp.svg new file mode 100644 index 0000000000000..bb7da75eb6268 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/whatsapp.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-antenna.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-antenna.svg new file mode 100644 index 0000000000000..b41ae562a48e1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-antenna.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-disabled.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-disabled.svg new file mode 100644 index 0000000000000..a561d55e842b4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-disabled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-horizontal.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-horizontal.svg new file mode 100644 index 0000000000000..9f0f3f20a6037 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-horizontal.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-router.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-router.svg new file mode 100644 index 0000000000000..d7d9490b1a48b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi-router.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi.svg new file mode 100644 index 0000000000000..c6ebd0432cbab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/wifi.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/computer_devices/windows.svg b/frontend/appflowy_web_app/public/af_icons/computer_devices/windows.svg new file mode 100644 index 0000000000000..b1923cc5f99e0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/computer_devices/windows.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/christian-cross-1.svg b/frontend/appflowy_web_app/public/af_icons/culture/christian-cross-1.svg new file mode 100644 index 0000000000000..8dea5f0109138 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/christian-cross-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/christian-cross-2.svg b/frontend/appflowy_web_app/public/af_icons/culture/christian-cross-2.svg new file mode 100644 index 0000000000000..4ac9b8ede7437 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/christian-cross-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/christianity.svg b/frontend/appflowy_web_app/public/af_icons/culture/christianity.svg new file mode 100644 index 0000000000000..1a083b53298a7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/christianity.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/dhammajak.svg b/frontend/appflowy_web_app/public/af_icons/culture/dhammajak.svg new file mode 100644 index 0000000000000..00ad06208139b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/dhammajak.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/hexagram.svg b/frontend/appflowy_web_app/public/af_icons/culture/hexagram.svg new file mode 100644 index 0000000000000..e9a5fbe4289d3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/hexagram.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/hinduism.svg b/frontend/appflowy_web_app/public/af_icons/culture/hinduism.svg new file mode 100644 index 0000000000000..cca81645928c8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/hinduism.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/islam.svg b/frontend/appflowy_web_app/public/af_icons/culture/islam.svg new file mode 100644 index 0000000000000..c2af2b380e6e0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/islam.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/news-paper.svg b/frontend/appflowy_web_app/public/af_icons/culture/news-paper.svg new file mode 100644 index 0000000000000..24d109d27d920 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/news-paper.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/peace-symbol.svg b/frontend/appflowy_web_app/public/af_icons/culture/peace-symbol.svg new file mode 100644 index 0000000000000..0249f8402e363 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/peace-symbol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/politics-compaign.svg b/frontend/appflowy_web_app/public/af_icons/culture/politics-compaign.svg new file mode 100644 index 0000000000000..2333d4f883ebc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/politics-compaign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/politics-speech.svg b/frontend/appflowy_web_app/public/af_icons/culture/politics-speech.svg new file mode 100644 index 0000000000000..e199c705cb8ad --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/politics-speech.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/politics-vote-2.svg b/frontend/appflowy_web_app/public/af_icons/culture/politics-vote-2.svg new file mode 100644 index 0000000000000..846d1522e9033 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/politics-vote-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/ticket-1.svg b/frontend/appflowy_web_app/public/af_icons/culture/ticket-1.svg new file mode 100644 index 0000000000000..67ecf103280d9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/ticket-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/tickets.svg b/frontend/appflowy_web_app/public/af_icons/culture/tickets.svg new file mode 100644 index 0000000000000..e06e36633f397 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/tickets.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/yin-yang-symbol.svg b/frontend/appflowy_web_app/public/af_icons/culture/yin-yang-symbol.svg new file mode 100644 index 0000000000000..e645e68433178 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/yin-yang-symbol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-1.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-1.svg new file mode 100644 index 0000000000000..721204e5e4870 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-10.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-10.svg new file mode 100644 index 0000000000000..4fdf248b38627 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-10.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-11.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-11.svg new file mode 100644 index 0000000000000..447b9c56c9e48 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-11.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-12.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-12.svg new file mode 100644 index 0000000000000..fb2b1cb991b7a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-12.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-2.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-2.svg new file mode 100644 index 0000000000000..d4425722d9c07 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-3.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-3.svg new file mode 100644 index 0000000000000..0208aea702b2e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-4.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-4.svg new file mode 100644 index 0000000000000..0469f30ae0f44 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-4.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-5.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-5.svg new file mode 100644 index 0000000000000..218ba4a391f8b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-5.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-6.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-6.svg new file mode 100644 index 0000000000000..f02c49ee738f9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-6.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-7.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-7.svg new file mode 100644 index 0000000000000..b9de613da2035 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-7.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-8.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-8.svg new file mode 100644 index 0000000000000..646ba98ea8b14 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-8.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/culture/zodiac-9.svg b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-9.svg new file mode 100644 index 0000000000000..062bf1140ffee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/culture/zodiac-9.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/balloon.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/balloon.svg new file mode 100644 index 0000000000000..328aaaaaf15ec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/balloon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/bow.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/bow.svg new file mode 100644 index 0000000000000..2864709ca8071 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/bow.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-fast-forward-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-fast-forward-1.svg new file mode 100644 index 0000000000000..dd04b7e8c6cc9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-fast-forward-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-fast-forward-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-fast-forward-2.svg new file mode 100644 index 0000000000000..f3d3dc72bc58c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-fast-forward-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-next.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-next.svg new file mode 100644 index 0000000000000..c3b1a23a065f8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-next.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-pause-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-pause-2.svg new file mode 100644 index 0000000000000..983544897aebd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-pause-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-play.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-play.svg new file mode 100644 index 0000000000000..a07ab94655767 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-play.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-power-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-power-1.svg new file mode 100644 index 0000000000000..ef9e77f8775e6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-power-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-previous.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-previous.svg new file mode 100644 index 0000000000000..1f376dc16f7bb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-previous.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-record-3.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-record-3.svg new file mode 100644 index 0000000000000..0e9332cb25a9a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-record-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-rewind-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-rewind-1.svg new file mode 100644 index 0000000000000..d36b320fd977f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-rewind-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-rewind-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-rewind-2.svg new file mode 100644 index 0000000000000..beb36d9804aaf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-rewind-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/button-stop.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/button-stop.svg new file mode 100644 index 0000000000000..a3339d0b1b871 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/button-stop.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/camera-video.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/camera-video.svg new file mode 100644 index 0000000000000..1dc4e57ea7ad2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/camera-video.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/cards.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/cards.svg new file mode 100644 index 0000000000000..aa54a4dcc6f47 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/cards.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/chess-bishop.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-bishop.svg new file mode 100644 index 0000000000000..f667a4e84c603 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-bishop.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/chess-king.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-king.svg new file mode 100644 index 0000000000000..6cdbf1a76ed5d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-king.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/chess-knight.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-knight.svg new file mode 100644 index 0000000000000..027afaedcc8b1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-knight.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/chess-pawn.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-pawn.svg new file mode 100644 index 0000000000000..9e995acb97968 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/chess-pawn.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/cloud-gaming-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/cloud-gaming-1.svg new file mode 100644 index 0000000000000..874cac202333d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/cloud-gaming-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/clubs-symbol.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/clubs-symbol.svg new file mode 100644 index 0000000000000..23207373a1d38 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/clubs-symbol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/diamonds-symbol.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/diamonds-symbol.svg new file mode 100644 index 0000000000000..d184ba745521d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/diamonds-symbol.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dice-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-1.svg new file mode 100644 index 0000000000000..adfab0f74c023 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dice-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-2.svg new file mode 100644 index 0000000000000..94ad5db18a4b9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dice-3.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-3.svg new file mode 100644 index 0000000000000..0e7571ae958a1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dice-4.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-4.svg new file mode 100644 index 0000000000000..37d68fcffcd30 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-4.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dice-5.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-5.svg new file mode 100644 index 0000000000000..eabbd0ed3b1bc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-5.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dice-6.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-6.svg new file mode 100644 index 0000000000000..36a19135ae795 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dice-6.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/dices-entertainment-gaming-dices.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/dices-entertainment-gaming-dices.svg new file mode 100644 index 0000000000000..ea1f1d84adf7e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/dices-entertainment-gaming-dices.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/earpods.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/earpods.svg new file mode 100644 index 0000000000000..890a89753e5c1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/earpods.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/epic-games-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/epic-games-1.svg new file mode 100644 index 0000000000000..d1eb2af8fe520 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/epic-games-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/esports.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/esports.svg new file mode 100644 index 0000000000000..3f7bcd4c410ec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/esports.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/fireworks-rocket.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/fireworks-rocket.svg new file mode 100644 index 0000000000000..fcc4d96bcdf64 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/fireworks-rocket.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/gameboy.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/gameboy.svg new file mode 100644 index 0000000000000..402531f20a3a7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/gameboy.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/gramophone.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/gramophone.svg new file mode 100644 index 0000000000000..0ed2f0b26fd16 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/gramophone.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/hearts-symbol.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/hearts-symbol.svg new file mode 100644 index 0000000000000..fc6cce023f387 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/hearts-symbol.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/music-equalizer.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/music-equalizer.svg new file mode 100644 index 0000000000000..9fbd4aba84791 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/music-equalizer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-1.svg new file mode 100644 index 0000000000000..644ba5553d936 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-2.svg new file mode 100644 index 0000000000000..96efe68daa720 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-off-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-off-1.svg new file mode 100644 index 0000000000000..5f5be24b3734e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-off-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-off-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-off-2.svg new file mode 100644 index 0000000000000..8e6cffcfbd195 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/music-note-off-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/nintendo-switch.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/nintendo-switch.svg new file mode 100644 index 0000000000000..31a17e97f1d6b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/nintendo-switch.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/one-vesus-one.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/one-vesus-one.svg new file mode 100644 index 0000000000000..31c3d7e26567f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/one-vesus-one.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/pacman.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/pacman.svg new file mode 100644 index 0000000000000..a42ee2bf02c36 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/pacman.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/party-popper.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/party-popper.svg new file mode 100644 index 0000000000000..2d7033ddb32c9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/party-popper.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-4.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-4.svg new file mode 100644 index 0000000000000..6655dfb7d60ce --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-4.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-5.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-5.svg new file mode 100644 index 0000000000000..747bb3d86f43c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-5.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-8.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-8.svg new file mode 100644 index 0000000000000..cda68aa414873 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-8.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-9.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-9.svg new file mode 100644 index 0000000000000..eb9df98361068 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-9.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-folder.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-folder.svg new file mode 100644 index 0000000000000..f6226c0d62b49 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/play-list-folder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/play-station.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/play-station.svg new file mode 100644 index 0000000000000..eb281023b8f89 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/play-station.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/radio.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/radio.svg new file mode 100644 index 0000000000000..068af297a279d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/radio.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/recording-tape-bubble-circle.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/recording-tape-bubble-circle.svg new file mode 100644 index 0000000000000..fa5ba15b9e0ec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/recording-tape-bubble-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/recording-tape-bubble-square.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/recording-tape-bubble-square.svg new file mode 100644 index 0000000000000..69d0897329363 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/recording-tape-bubble-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/song-recommendation.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/song-recommendation.svg new file mode 100644 index 0000000000000..a53a018dae167 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/song-recommendation.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/spades-symbol.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/spades-symbol.svg new file mode 100644 index 0000000000000..36a510d14ba6d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/spades-symbol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/speaker-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/speaker-1.svg new file mode 100644 index 0000000000000..105430d2ad587 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/speaker-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/speaker-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/speaker-2.svg new file mode 100644 index 0000000000000..79cf8682b6418 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/speaker-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/stream.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/stream.svg new file mode 100644 index 0000000000000..188e0c1a8f8c3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/stream.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/tape-cassette-record.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/tape-cassette-record.svg new file mode 100644 index 0000000000000..1ecc8cb52f3c2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/tape-cassette-record.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/volume-down.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-down.svg new file mode 100644 index 0000000000000..c86a4fd7d94bb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-high.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-high.svg new file mode 100644 index 0000000000000..b560324f28046 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-high.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-low.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-low.svg new file mode 100644 index 0000000000000..726d2adef3c95 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-low.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-off.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-off.svg new file mode 100644 index 0000000000000..a4a4d827dd1d5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-level-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/volume-mute.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-mute.svg new file mode 100644 index 0000000000000..02c8c1da05d32 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-mute.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/volume-off.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-off.svg new file mode 100644 index 0000000000000..5d9afb737a9d7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/volume-off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/vr-headset-1.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/vr-headset-1.svg new file mode 100644 index 0000000000000..99a7bea697187 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/vr-headset-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/vr-headset-2.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/vr-headset-2.svg new file mode 100644 index 0000000000000..88cd45c4ed459 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/vr-headset-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/entertainment/xbox.svg b/frontend/appflowy_web_app/public/af_icons/entertainment/xbox.svg new file mode 100644 index 0000000000000..47efc6bc543db --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/entertainment/xbox.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/beer-mug.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/beer-mug.svg new file mode 100644 index 0000000000000..01ecef571622e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/beer-mug.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/beer-pitch.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/beer-pitch.svg new file mode 100644 index 0000000000000..6eda98884b219 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/beer-pitch.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/burger.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/burger.svg new file mode 100644 index 0000000000000..12c6c9d249426 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/burger.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/burrito-fastfood.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/burrito-fastfood.svg new file mode 100644 index 0000000000000..88abc83543a83 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/burrito-fastfood.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/cake-slice.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/cake-slice.svg new file mode 100644 index 0000000000000..ec6132a52004a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/cake-slice.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/candy-cane.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/candy-cane.svg new file mode 100644 index 0000000000000..12510b6fcb6b4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/candy-cane.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/champagne-party-alcohol.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/champagne-party-alcohol.svg new file mode 100644 index 0000000000000..01c22f99550b5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/champagne-party-alcohol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/cheese.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/cheese.svg new file mode 100644 index 0000000000000..721c865fb4f6f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/cheese.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/cherries.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/cherries.svg new file mode 100644 index 0000000000000..df3d75d719f71 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/cherries.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/chicken-grilled-stream.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/chicken-grilled-stream.svg new file mode 100644 index 0000000000000..b3410829f2ca6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/chicken-grilled-stream.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/cocktail.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/cocktail.svg new file mode 100644 index 0000000000000..fa4f8a3c2f5ab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/cocktail.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-bean.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-bean.svg new file mode 100644 index 0000000000000..17cd87ef5213c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-bean.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-mug.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-mug.svg new file mode 100644 index 0000000000000..9d798f57617f3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-mug.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-takeaway-cup.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-takeaway-cup.svg new file mode 100644 index 0000000000000..c4db12c0235f9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/coffee-takeaway-cup.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/donut.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/donut.svg new file mode 100644 index 0000000000000..9e43a78ce1a28 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/donut.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/fork-knife.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/fork-knife.svg new file mode 100644 index 0000000000000..c084ce727ba48 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/fork-knife.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/fork-spoon.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/fork-spoon.svg new file mode 100644 index 0000000000000..b1ac770721293 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/fork-spoon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/ice-cream-2.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/ice-cream-2.svg new file mode 100644 index 0000000000000..de00d2d5d7ecd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/ice-cream-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/ice-cream-3.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/ice-cream-3.svg new file mode 100644 index 0000000000000..8b4d864570142 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/ice-cream-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/lemon-fruit-seasoning.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/lemon-fruit-seasoning.svg new file mode 100644 index 0000000000000..3da07de6799ff --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/lemon-fruit-seasoning.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/microwave.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/microwave.svg new file mode 100644 index 0000000000000..162c26c96d9b7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/microwave.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/milkshake.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/milkshake.svg new file mode 100644 index 0000000000000..9a73d0d4e43b6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/milkshake.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/popcorn.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/popcorn.svg new file mode 100644 index 0000000000000..33cf71d4442d1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/popcorn.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/pork-meat.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/pork-meat.svg new file mode 100644 index 0000000000000..081e550618f11 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/pork-meat.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/refrigerator.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/refrigerator.svg new file mode 100644 index 0000000000000..89f233c48d7c8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/refrigerator.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/serving-dome.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/serving-dome.svg new file mode 100644 index 0000000000000..1bdc48d306fba --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/serving-dome.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/shrimp.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/shrimp.svg new file mode 100644 index 0000000000000..b9a5add6da303 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/shrimp.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/strawberry.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/strawberry.svg new file mode 100644 index 0000000000000..14aa7a9f8d843 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/strawberry.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/tea-cup.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/tea-cup.svg new file mode 100644 index 0000000000000..e678274acc00f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/tea-cup.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/toast.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/toast.svg new file mode 100644 index 0000000000000..5aa9be15ad3d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/toast.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/water-glass.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/water-glass.svg new file mode 100644 index 0000000000000..8e9f674c8c64c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/water-glass.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/food_drink/wine.svg b/frontend/appflowy_web_app/public/af_icons/food_drink/wine.svg new file mode 100644 index 0000000000000..1f6be74e618ee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/food_drink/wine.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/ambulance.svg b/frontend/appflowy_web_app/public/af_icons/health/ambulance.svg new file mode 100644 index 0000000000000..c0747996ff9de --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/ambulance.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/bacteria-virus-cells-biology.svg b/frontend/appflowy_web_app/public/af_icons/health/bacteria-virus-cells-biology.svg new file mode 100644 index 0000000000000..39c0d6442a6f3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/bacteria-virus-cells-biology.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/bandage.svg b/frontend/appflowy_web_app/public/af_icons/health/bandage.svg new file mode 100644 index 0000000000000..ff4e17b118c51 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/bandage.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/blood-bag-donation.svg b/frontend/appflowy_web_app/public/af_icons/health/blood-bag-donation.svg new file mode 100644 index 0000000000000..6a558d54deb42 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/blood-bag-donation.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/blood-donate-drop.svg b/frontend/appflowy_web_app/public/af_icons/health/blood-donate-drop.svg new file mode 100644 index 0000000000000..6959a292d527f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/blood-donate-drop.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/blood-drop-donation.svg b/frontend/appflowy_web_app/public/af_icons/health/blood-drop-donation.svg new file mode 100644 index 0000000000000..8bd0bfc4324a0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/blood-drop-donation.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/brain-cognitive.svg b/frontend/appflowy_web_app/public/af_icons/health/brain-cognitive.svg new file mode 100644 index 0000000000000..9fdb4125d4715 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/brain-cognitive.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/brain.svg b/frontend/appflowy_web_app/public/af_icons/health/brain.svg new file mode 100644 index 0000000000000..e12682c2effb8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/brain.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/call-center-support-service.svg b/frontend/appflowy_web_app/public/af_icons/health/call-center-support-service.svg new file mode 100644 index 0000000000000..1593b6c790190 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/call-center-support-service.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/checkup-medical-report-clipboard.svg b/frontend/appflowy_web_app/public/af_icons/health/checkup-medical-report-clipboard.svg new file mode 100644 index 0000000000000..2837b7f2e595b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/checkup-medical-report-clipboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/ear-hearing.svg b/frontend/appflowy_web_app/public/af_icons/health/ear-hearing.svg new file mode 100644 index 0000000000000..5fa596e560608 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/ear-hearing.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/eye-optic.svg b/frontend/appflowy_web_app/public/af_icons/health/eye-optic.svg new file mode 100644 index 0000000000000..1617190a8e094 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/eye-optic.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/flu-mask.svg b/frontend/appflowy_web_app/public/af_icons/health/flu-mask.svg new file mode 100644 index 0000000000000..365d4cab843e8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/flu-mask.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/health-care-2.svg b/frontend/appflowy_web_app/public/af_icons/health/health-care-2.svg new file mode 100644 index 0000000000000..495a02dc3d312 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/health-care-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/heart-rate-pulse-graph.svg b/frontend/appflowy_web_app/public/af_icons/health/heart-rate-pulse-graph.svg new file mode 100644 index 0000000000000..0351f05eb27d4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/heart-rate-pulse-graph.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/heart-rate-search.svg b/frontend/appflowy_web_app/public/af_icons/health/heart-rate-search.svg new file mode 100644 index 0000000000000..e8a6faa1db7f8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/heart-rate-search.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/hospital-sign-circle.svg b/frontend/appflowy_web_app/public/af_icons/health/hospital-sign-circle.svg new file mode 100644 index 0000000000000..964abce175798 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/hospital-sign-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/hospital-sign-square.svg b/frontend/appflowy_web_app/public/af_icons/health/hospital-sign-square.svg new file mode 100644 index 0000000000000..1648b17479dfc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/hospital-sign-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/insurance-hand.svg b/frontend/appflowy_web_app/public/af_icons/health/insurance-hand.svg new file mode 100644 index 0000000000000..70ae0036b96eb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/insurance-hand.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/medical-bag.svg b/frontend/appflowy_web_app/public/af_icons/health/medical-bag.svg new file mode 100644 index 0000000000000..c469e591d9148 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/medical-bag.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/medical-cross-sign-healthcare.svg b/frontend/appflowy_web_app/public/af_icons/health/medical-cross-sign-healthcare.svg new file mode 100644 index 0000000000000..fc35cba77e818 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/medical-cross-sign-healthcare.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/medical-cross-symbol.svg b/frontend/appflowy_web_app/public/af_icons/health/medical-cross-symbol.svg new file mode 100644 index 0000000000000..7906b49bd2626 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/medical-cross-symbol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/medical-files-report-history.svg b/frontend/appflowy_web_app/public/af_icons/health/medical-files-report-history.svg new file mode 100644 index 0000000000000..b18c22e979c51 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/medical-files-report-history.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/medical-ribbon-1.svg b/frontend/appflowy_web_app/public/af_icons/health/medical-ribbon-1.svg new file mode 100644 index 0000000000000..c53c3ef448b92 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/medical-ribbon-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/medical-search-diagnosis.svg b/frontend/appflowy_web_app/public/af_icons/health/medical-search-diagnosis.svg new file mode 100644 index 0000000000000..f5995068ccff4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/medical-search-diagnosis.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/microscope-observation-sciene.svg b/frontend/appflowy_web_app/public/af_icons/health/microscope-observation-sciene.svg new file mode 100644 index 0000000000000..be4a39d09ec1d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/microscope-observation-sciene.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/nurse-assistant-emergency.svg b/frontend/appflowy_web_app/public/af_icons/health/nurse-assistant-emergency.svg new file mode 100644 index 0000000000000..43e1a0fcbf345 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/nurse-assistant-emergency.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/nurse-hat.svg b/frontend/appflowy_web_app/public/af_icons/health/nurse-hat.svg new file mode 100644 index 0000000000000..e8f3ca9dc3d61 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/nurse-hat.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/online-medical-call-service.svg b/frontend/appflowy_web_app/public/af_icons/health/online-medical-call-service.svg new file mode 100644 index 0000000000000..24190f5ed4aa8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/online-medical-call-service.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/online-medical-service-monitor.svg b/frontend/appflowy_web_app/public/af_icons/health/online-medical-service-monitor.svg new file mode 100644 index 0000000000000..85370f0d5710f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/online-medical-service-monitor.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/online-medical-web-service.svg b/frontend/appflowy_web_app/public/af_icons/health/online-medical-web-service.svg new file mode 100644 index 0000000000000..cf683b8d42cfb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/online-medical-web-service.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/petri-dish-lab-equipment.svg b/frontend/appflowy_web_app/public/af_icons/health/petri-dish-lab-equipment.svg new file mode 100644 index 0000000000000..46409cf4d8c1c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/petri-dish-lab-equipment.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/pharmacy.svg b/frontend/appflowy_web_app/public/af_icons/health/pharmacy.svg new file mode 100644 index 0000000000000..c2f871ae9b7b7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/pharmacy.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/prescription-pills-drugs-healthcare.svg b/frontend/appflowy_web_app/public/af_icons/health/prescription-pills-drugs-healthcare.svg new file mode 100644 index 0000000000000..0b297f59f049d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/prescription-pills-drugs-healthcare.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/sign-cross-square.svg b/frontend/appflowy_web_app/public/af_icons/health/sign-cross-square.svg new file mode 100644 index 0000000000000..a3f893c951bd1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/sign-cross-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/sos-help-emergency-sign.svg b/frontend/appflowy_web_app/public/af_icons/health/sos-help-emergency-sign.svg new file mode 100644 index 0000000000000..850b0371363f4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/sos-help-emergency-sign.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/stethoscope.svg b/frontend/appflowy_web_app/public/af_icons/health/stethoscope.svg new file mode 100644 index 0000000000000..f78716a7f86e9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/stethoscope.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/syringe.svg b/frontend/appflowy_web_app/public/af_icons/health/syringe.svg new file mode 100644 index 0000000000000..07fe454cff2d5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/syringe.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/tablet-capsule.svg b/frontend/appflowy_web_app/public/af_icons/health/tablet-capsule.svg new file mode 100644 index 0000000000000..9553c056c38a3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/tablet-capsule.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/tooth.svg b/frontend/appflowy_web_app/public/af_icons/health/tooth.svg new file mode 100644 index 0000000000000..6817c2b796bc3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/tooth.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/virus-antivirus.svg b/frontend/appflowy_web_app/public/af_icons/health/virus-antivirus.svg new file mode 100644 index 0000000000000..ad972cff8b54d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/virus-antivirus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/waiting-appointments-calendar.svg b/frontend/appflowy_web_app/public/af_icons/health/waiting-appointments-calendar.svg new file mode 100644 index 0000000000000..59ab62e17f948 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/waiting-appointments-calendar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/health/wheelchair.svg b/frontend/appflowy_web_app/public/af_icons/health/wheelchair.svg new file mode 100644 index 0000000000000..a29e32ca48402 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/health/wheelchair.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/icons.json b/frontend/appflowy_web_app/public/af_icons/icons.json new file mode 100644 index 0000000000000..b76b0d051a97e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/icons.json @@ -0,0 +1 @@ +{ "artificial_intelligence": [ { "id": "artificial_intelligence/ai-chip-spark", "name": "ai-chip-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-cloud-spark", "name": "ai-cloud-spark", "keywords": [], "content": "\n \n \n \n \n \n \n \n \n\n" }, { "id": "artificial_intelligence/ai-edit-spark", "name": "ai-edit-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-email-generator-spark", "name": "ai-email-generator-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-gaming-spark", "name": "ai-gaming-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-generate-landscape-image-spark", "name": "ai-generate-landscape-image-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-generate-music-spark", "name": "ai-generate-music-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-generate-portrait-image-spark", "name": "ai-generate-portrait-image-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-generate-variation-spark", "name": "ai-generate-variation-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-navigation-spark", "name": "ai-navigation-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-network-spark", "name": "ai-network-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-prompt-spark", "name": "ai-prompt-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-redo-spark", "name": "ai-redo-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-science-spark", "name": "ai-science-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-settings-spark", "name": "ai-settings-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-technology-spark", "name": "ai-technology-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-upscale-spark", "name": "ai-upscale-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/ai-vehicle-spark-1", "name": "ai-vehicle-spark-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "artificial_intelligence/artificial-intelligence-spark", "name": "artificial-intelligence-spark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "computer_devices": [ { "id": "computer_devices/adobe", "name": "adobe", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/alt", "name": "alt", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/amazon", "name": "amazon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/android", "name": "android", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/app-store", "name": "app-store", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/apple", "name": "apple", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/asterisk-1", "name": "asterisk-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/battery-alert-1", "name": "battery-alert-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/battery-charging", "name": "battery-charging", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/battery-empty-1", "name": "battery-empty-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/battery-empty-2", "name": "battery-empty-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/battery-full-1", "name": "battery-full-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/battery-low-1", "name": "battery-low-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/battery-medium-1", "name": "battery-medium-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/bluetooth-disabled", "name": "bluetooth-disabled", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/bluetooth-searching", "name": "bluetooth-searching", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/bluetooth", "name": "bluetooth", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/browser-wifi", "name": "browser-wifi", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/chrome", "name": "chrome", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/command", "name": "command", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/computer-chip-1", "name": "computer-chip-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/computer-chip-2", "name": "computer-chip-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/computer-pc-desktop", "name": "computer-pc-desktop", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/controller-1", "name": "controller-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/controller-wireless", "name": "controller-wireless", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/controller", "name": "controller", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/cursor-click", "name": "cursor-click", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/cyborg-2", "name": "cyborg-2", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/cyborg", "name": "cyborg", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/database-check", "name": "database-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-lock", "name": "database-lock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-refresh", "name": "database-refresh", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-remove", "name": "database-remove", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-server-1", "name": "database-server-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-server-2", "name": "database-server-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-setting", "name": "database-setting", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database-subtract-2-raid-storage-code-disk-programming-database-array-hard-disc-minus", "name": "database-subtract-2-raid-storage-code-disk-programming-database-array-hard-disc-minus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/database", "name": "database", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/delete-keyboard", "name": "delete-keyboard", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/desktop-chat", "name": "desktop-chat", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-check", "name": "desktop-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-code", "name": "desktop-code", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-delete", "name": "desktop-delete", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-dollar", "name": "desktop-dollar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-emoji", "name": "desktop-emoji", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-favorite-star", "name": "desktop-favorite-star", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-game", "name": "desktop-game", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/desktop-help", "name": "desktop-help", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/device-database-encryption-1", "name": "device-database-encryption-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/discord", "name": "discord", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/drone", "name": "drone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/dropbox", "name": "dropbox", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/eject", "name": "eject", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/electric-cord-1", "name": "electric-cord-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/electric-cord-3", "name": "electric-cord-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/facebook-1", "name": "facebook-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/figma", "name": "figma", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/floppy-disk", "name": "floppy-disk", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/gmail", "name": "gmail", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/google-drive", "name": "google-drive", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/google", "name": "google", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/hand-held-tablet-drawing", "name": "hand-held-tablet-drawing", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/hand-held-tablet-writing", "name": "hand-held-tablet-writing", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/hand-held", "name": "hand-held", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/hard-disk", "name": "hard-disk", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/hard-drive-1", "name": "hard-drive-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/instagram", "name": "instagram", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/keyboard-virtual", "name": "keyboard-virtual", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/keyboard-wireless-2", "name": "keyboard-wireless-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/keyboard", "name": "keyboard", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/laptop-charging", "name": "laptop-charging", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/linkedin", "name": "linkedin", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/local-storage-folder", "name": "local-storage-folder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/meta", "name": "meta", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/mouse-wireless-1", "name": "mouse-wireless-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/mouse-wireless", "name": "mouse-wireless", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/mouse", "name": "mouse", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/netflix", "name": "netflix", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/network", "name": "network", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/next", "name": "next", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/paypal", "name": "paypal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/play-store", "name": "play-store", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/printer", "name": "printer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/return-2", "name": "return-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/screen-1", "name": "screen-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/screen-2", "name": "screen-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/screen-curve", "name": "screen-curve", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/screensaver-monitor-wallpaper", "name": "screensaver-monitor-wallpaper", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/shift", "name": "shift", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/shredder", "name": "shredder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/signal-loading", "name": "signal-loading", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/slack", "name": "slack", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/spotify", "name": "spotify", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/telegram", "name": "telegram", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/tiktok", "name": "tiktok", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/tinder", "name": "tinder", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/twitter", "name": "twitter", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/usb-drive", "name": "usb-drive", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/virtual-reality", "name": "virtual-reality", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/voice-mail-off", "name": "voice-mail-off", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/voice-mail", "name": "voice-mail", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/VPN-connection", "name": "VPN-connection", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/watch-1", "name": "watch-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/watch-2", "name": "watch-2", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/watch-circle-charging", "name": "watch-circle-charging", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/watch-circle-heartbeat-monitor-1", "name": "watch-circle-heartbeat-monitor-1", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/watch-circle-heartbeat-monitor-2", "name": "watch-circle-heartbeat-monitor-2", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/watch-circle-menu", "name": "watch-circle-menu", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/watch-circle-time", "name": "watch-circle-time", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/webcam-video-circle", "name": "webcam-video-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/webcam-video-off", "name": "webcam-video-off", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/webcam-video", "name": "webcam-video", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/webcam", "name": "webcam", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/whatsapp", "name": "whatsapp", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/wifi-antenna", "name": "wifi-antenna", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/wifi-disabled", "name": "wifi-disabled", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/wifi-horizontal", "name": "wifi-horizontal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/wifi-router", "name": "wifi-router", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "computer_devices/wifi", "name": "wifi", "keywords": [], "content": "\n\n\n" }, { "id": "computer_devices/windows", "name": "windows", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "culture": [ { "id": "culture/christian-cross-1", "name": "christian-cross-1", "keywords": [], "content": "\n\n\n" }, { "id": "culture/christian-cross-2", "name": "christian-cross-2", "keywords": [], "content": "\n\n\n" }, { "id": "culture/christianity", "name": "christianity", "keywords": [], "content": "\n\n\n" }, { "id": "culture/dhammajak", "name": "dhammajak", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/hexagram", "name": "hexagram", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/hinduism", "name": "hinduism", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/islam", "name": "islam", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/news-paper", "name": "news-paper", "keywords": [], "content": "\n\n\n" }, { "id": "culture/peace-symbol", "name": "peace-symbol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/politics-compaign", "name": "politics-compaign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/politics-speech", "name": "politics-speech", "keywords": [], "content": "\n\n\n" }, { "id": "culture/politics-vote-2", "name": "politics-vote-2", "keywords": [], "content": "\n\n\n" }, { "id": "culture/ticket-1", "name": "ticket-1", "keywords": [], "content": "\n\n\n" }, { "id": "culture/tickets", "name": "tickets", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/yin-yang-symbol", "name": "yin-yang-symbol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-1", "name": "zodiac-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-10", "name": "zodiac-10", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-11", "name": "zodiac-11", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-12", "name": "zodiac-12", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-2", "name": "zodiac-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-3", "name": "zodiac-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-4", "name": "zodiac-4", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-5", "name": "zodiac-5", "keywords": [], "content": "\n\n\n" }, { "id": "culture/zodiac-6", "name": "zodiac-6", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-7", "name": "zodiac-7", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "culture/zodiac-8", "name": "zodiac-8", "keywords": [], "content": "\n\n\n" }, { "id": "culture/zodiac-9", "name": "zodiac-9", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "entertainment": [ { "id": "entertainment/balloon", "name": "balloon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/bow", "name": "bow", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-fast-forward-1", "name": "button-fast-forward-1", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/button-fast-forward-2", "name": "button-fast-forward-2", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/button-next", "name": "button-next", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-pause-2", "name": "button-pause-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-play", "name": "button-play", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-power-1", "name": "button-power-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-previous", "name": "button-previous", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-record-3", "name": "button-record-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/button-rewind-1", "name": "button-rewind-1", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/button-rewind-2", "name": "button-rewind-2", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/button-stop", "name": "button-stop", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/camera-video", "name": "camera-video", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/cards", "name": "cards", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/chess-bishop", "name": "chess-bishop", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/chess-king", "name": "chess-king", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/chess-knight", "name": "chess-knight", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/chess-pawn", "name": "chess-pawn", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/cloud-gaming-1", "name": "cloud-gaming-1", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/clubs-symbol", "name": "clubs-symbol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/diamonds-symbol", "name": "diamonds-symbol", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/dice-1", "name": "dice-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/dice-2", "name": "dice-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/dice-3", "name": "dice-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/dice-4", "name": "dice-4", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/dice-5", "name": "dice-5", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/dice-6", "name": "dice-6", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/dices-entertainment-gaming-dices", "name": "dices-entertainment-gaming-dices", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/earpods", "name": "earpods", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/epic-games-1", "name": "epic-games-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/esports", "name": "esports", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/fireworks-rocket", "name": "fireworks-rocket", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/gameboy", "name": "gameboy", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/gramophone", "name": "gramophone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/hearts-symbol", "name": "hearts-symbol", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/music-equalizer", "name": "music-equalizer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/music-note-1", "name": "music-note-1", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/music-note-2", "name": "music-note-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/music-note-off-1", "name": "music-note-off-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/music-note-off-2", "name": "music-note-off-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/nintendo-switch", "name": "nintendo-switch", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/one-vesus-one", "name": "one-vesus-one", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/pacman", "name": "pacman", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/party-popper", "name": "party-popper", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/play-list-4", "name": "play-list-4", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/play-list-5", "name": "play-list-5", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/play-list-8", "name": "play-list-8", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/play-list-9", "name": "play-list-9", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/play-list-folder", "name": "play-list-folder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/play-station", "name": "play-station", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/radio", "name": "radio", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/recording-tape-bubble-circle", "name": "recording-tape-bubble-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/recording-tape-bubble-square", "name": "recording-tape-bubble-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/song-recommendation", "name": "song-recommendation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/spades-symbol", "name": "spades-symbol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/speaker-1", "name": "speaker-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/speaker-2", "name": "speaker-2", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/stream", "name": "stream", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/tape-cassette-record", "name": "tape-cassette-record", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/volume-down", "name": "volume-down", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/volume-level-high", "name": "volume-level-high", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/volume-level-low", "name": "volume-level-low", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/volume-level-off", "name": "volume-level-off", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/volume-mute", "name": "volume-mute", "keywords": [], "content": "\n\n\n" }, { "id": "entertainment/volume-off", "name": "volume-off", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/vr-headset-1", "name": "vr-headset-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/vr-headset-2", "name": "vr-headset-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "entertainment/xbox", "name": "xbox", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "food_drink": [ { "id": "food_drink/beer-mug", "name": "beer-mug", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/beer-pitch", "name": "beer-pitch", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/burger", "name": "burger", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/burrito-fastfood", "name": "burrito-fastfood", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/cake-slice", "name": "cake-slice", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/candy-cane", "name": "candy-cane", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/champagne-party-alcohol", "name": "champagne-party-alcohol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/cheese", "name": "cheese", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/cherries", "name": "cherries", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/chicken-grilled-stream", "name": "chicken-grilled-stream", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/cocktail", "name": "cocktail", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/coffee-bean", "name": "coffee-bean", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/coffee-mug", "name": "coffee-mug", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/coffee-takeaway-cup", "name": "coffee-takeaway-cup", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/donut", "name": "donut", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/fork-knife", "name": "fork-knife", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/fork-spoon", "name": "fork-spoon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/ice-cream-2", "name": "ice-cream-2", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/ice-cream-3", "name": "ice-cream-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/lemon-fruit-seasoning", "name": "lemon-fruit-seasoning", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/microwave", "name": "microwave", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/milkshake", "name": "milkshake", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/popcorn", "name": "popcorn", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/pork-meat", "name": "pork-meat", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/refrigerator", "name": "refrigerator", "keywords": [], "content": "\n\n\n" }, { "id": "food_drink/serving-dome", "name": "serving-dome", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/shrimp", "name": "shrimp", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/strawberry", "name": "strawberry", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/tea-cup", "name": "tea-cup", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/toast", "name": "toast", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/water-glass", "name": "water-glass", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "food_drink/wine", "name": "wine", "keywords": [], "content": "\n\n\n" } ], "health": [ { "id": "health/ambulance", "name": "ambulance", "keywords": [], "content": "\n\n\n" }, { "id": "health/bacteria-virus-cells-biology", "name": "bacteria-virus-cells-biology", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/bandage", "name": "bandage", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/blood-bag-donation", "name": "blood-bag-donation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/blood-donate-drop", "name": "blood-donate-drop", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/blood-drop-donation", "name": "blood-drop-donation", "keywords": [], "content": "\n\n\n" }, { "id": "health/brain-cognitive", "name": "brain-cognitive", "keywords": [], "content": "\n\n\n" }, { "id": "health/brain", "name": "brain", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/call-center-support-service", "name": "call-center-support-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/checkup-medical-report-clipboard", "name": "checkup-medical-report-clipboard", "keywords": [], "content": "\n\n\n" }, { "id": "health/ear-hearing", "name": "ear-hearing", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/eye-optic", "name": "eye-optic", "keywords": [], "content": "\n\n\n" }, { "id": "health/flu-mask", "name": "flu-mask", "keywords": [], "content": "\n\n\n" }, { "id": "health/health-care-2", "name": "health-care-2", "keywords": [], "content": "\n\n\n" }, { "id": "health/heart-rate-pulse-graph", "name": "heart-rate-pulse-graph", "keywords": [], "content": "\n\n\n" }, { "id": "health/heart-rate-search", "name": "heart-rate-search", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/hospital-sign-circle", "name": "hospital-sign-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/hospital-sign-square", "name": "hospital-sign-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/insurance-hand", "name": "insurance-hand", "keywords": [], "content": "\n\n\n" }, { "id": "health/medical-bag", "name": "medical-bag", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/medical-cross-sign-healthcare", "name": "medical-cross-sign-healthcare", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/medical-cross-symbol", "name": "medical-cross-symbol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/medical-files-report-history", "name": "medical-files-report-history", "keywords": [], "content": "\n\n\n" }, { "id": "health/medical-ribbon-1", "name": "medical-ribbon-1", "keywords": [], "content": "\n\n\n" }, { "id": "health/medical-search-diagnosis", "name": "medical-search-diagnosis", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/microscope-observation-sciene", "name": "microscope-observation-sciene", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/nurse-assistant-emergency", "name": "nurse-assistant-emergency", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/nurse-hat", "name": "nurse-hat", "keywords": [], "content": "\n\n\n" }, { "id": "health/online-medical-call-service", "name": "online-medical-call-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/online-medical-service-monitor", "name": "online-medical-service-monitor", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/online-medical-web-service", "name": "online-medical-web-service", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/petri-dish-lab-equipment", "name": "petri-dish-lab-equipment", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/pharmacy", "name": "pharmacy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/prescription-pills-drugs-healthcare", "name": "prescription-pills-drugs-healthcare", "keywords": [], "content": "\n\n\n" }, { "id": "health/sign-cross-square", "name": "sign-cross-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/sos-help-emergency-sign", "name": "sos-help-emergency-sign", "keywords": [], "content": "\n\n\n" }, { "id": "health/stethoscope", "name": "stethoscope", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/syringe", "name": "syringe", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/tablet-capsule", "name": "tablet-capsule", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/tooth", "name": "tooth", "keywords": [], "content": "\n\n\n" }, { "id": "health/virus-antivirus", "name": "virus-antivirus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/waiting-appointments-calendar", "name": "waiting-appointments-calendar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "health/wheelchair", "name": "wheelchair", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "images_photography": [ { "id": "images_photography/auto-flash", "name": "auto-flash", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/camera-1", "name": "camera-1", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/camera-disabled", "name": "camera-disabled", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/camera-loading", "name": "camera-loading", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/camera-square", "name": "camera-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/composition-oval", "name": "composition-oval", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/composition-vertical", "name": "composition-vertical", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/compsition-horizontal", "name": "compsition-horizontal", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/edit-image-photo", "name": "edit-image-photo", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/film-roll-1", "name": "film-roll-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/film-slate", "name": "film-slate", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/flash-1", "name": "flash-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/flash-2", "name": "flash-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/flash-3", "name": "flash-3", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/flash-off", "name": "flash-off", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/flower", "name": "flower", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/focus-points", "name": "focus-points", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/landscape-2", "name": "landscape-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/landscape-setting", "name": "landscape-setting", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/laptop-camera", "name": "laptop-camera", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/mobile-phone-camera", "name": "mobile-phone-camera", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "images_photography/orientation-landscape", "name": "orientation-landscape", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/orientation-portrait", "name": "orientation-portrait", "keywords": [], "content": "\n\n\n" }, { "id": "images_photography/polaroid-four", "name": "polaroid-four", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "interface_essential": [ { "id": "interface_essential/add-1", "name": "add-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/add-bell-notification", "name": "add-bell-notification", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/add-circle", "name": "add-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/add-layer-2", "name": "add-layer-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/add-square", "name": "add-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/alarm-clock", "name": "alarm-clock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/align-back-1", "name": "align-back-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/align-center", "name": "align-center", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/align-front-1", "name": "align-front-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/align-left", "name": "align-left", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/align-right", "name": "align-right", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/ampersand", "name": "ampersand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/archive-box", "name": "archive-box", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-bend-left-down-2", "name": "arrow-bend-left-down-2", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/arrow-bend-right-down-2", "name": "arrow-bend-right-down-2", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/arrow-crossover-down", "name": "arrow-crossover-down", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-crossover-left", "name": "arrow-crossover-left", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-crossover-right", "name": "arrow-crossover-right", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-crossover-up", "name": "arrow-crossover-up", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-cursor-1", "name": "arrow-cursor-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-cursor-2", "name": "arrow-cursor-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-curvy-up-down-1", "name": "arrow-curvy-up-down-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-curvy-up-down-2", "name": "arrow-curvy-up-down-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-down-2", "name": "arrow-down-2", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/arrow-down-dashed-square", "name": "arrow-down-dashed-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-expand", "name": "arrow-expand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-infinite-loop", "name": "arrow-infinite-loop", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/arrow-move", "name": "arrow-move", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-reload-horizontal-1", "name": "arrow-reload-horizontal-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-reload-horizontal-2", "name": "arrow-reload-horizontal-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-reload-vertical-1", "name": "arrow-reload-vertical-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-reload-vertical-2", "name": "arrow-reload-vertical-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-roadmap", "name": "arrow-roadmap", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-round-left", "name": "arrow-round-left", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/arrow-round-right", "name": "arrow-round-right", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/arrow-shrink-diagonal-1", "name": "arrow-shrink-diagonal-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-shrink-diagonal-2", "name": "arrow-shrink-diagonal-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-shrink", "name": "arrow-shrink", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-transfer-diagonal-1", "name": "arrow-transfer-diagonal-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-transfer-diagonal-2", "name": "arrow-transfer-diagonal-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-transfer-diagonal-3", "name": "arrow-transfer-diagonal-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-up-1", "name": "arrow-up-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/arrow-up-dashed-square", "name": "arrow-up-dashed-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/ascending-number-order", "name": "ascending-number-order", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/attribution", "name": "attribution", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/blank-calendar", "name": "blank-calendar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/blank-notepad", "name": "blank-notepad", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/block-bell-notification", "name": "block-bell-notification", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/bomb", "name": "bomb", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/bookmark", "name": "bookmark", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/braces-circle", "name": "braces-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/brightness-1", "name": "brightness-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/brightness-2", "name": "brightness-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/brightness-3", "name": "brightness-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/broken-link-2", "name": "broken-link-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/bullet-list", "name": "bullet-list", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/calendar-add", "name": "calendar-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/calendar-edit", "name": "calendar-edit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/calendar-jump-to-date", "name": "calendar-jump-to-date", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/calendar-star", "name": "calendar-star", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/celsius", "name": "celsius", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/check-square", "name": "check-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/check", "name": "check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/circle-clock", "name": "circle-clock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/circle", "name": "circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/clipboard-add", "name": "clipboard-add", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/clipboard-check", "name": "clipboard-check", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/clipboard-remove", "name": "clipboard-remove", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/cloud", "name": "cloud", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/cog", "name": "cog", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/color-palette", "name": "color-palette", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/color-picker", "name": "color-picker", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/color-swatches", "name": "color-swatches", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/cone-shape", "name": "cone-shape", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/convert-PDF-2", "name": "convert-PDF-2", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/copy-paste", "name": "copy-paste", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/creative-commons", "name": "creative-commons", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/crop-selection", "name": "crop-selection", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/crown", "name": "crown", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/customer-support-1", "name": "customer-support-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/cut", "name": "cut", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/dark-dislay-mode", "name": "dark-dislay-mode", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/dashboard-3", "name": "dashboard-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/dashboard-circle", "name": "dashboard-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/delete-1", "name": "delete-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/descending-number-order", "name": "descending-number-order", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/disable-bell-notification", "name": "disable-bell-notification", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/disable-heart", "name": "disable-heart", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/division-circle", "name": "division-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/download-box-1", "name": "download-box-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/download-circle", "name": "download-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/download-computer", "name": "download-computer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/download-file", "name": "download-file", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/empty-clipboard", "name": "empty-clipboard", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/equal-sign", "name": "equal-sign", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/expand-horizontal-1", "name": "expand-horizontal-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/expand-window-2", "name": "expand-window-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/expand", "name": "expand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/face-scan-1", "name": "face-scan-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/factorial", "name": "factorial", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/fahrenheit", "name": "fahrenheit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/fastforward-clock", "name": "fastforward-clock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/file-add-alternate", "name": "file-add-alternate", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/file-delete-alternate", "name": "file-delete-alternate", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/file-remove-alternate", "name": "file-remove-alternate", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/filter-2", "name": "filter-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/fingerprint-1", "name": "fingerprint-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/fingerprint-2", "name": "fingerprint-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/fist", "name": "fist", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/fit-to-height-square", "name": "fit-to-height-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/flip-vertical-arrow-2", "name": "flip-vertical-arrow-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/flip-vertical-circle-1", "name": "flip-vertical-circle-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/flip-vertical-square-2", "name": "flip-vertical-square-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/folder-add", "name": "folder-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/folder-check", "name": "folder-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/folder-delete", "name": "folder-delete", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/front-camera", "name": "front-camera", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/gif-format", "name": "gif-format", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/give-gift", "name": "give-gift", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/glasses", "name": "glasses", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/half-star-1", "name": "half-star-1", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/hand-cursor", "name": "hand-cursor", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/hand-grab", "name": "hand-grab", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/heading-1-paragraph-styles-heading", "name": "heading-1-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/heading-2-paragraph-styles-heading", "name": "heading-2-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/heading-3-paragraph-styles-heading", "name": "heading-3-paragraph-styles-heading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/heart", "name": "heart", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/help-chat-2", "name": "help-chat-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/help-question-1", "name": "help-question-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/hierarchy-10", "name": "hierarchy-10", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/hierarchy-13", "name": "hierarchy-13", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/hierarchy-14", "name": "hierarchy-14", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/hierarchy-2", "name": "hierarchy-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/hierarchy-4", "name": "hierarchy-4", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/hierarchy-7", "name": "hierarchy-7", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/home-3", "name": "home-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/home-4", "name": "home-4", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/horizontal-menu-circle", "name": "horizontal-menu-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/humidity-none", "name": "humidity-none", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/image-blur", "name": "image-blur", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/image-saturation", "name": "image-saturation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/information-circle", "name": "information-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/input-box", "name": "input-box", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/insert-side", "name": "insert-side", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/insert-top-left", "name": "insert-top-left", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/insert-top-right", "name": "insert-top-right", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/invisible-1", "name": "invisible-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/invisible-2", "name": "invisible-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/jump-object", "name": "jump-object", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/key", "name": "key", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/keyhole-lock-circle", "name": "keyhole-lock-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/lasso-tool", "name": "lasso-tool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/layers-1", "name": "layers-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/layers-2", "name": "layers-2", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/layout-window-1", "name": "layout-window-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/layout-window-11", "name": "layout-window-11", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/layout-window-2", "name": "layout-window-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/layout-window-8", "name": "layout-window-8", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/lightbulb", "name": "lightbulb", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/like-1", "name": "like-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/link-chain", "name": "link-chain", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/live-video", "name": "live-video", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/lock-rotation", "name": "lock-rotation", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/login-1", "name": "login-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/logout-1", "name": "logout-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/loop-1", "name": "loop-1", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/magic-wand-2", "name": "magic-wand-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/magnifying-glass-circle", "name": "magnifying-glass-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/magnifying-glass", "name": "magnifying-glass", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/manual-book", "name": "manual-book", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/megaphone-2", "name": "megaphone-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/minimize-window-2", "name": "minimize-window-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/moon-cloud", "name": "moon-cloud", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/move-left", "name": "move-left", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/move-right", "name": "move-right", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/multiple-file-2", "name": "multiple-file-2", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/music-folder-song", "name": "music-folder-song", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/new-file", "name": "new-file", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/new-folder", "name": "new-folder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/new-sticky-note", "name": "new-sticky-note", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/not-equal-sign", "name": "not-equal-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/ok-hand", "name": "ok-hand", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/one-finger-drag-horizontal", "name": "one-finger-drag-horizontal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/one-finger-drag-vertical", "name": "one-finger-drag-vertical", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/one-finger-hold", "name": "one-finger-hold", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/one-finger-tap", "name": "one-finger-tap", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/open-book", "name": "open-book", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/open-umbrella", "name": "open-umbrella", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/padlock-square-1", "name": "padlock-square-1", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/page-setting", "name": "page-setting", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/paint-bucket", "name": "paint-bucket", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/paint-palette", "name": "paint-palette", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/paintbrush-1", "name": "paintbrush-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/paintbrush-2", "name": "paintbrush-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/paperclip-1", "name": "paperclip-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/paragraph", "name": "paragraph", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-divide", "name": "pathfinder-divide", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-exclude", "name": "pathfinder-exclude", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-intersect", "name": "pathfinder-intersect", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-merge", "name": "pathfinder-merge", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-minus-front-1", "name": "pathfinder-minus-front-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-trim", "name": "pathfinder-trim", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pathfinder-union", "name": "pathfinder-union", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/peace-hand", "name": "peace-hand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pen-3", "name": "pen-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pen-draw", "name": "pen-draw", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pen-tool", "name": "pen-tool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pencil", "name": "pencil", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pentagon", "name": "pentagon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pi-symbol-circle", "name": "pi-symbol-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pictures-folder-memories", "name": "pictures-folder-memories", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/podium", "name": "podium", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/polygon", "name": "polygon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/praying-hand", "name": "praying-hand", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/projector-board", "name": "projector-board", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/pyramid-shape", "name": "pyramid-shape", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/quotation-2", "name": "quotation-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/radioactive-2", "name": "radioactive-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/rain-cloud", "name": "rain-cloud", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/recycle-bin-2", "name": "recycle-bin-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/ringing-bell-notification", "name": "ringing-bell-notification", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/rock-and-roll-hand", "name": "rock-and-roll-hand", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/rotate-angle-45", "name": "rotate-angle-45", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/round-cap", "name": "round-cap", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/satellite-dish", "name": "satellite-dish", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/scanner", "name": "scanner", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/search-visual", "name": "search-visual", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/select-circle-area-1", "name": "select-circle-area-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/share-link", "name": "share-link", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/shield-1", "name": "shield-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/shield-2", "name": "shield-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/shield-check", "name": "shield-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/shield-cross", "name": "shield-cross", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/shrink-horizontal-1", "name": "shrink-horizontal-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/shuffle", "name": "shuffle", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/sigma", "name": "sigma", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/skull-1", "name": "skull-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/sleep", "name": "sleep", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/snow-flake", "name": "snow-flake", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/sort-descending", "name": "sort-descending", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/spiral-shape", "name": "spiral-shape", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/split-vertical", "name": "split-vertical", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/spray-paint", "name": "spray-paint", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/square-brackets-circle", "name": "square-brackets-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/square-cap", "name": "square-cap", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/square-clock", "name": "square-clock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/square-root-x-circle", "name": "square-root-x-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/star-1", "name": "star-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/star-2", "name": "star-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/star-badge", "name": "star-badge", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/straight-cap", "name": "straight-cap", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/subtract-1", "name": "subtract-1", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/subtract-circle", "name": "subtract-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/subtract-square", "name": "subtract-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/sun-cloud", "name": "sun-cloud", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/synchronize-disable", "name": "synchronize-disable", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/synchronize-warning", "name": "synchronize-warning", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/table-lamp-1", "name": "table-lamp-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/tag", "name": "tag", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/text-flow-rows", "name": "text-flow-rows", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/text-square", "name": "text-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/text-style", "name": "text-style", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/thermometer", "name": "thermometer", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/trending-content", "name": "trending-content", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/trophy", "name": "trophy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/two-finger-drag-hotizontal", "name": "two-finger-drag-hotizontal", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/two-finger-tap", "name": "two-finger-tap", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/underline-text-1", "name": "underline-text-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/upload-box-1", "name": "upload-box-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/upload-circle", "name": "upload-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/upload-computer", "name": "upload-computer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/upload-file", "name": "upload-file", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/user-add-plus", "name": "user-add-plus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-check-validate", "name": "user-check-validate", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-circle-single", "name": "user-circle-single", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-identifier-card", "name": "user-identifier-card", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/user-multiple-circle", "name": "user-multiple-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-multiple-group", "name": "user-multiple-group", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-profile-focus", "name": "user-profile-focus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-protection-2", "name": "user-protection-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-remove-subtract", "name": "user-remove-subtract", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/user-single-neutral-male", "name": "user-single-neutral-male", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/user-sync-online-in-person", "name": "user-sync-online-in-person", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/vertical-slider-square", "name": "vertical-slider-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/video-swap-camera", "name": "video-swap-camera", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/visible", "name": "visible", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/voice-scan-2", "name": "voice-scan-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/waning-cresent-moon", "name": "waning-cresent-moon", "keywords": [], "content": "\n\n\n" }, { "id": "interface_essential/warning-octagon", "name": "warning-octagon", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "interface_essential/warning-triangle", "name": "warning-triangle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "mail": [ { "id": "mail/chat-bubble-oval-notification", "name": "chat-bubble-oval-notification", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-oval-smiley-1", "name": "chat-bubble-oval-smiley-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-oval-smiley-2", "name": "chat-bubble-oval-smiley-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-oval", "name": "chat-bubble-oval", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-square-block", "name": "chat-bubble-square-block", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-square-question", "name": "chat-bubble-square-question", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-square-warning", "name": "chat-bubble-square-warning", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-square-write", "name": "chat-bubble-square-write", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-text-square", "name": "chat-bubble-text-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-bubble-typing-oval", "name": "chat-bubble-typing-oval", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/chat-two-bubbles-oval", "name": "chat-two-bubbles-oval", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/discussion-converstion-reply", "name": "discussion-converstion-reply", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/happy-face", "name": "happy-face", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/inbox-block", "name": "inbox-block", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/inbox-favorite-heart", "name": "inbox-favorite-heart", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/inbox-favorite", "name": "inbox-favorite", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/inbox-lock", "name": "inbox-lock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/inbox-tray-1", "name": "inbox-tray-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/inbox-tray-2", "name": "inbox-tray-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/mail-incoming", "name": "mail-incoming", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/mail-search", "name": "mail-search", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/mail-send-email-message", "name": "mail-send-email-message", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/mail-send-envelope", "name": "mail-send-envelope", "keywords": [], "content": "\n\n\n" }, { "id": "mail/mail-send-reply-all", "name": "mail-send-reply-all", "keywords": [], "content": "\n\n\n" }, { "id": "mail/sad-face", "name": "sad-face", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/send-email", "name": "send-email", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/sign-at", "name": "sign-at", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/sign-hashtag", "name": "sign-hashtag", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-angry", "name": "smiley-angry", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-cool", "name": "smiley-cool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-crying-1", "name": "smiley-crying-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-cute", "name": "smiley-cute", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-drool", "name": "smiley-drool", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-emoji-kiss-nervous", "name": "smiley-emoji-kiss-nervous", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-emoji-terrified", "name": "smiley-emoji-terrified", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-grumpy", "name": "smiley-grumpy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-happy", "name": "smiley-happy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-in-love", "name": "smiley-in-love", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-kiss", "name": "smiley-kiss", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "mail/smiley-laughing-3", "name": "smiley-laughing-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "map_travel": [ { "id": "map_travel/airplane", "name": "airplane", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/airport-plane-transit", "name": "airport-plane-transit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/airport-plane", "name": "airport-plane", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/airport-security", "name": "airport-security", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/anchor", "name": "anchor", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/baggage", "name": "baggage", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/beach", "name": "beach", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/bicycle-bike", "name": "bicycle-bike", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/braille-blind", "name": "braille-blind", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/bus", "name": "bus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/camping-tent", "name": "camping-tent", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/cane", "name": "cane", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/capitol", "name": "capitol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/car-battery-charging", "name": "car-battery-charging", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/car-taxi-1", "name": "car-taxi-1", "keywords": [], "content": "\n\n\n\n\n" }, { "id": "map_travel/city-hall", "name": "city-hall", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/compass-navigator", "name": "compass-navigator", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/crutch", "name": "crutch", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/dangerous-zone-sign", "name": "dangerous-zone-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/earth-1", "name": "earth-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/earth-airplane", "name": "earth-airplane", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/emergency-exit", "name": "emergency-exit", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/fire-alarm-2", "name": "fire-alarm-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/fire-extinguisher-sign", "name": "fire-extinguisher-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/gas-station-fuel-petroleum", "name": "gas-station-fuel-petroleum", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/hearing-deaf-1", "name": "hearing-deaf-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/hearing-deaf-2", "name": "hearing-deaf-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/high-speed-train-front", "name": "high-speed-train-front", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/hot-spring", "name": "hot-spring", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/hotel-air-conditioner", "name": "hotel-air-conditioner", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/hotel-bed-2", "name": "hotel-bed-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/hotel-laundry", "name": "hotel-laundry", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/hotel-one-star", "name": "hotel-one-star", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/hotel-shower-head", "name": "hotel-shower-head", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/hotel-two-star", "name": "hotel-two-star", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/information-desk-customer", "name": "information-desk-customer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/information-desk", "name": "information-desk", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/iron", "name": "iron", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/ladder", "name": "ladder", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/lift-disability", "name": "lift-disability", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/lift", "name": "lift", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/location-compass-1", "name": "location-compass-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/location-pin-3", "name": "location-pin-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/location-pin-disabled", "name": "location-pin-disabled", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/location-target-1", "name": "location-target-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/lost-and-found", "name": "lost-and-found", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/man-symbol", "name": "man-symbol", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/map-fold", "name": "map-fold", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/navigation-arrow-off", "name": "navigation-arrow-off", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/navigation-arrow-on", "name": "navigation-arrow-on", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/parking-sign", "name": "parking-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/parliament", "name": "parliament", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/passport", "name": "passport", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/pet-paw", "name": "pet-paw", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/pets-allowed", "name": "pets-allowed", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/pool-ladder", "name": "pool-ladder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/rock-slide", "name": "rock-slide", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/sail-ship", "name": "sail-ship", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/school-bus-side", "name": "school-bus-side", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/smoke-detector", "name": "smoke-detector", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/smoking-area", "name": "smoking-area", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/snorkle", "name": "snorkle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/steering-wheel", "name": "steering-wheel", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/street-road", "name": "street-road", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/street-sign", "name": "street-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/take-off", "name": "take-off", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/toilet-man", "name": "toilet-man", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/toilet-sign-man-woman-2", "name": "toilet-sign-man-woman-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/toilet-women", "name": "toilet-women", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/traffic-cone", "name": "traffic-cone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "map_travel/triangle-flag", "name": "triangle-flag", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/wheelchair-1", "name": "wheelchair-1", "keywords": [], "content": "\n\n\n" }, { "id": "map_travel/woman-symbol", "name": "woman-symbol", "keywords": [], "content": "\n\n\n" } ], "money_shopping": [ { "id": "money_shopping/annoncement-megaphone", "name": "annoncement-megaphone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/backpack", "name": "backpack", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag-dollar", "name": "bag-dollar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag-pound", "name": "bag-pound", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag-rupee", "name": "bag-rupee", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag-suitcase-1", "name": "bag-suitcase-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag-suitcase-2", "name": "bag-suitcase-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag-yen", "name": "bag-yen", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bag", "name": "bag", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/ball", "name": "ball", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bank", "name": "bank", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/beanie", "name": "beanie", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bill-1", "name": "bill-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bill-2", "name": "bill-2", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/bill-4", "name": "bill-4", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/bill-cashless", "name": "bill-cashless", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/binance-circle", "name": "binance-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/bitcoin", "name": "bitcoin", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/bow-tie", "name": "bow-tie", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/briefcase-dollar", "name": "briefcase-dollar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/building-2", "name": "building-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/business-card", "name": "business-card", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/business-handshake", "name": "business-handshake", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/business-idea-money", "name": "business-idea-money", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/business-profession-home-office", "name": "business-profession-home-office", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/business-progress-bar-2", "name": "business-progress-bar-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/business-user-curriculum", "name": "business-user-curriculum", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/calculator-1", "name": "calculator-1", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/calculator-2", "name": "calculator-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/cane", "name": "cane", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/chair", "name": "chair", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/closet", "name": "closet", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/coin-share", "name": "coin-share", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/coins-stack", "name": "coins-stack", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/credit-card-1", "name": "credit-card-1", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/credit-card-2", "name": "credit-card-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/diamond-2", "name": "diamond-2", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/discount-percent-badge", "name": "discount-percent-badge", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/discount-percent-circle", "name": "discount-percent-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/discount-percent-coupon", "name": "discount-percent-coupon", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/discount-percent-cutout", "name": "discount-percent-cutout", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/discount-percent-fire", "name": "discount-percent-fire", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/dollar-coin-1", "name": "dollar-coin-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/dollar-coin", "name": "dollar-coin", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/dressing-table", "name": "dressing-table", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/ethereum-circle", "name": "ethereum-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/ethereum", "name": "ethereum", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/euro", "name": "euro", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/gift-2", "name": "gift-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/gift", "name": "gift", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/gold", "name": "gold", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/graph-arrow-decrease", "name": "graph-arrow-decrease", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/graph-arrow-increase", "name": "graph-arrow-increase", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/graph-bar-decrease", "name": "graph-bar-decrease", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/graph-bar-increase", "name": "graph-bar-increase", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/graph-dot", "name": "graph-dot", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/graph", "name": "graph", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/investment-selection", "name": "investment-selection", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/justice-hammer", "name": "justice-hammer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/justice-scale-1", "name": "justice-scale-1", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/justice-scale-2", "name": "justice-scale-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/lipstick", "name": "lipstick", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/make-up-brush", "name": "make-up-brush", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/moustache", "name": "moustache", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/mouth-lip", "name": "mouth-lip", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/necklace", "name": "necklace", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/necktie", "name": "necktie", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/payment-10", "name": "payment-10", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/payment-cash-out-3", "name": "payment-cash-out-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/pie-chart", "name": "pie-chart", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/piggy-bank", "name": "piggy-bank", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/polka-dot-circle", "name": "polka-dot-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/production-belt", "name": "production-belt", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/qr-code", "name": "qr-code", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/receipt-add", "name": "receipt-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/receipt-check", "name": "receipt-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/receipt-subtract", "name": "receipt-subtract", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/receipt", "name": "receipt", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/safe-vault", "name": "safe-vault", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/scanner-3", "name": "scanner-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/scanner-bar-code", "name": "scanner-bar-code", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shelf", "name": "shelf", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/shopping-bag-hand-bag-2", "name": "shopping-bag-hand-bag-2", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/shopping-basket-1", "name": "shopping-basket-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-basket-2", "name": "shopping-basket-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-cart-1", "name": "shopping-cart-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-cart-2", "name": "shopping-cart-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-cart-3", "name": "shopping-cart-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-cart-add", "name": "shopping-cart-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-cart-check", "name": "shopping-cart-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/shopping-cart-subtract", "name": "shopping-cart-subtract", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/signage-3", "name": "signage-3", "keywords": [], "content": "\n\n\n" }, { "id": "money_shopping/signage-4", "name": "signage-4", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/startup", "name": "startup", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/stock", "name": "stock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/store-1", "name": "store-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/store-2", "name": "store-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/store-computer", "name": "store-computer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/subscription-cashflow", "name": "subscription-cashflow", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/tag", "name": "tag", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/tall-hat", "name": "tall-hat", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/target-3", "name": "target-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/target", "name": "target", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/wallet-purse", "name": "wallet-purse", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/wallet", "name": "wallet", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/xrp-circle", "name": "xrp-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/yuan-circle", "name": "yuan-circle", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "money_shopping/yuan", "name": "yuan", "keywords": [], "content": "\n\n\n" } ], "nature_ecology": [ { "id": "nature_ecology/affordable-and-clean-energy", "name": "affordable-and-clean-energy", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/alien", "name": "alien", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/bone", "name": "bone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/cat-1", "name": "cat-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/circle-flask", "name": "circle-flask", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/clean-water-and-sanitation", "name": "clean-water-and-sanitation", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/comet", "name": "comet", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/decent-work-and-economic-growth", "name": "decent-work-and-economic-growth", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/dna", "name": "dna", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/erlenmeyer-flask", "name": "erlenmeyer-flask", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/flower", "name": "flower", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/galaxy-1", "name": "galaxy-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/galaxy-2", "name": "galaxy-2", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/gender-equality", "name": "gender-equality", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/good-health-and-well-being", "name": "good-health-and-well-being", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/industry-innovation-and-infrastructure", "name": "industry-innovation-and-infrastructure", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/leaf", "name": "leaf", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/log", "name": "log", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/no-poverty", "name": "no-poverty", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/octopus", "name": "octopus", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/planet", "name": "planet", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/potted-flower-tulip", "name": "potted-flower-tulip", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/quality-education", "name": "quality-education", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/rainbow", "name": "rainbow", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/recycle-1", "name": "recycle-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/reduced-inequalities", "name": "reduced-inequalities", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/rose", "name": "rose", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/shell", "name": "shell", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/shovel-rake", "name": "shovel-rake", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/sprout", "name": "sprout", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/telescope", "name": "telescope", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/test-tube", "name": "test-tube", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/tidal-wave", "name": "tidal-wave", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/tree-2", "name": "tree-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/tree-3", "name": "tree-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/volcano", "name": "volcano", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "nature_ecology/windmill", "name": "windmill", "keywords": [], "content": "\n\n\n" }, { "id": "nature_ecology/zero-hunger", "name": "zero-hunger", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "phone": [ { "id": "phone/airplane-disabled", "name": "airplane-disabled", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/airplane-enabled", "name": "airplane-enabled", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/back-camera-1", "name": "back-camera-1", "keywords": [], "content": "\n\n\n" }, { "id": "phone/call-hang-up", "name": "call-hang-up", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/cellular-network-4g", "name": "cellular-network-4g", "keywords": [], "content": "\n\n\n" }, { "id": "phone/cellular-network-5g", "name": "cellular-network-5g", "keywords": [], "content": "\n\n\n" }, { "id": "phone/cellular-network-lte", "name": "cellular-network-lte", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/contact-phonebook-2", "name": "contact-phonebook-2", "keywords": [], "content": "\n\n\n" }, { "id": "phone/hang-up-1", "name": "hang-up-1", "keywords": [], "content": "\n\n\n" }, { "id": "phone/hang-up-2", "name": "hang-up-2", "keywords": [], "content": "\n\n\n" }, { "id": "phone/incoming-call", "name": "incoming-call", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/missed-call", "name": "missed-call", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/notification-alarm-2", "name": "notification-alarm-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/notification-application-1", "name": "notification-application-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/notification-application-2", "name": "notification-application-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/notification-message-alert", "name": "notification-message-alert", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/outgoing-call", "name": "outgoing-call", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/phone-mobile-phone", "name": "phone-mobile-phone", "keywords": [], "content": "\n\n\n" }, { "id": "phone/phone-qr", "name": "phone-qr", "keywords": [], "content": "\n\n\n" }, { "id": "phone/phone-ringing-1", "name": "phone-ringing-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/phone-ringing-2", "name": "phone-ringing-2", "keywords": [], "content": "\n\n\n" }, { "id": "phone/phone", "name": "phone", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "phone/signal-full", "name": "signal-full", "keywords": [], "content": "\n\n\n" }, { "id": "phone/signal-low", "name": "signal-low", "keywords": [], "content": "\n\n\n" }, { "id": "phone/signal-medium", "name": "signal-medium", "keywords": [], "content": "\n\n\n" }, { "id": "phone/signal-none", "name": "signal-none", "keywords": [], "content": "\n\n\n" } ], "programing": [ { "id": "programing/application-add", "name": "application-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bracket", "name": "bracket", "keywords": [], "content": "\n\n\n" }, { "id": "programing/browser-add", "name": "browser-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-block", "name": "browser-block", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-build", "name": "browser-build", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-check", "name": "browser-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-delete", "name": "browser-delete", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-hash", "name": "browser-hash", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-lock", "name": "browser-lock", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-multiple-window", "name": "browser-multiple-window", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-remove", "name": "browser-remove", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/browser-website-1", "name": "browser-website-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bug-antivirus-debugging", "name": "bug-antivirus-debugging", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bug-antivirus-shield", "name": "bug-antivirus-shield", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bug-virus-browser", "name": "bug-virus-browser", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bug-virus-document", "name": "bug-virus-document", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bug-virus-folder", "name": "bug-virus-folder", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/bug", "name": "bug", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-add", "name": "cloud-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-block", "name": "cloud-block", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-check", "name": "cloud-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-data-transfer", "name": "cloud-data-transfer", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-refresh", "name": "cloud-refresh", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-share", "name": "cloud-share", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-warning", "name": "cloud-warning", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/cloud-wifi", "name": "cloud-wifi", "keywords": [], "content": "\n\n\n" }, { "id": "programing/code-analysis", "name": "code-analysis", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/code-monitor-1", "name": "code-monitor-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/code-monitor-2", "name": "code-monitor-2", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/css-three", "name": "css-three", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/curly-brackets", "name": "curly-brackets", "keywords": [], "content": "\n\n\n" }, { "id": "programing/file-code-1", "name": "file-code-1", "keywords": [], "content": "\n\n\n" }, { "id": "programing/incognito-mode", "name": "incognito-mode", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/insert-cloud-video", "name": "insert-cloud-video", "keywords": [], "content": "\n\n\n" }, { "id": "programing/markdown-circle-programming", "name": "markdown-circle-programming", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/markdown-document-programming", "name": "markdown-document-programming", "keywords": [], "content": "\n\n\n" }, { "id": "programing/module-puzzle-1", "name": "module-puzzle-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/module-puzzle-3", "name": "module-puzzle-3", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/module-three", "name": "module-three", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "programing/rss-square", "name": "rss-square", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "shipping": [ { "id": "shipping/box-sign", "name": "box-sign", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/container", "name": "container", "keywords": [], "content": "\n\n\n" }, { "id": "shipping/fragile", "name": "fragile", "keywords": [], "content": "\n\n\n" }, { "id": "shipping/parachute-drop", "name": "parachute-drop", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipment-add", "name": "shipment-add", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipment-check", "name": "shipment-check", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipment-download", "name": "shipment-download", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipment-remove", "name": "shipment-remove", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipment-upload", "name": "shipment-upload", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipping-box-1", "name": "shipping-box-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/shipping-truck", "name": "shipping-truck", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "shipping/transfer-motorcycle", "name": "transfer-motorcycle", "keywords": [], "content": "\n\n\n" }, { "id": "shipping/transfer-van", "name": "transfer-van", "keywords": [], "content": "\n\n\n" }, { "id": "shipping/warehouse-1", "name": "warehouse-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ], "work_education": [ { "id": "work_education/book-reading", "name": "book-reading", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/class-lesson", "name": "class-lesson", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/collaborations-idea", "name": "collaborations-idea", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/definition-search-book", "name": "definition-search-book", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/dictionary-language-book", "name": "dictionary-language-book", "keywords": [], "content": "\n\n\n" }, { "id": "work_education/global-learning", "name": "global-learning", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/graduation-cap", "name": "graduation-cap", "keywords": [], "content": "\n\n\n" }, { "id": "work_education/group-meeting-call", "name": "group-meeting-call", "keywords": [], "content": "\n\n\n" }, { "id": "work_education/office-building-1", "name": "office-building-1", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/office-worker", "name": "office-worker", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/search-dollar", "name": "search-dollar", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" }, { "id": "work_education/strategy-tasks", "name": "strategy-tasks", "keywords": [], "content": "\n\n\n" }, { "id": "work_education/task-list", "name": "task-list", "keywords": [], "content": "\n\n\n" }, { "id": "work_education/workspace-desk", "name": "workspace-desk", "keywords": [], "content": "\n\n\n\n\n\n\n\n\n\n" } ] } \ No newline at end of file diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/auto-flash.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/auto-flash.svg new file mode 100644 index 0000000000000..0c1936fb80f39 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/auto-flash.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/camera-1.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-1.svg new file mode 100644 index 0000000000000..6b6609071c5e8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/camera-disabled.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-disabled.svg new file mode 100644 index 0000000000000..4f4c45d181ab2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-disabled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/camera-loading.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-loading.svg new file mode 100644 index 0000000000000..ad3ec3d08dfce --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-loading.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/camera-square.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-square.svg new file mode 100644 index 0000000000000..f90f048eaf1a2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/camera-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/composition-oval.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/composition-oval.svg new file mode 100644 index 0000000000000..1799610d70dee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/composition-oval.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/composition-vertical.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/composition-vertical.svg new file mode 100644 index 0000000000000..758a66a9a28d7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/composition-vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/compsition-horizontal.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/compsition-horizontal.svg new file mode 100644 index 0000000000000..b4b5ed760db88 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/compsition-horizontal.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/edit-image-photo.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/edit-image-photo.svg new file mode 100644 index 0000000000000..fc9c7e8b3f5df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/edit-image-photo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/film-roll-1.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/film-roll-1.svg new file mode 100644 index 0000000000000..d657abec5d3c2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/film-roll-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/film-slate.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/film-slate.svg new file mode 100644 index 0000000000000..8fd8f3fed8348 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/film-slate.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/flash-1.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-1.svg new file mode 100644 index 0000000000000..f1814e8186759 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/flash-2.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-2.svg new file mode 100644 index 0000000000000..24d2d68e079dd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/flash-3.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-3.svg new file mode 100644 index 0000000000000..e98c8193e9583 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-3.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/flash-off.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-off.svg new file mode 100644 index 0000000000000..4260106b57c22 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/flash-off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/flower.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/flower.svg new file mode 100644 index 0000000000000..87981fa4a1b2e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/flower.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/focus-points.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/focus-points.svg new file mode 100644 index 0000000000000..149495c4afc60 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/focus-points.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/landscape-2.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/landscape-2.svg new file mode 100644 index 0000000000000..ec970a9893e04 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/landscape-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/landscape-setting.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/landscape-setting.svg new file mode 100644 index 0000000000000..c87b58d38bd7f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/landscape-setting.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/laptop-camera.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/laptop-camera.svg new file mode 100644 index 0000000000000..88d1c7bb8f96f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/laptop-camera.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/mobile-phone-camera.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/mobile-phone-camera.svg new file mode 100644 index 0000000000000..b7b69aa738803 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/mobile-phone-camera.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/orientation-landscape.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/orientation-landscape.svg new file mode 100644 index 0000000000000..c432b7b0462ee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/orientation-landscape.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/orientation-portrait.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/orientation-portrait.svg new file mode 100644 index 0000000000000..deaf60faf6fab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/orientation-portrait.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/images_photography/polaroid-four.svg b/frontend/appflowy_web_app/public/af_icons/images_photography/polaroid-four.svg new file mode 100644 index 0000000000000..6e9121cd504fc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/images_photography/polaroid-four.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/add-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-1.svg new file mode 100644 index 0000000000000..dedf45912b9ab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/add-bell-notification.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-bell-notification.svg new file mode 100644 index 0000000000000..d8af9e31e5bc0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-bell-notification.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/add-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-circle.svg new file mode 100644 index 0000000000000..4e6af27c9a031 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/add-layer-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-layer-2.svg new file mode 100644 index 0000000000000..5027acb248171 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-layer-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/add-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-square.svg new file mode 100644 index 0000000000000..1900a45c11b51 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/add-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/alarm-clock.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/alarm-clock.svg new file mode 100644 index 0000000000000..773ca81fe6af5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/alarm-clock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/align-back-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-back-1.svg new file mode 100644 index 0000000000000..8045f92e3d243 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-back-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/align-center.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-center.svg new file mode 100644 index 0000000000000..25dd359f6ae37 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-center.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/align-front-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-front-1.svg new file mode 100644 index 0000000000000..402eb326da2cb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-front-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/align-left.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-left.svg new file mode 100644 index 0000000000000..e19e815cfbaa6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-left.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/align-right.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-right.svg new file mode 100644 index 0000000000000..3ff840a8134e2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/align-right.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/ampersand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/ampersand.svg new file mode 100644 index 0000000000000..11da33fb200cc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/ampersand.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/archive-box.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/archive-box.svg new file mode 100644 index 0000000000000..3816bf9f6c9fa --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/archive-box.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-bend-left-down-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-bend-left-down-2.svg new file mode 100644 index 0000000000000..7df296c6043bc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-bend-left-down-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-bend-right-down-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-bend-right-down-2.svg new file mode 100644 index 0000000000000..4d351c0f8ed0d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-bend-right-down-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-down.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-down.svg new file mode 100644 index 0000000000000..c824a1d2aa75f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-down.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-left.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-left.svg new file mode 100644 index 0000000000000..c64e0771b08ea --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-left.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-right.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-right.svg new file mode 100644 index 0000000000000..1e87e2927b0df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-right.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-up.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-up.svg new file mode 100644 index 0000000000000..8707846460c11 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-crossover-up.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-cursor-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-cursor-1.svg new file mode 100644 index 0000000000000..1d4948c62e241 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-cursor-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-cursor-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-cursor-2.svg new file mode 100644 index 0000000000000..2fddfa485df4c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-cursor-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-curvy-up-down-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-curvy-up-down-1.svg new file mode 100644 index 0000000000000..7df202b6dfb94 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-curvy-up-down-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-curvy-up-down-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-curvy-up-down-2.svg new file mode 100644 index 0000000000000..65762b3f51699 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-curvy-up-down-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-down-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-down-2.svg new file mode 100644 index 0000000000000..1eacf2b68d4a9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-down-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-down-dashed-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-down-dashed-square.svg new file mode 100644 index 0000000000000..7e3f1a5a40517 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-down-dashed-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-expand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-expand.svg new file mode 100644 index 0000000000000..6a282421ea3cc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-expand.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-infinite-loop.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-infinite-loop.svg new file mode 100644 index 0000000000000..a586e55081a8a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-infinite-loop.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-move.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-move.svg new file mode 100644 index 0000000000000..b106268e87675 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-move.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-horizontal-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-horizontal-1.svg new file mode 100644 index 0000000000000..0b0a93b6309ae --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-horizontal-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-horizontal-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-horizontal-2.svg new file mode 100644 index 0000000000000..a649467631150 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-horizontal-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-vertical-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-vertical-1.svg new file mode 100644 index 0000000000000..933f27a9e908e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-vertical-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-vertical-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-vertical-2.svg new file mode 100644 index 0000000000000..a307381d2cc61 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-reload-vertical-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-roadmap.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-roadmap.svg new file mode 100644 index 0000000000000..70870883bb3c9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-roadmap.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-round-left.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-round-left.svg new file mode 100644 index 0000000000000..f9520235025ed --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-round-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-round-right.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-round-right.svg new file mode 100644 index 0000000000000..e335b2a94faf6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-round-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink-diagonal-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink-diagonal-1.svg new file mode 100644 index 0000000000000..613ce1cabfdd4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink-diagonal-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink-diagonal-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink-diagonal-2.svg new file mode 100644 index 0000000000000..286c959465209 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink-diagonal-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink.svg new file mode 100644 index 0000000000000..19489b132a12b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-shrink.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-1.svg new file mode 100644 index 0000000000000..3e6efceb00c19 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-2.svg new file mode 100644 index 0000000000000..db9160cbfd9de --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-3.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-3.svg new file mode 100644 index 0000000000000..63b8361656a56 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-transfer-diagonal-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-up-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-up-1.svg new file mode 100644 index 0000000000000..6554aeefb4479 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-up-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-up-dashed-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-up-dashed-square.svg new file mode 100644 index 0000000000000..e583df64a3e33 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/arrow-up-dashed-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/ascending-number-order.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/ascending-number-order.svg new file mode 100644 index 0000000000000..8b8fe17bb3bf0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/ascending-number-order.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/attribution.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/attribution.svg new file mode 100644 index 0000000000000..9118a362ede3f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/attribution.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/blank-calendar.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/blank-calendar.svg new file mode 100644 index 0000000000000..cdbe62e663dac --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/blank-calendar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/blank-notepad.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/blank-notepad.svg new file mode 100644 index 0000000000000..b1f814264b1ae --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/blank-notepad.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/block-bell-notification.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/block-bell-notification.svg new file mode 100644 index 0000000000000..fd9389548aa61 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/block-bell-notification.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/bomb.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/bomb.svg new file mode 100644 index 0000000000000..972746f65e5e7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/bomb.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/bookmark.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/bookmark.svg new file mode 100644 index 0000000000000..808752dc47c3c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/bookmark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/braces-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/braces-circle.svg new file mode 100644 index 0000000000000..9ce91cffd0c5f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/braces-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-1.svg new file mode 100644 index 0000000000000..8374202149d83 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-2.svg new file mode 100644 index 0000000000000..343c13113d5e7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-3.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-3.svg new file mode 100644 index 0000000000000..d18adb4fc802b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/brightness-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/broken-link-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/broken-link-2.svg new file mode 100644 index 0000000000000..f2dc320b5053f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/broken-link-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/bullet-list.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/bullet-list.svg new file mode 100644 index 0000000000000..a282a82f70b76 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/bullet-list.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-add.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-add.svg new file mode 100644 index 0000000000000..36d0fd2ef4b3c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-edit.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-edit.svg new file mode 100644 index 0000000000000..2d5296ae1ca1e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-edit.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-jump-to-date.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-jump-to-date.svg new file mode 100644 index 0000000000000..b9b39c8dbbccd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-jump-to-date.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-star.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-star.svg new file mode 100644 index 0000000000000..18de81b0bfad3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/calendar-star.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/celsius.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/celsius.svg new file mode 100644 index 0000000000000..42c694210dc2e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/celsius.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/check-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/check-square.svg new file mode 100644 index 0000000000000..fd7897030357b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/check-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/check.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/check.svg new file mode 100644 index 0000000000000..1a0d205a49c4c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/circle-clock.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/circle-clock.svg new file mode 100644 index 0000000000000..66fea10946506 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/circle-clock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/circle.svg new file mode 100644 index 0000000000000..fc162183335db --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-add.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-add.svg new file mode 100644 index 0000000000000..ceea0cddffeff --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-add.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-check.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-check.svg new file mode 100644 index 0000000000000..5ac2b85299331 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-check.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-remove.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-remove.svg new file mode 100644 index 0000000000000..db6feb1ee1fc9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/clipboard-remove.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/cloud.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/cloud.svg new file mode 100644 index 0000000000000..22a7dfa2cf5c4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/cloud.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/cog.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/cog.svg new file mode 100644 index 0000000000000..f951e92137be3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/cog.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/color-palette.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/color-palette.svg new file mode 100644 index 0000000000000..05b74893670f3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/color-palette.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/color-picker.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/color-picker.svg new file mode 100644 index 0000000000000..9fde8baaa2956 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/color-picker.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/color-swatches.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/color-swatches.svg new file mode 100644 index 0000000000000..5071d67161e27 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/color-swatches.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/cone-shape.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/cone-shape.svg new file mode 100644 index 0000000000000..e5623415c6d14 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/cone-shape.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/convert-PDF-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/convert-PDF-2.svg new file mode 100644 index 0000000000000..ed7db405845e4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/convert-PDF-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/copy-paste.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/copy-paste.svg new file mode 100644 index 0000000000000..ce0fd6383c9fe --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/copy-paste.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/creative-commons.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/creative-commons.svg new file mode 100644 index 0000000000000..7a3997e6368bf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/creative-commons.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/crop-selection.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/crop-selection.svg new file mode 100644 index 0000000000000..4c5166cb656f5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/crop-selection.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/crown.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/crown.svg new file mode 100644 index 0000000000000..951fb68553eb6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/crown.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/customer-support-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/customer-support-1.svg new file mode 100644 index 0000000000000..5593196fbf2a2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/customer-support-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/cut.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/cut.svg new file mode 100644 index 0000000000000..8d63408f39c5b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/cut.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/dark-dislay-mode.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/dark-dislay-mode.svg new file mode 100644 index 0000000000000..b5fddc9f7dde7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/dark-dislay-mode.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/dashboard-3.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/dashboard-3.svg new file mode 100644 index 0000000000000..54eb799a01c72 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/dashboard-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/dashboard-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/dashboard-circle.svg new file mode 100644 index 0000000000000..e60ce62cdf6db --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/dashboard-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/delete-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/delete-1.svg new file mode 100644 index 0000000000000..534ae11cbabc2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/delete-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/descending-number-order.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/descending-number-order.svg new file mode 100644 index 0000000000000..9ce81193f3e21 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/descending-number-order.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/disable-bell-notification.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/disable-bell-notification.svg new file mode 100644 index 0000000000000..2e1a02036a9f7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/disable-bell-notification.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/disable-heart.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/disable-heart.svg new file mode 100644 index 0000000000000..d3943473ef938 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/disable-heart.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/division-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/division-circle.svg new file mode 100644 index 0000000000000..2695bb2aaae16 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/division-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/download-box-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-box-1.svg new file mode 100644 index 0000000000000..11bb09cabaf8d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-box-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/download-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-circle.svg new file mode 100644 index 0000000000000..bf14c7df8d7c1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/download-computer.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-computer.svg new file mode 100644 index 0000000000000..d7ea9900f4929 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-computer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/download-file.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-file.svg new file mode 100644 index 0000000000000..a298a8eec18d9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/download-file.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/empty-clipboard.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/empty-clipboard.svg new file mode 100644 index 0000000000000..5ea444ac50ccb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/empty-clipboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/equal-sign.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/equal-sign.svg new file mode 100644 index 0000000000000..94fa93fc415f1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/equal-sign.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/expand-horizontal-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/expand-horizontal-1.svg new file mode 100644 index 0000000000000..108286e4b213f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/expand-horizontal-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/expand-window-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/expand-window-2.svg new file mode 100644 index 0000000000000..f04b40d4613a5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/expand-window-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/expand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/expand.svg new file mode 100644 index 0000000000000..adad5b6fc5189 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/expand.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/face-scan-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/face-scan-1.svg new file mode 100644 index 0000000000000..468f7b9d25336 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/face-scan-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/factorial.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/factorial.svg new file mode 100644 index 0000000000000..127c8e23245a2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/factorial.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/fahrenheit.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/fahrenheit.svg new file mode 100644 index 0000000000000..3336086ece153 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/fahrenheit.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/fastforward-clock.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/fastforward-clock.svg new file mode 100644 index 0000000000000..c7d02240eabe5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/fastforward-clock.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/file-add-alternate.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/file-add-alternate.svg new file mode 100644 index 0000000000000..1df2d54768431 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/file-add-alternate.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/file-delete-alternate.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/file-delete-alternate.svg new file mode 100644 index 0000000000000..1dc099eaa6086 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/file-delete-alternate.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/file-remove-alternate.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/file-remove-alternate.svg new file mode 100644 index 0000000000000..9c019dea7a5a4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/file-remove-alternate.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/filter-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/filter-2.svg new file mode 100644 index 0000000000000..b8f72f9e89065 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/filter-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/fingerprint-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/fingerprint-1.svg new file mode 100644 index 0000000000000..2d481b19168f3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/fingerprint-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/fingerprint-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/fingerprint-2.svg new file mode 100644 index 0000000000000..8216298e20b3f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/fingerprint-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/fist.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/fist.svg new file mode 100644 index 0000000000000..7f9e043096d14 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/fist.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/fit-to-height-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/fit-to-height-square.svg new file mode 100644 index 0000000000000..b8976428b2908 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/fit-to-height-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-arrow-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-arrow-2.svg new file mode 100644 index 0000000000000..758b31b29afaf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-arrow-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-circle-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-circle-1.svg new file mode 100644 index 0000000000000..1be2ef6ffb469 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-circle-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-square-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-square-2.svg new file mode 100644 index 0000000000000..dfbb30b0c1ec1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/flip-vertical-square-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-add.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-add.svg new file mode 100644 index 0000000000000..d21b28a5847c8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-check.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-check.svg new file mode 100644 index 0000000000000..e838527d46b54 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-delete.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-delete.svg new file mode 100644 index 0000000000000..7f39330a0b372 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/folder-delete.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/front-camera.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/front-camera.svg new file mode 100644 index 0000000000000..1373e61f2d136 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/front-camera.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/gif-format.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/gif-format.svg new file mode 100644 index 0000000000000..432e41013029e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/gif-format.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/give-gift.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/give-gift.svg new file mode 100644 index 0000000000000..8040687c5ea32 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/give-gift.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/glasses.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/glasses.svg new file mode 100644 index 0000000000000..d5feb7462d96f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/glasses.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/half-star-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/half-star-1.svg new file mode 100644 index 0000000000000..57e9efaf7c763 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/half-star-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hand-cursor.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hand-cursor.svg new file mode 100644 index 0000000000000..2d09bc7926fba --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hand-cursor.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hand-grab.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hand-grab.svg new file mode 100644 index 0000000000000..24eec4e45319c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hand-grab.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-1-paragraph-styles-heading.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-1-paragraph-styles-heading.svg new file mode 100644 index 0000000000000..7bdad9cff93b2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-1-paragraph-styles-heading.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-2-paragraph-styles-heading.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-2-paragraph-styles-heading.svg new file mode 100644 index 0000000000000..53c948d7b2316 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-2-paragraph-styles-heading.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-3-paragraph-styles-heading.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-3-paragraph-styles-heading.svg new file mode 100644 index 0000000000000..69d6db3d3d62e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/heading-3-paragraph-styles-heading.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/heart.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/heart.svg new file mode 100644 index 0000000000000..e525ec4e3d3ce --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/heart.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/help-chat-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/help-chat-2.svg new file mode 100644 index 0000000000000..ee9b03674388d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/help-chat-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/help-question-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/help-question-1.svg new file mode 100644 index 0000000000000..e709c34077f88 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/help-question-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-10.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-10.svg new file mode 100644 index 0000000000000..a39558ce91125 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-10.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-13.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-13.svg new file mode 100644 index 0000000000000..7a95cd6bb0472 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-13.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-14.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-14.svg new file mode 100644 index 0000000000000..8d7650b3ef2e1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-14.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-2.svg new file mode 100644 index 0000000000000..7308894854391 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-4.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-4.svg new file mode 100644 index 0000000000000..24e31540de7cd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-4.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-7.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-7.svg new file mode 100644 index 0000000000000..3485bea5e0678 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/hierarchy-7.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/home-3.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/home-3.svg new file mode 100644 index 0000000000000..36d1d77fbb676 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/home-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/home-4.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/home-4.svg new file mode 100644 index 0000000000000..c7bc580449912 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/home-4.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/horizontal-menu-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/horizontal-menu-circle.svg new file mode 100644 index 0000000000000..a3091c335783e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/horizontal-menu-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/humidity-none.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/humidity-none.svg new file mode 100644 index 0000000000000..c81c318039bee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/humidity-none.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/image-blur.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/image-blur.svg new file mode 100644 index 0000000000000..132f437695079 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/image-blur.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/image-saturation.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/image-saturation.svg new file mode 100644 index 0000000000000..5bfd1feb04cd3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/image-saturation.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/information-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/information-circle.svg new file mode 100644 index 0000000000000..8ea9d8a04bb21 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/information-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/input-box.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/input-box.svg new file mode 100644 index 0000000000000..6369712e83e9e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/input-box.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-side.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-side.svg new file mode 100644 index 0000000000000..a8cb471c5b3ac --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-side.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-top-left.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-top-left.svg new file mode 100644 index 0000000000000..248fa83cb8fcb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-top-left.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-top-right.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-top-right.svg new file mode 100644 index 0000000000000..e8729e632b4f6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/insert-top-right.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/invisible-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/invisible-1.svg new file mode 100644 index 0000000000000..2faf921e82af7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/invisible-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/invisible-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/invisible-2.svg new file mode 100644 index 0000000000000..df9c4e5e4273b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/invisible-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/jump-object.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/jump-object.svg new file mode 100644 index 0000000000000..2859e74ef9f07 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/jump-object.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/key.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/key.svg new file mode 100644 index 0000000000000..738976a249a9e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/key.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/keyhole-lock-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/keyhole-lock-circle.svg new file mode 100644 index 0000000000000..ef1cd3be8e84c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/keyhole-lock-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/lasso-tool.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/lasso-tool.svg new file mode 100644 index 0000000000000..ff0238cdf760f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/lasso-tool.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/layers-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/layers-1.svg new file mode 100644 index 0000000000000..8475e73e3b473 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/layers-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/layers-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/layers-2.svg new file mode 100644 index 0000000000000..80ad0566b4b07 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/layers-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-1.svg new file mode 100644 index 0000000000000..111f8792656bf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-11.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-11.svg new file mode 100644 index 0000000000000..5ecd8b291bb2f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-11.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-2.svg new file mode 100644 index 0000000000000..9539f79acafa6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-8.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-8.svg new file mode 100644 index 0000000000000..8ddfa4d969c32 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/layout-window-8.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/lightbulb.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/lightbulb.svg new file mode 100644 index 0000000000000..84f1978687948 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/lightbulb.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/like-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/like-1.svg new file mode 100644 index 0000000000000..ab7b5ac62c5c8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/like-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/link-chain.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/link-chain.svg new file mode 100644 index 0000000000000..1e8c9ebf0322f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/link-chain.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/live-video.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/live-video.svg new file mode 100644 index 0000000000000..74eaac7e5d082 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/live-video.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/lock-rotation.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/lock-rotation.svg new file mode 100644 index 0000000000000..641f61bca4caf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/lock-rotation.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/login-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/login-1.svg new file mode 100644 index 0000000000000..1bed479e06261 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/login-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/logout-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/logout-1.svg new file mode 100644 index 0000000000000..d5aa2c018b56d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/logout-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/loop-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/loop-1.svg new file mode 100644 index 0000000000000..2ec2b2bd1bab5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/loop-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/magic-wand-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/magic-wand-2.svg new file mode 100644 index 0000000000000..4ddba5343354e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/magic-wand-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/magnifying-glass-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/magnifying-glass-circle.svg new file mode 100644 index 0000000000000..7c34859cc81ed --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/magnifying-glass-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/magnifying-glass.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/magnifying-glass.svg new file mode 100644 index 0000000000000..d7884201c5a1e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/magnifying-glass.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/manual-book.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/manual-book.svg new file mode 100644 index 0000000000000..2057e661ed39d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/manual-book.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/megaphone-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/megaphone-2.svg new file mode 100644 index 0000000000000..4f3236db97aa0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/megaphone-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/minimize-window-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/minimize-window-2.svg new file mode 100644 index 0000000000000..0c898ad6b36df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/minimize-window-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/moon-cloud.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/moon-cloud.svg new file mode 100644 index 0000000000000..75a3f4d90ef3d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/moon-cloud.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/move-left.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/move-left.svg new file mode 100644 index 0000000000000..3d1f5c3b1fa39 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/move-left.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/move-right.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/move-right.svg new file mode 100644 index 0000000000000..333693da80fd3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/move-right.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/multiple-file-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/multiple-file-2.svg new file mode 100644 index 0000000000000..a65117c01fbaa --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/multiple-file-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/music-folder-song.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/music-folder-song.svg new file mode 100644 index 0000000000000..13e2814e47570 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/music-folder-song.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/new-file.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/new-file.svg new file mode 100644 index 0000000000000..0618a6f84ebd8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/new-file.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/new-folder.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/new-folder.svg new file mode 100644 index 0000000000000..897ec6811296b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/new-folder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/new-sticky-note.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/new-sticky-note.svg new file mode 100644 index 0000000000000..2b9c67b187354 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/new-sticky-note.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/not-equal-sign.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/not-equal-sign.svg new file mode 100644 index 0000000000000..f24755f0d16fe --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/not-equal-sign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/ok-hand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/ok-hand.svg new file mode 100644 index 0000000000000..7a101d56e142c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/ok-hand.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-drag-horizontal.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-drag-horizontal.svg new file mode 100644 index 0000000000000..0f20eee768098 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-drag-horizontal.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-drag-vertical.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-drag-vertical.svg new file mode 100644 index 0000000000000..44d28b3a69549 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-drag-vertical.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-hold.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-hold.svg new file mode 100644 index 0000000000000..1945cd18fe82d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-hold.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-tap.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-tap.svg new file mode 100644 index 0000000000000..af6d35a12f7da --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/one-finger-tap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/open-book.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/open-book.svg new file mode 100644 index 0000000000000..7d0258d9ae7a2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/open-book.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/open-umbrella.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/open-umbrella.svg new file mode 100644 index 0000000000000..694edaee6e5e7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/open-umbrella.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/padlock-square-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/padlock-square-1.svg new file mode 100644 index 0000000000000..c15e161f51c16 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/padlock-square-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/page-setting.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/page-setting.svg new file mode 100644 index 0000000000000..05a50047b704f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/page-setting.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/paint-bucket.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/paint-bucket.svg new file mode 100644 index 0000000000000..bc0bb97da87a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/paint-bucket.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/paint-palette.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/paint-palette.svg new file mode 100644 index 0000000000000..05a7cd2c17eba --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/paint-palette.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/paintbrush-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/paintbrush-1.svg new file mode 100644 index 0000000000000..ac1d931e84291 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/paintbrush-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/paintbrush-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/paintbrush-2.svg new file mode 100644 index 0000000000000..ac8cbe43b42d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/paintbrush-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/paperclip-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/paperclip-1.svg new file mode 100644 index 0000000000000..2c4234130012f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/paperclip-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/paragraph.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/paragraph.svg new file mode 100644 index 0000000000000..077a43d46a2ee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/paragraph.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-divide.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-divide.svg new file mode 100644 index 0000000000000..11617189b9db5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-divide.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-exclude.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-exclude.svg new file mode 100644 index 0000000000000..6e791326e0dca --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-exclude.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-intersect.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-intersect.svg new file mode 100644 index 0000000000000..84050733ca228 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-intersect.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-merge.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-merge.svg new file mode 100644 index 0000000000000..81e6775419a4b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-merge.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-minus-front-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-minus-front-1.svg new file mode 100644 index 0000000000000..ee9f455c92938 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-minus-front-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-trim.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-trim.svg new file mode 100644 index 0000000000000..6a8d72d908f98 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-trim.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-union.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-union.svg new file mode 100644 index 0000000000000..470996d2bb36e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pathfinder-union.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/peace-hand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/peace-hand.svg new file mode 100644 index 0000000000000..6791449f4258d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/peace-hand.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-3.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-3.svg new file mode 100644 index 0000000000000..651b4a383a5b2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-draw.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-draw.svg new file mode 100644 index 0000000000000..1923942612201 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-draw.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-tool.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-tool.svg new file mode 100644 index 0000000000000..db0e8c253fd5c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pen-tool.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pencil.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pencil.svg new file mode 100644 index 0000000000000..95251d86d9cb0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pencil.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pentagon.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pentagon.svg new file mode 100644 index 0000000000000..c3a5663ef7c8f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pentagon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pi-symbol-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pi-symbol-circle.svg new file mode 100644 index 0000000000000..5656f8155a287 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pi-symbol-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pictures-folder-memories.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pictures-folder-memories.svg new file mode 100644 index 0000000000000..f7db57e6e765f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pictures-folder-memories.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/podium.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/podium.svg new file mode 100644 index 0000000000000..5914889c53ebe --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/podium.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/polygon.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/polygon.svg new file mode 100644 index 0000000000000..93d003ae129d8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/polygon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/praying-hand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/praying-hand.svg new file mode 100644 index 0000000000000..64e54f8d71f97 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/praying-hand.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/projector-board.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/projector-board.svg new file mode 100644 index 0000000000000..e79950e656091 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/projector-board.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/pyramid-shape.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/pyramid-shape.svg new file mode 100644 index 0000000000000..a8544363c6391 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/pyramid-shape.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/quotation-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/quotation-2.svg new file mode 100644 index 0000000000000..941b957351645 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/quotation-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/radioactive-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/radioactive-2.svg new file mode 100644 index 0000000000000..4bb4a1ad8f9d5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/radioactive-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/rain-cloud.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/rain-cloud.svg new file mode 100644 index 0000000000000..0ea0fc0369775 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/rain-cloud.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/recycle-bin-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/recycle-bin-2.svg new file mode 100644 index 0000000000000..71e8570525189 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/recycle-bin-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/ringing-bell-notification.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/ringing-bell-notification.svg new file mode 100644 index 0000000000000..fc194e471c4d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/ringing-bell-notification.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/rock-and-roll-hand.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/rock-and-roll-hand.svg new file mode 100644 index 0000000000000..12b685624f80d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/rock-and-roll-hand.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/rotate-angle-45.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/rotate-angle-45.svg new file mode 100644 index 0000000000000..697a2d232a593 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/rotate-angle-45.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/round-cap.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/round-cap.svg new file mode 100644 index 0000000000000..ca90db1ada1a4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/round-cap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/satellite-dish.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/satellite-dish.svg new file mode 100644 index 0000000000000..900f43faa28f2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/satellite-dish.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/scanner.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/scanner.svg new file mode 100644 index 0000000000000..a539ba8bcc048 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/scanner.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/search-visual.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/search-visual.svg new file mode 100644 index 0000000000000..01ae1a15513df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/search-visual.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/select-circle-area-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/select-circle-area-1.svg new file mode 100644 index 0000000000000..aa4caab372256 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/select-circle-area-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/share-link.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/share-link.svg new file mode 100644 index 0000000000000..b7871bc70d6c9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/share-link.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-1.svg new file mode 100644 index 0000000000000..55ec4a54989c1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-2.svg new file mode 100644 index 0000000000000..95eac8b8d1ac6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-check.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-check.svg new file mode 100644 index 0000000000000..a50dedecf33d5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-cross.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-cross.svg new file mode 100644 index 0000000000000..a9d68aeb874a1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/shield-cross.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/shrink-horizontal-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/shrink-horizontal-1.svg new file mode 100644 index 0000000000000..5211f9f9b0426 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/shrink-horizontal-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/shuffle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/shuffle.svg new file mode 100644 index 0000000000000..794fdec2c43f1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/shuffle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/sigma.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/sigma.svg new file mode 100644 index 0000000000000..c552e8c07dc68 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/sigma.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/skull-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/skull-1.svg new file mode 100644 index 0000000000000..44937e48ccf30 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/skull-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/sleep.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/sleep.svg new file mode 100644 index 0000000000000..20d55c012a97a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/sleep.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/snow-flake.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/snow-flake.svg new file mode 100644 index 0000000000000..d1bb1f1d450a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/snow-flake.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/sort-descending.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/sort-descending.svg new file mode 100644 index 0000000000000..912b92b88cae2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/sort-descending.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/spiral-shape.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/spiral-shape.svg new file mode 100644 index 0000000000000..dfd002ef8b565 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/spiral-shape.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/split-vertical.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/split-vertical.svg new file mode 100644 index 0000000000000..ce7c2bda52a56 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/split-vertical.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/spray-paint.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/spray-paint.svg new file mode 100644 index 0000000000000..8e18a39c40e4c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/spray-paint.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/square-brackets-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-brackets-circle.svg new file mode 100644 index 0000000000000..3b75475809f7b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-brackets-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/square-cap.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-cap.svg new file mode 100644 index 0000000000000..91c82353fc337 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-cap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/square-clock.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-clock.svg new file mode 100644 index 0000000000000..cdcaf984faac4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-clock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/square-root-x-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-root-x-circle.svg new file mode 100644 index 0000000000000..3b2dc980b80ae --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/square-root-x-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/star-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/star-1.svg new file mode 100644 index 0000000000000..58a5c887596e6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/star-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/star-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/star-2.svg new file mode 100644 index 0000000000000..660bbbe347c88 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/star-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/star-badge.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/star-badge.svg new file mode 100644 index 0000000000000..78307418c786a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/star-badge.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/straight-cap.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/straight-cap.svg new file mode 100644 index 0000000000000..a1b64c1675970 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/straight-cap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-1.svg new file mode 100644 index 0000000000000..32300958c5efb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-circle.svg new file mode 100644 index 0000000000000..6bcdece9d1850 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-square.svg new file mode 100644 index 0000000000000..0384f63da5bdf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/subtract-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/sun-cloud.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/sun-cloud.svg new file mode 100644 index 0000000000000..1606b89874e75 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/sun-cloud.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/synchronize-disable.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/synchronize-disable.svg new file mode 100644 index 0000000000000..fe5ae9bc25f1b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/synchronize-disable.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/synchronize-warning.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/synchronize-warning.svg new file mode 100644 index 0000000000000..3f773ad3ff082 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/synchronize-warning.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/table-lamp-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/table-lamp-1.svg new file mode 100644 index 0000000000000..a5859f39fc14d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/table-lamp-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/tag.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/tag.svg new file mode 100644 index 0000000000000..b79a4ff92f3de --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/tag.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/text-flow-rows.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/text-flow-rows.svg new file mode 100644 index 0000000000000..15977a72f7a81 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/text-flow-rows.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/text-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/text-square.svg new file mode 100644 index 0000000000000..b297b154de0f0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/text-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/text-style.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/text-style.svg new file mode 100644 index 0000000000000..9c5ae09c4444d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/text-style.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/thermometer.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/thermometer.svg new file mode 100644 index 0000000000000..362672a374a6a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/thermometer.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/trending-content.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/trending-content.svg new file mode 100644 index 0000000000000..42e3618f54c84 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/trending-content.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/trophy.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/trophy.svg new file mode 100644 index 0000000000000..d05a23292b3c0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/trophy.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/two-finger-drag-hotizontal.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/two-finger-drag-hotizontal.svg new file mode 100644 index 0000000000000..fc07dc2fb27df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/two-finger-drag-hotizontal.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/two-finger-tap.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/two-finger-tap.svg new file mode 100644 index 0000000000000..638319a091a24 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/two-finger-tap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/underline-text-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/underline-text-1.svg new file mode 100644 index 0000000000000..daa443a4d9abd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/underline-text-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-box-1.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-box-1.svg new file mode 100644 index 0000000000000..787314f4768c6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-box-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-circle.svg new file mode 100644 index 0000000000000..886fa014f3598 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-computer.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-computer.svg new file mode 100644 index 0000000000000..8784850a7eb2b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-computer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-file.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-file.svg new file mode 100644 index 0000000000000..c0696194eff29 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/upload-file.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-add-plus.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-add-plus.svg new file mode 100644 index 0000000000000..a95ceab231e37 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-add-plus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-check-validate.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-check-validate.svg new file mode 100644 index 0000000000000..d9bd0051e6b3e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-check-validate.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-circle-single.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-circle-single.svg new file mode 100644 index 0000000000000..a40b12549e1e1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-circle-single.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-identifier-card.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-identifier-card.svg new file mode 100644 index 0000000000000..bab5c06ac5fc4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-identifier-card.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-multiple-circle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-multiple-circle.svg new file mode 100644 index 0000000000000..6740649fa4a28 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-multiple-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-multiple-group.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-multiple-group.svg new file mode 100644 index 0000000000000..07c4e2ffd057d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-multiple-group.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-profile-focus.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-profile-focus.svg new file mode 100644 index 0000000000000..72f13810928d7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-profile-focus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-protection-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-protection-2.svg new file mode 100644 index 0000000000000..adcd16e31d561 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-protection-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-remove-subtract.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-remove-subtract.svg new file mode 100644 index 0000000000000..8b84f56060c50 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-remove-subtract.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-single-neutral-male.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-single-neutral-male.svg new file mode 100644 index 0000000000000..7f81c29bfb786 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-single-neutral-male.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/user-sync-online-in-person.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-sync-online-in-person.svg new file mode 100644 index 0000000000000..71b68e2f37cc7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/user-sync-online-in-person.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/vertical-slider-square.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/vertical-slider-square.svg new file mode 100644 index 0000000000000..0cf86e26e0537 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/vertical-slider-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/video-swap-camera.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/video-swap-camera.svg new file mode 100644 index 0000000000000..f7cdad5918fd6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/video-swap-camera.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/visible.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/visible.svg new file mode 100644 index 0000000000000..343ffa0ca98ab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/visible.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/voice-scan-2.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/voice-scan-2.svg new file mode 100644 index 0000000000000..8a083f13adee8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/voice-scan-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/waning-cresent-moon.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/waning-cresent-moon.svg new file mode 100644 index 0000000000000..91dde28366f18 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/waning-cresent-moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/warning-octagon.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/warning-octagon.svg new file mode 100644 index 0000000000000..52cc420522a72 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/warning-octagon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/interface_essential/warning-triangle.svg b/frontend/appflowy_web_app/public/af_icons/interface_essential/warning-triangle.svg new file mode 100644 index 0000000000000..5f205c4c95a0e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/interface_essential/warning-triangle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-notification.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-notification.svg new file mode 100644 index 0000000000000..320ab16d198c7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-notification.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-smiley-1.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-smiley-1.svg new file mode 100644 index 0000000000000..b79fed59f7e06 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-smiley-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-smiley-2.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-smiley-2.svg new file mode 100644 index 0000000000000..6f20c07292479 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval-smiley-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval.svg new file mode 100644 index 0000000000000..74bfb2b1f1e99 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-oval.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-block.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-block.svg new file mode 100644 index 0000000000000..81e9132b68639 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-block.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-question.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-question.svg new file mode 100644 index 0000000000000..b09ee92a0958e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-question.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-warning.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-warning.svg new file mode 100644 index 0000000000000..e784e77e10c5a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-warning.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-write.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-write.svg new file mode 100644 index 0000000000000..9dc5174dcab60 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-square-write.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-text-square.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-text-square.svg new file mode 100644 index 0000000000000..60f7c3032b721 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-text-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-typing-oval.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-typing-oval.svg new file mode 100644 index 0000000000000..05f9dc722dc5e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-bubble-typing-oval.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/chat-two-bubbles-oval.svg b/frontend/appflowy_web_app/public/af_icons/mail/chat-two-bubbles-oval.svg new file mode 100644 index 0000000000000..068e2de39e51e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/chat-two-bubbles-oval.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/discussion-converstion-reply.svg b/frontend/appflowy_web_app/public/af_icons/mail/discussion-converstion-reply.svg new file mode 100644 index 0000000000000..4d63be9a85dd8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/discussion-converstion-reply.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/happy-face.svg b/frontend/appflowy_web_app/public/af_icons/mail/happy-face.svg new file mode 100644 index 0000000000000..1f5f581da68d0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/happy-face.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/inbox-block.svg b/frontend/appflowy_web_app/public/af_icons/mail/inbox-block.svg new file mode 100644 index 0000000000000..251a31897f112 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/inbox-block.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/inbox-favorite-heart.svg b/frontend/appflowy_web_app/public/af_icons/mail/inbox-favorite-heart.svg new file mode 100644 index 0000000000000..68d266fe493f3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/inbox-favorite-heart.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/inbox-favorite.svg b/frontend/appflowy_web_app/public/af_icons/mail/inbox-favorite.svg new file mode 100644 index 0000000000000..9a27890605276 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/inbox-favorite.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/inbox-lock.svg b/frontend/appflowy_web_app/public/af_icons/mail/inbox-lock.svg new file mode 100644 index 0000000000000..721163917df7a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/inbox-lock.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/inbox-tray-1.svg b/frontend/appflowy_web_app/public/af_icons/mail/inbox-tray-1.svg new file mode 100644 index 0000000000000..25cf8d1ace3ab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/inbox-tray-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/inbox-tray-2.svg b/frontend/appflowy_web_app/public/af_icons/mail/inbox-tray-2.svg new file mode 100644 index 0000000000000..2d2b6afa4c267 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/inbox-tray-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/mail-incoming.svg b/frontend/appflowy_web_app/public/af_icons/mail/mail-incoming.svg new file mode 100644 index 0000000000000..e3cebdbffa5fe --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/mail-incoming.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/mail-search.svg b/frontend/appflowy_web_app/public/af_icons/mail/mail-search.svg new file mode 100644 index 0000000000000..280d7cb36360b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/mail-search.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/mail-send-email-message.svg b/frontend/appflowy_web_app/public/af_icons/mail/mail-send-email-message.svg new file mode 100644 index 0000000000000..5a017646076ff --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/mail-send-email-message.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/mail-send-envelope.svg b/frontend/appflowy_web_app/public/af_icons/mail/mail-send-envelope.svg new file mode 100644 index 0000000000000..ee32a0d2d5efc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/mail-send-envelope.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/mail-send-reply-all.svg b/frontend/appflowy_web_app/public/af_icons/mail/mail-send-reply-all.svg new file mode 100644 index 0000000000000..5a4bbd13ae54a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/mail-send-reply-all.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/sad-face.svg b/frontend/appflowy_web_app/public/af_icons/mail/sad-face.svg new file mode 100644 index 0000000000000..cb07b814ff738 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/sad-face.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/send-email.svg b/frontend/appflowy_web_app/public/af_icons/mail/send-email.svg new file mode 100644 index 0000000000000..0431ab66ebebf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/send-email.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/sign-at.svg b/frontend/appflowy_web_app/public/af_icons/mail/sign-at.svg new file mode 100644 index 0000000000000..764b2bf31269c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/sign-at.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/sign-hashtag.svg b/frontend/appflowy_web_app/public/af_icons/mail/sign-hashtag.svg new file mode 100644 index 0000000000000..545e6610078a9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/sign-hashtag.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-angry.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-angry.svg new file mode 100644 index 0000000000000..3e9ad9ee33bfa --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-angry.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-cool.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-cool.svg new file mode 100644 index 0000000000000..71d44d8279201 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-cool.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-crying-1.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-crying-1.svg new file mode 100644 index 0000000000000..c5cfe2df8d150 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-crying-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-cute.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-cute.svg new file mode 100644 index 0000000000000..918f29f705bbd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-cute.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-drool.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-drool.svg new file mode 100644 index 0000000000000..acc73cee7cba6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-drool.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-emoji-kiss-nervous.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-emoji-kiss-nervous.svg new file mode 100644 index 0000000000000..2e8ce83c873da --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-emoji-kiss-nervous.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-emoji-terrified.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-emoji-terrified.svg new file mode 100644 index 0000000000000..2ee952b07cde3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-emoji-terrified.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-grumpy.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-grumpy.svg new file mode 100644 index 0000000000000..e0f6d4c9396ac --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-grumpy.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-happy.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-happy.svg new file mode 100644 index 0000000000000..1849e6fc5fc1f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-happy.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-in-love.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-in-love.svg new file mode 100644 index 0000000000000..cb3f446338090 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-in-love.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-kiss.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-kiss.svg new file mode 100644 index 0000000000000..f86db7c10cb99 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-kiss.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/mail/smiley-laughing-3.svg b/frontend/appflowy_web_app/public/af_icons/mail/smiley-laughing-3.svg new file mode 100644 index 0000000000000..df01420baacf5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/mail/smiley-laughing-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/airplane.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/airplane.svg new file mode 100644 index 0000000000000..85b9018e5bd4c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/airplane.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/airport-plane-transit.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/airport-plane-transit.svg new file mode 100644 index 0000000000000..723a23d913926 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/airport-plane-transit.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/airport-plane.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/airport-plane.svg new file mode 100644 index 0000000000000..6731fd899264c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/airport-plane.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/airport-security.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/airport-security.svg new file mode 100644 index 0000000000000..30d2c370a6219 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/airport-security.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/anchor.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/anchor.svg new file mode 100644 index 0000000000000..8b05191e02f40 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/anchor.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/baggage.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/baggage.svg new file mode 100644 index 0000000000000..674be5b254ac1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/baggage.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/beach.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/beach.svg new file mode 100644 index 0000000000000..8e38eaea77334 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/beach.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/bicycle-bike.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/bicycle-bike.svg new file mode 100644 index 0000000000000..0f01d9cdd150e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/bicycle-bike.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/braille-blind.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/braille-blind.svg new file mode 100644 index 0000000000000..8c8f5310038bd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/braille-blind.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/bus.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/bus.svg new file mode 100644 index 0000000000000..2bf0c8ab84f29 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/bus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/camping-tent.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/camping-tent.svg new file mode 100644 index 0000000000000..46d9e7fcc7c85 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/camping-tent.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/cane.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/cane.svg new file mode 100644 index 0000000000000..6778b91182979 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/cane.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/capitol.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/capitol.svg new file mode 100644 index 0000000000000..c9f7106687e3c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/capitol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/car-battery-charging.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/car-battery-charging.svg new file mode 100644 index 0000000000000..610323ea42609 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/car-battery-charging.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/car-taxi-1.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/car-taxi-1.svg new file mode 100644 index 0000000000000..156ee2113aff0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/car-taxi-1.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/city-hall.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/city-hall.svg new file mode 100644 index 0000000000000..379f9a974a295 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/city-hall.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/compass-navigator.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/compass-navigator.svg new file mode 100644 index 0000000000000..63ead58975390 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/compass-navigator.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/crutch.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/crutch.svg new file mode 100644 index 0000000000000..6f46d47b8793b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/crutch.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/dangerous-zone-sign.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/dangerous-zone-sign.svg new file mode 100644 index 0000000000000..675fbfb386097 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/dangerous-zone-sign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/earth-1.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/earth-1.svg new file mode 100644 index 0000000000000..b38deea54bd94 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/earth-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/earth-airplane.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/earth-airplane.svg new file mode 100644 index 0000000000000..44103e0c82409 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/earth-airplane.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/emergency-exit.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/emergency-exit.svg new file mode 100644 index 0000000000000..047192a9f196d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/emergency-exit.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/fire-alarm-2.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/fire-alarm-2.svg new file mode 100644 index 0000000000000..17cd4f4304498 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/fire-alarm-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/fire-extinguisher-sign.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/fire-extinguisher-sign.svg new file mode 100644 index 0000000000000..54da68657a9f7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/fire-extinguisher-sign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/gas-station-fuel-petroleum.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/gas-station-fuel-petroleum.svg new file mode 100644 index 0000000000000..3fb3f024a71fa --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/gas-station-fuel-petroleum.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hearing-deaf-1.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hearing-deaf-1.svg new file mode 100644 index 0000000000000..5abe2f60a0454 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hearing-deaf-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hearing-deaf-2.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hearing-deaf-2.svg new file mode 100644 index 0000000000000..7623a86ae79e6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hearing-deaf-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/high-speed-train-front.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/high-speed-train-front.svg new file mode 100644 index 0000000000000..0334e072ee1d0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/high-speed-train-front.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hot-spring.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hot-spring.svg new file mode 100644 index 0000000000000..3f72df09e3004 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hot-spring.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-air-conditioner.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-air-conditioner.svg new file mode 100644 index 0000000000000..b8006f8ab8e02 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-air-conditioner.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-bed-2.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-bed-2.svg new file mode 100644 index 0000000000000..2af7f57ce18f6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-bed-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-laundry.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-laundry.svg new file mode 100644 index 0000000000000..5a7291b7da412 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-laundry.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-one-star.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-one-star.svg new file mode 100644 index 0000000000000..c7d69e2d42bb7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-one-star.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-shower-head.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-shower-head.svg new file mode 100644 index 0000000000000..5e3cf1de4055d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-shower-head.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-two-star.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-two-star.svg new file mode 100644 index 0000000000000..c1ae54056ccd7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/hotel-two-star.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/information-desk-customer.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/information-desk-customer.svg new file mode 100644 index 0000000000000..7add1d0610dfd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/information-desk-customer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/information-desk.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/information-desk.svg new file mode 100644 index 0000000000000..d13cba2fc51de --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/information-desk.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/iron.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/iron.svg new file mode 100644 index 0000000000000..641099f09aa1d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/iron.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/ladder.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/ladder.svg new file mode 100644 index 0000000000000..996312944529b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/ladder.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/lift-disability.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/lift-disability.svg new file mode 100644 index 0000000000000..588edf9c83a6f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/lift-disability.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/lift.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/lift.svg new file mode 100644 index 0000000000000..aafccb263e4bb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/lift.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/location-compass-1.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/location-compass-1.svg new file mode 100644 index 0000000000000..00647fbe0f5ed --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/location-compass-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/location-pin-3.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/location-pin-3.svg new file mode 100644 index 0000000000000..bc88a620b8a25 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/location-pin-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/location-pin-disabled.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/location-pin-disabled.svg new file mode 100644 index 0000000000000..2ff5e3cbd30c0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/location-pin-disabled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/location-target-1.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/location-target-1.svg new file mode 100644 index 0000000000000..9c015e2f4779b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/location-target-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/lost-and-found.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/lost-and-found.svg new file mode 100644 index 0000000000000..047a764906670 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/lost-and-found.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/man-symbol.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/man-symbol.svg new file mode 100644 index 0000000000000..0f2a3dcef70b5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/man-symbol.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/map-fold.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/map-fold.svg new file mode 100644 index 0000000000000..5f059cb98b653 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/map-fold.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/navigation-arrow-off.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/navigation-arrow-off.svg new file mode 100644 index 0000000000000..d40bbffe6fefe --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/navigation-arrow-off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/navigation-arrow-on.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/navigation-arrow-on.svg new file mode 100644 index 0000000000000..ca83a1f7b640d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/navigation-arrow-on.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/parking-sign.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/parking-sign.svg new file mode 100644 index 0000000000000..3708cca823543 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/parking-sign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/parliament.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/parliament.svg new file mode 100644 index 0000000000000..aae1a5f440afb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/parliament.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/passport.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/passport.svg new file mode 100644 index 0000000000000..848f049fbc39e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/passport.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/pet-paw.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/pet-paw.svg new file mode 100644 index 0000000000000..27cf85c3186d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/pet-paw.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/pets-allowed.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/pets-allowed.svg new file mode 100644 index 0000000000000..48bd43e11a3ef --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/pets-allowed.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/pool-ladder.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/pool-ladder.svg new file mode 100644 index 0000000000000..dabe71440e8fa --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/pool-ladder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/rock-slide.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/rock-slide.svg new file mode 100644 index 0000000000000..0a6a5a711d624 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/rock-slide.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/sail-ship.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/sail-ship.svg new file mode 100644 index 0000000000000..982767a3c5ba6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/sail-ship.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/school-bus-side.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/school-bus-side.svg new file mode 100644 index 0000000000000..7b5856e7ff2b8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/school-bus-side.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/smoke-detector.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/smoke-detector.svg new file mode 100644 index 0000000000000..89862439b9a8d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/smoke-detector.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/smoking-area.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/smoking-area.svg new file mode 100644 index 0000000000000..4e39d94df2a86 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/smoking-area.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/snorkle.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/snorkle.svg new file mode 100644 index 0000000000000..626e2cf4844c0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/snorkle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/steering-wheel.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/steering-wheel.svg new file mode 100644 index 0000000000000..2b3adeb5bb400 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/steering-wheel.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/street-road.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/street-road.svg new file mode 100644 index 0000000000000..5ff4e3c03cce8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/street-road.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/street-sign.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/street-sign.svg new file mode 100644 index 0000000000000..d5f80c30a8219 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/street-sign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/take-off.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/take-off.svg new file mode 100644 index 0000000000000..7d5aafc2c3a45 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/take-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-man.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-man.svg new file mode 100644 index 0000000000000..67df34ada541d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-man.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-sign-man-woman-2.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-sign-man-woman-2.svg new file mode 100644 index 0000000000000..44f3680273946 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-sign-man-woman-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-women.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-women.svg new file mode 100644 index 0000000000000..916f4591c7adb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/toilet-women.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/traffic-cone.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/traffic-cone.svg new file mode 100644 index 0000000000000..1cd16d0c2f577 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/traffic-cone.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/triangle-flag.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/triangle-flag.svg new file mode 100644 index 0000000000000..a6f86c16b3002 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/triangle-flag.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/wheelchair-1.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/wheelchair-1.svg new file mode 100644 index 0000000000000..a0d539594cca7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/wheelchair-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/map_travel/woman-symbol.svg b/frontend/appflowy_web_app/public/af_icons/map_travel/woman-symbol.svg new file mode 100644 index 0000000000000..a9c4377ec1774 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/map_travel/woman-symbol.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/annoncement-megaphone.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/annoncement-megaphone.svg new file mode 100644 index 0000000000000..472f766f0e491 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/annoncement-megaphone.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/backpack.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/backpack.svg new file mode 100644 index 0000000000000..d9800f6d76069 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/backpack.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-dollar.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-dollar.svg new file mode 100644 index 0000000000000..176e8d38de66f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-dollar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-pound.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-pound.svg new file mode 100644 index 0000000000000..a282df85fbc85 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-pound.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-rupee.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-rupee.svg new file mode 100644 index 0000000000000..eb5c02be60139 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-rupee.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-suitcase-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-suitcase-1.svg new file mode 100644 index 0000000000000..82a1e7ff3197c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-suitcase-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-suitcase-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-suitcase-2.svg new file mode 100644 index 0000000000000..706aab5df3fc1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-suitcase-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-yen.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-yen.svg new file mode 100644 index 0000000000000..2a54180e78aac --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag-yen.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bag.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag.svg new file mode 100644 index 0000000000000..d1abe114437a2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bag.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/ball.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/ball.svg new file mode 100644 index 0000000000000..6f7e278919564 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/ball.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bank.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bank.svg new file mode 100644 index 0000000000000..fd6cf58939fee --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bank.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/beanie.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/beanie.svg new file mode 100644 index 0000000000000..374557ac4fcdd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/beanie.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-1.svg new file mode 100644 index 0000000000000..6df0d554df06b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-2.svg new file mode 100644 index 0000000000000..61f55ac5d5084 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-4.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-4.svg new file mode 100644 index 0000000000000..2ffbf22977976 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-4.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-cashless.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-cashless.svg new file mode 100644 index 0000000000000..aa77bf8d67704 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bill-cashless.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/binance-circle.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/binance-circle.svg new file mode 100644 index 0000000000000..6b489a693e815 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/binance-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bitcoin.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bitcoin.svg new file mode 100644 index 0000000000000..211aee40c6887 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bitcoin.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/bow-tie.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/bow-tie.svg new file mode 100644 index 0000000000000..2f2bbdaa5570a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/bow-tie.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/briefcase-dollar.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/briefcase-dollar.svg new file mode 100644 index 0000000000000..14c68c87c4c02 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/briefcase-dollar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/building-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/building-2.svg new file mode 100644 index 0000000000000..56c3585f37ac3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/building-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/business-card.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-card.svg new file mode 100644 index 0000000000000..f980629bceb53 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-card.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/business-handshake.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-handshake.svg new file mode 100644 index 0000000000000..62ce5e55c45a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-handshake.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/business-idea-money.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-idea-money.svg new file mode 100644 index 0000000000000..d4eb07175f987 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-idea-money.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/business-profession-home-office.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-profession-home-office.svg new file mode 100644 index 0000000000000..f634c96f5f38a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-profession-home-office.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/business-progress-bar-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-progress-bar-2.svg new file mode 100644 index 0000000000000..b73b5e9015fec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-progress-bar-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/business-user-curriculum.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-user-curriculum.svg new file mode 100644 index 0000000000000..19714b52b13a3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/business-user-curriculum.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/calculator-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/calculator-1.svg new file mode 100644 index 0000000000000..d14d7b0050920 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/calculator-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/calculator-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/calculator-2.svg new file mode 100644 index 0000000000000..bf48853eaa2d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/calculator-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/cane.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/cane.svg new file mode 100644 index 0000000000000..4c7c27073cad0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/cane.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/chair.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/chair.svg new file mode 100644 index 0000000000000..a6d33f000f7b3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/chair.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/closet.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/closet.svg new file mode 100644 index 0000000000000..16991a60e9d39 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/closet.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/coin-share.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/coin-share.svg new file mode 100644 index 0000000000000..9aa28f013ff13 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/coin-share.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/coins-stack.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/coins-stack.svg new file mode 100644 index 0000000000000..fceb62950c741 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/coins-stack.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/credit-card-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/credit-card-1.svg new file mode 100644 index 0000000000000..e730a703d551f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/credit-card-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/credit-card-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/credit-card-2.svg new file mode 100644 index 0000000000000..f6ba16a21315a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/credit-card-2.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/diamond-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/diamond-2.svg new file mode 100644 index 0000000000000..e226caaf3813f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/diamond-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-badge.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-badge.svg new file mode 100644 index 0000000000000..5cda678b879b6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-badge.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-circle.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-circle.svg new file mode 100644 index 0000000000000..6fa8b79900033 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-coupon.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-coupon.svg new file mode 100644 index 0000000000000..9ff5dac1f453d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-coupon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-cutout.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-cutout.svg new file mode 100644 index 0000000000000..82a7587a6efd2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-cutout.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-fire.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-fire.svg new file mode 100644 index 0000000000000..00a78332c45fb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/discount-percent-fire.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/dollar-coin-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/dollar-coin-1.svg new file mode 100644 index 0000000000000..0db0836acacbb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/dollar-coin-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/dollar-coin.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/dollar-coin.svg new file mode 100644 index 0000000000000..b285d8d4b2199 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/dollar-coin.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/dressing-table.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/dressing-table.svg new file mode 100644 index 0000000000000..90e453e2b752a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/dressing-table.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/ethereum-circle.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/ethereum-circle.svg new file mode 100644 index 0000000000000..06db44f70d804 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/ethereum-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/ethereum.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/ethereum.svg new file mode 100644 index 0000000000000..40f205e9d74e1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/ethereum.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/euro.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/euro.svg new file mode 100644 index 0000000000000..73121ce282794 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/euro.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/gift-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/gift-2.svg new file mode 100644 index 0000000000000..e64b42428ee88 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/gift-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/gift.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/gift.svg new file mode 100644 index 0000000000000..ba788391024f4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/gift.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/gold.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/gold.svg new file mode 100644 index 0000000000000..33ef845ad24a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/gold.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-arrow-decrease.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-arrow-decrease.svg new file mode 100644 index 0000000000000..1177b1acfb644 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-arrow-decrease.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-arrow-increase.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-arrow-increase.svg new file mode 100644 index 0000000000000..82415d32c65e9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-arrow-increase.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-bar-decrease.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-bar-decrease.svg new file mode 100644 index 0000000000000..79f523f8d9b9a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-bar-decrease.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-bar-increase.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-bar-increase.svg new file mode 100644 index 0000000000000..801daa256be52 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-bar-increase.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-dot.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-dot.svg new file mode 100644 index 0000000000000..7ca8308d52d9e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph-dot.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/graph.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph.svg new file mode 100644 index 0000000000000..4c692f6ca0015 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/graph.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/investment-selection.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/investment-selection.svg new file mode 100644 index 0000000000000..478b852ad17bc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/investment-selection.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-hammer.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-hammer.svg new file mode 100644 index 0000000000000..d9bf64217e593 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-hammer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-scale-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-scale-1.svg new file mode 100644 index 0000000000000..6e531dd1b8145 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-scale-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-scale-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-scale-2.svg new file mode 100644 index 0000000000000..e90aa2594b6d2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/justice-scale-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/lipstick.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/lipstick.svg new file mode 100644 index 0000000000000..e2ffa968c624f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/lipstick.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/make-up-brush.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/make-up-brush.svg new file mode 100644 index 0000000000000..d5e28774ae060 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/make-up-brush.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/moustache.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/moustache.svg new file mode 100644 index 0000000000000..049e343f185b1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/moustache.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/mouth-lip.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/mouth-lip.svg new file mode 100644 index 0000000000000..22cb09ed17bc0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/mouth-lip.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/necklace.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/necklace.svg new file mode 100644 index 0000000000000..7e615d5fc1d9c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/necklace.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/necktie.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/necktie.svg new file mode 100644 index 0000000000000..dd502d68eccc1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/necktie.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/payment-10.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/payment-10.svg new file mode 100644 index 0000000000000..42935055e54ec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/payment-10.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/payment-cash-out-3.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/payment-cash-out-3.svg new file mode 100644 index 0000000000000..8f451cb9f827e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/payment-cash-out-3.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/pie-chart.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/pie-chart.svg new file mode 100644 index 0000000000000..2966a8ef8fcdd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/pie-chart.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/piggy-bank.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/piggy-bank.svg new file mode 100644 index 0000000000000..ef796838b2665 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/piggy-bank.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/polka-dot-circle.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/polka-dot-circle.svg new file mode 100644 index 0000000000000..f50f1edc7673b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/polka-dot-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/production-belt.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/production-belt.svg new file mode 100644 index 0000000000000..b56f95f19f838 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/production-belt.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/qr-code.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/qr-code.svg new file mode 100644 index 0000000000000..c93d63f0601f0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/qr-code.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-add.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-add.svg new file mode 100644 index 0000000000000..50cc1f57da36f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-check.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-check.svg new file mode 100644 index 0000000000000..99762d138f719 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-subtract.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-subtract.svg new file mode 100644 index 0000000000000..9a18ed6d061a1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt-subtract.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt.svg new file mode 100644 index 0000000000000..da4dab634027a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/receipt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/safe-vault.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/safe-vault.svg new file mode 100644 index 0000000000000..9c3d9d7230b5c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/safe-vault.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/scanner-3.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/scanner-3.svg new file mode 100644 index 0000000000000..f7db012503432 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/scanner-3.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/scanner-bar-code.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/scanner-bar-code.svg new file mode 100644 index 0000000000000..b60571a669758 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/scanner-bar-code.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shelf.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shelf.svg new file mode 100644 index 0000000000000..3ea11815bfb91 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shelf.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-bag-hand-bag-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-bag-hand-bag-2.svg new file mode 100644 index 0000000000000..ae8ef2d315c62 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-bag-hand-bag-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-basket-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-basket-1.svg new file mode 100644 index 0000000000000..b33b5304fb3ca --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-basket-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-basket-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-basket-2.svg new file mode 100644 index 0000000000000..f2610aab4c24e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-basket-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-1.svg new file mode 100644 index 0000000000000..74529f1ae54f9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-2.svg new file mode 100644 index 0000000000000..eecaeadf06e30 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-3.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-3.svg new file mode 100644 index 0000000000000..681b1653e9ac3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-add.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-add.svg new file mode 100644 index 0000000000000..50d05a70b71a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-check.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-check.svg new file mode 100644 index 0000000000000..6d7b9fb635dbb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-subtract.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-subtract.svg new file mode 100644 index 0000000000000..41c96d3ec9edb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/shopping-cart-subtract.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/signage-3.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/signage-3.svg new file mode 100644 index 0000000000000..92c321b4b57be --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/signage-3.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/signage-4.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/signage-4.svg new file mode 100644 index 0000000000000..42b4f8c6a9f89 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/signage-4.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/startup.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/startup.svg new file mode 100644 index 0000000000000..77ed266e676d2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/startup.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/stock.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/stock.svg new file mode 100644 index 0000000000000..d350449bcf0d6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/stock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/store-1.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/store-1.svg new file mode 100644 index 0000000000000..53751414d59b4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/store-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/store-2.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/store-2.svg new file mode 100644 index 0000000000000..7e2312aa57c4b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/store-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/store-computer.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/store-computer.svg new file mode 100644 index 0000000000000..4ea808bf31438 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/store-computer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/subscription-cashflow.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/subscription-cashflow.svg new file mode 100644 index 0000000000000..c7f45631aca95 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/subscription-cashflow.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/tag.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/tag.svg new file mode 100644 index 0000000000000..3aae6ba5c3ca0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/tag.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/tall-hat.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/tall-hat.svg new file mode 100644 index 0000000000000..1f5650785c4b6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/tall-hat.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/target-3.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/target-3.svg new file mode 100644 index 0000000000000..35591b9238df6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/target-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/target.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/target.svg new file mode 100644 index 0000000000000..632d7c4c3c928 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/target.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/wallet-purse.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/wallet-purse.svg new file mode 100644 index 0000000000000..6df7533528f00 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/wallet-purse.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/wallet.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/wallet.svg new file mode 100644 index 0000000000000..39042c9f04bf2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/wallet.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/xrp-circle.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/xrp-circle.svg new file mode 100644 index 0000000000000..4250e7055521c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/xrp-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/yuan-circle.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/yuan-circle.svg new file mode 100644 index 0000000000000..138056c639332 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/yuan-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/money_shopping/yuan.svg b/frontend/appflowy_web_app/public/af_icons/money_shopping/yuan.svg new file mode 100644 index 0000000000000..65976287feeca --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/money_shopping/yuan.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/affordable-and-clean-energy.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/affordable-and-clean-energy.svg new file mode 100644 index 0000000000000..70421c5d33893 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/affordable-and-clean-energy.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/alien.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/alien.svg new file mode 100644 index 0000000000000..5cfaf813bf575 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/alien.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/bone.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/bone.svg new file mode 100644 index 0000000000000..669f8e97557c9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/bone.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/cat-1.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/cat-1.svg new file mode 100644 index 0000000000000..d23a378b47f57 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/cat-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/circle-flask.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/circle-flask.svg new file mode 100644 index 0000000000000..fc75c75d0d237 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/circle-flask.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/clean-water-and-sanitation.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/clean-water-and-sanitation.svg new file mode 100644 index 0000000000000..c7b4738cee6a4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/clean-water-and-sanitation.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/comet.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/comet.svg new file mode 100644 index 0000000000000..3d0012a1fc1f7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/comet.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/decent-work-and-economic-growth.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/decent-work-and-economic-growth.svg new file mode 100644 index 0000000000000..3aa52629d704d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/decent-work-and-economic-growth.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/dna.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/dna.svg new file mode 100644 index 0000000000000..dda09e350c632 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/dna.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/erlenmeyer-flask.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/erlenmeyer-flask.svg new file mode 100644 index 0000000000000..14e078c4769df --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/erlenmeyer-flask.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/flower.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/flower.svg new file mode 100644 index 0000000000000..675f917956064 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/flower.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/galaxy-1.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/galaxy-1.svg new file mode 100644 index 0000000000000..59d4638f8c359 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/galaxy-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/galaxy-2.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/galaxy-2.svg new file mode 100644 index 0000000000000..d8948d5f353d5 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/galaxy-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/gender-equality.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/gender-equality.svg new file mode 100644 index 0000000000000..3f0a9cdecc76b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/gender-equality.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/good-health-and-well-being.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/good-health-and-well-being.svg new file mode 100644 index 0000000000000..c92374cff59e8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/good-health-and-well-being.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/industry-innovation-and-infrastructure.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/industry-innovation-and-infrastructure.svg new file mode 100644 index 0000000000000..7a58eecbcaf29 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/industry-innovation-and-infrastructure.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/leaf.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/leaf.svg new file mode 100644 index 0000000000000..2a53ca6fd5c5f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/leaf.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/log.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/log.svg new file mode 100644 index 0000000000000..1a3deaa880ad2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/log.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/no-poverty.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/no-poverty.svg new file mode 100644 index 0000000000000..f12c0b41114dc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/no-poverty.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/octopus.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/octopus.svg new file mode 100644 index 0000000000000..38855cbe547ab --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/octopus.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/planet.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/planet.svg new file mode 100644 index 0000000000000..9ee346f58413b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/planet.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/potted-flower-tulip.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/potted-flower-tulip.svg new file mode 100644 index 0000000000000..2be0dc0cb5740 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/potted-flower-tulip.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/quality-education.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/quality-education.svg new file mode 100644 index 0000000000000..c6e0335e9b24e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/quality-education.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/rainbow.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/rainbow.svg new file mode 100644 index 0000000000000..8c99192cee098 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/rainbow.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/recycle-1.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/recycle-1.svg new file mode 100644 index 0000000000000..30ebcf8fb2b33 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/recycle-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/reduced-inequalities.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/reduced-inequalities.svg new file mode 100644 index 0000000000000..cc3de7a365748 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/reduced-inequalities.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/rose.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/rose.svg new file mode 100644 index 0000000000000..3459894b5b4a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/rose.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/shell.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/shell.svg new file mode 100644 index 0000000000000..bde5159a0a133 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/shell.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/shovel-rake.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/shovel-rake.svg new file mode 100644 index 0000000000000..832d2a4f1d415 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/shovel-rake.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/sprout.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/sprout.svg new file mode 100644 index 0000000000000..3b8ea11ba38c0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/sprout.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/telescope.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/telescope.svg new file mode 100644 index 0000000000000..9a29c5483dc26 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/telescope.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/test-tube.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/test-tube.svg new file mode 100644 index 0000000000000..01d12af810b09 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/test-tube.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/tidal-wave.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/tidal-wave.svg new file mode 100644 index 0000000000000..56c80ec200a05 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/tidal-wave.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/tree-2.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/tree-2.svg new file mode 100644 index 0000000000000..da32de6697bdf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/tree-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/tree-3.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/tree-3.svg new file mode 100644 index 0000000000000..db8bfe987bae6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/tree-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/volcano.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/volcano.svg new file mode 100644 index 0000000000000..989f5e68b92bc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/volcano.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/windmill.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/windmill.svg new file mode 100644 index 0000000000000..edfb75988366d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/windmill.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/nature_ecology/zero-hunger.svg b/frontend/appflowy_web_app/public/af_icons/nature_ecology/zero-hunger.svg new file mode 100644 index 0000000000000..7cd7b52a34c60 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/nature_ecology/zero-hunger.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/airplane-disabled.svg b/frontend/appflowy_web_app/public/af_icons/phone/airplane-disabled.svg new file mode 100644 index 0000000000000..dd6bf8d91aac6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/airplane-disabled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/airplane-enabled.svg b/frontend/appflowy_web_app/public/af_icons/phone/airplane-enabled.svg new file mode 100644 index 0000000000000..af74878ae43e6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/airplane-enabled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/back-camera-1.svg b/frontend/appflowy_web_app/public/af_icons/phone/back-camera-1.svg new file mode 100644 index 0000000000000..9c38dd55e23ad --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/back-camera-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/call-hang-up.svg b/frontend/appflowy_web_app/public/af_icons/phone/call-hang-up.svg new file mode 100644 index 0000000000000..41d0886a0e573 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/call-hang-up.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-4g.svg b/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-4g.svg new file mode 100644 index 0000000000000..559284654ef6a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-4g.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-5g.svg b/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-5g.svg new file mode 100644 index 0000000000000..a4b9a8626f0f0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-5g.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-lte.svg b/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-lte.svg new file mode 100644 index 0000000000000..373d8e86405de --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/cellular-network-lte.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/contact-phonebook-2.svg b/frontend/appflowy_web_app/public/af_icons/phone/contact-phonebook-2.svg new file mode 100644 index 0000000000000..8968279a298c9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/contact-phonebook-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/hang-up-1.svg b/frontend/appflowy_web_app/public/af_icons/phone/hang-up-1.svg new file mode 100644 index 0000000000000..fb4c7970281fd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/hang-up-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/hang-up-2.svg b/frontend/appflowy_web_app/public/af_icons/phone/hang-up-2.svg new file mode 100644 index 0000000000000..dbd92cb2a3f7c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/hang-up-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/incoming-call.svg b/frontend/appflowy_web_app/public/af_icons/phone/incoming-call.svg new file mode 100644 index 0000000000000..dfb4e8a02eefb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/incoming-call.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/missed-call.svg b/frontend/appflowy_web_app/public/af_icons/phone/missed-call.svg new file mode 100644 index 0000000000000..9efc8a2eb9924 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/missed-call.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/notification-alarm-2.svg b/frontend/appflowy_web_app/public/af_icons/phone/notification-alarm-2.svg new file mode 100644 index 0000000000000..7eec376825fd6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/notification-alarm-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/notification-application-1.svg b/frontend/appflowy_web_app/public/af_icons/phone/notification-application-1.svg new file mode 100644 index 0000000000000..4908995f02161 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/notification-application-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/notification-application-2.svg b/frontend/appflowy_web_app/public/af_icons/phone/notification-application-2.svg new file mode 100644 index 0000000000000..32db7b5be0d0b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/notification-application-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/notification-message-alert.svg b/frontend/appflowy_web_app/public/af_icons/phone/notification-message-alert.svg new file mode 100644 index 0000000000000..7d1123fbee3d3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/notification-message-alert.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/outgoing-call.svg b/frontend/appflowy_web_app/public/af_icons/phone/outgoing-call.svg new file mode 100644 index 0000000000000..70ea90ca9eb5b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/outgoing-call.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/phone-mobile-phone.svg b/frontend/appflowy_web_app/public/af_icons/phone/phone-mobile-phone.svg new file mode 100644 index 0000000000000..299a57b66cd00 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/phone-mobile-phone.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/phone-qr.svg b/frontend/appflowy_web_app/public/af_icons/phone/phone-qr.svg new file mode 100644 index 0000000000000..f572d984d6a8c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/phone-qr.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/phone-ringing-1.svg b/frontend/appflowy_web_app/public/af_icons/phone/phone-ringing-1.svg new file mode 100644 index 0000000000000..1b13856b30825 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/phone-ringing-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/phone-ringing-2.svg b/frontend/appflowy_web_app/public/af_icons/phone/phone-ringing-2.svg new file mode 100644 index 0000000000000..889998fa684c1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/phone-ringing-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/phone.svg b/frontend/appflowy_web_app/public/af_icons/phone/phone.svg new file mode 100644 index 0000000000000..fc6ab0ad9c467 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/phone.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/signal-full.svg b/frontend/appflowy_web_app/public/af_icons/phone/signal-full.svg new file mode 100644 index 0000000000000..58823e23ea4e7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/signal-full.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/signal-low.svg b/frontend/appflowy_web_app/public/af_icons/phone/signal-low.svg new file mode 100644 index 0000000000000..e96c4af8a2762 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/signal-low.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/signal-medium.svg b/frontend/appflowy_web_app/public/af_icons/phone/signal-medium.svg new file mode 100644 index 0000000000000..1af4305dd0e5c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/signal-medium.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/phone/signal-none.svg b/frontend/appflowy_web_app/public/af_icons/phone/signal-none.svg new file mode 100644 index 0000000000000..a4eb9031e261e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/phone/signal-none.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/application-add.svg b/frontend/appflowy_web_app/public/af_icons/programing/application-add.svg new file mode 100644 index 0000000000000..3206d90bff512 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/application-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bracket.svg b/frontend/appflowy_web_app/public/af_icons/programing/bracket.svg new file mode 100644 index 0000000000000..19ec5e423ddc8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bracket.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-add.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-add.svg new file mode 100644 index 0000000000000..f202217042302 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-block.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-block.svg new file mode 100644 index 0000000000000..6eb1b7be24836 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-block.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-build.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-build.svg new file mode 100644 index 0000000000000..2fb2366c85ff0 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-build.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-check.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-check.svg new file mode 100644 index 0000000000000..198d2aad19f1c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-delete.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-delete.svg new file mode 100644 index 0000000000000..f6dc39ae72f2b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-delete.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-hash.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-hash.svg new file mode 100644 index 0000000000000..92664d39a7f35 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-hash.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-lock.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-lock.svg new file mode 100644 index 0000000000000..fec83df52fdd4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-lock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-multiple-window.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-multiple-window.svg new file mode 100644 index 0000000000000..7c8b3043cd7d1 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-multiple-window.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-remove.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-remove.svg new file mode 100644 index 0000000000000..eb5a5b1741465 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-remove.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/browser-website-1.svg b/frontend/appflowy_web_app/public/af_icons/programing/browser-website-1.svg new file mode 100644 index 0000000000000..a49315daca6bf --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/browser-website-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bug-antivirus-debugging.svg b/frontend/appflowy_web_app/public/af_icons/programing/bug-antivirus-debugging.svg new file mode 100644 index 0000000000000..be5811df0fcb6 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bug-antivirus-debugging.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bug-antivirus-shield.svg b/frontend/appflowy_web_app/public/af_icons/programing/bug-antivirus-shield.svg new file mode 100644 index 0000000000000..84af3bb9cce43 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bug-antivirus-shield.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-browser.svg b/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-browser.svg new file mode 100644 index 0000000000000..4f42f5432633f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-browser.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-document.svg b/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-document.svg new file mode 100644 index 0000000000000..a7c24e2ce6b0b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-document.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-folder.svg b/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-folder.svg new file mode 100644 index 0000000000000..eeea1baf39c18 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bug-virus-folder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/bug.svg b/frontend/appflowy_web_app/public/af_icons/programing/bug.svg new file mode 100644 index 0000000000000..a2d2795e45a58 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/bug.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-add.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-add.svg new file mode 100644 index 0000000000000..00a46324d0bc3 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-block.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-block.svg new file mode 100644 index 0000000000000..b321169629fb9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-block.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-check.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-check.svg new file mode 100644 index 0000000000000..a56b3fbbffc1b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-data-transfer.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-data-transfer.svg new file mode 100644 index 0000000000000..74d319d7f1534 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-data-transfer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-refresh.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-refresh.svg new file mode 100644 index 0000000000000..9096ac8796881 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-refresh.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-share.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-share.svg new file mode 100644 index 0000000000000..e69919e88c926 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-share.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-warning.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-warning.svg new file mode 100644 index 0000000000000..ebd5917d01535 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-warning.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/cloud-wifi.svg b/frontend/appflowy_web_app/public/af_icons/programing/cloud-wifi.svg new file mode 100644 index 0000000000000..51072ad76f7ae --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/cloud-wifi.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/code-analysis.svg b/frontend/appflowy_web_app/public/af_icons/programing/code-analysis.svg new file mode 100644 index 0000000000000..ef9dd46dbd51b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/code-analysis.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/code-monitor-1.svg b/frontend/appflowy_web_app/public/af_icons/programing/code-monitor-1.svg new file mode 100644 index 0000000000000..34c51a7ab24a8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/code-monitor-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/code-monitor-2.svg b/frontend/appflowy_web_app/public/af_icons/programing/code-monitor-2.svg new file mode 100644 index 0000000000000..5719d9699f341 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/code-monitor-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/css-three.svg b/frontend/appflowy_web_app/public/af_icons/programing/css-three.svg new file mode 100644 index 0000000000000..f20ce41833862 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/css-three.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/curly-brackets.svg b/frontend/appflowy_web_app/public/af_icons/programing/curly-brackets.svg new file mode 100644 index 0000000000000..6b6eb7d645d51 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/curly-brackets.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/file-code-1.svg b/frontend/appflowy_web_app/public/af_icons/programing/file-code-1.svg new file mode 100644 index 0000000000000..7e12e3847b7e7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/file-code-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/incognito-mode.svg b/frontend/appflowy_web_app/public/af_icons/programing/incognito-mode.svg new file mode 100644 index 0000000000000..84197023a4ae8 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/incognito-mode.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/insert-cloud-video.svg b/frontend/appflowy_web_app/public/af_icons/programing/insert-cloud-video.svg new file mode 100644 index 0000000000000..4f883df73fe4a --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/insert-cloud-video.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/markdown-circle-programming.svg b/frontend/appflowy_web_app/public/af_icons/programing/markdown-circle-programming.svg new file mode 100644 index 0000000000000..7c03684a41946 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/markdown-circle-programming.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/markdown-document-programming.svg b/frontend/appflowy_web_app/public/af_icons/programing/markdown-document-programming.svg new file mode 100644 index 0000000000000..514d68e7f81b2 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/markdown-document-programming.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/module-puzzle-1.svg b/frontend/appflowy_web_app/public/af_icons/programing/module-puzzle-1.svg new file mode 100644 index 0000000000000..c285965ff6ada --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/module-puzzle-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/module-puzzle-3.svg b/frontend/appflowy_web_app/public/af_icons/programing/module-puzzle-3.svg new file mode 100644 index 0000000000000..60c754e08db36 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/module-puzzle-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/module-three.svg b/frontend/appflowy_web_app/public/af_icons/programing/module-three.svg new file mode 100644 index 0000000000000..d517266362978 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/module-three.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/programing/rss-square.svg b/frontend/appflowy_web_app/public/af_icons/programing/rss-square.svg new file mode 100644 index 0000000000000..d84eeaabd14ba --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/programing/rss-square.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/box-sign.svg b/frontend/appflowy_web_app/public/af_icons/shipping/box-sign.svg new file mode 100644 index 0000000000000..4edefc3583e5d --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/box-sign.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/container.svg b/frontend/appflowy_web_app/public/af_icons/shipping/container.svg new file mode 100644 index 0000000000000..6b85af7c6c5d7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/container.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/fragile.svg b/frontend/appflowy_web_app/public/af_icons/shipping/fragile.svg new file mode 100644 index 0000000000000..0c694045289bd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/fragile.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/parachute-drop.svg b/frontend/appflowy_web_app/public/af_icons/shipping/parachute-drop.svg new file mode 100644 index 0000000000000..b4a08a3417474 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/parachute-drop.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipment-add.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-add.svg new file mode 100644 index 0000000000000..96060a0e5c265 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-add.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipment-check.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-check.svg new file mode 100644 index 0000000000000..80046da591480 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipment-download.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-download.svg new file mode 100644 index 0000000000000..aff16bb56ab25 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-download.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipment-remove.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-remove.svg new file mode 100644 index 0000000000000..cb2759d0c7dcb --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-remove.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipment-upload.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-upload.svg new file mode 100644 index 0000000000000..aa76150c54d60 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipment-upload.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipping-box-1.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipping-box-1.svg new file mode 100644 index 0000000000000..42a5129d1e5ec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipping-box-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/shipping-truck.svg b/frontend/appflowy_web_app/public/af_icons/shipping/shipping-truck.svg new file mode 100644 index 0000000000000..6835708103c7f --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/shipping-truck.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/transfer-motorcycle.svg b/frontend/appflowy_web_app/public/af_icons/shipping/transfer-motorcycle.svg new file mode 100644 index 0000000000000..2ab10a3db403b --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/transfer-motorcycle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/transfer-van.svg b/frontend/appflowy_web_app/public/af_icons/shipping/transfer-van.svg new file mode 100644 index 0000000000000..8c4a0cebc2b0e --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/transfer-van.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/shipping/warehouse-1.svg b/frontend/appflowy_web_app/public/af_icons/shipping/warehouse-1.svg new file mode 100644 index 0000000000000..57e18260f0987 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/shipping/warehouse-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/book-reading.svg b/frontend/appflowy_web_app/public/af_icons/work_education/book-reading.svg new file mode 100644 index 0000000000000..c792c95b11223 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/book-reading.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/class-lesson.svg b/frontend/appflowy_web_app/public/af_icons/work_education/class-lesson.svg new file mode 100644 index 0000000000000..ea2bef46a0315 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/class-lesson.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/collaborations-idea.svg b/frontend/appflowy_web_app/public/af_icons/work_education/collaborations-idea.svg new file mode 100644 index 0000000000000..633d1811a6c16 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/collaborations-idea.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/definition-search-book.svg b/frontend/appflowy_web_app/public/af_icons/work_education/definition-search-book.svg new file mode 100644 index 0000000000000..14039daefbedd --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/definition-search-book.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/dictionary-language-book.svg b/frontend/appflowy_web_app/public/af_icons/work_education/dictionary-language-book.svg new file mode 100644 index 0000000000000..60591372bbcff --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/dictionary-language-book.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/global-learning.svg b/frontend/appflowy_web_app/public/af_icons/work_education/global-learning.svg new file mode 100644 index 0000000000000..bbcea6ce493a9 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/global-learning.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/graduation-cap.svg b/frontend/appflowy_web_app/public/af_icons/work_education/graduation-cap.svg new file mode 100644 index 0000000000000..10dcd1e8342ec --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/graduation-cap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/group-meeting-call.svg b/frontend/appflowy_web_app/public/af_icons/work_education/group-meeting-call.svg new file mode 100644 index 0000000000000..786fad824d5a7 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/group-meeting-call.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/office-building-1.svg b/frontend/appflowy_web_app/public/af_icons/work_education/office-building-1.svg new file mode 100644 index 0000000000000..cba001bb4ebca --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/office-building-1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/office-worker.svg b/frontend/appflowy_web_app/public/af_icons/work_education/office-worker.svg new file mode 100644 index 0000000000000..42e2938530fbc --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/office-worker.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/search-dollar.svg b/frontend/appflowy_web_app/public/af_icons/work_education/search-dollar.svg new file mode 100644 index 0000000000000..acb98adab2f30 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/search-dollar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/strategy-tasks.svg b/frontend/appflowy_web_app/public/af_icons/work_education/strategy-tasks.svg new file mode 100644 index 0000000000000..3938e55804ee4 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/strategy-tasks.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/task-list.svg b/frontend/appflowy_web_app/public/af_icons/work_education/task-list.svg new file mode 100644 index 0000000000000..24f3080a8b322 --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/task-list.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/public/af_icons/work_education/workspace-desk.svg b/frontend/appflowy_web_app/public/af_icons/work_education/workspace-desk.svg new file mode 100644 index 0000000000000..9c2e287d7bb4c --- /dev/null +++ b/frontend/appflowy_web_app/public/af_icons/work_education/workspace-desk.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/appflowy.ico b/frontend/appflowy_web_app/public/appflowy.ico new file mode 100644 index 0000000000000..42570b2d501f1 Binary files /dev/null and b/frontend/appflowy_web_app/public/appflowy.ico differ diff --git a/frontend/appflowy_web_app/public/appflowy.svg b/frontend/appflowy_web_app/public/appflowy.svg new file mode 100644 index 0000000000000..e8ad42279473b --- /dev/null +++ b/frontend/appflowy_web_app/public/appflowy.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/appflowy_web_app/public/covers/m_cover_image_1.png b/frontend/appflowy_web_app/public/covers/m_cover_image_1.png new file mode 100644 index 0000000000000..fb720222877b6 Binary files /dev/null and b/frontend/appflowy_web_app/public/covers/m_cover_image_1.png differ diff --git a/frontend/appflowy_web_app/public/covers/m_cover_image_2.png b/frontend/appflowy_web_app/public/covers/m_cover_image_2.png new file mode 100644 index 0000000000000..9ecf02d253c15 Binary files /dev/null and b/frontend/appflowy_web_app/public/covers/m_cover_image_2.png differ diff --git a/frontend/appflowy_web_app/public/covers/m_cover_image_3.png b/frontend/appflowy_web_app/public/covers/m_cover_image_3.png new file mode 100644 index 0000000000000..97072b04f49d3 Binary files /dev/null and b/frontend/appflowy_web_app/public/covers/m_cover_image_3.png differ diff --git a/frontend/appflowy_web_app/public/covers/m_cover_image_4.png b/frontend/appflowy_web_app/public/covers/m_cover_image_4.png new file mode 100644 index 0000000000000..00d26a05000be Binary files /dev/null and b/frontend/appflowy_web_app/public/covers/m_cover_image_4.png differ diff --git a/frontend/appflowy_web_app/public/covers/m_cover_image_5.png b/frontend/appflowy_web_app/public/covers/m_cover_image_5.png new file mode 100644 index 0000000000000..3ecc9546c11ad Binary files /dev/null and b/frontend/appflowy_web_app/public/covers/m_cover_image_5.png differ diff --git a/frontend/appflowy_web_app/public/covers/m_cover_image_6.png b/frontend/appflowy_web_app/public/covers/m_cover_image_6.png new file mode 100644 index 0000000000000..0abd2700e86bf Binary files /dev/null and b/frontend/appflowy_web_app/public/covers/m_cover_image_6.png differ diff --git a/frontend/appflowy_web_app/public/og-image.png b/frontend/appflowy_web_app/public/og-image.png new file mode 100644 index 0000000000000..a43f64a46eb90 Binary files /dev/null and b/frontend/appflowy_web_app/public/og-image.png differ diff --git a/frontend/appflowy_web_app/scripts/create-symlink.cjs b/frontend/appflowy_web_app/scripts/create-symlink.cjs new file mode 100644 index 0000000000000..472f511f2766e --- /dev/null +++ b/frontend/appflowy_web_app/scripts/create-symlink.cjs @@ -0,0 +1,43 @@ +const fs = require('fs'); +const path = require('path'); +const chalk = require('chalk'); + +const sourcePath = process.argv[2]; +const targetPath = process.argv[3]; + +// ensure source and target paths are provided +if (!sourcePath || !targetPath) { + console.error(chalk.red('source and target paths are required')); + process.exit(1); +} + +const fullSourcePath = path.resolve(sourcePath); +const fullTargetPath = path.resolve(targetPath); +// ensure source path exists +if (!fs.existsSync(fullSourcePath)) { + console.error(chalk.red(`source path does not exist: ${fullSourcePath}`)); + process.exit(1); +} + +// ensure target path exists +if (!fs.existsSync(fullTargetPath)) { + console.error(chalk.red(`target path does not exist: ${fullTargetPath}`)); + process.exit(1); +} + + +if (fs.existsSync(fullTargetPath)) { + // unlink existing symlink + console.log(chalk.yellow(`unlinking existing symlink: `) + chalk.blue(`${fullTargetPath}`)); + fs.unlinkSync(fullTargetPath); +} + +// create symlink +fs.symlink(fullSourcePath, fullTargetPath, 'junction', (err) => { + if (err) { + console.error(chalk.red(`error creating symlink: ${err.message}`)); + process.exit(1); + } + console.log(chalk.green(`symlink created: `) + chalk.blue(`${fullSourcePath}`) + ' -> ' + chalk.blue(`${fullTargetPath}`)); + +}); diff --git a/frontend/appflowy_web_app/scripts/generateTailwindColors.cjs b/frontend/appflowy_web_app/scripts/generateTailwindColors.cjs new file mode 100644 index 0000000000000..83f5bb25d5114 --- /dev/null +++ b/frontend/appflowy_web_app/scripts/generateTailwindColors.cjs @@ -0,0 +1,61 @@ +const fs = require('fs'); +const path = require('path'); + +// Read CSS file +const cssFilePath = path.join(__dirname, '../src/styles/variables/light.variables.css'); +const cssContent = fs.readFileSync(cssFilePath, 'utf-8'); + +// Extract color variables +const shadowVariables = cssContent.match(/--shadow:\s.*;/g); +const colorVariables = cssContent.match(/--[\w-]+:\s*#[0-9a-fA-F]{6}/g); + +if (!colorVariables) { + console.error('No color variables found in CSS file.'); + process.exit(1); +} + +const shadows = shadowVariables.reduce((shadows, variable) => { + const [name, value] = variable.split(':').map(str => str.trim()); + const formattedName = name.replace('--', '').replace(/-/g, '_'); + const key = 'md'; + + shadows[key] = `var(${name})`; + return shadows; +}, {}); +// Generate Tailwind CSS colors configuration +// Replace -- with _ and - with _ in color variable names +const tailwindColors = colorVariables.reduce((colors, variable) => { + const [name, value] = variable.split(':').map(str => str.trim()); + const formattedName = name.replace('--', '').replace(/-/g, '_'); + const category = formattedName.split('_')[0]; + const key = formattedName.replace(`${category}_`, ''); + + if (!colors[category]) { + colors[category] = {}; + } + colors[category][key] = `var(${name})`; + return colors; +}, {}); + +const tailwindColorsFormatted = JSON.stringify(tailwindColors, null, 2) + .replace(/_/g, '-'); +const header = `/**\n` + '* Do not edit directly\n' + `* Generated on ${new Date().toUTCString()}\n` + `* Generated from $pnpm css:variables \n` + `*/\n\n`; + +// Write Tailwind CSS colors configuration to file +const tailwindColorTemplate = ` +${header} +module.exports = ${tailwindColorsFormatted}; +`; + +const tailwindShadowTemplate = ` +${header} +module.exports = ${JSON.stringify(shadows, null, 2).replace(/_/g, '-')}; +`; + +const tailwindConfigFilePath = path.join(__dirname, '../tailwind/colors.cjs'); +fs.writeFileSync(tailwindConfigFilePath, tailwindColorTemplate, 'utf-8'); + +const tailwindShadowFilePath = path.join(__dirname, '../tailwind/box-shadow.cjs'); +fs.writeFileSync(tailwindShadowFilePath, tailwindShadowTemplate, 'utf-8'); + +console.log('Tailwind CSS colors configuration generated successfully.'); diff --git a/frontend/appflowy_web_app/scripts/generate_af_icons.cjs b/frontend/appflowy_web_app/scripts/generate_af_icons.cjs new file mode 100644 index 0000000000000..9763beba56eda --- /dev/null +++ b/frontend/appflowy_web_app/scripts/generate_af_icons.cjs @@ -0,0 +1,64 @@ +const fs = require('fs'); +const path = require('path'); + +const getIconsDir = () => path.resolve(__dirname, '../public/af_icons'); + +const readSvgFile = (filePath) => { + return fs.readFileSync(filePath, 'utf8'); +}; + +const renameSvgFile = (filePath, newName) => { + const newPath = path.join(path.dirname(filePath), newName); + fs.renameSync(filePath, newPath); +}; + +const processSvgFiles = (dirPath) => { + const categories = {}; + + const traverseDir = (currentPath) => { + const items = fs.readdirSync(currentPath); + + items.forEach((item) => { + const itemPath = path.join(currentPath, item); + const stat = fs.statSync(itemPath); + + if (stat.isDirectory()) { + traverseDir(itemPath); + } else if (stat.isFile() && path.extname(item) === '.svg') { + const category = path.basename(currentPath); + const [namePart, ...keywordParts] = path.basename(item, '.svg').split('--'); + const name = namePart; + const keywords = keywordParts.length > 0 ? keywordParts[0].split('-') : []; + const svgContent = readSvgFile(itemPath); + renameSvgFile(itemPath, `${name}.svg`); + if (!categories[category]) { + categories[category] = []; + } + + categories[category].push({ + id: `${category}/${name}`, + name, + keywords, + content: svgContent, + }); + } + }); + }; + + traverseDir(dirPath); + return categories; +}; + +const outputJson = (data, outputFilePath) => { + fs.writeFileSync(outputFilePath, JSON.stringify(data, null, 2)); +}; + +const main = () => { + const iconsDirPath = getIconsDir(); + const categories = processSvgFiles(iconsDirPath); + const outputFilePath = path.join(iconsDirPath, 'icons.json'); + outputJson(categories, outputFilePath); + console.log(`JSON data has been written to ${outputFilePath}`); +}; + +main(); diff --git a/frontend/appflowy_web_app/scripts/i18n.cjs b/frontend/appflowy_web_app/scripts/i18n.cjs new file mode 100644 index 0000000000000..407a03694a0a1 --- /dev/null +++ b/frontend/appflowy_web_app/scripts/i18n.cjs @@ -0,0 +1,63 @@ +const languages = [ + 'ar-SA', + 'ca-ES', + 'de-DE', + 'en', + 'es-VE', + 'eu-ES', + 'fr-FR', + 'hu-HU', + 'id-ID', + 'it-IT', + 'ja-JP', + 'ko-KR', + 'pl-PL', + 'pt-BR', + 'pt-PT', + 'ru-RU', + 'sv-SE', + 'th-TH', + 'tr-TR', + 'zh-CN', + 'zh-TW', +]; + +const fs = require('fs'); +languages.forEach(language => { + const json = require(`../../resources/translations/${language}.json`); + const outputJSON = flattenJSON(json); + const output = JSON.stringify(outputJSON); + const isExistDir = fs.existsSync('./src/@types/translations'); + if (!isExistDir) { + fs.mkdirSync('./src/@types/translations'); + } + fs.writeFile(`./src/@types/translations/${language}.json`, new Uint8Array(Buffer.from(output)), (res) => { + if (res) { + console.error(res); + } + }) +}); + + +function flattenJSON(obj, prefix = '') { + let result = {}; + const pluralsKey = ["one", "other", "few", "many", "two", "zero"]; + + for (let key in obj) { + if (typeof obj[key] === 'object' && obj[key] !== null) { + + const nestedKeys = flattenJSON(obj[key], `${prefix}${key}.`); + result = { ...result, ...nestedKeys }; + } else { + let newKey = `${prefix}${key}`; + let replaceChar = '{' + if (pluralsKey.includes(key)) { + newKey = `${prefix.slice(0, -1)}_${key}`; + } + result[newKey] = obj[key].replaceAll('{', '{{').replaceAll('}', '}}'); + } + } + + return result; +} + diff --git a/frontend/appflowy_web_app/scripts/merge-coverage.cjs b/frontend/appflowy_web_app/scripts/merge-coverage.cjs new file mode 100644 index 0000000000000..1939ca4ef9234 --- /dev/null +++ b/frontend/appflowy_web_app/scripts/merge-coverage.cjs @@ -0,0 +1,35 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const jestCoverageFile = path.join(__dirname, '../coverage/jest/coverage-final.json'); +const cypressCoverageFile = path.join(__dirname, '../coverage/cypress/coverage-final.json'); +const nycOutputDir = path.join(__dirname, '../coverage/.nyc_output'); + +// Ensure .nyc_output directory exists +if (fs.existsSync(nycOutputDir)) { + fs.rmSync(nycOutputDir, { recursive: true }); +} +fs.mkdirSync(nycOutputDir, { recursive: true }); + +if (fs.existsSync(path.join(__dirname, '../coverage/merged'))) { + fs.rmSync(path.join(__dirname, '../coverage/merged'), { recursive: true }); +} +// Copy Jest coverage file +fs.copyFileSync(jestCoverageFile, path.join(nycOutputDir, 'jest-coverage.json')); +// Copy Cypress E2E coverage file +fs.copyFileSync(cypressCoverageFile, path.join(nycOutputDir, 'cypress-coverage.json')); + +// Merge coverage files +execSync('nyc merge ./coverage/.nyc_output ./coverage/merged/coverage-final.json', { stdio: 'inherit' }); + +// Move the merged result to the .nyc_output directory +fs.rmSync(nycOutputDir, { recursive: true }); +fs.mkdirSync(nycOutputDir, { recursive: true }); +fs.copyFileSync(path.join(__dirname, '../coverage/merged/coverage-final.json'), path.join(nycOutputDir, 'out.json')); + +// Generate final merged report +execSync('nyc report --reporter=html --reporter=text-summary --report-dir=coverage/merged --temp-dir=coverage/.nyc_output', { stdio: 'inherit' }); +console.log(`Merged coverage report written to coverage/merged`); + + + diff --git a/frontend/appflowy_web_app/src-tauri/.gitignore b/frontend/appflowy_web_app/src-tauri/.gitignore new file mode 100644 index 0000000000000..9e4914893d1a0 --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/.gitignore @@ -0,0 +1,4 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ +.env \ No newline at end of file diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.lock b/frontend/appflowy_web_app/src-tauri/Cargo.lock new file mode 100644 index 0000000000000..ed8976cffe603 --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/Cargo.lock @@ -0,0 +1,9465 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "accessory" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850bb534b9dc04744fbbb71d30ad6d25a7e4cf6dc33e223c81ef3a92ebab4e0b" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "again" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05802a5ad4d172eaf796f7047b42d0af9db513585d16d4169660a21613d34b93" +dependencies = [ + "log", + "rand 0.7.3", + "wasm-timer", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.12", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "getrandom 0.2.12", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allo-isolate" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b6d794345b06592d0ebeed8e477e41b71e5a0a49df4fc0e4184d5938b99509" +dependencies = [ + "atomic", + "pin-project", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" + +[[package]] +name = "app-error" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "bincode", + "getrandom 0.2.12", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "tokio", + "tsify", + "url", + "uuid", + "wasm-bindgen", +] + +[[package]] +name = "appflowy-ai-client" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "bytes", + "futures", + "pin-project", + "serde", + "serde_json", + "serde_repr", + "thiserror", +] + +[[package]] +name = "appflowy-local-ai" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=6f064efe232268f8d396edbb4b84d57fbb640f13#6f064efe232268f8d396edbb4b84d57fbb640f13" +dependencies = [ + "anyhow", + "appflowy-plugin", + "bytes", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "zip 2.2.0", + "zip-extensions", +] + +[[package]] +name = "appflowy-plugin" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=6f064efe232268f8d396edbb4b84d57fbb640f13#6f064efe232268f8d396edbb4b84d57fbb640f13" +dependencies = [ + "anyhow", + "cfg-if", + "crossbeam-utils", + "log", + "once_cell", + "parking_lot 0.12.1", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "xattr", +] + +[[package]] +name = "appflowy_tauri" +version = "0.0.0" +dependencies = [ + "bytes", + "dotenv", + "flowy-ai", + "flowy-config", + "flowy-core", + "flowy-date", + "flowy-document", + "flowy-error", + "flowy-notification", + "flowy-user", + "lib-dispatch", + "semver", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-deep-link", + "tauri-utils", + "tracing", + "uuid", +] + +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arboard" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2041f1943049c7978768d84e6d0fd95de98b76d6c4727b09e78ec253d29fa58" +dependencies = [ + "clipboard-win", + "core-graphics 0.23.1", + "image", + "log", + "objc", + "objc-foundation", + "objc_id", + "parking_lot 0.12.1", + "thiserror", + "windows-sys 0.48.0", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "arrayvec" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" + +[[package]] +name = "async-compression" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103db485efc3e41214fe4fda9f3dbeae2eb9082f48fd236e6095627a9422066e" +dependencies = [ + "bzip2", + "deflate64", + "flate2", + "futures-core", + "futures-io", + "memchr", + "pin-project-lite", + "xz2", + "zstd 0.13.2", + "zstd-safe 7.2.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "async-trait" +version = "0.1.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "async_zip" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52" +dependencies = [ + "async-compression", + "chrono", + "crc32fast", + "futures-lite", + "pin-project", + "thiserror", + "tokio", + "tokio-util", +] + +[[package]] +name = "atk" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3d816ce6f0e2909a96830d6911c2aff044370b1ef92d7f267b43bae5addedd" +dependencies = [ + "atk-sys", + "bitflags 1.3.2", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58aeb089fb698e06db8089971c7ee317ab9644bade33383f63631437b03aafb6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.2.2", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic_refcell" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.69.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" +dependencies = [ + "bitflags 2.5.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.55", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" + +[[package]] +name = "bitpacking" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c1d3e2bfd8d06048a179f7b17afc3188effa10385e7b00dc65af6aae732ea92" +dependencies = [ + "crunchy", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58b559fd6448c6e2fd0adb5720cd98a2506594cafa4737ff98c396f3e82f667" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aadb5b6ccbd078890f6d7003694e33816e6b784358f18e15e7e6d9f065a57cd" +dependencies = [ + "once_cell", + "proc-macro-crate 3.1.0", + "proc-macro2", + "quote", + "syn 2.0.55", + "syn_derive", +] + +[[package]] +name = "brotli" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d68c57235a3a081186990eca2867354726650f42f7516ca50c28d6281fd15" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +dependencies = [ + "serde", +] + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "cairo-rs" +version = "0.15.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c76ee391b03d35510d9fa917357c7f1855bd9a6659c95a1b392e33f49b3369bc" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "glib", + "libc", + "thiserror", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c55d429bef56ac9172d25fecb85dc8068307d17acd74b377866b7a1ef25d3c8" +dependencies = [ + "glib-sys", + "libc", + "system-deps 6.2.2", +] + +[[package]] +name = "cargo_toml" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599aa35200ffff8f04c1925aa1acc92fa2e08874379ef42e210a80e527e60838" +dependencies = [ + "serde", + "toml 0.7.8", +] + +[[package]] +name = "cc" +version = "1.0.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" +dependencies = [ + "jobserver", + "libc", +] + +[[package]] +name = "census" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3431df59f28accaf4cb4eed4a9acc66bea3f3c3753aa6cdc2f024174ef232af7" +dependencies = [ + "smallvec", +] + +[[package]] +name = "cfg-expr" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa50868b64a9a6fda9d593ce778849ea8715cd2a3d2cc17ffdb4a2f2f2f1961d" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets 0.52.4", +] + +[[package]] +name = "chrono-tz" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" +dependencies = [ + "chrono", + "chrono-tz-build 0.2.1", + "phf 0.11.2", +] + +[[package]] +name = "chrono-tz" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6dd8046d00723a59a2f8c5f295c515b9bb9a331ee4f8f3d4dd49e428acd3b6" +dependencies = [ + "chrono", + "chrono-tz-build 0.4.0", + "phf 0.11.2", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +dependencies = [ + "parse-zoneinfo", + "phf 0.11.2", + "phf_codegen 0.11.2", +] + +[[package]] +name = "chrono-tz-build" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94fea34d77a245229e7746bd2beb786cd2a896f306ff491fb8cecb3074b10a7" +dependencies = [ + "parse-zoneinfo", + "phf_codegen 0.11.2", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67523a3b4be3ce1989d607a828d036249522dd9c1c8de7f4dd2dae43a37369d1" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "client-api" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "again", + "anyhow", + "app-error", + "arc-swap", + "async-trait", + "base64 0.22.1", + "bincode", + "brotli", + "bytes", + "chrono", + "client-api-entity", + "client-websocket", + "collab", + "collab-rt-entity", + "collab-rt-protocol", + "futures", + "futures-core", + "futures-util", + "getrandom 0.2.12", + "gotrue", + "infra", + "lazy_static", + "md5", + "mime", + "mime_guess", + "parking_lot 0.12.1", + "percent-encoding", + "pin-project", + "prost 0.13.3", + "rayon", + "reqwest", + "scraper 0.17.1", + "semver", + "serde", + "serde_json", + "serde_repr", + "serde_urlencoded", + "shared-entity", + "thiserror", + "tokio", + "tokio-retry", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "uuid", + "wasm-bindgen-futures", + "yrs", + "zstd 0.13.2", +] + +[[package]] +name = "client-api-entity" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "collab-entity", + "collab-rt-entity", + "database-entity", + "gotrue-entity", + "shared-entity", + "uuid", +] + +[[package]] +name = "client-websocket" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "futures-channel", + "futures-util", + "http", + "httparse", + "js-sys", + "percent-encoding", + "thiserror", + "tokio", + "tokio-tungstenite", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "clipboard-win" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d517d4b86184dbb111d3556a10f1c8a04da7428d2987bf1081602bf11c3aa9ee" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmd_lib" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f4cbdcab51ca635c5b19c85ece4072ea42e0d2360242826a6fc96fb11f0d40" +dependencies = [ + "cmd_lib_macros", + "env_logger", + "faccess", + "lazy_static", + "log", + "os_pipe", +] + +[[package]] +name = "cmd_lib_macros" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae881960f7e2a409f91ef0b1c09558cf293031a1d6e8b45f908311f2a43f5fdf" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "collab" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "arc-swap", + "async-trait", + "bincode", + "bytes", + "chrono", + "js-sys", + "lazy_static", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "unicode-segmentation", + "web-sys", + "yrs", +] + +[[package]] +name = "collab-database" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "chrono-tz 0.10.0", + "collab", + "collab-entity", + "csv", + "dashmap 5.5.3", + "fancy-regex 0.13.0", + "futures", + "getrandom 0.2.12", + "iana-time-zone", + "js-sys", + "lazy_static", + "nanoid", + "percent-encoding", + "rayon", + "rust_decimal", + "rusty-money", + "serde", + "serde_json", + "serde_repr", + "sha2", + "strum", + "strum_macros 0.25.3", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "uuid", + "yrs", +] + +[[package]] +name = "collab-document" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "arc-swap", + "collab", + "collab-entity", + "getrandom 0.2.12", + "markdown", + "nanoid", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "uuid", +] + +[[package]] +name = "collab-entity" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "bytes", + "collab", + "getrandom 0.2.12", + "prost 0.13.3", + "prost-build", + "protoc-bin-vendored", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "uuid", + "walkdir", +] + +[[package]] +name = "collab-folder" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "arc-swap", + "chrono", + "collab", + "collab-entity", + "dashmap 5.5.3", + "getrandom 0.2.12", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "uuid", +] + +[[package]] +name = "collab-importer" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "async-recursion", + "async-trait", + "async_zip", + "base64 0.22.1", + "chrono", + "collab", + "collab-database", + "collab-document", + "collab-entity", + "collab-folder", + "csv", + "fancy-regex 0.13.0", + "futures", + "futures-lite", + "futures-util", + "fxhash", + "hex", + "markdown", + "percent-encoding", + "rayon", + "sanitize-filename", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", + "walkdir", + "zip 0.6.6", +] + +[[package]] +name = "collab-integrate" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "async-trait", + "collab", + "collab-database", + "collab-document", + "collab-entity", + "collab-folder", + "collab-plugins", + "collab-user", + "futures", + "lib-infra", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "collab-plugins" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "bincode", + "bytes", + "chrono", + "collab", + "collab-entity", + "futures", + "futures-util", + "getrandom 0.2.12", + "indexed_db_futures", + "js-sys", + "lazy_static", + "rand 0.8.5", + "rocksdb", + "serde", + "serde_json", + "similar 2.4.0", + "smallvec", + "thiserror", + "tokio", + "tokio-retry", + "tokio-stream", + "tracing", + "tracing-wasm", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yrs", +] + +[[package]] +name = "collab-rt-entity" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "bincode", + "bytes", + "chrono", + "client-websocket", + "collab", + "collab-entity", + "collab-rt-protocol", + "database-entity", + "prost 0.13.3", + "prost-build", + "protoc-bin-vendored", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "tokio-tungstenite", + "yrs", +] + +[[package]] +name = "collab-rt-protocol" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "async-trait", + "bincode", + "collab", + "collab-entity", + "serde", + "thiserror", + "tokio", + "tracing", + "yrs", +] + +[[package]] +name = "collab-user" +version = "0.2.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=dd8a1d13e97322338f027888a71340575cd9ad8f#dd8a1d13e97322338f027888a71340575cd9ad8f" +dependencies = [ + "anyhow", + "collab", + "collab-entity", + "getrandom 0.2.12", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3993e6445baa160675931ec041a5e03ca84b9c6e32a056150d3aa2bdda0a1f45" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "regex", + "terminal_size", + "unicode-width", + "winapi", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "constant_time_eq" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "387461abbc748185c3a6e1673d826918b450b87ff22639429c694619a83b6cf6" +dependencies = [ + "cookie", + "idna 0.3.0", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "970a29baf4110c26fedbc7f82107d42c23f7e88e404c4577ed73fe99ff85a212" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3db02a9c5b5121e1e42fbdb1aeb65f5e02624cc58c43f2884c6ccac0b82f95" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa 0.4.8", + "matches", + "phf 0.8.0", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa 1.0.10", + "phf 0.8.0", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.55", +] + +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa 1.0.10", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad291aa74992b9b7a7e88c38acbbf6ad7e107f1d90ee8775b7bc1fc3394f485c" +dependencies = [ + "quote", + "syn 2.0.55", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "darling" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 2.0.55", +] + +[[package]] +name = "darling_macro" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.3", + "lock_api", + "once_cell", + "parking_lot_core 0.9.9", +] + +[[package]] +name = "dashmap" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804c8821570c3f8b70230c2ba75ffa5c0f9a4189b9a432b6656c536712acae28" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.3", + "lock_api", + "once_cell", + "parking_lot_core 0.9.9", +] + +[[package]] +name = "data-encoding" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" + +[[package]] +name = "database-entity" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "app-error", + "appflowy-ai-client", + "bincode", + "bytes", + "chrono", + "collab-entity", + "infra", + "prost 0.13.3", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "tracing", + "uuid", + "validator 0.19.0", +] + +[[package]] +name = "date_time_parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0521d96e513670773ac503e5f5239178c3aef16cffda1e77a3cdbdbe993fb5a" +dependencies = [ + "chrono", + "regex", +] + +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + +[[package]] +name = "delegate-display" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a85201f233142ac819bbf6226e36d0b5e129a47bd325084674261c82d4cd66" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive-new" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d150dea618e920167e5973d70ae6ece4385b7164e0d799fe7c122dd0a5d912ad" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", +] + +[[package]] +name = "deunicode" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6e854126756c496b8c81dec88f9a706b15b875c5849d4097a3854476b9fdf94" + +[[package]] +name = "diesel" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03fc05c17098f21b89bc7d98fe1dd3cce2c11c2ad8e145f2a44fe08ed28eb559" +dependencies = [ + "chrono", + "diesel_derives", + "libsqlite3-sys", + "r2d2", + "serde_json", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d02eecb814ae714ffe61ddc2db2dd03e6c49a42e269b5001355500d431cce0c" +dependencies = [ + "diesel_table_macro_syntax", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "diesel_migrations" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6036b3f0120c5961381b570ee20a02432d7e2d27ea60de9578799cf9156914ac" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc5557efc453706fed5e4fa85006fe9817c224c3f480a34c7e5959fd700921c5" +dependencies = [ + "syn 2.0.55", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "downcast-rs" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" + +[[package]] +name = "dtoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653" + +[[package]] +name = "dtoa-short" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbaceec3c6e4211c79e7b1800fb9680527106beb2f9c51904a3210c03a448c74" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" + +[[package]] +name = "dyn-clone" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" + +[[package]] +name = "ego-tree" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a68a4904193147e0a8dec3314640e6db742afd5f6e634f428a6af230d9b3591" + +[[package]] +name = "either" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" + +[[package]] +name = "embed-resource" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6985554d0688b687c5cb73898a34fbe3ad6c24c58c238a4d91d5e840670ee9d" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.8.12", + "vswhom", + "winreg 0.52.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[package]] +name = "encoding_rs" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "error-code" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" + +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "faccess" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ae66425802d6a903e268ae1a08b8c38ba143520f227a205edf4e9c7e3e26d5" +dependencies = [ + "bitflags 1.3.2", + "libc", + "winapi", +] + +[[package]] +name = "fancy-regex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0678ab2d46fa5195aaf59ad034c083d351377d4af57f3e073c074d0da3e3c766" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata 0.4.6", + "regex-syntax 0.8.2", +] + +[[package]] +name = "fancy_constructor" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f71f317e4af73b2f8f608fac190c52eac4b1879d2145df1db2fe48881ca69435" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "fastdivide" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59668941c55e5c186b8b58c391629af56774ec768f73c08bbcd56f09348eb00b" + +[[package]] +name = "fastrand" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" +dependencies = [ + "getrandom 0.2.12", +] + +[[package]] +name = "fdeflate" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f9bfee30e4dedf0ab8b422f03af778d9612b63f502710fc500a334ebe2de645" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.4.1", + "windows-sys 0.52.0", +] + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flowy-ai" +version = "0.1.0" +dependencies = [ + "allo-isolate", + "anyhow", + "appflowy-local-ai", + "appflowy-plugin", + "arc-swap", + "base64 0.21.7", + "bytes", + "dashmap 6.0.1", + "flowy-ai-pub", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-notification", + "flowy-sqlite", + "flowy-storage-pub", + "futures", + "futures-util", + "lib-dispatch", + "lib-infra", + "log", + "md5", + "notify", + "pin-project", + "protobuf", + "reqwest", + "serde", + "serde_json", + "sha2", + "strum_macros 0.21.1", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "uuid", + "validator 0.18.1", + "zip 2.2.0", + "zip-extensions", +] + +[[package]] +name = "flowy-ai-pub" +version = "0.1.0" +dependencies = [ + "bytes", + "client-api", + "flowy-error", + "futures", + "lib-infra", + "serde_json", +] + +[[package]] +name = "flowy-ast" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "flowy-codegen" +version = "0.1.0" +dependencies = [ + "cmd_lib", + "console", + "fancy-regex 0.10.0", + "flowy-ast", + "itertools 0.10.5", + "lazy_static", + "log", + "phf 0.8.0", + "protoc-bin-vendored", + "protoc-rust", + "quote", + "serde", + "serde_json", + "similar 1.3.0", + "syn 1.0.109", + "tera", + "toml 0.5.11", + "walkdir", +] + +[[package]] +name = "flowy-config" +version = "0.1.0" +dependencies = [ + "bytes", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-sqlite", + "lib-dispatch", + "protobuf", + "strum_macros 0.21.1", +] + +[[package]] +name = "flowy-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "appflowy-local-ai", + "arc-swap", + "base64 0.21.7", + "bytes", + "client-api", + "collab", + "collab-entity", + "collab-folder", + "collab-integrate", + "collab-plugins", + "dashmap 6.0.1", + "diesel", + "flowy-ai", + "flowy-ai-pub", + "flowy-config", + "flowy-database-pub", + "flowy-database2", + "flowy-date", + "flowy-document", + "flowy-document-pub", + "flowy-error", + "flowy-folder", + "flowy-folder-pub", + "flowy-search", + "flowy-search-pub", + "flowy-server", + "flowy-server-pub", + "flowy-sqlite", + "flowy-storage", + "flowy-storage-pub", + "flowy-user", + "flowy-user-pub", + "futures", + "futures-core", + "lib-dispatch", + "lib-infra", + "lib-log", + "semver", + "serde", + "serde_json", + "serde_repr", + "sysinfo", + "tokio", + "tokio-stream", + "tracing", + "uuid", + "walkdir", +] + +[[package]] +name = "flowy-database-pub" +version = "0.1.0" +dependencies = [ + "anyhow", + "client-api", + "collab", + "collab-entity", + "flowy-error", + "lib-infra", +] + +[[package]] +name = "flowy-database2" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "async-stream", + "async-trait", + "bytes", + "chrono", + "chrono-tz 0.8.6", + "collab", + "collab-database", + "collab-entity", + "collab-integrate", + "collab-plugins", + "csv", + "dashmap 6.0.1", + "fancy-regex 0.11.0", + "flowy-codegen", + "flowy-database-pub", + "flowy-derive", + "flowy-error", + "flowy-notification", + "futures", + "indexmap 2.2.6", + "lazy_static", + "lib-dispatch", + "lib-infra", + "moka", + "nanoid", + "protobuf", + "rayon", + "rust_decimal", + "rusty-money", + "serde", + "serde_json", + "serde_repr", + "strum", + "strum_macros 0.25.3", + "tokio", + "tokio-util", + "tracing", + "url", + "validator 0.18.1", +] + +[[package]] +name = "flowy-date" +version = "0.1.0" +dependencies = [ + "bytes", + "chrono", + "date_time_parser", + "fancy-regex 0.11.0", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "lib-dispatch", + "protobuf", + "strum_macros 0.21.1", + "tracing", +] + +[[package]] +name = "flowy-derive" +version = "0.1.0" +dependencies = [ + "dashmap 6.0.1", + "flowy-ast", + "flowy-codegen", + "lazy_static", + "proc-macro2", + "quote", + "serde_json", + "syn 1.0.109", + "walkdir", +] + +[[package]] +name = "flowy-document" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "collab", + "collab-document", + "collab-entity", + "collab-integrate", + "collab-plugins", + "dashmap 6.0.1", + "flowy-codegen", + "flowy-derive", + "flowy-document-pub", + "flowy-error", + "flowy-notification", + "flowy-storage-pub", + "futures", + "getrandom 0.2.12", + "indexmap 2.2.6", + "lib-dispatch", + "lib-infra", + "nanoid", + "protobuf", + "scraper 0.18.1", + "serde", + "serde_json", + "strum_macros 0.21.1", + "tokio", + "tokio-stream", + "tracing", + "uuid", + "validator 0.18.1", +] + +[[package]] +name = "flowy-document-pub" +version = "0.1.0" +dependencies = [ + "anyhow", + "collab", + "collab-document", + "flowy-error", + "lib-infra", +] + +[[package]] +name = "flowy-encrypt" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "base64 0.21.7", + "getrandom 0.2.12", + "hmac", + "pbkdf2 0.12.2", + "rand 0.8.5", + "sha2", +] + +[[package]] +name = "flowy-error" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "client-api", + "collab", + "collab-database", + "collab-document", + "collab-folder", + "collab-plugins", + "fancy-regex 0.11.0", + "flowy-codegen", + "flowy-derive", + "flowy-sqlite", + "lib-dispatch", + "protobuf", + "r2d2", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "tantivy", + "thiserror", + "tokio", + "url", + "validator 0.18.1", +] + +[[package]] +name = "flowy-folder" +version = "0.1.0" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "chrono", + "client-api", + "collab", + "collab-document", + "collab-entity", + "collab-folder", + "collab-integrate", + "collab-plugins", + "dashmap 6.0.1", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-folder-pub", + "flowy-notification", + "flowy-search-pub", + "flowy-sqlite", + "futures", + "lazy_static", + "lib-dispatch", + "lib-infra", + "nanoid", + "protobuf", + "regex", + "serde", + "serde_json", + "strum_macros 0.21.1", + "tokio", + "tokio-stream", + "tracing", + "unicode-segmentation", + "uuid", + "validator 0.18.1", +] + +[[package]] +name = "flowy-folder-pub" +version = "0.1.0" +dependencies = [ + "anyhow", + "client-api", + "collab", + "collab-entity", + "collab-folder", + "flowy-error", + "lib-infra", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "flowy-notification" +version = "0.1.0" +dependencies = [ + "bytes", + "dashmap 6.0.1", + "flowy-codegen", + "flowy-derive", + "lazy_static", + "lib-dispatch", + "protobuf", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "flowy-search" +version = "0.1.0" +dependencies = [ + "async-stream", + "bytes", + "collab", + "collab-folder", + "diesel", + "diesel_derives", + "diesel_migrations", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-folder", + "flowy-notification", + "flowy-search-pub", + "flowy-sqlite", + "flowy-user", + "futures", + "lib-dispatch", + "lib-infra", + "protobuf", + "serde", + "serde_json", + "strsim 0.11.1", + "strum_macros 0.26.2", + "tantivy", + "tempfile", + "tokio", + "tracing", + "validator 0.18.1", +] + +[[package]] +name = "flowy-search-pub" +version = "0.1.0" +dependencies = [ + "client-api", + "collab", + "collab-folder", + "flowy-error", + "futures", + "lib-infra", +] + +[[package]] +name = "flowy-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "bytes", + "chrono", + "client-api", + "collab", + "collab-database", + "collab-document", + "collab-entity", + "collab-folder", + "collab-plugins", + "collab-user", + "dashmap 6.0.1", + "flowy-ai-pub", + "flowy-database-pub", + "flowy-document-pub", + "flowy-encrypt", + "flowy-error", + "flowy-folder-pub", + "flowy-search-pub", + "flowy-server-pub", + "flowy-storage", + "flowy-storage-pub", + "flowy-user-pub", + "futures", + "futures-util", + "hex", + "hyper", + "lazy_static", + "lib-dispatch", + "lib-infra", + "mime_guess", + "postgrest", + "rand 0.8.5", + "reqwest", + "semver", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-retry", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "uuid", + "yrs", +] + +[[package]] +name = "flowy-server-pub" +version = "0.1.0" +dependencies = [ + "flowy-error", + "serde", + "serde_repr", +] + +[[package]] +name = "flowy-sqlite" +version = "0.1.0" +dependencies = [ + "anyhow", + "diesel", + "diesel_derives", + "diesel_migrations", + "libsqlite3-sys", + "r2d2", + "scheduled-thread-pool", + "serde", + "serde_json", + "thiserror", + "tracing", +] + +[[package]] +name = "flowy-storage" +version = "0.1.0" +dependencies = [ + "allo-isolate", + "anyhow", + "async-trait", + "bytes", + "chrono", + "collab-importer", + "dashmap 6.0.1", + "flowy-codegen", + "flowy-derive", + "flowy-error", + "flowy-notification", + "flowy-sqlite", + "flowy-storage-pub", + "futures-util", + "fxhash", + "lib-dispatch", + "lib-infra", + "mime_guess", + "protobuf", + "serde", + "serde_json", + "strum_macros 0.25.3", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "flowy-storage-pub" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "client-api-entity", + "flowy-error", + "lib-infra", + "mime", + "mime_guess", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "flowy-user" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "base64 0.21.7", + "bytes", + "chrono", + "client-api", + "collab", + "collab-database", + "collab-document", + "collab-entity", + "collab-folder", + "collab-integrate", + "collab-plugins", + "collab-user", + "dashmap 6.0.1", + "diesel", + "diesel_derives", + "fancy-regex 0.11.0", + "flowy-codegen", + "flowy-derive", + "flowy-encrypt", + "flowy-error", + "flowy-folder-pub", + "flowy-notification", + "flowy-server-pub", + "flowy-sqlite", + "flowy-user-pub", + "lazy_static", + "lib-dispatch", + "lib-infra", + "once_cell", + "protobuf", + "rayon", + "semver", + "serde", + "serde_json", + "serde_repr", + "strum", + "strum_macros 0.25.3", + "tokio", + "tokio-stream", + "tracing", + "unicode-segmentation", + "uuid", + "validator 0.18.1", +] + +[[package]] +name = "flowy-user-pub" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.21.7", + "chrono", + "client-api", + "collab", + "collab-entity", + "collab-folder", + "flowy-error", + "flowy-folder-pub", + "lib-infra", + "serde", + "serde_json", + "serde_repr", + "tokio", + "tokio-stream", + "tracing", + "uuid", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs4" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" +dependencies = [ + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-lite" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e05c1f572ab0e1f15be94217f0dc29088c248b14f792a5ff0af0d84bcda9e8" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38dd9cc8b099cceecdf41375bb6d481b1b5a7cd5cd603e10a69a9383f8619a" +dependencies = [ + "bitflags 1.3.2", + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140b2f5378256527150350a8346dbdb08fadc13453a7a2d73aecd5fab3c402a7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.2.2", +] + +[[package]] +name = "gdk-sys" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e7a08c1e8f06f4177fb7e51a777b8c1689f743a7bc11ea91d44d2226073a88" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps 6.2.2", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca49a59ad8cfdf36ef7330fe7bdfbe1d34323220cc16a0de2679ee773aee2c2" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps 6.2.2", +] + +[[package]] +name = "gdkx11-sys" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b7f8c7a84b407aa9b143877e267e848ff34106578b64d1e0a24bf550716178" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps 6.2.2", + "x11", +] + +[[package]] +name = "generator" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" +dependencies = [ + "cc", + "libc", + "log", + "rustversion", + "windows 0.48.0", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "gethostname" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +dependencies = [ + "libc", + "windows-targets 0.48.5", +] + +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "gio" +version = "0.15.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68fdbc90312d462781a395f7a16d96a2b379bb6ef8cd6310a2df272771c4283b" +dependencies = [ + "bitflags 1.3.2", + "futures-channel", + "futures-core", + "futures-io", + "gio-sys", + "glib", + "libc", + "once_cell", + "thiserror", +] + +[[package]] +name = "gio-sys" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32157a475271e2c4a023382e9cab31c4584ee30a97da41d3c4e9fdd605abcf8d" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.2.2", + "winapi", +] + +[[package]] +name = "glib" +version = "0.15.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb0306fbad0ab5428b0ca674a23893db909a98582969c9b537be4ced78c505d" +dependencies = [ + "bitflags 1.3.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "once_cell", + "smallvec", + "thiserror", +] + +[[package]] +name = "glib-macros" +version = "0.15.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10c6ae9f6fa26f4fb2ac16b528d138d971ead56141de489f8111e259b9df3c4a" +dependencies = [ + "anyhow", + "heck 0.4.1", + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "glib-sys" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4b192f8e65e9cf76cbf4ea71fa8e3be4a0e18ffe3d68b8da6836974cc5bad4" +dependencies = [ + "libc", + "system-deps 6.2.2", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "globset" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.6", + "regex-syntax 0.8.2", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "gloo-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gobject-sys" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d57ce44246becd17153bd035ab4d32cfee096a657fc01f2231c9278378d1e0a" +dependencies = [ + "glib-sys", + "libc", + "system-deps 6.2.2", +] + +[[package]] +name = "gotrue" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "futures-util", + "getrandom 0.2.12", + "gotrue-entity", + "infra", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "gotrue-entity" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "app-error", + "chrono", + "jsonwebtoken", + "lazy_static", + "serde", + "serde_json", +] + +[[package]] +name = "gtk" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e3004a2d5d6d8b5057d2b57b3712c9529b62e82c77f25c1fecde1fd5c23bd0" +dependencies = [ + "atk", + "bitflags 1.3.2", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "once_cell", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5bc2f0587cba247f60246a0ca11fe25fb733eabc3de12d1965fc07efab87c84" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps 6.2.2", +] + +[[package]] +name = "gtk3-macros" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "684c0456c086e8e7e9af73ec5b84e35938df394712054550e81558d21c44ab0d" +dependencies = [ + "anyhow", + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "h2" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fbd2820c5e49886948654ab546d0688ff24530286bdcf8fca3cefb16d4618eb" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap 2.2.6", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash 0.8.11", + "allocator-api2", +] + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "html5ever" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "htmlescape" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa 1.0.10", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "0.14.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa 1.0.10", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3804960be0bb5e4edb1e1ad67afd321a9ecfd875c3e65c099468fd2717d7cae" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.6", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "num-traits", + "png", + "tiff", +] + +[[package]] +name = "indexed_db_futures" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0704b71f13f81b5933d791abf2de26b33c40935143985220299a357721166706" +dependencies = [ + "accessory", + "cfg-if", + "delegate-display", + "fancy_constructor", + "js-sys", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +dependencies = [ + "equivalent", + "hashbrown 0.14.3", + "serde", +] + +[[package]] +name = "infer" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f551f8c3a39f68f986517db0d1759de85881894fdc7db798bd2a9df9cb04b7fc" +dependencies = [ + "cfb", +] + +[[package]] +name = "infra" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "bytes", + "futures", + "pin-project", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "validator 0.19.0", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "interprocess" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f2533f3be42fffe3b5e63b71aeca416c1c3bc33e4e27be018521e76b1f38fb" +dependencies = [ + "cfg-if", + "libc", + "rustc_version", + "to_method", + "winapi", +] + +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + +[[package]] +name = "is-terminal" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + +[[package]] +name = "javascriptcore-rs" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf053e7843f2812ff03ef5afe34bb9c06ffee120385caad4f6b9967fcd37d41c" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "905fbb87419c5cde6e3269537e4ea7d46431f3008c5d057e915ef3f115e7793c" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 5.0.0", +] + +[[package]] +name = "jni" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "039022cdf4d7b1cf548d31f60ae783138e5fd42013f6271049d7df7afadef96c" +dependencies = [ + "cesu8", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab46a6e9526ddef3ae7f787c06f0f2600639ba80ea3eade3d8e670a2230f51d6" +dependencies = [ + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" + +[[package]] +name = "js-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ff1e1486799e3f64129f8ccad108b38290df9cd7015cd31bed17239f0789d6" +dependencies = [ + "serde", + "serde_json", + "thiserror", + "treediff", +] + +[[package]] +name = "jsonwebtoken" +version = "8.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" +dependencies = [ + "base64 0.21.7", + "pem", + "ring 0.16.20", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "kqueue" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "kuchikiki" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8" +dependencies = [ + "cssparser 0.27.2", + "html5ever", + "indexmap 1.9.3", + "matches", + "selectors 0.22.0", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "levenshtein_automata" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" + +[[package]] +name = "lib-dispatch" +version = "0.1.0" +dependencies = [ + "bincode", + "bytes", + "derivative", + "dyn-clone", + "futures", + "futures-channel", + "futures-core", + "futures-util", + "getrandom 0.2.12", + "nanoid", + "pin-project", + "protobuf", + "serde", + "serde_json", + "serde_repr", + "thread-id", + "tokio", + "tracing", + "validator 0.18.1", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "lib-infra" +version = "0.1.0" +dependencies = [ + "allo-isolate", + "anyhow", + "async-trait", + "atomic_refcell", + "bytes", + "cfg-if", + "chrono", + "futures", + "futures-core", + "futures-util", + "md5", + "pin-project", + "tempfile", + "tokio", + "tracing", + "validator 0.18.1", + "walkdir", + "zip 2.2.0", +] + +[[package]] +name = "lib-log" +version = "0.1.0" +dependencies = [ + "chrono", + "lazy_static", + "lib-infra", + "serde", + "serde_json", + "tracing", + "tracing-appender", + "tracing-bunyan-formatter", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "libloading" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" +dependencies = [ + "cfg-if", + "windows-targets 0.52.4", +] + +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + +[[package]] +name = "libredox" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" +dependencies = [ + "bitflags 2.5.0", + "libc", + "redox_syscall 0.4.1", +] + +[[package]] +name = "librocksdb-sys" +version = "0.16.0+8.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3d60bc059831dc1c83903fb45c103f75db65c5a7bf22272764d9cc683e348c" +dependencies = [ + "bindgen", + "bzip2-sys", + "cc", + "glob", + "libc", + "libz-sys", + "zstd-sys", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e143b5e666b2695d28f6bca6497720813f699c9602dd7f5cac91008b8ada7f9" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "line-wrap" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd1bc4d24ad230d21fb898d1116b1801d7adfc449d42026475862ab48b11e70e" + +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + +[[package]] +name = "loom" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" +dependencies = [ + "hashbrown 0.14.3", +] + +[[package]] +name = "lz4_flex" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75761162ae2b0e580d7e7c390558127e5f01b4194debd6221fd8c207fc80e3f5" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "macroific" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05c00ac596022625d01047c421a0d97d7f09a18e429187b341c201cb631b9dd" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "macroific_macro", +] + +[[package]] +name = "macroific_attr_parse" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd94d5da95b30ae6e10621ad02340909346ad91661f3f8c0f2b62345e46a2f67" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "macroific_core" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13198c120864097a565ccb3ff947672d969932b7975ebd4085732c9f09435e55" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "macroific_macro" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c9853143cbed7f1e41dc39fee95f9b361bec65c8dc2a01bf609be01b61f5ae" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markdown" +version = "1.0.0-alpha.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6491e6c702bf7e3b24e769d800746d5f2c06a6c6a2db7992612e0f429029e81" +dependencies = [ + "unicode-id", +] + +[[package]] +name = "markup5ever" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +dependencies = [ + "log", + "phf 0.10.1", + "phf_codegen 0.10.0", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "measure_time" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56220900f1a0923789ecd6bf25fbae8af3b2f1ff3e9e297fc9b6b8674dd4d852" +dependencies = [ + "instant", + "log", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memmap2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe751422e4a8caa417e13c3ea66452215d7d63e19e604f4980461212f3ae1322" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "migrations_internals" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f23f71580015254b020e856feac3df5878c2c7a8812297edd6c0a485ac9dada" +dependencies = [ + "serde", + "toml 0.7.8", +] + +[[package]] +name = "migrations_macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cce3325ac70e67bbab5bd837a31cae01f1a6db64e0e744a33cb03a543469ef08" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "moka" +version = "0.12.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cf62eb4dd975d2dde76432fb1075c49e3ee2331cf36f1f8fd4b66550d32b6f" +dependencies = [ + "async-lock", + "async-trait", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "event-listener", + "futures-util", + "once_cell", + "parking_lot 0.12.1", + "quanta", + "rustc_version", + "smallvec", + "tagptr", + "thiserror", + "triomphe", + "uuid", +] + +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + +[[package]] +name = "murmurhash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" + +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand 0.8.5", +] + +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2032c77e030ddee34a6787a64166008da93f6a352b629261d0fee232b8742dd4" +dependencies = [ + "bitflags 1.3.2", + "jni-sys", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e5a6ae77c8ee183dcbbba6150e2e6b9f3f4196a7666c02a715a95692ec1fa97" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.5.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.5.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c71324e4180d0899963fc83d9d241ac39e699609fc1025a850aadac8257459" + +[[package]] +name = "objc2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "oneshot" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6640c6bda7731b1fdbab747981a0f896dd1fedaf9f4a53fa237a04a84431f4" +dependencies = [ + "loom", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2078c0039e6a54a0c42c28faa984e115fb4c2d5bf2208f77d1961002df8576f8" +dependencies = [ + "pathdiff", + "windows-sys 0.42.0", +] + +[[package]] +name = "openssl" +version = "0.10.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +dependencies = [ + "bitflags 2.5.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-src" +version = "300.2.3+3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cff92b6f71555b61bb9315f7c64da3ca43d87531622120fea0195fc761b4843" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dda2b0f344e78efc2facf7d195d098df0dd72151b26ab98da807afc26c198dff" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "os_pipe" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57119c3b893986491ec9aa85056780d3a0f3cf4da7cc09dd3650dbd6c6738fb9" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "ownedbytes" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a059efb063b8f425b948e042e6b9bd85edfe60e913630ed727b23e2dfcc558" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "pango" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e4045548659aee5313bde6c582b0d83a627b7904dd20dc2d9ef0895d414e4f" +dependencies = [ + "bitflags 1.3.2", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2a00081cde4661982ed91d80ef437c20eacaf6aa1a5962c0279ae194662c3aa" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.2.2", +] + +[[package]] +name = "parking" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.9", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.4.1", + "smallvec", + "windows-targets 0.48.5", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" +dependencies = [ + "regex", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" +dependencies = [ + "base64 0.13.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pest" +version = "2.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f8023d0fb78c8e03784ea1c7f3fa36e68a723138990b8d5a47d916b651e7a8" +dependencies = [ + "memchr", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0d24f72393fd16ab6ac5738bc33cdb6a9aa73f8b902e8fe29cf4e67d7dd1026" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc17e2a6c7d0a492f0158d7a4bd66cc17280308bbaff78d5bef566dca35ab80" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "pest_meta" +version = "2.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "934cd7631c050f4674352a6e835d5f6711ffbfb9345c2fc0107155ac495ae293" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9" +dependencies = [ + "fixedbitset", + "indexmap 2.2.6", +] + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_macros 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros 0.11.2", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared 0.11.2", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be57f64e946e500c8ee36ef6331845d40a93055567ec57e8fae13efd33759b95" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "plist" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9d34169e64b3c7a80c8621a48adaf44e0cf62c78a9b25dd9dd35f1881a17cf9" +dependencies = [ + "base64 0.21.7", + "indexmap 2.2.6", + "line-wrap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06e4b0d3d1312775e782c86c91a111aa1f910cbb65e1337f9975b5f9a554b5e1" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "postgrest" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a966c650b47a064e7082170b4be74fca08c088d893244fc4b70123e3c1f3ee7" +dependencies = [ + "reqwest", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3928fb5db768cb86f891ff014f0144589297e3c6a1aba6ed7cecfdace270c7" +dependencies = [ + "proc-macro2", + "syn 2.0.55", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284" +dependencies = [ + "toml_edit 0.21.1", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c289cda302b98a28d40c8b3b90498d6e526dd24ac2ecea73e4e491685b94a" +dependencies = [ + "bytes", + "prost-derive 0.12.3", +] + +[[package]] +name = "prost" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b0487d90e047de87f984913713b85c601c05609aad5b0df4b4573fbf69aa13f" +dependencies = [ + "bytes", + "prost-derive 0.13.3", +] + +[[package]] +name = "prost-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c55e02e35260070b6f716a2423c2ff1c3bb1642ddca6f99e1f26d06268a0e2d2" +dependencies = [ + "bytes", + "heck 0.4.1", + "itertools 0.10.5", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost 0.12.3", + "prost-types", + "regex", + "syn 2.0.55", + "tempfile", + "which", +] + +[[package]] +name = "prost-derive" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "prost-derive" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9552f850d5f0964a4e4d0bf306459ac29323ddfbae05e35a7c0d35cb0803cc5" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "prost-types" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "193898f59edcf43c26227dcd4c8427f00d99d61e95dcde58dabd49fa291d470e" +dependencies = [ + "prost 0.12.3", +] + +[[package]] +name = "protobuf" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" + +[[package]] +name = "protobuf-codegen" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "033460afb75cf755fcfc16dfaed20b86468082a2ea24e05ac35ab4a099a017d6" +dependencies = [ + "protobuf", +] + +[[package]] +name = "protoc" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0218039c514f9e14a5060742ecd50427f8ac4f85a6dc58f2ddb806e318c55ee" +dependencies = [ + "log", + "which", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ca8623e5633e298ad1f917d8be0a44bcf406bf3cde3b80e63003e49a3f27d" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb9fc9cce84c8694b6ea01cc6296617b288b703719b725b8c9c65f7c5874435" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d2a07dcf7173a04d49974930ccbfb7fd4d74df30ecfc8762cf2f895a094516" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54fef0b04fcacba64d1d80eed74a20356d96847da8497a59b0a0a436c9165b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8782f2ce7d43a9a5c74ea4936f001e9e8442205c244f7a3d4286bd4c37bc924" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5de656c7ee83f08e0ae5b81792ccfdc1d04e7876b1d9a38e6876a9e09e02537" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9653c3ed92974e34c5a6e0a510864dab979760481714c172e0a34e437cb98804" + +[[package]] +name = "protoc-rust" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f8a182bb17c485f20bdc4274a8c39000a61024cfe461c799b50fec77267838" +dependencies = [ + "protobuf", + "protobuf-codegen", + "protoc", + "tempfile", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "publicsuffix" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a8c1bda5ae1af7f99a2962e49df150414a43d62404644d98dd5c3a93d07457" +dependencies = [ + "idna 0.3.0", + "psl-types", +] + +[[package]] +name = "quanta" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5167a477619228a0b284fac2674e3c388cba90631d7b7de620e6f1fcd08da5" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi 0.11.0+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot 0.12.1", + "scheduled-thread-pool", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.12", +] + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-cpuid" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9ee317cfe3fbd54b36a511efc1edd42e216903c9cd575e686dd68a2ba90d8d" +dependencies = [ + "bitflags 2.5.0", +] + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_users" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" +dependencies = [ + "getrandom 0.2.12", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.6", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", + "winreg 0.50.0", +] + +[[package]] +name = "rfd" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0149778bd99b6959285b0933288206090c50e2327f47a9c463bfdbf45c8823ea" +dependencies = [ + "block", + "dispatch", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "lazy_static", + "log", + "objc", + "objc-foundation", + "objc_id", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.37.0", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.12", + "libc", + "spin 0.9.8", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cba464629b3394fc4dbc6f940ff8f5b4ff5c7aef40f29166fd4ad12acbc99c0" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7dddfff8de25e6f62b9d64e6e432bf1c6736c57d20323e15ee10435fbda7c65" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rocksdb" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd13e55d6d7b8cd0ea569161127567cd587676c99f4472f779a0279aa60a7a7" +dependencies = [ + "libc", + "librocksdb-sys", +] + +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rust_decimal" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b082d80e3e3cc52b2ed634388d436fe1f4de6af5786cc2de9ba9737527bdf555" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rust_decimal_macros" +version = "1.34.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e418701588729bef95e7a655f2b483ad64bb97c46e8e79fde83efd92aaab6d82" +dependencies = [ + "quote", + "rust_decimal", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" +dependencies = [ + "bitflags 2.5.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" +dependencies = [ + "log", + "ring 0.17.8", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring 0.17.8", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + +[[package]] +name = "rusty-money" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b28f881005eac7ad8d46b6f075da5f322bd7f4f83a38720fc069694ddadd683" +dependencies = [ + "rust_decimal", + "rust_decimal_macros", +] + +[[package]] +name = "ryu" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sanitize-filename" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ed72fbaf78e6f2d41744923916966c4fbe3d7c74e3037a8ee482f1115572603" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot 0.12.1", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c95a930e03325234c18c7071fd2b60118307e025d6fff3e12745ffbf63a3d29c" +dependencies = [ + "ahash 0.8.11", + "cssparser 0.31.2", + "ego-tree", + "getopts", + "html5ever", + "once_cell", + "selectors 0.25.0", + "smallvec", + "tendril", +] + +[[package]] +name = "scraper" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585480e3719b311b78a573db1c9d9c4c1f8010c2dee4cc59c2efe58ea4dbc3e1" +dependencies = [ + "ahash 0.8.11", + "cssparser 0.31.2", + "ego-tree", + "getopts", + "html5ever", + "once_cell", + "selectors 0.25.0", + "tendril", +] + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.8", + "untrusted 0.9.0", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" +dependencies = [ + "bitflags 1.3.2", + "cssparser 0.27.2", + "derive_more", + "fxhash", + "log", + "matches", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc 0.1.1", + "smallvec", + "thin-slice", +] + +[[package]] +name = "selectors" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" +dependencies = [ + "bitflags 2.5.0", + "cssparser 0.31.2", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.10.1", + "phf_codegen 0.10.0", + "precomputed-hash", + "servo_arc 0.3.0", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "serde_derive_internals" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e578a843d40b4189a4d66bba51d7684f57da5bd7c304c64e14bd63efbef49509" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "serde_json" +version = "1.0.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +dependencies = [ + "indexmap 2.2.6", + "itoa 1.0.10", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2e6b945e9d3df726b65d6ee24060aff8e3533d431f677a9695db04eff9dfdb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "serde_spanned" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa 1.0.10", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee80b0e361bbf88fd2f6e242ccd19cfda072cb0faa6ae694ecee08199938569a" +dependencies = [ + "base64 0.21.7", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.2.6", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6561dc161a9224638a31d876ccdfefbc1df91d3f3a8342eddb35f055d48c7655" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9823f2d3b6a81d98228151fdeaf848206a7855a7a042bbf9bf870449a66cafb" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74064874e9f6a15f04c1f3cb627902d0e6b410abbf36668afa873c61889f1763" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "servo_arc" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "servo_arc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared-entity" +version = "0.1.0" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-Cloud?rev=ea131f0baab67defe7591067357eced490072372#ea131f0baab67defe7591067357eced490072372" +dependencies = [ + "anyhow", + "app-error", + "appflowy-ai-client", + "bytes", + "chrono", + "collab-entity", + "database-entity", + "futures", + "gotrue-entity", + "infra", + "log", + "pin-project", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "tracing", + "uuid", + "validator 0.19.0", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simdutf8" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" + +[[package]] +name = "similar" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad1d488a557b235fc46dae55512ffbfc429d2482b08b4d9435ab07384ca8aec" + +[[package]] +name = "similar" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32fea41aca09ee824cc9724996433064c89f7777e60762749a4170a14abbfa21" + +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "sketches-ddsketch" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" +dependencies = [ + "serde", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "slug" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd94acec9c8da640005f8e135a39fc0372e74535e6b368b7a04b875f784c8c4" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallstr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b1aefdf380735ff8ded0b15f31aab05daf1f70216c01c02a12926badd1df9d" +dependencies = [ + "smallvec", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "soup2" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b4d76501d8ba387cf0fefbe055c3e0a59891d09f0f995ae4e4b16f6b60f3c0" +dependencies = [ + "bitflags 1.3.2", + "gio", + "glib", + "libc", + "once_cell", + "soup2-sys", +] + +[[package]] +name = "soup2-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009ef427103fcb17f802871647a7fa6c60cbb654b4c4e4c0ac60a31c5f6dc9cf" +dependencies = [ + "bitflags 1.3.2", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps 5.0.0", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "state" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe866e1e51e8260c9eed836a042a5e7f6726bb2b411dffeaa712e19c388f23b" +dependencies = [ + "loom", +] + +[[package]] +name = "string_cache" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot 0.12.1", + "phf_shared 0.10.0", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" + +[[package]] +name = "strum_macros" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d06aaeeee809dbc59eb4556183dd927df67db1540de5be8d3ec0b6636358a5ec" +dependencies = [ + "heck 0.3.3", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.55", +] + +[[package]] +name = "strum_macros" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.55", +] + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "002a1b3dbf967edfafc32655d0f377ab0bb7b994aa1d32c8cc7e9b8bf3ebb8f0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn_derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1329189c02ff984e9736652b1631330da25eaa6bc639089ed4915d25446cbe7b" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "sysinfo" +version = "0.30.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c385888ef380a852a16209afc8cfad22795dd8873d69c9a14d2e2088f118d18" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "rayon", + "windows 0.52.0", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18db855554db7bd0e73e06cf7ba3df39f97812cb11d3f75e71c39bf45171797e" +dependencies = [ + "cfg-expr 0.9.1", + "heck 0.3.3", + "pkg-config", + "toml 0.5.11", + "version-compare 0.0.11", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr 0.15.7", + "heck 0.5.0", + "pkg-config", + "toml 0.8.12", + "version-compare 0.2.0", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tantivy" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d0582f186c0a6d55655d24543f15e43607299425c5ad8352c242b914b31856" +dependencies = [ + "aho-corasick", + "arc-swap", + "base64 0.22.1", + "bitpacking", + "byteorder", + "census", + "crc32fast", + "crossbeam-channel", + "downcast-rs", + "fastdivide", + "fnv", + "fs4", + "htmlescape", + "itertools 0.12.1", + "levenshtein_automata", + "log", + "lru", + "lz4_flex", + "measure_time", + "memmap2", + "num_cpus", + "once_cell", + "oneshot", + "rayon", + "regex", + "rust-stemmers", + "rustc-hash", + "serde", + "serde_json", + "sketches-ddsketch", + "smallvec", + "tantivy-bitpacker", + "tantivy-columnar", + "tantivy-common", + "tantivy-fst", + "tantivy-query-grammar", + "tantivy-stacker", + "tantivy-tokenizer-api", + "tempfile", + "thiserror", + "time", + "uuid", + "winapi", +] + +[[package]] +name = "tantivy-bitpacker" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284899c2325d6832203ac6ff5891b297fc5239c3dc754c5bc1977855b23c10df" +dependencies = [ + "bitpacking", +] + +[[package]] +name = "tantivy-columnar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12722224ffbe346c7fec3275c699e508fd0d4710e629e933d5736ec524a1f44e" +dependencies = [ + "downcast-rs", + "fastdivide", + "itertools 0.12.1", + "serde", + "tantivy-bitpacker", + "tantivy-common", + "tantivy-sstable", + "tantivy-stacker", +] + +[[package]] +name = "tantivy-common" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8019e3cabcfd20a1380b491e13ff42f57bb38bf97c3d5fa5c07e50816e0621f4" +dependencies = [ + "async-trait", + "byteorder", + "ownedbytes", + "serde", + "time", +] + +[[package]] +name = "tantivy-fst" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" +dependencies = [ + "byteorder", + "regex-syntax 0.8.2", + "utf8-ranges", +] + +[[package]] +name = "tantivy-query-grammar" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82" +dependencies = [ + "nom", +] + +[[package]] +name = "tantivy-sstable" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c69578242e8e9fc989119f522ba5b49a38ac20f576fc778035b96cc94f41f98e" +dependencies = [ + "tantivy-bitpacker", + "tantivy-common", + "tantivy-fst", + "zstd 0.13.2", +] + +[[package]] +name = "tantivy-stacker" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56d6ff5591fc332739b3ce7035b57995a3ce29a93ffd6012660e0949c956ea8" +dependencies = [ + "murmurhash32", + "rand_distr", + "tantivy-common", +] + +[[package]] +name = "tantivy-tokenizer-api" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0dcade25819a89cfe6f17d932c9cedff11989936bf6dd4f336d50392053b04" +dependencies = [ + "serde", +] + +[[package]] +name = "tao" +version = "0.16.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22205b267a679ca1c590b9f178488d50981fc3e48a1b91641ae31593db875ce" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "cc", + "cocoa", + "core-foundation", + "core-graphics 0.22.3", + "crossbeam-channel", + "dispatch", + "gdk", + "gdk-pixbuf", + "gdk-sys", + "gdkwayland-sys", + "gdkx11-sys", + "gio", + "glib", + "glib-sys", + "gtk", + "image", + "instant", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc", + "once_cell", + "parking_lot 0.12.1", + "png", + "raw-window-handle", + "scopeguard", + "serde", + "tao-macros", + "unicode-segmentation", + "uuid", + "windows 0.39.0", + "windows-implement", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec114582505d158b669b136e6851f85840c109819d77c42bb7c0709f727d18c2" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16afcea1f22891c49a00c751c7b63b2233284064f11a200fc624137c51e2ddb" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc403891a21bcfb7c37834ba66a547a8f402146eba7265b5a6d88059c9ff2f" + +[[package]] +name = "tauri" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f078117725e36d55d29fafcbb4b1e909073807ca328ae8deb8c0b3843aac0fed" +dependencies = [ + "anyhow", + "cocoa", + "dirs-next", + "dunce", + "embed_plist", + "encoding_rs", + "flate2", + "futures-util", + "glib", + "glob", + "gtk", + "heck 0.4.1", + "http", + "ignore", + "objc", + "once_cell", + "open", + "percent-encoding", + "rand 0.8.5", + "raw-window-handle", + "regex", + "rfd", + "semver", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "state", + "tar", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "tempfile", + "thiserror", + "tokio", + "url", + "uuid", + "webkit2gtk", + "webview2-com", + "windows 0.39.0", +] + +[[package]] +name = "tauri-build" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9914a4715e0b75d9f387a285c7e26b5bbfeb1249ad9f842675a82481565c532" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs-next", + "heck 0.4.1", + "json-patch", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1554c5857f65dbc377cefb6b97c8ac77b1cb2a90d30d3448114d5d6b48a77fc" +dependencies = [ + "base64 0.21.7", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "regex", + "semver", + "serde", + "serde_json", + "sha2", + "tauri-utils", + "thiserror", + "time", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "277abf361a3a6993ec16bcbb179de0d6518009b851090a01adfea12ac89fa875" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 1.0.109", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin-deep-link" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4536f5f6602e8fdfaa7b3b185076c2a0704f8eb7015f4e58461eb483ec3ed1f8" +dependencies = [ + "dirs", + "interprocess", + "log", + "objc2", + "once_cell", + "tauri-utils", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + +[[package]] +name = "tauri-runtime" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2d0652aa2891ff3e9caa2401405257ea29ab8372cce01f186a5825f1bd0e76" +dependencies = [ + "gtk", + "http", + "http-range", + "rand 0.8.5", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror", + "url", + "uuid", + "webview2-com", + "windows 0.39.0", +] + +[[package]] +name = "tauri-runtime-wry" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "067c56fc153b3caf406d7cd6de4486c80d1d66c0f414f39e94cb2f5543f6445f" +dependencies = [ + "arboard", + "cocoa", + "gtk", + "percent-encoding", + "rand 0.8.5", + "raw-window-handle", + "tauri-runtime", + "tauri-utils", + "uuid", + "webkit2gtk", + "webview2-com", + "windows 0.39.0", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ad0bbb31fccd1f4c56275d0a5c3abdf1f59999f72cb4ef8b79b4ed42082a21" +dependencies = [ + "brotli", + "ctor", + "dunce", + "glob", + "heck 0.4.1", + "html5ever", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.2", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "serde_with", + "thiserror", + "url", + "walkdir", + "windows-version", +] + +[[package]] +name = "tauri-winres" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5993dc129e544393574288923d1ec447c857f3f644187f4fbf7d9a875fbfc4fb" +dependencies = [ + "embed-resource", + "toml 0.7.8", +] + +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "tera" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8" +dependencies = [ + "chrono", + "chrono-tz 0.8.6", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.5", + "regex", + "serde", + "serde_json", + "slug", + "unic-segment", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "thin-slice" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" + +[[package]] +name = "thiserror" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "thread-id" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fbf4c9d56b320106cd64fd024dadfa0be7cb4706725fc44a7d7ce952d820c1" +dependencies = [ + "libc", + "redox_syscall 0.1.57", + "winapi", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa 1.0.10", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "to_method" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8" + +[[package]] +name = "tokio" +version = "1.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot 0.12.1", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-retry" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f57eb36ecbe0fc510036adff84824dd3c24bb781e21bfa67b69d556aa85214f" +dependencies = [ + "pin-project", + "rand 0.8.5", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "hashbrown 0.14.3", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.19.15", +] + +[[package]] +name = "toml" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9dd1545e8208b4a5af1aa9bbd0b4cf7e9ea08fabc5d0a5c67fcaafa17433aa3" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.22.9", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.2.6", + "serde", + "serde_spanned", + "toml_datetime", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" +dependencies = [ + "indexmap 2.2.6", + "toml_datetime", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e40bb779c5187258fd7aad0eb68cb8706a0a81fa712fbea808ab43c4b8374c4" +dependencies = [ + "indexmap 2.2.6", + "serde", + "serde_spanned", + "toml_datetime", + "winnow 0.6.5", +] + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "tracing-bunyan-formatter" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d637245a0d8774bd48df6482e086c59a8b5348a910c3b0579354045a9d82411" +dependencies = [ + "ahash 0.8.11", + "gethostname 0.2.3", + "log", + "serde", + "serde_json", + "time", + "tracing", + "tracing-core", + "tracing-log 0.1.4", + "tracing-subscriber", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f751112709b4e791d8ce53e32c4ed2d353565a795ce84da2285393f41557bdf2" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log 0.2.0", + "tracing-serde", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[package]] +name = "tree_magic_mini" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ee137597cdb361b55a4746983e4ac1b35ab6024396a419944ad473bb915265" +dependencies = [ + "fnv", + "home", + "memchr", + "nom", + "once_cell", + "petgraph", +] + +[[package]] +name = "treediff" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d127780145176e2b5d16611cc25a900150e86e9fd79d3bde6ff3a37359c9cb5" +dependencies = [ + "serde_json", +] + +[[package]] +name = "triomphe" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859eb650cfee7434994602c3a68b25d77ad9e68c8a6cd491616ef86661382eb3" + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tsify" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b26cf145f2f3b9ff84e182c448eaf05468e247f148cf3d2a7d67d78ff023a0" +dependencies = [ + "gloo-utils", + "serde", + "serde_json", + "tsify-macros", + "wasm-bindgen", +] + +[[package]] +name = "tsify-macros" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a94b0f0954b3e59bfc2c246b4c8574390d94a4ad4ad246aaf2fb07d7dfd3b47" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.55", +] + +[[package]] +name = "tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "ucd-trie" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23" +dependencies = [ + "unic-ucd-segment", +] + +[[package]] +name = "unic-ucd-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-id" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1b6def86329695390197b82c1e244a54a131ceb66c996f2088a3876e2ae083f" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna 0.5.0", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8-ranges" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +dependencies = [ + "getrandom 0.2.12", + "serde", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db79c75af171630a3148bd3e6d7c4f42b6a9a014c2945bc5ed0020cbb8d9478e" +dependencies = [ + "idna 0.5.0", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive 0.18.2", +] + +[[package]] +name = "validator" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0b4a29d8709210980a09379f27ee31549b73292c87ab9899beee1c0d3be6303" +dependencies = [ + "idna 1.0.3", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive 0.19.0", +] + +[[package]] +name = "validator_derive" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df0bcf92720c40105ac4b2dda2a4ea3aa717d4d6a862cc217da653a4bd5c6b10" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "validator_derive" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac855a2ce6f843beb229757e6e570a42e837bcb15e5f449dd48d5747d41bf77" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c18c859eead79d8b95d09e4678566e8d70105c4e7b251f707a03df32442661b" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b17ae1f6c8a2b28506cd96d412eebf83b4a0ff2cbefeeb952f2f9dfa44ba18" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.55", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + +[[package]] +name = "wasm-streams" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-timer" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.11.2", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d50fa61ce90d76474c87f5fc002828d81b32677340112b4ef08079a9d459a40" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82fb96ee935c2cea6668ccb470fb7771f6215d1691746c2d896b447a00ad3f1f" +dependencies = [ + "bitflags 2.5.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" +dependencies = [ + "bitflags 2.5.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" +dependencies = [ + "bitflags 2.5.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b3a62929287001986fb58c789dce9b67604a397c15c611ad9f747300b6c283" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15a0c8eaff5216d07f226cb7a549159267f3467b289d9a2e52fd3ef5aae2b7af" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit2gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8f859735e4a452aeb28c6c56a852967a8a76c8eb1cc32dbf931ad28a13d6370" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup2", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d76ca6ecc47aeba01ec61e480139dda143796abcae6f83bcddf50d6b5b1dcf3" +dependencies = [ + "atk-sys", + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pango-sys", + "pkg-config", + "soup2-sys", + "system-deps 6.2.2", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webview2-com" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a769c9f1a64a8734bde70caafac2b96cada12cd4aefa49196b3a386b8b4178" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.39.0", + "windows-implement", +] + +[[package]] +name = "webview2-com-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaebe196c01691db62e9e4ca52c5ef1e4fd837dcae27dae3ada599b5a8fd05ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "webview2-com-sys" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac48ef20ddf657755fdcda8dfed2a7b4fc7e4581acce6fe9b88c3d64f29dee7" +dependencies = [ + "regex", + "serde", + "serde_json", + "thiserror", + "windows 0.39.0", + "windows-bindgen", + "windows-metadata", +] + +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57b543186b344cc61c85b5aab0d2e3adf4e0f99bc076eff9aa5927bcc0b8a647" +dependencies = [ + "windows_aarch64_msvc 0.37.0", + "windows_i686_gnu 0.37.0", + "windows_i686_msvc 0.37.0", + "windows_x86_64_gnu 0.37.0", + "windows_x86_64_msvc 0.37.0", +] + +[[package]] +name = "windows" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c4bd0a50ac6020f65184721f758dba47bb9fbc2133df715ec74a237b26794a" +dependencies = [ + "windows-implement", + "windows_aarch64_msvc 0.39.0", + "windows_i686_gnu 0.39.0", + "windows_i686_msvc 0.39.0", + "windows_x86_64_gnu 0.39.0", + "windows_x86_64_msvc 0.39.0", +] + +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-bindgen" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68003dbd0e38abc0fb85b939240f4bce37c43a5981d3df37ccbaaa981b47cb41" +dependencies = [ + "windows-metadata", + "windows-tokens", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-implement" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba01f98f509cb5dc05f4e5fc95e535f78260f15fea8fe1a8abdd08f774f1cee7" +dependencies = [ + "syn 1.0.109", + "windows-tokens", +] + +[[package]] +name = "windows-metadata" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ee5e275231f07c6e240d14f34e1b635bf1faa1c76c57cfd59a5cdb9848e4278" + +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +dependencies = [ + "windows_aarch64_gnullvm 0.52.4", + "windows_aarch64_msvc 0.52.4", + "windows_i686_gnu 0.52.4", + "windows_i686_msvc 0.52.4", + "windows_x86_64_gnu 0.52.4", + "windows_x86_64_gnullvm 0.52.4", + "windows_x86_64_msvc 0.52.4", +] + +[[package]] +name = "windows-tokens" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f838de2fe15fe6bac988e74b798f26499a8b21a9d97edec321e79b28d1d7f597" + +[[package]] +name = "windows-version" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75aa004c988e080ad34aff5739c39d0312f4684699d6d71fc8a198d057b8b9b4" +dependencies = [ + "windows-targets 0.52.4", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7711666096bd4096ffa835238905bb33fb87267910e154b18b44eaabb340f2" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" + +[[package]] +name = "windows_i686_gnu" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1" + +[[package]] +name = "windows_i686_gnu" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "763fc57100a5f7042e3057e7e8d9bdd7860d330070251a73d003563a3bb49e1b" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" + +[[package]] +name = "windows_i686_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c" + +[[package]] +name = "windows_i686_msvc" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bc7cbfe58828921e10a9f446fcaaf649204dcfe6c1ddd712c5eebae6bda1106" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6868c165637d653ae1e8dc4d82c25d4f97dd6605eaa8d784b5c6e0ab2a252b65" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e4d40883ae9cae962787ca76ba76390ffa29214667a111db9e0a1ad8377e809" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dffa400e67ed5a4dd237983829e66475f0a4a26938c4b04c21baede6262215b8" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wl-clipboard-rs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b41773911497b18ca8553c3daaf8ec9fe9819caf93d451d3055f69de028adb" +dependencies = [ + "derive-new", + "libc", + "log", + "nix", + "os_pipe", + "tempfile", + "thiserror", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "wry" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ad85d0e067359e409fcb88903c3eac817c392e5d638258abfb3da5ad8ba6fc4" +dependencies = [ + "base64 0.13.1", + "block", + "cocoa", + "core-graphics 0.22.3", + "crossbeam-channel", + "dunce", + "gdk", + "gio", + "glib", + "gtk", + "html5ever", + "http", + "kuchikiki", + "libc", + "log", + "objc", + "objc_id", + "once_cell", + "serde", + "serde_json", + "sha2", + "soup2", + "tao", + "thiserror", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.39.0", + "windows-implement", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8f25ead8c7e4cba123243a6367da5d3990e0d3affa708ea19dce96356bd9f1a" +dependencies = [ + "gethostname 0.4.3", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e63e71c4b8bd9ffec2c963173a4dc4cbde9ee96961d4fcb4429db9929b606c34" + +[[package]] +name = "xattr" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f" +dependencies = [ + "libc", + "linux-raw-sys", + "rustix", +] + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", + "synstructure", +] + +[[package]] +name = "yrs" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81de5913bca29f43a1d12ca92a7b39a2945e9420e01602a7563917c7bfc60f70" +dependencies = [ + "arc-swap", + "async-lock", + "async-trait", + "dashmap 6.0.1", + "fastrand", + "serde", + "serde_json", + "smallstr", + "smallvec", + "thiserror", +] + +[[package]] +name = "zerocopy" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.55", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq 0.1.5", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2 0.11.0", + "sha1", + "time", + "zstd 0.11.2+zstd.1.5.2", +] + +[[package]] +name = "zip" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc5e4288ea4057ae23afc69a4472434a87a2495cafce6632fd1c4ec9f5cf3494" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq 0.3.0", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "hmac", + "indexmap 2.2.6", + "lzma-rs", + "memchr", + "pbkdf2 0.12.2", + "rand 0.8.5", + "sha1", + "thiserror", + "time", + "zeroize", + "zopfli", + "zstd 0.13.2", +] + +[[package]] +name = "zip-extensions" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb0a99499b3497d765525c5d05e3ade9ca4a731c184365c19472c3fd6ba86341" +dependencies = [ + "zip 2.2.0", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe 5.0.2+zstd.1.5.2", +] + +[[package]] +name = "zstd" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" +dependencies = [ + "zstd-safe 7.2.0", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-safe" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa556e971e7b568dc775c136fc9de8c779b1c2fc3a63defaafadffdbd3181afa" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.12+zstd.1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a4e40c320c3cb459d9a9ff6de98cff88f4751ee9275d140e2be94a2b74e4c13" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.toml b/frontend/appflowy_web_app/src-tauri/Cargo.toml new file mode 100644 index 0000000000000..da3ac7fd721ef --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/Cargo.toml @@ -0,0 +1,136 @@ +[package] +name = "appflowy_tauri" +version = "0.0.0" +description = "A Tauri App" +authors = ["you"] +license = "" +repository = "" +edition = "2021" +rust-version = "1.57" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[build-dependencies] +tauri-build = { version = "1.5", features = [] } + +[workspace.dependencies] +anyhow = "1.0" +tracing = "0.1.40" +bytes = "1.5.0" +serde = "1.0" +serde_json = "1.0.108" +protobuf = { version = "2.28.0" } +diesel = { version = "2.1.0", features = [ + "sqlite", + "chrono", + "r2d2", + "serde_json", +] } +uuid = { version = "1.5.0", features = ["serde", "v4"] } +serde_repr = "0.1" +parking_lot = "0.12" +futures = "0.3.29" +tokio = "1.34.0" +tokio-stream = "0.1.14" +async-trait = "0.1.74" +chrono = { version = "0.4.31", default-features = false, features = ["clock"] } +yrs = "0.19.1" +# Please use the following script to update collab. +# Working directory: frontend +# +# To update the commit ID, run: +# scripts/tool/update_collab_rev.sh new_rev_id +# +# To switch to the local path, run: +# scripts/tool/update_collab_source.sh +# ⚠️⚠️⚠️️ +collab = { version = "0.2" } +collab-entity = { version = "0.2" } +collab-folder = { version = "0.2" } +collab-document = { version = "0.2" } +collab-database = { version = "0.2" } +collab-plugins = { version = "0.2" } +collab-user = { version = "0.2" } +collab-importer = { version = "0.1" } + +# Please using the following command to update the revision id +# Current directory: frontend +# Run the script: +# scripts/tool/update_client_api_rev.sh new_rev_id +# ⚠️⚠️⚠️️ +client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "ea131f0baab67defe7591067357eced490072372" } + +[dependencies] +serde_json.workspace = true +serde.workspace = true +tauri = { version = "1.5", features = [ + "dialog-all", + "clipboard-all", + "fs-all", + "shell-open", +] } +tauri-utils = "1.5.2" +bytes.workspace = true +tracing.workspace = true +lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = [ + "use_serde", +] } +flowy-core = { path = "../../rust-lib/flowy-core", features = ["ts"] } +flowy-user = { path = "../../rust-lib/flowy-user", features = ["tauri_ts"] } +flowy-config = { path = "../../rust-lib/flowy-config", features = ["tauri_ts"] } +flowy-date = { path = "../../rust-lib/flowy-date", features = ["tauri_ts"] } +flowy-error = { path = "../../rust-lib/flowy-error", features = [ + "impl_from_sqlite", + "impl_from_dispatch_error", + "impl_from_appflowy_cloud", + "impl_from_reqwest", + "impl_from_serde", + "tauri_ts", +] } +flowy-document = { path = "../../rust-lib/flowy-document", features = [ + "tauri_ts", +] } +flowy-notification = { path = "../../rust-lib/flowy-notification", features = [ + "tauri_ts", +] } +flowy-ai = { path = "../../rust-lib/flowy-ai", features = ["tauri_ts"] } + +uuid = "1.5.0" +tauri-plugin-deep-link = "0.1.2" +dotenv = "0.15.0" +semver = "1.0.23" + +[features] +# by default Tauri runs in production mode +# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL +default = ["custom-protocol"] +# this feature is used used for production builds where `devPath` points to the filesystem +# DO NOT remove this +custom-protocol = ["tauri/custom-protocol"] + +[patch.crates-io] +# Please use the following script to update collab. +# Working directory: frontend +# +# To update the commit ID, run: +# scripts/tool/update_collab_rev.sh new_rev_id +# +# To switch to the local path, run: +# scripts/tool/update_collab_source.sh +# ⚠️⚠️⚠️️ +collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } +collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "dd8a1d13e97322338f027888a71340575cd9ad8f" } + + +# Working directory: frontend +# To update the commit ID, run: +# scripts/tool/update_local_ai_rev.sh new_rev_id +# ⚠️⚠️⚠️️ +appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "6f064efe232268f8d396edbb4b84d57fbb640f13" } +appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "6f064efe232268f8d396edbb4b84d57fbb640f13" } diff --git a/frontend/appflowy_web_app/src-tauri/Info.plist b/frontend/appflowy_web_app/src-tauri/Info.plist new file mode 100644 index 0000000000000..25b430c049f36 --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/Info.plist @@ -0,0 +1,19 @@ + + + + + + CFBundleURLTypes + + + CFBundleURLName + + appflowy-flutter + CFBundleURLSchemes + + appflowy-flutter + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src-tauri/build.rs b/frontend/appflowy_web_app/src-tauri/build.rs new file mode 100644 index 0000000000000..795b9b7c83dbf --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/frontend/appflowy_web_app/src-tauri/env.development b/frontend/appflowy_web_app/src-tauri/env.development new file mode 100644 index 0000000000000..188835e3d05be --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/env.development @@ -0,0 +1,4 @@ +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_BASE_URL=https://test.appflowy.cloud +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_WS_BASE_URL=wss://test.appflowy.cloud/ws/v1 +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_GOTRUE_URL=https://test.appflowy.cloud/gotrue +APPFLOWY_CLOUD_ENV_CLOUD_TYPE=2 diff --git a/frontend/appflowy_web_app/src-tauri/env.production b/frontend/appflowy_web_app/src-tauri/env.production new file mode 100644 index 0000000000000..b03c328b84eac --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/env.production @@ -0,0 +1,4 @@ +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_BASE_URL=https://beta.appflowy.cloud +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_WS_BASE_URL=wss://beta.appflowy.cloud/ws/v1 +APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_GOTRUE_URL=https://beta.appflowy.cloud/gotrue +APPFLOWY_CLOUD_ENV_CLOUD_TYPE=2 diff --git a/frontend/appflowy_web_app/src-tauri/icons/128x128.png b/frontend/appflowy_web_app/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000..3a51041313f50 Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/128x128.png differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/128x128@2x.png b/frontend/appflowy_web_app/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000..9076de3a4b004 Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/128x128@2x.png differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/32x32.png b/frontend/appflowy_web_app/src-tauri/icons/32x32.png new file mode 100644 index 0000000000000..6ae6683fefb97 Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/32x32.png differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square107x107Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000000000..b08dcf7d21ab9 Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/Square107x107Logo.png differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square142x142Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000000000..f3e437b76e43e Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/Square142x142Logo.png differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square150x150Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000000000..6a1dc04864d95 Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/Square150x150Logo.png differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square284x284Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000000000..2f2d9d6fe630c Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/Square284x284Logo.png differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square30x30Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000000000..46e3802c0bd4a Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/Square30x30Logo.png differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square310x310Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000000000..230b1abe58c88 Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/Square310x310Logo.png differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square44x44Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000000000..ad188037a377e Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/Square44x44Logo.png differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square71x71Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000000000..ceae9ad1bb025 Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/Square71x71Logo.png differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/Square89x89Logo.png b/frontend/appflowy_web_app/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000000000..123dcea650fb2 Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/Square89x89Logo.png differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/StoreLogo.png b/frontend/appflowy_web_app/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000000000..d7906c3c03478 Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/StoreLogo.png differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/icon.icns b/frontend/appflowy_web_app/src-tauri/icons/icon.icns new file mode 100644 index 0000000000000..74b585f25dfbd Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/icon.icns differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/icon.ico b/frontend/appflowy_web_app/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000..cd9ad402d1c9d Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/icon.ico differ diff --git a/frontend/appflowy_web_app/src-tauri/icons/icon.png b/frontend/appflowy_web_app/src-tauri/icons/icon.png new file mode 100644 index 0000000000000..7cc3853d67255 Binary files /dev/null and b/frontend/appflowy_web_app/src-tauri/icons/icon.png differ diff --git a/frontend/appflowy_web_app/src-tauri/rust-toolchain.toml b/frontend/appflowy_web_app/src-tauri/rust-toolchain.toml new file mode 100644 index 0000000000000..6f14058b2e28e --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.77.2" diff --git a/frontend/appflowy_web_app/src-tauri/rustfmt.toml b/frontend/appflowy_web_app/src-tauri/rustfmt.toml new file mode 100644 index 0000000000000..5cb0d67ee549e --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/rustfmt.toml @@ -0,0 +1,12 @@ +# https://rust-lang.github.io/rustfmt/?version=master&search= +max_width = 100 +tab_spaces = 2 +newline_style = "Auto" +match_block_trailing_comma = true +use_field_init_shorthand = true +use_try_shorthand = true +reorder_imports = true +reorder_modules = true +remove_nested_parens = true +merge_derives = true +edition = "2021" \ No newline at end of file diff --git a/frontend/appflowy_web_app/src-tauri/src/init.rs b/frontend/appflowy_web_app/src-tauri/src/init.rs new file mode 100644 index 0000000000000..7af31af362b3d --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/src/init.rs @@ -0,0 +1,79 @@ +use dotenv::dotenv; +use flowy_core::config::AppFlowyCoreConfig; +use flowy_core::{AppFlowyCore, DEFAULT_NAME}; +use lib_dispatch::runtime::AFPluginRuntime; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; + +pub fn read_env() { + dotenv().ok(); + + let env = if cfg!(debug_assertions) { + include_str!("../env.development") + } else { + include_str!("../env.production") + }; + + for line in env.lines() { + if let Some((key, value)) = line.split_once('=') { + // Check if the environment variable is not already set in the system + let current_value = std::env::var(key).unwrap_or_default(); + if current_value.is_empty() { + std::env::set_var(key, value); + } + } + } +} + +pub fn init_appflowy_core() -> MutexAppFlowyCore { + let config_json = include_str!("../tauri.conf.json"); + let config: tauri_utils::config::Config = serde_json::from_str(config_json).unwrap(); + + let app_version = config + .package + .version + .clone() + .map(|v| v.to_string()) + .unwrap_or_else(|| "0.5.8".to_string()); + let app_version = + semver::Version::parse(&app_version).unwrap_or_else(|_| semver::Version::new(0, 5, 8)); + let mut data_path = tauri::api::path::app_local_data_dir(&config).unwrap(); + if cfg!(debug_assertions) { + data_path.push("data_dev"); + } else { + data_path.push("data"); + } + + let custom_application_path = data_path.to_str().unwrap().to_string(); + let application_path = data_path.to_str().unwrap().to_string(); + let device_id = uuid::Uuid::new_v4().to_string(); + + read_env(); + std::env::set_var("RUST_LOG", "trace"); + + let config = AppFlowyCoreConfig::new( + app_version, + custom_application_path, + application_path, + device_id, + "tauri".to_string(), + DEFAULT_NAME.to_string(), + ) + .log_filter("trace", vec!["appflowy_tauri".to_string()]); + + let runtime = Arc::new(AFPluginRuntime::new().unwrap()); + let cloned_runtime = runtime.clone(); + runtime.block_on(async move { + MutexAppFlowyCore::new(AppFlowyCore::new(config, cloned_runtime, None).await) + }) +} + +pub struct MutexAppFlowyCore(pub Arc>); + +impl MutexAppFlowyCore { + pub(crate) fn new(appflowy_core: AppFlowyCore) -> Self { + Self(Arc::new(Mutex::new(appflowy_core))) + } +} +unsafe impl Sync for MutexAppFlowyCore {} +unsafe impl Send for MutexAppFlowyCore {} diff --git a/frontend/appflowy_web_app/src-tauri/src/main.rs b/frontend/appflowy_web_app/src-tauri/src/main.rs new file mode 100644 index 0000000000000..781ce55098126 --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/src/main.rs @@ -0,0 +1,71 @@ +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +#[allow(dead_code)] +pub const DEEP_LINK_SCHEME: &str = "appflowy-flutter"; +pub const OPEN_DEEP_LINK: &str = "open_deep_link"; + +mod init; +mod notification; +mod request; + +use flowy_notification::{register_notification_sender, unregister_all_notification_sender}; +use init::*; +use notification::*; +use request::*; +use tauri::Manager; +extern crate dotenv; + +fn main() { + tauri_plugin_deep_link::prepare(DEEP_LINK_SCHEME); + + let flowy_core = init_appflowy_core(); + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![invoke_request]) + .manage(flowy_core) + .on_window_event(|_window_event| {}) + .on_menu_event(|_menu| {}) + .on_page_load(|window, _payload| { + let app_handler = window.app_handle(); + // Make sure hot reload won't register the notification sender twice + unregister_all_notification_sender(); + register_notification_sender(TSNotificationSender::new(app_handler.clone())); + // tauri::async_runtime::spawn(async move {}); + + window.listen_global(AF_EVENT, move |event| { + on_event(app_handler.clone(), event); + }); + }) + .setup(|_app| { + let splashscreen_window = _app.get_window("splashscreen").unwrap(); + let window = _app.get_window("main").unwrap(); + let handle = _app.handle(); + + // we perform the initialization code on a new task so the app doesn't freeze + tauri::async_runtime::spawn(async move { + // initialize your app here instead of sleeping :) + std::thread::sleep(std::time::Duration::from_secs(2)); + + // After it's done, close the splashscreen and display the main window + splashscreen_window.close().unwrap(); + window.show().unwrap(); + // If you need macOS support this must be called in .setup() ! + // Otherwise this could be called right after prepare() but then you don't have access to tauri APIs + // On macOS You still have to install a .app bundle you got from tauri build --debug for this to work! + tauri_plugin_deep_link::register( + DEEP_LINK_SCHEME, + move |request| { + dbg!(&request); + handle.emit_all(OPEN_DEEP_LINK, request).unwrap(); + }, + ) + .unwrap(/* If listening to the scheme is optional for your app, you don't want to unwrap here. */); + }); + + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/frontend/appflowy_web_app/src-tauri/src/notification.rs b/frontend/appflowy_web_app/src-tauri/src/notification.rs new file mode 100644 index 0000000000000..b42541edeccd7 --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/src/notification.rs @@ -0,0 +1,35 @@ +use flowy_notification::entities::SubscribeObject; +use flowy_notification::NotificationSender; +use serde::Serialize; +use tauri::{AppHandle, Event, Manager, Wry}; + +#[allow(dead_code)] +pub const AF_EVENT: &str = "af-event"; +pub const AF_NOTIFICATION: &str = "af-notification"; + +#[tracing::instrument(level = "trace")] +pub fn on_event(app_handler: AppHandle, event: Event) {} + +#[allow(dead_code)] +pub fn send_notification(app_handler: AppHandle, payload: P) { + app_handler.emit_all(AF_NOTIFICATION, payload).unwrap(); +} + +pub struct TSNotificationSender { + handler: AppHandle, +} + +impl TSNotificationSender { + pub fn new(handler: AppHandle) -> Self { + Self { handler } + } +} + +impl NotificationSender for TSNotificationSender { + fn send_subject(&self, subject: SubscribeObject) -> Result<(), String> { + self + .handler + .emit_all(AF_NOTIFICATION, subject) + .map_err(|e| format!("{:?}", e)) + } +} diff --git a/frontend/appflowy_web_app/src-tauri/src/request.rs b/frontend/appflowy_web_app/src-tauri/src/request.rs new file mode 100644 index 0000000000000..ff69a438c9c19 --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/src/request.rs @@ -0,0 +1,45 @@ +use crate::init::MutexAppFlowyCore; +use lib_dispatch::prelude::{ + AFPluginDispatcher, AFPluginEventResponse, AFPluginRequest, StatusCode, +}; +use tauri::{AppHandle, Manager, State, Wry}; + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct AFTauriRequest { + ty: String, + payload: Vec, +} + +impl std::convert::From for AFPluginRequest { + fn from(event: AFTauriRequest) -> Self { + AFPluginRequest::new(event.ty).payload(event.payload) + } +} + +#[derive(Clone, serde::Serialize)] +pub struct AFTauriResponse { + code: StatusCode, + payload: Vec, +} + +impl std::convert::From for AFTauriResponse { + fn from(response: AFPluginEventResponse) -> Self { + Self { + code: response.status_code, + payload: response.payload.to_vec(), + } + } +} + +// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command +#[tauri::command] +pub async fn invoke_request( + request: AFTauriRequest, + app_handler: AppHandle, +) -> AFTauriResponse { + let request: AFPluginRequest = request.into(); + let state: State = app_handler.state(); + let dispatcher = state.0.lock().unwrap().dispatcher(); + let response = AFPluginDispatcher::sync_send(dispatcher, request); + response.into() +} diff --git a/frontend/appflowy_web_app/src-tauri/tauri.conf.json b/frontend/appflowy_web_app/src-tauri/tauri.conf.json new file mode 100644 index 0000000000000..ea11f47def506 --- /dev/null +++ b/frontend/appflowy_web_app/src-tauri/tauri.conf.json @@ -0,0 +1,113 @@ +{ + "build": { + "beforeBuildCommand": "npm run build:tauri", + "beforeDevCommand": "npm run dev:tauri", + "devPath": "http://localhost:5173", + "distDir": "../dist", + "withGlobalTauri": false + }, + "package": { + "productName": "AppFlowy", + "version": "0.0.1" + }, + "tauri": { + "allowlist": { + "all": false, + "shell": { + "all": false, + "open": true + }, + "fs": { + "all": true, + "scope": [ + "$APPLOCALDATA/**" + ], + "readFile": true, + "writeFile": true, + "readDir": true, + "copyFile": true, + "createDir": true, + "removeDir": true, + "removeFile": true, + "renameFile": true, + "exists": true + }, + "clipboard": { + "all": true, + "writeText": true, + "readText": true + }, + "dialog": { + "all": true, + "ask": true, + "confirm": true, + "message": true, + "open": true, + "save": true + } + }, + "bundle": { + "active": true, + "category": "DeveloperTool", + "copyright": "", + "deb": { + "depends": [] + }, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "externalBin": [], + "identifier": "com.appflowy.tauri", + "longDescription": "", + "macOS": { + "entitlements": null, + "exceptionDomain": "", + "frameworks": [], + "providerShortName": null, + "signingIdentity": null, + "minimumSystemVersion": "10.15.0" + }, + "resources": [], + "shortDescription": "", + "targets": "all", + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "" + } + }, + "security": { + "csp": null + }, + "updater": { + "active": false + }, + "windows": [ + { + "fileDropEnabled": false, + "fullscreen": false, + "height": 800, + "resizable": true, + "title": "AppFlowy", + "width": 1200, + "minWidth": 800, + "minHeight": 600, + "visible": false, + "label": "main" + }, + { + "height": 300, + "width": 549, + "decorations": false, + "url": "launch_splash.jpg", + "label": "splashscreen", + "center": true, + "visible": true + } + ] + } +} diff --git a/frontend/appflowy_web_app/src/@types/i18next.d.ts b/frontend/appflowy_web_app/src/@types/i18next.d.ts new file mode 100644 index 0000000000000..6adbb4a5124a0 --- /dev/null +++ b/frontend/appflowy_web_app/src/@types/i18next.d.ts @@ -0,0 +1,8 @@ +import resources from './resources'; + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: 'translation'; + resources: typeof resources; + } +} diff --git a/frontend/appflowy_web_app/src/@types/resources.ts b/frontend/appflowy_web_app/src/@types/resources.ts new file mode 100644 index 0000000000000..6bd90364e016a --- /dev/null +++ b/frontend/appflowy_web_app/src/@types/resources.ts @@ -0,0 +1,7 @@ +import translation from './translations/en.json'; + +const resources = { + translation, +} as const; + +export default resources; diff --git a/frontend/appflowy_web_app/src/application/comment.type.ts b/frontend/appflowy_web_app/src/application/comment.type.ts new file mode 100644 index 0000000000000..0d5bdecf531d0 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/comment.type.ts @@ -0,0 +1,18 @@ +import { AFWebUser } from '@/application/types'; + +export interface GlobalComment { + commentId: string; + user: AFWebUser | null; + content: string; + createdAt: string; + lastUpdatedAt: string; + replyCommentId: string | null; + isDeleted: boolean; + canDeleted: boolean; +} + +export interface Reaction { + reactionType: string; + reactUsers: AFWebUser[]; + commentId: string; +} diff --git a/frontend/appflowy_web_app/src/application/constants.ts b/frontend/appflowy_web_app/src/application/constants.ts new file mode 100644 index 0000000000000..5880ff63267e4 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/constants.ts @@ -0,0 +1,3 @@ +export const databasePrefix = 'af_database'; + +export const HEADER_HEIGHT = 48; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/filter.test.ts b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/filter.test.ts new file mode 100644 index 0000000000000..b07dc9beac6b5 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/filter.test.ts @@ -0,0 +1,672 @@ +import { + NumberFilterCondition, + TextFilterCondition, + CheckboxFilterCondition, + ChecklistFilterCondition, + SelectOptionFilterCondition, + Row, +} from '@/application/database-yjs'; +import { withTestingData } from '@/application/database-yjs/__tests__/withTestingData'; +import { + withCheckboxFilter, + withChecklistFilter, + withDateTimeFilter, + withMultiSelectOptionFilter, + withNumberFilter, + withRichTextFilter, + withSingleSelectOptionFilter, + withUrlFilter, +} from '@/application/database-yjs/__tests__/withTestingFilters'; +import { withTestingRows } from '@/application/database-yjs/__tests__/withTestingRows'; +import { RowId, YDoc } from '@/application/types'; +import { + textFilterCheck, + numberFilterCheck, + checkboxFilterCheck, + checklistFilterCheck, + selectOptionFilterCheck, + filterBy, +} from '../filter'; +import { expect } from '@jest/globals'; +import * as Y from 'yjs'; + +describe('Text filter check', () => { + const text = 'Hello, world!'; + it('should return true for TextIs condition', () => { + const condition = TextFilterCondition.TextIs; + const content = 'Hello, world!'; + + const result = textFilterCheck(text, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for TextIs condition', () => { + const condition = TextFilterCondition.TextIs; + const content = 'Hello, world'; + + const result = textFilterCheck(text, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for TextIsNot condition', () => { + const condition = TextFilterCondition.TextIsNot; + const content = 'Hello, world'; + + const result = textFilterCheck(text, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for TextIsNot condition', () => { + const condition = TextFilterCondition.TextIsNot; + const content = 'Hello, world!'; + + const result = textFilterCheck(text, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for TextContains condition', () => { + const condition = TextFilterCondition.TextContains; + const content = 'world'; + + const result = textFilterCheck(text, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for TextContains condition', () => { + const condition = TextFilterCondition.TextContains; + const content = 'planet'; + + const result = textFilterCheck(text, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for TextDoesNotContain condition', () => { + const condition = TextFilterCondition.TextDoesNotContain; + const content = 'planet'; + + const result = textFilterCheck(text, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for TextDoesNotContain condition', () => { + const condition = TextFilterCondition.TextDoesNotContain; + const content = 'world'; + + const result = textFilterCheck(text, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for TextIsEmpty condition', () => { + const condition = TextFilterCondition.TextIsEmpty; + const text = ''; + + const result = textFilterCheck(text, '', condition); + + expect(result).toBe(true); + }); + + it('should return false for TextIsEmpty condition', () => { + const condition = TextFilterCondition.TextIsEmpty; + const text = 'Hello, world!'; + + const result = textFilterCheck(text, '', condition); + + expect(result).toBe(false); + }); + + it('should return true for TextIsNotEmpty condition', () => { + const condition = TextFilterCondition.TextIsNotEmpty; + const text = 'Hello, world!'; + + const result = textFilterCheck(text, '', condition); + + expect(result).toBe(true); + }); + + it('should return false for TextIsNotEmpty condition', () => { + const condition = TextFilterCondition.TextIsNotEmpty; + const text = ''; + + const result = textFilterCheck(text, '', condition); + + expect(result).toBe(false); + }); + + it('should return false for unknown condition', () => { + const condition = 42; + const content = 'Hello, world!'; + + const result = textFilterCheck(text, content, condition); + + expect(result).toBe(false); + }); +}); + +describe('Number filter check', () => { + const num = '42'; + it('should return true for Equal condition', () => { + const condition = NumberFilterCondition.Equal; + const content = '42'; + + const result = numberFilterCheck(num, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for Equal condition', () => { + const condition = NumberFilterCondition.Equal; + const content = '43'; + + const result = numberFilterCheck(num, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for NotEqual condition', () => { + const condition = NumberFilterCondition.NotEqual; + const content = '43'; + + const result = numberFilterCheck(num, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for NotEqual condition', () => { + const condition = NumberFilterCondition.NotEqual; + const content = '42'; + + const result = numberFilterCheck(num, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for GreaterThan condition', () => { + const condition = NumberFilterCondition.GreaterThan; + const content = '41'; + + const result = numberFilterCheck(num, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for GreaterThan condition', () => { + const condition = NumberFilterCondition.GreaterThan; + const content = '42'; + + const result = numberFilterCheck(num, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for GreaterThanOrEqualTo condition', () => { + const condition = NumberFilterCondition.GreaterThanOrEqualTo; + const content = '42'; + + const result = numberFilterCheck(num, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for GreaterThanOrEqualTo condition', () => { + const condition = NumberFilterCondition.GreaterThanOrEqualTo; + const content = '43'; + + const result = numberFilterCheck(num, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for LessThan condition', () => { + const condition = NumberFilterCondition.LessThan; + const content = '43'; + + const result = numberFilterCheck(num, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for LessThan condition', () => { + const condition = NumberFilterCondition.LessThan; + const content = '42'; + + const result = numberFilterCheck(num, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for LessThanOrEqualTo condition', () => { + const condition = NumberFilterCondition.LessThanOrEqualTo; + const content = '42'; + + const result = numberFilterCheck(num, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for LessThanOrEqualTo condition', () => { + const condition = NumberFilterCondition.LessThanOrEqualTo; + const content = '41'; + + const result = numberFilterCheck(num, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for NumberIsEmpty condition', () => { + const condition = NumberFilterCondition.NumberIsEmpty; + + const result = numberFilterCheck('', '', condition); + + expect(result).toBe(true); + }); + + it('should return false for NumberIsEmpty condition', () => { + const condition = NumberFilterCondition.NumberIsEmpty; + const num = '42'; + + const result = numberFilterCheck(num, '', condition); + + expect(result).toBe(false); + }); + + it('should return true for NumberIsNotEmpty condition', () => { + const condition = NumberFilterCondition.NumberIsNotEmpty; + const num = '42'; + + const result = numberFilterCheck(num, '', condition); + + expect(result).toBe(true); + }); + + it('should return false for NumberIsNotEmpty condition', () => { + const condition = NumberFilterCondition.NumberIsNotEmpty; + const num = ''; + + const result = numberFilterCheck(num, '', condition); + + expect(result).toBe(false); + }); + + it('should return false for unknown condition', () => { + const condition = 42; + const content = '42'; + + const result = numberFilterCheck(num, content, condition); + + expect(result).toBe(false); + }); +}); + +describe('Checkbox filter check', () => { + it('should return true for IsChecked condition', () => { + const condition = CheckboxFilterCondition.IsChecked; + const data = 'Yes'; + + const result = checkboxFilterCheck(data, condition); + + expect(result).toBe(true); + }); + + it('should return false for IsChecked condition', () => { + const condition = CheckboxFilterCondition.IsChecked; + const data = 'No'; + + const result = checkboxFilterCheck(data, condition); + + expect(result).toBe(false); + }); + + it('should return true for IsUnChecked condition', () => { + const condition = CheckboxFilterCondition.IsUnChecked; + const data = 'No'; + + const result = checkboxFilterCheck(data, condition); + + expect(result).toBe(true); + }); + + it('should return false for IsUnChecked condition', () => { + const condition = CheckboxFilterCondition.IsUnChecked; + const data = 'Yes'; + + const result = checkboxFilterCheck(data, condition); + + expect(result).toBe(false); + }); + + it('should return false for unknown condition', () => { + const condition = 42; + const data = 'Yes'; + + const result = checkboxFilterCheck(data, condition); + + expect(result).toBe(false); + }); +}); + +describe('Checklist filter check', () => { + it('should return true for IsComplete condition', () => { + const condition = ChecklistFilterCondition.IsComplete; + const data = JSON.stringify({ + options: [ + { id: '1', name: 'Option 1' }, + { id: '2', name: 'Option 2' }, + ], + selected_option_ids: ['1', '2'], + }); + + const result = checklistFilterCheck(data, '', condition); + + expect(result).toBe(true); + }); + + it('should return false for IsComplete condition', () => { + const condition = ChecklistFilterCondition.IsComplete; + const data = JSON.stringify({ + options: [ + { id: '1', name: 'Option 1' }, + { id: '2', name: 'Option 2' }, + ], + selected_option_ids: ['1'], + }); + + const result = checklistFilterCheck(data, '', condition); + + expect(result).toBe(false); + }); + + it('should return false for unknown condition', () => { + const condition = 42; + const data = JSON.stringify({ + options: [ + { id: '1', name: 'Option 1' }, + { id: '2', name: 'Option 2' }, + ], + selected_option_ids: ['1', '2'], + }); + + const result = checklistFilterCheck(data, '', condition); + + expect(result).toBe(false); + }); +}); + +describe('SelectOption filter check', () => { + it('should return true for OptionIs condition', () => { + const condition = SelectOptionFilterCondition.OptionIs; + const content = '1'; + const data = '1,2'; + + const result = selectOptionFilterCheck(data, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for OptionIs condition', () => { + const condition = SelectOptionFilterCondition.OptionIs; + const content = '3'; + const data = '1,2'; + + const result = selectOptionFilterCheck(data, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for OptionIsNot condition', () => { + const condition = SelectOptionFilterCondition.OptionIsNot; + const content = '3'; + const data = '1,2'; + + const result = selectOptionFilterCheck(data, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for OptionIsNot condition', () => { + const condition = SelectOptionFilterCondition.OptionIsNot; + const content = '1'; + const data = '1,2'; + + const result = selectOptionFilterCheck(data, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for OptionContains condition', () => { + const condition = SelectOptionFilterCondition.OptionContains; + const content = '1,3'; + const data = '1,2,3'; + + const result = selectOptionFilterCheck(data, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for OptionContains condition', () => { + const condition = SelectOptionFilterCondition.OptionContains; + const content = '4'; + const data = '1,2,3'; + + const result = selectOptionFilterCheck(data, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for OptionDoesNotContain condition', () => { + const condition = SelectOptionFilterCondition.OptionDoesNotContain; + const content = '4,5'; + const data = '1,2,3'; + + const result = selectOptionFilterCheck(data, content, condition); + + expect(result).toBe(true); + }); + + it('should return false for OptionDoesNotContain condition', () => { + const condition = SelectOptionFilterCondition.OptionDoesNotContain; + const content = '1,3'; + const data = '1,2,3'; + + const result = selectOptionFilterCheck(data, content, condition); + + expect(result).toBe(false); + }); + + it('should return true for OptionIsEmpty condition', () => { + const condition = SelectOptionFilterCondition.OptionIsEmpty; + const data = ''; + + const result = selectOptionFilterCheck(data, '', condition); + + expect(result).toBe(true); + }); + + it('should return false for OptionIsEmpty condition', () => { + const condition = SelectOptionFilterCondition.OptionIsEmpty; + const data = '1,2'; + + const result = selectOptionFilterCheck(data, '', condition); + + expect(result).toBe(false); + }); + + it('should return true for OptionIsNotEmpty condition', () => { + const condition = SelectOptionFilterCondition.OptionIsNotEmpty; + const data = '1,2'; + + const result = selectOptionFilterCheck(data, '', condition); + + expect(result).toBe(true); + }); + + it('should return false for OptionIsNotEmpty condition', () => { + const condition = SelectOptionFilterCondition.OptionIsNotEmpty; + const data = ''; + + const result = selectOptionFilterCheck(data, '', condition); + + expect(result).toBe(false); + }); + + it('should return false for unknown condition', () => { + const condition = 42; + const content = '1'; + const data = '1,2'; + + const result = selectOptionFilterCheck(data, content, condition); + + expect(result).toBe(false); + }); +}); + +describe('Database filterBy', () => { + let rows: Row[]; + + beforeEach(() => { + rows = withTestingRows(); + }); + + it('should return all rows for empty filter', () => { + const { filters, fields, rowMap } = withTestingData(); + const result = filterBy(rows, filters, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(result).toBe('1,2,3,4,5,6,7,8,9,10'); + }); + + it('should return all rows for empty rowMap', () => { + const { filters, fields } = withTestingData(); + const rowMap: Record = {}; + const result = filterBy(rows, filters, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(result).toBe('1,2,3,4,5,6,7,8,9,10'); + }); + + it('should return rows that match text filter', () => { + const { filters, fields, rowMap } = withTestingData(); + const filter = withRichTextFilter(); + filters.push([filter]); + const result = filterBy(rows, filters, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(result).toBe('1,5'); + }); + + it('should return rows that match number filter', () => { + const { filters, fields, rowMap } = withTestingData(); + const filter = withNumberFilter(); + filters.push([filter]); + const result = filterBy(rows, filters, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(result).toBe('4,5,6,7,8,9,10'); + }); + + it('should return rows that match checkbox filter', () => { + const { filters, fields, rowMap } = withTestingData(); + const filter = withCheckboxFilter(); + filters.push([filter]); + const result = filterBy(rows, filters, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(result).toBe('2,4,6,8,10'); + }); + + it('should return rows that match checklist filter', () => { + const { filters, fields, rowMap } = withTestingData(); + const filter = withChecklistFilter(); + filters.push([filter]); + const result = filterBy(rows, filters, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(result).toBe('1,2,4,5,6,7,8,10'); + }); + + it('should return rows that match multiple filters', () => { + const { filters, fields, rowMap } = withTestingData(); + const filter1 = withRichTextFilter(); + const filter2 = withNumberFilter(); + filters.push([filter1, filter2]); + const result = filterBy(rows, filters, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(result).toBe('5'); + }); + + it('should return rows that match url filter', () => { + const { filters, fields, rowMap } = withTestingData(); + const filter = withUrlFilter(); + filters.push([filter]); + const result = filterBy(rows, filters, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(result).toBe('4'); + }); + + it('should return rows that match date filter', () => { + const { filters, fields, rowMap } = withTestingData(); + const filter = withDateTimeFilter(); + filters.push([filter]); + const result = filterBy(rows, filters, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(result).toBe('1,2,3,4,5,6,7,8,9,10'); + }); + + it('should return rows that match select option filter', () => { + const { filters, fields, rowMap } = withTestingData(); + const filter = withSingleSelectOptionFilter(); + filters.push([filter]); + const result = filterBy(rows, filters, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(result).toBe('2,5,8'); + }); + + it('should return rows that match multi select option filter', () => { + const { filters, fields, rowMap } = withTestingData(); + const filter = withMultiSelectOptionFilter(); + filters.push([filter]); + const result = filterBy(rows, filters, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(result).toBe('1,2,3,5,6,7,8,9'); + }); + + it('should return rows that match multiple filters', () => { + const { filters, fields, rowMap } = withTestingData(); + const filter1 = withNumberFilter(); + const filter2 = withChecklistFilter(); + filters.push([filter1, filter2]); + const result = filterBy(rows, filters, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(result).toBe('4,5,6,7,8,10'); + }); + + it('should return empty array for all filters', () => { + const { filters, fields, rowMap } = withTestingData(); + const filter1 = withNumberFilter(); + const filter2 = withChecklistFilter(); + const filter3 = withRichTextFilter(); + const filter4 = withCheckboxFilter(); + const filter5 = withSingleSelectOptionFilter(); + const filter6 = withMultiSelectOptionFilter(); + const filter7 = withUrlFilter(); + const filter8 = withDateTimeFilter(); + filters.push([filter1, filter2, filter3, filter4, filter5, filter6, filter7, filter8]); + const result = filterBy(rows, filters, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(result).toBe(''); + }); +}); diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/fixtures/filters.json b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/fixtures/filters.json new file mode 100644 index 0000000000000..eb0688a5de332 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/fixtures/filters.json @@ -0,0 +1,40 @@ +{ + "filter_text_field": { + "field_id": "text_field", + "condition": 2, + "content": "w" + }, + "filter_number_field": { + "field_id": "number_field", + "condition": 2, + "content": 1000 + }, + "filter_date_field": { + "field_id": "date_field", + "condition": 1, + "content": 1685798400000 + }, + "filter_checkbox_field": { + "field_id": "checkbox_field", + "condition": 1 + }, + "filter_checklist_field": { + "field_id": "checklist_field", + "condition": 1 + }, + "filter_url_field": { + "field_id": "url_field", + "condition": 0, + "content": "https://example.com/4" + }, + "filter_single_select_field": { + "field_id": "single_select_field", + "condition": 0, + "content": "2" + }, + "filter_multi_select_field": { + "field_id": "multi_select_field", + "condition": 2, + "content": "1,3" + } +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/fixtures/rows.json b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/fixtures/rows.json new file mode 100644 index 0000000000000..989a335527a67 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/fixtures/rows.json @@ -0,0 +1,412 @@ +[ + { + "id": "1", + "cells": { + "text_field": { + "id": "text_field", + "data": "Hello world" + }, + "number_field": { + "id": "number_field", + "data": 123 + }, + "checkbox_field": { + "id": "checkbox_field", + "data": "Yes" + }, + "date_field": { + "id": "date_field", + "data": 1685539200000, + "end_timestamp": 1685625600000, + "include_time": true, + "is_range": false, + "reminder_id": "rem1" + }, + "url_field": { + "id": "url_field", + "data": "https://example.com/1" + }, + "single_select_field": { + "id": "single_select_field", + "data": "1" + }, + "multi_select_field": { + "id": "multi_select_field", + "data": "1,2" + }, + "checklist_field": { + "id": "checklist_field", + "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\"]}" + } + } + }, + { + "id": "2", + "cells": { + "text_field": { + "id": "text_field", + "data": "Good morning" + }, + "number_field": { + "id": "number_field", + "data": 456 + }, + "checkbox_field": { + "id": "checkbox_field", + "data": "No" + }, + "date_field": { + "id": "date_field", + "data": 1685625600000, + "end_timestamp": 1685712000000, + "include_time": false, + "is_range": true, + "reminder_id": "rem2" + }, + "url_field": { + "id": "url_field", + "data": "https://example.com/2" + }, + "single_select_field": { + "id": "single_select_field", + "data": "2" + }, + "multi_select_field": { + "id": "multi_select_field", + "data": "2,3" + }, + "checklist_field": { + "id": "checklist_field", + "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"2\"]}" + } + } + }, + { + "id": "3", + "cells": { + "text_field": { + "id": "text_field", + "data": "Good night" + }, + "number_field": { + "id": "number_field", + "data": 789 + }, + "checkbox_field": { + "id": "checkbox_field", + "data": "Yes" + }, + "date_field": { + "id": "date_field", + "data": 1685712000000, + "end_timestamp": 1685798400000, + "include_time": true, + "is_range": false, + "reminder_id": "rem3" + }, + "url_field": { + "id": "url_field", + "data": "https://example.com/3" + }, + "single_select_field": { + "id": "single_select_field", + "data": "3" + }, + "multi_select_field": { + "id": "multi_select_field", + "data": "1,3" + }, + "checklist_field": { + "id": "checklist_field", + "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\",\"2\"]}" + } + } + }, + { + "id": "4", + "cells": { + "text_field": { + "id": "text_field", + "data": "Happy day" + }, + "number_field": { + "id": "number_field", + "data": 1011 + }, + "checkbox_field": { + "id": "checkbox_field", + "data": "No" + }, + "date_field": { + "id": "date_field", + "data": 1685798400000, + "end_timestamp": 1685884800000, + "include_time": false, + "is_range": true, + "reminder_id": "rem4" + }, + "url_field": { + "id": "url_field", + "data": "https://example.com/4" + }, + "single_select_field": { + "id": "single_select_field", + "data": "1" + }, + "multi_select_field": { + "id": "multi_select_field", + "data": "2" + }, + "checklist_field": { + "id": "checklist_field", + "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[]}" + } + } + }, + { + "id": "5", + "cells": { + "text_field": { + "id": "text_field", + "data": "Sunny weather" + }, + "number_field": { + "id": "number_field", + "data": 1213 + }, + "checkbox_field": { + "id": "checkbox_field", + "data": "Yes" + }, + "date_field": { + "id": "date_field", + "data": 1685884800000, + "end_timestamp": 1685971200000, + "include_time": true, + "is_range": false, + "reminder_id": "rem5" + }, + "url_field": { + "id": "url_field", + "data": "https://example.com/5" + }, + "single_select_field": { + "id": "single_select_field", + "data": "2" + }, + "multi_select_field": { + "id": "multi_select_field", + "data": "1,2,3" + }, + "checklist_field": { + "id": "checklist_field", + "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\"]}" + } + } + }, + { + "id": "6", + "cells": { + "text_field": { + "id": "text_field", + "data": "Rainy day" + }, + "number_field": { + "id": "number_field", + "data": 1415 + }, + "checkbox_field": { + "id": "checkbox_field", + "data": "No" + }, + "date_field": { + "id": "date_field", + "data": 1685971200000, + "end_timestamp": 1686057600000, + "include_time": false, + "is_range": true, + "reminder_id": "rem6" + }, + "url_field": { + "id": "url_field", + "data": "https://example.com/6" + }, + "single_select_field": { + "id": "single_select_field", + "data": "3" + }, + "multi_select_field": { + "id": "multi_select_field", + "data": "1,3" + }, + "checklist_field": { + "id": "checklist_field", + "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"2\"]}" + } + } + }, + { + "id": "7", + "cells": { + "text_field": { + "id": "text_field", + "data": "Winter is coming" + }, + "number_field": { + "id": "number_field", + "data": 1617 + }, + "checkbox_field": { + "id": "checkbox_field", + "data": "Yes" + }, + "date_field": { + "id": "date_field", + "data": 1686057600000, + "end_timestamp": 1686144000000, + "include_time": true, + "is_range": false, + "reminder_id": "rem7" + }, + "url_field": { + "id": "url_field", + "data": "https://example.com/7" + }, + "single_select_field": { + "id": "single_select_field", + "data": "1" + }, + "multi_select_field": { + "id": "multi_select_field", + "data": "1,2" + }, + "checklist_field": { + "id": "checklist_field", + "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\"]}" + } + } + }, + { + "id": "8", + "cells": { + "text_field": { + "id": "text_field", + "data": "Summer vibes" + }, + "number_field": { + "id": "number_field", + "data": 1819 + }, + "checkbox_field": { + "id": "checkbox_field", + "data": "No" + }, + "date_field": { + "id": "date_field", + "data": 1686144000000, + "end_timestamp": 1686230400000, + "include_time": false, + "is_range": true, + "reminder_id": "rem8" + }, + "url_field": { + "id": "url_field", + "data": "https://example.com/8" + }, + "single_select_field": { + "id": "single_select_field", + "data": "2" + }, + "multi_select_field": { + "id": "multi_select_field", + "data": "2,3" + }, + "checklist_field": { + "id": "checklist_field", + "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"2\"]}" + } + } + }, + { + "id": "9", + "cells": { + "text_field": { + "id": "text_field", + "data": "Autumn leaves" + }, + "number_field": { + "id": "number_field", + "data": 2021 + }, + "checkbox_field": { + "id": "checkbox_field", + "data": "Yes" + }, + "date_field": { + "id": "date_field", + "data": 1686230400000, + "end_timestamp": 1686316800000, + "include_time": true, + "is_range": false, + "reminder_id": "rem9" + }, + "url_field": { + "id": "url_field", + "data": "https://example.com/9" + }, + "single_select_field": { + "id": "single_select_field", + "data": "3" + }, + "multi_select_field": { + "id": "multi_select_field", + "data": "1,3" + }, + "checklist_field": { + "id": "checklist_field", + "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[\"1\",\"2\"]}" + } + } + }, + { + "id": "10", + "cells": { + "text_field": { + "id": "text_field", + "data": "Spring blossoms" + }, + "number_field": { + "id": "number_field", + "data": 2223 + }, + "checkbox_field": { + "id": "checkbox_field", + "data": "No" + }, + "date_field": { + "id": "date_field", + "data": 1686316800000, + "end_timestamp": 1686403200000, + "include_time": false, + "is_range": true, + "reminder_id": "rem10" + }, + "url_field": { + "id": "url_field", + "data": "https://example.com/10" + }, + "single_select_field": { + "id": "single_select_field", + "data": "1" + }, + "multi_select_field": { + "id": "multi_select_field", + "data": "2" + }, + "checklist_field": { + "id": "checklist_field", + "data": "{\"options\":[{\"id\":\"1\",\"name\":\"Task 1\"},{\"id\":\"2\",\"name\":\"Task 2\"}],\"selected_option_ids\":[]}" + } + } + } +] diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/fixtures/sorts.json b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/fixtures/sorts.json new file mode 100644 index 0000000000000..11ae36cf60471 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/fixtures/sorts.json @@ -0,0 +1,102 @@ +{ + "sort_asc_text_field": { + "id": "sort_asc_text_field", + "field_id": "text_field", + "condition": "asc" + }, + "sort_desc_text_field": { + "field_id": "text_field", + "condition": "desc", + "id": "sort_desc_text_field" + }, + "sort_asc_number_field": { + "field_id": "number_field", + "condition": "asc", + "id": "sort_asc_number_field" + }, + "sort_desc_number_field": { + "field_id": "number_field", + "condition": "desc", + "id": "sort_desc_number_field" + }, + "sort_asc_date_field": { + "field_id": "date_field", + "condition": "asc", + "id": "sort_asc_date_field" + }, + "sort_desc_date_field": { + "field_id": "date_field", + "condition": "desc", + "id": "sort_desc_date_field" + }, + "sort_asc_checkbox_field": { + "field_id": "checkbox_field", + "condition": "asc", + "id": "sort_asc_checkbox_field" + }, + "sort_desc_checkbox_field": { + "field_id": "checkbox_field", + "condition": "desc", + "id": "sort_desc_checkbox_field" + }, + "sort_asc_checklist_field": { + "field_id": "checklist_field", + "condition": "asc", + "id": "sort_asc_checklist_field" + }, + "sort_desc_checklist_field": { + "field_id": "checklist_field", + "condition": "desc", + "id": "sort_desc_checklist_field" + }, + "sort_asc_single_select_field": { + "field_id": "single_select_field", + "condition": "asc", + "id": "sort_asc_single_select_field" + }, + "sort_desc_single_select_field": { + "field_id": "single_select_field", + "condition": "desc", + "id": "sort_desc_single_select_field" + }, + "sort_asc_multi_select_field": { + "field_id": "multi_select_field", + "condition": "asc", + "id": "sort_asc_multi_select_field" + }, + "sort_desc_multi_select_field": { + "field_id": "multi_select_field", + "condition": "desc", + "id": "sort_desc_multi_select_field" + }, + "sort_asc_url_field": { + "field_id": "url_field", + "condition": "asc", + "id": "sort_asc_url_field" + }, + "sort_desc_url_field": { + "field_id": "url_field", + "condition": "desc", + "id": "sort_desc_url_field" + }, + "sort_asc_created_at": { + "field_id": "created_at_field", + "condition": "asc", + "id": "sort_asc_created_at" + }, + "sort_desc_created_at": { + "field_id": "created_at_field", + "condition": "desc", + "id": "sort_desc_created_at" + }, + "sort_asc_updated_at": { + "field_id": "last_modified_field", + "condition": "asc", + "id": "sort_asc_updated_at" + }, + "sort_desc_updated_at": { + "field_id": "last_modified_field", + "condition": "desc", + "id": "sort_desc_updated_at" + } +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/group.test.ts b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/group.test.ts new file mode 100644 index 0000000000000..a38ed139d666f --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/group.test.ts @@ -0,0 +1,198 @@ +import { FieldType, Row } from '@/application/database-yjs'; +import { withTestingData } from '@/application/database-yjs/__tests__/withTestingData'; +import { withTestingRows } from '@/application/database-yjs/__tests__/withTestingRows'; +import { expect } from '@jest/globals'; +import { groupByField } from '../group'; +import * as Y from 'yjs'; +import { + YDatabaseField, + YDatabaseFieldTypeOption, + YjsDatabaseKey, + YjsEditorKey, + YMapFieldTypeOption, +} from '@/application/types'; +import { YjsEditor } from '@/application/slate-yjs'; + +describe('Database group', () => { + let rows: Row[]; + + beforeEach(() => { + rows = withTestingRows(); + }); + + it('should return undefined if field is not select option', () => { + const { fields, rowMap } = withTestingData(); + expect(groupByField(rows, rowMap, fields.get('text_field'))).toBeUndefined(); + expect(groupByField(rows, rowMap, fields.get('number_field'))).toBeUndefined(); + expect(groupByField(rows, rowMap, fields.get('checklist_field'))).toBeUndefined(); + }); + + it('should gourp by checkbox field', () => { + const { fields, rowMap } = withTestingData(); + const field = fields.get('checkbox_field'); + const result = groupByField(rows, rowMap, field); + const expectRes = new Map([ + [ + 'Yes', + [ + { id: '1', height: 37 }, + { id: '3', height: 37 }, + { id: '5', height: 37 }, + { id: '7', height: 37 }, + { id: '9', height: 37 }, + ], + ], + [ + 'No', + [ + { id: '2', height: 37 }, + { id: '4', height: 37 }, + { id: '6', height: 37 }, + { id: '8', height: 37 }, + { id: '10', height: 37 }, + ], + ], + ]); + expect(result).toEqual(expectRes); + }); + it('should group by select option field', () => { + const { fields, rowMap } = withTestingData(); + const field = fields.get('single_select_field'); + const result = groupByField(rows, rowMap, field); + const expectRes = new Map([ + [ + '1', + [ + { id: '1', height: 37 }, + { id: '4', height: 37 }, + { id: '7', height: 37 }, + { id: '10', height: 37 }, + ], + ], + [ + '2', + [ + { id: '2', height: 37 }, + { id: '5', height: 37 }, + { id: '8', height: 37 }, + ], + ], + [ + '3', + [ + { id: '3', height: 37 }, + { id: '6', height: 37 }, + { id: '9', height: 37 }, + ], + ], + ]); + expect(result).toEqual(expectRes); + }); + + it('should group by multi select option field', () => { + const { fields, rowMap } = withTestingData(); + const field = fields.get('multi_select_field'); + const result = groupByField(rows, rowMap, field); + const expectRes = new Map([ + [ + '1', + [ + { id: '1', height: 37 }, + { id: '3', height: 37 }, + { id: '5', height: 37 }, + { id: '6', height: 37 }, + { id: '7', height: 37 }, + { id: '9', height: 37 }, + ], + ], + [ + '2', + [ + { id: '1', height: 37 }, + { id: '2', height: 37 }, + { id: '4', height: 37 }, + { id: '5', height: 37 }, + { id: '7', height: 37 }, + { id: '8', height: 37 }, + { id: '10', height: 37 }, + ], + ], + [ + '3', + [ + { id: '2', height: 37 }, + { id: '3', height: 37 }, + { id: '5', height: 37 }, + { id: '6', height: 37 }, + { id: '8', height: 37 }, + { id: '9', height: 37 }, + ], + ], + ]); + expect(result).toEqual(expectRes); + }); + + it('should not group if no options', () => { + const { fields, rowMap } = withTestingData(); + const field = new Y.Map() as YDatabaseField; + const typeOption = new Y.Map() as YDatabaseFieldTypeOption; + const now = Date.now().toString(); + + field.set(YjsDatabaseKey.name, 'Single Select Field'); + field.set(YjsDatabaseKey.id, 'another_single_select_field'); + field.set(YjsDatabaseKey.type, String(FieldType.SingleSelect)); + field.set(YjsDatabaseKey.last_modified, now.valueOf()); + field.set(YjsDatabaseKey.type_option, typeOption); + fields.set('another_single_select_field', field); + expect(groupByField(rows, rowMap, field)).toBeUndefined(); + + const selectTypeOption = new Y.Map() as YMapFieldTypeOption; + + typeOption.set(String(FieldType.SingleSelect), selectTypeOption); + selectTypeOption.set(YjsDatabaseKey.content, JSON.stringify({ disable_color: false, options: [] })); + const expectRes = new Map([['another_single_select_field', rows]]); + expect(groupByField(rows, rowMap, field)).toEqual(expectRes); + }); + + it('should handle empty selected ids', () => { + const { fields, rowMap } = withTestingData(); + const cell = rowMap['1'] + ?.getMap(YjsEditorKey.data_section) + ?.get(YjsEditorKey.database_row) + ?.get(YjsDatabaseKey.cells) + ?.get('single_select_field'); + cell?.set(YjsDatabaseKey.data, null); + + const field = fields.get('single_select_field'); + const result = groupByField(rows, rowMap, field); + expect(result).toEqual( + new Map([ + ['single_select_field', [{ id: '1', height: 37 }]], + [ + '2', + [ + { id: '2', height: 37 }, + { id: '5', height: 37 }, + { id: '8', height: 37 }, + ], + ], + [ + '3', + [ + { id: '3', height: 37 }, + { id: '6', height: 37 }, + { id: '9', height: 37 }, + ], + ], + [ + '1', + [ + { id: '4', height: 37 }, + { id: '7', height: 37 }, + { id: '10', height: 37 }, + ], + ], + ]), + ); + }); +}); diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/parse.test.ts b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/parse.test.ts new file mode 100644 index 0000000000000..52e6df60e7ba3 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/parse.test.ts @@ -0,0 +1,75 @@ +import { parseYDatabaseCellToCell } from '@/application/database-yjs/cell.parse'; +import { expect } from '@jest/globals'; +import { withTestingCheckboxCell, withTestingDateCell } from '@/application/database-yjs/__tests__/withTestingCell'; +import * as Y from 'yjs'; +import { + FieldType, + parseSelectOptionTypeOptions, + parseRelationTypeOption, + parseNumberTypeOptions, +} from '@/application/database-yjs'; +import { YDatabaseField, YDatabaseFieldTypeOption, YjsDatabaseKey } from '@/application/types'; +import { + withNumberTestingField, + withRelationTestingField, +} from '@/application/database-yjs/__tests__/withTestingField'; + +describe('parseYDatabaseCellToCell', () => { + it('should parse a DateTime cell', () => { + const doc = new Y.Doc(); + const cell = withTestingDateCell(); + doc.getMap('cells').set('date_field', cell); + const parsedCell = parseYDatabaseCellToCell(cell); + expect(parsedCell.data).not.toBe(undefined); + expect(parsedCell.createdAt).not.toBe(undefined); + expect(parsedCell.lastModified).not.toBe(undefined); + expect(parsedCell.fieldType).toBe(Number(FieldType.DateTime)); + }); + it('should parse a Checkbox cell', () => { + const doc = new Y.Doc(); + const cell = withTestingCheckboxCell(); + doc.getMap('cells').set('checkbox_field', cell); + const parsedCell = parseYDatabaseCellToCell(cell); + expect(parsedCell.data).toBe(true); + expect(parsedCell.createdAt).not.toBe(undefined); + expect(parsedCell.lastModified).not.toBe(undefined); + expect(parsedCell.fieldType).toBe(Number(FieldType.Checkbox)); + }); +}); + +describe('Select option field parse', () => { + it('should parse select option type options', () => { + const doc = new Y.Doc(); + const field = new Y.Map() as YDatabaseField; + const typeOption = new Y.Map() as YDatabaseFieldTypeOption; + const now = Date.now().toString(); + + field.set(YjsDatabaseKey.name, 'Single Select Field'); + field.set(YjsDatabaseKey.id, 'single_select_field'); + field.set(YjsDatabaseKey.type, String(FieldType.SingleSelect)); + field.set(YjsDatabaseKey.last_modified, now.valueOf()); + field.set(YjsDatabaseKey.type_option, typeOption); + doc.getMap('fields').set('single_select_field', field); + expect(parseSelectOptionTypeOptions(field)).toEqual(null); + }); +}); + +describe('number field parse', () => { + it('should parse number field', () => { + const doc = new Y.Doc(); + const field = withNumberTestingField(); + doc.getMap('fields').set('number_field', field); + expect(parseNumberTypeOptions(field)).toEqual({ + format: 0, + }); + }); +}); + +describe('relation field parse', () => { + it('should parse relation field', () => { + const doc = new Y.Doc(); + const field = withRelationTestingField(); + doc.getMap('fields').set('relation_field', field); + expect(parseRelationTypeOption(field)).toEqual(undefined); + }); +}); diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/selector.test.tsx b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/selector.test.tsx new file mode 100644 index 0000000000000..c4c669079e6e7 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/selector.test.tsx @@ -0,0 +1,267 @@ +import { renderHook } from '@testing-library/react'; +import { + useCellSelector, + useFieldSelector, + useFieldsSelector, + useFilterSelector, + useFiltersSelector, + useGroup, + useGroupsSelector, + usePrimaryFieldId, + useRowDataSelector, + useRowMetaSelector, + useRowOrdersSelector, + useRowsByGroup, + useSortSelector, + useSortsSelector, +} from '../selector'; +import { useDatabaseViewId } from '../context'; +import { DatabaseContextProvider } from '@/components/database/DatabaseContext'; +import { withTestingDatabase } from '@/application/database-yjs/__tests__/withTestingData'; +import { expect } from '@jest/globals'; +import { YDoc, YjsDatabaseKey, YjsEditorKey, YSharedRoot } from '@/application/types'; +import * as Y from 'yjs'; +import { withNumberTestingField, withTestingFields } from '@/application/database-yjs/__tests__/withTestingField'; +import { withTestingRows } from '@/application/database-yjs/__tests__/withTestingRows'; + +const wrapperCreator = + (viewId: string, doc: YDoc, rowDocMap: Record) => + ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); + }; + +describe('Database selector', () => { + let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element; + let rowDocMap: Record; + let doc: YDoc; + + beforeEach(() => { + const data = withTestingDatabase('1'); + + doc = data.doc; + rowDocMap = data.rowDocMap; + wrapper = wrapperCreator('1', doc, rowDocMap); + }); + + it('should select a field', () => { + const { result } = renderHook(() => useFieldSelector('number_field'), { wrapper }); + + const tempDoc = new Y.Doc(); + const field = withNumberTestingField(); + + tempDoc.getMap().set('number_field', field); + + expect(result.current.field?.toJSON()).toEqual(field.toJSON()); + }); + + it('should select all fields', () => { + const { result } = renderHook(() => useFieldsSelector(), { wrapper }); + + expect(result.current.map((item) => item.fieldId)).toEqual(Array.from(withTestingFields().keys())); + }); + + it('should select all filters', () => { + const { result } = renderHook(() => useFiltersSelector(), { wrapper }); + + expect(result.current).toEqual(['filter_multi_select_field']); + }); + + it('should select a filter', () => { + const { result } = renderHook(() => useFilterSelector('filter_multi_select_field'), { wrapper }); + + expect(result.current).toEqual({ + content: '1,3', + condition: 2, + fieldId: 'multi_select_field', + id: 'filter_multi_select_field', + filterType: NaN, + optionIds: ['1', '3'], + }); + }); + + it('should select all sorts', () => { + const { result } = renderHook(() => useSortsSelector(), { wrapper }); + + expect(result.current).toEqual(['sort_asc_text_field']); + }); + + it('should select a sort', () => { + const { result } = renderHook(() => useSortSelector('sort_asc_text_field'), { wrapper }); + + expect(result.current).toEqual({ + fieldId: 'text_field', + id: 'sort_asc_text_field', + condition: 0, + }); + }); + + it('should select all groups', () => { + const { result } = renderHook(() => useGroupsSelector(), { wrapper }); + + expect(result.current).toEqual(['g:single_select_field']); + }); + + it('should select a group', () => { + const { result } = renderHook(() => useGroup('g:single_select_field'), { wrapper }); + + expect(result.current).toEqual({ + fieldId: 'single_select_field', + columns: [ + { + id: '1', + visible: true, + }, + { + id: 'single_select_field', + visible: true, + }, + ], + }); + }); + + it('should select rows by group', () => { + const { result } = renderHook(() => useRowsByGroup('g:single_select_field'), { wrapper }); + + const { fieldId, columns, notFound, groupResult } = result.current; + + expect(fieldId).toEqual('single_select_field'); + expect(columns).toEqual([ + { + id: '1', + visible: true, + }, + { + id: 'single_select_field', + visible: true, + }, + ]); + expect(notFound).toBeFalsy(); + + expect(groupResult).toEqual( + new Map([ + [ + '1', + [ + { id: '1', height: 37 }, + { id: '7', height: 37 }, + ], + ], + [ + '2', + [ + { id: '2', height: 37 }, + { id: '8', height: 37 }, + { id: '5', height: 37 }, + ], + ], + [ + '3', + [ + { id: '9', height: 37 }, + { id: '3', height: 37 }, + { id: '6', height: 37 }, + ], + ], + ]), + ); + }); + + it('should select all row orders', () => { + const { result } = renderHook(() => useRowOrdersSelector(), { wrapper }); + + expect(result.current?.map((item) => item.id).join(',')).toEqual('9,2,3,1,6,8,5,7'); + }); + + it('should select a row data', () => { + const rows = withTestingRows(); + const { result } = renderHook(() => useRowDataSelector(rows[0].id), { wrapper }); + + expect(result.current.row?.toJSON()).toEqual( + rowDocMap[rows[0].id]?.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database_row)?.toJSON(), + ); + }); + + it('should select a cell', () => { + const rows = withTestingRows(); + const { result } = renderHook( + () => + useCellSelector({ + rowId: rows[0].id, + fieldId: 'number_field', + }), + { wrapper }, + ); + + expect(result.current).toEqual({ + createdAt: NaN, + data: 123, + fieldType: 1, + lastModified: NaN, + }); + }); + + it('should select a primary field id', () => { + const { result } = renderHook(() => usePrimaryFieldId(), { wrapper }); + + expect(result.current).toEqual('text_field'); + }); + + it('should select a row meta', () => { + const rows = withTestingRows(); + const { result } = renderHook(() => useRowMetaSelector(rows[0].id), { wrapper }); + + expect(result.current?.documentId).not.toBeNull(); + }); + + it('should select view id', () => { + const { result } = renderHook(() => useDatabaseViewId(), { wrapper }); + + expect(result.current).toEqual('1'); + }); + + it('should select all rows if filter is not found', () => { + const view = (doc.get(YjsEditorKey.data_section) as YSharedRoot) + .get(YjsEditorKey.database) + .get(YjsDatabaseKey.views) + .get('1'); + + view.set(YjsDatabaseKey.filters, new Y.Array()); + + const { result } = renderHook(() => useRowOrdersSelector(), { wrapper }); + + expect(result.current?.map((item) => item.id).join(',')).toEqual('9,2,3,4,1,6,10,8,5,7'); + }); + + it('should select original row orders if sorts is not found', () => { + const view = (doc.get(YjsEditorKey.data_section) as YSharedRoot) + .get(YjsEditorKey.database) + .get(YjsDatabaseKey.views) + .get('1'); + + view.set(YjsDatabaseKey.sorts, new Y.Array()); + + const { result } = renderHook(() => useRowOrdersSelector(), { wrapper }); + + expect(result.current?.map((item) => item.id).join(',')).toEqual('1,2,3,5,6,7,8,9'); + }); + + it('should select all rows if filters and sorts are not found', () => { + const view = (doc.get(YjsEditorKey.data_section) as YSharedRoot) + .get(YjsEditorKey.database) + .get(YjsDatabaseKey.views) + .get('1'); + + view.set(YjsDatabaseKey.filters, new Y.Array()); + view.set(YjsDatabaseKey.sorts, new Y.Array()); + + const { result } = renderHook(() => useRowOrdersSelector(), { wrapper }); + + expect(result.current?.map((item) => item.id).join(',')).toEqual('1,2,3,4,5,6,7,8,9,10'); + }); +}); diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/sort.test.ts b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/sort.test.ts new file mode 100644 index 0000000000000..ce41777e265eb --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/sort.test.ts @@ -0,0 +1,397 @@ +import { Row } from '@/application/database-yjs'; +import { withTestingData } from '@/application/database-yjs/__tests__/withTestingData'; +import { withTestingRows } from '@/application/database-yjs/__tests__/withTestingRows'; +import { + withCheckboxSort, + withChecklistSort, + withCreatedAtSort, + withDateTimeSort, + withLastModifiedSort, + withMultiSelectOptionSort, + withNumberSort, + withRichTextSort, + withSingleSelectOptionSort, + withUrlSort, +} from '@/application/database-yjs/__tests__/withTestingSorts'; +import { + withCheckboxTestingField, + withDateTimeTestingField, + withNumberTestingField, + withRichTextTestingField, + withSelectOptionTestingField, + withURLTestingField, + withChecklistTestingField, + withRelationTestingField, +} from './withTestingField'; +import { sortBy, parseCellDataForSort } from '../sort'; +import * as Y from 'yjs'; +import { expect } from '@jest/globals'; +import { RowId, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; + +describe('parseCellDataForSort', () => { + it('should parse data correctly based on field type', () => { + const doc = new Y.Doc(); + const field = withNumberTestingField(); + doc.getMap().set('field', field); + const data = 42; + + const result = parseCellDataForSort(field, data); + + expect(result).toEqual(data); + }); + + it('should return default value for empty rich text', () => { + const doc = new Y.Doc(); + const field = withRichTextTestingField(); + doc.getMap().set('field', field); + const data = ''; + + const result = parseCellDataForSort(field, data); + + expect(result).toEqual('\uFFFF'); + }); + + it('should return default value for empty URL', () => { + const doc = new Y.Doc(); + const field = withURLTestingField(); + doc.getMap().set('field', field); + const data = ''; + + const result = parseCellDataForSort(field, data); + + expect(result).toBe('\uFFFF'); + }); + + it('should return data for non-empty rich text', () => { + const doc = new Y.Doc(); + const field = withRichTextTestingField(); + doc.getMap().set('field', field); + const data = 'Hello, world!'; + + const result = parseCellDataForSort(field, data); + + expect(result).toBe(data); + }); + + it('should parse checkbox data correctly', () => { + const doc = new Y.Doc(); + const field = withCheckboxTestingField(); + doc.getMap().set('field', field); + const data = 'Yes'; + + const result = parseCellDataForSort(field, data); + + expect(result).toBe(true); + + const noData = 'No'; + const noResult = parseCellDataForSort(field, noData); + expect(noResult).toBe(false); + }); + + it('should parse DateTime data correctly', () => { + const doc = new Y.Doc(); + const field = withDateTimeTestingField(); + doc.getMap().set('field', field); + const data = '1633046400000'; + + const result = parseCellDataForSort(field, data); + + expect(result).toBe(Number(data)); + }); + + it('should parse SingleSelect data correctly', () => { + const doc = new Y.Doc(); + const field = withSelectOptionTestingField(); + doc.getMap().set('field', field); + const data = '1'; + + const result = parseCellDataForSort(field, data); + + expect(result).toBe('Option 1'); + }); + + it('should parse MultiSelect data correctly', () => { + const doc = new Y.Doc(); + const field = withSelectOptionTestingField(); + doc.getMap().set('field', field); + const data = '1,2'; + + const result = parseCellDataForSort(field, data); + + expect(result).toBe('Option 1, Option 2'); + }); + + it('should parse Checklist data correctly', () => { + const doc = new Y.Doc(); + const field = withChecklistTestingField(); + doc.getMap().set('field', field); + const data = '[]'; + + const result = parseCellDataForSort(field, data); + + expect(result).toBe(0); + }); + + it('should return empty string for Relation field', () => { + const doc = new Y.Doc(); + const field = withRelationTestingField(); + doc.getMap().set('field', field); + const data = ''; + + const result = parseCellDataForSort(field, data); + + expect(result).toBe(''); + }); +}); + +describe('Database sortBy', () => { + let rows: Row[]; + + beforeEach(() => { + rows = withTestingRows(); + }); + + it('should not sort rows if no sort is provided', () => { + const { sorts, fields, rowMap } = withTestingData(); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); + }); + + it('should not sort rows if no rows are provided', () => { + const { sorts, fields } = withTestingData(); + const rowMap: Record = {}; + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); + }); + + it('should return default data if rowMeta is not found', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withNumberSort(); + sorts.push([sort]); + delete rowMap['1']; + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); + }); + + it('should return default data if cell is not found', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withNumberSort(); + sorts.push([sort]); + const rowDoc = rowMap['1']; + rowDoc + ?.getMap(YjsEditorKey.data_section) + .get(YjsEditorKey.database_row) + ?.get(YjsDatabaseKey.cells) + .delete('number_field'); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); + }); + + it('should sort by number field in ascending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withNumberSort(); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); + }); + + it('should sort by number field in descending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withNumberSort(false); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('10,9,8,7,6,5,4,3,2,1'); + }); + + it('should sort by rich text field in ascending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withRichTextSort(); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('9,2,3,4,1,6,10,8,5,7'); + }); + + it('should sort by rich text field in descending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withRichTextSort(false); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('7,5,8,10,6,1,4,3,2,9'); + }); + + it('should sort by url field in ascending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withUrlSort(); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('1,10,2,3,4,5,6,7,8,9'); + }); + + it('should sort by url field in descending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withUrlSort(false); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('9,8,7,6,5,4,3,2,10,1'); + }); + + it('should sort by checkbox field in ascending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withCheckboxSort(); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('2,4,6,8,10,1,3,5,7,9'); + }); + + it('should sort by checkbox field in descending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withCheckboxSort(false); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('1,3,5,7,9,2,4,6,8,10'); + }); + + it('should sort by DateTime field in ascending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withDateTimeSort(); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); + }); + + it('should sort by DateTime field in descending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withDateTimeSort(false); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('10,9,8,7,6,5,4,3,2,1'); + }); + + it('should sort by SingleSelect field in ascending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withSingleSelectOptionSort(); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('1,4,7,10,2,5,8,3,6,9'); + }); + + it('should sort by SingleSelect field in descending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withSingleSelectOptionSort(false); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('3,6,9,2,5,8,1,4,7,10'); + }); + + it('should sort by MultiSelect field in ascending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withMultiSelectOptionSort(); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('1,7,5,3,6,9,4,10,2,8'); + }); + + it('should sort by MultiSelect field in descending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withMultiSelectOptionSort(false); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('2,8,4,10,3,6,9,5,1,7'); + }); + + it('should sort by Checklist field in ascending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withChecklistSort(); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('4,10,1,2,5,6,7,8,3,9'); + }); + + it('should sort by Checklist field in descending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withChecklistSort(false); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('3,9,1,2,5,6,7,8,4,10'); + }); + + it('should sort by CreatedAt field in ascending order', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withCreatedAtSort(); + sorts.push([sort]); + + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); + }); + + it('should sort by LastEditedTime field', () => { + const { sorts, fields, rowMap } = withTestingData(); + const sort = withLastModifiedSort(); + sorts.push([sort]); + const sortedRows = sortBy(rows, sorts, fields, rowMap) + .map((row) => row.id) + .join(','); + expect(sortedRows).toBe('1,2,3,4,5,6,7,8,9,10'); + }); +}); diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingCell.ts b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingCell.ts new file mode 100644 index 0000000000000..de0f7d68e372b --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingCell.ts @@ -0,0 +1,43 @@ +import * as Y from 'yjs'; +import { YDatabaseCell, YjsDatabaseKey } from '@/application/types'; +import { FieldType } from '@/application/database-yjs'; + +export function withTestingDateCell() { + const cell = new Y.Map() as YDatabaseCell; + + cell.set(YjsDatabaseKey.id, 'date_field'); + cell.set(YjsDatabaseKey.data, Date.now()); + cell.set(YjsDatabaseKey.field_type, Number(FieldType.DateTime)); + cell.set(YjsDatabaseKey.created_at, Date.now()); + cell.set(YjsDatabaseKey.last_modified, Date.now()); + cell.set(YjsDatabaseKey.end_timestamp, Date.now() + 1000); + cell.set(YjsDatabaseKey.include_time, true); + cell.set(YjsDatabaseKey.is_range, true); + cell.set(YjsDatabaseKey.reminder_id, 'reminderId'); + + return cell; +} + +export function withTestingCheckboxCell() { + const cell = new Y.Map() as YDatabaseCell; + + cell.set(YjsDatabaseKey.id, 'checkbox_field'); + cell.set(YjsDatabaseKey.data, 'Yes'); + cell.set(YjsDatabaseKey.field_type, Number(FieldType.Checkbox)); + cell.set(YjsDatabaseKey.created_at, Date.now()); + cell.set(YjsDatabaseKey.last_modified, Date.now()); + + return cell; +} + +export function withTestingSingleOptionCell() { + const cell = new Y.Map() as YDatabaseCell; + + cell.set(YjsDatabaseKey.id, 'single_select_field'); + cell.set(YjsDatabaseKey.data, 'optionId'); + cell.set(YjsDatabaseKey.field_type, Number(FieldType.SingleSelect)); + cell.set(YjsDatabaseKey.created_at, Date.now()); + cell.set(YjsDatabaseKey.last_modified, Date.now()); + + return cell; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingData.ts b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingData.ts new file mode 100644 index 0000000000000..282590eb35486 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingData.ts @@ -0,0 +1,181 @@ +import { + RowId, + YDatabase, + YDatabaseFields, + YDatabaseFilters, + YDatabaseGroup, + YDatabaseGroupColumn, + YDatabaseGroupColumns, + YDatabaseLayoutSettings, + YDatabaseSorts, + YDatabaseView, + YDatabaseViews, + YDoc, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/types'; +import { withTestingFields } from '@/application/database-yjs/__tests__/withTestingField'; +import { + withTestingRowData, + withTestingRowDataMap, + withTestingRows, +} from '@/application/database-yjs/__tests__/withTestingRows'; +import * as Y from 'yjs'; +import { withMultiSelectOptionFilter } from '@/application/database-yjs/__tests__/withTestingFilters'; +import { withRichTextSort } from '@/application/database-yjs/__tests__/withTestingSorts'; +import { metaIdFromRowId, RowMetaKey } from '@/application/database-yjs'; + +export function withTestingData () { + const doc = new Y.Doc(); + const sharedRoot = doc.getMap(); + const fields = withTestingFields() as YDatabaseFields; + + sharedRoot.set('fields', fields); + + const rowMap = withTestingRowDataMap(); + + sharedRoot.set('rows', rowMap); + + const sorts = new Y.Array() as YDatabaseSorts; + + sharedRoot.set('sorts', sorts); + + const filters = new Y.Array() as YDatabaseFilters; + + sharedRoot.set('filters', filters); + + return { + fields, + rowMap, + sorts, + filters, + doc, + }; +} + +export function withTestingDatabase (viewId: string) { + const doc = new Y.Doc(); + const sharedRoot = doc.getMap(YjsEditorKey.data_section); + const database = new Y.Map() as YDatabase; + + sharedRoot.set(YjsEditorKey.database, database); + + const fields = withTestingFields() as YDatabaseFields; + + database.set(YjsDatabaseKey.fields, fields); + database.set(YjsDatabaseKey.id, viewId); + + const metas = new Y.Map(); + + database.set(YjsDatabaseKey.metas, metas); + metas.set(YjsDatabaseKey.iid, viewId); + + const views = new Y.Map() as YDatabaseViews; + + database.set(YjsDatabaseKey.views, views); + + const view = new Y.Map() as YDatabaseView; + + views.set('1', view); + view.set(YjsDatabaseKey.id, viewId); + view.set(YjsDatabaseKey.layout, 0); + view.set(YjsDatabaseKey.name, 'View 1'); + view.set(YjsDatabaseKey.database_id, viewId); + + const layoutSetting = new Y.Map() as YDatabaseLayoutSettings; + + const calendarSetting = new Y.Map(); + + calendarSetting.set(YjsDatabaseKey.field_id, 'date_field'); + layoutSetting.set('2', calendarSetting); + + view.set(YjsDatabaseKey.layout_settings, layoutSetting); + + const filters = new Y.Array() as YDatabaseFilters; + const filter = withMultiSelectOptionFilter(); + + filters.push([filter]); + + const sorts = new Y.Array() as YDatabaseSorts; + const sort = withRichTextSort(); + + sorts.push([sort]); + + const groups = new Y.Array(); + const group = new Y.Map() as YDatabaseGroup; + + groups.push([group]); + group.set(YjsDatabaseKey.id, 'g:single_select_field'); + group.set(YjsDatabaseKey.field_id, 'single_select_field'); + group.set(YjsDatabaseKey.type, '3'); + group.set(YjsDatabaseKey.content, ''); + + const groupColumns = new Y.Array() as YDatabaseGroupColumns; + + group.set(YjsDatabaseKey.groups, groupColumns); + + const column1 = new Y.Map() as YDatabaseGroupColumn; + const column2 = new Y.Map() as YDatabaseGroupColumn; + + column1.set(YjsDatabaseKey.id, '1'); + column1.set(YjsDatabaseKey.visible, true); + column2.set(YjsDatabaseKey.id, 'single_select_field'); + column2.set(YjsDatabaseKey.visible, true); + + groupColumns.push([column1]); + groupColumns.push([column2]); + + view.set(YjsDatabaseKey.filters, filters); + view.set(YjsDatabaseKey.sorts, sorts); + view.set(YjsDatabaseKey.groups, groups); + + const fieldSettings = new Y.Map(); + const fieldOrder = new Y.Array(); + const rowOrders = new Y.Array(); + + fields.forEach((field) => { + const setting = new Y.Map(); + + const fieldId = field.get(YjsDatabaseKey.id); + + if (fieldId === 'text_field') { + field.set(YjsDatabaseKey.is_primary, true); + } + + fieldOrder.push([fieldId]); + fieldSettings.set(fieldId, setting); + setting.set(YjsDatabaseKey.visibility, 0); + }); + const rows = withTestingRows(); + + rows.forEach(({ id, height }) => { + const row = new Y.Map(); + + row.set(YjsDatabaseKey.id, id); + row.set(YjsDatabaseKey.height, height); + rowOrders.push([row]); + }); + + view.set(YjsDatabaseKey.field_settings, fieldSettings); + view.set(YjsDatabaseKey.field_orders, fieldOrder); + view.set(YjsDatabaseKey.row_orders, rowOrders); + + const rowMap: Record = {}; + + rows.forEach((row, index) => { + const rowDoc = new Y.Doc(); + const rowData = withTestingRowData(row.id, index); + const rowMeta = new Y.Map(); + const parser = metaIdFromRowId('281e76fb-712e-59e2-8370-678bf0788355'); + + rowMeta.set(parser(RowMetaKey.IconId), '😊'); + rowDoc.getMap(YjsEditorKey.data_section).set(YjsEditorKey.meta, rowMeta); + rowDoc.getMap(YjsEditorKey.data_section).set(YjsEditorKey.database_row, rowData); + rowMap[row.id] = rowDoc; + }); + + return { + rowDocMap: rowMap, + doc: doc as YDoc, + }; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingField.ts b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingField.ts new file mode 100644 index 0000000000000..e9329f341df6f --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingField.ts @@ -0,0 +1,204 @@ +import { + YDatabaseField, + YDatabaseFieldTypeOption, + YjsDatabaseKey, + YMapFieldTypeOption, +} from '@/application/types'; +import { FieldType } from '@/application/database-yjs'; +import { SelectOptionColor } from '@/application/database-yjs/fields/select-option'; +import * as Y from 'yjs'; + +export function withTestingFields() { + const fields = new Y.Map(); + const textField = withRichTextTestingField(); + + fields.set('text_field', textField); + const numberField = withNumberTestingField(); + + fields.set('number_field', numberField); + + const checkboxField = withCheckboxTestingField(); + + fields.set('checkbox_field', checkboxField); + + const dateTimeField = withDateTimeTestingField(); + + fields.set('date_field', dateTimeField); + + const singleSelectField = withSelectOptionTestingField(); + + fields.set('single_select_field', singleSelectField); + const multipleSelectField = withSelectOptionTestingField(true); + + fields.set('multi_select_field', multipleSelectField); + + const urlField = withURLTestingField(); + + fields.set('url_field', urlField); + + const checklistField = withChecklistTestingField(); + + fields.set('checklist_field', checklistField); + + const createdAtField = withCreatedAtTestingField(); + + fields.set('created_at_field', createdAtField); + + const lastModifiedField = withLastModifiedTestingField(); + + fields.set('last_modified_field', lastModifiedField); + + return fields; +} + +export function withRichTextTestingField() { + const field = new Y.Map() as YDatabaseField; + const now = Date.now().toString(); + + field.set(YjsDatabaseKey.name, 'Rich Text Field'); + field.set(YjsDatabaseKey.id, 'text_field'); + field.set(YjsDatabaseKey.type, String(FieldType.RichText)); + field.set(YjsDatabaseKey.last_modified, now.valueOf()); + + return field; +} + +export function withNumberTestingField() { + const field = new Y.Map() as YDatabaseField; + + field.set(YjsDatabaseKey.name, 'Number Field'); + field.set(YjsDatabaseKey.id, 'number_field'); + field.set(YjsDatabaseKey.type, String(FieldType.Number)); + const typeOption = new Y.Map() as YDatabaseFieldTypeOption; + + const numberTypeOption = new Y.Map() as YMapFieldTypeOption; + + typeOption.set(String(FieldType.Number), numberTypeOption); + numberTypeOption.set(YjsDatabaseKey.format, '0'); + field.set(YjsDatabaseKey.type_option, typeOption); + + return field; +} + +export function withRelationTestingField() { + const field = new Y.Map() as YDatabaseField; + const typeOption = new Y.Map() as YDatabaseFieldTypeOption; + const now = Date.now().toString(); + + field.set(YjsDatabaseKey.name, 'Relation Field'); + field.set(YjsDatabaseKey.id, 'relation_field'); + field.set(YjsDatabaseKey.type, String(FieldType.Relation)); + field.set(YjsDatabaseKey.last_modified, now.valueOf()); + field.set(YjsDatabaseKey.type_option, typeOption); + + return field; +} + +export function withCheckboxTestingField() { + const field = new Y.Map() as YDatabaseField; + const now = Date.now().toString(); + + field.set(YjsDatabaseKey.name, 'Checkbox Field'); + field.set(YjsDatabaseKey.id, 'checkbox_field'); + field.set(YjsDatabaseKey.type, String(FieldType.Checkbox)); + field.set(YjsDatabaseKey.last_modified, now.valueOf()); + + return field; +} + +export function withDateTimeTestingField() { + const field = new Y.Map() as YDatabaseField; + const typeOption = new Y.Map() as YDatabaseFieldTypeOption; + const now = Date.now().toString(); + + field.set(YjsDatabaseKey.name, 'DateTime Field'); + field.set(YjsDatabaseKey.id, 'date_field'); + field.set(YjsDatabaseKey.type, String(FieldType.DateTime)); + field.set(YjsDatabaseKey.last_modified, now.valueOf()); + field.set(YjsDatabaseKey.type_option, typeOption); + + const dateTypeOption = new Y.Map() as YMapFieldTypeOption; + + typeOption.set(String(FieldType.DateTime), dateTypeOption); + + dateTypeOption.set(YjsDatabaseKey.time_format, '0'); + dateTypeOption.set(YjsDatabaseKey.date_format, '0'); + return field; +} + +export function withURLTestingField() { + const field = new Y.Map() as YDatabaseField; + const now = Date.now().toString(); + + field.set(YjsDatabaseKey.name, 'URL Field'); + field.set(YjsDatabaseKey.id, 'url_field'); + field.set(YjsDatabaseKey.type, String(FieldType.URL)); + field.set(YjsDatabaseKey.last_modified, now.valueOf()); + + return field; +} + +export function withSelectOptionTestingField(isMultiple = false) { + const field = new Y.Map() as YDatabaseField; + const typeOption = new Y.Map() as YDatabaseFieldTypeOption; + const now = Date.now().toString(); + + field.set(YjsDatabaseKey.name, 'Single Select Field'); + field.set(YjsDatabaseKey.id, isMultiple ? 'multi_select_field' : 'single_select_field'); + field.set(YjsDatabaseKey.type, String(FieldType.SingleSelect)); + field.set(YjsDatabaseKey.last_modified, now.valueOf()); + field.set(YjsDatabaseKey.type_option, typeOption); + + const selectTypeOption = new Y.Map() as YMapFieldTypeOption; + + typeOption.set(String(FieldType.SingleSelect), selectTypeOption); + + selectTypeOption.set( + YjsDatabaseKey.content, + JSON.stringify({ + disable_color: false, + options: [ + { id: '1', name: 'Option 1', color: SelectOptionColor.Purple }, + { id: '2', name: 'Option 2', color: SelectOptionColor.Pink }, + { id: '3', name: 'Option 3', color: SelectOptionColor.LightPink }, + ], + }) + ); + return field; +} + +export function withChecklistTestingField() { + const field = new Y.Map() as YDatabaseField; + const now = Date.now().toString(); + + field.set(YjsDatabaseKey.name, 'Checklist Field'); + field.set(YjsDatabaseKey.id, 'checklist_field'); + field.set(YjsDatabaseKey.type, String(FieldType.Checklist)); + field.set(YjsDatabaseKey.last_modified, now.valueOf()); + + return field; +} + +export function withCreatedAtTestingField() { + const field = new Y.Map() as YDatabaseField; + const now = Date.now().toString(); + + field.set(YjsDatabaseKey.name, 'Created At Field'); + field.set(YjsDatabaseKey.id, 'created_at_field'); + field.set(YjsDatabaseKey.type, String(FieldType.CreatedTime)); + field.set(YjsDatabaseKey.last_modified, now.valueOf()); + + return field; +} + +export function withLastModifiedTestingField() { + const field = new Y.Map() as YDatabaseField; + const now = Date.now().toString(); + + field.set(YjsDatabaseKey.name, 'Last Modified Field'); + field.set(YjsDatabaseKey.id, 'last_modified_field'); + field.set(YjsDatabaseKey.type, String(FieldType.LastEditedTime)); + field.set(YjsDatabaseKey.last_modified, now.valueOf()); + + return field; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingFilters.ts b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingFilters.ts new file mode 100644 index 0000000000000..57a64402f87d3 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingFilters.ts @@ -0,0 +1,83 @@ +import { YDatabaseFilter, YjsDatabaseKey } from '@/application/types'; +import * as Y from 'yjs'; +import * as filtersJson from './fixtures/filters.json'; + +export function withRichTextFilter() { + const filter = new Y.Map() as YDatabaseFilter; + + filter.set(YjsDatabaseKey.id, 'filter_text_field'); + filter.set(YjsDatabaseKey.field_id, filtersJson.filter_text_field.field_id); + filter.set(YjsDatabaseKey.condition, filtersJson.filter_text_field.condition); + filter.set(YjsDatabaseKey.content, filtersJson.filter_text_field.content); + return filter; +} + +export function withUrlFilter() { + const filter = new Y.Map() as YDatabaseFilter; + + filter.set(YjsDatabaseKey.id, 'filter_url_field'); + filter.set(YjsDatabaseKey.field_id, filtersJson.filter_url_field.field_id); + filter.set(YjsDatabaseKey.condition, filtersJson.filter_url_field.condition); + filter.set(YjsDatabaseKey.content, filtersJson.filter_url_field.content); + return filter; +} + +export function withNumberFilter() { + const filter = new Y.Map() as YDatabaseFilter; + + filter.set(YjsDatabaseKey.id, 'filter_number_field'); + filter.set(YjsDatabaseKey.field_id, filtersJson.filter_number_field.field_id); + filter.set(YjsDatabaseKey.condition, filtersJson.filter_number_field.condition); + filter.set(YjsDatabaseKey.content, filtersJson.filter_number_field.content); + return filter; +} + +export function withCheckboxFilter() { + const filter = new Y.Map() as YDatabaseFilter; + + filter.set(YjsDatabaseKey.id, 'filter_checkbox_field'); + filter.set(YjsDatabaseKey.field_id, filtersJson.filter_checkbox_field.field_id); + filter.set(YjsDatabaseKey.condition, filtersJson.filter_checkbox_field.condition); + filter.set(YjsDatabaseKey.content, ''); + return filter; +} + +export function withChecklistFilter() { + const filter = new Y.Map() as YDatabaseFilter; + + filter.set(YjsDatabaseKey.id, 'filter_checklist_field'); + filter.set(YjsDatabaseKey.field_id, filtersJson.filter_checklist_field.field_id); + filter.set(YjsDatabaseKey.condition, filtersJson.filter_checklist_field.condition); + filter.set(YjsDatabaseKey.content, ''); + return filter; +} + +export function withSingleSelectOptionFilter() { + const filter = new Y.Map() as YDatabaseFilter; + + filter.set(YjsDatabaseKey.id, 'filter_single_select_field'); + filter.set(YjsDatabaseKey.field_id, filtersJson.filter_single_select_field.field_id); + filter.set(YjsDatabaseKey.condition, filtersJson.filter_single_select_field.condition); + filter.set(YjsDatabaseKey.content, filtersJson.filter_single_select_field.content); + return filter; +} + +export function withMultiSelectOptionFilter() { + const filter = new Y.Map() as YDatabaseFilter; + + filter.set(YjsDatabaseKey.id, 'filter_multi_select_field'); + filter.set(YjsDatabaseKey.field_id, filtersJson.filter_multi_select_field.field_id); + filter.set(YjsDatabaseKey.condition, filtersJson.filter_multi_select_field.condition); + filter.set(YjsDatabaseKey.content, filtersJson.filter_multi_select_field.content); + return filter; +} + +export function withDateTimeFilter() { + const filter = new Y.Map() as YDatabaseFilter; + + filter.set(YjsDatabaseKey.id, 'filter_date_field'); + filter.set(YjsDatabaseKey.field_id, filtersJson.filter_date_field.field_id); + filter.set(YjsDatabaseKey.condition, filtersJson.filter_date_field.condition); + filter.set(YjsDatabaseKey.content, filtersJson.filter_date_field.content); + return filter; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingRows.ts b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingRows.ts new file mode 100644 index 0000000000000..bffaccf28a685 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingRows.ts @@ -0,0 +1,97 @@ +import { + RowId, + YDatabaseCell, + YDatabaseCells, + YDatabaseRow, + YDoc, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/types'; +import { FieldType, Row } from '@/application/database-yjs'; +import * as Y from 'yjs'; +import * as rowsJson from './fixtures/rows.json'; + +export function withTestingRows (): Row[] { + return rowsJson.map((row) => { + return { + id: row.id, + height: 37, + }; + }); +} + +export function withTestingRowDataMap (): Record { + const folder: Record = {}; + const rows = withTestingRows(); + + rows.forEach((row, index) => { + const rowDoc = new Y.Doc(); + const rowData = withTestingRowData(row.id, index); + + rowDoc.getMap(YjsEditorKey.data_section).set(YjsEditorKey.database_row, rowData); + folder[row.id] = rowDoc; + }); + + return folder; +} + +export function withTestingRowData (id: string, index: number) { + const rowData = new Y.Map() as YDatabaseRow; + + rowData.set(YjsDatabaseKey.id, id); + rowData.set(YjsDatabaseKey.height, 37); + rowData.set(YjsDatabaseKey.last_modified, Date.now() + index * 1000); + rowData.set(YjsDatabaseKey.created_at, Date.now() + index * 1000); + + const cells = new Y.Map() as YDatabaseCells; + + const textFieldCell = withTestingCell(rowsJson[index].cells.text_field.data); + + textFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.RichText)); + cells.set('text_field', textFieldCell); + + const numberFieldCell = withTestingCell(rowsJson[index].cells.number_field.data); + + numberFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.Number)); + cells.set('number_field', numberFieldCell); + + const checkboxFieldCell = withTestingCell(rowsJson[index].cells.checkbox_field.data); + + checkboxFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.Checkbox)); + cells.set('checkbox_field', checkboxFieldCell); + + const dateTimeFieldCell = withTestingCell(rowsJson[index].cells.date_field.data); + + dateTimeFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.DateTime)); + cells.set('date_field', dateTimeFieldCell); + + const urlFieldCell = withTestingCell(rowsJson[index].cells.url_field.data); + + urlFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.URL)); + cells.set('url_field', urlFieldCell); + + const singleSelectFieldCell = withTestingCell(rowsJson[index].cells.single_select_field.data); + + singleSelectFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.SingleSelect)); + cells.set('single_select_field', singleSelectFieldCell); + + const multiSelectFieldCell = withTestingCell(rowsJson[index].cells.multi_select_field.data); + + multiSelectFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.MultiSelect)); + cells.set('multi_select_field', multiSelectFieldCell); + + const checlistFieldCell = withTestingCell(rowsJson[index].cells.checklist_field.data); + + checlistFieldCell.set(YjsDatabaseKey.field_type, Number(FieldType.Checklist)); + cells.set('checklist_field', checlistFieldCell); + + rowData.set(YjsDatabaseKey.cells, cells); + return rowData; +} + +export function withTestingCell (cellData: string | number) { + const cell = new Y.Map() as YDatabaseCell; + + cell.set(YjsDatabaseKey.data, cellData); + return cell; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingSorts.ts b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingSorts.ts new file mode 100644 index 0000000000000..d9421d5e7c3cb --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/__tests__/withTestingSorts.ts @@ -0,0 +1,113 @@ +import { YDatabaseSort, YjsDatabaseKey } from '@/application/types'; +import * as Y from 'yjs'; +import * as sortsJson from './fixtures/sorts.json'; + +export function withRichTextSort(isAscending: boolean = true) { + const sort = new Y.Map() as YDatabaseSort; + const sortJSON = isAscending ? sortsJson.sort_asc_text_field : sortsJson.sort_desc_text_field; + + sort.set(YjsDatabaseKey.id, sortJSON.id); + sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); + sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); + + return sort; +} + +export function withUrlSort(isAscending: boolean = true) { + const sort = new Y.Map() as YDatabaseSort; + const sortJSON = isAscending ? sortsJson.sort_asc_url_field : sortsJson.sort_desc_url_field; + + sort.set(YjsDatabaseKey.id, sortJSON.id); + sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); + sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); + + return sort; +} + +export function withNumberSort(isAscending: boolean = true) { + const sort = new Y.Map() as YDatabaseSort; + const sortJSON = isAscending ? sortsJson.sort_asc_number_field : sortsJson.sort_desc_number_field; + + sort.set(YjsDatabaseKey.id, sortJSON.id); + sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); + sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); + + return sort; +} + +export function withCheckboxSort(isAscending: boolean = true) { + const sort = new Y.Map() as YDatabaseSort; + const sortJSON = isAscending ? sortsJson.sort_asc_checkbox_field : sortsJson.sort_desc_checkbox_field; + + sort.set(YjsDatabaseKey.id, sortJSON.id); + sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); + sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); + + return sort; +} + +export function withDateTimeSort(isAscending: boolean = true) { + const sort = new Y.Map() as YDatabaseSort; + const sortJSON = isAscending ? sortsJson.sort_asc_date_field : sortsJson.sort_desc_date_field; + + sort.set(YjsDatabaseKey.id, sortJSON.id); + sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); + sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); + + return sort; +} + +export function withSingleSelectOptionSort(isAscending: boolean = true) { + const sort = new Y.Map() as YDatabaseSort; + const sortJSON = isAscending ? sortsJson.sort_asc_single_select_field : sortsJson.sort_desc_single_select_field; + + sort.set(YjsDatabaseKey.id, sortJSON.id); + sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); + sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); + + return sort; +} + +export function withMultiSelectOptionSort(isAscending: boolean = true) { + const sort = new Y.Map() as YDatabaseSort; + const sortJSON = isAscending ? sortsJson.sort_asc_multi_select_field : sortsJson.sort_desc_multi_select_field; + + sort.set(YjsDatabaseKey.id, sortJSON.id); + sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); + sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); + + return sort; +} + +export function withChecklistSort(isAscending: boolean = true) { + const sort = new Y.Map() as YDatabaseSort; + const sortJSON = isAscending ? sortsJson.sort_asc_checklist_field : sortsJson.sort_desc_checklist_field; + + sort.set(YjsDatabaseKey.id, sortJSON.id); + sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); + sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); + + return sort; +} + +export function withCreatedAtSort(isAscending: boolean = true) { + const sort = new Y.Map() as YDatabaseSort; + const sortJSON = isAscending ? sortsJson.sort_asc_created_at : sortsJson.sort_desc_created_at; + + sort.set(YjsDatabaseKey.id, sortJSON.id); + sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); + sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); + + return sort; +} + +export function withLastModifiedSort(isAscending: boolean = true) { + const sort = new Y.Map() as YDatabaseSort; + const sortJSON = isAscending ? sortsJson.sort_asc_updated_at : sortsJson.sort_desc_updated_at; + + sort.set(YjsDatabaseKey.id, sortJSON.id); + sort.set(YjsDatabaseKey.field_id, sortJSON.field_id); + sort.set(YjsDatabaseKey.condition, sortJSON.condition === 'asc' ? '0' : '1'); + + return sort; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/cell.parse.ts b/frontend/appflowy_web_app/src/application/database-yjs/cell.parse.ts new file mode 100644 index 0000000000000..97bc5ca10d7a4 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/cell.parse.ts @@ -0,0 +1,62 @@ +import { YDatabaseCell, YjsDatabaseKey } from '@/application/types'; +import { FieldType } from '@/application/database-yjs/database.type'; +import { YArray } from 'yjs/dist/src/types/YArray'; +import { Cell, CheckboxCell, DateTimeCell, FileMediaCell, FileMediaCellData } from './cell.type'; + +export function parseYDatabaseCommonCellToCell (cell: YDatabaseCell): Cell { + return { + createdAt: Number(cell.get(YjsDatabaseKey.created_at)), + lastModified: Number(cell.get(YjsDatabaseKey.last_modified)), + fieldType: parseInt(cell.get(YjsDatabaseKey.field_type)) as FieldType, + data: cell.get(YjsDatabaseKey.data), + }; +} + +export function parseYDatabaseCellToCell (cell: YDatabaseCell): Cell { + const fieldType = parseInt(cell.get(YjsDatabaseKey.field_type)); + + if (fieldType === FieldType.DateTime) { + return parseYDatabaseDateTimeCellToCell(cell); + } + + if (fieldType === FieldType.Checkbox) { + return parseYDatabaseCheckboxCellToCell(cell); + } + + if (fieldType === FieldType.FileMedia) { + return parseYDatabaseFileMediaCellToCell(cell); + } + + return parseYDatabaseCommonCellToCell(cell); +} + +export function parseYDatabaseDateTimeCellToCell (cell: YDatabaseCell): DateTimeCell { + return { + ...parseYDatabaseCommonCellToCell(cell), + data: cell.get(YjsDatabaseKey.data) as string, + fieldType: FieldType.DateTime, + endTimestamp: cell.get(YjsDatabaseKey.end_timestamp), + includeTime: cell.get(YjsDatabaseKey.include_time), + isRange: cell.get(YjsDatabaseKey.is_range), + reminderId: cell.get(YjsDatabaseKey.reminder_id), + }; +} + +export function parseYDatabaseFileMediaCellToCell (cell: YDatabaseCell): FileMediaCell { + const data = cell.get(YjsDatabaseKey.data) as YArray; + const dataJson = data.toJSON().map((item: string) => JSON.parse(item)) as FileMediaCellData; + + return { + ...parseYDatabaseCommonCellToCell(cell), + data: dataJson, + fieldType: FieldType.FileMedia, + }; +} + +export function parseYDatabaseCheckboxCellToCell (cell: YDatabaseCell): CheckboxCell { + return { + ...parseYDatabaseCommonCellToCell(cell), + data: cell.get(YjsDatabaseKey.data) === 'Yes', + fieldType: FieldType.Checkbox, + }; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/cell.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/cell.type.ts new file mode 100644 index 0000000000000..67b991dc0345c --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/cell.type.ts @@ -0,0 +1,113 @@ +import { FieldId, RowId } from '@/application/types'; +import { DateFormat, TimeFormat } from '@/application/database-yjs/index'; +import { FieldType } from '@/application/database-yjs/database.type'; +import React from 'react'; +import { YArray } from 'yjs/dist/src/types/YArray'; + +export interface Cell { + createdAt: number; + lastModified: number; + fieldType: FieldType; + data: unknown; +} + +export interface TextCell extends Cell { + fieldType: FieldType.RichText; + data: string; +} + +export interface NumberCell extends Cell { + fieldType: FieldType.Number; + data: string; +} + +export interface CheckboxCell extends Cell { + fieldType: FieldType.Checkbox; + data: boolean; +} + +export interface UrlCell extends Cell { + fieldType: FieldType.URL; + data: string; +} + +export type SelectionId = string; + +export interface SelectOptionCell extends Cell { + fieldType: FieldType.SingleSelect | FieldType.MultiSelect; + data: SelectionId; +} + +export interface DataTimeTypeOption { + timeFormat: TimeFormat; + dateFormat: DateFormat; +} + +export interface DateTimeCell extends Cell { + fieldType: FieldType.DateTime; + data: string; + endTimestamp?: string; + includeTime?: boolean; + isRange?: boolean; + reminderId?: string; +} + +export enum FileMediaType { + Image = 'Image', + Video = 'Video', + Link = 'Link', + Other = 'Other', +} + +export enum FileMediaUploadType { + CloudMedia = 'CloudMedia', + NetworkMedia = 'NetworkMedia', +} + +export interface FileMediaCellDataItem { + file_type: FileMediaType; + id: string; + name: string; + upload_type: FileMediaUploadType; + url: string; +} + +export type FileMediaCellData = FileMediaCellDataItem[] + +export interface FileMediaCell extends Cell { + fieldType: FieldType.FileMedia; + data: FileMediaCellData; +} + +export interface DateTimeCellData { + date?: string; + time?: string; + timestamp?: number; + includeTime?: boolean; + endDate?: string; + endTime?: string; + endTimestamp?: number; + isRange?: boolean; +} + +export interface ChecklistCell extends Cell { + fieldType: FieldType.Checklist; + data: string; +} + +export interface RelationCell extends Cell { + fieldType: FieldType.Relation; + data: YArray; +} + +export type RelationCellData = RowId[]; + +export interface CellProps { + cell?: T; + rowId: string; + fieldId: FieldId; + style?: React.CSSProperties; + readOnly?: boolean; + placeholder?: string; + className?: string; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/const.ts b/frontend/appflowy_web_app/src/application/database-yjs/const.ts new file mode 100644 index 0000000000000..12deaaa2185b2 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/const.ts @@ -0,0 +1,32 @@ +import { RowId, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; +import { RowMetaKey } from '@/application/database-yjs/database.type'; +import { v5 as uuidv5, parse as uuidParse } from 'uuid'; + +export const DEFAULT_ROW_HEIGHT = 36; +export const MIN_COLUMN_WIDTH = 150; + +export const getCell = (rowId: string, fieldId: string, rowMetas: Record) => { + const rowMeta = rowMetas[rowId]; + + const meta = rowMeta?.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow; + + return meta?.get(YjsDatabaseKey.cells)?.get(fieldId); +}; + +export const getCellData = (rowId: string, fieldId: string, rowMetas: Record) => { + return getCell(rowId, fieldId, rowMetas)?.get(YjsDatabaseKey.data); +}; + +export const metaIdFromRowId = (rowId: string) => { + let namespace: Uint8Array; + + try { + namespace = uuidParse(rowId); + } catch (e) { + namespace = uuidParse(generateUUID()); + } + + return (key: RowMetaKey) => uuidv5(key, namespace).toString(); +}; + +export const generateUUID = () => uuidv5(Date.now().toString(), uuidv5.URL); diff --git a/frontend/appflowy_web_app/src/application/database-yjs/context.ts b/frontend/appflowy_web_app/src/application/database-yjs/context.ts new file mode 100644 index 0000000000000..41ca2374931fe --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/context.ts @@ -0,0 +1,86 @@ +import { + CreateRowDoc, + LoadView, + LoadViewMeta, RowId, + YDatabase, + YDatabaseRow, + YDoc, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/types'; +import { createContext, useContext } from 'react'; + +export interface DatabaseContextState { + readOnly: boolean; + databaseDoc: YDoc; + iidIndex: string; + viewId: string; + rowDocMap: Record | null; + isDatabaseRowPage?: boolean; + navigateToRow?: (rowId: string) => void; + loadView?: LoadView; + createRowDoc?: CreateRowDoc; + loadViewMeta?: LoadViewMeta; + navigateToView?: (viewId: string, blockId?: string) => Promise; +} + +export const DatabaseContext = createContext(null); + +export const useDatabase = () => { + const database = useContext(DatabaseContext) + ?.databaseDoc?.getMap(YjsEditorKey.data_section) + .get(YjsEditorKey.database) as YDatabase; + + return database; +}; + +export function useDatabaseViewId () { + return useContext(DatabaseContext)?.viewId; +} + +export const useNavigateToRow = () => { + return useContext(DatabaseContext)?.navigateToRow; +}; + +export const useRowDocMap = () => { + return useContext(DatabaseContext)?.rowDocMap; +}; + +export const useIsDatabaseRowPage = () => { + return useContext(DatabaseContext)?.isDatabaseRowPage; +}; + +export const useRow = (rowId: string) => { + const rows = useRowDocMap(); + + return rows?.[rowId]?.getMap(YjsEditorKey.data_section); +}; + +export const useRowData = (rowId: string) => { + return useRow(rowId)?.get(YjsEditorKey.database_row) as YDatabaseRow; +}; + +export const useViewId = () => { + const context = useContext(DatabaseContext); + + return context?.viewId; +}; + +export const useReadOnly = () => { + const context = useContext(DatabaseContext); + + return context?.readOnly; +}; + +export const useDatabaseView = () => { + const database = useDatabase(); + const viewId = useViewId(); + + return viewId ? database?.get(YjsDatabaseKey.views)?.get(viewId) : undefined; +}; + +export function useDatabaseFields () { + const database = useDatabase(); + + return database.get(YjsDatabaseKey.fields); +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/database.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/database.type.ts new file mode 100644 index 0000000000000..c73ceb7bef9da --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/database.type.ts @@ -0,0 +1,75 @@ +import { FieldId } from '@/application/types'; + +export enum FieldVisibility { + AlwaysShown = 0, + HideWhenEmpty = 1, + AlwaysHidden = 2, +} + +export enum FieldType { + RichText = 0, + Number = 1, + DateTime = 2, + SingleSelect = 3, + MultiSelect = 4, + Checkbox = 5, + URL = 6, + Checklist = 7, + LastEditedTime = 8, + CreatedTime = 9, + Relation = 10, + AISummaries = 11, + AITranslations = 12, + FileMedia = 14 +} + +export enum CalculationType { + Average = 0, + Max = 1, + Median = 2, + Min = 3, + Sum = 4, + Count = 5, + CountEmpty = 6, + CountNonEmpty = 7, +} + +export enum SortCondition { + Ascending = 0, + Descending = 1, +} + +export enum FilterType { + Data = 0, + And = 1, + Or = 2, +} + +export interface Filter { + fieldId: FieldId; + filterType: FilterType; + condition: number; + id: string; + content: string; +} + +export enum CalendarLayout { + MonthLayout = 0, + WeekLayout = 1, + DayLayout = 2, +} + +export interface CalendarLayoutSetting { + fieldId: string; + firstDayOfWeek: number; + showWeekNumbers: boolean; + showWeekends: boolean; + layout: CalendarLayout; +} + +export enum RowMetaKey { + DocumentId = 'document_id', + IconId = 'icon_id', + CoverId = 'cover_id', + IsDocumentEmpty = 'is_document_empty', +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/checkbox/checkbox.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/checkbox/checkbox.type.ts new file mode 100644 index 0000000000000..b9da4341f6a47 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/checkbox/checkbox.type.ts @@ -0,0 +1,10 @@ +import { Filter } from '@/application/database-yjs'; + +export enum CheckboxFilterCondition { + IsChecked = 0, + IsUnChecked = 1, +} + +export interface CheckboxFilter extends Filter { + condition: CheckboxFilterCondition; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/checkbox/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/checkbox/index.ts new file mode 100644 index 0000000000000..9ccd409dc8240 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/checkbox/index.ts @@ -0,0 +1 @@ +export * from './checkbox.type'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/checklist.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/checklist.type.ts new file mode 100644 index 0000000000000..2b504ded8ae81 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/checklist.type.ts @@ -0,0 +1,10 @@ +import { Filter } from '@/application/database-yjs'; + +export enum ChecklistFilterCondition { + IsComplete = 0, + IsIncomplete = 1, +} + +export interface ChecklistFilter extends Filter { + condition: ChecklistFilterCondition; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/index.ts new file mode 100644 index 0000000000000..15d37f912ba47 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/index.ts @@ -0,0 +1,2 @@ +export * from './checklist.type'; +export * from './parse'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/parse.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/parse.ts new file mode 100644 index 0000000000000..c93fee7a3815f --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/checklist/parse.ts @@ -0,0 +1,22 @@ +import { SelectOption } from '../select-option'; + +export interface ChecklistCellData { + selectedOptionIds?: string[]; + options?: SelectOption[]; + percentage: number; +} + +export function parseChecklistData(data: string): ChecklistCellData | null { + try { + const { options, selected_option_ids } = JSON.parse(data); + const percentage = selected_option_ids.length / options.length; + + return { + percentage, + options, + selectedOptionIds: selected_option_ids, + }; + } catch (e) { + return null; + } +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/date/date.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/date.type.ts new file mode 100644 index 0000000000000..0db15f21ebec9 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/date.type.ts @@ -0,0 +1,32 @@ +import { Filter } from '@/application/database-yjs'; + +export enum TimeFormat { + TwelveHour = 0, + TwentyFourHour = 1, +} + +export enum DateFormat { + Local = 0, + US = 1, + ISO = 2, + Friendly = 3, + DayMonthYear = 4, +} + +export enum DateFilterCondition { + DateIs = 0, + DateBefore = 1, + DateAfter = 2, + DateOnOrBefore = 3, + DateOnOrAfter = 4, + DateWithIn = 5, + DateIsEmpty = 6, + DateIsNotEmpty = 7, +} + +export interface DateFilter extends Filter { + condition: DateFilterCondition; + start?: number; + end?: number; + timestamp?: number; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/date/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/index.ts new file mode 100644 index 0000000000000..106279c949d4c --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/index.ts @@ -0,0 +1,2 @@ +export * from './date.type'; +export * from './utils'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/date/utils.test.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/utils.test.ts new file mode 100644 index 0000000000000..9d3821ba1cfb1 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/utils.test.ts @@ -0,0 +1,21 @@ +import { getTimeFormat, getDateFormat } from './utils'; +import { expect } from '@jest/globals'; +import { DateFormat, TimeFormat } from '@/application/database-yjs'; + +describe('DateFormat', () => { + it('should return time format', () => { + expect(getTimeFormat(TimeFormat.TwelveHour)).toEqual('h:mm A'); + expect(getTimeFormat(TimeFormat.TwentyFourHour)).toEqual('HH:mm'); + expect(getTimeFormat(56)).toEqual('HH:mm'); + }); + + it('should return date format', () => { + expect(getDateFormat(DateFormat.US)).toEqual('YYYY/MM/DD'); + expect(getDateFormat(DateFormat.ISO)).toEqual('YYYY-MM-DD'); + expect(getDateFormat(DateFormat.Friendly)).toEqual('MMM DD, YYYY'); + expect(getDateFormat(DateFormat.Local)).toEqual('MM/DD/YYYY'); + expect(getDateFormat(DateFormat.DayMonthYear)).toEqual('DD/MM/YYYY'); + + expect(getDateFormat(56)).toEqual('YYYY-MM-DD'); + }); +}); diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/date/utils.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/utils.ts new file mode 100644 index 0000000000000..985402768b9aa --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/date/utils.ts @@ -0,0 +1,29 @@ +import { TimeFormat, DateFormat } from '@/application/database-yjs'; + +export function getTimeFormat(timeFormat?: TimeFormat) { + switch (timeFormat) { + case TimeFormat.TwelveHour: + return 'h:mm A'; + case TimeFormat.TwentyFourHour: + return 'HH:mm'; + default: + return 'HH:mm'; + } +} + +export function getDateFormat(dateFormat?: DateFormat) { + switch (dateFormat) { + case DateFormat.Friendly: + return 'MMM DD, YYYY'; + case DateFormat.ISO: + return 'YYYY-MM-DD'; + case DateFormat.US: + return 'YYYY/MM/DD'; + case DateFormat.Local: + return 'MM/DD/YYYY'; + case DateFormat.DayMonthYear: + return 'DD/MM/YYYY'; + default: + return 'YYYY-MM-DD'; + } +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/index.ts new file mode 100644 index 0000000000000..5505f0e4edd2b --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/index.ts @@ -0,0 +1,8 @@ +export * from './type_option'; +export * from './date'; +export * from './number'; +export * from './select-option'; +export * from './text'; +export * from './checkbox'; +export * from './checklist'; +export * from './relation'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/number/__tests__/format.test.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/__tests__/format.test.ts new file mode 100644 index 0000000000000..f80b1db22020e --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/__tests__/format.test.ts @@ -0,0 +1,628 @@ +import { currencyFormaterMap } from '../format'; +import { NumberFormat } from '../number.type'; +import { expect } from '@jest/globals'; + +const testCases = [0, 1, 0.5, 0.5666, 1000, 10000, 1000000, 10000000, 1000000.0]; +describe('currencyFormaterMap', () => { + test('should return the correct formatter for Num', () => { + const formater = currencyFormaterMap[NumberFormat.Num]; + const result = ['0', '1', '0.5', '0.5666', '1,000', '10,000', '1,000,000', '10,000,000', '1,000,000']; + testCases.forEach((testCase) => { + expect(formater(testCase)).toBe(result[testCases.indexOf(testCase)]); + }); + }); + + test('should return the correct formatter for Percent', () => { + const formater = currencyFormaterMap[NumberFormat.Percent]; + const result = ['0%', '1%', '0.5%', '0.57%', '1,000%', '10,000%', '1,000,000%', '10,000,000%', '1,000,000%']; + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for USD', () => { + const formater = currencyFormaterMap[NumberFormat.USD]; + const result = ['$0', '$1', '$0.5', '$0.57', '$1,000', '$10,000', '$1,000,000', '$10,000,000', '$1,000,000']; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for CanadianDollar', () => { + const formater = currencyFormaterMap[NumberFormat.CanadianDollar]; + const result = [ + 'CA$0', + 'CA$1', + 'CA$0.5', + 'CA$0.57', + 'CA$1,000', + 'CA$10,000', + 'CA$1,000,000', + 'CA$10,000,000', + 'CA$1,000,000', + ]; + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for EUR', () => { + const formater = currencyFormaterMap[NumberFormat.EUR]; + + const result = ['€0', '€1', '€0,5', '€0,57', '€1.000', '€10.000', '€1.000.000', '€10.000.000', '€1.000.000']; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Pound', () => { + const formater = currencyFormaterMap[NumberFormat.Pound]; + + const result = ['£0', '£1', '£0.5', '£0.57', '£1,000', '£10,000', '£1,000,000', '£10,000,000', '£1,000,000']; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Yen', () => { + const formater = currencyFormaterMap[NumberFormat.Yen]; + + const result = [ + '¥0', + '¥1', + '¥0.5', + '¥0.57', + '¥1,000', + '¥10,000', + '¥1,000,000', + '¥10,000,000', + '¥1,000,000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Ruble', () => { + const formater = currencyFormaterMap[NumberFormat.Ruble]; + + const result = [ + '0 RUB', + '1 RUB', + '0,5 RUB', + '0,57 RUB', + '1 000 RUB', + '10 000 RUB', + '1 000 000 RUB', + '10 000 000 RUB', + '1 000 000 RUB', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Rupee', () => { + const formater = currencyFormaterMap[NumberFormat.Rupee]; + + const result = ['₹0', '₹1', '₹0.5', '₹0.57', '₹1,000', '₹10,000', '₹10,00,000', '₹1,00,00,000', '₹10,00,000']; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Won', () => { + const formater = currencyFormaterMap[NumberFormat.Won]; + + const result = ['₩0', '₩1', '₩0.5', '₩0.57', '₩1,000', '₩10,000', '₩1,000,000', '₩10,000,000', '₩1,000,000']; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Yuan', () => { + const formater = currencyFormaterMap[NumberFormat.Yuan]; + + const result = [ + 'CN¥0', + 'CN¥1', + 'CN¥0.5', + 'CN¥0.57', + 'CN¥1,000', + 'CN¥10,000', + 'CN¥1,000,000', + 'CN¥10,000,000', + 'CN¥1,000,000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Real', () => { + const formater = currencyFormaterMap[NumberFormat.Real]; + + const result = [ + 'R$ 0', + 'R$ 1', + 'R$ 0,5', + 'R$ 0,57', + 'R$ 1.000', + 'R$ 10.000', + 'R$ 1.000.000', + 'R$ 10.000.000', + 'R$ 1.000.000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Lira', () => { + const formater = currencyFormaterMap[NumberFormat.Lira]; + + const result = [ + 'TRY 0', + 'TRY 1', + 'TRY 0,5', + 'TRY 0,57', + 'TRY 1.000', + 'TRY 10.000', + 'TRY 1.000.000', + 'TRY 10.000.000', + 'TRY 1.000.000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Rupiah', () => { + const formater = currencyFormaterMap[NumberFormat.Rupiah]; + + const result = [ + 'IDR 0', + 'IDR 1', + 'IDR 0,5', + 'IDR 0,57', + 'IDR 1.000', + 'IDR 10.000', + 'IDR 1.000.000', + 'IDR 10.000.000', + 'IDR 1.000.000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Franc', () => { + const formater = currencyFormaterMap[NumberFormat.Franc]; + + const result = [ + 'CHF 0', + 'CHF 1', + 'CHF 0.5', + 'CHF 0.57', + `CHF 1’000`, + `CHF 10’000`, + `CHF 1’000’000`, + `CHF 10’000’000`, + `CHF 1’000’000`, + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for HongKongDollar', () => { + const formater = currencyFormaterMap[NumberFormat.HongKongDollar]; + + const result = [ + 'HK$0', + 'HK$1', + 'HK$0.5', + 'HK$0.57', + 'HK$1,000', + 'HK$10,000', + 'HK$1,000,000', + 'HK$10,000,000', + 'HK$1,000,000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for NewZealandDollar', () => { + const formater = currencyFormaterMap[NumberFormat.NewZealandDollar]; + + const result = [ + 'NZ$0', + 'NZ$1', + 'NZ$0.5', + 'NZ$0.57', + 'NZ$1,000', + 'NZ$10,000', + 'NZ$1,000,000', + 'NZ$10,000,000', + 'NZ$1,000,000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Krona', () => { + const formater = currencyFormaterMap[NumberFormat.Krona]; + + const result = [ + '0 SEK', + '1 SEK', + '0,5 SEK', + '0,57 SEK', + '1 000 SEK', + '10 000 SEK', + '1 000 000 SEK', + '10 000 000 SEK', + '1 000 000 SEK', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + test('should return the correct formatter for NorwegianKrone', () => { + const formater = currencyFormaterMap[NumberFormat.NorwegianKrone]; + + const result = [ + 'NOK 0', + 'NOK 1', + 'NOK 0,5', + 'NOK 0,57', + 'NOK 1 000', + 'NOK 10 000', + 'NOK 1 000 000', + 'NOK 10 000 000', + 'NOK 1 000 000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for MexicanPeso', () => { + const formater = currencyFormaterMap[NumberFormat.MexicanPeso]; + + const result = [ + 'MX$0', + 'MX$1', + 'MX$0.5', + 'MX$0.57', + 'MX$1,000', + 'MX$10,000', + 'MX$1,000,000', + 'MX$10,000,000', + 'MX$1,000,000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Rand', () => { + const formater = currencyFormaterMap[NumberFormat.Rand]; + + const result = [ + 'ZAR 0', + 'ZAR 1', + 'ZAR 0,5', + 'ZAR 0,57', + 'ZAR 1 000', + 'ZAR 10 000', + 'ZAR 1 000 000', + 'ZAR 10 000 000', + 'ZAR 1 000 000', + ]; + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for NewTaiwanDollar', () => { + const formater = currencyFormaterMap[NumberFormat.NewTaiwanDollar]; + + const result = [ + 'NT$0', + 'NT$1', + 'NT$0.5', + 'NT$0.57', + 'NT$1,000', + 'NT$10,000', + 'NT$1,000,000', + 'NT$10,000,000', + 'NT$1,000,000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for DanishKrone', () => { + const formater = currencyFormaterMap[NumberFormat.DanishKrone]; + + const result = [ + '0 DKK', + '1 DKK', + '0,5 DKK', + '0,57 DKK', + '1.000 DKK', + '10.000 DKK', + '1.000.000 DKK', + '10.000.000 DKK', + '1.000.000 DKK', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + test('should return the correct formatter for Baht', () => { + const formater = currencyFormaterMap[NumberFormat.Baht]; + + const result = [ + 'THB 0', + 'THB 1', + 'THB 0.5', + 'THB 0.57', + 'THB 1,000', + 'THB 10,000', + 'THB 1,000,000', + 'THB 10,000,000', + 'THB 1,000,000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + test('should return the correct formatter for Forint', () => { + const formater = currencyFormaterMap[NumberFormat.Forint]; + + const result = [ + '0 HUF', + '1 HUF', + '0,5 HUF', + '0,57 HUF', + '1 000 HUF', + '10 000 HUF', + '1 000 000 HUF', + '10 000 000 HUF', + '1 000 000 HUF', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Koruna', () => { + const formater = currencyFormaterMap[NumberFormat.Koruna]; + + const result = [ + '0 CZK', + '1 CZK', + '0,5 CZK', + '0,57 CZK', + '1 000 CZK', + '10 000 CZK', + '1 000 000 CZK', + '10 000 000 CZK', + '1 000 000 CZK', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Shekel', () => { + const formater = currencyFormaterMap[NumberFormat.Shekel]; + + const result = [ + '‏0 ‏₪', + '‏1 ‏₪', + '‏0.5 ‏₪', + '‏0.57 ‏₪', + '‏1,000 ‏₪', + '‏10,000 ‏₪', + '‏1,000,000 ‏₪', + '‏10,000,000 ‏₪', + '‏1,000,000 ‏₪', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + test('should return the correct formatter for ChileanPeso', () => { + const formater = currencyFormaterMap[NumberFormat.ChileanPeso]; + + const result = [ + 'CLP 0', + 'CLP 1', + 'CLP 0,5', + 'CLP 0,57', + 'CLP 1.000', + 'CLP 10.000', + 'CLP 1.000.000', + 'CLP 10.000.000', + 'CLP 1.000.000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + test('should return the correct formatter for PhilippinePeso', () => { + const formater = currencyFormaterMap[NumberFormat.PhilippinePeso]; + + const result = ['₱0', '₱1', '₱0.5', '₱0.57', '₱1,000', '₱10,000', '₱1,000,000', '₱10,000,000', '₱1,000,000']; + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + test('should return the correct formatter for Dirham', () => { + const formater = currencyFormaterMap[NumberFormat.Dirham]; + + const result = [ + '‏0 AED', + '‏1 AED', + '‏0.5 AED', + '‏0.57 AED', + '‏1,000 AED', + '‏10,000 AED', + '‏1,000,000 AED', + '‏10,000,000 AED', + '‏1,000,000 AED', + ]; + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + test('should return the correct formatter for ColombianPeso', () => { + const formater = currencyFormaterMap[NumberFormat.ColombianPeso]; + + const result = [ + 'COP 0', + 'COP 1', + 'COP 0,5', + 'COP 0,57', + 'COP 1.000', + 'COP 10.000', + 'COP 1.000.000', + 'COP 10.000.000', + 'COP 1.000.000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + test('should return the correct formatter for Riyal', () => { + const formater = currencyFormaterMap[NumberFormat.Riyal]; + + const result = [ + 'SAR 0', + 'SAR 1', + 'SAR 0.5', + 'SAR 0.57', + 'SAR 1,000', + 'SAR 10,000', + 'SAR 1,000,000', + 'SAR 10,000,000', + 'SAR 1,000,000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Ringgit', () => { + const formater = currencyFormaterMap[NumberFormat.Ringgit]; + + const result = [ + 'RM 0', + 'RM 1', + 'RM 0.5', + 'RM 0.57', + 'RM 1,000', + 'RM 10,000', + 'RM 1,000,000', + 'RM 10,000,000', + 'RM 1,000,000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for Leu', () => { + const formater = currencyFormaterMap[NumberFormat.Leu]; + + const result = [ + '0 RON', + '1 RON', + '0,5 RON', + '0,57 RON', + '1.000 RON', + '10.000 RON', + '1.000.000 RON', + '10.000.000 RON', + '1.000.000 RON', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for ArgentinePeso', () => { + const formater = currencyFormaterMap[NumberFormat.ArgentinePeso]; + + const result = [ + 'ARS 0', + 'ARS 1', + 'ARS 0,5', + 'ARS 0,57', + 'ARS 1.000', + 'ARS 10.000', + 'ARS 1.000.000', + 'ARS 10.000.000', + 'ARS 1.000.000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); + + test('should return the correct formatter for UruguayanPeso', () => { + const formater = currencyFormaterMap[NumberFormat.UruguayanPeso]; + + const result = [ + 'UYU 0', + 'UYU 1', + 'UYU 0,5', + 'UYU 0,57', + 'UYU 1.000', + 'UYU 10.000', + 'UYU 1.000.000', + 'UYU 10.000.000', + 'UYU 1.000.000', + ]; + + testCases.forEach((testCase, index) => { + expect(formater(testCase)).toBe(result[index]); + }); + }); +}); diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/number/format.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/format.ts new file mode 100644 index 0000000000000..61e0942b01e57 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/format.ts @@ -0,0 +1,236 @@ +import { NumberFormat } from './number.type'; + +const commonProps = { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + style: 'currency', + currencyDisplay: 'symbol', + useGrouping: true, +}; + +export const currencyFormaterMap: Record string> = { + [NumberFormat.Num]: (n: number) => + new Intl.NumberFormat('en-US', { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: 20, + }).format(n), + [NumberFormat.Percent]: (n: number) => + new Intl.NumberFormat('en-US', { + ...commonProps, + style: 'decimal', + }).format(n) + '%', + [NumberFormat.USD]: (n: number) => + new Intl.NumberFormat('en-US', { + ...commonProps, + currency: 'USD', + }).format(n), + [NumberFormat.CanadianDollar]: (n: number) => + new Intl.NumberFormat('en-CA', { + ...commonProps, + currency: 'CAD', + }) + .format(n) + .replace('$', 'CA$'), + [NumberFormat.EUR]: (n: number) => { + const formattedAmount = new Intl.NumberFormat('de-DE', { + ...commonProps, + currency: 'EUR', + }) + .format(n) + .replace('€', '') + .trim(); + + return `€${formattedAmount}`; + }, + + [NumberFormat.Pound]: (n: number) => + new Intl.NumberFormat('en-GB', { + ...commonProps, + currency: 'GBP', + }).format(n), + [NumberFormat.Yen]: (n: number) => + new Intl.NumberFormat('ja-JP', { + ...commonProps, + currency: 'JPY', + }).format(n), + [NumberFormat.Ruble]: (n: number) => + new Intl.NumberFormat('ru-RU', { + ...commonProps, + currency: 'RUB', + currencyDisplay: 'code', + }) + .format(n) + .replaceAll(' ', ' '), + [NumberFormat.Rupee]: (n: number) => + new Intl.NumberFormat('hi-IN', { + ...commonProps, + currency: 'INR', + }).format(n), + [NumberFormat.Won]: (n: number) => + new Intl.NumberFormat('ko-KR', { + ...commonProps, + currency: 'KRW', + }).format(n), + [NumberFormat.Yuan]: (n: number) => + new Intl.NumberFormat('zh-CN', { + ...commonProps, + currency: 'CNY', + }) + .format(n) + .replace('¥', 'CN¥'), + [NumberFormat.Real]: (n: number) => + new Intl.NumberFormat('pt-BR', { + ...commonProps, + currency: 'BRL', + }) + .format(n) + .replaceAll(' ', ' '), + [NumberFormat.Lira]: (n: number) => + new Intl.NumberFormat('tr-TR', { + ...commonProps, + currency: 'TRY', + currencyDisplay: 'code', + }) + .format(n) + .replaceAll(' ', ' '), + [NumberFormat.Rupiah]: (n: number) => + new Intl.NumberFormat('id-ID', { + ...commonProps, + currency: 'IDR', + currencyDisplay: 'code', + }) + .format(n) + .replaceAll(' ', ' '), + [NumberFormat.Franc]: (n: number) => + new Intl.NumberFormat('de-CH', { + ...commonProps, + currency: 'CHF', + }) + .format(n) + .replaceAll(' ', ' '), + [NumberFormat.HongKongDollar]: (n: number) => + new Intl.NumberFormat('zh-HK', { + ...commonProps, + currency: 'HKD', + }).format(n), + [NumberFormat.NewZealandDollar]: (n: number) => + new Intl.NumberFormat('en-NZ', { + ...commonProps, + currency: 'NZD', + }) + .format(n) + .replace('$', 'NZ$'), + [NumberFormat.Krona]: (n: number) => + new Intl.NumberFormat('sv-SE', { + ...commonProps, + currency: 'SEK', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.NorwegianKrone]: (n: number) => + new Intl.NumberFormat('nb-NO', { + ...commonProps, + currency: 'NOK', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.MexicanPeso]: (n: number) => + new Intl.NumberFormat('es-MX', { + ...commonProps, + currency: 'MXN', + }) + .format(n) + .replace('$', 'MX$'), + [NumberFormat.Rand]: (n: number) => + new Intl.NumberFormat('en-ZA', { + ...commonProps, + currency: 'ZAR', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.NewTaiwanDollar]: (n: number) => + new Intl.NumberFormat('zh-TW', { + ...commonProps, + currency: 'TWD', + }) + .format(n) + .replace('$', 'NT$'), + [NumberFormat.DanishKrone]: (n: number) => + new Intl.NumberFormat('da-DK', { + ...commonProps, + currency: 'DKK', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.Baht]: (n: number) => + new Intl.NumberFormat('th-TH', { + ...commonProps, + currency: 'THB', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.Forint]: (n: number) => + new Intl.NumberFormat('hu-HU', { + ...commonProps, + currency: 'HUF', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.Koruna]: (n: number) => + new Intl.NumberFormat('cs-CZ', { + ...commonProps, + currency: 'CZK', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.Shekel]: (n: number) => + new Intl.NumberFormat('he-IL', { + ...commonProps, + currency: 'ILS', + }).format(n), + [NumberFormat.ChileanPeso]: (n: number) => + new Intl.NumberFormat('es-CL', { + ...commonProps, + currency: 'CLP', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.PhilippinePeso]: (n: number) => + new Intl.NumberFormat('fil-PH', { + ...commonProps, + currency: 'PHP', + }).format(n), + [NumberFormat.Dirham]: (n: number) => + new Intl.NumberFormat('ar-AE', { + ...commonProps, + currency: 'AED', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.ColombianPeso]: (n: number) => + new Intl.NumberFormat('es-CO', { + ...commonProps, + currency: 'COP', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.Riyal]: (n: number) => + new Intl.NumberFormat('en-US', { + ...commonProps, + currency: 'SAR', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.Ringgit]: (n: number) => + new Intl.NumberFormat('ms-MY', { + ...commonProps, + currency: 'MYR', + }).format(n), + [NumberFormat.Leu]: (n: number) => + new Intl.NumberFormat('ro-RO', { + ...commonProps, + currency: 'RON', + }).format(n), + [NumberFormat.ArgentinePeso]: (n: number) => + new Intl.NumberFormat('es-AR', { + ...commonProps, + currency: 'ARS', + currencyDisplay: 'code', + }).format(n), + [NumberFormat.UruguayanPeso]: (n: number) => + new Intl.NumberFormat('es-UY', { + ...commonProps, + currency: 'UYU', + currencyDisplay: 'code', + }).format(n), +}; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/number/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/index.ts new file mode 100644 index 0000000000000..27ca7cd8d8e6c --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/index.ts @@ -0,0 +1,3 @@ +export * from './format'; +export * from './number.type'; +export * from './parse'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/number/number.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/number.type.ts new file mode 100644 index 0000000000000..9140531325919 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/number.type.ts @@ -0,0 +1,56 @@ +import { Filter } from '@/application/database-yjs'; + +export enum NumberFormat { + Num = 0, + USD = 1, + CanadianDollar = 2, + EUR = 4, + Pound = 5, + Yen = 6, + Ruble = 7, + Rupee = 8, + Won = 9, + Yuan = 10, + Real = 11, + Lira = 12, + Rupiah = 13, + Franc = 14, + HongKongDollar = 15, + NewZealandDollar = 16, + Krona = 17, + NorwegianKrone = 18, + MexicanPeso = 19, + Rand = 20, + NewTaiwanDollar = 21, + DanishKrone = 22, + Baht = 23, + Forint = 24, + Koruna = 25, + Shekel = 26, + ChileanPeso = 27, + PhilippinePeso = 28, + Dirham = 29, + ColombianPeso = 30, + Riyal = 31, + Ringgit = 32, + Leu = 33, + ArgentinePeso = 34, + UruguayanPeso = 35, + Percent = 36, +} + +export enum NumberFilterCondition { + Equal = 0, + NotEqual = 1, + GreaterThan = 2, + LessThan = 3, + GreaterThanOrEqualTo = 4, + LessThanOrEqualTo = 5, + NumberIsEmpty = 6, + NumberIsNotEmpty = 7, +} + +export interface NumberFilter extends Filter { + condition: NumberFilterCondition; + content: string; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/number/parse.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/parse.ts new file mode 100644 index 0000000000000..d96ea879621f1 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/number/parse.ts @@ -0,0 +1,11 @@ +import { YDatabaseField } from '@/application/types'; +import { getTypeOptions } from '../type_option'; +import { NumberFormat } from './number.type'; + +export function parseNumberTypeOptions(field: YDatabaseField) { + const numberTypeOption = getTypeOptions(field)?.toJSON(); + + return { + format: parseInt(numberTypeOption.format) as NumberFormat, + }; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/index.ts new file mode 100644 index 0000000000000..4b94064b5286a --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/index.ts @@ -0,0 +1,2 @@ +export * from './parse'; +export * from './relation.type'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/parse.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/parse.ts new file mode 100644 index 0000000000000..42bbfe42e2f00 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/parse.ts @@ -0,0 +1,9 @@ +import { YDatabaseField } from '@/application/types'; +import { RelationTypeOption } from './relation.type'; +import { getTypeOptions } from '../type_option'; + +export function parseRelationTypeOption(field: YDatabaseField) { + const relationTypeOption = getTypeOptions(field)?.toJSON(); + + return relationTypeOption as RelationTypeOption; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/relation.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/relation.type.ts new file mode 100644 index 0000000000000..31021afc38ca6 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/relation/relation.type.ts @@ -0,0 +1,9 @@ +import { Filter } from '@/application/database-yjs'; + +export interface RelationTypeOption { + database_id: string; +} + +export interface RelationFilter extends Filter { + condition: number; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/index.ts new file mode 100644 index 0000000000000..a569b2ca47f09 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/index.ts @@ -0,0 +1,2 @@ +export * from './select_option.type'; +export * from './parse'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/parse.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/parse.ts new file mode 100644 index 0000000000000..83446338d2aac --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/parse.ts @@ -0,0 +1,28 @@ +import { YDatabaseField, YjsDatabaseKey } from '@/application/types'; +import { getTypeOptions } from '../type_option'; +import { SelectTypeOption } from './select_option.type'; + +export function parseSelectOptionTypeOptions(field: YDatabaseField) { + const content = getTypeOptions(field)?.get(YjsDatabaseKey.content); + + if (!content) return null; + + try { + return JSON.parse(content) as SelectTypeOption; + } catch (e) { + return null; + } +} + +export function parseSelectOptionCellData(field: YDatabaseField, data: string) { + const typeOption = parseSelectOptionTypeOptions(field); + const selectedIds = typeof data === 'string' ? data.split(',') : []; + + return selectedIds + .map((id) => { + const option = typeOption?.options?.find((option) => option.id === id); + + return option?.name ?? ''; + }) + .join(', '); +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/select_option.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/select_option.type.ts new file mode 100644 index 0000000000000..343941d58816f --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/select-option/select_option.type.ts @@ -0,0 +1,38 @@ +import { Filter } from '@/application/database-yjs'; + +export enum SelectOptionColor { + Purple = 'Purple', + Pink = 'Pink', + LightPink = 'LightPink', + Orange = 'Orange', + Yellow = 'Yellow', + Lime = 'Lime', + Green = 'Green', + Aqua = 'Aqua', + Blue = 'Blue', +} + +export enum SelectOptionFilterCondition { + OptionIs = 0, + OptionIsNot = 1, + OptionContains = 2, + OptionDoesNotContain = 3, + OptionIsEmpty = 4, + OptionIsNotEmpty = 5, +} + +export interface SelectOptionFilter extends Filter { + condition: SelectOptionFilterCondition; + optionIds: string[]; +} + +export interface SelectOption { + id: string; + name: string; + color: SelectOptionColor; +} + +export interface SelectTypeOption { + disable_color: boolean; + options: SelectOption[]; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/text/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/text/index.ts new file mode 100644 index 0000000000000..7d0a52cd9da85 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/text/index.ts @@ -0,0 +1 @@ +export * from './text.type'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/text/text.type.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/text/text.type.ts new file mode 100644 index 0000000000000..c2f230c738442 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/text/text.type.ts @@ -0,0 +1,17 @@ +import { Filter } from '@/application/database-yjs'; + +export enum TextFilterCondition { + TextIs = 0, + TextIsNot = 1, + TextContains = 2, + TextDoesNotContain = 3, + TextStartsWith = 4, + TextEndsWith = 5, + TextIsEmpty = 6, + TextIsNotEmpty = 7, +} + +export interface TextFilter extends Filter { + condition: TextFilterCondition; + content: string; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/fields/type_option.ts b/frontend/appflowy_web_app/src/application/database-yjs/fields/type_option.ts new file mode 100644 index 0000000000000..11da9948735b5 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/fields/type_option.ts @@ -0,0 +1,8 @@ +import { YDatabaseField, YjsDatabaseKey } from '@/application/types'; +import { FieldType } from '@/application/database-yjs'; + +export function getTypeOptions(field: YDatabaseField) { + const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; + + return field?.get(YjsDatabaseKey.type_option)?.get(String(fieldType)); +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/filter.ts b/frontend/appflowy_web_app/src/application/database-yjs/filter.ts new file mode 100644 index 0000000000000..e3cb188cdae5c --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/filter.ts @@ -0,0 +1,234 @@ +import { + RowId, + YDatabaseFields, + YDatabaseFilter, + YDatabaseFilters, + YDatabaseRow, + YDoc, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/types'; +import { FieldType } from '@/application/database-yjs/database.type'; +import { + CheckboxFilter, + CheckboxFilterCondition, + ChecklistFilter, + ChecklistFilterCondition, + DateFilter, + NumberFilter, + NumberFilterCondition, + parseChecklistData, + SelectOptionFilter, + SelectOptionFilterCondition, + TextFilter, + TextFilterCondition, +} from '@/application/database-yjs/fields'; +import { Row } from '@/application/database-yjs/selector'; +import Decimal from 'decimal.js'; +import { every, filter, some } from 'lodash-es'; + +export function parseFilter (fieldType: FieldType, filter: YDatabaseFilter) { + const fieldId = filter.get(YjsDatabaseKey.field_id); + const filterType = Number(filter.get(YjsDatabaseKey.filter_type)); + const id = filter.get(YjsDatabaseKey.id); + const content = filter.get(YjsDatabaseKey.content); + const condition = Number(filter.get(YjsDatabaseKey.condition)); + + const value = { + fieldId, + filterType, + condition, + id, + content, + }; + + switch (fieldType) { + case FieldType.URL: + case FieldType.RichText: + return value as TextFilter; + case FieldType.Number: + return value as NumberFilter; + case FieldType.Checklist: + return value as ChecklistFilter; + case FieldType.Checkbox: + return value as CheckboxFilter; + case FieldType.SingleSelect: + case FieldType.MultiSelect: + // eslint-disable-next-line no-case-declarations + const options = content.split(','); + + return { + ...value, + optionIds: options, + } as SelectOptionFilter; + case FieldType.DateTime: + case FieldType.CreatedTime: + case FieldType.LastEditedTime: + return value as DateFilter; + } + + return value; +} + +function createPredicate (conditions: ((row: Row) => boolean)[]) { + return function (item: Row) { + return every(conditions, (condition) => condition(item)); + }; +} + +export function filterBy (rows: Row[], filters: YDatabaseFilters, fields: YDatabaseFields, rowMetas: Record) { + const filterArray = filters.toArray(); + + if (filterArray.length === 0 || Object.keys(rowMetas).length === 0 || fields.size === 0) return rows; + + const conditions = filterArray.map((filter) => { + return (row: { id: string }) => { + const fieldId = filter.get(YjsDatabaseKey.field_id); + const field = fields.get(fieldId); + const fieldType = Number(field.get(YjsDatabaseKey.type)); + const rowId = row.id; + const rowMeta = rowMetas[rowId]; + + if (!rowMeta) return false; + const filterValue = parseFilter(fieldType, filter); + const meta = rowMeta.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow; + + if (!meta) return false; + + const cells = meta.get(YjsDatabaseKey.cells); + const cell = cells.get(fieldId); + + if (!cell) return false; + const { condition, content } = filterValue; + + switch (fieldType) { + case FieldType.URL: + case FieldType.RichText: + return textFilterCheck(cell.get(YjsDatabaseKey.data) as string, content, condition); + case FieldType.Number: + return numberFilterCheck(cell.get(YjsDatabaseKey.data) as string, content, condition); + case FieldType.Checkbox: + return checkboxFilterCheck(cell.get(YjsDatabaseKey.data) as string, condition); + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return selectOptionFilterCheck(cell.get(YjsDatabaseKey.data) as string, content, condition); + case FieldType.Checklist: + return checklistFilterCheck(cell.get(YjsDatabaseKey.data) as string, content, condition); + default: + return true; + } + }; + }); + const predicate = createPredicate(conditions); + + return filter(rows, predicate); +} + +export function textFilterCheck (data: string, content: string, condition: TextFilterCondition) { + switch (condition) { + case TextFilterCondition.TextContains: + return data.includes(content); + case TextFilterCondition.TextDoesNotContain: + return !data.includes(content); + case TextFilterCondition.TextIs: + return data === content; + case TextFilterCondition.TextIsNot: + return data !== content; + case TextFilterCondition.TextIsEmpty: + return data === ''; + case TextFilterCondition.TextIsNotEmpty: + return data !== ''; + default: + return false; + } +} + +export function numberFilterCheck (data: string, content: string, condition: number) { + if (isNaN(Number(data)) || isNaN(Number(content)) || data === '' || content === '') { + if (condition === NumberFilterCondition.NumberIsEmpty) { + return data === ''; + } + + if (condition === NumberFilterCondition.NumberIsNotEmpty) { + return data !== ''; + } + + return false; + } + + const decimal = new Decimal(data).toNumber(); + const filterDecimal = new Decimal(content).toNumber(); + + switch (condition) { + case NumberFilterCondition.Equal: + return decimal === filterDecimal; + case NumberFilterCondition.NotEqual: + return decimal !== filterDecimal; + case NumberFilterCondition.GreaterThan: + return decimal > filterDecimal; + case NumberFilterCondition.GreaterThanOrEqualTo: + return decimal >= filterDecimal; + case NumberFilterCondition.LessThan: + return decimal < filterDecimal; + case NumberFilterCondition.LessThanOrEqualTo: + return decimal <= filterDecimal; + default: + return false; + } +} + +export function checkboxFilterCheck (data: string, condition: number) { + switch (condition) { + case CheckboxFilterCondition.IsChecked: + return data === 'Yes'; + case CheckboxFilterCondition.IsUnChecked: + return data !== 'Yes'; + default: + return false; + } +} + +export function checklistFilterCheck (data: string, content: string, condition: number) { + const percentage = parseChecklistData(data)?.percentage ?? 0; + + if (condition === ChecklistFilterCondition.IsComplete) { + return percentage === 1; + } + + return percentage !== 1; +} + +export function selectOptionFilterCheck (data: string, content: string, condition: number) { + if (SelectOptionFilterCondition.OptionIsEmpty === condition) { + return data === ''; + } + + if (SelectOptionFilterCondition.OptionIsNotEmpty === condition) { + return data !== ''; + } + + const selectedOptionIds = data.split(','); + const filterOptionIds = content.split(','); + + switch (condition) { + // Ensure all filterOptionIds are included in selectedOptionIds + case SelectOptionFilterCondition.OptionIs: + return every(filterOptionIds, (option) => selectedOptionIds.includes(option)); + + // Ensure none of the filterOptionIds are included in selectedOptionIds + case SelectOptionFilterCondition.OptionIsNot: + return every(filterOptionIds, (option) => !selectedOptionIds.includes(option)); + + // Ensure at least one of the filterOptionIds is included in selectedOptionIds + case SelectOptionFilterCondition.OptionContains: + return some(filterOptionIds, (option) => selectedOptionIds.includes(option)); + + // Ensure at least one of the filterOptionIds is not included in selectedOptionIds + case SelectOptionFilterCondition.OptionDoesNotContain: + return some(filterOptionIds, (option) => !selectedOptionIds.includes(option)); + + // Default case, if no conditions match + default: + return false; + } +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/group.ts b/frontend/appflowy_web_app/src/application/database-yjs/group.ts new file mode 100644 index 0000000000000..461748605e1c7 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/group.ts @@ -0,0 +1,76 @@ +import { RowId, YDatabaseField, YDoc, YjsDatabaseKey } from '@/application/types'; +import { getCellData } from '@/application/database-yjs/const'; +import { FieldType } from '@/application/database-yjs/database.type'; +import { parseSelectOptionTypeOptions } from '@/application/database-yjs/fields'; +import { Row } from '@/application/database-yjs/selector'; + +export function groupByField (rows: Row[], rowMetas: Record, field: YDatabaseField) { + const fieldType = Number(field.get(YjsDatabaseKey.type)); + const isSelectOptionField = [FieldType.SingleSelect, FieldType.MultiSelect].includes(fieldType); + + if (isSelectOptionField) { + return groupBySelectOption(rows, rowMetas, field); + } + + if (fieldType === FieldType.Checkbox) { + return groupByCheckbox(rows, rowMetas, field); + } + + return; +} + +export function groupByCheckbox (rows: Row[], rowMetas: Record, field: YDatabaseField) { + const fieldId = field.get(YjsDatabaseKey.id); + const result = new Map(); + + rows.forEach((row) => { + const cellData = getCellData(row.id, fieldId, rowMetas); + + const groupName = cellData === 'Yes' ? 'Yes' : 'No'; + const group = result.get(groupName) ?? []; + + group.push(row); + result.set(groupName, group); + }); + return result; +} + +export function groupBySelectOption (rows: Row[], rowMetas: Record, field: YDatabaseField) { + const fieldId = field.get(YjsDatabaseKey.id); + const result = new Map(); + const typeOption = parseSelectOptionTypeOptions(field); + + if (!typeOption) { + return; + } + + if (typeOption.options.length === 0) { + result.set(fieldId, rows); + return result; + } + + rows.forEach((row) => { + const cellData = getCellData(row.id, fieldId, rowMetas); + + const selectedIds = (cellData as string)?.split(',') ?? []; + + if (selectedIds.length === 0) { + const group = result.get(fieldId) ?? []; + + group.push(row); + result.set(fieldId, group); + return; + } + + selectedIds.forEach((id) => { + const option = typeOption.options.find((option) => option.id === id); + const groupName = option?.id ?? fieldId; + const group = result.get(groupName) ?? []; + + group.push(row); + result.set(groupName, group); + }); + }); + + return result; +} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/index.ts b/frontend/appflowy_web_app/src/application/database-yjs/index.ts new file mode 100644 index 0000000000000..1d5aa0ce3deb8 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/index.ts @@ -0,0 +1,6 @@ +export * from './context'; +export * from './fields'; +export * from './context'; +export * from './selector'; +export * from './database.type'; +export * from './const'; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/selector.ts b/frontend/appflowy_web_app/src/application/database-yjs/selector.ts new file mode 100644 index 0000000000000..2ead86c53c907 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/selector.ts @@ -0,0 +1,733 @@ +import { + FieldId, RowCoverType, + SortId, + YDatabase, + YDatabaseField, YDatabaseMetas, YDatabaseRow, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/types'; +import { getCell, metaIdFromRowId, MIN_COLUMN_WIDTH } from '@/application/database-yjs/const'; +import { + useDatabase, + useDatabaseFields, + useDatabaseView, + useRowDocMap, + useViewId, +} from '@/application/database-yjs/context'; +import { filterBy, parseFilter } from '@/application/database-yjs/filter'; +import { groupByField } from '@/application/database-yjs/group'; +import { sortBy } from '@/application/database-yjs/sort'; +import { parseYDatabaseCellToCell } from '@/application/database-yjs/cell.parse'; +import { DateTimeCell } from '@/application/database-yjs/cell.type'; +import dayjs from 'dayjs'; +import { debounce } from 'lodash-es'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { CalendarLayoutSetting, FieldType, FieldVisibility, Filter, RowMetaKey, SortCondition } from './database.type'; + +export interface Column { + fieldId: string; + width: number; + visibility: FieldVisibility; + wrap?: boolean; +} + +export interface Row { + id: string; + height: number; +} + +const defaultVisible = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty]; + +export function useDatabaseViewsSelector (_iidIndex: string, visibleViewIds?: string[]) { + const database = useDatabase(); + + const views = database?.get(YjsDatabaseKey.views); + const [viewIds, setViewIds] = useState([]); + const childViews = useMemo(() => { + return viewIds.map((viewId) => views?.get(viewId)); + }, [viewIds, views]); + + useEffect(() => { + if (!views) return; + + const observerEvent = () => { + const viewsObj = views.toJSON() as Record< + string, + { + created_at: number; + } + >; + + const viewsSorted = Object.entries(viewsObj).sort((a, b) => { + const [, viewA] = a; + const [, viewB] = b; + + return Number(viewB.created_at) - Number(viewA.created_at); + }); + + setViewIds( + viewsSorted + .map(([key]) => key) + .filter((id) => { + return !visibleViewIds || visibleViewIds.includes(id); + }), + ); + }; + + observerEvent(); + views.observe(observerEvent); + + return () => { + views.unobserve(observerEvent); + }; + }, [views, visibleViewIds]); + + return { + childViews, + viewIds, + }; +} + +export function useFieldsSelector (visibilitys: FieldVisibility[] = defaultVisible) { + const viewId = useViewId(); + const database = useDatabase(); + const [columns, setColumns] = useState([]); + + useEffect(() => { + if (!viewId) return; + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const fields = database?.get(YjsDatabaseKey.fields); + const fieldsOrder = view?.get(YjsDatabaseKey.field_orders); + const fieldSettings = view?.get(YjsDatabaseKey.field_settings); + const getColumns = () => { + if (!fields || !fieldsOrder || !fieldSettings) return []; + + const fieldIds = (fieldsOrder.toJSON() as { id: string }[]).map((item) => item.id); + + return fieldIds + .map((fieldId) => { + const setting = fieldSettings.get(fieldId); + + return { + fieldId, + width: parseInt(setting?.get(YjsDatabaseKey.width)) || MIN_COLUMN_WIDTH, + visibility: Number( + setting?.get(YjsDatabaseKey.visibility) || FieldVisibility.AlwaysShown, + ) as FieldVisibility, + wrap: setting?.get(YjsDatabaseKey.wrap) ?? true, + }; + }) + .filter((column) => { + return visibilitys.includes(column.visibility); + }); + }; + + const observerEvent = () => setColumns(getColumns()); + + setColumns(getColumns()); + + fieldsOrder?.observe(observerEvent); + fieldSettings?.observe(observerEvent); + + return () => { + fieldsOrder?.unobserve(observerEvent); + fieldSettings?.unobserve(observerEvent); + }; + }, [database, viewId, visibilitys]); + + return columns; +} + +export function useFieldSelector (fieldId: string) { + const database = useDatabase(); + const [field, setField] = useState(null); + const [clock, setClock] = useState(0); + + useEffect(() => { + if (!database) return; + + const field = database.get(YjsDatabaseKey.fields)?.get(fieldId); + + setField(field || null); + const observerEvent = () => setClock((prev) => prev + 1); + + field?.observe(observerEvent); + + return () => { + field?.unobserve(observerEvent); + }; + }, [database, fieldId]); + + return { + field, + clock, + }; +} + +export function useFiltersSelector () { + const database = useDatabase(); + const viewId = useViewId(); + const [filters, setFilters] = useState([]); + + useEffect(() => { + if (!viewId) return; + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const filterOrders = view?.get(YjsDatabaseKey.filters); + + if (!filterOrders) return; + + const getFilters = () => { + return (filterOrders.toJSON() as { id: string }[]).map((item) => item.id); + }; + + const observerEvent = () => setFilters(getFilters()); + + setFilters(getFilters()); + + filterOrders.observe(observerEvent); + + return () => { + filterOrders.unobserve(observerEvent); + }; + }, [database, viewId]); + + return filters; +} + +export function useFilterSelector (filterId: string) { + const database = useDatabase(); + const viewId = useViewId(); + const fields = database?.get(YjsDatabaseKey.fields); + const [filterValue, setFilterValue] = useState(null); + + useEffect(() => { + if (!viewId) return; + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const filter = view + ?.get(YjsDatabaseKey.filters) + .toArray() + .find((filter) => filter.get(YjsDatabaseKey.id) === filterId); + const field = fields?.get(filter?.get(YjsDatabaseKey.field_id) as FieldId); + + const observerEvent = () => { + if (!filter || !field) return; + const fieldType = Number(field.get(YjsDatabaseKey.type)) as FieldType; + + setFilterValue(parseFilter(fieldType, filter)); + }; + + observerEvent(); + field?.observe(observerEvent); + filter?.observe(observerEvent); + return () => { + field?.unobserve(observerEvent); + filter?.unobserve(observerEvent); + }; + }, [fields, viewId, filterId, database]); + return filterValue; +} + +export function useSortsSelector () { + const database = useDatabase(); + const viewId = useViewId(); + const [sorts, setSorts] = useState([]); + + useEffect(() => { + if (!viewId) return; + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const sortOrders = view?.get(YjsDatabaseKey.sorts); + + if (!sortOrders) return; + + const getSorts = () => { + return (sortOrders.toJSON() as { id: string }[]).map((item) => item.id); + }; + + const observerEvent = () => setSorts(getSorts()); + + setSorts(getSorts()); + + sortOrders.observe(observerEvent); + + return () => { + sortOrders.unobserve(observerEvent); + }; + }, [database, viewId]); + + return sorts; +} + +export interface Sort { + fieldId: FieldId; + condition: SortCondition; + id: SortId; +} + +export function useSortSelector (sortId: SortId) { + const database = useDatabase(); + const viewId = useViewId(); + const [sortValue, setSortValue] = useState(null); + const views = database?.get(YjsDatabaseKey.views); + + useEffect(() => { + if (!viewId) return; + const view = views?.get(viewId); + const sort = view + ?.get(YjsDatabaseKey.sorts) + .toArray() + .find((sort) => sort.get(YjsDatabaseKey.id) === sortId); + + const observerEvent = () => { + setSortValue({ + fieldId: sort?.get(YjsDatabaseKey.field_id) as FieldId, + condition: Number(sort?.get(YjsDatabaseKey.condition)), + id: sort?.get(YjsDatabaseKey.id) as SortId, + }); + }; + + observerEvent(); + sort?.observe(observerEvent); + + return () => { + sort?.unobserve(observerEvent); + }; + }, [viewId, sortId, views]); + + return sortValue; +} + +export function useGroupsSelector () { + const database = useDatabase(); + const viewId = useViewId(); + const [groups, setGroups] = useState([]); + + useEffect(() => { + if (!viewId) return; + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + + const groupOrders = view?.get(YjsDatabaseKey.groups); + + if (!groupOrders) return; + + const getGroups = () => { + return (groupOrders.toJSON() as { id: string }[]).map((item) => item.id); + }; + + const observerEvent = () => setGroups(getGroups()); + + setGroups(getGroups()); + + groupOrders.observe(observerEvent); + + return () => { + groupOrders.unobserve(observerEvent); + }; + }, [database, viewId]); + + return groups; +} + +export interface GroupColumn { + id: string; + visible: boolean; +} + +export function useGroup (groupId: string) { + const database = useDatabase(); + const viewId = useViewId() as string; + const view = database?.get(YjsDatabaseKey.views)?.get(viewId); + const group = view + ?.get(YjsDatabaseKey.groups) + ?.toArray() + .find((group) => group.get(YjsDatabaseKey.id) === groupId); + const groupColumns = group?.get(YjsDatabaseKey.groups); + const [fieldId, setFieldId] = useState(null); + const [columns, setColumns] = useState([]); + + useEffect(() => { + if (!viewId) return; + + const observerEvent = () => { + setFieldId(group?.get(YjsDatabaseKey.field_id) as string); + }; + + observerEvent(); + group?.observe(observerEvent); + + const observerColumns = () => { + if (!groupColumns) return; + setColumns(groupColumns.toJSON()); + }; + + observerColumns(); + groupColumns?.observe(observerColumns); + + return () => { + group?.unobserve(observerEvent); + groupColumns?.unobserve(observerColumns); + }; + }, [database, viewId, groupId, group, groupColumns]); + + return { + columns, + fieldId, + }; +} + +export function useRowsByGroup (groupId: string) { + const { columns, fieldId } = useGroup(groupId); + const rows = useRowDocMap(); + const rowOrders = useRowOrdersSelector(); + + const fields = useDatabaseFields(); + const [notFound, setNotFound] = useState(false); + const [groupResult, setGroupResult] = useState>(new Map()); + const view = useDatabaseView(); + const layoutSetting = view?.get(YjsDatabaseKey.layout_settings)?.get('1'); + + useEffect(() => { + if (!fieldId || !rowOrders || !rows) return; + + const onConditionsChange = () => { + const newResult = new Map(); + + const field = fields.get(fieldId); + + if (!field) { + setNotFound(true); + setGroupResult(newResult); + return; + } + + const groupResult = groupByField(rowOrders, rows, field); + + if (!groupResult) { + setGroupResult(newResult); + return; + } + + setGroupResult(groupResult); + }; + + onConditionsChange(); + + fields.observeDeep(onConditionsChange); + return () => { + fields.unobserveDeep(onConditionsChange); + }; + }, [fieldId, fields, rowOrders, rows]); + + const visibleColumns = columns.filter((column) => { + if (column.id === fieldId) return !layoutSetting?.get(YjsDatabaseKey.hide_ungrouped_column); + return column.visible; + }); + + return { + fieldId, + groupResult, + columns: visibleColumns, + notFound, + }; +} + +export function useRowOrdersSelector () { + const rows = useRowDocMap(); + const [rowOrders, setRowOrders] = useState(); + const view = useDatabaseView(); + const sorts = view?.get(YjsDatabaseKey.sorts); + const fields = useDatabaseFields(); + const filters = view?.get(YjsDatabaseKey.filters); + const onConditionsChange = useCallback(() => { + const originalRowOrders = view?.get(YjsDatabaseKey.row_orders).toJSON(); + + if (!originalRowOrders || !rows) return; + + if (sorts?.length === 0 && filters?.length === 0) { + setRowOrders(originalRowOrders); + return; + } + + let rowOrders: Row[] | undefined; + + if (sorts?.length) { + rowOrders = sortBy(originalRowOrders, sorts, fields, rows); + } + + if (filters?.length) { + rowOrders = filterBy(rowOrders ?? originalRowOrders, filters, fields, rows); + } + + if (rowOrders) { + setRowOrders(rowOrders); + } else { + setRowOrders(originalRowOrders); + } + }, [fields, filters, rows, sorts, view]); + + useEffect(() => { + onConditionsChange(); + }, [onConditionsChange]); + + useEffect(() => { + const throttleChange = debounce(onConditionsChange, 200); + + view?.get(YjsDatabaseKey.row_orders)?.observeDeep(throttleChange); + sorts?.observeDeep(throttleChange); + filters?.observeDeep(throttleChange); + fields?.observeDeep(throttleChange); + + return () => { + view?.get(YjsDatabaseKey.row_orders)?.unobserveDeep(throttleChange); + sorts?.unobserveDeep(throttleChange); + filters?.unobserveDeep(throttleChange); + fields?.unobserveDeep(throttleChange); + }; + }, [onConditionsChange, view, fields, filters, sorts]); + + return rowOrders; +} + +export function useRowDataSelector (rowId: string) { + const rowMap = useRowDocMap(); + const [row, setRow] = useState(null); + + useEffect(() => { + const rowDoc = rowMap?.[rowId]; + + if (!rowDoc || !rowDoc.share.has(YjsEditorKey.data_section)) return; + const rowSharedRoot = rowDoc?.getMap(YjsEditorKey.data_section); + const row = rowSharedRoot?.get(YjsEditorKey.database_row); + + setRow(row); + }, [rowId, rowMap]); + return { + row, + }; +} + +export function useCellSelector ({ rowId, fieldId }: { rowId: string; fieldId: string }) { + const { row } = useRowDataSelector(rowId); + const cell = row?.get(YjsDatabaseKey.cells)?.get(fieldId); + + const [cellValue, setCellValue] = useState(() => (cell ? parseYDatabaseCellToCell(cell) : undefined)); + + useEffect(() => { + if (!cell) return; + setCellValue(parseYDatabaseCellToCell(cell)); + const observerEvent = () => setCellValue(parseYDatabaseCellToCell(cell)); + + cell.observeDeep(observerEvent); + + return () => { + cell.unobserveDeep(observerEvent); + }; + }, [cell]); + + return cellValue; +} + +export interface CalendarEvent { + start?: Date; + end?: Date; + id: string; +} + +export function useCalendarEventsSelector () { + const setting = useCalendarLayoutSetting(); + const filedId = setting.fieldId; + const { field } = useFieldSelector(filedId); + const rowOrders = useRowOrdersSelector(); + const rows = useRowDocMap(); + const [events, setEvents] = useState([]); + const [emptyEvents, setEmptyEvents] = useState([]); + + useEffect(() => { + if (!field || !rowOrders || !rows) return; + const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; + + if (fieldType !== FieldType.DateTime) return; + const newEvents: CalendarEvent[] = []; + const emptyEvents: CalendarEvent[] = []; + + rowOrders?.forEach((row) => { + const cell = getCell(row.id, filedId, rows); + + if (!cell) { + emptyEvents.push({ + id: `${row.id}:${filedId}`, + }); + return; + } + + const value = parseYDatabaseCellToCell(cell) as DateTimeCell; + + if (!value || !value.data) { + emptyEvents.push({ + id: `${row.id}:${filedId}`, + }); + return; + } + + const getDate = (timestamp: string) => { + const dayjsResult = timestamp.length === 10 ? dayjs.unix(Number(timestamp)) : dayjs(timestamp); + + return dayjsResult.toDate(); + }; + + newEvents.push({ + id: `${row.id}:${filedId}`, + start: getDate(value.data), + end: value.endTimestamp && value.isRange ? getDate(value.endTimestamp) : getDate(value.data), + }); + }); + + setEvents(newEvents); + setEmptyEvents(emptyEvents); + }, [field, rowOrders, rows, filedId]); + + return { events, emptyEvents }; +} + +export function useCalendarLayoutSetting () { + const view = useDatabaseView(); + const layoutSetting = view?.get(YjsDatabaseKey.layout_settings)?.get('2'); + const [setting, setSetting] = useState({ + fieldId: '', + firstDayOfWeek: 0, + showWeekNumbers: true, + showWeekends: true, + layout: 0, + }); + + useEffect(() => { + const observerHandler = () => { + setSetting({ + fieldId: layoutSetting?.get(YjsDatabaseKey.field_id) as string, + firstDayOfWeek: Number(layoutSetting?.get(YjsDatabaseKey.first_day_of_week)), + showWeekNumbers: Boolean(layoutSetting?.get(YjsDatabaseKey.show_week_numbers)), + showWeekends: Boolean(layoutSetting?.get(YjsDatabaseKey.show_weekends)), + layout: Number(layoutSetting?.get(YjsDatabaseKey.layout_ty)), + }); + }; + + observerHandler(); + layoutSetting?.observe(observerHandler); + return () => { + layoutSetting?.unobserve(observerHandler); + }; + }, [layoutSetting]); + + return setting; +} + +export function getPrimaryFieldId (database: YDatabase) { + const fields = database?.get(YjsDatabaseKey.fields); + + return Array.from(fields?.keys() || []).find((fieldId) => { + return fields?.get(fieldId)?.get(YjsDatabaseKey.is_primary); + }); +} + +export function usePrimaryFieldId () { + const database = useDatabase(); + const [primaryFieldId, setPrimaryFieldId] = useState(null); + + useEffect(() => { + setPrimaryFieldId(getPrimaryFieldId(database) || null); + }, [database]); + + return primaryFieldId; +} + +export interface RowMeta { + documentId: string; + cover: { + data: string, + cover_type: RowCoverType, + } | null; + icon: string; + isEmptyDocument: boolean; +} + +const metaIdMapFromRowIdMap = new Map>(); + +function getMetaIdMap (rowId: string) { + const hasMetaIdMap = metaIdMapFromRowIdMap.has(rowId); + + if (!hasMetaIdMap) { + const parser = metaIdFromRowId(rowId); + const map = new Map(); + + map.set(RowMetaKey.IconId, parser(RowMetaKey.IconId)); + map.set(RowMetaKey.CoverId, parser(RowMetaKey.CoverId)); + map.set(RowMetaKey.DocumentId, parser(RowMetaKey.DocumentId)); + map.set(RowMetaKey.IsDocumentEmpty, parser(RowMetaKey.IsDocumentEmpty)); + metaIdMapFromRowIdMap.set(rowId, map); + return map; + } + + return metaIdMapFromRowIdMap.get(rowId) as Map; +} + +export const useRowMetaSelector = (rowId: string) => { + const [meta, setMeta] = useState(); + const rowMap = useRowDocMap(); + + const updateMeta = useCallback(() => { + + const row = rowMap?.[rowId]; + + if (!row || !row.share.has(YjsEditorKey.data_section)) return; + + const rowSharedRoot = row.getMap(YjsEditorKey.data_section); + + const yMeta = rowSharedRoot?.get(YjsEditorKey.meta); + + if (!yMeta) return; + + const metaKeyMap = getMetaIdMap(rowId); + + const iconKey = metaKeyMap.get(RowMetaKey.IconId) ?? ''; + const coverKey = metaKeyMap.get(RowMetaKey.CoverId) ?? ''; + const documentId = metaKeyMap.get(RowMetaKey.DocumentId) ?? ''; + const isEmptyDocumentKey = metaKeyMap.get(RowMetaKey.IsDocumentEmpty) ?? ''; + const metaJson = yMeta.toJSON(); + + const icon = metaJson[iconKey]; + let cover = null; + + try { + cover = metaJson[coverKey] ? JSON.parse(metaJson[coverKey]) : null; + } catch (e) { + // do nothing + } + + const isEmptyDocument = metaJson[isEmptyDocumentKey]; + + setMeta({ + icon, + cover, + documentId, + isEmptyDocument, + }); + }, [rowId, rowMap]); + + useEffect(() => { + if (!rowMap) return; + updateMeta(); + const observerEvent = () => updateMeta(); + + const rowDoc = rowMap[rowId]; + + if (!rowDoc || !rowDoc.share.has(YjsEditorKey.data_section)) return; + const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section); + const meta = rowSharedRoot?.get(YjsEditorKey.meta) as YDatabaseMetas; + + meta?.observeDeep(observerEvent); + return () => { + meta?.unobserveDeep(observerEvent); + }; + }, [rowId, rowMap, updateMeta]); + + return meta; +}; diff --git a/frontend/appflowy_web_app/src/application/database-yjs/sort.ts b/frontend/appflowy_web_app/src/application/database-yjs/sort.ts new file mode 100644 index 0000000000000..5e6e078d89cdc --- /dev/null +++ b/frontend/appflowy_web_app/src/application/database-yjs/sort.ts @@ -0,0 +1,81 @@ +import { + RowId, + YDatabaseField, + YDatabaseFields, + YDatabaseRow, + YDatabaseSorts, + YDoc, + YjsDatabaseKey, + YjsEditorKey, +} from '@/application/types'; +import { FieldType, SortCondition } from '@/application/database-yjs/database.type'; +import { parseChecklistData, parseSelectOptionCellData } from '@/application/database-yjs/fields'; +import { Row } from '@/application/database-yjs/selector'; +import { orderBy } from 'lodash-es'; + +export function sortBy (rows: Row[], sorts: YDatabaseSorts, fields: YDatabaseFields, rowMetas: Record) { + const sortArray = sorts.toArray(); + + if (sortArray.length === 0 || Object.keys(rowMetas).length === 0 || fields.size === 0) return rows; + const iteratees = sortArray.map((sort) => { + return (row: { id: string }) => { + const fieldId = sort.get(YjsDatabaseKey.field_id); + const field = fields.get(fieldId); + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + const rowId = row.id; + const rowMeta = rowMetas[rowId]; + + const defaultData = parseCellDataForSort(field, ''); + + const meta = rowMeta?.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow; + + if (!meta) return defaultData; + if (fieldType === FieldType.LastEditedTime) { + return meta.get(YjsDatabaseKey.last_modified); + } + + if (fieldType === FieldType.CreatedTime) { + return meta.get(YjsDatabaseKey.created_at); + } + + const cells = meta.get(YjsDatabaseKey.cells); + const cell = cells.get(fieldId); + + if (!cell) return defaultData; + + return parseCellDataForSort(field, cell.get(YjsDatabaseKey.data) ?? ''); + }; + }); + const orders = sortArray.map((sort) => { + const condition = Number(sort.get(YjsDatabaseKey.condition)); + + if (condition === SortCondition.Descending) return 'desc'; + return 'asc'; + }); + + return orderBy(rows, iteratees, orders); +} + +export function parseCellDataForSort (field: YDatabaseField, data: string | boolean | number | object) { + const fieldType = Number(field.get(YjsDatabaseKey.type)); + + switch (fieldType) { + case FieldType.RichText: + case FieldType.URL: + return data ? data : '\uFFFF'; + case FieldType.Number: + return data === 'string' && !isNaN(parseInt(data)) ? parseInt(data) : data; + case FieldType.Checkbox: + return data === 'Yes'; + case FieldType.SingleSelect: + case FieldType.MultiSelect: + return parseSelectOptionCellData(field, data as string); + case FieldType.Checklist: + return parseChecklistData(data as string)?.percentage ?? 0; + case FieldType.DateTime: + return Number(data); + case FieldType.Relation: + return ''; + } +} diff --git a/frontend/appflowy_web_app/src/application/db/index.ts b/frontend/appflowy_web_app/src/application/db/index.ts new file mode 100644 index 0000000000000..6184cc5f519a1 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/db/index.ts @@ -0,0 +1,113 @@ +import { userSchema, UserTable } from '@/application/db/tables/users'; +import { YDoc } from '@/application/types'; +import { databasePrefix } from '@/application/constants'; +import { IndexeddbPersistence } from 'y-indexeddb'; +import * as Y from 'yjs'; +import BaseDexie from 'dexie'; +import { viewMetasSchema, ViewMetasTable } from '@/application/db/tables/view_metas'; +import { rowSchema, rowTable } from '@/application/db/tables/rows'; + +type DexieTables = ViewMetasTable & UserTable & rowTable; + +export type Dexie = BaseDexie & T; + +export const db = new BaseDexie(`${databasePrefix}_cache`) as Dexie; +const schema = Object.assign({}, { ...viewMetasSchema, ...userSchema, ...rowSchema }); + +db.version(1).stores(schema); + +const openedSet = new Set(); + +/** + * Open the collaboration database, and return a function to close it + */ +export async function openCollabDB (docName: string): Promise { + const name = `${databasePrefix}_${docName}`; + const doc = new Y.Doc({ + guid: docName, + }); + + const provider = new IndexeddbPersistence(name, doc); + + let resolve: (value: unknown) => void; + const promise = new Promise((resolveFn) => { + resolve = resolveFn; + }); + + provider.on('synced', () => { + if (!openedSet.has(name)) { + openedSet.add(name); + } + + resolve(true); + }); + + await promise; + + return doc as YDoc; +} + +export async function closeCollabDB (docName: string) { + const name = `${databasePrefix}_${docName}`; + + if (openedSet.has(name)) { + openedSet.delete(name); + } + + const doc = new Y.Doc(); + + const provider = new IndexeddbPersistence(name, doc); + + await provider.destroy(); +} + +export async function clearData () { + const databases = await indexedDB.databases(); + + const deleteDatabase = async (dbInfo: IDBDatabaseInfo): Promise => { + const dbName = dbInfo.name; + + if (!dbName) return false; + + return new Promise((resolve) => { + const request = indexedDB.open(dbName); + + request.onsuccess = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + db.close(); + + const deleteRequest = indexedDB.deleteDatabase(dbName); + + deleteRequest.onsuccess = () => { + console.log(`Database ${dbName} deleted successfully`); + resolve(true); + }; + + deleteRequest.onerror = (event) => { + console.error(`Error deleting database ${dbName}`, event); + resolve(false); + }; + + deleteRequest.onblocked = () => { + console.warn(`Delete operation blocked for database ${dbName}`); + resolve(false); + }; + }; + + request.onerror = (event) => { + console.error(`Error opening database ${dbName}`, event); + resolve(false); + }; + }); + }; + + try { + const results = await Promise.all(databases.map(deleteDatabase)); + + return results.every(Boolean); + } catch (error) { + console.error('Error during database deletion process:', error); + return false; + } +} diff --git a/frontend/appflowy_web_app/src/application/db/tables/rows.ts b/frontend/appflowy_web_app/src/application/db/tables/rows.ts new file mode 100644 index 0000000000000..1275a347f42f7 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/db/tables/rows.ts @@ -0,0 +1,13 @@ +import { Table } from 'dexie'; + +export type rowTable = { + rows: Table<{ + row_id: string; + row_key: string; + version: number; + }>; +}; + +export const rowSchema = { + rows: 'row_key', +}; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/db/tables/users.ts b/frontend/appflowy_web_app/src/application/db/tables/users.ts new file mode 100644 index 0000000000000..2b84d1ad0ea24 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/db/tables/users.ts @@ -0,0 +1,10 @@ +import { User } from '@/application/types'; +import { Table } from 'dexie'; + +export type UserTable = { + users: Table; +}; + +export const userSchema = { + users: 'uuid', +}; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/db/tables/view_metas.ts b/frontend/appflowy_web_app/src/application/db/tables/view_metas.ts new file mode 100644 index 0000000000000..9c851e6fb1c28 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/db/tables/view_metas.ts @@ -0,0 +1,20 @@ +import { Table } from 'dexie'; +import { ViewInfo } from '@/application/types'; + +export type ViewMeta = { + publish_name: string; + + child_views: ViewInfo[]; + ancestor_views: ViewInfo[]; + + visible_view_ids: string[]; + database_relations: Record; +} & ViewInfo; + +export type ViewMetasTable = { + view_metas: Table; +}; + +export const viewMetasSchema = { + view_metas: 'publish_name', +}; diff --git a/frontend/appflowy_web_app/src/application/publish/context.tsx b/frontend/appflowy_web_app/src/application/publish/context.tsx new file mode 100644 index 0000000000000..a3c05f629ef83 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/publish/context.tsx @@ -0,0 +1,382 @@ +import { + AppendBreadcrumb, + CreateRowDoc, + LoadView, + LoadViewMeta, + View, + ViewInfo, +} from '@/application/types'; +import { db } from '@/application/db'; +import { ViewMeta } from '@/application/db/tables/view_metas'; +import { findAncestors, findView } from '@/components/_shared/outline/utils'; +import { useService } from '@/components/main/app.hooks'; +import { notify } from '@/components/_shared/notify'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +export interface PublishContextType { + namespace: string; + publishName: string; + isTemplate?: boolean; + isTemplateThumb?: boolean; + viewMeta?: ViewMeta; + toView: (viewId: string, blockId?: string) => Promise; + loadViewMeta: LoadViewMeta; + createRowDoc?: CreateRowDoc; + loadView: LoadView; + outline?: View[]; + appendBreadcrumb?: AppendBreadcrumb; + breadcrumbs: View[]; + rendered?: boolean; + onRendered?: () => void; +} + +export const PublishContext = createContext(null); + +export const PublishProvider = ({ + children, + namespace, + publishName, + isTemplateThumb, + isTemplate, +}: { + children: React.ReactNode; + namespace: string; + publishName: string; + isTemplateThumb?: boolean; + isTemplate?: boolean; +}) => { + const [outline, setOutline] = useState([]); + const createdRowKeys = useRef([]); + const [rendered, setRendered] = useState(false); + + const [subscribers, setSubscribers] = useState void>>(new Map()); + + useEffect(() => { + return () => { + setSubscribers(new Map()); + }; + }, []); + + const viewMeta = useLiveQuery(async () => { + const name = `${namespace}_${publishName}`; + + const view = await db.view_metas.get(name); + + if (!view) return; + + return { + ...view, + name: findView(outline, view.view_id)?.name || view.name, + }; + }, [namespace, publishName, outline]); + + const originalCrumbs = useMemo(() => { + if (!viewMeta || !outline) return []; + const ancestors = findAncestors(outline, viewMeta?.view_id); + + if (ancestors) return ancestors; + if (!viewMeta?.ancestor_views) return []; + const parseToView = (ancestor: ViewInfo): View => { + let extra = null; + + try { + extra = ancestor.extra ? JSON.parse(ancestor.extra) : null; + } catch (e) { + // do nothing + } + + return { + view_id: ancestor.view_id, + name: ancestor.name, + icon: ancestor.icon, + layout: ancestor.layout, + extra, + is_published: true, + children: [], + is_private: false, + }; + }; + + const currentView = parseToView(viewMeta); + + return viewMeta?.ancestor_views.slice(1).map(item => findView(outline, item.view_id) || parseToView(item)) || [currentView]; + }, [viewMeta, outline]); + + const [breadcrumbs, setBreadcrumbs] = useState([]); + + useEffect(() => { + setBreadcrumbs(originalCrumbs); + }, [originalCrumbs]); + + const appendBreadcrumb = useCallback((view?: View) => { + setBreadcrumbs((prev) => { + if (!view) { + return prev.slice(0, -1); + } + + const index = prev.findIndex((v) => v.view_id === view.view_id); + + if (index === -1) { + return [...prev, view]; + } + + const rest = prev.slice(0, index); + + return [...rest, view]; + }); + }, []); + + useEffect(() => { + db.view_metas.hook('creating', (primaryKey, obj) => { + const subscriber = subscribers.get(primaryKey); + + subscriber?.(obj); + + return obj; + }); + db.view_metas.hook('deleting', (primaryKey, obj) => { + const subscriber = subscribers.get(primaryKey); + + subscriber?.(obj); + + return; + }); + db.view_metas.hook('updating', (modifications, primaryKey, obj) => { + const subscriber = subscribers.get(primaryKey); + + subscriber?.({ + ...obj, + ...modifications, + }); + + return modifications; + }); + }, [subscribers]); + + const prevViewMeta = useRef(viewMeta); + + const service = useService(); + + useEffect(() => { + const rowKeys = createdRowKeys.current; + + createdRowKeys.current = []; + + if (!rowKeys.length) return; + rowKeys.forEach((rowKey) => { + try { + service?.deleteRowDoc(rowKey); + } catch (e) { + console.error(e); + } + }); + + }, [service, publishName]); + const navigate = useNavigate(); + const toView = useCallback( + async (viewId: string, blockId?: string) => { + try { + const res = await service?.getPublishInfo(viewId); + + if (!res) { + throw new Error('View has not been published yet'); + } + + const { namespace: viewNamespace, publishName } = res; + + prevViewMeta.current = undefined; + const searchParams = new URLSearchParams(''); + + if (blockId) { + searchParams.set('blockId', blockId); + } + + if (isTemplate) { + searchParams.set('template', 'true'); + } + + let url = `/${viewNamespace}/${publishName}`; + + if (searchParams.toString()) { + url += `?${searchParams.toString()}`; + } + + navigate(url, { + replace: true, + }); + return; + } catch (e) { + return Promise.reject(e); + } + }, + [navigate, service, isTemplate], + ); + + const loadOutline = useCallback(async () => { + if (!service || !namespace) return; + try { + const res = await service?.getPublishOutline(namespace); + + if (!res) { + throw new Error('Publish outline not found'); + } + + setOutline(res); + } catch (e) { + notify.error('Publish outline not found'); + } + }, [namespace, service]); + + const loadViewMeta = useCallback( + async (viewId: string, callback?: (meta: View) => void) => { + try { + const info = await service?.getPublishInfo(viewId); + + if (!info) { + throw new Error('View has not been published yet'); + } + + const { namespace, publishName } = info; + + const name = `${namespace}_${publishName}`; + + const meta = await service?.getPublishViewMeta(namespace, publishName); + + if (!meta) { + return Promise.reject(new Error('View meta has not been published yet')); + } + + const parseMetaToView = (meta: ViewInfo | ViewMeta): View => { + return { + is_private: false, + view_id: meta.view_id, + name: meta.name, + layout: meta.layout, + extra: meta.extra ? JSON.parse(meta.extra) : undefined, + icon: meta.icon, + children: meta.child_views?.map(parseMetaToView) || [], + is_published: true, + database_relations: 'database_relations' in meta ? meta.database_relations : undefined, + }; + }; + + const res = parseMetaToView(meta); + + callback?.(res); + + if (callback) { + setSubscribers((prev) => { + prev.set(name, (meta) => { + return callback?.(parseMetaToView(meta)); + }); + + return prev; + }); + } + + return res; + } catch (e) { + return Promise.reject(e); + } + }, + [service], + ); + + const createRowDoc = useCallback( + async (rowKey: string) => { + try { + const doc = await service?.createRowDoc(rowKey); + + if (!doc) { + throw new Error('Failed to create row doc'); + } + + createdRowKeys.current.push(rowKey); + return doc; + } catch (e) { + return Promise.reject(e); + } + }, + [service], + ); + + const loadView = useCallback( + async (viewId: string, isSubDocument?: boolean) => { + if (isSubDocument) { + const data = await service?.getPublishRowDocument(viewId); + + if (!data) { + return Promise.reject(new Error('View has not been published yet')); + } + + return data; + } + + try { + const res = await service?.getPublishInfo(viewId); + + if (!res) { + throw new Error('View has not been published yet'); + } + + const { namespace, publishName } = res; + + const data = service?.getPublishView(namespace, publishName); + + if (!data) { + throw new Error('View has not been published yet'); + } + + return data; + } catch (e) { + return Promise.reject(e); + } + }, + [service], + ); + + const onRendered = useCallback(() => { + setRendered(true); + }, []); + + useEffect(() => { + if (!viewMeta && prevViewMeta.current) { + window.location.reload(); + return; + } + + prevViewMeta.current = viewMeta; + }, [viewMeta]); + + useEffect(() => { + void loadOutline(); + }, [loadOutline]); + + return ( + + {children} + + ); +}; + +export function usePublishContext () { + return useContext(PublishContext); +} diff --git a/frontend/appflowy_web_app/src/application/publish/index.ts b/frontend/appflowy_web_app/src/application/publish/index.ts new file mode 100644 index 0000000000000..c38e8e82152d2 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/publish/index.ts @@ -0,0 +1 @@ +export * from './context'; diff --git a/frontend/appflowy_web_app/src/application/services/index.ts b/frontend/appflowy_web_app/src/application/services/index.ts new file mode 100644 index 0000000000000..70c63ed3cdda8 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/index.ts @@ -0,0 +1,11 @@ +import { AFService, AFServiceConfig } from '@/application/services/services.type'; +import { AFClientService } from '$client-services'; + +let service: AFService; + +export function getService (config: AFServiceConfig) { + if (service) return service; + + service = new AFClientService(config); + return service; +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts new file mode 100644 index 0000000000000..b80434a93d9d7 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/fetch.test.ts @@ -0,0 +1,116 @@ +import { expect } from '@jest/globals'; +import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '../fetch'; +import { APIService } from '@/application/services/js-services/http'; + +jest.mock('@/application/services/js-services/http', () => { + return { + APIService: { + getPublishView: jest.fn(), + getPublishViewMeta: jest.fn(), + getPublishInfoWithViewId: jest.fn(), + }, + }; +}); + +describe('Collab fetch functions with deduplication', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchPublishView', () => { + it('should fetch publish view without duplicating requests', async () => { + const namespace = 'namespace1'; + const publishName = 'publish1'; + const mockResponse = { data: 'mockData' }; + + (APIService.getPublishView as jest.Mock).mockResolvedValue(mockResponse); + + const result1 = fetchPublishView(namespace, publishName); + const result2 = fetchPublishView(namespace, publishName); + + expect(result1).toBe(result2); + await expect(result1).resolves.toEqual(mockResponse); + expect(APIService.getPublishView).toHaveBeenCalledTimes(1); + }); + + it('should fetch publish view with different params', async () => { + const namespace = 'namespace1'; + const publishName = 'publish1'; + const mockResponse = { data: 'mockData' }; + + (APIService.getPublishView as jest.Mock).mockResolvedValue(mockResponse); + + const result1 = fetchPublishView(namespace, publishName); + const result2 = fetchPublishView(namespace, 'publish2'); + + expect(result1).not.toBe(result2); + await expect(result1).resolves.toEqual(mockResponse); + await expect(result2).resolves.toEqual(mockResponse); + expect(APIService.getPublishView).toHaveBeenCalledTimes(2); + }); + }); + + describe('fetchViewInfo', () => { + it('should fetch view info without duplicating requests', async () => { + const viewId = 'view1'; + const mockResponse = { data: 'mockData' }; + + (APIService.getPublishInfoWithViewId as jest.Mock).mockResolvedValue(mockResponse); + + const result1 = fetchViewInfo(viewId); + const result2 = fetchViewInfo(viewId); + + expect(result1).toBe(result2); + await expect(result1).resolves.toEqual(mockResponse); + expect(APIService.getPublishInfoWithViewId).toHaveBeenCalledTimes(1); + }); + + it('should fetch view info with different params', async () => { + const viewId = 'view1'; + const mockResponse = { data: 'mockData' }; + + (APIService.getPublishInfoWithViewId as jest.Mock).mockResolvedValue(mockResponse); + + const result1 = fetchViewInfo(viewId); + const result2 = fetchViewInfo('view2'); + + expect(result1).not.toBe(result2); + await expect(result1).resolves.toEqual(mockResponse); + await expect(result2).resolves.toEqual(mockResponse); + expect(APIService.getPublishInfoWithViewId).toHaveBeenCalledTimes(2); + }); + }); + + describe('fetchPublishViewMeta', () => { + it('should fetch publish view meta without duplicating requests', async () => { + const namespace = 'namespace1'; + const publishName = 'publish1'; + const mockResponse = { data: 'mockData' }; + + (APIService.getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse); + + const result1 = fetchPublishViewMeta(namespace, publishName); + const result2 = fetchPublishViewMeta(namespace, publishName); + + expect(result1).toBe(result2); + await expect(result1).resolves.toEqual(mockResponse); + expect(APIService.getPublishViewMeta).toHaveBeenCalledTimes(1); + }); + + it('should fetch publish view meta with different params', async () => { + const namespace = 'namespace1'; + const publishName = 'publish1'; + const mockResponse = { data: 'mockData' }; + + (APIService.getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse); + + const result1 = fetchPublishViewMeta(namespace, publishName); + const result2 = fetchPublishViewMeta(namespace, 'publish2'); + + expect(result1).not.toBe(result2); + await expect(result1).resolves.toEqual(mockResponse); + await expect(result2).resolves.toEqual(mockResponse); + expect(APIService.getPublishViewMeta).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts new file mode 100644 index 0000000000000..9bab9c935216f --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/__tests__/index.test.ts @@ -0,0 +1,104 @@ +import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor'; +import * as Y from 'yjs'; +import { AFClientService } from '../index'; +import { fetchViewInfo } from '@/application/services/js-services/fetch'; +import { expect, jest } from '@jest/globals'; +import { getPublishView, getPublishViewMeta } from '@/application/services/js-services/cache'; + +jest.mock('@/application/services/js-services/http/http_api', () => { + return { + initAPIService: jest.fn(), + }; +}); +jest.mock('nanoid', () => { + return { + nanoid: jest.fn().mockReturnValue('12345678'), + }; +}); +jest.mock('@/application/services/js-services/fetch', () => { + return { + fetchPublishView: jest.fn(), + fetchPublishViewMeta: jest.fn(), + fetchViewInfo: jest.fn(), + }; +}); + +jest.mock('@/application/services/js-services/cache', () => { + return { + getPublishView: jest.fn(), + getPublishViewMeta: jest.fn(), + getBatchCollabs: jest.fn(), + }; +}); +describe('AFClientService', () => { + let service: AFClientService; + beforeEach(() => { + jest.clearAllMocks(); + service = new AFClientService({ + cloudConfig: { + baseURL: 'http://localhost:3000', + gotrueURL: 'http://localhost:3000', + wsURL: 'ws://localhost:3000', + }, + }); + }); + + it('should get view meta', async () => { + const namespace = 'namespace'; + const publishName = 'publishName'; + const mockResponse = { + view_id: 'view_id', + publish_name: publishName, + metadata: { + view: { + name: 'viewName', + view_id: 'view_id', + }, + child_views: [], + ancestor_views: [], + }, + }; + + // @ts-ignore + (getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse); + + const result = await service.getPublishViewMeta(namespace, publishName); + + expect(result).toEqual(mockResponse); + }); + + it('should get view', async () => { + const namespace = 'namespace'; + const publishName = 'publishName'; + const rowDoc = new Y.Doc(); + const mockResponse = { + doc: withTestingYDoc('1'), + rowDocMap: rowDoc.getMap(), + }; + + // @ts-ignore + (getPublishView as jest.Mock).mockResolvedValue(mockResponse); + + const result = await service.getPublishView(namespace, publishName); + + expect(result).toEqual(mockResponse.doc); + }); + + it('should get view info', async () => { + const viewId = 'viewId'; + const mockResponse = { + namespace: 'namespace', + publish_name: 'publishName', + }; + + // @ts-ignore + (fetchViewInfo as jest.Mock).mockResolvedValue(mockResponse); + + const result = await service.getPublishInfo(viewId); + + expect(result).toEqual({ + namespace: 'namespace', + publishName: 'publishName', + }); + }); +}); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/cache/__tests__/cache.test.ts b/frontend/appflowy_web_app/src/application/services/js-services/cache/__tests__/cache.test.ts new file mode 100644 index 0000000000000..2acb4a8b3b53c --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/cache/__tests__/cache.test.ts @@ -0,0 +1,126 @@ +import { Types } from '@/application/types'; +import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor'; +import { expect } from '@jest/globals'; +import { collabTypeToDBType, getPublishView, getPublishViewMeta } from '@/application/services/js-services/cache'; +import { openCollabDB, db } from '@/application/db'; +import { StrategyType } from '@/application/services/js-services/cache/types'; + +jest.mock('@/application/ydoc/apply', () => ({ + applyYDoc: jest.fn(), +})); + +jest.mock('@/application/db', () => ({ + openCollabDB: jest.fn(), + db: { + view_metas: { + get: jest.fn(), + put: jest.fn(), + }, + }, +})); + +const normalDoc = withTestingYDoc('1'); +const mockFetcher = jest.fn(); + +async function runTestWithStrategy (strategy: StrategyType) { + return getPublishView( + mockFetcher, + { + namespace: 'appflowy', + publishName: 'test', + }, + strategy, + ); +} + +async function runGetPublishViewMetaWithStrategy (strategy: StrategyType) { + return getPublishViewMeta( + mockFetcher, + { + namespace: 'appflowy', + publishName: 'test', + }, + strategy, + ); +} + +describe('Cache functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFetcher.mockClear(); + (openCollabDB as jest.Mock).mockClear(); + }); + + describe('getPublishView', () => { + it('should call fetcher when no cache found', async () => { + (openCollabDB as jest.Mock).mockResolvedValue(normalDoc); + mockFetcher.mockResolvedValue({ data: [1, 2, 3], meta: { metadata: { view: { id: '1' } } } }); + (db.view_metas.get as jest.Mock).mockResolvedValue(undefined); + await runTestWithStrategy(StrategyType.CACHE_FIRST); + expect(mockFetcher).toBeCalledTimes(1); + + await runTestWithStrategy(StrategyType.CACHE_AND_NETWORK); + expect(mockFetcher).toBeCalledTimes(2); + await expect(runTestWithStrategy(StrategyType.CACHE_ONLY)).rejects.toThrow('No cache found'); + }); + it('should call fetcher when cache is invalid or strategy is CACHE_AND_NETWORK', async () => { + (openCollabDB as jest.Mock).mockResolvedValue(normalDoc); + (db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' }); + mockFetcher.mockResolvedValue({ data: [1, 2, 3], meta: { metadata: { view: { id: '1' } } } }); + await runTestWithStrategy(StrategyType.CACHE_ONLY); + expect(openCollabDB).toBeCalledTimes(1); + + await runTestWithStrategy(StrategyType.CACHE_FIRST); + expect(openCollabDB).toBeCalledTimes(2); + expect(mockFetcher).toBeCalledTimes(0); + + await runTestWithStrategy(StrategyType.CACHE_AND_NETWORK); + expect(openCollabDB).toBeCalledTimes(3); + expect(mockFetcher).toBeCalledTimes(1); + }); + }); + + describe('getPublishViewMeta', () => { + it('should call fetcher when no cache found', async () => { + mockFetcher.mockResolvedValue({ metadata: { view: { id: '1' }, child_views: [], ancestor_views: [] } }); + (db.view_metas.get as jest.Mock).mockResolvedValue(undefined); + await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_FIRST); + expect(mockFetcher).toBeCalledTimes(1); + + await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_AND_NETWORK); + expect(mockFetcher).toBeCalledTimes(2); + + await expect(runGetPublishViewMetaWithStrategy(StrategyType.CACHE_ONLY)).rejects.toThrow('No cache found'); + }); + + it('should call fetcher when cache is invalid or strategy is CACHE_AND_NETWORK', async () => { + (openCollabDB as jest.Mock).mockResolvedValue(normalDoc); + (db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' }); + + mockFetcher.mockResolvedValue({ metadata: { view: { id: '1' }, child_views: [], ancestor_views: [] } }); + const meta = await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_ONLY); + expect(openCollabDB).toBeCalledTimes(0); + expect(meta).toBeDefined(); + + await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_FIRST); + expect(openCollabDB).toBeCalledTimes(0); + expect(mockFetcher).toBeCalledTimes(0); + + await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_AND_NETWORK); + expect(openCollabDB).toBeCalledTimes(0); + expect(mockFetcher).toBeCalledTimes(1); + }); + }); +}); + +describe('collabTypeToDBType', () => { + it('should return correct DB type', () => { + expect(collabTypeToDBType(Types.Document)).toBe('document'); + expect(collabTypeToDBType(Types.Folder)).toBe('folder'); + expect(collabTypeToDBType(Types.Database)).toBe('database'); + expect(collabTypeToDBType(Types.WorkspaceDatabase)).toBe('databases'); + expect(collabTypeToDBType(Types.DatabaseRow)).toBe('database_row'); + expect(collabTypeToDBType(Types.UserAwareness)).toBe('user_awareness'); + expect(collabTypeToDBType(Types.Empty)).toBe(''); + }); +}); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts new file mode 100644 index 0000000000000..ba34ebbfaa7d0 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/cache/index.ts @@ -0,0 +1,421 @@ +import { closeCollabDB, db, openCollabDB } from '@/application/db'; +import { Fetcher, StrategyType } from '@/application/services/js-services/cache/types'; +import { + DatabaseId, + PublishViewMetaData, + RowId, + Types, + User, + ViewId, + ViewInfo, + YDoc, + YjsEditorKey, + YSharedRoot, +} from '@/application/types'; +import { applyYDoc } from '@/application/ydoc/apply'; + +export function collabTypeToDBType (type: Types) { + switch (type) { + case Types.Folder: + return 'folder'; + case Types.Document: + return 'document'; + case Types.Database: + return 'database'; + case Types.WorkspaceDatabase: + return 'databases'; + case Types.DatabaseRow: + return 'database_row'; + case Types.UserAwareness: + return 'user_awareness'; + default: + return ''; + } +} + +const collabSharedRootKeyMap = { + [Types.Folder]: YjsEditorKey.folder, + [Types.Document]: YjsEditorKey.document, + [Types.Database]: YjsEditorKey.database, + [Types.WorkspaceDatabase]: YjsEditorKey.workspace_database, + [Types.DatabaseRow]: YjsEditorKey.database_row, + [Types.UserAwareness]: YjsEditorKey.user_awareness, + [Types.Empty]: YjsEditorKey.empty, +}; + +export function hasCollabCache (doc: YDoc) { + const data = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; + + return Object.values(collabSharedRootKeyMap).some((key) => { + return data.has(key); + }); +} + +export async function hasViewMetaCache (name: string) { + const data = await db.view_metas.get(name); + + return !!data; +} + +export async function hasUserCache (userId: string) { + const data = await db.users.get(userId); + + return !!data; +} + +export async function getPublishViewMeta< + T extends { + view: ViewInfo; + child_views: ViewInfo[]; + ancestor_views: ViewInfo[]; + } +> ( + fetcher: Fetcher, + { + namespace, + publishName, + }: { + namespace: string; + publishName: string; + }, + strategy: StrategyType = StrategyType.CACHE_AND_NETWORK, +) { + const name = `${namespace}_${publishName}`; + const exist = await hasViewMetaCache(name); + const meta = await db.view_metas.get(name); + + switch (strategy) { + case StrategyType.CACHE_ONLY: { + if (!exist) { + throw new Error('No cache found'); + } + + return meta; + } + + case StrategyType.CACHE_FIRST: { + if (!exist) { + return revalidatePublishViewMeta(name, fetcher); + } + + return meta; + } + + case StrategyType.CACHE_AND_NETWORK: { + if (!exist) { + return revalidatePublishViewMeta(name, fetcher); + } else { + void revalidatePublishViewMeta(name, fetcher); + } + + return meta; + } + + default: { + return revalidatePublishViewMeta(name, fetcher); + } + } +} + +export async function getUser< + T extends User +> ( + fetcher: Fetcher, + userId?: string, + strategy: StrategyType = StrategyType.CACHE_AND_NETWORK, +) { + const exist = userId && (await hasUserCache(userId)); + const data = await db.users.get(userId); + + switch (strategy) { + case StrategyType.CACHE_ONLY: { + if (!exist) { + throw new Error('No cache found'); + } + + return data; + } + + case StrategyType.CACHE_FIRST: { + if (!exist) { + return revalidateUser(fetcher); + } + + return data; + } + + case StrategyType.CACHE_AND_NETWORK: { + if (!exist) { + return revalidateUser(fetcher); + } else { + void revalidateUser(fetcher); + } + + return data; + } + + default: { + return revalidateUser(fetcher); + } + } +} + +export async function getPublishView< + T extends { + data: Uint8Array; + rows?: Record; + visibleViewIds?: ViewId[]; + relations?: Record; + subDocuments?: Record; + meta: { + view: ViewInfo; + child_views: ViewInfo[]; + ancestor_views: ViewInfo[]; + }; + } +> ( + fetcher: Fetcher, + { + namespace, + publishName, + }: { + namespace: string; + publishName: string; + }, + strategy: StrategyType = StrategyType.CACHE_AND_NETWORK, +) { + const name = `${namespace}_${publishName}`; + + const doc = await openCollabDB(name); + + const exist = (await hasViewMetaCache(name)) && hasCollabCache(doc); + + switch (strategy) { + case StrategyType.CACHE_ONLY: { + if (!exist) { + throw new Error('No cache found'); + } + + break; + } + + case StrategyType.CACHE_FIRST: { + if (!exist) { + await revalidatePublishView(name, fetcher, doc); + } + + break; + } + + case StrategyType.CACHE_AND_NETWORK: { + if (!exist) { + await revalidatePublishView(name, fetcher, doc); + } else { + void revalidatePublishView(name, fetcher, doc); + } + + break; + } + + default: { + await revalidatePublishView(name, fetcher, doc); + break; + } + } + + return { doc }; +} + +export async function getPageDoc; +}> (fetcher: Fetcher, name: string, strategy: StrategyType = StrategyType.CACHE_AND_NETWORK) { + + const doc = await openCollabDB(name); + + const exist = hasCollabCache(doc); + + switch (strategy) { + case StrategyType.CACHE_ONLY: { + if (!exist) { + throw new Error('No cache found'); + } + + break; + } + + case StrategyType.CACHE_FIRST: { + if (!exist) { + await revalidateView(fetcher, doc); + } + + break; + } + + case StrategyType.CACHE_AND_NETWORK: { + if (!exist) { + await revalidateView(fetcher, doc); + } else { + void revalidateView(fetcher, doc); + } + + break; + } + + default: { + await revalidateView(fetcher, doc); + break; + } + } + + return { doc }; +} + +async function updateRows (collab: YDoc, rows: Record) { + const bulkData = []; + + for (const [key, value] of Object.entries(rows)) { + const rowKey = `${collab.guid}_rows_${key}`; + const doc = await createRowDoc(rowKey); + + const dbRow = await db.rows.get(key); + + applyYDoc(doc, new Uint8Array(value)); + + bulkData.push({ + row_id: key, + version: (dbRow?.version || 0) + 1, + row_key: rowKey, + }); + } + + await db.rows.bulkPut(bulkData); +} + +export async function revalidateView< + T extends { + data: Uint8Array; + rows?: Record; + }> (fetcher: Fetcher, collab: YDoc) { + try { + const { data, rows } = await fetcher(); + + if (rows) { + await updateRows(collab, rows); + } + + applyYDoc(collab, data); + } catch (e) { + return Promise.reject(e); + } + +} + +export async function revalidatePublishViewMeta< + T extends { + view: ViewInfo; + child_views: ViewInfo[]; + ancestor_views: ViewInfo[]; + } +> (name: string, fetcher: Fetcher) { + const { view, child_views, ancestor_views } = await fetcher(); + + const dbView = await db.view_metas.get(name); + + await db.view_metas.put( + { + publish_name: name, + ...view, + child_views: child_views, + ancestor_views: ancestor_views, + visible_view_ids: dbView?.visible_view_ids ?? [], + database_relations: dbView?.database_relations ?? {}, + }, + name, + ); + + return db.view_metas.get(name); +} + +export async function revalidatePublishView< + T extends { + data: Uint8Array; + rows?: Record; + visibleViewIds?: ViewId[]; + relations?: Record; + subDocuments?: Record; + meta: PublishViewMetaData; + } +> (name: string, fetcher: Fetcher, collab: YDoc) { + const { data, meta, rows, visibleViewIds = [], relations = {}, subDocuments } = await fetcher(); + + await db.view_metas.put( + { + publish_name: name, + ...meta.view, + child_views: meta.child_views, + ancestor_views: meta.ancestor_views, + visible_view_ids: visibleViewIds, + database_relations: relations, + }, + name, + ); + + if (rows) { + await updateRows(collab, rows); + } + + if (subDocuments) { + for (const [key, value] of Object.entries(subDocuments)) { + const doc = await openCollabDB(key); + + applyYDoc(doc, new Uint8Array(value)); + } + } + + applyYDoc(collab, data); +} + +export async function deleteViewMeta (name: string) { + try { + await db.view_metas.delete(name); + + } catch (e) { + console.error(e); + } +} + +export async function deleteView (name: string) { + console.log('deleteView', name); + await deleteViewMeta(name); + await closeCollabDB(name); + + await closeCollabDB(`${name}_rows`); +} + +export async function revalidateUser< + T extends User> (fetcher: Fetcher) { + const data = await fetcher(); + + await db.users.put(data, data.uuid); + + return data; +} + +const rowDocs = new Map(); + +export async function createRowDoc (rowKey: string) { + if (rowDocs.has(rowKey)) { + return rowDocs.get(rowKey) as YDoc; + } + + const doc = await openCollabDB(rowKey); + + rowDocs.set(rowKey, doc); + + return doc; +} + +export function deleteRowDoc (rowKey: string) { + rowDocs.delete(rowKey); +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/cache/types.ts b/frontend/appflowy_web_app/src/application/services/js-services/cache/types.ts new file mode 100644 index 0000000000000..1c1a9497235f2 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/cache/types.ts @@ -0,0 +1,12 @@ +export enum StrategyType { + // Cache only: return the cache if it exists, otherwise throw an error + CACHE_ONLY = 'CACHE_ONLY', + // Cache first: return the cache if it exists, otherwise fetch from the network + CACHE_FIRST = 'CACHE_FIRST', + // Cache and network: return the cache if it exists, otherwise fetch from the network and update the cache + CACHE_AND_NETWORK = 'CACHE_AND_NETWORK', + // Network only: fetch from the network and update the cache + NETWORK_ONLY = 'NETWORK_ONLY', +} + +export type Fetcher = () => Promise; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts b/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts new file mode 100644 index 0000000000000..783ba764757ef --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/fetch.ts @@ -0,0 +1,55 @@ +import { APIService } from '@/application/services/js-services/http'; + +const pendingRequests = new Map(); + +function generateRequestKey (url: string, params: T) { + if (!params) return url; + + try { + return `${url}_${JSON.stringify(params)}`; + } catch (_e) { + return `${url}_${params}`; + } +} + +// Deduplication fetch requests +// When multiple requests are made to the same URL with the same params, only one request is made +// and the result is shared with all the requests +function fetchWithDeduplication (url: string, params: Req, fetchFunction: () => Promise): Promise { + const requestKey = generateRequestKey(url, params); + + if (pendingRequests.has(requestKey)) { + return pendingRequests.get(requestKey); + } + + const fetchPromise = fetchFunction().finally(() => { + pendingRequests.delete(requestKey); + }); + + pendingRequests.set(requestKey, fetchPromise); + return fetchPromise; +} + +export function fetchPublishView (namespace: string, publishName: string) { + const fetchFunction = () => APIService.getPublishView(namespace, publishName); + + return fetchWithDeduplication(`fetchPublishView_${namespace}`, { publishName }, fetchFunction); +} + +export function fetchPageCollab (workspaceId: string, viewId: string) { + const fetchFunction = () => APIService.getPageCollab(workspaceId, viewId); + + return fetchWithDeduplication(`fetchPageCollab_${workspaceId}`, { viewId }, fetchFunction); +} + +export function fetchViewInfo (viewId: string) { + const fetchFunction = () => APIService.getPublishInfoWithViewId(viewId); + + return fetchWithDeduplication(`fetchViewInfo`, { viewId }, fetchFunction); +} + +export function fetchPublishViewMeta (namespace: string, publishName: string) { + const fetchFunction = () => APIService.getPublishViewMeta(namespace, publishName); + + return fetchWithDeduplication(`fetchPublishViewMeta_${namespace}`, { publishName }, fetchFunction); +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts new file mode 100644 index 0000000000000..4668af7d906d8 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts @@ -0,0 +1,103 @@ +import { refreshToken as refreshSessionToken } from '@/application/session/token'; +import axios, { AxiosInstance } from 'axios'; + +let axiosInstance: AxiosInstance | null = null; + +export function initGrantService (baseURL: string) { + if (axiosInstance) { + return; + } + + axiosInstance = axios.create({ + baseURL, + }); + + axiosInstance.interceptors.request.use((config) => { + Object.assign(config.headers, { + 'Content-Type': 'application/json', + }); + + return config; + }); +} + +export async function refreshToken (refresh_token: string) { + const response = await axiosInstance?.post<{ + access_token: string; + expires_at: number; + refresh_token: string; + }>('/token?grant_type=refresh_token', { + refresh_token, + }); + + const newToken = response?.data; + + if (newToken) { + refreshSessionToken(JSON.stringify(newToken)); + } + + return newToken; +} + +export async function signInWithMagicLink (email: string, authUrl: string) { + const res = await axiosInstance?.post( + '/magiclink', + { + code_challenge: '', + code_challenge_method: '', + data: {}, + email, + }, + { + headers: { + Redirect_to: authUrl, + }, + }, + ); + + return res?.data; +} + +export async function settings () { + const res = await axiosInstance?.get('/settings'); + + return res?.data; +} + +export function signInGoogle (authUrl: string) { + const provider = 'google'; + const redirectTo = encodeURIComponent(authUrl); + const accessType = 'offline'; + const prompt = 'consent'; + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}&access_type=${accessType}&prompt=${prompt}`; + + window.open(url, '_current'); +} + +export function signInApple (authUrl: string) { + const provider = 'apple'; + const redirectTo = encodeURIComponent(authUrl); + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`; + + window.open(url, '_current'); +} + +export function signInGithub (authUrl: string) { + const provider = 'github'; + const redirectTo = encodeURIComponent(authUrl); + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`; + + window.open(url, '_current'); +} + +export function signInDiscord (authUrl: string) { + const provider = 'discord'; + const redirectTo = encodeURIComponent(authUrl); + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`; + + window.open(url, '_current'); +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts new file mode 100644 index 0000000000000..f1e8799d19b55 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts @@ -0,0 +1,1238 @@ +import { + DatabaseId, + FolderView, + RowId, + User, + View, + ViewId, + ViewLayout, + Workspace, + Invitation, + Types, + AFWebUser, + GetRequestAccessInfoResponse, + Subscriptions, + SubscriptionPlan, + SubscriptionInterval, + RequestAccessInfoStatus, ViewInfo, +} from '@/application/types'; +import { GlobalComment, Reaction } from '@/application/comment.type'; +import { initGrantService, refreshToken } from '@/application/services/js-services/http/gotrue'; +import { blobToBytes } from '@/application/services/js-services/http/utils'; +import { AFCloudConfig } from '@/application/services/services.type'; +import { getTokenParsed, invalidToken } from '@/application/session/token'; +import { + Template, + TemplateCategory, + TemplateCategoryFormValues, + TemplateCreator, TemplateCreatorFormValues, TemplateSummary, + UploadTemplatePayload, +} from '@/application/template.type'; +import axios, { AxiosInstance } from 'axios'; +import dayjs from 'dayjs'; + +export * from './gotrue'; + +let axiosInstance: AxiosInstance | null = null; + +export function initAPIService (config: AFCloudConfig) { + if (axiosInstance) { + return; + } + + axiosInstance = axios.create({ + baseURL: config.baseURL, + headers: { + 'Content-Type': 'application/json', + }, + }); + + initGrantService(config.gotrueURL); + + axiosInstance.interceptors.request.use( + async (config) => { + const token = getTokenParsed(); + + if (!token) { + return config; + } + + const isExpired = dayjs().isAfter(dayjs.unix(token.expires_at)); + + let access_token = token.access_token; + const refresh_token = token.refresh_token; + + if (isExpired) { + const newToken = await refreshToken(refresh_token); + + access_token = newToken?.access_token || ''; + } + + if (access_token) { + Object.assign(config.headers, { + Authorization: `Bearer ${access_token}`, + }); + } + + return config; + }, + (error) => { + return Promise.reject(error); + }, + ); + + axiosInstance.interceptors.response.use(async (response) => { + const status = response.status; + + if (status === 401) { + const token = getTokenParsed(); + + if (!token) { + invalidToken(); + return response; + } + + const refresh_token = token.refresh_token; + + try { + await refreshToken(refresh_token); + } catch (e) { + invalidToken(); + } + } + + return response; + }); +} + +export async function signInWithUrl (url: string) { + const hash = new URL(url).hash; + + if (!hash) { + return Promise.reject('No hash found'); + } + + const params = new URLSearchParams(hash.slice(1)); + const accessToken = params.get('access_token'); + const refresh_token = params.get('refresh_token'); + + if (!accessToken || !refresh_token) { + return Promise.reject({ + code: -1, + message: 'No access token or refresh token found', + }); + } + + try { + await verifyToken(accessToken); + } catch (e) { + return Promise.reject({ + code: -1, + message: 'Verify token failed', + }); + } + + try { + await refreshToken(refresh_token); + } catch (e) { + return Promise.reject({ + code: -1, + message: 'Refresh token failed', + }); + } +} + +export async function verifyToken (accessToken: string) { + const url = `/api/user/verify/${accessToken}`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + is_new: boolean; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function getCurrentUser (): Promise { + const url = '/api/user/profile'; + const response = await axiosInstance?.get<{ + code: number; + data?: { + uid: number; + uuid: string; + email: string; + name: string; + metadata: { + icon_url: string; + }; + encryption_sign: null; + latest_workspace_id: string; + updated_at: number; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + const { uid, uuid, email, name, metadata } = data.data; + + return { + uid: String(uid), + uuid, + email, + name, + avatar: metadata.icon_url, + latestWorkspaceId: data.data.latest_workspace_id, + }; + } + + return Promise.reject(data); +} + +interface AFWorkspace { + workspace_id: string, + owner_uid: number, + owner_name: string, + workspace_name: string, + icon: string, + created_at: string, + member_count: number, + database_storage_id: string, +} + +function afWorkspace2Workspace (workspace: AFWorkspace): Workspace { + return { + id: workspace.workspace_id, + owner: { + uid: workspace.owner_uid, + name: workspace.owner_name, + }, + name: workspace.workspace_name, + icon: workspace.icon, + memberCount: workspace.member_count, + databaseStorageId: workspace.database_storage_id, + createdAt: workspace.created_at, + }; +} + +export async function openWorkspace (workspaceId: string) { + const url = `/api/workspace/${workspaceId}/open`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function getUserWorkspaceInfo (): Promise<{ + user_id: string; + selected_workspace: Workspace; + workspaces: Workspace[]; +}> { + const url = '/api/user/workspace'; + const response = await axiosInstance?.get<{ + code: number, + message: string, + data: { + user_profile: { + uuid: string; + }, + visiting_workspace: AFWorkspace, + workspaces: AFWorkspace[] + } + + }>(url); + + const data = response?.data; + + if (data?.code === 0) { + const { visiting_workspace, workspaces, user_profile } = data.data; + + return { + user_id: user_profile.uuid, + selected_workspace: afWorkspace2Workspace(visiting_workspace), + workspaces: workspaces.map(afWorkspace2Workspace), + }; + } + + return Promise.reject(data); +} + +export async function getPublishViewMeta (namespace: string, publishName: string) { + const url = `/api/workspace/v1/published/${namespace}/${publishName}`; + const response = await axiosInstance?.get<{ + code: number; + data: { + view: ViewInfo; + child_views: ViewInfo[]; + ancestor_views: ViewInfo[]; + }; + message: string; + }>(url); + + if (response?.data.code !== 0) { + return Promise.reject(response?.data); + } + + return response?.data.data; +} + +export async function getPublishViewBlob (namespace: string, publishName: string) { + const url = `/api/workspace/published/${namespace}/${publishName}/blob`; + const response = await axiosInstance?.get(url, { + responseType: 'blob', + }); + + return blobToBytes(response?.data); +} + +export async function updateCollab (workspaceId: string, objectId: string, collabType: Types, docState: Uint8Array, context: { + version_vector: number; +}) { + const url = `/api/workspace/v1/${workspaceId}/collab/${objectId}/web-update`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, { + doc_state: Array.from(docState), + collab_type: collabType, + }); + + if (response?.data.code !== 0) { + return Promise.reject(response?.data); + } + + return context; +} + +export async function getCollab (workspaceId: string, objectId: string, collabType: Types) { + const url = `/api/workspace/v1/${workspaceId}/collab/${objectId}`; + const response = await axiosInstance?.get<{ + code: number; + data: { + doc_state: number[]; + object_id: string; + }; + message: string; + }>(url, { + params: { + collab_type: collabType, + }, + }); + + if (response?.data.code !== 0) { + return Promise.reject(response?.data); + } + + const docState = response?.data.data.doc_state; + + return { + data: new Uint8Array(docState), + }; +} + +export async function getPageCollab (workspaceId: string, viewId: string) { + const url = `/api/workspace/${workspaceId}/page-view/${viewId}`; + const response = await axiosInstance?.get<{ + code: number; + data: { + view: View; + data: { + encoded_collab: number[]; + row_data: Record; + owner?: User; + last_editor?: User; + } + }; + message: string; + }>(url); + + if (!response) { + return Promise.reject('No response'); + } + + if (response.data.code !== 0) { + return Promise.reject(response?.data); + } + + const { encoded_collab, row_data, owner, last_editor } = response.data.data.data; + + return { + data: new Uint8Array(encoded_collab), + rows: row_data, + owner, + lastEditor: last_editor, + }; +} + +export async function getPublishView (publishNamespace: string, publishName: string) { + const meta = await getPublishViewMeta(publishNamespace, publishName); + const blob = await getPublishViewBlob(publishNamespace, publishName); + + if (meta.view.layout === ViewLayout.Document) { + return { + data: blob, + meta, + }; + } + + try { + const decoder = new TextDecoder('utf-8'); + + const jsonStr = decoder.decode(blob); + + const res = JSON.parse(jsonStr) as { + database_collab: Uint8Array; + database_row_collabs: Record; + database_row_document_collabs: Record; + visible_database_view_ids: ViewId[]; + database_relations: Record; + }; + + return { + data: new Uint8Array(res.database_collab), + rows: res.database_row_collabs, + visibleViewIds: res.visible_database_view_ids, + relations: res.database_relations, + subDocuments: res.database_row_document_collabs, + meta, + }; + } catch (e) { + return Promise.reject(e); + } +} + +export async function getPublishInfoWithViewId (viewId: string) { + const url = `/api/workspace/published-info/${viewId}`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + namespace: string; + publish_name: string; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function getAppFavorites (workspaceId: string) { + const url = `/api/workspace/${workspaceId}/favorite`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + views: View[] + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.views; + } + + return Promise.reject(data); +} + +export async function getAppTrash (workspaceId: string) { + const url = `/api/workspace/${workspaceId}/trash`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + views: View[] + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.views; + } + + return Promise.reject(data); +} + +export async function getAppRecent (workspaceId: string) { + const url = `/api/workspace/${workspaceId}/recent`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + views: View[] + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.views; + } + + return Promise.reject(data); +} + +export async function getAppOutline (workspaceId: string) { + const url = `/api/workspace/${workspaceId}/folder?depth=10`; + + const response = await axiosInstance?.get<{ + code: number; + data?: View; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.children; + } + + return Promise.reject(data); +} + +export async function getView (workspaceId: string, viewId: string, depth: number = 1) { + const url = `/api/workspace/${workspaceId}/folder?depth=${depth}&root_view_id=${viewId}`; + const response = await axiosInstance?.get<{ + code: number; + data?: View; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function getPublishOutline (publishNamespace: string) { + const url = `/api/workspace/published-outline/${publishNamespace}`; + const response = await axiosInstance?.get<{ + code: number; + data?: View; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.children; + } + + return Promise.reject(data); +} + +export async function getPublishViewComments (viewId: string): Promise { + const url = `/api/workspace/published-info/${viewId}/comment`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + comments: { + comment_id: string; + user: { + uuid: string; + name: string; + avatar_url: string | null; + }; + content: string; + created_at: string; + last_updated_at: string; + reply_comment_id: string | null; + is_deleted: boolean; + can_be_deleted: boolean; + }[]; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + const { comments } = data.data; + + return comments.map((comment) => { + return { + commentId: comment.comment_id, + user: { + uuid: comment.user?.uuid || '', + name: comment.user?.name || '', + avatarUrl: comment.user?.avatar_url || null, + }, + content: comment.content, + createdAt: comment.created_at, + lastUpdatedAt: comment.last_updated_at, + replyCommentId: comment.reply_comment_id, + isDeleted: comment.is_deleted, + canDeleted: comment.can_be_deleted, + }; + }); + } + + return Promise.reject(data); +} + +export async function getReactions (viewId: string, commentId?: string): Promise> { + let url = `/api/workspace/published-info/${viewId}/reaction`; + + if (commentId) { + url += `?comment_id=${commentId}`; + } + + const response = await axiosInstance?.get<{ + code: number; + data?: { + reactions: { + reaction_type: string; + react_users: { + uuid: string; + name: string; + avatar_url: string | null; + }[]; + comment_id: string; + }[]; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + const { reactions } = data.data; + const reactionsMap: Record = {}; + + for (const reaction of reactions) { + if (!reactionsMap[reaction.comment_id]) { + reactionsMap[reaction.comment_id] = []; + } + + reactionsMap[reaction.comment_id].push({ + reactionType: reaction.reaction_type, + commentId: reaction.comment_id, + reactUsers: reaction.react_users.map((user) => ({ + uuid: user.uuid, + name: user.name, + avatarUrl: user.avatar_url, + })), + }); + } + + return reactionsMap; + } + + return Promise.reject(data); +} + +export async function createGlobalCommentOnPublishView (viewId: string, content: string, replyCommentId?: string) { + const url = `/api/workspace/published-info/${viewId}/comment`; + const response = await axiosInstance?.post<{ code: number; message: string }>(url, { + content, + reply_comment_id: replyCommentId, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function deleteGlobalCommentOnPublishView (viewId: string, commentId: string) { + const url = `/api/workspace/published-info/${viewId}/comment`; + const response = await axiosInstance?.delete<{ code: number; message: string }>(url, { + data: { + comment_id: commentId, + }, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function addReaction (viewId: string, commentId: string, reactionType: string) { + const url = `/api/workspace/published-info/${viewId}/reaction`; + const response = await axiosInstance?.post<{ code: number; message: string }>(url, { + comment_id: commentId, + reaction_type: reactionType, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function removeReaction (viewId: string, commentId: string, reactionType: string) { + const url = `/api/workspace/published-info/${viewId}/reaction`; + const response = await axiosInstance?.delete<{ code: number; message: string }>(url, { + data: { + comment_id: commentId, + reaction_type: reactionType, + }, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function getWorkspaces (): Promise { + const query = new URLSearchParams({ + include_member_count: 'true', + }); + + const url = `/api/workspace?${query.toString()}`; + const response = await axiosInstance?.get<{ + code: number; + data?: AFWorkspace[]; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.map(afWorkspace2Workspace); + } + + return Promise.reject(data); +} + +export interface WorkspaceFolder { + view_id: string; + icon: string | null; + name: string; + is_space: boolean; + is_private: boolean; + extra: { + is_space: boolean; + space_created_at: number; + space_icon: string; + space_icon_color: string; + space_permission: number; + }; + + children: WorkspaceFolder[]; +} + +function iterateFolder (folder: WorkspaceFolder): FolderView { + return { + id: folder.view_id, + name: folder.name, + icon: folder.icon, + isSpace: folder.is_space, + extra: folder.extra ? JSON.stringify(folder.extra) : null, + isPrivate: folder.is_private, + children: folder.children.map((child: WorkspaceFolder) => { + return iterateFolder(child); + }), + }; +} + +export async function getWorkspaceFolder (workspaceId: string): Promise { + const url = `/api/workspace/${workspaceId}/folder`; + const response = await axiosInstance?.get<{ + code: number; + data?: WorkspaceFolder; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return iterateFolder(data.data); + } + + return Promise.reject(data); +} + +export interface DuplicatePublishViewPayload { + published_collab_type: 0 | 1 | 2 | 3 | 4 | 5 | 6; + published_view_id: string; + dest_view_id: string; +} + +export async function duplicatePublishView (workspaceId: string, payload: DuplicatePublishViewPayload) { + const url = `/api/workspace/${workspaceId}/published-duplicate`; + + const res = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, payload); + + if (res?.data.code === 0) { + return; + } + + return Promise.reject(res?.data.message); +} + +export async function createTemplate (template: UploadTemplatePayload) { + const url = '/api/template-center/template'; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, template); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function updateTemplate (viewId: string, template: UploadTemplatePayload) { + const url = `/api/template-center/template/${viewId}`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url, template); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function getTemplates ({ + categoryId, + nameContains, +}: { + categoryId?: string; + nameContains?: string; +}) { + const url = `/api/template-center/template`; + + const response = await axiosInstance?.get<{ + code: number; + data?: { + templates: TemplateSummary[]; + }; + message: string; + }>(url, { + params: { + category_id: categoryId, + name_contains: nameContains, + }, + }); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.templates; + } + + return Promise.reject(data); +} + +export async function getTemplateById (viewId: string) { + const url = `/api/template-center/template/${viewId}`; + const response = await axiosInstance?.get<{ + code: number; + data?: Template; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function deleteTemplate (viewId: string) { + const url = `/api/template-center/template/${viewId}`; + const response = await axiosInstance?.delete<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function getTemplateCategories () { + const url = '/api/template-center/category'; + const response = await axiosInstance?.get<{ + code: number; + data?: { + categories: TemplateCategory[] + + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.categories; + } + + return Promise.reject(data); +} + +export async function addTemplateCategory (category: TemplateCategoryFormValues) { + const url = '/api/template-center/category'; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, category); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function updateTemplateCategory (id: string, category: TemplateCategoryFormValues) { + const url = `/api/template-center/category/${id}`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url, category); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function deleteTemplateCategory (categoryId: string) { + const url = `/api/template-center/category/${categoryId}`; + const response = await axiosInstance?.delete<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function getTemplateCreators () { + const url = '/api/template-center/creator'; + const response = await axiosInstance?.get<{ + code: number; + data?: { + creators: TemplateCreator[]; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data.creators; + } + + return Promise.reject(data); +} + +export async function createTemplateCreator (creator: TemplateCreatorFormValues) { + const url = '/api/template-center/creator'; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, creator); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function updateTemplateCreator (creatorId: string, creator: TemplateCreatorFormValues) { + const url = `/api/template-center/creator/${creatorId}`; + const response = await axiosInstance?.put<{ + code: number; + message: string; + }>(url, creator); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function deleteTemplateCreator (creatorId: string) { + const url = `/api/template-center/creator/${creatorId}`; + const response = await axiosInstance?.delete<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function uploadFileToCDN (file: File) { + const url = '/api/template-center/avatar'; + const formData = new FormData(); + + formData.append('avatar', file); + + const response = await axiosInstance?.request<{ + code: number; + data?: { + file_id: string; + }; + message: string; + }>({ + method: 'PUT', + url, + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return axiosInstance?.defaults.baseURL + '/api/template-center/avatar/' + data.data.file_id; + } + + return Promise.reject(data); +} + +export async function getInvitation (invitationId: string) { + const url = `/api/workspace/invite/${invitationId}`; + const response = await axiosInstance?.get<{ + code: number; + data?: Invitation; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function acceptInvitation (invitationId: string) { + const url = `/api/workspace/accept-invite/${invitationId}`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); +} + +export async function getRequestAccessInfo (requestId: string): Promise { + const url = `/api/access-request/${requestId}`; + const response = await axiosInstance?.get<{ + code: number; + data?: { + request_id: string; + workspace: AFWorkspace; + requester: AFWebUser & { + email: string; + }; + view: View; + status: RequestAccessInfoStatus; + }; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + const workspace = data.data.workspace; + + return { + ...data.data, + workspace: afWorkspace2Workspace(workspace), + }; + } + + return Promise.reject(data); +} + +export async function approveRequestAccess (requestId: string) { + const url = `/api/access-request/${requestId}/approve`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, { + is_approved: true, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function sendRequestAccess (workspaceId: string, viewId: string) { + const url = `/api/access-request`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, { + workspace_id: workspaceId, + view_id: viewId, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function getSubscriptionLink (workspaceId: string, plan: SubscriptionPlan, interval: SubscriptionInterval) { + const url = `/billing/api/v1/subscription-link`; + const response = await axiosInstance?.get<{ + code: number; + data?: string; + message: string; + }>(url, { + params: { + workspace_subscription_plan: plan, + recurring_interval: interval, + workspace_id: workspaceId, + success_url: window.location.href, + }, + }); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function getSubscriptions () { + const url = `/billing/api/v1/subscriptions`; + const response = await axiosInstance?.get<{ + code: number; + data: Subscriptions; + message: string; + }>(url); + + if (response?.data.code === 0) { + return response?.data.data; + } + + return Promise.reject(response?.data); + +} + +export async function getActiveSubscription (workspaceId: string) { + const url = `/billing/api/v1/active-subscription/${workspaceId}`; + + const response = await axiosInstance?.get<{ + code: number; + data: SubscriptionPlan[]; + message: string; + }>(url); + + if (response?.data.code === 0) { + return response?.data.data; + } + + return Promise.reject(response?.data); +} + +export async function createImportTask (file: File) { + const url = `/api/import/create`; + const fileName = file.name.split('.').slice(0, -1).join('.') || crypto.randomUUID(); + + const res = await axiosInstance?.post<{ + code: number; + data: { + task_id: string; + presigned_url: string; + }; + message: string; + }>(url, { + workspace_name: fileName, + content_length: file.size, + }); + + if (res?.data.code === 0) { + return { + taskId: res?.data.data.task_id, + presignedUrl: res?.data.data.presigned_url, + }; + } + + return Promise.reject(res?.data); +} + +export async function uploadImportFile (presignedUrl: string, file: File, onProgress: (progress: number) => void) { + const response = await axios.put(presignedUrl, file, { + onUploadProgress: (progressEvent) => { + const { progress = 0 } = progressEvent; + + console.log(`Upload progress: ${progress * 100}%`); + onProgress(progress); + }, + headers: { + 'Content-Type': 'application/zip', + }, + }); + + if (response.status === 200) { + return; + } + + return Promise.reject({ + code: -1, + message: `Upload file failed. ${response.statusText}`, + }); +} + diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/index.ts new file mode 100644 index 0000000000000..e170c830a47be --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/index.ts @@ -0,0 +1 @@ +export * as APIService from './http_api'; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts new file mode 100644 index 0000000000000..aa197a7516105 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/utils.ts @@ -0,0 +1,17 @@ +export function blobToBytes (blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onloadend = () => { + if (!(reader.result instanceof ArrayBuffer)) { + reject(new Error('Failed to convert blob to bytes')); + return; + } + + resolve(new Uint8Array(reader.result)); + }; + + reader.onerror = reject; + reader.readAsArrayBuffer(blob); + }); +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/index.ts new file mode 100644 index 0000000000000..7f2a91aa14826 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/index.ts @@ -0,0 +1,482 @@ +import { GlobalComment, Reaction } from '@/application/comment.type'; +import { openCollabDB } from '@/application/db'; +import { + createRowDoc, deleteRowDoc, + deleteView, + getPageDoc, + getPublishView, + getPublishViewMeta, + getUser, hasCollabCache, + hasViewMetaCache, +} from '@/application/services/js-services/cache'; +import { StrategyType } from '@/application/services/js-services/cache/types'; +import { + fetchPageCollab, + fetchPublishView, + fetchPublishViewMeta, + fetchViewInfo, +} from '@/application/services/js-services/fetch'; +import { APIService } from '@/application/services/js-services/http'; +import { SyncManager } from '@/application/services/js-services/sync'; + +import { AFService, AFServiceConfig } from '@/application/services/services.type'; +import { emit, EventType } from '@/application/session'; +import { afterAuth, AUTH_CALLBACK_URL, withSignIn } from '@/application/session/sign_in'; +import { getTokenParsed } from '@/application/session/token'; +import { + TemplateCategoryFormValues, + TemplateCreatorFormValues, + UploadTemplatePayload, +} from '@/application/template.type'; +import { + DatabaseRelations, + DuplicatePublishView, + SubscriptionInterval, SubscriptionPlan, + Types, + YjsEditorKey, +} from '@/application/types'; +import { applyYDoc } from '@/application/ydoc/apply'; +import { nanoid } from 'nanoid'; +import * as Y from 'yjs'; + +export class AFClientService implements AFService { + private deviceId: string = nanoid(8); + + private clientId: string = 'web'; + + private viewLoaded: Set = new Set(); + + private publishViewLoaded: Set = new Set(); + + private publishViewInfo: Map< + string, + { + namespace: string; + publishName: string; + } + > = new Map(); + + constructor (config: AFServiceConfig) { + APIService.initAPIService(config.cloudConfig); + } + + getClientId () { + return this.clientId; + } + + async getPublishViewMeta (namespace: string, publishName: string) { + const name = `${namespace}_${publishName}`; + + const isLoaded = this.publishViewLoaded.has(name); + const viewMeta = await getPublishViewMeta( + () => { + return fetchPublishViewMeta(namespace, publishName); + }, + { + namespace, + publishName, + }, + isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK, + ); + + if (!viewMeta) { + return Promise.reject(new Error('View has not been published yet')); + } + + return viewMeta; + } + + async getPublishView (namespace: string, publishName: string) { + const name = `${namespace}_${publishName}`; + + const isLoaded = this.publishViewLoaded.has(name); + + const { doc } = await getPublishView( + async () => { + try { + return await fetchPublishView(namespace, publishName); + } catch (e) { + console.error(e); + void (async () => { + if (await hasViewMetaCache(name)) { + this.publishViewLoaded.delete(name); + void deleteView(name); + } + })(); + + return Promise.reject(e); + } + }, + { + namespace, + publishName, + }, + isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK, + ); + + if (!isLoaded) { + this.publishViewLoaded.add(name); + } + + return doc; + } + + async getPublishRowDocument (viewId: string) { + const doc = await openCollabDB(viewId); + + if (hasCollabCache(doc)) { + return doc; + } + + return Promise.reject(new Error('Document not found')); + + } + + async createRowDoc (rowKey: string) { + return createRowDoc(rowKey); + } + + deleteRowDoc (rowKey: string) { + return deleteRowDoc(rowKey); + } + + async getAppDatabaseViewRelations (workspaceId: string, databaseStorageId: string) { + + const res = await APIService.getCollab(workspaceId, databaseStorageId, Types.WorkspaceDatabase); + const doc = new Y.Doc(); + + applyYDoc(doc, res.data); + + const { databases } = doc.getMap(YjsEditorKey.data_section).toJSON(); + const result: DatabaseRelations = {}; + + databases.forEach((database: { + database_id: string; + views: string[] + }) => { + result[database.database_id] = database.views[0]; + }); + return result; + } + + async getPublishInfo (viewId: string) { + if (this.publishViewInfo.has(viewId)) { + return this.publishViewInfo.get(viewId) as { + namespace: string; + publishName: string; + }; + } + + const info = await fetchViewInfo(viewId); + + const namespace = info.namespace; + + if (!namespace) { + return Promise.reject(new Error('View not found')); + } + + const data = { + namespace, + publishName: info.publish_name, + }; + + this.publishViewInfo.set(viewId, data); + + return data; + } + + async getPublishOutline (namespace: string) { + return APIService.getPublishOutline(namespace); + } + + async getAppOutline (workspaceId: string) { + return APIService.getAppOutline(workspaceId); + } + + async getAppView (workspaceId: string, viewId: string) { + return APIService.getView(workspaceId, viewId); + } + + async getAppFavorites (workspaceId: string) { + return APIService.getAppFavorites(workspaceId); + } + + async getAppRecent (workspaceId: string) { + return APIService.getAppRecent(workspaceId); + } + + async getAppTrash (workspaceId: string) { + return APIService.getAppTrash(workspaceId); + } + + async loginAuth (url: string) { + try { + await APIService.signInWithUrl(url); + emit(EventType.SESSION_VALID); + afterAuth(); + return; + } catch (e) { + emit(EventType.SESSION_INVALID); + return Promise.reject(e); + } + } + + @withSignIn() + async signInMagicLink ({ email }: { email: string; redirectTo: string }) { + return await APIService.signInWithMagicLink(email, AUTH_CALLBACK_URL); + } + + @withSignIn() + async signInGoogle (_: { redirectTo: string }) { + return APIService.signInGoogle(AUTH_CALLBACK_URL); + } + + @withSignIn() + async signInApple (_: { redirectTo: string }) { + return APIService.signInApple(AUTH_CALLBACK_URL); + } + + @withSignIn() + async signInGithub (_: { redirectTo: string }) { + return APIService.signInGithub(AUTH_CALLBACK_URL); + } + + @withSignIn() + async signInDiscord (_: { redirectTo: string }) { + return APIService.signInDiscord(AUTH_CALLBACK_URL); + } + + async getWorkspaces () { + const data = APIService.getWorkspaces(); + + return data; + } + + async getWorkspaceFolder (workspaceId: string) { + const data = await APIService.getWorkspaceFolder(workspaceId); + + return data; + } + + async getCurrentUser () { + const token = getTokenParsed(); + const userId = token?.user?.id; + + const user = await getUser( + () => APIService.getCurrentUser(), + userId, + StrategyType.CACHE_AND_NETWORK, + ); + + if (!user) { + return Promise.reject(new Error('User not found')); + } + + return user; + } + + async openWorkspace (workspaceId: string) { + return APIService.openWorkspace(workspaceId); + } + + async getUserWorkspaceInfo () { + const workspaceInfo = await APIService.getUserWorkspaceInfo(); + + if (!workspaceInfo) { + return Promise.reject(new Error('Workspace info not found')); + } + + return { + userId: workspaceInfo.user_id, + selectedWorkspace: workspaceInfo.selected_workspace, + workspaces: workspaceInfo.workspaces, + }; + } + + async duplicatePublishView (params: DuplicatePublishView) { + return APIService.duplicatePublishView(params.workspaceId, { + dest_view_id: params.spaceViewId, + published_view_id: params.viewId, + published_collab_type: params.collabType, + }); + } + + createCommentOnPublishView (viewId: string, content: string, replyCommentId: string | undefined): Promise { + return APIService.createGlobalCommentOnPublishView(viewId, content, replyCommentId); + } + + deleteCommentOnPublishView (viewId: string, commentId: string): Promise { + return APIService.deleteGlobalCommentOnPublishView(viewId, commentId); + } + + getPublishViewGlobalComments (viewId: string): Promise { + return APIService.getPublishViewComments(viewId); + } + + getPublishViewReactions (viewId: string, commentId?: string): Promise> { + return APIService.getReactions(viewId, commentId); + } + + addPublishViewReaction (viewId: string, commentId: string, reactionType: string): Promise { + return APIService.addReaction(viewId, commentId, reactionType); + } + + removePublishViewReaction (viewId: string, commentId: string, reactionType: string): Promise { + return APIService.removeReaction(viewId, commentId, reactionType); + } + + async getTemplateCategories () { + return APIService.getTemplateCategories(); + } + + async getTemplateCreators () { + return APIService.getTemplateCreators(); + } + + async createTemplate (template: UploadTemplatePayload) { + return APIService.createTemplate(template); + } + + async updateTemplate (id: string, template: UploadTemplatePayload) { + return APIService.updateTemplate(id, template); + } + + async getTemplateById (id: string) { + return APIService.getTemplateById(id); + } + + async getTemplates (params: { + categoryId?: string; + nameContains?: string; + }) { + return APIService.getTemplates(params); + } + + async deleteTemplate (id: string) { + return APIService.deleteTemplate(id); + } + + async addTemplateCategory (category: TemplateCategoryFormValues) { + return APIService.addTemplateCategory(category); + } + + async updateTemplateCategory (categoryId: string, category: TemplateCategoryFormValues) { + return APIService.updateTemplateCategory(categoryId, category); + } + + async deleteTemplateCategory (categoryId: string) { + return APIService.deleteTemplateCategory(categoryId); + } + + async updateTemplateCreator (creatorId: string, creator: TemplateCreatorFormValues) { + return APIService.updateTemplateCreator(creatorId, creator); + } + + async createTemplateCreator (creator: TemplateCreatorFormValues) { + return APIService.createTemplateCreator(creator); + } + + async deleteTemplateCreator (creatorId: string) { + return APIService.deleteTemplateCreator(creatorId); + } + + async uploadFileToCDN (file: File) { + return APIService.uploadFileToCDN(file); + } + + async getPageDoc (workspaceId: string, viewId: string, errorCallback?: (error: { + code: number; + }) => void) { + + const token = getTokenParsed(); + const userId = token?.user.id; + + if (!userId) { + throw new Error('User not found'); + } + + const name = `${userId}_${workspaceId}_${viewId}`; + + const isLoaded = this.viewLoaded.has(name); + + const { doc } = await getPageDoc( + async () => { + try { + return await fetchPageCollab(workspaceId, viewId); + // eslint-disable-next-line + } catch (e: any) { + console.error(e); + + errorCallback?.(e); + void (async () => { + this.viewLoaded.delete(name); + void deleteView(name); + })(); + + return Promise.reject(e); + } + }, + name, + isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK, + ); + + if (!isLoaded) { + this.viewLoaded.add(name); + } + + return doc; + } + + async getInvitation (invitationId: string) { + return APIService.getInvitation(invitationId); + } + + async acceptInvitation (invitationId: string) { + return APIService.acceptInvitation(invitationId); + } + + approveRequestAccess (requestId: string): Promise { + return APIService.approveRequestAccess(requestId); + } + + getRequestAccessInfo (requestId: string) { + return APIService.getRequestAccessInfo(requestId); + } + + sendRequestAccess (workspaceId: string, viewId: string): Promise { + return APIService.sendRequestAccess(workspaceId, viewId); + } + + getSubscriptionLink (workspaceId: string, plan: SubscriptionPlan, interval: SubscriptionInterval) { + return APIService.getSubscriptionLink(workspaceId, plan, interval); + } + + getSubscriptions () { + return APIService.getSubscriptions(); + } + + getActiveSubscription (workspaceId: string) { + return APIService.getActiveSubscription(workspaceId); + } + + registerDocUpdate (doc: Y.Doc, context: { + workspaceId: string, objectId: string, collabType: Types + }) { + const token = getTokenParsed(); + const userId = token?.user.id; + + if (!userId) { + throw new Error('User not found'); + } + + const sync = new SyncManager(doc, { userId, ...context }); + + sync.initialize(); + } + + async importFile (file: File, onProgress: (progress: number) => void) { + const task = await APIService.createImportTask(file); + + await APIService.uploadImportFile(task.presignedUrl, file, onProgress); + } +} diff --git a/frontend/appflowy_web_app/src/application/services/js-services/sync.ts b/frontend/appflowy_web_app/src/application/services/js-services/sync.ts new file mode 100644 index 0000000000000..231d560581c98 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/js-services/sync.ts @@ -0,0 +1,137 @@ +import { updateCollab } from '@/application/services/js-services/http/http_api'; +import { CollabOrigin, Types } from '@/application/types'; +import { debounce } from 'lodash-es'; +import * as Y from 'yjs'; + +const VERSION_VECTOR_KEY = 'ydoc_version_vector'; +const UNSYNCED_FLAG_KEY = 'ydoc_unsynced_changes'; +const LAST_SYNCED_AT_KEY = 'ydoc_last_synced_at'; + +export class SyncManager { + + private versionVector: number; + + private lastSyncedAt: string; + + private hasUnsyncedChanges: boolean = false; + + private isSending = false; + + constructor (private doc: Y.Doc, private context: { + userId: string, workspaceId: string, objectId: string, collabType: Types + }) { + this.versionVector = this.loadVersionVector(); + this.hasUnsyncedChanges = this.loadUnsyncedFlag(); + this.lastSyncedAt = this.loadLastSyncedAt(); + this.setupListener(); + } + + private setupListener () { + this.doc.on('update', (_update: Uint8Array, origin: CollabOrigin) => { + if (origin === CollabOrigin.Remote) return; + + this.debouncedSendUpdate(); + }); + } + + private getStorageKey (baseKey: string): string { + return `${this.context.userId}_${baseKey}_${this.context.workspaceId}_${this.context.objectId}`; + } + + private loadVersionVector (): number { + const storedVector = localStorage.getItem(this.getStorageKey(VERSION_VECTOR_KEY)); + + return storedVector ? parseInt(storedVector, 10) : 0; + } + + private saveVersionVector () { + localStorage.setItem(this.getStorageKey(VERSION_VECTOR_KEY), this.versionVector.toString()); + } + + private loadUnsyncedFlag (): boolean { + return localStorage.getItem(this.getStorageKey(UNSYNCED_FLAG_KEY)) === 'true'; + } + + private saveUnsyncedFlag () { + localStorage.setItem(this.getStorageKey(UNSYNCED_FLAG_KEY), this.hasUnsyncedChanges.toString()); + } + + private loadLastSyncedAt (): string { + return localStorage.getItem(this.getStorageKey(LAST_SYNCED_AT_KEY)) || ''; + } + + private saveLastSyncedAt () { + localStorage.setItem(this.getStorageKey(LAST_SYNCED_AT_KEY), this.lastSyncedAt); + } + + private debouncedSendUpdate = debounce(() => { + this.hasUnsyncedChanges = true; + this.saveUnsyncedFlag(); + + void this.sendUpdate(); + }, 1000); // 1 second debounce + + private async sendUpdate () { + if (this.isSending) return; + this.isSending = true; + + try { + // Increment version vector before sending + this.versionVector++; + this.saveVersionVector(); + + const update = Y.encodeStateAsUpdate(this.doc); + const context = { version_vector: this.versionVector }; + + const response = await updateCollab(this.context.workspaceId, this.context.objectId, this.context.collabType, update, context); + + if (response) { + console.log(`Update sent successfully. Server version: ${response.version_vector}`); + + // Update last synced time + this.lastSyncedAt = String(Date.now()); + this.saveLastSyncedAt(); + + if (response.version_vector === this.versionVector) { + // Our update was the latest + this.hasUnsyncedChanges = false; + this.saveUnsyncedFlag(); + console.log('Local changes fully synced'); + } else { + // There are still unsynced changes (possibly from other clients) + this.hasUnsyncedChanges = true; + this.saveUnsyncedFlag(); + console.log('There are still unsynced changes'); + } + } else { + return Promise.reject(response); + } + } catch (error) { + console.error('Failed to send update:', error); + // Keep the unsynced flag as true + this.hasUnsyncedChanges = true; + this.saveUnsyncedFlag(); + } finally { + this.isSending = false; + } + } + + public initialize () { + if (this.hasUnsyncedChanges) { + // Send an update if there are unsynced changes + this.debouncedSendUpdate(); + } + } + + public getUnsyncedStatus (): boolean { + return this.hasUnsyncedChanges; + } + + public getLastSyncedAt (): string { + return this.lastSyncedAt; + } + + public getCurrentVersionVector (): number { + return this.versionVector; + } +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/services/services.type.ts b/frontend/appflowy_web_app/src/application/services/services.type.ts new file mode 100644 index 0000000000000..b4933bd87ee37 --- /dev/null +++ b/frontend/appflowy_web_app/src/application/services/services.type.ts @@ -0,0 +1,109 @@ +import { + Invitation, + DuplicatePublishView, + FolderView, + User, + UserWorkspaceInfo, + View, + Workspace, + YDoc, DatabaseRelations, GetRequestAccessInfoResponse, Subscriptions, SubscriptionPlan, SubscriptionInterval, Types, +} from '@/application/types'; +import { GlobalComment, Reaction } from '@/application/comment.type'; +import { ViewMeta } from '@/application/db/tables/view_metas'; +import { + Template, + TemplateCategory, + TemplateCategoryFormValues, + TemplateCreator, TemplateCreatorFormValues, TemplateSummary, + UploadTemplatePayload, +} from '@/application/template.type'; + +export type AFService = PublishService & AppService & TemplateService & { + getClientId: () => string; +}; + +export interface AFServiceConfig { + cloudConfig: AFCloudConfig; +} + +export interface AFCloudConfig { + baseURL: string; + gotrueURL: string; + wsURL: string; +} + +export interface AppService { + openWorkspace: (workspaceId: string) => Promise; + getPageDoc: (workspaceId: string, viewId: string, errorCallback?: (error: { + code: number; + }) => void) => Promise; + createRowDoc: (rowKey: string) => Promise; + deleteRowDoc: (rowKey: string) => void; + getAppDatabaseViewRelations: (workspaceId: string, databaseStorageId: string) => Promise; + getAppOutline: (workspaceId: string) => Promise; + getAppView: (workspaceId: string, viewId: string) => Promise; + getAppFavorites: (workspaceId: string) => Promise; + getAppRecent: (workspaceId: string) => Promise; + getAppTrash: (workspaceId: string) => Promise; + loginAuth: (url: string) => Promise; + signInMagicLink: (params: { email: string; redirectTo: string }) => Promise; + signInGoogle: (params: { redirectTo: string }) => Promise; + signInGithub: (params: { redirectTo: string }) => Promise; + signInDiscord: (params: { redirectTo: string }) => Promise; + signInApple: (params: { redirectTo: string }) => Promise; + getWorkspaces: () => Promise; + getWorkspaceFolder: (workspaceId: string) => Promise; + getCurrentUser: () => Promise; + getUserWorkspaceInfo: () => Promise; + uploadFileToCDN: (file: File) => Promise; + getInvitation: (invitationId: string) => Promise; + acceptInvitation: (invitationId: string) => Promise; + getRequestAccessInfo: (requestId: string) => Promise; + approveRequestAccess: (requestId: string) => Promise; + sendRequestAccess: (workspaceId: string, viewId: string) => Promise; + getSubscriptionLink: (workspaceId: string, plan: SubscriptionPlan, interval: SubscriptionInterval) => Promise; + getSubscriptions: () => Promise; + getActiveSubscription: (workspaceId: string) => Promise; + registerDocUpdate: (doc: YDoc, context: { + workspaceId: string, objectId: string, collabType: Types + }) => void; + importFile: (file: File, onProgress: (progress: number) => void) => Promise; +} + +export interface TemplateService { + getTemplateCategories: () => Promise; + addTemplateCategory: (category: TemplateCategoryFormValues) => Promise; + deleteTemplateCategory: (categoryId: string) => Promise; + getTemplateCreators: () => Promise; + createTemplateCreator: (creator: TemplateCreatorFormValues) => Promise; + deleteTemplateCreator: (creatorId: string) => Promise; + getTemplateById: (id: string) => Promise

- Discord • - Twitter -